🔥 本文定位:这是 Linux 线程同步系列上篇。我们从一段"偶尔出错"的售票代码出发,逐层拆解数据竞争、临界区、互斥锁、条件变量和阻塞队列。
💡 学习目标 :不仅会调用
pthread_mutex_lock()和pthread_cond_wait(),还要真正理解 为什么ticket--不是原子操作、锁建立了什么顺序、条件变量为什么必须绑定谓词、为什么 wait 必须放在 while 中,以及生产者消费者如何实现解耦与背压。📌 系列导航:上篇聚焦 mutex 与 condition variable;下篇继续讨论 POSIX 信号量、环形队列、线程池、线程安全/可重入、单例与死锁。

文章目录
- 一、为什么共享内存会带来并发问题
- [二、ticket-- 为什么不是原子操作](#二、ticket-- 为什么不是原子操作)
- 三、mutex:把临界区变成互斥区
- [四、互斥锁在 Linux 中如何工作](#四、互斥锁在 Linux 中如何工作)
- 五、条件变量:等待状态变化而不是轮询
- [六、pthread_cond_wait 为什么必须接收 mutex](#六、pthread_cond_wait 为什么必须接收 mutex)
- [七、生产者消费者与 BlockingQueue](#七、生产者消费者与 BlockingQueue)
- 八、常见错误与高频面试题
- 总结
一、为什么共享内存会带来并发问题
1.1 先分清四个概念
- 共享资源:多个执行流都可能访问的对象,例如全局变量、堆对象、队列、文件或设备;
- 临界资源:需要在并发访问时受到保护的共享资源;
- 临界区:线程中读取或修改临界资源的那段代码;
- 互斥:同一时刻只允许一个执行流进入指定临界区。
互斥保护的是访问协议,不是给变量加上一层"任何人都碰不到"的硬件外壳。同一个地址仍位于进程共享地址空间,只是所有遵守协议的线程都必须先取得同一把锁。
1.2 "线程栈变量一定私有"也要加限定
自动变量通常位于当前线程栈中,其他线程默认没有它的名字,但只要把地址或引用传出去,其他线程仍能访问。所谓"线程私有栈"描述的是每条线程独立使用自己的栈区域,并不代表硬件禁止其他线程寻址。
cpp
int local = 42;
pthread_create(&tid, nullptr, worker, &local); // 地址已经跨线程共享
此时必须保证:
local在线程使用期间仍然存活;- 若存在并发读写,要建立同步;
- 创建者不能提前离开作用域或销毁宿主对象。
1.3 数据竞争不是"结果不稳定"这么简单
在 C/C++ 内存模型中,如果两个线程并发访问同一内存位置,至少一个访问是写操作,并且两者之间没有 happens-before 关系,也没有使用合适的原子操作,就形成了 data race。
对普通 C/C++ 对象发生数据竞争时,程序行为是未定义的 。这意味着编译器不只可能生成"丢失更新",还可以基于"程序没有数据竞争"的前提进行优化。因此,不能仅用 sleep()、降低优化等级或"在我的机器上正常"来证明线程安全。
二、ticket-- 为什么不是原子操作
2.1 一个有数据竞争的售票模型
cpp
int ticket = 100;
void* route(void* arg) {
const char* name = static_cast<const char*>(arg);
while (true) {
if (ticket > 0) {
usleep(1000); // 扩大竞态窗口,仅用于演示
std::printf("%s sells ticket: %d\n", name, ticket);
--ticket;
} else {
break;
}
}
return nullptr;
}
多个线程可能同时观察到 ticket > 0,随后分别打印并写回。出现 0、负数或重复票号只是表象,根因是对 ticket 的并发访问没有同步。
2.2 一条 C++ 语句不等于一条不可分割的硬件操作
概念上,ticket-- 可以拆成三步:
text
load : 从内存读取 ticket 到寄存器
update : 寄存器中的值减 1
store : 把结果写回 ticket

假设初值为 10:
text
线程 A load 10 线程 B load 10
线程 A update 9 线程 B update 9
线程 A store 9 线程 B store 9
逻辑上执行了两次减一,内存里却只从 10 变成 9,这就是典型的 lost update。
注意:这张交错图用于帮助理解风险,并不是 C++ 标准对数据竞争程序行为的保证。发生数据竞争后,语言层面已经进入未定义行为。
2.3 "原子变量"能否替代锁
如果只需要无条件计数,可以考虑原子类型:
cpp
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);
但售票逻辑包含"检查大于 0、取得票号、递减、打印/交付"的复合不变量。单独把 ticket 改成 atomic<int> 并不会自动让整段业务成为一个事务。你需要 CAS 循环,或者直接用 mutex 把复合临界区保护起来。
三、mutex:把临界区变成互斥区
3.1 Pthreads mutex 基本接口
静态初始化:
cpp
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
动态初始化与销毁:
cpp
pthread_mutex_t mutex;
int rc = pthread_mutex_init(&mutex, nullptr);
// 使用 mutex
rc = pthread_mutex_destroy(&mutex);
加锁与解锁:
cpp
int rc = pthread_mutex_lock(&mutex);
// critical section
rc = pthread_mutex_unlock(&mutex);
Pthreads 函数通常直接返回错误号,而不是通过 errno 报错:
cpp
int rc = pthread_mutex_lock(&mutex);
if (rc != 0) {
std::fprintf(stderr, "pthread_mutex_lock: %s\n", std::strerror(rc));
}
这里讨论的是默认普通 mutex。若显式使用 robust mutex,pthread_mutex_lock() 返回 EOWNERDEAD 时调用线程实际上已经取得锁,但受保护状态被标记为不一致,需要按 robust mutex 协议修复并调用 pthread_mutex_consistent()。
3.2 临界区的正确边界

修正售票代码:
cpp
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int ticket = 100;
void* route(void* arg) {
const char* name = static_cast<const char*>(arg);
while (true) {
pthread_mutex_lock(&mutex);
if (ticket <= 0) {
pthread_mutex_unlock(&mutex);
break;
}
const int sold = ticket--;
pthread_mutex_unlock(&mutex);
// 与共享状态无关的慢操作放到锁外
std::printf("%s sells ticket: %d\n", name, sold);
}
return nullptr;
}
这里把"检查与递减"放在同一临界区,同时把打印移到锁外,缩短持锁时间。
3.3 锁的范围不是越大越安全
锁太小:复合不变量被拆开,仍可能竞态。
cpp
lock();
bool available = ticket > 0;
unlock();
if (available) {
lock();
--ticket; // 两次加锁之间状态可能已变化
unlock();
}
锁太大:把 I/O、睡眠、网络请求或耗时计算放进临界区,会让其他线程长时间等待,程序虽然"正确",并发度却接近单线程。
正确原则是:围绕共享不变量确定临界区,以满足正确性为前提尽量缩短持锁时间。
3.4 RAII 避免遗漏 unlock
手写多条返回路径很容易漏解锁:
cpp
pthread_mutex_lock(&mutex);
if (error) {
return nullptr; // 锁泄漏
}
pthread_mutex_unlock(&mutex);
C++ 项目优先使用 RAII:
cpp
std::mutex mutex;
void update() {
std::lock_guard<std::mutex> guard(mutex);
// 离开作用域自动解锁
}
若为了学习封装 pthread_mutex_t,包装类应禁止复制,并让 guard 持有引用:
cpp
class Mutex {
public:
Mutex() { check(pthread_mutex_init(&mutex_, nullptr)); }
~Mutex() { pthread_mutex_destroy(&mutex_); }
Mutex(const Mutex&) = delete;
Mutex& operator=(const Mutex&) = delete;
void lock() { check(pthread_mutex_lock(&mutex_)); }
void unlock() noexcept {
if (pthread_mutex_unlock(&mutex_) != 0) {
std::terminate(); // 解锁失败说明同步不变量已经被破坏
}
}
pthread_mutex_t* native_handle() { return &mutex_; }
private:
static void check(int rc) {
if (rc != 0) throw std::system_error(rc, std::generic_category());
}
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_;
};
教学代码常用
(void)rc丢弃返回值,但工程代码至少应记录、传播或转换错误,不能假设同步 API 永远成功。
四、互斥锁在 Linux 中如何工作
4.1 不应把实现简化成"永远进入内核"
现代 Linux 用户态线程库通常基于原子指令与 futex 构建高层同步原语。

典型思路是:
- 无竞争时,用户态原子操作直接取得锁;
- 发现锁已被持有时,进入竞争路径;
- 必要时通过 futex 让线程在内核中睡眠;
- 解锁方发现存在等待者时,执行唤醒;
- 被唤醒不等于已经持锁,线程仍要重新竞争。
这解释了两个事实:
- mutex 在无竞争时可以很快;
- 高竞争、长临界区和频繁唤醒仍会带来调度与缓存开销。
4.2 原子指令只是构建锁的基础
PDF 使用 swap/exchange 解释锁状态切换,这个方向有助于理解"为什么抢锁本身能原子完成",但真实 pthread_mutex_t 还要处理等待者、调度、mutex 类型、robust/priority 属性等问题。
应用层不要依赖 pthread_mutex_t 的内部字段,也不要手写一个普通整数加 while 循环就声称实现了等价 mutex。内存序、阻塞策略、公平性和异常退出都比一个交换指令复杂。
4.3 mutex 不保证固定公平顺序
等待者最终由实现和调度策略决定谁先继续执行,不能假设严格 FIFO。若业务需要公平队列、优先级或限流,应显式设计调度协议,而不是依赖"每个线程迟早轮到"。
五、条件变量:等待状态变化而不是轮询
5.1 mutex 解决安全,条件变量解决等待
互斥锁保证同一时刻只有一个线程修改队列,但无法回答:
队列为空时,消费者应该怎样高效等待新任务?
忙轮询会浪费 CPU:
cpp
while (true) {
pthread_mutex_lock(&mutex);
bool empty = queue.empty();
pthread_mutex_unlock(&mutex);
if (!empty) break;
}
条件变量允许线程等待某个由共享状态表达的谓词,例如:
text
消费者可继续:queue 非空 或 系统正在停止
生产者可继续:queue 未满 或 系统正在停止
5.2 条件变量不是"存放条件的变量"
真正的条件存在共享数据中,condition variable 负责把等待者挂起并在状态变化后通知它重新检查。
text
predicate = !queue.empty() || stopping
cond = 等待/通知机制
mutex = 保护 predicate 所依赖的共享数据
signal 也不是可累积消息。若发出通知时没有等待者,它通常不会替未来的等待者保存"一张票"。因此程序正确性必须来自谓词,而不是依赖通知次数。
5.3 Pthreads 条件变量接口
cpp
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_cond_init(&cond, nullptr);
pthread_cond_wait(&cond, &mutex);
pthread_cond_signal(&cond); // 至少唤醒一个等待者
pthread_cond_broadcast(&cond); // 唤醒所有等待者
pthread_cond_destroy(&cond);
pthread_cond_signal() 不承诺唤醒哪个线程,被唤醒的线程也必须在返回前重新取得关联 mutex。
六、pthread_cond_wait 为什么必须接收 mutex
6.1 错误写法:先 unlock,再 wait
cpp
pthread_mutex_lock(&mutex);
while (!ready) {
pthread_mutex_unlock(&mutex);
// 这里存在丢失唤醒窗口
pthread_cond_wait(&cond, &mutex);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
在显式 unlock 与真正进入等待之间,另一个线程可能:
- 获得 mutex;
- 把
ready改为true; - 调用 signal;
- 释放 mutex。
此时当前线程还没进入等待队列,通知已经过去,之后可能永久睡眠。
6.2 pthread_cond_wait 的原子交接

调用前,当前线程必须持有 mutex。pthread_cond_wait() 负责:
text
持有 mutex 并检查谓词
↓
原子地释放 mutex + 进入条件变量等待
↓
收到通知或发生伪唤醒
↓
重新竞争并取得 mutex
↓
从 pthread_cond_wait 返回
↓
再次检查谓词
这里的"原子"是针对"其他线程取得同一 mutex 后再通知该 condition variable"这一交互而言,目的就是关闭丢失唤醒窗口。
6.3 为什么必须用 while,而不是 if
正确模板:
cpp
pthread_mutex_lock(&mutex);
while (!predicate()) {
pthread_cond_wait(&cond, &mutex);
}
// mutex 仍由当前线程持有,predicate 此刻为真
consume_or_update_state();
pthread_mutex_unlock(&mutex);
原因至少有三个:
- POSIX 允许 spurious wakeup;
- broadcast 会唤醒多个线程,但第一个获得锁的线程可能先把资源取走;
- 从 signal 到当前线程重新获得 mutex 之间,谓词可能再次变化。
因此:通知只是"状态可能变化了",不是"资源已经分配给你"。
6.4 signal 放在锁内还是锁外
只要谓词变化受到同一 mutex 保护,两种写法都可能正确:
cpp
pthread_mutex_lock(&mutex);
ready = true;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
或:
cpp
pthread_mutex_lock(&mutex);
ready = true;
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond);
第一种更容易审查"状态变化与通知"的关系;第二种有时减少被唤醒线程立刻阻塞在 mutex 上的机会。选择应基于明确谓词和性能测量,而不是套用绝对口诀。
七、生产者消费者与 BlockingQueue
7.1 为什么需要生产者消费者模型
生产者不直接调用消费者,而是把任务放入有界缓冲区;消费者从缓冲区取任务。

它带来三项核心价值:
- 解耦:生产和处理逻辑只依赖队列契约;
- 并发:生产者和消费者可以在不同线程推进;
- 削峰与背压:队列吸收短时波动,满时阻塞或拒绝继续生产。
无界队列只能削峰,不能形成有效背压。若生产速度长期大于消费速度,内存仍会持续增长。
7.2 有界阻塞队列的两个谓词
text
消费者等待条件:队列非空,或系统停止
生产者等待条件:队列未满,或系统停止

需要:
- 一把 mutex 保护队列、容量和停止状态;
not_empty条件变量唤醒消费者;not_full条件变量唤醒生产者。
7.3 一个更完整的 Pthreads BlockingQueue
下面保留 PDF 的 Pthreads 主线,但补上停止协议、const 引用和错误边界:
cpp
#include <pthread.h>
#include <cstddef>
#include <queue>
#include <stdexcept>
template<class T>
class BlockingQueue {
public:
explicit BlockingQueue(std::size_t capacity)
: capacity_(capacity) {
if (capacity_ == 0) {
throw std::invalid_argument("capacity must be positive");
}
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(¬_empty_, nullptr);
pthread_cond_init(¬_full_, nullptr);
}
BlockingQueue(const BlockingQueue&) = delete;
BlockingQueue& operator=(const BlockingQueue&) = delete;
bool push(const T& value) {
pthread_mutex_lock(&mutex_);
while (queue_.size() == capacity_ && !closed_) {
pthread_cond_wait(¬_full_, &mutex_);
}
if (closed_) {
pthread_mutex_unlock(&mutex_);
return false;
}
queue_.push(value);
pthread_cond_signal(¬_empty_);
pthread_mutex_unlock(&mutex_);
return true;
}
bool pop(T& out) {
pthread_mutex_lock(&mutex_);
while (queue_.empty() && !closed_) {
pthread_cond_wait(¬_empty_, &mutex_);
}
if (queue_.empty() && closed_) {
pthread_mutex_unlock(&mutex_);
return false;
}
out = std::move(queue_.front());
queue_.pop();
pthread_cond_signal(¬_full_);
pthread_mutex_unlock(&mutex_);
return true;
}
void close() {
pthread_mutex_lock(&mutex_);
closed_ = true;
pthread_cond_broadcast(¬_empty_);
pthread_cond_broadcast(¬_full_);
pthread_mutex_unlock(&mutex_);
}
~BlockingQueue() {
// 调用方必须先停止并 join 所有使用者
pthread_cond_destroy(¬_empty_);
pthread_cond_destroy(¬_full_);
pthread_mutex_destroy(&mutex_);
}
private:
std::queue<T> queue_;
std::size_t capacity_;
bool closed_{false};
pthread_mutex_t mutex_{};
pthread_cond_t not_empty_{};
pthread_cond_t not_full_{};
};
7.4 为什么不需要手工维护 waiter 计数
PDF 示例通过 _consumer_wait_num > 0 判断是否 signal。多数情况下没有必要:对没有等待者的 condition variable 调用 signal 是允许的,通知不会因此成为错误。
手工 waiter 计数本身也是共享状态,会增加不变量和维护成本。除非有明确性能数据证明需要优化,否则直接在状态变化后 signal/broadcast 更易验证。
7.5 析构不是停止协议
不能在还有线程等待 condition variable 或使用 mutex 时销毁它们。正确关闭顺序通常是:
text
停止接收新任务
↓
在 mutex 保护下写入 closed/stopping
↓
broadcast 唤醒所有等待者
↓
等待生产者与消费者退出(join)
↓
销毁队列、condition variable 与 mutex
八、常见错误与高频面试题
8.1 常见错误清单
- 认为一条 C++ 语句天然是原子操作;
- 把数据竞争仅理解为"偶尔丢一次更新",忽略未定义行为;
- 用
volatile替代 mutex 或 atomic; - 加锁只保护写,不保护与写并发的普通读;
- 在锁内执行
sleep、网络 I/O 或大块计算; - 多条 return/exception 路径手写 unlock,造成锁泄漏;
- 用
if包围pthread_cond_wait(); - 把 condition variable 当成可累积消息队列;
- 在没有明确谓词的情况下 wait/signal;
- 队列析构时仍有线程正在等待;
- 用
sleep()猜等待线程已经启动; - 忽略 Pthreads API 的返回错误号。
8.2 高频面试题
问题 1:互斥与同步有什么区别?
互斥解决"同一时刻谁能进入临界区",同步解决"线程在什么状态和顺序下继续执行"。mutex 主要保护共享不变量,condition variable 让线程等待谓词变化;两者通常配合使用。
问题 2:为什么 i++ 不是线程安全的?
它通常包含读取、计算、写回多个步骤。普通对象上的无同步并发读写会形成数据竞争,在 C/C++ 中属于未定义行为。
问题 3:pthread_cond_wait 返回时 mutex 是什么状态?
调用前线程必须持有 mutex;wait 原子释放 mutex 并进入等待;返回前重新取得 mutex,所以函数成功返回时当前线程再次持有该 mutex。
问题 4:为什么条件等待必须使用 while?
因为可能发生伪唤醒,多个等待者可能同时醒来,而且谓词在重新取得 mutex 前可能再次变化。返回只代表"应该重新检查",不代表条件必然为真。
问题 5:signal 会保存到下一次 wait 吗?
不会把通知作为未来可消费的计数保存。正确性必须依赖受 mutex 保护的共享谓词。若需要累积许可,应考虑 semaphore 或显式计数器。
问题 6:为什么条件变量一定要配 mutex?
谓词依赖共享数据,需要 mutex 保护;同时 wait 必须原子完成"释放 mutex + 进入等待",以关闭检查谓词与真正睡眠之间的丢失唤醒窗口。
问题 7:被 signal 唤醒是否已经获得锁?
不是。等待线程需要重新竞争关联 mutex,取得后 pthread_cond_wait() 才返回。
8.3 权威参考
- pthread_mutex_lock(3p):POSIX mutex 加锁、解锁与错误语义
- pthread_cond_wait(3p):原子释放/重获 mutex、谓词与伪唤醒
- pthread_cond_broadcast(3p):signal、broadcast 与多处理器唤醒语义
- futex(7):Linux 快速用户态锁的基本模型
- C++ 多线程执行与数据竞争
总结
上篇的完整主线可以压缩为:
text
共享对象被并发访问
↓
普通读写缺少 happens-before → data race
↓
mutex 保护共享不变量与临界区
↓
无竞争走用户态快路径,竞争时可能借助 futex 阻塞
↓
condition variable 让线程等待谓词变化
↓
BlockingQueue 用 not_empty / not_full 实现解耦与背压
请牢记:
- 数据竞争在 C/C++ 中是未定义行为,不只是结果偶尔不对;
- 锁保护的是共享不变量,临界区要正确且尽量短;
- 条件变量绑定的是谓词,不是可累积通知;
pthread_cond_wait()必须在持锁状态调用,并始终放在 while 循环中;- 唤醒不等于条件为真,也不等于已经获得 mutex;
- 有界队列提供背压,析构前必须先停止并 join 所有使用者。
📖 下篇预告:《Linux 线程同步进阶:POSIX 信号量、环形队列、线程池、死锁与线程安全(下篇)》
如果本文对你有帮助,欢迎点赞、收藏。下篇将把这些同步原语组合成环形队列与线程池,并系统梳理死锁、可重入、单例和 STL/智能指针的线程安全边界。