尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

【Linux】malloc 1GB 内存为什么没立刻占满?从缺页、COW 到 Cache/TLB,看懂线程为什么更轻

【Linux】malloc 1GB 内存为什么没立刻占满?从缺页、COW 到 Cache/TLB,看懂线程为什么更轻 个人主页爱和冰阔乐专栏传送门《数据结构与算法》 、C学习方向C方向学习爱好者⭐人生格言得知坦然 失之淡然博主简介文章目录前言一、缺页异常虚拟地址合法不代表物理页已经存在1.1 什么是缺页异常1.2 三种缺页类型1.3 查页表失败到底什么意思1.4 延迟分配惰性分配二、写时拷贝COWfork 为什么不用立刻复制全部内存2.1 fork 之后发生了什么2.2 只读是页表的标记不是硬件锁死踩坑点三、局部性原理为什么缓存和延迟加载有效3.1 为什么低 12 位变化不跨页面空间局部性3.2 完整链路串起来3.3 易混淆辨析四、new/malloc、延迟分配与越界访问4.1 申请内存到底在干什么4.2 越界了不一定会报错4.3 如何区分缺页和越界五、线程为什么比进程更轻5.1 创建代价小5.2 切换开销小表面原因不用切换地址空间真正原因Cache 和 TLB 失效进程切换 vs 线程切换线程更轻量化的主要原因5.3 占用资源少5.4 充分利用多处理器5.5 计算密集型应用5.6 IO 密集型应用IO 等待重叠是什么意思为什么多线程有效六、多线程并不是只有优点6.1 性能损失6.2 健壮性降低6.3 缺乏访问控制6.4 编程难度提高七、线程异常与适用场景7.1 线程异常7.2 线程用途八、进程 VS 线程哪些共享哪些必须独占8.1 基本定位8.2 线程的私有数据上下文数据为什么重要栈为什么独立errno 为什么不能共享8.3 线程共享的进程资源8.4 关于进程线程的问题总结前言上一篇把地址空间和页表建立起来这一篇继续看“地址暂时没有物理页”以后会发生什么。缺页、COW、malloc/new的延迟分配看起来属于内存管理Cache、TLB、线程切换看起来又属于线程性能。实际上它们是一条线线程之所以更轻正是因为它不用像进程那样频繁更换整套地址空间和页表环境。一、缺页异常虚拟地址合法不代表物理页已经存在1.1 什么是缺页异常设想 CPU 给 MMU 的虚拟地址在 TLB 和页表都没有找到对应的物理页该怎么办这就是缺页异常 Page Fault——一个由硬件中断触发的、可以由软件逻辑纠正的错误。假如目标内存页在物理内存中没有对应的物理页或者存在但无对应权限CPU 就无法获取数据就会报告缺页错误。CPU 没有数据就无法计算CPU 罢工了。用户进程出现缺页中断进程从用户态切换到内核态将缺页中断交给内核的 Page Fault Handler 处理。过程CPU 访问虚拟地址MMU 进行虚拟和物理地址转换时没有找到对应的物理页CPU 内部自动触发缺页异常CPU 执行缺页中断程序CPU 执行中断向量表里的内核中断处理器做内存申请和重新构建页表1.2 三种缺页类型Hard Page Fault硬缺页 / 主要缺页物理内存中没有对应的物理页需要 CPU 打开磁盘设备读取到物理内存中再让 MMU 建立虚拟地址和物理地址的映射。要 IO 磁盘速度很慢。Soft Page Fault软缺页 / 次要缺页物理内存中存在对应物理页只不过可能是其他进程调入的发出缺页异常的进程不知道而已。MMU 只需要建立映射即可无需从磁盘读取写入内存。一般出现在多进程共享内存区域。比如要访问动态库其库代码可能已经被其他进程提前载入了。又比如共享内存多个进程共享同一块物理内存后访问的进程触发软缺页建立映射。Invalid Page Fault无效缺页错误比如进程访问的内存地址越界访问又如对空指针解引用内核就会报segment fault错误中断进程直接挂掉。类型物理内存有数据吗需要磁盘 IO 吗开销硬缺页 (Major)没有需要很慢软缺页 (Minor)有不需要很小无效缺页——进程挂掉1.3 查页表失败到底什么意思给虚拟地址查页表发现页目录所对应的页表不存在——可是判定这个虚拟地址是合法地址在进程的地址空间是合法的但页目录项存在页表不存在说明磁盘上的代码数据还没加载到内存。MMU 虚拟地址转换失败触发缺页中断。OS 进入中断处理确认虚拟地址合法内存管理模块申请空闲物理页框从描述物理内存的 page 数组找到空闲页框如果页表本身还没存在先分配物理内存创建页表把磁盘的数据读入刚分配的物理页框把物理页框号填入页表中标志位置为 1中断返回重新执行指令合法 ≠ 已经在物理内存合法只是你有权用这块虚拟地址不代表物理内存已经分配、页表已经填好。1.4 延迟分配惰性分配操作系统使用延迟分配进程申请虚拟地址只是在进程的 vma虚拟内存区域记录这块地址归你用暂时不建页表、不分配物理页框。等到真正访问时才现场分配。标志位 0有两种可能页表不存在页目录 present0页表存在但该页面没有物理页框页表 present0都会触发缺页中断。合法虚拟地址 ≠ PDE/PTE 有效。惰性分配只登记 VMA不建页表不分页框。缺页中断里面才做分配页框、读磁盘、现场构建 / 补全页表项。写时拷贝只是缺页中断其中的一个分支不是所有缺页都拷贝。二、写时拷贝COWfork 为什么不用立刻复制全部内存2.1 fork 之后发生了什么fork 之后父子进程共享同一个物理页框。全局变量处在这个 4KB 页框中操作系统把双方页表项的 R/W 权限设置只读。权限管理是以整个页框为单位的不能单独针对页内某个变量设置权限。页表项不光存物理页框号还附带一堆标志位读写权限、存在位等。全局变量只是 4 字节但它放在一个 4KB 的物理页框里。页表的权限位作用于整个页不能给页里面某一个 4 字节变量单独设置只读。只要这个页表项标记只读页框里面全部内容都只读。哪怕你只修改页内一个 int 全局变量只要写这个页内任意字节都会触发保护。fork 做的核心操作给子进程创建一套全新独立的页表子进程的每一个虚拟页全部填写和父进程一模一样的物理页框号父子映射到同一个物理页框把父子双方对应的这组页表项的 R/W 权限全部改成只读重点不是给变量加只读是整个物理页框被标记只读。2.2 只读是页表的标记不是硬件锁死物理页框硬件本身没有只读属性。只读是 MMU 硬件读取页表项里面 R/W 标志做检查。CPU 尝试写这个虚拟地址MMU 看到页表项 R/W 只读直接抛出缺页异常。场景 1只是读全局变量父子只是读取全局变量没有写操作。MMU 检查权限读是允许的不会触发异常。父子继续共用同一个物理页框不复制内存。这就是 COW 的好处——fork 瞬间完成不消耗大量物理内存。场景 2任意一方尝试写全局变量比如子进程执行g_val 100;CPU 执行写指令MMU 查页表项R/W 是只读。触发 #PF 缺页异常进入内核缺页处理。内核判断这是 COW 写时复制场景不是真的权限错误内核分配一块全新的物理页框把原来共享页框里面完整 4KB 全部拷贝到新页框⚠️ 哪怕你仅仅修改 4 字节全局变量也要复制整个 4KB 页框不是只拷贝那一个变量修改执行写操作那个进程的页表项虚拟页映射到新复制的物理页框R/W 改成可写另一个进程仍然保留原来的物理页框保持只读返回用户态重新执行写指令修改新页框里面的全局变量踩坑点说法正误物理页框硬件变成只读❌ 是两边进程的页表项标记只读只拷贝被修改的全局变量❌ 拷贝完整 4KB 页框读会触发复制❌只有写才触发复制fork 的时候有物理内存拷贝❌ 写的时候才分配新物理页属于软缺页不需要磁盘 IO三、局部性原理为什么缓存和延迟加载有效3.1 为什么低 12 位变化不跨页面为什么是低的 12 位可执行程序在磁盘上编址从全 0 到全 F。在 ELF 中认为每一个区域都是起始地址 偏移量。基于平坦模式所有数据段编址时起始偏移量都为 0。可执行程序加载到内存时前面若干位前 20 位相同的地址一定在一起因此低 12 位连续的地址一定属于同一个 4KB。所以加载内存时4KB 内部的数据在 ELF 编码中一定是聚集在一块的——这就是局部性原理。当我们访问某一行代码时较大概率会访问这一行周围的代码因为程序大部分情况都是顺序执行的。局部性原理分为时间局部性与空间局部性这里主要体现空间局部性。空间局部性如果一个存储位置被访问那么它附近相邻的地址很大概率很快也会被访问。程序运行特征CPU 执行代码指令顺序执行访问的虚拟地址是连续递增的遍历数组也是连续访问相邻内存地址既然程序大概率访问连续地址操作系统就利用分页配合 ELF 布局把连续虚拟地址打包进同一个 4KB 页面ELF 磁盘文件上把这一块内容聚集存放一次缺页读入完整 4KB 页面读进来之后后续访问页面内其他地址就不再需要访问磁盘直接命中内存。3.2 完整链路串起来程序代码逻辑顺序执行访问的虚拟地址连续空间局部性编译器生成 ELF把连续虚拟地址对应的代码、数据在磁盘文件聚集存放按页面大小对齐运行时第一次访问这片虚拟地址发生缺页中断OS 发起磁盘 IO一次性把磁盘上聚集的 4KB 内容读入一个物理页框填充 PTE后续访问该页面内其余上千个字节都在内存不再需要磁盘读取一句话总结因为程序有空间局部性相邻虚拟地址大概率会被访问所以 ELF 把相邻虚拟地址对应的内容在磁盘聚集分页以 4KB 为粒度加载一次 IO 预加载一大块减少磁盘访问次数提升效率。3.3 易混淆辨析❌ 不是ELF 文件里面每 4KB 严格切割分开✅ 是逻辑临近虚拟地址对应的内容在磁盘上聚集段按页边界对齐❌ 局部性不是分页带来的✅局部性是程序本身的行为特征分页机制、ELF 布局是利用局部性来优化性能低 12 位变化不会跨页面高 20 位变化才切换新页面才可能触发新的缺页四、new/malloc、延迟分配与越界访问4.1 申请内存到底在干什么当我们 new/malloc 时并没有在物理内存上开辟空间。其底层系统调用是brk更改数据段的大小或mmap基于文件的。new 和 malloc 时只需要修改虚拟地址空间上堆空间的范围改的是虚拟地址没有申请物理地址。申请完堆空间后不一定立马使用它OS 就不会立马去申请物理空间。当真正想要使用虚拟地址进行访问时系统触发缺页中断再做内存的二次申请。在虚拟地址上开辟空间的本质是对物理地址的延迟申请。当用户申请内存但没有使用这个内存可以给有需要的用户使用变相提高了内存使用效率。申请内存 申请地址空间即将堆空间的 start 和 end 指针移动即可。4.2 越界了不一定会报错在程序里定义了野指针和数组越界一旦出现错误一定会报错吗不一定inti0;inta[10];for(inti0;i10;i){a[i]0;}编译器把变量i放在数组的高地址后方数组下标变大地址往高地址增长a[10]的地址正好就是i的地址访问array[10]刚好命中i的内存把i强制写为 0循环条件永远成立——死循环。访问的代码空间指针指向的地址不一定是越界的地址而是合法的地址。你的越界OS 都不知道否则就会将程序终止。4.3 如何区分缺页和越界1. 页号合法性检查不是去查页表是先拿触发异常的虚拟地址对比进程自己的虚拟地址区间vm_area_struct看这个虚拟地址是不是属于这个进程允许使用的范围。合法虚拟地址 落在某一个vm_area_struct的[vm_start, vm_end)区间内。不在任何一个区间就是非法地址。如果页号合法但页不在内存中则为缺页中断如果页号非法则为越界访问。2. 内存映射检查检查触发事件的虚拟地址是否在当前进程的内存映射范围内。在映射范围内但页不在内存中 → 缺页中断不在映射范围内 → 越界访问。五、线程为什么比进程更轻5.1 创建代价小创建一个新线程的代价要比创建一个新进程小得多。创建进程需要分配 PCB、地址空间、页表等一整套结构而线程复用进程的地址空间只需要创建新的task_struct不需要重新构建地址空间和页表。5.2 切换开销小与进程之间的切换相比线程之间的切换需要操作系统做的工作要少很多。表面原因不用切换地址空间最主要的区别是线程切换时虚拟内存空间依然是相同的但进程切换是不同的。线程共享地址空间线程的 PCB 指向同一个mm_struct因此页表也不需要换映射关系也没变。所以线程切换时不用对 CR3 寄存器的内容进行保存。但仅凭有无保存 CR3其实看不出进程和线程谁更轻量化。真正原因Cache 和 TLB 失效另外一个隐藏的损耗是上下文切换会扰乱处理器的缓存机制。在 CPU 内部有两个缓冲Cache 缓存CPU 运算速度远远快于主存。如果没有 CacheCPU 每读一个变量都要访问慢腾腾的主存大部分时间在等待内存性能极低。Cache 把近期很可能要用的数据放到 CPU 内部高速存储。Cache 不是按单个字节存放是以Cache 行块为最小单位和内存交换数据典型大小 64 字节。一次从内存搬一整块进来这就是把周围数据一起放入 Cache的来源。命中CPU 直接从 Cache 拿数据不用访问主存速度极快不命中会去主存读取数据依据空间局部性不只是读目标那几个字节会把相邻一整块Cache 行全部加载进 CacheCache 满了执行替换算法LRU 最常用淘汰掉某一个旧 Cache 行Cache 存的是内存真正的数据 / 指令不是地址映射。比如数组元素、变量、代码指令。Cache 解决取数据慢的问题拿到物理地址之后读取内存数据cat/proc/cpuinfoTLB 快表进程在虚拟和物理转换时还有 TLB会将虚拟到物理之间的映射也缓存起来。TLB 解决地址翻译慢的问题翻译虚拟→物理地址进程切换 vs 线程切换一旦切换上下文处理器中所有已经缓存的内存地址一瞬间都作废。当你改变虚拟内存空间的时候处理的页表缓冲 TLB 会被全部刷新导致内存访问在一段时间内相当低效。进程切换时会导致 TLB 和 Cache 失效下次运行需要重新缓存。而在线程的切换中不会出现这个问题线程切换不用刷新 TLB但 Cache 内容会被污染、被置换。线程 A 在用一部分数据切到线程 B线程 B 访问另外一批数据会把 Cache 里面旧的数据挤掉。Cache 不会特意清空只是内容被新访问覆盖。切换类型TLBCache进程切换全部刷新失效数据失效需重新缓存线程切换不用刷新内容被新数据覆盖不主动清空Cache 中存真实数据不管进程还是线程切换硬件不会主动清空 Cache只是数据会被新访问挤掉。线程更轻量化的主要原因线程切换不切换页表和虚拟地址空间不刷新 TLBCache 污染更小——这就是线程更轻量化的主要原因。5.3 占用资源少线程占用的资源要比进程少。线程共享进程的地址空间不需要像进程那样独占一整套资源。5.4 充分利用多处理器线程是调度的基本单位当有多个 CPU 时可以创建多线程进行利用。多核 CPU 上多个线程可以真正并行执行。5.5 计算密集型应用为了能在多处理器系统上运行将计算分解到多个线程中实现。所有的代码都会变成进程在进行某种计算时会有不同类型的计算比如加密、解密、压缩等。使用 CPU 资源的都是计算密集型。举例要进行 4G 数据的排序假设有 4 个 CPU那么就可以将 4G 分给四个线程每个线程 1G 进行排序最后再进行归并即可。那是不是线程越多越好不是。在计算密集型应用中如果只有一个 CPU 但创建了很多线程线程切换会消耗本应给计算的算力。只有一个线程反而可以更快地计算。计算密集型应用中最快的方法是CPU 有多少个就创建多少个线程。5.6 IO 密集型应用为了提高性能将 IO 操作重叠。线程可以同时等待不同的 IO 操作——CPU 不要卡在那里干等一个 IO 完成让多个 IO 等待时间互相重叠硬件 IO 设备并行干活CPU 切换处理别的任务提升整体吞吐。IO 等待重叠是什么意思每个线程执行网络 recv 的时候线程会阻塞让出 CPU线程 1发起请求拿 A 块 → 陷入等待网络 IO不占 CPUCPU 切走调度线程 2发起请求拿 B 块 → 陷入等待网络 IO不占 CPUCPU 切走调度线程 3发起请求拿 C 块 → 陷入等待网络 IO不占 CPU三个线程同时都在等待网络三个网络 IO 在硬件层面并行跑等待时间互相重叠。不是 CPU 同时在干活是多个 IO 硬件等待并发进行。如果只用单线程只能 A 下载完再 B 再 CIO 等待是串行总耗时 T_A T_B T_C。多线程 IO 重叠后总耗时 ≈ max(T_A, T_B, T_C)速度大幅提升。为什么多线程有效CPU 同一时刻只有一个线程在跑那多线程和单线程有啥区别关键点下载绝大部分时间线程根本不占用 CPU。线程在阻塞等待 IO根本不在 CPU 上运行。把两件事严格拆开CPU 执行运行线程代码短暂只有发请求、收到数据拷贝这一小段需要 CPU网络 IO 传输下载数据硬件网卡自己干活完全不需要 CPU 参与这是漫长的等待阶段IO 密集型应用是不是线程越多越好可以适当多创建线程。IO 的时候大部分在等待可以提高上传或下载的效率。多个 IO 请求并行在硬件上执行等待时间重叠充分利用 IO 设备带宽。只有发送请求那一小段需要 CPU网络传输主体阶段 CPU 基本不参与网卡硬件干活。但数据到达之后还是要 CPU 接手处理。六、多线程并不是只有优点6.1 性能损失一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多那么可能会有较大的性能损失。这里的性能损失指的是增加了额外的同步和调度开销而可用资源不变。6.2 健壮性降低编写多线程需要更全面更深入的考虑。在一个多线程程序里因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性很大。线程之间是缺乏保护的。6.3 缺乏访问控制进程是访问控制的基本粒度。在一个线程中调用某些 OS 函数会对整个进程造成影响。6.4 编程难度提高编写与调试一个多线程程序比单线程程序困难得多。七、线程异常与适用场景7.1 线程异常单个线程如果出现除零、野指针问题导致线程崩溃进程也会随着崩溃线程是进程的执行分支线程出异常就类似进程出异常进程触发信号机制终止进程进程终止该进程内的所有线程也就随即退出线程异常的验证让新线程进行除 0 操作——任何一个线程崩溃都会导致整个进程崩溃。7.2 线程用途合理地使用多线程能提高CPU 密集型程序的执行效率合理地使用多线程能提高IO 密集型程序的用户体验如生活中我们一边写代码一边下载开发工具就是多线程运行的一种表现八、进程 VS 线程哪些共享哪些必须独占8.1 基本定位进程是资源分配的基本单位线程是调度的基本单位进程间具有独立性线程共享地址空间也就共享进程资源进程和线程的关系如下图8.2 线程的私有数据线程共享进程数据但也拥有自己的一部分私有数据私有数据说明线程 ID每个线程有唯一标识一组寄存器线程的上下文数据栈局部变量的地址只存在当前线程自己的栈、寄存器里信号屏蔽字哪些信号当前要被屏蔽、暂时不递送errno线程 1 系统调用出错修改自己的 errno不会影响线程 2 的 errno调度优先级独立的调度优先级上下文数据为什么重要线程的上下文数据可以证明线程是可以被独立调度的。栈为什么独立函数调用时会形成栈帧其内部会形成临时变量需要栈对临时数据进行保存。线程也需要函数调用因此栈也要独立——即表明线程是一个动态的概念。errno 为什么不能共享如果 errno 是进程全局共享多线程程序会出现严重 bug一个线程出错把 errno 改了另一个线程读到错乱错误码。所以 errno 必须是线程私有的。⚠️用户进程并不是只有一块栈区。每新建一个 pthread 线程操作系统会在这个进程的虚拟地址空间里再开辟一块全新独立的虚拟内存区域vm_area_struct作为这个新线程的栈。整个进程的虚拟地址空间是一块巨大的地址池子不是代码段、数据段、堆、主线程栈这四块就占满全部。剩下大量空闲虚拟地址可以分配给各个子线程做栈。8.3 线程共享的进程资源同一地址空间因此 Text Segment、Data Segment 都是共享的。如果定义一个函数在各线程中都可以调用如果定义一个全局变量在各线程中都可以访问到。除此之外各线程还共享以下进程资源和环境文件描述符表每种信号的处理方式SIG_IGN、SIG_DFL或自定义的信号处理函数当前工作目录用户 id 和组 id8.4 关于进程线程的问题如何看待之前学习的单进程——具有一个线程执行流的进程。总结缺页异常解决的是“地址合法但映射暂时不完整”COW 解决的是“先共享真正写时再复制”局部性让 Cache、TLB 和按需加载真正有意义。再回到线程就能看出线程轻量化并不是一句“创建快、切换快”就结束了。地址空间不换、页表不换、TLB 不需要像进程切换那样重新建立工作集才是这些现象背后的联系。资源分享【Linux】线程到底是什么从轻量级进程、虚拟地址到页表与 MMU一次理清线程底层模型【Linux】信号到底什么时候被处理sigaction、中断、用户态内核态与 SIGCHLD【Linux】信号产生后去了哪里从 Pending、Block 到 Core Dump讲清信号的保存
返回列表