在 C++ 中,线程的启动主要通过 std::thread
类完成。std::thread
是 C++11 引入的线程库,用于创建和管理线程。
1.1 使用 std::thread
启动线程的基本流程
- 创建线程对象:通过
std::thread
构造一个线程对象,同时指定线程要执行的任务。 - 执行任务:任务可以是一个函数、Lambda 表达式,或一个可调用对象。
- 管理线程:线程启动后需要明确调用
join
或detach
。
1.2 线程启动的方式
(1)通过普通函数启动线程
- 定义一个普通函数作为线程任务。
- 创建
std::thread
对象,并将函数传递给它。
#include <iostream>
#include <thread>
void taskFunction() {
std::cout << "线程任务正在运行\n";
}
int main() {
std::thread t(taskFunction); // 启动线程并执行 taskFunction
t.join(); // 等待线程完成
return 0;
}
(2)通过 Lambda 表达式启动线程
- 使用匿名函数(Lambda 表达式)定义线程的任务。
- 这种方式非常简洁,常用于小型任务。
#include <iostream>
#include <thread>
int main() {
std::thread t([] {
std::cout << "Lambda 线程正在运行\n";
});
t.join(); // 等待线程完成
return 0;
}
问题: []
、[=]
和 [&]
之间有什么区别?
在 C++ 中,Lambda 表达式的捕获列表用于决定如何访问外部作用域的变量。[]
、[=]
和 [&]
是三种常见的捕获方式,它们在行为上有显著的差异。下面我们来一一解析它们的区别。
1. []
—— 不捕获任何变量
- 行为:没有捕获任何外部变量,Lambda 只能访问其内部定义的变量。
- 适用场景:适用于完全自包含、不依赖外部变量的任务。
2. [=]
—— 值捕获(捕获外部变量的副本)
- 行为:捕获外部变量的副本,Lambda 执行时,使用的是外部变量的副本,而不是原始变量。
- 适用场景:适用于仅需要访问外部变量的值而不修改它的场景。
3. [&]
—— 引用捕获(捕获外部变量的引用)
- 行为:捕获外部变量的引用,Lambda 执行时,可以修改外部变量的值。
- 适用场景:适用于需要修改外部变量或多个线程共享外部变量的场景。
捕获方式 | 行为 | 适用场景 | 示例代码 | ||
---|---|---|---|---|---|
[] | 不捕获任何变量 | 完全自包含的任务,不依赖外部变量。 | std::thread([] { std::cout << "不捕获变量\n"; }).join(); | ||
[=] | 值捕获,捕获外部变量的副本 | 需要使用外部变量的值,但不修改它。 | int x = 42; std::thread([=] { std::cout << x; }).join(); | ||
[&] | 引用捕获,捕获外部变量的引用 | 需要在 Lambda 中修改外部变量,或者共享它。 | int x = 42; std::thread([&] { x = 100; }).join(); |
(3)通过类的成员函数启动线程
- 使用类的成员函数作为线程任务。
- 如果是非静态成员函数,需要通过对象实例绑定。
#include <iostream>
#include <thread>
class Task {
public:
void run() {
std::cout << "成员函数线程正在运行\n";
}
};
int main() {
Task task;
std::thread t(&Task::run, &task); // 通过对象调用成员函数
t.join(); // 等待线程完成
return 0;
}
对于静态成员函数,它与类的对象实例无关,因此你可以像普通函数一样直接调用它。这样,静态成员函数可以直接作为 std::thread
的线程任务。
下面是一个示例,展示了如何通过静态成员函数启动线程:
#include <iostream>
#include <thread>
class Task {
public:
static void run() {
std::cout << "Hello from static member function" << std::endl;
}
};
int main() {
std::thread t(&Task::run); // 直接传递静态成员函数
t.join();
return 0;
}
(4)通过类的仿函数(可调用对象)启动线程
#include <iostream>
#include <thread>
// 定义一个仿函数
class Task {
public:
void operator()() {
std::cout << "Hello from function object!" << std::endl;
}
};
int main() {
Task task; // 创建仿函数对象
std::thread t(task); // 将仿函数传递给线程
t.join(); // 等待线程完成
return 0;
}
关键点:
Task
类重载了operator()
,使得Task
的实例task
变成了一个可以像普通函数一样被调用的对象。- 通过
std::thread t(task)
将仿函数对象传递给线程,线程会调用task()
来执行任务。 - 通过
t.join()
来确保主线程等待新线程完成。
2. 那么启动线程后呢?
启动线程后,需要明确决定是等待其完成(join)还是让其自行运行(detach)
1. 关于 std::thread
对象和系统线程
-
std::thread
对象的本质:
std::thread
是 C++ 标准库中的一个类,它封装了一个系统线程的句柄,用于管理系统线程的生命周期。 -
系统线程的独立性:
std::thread
对象与它关联的系统线程是两个独立的实体:std::thread
对象:存在于你的 C++ 程序中,用来管理线程。- 系统线程:由操作系统调度和执行,与
std::thread
的生命周期分离。
2. detach
的行为
-
detach
的作用:
调用detach
后,std::thread
对象和系统线程脱离关联:std::thread
对象的作用结束,随时可以被销毁。- 系统线程变成“孤立线程”(Detached Thread),继续在后台运行,直到它完成任务。
-
析构函数是否影响线程:
一旦调用detach
,std::thread
的析构函数只会销毁管理对象本身,不影响系统线程的运行:
{
std::thread t([] {
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "线程任务完成\n";
});
t.detach(); // 系统线程独立运行
} // t 对象销毁,系统线程继续运行
// 输出(稍后):线程任务完成
3. 线程的生命周期
-
对于
join
的线程:
如果调用join
,当前线程会等待系统线程完成,随后关联的系统线程结束生命周期。std::thread
对象随后可以被销毁。 -
对于
detach
的线程:
如果调用detach
,系统线程的生命周期由操作系统管理,与std::thread
的生命周期无关。即使std::thread
对象被销毁,系统线程仍然会运行,直到完成任务。
4. 数据安全
-
线程访问的数据问题:
即使线程继续运行,必须确保它访问的数据在线程完成之前是有效的。否则会导致未定义行为。 -
示例(潜在问题):
{
std::string message = "Hello, thread!";
std::thread t([&message] {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << message << "\n"; // 可能访问已销毁的数据
});
t.detach();
} // 离开作用域,message 被销毁,线程继续运行
// 未定义行为:线程访问已销毁的 message
解决方案:
确保线程使用的数据是有效的:
- 使用动态分配的资源(如
new
或shared_ptr
)。 - 或使用同步机制(如
std::future
或信号量)管理数据的生命周期。
**如果在启动线程后没有明确决定调用 join()
或 detach()
,会导致未定义行为。具体情况如下:
-
线程对象在销毁时未调用
join()
或detach()
:如果线程对象超出作用域并被销毁,而你没有调用join()
或detach()
,则会触发未定义行为。这个行为通常会导致程序崩溃或引发运行时错误。- 销毁未被
join()
或detach()
的线程对象时,C++ 标准规定会抛出异常std::terminate()
,导致程序终止。 std::terminate()
是 C++ 标准库中的一个函数,用于终止程序的执行。当程序遇到无法继续执行的严重错误时,调用std::terminate()
会立即终止程序并触发异常处理机制。
- 销毁未被
-
资源未清理:如果没有适当的线程管理(即
join()
或detach()
),线程可能会继续运行,但它的资源没有被正确清理。线程的销毁可能会导致内存泄漏、文件句柄等资源未释放,最终影响程序的稳定性。
3.一些异常情况(Exception)
在C++中,std::thread
对象的join()
和detach()
方法用于线程的管理。这两者有不同的使用场景和要求。
通常可以线程启动后立即调用detach()
详细解释:
-
detach()
的作用:- 当你调用
std::thread
的detach()
方法时,线程会被“分离”——这意味着目标线程将独立运行,而不再与创建它的线程(主线程或其他线程)有任何关联。当前线程不再等待它完成。 - 被分离的线程在完成执行后,会自动清理自己占用的资源。也就是说,你无需显式调用
join()
来等待它,线程资源将在其完成时自动释放。
- 当你调用
-
为什么可以立即调用
detach()
:- 独立执行:
detach()
将线程与主线程解耦,主线程无需再管理这个线程的生命周期。如果你不需要等待线程完成,调用detach()
是可以的。被分离的线程会继续执行其任务,而不需要被主线程同步。 - 无需同步:如果你的程序逻辑不关心线程的返回值或是否完成任务,
detach()
是一个简单且合适的选择。它允许线程继续执行,而不需要主线程做任何后续处理。 - 适合后台任务:在一些场景下,你可能只希望启动一个后台线程来完成一些工作,而不在乎它是否成功完成。例如,日志记录、异步处理等任务,可以使用
detach()
来避免不必要的同步和等待。
- 独立执行:
-
适用场景:
- 后台任务:当线程执行的是一个后台任务,且你不需要在主线程中等待其完成时,使用
detach()
是合理的选择。 - 无须线程结果的情况:如果线程的执行结果不需要被主线程处理,且你不打算等待线程执行完毕,则可以调用
detach()
,避免了不必要的线程同步。
- 后台任务:当线程执行的是一个后台任务,且你不需要在主线程中等待其完成时,使用
-
detach()
的使用注意事项:- 避免未完成的线程:线程被分离后,它会独立运行,主线程无法再等待它的完成。如果线程在主线程结束之前还没有完成,那么它的资源就可能没有被正确释放,可能导致资源泄漏。
- 确保线程完成:如果调用了
detach()
,需要确保线程最终能够完成其任务,否则它可能在程序结束时依然处于运行状态(这是未定义行为)。例如,避免在线程完成之前程序就结束。 - 不能再次调用
join()
:一旦线程被分离,不能再调用join()
,否则会导致程序崩溃或异常。
对于join,特别地,在抛出异常时(在线程创建之后,在调用join()
之前),可能会导致join()
的调用被跳过,从而导致线程没有被正确地等待或管理。
如何确保join()
不会被跳过
- 虽然主线程未调用
join()
来同步子线程,但子线程仍会继续运行。 - 如果主线程提前退出或程序在子线程完成前结束,子线程可能未完成工作,或者程序行为未定义。
为了确保线程资源能在异常发生时得到正确管理,可以采用以下方法:
方法 1: 在catch
中调用join()
确保在捕获异常时就处理线程,避免异常引发的跳过行为:
#include <iostream>
#include <thread>
#include <stdexcept>
void task() {
std::cout << "Thread started!" << std::endl;
// 模拟可能抛出的异常
throw std::runtime_error("Error in thread");
}
int main() {
std::thread t(task);
try {
// 在这里进行可能抛出异常的操作
std::cout << "Main thread doing work." << std::endl;
// 确保线程能正常被 join
if (t.joinable()) {
t.join(); // 等待线程结束
}
} catch (const std::exception& e) {
std::cout << "Exception caught: " << e.what() << std::endl;
} catch (...) {
std::cout << "Unknown exception caught." << std::endl;
}
std::cout << "Main thread ends." << std::endl;
return 0;
}
但是,我们会得到如下的结果:
![[Pasted image 20250113170037.png]]
代码中使用了 try-catch
语句,但是错误并没有被捕获。这个问题可能是因为线程中的异常没有被正确捕获到主线程。
问题原因
在 C++ 中,当一个线程抛出异常时,该异常只会在该线程中捕获,并不会自动传播到主线程。也就是说,主线程并不会捕获到线程内部的异常。因此,在线程内部抛出的异常必须在该线程中进行处理,否则会导致 std::terminate()
被调用。
所以我们需要谨慎地选择join()
的位置:
#include <iostream>
#include <thread>
#include <stdexcept>
void task() {
std::cout << "Thread started!" << std::endl;
// 模拟可能抛出的异常
// throw std::runtime_error("Error in thread");
}
void do_something() {
throw std::runtime_error("Error in thread");
}
int main() {
std::thread t(task);
try {
std::cout << "Main thread doing work." << std::endl;
do_something();
} catch (const std::exception& e) {
std::cout << "Exception caught: " << e.what() << std::endl;
t.join();
}
if (t.joinable()) {
t.join();
}
std::cout << "Main thread ends." << std::endl;
return 0;
}
![[Pasted image 20250113170837.png]]
2. 使用 RAII 管理线程
RAII(Resource Acquisition Is Initialization)是资源获取即初始化的缩写,是C++中的一种常见编程习惯和技术,确保资源的自动管理。RAII的核心思想是:资源(如内存、文件句柄、数据库连接等)的获取和释放绑定到对象的生命周期上。
RAII的核心思想:
- 资源获取与对象生命周期绑定:在对象创建时,资源被分配;在对象销毁时,资源被释放。这使得程序能够确保即使发生异常或其他错误,资源也会被自动释放,避免资源泄漏。
- 作用域结束时自动释放资源:通过栈上的局部变量(对象)管理资源,一旦对象超出作用域,析构函数被自动调用,从而释放资源。
RAII在C++中的实现方式:
C++通过构造函数和析构函数来实现RAII。构造函数用于资源的获取,析构函数用于资源的释放。
通过自定义 RAII 类或使用 C++ 标准库实现线程的安全管理:
class ThreadGuard {
std::thread& t;
public:
explicit ThreadGuard(std::thread& t_) : t(t_) {}
~ThreadGuard() {
if (t.joinable()) { // 确保线程被 join
t.join();
}
}
ThreadGuard(const ThreadGuard&) = delete;
ThreadGuard& operator=(const ThreadGuard&) = delete;
};
void f() {
std::thread t([]() {
std::cout << "Thread is running..." << std::endl;
});
ThreadGuard guard(t); // 自动管理线程
do_something_in_current_thread(); // 可能抛出异常
}
改进建议
- 支持移动语义: 如果希望更灵活地管理线程,可以考虑使用
std::thread
的移动语义,允许ThreadGuard
持有线程对象的所有权:
class ThreadGuard {
std::thread t; // 持有线程对象
public:
explicit ThreadGuard(std::thread&& t_) : t(std::move(t_)) {}
~ThreadGuard() {
if (t.joinable()) {
t.join();
}
}
ThreadGuard(const ThreadGuard&) = delete;
ThreadGuard& operator=(const ThreadGuard&) = delete;
};
避免传递线程引用可能导致的问题
在原始代码中,ThreadGuard
使用线程的引用进行管理 (std::thread& t
)。这种方式有以下局限性:
-
引用的生命周期问题: 如果传入的线程对象在
ThreadGuard
存续期间被销毁,会导致未定义行为,因为引用可能变为悬空引用。 -
所有权模糊: 使用引用时,
ThreadGuard
并不真正拥有线程对象的所有权,这可能引发管理上的混乱。线程的生命周期可能由多个部分的代码同时管理,容易出错。
利用 C++11 的线程移动语义
在 C++11 中,std::thread
被设计为不可拷贝但可移动。这样设计的目的是明确线程的所有权:线程的所有权只能通过移动操作传递给新的管理者。
-
使用
std::scoped_thread
在 C++20 中,可以直接使用标准库中的std::jthread
,其作用类似于ThreadGuard
且更为便捷,支持自动管理线程生命周期,并支持中止操作。 -
改进
f
函数的异常捕获 若需要对do_something_in_current_thread()
中的异常进行特殊处理,可以添加明确的try-catch
块。