Linux线程:从内存分页机制(Page Table/TLB/Page Fault)彻底读懂 Linux 线程本质

上篇文章:Linux进程信号捕捉与操作系统运行本质深度解析

目录

1.Linux线程:重新定义"轻量"

[1.1 线程的核心概念](#1.1 线程的核心概念)

1.2分页式存储管理

1.2.1虚拟地址和页表的由来

小总结

1.2.2物理内存管理

1.2.3页表

1.2.4页目录结构

1.2.5两级页表的地址转换

转换流程图:

1.2.6快表TLB(缓存机制)

1.2.7缺页异常

三类缺页异常深度对比:

[2. Linux 线程的优缺点与代码实战](#2. Linux 线程的优缺点与代码实战)

[2.1 线程的核心优势](#2.1 线程的核心优势)

[2.2 线程的致命缺陷](#2.2 线程的致命缺陷)

[2.3 线程安全与数据不一致:C++ 代码实战](#2.3 线程安全与数据不一致:C++ 代码实战)

实验现象与原理解析:

2.4线程的异常现象

2.5线程用途

[3. Linux 进程与线程的底层对比与资源边界](#3. Linux 进程与线程的底层对比与资源边界)

[3.1 核心分工](#3.1 核心分工)

[3.2 线程的"私有财产"与"共享大锅饭"](#3.2 线程的“私有财产”与“共享大锅饭”)

3.3进程的多个线程共享


1.Linux线程:重新定义"轻量"

1.1 线程的核心概念

在之前的进程章节中,我们了解到进程的本质是 内核数据结构(PCB/task_struct) + 进程自身的代码和数据 。从内核视角来看:进程是承担分配系统资源的基本单位

而在一路执行流中,我们将其中一个具体的执行路线称为线程(Thread) 。更准确的定义是:线程是进程内部的一个执行分支。线程的粒度比进程更小、更细,也更加"轻量"。

  • 线程是"一个进程内部的控制序列"。一切进程至少都有一个执行流。

  • 线程在进程内部运行,其本质是在进程的虚拟地址空间内运行

  • 在 Linux 系统中,从 CPU 的视角来看,每一个被调度的 PCB(在 Linux 中即 task_struct)都比传统进程更加轻量化。

  • 透过进程虚拟地址空间,我们可以看到进程的大部分资源。将这些资源合理地分配给不同的执行流,就形成了多个并行的线程执行流。

理解线程,必须要先吃透内核的内存分页机制,因为线程的本质就是"共享地址空间" 。线程之所以能做到"轻量",是因为它们不需要像进程那样拥有独立的页表与内存映射。要想彻底搞懂线程是如何共享资源的、线程切换为什么比进程切换快得多,我们就必须先剥开 Linux 虚拟内存的内核外壳,探寻分页式存储管理的底层奥秘。

1.2分页式存储管理

1.2.1虚拟地址和页表的由来

在没有虚拟内存和分页机制下,每个用户程序在物理内存上所对应的空间都必须是连续的,如下:

痛点:由于每个程序的代码、数据长度都是不一样的,按照这样的映射方式,物理内存将会被分割为各种离散的,大小不同的块。经过一段运行时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。

我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。此时,虚拟内存和分页就出现了,如下图:

把物理内存按照一个固定的长度的页框进行分割 ,有时叫做物理页。每个页框包含一个物理页(page)。一个页的大小等于页框的大小。大多数32位体系结构支持4KB的页,而64位体系结构一般会支持8KB的页。

区分一页和一个页框:

页框:一个存储区域。

页:一个数据块,可以存放在任何页框或磁盘中。

有了这种机制,CPU便并非是直接访问物理内存地址,而是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每一个正在执行的进程分配的一个逻辑地址,在32位机上,其范围从0~4G.

操作系统通过将虚拟地址空间和物理内存之间建立映射关系,也就是页表。这张表上记录了每一对页和页框的映射关系,能让CPU间接的访问物理内存地址。

小总结

其思想就是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能将连续的虚拟内存映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题。

1.2.2物理内存管理

操作系统需要对每个页进行管理,内核用struct page结构表示系统中的每个物理页,出于节省内存的考虑,struct page中使用了大量的联合体union。

以下是简化版的内核源码结构:

cpp 复制代码
/* include/linux/mm_types.h */
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; /* uses lru */
                struct
                { /* Partial pages */
                    struct page *next;
#ifdef CONFIG_64BIT
                    int pages;    /* Nr of pages left */
                    int pobjects; /* Approximate count */
#else
                    short int pages;
                    short int pobjects;
#endif
                };
            };
            struct kmem_cache *slab_cache; /* not slob */
            /* Double-word boundary */
            void *freelist; /* first free object */
            union
            {
                void *s_mem;            /* slab: first object */
                unsigned long counters; /* SLUB */
                struct
                {                        /* SLUB */
                    unsigned inuse : 16; /* ⽤于SLUB分配器:对象的数⽬ */
                    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 */
    ...
}

核心成员解析:

  1. flags:用来存放页的状态。这些状态包括是不是脏的,是不是被锁定在内存中等。flag的每一位单独表示一种状态,所以它至少可以同时表示出32种不同的状态。这些标志定义在<linux/page-flags.h>中。其中一些比特位非常重要,如PG_locked用于指定页是否锁定,PG_uptodate用于表示页的数据已经从块设备读取并且没有出现错误。

  2. _mapcount:表示在页表中有多少项指向该页,也就是这一页被引用了多少次。当计数值变为-1时,就说明当前内核并没有引用这一页,于是在新的分配中就可以使用它。

  3. virtual:是页的虚拟地址。通常情况下,它就是页在虚拟地址中的地址。有些内存(即所谓的高端内存)并不永久的映射到内核空间上。在这种情况下,这个域的值为NULL,需要的时候,必须动态的映射这些页。

要注意的是struct page与物理页相关,而并非与虚拟页相关。而系统中的每个物理页都要分配一个这样的结构体。算struct page占40个字节的内存,假定系统的物理页为4KB大小,系统有4GB物理内存。那么系统中共有页面1048576个(1兆个:2^20),所以描述这么多页面的page结构体消耗的内存只不过40MB,相对系统4GB内存而言,仅仅是很小的一部分罢了。

页的大小对于内存利用和系统开销来说非常重要,页太大,页内必然会剩余较大不能利用的空间(页内碎片)。页太小,虽然可以减小页内碎片的大小,但是页太多,会使得页表太长而占用内存,同时系统频繁地进行页转化,加重系统开销。因此,页的大小通常为512B~8KB,windows/Linux系统的页框大小为4KB.

1.2.3页表

页表中的每一个表项,指向一个物理页的开始地址。在32位系统中,虚拟内存的最大空间是4GB,这是每一个用户程序都拥有的虚拟内存空间。既然需要让4GB的虚拟内存全部可用,那么页表中需要能够表示这所有的4GB空间,那么就一共需要4GB/4KB = 1048576个表项。如图:

虚拟内存看上去被虚线"分割"为一个个单元,但实际上不是真的分割,虚拟内存仍然是连续的。

页表中的物理地址与物理内存之间是随机的映射关系,哪里可用就指向哪里(物理页)。虽然最终使用的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使用的都是线性地址,只要它是连续的即可,最终都能通过页表找到实际的物理地址。

如果要解决大容量页表,最好的方式就是将页表看为普通文件,对它进行离散分配,即对页表再分页,由此形成多级页表的思想。

1.2.4页目录结构

由上所知,每一个页框都被一个页表中的一个表项来指向。那么这1024个页表也需要被管理起来。管理页表的表称之为页目录表形成二级页表。如图:

  • 所有页表的物理地址被页目录表项指向。
  • 页目录的物理地址被CR3寄存器指向,这个寄存器中,保存了当前正在执行任务的页目录地址。

所以操作系统在加载用户程序的时候,不仅仅需要为程序内容分配物理内存,还需要为用来保存程序的页目录和页表分配物理内存。

1.2.5两级页表的地址转换

假设 CPU 获得了一个 32 位的虚拟地址,其划分如下:

  • 高 10 位31 \\sim 22):一级页号(用来检索页目录表,找到对应的二级页表基地址)。

  • 中 10 位21 \\sim 12):二级页号(用来检索二级页表,找到实际物理页框的起始物理地址)。

  • 低 12 位11 \\sim 0):物理页内偏移量(2\^{12} = 4\\text{ KB},用于在选中的物理页中准确定位字节)。

转换流程图:
复制代码
 虚拟地址: [ 一级页号 (10bit) ] [ 二级页号 (10bit) ] [ 页内偏移 (12bit) ]
                 |                     |                    |
                 v                     v                    |
CR3 ---> [ 页目录表 ]                 |                    |
                 |                     |                    |
                 +--> 寻址二级页表 --->|                    |
                             |                              |
                             +--> 寻址物理页框 (高20位) --> + --> [最终物理地址]

将逻辑地址(0000000000,0000000001,11111111111 )转换为物理地址的过程:

  1. 在32位处理器中,采用4KB的页大小,则虚拟地址中低12位为页偏移,剩下高20位给页表,分为两级,每个级别占10个bit(10 + 10)
  2. CR3寄存器读取页目录起始地址,再根据一级也好查页目录表,找到下一级页表在物理内存中存放位置。
  3. 根据二级页号查表,找到最终想要访问的内存块号。
  4. 结合页内偏移量得到物理地址。
  5. 注意:一个物理页的地址一定是4KB对齐的(最后的12位全为0),所以只需要记录物理页地址的高20位即可。
  6. 以上操作实际上就是MMU的工作流程。MMU(Memory Manage Unit)是一种硬件电路,其速度很快,主要工作是进行内存管理,地址转换只是作用之一。

1.2.6快表TLB(缓存机制)

MMU要先进行两次页表查询确定物理地址,在确认了权限等问题后,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。那么当页表变为N级时,就变为了N次检索+1次读写。可见,页表级数越多,查询的步骤越多,对于CPU来说,等待时间越长,效率越低。

也就是说,单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。

为了提升效率,计算机科学中的所有问题都可以通过添加一个中间层来解决。MMU引入了快表TLB(Translation Lookaside Buffer,旁路转换缓冲,俗称快表)

当CPU给MMU传新虚拟地址之后,MMU先去问TLB那边有没有,如果有,就直接拿到物理地址发到总线给内存。但TLB容量较小,难免发生Cache Miss,这时MMU还有保底的老武器页表,在页表中找到之后,MMU除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录一下刷新缓存。

TLB 是一种集成在 CPU 内部的高速 SRAM。它记录了近期最常使用的虚拟页到物理页框的映射关系。当 TLB 命中时,地址翻译延迟几乎为零。

1.2.7缺页异常

如果CPU给MMU的虚拟地址,在TLB和页表都没有找到对应的物理页,该怎么办?这就是缺页异常(Page Fault),它是一个由硬件中断触发的可以由软件逻辑纠正的错误

假如目标内存页在物理内存中没有对应的物理页或者存在但无对应权限,CPU就无法获取数据,这种情况下CPU就会报告一个缺页错误。

由于CPU没有数据就无法计算,CPU罢工,用户进程出现缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的Page Fault Handler处理。

缺页中断会交给Page Fault Handler处理,其根据缺页中断的不同类型会进行不同的处理:

三类缺页异常深度对比:
异常类型 英文名称 触发场景 内核处理机制
硬缺页错误 Hard / Major Page Fault 物理内存中完全没有对应的数据,数据还躺在磁盘里(如刚运行程序或内存被交换到了 Swap 分区)。 CPU 挂起当前进程,发出磁盘 I/O 请求将数据读入物理内存,建立页表映射,随后恢复进程。
软缺页错误 Soft / Minor Page Fault 物理内存中其实已经存在该数据(例如其他进程已经将此动态链接库加载到了物理内存中),但当前进程的页表还没建立映射。 内核直接在当前进程的页表中指向该物理页框,无需触发耗时的磁盘 I/O。多进程共享内存、动态链接库常发生此类缺页。
无效缺页错误 Invalid Page Fault 进程企图访问不属于它的非法地址(越界访问),或对只读内存(如常量区 .rodata)进行写操作。 内核判定为非法操作,不予建立映射,直接向进程发送 SIGSEGV 信号,进程异常终止(Segment Fault)。

2. Linux 线程的优缺点与代码实战

有了上述内存知识的铺垫,我们现在可以轻松理解线程的本质了: 在 Linux 中,同一个进程内的多个线程,它们共享同一个进程虚拟地址空间。这意味着,它们共用同一套页目录和页表!

2.1 线程的核心优势

  1. 创建⼀个新线程的代价要比创建⼀个新进程小得多,创建新线程不需要为其开辟独立的虚拟地址空间和拷贝页表,只需分配少量的 task_struct 与私有栈空间。

  2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少:

  • 最主要区别:**线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。**这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
  • 另一个隐藏的损耗:上下文的切换会扰乱处理器的缓存机制。一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间作废。当改变虚拟内存空间时,处理的页表缓冲TLB会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但在线程切换中不会出现这个问题,还有硬件cache。
  1. 线程占用资源比进程少

  2. 能充分利用多处理器的可并行数量

  3. 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务

  4. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现

  5. I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

2.2 线程的致命缺陷

  • 性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  • 健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。一个线程异常,全进程内部的线程遭殃,整个进程出现问题。
  • 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  • 编程难度提高:编写与调试一个多线程程序比单线程程序困难得多

2.3 线程安全与数据不一致:C++ 代码实战

下面是一段生动的多线程并发代码。我们让 thread-1 循环读取并打印全局变量 g_val,而让 thread-2 在运行过程中对 g_val 进行累加修改:

cpp 复制代码
#include <iostream>
#include <stdio.h>
#include <string>
#include <unistd.h>
#include <pthread.h>

int g_val = 100;

void hello(const std::string &name)
{
    printf("haha ,I am common funciton!, %s\n", name.c_str());
    sleep(5);
}

void *threadrun1(void *args)
{
    std::string threadname = static_cast<const char *>(args);
    while (true)
    {
        printf("%s is running, g_val: %d, &g_val: %p\n", threadname.c_str(), g_val, &g_val);
        sleep(1);
        hello(threadname);
    }
}

void *threadrun2(void *args)
{
    std::string threadname = static_cast<const char *>(args);
    while (true)
    {
        printf("%s is running, g_val: %d, &g_val: %p\n", threadname.c_str(), g_val, &g_val);
        sleep(1);
        g_val++;
        hello(threadname);
    }
}

int main()
{
    pthread_t t1, t2;

    pthread_create(&t1, nullptr, threadrun1, (void *)"thread-1");
    pthread_create(&t2, nullptr, threadrun2, (void *)"thread-2");

    while (true)
    {
        sleep(1);
    }
    return 0;
}

实验现象与原理解析:

当你编译并运行上述代码后,你会观察到以下现象:

  1. 地址完全相同thread-1thread-2 打印出来的 &g_val 物理/虚拟地址是完全一致的,这直接印证了多线程共享相同的虚拟内存空间

  2. 数据一变俱变 :当 thread-2g_val 自增后,thread-1 几乎是立竿见影地读到了最新的数值。这虽然带来了通信的高效,但也暴露出严重的安全隐患:如果多个线程在没有互斥锁保护的情况下同时写一个变量,就会导致该变量的终值不可预测。

2.4线程的异常现象

单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃

线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

2.5线程用途

合理的使用多线程,能提高CPU密集型程序的执行效率

合理的使用多线程,能提高IO密集型程序的户体验(如活中我们边写代码边下载开发具,就是多线程运的种表现)

3. Linux 进程与线程的底层对比与资源边界

进程间具有独立性

线程共享地址空间,也就共享进程资源

3.1 核心分工

  • 进程(Process) :是资源分配的基本单位(每个进程拥有独立的地址空间、文件描述符表等)。

  • 线程(Thread) :是CPU 调度的基本单位(在 Linux 中,每个线程被视为一个 LWP,即轻量级进程,由系统直接调度)。

3.2 线程的"私有财产"与"共享大锅饭"

虽然线程共享进程的绝大部分资源,但在高并发的乱局中,线程必须拥有一部分绝对私有的资源,否则无法正常工作运行。

私有数据:

  • 线程ID(TID):每个执行流的唯一身份标识。
  • 一组寄存器,线程的上下文数据:当 CPU 切换线程时,必须能保存和恢复当前线程的临时计算状态和 PC 计数器值。
  • 栈(Stack):每个线程有自己独立的局部变量和函数调用栈帧,确保它们在函数嵌套调用时不相互踩踏。
  • errno错误码:保证当前线程的系统调用报错不会覆盖其他线程的错误状态。
  • 信号屏蔽字(Signal Mask):各个线程可以独立决定屏蔽哪些信号。
  • 调度优先级

共享进程资源:

  • 代码段(Text Segment)数据段(Data Segment)(因此全局变量和所有函数对各线程完全公开)。

  • 堆区(Heap) :通过 newmalloc 申请的内存。

  • 文件描述符表(File Descriptor Table):一个线程打开的文件,其他线程可以直接进行读写。

  • 每种信号的处理方式(如忽略、自定义信号处理函数等)。

  • 当前工作目录。

  • 用户 ID(UID)和组 ID(GID)。

3.3进程的多个线程共享

同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表
  • 每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id

进程和线程的关系如下图:

相关推荐
爱莉希雅&&&1 小时前
Redis哨兵模式和主从复制和集群模式搭建与扩容缩容
linux·redis·缓存·集群·哨兵·数据库同步
jkyy20141 小时前
新零售如何跳出货品内卷,以健康服务实现用户深度经营?
人工智能·健康医疗·零售
IT_陈寒1 小时前
Java的finally块竟然不是你想的那个finally!
前端·人工智能·后端
2301_789015621 小时前
C++_string增删查改模拟实现
java·开发语言·c++
不做无法实现的梦~1 小时前
怎么实现codex控制嘉立创EDA绘制原理图
linux
融智兴科技1 小时前
UHF RFID零售标签市场持续爆发
人工智能·零售
星河耀银海1 小时前
JAVA 注解(Annotation):从原理到实战应用
java·开发语言·数据库
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第68题】【JVM篇】第28题:对于 JDK 自带的监控和性能分析工具用过哪些?一般你怎么用的?
java·开发语言·jvm·面试
MicrosoftReactor1 小时前
技术速递|六个编码智能体,一个生产级系统:基于 AKS-Lab-GitHubCopilot 的 AgenticOps 实战指南
ai·github·copilot·智能体