看 go-patterns/semaphore.md at master · tmrts/go-patterns (github.com) 时产生了疑问,信号量为啥长得和互斥锁没啥区别呢。于是就谷歌了一圈,重温下一些关于并发的知识,对比信号量 semaphore 和互斥锁 mutex 。
互斥锁 mutex
以 pthread 自带的互斥锁为例,提供了三种不同类型的互斥锁:
- PTHREAD_MUTEX_NORMAL ,普通的互斥锁,不支持死锁检测(does not detect deadlock),不支持递归加锁(relock without first unlocking it 会导致死锁),不检测解锁线程,解锁一个未加锁的互斥锁是未定义行为(undefined behavior)。
- PTHREAD_MUTEX_ERRORCHECK,带错误检查的互斥锁,不支持递归加锁(会返回错误),解锁其他线程的互斥锁会返回错误,解锁未加锁的互斥锁会返回错误。
- PTHREAD_MUTEX_RECURSIVE,递归加锁(relock with out unlocking it)会成功,解锁时需要调用解锁的次数和加锁时调用加锁的次数相同。解锁其他线程的互斥锁会返回错误。解锁未加锁的互斥锁会返回错误。
- PTHREAD_MUTEX_DEFAULT,默认互斥锁类型,对这一类型的互斥锁递归加锁时行为是未定义的,解锁未加锁的互斥锁行为是未定义的,解锁其他线程的互斥锁行为是未定义的。这一类型的互斥锁通常映射为另外几种互斥锁之一。
可以比较清楚地看出,互斥锁有三个基本特征:
- 是否可重复加锁
- 是否可解锁未加锁的互斥锁
- 是否可解锁被其他人加锁的互斥锁
最严格的 PTHREAD_MUTEX_ERRORCHECK 类型互斥锁,对此定义是 NO、NO、NO 。
互斥锁的基本使用方式和使用场景有点像厕所的坑位:
- 抢坑位,锁门
- 你懂的
- 解锁,出门
其中有隐含的信息包括:
- 坑位是提前选择好的,你只能抢一个坑位,不能抢多个坑位。
- 坑位在使用期间是独占的,你不能和别人分享一个坑位。
- 只有你自己能解锁坑位,谁也不想办事儿的时候有人闯进来吧?
而递归加锁这一特殊场景,我寻思吧,有点难拿坑位比喻。反正也不重要,就别管了。
信号量 semaphore
信号量本质上是一个整型值,不细分什么类型了。还是用 pthread 举例吧,依据 POSIX 标准。
对信号量的操作可以先简单分5种。
sem_init(sem,pshared,value)
,初始化一个信号量,可以指定要不要在fork()
创建的进程间共享,还可以指定信号量初始值。sem_wait(sem)
,等待信号量,信号量等于0时阻塞,其他线程通过sem_post
唤醒。sem_post(sem)
,发送信号量,唤醒阻塞在sem_wait
的线程。sem_getvalue(sem,valp)
,获取信号量当前值。sem_destroy(sem)
,销毁信号量。
信号量的主要特征就是它的值:
- 当值等于0时,
sem_wait
会阻塞。 - 当值大于0时,
sem_wait
返回并使值-1。
可以注意到,信号量的确可以做到互斥锁能做到的事情:设定好初始值1,然后sem_wait
等同于加锁,sem_post
等同于解锁,的确模拟出了互斥锁的功能。
不过信号量去模拟互斥锁会有一些问题。比如说无法实现递归加锁(信号量值等于0时,sem_wait
会阻塞),无法检测解锁线程是不是加锁线程(除非你自己再封装一次,把信号量和线程ID绑定),解锁未加锁会导致信号量值大于1,进而造成sem_wait
会允许多个线程并行执行(还是一样,你得自己封装,在sem_post
前检查当前信号量的值)。
好,模拟互斥锁的话题到此为止。回到屎尿屁的比喻上。互斥锁可以比作公厕收费的老大爷。
- 老规矩,不排队,大家从老大爷手里抢坑位。
- 坑位满员的时候老大爷谁也不让进。
- 每出来一个人,老大爷就放进去一个人。
其中隐含的信息包括:
- 当然,可用的坑位或者说资源依然是有限的,数量不确定。
- 你只能独占一部分资源,而且每个人独占的资源都一样多。不然老大爷看到有一个坑位放你进去了,但你想要用两个坑位,那你就只能继续等着,或者和别人分享坑位了。
信号量最好用的场景还是 生产者-消费者 模型的队列,来统计队列中元素数量。消费者可以用一个简单的 sem_timedwait
调用实现等待新元素加入队列,用互斥锁来确保队列操作是线程安全的。
可见管公厕的老大爷也是非常有生活智慧哈,充分利用了年轻时的编程经验来提高晚年生活质量。
结论
互斥锁和信号量都能处理数据竞争,但各有侧重。
典型的数据竞争场景当然是互斥锁好用,但信号量也不是完全不行。
信号量的典型场景也一样,互斥锁即便能行也会显得别扭。