现代计算机早已进入多核时代,我的第一台双核电脑还是2006年买的,那时候多线程编程还只是高级程序员的玩具。如今就连手机都有8个核心,不会多线程编程简直就是在浪费硬件资源。
想象你在餐厅点餐:单线程就像只有一个服务员,他得依次处理点单、上菜、结账所有事情;而多线程则像是有多个服务员各司其职,整个餐厅的运转效率自然天差地别。这就是为什么我们需要掌握多线程编程——让程序像高效运转的餐厅一样,充分利用每个CPU核心。
很多人分不清线程和进程,用个简单的比喻:进程就像一栋独立的别墅,有自己完整的厨房、卫生间;而线程则是别墅里的租客,共享这些公共设施。在C++中:
关键区别在于:
这两个概念经常被混用,但它们有本质区别:
用高速公路来比喻:
在C++中,我们通过std::thread创建的线程能否真正并行,取决于硬件核心数。
创建线程最基本的姿势:
cpp复制#include <thread>
#include <iostream>
void hello() {
std::cout << "Hello from thread!\n";
}
int main() {
std::thread t(hello);
t.join(); // 等待线程结束
return 0;
}
但这里有个新手常踩的坑:如果忘记join()或detach(),程序终止时会调用std::terminate。我的经验是使用RAII包装:
cpp复制class ThreadGuard {
std::thread& t;
public:
explicit ThreadGuard(std::thread& t_) : t(t_) {}
~ThreadGuard() {
if(t.joinable()) {
t.join();
}
}
// 禁止拷贝
ThreadGuard(const ThreadGuard&)=delete;
ThreadGuard& operator=(const ThreadGuard&)=delete;
};
除了基本的std::mutex,C++还提供了:
重要经验:永远使用std::lock_guard或std::unique_lock,避免直接操作mutex。我曾调试过一个死锁问题,就是因为直接调用mutex.unlock()时抛出了异常。
条件变量是多线程通信的核心工具,但使用起来容易出错。标准模式:
cpp复制std::mutex mtx;
std::condition_variable cv;
bool ready = false;
// 等待线程
{
std::unique_lock<std::mutex> lck(mtx);
cv.wait(lck, []{return ready;});
}
// 通知线程
{
std::lock_guard<std::mutex> lck(mtx);
ready = true;
}
cv.notify_one();
注意虚假唤醒问题:wait()应该在循环中检查条件,这也是为什么第二个参数是predicate。
std::atomic是无需锁的线程安全操作,但性能并非总是更好。它的实现通常有三种方式:
实测对比(i7-10750H @2.6GHz):
一个简单的线程池应包含:
核心代码结构:
cpp复制class ThreadPool {
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queue_mutex;
std::condition_variable condition;
bool stop = false;
public:
explicit ThreadPool(size_t threads) {
for(size_t i = 0; i < threads; ++i) {
workers.emplace_back([this] {
while(true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(this->queue_mutex);
this->condition.wait(lock,
[this]{ return this->stop || !this->tasks.empty(); });
if(this->stop && this->tasks.empty())
return;
task = std::move(this->tasks.front());
this->tasks.pop();
}
task();
}
});
}
}
template<class F>
void enqueue(F&& f) {
{
std::unique_lock<std::mutex> lock(queue_mutex);
tasks.emplace(std::forward<F>(f));
}
condition.notify_one();
}
~ThreadPool() {
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for(std::thread &worker: workers)
worker.join();
}
};
任务窃取(Work Stealing):每个线程维护自己的任务队列,当空闲时可以从其他线程"偷"任务。这能减少锁竞争,提升吞吐量约30%。
动态线程数调整:根据任务负载自动增减线程数。注意:创建线程成本很高(约100μs),不宜频繁调整。
批量任务提交:合并小任务为批次,减少锁操作次数。实测在处理10000个小任务时,批量提交(每批100个)比单个提交快4倍。
死锁的四个必要条件:
避免死锁的实用方法:
调试技巧:在调试器中查看各线程的调用栈,寻找互相等待的锁。
ThreadSanitizer(TSan):
Helgrind(Valgrind工具):
我曾用TSan发现过一个隐藏很深的数据竞争:一个看似线程安全的单例模式,因为静态变量初始化顺序问题导致竞态。
相比std::thread的改进:
示例:
cpp复制std::jthread worker([](std::stop_token stoken) {
while(!stoken.stop_requested()) {
// 执行任务
}
});
// 需要停止时
worker.request_stop();
允许对现有变量进行原子操作:
cpp复制int data = 0;
std::atomic_ref<int> atomic_data(data);
atomic_data.store(42); // 线程安全
C++20引入了std::counting_semaphore,比条件变量更适合某些场景:
cpp复制std::counting_semaphore<10> sem(0); // 最大10,初始0
// 生产者
sem.release();
// 消费者
sem.acquire();
基于原子操作的无锁队列能极大提升并发性能。核心思路:
cpp复制template<typename T>
class LockFreeQueue {
struct Node {
std::shared_ptr<T> data;
std::atomic<Node*> next;
Node(T const& data_) : data(std::make_shared<T>(data_)) {}
};
std::atomic<Node*> head;
std::atomic<Node*> tail;
public:
void push(T const& data) {
Node* const new_node = new Node(data);
Node* old_tail = tail.load();
while(!tail.compare_exchange_weak(old_tail, new_node)) {
// CAS失败,重试
}
old_tail->next = new_node;
}
std::shared_ptr<T> pop() {
Node* old_head = head.load();
while(old_head && !head.compare_exchange_weak(old_head, old_head->next)) {
// CAS失败,重试
}
return old_head ? old_head->data : std::shared_ptr<T>();
}
};
注意:这只是一个简化示例,实际实现还需要考虑ABA问题、内存回收等复杂情况。
多核CPU的缓存一致性协议(MESI)会导致"假共享"(False Sharing)问题。当两个线程频繁修改同一缓存行(Cache Line,通常64字节)中的不同变量时,会导致大量缓存失效。
解决方案:
cpp复制struct alignas(64) CacheLineAligned {
int data;
char padding[64 - sizeof(int)];
};
实测案例:一个4线程统计程序,优化假共享后性能提升3倍。
线程创建成本:
线程局部存储(TLS):
调度策略:
不同编译器对原子操作的实现:
编写跨平台代码时,应优先使用std::atomic,只有在必须时才使用编译器特定扩展。
Google Test + GMock:
Catch2:
顺序一致性测试:验证单线程逻辑正确性
竞态压力测试:
死锁检测测试:
我常用的测试模式是:先单线程验证逻辑,然后逐步增加线程数,同时运行TSan检测。
在多线程网络服务器项目中,我总结了这些血泪教训:
避免过度线程化:不是线程越多越好,最佳数量通常是CPU核心数的1-2倍。过多的线程会导致频繁上下文切换,反而降低性能。
优先考虑任务并行而非数据并行:将工作划分为独立任务比将数据划分为块更不容易出错。
日志系统的线程安全:确保日志输出是原子的,最好使用单独的日志线程通过队列接收消息。
性能分析工具链:
异常安全:多线程环境中的异常传播特别危险,任何可能抛异常的地方都要考虑其对锁状态的影响。