这是我参与「第三届青训营 -后端场」笔记创作活动的的第5篇笔记。
信号量是什么?都有什么操作?信号量的概念是荷兰计算机科学家 Edsger Dijkstra 在 1963 年左右提出来的,广泛应用在不同的操作系统中。在系统中,会给每一个进程一个信号量,代表每个进程目前的状态。未得到控制权的进程,会在特定的地方被迫停下来,等待可以继续进行的信号到来。最简单的信号量就是一个变量加一些并发控制的能力,这个变量是 0 到 n 之间的一个数值。当 goroutine 完成对此信号量的等待(wait)时,该计数值就减 1,当 goroutine 完成对此信号量的释放(release)时,该计数值就加 1。当计数值为 0 的时候,goroutine 调用 wait 等待该信号量是不会成功的,除非计数器又大于 0,等待的 goroutine 才有可能成功返回。更复杂的信号量类型,就是使用抽象数据类型代替变量,用来代表复杂的资源类型。实际上,大部分的信号量都使用一个整型变量来表示一组资源,并没有实现太复杂的抽象数据类型,所以你只要知道有更复杂的信号量就行了,我们这节课主要是学习最简单的信号量。说到这儿呢,我想借助一个生活中的例子,来帮你进一步理解信号量。举个例子,图书馆新购买了 10 本《Go 并发编程的独家秘籍》,有 1 万个学生都想读这本书,“僧多粥少”。所以,图书馆管理员先会让这 1 万个同学进行登记,按照登记的顺序,借阅此书。如果书全部被借走,那么,其他想看此书的同学就需要等待,如果有人还书了,图书馆管理员就会通知下一位同学来借阅这本书。这里的资源是《Go 并发编程的独家秘籍》这十本书,想读此书的同学就是 goroutine,图书管理员就是信号量。怎么样,现在是不是很好理解了?那么,接下来,我们来学习下信号量的 P/V 操作。P/V 操作Dijkstra 在他的论文中为信号量定义了两个操作 P 和 V。P 操作(descrease、wait、acquire)是减少信号量的计数值,而 V 操作(increase、signal、release)是增加信号量的计数值。使用伪代码表示如下(中括号代表原子操作` function V(semaphore S, integer I): [S ← S + I]
function P(semaphore S, integer I): repeat: [if S ≥ I: S ← S − I break]`
可以看到,初始化信号量 S 有一个指定数量(n)的资源,它就像是一个有 n 个资源的池子。P 操作相当于请求资源,如果资源可用,就立即返回;如果没有资源或者不够,那么,它可以不断尝试或者阻塞等待。V 操作会释放自己持有的资源,把资源返还给信号量。信号量的值除了初始化的操作以外,只能由 P/V 操作改变。现在,我们来总结下信号量的实现。初始化信号量:设定初始的资源的数量。P 操作:将信号量的计数值减去 1,如果新值已经为负,那么调用者会被阻塞并加入到等待队列中。否则,调用者会继续执行,并且获得一个资源。V 操作:将信号量的计数值加 1,如果先前的计数值为负,就说明有等待的 P 操作的调用者。它会从等待队列中取出一个等待的调用者,唤醒它,让它继续执行。讲到这里,我想再稍微说一个题外话,我们在第 2 讲提到过饥饿,就是说在高并发的极端场景下,会有些 goroutine 始终抢不到锁。为了处理饥饿的问题,你可以在等待队列中做一些“文章”。比如实现一个优先级的队列,或者先入先出的队列,等等,保持公平性,并且照顾到优先级。在正式进入实现信号量的具体实现原理之前,我想先讲一个知识点,就是信号量和互斥锁的区别与联系,这有助于我们掌握接下来的内容。其实,信号量可以分为计数信号量(counting semaphore)和二进位信号量(binary semaphore)。刚刚所说的图书馆借书的例子就是一个计数信号量,它的计数可以是任意一个整数。在特殊的情况下,如果计数值只能是 0 或者 1,那么,这个信号量就是二进位信号量,提供了互斥的功能(要么是 0,要么是 1),所以,有时候互斥锁也会使用二进位信号量来实现。我们一般用信号量保护一组资源,比如数据库连接池、一组客户端的连接、几个打印机资源,等等。如果信号量蜕变成二进位信号量,那么,它的 P/V 就和互斥锁的 Lock/Unlock 一样了。有人会很细致地区分二进位信号量和互斥锁。比如说,有人提出,在 Windows 系统中,互斥锁只能由持有锁的线程释放锁,而二进位信号量则没有这个限制(Stack Overflow上也有相关的讨论)。