[Linux]线程三部曲(上):一个执行流的诞生——从操作系统一路拆到 pthread_create

在所有操作系统的话题里,"线程"大概是最容易让人以为自己已经懂了的那一个。

  背下 pthread_create 的四个参数只要二十分钟,可只要往下多问一层------线程的栈是谁分配的?pthread_t 打印出来为什么像个地址?一个线程抛了异常,凭什么整个进程跟着陪葬?------那点"懂了"的感觉就会立刻漏气。

  这一篇要做的,就是把漏气的地方全部补回来。而补完之后会有一个相当过瘾的收获:Linux 对线程的处理方式有一种近乎吝啬的聪明------它压根没有为线程发明任何新东西,只是把进程的零件拆开,让它们共用。 这个"抠门"的答案,比教科书上那句"线程是进程内的执行单元"有意思得多。

  从这里出发,把镜头从操作系统的大全景一路推近,直到看清 pthread_create 括号里的每一个参数。

hello hello 呀,大家好 ,提前祝大家中秋快乐 这是"线程三部曲"的第一篇。

三篇各有各的视角:上篇是内核视角 ,我们把镜头从操作系统的大全景一路推近,直到看清 pthread_create 那四个参数到底是什么;中篇是工程视角 ,讲怎么把 pthread 装进 C++ 的盒子,以及共享资源第一次翻车的事故报告;下篇是协同视角,讲一群线程怎么从"各抢各的"变成"排好队干活"。

本篇不写一行"能跑起来"的多线程代码,但读完回过头再看那条 pthread_create(&tid, NULL, threadRun, NULL),会知道括号里每一个参数在内核里对应着什么。


本篇目录

  • 一、先说清楚我们为什么要绕这么大一圈
  • 二、宏观全景:三层视角,一张地图
  • 三、第一层拆解:一个"正在运行的程序"到底占用了什么
    • 3.1 从"进程"的定义说起
    • 3.2 task_struct:一个执行流的全部档案
    • 3.3 内存视野这一项,值得单独展开:mm_struct
  • 四、第二层拆解:执行流自己,需要什么私有财产
    • 4.1 一次思想实验:两兄弟共用一间房
    • 4.2 私有的三样东西
    • 4.3 一张表讲清楚:共享什么,私有什么
  • 五、第三层拆解:Linux 的选择------不造线程,复用进程
    • 5.1 一句话捅破窗户纸
    • 5.2 pid 和 tgid:两个编号,各管一摊
    • 5.3 三个可以立刻动手验证的实验
  • 六、第四层拆解:内核给得太素,用户层来补
    • 6.1 pthread 库存在的理由
    • 6.2 库里的档案:TCB 是怎么被设计出来的
    • 6.3 一个反直觉的结论:线程 ID 是一个地址
  • 七、最底层:clone ------ 一切的起点
    • 7.1 为什么不是 fork
    • 7.2 用标志位"点菜"
  • 八、回到起点:pthread_create 的四个参数到底是什么
    • 8.1 第一个多线程程序:把理论跑出来
    • 8.2 承上启下的收尾:两个被埋下的雷
  • 九、本篇速查表
  • 十、下一篇预告

一、先说清楚我们为什么要绕这么大一圈

  线程的入门路径往往是这样:打开教程,抄一段 pthread_create,编译跑通,看到两行字交替打印,然后点点头------"哦,线程就是能同时跑两段代码的东西"。

  这个理解没错,但是它薄。薄在哪里?薄在它只记住了现象 ,没有建立因果。于是接下来会遇到一连串想不通的问题:

  • 为什么两个线程里 getpid() 打印出来是同一个数字?
  • 为什么我在线程里改了全局变量,另一个线程能看见?
  • 为什么任何一个线程崩了,整个程序都跟着死?
  • 为什么 pthread_t 打印出来是一个奇奇怪怪的大数(地址),而不是 1、2、3?
  • 为什么线程的栈要单独分配,而全局变量不用?
  • fork() 和创建线程,底层到底差在哪?

  这些问题,靠背 API 是回答不了的。它们的答案不在 pthread.h 里,而在内核里。所以这一篇我们换个讲法------不从 API 讲起,从操作系统讲起

  打个比方。要理解"一个城市的某条地铁线为什么这么走",光看地铁图是不够的,还得先知道这个城市的地形、人口分布、规划规则。线程就是那条地铁线,操作系统就是那座城市。

本篇的主线只有一条:由大到小,一层一层往下拆

操作系统怎么看待"正在运行的程序" → 进程里到底装了什么 → 那些东西怎么被描述 → 执行流本身需要什么 → Linux 为什么说"我没有线程" → 用户层怎么把它补回来 → 最后一个系统调用怎么把这一切串起来。

  提前说一句:这条路走完就会发现,Linux 的线程实现方案其实非常"抠门"------它没有为线程发明任何新东西,而是把进程的零件拆开,让大家共用。这个"抠门"的设计,正是后面所有并发问题的总源头。


二、宏观全景:三层视角,一张地图

  在往下拆之前,先站远一点,看一眼全貌。

  一个多线程程序,从应用层写下 std::thread t(f); 到 CPU 真的开始执行 f,中间要穿过三个世界。这三个世界各自说自己的语言,各自有各自的概念:

复制代码
┌─────────────────────────────────────────────────────┐
│  ① 应用层(开发者代码)                              │
│     语言:C++ / std::thread、std::function、lambda   │
│     概念:任务、回调、"我要并发做几件事"              │
├─────────────────────────────────────────────────────┤
│  ② 用户库层(pthread 库,libpthread / glibc 内置)    │
│     语言:POSIX 线程接口(pthread_*)、TCB            │
│     概念:线程对象、线程ID、独立栈、join/detach       │
├─────────────────────────────────────────────────────┤
│  ③ 内核层(Linux)                                   │
│     语言:task_struct、clone、调度器                  │
│     概念:轻量级进程 LWP、地址空间、页表、上下文      │
└─────────────────────────────────────────────────────┘

  这张图是本篇的骨架。上篇要做的事情,就是把这张图从上往下、再从下往上,走通一遍。

  走得通的标志是什么?是看到 pthread_create(&tid, nullptr, threadRun, nullptr) 时,脑子里能自动分层展开:

  • 第 4 个参数 nullptr,最终会变成内核里一个新 task_struct 的某个寄存器初值;
  • 第 3 个参数 threadRun 的地址,会变成那个新执行流的第一条指令地址(也就是 PC 初值);
  • 第 1 个参数 &tid,会被用户库填上一个地址------那个地址指向库里的一个结构体;
  • 而这一切的动作,最终落到一个系统调用 上:clone

  全景看完了。现在从最外层开始拆。


三、第一层拆解:一个"正在运行的程序"到底占用了什么

3.1 从"进程"的定义说起

  课本上对进程的定义通常是"程序的一次执行过程"。这个定义不算错,但它对写代码的人没什么帮助------没法拿这句话去理解 fork() 为什么慢,也没法理解线程为什么快。

  我们换一个更有操作性的定义:

进程 = 内核里的一组数据结构 + 这份程序自己的代码和数据。

  这句话是本篇的地基,值得拆开看。"内核里的一组数据结构"意味着:进程不是一个虚的东西,它是内核里实实在在的一堆 C 结构体,能被遍历、能被计数、能被销毁。"自己的代码和数据"意味着:这些东西是从磁盘上的可执行文件加载进来的,放在一块属于它的内存里。

  那么问题来了。内核要描述一个进程,需要记下哪些事?

  我们来当一回内核设计师,从需求倒推:

  1. 调度器要能在成百上千个进程里挑一个来跑,那就得知道每个进程现在处于什么状态(在跑?在睡?已停止?)。
  2. 调度器切走一个进程、过会儿再切回来,得能接着上次的位置继续跑 ,那就得保存CPU 上下文的现场(寄存器、程序计数器)。
  3. 用户执行 ps 要看到一个编号,那就得给进程编号(pid)。
  4. 进程有自己的内存视野,那就得有一个东西描述它能看到的内存
  5. 进程打开的文件、当前工作目录、收到的信号怎么处理......这些也都得记着。

  需求列出来了,接下来才是结构体登场------这就是"先讲问题、再贴结构"的意思。内核把这些需求捏成了一个结构体,叫 task_struct ,也就是我们常说的 PCB(进程控制块)

3.2 task_struct:一个执行流的全部档案

  下面是一段简化过的 task_struct 片段(字段名以 Linux 5.x/6.x 为准,不同版本会有出入,但主干一致)。注意看注释------每个字段的存在,都对应上面列出的某一条需求

复制代码
struct task_struct {
    /* ---- 需求 1:调度器要挑人 ---- */
    volatile long state;          // 执行流当前状态:TASK_RUNNING(就绪/运行)、
                                  // TASK_INTERRUPTIBLE(可中断睡眠)等
    int prio;                     // 动态优先级,调度器排序用
    unsigned int policy;          // 调度策略:普通分时?实时?

    /* ---- 需求 2:切走了要能切回来 ---- */
    struct thread_struct thread;  // CPU 上下文:通用寄存器、栈指针、PC 等,
                                  // 切换执行流时保存/恢复的就是它

    /* ---- 需求 3:要有个身份编号 ---- */
    pid_t pid;                    // 这个 task_struct 自己的编号
    pid_t tgid;                   // 它所属"线程组"的编号(后面细说,关键!)

    /* ---- 需求 4:要有自己的内存视野 ---- */
    struct mm_struct *mm;         // 指向地址空间描述符
    struct mm_struct *active_mm;  // 内核线程没有 mm,借用别人的

    /* ---- 需求 5:各种附属资源 ---- */
    struct fs_struct    *fs;      // 当前工作目录、根目录
    struct files_struct *files;   // 打开的文件描述符表
    struct signal_struct *signal; // 信号相关
    struct sighand_struct *sighand; // 信号处理函数表

    /* ---- 其他 ---- */
    void *stack;                  // 内核栈指针
    struct list_head thread_group;// 同组的兄弟执行流,串成一条链
    struct task_struct *parent;   // 父进程
};

  这里有一个必须现在就点破的伏笔

  上面这个结构体,我们习惯称它为"进程控制块 "。但仔细数一数------它描述的其实是一个独立的执行流 需要的一切:状态、上下文、身份、内存视野、附属资源。它描述的对象是"一条执行流的全部档案",而不是"一个程序的全部资源"。

  这个区别,就是后面 Linux 实现线程的全部秘密。先记着,第三节末尾我们回来收这个伏笔。

3.3 内存视野这一项,值得单独展开:mm_struct

  task_struct 里那个 struct mm_struct *mm,是整篇里最值得细看的一个字段,因为线程和进程的分野,就画在这个字段上

  还是先问问题:内核要给进程描述内存,具体要描述什么?

  写过 C 程序的人都知道,一个进程的虚拟地址空间长这样:低地址是代码段、只读数据段、已初始化数据段、未初始化数据段,然后往上堆生长,中间一大片空着,然后栈从高地址往下生长;最顶上还有一块留给内核。另外还有命令行参数和环境变量,它们也在栈区附近。

  内核要能回答这些问题:这段内存是代码还是数据?堆现在涨到哪了?栈顶在哪?程序名和参数放哪儿了?

  所以内核设计了 mm_struct,也就是"地址空间描述符"。同样,字段和需求一一对应:

复制代码
struct mm_struct {
    /* ---- 需求:虚拟地址怎么变成物理地址? ---- */
    pgd_t *pgd;                  // 页全局目录(Page Global Directory)基址。
                                 // 这块内存就是"页表"的根,MMU 靠它做地址翻译

    /* ---- 需求:各段的边界在哪? ---- */
    unsigned long start_code, end_code;   // 代码段起止
    unsigned long start_data, end_data;   // 数据段起止
    unsigned long start_brk, brk;         // 堆的起点、堆当前顶端
    unsigned long start_stack;            // 栈的起始位置

    /* ---- 需求:命令行参数和环境变量 ---- */
    unsigned long arg_start, arg_end;     // argv 区域
    unsigned long env_start, env_end;     // envp 区域

    /* ---- 需求:这片地址空间被多少人在用? ---- */
    atomic_t mm_users;           // 有多少个 task_struct 正在使用它 ★★★
    atomic_t mm_count;           // 主引用计数

    /* ---- 需求:那些零散的映射(mmap 出来的)怎么记? ---- */
    struct vm_area_struct *mmap; // 虚拟内存区域链表
};

  请把目光停在 mm_users 这个字段上,多停几秒。

  一个"普通进程"的 mm_users 是 1------只有它自己在用这片地址空间。

  现在设想另一种情况:如果我把同一个 mm_struct 指针,赋给两个不同的 task_struct 呢?

  那这两个执行流就会看同一片内存 :同一个代码段、同一个堆、同一批全局变量。它们各自有自己的状态、自己的上下文(thread_struct)、自己的身份编号(pid),但是共享同一个"内存视野"。

  ......这不就是我们想要的线程吗?

  是的。答案就在这里。Linux 造线程的方法,不是发明一个新结构体,而是让多个 task_struct 共享同一个 mm_struct

  这个想法一旦成立,剩下的事情就顺理成章了:files_struct 也可以共享、fs_struct 也可以共享、sighand_struct 也可以共享。内核要做的,只是提供一种"创建执行流时,选择哪些零件共享、哪些零件拷贝"的机制。

  那个机制叫 clone。我们第六节见它。


四、第二层拆解:执行流自己,需要什么私有财产

4.1 一次思想实验:两兄弟共用一间房

  上一节我们得出了一个漂亮的结论:共享 mm_struct 就能得到线程。

  但先不急着下结论,这里可以做一次思想实验。

  假设有两个执行流共享了同一片地址空间、同一个堆。现在第一个执行流正在执行 f() 函数,f() 里定义了一个局部变量 int a = 10;。紧接着调度器把第一个执行流切走,让第二个执行流上 CPU------第二个执行流也调用了 f(),也定义了 int a = 20;

  请问:这两个 a 是同一个 a 吗?

  必须不是。如果是同一个,那 f() 这个函数就没法被两个线程同时调用了------第一个线程算到一半,第二个线程把它的临时变量改了,结果必然错乱。

  这就引出了一个问题:共享地址空间之后,哪些东西必须保持私有?

  答案是:凡是描述"我这条执行流跑到哪儿了"的东西,都必须是私有的。

4.2 私有的三样东西

  具体是哪三样?

  第一样:CPU 的硬件上下文。 包括通用寄存器、栈指针(SP)、程序计数器(PC/EIP/RIP)。这是"我执行到哪一条指令了"的唯一凭证。两个执行流如果共用一套寄存器,那就不是两条执行流了,那是一条------因为它们会互相覆盖对方的进度。

  在内核里,这份上下文就存在 task_structthread_struct thread 字段里(第一节见过)。这是最核心的私有财产。

  第二样:栈。 栈是函数调用链的载体------每个函数的局部变量、返回地址都压在栈上。两条执行流的函数调用链必然不同(一个可能在 f() 里,另一个可能在 g() 里),所以必须各自有各自的栈

  这一点值得多说一句,因为它解释了一个常见困惑:线程的栈从不在代码里显式管理,但它确实存在,这是为什么? 答案是:用户线程的栈,是 pthread 库在创建线程时替线程在堆上(或者 mmap 区)分配好的一块内存 。默认大小一般是 8MB,可以用 pthread_attr_setstacksize 改,也可以用 pthread_getattr_np + pthread_attr_getstack 把它查出来。它是地址空间里的一块普通内存,只是库把它登记成了"这个线程的栈",然后把栈指针指过去。

  第三样:线程局部存储(TLS)。 这是最容易被忽略的一样。

  举个例子。errno 在单线程程序里当全局变量用得好好的,但它在 glibc 里其实是每个线程一份的------否则线程 A 的出错码会被线程 B 覆盖掉。

  再比如课程痕迹文件 TestThread.cc 里躺着这么一段注释代码:

复制代码
// 线程局部存储: 只能用来局部存储内置类型,常见的是整形。
// 可以让不同的线程用同样的变量名,访问不同的内存块,各自访问各自的局部存储!
__thread pid_t id = 0;   // GCC 扩展关键字;C++11 的标准写法是 thread_local

  __thread(C++11 起是 thread_local)的作用就是:同一个变量名,每个线程看到的是一份独立的存储 。上面这段代码里,主线程把 id 设成自己的 LWP 号,新线程把 id 设成它的 LWP 号,两边互不干扰------因为它们在物理上是两块内存。

  这三样东西(上下文、栈、TLS),构成了执行流的"私有财产清单"。

4.3 一张表讲清楚:共享什么,私有什么

  把前面几节的内容合起来,就是这张表。这张表是本篇的题眼,也是后面所有并发问题的总源头------出了问题的地方,永远在"共享"那一栏:

资源 进程内线程之间 内核里的载体
地址空间(代码、全局变量、堆) 共享 mm_struct
页表 共享(同一个 mm 就同一张页表) mm_struct->pgd
打开的文件描述符表 共享 files_struct
当前工作目录、根目录 共享 fs_struct
信号处理函数表 共享 sighand_struct
CPU 上下文(寄存器、PC、SP) 私有 task_struct->thread
私有 pthread 库分配的独立栈
线程局部存储(TLS、errno) 私有 库 + TLS 机制
身份编号 私有(各有一个 pid) task_struct->pid

  看完这张表,前面那些"想不通的问题"就自动有答案了:

  • 为什么两个线程 getpid() 一样? 不,它们内核里的 pid 其实不一样,是 getpid() 这个函数特意返回了 tgid(线程组 ID),我们第五节就讲这个。
  • 为什么改全局变量另一个线程能看见? 因为全局变量在代码/数据段里,属于 mm_struct 管的地址空间,是共享的。
  • 为什么线程要有自己的栈? 见 4.2。
  • 为什么 fork() 慢、创建线程快? 因为 fork() 要拷贝一整套(页表、fd 表......),而线程只创建了一个新 task_struct 加一个栈,剩下的全是指针指过去。

  到这里,"由大到小"的拆解已经走过了三层:操作系统 → 进程 → 地址空间/执行流。再往下,就是内核最底层的抉择了。


五、第三层拆解:Linux 的选择------不造线程,复用进程

5.1 一句话捅破窗户纸

  现在我们把前面的伏笔收掉。

  我们已知:

  • task_struct 描述的是一条执行流的全部档案
  • 多个 task_struct 共享一个 mm_struct,就得到了线程。

  那么请问:Linux 内核里,到底有没有一个叫"线程"的实体?

  答案是:没有。

  课上的原话是这样的:

在 Linux 当中,在 CPU 视角里看到的一个个的执行流,不叫进程,我们也不把它叫线程,我们把它叫做轻量级进程(LWP,Light Weight Process)

因为很显然,它在执行的时候,加载的 PCB 可能是进程的唯一一份,也可能是多份当中的一份------所以我们把它叫做轻量级进程。

  这段话值得逐字读三遍。它的意思是:

  内核根本不在乎它被叫作什么。内核眼里只有 task_struct。一个 task_struct 就是一个可以被调度器挑选的执行流 ,这是调度层看到的东西。至于这些 task_struct 背后共享了多少资源------共享了 mm_struct 就是"线程",不共享就是"进程"------那是资源层看到的东西。

  Linux 把"调度"和"资源"这两件事彻底拆开了,然后用同一个结构体描述前者。 这个设计也解释了为什么 Linux 内核里找不到 thread_struct 这样一个"线程结构体"(注意:task_struct 里那个字段虽然叫 struct thread_struct thread,但它存的是 CPU 寄存器上下文,不是"线程对象")。

  于是我们得到了一个非常重要的等式:

进程 = 一个或多个轻量级进程(LWP) + 它们共用的 PCB 与虚拟地址空间、页表、代码数据

  反过来说:我们以前学的"进程",本质上是一个"内部只有一个执行流的进程"------单执行流进程。

5.2 pid 和 tgid:两个编号,各管一摊

  既然一个进程里可以有多个 task_struct,每个 task_struct 又都有自己的 pid 字段,那么用户敲 kill 1234 的时候,杀的是哪一个?

  这就轮到 task_struct 里那个被我们标了"关键"的字段出场了:

复制代码
pid_t pid;    // 这条执行流自己的编号 ------ 对线程而言就是它的 LWP 号
pid_t tgid;   // thread group id ------ "线程组"的编号,等于主线程的 pid

  内核用这两个编号分工

  • pid 是调度层的编号。区分执行流,靠的是它。 在多线程程序里,每个线程的这个值都不一样。
  • tgid 是资源层的编号。一个进程里的所有线程,tgid 全都相同 ,而且等于主线程的 pid

  而 getpid() 这个函数,返回的是 tgid 而不是 pid 。这就是"两个线程打印出来的 pid 一模一样"的全部答案------不是它们的 pid 相同,是 getpid() 压根没返回 pid

  课程里有一个非常直观的实验观察记录:

两个执行流名字都叫"线程",它们两个的 pid 竟然是一样的;而它们两个还有一个 LWP(轻量级进程)编号,很显然,一个叫 205,一个叫 206,轻量级进程的 pid 不一样。

那么这两个执行流,哪一个是主线程呢?LWP 的值和 pid 的值相等的那个,就是主线程。

5.3 三个可以立刻动手验证的实验

  理论讲完了,验证一下。以下三个实验都值得亲自跑一遍,眼见为实。

  实验一:看见 LWP。 用课程痕迹里的 lesson43/2.code/testThread.c,编译运行后另开一个终端:

复制代码
ps -aL | grep testThread
#  -a 显示所有终端上的进程
#  -L 关键!显示"线程"------也就是把每个 LWP 单独列成一行

  可以看到同一个 PID 名下,出现了两行不同的 LWP 。第一行的 PID 和 LWP 相同,那就是主线程;第二行 LWP 不同而 PID 相同,那就是我们用 pthread_create 创建的新线程。这行输出就是"多线程"这件事在内核里的真身。

  实验二:杀进程会连坐。 把上面那个程序跑起来,然后用 kill <pid> 杀它。这节课的原话是:

那么依赖进程资源生存的所有线程,自然也会被杀掉。因为进程一旦被杀掉,进程的 PCB、页表、还有代码和数据全部都没有了。

  这句话反过来读,就是"线程为什么轻"的代价:线程的"轻",是建立在"寄人篱下"之上的。它不拥有地址空间,所以它必须依赖进程活着。

  实验三:一个线程崩溃,全进程陪葬。 课程痕迹里有一段被注释掉、但专门留着的实验代码:

复制代码
void *Routine(void *args)
{
    int cnt = 5;
    while (cnt) {
        std::cout << "新线程在运行: " << cnt-- << std::endl;
        sleep(1);
        int a = 10;
        a /= 0;          // 故意制造除零异常
    }
    return nullptr;
}

  跑起来可以看到:新线程一除零,主线程也跟着一起死了,整个进程直接挂掉。

  为什么?还是因为共享。除零会触发一个信号,而信号处理函数表(sighand_struct)是进程内所有线程共享的 ;更要命的是,一个未处理的致命信号是发给整个进程的,内核会终止线程组里的所有执行流。共享地址空间让线程"轻",也让它"一损俱损"。

  ------这也是为什么痕迹文件的注释里会写:新线程出异常,进程全部都会退出,根本没机会 join 成功,所以 pthread_join 获取到的退出信息里没有退出信号,不需要关心异常。这句话看着像抱怨,其实是精确的内核行为描述。


六、第四层拆解:内核给得太素,用户层来补

6.1 pthread 库存在的理由

  到现在为止,内核给我们的东西是:一个 clone 系统调用,能造出一个共享了部分资源的 task_struct,也就是一个 LWP。

  对内核来说这够用了。但对写程序的人来说,这东西太素了

  • 它没有"线程"这个名字,只有"轻量级进程"这个语义模糊的说法;
  • 它没有线程 ID 的概念(task_struct->pid 是内核编号,而且会被回收复用,不能长期持有);
  • 它没有线程返回值------线程干完活要带个结果回来,往哪儿放?
  • 它没有"等待一个线程结束"的原语------wait() 是给进程用的,而且线程退出并不产生可 wait 的僵尸进程状态;
  • 它没有"分离线程"的概念------我希望这个线程自己跑完自己清理,不要人来收尸;
  • 它没有取消(cancel)机制------我想让一个死循环线程停下来。

  所以,Linux 在用户层提供了一套库,把这堆裸露的 LWP 包装成"线程"这个概念 。这套库就是 pthread 库(原生线程库,glibc 里已经内置)。它实现了 POSIX 线程标准定义的那套接口:

复制代码
用户层:线程(pthread 库封装出来的概念)
   ↓ 底层封装
系统调用:clone(创建轻量级进程,可以不用拷贝页表和虚拟地址空间)
   ↓
内核层:轻量级进程 LWP

  这张三层图,就是本篇开头那张全景图的下半部分,现在算是补全了。

6.2 库里的档案:TCB 是怎么被设计出来的

  进程有 PCB(task_struct),线程也得有自己的"档案",这份档案叫 TCB(Thread Control Block)

  但这里有一个关键区别:线程的 TCB 不在内核里,而在 pthread 库里。 内核只管 LWP,LWP 之外的线程语义(ID、返回值、分离状态、清理函数),全部由库自己记账。

  那么,这份账要记哪些项目?还是老规矩,从需求倒推:

  1. 每个线程要有自己的独立栈------要记下栈的起始地址和大小(第 4.2 节讲过为什么必须有独立栈)。
  2. 线程函数可以 return 一个 void* 作为退出结果,这个结果要存着 ,等别人 join 的时候交出去。
  3. 线程可以被 join 或 detach,库得记住它当前是哪种状态。
  4. 内核给的 LWP 号也要存一份,方便调试和系统调用。
  5. 线程可能注册了线程局部存储pthread_key_create 那套),这些也要有个地方放。
  6. 线程退出时可能有清理函数 要回调(pthread_cleanup_push),需要一个栈来记录。

  把这些需求捏起来,就得到了课程里给出的简化模型(对应的是 glibc 源码里的 struct pthread,位于 nptl/descr.h):

复制代码
struct thread {          // pthread 库中的线程控制块(简化模型)
    pid_t tid;           // 轻量级进程 ID(LWP),即对 clone 等系统调用的封装结果
    void *stack;         // 线程独立栈的起始地址
    size_t stack_size;   // 线程独立栈的大小
    void *retval;        // 线程的返回值(return 回来的那个 void* 存在这)
    /* ... 还有描述线程是否分离的相关指针字段 ... */
};

  对照真实的 glibc struct pthread,可以看到这些字段几乎一一对应:tid(LWP 号)、stackblock / stackblock_size(独立栈)、result(返回值)、joinid(谁在等我,对应分离状态)、specific[](线程局部存储数组)、cleanup(清理函数栈)、cancelhandling(取消状态)。

顺带一个有意思的细节:glibc 里那份真实结构体的名字不叫 struct thread,叫 struct pthread 。而它的第一个字段通常是一个 tcbhead_t header------这个"放在最前面"的安排不是随手写的,TLS 机制要求线程控制块的地址能直接被一段特殊寄存器定位到,所以它必须在结构体的固定偏移上。这也是为什么这份结构体不能随便改字段顺序。

6.3 一个反直觉的结论:线程 ID 是一个地址

  现在可以回答那个经典问题了------pthread_t 到底是什么?

  很多人的直觉是:它是个编号,就像 pid 那样,1、2、3 递增。

  不是。

线程 ID,本质上是 pthread 库映射到当前进程虚拟空间之后,该线程的线程控制块(TCB)在库内部的起始地址。

  也就是说,pthread_t 装的是一个虚拟地址,它指向这个线程的 TCB。只要拿到这个地址,就能顺着它找到该线程的控制块、线程局部存储、以及独立栈。

  这件事有三个可观察的推论:

  1. 代码里 printf("tid: 0x%lx", tid) 打印出来的会是一个像地址的大数 ,不是 1、2、3。课程痕迹里就有这么一行 printf("new thread id: 0x%lx\n", tid); // lwp?? ------那个问号问得好,答案是:它既不是 LWP 号,也不是序号,是地址。
  2. pthread_self() 返回的也是这个地址。
  3. 内核眼里的 LWP 号和它完全不是一回事 。想拿内核的 LWP 号,得用 syscall(SYS_gettid) 去问内核(痕迹文件的注释里就留了这个函数:pid_t GetTid() { return syscall(SYS_gettid); })。

  换句话说,一个线程身上挂着两个"ID"

名字 谁给的 是什么 怎么拿
pthread_t pthread 库 TCB 的起始地址 pthread_self()
tid / LWP 号 内核 task_struct->pid syscall(SYS_gettid),或 ps -L 看 LWP 列

  调试的时候用哪个?两个都用得上:pthread_t 用来在库里定位线程,tid 用来在 ps -Ltop -H/proc/<pid>/task/ 里定位线程。课程里 pthread_setname_np 改的是内核里那个 LWP 的名字 (所以 ps -L 能看到名字变了),这件事我们中篇会细说。


七、最底层:clone ------ 一切的起点

7.1 为什么不是 fork

  故事讲到这里,只剩最后一个问题:那个"选择性共享零件"的机制,到底长什么样?

  先看为什么不能用 fork()

  fork() 的语义是"克隆一个几乎一模一样的进程":子进程有自己独立的 地址空间(写时拷贝,但语义上是独立的)、独立的 文件描述符表、独立的信号处理。这正好是线程的反面。

  我们需要的是一个能"点菜"的创建接口:我要共享地址空间、共享 fd 表、共享信号处理,但是要一个独立的执行流和独立的栈。

  这个接口就是 clone

7.2 用标志位"点菜"

  clone 的接口大致是:

复制代码
#define _GNU_SOURCE
#include <sched.h>

int clone(int (*fn)(void *),    // 新执行流从哪个函数开始跑
          void *stack,          // 新执行流的栈(用户态自己准备)
          int flags,            // ★ 最关键:一张"共享还是拷贝"的点菜单
          void *arg,            // 传给 fn 的参数
          ...                   // 还有若干可选参数(父/子 TID 写到哪等)
          );

  那个 flags 参数是一堆按位或的标志位,每一项都在回答"这个零件,新的执行流是共用 还是自己拷贝一份"。挑几个和线程最相关的:

标志位 含义 不设置时(即 fork 的行为)
CLONE_VM 共享虚拟地址空间(共用 mm_struct 拷贝一份地址空间
CLONE_FS 共享文件系统信息(fs_struct:cwd、根目录) 各自一份
CLONE_FILES 共享文件描述符表(files_struct 各自一份
CLONE_SIGHAND 共享信号处理函数表(sighand_struct 各自一份
CLONE_THREAD 放进同一个线程组 (于是 tgid 相同) 独立成组
CLONE_SYSVSEM 共享 System V 信号量的 undo 信息 各自一份
CLONE_SETTLS 为新执行流设置 TLS 区域 ---
CLONE_CHILD_CLEARTID 线程退出时清零一块内存并唤醒等待者(join 的底层机制之一) ---

  现在可以一眼看穿这张表的规律了:

CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS | ... 这一串全点上,得到的就是一个"线程";一个都不点,得到的就是 fork 出来的"进程"。

  这就是 Linux 那句著名的说法的由来:线程和进程在内核里没有本质区别,区别只在 clone 的标志位上。

  而 pthread 库干的事情,说白了就是:准备好栈,拼好这一串标志位,然后调 clone 课程里那句总结非常到位:

我们写的所有线程代码,本质上都是对 clone 系统调用的封装。


八、回到起点:pthread_create 的四个参数到底是什么

  绕了一大圈,现在我们从内核爬回用户层,重新看这条语句:

复制代码
pthread_t tid;
pthread_create(&tid, NULL, threadRun, NULL);

  以前看它,是四个"规定要这么写"的参数。现在我们把它们逐个拆开,看它们分别对应着前面哪一层的知识。

复制代码
// 1. 输出型参数:创建成功后,库把新线程 TCB 的地址填进来
//    (不是序号,是地址 ------ 见 6.3 节)
pthread_t *tid

// 2. 线程属性:NULL 表示全用默认
//    属性里能改的东西包括:栈大小、是否分离、调度策略......
//    想想 4.2 节:栈是库替我们分配的,所以"栈多大"这个属性才有意义
const pthread_attr_t *attr

// 3. 线程入口函数:新执行流从哪个函数开始跑
//    它会变成内核里新 task_struct 的 PC 初值 ------ 第一节需求 2 讲过的上下文
//    类型必须是 void *(*)(void *),这是 pthread 的约定
void *(*start_routine)(void *)

// 4. 传给入口函数的参数:会成为新执行流第一次执行 start_routine 时,
//    寄存器里传进去的那个值(对应 4.2 节"私有上下文")
void *arg

  课程里对这个函数有句总结,把它的两件事说得很清楚:

pthread_create 干两件事:1. 返回唯一 tid 编号,方便后续线程操作;2. 分配线程执行任务。

  现在可以补上第三件事和第四件事了:3. 在堆上分配一块栈;4. 拼好一整串 CLONE_* 标志位,调用 clone

8.1 第一个多线程程序:把理论跑出来

  理论说完了,跑一个最小验证。这份代码对应课程痕迹 lesson43/2.code/testThread.c

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

void *threadRun(void *args)          // 签名必须是 void* f(void*)
{
    while (1) {
        printf("new thread is running, pid: %d\n", getpid());
        sleep(1);                    // 睡一下,让两条流交替打印,肉眼可见
    }
    return NULL;
}

int main()
{
    pthread_t tid;                                       // 装 TCB 地址
    pthread_create(&tid, NULL, threadRun, NULL);         // 造一个新 LWP

    while (1) {
        printf("main thread is running, pid: %d\n", getpid());
        sleep(1);
    }
}

  编译要链接线程库(老系统必需,因为 pthread 曾经是独立库):

复制代码
gcc testThread.c -o testThread -lpthread

  运行后可以看到两行打印交替出现,而 pid 完全相同 ------这正是第 5.2 节讲的:getpid() 返回 tgid,两条执行流同属一个线程组。

  课程里对这个现象的评语是:"两个线程里 getpid() 打印出来的是同一个进程 id------这是本阶段最重要的观察点。"现在可以看出,这句话背后是 tgidpid 的分工。

  顺手做一个进阶观察: 运行时另开终端执行 ps -aL | grep testThread,可以看到同一个 PID 下挂着两条 LWP 记录。再把 syscall(SYS_gettid) 打印出来,就能看出:主线程的 LWP 号 == getpid(),新线程的 LWP 号 != getpid()"LWP 号和 pid 号相等的那个是主线程"------第 5.2 节的结论,闭环了。

8.2 承上启下的收尾:两个被埋下的雷

  上篇到这里,整条"由大到小"的故事线走完了:

复制代码
操作系统(调度单位是执行流)
  └─ 进程(= 一个或多个 LWP + 共用的 mm_struct/页表/代码数据)
       └─ mm_struct(地址空间描述符,mm_users 记录几个人在用)★线程的题眼
            └─ 页表(pgd 指向的翻译表,共享同一份)
                 └─ 执行流(task_struct:状态/上下文/pid/tgid)
                      └─ Linux 复用 task_struct 造 LWP,不发明"线程"实体
                           └─ pthread 库补上线程语义:TCB、线程ID、独立栈
                                └─ clone + 一串 CLONE_* 标志位
                                     └─ pthread_create 的四个参数

  但是,这条线走完的同时,我们其实埋下了两颗雷。它们是后两篇的全部素材:

  第一颗雷:共享是双刃剑。 第 4.3 节那张"共享 / 私有"表里,"共享"那一栏有多长,出问题的面就有多广。全局变量被两个线程同时改会怎样?fd 表被两个线程同时用会怎样?这正是中篇 要处理的事故现场------我们会先亲手把程序跑出一个负数票号,再回头看那些"判断也是运算""++ 不是原子操作"的细节。

  第二颗雷:谁来收尸。 进程退出有 wait 收尸(课程第一阶段专门讲过 SIGCHLD 和僵尸进程)。那么线程退出呢?如果主线程不管它,会不会留下"僵尸线程"?答案是会------线程也有和僵尸进程一模一样的问题 ,解法叫 join

  而且,第二颗雷还牵出了更麻烦的一层:原生 pthread 太难用了。

  再看上面那段 testThread.c:函数指针、void* 强转、tid 要自己声明和保管、-lpthread 忘了加就报错、参数只能传一个 void*......这还只是一个什么都不干的线程。真实项目里主线程要管理几十上百个线程,全靠这套 C 风格接口裸写,代码会变成什么样?

  ------这就是中篇 要解决的事:把 pthread 装进一个 C++ 的盒子里,让它像对象一样被创建、启动、等待、终止;然后用这个盒子,去正面撞上第一颗雷。


九、本篇速查表

问题 答案
进程的操作性定义? 内核里的一组数据结构 + 这份程序自己的代码和数据
PCB 是哪个结构体? task_struct,描述一条执行流的全部档案
内核描述地址空间用哪个结构体? mm_struct(地址空间描述符)
线程的题眼在哪个字段? task_struct->mm;多个 task_struct 共享一个 mm_struct 就是线程
内核怎么表示页表? mm_struct->pgd(页全局目录基址),MMU 靠它做地址翻译
地址空间被几个人用,记在哪? mm_struct->mm_users(原子计数)
执行流必须私有的三样东西? CPU 上下文(寄存器/PC/SP)、栈、线程局部存储
执行流共享哪些资源? 地址空间、页表、fd 表(files_struct)、cwd(fs_struct)、信号处理表(sighand_struct
Linux 内核里有"线程"实体吗? 没有。CPU 视角看到的执行流叫轻量级进程 LWP
进程 = ? 一个或多个 LWP + 共用的 PCB/虚拟地址空间/页表/代码数据
我们以前学的"进程"本质是什么? 一个内部只有一个执行流的进程,即单执行流进程
pidtgid 分工? pid 区分执行流(线程各自的 LWP 号);tgid 标识线程组,组内全相同
为什么所有线程 getpid() 一样? 因为 getpid() 返回的是 tgid,不是 pid
怎么看主线程? LWP 号 == pid 号的那条就是主线程
看线程的命令? ps -aL(列出所有 LWP)、top -H/proc/<pid>/task/
线程的 TCB 在哪? pthread 库里 (glibc 的 struct pthreadnptl/descr.h),不在内核
TCB 里记什么? 栈地址与大小、返回值 result、分离状态、LWP 号、TLS 数组、清理函数栈
pthread_t 是什么? 不是序号,是 TCB 在进程虚拟空间里的起始地址
线程身上的两个 ID? pthread_t(库给的,TCB 地址,用 pthread_self() 取);tid/LWP 号(内核给的 task_struct->pid,用 syscall(SYS_gettid) 取)
线程的栈谁分配? pthread 库在创建时分配(默认约 8MB),可用 pthread_attr_setstacksize 调整
clonefork 的区别? cloneflags 点菜决定哪些资源共享;fork 相当于一个标志位都不点
哪些标志位拼出一个线程? CLONE_VMCLONE_FSCLONE_FILESCLONE_SIGHANDCLONE_THREADCLONE_SYSVSEMCLONE_SETTLS
线程代码的本质? clone 系统调用的封装(换成轻量级进程,省掉拷贝页表和虚拟空间的开销)
pthread_create 四个参数分别是什么? ①出参,存 TCB 地址 ②属性(NULL=默认)③入口函数,签名必须 void*(*)(void*) ④传给入口函数的参数
pthread_create 到底干了哪几件事? ①填 tid ②记录任务 ③分配独立栈 ④拼好 CLONE_* 标志位调 clone
一个线程崩溃会怎样? 整个进程挂掉。因为信号处理表共享,且致命信号发给整个线程组
kill 杀进程时线程会怎样? 全部陪葬。PCB、页表、代码数据全没了,依赖进程资源的线程自然全死
线程的"轻"代价是什么? 寄人篱下:不拥有地址空间,必须依赖进程活着
pthread_join 拿到的退出信息里有异常信号吗? 没有。线程出异常整个进程就没了,根本没机会 join 成功,只需关心正常退出
上一节课提的 __thread 是什么? 线程局部存储,同一变量名在每个线程各有一份独立内存;C++11 标准写法是 thread_local

十、下一篇预告

  本篇我们把镜头一路推到底,看清了线程在内核里的真身:一个和进程共享地址空间的 task_struct,一个被 pthread 库精心包装过的 LWP。

  但这个"包装"在 API 层面还是很粗糙。中篇要做的,是把这堆 C 风格的 pthread_xxx 收进一个 C++ 类里,让它用起来像对象:

  • 怎么让"线程入口函数"这个要求(必须是 void*(*)(void*))和一个 C++ 成员函数 握手?答案是 static 入口 + this 指针回传------这是所有线程库封装的标准套路。
  • 为什么"构造对象"和"启动线程"要拆成两件事?为什么 start() 之后立刻 detach() 会失败?这是一个真实的竞态,我们会看它怎么被一个"谁来写状态"的分工解决掉。
  • 线程要传参数怎么办?课程给了三个方案,前两个都是坑,第三个方案会顺手把"线程模块"和"任务模块"彻底解耦。
  • 以及,我们要亲手撞上第一颗雷:10000 张票,会抢成负数。然后从汇编层面看清楚那几个字节到底发生了什么,最后请出互斥锁,并用 RAII 把它也装进盒子。

  带着上篇那句"共享是线程的题眼",去中篇看看它惹的祸。


(本篇为线程三部曲之上篇。中篇《把线程装进盒子里》讲封装、传参与互斥;下篇《让线程排好队》讲同步、生产者消费者与线程池。)

相关推荐
琥珀色糖1 小时前
SimlpeHttp
linux·服务器
qeen871 小时前
【Linux】操作系统之进程介绍(二)
linux·笔记·学习·进程
Zenova EdgeOS2 小时前
Linux ss 工业边缘网络分析实战
linux·python·边缘计算·工业网关
第十人i2 小时前
Linux 日志管理系统:journald 配置与优化
linux·服务器
wuminyu2 小时前
HotSpot的轻量锁与膨胀后的ObjectMonitor状态重建原理
java·linux·c语言·jvm·c++
CCCCCCCCharlie3 小时前
Linux进程控制四大核心操作
linux·开发语言
nazisami3 小时前
操作系统进程状态
linux·进程状态
xbzb3 小时前
Linux 文件查找命令 locate 与 find 完全指南
linux·运维
ITxiaobing20233 小时前
IP 定位服务选型指南:从准确率到工程落地的技术考察
linux·服务器·网络