Linux系统篇47——线程(十二) POSIX 信号量与环形队列,把判空判满提前到访问之前


📚 本文收录于「流浪」的系列专栏

🐧 Linux系统 ⚙️ C++
📊 数据结构与算法 🐍 Python
🔗 LangChain & LangGraph 🗄️ MySQL 数据库
🌿 Git 工具 🌐 计算机网络
🤖 LLM 💯 大厂面试、八股
📚 学习筑基专栏

🏠 博客主页:流浪 | 📝 原创首发于 CSDN


前言: 线程(十一)用一把互斥锁加两个条件变量把阻塞队列写完整 ,判空判满发生在临界区里面。本篇换第二种实现------POSIX 信号量 :计数器数的就是资源剩余份数,申请失败就等于条件不成立,判断被挪到访问临界资源之前,还自带原子性。最后落到环形队列版生产者消费者,单生产单消费一把锁都不用。


一、POSIX 信号量是什么

1.1 一种新的标准

1. 和 System V 是同类角色
  1. POSIX 是一种新的标准,它和 System V 扮演的是同类角色,都提供信号量这套同步机制
  2. System V 那一版在《Linux系统篇28------通信(五):信号量专篇》里拆过底朝天:三件套加自定的 union semun,全挂在内核大表上
  3. POSIX 相比 System V 更常用,写多线程代码默认选它
2. 手册给两套接口的定位
  1. 手册口径:System V 信号量是更老的 API,POSIX 信号量提供的是**更简单、设计更好的接口**
  2. 同一页手册也补了实话:POSIX 可用面不如 System V 广,老系统上更常见的是后者
  3. 所以「淘汰」说的是选择,不是删除,内核从没把 System V 摘掉

1.2 System V 为什么被淘汰

1. 复杂度摆在那
  1. 创建、控制、操作是 semget、semctl、semop 三套系统调用,各记一套参数,还得惦记 SEM_UNDO------异常退出不还就是死锁隐患,这些篇 28 都演示过
  2. POSIX 这边四个函数走完全程:sem_init、sem_wait、sem_post、sem_destroy,本体就是一个 sem_t 变量
2. 本体就是一个变量
  1. sem_t 不需要先去内核创建对象拿 id,直接当全局变量或类的成员变量用
  2. 接口变简单的本质,是把使用门槛从三套系统调用压成一个变量的四个方法

1.3 出现时间与发展历史

1. 时间线
  1. System V 在前,出自 AT&T 的 System V UNIX 一脉,三样 IPC 都在这条线上
  2. POSIX 信号量在后,1993 年获批的 POSIX.1b 实时扩展把它标准化,2001 年并入 POSIX.1 基础规范,冠 POSIX 前缀就是为了和更古老的 System V 区分开
2. Linux 侧的落地
  1. Linux 2.6 之前只支持无名的线程间信号量,2.6 内核配上提供 NPTL 的 glibc 之后才算完整实现,NPTL 在《Linux系统篇42------线程(七)pthread库管理线程的工作流》里拆过
  2. 头文件是 semaphore.h,编译加 -pthread;信号量分有名、无名两种形态,跨进程用有名的,多线程用无名的就够

二、信号量在数什么

2.1 从订票说起

1. 买票就是预定
  1. 打个比方还是那家电影院:座位被所有观众共享,看电影先买票,买票就是对座位这一临界资源的预定
  2. 票卖一张少一张,还有多少票,就有多少座位没被预定
  3. 完整版篇 28 铺过,信号量干的就是售票处的活
2. 计数器统计的是剩余份数
  1. 信号量这个计数器,数的就是临界资源还剩几份
  2. 大于 0 意味着还能预定,等于 0 意味着一份都预定不到
  3. 资源份数、计数器数值、可预定次数,三者始终是一回事

2.2 手册定义

1. 一个不许减到负数的整数
  1. 手册口径:信号量是一个整数,它的值永远不被允许低于零
  2. 能做的操作只有两种:sem_post 让值加一,sem_wait 让值减一;值为零时 sem_wait 阻塞,直到值重新大于零才能完成这次减一
2. 减一和加一各是什么语义
  1. 减一是申请资源,剩余份数少一份;加一是释放资源,用完还回去多一份
  2. 申请和释放对应教科书上的 P 操作和 V 操作,篇 28 起就沿用这对记号

2.3 普通信号量与二元信号量

1. 按数的份数分两种
  1. 相对于数 N 份相同资源的普通信号量,还有一种信号量是二元信号量,数的是只有一份的资源
  2. 普通信号量初始值是 N,二元信号量初始值是 1,取值只在 0 和 1 之间变化
2. 二元与互斥锁
  1. 初始值为 1 的信号量,行为上和互斥锁非常像:一次只放一个执行流进去
  2. 但两者不等价------信号量没有持有者概念,谁都能释放;互斥锁有,不是加锁者解锁会被拦下
  3. 这组区别篇 28 拆过完整版,记住结论:像,但不等价

三、两种使用场景

3.1 整体使用

1. 资源当一个整体进出
  1. 第一种场景:把资源当成一个整体使用,执行流要么整体拿走、要么整体还回
  2. 阻塞队列就是典型,篇 46 的阻塞队列被一把互斥锁全包,判空判满都在锁里面完成
2. 配套技术是互斥加同步
  1. mutex 负责动资源这一步的互斥:进临界区改数据,一次只许一个
  2. 二元信号量负责等资源这一步的同步:资源不可用时让申请者睡回去等,篇 46 的条件变量就是这个角色

3.2 分块使用

1. 资源划成格子分别预定
  1. 第二种场景:把资源按不同块分批使用,划成 N 个独立格子,每个格子单独预定
  2. 格子之间互不干扰,生产和消费可以落在不同格子上并行,环形队列就是典型结构
2. 配套技术是信号量本身
  1. 一格一份资源,信号量直接数格子:数空格子的一个,数有数据格子的一个
  2. 整体使用选 mutex 加二元信号量,分块使用选信号量,两种场景两套工具

四、P 与 V 的原子性

4.1 申请与释放的约定

1. 所有线程先看到同一个信号量
  1. 多线程使用资源的第一步,是所有线程都能看到同一个信号量 sem,它本身是个共享变量
  2. 申请的约定是 P 操作:计数器减一,落到接口上是 sem_wait;释放的约定是 V 操作:计数器加一,落到接口上是 sem_post
2. 申请失败怎么办
  1. 计数器值为零时申请失败,条件不满足,申请者阻塞在信号量上等,没有进临界区
  2. 别的线程释放资源触发 V 操作,值回到大于零,等待者被唤醒接着完成自己的减一

4.2 信号量本身也是临界资源

1. 保护者也需要被保护
  1. 计数器被所有线程同时读写,所以信号量这个变量本身也是临界资源
  2. 线程(八)里的 ticket-- 就是反面教材:一句减一被编译成三步,切走一次就错一次
2. P 和 V 必须是原子的
  1. 所以 P: --、V: ++ 这两步必须是原子操作,这是信号量能立住的前提
  2. 手册口径:sem_wait 在值大于零时减一立即返回,值为零时阻塞;sem_post 完成加一后,若值因此大于零,唤醒一个阻塞在 sem_wait 里的线程
  3. 原子性由库函数在内部打包保证,使用者拿到的是一步到位的语义

五、POSIX 信号量的接口

5.1 sem_init

1. 原型与参数
c 复制代码
int sem_init(sem_t *sem, int pshared, unsigned int value);
// sem:要初始化的信号量,本体就是一个 sem_t 变量
// pshared:0 表示线程间共享,非 0 表示进程间共享
// value:计数器初始值,也就是资源的初始份数
  1. pshared 为 0 时,信号量放在所有线程都可见的位置,全局或堆上都行;非 0 时必须放进共享内存,进程间才能共同操作
  2. 重复 init 是未定义行为;value 超过 SEM_VALUE_MAX 返回 EINVAL,系统不支持进程间共享返回 ENOSYS,成功返回 0、失败返回 -1 并设置 errno

5.2 sem_wait 与两个变体

1. 原型与语义
c 复制代码
int sem_wait(sem_t *sem);       // 申请资源:计数器减 1,值为 0 就阻塞
int sem_trywait(sem_t *sem);    // 非阻塞版:申请不到立刻返回,errno 置 EAGAIN
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);  // 带绝对超时的等待
  1. sem_wait 的三态:值大于零,减一立即返回;值为零,阻塞等;被信号打断,返回 EINTR
  2. EINTR 不是真正的失败,值没减、资源没拿到,重试一次就行
  3. sem_timedwait 传绝对时刻,到点没申请到就返回 ETIMEDOUT,怕睡死过去的场景用它

5.3 sem_post 与 sem_destroy

1. 原型与语义
c 复制代码
int sem_post(sem_t *sem);       // 释放资源:计数器加 1,唤醒一个等待者
int sem_destroy(sem_t *sem);    // 销毁无名信号量,用完且无人等待时调用
  1. sem_post 完成加一后,若值因此大于零,唤醒一个阻塞在 sem_wait 中的线程;一次 post 只唤醒一个,要叫醒几个就 post 几次
  2. 手册口径:sem_post 是异步信号安全的,可以在信号处理函数里安全调用
2. destroy 的雷区
  1. 只销毁 sem_init 初始化的信号量;还有线程阻塞在它上等待时销毁,是未定义行为
  2. 所在内存要释放前先 destroy 再放内存,顺序反了在某些实现上会漏资源

5.4 封装

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

static const int NUM=1;

class SemModule
{
    public:
    SemModule(int defaultnum=NUM)
    {
        sem_init(&_sem,0,defaultnum);
    }

    void P()
    {
        sem_wait(&_sem);//自动减减
    }

    void V()
    {
        sem_post(&_sem);//自动加加
    }
    ~SemModule()
    {
        sem_destroy(&_sem);
    }
private:
    sem_t _sem;
};

六、为什么信号量版不用再判条件

6.1 条件变量版的判断在临界区里面

1. 篇 46 的骨架回顾
  1. 阻塞队列版的生产者流程:加锁、判满、满了就 wait、不满就写入、唤醒、解锁
  2. 判空判满发生在持有互斥锁的状态下,是临界区内部的动作,判断针对的是队列这一整块资源

6.2 信号量把判断提前到访问之前

1. 申请失败就是条件不满足
  1. 信号量是用来描述临界资源数目的,申请失败就是条件不满足,这两件事是同一件事
  2. 生产者关心有没有空位,就让一个信号量数空位;消费者关心有没有数据,就让一个信号量数数据
  3. P 操作挡在访问临界资源之前,值不够就睡在信号量上,人根本没走到临界区门口
2. 判断被原子化地前移了
  1. 信号量是把对临界资源是否存在、是否就绪等条件,以原子性的形式呈现在访问临界资源之前就判断了
  2. 判断和申请合成一个原子动作,没有「判完到进门之间条件变了」的窗口
  3. 篇 46 里判空判满的 while 循环,在这一版里没有对应物

6.3 醒来之后不用再判

1. 信号量的值本身就是条件

被唤醒意味着值已经回到大于零,条件已经成立,直接进临界区干活,不存在唤醒打偏的问题


七、基于环形队列的生产者消费者

7.1 单生产者消费者模型

1 环形结构
2 成员变量
cpp 复制代码
    int _cap;//信号量
    std::vector<T> _rq;//队列 用数组模拟 方便实现环形

    SemModule _c_sem;//封装过的信号量   消费者
    SemModule _p_sem;//               生产者

    Mutex _c_mutex;//封装过的锁多		  消费者
    			   //生产者会用到    
    			        
    Mutex _p_mutex;//                 生产者
    int _c_step;//                    消费者下标 
    int _p_step;//                    生产者下标
3 构造
cpp 复制代码
static const int Space = 5;

    RingQueue()
        : _cap(Space)//信号量
        , _rq(_cap)//队列容量
        , _c_step(0)//消费者初始下标
        , _p_step(0)//生产者初始下标
        , _c_sem(0)//消费者信号量(刚开始没有数据)
        , _p_sem(_cap)//生产者信号量(刚开始全是空位)
    {}
4 生产者入队
cpp 复制代码
void Equeue(const T &in)
    {
        // 生产者
        // 1. 申请信号量,空位置信号量
        //先申请信号量(P)是检查 "有没有空位",没有就先在门口排队、不占互斥锁,从而避免 "占着锁等空位" 造成的死锁。
        _blank_sem.P();
        {
            LockGuard lockguard(_pmutex);
            // 2. 生产
            _rq[_p_step] = in;
            // 3. 更新下标
            ++_p_step;
            // 4. 维持环形特性
            _p_step %= _cap;
        }
        _data_sem.V();
    }
5 消费者拿数据
cpp 复制代码
void Pop(T *out)
    {
        // 消费者
        // 1. 申请信号量,数据信号量
        _data_sem.P();
        {
            LockGuard lockguard(_cmutex);
            // 2. 消费
            *out = _rq[_c_step];
            // 3. 更新下标
            ++_c_step;
            // 4. 维持环形特性
            _c_step %= _cap;
        }
        _blank_sem.V();
    }
6 完整落地
cpp 复制代码
#include <iostream>
#include <vector>
#include "Sem.hpp"

static const int gcap = 5; 
using namespace SemModule;

template <typename T>
class RingQueue
{
public:
    RingQueue(int cap = gcap)
        : _cap(cap),
          _rq(cap),
          _blank_sem(cap),
          _p_step(0),
          _data_sem(0),
          _c_step(0)
    {
    }
    void Equeue(const T &in)
    {
        // 生产者
        // 1. 申请信号量,空位置信号量
        _blank_sem.P();
        {
            // 2. 生产
            _rq[_p_step] = in;
            // 3. 更新下标
            ++_p_step;
            // 4. 维持环形特性
            _p_step %= _cap;
        }
        _data_sem.V();
    }
    void Pop(T *out)
    {
        // 消费者
        // 1. 申请信号量,数据信号量
        _data_sem.P();
        {
            // 2. 消费
            *out = _rq[_c_step];
            // 3. 更新下标
            ++_c_step;
            // 4. 维持环形特性
            _c_step %= _cap;
        }
        _blank_sem.V();
    }

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

    // 生产者
    Sem _blank_sem; // 空位置
    int _p_step;
    // 消费者
    Sem _data_sem; // 数据
    int _c_step;

};

main文件
void *consumer(void *mes)
{
    RingQueue<int> *bq = static_cast<RingQueue<int> *>(mes);
    while (true)
    {
        sleep(1);
        int i = 0;
        bq->Pop(&i);
        std::cout << "我是客户端,我拿到了一份数据" << i << std::endl;
    }

    return nullptr;
}

int data = 1;
void *productor(void *mes)
{
    RingQueue<int> *bq = static_cast<RingQueue<int> *>(mes);
    while (true)
    {
        sleep(1);
        std::cout << "我是服务端,我生产了一份数据" << std::endl;

        bq->Equeue(data);

        data++;
    }
    return nullptr;
}

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

    pthread_t c[1], p[1];

    pthread_create(&p[0], nullptr, productor, bq);

    pthread_create(&c[0], nullptr, consumer, bq);

    pthread_join(c[0], nullptr);
    pthread_join(p[0], nullptr);

    delete bq;
    return 0;
}

7.2 多生产者消费者模型

1 mutex封装
cpp 复制代码
#include <iostream>
#include <pthread.h>

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

        void Lock()
        {
            pthread_mutex_lock(&_mutex);
        }

        void UnLock()
        {
            pthread_mutex_unlock(&_mutex);
        }

        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }

    private:
        pthread_mutex_t _mutex;
    };

    class LockGrund
    {
    public:
        LockGrund(Mutex &mutex) : _mutex(mutex)
        {
            _mutex.Lock();
        }

        ~LockGrund()
        {
            _mutex.UnLock();
        }

    private:
        Mutex &_mutex;
    };
}
2 参数多两把锁
cpp 复制代码
    int _cap;
    std::vector<T> _rq;

    SemModule _c_sem;
    SemModule _p_sem;

    Mutex _c_mutex;
    Mutex _p_mutex;
    int _c_step;
    int _p_step;
3 生产者入队 消费者出队模块加入锁
cpp 复制代码
void Equeue(const T &in)
    {
        // 生产者
        // 1. 申请信号量,空位置信号量
        _blank_sem.P();
        {
            LockGuard lockguard(_pmutex);
            // 2. 生产
            _rq[_p_step] = in;
            // 3. 更新下标
            ++_p_step;
            // 4. 维持环形特性
            _p_step %= _cap;
        }
        _data_sem.V();
    }
    void Pop(T *out)
    {
        // 消费者
        // 1. 申请信号量,数据信号量
        _data_sem.P();
        {
            LockGuard lockguard(_cmutex);
            // 2. 消费
            *out = _rq[_c_step];
            // 3. 更新下标
            ++_c_step;
            // 4. 维持环形特性
            _c_step %= _cap;
        }
        _blank_sem.V();
    }
4 主函数逻辑
cpp 复制代码
struct threaddata
{
    RingQueue<int> *rq;
    std::string name;
};

void *consumer(void *args)
{
    threaddata *td = static_cast<threaddata*>(args);

    while (true)
    {
        sleep(3);
        // 1. 消费任务
        int t = 0;
        td->rq->Pop(&t);

        // 2. 处理任务 -- 处理任务的时候,这个任务,已经被拿到线程的上下文中了,不属于队列了
        std::cout << td->name << " 消费者拿到了一个数据:  " << t << std::endl;
        // t();
    }
}

int data = 1;

void *productor(void *args)
{
    threaddata *td = static_cast<threaddata*>(args);
    
    while (true)
    {
        sleep(1);
        // sleep(2);
        // 1. 获得任务
        // std::cout << "生产了一个任务: " << x << "+" << y << "=?" << std::endl;
        std::cout << td->name << " 生产了一个任务: " << data << std::endl;

        // 2. 生产任务
        td->rq->Equeue(data);

        data++;
    }
}

int main()
{
    // 扩展认识: 阻塞队列: 可以放任务吗?
    // 申请阻塞队列
    RingQueue<int> *rq = new RingQueue<int>();

    // 构建生产和消费者
    // 如果我们改成多生产多消费呢??
    // 单单: cc, pp -> 互斥关系不需要维护,互斥与同步
    // 多多:cc, pp -> 之间的互斥关系!
    pthread_t c[2], p[3];

    threaddata *td = new threaddata();
    td->name = "cthread-1";
    td->rq = rq;
    pthread_create(c, nullptr, consumer, td);

    threaddata *td2 = new threaddata();
    td2->name = "cthread-2";
    td2->rq = rq;
    pthread_create(c + 1, nullptr, consumer, td2);

    threaddata *td3 = new threaddata();
    td3->name = "pthread-3";
    td3->rq = rq;
    pthread_create(p, nullptr, productor, td3);

    threaddata *td4 = new threaddata();
    td4->name = "pthread-4";
    td4->rq = rq;
    pthread_create(p + 1, nullptr, productor, td4);

    threaddata *td5 = new threaddata();
    td5->name = "pthread-5";
    td5->rq = rq;
    pthread_create(p + 2, nullptr, productor, td5);

    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);
    pthread_join(p[2], nullptr);

    return 0;
}

八、文末面试题

8.1 推导题

1. 信号量版生产者消费者为什么不需要 while 再判条件?

答(推导):条件变量版判的是队列整体状态,唤醒可能打偏,醒来条件未必成立,所以要 while 再判。信号量版的条件就是计数器的值本身,P 操作把判断和申请合成一个原子动作,申请失败直接睡在信号量上;被唤醒意味着值已经大于零,条件已经成立,不存在醒了条件又不成立的路径。

2. _p_sem 和 _c_sem 的初值为什么是容量和 0?

答(推导):初值代表初始时刻资源的份数,开场全是空格子、没有数据,所以数空位的 _p_sem 初值等于容量,数数据的 _c_sem 初值为 0。此后生产是 p 减一、c 加一,消费反过来,两个计数的和始终等于容量,队列状态被两个数字分工记着。

3. 单生产者单消费者的环形队列为什么一把锁都不用?

答(推导):需要加锁的是被多方读写的变量,而生产者下标和消费者下标各只有一个写者,单写者变量无竞争。剩下的风险是同一格子同时被读写,这被两个信号量挡住:写格子的前提是它被计在空位里,读格子的前提是它被计在数据里,两个集合不相交,读写永不相遇。

4. 多生产多消费时为什么恰好是两把锁,合并成一把行不行?

答(推导):多生产会踩生产者下标,多消费会踩消费者下标,各需要一把锁把写入加前移锁成整体,正好对应两条同类竞争关系。合并成一把会死锁:生产者拿锁后 P 空位失败睡下,锁还攥在手里;消费者进不了临界区,无法消费也就腾不出空位,双双卡死。两把锁不是省并发度,是正确性的硬要求。

5. 信号量本身也是临界资源,P 和 V 的原子性由谁保证?

答(推导):由库函数在内部打包保证。sem_wait 把读值、判断、减一、必要时挂起实现成不可分割的整体,sem_post 把加一和唤醒打包成一体。使用者在接口外看不到中间状态,也就不需要自己再加锁去保护计数器,原子性在库这一层被消化掉了。

6. 连续 sem_post 多次但没有人等待,这些释放会丢吗?

答(推导):不会。sem_post 每次把计数器加一,值会累积下来,之后来的 sem_wait 直接发现值大于零,减一通过,一次都不浪费。对照条件变量:pthread_cond_signal 在没有等待者时信号直接丢失,不记数,所以条件变量必须配 while 重判,而信号量的值本身就是账本。

8.2 真题

1. 阻塞队列的生产者消费者问题中,empty、full、mutex 三个信号量的初值应如何设置?

答(推导 · 已对照解析,转述):题目给容量为 10 的缓冲区,正确答案是 empty 等于 10、full 等于 0、mutex 等于 1。empty 数空缓冲区,初始全空所以等于容量;full 数产品数量,初始没有产品所以为 0;mutex 是互斥信号量,保护缓冲区一次只被一个执行流操作,名额只有一个所以初值为 1。这和本篇的 blank_sem、data_sem 初值设定是同一套逻辑。

真题·转述自 51CTO 软考《信号量机制的核心概念与数学依据》

2. 九个生产者、六个消费者共享容量为 8 的缓冲区,互斥使用的信号量初始值应为多少?

答(推导 · 已对照解析,转述):正确答案是 1。互斥信号量保护的是缓冲区的整体访问权,任何时刻最多一个执行流在操作,名额数就是 1,跟生产者数量、消费者数量、缓冲区容量都没有关系。解析里专门纠了错选 6:把消费者个数当成互斥名额,属于同步互斥混为一谈。

真题·转述自 CSDN《2.3 进程同步》习题解析


💬 结语: 信号量把「有没有资源」的判断提前到访问之前并且原子化,环形队列再把资源拆到格子级,锁就省到了最少。评论区聊聊你写环形队列版时踩过的坑,觉得有用点个赞,Linux 系统篇持续更新。

相关推荐
咖丨喱2 小时前
【MMC Core + hc16 Host(WiFi SDIO 如何落到控制器)】
linux
等我调个Bug2 小时前
【Linux系统篇】权限
linux·服务器
~光~~3 小时前
【嵌入式linux学习】Cache 策略:Write-Through / Write-Back + Allocation
linux·学习·spring
峥无3 小时前
Linux NPTL线程:创建/终止/等待/分离/栈布局内核原理|线程下篇
linux·运维·线程
是jin奥4 小时前
Ubuntu 访问 Windows 共享目录
linux·运维·ubuntu
星恒随风4 小时前
Linux基础IO万字详解:从文件描述符fd到重定向、FILE缓冲机制及MiniShell升级
linux·运维·笔记·学习·状态模式
yunwei374 小时前
eBPF 入门开发实践教程一:Hello World,基本框架和开发流程
linux·后端·性能优化
小雪崩4 小时前
嵌入式学习 day65:Linux驱动进阶——pinctrl 子系统和 platform 驱动框架
linux·学习·驱动
wixzjsh5 小时前
嵌入式Linux系统启动与内核驱动开发全解
linux·运维·驱动开发