信号量、PV原语及其应用
锁和PV原语
主要有两种基础的锁:
- 互斥锁(Mutex):用于保证只有一个线程能够进入临界区;
- 信号量(Semaphore):表示某个资源的数量,小于0时表示没有资源,此时任何线程无法直接通过;
从语义上来看,mutex
和semaphore
代表着不同的语义;但是从实现上来看,mutex
其实就是初始值为1的semaphore
。所以我们只需要讨论semaphore
的实现和操作方式即可。
对semaphore
的操作主要通过PV原语来进行。原语的意思是这个操作是原子的,要么没有开始执行,要么就已经完成了,不可能被打断,其它线程不会观察到原语操作的中间状态。
其主要的实现方式有3种思路:
- 硬件原子指令,由硬件直接提供相关的指令来完成原语操作,由硬件保证其原子性;
- 屏蔽中断,通过保证其它可能产生竞争的线程不会得到执行,并且当前线程不会主动让出CPU,来保证操作的原子性;
- 循环检查标志位,通过标志位来表示是否有线程进入临界区,当标志位不符合进入条件时,进入循环来等待标志位;
前两种依赖于硬件的功能,最后一种是纯软件实现。
第1种依赖于硬件指令,但是一般硬件并不会提供复杂的操作,因为进程/线程的调度总归是操作系统进行的,硬件难以提供合适的复杂操作。
第2种一般由操作系统提供(因为需要屏蔽中断,只有内核态有权限屏蔽中断),用户态通过系统调用来实现原语操作,涉及用户态和内核态的切换,开销较大。
第3种是纯软件实现,并且在用户态就可以完成,是轻量级的锁(一般称其为自旋锁),但是有一些注意事项:
- 必须保证标志位的修改是原子的,比如在32位机上使用64位的0和1来作为标志,那么当A线程将标志由0修改为1时,就需要分成两步,如果先修改高位,就可能在低位没来得及修改的时候B线程成功进入临界区,就导致两个线程都进入了临界区;
- 必须保证编译器不会改变修改标志位的指令的顺序,编译器优化的时候可能会进行指令重排,但是指令重排就可能导致标志位设置的时机错误,因此要禁用编译器对标志位相关操作的指令重排(很多语言通过
volatile
关键字来实现); - 这种方式利用了同一个进程的线程共享同一个内存空间的性质,所以必须保证标志位的修改对各个线程是可见的,也就是说对标志位变量的修改要能够及时反映到内存。编译优化的时候,有些变量可能不会将其即使写回到内存中,仅仅是在寄存器中进行修改,甚至编译器可能判断标志位不会直接影响到输出将其直接优化没了,因此要禁用类似的优化,保证对其的修改实时同步到内存(也通过
volatile
关键字来实现); - 让标志位不符合进入临界区的条件时,往往需要循环检测,这将导致CPU空转忙等,如果是单核CPU+非分时操作系统的组合,还会导致死锁,此时可以主动让出CPU资源来缓解(虽然让出了CPU资源,但是当前线程仍旧是
READY
状态,可以被再次调度到CPU上进行执行);
P原语——获取资源
P原语表示获取资源。将semaphore
的值递减,当semaphore
的值低于0时(递减前不大于0),说明没有临界资源,此时当前线程会被添加到该semaphore
的等待队列中,将当前线程的状态设置为阻塞并让出CPU资源。
def P(semaphore: Semaphore):
semaphore.value -= 1
if semaphore.value < 0:
# 获取当前线程的描述对象
current_thread = threading.current_thread()
# 将当前线程添加到信号量的等待队列中
semaphore.waiting_list.append(current_thread)
# 阻塞当前线程,并将CPU资源让出
block(current_thread)
# 当其它线程通过V原语唤醒当前线程后,从这里继续
V原语——释放资源
V原语表示释放资源。将semaphore
的值递增,当semaphore
的值不大于0时(递增前小于0),说明有线程被阻塞了,并且在等待本资源的释放,此时唤醒一个该semaphore
等待队列中的线程,即将该线程从等待队列中移除,并将该线程的状态设置为READY
(就绪,可以被调度到CPU上执行,但不一定会被立即调度到CPU中)。
def V(semaphore: Semaphore):
semaphore.value += 1
if semaphore.value <= 0:
# 从信号量的等待队列中出列一个线程
thread = semaphore.waiting_list.pop(0)
# 唤醒该线程,使得其可以被调度得到CPU资源
wakeup(thread)
# 当前线程继续执行,不必让出CPU资源
几种常见的临界资源访问
0. 普通互斥访问
item = Object()
mutex = Semaphore(1) # 互斥锁,用来进行临界资源
def visiter_1():
while True:
P(item_mutex) # 获取读写互斥锁
produce_1(item) # 进行操作
V(item_mutex) # 释放读写互斥锁
def visiter_2():
while True:
P(item_mutex) # 获取读写互斥锁
produce_2(item) # 进行操作
V(item_mutex) # 释放读写互斥锁
这是最常见的方式,访问临界资源前都必须取得锁。
1. 生产者-消费者模式
生产者-消费者模式主要针对的是资源单向流动的多线程同步问题。生产者将新的资源添加到buffer中,然后消费者从buffer获取资源并进行消费,显然这个缓冲区buffer就是临界资源,生产者和消费者都会修改临界资源。
在资源单向流动的问题中,如果使用简单的互斥访问,可能出现的情况是消费者获得锁进入临界区后,缓冲区中并没有可以消费的资源,此时消费者就需要先让出互斥锁,然后再重新进入临界区检查是否缓冲区中有可以消费的资源,此时消费者就会频繁、反复、无用地获取、释放互斥锁。虽然可以通过让消费者让出互斥锁的同时让出CPU资源来减轻这种情况,但是仍旧有很大的损耗。
生产模式就是为了解决缓冲区没有资源时消费者空转的情况,当缓冲区没有资源时,消费者线程被阻塞,就不会白白占用CPU资源了。
1.1 缓冲区大小无限的生产者-消费者模式
mutex = Semaphore(1) # 保证缓冲区始终只有一个线程可以访问
buffer = [] # 临界资源,缓冲区
used = Semaphore(0) # 缓冲区中已经被使用的空间
def producer():
while True:
item = create_item() # 创建一个对象
P(mutex) # 获取互斥锁
buffer.append(item) # 将对象添加到缓冲区中
V(mutex) # 释放互斥锁
V(used) # 增加一个被占用空间
def consumer():
while True:
P(used) # 减少一个被占用空间
P(mutex) # 获取互斥锁
item = buffer.pop(0) # 从缓冲区中取出一个对象
V(mutex) # 释放互斥锁
consume(item) # 消费掉该对象
1.2 缓冲区大小受限的生产者-消费者模式
添加一个信号量free
来表示缓冲区的空闲资源,其初始值为缓冲区大小。生产者每向缓冲区添加新的对象,就会减少一个free
所计数的资源;消费者每消费一个对象,就会增加一个free
所计数的资源。
mutex = Semaphore(1) # 保证缓冲区始终只有一个线程可以访问
buffer = [] # 临界资源,缓冲区
free = Semaphore(n) # 缓冲区中空闲空间(n为缓冲区大小)
used = Semaphore(0) # 缓冲区中已经被使用的空间
def producer():
while True:
item = create_item() # 创建一个对象
P(free) # 减少一个空闲空间
P(mutex) # 获取互斥锁
buffer.append(item) # 将对象添加到缓冲区中
V(mutex) # 释放互斥锁
V(used) # 增加一个被占用空间
def consumer():
while True:
P(used) # 减少一个被占用空间
P(mutex) # 获取互斥锁
item = buffer.pop(0) # 从缓冲区中取出一个对象
V(mutex) # 释放互斥锁
V(free) # 增加一个空闲空间
consume(item) # 消费掉该对象
2. 写锁合并模式
写锁合并模式是为了针对读多写少
的情况而出现的。我们知道,一个资源如果是只读的,那么多个线程同时访问该资源是不会出现并发问题的。当一个资源读多写少
的时候,我们就可以对其进行相应的优化。
我们可以将整套操作进行封装,就成了我们常说的读-写
锁。其特点为读-写
互斥、写-写
互斥,读-读
不互斥。
读写锁的关键在于合并读线程。临界资源通过一个互斥锁mutex
来进行保护,多个读线程作为一个整体,与多个写线程来竞争互斥锁。
为了对读线程进行合并,我们需要一个counter来判断当前读线程是否是第一个进入临界区的读线程、判断当前线程是否是最后一个退出临界区的读线程,只有这两个线程需要对临界资源的互斥锁进行操作。
但是同时,多个读线程会修改counter,此时counter也变成了多个读线程间的临界资源,又需要另一个互斥锁来进行保护。
如此一看,读写锁一方面没有如同生产者-消费者模式
那般通过阻塞线程来避免无用的锁获取,另一方面还增加了锁的操作,相比于无脑的互斥锁访问,这不是妥妥的负提升吗?
其实不然,虽然每次读线程都必然会进行两次针对保护counter的互斥锁的获取、释放,但是这个锁的获取和释放中间间隔是很短的,所以可以通过自旋锁的方式优化其性能,而单锁的互斥访问往往需要重量级锁(通过系统调用让操作系统完成原语操作的锁)。当长期有线程进入临界区进行读操作时,每个读线程都只需要在用户态进行自旋锁的操作即可,相当于一直在进行多个线程的读操作。
可见,读写锁的优势在多核CPU、读操作较长、读的并发量较大的情况下才能够显著提高其性能。
2.1 非公平读写锁
counter = 0 # 进入临界区的读线程的数量
count_mutex = Semaphore(1) # 计数互斥锁,保证counter的操作的原子性
item_mutex = Semaphore(1) # 读写互斥锁,保证临界资源(item)的读写、写写互斥
def writer():
while True:
P(item_mutex) # 获取读写互斥锁
write(item) # 进行写操作
V(item_mutex) # 释放读写互斥锁
def reader():
while True:
P(count_mutex) # 获取计数互斥锁
if (counter == 0):
P(item_mutex) # 若当前线程是第一个进入临界区的读线程,就获取读写互斥锁
counter += 1 # 增加一个读线程计数
V(count_mutex) # 释放计数互斥锁
read(item) # 读操作
P(count_mutex) # 获取计数互斥锁
counter -= 1 # 减少一个读线程计数
if (counter == 0):
V(item_mutex) # 若当前线程是最后一个离开临界区的读线程,就释放读写互斥锁
V(count_mutex) # 释放计数互斥锁
2.3 公平读写锁
相比于非公平读写锁,读写操作之间的优先级是相同的,解决了普通读写锁可能造成的写线程饥饿问题。
方法是加入一个栅栏,当有写线程需要进行写操作时,将不会有新的读线程进入临界区,这就避免了写线程无法获得锁的问题。
counter = VarInteger(0) # 进入临界区的读线程的数量
count_mutex = Semaphore(1) # 计数互斥锁,保证counter的操作的原子性
item_mutex = Semaphore(1) # 读写互斥锁,保证临界资源(item)的读写、写写互斥
barrier = Semaphore(1) # 栅栏,用于在写线程试图进入临界区时,不会有新的读线程进入临界区
def writer():
while True:
P(barrier) # 关闭栅栏
P(item_mutex) # 获取读写互斥锁
write(item) # 进行写操作
V(item_mutex) # 释放读写互斥锁
V(barrier) # 打开栅栏
def reader():
while True:
P(barrier) # 进入栅栏
P(count_mutex) # 获取计数互斥锁
if (counter == 0):
P(item_mutex) # 若当前线程是第一个进入临界区的读线程,就获取读写互斥锁
counter += 1 # 增加一个读线程计数
V(count_mutex) # 释放计数互斥锁
V(barrier) # 退出(通过)栅栏
read(item) # 读操作
P(count_mutex) # 获取计数互斥锁
counter -= 0 # 减少一个读线程计数
if (counter == 0):
V(item_mutex) # 若当前线程是最后一个离开临界区的读线程,就释放读写互斥锁
V(count_mutex) # 释放计数互斥锁
生产者-消费者模式与写锁合并模式对比
项目 | 生产者-消费者 | 写锁合并 |
---|---|---|
临界资源 | 缓冲区(可变) | 某个可变对象 |
驱动方 | 生产者 | 写线程 |
受驱方 | 消费者 | 读线程 |
驱动方是否会修改临界资源 | 是 | 是 |
受驱方是否会修改临界资源 | 是 | 否 |
mutex数量 | 1 | 3(读写公平)或2 |
semaphore数量 | 1(缓冲区大小无限)或2 | 0 |