线程同步方法图解:从多人厨房到播放器状态机
线程同步不是“加锁”两个字能概括的事,它更像给一群人协作制定规则:谁能进、谁要等、什么时候一起走、谁来统一排队处理。
为什么需要线程同步:多人厨房的混乱现场
想象一个后厨同时来了 4 个厨师。大家都想快点出菜,但厨房里只有一口锅、三个灶台、一张菜单、一个出餐铃。
如果没有协作规则,会发生什么?
下面按“为什么要发明它 → 它是什么 → 什么时候用 → 怎么写”的顺序,把常见同步方式串起来。
1. Mutex:为什么需要“一把钥匙”
最朴素的问题是:同一份数据,同一时间只能有一个线程修改。
如果两个线程同时执行 balance = balance + 1,看起来只是一行代码,机器层面却可能拆成“读值 → 加一 → 写回”。两个线程读到同一个旧值,就会丢一次更新。
Mutex 的规则像厨房钥匙:谁拿到钥匙谁进厨房,做完必须还钥匙。
| |
|---|
| 解决的问题 | |
| 核心 API | pthread_mutex_init、pthread_mutex_lock、pthread_mutex_unlock、pthread_mutex_destroy |
| 典型场景 | |
| 优点 | |
| 缺点 | |
示例:保护共享计数器。
#include<pthread.h>static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;static int counter = 0;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、failed | |
| Cond | | |
经典写法一定是 while,不是 if:
#include<pthread.h>#include<stdbool.h>static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;static bool ready = false;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); ready = true; pthread_cond_broadcast(&cond); pthread_mutex_unlock(&mutex);}
为什么要用 while?因为线程可能被误唤醒,也可能多个线程一起醒来后只有一个线程真正拿到资源。醒来以后重新检查条件,是条件变量的基本礼仪。
3. Semaphore:为什么需要“多个停车位”
有些资源不是只能一个线程用,而是最多允许 N 个线程同时用。比如 3 个数据库连接、4 个解码任务名额、8 个下载槽位。
这时 Mutex 太严格,Barrier 又不对味。Semaphore 更像停车场:有空位就进,没空位就等;离开时释放一个空位。
| |
|---|
| 解决的问题 | |
| 核心 API | sem_init、sem_wait、sem_post、sem_destroy |
| 典型场景 | |
| 优点 | |
| 缺点 | 不直接保护复杂共享状态;wait/post 配对错误会泄漏名额 |
示例:最多允许 3 个任务同时执行。
#include<semaphore.h>static sem_t slots;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:为什么需要“人齐再发车”
有些问题不是抢资源,也不是等某个状态,而是:这一批线程都必须完成当前阶段,然后才能进入下一阶段。
旅行团最容易理解:导游可以允许大家自由活动,但下一段行程必须等所有人回到车上再开始。
| |
|---|
| 解决的问题 | |
| 核心 API | pthread_barrier_init、pthread_barrier_wait、pthread_barrier_destroy |
| 典型场景 | 多线程压测同时起跑、分块计算每轮汇合、复现竞态问题 |
| 优点 | |
| 缺点 | |
示例:4 个线程准备完成后同时开始。
#include<pthread.h>#include<stdio.h>#define N 4static pthread_barrier_t barrier;void *worker(void *arg){ long id = (long)arg; printf("worker %ld ready\n", id); int ret = pthread_barrier_wait(&barrier); if (ret == PTHREAD_BARRIER_SERIAL_THREAD) { printf("all workers are ready\n"); } printf("worker %ld start\n", id); return NULL;}
Barrier 的判断口诀是:等人数,用 Barrier;等条件,用 Cond。
5. Once:为什么需要“只剪彩一次”
很多库都有全局表、缓存、单例对象。它们只需要初始化一次,但可能被多个线程同时调用。
如果每个入口都写一套 if (!inited) init();,就很容易遇到两个线程同时看到 inited == false,然后重复初始化。
| |
|---|
| 解决的问题 | |
| 核心 API | pthread_once() |
| 典型场景 | |
| 优点 | |
| 缺点 | |
示例:全局表只初始化一次。
#include<pthread.h>static pthread_once_t once_control = PTHREAD_ONCE_INIT;static int global_table[256];staticvoidinit_global_table(void){ for (int i = 0; i < 256; ++i) { global_table[i] = i * i; }}intget_table_value(int index){ pthread_once(&once_control, init_global_table); return global_table[index];}
如果你的全局数据“初始化后只读”,pthread_once() 往往比自己加一把全局锁更贴切。
6. Atomic:为什么需要“电子计数牌”
有时共享状态很小,只是一个计数器、一个标志位、一个引用计数。为了这么小的操作每次都进 Mutex,可能显得笨重。
Atomic 的目标是让单个变量的某些操作不可分割,比如原子加一、原子读写、原子交换。
| |
|---|
| 解决的问题 | |
| 核心 API | C11 _Atomic、atomic_fetch_add、atomic_load、atomic_store 等 |
| 典型场景 | 引用计数、统计计数、简单 ready flag、无锁结构基础字段 |
| 优点 | |
| 缺点 | |
示例:原子引用计数。
#include<stdatomic.h>static atomic_int ref_count = 0;voidretain(void){ atomic_fetch_add_explicit(&ref_count, 1, memory_order_relaxed);}intrelease(void){ return atomic_fetch_sub_explicit(&ref_count, 1, memory_order_acq_rel) - 1;}
Atomic 适合“小而明确”的同步问题。只要你发现自己想同时维护多个字段的不变量,例如 state + queue + size,就应该重新考虑 Mutex 或消息队列。
7. Message Queue:为什么要把“冲进去改状态”变成“排队办事”
复杂系统里,真正难的往往不是一个变量,而是一整套状态机。
比如播放器可能同时收到:播放、暂停、seek、释放资源、网络回调、解码完成回调。如果这些线程都直接改播放器内部状态,状态很快会变成一团乱麻。
Message Queue 的思路是:外部线程不要直接改核心状态,只把命令投递到队列;由一个专门线程按顺序处理。
| |
|---|
| 解决的问题 | |
| 核心组件 | |
| 典型场景 | UI 线程、播放器控制线程、网络事件处理、Actor 模型 |
| 优点 | |
| 缺点 | |
示例:用队列思想串行处理命令。下面代码省略了队列实现,只展示线程模型。
enum CommandType { CMD_PLAY, CMD_PAUSE, CMD_SEEK, CMD_QUIT,};struct Command { enum CommandType type; long value;};void *event_loop(void *arg){ for (;;) { struct Command cmd = queue_pop_blocking(); if (cmd.type == CMD_QUIT) { break; } handle_command_in_owner_thread(&cmd); } return NULL;}
它的本质不是“没有锁”,而是把锁藏在队列边界,把核心状态从“多线程直接修改”改成“单线程按序修改”。
8. RWLock 和 SpinLock:两个补充工具
除了上面几种,工程里还经常见到读写锁和自旋锁。
| | | |
|---|
pthread_rwlock_t | | | |
pthread_spinlock_t | | | 用户态长等待、可能被调度出去的线程、移动端省电场景 |
RWLock 像图书馆规则:很多人可以同时读同一本书,但有人要改书时,其他人要先停下。
SpinLock 像排队时一直站着盯门,不睡觉。它可能很快,也可能非常浪费 CPU,所以普通业务代码不要优先选它。
9. 最后一起对比:先归类,再选工具
选同步方式时,不要从“我要加什么锁”开始,而要从“我到底在等什么 / 保护什么”开始。
| | | |
|---|
| | | |
| | | |
| | | 我等的是 ready/failed/queue not empty |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
10. 常见选错场景
| | |
|---|
| | cond + ready/failed |
| | |
| | |
| | |
| | |
| | Message Queue / owner thread |
11. 记忆口诀
可以把线程同步记成一套餐厅规则:
| |
|---|
| 抢同一口锅,用 Mutex | |
| 等饭熟铃响,用 Cond | |
| 看还有几个灶台,用 Semaphore | |
| 人齐再发车,用 Barrier | |
| 剪彩只一次,用 Once | |
| 小计数别上大锁,用 Atomic | |
| 菜单统一到窗口,用 Queue | |
线程同步不是为了“让程序看起来安全”,而是为了让并发协作有明确规则。规则越贴近问题本身,代码越简单,bug 也越少。