
多线程编程入门:从线程创建到任务协作
当程序需要同时处理多个任务时,多线程是一种常见选择。它可以让程序在等待网络、磁盘等操作时继续做其他工作,也可以在具备多个处理器核心的机器上并行计算。不过,线程并不会自动让程序变快:线程之间共享进程内存,调度顺序也不确定。缺少同步时,程序可能出现数据竞争、死锁或难以复现的错误。
本文使用 C++17 标准库介绍线程创建、生命周期管理、共享数据保护和任务协作。示例可用支持 C++17 的编译器运行;在 GCC 或 Clang 上通常使用 -pthread 参数,例如:
g++ -std=c++17 -Wall -Wextra -pthread main.cpp -o main
./main
不同平台的编译和链接选项可能不同;Windows 上可使用支持 C++17 的 MSVC 编译器。线程数量、栈大小等资源限制也依赖操作系统和运行环境,不存在适用于所有机器的固定最大线程数。
一、什么时候适合使用多线程
1. I/O 等待期间继续处理工作
程序等待文件、网络、数据库或设备操作时,CPU 可能暂时没有可执行的工作。将彼此独立的任务交给不同线程,可以让一个线程等待时,其他线程继续运行。实际收益取决于 I/O 接口、任务数量和系统调度;有些异步 I/O 方案可以在不为每个等待任务创建线程的情况下完成类似工作。
2. 将计算任务并行化
如果一项计算可以拆分为相互独立的部分,并且运行环境有多个可用核心,那么多个线程有机会同时计算。例如,对数组不同区间分别统计。拆分、同步和合并结果也需要成本;任务太小、线程太多或共享数据争用严重时,多线程可能比单线程更慢。
3. 保持交互响应
图形界面、音视频处理和服务程序可能需要在后台执行耗时工作,避免阻塞负责响应用户或处理新请求的线程。这不意味着应该为每个细小操作都创建一个线程:频繁创建和销毁线程会带来额外成本,通常需要复用线程或采用任务队列。
二、创建线程与管理生命周期
C++ 标准库提供 std::thread。构造线程对象时传入可调用对象及参数,新的执行流便开始运行。下面的程序创建一个工作线程,并由主线程等待它完成:
#include <iostream>
#include <thread>
void do_work(int id)
{
std::cout << "worker " << id << " is working\n";
}
int main()
{
std::thread worker(do_work, 1);
std::cout << "main continues\n";
worker.join();
std::cout << "worker has finished\n";
return 0;
}
join() 会阻塞调用它的线程,直到目标线程结束。执行到 join() 之后,主线程可以确定工作线程已经完成。输出顺序未必固定:在执行 join() 前,主线程和工作线程可能以不同次序打印内容。
若线程函数需要访问已有对象,可以传引用,但必须确保对象在线程结束前仍然有效。例如,传引用时使用 std::ref:
#include <iostream>
#include <functional>
#include <thread>
void add_one(int& value)
{
++value;
}
int main()
{
int value = 0;
std::thread worker(add_one, std::ref(value));
worker.join();
std::cout << value << '\n';
}
这里的主线程在读取 value 前先等待工作线程完成,因此不存在两个线程同时读写该变量的问题。若在 join() 前就读取或修改它,则必须另外设计同步。
join 与 detach 的区别
每个已经启动的 std::thread 对象,在销毁前都必须变成“不可连接”状态:通常调用 join() 等待线程结束,或者调用 detach() 将线程与线程对象分离。若一个仍可连接的 std::thread 对象被销毁,标准库会调用 std::terminate 终止程序。
detach() 不是“自动安全地后台运行”。分离后,原线程对象不再能等待该线程,也不再直接管理它的完成时机。分离线程若访问已经销毁的局部变量、对象或服务,就会产生悬空引用等问题;进程退出时,剩余工作也可能来不及完成。除非能明确管理线程访问的数据和程序退出行为,否则优先使用 join(),或使用更高层的任务机制。
从 C++20 起可以使用 std::jthread,它在析构时会请求停止并等待线程结束。但停止请求是协作式的:线程函数必须检查停止令牌或其他退出条件;它不能强行中断任意正在运行的代码。即使使用 std::jthread,也要避免线程退出条件永远无法满足。
三、共享数据:先认识数据竞争
线程共享同一进程的地址空间。多个线程访问同一个对象本身并不一定有问题;但若两个或多个线程并发访问同一内存位置,至少一个访问是写入,并且这些访问之间没有适当同步,就构成数据竞争。在 C++ 中,数据竞争会导致未定义行为,不能依赖某次测试中“看起来正确”的结果。
例如,下面的计数器有数据竞争。count++ 不是不可分割的操作,多个线程可能同时读取旧值,再分别写回,造成更新丢失:
#include <thread>
int count = 0;
void increment()
{
for (int i = 0; i < 100000; ++i) {
++count; // 多个线程同时执行时存在数据竞争
}
}
int main()
{
std::thread a(increment);
std::thread b(increment);
a.join();
b.join();
}
下面介绍几种常见同步工具。选择哪一种,取决于共享状态、访问模式和性能要求;同步机制也不能替代对对象生命周期的管理。
四、用互斥锁保护临界区
std::mutex 让同一时刻只有一个线程持有锁。对于常规代码,使用 std::lock_guard 或 std::scoped_lock 管理锁的作用域,避免忘记解锁。锁对象离开作用域时会自动释放锁,即使中途发生异常也一样。
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
int count = 0;
std::mutex count_mutex;
void increment()
{
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(count_mutex);
++count;
}
}
int main()
{
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(increment);
}
for (auto& thread : threads) {
thread.join();
}
std::cout << "count = " << count << '\n';
}
程序输出的计数值是 400000。锁保护的是对 count 的访问,所有读写该变量的代码都应遵循相同的加锁约定;如果有一处绕过锁直接读写,保护就不完整。示例中每次递增都加锁,便于说明原理,但频繁锁定会产生开销。可以考虑让每个线程先计算本地结果,结束后再合并,以减少共享更新。
锁的常见边界
- 临界区应尽量短。持锁期间执行慢速 I/O、等待用户输入或调用不受控的回调,会延长其他线程等待时间。
- 不要在持有某把锁时调用可能再次尝试获取同一把非递归互斥锁的代码,否则可能自我死锁。
- 多个线程以不同顺序获取多把锁,可能形成循环等待。需要多把锁时,应采用一致的加锁顺序,或用
std::scoped_lock同时管理多把锁。 - 加锁并不自动解决对象销毁顺序问题。必须保证锁本身和受保护对象在线程使用期间仍然有效。
五、用原子变量完成简单并发更新
对于独立计数、标志位等简单状态,可以考虑 std::atomic。原子操作避免对该原子对象进行非原子并发更新,不需要为单次操作显式加互斥锁。将示例计数器改为原子变量:
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
std::atomic<int> count{0};
void increment()
{
for (int i = 0; i < 100000; ++i) {
count.fetch_add(1, std::memory_order_relaxed);
}
}
int main()
{
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(increment);
}
for (auto& thread : threads) {
thread.join();
}
std::cout << "count = " << count.load() << '\n';
}
这里的 memory_order_relaxed 保证对计数器的原子性,但不用于建立其他普通数据的先后可见关系。所有线程结束后,主线程再读取最终计数;示例不依靠计数器来发布其他数据,因此这种顺序足够。若需要通过原子变量协调其他数据的读写可见性,就必须理解内存序,或采用互斥锁等更容易推理的机制。
原子变量也不是通用的“线程安全开关”。多个字段组成的整体状态、需要同时检查并更新的业务条件,通常不能靠把其中一个变量改成原子变量就解决。原子操作是否无锁也不能一概而论,具体实现和类型会影响这一点。
六、用条件变量等待任务
互斥锁适合保护数据,但线程若要等待“队列中有任务”这样的条件,不应持续循环检查并占用 CPU。条件变量可以让线程在条件不满足时休眠,并在其他线程改变状态后被唤醒。等待必须围绕受互斥锁保护的条件进行,并在醒来后重新检查条件,因为唤醒并不意味着条件一定成立。
下面是一个可运行的单生产者、单消费者示例。生产者写入一个整数后通知消费者;消费者取出并打印,收到结束标记后退出:
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
int main()
{
std::mutex mutex;
std::condition_variable cv;
std::queue<int> queue;
bool done = false;
std::thread consumer([&] {
for (;;) {
int value;
{
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [&] {
return !queue.empty() || done;
});
if (queue.empty() && done) {
break;
}
value = queue.front();
queue.pop();
}
std::cout << "got " << value << '\n';
}
});
std::thread producer([&] {
for (int value = 1; value <= 5; ++value) {
{
std::lock_guard<std::mutex> lock(mutex);
queue.push(value);
}
cv.notify_one();
}
{
std::lock_guard<std::mutex> lock(mutex);
done = true;
}
cv.notify_one();
});
producer.join();
consumer.join();
}
cv.wait(lock, predicate) 会在等待期间配合释放和重新获取互斥锁,并反复检查谓词。谓词写成 !queue.empty() || done,因此消费者既会在有数据时醒来,也会在生产者结束时醒来。访问队列和 done 都在同一把锁的保护下,避免并发读写冲突。
通知本身不保存“信号”。如果线程发出通知时还没有线程在等待,之后开始等待的线程不会因为那次通知自动醒来;因此,程序必须把真正的状态保存在共享数据中,并让等待方检查状态。若生产者可能加入多个、队列有容量上限,或者需要优雅关闭,必须进一步处理阻塞、背压和退出条件;不能简单假定上述单生产者示例已经覆盖所有生产环境需求。
七、把线程和任务结果关联起来
有时需要的不只是“启动一个线程”,还包括获取返回值或异常。std::async 和 std::future 可以表达这类任务关系:
#include <future>
#include <iostream>
int calculate(int value)
{
return value * value;
}
int main()
{
std::future<int> result =
std::async(std::launch::async, calculate, 12);
std::cout << result.get() << '\n';
}
程序打印 144。指定 std::launch::async,表示以异步执行策略启动任务;调用 get() 会等待结果,并在任务抛出异常时重新抛出该异常。一个 future 的结果通常只能通过 get() 取一次;如果需要多个接收者,可了解 std::shared_future。
若使用默认启动策略,标准库可以选择异步执行,也可以选择延迟到等待结果时执行;因此,不能把默认 std::async 一概当成“立即新建线程”。是否适合使用它,还取决于任务调度需求和实现行为。大量短任务通常更适合线程池或其他任务调度器,而不是为每项任务各自创建一个线程。
八、线程数量与资源边界
线程会消耗操作系统和运行库资源,例如线程栈、调度结构和同步对象。可创建线程数量受系统资源、进程限制、运行时实现及当时负载影响;达到限制时,线程创建可能失败。C++ 标准没有规定一个适用于所有平台的线程上限。创建线程时应处理失败情况,不能用一个固定数字推断所有目标机器都能承受相同负载。
std::thread::hardware_concurrency() 可以提供系统支持的并发线程数提示,但返回值是提示,可能为零,也不保证等于程序在当前环境中可用的核心数。它不是可创建线程数的上限,也不意味着创建这么多线程一定最优。计算密集任务可以用它作为估算起点,再通过实际测量调整;I/O 密集任务的合适并发度还与等待比例、服务端限制及资源预算有关。
创建线程时还可能因为资源不足而抛出 std::system_error。如果循环创建多个线程,必须考虑创建中途失败时,已经启动的线程仍然需要妥善结束和等待。生产程序通常通过线程池限制同时运行的工作数量,避免任务规模直接转化为线程数量。
九、常见错误清单
1. 忘记 join 或 detach
仍可连接的 std::thread 被析构会导致程序调用 std::terminate。建立清晰的所有权和退出流程,让负责管理线程的一方负责等待线程结束。
2. 在线程完成前销毁被引用对象
线程函数引用局部变量,而创建线程的函数提前返回,可能导致线程访问无效对象。传引用前确认生命周期覆盖整个并发访问期间;更复杂的所有权关系可以考虑由线程共享管理的对象,但仍要设计好释放时机。
3. 只保护写操作,不保护并发读取
如果一个线程写变量、另一个线程同时读取,而双方没有同步,仍可能发生数据竞争。检查所有访问路径,而不只是修改数据的那一行。
4. 用 sleep 代替同步
sleep_for 只会让当前线程暂停一段时间,不能保证另一个线程在这段时间内完成某项工作。它既可能让测试偶尔通过,也可能让程序在更慢的机器上失败。线程间等待应表达为明确的条件、通知或任务结果。
5. 持锁等待,或拿锁顺序不一致
等待其他线程时若仍持有对方必须获取的锁,可能造成死锁。多把锁的获取顺序不一致也有同类风险。缩小临界区,并为锁定顺序建立明确规则。
6. 误以为线程数越多越快
线程调度、锁竞争、缓存争用和任务拆分都会带来成本。先测量瓶颈,再决定是否并行以及如何分配任务;需要限制并发时,使用有界队列或线程池,而不是无限制地产生线程。
十、线程不是安全隔离边界
同一进程中的线程共享进程地址空间和许多进程级资源。线程可以用于并发处理,但不提供进程级或容器级的安全隔离:一个线程中的越界访问或未定义行为可能破坏同一进程的其他线程。若需要隔离不可信代码、限制资源或防止故障扩散,应评估进程隔离、操作系统权限、沙箱等机制,并结合具体威胁模型设计,不能把“放进另一个线程”当作安全措施。
对敏感数据的访问也应遵循权限控制和生命周期管理。互斥锁只协调并发访问,不会加密数据,也不会限制有权访问进程内存的其他代码。分离线程同样不增加安全性,还可能让敏感数据的使用周期和程序退出行为更难控制。
结语:先定义共享状态,再选择同步方式
多线程编程的关键不只是启动线程,而是明确任务边界、共享状态、数据所有权和退出流程。独立计算可先尝试拆分任务;简单共享计数可用原子变量;需要保护多个相关操作时通常用互斥锁;等待队列或状态变化时使用条件变量;需要返回值时可考虑 future。这些工具各有用途,也都有边界。
实践时可以按这个顺序推进:先用单线程版本确定正确结果,再找出可并行的工作;随后明确哪些数据共享、谁负责销毁对象、线程如何结束;最后添加最少但充分的同步,并通过压力测试和并发诊断工具检查问题。若任务数量很多,优先评估线程池或成熟的并发框架,而不是让每个任务随意创建新线程。




