1.2、线程使用
线程基本概念与C++程序的多线程本质
线程是操作系统分配处理器时间的基本单位,简单说就是程序执行流程的最小序列。许多人不知道,每个C++程序从启动那一刻起,就已经在使用多线程了。
当你运行一个最简单的程序:
#include <iostream>
int main() {
std::cout << "Hello, World!" << std::endl;
return 0;
}
这里面至少已经包含了一个线程--运行main()
函数的主线程。C++运行时系统会自动创建这个线程,它是我们程序执行的起点。
案例实战:观察线程ID
让我们编写一个简单程序来观察多线程执行:
#include <iostream>
#include <thread>
#include <chrono>
void background_task() {
std::cout << "子线程ID: " << std::this_thread::get_id() << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "子线程工作完成" << std::endl;
}
int main() {
std::cout << "主线程ID: " << std::this_thread::get_id() << std::endl;
std::thread worker(background_task); // 创建并启动一个新线程
std::cout << "主线程继续执行其他工作" << std::endl;
worker.join(); // 等待子线程完成
std::cout << "所有工作已完成" << std::endl;
return 0;
}
运行这段代码,你会发现主线程和子线程有不同的ID,它们并发执行--这正是多线程程序的基本特性。
线程的创建与启动方式
C++提供了多种方式来指定线程要执行的任务,灵活性极高。
使用函数指针
最直观的方式是用普通函数作为线程入口:
void task() {
// 线程执行的代码
}
std::thread t(task); // 创建并启动线程
使用函数对象(仿函数)
函数对象提供了更多灵活性,可以携带状态:
class TaskWithState {
private:
int value;
public:
TaskWithState(int v) : value(v) {}
void operator()() {
std::cout << "执行任务,值为:" << value << std::endl;
}
};
TaskWithState task(42);
std::thread t(task); // 使用函数对象创建线程
注意陷阱:使用临时函数对象时,必须避免C++最令人困惑的语法解析问题:
// 错误写法:可能被解释为函数声明而非创建线程
std::thread t(TaskWithState());
// 正确写法1:使用额外括号
std::thread t((TaskWithState()));
// 正确写法2:使用统一初始化语法
std::thread t{TaskWithState()};
使用Lambda表达式
Lambda表达式是创建线程最简洁的方式之一:
std::thread t([](){
std::cout << "使用Lambda表达式创建的线程" << std::endl;
});
实战案例:不同创建方式的性能对比
#include <iostream>
#include <thread>
#include <chrono>
#include <vector>
#include <functional>
// 计时辅助函数
template<typename Func>
long long timeIt(Func f) {
auto start = std::chrono::high_resolution_clock::now();
f();
auto end = std::chrono::high_resolution_clock::now();
return std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
}
// 普通函数
void normalFunction() {
volatile int sum = 0;
for (int i = 0; i < 1000000; ++i) sum += i;
}
// 函数对象
class FunctionObject {
public:
void operator()() {
volatile int sum = 0;
for (int i = 0; i < 1000000; ++i) sum += i;
}
};
int main() {
const int ITERATIONS = 1000;
// 测试普通函数
long long normalTime = timeIt([&](){
for (int i = 0; i < ITERATIONS; ++i) {
std::thread t(normalFunction);
t.join();
}
});
// 测试函数对象
long long objectTime = timeIt([&](){
for (int i = 0; i < ITERATIONS; ++i) {
FunctionObject fo;
std::thread t(fo);
t.join();
}
});
// 测试Lambda表达式
long long lambdaTime = timeIt([&](){
for (int i = 0; i < ITERATIONS; ++i) {
std::thread t([](){
volatile int sum = 0;
for (int j = 0; j < 1000000; ++j) sum += j;
});
t.join();
}
});
std::cout << "普通函数: " << normalTime << " 微秒" << std::endl;
std::cout << "函数对象: " << objectTime << " 微秒" << std::endl;
std::cout << "Lambda表达式: " << lambdaTime << " 微秒" << std::endl;
return 0;
}
在我的测试中,这三种方式性能差异不大,选择哪种主要取决于代码清晰度和具体需求。
线程的生命周期管理:join与detach
核心规则:一旦线程启动,必须在std::thread
对象销毁前调用join()
或detach()
方法,否则程序会调用std::terminate()
终止运行。
join:等待线程完成
std::thread t(task);
// ...
t.join(); // 阻塞当前线程,直到t完成执行
join()
的重要特性:
- • 只能调用一次,重复调用会导致程序崩溃
- • 调用后,线程不再可汇合(
joinable()
返回false
) - • 会阻塞当前线程直到目标线程完成
detach:分离线程
当线程需要在后台独立运行时,可以使用detach()
:
std::thread t(background_task);
t.detach(); // 线程在后台独立运行
分离后的线程被称为"守护线程",程序无法直接与它通信或等待它完成。线程的资源管理由C++运行时系统负责。
警告:分离线程访问主线程栈上的变量是极其危险的!
实战案例:异常安全的线程等待
考虑以下场景,如果在t.join()
执行前抛出异常,程序将终止:
void unsafe_thread_management() {
std::thread t([]{ /* 任务代码 */ });
process_data(); // 如果这里抛出异常...
t.join(); // ...这行永远不会执行
} // t析构时,如果未join或detach,程序终止
专业解决方案:使用RAII(资源获取即初始化)技术
class thread_guard {
private:
std::thread& t;
public:
explicit thread_guard(std::thread& t_) : t(t_) {}
~thread_guard() {
if(t.joinable()) {
t.join();
}
}
// 禁止复制
thread_guard(const thread_guard&) = delete;
thread_guard& operator=(const thread_guard&) = delete;
};
void safe_thread_management() {
std::thread t([]{ /* 任务代码 */ });
thread_guard g(t); // 守卫对象,确保t在函数退出时被join
process_data(); // 即使这里抛出异常,t也会被正确join
// 函数结束时,g的析构函数确保t被join
}
实战案例:文档编辑器中的后台任务
class DocumentEditor {
public:
void saveDocument(const std::string& filename, const std::string& content) {
// 在后台线程中保存文档,不阻塞UI
std::thread t(&DocumentEditor::saveDocumentImpl, this, filename, content);
t.detach(); // 分离线程,让保存过程在后台进行
// 立即向用户显示保存中消息
displayStatusMessage("文档保存中...");
}
private:
void saveDocumentImpl(std::string filename, std::string content) {
// 注意参数是传值的,创建了副本
try {
// 模拟耗时的IO操作
std::this_thread::sleep_for(std::chrono::seconds(2));
// 实际保存文件
std::ofstream file(filename);
file << content;
// 通知UI线程保存完成(使用某种线程安全的通信机制)
notifyUiThreadSafely("文档已保存: " + filename);
}
catch(const std::exception& e) {
// 处理异常,通知UI线程
notifyUiThreadSafely("保存失败: " + std::string(e.what()));
}
};
void displayStatusMessage(const std::string& msg) {
// 显示状态消息
std::cout << msg << std::endl;
}
void notifyUiThreadSafely(const std::string& msg) {
// 实际应用中,这里会使用事件队列或其他线程安全机制
std::cout << msg << std::endl;
}
};
线程参数传递
向线程函数传递参数看似简单,但隐藏着许多容易导致灾难性错误的陷阱。
参数传递的基本机制
void func(int i, double d, const std::string& s) {
std::cout << "线程收到参数: " << i << ", " << d << ", " << s << std::endl;
}
int main() {
int num = 42;
double pi = 3.14159;
std::string msg = "Hello";
std::thread t(func, num, pi, msg);
t.join();
return 0;
}
关键要点:
- • 参数默认是拷贝到线程内部存储
- • 拷贝的参数以右值方式传递给线程函数
- • 参数拷贝发生在
std::thread
构造时,而非线程实际开始执行时
致命陷阱:线程访问已销毁的栈变量
考虑以下看似无害的代码:
void process_data(const std::string* data) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << *data << std::endl; // 可能访问已释放内存!
}
void dangerous_function() {
std::string local_data = "局部变量数据";
std::thread t(process_data, &local_data);
t.detach(); // 危险!线程可能在local_data销毁后仍尝试访问
} // local_data在函数结束时销毁
这段代码可能导致程序崩溃或不确定行为,因为线程可能在local_data
销毁后仍试图访问它。
安全解决方案:确保数据生命周期长于线程,或在线程内创建数据副本
void process_data(std::string data) { // 注意:传值而非指针
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << data << std::endl; // 安全:使用线程私有的数据副本
}
void safe_function() {
std::string local_data = "局部变量数据";
std::thread t(process_data, local_data); // 数据被复制
t.detach(); // 安全:线程使用的是数据副本
}
引用参数传递需要std::ref
如果线程函数需要引用参数,必须显式使用std::ref
:
void increment(int& value) {
++value;
}
int main() {
int number = 0;
// 错误:number会被拷贝,而非引用传递
// std::thread t1(increment, number);
// 正确:使用std::ref确保引用传递
std::thread t2(increment, std::ref(number));
t2.join();
std::cout << "Number: " << number << std::endl; // 输出1
return 0;
}
传递成员函数与this
指针
调用类的成员函数作为线程任务需要特殊处理:
class DataProcessor {
public:
void process(const std::string& data) {
std::cout << "处理数据: " << data << std::endl;
processed_count++;
}
int get_processed_count() const {
return processed_count;
}
private:
int processed_count = 0;
};
int main() {
DataProcessor processor;
std::string data = "重要数据";
// 传递成员函数指针、对象指针和参数
std::thread t(&DataProcessor::process, &processor, data);
t.join();
std::cout << "已处理项目数: " << processor.get_processed_count() << std::endl;
return 0;
}
移动语义与不可复制对象
对于只能移动不能复制的对象(如std::unique_ptr
),必须使用std::move
:
#include <iostream>
#include <thread>
#include <memory>
void process_unique_data(std::unique_ptr<int> data) {
std::cout << "处理数据: " << *data << std::endl;
*data = 100; // 修改数据
}
int main() {
std::unique_ptr<int> data = std::make_unique<int>(42);
// 错误:unique_ptr不能被复制
// std::thread t(process_unique_data, data);
// 正确:转移所有权
std::thread t(process_unique_data, std::move(data));
t.join();
// 此时data为nullptr,所有权已转移
if (!data) {
std::cout << "数据所有权已转移到线程" << std::endl;
}
return 0;
}
线程所有权的转移:移动而非复制
std::thread
对象自身也是只能移动不能复制的,这允许我们在不同对象间转移线程所有权:
std::thread worker([]{ std::cout << "工作线程" << std::endl; });
// 转移所有权到t2,worker不再关联任何线程
std::thread manager = std::move(worker);
// 此时worker.joinable()为false
实战案例:动态创建并管理多个线程
#include <iostream>
#include <thread>
#include <vector>
#include <numeric>
// 计算一个区间内所有数字的和
void sum_range(size_t start, size_t end, size_t& result) {
result = 0;
for (size_t i = start; i < end; ++i) {
result += i;
}
}
// 执行并行求和
size_t parallel_sum(size_t n, size_t num_threads) {
std::vector<std::thread> threads;
std::vector<size_t> results(num_threads);
size_t chunk_size = n / num_threads;
for (size_t i = 0; i < num_threads; ++i) {
size_t start = i * chunk_size;
size_t end = (i == num_threads - 1) ? n : (i + 1) * chunk_size;
threads.push_back(
std::thread(sum_range, start, end, std::ref(results[i]))
);
}
// 等待所有线程完成
for (auto& t : threads) {
t.join();
}
// 合并结果
return std::accumulate(results.begin(), results.end(), 0ull);
}
int main() {
size_t total = parallel_sum(1000000, 8);
std::cout << "Sum: " << total << std::endl;
return 0;
}
线程所有权转移的实用场景
功能一:返回创建的线程
std::thread create_worker(const std::string& name) {
return std::thread([name]{
std::cout << "工作线程 " << name << " 启动" << std::endl;
// 执行实际工作...
});
}
int main() {
// 创建并获取线程所有权
std::thread worker = create_worker("数据处理器");
worker.join();
return 0;
}
功能二:将线程移入容器
class ThreadPool {
private:
std::vector<std::thread> workers;
public:
template<typename Func, typename... Args>
void add_task(Func&& f, Args&&... args) {
workers.emplace_back(
std::forward<Func>(f),
std::forward<Args>(args)...
);
}
~ThreadPool() {
for (auto& worker : workers) {
if (worker.joinable()) {
worker.join();
}
}
}
};
线程异常处理的高级技巧
C++的异常机制与多线程结合时需要特别小心。关键法则:线程函数内抛出的异常必须在该线程内处理,否则程序将调用std::terminate()
终止!
安全的线程异常处理模式:
void thread_task() {
try {
// 可能抛出异常的代码
throw std::runtime_error("模拟错误");
}
catch(const std::exception& e) {
// 记录错误
std::cerr << "线程异常: " << e.what() << std::endl;
// 可能的恢复操作
// ...
}
catch(...) {
std::cerr << "线程中发生未知异常" << std::endl;
}
}
如果需要在主线程中获取工作线程的异常信息,可以使用std::promise
和std::future
:
#include <iostream>
#include <thread>
#include <future>
#include <stdexcept>
void thread_task(std::promise<void> result_promise) {
try {
// 模拟一个会失败的任务
throw std::runtime_error("任务执行失败");
// 如果成功,设置值
result_promise.set_value();
}
catch(...) {
// 捕获所有异常,并通过promise传递给等待的线程
result_promise.set_exception(std::current_exception());
}
}
int main() {
std::promise<void> result_promise;
std::future<void> result_future = result_promise.get_future();
std::thread t(thread_task, std::move(result_promise));
try {
// 等待线程完成或抛出异常
result_future.get();
std::cout << "任务成功完成" << std::endl;
}
catch(const std::exception& e) {
std::cout << "任务失败,异常: " << e.what() << std::endl;
}
t.join();
return 0;
}
总结与心得
C++11的std::thread
库为我们提供了强大而灵活的多线程编程能力,但这种能力需要谨慎使用。我发现,真正掌握多线程编程不仅要学会API使用,更要深刻理解底层原理和可能的陷阱。
本文首发于【讳疾忌医-note】公众号,未经授权,不得转载。
(加入我的知识星球,免费获取账号,解锁所有文章。)