
🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

🏠博主简介

文章目录
- 前言
- 一、为什么只有互斥还不够
-
- [1.1 互斥解决安全,同步安排先后](#1.1 互斥解决安全,同步安排先后)
- [1.2 忙等为什么不合适](#1.2 忙等为什么不合适)
- 二、条件变量到底是什么
-
- [2.1 条件变量不是锁](#2.1 条件变量不是锁)
- [2.2 常用接口](#2.2 常用接口)
-
- [`pthread_cond_timedwait` 超时后是否持锁](#
pthread_cond_timedwait超时后是否持锁)
- [`pthread_cond_timedwait` 超时后是否持锁](#
- [2.3 `pthread_cond_wait` 做了哪几步](#2.3
pthread_cond_wait做了哪几步) - [2.4 为什么判断条件必须用 `while`](#2.4 为什么判断条件必须用
while) - [2.5 通知应该放在解锁前还是解锁后](#2.5 通知应该放在解锁前还是解锁后)
- 三、生产者消费者模型
-
- [3.1 模型中的三种关系](#3.1 模型中的三种关系)
- [3.2 为什么要多加一个缓冲区](#3.2 为什么要多加一个缓冲区)
- [3.3 阻塞队列的两组条件](#3.3 阻塞队列的两组条件)
- 四、封装互斥锁和条件变量
-
- [4.1 `Mutex` 与 `LockGuard`](#4.1
Mutex与LockGuard) - [4.2 `Cond` 封装](#4.2
Cond封装)
- [4.1 `Mutex` 与 `LockGuard`](#4.1
- 五、完整实现阻塞队列
-
- [5.1 `BlockQueue.hpp`](#5.1
BlockQueue.hpp) - [5.2 `Push` 的执行过程](#5.2
Push的执行过程) - [5.3 `Pop` 的执行过程](#5.3
Pop的执行过程) - [5.4 测试代码](#5.4 测试代码)
- [5.1 `BlockQueue.hpp`](#5.1
- 六、提交代码前再检查一遍
- 总结
- 参考资料
前言
上一篇用抢票程序说明了线程互斥:多个线程访问同一份数据时,要用同一把锁保护完整的临界区。但只有互斥还不够。
假设消费者拿到锁后发现队列为空,它现在不能取数据。最笨的做法是解锁、再次抢锁、再检查队列,如此反复。数据虽然没有被破坏,CPU 却一直花在无效检查上,生产者也要和它反复竞争锁。
这一篇继续解决这个问题:条件不满足时,线程应该先睡眠;条件满足后,再由其他线程把它叫醒。 这就是条件变量要做的事。最后我们把互斥锁、条件变量和队列组合起来,实现一个完整的阻塞队列。
系列阅读:前一篇是 线程互斥:从抢票问题到互斥锁与 RAII,后一篇是 信号量到底在数什么:从 P/V 操作到 RingQueue。本文只解决条件变量与阻塞队列,不把三个主题揉成一篇。
本文使用的接口与范围
本文使用 POSIX Threads 接口,示例以 C++17 编写,编译时需要添加 -pthread。条件变量的接口语义由 POSIX 规定,不同 libc 的内部等待队列和唤醒实现可能不同,正文不依赖某个 glibc 版本的内部字段。
一、为什么只有互斥还不够
1.1 互斥解决安全,同步安排先后
还是用我原来写的自习室例子理解。VIP 自习室只有一把钥匙,一次只能进去一个人,这把钥匙相当于互斥锁。只要所有人进门前拿钥匙、出门后还钥匙,就能保证自习室不会同时进入两个人。
问题在于,有人拿到钥匙后发现自己暂时什么也做不了,放回钥匙后又立即抢一次。由于他正在 CPU 上运行,重新申请锁可能很快;其他已经阻塞的线程还要经历唤醒、调度和重新竞争,未必能马上拿到锁。

这里需要把两个概念分开:
- 互斥:同一时刻只允许一个线程进入临界区,重点是保护共享数据;
- 同步:当运行条件不满足时,让线程等待;条件发生变化后,再通知它继续运行。
同步不是承诺严格的先来先服务,条件变量也不是公平队列。它解决的是"现在做不了就别空转,状态改变后再来检查"的问题。
**先记住区别:**互斥锁回答"现在谁能访问共享数据",条件变量回答"条件不满足的线程在哪里等、什么时候再回来检查"。
1.2 忙等为什么不合适
如果消费者这样写:
cpp
std::atomic<bool> stop_requested{false};
while (!stop_requested.load())
{
pthread_mutex_lock(&mutex);
if (!queue.empty())
{
Task task = queue.front();
queue.pop();
pthread_mutex_unlock(&mutex);
task();
}
else
{
pthread_mutex_unlock(&mutex);
}
}
队列长时间为空时,消费者会不停地加锁、判断、解锁。这种写法没有越界访问队列,却产生了大量没有意义的循环。更合理的处理是:队列为空就阻塞消费者,生产者放入数据以后再通知它。
管道其实也有相似行为:管道为空时读端等待,管道写满时写端等待。等待条件改变以后,双方再继续工作。
二、条件变量到底是什么
2.1 条件变量不是锁
我原来用"盘子、苹果和铃铛"理解条件变量,这个类比可以保留。
盘子是共享资源,放苹果和取苹果的人访问盘子前都要先拿钥匙。钥匙负责互斥,铃铛负责通知:取苹果的人发现盘子为空,就去旁边等待;放苹果的人把苹果放好后,敲铃铛通知等待者。
钥匙对应互斥锁,铃铛和等待队列对应条件变量。
条件变量本身不保护队列,也不知道"队列为空"是什么意思。真正的条件来自我们写的谓词,例如 queue.empty()、queue.size() == capacity。互斥锁负责保证检查状态和修改状态时数据一致,条件变量负责睡眠和唤醒。
条件变量不能单独保证线程安全。只声明一个
pthread_cond_t,却不使用互斥锁保护谓词,仍然会产生数据竞争。
2.2 常用接口
POSIX 线程库提供了下面几组接口:
cpp
int pthread_cond_init(pthread_cond_t *cond,
const pthread_condattr_t *attr);
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_wait(pthread_cond_t *cond,
pthread_mutex_t *mutex);
int pthread_cond_timedwait(pthread_cond_t *cond,
pthread_mutex_t *mutex,
const struct timespec *abstime);
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
局部条件变量要先 pthread_cond_init,不用时再 pthread_cond_destroy。如果生命周期覆盖整个程序,也可以使用静态初始化:
cpp
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_cond_signal 至少唤醒一个等待线程,pthread_cond_broadcast 唤醒所有等待线程。被唤醒只表示线程可以重新参与竞争,并不表示它已经拿到互斥锁,更不表示业务条件一定仍然成立。
如果有多个线程正在等待,pthread_cond_signal 至少唤醒其中一个,pthread_cond_broadcast 则唤醒所有等待者。不过被唤醒的线程仍然要重新竞争互斥锁,所以它们真正继续执行的顺序并不固定。
signal与broadcast改变的是唤醒范围,不改变"醒来后重新抢锁、重新检查条件"这条规则。
pthread_cond_timedwait 超时后是否持锁
pthread_cond_timedwait 与普通等待的区别,是它多接收一个绝对时间 abstime。到达该时间仍未收到通知时,函数返回 ETIMEDOUT。
容易忽略的是:即使因为超时返回,pthread_cond_timedwait 也会先重新获得互斥锁。函数返回后,调用线程仍然处在受这把锁保护的临界区中。
abstime是绝对时间,不是"再等多少毫秒"。若业务使用超时等待,还要统一时钟来源,并处理ETIMEDOUT。
2.3 pthread_cond_wait 做了哪几步
调用 pthread_cond_wait 以前,当前线程必须已经持有参数中的互斥锁。这个函数可以先按三步理解:
- 原子地释放互斥锁,并把线程放入条件变量的等待过程;
- 当前线程阻塞,不再反复占用 CPU 检查条件;
- 收到通知后重新竞争互斥锁,成功拿到锁以后,
wait才返回。
第一步必须具有原子效果。若先手动解锁,再调用另一个"睡眠函数",生产者可能恰好在两步之间完成通知。消费者还没有进入等待就错过了通知,随后可能一直睡下去,这就是丢失唤醒问题。
pthread_cond_wait返回时,调用线程重新持有互斥锁。后面的共享数据检查仍然位于临界区中。
也就是说,线程阻塞在 pthread_cond_wait 期间不会一直占着互斥锁;否则负责修改条件的线程拿不到锁,条件也就永远无法成立。
2.4 为什么判断条件必须用 while
下面这种写法看起来少判断一次,但不可靠:
cpp
if (queue.empty())
{
pthread_cond_wait(&consumer_cond, &mutex);
}
正确写法应该是:
cpp
while (queue.empty())
{
pthread_cond_wait(&consumer_cond, &mutex);
}
原因主要有两个:
- 条件变量允许出现伪唤醒,线程醒来后条件未必真的改变;
broadcast会唤醒多个线程,但它们重新拿锁有先后顺序。前面的线程可能已经取走数据,后面的线程拿到锁时队列又空了。
因此通知只表示"状态可能发生变化",线程醒来后必须重新检查谓词。条件判断属于程序逻辑,不能交给条件变量猜。
假设两个消费者都因为队列为空而等待,生产者只放入一个元素,却使用 broadcast 把两者都唤醒。先拿到锁的消费者取走数据后,第二个消费者再拿到锁时,队列已经重新变空。如果判断写成 if,第二个消费者会直接向后执行;改成 while 后,它会再次检查条件并继续等待。
**通知不是条件本身。**真正决定线程能否继续执行的,是锁内检查到的共享状态。
2.5 通知应该放在解锁前还是解锁后
只要状态修改受到同一把锁保护,两种顺序都可能写出正确程序:
cpp
// 写法一
queue.push(data);
pthread_cond_signal(&consumer_cond);
pthread_mutex_unlock(&mutex);
// 写法二
queue.push(data);
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&consumer_cond);
初学阶段我更愿意把"修改状态"和"发送通知"放在同一个加锁作用域里,因为阅读时不容易漏掉二者的对应关系。实际工程还会根据竞争情况和对象生命周期决定顺序,但不能在状态尚未修改时就先通知。
三、生产者消费者模型
3.1 模型中的三种关系
我原稿使用工厂、超市和顾客来类比:工厂生产商品,超市暂存商品,顾客从超市取商品。换成程序语言就是:
- 生产者线程生成数据或任务;
- 阻塞队列保存暂时来不及处理的数据;
- 消费者线程从队列取出数据并执行。

这个模型中有三种关系:
- 生产者与生产者之间互斥,避免同时破坏队列;
- 消费者与消费者之间互斥,避免重复取走同一个元素;
- 生产者与消费者既互斥又同步:访问队列要互斥,队列空或满时还要相互通知。
可以继续用"321"来记:三种关系、两类角色、一个交易场所。 其中交易场所通常就是一块按特定数据结构组织起来的内存。
3.2 为什么要多加一个缓冲区
生产者和消费者直接调用对方,会把双方的速度和生命周期绑在一起。中间加入阻塞队列以后,生产者只负责把任务放进去,消费者只负责把任务取出来。
它带来三个直接好处:
- 解耦:生产任务与处理任务不必写在同一条调用链上;
- 支持忙闲不均:短时间内生产得快,任务可以先放在队列里;
- 限制压力:队列容量有限,满了以后生产者等待,避免任务无限堆积。
阻塞队列不是让程序永远不阻塞,而是让阻塞发生在明确的位置,并让等待线程睡眠而不是空转。
3.3 阻塞队列的两组条件

容量为 capacity 的队列有两个相反条件:
| 角色 | 不能继续的条件 | 等待位置 | 谁来唤醒 |
|---|---|---|---|
| 生产者 | 队列已满 | producer_cond |
消费者取走数据后 |
| 消费者 | 队列为空 | consumer_cond |
生产者放入数据后 |
只用一个条件变量也能实现,但空和满的等待者混在一起,可能唤醒当前仍无法工作的线程。把两类等待分开,含义更清楚。
两个条件变量分别对应"有数据可取"和"有空位可放"。它们保护的仍是同一个队列,因此都配合同一把互斥锁。
四、封装互斥锁和条件变量
4.1 Mutex 与 LockGuard
cpp
#pragma once
#include <pthread.h>
class Mutex
{
public:
Mutex() { pthread_mutex_init(&_mutex, nullptr); }
~Mutex() { pthread_mutex_destroy(&_mutex); }
Mutex(const Mutex &) = delete;
Mutex &operator=(const Mutex &) = delete;
void Lock() { pthread_mutex_lock(&_mutex); }
void Unlock() { pthread_mutex_unlock(&_mutex); }
pthread_mutex_t *Get() { return &_mutex; }
private:
pthread_mutex_t _mutex;
};
class LockGuard
{
public:
explicit LockGuard(Mutex &mutex) : _mutex(mutex) { _mutex.Lock(); }
~LockGuard() { _mutex.Unlock(); }
LockGuard(const LockGuard &) = delete;
LockGuard &operator=(const LockGuard &) = delete;
private:
Mutex &_mutex;
};
互斥量不能随意复制,守卫对象也不能复制,否则容易出现多个对象管理同一次解锁,所以这里直接删除拷贝构造和赋值。
4.2 Cond 封装
cpp
#pragma once
#include <pthread.h>
#include "Mutex.hpp"
class Cond
{
public:
Cond() { pthread_cond_init(&_cond, nullptr); }
~Cond() { pthread_cond_destroy(&_cond); }
Cond(const Cond &) = delete;
Cond &operator=(const Cond &) = delete;
void Wait(Mutex &mutex)
{
pthread_cond_wait(&_cond, mutex.Get());
}
void Signal() { pthread_cond_signal(&_cond); }
void Broadcast() { pthread_cond_broadcast(&_cond); }
private:
pthread_cond_t _cond;
};
Cond::Wait 必须接收互斥锁,因为等待过程需要先释放它,并在返回前重新拿到它。条件变量和保护业务状态的锁是一组配合关系,不能随便换成另一把锁。
五、完整实现阻塞队列
5.1 BlockQueue.hpp
下面保留我原稿的整体设计,但修正一个明显问题:原来的 Pop 使用 LockGuard 后,又手动调用了一次 pthread_mutex_unlock,函数返回时守卫析构还会再次解锁,形成重复解锁。使用 RAII 后,作用域中不要再手动释放同一把锁。
cpp
#pragma once
#include <cstddef>
#include <queue>
#include <utility>
#include "Cond.hpp"
#include "Mutex.hpp"
template <class T>
class BlockQueue
{
public:
explicit BlockQueue(std::size_t capacity = 10)
: _capacity(capacity)
{}
bool Push(const T &data)
{
LockGuard lock_guard(_mutex);
while (IsFull() && !_stop)
{
_producer_cond.Wait(_mutex);
}
if (_stop)
return false;
_queue.push(data);
_consumer_cond.Signal();
return true;
}
bool Push(T &&data)
{
LockGuard lock_guard(_mutex);
while (IsFull() && !_stop)
{
_producer_cond.Wait(_mutex);
}
if (_stop)
return false;
_queue.push(std::move(data));
_consumer_cond.Signal();
return true;
}
bool Pop(T *data)
{
LockGuard lock_guard(_mutex);
while (IsEmpty() && !_stop)
{
_consumer_cond.Wait(_mutex);
}
if (IsEmpty())
return false;
*data = std::move(_queue.front());
_queue.pop();
_producer_cond.Signal();
return true;
}
void Stop()
{
LockGuard lock_guard(_mutex);
_stop = true;
_producer_cond.Broadcast();
_consumer_cond.Broadcast();
}
private:
bool IsEmpty() const { return _queue.empty(); }
bool IsFull() const { return _queue.size() >= _capacity; }
private:
std::queue<T> _queue;
std::size_t _capacity;
Mutex _mutex;
Cond _producer_cond;
Cond _consumer_cond;
bool _stop = false;
};
5.2 Push 的执行过程
生产者调用 Push 后按下面的顺序运行:
- 先申请互斥锁,保证自己独占队列;
- 队列已满且没有停止,就在
_producer_cond下等待,Wait会临时释放锁; - 醒来并重新拿到锁后,再次用
while检查是否仍然已满; - 若队列已经停止,返回
false,不再接收新任务; - 放入数据并通知一个消费者,函数结束后
LockGuard自动解锁。
这里没有维护"睡眠线程数量"。即使当前没有消费者等待,调用 Signal 也只是没有线程被唤醒,不会破坏队列状态。先做出正确版本,再考虑是否值得通过计数减少一次系统调用。
5.3 Pop 的执行过程
消费者的逻辑正好相反:
- 申请同一把互斥锁;
- 队列为空且没有停止,就在
_consumer_cond下等待; - 醒来后重新检查队列;
- 若队列已经停止且没有剩余任务,返回
false; - 否则取出队头元素,并通知一个可能因队列已满而等待的生产者。
Pop 返回局部对象时,LockGuard 先析构并释放锁。任务真正执行应该放到 Pop 外面,否则消费者会在执行耗时任务期间一直占着队列锁,其他线程既不能生产也不能消费。
5.4 测试代码
cpp
#include <atomic>
#include <cstdio>
#include <pthread.h>
#include <unistd.h>
#include "BlockQueue.hpp"
struct Task
{
int left;
int right;
void operator()() const
{
std::printf("%d + %d = %d\n", left, right, left + right);
}
};
BlockQueue<Task> queue(5);
std::atomic<int> next_value{0};
constexpr int tasks_per_producer = 20;
void *Producer(void *)
{
for (int i = 0; i < tasks_per_producer; ++i)
{
int value = next_value.fetch_add(1);
if (!queue.Push(Task{value, value + 1}))
break;
std::printf("producer: push task %d\n", value);
usleep(200000);
}
return nullptr;
}
void *Consumer(void *)
{
Task task{};
while (queue.Pop(&task))
{
task();
usleep(500000);
}
return nullptr;
}
int main()
{
pthread_t producers[2];
pthread_t consumers[2];
for (auto &tid : producers)
pthread_create(&tid, nullptr, Producer, nullptr);
for (auto &tid : consumers)
pthread_create(&tid, nullptr, Consumer, nullptr);
for (auto tid : producers)
pthread_join(tid, nullptr);
queue.Stop();
for (auto tid : consumers)
pthread_join(tid, nullptr);
}
编译时同样要加 -pthread:
bash
g++ -std=c++17 -O2 -pthread main.cc -o block_queue
./block_queue
两个生产者完成任务提交后,主线程调用 Stop。此时队列拒绝新的 Push,消费者继续取完已有任务;队列为空后,Pop 返回 false,消费者退出,主线程才能完成 join。
阅读或运行这段代码时,可以调整生产者和消费者的休眠时间:生产快、消费慢时,生产者会在队列满后等待;生产慢、消费快时,消费者会在队列空后等待。这里关注的是两组条件变量的等待方向是否写反,以及停止时能否唤醒仍在等待的线程。
六、提交代码前再检查一遍
下面这些问题在小数据量下未必立刻出现,但换成多个线程或增加运行时间后,很容易表现成偶发越界、任务卡住或程序无法退出。
1. 把 wait 写在 if 中
线程被唤醒不等于条件已经满足。统一使用 while (条件不满足) wait(),可以同时应对伪唤醒和多个线程被唤醒后的再次竞争。
2. 等待前没有持锁
谓词检查、状态修改和进入等待必须由同一把锁协调。没有持锁就调用 pthread_cond_wait,或者检查完条件后先手动解锁再等待,都会破坏这套关系。
3. 忘记状态和通知的对应关系
生产者放入数据后应通知消费者,消费者取走数据后应通知生产者。不要只在某个测试分支中通知,否则换一种生产消费速度后就可能卡住。
4. 在持锁期间执行任务
队列锁只保护队列。消费者拿出任务后应尽快解锁,再执行网络请求、文件 I/O 或复杂计算。否则阻塞队列会被无关工作拖成串行程序。
5. 把条件变量理解成消息队列
条件变量不保存业务消息。没有等待者时调用一次 signal,不会留下一个可供以后消费的"通知"。真正需要保存的是队列中的数据和用于判断的状态。
6. 停止时只改标志还不够
前面的完整实现已经增加 _stop。真正退出时,不能只设置标志,更不能直接销毁条件变量和互斥锁,因为此时可能还有线程睡在 Wait 中。
Stop 要在锁内设置 _stop = true,再分别 Broadcast 两类等待者。线程醒来后同时检查"队列状态"和"是否停止":生产者立即停止写入,消费者把已有任务处理完,队列为空后退出。主线程完成 join,对象离开作用域时才会销毁同步对象。
停止条件仍然是业务状态,不是一次通知。也就是说,正确的等待谓词应类似 !queue.empty() || stop,而不是收到 broadcast 就无条件退出。
先设置停止状态,再唤醒等待线程;最后等待线程退出,才能安全销毁互斥锁和条件变量。
总结
互斥锁解决的是共享数据安全,条件变量解决的是条件不满足时如何高效等待。pthread_cond_wait 会在等待期间释放互斥锁,被唤醒后重新竞争锁,并在持锁状态下返回。
阻塞队列把二者组合起来:队列为空时消费者等待,队列满时生产者等待;生产和消费改变队列状态以后,再通知对方。写代码时最关键的三个习惯是:谓词在锁内检查、等待使用 while、任务拿出队列后再执行。
文章还说明了 signal 与 broadcast 的区别、超时等待的返回语义,以及为什么条件判断必须写成 while。这些规则最终都落在阻塞队列的 Push、Pop 和停止流程中。
下一篇继续写 POSIX 信号量和环形队列。它们解决的还是生产者消费者问题,但表达资源数量的方式和条件变量不同。
参考资料
【Linux】信号量到底在数什么:从PV操作到RingQueue环形队列生产者消费者模型
【Linux】多线程抢票为什么会出错?从 mutex、futex 到 RAII,讲清线程互斥
【Linux】epoll 真的是 O(1) 吗?从阻塞 I/O、select/poll 到 LT/ET 与 Reactor 一次讲透