一、线程概念
什么是线程
进程:一个运行起来的执行流,一个加载到内存中的程序 (教材)。进程 = 内核数据结构 + 自己的代码和数据。 线程:进程内部的一个执行流,轻量化。 观点:进程是系统分配资源的基本单位 (内核角度,给进程下的定义), 线程是 CPU 调度的基本单位。
第四次谈地址空间
物理内存结构——page
- 页表的每一个虚拟到物理的映射条目叫做页表项。
- 访问内存的基本单位是字节,但是页表并不是以字节为单位映射的。因为地址空间一共有 4GB 空间 = 4 * 1024 * 1024 * 1024 字节,假设一个页表项占 8 个字节(32 位下虚拟地址 4 字节,物理地址 4 字节),按字节映射的话每一个字节的虚拟地址都需要一个 8 字节的页表项来存储映射关系,那么页表一共就会占 8 * 4 * 1024 * 1024 * 1024 字节 = 32GB 空间,远超 4GB 的物理内存空间大小,很明显不合理。
- 我们在讲文件系统的时候讲过,磁盘存储是以 4kb 为单位,实际上物理内存也从逻辑层面上被划分成了一个个 4kb 大小的小内存,内存与磁盘之间的 IO 就叫做文件系统 IO,且 IO 的基本单位大小是 4kb。
- 物理内存中的每一个 4kb 空间被叫做页框或者页帧。
- 当每出现一个概念时,OS 都需要对其管理,页框也是同理,在内核中有一个 struct page 结构体用来描述页框,结构体当作有一个位图标志位字段用于描述页框的状态,还会有一个整型变量表示当前内存页的引用计数等等,struct page 结构体比较小,因为 4GB 内存会存在 4GB / 4KB = 1048576 个 struct page 结构体,所以单个 struct page 不能占据太多空间。
- 将页框进行描述后还需要对其进行管理,OS 是用一个结构体数组:struct page pages[1048576] 对其进行管理的,那么未来 OS 对物理内存的管理就转化成为了对 pages 数组的增删查改。
- 有了上面的知识,我们就认识到了一个颠覆认知的知识:所有物理地址,都可以通过数组下标转化而来:数组下标 0 对应物理内存第一个 4kb 内存块,数组下标 1 对应物理内存第二个 4kb 内存块…这样一来物理地址这个概念就被弱化了,所以在内核中我们经常会看到虚拟内存地址,但很少看到物理内存地址,因为物理地址是拿着数组起始地址和 page 元素下标进行计算、转化得来的。
- 进程申请物理内存本质就是在 pages 数组中申请一个 page 结构体,然后 OS 拿到数组的下标,并结合 pages 数组起始地址就能找到该物理内存块的地址了,转化关系是:页框起始地址 = pages 数组下标 * 4KB。
虚拟/物理内存转化
32 位系统下 PC 指针(程序计数器)的具体实现是 EIP 寄存器,32 位系统下页表不是一个整体,而是采用二级页表结构,下面是利用页表进行虚拟到物理内存转化的示意图(一个页表对应一个 4KB 页框空间):
当我们拿着虚拟地址要找物理地址时,首先要查页表,找到要访问的物理地址在哪一个页框,然后加上页内偏移量就能找到具体物理地址了,下面是详细过程(整个查询过程由硬件 MMU 完成,比用软件效率更高): 首先用虚拟地址的高 10 位查页目录表,2^10 正好对应页目录表中 1024 个页表起始地址。利用页目录表索引到具体哪个页表后,然后用虚拟地址的次高 10 位查该页表,查找页表后就得到了待找页框的起始物理地址。最后用虚拟地址的低 12 位正好就能索引整个页框 4kb 空间的所有字节,这里我们称为页内偏移,由此就能访问页内的任意一个字节了。
这样映射的话一个进程的页表所占空间大小:页目录表(4kb)+ 1024 个页表(1024 * 4kb)约等于 4MB,并且一个进程并不会把整个物理内存映射完,实际大小只会小于 4MB,远小于按字节映射的 32GB 大小。
下面是一些细节补充:
- CR3 保存当前进程页表的基地址,该地址是物理地址。
- 虚拟地址 32 个比特位被划分为 10/10/12 三部分编译器不参与。
- 虚拟地址高 20 位相同的地址,映射后一定存放在同一个页框中。
- 除了能把虚拟地址转化为物理地址,也能把物理地址转化为虚拟地址。
- 但进程首次加载磁盘块时,OS 会做以下事情:先内存管理申请内存(在 pages 数组中申请 page 结构体)-> 拿到数组下标 -> 计算出页框的起始物理地址 -> 把页框的起始物理地址填充到页表中 -> ELF 拿 MMU 进行虚实转换。
- 除了访问单字节变量外,我们还可能访问 int 数组 结构体等等变量,这些变量大小不止一个字节,但是所有变量都只有一个地址——开辟空间的最小字节的地址,所以页表在进行转化的时候,虽然只能拿到一个字节的地址,实际是参考变量具体类型,利用起始地址 + 偏移量的方式进行转换。
- 有了上面的认识,我们就能认识到 OS 申请和管理物理内存都是以 4KB 为单位的,所以写时拷贝和缺页中断并不是只拷贝访问的一个变量、缺页中断也不是只换入一个变量,它们俩都是以 4KB 为单位进行操作的。
- 既然 OS 申请和管理物理内存都是以 4KB 为单位的,为什么我们可以用 new、malloc 申请 1、4、n 字节空间呢?这是因为 c/c++ 在语言层面有自己的内存管理机制,语言已经为你提前向系统申请好了一些空间,并用链表组织管理起来,你需要多少就给你多少,类似于 STL 的空间配置器。
- 单级⻚表对连续内存要求⾼,于是引⼊了多级⻚表,但是多级⻚表也是⼀把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。 有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加⼀个中间层来解决。MMU 引⼊了新武器,江湖人称快表的 TLB(其实,就是缓存)。当 CPU 给 MMU 传新虚拟地址之后,MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,齐活。但 TLB 容量⽐较⼩,难免发⽣ Cache Miss,这时候 MMU 还有保底的⽼武器⻚表,在⻚表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到 TLB,让它记录一下刷新缓存。
linux 中多线程的实现
我们先介绍 linux 中多线程的实现,后面再推而广之,介绍线程在 OS 学科中的实现方式。 我们已经知道了线程是进程内部的一个执行流,对于 linux 程序员来说,新出现了一个概念最方便的实现方式就是复用已有的代码,类似管道的实现,所以 linux 中线程是用进程模拟的,复用了进程的代码和内核结构。 我们知道一个进程要有 task_struct、mm_struct、页表和物理内存中进程的代码和数据,当进程创建一个线程后本质就是创建了一个 task_struct,指向当前进程的 mm_struct,也就是进程和线程共用一个 mm_struct、页表,不同的线程各自拥有该进程的代码和数据资源,各自执行当前进程代码的一部分。 内核管理线程也要遵循先描述再组织,操作系统学科中描述线程的标准结构体是 TCB(Thread Control Block),也被相应的内核容器化数据结构管理起来。(这里我们会隐约感知到 linux 中是没有 TCB 的,但是 windows 是真正实现了 TCB 的,学到后面我相信大家就会通透了)
所以线程本质就会在进程虚拟空间中运行,未来要让'线程'各自访问进程虚拟空间中不同的资源只用让'线程'执行不同的入口函数即可(如示例中主线程执行 main 函数,新线程执行 thread_routine),编译器会为我们自动完成资源划分:分配虚拟地址、页表映射。(因为编译器会编址不同的函数,函数本质就是许多汇编指令,每条汇编指令都有地址,函数地址也叫做入口地址就是第一条指令的地址)
下面我们回头再理解一下以前我们学习的进程,以前我们说进程是真正意义上的进程的一种特殊情况,进程内部是可以有一个或者多个执行流(线程)的,而以前我们学习的进程是只有一个执行流的,所以我们会说一个进程一个 task_struct,实际上一个进程可以有多个 task_struct。
现在我们切换视角,站在 CPU 的角度看待进程和线程,在 CPU 角度是不区分进程和线程的,在 CPU 眼中只有 task_struct(执行流),所以在 linux 中把进程和线程统一称为轻量级进程,也就是在 linux 中没有线程的概念!!
现在我们就能理解在操作系统学科中对进程和线程下的定义了:进程是承担分配系统资源的基本实体。线程是 CPU 调度的基本单位,线程在进程内部运行。
线程操作
有了上面的认识,我们上手写份代码感受一下线程:先看一个创建线程的库函数,我们先照葫芦画瓢试用一下,在线程控制章节再细讲。
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
void* thread_routine(void* arg) {
std::string name = (const char*)arg;
while (true) {
std::cout << "我是新线程..., 名字是:" << name << std::endl;
sleep(1);
}
}
int main() {
pthread_t tid;
pthread_create(&tid, nullptr, thread_routine, (void*)"thread-1");
while (true) {
std::cout << "我是主线程..." << std::endl;
sleep(1);
}
return 0;
}
编译一下:
下面我们运行一下程序:
在 linux 中,用 LWP(Light Weight Process)标识不同的轻量级进程,ps -aL 是查看进程 LWP 的指令,我们可以看到,一个进程中的所有线程的 pid 的相同的,进程中不同的线程有不同的 LWP,其中主线程和进程 pid 相同,所以实际上 OS 调度只看 LWP,但是原来不是说 CPU 是看 pid 的吗?其实这两者并不冲突,因为以前我们的进程只有一个执行流,所以 LWP 本身就等于 pid,当一个进程有多个执行流时,进程 pid 和主执行流的 LWP 相等。
linux 中没有创建线程的系统调用,只有创建轻量级进程的系统调用:clone,这个系统调用很复杂,我们只用了解它的第一个参数即可:
我们之前介绍的 pthread_create 底层就会调用 clone。 但是这时会出现一个问题,因为 linux 只有创建轻量级进程的系统调用,有一些没学过 linux 的用户是不知道轻量级进程的概念的,他们只知道进程和线程,所以 linux 对上封装了一套 pthread 库,也叫做原生线程库(它和 linux 内核是会一直绑定在一起的),包含一整套线程接口供用户使用。
缺页异常
设想,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 错误中断进程直接挂掉。
线程的优点
-
创建一个新线程的代价要比创建一个新进程小得多。
-
与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。最主要的区别是是否切换 CPU 中的 CR3 寄存器(指向当前进程的页表),也就是线程切换后虚拟内存空间和页表不会被切换,但是进程切换时会将虚拟内存空间和页表都切换。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。 (CPU 如何判断是否需要切换 CR3 寄存器呢?因为进程中所有线程共享同一个时间片,当进程中创建线程会将剩余时间片与所有线程平分,当时间片还未耗尽时 CPU 就是进行线程切换,不用切换 CR3 寄存器,当时间片耗尽后 CPU 就是进行进程切换,也就需要切换 CR3 寄存器) 另一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。TLB 缓存虚拟到物理的映射关系,cache 缓存物理内存数据(CPU 与物理内存之间),简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲 TLB(快表)会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件 cache。(一个进程内不同线程之间的切换不需要更换 TLB 缓存(块表),因为所有线程用于用一个虚拟地址空间和页表,虚拟到物理的映射关系都是同一套,但切换进程后原来的 TLB 就失效了,所以需要更换 TLB。)
-
线程占用的资源要比进程少很多。
-
能充分利用多处理器的可并行数量(进程线程共有)。
-
在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务(进程线程共有)。
-
计算密集型应用,如我们平时写的算法题,为了能在多处理器系统上运行,将计算分解到多个线程中实现,注意计算密集型应用创建的线程不宜过多,一般推荐创建的线程个数 = CPU 个数 * 核数,避免同一核中线程切换的花销。
-
I/O 密集型应用,特点是程序频繁与外设交互,为了提高性能,将 I/O 操作重叠,线程可以同时等待不同的 I/O 操作。I/O 密集型应用可以适当多创建一些线程,因为 I/O 操作大部分时间都在等,不如多创建一些线程一起等。
这里补充说明一下 linux 中进程 task_struct、CPU、物理内存、虚拟内存、cache 缓存和 TLB 缓存之间的关系,把知识串联一下,下面用一个实际执行流程串联所有环节:调度器选中进程 A → 从进程 A 的 task_struct 加载 CPU 上下文(寄存器、PC 等)→ 将 task_struct->mm->pgd 的物理地址加载到 CR3 寄存器;CPU 读取指令(访问虚拟地址 VA1)→ 先查 TLB:若 TLB 命中:直接得到物理地址 PA1;若 TLB 未命中:遍历 CR3 指向的页表,找到 VA1→PA1,将映射写入 TLB;CPU 用 PA1 访问 Cache:若 Cache 命中:直接读取数据到 CPU 寄存器,执行指令;若 Cache 未命中:从物理内存读取 PA1 对应的数据到 Cache,再读取到 CPU 寄存器执行;指令执行完成后,调度器判断是否切换进程:若切换到进程 B:保存进程 A 的上下文到其 task_struct → 加载进程 B 的上下文 → 切换 CR3 到进程 B 的页表基址 → TLB 失效 → 后续访问进程 B 的 VA 重新填充 TLB/Cache;若切换到进程 A 的线程:仅切换线程上下文(寄存器/栈),CR3/TLB/Cache 均不失效,开销极低。
线程的缺点
- 性能损失。该点其实不属于线程的缺点范畴,而是不合理使用多线程会造成性能损失,一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低。编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。(例如一个进程中的一个线程除零或野指针了,该进程内的所有线程都会收到退出信号)
- 缺乏访问控制。进程之间具有独立性,这就是具有访问控制的良好实现。而线程之间的数据(也就是虚拟地址空间)基本都是共享的,虽然我们的说法是线程各自享有自己的虚拟地址资源,但是线程之间是可以互相访问资源的,所以说线程缺乏访问控制。进程是访问控制的基本粒度,在一个线程中调用某些 OS 函数会对整个进程造成影响。
- 编程难度提高。编写与调试一个多线程程序比单线程程序困难得多。
线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
线程用途
合理的使⽤多线程,能提高 CPU 密集型程序的执⾏效率。 合理的使⽤多线程,能提高 IO 密集型程序的⽤户体验(如⽣活中我们⼀边写代码⼀边下载开发⼯具,就是多线程运⾏的⼀种表现)。
指令查看线程
ps -aL
- a:显示所有与终端相关的进程;
- L:显示线程(轻量级进程)信息。
区别于之前使用的 ps ajx
- a:显示所有与终端相关的进程(含其他用户);
- x:补充显示无控制终端的进程(如后台守护进程);
- j:以'作业格式'展示,重点体现进程的作业控制结构。
二、Linux 进程 VS 线程
线程独占资源
- 线程 ID(LWP)。
- CPU 中一组寄存器,线程的上下文数据(因为线程是要被调度的,由内核 LWP 维护)。
- 线程栈(线程除了被调度,线程运行时也会产生各种临时数据,线程自己也要有函数调用、栈帧结构,在 pthread 库中)。 补充:线程共享进程虚拟地址空间这个'大容器',但栈区在这个容器里被拆分成了多个独立、不重叠的子区域,每个线程独占其中一个。
- errno。
- 信号屏蔽字。
- 调度优先级。 面试时前三个必须答出,这样面试官就会知道你是知道线程不是静态而是动态的。
线程共享资源
进程中的线程共享同一地址空间,因此 Text Segment、Data Segment、堆区、命令行参数等等都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表。
- 每种信号的处理方式(SIG_IGN、SIG_DFL 或者自定义的信号处理函数)。
- 当前工作目录。
- 用户 id 和组 id。 进程和线程的关系如下图:


