1、线程的定义
进程是运行起来的程序,进程 = 内核数据结构 + 程序代码与数据,一个完整的进程,内核会为其创建全套专属资源结构,但进程更高层的核心定义是操作系统分配系统资源的最小基本实体,这是区分进程和线程的终极核心,线程是进程内部的执行分支 / 执行流
Linux内核具象定义
线程:共享进程所有资源的、独立的PCB执行流
单进程原生状态:一个进程默认只有一个PCB,从main函数入口,串行所有代码(函数1、函数2、函数3等),从头到尾顺序执行
多线程逻辑:不创建新的地址空间、不创建新页表、不加载新代码数据,仅新增一个全新的PCB,新PCB共享当前进程的所有资源,然后还需要为新PCB指定一个新的代码入口函数
最终效果:是同一个进程的代码区,被多个不同的PCB分别执行不同的代码片段,多个执行流并行运行、资源完全共享,这就是多线程
2、单线程进程和多线程进程
之前学过的所有进程,全部都是单线程进程,是进程的特殊情况
进程 = 1套资源(地址空间+页表+代码数据) + 1个 / 多个PCB执行流
单线程进程:1套资源 + 1个PCB,这是最基础的进程形态
多线程进程:1套资源 + 多个PCB,这是主流并发形态
进程与线程的核心分工
进程:管理资源(分配资源),负责向系统申请内存、页表、IO、文件描述符等所有资源,全局唯一、独立不共享
线程:管理执行(CPU调度的单位),执行指令任务,共享所有进程的所有资源,创建销毁开销极小
如果把进程类比为一个家庭,那么线程就是家庭成员,每个家庭成员独立做事,互不干扰,但共享房子、资产,成员增减(线程创建/销毁)不会销毁家庭资源
单线程进程:单人家庭,一个人包揽全部工作
多线程进程:多人家庭,分工协作,共同维持家庭运行
3、CPU调度的核心视角
3.1 CPU调度的进程和底层认知
CPU调度时,完全不区分进程和线程,CPU只识别PCB,无论是进程的PCB,还是线程的PCB,在CPU队列里完全等价,CPU只根据PCB的状态、优先级、时间片进行调度,不关心资源归属
3.2 结论
线程是操作系统CPU调度的最小单位,所有CPU调度,本质都是线程调度,单线程进程的调度,本质也是调度它唯一的线程PCB
4、线程内核实现
无独立的TCB结构体,直接复用进程的PCB结构体来描述线程,核心逻辑:线程 = 共享资源的轻量级进程,这样无需单独设计调度队列、算法、接口,所有进程调度逻辑完全复用,内核简单、高效
进程与线程的核心区别
- 资源归属
- 进程:独立资源(地址空间、页表、文件描述符),资源不共享
- 线程:共享所属进程的全部资源,无独立资源
- 创建开销
- 进程:极大,需要申请全套内核资源、拷贝页表、地址空间
- 调度单位
- 进程:不参与CPU直接调度
- 线程:操作系统唯一CPU调度单位
- 内核实现
- Windows:PCB + 多级TCB架构
- Linux:全PCB架构,线程是轻量级进程
- 容错性
- 进程:独立隔离,一个进程崩溃不影响其他进程
- 线程:共享进程资源,一个线程崩溃,整个进程直接退出
在CPU的视角,CPU的调度器不区分进程和线程,调度器只看task_struct,不管它是传统进程还是轻量级进程
5、pthread_creat创建一个线程
cpp
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine)(void*), void *arg);
pthread_create不是系统调用,是用户态 pthread 原生线程库的函数,pthread是用户态库,不是内核系统调用
pthread_t *thread:输出型参数。创建成功后,把新线程 ID 写入这个变量const pthread_attr_t *attr:线程属性。NULL 表示使用默认属性void *(*start_routine)(void *):线程回调函数指针。新线程启动后,自动执行这个函数void *arg:传给线程回调函数的参数;不需要传参填 NULL- 返回值:成功返回0,失败返回错误码
cpp
#include <iostream>
#include <unistd.h>
#include <thread>
void routine(int cnt)
{
while (cnt)
{
std::cout << "new thread : " << cnt << std::endl;
sleep(1);
cnt--;
}
}
int main()
{
// 创建线程对象t,启动一个新子线程
// 第一个参数:线程执行函数routine
// 第二个参数10:传给 routine 的形参cnt,也就是routine(10)
std::thread t(routine, 10);
while (true)
{
std::cout << "main thread" << std::endl;
sleep(1);
}
t.join();
return 0;
}
!image.png
子线程打印 10 次之后,子线程函数执行结束,子线程退出,主线程继续循环打印
6、页框
6.1 物理内存分块
物理内存逻辑上划分成一个个 4KB 数据块,叫页框;磁盘文件系统块同样 4KB
磁盘最小硬件访问单元:扇区 512B,操作系统不按 512B 管理,粒度太小效率低
Linux文件系统与内存交换粒度:4KB
- 哪怕只读文件1 个字节 ,OS 也要把磁盘上对应的整个 4KB 块拷贝到物理内存的一个页框
- 修改这个 4KB 块内任意 1bit,写回磁盘时必须把整个 4KB 块刷回磁盘
- 磁盘块 ↔ 物理内存页框:一一对应交换单位。
- 加载对象:可执行程序代码、数据、动态库、图片音频,全部按 4KB 块加载
内存申请释放粒度:内核层面申请 / 释放物理内存,单位是 4KB 页框,用户程序可以只使用页框内一小部分字节,但 OS 分配回收必须整页操作
所以用户读写可以字节,OS 和磁盘 IO、物理内存分配,都是 4KB 整块操作
6.2 页框地址转数组下标映射
物理内存被切割成固定大小块,叫页框,大小4KB
页框起始地址一定是4096的整数倍,低12位永远是0
- 页框起始地址 → 页框下标:地址 >> 12
- 页框下标 → 页框起始地址:下标 << 12
- 例如:下标为5,物理页框起始地址 = (5 << 12 = 5 * 4096)
如果给的不是页首地址,而是页内部任意字节地址
- 提取页框起始地址:把地址低 12 位全部清零
- 位运算写法:
addr & 0xFFFFF000 - 0x0FFF 是低 12 位全 1,取反就是高 20 位保留、低 12 位清零
操作系统管理物理内存:维护一个MEM 数组,数组下标就是页框编号。数组里存每个页框的属性,分配内存:遍历数组,找占用标志=0的下标,标记为占用,下标转物理页框地址
7、虚拟地址的拆分结构
7.1 三段结构
32bit 虚拟地址,硬件 MMU 自动切成三段:
| 高 10 位 | 中间 10 位 | 低 12 位 |
|---|---|---|
| 页目录索引 PDE index | 页表索引 PTE index | 页内偏移 offset |
高0位:2^10=1024,取值 0~1023,用来索引页目录
中间 10 位:2^10=1024,取值 0~1023,用来索引二级页表
低 12 位:2^12=4096,取值 0~4095,页内偏移,定位页框内部的字节
页目录包含2^10=1024个条目,每个条目4字节,指向一张页表的起始地址,页目录和页表自身大小均为4KB,恰好占满一个物理页框
虚拟地址的高 20 位用来找物理页框的起始地址;低 12 位在页框内部找字节,所以两个虚拟地址,只要高 20 位相同,一定属于同一个物理页框,只是页内偏移不同
7.2 两级页表整体结构
- 页目录
页目录本身存放在一个物理页框,大小4KB
一共1024个页目录项PDE,每个PDE占4 字节
PDE 里面:高 20 位 = 下级页表的物理页框编号;低 12 位 = 标志位
整个进程只有一张页目录
CPU 的CR3寄存器:存放当前进程页目录的物理起始地址。切换进程时,操作系统修改 CR3,MMU 就自动切换到新进程的页表
- 二级页表
每一张二级页表也占用一个独立物理页框,4KB
每张页表包含1024个页表项PTE,每个PTE占4个字节
PTE里面,最高20位是目标物理页框编号,低 12 位是标志位
页目录里的每一个 PDE,指向一张独立的二级页表
页目录共 1024 项 → 最多可以有 1024 张二级页表
- 完整地址翻译流程
输入:32位虚拟地址
第一步从CR3拿到页目录物理起始地址,第二步取出虚拟地址高10位,作为下标,在页目录中取出 PDE,PDE 高 20 位 → 二级页表的物理页框编号,左移 12 位得到二级页表物理起始地址,第三步取出虚拟地址中间 10 位,作为下标,在二级页表取出PTE,第四步取出虚拟地址低12位,第五步最终物理地址 = 物理页框起始地址加页内偏移
7.3 页表项4字节结构
PDE、PTE 都是 4 字节
- bit31~bit12(高 20bit):物理页框编号
因为页框地址低 12 位恒为 0,所以只需要保存高 20 位,使用时左移 12 位恢复完整物理地址
- bit11~bit0(低 12bit):标志位
包含:存在位 §:这个页有没有在物理内存,缺页中断核心标志,读写权限,共享位、缓存控制位等
7.4 多级页表优势
- 权限占用
全部 4GB 虚拟地址都映射:
1 页目录 (4KB) + 1024 张页表 (每张 4KB) = 4KB + 1024 * 4KB=4MB+4KB,对比单层页表几十 GB,差距巨大
- 真实运行场景:按需创建页表
进程虽然拥有 4GB 虚拟地址空间,但不会一次性把所有页表全部创建,页目录是进程 PCB 创建时必须分配,二级页表只有当进程需要用到对应的虚拟地址区域时,操作系统才动态申请物理页框创建这张二级页表
例如:进程只需要 8MB 虚拟空间,一张二级页表映射 4MB,只需要 2 张二级页表,总页表开销:4KB + 2*4KB = 12KB
- 懒加载
进程的代码、数据不是一次性全部加载进物理内存。
只有访问某虚拟地址时,MMU 查表发现 P 位 = 0,触发缺页中断
缺页中断处理流程:
- 操作系统分配物理页框
- 把磁盘上对应内容读到这个物理页
- 填充页表项 PTE,把存在位 P 置 1
- 返回,重新执行触发中断的指令
7.5 写时拷贝
fork 创建子进程的时候,不会立刻复制物理内存,父子进程的页表,全部指向同一份物理页框 ,页表权限标记为只读,只要任意一方尝试修改这个页里的数据 → MMU 发现写权限不匹配,触发异常,OS 这时才单独分配新的物理页框,把整个 4KB 页框拷贝一份,修改对应进程的页表,各自指向自己独立的页框
为什么一次性拷贝整个 4KB 页框?局部性原理:访问内存时,大概率接下来还会访问相邻数据。如果只拷贝单个变量,附近变量修改时会反复触发异常,开销更大,用空间换时间
7.6 寄存器
- CR0 :控制开关,里面有位用来开启 / 关闭 MMU 分页机制。开机初期是实模式,不分页,直接访问物理内存;OS 启动后开启分页,进入保护模式
- CR2 :当发生页故障(缺页、非法访问),CPU 自动把触发异常的虚拟地址存入 CR2。内核读 CR2 就能知道是哪个地址出错,用于报错、调试
- CR3 :当前进程页目录的物理基地址
- CR1为保留寄存器,不使用
| 寄存器 | 作用 | 核心要点 |
|---|---|---|
| CR0 | CPU 模式控制 | 开启 / 关闭 MMU 分页机制;决定 CPU 是实模式 (开机直接访问物理内存,无虚拟地址)还是保护模式(启用虚拟地址 + 页表) |
| CR1 | 保留 | 未使用 |
| CR2 | 页故障线性地址寄存器 | 保存触发缺页 / 页异常的虚拟地址。野指针、越界访问触发异常时,硬件自动把出错虚拟地址写入 CR2;内核异常处理程序读取 CR2 拿到出错地址,用于报错、段错误(segfault)定位 |
| CR3 | 页目录基址寄存器 | 存放页目录的物理地址(不是虚拟地址)原因:如果存虚拟地址,MMU 要查页表翻译 CR3 本身,就陷入鸡生蛋蛋生鸡的无限递归。属于进程硬件上下文 ;进程切换时,内核会把 CR3 的值替换为新进程页目录物理地址,一切换 CR3 就切换整个进程的页表与虚拟地址空间 |
8、Linux轻量级进程、分页存储管理和物理页描述
8.1 Linux 轻量级进程 LWP
Linux 内核没有独立 TCB(线程控制块),复用 task_struct(PCB)来表示执行流。内核层面不存在传统意义上的线程,只有轻量级进程 LWP;用户态 pthread 库封装 LWP,对外提供线程 API
8.1.1 进程和LWP
- 进程:资源分配的基本单位,拥有独立虚拟地址空间、独立一套页表。创建完整进程时,内核要拷贝页表、虚拟地址空间、文件描述符表等资源,开销大
- LWP(轻量级进程):CPU 调度的基本单位 ,同一个进程内的多个 LWP:共享同一份虚拟地址空间、同一张页表,CR3 寄存器在 LWP 切换时不会修改,多个 LWP 共享:代码段、全局数据、堆、文件描述符表、信号处理方式、工作目录、UID/GID,LWP私有资源:自己的 task_struct、独立内核栈、用户栈、信号屏蔽字、调度优先级、LWP 号
进程切换:更换 CR3,更换整套页表,开销大。
同一进程内 LWP 切换:不切换 CR3,页表不变,只切换 CPU 通用寄存器、栈等执行上下文,开销很小
8.1.2 clone系统调用
Linux 所有创建执行流的底层都是clone系统调用,依靠 flag 标记决定要不要复制地址空间:
clone+ 复制虚拟地址空间(复制页表) → 创建独立进程(fork 底层就是封装 clone)clone+ 不复制虚拟地址空间,共享页表 → 创建LWP 轻量级进程
vfork:也是 clone 封装,共享地址空间。pthread_create(用户态 pthread 库):上层给用户线程 API,底层调用 clone 创建 LWP,对用户透明
8.1.3 Linux线程的两层视图
- 内核视角:只有 task_struct(PCB),所有执行流都叫 LWP,调度器统一调度 LWP,没有专门线程结构 TCB
- 用户视角:pthread 库封装 LWP,抽象出 "线程" 概念,用户写多线程程序感知不到 LWP
8.1.4 多线程共享资源带来的核心问题
全局变量、堆资源全部共享,多个 LWP 并发读写共享资源,会产生竞态条件,必须用互斥锁、信号量等同步互斥机制保护
8.2 MMU + 两级页表
- MMU :CPU 内部硬件电路,专门负责虚拟地址 → 物理地址的翻译
- 输入:虚拟地址 + CR3(页目录物理基址)
- 输出:物理地址,送到内存总线访问物理内存
- 32 位虚拟地址拆分:高 10 位 (页目录索引) + 中间 10 位 (页表索引) + 低 12 位 (页内偏移)
- 页目录:1024 项,每一项指向二级页表的物理地址
- 二级页表:1024 项,每一项存放物理页框的高 20 位(物理页 4KB 对齐,低 12 位恒 0)
- 最终物理地址 = 查到的物理页框起始地址 + 虚拟地址低 12 位页内偏移
- TLB快表
MMU 内部的高速缓存,缓存近期虚拟地址→物理页映射。优先查 TLB 命中,不用走两级页表内存查询,大幅加速地址翻译;TLB miss 才去内存遍历页
地址翻译失败 → 硬件异常,不是外部中断
MMU 翻译失败(页不存在、权限不足、写只读页),触发页异常(Page Fault,缺页异常) ,CPU 陷入内核,内核执行缺页异常处理函数page_fault_handler,分三类:
- 硬缺页:物理内存没有该页,需要从磁盘加载数据到物理内存,建立页表映射
- 软缺页:数据已经在物理内存,只是当前进程页表没有建立映射,不需要读磁盘,只更新页表项。典型:共享动态库
- 无效页异常 :非法访问(野指针、越界),直接触发段错误
Segmentation fault,杀死进程
页表本质:进程看待物理内存资源的窗口。虚拟地址越多,未来可以映射的物理内存越多;划分虚拟地址,本质就是划分物理内存资源
页的相关概念:
- 页:虚拟内存侧,4KB 一块;页框:物理内存侧 4KB 一块。
struct page:内核描述每一个物理页框的结构体,记录页状态(脏页、锁定、引用计数、LRU、私有 / 共享等)
脏页:页内数据被修改,需要后续写回磁盘;干净页和磁盘文件一致,无需回写。
- 碎片:
外部碎片:不连续空闲小块,分页机制可以消除;
内部碎片:一个 4KB 页只用一部分,页内剩余空间浪费。
malloc/new:只分配虚拟地址空间,不会立刻分配物理内存 。等到第一次访问该虚拟地址,触发缺页中断,内核才真正分配物理页、填充页表,也就是延迟分配