信号量、PV原语及其应用

锁和PV原语

主要有两种基础的锁:

  1. 互斥锁(Mutex):用于保证只有一个线程能够进入临界区;
  2. 信号量(Semaphore):表示某个资源的数量,小于0时表示没有资源,此时任何线程无法直接通过;

从语义上来看,mutexsemaphore代表着不同的语义;但是从实现上来看,mutex其实就是初始值为1的semaphore。所以我们只需要讨论semaphore的实现和操作方式即可。

semaphore的操作主要通过PV原语来进行。原语的意思是这个操作是原子的,要么没有开始执行,要么就已经完成了,不可能被打断,其它线程不会观察到原语操作的中间状态。

其主要的实现方式有3种思路:

  1. 硬件原子指令,由硬件直接提供相关的指令来完成原语操作,由硬件保证其原子性;
  2. 屏蔽中断,通过保证其它可能产生竞争的线程不会得到执行,并且当前线程不会主动让出CPU,来保证操作的原子性;
  3. 循环检查标志位,通过标志位来表示是否有线程进入临界区,当标志位不符合进入条件时,进入循环来等待标志位;

前两种依赖于硬件的功能,最后一种是纯软件实现。

第1种依赖于硬件指令,但是一般硬件并不会提供复杂的操作,因为进程/线程的调度总归是操作系统进行的,硬件难以提供合适的复杂操作。

第2种一般由操作系统提供(因为需要屏蔽中断,只有内核态有权限屏蔽中断),用户态通过系统调用来实现原语操作,涉及用户态和内核态的切换,开销较大。

第3种是纯软件实现,并且在用户态就可以完成,是轻量级的锁(一般称其为自旋锁),但是有一些注意事项:

  1. 必须保证标志位的修改是原子的,比如在32位机上使用64位的0和1来作为标志,那么当A线程将标志由0修改为1时,就需要分成两步,如果先修改高位,就可能在低位没来得及修改的时候B线程成功进入临界区,就导致两个线程都进入了临界区;
  2. 必须保证编译器不会改变修改标志位的指令的顺序,编译器优化的时候可能会进行指令重排,但是指令重排就可能导致标志位设置的时机错误,因此要禁用编译器对标志位相关操作的指令重排(很多语言通过volatile关键字来实现);
  3. 这种方式利用了同一个进程的线程共享同一个内存空间的性质,所以必须保证标志位的修改对各个线程是可见的,也就是说对标志位变量的修改要能够及时反映到内存。编译优化的时候,有些变量可能不会将其即使写回到内存中,仅仅是在寄存器中进行修改,甚至编译器可能判断标志位不会直接影响到输出将其直接优化没了,因此要禁用类似的优化,保证对其的修改实时同步到内存(也通过volatile关键字来实现);
  4. 让标志位不符合进入临界区的条件时,往往需要循环检测,这将导致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
posted @ 2021-11-06 00:58  SnowPhoenix  阅读(674)  评论(1编辑  收藏  举报