【Linux线程同步】从数据竞争到 mutex、条件变量与生产者消费者(上篇)

📅 2026/8/21 9:30:42
【Linux线程同步】从数据竞争到 mutex、条件变量与生产者消费者(上篇)
本文定位这是 Linux 线程同步系列上篇。我们从一段“偶尔出错”的售票代码出发逐层拆解数据竞争、临界区、互斥锁、条件变量和阻塞队列。学习目标不仅会调用pthread_mutex_lock()和pthread_cond_wait()还要真正理解为什么ticket--不是原子操作、锁建立了什么顺序、条件变量为什么必须绑定谓词、为什么 wait 必须放在 while 中以及生产者消费者如何实现解耦与背压。系列导航上篇聚焦 mutex 与 condition variable下篇继续讨论 POSIX 信号量、环形队列、线程池、线程安全/可重入、单例与死锁。文章目录一、为什么共享内存会带来并发问题二、ticket-- 为什么不是原子操作三、mutex把临界区变成互斥区四、互斥锁在 Linux 中如何工作五、条件变量等待状态变化而不是轮询六、pthread_cond_wait 为什么必须接收 mutex七、生产者消费者与 BlockingQueue八、常见错误与高频面试题总结一、为什么共享内存会带来并发问题1.1 先分清四个概念共享资源多个执行流都可能访问的对象例如全局变量、堆对象、队列、文件或设备临界资源需要在并发访问时受到保护的共享资源临界区线程中读取或修改临界资源的那段代码互斥同一时刻只允许一个执行流进入指定临界区。互斥保护的是访问协议不是给变量加上一层“任何人都碰不到”的硬件外壳。同一个地址仍位于进程共享地址空间只是所有遵守协议的线程都必须先取得同一把锁。1.2 “线程栈变量一定私有”也要加限定自动变量通常位于当前线程栈中其他线程默认没有它的名字但只要把地址或引用传出去其他线程仍能访问。所谓“线程私有栈”描述的是每条线程独立使用自己的栈区域并不代表硬件禁止其他线程寻址。intlocal42;pthread_create(tid,nullptr,worker,local);// 地址已经跨线程共享此时必须保证local在线程使用期间仍然存活若存在并发读写要建立同步创建者不能提前离开作用域或销毁宿主对象。1.3 数据竞争不是“结果不稳定”这么简单在 C/C 内存模型中如果两个线程并发访问同一内存位置至少一个访问是写操作并且两者之间没有 happens-before 关系也没有使用合适的原子操作就形成了data race。对普通 C/C 对象发生数据竞争时程序行为是未定义的。这意味着编译器不只可能生成“丢失更新”还可以基于“程序没有数据竞争”的前提进行优化。因此不能仅用sleep()、降低优化等级或“在我的机器上正常”来证明线程安全。二、ticket-- 为什么不是原子操作2.1 一个有数据竞争的售票模型intticket100;void*route(void*arg){constchar*namestatic_castconstchar*(arg);while(true){if(ticket0){usleep(1000);// 扩大竞态窗口仅用于演示std::printf(%s sells ticket: %d\n,name,ticket);--ticket;}else{break;}}returnnullptr;}多个线程可能同时观察到ticket 0随后分别打印并写回。出现 0、负数或重复票号只是表象根因是对ticket的并发访问没有同步。2.2 一条 C 语句不等于一条不可分割的硬件操作概念上ticket--可以拆成三步load : 从内存读取 ticket 到寄存器 update : 寄存器中的值减 1 store : 把结果写回 ticket假设初值为 10线程 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 “原子变量”能否替代锁如果只需要无条件计数可以考虑原子类型std::atomicintcounter{0};counter.fetch_add(1,std::memory_order_relaxed);但售票逻辑包含“检查大于 0、取得票号、递减、打印/交付”的复合不变量。单独把ticket改成atomicint并不会自动让整段业务成为一个事务。你需要 CAS 循环或者直接用 mutex 把复合临界区保护起来。三、mutex把临界区变成互斥区3.1 Pthreads mutex 基本接口静态初始化pthread_mutex_t mutexPTHREAD_MUTEX_INITIALIZER;动态初始化与销毁pthread_mutex_t mutex;intrcpthread_mutex_init(mutex,nullptr);// 使用 mutexrcpthread_mutex_destroy(mutex);加锁与解锁intrcpthread_mutex_lock(mutex);// critical sectionrcpthread_mutex_unlock(mutex);Pthreads 函数通常直接返回错误号而不是通过errno报错intrcpthread_mutex_lock(mutex);if(rc!0){std::fprintf(stderr,pthread_mutex_lock: %s\n,std::strerror(rc));}这里讨论的是默认普通 mutex。若显式使用 robust mutexpthread_mutex_lock()返回EOWNERDEAD时调用线程实际上已经取得锁但受保护状态被标记为不一致需要按 robust mutex 协议修复并调用pthread_mutex_consistent()。3.2 临界区的正确边界修正售票代码pthread_mutex_t mutexPTHREAD_MUTEX_INITIALIZER;intticket100;void*route(void*arg){constchar*namestatic_castconstchar*(arg);while(true){pthread_mutex_lock(mutex);if(ticket0){pthread_mutex_unlock(mutex);break;}constintsoldticket--;pthread_mutex_unlock(mutex);// 与共享状态无关的慢操作放到锁外std::printf(%s sells ticket: %d\n,name,sold);}returnnullptr;}这里把“检查与递减”放在同一临界区同时把打印移到锁外缩短持锁时间。3.3 锁的范围不是越大越安全锁太小复合不变量被拆开仍可能竞态。lock();boolavailableticket0;unlock();if(available){lock();--ticket;// 两次加锁之间状态可能已变化unlock();}锁太大把 I/O、睡眠、网络请求或耗时计算放进临界区会让其他线程长时间等待程序虽然“正确”并发度却接近单线程。正确原则是围绕共享不变量确定临界区以满足正确性为前提尽量缩短持锁时间。3.4 RAII 避免遗漏 unlock手写多条返回路径很容易漏解锁pthread_mutex_lock(mutex);if(error){returnnullptr;// 锁泄漏}pthread_mutex_unlock(mutex);C 项目优先使用 RAIIstd::mutex mutex;voidupdate(){std::lock_guardstd::mutexguard(mutex);// 离开作用域自动解锁}若为了学习封装pthread_mutex_t包装类应禁止复制并让 guard 持有引用classMutex{public:Mutex(){check(pthread_mutex_init(mutex_,nullptr));}~Mutex(){pthread_mutex_destroy(mutex_);}Mutex(constMutex)delete;Mutexoperator(constMutex)delete;voidlock(){check(pthread_mutex_lock(mutex_));}voidunlock()noexcept{if(pthread_mutex_unlock(mutex_)!0){std::terminate();// 解锁失败说明同步不变量已经被破坏}}pthread_mutex_t*native_handle(){returnmutex_;}private:staticvoidcheck(intrc){if(rc!0)throwstd::system_error(rc,std::generic_category());}pthread_mutex_t mutex_{};};classLockGuard{public:explicitLockGuard(Mutexmutex):mutex_(mutex){mutex_.lock();}~LockGuard(){mutex_.unlock();}LockGuard(constLockGuard)delete;LockGuardoperator(constLockGuard)delete;private:Mutexmutex_;};教学代码常用(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 解决安全条件变量解决等待互斥锁保证同一时刻只有一个线程修改队列但无法回答队列为空时消费者应该怎样高效等待新任务忙轮询会浪费 CPUwhile(true){pthread_mutex_lock(mutex);boolemptyqueue.empty();pthread_mutex_unlock(mutex);if(!empty)break;}条件变量允许线程等待某个由共享状态表达的谓词例如消费者可继续queue 非空 或 系统正在停止 生产者可继续queue 未满 或 系统正在停止5.2 条件变量不是“存放条件的变量”真正的条件存在共享数据中condition variable 负责把等待者挂起并在状态变化后通知它重新检查。predicate !queue.empty() || stopping cond 等待/通知机制 mutex 保护 predicate 所依赖的共享数据signal 也不是可累积消息。若发出通知时没有等待者它通常不会替未来的等待者保存“一张票”。因此程序正确性必须来自谓词而不是依赖通知次数。5.3 Pthreads 条件变量接口pthread_cond_t condPTHREAD_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 为什么必须接收 mutex6.1 错误写法先 unlock再 waitpthread_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()负责持有 mutex 并检查谓词 ↓ 原子地释放 mutex 进入条件变量等待 ↓ 收到通知或发生伪唤醒 ↓ 重新竞争并取得 mutex ↓ 从 pthread_cond_wait 返回 ↓ 再次检查谓词这里的“原子”是针对“其他线程取得同一 mutex 后再通知该 condition variable”这一交互而言目的就是关闭丢失唤醒窗口。6.3 为什么必须用 while而不是 if正确模板pthread_mutex_lock(mutex);while(!predicate()){pthread_cond_wait(cond,mutex);}// mutex 仍由当前线程持有predicate 此刻为真consume_or_update_state();pthread_mutex_unlock(mutex);原因至少有三个POSIX 允许spurious wakeupbroadcast 会唤醒多个线程但第一个获得锁的线程可能先把资源取走从 signal 到当前线程重新获得 mutex 之间谓词可能再次变化。因此通知只是“状态可能变化了”不是“资源已经分配给你”。6.4 signal 放在锁内还是锁外只要谓词变化受到同一 mutex 保护两种写法都可能正确pthread_mutex_lock(mutex);readytrue;pthread_cond_signal(cond);pthread_mutex_unlock(mutex);或pthread_mutex_lock(mutex);readytrue;pthread_mutex_unlock(mutex);pthread_cond_signal(cond);第一种更容易审查“状态变化与通知”的关系第二种有时减少被唤醒线程立刻阻塞在 mutex 上的机会。选择应基于明确谓词和性能测量而不是套用绝对口诀。七、生产者消费者与 BlockingQueue7.1 为什么需要生产者消费者模型生产者不直接调用消费者而是把任务放入有界缓冲区消费者从缓冲区取任务。它带来三项核心价值解耦生产和处理逻辑只依赖队列契约并发生产者和消费者可以在不同线程推进削峰与背压队列吸收短时波动满时阻塞或拒绝继续生产。无界队列只能削峰不能形成有效背压。若生产速度长期大于消费速度内存仍会持续增长。7.2 有界阻塞队列的两个谓词消费者等待条件队列非空或系统停止 生产者等待条件队列未满或系统停止需要一把 mutex 保护队列、容量和停止状态not_empty条件变量唤醒消费者not_full条件变量唤醒生产者。7.3 一个更完整的 Pthreads BlockingQueue下面保留 PDF 的 Pthreads 主线但补上停止协议、const 引用和错误边界#includepthread.h#includecstddef#includequeue#includestdexcepttemplateclassTclassBlockingQueue{public:explicitBlockingQueue(std::size_t capacity):capacity_(capacity){if(capacity_0){throwstd::invalid_argument(capacity must be positive);}pthread_mutex_init(mutex_,nullptr);pthread_cond_init(not_empty_,nullptr);pthread_cond_init(not_full_,nullptr);}BlockingQueue(constBlockingQueue)delete;BlockingQueueoperator(constBlockingQueue)delete;boolpush(constTvalue){pthread_mutex_lock(mutex_);while(queue_.size()capacity_!closed_){pthread_cond_wait(not_full_,mutex_);}if(closed_){pthread_mutex_unlock(mutex_);returnfalse;}queue_.push(value);pthread_cond_signal(not_empty_);pthread_mutex_unlock(mutex_);returntrue;}boolpop(Tout){pthread_mutex_lock(mutex_);while(queue_.empty()!closed_){pthread_cond_wait(not_empty_,mutex_);}if(queue_.empty()closed_){pthread_mutex_unlock(mutex_);returnfalse;}outstd::move(queue_.front());queue_.pop();pthread_cond_signal(not_full_);pthread_mutex_unlock(mutex_);returntrue;}voidclose(){pthread_mutex_lock(mutex_);closed_true;pthread_cond_broadcast(not_empty_);pthread_cond_broadcast(not_full_);pthread_mutex_unlock(mutex_);}~BlockingQueue(){// 调用方必须先停止并 join 所有使用者pthread_cond_destroy(not_empty_);pthread_cond_destroy(not_full_);pthread_mutex_destroy(mutex_);}private:std::queueTqueue_;std::size_t capacity_;boolclosed_{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 时销毁它们。正确关闭顺序通常是停止接收新任务 ↓ 在 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 中属于未定义行为。问题 3pthread_cond_wait 返回时 mutex 是什么状态调用前线程必须持有 mutexwait 原子释放 mutex 并进入等待返回前重新取得 mutex所以函数成功返回时当前线程再次持有该 mutex。问题 4为什么条件等待必须使用 while因为可能发生伪唤醒多个等待者可能同时醒来而且谓词在重新取得 mutex 前可能再次变化。返回只代表“应该重新检查”不代表条件必然为真。问题 5signal 会保存到下一次 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 多线程执行与数据竞争总结上篇的完整主线可以压缩为共享对象被并发访问 ↓ 普通读写缺少 happens-before → data race ↓ mutex 保护共享不变量与临界区 ↓ 无竞争走用户态快路径竞争时可能借助 futex 阻塞 ↓ condition variable 让线程等待谓词变化 ↓ BlockingQueue 用 not_empty / not_full 实现解耦与背压请牢记数据竞争在 C/C 中是未定义行为不只是结果偶尔不对锁保护的是共享不变量临界区要正确且尽量短条件变量绑定的是谓词不是可累积通知pthread_cond_wait()必须在持锁状态调用并始终放在 while 循环中唤醒不等于条件为真也不等于已经获得 mutex有界队列提供背压析构前必须先停止并 join 所有使用者。下篇预告《Linux 线程同步进阶POSIX 信号量、环形队列、线程池、死锁与线程安全下篇》如果本文对你有帮助欢迎点赞、收藏。下篇将把这些同步原语组合成环形队列与线程池并系统梳理死锁、可重入、单例和 STL/智能指针的线程安全边界。