封面


文章目录


引言

刚开始学线程的时候,我其实一直把它理解得很简单:线程不就是“一个进程里面多条执行路线”吗?好像只要会用 pthread_create 创建线程、会用 pthread_join 等待线程结束,就算学会了。

但后来继续往底层看,我发现这个理解只能算入门。真正难的地方不是“线程是什么”这句话本身,而是:

线程为什么比进程轻量?
线程到底共享了什么?
线程自己的东西又在哪里?
为什么一个线程崩了,整个进程也可能一起崩?
pthread_t 和 Linux 里真正看到的 LWP 又是不是同一个东西?

这些问题一开始看起来很零散,但后来我慢慢发现,它们其实都绕不开一个核心:虚拟地址空间

以前我学习进程的时候,经常说“进程 = 内核数据结构 + 代码和数据”。但到了线程这里才发现,如果一个进程的地址空间已经把代码段、数据段、堆、栈、共享区这些资源组织好了,那么所谓多个线程,本质上就是在同一份进程资源里面划分出多条执行流。

💡可以把进程理解成一个宿舍,宿舍里面有桌子、柜子、路由器、电源这些公共资源;线程就像宿舍里的几个人,每个人都有自己的书包和学习进度,但很多东西都是一起用的。

所以这篇博客主要是我学习 Linux 线程概念与控制之后的一次系统整理。我会从虚拟地址空间、分页、页表开始,再过渡到进程和线程的关系,最后结合 pthread 接口、线程封装和底层 clone 思路,把线程这部分尽量串起来。


1. 从执行流开始理解 Linux 线程

1.1 线程到底是什么

线程最直观的理解是:

线程是一个程序内部的一条执行路线。

更准确一点说:

线程是一个进程内部的控制序列。

也就是说,进程里面至少有一条执行流,这条默认存在的执行流就是主线程。当一个进程里面只有一条执行流时,我们平时写的普通程序看起来就是“单进程单线程”的。

但是当一个进程内部出现多条执行流时,这个进程就变成了多线程进程。

这里我一开始容易想错的地方是:我以为线程是进程外面额外创建出来的东西。后来才意识到,线程一定是在进程内部运行的,它本质上是在同一个进程地址空间里执行代码。

也就是说:

  • 线程不是脱离进程单独存在的;
  • 线程运行时依赖进程的虚拟地址空间;
  • 一个进程至少有一个线程;
  • Linux 内核调度时看到的是一个个轻量级执行流;
  • 线程比传统进程更轻量,核心原因是很多资源不需要重新创建。

💡进程像一个正在运行的项目组,线程像项目组里的不同成员。成员可以同时干活,但他们共享同一个项目资料库、同一个办公空间,只是每个人手头正在做的任务不同。

进程与线程关系简图

1.2 仅仅说“线程是执行流”还不够

如果只是记住“线程是一个执行流”,其实还是停留在概念层面。

真正理解线程,我觉得至少要回答一个问题:

线程之间到底是怎么共享资源、又怎么拥有私有数据的?

这个问题最后会落到进程地址空间上。因为进程的大部分资源都可以通过虚拟地址空间看到,比如:

  • 代码段;
  • 数据段;
  • 堆;
  • 栈;
  • 共享区;
  • 文件映射区;
  • 动态库映射区域。

当这些资源被合理分配给不同执行流时,就形成了线程执行流。

这里我觉得最关键的一句话是:

只要把虚拟地址空间划分清楚,进程资源天然就被划分好了。

所以在线程之前,必须先把虚拟地址空间和分页机制理解清楚。


2. 为什么理解线程必须先理解虚拟地址空间

2.1 如果没有虚拟内存会发生什么

刚开始学虚拟地址空间的时候,我经常会有一个疑问:

既然程序最终都是在物理内存里运行,那为什么不直接访问物理内存呢?

后来发现,直接访问物理内存会带来很多问题。比如每个程序的代码和数据长度都不一样,如果要求每个程序在物理内存中必须连续存放,那么物理内存很快会被切成各种大小不一的碎片。

程序 A 占一块,程序 B 占一块,程序 C 退出以后中间空出来一块。下一次程序 D 想进来,如果它需要的连续空间比这个空洞大,那就算总剩余内存够,也可能放不下。

💡这有点像自习室占座。如果每个小组都必须连续坐在一起,那么中间零零散散空了很多座位,也不一定能容纳一个新来的大组。

所以操作系统希望做到一件事:

用户程序看到的地址空间是连续的,但实际物理内存不一定连续。

这就是虚拟内存和分页机制出现的重要原因。

2.2 虚拟地址、物理内存和页框

在分页机制下,物理内存会被切成固定大小的页框。常见情况下,一个页框大小是 4KB

这里要区分两个概念:

  • 页框:物理内存中的固定大小存储区域;
  • :虚拟内存中的固定大小数据块,可以映射到某个物理页框中,也可能暂时在磁盘中。

虚拟地址空间看起来是连续的,但是通过页表映射之后,可以对应到离散的物理页框。

也就是说:

连续的虚拟地址空间
        ↓ 页表映射
离散的物理内存页框

这样一来,程序以为自己拥有连续地址,但操作系统可以把它的内容分散放到物理内存不同位置。

虚拟地址到物页框映射关系

![建议插入图片:虚拟地址到物理页框映射关系]

【图片来源建议】
建议使用简约黑白线框图。左侧是连续虚拟地址空间,中间是页表,右侧是离散物理内存页框,用箭头表示映射关系。

【Images2 生成提示词】
生成一张简约的虚拟地址空间到物理内存映射示意图,白色背景,黑色和灰色线条。左侧画连续的虚拟地址空间,被分成若干页;中间画页表;右侧画物理内存页框,页框位置可以不连续。用箭头从虚拟页经过页表指向不同物理页框。文字标注尽量少,只保留“虚拟地址空间”“页表”“物理内存”。整体像课堂讲义中的黑白结构图。

【插入目的】
帮助理解虚拟空间连续、物理空间不连续这一点。

2.3 操作系统如何管理每一个物理页

有了分页以后,操作系统还需要管理每一个物理页。比如:

  • 哪些页正在使用;
  • 哪些页是空闲的;
  • 哪些页是脏页;
  • 哪些页被锁定;
  • 哪些页被映射了多少次。

Linux 内核中用 struct page 来描述系统中的每个物理页。这个结构体和“物理页”相关,而不是和“虚拟页”相关。

下面这段代码是理解物理页管理时非常重要的一段结构体代码,我在注释里把关键字段的作用补充了一下。

/* include/linux/mm_types.h */

/*
 * struct page 用来描述一个物理页。
 * 注意:它描述的是物理内存中的页框,而不是用户进程看到的虚拟页。
 */
struct page {
    /*
     * flags 用来保存页的各种状态。
     * 例如:页是否被锁定、是否是脏页、数据是否最新等。
     * 每一位可以表示一种状态,所以一个 flags 可以同时保存多个状态。
     */
    unsigned long flags;

    union {
        struct {
            /*
             * lru 用于把页挂到 LRU 链表上。
             * 操作系统进行页面回收时,会根据这些链表判断哪些页更适合被换出。
             */
            struct list_head lru;

            /*
             * mapping 表示这个页属于哪个 address_space。
             * 如果是文件映射页,可能会和 inode 的 address_space 有关。
             * 如果是匿名页,可能会和 anon_vma 有关。
             */
            struct address_space* mapping;

            /*
             * index 表示这个页在映射对象中的偏移。
             * 比如一个文件被映射到内存,这个字段可以表示当前页对应文件中的哪一部分。
             */
            pgoff_t index;

            /*
             * private 是映射私有数据。
             * 在不同场景下含义不同:
             * - PagePrivate:常用于 buffer_heads;
             * - PageSwapCache:可能用于交换分区相关信息;
             * - PG_buddy:可能用于伙伴系统中的阶。
             */
            unsigned long private;
        };

        struct {
            /*
             * slab/slob/slub 分配器相关字段。
             * 当物理页被用作小对象分配器管理时,这些字段会有对应含义。
             */
            union {
                struct list_head slab_list;

                struct {
                    struct page* next;
#ifdef CONFIG_64BIT
                    int pages;      /* 剩余页数量 */
                    int pobjects;   /* 近似对象数量 */
#else
                    short int pages;
                    short int pobjects;
#endif
                };
            };

            /*
             * 指向 slab cache。
             * 这说明同一个 struct page 在不同内存管理场景下会有不同解释。
             */
            struct kmem_cache* slab_cache;

            /*
             * freelist 指向第一个空闲对象。
             * 当该页被 slab/slub 用于对象分配时,这个字段很关键。
             */
            void* freelist;

            union {
                void* s_mem;              /* slab 中第一个对象 */
                unsigned long counters;   /* SLUB 计数器 */

                struct {
                    unsigned inuse : 16;   /* 已经使用的对象数量 */
                    unsigned objects : 15; /* 总对象数量 */
                    unsigned frozen : 1;   /* 是否冻结 */
                };
            };
        };

        /*
         * 这里省略的是内核中其他联合体布局。
         * struct page 大量使用 union 的原因是节省内存。
         * 因为系统中每个物理页都要有一个 struct page,如果字段过多,内存开销会变大。
         */
    };

    union {
        /*
         * _mapcount 表示有多少页表项映射到了这个物理页。
         * 可以理解为这个物理页被引用了多少次。
         */
        atomic_t _mapcount;

        unsigned int page_type;
        unsigned int active;
        int units;
    };

#if defined(WANT_PAGE_VIRTUAL)
    /*
     * virtual 表示该物理页在内核虚拟地址空间中的地址。
     * 对于高端内存,有些页不会永久映射到内核空间,此时可能为 NULL。
     */
    void* virtual;
#endif
};

2.3.1 flags 字段

flags 主要记录页状态。比如:

  • PG_locked:页是否被锁定;
  • PG_uptodate:页中的数据是否已经从块设备读取并且没有错误;
  • dirty 标志:页是否被修改过。

这里我觉得很有意思的一点是:操作系统不是只管理“进程”,它还要细到管理每一个物理页。

如果一个系统有 4GB 物理内存,页大小是 4KB,那么页框数量就是:

4GB / 4KB = 1048576

也就是大约一百万个物理页。

假设一个 struct page 大约占 40 字节,那么所有物理页的描述结构大约消耗:

1048576 * 40B ≈ 40MB

相对于 4GB 内存来说,这个管理成本是可以接受的。

💡这有点像图书馆给每一本书都贴一个编号标签。标签本身也占空间,但如果没有标签,管理员根本不知道哪本书在哪、有没有被借走。

2.4 页表:虚拟地址和物理地址之间的桥

页表中的每一个表项,指向一个物理页的起始地址。

在 32 位系统中,虚拟地址空间最大是 4GB。如果页大小是 4KB,那么一共需要:

4GB / 4KB = 1048576 个页表项

如果一个页表项占 4 字节,那么单级页表大小就是:

1048576 * 4B = 4MB

看起来 4MB 也不算很大,但问题在于:

单级页表本身也需要连续空间。

这就有点尴尬了。我们引入分页机制本来是为了避免连续物理内存分配,可页表自己却又需要一大块连续空间。

所以后来引入了多级页表。

2.5 多级页表和页目录

多级页表的思路是:

不要一次性把整个大页表都创建出来,而是把页表本身也拆开管理。

在二级页表中,会有:

  • 页目录表;
  • 页表;
  • 物理页。

其中:

  • 页目录项指向页表;
  • 页表项指向物理页;
  • CR3 寄存器保存当前任务页目录的起始地址。

二级页表地址转换流程

2.6 MMU 和 TLB

地址转换并不是 CPU 自己慢慢查表完成的,而是由硬件 MMU 参与完成。

MMU 的基本流程可以理解为:

  1. CPU 给出虚拟地址;
  2. MMU 根据页表进行地址转换;
  3. 检查权限;
  4. 得到物理地址;
  5. 访问物理内存。

但是多级页表会带来一个问题:页表级数越多,查询次数越多。为了解决这个效率问题,硬件中引入了 TLB。

TLB 可以理解成页表映射关系的缓存。

流程大概是:

CPU 给出虚拟地址
        ↓
MMU 先查 TLB
        ↓
命中:直接得到物理地址
        ↓
未命中:再查页表,并把结果缓存到 TLB

💡TLB 就像我去食堂吃饭时记住了常去窗口的位置。第一次要找地图,后面熟了就直接走过去,不用每次重新查。

2.7 缺页异常:申请内存到底是在干什么

这里我之前特别容易想错:调用 malloc 或者 new 之后,是不是物理内存马上就分配好了?

后来理解缺页异常之后,发现事情没那么简单。

当 CPU 给 MMU 一个虚拟地址,但 TLB 和页表里都找不到对应物理页时,就会产生缺页异常,也就是 Page Fault

缺页大致可以分为三类:Hard Page Fault、Soft Page Fault、Invalid Page Fault。

2.7.1 Hard Page Fault

硬缺页表示物理内存里没有对应的物理页,需要从磁盘加载数据到物理内存,再建立映射。

这种情况开销比较大。

2.7.2 Soft Page Fault

软缺页表示物理页其实已经在内存里了,只是当前进程还没有建立映射。

这种情况不需要从磁盘读取,只需要建立映射。

2.7.3 Invalid Page Fault

无效缺页一般表示访问了非法地址,比如:

  • 空指针解引用;
  • 越界访问到非法区域;
  • 权限不允许。

这种情况就可能导致 Segmentation fault

缺页异常处理流程图


3. Linux 线程的优点、缺点和异常

3.1 线程为什么比进程更轻量

线程比进程轻量,核心不是因为线程“代码少”,而是因为线程共享了很多进程资源。

创建新进程时,需要创建或复制更多资源,比如:

  • 地址空间;
  • 页表;
  • 文件描述符表;
  • 内核相关数据结构。

而创建线程时,同一进程内的线程共享地址空间,因此不需要重新准备一整套进程资源。

更关键的是上下文切换

进程切换时,虚拟地址空间通常会改变,页表也会改变,TLB 可能需要刷新。线程切换时,因为还在同一个进程地址空间中,虚拟地址空间不变,开销自然小很多。

💡进程切换像换一个教室上课,桌椅、黑板、多媒体都可能不同;线程切换像同一个教室里换一个同学上台做题,环境不用重新布置。

3.2 线程的优点

线程的优点主要有:

  • 创建线程比创建进程成本低;
  • 线程切换比进程切换成本低;
  • 线程占用资源少;
  • 多核 CPU 下可以并行执行;
  • 等待慢速 I/O 时,其他线程可以继续计算;
  • I/O 密集型程序可以通过多线程提高体验;
  • CPU 密集型程序可以把计算任务拆分到多个线程。

比如一边下载开发工具,一边继续写代码,这就是多线程思想在生活中的直观表现。

3.3 线程的缺点

线程也不是越多越好。

多线程的缺点主要有:

  • 线程过多会增加调度开销;
  • 多线程共享资源,容易出现数据竞争;
  • 一个线程出错可能影响整个进程;
  • 调试难度比单线程高;
  • 多线程代码需要考虑同步、互斥、死锁等问题。

这里我最大的感受是:线程提高了效率,但也降低了隔离性。

进程之间天然隔离,而线程之间是共享地址空间的,所以一个线程如果乱写内存,可能会把其他线程也一起拖下水。

3.4 单个线程崩溃为什么可能导致整个进程崩溃

线程是进程内部的执行分支。

如果某个线程出现:

  • 除零错误;
  • 野指针;
  • 非法地址访问;
  • 栈溢出;

那么它可能触发信号机制,导致整个进程终止。

原因是:线程不是独立的资源容器,它属于同一个进程。进程终止后,进程内部所有线程自然都会退出。


4. Linux 进程 VS 线程:哪些资源共享,哪些资源独占

4.1 进程和线程的核心区别

我觉得可以先记住两句话:

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

也就是说:

  • 进程更强调“资源”;
  • 线程更强调“执行”。

线程共享进程数据,但也有自己的一部分私有数据。

4.2 线程私有的数据

线程私有的数据主要包括:

  • 线程 ID;
  • 一组寄存器;
  • 线程上下文;
  • 栈;
  • errno
  • 信号屏蔽字;
  • 调度优先级。

其中寄存器非常关键。

因为每个线程都要有自己的执行现场。一个线程执行到哪里、函数调用到了哪一层、局部变量在哪里,都需要自己保存。

💡多线程像几个人共用一张实验桌,但每个人都有自己的草稿纸。实验器材是共享的,草稿纸上的推导过程是自己的。

4.3 线程共享的资源

同一进程内的多个线程共享同一地址空间,所以它们共享:

  • 代码段 Text Segment
  • 数据段 Data Segment
  • 堆;
  • 文件描述符表;
  • 信号处理方式;
  • 当前工作目录;
  • 用户 ID 和组 ID。

如果定义一个函数,多个线程都可以调用。
如果定义一个全局变量,多个线程都可以访问。

这也是多线程容易出问题的原因:共享带来了方便,也带来了竞争。

线程共享资源与私有资源对比图


5. POSIX 线程控制

5.1 POSIX 线程库

Linux 下使用线程,通常会用 POSIX 线程库,也就是 pthread 系列接口。

特点是:

  • 函数名大多以 pthread_ 开头;
  • 需要包含头文件 <pthread.h>
  • 编译链接时需要加 -lpthread-pthread

常见编译方式:

gcc mythread.c -o mythread -lpthread

或者:

gcc mythread.c -o mythread -pthread

5.2 创建线程:pthread_create

5.2.1 函数原型

#include <pthread.h>

/*
 * 功能:
 *   创建一个新的线程。
 *
 * 参数:
 *   thread:
 *     输出型参数,用来保存 pthread 库层面的线程 ID。
 *
 *   attr:
 *     线程属性。如果传 NULL,表示使用默认属性。
 *
 *   start_routine:
 *     函数指针,表示线程启动后要执行的函数。
 *     该函数参数类型必须是 void*,返回值也必须是 void*。
 *
 *   arg:
 *     传给线程入口函数 start_routine 的参数。
 *
 * 返回值:
 *   成功返回 0;
 *   失败返回错误码。
 *
 * 注意:
 *   pthread 系列函数失败时通常不会通过全局 errno 返回错误,
 *   而是直接把错误码作为返回值返回。
 */
int pthread_create(
    pthread_t *thread,
    const pthread_attr_t *attr,
    void *(*start_routine)(void*),
    void *arg
);

这里最容易注意的地方是 start_routine

它是一个函数指针,类型是:

void *(*start_routine)(void*)

意思是:

  • 参数是 void*
  • 返回值也是 void*
  • 新线程启动后会执行这个函数。

💡可以把 start_routine 理解成给新同学安排的第一项任务。线程创建出来以后,不是随便乱跑,而是从这个函数开始执行。

5.2.2 创建线程示例

#include <unistd.h>     // sleep 函数
#include <stdlib.h>     // exit、EXIT_FAILURE
#include <stdio.h>      // printf、fprintf
#include <string.h>     // strerror
#include <pthread.h>    // pthread_create、pthread_t

/*
 * 线程入口函数。
 *
 * 参数:
 *   arg 是 pthread_create 的第四个参数传进来的。
 *   当前例子没有使用参数,所以可以传 NULL。
 *
 * 返回值:
 *   返回 void*,这里线程一直循环执行,所以不会正常 return。
 */
void *rout(void *arg)
{
    int i;

    /*
     * 子线程不断打印信息。
     * 这里的无限循环是为了观察主线程和子线程同时运行的现象。
     */
    for (;;) {
        printf("I'am thread 1\n");
        sleep(1);
    }

    return NULL;
}

int main(void)
{
    /*
     * pthread_t 是 pthread 库层面的线程 ID。
     * 注意:它不一定等于内核中的 LWP。
     */
    pthread_t tid;

    /*
     * ret 用来保存 pthread_create 的返回值。
     * pthread_create 成功返回 0,失败返回错误码。
     */
    int ret;

    /*
     * 创建一个新线程:
     *   &tid:保存新线程 ID;
     *   NULL:使用默认线程属性;
     *   rout:新线程执行的入口函数;
     *   NULL:传给 rout 的参数。
     */
    if ((ret = pthread_create(&tid, NULL, rout, NULL)) != 0) {
        /*
         * pthread_create 失败时,ret 本身就是错误码。
         * strerror(ret) 可以把错误码转成可读字符串。
         */
        fprintf(stderr, "pthread_create : %s\n", strerror(ret));
        exit(EXIT_FAILURE);
    }

    int i;

    /*
     * 主线程也不断打印信息。
     * 如果程序运行后两种输出交替出现,就说明主线程和子线程都在运行。
     */
    for (;;) {
        printf("I'am main thread\n");
        sleep(1);
    }

    return 0;
}

5.2.3 代码理解

这段代码验证了一个现象:

创建线程之后,主线程和新线程会在同一个进程中并发执行。

主线程执行 main 中的循环,新线程执行 rout 中的循环。

这里不是 main 调用了 rout,而是 pthread_create 告诉线程库和内核:新建一个执行流,让它从 rout 函数开始运行。

这个区别很重要。

如果直接写:

rout(NULL);

那只是普通函数调用,并没有新线程。

pthread_create(&tid, NULL, rout, NULL) 创建的是新的执行流。

5.3 获取线程 ID:pthread_self

5.3.1 函数原型

#include <pthread.h>

/*
 * 功能:
 *   获取当前调用线程自己的 pthread_t ID。
 *
 * 参数:
 *   无。
 *
 * 返回值:
 *   返回当前线程的 pthread_t。
 *
 * 注意:
 *   这是 pthread 库维护的线程 ID,
 *   在 Linux NPTL 实现中,它本质上可能是进程地址空间中的一个地址。
 */
pthread_t pthread_self(void);

pthread_self 返回的是 pthread 库层面的线程 ID。

这个 ID 的作用范围主要在当前进程内部。也就是说,它是线程库为了管理线程而维护的标识。

5.4 用 ps -aL 查看线程

运行多线程程序后,可以使用:

ps -aL | head -1 && ps -aL | grep mythread

可能看到类似结果:

PID       LWP       TTY        TIME       CMD
2711838   2711838   pts/235    00:00:00   mythread
2711838   2711839   pts/235    00:00:00   mythread

这里:

  • PID 是进程 ID;
  • LWP 是轻量级进程 ID,也可以理解为内核看到的线程 ID;
  • CMD 是命令名。

如果一个进程里有两个线程,就可能看到同一个 PID 对应多个 LWP

其中有一个 LWPPID 相同,它通常就是主线程。

pthread_t 与 LWP 的关系图


6. 线程终止、等待和分离

6.1 线程终止的三种方式

如果只想终止某个线程,而不是终止整个进程,一般有三种方式:

  1. 在线程函数中 return
  2. 线程调用 pthread_exit 终止自己;
  3. 其他线程调用 pthread_cancel 取消目标线程。

需要注意:

  • 主线程从 main 函数 return,相当于调用 exit,会导致整个进程结束;
  • 子线程从入口函数 return,只是当前线程结束;
  • pthread_exit 可以显式终止当前线程;
  • pthread_cancel 可以请求取消另一个线程。

6.2 pthread_exit

6.2.1 函数原型

#include <pthread.h>

/*
 * 功能:
 *   终止当前线程。
 *
 * 参数:
 *   value_ptr:
 *     当前线程的退出返回值。
 *     其他线程可以通过 pthread_join 拿到这个值。
 *
 * 返回值:
 *   无返回值。
 *
 * 注意:
 *   value_ptr 不要指向当前线程栈上的局部变量。
 *   因为线程退出后,线程栈上的局部变量可能已经失效。
 */
void pthread_exit(void *value_ptr);

这里最容易踩坑的是:

不要返回局部变量地址。

错误示例:

void *thread_run(void *arg)
{
    int x = 10;
    return &x;  // 错误:x 是局部变量,线程退出后这个地址不可靠
}

正确做法一般是:

  • 返回全局变量地址;
  • 或者使用 malloc 在堆上申请空间;
  • 或者不关心返回值,直接返回 NULL

6.3 pthread_cancel

6.3.1 函数原型

#include <pthread.h>

/*
 * 功能:
 *   请求取消一个正在执行的线程。
 *
 * 参数:
 *   thread:
 *     要取消的目标线程 ID。
 *
 * 返回值:
 *   成功返回 0;
 *   失败返回错误码。
 */
int pthread_cancel(pthread_t thread);

pthread_cancel 不是直接粗暴地“杀死线程”,更准确说是向目标线程发出取消请求。

目标线程是否立刻退出,还和取消状态、取消类型、取消点有关。对于初学阶段,可以先理解成:用它可以请求终止同一进程中的另一个线程。

6.4 线程等待:pthread_join

6.4.1 为什么需要线程等待

线程退出后,如果它是 joinable 状态,那么它的相关资源不会立刻完全释放。

所以需要其他线程调用 pthread_join 来等待它结束,并回收它的退出信息。

否则可能造成类似“资源泄漏”的问题。

💡joinable 线程就像一个同学做完实验后,实验台还没收拾。人已经走了,但桌上的记录、工具还在,需要另一个人来确认并收拾。

6.4.2 函数原型

#include <pthread.h>

/*
 * 功能:
 *   等待指定线程结束,并获取线程返回值。
 *
 * 参数:
 *   thread:
 *     要等待的线程 ID。
 *
 *   value_ptr:
 *     输出型参数。
 *     它指向一个 void* 指针,用来接收线程的退出返回值。
 *     如果不关心线程返回值,可以传 NULL。
 *
 * 返回值:
 *   成功返回 0;
 *   失败返回错误码。
 */
int pthread_join(pthread_t thread, void **value_ptr);

线程结束方式不同,pthread_join 拿到的结果也不同:

  • 如果线程函数 return,得到的是 return 的值;
  • 如果线程调用 pthread_exit,得到的是 pthread_exit 的参数;
  • 如果线程被 pthread_cancel 取消,得到的是 PTHREAD_CANCELED
  • 如果不关心返回值,可以传 NULL

6.5 线程终止与等待示例

#include <stdio.h>      // printf
#include <stdlib.h>     // malloc、free
#include <string.h>     // 字符串相关函数
#include <unistd.h>     // sleep
#include <pthread.h>    // pthread_create、pthread_join、pthread_exit、pthread_cancel

/*
 * thread1:
 *   通过 return 结束线程。
 *   返回值是堆上申请的 int*,这样线程退出后仍然有效。
 */
void *thread1(void *arg)
{
    printf("thread 1 returning ... \n");

    /*
     * 这里必须用 malloc。
     * 如果使用局部变量 int x,然后 return &x,
     * 在线程退出后 x 的生命周期结束,其他线程拿到的是无效地址。
     */
    int *p = (int*)malloc(sizeof(int));
    *p = 1;

    /*
     * return 的值可以被 pthread_join 获取。
     */
    return (void*)p;
}

/*
 * thread2:
 *   通过 pthread_exit 结束线程。
 */
void *thread2(void *arg)
{
    printf("thread 2 exiting ...\n");

    /*
     * 同样使用堆空间保存返回值。
     */
    int *p = (int*)malloc(sizeof(int));
    *p = 2;

    /*
     * pthread_exit 的参数也可以被 pthread_join 获取。
     */
    pthread_exit((void*)p);
}

/*
 * thread3:
 *   一直运行,后面由主线程调用 pthread_cancel 取消。
 */
void *thread3(void *arg)
{
    while (1) {
        printf("thread 3 is running ...\n");
        sleep(1);
    }

    return NULL;
}

int main(void)
{
    pthread_t tid;
    void *ret;

    /*
     * 第一种情况:
     *   线程函数通过 return 返回。
     */
    pthread_create(&tid, NULL, thread1, NULL);
    pthread_join(tid, &ret);
    printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
    free(ret);

    /*
     * 第二种情况:
     *   线程函数通过 pthread_exit 退出。
     */
    pthread_create(&tid, NULL, thread2, NULL);
    pthread_join(tid, &ret);
    printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
    free(ret);

    /*
     * 第三种情况:
     *   线程被其他线程取消。
     */
    pthread_create(&tid, NULL, thread3, NULL);

    /*
     * 让 thread3 先运行 3 秒。
     */
    sleep(3);

    /*
     * 请求取消 thread3。
     */
    pthread_cancel(tid);

    /*
     * 等待 thread3 退出,并获取退出状态。
     */
    pthread_join(tid, &ret);

    if (ret == PTHREAD_CANCELED) {
        printf("thread return, thread id %X, return code:PTHREAD_CANCELED\n", tid);
    } else {
        printf("thread return, thread id %X, return code:NULL\n", tid);
    }

    return 0;
}

运行结果类似:

[root@localhost linux]# ./a.out
thread 1 returning ...
thread return, thread id 5AA79700, return code:1
thread 2 exiting ...
thread return, thread id 5AA79700, return code:2
thread 3 is running ...
thread 3 is running ...
thread 3 is running ...
thread return, thread id 5AA79700, return code:PTHREAD_CANCELED

6.5.1 代码理解

这段代码把线程退出的三种情况放在一起对比:

  • thread1:通过 return 返回;
  • thread2:通过 pthread_exit 返回;
  • thread3:通过 pthread_cancel 被取消。

最重要的细节是:

pthread_join 不只是“等待”,它还承担了“回收线程退出信息”的作用。

💡pthread_join 有点像等舍友做完实验后,把实验台收拾干净。如果不等、不收拾,东西可能一直占着位置。

6.6 分离线程:pthread_detach

6.6.1 为什么需要线程分离

默认情况下,新创建的线程是 joinable 的。

这意味着线程退出后,需要其他线程调用 pthread_join 回收资源。

但有些线程我并不关心它的返回值,比如后台日志线程、定时清理线程。这时如果还必须 join,就会很麻烦。

所以可以让线程进入分离状态。分离线程退出后,系统会自动释放相关资源。

6.6.2 函数原型

#include <pthread.h>

/*
 * 功能:
 *   将指定线程设置为分离状态。
 *
 * 参数:
 *   thread:
 *     要分离的线程 ID。
 *
 * 返回值:
 *   成功返回 0;
 *   失败返回错误码。
 *
 * 注意:
 *   joinable 和 detached 是冲突的。
 *   一个线程不能既被 join,又被 detach。
 */
int pthread_detach(pthread_t thread);

线程可以分离其他线程,也可以分离自己:

pthread_detach(pthread_self());

6.7 分离线程示例

#include <stdio.h>      // printf
#include <stdlib.h>     // 标准库函数
#include <string.h>     // 字符串相关函数
#include <unistd.h>     // sleep
#include <pthread.h>    // pthread_create、pthread_detach、pthread_join、pthread_self

/*
 * 子线程入口函数。
 */
void *thread_run(void *arg)
{
    /*
     * 线程自己把自己设置为分离状态。
     * 设置之后,线程退出时资源会自动释放。
     */
    pthread_detach(pthread_self());

    /*
     * arg 是 pthread_create 第四个参数传进来的。
     */
    printf("%s\n", (char*)arg);

    return NULL;
}

int main(void)
{
    pthread_t tid;

    /*
     * 创建子线程。
     */
    if (pthread_create(&tid, NULL, thread_run, "thread1 run...") != 0) {
        printf("create thread error\n");
        return 1;
    }

    int ret = 0;

    /*
     * 这里 sleep 很重要:
     * 让子线程先运行,并执行 pthread_detach。
     */
    sleep(1);

    /*
     * 子线程已经分离,此时再 pthread_join 会失败。
     */
    if (pthread_join(tid, NULL) == 0) {
        printf("pthread wait success\n");
        ret = 0;
    } else {
        printf("pthread wait failed\n");
        ret = 1;
    }

    return ret;
}

6.7.1 代码理解

这个例子说明:

分离线程不能再被 join。

如果一个线程已经处于 detached 状态,系统会在线程退出后自动回收资源,其他线程就不应该再尝试 pthread_join 它。


7. 线程 ID、LWP 与地址空间布局

7.1 pthread_t 不是内核线程 ID

这里是我一开始最容易混的地方。

pthread_create 产生的线程 ID 和 ps -aL 看到的 LWP 不是一个层面的东西。

pthread_t 属于线程库层面。
LWP 属于内核调度层面。

在 Linux NPTL 实现中,pthread_t 本质上可以理解成进程地址空间中的一个地址,这个地址可以找到线程库维护的线程控制信息,比如:

  • 线程 ID;
  • 线程栈;
  • 寄存器相关信息;
  • 线程属性;
  • 返回值;
  • 分离状态。

LWP 是内核真正调度时看到的轻量级执行流 ID。

7.2 主线程和子线程栈的位置

ps -aL 中,如果一个线程的 LWPPID 相同,一般可以理解为主线程。

主线程的栈位于进程虚拟地址空间中的栈区域。

其他线程的栈通常位于共享区附近,因为线程库需要为它们单独准备栈空间。

这就解释了为什么线程既共享地址空间,又拥有自己的栈。

多线程地址空间布局图


8. 用 C++ 封装一个简单线程类

8.1 第一版:不考虑复杂参数传递

学习完 pthread_create 后,我觉得一个很好的练习是:把线程封装成一个 C++ 类。

这样做不是为了重复造轮子,而是为了理解:

  • 线程对象应该保存哪些信息;
  • pthread_create 的入口函数为什么必须是静态函数;
  • 如何把 this 指针传给线程入口;
  • 如何在线程类中支持 join 和 detach。

8.2 Thread.hpp

// Thread.hpp
#pragma once

#include <iostream>     // std::cout、std::endl
#include <string>       // std::string
#include <functional>   // std::function
#include <pthread.h>    // pthread_create、pthread_join、pthread_detach

namespace ThreadModule
{
    /*
     * 计数器,用来生成线程名称。
     * 注意:
     *   这里为了理解封装思路,暂时没有加锁。
     *   如果多个线程同时创建 Thread 对象,cnt++ 可能存在线程安全问题。
     */
    std::uint32_t cnt = 0;

    /*
     * 线程要执行的函数类型。
     * 这里不考虑参数传递,统一封装成 void()。
     */
    using threadfunc_t = std::function<void()>;

    /*
     * 线程状态。
     */
    enum class TSTATUS
    {
        THREAD_NEW,      // 新建状态,还没有启动
        THREAD_RUNNING,  // 正在运行
        THREAD_STOP      // 已经停止
    };

    class Thread
    {
    private:
        /*
         * pthread_create 要求线程入口函数类型是:
         *   void* (*)(void*)
         *
         * 普通成员函数会隐含 this 指针,类型不匹配。
         * 所以这里必须使用 static 静态成员函数作为线程入口。
         */
        static void *run(void *obj)
        {
            /*
             * obj 是 pthread_create 第四个参数传进来的 this 指针。
             * 这里把 void* 转回 Thread*,就可以访问当前对象。
             */
            Thread *self = static_cast<Thread *>(obj);

            /*
             * 设置线程名称,方便 ps -aL 或调试时观察。
             * pthread_setname_np 是非标准接口,_np 表示 non-portable。
             */
            pthread_setname_np(pthread_self(), self->_name.c_str());

            /*
             * 线程真正开始运行。
             */
            self->_status = TSTATUS::THREAD_RUNNING;

            /*
             * 如果设置为非 join 模式,就在线程内部 detach 自己。
             */
            if (!self->_joined)
            {
                pthread_detach(pthread_self());
            }

            /*
             * 执行用户传入的函数。
             */
            self->_func();

            return nullptr;
        }

        /*
         * 设置线程名称。
         */
        void SetName()
        {
            /*
             * 注意:
             *   这里 cnt++ 在多线程环境下不是线程安全的。
             *   后续如果追求严格正确,需要加锁或使用原子变量。
             */
            _name = "Thread-" + std::to_string(cnt++);
        }

    public:
        /*
         * 构造函数:
         *   默认线程处于 THREAD_NEW;
         *   默认线程是 joinable;
         *   保存用户传入的函数。
         */
        Thread(threadfunc_t func)
            : _status(TSTATUS::THREAD_NEW),
              _joined(true),
              _func(func)
        {
            SetName();
        }

        /*
         * 开启分离模式。
         * 只有在线程还没启动时设置才合理。
         */
        void EnableDetach()
        {
            if (_status == TSTATUS::THREAD_NEW)
                _joined = false;
        }

        /*
         * 开启 join 模式。
         */
        void EnableJoined()
        {
            if (_status == TSTATUS::THREAD_NEW)
                _joined = true;
        }

        /*
         * 启动线程。
         */
        bool Start()
        {
            if (_status == TSTATUS::THREAD_RUNNING)
                return true;

            /*
             * 创建线程:
             *   &_id:保存 pthread_t;
             *   nullptr:默认属性;
             *   run:静态入口函数;
             *   this:把当前对象传给线程入口。
             */
            int n = ::pthread_create(&_id, nullptr, run, this);
            if (n != 0)
                return false;

            return true;
        }

        /*
         * 等待线程结束。
         */
        bool Join()
        {
            if (_joined)
            {
                int n = pthread_join(_id, nullptr);
                if (n != 0)
                    return false;

                return true;
            }

            return false;
        }

        ~Thread() {}

    private:
        std::string _name;     // 线程名称
        pthread_t _id;         // pthread 库层面的线程 ID
        TSTATUS _status;       // 线程状态
        bool _joined;          // 是否需要 join
        threadfunc_t _func;    // 线程执行函数
    };
}

8.3 测试代码

// main.cc
#include <iostream>     // std::cout
#include <unistd.h>     // sleep
#include <pthread.h>    // pthread_self、pthread_setname_np、pthread_getname_np
#include <functional>   // std::bind
#include "Thread.hpp"

void hello1()
{
    char buffer[64];

    /*
     * 获取当前线程名称。
     * sizeof(buffer) - 1 是为了预留字符串结尾的 '\0'。
     */
    pthread_getname_np(pthread_self(), buffer, sizeof(buffer) - 1);

    while (true)
    {
        std::cout << "hello world, " << buffer << std::endl;
        sleep(1);
    }
}

void hello2()
{
    char buffer[64];

    /*
     * 获取当前线程名称。
     */
    pthread_getname_np(pthread_self(), buffer, sizeof(buffer) - 1);

    while (true)
    {
        std::cout << "hello world, " << buffer << std::endl;
        sleep(1);
    }
}

int main()
{
    /*
     * 设置主线程名称。
     */
    pthread_setname_np(pthread_self(), "main");

    /*
     * 创建线程对象 t1,执行 hello1。
     */
    ThreadModule::Thread t1(hello1);
    t1.Start();

    /*
     * 创建线程对象 t2。
     * 这里使用 std::bind,是为了演示函数对象也可以传入。
     */
    ThreadModule::Thread t2(std::bind(&hello2));
    t2.Start();

    /*
     * 等待两个线程结束。
     * 由于 hello1 和 hello2 都是死循环,所以这里一般不会返回。
     */
    t1.Join();
    t2.Join();

    return 0;
}

运行时可以用:

ps -aL

观察结果可能类似:

PID       LWP       TTY      TIME       CMD
195828    195828    pts/1    00:00:00   main
195828    195829    pts/1    00:00:00   Thread-0
195828    195830    pts/1    00:00:00   Thread-1

8.4 关于 pthread_setname_nppthread_getname_np

这两个函数用于设置和获取线程名称。

注意:

  • _np 表示 non-portable,即非可移植;
  • Linux 上线程名称最大长度通常是 16 字符,包括结尾的 \0
  • 线程名称主要用于调试和观察;
  • 名字太长可能会被截断。

8.5 模板版本线程封装

这个版本支持传递一个参数。

#include <iostream>     // std::cout
#include <string>       // std::string
#include <functional>   // std::function
#include <pthread.h>    // pthread_create、pthread_join、pthread_cancel、pthread_detach
#include <unistd.h>     // getpid

namespace ThreadModule
{
    /*
     * 静态编号,用来生成线程名。
     */
    static int number = 1;

    /*
     * 线程状态枚举。
     */
    enum class TSTATUS
    {
        NEW,
        RUNNING,
        STOP
    };

    /*
     * 模板线程类。
     * T 表示线程函数接收的数据类型。
     */
    template <typename T>
    class Thread
    {
        /*
         * func_t 表示线程要执行的函数类型。
         * 该函数接收一个 T 类型参数,返回 void。
         */
        using func_t = std::function<void(T)>;

    private:
        /*
         * pthread_create 的入口函数必须是静态函数。
         */
        static void *Routine(void *args)
        {
            /*
             * args 是 this 指针。
             */
            Thread<T> *t = static_cast<Thread<T> *>(args);

            /*
             * 修改线程状态。
             */
            t->_status = TSTATUS::RUNNING;

            /*
             * 执行用户传入的函数,并传入保存的数据。
             */
            t->_func(t->_data);

            return nullptr;
        }

        /*
         * 设置为分离状态。
         */
        void EnableDetach()
        {
            _joinable = false;
        }

    public:
        /*
         * 构造函数:
         *   func:线程执行函数;
         *   data:传给线程函数的数据。
         */
        Thread(func_t func, T data)
            : _func(func),
              _data(data),
              _status(TSTATUS::NEW),
              _joinable(true)
        {
            _name = "Thread-" + std::to_string(number++);
            _pid = getpid();
        }

        /*
         * 启动线程。
         */
        bool Start()
        {
            if (_status != TSTATUS::RUNNING)
            {
                /*
                 * 创建线程,入口函数是 Routine,参数是 this。
                 */
                int n = ::pthread_create(&_tid, nullptr, Routine, this);
                if (n != 0)
                    return false;

                return true;
            }

            return false;
        }

        /*
         * 停止线程。
         * 这里使用 pthread_cancel 请求取消线程。
         */
        bool Stop()
        {
            if (_status == TSTATUS::RUNNING)
            {
                int n = ::pthread_cancel(_tid);
                if (n != 0)
                    return false;

                _status = TSTATUS::STOP;
                return true;
            }

            return false;
        }

        /*
         * 等待线程退出。
         */
        bool Join()
        {
            if (_joinable)
            {
                int n = ::pthread_join(_tid, nullptr);
                if (n != 0)
                    return false;

                _status = TSTATUS::STOP;
                return true;
            }

            return false;
        }

        /*
         * 分离线程。
         */
        void Detach()
        {
            EnableDetach();
            pthread_detach(_tid);
        }

        /*
         * 判断是否 joinable。
         */
        bool IsJoinable()
        {
            return _joinable;
        }

        /*
         * 获取线程名称。
         */
        std::string Name()
        {
            return _name;
        }

        ~Thread()
        {
        }

    private:
        std::string _name; // 线程名称
        pthread_t _tid;    // pthread 线程 ID
        pid_t _pid;        // 所属进程 ID
        bool _joinable;    // 是否可以被 join
        func_t _func;      // 线程执行函数
        TSTATUS _status;   // 线程状态
        T _data;           // 线程数据
    };
}

8.5.1 封装理解

这里最关键的设计是:

pthread_create(&_tid, nullptr, Routine, this);

因为 Routine 是静态函数,可以满足 pthread_create 的函数指针类型要求。

this 被作为参数传进去后,在线程入口函数里再强转回 Thread<T>*,这样就能访问对象中的成员变量。

💡这就像把“对象自己的身份证”传给新线程。线程入口函数虽然是公共入口,但拿到 this 之后,就知道自己应该执行哪个对象里的任务。


9. 从 NPTL 思路理解 pthread_create 背后发生了什么

9.1 pthread_create 不只是一个普通函数

学到这里,我才发现 pthread_create 背后其实做了很多事。

从使用者角度看,我们只是写:

pthread_create(&tid, NULL, routine, arg);

但线程库内部大致要做:

  1. 读取线程属性;
  2. 分配线程栈;
  3. 创建线程控制块;
  4. 保存入口函数和参数;
  5. 设置线程状态;
  6. 调用底层 clone 创建轻量级执行流;
  7. 把线程控制块地址返回给上层作为 pthread_t

所以 pthread_t 为什么可能是一个地址?因为线程库内部会创建一个描述线程的对象,然后把这个对象地址作为线程库层面的 ID 返回给用户。

9.2 pthread_create 核心源码理解

下面是一个经过注释整理的核心流程。

/*
 * pthread_create 底层核心流程示意。
 * 这里重点理解:
 *   1. pthread 库先创建线程描述对象;
 *   2. 再申请线程栈;
 *   3. 把用户传入的函数和参数保存进线程控制块;
 *   4. 最后调用 create_thread,进一步走向 clone。
 */
int __pthread_create_2_1(
    pthread_t *newthread,
    const pthread_attr_t *attr,
    void *(*start_routine)(void *),
    void *arg
)
{
    STACK_VARIABLES;

    /*
     * 线程属性。
     * 如果用户没有传 attr,就使用默认属性。
     */
    const struct pthread_attr *iattr = (struct pthread_attr *)attr;
    if (iattr == NULL)
        iattr = &default_attr;

    /*
     * pd 可以理解成 pthread 库内部描述线程的 TCB。
     */
    struct pthread *pd = NULL;

    /*
     * 申请线程栈,并准备 struct pthread 对象。
     * 这里的 pd 会指向线程控制块。
     */
    int err = ALLOCATE_STACK(iattr, &pd);
    if (__builtin_expect(err != 0, 0))
        return err;

#ifdef TLS_TCB_AT_TP
    /*
     * 设置 TCB 自引用。
     */
    pd->header.self = pd;
    pd->header.tcb = pd;
#endif

    /*
     * 保存线程入口函数和参数。
     * 注意:
     *   新线程并不是立即直接调用 start_routine,
     *   而是先把它保存到线程描述结构里,
     *   之后新线程启动时再取出来执行。
     */
    pd->start_routine = start_routine;
    pd->arg = arg;

    /*
     * 复制线程属性标志。
     */
    struct pthread *self = THREAD_SELF;
    pd->flags =
        ((iattr->flags & ~(ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET))
        | (self->flags & (ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET)));

    /*
     * joinid 用来判断线程是否分离。
     * 如果线程是 detached,则 joinid 指向自己。
     */
    pd->joinid = iattr->flags & ATTR_FLAG_DETACHSTATE ? pd : NULL;

    /*
     * 继承调试事件、调度策略和调度参数。
     */
    pd->eventbuf = self->eventbuf;
    pd->schedpolicy = self->schedpolicy;
    pd->schedparam = self->schedparam;

#ifdef THREAD_COPY_STACK_GUARD
    THREAD_COPY_STACK_GUARD(pd);
#endif

#ifdef THREAD_COPY_POINTER_GUARD
    THREAD_COPY_POINTER_GUARD(pd);
#endif

    /*
     * 如果用户设置了调度属性,需要检查调度策略和优先级是否合法。
     */
    if (attr != NULL &&
        __builtin_expect((iattr->flags & ATTR_FLAG_NOTINHERITSCHED) != 0, 0) &&
        (iattr->flags & (ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET)) != 0)
    {
        INTERNAL_SYSCALL_DECL(scerr);

        if (iattr->flags & ATTR_FLAG_POLICY_SET)
            pd->schedpolicy = iattr->schedpolicy;
        else if ((pd->flags & ATTR_FLAG_POLICY_SET) == 0)
        {
            pd->schedpolicy = INTERNAL_SYSCALL(sched_getscheduler, scerr, 1, 0);
            pd->flags |= ATTR_FLAG_POLICY_SET;
        }

        if (iattr->flags & ATTR_FLAG_SCHED_SET)
            memcpy(&pd->schedparam, &iattr->schedparam,
                   sizeof(struct sched_param));
        else if ((pd->flags & ATTR_FLAG_SCHED_SET) == 0)
        {
            INTERNAL_SYSCALL(sched_getparam, scerr, 2, 0, &pd->schedparam);
            pd->flags |= ATTR_FLAG_SCHED_SET;
        }

        /*
         * 检查优先级范围。
         */
        int minprio = INTERNAL_SYSCALL(sched_get_priority_min, scerr, 1,
                                       iattr->schedpolicy);
        int maxprio = INTERNAL_SYSCALL(sched_get_priority_max, scerr, 1,
                                       iattr->schedpolicy);

        if (pd->schedparam.sched_priority < minprio ||
            pd->schedparam.sched_priority > maxprio)
        {
            err = EINVAL;
            goto errout;
        }
    }

    /*
     * 关键点:
     *   把 pd 转成 pthread_t 返回给用户。
     *   所以上层拿到的 pthread_t,本质上可能就是线程控制块地址。
     */
    *newthread = (pthread_t)pd;

    /*
     * 判断线程是否是分离状态。
     */
    bool is_detached = IS_DETACHED(pd);

    /*
     * 真正创建线程。
     */
    err = create_thread(pd, iattr, STACK_VARIABLES_ARGS);
    if (err != 0)
    {
        if (!is_detached)
        {
        errout:
            __deallocate_stack(pd);
        }

        return err;
    }

    return 0;
}

9.2.1 代码理解

这段代码最关键的地方是:

*newthread = (pthread_t)pd;

也就是说,用户拿到的 pthread_t 并不是内核中的 LWP,而是线程库内部线程描述对象的地址。

这也解释了前面的问题:

  • pthread_self() 拿到的是 pthread 库 ID;
  • ps -aL 看到的是内核 LWP;
  • 二者不是一个层面的东西。

9.3 线程属性结构

/*
 * pthread_attr 表示线程属性。
 * 线程属性可以控制调度策略、栈大小、分离状态、CPU 亲和性等。
 */
struct pthread_attr
{
    /*
     * 调度参数,比如优先级。
     */
    struct sched_param schedparam;

    /*
     * 调度策略。
     */
    int schedpolicy;

    /*
     * 各种标志位,比如是否分离、调度策略是否显式设置等。
     */
    int flags;

    /*
     * 栈保护区大小。
     * 用于防止栈溢出破坏其他内存。
     */
    size_t guardsize;

    /*
     * 用户指定的栈地址和栈大小。
     * 如果用户不指定,就由线程库自己申请。
     */
    void *stackaddr;
    size_t stacksize;

    /*
     * CPU 亲和性相关设置。
     */
    cpu_set_t *cpuset;
    size_t cpusetsize;
};

9.4 线程控制块中几个重要字段

线程控制块很大,但我觉得理解线程时重点关注这些字段就够了:

/*
 * struct pthread 是线程库内部描述线程的结构。
 * 它保存了线程 ID、进程 ID、线程返回值、线程入口函数、线程栈等信息。
 */
struct pthread
{
    /*
     * tid 是内核层面的线程 ID。
     */
    pid_t tid;

    /*
     * pid 是线程所属进程的 ID,也可以理解成线程组 ID。
     */
    pid_t pid;

    /*
     * joinid 用来描述 join/detach 关系。
     * 如果线程是 detached,joinid 可能指向自己。
     */
    struct pthread *joinid;

#define IS_DETACHED(pd) ((pd)->joinid == (pd))

    /*
     * 线程函数执行结束后的返回值。
     * pthread_join 获取线程返回值时,本质上就和这个结果有关。
     */
    void *result;

    /*
     * 调度参数和调度策略。
     */
    struct sched_param schedparam;
    int schedpolicy;

    /*
     * 用户传入的线程入口函数和参数。
     */
    void *(*start_routine)(void *);
    void *arg;

    /*
     * 线程自己的栈空间起始位置和大小。
     */
    void *stackblock;
    size_t stackblock_size;

    /*
     * 栈保护区大小。
     */
    size_t guardsize;
    size_t reported_guardsize;
};

9.4.1 代码理解

线程控制块里面有几个字段特别重要:

  • start_routine:保存用户传入的线程函数;
  • arg:保存传给线程函数的参数;
  • result:保存线程退出结果;
  • stackblock:保存线程栈位置;
  • tid:保存内核层面的线程 ID;
  • joinid:用于判断线程是否分离。

这也说明线程库并不是“魔法”,它本质上是在用户层维护了一套线程对象,再配合内核创建轻量级执行流。

9.5 create_thread 和 clone 标志

/*
 * create_thread 会进一步调用 do_clone。
 * clone_flags 决定新执行流和当前执行流共享哪些资源。
 */
static int create_thread(
    struct pthread *pd,
    const struct pthread_attr *attr,
    STACK_VARIABLES_PARMS
)
{
    /*
     * 这些 CLONE 标志非常关键。
     */
    int clone_flags =
        (CLONE_VM              /* 共享虚拟地址空间 */
        | CLONE_FS             /* 共享文件系统信息,比如当前工作目录 */
        | CLONE_FILES          /* 共享文件描述符表 */
        | CLONE_SIGNAL         /* 共享信号处理语义 */
        | CLONE_SETTLS         /* 设置 TLS 区域 */
        | CLONE_PARENT_SETTID  /* 内核把新线程 ID 写到父线程指定位置 */
        | CLONE_CHILD_CLEARTID /* 线程退出时清理 TID */
        | CLONE_SYSVSEM        /* 共享 System V 信号量调整值 */
#if __ASSUME_NO_CLONE_DETACHED == 0
        | CLONE_DETACHED
#endif
        | 0);

    /*
     * 如果调试器需要线程创建事件,可能先让线程暂停启动。
     */
    if (__builtin_expect(THREAD_GETMEM(THREAD_SELF, report_events), 0))
    {
        pd->stopped_start = true;

        /*
         * 通过 do_clone 创建线程。
         */
        int res = do_clone(pd, attr, clone_flags, start_thread,
                           STACK_VARIABLES_ARGS, 1);

        if (res == 0)
        {
            /*
             * 设置线程创建事件信息。
             */
            pd->eventbuf.eventnum = TD_CREATE;
            pd->eventbuf.eventdata = pd;

            /*
             * 记录事件。
             */
            do
                pd->nextevent = __nptl_last_event;
            while (atomic_compare_and_exchange_bool_acq(&__nptl_last_event,
                                                        pd,
                                                        pd->nextevent) != 0);

            /*
             * 通知调试器线程创建事件。
             */
            __nptl_create_event();

            /*
             * 最后解锁,让新线程继续运行。
             */
            lll_unlock(pd->lock);
        }

        return res;
    }

    /*
     * 如果设置了 CPU 亲和性或者调度属性,也可能让线程先停止。
     */
    bool stopped = false;
    if (attr != NULL &&
        (attr->cpuset != NULL ||
         (attr->flags & ATTR_FLAG_NOTINHERITSCHED) != 0))
        stopped = true;

    pd->stopped_start = stopped;

    /*
     * 真正创建线程。
     */
    int res = do_clone(pd, attr, clone_flags, start_thread,
                       STACK_VARIABLES_ARGS, stopped);

    if (res == 0 && stopped)
        lll_unlock(pd->lock);

    return res;
}

9.5.1 CLONE_VM 为什么关键

CLONE_VM 表示共享虚拟地址空间。

这正是线程和进程的重要区别之一。

如果不共享地址空间,那更像创建一个新进程。
如果共享地址空间,再加上共享文件描述符表、信号处理等资源,就更符合线程语义。

💡CLONE_VM 就像几个人共用同一本实验记录本。每个人可以在不同位置写,但如果没有约定好,可能会互相覆盖。

9.6 do_clone 的核心流程

/*
 * do_clone 进一步调用体系结构相关的 clone。
 */
static int do_clone(
    struct pthread *pd,
    const struct pthread_attr *attr,
    int clone_flags,
    int (*fct)(void *),
    STACK_VARIABLES_PARMS,
    int stopped
)
{
#ifdef PREPARE_CREATE
    PREPARE_CREATE;
#endif

    /*
     * 如果需要让线程先暂停,就先加锁。
     */
    if (stopped)
        lll_lock(pd->lock);

    /*
     * 全局线程数量加一。
     */
    atomic_increment(&__nptl_nthreads);

    /*
     * ARCH_CLONE 最终会走到体系结构相关的 clone 实现。
     */
    if (ARCH_CLONE(fct,
                   STACK_VARIABLES_ARGS,
                   clone_flags,
                   pd,
                   &pd->tid,
                   TLS_VALUE,
                   &pd->tid) == -1)
    {
        atomic_decrement(&__nptl_nthreads);

        /*
         * 如果创建失败,并且线程是分离状态,需要释放 TCB 和栈。
         */
        if (IS_DETACHED(pd))
            __deallocate_stack(pd);

        return errno;
    }

    /*
     * 如果线程是 stopped 状态,还需要设置 CPU 亲和性和调度参数。
     */
    if (__builtin_expect(stopped != 0, 0))
    {
        INTERNAL_SYSCALL_DECL(err);
        int res = 0;

        if (attr->cpuset != NULL)
        {
            res = INTERNAL_SYSCALL(sched_setaffinity, err, 3, pd->tid,
                                   sizeof(cpu_set_t), attr->cpuset);

            if (__builtin_expect(INTERNAL_SYSCALL_ERROR_P(res, err), 0))
            {
                INTERNAL_SYSCALL_DECL(err2);

            err_out:
#if __ASSUME_TGKILL
                (void)INTERNAL_SYSCALL(tgkill, err2, 3,
                                        THREAD_GETMEM(THREAD_SELF, pid),
                                        pd->tid, SIGCANCEL);
#else
                (void)INTERNAL_SYSCALL(tkill, err2, 2, pd->tid, SIGCANCEL);
#endif
                return (INTERNAL_SYSCALL_ERROR_P(res, err)
                            ? INTERNAL_SYSCALL_ERRNO(res, err)
                            : 0);
            }
        }

        if ((attr->flags & ATTR_FLAG_NOTINHERITSCHED) != 0)
        {
            res = INTERNAL_SYSCALL(sched_setscheduler, err, 3, pd->tid,
                                   pd->schedpolicy, &pd->schedparam);

            if (__builtin_expect(INTERNAL_SYSCALL_ERROR_P(res, err), 0))
                goto err_out;
        }
    }

    /*
     * 标记当前进程已经是多线程状态。
     */
    THREAD_SETMEM(THREAD_SELF, header.multiple_threads, 1);

    return 0;
}

9.7 __clone 汇编中的系统调用

/*
 * __clone 是 glibc 用汇编封装的 clone 系统调用。
 * 这里不用死抠汇编细节,重点看它最终会 syscall 进入内核。
 */
ENTRY (BP_SYM (__clone))
        /* 参数检查:函数指针不能为空 */
        movq    $-EINVAL,%rax
        testq   %rdi,%rdi
        jz      SYSCALL_ERROR_LABEL

        /* 参数检查:栈指针不能为空 */
        testq   %rsi,%rsi
        jz      SYSCALL_ERROR_LABEL

        /* 在新线程栈上预留空间 */
        subq    $16,%rsi
        movq    %rcx,8(%rsi)

        /* 保存函数指针 */
        movq    %rdi,0(%rsi)

        /* 准备系统调用参数 */
        movq    %rdx, %rdi
        movq    %r8, %rdx
        movq    %r9, %r8
        movq    8(%rsp), %r10

        /* 获取 clone 系统调用号 */
        movl    $SYS_ify(clone),%eax

        /*
         * syscall 陷入内核。
         * 这一步要求内核创建轻量级执行流。
         */
        syscall

        testq   %rax,%rax
        jl      SYSCALL_ERROR_LABEL
        jz      L(thread_start)

9.7.1 代码理解

这个地方我觉得只要理解一件事:

pthread_create 最终还是要通过系统调用进入内核。

线程库负责包装和管理,内核负责真正调度。

也就是说:

pthread_create
        ↓
NPTL 线程库
        ↓
分配线程栈和 TCB
        ↓
clone 系统调用
        ↓
内核创建轻量级执行流

pthread_create 到 clone 的调用链


10. 线程栈:主线程栈和子线程栈不完全一样

10.1 主线程栈

主线程栈可以简单理解为 main 函数对应的栈空间。

fork 的时候,子进程会复制父进程的地址空间结构,并通过写时拷贝机制减少真实复制开销。

主线程栈可以动态增长,但增长也有上限。如果超过上限,可能触发栈溢出,最终导致段错误。

10.2 子线程栈

子线程的栈通常不是像主线程栈那样动态增长,而是线程库提前通过 mmap 映射出一块固定大小的区域。

常见默认大小可能是 8MB

相关思想可以用下面这段代码理解:

/*
 * pthread 库内部可能通过 mmap 为线程申请栈空间。
 *
 * 参数说明:
 *   NULL:
 *     让内核自动选择映射地址。
 *
 *   size:
 *     映射区域大小,也就是线程栈大小。
 *
 *   prot:
 *     映射区域权限,比如可读可写。
 *
 *   MAP_PRIVATE:
 *     私有映射。
 *
 *   MAP_ANONYMOUS:
 *     匿名映射,不关联具体文件。
 *
 *   MAP_STACK:
 *     表示该映射用于栈。
 *
 *   -1 和 0:
 *     匿名映射下不需要文件描述符和文件偏移。
 */
mem = mmap(NULL, size, prot,
           MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);

所以子线程栈本质上是在进程地址空间中映射出来的一块区域。

它原则上是线程私有的,但因为同一进程内线程共享地址空间,如果其他线程知道它的位置,也可能访问到。

这就是多线程危险的地方。

💡子线程栈像每个人自己的草稿本,但这些草稿本都放在同一个宿舍桌子上。如果别人非要翻,也不是完全做不到。

10.3 sys_clone 中的栈指针

/*
 * sys_clone 会从寄存器中取出 clone_flags 和新栈指针。
 * 这里可以看到,创建线程/轻量级进程时,新执行流需要自己的栈。
 */
int sys_clone(struct pt_regs *regs)
{
    unsigned long clone_flags;
    unsigned long newsp;
    int __user *parent_tidptr;
    int __user *child_tidptr;

    /*
     * 获取 clone 标志位。
     */
    clone_flags = regs->bx;

    /*
     * 获取新栈指针。
     * 对线程来说,这个栈通常来自 pthread 库前面 mmap 出来的区域。
     */
    newsp = regs->cx;

    parent_tidptr = (int __user *)regs->dx;
    child_tidptr = (int __user *)regs->di;

    /*
     * 如果没有传新栈,就使用当前栈指针。
     */
    if (!newsp)
        newsp = regs->sp;

    /*
     * 最终进入 do_fork。
     */
    return do_fork(clone_flags, newsp, regs, 0, parent_tidptr, child_tidptr);
}

11. 页表项、页目录和 mm_struct

11.1 页表项标志位

/*
 * 页表项相关标志位。
 * 这些标志位用来描述页是否存在、是否可写、是否可执行、是否脏页等。
 */

#define L_PTE_PRESENT       (1 << 0)   // 页是否存在
#define L_PTE_FILE          (1 << 1)   // 非 PRESENT 时表示文件映射
#define L_PTE_YOUNG         (1 << 1)   // 页是否最近被访问
#define L_PTE_BUFFERABLE    (1 << 2)   // 是否可缓冲
#define L_PTE_CACHEABLE     (1 << 3)   // 是否可缓存
#define L_PTE_USER          (1 << 4)   // 用户态是否可访问
#define L_PTE_WRITE         (1 << 5)   // 是否可写
#define L_PTE_EXEC          (1 << 6)   // 是否可执行
#define L_PTE_DIRTY         (1 << 7)   // 是否为脏页
#define L_PTE_COHERENT      (1 << 9)   // I/O 一致性相关
#define L_PTE_SHARED        (1 << 10)  // 是否在 CPU 间共享
#define L_PTE_ASID          (1 << 11)  // ASID 相关

11.2 页表项和页目录项类型

/*
 * pte_t 表示页表项。
 * pgd_t 表示页全局目录项。
 */
typedef struct { unsigned long pte; } pte_t;
typedef struct { unsigned long pgd; } pgd_t;

这段代码看起来很简单,但背后很重要。

它说明页表项和页目录项,本质上都是对底层整数的封装。只不过为了类型安全和语义清晰,内核把它们封装成不同类型。

11.3 分配页目录和页表

/*
 * 分配页全局目录。
 */
pgd_t *pgd_alloc(struct mm_struct *mm)
{
    pgd_t *ret;
    pgd_t *init;

    /*
     * 申请一个空闲页作为页目录。
     * GFP_KERNEL 表示内核正常内存分配。
     * __GFP_ZERO 表示分配后清零。
     */
    ret = (pgd_t *)__get_free_page(GFP_KERNEL | __GFP_ZERO);

    /*
     * 获取 init_mm 的页目录偏移。
     */
    init = pgd_offset(&init_mm, 0UL);

    if (ret) {
#ifdef CONFIG_ALPHA_LARGE_VMALLOC
        memcpy(ret + USER_PTRS_PER_PGD,
               init + USER_PTRS_PER_PGD,
               (PTRS_PER_PGD - USER_PTRS_PER_PGD - 1) * sizeof(pgd_t));
#else
        pgd_val(ret[PTRS_PER_PGD - 2]) = pgd_val(init[PTRS_PER_PGD - 2]);
#endif

        /*
         * 最后一个 PGD 项是自映射相关内容。
         */
        pgd_val(ret[PTRS_PER_PGD - 1]) =
            pte_val(mk_pte(virt_to_page(ret), PAGE_KERNEL));
    }

    return ret;
}

/*
 * 分配一个内核页表。
 */
pte_t *pte_alloc_one_kernel(struct mm_struct *mm, unsigned long address)
{
    /*
     * 申请一个空闲页作为页表。
     */
    pte_t *pte =
        (pte_t *)__get_free_page(GFP_KERNEL | __GFP_REPEAT | __GFP_ZERO);

    return pte;
}

11.4 mm_struct 中的页目录

/*
 * mm_struct 描述一个进程的内存空间。
 */
struct mm_struct {
    /*
     * VMA 链表。
     * 每个 vm_area_struct 描述一段虚拟内存区域。
     */
    struct vm_area_struct *mmap;

    /*
     * VMA 红黑树。
     * 用于快速查找某个虚拟地址属于哪个 VMA。
     */
    struct rb_root mm_rb;

    /*
     * 最近一次 find_vma 的缓存。
     */
    struct vm_area_struct *mmap_cache;

    /*
     * 查找未映射区域的函数指针。
     */
    unsigned long (*get_unmapped_area)(
        struct file *filp,
        unsigned long addr,
        unsigned long len,
        unsigned long pgoff,
        unsigned long flags
    );

    /*
     * 取消映射区域的函数指针。
     */
    void (*unmap_area)(struct mm_struct *mm, unsigned long addr);

    /*
     * mmap 区域基地址。
     */
    unsigned long mmap_base;

    /*
     * 用户虚拟地址空间大小。
     */
    unsigned long task_size;

    /*
     * 空洞缓存相关字段。
     */
    unsigned long cached_hole_size;
    unsigned long free_area_cache;

    /*
     * 页目录起始地址。
     * 进程切换时,页目录变化是影响 TLB 的重要因素。
     */
    pgd_t *pgd;
};

11.4.1 和线程的联系

同一进程内的线程共享地址空间,也就意味着它们共享同一个 mm_struct

这也是线程切换比进程切换更轻量的重要原因之一。

线程切换时,不需要像进程切换那样切换整套地址空间。


12. 支持任意参数传递的线程封装 Demo

12.1 设计思路

前面的线程封装版本不支持任意参数传递。

如果想让线程类支持任意函数和任意参数,可以结合:

  • 模板;
  • std::bind
  • std::function<void()>
  • std::shared_ptr
  • std::unique_ptr

核心思想是:

把用户传入的函数和参数绑定成一个无参任务,然后在线程入口函数里统一执行这个任务。

12.2 完整代码

#include <iostream>     // std::cout、std::cerr
#include <functional>   // std::function、std::bind
#include <memory>       // std::shared_ptr、std::unique_ptr
#include <pthread.h>    // POSIX 线程库
#include <unistd.h>     // sleep

class Thread {
public:
    /*
     * 构造函数。
     * thread_id_ 初始化为 0;
     * running_ 表示线程是否正在运行。
     */
    Thread()
        : thread_id_(0),
          running_(false)
    {
    }

    /*
     * 析构函数。
     * 如果对象析构时线程仍在运行,就 detach,避免资源泄漏。
     */
    ~Thread()
    {
        if (running_) {
            pthread_detach(thread_id_);
        }
    }

    /*
     * 启动线程。
     *
     * Callable:
     *   任意可调用对象,比如普通函数、lambda、函数对象等。
     *
     * Args:
     *   可变参数模板,用来接收任意数量参数。
     */
    template <typename Callable, typename... Args>
    bool start(Callable&& func, Args&&... args)
    {
        if (running_) {
            std::cerr << "Thread is already running!" << std::endl;
            return false;
        }

        /*
         * 将可调用对象和参数绑定为 std::function<void()>。
         *
         * std::bind:
         *   把函数和参数提前绑定起来。
         *
         * std::forward:
         *   完美转发,尽量保持参数原本的左值/右值属性。
         *
         * shared_ptr:
         *   保证任务对象在线程执行期间依然有效。
         */
        auto task = std::make_shared<std::function<void()>>(
            std::bind(std::forward<Callable>(func),
                      std::forward<Args>(args)...)
        );

        /*
         * 创建线程。
         *
         * 注意:
         *   pthread_create 的第四个参数只能传 void*。
         *   所以这里把 shared_ptr 再放到堆上,然后传递指针。
         */
        if (pthread_create(&thread_id_,
                           nullptr,
                           &Thread::threadEntry,
                           new std::shared_ptr<std::function<void()>>(task)) != 0) {
            std::cerr << "Failed to create thread!" << std::endl;
            return false;
        }

        running_ = true;
        return true;
    }

    /*
     * 等待线程结束。
     */
    void join()
    {
        if (running_) {
            pthread_join(thread_id_, nullptr);
            running_ = false;
        }
    }

private:
    pthread_t thread_id_; // 线程 ID
    bool running_;        // 线程是否正在运行

    /*
     * 线程入口函数。
     *
     * 参数:
     *   arg 是 pthread_create 第四个参数传进来的任务对象指针。
     */
    static void* threadEntry(void* arg)
    {
        /*
         * 使用 unique_ptr 管理堆上分配的 shared_ptr 指针。
         * 这样 threadEntry 结束时可以自动释放这层 new 出来的对象。
         */
        std::unique_ptr<std::shared_ptr<std::function<void()>>> task_ptr(
            static_cast<std::shared_ptr<std::function<void()>>*>(arg)
        );

        /*
         * 取出真正的任务对象。
         */
        auto task = *task_ptr;

        /*
         * 执行任务。
         */
        (*task)();

        return nullptr;
    }
};

/*
 * 测试函数。
 */
void printMessage(const std::string& message, int value, int a, int b, int c)
{
    std::cout << "Message: " << message << ", Value: " << value << std::endl;
    std::cout << "a:" << a << std::endl;
    std::cout << "b:" << b << std::endl;
    std::cout << "c:" << c << std::endl;

    /*
     * sleep 用来让线程保持一段时间,方便观察 ps -aL。
     */
    sleep(10);
}

int main()
{
    Thread thread;

    /*
     * 启动线程,并传递多个参数。
     */
    thread.start(printMessage, "Hello, World!", 42, 1, 2, 3);

    /*
     * 等待线程结束。
     */
    thread.join();

    return 0;
}

运行结果类似:

$ ./a.out
Message: Hello, World!, Value: 42
a:1
b:2
c:3

查看线程:

$ ps -aL
PID       LWP       TTY      TIME       CMD
923509    923509    pts/1    00:00:00   a.out
923509    923510    pts/1    00:00:00   a.out

12.2.1 代码理解

这个封装最重要的是把“任意函数 + 任意参数”统一转换成:

std::function<void()>

这样线程入口函数就不需要关心用户到底传了什么函数、几个参数、参数类型是什么。

💡这就像快递打包。不同形状的东西先统一装进箱子里,运输流程只需要处理“箱子”,不用关心里面具体是什么。


13. 自己调用 clone 观察轻量级执行流

13.1 为什么要自己调用 clone

pthread_create 最终会走到底层 clone,所以自己调用一次 clone,可以更直观地理解:

  • 新执行流需要自己的栈;
  • clone_flags 决定共享哪些资源;
  • CLONE_VM 会让新执行流共享虚拟地址空间;
  • SIGCHLD 表示子执行流结束后给父进程发送信号。

13.2 示例代码

#define _GNU_SOURCE     // 开启 GNU 扩展,使用 clone 需要
#include <sched.h>      // clone、CLONE_VM
#include <stdio.h>      // printf、perror
#include <stdlib.h>     // malloc、free、exit
#include <sys/wait.h>   // waitpid
#include <unistd.h>     // getpid

/*
 * 给 clone 创建的子执行流准备 1MB 栈空间。
 */
#define STACK_SIZE (1024 * 1024)

/*
 * 子执行流执行的函数。
 *
 * 参数:
 *   arg 是 clone 最后一个参数传进来的。
 *
 * 返回值:
 *   返回 0 表示正常结束。
 */
static int child_func(void *arg)
{
    printf("Child process: PID = %d\n", getpid());
    return 0;
}

int main()
{
    /*
     * clone 要求调用者提供栈空间。
     * 这里使用 malloc 在堆上申请 1MB 空间。
     */
    char *stack = (char*)malloc(STACK_SIZE);
    if (stack == NULL) {
        perror("malloc");
        exit(EXIT_FAILURE);
    }

    /*
     * clone 创建子执行流。
     *
     * child_func:
     *   子执行流入口函数。
     *
     * stack + STACK_SIZE:
     *   栈顶地址。
     *   栈通常向低地址增长,所以传入申请空间的末尾。
     *
     * CLONE_VM:
     *   共享虚拟地址空间。
     *
     * SIGCHLD:
     *   子执行流结束时通知父进程。
     *
     * NULL:
     *   传给 child_func 的参数。
     */
    pid_t pid = clone(child_func,
                      stack + STACK_SIZE,
                      CLONE_VM | SIGCHLD,
                      NULL);

    if (pid == -1) {
        perror("clone");
        free(stack);
        exit(EXIT_FAILURE);
    }

    printf("Parent process: PID = %d, Child PID = %d\n", getpid(), pid);

    /*
     * 等待子执行流结束。
     */
    if (waitpid(pid, NULL, 0) == -1) {
        perror("waitpid");
        free(stack);
        exit(EXIT_FAILURE);
    }

    /*
     * 释放栈空间。
     */
    free(stack);

    return 0;
}

13.2.1 代码理解

这段代码和 pthread_create 最大的区别是:这里我们自己显式准备栈空间,并直接指定 clone_flags

如果加上更多 CLONE_ 标志,就可以更接近线程语义。

pthread_create 其实就是帮我们把这些底层细节封装好了。


14. 高频技术题 / 面试题

14.1 线程和进程的区别是什么?

进程是资源分配的基本单位,线程是调度的基本单位。

进程拥有独立地址空间,而同一进程内的多个线程共享地址空间。

线程共享代码段、数据段、堆、文件描述符表、信号处理方式等资源,但每个线程也有自己的栈、寄存器上下文、线程 ID、errno、信号屏蔽字和调度优先级。

所以线程比进程轻量,但线程之间隔离性更差。

常见误区是只回答“线程比进程小”。这样太表面。真正的原因应该落到地址空间和资源共享上。

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

因为同一进程内线程共享虚拟地址空间。

进程切换时,通常需要切换地址空间,页表会变化,CR3 可能变化,TLB 可能被刷新,缓存命中率也会受到影响。

线程切换时,地址空间不变,只需要切换线程上下文,比如寄存器、栈指针等,开销更小。

所以线程切换轻量的核心不是“线程名字短”,而是它复用了进程的大量资源。

14.3 pthread_t 和 LWP 是同一个东西吗?

不是。

pthread_t 是 pthread 库维护的线程 ID,在 Linux NPTL 实现中,它本质上可能是进程地址空间中的一个地址,指向线程库内部的线程控制块。

LWP 是内核调度看到的轻量级进程 ID。

可以用:

ps -aL

查看进程中的多个 LWP。

14.4 为什么一个线程崩溃,整个进程可能会崩溃?

因为线程共享同一个进程地址空间。

如果某个线程出现非法内存访问、除零、野指针等问题,可能触发信号。信号处理如果导致进程终止,那么该进程内所有线程都会退出。

线程不是独立进程,它只是进程内部的执行流,所以一个线程出严重异常时,影响范围可能是整个进程。

14.5 pthread_join 的作用是什么?

pthread_join 主要有两个作用:

  1. 等待指定线程结束;
  2. 获取线程退出返回值并回收相关资源。

如果线程是 joinable 状态,但一直没有被 join,线程退出后的部分资源可能无法释放。

如果不关心线程返回值,可以传 NULL

14.6 pthread_detachpthread_join 能同时使用吗?

不能。

一个线程要么是 joinable,要么是 detached。

joinable 线程退出后需要被 pthread_join 回收。
detached 线程退出后由系统自动回收资源,不能再被 pthread_join

所以一个线程不能既 detach 又 join。

14.7 malloc 申请内存后,物理内存一定立刻分配了吗?

不一定。

很多情况下,malloc 可能只是让虚拟地址空间中出现了一段可用区域,真正物理页可能在第一次访问时通过缺页异常建立映射。

所以“申请内存”不一定等于“马上占用对应物理内存”。

这和虚拟内存、页表、缺页异常都有关系。

14.8 CLONE_VM 的作用是什么?

CLONE_VM 表示新创建的执行流和当前执行流共享虚拟地址空间。

这是实现线程语义的重要标志之一。

如果没有 CLONE_VM,新执行流就不会共享地址空间,更接近进程。
如果设置了 CLONE_VM,多个执行流可以访问同一份代码段、数据段、堆等资源。

14.9 子线程的栈在哪里?

主线程栈通常在进程地址空间的栈区域。

子线程栈通常由 pthread 库通过 mmap 在共享区附近映射出来,大小一般是固定的,比如默认可能是 8MB。

子线程栈原则上是线程私有的,但因为同一进程的线程共享地址空间,如果其他线程知道地址,也可能访问到。

14.10 为什么多线程程序更难调试?

因为多线程存在并发执行,线程调度顺序具有不确定性。

同一个 bug 可能这次出现,下次不出现。

常见问题包括:

  • 数据竞争;
  • 死锁;
  • 资源泄漏;
  • 野指针;
  • 线程退出顺序错误;
  • join/detach 使用错误。

所以多线程程序不仅要关心代码逻辑,还要关心执行时序。


结语

学完线程这一部分之后,我最大的感受是:线程并不是孤立的一个 API 知识点,而是把进程地址空间、页表、调度、信号、栈、系统调用这些内容串在了一起。

刚开始我只是觉得线程就是“多条执行路线”,会创建、会等待就差不多了。后来真正往底层看,才发现线程为什么轻量、为什么共享资源、为什么又有自己的栈,背后其实都和虚拟地址空间有关。

尤其是 pthread_tLWP 这一块,让我意识到 Linux 线程并不是单纯由一个库函数“变出来”的。pthread 库在用户层维护线程控制块、线程属性和线程栈,而真正的执行流创建和调度,最终还是要交给内核完成。

再回过头看 pthread_create,它就不再只是一个函数调用了,而是一条完整链路:

用户调用 pthread_create
        ↓
线程库准备线程属性、线程栈和 TCB
        ↓
保存入口函数和参数
        ↓
通过 clone 创建轻量级执行流
        ↓
内核参与调度

这也是我觉得 Linux 学习比较有意思的地方:很多一开始看起来只是“会用就行”的接口,背后其实都有完整的操作系统机制在支撑。

线程这一部分学完之后,再去看线程池、互斥锁、条件变量、生产者消费者模型、协程,都会更容易理解一些。因为这些东西本质上都是围绕“多个执行流如何共享资源、如何安全协作”展开的。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐