本篇目标:
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,如简化代码所示:
cpp
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位?
答案:因为页框大小为 4KB(2¹²),所以虚拟地址的低 12 位可以用于表示页内偏移,这一部分
在地址转换过程中不会改变,只用于在物理页框内部定位具体字节。
<4>.如何区分是缺页了,还是真的越界了?
⼀个问题,越界了⼀定会报错吗?
比如,有这么个代码:
cpp
int arr[10]={0};
for(int i=0;i<=10;i++)
{
arr[i]=i;
}
我们可能以前就已经发现了,即使我们已经越界访问了,但是却不一定会崩溃,连操作系统都不知道我们的debug越界,因为arr10 对应的地址 ≠ 一定非法,可能越界了,但是"访问了其他的合法内存",
结论:在发生缺页异常时,操作系统首先检查触发访问的虚拟地址是否落在当前进程的 vm_area_struct 管理的虚拟地址空间范围内。如果在该范围内但对应物理页未驻留内存,则属于缺页中断;如果不在任何合法的虚拟内存区域内,则属于非法访问(越界访问),操作系统会终止进程。
总结:所以,通过对页表和线程的理解,我们就可以知道:
执行流在看到资源时,本质上是:在合法的前提下,拥有多少可用的虚拟地址空间。虚拟地址本身就是资源的抽象表示。
虚拟地址空间的管理结构,例如 mm_struct 和 vm_area_struct,本质上是在对进程的地址空间进行统计、划分与组织,用来描述"资源整体分布情况"。
从这个角度来看,资源的划分本质上就是地址空间的划分,而资源的共享,本质上就是地址空间的共享(或部分地址空间的共享)。
三.线程和进程的比较
1.线程优点
线程的优点:
• 创建⼀个新线程的代价要比创建⼀个新进程小得多
• 与进程之间的切换相比,线程之间的切换需要操作系统做的⼯作要少很多,
• 线程占⽤的资源要比进程少
• 能充分利用多处理器的可并行数量
• 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
• 计算密集型应用,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现
大概意思:用多线程吃满CPU多核能力 ,为了能在多处理器系统上运行,将计算分解到多个线程中例如:
cpp
任务:计算 1~1亿的和
线程1:1~2500万
线程2:2500万~5000万
线程3:5000万~7500万
线程4:7500万~1亿
• I/O密集型应用,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
大概意思:用多线程隐藏等待时间,提高CPU利用率,例如:
cpp
单线程:读文件 → 等待IO → CPU空转 → 再处理
cpp
多线程:
线程1:等磁盘A
线程2:等磁盘B
线程3:等网络请求
但是这些都不是线程比进程最大的优点,最主要的区别是:
线程切换时虚拟内存空间是相同的,而进程切换时虚拟内存空间是不同的。这两种上下文切换的处理都是由操作系统内核完成的。
在内核进行上下文切换的过程中,最显著的性能开销来自于寄存器上下文的保存与恢复,也就是将当前 CPU 寄存器中的内容保存到内存中,并加载新任务的寄存器状态。
另外一个隐藏的性能损耗是缓存失效问题 。上下文切换会扰乱处理器的缓存机制,使得原本缓存的内存访问局部性被破坏,导致 cache 命中率下降。
更重要的一点是,当发生进程切换时,由于虚拟地址空间发生变化,处理器中的 TLB(快表)通常需要被刷新或部分失效 ,这会导致一段时间内内存访问效率显著下降。
而在线程切换中,由于多个线程共享同一个虚拟地址空间,因此不会发生 TLB 的整体失效问题(通常无需完全刷新),同时 cache 复用性也更好,因此开销相对更小。
2.线程的缺点
<1>. 性能损失
◦ ⼀个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同⼀个处理器 。如果计算密集型线程的数量比可用的处理器多,那么可能会有较⼤的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,⽽可用的资源不变。
例如:
假设:
CPU只有4核
但你开了10个计算线程
结果:
- 10个线程抢4个CPU
- 线程不断切换
不是"算得更快",而是:CPU没有变多,计算能力没增加,但是多了调度开销
开销包括:
- 线程切换(上下文保存/恢复)
- 调度器调度
- cache/TLB扰动
<2>. 健壮性降低
◦ 编写多线程需要更全⾯更深⼊的考虑,在⼀个多线程程序⾥,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护 的。
例如:
cpp
线程A改了变量
线程B同时在读
→ 数据错乱
<3>.缺乏访问控制
◦ 进程是访问控制的基本粒度,在⼀个线程中调用某些OS函数会对整个进程造成影响。
<4>. 编程难度提高
◦ 编写与调试⼀个多线程程序⽐单线程程序困难得多
总结:在操作系统中,进程本质上是资源分配的基本单位,而线程是CPU调度的基本单位。
资源的核心抽象是虚拟地址空间,进程通过 mm_struct 管理自己的虚拟内存,而线程则通过共享同一虚拟地址空间来实现轻量级执行流。
因此,从本质上看,操作系统的资源管理就是对虚拟地址空间的划分与映射管理。