当前位置:首页>Linux>linux线程同步图解

linux线程同步图解

  • 2026-09-11 00:12:46
linux线程同步图解

线程同步方法图解:从多人厨房到播放器状态机

线程同步不是“加锁”两个字能概括的事,它更像给一群人协作制定规则:谁能进、谁要等、什么时候一起走、谁来统一排队处理。

为什么需要线程同步:多人厨房的混乱现场

想象一个后厨同时来了 4 个厨师。大家都想快点出菜,但厨房里只有一口锅、三个灶台、一张菜单、一个出餐铃。

如果没有协作规则,会发生什么?

厨房里的混乱
程序里的对应问题
需要的同步语义
两个人同时往同一口锅里倒菜
多线程同时修改同一份数据
互斥访问
厨师一直问“饭熟了吗”
线程忙等某个条件
条件通知
10 个人抢 3 个灶台
并发任务超过资源容量
限制并发数
旅行团没等齐就发车
多个线程阶段不同步
阶段对齐
开业剪彩被办了 5 次
全局初始化重复执行
只执行一次
计数牌被同时按
简单变量读改写冲突
原子操作
所有人都改菜单
复杂状态机被多线程打乱
消息排队串行化

下面按“为什么要发明它 → 它是什么 → 什么时候用 → 怎么写”的顺序,把常见同步方式串起来。

1. Mutex:为什么需要“一把钥匙”

最朴素的问题是:同一份数据,同一时间只能有一个线程修改。

如果两个线程同时执行 balance = balance + 1,看起来只是一行代码,机器层面却可能拆成“读值 → 加一 → 写回”。两个线程读到同一个旧值,就会丢一次更新。

Mutex 的规则像厨房钥匙:谁拿到钥匙谁进厨房,做完必须还钥匙。

维度
说明
解决的问题
多线程同时读写同一份共享数据
核心 APIpthread_mutex_init
、pthread_mutex_lock、pthread_mutex_unlock、pthread_mutex_destroy
典型场景
保护结构体字段、全局表、链表、队列、状态变量
优点
语义简单,适合大多数共享数据保护
缺点
锁粒度过大会降低并发;忘记 unlock 会死锁

示例:保护共享计数器。

#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 只是通知,本身不保存业务状态
Cond
让等待线程睡眠/唤醒
避免忙等浪费 CPU

经典写法一定是 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 更像停车场:有空位就进,没空位就等;离开时释放一个空位。

维度
说明
解决的问题
控制最多 N 个线程同时访问某类资源
核心 APIsem_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:为什么需要“人齐再发车”

有些问题不是抢资源,也不是等某个状态,而是:这一批线程都必须完成当前阶段,然后才能进入下一阶段。

旅行团最容易理解:导游可以允许大家自由活动,但下一段行程必须等所有人回到车上再开始。

维度
说明
解决的问题
固定数量线程在阶段边界集合
核心 APIpthread_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,然后重复初始化。

维度
说明
解决的问题
多线程环境下保证初始化函数只执行一次
核心 APIpthread_once()
,C++ 可用 std::call_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
读多写少时,普通 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/O
CPU 空转,耗电且拖慢系统
Mutex 或异步队列
多个线程直接改状态机
偶现顺序错乱,难复现
Message Queue / owner thread

11. 记忆口诀

可以把线程同步记成一套餐厅规则:

口诀
对应工具
抢同一口锅,用 Mutex
保护共享资源
等饭熟铃响,用 Cond
等条件变化
看还有几个灶台,用 Semaphore
控制并发名额
人齐再发车,用 Barrier
阶段对齐
剪彩只一次,用 Once
一次性初始化
小计数别上大锁,用 Atomic
单变量原子操作
菜单统一到窗口,用 Queue
复杂状态串行化

线程同步不是为了“让程序看起来安全”,而是为了让并发协作有明确规则。规则越贴近问题本身,代码越简单,bug 也越少。

最新文章

随机文章