线程概念与控制(上)
本篇目标1.认识什么是线程以及与进程的区别2.理解地址空间和资源划分的关系3.加深对页表的认识3.比较线程和进程一.Linux线程概念1.概念通过之前对进程的学习我们知道进程是内核数据结构代码和数据构成那么线程其实就是进程内部的一个执行流。如果我们从内核和资源的角度那么进程就是承担分配系统资源的基本实体线程是CPU调度的基本单位。接下来让我由进程过渡到进程先看图解释我们知道创建一个进程后操作系统会为这个进程创建对应的task_struct虚拟地址空间当进程想要访问资源的时候就可以通过页表来建立由虚拟地址到物理地址的转化也就是说进程访问的大部分资源都是通过地址空间访问的地址空间是进程访问资源的窗口。而在操作系统中许多的进程都有自己的地址空间这也就保证了许多进程看到的地址空间不同所以对应的资源也就不同那如果我们又创建一个‘’进程‘’共享这个地址空间呢如图所示解释也就是说我们将资源分配给了不同的task_struct我们就可以用进程模拟出线程了。可能这时有人会有疑惑那我们之前学的进程呢这个‘’进程‘’和我们之前学的进程难道不一样吗其实是不一样的我有说进程就是tast_struct吗进程难道不是内核数据结构代码和数据可不单单是task_struct啊所以我们将这个tast_struct称为线程也就是说我们之前学的是内部只有一个线程的进程。结论1.Linux线程可以用进程来模拟2.对资源的划分本质是对地址空间虚拟地址的划分虚拟地址就是资源的代表3.在Linux系统中在CPU眼中看到的PCB都要比传统的进程更加轻量化称为轻量级进程。2.初步理解那么这时有人会有疑惑其他的平台比如windows也是这样设计的吗有没有具体的实现方案答案在linux系统下的PCB在windows下称为TCB那么线程在内核要不要管理是不是先描述在组织啊是不是要创建对应的数据结构然后描述创建线程对象组织放进调度队列调度CPU运行/切换但是这样岂不是很复杂吗是的但是在Linux中是不是没有这个必要单独创建线程的内核数据结构啊可以直接复用task_struct用‘’进程‘’来模拟线程复用进程内核代码所以在linux下是不用改变调度结构和调度算法的这样就会设计的更加健壮。所以在Linux中线程就是轻量级进程或者由轻量级进程模拟的。所以这里我们就可以知道操作系统和具体的操作系统的概念了操作系统是抽象的设计模型而Linux / Windows 是根据提供的思想而实现出的具体工程。二.分页式存储管理1.物理内存管理通过我们之前对磁盘的了解我们可以知道粗略的知道磁盘在文件系统管理下通常以 4KB 作为数据块block单位进行组织和分配那么我们的可执行程序也是文件文件就在磁盘上存储那么可执行程序在存储时无论是属性还是内容天然就是4KB为单位存储的。而我们的物理内存其实也是划分为4KB的内存块将这个称为页框假设⼀个可用的物理内存有 4GB 的空间按照⼀个页框的⼤小 4KB 进⾏划分4GB 的空间就是4GB/4KB 1048576 个页框。有这么多的物理⻚操作系统肯定是要将其管理起来的也就是说操作系统需要知道哪些页正在被使用哪些页框空闲等等是不是就要先管理在组织啊所以内核用struct page结构表示系统中的每个物理页而出于节省内存的考虑 struct page 中使用了⼤量的联合体union如简化代码所示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 / slub */ union { struct list_head slab_list; /* uses lru */ struct { /* Partial pages */ struct page *next; #ifdef CONFIG_64BIT int pages; /* Nr of pages left */ int pobjects; /* Approximate count */ #else short int pages; short int pobjects; #endif }; }; struct kmem_cache *slab_cache; /* not slob */ void *freelist; /* first free object */ union { void *s_mem; /* slab: first object */ unsigned long counters; /* SLUB */ struct { /* SLUB */ unsigned inuse : 16; /* 已使用对象数 */ unsigned objects : 15; /* 总对象数 */ unsigned frozen : 1; }; }; }; }; union { /* * 页是否被映射等信息 */ atomic_t _mapcount; unsigned int page_type; unsigned int active; /* SLAB */ int units; /* SLOB */ }; #ifdef WANT_PAGE_VIRTUAL /* * 内核虚拟地址高端内存可能为 NULL */ void *virtual; #endif };其中重要的就是flags是为了存放页的状态。然后我们可以粗略地认为操作系统维护了一个struct page mem[]数组数组中的每一个元素都用来描述一个物理页框。这样一来对物理页框的管理就可以转化为对数组元素的管理。每个页框都可以通过数组下标进行编号再根据页框大小计算出该页框的起始物理地址最终物理地址 页框起始地址 页内偏移当然这个页内偏移的获得后面会讲的。通过上面的理解我们就可以清楚的知道了物理内存和磁盘之间常常可以以 4KB 为单位进行 I/O 交互如图所示系统中的每个物理页都要分配⼀ 个这样的结构体让我们来算算对所有这些页都这么做到底要消耗掉多少内存系统中每个物理页框都需要一个struct page来描述。如果系统有 4GB 物理内存并且页框大小为 4KB那么一共有1048576 个物理页框。假设每个struct page占用 40 字节那么所有struct page总共占用约40MB 内存。相对于 4GB 物理内存来说这个开销大约不到 1%因此这个管理成本是可以接受的。页的大小对于内存利用率和系统开销非常重要。如果页太大那么一个页中可能会有较多未被使用的空间从而造成页内碎片。如果页太小虽然可以减少页内碎片但是物理页和虚拟页的数量会变多页表也会变大从而占用更多内存。同时系统进行地址转换和页管理的次数也会增加带来额外开销。因此页的大小需要在内存利用率和管理成本之间进行折中。常见的 Windows/Linux 系统中页框大小通常为4KB。2.页表2.1.为什么要页表如果没有虚拟内存和分页机制时用户程序必须在物理内存中占用一整块连续空间这会让内存分配变得很困难空闲页框可能是零散的不一定能凑出一段连续的大空间例如假设物理内存被分成 8 个页框每个页框 4KB物理内存 页框0 页框1 页框2 页框3 页框4 页框5 页框6 页框7 [空闲] [已用] [空闲] [已用] [空闲] [空闲] [已用] [空闲]现在有一个程序要运行需要12KB 内存。12KB 3 × 4KB所以它需要3 个页框。没有分页机制时如果要求程序在物理内存中必须连续存放那么它必须找到连续 3 个空闲页框需要 [空闲][空闲][空闲]但是现在物理内存里空闲页框是页框0、页框2、页框4、页框5、页框7总共有 5 个空闲页框也就是5 × 4KB 20KB总空间明明够。但是连续的最多只有页框4、页框5 [空闲][空闲]只有 2 个连续页框不够 3 个。所以结果是总空闲内存够但因为没有连续 12KB 空间程序放不进去。怎么办呢我们希望操作系统提供给用户的空间必须是连续的但是物理内存最好不要连续此时虚拟内存和分页便出现了如下图所示结论我们可以通过页表便能把连续的虚拟内存映射到若干个不连续的物理内存页。2.2.重谈页表我们之前谈页表的时候经常说页表用于建立虚拟地址到物理地址之间的映射。但问题是它真的只是一张简单的“页表”吗我们可以先来算一笔账。假设按照 4GB 的地址空间来计算如果我们不考虑页表项中权限位、存在位等额外信息只简单地认为一条映射关系需要保存 4 字节的虚拟地址和 4 字节的物理地址那么一条映射关系就需要 8 字节。如果真的要把整个 4GB 地址空间中的每一个地址都建立映射关系那么页表所需要的空间将会非常夸张甚至远远超过普通系统能够承受的范围。显然这种方式是不现实的。所以我们之前谈页表时经常说页表用于完成虚拟地址到物理地址之间的映射。但实际上在 32 位平台下并不是整个虚拟地址都直接拿去映射物理地址。在常见的 32 位分页机制中一个虚拟地址共 32 位会被拆成三部分32 位虚拟地址 高 10 位 中间 10 位 低 12 位 页目录索引 页表索引 页内偏移其中高 10 位可以表示 2^10 1024 个页目录项 中间 10 位可以表示 2^10 1024 个页表项 低 12 位可以表示 2^12 4096 字节也就是 4KB 页内偏移也就是说一个页目录中最多有 1024 个页目录项每个页目录项可以找到一张下一级页表而每张页表中又有1024 个页表项每个页表项可以对应一个 4KB 的物理页框。但是我们要通过虚拟地址访问到具体的字节啊所以我们通过页表有了要访问的页框的起始地址后再加上虚拟地址的低12位(业内偏移)就可以访问具体的字节了。所以地址转换过程大致如下虚拟地址高 10 位 ↓ 在页目录中找到对应的页目录项 PDE ↓ PDE 中保存下一级页表的起始地址 ↓ 虚拟地址中间 10 位 ↓ 在页表中找到对应的页表项 PTE ↓ PTE 中保存物理页框的起始地址 ↓ 加上低 12 位页内偏移 ↓ 得到最终物理地址因此页表并不是直接保存每一个虚拟地址到物理地址的映射而是通过页目录 页表 页内偏移来完成虚拟地址到物理地址的转换。流程图图1图2但是这里有个问题虚拟地址高 10 位只是告诉我们“页目录中的第几项”不能告诉你“页目录在哪里啊就好像我们知道下标 5但是我们可以能找到arr[5]吗不能我们还必须知道arr 数组从哪里开始所以arr 的起始地址 下标 5才能找到arr[5]。那么页目录在哪里CPU 怎么知道页目录的起始地址其实这个页目录的起始物理地址就保存在 CPU 的CR3 寄存器中也就是说CR3 寄存器保存的是当前进程页目录表的起始地址。当 CPU 要进行虚拟地址到物理地址的转换时会先从 CR3 中拿到页目录的起始地址然后根据虚拟地址的高 10 位找到对应的页目录项再根据页目录项找到下一级页表最后利用中间 10 位找到页表项得到物理页框的起始地址再加上低 12 位页内偏移最终得到物理地址。但是到这⾥其实还有个问题MMU要先进行两次页表查询确定物理地址在确认了权限等问题后MMU再 将这个物理地址发送到总线内存收到之后开始读取对应地址的数据并返回。那么当⻚表变为N级时 就变成了N次检索1次读写。可⻅⻚表级数越多查询的步骤越多对于CPU来说等待时间越长效率 越低。 让我们现在总结⼀下单级页表对连续内存要求高于是引入了多级页表但是多级页表也是⼀把双 刃剑在减少连续存储要求且减少存储空间的同时降低了查询效率。有没有提升效率的办法呢计算机科学中的所有问题都可以通过添加⼀个中间层来解决。MMU引入 了新武器江湖⼈称快表的TLB 其实就是缓存TranslationLookasideBuffer学名转译后备 缓冲器当 CPU 给 MMU 传新虚拟地址之后 MMU先去问 TLB 那边有没有如果有就直接拿到物理地址发到总线给内存但是 TLB 容量⽐较⼩难免发⽣ Cache Mis这时候 MMU 还有保底的⽼武器页表 所以在⻚表中找到之后除了把地址发到总线传给内存还把这条映射关系给到TLB让它记录 ⼀下刷新缓存流程图3.细节补充1.通过对上面页表的认识我们能否加深对缺页中断的理解呢当我们的 CPU 想要获得数据通过 MMU 完成由虚拟地址到物理地址的转换时发生失败然后触发缺页异常发现页表中对应的页表项无效或未映射。那么这个虚拟地址是否合法是通过操作系统维护的虚拟地址空间范围VMA来判断的而不是仅仅通过页表判断。如果虚拟地址是合法的那么说明该地址属于进程的地址空间但当前并没有对应的物理页框映射。此时就会触发缺页处理操作系统会从物理内存中申请一个空闲页框查看物理内存中未被使用的页框获得该页框的物理地址并建立新的页表映射关系。流程图2.写时拷贝缺页中断内存申请等等背后可能都要重新建立新的页框和建立映射关系3.为什么是虚拟地址的低12位答案因为页框大小为 4KB2¹²所以虚拟地址的低 12 位可以用于表示页内偏移这一部分在地址转换过程中不会改变只用于在物理页框内部定位具体字节。4.如何区分是缺页了还是真的越界了⼀个问题越界了⼀定会报错吗比如有这么个代码int arr[10]{0}; for(int i0;i10;i) { arr[i]i; }我们可能以前就已经发现了即使我们已经越界访问了但是却不一定会崩溃连操作系统都不知道我们的debug越界因为arr[10] 对应的地址 ≠ 一定非法可能越界了但是“访问了其他的合法内存”结论在发生缺页异常时操作系统首先检查触发访问的虚拟地址是否落在当前进程的 vm_area_struct 管理的虚拟地址空间范围内。如果在该范围内但对应物理页未驻留内存则属于缺页中断如果不在任何合法的虚拟内存区域内则属于非法访问越界访问操作系统会终止进程。总结所以通过对页表和线程的理解我们就可以知道执行流在看到资源时本质上是在合法的前提下拥有多少可用的虚拟地址空间。虚拟地址本身就是资源的抽象表示。虚拟地址空间的管理结构例如mm_struct和vm_area_struct本质上是在对进程的地址空间进行统计、划分与组织用来描述“资源整体分布情况”。从这个角度来看资源的划分本质上就是地址空间的划分而资源的共享本质上就是地址空间的共享或部分地址空间的共享。三.线程和进程的比较1.线程优点线程的优点• 创建⼀个新线程的代价要比创建⼀个新进程小得多• 与进程之间的切换相比线程之间的切换需要操作系统做的⼯作要少很多• 线程占⽤的资源要比进程少• 能充分利用多处理器的可并行数量• 在等待慢速I/O操作结束的同时程序可执行其他的计算任务•计算密集型应用为了能在多处理器系统上运⾏将计算分解到多个线程中实现大概意思用多线程吃满CPU多核能力为了能在多处理器系统上运行将计算分解到多个线程中例如任务计算 1~1亿的和 线程11~2500万 线程22500万~5000万 线程35000万~7500万 线程47500万~1亿•I/O密集型应用为了提⾼性能将I/O操作重叠。线程可以同时等待不同的I/O操作。大概意思用多线程隐藏等待时间提高CPU利用率例如单线程读文件 → 等待IO → CPU空转 → 再处理多线程 线程1等磁盘A 线程2等磁盘B 线程3等网络请求但是这些都不是线程比进程最大的优点最主要的区别是线程切换时虚拟内存空间是相同的而进程切换时虚拟内存空间是不同的。这两种上下文切换的处理都是由操作系统内核完成的。在内核进行上下文切换的过程中最显著的性能开销来自于寄存器上下文的保存与恢复也就是将当前 CPU 寄存器中的内容保存到内存中并加载新任务的寄存器状态。另外一个隐藏的性能损耗是缓存失效问题。上下文切换会扰乱处理器的缓存机制使得原本缓存的内存访问局部性被破坏导致 cache 命中率下降。更重要的一点是当发生进程切换时由于虚拟地址空间发生变化处理器中的TLB快表通常需要被刷新或部分失效这会导致一段时间内内存访问效率显著下降。而在线程切换中由于多个线程共享同一个虚拟地址空间因此不会发生 TLB 的整体失效问题通常无需完全刷新同时 cache 复用性也更好因此开销相对更小。2.线程的缺点1. 性能损失◦ ⼀个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同⼀个处理器。如果计算密集型线程的数量比可用的处理器多那么可能会有较⼤的性能损失这里的性能损失指的是增加了额外的同步和调度开销⽽可用的资源不变。例如假设CPU只有4核 但你开了10个计算线程结果10个线程抢4个CPU线程不断切换不是“算得更快”而是CPU没有变多计算能力没增加但是多了调度开销开销包括线程切换上下文保存/恢复调度器调度cache/TLB扰动2. 健壮性降低◦ 编写多线程需要更全⾯更深⼊的考虑在⼀个多线程程序⾥因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的换句话说线程之间是缺乏保护 的。例如线程A改了变量 线程B同时在读 → 数据错乱3.缺乏访问控制◦ 进程是访问控制的基本粒度在⼀个线程中调用某些OS函数会对整个进程造成影响。4. 编程难度提高◦ 编写与调试⼀个多线程程序⽐单线程程序困难得多总结在操作系统中进程本质上是资源分配的基本单位而线程是CPU调度的基本单位。资源的核心抽象是虚拟地址空间进程通过 mm_struct 管理自己的虚拟内存而线程则通过共享同一虚拟地址空间来实现轻量级执行流。因此从本质上看操作系统的资源管理就是对虚拟地址空间的划分与映射管理。