【Linux】线程概念和控制

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结构体来描述线程,核心逻辑:线程 = 共享资源的轻量级进程,这样无需单独设计调度队列、算法、接口,所有进程调度逻辑完全复用,内核简单、高效

进程与线程的核心区别
  1. 资源归属
  • 进程:独立资源(地址空间、页表、文件描述符),资源不共享
  • 线程:共享所属进程的全部资源,无独立资源
  1. 创建开销
  • 进程:极大,需要申请全套内核资源、拷贝页表、地址空间
  1. 调度单位
  • 进程:不参与CPU直接调度
  • 线程:操作系统唯一CPU调度单位
  1. 内核实现
  • Windows:PCB + 多级TCB架构
  • Linux:全PCB架构,线程是轻量级进程
  1. 容错性
  • 进程:独立隔离,一个进程崩溃不影响其他进程
  • 线程:共享进程资源,一个线程崩溃,整个进程直接退出

在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 两级页表整体结构

  1. 页目录

页目录本身存放在一个物理页框,大小4KB

一共1024个页目录项PDE,每个PDE占4 字节

PDE 里面:高 20 位 = 下级页表的物理页框编号;低 12 位 = 标志位

整个进程只有一张页目录

CPU 的CR3寄存器:存放当前进程页目录的物理起始地址。切换进程时,操作系统修改 CR3,MMU 就自动切换到新进程的页表

  1. 二级页表

每一张二级页表也占用一个独立物理页框,4KB

每张页表包含1024个页表项PTE,每个PTE占4个字节

PTE里面,最高20位是目标物理页框编号,低 12 位是标志位

页目录里的每一个 PDE,指向一张独立的二级页表

页目录共 1024 项 → 最多可以有 1024 张二级页表

  1. 完整地址翻译流程

输入: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 多级页表优势

  1. 权限占用

全部 4GB 虚拟地址都映射:

1 页目录 (4KB) + 1024 张页表 (每张 4KB) = 4KB + 1024 * 4KB=4MB+4KB,对比单层页表几十 GB,差距巨大

  1. 真实运行场景:按需创建页表

进程虽然拥有 4GB 虚拟地址空间,但不会一次性把所有页表全部创建,页目录是进程 PCB 创建时必须分配,二级页表只有当进程需要用到对应的虚拟地址区域时,操作系统才动态申请物理页框创建这张二级页表

例如:进程只需要 8MB 虚拟空间,一张二级页表映射 4MB,只需要 2 张二级页表,总页表开销:4KB + 2*4KB = 12KB

  1. 懒加载

进程的代码、数据不是一次性全部加载进物理内存。

只有访问某虚拟地址时,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 标记决定要不要复制地址空间:

  1. clone + 复制虚拟地址空间(复制页表) → 创建独立进程(fork 底层就是封装 clone)
  2. clone + 不复制虚拟地址空间,共享页表 → 创建LWP 轻量级进程
  • vfork:也是 clone 封装,共享地址空间。
  • pthread_create(用户态 pthread 库):上层给用户线程 API,底层调用 clone 创建 LWP,对用户透明

8.1.3 Linux线程的两层视图

  1. 内核视角:只有 task_struct(PCB),所有执行流都叫 LWP,调度器统一调度 LWP,没有专门线程结构 TCB
  2. 用户视角:pthread 库封装 LWP,抽象出 "线程" 概念,用户写多线程程序感知不到 LWP

8.1.4 多线程共享资源带来的核心问题

全局变量、堆资源全部共享,多个 LWP 并发读写共享资源,会产生竞态条件,必须用互斥锁、信号量等同步互斥机制保护

8.2 MMU + 两级页表

  1. MMU :CPU 内部硬件电路,专门负责虚拟地址 → 物理地址的翻译
  • 输入:虚拟地址 + CR3(页目录物理基址)
  • 输出:物理地址,送到内存总线访问物理内存
  1. 32 位虚拟地址拆分:高 10 位 (页目录索引) + 中间 10 位 (页表索引) + 低 12 位 (页内偏移)
  • 页目录:1024 项,每一项指向二级页表的物理地址
  • 二级页表:1024 项,每一项存放物理页框的高 20 位(物理页 4KB 对齐,低 12 位恒 0)
  • 最终物理地址 = 查到的物理页框起始地址 + 虚拟地址低 12 位页内偏移
  1. TLB快表

MMU 内部的高速缓存,缓存近期虚拟地址→物理页映射。优先查 TLB 命中,不用走两级页表内存查询,大幅加速地址翻译;TLB miss 才去内存遍历页

地址翻译失败 → 硬件异常,不是外部中断

MMU 翻译失败(页不存在、权限不足、写只读页),触发页异常(Page Fault,缺页异常) ,CPU 陷入内核,内核执行缺页异常处理函数page_fault_handler,分三类:

  1. 硬缺页:物理内存没有该页,需要从磁盘加载数据到物理内存,建立页表映射
  2. 软缺页:数据已经在物理内存,只是当前进程页表没有建立映射,不需要读磁盘,只更新页表项。典型:共享动态库
  3. 无效页异常 :非法访问(野指针、越界),直接触发段错误Segmentation fault,杀死进程

页表本质:进程看待物理内存资源的窗口。虚拟地址越多,未来可以映射的物理内存越多;划分虚拟地址,本质就是划分物理内存资源

页的相关概念:

  • 页:虚拟内存侧,4KB 一块;页框:物理内存侧 4KB 一块。
  • struct page:内核描述每一个物理页框的结构体,记录页状态(脏页、锁定、引用计数、LRU、私有 / 共享等)

脏页:页内数据被修改,需要后续写回磁盘;干净页和磁盘文件一致,无需回写。

  • 碎片:

外部碎片:不连续空闲小块,分页机制可以消除;

内部碎片:一个 4KB 页只用一部分,页内剩余空间浪费。

malloc/new:只分配虚拟地址空间,不会立刻分配物理内存 。等到第一次访问该虚拟地址,触发缺页中断,内核才真正分配物理页、填充页表,也就是延迟分配

相关推荐
CarIise1 小时前
MyBatis缓存与分页插件:从一级二级缓存到底层原理与PageHelper实战
java·开发语言·spring
宸津-代码粉碎机7 小时前
OpenAI 连夜迎战 Grok Bot 和 Muse:AI 智能体从 “会聊天” 到 “能办事”,现在入场还来得及吗
java·大数据·人工智能·分布式·python
噢,我明白了9 小时前
java中唯一键和幂等键的应用
java·后端
程序猿乐锅9 小时前
【黑马点评 | 第八篇】Redisson分布式锁
java·数据库·spring boot·redis·分布式·spring·缓存
考虑考虑9 小时前
synchronized字符串常量
java·后端·java ee
长谷深风1119 小时前
Tool与Skill:AI能力设计的分水岭
java·人工智能·ai·大模型·aiagent
m0_587383009 小时前
广州24小时自助健身房解决方案实战指南与系统部署要点
java·spring·小程序·架构·需求分析
Escalating_xu9 小时前
【C 语言】深入理解指针(1·下):指针运算、野指针、assert 与传址实战
java·c语言·开发语言
周杰偷奶茶10 小时前
【Java】数据类型与变量
java·开发语言