【Linux】线程的内核级理解&&详谈页表以及假造地址到物理地址之间的转化

[复制链接]
发表于 2026-10-3 16:14:13 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×

一、线程到底是什么:从"资源分配单位"讲起

要理解线程,得先回到进程的一个基本属性:进程是操作系统分配资源的基本单位——独立的地址空间、打开的文件描述符、信号处理器、内存映射……创建和维护这些都要付出时间和空间成本。

正因为进程"重",只靠进程来的并发粒度太粗。于是有了线程。

Linux 设计者在这里做了一个很务实的取舍:如果再专门为线程设计一套全新的数据结构和调度算法,整个内核会复杂到难以维护。所以 Linux 走了一条"复用"的路子。

进程 = 资源容器(地址空间 + 各类内核资源)
线程 = CPU 调度的基本单位(执行流)

更准确地说:在 Linux 上,所有可调度执行流都叫轻量级进程。线程和进程在内核里并没有本质区别,共同的结构体都是 task_struct;CPU 调度时压根不用区分"这是进程还是线程",反正都是 task_struct,都是一个执行流。

那线程到底"轻"在哪?答案就一句话:线程共享进程的资源,但要有一份独立的执行上下文。

二、线程共享什么,又独享什么

同一个进程里的多个线程,跑在同一个虚拟地址空间上:

  • 共享:代码区、全局数据区、堆、以及打开的文件描述符表、信号处理设置、当前工作目录、用户凭证等。
  • 各自独享:线程 ID(pthread_t)、独立的栈(stack)、寄存器上下文(尤其是程序计数器 PC 和栈指针 SP)、errno、调度优先级。

所谓"让不同线程看到进程地址空间中代码区的不同代码、并执行不同的代码",实现手段就是:给每个线程一份独立的栈(以及独立的寄存器上下文),但共用同一张页表级映射下的地址空间。不同线程从地址空间的不同入口开始跑,就自然形成了"多执行流"的效果。

这也解释了线程的两面性:共享带来了通信便利(全局变量、共享内存直接可达),也带来了同步难题(数据竞争、需要锁)。这是理解多线程编程的起点。

三、内核视角:task_struct 里装着什么

每个线程都有一个与之关联的 task_struct 结构体,里面包含:

  • 进程/线程状态信息(运行、可中断睡眠、停止、僵尸等)
  • 调度信息(优先级、调度策略、CPU 亲和性)
  • 资源使用情况(占用的内存、打开文件数、CPU 时间)
  • 指向地址空间(mm_struct)、文件系统、文件描述符表等的指针

因为线程和进程共用 task_struct,所以从内核调度器看过去,它们长得一模一样——这就是"轻量级进程"这个叫法的由来。

四、页表:内存管理的基本单位是 4KB

先记住一个数字:内存和磁盘上的数据块,基本大小都是 4KB(page,页)。

为了管理内存中每一块 4KB 的区域,操作系统内核里有一个描述该内存块的数据结构;一片 4GB 的内存就有 4GB / 4KB = 1048576 个这样的块。用数组把它们组织起来,整个内存的管理就退化成对数组的增删查改——这是内核内存管理最朴素的思路。

所以:操作系统进行内存管理的基本单元是 4KB(页)。

五、页表不是一张大表,而是两级(乃至多级)

很多人以为页表就是一整张薄薄的表,其实在 32 位地址的语境下它是分层的:页目录(page directory)+ 页表(page table)。

一个虚拟地址有 32 位,拆成三段:

位段 bits 0–11bits 11–21bits 21–31
名称页内偏移页表项下标页目录项下标
作用定位 4KB 块里的具体哪个字节定位页表里哪一项定位页目录里哪一个页表

具体机制:

  • 高 10 位(典型实现是 bits 22–31)用来表示处于页目录的哪一个位置,页目录里可以存放若干(1024 个)页表的首地址。
  • 中间 10 位(bits 12–21)用来表示处于哪一个页表,一个页表里也有若干(1024 个)页表项。
  • 低 12 位定位到该 4KB 数据块中的具体字节。12 位刚好能表示 2^12 = 4096 = 4KB 个状态,所以低 12 位也被叫做页内偏移。

地址转换就是"三级查表":虚拟地址 → 页目录找到页表 → 页表找到页框号(物理页)→ 拼上页内偏移 = 物理地址。

页表项里还有权限位

如果再在页表项中加入访问权限审查、是否被使用过等等标志位,就能对用户的内存访问做审查与拦截,保证内存访问的安全性。常见的标志位包括:

  • present / valid:这一项是否有效
  • read/write:这一页是否可写
  • user/superuser:用户态能不能访问
  • executed/disabled:是否禁止执行(这个位就是后来 CPU 安全加固的关键一环,用来堵"栈上执行代码")
  • accessed / dirty:是否被访问过、是否被修改过(供换页算法判断能不能直接丢弃)

换句话说,你写的那段内存越权访问会被拦下,靠的就是页表项里的这些比特,而不是靠什么额外的检查代码。

六、虚拟地址到物理地址:多线程是怎么被"撑"起来的

按上面这套分页存储模式,我们写的函数编译完之后,就是一块一块的虚拟地址。根据函数名就能找到函数入口地址,各个函数被分配到不同的虚拟地址,也就落到了页表区域的不同位置。

关键点在这里:

同一进程的不同线程 → 共用同一套页表(同一地址空间)
切换线程时 → 只切换栈指针 + 寄存器上下文,页表基准(mm)不变
切换进程时 → 连页表基准一起换(TLB 要刷新)

给不同的线程分配不同的栈区(以及独立的执行入口),就能让不同的线程访问到页表的不同子集,进一步就能访问到不同的代码——多线程技术就是这么落地的。

反过来想,如果每个线程都用独立的一套页表、独立的一套地址空间,那它就是"另一个进程"了。Linux 干脆不区分:线程只是一个共享 mm 的 task_struct。

顺带理解一下 TLB

每次地址转换都查两级/多级页表,性能代价不小,所以 CPU 里有个 TLB(快表)缓存最近用过的地址映射结果。切进程要刷 TLB,切线程不用——这也是"线程切换比进程切换便宜"的一个重要来源。

七、pthread_create 的四个参数到底在说什么

落到代码层面,pthread_create 的原型是:

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

四个参数逐个拆开:

thread:用来回传新线程的 ID

这是一个指向 pthread_t 类型的指针,用于获取新创建线程的线程 ID。调用 pthread_create 之后,这个指针会被设置为新线程的 ID。后面 pthread_join、pthread_detach 都要用它。

attr:线程属性

这是一个指向 pthread_attr_t 类型的指针,用于设置线程属性,如栈大小、优先级等。传 NULL 就使用默认属性——大多数场景都应该传 NULL,不需要自己折腾。

start_routine:线程的入口点

一个函数指针,指向线程开始执行时要调用的函数,通常称为线程的"入口点"或"启动例程"。它的返回值是 void*、参数也是 void*,这个约定就是为了让你能传任意类型的数据进去、也能让线程返回任意结果。

arg:传给启动例程的参数

用于向线程的启动例程传递参数,可以通过这个参数向线程传递任何类型的数据。如果启动例程不需要参数,就传 NULL。

#include <pthread.h>
#include <iostream>

void* newthread(void* arg)
{
    while (true) {
        std::cout << "hello from thread" << std::endl;
    }
    return NULL;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, newthread, NULL);
    pthread_join(tid, NULL);
    return 0;
}

编译时需要链接 pthread:g++ main.cpp -o app -lpthread。上面这个循环是个死循环,只为演示"线程里持续执行"这件事,实际写代码一定要有退出条件,否则线程永远不结束。

八、三个必须记住的配套结论

  1. 线程是 CPU 调度的基本单位,进程是资源分配的基本单位。Linux 上两者都体现为 task_struct,线程也叫轻量级进程。
  2. 4KB 是内存管理的基本单元,页表是分层的(页目录 → 页表 → 页内偏移),虚拟地址到物理地址靠逐级查表,页表项里的权限位承担了访问审查。
  3. 多线程共享地址空间但各自有栈,这既省了创建成本,也带来了同步问题——共享数据就必须配锁。

把"进程管资源、线程管执行、页管内存"这三件事串起来,再看 pthread_create,就不会觉得它只是一串函数调用了。

(本文由 qidao123.com企服评测-应用市场 编辑提示整理:示例基于 Linux 2.6+ 内核的 NPTL 线程模型与 x86 两级页表,代码示例为 POSIX 标准用法,适用于 GCC / G++ 环境。)

回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表