![]()
🔥小叶-duck:个人主页
❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》
《Linux操作系统从入门到实践》《Qt从入门到实践》
《算法题讲解指南》--优选算法
《算法题讲解指南》--递归、搜索与回溯算法
《算法题讲解指南》--动态规划算法
✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游
目录
1.2.2 windows实现线程方式与Linux的不同之处
前言
从本篇文章开始我们就要讲解Linux系统编程中线程这一大板块的知识点了,由于线程相关的内容非常多所以我会拆分成好几个板块分别进行讲解。本篇文章是线程入门的第一篇,主要讲解线程是什么、为什么需要虚拟内存、页表究竟是什么以及分页机制、Linux 线程本质和优缺点与用途。
一、感性角度理解线程:线程是什么

1.1 什么是线程?
讲线程是什么之前,必须先谈一下进程是什么?进程 = 内核数据结构 + 自己的代码和数据(具体实现角度去谈的,抽象 -> 具体)
今天给进程再下一个定义:从内核视角出发的
- 进程是承担分配系统资源的基本单位。
我们再给线程下个定义:
- 线程是进程内部的一个执行分支(概念)
从内核视角出发
- 线程是CPU调度的基本单位。
- 一个进程至少有 1 个线程(主线程)
- 线程在进程虚拟地址空间内运行,共享进程大部分资源
- Linux 内核没有专门的 “线程结构体”,线程本质是轻量级进程 LWP,用 task_struct 描述,只是比普通进程更 “轻量化”
1.2 Linux线程的一种实现方式:轻量级进程

1.2.1 什么是轻量级进程?

这里的 task_struct 叫做轻量级进程。

在系统当中,线程个数比进程更多。这很好理解,对于线程定义说了:线程知识是进程内部的一个执行分支。线程是在进程地址空间内运行的。
1.2.2 windows实现线程方式与Linux的不同之处
线程也有自己的唯一性标识——线程也需要被操作系统管理起来,那必然要有一个结构体:struct TCB {}。
线程在OS内如果存在,并且一个进程可能存在多个线程,那么就一定会存很多?OS也就必然要对线程进行管理——要管理,那必然还是“先描述,再组织”。
线程内部是不是有自己的结构呢,也有状态也有优先级。
- TCB:Thread Ctrl Block

线程是作为进程内部中的一个执行分支,那么 PCB 里面是不是还要用一个结构将这些线程组织起来呢(比如线程链表等等)?这个系统就会导致设置的非常复杂,而Windows操作系统就是这样干的所以有时候会有莫名其妙的错误。
Linux 在这里就比较聪明了,并没有像windows那样创建内核级的 TCB,而是直接复用进程的 PCB,实现线程的效果——轻量级进程。这样就可以不用再像windows那样需要为 TCB 重新设计一套独立的调度算法,Linux的复用思想使得"线程"和进程共同使用同一个调度算法。

1.2.3 题外话:操作系统和具体操作系统概念之间的关联

1.2.4 重谈进程的定义
当我们现在接触了线程后,我们需要重新定义一下曾经学习的进程了:
- 进程:内部的执行流 + 地址空间 + 代码和数据,合起来叫做进程。

1.2.5 线程(轻量级进程)和进程的关系

今天谈的和之前的概念完全适配,今天的进程叫做多执行流进程,内部可能有一个或多个PCB。我们以前学的是特殊情况,今天是普遍情况。
创建线程只需要创建PCB,然后分配就行啦,所以说线程比进程更小、更细、更轻。

1.2.5.1 “家庭”示例理解两者关系
故事:全世界的社会资源分配的单位如果是家庭,假设每个家庭有一个自己的房子,今天内部通常会存在很多的人,每一个人都在做各自的事情,今天内部,会有各种各样的成员,各自做着各自的事情——其实都在共同实现同一个目标:我们都在完成把家里的日子过好这样一个目标——社会交给家庭的任务。
今天的线程其实是轻量级进程的多执行流进程,以前的“进程”只是单执行流的特殊情况,相当于一个家里只有一个家庭成员,而线程就是有各种各样的家人,为了一个同样的目标——“把家里面的日子过好”——把家庭看成进程,把家庭内部的一个一个成员叫做线程。

1.2.5.2 创建进程和创建线程本质

二、理性角度理解线程:资源划分(内存管理)
2.1 虚拟地址和页表的由来
线程之所以能 “共享、轻量化”,完全依赖虚拟地址空间。这部分是理解线程的根基。
2.1.1 没有虚拟内存会怎样?
早期的操作系统没有虚拟内存:
- 程序直接占用连续物理内存
- 程序大小不一,退出后留下大量内存碎片
- 多程序容易地址冲突、越界、崩溃

因为每一个程序的代码、数据长度都是不一样的,按照这样的映射方式,物理内存将会被分割成各种离散的、大小不同的块。经过一段运行时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。
而我们希望的是:
- 用户视角:地址连续、用起来方便
- 内核视角:物理内存离散、无碎片、可管理

我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分页便出现了。
2.1.2 分页基本概念
- 虚拟地址按同样大小分割:页(Page)
- 物理内存按固定大小分割:页框(Page Frame)
- 常见页大小:32 位系统 4KB,64 位系统 8KB
- 作用:把连续的虚拟地址,映射到不连续的物理内存页,解决内存碎片问题。
机制:
- CPU不直接访问物理内存,而是通过虚拟地址空间间接访问。
- 操作系统为每个执行中的进程分配逻辑地址空间。32 位机:范围 0 ~ 4GB。
- 通过页表建立虚拟地址 ↔ 物理地址的映射。

总结一下,其思想是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题。
2.2 物理内存管理(重点看图)
以 4GB 物理内存、4KB 页框为例:总页数 = 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 and slub */
union {
struct list_head slab_list; /* 复用 lru */
struct { /* Partial pages */
struct page *next;
#ifdef CONFIG_64BIT
int pages; /* 剩余页数 */
int pobjects; /* 近似对象计数 */
#else
short int pages;
short int pobjects;
#endif
};
};
struct kmem_cache *slab_cache; /* 不用于 slob */
/* 双字边界对齐 */
void *freelist; /* 第一个空闲对象 */
union {
void *s_mem; /* slab: 第一个对象 */
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 */
};
/* 其余字段(如引用计数、私有用例等) */
...
#if defined(WANT_PAGE_VIRTUAL)
/* 内核虚拟地址(如果没有映射则为 NULL,即高端内存) */
void *virtual;
#endif /* WANT_PAGE_VIRTUAL */
/* 后续可能还有其他成员,取决于内核配置 */
...
};
关键成员:
-
flags
- 存放页的状态:是否锁定、是否脏页、是否在缓存、是否空闲等。每一位表示一种状态,最多可表示 32 种状态。重要标志:
- PG_locked:页是否被锁定
- PG_uptodate:页数据是否有效
- PG_dirty:页是否被修改(脏页)

-
_mapcount
- 表示有多少页表项指向该页,即被引用计数。当值为 -1 时,表示该页空闲可分配。
-
virtual
- 页的内核虚拟地址。高端内存不永久映射,此时 virtual 为 NULL,需要动态映射。
内存开销计算:要注意的是 struct page 与物理页相关,而并非与虚拟页相关。而系统中的每个物理页都要分配一个这样的结构体,那么如果对所有这些页都这么做,到底要消耗掉多少内存。算 struct page 大约占40个字节的内存,假定系统的物理页为4KB大小,系统有4GB物理内存。那么系统中共有页面1048576个(1兆个),所以描述这么多页面的 page 结构体消耗的内存只不过40MB,相对系统4GB内存而言,仅是很小的一部分罢了。因此,要管理系统中这么多物理页面,这个代价并不算太大。
页大小的权衡:
- 页太大:页内碎片大
- 页太小:页表过长、切换开销大
- Linux/Windows 默认:4KB。

2.3 页表(单级页表)

页表中的每一个表项,指向一个物理页的开始地址。在32位系统中,虚拟内存的最大空间是4GB,这是每一个用户程序都拥有的虚拟内存空间。既然需要让4GB的虚拟内存全部可用,那么页表中就需要能够表示这所有的4GB空间,那么就一共需要 4GB/4KB=1048576 个表。
页表中的物理地址,与物理内存之间,是随机的映射关系,哪里可用就指向哪里(物理页)。虽然最终使用的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使用的都是线性地址,只要它是连续的就可以了,最终都能够通过页表找到实际的物理地址。
假设,在32位系统中,地址的长度是4个字节,那么页表中的每一个表项就是占用4个字节。所以页表占据的总空间大小就是:1048576*4=4MB 的大小。也就是说映射表自己本身,就要占用4MB /4KB=1024个物理页。这会存在哪些问题呢?

虽然会存在上面这些问题,但是不是说不能接受,而之所以现代通用OS坚决不用单张页表的其中一个原因是对于64位操作系统而言:

所以为了解决单张页表带来的这些问题,分页机制也就出现了。
2.4 多级页表(二级页表)&& 地址转换
解决需要大容量页表的最好方法是:把页表看成普通的文件,对它进行离散分配,即对页表再分页,由此形成多级页表的思想。
为了解决这个问题,可以把这个单一页表拆分成1024个体积更小的映射表。如下图所示。这样一来,1024(每个表中的表项个数)*1024(表的个数),仍然可以覆盖4GB的物理内存空间。

这里的每一个表,就是真正的页表,所以一共有1024个页表。一个页表自身占用4KB,那么1024个页表一共就占用了4MB的物理内存空间,那好像和之前没什么差别啊?
从总数上看是这样,但是一个应用程序是不可能完全使用全部的4GB空间的,也许只要几十个页表就可以了。例如:一个用户程序的代码段、数据段、栈段,一共需要10MB的空间,那么向上对齐取整之后(4MB的倍数,就是12MB),就只需要3个页表就可以了。
2.4.1 页目录结构
到目前为止,每一个页框都被一个页表中的一个表项来指向了,但是这1024个页表呢?是不是也需要被管理起来。所以这张管理页表的表称之为页目录表,形成二级页表。如下图所示:

- 所有页表的物理地址被页目录表项指向
- 页目录的物理地址被CR3寄存器指向,这个寄存器中,保存了当前正在执行任务的页目录地址。
虚拟地址划分(32 位、4KB 页):10 位页目录 | 10 位页表 | 12 位页内偏移
- 页目录与页表可以离散存储
- 进程只加载用到的页表,大幅节省内存
- 支持大地址空间

2.4.2 两级页表地址转换流程
- CR3 寄存器存放页目录物理地址
- 用虚拟地址高 10 位查页目录 → 找到页表
- 用中间 10 位查页表 → 找到物理页框
- 低 12 位偏移 → 最终物理地址
- 整个过程由 MMU(内存管理单元) 硬件完成。
到这里其实还有个问题,MMU要先进行两次页表查询确定物理地址,在确认了权限等问题后,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。
那么当页表变为N级时,就变成了N次检索+1次读写。可见,页表级数越多查询的步骤越多,对于CPU来说等待时间越长,效率越低。

总结:单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。
那对于上面的问题,有没有提升效率的办法呢?有的。
从计算机科学中的所有问题,都可以通过添加一个中间层来解决。MMU引入了新武器,江湖人称快表的TLB(其实,就是缓存,Translation Lookaside Buffer,学名:转译后备缓冲器)
2.5 TLB 快表
MMU 集成TLB 缓存,流程如下:
- CPU 给出虚拟地址
- MMU 先查 TLB
- 命中:直接得到物理地址
- 不命中:查页表,并把映射写入 TLB
当CPU给MMU传新虚拟地址之后,MMU先去问TLB那边有没有,如果有(命中)就直接拿到物理地址发到总线给内存。但TLB容量比较小,难免发生没命中(CacheMiss),这时候MMU还有保底的老武器页表,在页表中找到之后MMU除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录一下刷新缓存。

2.6 缺页异常 Page Fault
当虚拟地址在 TLB 与页表中都找不到物理页时,触发缺页异常。这是硬件中断 ,可由软件修复。
缺页异常分为三类:
- 硬缺页(Major Page Fault)物理内存中没有该页,必须从磁盘读取到内存,再建立映射。
- 软缺页(Minor Page Fault)物理内存已有该页,只是当前进程未建立映射,直接映射即可。常见于共享内存、父子进程、多线程。
- 无效缺页(Invalid Page Fault) 地址非法:越界、空指针、非法权限。触发 Segment Fault,内核直接终止进程。

三、线程的优缺点和异常与用途
3.1 线程的优点
-
创建一个新线程的代价要比创建一个新进程小的多
之前创建进程申请资源的时候会浪费大量的时间,创建PCB、申请虚拟地址空间、建立映射关系。创建线程更加轻量化了!
进程可以做的事线程也可以做,进程的代码和线程的代码不都是CPU一行行执行的嘛,但是线程占用的资源要比进程少,不用重新建立大量的虚拟地址空间,虚拟物理地址映射关系都不需要。创建PCB就可以了,然后有一部分进程的代码给你,因为线程在进程内部运行。
3.1.1 线程切换 VS 进程切换

-
与进程之前的切换相比,线程之间的切换需要OS做的工作要少很多
有些人会认为:之所以线程切换比进程切换开销小的原因是不需要切换页表、虚拟地址空间等等。虽然这的确是线程切换比进程切换开销小的其中一个原因,但这个开销其实微乎其微。

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


-
线程占用的资源要比进程少
-
能充分利用多处理器的并行数量
-
在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
-
计算密集型应用,为了能够在多处理器系统上运行,将计算分解到多个线程中实现
-
I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
总结:


3.2 线程的缺点
-
性能损失
- ⼀个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同⼀个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
-
健壮性降低
- 编写多线程需要更全面更深入的考虑,在⼀个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
-
缺乏访问控制
- 进程是访问控制的基本粒度,在⼀个线程中调用某些OS函数会对整个进程造成影响。
-
编程难度提高
- 编写与调试⼀个多线程程序比单线程程序困难得多
对于线程的健壮性降低的缺点,我们会在下篇文章线程控制中,先了解学习创建线程的相关接口,然后通过代码对线程的这个缺点进行证明讲解。


3.3 线程的异常与用途
异常:
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
用途:
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们⼀边写代码一边下载开发工具,就是多线程运行的⼀种表现)
最后总结
- 进程:资源分配的基本单位
- 线程:CPU 调度的基本单位
- Linux 线程 = 轻量级进程 LWP
- 线程共享地址空间、页表、文件描述符、堆、数据段
- 线程私有:栈、寄存器、线程 ID、errno、信号屏蔽字
- 线程优点:创建快、切换快、占用少、适合多核、适合 I/O
- 线程缺点:健壮性差、一崩全崩、编程复杂
- 线程用途:加速计算、提高 I/O 体验、高并发服务
结束语
到此,线程的概念我们就讲解的差不多了。线程是 Linux 并发编程的核心基石,理解其内核本质、与进程的核心区别、优缺点和适用场景,是我们后续掌握线程控制、同步互斥、线程安全的关键。希望这篇博客能帮你吃透线程的基础概念。
:线程概念(从虚拟地址空间与分页机制到优缺点解析)&spm=1001.2101.3001.5002&articleId=162923005&d=1&t=3&u=f6e57b2e80424e72956cfc9189779550)
2万+

被折叠的 条评论
为什么被折叠?



