12. 多线程性能优化:线程创建开销、锁竞争、无锁编程、原子操作

多线程优化,说白了就是跟操作系统抢时间。我做了这么多年性能调优,见过太多项目一上多线程反而变慢的案例。嗯,今天咱们就把这块硬骨头啃下来。

12.1 线程创建开销——别让创建线程比干活还累

很多人写多线程程序,上来就 pthread_create,用完就 pthread_join。这在原型阶段没问题,但到了生产环境,线程创建的开销会让你吃大亏。

我曾在某个高并发网关项目中遇到过:每来一个请求就新建一个线程,结果压测到 2000 QPS 时,CPU 全耗在线程创建和销毁上了,真正处理业务逻辑的不到 20%。

线程创建到底有多贵?看这张图你就明白了:

线程创建 vs 任务执行时间对比 线程创建 ~10-30μs 简单任务 ~5-10μs 复杂任务 ~100-500μs 浪费

看到没?如果任务执行时间小于 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 ns1x
原子操作~10-20 ns10-20x
互斥锁(无竞争)~50-100 ns50-100x
互斥锁(有竞争)~1-10 μs1000-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 实战中的选择策略

说了这么多,到底该怎么选?我总结了一张决策图:

多线程性能优化决策流程 开始优化 任务耗时 < 100μs? 用线程池 避免创建开销 直接创建线程 任务够大,开销可忽略 读多写少? 读写锁 互斥锁 / 无锁

12.5 总结一下

多线程优化没有银弹。我个人的经验是:

  • 先测量,再优化——用 perf、gprof 看看瓶颈到底在哪
  • 从简单方案开始——互斥锁能解决问题就别上无锁
  • 注意缓存行对齐——伪共享(False Sharing)会让原子操作也变慢
  • 测试要覆盖高竞争场景——低负载下没问题的代码,高负载下可能死锁

最后提醒一句:我曾经在一个项目里过度优化,把互斥锁全改成无锁,结果代码维护成本翻了三倍,性能只提升了 15%。性能优化要适可而止,别为了炫技把项目搞砸了。


公众号:蓝海资料掘金营,微信deep3321