文章目录
- 线程的理解
- 地址空间的转换问题
- 总结
- 线程的优点
- 线程的缺点
- 线程的健壮性问题
本篇主要进行对于进程和线程的理解,以及对于线程的一部分使用方法和使用的原理
线程的理解
首先回顾前面一篇的内容中,对于进程的基本认识:
什么是线程,如何理解线程?线程是进程内部的一个执行分支,那么也就意味着肯定是先有进程,再有线程,这也是最基本的一个逻辑,那么在Linux系统中也确实是这样,当一个代码被编译完成之后,如果想要执行这个进程,那么在进行启动这个进程的时候,就会对这个进程建立起对应的地址,空间,页表,代码数据,内存区域的映射关系等等,这些就都是对于进程在创建之初就应该要储备好的部分,后续在用特定的方案创建一个PCB的时候,其实只需要让多个PCB去指向同样的一部分内容,就可以实现在共享地址空间中做到让多个不同的执行流去访问同一块进程内的代码和数据,那么对于进程的代码和数据资源进程合理的划分,就能实现让这些代码之间的串行变成并发执行,那么这一个一个的分支就叫做线程,因此基于上述的这两个点,可以得出下面的结论
- 线程在创建和释放的时候可以比进程更加轻量化,只需要对于各种各样的PCB进行管理即可
- 线程是进程内部的执行流,本质上线程是在进程的地址空间中进行运行的
- 对于进程的理解应该更加深一点:在之前的理解中,进程就是代码和数据以及内核的数据结构,这个结论是没有任何问题的,但是放在现在要多一个新的理解,进程是承担分配系统资源的基本实体,具体是如何进行承担的,简单来说就是从各种的内核数据结构和表现上来体现的,就是要花内存占用对应的CPU资源,这样就是体现出分配系统资源的基本实体
- 有了对于线程的理解,那么在之前对于进程的理解就应该更加广泛一些,之前对于进程的理解是体现在了内部只有一个PCB结构,这样的进程在现在看来是可以叫做是一个执行流的进程,现在对于进程就转换成了一个进程内会包含有一大堆的申请的资源,并且还会有至少一个的PCB结构,而多线程其实就是内部有多个这样的PCB结构,这就实现了一个单进程中有多线程
- Linux当中线程和进程都会被操作系统调度,那么现在的问题是,是否需要重新设计一个线程的结构呢?事实上从Linux的操作系统设计模式中可以看出并没有这样做,而是把之前对于进程的相关字段都重新复用了起来,也就是说Linux中的线程设计是完全用进程来进行模拟的,站在CPU的角度来说,当它拿到一个具体的PCB结构执行流的时候,它认为自己将来在CPU上跑的时候的执行的那个PCB,是要比传统意义上理解的那个单个进程PCB是不一样的,最起码是小于等于单PCB的进程,所以自此之后,可以换一个角度去看Linux中的进程,可以把进程线程都看做事一个轻量级的进程,这个轻量级的进程指的就是单纯这个PCB,而对应的整个地址,比如有地址空间页表等等信息,这些全部加起来就叫做是真正意义上的进程,而并非是轻量级进程
地址空间的转换问题
下面要谈的一个问题是虚拟到物理地址转换的问题,这个问题其实已经提过了,在最初对于进程的理解中就已经有了一个初步的描述,在前面的对于地址空间的转换问题的理解只是停留在于,从虚拟地址到物理地址是要进行页表的映射进行转换,而这样的理解其实并不是特别到位的,下面这个模块将对于这个过程进行更加详细的描述和理解
在之前对于文件系统的理解当中,提及过这样的一个观点,文件系统最终会指导操作系统和磁盘的设备进行io交互的时候,是以4kb为一个单位进行数据交互的,那么这个4kb也被叫做是一个文件块,换句话说,站在操作系统的角度来讲,它并不关心这个数据是啥,到底是图片还是视频还是音频,它关心的是读取这个文件的内容,要从外设搬到内存中,是要以一个数据块为一个单位进行搬运的,那在进程间通信的时候,也提及过对于共享内存的创建大小,必须要以这个配置为单位
为了方便描述,画出下面的最初步的示意图
在上述的这个系统中,物理内存和磁盘进行io交互的时候,在硬件的层面上,可以通过某种方式把数据从磁盘弄到内存当中,而从纯硬件的角度来讲,这个过程就是把数据从一个设备拷贝到另外一个设备上,但是现在的一个问题是,把数据加载到内存的什么位置?加载多少?从哪里开始加载?这些问题都是需要解决的,甚至以至于还有比如说这个数据的访问权限是多少,这个数据的属性是什么,这些问题本质上来说就已经不属于是硬件的范畴了,那这个问题的解决措施是由操作系统中的文件系统来解决的,文件系统把磁盘整体进行一个管理,那么此时文件系统就可以通过文件路径来把对应的文件打开,再把对应的数据块中的信息加载到物理内存中,从而实现了一个文件的加载和读取的过程,而这个过程其实也是在之前的理解范围之内的
这里要引出的一个结论就是,不管是什么数据,可能是这个文件的内容,也可能是这个文件的属性,但只要是属于这个文件的数据范围内,它在进行加载的时候的基本单位就是4kb,这是由文件系统来进行设置的,也就是说在文件系统的层面上看这个新建的hello.exe这个可执行程序,本质上可以看成是由若干个小的4kb这样的文件块组成的,一般给这样的数据段叫做是ELF数据段,那么最终的结论是,这里的每一个数据块都是4kb为单位的,那么在最终的操作系统中,本质上也会这样进行划分,而这样的每一个这样的4kb大小的数据块,在磁盘的角度看来就叫做是页帧,而在物理空间中的这个4kb的一块一块的空间就被叫做是页框,所以上面的图可以修改为下面的模样:
那么这样就能得出一个朴素的结论是,未来这个文件当中哪怕只是需要修改一个比特位,也必须要把这个数据所在的这个4kb的数据块全部弄出来加载到内存中,在内存中进行修改后,再刷新到磁盘上,这个就是基本单位的含义
下面的一个问题是,既然物理空间被划分成了这样,那么实际上计算一下:
在地址空间中按4kb的划分,是需要有100多万个页框的,这是一个相当庞大的数据,那么下一个问题是,对于这个页框的管理该如何进行管理呢?所以操作系统的内部会维护一个结构体对象,用来描述这个页框的属性,而在操作系统中就存在这样一个数组,用来描述这个结构体,可以近似的理解为是struct page pages[1048576]
,而在这个结构体page中,里面存在了很多的字段,这些字段可能是意味着有物理内存是否被锁定,也有这段内存是否被使用,当前内存是否准备释放,每一种情况都用一个比特位来表示,所以在内存中对于物理内存的管理,就可以转换成对于这个标记位的每一个比特位的管理
下一个问题是,页表的大小问题,在虚拟地址中有2^32个地址,这些地址都要被页表进行映射吗?经过简单的计算就可以很容易知道,这是不可能的,如果每一个地址都进行映射,那么页表所占据的空间可能就已经是一个很大的概念了,所以得出的一个结论是,上面图中的这个页表其实是不完全准确的,真正的页表结构还有其他的结构组成
虚拟到物理地址到底是如何进行转换的呢?在CPU中有很多的寄存器,其中一个寄存器叫做eip,那这个寄存器主要是存储的是虚拟地址,而在CPU上还会有一个小组件叫做MMU,那这个组件其实就可以理解为可以完成从虚拟到物理地址的转换工作,那这个转换是如何进行转换的?
以32位的机器为例,现在有一个地址,假设它是1111 1111 1001 0001 0000 0000 0010 1000
,那么在实际的内存中其实并不会把这32个数字看成一个整体,而是会把前10个数字看出一个整体,中间10个数字看成一个整体,后面的12个数字看成一个整体,而实际上页表也并非是一个大的页表,而是会存在分级的情况,按32位的机器来看,会分成两级,第一级叫做是页目录,页目录整体上来说是被用作查找的,但是页目录的查找只会使用前10个比特位,那页目录总共会有多少个呢?经过计算总共会有1024个组合方式,那么在之后进行查询页表的时候,就会先找到前10位对应的内容,再用中间的十位进行查找,那中间的这10位就被叫做是页表项,也叫做是二级页表,在实际进行查找的时候,可以把前10个转换成一个二进制的数字,而这个数字就对应了这个数组的下标,而这个数组里面的内容指向的是下一级的页表,从这个开始的地方就能搜索到下一级的页表是谁,那么实际上,在这个一级页表中其实存储了的是一个映射关系,而这个第一部分的内容是不需要被存储起来的,直接把前10个比特位当成是一个数组的下标用就可以了,实现了一个二进制的拆分机制
那为什么要这样做,这样做的好处在哪里?实际上页表的存储是物理内存的哪个位置,本身我们也不清楚,所以当前在进行页表的构建的时候,从虚拟地址映射的这个过程中,如果没有对应的这级页表,在进行重新申请的时候就填到这个里面,最终凡是从这个地址开始的都会通过这个页表来查,而这个页表也可以当成是一个数组,而这个数组其中也有10个比特位,也就是1024,那么这个数字实际上也可以看出是一个数组的下标,里面存放的内容就是实际这个地址最终映射到所要申请的页框的起始地址,因为物理内存的角度来讲这个页框的大小是以4kb进行io的,所以在实际进行寻找这个具体内容的时候,就要通过先找到这个内容属于哪个页框,再通过这个页框找到内部的这个内容,那要找某一个数据或者是代码,前提就是要找到虚拟地址所在的页框,所以在这个页表中存储的就是物理内存的页框,因此,本质上来说,对于页表这个结构来说,整个页目录和页表项当中真正有用的部分是帮助操作系统找到页框,随后再找到具体的对应的内容,而找到页框之后,就可以根据最后的那12个比特位当成是一个偏移量,来找到与之对应的内容数据了,来用下面的这个图描述一下更为清楚:
借助这样的结构,就可以使得整个页表的体积大大减小,但是更为重要的是,此时找到的就是物理内存中的页框了,但是在平时进行访问的时候其实并不关心页框,关心的是页框中的某一个数据,但是只需要借助最后的偏移量就可以找到了,只需要由前10个比特位找到页目录,然后再通过下标找到对应的页表,再通过中间的10个比特位找到对应的页表项,再通过页表项的内容找到页框,再通过最后的这个偏移量来找到所需要的数据内容
所以说,页表中不存物理地址,但是会有页框的物理地址,严格意义来说,它当中只有页框的物理地址,通过第二级索引就能找到物理内存中页框的物理地址,再根据偏移量就能直接定位到具体的地址了
完善一下刚才的图:
总结
划分页表到底是什么呢?划分页表的本质是什么呢?本质上来说,就是在划分地址空间,站在进程的角度来讲,地址空间本身就是资源,所以划分页表就是在划分资源
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
线程的缺点
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变 - 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的 - 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响 - 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
线程的健壮性问题
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>// 新线程
void *ThreadRoutine(void *arg)
{const char *threadname = (const char *)arg;while (true){std::cout << "I am a new thread: " << threadname << ", pid: " << getpid() << std::endl;sleep(1);}
}int main()
{// 已经有进程了for (int i = 0; i < 5; i++){char threadname[64];snprintf(threadname, sizeof(threadname), "%s-%d", "thread", i);pthread_t tid;pthread_create(&tid, nullptr, ThreadRoutine, (void *)threadname);sleep(1);if (i == 4)int a = 10 / 0;}return 0;
}
运行结果如下:
从中看出的一个问题是,当出现除0错误的时候,所有的线程都被终止了,这是因为所有的线程都共享信号的处理方式,所以当有一个线程收到信号后,其实所有的线程也就都被终止了