第十五章:多线程性能优化
多线程,说白了就是让程序同时干多件事。但很多人一上来就开线程,结果性能反而更差。我见过太多这样的案例了——线程开了一堆,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参数,但用错一个,程序就可能出现诡异的数据竞争。
我个人建议:除非性能瓶颈明确在锁上,否则先用锁。无锁编程是最后的手段,不是第一选择。
总结一下:多线程优化的核心就三件事——减少线程创建销毁、选对锁、必要时用无锁。别把简单问题复杂化,能用锁解决的问题,先用锁解决。