
线程同步方法图解从多人厨房到播放器状态机线程同步不是“加锁”两个字能概括的事它更像给一群人协作制定规则谁能进、谁要等、什么时候一起走、谁来统一排队处理。为什么需要线程同步多人厨房的混乱现场想象一个后厨同时来了 4 个厨师。大家都想快点出菜但厨房里只有一口锅、三个灶台、一张菜单、一个出餐铃。如果没有协作规则会发生什么厨房里的混乱程序里的对应问题需要的同步语义两个人同时往同一口锅里倒菜多线程同时修改同一份数据互斥访问厨师一直问“饭熟了吗”线程忙等某个条件条件通知10 个人抢 3 个灶台并发任务超过资源容量限制并发数旅行团没等齐就发车多个线程阶段不同步阶段对齐开业剪彩被办了 5 次全局初始化重复执行只执行一次计数牌被同时按简单变量读改写冲突原子操作所有人都改菜单复杂状态机被多线程打乱消息排队串行化下面按“为什么要发明它 → 它是什么 → 什么时候用 → 怎么写”的顺序把常见同步方式串起来。1. Mutex为什么需要“一把钥匙”最朴素的问题是同一份数据同一时间只能有一个线程修改。如果两个线程同时执行balance balance 1看起来只是一行代码机器层面却可能拆成“读值 → 加一 → 写回”。两个线程读到同一个旧值就会丢一次更新。Mutex 的规则像厨房钥匙谁拿到钥匙谁进厨房做完必须还钥匙。维度说明解决的问题多线程同时读写同一份共享数据核心 APIpthread_mutex_init、pthread_mutex_lock、pthread_mutex_unlock、pthread_mutex_destroy典型场景保护结构体字段、全局表、链表、队列、状态变量优点语义简单适合大多数共享数据保护缺点锁粒度过大会降低并发忘记 unlock 会死锁示例保护共享计数器。#includepthread.hstaticpthread_mutex_tmutexPTHREAD_MUTEX_INITIALIZER;staticintcounter0;voidadd_one(void){pthread_mutex_lock(mutex);counter;pthread_mutex_unlock(mutex);}使用 Mutex 的核心不是“把所有代码都锁住”而是先问清楚哪一小段代码在访问共享状态只把这段临界区锁住。2. Condition Variable为什么需要“饭熟铃”Mutex 能保证“同一时间只有一个人改锅”但它解决不了另一个问题条件没满足时线程应该怎么等比如消费者线程要从队列取数据。队列为空时它不应该一直循环检查因为这会浪费 CPU。更好的方式是条件不满足就睡觉生产者放入数据后按铃叫醒它。Condition Variable 通常不单独使用而是和 Mutex、状态变量一起出现。组成作用为什么不能省Mutex保护条件对应的共享状态防止检查条件和修改条件并发打架State真实条件例如ready、queue_size、failedCond 只是通知本身不保存业务状态Cond让等待线程睡眠/唤醒避免忙等浪费 CPU经典写法一定是while不是if#includepthread.h#includestdbool.hstaticpthread_mutex_tmutexPTHREAD_MUTEX_INITIALIZER;staticpthread_cond_tcondPTHREAD_COND_INITIALIZER;staticbool readyfalse;voidwait_until_ready(void){pthread_mutex_lock(mutex);while(!ready){pthread_cond_wait(cond,mutex);}pthread_mutex_unlock(mutex);}voidmark_ready(void){pthread_mutex_lock(mutex);readytrue;pthread_cond_broadcast(cond);pthread_mutex_unlock(mutex);}为什么要用while因为线程可能被误唤醒也可能多个线程一起醒来后只有一个线程真正拿到资源。醒来以后重新检查条件是条件变量的基本礼仪。3. Semaphore为什么需要“多个停车位”有些资源不是只能一个线程用而是最多允许 N 个线程同时用。比如 3 个数据库连接、4 个解码任务名额、8 个下载槽位。这时 Mutex 太严格Barrier 又不对味。Semaphore 更像停车场有空位就进没空位就等离开时释放一个空位。维度说明解决的问题控制最多 N 个线程同时访问某类资源核心 APIsem_init、sem_wait、sem_post、sem_destroy典型场景连接池、任务并发数限制、资源池、队列容量控制优点自带计数表达“多个名额”很自然缺点不直接保护复杂共享状态wait/post配对错误会泄漏名额示例最多允许 3 个任务同时执行。#includesemaphore.hstaticsem_tslots;voidinit_slots(void){sem_init(slots,0,3);}voidrun_task(void){sem_wait(slots);/* Do work with limited resource. */sem_post(slots);}Semaphore 的重点是“名额”不是“所有权”。如果你要保护某个结构体内部的一致性仍然优先考虑 Mutex。4. Barrier为什么需要“人齐再发车”有些问题不是抢资源也不是等某个状态而是这一批线程都必须完成当前阶段然后才能进入下一阶段。旅行团最容易理解导游可以允许大家自由活动但下一段行程必须等所有人回到车上再开始。维度说明解决的问题固定数量线程在阶段边界集合核心 APIpthread_barrier_init、pthread_barrier_wait、pthread_barrier_destroy典型场景多线程压测同时起跑、分块计算每轮汇合、复现竞态问题优点语义直接特别适合“等齐”缺点参与线程数要固定有人不来就可能一直等示例4 个线程准备完成后同时开始。#includepthread.h#includestdio.h#defineN4staticpthread_barrier_tbarrier;void*worker(void*arg){longid(long)arg;printf(worker %ld ready\n,id);intretpthread_barrier_wait(barrier);if(retPTHREAD_BARRIER_SERIAL_THREAD){printf(all workers are ready\n);}printf(worker %ld start\n,id);returnNULL;}Barrier 的判断口诀是等人数用 Barrier等条件用 Cond。5. Once为什么需要“只剪彩一次”很多库都有全局表、缓存、单例对象。它们只需要初始化一次但可能被多个线程同时调用。如果每个入口都写一套if (!inited) init();就很容易遇到两个线程同时看到inited false然后重复初始化。维度说明解决的问题多线程环境下保证初始化函数只执行一次核心 APIpthread_once()C 可用std::call_once()典型场景全局查表初始化、单例对象创建、库级静态资源准备优点比自己手写双重检查更安全、更清晰缺点只适合一次性初始化不适合反复启停的状态示例全局表只初始化一次。#includepthread.hstaticpthread_once_tonce_controlPTHREAD_ONCE_INIT;staticintglobal_table[256];staticvoidinit_global_table(void){for(inti0;i256;i){global_table[i]i*i;}}intget_table_value(intindex){pthread_once(once_control,init_global_table);returnglobal_table[index];}如果你的全局数据“初始化后只读”pthread_once()往往比自己加一把全局锁更贴切。6. Atomic为什么需要“电子计数牌”有时共享状态很小只是一个计数器、一个标志位、一个引用计数。为了这么小的操作每次都进 Mutex可能显得笨重。Atomic 的目标是让单个变量的某些操作不可分割比如原子加一、原子读写、原子交换。维度说明解决的问题单个变量的并发读写和读改写核心 APIC11_Atomic、atomic_fetch_add、atomic_load、atomic_store等典型场景引用计数、统计计数、简单 ready flag、无锁结构基础字段优点开销小不一定阻塞线程缺点不适合保护多个字段之间的一致性内存序理解成本高示例原子引用计数。#includestdatomic.hstaticatomic_int ref_count0;voidretain(void){atomic_fetch_add_explicit(ref_count,1,memory_order_relaxed);}intrelease(void){returnatomic_fetch_sub_explicit(ref_count,1,memory_order_acq_rel)-1;}Atomic 适合“小而明确”的同步问题。只要你发现自己想同时维护多个字段的不变量例如state queue size就应该重新考虑 Mutex 或消息队列。7. Message Queue为什么要把“冲进去改状态”变成“排队办事”复杂系统里真正难的往往不是一个变量而是一整套状态机。比如播放器可能同时收到播放、暂停、seek、释放资源、网络回调、解码完成回调。如果这些线程都直接改播放器内部状态状态很快会变成一团乱麻。Message Queue 的思路是外部线程不要直接改核心状态只把命令投递到队列由一个专门线程按顺序处理。维度说明解决的问题多线程同时驱动复杂状态机导致顺序混乱核心组件线程安全队列、消息结构、事件循环、退出消息典型场景UI 线程、播放器控制线程、网络事件处理、Actor 模型优点把复杂状态集中到一个线程顺序清晰缺点有排队延迟需要设计消息优先级、退出和取消语义示例用队列思想串行处理命令。下面代码省略了队列实现只展示线程模型。enumCommandType{CMD_PLAY,CMD_PAUSE,CMD_SEEK,CMD_QUIT,};structCommand{enumCommandTypetype;longvalue;};void*event_loop(void*arg){for(;;){structCommandcmdqueue_pop_blocking();if(cmd.typeCMD_QUIT){break;}handle_command_in_owner_thread(cmd);}returnNULL;}它的本质不是“没有锁”而是把锁藏在队列边界把核心状态从“多线程直接修改”改成“单线程按序修改”。8. RWLock 和 SpinLock两个补充工具除了上面几种工程里还经常见到读写锁和自旋锁。工具为什么出现适合场景不适合场景pthread_rwlock_t读多写少时普通 Mutex 会让读者互相等待配置表、路由表、读多写少缓存写很多、读写关系复杂、需要严格公平性pthread_spinlock_t临界区极短时睡眠/唤醒成本可能比等待更高内核态或非常底层的短临界区用户态长等待、可能被调度出去的线程、移动端省电场景RWLock 像图书馆规则很多人可以同时读同一本书但有人要改书时其他人要先停下。SpinLock 像排队时一直站着盯门不睡觉。它可能很快也可能非常浪费 CPU所以普通业务代码不要优先选它。9. 最后一起对比先归类再选工具选同步方式时不要从“我要加什么锁”开始而要从“我到底在等什么 / 保护什么”开始。问题类型推荐工具生活类比一句话判断共享数据只能一个线程改Mutex厨房钥匙我要保护一段临界区读多写少共享数据RWLock图书馆读写规则多个读者可并发写者独占等某个条件变成真Cond State饭熟铃我等的是ready/failed/queue not empty最多 N 个线程同时用资源Semaphore停车位我等的是资源名额固定 N 个线程都到达Barrier旅行团发车我等的是人数到齐初始化只执行一次Once开业剪彩我怕重复初始化单变量小操作Atomic电子计数牌我只改一个计数或 flag复杂状态机按顺序处理Message Queue取号窗口我想把并发输入串行化极短临界区且能接受忙等SpinLock站着盯门我确认等待非常短10. 常见选错场景错误选择典型表现更好的选择用 Barrier 等后台初始化完成如果后台失败等待方不知道怎么退出cond ready/failed用 Atomic 维护多个字段单个字段都原子但整体状态不一致Mutex 或消息队列用 Mutex 限制并发下载数变成一次只能下载一个浪费并发能力Semaphore用 Cond 但没有状态变量丢通知后线程可能永远睡眠Cond 必须绑定 state用 SpinLock 包住耗时 I/OCPU 空转耗电且拖慢系统Mutex 或异步队列多个线程直接改状态机偶现顺序错乱难复现Message Queue / owner thread11. 记忆口诀可以把线程同步记成一套餐厅规则口诀对应工具抢同一口锅用 Mutex保护共享资源等饭熟铃响用 Cond等条件变化看还有几个灶台用 Semaphore控制并发名额人齐再发车用 Barrier阶段对齐剪彩只一次用 Once一次性初始化小计数别上大锁用 Atomic单变量原子操作菜单统一到窗口用 Queue复杂状态串行化线程同步不是为了“让程序看起来安全”而是为了让并发协作有明确规则。规则越贴近问题本身代码越简单bug 也越少。