C++并发学习笔记(2):线程启动与运行

在 C++ 中,线程的启动主要通过 std::thread 类完成。std::thread 是 C++11 引入的线程库,用于创建和管理线程。


1.1 使用 std::thread 启动线程的基本流程

  1. 创建线程对象:通过 std::thread 构造一个线程对象,同时指定线程要执行的任务。
  2. 执行任务:任务可以是一个函数、Lambda 表达式,或一个可调用对象。
  3. 管理线程:线程启动后需要明确调用 joindetach

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),继续在后台运行,直到它完成任务。
  • 析构函数是否影响线程
    一旦调用 detachstd::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

解决方案
确保线程使用的数据是有效的:

  • 使用动态分配的资源(如 newshared_ptr)。
  • 或使用同步机制(如 std::future 或信号量)管理数据的生命周期。

**如果在启动线程后没有明确决定调用 join()detach(),会导致未定义行为。具体情况如下:

  1. 线程对象在销毁时未调用 join()detach():如果线程对象超出作用域并被销毁,而你没有调用 join()detach(),则会触发未定义行为。这个行为通常会导致程序崩溃或引发运行时错误。

    • 销毁未被 join()detach() 的线程对象时,C++ 标准规定会抛出异常 std::terminate(),导致程序终止。
    • std::terminate() 是 C++ 标准库中的一个函数,用于终止程序的执行。当程序遇到无法继续执行的严重错误时,调用 std::terminate() 会立即终止程序并触发异常处理机制。
  2. 资源未清理:如果没有适当的线程管理(即 join()detach()),线程可能会继续运行,但它的资源没有被正确清理。线程的销毁可能会导致内存泄漏、文件句柄等资源未释放,最终影响程序的稳定性。

3.一些异常情况(Exception)

在C++中,std::thread对象的join()detach()方法用于线程的管理。这两者有不同的使用场景和要求。
通常可以线程启动后立即调用detach()

详细解释:

  1. detach()的作用:

    • 当你调用std::threaddetach()方法时,线程会被“分离”——这意味着目标线程将独立运行,而不再与创建它的线程(主线程或其他线程)有任何关联。当前线程不再等待它完成。
    • 被分离的线程在完成执行后,会自动清理自己占用的资源。也就是说,你无需显式调用join()来等待它,线程资源将在其完成时自动释放。
  2. 为什么可以立即调用detach()

    • 独立执行detach()将线程与主线程解耦,主线程无需再管理这个线程的生命周期。如果你不需要等待线程完成,调用detach()是可以的。被分离的线程会继续执行其任务,而不需要被主线程同步。
    • 无需同步:如果你的程序逻辑不关心线程的返回值或是否完成任务,detach()是一个简单且合适的选择。它允许线程继续执行,而不需要主线程做任何后续处理。
    • 适合后台任务:在一些场景下,你可能只希望启动一个后台线程来完成一些工作,而不在乎它是否成功完成。例如,日志记录、异步处理等任务,可以使用detach()来避免不必要的同步和等待。
  3. 适用场景:

    • 后台任务:当线程执行的是一个后台任务,且你不需要在主线程中等待其完成时,使用detach()是合理的选择。
    • 无须线程结果的情况:如果线程的执行结果不需要被主线程处理,且你不打算等待线程执行完毕,则可以调用detach(),避免了不必要的线程同步。
  4. 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的核心思想:

  1. 资源获取与对象生命周期绑定:在对象创建时,资源被分配;在对象销毁时,资源被释放。这使得程序能够确保即使发生异常或其他错误,资源也会被自动释放,避免资源泄漏。
  2. 作用域结束时自动释放资源:通过栈上的局部变量(对象)管理资源,一旦对象超出作用域,析构函数被自动调用,从而释放资源。

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(); // 可能抛出异常
}

改进建议

  1. 支持移动语义: 如果希望更灵活地管理线程,可以考虑使用 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 块。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值