目录
13.1、通过线程库也保证了每一个线程的栈结构都是独立的,为什么呢?
13.3、既然每个线程的栈是独立的,那线程A可以访问线程B的栈吗?
14、在线程中使用execl等函数进行进程替换会发生什么呢?
1、初识线程
1.我们说CPU调度一个进程,本质是找到进程的task_struct,然后通过task_struct就可以找到并执行进程的代码和数据。我们在之前的进程相关的文章中,认为只有一个task_struct可以管理进程的资源(这里的资源是指其他内核数据结构与进程的代码和数据等等)或者说认为一个进程里只有一个线程task_struct,但事实上并不是这样,通过一定的技术手段,可以将当前进程的资源划分给不同的task_struct,如上图有许多task_struct,它们内部都有一个mm_struct*的指针指向同一个虚拟地址空间,这里每一个task_struct都可以被称作一个线程或者执行流,所以说一个进程中是可以存在多个线程,即task_struct的。
2.Linux下的PCB<=其他OS下的PCB,所以Linux下的进程也被称为轻量级进程。什么时候Linux下的PCB<其他OS下的PCB呢?Linux中一个进程可以有很多PCB,每个PCB都占用了进程资源的一部分,而其他OS中一个进程只能有一个PCB,该PCB独占一个进程全部资源,所以Linux中当一个进程有多个PCB或者说多个线程时,Linux下的PCB<其他OS下的PCB。什么时候Linux下的PCB=其他OS下的PCB呢?当Linux中一个进程只有一个task_struct,即线程或者说执行流时Linux下的PCB=其他OS下的PCB。
3.线程也被称为轻量级进程。
4.线程是在进程的地址空间中运行的。
5.和【进程中父进程和子进程谁先运行不确定】一样,主线程和新线程谁先运行也是不确定的。
6.CPU调度进程时,在CPU的视角,它只认task_struct,不关心该task_struct是否和其他task_struct共享某些资源,调度某个进程时,就去找该进程的task_struct,找到了就执行它的代码和数据。
7.读了上文可以发现:Linux中没有真正意义上的线程结构,因为Linux没有为线程单独设计内核数据结构,而是用task_struct模拟线程。
8.线程也是需要线程切换的,比如时间片到了或者满足其他条件时就会进行线程切换。
2、如何创建线程呢?
1.函数用于创建一个线程,第一个参数为线程ID,是个输出型参数。第二个参数是线程属性,不需要管,传入nullptr即可。第三个参数是函数指针,指向线程创建完毕后,需要线程调用的函数,这里我们把该函数称之为回调函数,函数调用完毕后线程就退出了。第四个参数用于作为实参传给第三个参数(即函数指针)指向的回调函数的形参,如下图画红线处就是把第四个参数传给第三个参数指向的函数的形参。返回值:成功则返回0,失败返回错误码errno。
2.如果程序中使用了该函数,用gcc或者g++编译时一定要带选项 -pthread,如下图。因为Linux没有为线程设计内核数据结构,而是用进程模拟线程,所以无法提供线程相关的接口,只能提供轻量级进程的接口,所以用进程实现了一套用户层线程的方案,以库的形式提供给用户使用,phread就是这个线程库。
3、如何证明线程是在进程中运行的呢?
代码如下
运行结果如下
如上图,运行结果中,不管是主线程还是新线程,它们打印出的PID,即进程ID都是24158,也就证明了线程是在进程中运行的。
4、线程如何看待进程内部的资源呢?
进程会向OS申请资源,而线程会找进程索要资源。如果用kill杀死进程,如下图,进程退出后,进程中所有的线程也就不复存在了。因为进程退出后,OS会释放进程所占的资源。注意杀死主线程后其他线程也会终止,因为本质上杀死进程就是杀死主线程。
ps命令用于查看当前有哪些进程正在运行,带上-L选项后可以查看轻量级进程。LWP表示light weight process(轻量级进程),即线程ID。OS调度线程时就是通过LWP而不是PID识别各个线程的。可以看到只有第一行的PID和LWP一样,说明该执行流是主线程,而其他执行流是新线程。
可以看到,进程退出后,再次查找mythread就找不到了,即所有线程都不存在了。
5、进程中有哪些资源是线程共享的呢?
进程的多个线程共享同一地址空间,因此Text Segment即代码段、Data Segment即数据段都是共享的,如果定义一个函数,那么在各线程中都可以调用,如果定义一个全局变量,那么在各线程中都可以访问到,所以各线程会共享以下的进程资源和环境:
1.函数。
2.全局变量。但也有办法让各自线程的全局变量独立,如下图增加修饰符__thread,注意thread前面有两个下划线,添加修饰符后,主线程的g_val和新线程的g_val就不会互相影响了。
3.同一进程不同线程在堆区上的数据是共享的。比如在新线程的回调函数中开辟一块空间,填入数据后可以通过返回值把这块空间的地址交给主线程(如果不返回,主线程则不知道地址,则不知道该访问哪了)。
4.文件描述符表是所有线程共享的,比如一个线程打开了一个文件,其他线程是可以访问该文件的。注意不要和多进程模式下混淆了,虽然多进程下父进程打开了一个文件,其他子进程也是可以访问该文件的,但多进程模式下每个进程访问该文件时并不是从同一个文件描述符表中访问的,每个进程都有一个文件描述符表;而多线程下,所有线程在访问文件时是从同一个文件描述符表中访问的,比如一个线程打开了一个文件,假如占用了文件描述符表中下标为3的位置,那么另一个线程再次打开一个文件时,就只能占用了文件描述符表中下标为4的位置了,而这是因为所有线程才共同组成了一个进程,OS只会给每个进程分配一个文件描述符表。
5.每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)是共享的。
6.当前工作目录是共享的。
7.用户id和组id是共享的。
6、进程中有哪些资源是线程不能共享的呢?
进程是资源分配的基本单位,线程是调度的基本单位。线程共享进程数据,但也拥有独属于自己的一部分数据,如下:
1.线程ID。
2.(重点)一组寄存器,用于保存线程的上下文信息,每一个线程的上下文信息都不一样。
3.(重点)栈,用于保存线程中创建的数据,也就是局部变量。
4.errno。
5.信号屏蔽字。
6.调度优先级。
7、为什么说相比于进程,线程切换的时候成本更低呢?
1.(非主要)原因一:CPU执行一个进程的代码时,CPU会将地址空间、页表和task_struct的某些信息加载进寄存器中,当然还有许多其他信息也会load进CPU的寄存器里。相比于切换进程,切换线程时不需要改变CPU寄存器中的值,所以成本更低。
2.(主要)原因二:首先CPU中是存在缓存这一硬件的,缓存分为一级到三级。为什么需要缓存呢?如果CPU中没有缓存,那么意味着CPU每次读取代码都必须得和内存进行IO,而外设之间进行IO是很慢的,所以这样做一定会造成整机效率低下。而如果CPU中加入了缓存这一硬件后,这时CPU向内存中读取某行代码时,因为局部性原理,即执行某行代码时,这行代码周围的其他代码也非常有可能会被执行,所以OS会把这行代码附近的其他代码都一次性加载进缓存,这样下次CPU读取指令时就有很大概率不需要和内存进行IO,CPU直接向CPU内部的缓存读取即可。注意CPU不是一定不用和内存进行IO,而是大概率不用,因为不能保证CPU下一次需要读取的代码一定被预加载进缓存中了。所以相比于进程,线程切换的成本更低的原因是:进程每次切换时,CPU内部的缓存都会被清空,每次新进程过来时都需要重新进行缓存,这个缓存的量是不小的,所以成本就高了,而线程是不需要重新进行缓存的,所以成本低。
8、线程的优缺点
线程的优点
1.创建一个新线程的代价要比创建一个新进程小得多。
2.与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
3.线程占用的资源要比进程少很多。
4.能充分利用多处理器的可并行数量。
5.在等待慢速I / O操作结束的同时,程序可执行其他的计算任务。
6.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
7.I / O密集型应用,为了提高性能,将I / O操作重叠。线程可以同时等待不同的I / O操作。
线程的缺点
1.性能损失。一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
2.健壮性降低,换句话说线程之间是缺乏保护的。编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,比如说在一个线程中修改了某个变量,那么另一个线程使用该变量时可能就会受到影响。
3.缺乏访问控制。进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
4.编程难度提高。编写与调试一个多线程程序比单线程程序困难得多,因为假如说线程A出现了问题时,不一定真的是因为线程A中的代码有问题,而是因为线程B或者线程C等造成线程A出现问题。
9、线程异常
10、线程也需要解决内存泄漏的问题(线程等待)
线程创建并执行时,主线程也需要等待新线程,如果主线程不等待,就会造成类似于(注意这里说的是类似,两者并不等于)僵尸进程的问题,导致内存泄漏。
1.函数用于等待新线程,还可以获取新线程调用的回调函数的返回值。在主线程中使用该函数时,主线程会阻塞式地等待新线程退出,等新线程退出后,主线程才会继续运行直至结束。
2.这里额外补充一点:指针就是地址,是一个字面常量。指针和指针变量是不同的,指针变量是一个变量,而指针和地址完全等同,是个常量。
3.第一个形参为线程ID,第二个形参是一个输出型参数,用来接收新线程调用的回调函数的返回值,如下图ret就是用于接收threadRoutinue函数的返回值,即(void*)10的。为什么第二个形参的类型是void**呢?我们可以倒着推理一下,首先用于创建线程的函数即thread_create的第三个形参,即函数指针指向的回调函数的返回值是void*类型的值,下图中用于接收返回值设置的变量ret也是一个void*类型的值,如果想要通过调用pthread_join改变ret的值,就必须把ret的地址传进pthread_join函数中,那么传进pthread_join函数的实参的类型就是void**,所以pthread_join函数的第二个形参的类型是void**。如果不需要接收返回值,直接将第二个参数retval设置为nullptr即可。
代码如下
运行结果如下
上图最后打印出10,说明ret接收到了回调函数的返回值。
11、线程分离
讲进程时,我们说有办法让【父进程不等待子进程,子进程退出变成僵尸进程后被自动释放】这一方案实现。那么在线程中,有什么方法让主线程不用等待新线程,新线程结束后也被自动释放吗?
答案:有的,通过线程分离可以实现这个方案,并且在线程分离后,主线程不需要也不能通过函数pthread_join等待新线程了。用于线程分离的函数为int pthread_detach(pthread_t tid),成功时返回0,失败时返回错误码。注意即使新线程分离后,新线程如果出现除0等错误导致异常,所有的线程还是都会终止,所以这个分离并没有那么强大。 注意这个detach函数最好在线程创建好之后就立即调用,否则线程可能会因为某些原因,在后续调用detach
函数分离线程之前被销毁掉。
问题:那如果我进行线程分离后还非要用pthread_join函数等待新线程,会发生什么呢?
答案:会发生错误,pthread_join函数会调用失败,并返回错误码,如下图的【错误示范】。
1.错误示范
代码如下
运行结果如下
2.正确示范
代码如下
运行结果如下
等待成功,pthread_join函数返回0。再次验证了上文结论:线程分离后,新线程终止时会被自动释放,主线程不需要也不能通过函数pthread_join等待新线程了。
额外补充:
1.linux线程执行和windows不同,pthread有两种状态,为joinable状态和unjoinable状态。如果线程是joinable状态,当线程函数自己返回退出时或pthread_exit时都不会释放线程所占用堆栈和线程描述符(总计8K多)。只有当你调用了pthread_join之后这些资源才会被释放。如果是unjoinable状态的线程,这些资源在线程函数退出时或pthread_exit时自动会被释放。
2.unjoinable属性可以在pthread_create时指定,也可以在新线程中使用函数pthread_detach自己,比如pthread_detach(pthread_self()),表示将状态改为unjoinable状态,确保资源的释放。或者将新线程置为joinable,然后在主线程适时调用pthread_join等待新线程。
3.一句话总结:线程创建的时候默认处于joinable状态,此状态线程结束的时候不会自动回收线程资源,需要pthread_join函数来回收;pthread_detach可以将线程转换为detached状态,子线程运行完成之后可以自行回收资源。
12、如何终止线程呢?
注意不要使用exit函数终止线程,因为exit是用来终止进程的,如果在线程中使用exit函数,那么进程就会终止进程,从而导致终止进程中的所有线程。
12.1、方法一
void pthread_exit(void *retval)
函数用于终止该函数所在线程。参数为线程函数的返回值,新线程使用pthread_exit函数后,主线程可以通过函数pthread_join获取到retavl的值。头文件为pthread.h。
12.2、方法二
![ba2722cd061448c9bf7f5eae955ee2e9.png](https://img-blog.csdnimg.cn/ba2722cd061448c9bf7f5eae955ee2e9.png)
![ed573177c16546398ec3730425a10d48.png](https://img-blog.csdnimg.cn/ed573177c16546398ec3730425a10d48.png)
12.3、方法三
13、线程ID(LWP和pthread_t类型的变量)
注意下文说的线程库都是下图中的头文件<pthread.h>。
线程ID有两种,一种是内核级的,称为LWP,如下图,可以使用ps -L查看线程的LWP,即线程ID。
因为Linux没有为线程单独实现内核数据结构,而是用进程的内核数据结构模拟线程,但光靠进程模拟肯定是做不到等同于线程的,所以在用户层设计了pthread线程库,库会为线程补充很多属性字段,其中就包括了用户级的线程ID,类型名为pthread_t,如下图红框处, 是长整形的别名,还有更多细节请看下文。
代码如下
运行结果如下
如上图线程ID的值是140699347699456,为什么线程ID是个这么大的值呢?本质上用户级的线程ID,即pthread_t这种长整形的变量保存的值都用于表示一个地址,地址指向哪里呢?这个地址是【在线程库中实现的用于补充线程属性的结构体】的结构体变量的首地址,如下图。
13.1、通过线程库也保证了每一个线程的栈结构都是独立的,为什么呢?
不同进程的地址空间都不同,所以可以做到各自进程的栈结构独立。但线程是共用同一块地址空间,并且Linux的OS内部是没有线程这一概念的,也就并没有为线程设计一个【可以让不同线程的栈结构独立】的方案,所以这个栈一定不是OS提供给线程的,所以这个方案一定是在用户层实现的,方案就在线程库中,即这个栈是线程库为线程提供的,如下图的结构体成员中就包含了线程栈。
所以读了上文可以发现:每一个线程都需要一个上图中的struct_pthread结构体变量用于将线程的属性补充完整,所以每创建一个线程后都还需要创建一个struct_pthread结构体变量,而用于创建线程的函数和struct_pthread结构体的声明都在线程库中,所以创建线程时可以很便利的找到并创建struct_pthread结构体的变量。而用户级的线程ID,也就是pthread_t的变量的值表示的地址就是这个struct_pthread结构体的变量的首地址。所以再次回看下图2红框中的两个值,就可以看出左边的值表示struct_pthread结构体的变量的首地址,右边的值表示pthread_t的变量的地址(即下图1中tid的地址),这个地址一定是在struct_pthread结构体变量提供的栈空间中,因为该栈在结构体中,结构体又在线程库中,线程库又在内存的共享区即堆栈之间的区域,所以这个结构体中的栈是在上图中堆栈之间开辟的空间。
代码如下
运行结果如下
13.2、那么进程中只有一个线程时,线程该使用哪个栈呢?
问题:上文中也说过,线程库中给线程提供了用户层的栈空间,而在系统层面上,地址空间上也有一个栈空间,当进程中只有一个线程时,线程该如何选择呢?
答案:主线程使用OS提供的地址空间上的栈空间,而新线程使用线程库提供的栈空间。当只有一个线程时,该线程就是主线程,所以该线程会使用地址空间上的栈空间。
13.3、既然每个线程的栈是独立的,那线程A可以访问线程B的栈吗?
答案是可以的,因为不同线程的栈虽然是独立的,但所有线程的栈都在地址空间的共享区上,该区域是所有线程都可以访问的,所有只要线程A想,是有办法访问线程B的栈的。如何访问呢?首先全局变量是所有线程共享的,只要把线程B【位于进程的地址空间的共享区上】的栈的某个数据的地址给一个全局变量,线程A就可以通过全局变量修改线程B的栈上的数据,即访问了线程B的栈。但一般不建议这样做。
13.4、如何获取线程ID呢?
给创建线程的函数传入输出型参数可以获取用户级的线程ID,但除此之外还有其他方法获取线程ID吗?
答案是有的,可以通过下图函数,哪个线程调用的pthread_self()函数,就获取哪个线程的线程ID。
14、在线程中使用execl等函数进行进程替换会发生什么呢?
进程替换只是单纯的把新程序的代码和数据加载进内存,然后将进程替换之前就已经存在的页表的映射关系修改,task_struct和mm_struct不会发生改变。也就是说进程替换后,进程中的所有线程都找不到进程替换前的代码和数据了,所以进程替换前的进程中的所有的线程已经毫无意义,等到进程替换后,CPU就会执行新进程的代码。
15、在线程中可以使用fork等函数创建子进程吗?
答案是可以的,当进程中有多个线程时,OS会将父进程的主线程的PCB数据拷贝到子进程的主线程的PCB中。