小此方头像
关注
Re:Linux系统篇(五十五)线程篇 · 八:为什么需要条件变量?从生活比喻、API 梳理到 pthread_cond 实战源码探究封面图

Re:Linux系统篇(五十五)线程篇 · 八:为什么需要条件变量?从生活比喻、API 梳理到 pthread_cond 实战源码探究

在这里插入图片描述

◆ 博主名称: 小此方-CSDN博客
大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)

文章目录


概要&序論

  Hello大家好,我是此方,上两篇我们讲到了,互斥量的概念、接口、原理和封装。本文开始讲解同步和条件变量相关内容。

一、从互斥到同步:为什么仅有互斥是不够的

  引入新的技术,必然是为了解决旧技术带来的新问题。
  在上一篇文章中,我们学习了 互斥锁(Mutex) 的基本概念与使用。互斥成功解决了多线程并发访问临界资源时的“数据不一致”与“竞争条件”问题,确保了同一时刻只有一个执行流能够进入临界区。
  然而,单纯依靠“纯互斥”真的就完美无缺了吗?

1.1 纯互斥场景下的极端故事:超级自习室

  为了更好地理解纯互斥带来的隐患,我们来看一个生活中“超级自习室”的例子。
  假设有一个“超级自习室”,里面只有一张桌子,且自习室的门锁只能由一把钥匙打开。

  • 自习室:相当于临界资源
  • 钥匙:相当于互斥锁
  • 想进去自习的人:相当于各个线程(执行流)

1.1.1 争夺钥匙与高效释放

  当一个人(线程 A)成功拿到了钥匙并进入自习室后,其他人就只能在门外等待。
  过了一段时间,线程 A 准备离开自习室。他打开门、走出自习室,并把钥匙归还到了门口的钥匙挂钩上(释放锁)。
  但此时存在一个极其特殊的物理现象:
由于线程 A 离钥匙挂钩距离最近,刚刚把钥匙挂上去,他又可以瞬间再次伸手将钥匙拿下来!
  此时,门外排队的其他人在做什么呢?
  其他的线程离钥匙挂钩相对较远,或者正处于“准备唤醒”、“休眠恢复”等各种微小的系统开销与延迟中。
  因此,线程 A 刚出来,又立马申请到了钥匙,重新关上门进入自习室。

1.1.2 极端循环与“饥饿问题”

  在自习室内部,线程 A 发现自己其实并没有什么实质性的自习任务可做(没有做有效动作),于是他又开门出来挂上钥匙。挂上钥匙的瞬间,他又再次抢到了钥匙……
  如此循环往复:

  1. 申请锁
  2. 进入临界区(但未进行有效操作或高频重复操作)
  3. 释放锁
  4. 凭借距离优势,瞬间重新申请锁

  从互斥规则来看:纯互斥,有错吗?没有任何错!
  因为任何时刻确实都只有一个人进入了自习室,数据安全得到了绝对保证。
  但是从系统整体的运行效率与公平性来看:

  • 不高效:线程 A 在频繁申请和释放锁,却没能进行有效的业务处理。
  • 不太公平:其他想要自习的人(其他线程)长时间在门外等待,永远抢不过距离挂钩最近的线程 A。

  在多线程编程中,这种其他线程长时间获取不到临界资源、导致无法继续推进的现象,就称为线程饥饿问题(Starvation)

  在 Ubuntu 或 CentOS 等不同的 Linux 发行版/内核调度环境下,如果不加任何干预,纯互斥锁的这种弊端往往表现得非常明显。

Tips:其他线程获取临界资源的契机

  既然活跃线程每次都能近水楼台先得月,其他线程有没有可能获取道临界资源呢?我让GPT帮我整理了三种情况:

在这里插入图片描述

1.2 问题的解决方案与临时调优

  既然纯互斥可能会带来“饥饿问题”和“效率低下”,我们该如何改进?

1.2.1 临时调优方案:在循环中加入微小休眠

  在编写多线程代码时,如果在循环体内释放锁之后,加上一行极小的休眠指令,如:

usleep(100); // 释放锁后给其他线程一些调度和申请的时间

  当线程 A 释放锁后主动休眠(哪怕只有 100 微秒),就能将CPU的使用权和抢锁机会让渡出来,给其他线程留出反应和唤醒的时间。
  这种做法能够让各个线程更加平均地申请到锁,显著缓解饥饿现象。但这仅仅是一种通过“人为硬编码延时”进行的妥协式调优,并没有从根本的机制层面彻底解决“访问顺序”问题。

1.2.2 制度层面重塑:管理者规则与队列机制

  要从根本上解决自习室的秩序问题,自习室管理员提出了两项严格的新规定:

  1. 不能立即申请第二次:刚从自习室出来(释放锁)的人,不能当场重新伸手抢钥匙。
  2. 按顺序排队
    • 所有想进入自习室的人,必须先在一个指定的队列末尾进行排队。
    • 刚出来的人,如果还想再次进入,必须跑回到队列的尾部重新排队,等待下一次轮到自己。

  通过这种“先来后到”的排队约束,所有人都有了明确且可预测的进入顺序。

1.3 引入线程同步(Thread Synchronization)

  通过给纯互斥加上“按顺序排队/访问”的约束规则,我们就正式引出了操作系统中的线程同步(Thread Synchronization) 机制。

1.3.1 什么是线程同步?

  所谓的线程同步

在保证临界资源安全(互斥)的前提下,让所有的执行流按照某种一定的顺序来访问临界资源。

1.3.2 互斥与同步的互补关系

  • 互斥:解决的是“安全”问题,确保数据不被破坏(即“不能同时进”)。
  • 同步:解决的是“高效与公平”问题,避免线程饥饿并提高整体系统的协同吞吐量(即“有序轮流进”)。

只有将“互斥”与“同步”结合起来,多线程对共享资源的协同访问才算真正走向了成熟与高效。

二、理解同步的概念

  在深入操作系统底层接口之前,我们先回顾一个之前接触过的类似机制:管道(Pipe)
  在进程间通信的管道中,当管道为空时,读端会被阻塞,不能继续读取;当管道被写满时,写端会被阻塞,不能继续写入。读端与写端之间这种“有资源才读、有空间才写”的配合,本质上就是一种典型的同步机制。
  先讲抽象的概念容易劝退,我们先来做一个游戏。

2.1 一个游戏:拿苹果与放苹果

  假设桌子上有一个盘子,盘子里最多只能放一个苹果。
现在有两类角色:

  • 放苹果的人
  • 拿苹果的人

  由于盘子属于共享资源,为了防止多人同时抢夺,盘子上方挂着一把,每次只能有一个人拿到锁并靠近盘子。

2.1.1 阶段一:没有铃铛与队列(纯互斥逻辑)

  在这个阶段,桌子上只有一把锁。拿苹果的人因为蒙着眼睛,在不拿到锁、伸手摸盘子之前,根本不知道盘子里到底有没有苹果
  此时的情况如下:

  1. 拿苹果的人先申请锁。
  2. 拿到锁后,伸手摸盘子,发现盘子是空的。
  3. 他什么也拿不到,只能释放锁离开。
  4. 由于他距离锁最近,刚释放锁,他又顺手重新申请了锁,再摸一次盘子,发现还是空的,又释放锁……

  这种模式带来的危害:
  拿苹果的人在没有苹果时,疯狂地“申请锁 → \rightarrow 发现没苹果 → \rightarrow 释放锁 → \rightarrow 再申请锁”。
  极大地消耗了精力(占用 CPU 资源),却没有做出任何有效动作。而放苹果的人想过来放苹果,却根本抢不到锁。

2.1.2 阶段二:引入“铃铛与队列”(同步机制)

在这里插入图片描述

  为了解决上述危害,游戏规则进行了升级:在盘子旁边放置了一个铃铛,并在旁边设立了一条排队队列
  现在的互动约定如下:

  1. 拿苹果的人申请锁后摸盘子:
    • 如果摸到了苹果,直接拿走,释放锁离开。
    • 如果发现盘子为空,他不再盲目重新抢锁,而是主动释放锁,并跑到队列末尾排队休眠
  2. 放苹果的人申请锁后将苹果放入盘子:
    • 放完苹果后,他去按一下铃铛
    • 铃声响起,唤醒在队列头部等待的第一个人。
  3. 被唤醒的人直接从队头出来去拿苹果,拿完后再回到队尾排队。

引入后的好处:
拿苹果的人不再进行无意义的重复抢锁,盘子为空时就安静地在队列中等待;放苹果的人放完资源后通过“按铃”精准通知等待者。整个过程井然有序,资源得到了最充分的利用。

  故事中的**“铃铛 + 队列”** 组合,在 Linux 多线程编程中对应的正是 条件变量(Condition Variable)

2.2 专业抽象的概念定义

  看懂了上面的故事,我们再来看操作系统中关于同步与条件变量的专业定义,就会一目了然:

2.2.1 条件变量(Condition Variable)

  当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了
  例如:一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况下就需要用到条件变量

2.2.2 同步概念与竞态条件

  • 同步:在保证数据安全(互斥)的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
  • 竞态条件(Race Condition):由于执行时序的问题,而导致程序运行出现异常或结果不符合预期,我们称之为竞态条件。

三、快速梳理条件变量的调用接口

  条件变量的调用接口与我们前面讲过的互斥量调用接口十分相似,在使用时都需要引入头文件 <pthread.h>
  在 POSIX 线程库中,条件变量的数据类型为 pthread_cond_t。下面我们对条件变量的核心 API 进行快速梳理。

3.1 条件变量的初始化与销毁

  与互斥锁类似,条件变量也分为静态初始化动态初始化两种方式。

3.1.1 静态初始化

  如果条件变量被定义为全局变量或 static 静态变量,可以使用宏进行快速静态初始化,无需手动销毁:

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

3.1.2 动态初始化

  如果条件变量是局部变量或在堆上动态分配的,则需要调用 pthread_cond_init 函数进行动态初始化:

int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
  • cond:指向需要初始化的条件变量的指针。
  • attr:条件变量的属性,通常设置为 NULL 表示使用默认属性。

3.1.3 销毁条件变量

  对于通过 pthread_cond_init 动态初始化的条件变量,在不再使用时必须调用 pthread_cond_destroy 进行销毁和资源释放:

int pthread_cond_destroy(pthread_cond_t *cond);

3.2 在条件变量下等待:pthread_cond_wait

  当线程访问临界资源前发现条件不满足时,需要调用等待接口使自身挂起休眠,进入该条件变量的等待队列中。

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
  • cond:指定要在哪个条件变量下进行等待。
  • mutex:互斥锁指针。

关键疑问:
  这里可以注意到一个很特殊的细节——pthread_cond_wait 的第二个参数接收了一把互斥锁 mutex。为什么在条件变量下等待时,必须把互斥锁作为参数传进去?

3.2.1在条件变量中等待的线程被唤醒后pthread_cond_wait内部做了什么工作

  1. 脱离条件变量队列
  2. 重新竞争互斥锁(关键步骤)
  3. 成功获取锁,函数准备返回
  4. 函数返回,继续向下执行代码

3.3 唤醒在条件变量下等待的线程

  当其它线程改变了状态(例如产生了新的资源、放下了苹果),就需要去唤醒在条件变量队列中等待的线程。POSIX 提供了两种唤醒方式:

3.3.1 唤醒单个线程:pthread_cond_signal

int pthread_cond_signal(pthread_cond_t *cond);
  • 功能:唤醒在指定条件变量 cond 下等待的一个线程(相当于按一下铃铛,唤醒队列头部的线程)。

3.3.2 广播唤醒所有线程:pthread_cond_broadcast

int pthread_cond_broadcast(pthread_cond_t *cond);
  • 功能:唤醒在指定条件变量 cond 下等待的所有线程(相当于大喇叭广播,让队列中所有等待的线程同时醒来重新竞争资源)。

四、第一个测试 demo

4.1 测试代码展示

#include <iostream>
#include <pthread.h>
#include <vector>
#include <unistd.h>

int ticket = 100000;
pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t _cond = PTHREAD_COND_INITIALIZER;

void *RunThread(void *args)
{
    char *name = static_cast<char *>(args);
    while (true)
    {
        {
            pthread_mutex_lock(&_mutex);
            pthread_cond_wait(&_cond, &_mutex);
            if (ticket > 0)
            {
                ticket--;
                std::cout << "线程" << name << "抢到票: " << ticket << std::endl;
            }
            pthread_mutex_unlock(&_mutex);
        }
    }
}

int main()
{
    int cnt = 5;
    std::vector<pthread_t> pvr;
    while (cnt)
    {
        pthread_t tid;
        pvr.push_back(tid);
        char *name = new char[64];
        int n = snprintf(name, 64, "thread-%d", cnt);
        if (n < 0)
            perror("snprintf error");
        pthread_create(&tid, nullptr, RunThread, name);
        cnt--;
    }

    while (true)
    {
        std::cout << "唤醒一个线程" << std::endl;
        usleep(1000);
        pthread_cond_signal(&_cond);
    }

    for (auto e : pvr)
    {
        pthread_join(e, nullptr);
    }
    return 0;
}

4.2 Demo 核心逻辑拆解与原理探究

  在这段代码中,主线程创建了 5 个工作线程,它们都在执行 RunThread 函数。我们可以通过以下三个关键问题来深入理解这段 Demo 的运行机制。

4.2.1 为什么一定要先加锁,再调用 pthread_cond_wait?

观察 RunThread 函数内部逻辑:

pthread_mutex_lock(&_mutex);
pthread_cond_wait(&_cond, &_mutex);
  1. 判断条件本身就是访问临界资源:线程之所以要等待,是因为“某种条件不满足”(如票数不足或资源未就绪)。而要检查条件是否满足,就必须读取共享变量 ticket。检查共享变量的操作必须在加锁保护的临界区内部进行。
  2. 休眠必须在临界区内发起:当判定条件不满足需要休眠时,线程此时已经处于临界区内部,手握互斥锁。

4.2.2 pthread_cond_wait 内部隐藏的自动解锁机制

  这就引出了一个严密的逻辑冲突:如果线程拿着锁直接挂起休眠了,其他线程不就再也无法申请到锁去改变条件了吗?
  这就是为什么 pthread_cond_wait 必须传入第二个参数 &_mutex 的根本原因:

  • 当线程执行 pthread_cond_wait 时,操作系统会在将该线程挂入 _cond 等待队列的同时,自动释放传入的互斥锁 _mutex
  • 线程执行链路:申请锁成功进入临界区 → \rightarrow 判定条件不满足 → \rightarrow 调用 pthread_cond_wait → \rightarrow 线程挂入条件变量等待队列 → \rightarrow 自动释放锁 → \rightarrow 允许新线程进入临界区。

  这样一来,其他线程(如主线程或其他生产者)才能顺利拿到互斥锁去修改共享变量或发出唤醒信号。

4.2.3 线程被唤醒后的重新竞争锁

  在主线程中,通过一个死循环定期调用:

pthread_cond_signal(&_cond);

运行表现与底层动作:

  1. 有序被唤醒:主线程每发出一次 pthread_cond_signal,就会从 _cond 等待队列的队头唤醒一个线程。从终端打印输出可以清晰看到,各个线程被唤醒的顺序完全遵循入队列的顺序,避免了线程饥饿。
  2. 唤醒后重新竞争锁:当被唤醒的线程从 pthread_cond_wait 函数返回时,它并不是直接继续往下执行,而是必须在函数内部重新去竞争 _mutex
  3. 安全退出临界区:只有重新成功抢到锁的线程,才会从 pthread_cond_wait 内部返回出来,接着执行后续的 ticket-- 操作,并在最后通过 pthread_mutex_unlock(&_mutex) 释放锁。

  如果将主线程中的 pthread_cond_signal 替换为广播唤醒 pthread_cond_broadcast,虽然所有等待线程会在瞬间全部被唤醒,但由于重新竞争锁的机制存在,它们依然会排队依次获取锁进入后续逻辑,绝对不会出现多个线程同时在临界区内修改数据导致的混乱现象。


好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye! Linux、C++、算法持续连载中,欢迎关注WeChat Official Account 【此方的技术栈】。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/Z2314246476/article/details/163572717

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--