第十五章:多线程性能优化

多线程,说白了就是让程序同时干多件事。但很多人一上来就开线程,结果性能反而更差。我见过太多这样的案例了——线程开了一堆,CPU全耗在上下文切换上,真正干活的时间没多少。

这一章,我们来聊聊多线程优化的核心问题。我会结合自己踩过的坑,把线程创建销毁、锁的选择、甚至无锁编程这些话题讲透。

线程创建与销毁的开销

先问一个问题:创建一个线程到底有多贵?

我曾在一次性能分析中测过,Linux下pthread_create的平均耗时大约在10-50微秒。听起来不多?但如果你每秒创建销毁上千个线程,这个开销就非常可观了。

线程创建的开销主要来自:

  • 内核资源分配:线程栈、TCB(线程控制块)的分配
  • 调度器注册:新线程需要被纳入调度队列
  • 上下文初始化:寄存器状态、信号掩码等设置

核心观点:线程不是免费的。每次创建和销毁,都是一次系统调用,一次内核态与用户态的切换。

我记得有一次优化一个网络服务,发现QPS上不去。用perf一看,pthread_create占了15%的CPU时间。嗯,这就是典型的「线程滥用」问题。

线程池设计

解决线程频繁创建销毁的方案,就是线程池。说白了,就是提前创建好一批线程,任务来了直接分配,任务做完线程不销毁,继续等下一个任务。

一个基础的线程池包含:

  • 任务队列:存放待执行的任务
  • 工作线程:从队列取任务执行
  • 同步机制:通知线程有新任务、保护队列安全
// 一个简化的线程池核心结构
class ThreadPool {
public:
    ThreadPool(size_t numThreads) {
        for (size_t i = 0; i < numThreads; ++i) {
            workers.emplace_back([this] {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(queueMutex);
                        condition.wait(lock, [this] {
                            return stop || !tasks.empty();
                        });
                        if (stop && tasks.empty()) return;
                        task = std::move(tasks.front());
                        tasks.pop();
                    }
                    task();
                }
            });
        }
    }
    // ... 提交任务、销毁等接口
private:
    std::vector<std::thread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex queueMutex;
    std::condition_variable condition;
    bool stop = false;
};

设计要点:线程数一般设为 std::thread::hardware_concurrency(),也就是CPU核心数。IO密集型任务可以适当增加,但别超过核心数的2倍。

我个人的习惯是:线程池大小先按核心数设,然后压测,看CPU利用率。如果CPU没跑满,再慢慢加线程。别一上来就设个100,那只会让调度器忙死。

互斥锁 vs 读写锁 vs 自旋锁

锁的选择,直接影响多线程性能。三种锁各有适用场景,我们来拆解一下。

锁类型 适用场景 开销 注意事项
互斥锁 (mutex) 临界区小,写操作多 中等 线程会睡眠,适合锁冲突较少的场景
读写锁 (rwlock) 读多写少 较高 读操作不互斥,写操作独占
自旋锁 (spinlock) 临界区极小,锁持有时间短 低(但浪费CPU) 忙等待,不适合长时间持有

我曾经在一个缓存系统中,把互斥锁改成读写锁,读性能提升了3倍。但要注意,读写锁不是银弹——如果写操作频繁,读写锁反而比互斥锁更慢,因为它的内部维护了更复杂的状态。

避坑指南:我曾经在临界区只有一条赋值语句的地方用了自旋锁,结果多核环境下性能反而下降。原因是自旋锁的忙等待占用了本该执行其他线程的CPU时间。后来换成互斥锁,线程睡眠后让出CPU,整体吞吐反而上去了。

选锁的原则其实很简单:

  • 临界区小且锁持有时间极短 → 自旋锁
  • 读远多于写 → 读写锁
  • 其他情况 → 互斥锁

无锁编程入门

无锁编程,听起来很酷,但门槛确实高。它的核心思想是:用原子操作代替锁,避免线程阻塞。

最常用的原子操作是CAS(Compare-And-Swap):

// C++11 提供的原子操作
std::atomic<int> counter{0};

// CAS 操作:如果当前值等于expected,就更新为desired
int expected = counter.load();
while (!counter.compare_exchange_weak(expected, expected + 1)) {
    // 如果CAS失败,expected会被更新为当前值,重试
}

无锁数据结构的一个经典例子是无锁队列。我曾在日志系统中用过,效果不错:

// 一个简单的无锁单生产者单消费者队列
template<typename T>
class LockFreeQueue {
    struct Node {
        T data;
        std::atomic<Node*> next;
    };
    std::atomic<Node*> head;
    std::atomic<Node*> tail;
public:
    LockFreeQueue() {
        Node* dummy = new Node{};
        head.store(dummy);
        tail.store(dummy);
    }
    void push(T value) {
        Node* node = new Node{value, nullptr};
        Node* prev = tail.exchange(node);
        prev->next.store(node);
    }
    bool pop(T& result) {
        Node* first = head.load();
        Node* next = first->next.load();
        if (next == nullptr) return false;
        result = next->data;
        head.store(next);
        delete first;
        return true;
    }
};

重要提醒:无锁编程的难点在于ABA问题、内存序、以及正确性证明。我建议初学者先从简单的原子计数器开始,别一上来就写无锁栈或队列。

你想想看,无锁编程为什么难?因为你要同时考虑多个线程的并发访问,还要保证内存序的正确性。C++11提供了memory_order参数,但用错一个,程序就可能出现诡异的数据竞争。

我个人建议:除非性能瓶颈明确在锁上,否则先用锁。无锁编程是最后的手段,不是第一选择。

多线程性能优化 线程创建与销毁 线程池设计 锁的选择 无锁编程入门 内核开销 上下文切换 任务队列 工作线程 互斥锁 读写锁 自旋锁 原子操作 CAS ABA问题 多线程性能优化知识体系

总结一下:多线程优化的核心就三件事——减少线程创建销毁、选对锁、必要时用无锁。别把简单问题复杂化,能用锁解决的问题,先用锁解决。

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