文章

线程的基本操作

C++ 中线程启动后需调用 join() 等待其结束,或用 detach() 分离线程后台运行,否则析构时报错。避免线程访问已销毁局部变量。推荐用 RAII 自动管理线程生命周期。

线程的基本操作

线程的基本操作

每个程序至少有一个线程:主线程(执行 main() 函数)。使用 std::thread 可以创建更多线程,它们与主线程并发运行

当线程完成任务,其对应的 std::thread 对象必须被妥善处理(如 join()detach()),否则程序会异常终止。

启动线程

启动线程的基本用法

1
2
void do_some_work();
std::thread my_thread(do_some_work);

创建 std::thread 对象时即启动线程。需包含 <thread> 头文件。

使用函数对象

1
2
3
4
5
6
7
8
9
10
class background_task {
public:
  void operator()() const {
    do_something();
    do_something_else();
  }
};

background_task f;
std::thread my_thread(f);

最令人头痛的语法解析(Most Vexing Parse)

1
std::thread my_thread(background_task()); // ❌ 实际上是函数声明!

看起来像是构造一个 std::thread 对象 my_thread,传入 background_task() 作为参数,但实际上,C++ 编译器会将它解析为函数声明,而不是对象定义。

从 C++ 编译器的视角来看,这个语法匹配了下面这种形式的函数声明:

1
2
3
返回类型       函数名     (       参数       );
----------    --------   --------------------
std::thread   my_thread  (background_task(*)())

即:这是一个函数声明,函数名是 my_thread,它的参数是一个函数指针,这个指针指向一个无参数、返回类型为 background_task 的函数,整个函数返回一个 std::thread 对象。

所以尽量避免写形如 Type name(Type()) 的代码。

避免方法:

使用额外一对括号
1
std::thread my_thread((background_task()));  // 使用双括号

外层括号强制编译器将其解释为构造函数参数,而不是函数声明。

使用统一初始化语法(C++11 起)
1
std::thread my_thread{background_task()};    // 使用统一初始化语法

大括号初始化不会被解释为函数声明。

使用 Lambda 表达式

1
2
3
4
std::thread my_thread([] {
  do_something();
  do_something_else();
});

等待线程完成(join)

线程运行后必须:

  • join():等待线程完成

  • detach():分离线程,后台运行

  • 否则程序崩溃(析构时报 std::terminate()

    • std::thread 的析构函数这么写的

      1
      2
      3
      4
      5
      
      ~thread() {
          if (joinable()) {
              std::terminate(); // 崩溃:没处理这个线程
          }
      }
      
    • 也就是说:

      • 如果线程还 joinable(可连接),说明它没被处理;
      • 析构时如果忘了 join()detach(),就直接调用 std::terminate()

示例:线程访问了已经销毁的局部变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct func {
  int& i;
  func(int& i_) : i(i_) {}
  void operator() () {
    for (unsigned j = 0; j < 1000000; ++j) {
      do_something(i);  // 潜在访问无效引用
    }
  }
};

void oops() {
  int some_local_state = 0;
  func my_func(some_local_state);
  std::thread my_thread(my_func);
  my_thread.detach(); // ❌ 没有等待线程结束
} // ❌ 线程仍运行,但局部变量销毁
  • 主线程中 some_local_state 是局部变量,其生命周期到 oops() 函数结束为止。
  • 新线程通过引用访问它,如果未等待线程结束(即使用 detach()),新线程可能在局部变量销毁后仍尝试访问它。
  • 这是一种典型的多线程悬空引用(Dangling Reference)问题,可能导致程序崩溃或行为异常。
🧵 主线程执行步骤🔀 新线程执行步骤
使用 some_local_state 构造 my_func 
创建新线程 my_thread 
 启动线程,执行 func::operator()
 do_something(i) 使用 some_local_state 的引用
调用 my_thread.detach() 
some_local_state 被销毁线程仍在运行,可能继续访问 some_local_state(已销毁)
函数 oops() 结束若线程仍执行 do_something(i),则访问无效引用 → ❌ 未定义行为

特殊情况下的等待

线程异常期间也要确保调用 join()。否则会因为没有汇入线程导致程序崩溃。

示例:异常保护下的 join

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct func; // 同上

void f() {
  int some_local_state = 0;
  func my_func(some_local_state);
  std::thread t(my_func);
  try {
    do_something_in_current_thread();
  } catch (...) {
    t.join();  // ① 异常时确保线程结束
    throw;
  }
  t.join();    // ② 正常路径也等待线程结束
}

RAII风格的线程保护(推荐)

使用析构函数自动调用 join(),避免忘记或异常跳过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class thread_guard {
  std::thread& t;
public:
  explicit thread_guard(std::thread& t_): t(t_) {}
  ~thread_guard() {
    if (t.joinable()) {
      t.join();  // ① 确保线程汇入
    }
  }
  thread_guard(thread_guard const&) = delete;       // ② 禁止拷贝
  thread_guard& operator=(thread_guard const&) = delete;
};

void f() {
  int some_local_state = 0;
  func my_func(some_local_state);
  std::thread t(my_func);
  thread_guard g(t);  // ③ RAII 保证安全
  do_something_in_current_thread();
} // ④ 离开作用域自动 join

后台运行线程(detach)

detach() 表示让线程在后台运行,即成为守护线程(daemon thread):

1
2
3
std::thread t(do_background_work);
t.detach();
assert(!t.joinable()); // 已分离

使用 t.joinable() 判断线程是否可被 join。

示例:多文档编辑器中使用分离线程

1
2
3
4
5
6
7
8
9
10
11
12
13
void edit_document(std::string const& filename) {
  open_document_and_display_gui(filename);
  while (!done_editing()) {
    user_command cmd = get_user_input();
    if (cmd.type == open_new_document) {
      std::string const new_name = get_filename_from_user();
      std::thread t(edit_document, new_name);  // ① 新线程打开新文档
      t.detach();  // ② 分离线程后台运行
    } else {
      process_user_input(cmd);
    }
  }
}

用户打开新文档时,创建新线程进行处理。每个文档处理线程互不干扰,适合使用分离线程。

对比

图解

1
2
3
4
5
6
7
主线程

├─ 创建 std::thread t(...)

├─ join() ─────────────→ 等待子线程执行完毕同步

└─ detach() ───────────→ 子线程后台独立执行异步自己跑

基础定义对比

功能join()detach()
含义主动等待线程执行完毕分离线程,让其独立后台运行
所属权调用后线程归主线程管理调用后线程归系统管理
阻塞行为✅ 阻塞当前线程,直到目标线程执行完❌ 不阻塞,立即返回
清理资源✅ 自动释放线程资源✅ 系统在线程结束时自动清理
是否 joinable()❌ join 后不可 joinable❌ detach 后不可 joinable

使用场景对比

场景推荐操作理由
需要等待线程结果join()等待线程执行完成,例如:下载完成、计算完成
子线程非关键、后台运行detach()无需等待线程,例如:日志、心跳、监控、缓存清理
创建线程但忘记处理❌ 程序崩溃如果既不 join 也不 detach,析构时会 std::terminate()

注意事项对比

项目join()detach()
是否可以多次调用❌ 不可以多次 join❌ 不可以多次 detach
是否可以混用❌ join 和 detach 只能调用一次(或不调用)否则程序崩溃
销毁前是否必须调用✅ 必须调用(join 或 detach 至少一个)✅ 必须调用(否则析构崩溃)
线程安全性安全,资源回收明确⚠️ 危险,需确保线程中不访问悬空对象
调试友好性✅ 调试方便,主线程可捕获异常或日志❌ 调试困难,后台线程奔溃你可能都不知道

总结

操作含义与场景
join()主动等待线程完成,释放资源,常规推荐方式
detach()分离线程,后台运行,适用于 fire-and-forget
RAII用类封装线程,自动在析构时调用 join()
避免错误不要在线程中访问已销毁的局部变量引用
本文由作者按照 CC BY 4.0 进行授权