C++并发编程指南02 C++线程的基本操作


2.1 线程的基本操作

想象一下你的程序就像一个公司。main()函数是公司的CEO(主线程),负责整体流程。但公司要高效运转,光靠CEO一个人可不行,需要多个员工(线程)同时处理不同的任务。每个线程执行完分配的任务(函数)后就会“下班”。创建线程就像招聘新员工(std::thread对象),但作为负责任的管理者(程序员),你需要决定是等待员工完成任务(join())还是让他们在后台独立工作(detach())。让我们先学习如何“招聘”线程(启动线程)。

2.1.1 启动线程:创建你的“员工”​

最简单的“招聘”方式,就是让新员工执行一个不需要参数、也不需要返回结果的任务(函数):

#include <thread> // 必须包含这个头文件

void simple_task() {
    std::cout << "Hello from a new thread!\n";
    // ... 做一些工作 ...
}

int main() {
    // "招聘"新员工,让他执行 simple_task
    std::thread worker(simple_task);

    // ... 主线程(CEO)继续做自己的工作 ...

    // 重要!必须决定是等待(join)还是分离(detach)这个员工
    // worker.join();   // 等待他完成
    // worker.detach(); // 让他在后台独立工作(后面会讲)
    return 0;
}

新员工(线程)也可以执行更复杂的任务,比如一个“工作包”(函数对象):

class ComplexTask {
public:
    // 这个操作符()让对象像函数一样被调用
    void operator()() const {
        std::cout << "Starting complex task part 1...\n";
        // ... 第一部分工作 ...
        std::cout << "Starting complex task part 2...\n";
        // ... 第二部分工作 ...
    }
};

int main() {
    ComplexTask task_package; // 创建一个工作包
    std::thread worker(task_package); // 招聘员工,给他这个工作包
    // ... (join 或 detach) ...
    return 0;
}

⚠️ 小心语法陷阱!(最令人头痛的语法解析)​

直接传递一个临时的工作包对象可能会被编译器误解:

std::thread my_thread(ComplexTask()); // 危险!会被当成函数声明!

编译器会认为你在声明一个叫my_thread的函数,它接受一个参数(一个返回ComplexTask的函数指针),返回一个std::thread对象。这完全不是我们想要的!

如何避免这个陷阱?​

  1. 使用额外的括号:​

    std::thread my_thread((ComplexTask())); // 方法1:加括号
    
  2. 使用花括号初始化(C++11推荐):​

    std::thread my_thread{ComplexTask()}; // 方法2:花括号初始化
    
  3. 先创建命名对象:​

    ComplexTask task; // 先创建命名的工作包
    std::thread my_thread(task); // 再传给线程
    
  4. 使用Lambda表达式(简洁有力!):​

    std::thread my_thread([] { // 使用Lambda定义匿名任务
        std::cout << "Lambda task part 1...\n";
        // ... 工作A ...
        std::cout << "Lambda task part 2...\n";
        // ... 工作B ...
    });
    

🚨 重要警告:线程与局部变量的“寿命”问题

想象新员工(线程)需要访问CEO办公室(主线程)里的文件(局部变量)。如果CEO提前下班锁了办公室(函数结束,局部变量销毁),而员工还在试图读文件,就会出大问题(未定义行为,通常是崩溃)!

struct ProblematicTask {
    int& ref_i; // 引用主线程的局部变量
    ProblematicTask(int& i) : ref_i(i) {} // 记住那个变量的引用
    void operator()() {
        for (int j = 0; j < 1000000; ++j) {
            // 危险!如果主线程结束,ref_i 引用的变量可能已销毁!
            std::cout << "Accessing ref_i: " << ref_i << "\n";
            // ... 用 ref_i 做些事情 ...
        }
    }
};

void risky_function() {
    int local_var = 42; // CEO办公室的重要文件
    ProblematicTask task(local_var); // 员工记住了文件位置
    std::thread worker(task);
    worker.detach(); // 💣 大问题!CEO不等员工,直接下班(函数结束)
} // local_var 在这里被销毁!但 worker 线程可能还在运行并试图访问它!

解决方案:​

  • 复制数据:​​ 把员工需要的数据复制一份给他(传值),而不是给引用。

  • 等待员工完成:​​ CEO等员工处理完文件再下班(使用join())。

  • 确保数据寿命:​​ 使用全局数据、静态数据或智能指针管理的数据(如std::shared_ptr),确保数据比所有使用它的线程都活得长。

2.1.2 等待线程完成 (join()):CEO等员工下班

最安全的做法是CEO(主线程)等待员工(新线程)完成任务后再继续或结束。

void safe_function() {
    int local_var = 42;
    ProblematicTask task(local_var); // 仍有潜在问题,但...
    std::thread worker(task);

    // ... CEO 可以做点其他事 ...

    worker.join(); // ✅ CEO 等待 worker 线程完成工作
    // 现在 local_var 可以被安全销毁了
} // 安全退出

关键点:​

  • join()会阻塞主线程,直到新线程执行完毕。

  • join()清理了新线程相关的资源。调用后,std::thread对象就不再代表任何线程。

  • 一个std::thread对象只能join()一次!调用join()后,worker.joinable()会返回false

2.1.3 特殊情况下的等待:当“意外”发生时

如果CEO在等待员工时突然遇到紧急情况(异常),可能会忘记等待员工。我们需要一个更可靠的方法,确保无论是否发生异常,员工都会被妥善处理(等待或分离)。

解决方案:RAII (资源获取即初始化)​

创建一个“线程守卫”类。它的职责是:在其生命周期结束时(比如函数退出或发生异常),如果它守护的线程还没被处理(joinable()),就自动等待它完成(join())。

class ThreadGuard {
    std::thread& t; // 引用它守护的线程
public:
    explicit ThreadGuard(std::thread& t_ref) : t(t_ref) {}
    ~ThreadGuard() { // 析构函数是关键!
        if (t.joinable()) { // 检查线程是否还需要处理
            std::cout << "ThreadGuard: Joining thread before destruction.\n";
            t.join(); // 确保等待线程完成
        }
    }
    // 禁止拷贝和赋值,防止意外复制守卫导致线程被多次join或管理混乱
    ThreadGuard(const ThreadGuard&) = delete;
    ThreadGuard& operator=(const ThreadGuard&) = delete;
};

void robust_function() {
    int local_var = 100;
    // 定义一个安全的任务(这次只使用局部变量的值副本,避免引用问题)
    struct SafeTask {
        int value; // 保存副本,而不是引用!
        SafeTask(int v) : value(v) {}
        void operator()() {
            for (int i = 0; i < 5; ++i) {
                std::cout << "SafeTask working: " << value
                          << " (count " << i+1 << "/5)\n";
                std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 模拟工作耗时
            }
        }
    };

    SafeTask safe_task(local_var); // 创建任务,传递 local_var 的*值*(拷贝)
    std::thread worker(safe_task);

    ThreadGuard guard(worker); // 创建守卫,让它看管 worker 线程

    // ... CEO 做一些可能有风险的工作 ...
    try {
        std::cout << "Main thread doing some work...\n";
        // 模拟可能抛出异常的操作
        // throw std::runtime_error("Something went wrong!"); // 取消注释测试异常
        std::this_thread::sleep_for(std::chrono::seconds(1));
    } catch (...) {
        std::cout << "Main thread caught an exception!\n";
        throw; // 重新抛出异常
    }
    std::cout << "Main thread finished its work.\n";
} // 函数退出时,guard 的析构函数会被调用,确保 worker.join() 被执行

运行说明:​

  • 无论robust_function是正常退出还是因为异常退出,ThreadGuard guard的析构函数都会被调用。

  • 析构函数检查worker线程是否joinable()(即是否尚未被joindetach)。

  • 如果是,则调用worker.join(),主线程等待工作线程完成。

  • 禁止拷贝构造和赋值是为了防止多个ThreadGuard对象管理同一个线程,导致重复join等问题。

2.1.4 让线程在后台运行 (detach()):独立工作的“守护者”​

有时,CEO不需要等待某个员工的结果,可以让他长期在后台独立工作(守护线程 / daemon thread)。比如后台自动保存、监控网络连接、清理缓存等。

void independent_worker() {
    while (true) { // 长期运行
        std::cout << "Daemon thread is working in the background...\n";
        std::this_thread::sleep_for(std::chrono::seconds(2));
        // ... 执行后台任务 ...
    }
}

int main() {
    std::thread daemon(independent_worker);
    daemon.detach(); // 让 daemon 线程独立运行

    std::cout << "Main thread: Daemon launched. I'm free to do other things.\n";
    std::this_thread::sleep_for(std::chrono::seconds(5)); // 主线程做点事
    std::cout << "Main thread: Exiting. The daemon might still be running!\n";
    return 0;
} // main 结束,但如果运行时库支持,detached 线程可能继续在后台运行

关键点:​

  • detach()切断了 std::thread对象 daemon与它启动的实际执行线程的联系。

  • 调用 detach()后,daemon.joinable()返回 false

  • 你不能再对 daemon调用 join()(无效)。

  • 你无法再直接控制或等待这个线程。它完全独立运行。

  • C++ 运行库保证,当这个后台线程最终结束时,其资源会被正确回收。

  • 只能对 joinable()true的线程调用 detach()!​​ 对已经 join过或 detach过的线程调用 detach()会导致程序终止 (std::terminate)。

实际应用案例:文档编辑器

想象一个文字处理软件(如简化版记事本):

void open_document_and_display_gui(const std::string& filename) {
    std::cout << "GUI: Opening document: " << filename << "\n";
    // 模拟加载文档和显示界面
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
}

void process_user_input(const std::string& cmd) {
    std::cout << "Processing command: " << cmd << "\n";
    // 模拟处理用户输入耗时
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
}

std::string get_filename_from_user() {
    // 模拟用户输入文件名
    std::cout << "User: Enter new filename: ";
    return "new_document.txt"; // 简化,实际应读取用户输入
}

// 核心函数:编辑文档
void edit_document(const std::string& filename) {
    open_document_and_display_gui(filename);

    bool done_editing = false;
    while (!done_editing) {
        // 模拟获取用户命令 (简化)
        std::cout << "User command (type 'open', 'save', 'exit'): ";
        std::string command = "open"; // 简化,实际应读取用户输入

        if (command == "open") {
            std::string new_name = get_filename_from_user();
            std::cout << "Opening new document: " << new_name
                      << " in a new window/thread.\n";

            // 为新文档启动一个新线程 (新窗口)
            std::thread t(edit_document, new_name); // 1. 启动线程
            t.detach();                             // 2. 分离线程
            // 当前窗口继续编辑当前文档
        } else if (command == "save") {
            process_user_input("save");
        } else if (command == "exit") {
            process_user_input("exit");
            done_editing = true;
        }
    }
    std::cout << "Closing document: " << filename << "\n";
}

int main() {
    edit_document("initial_document.txt");
    std::cout << "Main: Application exiting. Background threads may still run.\n";
    return 0;
}

解释:​

  • 当用户在当前文档中选择“打开新文档”(open)时,程序需要打开一个新窗口(或标签页)来编辑新文档。

  • 我们不想阻塞当前窗口(主线程),所以使用 std::thread t(edit_document, new_name);启动一个新线程来处理新文档。

  • 立即调用 t.detach();让这个新线程在后台独立运行,管理它自己的新文档窗口。

  • 当前的主线程(处理原始文档)继续响应用户输入。

  • 这样用户就可以同时编辑多个文档,每个文档都在自己的(可能分离的)线程中运行。


总结关键点:​

  1. 启动线程 (std::thread):​​ 传递函数、函数对象或Lambda表达式。小心“最令人头痛的语法解析”,使用花括号初始化或Lambda避免。

  2. 线程与数据:​​ 警惕线程访问主线程的局部变量(尤其是引用/指针)。优先传递值拷贝或确保数据寿命长于线程。

  3. 等待线程 (join()):​​ 安全的选择。阻塞主线程直到新线程结束。一个线程对象只能join()一次。join()后对象不再关联线程。

  4. 资源管理与异常 (RAII):​​ 使用像 ThreadGuard这样的RAII类确保线程在作用域结束时被正确处理(join),即使发生异常。

  5. 分离线程 (detach()):​​ 让线程在后台独立运行(守护线程)。切断std::thread对象与线程的联系。分离后无法再控制或等待该线程。只能对 joinable()true的线程调用。适用于“发后即忘”(fire-and-forget)的任务。

  6. 必须处理:​​ 在 std::thread对象销毁前,​必须调用 join()detach(),否则程序会终止 (std::terminate)。

完整但枯燥的文章

2.1 线程的基本操作

每个程序至少有一个执行main()函数的线程,其他线程与主线程同时运行。如main()函数执行完会退出一样,线程执行完函数也会退出。为线程创建std::thread对象后,需要等待这个线程结束。那么,就先来启动线程。

2.1.1 启动线程

第1章中,线程在std::thread对象创建时启动,通常使用的是无参数无返回的函数。这种函数在执行完毕,线程也就结束了。一些情况下,任务函数对象需要通过某种通讯机制进行参数的传递,或者执行一系列独立操作,通过通讯机制传递信号让线程停止。先放下这些特殊情况不谈,简单来说,使用C++线程库启动线程,就是构造std::thread对象:

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

这里需要包含<thread>头文件,std::thread可以通过有函数操作符类型的实例进行构造:

class background_task
{
public:
  void operator()() const
  {
    do_something();
    do_something_else();
  }
};

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

代码中,提供的函数对象会复制到新线程的存储空间中,函数对象的执行和调用都在线程的内存空间中进行。

有件事需要注意,当把函数对象传入到线程构造函数中时,需要避免“最令人头痛的语法解析”(C++’s most vexing parse, 中文简介)。如果你传递了一个临时变量,而不是一个命名的变量。C++编译器会将其解析为函数声明,而不是类型对象的定义。

std::thread my_thread(background_task());

这相当于声明了一个名为my_thread的函数,这个函数带有一个参数(函数指针指向没有参数并返回background_task对象的函数),返回一个std::thread对象的函数。

使用在前面命名函数对象的方式,或使用多组括号①,或使用统一的初始化语法②,都可以避免这个问题。

如下所示:

std::thread my_thread((background_task()));  // 1
std::thread my_thread{background_task()};    // 2

Lambda表达式也能避免这个问题。Lambda表达式是C++11的一个新特性,允许使用一个可以捕获局部变量的局部函数(可以避免传递参数,参见2.2节)。想要详细了解Lambda表达式,可以阅读附录A的A.5节。之前的例子可以改写为Lambda表达式的方式:

std::thread my_thread([]{
  do_something();
  do_something_else();
});

线程启动后是要等待线程结束,还是让其自主运行。当std::thread对象销毁之前还没有做出决定,程序就会终止(std::thread的析构函数会调用std::terminate())。因此,即便是有异常存在,也需要确保线程能够正确汇入(joined)或分离(detached)。

如果不等待线程汇入 ,就必须保证线程结束之前,访问数据的有效性。这不是一个新问题——单线程代码中,对象销毁之后再去访问,会产生未定义行为——不过,线程的生命周期增加了这个问题发生的几率。

这种情况很可能发生在线程还没结束,函数已经退出的时候,这时线程函数还持有函数局部变量的指针或引用。

代码2.1 函数已经返回,线程依旧访问局部变量

struct func
{
  int& i;
  func(int& i_) : i(i_) {}
  void operator() ()
  {
    for (unsigned j=0 ; j<1000000 ; ++j)
    {
      do_something(i);           // 1 潜在访问隐患:空引用
    }
  }
};

void oops()
{
  int some_local_state=0;
  func my_func(some_local_state);
  std::thread my_thread(my_func);
  my_thread.detach();          // 2 不等待线程结束
}                              // 3 新线程可能还在运行

代码中,已经决定不等待线程(使用了detach()②),所以当oops()函数执行完成时③,线程中的函数可能还在运行。如果线程还在运行,就会去调用do_something(i)①,这时就会访问已经销毁的变量。如同一个单线程程序——允许在函数完成后继续持有局部变量的指针或引用。当然,这种情况发生时,错误并不明显,会使多线程更容易出错。运行顺序参考表2.1。

表2.1 分离线程在局部变量销毁后,仍对该变量进行访问

主线程新线程
使用some_local_state构造my_func
开启新线程my_thread
启动
调用func::operator()
将my_thread分离执行func::operator();可能会在do_something中调用some_local_state的引用
销毁some_local_state持续运行
退出oops函数持续执行func::operator();可能会在do_something中调用some_local_state的引用 --> 导致未定义行为

这种情况的常规处理方法:将数据复制到线程中。如果使用一个可调用的对象作为线程函数,这个对象就会复制到线程中,而后原始对象会立即销毁。如代码2.1所示,但对于对象中包含的指针和引用还需谨慎。使用访问局部变量的函数去创建线程是一个糟糕的主意。

此外,可以通过join()函数来确保线程在主函数完成前结束。

2.1.2 等待线程完成

如需等待线程,需要使用join()。将代码2.1中的my_thread.detach()替换为my_thread.join(),就可以确保局部变量在线程完成后才销毁。因为主线程并没有做什么事,使用独立的线程去执行函数变得意义不大。但在实际中,原始线程要么有自己的工作要做,要么会启动多个子线程来做一些有用的工作,并等待这些线程结束。

当你需要对等待中的线程有更灵活的控制时,比如:看一下某个线程是否结束,或者只等待一段时间(超过时间就判定为超时)。想要做到这些,需要使用其他机制来完成,比如条件变量和future。调用join(),还可以清理了线程相关的内存,这样std::thread对象将不再与已经完成的线程有任何关联。这意味着,只能对一个线程使用一次join(),一旦使用过join(),std::thread对象就不能再次汇入了。当对其使用joinable()时,将返回false。

2.1.3 特殊情况下的等待

如前所述,需要对一个未销毁的std::thread对象使用join()或detach()。如果想要分离线程,可以在线程启动后,直接使用detach()进行分离。如果等待线程,则需要细心挑选使用join()的位置。当在线程运行后产生的异常,会在join()调用之前抛出,这样就会跳过join()。

避免应用被抛出的异常所终止。通常,在无异常的情况下使用join()时,需要在异常处理过程中调用join(),从而避免生命周期的问题。

代码2.2 等待线程完成

struct func; // 定义在代码2.1中
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();  // 1
    throw;
  }
  t.join();  // 2
}

代码2.2中使用了try/catch块确保线程退出后函数才结束。当函数正常退出后,会执行到②处。当执行过程中抛出异常,程序会执行到①处。如果线程在函数之前结束——就要查看是否因为线程函数使用了局部变量的引用——而后再确定一下程序可能会退出的途径,无论正常与否,有一个简单的机制,可以解决这个问题。

一种方式是使用“资源获取即初始化方式”(RAII,Resource Acquisition Is Initialization),提供一个类,在析构函数中使用join()。如同下面代码。

代码2.3 使用RAII等待线程完成

class thread_guard
{
  std::thread& t;
public:
  explicit thread_guard(std::thread& t_):
    t(t_)
  {}
  ~thread_guard()
  {
    if(t.joinable()) // 1
    {
      t.join();      // 2
    }
  }
  thread_guard(thread_guard const&)=delete;   // 3
  thread_guard& operator=(thread_guard const&)=delete;
};

struct func; // 定义在代码2.1中

void f()
{
  int some_local_state=0;
  func my_func(some_local_state);
  std::thread t(my_func);
  thread_guard g(t);
  do_something_in_current_thread();
}    // 4

线程执行到④处时,局部对象就要被逆序销毁了。因此,thread_guard对象g是第一个被销毁的,这时线程在析构函数中被加入②到原始线程中。即使do_something_in_current_thread抛出一个异常,这个销毁依旧会发生。

在thread_guard析构函数的测试中,首先判断线程是否可汇入①。如果可汇入,会调用join()②进行汇入。

拷贝构造函数和拷贝赋值操作标记为=delete③,是为了不让编译器自动生成。直接对对象进行拷贝或赋值是很危险的,因为这可能会弄丢已汇入的线程。通过删除声明,任何尝试给thread_guard对象赋值的操作都会引发一个编译错误。想要了解删除函数的更多知识,请参阅附录A的A.2节。

如果不想等待线程结束,可以分离线程,从而避免异常。不过,这就打破了线程与std::thread对象的联系,即使线程仍然在后台运行着,分离操作也能确保在std::thread对象销毁时不调用std::terminate()

2.1.4 后台运行线程

使用detach()会让线程在后台运行,这就意味着与主线程不能直接交互。如果线程分离,就不可能有std::thread对象能引用它,分离线程的确在后台运行,所以分离的线程不能汇入。不过C++运行库保证,当线程退出时,相关资源的能够正确回收。

分离线程通常称为守护线程(daemon threads)。UNIX中守护线程,是指没有任何显式的接口,并在后台运行的线程,这种线程的特点就是长时间运行。线程的生命周期可能会从应用的起始到结束,可能会在后台监视文件系统,还有可能对缓存进行清理,亦或对数据结构进行优化。另外,分离线程只能确定线程什么时候结束,发后即忘(fire and forget)的任务使用到就是分离线程。

如2.1.2节所示,调用std::thread成员函数detach()来分离一个线程。之后,相应的std::thread对象就与实际执行的线程无关了,并且这个线程也无法汇入:

std::thread t(do_background_work);
t.detach();
assert(!t.joinable());

为了从std::thread对象中分离线程,不能对没有执行线程的std::thread对象使用detach(),并且要用同样的方式进行检查——当std::thread对象使用t.joinable()返回的是true,就可以使用t.detach()。

试想如何能让一个文字处理应用同时编辑多个文档。无论是用户界面,还是在内部应用内部进行,都有很多的解决方法。虽然,这些窗口看起来是完全独立的,每个窗口都有自己独立的菜单选项,但他们却运行在同一个应用实例中。一种内部处理方式是,让每个文档处理窗口拥有自己的线程。每个线程运行同样的的代码,并隔离不同窗口处理的数据。如此这般,打开一个文档就要启动一个新线程。因为是对独立文档进行操作,所以没有必要等待其他线程完成,这里就可以让文档处理窗口运行在分离线程上。

代码2.4 使用分离线程处理文档

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);  // 1
      t.detach();  // 2
    }
    else
    {
       process_user_input(cmd);
    }
  }
}

如果用户选择打开一个新文档,需要启动一个新线程去打开新文档①,并分离线程②。与当前线程做出的操作一样,新线程只不过是打开另一个文件而已。所以,edit_document函数可以复用, 并通过传参的形式打开新的文件。

这个例子也展示了传参启动线程的方法:不仅可以向std::thread构造函数①传递函数名,还可以传递函数所需的参数(实参)。当然,也有其他方法可以完成这项功能,比如:使用带有数据的成员函数,代替需要传参的普通函数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

丁金金_chihiro_修行

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值