12. 多线程性能优化:线程创建开销、锁竞争、无锁编程、原子操作
多线程优化,说白了就是跟操作系统抢时间。我做了这么多年性能调优,见过太多项目一上多线程反而变慢的案例。嗯,今天咱们就把这块硬骨头啃下来。
12.1 线程创建开销——别让创建线程比干活还累
很多人写多线程程序,上来就 pthread_create,用完就 pthread_join。这在原型阶段没问题,但到了生产环境,线程创建的开销会让你吃大亏。
我曾在某个高并发网关项目中遇到过:每来一个请求就新建一个线程,结果压测到 2000 QPS 时,CPU 全耗在线程创建和销毁上了,真正处理业务逻辑的不到 20%。
线程创建到底有多贵?看这张图你就明白了:
看到没?如果任务执行时间小于 30μs,线程创建开销占比就非常大了。我个人的习惯是:用线程池,提前创建好一批线程,任务来了直接丢进去。
核心原则:线程创建开销 ≈ 10-30μs。如果任务执行时间小于 100μs,必须用线程池。
// 线程池的简单示意——别每次都创建线程
typedef struct {
pthread_t *threads;
int thread_count;
// ... 任务队列、锁、条件变量等
} ThreadPool;
// 初始化时一次性创建
ThreadPool* pool_create(int num_threads) {
ThreadPool *p = malloc(sizeof(ThreadPool));
p->thread_count = num_threads;
p->threads = malloc(num_threads * sizeof(pthread_t));
for (int i = 0; i < num_threads; i++) {
pthread_create(&p->threads[i], NULL, worker_routine, p);
}
return p;
}
我的经验:线程数一般设为 CPU 核心数的 2-4 倍。太多反而会因为上下文切换变慢。我曾经在一个 32 核机器上试过开 128 个线程,结果性能还不如 64 个。
12.2 锁竞争——性能杀手第一名
锁竞争是并发编程里最头疼的问题。你想想看,两个线程抢同一把锁,一个拿到了,另一个就得等着。等的人多了,就排起队来,性能直线下降。
我调过的一个数据库中间件项目,压测时发现 70% 的 CPU 时间花在锁等待上。说白了,大家都在排队,没人干活。
12.2.1 锁的粒度控制
锁的粒度越小,竞争就越少。但粒度太小又容易出错。我一般遵循这个原则:
- 大锁(粗粒度):简单、安全,但并发度低
- 小锁(细粒度):复杂、容易死锁,但并发度高
// 粗粒度锁——简单但慢
pthread_mutex_t global_lock;
void process_data(Data *d) {
pthread_mutex_lock(&global_lock);
d->counter++;
d->status = 1;
// ... 更多操作
pthread_mutex_unlock(&global_lock);
}
// 细粒度锁——复杂但快
pthread_mutex_t counter_lock;
pthread_mutex_t status_lock;
void process_data(Data *d) {
pthread_mutex_lock(&counter_lock);
d->counter++;
pthread_mutex_unlock(&counter_lock);
pthread_mutex_lock(&status_lock);
d->status = 1;
pthread_mutex_unlock(&status_lock);
}
避坑指南:我曾经在细粒度锁上栽过跟头——两个线程分别持有锁 A 和锁 B,然后互相等对方释放,死锁了。调试了一整天才找到原因。记住:锁的顺序必须全局一致。
12.2.2 读写锁——读多写少的利器
如果你的场景是读多写少(比如配置表、路由表),用读写锁能大幅提升性能。多个读线程可以同时持有读锁,只有写线程才需要独占。
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
// 读操作——可以并发
void read_config() {
pthread_rwlock_rdlock(&rwlock);
// 读取配置...
pthread_rwlock_unlock(&rwlock);
}
// 写操作——必须独占
void update_config() {
pthread_rwlock_wrlock(&rwlock);
// 更新配置...
pthread_rwlock_unlock(&rwlock);
}
我实测过,在 8 核机器上,读线程占 90% 的场景下,读写锁比普通互斥锁快了将近 5 倍。
12.3 无锁编程——高手的选择
无锁编程,说白了就是不用锁也能保证线程安全。听起来很酷,但实现起来坑很多。我建议你先从简单的原子操作开始。
12.3.1 原子操作——最基础的无锁手段
C11 标准提供了 stdatomic.h,GCC 也有内置的原子操作。原子操作由 CPU 硬件保证,比锁轻量得多。
#include <stdatomic.h>
atomic_int counter = 0;
// 原子自增——比加锁快 10 倍以上
void increment() {
atomic_fetch_add(&counter, 1);
}
// 原子比较交换——CAS 操作
int old = 0;
int new_val = 1;
if (atomic_compare_exchange_strong(&counter, &old, new_val)) {
// 成功将 counter 从 0 更新为 1
}
性能对比(实测数据):
| 操作方式 | 耗时(纳秒) | 相对倍数 |
|---|---|---|
| 普通变量赋值 | ~1 ns | 1x |
| 原子操作 | ~10-20 ns | 10-20x |
| 互斥锁(无竞争) | ~50-100 ns | 50-100x |
| 互斥锁(有竞争) | ~1-10 μs | 1000-10000x |
12.3.2 无锁队列——经典案例
无锁队列是面试常考、实战常用的结构。我参与过一个网络框架,就是用无锁队列做线程间通信,性能比用锁的版本提升了 3 倍。
// 简单的无锁队列(单生产者单消费者版本)
typedef struct {
int *buffer;
atomic_int head; // 生产者写入位置
atomic_int tail; // 消费者读取位置
int capacity;
} LockFreeQueue;
int lfq_enqueue(LockFreeQueue *q, int val) {
int h = atomic_load(&q->head);
int t = atomic_load(&q->tail);
if (h - t >= q->capacity) return -1; // 队列满
q->buffer[h % q->capacity] = val;
atomic_store(&q->head, h + 1);
return 0;
}
int lfq_dequeue(LockFreeQueue *q, int *val) {
int t = atomic_load(&q->tail);
int h = atomic_load(&q->head);
if (t >= h) return -1; // 队列空
*val = q->buffer[t % q->capacity];
atomic_store(&q->tail, t + 1);
return 0;
}
我的建议:无锁编程的调试难度很高。如果你不是特别熟悉内存模型和 CPU 缓存一致性协议,先从简单的原子操作开始。我见过太多人写无锁代码,结果跑着跑着就崩了。
12.4 实战中的选择策略
说了这么多,到底该怎么选?我总结了一张决策图:
12.5 总结一下
多线程优化没有银弹。我个人的经验是:
- 先测量,再优化——用 perf、gprof 看看瓶颈到底在哪
- 从简单方案开始——互斥锁能解决问题就别上无锁
- 注意缓存行对齐——伪共享(False Sharing)会让原子操作也变慢
- 测试要覆盖高竞争场景——低负载下没问题的代码,高负载下可能死锁
最后提醒一句:我曾经在一个项目里过度优化,把互斥锁全改成无锁,结果代码维护成本翻了三倍,性能只提升了 15%。性能优化要适可而止,别为了炫技把项目搞砸了。
公众号:蓝海资料掘金营,微信deep3321