🐱作者:一只大喵咪1201
🐱专栏:《Linux学习》
🔥格言:你只管努力,剩下的交给时间!
线程控制 | 线程TCB
🧰线程控制
Linux内核中并不存在线程的概念,我们程序员是通过库来使用线程的,这个库是POSIX线程库,是由原生线程库提供的,它遵守POSIX标准,就像之前学过的System V标准一样。POSIX线程库有以下几个特点:
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的。
- 要使用这些函数库,要通过引入头文<pthread.h>。
- 链接这些线程函数库时要使用编译器命令的“-lpthread”选项。
🎴线程创建
系统调用接口:
- pthread_t* thread:输出型参数,将线程的tid值放入到我们外部创建好的pthread_t 类型的变量中。
- 第二个参数:用来设置线程属性,一般情况下设置成nullptr,等用到的时候再详细讲解。
- void* (*start_routine)(void *):函数指针,这是一个回调函数,该函数的内容就是新线程要执行的。
- void* arg:回到函数的参数。
- 返回值: 线程创建成功返回0,不成功返回错误码。
一般情况下,新线程的创建是不会失败的,万一失败了,也不会设置errno,因为errno是一个全局变量,某个线程改变了这个变量会对其他线程造成影响,所以直接将错误码返回即可。
- 在编译的时候,必须指定线程库,使用-l pthread选项。
接下来用这个接口创建一批线程:
#defineNUM10void*start_routine(void* args){sleep(1);
string name =(char*)(args);while(1){
cout<<"new thread name: "<<name<<endl;sleep(1);}}intmain(){//创建一批线程for(size_t i =0; i < NUM;++i){
pthread_t tid;char buffer[64];snprintf(buffer,sizeof buffer,"thread %d",i+1);pthread_create(&tid,nullptr,start_routine,(void*)buffer);}while(1){
cout<<"----create success----"<<endl;sleep(1);}return0;}
创建10个线程,让它们同时运行,并且给每个线程编号,新线程死循环打印各自的线程名字,新线程在延时1秒后开始执行。
将上诉代码运行起来后,查看线程,可以看到一共有11个线程,其中1个主线程,10个新线程。
但是运行结果中,10个线程都是线程10,其他9个线程并没有出现,这是什么原因呢?
- 新线程中首先要延时1秒钟,然后才开始执行代码,在它延时的过程中,主线程一直在跑。
- 主线程中的名字缓冲区会被覆盖,最终只有"thread 10"。
- 当10个新线程开始执行时,需要去缓冲区中拿数据(缓冲区所有线程共享),所以拿到的都是"thread 10"。
上面代码中,本喵故意给新线程先延时了一秒钟,让主线程先跑,去覆盖缓冲区,如果不延时也有可能会出现上诉情况。
- 主线程和新线程到底谁先执行是不确定的,是由操作系统的调度器决定的。
即使不给新线程延时,也有可能是主线程先运行,在时间片结束之前,同样会完成数据覆盖,导致新线程从缓冲区中只能读到最终的数据。
所以说,上面的代码是有问题的,我们需要保证每个线程都有自己独一无二的缓冲区。
classThreadData{public:
pthread_t _tid;char _name[64];};
创建一个类,这个类中包括线程的tid以及名字的缓冲区。
- 每个线程都在堆区new一个对象,来存放该线程的tid以及名字,然后将这个对象的地址传给新线程。
- 新线程通过主线程传过来的指针找到属于它的结构体对象,然后使用里面的数据。
此时10个线程就都能正常运行了,不存在缓冲区的覆盖问题了,因为一个线程有一个缓冲区。
🎴线程结束
- return nullptr结束线程
当新线程执行到return的时候,就会结束。
- 在线程中加了计数值,5秒后跳出循环,执行return,结束线程。
当计数值到了以后,新线程全部结束,只剩下主线程在执行。
- pthread_exit()结束线程
POSIX线程库专门提供了一个接口来结束线程:
- 参数:返回线程结束信息,当前阶段设置成nullptr即可。
调用该接口的线程会结束。
同样,当计数值到了以后,新线程会调用该接口,然后就只剩下主线程了,新线程全部结束了。
注意:
不能使用exit()来结束线程,因为exit系统调用是争对进程的,调用该接口会让整个进程都结束掉。
🎴线程等待
和进程一样,线程也是需要等待的,如果不等待会造成内存泄漏,也就是结束掉的线程PCB不会被回收(类似僵尸进程),但是我们看不到没有回收的现象。
系统调用:
- pthread_t thread:要等待的线程tid。
- void** retval:线程结束信息返回,这是一个输出型参数。
- 返回值:等待成功返回0,等待失败返回错误码。
主线程中并没有延时,它执行的速度是很快的。在新线程中需要进行计数,所以执行速度会慢很多。
可以看到,主线程在执行到线程等待的时候,会阻塞等待,不再往下执行,直到所有线程都等待成功才会继续向下执行。
所以说,线程等待是阻塞式等待。
线程返回值
线程等待和进程等待一样,主要有两个作用:
- 获取线程退出信息。
- 回收线程PCB资源,防止内存泄漏。
上面线程等待的代码中并没有获取线程退出的相关信息,那么该如何获取线程退出的相关信息呢?
- 新线程在结束的时候会返回一个void*类型的指针。
- 在pthread线程库中,有一个void类型的指针变量来接收从线程中返回的void指针。
- 指针变量和指针是有区别的,指针变量会开辟空间,里面存放的是指针。
- 指针就是地址,是数字,不会开辟空间。
如上图中代码所示,将整形数字10强转成void*类型,然后返回。
- 现在面临的问题就是怎么从pthread线程库中拿到从线程中返回的void*指针。
在主线程的栈区中有一个void类型的指针变量,新线程中返回的void类型指针最终会放到这个ret中。
- pthread线程库中有一个void** 类型的二级指针变量retval。
- pthread_join()系统调用将主线程中void*类型的指针变量的地址传给了pthread线程库中的二级指针变量,此时主线程就和线程库建立了联系。
- 将新线程中返回到线程库中的void*指针变量中的返回值,通过这种联系放到主线程中指针变量中----也就是 *retval = ret。
这样,我们就可以成功的获取到新线程退出时的返回信息了,桥梁就是pthread_join()系统调用。
pthread_join()系统调用中,之所以传的是二级指针,是为了在pthread库中能够找到主线程中一级指针变量 void * ret。
在线程等待时,传入ret的二级指针获取线程退出信息。
- 由于Linux中void* ret是8个字节,接收到的线程退出信息10也是一个void*类型的。
- 我们要想看到这个值,需要将它转换成整数,所以必须转成longlong类型,也是8个字节,如果转成int的话会有精度损失从而会报错。
可以看到,每个线程在退出时的退出信息都被主线程接收到了,由于所有线程的退出信息都是10,所以接收到的也都是10。
通过pthread_exit()同样可以将线程的退出信息返回到pthread的线程库中,然后再通过线程等待接口拿走这个退出信息。
- 在结构体中增加一个线程编号信息,每创建成功一个线程都给它一个编号。
- 新线程在退出的时候返回各自的编号。
- 线程等待代码不变,和上面一样。
此时我们就成功获得了各个线程在退出时候返回的编号,也就是获得了线程的退出信息。
整数都可以返回,更别说一个真正的地址了,可以将要返回的信息放在数组中,然后返回数组地址。
- 在学习进程等待的时候,我们不仅可以获得进程的退出信息,还能获得进程的退出信号,但是在线程退出时就没有获得线程退出信号,这是为什么呢?
- 因为信号是发给进程的,整个进程都会被退出,线程要退出信号也没有意义了。
- 而且pthread_join默认是能够等待成功的,并不考虑异常的问题,异常是进程要考虑的事,线程不用考虑。
线程取消(线程结束的一种方式)
线程取消的接口:
- 参数:要取消的线程tid。
- 返回值:取消成功返回0,失败返回错误码。
- 只有运行起来的线程才能被取消。
在主线程中,新线程被创建后,取消一半的线程,然后继续进行线程等待。
10个线程被创建后就会跑起来。
- 前五个线程被取消了,线程等待直接成功,不用再阻塞,被取消的线程等待成功后的返回值是-1,并不是我们设定的线程编号。
- 未被取消的后五个线程,仍然阻塞等待,等待成功后返回的是我们设定的线程编号。
所以说,如果一个线程是被取消结束的,它的退出码就是-1。它其实是一个宏定义:PTHREAD_CANCELED。
线程取消也是一种线程结束的方式,放在这里是为了能够通过线程等待看线程退出的退出码。
🎴线程分离
线程的tid也可以通过接口获得,就像获得pid一样,获取tid的接口:
这个接口也是POSIX线程库提供的,哪个线程调用该接口就会返回哪个线程的tid。
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
但是这样主线程就需要阻塞式等待线程的释放,主线程什么都干不了。能不能像进程那样不需要阻塞式等待(将SIGCHID信号设置为忽略),等新线程结束以后自动释放呢?
- 尤其是不需要关心线程返回值的时候,join是一种负担。
当然可以,将需要自动释放的线程设置成分离状态,将线程设置成分离状态意味着不需要主线程再关心该线程的状态,它会自动释放。
- joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
线程分离的接口:
- 参数:要分离的线程tid。
- 返回值:成功返回0,不成功返回错误码。
- 可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。自己分离自己就需要使用接口获取到自己的tid。
线程分离后,如果主线程仍然等待该线程,就会等待失败,返回错误码。
新线程中分离自己:
void*start_routine(void* args){
string name =static_cast<constchar*>(args);
size_t cnt =5;pthread_detach(pthread_self());//线程分离while(cnt--){
cout<<"new thread name: "<<name<<", cnt: "<<cnt<<endl;sleep(1);}pthread_exit(nullptr);}intmain(){//创建新线程
pthread_t tid;pthread_create(&tid,nullptr,start_routine,(void*)"thread one");
cout<<"main thread tid: 0x"<<(void*)pthread_self()<<endl;//线程等待int n =pthread_join(tid,nullptr);
cout<<"error: "<<n<<"->"<<strerror(n)<<endl;return0;}
在新线程中分离线程。
不是说线程分离了再进行线程等待就会失败吗?怎么上面的运行结果仍然是等待成功呢?
- 因为主线程先被调度,在新线程被创建但是没有执行的时候主线程就开始等待新线程了。
所以当新线程将自己分离以后,主线程已经处于等待状态了,它不认为新线程被分离,还会继续等待,而且可以等待成功。
可以让主线程延时一段时间,保证新线程先执行,也就是保证线程分离发生在线程等待之前。
可以看到,此时主线程在进行线程等待的时候就会失败,而且返回错误码。
在主线程中分离新线程:
最为稳妥的办法就是在主线程中分离新线程:
在主线程中分离新线程,任何进行线程等待,并且主线程在一直运行。
- 主线程等待新线程失败后直接返回错误码,然后接着向下运行,并不会阻塞。
在新线程运行结束以后,自动回收其PCB资源,只剩下主线程在运行。
- 一个线程一旦被分离就不用再管这个线程了,在它运行结束的时候系统会自动回收,不会造成内存泄漏。
🧰C++多线程
我们知道,C++也是可以多线程编程的,而且提供了多线程的库,而无论什么编程语言,什么库,在Linux系统上的多线程本质上都是对pthread原生线程库的封装。
接下面本喵就模拟一下C++对线程库的封装,写一个小组件,同时也方便我们后面直接使用:
#defineNUM1024classThread;//前置声明classContext//线程上下文{public:Context():_this(nullptr),_args(nullptr){}//成员变量
Thread* _this;void* _args;};classThread{public://重命名函数对象typedef std::function<void*(void*)> func_t;//构造函数//传入新线程执行的函数,参数,以及新线程编号Thread(func_t func,void* args =nullptr,int number =0):_func(func),_args(args){//格式化线程名char buffer[NUM];snprintf(buffer,sizeof(buffer),"thread-%d",number);
_name = buffer;//创建线程
Context* ctx =newContext();
ctx->_this =this;
ctx->_args = _args;int n =pthread_create(&_tid,nullptr,start_routine,ctx);}void*run(void* args){return_func(args);}//由于调用成员函数有隐藏的this指针,所以使用static修饰//void* start_routine(this, void* args)staticvoid*start_routine(void* args){
Context* ctx =static_cast<Context*>(args);//安全的类型转换void* ret = ctx->_this->run(ctx->_args);delete ctx;return ret;}//线程等待voidjoin(){int n =pthread_join(_tid,nullptr);assert(n==0);(void)n;}private:
std::string _name;
func_t _func;void* _args;
pthread_t _tid;};
- 在调用start_routine成员函数的时候,会隐藏一个this指针。
- 而pthread_create中的函数指针只有一个形参,为了消除这个指针,用static修饰新线程调用的函数。
此时就面临一个新的问题,在static函数内,需要调用类内的成员函数run(),但是没有this指针无法调用。
- 创建一个上下文类,里面放线程类的this指针,在static函数内通过这个指针来调用类内的成员函数run()。
测试代码:
void*thread_run(void* args){
string work_type=static_cast<constchar*>(args);while(1){
cout<<"新线程:"<<work_type<<endl;sleep(1);}}intmain(){
unique_ptr<Thread>thread1(newThread(thread_run,(void*)"thread1",1));
unique_ptr<Thread>thread2(newThread(thread_run,(void*)"thread2",2));
unique_ptr<Thread>thread3(newThread(thread_run,(void*)"thread3",3));
thread1->join();
thread2->join();
thread3->join();return0;}
可以看到,成功创建3个新线程,并且在不停运行。
这里仅是语言层面对线程库的封装。
🧰线程库中的TCB
🎴线程tid
前面多次见过线程的tid值,但是一直不知道它是什么,现在来揭开它的神秘面纱。
新线程和主线成都打印新线程的tid,并且主线程也打印自己的tid。
- 主线程和新线程打印的新线程tid的值都是一样的。
- 而且tid的值是一个地址。
我们知道,Linux内核中是没有线程概念的,也没有对应的TCB结构。
- 用户创建线程时使用的是POSIX线程库提供的接口。
- 线程库中会调用clone()系统调用接口,在内核中创建线程复用的PCB结构。
- 这些轻量级进程共用一个进程地址空间。
系统中肯定不只一个线程存在,大量的线程势必要管理起来,管理的方式同样是先描述再组织。既然Linux内核中只有轻量级进程的PCB,那么描述线程的TCB结构就只能存在于线程库中。
所以pthread线程库中就会维护很多TCB结构:
//伪代码structpthread{//线程局部存储//线程栈//....}
线程库中的TCB里,存放着线程的属性,这里的TCB被叫做用户级线程。
- Linux线程方案:用户级线程以及用户关心的线程属性在线程库中,内核提供线程执行流的调度。
- Linux 用户级线程 : 内核轻量级进程= 1 :1
一个线程的所有属性描述是由两部组成的,一部分就是在pthread线程库中的用户级线程,另一部分就是Linux中的轻量级进程,它们俩的比例大约是1比1。
- pthread线程库从磁盘上加载到内存中后,通过页表再将虚拟地址空间和物理地址映射起来。
- 线程库最终是映射在虚拟地址空间中的共享区中的mmap区域。
既然线程库是映射在共享区的,那么线程库所维护的TCB结构也就一定在共享区。
如上图所示,将映射到共享区的动态线程库放大。
- 线程库中存在多个TCB结构来描述线程。
- 每个TCB的地址就是线程id。
线程tid的本质就是虚拟地址共享区中TCB结构体的地址。
- 线程的栈也在共享区中,而不在栈中。
- 虚拟地址空间中的栈是主线程的栈,共享区中动态库中的栈是新线程的栈。
**所以说,线程的栈结构是相互独立的,因为存在于不同的TCB中(主线程除外)**。
🎴线程局部存储(__thread)
在共享区线程库中的TCB里,有一个线程的局部存储属性,它是一个介于全局变量和局部变量之间线程特有的属性。
在主线程和新现在中同时打印全局变量g_val以及它的地址。
主线程和新线程打印的值都是一样的。
- 说明主线程和新线程共用一个全局变量。
那如果此时新线程仍然想用这个变量名,但是又不想影响其他线程,也就是让这个全局变量独立出来,该怎么办呢?此时就可以使用线程的局部存储属性了。
- 在全局变量g_val前面加__thread(两个下划线),此时这个全局变量就具有了局部存储的属性。
主线程和新线程同样打印这个全局变量,并且新线程将这个具有局部存储属性的全局变量不断加一。
- 主线程和新线程打印出来的全局变量的地址不相同了,说明此时用的并不是同一个全局变量。
- 新线程修改这个值,主线程不受影响。
- 可以将全局变量或者static变量添加 __thread,设置位线程局部存储。
- 此时每个线程的TCB中都会有一份该变量,相互独立,并不会互相影响。
🧰总结
有了进程的基础,线程有些地方可以进行类比,还是比较容易理解的。线程控制非常重要,而且在编程中经常使用到。
版权归原作者 一只大喵咪1201 所有, 如有侵权,请联系我们删除。