本节内容,我们将详细讲解Linux线程的有关知识,并为同学们铺垫多线程的有关知识。
一、线程的概念和理解
理解线程之前,我们需要重新对进程进行理解
我们前面说一个task_struct有着一个进程地址空间,然后有页表,搭建其和物理内存的映射。
我们现在需要重新对进程进行定位理解:
(1)对于CPU来说,它在运行一个进程的时候,其看到的PCB可以是多个,然后共享一份地址空间。(说是PCB其实不够准确,应该叫TCB,但是和PCB非常相似,这里为了引出线程,暂时可以当PCB这样理解)
注意,这里你没有看错,就是多个PCB共享同一块地址空间。允许这样的情况存在。
如上图。
(2)我要提出问题了,那如果这样的话,进程到底是啥呢?
我们之前说进程是一系列的代码和数据以及组织它的数据结构,并且这主要的数据结构就是PCB。为什么会有多个PCB?
那现在呢?这个说法难道要被推翻了吗?
实际上,我们不难发现,我们在进程创建的时候,会在同时创建一大批的系统资源:比如PCB、进程地址空间、页表等。而当存在这样一种情况:多个PCB共享同一块进程地址空间的时候,就会有如上图中三个进程控制块同时指向code区域中的三份不同的代码的情形。我们是允许这样的情况出现的。
希望上述的例子你能够接受。可以不用理解,接受即可。
(3)那我们现在,对线程再做一下定义,线程到底算是什么呢?
我们说,进程是承担分配系统资源的基本实体。
那么线程,是系统调度的基本单位。(线程是在进程的地址空间内运行的)
我们之前说进程是系统调度的基本单位,实际上是不够准确的。
应该是线程。线程是进程的执行流!!!
上面的每一个PCB,都可以认为是一个线程(说法不准确,仅为了在此引出线程、方便理解,阶段性认知错误是必要的哈哈哈)。
所以,一个大的进程内部,是可以有多个线程的。
我们的每一个执行流并不是一个个进程。而是一个个线程。
而我们之前所学的,都是一个进程有一个线程的。即单线程的简单的执行流。
ps:还需要注意的是,在Linux中,是没有真正意义上的线程(注意,这里指的是Linux没有为其专门开辟新的数据结构)。而线程是用进程来模拟的。
为什么呢?线程和进程实际上是拥有许多相同属性的。如果我们再为线程专门去开辟对应的结构,那么调度关系就会变得相对复杂,而复杂的关系一般不会太高效。(不过Windows还是有专门的线程库的)
我们以后该怎么面对PCB呢?
我们依旧讲PCB叫做进程控制块。
不过,如果在一个进程中,如果只有一个PCB,那么该进程就是拥有单线程的进程。
如果有多个PCB,那么其就是拥有多线程的进程。
而所有的PCB/线程,都可以叫做轻量级进程。
结合上面的,我们来总结一下何为线程:
在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”,即更准确地来说,其是在一个进程地址空间内运行的执行流。
所以说,一切进程至少都有一个执行线程。而线程在进程内部运行,本质是在进程地址空间内运行。
在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
线程的优点
1、创建一个新线程的代价要比创建一个新进程小得多
2、与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
3、线程占用的资源要比进程少很多
4、能充分利用多处理器的可并行数量
5、在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
6、计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
7、I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
注意:上述进程的优点的后四点也是进程的优点。
虽然Linux操作系统没有为我们提供多线程的库,但是呢,作为用户,我们又要用到多线程,所以,我们就用基于LInux系统开发的第三方库——pthread库
1)创建线程接口:pthread_creat
注意,编译链接的时候要加上 -pthread(如下图)
a、这里的第一个参数为线程id(就是我们后面要说的lwp),这个参数OS实际上并不知道,其是基于用户层面上开发的。但是呢,在运行到内核的时候,我们的OS的PCB会与这里的lwp存在着某种对应关系。
就是说,线程的管理和组织不再由OS来管控,而是由用户层的库来管控。我们“先描述、再组织”的方式是放在库里面的了。(稍后我们再来看库里面有什么东西)
b、第二个参数表示线程属性,我们暂且不关心。
c、第三个参数表示线程执行流,说白了,就是去调用这个函数。通过这个函数来去完成新线程的执行流。
d、第四个参数表示第三个函数的参数。
其返回值成功就为0,若出错,就返回相应的错误码。
我们来看下面的一个例子:
原码:
1 #include 2 #include 3 #include 4 5 6 void* thread_run(void* arg) 7 { 8 while(1) 9 { 10 printf("I am %s\n",(char*)arg); 11 sleep(1); 12 } 13 } 14 15 int main() 16 { 17 pthread_t rid; 18 pthread_create(&rid, NULL , thread_run , (void*)"thread 1"); 19 20 while(1) 21 { 22 printf("I am main thread\n"); 23 sleep(2); 24 } 25 26 return 0; 27 }
运行截图:
可以很容易地发现,我们这里是有两个执行流的。
我们可以通过ldd,来查看我们的这个可执行文件链接的动态库。
如上,我们可以看到,其链接的是一个pthread的动态库。 (与我们刚刚在编译的时候的-phread相对应)
那怎么证明我们的两个执行流(线程)是一个进程里的呢?
我们先来认识一个函数:pthread_self
其作用很简单,其是获得当前线程的id
我们将上面的代码改一下:
那么现在,我们对于线程的理解,可以这样来说明了:
我们前面所说的结构体、控制块都是由OS创建并完成。
但是线程由于Linux操作系统没有为其有专门的线程库,我们又要用
所以就在用户层面, 由用户链接第三方库。这个第三方库叫做pthread库
在这个第三方库中,其会有模拟线程描述的结构体,叫tcb,它和PCB功能类似,它用来描述线程和组织线程,也叫做线程实体。
每一个这样的tcb中里面会有一个用来标识线程唯一性的lwp,它将来会以某种关系和PCB连接起来。
而每一个线程实体和Linux内核中的每一条执行流又是一一对应的关系。
所以说,OS为我们提供的是线程的执行功能,而线程的管理和组织是由用户空间来提供的。
来换一张图更加细致地说明:
左边是我们的进程地址空间,右边是我们链接的动态库
在外面的共享区,会有着pthread维护的结构体,遵循先描述、再组织,每一个结构体当中又会有着tid,线程栈这样的东西。
tid可以认为是该线程控制库在地址空间里的起始地址。
注意,在进程地址空间的栈是主线程栈!其他线程的栈是要在自己的线程栈中的,要不然数据就乱了。
我们解释完毕,再来说说一些其他的基本概念:
线程异常:
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
理由:线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
因为信号处理的基本单位是进程!所以线程的健壮性不强。
但是,合理的使用多线程,能提高CPU密集型程序的执行效率
合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
二、线程TID的内容
我们前面说到,进程是资源分配的基本单位,线程是调度的基本单位。
虽然说线程共享进程数据,但也拥有自己的一部分数据:(即下面的数据是线程自身私有的)
线程ID
一组寄存器(上下文)(重要)
栈(重要)
errno
信号屏蔽字
调度优先级
进程的多个线程共享 同一地址空间,因此Text Segment、Data Segment都是共享的,
如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到。
除此之外,各线程还共享以下进程资源和环境:(即下面的资源是各个线程共享的)
文件描述符表
每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
当前工作目录
用户id和组id
我们接着我们之前的代码,来说说线程退出和等待
1、主线程退出,整个线程退出。
因为进程是承担资源分配的基本实体。当主线程退出的时候,整个进程全部退出。整个进程都不存在了,自然所有的线程就都不会再存在了。
2、线程退出需要等待
pthread_join()
第一个参数是需要等到的线程ID,第二个参数是一个一级指针的接收列表。
什么意思呢?
我们还是按照刚刚的例子来说:(稍微改动一下)
我们通过pthread_join,来将线程tid的返回值拿了回来,然后再将其打印出来。
我们将其返回值打印了出来。
我们来运行看一下:(其确实拿到了返回值10)
如果不去进行等待,就可能会造成类似于僵尸进程的情况,即我们新创建出来的PCB没有被立即回收。
那么结束线程的方式,我们可以再来总结补充一下都有哪些。
1、函数执行完了,线程结束;
2、通过pthread_exit()函数;该函数的用法和进程当中的exit是相同的。
3、pthread_cancel()函数,从外部将该线程杀掉。即直接在别的线程中调用pthread_cancel(tid)即可。有种人在家中坐,锅从天上来的感觉。
与进程不同的是,对于线程,我如果不想要等待,让这个线程自己玩自己的,我们可以让其分离。
用pthread_datch()函数。
来解释一下:
1、默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
2、如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。(注意,这个时候,我们也是不会再拿到退出码的了)
3、分离的时候,就直接pthread_detach(pthread_self());就可以了。
但是需要注意的是,二者还是在一个进程当中,如果有一个出问题了,还是整个进程都会崩掉的。