🌻个人主页:路飞雪吖~
🌠专栏:Linux
目录
一、Linux线程概念
✨ 什么是线程?
✨Linux下线程的概念和实现
二、分页式存储管理【地址空间 ---> 资源划分】
✨虚拟地址和页表的由来
✨物理内存管理
✨页表
✨页目录结构(32位下)
✨两级页表的地址转换
🌠如何理解进程划分资源给线程?
🌠页表究竟是什么?
✨缺页异常
✨虚拟地址和物理地址是如何转换的?(32位下)
🌠深刻理解
三、线程优缺点 && 用途
✨线程的优点
✨线程的缺点
✨线程异常
✨线程用途
四、Linux进程 VS 线程
✨进程和线程
✨进程的多个线程共享
✨关于进程线程的问题
一、Linux线程概念
✨ 什么是线程?
• 进程是一个执行起来的程序,进程 = 内核数据结构 + 代码和数据;【承担分配系统资源的基本实体】
• 线程:是一个执行流,执行力度比进程要更细。是进程内部的一个执行分支。【线程是OS调度的基本单位】
• 用户 和 OS 沟通的唯一方式就是进程;
• 进程 和 系统 沟通的唯一方式是 系统调用;
• 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“⼀个进程内部 的控制序列”;
• 一切进程至少都有一个执行线程;
• 线程在进程内部运行,本质是在进程地址空间内运行;
• 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化;
• 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形 成了线程执行流;
✨Linux下线程的概念和实现
• 一个可执行程序 加载 ELF 到 CPU 的寄存器里面, 创建一个进程【task_struct、mm_struct、vm_area_struct list、页表、数据和代码】;
• OS在调度选择进程要执行时,就拿着 task_struct 根据优先级分级的大O(1),每一个优先级都有自己的调度队列【过期+运行 队列】进行轮转,让所有进程开始调度。
• 线程也要被调度,也要被执行,系统当中可以同时存在多个进程,线程是在进程内部的执行分支,Linux系统下要支持线程,进程有100个,线程就可能有200/500个,线程一旦在系统里面存在,OS 必然将线程进行管理【先描述,再组织】,OS内为了管理线程就会有【struct thread ctlblock --> tcb】,tcb 里面有 ID值 区分唯一性,优先级、状态、调度记账信息、自己的代码和数据....,每个线程都有自己的 tcb 里面不光有属性【这个属性跟当前的 task_struct 里面的属性高度相似】。
• PCB【task_struct】== tcb,一个线程就是在进程的地址空间内,创建的线程 和 原始进程的本身是共享地址空间的,同时 代码区 也分成多份【多个线程就多份】,此时 一个进程就被肢解开了,代码区的每一个执行分支就是线程。
• Linux下的线程,是用进程模拟实现的!!!【复用历史代码,增加代码可维护性】
• 线程在进程内部运行,线程在进程的地址空间运行!
二、分页式存储管理【地址空间 ---> 资源划分】
✨虚拟地址和页表的由来
思考⼀下,如果在没有虚拟内存和分页机制的情况下,每⼀个用户程序在物理内存上所对应的空间必 须是连续的,如下图:
因为每一个程序的代码、数据长度度都是不⼀样的,按照这样的映射方式,物理内存将会被分割成各种 离散的、大小不同的块。经过一段运行时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。 怎么办呢?我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分页便出现了,如下图所示:
把物理内存按照⼀个固定的长度的页框进行分割,有时叫做物理页。每个页框包含⼀个物理页(page)。一个页的大小等于页框的大小。大多数 32位 体系结构支持 4KB 的页,而 64位 体系结 构⼀般会支持 8KB 的页。区分一页和一个页框是很重要的:
• 页框是一个存储区域;
• 页是一个数据块,可以存放在任何页框或磁盘中。
有了这种机制,CPU 并非是直接访问物理内存地址,而是通过虚拟地址空间来间接的访问物理内存 地址。所谓的虚拟地址空间,是操作系统为每⼀个正在执行的进程分配的⼀个逻辑地址,在32位机 上,其范围从0~4G-1。
操作系统通过将 虚拟地址空间 和 物理内存地址 之间建立映射关系,也就是页表,这张表上记录了每一对 页和页框 的映射关系,能让CPU间接的访问物理内存地址。
总结一下,其思想是 将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过 页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存 造成的碎片问题。
✨物理内存管理
• 磁盘上的可执行程序【a.exe】,固定格式ELF【各种section】,我们把可执行程序加载到物理内存【申请物理内存/释放】。OS在管理物理内存时,在磁盘上文件大小 和 物理内存 都是以 数据块 为单位【4KB,文件块大小】被划分的,磁盘上的文件 和 物理内存 都是 块 和块 之间的对应关系,即 要做的就是 把磁盘上的文件 加载到 物理内存 上的哪一个块上面。
• 无论是 内存管理 还是 文件管理 都是以 数据块 为单位的。
• 在磁盘当中的文件,是由 inode 和 data block 来构成的;
• 我们要把当前文件加载到内存里,本质就是 把 inode 的数据块 加载到 物理内存里;
• 当 15KB 的文件 加载到 物理内存上 就会占 4个 数据块,即便有 1KB 的空间是浪费的,这种内存管理的模式简单,不会出现大块的碎片,出碎片 只在 块内 出现【内部碎片】。
• 在 OS 内 把对应划分好的数据块【4KB】叫做 页框;
• OS 对内存进行管理,先描述,再组织!
struct page --- 数据结构对象,描述一个内存块;
• struct page *mem_map[N]; ---- 用数组对 struct page 管理起来,申请内存-->对数组的增删查改。每一个 page 就有了下标,即 每个内存块就有了 对应的映射关系。下标 和 物理地址 就可以快速相互转换【物理地址 = 下标 * 4KB;下标 = 物理地址/4KB】。
addr / 4KB == addr / 2 ^ 12次方
addr >> 12
page 起始地址:addr & 0xFFFF 0000 0000 0000
• 只要找到了 page 【page有下标】,就等同于找到了物理内存!Linux内核的文件缓冲区,本质就是 page 的列表。
• 整个物理内存当中 大概需要 1000 个 页框,物理内存当中有一部分空间 整体是被用来做内存管理的【struct page mem_map[N] 存的是数组,全局的指针,在OS内随时可以被找到,根据数组就能随时了解所有内存块的使用情况】。
• 当我们在磁盘上把自己的文件加载到内存,首先就是 查页表【struct page mem_map[N]】对应的数组,扫描到没有被用的page,把page所对应的标志位 flag 由未使用 改为 已使用,接着把磁盘上的文件 内存块 拷贝到 物理内存上,此时这个内存块就被占用了。当你不想要这个内存块时,只需要找到对应的物理地址,把物理地址转化为对应的下标,把 flag 由 1 清零,这个内存就被释放了。
物理内存 访问 ---- 每一种设备都有自己的寄存器【数字寄存器、地址寄存器...】,当 CPU 访问物理内存时,是通过 地址总线 把自己 CPU寄存器 里的地址 再打回到给 物理内存,写在物理内存的寄存器里面,在告诉物理内存 我要读,这时 物理内存 就可以根据 物理内存上的地址 自动去找物理内存上的某个地址 ;
假设⼀个可用的物理内存有4GB 的空间。按照⼀个页框的大小4KB 进行划分, 4GB 的空间就是4GB/4KB = 1048576 个页框。有这么多的物理页,操作系统肯定是要将其管理起来的,操作系统 需要知道哪些页正在被使用,哪些页空闲等等。 内核用 struct page 结构表示系统中的每个物理页,出于节省内存的考虑, struct page 中使用了大量的联合体union。
/* include/linux/mm_types.h */
struct page {/* 原⼦标志,有些情况下会异步更新 */unsigned long flags;union {struct {/* 换出⻚列表,例如由zone->lru_lock保护的active_list */ struct list_head lru;/* 如果最低为为0,则指向inode * address_space,或为NULL * 如果⻚映射为匿名内存,最低为置位 * ⽽且该指针指向anon_vma对象 */struct address_space* mapping;/* 在映射内的偏移量 */ pgoff_t index;/** 由映射私有,不透明数据 * 如果设置了PagePrivate,通常⽤于buffer_heads * 如果设置了PageSwapCache,则⽤于swp_entry_t * 如果设置了PG_buddy,则⽤于表⽰伙伴系统中的阶 */unsigned long private;};struct { /* slab, slob and slub */union {struct list_head slab_list; /* uses lru */struct { /* Partial pages */struct page* next;
#ifdef CONFIG_64BITint pages; /* Nr of pages left */int pobjects; /* Approximate count */
#elseshort int pages;short int pobjects;
#endif};};struct kmem_cache* slab_cache; /* not slob *//* Double-word boundary */void* freelist; /* first free object */union {void* s_mem; /* slab: first object */unsigned long counters; /* SLUB */struct { /* SLUB */unsigned inuse : 16; /* ⽤于SLUB分配器:对象的数⽬ */ unsigned objects : 15;unsigned frozen : 1;};};};...};union {/* 内存管理⼦系统中映射的⻚表项计数,⽤于表⽰⻚是否已经映射,还⽤于限制逆向映射搜
索*/ atomic_t _mapcount;unsigned int page_type;unsigned int active; /* SLAB */int units; /* SLOB */};...
#if defined(WANT_PAGE_VIRTUAL)/* 内核虚拟地址(如果没有映射则为NULL,即⾼端内存) */ void* virtual;
#endif /* WANT_PAGE_VIRTUAL */...
}
• flags :用来存放页的状态。这些状态包括页是不是脏的,是不是被锁定在内存中等。flag的每一位单独表示一种状态,所以它至少可以同时表示出32种不同的状态。这些标志定义在 中。其中一些比特位非常重要,如PG_locked用于指定页是否锁定, PG_uptodate用于表示页的数据已经从块设备读取并且没有出现错误。
• _mapcount :表示在页表中有多少项指向该页,也就是这一页被引用了多少次。当计数值变为-1时,就说明当前内核并没有引用这一页,于是在新的分配中就可以使⽤用它。
• virtual :是页的虚拟地址。通常情况下,它就是页在虚拟内存中的地址。有些内存(即所谓 的高端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的 时候,必须动态地映射这些页。
要注意的是 struct page 与物理页相关,而并非与虚拟页相关。
✨页表
若页表只是一个单纯的页表,页表里面,左侧存的是虚拟地址,右侧存的是物理地址;
物理地址是4KB,我们页可以想象虚拟地址也是4KB,所以我们在做映射时,虚拟地址的4KB地址从0到全1【虚拟地址空间上的所有地址都是连续的】,不用记录虚拟地址,只要在页表的条目里面记录它的虚拟地址映射到物理地址上【数组下标直接索引这个页表】;
页表中的每一个表项,指向一个物理页的开始地址。在 32 位系统中,虚拟内存的最大空间是 4GB , 这是每一个用户程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可用,那么页表中就需要能够表示这所有的 4GB 空间,那么就一共需要 4GB/4KB = 1048576 个表项。如下图所示:
虚拟内存看上去被虚线“分割”成一个个单元,其实并不是真的分割,虚拟内存仍然是连续的。这个 虚线的单元仅仅表示它与页表中每一个 表项 的 映射关系,并最终映射到相同大小的一个 物理内存页 上。
页表中的物理地址,与物理内存之间,是随机的映射关系,哪里可用就指向哪里(物理页)。虽然最终使用的物理内存是离散的,但是与虚拟内存对应的 线性地址 是连续的。处理器在访问数据、获取指令 时,使用的都是线性地址,只要它是连续的就可以了,最终都能够通过 页表 找到实际的物理地址。
在 32 位系统中,地址的长度是 4 个字节,那么 页表 中的每一个 表项 就是占用 4 个字节。所以 页表 占据的总空间大小就是: 1048576*4 = 4MB 的大小。也就是说映射表自己本身,就要占用 4MB / 4KB = 1024 个物理页。这会存在哪些问题呢?
• 回想⼀下,当初为什么使用页表,就是要将进程划分为一个个页可以不用连续的存放在物理内存中,但是此时页表就需要1024个连续的页框,似乎和当时的目标有点背道而驰了......
• 此外,根据局部性原理可知,很多时候进程在一段时间内只需要访问某几个页就可以正常运行了。因此也没有必要一次让所有的物理页都常驻内存。
解决需要大容量页表的最好方法是:把 页表 看成 普通的文件,对它进行离散分配,即对 页表 再分页, 由此形成 多级页表 的思想。
为了解决这个问题,可以把这个 单一页表 拆分成 1024 个体积更小的 映射表。这样一来,1024(每个表中的 表项个数) * 1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。
这里的每一个表,就是真正的 页表,所以一共有 1024 个页表。一个页表自身占用 4KB ,那么 1024 个 页表 一共就占用了 4MB 的物理内存空间,和之前没差别啊? 从总数上看是这样,但是一个应用程序是不可能完全使用全部的 4GB 空间的,也许只要 几十个页表 就可以了。例如:一个用户程序的代码段、数据段、栈段,⼀共就需要 10 MB 的空间,那么使用 3 个 页表就足够了。
计算过程:
每一个 页表 项指向⼀个 4KB 的物理页,那么一个 页表 中 1024 个页表项,一共能覆盖 4MB 的物理内存;
那么 10MB 的程序,向上对齐取整之后(4MB 的倍数,就是 12 MB),就需要 3 个 页表 就可以了。
✨页目录结构(32位下)
每一个 页框 都被一个 页表 中的一个 表项 来指向了,那么这 1024 个页表也需要被管理起来。管理 页表的表 称之为 页目录表 ,形成 二级页表。如下图所⽰:
• 所有页表的物理地址被页目录表项指向;
• 页目录的物理地址被 CR3 寄存器 指向,这个寄存器中,保存了当前正在执行任务的页目录地 址。
所以操作系统在加载用户程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为用来保存程序的页目录和页表分配物理内存。
✨两级页表的地址转换
下⾯以一个逻辑地址为例。将逻辑地址( 0000000000,0000000001,11111111111 )转换为物 理地址的过程:
1. 在32位处理器中,采用4KB的页大小,则虚拟地址中 低12位 为 页偏移,剩下⾼20位给 页表,分成 两级,每个级别占10个bit(10+10)。
2. CR3 寄存器 读取 页目录起始地址,再根据一级页号 查目录表,找到下一级页表 在物理内存中存放位置。
3. 根据二级页号 查表,找到最终想要访问的内存块号。
4. 结合页内偏移量得到物理地址。
5. 注:一个 物理页 的地址一定是 4KB 对齐的(最后的 12 位全部为 0 ),所以其实只需要记录物理页 地址的 高20位即可。
6. 以上其实就是 MMU 的工作流程。MMU(Memory Manage Unit)是⼀种硬件电路,其速度很快,主要工作是进行内存管理,地址转换只是它承接的业务之一。
到这里其实还有个问题,MMU要先进行两次 页表 查询 确定物理地址,在确认了权限等问题后,MMU 再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。那么当 页表 变为N级时, 就变成了N次检索+1次读写。可见,页表级数越多查询的步骤越多,对于CPU来说等待时间越长,效率越低。
让我们现在总结一下:单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。
有没有提升效率的办法呢?【TLB缓存历史记录,虚拟地址到物理地址的缓存记录】 MMU 引入了新武器 TLB (其实,就是缓存) 当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有【代码会有循环,重复循环多的话,就能直接从缓存里拿数据,加速CPU的寻址】,如果有就直接拿到物理地址发到 总线给内存,齐活。但 TLB 容量比较小,难免发生 Cache Miss【缓存未命中】 ,这时候 MMU 还有保底的老武器 页表,在 页表 中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录一下刷新缓存。
🌠如何理解进程划分资源给线程?
可执行程序在磁盘 加载到 物理内存时,1.可执行程序内部使用的虚拟地址全部都知道;2.它的每一行指令在 物理内存 里一定会存在它所对应的起始 物理地址;所以对于任何一个 代码/数据 内部用的是 虚拟地址,外部用的是 物理地址,一旦加载了它的 起始物理地址 也就知道了。代码进行加载时,进程的虚拟地址空间当中,就已经加载到了 可执行程序的 入口/开始地址 和 结尾地址,一旦它的 虚拟地址 和 物理地址 都知道了,此时页表就可以构建出 虚拟地址 到 物理内存 之间的映射;此时 磁盘上的 可执行程序的 入口地址 就可以直接加载到 CPU 里面的 EIP寄存器 同时 页表的起始地址 就放到了 CPU 里的 CR3寄存器里,从此 CPU 就可以调度这个进程,CPU 根据 EIP找到 指令的地址空间【取/分析指令】--> 找到页表 --> CR3【找到页表起始地址】--> 根据页表转化成物理地址 --> 一次读取全部的指令。即 虚拟地址 到 物理地址 之间的转换时,页表的映射关系已经在加载的时候,进行关联了。
实际上 页表 里面根本就 不需要 存储虚拟地址,因为 给我的任何的 虚拟地址,直接根据虚拟地址就可以查到对应的物理地址【虚拟地址的前十位查 页目录,再十位 查页表,最后 12位 偏移量 找到物理地址[任意一个字节]】。
OS 内有一个全局变量【*current 永远都会指向当前正在运行的进程,再必要时 *current 指针的内容会被转到CPU的寄存器里[存储当前进程的起始地址]】,CPU在调度某个进程的时候,会把 *current指针的内容全部放到寄存器里面;当进程在切换时,*current指针就指向对应的进程,CPU就会进行调度,所以CPU永远都知道当前正在调度的进程是谁。
CPU里的 CR3寄存器 存储的是整个进程自己对应的 页目录 起始地址;EIP寄存器 存的是当前进程的入口虚拟地址;IR寄存器 存的是具体的指令;
进程切换 无非就是 把CPU里的寄存器【寄存器、CR3、EIP】的值却换到下一个进程的值,地址空间和页表就都换了【CPU内部的寄存器属于进程上下文】;
CPU 和 物理内存 两个都是在主板上的,CPU 和 物理内存 通过 系统总线【系统总线是被复用的既是数据总线又是地址总线】 进行连接;
在CPU内部集成了一个硬件单元 MMU内存管理单元,虚拟地址到物理地址转换的前提条件1.知道当前进程要转化的虚拟地址是谁,2. 知道当前进程所对应的页目录起始地址,经过MMU转化出来的就是物理地址 ---- 在硬件上完成 虚拟地址到物理地址的转换过程。MMU地址转化的过程是硬件完成的,所以它就不能复杂;
地址转换为什么非得靠硬件呢?软件不行吗?【软件可以,进行2次索引】软件太慢了,整个计算机里面有许多进程,要做非常多次的 虚拟地址到物理地址的转换,拿软件做 整个计算机的效率就会降低,软件要进行IO交互,硬件不需要 IO。
MMU得到的物理地址 就会放入到 系统总线上,开始在物理内存上进行寻址。物理内存里面也存在一个寄存器【地址】 和 寄存器【操作】;物理地址 放到总线上经过流动就会放到 物理内存的寄存器【地址】上,所以 CPU 就拿到了想要访问的 物理地址,接着 把要编辑的指令放到 寄存器【操作】上,此时 CPU 就把 自己的操作需求交给了物理内存,物理内存就把CPU所要的虚拟地址 再通过总线给 CPU的 IR寄存器,CPU 的 EIP【入口虚拟地址】+ 历史命令的长度【old cmd长度】,此时 EIP 就自动更新到物理内存上的下一条指令,接着循环这个步骤。
如何理解进程划分资源给线程?代码如何划分?需要刻意做吗?
• pthread_create() 里的 run函数 会被编译成 一份代码,每一个 run函数【代码块】地址都是以绝对编址从0到全F 编好的,此时就意味着 一个线程一旦执行 run函数 ,天然就拥有了 run函数 的一批虚拟地址,相当于每一个线程 分别拥有 对应的虚拟地址,在查 页表 时,就是 每个线程 在 查页表 的一部分,此时资源就分开了;所以进程不用可以去区分 线程的代码分别是谁的,只需要让不同的线程未来去执行 不同的入口函数。
• Linux在用户层只需要给 LWP 指定一个 新的地址,函数直接编址在代码块里,每一个函数都有各自的虚拟地址范围,哪个线程进入哪个函数,此时对应的线程就执行对应的代码,而且 页表 还是共享的。
• Linux系统里不需要为线程单独再设计TCB来管理线程,Linux当中只有轻量级进程。
🌠页表究竟是什么?
页表不仅仅是填充下个页的地址和物理地址页框的地址,还要 标记位,例如 是否 命中【页框在不在内存里,若不在就从外设把数据加载进来 虚拟地址到物理地址重新建立映射关系】;读写权限【数据区:RW;代码区:R】。
char *msg = "hello bit"
*msg = 'H'
上层语言【在代码区】在写的时候,当我们在尝试修改时,为什么直接报错?当进行 虚拟地址到物理地址 之间的转化的时候,查页表 虚拟地址是没问题的,映射的字符常量区是只读权限【代码区】,但是 修改的这个操作 是想 写入【W】,所以 MMU 在转化的时候,就会发现你要对一个只具有读权限的 进行写入了,因而 MMU就发生了报错,CPU 就知道有内部错误了,所以OS就把当前的错误转化成信号,根据寄存器找到当前进程,修改比特位,发送信号,再调度这个进程 可能遇到 从内核到用户 进行切换,查信号 进程崩溃。【软中断】
究竟什么是页表?unsigned long类型的数组
typedef struct {unsigned long pte;} pte_t; // 页表项
typedef struct {unsigned long pgd;} pgd_t; // 页全局目录项
虚拟地址的后12位 比特位,就是标记位。
页目录不需要标记位,页表需要标记位,刚好用 后12个比特位 做页框,所以每一个页框的标记位是什么就清楚了。这个标记位是在什么时候被设定的?任何一个页框加载进来的时候,对应的标记位就被设置好了,所以可执行程序里 就必须把 代码段和数据段 划分好【ELF】。
代码区只有【R】权限,当我们执意要把代码区中的【h --改--> H】,CPU 拿到了这个操作在内部做虚拟到物理地址的转化,MMU 经过 页表 查找时 发现 页表是 只读的,MMU 就报错,一旦报错,OS就会知道,把 MMU 报错 转化成 信号,发送进程,就被干掉。操作系统 怎么知道转化失败了?MMU + TLB = CPU ---> 出错了 ---> 软中断!中断向量表,内部就直接预设,一旦触发这种错误,就给目标进程发信号,所以一旦发生错误 硬件上 就会直接转到中断逻辑,执行 操作系统代码,向目标进程发送中断。
✨缺页异常
我们要访问一部分代码和数据,可能对应的代码数据只有一部分加载到内存,并不需要把可执行程序全部的代码数据 加载到内存【不需要把页表直接构建好】,例如:代码有10万行,而我只需要执行前1万行,就把前1万行加载到内存里,此时就填充页表构建虚拟到物理地址的映射关系,当我们访问完这 1万行 代码后,剩下的9万行并不在内存里,虚拟地址空间认为是10万行,但是页表只构建 1万行 的映射,当我们想要访问 1万行后的代码时,此时 我们在查 页表 的时候 发现 页表上没有【不命中,要访问的地址并不在内存里】MMU 虚拟地址到物理地址 转换失败!CPU 内部 出现错误,自动形成软中断,OS 给中断向量表中出现错误的中断号 进行加载,此时 进一步 把外设上所需要的数据 重新加载 分配物理内存 构建映射关系 填写 虚拟地址和物理地址的映射关系,再再把是否命中 改为 是,重新执行 没有发生错误前的 指令,就继续可以运行程序了。整个过程对CPU来说,完全是透明的,这个过程因为没有命中,而要求 OS 通过 软中断 执行 新加载逻辑 --- 缺页中断!
设想,CPU 给 MMU 的虚拟地址,在 TLB 和 页表 都没有找到对应的 物理页,该怎么办呢?其实这就是 缺页异常 Page Fault ,它是⼀个由 硬件中断 触发的可以由 软件逻辑纠正的错误。 假如目标 内存页 在 物理内存 中没有对应的 物理页 或者 存在但无对应权限,CPU 就无法获取数据,这种情况下 CPU 就会报告一个 缺页错误。由于 CPU 没有数据就无法进行计算,CPU 罢工了用户进程也就出现了 缺页中断,进程会从 用户态 切换到 内核态,并将 缺页中断 交给内核的 Page Fault Handler 处理。
缺页中断会交给 PageFaultHandler 处理,其根据 缺页中断 的不同类型会进行不同的处理:
• Hard Page Fault 也被称为 Major Page Fault ,翻译为 硬缺页错误/主要缺页错误,这时 物理内存 中没有对应的物理页,需要CPU打开磁盘设备读取到物理内存中,再让MMU建立 虚拟地址 和 物理地址 的映射。
• Soft Page Fault 也被称为 Minor Page Fault ,翻译为 软缺页错误/次要缺页错误,这时 物理内存 中是存在对应物理页的,只不过可能是其他进程调入的,发出缺页异常的进程不知道 而已,此时 MMU 只需要建立 映射 即可,无需从磁盘读取写入内存,一般出现在多进程共享内存区域。
• Invalid Page Fault 翻译为 无效缺页错误,比如进程访问的内存地址越界访问,又比如 对空指针解引用 内核 就会报 segment fault 错误中断进程直接挂掉。
✨虚拟地址和物理地址是如何转换的?(32位下)
• 物理内存是以4KB为单位进行管理的,一个页表的容量根本不能把所有的物理内存放下,所以我们把页表进行拆分,页目录【1024项,每个页目录里面指向的是页表,页目录里面的内容为页目录表项】,页表【页表里面的内容为页表项】;
• 拆成多个页表有什么用呢?我们有1024张页表,我们每一个所对应的页表的大小都是1024byte,页表里面的页表项占4byte,所以整个页表最大变成了 1024 * 1024 * 4 = 4MB,内存管理的基本单位是4KB,所以页表项里只要指向物理内存特定4KB的起始地址就可以了,这样的话,我们的页表项就由刚刚的 16GB 缩短为 4MB ,就能够把虚拟地址全部转换为物理地址;
• 我们自己的可执行程序都有自己的编址,我们对应的代码当中所有的编址都是绝对编址的,虚拟地址每一个比特位就是 32 个,对应的虚拟地址是怎么转换到物理地址的你呢?如果我们使用的是一个具体的虚拟地址,它一定表示要访问某一个字节的地址;
• 虚拟地址的 前10个比特位 来查第一级页目录,接着的后 10个比特位 来查对应的页表,每一个进程的页目录只需要1024个,页目录所对应的虚拟地址的 前10位 就是页目录的下标【大O(1)】,一旦我们拿这个下标作为索引找到这个页表的时候,接着的后 10个比特位 做页表的索引 找到 页表项;所以最终我们用一个虚拟地址的 前20个比特位 就一定能找到该虚拟地址未来要转化到的物理内存的页框地址,即 页表项 里面填的不是某一个字节的地址,指向的是 物理内存 4KB 页框的起始地址;
• 映射到页框之后,如何找到具体的物理地址?虚拟地址 剩下的后 12位【2^12=4096】,后12位 不一定全零/全1,所以一旦找到页框地址再根据后 12位,在页框内【后 12位地址 本质是该页框内的某一个字节的起始地址,它是一个偏移量!!】;
• 所以虚拟地址在查页表时,查前20个对应的页目录和页表 找到对应的页框,再根据虚拟地址的 后12位 做偏移量,就一定能找到页框内的某一个字节的地址!!
• 当我们已经找到一个字节的偏移量了,当我们要访问多个字节如何处理?当我们在学语言的时候,需要给任意 变量/对象 都要有类型,我们拿着起始地址,从该地址连续读取4个字节【int】此时 数据就能被识别了,所以虚拟地址到物理地址的转化并不是真正映射到物理地址,而是映射到该虚拟地址所对应的物理地址的起始页框,然后拿虚拟地址的后12位做页内偏移,找到对应的物理地址。
• 虚拟地址和物理地址没有关系,磁盘上的可执行程序,在磁盘上是连续编址的,所以凡是放在一个页框里,大家所对应的虚拟地址全都是连续的,物理地址也是连续的,只要在4KB 内,虚拟和物理地址都是连续的!!虚拟地址是连续的,所以用虚拟地址的后12位做偏移量再好不过了。
• 我们怎么知道这页表所指向的 物理内存 页框page 当前是否 被占用/还没有被申请 了?拿着页表项当中的 页框起始地址/4KB = struct page mem_map[N] 所对应的下标,得到下标去索引page,查 flag 是否被使用!【mem_map[N]数组下标 和 页框物理地址 可以互相转换,所以进程的物理内存哪些 被使用/未被使用 自己都一清二楚】。
• 页表可以找到物理内存当中的任意一个页框,加上虚拟地址的页内偏移,就可以找到物理内存的任意一个地址;进程 不可能 使用物理地址的全部地址【物理内存还存有OS的代码和数据】,这就意味着 页表一定是不完整的! 页表远远小于4KMB。
• 可执行程序是如何加载和运行的?磁盘是的可执行程序ELF本来就是4KB的,所以ELF要映射/加载 这个程序加载到物理内存时 查对应的 struct page mem_map[N]数组,申请对应的块,把ELF块加载到物理内存上,与此同时,要构建PCB地址空间页表项,页表在构建时,EIL加载完成,ELF每一个块所对应的页框都知道了 ,根据页框填充页表,此时就构建 页目录,根据页表填充页目录 ,构建虚拟地址和物理地址的映射关系,进程和物理内存块就建立好了,页目录 索引时 拿虚拟地址来索引,页表项里面的内容 填的是页框的地址,所以一个进程所有的代码块就全能找到,物理内存的数据块天的是代码,代码内部有自己的虚拟地址,所以这个程序再把自己的入口函数 main函数地址,加载到CPU的esp寄存器,在CPU内部把虚拟地址经过 MMU【硬件】查表 页目录 -> 页表项 -> 页框 页内偏移,找到入口地址,把内容读到CPU开始执行/解析。
• 页目录怎么让进程找到呢?实际上我们不通过虚拟地址里查 页目录 的地址,CPU里面有一个 CR3寄存器 保存当前进程的 页目录 的起始地址。
🌠深刻理解
• 🪄 如何理解 new 和 malloc ?
不用分配物理内存,只需要申请虚拟地址空间,对堆区 vm_area_struct 的 start 进行扩容 把数字++,然后把页表的关系维护起来,只不过 页表 对应的 是否命中 为零,物理地址【页框】为 全0,所以 本质就是在 虚拟地址上划分好,当要使用时,才会进行缺页中断!
• 🪄如何理解 写时拷贝 ?
刚开始创建子进程,父子进程的代码和数据都是共享的,即 页表 是共享的,所以写时拷贝只需要 将页表里面的 读写权限 数据区【本来 页表 里面的 数据区 是RW】改成 只读,当子进程 尝试去做写入时,也会发生 缺页中断,申请内存,OS 把对应的变量和数据 重新拷贝一份。
写时拷贝 是按照4KB为单位的,以空间来换时间。
• 🪄申请内存,究竟是在干什么?
申请虚拟地址空间 和 填充修改页表。【由OS自主决定】
把 用户进程 和 内存管理 进行 解耦 了。
• 🪄如何区分是缺页了,还是真的越界了?
问题:越界了一定会保存吗?
1. 页号合法性检查:OS 在处理中断或异常时,首先检查触发事件的虚拟地址的页号是否合法。如果页号合法但页面不在内存中,则为缺页中断;如果页号非法,则为越界访问。
2. 内存映射检查:OS 还可以检查触发事件的虚拟地址 是否 在当前进程的内存映射范围内, 如果地址在映射范围内 但页面不在内存中,则为缺页中断;如果地址不在映射范围内,则为越界访问。
即 每一个进程的 代码区和数据区 加载的时候 虚拟地址范围 都是不一样的;当一个进程给分配的代码区为 4-8KB,你非得拿它指向12KB 此时就是越界【异常】;当一个进程给分配的代码区为 10-20KB,你要访问 15KB,当前页表的映射关系并不在,即为 缺页中断。
• 🪄线程资源划分的真相:只要将虚拟地址空间进行划分,进程资源就天然被划分好了。
三、线程优缺点 && 用途
✨线程的优点
• 创建一个新线程的代价要比创建一个新进程小得多;
进程要牵扯许多的数据结构和加载【PCB、地址空间、构建页表、加载程序构建映射关系、进程放入对应的地址空间当中、放到调度队列里面、打开默认的标准输入/输出/错误各种文件】,线程的创建 只需要 一个 TCB。
•🚩 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多:
◦ 最主要的区别是线程的切换 虚拟内存空间依然是相同的【CR3不变】,但是进程切换是不同的。1. 线程切换时,CPU 里的 MMU 中的 TLB 【会缓存 虚拟到物理地址最高频的映射关系】不需要更新,而进程切换要更新加载TLB。
2. 每一个CPU内部都有自己的 cache【缓存 代码和数据块】,即 CPU 找到 物理地址 是先拿着 物理地址 去查 cache,在 cache 查到了就把对应的代码/数据放入CPU对应的 IR寄存器 中。查 cache 没有找到就去地址总线 访问物理内存,就会把物理内存当中的块级别的数据导入到 cache 里【只访问一个 10字节 的数据,就会把正在访问 10字节 附近的 4KB 数据全部导入】,加速 OS 给 未指令的速度【代码和数据进行缓存】。进程间切换,cache就会失效,而线程不会失效,线程的代码和数据都能共享【共享地址空间】。
🌠 cache到多余的指令,有什么意义?
有无Miss到为概率问题,访问到的概率非常大。有 较大概率 正在访问附近的代码数据 --- 局部性原理。cache本质是一种缓存机制 ---> 预加载。
这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
◦ 另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有⼀个显著的区别是当你改变虚 拟内存空间的时候,处理的⻚表缓冲 TLB (快表)会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件cache。
• 线程占用的资源要比进程少很:线程所需要的资源都是从进程里拿的。
线程 && 进程 都有:
• 能充分利用多处理器的可并行数量;
• 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务【多线程】
• 【对数据进行计算---使用CPU资源】计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现:
线程在计算密集型应用中,线程太多CPU就会从 计算问题 转换成 调度问题,效率就会降低,所以 CPU的物理个数 * 核数 = 线程在计算密集型应用中 线程创建最合适的个数。
• I/O密集型应⽤,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
✨线程的缺点
• 性能损失:线程过多,切换需要成本,相当于进程调度。
• 健壮性降低:
在多线程里大部分资源是共享的,一旦共享就容易起冲突,定义的全局变量能被多个线程访问到,可能对于一个指针/变量 进行修改,一个线程的修改 会影响 其他线程。换句话说线程之间是缺乏保护的。
• 缺乏访问控制
◦ 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
• 编程难度提高【同步互斥】
◦ 编写与调试一个多线程程序比单线程程序困难得多。
✨线程异常
• 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃;
• 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程 终止,该进程内的所有线程也就随即退出;【在OS看来,信号发送是以进程为载体的】
✨线程用途
• 合理的使用多线程,能提高CPU密集型程序的执行效率;
• 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。
四、Linux进程 VS 线程
• 所有空间的 task_struct 不在是进程 而是 执行流,进程是 task_struct + mm_struct + vm_area_struct_struct list + 页表 (+ 代码和数据);线程 就是许多 task_struct 中的其中一个;
• 前几篇文章里面我们所学的,是进程,只不过进程内,只有一个执行分支,即内部只有一个线程!
• 进程创建;线程划分;
• task_struct <= 进程,Linux执行流,统称为:轻量级进程(LWP);
• Linux中没有真正意义上的线程,存在Linux的线程概念,是用LWP进行模拟实现的!
#include <iostream> #include <unistd.h> #include <pthread.h>// 新线程 void *run(void *args) {while(true){std::cout << "new thread" << ",pid: " << getpid() << std::endl;sleep(1);}return nullptr; }int main() {std::cout << "我是一个进程:" << getpid() << std::endl;pthread_t id;pthread_create(&id, nullptr, run, (void*)"thread-1");// 主线程while(true){std::cout << "main thread" << ",pid: " << getpid() << std::endl;sleep(1);}return 0; }
• 在 OS 内,CPU 真实调度 区分执行流唯一性 是 看LWP!
✨进程和线程
进程是承担资源分配的基本实体,一旦创建进程,地址空间、页表、数据和代码加载建立映射关系...,先创建进程再创建线程。
🪄进程是资源分配的基本单位;
🪄 线程是调度的基本单位;
🪄 线程共享进程数据,但也拥有自己的一部分数据:
◦ 线程ID
◦ 一组寄存器【线程独立的上下文数据】
◦ 栈【独立的栈结构,线程里面也可以调用函数,函数调用就要申请栈空间,所以线程的栈是独立的,如果栈是共享的就会很混乱】
◦ errno
◦ 信号屏蔽字
◦ 调度优先级
✨进程的多个线程共享
同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
• 文件描述符表;
• 每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数);
• 当前工作目录;
• 用户id 和 组id;
进程和线程的关系如下图:
✨关于进程线程的问题
之前学习的单进程就是具有一个线程执行流的进程。
如若对你有帮助,记得关注、收藏、点赞哦~ 您的支持是我最大的动力🌹🌹🌹🌹!!!
若有误,望各位,在评论区留言或者私信我 指点迷津!!!谢谢 ヾ(≧▽≦*)o \( •̀ ω •́ )/