Linux 线程同步与互斥:锁的本质、条件变量与信号量的底层原理

线程同步与互斥

我们之前说过,线程是共享地址空间的,也就意味着会共享大部分资源。这些资源就是公共资源。多执行流访问这些资源,就会出现各种数据不一致问题。比如我们之前的多线程向同一个显示器文件写入,结果出现了数据混杂,也就是数据不一致。为了解决这个问题,就有了线程的同步与互斥。


1. 线程互斥

1-1 进程线程间的互斥相关背景概念

1-2 互斥量 mutex

大部分情况下,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内。这种情况下,变量归属单个线程,其他线程无法获得。

但有时候,很多变量需要在线程间共享,这样的变量称为共享变量。通过数据的共享,可以完成线程之间的交互。多个线程并发操作共享变量,会带来一些问题。

示例

cpp 复制代码
int ticket = 100;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        // 这一小段代码访问了公共资源,就叫做临界区
        // 临界区之外的就是非临界区
        ////////////////////////////////////////////////////////////
        if (ticket > 0)          // 判断
        {
            usleep(1000);        // 模拟抢票时间
            printf("%s sells ticket:%d\n", id, ticket);  // 抢到了票
            ticket--;            // 票数--
        }
        ////////////////////////////////////////////////////////////
        else
        {
            break;
        }
    }
    return nullptr;
}

int main(void)
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, (void *)"thread 1");
    pthread_create(&t2, NULL, route, (void *)"thread 2");
    pthread_create(&t3, NULL, route, (void *)"thread 3");
    pthread_create(&t4, NULL, route, (void *)"thread 4");

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    return 0;
}

运行结果

cpp 复制代码
。。。。。。
thread 3 sells ticket:3
thread 2 sells ticket:2
thread 4 sells ticket:1
thread 1 sells ticket:0
thread 3 sells ticket:-1
thread 2 sells ticket:-1

为什么会减到负数?

这里是多个线程竞争资源,也就是说有多个线程(执行流)进入 route 函数,也就是 route 函数被重入了。

在我们的代码中,有以下场景会影响抢票的逻辑:

  • 一个是 ticket--

ticket-- 并不是造成票数为负数的主要原因,但确实与它有关。ticket 是全局的整数变量,而我们的自减操作并不是原子的,也就是说自减有中间状态,而不是两态的(要么减完,要么没减)。

为什么?在冯·诺依曼体系结构中,无论是全局变量、栈变量、堆变量等,不管在哪里开辟,它们都叫做内存变量,通过虚拟地址转化来访问物理内存。也就是说 ticket 是内存中的一块空间。而计算机对变量进行自减操作,其实就是算术运算,这种操作只能由 CPU 来完成。

所以变量要从内存读取到 CPU 的寄存器里,其次 CPU 运算器对寄存器里的值进行计算。由于这个操作会影响 ticket 变量本身(自减),所以还要把寄存器里的值写回内存。这个从汇编语言可以看清:

cpp 复制代码
1265:  8b 05 a5 2d 00 00       mov    0x2da5(%rip),%eax        # 4010 <ticket>  ; 读 ticket
126b:  83 e8 01                sub    $0x1,%eax                ; 减 1
126e:  89 05 9c 2d 00 00       mov    %eax,0x2d9c(%rip)        # 4010 <ticket>  ; 写回

CPU 执行这个过程时,随时可能发生线程调度。这个过程涉及上下文数据。当还没来得及写回内存时,线程就被切换了,于是就要进行上下文保存。CPU 里面的 PC 指针就指向了下一条指令的地址。

一开始 CPU 执行第一条指令,PC 就是 1265,载入内存。接着增加 PC 里面的值------由于指令的长度 CPU 内部可以自动识别,偏移量就确定了,所以需要将 PC 的值加上一个偏移量(即指令长度)就可以自动找到下一条指令了。接着运算器对该寄存器做计算,从 100 变成 99。第二步完成后就自动执行下一条指令,写回内存。但是此时正准备执行时线程就被切换了,于是线程就将自己的上下文数据保存下来了,此时这个线程的 PC 就是 126e,被放入了等待队列。

然后 CPU 继续调度下一个线程,也是先执行载入操作。但由于第一个线程没有将 99 写入内存,于是第二个线程调度时,CPU 寄存器就拿到了 100。

假设线程 B 运气好,极端情况下,整个操作没有被切换,将 100 减到了 1(ticket 减了 99 次),此时准备将 1 减到 0,此时 PC 又被恢复到 1265,恰好又被切换了,保存当前的值 ebx=1pc=1265

CPU 继续调度线程 A,于是恢复上下文数据,PC 就指向了 126eebx 是 99,于是直接将内存里的 ticket=1 写成 99,瞬间将线程 B 的工作清空。于是就造成了数据不一致。

所以 -- 操作并不是原子的。由于 -- 会变成三条汇编,而在其之间任意一个位置都可能会被切换。这也解释了为什么信号量本质是一个计数器,但不能用全局整数代表,因为 ++/-- 操作不是原子的。

所以可以先这样简单理解:原子操作就是一条 C 语句翻译成汇编语句后,仍只有一条指令。

  • 一个是 if (ticket > 0) 这个判断

其实这个才是主要原因。比较大小也是一种计算,叫做逻辑计算(CPU 两种计算的另一种),结果是 bool 值。也是两步:载入、判断。

假设第一个线程刚载入完就被切走,第二个线程也是刚刚把 ticket 载入到寄存器就被切走,第三个、第四个也都载入了 ticket=1,都准备进行 -- 操作。然后由于内部代码逻辑将线程 1、2、3、4 按顺序依次唤醒。

线程一执行 ---- 是非原子的,要重新载入、计算、写回(第一次载入是用来判断的,第二次是用来计算的)。于是第一个线程将 1 写成 0 后写入了内存。

从第二个线程开始,由于判断已经成功了,于是就载入 ticket=0 继续对 ticket--,写入内存 ticket=-1;继续减 =-2;到了第三个线程就写入了 -3。于是票数就减成了负数。

也就是说,判断时大家同时(或者说短时间)全判断进来了。我们的 usleep() 正好加大了时间,于是增大了同时进入的概率------一个线程判断进来了,先别着急 --,先等等其他线程进来,最后一瞬间依次 --

为什么可能无法获得正确结果?

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程

  • usleep 模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段

  • ticket-- 操作本身就不是一个原子操作

所以这些都警示我们:全局资源没有加保护可能会存在并发问题。比如把票抢到了负数,就是线程安全问题。而我们的 route 函数被重复进入后出现了问题,叫做不可重入函数

要精准复现上面的问题,就要在多线程中制造更多的并发、更多的线程切换。

什么时候进行切换?其实就是:

  • 时间片耗尽

  • 调用了阻塞式 I/O

  • 线程调用 read 读取键盘输入阻塞,于是将线程 task_struct 连入到键盘的等待队列里

  • 挂起或休眠函数如 sleep

怎么切回呢?什么时候选择新的呢?答案是:等我们从内核态切换回用户态时进行检查。也就是说,当 OS 从内核态切换回用户态时,不仅会进行信号检查,也会进行线程调度检查。

我们发现线程切换的条件都有"陷入内核":

  • 时间片耗尽 → 触发中断 → 陷入内核

  • 阻塞 I/O → 调用系统调用 → syscall

这解释了为什么为了更好演示这个现象,在 ticket-- 前要 usleep、要 printf------这样就让线程有更大的概率陷入内核。

解决以上问题需要做到三点

  1. 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。

  2. 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。

  3. 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁 。Linux 上提供的这把锁叫互斥量

1-3 加锁后的代码

cpp 复制代码
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&lock);

        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_mutex_unlock(&lock);
        }
        else
        {
            pthread_mutex_unlock(&lock);   // 注意:else 分支也要解锁!
            break;
        }
    }
    return nullptr;
}

运行结果

cpp 复制代码
thread 1 sells ticket:4
thread 1 sells ticket:3
thread 1 sells ticket:2
thread 1 sells ticket:1

1-4 互斥量的接口

pthread 库提供互斥量接口。

锁将来一定要被所有线程看到。我们可以在主线程定义,以参数的形式传递给线程,也可以直接定义全局锁。因为未来的锁要保护我们的代码,所以线程在访问临界资源之前,必须先申请锁。要申请锁,必须先看到锁。

初始化互斥量

初始化互斥量有两种方法:

方法 1:静态分配

定义全局锁,不用手动释放,程序结束后自动释放。

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

方法 2:动态分配

局部方式定义:

cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
                       const pthread_mutexattr_t *restrict attr);

参数:

参数 说明
mutex 要初始化的互斥量
attr 一般传 NULL

销毁互斥量

cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

销毁互斥量需要注意:

  • 使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要销毁

  • 不要销毁一个已经加锁的互斥量

  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

互斥量加锁和解锁

加锁和解锁都是原子的。

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

补充接口:pthread_mutex_trylock

cpp 复制代码
int pthread_mutex_trylock(pthread_mutex_t *mutex);

这是非阻塞式加锁。当线程申请锁失败时,不会把线程阻塞挂起,而是直接返回错误码。

返回值:成功返回 0,失败返回错误号。

1-5 锁的本质

一旦把锁定义出来了,所有线程在访问公共资源时必须调用 pthread_mutex_lock 来竞争申请锁。也就是说多线程必须先看到锁,意味着锁本身就是临界资源

锁保护我们的代码,那么谁来保护锁的安全呢?所以我们必须要求申请锁的过程必须是原子的

  • 申请成功,就继续向后运行,访问临界区代码

  • 申请失败,就会挂起执行流

锁提供的能力本质就是:将临界区代码的执行由并行转换为串行

可能会遇到的情况


一句话总结

互斥锁解决的是"多个执行流同时访问临界资源"导致的数据不一致问题。它的核心是:申请锁的操作本身必须是原子的,锁让临界区的并行执行变成了串行执行。售票系统从"减到负数"到"正确递减",就是互斥锁价值的直观体现。

进程间如何共享一把锁?

锁的本质就是变量,就是一块内存空间。无论是定义全局锁还是局部锁,本质都是申请了一块空间。

如果要把锁用于进程间互斥,可以直接把进程间共享的内存(如共享内存)头部强转成 pthread_mutex_t*,这样多个进程就能看到同一把锁,从而进行互斥。

关键:必须设置进程共享属性

pthread_mutex_init 默认属性是 PTHREAD_PROCESS_PRIVATE,只对同进程内的线程有效。跨进程使用时,必须设置为 PTHREAD_PROCESS_SHARED,否则其他进程 lock 同一把锁不会生效。

代码示例

cpp 复制代码
// 创建共享内存后
void* shm = shmat(shmid, nullptr, 0);

pthread_mutex_t* mutex = (pthread_mutex_t*)shm;

// 关键:设置进程共享属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);

// 初始化锁(只需一个进程做一次)
pthread_mutex_init(mutex, &attr);

// 使用
pthread_mutex_lock(mutex);
// ... 访问共享资源 ...
pthread_mutex_unlock(mutex);

// 销毁(最后一个进程退出前)
pthread_mutex_destroy(mutex);

注意事项

优化后的售票系统

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include<mutex>
std::mutex cpp_mutex;
class ThreadData
{
public:
    ThreadData(std::string name, pthread_mutex_t &lock)
        : _name(name), _lockp(&lock)
    {
    }
    ~ThreadData()
    {
    }
    pthread_mutex_t *_lockp;
    std::string _name;
};
int ticket = 100;
// 加锁:尽量加锁的范围粒度要比较细尽可能的不要包含太多的非临界区代码
void *route(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);

    while (1)
    {
        //pthread_mutex_lock(td->_lockp);
        cpp_mutex.lock();
        if (ticket > 0) // 判断
        {
            usleep(1000);                                              // 模拟抢票时间
            printf("%s sells ticket:%d\n", td->_name.c_str(), ticket); // 抢到了票
            ticket--;                                                  // 票数--
            //pthread_mutex_unlock(td->_lockp);
            cpp_mutex.unlock();
        }

        else
        {
            //pthread_mutex_unlock(td->_lockp);
            cpp_mutex.unlock();
            break;
        }
    }
    delete td; // 线程结束时释放
    return nullptr;
}
int main()
{
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr); // 初始化锁

    pthread_t t1, t2, t3, t4;

    ThreadData *td1 = new ThreadData("thread 1", lock);
    pthread_create(&t1, NULL, route, td1);

    ThreadData *td2 = new ThreadData("thread 2", lock);
    pthread_create(&t2, NULL, route, td2);

    ThreadData *td3 = new ThreadData("thread 3", lock);
    pthread_create(&t3, NULL, route, td3);

    ThreadData *td4 = new ThreadData("thread 4", lock);
    pthread_create(&t4, NULL, route, td4);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    pthread_mutex_destroy(&lock);
    return 0;
}

锁的两个问题

问题一:线程不遵守规则会怎样?

如果有一个线程不遵守规则,不加锁就直接访问临界资源,照样会出现数据不一致问题。

锁只对遵守规则的线程有效。 必须保证所有线程都遵守"先加锁再访问"的规则,否则代码就有 bug,程序就是错的。

锁是一种约定,不是强制隔离。所有线程都必须遵守这个约定,保护才成立。


问题二:加锁后临界区内允许线程切换吗?

允许。 在 OS 看来,无论有没有加锁,代码就是代码,执行过程中随时可能发生线程切换。

但不用担心。因为:

  • 线程是持有锁被切换的

  • 即使它不在调度中,其他线程也必须等它回来

  • 等它执行完临界区代码、释放锁后,其他线程才能竞争这把锁


类比:带钥匙的自习室,有一个只允许一个人使用的自习室,使用前必须拿到钥匙:

  • 我先拿到了钥匙,就拥有了这个自习室

  • 其他人没钥匙,进不来

  • 如果我中途想上厕所,我也带着钥匙去

  • 其他人依旧要等我还回钥匙后才能竞争

在外人看来:

  • 他们不关心我在自习室里做了什么

  • 他们也没办法中断我

  • 对他们来说,只有两种状态有意义:我不用我用完了

所以,我使用自习室的整个过程,对外面的人来说就是原子的------在我执行期间不会被打扰。


加锁的本质

加锁的本质,就是把整个代码块原子化。

视角 表现
临界区内线程 可能被切换,但持有锁
临界区外线程 只能看到"未加锁"和"已解锁"两种状态
整体效果 临界区代码对外表现为不可分割的原子操作

2. 互斥量实现原理探究

锁是怎么实现的?其实锁的实现有很多方案,有硬件实现,也有软件实现。

2-1 硬件级实现:关闭中断

锁的硬件级实现,内核在用,其原理是:

我们的代码为什么不是原子的?因为我们的代码不是一条汇编,而是大量的代码块,随时可能被切换。为什么被切换?------时间片到了。操作系统会一直触发时钟中断来调度,一直检查时间片。一旦调度,代码就交叉了,而互斥的根本要求就是不允许交叉

所以硬件实现方案就是:关闭时钟中断,外部中断不响应。于是当前代码运行时就没有其他线程影响。当当前线程对应的代码块执行完后,再打开时钟中断、响应外部中断。

但这个方案风险很大:

  • 如果访问资源时出现问题,可能关闭中断后无法再开启

  • OS 就会死机

所以 OS 不会让用户使用这个方案。

2-2 软件级实现:swap / exchange 指令

为了实现互斥锁操作,大多数体系结构都提供了 swap 或 exchange 指令 。该指令的作用是把寄存器和内存单元的数据交换(注意是交换,而不是拷贝)。

由于只有一条指令,保证了原子性。即使是多处理器平台,访问内存的总线周期也有先后:一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。

锁的本质

所谓锁,就是一种标记位(可以暂时理解为一个整数)。锁也要在内存开辟一段空间:

  • 值被设置成 1:表示该锁没有被持有,当前处于可申请状态

  • 值被设置成 0:表示该锁已被持有

申请锁时,会有很多线程竞争。这里以两个线程 A、B 为例,它们申请锁的伪代码如下:

cpp 复制代码
pthread_mutex_lock:
    mov $0, %al           ; al = 0
    xchgb %al, mutex      ; 交换 al 和 mutex(原子操作)
    if (al > 0)           ; 如果交换前 mutex 是 1
    {
        return 0;         ; 加锁成功,return 后继续执行 mutex_lock 后面的代码
    }
    else                  ; 否则 mutex 已被占用
    {
        挂起等待;          ; 线程阻塞
    }
    goto lock             ; 被唤醒后重新尝试

为什么交换就能保证安全?

进程/线程切换时,CPU 内的寄存器硬件只有一套,但 CPU 寄存器内的数据可以有多份。每一份就是当前执行流的上下文数据:

  • 调度线程 A 时,放的是线程 A 的数据

  • 切换成线程 B 时,用的还是同一套寄存器,但放的是 B 的数据

所以线程切换时就要做上下文保护------说白了就是把当前线程的数据打包带走。

如果把一个变量内容交换到 CPU 内部的寄存器里,本质就是把变量的内容获取到当前执行流的硬件上下文中。这属于进程/线程私有。

所以用 swap 或 exchange 将内存中的变量交换到 CPU 寄存器中,本质就是当前进程在获取锁 。因为是交换,不是拷贝!由于 1 只有一份,所以谁申请到,谁就持有锁。

两个线程竞争的过程

线程 A 申请锁时,将 al 清零。线程 B 申请时,也要把 al 清零。两个线程所清的都是自己的上下文数据,所以不会互相影响,也不怕中断。

由于 exchange 是一条汇编,保证了原子性,于是两个线程继续往下:

  1. 假设 A 更快一点,先将 1 拿到。如果交换完(原子操作,要么没交换,要么交换完)就中断切换了------其实在这个函数任何位置切换,原本的线程都会把唯一的一个 1 带走。

  2. A 把自己的上下文数据带走,包含了唯一一份的 1

  3. 线程 B 竞争锁,先把 al 清零,然后也要交换。此时内存里的 mutex 就是 0 了,线程 B 没有竞争到这把锁。

  4. 接着判断 al 值,B 发现是 0,于是挂起等待。

  5. 再切回 A,先恢复上下文数据,就把 al=1 恢复了。此时 PC 指向判断这一条指令,于是判断成功,pthread_mutex_lock 返回,继续执行 A 的代码。

所以:谁执行 xchgb %al, mutex 成功,谁就拥有了这把锁。

释放锁

cpp 复制代码
pthread_mutex_unlock:
    movb $1, mutex        ; mutex = 1
    唤醒等待 mutex 的线程;
    return 0;

可以看到,解锁就是往内存里的 mutex1(也是原子的)。为什么不再与持有锁的线程交换、再换回来?因为该线程申请锁成功后就会执行后面的代码,可能会使用到这个寄存器,所以不能简单地换回。

总结

经过上面的例子,大家已经意识到:单纯的 i++ 或者 ++i 都不是原子的。这是因为自增是一种拷贝,而不是交换,可能会产生数据一致性问题。
锁的软件实现核心,就是利用 swap / exchange 这类原子交换指令,把"检查"和"标记"合成一条不可分割的指令。谁交换到了唯一的 1,谁就持有锁;没交换到的线程挂起等待。这就是互斥锁最底层的原理。

3.互斥量的封装

Mutex.hpp

cpp 复制代码
#pragma once
#include <pthread.h>
#include <iostream>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }

        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            if (n != 0)
            {
                std::cerr << "pthread_mutex_lock err" << std::endl;
            }
        }

        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            if (n != 0)
            {
                std::cerr << "pthread_mutex_unlock err" << std::endl;
            }
        }

    private:
        pthread_mutex_t _mutex;
    };
    
    //利用 C++ 的 RAII 机制:对象离开作用域时,析构函数自动调用
    class LockGuard
    {
    public:
        LockGuard(Mutex &mutex) : _mutex(&mutex)
        {
            _mutex->Lock(); // 构造时加锁
        }
        ~LockGuard()
        {
            _mutex->Unlock(); // 析构时自动解锁
        }

    private:
        Mutex *_mutex;
    };
}

Main.cc

cpp 复制代码
#include "Mutex.hpp"
#include <unistd.h>
class ThreadData
{
public:
    ThreadData(std::string name, MutexModule::Mutex &lock)
        : _name(name), _lockp(&lock)
    {
    }
    ~ThreadData()
    {
    }
    MutexModule::Mutex *_lockp;
    std::string _name;
};

int ticket = 100;
// 加锁:尽量加锁的范围粒度要比较细尽可能的不要包含太多的非临界区代码
void *route(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);

    while (1)
    {
        {//花括号括起来就是临界区
            MutexModule::LockGuard guard(*td->_lockp); // 加锁完成,临时变量,在while里,于是每次进循环都创建新对象,调用构造函数加锁,
            // td->_lockp->Lock();                                               出循环后离开了作用域就自动调用析构函数解锁
            if (ticket > 0) // 判断
            {
                usleep(1000);                                              // 模拟抢票时间
                printf("%s sells ticket:%d\n", td->_name.c_str(), ticket); // 抢到了票
                ticket--;
                // td->_lockp->Unlock();                                                 // 票数--
            }
            else
            {
                // td->_lockp->Unlock();
                break;
            }
        }
    }
    delete td; // 线程结束时释放
    return nullptr;
}
int main()
{
    MutexModule::Mutex lock;
    pthread_t t1, t2, t3, t4;

    ThreadData *td1 = new ThreadData("thread 1", lock);
    pthread_create(&t1, NULL, route, td1);

    ThreadData *td2 = new ThreadData("thread 2", lock);
    pthread_create(&t2, NULL, route, td2);

    ThreadData *td3 = new ThreadData("thread 3", lock);
    pthread_create(&t3, NULL, route, td3);

    ThreadData *td4 = new ThreadData("thread 4", lock);
    pthread_create(&t4, NULL, route, td4);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    return 0;
}

4. 线程同步

引入新的技术,必然会引入新的问题。为了进一步解决问题,就必须引入新的技术。

比如我们之前的共享内存,为了解决进程间通信问题,引入了多进程访问共享资源。但多进程访问共享资源会有数据不一致问题,为了解决数据不一致,我们引入了锁。而锁的加入又可能使代码效率降低、复杂度变高、甚至引发死锁等问题。

所以,互斥也会引入新的问题。

从自习室到线程饥饿

回到之前的自习室问题:

我持有自习室的钥匙,因为我来得早。由于自习室只允许一个人使用,外面的人就得等。

过了很久,发现 12 点了,该干饭了。于是我出来了,把钥匙挂墙上,正准备走。但转念一想:要是我走了,竞争这把钥匙的人拿走钥匙,那我回来就要等好久。不行,还是咬咬牙再自习一会吧。

呆了 2 分钟后饿得不行,还是放弃吧,于是又出来了。出来后想到自习室不属于自己了,又进去了......

于是,由于自己的原因,我高频地去申请钥匙,但并没有在自习室做有效学习 。也就是说,线程在高频地申请锁、释放锁,又因为别人抢不过,导致其他线程申请不到锁,得不到 CPU 资源------这就是线程饥饿

之前售票系统的例子:

cpp 复制代码
thread 1 sells ticket:4
thread 1 sells ticket:3
thread 1 sells ticket:2
thread 1 sells ticket:1

加锁后全是 thread 1 在卖票,其他线程抢不到锁。因为 thread 1 申请完锁、释放锁后,立马进入循环又申请锁。而其他线程还要先被唤醒、寄存器清零等工作,于是在 thread 1 的时间片内,它尽可能会去申请锁。这就是锁饥饿。

纯互斥的问题

后来,自习室管理员也看到了这个 bug。所以纯互斥做法没错,但不行:

  • 确实确保了只有一个人进来

  • 但没规定必须是不同的人

  • 整体效率很低

于是管理员规定:

  1. 一旦申请完钥匙后,不能立即申请第二次

  2. 所有在外面等的人,不要乱哄哄去抢,而是排队等

  3. 刚申请完钥匙的人要到队尾等

虽然这不是我们后面要讲的技术细节,但可以保证自习室安全的情况下,公平顺序地访问临界资源

这种按顺序访问临界资源 ,就叫做同步

管道的同步机制

我们知道管道也有同步机制:

  • 管道写满时,写端不能再写,要等读端读出数据

  • 管道为空时,读端要等写端写入数据

所以管道的读端线程与写端线程是同步的。


4-1 条件变量

理解条件变量

假设今天要玩一个放苹果的游戏:

  • 要把苹果放到盘子里

  • 苹果比较大,一个盘子只能放一个苹果

  • 放苹果的人眼睛是蒙起来的

  • 另一个人从盘子中拿苹果,眼睛也是蒙起来的

盘子 就是放苹果的人和拿苹果的人都要用到的,所以盘子叫做临界资源

我们规定:不管是拿还是放,都要对盘子加锁。不管是放还是拿,都要先申请钥匙,再进行操作:

  • 有苹果就放不了

  • 没苹果就取不了

由于双方都不知道盘子里是否有苹果,于是都要非常高频地竞争锁。其中一方(比如放的人)高频地申请锁,有苹果就什么都不做(进入临界区却什么都不干),导致另一方饥饿。

于是我们引入了铃铛 + 队列

  1. 放苹果的人申请锁、访问临界资源、检查有没有苹果

  2. 没有就放,之后释放锁

  3. 释放完理论上要继续申请锁(因为他并不知道有没有被拿)

  4. 但现在放完之后就敲一下铃铛 ,然后放的人就去等待

  5. 拿的一方申请锁时进入临界资源,发现是空的,就不能申请锁了,要到队列里面等

  6. 当铃铛响了,就醒来去申请锁、去拿

  7. 如果还有取的一方想拿,发现没有,就到队列后面等

  8. 直到放苹果的一方申请锁、放完后,不会继续申请锁,而是敲一下铃铛唤醒队列里的取方

  9. 取方队列头去拿,申请锁后取出来

  10. 接着继续申请锁拿,发现没了,就到队尾等待

在这个模型中,铃铛 + 队列 就叫做条件变量


条件变量的作用

这种情况就需要用到条件变量


4-2 同步概念与竞态条件


4-3 条件变量函数

初始化

与锁接口类似,一样分为全局和局部:

全局:自动初始化和销毁

cpp 复制代码
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

局部

cpp 复制代码
int pthread_cond_init(pthread_cond_t *restrict cond,
                      const pthread_condattr_t *restrict attr);

参数:

参数 说明
cond 要初始化的条件变量
attr 一般传 NULL

销毁

cpp 复制代码
int pthread_cond_destroy(pthread_cond_t *cond);

等待条件满足

cpp 复制代码
int pthread_cond_wait(pthread_cond_t *restrict cond,
                      pthread_mutex_t *restrict mutex);

参数:

参数 说明
cond 要在这个条件变量上等待
mutex 互斥量(后面详细解释)

判定与等待的关系

等待是因为条件不满足才等待,而"条件不满足"本身就是对临界资源的状态判定。

因此:

  • 判定一定在临界区内部进行(因为判定本身就是访问临界资源)

  • 判定结果也一定来自临界资源

但关键是:

条件不满足时,不能直接持锁休眠。

如果线程在临界区内(持有锁)直接休眠,锁就不会释放,其他线程无法进入临界区,条件永远无法改变,最终造成死锁。

这就相当于我去自习室学习了一段时间后,不想自习了,于是带着钥匙就去自己房间睡觉了。结果其他人拿不到钥匙,自习室空着,但谁也用不了,大家全在等一个"睡着了的人"

这就是持锁休眠导致死锁的本质。

正确做法

要走就先把钥匙挂回墙上(释放锁),然后回房间睡觉(休眠)。

pthread_cond_wait 就是做这件事内部流程:

  1. 原子地释放锁

  2. 线程休眠, cond 的等待队列上

  3. 被唤醒后**重新竞争获取锁,**成功才返回

为什么醒来后要重新拿锁?

因为等待的线程醒来后,还要继续访问临界资源(比如检查队列是否非空)。而访问临界资源必须持锁。

补充接口:pthread_cond_timedwait

cpp 复制代码
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
                           pthread_mutex_t *restrict mutex,
                           const struct timespec *restrict abstime);

功能:带超时的条件等待。和 pthread_cond_wait 类似,但最多等待到 abstime 指定的绝对时间。超时后即使条件未满足也返回,返回值为 ETIMEDOUT

唤醒等待

cpp 复制代码
int pthread_cond_broadcast(pthread_cond_t *cond);   // 唤醒所有等待线程
int pthread_cond_signal(pthread_cond_t *cond);      // 唤醒一个等待线程

条件变量使用规范

  • 等待条件代码
cpp 复制代码
pthread_mutex_lock(&mutex);

while (条件为假)
    pthread_cond_wait(cond, mutex);

// 修改条件
pthread_mutex_unlock(&mutex);
  • 给条件发送信号代码
cpp 复制代码
pthread_mutex_lock(&mutex);

// 设置条件为真
pthread_cond_signal(cond);

pthread_mutex_unlock(&mutex);

简单案例

先用 PTHREAD_COND_INITIALIZERPTHREAD_MUTEX_INITIALIZER 进行测试,对其他细节暂不追究。

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <string>
#include <vector>
#include<unistd.h>

#define NUM 5
pthread_mutex_t glock = PTHREAD_MUTEX_INITIALIZER; // 定义全局锁
pthread_cond_t gcond = PTHREAD_COND_INITIALIZER;   // 定义全局条件变量
int cnt = 1000;
void *routine(void *args)
{
    std::string name = static_cast<const char *>(args);
    while (true)
    {
        pthread_mutex_lock(&glock);
        //不要让线程疯狂申请释放锁,而是一个个执行,直接让线程等待
        pthread_cond_wait(&gcond, &glock);
        std::cout << name << "计算:" << cnt++ << std::endl;
        pthread_mutex_unlock(&glock);
    }
    return nullptr;
}
int main()
{
    std::vector<pthread_t> threads;
    for (int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        char *name = new char[64];
        snprintf(name, 64, "thread-%d", i);
        int n = pthread_create(&tid, nullptr, routine, name);
        if (n != 0)
            continue;
        threads.push_back(tid);
        //sleep(1);
        //线程按顺序、每隔一秒启动,它们进入等待队列的顺序就基本确定了(01234)。
    }
    sleep(3);
    //每隔1s唤醒一个/所有线程
    while(true)
    {
        // std::cout<<"唤醒一个线程"<<std::endl;
        // pthread_cond_signal(&gcond);

        std::cout<<"唤醒所有线程"<<std::endl;
        pthread_cond_broadcast(&gcond);
        sleep(1);
    }
    for (auto &id : threads)
    {
        int n = pthread_join(id, nullptr);
        if (n != 0)
            continue;
    }
    return 0;
}


4-4 生产者消费者模型

321 原则(便于记忆)

  • 3 种关系:生产者与生产者(互斥)、消费者与消费者(互斥)、生产者与消费者(同步 + 互斥)

  • 2 种角色:生产者、消费者

  • 1 个交易场所:阻塞队列

4-4-1 为何要使用生产者消费者模型

生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题

这个容器其实就是一块内存空间,本质就是一种临界资源,而我们的消费者和生产者角色就由线程来承担

生产者和消费者彼此之间不直接通讯,而是通过阻塞队列来进行通讯:

  • 生产者生产完数据后,不用等待消费者处理,直接扔给阻塞队列

  • 消费者不找生产者要数据,而是直接从阻塞队列里取

阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的

生产者消费者模型的代码,本质就是通过锁和条件变量,处理好三种关系,保证数据一致、不覆盖、不丢失、不混乱。

4-4-2 生产者消费者模型优点

  1. 生产过程和消费过程解耦

生产者和消费者不直接通信,而是通过阻塞队列交互。

  • 生产者只关心"把数据放进队列"

  • 消费者只关心"从队列取数据"

  • 任何一方的变化都不直接影响另一方

好处:代码模块化,双方独立开发、独立修改。


  1. 支持并发,提高效率

生产者和消费者可以同时运行:

  • 生产者生产数据的同时,消费者在消费数据

  • 多个生产者可以并发生产

  • 多个消费者可以并发消费

好处:充分利用多核 CPU,提高整体吞吐量。


  1. 支持忙闲不均

生产者和消费者的速度往往不一致:

好处:缓冲区吸收了速度差,快的一方不用等慢的一方,慢的一方也不用被催。

基于 BlockingQueue 的生产者消费者模型

BlockingQueue(阻塞队列)

在多线程编程中,阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。

与普通队列的区别在于:

  • 当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素

  • 当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出

以上的操作都是基于不同的线程来说的,线程在对阻塞队列进行操作时会被阻塞。


代码实现

BlockQueue.hpp

cpp 复制代码
#pragma once
#include <unistd.h>
#include <iostream>
#include <pthread.h>
#include <queue>

const int defaultcap = 5;

template <class T>
class BlockQueue
{
public:
    BlockQueue(int cap = defaultcap)
        : _cap(cap), _consumer_sleep_num(0), _producer_sleep_num(0)
    {
        pthread_mutex_init(&_lock, nullptr);
        pthread_cond_init(&_full_cond, nullptr);
        pthread_cond_init(&_empty_cond, nullptr);
    }

    void Equeue(const T &in)
    {
        pthread_mutex_lock(&_lock);

        while (IsFull())
        {// 满了就不能生产了,就要等待,在_full_cond条件下等,即休眠,而休眠也是在临界区休眠的
            _producer_sleep_num++;
            pthread_cond_wait(&_full_cond, &_lock);
            _producer_sleep_num--;
        }
        // 没满,队列有空间
        _q.push(in);

        //确实有消费者在等待时才唤醒,避免无效 signal,此时队列一定有数据,因为刚刚push完
        if (_consumer_sleep_num > 0)
            pthread_cond_signal(&_empty_cond);
        // 唤醒消费者(注意:此时消费者并不会立刻运行)
        // signal 只是把消费者从条件变量的等待队列中剥离,
        // 转移到锁的竞争队列上。因为当前生产者仍持有锁,
        // 消费者必须等生产者调用 unlock 释放锁后,
        // 才能竞争到锁,并从 pthread_cond_wait 返回真正执行。

        pthread_mutex_unlock(&_lock);
    }

    T Pop()
    {
        pthread_mutex_lock(&_lock);

        while (IsEmpty())
        {// 空了就不能取了,就要等待,在_empty_cond条件下等,即休眠,而休眠也是在临界区休眠的
            _consumer_sleep_num++;
            pthread_cond_wait(&_empty_cond, &_lock);
            _consumer_sleep_num--;
        }

        T data = _q.front();
        _q.pop();
        //确实有生产者在等待时才唤醒,此时队列一定有空闲位置,因为刚刚pop完
        if (_producer_sleep_num > 0)
            pthread_cond_signal(&_full_cond);

        pthread_mutex_unlock(&_lock);
        return data;
    }

    ~BlockQueue()
    {
        pthread_mutex_destroy(&_lock);
        pthread_cond_destroy(&_full_cond);
        pthread_cond_destroy(&_empty_cond);
    }

private:
    bool IsFull() { return _q.size() >= _cap; }
    bool IsEmpty() { return _q.size() == 0; }

private:
    std::queue<T> _q;           // 临界资源
    int _cap;                   // 容量大小
    pthread_mutex_t _lock;      // 保护临界资源
    pthread_cond_t _full_cond;  // 生产满了就要等
    pthread_cond_t _empty_cond; // 消费完了也要等

    int _consumer_sleep_num;    // 消费者休眠个数
    int _producer_sleep_num;    // 生产者休眠个数
};

关键点讲解

  1. 为什么判断要用 while 而不是 if
cpp 复制代码
// if (IsFull())bug,要用while
//  判断就是访问_q,_q就是临界资源,于是就要加锁,而这个判定和结果都是在临界区
while (IsFull())
    pthread_cond_wait(&_full_cond, &_lock);

两个原因:

原因一:pthread_cond_wait 可能调用失败

pthread_cond_wait 是函数,函数就可能调用失败。如果失败,线程没有被阻塞,就会继续往下走。此时:

  • 队列还是满的,生产者却继续生产

  • 队列还是空的,消费者却继续消费

while 会在 wait 返回后重新检查条件,失败就再等一次。

原因二:虚假唤醒

以"多生产单消费"为例:

  • 消费者取走一个数据后,用 broadcast 唤醒了 5 个生产者

  • 此时队列只空了一个位置,但 5 个生产者同时醒来

  • 第 1 个生产者填满队列后,剩下 4 个生产者条件又不满足了

这 4 个生产者就是被"虚假唤醒"了------醒来了,但条件其实不满足。

while 让线程醒来后重新检查条件

  • 条件仍不满足 → 继续等待

  • 条件满足了 → 才继续执行

  1. pthread_cond_wait 为什么要传锁?
cpp 复制代码
pthread_cond_wait(&_full_cond, &_lock);

三个重点:

  1. 唤醒的时机:signal 在解锁前还是解锁后?
cpp 复制代码
// 方案 1:先唤醒,再解锁
pthread_cond_signal(&_empty_cond);
pthread_mutex_unlock(&_lock);

// 方案 2:先解锁,再唤醒
pthread_mutex_unlock(&_lock);
pthread_cond_signal(&_empty_cond);

结论 :两种都正确。因为不管先唤醒还是后唤醒,本质都是必须持有锁才能真正醒来signal 只是把线程从条件变量队列移到锁竞争队列,真正执行要等拿到锁。

  1. 休眠计数的优化
cpp 复制代码
if (_consumer_sleep_num > 0)
    pthread_cond_signal(&_empty_cond);

只有当对方确实有线程在等待时,才调用 signal,避免无效唤醒。


Task.hpp

cpp 复制代码
#pragma once

class Task
{
public:
    Task() {}
    Task(int x, int y) : _x(x), _y(y) {}

    void Execute() { _result = _x + _y; }

    int result() { return _result; }
    int x() { return _x; }
    int y() { return _y; }

private:
    int _x;
    int _y;
    int _result;
};

Main.cc

单生产单消费

版本 1:队列中放内置类型

cpp 复制代码
void* ConsumerRoutine(void* args)
{
    BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(args);
    while (true)
    {
        int data = bq->Pop();
        std::cout << "消费了一个数据:" << data << std::endl;
    }
    return nullptr;
}

void* ProducerRoutine(void* args)
{
    int data = 1;
    BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(args);
    while (true)
    {
        bq->Equeue(data++);
        std::cout << "生产了一个数据:" << data - 1 << std::endl;
    }
    return nullptr;
}

消费快、生产慢

cpp 复制代码
生产了一个数据:1
消费了一个数据:1
生产了一个数据:2
消费了一个数据:2
...

生产一个立马被消费,节奏跟随生产者。

消费慢、生产快

cpp 复制代码
生产了一个数据:1
生产了一个数据:2
生产了一个数据:3
生产了一个数据:4
生产了一个数据:5
消费了一个数据:1
生产了一个数据:6
消费了一个数据:2
...

队列瞬间被填满,之后消费一个生产一个,节奏跟随消费者。


版本 2:队列中放自定义类型

cpp 复制代码
void* ConsumerRoutine(void* args)
{
    BlockQueue<Task>* bq = static_cast<BlockQueue<Task>*>(args);
    while (true)
    {
        Task t = bq->Pop();
        t.Execute();
        std::cout << "消费了一个任务:" << t.x() << "+" << t.y() << "=" << t.result() << std::endl;
    }
        
}

void* ProducerRoutine(void* args)
{
    int x = 1, y = 1;
    BlockQueue<Task>* bq = static_cast<BlockQueue<Task>*>(args);
    while (true)
    {
        sleep(1);
        std::cout << "生产了一个任务:" << x << "+" << y << "=?" << std::endl;
        bq->Equeue(Task(x++, y++));
    }
}

一个线程给另一个线程派发任务。

cpp 复制代码
xqq@ubuntu-server:~/linux/module7/BlockingQueue$ ./cp
生产了一个任务:1+1=?
消费了一个任务:1+1=2
生产了一个任务:2+2=?
消费了一个任务:2+2=4
生产了一个任务:3+3=?
消费了一个任务:3+3=6
生产了一个任务:4+4=?
消费了一个任务:4+4=8

版本 3:队列中放函数对象

cpp 复制代码
#include <functional>
using task_t = std::function<void()>;

void Download()
{
    std::cout << "我是一个下载任务..." << std::endl;
    sleep(3);   // 模拟处理任务耗时
}
void* ConsumerRoutine(void* args)
{
    // 消费者消费任务两个阶段:
    BlockQueue<task_t>* bq = static_cast<BlockQueue<task_t>*>(args);
    while (true)
    {
        // 1. 消费任务
        task_t task = bq->Pop();
        std::cout << "消费(执行)了一个任务:";
        // 2. 执行任务
        task();
    }
}
// 处理任务时,这个任务已经被拿到线程的上下文数据当中了
void* ProducerRoutine(void* args)
{
    // 生产者生产任务两个阶段:1.获得任务
    BlockQueue<task_t>* bq = static_cast<BlockQueue<task_t>*>(args);
    while (true)
    {
        sleep(1);
        std::cout << "生产了一个任务:" << std::endl;
        // 2.生产任务
        bq->Equeue(Download);
    }
}

队列里放函数对象,生产者投递任务,消费者直接执行。

cpp 复制代码
xqq@ubuntu-server:~/linux/module7/BlockingQueue$ ./cp
生产了一个任务:
消费(执行)了一个任务:我是一个下载任务...
生产了一个任务:
生产了一个任务:
消费(执行)了一个任务:我是一个下载任务...
生产了一个任务:
生产了一个任务:
生产了一个任务:
消费(执行)了一个任务:我是一个下载任务...
生产了一个任务:
生产了一个任务:
生产了一个任务:
消费(执行)了一个任务:我是一个下载任务...
生产了一个任务:
消费(执行)了一个任务:我是一个下载任务...
生产了一个任务:
消费(执行)了一个任务:我是一个下载任务...
生产了一个任务:

为什么生产者消费者模型效率高?

生产和消费因为加锁是串行的:生产时不能消费,消费时不能生产。那为什么还说它高效?

关键点:

  • 消费一个任务分两阶段:取出任务 (快)和执行任务(慢)

  • 生产一个任务也分两阶段:获取任务 (慢)和放入队列(快)

真正耗时的不是队列操作,而是:

角色 耗时阶段
生产者 获取任务(如从网络读取)
消费者 处理任务(执行具体逻辑)

所以:

  • 生产者在获取任务 时,消费者可以并发处理队列中已有的任务

  • 消费者在处理任务 时,生产者可以继续生产

加锁串行的只是"放入/取出"这一瞬间,真正耗时的重活都在锁外并行。这就是效率的来源。


扩展:多生产多消费

cpp 复制代码
int main()
{
    BlockQueue<int>* bq = new BlockQueue<int>();

    const int producer_num = 2;
    const int consumer_num = 3;

    pthread_t producers[producer_num];
    pthread_t consumers[consumer_num];

    for (int i = 0; i < producer_num; i++)
        pthread_create(&producers[i], nullptr, ProducerRoutine, bq);

    for (int i = 0; i < consumer_num; i++)
        pthread_create(&consumers[i], nullptr, ConsumerRoutine, bq);

    for (int i = 0; i < producer_num; i++)
        pthread_join(producers[i], nullptr);

    for (int i = 0; i < consumer_num; i++)
        pthread_join(consumers[i], nullptr);

    return 0;
}

为什么天然支持多生产多消费?

因为锁和条件变量把互斥和同步都封装在了队列内部,外部线程不用关心协调问题。

每个线程只需调用 EqueuePop,锁和条件变量会自动处理竞争、阻塞和唤醒。多生产多消费不过是同时有更多线程调用这两个函数而已。


总结

阻塞队列通过锁 + 条件变量,把"互斥"和"同步"封装在内部。生产者消费者模型的核心价值不是队列操作本身,而是解耦后让"获取任务"和"处理任务"这两个耗时阶段并行执行。从传整数到传对象到传函数对象,最终演进成任务队列的雏形,为线程池打下基础。

对条件变量的简单面向对象封装:

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"
namespace CondModule
{
    class Cond
    {
    public:
        Cond()
        {
            pthread_cond_init(&_cond, nullptr);
        }
        ~Cond()
        {
            pthread_cond_destroy(&_cond);
        }
        void Wait(MutexModule::Mutex &lock)
        {
            int n = pthread_cond_wait(&_cond, lock.Get());
            (void)n;
        }
        void Signal()
        { // 唤醒在定条件变量下等待的一个线程
            int n = pthread_cond_signal(&_cond);
            (void)n;
        }
        void Broadcast()
        {
            int n = pthread_cond_broadcast(&_cond);
            (void)n;
        }

    private:
        pthread_cond_t _cond;
    };
}

4-5 POSIX 信号量

POSIX 信号量和 System V 信号量作用相同,都是用于同步操作,达到无冲突地访问共享资源的目的。但 POSIX 可以用于线程间同步。关于systemv信号量在这篇文章中有详细的说明进程间通信:深入 System V IPC:共享内存、消息队列与信号量-CSDN博客

两个重要概念

在讲信号量之前,先回顾两个关键概念:

资源整体使用 vs 分块使用:

  • 资源整体使用 时,采用二元信号量,其本质就是互斥

  • 资源分块使用 时,采用多元信号量

由于多个线程访问资源是将资源分成小块来访问的,每块资源只有一个线程访问,于是整块资源就不会访问冲突了。

而我们之前基于条件变量构建的阻塞队列模型,其实就是把阻塞队列整体使用,所以我们要进行加锁。这种队列,我们不能在插入的同时取。

而今天我们如果将资源分块使用,此时就可以放多个线程来访问。但我们要做的就是:

  • 不要让太多线程进来

  • 不要让多个线程同时访问同一个资源

也就是说,当线程想要访问资源时先申请信号量,申请成功就是对资源的预定,于是就一定有一个资源是给这个线程留着的。

申请信号量有要求:都要先看到信号量。也就是说,信号量本身就是一个共享资源。

信号量本质就是一个计数器

  • 申请信号量成功 → --(P 操作)

  • 释放信号量 → ++(V 操作)

  • P/V 都被要求是原子的

要求一"不要太多线程进来"我们可以做到了:如果信号量超过上限就申请失败。

那怎么确保第二个条件也满足呢?怎么确保多个线程访问不同的位置呢?接下来就要结合具体的场景了。

总结:信号量就是对资源的预定机制,描述的就是临界资源的数目。


基于环形队列的生产消费模型

环形队列采用数组 模拟,用模运算 来模拟环状特性(也可以用链表,但比较麻烦)。

环形结构起始状态和结束状态都是一样的(都是 head 指针 == tail 指针),不好判断为空或者为满。所以可以通过:

但是我们现在有信号量这个计数器,就可以很简单地进行多线程间的同步过程。

追逐游戏模型

假设今天玩一个追逐游戏:tail 放数据,head 拿数据。也就是:

  • head 是消费者

  • tail 是生产者

只有当生产者放了数据,消费者才能拿

当 head 和 tail 同时指向同一个位置时:

  • 为空时:要先让 tail 放数据,因为此时没数据消费不了,于是让 tail 一直生产

  • 为满时:tail 生产满时,tail 和 head 又指向同一个位置,此时就不能继续放数据了,不然会覆盖,此时就要让 head 消费数据

消费又绕一圈,数据又全空了,于是循环往复。

四条约定

于是我们约定:

  1. 数据为空时 ,两个指针指向同一个位置,此时必须要求生产者生产

  2. 数据放满了 ,两个指针又指向同一个位置,此时必须要求消费者消费

  3. 生产者不能把消费者套一个圈以上

  4. 消费者不能超过生产者

所以我们只要不访问同一个位置,就可以同时进行生产和消费。

那么什么时候我们才会指向同一个位置呢?其实就是队列满了或者为空的时候

  • 为空时:只能(互斥)让生产者先运行,这个过程只能是互斥的,不能正在生产时消费者进来了

  • 为满时:只能让消费者先运行,这个过程就是同步过程

结论

  • 在环形队列不为空且不为满时,生产和消费可以同时运行

  • 环形队列为空或者为满时,生产和消费要互斥和同步

怎么保证上面四个约定?------信号量

信号量本质是一个计数器,表示临界资源的数目。

对于生产者,它最关心的是环形队列的空位置 ;而作为消费者,它最关注的是有效数据位置

于是我们就可以定义两个信号量来表示:

一开始 N 个空位,0 个数据。

int pstep 表示生产位置,int cstep 表示消费位置。

生产者:

  1. 先对 sem_blank 做 P 操作,即申请空格子(sem_blank--

  2. 然后在 pstep 位置生产

  3. 接着 ++pstep,然后 pstep %= N

  4. 由于放了数据,于是要对 sem_data 进行 V 操作(sem_data++

消费者:

  1. 先申请有效数据,也就是对 sem_data 进行 P 操作(sem_data--

  2. 然后在 cstep 位置进行消费

  3. 接着 cstep++,然后 cstep %= N

  4. 由于数据被拿走,空位空出来了,于是就要对 sem_blank 进行 V 操作(sem_blank++

两个问题的解释

信号量 PV 操作是原子的:申请成功继续运行,申请失败,申请线程会被阻塞。

问题一:队列为空时为什么生产者先运行?

由于生产者的 sem_blank 很多,可以申请成功;而 sem_data 为零,申请失败就阻塞,所以生产者就先运行。

问题二:队列为满时为什么消费者先运行?

由于生产者的 sem_blank 为零,申请失败就阻塞;而 sem_data 很多,消费者可以申请成功,所以消费者就先运行。

问题三:如何保证消费者不超过生产者?

假设消费者更快,即使队列为满时,sem_data 也会从 N 慢慢减到 0,于是对 sem_data 做 P 操作就会被阻塞,于是约定三"消费者不能超过生产者"也满足了。

问题四:不为空不为满时为什么能并行?

当队列里面有一定数据但不为满时,sem_datasem_blank 都有值,于是两者对各自的信号量都可以进行 P 操作,于是生产者消费者就可以同时进来了,也就是并行了。

问题五:为什么天然访问不同位置?

一开始消费者和生产者的 step 都为 0,而由于一开始只能生产者先进行 P 操作进入,于是他先对自己的 pstep++ 了,此时 cstep 还是为零。只有当 pstepsem_data 进行 V 操作后,消费者才可以进入,于是两者天然就访问不同位置了。


信号量接口

初始化信号量:

cpp 复制代码
#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);

参数:

参数 说明
pshared 0 表示线程间共享,非零表示进程间共享
value 信号int sem_wait(sem_t *sem); // 信号量值减 1量初始值

返回值:0 成功,非零错误码。

销毁信号量:

cpp 复制代码
int sem_destroy(sem_t *sem);

等待信号量(P 操作):

cpp 复制代码
int sem_wait(sem_t *sem);   // 信号量值减 1

发布信号量(V 操作):

cpp 复制代码
int sem_post(sem_t *sem);   // 信号量值加 1

两个函数申请成功才会继续运行,否则阻塞等待。


代码实现

Sem.hpp

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <semaphore.h>
const int default_sem_value = 1;
namespace SemModule
{
    class Sem
    {
    public:
        Sem(unsigned int sem_value = default_sem_value)
        {
            sem_init(&_sem, 0, sem_value);
        }
        ~Sem()
        {
            sem_destroy(&_sem);
        }
        void P()
        {
            sem_wait(&_sem); // 原子的
        }

        void V()
        {
            sem_post(&_sem); // 原子的
        }

    private:
        sem_t _sem;
    };
}

RingQueue.hpp

cpp 复制代码
#pragma once
#include <vector>
#include <iostream>
#include <unistd.h>
#include "Sem.hpp"
#include "Mutex.hpp"

const static int gcap = 5;
template <class T>
class RingQueue
{
public:
    RingQueue(int cap = gcap)
        : _cap(cap), _rq(cap), _producer_step(0), _consumer_step(0), _blank_sem(cap), _data_sem(0)
    {
    }
    ~RingQueue()
    {
    }
    void Enqueue(const T &in) // 生产者
    {
        // 1.先申请空位置信号量
        _blank_sem.P();
        {
            MutexModule::LockGuard guard(_pmutex);
            // 2.生产
            _rq[_producer_step] = in;
            // 3.更新下标
            ++_producer_step;
            // 4.维持环形队列
            _producer_step %= _cap;
            // 5.通知消费者,写了一个数据
            _data_sem.V();
        }
    }
    void Pop(T *out) // 消费者
    {
        // 1.先数据信号量
        _data_sem.P();
        {
            MutexModule::LockGuard guard(_cmutex);
            // 2.消费
            *out = _rq[_consumer_step];
            // 3.更新下标
            ++_consumer_step;
            // 4.维持环形队列
            _consumer_step %= _cap;
            // 5.通知生产者,空了一个位置
            _blank_sem.V();
        }
    }

private:
    int _cap;
    std::vector<T> _rq;

    // 生产者
    SemModule::Sem _blank_sem; // 空位置
    int _producer_step;

    // 消费者
    SemModule::Sem _data_sem; // 数据
    int _consumer_step;

    // 我们需要两把锁,一个用来维护生产者之间的互斥关系,一把锁维护消费者之间的关系
    MutexModule::Mutex _pmutex;
    MutexModule::Mutex _cmutex;
};

Main.cc

cpp 复制代码
#include "RingQueue.hpp"
#include <functional>
void *ConsumerRoutine(void *args)
{
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
    while (true)
    {
        // sleep(1);//生产快消费慢时,队列先满,然后消费一个生产一个,
        int data = 0;
        rq->Pop(&data);
        std::cout << "消费一个数据:" << data << std::endl;
    }
    return nullptr;
}
// 去掉 sleep 后,生产者和消费者全速并发,环形队列 + 信号量本身开销极小,所以速度非常快
void *ProducerRoutine(void *args)
{
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
    int data = 0;
    while (true)
    {
        // sleep(1);//生产慢消费快时,队列基本保持空,生产一个消费一个。
        rq->Enqueue(data);
        std::cout << "生产了一个数据:" << data++ << std::endl;
    }
    return nullptr;
}
// int main()
// {
//     RingQueue<int> *rq = new RingQueue<int>();
//     pthread_t producer, consumer;

//     pthread_create(&producer, nullptr, ProducerRoutine, rq);
//     pthread_create(&consumer, nullptr, ConsumerRoutine, rq);

//     pthread_join(producer, nullptr);
//     pthread_join(consumer, nullptr);
//     return 0;
// }

// 交易场所确定是环形队列,两种角色确定,三种关系也确定(由于是单生产单消费,所以生产者之间和消费者之间关系不需要维护,而生产者与消费者关系已经用信号量完成了互斥与同步)
// 单生产单消费时,生产者之间、消费者之间没有竞争,无需维护。生产者与消费者之间的同步由信号量完成。由于双方操作的是不同下标,天然不会访问同一个位置,所以不需要额外互斥。
// 但改成多生产多消费后,生产者之间、消费者之间会竞争同一下标,必须加锁维护互斥。这就是生产者消费者模型的指导思想。

int main()
{
    RingQueue<int> *rq = new RingQueue<int>();

    const int producer_num = 2; // 两个生产者
    const int consumer_num = 3; // 三个消费者

    pthread_t producers[producer_num];
    pthread_t consumers[consumer_num];

    // 创建生产者线程
    for (int i = 0; i < producer_num; i++)
    {
        pthread_create(&producers[i], nullptr, ProducerRoutine, rq);
    }

    // 创建消费者线程
    for (int i = 0; i < consumer_num; i++)
    {
        pthread_create(&consumers[i], nullptr, ConsumerRoutine, rq);
    }

    // 等待所有线程
    for (int i = 0; i < producer_num; i++)
    {
        pthread_join(producers[i], nullptr);
    }

    for (int i = 0; i < consumer_num; i++)
    {
        pthread_join(consumers[i], nullptr);
    }

    return 0;
}

补充说明:关于加锁顺序

方案对比

cpp 复制代码
// 方案 1:先加锁,再申请信号量
_pmutex.Lock();
_blank_sem.P();

// 方案 2:先申请信号量,再加锁
_blank_sem.P();
_pmutex.Lock();

方案 1 的问题

先加锁,再申请信号量:

cpp 复制代码
生产者 A:lock 成功 → 申请信号量
生产者 B:等锁...
生产者 C:等锁...
  • 生产者之间竞争一把锁,只有一个能拿到锁

  • 拿到锁的线程才去申请信号量

  • 如果信号量申请失败(比如队列满),它拿着锁阻塞

  • 其他生产者既拿不到锁,也申请不了信号量,只能干等

  • 锁被白白占用,效率低


方案 2 的优势

先申请信号量,再加锁:

cpp 复制代码
生产者 A:申请信号量成功 → lock → 生产 → unlock
生产者 B:申请信号量成功 → lock(等 A 解锁)→ 生产
生产者 C:申请信号量失败 → 阻塞等待(不占锁)
  • 线程先对信号量资源进行竞争,买到票才有资格排队

  • 申请信号量成功,说明资源已预定,一定有位子

  • 申请信号量失败,线程阻塞,此时不持有锁,不占用锁资源

  • 其他线程可以继续申请信号量、竞争锁,并发度更高


电影院类比

方案 2 相当于:先对票数资源进行竞争,买到票才有资格去排队。


核心原因


总结一下:

方案 2 更高效,因为线程先对信号量资源进行竞争瓜分(信号量 P/V 本身是原子的,不会超申请),申请成功再串行申请锁。这样:

  • 申请信号量失败时,线程阻塞,不占用锁

  • 其他线程可以继续竞争信号量,把资源先准备好

而方案 1 先申请锁,申请失败或信号量申请失败时,线程持有锁阻塞,其他线程只能干等,效率低。

单生产单消费 vs 多生产多消费

单生产单消费时,生产者之间、消费者之间没有竞争,无需维护。生产者与消费者之间的同步由信号量完成。由于双方操作的是不同下标,天然不会访问同一个位置,所以不需要额外互斥。

但改成多生产多消费后,生产者之间、消费者之间会竞争同一下标,必须加锁维护互斥。这就是生产者消费者模型的指导思想。

重新理解信号量

为什么信号量不需要 while 判断条件?

之前用条件变量实现生产者消费者模型时,需要用 while 判断队列是否为满或空:

cpp 复制代码
while (IsFull())
    pthread_cond_wait(&_full_cond, &_lock);

不满足条件就在条件变量上阻塞等待。

但在信号量实现的生产者消费者模型中,不需要判断条件

cpp 复制代码
_blank_sem.P();   // 申请空位
// 直接生产

为什么?

信号量本身就代表了临界资源的数目。信号量申请失败,就代表条件不满足。

  • 申请成功 → 一定有一个资源属于这个线程

  • 申请失败 → 条件不满足(没有空位、没有数据)

所以,信号量把"临界资源是否存在、是否就绪"的判断,以原子形式呈现在访问临界资源之前 。条件是否满足,信号量自己就能判断出来,不需要线程再写 while 检查。


条件变量 vs 信号量


为什么环形队列用信号量,阻塞队列用锁 + 条件变量?

原因:

  • 环形队列 :固定大小,资源可以拆分成一个个位置,每个位置只有一个线程访问。信号量天然适合描述"有多少个空位、多少个数据"。

  • 阻塞队列 :把队列当整体使用,需要锁保护整体,条件变量负责同步。

今天我们使用信号量实现环形队列,如果用锁 + 条件变量也一定能实现------无非是锁提供互斥,条件变量提供同步。

但这里用信号量更自然,因为:

资源可以拆分时,考虑用信号量;资源要整体使用时,用锁 + 条件变量。


二元信号量 = 互斥锁

当我们把信号量值设为 1 时:

cpp 复制代码
sem_blank = 1
sem_data = 0

环形队列退化成容量为 1 的队列:

  • 同一时刻只能有一个线程生产或消费

  • sem_blank 的 P/V 操作等价于加锁/解锁

所以:

二元信号量本质上就是互斥锁。

N=1 的环形队列,就退化成了互斥模型。


总结

信号量把"条件是否满足"封装进了 P 操作------成功即有资源,失败即阻塞。资源可拆分用信号量,整体使用用锁 + 条件变量。当信号量设为 1,环形队列退化成互斥模型,二元信号量等价于互斥锁。这就是从"锁 + 条件变量"到"信号量"的思维跨越。

相关推荐
Json____1 小时前
从零构建在线拍卖系统:Java 全栈开发实践
java·开发语言·管理系统·it学习·wwwoop.com
jearry1 小时前
WebView2 原生拖放的桥接之道:拆解 yyzTools 的 drop-zone.js
前端·c++
2601_966949651 小时前
批量 K 线不只是提速:因子研究中的数据质量、复权与回测偏差
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
matlab代码1 小时前
基于matlab水果识别系统(西红柿、香蕉、梨、青椒)【源码64期】
开发语言·matlab
王的宝库1 小时前
Gin + GORM
开发语言·golang·gin
AI职业加油站1 小时前
2026大数据运维行业趋势复盘:大数据运维工程师赋能发展
大数据·运维·人工智能·学习·数据分析·职场发展
零基础的修炼1 小时前
CUDA知识汇总
开发语言·c++·编辑器
妙码生花1 小时前
golang 应用服务端部署(使用 systemd 服务)
开发语言·人工智能·后端·golang·node.js·php·gin
欧特克_Glodon1 小时前
OpenCV计算机视觉开发入门与实践<三十八>:图像添加数字水印
c++·人工智能·opencv·计算机视觉