0


【Linux学习】多线程——线程控制 | 线程TCB

🐱作者:一只大喵咪1201
🐱专栏:《Linux学习》
🔥格言:你只管努力,剩下的交给时间!
图

线程控制 | 线程TCB

🧰线程控制

Linux内核中并不存在线程的概念,我们程序员是通过库来使用线程的,这个库是POSIX线程库,是由原生线程库提供的,它遵守POSIX标准,就像之前学过的System V标准一样。POSIX线程库有以下几个特点:

  1. 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的。
  2. 要使用这些函数库,要通过引入头文<pthread.h>。
  3. 链接这些线程函数库时要使用编译器命令的“-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个线程就都能正常运行了,不存在缓冲区的覆盖问题了,因为一个线程有一个缓冲区。

🎴线程结束

  1. return nullptr结束线程

图
当新线程执行到return的时候,就会结束。

  • 在线程中加了计数值,5秒后跳出循环,执行return,结束线程。

图
当计数值到了以后,新线程全部结束,只剩下主线程在执行。

  1. 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;};

tu

  • 在调用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;}

tu
可以看到,成功创建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中都会有一份该变量,相互独立,并不会互相影响。

🧰总结

有了进程的基础,线程有些地方可以进行类比,还是比较容易理解的。线程控制非常重要,而且在编程中经常使用到。

标签: linux 学习

本文转载自: https://blog.csdn.net/weixin_63726869/article/details/130484538
版权归原作者 一只大喵咪1201 所有, 如有侵权,请联系我们删除。

“【Linux学习】多线程——线程控制 | 线程TCB”的评论:

还没有评论