Thread线程库的基本使用

ZaynPei Lv6

C++11 标准的发布是一个里程碑,它首次将线程支持纳入了标准库。这意味着开发者终于可以编写跨平台的、标准化的多线程程序了。 在使用 std::thread 之前,必须包含头文件 : #include <thread>

创建和启动线程

在 C++11 中,创建一个 std::thread 对象的同时,新线程就已经开始执行了, 同时主线程立即、不阻塞地继续往下执行,新线程和主线程并行执行(这就有可能导致两个线程流同时执行而引发的资源竞争问题)

std::thread 的构造函数接受一个可调用对象(如函数、Lambda表达式、函数对象等)作为线程的入口点,后面跟着传递给该可调用对象的所有参数, 即std::thread t(function_name, args...); 例如, 下面的例子传入thread的是一个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <thread>

// 线程要执行的函数
void task() {
std::cout << "Hello from a new thread!" << std::endl;
}

int main() {
// 1. 创建一个 thread 对象 t,并传入函数名 task
// 2. 线程 t 立刻开始执行 task() 函数
std::thread t(task);

// 等待线程 t 执行完毕(后面会详细讲)
t.join();
return 0;
}

我们也可以直接传入lambda表达式

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <thread>

int main() {
// 直接将 Lambda 表达式作为参数传递
std::thread t([]() {
std::cout << "Hello from a lambda thread!" << std::endl;
});

t.join();
return 0;
}

等待线程完成 (join)

当你需要确保一个线程在主线程继续执行之前完成它的所有工作时,就需要使用 join() 方法。join() 的行为是:阻塞调用它的线程(例如主线程),直到被调用的线程(例如 t)执行结束

使用阻塞的好处是, 一方面可以实现同步:确保子线程的工作成果在主线程的后续步骤中是可用的; 另一方面也确保资源安全:防止主线程退出导致整个进程结束,而子线程可能还在执行,从而被强行终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <thread>
#include <chrono>

void long_running_task() {
std::cout << "Task started..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(2)); // 模拟耗时操作
std::cout << "Task finished!" << std::endl;
}

int main() {
std::thread t(long_running_task);

std::cout << "Main thread is waiting for the task to finish." << std::endl;
t.join(); // 主线程会在这里阻塞,直到 long_running_task 执行完毕

std::cout << "Task has been joined. Main thread continues." << std::endl;
return 0;
}
一个 std::thread 对象只能被 join 一次。调用 join() 后,该线程对象不再与任何活动的执行线程相关联,其状态变为“不可加入” (joinable() 会返回 false)。

检查线程是否可加入 (joinable)

joinable()方法返回一个布尔值,如果线程可以被 join()或 detach(),则返回true,否则返回 false。如果我们试图对一个不可加入的线程调用 join()或 detach(),则会抛出一个 std::system_error异常。

下面是更安全的做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <thread>
void foo() {   
std::cout << "Thread started" << std::endl;
}
int main() {    
std::thread t(foo);    
if (t.joinable()) {
t.join();   
}   
std::cout << "Thread joined" << std::endl;    
return 0;
}

分离线程 (detach)

如果你不关心线程何时结束,也不需要等待它的结果,你可以选择“分离”它。

detach() 的行为是:将 std::thread 对象与实际执行的线程“断开连接”。 之后,这个线程会在后台独立运行,而 std::thread 对象本身不再代表这个线程。

其优点在于, 主线程无需等待,可以立即继续执行自己的任务。

但是同样具有风险:你失去了对该线程的控制。你无法再 join 它。更重要的是,如果主线程(或整个程序)退出了,所有分离的线程都会被操作系统粗暴地终止,不管它们是否完成了任务。这可能导致资源泄露数据损坏

例如, 你有一个分离的线程,它的任务是向一个文件中写入 1000 行日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void log_writer_task() {
std::ofstream log_file("my_app.log");
for (int i = 0; i < 1000; ++i) {
log_file << "Log entry " << i << std::endl;
// 假设在这里有一些微小的延迟
}
// 正常情况下,函数结束时 log_file 的析构函数会自动关闭文件
}

int main() {
std::thread logger(log_writer_task);
logger.detach(); // 分离日志线程

std::cout << "Main function finished quickly!" << std::endl;
return 0; // main函数立即退出
}
当logger 线程被创建并分离,开始向 my_app.log 文件写入数据。然而 main 函数执行得非常快,几乎立刻就结束了, 此时进程开始关闭,操作系统发现 logger 线程还在运行,于是强行终止它。一种可能是情况是 logger 线程只写入了 150 行日志,就被终止了, 造成数据损坏。

再例如, 一个分离的线程在堆上分配了内存,或者获取了一个数据库连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void resource_task() {
// 1. 获取资源
DatabaseConnection* db_conn = new DatabaseConnection("my_db");

// 2. 使用资源执行一些操作
db_conn->executeQuery("UPDATE users SET last_login = NOW() WHERE id = 1;");

// 3. 释放资源
delete db_conn; // 关键的清理步骤
}

int main() {
std::thread t(resource_task);
t.detach();

// main 很快结束
return 0;
}
main 函数很可能在 resource_task 线程执行到 delete db_conn; 之前就退出了, 线程被终止, 导致delete db_conn; 这行代码永远没有机会被执行, 造成资源泄露.

因此,使用 detach 的基本原则是:只对那些执行非常简单不访问共享数据不涉及需要清理的资源,并且允许被随时终止的“辅助性”任务使用。 对于任何核心的、需要确保完成的任务,都应该使用 join()

RAII 与 std::thread 的所有权

一个非常重要的规则是:一个 std::thread 对象在析构时,如果它仍然是 joinable()(即既没有被 join 也没被 detach),那么程序的行为是调用 std::terminate(),导致程序崩溃。

这是为了防止开发者忘记处理线程而导致的资源泄露和未定义行为。

1
2
3
4
5
6
// 错误示例:将导致程序崩溃
void problematic_function() {
std::thread t([]() { /* do something */ });
// 函数结束时,t 将被析构
// 因为 t 既没有 join 也没有 detach,程序会调用 std::terminate
}
这强制我们必须对创建的每一个 std::thread 对象负责,在其生命周期结束前,明确地调用 join()detach()。这种思想也体现了 C++ 的 RAII(Resource Acquisition Is Initialization,资源获取即初始化) 原则。

thread_local 变量

C++11 引入了 thread_local 关键字,用于创建线程私有的、具有静态生命周期的变量。每个线程都会有自己独立的 thread_local 变量实例,互不干扰。

线程私有性 (Thread Privacy): 这是 thread_local 最核心的特性。每个线程都拥有变量的独立实例。一个线程对它的 thread_local 变量进行任何修改,都绝对不会影响到其他线程中的同名变量。

静态存储期 (Static Storage Duration): thread_local 变量的生命周期与它所在的线程绑定。它在线程首次使用该变量时被创建和初始化,并在线程结束时被销毁。这意味着,对于一个特定的线程,这个变量的值在函数调用之间是持久的。

使用范围 (Usage Scope): thread_local 可以用于修饰命名空间作用域的变量(全局变量)、文件静态变量、函数静态变量以及类的静态成员变量。它不能用于修饰非静态的局部变量或类的非静态成员。

它非常适合用于解决那些“需要全局访问,但又不希望线程间共享”的场景,例如:

  • 线程安全的计数器或日志记录器。
  • 每个线程独立的随机数生成器(避免锁和种子问题)。
  • 线程级别的缓存或错误码。

在 thread_local 出现之前,我们在多线程编程中处理变量时面临一个两难的境地:

全局变量/静态变量

  • 优点:生命周期是整个程序,可以在任何函数中访问。

  • 缺点:所有线程共享同一个实例。如果多个线程同时修改它,就会产生竞争条件 (Race Condition),必须使用互斥锁 (std::mutex) 等同步机制来保护,这会增加代码复杂性并降低性能。

局部变量

  • 优点:位于函数栈上,是线程私有的,不存在竞争问题。

  • 缺点:生命周期仅限于函数的单次调用。函数返回后,变量就被销毁了,无法在多次函数调用之间为同一个线程保持状态。

这时就出现了一个需求空白:如果我需要一个变量,它能像全局变量一样在多次函数调用间保持自己的状态,但又希望它像局部变量一样是线程私有的,避免加锁,该怎么办?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <thread>
#include <chrono>
// 声明一个线程局部变量
thread_local int thread_id = 0;
void thread_function(int id) {
thread_id = id; // 每个线程设置自己的 thread_id
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟工作
std::cout << "Thread " << id << " has thread_local id: " << thread_id << std::endl;
}
int main() {
std::thread t1(thread_function, 1);
std::thread t2(thread_function, 2);
std::thread t3(thread_function, 3);

t1.join();
t2.join();
t3.join();
return 0;
}

上述代码中,虽然所有线程都访问同一个 thread_local 变量 thread_id,但每个线程都有自己独立的实例。线程 1 设置 thread_id 为 1,线程 2 设置为 2,线程 3 设置为 3。它们互不干扰,输出结果会显示每个线程的独立值。

同时, 虽然 thread_local 名称中有 “local” 一词, 但它并不是局部变量。它的作用域仍然是声明它的文件或函数内,但它的生命周期是整个线程的运行期间。每个线程在第一次访问 thread_local 变量时,都会初始化它,并且在该线程结束时销毁它