目录
一、进程地址空间和页表再理解
二、线程
1.线程的概念
2. 进程与线程
3. 线程的意义
4.线程的优点缺点
4.1 优点
4.2 缺点
4.linux中线程的优缺点
4.1 优点
4.2 缺点
5. linux中线程创建相关接口
5.1 线程创建
6. 通过代码查看进程与线程的关系
6.1 线程库
6.2 信号与线程
6.3 主线程与新线程
6.4 pthread_create()函数的第四个参数arg
6.5 pthread_create()函数的第一个参数pthread
7. 线程共享进程资源
7.1 线程之间的大部分共享资源
7.2 线程的独有资源
在了解线程之前,先来对进程地址空间和页表进一步理解。
首先,我们要对进程地址空间和页表有一个认识,那就是进程地址空间是“进程能看到的资源窗口”,而页表则是“决定进程真正拥有的资源状况”。这两个概念都很好了解,因为我们知道,在32位系统下,每个进程都拥有4GB的虚拟内存,在要读写数据调用资源时,都需要通过页表来找到该进程在物理内存上保存数据的位置。因此,通过合理的对地址空间+页表进行划分,就可以对一个进程的所有资源进行分类。
而为了方便管理,页表中除了物理地址的映射,其实还包含了很多其他属性,例如是否命中,对应数据的RWX权限,U/K权限等内容
那么大家有没有想过,虽然一直都在说进程的数据需要通过虚拟地址+页表映射来找到它物理内存上的位置,但是,这个页表是映射这么多虚拟地址的呢?要知道,一个32位系统下的虚拟地址就有2^32,约42亿个。如果这个页表是一个虚拟地址映射一个物理地址,假设一个地址占4字节,它对于每个地址的属性占1字节,再加上2的内存对齐,就意味着页表中保存一个地址映射的信息就需要6字节。如果采用一个虚拟地址映射一个物理地址的方法,一个进程的页表仅仅只是保存这些地址映射,就需要24GB。很明显是不现实的。
同时我们要知道,物理内存上的空间其实也是经过划分了的。在物理内存中,所有的空间都被划分为了一个个小数据块,这些小数据块一般被叫做“页框”,一般是4KB大小。当然,这些数据块也是需要被管理起来的,所以每个小数据块都有一个保存了它的相关属性的结构体,被叫做“页”。而为了便于管理这些结构体,就将这些结构体放到了一个数组中。
大家应该也知道,在磁盘中,磁盘的空间其实也是按照4KB大小进行了划分的。而我们在磁盘上的数据就被保存在这一个个4KB大小的数据块中,这些数据块被叫做“页帧”。因此,当磁盘与内存进行交互时,就是以4KB为单位进行交互的,与内存中的数据块划分一致。
当然,内存中的这些数据块要被管理起来,也是需要有配套的管理算法的,一般linux中的管理算法叫做“伙伴系统”,这里就不再做介绍,有兴趣的话大家可以自行了解。
再回到页表映射的问题上来。页表映射其实是采用的“10 10 12”的映射方案。我们知道,一个虚拟地址是32bit位,这里的“10 10 12”指的就是将这32个bit位划分为“10”,“10”,“12”三个部分。而页表其实也并不是只有一张页表。首先,页表中的一张页表,就是“页目录”。这个页目录中就保存了地址的前10个bit位,即2^10个地址。而2^10次方,算下来也就1KB左右,再加上其他的各种属性,可能也就10KB左右的空间。
有了页目录后,第二层就是“页表项”。每个页目录中的每个值都有一个对应的页表项,这个页表项中也保存了10个bit位,这10个bit位其实就是虚拟地址中的第10 ~19个bit 位。而页表项中保存的内容就是“指定页框的物理起始地址”。到了这里,虚拟地址就还剩下后12位,而这后12位,就是页内偏移量,即这后12位+页表项中保存的页框的起始物理地址,就是进程中一个数据所对应的物理地址。
而一个页框的大小是4KB,它的偏移量最大就是2*12次方,这也就是为什么要以虚拟地址的后12位作为偏移量。通过这种映射和页内偏移的方式寻址,就能讲页表的大小压缩的很小了。
在了解线程之前,我们先来回顾一下进程。大家知道,一个程序运行起来,就会在内存中生成一个进程,而为了管理这个进程,就会有进程PCB,这个PCB里面有一个指针,指向的就是该进程所拥有的虚拟地址空间。进程在运行时所需要的资源都会通过这个虚拟地址空间中保存的地址,通过页表+mmu映射到物理内存中,页表用于维护虚拟地址,mmu用于将虚拟地址转化为物理地址,转化方案就是上面的第一节的内容。
在以前,我们对进程的理解就是“进程 = 内核数据结构 + 进程对应的代码和数据”,而一个进程就是内存中的一个执行流。而虚拟地址空间,就可以看做是一个进程所能看到的“资源”,换句话说,虚拟内存中的内容就决定了进程能够看到的“资源”,例如它里面的堆区、未初始化数据区、已初始化数据区等。
那么这些“资源”能否被划分为一个个小块呢?答案是可以的。例如在父进程中创建一个子进程,通过if判断的方式让子进程执行其他代码,这其实就是将父进程中的资源划分出了一个小块交给子进程去执行。但是,通过这种方式创建的进程还是需要创建属于它自己的结构体,虚拟地址空间、页表等内容。
既然如此,按照子进程的思想,我们就可以将父进程的这块虚拟地址空间划分为一个个的小块,然后只创建PCB,让这些PCB都指向父进程的虚拟地址空间中的一部分资源。这就意味着这些PCB和父进程共享同一块地址空间,进行映射时也是通过同一个页表映射。
这种“只创建PCB”,让进程给它分配资源的执行流,就叫做“线程”。所以,线程其实就是进程中的一个执行流。这就和在父进程中创建一个子进程,让这个子进程去执行指定的代码块有点像。只不过线程并不需要像子进程那样拷贝一份地址空间和页表,只需要创建一个PCB即可。
按照上面的说法,那么在一个进程中就可能存在大量的线程。以windows为例,大家可以打开自己电脑上的资源监视器,点到CPU,就会发现,在windows中的进程运行时,就存在大量的线程:
这也就直接印证了上面的说法,即一个进程中可能存在大量的线程。既然在进程中存在大量线程, 这些线程就必定需要被管理起来,那么如何管理呢?很明显,就需要为线程设计专门的数据结构表示线程对象,如TCB。然后在进程中以链表或其他数据结构,将这些线程链接起来。这种解决方案,就是windows所使用的方案。但是,这仅仅只是windows所使用的方案,而每个系统下关于线程的解决方案可能都会有所不痛,其中,linux的线程方案就和windows的方案天差地别。这里我们主要讲的是linux的线程方案,所以就不再过多赘述windows的线程方案了。
诚然,windows的线程方案是可行的,但是这也导致了它的方案实现起来非常的复杂。而我们仔细思考一下,线程作为进程中的一个执行流,它需要去执行进程中的某个代码块,这也就意味着它的PCB中也需要包括id、 状态、 优先级、 上下文、栈等进程PCB都拥有的内容。因此,单从线程调度的角度来看,线程和进程有很多地方都是重叠的。按照这个理论,其实就并不需要给线程单独设计一个数据结构,而是直接复用进程的PCB即可。
到这里,我们就可以对线程有更进一步的理解了,即“线程其实就是在进程内部,即进程的地址空间内运行,拥有进程的一部分资源”。
看到这里,大家可能就会有一个疑问。前文中才说过,“进程 = 内核数据结构 + 进程对应的代码和数据”。而一个进程只有一个内核数据结构,那这里的这些线程又算是什么呢?其实上文中的说法并没有错,但是那只是以前对进程的单个执行流的理解。到现在,我们对进程的认识就应该更新为“进程是承担分配系统资源的实体”。即,进程通过使用系统资源,占用CPU资源来创建的大量PCB,地址空间,页表和物理内存中的对应代码和数据这些加起来的一个整体,就叫做进程。
既然进程是承担分配系统资源的实体,那线程是什么呢?很简单,线程其实就是“CPU调度的基本单位”。大家此时又会有问题,既然线程是CPU调度的基本单位,可是在以前我们写的程序中,CPU在运行时都是以进程为单位调度的,无论是进程的等待、阻塞还是挂起等等操作,都是通过进程来实现的,这一点,大家应该都通过查看进程的pid得到了验证的。其实以前的这一理解并没有什么问题,因为在以前我们所写的程序都是“在一个进程里,只有一个执行流”。
但是从CPU的角度看,其实我们以前加载进CPU的PCB,也是被当做一个线程来看待。因为无论是进程PCB还是线程PCB, 都属于一个执行流,都需要被加载到CPU中运行。而CPU并不关心进来的到底是进程还是线程,它都会一律视为线程看待并运行它。因此,无论是以前交给CPU的一整个进程,还是如今交给CPU的一个线程,在CPU眼里,它都是一个'“线程”。
但是,在linux中,其实是没有真正意义上的“线程”的,因为它并不像windows那样单独为线程设计了数据结构,而是复用的进程PCB,因此,linux中的线程,准确来讲,应该被叫做“轻量级进程”。每一个被加载到CPU中运行的PCB,都被CPU视为轻量级进程。
轻量级进程这个名字很好理解,因为线程作为进程中的一个执行流,它只有一个单独的PCB,没有地址空间和页表,从内容上看,它就比传统的进程要少了很多内容。
进程和线程的区别也很简单,进程是线程内的一个执行分支,进程是用来整体申请资源的,线程则是向进程申请资源。举个例子,假设你现在在公司中的一个开发小组中,当我们的小组需要某种资源时,都是以小组的身份整体申请资源的。每个小组成员为完成小组任务,都会有各自的分工。当需要某种资源时,就会向组内申请,然后再以整个小组的名义,向公司申请资源。在这里,这个小组就是进程,而小组中的每个成员,就是线程。而小组成员完成自己的任务,就是线程执行进程给它分配的代码块。小组向公司申请资源,就是进程中需要某种资源时,进程向OS申请资源。
总结起来,进程与线程就可以看成如下几个方面的关系:
(1)在一个程序中的执行流就叫做线程,即线程是“一个进程内部的控制序列”。
(2)一个进程至少有一个线程。
(3)线程在进程内部运行,本质是在进程地址空间运行。
(4)在linux系统中,在CPU眼里,看到的PCB都要比传统的进程更加轻量化。因为线程只有进程的部分资源。
(5)通过进程地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
线程的存在,其实就是为了能够让一个进程内可以并行的执行多项任务。例如在使用百度云盘时,你可能正在用云盘看一部电影,但与此同时,你又想用它下载你所保存的某些资源。此时,你的需求就是让百度云盘能够一边播放电影,一边下载资源。这一行为其实就是在让该程序并行的执行多项任务。如果一个程序中没有多个执行流,就无法实现这一需求。
而在CPU眼中,此时该程序就生成了两个执行流,一个播放,一个下载。这两个执行流通过时间片轮换的方式高速的在CPU中运行、切换、运行、切换。因此,虽然在CPU眼中,这两个执行流并没有同时运行,但是因为它的切换速度非常快,从我们的肉眼来看,它就是在同时运行。
(1)创建一个线程的代价要比创建一个新进程小得多。因为无需创建新的虚拟地址空间,页表映射等内容。
(2)与进程之间的切换相比,线程之间的切换需要OS做的工作少得多。
这一点不太好理解。我们知道,当CPU切换为进程时,需要进行包括切换PCB、页表、上下文、进程地址空间等操作。但如果是切换线程,则只需要切换PCB和上下文即可。但是切换页表和进程地址空间,本质上只需要切换指针即可,其实性能损耗上很少。那为什么说线程之间切换要比进程切换做的工作少很多呢?
其实是因为在CPU中还有一个非常重要的东西——cache,即高速缓存。
当进程在CPU中运行时,CPU在读取时,并不是直接读取进程的数据,而是让进程预先将数据放到cache中,如果CPU在cache中命中了,就返回;没有命中,则到内存中读取数据。当读到了对应数据后,再将这些数据加载到cache中,CPU再从cache中读取这些数据。当该程序运行一定时间后,cache中就会有大量的“热点数据”,这些数据就是当前进程中调用频率极高的数据。通过在cache中加载热点数据的方式,就可以提高CPU的运行效率。
由此,当在CPU中要进行进程切换时,就需要重新加载新的进程的数据到cache中,并且需要通过一段时间的运行来获取热点数据。而线程之间的切换,就无需切换cache中的数据,因为它们所用的都是同一个进程的资源,所以在切换后,cache中的数据很可能也能为这个线程提供使用。
所以,线程之间切换就无需大量更新cache的数据;而进程之间切换就需要更新cache中的所有数据。要知道,CPU一开始并不知道进程中的哪些数据是热点数据,cache中保存的热点数据都是需要该进程运行一段时间后才能知道的,而在这段时间内,CPU就需要频繁到内存中访问数据,与在CPU内部访问数据的效率相比就很很低了。
(3)线程占用的资源比进程少得多。因为线程使用的仅是进程的部分资源。
(4)能充分利用多处理器的可并行数量。进程也是可以的,但进程就很难使用上cache。这里的充分就包括了线程能更好的使用cache。
(5)在等待慢速I/O操作结束的同时,程序可执行其他计算任务。
(6)计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
计算密集型,简单来讲,就是需要大量进行计算。如CPU、加密、解密、算法等任务。举个例子,假设你有1GB的数据需要计算,就可以通过线程,将这1GB数据分解为2个512GB数据,让两个线程并行式的同时进行计算。这种方式就比让单进程计算效率要高。
(7)I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
I/O密集型,就是需要频繁进行读取写入数据。例如外设、网络等。这里的同时等待不同的I/O操作,就好比你在云盘或者浏览器等软件上同时执行多个下载任务,这些下载任务都是需要高I/O的。
(1)性能损失
线程的数量其实并不是越多越好,因为线程切换也是需要有调度成本的,如果线程过多,调度成本过大, 可能就会导致效率降低。一般来讲,线程的数量最好和CPU的核数是一样的,例如一个单核单CPU的处理器,最好就是只创建一个线程。这里的核指的CPU中的计算器,单核就是只有一个计算器,而多核则是有多个计算器。当然,这也只是理论上,在实际中很少能做到。
(2)健壮性降低
健壮性低,指的就是如果一个线程出现问题,那么它的所有线程都会被结束。因为线程是进程内的一个执行流,如果线程出现问题,就说明这个进程也出现了问题,因此,OS就会直接向整个进程发送信号,结束该进程。
写出以下测试程序,在该程序中, p指针指向空,并且去修改了p指针所指地址的值。此时该线程会出现错误。
运行程序:
此时就可以看到,线程如我们所料那样,出现了错误。并且在出现错误后,整个进程就全部结束了。这也就证实了上面所说的“一个进程内只要有一个线程出现了异常,整个进程就都回结束”。
但是进程之间就不会出现问题。就好比你电脑上的qq崩溃了并不会影响到你打开的其他软件。
(3)缺乏访问控制
线程天然就可以共享进程内的资源。因此,如果我们在一个程序中定义了一个全局变量,那么这个进程内的所有线程都可以看到这个全局变量。如果某个线程修改了这个全局变量,其他线程立刻就可以知道修改后的值。诚然,这种特性降低了线程间交互的成本,但同时也导致如果使用不当,就可能导致一个线程错误的修改了其他线程所需要的数据。
(4)编程难度提高
在linux中,线程方案就是复用的进程的PCB。因此,它非常的高效稳定。举个例子,绝大部分人的手机我相信基本半年一年乃至2,3年都不会关机一次。但是大家有没有想过,为什么我们的手机长时间不关机,却不会非常明显地变卡呢?原因就是现在市面上的手机的底层操作系统绝大部分都是从linux修改而来的。而linux凭借其本身各种实现,使得这一系统非常的高效且稳定,哪怕长时间不关机也很少出现bug。
与此相对的,windows系统就不是用的linux的改版,它的底层实现非常的复杂。这也就导致了windows的维护成本很高,且很容易出现各种内存泄漏和bug。这也是windows的官方会时不时的更新修复补丁和windows系统在长时间开机后就会变卡的原因。
虽然liunx的线程设计非常的巧妙,但是它依然有缺点,那就是在OS和用户(特指程序员这一类人)只认识线程,并不认识轻量级进程。而linux的线程方案导致它并没有真正意义上的线程,只有轻量级进程。因此,在linux中,它只能为我们提供创建轻量级进程的系统接口,而无法直接提供创建线程的系统调用接口,需要通过一个软件层来模拟实现调用。
前文也说过了,在linux中,没有真正意义上的线程,只有轻量级进程。但是,在linux中可以通过一层软件层模拟实现线程调用。在这里,就介绍几个与线程创建相关的函数
pthread_create()函数可以用于创建线程。
第一个参数thread是一个输出型参数,用于接收创建好的线程的线程id。
第二个参数attr是线程属性,可以不管,直接设置为nullptr。
第三个参数,是一个返回值为void*,参数为void*的函数指针,就是用户要让该线程执行的代码内容
第四个参数,arg就是第三个参数的的函数参数。即执行代码内容时,会将arg传递给函数,void*就是void*arg
在上文中说了,线程是CPU调度的基本单位,我们以前传给CPU的一整个进程,其实在CPU眼中,也是一个进程,当然,在linux中,这个线程应该叫做“轻量级进程”。
为了证明这一点,我们就可以通过实际创建线程,然后将线程与以前的进程相对比。
写出如上代码后,在linux中先用g++命令生成该程序:
此时就会出现如上图中的报错,告诉我们找不到pthread_create()函数。原因很简单,上文中说了linux中没有真正意义上的线程,只提供创建轻量级进程的系统接口。而要创建线程,必须通过一个软件层,这个软件层其实就是一个“动态库”,该动态库的名字被叫做“线程库”。这个要求在pthread_create()的文档说明中也是有的:
因此,在生成可执行文件时,要加上“-l pthread”链接线程库。
再次生成可执行文件:
可以看到,此时就可以再次生成了。我们再输入“ldd 文件名”查看该文件的链接库:
图中所圈出来的内容,观察它的文件名,就可以发现,它其实就是我们所链接的线程库,根据它的后缀,也可以知道它就是一个动态库。
再输入“ls /lib64/libpthread.* -al”命令,就可以查看到该库。
可以看到,在这里我们所使用的库其实是一个软链接,链接的库文件是“libpthread-2.17.so”,该库就是其他程序员已经封装好的一个“用户级线程库”。当需要创建线程时,就需要用这个库中的内容,通过这个库将线程调用的方法转化为创建一个轻量级进程。
这个库是一个“原生线程库”。指的就是这个线程库是每一个linux系统必须自带的一个库。这就意味着,哪怕你的linux刚买来什么都没有,它的内部也一定带有这个库。
了解完线程库后,再来运行该程序:
在该程序中,创建了一个新线程,是一个新的执行流,再加上主函数执行流,所以当该程序运行起来后,它的进程里面就会有两个线程执行流,因此,我们应该可以看到给程序中的两个while在同时运行。运行结果也正如我们所料。这也就说明了线程和子进程一样,都可以并行执行一个进程内的其他代码块。
让这个程序保持运行,再打开一个会话窗口,在里面输入“ps axj | head -1 && ps axj | grep mythread”命令,查看该程序的进程:
此时可以发现,在这里只显示了一个执行流。这就很奇怪了,上文中说过了该程序中新创建了一个线程,应该有两个线程执行流才对啊。我们再输入“kill -9 进程pid”来结束该进程:
可以看到,当向该进程发送一个信号后,这两个线程执行流都结束了。原因很简单,信号是OS向进程发送的,可以将其看做发送给进程中的主线程,只要主线程结束,就代表该进程的所有资源都需要被释放,而其他线程使用的就是进程中的资源,此时所有资源被释放,其中就包括其他线程使用的资源。此时线程继续执行无意义,也结束运行。简单来看,可以理解为信号是向该进程中的所有线程发送的,所以就结束了所有线程的执行流。
再次运行该程序,然后输入“ps axj | head -1 && ps axj | grep mythread”查看进程:
可以看到,虽然有两个执行流,但是依然只显示了一个进程。我们再输入“ps -aL”命令,该命令中的“L”,就是查看轻量级进程的选项:
此时就可以看到有两个执行流了。前文中说了,CPU调度的基本单位是线程。这也就是说,每个线程也应该有一个属于自己的唯一id来标识自己。而这个唯一id,其实就是上图中的“LWP”。再仔细观察就可以发现,这里还有一个pid,其中,第一个线程的pid和LWP是一样的,这个线程就是该进程的“主线程”。而第二个线程的LWP和pid不相同,这个线程就是新创建的新线程。这也就是说,我们以前所说的CPU通过进程pid进行调度,其实是建立在这个进程只有一个主线程执行流的情况下,因为在这种情况下LWP和进程pid相同且只有一个执行流。但,当这个进程中有多个线程执行流时,以前大家所理解的CPU通过进程pid来进行调度就不太对了,准确来讲,应该是“CPU通过线程LWP进行调度”。
有了上面的理解,再结合信号的知识,我们可不可以对一个线程发送信号呢?答案是不可以的。因为信号是OS给一整个进程发送的,无法对单独一个线程发送。
在pthread_create()中的第四个参数是喂给第三个函数指针的参数的。这里就来验证一下。修改程序如下:
运行该程序:
可以看到,此时就打印出了args中的内容,这也就证明了pthread_create()的第四个参数就是喂给第三个函数指针的参数的。
将程序修改如下,打印出生成的线程的id:
运行该程序:
可以看到,它是一个很长的一串数字,很明显它的长度就不会是线程的LWP。我们再修改打印:
运行该程序:
通过转为16进制后,就可以发现,它打印的内容很有可能就是一个地址。所以,pthread_create()的第一个参数返回的并不是线程的LWP,而是一串地址,这个地址到底是什么,在这里还不太好解释。
在一个进程中,一个线程一旦被创建,几乎所有的资源都是会被线程所共享的。
为了验证这一点,写出如下测试代码:
在这个程序中,我们定义了一个全局函数,然后让主线程和新线程都执行这个函数。运行该程序:
可以看到,打印出来的内容就包含了func()函数的内容。这也就证实了上面“一个进程内的几乎所有资源都被进程所共享”的说法。所以,在线程之间进行交互是很容易的。因为线程不同于进程,进程之间具有独立性,无法看到同一份资源,所以我们要通过各种方式让它们看到同一份资源。而线程之间天然就可以共享数据,所以线程之间的数据交互很简单,直接在进程中定义一个全局缓冲区即可。
因此,一个进程中的如堆区、未初始化数据区、初始化数据区等等区域中的数据,绝大部分其实都是线程共享的。当然,这也就意味着每个线程自己所使用的资源,在本质上也是共享的,可以通过某种方式,如定义全局指针指向线程的某个资源,让其他线程看到这份资源。
虽然线程的大部分资源都是共享的,但是它也应该有自己的私有资源。就好比你的家中的绝大部分东西都是一家人可以共同使用的,但你作为这个家庭中的一员,也会有自己的私人物品,不能被其他人使用。线程也是如此。
那么线程有哪些数据是私有的呢?
首先,每个线程其实就是进程中创建的一个PCB,所以,这个PCB中的属性,一定是线程私有的。
然后,CPU调度的基本单位是线程,每个线程都需要被加载进CPU中运行。这也就意味着线程在时间片轮换后,需要带走自己的上下文数据,因此,线程一定有自己私有的上下文结构。
其次,每个线程都是要执行自己的代码块的,而这些代码块中就有大量的临时变量,而临时变量我们知道都是保存在栈中的,所以,每个线程一定要有自己的栈结构。
当然,线程还有一些其他私有的资源,例如信号屏蔽字、调度优先级等。但上面的三个内容,特别是后面两个内容,才是最为重要的。因为这两个内容才能直接表明出线程在CPU中其实是动态运行的。
但是大家仔细思考一下,在这三个私有数据中,线程有私有的PCB和私有的上下文结构很很好理解,它们都保存在堆中。但是,线程到底是如何实现又独立的栈结构的呢?要知道,每个进程中,都只会有一个栈,而这个栈是供主线程所使用的。那么那些新创建的新线程的栈结构是如何存在的呢?
在了解这个问题前,我们要先回忆一下,在前文中说了,linux系统中并没有提供创建线程的接口,只提供了创建轻量级进程的接口。那这个接口是什么呢?其实就是clone()接口:
无论是以前大家了解到了创建子进程的fork()还是现在创建线程的pthread_create(),究其本源,其实都是调用的这个clone()接口。在这个接口里面有一个“child_tack”参数,它其实就是用于创建栈结构的。当然,到底如何创建在这里还不太讲,所以就放到下一篇文章“线程控制”中讲解。