自学内容网 自学内容网

Linux·线程概念与页表

1. 线程的概念

        线程是一个执行流,其执行粒度比进程更细,线程是进程内部的一个执行分支。之前我们认为进程是内核数据结构+代码和数据,今天我们个更新这个概念。

        进程是承担分配系统资源的基本实体线程是OS调度的基本单位

        如果操作系统要支持线程,那就要先描述再组织的管理线程,也就会有TCB结构体(thread ctrl block),结构体的内容和PCB应该是差不多的,但是这样太复杂了,于是Linux设计者任务,既然线程是在进程内部的一个执行分支,那就把PCB和TCB合并,如果谈进程就用PCB,如果谈线程就再建立一个新TCB结构体,但地址空间与PCB共享。

        事实上Linux下的线程就是用进程模拟实现的,由多个线程管理同一块地址空间,就是由多个PCB指向同一块地址空间。这样做的好处就是不用单独再给线程设计一个先描述的TCB数据结构了,管理线程阻塞线程等待线程等再组织动作也不用重新设计了。

        也就是说暂时我们可以认为,进程 = PCB(task_struct) + 虚拟地址空间 + 页表 ,而每一个task_struct就对应一个"线程"确切来说是执行流

        一个进程根本就不是一个PCB能描述的,由此当为一个进程创建新的执行流的时候就不用重新开辟地址空间,构建页表,加载代码数据映射,只需要把曾经分配好的地址空间给每个执行流一人拿一块就好了。

        我们之前学的一个PCB控制一个虚拟地址空间,不过是一个进程中只有一个执行分支,及内部只有一个线程而已。

        对于CPU来说看到的都是一个个的task_struck,让他们在运行队列中调度等待,这些task_struck有的可能是进程中的唯一线程,有的可能是进程中多个线程的一个。但是我们能确定的一点是task_struck<=进程,在Linux中,我们将执行流统称为 LWP (轻量级进程),也就是说我们之前学到的进程都是一个轻量级进程+虚拟地址空间+页表。

        Linux下没有真正意义上的物理结构的线程,Linux下的线程概念是由LWP进行模拟实现的。但是Windows下是由线程的真实物理结构的。也就是说在Linux下我们想创建一个线程其实创建的是一个LWP轻量级进程。

        创建线程 man pthread_create 查看

                

        因为这个函数是C++库给我们封装好的,要求使用这个函数要连接 pthread 线程库

                                        

        这个函数我们后面讲解,现在先创建一个线程看看是什么样子

                        

                        

        可以看到两个线程同时都跑起来了,但是它们所属的进程pid都是一个,也就是说它们从属于同一个进程,也就是说这个进程正在执行多执行流任务。

        程序跑起来之后我们axj查,发现进程确确实实只有一个。

        但是 -aL 查LWP轻量级进程,就能发现这个名称下有两个轻量级进程在跑,-L选项查看LWP。看哪个是主线程哪个是新线程,LWP与PID相等的就是主线程。

        真实调度OS看的是LWP!

2. 资源划分

2.1 分页式存储管理

        多个程序运行的时候势必要占用各自的物理内存此时如果一个程序退出,那物理内存中就不可避免的出现镂空不连续的现象,这种现象我们称为内存碎片。而虚拟地址空间的出现让进程不必关心物理内存中数据的真实存放位置,通过虚拟地址空间让数据在虚拟内存内存中呈现连续的状态,此时OS提供给用户的空间就是连续的了,后续通过页表再映射不连续的物理内存。

        我们知道文件从磁盘加载到内存中时遵循ELF格式,加载的时候是以4KB一个数据块为单位加载的,这样以数据块为单位加载的好处就是不会造成太多的内存碎片,就算有碎片也是在某个数据块中的。

Linux·磁盘和文件_linux文件读写和磁盘读写-CSDN博客文章浏览阅读851次,点赞21次,收藏8次。本节讲解了磁盘的物理结构和逻辑结构,文件信息从内存中转存到磁盘上,inode作为文件的唯一描述符有什么意义。_linux文件读写和磁盘读写https://blog.csdn.net/atlanteep/article/details/143061774

        我们将一4KB数据块称为一个页框,所以物理内存都被规定成一个个的页框,此时内存上有若干个页框,那OS就要把这些页框都管理起来。

        OS为了描述页框,创建struct page结构体来描述页框。struct page中有一个无符号长整形flags变量,用来描述这个页框的使用状态,flags是以位图的结构描述在使用状态的,我们可以提前使用宏将使用状态对应的二进制位设置好。

        组织页框在OS中是用一个顺序表的结构mem_nap将所有page的指针统一管理起来,此时就可以把申请页框的行为变成了对mem_nap顺序表的增删查改。

        只要我们把mem_nap顺序表定好了,那其每个page元素的下标就能快速完成对物理地址的映射,也就是说每一个页框的物理地址=下标*4KB

        4GB的内存需要1百万个页框来组织,一个page结构体算40字节的大小,page的总大小差不多占40MB。40MB的数据在内存中需要10000个页框来记录页框page信息。

2.2 页表

        首先我们可以粗略的计算一下页表的大小,如果页表的设计原理是只记录物理内存,其偏移量与虚拟地址的偏移量相同,这样页表中就只用存物理内存了。此时一个记录物理内存地址需要4字节,整个虚拟地址空间有2的32次方(4GB)条地址需要记录,总共需要16GB的空间才能容纳下一个页表,这显然是不合理的,因为内存一共才4GB。

        那么虚拟地址和物理地址之间到底是如何转化的?

        实际上的页表是多级的

        首先有一个页目录结构,每项叫页目录表项,一共1024项。每个页目录表项指向一个子页表,每个页表中的元素叫页表项,每个页表中一共有1024项。每个页表项指向一个物理内存中的4KB页框。此时假设每个页表项中的内容为4字节的页框地址,整个多级页表的大小就是1024*1024*4,为4MB。

        光找到页框的起始地址肯定不够,因为地址的粒度是要能指向一字节的数据的,这就要看到地址的构成了。

        虚拟地址是由32个比特位构成的,地址的前10个比特位对应页目录表象,通过地址的前十个比特位可以确定唯一的页目录表象,从而确定唯一的页表,然后再看下面10个比特位,对应页表项,也能确定唯一的页表项,从而找到唯一的页框起始地址。地址中最后12位:2的12次方为4096,正好对应页框4KB的大小,也就是说最后的12位可以标注某字节地址在页框中的偏移量。至此地址也页表的转换完成闭环。

        一个进程不可能使用全部的物理内存,这意味着,虽然我们的页表画的是1024个但实际上页表的大小远远小于4MB

        CPU收到一个虚拟地址之后,通过CR3寄存器记录的页目录起始地址,和MMU硬件电路就可以解析到物理地址了,下面我们看看一个线程具体是怎么调度起来的。

        具体来讲,CPU中有一个寄存器,可以保存当前要调度的LWP,EIP寄存器(PC指针)中可以得到这个线程的入口虚拟地址,无论是主线程的start还是子线程的各种函数入口,之后CR3寄存器拿到虚拟地址交给MMU硬件进行查表从MMU中出来的就是物理地址了。

        物理地址+操作码通过系统总线给到物理内存,物理内存中也有两个寄存器分别记录这俩,然后索引对应地址的内容,通过系统总线传回CPU,此时传回来的就是入口的第一句指令,将这个携带虚拟地址的指明给到IR寄存器CPU进行运算。

        然后EIP寄存器中的起始地址+这句指令的长度就得到下一句指令的虚拟地址,然后就再交给CR3交给MMU,如此循环就可以将LWP跑起来了。

        MMU的查表速度后来认为还是太慢了,于是有找了个TLB(快表)硬件来辅助MMU,TLB是一个缓存器,会将MMU中解析过的虚拟到物理地址的映射记录下来,此后MMU在解析地址的时候会先去TLB中查,如果TLB未命中MMU再老老实实的做转换工作,因为我们的代码中有比如循环结构等可能重复的结构,因此TLB在一定程度上可以提高MMU的查表效率。
 

2.2.1 页表最终态

        我们在以前还提到过页表中还会有标志位,来记录比如RW权限、是否命中(野指针),等信息那这些标志位又记录在哪了呢?

        思考一下就能发现一个页框的起始地址,后12位全是0,也就是说当页表项在记录页框起始地址的时候只需要20个比特位就可以记录一个页框的起始地址。一个页表项是32位的数据,前20位用来记录页框起始地址,后12位就用来做标记位。

        ​​​​​​​        ​​​​​​​        ​​​​​​​        

        页表的标记位是在页框加载的时候写好的,因为代码在编译的时候就已经将每句命令的权限都设定好了,后面加载程序的时候就自带每句命令的权限的,也就是说只要发生缺页中断,要将一个页框调入内存时,就会更新相应页表项,此时就把标志位也写好了。

        MMU在做虚拟到物理的地址转化时就会检查对应标志位,如果一个只读标记的区域中要写点东西,MMU就直接报错了,此时操作系统就通过CPU发出的软中断知道MMU报错了,然后就会查对应软中断的中断向量表,得到给对应进程发信号杀进程的操作,于是就发信号杀进程了。

        一个可执行程序可能很大,超过内存的4GB了,那么在加载的时候肯定不能把整个程序都加载进内存。比如一个程序有100W行指令,但是只能加载1W行,此时在虚拟地址空间中还是认为该程序有100W行命令,但是构建页表的时候只把能加载进来的1W行命令的地址写进页表中,后面如果要访问1W+1行代码的时候MMU就会发生命中错误,CPU发出软中断,OS拿这个软中断号去查中断向量表,表中记录的操作方案是加载行命令,然后OS就再加载命令进内存中,将命中状态改为命中,然后程序就能继续跑了。这个没有命令了发出中断要重新加载命令的中断情况就是:缺页中断

        我们之前学到的 new / melloc 并不是真正的在申请物理内存空间,而是先在虚拟地址空间中将堆区的空间增加,当我们要用这部分空间的时候才会触发缺页中断把数据真正写进物理内存中。申请内存做的事情就是修改虚拟地址空间再填充修改页表。

        MMU上的报错统一用的都是一个中断号,那OS是如何区分错误的呢,比如在访问一块没被命中的地址时,到底是应该触发缺页中断还是野指针报错杀进程呢?

        首先我们回忆一下在编写C程序时,发生指针越界的时候并不一定会报错杀进程,典型案例就是数组的越界访问,并不一定会报错。

        这是因为操作系统在处理中断或异常时,首先检查触发事件的虚拟地址的页号是否合法,如果页号合法但页面不在内存中,则为缺页中断;如果页号非法则为越界访问。比如一个进程A代码区总共分配了5页,但指令却要去第10页中找内容,此时就是越界访问。

        操作系统还会检查虚拟地址是否在当前进程的内存映射范围中。如果地址在映射范围内但页面不在内存中触发缺页中断,如果地址不在映射范围内为越界访问。

        简单来讲就是OS给这个进程分配了20到50的页框,此时进程中如果把指针访问到10号或60号页框这就越界了,但是如果访问40号页框但是页框还没建立映射就触发缺页中断。

2.4 如何划分资源给线程

        我们知道一个进程下的所有LWP都指向同一块虚拟地址空间,也就是说LWP之间的内存资源是都是共享的,那资源划分或者说代码划分到底是怎么回事?

        前面我们使用pthread_create函数创建一个线程之后,它会自动走调度那一套机制,每个线程的入口函数就是我们设定的函数,也就是说线程的创建就是先创建一个LWP然后指向虚拟地址空间中的某个函数作为入口,之后我们就不用管了,OS会自动把线程入运行队列进行调度。

        Linux下的线程是用进程模拟实现的,线程的进入和调度和进程进入调度等操作都复用了历史代码,因此Linux下不需要为线程再单独设计TCB来管理线程了,Linux中只有LWP轻量级进程就完全够用了。

        资源划分就是把函数入口交给不同的LWP,就相当于把虚拟地址空间资源划分了。

2.5 线程的优缺点

        优点:

        创建一个新线程的代价比创建一个新进程小的多。线程占用的资源比进程少。能够充分利用多处理器的可并行数量。在等待慢速IO操作结束的同时,可以执行其他计算任务。

        计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现,在计算密集型任务中线程并不是越多越好,一般解决计算密集型任务时多线程个数=CPU物理个数*核数。

        IO密集型应用,为了提高性能,将IO操作重叠,线程可以同时等待不同的IO操作。

        线程切换的代价比进程切换小的多,虽然在Linux下线程和进程复用同一份代码,但是在CPU中有一个cache硬件用来保存当前执行命令的附近4KB的命令,也就是说对附近命令的一个缓存,因而不必每次调用命令的时候都要到内存中查找,如果cache硬件中有下一条命令,就直接在CPU内部执行了。线程切换在一定概率上cache可能还能用,但是进程切换cache就真的要完全重新缓存了因为进程之间的虚拟地址空间是不一样的了。

        缺点:

        性能的损失,线程如果过多切换的成本就增加了。

        健壮性降低,因为线程之间资源是共享的,如果一个线程不小心把一个重要的全局变量改了,就可能导致进程中的所有线程都出现问题。

        

2.6 线程用途

        一个进程中如果一个线程出现问题了,OS就会把整个进程所有线程都杀掉,因为线程是进程的执行分支,线程的异常操作就是进程的异常操作。一个线程出现野指针访问了,在OS内部有线程组,发现这几个线程都是一组的,就会给这组线程都发野指针信号杀掉。

        合理的使用多线程,能提高CPU密集型程序的执行效率,以及IO密集型程序的用户体验。

        不过线程的优越性在后面的网络章节才能得到最完美的体现。

2.7 多个线程共享

        同一个进程中的线程的大部分资源都是共享的,包括文件描述符表、每种信号的处理方式、当前工作目录等等,但是线程ID、、一组保存本线程上下文的寄存器、信号屏蔽字、调度优先级都是各自私有的。

        线程每个人压栈形成栈帧都是用自己的栈,所以在一定程度上,线程之间也可以互不影响。


原文地址:https://blog.csdn.net/atlanteep/article/details/143742969

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!