linux之线程概念和控制


一、虚拟内存与分页机制

1.1 内存的两种粒度:寻址与管理

很多人会有一个疑问:C 语言中char占 1 字节,内存明明可以按字节访问,为什么常说内存的最小单位是 4KB? 这里需要区分两个完全不同的概念:

  • 最小寻址单元:字节。CPU 可以独立读写内存中任意一个字节的内容,这是硬件层面的访问粒度。
  • 内存管理粒度:页(4KB)。操作系统和 CPU 的内存管理单元(MMU)不会为每个字节单独做地址映射,而是把虚拟内存和物理内存都划分为连续的 4KB 块,以块为单位进行映射、保护和回收。 这样设计的核心目的是降低地址映射的开销、简化内存管理;和硬盘块对齐、便于 swap 交换只是分页机制的附带收益,并非核心设计目标。

1.2 虚拟页与物理页框

分页机制下,内存被划分为两类对等的 4KB 块:

  • 虚拟页(Page):虚拟地址空间中逻辑上的 4KB 内存块,是进程视角下的逻辑单元。
  • 物理页框(Page Frame):物理内存中实际的 4KB 内存块,是硬件层面的物理实体。

页表的核心作用,就是完成虚拟页号 → 物理页框号的映射。虚拟地址和物理地址的低 12 位(页内偏移)完全一致,不需要翻译,直接拼接即可得到最终物理地址。

如果不按页映射,而是为每个字节单独建立映射,页表本身会占用远超可用内存的空间,完全不可行;按页映射是在映射精度和管理成本之间的最优平衡。

1.3 二级页表的设计与寻址逻辑

32 位系统的虚拟地址空间总大小为 4GB,按 4KB 分页共有 1,048,576 个虚拟页。如果使用一级页表,每个进程都需要维护一张包含百万个条目的完整页表,内存开销极大且大部分空间会被浪费。

因此操作系统采用二级页表的分层设计,和文件系统的多级索引思路同源,实现按需分配、节省内存。32 位虚拟地址被拆分为三段:

复制代码
31        22 21        12 11          0
┌──────────┬───────────┬──────────────┐
│ 页目录索引 │  页表索引  │   页内偏移    │
│  10 bit   │   10 bit  │    12 bit    │
└──────────┴───────────┴──────────────┘

二级页表的层级关系严格如下:

  1. 一个进程有且仅有 1 个页目录(页全局目录,PGD) :它是第一级索引表,包含 1024 个页目录项(PDE),每个 PDE 指向一个下一级的页表。
  2. 每个页表包含 1024 个页表项(PTE):每个 PTE 指向一个具体的物理页框。

设计的自洽性在于:每个条目占 4 字节,1024 个条目总大小恰好为 1024 × 4 = 4096 字节(4KB),因此页目录、每个页表都恰好占用一个物理页框,内存分配、地址对齐都可以用统一的页管理逻辑处理。

说明:不存在 "1024 个页目录" 的说法,页目录是每个进程唯一的一级表;1024 是页目录项、页表项的数量。

1.4 页表项的结构:高 20 位页框号 + 低 12 位属性位

无论是页目录项还是页表项,大小都是 4 字节(32 位),其结构遵循同一套设计:

  • 高 20 位:存储物理基址(页表的物理页框号 / 数据页的物理页框号)。 因为物理页框是 4KB 对齐的,起始地址的低 12 位恒为 0,因此只需要 20 位就能完整表示所有物理页框的基址。
  • 低 12 位:被复用为属性位和状态位,包括存在位(该页是否已映射物理内存)、读写权限位、用户 / 内核权限位、脏位、访问位等。 我们常说的 "某块内存只读、某块内存可读写",本质就是通过页表项中的权限位进行管控的。

1.5 缺页中断与按需分配

虚拟内存的核心优势之一是延迟分配、按需加载

  1. 调用malloc/mmap申请内存时,操作系统仅仅是在进程的虚拟地址空间中划出一块合法范围,不会立刻分配对应的物理页框,也不会建立页表映射。
  2. 当程序真正访问这块虚拟地址时,MMU 查页表发现对应页表项的 "存在位" 为 0,会触发缺页中断,陷入内核。
  3. 内核判断地址合法后,会从空闲物理页中分配一个页框,建立页表映射,再返回用户态继续执行。

动态库的加载也遵循这一逻辑:

  • 程序启动时不会把整个动态库全部加载进内存,只有执行到对应代码、访问到对应虚拟页时,才会触发缺页、将对应页加载到物理内存。
  • 动态库的代码段是全局共享的:物理内存中只存在一份代码页,所有使用该库的进程都通过自己的页表,将同一块物理页映射到自身的虚拟地址空间,大幅节省内存;而数据段则是每个进程私有。

1.6 地址翻译的硬件加速:TLB 快表

虚拟地址到物理地址的翻译,全程由 CPU 内置的内存管理单元(MMU) 硬件自动完成,正常情况下不需要操作系统软件介入。 如果每次地址翻译都要遍历两级页表,会带来两次内存访问,性能损耗过大。因此 CPU 内置了转译后备缓冲器(Translation Lookaside Buffer,TLB,简称快表),用来缓存高频使用的「虚拟页号 → 物理页框号」映射。 TLB 的作用和文件系统的 dentry 缓存、页缓存思路一致,都是通过缓存高频结果减少查表开销;区别在于 TLB 是纯硬件实现,速度极快。

1.7 进程页表的管理与切换

每个进程拥有独立的虚拟地址空间,因此每个进程都有独立的页目录和一套页表。

  • 进程的mm_struct结构体中,保存了当前进程页目录的物理基址(pgd字段)。
  • 发生进程切换时,内核会将当前进程的页目录基址写入 CPU 的 CR3 控制寄存器;MMU 始终通过 CR3 寄存器找到当前进程的页目录,启动地址翻译。

所有的页目录、页表本身,都存储在物理内存中。

1.8 内存越界与段错误

如果程序访问的虚拟地址超出了mm_struct中记录的合法地址范围,或者访问了没有对应权限的内存,MMU 会触发页错误,内核校验后判定为非法访问,向进程发送SIGSEGV信号(段错误),直接终止整个进程。 我们常说的 "缓冲区溢出" 是内存越界的一种具体场景,指向缓冲区写入超出容量的数据、覆盖后续内存,二者不能等同。


二、Linux 线程的底层实现与 POSIX 标准

2.1 内核视角:只有轻量级进程,没有 "线程"

Linux 内核中不存在专门的 "线程" 数据类型,所有执行单元都用task_struct结构体描述,统称为轻量级进程(LWP) 。 内核通过clone()系统调用创建新的执行单元,可以通过参数灵活指定是否共享地址空间、文件描述符表、信号处理表、用户 ID 等资源:

  • 不共享核心资源,创建出的就是传统意义上的独立进程;
  • 共享地址空间、文件描述符等资源,创建出的就是用户态视角下的 "线程"。

也就是说,线程和进程在内核层面只有资源共享程度的区别,没有本质的类型差异。我们常说的 "单线程进程",就是只有一个执行单元的进程。

2.2 POSIX 标准与 pthread 的实现

真正定义线程接口规范的,是POSIX(可移植操作系统接口)标准,其中的线程规范称为 POSIX Threads,简称 pthread。 glibc 作为 Linux 系统的标准 C 库,基于内核的轻量级进程能力,在用户态补充了线程属性管理、同步原语、线程局部存储等逻辑,实现了完全符合 POSIX 标准的线程接口。

Linux 的 pthread 采用1:1 线程模型:一个用户态线程,对应一个内核轻量级进程,线程调度完全由内核调度器完成,属于典型的内核级线程,并非用户级线程。 早期 Linux 的线程实现为 LinuxThreads,存在诸多兼容性缺陷;2.6 内核之后升级为 NPTL(Native POSIX Thread Library,原生 POSIX 线程库),成为当前 Linux 线程的标准实现。

补充:历史上 pthread 的核心实现位于libpthread.so动态库中,编译时需要通过-lpthread显式链接;从 glibc 2.34 版本开始,pthread 核心逻辑已合并入主库libc.so.6libpthread.so仅作为兼容占位,现代 Linux 系统中编译 pthread 程序可以无需显式链接该库。

2.3 线程与进程的优劣对比

同一进程内的多个线程共享同一份虚拟地址空间、页表、文件描述符等资源,基于这个核心特性,线程相比独立进程有明确的优劣。

优点
  1. 上下文切换开销极低 同一进程的线程共享地址空间,线程切换时不需要更换页表、不需要刷新 TLB,CPU 缓存中的数据依然有效;仅需保存和恢复线程的寄存器、栈指针等私有上下文,切换成本远低于进程切换。
  2. 资源共享方便,通信效率高 线程间可以直接通过全局变量、堆内存交换数据,不需要复杂的进程间通信(IPC)机制,通信成本极低。
  3. 并发能力强,充分利用多核 计算密集场景下,可将重型任务拆分,通过多线程在多核 CPU 上并行执行,提升计算效率;IO 密集场景下,单个线程阻塞时,其他线程可以继续占用 CPU,避免 CPU 闲置,提升程序吞吐量。
缺点
  1. 故障隔离性差,健壮性弱 所有线程共享进程资源,单个线程出现非法内存访问、崩溃等异常,会直接导致整个进程终止,所有线程都会退出,故障会在进程内全局扩散。
  2. 编程复杂度高 共享资源的并发访问存在竞态条件,需要通过互斥锁、信号量等同步机制保证线程安全,设计不当容易出现死锁、数据竞争、优先级反转等难以复现的并发问题。
  3. 过量线程会导致性能下降 当全局就绪态的执行单元总数超过 CPU 物理核心数时,调度器需要通过时间片轮转让任务分时共享 CPU,频繁的上下文切换会带来持续的调度开销,缓存命中率也会下降,导致整体性能不升反降。 这一特性对进程和线程同样成立,区别仅在于进程切换的开销远大于同进程内的线程切换,超量时性能衰减更剧烈。

三、pthread 线程库核心接口

头文件:<pthread.h>

3.1 线程创建

复制代码
int pthread_create(pthread_t* thread, const pthread_attr_t * attr, 
                   void* (*start_routine)(void*), void* arg);
  • 参数 1 thread:输出型参数,调用成功后,写入新创建线程的用户态线程 ID。
  • 参数 2 attr:线程属性结构体指针,传入NULL则使用默认属性。
  • 参数 3 start_routine:线程入口函数指针,函数返回值为void*,形参为void*
  • 参数 4 arg:传递给入口函数的实参,无需传参则传入NULL
  • 返回值:成功返回 0;失败返回对应错误码,此时thread参数的值未定义。

补充说明:pthread_t类型的线程 ID,是用户态线程库层面的 ID,和内核层面的轻量级进程 ID(LWP)不是同一个概念。可以通过ps -Lf命令查看,其中PID列是线程组 ID(即通常说的进程 ID),LWP列是内核轻量级进程的 ID。

3.2 线程等待与资源回收

复制代码
int pthread_join(pthread_t thread, void** retval);
  • 功能:阻塞等待指定线程退出,并回收其资源;若不等待,线程退出后会残留资源,类似进程的 "僵尸状态",造成内存泄漏。
  • 参数 1 thread:要等待的线程 ID。
  • 参数 2 retval:输出型参数,用于接收线程入口函数的返回值;无需获取则传入NULL

补充:线程等待没有专门的 "异常状态" 参数。因为线程异常会直接导致整个进程退出,异常处理是以进程为单位的,单个线程不存在 "异常退出但进程存活" 的情况。

3.3 获取当前线程 ID

复制代码
pthread_t pthread_self(void);

返回当前调用线程的用户态线程 ID。

3.4 线程的退出方式

线程的生命周期对应其入口函数的执行周期,入口函数返回则线程退出。常见的主动退出方式有三种:

  1. 入口函数return返回 :最常用的方式,返回值会被pthread_join获取。

  2. 调用pthread_exit

    复制代码
    void pthread_exit(void* retval);

    在函数任意位置调用即可终止当前线程,retval为返回值,同样可被pthread_join获取。

    注意:禁止在线程内调用exit()退出。exit是终止整个进程的函数,调用后进程内所有线程都会一同退出,无法达到只退出单个线程的目的。

  3. 被其他线程取消

    复制代码
    int pthread_cancel(pthread_t thread);

    向指定线程发送取消请求。默认情况下线程采用延迟取消策略:只有执行到取消点(如sleepreadwrite等系统调用)时,才会响应取消并退出,并非立即强制终止。

3.5 线程分离

默认创建的线程处于joinable状态,退出后必须由其他线程调用pthread_join回收资源。如果不需要等待线程退出,可以将线程设置为detach(分离)状态,线程退出后系统会自动回收其资源,无需手动等待。

复制代码
int pthread_detach(pthread_t thread);
  • 成功返回 0,失败返回错误码。
  • 线程分离后,无法再对其调用pthread_join,调用会失败。

四、可重入函数与线程安全

多个执行流并发调用同一个函数时,函数的正确性会因为共享资源的访问受到影响,由此衍生出两个相关但不等价的概念:

  • 可重入:函数在执行过程中被中断后再次进入(无论是单线程信号重入,还是多线程并发重入),执行结果依然正确。核心要求是不依赖全局变量、静态变量,仅使用栈上的局部数据和入参,且不调用不可重入函数。
  • 线程安全:多线程并发调用函数时,执行结果依然正确。可以通过加锁保护共享资源来实现线程安全。

二者的关系:可重入函数一定是线程安全的;但线程安全的函数不一定可重入。例如通过互斥锁保护全局变量的函数是线程安全的,但在信号处理中重入可能导致死锁,因此不可重入。

线程局部存储(TLS)

如果希望一个变量每个线程拥有独立副本,互不干扰,可以使用线程局部存储。GCC 下通过__thread关键字声明:

复制代码
__thread int g_val = 0;

每个线程都会拥有该变量的独立副本,修改只对当前线程生效。

注意:线程局部存储不位于线程栈中,而是存储在专门的 TLS 内存段,通过线程控制块中的指针寻址,和线程栈是相互独立的内存区域。


五、线程控制块(TCB)与线程栈

pthread 线程库在用户态为每个线程维护一个线程控制块(TCB,对应 glibc 中的struct pthread结构体),记录了线程的入口函数、参数、返回值、线程属性、栈地址、线程局部存储指针等信息。

pthread_create的执行流程大致为:

  1. 在用户态分配 TCB 与线程栈空间(通常通过mmap映射在内存映射段);
  2. 初始化 TCB 中的各项信息;
  3. 调用clone()系统调用,创建内核轻量级进程,并将用户态栈地址传入内核;
  4. 轻量级进程启动后,执行用户态的线程入口逻辑,最终调用业务入口函数。

我们常说的pthread_t类型的线程 ID,本质上就是当前线程 TCB 结构体的首地址。这也是pthread_join能够获取线程返回值的底层原因 ------ 通过线程 ID 找到对应 TCB,读取其中保存的返回值。


六、C++ 类成员函数作为线程回调的实现

pthread_create要求的入口函数签名是void* (*)(void*),而类的非静态成员函数有一个隐式的this指针参数,函数签名不匹配,无法直接作为线程入口函数。 原因在于:非静态成员函数的调用必须依赖类的实例,编译器会隐式将对象地址作为第一个参数(this 指针)注入函数,因此其参数列表和 pthread 要求的回调签名不兼容。

静态成员函数属于类本身,不绑定具体对象,没有隐式的 this 指针,签名可以匹配回调要求;但静态成员函数无法直接访问类的非静态成员。

行业内的标准解决方案是静态函数跳板法 :用静态成员函数作为回调入口,将对象的this指针作为参数传入,在静态函数内部通过 this 指针调用非静态成员函数。既满足了回调签名要求,又能访问类的所有成员。示例框架如下:

复制代码
class MyThread {
public:
    void create() {
        // 将当前对象的this指针作为参数传给静态入口函数
        pthread_create(&tid_, nullptr, thread_entry, this);
    }

private:
    // 静态成员函数,作为回调跳板,无this指针
    static void* thread_entry(void* arg) {
        // 将参数转回对象指针
        MyThread* self = static_cast<MyThread*>(arg);
        // 调用真正的业务成员函数
        self->run();
        return nullptr;
    }

    // 真正的线程业务逻辑,非静态成员函数
    void run() {
        // 可以自由访问类的所有成员变量和成员函数
    }

    pthread_t tid_;
};

C++ 标准库的std::thread,底层也是基于这一思路,结合模板、参数包、完美转发等特性,实现了对任意可调用对象、任意参数的通用支持。在 Linux 平台下,std::thread的底层本质就是对 pthread 库的面向对象封装;在 Windows 平台下则封装 Win32 原生线程 API,通过条件编译实现跨平台的统一接口。

相关推荐
爱网络爱Linux2 小时前
Linux proc 目录安全加固
linux·运维·rhce·rhca·红帽认证·proc 目录安全加固
JackieZhengChina2 小时前
零依赖轻量级JS悬浮提示插件title2tip-js的使用教程
开发语言·javascript·ecmascript
MindUp2 小时前
企业网盘权限模型解析:多层级访问控制与审计能力选型指南
java·linux·运维
hansang_IR2 小时前
【记录】「SCOI2016」三道模拟赛/26.7.12
c++·算法
ICECREAM2 小时前
环形队列设计——基于共享内存的高效进程间通信
linux
pt10432 小时前
思科网络自动化-API常用数据编码格式(JSON/XML/YAML)课程与实践
linux·服务器·网络
kdxiaojie2 小时前
Linux 驱动研究 —— I2C (5)
linux·运维·笔记·学习
李永奉3 小时前
杰理可视化SDK开发-【BUG】配置“TWS两边同时按消息使能”功能后,按键单击功能无效失灵
开发语言·单片机·嵌入式硬件·物联网·bug
会周易的程序员3 小时前
从零构建多核CPU负载自适应控制系统
linux·c++·笔记·物联网·测试工具