前言
前面在介绍进程的时候,说过进程的内核表述是"进程是承担资源分配的基本实体",但是我们至今都没有介绍如何理解他?本期我们就会介绍!
目录
一、再谈地址空间和页表
这是我们第四次也是最后一次谈地址空间了!
第一次,是在初识地址空间引入的;
第二次,在动静态库引入了虚拟地址空间的地址来自编译后的源文件;
第三次,是在信号引入内核太和用户态的时候引入了OS内核;
本次,我们将介绍物理内存的管理、二级页表;
1、OS对物理内存的管理
当一个进程被运行时,他的代码和数据要加载到内存;但是OS如何知道要把数据load到物理内存的那个位置?也就是OS如何知道哪些位置是可用的呢?其实OS也是会对物理内存进行管理的,如何管理?先描述,再组织!
OS会把物理内存"划分"成一个个的数据块,每个内存块的大小默认是 4KB。把这个数据块叫做页框/页帧;
此时对物理内存的管理变成了,对一个个页框/页帧的管理!所以先用一个struct page的结构体进行对页框/页帧描述,再用数组对一个个的struct page对象进行组织!即对物理内存的管理就变成了数组的管理!
struct page的结构体大致如下:
struct page
{
int flag; // 是否被占用、是否是脏页、是否被绑定
int mode; // 权限
//.....
};
这里看到这个数据块的大小默认是 4KB,是不是感觉有点熟悉呀!我们前面在文件系统介绍了磁盘的I/O基本单位就是4KB(8个扇区)!其实 4KB 是OS进行I/O的基本单位!
• 为什么4KB是OS进行I/O的基本单位?
OS的I/O无非两方面:1、内存换出到磁盘 2、磁盘加载到内存!
第一个原因是,磁盘中的数据也是以4KB的数据块进行存储的,物理内存也是以4KB的页进行管理的,所以这样设计提高了数据的传输效率!
第二个原因是,局部性原理。局部性原理又分为时间局部性和空间局部性!
• 时间局部性原理:当前正在访问的资源在未来还会访问。
• 空间局部性原理:未来访问的资源是当前访问资源的临近资源。
比如,父子进程共享一个全局变量,当父子进程都不对这个全局变量修改时,他们共享!一旦其中一个进程进行写入时,OS就要发生写时拷贝,也就是要开辟物理内存,然后拷贝改全局变量的数据!然而,虽然只是修改一个变量但是在写时拷贝的时候会将那一个4KB的数据块进行整体拷贝,这样比你修改一个我给你拷贝一个的效率高,其实可以理解为一种预加载机制!
2、再谈页表
我们以前都是简单的说,页表是一张进行虚拟地址到物理地址转换的表!但是,不知道你想过没有,我们以32位的机器为例,虚拟地址是2^32个,也就是页表左边的虚拟地址就是2^32个,再加上右边对应的物理地址,2^32个,再加上标记为2个!而32位每个指针4字节,标记为都算成一个字节,也就是每一行是10个字节,一共2^32个,计算下来就是40GB,你还啥都没干,就搞了个页表就40GB,你的内存在大也没有40GB吧!所以我们以前对页表的理解是太简单了,这里需要我们重新认识页表了!
• 二级页表
我们这里以32位,为例。虚拟地址其实就是32位的一个二进制。其中,将32位分成了三段,分别是10位,10位,12位。
前10位一共有2^10=1024个虚拟地址,他是用来在页目录中做索引的!其中每个页目录中的内容是指向一张页表!该页表的大小是2^10=1024,然后取中间的10位,中间10位的范围正好是[0,1023],所以中间的10位是获取对应页表的下标的!而每个页表的元素都指向一个页框的起始地址!
而最后的12位的范围就是[0,4095]正好可以把一个数块的每一个字节都获取到!所以,最后12位是页内偏移!
而我们把上述的页表称为二级页表!其实页目录和页表即前10位和中间10位的目的是在搜索页框!
这样下来,每个页表的元素算4字节,1024*4KB=4MB+4KB的页目录,这可比前面的40GB小太多了,而且内存中也可以放下了!
• 如何找到一个变量的所有字节?
我们目前,可以通过目录+页表搜索到对应页框的起始地址,然后加上页内偏移找到对应的起始字节!但是我们平时的数据可不是一个字节啊!例如int类型的变量是4个字节,如何找到呢?这个问题呢,很好解决!每一个变量是不是有类型啊,每个类型是不是在语言层面就规定好了几个字节,所以找到也框中数据的起始字节后,根据类型,获取类型对应的大小偏移对应的字节即可!
• 虚拟地址是如何转为物理地址的?
我们前面就介绍过,CPU读取的都是虚拟地址!当CPU读取到虚拟地址后,由内部的寄存器MMU(Memory Management Unit,内存管理单元)进行根据页目录和页表进行搜索页框,找到对应的叶匡起始地址,然后由页内偏移找到具体的物理地址!很好,但是CPU如何找到页目录表呢?其实在CPU内部有一个CR3寄存器,是存储当前进程的页目录表的!OK,这样CPU拿到虚拟地址后出来的就直接是物理地址!其实如果你拆开过电脑的话,你也会看见CPU人家是直接和物理内存通过总线连接的!
• 理解动态内存管理
当我们在动态的(malloc/new等)申请堆上的空间时,操作系统并没有立即给你申请物理内存空间(申请了不一定立即用),而是先给你申请虚拟地址以及构建页目录和页表,此时页表中没有实际的物理页框的地址,等我们实际用的时候,查页表+寻物理地址时,发现此时的页表中的物理地址没有,此时就会发生缺页中断,OS发出对应的中断信号,然后陷入内核态,根据CPU寄存器中的中断号,执行中断向量表中的对应方法。这里就是申请物理内存,构建页表信息!然后用户就可以正常的使用了,这个过程很快用户几乎没有感知!
和动态申请内存这种赌博的方式的操作,OS中很常见的例如:写实拷贝、文件IO、动态内存管理!
• 为什么对常量不能修改?
我们前面都是记住了,不能对常量修改!比如:
char* ptr = "hello world”;
*ptr = "hehe";
就不可以被修改!但是为什么呢?当把他加载到内存之后,我们有了对应的虚拟地址+页表就可以找到他的物理地址,那不就可以随便修改了吗?为什么不能修改呢?
其实,页表中还有标记位!比如:是否命中、RWX权限、U/K权限、访问位等!
当CPU执行代码是拿到虚拟地址,根据CR3+MMU转换为物理地址时,会检查页表对应的标记位,这里的对常量修改发现他的权限是R,此时发生异常中断,即OS对该进程发送信号,然后陷入内核态对该进程的信号保存,当有内核态切换为用户态时,检测信号处理信号即发生段错误!
到这里我们对地址空间和页表所有的相关东西就打通了,再次回顾这种设计,我们发现 用户根本不知道,也不需要知道地址空间之后的事情,只需要正常使用即可,当发生非法操作时OS可以通过查页表进行拦截,而不影响物理内存!
其实这里的 虚拟地址空间 本质就是在物理内存和用户直接添加的一个软件层,这样做的好处就是当多个进程访问物理内存时可以有效的保护物理内存,同时也为用户提供了统一的地址空间!即做到了完美的物理与虚拟的解耦!
这种设计思想其实在计算机界是非常出名的,“所有问题都可以加一层软件层解决”,这种设计思想不仅用于 OS,还适用于 网络 ,比如赫赫有名的 OSI 七层模型!
• 浅理解文件缓冲区
文件缓冲区其实本质也是一段物理内存!将这段物理内存申请之后,不将他映射到页表中,而是将他挂接到前面介绍的虚拟文件系统的struct file对象中!然后struct file对象和文件描述符表中的fd_array进行映射!当然我们这只是非常简单的理解,真实的比这个复杂的多!
二、初识线程
1、线程的概念
• 教材观点
线程是进程内部的一个执行流,执行的力度比进程更细、调度成本更低
• 内核观点
进程是承担资源分配的基本实体,线程在进程内部运行,是CPU调度的基本单位
结合上述的观点,可以得到下面的图:
那既然线程是进程内部的一个执行流,那一个进程是不是可以有多个执行流,即有多个线程!那OS要不要对这么多的线程管理呢?答案毫无疑问是要的!如何管理?先描述,再组织!所以,就得有描述线程的结构体,这个结构体叫线程控制块 TCB(Thread Control Block),然后用某种数据结构组织起来即可!
其中,对于上述的管理思想各个平台的实现是不一样的!windows就是老老实实的干了
但是Linux可不是这样实现的!下面我们呢就来介绍一下Linux中的线程 !
2、Linux下线程
Linux并不是和上面的windows一样实现的!而是复用了进程的PCB等!原因是,线程是进程内部的一个执行流,和进程共用进程的大部分资源,也就是线程是进程内部的一个PCB,而在CPU眼中是按照PCB调度的,不管线程还是进程都是执行流,所以Linux干脆不叫线程,而叫做轻量级进程(LWP)!所以Linux中的线程是由进程模拟实现的!这样做的好处就是可以和进程一样使用和管理,大大减小了维护成本!
3、进程和线程的关系
我们以前都是说,进程=内核数据结构+代码和数据!而上面刚说完,进程是资源分配的基本实体!这两个概念会不会有冲突啊?不会,它两其实说的是一个意思,进程运行起来,要想OS申请内核数据结构,物理内存!这些不就是资源嘛!OS中资源分配是由进程为基本单位的,所以进程是资源分配的实体/基本单位!而线程是进程中的一个执行流即一个PCB,当进程中只有一个执行流即只有一个线程时,此时的线程就是进程!因为线程是进程中的执行流,他执行时的资源都是进程的!而在CPU眼中,无论进程还是线程,调度只认 PCB,即线程,所以线程是CPU调度的基本单位!
总结:
1、进程中的一个执行流叫线程
2、Linux中的线程是由进程模拟实现的
3、一个进程中至少有一个执行流(线程)
4、Linux中,CPU眼中的PCB比进程的更加轻量化(调度/切换成本更低)
5、线程透过进程的虚拟地址空间,可以看到进程的大部分资源,将进程的资源合理的分配给每个执行流,就形成了线程的执行流
4、有进程了,为什么还要有线程?
前面也说过了,进程在运行前要加载个中数据,以及创建各种数据结构,而这些都本质是资源!所以,进程的创建成本是非常高的!而线程而言,创建就是创建一个PCB,使用的是进程申请的资源!所以创建线程的成本是很低的!其次,进程的调度成本比线程的高(这点很重要,下面专门谈);最后,销毁而言,进程是要销毁各种数据结构和各种数据资源!但是线程销毁的话,只需要把PCB删除即可,成本比进程低的多!
5、如何理解线程调度的成本更低?
线程调度的话,不需要管CR3的页目录表,因为他共用的就是进程的!进程调度的话,就比线程多一个寄存器的事情,为什么说线程的调度比进程更低呢?确实站在上层看,没什么差别!它的原因是在底层!
一是线程的上下文切换的开销小:
由于共享了进程的内存空间和资源,因此只需要保存和恢复线程特有的上下文,这比进程切换时需要保存和恢复整个进程的上下文要小得多。因此,线程切换的开销更小。
二是局部缓存性:
CPU在调度的时候,为了提高效率根据局部性原理,会把一些可能访问的数据(“热数据”)提前预加载到CPU中的cache中,线程切换调度的话,另一个线程可能也会用到这些热数据,所以就不需要在重新到内存加载这些热数据了,既提高了效率也降低了调度成本!但是进程切换调度的时候并不会用到原先cache中的数据,就会重新到内存加载这些热数据,所以没有线程的切换调度效率高了!
三、线程总结
1、线程的优点
• 创建一个新线程的代价比创建一个新进程的小的多
• 与进程直接切换相比,线程之间的切换需要OS做的工作很少
• 线程占用的资源比进程少
• 能充分利用多处理器的并行数量
• 在等待慢速 I/O 操作结束时间,程序可执行其他的计算任务
• 计算密集型应用、为了能在多处理器系统上运行,将计算分解到多个线程中实现
• I/O密集型应用,为了提高性能,将提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作;
2、线程的缺点
• 性能损失一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型 线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的 同步和调度开销,而可用的资源不变。• 健壮性降低编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了 不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。• 缺乏访问控制进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。• 编程难度提高编写与调试一个多线程程序比单线程程序困难得多
3、线程异常
• 单个线程如果出现,除0/野指针等异常,此时OS会给当前线程的进程发送信号导致,当前进程异常终止(也就是进程奔溃)
• 线程是进程的一个分支,线程出现异常,就类似于进程直接出现异常,进程会触发异常机制想改进程发送信号,终止当前的进程;进程终止了,该进程内的所有线程也就是随之推出了!
4、线程的用途
• 合理的使用多线程,能提高CPU密集型程序的执行效率• 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
5、Linux进程VS线程
• 进程是资源分配的基本单位
• 线程是调度的基本单位
1、线程的私有数据
线程共享进程的数据,但是也拥有自己的部分数据:
• 线程ID
• 一组寄存器
• 栈
• errno
• 信号屏蔽字
• 调度优先级
其中上面用红色标记出来的这两个是最重要的!
• 一组寄存器体现出来的是硬件的上下文数据即线程是动态运行的!
• 栈体现的是每个线程在运行时都会有临时数据,这些数据都保存在各自的栈区!
2、多线程的共享数据
多个线程在同一个进程内部,所以共享同一个地址空间,即共享数据段和代码段,如果定义一个函数,在各个线程中都可以调用,如果定义一个全局变量,在各个线程中都可以访问到,除此之外,各个线程还共享以下进程资源和环境:
• 文件描述符表
• 每种信号的处理方式(SIG_IGN、SIG_DFL、或者自定义信号的处理函数)
• 当前工作目录
• 用户 id 和 组id
进程和线程的关系图:
结束语:焦虑也没用,冲就完事了!