Linux线程概念详解:从分页存储、页表、MMU与TLB,深入理解线程和进程的区别与联系

Linux线程概念详解:从分页存储、页表、MMU与TLB,深入理解线程和进程的区别与联系


🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 | 《C++从入门到入土》 | 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能


文章目录

  • Linux线程概念详解:从分页存储、页表、MMU与TLB,深入理解线程和进程的区别与联系
  • 一、Linux线程的基本概念
    • [1.1 什么是线程?](#1.1 什么是线程?)
    • [1.2 一个进程至少有一个线程](#1.2 一个进程至少有一个线程)
    • [1.3 线程运行在哪里?](#1.3 线程运行在哪里?)
    • [1.4 Linux如何看待线程?](#1.4 Linux如何看待线程?)
  • 二、为什么需要分页式存储管理?
    • [2.1 如果没有虚拟内存,会发生什么?](#2.1 如果没有虚拟内存,会发生什么?)
    • [2.2 我们真正希望实现什么?](#2.2 我们真正希望实现什么?)
    • [2.3 连续虚拟空间,不连续物理空间](#2.3 连续虚拟空间,不连续物理空间)
  • 三、物理页框与虚拟页
    • [3.1 为什么要把内存分成固定大小的块?](#3.1 为什么要把内存分成固定大小的块?)
    • [3.2 什么是页框?](#3.2 什么是页框?)
    • [3.3 什么是虚拟页?](#3.3 什么是虚拟页?)
    • [3.4 为什么分页能减轻外部碎片问题?](#3.4 为什么分页能减轻外部碎片问题?)
  • 四、操作系统如何管理大量物理页?
    • [4.1 一个具体计算](#4.1 一个具体计算)
    • [4.2 struct page:内核对物理页的描述](#4.2 struct page:内核对物理页的描述)
    • [4.3 flags:描述物理页状态](#4.3 flags:描述物理页状态)
    • [4.4 lru:页面链表管理](#4.4 lru:页面链表管理)
    • [4.5 mapping:与映射对象建立联系](#4.5 mapping:与映射对象建立联系)
    • [4.6 index:页面在映射对象中的位置](#4.6 index:页面在映射对象中的位置)
    • [4.7 private:不同用途下的私有信息](#4.7 private:不同用途下的私有信息)
    • [4.8 _mapcount:页面映射计数](#4.8 _mapcount:页面映射计数)
    • [4.9 virtual:内核虚拟地址](#4.9 virtual:内核虚拟地址)
    • [4.10 管理物理页需要多少内存?](#4.10 管理物理页需要多少内存?)
    • [4.11 这一部分为什么重要?](#4.11 这一部分为什么重要?)
  • 五、页表:虚拟地址究竟怎样找到物理内存?
    • [5.1 什么是页表?](#5.1 什么是页表?)
    • [5.2 32位虚拟地址空间有多大?](#5.2 32位虚拟地址空间有多大?)
    • [5.3 单级页表的问题](#5.3 单级页表的问题)
  • 六、多级页表与页目录
    • [6.1 为什么把一张大页表拆成多张小页表?](#6.1 为什么把一张大页表拆成多张小页表?)
    • [6.2 32位二级页表模型](#6.2 32位二级页表模型)
    • [6.3 10MB程序需要多少张页表?](#6.3 10MB程序需要多少张页表?)
    • [6.4 页目录有什么作用?](#6.4 页目录有什么作用?)
    • [6.5 CR3寄存器](#6.5 CR3寄存器)
  • 七、一个虚拟地址如何完成地址转换?
    • [7.1 为什么要把32位地址拆分?](#7.1 为什么要把32位地址拆分?)
    • [7.2 为什么低12位是页内偏移?](#7.2 为什么低12位是页内偏移?)
    • [7.3 地址转换的步骤](#7.3 地址转换的步骤)
    • [7.4 为什么课件强调物理页地址4KB对齐?](#7.4 为什么课件强调物理页地址4KB对齐?)
  • 八、MMU:地址转换由谁完成?
    • [8.1 什么是MMU?](#8.1 什么是MMU?)
    • [8.2 CPU是否直接使用物理地址?](#8.2 CPU是否直接使用物理地址?)
    • [8.3 多级页表有什么代价?](#8.3 多级页表有什么代价?)
  • 九、TLB:为什么地址转换不需要每次都重新查页表?
    • [9.1 什么是TLB?](#9.1 什么是TLB?)
    • [9.2 为什么需要缓存?](#9.2 为什么需要缓存?)
    • [9.3 TLB命中](#9.3 TLB命中)
    • [9.4 TLB未命中](#9.4 TLB未命中)
    • [9.5 与线程切换有什么联系?](#9.5 与线程切换有什么联系?)
  • 十、缺页异常:虚拟地址找不到映射怎么办?
    • [10.1 什么是Page Fault?](#10.1 什么是Page Fault?)
    • [10.2 Major Page Fault:主要缺页](#10.2 Major Page Fault:主要缺页)
    • [10.3 Minor Page Fault:次要缺页](#10.3 Minor Page Fault:次要缺页)
    • [10.4 Invalid Page Fault:非法访问](#10.4 Invalid Page Fault:非法访问)
    • [10.5 为什么malloc和new申请的内存可能没有立刻分配全部物理页?](#10.5 为什么malloc和new申请的内存可能没有立刻分配全部物理页?)
    • [10.6 与fork写时复制的关系](#10.6 与fork写时复制的关系)
    • [10.7 为什么有时数组越界不一定立即崩溃?](#10.7 为什么有时数组越界不一定立即崩溃?)
  • 十一、回到线程:为什么要学这么多内存管理知识?
  • 十二、线程的优点
    • [12.1 创建线程的开销通常小于创建独立进程](#12.1 创建线程的开销通常小于创建独立进程)
    • [12.2 线程切换的某些开销较低](#12.2 线程切换的某些开销较低)
    • [12.3 更容易共享进程数据](#12.3 更容易共享进程数据)
    • [12.4 可以利用多个CPU执行单元](#12.4 可以利用多个CPU执行单元)
    • [12.5 适合计算密集型与IO密集型任务](#12.5 适合计算密集型与IO密集型任务)
  • 十三、线程的缺点
    • [13.1 可能造成性能损失](#13.1 可能造成性能损失)
    • [13.2 健壮性降低](#13.2 健壮性降低)
    • [13.3 缺乏进程之间那样的隔离性](#13.3 缺乏进程之间那样的隔离性)
    • [13.4 多线程程序更难调试](#13.4 多线程程序更难调试)
  • 十四、线程异常:一个线程崩溃会发生什么?
  • 十五、线程的主要用途
    • [15.1 计算密集型应用](#15.1 计算密集型应用)
    • [15.2 IO密集型应用](#15.2 IO密集型应用)
  • 十六、Linux进程与线程:哪些资源共享,哪些资源独占?
    • [16.1 进程与线程的基本区别](#16.1 进程与线程的基本区别)
    • [16.2 线程独有的资源](#16.2 线程独有的资源)
    • [16.3 线程共享的资源](#16.3 线程共享的资源)
    • [16.4 文件描述符也是共享的](#16.4 文件描述符也是共享的)
    • [16.5 与进程间通信的对比](#16.5 与进程间通信的对比)
  • 十七、总结:Linux为什么能够在一个进程中运行多个线程?

前言:为什么理解线程之前,要先理解虚拟地址空间?

在之前的 Linux 进程控制中,我们已经学习过:

c 复制代码
fork()
exec()
waitpid()

也了解了进程的基本概念。

通常我们会认为:

进程是一个正在执行的程序。

但是,一个进程内部是否只能存在一条执行路线?

例如,某个程序需要同时完成:

  • 接收用户输入。
  • 执行计算任务。
  • 等待文件 IO。
  • 更新程序状态。

如果每项任务都创建一个新进程,是否会产生额外的资源开销?

能否在同一个进程内部,让多条执行路线共同使用已经分配好的资源?

这就引出了本节的核心概念:

线程(Thread)。


一、Linux线程的基本概念

1.1 什么是线程?

课件给出了一个定义:

在一个程序里的一个执行路线,就叫做线程。

更加准确地说:

线程是进程内部的一条控制序列,也就是一条执行流。

例如:

text 复制代码
进程A
    |
    +-- 执行流1
    |
    +-- 执行流2
    |
    +-- 执行流3

如果进程内部只有一条执行流,就是我们之前主要学习的单线程程序。

如果进程内部存在多条执行流,就是多线程程序。

1.2 一个进程至少有一个线程

假设编写:

cpp 复制代码
#include <iostream>

int main()
{
    std::cout << "hello Linux" << std::endl;
    return 0;
}

我们并没有主动创建任何线程。

但程序开始执行时,依然存在一条执行路线。

这条初始执行流就是主线程。

因此:

之前学习的普通单进程程序,也可以理解为只有一条执行流的进程。

1.3 线程运行在哪里?

因为线程的本质:

线程在进程内部运行,本质是在进程地址空间内运行。

一个进程拥有自己的虚拟地址空间。

进程中的代码、数据、堆以及其他映射区域,都与这份地址空间有关。

多个线程则可以在同一份进程地址空间内运行。

因此,我们可以先建立以下认识:

text 复制代码
进程:提供资源和运行环境

线程:在这些资源上执行任务

1.4 Linux如何看待线程?

在 Linux 中,进程与线程都可以通过内核中的任务管理机制表示。

c 复制代码
task_struct

来说明这一点。

我们可以先从概念上理解:

同一个进程中的多条线程拥有各自的执行上下文,却可以共享很多进程资源。

对于调度器而言,这些执行实体比彼此完全独立的进程具有更紧密的资源共享关系。

因此,Linux 线程也常被称为:

轻量级进程(Light Weight Process,LWP)。

不过,仅仅知道这个名称还不够。

要进一步理解线程,首先需要研究进程的虚拟地址空间。


二、为什么需要分页式存储管理?

2.1 如果没有虚拟内存,会发生什么?

假设物理内存中运行三个程序:

text 复制代码
程序A:需要100MB

程序B:需要200MB

程序C:需要150MB

如果要求每个程序的所有数据必须存放在一块连续的物理内存区域中,就可能形成:

text 复制代码
程序A | 程序B | 程序C | 剩余空间

一段时间后:

text 复制代码
程序B退出

操作系统将它使用的物理空间回收。

于是物理内存变成:

text 复制代码
程序A | 空闲200MB | 程序C | 剩余空间

现在又启动一个需要 250MB 的程序。

即使物理内存中的空闲空间总量足够,也可能因为没有足够大的连续区域而无法按照这种模型分配。

这就是内存碎片带来的问题。

2.2 我们真正希望实现什么?

从用户程序的角度:

程序最好拥有连续、方便使用的地址空间。

从物理内存管理的角度:

程序使用的物理内存最好不必连续。

这两个要求看起来存在矛盾。

但通过虚拟内存和分页机制,可以同时满足。

2.3 连续虚拟空间,不连续物理空间

例如某个程序的虚拟空间依次包含:

text 复制代码
代码区域
数据区域
堆区域
栈区域

从程序自己的视角看,这些内容都位于统一的虚拟地址空间中。

但对应的实际物理页面,可以分布在不同的物理位置。

例如:

text 复制代码
虚拟页0 -> 物理页框8

虚拟页1 -> 物理页框3

虚拟页2 -> 物理页框15

因此:

虚拟地址连续,不代表物理内存连续。

实现这层转换的关键,就是页表。


三、物理页框与虚拟页

3.1 为什么要把内存分成固定大小的块?

如果操作系统管理的内存区域大小完全不固定,管理和映射都会更加复杂。

因此,分页存储管理将内存划分为具有规定大小的单位。

在常见的 x86 Linux 环境中,基本页大小通常是:

text 复制代码
4KB

但实际页大小取决于处理器架构与系统配置,也存在大页等机制。

3.2 什么是页框?

物理内存被划分为一个个固定大小的区域。

这些区域称为:

text 复制代码
页框(Page Frame)

例如:

text 复制代码
物理页框0:4KB

物理页框1:4KB

物理页框2:4KB

页框是物理内存管理的基本单位之一。

3.3 什么是虚拟页?

虚拟地址空间也可以按照相同的基本页大小划分。

例如:

text 复制代码
虚拟页0:4KB

虚拟页1:4KB

虚拟页2:4KB

虚拟页可以通过页表映射到物理页框。

所以需要区分:

虚拟页是逻辑地址空间中的划分单位,页框是物理内存中的存储区域。

3.4 为什么分页能减轻外部碎片问题?

假设程序需要:

text 复制代码
12KB

使用 4KB 的基本页,那么需要三个虚拟页。

这三个虚拟页可以分别映射到任意三个适合分配的物理页框。

不要求三个物理页框连续。

因此,固定大小的分页方式降低了连续物理内存分配的要求。

但是页大小也不能无限增大。

如果每个页非常大,而程序实际使用的数据较少,就可能浪费页内部空间。

这种浪费属于:

text 复制代码
内部碎片

所以页大小需要在管理开销与空间利用率之间取得平衡。


四、操作系统如何管理大量物理页?

4.1 一个具体计算

课件给出了这样的例子:

假设:

text 复制代码
物理内存:4GB

基本页大小:4KB

那么物理页框数量为:

4 G B 4 K B = 1048576 \frac{4GB}{4KB}=1048576 4KB4GB=1048576

即:

text 复制代码
1,048,576个物理页框

操作系统需要知道这些页框的状态:

text 复制代码
哪些正在使用?

哪些可以分配?

哪些属于文件缓存?

哪些页面已经被修改?

哪些页面被其他对象引用?

因此:

内核必须用数据结构描述和管理物理页。

4.2 struct page:内核对物理页的描述

课件引用了 Linux 历史内核中的:

c 复制代码
struct page

结构。

以下代码为历史版本源码的关键字段摘录,仅供理解内核物理页管理思想,不代表当前 Linux 内核的完整结构定义。

c 复制代码
/* include/linux/mm_types.h */

struct page
{
    unsigned long flags;

    union
    {
        struct
        {
            struct list_head lru;
            struct address_space* mapping;
            pgoff_t index;
            unsigned long private;
        };

        /* 其他页面用途对应的成员 ... */
    };

    union
    {
        atomic_t _mapcount;
        unsigned int page_type;
        unsigned int active;
        int units;
    };

#ifdef WANT_PAGE_VIRTUAL
    void* virtual;
#endif

    /* 其他成员 ... */
};

原始结构还包含大量用于内存分配器的联合体成员,例如:

c 复制代码
struct kmem_cache* slab_cache;
void* freelist;

这些成员反映了同一个物理页面描述结构可能服务于不同用途,因此采用联合体复用相关存储。

接下来按照课件顺序,重点解释几个字段。

4.3 flags:描述物理页状态

c 复制代码
unsigned long flags;

用于保存物理页的状态标志。

例如,课件引用历史内核中的标志:

c 复制代码
PG_locked
PG_error
PG_referenced
PG_uptodate
PG_dirty

这些标志分别用于描述不同的页面状态。

例如:

text 复制代码
PG_locked:页面相关锁定状态

PG_dirty:页面数据已被修改

PG_uptodate:页面内容处于最新有效状态

这些标志可以通过位来保存。

假设:

text 复制代码
0000 0101

可以表示某几个状态位被设置。

所以:

一个整数中的不同二进制位,可以同时保存一个页面的多个状态。

这是内核常用的数据表示方法。

4.4 lru:页面链表管理

c 复制代码
struct list_head lru;

在课件所引用的历史实现中,用于将页面组织进相应的链表结构。

为什么需要链表?

因为操作系统除了知道某个物理页存在,还需要按照一定策略组织、访问和回收页面。

通过链表等结构,可以方便地把多个页面关联起来进行管理。

4.5 mapping:与映射对象建立联系

c 复制代码
struct address_space* mapping;

用于关联页面与相关的地址空间映射对象。

例如某些页面可能与文件数据有关。

内核需要知道:

text 复制代码
这个页面和哪个映射对象有关?

从而管理对应的页面。

4.6 index:页面在映射对象中的位置

c 复制代码
pgoff_t index;

表示页面在相关映射对象中的偏移位置。

这样系统不仅知道页面所属对象,还能够确定它对应的数据位置。

4.7 private:不同用途下的私有信息

c 复制代码
unsigned long private;

在不同页面用途下,可以保存特定的附加信息。

例如历史源码中,不同标志状态下,这个成员可能关联不同的内核对象。

这里重点理解:

struct page可以通过成员和联合体描述物理页的多种用途,而不需要给所有页面使用完全不同的外部管理结构。

4.8 _mapcount:页面映射计数

c 复制代码
atomic_t _mapcount;

该字段解释物理页的映射情况。

在所引用的历史实现中,它用于反映相关页面被页表映射的计数状态,并采用特定的计数编码。

例如在相应实现中,值为:

text 复制代码
-1

可以表示没有普通映射引用。

需要注意,不能直接把 _mapcount 等同于一个始终从 0 开始的普通引用计数。具体计数规则属于对应版本的内核实现细节。

4.9 virtual:内核虚拟地址

c 复制代码
void* virtual;

历史内核的某些配置会为页面描述结构提供这个成员。

它可以关联页面在内核虚拟地址空间中的地址。

例如,某些高端内存页面可能没有长期有效的内核直接映射,需要在使用时建立相应映射。

这也是为什么:

text 复制代码
物理页

与:

text 复制代码
虚拟地址

是两个不同概念。

4.10 管理物理页需要多少内存?

继续采用课件中的假设:

text 复制代码
物理内存总大小:4GB

每页大小:4KB

物理页数量:1,048,576

假设每个struct page大小为40字节

那么全部页面描述结构占用:

1048576 × 40 = 41943040 Bytes 1048576\times40=41943040\text{ Bytes} 1048576×40=41943040 Bytes

约为:

text 复制代码
40MiB

相对于 4GiB 内存而言,这是一项可以接受但并非不存在的管理开销。

这里的 40字节 只是课件用于计算的假设值,并不是现代内核中 struct page 的固定大小。

4.11 这一部分为什么重要?

因为它解释了一种操作系统设计思想:先描述再组织。

也就是说:

物理内存虽然是硬件资源,但操作系统必须先用数据结构描述它,才能进行分配、回收、映射和管理。

之前我们学习进程时,也看到:

c 复制代码
task_struct

用于描述任务。

现在又看到:

c 复制代码
struct page

用于描述物理页。

两者体现了类似的思路:

text 复制代码
先描述对象,再组织和管理对象

五、页表:虚拟地址究竟怎样找到物理内存?

5.1 什么是页表?

如果程序使用虚拟地址:

text 复制代码
0x12345000

CPU需要知道它对应哪个物理地址。

操作系统通过页表建立虚拟页和物理页框之间的映射关系。

页表中包含多个页表项。

可以概念化为:

text 复制代码
虚拟页编号
     |
     v
页表项
     |
     v
物理页框编号

此外,页表项还会保存一些访问权限或状态信息。

5.2 32位虚拟地址空间有多大?

按照课件使用的 32 位环境:

2 32 = 4294967296 2^{32}=4294967296 232=4294967296

即:

text 复制代码
4GB的虚拟地址范围

如果每页:

text 复制代码
4KB

那么全部虚拟页数量:

4 G B 4 K B = 1048576 \frac{4GB}{4KB}=1048576 4KB4GB=1048576

因此理论上需要描述:

text 复制代码
1,048,576个虚拟页

对应的映射。

5.3 单级页表的问题

假设一个页表项占:

text 复制代码
4字节

那么单级页表需要:

1048576 × 4 = 4194304 Bytes 1048576\times4=4194304\text{ Bytes} 1048576×4=4194304 Bytes

也就是:

text 复制代码
4MiB

如果要求这个单级页表整体以一块连续物理内存保存,会形成较大的连续存储要求。

另外,很多进程实际上并不会使用全部 4GB 虚拟地址空间。

如果为所有虚拟页都提前分配映射表项,也会产生不必要的开销。

因此,课件引出了:

多级页表。


六、多级页表与页目录

6.1 为什么把一张大页表拆成多张小页表?

可以先理解一个生活例子。

假设一本书有:

text 复制代码
1024页

如果查找内容只能一页一页翻,效率并不理想。

我们可以先根据目录定位章节,再在章节内部查找对应内容。

页表也可以采用分层思想。

6.2 32位二级页表模型

这里我们使用经典 32 位 x86、4KB 基本页的二级页表模型进行讲解。

其中:

text 复制代码
页目录:1024个目录项

每张页表:1024个页表项

每个页表项:4字节

一张页表大小:

1024 × 4 = 4096 Bytes 1024\times4=4096\text{ Bytes} 1024×4=4096 Bytes

恰好:

text 复制代码
4KB

一张页表可以映射:

1024 × 4 K B = 4 M B 1024\times4KB=4MB 1024×4KB=4MB

的虚拟地址区域。

6.3 10MB程序需要多少张页表?

如果某个程序使用的虚拟区域总计约为:

text 复制代码
10MB

并且这些映射集中分布在连续区域中。

由于每张页表可以覆盖:

text 复制代码
4MB

所以按覆盖范围向上取整:

⌈ 10 / 4 ⌉ = 3 \lceil10/4\rceil=3 ⌈10/4⌉=3

需要三张相应的二级页表。

这比无条件提前建立覆盖全部 4GB 空间的页表更加灵活。

注意,这个计算建立在虚拟区域连续分布的简化前提下。如果实际映射分散到不同区域,需要的二级页表数量也可能不同。

6.4 页目录有什么作用?

如果把原来的大页表拆成很多小页表,那么:

text 复制代码
CPU怎样找到目标页表?

就需要一个更高层次的表。

这就是:

text 复制代码
页目录(Page Directory)

在经典二级页表模型中:

text 复制代码
页目录
   |
   v
二级页表
   |
   v
物理页框

页目录项负责找到对应页表。

二级页表项负责找到对应物理页框。

6.5 CR3寄存器

接着介绍:

text 复制代码
CR3

寄存器。

在经典 x86 分页模型中,CR3 用于保存当前地址转换所需页表根结构的相关物理地址信息。

因此:

text 复制代码
CR3
 |
 v
页目录
 |
 v
页表
 |
 v
物理内存

这是理解 CPU 如何进行地址转换的重要入口。


七、一个虚拟地址如何完成地址转换?

7.1 为什么要把32位地址拆分?

在经典 32 位 x86、4KB 基本页、二级页表模型中,一个虚拟地址可以划分为:

text 复制代码
高10位:页目录索引

中10位:页表索引

低12位:页内偏移

即:

10 + 10 + 12 = 32 10+10+12=32 10+10+12=32

其中:

text 复制代码
页目录索引

用于找到对应页表。

text 复制代码
页表索引

用于找到对应物理页框。

text 复制代码
页内偏移

用于找到页框内部的具体字节位置。

7.2 为什么低12位是页内偏移?

因为:

4 K B = 4096 = 2 12 4KB=4096=2^{12} 4KB=4096=212

一个 4KB 页内部包含 4096 个字节位置。

因此需要 12 位二进制数表示页内偏移。

7.3 地址转换的步骤

假设 CPU 产生一个虚拟地址。

第一步:

根据 CR3 找到页目录。

第二步:

利用虚拟地址中的高 10 位,找到对应页目录项。

第三步:

通过页目录项找到二级页表。

第四步:

利用中间 10 位找到页表项。

第五步:

从页表项取得物理页框的相关地址信息。

第六步:

结合低 12 位的页内偏移,得到实际物理地址。

这样就完成一次地址转换。

7.4 为什么课件强调物理页地址4KB对齐?

因为 4KB 页框的起始地址必须满足相应对齐要求。

所以在这个简化模型中,页框起始物理地址的低 12 位为:

text 复制代码
0

页表项只需使用相应的地址字段与状态字段,就能描述物理页框。

最终的物理地址由页框地址和页内偏移组合得到。


八、MMU:地址转换由谁完成?

8.1 什么是MMU?

MMU 全称:

text 复制代码
Memory Management Unit

中文通常称为:

text 复制代码
内存管理单元

它是处理器地址转换与内存访问保护机制中的重要硬件组成部分。

8.2 CPU是否直接使用物理地址?

普通用户程序通常使用虚拟地址。

例如:

cpp 复制代码
int value = 100;

如果程序访问:

cpp 复制代码
&value

获得的是程序虚拟地址空间中的地址。

CPU在执行相关内存访问时,需要通过硬件地址转换机制找到对应的物理位置。

可以先理解为:

text 复制代码
CPU产生虚拟地址
       |
       v
MMU地址转换
       |
       v
得到物理地址
       |
       v
访问物理内存

8.3 多级页表有什么代价?

假设使用二级页表。

如果每次地址转换都需要访问两级页表,再进行目标内存访问,就会增加额外开销。

如果页表级数更多,地址转换过程还可能涉及更多次页表访问。

所以多级页表虽然减轻了页表存储问题,却带来了地址转换性能方面的考虑。

这就引出了课件中的下一个概念:

TLB。


九、TLB:为什么地址转换不需要每次都重新查页表?

9.1 什么是TLB?

TLB 全称:

text 复制代码
Translation Lookaside Buffer

通常称为:

text 复制代码
快表

可以把它理解为用于加速地址转换的缓存结构。

9.2 为什么需要缓存?

假设程序在循环中反复访问:

cpp 复制代码
int arr[100];

这些数据可能集中在少量虚拟页中。

如果每次访问都重新进行完整的多级页表查询,就会产生较大的额外开销。

因此,可以缓存最近使用过的地址转换信息。

9.3 TLB命中

当 CPU 需要转换某个虚拟地址时,先查找 TLB。

如果其中已经保存了对应的可用转换信息,就可以较快得到物理地址。

这称为:

text 复制代码
TLB Hit

即 TLB 命中。

9.4 TLB未命中

如果没有找到:

text 复制代码
TLB Miss

就需要进一步进行页表查询。

在支持硬件页表遍历的体系结构上,相应硬件机制会完成所需的页表访问;其他架构也可能需要内核参与特定处理。

如果页表中存在合法映射,就可以取得相应地址信息,并更新相关缓存。

所以概念流程是:

text 复制代码
虚拟地址
    |
    v
查询TLB
    |
    +-- 命中 --> 得到转换信息
    |
    +-- 未命中 --> 查询页表
                       |
                       v
                  获得映射关系
                       |
                       v
                  更新TLB

注意:

TLB Miss不等于缺页异常。

TLB 没有缓存某条转换信息,不意味着页表中就不存在合法映射。

9.5 与线程切换有什么联系?

现在先记住一个结论:

同一进程中的多个线程通常共享同一份地址空间。

因此线程切换时,不一定需要切换到另一套完全不同的进程页表。

这有助于减少某些地址转换相关开销。

但不能简单理解为:

text 复制代码
任何进程切换都会清空全部TLB

具体行为依赖体系结构和相关硬件优化机制。


十、缺页异常:虚拟地址找不到映射怎么办?

10.1 什么是Page Fault?

假设程序访问一个虚拟地址。

CPU在地址转换或权限检查过程中发现当前页表状态不能满足访问要求,就可能触发:

text 复制代码
Page Fault

即缺页异常。

此时,内核需要检查具体原因。

课件将相关情况分为三类介绍。

10.2 Major Page Fault:主要缺页

Major Page Fault 通常表示此次缺页处理涉及实际存储 IO。

例如某个需要访问的文件页面暂时不在物理内存中,需要从存储设备读取。

可以概念化为:

text 复制代码
访问虚拟页
    |
    v
需要的数据不在内存
    |
    v
发生缺页异常
    |
    v
内核安排存储IO
    |
    v
建立映射并继续执行

课件也将其称为:

text 复制代码
Hard Page Fault

这里重点理解:主要缺页往往比无需实际存储 IO 的缺页具有更高的访问成本。

10.3 Minor Page Fault:次要缺页

Minor Page Fault 表示缺页处理不需要执行实际存储 IO。

例如所需物理页已经存在,只需要建立相应映射。

或者为符合条件的匿名内存建立页面映射。

课件将其称为:

text 复制代码
Soft Page Fault

简化流程:

text 复制代码
访问虚拟页
    |
    v
当前映射尚未满足要求
    |
    v
进入内核缺页处理
    |
    v
不需要实际存储IO
    |
    v
完成映射并继续执行

10.4 Invalid Page Fault:非法访问

假设:

cpp 复制代码
int* p = nullptr;
*p = 100;

程序试图访问不合法的内存地址。

在常见 Linux 环境中,如果实际执行了非法访问指令,可能触发异常,并最终导致:

text 复制代码
SIGSEGV

信号。

这里需要结合之前的信号知识理解:

并不是所有 Page Fault 都会终止进程。

合法的缺页可以得到处理。

而无法正常处理的非法访问,才可能导致异常信号与进程终止。

10.5 为什么malloc和new申请的内存可能没有立刻分配全部物理页?

之前我们学习:

cpp 复制代码
int* p = new int[100000];

或者:

c 复制代码
void* p = malloc(size);

可能产生这样的疑问:

申请了一大块内存,操作系统是不是立即为其中每个字节都分配好物理内存?

答案是不一定。

内存分配器可能先完成虚拟地址范围的管理。

对于某些页面,实际的物理页分配可以在后续首次访问时按需完成。

因此:

text 复制代码
申请虚拟地址空间

与:

text 复制代码
实际建立物理页面映射

可能发生在不同阶段。

10.6 与fork写时复制的关系

还可以回顾:

c 复制代码
fork()

之前我们知道,父子进程初始时可以共享部分只读映射到相同物理页的内容。

当某一方尝试写入时,可能触发写时复制相关的缺页处理。

内核为其建立新的私有物理页面映射。

所以缺页异常不仅是错误处理机制,也是内存管理的重要组成部分。

10.7 为什么有时数组越界不一定立即崩溃?

在 C/C++ 语言中,数组越界是未定义行为。

但从操作系统层面看,如果程序实际访问的地址仍然恰好处于当前进程可访问的映射区域中,硬件未必立即产生访问异常。

因此:

text 复制代码
程序发生未定义行为

并不意味着:

text 复制代码
每次都产生SIGSEGV

也正因为如此,不能把"程序没有崩溃"当作"内存访问一定合法"的证据。


十一、回到线程:为什么要学这么多内存管理知识?

现在重新观察第一章提出的问题:

一个进程中为什么能够存在多个线程?

因为一个进程的资源不一定只能被单独的一条执行流使用。

例如:

text 复制代码
同一份代码
同一份全局数据
同一份堆内存
同一份文件描述符表

可以供多个执行流访问。

而每条线程又需要保留自己的:

text 复制代码
执行上下文
栈
线程标识

等状态。

因此,可以得出:

只要合理划分和共享进程资源,就能够在同一份进程地址空间中组织多条执行流。

这也是前面详细学习虚拟地址空间、页表和物理内存管理的原因。


十二、线程的优点

接下来我们从资源开销、性能和应用场景三个角度分析了线程的优点。

12.1 创建线程的开销通常小于创建独立进程

两个完全独立的进程通常需要分别管理各自的地址空间和相关资源。

而同一进程中的多个线程可以共享许多资源。

因此,在一般情况下,创建线程所需的资源开销可能比创建一个全新独立进程更小。

12.2 线程切换的某些开销较低

同一进程中的多个线程共享地址空间。

因此,线程切换不必天然伴随独立进程地址空间的切换。

这能够减少部分内存管理相关开销。

但是线程切换依然需要保存和恢复相应执行上下文,也会产生调度成本。

12.3 更容易共享进程数据

例如:

cpp 复制代码
int global_value = 100;

同一进程中的多个线程通常都能够访问这个全局变量。

这使得线程之间交换数据更直接。

但是:

共享数据方便,也意味着必须考虑并发访问是否安全。

12.4 可以利用多个CPU执行单元

如果一个程序具有多个可以并行执行的任务,在适当的硬件和调度条件下,可以通过多个线程利用多个处理器核心。

例如:

text 复制代码
线程A计算任务1
线程B计算任务2
线程C计算任务3

12.5 适合计算密集型与IO密集型任务

对于计算密集型应用,可以将适合并行的计算任务拆分给多个线程。

对于 IO 密集型应用,可以在某些线程等待 IO 时,让其他线程继续完成工作。

但具体能否提高性能,还需要看任务规模、硬件资源和额外调度开销。


十三、线程的缺点

但是多线程并不一定总是更好。

13.1 可能造成性能损失

如果线程数量远多于适合并行执行的任务数量,可能增加:

text 复制代码
调度开销
上下文切换
同步开销

反而降低性能。

因此:

线程数量越多,并不意味着程序越快。

13.2 健壮性降低

多个线程可以访问同一份共享数据。

如果执行顺序不符合预期,就可能产生问题。

例如:

cpp 复制代码
global_value++;

如果多个线程并发执行,而没有进行合适的同步,就可能出现数据竞争。

具体的线程同步和互斥机制会在后续课程中进一步学习。

13.3 缺乏进程之间那样的隔离性

不同进程具有较强的地址空间隔离。

但同一进程中的线程共享地址空间。

一个线程对共享内存的不正确写入,可能破坏其他线程正在使用的数据。

13.4 多线程程序更难调试

单线程程序通常只需要沿着一条执行路线分析。

而多线程程序中:

text 复制代码
线程A什么时候运行?

线程B什么时候运行?

两个线程先后访问了什么数据?

这些问题都可能影响程序结果。

所以编写和调试多线程程序通常更加复杂。


十四、线程异常:一个线程崩溃会发生什么?

特别指出:

某个线程因严重异常终止,可能导致整个进程终止。

例如,一个进程存在:

text 复制代码
线程1
线程2
线程3

如果线程2发生非法内存访问,触发默认终止整个进程的信号处理动作,那么整个进程可能结束。

这意味着:

text 复制代码
线程1
线程2
线程3

都会随进程退出而结束。

所以不能将:

text 复制代码
线程终止

一概理解为:

text 复制代码
只结束一个独立的执行流,不影响其他线程

需要区分正常线程退出和导致进程终止的严重异常。


十五、线程的主要用途

最后介绍了线程的两类典型应用。

15.1 计算密集型应用

例如:

text 复制代码
大规模数值计算
图像处理
部分可并行算法

如果计算能够合理拆分,可以交给不同线程执行。

15.2 IO密集型应用

例如某个程序需要同时:

text 复制代码
接收输入
处理网络通信
读写文件

多个线程可以使不同任务的等待与执行过程发生重叠。

从而改善整体任务响应能力。

但最终是否采用多线程,还需要结合任务的实际需求判断。


十六、Linux进程与线程:哪些资源共享,哪些资源独占?

16.1 进程与线程的基本区别

进程是资源分配的基本单位。

线程是调度的基本单位。

这里是一种用于教学的基本抽象。

Linux 内核会为可调度的任务维护相应执行状态,而同一线程组中的任务可以共享地址空间等资源。

16.2 线程独有的资源

线程自己的部分数据:

资源 说明
线程ID 标识相应执行流
寄存器与上下文 保存当前线程的执行状态
栈 保存对应函数调用及局部数据等
errno 各线程可拥有自己的错误状态
信号屏蔽字 各线程具有各自的信号阻塞集合
调度属性 相关调度状态可按线程管理

其中:

text 复制代码
寄存器上下文

决定了线程下一步从哪里继续执行。

text 复制代码
栈

则与该线程的函数调用过程有关。

虽然各线程有自己的栈区域,但由于处于共享地址空间,同一进程中的其他线程原则上也可能通过有效地址访问这些栈区域。

所以这里说的"独有",主要强调执行用途,而不是完全的内存访问隔离。

16.3 线程共享的资源

同一进程中的线程通常共享:

资源 说明
代码段 可以执行相同的函数
数据段 可以访问全局变量、静态变量
堆 可以通过堆内存交换数据
文件描述符表 可以访问同一进程打开的文件
信号处理动作 共享相应信号的处理方式
当前工作目录 通常属于共享的进程资源
用户ID和组ID等凭证 以进程级语义对外表现

例如:

cpp 复制代码
int global_value = 100;

如果线程 A 修改:

cpp 复制代码
global_value = 200;

线程 B 访问的是同一份共享对象。

至于线程 B 什么时候能够可靠观察到修改,以及是否产生数据竞争,需要进一步考虑并发同步规则。

16.4 文件描述符也是共享的

回忆基础 IO:

c 复制代码
int fd = open("a.txt", O_WRONLY);

同一进程中的其他线程可以使用这个文件描述符。

因此,如果一个线程关闭:

c 复制代码
close(fd);

其他线程后续使用同一描述符时,也可能受到影响。

所以线程共享资源时,需要注意不同线程之间的相互影响。

16.5 与进程间通信的对比

之前学习不同进程之间通信,我们使用:

text 复制代码
管道
共享内存
消息队列

等方式交换数据。

而同一进程中的线程,本来就可以共享很多内存资源。

因此:

text 复制代码
进程之间的数据交换

和:

text 复制代码
线程之间的数据共享

所面对的问题并不完全相同。

多线程的优势在于共享方便,风险也恰恰来自共享方便。


十七、总结:Linux为什么能够在一个进程中运行多个线程?

现在可以将前面学到的知识组合起来。

首先:

text 复制代码
进程具有虚拟地址空间

其次:

text 复制代码
虚拟地址通过页表映射到物理内存

操作系统可以管理:

text 复制代码
代码
数据
堆
栈
文件描述符

等资源。

在同一份地址空间中,可以建立多个独立的执行上下文。

例如:

text 复制代码
进程地址空间
       |
       +-- 线程A:自己的寄存器和栈
       |
       +-- 线程B:自己的寄存器和栈
       |
       +-- 线程C:自己的寄存器和栈
       |
       +-- 共享的代码、数据和堆

因此:

线程可以理解为在同一进程资源环境中运行的多个执行流。

而 Linux 内核使用任务相关的数据结构管理这些执行流。

这也解释了为什么创建线程、切换线程以及线程之间共享资源,与创建彼此独立的进程有所不同。


相关推荐
qq_284274051 小时前
数控机床编程基础与坐标系:编程六步流程、程序号/程序段/程序字格式,与右手定则下车床铣床XYZ轴判定(含考点)
笔记·学习·自动化·制造
小范的技术工坊1 小时前
Linux应用开发(文件IO)
linux·stm32·单片机
救救孩子把1 小时前
AI面试八股文-13_12-AI 框架与运维面试题
运维·人工智能·面试
德林恩宝1 小时前
On-Policy Distillation(OPD)学习文档
人工智能·学习·机器学习·大模型·llm·大模型微调
小范的技术工坊1 小时前
Linux应用开发(文件属性)
linux·运维·服务器
问心无愧05131 小时前
[SUCTF 2019]EasySQL
笔记
ss2731 小时前
PG实战笔记 | 09 PostgreSQL psql客户端基本操作
数据库·笔记·postgresql
wuminyu1 小时前
JMM学习总结:深入理解happens-before关系
java·linux·c语言·jvm·c++
xxwl5852 小时前
区域赛分类题解
c++·笔记·学习·算法