19、性能优化与调试:线程池的负载均衡、锁的粒度控制、使用perf与gdb调试多线程程序
各位同学,咱们今天聊点硬核的。线程池写出来了,锁也加上了,程序能跑了——然后呢?
然后你会发现,性能上不去,或者莫名其妙就卡死了。嗯,这才是真正的开始。
我个人习惯把多线程开发分成三个阶段:能跑 → 跑得快 → 跑得稳。今天这章,就是帮你从「能跑」跨到「跑得快且稳」的关键一步。
19.1 线程池的负载均衡:别让线程闲着
先问个问题:你辛辛苦苦开了8个线程,结果7个在摸鱼,1个累死累活——这图啥?
我在项目中遇到过这种场景:一个视频处理服务,任务队列里既有几毫秒的小任务,也有几秒钟的大任务。结果呢?某个线程抢到大任务,其他线程把小的全干完了,然后一起等那个大任务做完。吞吐量直接腰斩。
19.1.1 工作窃取(Work Stealing)
说白了,就是让空闲线程去偷别人的任务。每个线程有自己的任务队列,干完活就去看看别人有没有积压的任务。
// 简化版工作窃取队列
class WorkStealingQueue {
std::deque<std::function<void()>> queue_;
mutable std::mutex mtx_;
public:
void push(std::function<void()> task) {
std::lock_guard<std::mutex> lock(mtx_);
queue_.push_front(std::move(task)); // 新任务放前面
}
bool try_pop(std::function<void()>& task) {
std::lock_guard<std::mutex> lock(mtx_);
if (queue_.empty()) return false;
task = std::move(queue_.front());
queue_.pop_front();
return true;
}
bool try_steal(std::function<void()>& task) {
std::lock_guard<std::mutex> lock(mtx_);
if (queue_.empty()) return false;
task = std::move(queue_.back()); // 偷尾部的任务
queue_.pop_back();
return true;
}
};
注意看:push 放前面,try_steal 偷后面。这样本线程拿到的总是最新任务(局部性好),偷任务的拿的是最老的任务(反正也等很久了)。
19.1.2 任务分片与批量提交
另一个思路:别让线程一个一个地取任务,一次拿一批。
class BatchTaskQueue {
std::queue<std::function<void()>> queue_;
std::mutex mtx_;
static constexpr size_t BATCH_SIZE = 16;
public:
// 批量获取任务
size_t try_pop_batch(std::vector<std::function<void()>>& batch) {
std::lock_guard<std::mutex> lock(mtx_);
size_t count = 0;
while (!queue_.empty() && count < BATCH_SIZE) {
batch.push_back(std::move(queue_.front()));
queue_.pop();
++count;
}
return count;
}
};
你想想看,每次加锁取一个任务,和一次加锁取16个任务,锁的竞争次数直接降到1/16。对于高频小任务场景,这个优化立竿见影。
19.2 锁的粒度控制:别锁住不该锁的
锁的粒度,说白了就是「你锁了多少代码」。锁得太多,并发度低;锁得太少,数据竞争。这个度怎么把握?
我曾经接手过一个日志系统,写日志居然把整个文件句柄锁住,连格式化字符串都在锁里做。结果高并发下,80%的线程都在等锁。格式化字符串又不涉及共享数据,你锁它干嘛?
19.2.1 粗粒度 vs 细粒度
| 类型 | 特点 | 适用场景 | 我的建议 |
|---|---|---|---|
| 粗粒度锁 | 一个锁保护大块数据,实现简单 | 低并发、数据访问频率低 | 先粗后细,别一开始就搞复杂 |
| 细粒度锁 | 多个锁分别保护不同数据 | 高并发、热点数据分散 | 注意死锁风险,锁顺序要一致 |
| 读写锁 | 读读不互斥,读写互斥 | 读多写少 | 写操作少时效果极好 |
19.2.2 锁外移:把不必要的工作移出临界区
// ❌ 坏例子:在锁里做耗时操作
void process(int id) {
std::lock_guard<std::mutex> lock(mtx_);
auto data = load_from_db(id); // 数据库查询,可能几十毫秒
cache_[id] = data;
}
// ✅ 好例子:只锁共享数据的操作
void process(int id) {
auto data = load_from_db(id); // 耗时操作,不需要锁
std::lock_guard<std::mutex> lock(mtx_);
cache_[id] = data; // 只锁写缓存这一行
}
19.2.3 锁分解与锁分段
锁分解:把一个锁拆成多个。比如哈希表,每个桶一个锁。
锁分段:把数据分成多个区域,每个区域独立加锁。比如ConcurrentHashMap的思路。
// 锁分段示例:分片计数器
class ShardedCounter {
static constexpr size_t SHARD_COUNT = 64;
struct Shard {
std::atomic<int64_t> value{0};
// 避免伪共享
alignas(64) char padding[64 - sizeof(std::atomic<int64_t>)];
};
std::array<Shard, SHARD_COUNT> shards_;
public:
void increment(size_t key) {
size_t idx = key % SHARD_COUNT;
shards_[idx].value.fetch_add(1, std::memory_order_relaxed);
}
int64_t total() const {
int64_t sum = 0;
for (auto& shard : shards_) {
sum += shard.value.load(std::memory_order_acquire);
}
return sum;
}
};
这里有个小细节:alignas(64) 是为了避免伪共享。两个线程如果操作同一个缓存行里的不同变量,性能会暴跌。我踩过这个坑,排查了两天才发现是缓存行冲突。
19.3 使用perf与gdb调试多线程程序
代码写完了,性能怎么样?有没有死锁?别靠猜,上工具。
19.3.1 perf:性能分析的瑞士军刀
perf 是 Linux 下的性能分析工具,可以告诉你 CPU 时间花在哪了、缓存命中率、锁竞争情况等。
# 采样程序性能数据
perf record -g -p <pid> -- sleep 10
# 查看调用链热点
perf report -g graph
# 查看锁竞争(需要内核支持)
perf lock record -p <pid> -- sleep 10
perf lock report
我个人习惯先用 perf top 看看全局热点,再用 perf record 抓具体进程。有一次我发现一个函数占了40%的CPU时间,仔细一看,是日志打印里的字符串格式化——嗯,后来改成异步日志了。
- cycles:CPU周期数,看热点函数
- cache-misses:缓存未命中,太高说明数据布局有问题
- context-switches:上下文切换,太高说明锁竞争严重
19.3.2 gdb调试多线程:死锁与竞态条件
gdb 调试多线程,核心就几个命令:
# 启动程序
gdb ./your_program
# 查看所有线程
(gdb) info threads
# 切换到指定线程
(gdb) thread 3
# 查看线程堆栈
(gdb) bt
# 查看所有线程的堆栈(超实用)
(gdb) thread apply all bt
# 锁定某个线程(调试时很有用)
(gdb) set scheduler-locking on
我曾经遇到一个死锁问题:线程A持有锁1等锁2,线程B持有锁2等锁1。用 thread apply all bt 一看,两个线程都在 __lll_lock_wait 里卡着。再结合 frame 命令看看锁的地址,一下就定位到了。
- 用
thread apply all bt看所有线程的堆栈 - 找到卡在锁等待的线程(通常是
__lll_lock_wait或pthread_mutex_lock) - 查看每个线程持有哪些锁(
p *mutex看锁的 owner) - 画出锁依赖图,循环依赖就是死锁
19.3.3 使用AddressSanitizer检测数据竞争
gcc/clang 内置的 AddressSanitizer 可以检测数据竞争:
// 编译时加上 -fsanitize=thread
// g++ -fsanitize=thread -g -o test test.cpp -lpthread
#include <thread>
int counter = 0; // 没有保护!
void increment() {
for (int i = 0; i < 100000; ++i) {
++counter; // 数据竞争!
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
return 0;
}
运行时会直接报出数据竞争的位置和调用栈。嗯,这比肉眼排查靠谱一万倍。
19.4 本章小结
咱们今天聊了三件事:
- 负载均衡:工作窃取、批量提交,让每个线程都有活干
- 锁粒度:把不必要的工作移出临界区,用锁分段减少竞争
- 调试工具:perf 看性能热点,gdb 查死锁,Sanitizer 抓数据竞争
说实话,多线程编程里,80%的问题都是「锁用得不对」和「不知道瓶颈在哪」。工具摆在那,关键是你得会用、敢用。下次遇到性能问题,别急着改代码,先跑一遍 perf 看看——你会发现很多惊喜(或者惊吓)。
最后送大家一句话:没有性能数据支撑的优化,都是瞎优化。
公众号:蓝海资料掘金营,微信deep3321