1. 一次线上事故让我重新理解了线程同步
先讲个真实经历。
几年前我维护一个嵌入式网关程序,进程里有主线程和业务线程。主线程负责从网络上周期拉取配置,解析后写入一个全局配置结构体;业务线程每次转发数据前都要读这个结构体做策略判断。程序上线第二天就出问题了:转发策略时而生效、时而保持旧值,最诡异的是偶尔整个进程直接core dump。当时第一反应是"网络数据包解析是不是有bug",排查了整整一天没定位到。后来在代码里铺了一堆打印,才看到两个线程在同时访问同一个结构体的不同字段——一个在写,一个在读,每次读到的数据都处于"半新半旧"的中间态。
这就是典型的线程同步缺失。线程同步与互斥不是"让代码更规范"这种锦上添花的事,而是保证并发程序正确性的底线。没有同步,多线程程序的结果就是不确定的,今天跑得好好的,明天换个CPU调度时机就崩了。
这一章在《Linux系统编程》里属于承上启下的位置:前面讲了进程、文件、信号,后面要讲网络编程和高级IO,而线程同步是贯穿所有并发场景的基础设施。我见过不少刚开始接触系统编程的朋友,写多线程程序时只知道pthread_create,等数据出错、程序卡死、CPU跑满,才回头补同步的课。这篇文章把我这些年踩过的坑、复现过的死锁、实测过的数据一并整理出来,给正在学这章的朋友一份可以"抄作业"的参考。
1.1 双线程更新配置导致的"半新半旧"状态
很多人把线程同步理解成"让多个线程共享数据时不会出错",这个说法不够准确。共享数据本身不会出错,出错的是多个线程在同一段时间内交叉访问同一块内存,导致某个线程读到的数据不是"某个完整时刻"的数据,而是"写了一半"的数据。
拿我那个网关程序举例:
c复制struct config {
uint32_t seq;
uint32_t flags;
uint32_t max_retry;
};
struct config g_cfg;
主线程更新配置时,代码大概是这样的:
c复制g_cfg.seq++;
g_cfg.flags = new_flags;
g_cfg.max_retry = new_retry;
业务线程读取时,可能正好在主线程改完seq、还没改完max_retry的时间窗口内执行了一次完整读取。它拿到的就是seq是新的、max_retry是旧的,这种状态在逻辑上根本不可能出现。对于简单的整数来说可能只是逻辑错误,如果共享的是指针、缓冲区或带内部约束的结构体,这种交叉访问会直接造成内存损坏,core dump只是轻的。
理解这个之后,再看互斥锁、读写锁、信号量、原子操作这些工具就非常清楚:它们都是在不同场景下解决"临界区互斥"或"执行顺序约定"这两个核心问题。
1.2 为什么临界区必须保持最小
聊同步就绕不开临界区(critical section)。临界区就是访问共享资源的代码段,互斥锁的作用就是保证同一时刻只有一个线程能进入临界区。
我在项目里最常见的错误,不是不用锁,而是把锁的范围画得过大。有人图省事,把整个while(1)循环体全部锁住,多线程硬是被写成了"伪并发";有人把锁放到函数最外层,里面调了好几个子函数,每个子函数里又有锁,最后死锁了都找不到原因。
正确的做法是:锁的范围精确覆盖共享数据的读-改-写区间,锁外不做多余的事。IO、sleep、网络请求这类耗时操作,绝对不能放在持锁期间执行。原因不难理解:持锁时间越长,其他线程等待的概率越高;等待的线程越多,锁竞争越严重;竞争一严重,整个进程的吞吐量就塌了。这个原则后面聊锁粒度优化时还会展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 互斥锁pthread_mutex:正确使用与死锁排查
互斥锁是Linux下C语言多线程编程里最基础的同步原语,对应POSIX接口就是pthread_mutex_t。这一节从初始化、加锁解锁、销毁三个环节讲起,重点聊死锁的排查过程,这是所有新手都要过的一道坎。
2.1 初始化方式、锁类型和它们的适用场景
互斥锁的初始化有两种方式:静态初始化和动态初始化。
c复制#include <pthread.h>
// 静态初始化:最简单,通常用于全局锁
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
// 动态初始化:可以设置属性,适合堆上创建的锁
pthread_mutex_t *plock = malloc(sizeof(pthread_mutex_t));
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_ERRORCHECK);
pthread_mutex_init(plock, &attr);
静态初始化只能用默认属性,如果要用到下面表格里的特殊类型,必须走pthread_mutex_init。完整流程里还需要在锁不再使用时调用pthread_mutex_destroy,长生命周期服务里频繁创建锁而不销毁,可以观察到内核对象句柄数一路涨。
| 锁类型 | 行为特点 | 建议 |
|---|---|---|
PTHREAD_MUTEX_NORMAL |
重复加锁导致死锁,无错误检查 | 默认类型,生产环境用 |
PTHREAD_MUTEX_ERRORCHECK |
重复加锁返回EDEADLK,不做死锁 |
调试阶段强烈推荐 |
PTHREAD_MUTEX_RECURSIVE |
同一线程可以重复加锁,需对应次数的unlock | 递归数据结构同步时用,尽量少用 |
PTHREAD_MUTEX_DEFAULT |
通常是NORMAL的别名 | 语义随实现变化,别依赖 |
加锁返回值也要检查。pthread_mutex_lock在参数正常时一般不会失败,但pthread_mutex_trylock和pthread_mutex_timedlock会因竞争失败返回EBUSY或ETIMEDOUT,这些错误不处理,程序逻辑就会在分支里悄悄走偏。
2.2 死锁复现实验:ABBA锁的完整演示
死锁是互斥锁最容易踩、也最难排查的问题,最常见的两种形态:一种是两个线程各自持有一把锁,再去申请对方手里的锁,形成循环等待,也就是经典的ABBA死锁;另一种是同一线程对同一把非递归锁连续加锁两次,即自死锁。
下面这段代码可以稳定复现ABBA死锁:
c复制#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER;
void *thread_1(void *arg) {
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&lock_a);
usleep(10); // 扩大竞争窗口
pthread_mutex_lock(&lock_b);
pthread_mutex_unlock(&lock_b);
pthread_mutex_unlock(&lock_a);
}
return NULL;
}
void *thread_2(void *arg) {
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&lock_b);
usleep(10);
pthread_mutex_lock(&lock_a);
pthread_mutex_unlock(&lock_a);
pthread_mutex_unlock(&lock_b);
}
return NULL;
}
线程1持有A锁等B锁,线程2持有B锁等A锁,调度时机一旦凑上,两个线程就会永远互相等待。这个时候ps -eLf能看到两个线程长时间停在锁调用上,CPU占用趋近于0,程序假死。usleep(10)的作用是人为把竞争窗口拉大,让死锁出现的概率从"偶尔"变成"稳定复现"。
防止死锁最有效的办法不是事后用工具查,而是在设计层面约定锁的申请顺序。上面的例子,只要规定"任何代码必须先拿A锁、再拿B锁",死锁就不可能发生。很多大型项目的编码规范都要求"多持锁时必须全序加锁",就是这个原因。自死锁的本质也一样,它是因为代码路径上重复对同一把非递归锁加锁,本质是加锁顺序上自己没有遵守"已持锁就不再加同一把锁"的约定。
2.3 一次真实死锁的排查链路和工具组合
有一次我在服务里发现某个业务接口的响应时间偶尔会飙到几十秒,一开始怀疑是网络问题。用top -H看线程,发现两个线程长期处于D状态,而且wchan(等待通道)都停在futex_wait。futex_wait就是pthread_mutex_lock在锁被占用时进入的内核等待状态,两个线程都卡在futex上,基本可以断定是死锁。
我当时的排查链路是这样的:
top -H -p <pid>找到异常线程的TID,确认有多个线程长时间处于不可中断睡眠。gdb attach到进程,执行thread apply all bt,看到线程A停在第二把锁的pthread_mutex_lock调用上,线程B也停在另一把锁的pthread_mutex_lock调用上。- 分别查看两把锁的地址,确认线程A持有的第一把锁正是线程B等待的锁,线程B持有的第一把锁正是线程A等待的锁,循环等待闭环成立。
- 翻代码,发现两个模块分别封装了自己的锁获取接口,模块X内部总是先锁X再锁Y,模块Y内部总是先锁Y再锁X。跨模块调用时出现了交叉申请。
- 修复方案:把两把锁的申请顺序统一成"先X后Y",并在代码评审环节加入锁顺序检查。
死锁排查工具方面,Linux内核自带lockdep,用户态可以借助valgrind的helgrind和ThreadSanitizer(-fsanitize=thread编译选项)。ThreadSanitizer能在测试阶段直接上报数据竞争和死锁风险,我在好几个项目里用它抓到过隐蔽的竞态。要强调的是:死锁不是每次运行都会触发,它依赖特定调度时序,所以修复后一定要跑压力测试验证,跑一两次通过不算数。
3. 条件变量:生产者-消费者模型的两个经典坑
互斥锁解决的是"互斥"问题,但很多场景还需要"同步"——让一个线程等另一个线程完成某件事,或者等某个共享条件成立。轮询能解决,但CPU占用太难看;用sleep睡眠又无法保证实时性。条件变量(condition variable)就是为此设计的:让线程在条件不满足时睡眠,条件满足时被唤醒。
3.1 为什么等待条件必须用while循环
条件变量的标准用法是这样:
c复制pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int available = 0; // 共享条件
// 等待方
pthread_mutex_lock(&lock);
while (available == 0) {
pthread_cond_wait(&cond, &lock);
}
// 此时 available == 1,处理数据
pthread_mutex_unlock(&lock);
// 通知方
pthread_mutex_lock(&lock);
available = 1;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&lock);
这段代码我在教程里看过无数遍,但真正动手写时最容易犯的错误,是把while (available == 0)写成if (available == 0)。为什么要用while?有两个原因。
第一,POSIX标准允许虚假唤醒(spurious wakeup)。pthread_cond_wait返回,并不一定意味着条件真的成立了,系统内部原因也可能导致提前返回。这是标准对硬件和内核实现的有意放宽,换来了更简单的底层实现。用while再检查一次条件,就能把虚假唤醒挡在门外。
第二,多个等待线程被唤醒时,先抢到锁的线程可能把条件改写掉。比如pthread_cond_broadcast会唤醒所有等待线程,线程1拿到锁消费掉数据,把available改回0;线程2再拿到锁,如果它用的是if,就会直接往下执行,但此时数据已经被线程1拿走了。用while循环重新判断,线程2会继续等待。这个坑我用if写法踩过,上线后偶发出现空数据处理,查了很久才定位到这里。
3.2 信号丢失、通知时机与单播广播的选择
条件变量的另一个经典坑是信号丢失。如果通知方在线程进入pthread_cond_wait之前就调用了pthread_cond_signal,这个信号就白白丢了,等待线程可能永远醒不过来。解决方案是保证"先改共享条件、再发信号",而且这个顺序要在持有互斥锁的代码路径里完成。看上面的代码,通知方先执行available = 1,再pthread_cond_signal,顺序不能反。
pthread_cond_signal只唤醒一个等待线程,pthread_cond_broadcast唤醒所有等待线程。如果所有等待者等待的是同一个条件,signal就够了;如果条件有多个分支,或者你可能放入多条数据,建议用broadcast更安全。选错唤醒方式不会出正确性问题,只会导致部分线程唤醒后重新睡眠,性能上有损耗罢了。
3.3 一个可用的日志阻塞队列
生产环境里的日志落盘模块,我喜欢用"多个业务线程写日志、一个后台线程刷盘"的模型,核心就是一个阻塞队列。这里给出一个精简可用的实现:
c复制#include <pthread.h>
#include <string.h>
#include <stdlib.h>
#define QUEUE_CAPACITY 1024
typedef struct {
char **buf;
int head, tail, count;
pthread_mutex_t lock;
pthread_cond_t not_empty;
pthread_cond_t not_full;
} log_queue_t;
void log_queue_init(log_queue_t *q) {
q->buf = calloc(QUEUE_CAPACITY, sizeof(char *));
q->head = q->tail = q->count = 0;
pthread_mutex_init(&q->lock, NULL);
pthread_cond_init(&q->not_empty, NULL);
pthread_cond_init(&q->not_full, NULL);
}
void log_queue_push(log_queue_t *q, const char *msg) {
pthread_mutex_lock(&q->lock);
while (q->count == QUEUE_CAPACITY) {
pthread_cond_wait(&q->not_full, &q->lock);
}
q->buf[q->tail] = strdup(msg);
q->tail = (q->tail + 1) % QUEUE_CAPACITY;
q->count++;
pthread_cond_signal(&q->not_empty);
pthread_mutex_unlock(&q->lock);
}
char *log_queue_pop(log_queue_t *q) {
pthread_mutex_lock(&q->lock);
while (q->count == 0) {
pthread_cond_wait(&q->not_empty, &q->lock);
}
char *msg = q->buf[q->head];
q->head = (q->head + 1) % QUEUE_CAPACITY;
q->count--;
pthread_cond_signal(&q->not_full);
pthread_mutex_unlock(&q->lock);
return msg;
}
这个队列用了两个条件变量,分别表示"非空"和"非满"。生产者等not_full,消费者等not_empty,两类线程互不干扰。有一个细节值得多说两句:队列满时生产者会阻塞,这正好实现了背压,避免业务线程写日志太快导致内存无限增长;另外strdup返回的字符串由消费者负责释放,内存所有权必须在模块设计时就约定清楚,否则后面内存泄漏和double free会轮番上阵。
4. 读写锁、自旋锁与屏障:按临界区特征选型
互斥锁和条件变量能覆盖大多数同步场景,但它们不是万能药。临界区特征不同,选型应该不同。这一节聊聊三类常用同步原语的使用场景和取舍。
4.1 读写锁的收益边界与写者饥饿
pthread_rwlock_t允许多个线程同时读,但写线程必须独占。典型场景是配置表、路由表这类"读频率远高于写频率"的数据结构。
c复制#include <pthread.h>
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
// 读路径
pthread_rwlock_rdlock(&rwlock);
// 读取共享数据
pthread_rwlock_unlock(&rwlock);
// 写路径
pthread_rwlock_wrlock(&rwlock);
// 修改共享数据
pthread_rwlock_unlock(&rwlock);
但读写锁不是免费的。它的内部实现要维护读者计数和写者等待队列,锁竞争激烈时开销比普通互斥锁大不少。我做过一个压测:8个线程,全是读操作,互斥锁和读写锁的吞吐差距不明显,因为锁本身没有竞争;一旦混入1个写线程,读写锁会因为写者优先或读者优先的调度策略产生额外延迟。结论很明确:如果写操作占比超过10%,读写锁很可能是负优化,直接用互斥锁更省事。 这个比例是我个人的粗略经验,不同硬件和内核版本会有差异,选型前最好先跑基准测试。
另外,读写锁还存在写者饥饿隐患。老的Linux实现里,如果读者源源不断,写者可能长时间拿不到锁。glibc的NPTL实现用了写者优先策略来缓解,但如果你在内核模块或自研同步机制里遇到类似问题,要考虑清楚你的策略是读者优先还是写者优先。
4.2 自旋锁:一次CPU打满的事故复盘
自旋锁pthread_spinlock_t的特点是拿不到锁时不会让出CPU,而是在原地忙等(spin)。它适合临界区极短、锁竞争概率低的场景,因为忙等的成本远低于线程切换。
c复制#include <pthread.h>
pthread_spinlock_t spin;
pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);
pthread_spin_lock(&spin);
// 极短临界区,比如维护一个计数器
pthread_spin_unlock(&spin);
pthread_spin_destroy(&spin);
使用自旋锁最大的风险是临界区里有阻塞操作或耗时代码,比如网络IO、磁盘读写、printf。一旦持锁线程被调度走,其他等待线程就会在CPU上空转,严重时整机性能雪崩。我亲眼见过一个线上事故:有人把自旋锁用在日志打印路径上,打印量大时所有核全在spin,业务延迟飙升,top里每个线程CPU占用都是100%。换成互斥锁后,问题立刻消失。
所以自旋锁的使用要慎之又慎。默认情况下优先考虑互斥锁,只有确认临界区只有几条指令、且锁竞争极低时,才值得用自旋锁。这个顺序和直觉是相反的——很多人觉得自旋锁"高级"就想用,但实际上它在绝大多数业务场景里都是负优化。
4.3 屏障:并行计算中的回合同步
pthread_barrier_t解决的是另一类问题:N个线程各自完成一阶段工作后,必须全部到达某个汇合点,才能一起进入下一阶段。这在并行计算、分阶段流水线里很常见。
c复制#include <pthread.h>
#define THREAD_NUM 4
pthread_barrier_t barrier;
void *worker(void *arg) {
int id = *(int *)arg;
// 阶段一:独立计算
// ...
pthread_barrier_wait(&barrier);
// 阶段二:此时所有线程都已完成阶段一
// ...
return NULL;
}
int main() {
pthread_barrier_init(&barrier, NULL, THREAD_NUM);
// 创建4个线程...
pthread_barrier_destroy(&barrier);
return 0;
}
pthread_barrier_wait的返回值有个细节:当某个线程是最后一个到达栅栏的线程时,函数返回PTHREAD_BARRIER_SERIAL_THREAD,其他线程返回0。利用这个特点,可以指定某个线程在回合切换时做汇总工作,省一次额外的回调。这里建议barrier只用于真正的并行计算场景,普通业务服务里"等待多个线程完成任务"的需求,更合适的做法是任务队列加条件变量,因为barrier不具备超时机制,一旦某个线程提前退出,其他线程会永远等下去。
5. 锁之外的选择:原子操作与锁粒度优化
锁能保证正确性,但锁竞争会拖慢性能。在高并发场景里,锁优化本质上是在做一件事:尽量让线程不需要互相等。
5.1 原子变量解决计数器热点
对简单的计数器、标志位、引用计数这类操作,原子变量完全可以替代锁。这里说的是C11标准引入的stdatomic.h接口,在glibc和内核代码里非常常用:
c复制#include <stdatomic.h>
atomic_int counter = 0;
void increment(void) {
atomic_fetch_add(&counter, 1);
}
int get(void) {
return atomic_load(&counter);
}
原子操作的原理是直接使用CPU提供的单条原子指令(比如x86的lock xadd),在硬件层面保证读-改-写的不可分割性,不需要操作系统调度器参与,也就不存在线程排队和锁等待的系统调用开销。我在网络服务里用原子变量管理连接数,替代原来每收到一个连接就加锁的实现,实测高并发下锁热点直接消失。
但要注意:原子操作只能解决单个变量、单个操作的原子性,解决不了"读-判断-写"这种复合操作的原子性。如果业务逻辑需要先读一个值、判断、再写回,在原子层面这是两步操作,必须用atomic_compare_exchange_strong这类CAS原语,否则还是会出竞态。更复杂的复合操作就别硬撑了,回到互斥锁是更务实的选择。
5.2 内存序的保守建议
__atomic内建函数和C11原子接口都提供内存序(memory order)参数,memory_order_relaxed、memory_order_acquire、memory_order_release、memory_order_seq_cst等等。简单说,它们控制的是编译器和CPU对内存操作重排的可见性。
这块我给出保守建议:默认全部用memory_order_seq_cst,等系统真的出现性能瓶颈,并且你对内存模型的理解足够深,再考虑用更弱的语义。 弱内存序写出的代码,bug往往只在特定架构、特定CPU上复现,极难排查。Linux内核里大量精细的acquire/release用法,是经过长时间review和维护的,普通应用开发没必要追求这个水平。
5.3 一个大锁拆小锁的实测重构案例
最后聊一个实战案例。我之前优化过一个消息网关,四核CPU,16个工作线程处理来自不同客户端的消息,每个客户端有独立的会话状态。原始版本用一个全局互斥锁保护整个会话表,压测时QPS上到2万就卡住,perf top显示60%以上的时间在锁竞争上。
重构思路是分段加锁:
- 把会话表按客户端ID哈希分成16个桶,每个桶一把独立互斥锁。
- 单条消息处理只需要锁住对应会话所在的桶,不与其他桶的线程互斥。
- 查询在线统计这类跨桶操作,用全局读写锁的低频路径处理。
重构后同样的压测环境,QPS从2万涨到8万,锁竞争从perf top的第一位跌出前五。这个案例说明:锁粒度不是越细越好,太细了会引入锁管理本身的复杂度,增加加锁解锁开销;关键是让"不同线程经常访问的数据"尽量落在不同锁上,把锁竞争的热点拆开。
6. 六个让我少走弯路的编码习惯
前面把Linux下主要的线程同步工具都过了一遍,最后想分享几个对实际项目帮助极大的习惯。这些不是理论,是踩坑换来的经验。
6.1 消灭共享,而不是优化同步
永远先考虑能不能不做同步。全局变量能改造成线程私有数据,就用线程私有数据(__thread关键字或pthread_key_t),能不共享就不共享。很多问题的正确解法不是"把锁用得更溜",而是"从设计上消灭共享"。比如每个线程维护自己的计数器,最后汇总时再合并,比用一个原子计数器在每次写入时竞争要高效得多。线程同步再怎么优化,也是有成本的;不共享,成本就是零。
6.2 测试阶段就开竞态检测
-fsanitize=thread在测试环境抓到过我多次数据竞争,比上线后靠用户反馈再排查高效太多。编译时加上-fsanitize=thread -g -O1,运行到数据竞争时会直接打印冲突的线程栈和变量地址。缺点是会显著增加运行内存占用,所以只建议在测试阶段开,线上保持正常编译。
另外,锁的接口最好集中在少数基础模块里,不要散落在业务代码各处。封装成lock_acquire(lock_id)、lock_release(lock_id)这样的接口,以后要加锁顺序检查、要加调试日志都方便。我在一个项目里就是因为当初直接裸用pthread_mutex_lock,后来想加锁顺序校验时差点把所有业务代码翻一遍。
还有一点深有体会:死锁检测工具只是最后一道防线,不要依赖它。最好的修复是设计上杜绝多持锁,或者加锁前把锁对象的顺序排好,保证全序一致。这条原则值得我们像背口诀一样记住——加锁顺序统一,死锁自动消失。
线程同步这块知识点多而杂,但主线很清晰:互斥锁解决临界区互斥,条件变量解决等待和唤醒,读写锁优化读多写少场景,自旋锁用于短临界区,屏障做回合同步,原子操作用来消灭细粒度锁竞争。我个人的感觉是,学完这些API并不难,难的是遇到具体场景时能选对工具,并且知道每个工具背后的代价。看这篇文章的朋友如果在学《Linux系统编程》第19章,建议把每段示例代码亲手编译运行一遍,再配合ThreadSanitizer做几个小实验,对同步机制的体感会完全不一样。
