细致讲解一下 pthread_join()
函数
有些人可能觉得join的第二个参数不太好理解,所以这里在细说一下这个部分,以前如果我们想拿到一个函数中的多个返回值,但由于函数的返回值只能有一个,所以为了拿到多个返回值,我们都是在调用函数之前,定义出想要拿到的返回值的类型的变量,然后把这个变量的地址传给需要调用的函数,这样的函数参数我们称为输出型参数,然后在函数内部会通过解引用输出型参数的方式,将函数内部的某个需要返回给外部的值拷贝到解引用后的参数里面,那其实就是修改了我们函数外部定义的变量的值。
这里不好理解的原因其实是因为二级指针,我们想要拿到的线程函数的返回值是一个指针,不再是一个变量,所以在调用join的时候,仅仅传一级指针是不够的,我们需要传一级指针变量的地址,让join内部能解引用一级指针变量的地址,拿到外面的一级指针内容并对其修改。
三、 线程的封装 — 再次理解线程
线程是进程内的一个执行单元,它共享进程的资源,但有自己的栈、寄存器、信号屏蔽字等。线程之间可以通过共享内存、信号、信号量等方式进行通信和同步。线程相比进程,创建和销毁的开销更小,切换的代价也更低。
Linux中有两种线程模型:用户级线程和内核级线程。用户级线程是由用户程序或库实现的,不依赖于内核支持,因此具有更高的灵活性和效率,但也有一些缺点,如不能利用多核处理器,不能响应信号等。内核级线程是由内核提供的,每个线程都对应一个轻量级进程(LWP),因此可以享受内核的调度和管理,但也要付出更多的系统开销。
Linux中最常用的线程库是POSIX线程库(pthread),它提供了一套标准的API来创建和控制线程。
1. 用户级线程tid究竟是什么?(映射段中线程库内的TCB的起始地址)
除线程库要在用户层创建一个描述线程的数据结构外,实际操作系统还会给用户层的TCB创建出来对应的轻量级进程内核数据结构,进行内核中轻量级进程的管理。
所以可以认为,线程是POSIX库中实现了一部分,操作系统中实现了一部分。每当我们创建一个线程时,库就要帮我们在用户层创建出对应的线程控制块TCB,来对库中的多个线程进行管理,同时操作系统还要在对应的创建出轻量级进程。
所以,Linux用户级线程 : 内核轻量级进程 = 1:1。用户关心的线程属性在用户级线程中,内核提供轻量级进程(线程)的调度!
内核中创建轻量级进程调用的接口就是clone,它可以帮助我们创建出linux认为的"线程"。
像之前所使用的join函数的第一个参数,也就是tid,他就是TCB的起始地址,也就是指向TCB结构体的指针,而线程函数的返回值实际会写到TCB结构体中的某一个字段,join函数需要tid这个地址,实际就会通过这个结构体指针从TCB中拿到表示线程函数返回值的那个字段的内容。然后将其写到join的第二个参数 void **retval里面。
并且我们现在也能回头去理解一些东西了,例如为什么叫用户级线程库,当然是因为线程库会被映射到虚拟地址空间的映射段啊,而映射段不就是在用户空间吗?线程库的代码都是跑在用户空间的上的,所以线程库也叫用户级线程库。
2.线程的局部存储(介于全局和局部变量之间的,线程特有的一种存储方案)
Linux中线程的局部存储(thread-local storage,TLS)是一种分配不同线程独有的对象的机制,它们允许使用声明的名字来引用与当前线程相关联的实体。
为什么需要线程的局部存储
在多线程编程中,有三种类型的变量:
- 全局变量:在程序的整个生命周期内存在,对所有线程可见,需要使用同步机制来避免竞争条件。
- 局部变量:在函数调用时创建,函数返回时销毁,对当前线程可见,不需要使用同步机制。
- 线程的局部存储:在线程创建时创建,线程结束时销毁,对当前线程可见,不需要使用同步机制。
线程的局部存储相当于介于全局变量和局部变量之间的一种存储方案,它可以实现以下目标:
- 避免全局变量带来的同步开销和数据不一致问题。
- 避免局部变量带来的重复创建和销毁开销和数据传递问题。
- 提高数据访问的效率和安全性。
如何使用线程的局部存储
要使用线程的局部存储,只需要在变量声明时加上__thread、_Thread_local或者thread_local关键字,例如:
__thread int i; // GCC扩展 _Thread_local int j; // C11标准 thread_local int k; // C++11标准
这些关键字可以单独使用,也可以和extern或static一起使用,但不能和其他存储类别说明符一起使用。当和extern或static一起使用时,__thread、_Thread_local或者thread_local必须紧跟在其他存储类别说明符之后。
这些关键字可以应用于任何全局变量、文件作用域静态变量、函数作用域静态变量或者类的静态数据成员。它们不能应用于块作用域自动变量或者类的非静态数据成员。
当对一个线程的局部存储变量应用取地址运算符时,它会在运行时计算出当前线程实例该变量的地址,并返回该地址。这个地址可以被任何线程使用。当一个线程终止时,任何指向该线程中线程的局部存储变量的指针都会失效。
在C++中,如果一个线程的局部存储变量有初始化器,则必须是一个常量表达式。
Linux中线程的局部存储的实现
Linux中线程的局部存储的实现比较复杂(这篇文档详细解释了四种线程的局部存储寻址模型以及运行时期望如何工作),但基本思路是这样的:
编译器会将一个含有__thread、_Thread_local或者thread_local关键字的变量放到一个特殊的.tdata段中,这个段包含了所有线程的局部存储变量。
在运行时,每个线程都会创建一个新的数据段,并复制.tdata段中的数据到该段中,当线程切换时,该段也会自动切换。
最终结果是__thread、_Thread_local或者thread_local变量和普通变量一样快,并且不占用额外的栈空间。
下面是一个示例代码,使用__thread关键字定义了一个全局变量和一个函数作用域静态变量,并打印出它们在不同线程中的地址:
#include <stdio.h> #include <pthread.h> __thread int global = 0; // 全局变量 void *foo(void *arg) { __thread static int local = 0; // 函数作用域静态变量 printf("This is thread %d\n", *(int *)arg); printf("The address of global is %p\n", &global); printf("The address of local is %p\n", &local); return NULL; } int main() { pthread_t tid1, tid2; int ret1, ret2; int arg1 = 1, arg2 = 2; ret1 = pthread_create(&tid1, NULL, foo, &arg1); if (ret1 != 0) { perror("pthread_create"); return -1; } ret2 = pthread_create(&tid2, NULL, foo, &arg2); if (ret2 != 0) { perror("pthread_create"); return -1; } pthread_join(tid1, NULL); // 等待第一个子线程结束 pthread_join(tid2, NULL); // 等待第二个子线程结束 return 0; }
输出结果可能是这样:
This is thread 1 The address of global is 0x7f9a6e8a6e80 The address of local is 0x7f9a6e8a6e84 This is thread 2 The address of global is 0x7f9a6e0a5e80 The address of local is 0x7f9a6e0a5e84
可以看到,在不同线程中,global和local变量有不同的地址,但相对位置是相同的。这说明每个线程都有自己独立的数据段,并且该段中包含了所有线程的局部存储变量。
3.封装一个线程类 — 面向对象思想
Linux中提供了一套标准的线程API,它允许我们创建并控制多个并发的执行流。但是,pthread库是基于C语言的,它只提供了一些函数和数据结构,而没有提供面向对象的抽象和封装。如果我们也想像C++11那样通过面向对象的方式来玩,我们可能需要自己封装一些接口,来实现一个线程类(thread class),使得我们可以更方便地创建和管理线程对象。
线程类的设计
我们首先要设计一个线程类的接口,即定义它的成员变量和成员函数。一个基本的线程类应该包含以下内容:
一个pthread_t类型的变量,用来存储线程的标识符。
一个构造函数,用来初始化线程对象。
一个析构函数,用来销毁线程对象。
一个Start方法,用来启动线程,并传递一个可选的参数。
一个Join方法,用来等待线程结束,并获取其返回值。
一个Detach方法,用来分离线程,使其在结束时自动释放资源。
一个Cancel方法,用来取消线程的执行。
一个静态的辅助函数,用来作为pthread_create函数的第三个参数,即线程要执行的函数。
一个虚函数,用来作为辅助函数的参数,即线程要执行的具体逻辑。
我们可以定义一个基类Thread如下:
class Thread { public: Thread(); // 构造函数 virtual ~Thread(); // 析构函数 bool Start(void * arg = NULL); // 启动线程 void Join(); // 等待线程结束 void Detach(); // 分离线程 void Cancel(); // 取消线程 protected: virtual void Run(void * arg) = 0; // 线程要执行的具体逻辑 private: pthread_t tid; // 线程标识符 static void * ThreadFunc(void * arg); // 辅助函数 };
线程类的实现
接下来,我们要实现线程类的各个成员函数。首先是构造函数和析构函数:
Thread::Thread() { // 初始化tid为0 tid = 0; } Thread::~Thread() { // 如果tid不为0,则调用Cancel方法 if (tid != 0) { Cancel(); } }
然后是Start方法:
bool Thread::Start(void * arg) { // 调用pthread_create函数创建新线程,并将this指针作为参数传递给辅助函数 int ret = pthread_create(&tid, NULL, ThreadFunc, this); // 如果成功创建,则返回true;否则返回false return ret == 0; }
然后是Join方法:
void Thread::Join() { // 调用pthread_join函数等待当前线程结束,并将tid置为0 pthread_join(tid, NULL); tid = 0; }
然后是Detach方法:
void Thread::Detach() { // 调用pthread_detach函数分离当前线程,并将tid置为0 pthread_detach(tid); tid = 0; }
然后是Cancel方法:
void Thread::Cancel() { // 调用pthread_cancel函数取消当前线程,并将tid置为0 pthread_cancel(tid); tid = 0; }
最后是辅助函数和虚函数:
void * Thread::ThreadFunc(void * arg) { // 将arg转换为Thread类型的指针,并调用其Run方法 Thread * ptr = (Thread *)arg; ptr->Run(ptr->arg); return NULL; } void Thread::Run(void * arg) { // 这是一个纯虚函数,需要在子类中重写 }
线程类的使用
有了这个基类Thread之后,我们就可以继承它并重写Run方法来实现自己想要的线程逻辑。例如,我们可以定义一个打印信息的子类PrintThread如下:
class PrintThread : public Thread { public: PrintThread(const char * msg); // 构造函数 protected: virtual void Run(void * arg); // 重写Run方法 private: const char * message; // 要打印的信息 }; PrintThread::PrintThread(const char * msg) { // 初始化message为msg message = msg; } void PrintThread::Run(void * arg) { // 打印message和当前线程ID printf("%s from thread %lu\n", message, pthread_self()); }
然后我们可以在主程序中创建并使用PrintThread对象:
#include <stdio.h> #include <unistd.h> int main() { // 创建两个PrintThread对象,分别传递不同的信息 PrintThread pt1("Hello"); PrintThread pt2("World"); // 启动两个线程 pt1.Start(); pt2.Start(); // 等待两个线程结束 pt1.Join(); pt2.Join(); // 打印主线程ID printf("Main thread %lu done\n", pthread_self()); return 0;
输出结果:
当然,这只是一个简单的例子,我们还可以在线程类中添加更多的功能和属性满足不同需求。