news 2026/8/28 5:12:44

【Linux 篇】数字世界的任务执行者 —— Linux 线程概念与虚拟物理内存页表映射深度实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【Linux 篇】数字世界的任务执行者 —— Linux 线程概念与虚拟物理内存页表映射深度实战解析

于此止?:个人主页

本文选自专栏: 《Linux篇》

❄️个人专栏传送门: 《QT百日筑基篇》 《算法篇》 《Linux网络篇》

《C++与STL剖析篇》 《数据结构篇》 《Python基础篇》《嵌入式砺剑录》

⭐️纵有狂风拔地起,我亦乘风破万里⭐️

Linux线程的概念和页表的映射规则

前言:本文主要介绍我们的Linux中的线程的概念,我们的代码实操部分在我们的下一篇文章中我们会编写我们自己的系统库,这里我们只是初始的认识一下我们的线程,说到线程我们的页表的映射也是很重要的。我们也会说明我们页表是怎么映射的是如何映射的。


目录/索引

目录

Linux线程的概念和页表的映射规则

目录/索引

一、Linux线程的概念

什么是线程

初步理解线程

二、从理性的角度理解资源划分(虚拟到物理空间,页表,页目录的相关概念,部分内存管理的理解)

1.分页式存储管理

2.物理内存的管理

3.页表

4.页目录结构

5.两级页表的地址转换

6.缺页异常

三、线程的优点

四、线程的缺点

五、线程异常

六、线程的用途

七、线程的深度理解

八、Linux进程VS线程--哪些资源共享,哪些独占

进程和线程

进程的多个线程的共享

九、附录——页表和页表项的源码


一、Linux线程的概念

什么是线程

1.我们像我们的进程一样分以下的几个方面去介绍。

在我们的教材的概念:

进程:我们打开一个文件。

线程:线程是一个进程的执行分支。

内核和资源:

进程:承担分配资源的实体。

线程:CPU的调度的基本单位。

我的理解:

进程:内核数据结构(PCB)+我们的代码和数据

线程:本质就是一个轻量级的进程。

2.线程的概念:在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制程序”。

3.线程的分配和在哪里?一般来说我们的一个进程内部就存在我们一个对应的线程,我们只能有一个吗?不是我们的一个进程内部至少有一个线程,但是我们也可以有多个线程,我们的进程:线程=1:n。一个进程可以对应多个线程。

4.进程和线程的区别:我们的进程在创建的时候我们是先继承我们的父进程的属性的,但是我们的在写入时会发生对应的写实考呗,我们会重新开辟空间,在映射我们的页表,我们的进程主要强调的是我们独占一份资源,部分强调共享(什么时候?我们在system通信的时候我们会让我们的进程看到同一份资源),现在我们没学线程我们先输出一个结论,我们的线程是主要强调我们共享资源,部分强调我们的强占。这样我们先怎么理解呢?我们知道一个进程可能有多个线程,我们的线程同时指向一份的进程的空间在一个进程中,我们看到的都是同一份资源。

5.线程在哪进行我们的运行?

我们前面说了一个进程中至少有一个线程,我们也可以有多个线程,所以线程是在我们进程内部进行运行的,本质是在进程地址空间内运行的。

6.在我们Linux中,在CPU中,看到PCB都要比传统的进程更加的轻量化。

7.透过我们的进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程的执行流。

初步理解线程

我们现在回想一下我们是怎么创建我们的进程的,我们先创建我们的PCB,创建我们的虚拟地址空间和我们的页表。然后我们建立我们的物理和虚拟空间的映射。我们知道我们如果fork以后我们还要创建我们新的虚拟地址空间和我们的页表,那这样我们的资源的耗费就会增多一点,我们可不可以创建一个轻量级的进程我们不开辟我们的虚拟地址空间和我们的页表,我们用一个结构体再次指向我们的这个上一个进程的虚拟地址空间。这样我们多创建几个(下图创建了3个)不就模拟了我们对应的线程了吗。我们不需要我们再次规定一个我们的新的管理线程的结构体,而是使用我们的进程创建一个轻量化的进程来模拟我们的线程。我们的在这一个好几个指向同一份空间的地方我们的进程看到了我们的同一份资源(所以我们的线程强调我们共享资源)。我们只要划分我们对应的地址空间我们每个进程都只执行我们其中的一小部分。我们就能加快我们的效率。

结论:1.Linux中,线程是可以使用进程来进行模拟实现的。

2.对资源的划分本质上就是对地址空间的划分,通过划分地址能加快我们代码的执行速度。虚拟地址空间就是代表。

3.代码区划分后。函数就是我们的虚拟地址空间的集合,让我们的线程来执行我们对应的进程即可。

4.Linux的线程就是轻量级进程,我们在Linux中就是用轻量级进程来进行模拟实现的。

5.我们的线程的创建就可以理解为我们先创建进程开辟我们的虚拟内存和我们的页表,然后我们在创建几个进程不开辟我们的虚拟地址空间和我们的页表,指向另一个我们进程的虚拟空间和我们的页表。所以我们就是使用我们的进程来模拟我们的进程的,后续我们不需要创建我们一个独立的线程的描述了,我们只需要将我们的进程代码全部复用即可。

6.为什么要这么设计?

因为我们要将我们的代码效率变高,我们这样直接去复用我们的进程的代码就好了,进程的创建是非常的早的,我们如果在创建我们的线程可能会出现一些bug。我们的进程已经跑过很多遍了是不会出现很大的问题的,还能增加我们代码的健壮性。

7.其他平台怎么设计的?比如windows,也是这样吗,还是他有自己的一套实现方案呢?

这里我们说一下我们的win是创建了一个管理我们线程的结构体交TCB,可能win设计的效率更快可更好,我们不做深度的理解,我们只要知道我们Linux是怎么进行实现的就可以了。如果我们创建我们的TCB的话,我们要将我们的线程进行管理我们又要创建我们的结构体对象。

仅仅有上面的管理是不够的,要真正的理解我们的线程,我们必须要搞清楚我们的内核时如何进行资源划分的,尤其是代码。

二、从理性的角度理解资源划分(虚拟到物理空间,页表,页目录的相关概念,部分内存管理的理解)

1.分页式存储管理

我们之前我们说过一些关于虚拟空间的问题,我们这里不再过多的说明,我们现在只是在重谈我们的虚拟地址空间罢了。

传送门:【Linux篇】从虚拟到物理的地址翻译之路——进程地址空间与页表机制详解-CSDN博客

我们现在要对我们的页表进行详细的讲解,之前我们谈论了我们的虚拟到物理的映射,但是页表我们只说填写我们对应的虚拟和物理地址的映射关系,并没有说我们的页表更深层的意思。

那么现在我们就来说说我们的页表的一些具体的功能。

我们之前在我们的我们的文件系统中我们提到过我们的一个个文件大多都是4kb划分的内存块,我们的物理内存也被划分为我们一个个的内存块,大小4kb,我们的cpu读取就是以这样的一个个4kb的内存块进行读取的。

思考一下:如果在没有虚拟内存和分页机制的话没每一个用户在物理内存上对应的空间必须是连续的,如下图:

如果我们不这样划分的情况下,可能我i正在使用的这块空间正是别人正在使用的空间,可能我的代码越界了,但是越界的部分已经被释放了,所以我们的代码可能也会出现我们的野指针乱指的情况。

因为每一个程序的代码、数据长度都是不一样的,按照这样的映射方式,物理内存将会被分割成各种离散的、大小不同的块。经过一段时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在

怎么办呢?我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分页便出现了,如下图所示:

把物理内存按照一个固定的长度的页框进行分割,有时叫做物理页(我们在上面看到的4kb就是一个物理页,旁边的框框能框出一个范围的叫做页框)。每个页框包含一个物理页(page)。一个页的大小等于页框的大小。大多数 32 位体系结构支持 4KB 的页,而 64 位体系结构一般会支持 8KB 的页。区分一页和一个页框是很重要的:

  • 页框是一个存储区域;
  • 而页是一个数据块,可以存放在任何页框或磁盘中。

然后通过我们的虚拟地址空间映射我们的页表并且找到我们对应的物理内存。CPU 便并非是直接访问物理内存地址,而是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每一个正在执行的进程分配的一个逻辑地址,在 32 位机上,其范围从 0 ~ 4G‑1我们 32位下我们的虚拟地址空间的大小一般是4GB(2的32次方)

架构理论虚拟地址硬件实际可用
32 位 x86232=4GB4GB
64 位 x86‑64264=16EB256TB(48 位)

操作系统通过将虚拟地址空间和物理内存地址之间建立映射关系,也就是页表,这张表上记录了每一对页和页框的映射关系(我们能这样理解就是我们存储位置 和存储范围的映射关系),能让 CPU 间接的访问物理内存地址。

总结一下,其思想是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题


2.物理内存的管理

假设一个可用的物理内存有4GB的空间。按照一个页4kb进行划分,那我们的页框就有我们的4GB/4KB=1048576个页框。有了这么多的物理页,操作系统肯定要将其管理起来的,操作系统知道那些页是被管理起来的,哪些页是正在被使用的,哪些页是空闲的等等等等。

所以我们的页要进行管理吗?答案是要的我们怎么管理我们的页呢?答案还是我么们的先描述,在组织,我们的内核用我们的struct page来表示我们的页,我们的页要经常的被访问,要经常的加载到内存所以我们要考虑我们效率的问题,出于节省的考虑我们的初始的设计采用了大量的联合体(union)进行的实现。

我们来看一看我们的struct page的源代码。

/* 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_64BIT int pages; /* Nr of pages left */ int objects;/* Approximate count */ #else short int pages; short int objects; #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 */ ... };

所以我们上面计算过我们应该有多少个页。

所以我们就有了struct page mem[1048576],这样我们对页的操作就转换为了对数组的操作了;

我们的进程和我们的线程申请我们的内存是在作什么?

1.查数组(看看哪个我们的页没有被使用),改page(将0置为1表示已经被使用)!

2.建立内核数据结构的对应关系。

我们有个参数几个参数是要重点说一下的

1.struct address_space* mapping;是我们的偏移量。

我们并不知道我们的地址,我们的每一个页也没有储存对应的地址,那我们是不是不知道对应的地址?那我们怎么进行访问呢?我们的地址天然就知道,我们先要让我们的属性inode*我们4kb的大小就知道我们inode属性的大小。然后存储我们对应的内容+我们的偏移量就知道我们的物理页的起始位置了。

所以具体的物理地址=inode*4kb+偏移量。所以我们的页内不需要保存我们的起始地址。

2. unsigned long flags:用来存放页的状态(32位)。

这些状态包括页是不是脏的,是不是被锁定在内存中等。flag 的每一位单独表示一种状态,所以它至少可以同时表示出 32 种不同的状态。这些标志定义在<linux/page-flags.h>中。其中一些比特位非常重要,如PG_locked用于指定页是否锁定,PG_uptodate用于表示页的数据已经从块设备读取并且没有出现错误。

含义
PG_locked页面锁,页面正在 IO 操作,禁止其他内核路径访问该页
PG_uptodate页面数据有效,已成功从磁盘读取完成
PG_dirty脏页:内存中页面已被修改,磁盘副本陈旧,需要回写磁盘
PG_lru该页面挂在 LRU 回收链表上,参与内存页面回收
PG_active页面处于 active 活跃 LRU 链表,近期被访问过
PG_writeback页面正在执行回写磁盘操作
PG_swapcache页面处于 swap 缓存,page->private存放 swap 条目
PG_buddy页面空闲,位于伙伴系统空闲链表中
PG_reserved保留页,不会被内存回收
PG_privatepage->private成员存有有效私有数据
#define PG_locked 0 /* Page is locked. Don't touch. */ #define PG_error 1 #define PG_referenced 2 #define PG_uptodate 3 #define PG_dirty 4 #define PG_lru 5 #define PG_active 6 #define PG_slab 7 /* slab debug (Suparna wants this) */ #define PG_checked 8 /* kill me in 2.5.<early>. */ #define PG_arch_1 9 #define PG_reserved 10 #define PG_private 11 /* Has something at ->private */ #define PG_writeback 12 /* Page is under writeback */ #define PG_nosave 13 /* Used for system suspend/resume */ #define PG_compound 14 /* Part of a compound page */ #define PG_swapcache 15 /* Swap page: swp_entry_t in private */ #define PG_mappedtodisk 16 /* Has blocks allocated on‑disk */ #define PG_reclaim 17 /* To be reclaimed asap */ #define PG_nosave_free 18 /* Free, should not be written */ #define PG_buddy 19 /* Page is free, on buddy lists */

3._mapcount:映射计数器

表示在页表中有多少项指向该页,也就是这一页被引用了多少次。当计数值变为‑1 时,就说明当前内核并没有引用这一页,于是在新的分配中就可以使用它

4.virtual:是页的虚拟地址。

通常情况下,它就是页在虚拟内存中的地址。有些内存(即所谓的高端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为 NULL,需要的时候,必须动态地映射这些页


要注意的是struct page与物理页相关,而并非与虚拟页相关。而系统中的每个物理页都要分配一个这样的结构体,让我们来算算对所有这些页都这么做,到底要消耗掉多少内存。

struct page占 40 个字节的内存吧,假定系统的物理页为 4KB 大小,系统有 4GB 物理内存。那么系统中共有页面 1048576 个(1 兆个),所以描述这么多页面的 page 结构体消耗的内存只不过 40MB,相对系统 4GB 内存而言,仅是很小的一部分罢了。因此,要管理系统中这么多物理页面,这个代价并不算太大。

要知道的是,页的大小对于内存利用和系统开销来说非常重要,页太大,页内必然会剩余较大不能利用的空间(页内碎片)。页太小,虽然可以减小页内碎片的大小,但是页太多,会使得页表太长而占用内存,同时系统频繁地进行页转化,加重系统开销。因此,页的大小应该适中,通常为512B‑8KB,windows/Linux 系统的页框大小为 4KB。


3.页表

页表中的每一个表项,指向一个物理页的开始地址。在 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 个页表就足够了。我们虽然说有1024张页表其实我们的页表是要远小于我们的1024的,我们一般编写代码的时候最多4张,就够用了一般不会超过我们的1024张的。

4.页目录结构

到目前为止,每一个页框都被一个页表中的一个表项来指向了,那么这 1024 个页表也需要被管理起来。管理页表的表称之为页目录表,形成二级页表。如下图所示:

  • 所有页表的物理地址被页目录表项指向,这样有利于我们找到我们的页表。
  • 页目录的物理地址被CR3 寄存器指向,这个寄存器中,保存了当前正在执行任务的页目录地址。
  • MMU(内存管理单元,Memory Management Unit)是CPU 内部的硬件部件,专门负责虚拟地址→物理地址的转换,两级页表查表整个过程是MMU 硬件自动完成,不是操作系统软件做的。CPU 执行指令给出的是虚拟地址,直接交给 MMU。还有一点我们下面说。

所以操作系统在加载用户程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为用来保存程序的页目录和页表分配物理内存。

5.两级页表的地址转换

我们的页表怎么进行转化呢?其实我们的管理我们页和我们的页框是有一个位图的位图是一个32位的整数。下面我们举个列子:

下面以一个逻辑地址为例。将逻辑地址(0000000000,0000000001,11111111111)转换为物理地址的过程:

  1. 在 32 位处理器中,采用 4KB 的页大小,则虚拟地址中低 12 位为页偏移剩下高 20 位给页表,分成两级,每个级别占 10 个 bit(10+10)。我们计算一下我们的这些是否能后映射完我们的虚拟地址空间1024(页目录的总数)*1024(一张页目录中一张页表的总数)*4kb(一张页表的大小)=4GB
  2. CR3 寄存器读取页目录起始地址,再根据一级页号查页目录表,找到下一级页表在物理内存中存放位置。
  3. 根据二级页号查表,找到最终想要访问的内存块号。
  4. 结合页内偏移量得到物理地址。
  5. 注:一个物理页的地址一定是4KB 对齐的 (最后的 12 位全部为 0),所以其实只需要记录物理页地址的高 20 位即可。
  6. 找到对应的表后我们将我们的虚拟地址返回给我们上一层的用户。
  7. 以上其实就是 MMU 的工作流程。MMU (Memory Manage Unit) 是一种硬件电路,其速度很快,主要工作是进行内存管理,地址转换只是它承接的业务之一。
  8. CPU 执行指令给出的是虚拟地址,直接交给 MMU。拆分虚拟地址字段。把物理页框号左移 12 位,和页内偏移拼接,得到最终物理地址,发给内存。。MMU 内部有 TLB(转换后备缓冲器),缓存已经翻译过的「虚拟页→物理页框」映射。TLB 命中:不用去内存查两级页表,直接得到物理地址,速度极快TLB 未命中:才去内存走上面两次查表流程,检查页面读写权限(是否可读、可写、可执行),非法访问直接触发异常。
  9. 一句话总结:OS(操作系统):负责建立页目录、页表,设置 CR3 寄存器,处理缺页异常;不做每一条指令的地址翻译MMU 硬件:运行时实时完成虚拟地址到物理地址转换、TLB 缓存、权限检查

所以我们的页表不仅仅只有我们的物理和虚拟之间的映射还应该有我们的命中和权限。

下图的工作流程主要是我们的MMU在进行工作。

结论:

细节1:我们申请对应的内存,就是查找我们对应的struct page(也就是对数组的查询), 找到未使用的page,通过page index(偏移)就能找到我们对应的物理地址。

细节2:我们在发生我们的写实拷贝,缺页中断,内存申请等等,背后可能要重新建立新的页表和建立映射关系的操作。

细节3:我们在创建我们进程的时候。我们的会创建一张虚拟地址空间,一张页目录表+n张我们的页表构建映射体系,虚拟地址是索引,物理地址是页框目标,虚拟地址低12位+页框地址= 物理地址。
细节4:为什么是我们的低12位进行页内偏移?因为简单,我们要经常的查我们的页表的位置,所以我们只需要&(0000 0000 0000 0000 0000 1111 1111 1111)就可以拿到我们的低12位了。

到这里其实还有个问题,MMU 要先进行两次页表查询确定物理地址,在确认了权限等问题后,MMU 再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。那么当页表变为 N 级时,就变成了 N 次检索 + 1 次读写。可见,页表级数越多查询的步骤越多,对于 CPU 来说等待时间越长,效率越低。

让我们现在总结一下:单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。

有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加一个中间层来解决。MMU 引入了新武器,江湖人称快表的 TLB(其实,就是缓存,Translation Lookaside Buffer,学名转译后备缓冲器

当 CPU 给 MMU 传新虚拟地址之后,MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,齐活。但 TLB 容量比较小,难免发生 Cache Miss,这时候 MMU 还有保底的老武器页表,在页表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到 TLB,让它记录一下刷新缓存。

总结:

执行流看到资源本质是:在合法的情况下,你拥有多少虚拟地址,我们的虚拟地址就是我们资源的代表,虚拟地址空间mm_struct + nm_area_struct 本质:进行资源的统计数据和整体数据,页表就是我们一张虚拟到物理的地图。

文章的结尾处附带我们的页表与页目录的源码


6.缺页异常

设想,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 错误中断进程直接挂掉。

注意:

  • 如何理解我们之前的 new 和 malloc?我们只是知道我们进行了我们的空间申请,虚拟地址会开辟但是物理内存会进行开辟等到我们下次使用了我们就会发生对应的缺页异常,进而开辟我们物理内存的空间。
  • 如何理解我们之前学习的写时拷贝?我们的写实拷贝是我们只拥有对应的只读权限,我们没有写的权限,如果我们强制的进行写入我们会触发我们的缺页异常,那么我们就会发生写实拷贝进而再次开辟我们的空间。
  • 申请内存,究竟是在干什么?生成对应的页表进行关系的映射,填写我们的物理和虚拟的空间映射,填写我们对应的权限。

如何区分是缺页了,还是真的越界了?

  • 一个问题,越界了一定会报错吗?
  1. 不一定,向我们数组我们c语言是设置了两个检查位,但是超过两个之后我们不会报错我们可能会触发我们的程序崩溃。

  2. 页号合法性检查:操作系统在处理中断或异常时,首先检查触发事件的虚拟地址的页号是否合法。如果页号合法但页面不在内存中,则为缺页中断;如果页号非法,则为越界访问。

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

  • 线程资源划分的真相:只要将虚拟地址空间进行划分,进程资源就天然被划分好了。因为我们进程的资源其中就有我们的虚拟地址空间,虚拟地址空间创建好了代表我们的页表也创建好了。进而我们的进程的资源也就创建好了。

三、线程的优点

  • 创建一个新线程的代价要比创建一个新进程小得多,线程不要开辟我们的虚拟地址空间和建立我们页表之间的映射关系。

  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多

    • 最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出
    • 另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲 TLB(快表)会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件 cache(缓存)。
  • 线程占用的资源要比进程少

  • 能充分利用多处理器的可并行数量

  • 我们一般怎么创建线程呢?我们一般是有几个cpu就创建几个线程。

  • 在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务,并行,我们现在经常使用线程来实现我们对应的高并发。

  • 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现

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


四、线程的缺点

  • 性能损失

    • 一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。我们的处理器不多的情况下我们的线程之间进行调度的时候我们也会消耗对应的时间。所以我们推荐有几个cpu就创建几个线程。
  • 健壮性降低

    • 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。所以我们后面引入了互斥的概念来对这一块内存进行保护。
  • 缺乏访问控制

    • 进程是访问控制的基本粒度,在一个线程中调用某些 OS 函数会对整个进程造成影响。
  • 编程难度提高

    • 编写与调试一个多线程程序比单线程程序困难得多

五、线程异常

  • 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
  • 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

六、线程的用途

  • 合理的使用多线程,能提高 CPU 密集型程序的执行效率
  • 合理的使用多线程,能提高 IO 密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

七、线程的深度理解

线程对地址的划分:本质就是对地址空间的划分,获得一定范围的合法地址,在本质就是我们对页表的划分。

线程对资源的划分,本质是对我们地址空间的共享,在本质,就是对页表条目的共享。


八、Linux进程VS线程--哪些资源共享,哪些独占

进程主要强调独立,部分共享。

线程主要强调共享,部分独占。

进程和线程

  • 进程是资源分配的基本单位

  • 线程是调度的基本单位

  • 线程共享进程数据,但也拥有自己的一部分 "私有" 数据:

    • 线程 ID
    • 一组寄存器,线程的上下文数据
    • 栈,线程有独立的栈结构。
    • errno
    • 信号屏蔽字
    • 调度优先级

进程的多个线程的共享

同一地址空间,因此 Text Segment、Data Segment 都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表
  • 每种信号的处理方式 (SIG_IGN、SIG_DFL 或者自定义的信号处理函数)
  • 当前工作目录
  • 用户 id 和组 id

九、附录——页表和页表项的源码

我们能看到我们对应的标记位,我们的(pie_t)页表项,和我们的(pgd_t)页目录

/* * We keep two sets of PTEs - the hardware and the linux version. * This allows greater flexibility in the way we map the Linux bits * onto the hardware tables, and allows us to have YOUNG and DIRTY * bits. * * The PTE table pointer refers to the hardware entries; the "Linux" * entries are stored 1024 bytes below. */ // 页表标志位 #define L_PTE_PRESENT (1 << 0) #define L_PTE_FILE (1 << 1) /* only when !PRESENT */ #define L_PTE_YOUNG (1 << 1) #define L_PTE_BUFFERABLE (1 << 2) /* matches PTE */ #define L_PTE_CACHEABLE (1 << 3) /* matches PTE */ #define L_PTE_USER (1 << 4) #define L_PTE_WRITE (1 << 5) #define L_PTE_EXEC (1 << 6) #define L_PTE_DIRTY (1 << 7) #define L_PTE_COHERENT (1 << 9) /* I/O coherent (xsc3) */ #define L_PTE_ASID (1 << 11) /* non-global (use ASID, v6) */ // 页表是? typedef struct { unsigned long pte; } pte_t; // 页表项 typedef struct { unsigned long pgd; } pgd_t; // 页全局目录项 pgd_t * pgd_alloc(struct mm_struct *mm) { pgd_t *ret, *init; ret = (pgd_t *)__get_free_page(GFP_KERNEL | __GFP_ZERO); init = pgd_offset(&init_mm, 0UL); if (ret) { #ifdef CONFIG_ALPHA_LARGE_VMALLOC memcpy (ret + USER_PTRS_PER_PGD, init + USER_PTRS_PER_PGD, (PTRS_PER_PGD - USER_PTRS_PER_PGD - 1)*sizeof(pgd_t)); #else pgd_val(ret[PTRS_PER_PGD-2]) = pgd_val(init[PTRS_PER_PGD-2]); #endif /* The last PGD entry is the VPTB self-map. */ pgd_val(ret[PTRS_PER_PGD-1]) = pte_val(mk_pte(virt_to_page(ret), PAGE_KERNEL)); } return ret; } pte_t * pte_alloc_one_kernel(struct mm_struct *mm, unsigned long address) { pte_t *pte = (pte_t *)__get_free_page(GFP_KERNEL|__GFP_REPEAT|__GFP_ZERO); return pte; } struct mm_struct { struct vm_area_struct * mmap; /* list of VMAs */ struct rb_root mm_rb; struct vm_area_struct * mmap_cache; /* last find_vma result */ unsigned long (*get_unmapped_area) (struct file *filp, unsigned long addr, unsigned long len, unsigned long pgoff, unsigned long flags); void (*unmap_area) (struct mm_struct *mm, unsigned long addr); unsigned long mmap_base; /* base of mmap area */ unsigned long task_size; /* size of task vm space */ unsigned long cached_hole_size; /* if non‑zero, the largest hole below free_area_cache */ unsigned long free_area_cache; /* first hole of size cached_hole_size or larger */ pgd_t * pgd; // 页目录起始地址 }
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/28 5:12:12

高频必考!差分数组:批量区间更新如何做到 O(1) 一条?

LeetCode 1109「航班预订统计」&#xff0c;场景真实得不像算法题&#xff1a;你收到2万条预订记录&#xff0c;每条都是“从第a天到第b天&#xff0c;每天加c个座位”。 要你算出每一天的总座位数。大多数人第一反应&#xff1a; for first, last, seats in bookings: for …

作者头像 李华
网站建设 2026/8/28 5:11:47

C函数_函数调用表达式

什么是函数调用表达式&#xff1f;函数调用表达式的格式是&#xff1a;函数名(参数列表)它的含义是&#xff1a;调用一个函数&#xff0c;并产生一个值——这个值就是函数的返回值。所以函数调用表达式本身是有"值"的&#xff0c;它的值就是函数 return 回来的那个东…

作者头像 李华
网站建设 2026/8/28 5:10:51

混合LoRA专家路由:不确定性不够,信息价值才是关键

刚接触 LoRA 和 MoE 路由的人&#xff0c;往往先被一个问题卡住&#xff1a;手头已经有好几个训练好的 LoRA 专家&#xff0c;系统不知道当前请求该交给谁。于是大家会想到让路由器看不确定性&#xff0c;也就是“哪个模型更犹豫就避开哪个”。但这个思路往往只能帮你排除错误答…

作者头像 李华
网站建设 2026/8/28 5:10:45

Zig Io.Threaded:用线程池封装阻塞I/O的设计与取舍

Zig 的 Io.Threaded 是我翻标准库时觉得最值得停下来看一遍的设计之一。它解决的是很具体的问题&#xff1a;你想在 Zig 里写看起来正常的同步文件读写&#xff0c;又不想让主线程被一次慢盘、一次网络请求卡住。Io.Threaded 的做法&#xff0c;是把所有会阻塞的 I/O 操作丢给线…

作者头像 李华
网站建设 2026/8/28 5:09:13

蓝桥杯国赛填空题复盘:从暴力枚举到数学优化与边界处理

1. 项目概述&#xff1a;为什么我们需要复盘2020年蓝桥杯国赛填空题&#xff1f;如果你是参加过蓝桥杯&#xff0c;或者正在备赛的选手&#xff0c;看到“2020年蓝桥杯B组国赛填空题整理”这个标题&#xff0c;大概会心一笑。这玩意儿&#xff0c;懂的都懂。它不像那些动辄几百…

作者头像 李华
网站建设 2026/8/28 5:08:15

Shopee Client秋招笔试复盘:从网络协议到MCP的客户端全链路考点

2024 年秋招&#xff0c;我投的是 Shopee 的 Client 提前批。说句实话&#xff0c;看到题目之前&#xff0c;我以为“Client 岗”的笔试重点会是界面框架、组件化、状态管理或者渲染优化这类东西。真正坐到在线笔试页面里我才发现&#xff0c;这套题更看重的是一个客户端工程师…

作者头像 李华