当前位置:首页>Linux>linux系统编程(二):mutex与atomic实战指南

linux系统编程(二):mutex与atomic实战指南

  • 2026-10-11 05:39:16
linux系统编程(二):mutex与atomic实战指南

mutex 和 atomic 怎么选 —— 从原理到实战

多线程读写同一个变量,几乎所有 bug 都来源于"以为它原子,其实不是"。这篇用一个简单的"1 亿次 +1"实测,把 mutex 和 atomic 的差异、性能、底层机制一次讲清楚。


0. 一个实验

让 4 个线程各自 +1 一亿次,预期最终 counter = 4 亿。

三种写法

写法 A:裸 int

int counter = 0;void *worker(void *arg){    for (int i = 0; i < 100000000; i++) counter++;    return NULL;}

实测结果:约 1 ~ 3 亿(每次都不一样,远小于 4 亿)。

写法 B:mutex 保护

int counter = 0;pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;void *worker(void *arg){    for (int i = 0; i < 100000000; i++) {        pthread_mutex_lock(&m);        counter++;        pthread_mutex_unlock(&m);    }    return NULL;}

实测结果:4 亿(正确),耗时约 30 秒。

写法 C:atomic

#include<stdatomic.h>atomic_int counter = 0;void *worker(void *arg){    for (int i = 0; i < 100000000; i++) {        atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);    }    return NULL;}

实测结果:4 亿(正确),耗时约 3 秒。

为什么裸 int 错?为什么 atomic 比 mutex 快 10 倍?下面拆解。


1. 为什么裸 int 会丢数据?

counter++ 在汇编层不是一条指令,是三步:

load  counter -> r0    (Read)add   r0, 1            (Modify)store r0 -> counter    (Write)

两个线程并发执行时,三步可以任意交错:

最终 counter = 11,而不是 12 —— 丢失了一次更新。这就是 data race:多线程并发读写同一变量、又没有任何同步原语保护。

按 C11 标准,data race 是未定义行为(UB)。编译器/CPU 有权做任何优化,包括:

  • 把这个变量优化成寄存器常量(永远不从内存读)
  • 跨越循环边界 reorder
  • 直接合并多次读写

所以"裸 int 实测能跑出某个值"完全靠运气,改一下编译选项、换一个 CPU、加一个 LTO,行为就可能完全变。


2. mutex:用"锁"串行化访问

2.1 基础用法

#include<pthread.h>// 静态初始化(最方便)pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;// 或动态初始化pthread_mutex_t m;pthread_mutex_init(&m, NULL);// 加锁 / 解锁 / 销毁pthread_mutex_lock(&m);counter++;pthread_mutex_unlock(&m);pthread_mutex_destroy(&m);  // 必须在没人用了之后

2.2 trylock / timedlock

intpthread_mutex_trylock(pthread_mutex_t *m);// 拿不到锁立即返回 EBUSY,不阻塞intpthread_mutex_timedlock(pthread_mutex_t *m, conststruct timespec *abstime);// 拿不到锁等到 abstime 时刻

适用场景:

  • trylock
    :避免阻塞主线程的兜底逻辑
  • timedlock
    :带超时的临界区(防死锁兜底)

2.3 mutex 底层:futex 双路径

Linux 上的 pthread_mutex 基于 futex (Fast Userspace muTEX) 实现。设计哲学:99% 的锁都是无竞争的,能在用户态搞定就别进内核。

伪代码:

struct mutex {    int state;   // 0 = 空闲;1 = 有人持有;2 = 有人持有 + 有等待者};void lock(mutex *m) {    /* ① fast path:无竞争 → 用户态 CAS,约 30-60 ns */    if (atomic_compare_exchange(&m->state, 0, 1)) return;    /* ② slow path:有人持有 → 进内核排队,约 1-10 μs */    while (atomic_exchange(&m->state, 2) != 0) {        syscall(SYS_futex, &m->state, FUTEX_WAIT, 2);    }}void unlock(mutex *m) {    int prev = atomic_exchange(&m->state, 0);    if (prev == 2) {        /* 有人在等 → syscall 唤醒 */        syscall(SYS_futex, &m->state, FUTEX_WAKE, 1);    }}

2.4 mutex 性能数据

典型 ARM64 / x86-64 实测:

场景
耗时
无竞争 lock + unlock
30 ~ 60 ns
(纯用户态 CAS)
有竞争(进内核等)
1 ~ 10 μs
阻塞被唤醒一轮
10 ~ 100 μs

fast path 没有 syscall,开销跟普通函数调用差不多。慢的是 slow path —— 一旦进内核就跨越了用户态/内核态切换 + 调度。

2.5 mutex 的常见坑

  1. 忘记 unlock:所有路径(包括异常 / 提前 return)都要解锁。C++ 用 RAII / std::lock_guard,C 靠纪律 + cleanup_push。

  2. 递归锁:默认 mutex 不允许同一线程 lock 两次(死锁)。需要时用:

    pthread_mutex_t m = PTHREAD_RECURSIVE_MUTEX_INITIALIZER_NP;
  3. 死锁:A 持锁 1 等锁 2,B 持锁 2 等锁 1。规避:固定加锁顺序 + 分级锁。

  4. 锁粒度:临界区太大 → 并发度低;太小 → 可能数据不一致。粒度选择没标准答案,看场景。

  5. destroy 时还有人持锁:UB。destroy 必须在所有线程退出 / 不再使用之后。


3. atomic:硬件级原子操作

3.1 基础类型和操作

C11 <stdatomic.h>:

#include<stdatomic.h>atomic_int counter;atomic_init(&counter, 0);/* 原子加(RMW = Read-Modify-Write,单条指令完成) */atomic_fetch_add(&counter, 1);/* 简单读写 */int v = atomic_load(&counter);atomic_store(&counter, 0);/* CAS(compare-and-swap,原子条件写) */int expected = 10, desired = 20;atomic_compare_exchange_strong(&counter, &expected, desired);/* * 如果 *counter == expected:写入 desired,返回 true * 否则:把 *counter 实际值写回 expected,返回 false */

可用类型不止 atomic_int,还有 atomic_long、atomic_bool、atomic_uintptr_t、_Atomic(struct foo *) 等。

3.2 底层:硬件原子指令

x86-64 上 atomic_fetch_add 编译成:

lock xadd %eax, (%rdi)

lock 前缀让这条指令变成原子操作,整个 cache line 在执行期间被独占。

ARM64 上没有 lock 前缀,而是用 LL/SC(Load-Linked / Store-Conditional):

1: ldxr  w0, [x1]       ; load-exclusive,标记这块内存   add   w0, w0, #1   stxr  w2, w0, [x1]   ; store-exclusive,期间被改过会失败   cbnz  w2, 1b         ; 失败重试

或者 ARM v8.1 后用 ldadd 一条指令完成。

无论哪种实现,整个 RMW 在硬件层不可被打断。

3.3 memory_order:要多强的同步保证

atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);

memory_order 选项:

选项
强度
适用
relaxed
最弱:只保证原子性,不保证跨变量顺序
计数器(不在乎相对顺序)
acquire
读屏障:之后的所有读必然看到对方 release 之前的写
标志位读
release
写屏障:之前的所有写在 store 完成时对其他线程可见
标志位写
acq_rel
acquire + release
RMW(既读又写)
seq_cst
全局总序
默认值,最严格但最慢

acquire / release 配对:建立 happens-before

最常用的同步模式:

/* 生产者线程 */data = 42;atomic_store_explicit(&ready, 1, memory_order_release);/* release 保证 data = 42 这个写在 store 之前完成 *//* 消费者线程 */while (atomic_load_explicit(&ready, memory_order_acquire) == 0)    /* spin */ ;/* acquire 保证读到 1 之后能看到 data = 42 */int x = data;   /* 一定是 42 */

如果没有 acquire / release,CPU 和编译器可以把指令前后重排,消费者可能读到 ready=1 但 data 还是旧值。


4. mutex vs atomic:怎么选

4.1 性能对比(呼应开头的实验)

ARM64 实测(4 线程 × 1 亿次 +1):

实现
耗时
单次操作
裸 int(错的)
~1.5 秒
~3.7 ns
atomic
~3 秒
~7.5 ns
mutex
~30 秒
~75 ns

atomic 比 mutex 快约 10 倍(无竞争场景)。竞争激烈时差距更大 —— mutex 可能进 syscall(~μs 级),atomic 只是自旋重试(~ns 级)。

4.2 选择决策树

4.3 atomic 单变量也救不了:经典反例

银行转账:从账户 A 减 100,账户 B 加 100。要保持"两账户总额恒定"。

atomic_int balance_a = 1000;atomic_int balance_b = 1000;void transfer(int amount) {    atomic_fetch_sub(&balance_a, amount);   /* ← 这里和下面之间有窗口期 */    /* 别的线程读 balance_a + balance_b 总和会少 amount */    atomic_fetch_add(&balance_b, amount);}

虽然两个变量各自是 atomic,但两步之间不是原子的。要让总和恒定,必须用 mutex 把两步包起来:

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;int balance_a = 1000, balance_b = 1000;voidtransfer(int amount){    pthread_mutex_lock(&m);    balance_a -= amount;    balance_b += amount;    pthread_mutex_unlock(&m);}

多变量协调 → mutex,没有捷径。

4.4 atomic 复合操作:用 CAS 循环造

如果 atomic 没提供你要的原语(比如"如果 x < 10 才 ++"),用 CAS 循环:

atomic_int x;boolincrement_if_small(void) {    int cur = atomic_load(&x);    while (cur < 10) {        /* 期望当前是 cur,写入 cur+1;         * 如果中途被改过,cur 会被自动更新成新值,重试 */        if (atomic_compare_exchange_weak(&x, &cur, cur + 1))            return true;    }    return false;}

这是 lock-free 数据结构的基础套路(无锁队列、无锁栈、引用计数等)。

4.5 双角度对比表

维度
atomic
mutex
互斥性
❌(多个线程能同时进入)
✅
顺序性 / 可见性
✅
✅
阻塞
❌ 永不阻塞
⚠️ 可能阻塞
死锁风险
❌ 完全不可能
⚠️ 有
性能(无竞争)
⭐⭐⭐ ~10 ns
⭐⭐ ~50 ns
性能(有竞争)
⭐⭐⭐ 自旋等
⭐ 进内核 ~μs
单变量原子操作
✅ 完美
浪费
多变量协调
❌ 不够
✅
复杂逻辑临界区
❌
✅
学习曲线
陡(memory_order)
平

经验法则:

  • 计数器、标志位、单变量 → atomic
  • 多变量协调、复杂临界区 → mutex
  • 性能敏感 hot path 上的简单数据 → atomic
  • 不确定怎么选 → mutex(更安全,性能差一个数量级但绝大多数场景能接受)

5. 几个真正容易踩的坑

5.1 atomic 的 RMW 陷阱

看着像 atomic 其实不是:

atomic_int x;/* ⛔ 这是三步,不是原子的 */int v = atomic_load(&x);v++;atomic_store(&x, v);/* ✅ 这才是原子的 */atomic_fetch_add(&x, 1);

5.2 memory_order 选错

/* ⛔ 用 relaxed 做生产者-消费者同步,可能读到 data 旧值 */data = 42;atomic_store_explicit(&ready, 1, memory_order_relaxed);/* ✅ 必须用 release */data = 42;atomic_store_explicit(&ready, 1, memory_order_release);

不确定时用默认的 seq_cst(不写 explicit 后缀),代码更慢但绝对不会出错。

5.3 mutex 跨进程不能用 PTHREAD_MUTEX_INITIALIZER

PTHREAD_MUTEX_INITIALIZER 只在进程内有效。跨进程要:

pthread_mutexattr_t attr;pthread_mutexattr_init(&attr);pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);pthread_mutex_init(&m, &attr);   /* m 必须在共享内存里 */pthread_mutexattr_destroy(&attr);

5.4 析构忘记 destroy mutex

struct foo {    pthread_mutex_t lock;    ...};struct foo *f = malloc(sizeof(*f));pthread_mutex_init(&f->lock, NULL);/* 用完了... */pthread_mutex_destroy(&f->lock);   /* ✅ 必须在 free 之前 */free(f);

直接 free 不 destroy 在 Linux 上通常没问题(pthread_mutex 内部不分配额外资源),但严格来说是资源泄漏,valgrind 等工具会标记。

5.5 atomic_bool 不能用赋值

atomic_bool b;b = true;            /* ⛔ 不是 atomic 操作 */atomic_store(&b, true);  /* ✅ */

C11 规定"对 atomic 变量的所有访问必须通过 atomic_* 函数",直接 = 是 UB。


6. 一张图理清 mutex 和 atomic 的层级关系

mutex 和 atomic 都建立在 CPU 原子指令之上,但抽象层级不同:

  • atomic
     是单点原子操作 —— 硬件指令直接保证
  • mutex
     是临界区互斥 —— 用 atomic CAS + 内核排队组合实现,包多条语句

它们解决的是同一类问题(共享内存的并发访问),但工具的颗粒度不同。


7. 收尾

写多线程代码的核心难点不在于"会不会用 lock",而在于先想清楚"要保护什么数据 / 什么不变量"。然后选工具:

  • 保护单变量的原子操作
     → atomic
  • 保护多个变量的某种关系
     → mutex
  • 保护一段代码逻辑
     → mutex

mutex 慢是为了让线程睡觉省 CPU;atomic 快是因为它不睡觉但占着 CPU 自旋重试。这是一个 trade-off,没有绝对的对错。

新手常犯的错是一上来就 mutex(性能浪费)或一上来就 atomic 自己造车(撞上多变量场景必须 mutex 的坑)。先想清楚要保护什么,再选工具,多线程代码 80% 的 bug 就避免了。

请在微信客户端打开

最新文章

随机文章