【Linux】线程与虚拟内存的底层世界

1. 线程概念

1.1 啥是线程

  • 以前的进程:我们可以将其理解为"内部只有一个线程的进程"。这并没有改变进程的定义,只是让我们对进程的理解更加丰富了。

  • 进程 :强调独占隔离。不同进程间的资源是相互独立的,通信需要特殊机制(IPC)。

  • 线程 :强调共享协作。同一进程内的多个线程天然共享大部分资源,通信非常便捷。

  • 进程是资源分配的基本单位线程是CPU调度的基本单位

1.2 Linux 用进程模拟线程

1.2.1 为什么要这么设计?

  • 复用代码,降低复杂度 :Linux内核本身是一个非常复杂的系统,进程管理(task_struct)的代码已经非常成熟和健壮。如果为线程重新设计一套全新的管理结构(TCB,线程控制块),不仅工作量大,而且容易引入新的Bug。复用现有代码是内核开发的一个核心原则。

  • 调度器无需修改 :Linux的进程调度器(CFS等)是为调度task_struct设计的。如果将线程也实现为task_struct,那么调度器就可以零成本地支持线程调度,无需任何改动。

  • 轻量级:这种实现方式让线程比传统意义上的进程更"轻量"。因为它们共享了大量资源,创建、销毁和切换的成本更低。

1.2.2 其他平台(如Windows)也是这样吗?

  • 不是 。Windows有自己独立的、原生的线程实现。Windows内核中明确区分了EPROCESS(进程对象)和ETHREAD(线程对象),它们的管理方式与Linux截然不同。Linux的"用进程模拟线程"是其独有的设计哲学,体现了"一切皆文件"、"机制与策略分离"的思想。

1.3 Linux下的具体实现:轻量级进程(LWP)

  • PCB (Process Control Block) vs TCB (Thread Control Block)

    • 从概念上讲,进程和线程在内核中都需要被管理,即"先描述,再组织"。

    • 但在Linux中,没有单独的TCB结构 。线程在内核中的描述符,就是**task_struct**。

    • 这个被用来描述线程的task_struct,在Linux中被称为轻量级进程(Light Weight Process, LWP)

  • 如何实现共享?

    • 一个传统的进程,内部只有一个执行流,其task_struct指向一个独立的mm_struct(内存描述符)。

    • 当我们创建一个线程时,Linux内核会创建一个新的task_struct(LWP)。

    • 这个新的task_struct并不会创建自己的mm_struct,而是直接指向原来进程的mm_struct

    • 这样,多个task_struct(LWP)就共享了同一个地址空间(mm_struct),从而实现了线程的资源共享。

结论:Linux线程,就是轻量级进程,或者说,就是用轻量级进程模拟实现的。

2. 资源划分 - 虚拟内存与页表

线程共享资源的本质,是共享了地址空间。而地址空间的管理,离不开虚拟内存和页表

2.1 为啥要虚拟内存和页表

  1. 隔离

    每个进程有独立的虚拟地址空间,A 进程崩溃不影响 B 进程,安全性提高。

  2. 保护

    页表里可以标记权限(读/写/执行、用户/内核),防止程序非法访问其他进程或内核内存。

  3. 简化编程

    每个进程都以为自己独占从 0 开始的连续内存,编译器、链接器无需操心物理内存布局。

  4. 高效利用物理内存

    • 物理页框可以非连续分配,避免外部碎片。

    • 支持按需分页(用到才分配物理页)。

    • 支持共享(多个进程的虚拟页映射到同一物理页,如共享库)。

    • 支持写时拷贝(fork 时只复制页表,物理页延迟复制)。

    • 支持交换(将不常用页换出到磁盘)。

  5. 页表是实现上述功能的硬件映射机制

    • 由 MMU 根据页表完成虚拟地址 → 物理地址的转换。

    • 多级页表节省了页表本身占用的内存(只用实际需要的页表项)。

2.2 物理内存管理:以页(Page)为单位

  • 页框/页帧 :物理内存被操作系统划分为大小固定的块,通常为4KB。这些块称为页框。

  • :磁盘(或可执行文件)中的数据也被划分为同样大小的4KB块,这些块称为页。

  • 为什么是4KB? 这是硬件和操作系统共同决定的,它平衡了管理开销和I/O效率。4GB / 4KB = 1,048,576,即一个32位系统有大约100万个物理页框。

2.2.2 操作系统如何管理物理页框?

操作系统需要知道每个物理页框的当前状态(是否被使用、是否脏了、被哪个进程使用等)。为此,内核为每一个物理页框都创建了一个**struct page**对象。

  • 数据结构struct page 存在于<linux/mm_types.h>中,它包含了页框的状态标志(flags)、用于LRU(最近最少使用)链表的指针(lru)等信息。

  • 关键点struct page中不直接存储该页框的物理地址

  • 为什么? 因为物理地址可以通过计算得到!所有的struct page对象被存放在一个全局数组mem_map[]中。数组的下标(index)就是该物理页框的编号。

    • 物理页框的起始地址 = mem_map数组的起始物理地址 + index * 4KB

    • 这种设计极大地提高了管理效率,将对物理内存的管理转化为对数组的管理。

c 复制代码
/* include/linux/mm_types.h */
struct page {
    unsigned long flags;        /* 页状态:脏、锁定、活动等 */
    struct list_head lru;       /* LRU链表,用于页面回收 */
    struct address_space *mapping; /* 所属地址空间(文件或匿名) */
    pgoff_t index;              /* 在地址空间中的偏移(以页为单位) */
    atomic_t _mapcount;         /* 被页表映射的次数,-1表示空闲 */
#if defined(WANT_PAGE_VIRTUAL)
    void *virtual;              /* 内核虚拟地址(高端内存为NULL) */
#endif
    /* 其他字段用于slab分配器、私有数据等,一般场景不用深究 */
};
字段 作用
flags 记录页的状态(如 PG_lockedPG_dirtyPG_active),定义在 <linux/page-flags.h>
lru 将页挂入 LRU(最近最少使用)链表,供内存回收算法使用
mapping 指向页所属的地址空间:文件页指向文件的 address_space,匿名页指向 anon_vma
index 页在地址空间中的页偏移量,用于定位页内数据在文件或匿名映射中的位置
_mapcount 该页在页表中被映射的次数;为 -1 时表示没有被任何页表引用,可以回收
virtual 该页在内核空间的虚拟地址(仅当低端内存时有效;高端内存需临时映射)

2.3 虚拟地址到物理地址的转换:页表

用户程序操作的是虚拟地址,CPU执行指令时,需要的是物理地址。这个转换过程由MMU(内存管理单元) 硬件完成,转换的依据就是页表

2.3.1 为什么不用单张页表?

一个32位系统的虚拟地址空间是4GB,如果用一张巨大的页表来映射,这个页表本身就会占用4GB / 4KB * 4字节 = 4MB的内存(假设每个页表项4字节)。如果每个进程都维护这样一张页表,内存开销将无法接受。

2.3.2 多级页表(以二级页表为例)

Linux采用多级页表来节省内存。

  1. 虚拟地址结构:一个32位虚拟地址被划分为三部分:

    • 10位 :页目录索引([0, 1023]

    • 10位 :页表索引([0, 1023]

    • 12位 :页内偏移([0, 4095]

  2. 转换过程

    • CR3寄存器 :CPU中的CR3寄存器存放着当前进程的页目录的物理地址。当进程切换时,CR3的值也会被切换。

    • 第一步:查页目录 。MMU取出虚拟地址的高10位,作为索引,在CR3指向的页目录中找到对应的页表项 。这个页表项里存放着页表的物理地址

    • 第二步:查页表 。MMU取出虚拟地址的中间10位,作为索引,在第一步找到的页表中找到对应的页表项 。这个页表项里存放着物理页框的起始物理地址

    • 第三步:计算物理地址。MMU将找到的物理页框起始地址,加上虚拟地址的低12位(页内偏移),得到最终的物理地址。

    • 第四步:访问物理内存。MMU拿着这个物理地址,通过内存总线去访问物理内存中的具体字节。

  3. 节省内存的原理

    • 一个进程并不需要所有的页表。比如,如果一个进程只使用了地址空间的底部和顶部,那么只需要创建映射底部区域的页表和映射顶部区域的页表,中间的页目录项可以指向空的页表或标记为无效,这样就节省了大量内存。

2.4 页表项(PTE)的内容

页表项不仅包含物理页框地址,还包含一系列重要的标志位:

  • 物理地址:映射到的物理页框的起始地址。

  • 是否命中Present位。如果为1,表示该虚拟页已映射到物理页框,可以直接访问;如果为0,则访问该地址会触发缺页中断

  • RWX权限:Read/Write/eXecute权限位,控制对该页的访问权限。

  • U/K权限:User/Supervisor权限位,区分该页是用户空间还是内核空间,防止用户程序访问内核内存。

2.5 TLB(快表/缓存)

  • 当CPU给MMU传新虚拟地址之后,MMU先去问TLB那边有没有,如果有就直接那物理地址发到总线给内存。但如果TLB那没有(Cache Miss),这时候MMU就去问页表,在页表中找到之后MMU除了把地址发到总线传给内存,还把这条映射关系给TLB,让他记录一下刷新缓存

2.6 缺页异常(Page Fault)

当 CPU 发送给 MMU 的虚拟地址在 TLB页表 中都找不到对应的物理页时,就会触发缺页异常 。这是一种由硬件中断触发、可由软件逻辑纠正的错误。

2.6.1 触发条件

  • 目标内存页在物理内存中不存在对应的物理页

  • 或者存在但不具备所需权限(如写只读页、执行非执行页)

此时 CPU 无法获取数据,暂停当前操作,触发缺页中断,进程从用户态切换到内核态 ,由内核的 Page Fault Handler 进行处理。

2.6.2 缺页中断的分类与处理

类型 别名 说明 处理方式
Hard Page Fault Major Page Fault (硬缺页错误 / 主要缺页错误) 物理内存中不存在对应的物理页 需要从磁盘设备读取页面到物理内存,再由 MMU 建立虚拟地址到物理地址的映射
Soft Page Fault Minor Page Fault (软缺页错误 / 次要缺页错误) 物理内存中存在对应的物理页(如其他进程已加载) 仅需在当前进程的页表中建立映射,无需磁盘 I/O,常见于多进程共享内存区域
Invalid Page Fault 无效缺页错误 访问了非法地址,如越界、空指针解引用等 无法修复,内核直接终止进程,常见错误为 Segmentation Fault

2.7 内存申请与写时拷贝

  • 申请内存(如malloc)

    1. 程序调用malloc,库函数在用户空间进行内存管理。如果堆空间不够,会调用brkmmap系统调用向内核申请内存。

    2. 内核收到请求后,并不会立即分配物理内存。它只会在进程的vm_area_struct(虚拟内存区域)中记录:从虚拟地址A到虚拟地址B这一段区域被分配了。

    3. 真正分配物理内存的时机,是当程序第一次访问这些虚拟地址时。此时MMU发现页表项中的Present位为0,触发缺页中断

    4. 缺页中断处理程序会去查找vm_area_struct,判断这个地址是否合法。如果合法,就会去物理内存的mem_map数组中找一个空闲的struct page(空闲页框),建立该虚拟页到该物理页框的映射(填充页表项),然后重新执行引发中断的指令。

  • 写时拷贝(COW)

    1. 当使用fork()创建子进程时,为了效率,子进程不会复制父进程的物理内存。

    2. 子进程会复制父进程的页表,但将这些页表项标记为只读。父进程和子进程的页表都指向同一块物理内存。

    3. 如果父子进程都不修改这些内存,那么就一直共享。

    4. 如果其中一个进程(比如子进程)试图写入 一个共享页,MMU会因为"只读"权限而触发一个页错误

    5. 内核的缺页中断处理程序发现这是一个COW场景,就会重新分配一个新的物理页框 ,将原来页框的内容拷贝到新页框,并更新子进程的页表项,使其指向新页框,同时赋予可读可写权限。然后重新执行写入指令

3. 线程共享的本质

现在,我们可以将线程和内存管理知识结合起来,揭示线程共享的本质。

3.1 资源划分 = 地址空间划分 = 页表划分

  • 虚拟地址是资源的代表。一个进程或线程能访问哪些资源(内存),完全由它"看到"的虚拟地址范围决定。

  • 地址空间的划分mm_struct 结构体描述了一个完整的地址空间,而其中的 vm_area_struct 链表则精确地划分了地址空间中各个区域的用途(代码段、数据段、堆、栈、共享库等)。

  • 页表是地图:页表是虚拟地址到物理地址的映射图,它决定了哪些虚拟地址是有效的,以及它们指向哪块物理内存。

结论线程进行资源划分,本质上是划分地址空间,获得一定范围的合法虚拟地址。而获得合法虚拟地址的过程,在底层就是对页表进行配置的过程。

3.2 资源共享 = 地址空间共享 = 页表条目共享

  • 共享mm_struct :当我们创建一个新线程时,新的task_struct(LWP)被创建,但它的mm_struct指针直接指向了主进程(或其他线程)的mm_struct

  • 共享页表 :因为mm_struct是共享的,所以所有线程的页表也是共享的(都是指向同一份页目录和页表)。这意味着:

    • 如果一个线程在共享内存区(如堆)分配了一个对象,其他线程可以通过相应的虚拟地址立即访问到它。

    • 如果父进程的代码段被映射到物理内存,所有子线程也都能看到同样的代码。

  • 部分独占的资源

    • :每个线程必须有自己独立的栈,否则函数调用就会混乱。在共享的地址空间中,如何实现独立栈?操作系统在创建线程时,会在共享的mm_struct划分出一块独立的虚拟地址范围作为该线程的栈。虽然这些栈在同一个地址空间中,但它们的地址范围是不重叠的,所以每个线程的栈是独立的。

    • 寄存器 :每个线程有自己独立的寄存器上下文,这是线程切换时需要保存和恢复的内容。其中最重要的就是CR3寄存器

3.3 为什么线程切换比进程切换更轻量?

  • 进程切换 :需要保存和恢复整个CPU上下文,最关键的是需要切换CR3寄存器,使其指向新进程的页目录。切换CR3会使得CPU的TLB(快表,用于缓存地址转换结果)全部失效,导致后续访问内存时TLB未命中,性能开销较大。

    • 进程切换不仅需要切换寄存器,还会导致TLB和各级CPU Cache失效。当被切换出去的进程再次运行时,其指令和数据需要重新加载到Cache和TLB中,这个重新加载的过程是一个巨大的性能开销。
  • 线程切换(同一进程内) :由于线程共享地址空间,它们也共享页表。因此,线程切换时不需要切换CR3寄存器!这意味着TLB中的内容依然有效,大大减少了地址转换的开销。这就是线程切换比进程切换更"轻量"的核心原因。

总结执行流看到的资源,本质是它拥有的合法虚拟地址。线程间资源共享,本质上就是虚拟地址的共享,也就是页表条目(PTE)的共享。

3.4 线程的特性总结

  • 独立调度:线程是操作系统调度的基本单位。每个线程都拥有一组独立的寄存器(即线程的上下文),这背后体现了"线程是被独立调度的"这一事实。

  • 独立栈结构:每个线程都有自己的栈空间,用于保存函数调用链、局部变量等,保证线程执行的独立性。线程是一个"动态"的概念,是进程中一条独立的执行流。

  • 数据缓存:对于用户数据,可以利用CPU的Cache进行缓存,这对开发者是"透明"的。

3.5 线程异常

  • 单个线程如果出现除零错误,野指针问题导致线程崩溃,进程也随之崩溃
  • 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也随即退出

3.6 多线程程序的特性与挑战

  • 时间片分配:操作系统将CPU时间片等分给进程内的每个线程,它们轮流获得执行机会。

  • 健壮性问题:如果一个线程因为段错误、除零等异常而崩溃,整个进程都会崩溃。因为线程共享进程的地址空间,一个线程的严重错误会污染整个进程的内存空间。这降低了程序的健壮性,要求编写多线程程序时,必须对共享资源、同步等问题有更深入、更全面的考虑。

  • 输出混杂:多个线程同时向同一个输出设备(如终端)打印信息时,如果不加同步,消息可能会交错混杂在一起,如示例所示。

3.7 关于内存越界

  • int array[10]; for(int i = 0; i <= 10; i++) array[i] = 0;

    • 这里假如变量i 的虚拟地址等于array10 (这里是不小心越界了)的地址,就会导致死循环,到10时把i修改成0
  • 越界了不一定会立即崩溃 。因为array[10]这个地址可能仍然在当前线程的合法虚拟地址范围内(比如刚好是栈上的一个对齐空隙,或者属于堆上另一块已分配的内存)。

  • 只有当越界访问的虚拟地址是未映射的 (页表项Present位为0),或者权限不匹配的 (比如尝试写入只读的代码段),才会触发缺页异常,操作系统发现这是一个非法访问,最终发送SIGSEGV信号导致进程崩溃。

  • 这提醒我们,内存越界是非常隐蔽且危险的Bug,因为它可能在很长时间内都不会导致程序崩溃,但会悄悄破坏其他线程的数据,造成难以调试的随机问题。

4. 线程控制

4.1 轻量级进程(LWP)

在Linux中,通过ps -aL命令可以观察到线程的LWP(Light Weight Process,轻量级进程)。

bash 复制代码
whb@bite:~$ ps -aL
PID   LWP TTY      TIME CMD
902075 902075 pts/0    00:00:00 test_thread
902075 902076 pts/0    00:00:00 test_thread
  • PID (Process ID):进程ID,表示这个线程所属的进程。

  • LWP (Light Weight Process) :轻量级进程ID。在Linux内核中,CPU调度器调度的最小单位就是LWP,而不是进程的PID。

  • 结论 :Linux内核并没有真正意义上的"线程"概念,它只有"轻量级进程(LWP)"。用户所感知的"线程",在Linux底层是通过LWP来模拟实现的。因此,Linux中不存在真正意义上的线程 ,只有用轻量级进程模拟的线程。这种实现方式被称为用户级线程

4.2 pthread 原生线程库

  • 问题 :为什么在Linux上写多线程程序需要链接pthread库(-lpthread)?

  • 答案

    • Linux内核只提供创建"轻量级进程(LWP)"的系统调用,例如 clone()

    • 为了让开发者能够更方便地使用"线程"这个概念,社区开发了 pthread(POSIX thread)库。

    • pthread 库的作用 :它在用户层对内核提供的 clone 等系统调用进行了封装。当用户调用 pthread_create 时,库内部会调用 clone 并传递合适的标志(flags),创建一个新的LWP。这个LWP与调用进程共享虚拟内存空间、文件描述符等资源,从而在用户层面表现为一个"线程"。

    • 结论 :Linux的线程实现,本质是在用户层实现的,因此被称为用户级线程pthread库也被称为原生线程库(Native POSIX Thread Library, NPTL)

4.2.1 使用pthread库却未链接

例:

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

void *threadrun(void *args) {
    std::string name = (const char*)args;
    while(true) {
        std::cout << "我是新线程: name: " << name << std::endl;
        sleep(1);
    }
    return nullptr;
}

int main() {
    pthread_t tid;
    // 创建线程
    pthread_create(&tid, nullptr, threadrun, (void*)"thread-1");

    while(true) {
        std::cout << "我是主线程..." << std::endl;
        sleep(1);
    }
    return 0;
}

编译错误

bash 复制代码
g++ -o test_thread TestThread.cc
/usr/bin/ld: in function `main':
TestThread.cc:(.text+0x107): undefined reference to `pthread_create'
collect2: error: ld returned 1 exit status

错误原因pthread_create 函数在 pthread 库中实现,而 g++ 默认不链接该库。

解决方案 :编译时需要显式链接 pthread 库。

bash 复制代码
g++ -o test_thread TestThread.cc -lpthread

4.2.2 使用 C++11 标准库thread却未链接

例:

cpp 复制代码
#include <iostream>
#include <thread>
#include <unistd.h>

void hello() {
    while (true) {
        std::cout << "新线程: hello world, pid: " << getpid() << std::endl;
        sleep(1);
    }
}
int main() {
    std::thread t(hello);

    while (true) {
        std::cout << "我是主线程..." << ", pid: " << getpid() << std::endl;
        sleep(1);
    }

    t.join();
    return 0;
}

编译错误

bash 复制代码
g++ -o test_thread TestThread.cc -std=c++11
/usr/bin/ld: /tmp/cctmzPsU.o: in function `std::thread::thread<void (&)(), , void>(void (&)())':
TestThread.cc:(.text._ZNSt6threadC2IRFvvEJEvEEOT0_[_ZNSt6threadC5IRFvvEJEvEEOT0_]+0x33): undefined reference to `pthread_create'
collect2: error: ld returned 1 exit status

错误原因 :即使使用了C++11的 std::thread,在Linux平台下,其底层实现依然依赖于 pthread 库。因此,编译时仍然需要链接 pthread 库。

解决方案

bash 复制代码
g++ -o test_thread TestThread.cc -std=c++11 -lpthread

关于语言跨平台性

C++标准库(如 std::thread)为了保证跨平台,其实现方式是在不同操作系统上封装各自的原生线程接口。

  • Linux :封装 pthread 库。

  • Windows :封装 CreateThread 等Windows API。

  • 这是通过条件编译等技术,在库代码内部对不同平台进行适配,对用户提供了统一的接口,即所谓"大力出奇迹"

4.3 Linux线程控制核心接口

4.3.1 线程创建:pthread_create

c 复制代码
#include <pthread.h>

int pthread_create(pthread_t *thread,
                   const pthread_attr_t *attr,
                   void *(*start_routine) (void *),
                   void *arg);
  • 参数

    • thread输出型参数 。用于返回新创建的线程ID(pthread_t类型,通常是一个无符号长整型)。

    • attr:线程属性。通常设置为 nullptr,表示使用默认属性。

    • start_routine函数指针 。新线程创建后将要执行的函数入口。该函数类型为 void* (void*)

    • arg:传递给 start_routine 函数的参数。

  • 返回值

    • 成功:返回 0

    • 失败:返回一个非零的错误码(errno),并且 thread 指向的内容是未定义的

4.3.2 线程等待pthread_join

线程创建后,类似于进程,也需要被等待,否则会产生类似于"僵尸进程"的内存泄漏问题。

c 复制代码
#include <pthread.h>

int pthread_join(pthread_t thread, void **retval);
  • 功能 :阻塞调用线程(通常是主线程),直到指定的 thread 线程终止。

  • 参数

    • thread:要等待的线程ID。

    • retval输出型参数 ,二级指针。用于接收被等待线程的退出码(即 start_routine 的返回值)。如果不需要,可以设置为 nullptr

  • 返回值 :成功返回 0,失败返回错误码。

4.3.3 线程ID获取:pthread_self

c 复制代码
#include <pthread.h>
pthread_t pthread_self(void);
  • 功能:获取调用线程自身的线程ID。

  • 返回值:返回调用线程的ID。

  • 注意pthread_t 类型的线程ID是一个不透明的类型,通常是一个结构体指针或无符号长整型。它用于在 pthread 库层面标识线程,不要将其与内核调度的LWP混淆。我们不应该直接暴露LWP的概念给用户。

4.3.4 线程终止:pthread_exit

c 复制代码
#include <pthread.h>
void pthread_exit(void *retval);
  • 功能:终止调用它的线程。

  • retval 是一个指针,指向线程的返回值。这个返回值可以被同一进程中的其他线程通过 pthread_join 获取。

  • 调用 pthread_exit 后,当前线程立即终止,其后续代码(如下例中的 std::cout)将不会被执行

c 复制代码
void* routine(void* args) {
    Task *t = static_cast<Task*>(args);
    sleep(100);
    Result *res = new Result(t->Execute());
    sleep(1);
    // return res;      // 1. 正常返回,等同于 pthread_exit(res)
    // exit(13);        // 2. 进程退出,所有线程都将终止
    pthread_exit(res);  // 3. 仅当前线程退出,并返回 res
    std::cout << "haha,新线程不应该看到这里" << std::endl; // 此行永不执行
}
    • return res :在线程函数中,return 等同于隐式调用了 pthread_exit,返回值会被传递。
  • exit(13):这是进程级别的退出。一旦调用,整个进程(包括所有线程)都会终止,是一种更"激烈"的退出方式。

  • pthread_exit(res) :这是线程级别的优雅退出,仅终止当前线程,并将 res 作为返回值。

4.3.5 线程取消:pthread_cancel

c 复制代码
#include <pthread.h>
int pthread_cancel(pthread_t thread);
  • 功能:向一个指定线程发送取消请求

  • 调用时机 :主线程或其他线程调用 pthread_cancel 时,必须确保目标线程已经启动。否则,取消请求可能无效或产生未定义行为。

  • 并发不确定性 :主线程和新创建的线程谁先运行是不确定的,由操作系统的调度器决定。这可能导致竞争条件,因此在编写代码时必须考虑线程的同步问题。

4.3.6 线程分离:pthread_detach

c 复制代码
#include <pthread.h>
int pthread_detach(pthread_t thread);
  • 功能:将一个线程设置为分离状态。

  • 一个线程可以是可结合的分离的

  • 可结合线程 :默认状态。当它终止时,其资源(如线程控制块TCB、栈空间)不会被自动回收。必须由另一个线程(通常是主线程)对其调用 pthread_join 来回收资源并获取其返回值。

  • 分离线程 :当它终止时,其所有资源会被系统自动回收。不需要、也不能对它调用 pthread_join。尝试 join 一个已分离的线程会出错。

5. 线程在库中的管理

5.1 为啥线程在库中

用户级线程库 pthread

  • 操作系统不会直接提供线程接口给程序员。我们使用的 pthread_create 等接口,是由用户态的原生线程库(Native POSIX Thread Library, NPTL)提供的。

  • 这个库(通常是 libpthread.so.0)是一个用户级别的库,它封装了底层的轻量级进程接口。

  • 通过 ldd 命令可以查看其位置:

c 复制代码
ldd ./your_program
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0
  • libpthread.so 本身是一个动态库

    • 它是ELF格式的可执行文件,但在运行时被动态链接到用户进程的地址空间中。

    • 这意味着,libpthread.so 的代码和数据会通过动态链接地址重定向,被加载到内存,并映射到当前进程的地址空间中。进程的代码区可以访问到库内部的函数和数据。

既然线程的概念是在用户库中维护的,那么库就必须对它们进行管理。其核心原则是:先描述,再组织

5.2 线程控制块 (TCB)

库内部会为每一个创建的线程维护一个结构体,通常称为 struct pthreadstruct tcb(线程控制块)。这个结构体描述了线程的所有属性:

  • 线程状态:运行、就绪、阻塞、终止等。

  • 线程ID (pthread_t):用户库层面的线程标识符。

  • 线程独立的栈结构:每个线程必须有自己的栈空间,用于保存局部变量、函数调用信息等。

  • 线程栈大小:可以配置,默认通常为8MB。

  • 优先级、时间片:调度相关的信息。

  • joinable 状态:标记该线程是可结合的(需要被join)还是已分离的。

  • LWP (Light Weight Process):与内核中轻量级进程的PID相关联的ID。

  • tid:线程ID,与LWP关联。

  • 上下文:用于线程切换时保存和恢复CPU寄存器状态。

5.3 线程栈与存储布局

每个线程都必须有自己独立的栈空间。这些栈空间和TCB都位于进程地址空间的**共享区(或mmap映射区域)**中。

5.4 线程的创建与 clone 系统调用

当我们在用户态调用 pthread_create 时,库内部会做以下事情:

  1. 在共享区分配空间 :为该线程分配一个 struct pthread (TCB) 和独立的线程栈。

  2. 初始化TCB:填充线程的各种属性(状态、栈指针、优先级等)。

  3. 调用系统调用 cloneclone 是Linux特有的系统调用,它类似于 fork,但可以精确控制父子进程(或线程)之间共享哪些资源。

c 复制代码
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...);
    • fn:新线程要执行的函数指针,即 routine 函数。

    • stack:新线程使用的栈地址,就是我们刚刚在共享区分配的线程栈。

    • flags:关键参数,通过指定 CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND 等标志,告诉内核新的LWP与调用者共享内存空间、文件系统、文件描述符、信号处理表等。正是这些标志,使得新的LWP看起来像一个"线程"。

    • arg:传递给 fn 的参数。

  1. 内核创建LWPclone 系统调用在内核中创建一个新的 task_struct(LWP),并根据 flags 设置其资源与父LWP共享。这个新的 task_struct 拥有了一个在内核中唯一的PID(我们称之为LWP ID)。

总结pthread_create 在用户空间创建TCB和栈,并通过 clone 系统调用在内核空间创建一个与其关联的轻量级进程。这就是"用户线程 vs LWP"的联动关系。

5.5 用户线程与LWP的映射模型:1:1

  • NPTL (Native POSIX Thread Library) 采用的模型是 1:1

  • 含义 :每一个用户态的线程(由 pthread_create 创建),都直接对应一个内核态的轻量级进程(LWP)。

  • 优点:实现简单,多个线程可以真正并行地在多核CPU上运行(因为内核调度器调度的是LWP)。一个线程阻塞(如I/O)不会影响其他线程。

  • 结论:Linux的线程实现,就是用户态线程库与内核态轻量级进程的完美结合。

5.6 pthread_join与内存泄露

pthread_join 的必要性

  • 前面提到,可结合的线程终止后,其资源(TCB、栈)不会自动释放。

  • 调用 pthread_join 有两个目的:

    1. 等待:阻塞调用线程,直到目标线程终止。

    2. 回收资源:释放目标线程占用的TCB和栈等资源。

    3. 获取返回值 :通过第二个参数 void **retval 获取目标线程的返回值。

c 复制代码
void *ret = nullptr;
// 等待 tid 线程结束,并将其返回值存入 ret
pthread_join(tid, &ret);
// 假设 routine 返回了 (void*)10
// 则 ret 的值就是 (void*)10
  • 重要 :如果一个可结合的线程终止了,但没有其他线程对它调用 pthread_join,那么它的资源就无法被回收,这将导致内存泄漏(确切地说是线程资源泄漏)。

5.7 总结

  1. Linux无真线程:Linux内核只提供轻量级进程,通过共享资源来模拟线程。

  2. 线程在库中 :线程的概念、管理(TCB、栈)都是在用户态的 libpthread 库中实现的。

  3. 库映射到进程libpthread.so 作为动态库,被映射到进程地址空间的共享区(mmap区域)。

  4. 创建线程 = 用户态准备 + 内核态 clonepthread_create 在共享区分配TCB和栈,然后调用 clone 系统调用创建对应的LWP。

  5. 模型为1:1:每个用户线程对应一个内核LWP。

  6. 线程必须被管理

    • 可结合的线程终止后,必须由另一个线程调用 pthread_join 来回收其资源。

    • 可以调用 pthread_detach 将线程设为分离状态,使其终止时自动回收资源。

  7. 线程有独立栈:每个线程在共享区拥有自己独立的栈空间,确保函数调用和局部变量互不干扰。

  8. 线程ID (pthread_t):是用户库中TCB的标识符,不同于内核中的LWP ID。

  9. 取消和退出

    • pthread_exit:退出当前线程。

    • exit:退出整个进程。

    • pthread_cancel:请求取消另一个线程。

相关推荐
布莱克6051 小时前
TCP 三次握手与四次挥手详解
服务器·网络·tcp/ip·计算机网络·网络编程
郝学胜-神的一滴1 小时前
C++11 工程级应用 09:告别无谓拷贝,解锁高性能移动语义
开发语言·数据结构·c++·vscode·软件工程·visual studio
新时代牛马1 小时前
实时 Linux:PREEMPT_RT、延迟来源与观测
linux·运维·服务器
微露清风2 小时前
# Linux 进程全景:从 PCB 到 O(1) 调度队列
linux·服务器
H_oRIZoN_2 小时前
Linux入门DAY38(手写 HTTP 客户端抓取天气 + 简易 HTTP 服务器实现元器件 Web 系统)
linux·linux应用编程
zhexunnb2 小时前
深挖SAP系统模块价值,助力无锡制造企业打通数字化全链路
运维
fangjianj2 小时前
windows环境反弹shell
linux·windows·web安全
明月不会写代码2 小时前
Ubuntu 下 Kitty 终端配置记录
linux·ubuntu
weipt3 小时前
利用wsl给windows电脑上安装一个ubuntu
linux·运维·ubuntu