首页 > 编程语言 >并发编程(1)——线程

并发编程(1)——线程

时间:2024-10-29 20:52:04浏览次数:6  
标签:std 调用 函数 thread 编程 并发 线程 参数

目录

一、day1

1. 线程的建立

1.1 线程如何发起

1.1.1 普通函数

1.1.2 仿函数

1.1.3 lambda函数

1.1.4 类的成员函数

1.1.5 move

1.2 子线程需要被等待

1.3 detach

1.4. 异常处理

1.5 慎重使用隐式转换

1.6 如何在线程中使用引用

2. thread参数传递和调用原理

2.1 数据成员

2.2 构造函数

2.2.1 函数原型

2.2.2 关于第四个构造函数的一些疑问

2.3 _Start

2.3.1 定义元组来存储函数对象和函数的参数

2.3.2 创建元组实例

2.3.3 定义线程启动函数

2.3.4 启动线程

2.3.5 其他

2.4 总结


一、day1

今天学习的内容包括:

1)线程如何发起(普通函数、仿函数、labda函数,类的成员函数,以及仿函数可能会遇到“最烦恼的解析”问题);⭐⭐⭐⭐⭐

2)线程的等待、detach,以及子线程使用主线程资源可能会遇到的风险;

3)线程的异常处理(如何通过RAII技术,也就是线程守卫实现);

4)线程中隐式转换同样会造成风险,类型和子线程使用主线程资源可能会遇到的风险相似;

5)线程中即使传入实参的是左值,形参类型是引用,但仍然会将其拷贝而不是使用传入值;

6)C++中thread参数传递和调用原理(解释了为什么thread传入的参数若不经过std::ref包装,均会作为右值被保存使用)⭐⭐⭐⭐⭐

参考:

C++ 并发编程实战(第 2 版) - 知乎书店​www.zhihu.com/pub/book/120284204icon-default.png?t=O83Ahttps://www.zhihu.com/pub/book/120284204

https://www.bilibili.com/video/BV1FP411x73X?vd_source=cb95e3058c2624d2641da6f4eeb7e3a1​www.bilibili.com/video/BV1FP411x73X?vd_source=cb95e3058c2624d2641da6f4eeb7e3a1icon-default.png?t=O83Ahttps://link.zhihu.com/?target=https%3A//www.bilibili.com/video/BV1FP411x73X%3Fvd_source%3Dcb95e3058c2624d2641da6f4eeb7e3a1

恋恋风辰官方博客​llfc.club/category?catid=225RaiVNI8pFDD5L4m807g7ZwmF#!aid/2TayNx5QxbGTaWW5s48vMjtuvCBicon-default.png?t=O83Ahttps://link.zhihu.com/?target=https%3A//llfc.club/category%3Fcatid%3D225RaiVNI8pFDD5L4m807g7ZwmF%23%21aid/2TayNx5QxbGTaWW5s48vMjtuvCB

ModernCpp-ConcurrentProgramming-Tutorial/md/详细分析/01thread的构造与源码解析.md at main · Mq-b/ModernCpp-ConcurrentProgramming-Tutorial​github.com/Mq-b/ModernCpp-ConcurrentProgramming-Tutorial/blob/main/md/%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/01thread%E7%9A%84%E6%9E%84%E9%80%A0%E4%B8%8E%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90.mdicon-default.png?t=O83Ahttps://link.zhihu.com/?target=https%3A//github.com/Mq-b/ModernCpp-ConcurrentProgramming-Tutorial/blob/main/md/%25E8%25AF%25A6%25E7%25BB%2586%25E5%2588%2586%25E6%259E%2590/01thread%25E7%259A%2584%25E6%259E%2584%25E9%2580%25A0%25E4%25B8%258E%25E6%25BA%2590%25E7%25A0%2581%25E8%25A7%25A3%25E6%259E%2590.md

信萌新:【C++并发编程实战】 thread 源码实现 && 向线程函数传递参数1 赞同 · 0 评论文章​编辑icon-default.png?t=O83Ahttps://zhuanlan.zhihu.com/p/717266327


1. 线程的建立

进程:一个进程是一个正在运行的程序的实例,拥有自己的地址空间、代码、数据和系统资源。一个进程可以包含一个或多个线程。进程也是程序的⼀次执⾏过程,是系统运⾏程序的基本单位;系统运⾏⼀个程序即是 ⼀个进程从创建、运⾏到消亡的过程。

线程:线程是进程中的⼀个执⾏单元,负责当前进程中程序的执⾏,⼀个进程中⾄少有⼀个线程。⼀个进程中是可以有多个线程的,这个应⽤程序也可以称之为多线程程序。

简⽽⾔之:⼀个程序运⾏后⾄少有⼀个进程,⼀个进程中可以包含多个线程

1.1 线程如何发起

C++线程自C++11及以后的版本中被统一,在包含头文件<thread>之后,通过使用 std::thread 定义线程对象(该对象可以通过构造函数接受一个可调用对象,比如函数、仿函数、lambda函数等),该对象可以启动线程执行回调逻辑(执行传入的可调用对象),线程的发起是在创建对象的同时被启动。

std::thread() 的原型为:

template <class F, class... Args>
explicit thread(F&& f, Args&&... args);
  • F:可调用对象的类型,可以是函数指针、函数对象、lambda 表达式等。
  • Args:可变参数模板,表示传递给可调用对象的参数,比如参数列表中的形参。


1.1.1 普通函数

可以使用普通函数作为可调用对象传入给线程对象。

#include <thread>

void thread_hello(std::string str) {
    std::cout << str << std::endl;
}

std::string str = "hello world!";
std::thread t(thread_hello, str); // 发起线程

1.1.2 仿函数

可以使用仿函数作为可调用对象传入给线程对象。

class background_task {
public:
    void operator() {
        std::cout << "str is " << std::endl;
    }
};
注意,传入仿函数作为可调用对象时不能加 '()'
std::thread t2(background_task());
t2.join();

这样是错误的:

1)background_task() 会被解释为一个函数声明,而不是对象的创建。因为编译器会将t2当成一个函数对象, 返回一个std::thread类型的值, 函数的参数为一个函数指针,该函数指针返回类型为background_task, 没有参数。导致编译器将原本应该是对象的构造解析为函数的声明。

可以理解为:

"std::thread (*)(background_task (*)())"

这是因为C++中“最烦恼的解析”造成的,比如:

class MyClass {
public:
    MyClass() {}
};

// 返回对象是MyClass ,函数名为obj,无参数
MyClass obj(); // 这不是对象的声明,而是一个函数的声明

在上面的代码中,MyClass obj(); 被编译器解析为一个返回 MyClass 类型的函数 obj,而不是一个 MyClass 类型的对象。这种情况被称为“最烦恼的解析”,导致编译器将原本应该是对象的构造解析为函数的声明。原因:

  • 语法规则:C++ 的语法规则允许使用类名后跟括号的形式来声明函数(仿函数)。如果没有其他上下文,编译器会选择这种解析方式。
  • 上下文歧义:在某些情况下,编译器无法明确判断你是想要创建一个对象还是声明一个函数,因此选择最符合语法的解析方式。

为了避免最烦恼的解析,可以使用如下方法:

// 1. 使用花括号
MyClass obj{}; // 这明确表示对象的构造
// 2. 使用额外的括号
MyClass obj((1)); // 使用额外的括号,避免解析为函数声明
// 3. 使用指针
MyClass* obj = new MyClass(); // 使用指针来创建对象

所以我们如果使用仿函数作为可调用对象传入时,可以这样做:

// 1.创建对象
background_task task; // 创建对象
std::thread t2(task); // 传入对象和参数
// 2. 使用花括号
std::thread t2{ background_task() }; // 使用花括号初始化对象
// 3. 使用指针
background_task* task = new background_task(); // 创建对象并使用指针
std::thread t2(*task);
// 4. 使用临时对象
std::thread t2((background_task())); // 使用额外的括号

但如果仿函数中有参数,那么就不会造成"最烦恼的解析”,因为上下文有解释,我是要调用仿函数(因为传入的参数和仿函数对应,构造函数与其不对应),比如:

class background_task {
public:
    void operator()(std::string str) {
        std::cout << "str is " << str << std::endl;
    }
};

std::string str = "hello world!";
// 不会发生报错
std::thread t2(background_task(), str);
t2.join();
"最烦恼的解析”一般在无传入参数的情况下发生。

2)这样并没有创建一个可调用对象。因为 background_task() 并没有创建对象(这样是在调用仿函数,而不是把仿函数作为对象),t2 线程实际上没有被正确初始化。

1.1.3 lambda函数

可以使用lambda函数作为可调用对象传入给线程对象。

std::thread t4([](std::string  str) {
    std::cout << "str is " << str << std::endl;
},  hellostr);
t4.join();

1.1.4 类的成员函数

class X
{
public:
    void do_lengthy_work() {
        std::cout << "do_lengthy_work " << std::endl;
    }
};
void bind_class_oops() {
    X my_x;
    // 还得传入类的指针,因为类的成员函数会调用类的成员变量
    std::thread t(&X::do_lengthy_work, &my_x);
    t.join();
}

注意:如果 thread 绑定的回调函数是普通函数,可以在函数前加&或者不加&,因为编译器默认将普通函数名作为函数地址,如下两种写法都正确。

void thead_work1(std::string str) {
    std::cout << "str is " << str << std::endl;
}
std::string hellostr = "hello world!";
//两种方式都正确
std::thread t1(thead_work1, hellostr);
std::thread t2(&thead_work1, hellostr);
但是如果是绑定类的成员函数,必须添加& (还得传入类的指针,因为类的成员函数会调用类的成员变量)

1.1.5 move

若传递给线程的参数是独占的,也就是不支持拷贝赋值和构造,但我们可以通过 std::move 的方式将参数的所有权转移给线程,如下

void deal_unique(std::unique_ptr<int> p) {
    std::cout << "unique ptr data is " << *p << std::endl;
    (*p)++;
    std::cout << "after unique ptr data is " << *p << std::endl;
}
void move_oops() {
    // p是独占有权的,不能被复制、赋值,但可以转移所有权
    auto p = std::make_unique<int>(100);
    std::thread  t(deal_unique, std::move(p));
    t.join();
    //不能再使用p了,p已经被move废弃
   // std::cout << "after unique ptr data is " << *p << std::endl;
}
注意,若参数的占有权被转移,那么该参数就不能管理之前保存的值,丧失了对该值的占有权

1.2 子线程需要被等待

虽然使用 std::thread 创建的线程在结束时会自动释放其资源,但在主线程(或创建线程的线程)中仍需要等待其子线程结束。 我们需要在主线程中显示调用join()函数等待子线程的结束,子线程结束后主线程才会继续运行。

原因如下:

  1. 如果创建的子线程在其执行过程中没有被主线程等待,那么当主线程结束或被销毁时,操作系统将会终止这个子线程,这可能导致子线程的资源(如内存、文件句柄等)不会被释放,产生资源泄漏
  2. 如果不调用 join(),主线程在没有等待子线程结束的情况下继续执行,可能会导致程序在子线程完成之前就结束,从而未能正确处理子线程的结果(子线程的结果可能不会被主线程处理)。
  3. 如果主线程需要依赖于子线程完成某些任务(例如数据处理或文件写入),需要通过 join() 确保子线程在主线程继续执行之前完成,可以避免因数据未更新而导致的不一致性
线程的回收通过线程的析构函数来完成,即执行terminate操作。
// 1.发起线程
std::string str = "hello world!";
std::thread t1(thread_hello, str); 
// 让主线程暂停执行 1 秒钟,确保子线程能够执行完毕
std::this_thread::sleep_for(std::chrono::seconds(1));
// 2.主线程等待子线程结束
t1.join();

1.3 detach

可以使用detach允许子线程采用分离的方式在后台独自运行,不受主线程影响。主线程和子线程执行各自的任务,使用各自的资源。

注意:当一个线程被分离后,主线程将无法直接管理它,也无法使用join()等待被分离的线程结束。处理日志记录或监控任务这些线程一般会让其在后台持续运行,使用detach。
struct func {
    int& _i;
    func(int & i): _i(i){}
    void operator()() {
        for (int i = 0; i < 3; i++) {
            _i = i;
            std::cout << "_i is " << _i << std::endl;
            std::this_thread::sleep_for(std::chrono::seconds(1));
        }
    }
};
void oops() {
        int some_local_state = 0;
        func myfunc(some_local_state);
        std::thread functhread(myfunc);
        //隐患,访问局部变量,局部变量可能会随着}结束而回收或随着主线程退出而回收
        functhread.detach();    
}
// detach 注意事项
oops();
//防止主线程退出过快,需要停顿一下,让子线程跑起来detach
std::this_thread::sleep_for(std::chrono::seconds(1));

detach使用时有一些风险,比如上述代码。

当主线程调用oops时,会创建一个线程执行myfunc的重载()运算符,然后将主线程将oops创建的一个线程分离。但注意,当oops执行到 '}' 时,局部变量 some_local_state 会被释放,但引用(这里是引用传递而不是按值传递,按值传递不会引起该错误,因为线程中已经有一个自己的拷贝副本了)该局部资源的子线程 functhread 却仍然在后台运行,容易发生错误。

我们可以采取一些措施解决该问题:

  1. 通过智能指针传递局部变量,因为引用计数会随着赋值增加,可保证局部变量在使用期间不被释放,避免悬空指针的问题(网络编程中学习的伪闭包原理)。
  2. 按值传递,将局部变量的值作为参数传递而不是按引用传递,这么做需要局部变量有拷贝复制的功能,而且拷贝耗费空间和效率。
  3. 使用 join() 确保局部变量的生命周期,保证局部变量被释放前线程已经运行结束,但是可能会影响运行逻辑。

1.4. 异常处理

当启动一个子线程时,子线程和主线程是并发运行的。如果主线程由于某种原因崩溃(例如未捕获的异常),则整个进程将会终止(主线程崩溃或者结束时,主进程会回收所有线程的资源),这意味着所有正在运行的线程,包括子线程(不管有没有被detach)都会被强制结束,导致子线程未完成的操作(如数据库写入)被中断。这可能会导致子线程待写入的信息丢失。

为了防止主线程崩溃导致子线程异常退出,可以在主线程中捕获可能抛出的异常,在捕获到异常后,可以选择在主线程中等待所有子线程完成。这样可以确保即使主线程遇到问题,子线程仍然能够完成其操作,并安全地结束。

struct func {
    int& _i;
    func(int & i): _i(i){}
    void operator()() {
        for (int i = 0; i < 3; i++) {
            _i = i;
            std::cout << "_i is " << _i << std::endl;
            std::this_thread::sleep_for(std::chrono::seconds(1));
        }
    }
};

void catch_exception() {
    int some_local_state = 0;
    func myfunc(some_local_state);
    std::thread  functhread{ myfunc };
    try {
        // 可能引发崩溃的程序
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
    catch (std::exception& e) {
        functhread.join();
        throw;
    }
    functhread.join();
}

但是这样太过于繁琐,我们还得捕获异常后将对应的线程进行join,但如果我们有多个线程和多个异常呢?难道还要一个个的组合,写异常处理?之前学习的通过协程实现异步服务器中设计了一个逻辑层,其中逻辑层中对逻辑队列消息的处理就是对上面代码的简化。逻辑层首先会创建一个线程处理逻辑队列的消息,并一直while循环(条件变量挂起,防止队列为空时仍然循环浪费资源),该线程仅有在逻辑层的析构函数被调用时才会结束。在逻辑层的析构函数中,首先将一个标志位置为true,表示逻辑队列的消息处理线程可以退出;然后使用条件变量的notify.one()函数唤醒该线程,如果队列有数据那么将所有数据都处理完;最后,析构函数会等待该线程的所有消息处理完才会析构完成。也就是RAII技术,如下:

LogicSystem::~LogicSystem() {
	std::cout << "逻辑层成功析构" << std::endl;
	_b_stop = true;
	_consume.notify_one(); // 唤醒逻辑处理线程
	_worker_thread.join(); // 等待逻辑消息线程处理完成
}

详细内容可参考:

爱吃土豆:网络编程(19)——C++使用asio协程实现并发服务器3 赞同 · 0 评论文章icon-default.png?t=O83Ahttps://zhuanlan.zhihu.com/p/957175334

那么,我们也可以使用相同的思维方法来对上面这段代码进行简化处理,即线程守卫:

class thread_guard {
private:
    std::thread& _t;
public:
    explicit thread_guard(std::thread& t):_t(t){}
    ~thread_guard() {
        //join只能调用一次
        if (_t.joinable()) {
            _t.join();
        }
    }
    thread_guard(thread_guard const&) = delete;
    thread_guard& operator=(thread_guard const&) = delete;
};
  • joinable() 是 std::thread 的一个成员函数,返回一个布尔值,指示线程是否可连接(即是否已创建且尚未调用 join() 或 detach()),如果_t是一个有效的线程对象且没有调用join() 或 detach(),那么调用join等待该线程结束。

我们可以将需要保护的线程(可能发生异常错误的线程)传递给thread_guard创建一个实例,如果主线程异常发生,保护子线程实例的析构函数会自动调用,确保主线程发生异常时,子线程也能被正确管理,防止资源泄漏

举例:

void auto_guard() {
    int some_local_state = 0;
    func my_func(some_local_state);
    std::thread  t(my_func);
    thread_guard g(t);
    //主线程可能会造成异常的程序代码
    std::cout << "auto guard finished " << std::endl;
}
auto_guard();

如上例所示,通过thread_guard 构造一个新实例来保护线程t,那么即使在 auto_guard 函数中发生异常,thread_guard 也会确保线程t被正确管理,避免资源泄漏。

1.5 慎重使用隐式转换

C++中经常可以看到一些隐式转换,比如short转换为int、char*转换为string等,但这些隐式转换在线程的调用上可能会造成崩溃问题。

void print_str(int i, std::string const& s) {
    std::cout <<"i is "<<i<<" str is " << s << std::endl; 
}

void danger_oops(int som_param) {
    char buffer[1024];
    sprintf(buffer, "%i", som_param);
    //在线程内部将char const* 转化为std::string
    //指向字符的常量指针  char * const  指针的地址是常量,即指针本身不能被修改(不能让它指向其他地方),但可以通过该指针修改它指向的字符数据。
    //指向常量字符的指针  const char * 指向的内容不能变
    // const char* 和 char const* 都表示的常量指针,即指针所指向的字符数据是常量,不能通过该指针修改。
    std::thread t(print_str, 3, buffer);
    t.detach();
    std::cout << "danger oops finished " << std::endl;
}

buffer 是一个局部变量,在线程 t 启动后,danger_oops 函数会继续执行并最终结束。当 danger_oops 函数返回后,buffer 会被销毁,导致线程在执行 print_str 时尝试访问一个无效的内存地址。因为当定义一个线程变量thread t 时,传递给线程 t 的参数buffer会被保存到thread的成员变量中。而在线程对象t内部启动并运行线程时,参数才会被传递给调用函数print_str,但此时danger_oops 函数可能已经返回,局部变量 buffer 被销毁,导致线程在执行 print_str 时尝试访问一个无效的内存地址(传入的是char,即字符串首地址,如果传入的不是地址,也不是按引用传递而是按值传递,那么子线程会创建一个拷贝副本,即使局部变量被释放,子线程仍然可以继续工作),虽然我们确实是按值传递,接收的类型是string,而不是string&和string,但因为传入的是char恰好可以通过隐式转换变为string,所以此时相当于传入的是string*,而形参类型也是string*。这部分内容可以参考上面1.3将的detach。

所以我们只需将隐式转换变为显示转换即可,将char*字符串显示转换为string,那么传入的就是一个string对象,此时,子线程会创建一个拷贝对象,即使danger_oops函数返回,子线程也不会指向空的对象。

void safe_oops(int some_param) {
    char buffer[1024];
    sprintf(buffer, "%i", some_param);
    std::thread t(print_str, 3, std::string(buffer));
    t.detach();
}

1.6 如何在线程中使用引用

在创建线程时,使用 std::thread 来传递参数时, 参数是以拷贝的方式传递的。即使你传入的是一个左值(如一个变量),std::thread 会在内部创建该参数的拷贝。但是在main函数中,如果传入的实参是左值,形参类型是引用,那么函数 不会创建副本,而是直接对传入的值进行修改。

主线程:当在主线程中调用函数时,参数是按值传递还是按引用传递取决于函数的参数声明。如果函数的参数是引用类型(如 int&),那么传递的是对原始变量的引用,可以直接修改这个变量。

void change_param(int& param) {
    param++; // 修改引用的值
}

int main() {
    int value = 5;
    change_param(value); // 这里传递的是 value 的引用
    std::cout << value; // 输出 6
}

子线程:当在子线程中调用函数时,即使参数在函数定义中是引用类型(如 int&),如果在 std::thread 创建线程时直接传递一个变量(如 some_param),这个变量仍会被复制到线程中,子线程内部的修改不会影响主线程中的原始变量。

void change_param(int& param) {
    param++; // 修改引用的值
}

void ref_oops() {
    int some_param = 5;
    std::thread t2(change_param, some_param); // 传递的是 some_param 的拷贝
    t2.join();
    // some_param 的值仍然是 5
}

所以以下代码会报错:

void change_param(int& param) {
    param++;
}
void ref_oops(int some_param) {
    std::cout << "before change , param is " << some_param << std::endl;
    //需使用引用显示转换
    std::thread  t2(change_param, some_param);
    t2.join();
    std::cout << "after change , param is " << some_param << std::endl;
}

即使函数 change_param 的参数为int&类型,我们传递给t2的构造函数为some_param,也不会达到在change_param函数内部修改关联到外部some_param的效果。因为some_param是外部传给函数ref_oops实参的拷贝(右值),右值传递给一个左值引用会出问题,编译会报错。有两种方法可以修正:

方法一:修改 some_param 为引用类型

void ref_oops(int& some_param) { // 将 some_param 改为引用类型
    std::cout << "before change, param is " << some_param << std::endl;
    std::thread t2(change_param, std::ref(some_param)); // 使用 std::ref 显式传递引用
    t2.join();
    std::cout << "after change, param is " << some_param << std::endl;
}

方法二:传递 std::ref

void ref_oops(int some_param) {
    std::cout << "before change, param is " << some_param << std::endl;
    std::thread t2(change_param, std::ref(some_param)); // 使用 std::ref 显式传递引用
    t2.join();
    std::cout << "after change, param is " << some_param << std::endl;
}
那么如果我传递的是一个左值,而不是实参的拷贝呢,会不会还有问题?
void change_param(int& param) {
    param++;
}
void ref_oops() {
    int some_param = 5;
    std::cout << "before change , param is " << some_param << std::endl;
    //需使用引用显示转换
    std::thread  t2(change_param, some_param);
    t2.join();
    std::cout << "after change , param is " << some_param << std::endl;
}

该段函数中,我们传给线程调用对象change_param的参数是一个左值,而change_param形参的类型是引用,那么这样按理说应该是正确的,即线程内部对some_param的处理会影响到外部的some_param。但是,要注意线程无视引用,即使你传入的是左值,形参是引用,参数同样会被拷贝,除非你按引用传入(ref),或者传入的实参本来就是个引用。

void ref_oops() {
    int some_param = 5;
    std::cout << "before change , param is " << some_param << std::endl;
    //需使用引用显示转换
    std::thread  t2(change_param, std::ref(some_param));
    t2.join();
    std::cout << "after change , param is " << some_param << std::endl;
}

线程调用中,左值同样要加ref显式变为引用。可以参考1.6刚开始。

2. thread参数传递和调用原理

thread参数传递涉及到引用折叠问题,即

  • 左值引用+左值引用->左值引用
  • 左值引用+右值引用->左值引用
  • 右值引用+右值引用->右值引用
凡是折叠中出现左值引用,优先将其折叠为左值引用

如果需要向子线程传递参数,直接向std::thread的构造函数传递参数即可。比如:

void f(int i, std::string const& s);
std::thread t(f, 3, "hello");

不过请务必牢记,子线程具有内部存储空间,参数先默认地复制到该处,子线程才能直接访问这些参数。这些副本被当作临时变量,以右值的形式传递给子线程上的可调用对象(也就是如果不显式的将实参以引用ref的方式传入,那么即使子线程的可调用对象的形参是引用类型,可调用对象仍然使用的是传入参数的拷贝,因为子线程首先将传入值复制到子线程的内部存储空间,然后将副本右值的形式传递给子线程上的可调用对象)。

在上述例子中,即使函数f有引用参数 std::string const& s,参数仍然以复制的方式传递。

请注意,尽管函数f的第二个形参为 std::string类型,但是"hello"仍然以指针char const *的形式传入到子线程的内存空间中,当指针被拷贝至子线程的内存以后, 才转换为std::string类型。

2.1 数据成员

std::thread 只有一个私有数据成员_Thr

private:
    _Thrd_t _Thr;

_Thrd_t 是一个结构体,它有两个数据成员:

using _Thrd_id_t = unsigned int;
struct _Thrd_t { // thread identifier for Win32
    void* _Hnd; // Win32 HANDLE
    _Thrd_id_t _Id;
};

这个结构体的 _Hnd 成员是指向线程的句柄,句柄允许 C++ 程序与底层操作系统线程进行交互,如等待线程结束、获取线程信息等;_Id 成员就是保有线程的 ID。

在64 位操作系统,因为内存对齐内存对齐要求通常是基于最大成员的对齐方式,这里必须保证结构体的大小是最大成员大小的倍数,这里最大成员是指针8,所以结构体的大小必须是8的倍数),指针 8 ,无符号 int 4,这个结构体 _Thrd_t 就是占据 16 个字节(尽管成员总共只占用12字节,但为了使整个结构体的大小为16字节,编译器会在结构体末尾添加4个字节的填充)。也就是说 sizeof(std::thread) 的结果应该为 16。

2.2 构造函数

2.2.1 函数原型

std::thread有四个构造函数,分别是:

1)默认构造函数,构造不关联线程的新 std::thread 对象。

thread() noexcept : _Thr{} {}

值初始化了数据成员 _Thr ,这里的效果相当于给其成员_Hnd_Id都进行零初始化

这里的默认构造函数不接受任何参数,并且被标记为 noexcept,这意味着它保证不抛出异常。它能创建但不立即执行任何线程的thread对象,这样的对象通常称为空线程对象。在C++中创建线程时,可以先声明一个空的线程对象,稍后再将其与实际的执行函数关联起来。

2)移动构造函数,转移线程的所有权,将 _Other 的线程对象 _Thr 的所有权转移到新创建的线程对象中。此调用后 other 失去了其线程的所有权。

thread(thread&& _Other) noexcept : _Thr(_STD exchange(_Other._Thr, {})) {}

_STD是一个宏,展开就是 ::std::,也就是 ::std::exchange ,将 _Other._Thr 赋为 {}(也就是置空,通常是一个无效的线程状态),返回_Other._Thr 的旧值用以初始化当前对象的数据成员 _Thr(转移所有权)

std::exchange 是C++14引入的一个实用函数,它用于交换两个值并返回被交换掉的旧值。

#include <iostream>
#include <utility>

int main() {
    int a = 10;
    int b = 20;

    int result = std::exchange(a, b);
    std::cout << "a: " << a << ", result: " << result << std::endl;  // 输出 a: 20, result: 10

    return 0;
}

输出结果:

a: 20, result: 10

a的值和b的值进行交换,所以a的值为10,std::exchange(a, b);的返回结果是左操作数,即旧值

3)复制构造函数被定义为弃置的,std::thread 不可复制。两个 std::thread 不可表示一个线程,std::thread 对线程资源是独占所有权

thread(const thread&) = delete;

4)构造新的 std::thread 对象并将它与执行线程关联。表示新的执行线程开始执行。⭐ ⭐⭐⭐ ⭐(重要)

template <class _Fn, class... _Args, enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0>
    _NODISCARD_CTOR_THREAD explicit thread(_Fn&& _Fx, _Args&&... _Ax) {
        _Start(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
    }

该构造函数是最常使用的,同时也是最复杂的。

  • _Fn:这是传递给线程的可调用对象类型。它可以是普通函数、lambda表达式、函数对象等。
  • _Args…:这是一个「参数包」,代表可变参数类型,包含传递给 _Fn 的参数,允许传入任意数量和类型的参数。
  • enable_if_t:这是一个 SFINAE(Substitution Failure Is Not An Error)技术,用于在模板实例化过程中进行条件编译。这里的条件 !is_same_v<_Remove_cvref_t<_Fn>, thread> 确保 _Fn 的类型在去除 const/volatile 修饰和引用后,不是 std::thread 类型本身,从而避免将 std::thread 对象作为函数参数,进一步避免线程的拷贝。

2.2.2 关于第四个构造函数的一些疑问

1. 关于这个约束你可能有问题,因为 std::thread他并没有 operator()的重载,不是可调用类型,也就是说不能将 std::thread 作为可调用参数传入,那么这个 enable_if_t的意义是什么呢?
struct X{
    X(X&& x)noexcept{} // 移动构造函数
    template <class Fn, class... Args> 
    X(Fn&& f,Args&&...args){} // 模板构造函数
    X(const X&) = delete;
};

X x1{ [] {} };
X x2{ x }; // 选择到了有参构造函数,不导致编译错误

在上段代码中,创建了一个 X 对象 x1,通过模板构造函数,传入了一个 Lambda 表达式(无参数的空函数)。模板构造函数匹配成功,因此 x1 被成功构造。

当试图通过已有的 X 对象 x1 创建另一个 X 对象 x2 时,编译器会选择模板构造函数。这是因为 x1 是一个 X 类型的对象,而模板构造函数可以接受任意类型(包括 X),并且与参数类型的匹配规则使得它可以接受一个 X 对象。这个过程不会导致编译错误,因为模板构造函数并不依赖于传入的对象是否是可调用的(构造函数的选择是基于类型匹配和参数的匹配,而不是基于可调用性),尽管 x1 不是可调用类型,编译器选择了这个构造函数来匹配。

以上这段代码可以正常的通过编译。这是重载决议的事情,但我们知道,std::thread是不可复制的,这种代码自然不应该让它通过编译,选择到我们的有参构造,所以我们添加一个约束让其不能选择到我们的有参构造:

template <class Fn, class... Args, std::enable_if_t<!std::is_same_v<std::remove_cvref_t<Fn>, X>, int> = 0>

这样,这段代码就会正常的出现编译错误,信息如下:

error C2280: “X::X(const X &)”: 尝试引用已删除的函数
note: 参见“X::X”的声明
note: “X::X(const X &)”: 已隐式删除函数
2.  _NODISCARD_CTOR_THREAD是什么?

_NODISCARD_CTOR_THREAD 的实现:

#define _NODISCARD_CTOR_THREAD                                                     \
    _NODISCARD_CTOR_MSG("This temporary 'std::thread' is not joined or detached, " \
                        "so 'std::terminate' will be called at the end of the statement.")

_NODISCARD_CTOR_THREA是一个宏定义,防止线程在不适当的时候被销毁。也就是一段警告消息,用于提醒开发者,如果一个临时的 std::thread 对象在声明结束时既没有加入(joined)也没有分离(detached),程序将调用std::terminate终止执行。

2.3 _Start

在第四个构造函数中,使用了_Start 函数 ,该函数用于将构造函数的参数全部完美转发,是第四个构造函数的核心。⭐ ⭐⭐⭐ ⭐

{
    _Start(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
}

_Start 函数的实现如下:

template <class _Fn, class... _Args>
void _Start(_Fn&& _Fx, _Args&&... _Ax) {
    using _Tuple                 = tuple<decay_t<_Fn>, decay_t<_Args>...>;
    auto _Decay_copied           = _STD make_unique<_Tuple>(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
    constexpr auto _Invoker_proc = _Get_invoke<_Tuple>(make_index_sequence<1 + sizeof...(_Args)>{});

    _Thr._Hnd =
        reinterpret_cast<void*>(_CSTD _beginthreadex(nullptr, 0, _Invoker_proc, _Decay_copied.get(), 0, &_Thr._Id));

    if (_Thr._Hnd) { // ownership transferred to the thread
        (void) _Decay_copied.release();
    } else { // failed to start thread
        _Thr._Id = 0;
        _Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN);
    }
}

2.3.1 定义元组来存储函数对象和函数的参数

using _Tuple = tuple<decay_t<_Fn>, decay_t<_Args>...>;

std::decay_t 是一个类型特征,它用于将类型“衰变”,也就是说它可以用于

  • 移除引用(将引用类型转换为其基础类型)
  • 移除 const 和 volatile 修饰符
  • 移除数组和函数类型的修饰符(将数组和函数转换为指针类型)

_Tuple:表示存储用户可调用对象及其参数的元组类型

2.3.2 创建元组实例

auto _Decay_copied = _STD make_unique<_Tuple>(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);

std::forward 用于完美转发参数,可以确保传递给其他函数的参数保持其原有的类型;

_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...

上段代码将 _Fx 和 _Ax 参数转发到构造函数中,这里 _Fx 和 _Ax 是传入的参数,它们的类型分别对应 _Fn 和 _Args 。通过该实例,可以创建一个独占有权的unique_ptr保存一个Tuple对象,Tuple对象包含函数对象和函数的参数。也就是说,这行代码的目的是存储传入的可调用对象形参的副本

可调用对象的类型没有发生改变,而传给可调用对象的参数其实是形参的副本,而不是形参

2.3.3 定义线程启动函数

constexpr auto _Invoker_proc = _Get_invoke<_Tuple>(make_index_sequence<1 + sizeof...(_Args)>{})

调用_Get_invoke 函数,传入 _Tuple 类型和一个参数序列的索引序列(为了遍历形参包)。这个函数用于获取一个函数指针,指向了一个静态成员函数 _Invoke,它是线程实际执行的函数。

其中 _Get_invoke 和 _Invoke 的实现:

// _Get_invoke 函数的实现
template <class _Tuple, size_t... _Indices>
 _NODISCARD static constexpr auto _Get_invoke(index_sequence<_Indices...>) noexcept {
     return &_Invoke<_Tuple, _Indices...>;
 }

// _Invoke 函数的实现
template <class _Tuple, size_t... _Indices>
static unsigned int __stdcall _Invoke(void* _RawVals) noexcept /* terminates */ {
    // adapt invoke of user's callable object to _beginthreadex's thread procedure
    const unique_ptr<_Tuple> _FnVals(static_cast<_Tuple*>(_RawVals));
    _Tuple& _Tup = *_FnVals.get(); // avoid ADL, handle incomplete types
    _STD invoke(_STD move(_STD get<_Indices>(_Tup))...);
    _Cnd_do_broadcast_at_thread_exit(); // TRANSITION, ABI
    return 0;
}

a. _Get_invoke

// _Get_invoke 函数的实现
template <class _Tuple, size_t... _Indices>
 _NODISCARD static constexpr auto _Get_invoke(index_sequence<_Indices...>) noexcept {
     // 返回的是一个指向特定模板实例 _Invoke 的函数指针,并没有调用该函数
     return &_Invoke<_Tuple, _Indices...>;
 }

_Get_invoke 函数很简单,就是接受一个元组类型,和形参包的索引,传递给 _Invoke 静态成员函数模板,实例化,获取它的函数指针。

注意:return&_Invoke<_Tuple, _Indices...>; 实际上没有直接给 _Invoke 函数提供参数,这是因为它是在返回一个实例化后的函数指针,而不是调用这个函数。返回的函数指针在线程启动时会被调用,并传递一个参数(void* _RawVals),在 _Invoke 中进行处理。
_Get_invoke 中没有调用_Invoke函数,_Invoke函数只在线程启动时会被调用

std::index_sequence 是一个类型,表示一个由一系列整数(索引)构成的序列,常用于参数包展开,让我们能够在模板中以索引方式访问和操作参数。std::index_sequence 经常和 std::make_index_sequence<N> 和 std::index_sequence_for<Ts...>一起配套使用,前者用于生成一个 std::index_sequence,其中包含从 0 到 N-1 的索引,后者用于生成一个 std::index_sequence,其大小与类型参数包 Ts 相同,并且索引顺序与类型参数的顺序相同,比如:

// std::make_index_sequence<3> 生成的类型为:
std::index_sequence<0, 1, 2>
// std::index_sequence_for<int, double, char> 生成的类型为:
std::index_sequence<0, 1, 2>

举例:

// 通过索引展开参数包的函数
template <typename... Args, std::size_t... Is>
void printArgs(std::index_sequence<Is...>, Args&&... args) {
    // C++17 的折叠表达式,用于依次输出每个参数的值和索引
    ((std::cout << "Argument " << Is << ": " << args << std::endl), ...);
}

// 接口函数,生成索引序列
template <typename... Args>
void print(Args&&... args) {
    // 首先完美转发,然后生成一个序列std::index_sequence<0, 1, 2, 3>,最后传给printArgs函数
    printArgs(std::index_sequence_for<Args...>(), std::forward<Args>(args)...);
}

int main() {
    print(10, 3.14, "Hello", 'A');
    return 0;
}

输出为:

Argument 0: 10
Argument 1: 3.14
Argument 2: Hello
Argument 3: A

b. _Invoke

当线程启动时,_Invoke 会被调用;而在_Get_invoke函数中,只会获得一个实例化的_Invoke 指针,并没有调用该_Invoke 函数。

// _Invoke 函数的实现
template <class _Tuple, size_t... _Indices>
static unsigned int __stdcall _Invoke(void* _RawVals) noexcept /* terminates */ {
    // adapt invoke of user's callable object to _beginthreadex's thread procedure
    const unique_ptr<_Tuple> _FnVals(static_cast<_Tuple*>(_RawVals));
    _Tuple& _Tup = *_FnVals.get(); // avoid ADL, handle incomplete types
    _STD invoke(_STD move(_STD get<_Indices>(_Tup))...);
    _Cnd_do_broadcast_at_thread_exit(); // TRANSITION, ABI
    return 0;
}

_Invoke 是重中之重,它是线程实际执行的函数。当线程启动时,_Invoke 函数被调用并传入一个_Tuple对象,包含了可调用对象及其参数。如你所见它的形参类型是 void* ,这是必须的,要符合 _beginthreadex 执行函数的类型要求。虽然是 void*,但是我可以将它转换为 _Tuple* 类型,构造一个独占智能指针指向包含可调用对象及其参数的元组,然后调用 get() 成员函数获取底层指针,解引用指针,得到元组的引用初始化_Tup 。此时,我们就可以调用可调用对象(用户传给thread的可调用对象)

_STD invoke(_STD move(_STD get<_Indices>(_Tup))...);

使用 std::invoke 调用存储在 _Tup 中的可调用对象std::get<_Indices>(_Tup) 提取元组中的元素,_STD move 确保将元素以右值形式传递,避免不必要的复制。

这里有一个形参包展开,_STD get<_Indices>(_Tup))...,_Tup 就是 std::tuple 的引用,我们使用 std::get<> 获取元组存储的数据,需要传入一个索引,这里就用到了 _Indices。展开之后,就等于 invoke 就接受了我们构造 std::thread 传入的可调用对象,调用可调用对象的参数,invoke 就可以执行了。

所以,传给可调用对象的实参并不是用户传给thread的参数,而是线程内部会将传入的参数先进行delay(解除cv和引用)并保存到_Decay_copied (tuple)实例中,然后在_Invoke 函数调用可调用对象时,使用 std::move 将其以 右值的方式传递至可调用对象。也就是说, 传给可调用对象的参数是二手(经过一系列处理)的,并不是传给thread的参数

2.3.4 启动线程

_Thr._Hnd = reinterpret_cast<void*>(_CSTD _beginthreadex(nullptr, 0, _Invoker_proc, _Decay_copied.get(), 0, &_Thr._Id))

调用 _beginthreadex 函数来启动一个线程,并将线程句柄存储到 _Thr._Hnd 中。传递给线程的参数为 _Invoker_proc(之前通过 _Get_invoke 生成的一个函数指针,指向用于执行用户可调用对象的 _Invoke 函数)和 _Decay_copied.get()(存储了函数对象和参数的副本的指针)。

这行代码的整体作用是使用  _beginthreadex 创建一个新线程,执行  _Invoker_proc 函数,并将相关的参数传递给它,新线程的句柄被存储在 _Thr._Hnd 中,以便后续对线程进行管理。

2.3.5 其他

if (_Thr._Hnd) { // ownership transferred to the thread
        (void) _Decay_copied.release();
    } else { // failed to start thread
        _Thr._Id = 0;
        _Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN);
    }
  • 如果线程句柄_Thr._Hnd不为空,则表示线程已成功启动,将独占指针的所有权转移给线程
    • 释放独占指针的所有权,因为已经将参数传递给了线程(原本的一手数据已经二手传递给了可调用对象)
  • 如果线程启动失败,则进入这个分支
    • 将线程ID设置为0
    • 抛出一个 C++ 错误,表示资源不可用,请再次尝试

2.4 总结

通过对thread源码的解读,也就明白了为什么1.6的疑问:

当在子线程中调用函数时,为什么 即使参数在函数定义中是引用类型(如 int&),如果在 std::thread 创建线程时直接传递一个变量(如 some_param),这个变量仍会被复制到线程中,子线程内部的修改不会影响主线程中的原始变量?

因为thread的实现中将类型先经过 decay(解除cv、引用) 处理,如果要传递引用,则必须用类包装一下才行,使用std::ref(不会被decay解除)函数就会返回一个包装对象。

然后传给可调用对象的实参并不是用户传给thread的参数,而是线程内部会将传入的参数先进行delay(解除cv和引用)并保存到_Decay_copied (tuple)实例中,然后在_Invoke 函数调用可调用对象时,使用 std::move 将其以右值的方式传递至可调用对象。也就是说,传给可调用对象的参数是二手(经过一系列处理)的,并不是传给thread的参数

标签:std,调用,函数,thread,编程,并发,线程,参数
From: https://blog.csdn.net/m0_63086198/article/details/143325213

相关文章

  • 实验2 类和对象_基础编程1
    实验任务1代码t.h1#pragmaonce2#include<string>34classT{5public:6T(intx=0,inty=0);7T(constT&t);8T(T&&t);9~T();1011voidadjust(intratio);12voi......
  • 少儿编程学习中的家庭支持:家长角色如何从监督到参与?
    随着少儿编程教育的普及,越来越多的家庭开始意识到编程对孩子未来发展的重要性。编程不仅仅是一项技术技能,更是培养逻辑思维、解决问题能力和创新意识的有效途径。然而,如何在家庭中正确支持孩子的编程学习,对家长而言是一个新的挑战。从过去的“监督学习”到如今的“积极参与和......
  • 少儿编程进入义务教育课程:培养信息科技素养的新政策解读
    近年来,随着数字化进程的推进和人工智能技术的普及,编程教育逐渐走入中小学课堂。教育部在《义务教育课程方案和课程标准(2022年版)》中正式将编程与信息科技教育纳入小学和初中的课程体系中,强调培养学生的计算思维、编程能力和科技素养。这一政策的出台,标志着编程教育已成为义务......
  • [python]多线程快速入门
    前言线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。由于CPython的GIL限制,多线程实际为单线程,大多只用来处理IO密集型任务。Python一般用标准库threading来进行多线程编程。基本使用方式1,创建threading.Thread类的示例importthreadi......
  • Python 编程的最好搭档—VSCode 详细指南
     刚学Python的同学可能会觉得每次写Python的时候都得打开Cmd有点烦躁,直接上手Pycharm的同学可能会觉得这软件太笨重了,晦涩难用。那么有没有省去打开CMD的步骤,又能弥补Pycharm笨重的特点的软件呢?当然有,答案是VSCode.诞生于2015年的VSCode编辑器,现在可以说是目前最强的编辑......
  • 实验2 类和对象_基础编程1
    实验1task1.cppt.h:#pragmaonce#include<string>//类T:声明classT{//对象属性、方法public:T(intx=0,inty=0);//普通构造函数T(constT&t);//复制构造函数T(T&&t);//移动构造函数~T();//析构函数......
  • 实验3 C语言函数应用编程
    1.实验任务1#include<stdio.h>charscore_to_grade(intscore);intmain(){intscore;chargrade;while(scanf("%d",&score)!=EOF){grade=score_to_grade(score);printf("分数:%d,等级:%c\n\n",score,grad......
  • 实验2 类和对象_基础编程1
    任务1:源代码:t.h1#pragmaonce23#include<string>45classT{6public:7T(intx=0,inty=0);8T(constT&t);9T(T&&t);10~T();1112voidadjust(intratio);13voiddisplay()const;1415......
  • 现在职业PHP 程序员通常用什么编程工具
    标题:现代职业PHP程序员通常使用的编程工具开头段落:现代职业PHP程序员通常使用的编程工具主要包括集成开发环境(IDE)、版本控制系统、调试工具、数据库管理工具、以及代码质量工具。这些工具共同构建了PHP开发的基础框架,使开发工作更加高效、组织化。特别地,集成开发环境(IDE)无疑是......
  • 实验2 类和对象 基础编程1
    实验任务1:源代码t.h:点击查看代码#pragmaonce#include<string>//类T:声明classT{//对象属性、方法public:  T(intx=0,inty=0); //普通构造函数  T(constT&t); //复制构造函数  T(T&&t);   //移动构造函数  ~T();     //析......