Linux 线程编程:从进程、分页到线程控制与封装

Linux线程概念

1-1 什么是线程

聊到线程,就必须先从进程开始。进程 = 内核数据结构 + 代码数据。进程要被 CPU 调度,所以它还与"执行流"的概念有关。

执行流就是一段具有独立"进度"的指令序列,在运行中随时可以被挂起、切换和恢复,就像一个有自己专属书签的阅读过程。

拆开来讲:

  • 它有一个独立的指令指针(PC),记录"当前执行到哪了"

  • 它有自己独立的栈,保存函数调用关系和局部变量

  • 它的进度可以被内核挂起、保存、恢复

一句话总结:执行流是"运行"这件事本身的抽象,线程是它在操作系统中的实现。

1. 早期操作系统:进程 ≈ 执行流

在早期的单线程操作系统中,一个进程确实只有一个执行序列,从入口开始顺序执行。那时在口语上把进程说成"执行流"问题不大,因为它们是 1:1 绑定的。

2. 现代多线程系统:进程是资源容器,线程是执行流

在现代操作系统中,两个概念已经明确分开:

所以准确的表述是:进程提供资源环境,线程才是真正的执行流。 一个进程可以包含多个执行流(多线程),这些执行流并发地运行在同一进程空间内。

在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是"一个进程内部的控制序列"。

  • 一切进程至少都有一个执行线程

  • 线程在进程内部运行,本质是在进程地址空间内运行

  • 在 Linux 内核调度器眼中,线程和进程都是 task_struct;线程(轻量级进程)相比于完整进程,不需要重新创建地址空间与页表,因此更加轻量化。

进程访问资源,都是通过地址空间访问,即地址空间是进程访问资源的窗口。透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

进程用户态资源包含程序代码数据、动态库代码数据;而打开的文件描述符、信号处理方式、工作目录等,属于进程内核层面资源,不在虚拟地址空间之内

所谓的进程具有独立性、资源不同,就是因为窗口不同------有自己的地址空间。创建进程就要创建 PCB、创建地址空间、建立页表映射等工作。这也就是为什么进程是资源分配的基本单位,因为要申请大量的资源。

若今天创建"进程",要与之前的进程共享同一份资源、共享地址空间,不分配独立的地址空间、不构建页表等,这个就是线程 。新线程与其所属的进程共享同一份地址空间和页表,所以进程的地址空间对于线程来说是可见的。线程之间可以直接地共享数据,无需通过进程间通信(IPC)机制。即:同一进程内的多个线程之间共享地址空间和其他资源,这使得线程的创建和切换更加高效。

Linux 内核没有真正的"线程"概念,所有线程本质上都是共享同一个地址空间的轻量级进程(LWP)。 通过这种方式,就不会单独对线程设计数据结构和算法,直接复用进程的数据结构算法,比如调度算法等。

"进程"是一个资源集合(地址空间、文件等),"线程"是穿梭其中的执行流。每个执行流在内核中就是一个 task_struct 实体,它们通过指向同一套资源结构体来共同构成一个进程。

总结:Linux 线程可以用进程来模拟。

对资源的划分,本质就是对地址空间虚拟地址范围的划分,虚拟地址就是资源的代表。

程序的代码段包含了所有的函数和指令,每个函数在编译后都会形成一个代码块,每个代码块都有一个入口地址,这个地址是函数名的符号地址。

在单进程中,函数的调用通常是串行调用,即:一个函数调用完后才会调用另外一个函数。将代码分成多个部分,每个部分由不同的执行流(线程或进程)执行,这样可以将原本串行执行的任务变为并行执行,提高效率。

那么怎么划分呢?其实函数就是代码块,也就是虚拟地址的集合(内存视角)。所以让不同的线程执行不同的代码块,本质就是让线程执行 ELF 可执行程序中不同的函数。

我们以前学的进程,其实是内部只有一个线程的进程。

补充:Windows 的线程实现方案(了解即可)

不同的平台对于进程和线程的实现大同小异,但对于线程实现,差异还是比较大的。在控制和管理线程时,需要描述线程的结构体 TCB。TCB 跟 PCB 一样是操作系统理论抽象概念,不是某个系统固定的结构体名字。

平台 实现方式
Windows 内核层区分进程对象与线程对象
Linux 复用 task_struct,通过共享资源的轻量级进程实现线程

生活类比

社会分配资源的基本实体就是家庭。家庭内部人员就是爸爸妈妈爷爷奶奶......站在家庭的角度,每个人都在做着不同的事情------工作、学习、散步等,核心目标都是过好日子。所以内部人员就是线程,家庭就是进程
进程强调独占,线程强调共享。

1-2 分页式存储管理

1-2-1 虚拟地址和页表的由来

思考一下,如果在没有虚拟内存和分页机制的情况下,每一个用户程序在物理内存上所对应的空间必须是连续的,如下图:

因为每一个程序的代码、数据长度都是不一样的,按照这样的映射方式,物理内存将会被分割成各种离散的、大小不同的块。经过一段运行时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。

怎么办呢?我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分页便出现了,如下图所示:

物理内存由 OS 划分成一个个固定长度的页框(page frame),每个页框就是一块物理内存,有时也称为物理页。虚拟地址空间同样被划分成固定大小的页(page)。页的大小等于页框的大小。大多数体系结构(包括 32 位和 64 位 x86)默认都使用 4KB 的页,64 位系统通常还支持 2MB、1GB 等大页。

区分一页和一个页框是很重要的:

  • 页框是一个存储区域;

  • 而页是一个数据块,可以存放在任何页框或磁盘中。

当物理内存不足时,OS 可以将暂时不用的页换出到磁盘(swap 区),之后再重新装入一个空闲页框。所以"页"是数据,可以移动;"页框"是容器,始终在物理内存中。

有了这种机制,CPU 便并非是直接访问物理内存地址,而是通过虚拟地址空间来间接地访问物理内存地址。所谓的虚拟地址空间,是操作系统为每一个正在执行的进程分配的一个逻辑地址,在 32 位机上,其范围从 0 ~ 4G-1。

操作系统通过将虚拟地址空间和物理内存地址之间建立映射关系,也就是页表,这张表上记录了每一对页和页框的映射关系,能让 CPU 间接的访问物理内存地址。

总结一下,其思想是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题。


1-2-2 物理内存管理

假设一个可用的物理内存有 4GB 的空间。按照一个页框的大小 4KB 进行划分, 4GB 的空间就是 4GB/4KB = 1048576 个页框。有这么多的物理页,操作系统肯定是要将其管理起来的,操作系统需要知道哪些页正在被使用,哪些页空闲等等。

内核用 struct page 结构表示系统中的每个物理页,出于节省内存的考虑, struct page 中使用了大量的联合体 union。

有了对应的结构体描述后,把它放到特定的数据结构里,管理物理内存就转换为对数据结构的增删查改。Linux 内核使用全局数组来管理物理页框:

cpp 复制代码
struct page mem_map[1048576];  // 4GB 物理内存、4KB 页大小的示例

每个物理页框都有对应的下标。由于物理页框是连续存储的,知道下标后,就能算出该页的起始物理地址:index × 4KB。具体物理地址 = 页框起始地址 + 页内偏移。因此 struct page 里不需要保存起始地址。

用户访问内存可以按 1 字节、2 字节、4 字节等任意粒度,但 OS 申请物理内存都是以 4KB 为单位的。

  1. 之前讲过的写时拷贝就是一个典型例子:一个变量在某一个页里,发生写时拷贝时并不是只拷贝那一个变量,而是把整个变量所在的页都复制一份。
  2. 在讲解共享内存时也提过,建议申请 4KB 的整数倍,否则 OS 会向上取整,实际分配给用户的是按页对齐后的空间。
  3. 程序发生缺页中断时,内核通过伙伴系统找到一个空闲页框(对应 struct page 不在已分配状态),然后把缺的那一页数据从外设加载到该页框中,而不是加载整个文件。
  4. 这也解释了为什么加载可执行程序时要把 section 合并成 segment------segment 按页对齐,方便以页为单位加载到内存。
cpp 复制代码
/* include/linux/mm_types.h */
struct page {
    /* 原子标志,有些情况下会异步更新 */
    unsigned long flags;
    union {
        struct {
            /* 换出页列表,例如由zone->lru_lock保护的active_list */
            struct list_head lru;
            /* 如果最低位为0,则指向inode
             * address_space,或为NULL
             * 如果页映射为匿名内存,最低位置位
             * 而且该指针指向anon_vma对象
             */
            struct address_space* mapping;
            /* 在映射内的偏移量 */
            pgoff_t index;
            /*
             * 由映射私有,不透明数据
             * 如果设置了PagePrivate,通常用于buffer_heads
             * 如果设置了PageSwapCache,则用于swp_entry_t
             * 如果设置了PG_buddy,则用于表示伙伴系统中的阶
             */
            unsigned long private;
        };
        struct { /* slab, slob and slub */
            union {
                struct list_head slab_list; /* uses lru */
                struct { /* Partial pages */
                    struct page* next;
#ifdef CONFIG_64BIT
                    int pages; /* Nr of pages left */
                    int pobjects; /* Approximate count */
#else
                    short int pages;
                    short int pobjects;
#endif
                };
            };
            struct kmem_cache* slab_cache; /* not slob */
            /* Double-word boundary */
            void* freelist; /* first free object */
            union {
                void* s_mem; /* slab: first object */
                unsigned long counters; /* SLUB */
                struct { /* SLUB */
                    unsigned inuse : 16;
                    unsigned objects : 15;
                    unsigned frozen : 1;
                };
            };
        };
        ...
    };
    union {
        /* 内存管理子系统中映射的页表项计数,用于表示页是否已经映射,还用于限制逆向映射搜索 */
        atomic_t _mapcount;
        unsigned int page_type;
        unsigned int active; /* SLAB */
        int units; /* SLOB */
    };
    ...
#if defined(WANT_PAGE_VIRTUAL)
    /* 内核虚拟地址(如果没有映射则为NULL,即高端内存) */
    void* virtual;
#endif /* WANT_PAGE_VIRTUAL */
    ...
}

其中比较重要的几个参数:

要注意的是 struct page 与物理页相关,而并非与虚拟页相关。而系统中的每个物理页都要分配一个这样的结构体,让我们来算算对所有这些页都这么做,到底要消耗掉多少内存。

struct page 占 40 个字节的内存吧,假定系统的物理页为 4KB 大小,系统有 4GB 物理内存。那么系统中共有页面 1048576 个(1 兆个),所以描述这么多页面的 page 结构体消耗的内存只不过 40MB,相对系统 4GB 内存而言,仅是很小的一部分罢了。因此,要管理系统中这么多物理页面,这个代价并不算太大。

要知道的是,页的大小对于内存利用和系统开销来说非常重要:

  • 页太大,页必然会剩余较大不能利用的空间(页内碎片)

  • 页太小,虽然可以减小页内碎片的大小,但是页太多,会使得页表太长而占用内存,同时系统频繁地进行页转化,加重系统开销

因此,页的大小应该适中,通常为 512B - 8KB,Windows 系统的页框大小为 4KB。

申请物理内存,本质就是查数组,改 page,建立内核数据结构的对应关系确保以后可以找到数据。struct file 通过 f_mapping 找到 address_spaceaddress_space 里管理着该文件的多个 struct page;而每个 struct page 又通过 mapping 指针反过来指向它所属的 address_space。两者通过 address_space 间接关联。


1-2-3 页表

页表中的每一个表项,指向一个物理页的开始地址。在 32 位系统中,虚拟内存的最大空间是 4GB,这是每一个用户程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可用,那么页表中就需要能够表示这所有的 4GB 空间,那么就一共需要 4GB/4KB = 1048576 个表项。如下图所示:

虚拟内存看上去被虚线"分割"成一个个单元,其实并不是真的分割,虚拟内存仍然是连续的。这个虚线的单元仅仅表示它与页表中每一个表项的映射关系,并最终映射到相同大小的一个物理内存页上。

页表中的物理地址,与物理内存之间,是随机的映射关系,哪里可用就指向哪里(物理页)。虽然最终使用的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使用的都是线性地址,只要它是连续的就可以了,最终都能够通过页表找到实际的物理地址。

在 32 位系统中,地址的长度是 4 个字节,那么页表中的每一个表项就是占用 4 个字节。所以页表占据的总空间大小就是:1048576 × 4 = 4MB 的大小。也就是说映射表自己本身,就要占用 4MB / 4KB = 1024 个物理页。这会存在哪些问题呢?

  • 回想一下,当初为什么使用页表,就是要将进程划分为一个个页可以不连续地存放在物理内存中,但是此时页表就需要 1024 个连续的页框,似乎和当时的目标有点背道而驰了......

  • 此外,根据局部性原理可知,很多时候进程在一段时间内只需要访问某几个页就可以正常运行了。因此也没有必要一次让所有的物理页都常驻内存。

解决需要大容量页表的最好方法是:把页表看成普通的文件,对它进行离散分配,即对页表再分页,由此形成多级页表的思想。

为了解决这个问题,可以把这个单一页表拆分成 1024 个体积更小的映射表。如下图所示。这样一来,1024(每个表中的表项个数)× 1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。

这里的每一个表,就是真正的页表,所以一共有 1024 个页表。一个页表自身占用 4KB,那么 1024 个页表一共就占用了 4MB 的物理内存空间,和之前没差别啊?

从总数上看是这样,但是一个应用程序是不可能完全使用全部的 4GB 空间的,也许只要几十个页表就可以了。例如:一个用户程序的代码段、数据段、栈段,一共就需要 10MB 的空间,那么使用 3 个页表就足够了。

计算过程:

  • 每一个页表项指向一个 4KB 的物理页,那么一个页表中 1024 个页表项,一共能覆盖 4MB 的物理内存

  • 那么 10MB 的程序,向上对齐取整之后(4MB 的倍数,就是 12MB),就需要 3 个页表就可以了


1-2-4 页目录结构

到目前为止,每一个页框都被一个页表中的一个表项来指向了,那么这 1024 个页表也需要被管理起来。管理页表的表称之为页目录表,形成二级页表。如下图所示:

  • 所有页表的物理地址被页目录表项指向

  • 页目录的物理地址被 CR3 寄存器指向,这个寄存器中,保存了当前正在执行任务的页目录地址

所以操作系统在加载用户程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为用来保存程序的页目录和页表分配物理内存。

CR3 就是当前进程的硬件上下文。进程切换了,上下文就切换了,页表也就切换了。


1-2-5 两级页表的地址转换

页目录表和页表存放的都是页表项(PTE,Page Table Entry),每一项指向下一级结构或物理页框。

页目录表项

理论上页目录只有一个,页目录表里有 1024 项,每一项存放:

内容 说明
二级页表的物理起始地址 指向一个页表
权限标志位 可读/可写/用户态访问等
存在位 该页表是否在物理内存中

页表项

理论上页表可以有 1024 个,一个页表里可以有 1024 项,每一项存放:

内容 说明
物理页框的起始地址 指向最终要访问的物理页
权限标志位 可读/可写/用户态访问等
存在位 该页是否已加载到物理内存
访问标志位 是否被访问过、是否被修改过等

下面以一个逻辑地址为例,说明将逻辑地址转换为物理地址的过程。

在 32 位处理器中(在 32 位处理器中地址就是 32 个二进制数,64 位就是 64 个二进制数),采用 4KB 的页大小,则 32 位虚拟地址被划分为三部分:

以逻辑地址 (0000000000, 0000000001, 111111111111) 为例,转换过程如下:

  1. CR3 寄存器读出页目录的物理起始地址。

  2. 根据页目录号(高 10 位)查页目录表,找到对应的二级页表在物理内存中的存放位置。

  3. 根据页表号(中间 10 位)查二级页表,找到最终要访问的物理页框号。

  4. 将物理页框的起始地址与页内偏移(低 12 位)相加,得到最终的物理地址。

注:一个物理页的地址一定是 4KB 对齐的(最后的 12 位全部为 0),所以其实只需要记录物理页地址的高 20 位即可。我们的进程一般只使用一部分内存,因此页表数远远小于 1024。

以上其实就是 MMU 的工作流程。MMU(Memory Manage Unit)是一种硬件电路,其速度很快,主要工作是进行内存管理,地址转换只是它承接的业务之一。

申请内存大致流程 惰性分配)

cpp 复制代码
1. 用户调用 malloc / 访问一段未映射的地址
       ↓
2. 申请虚拟地址空间(brk / mmap)
       ↓
3. 第一次访问该地址 → 触发缺页中断
       ↓
4. 内核查找数组(mem_map / 伙伴系统)
       ↓
5. 找到没有被占用的 page
       ↓
6. 拿到 page index
       ↓
7. 由 index 得到物理页框地址(index × 4KB)
       ↓
8. 构建页表,填写物理页框地址
       ↓
9. 返回用户态,继续执行

所以写时拷贝、缺页中断、内存申请等背后都可能要重新建立新的页表和建立映射关系的操作。其中申请内存本质就是管理 vm_area_struct。小块内存通过 brk 调整堆区的 end 指针,大块内存通过 mmap 创建新的 VMA。此时都只动了虚拟地址空间,不分配物理内存。

总结

对进程来说,进程是由一张页目录加 n 张页表构建的映射体系。虚拟地址是索引,物理页框是目标,虚拟地址(低 12 位)+ 页框地址 = 物理地址。执行流看到的资源本质就是在合法的条件下拥有多少虚拟地址,因为虚拟地址就是资源的代表。而我们的 mm_structvm_area_struct 的本质就是对资源的统计和整体数据,页表就是一张虚拟到物理的地址转换地图。资源划分就是地址空间划分,资源共享就是虚拟地址空间共享。

为什么是 12,为什么是低 12 而不是高 12 位?

为什么是 12:因为页框大小是 4KB,取值范围就是 0~4095,也就是 2^12。

为什么是低 12 位

在磁盘上,基于平坦模式(flat mode)对可执行程序进行编址时,地址范围就是从全 0 到全 F。在 ELF 文件视角下,每一个区域都可以用"起始地址 + 偏移量"来访问。

当动态库被加载到内存时,加载器会为它分配一个起始虚拟地址,后续就以偏移量的方式访问库中的代码和数据。因此,虚拟地址本身就可以看作一个偏移量,这个特性使得"平坦模式"下的地址天然具有线性、连续的特点。

在这种从全 0 到全 F 的编址模式中,如果两个地址的前 20 位相同,那么它们一定落在同一个 4KB 页内。也就是说,编址过程本身就把代码和数据进行了"有序化"整理------连续的低位地址在物理上也倾向于连续。

所以,用低 12 位作为页内偏移,就意味着用高 20 位来区分"是哪一个页"。平坦模式下的这种编码方式具有非常好的聚集效果:相邻的代码/数据,地址也相邻;地址相邻,前 20 位就相同;前 20 位相同,就落在同一个页里,从而更好地实现局部性原理。

这就是为什么页内偏移用低 12 位,而不是高 12 位:低位代表页内的连续细节,高位代表页的编号,刚好契合平坦模式下地址的连续性和局部性。

TLB:多级页表的加速器

到这里其实还有个问题,MMU 要先进行两次页表查询确定物理地址,在确认了权限等问题后,MMU 再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。那么当页表变为 N 级时,就变成了 N 次检索 + 1 次读写。可见,页表级数越多查询的步骤越多,对于 CPU 来说等待时间越长,效率越低。

让我们现在总结一下:单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。

有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加一个中间层来解决。MMU 引入了新武器,江湖人称快表的 TLB(其实,就是缓存)。

当 CPU 给 MMU 传新虚拟地址之后,MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,齐活。但 TLB 容量比较小,难免发生 Cache Miss,这时候 MMU 还有保底的老武器页表,在页表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到 TLB,让它记录一下刷新缓存。


1-2-6 缺页异常

设想,CPU 给 MMU 的虚拟地址,在 TLB 和页表都没有找到对应的物理页,该怎么办呢?其实这就是缺页异常 Page Fault,它是一个由硬件中断触发的可以由软件逻辑纠正的错误。

假如目标内存页在物理内存中没有对应的物理页,或者存在但无对应权限,CPU 就无法获取数据,这种情况下 CPU 就会报告一个缺页错误。

由于 CPU 没有数据就无法进行计算,CPU 罢工了,用户进程也就出现了缺页中断。进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler 处理。

缺页中断会交给 Page Fault Handler 处理,其根据缺页中断的不同类型会进行不同的处理:

1-3 线程的优点

  • 创建一个新线程的代价要比创建一个新进程小得多

  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多(重要)

    ++最主要的区别是:线程切换时虚拟内存空间保持不变,而进程切换时虚拟内存空间会改变。这两种上下文切换的处理都是通过操作系统内核来完成的。内核切换过程伴随的最显著性能损耗,是将寄存器中的内容切换出去。++

    ++另一个隐藏的主要损耗是:上下文切换会扰乱处理器的缓存机制。简单说,一旦切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著区别是:当改变虚拟内存空间时,处理器的页表缓冲 TLB(快表)会被全部刷新,这将导致内存的访问在一段时间内相当低效。但线程切换不会出现这个问题,当然硬件 cache 也不会受影响。++

  • 线程占用的资源要比进程少很多

  • 能充分利用多处理器的可并行数量

  • 在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务

    • 计算密集型应用:为了能在多处理器系统上运行,将计算分解到多个线程中实现

    • I/O 密集型应用:为了提高性能,将 I/O 操作重叠。线程可以同时等待不同的 I/O 操作


核心对比:线程切换 vs 进程切换

1-4 线程的缺点

  • 性能损失(也是多进程的缺点)

    一个很少被外部事件阻塞的计算密集型线程,往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失。这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。

  • 健壮性降低

    编写多线程需要更全面、更深入的考虑。在一个多线程程序里,因时间分配上的细微偏差,或者因共享了不该共享的变量而造成不良影响的可能性是很大的。换句话说,线程之间是缺乏保护的。

  • 缺乏访问控制

    进程是访问控制的基本粒度。在一个线程中调用某些 OS 函数,会对整个进程造成影响。但这一点也可以是优点,在之前我们实现进程间通信时要让进程看到同一份资源是比较困难的,而在线程中,资源是天然共享的

  • 编程难度提高

    编写与调试一个多线程程序,比单线程程序困难得多。


核心问题

1-5 线程异常

  • 单个线程如果出现除零、野指针等问题导致线程崩溃,进程也会随着崩溃。

  • 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程。进程终止,该进程内的所有线程也就随即退出。


本质原因

线程共享进程的地址空间和内核资源,但没有独立的异常隔离边界。

cpp 复制代码
线程 A 发生除零
  → 触发 SIGFPE
  → 信号发送给整个进程
  → 进程终止
  → 线程 A、B、C 全部退出

与多进程的对比

1-6 线程用途

  • 合理地使用多线程,能提高 CPU 密集型程序的执行效率。

  • 合理地使用多线程,能提高 I/O 密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。


两类典型场景

2. Linux 进程 VS 线程

2-1 进程和线程

  • 进程是资源分配的基本单位

  • 线程是调度的基本单位

  • 线程共享进程数据,但也拥有自己的一部分数据:

    • 线程 ID

    • 一组寄存器(线程的上下文数据)

    • 独立的栈结构

    • errno(错误码)

    • 信号屏蔽字

    • 调度优先级

2-2 进程的多个线程共享

多个线程共享同一地址空间,因此 Text Segment、Data Segment 都是共享的。如果定义一个函数,各线程都可以调用;如果定义一个全局变量,各线程都可以访问到。除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表

  • 每种信号的处理方式(SIG_IGN、SIG_DFL 或自定义的信号处理函数)

  • 当前工作目录

  • 用户 id 和组 id

进程和线程的关系:

3. Linux 线程控制

3-1 POSIX 线程库

与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以 pthread_ 打头的。要使用这些函数库,需要引入头文件 <pthread.h>

使用 POSIX 线程接口(如 pthread_create)时,传统上需要加 -lpthread 链接 pthread 库。但在现代 glibc 2.34 及以上版本中,线程符号已合并到 libc,不加也能链接成功。为了兼容旧系统和可移植性,建议仍显式加上 -lpthread

cpp 复制代码
xqq@ubuntu-server:~/linux/module6$ ll /usr/include/pthread.h
-rw-r--r-- 1 root root 48376 Jul 24 20:11 /usr/include/pthread.h #头文件
xqq@ubuntu-server:~/linux/module6$ ll /lib/x86_64-linux-gnu/libpthread.so.0
-rw-r--r-- 1 root root 21448 Jul 24 20:11 /lib/x86_64-linux-gnu/libpthread.so.0 #库文件

为什么需要线程库?

Linux 系统不存在真正意义上的线程,它所谓的"线程"概念是用轻量级进程(LWP) 模拟的。OS 中只有轻量级进程,所谓"用进程模拟线程"只是我们的说法。Linux 只提供创建轻量级进程的接口(系统调用),如:

所以 Linux 创建"子进程"有两种模式:

  1. 普通进程:拷贝整个地址空间 + 创建 PCB

  2. 轻量级进程:地址空间不变,只创建新的 PCB(即我们所说的"线程")

但 Linux 内核不会提供真正意义上的"创建线程"接口,因为它只有轻量级进程的概念。然而作为用户的我们,只认识"线程",不认识"轻量级进程"------几乎所有操作系统教材讲的都是线程。于是用户和操作系统之间就出现了概念上的鸿沟。

为了解决这个问题,Linux 设计者在用户层提供了一个软件层------pthread 线程库。它向用户提供一批创建线程的接口,而底层对轻量级进程的封装就藏在库里面,用户无需关心。

这就是为什么 Linux 的线程实现是在用户层完成的,我们也称之为用户级线程 。pthread 原生线程库因此得名。
补充:与 Linux 不同,Windows 内核本身区分进程对象与线程对象,操作系统原生提供了创建线程的接口(如 CreateThread,底层对应 NtCreateThread 系统调用)。而 Linux 内核只有轻量级进程,没有真正的线程概念,所以需要通过 pthread 库在用户层封装 clone 来模拟线程。
C++11 引入的多线程库,本质上是定义了一套与平台无关的标准线程接口 ,而不是直接封装某一个系统的线程库。Linux 平台上,这套接口底层由 pthread 线程库实现;Windows 平台上,则由 Windows 自身的线程 API(如 CreateThread)实现。之所以这样做,是为了解决 C++ 代码的跨平台和可移植性问题:同一份 C++ 多线程代码,在 Linux 和 Windows 上分别编译时,会各自链接对应平台的线程实现,从而无需修改代码即可在两个系统上运行。实现方式上,标准库针对不同平台分别编写实现,编译时根据目标平台进行裁剪和选择,最终链接的只是当前平台所需的那部分实现。

3-2 创建线程

功能

创建一个新的线程。

原型

cpp 复制代码
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);

编译和链接时需要加 -pthread

传参及返回值问题

pthread_create 的原型中最后两个参数是:

cpp 复制代码
void *(*start_routine) (void *), void *arg
  • arg :创建线程时,会将其作为参数传递给 start_routine

  • start_routine 的返回值 :是一个 void *,最终会被主线程的 pthread_join() 通过 void **retval 拿到

因此,我们可以手动设置这个返回值,用来表示线程的退出码。后面的例2有演示将100设置成退出码

这里使用了二级指针void **retval)来拿到线程函数的返回值:

  • pthread_join 需要修改调用者传进来的指针本身

  • 所以要传入指针的地址,即二级指针

后续章节会讲解其底层原理,届时就能理解二级指针的作用了。

参数

参数 说明
thread 返回线程 ID
attr 设置线程的属性,attrNULL 表示使用默认属性
start_routine 函数地址,线程启动后要执行的函数(即新线程执行入口)
arg 传给线程启动函数的参数

返回值

成功返回 0;失败返回错误码

错误检查

  • 传统的一些函数是:成功返回 0,失败返回 -1,并且对全局变量 errno 赋值以指示错误。

  • pthreads 函数出错时不会设置全局变量 errno(而大部分其他 POSIX 函数会这样做),而是将错误代码通过返回值返回。

  • pthreads 同样也提供了线程内的 errno 变量,以支持其它使用 errno 的代码。对于 pthreads 函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的 errno 变量的开销更小。

例一:

cpp 复制代码
#include <iostream>
#include <pthread.h>
#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;
    int n=pthread_create(&tid,nullptr,ThreadRun,(void*)"thread-1");
    if(n<0)
    {
        std::cerr<<"创建线程失败"<<std::endl;
        return -1;
    }
    while(true)
    {
        std::cout<<"我是主线程"<<std::endl;
        sleep(1);
    }
    return 0;
}

运行结果

cpp 复制代码
$ ./test
我是主线程
我是一个新线程:name: thread-1
我是主线程
我是一个新线程:name: thread-1
我是主线程我是一个新线程:name:
thread-1
我是主线程
我是一个新线程:name: thread-1
我是主线程
我是一个新线程:name: thread-1

验证两个线程:

使用ps -aL

cpp 复制代码
xqq@ubuntu-server:~$ ps -aL
    PID     LWP TTY          TIME CMD
 712964  712964 pts/0    00:00:00 test
 712964  712965 pts/0    00:00:00 test
 713131  713131 pts/1    00:00:00 ps

分析:

  • 两个线程的 PID 相同,说明它们属于同一个进程。

  • TTY 表示它们属于同一终端。终端屏幕也是一个文件(Linux 一切皆文件),也是一个共享资源。从运行结果来看,两个线程同时向终端打印,出现了输出错乱,这就是数据不一致问题,后面可以通过锁来解决。

  • LWP 不同 ,说明进程存在两个线程。没有进行 pthread_create 之前只有一个线程,即 LWP 与 PID 相同(主线程)。创建后就新建了一个 LWP。

CPU 调度时看的是 LWP 而不是 PID。Linux 内核认为只有轻量级进程,没有真正的线程。在 CPU 视角里,PID 相同表示属于同一个进程,CPU 可以区分是"进程切换"还是"线程切换",从而决定要不要切换页表。

  • 关于调度的时间片问题

时间片是要等分的。比如进程分到 10ms,有两个线程就各自 5ms。因为时间片也是进程级资源共享的。如果允许进程通过创建线程来得到更多的时间片,那么恶意程序就可以通过不断创建新线程来获得更多的 CPU 时间片了。

  • 线程异常

线程出异常后,比如子线程发生除零、野指针等错误,在内核就会触发中断,执行中断处理流程,给当前进程 (而不是线程)发送信号,于是进程挂掉,所有线程也随之死亡。也就是说,任何一个线程崩溃,就会导致整个进程崩溃。这也体现了多线程健壮性低的劣势。

  • 关于调度顺序问题

pthread_create 之后,主线程和新线程谁先被调度运行,和 fork 后父子进程谁先执行一样,都是不确定的,完全由操作系统调度器决定。这正是并发编程中顺序不可预测性的体现。


同一进程的多个线程由于共享地址空间,因此:

  • 天然共享全局变量、静态变量、堆内存等进程级资源

  • 可以同时调用同一个函数

当多个线程(即多个执行流)同时进入同一个函数时,这个函数就叫做被重入了

与信号篇"函数重入"的呼应

在上一篇信号章节中,函数重入的场景是:main 执行流 + 信号处理执行流 → 同时进入 insert()

信号处理函数通过中断插入,导致同一个函数被两个执行流交替执行。

而在多线程中,函数重入的场景是:线程 A 执行流 + 线程 B 执行流 → 同时进入同一个函数

多个线程并发执行,同一个函数可能同时被多个线程进入。

对比

本质完全一样:函数被多个执行流重复进入 。如果重入后出错,就是不可重入函数 ;如果安全,就是可重入函数

关于传参和返回值的进一步说明

pthread_create 的参数类型是 void *start_routine 的返回值类型也是 void *。这意味着:

  • 传参:不仅可以传字符串、整数,也可以传任意对象的地址

  • 返回:不仅可以返回强转后的整数,也可以返回对象的地址

只要是指针类型,都可以作为线程的参数或返回值使用。

因此,在实际工程中,我们可以这样设计:

  1. 定义一个任务类,封装要执行的任务信息

  2. 创建任务对象,把对象地址作为参数传给线程

  3. 线程函数内通过类型转换拿回对象,执行任务

  4. 定义一个结果类,线程把执行结果封装成对象

  5. 返回结果对象地址 ,主线程通过 pthread_join 拿到结果

这样就能以对象的形式组织线程的输入和输出,提高代码的工程化和可维护性。


  • 传参注意事项:不要传栈上的局部变量

下面程序演示了一个典型的线程安全问题。

问题代码

cpp 复制代码
const int nums = 10;
void *routine(void *args)
{
    sleep(1);
    std::string name = static_cast<const char *>(args);
    std::cout << "新线程名字:" << name << std::endl;
    return nullptr;
}
int main()
{
    std::vector<pthread_t> tids;
    for (int i = 0; i &lt; nums; i++)
    {
        char buff[64] = {0};
        snprintf(buff, sizeof(buff), "thread-%d", i);
        pthread_t tid;
        int n = pthread_create(&amp;tid, nullptr, routine, (void *)buff);
        if (n != 0)
        {
            std::cerr &lt;&lt; "创建线程失败" &lt;&lt; std::endl;
            continue;
        }
        else
        {
            tids.push_back(tid);
        }
    }

    for (int i = 0; i &lt; nums; i++)
    {
        int n = pthread_join(tids[i], nullptr);
        if (n == 0)
        {
            std::cout &lt;&lt; "进程等待成功" &lt;&lt; std::endl;
        }
    }
    return 0;
}

问题分析

环节 发生了什么
循环体内 buff 始终是同一块栈空间
每轮 snprintf 覆盖 buff 内容,最后一轮是 thread-9
出循环体 空间还在,数据残留为 thread-9
pthread_create 快速完成 10 个线程都拿到了同一个地址
线程 sleep(1) 后读取 读到 buff 的最终内容 thread-9

更直观的等价写法

buff 放到 for 外面,问题就一目了然:

cpp 复制代码
char buff[64] = {0};   // 整个循环共用这一块空间
for (int i = 0; i < nums; i++)
{
    snprintf(buff, sizeof(buff), "thread-%d", i);
    pthread_create(&tid, nullptr, routine, (void*)buff);
}

无论 buff 放在 for 内还是 for 外,它本质上都是同一块栈空间,每轮循环都会覆盖前一轮的内容。

运行结果

bash 复制代码
$ ./test
新线程名字:thread-9
新线程名字:thread-9
...

所有线程都会打印 thread-9

总结

buff 是一块栈空间,被反复覆盖,循环结束时留下 thread-9。10 个线程拿到的都是同一个地址,最终全都打印 thread-9。这就是典型的**"传栈变量地址给异步任务"的 bug**。

解决方案

总结:

  • main 函数结束,代表主线程结束,一般也代表整个进程结束。

  • 新线程对应的入口函数运行结束,代表当前线程运行结束。

  • 给线程传递参数和返回值,可以是任意类型(通过 void* 传递地址,使用时再转换类型)。

3-3 获取线程 ID

原型

cpp 复制代码
#include <pthread.h>
pthread_t pthread_self(void);

编译和链接时需要加 -pthread

说明

打印出来的 tid 是通过 pthread 库中的函数 pthread_self 得到的,它返回一个 pthread_t 类型的变量,指代的是调用 pthread_self 函数的线程的"ID"。

怎么理解这个"ID"呢?

  • 这个"ID"是 pthread 库给每个线程定义的进程内唯一标识,由 pthread 库维持。

  • 由于每个进程有自己独立的内存空间,故此"ID"的作用域是进程级而非系统级(内核不认识)。

  • 实际上 pthread 库也是通过内核提供的系统调用(例如 clone)来创建线程的,而内核会为每个线程创建系统全局唯一的 ID 来唯一标识这个线程。

使用 PS 命令查看线程信息

运行代码后执行:

cpp 复制代码
$ 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
  • -L 选项:打印线程信息。

  • LWP 得到的才是真正的线程 ID。之前使用 pthread_self 得到的那个数实际上是一个地址,是虚拟地址空间上的一个地址。通过这个地址,可以找到关于这个线程的基本信息,包括线程 ID、线程栈、寄存器等属性。

ps -aL 得到的线程 ID 中,有一个线程 ID 和进程 ID 相同,这个线程就是主线程 。主线程的栈在虚拟地址空间的栈区 上,而其他线程的栈在共享区(堆栈之间)。

为什么其他线程的栈在共享区?因为 pthread 系列函数都是 pthread 库提供给我们的,而 pthread 库本身是被加载到共享区的。所以除了主线程之外的其他线程,它们的栈都在共享区。

3-4 线程终止

如果需要只终止某个线程而不终止整个进程,可以有三种方法:

  1. 从线程函数 return。这种方法对主线程不适用,从 main 函数 return 相当于调用 exit

  2. 线程可以调用 pthread_exit 终止自己。

  3. 一个线程可以调用 pthread_cancel 终止同一进程中的另一个线程。

pthread_exit 函数

功能:线程终止

原型

cpp 复制代码
void pthread_exit(void *value_ptr);

参数

  • value_ptr:线程的退出返回值。注意,value_ptr 不要指向一个局部变量。

返回值

无返回值。跟进程一样,线程结束的时候无法返回到它的调用者(自身)。

  • 需要注意,pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的 或者是malloc 分配的,不能在线程函数的栈上分配。因为当其它线程得到这个返回指针时,线程函数已经退出了,栈上空间会被回收。
  • 线程函数内使用 pthread_exitreturn 仅终止当前线程;一旦调用 exit,则终止整个进程,该进程的所有线程都会随进程一起退出。线程没有独立的进程边界,所以不能把进程级终止接口误用在"只退出线程"的场景中。
  • 在线程函数的顶层,return (void*)100pthread_exit((void*)100) 效果等价。但在更深层调用中想立即终止线程,就必须用 pthread_exit。两者的差异在于"正常返回" vs "任意位置立即退出"。

pthread_cancel 函数

功能:取消一个执行中的线程

原型

cpp 复制代码
int pthread_cancel(pthread_t thread);

参数

  • thread:线程 ID

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

被取消的线程,通过 pthread_join 拿到的退出码是 PTHREAD_CANCELED,这个宏的值通常是 (void*)-1

3-5线程等待

主线程要对自己创建的线程进行等待,否则会发生内存泄漏

为什么需要线程等待?

  • 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。

  • 创建新的线程不会复用刚才退出线程的地址空间。

pthread_join 函数

功能:等待线程结束

原型

cpp 复制代码
int pthread_join(pthread_t thread, void **value_ptr);

参数

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

调用该函数的线程将挂起等待,直到 id 为 thread 的线程终止。thread 线程以不同的方法终止,通过 pthread_join 得到的终止状态是不同的,总结如下:
例二:

cpp 复制代码
int flag=0;//共享全局变量
void*routine(void *args)
{
    std::string name=static_cast<const char *>(args);
    int cnt=5;
    while(cnt--)
    {
        std::cout<<"我是一个新线程,我的名字是:"<<name<<std::endl;
        flag++;
        sleep(1);
    }
    std::cout<<"pthread_t pthread_self(void)get:"<<pthread_self()<<std::endl;
    return (void*)100;//退出码
}
int main()
{
    pthread_t tid;
    int n=pthread_create(&tid,nullptr,routine,(void*)"thread-1");
    if(n<0)
    {
        std::cerr<<"创建线程失败"<<std::endl;
        return 1;
    }
    int cnt=5;
    while(cnt--)
    {
        std::cout<<"flag :"<<flag<<std::endl;
        sleep(1);
    }
    void* ret=nullptr;
    pthread_join(tid,&ret);
    std::cout<<"ret is :"<<(long long int)ret<<std::endl;
    std::cout<<"子线程tid:"<<tid<<std::endl;
    std::cout<<"主线程tid:"<<pthread_self()<<std::endl;
    return 0;
}

运行结果:

cpp 复制代码
xqq@ubuntu-server:~/linux/module6$ ./test
flag :0
我是一个新线程,我的名字是:thread-1
flag :1
我是一个新线程,我的名字是:thread-1
flag :2
我是一个新线程,我的名字是:thread-1
flag :我是一个新线程,我的名字是:thread-13----->数据不一致
我是一个新线程,我的名字是:thread-1
flag :5
pthread_t pthread_self(void)get:140059605132864
ret is :100
子线程tid:140059605132864
主线程tid:140059605137216

关于线程等待与异常处理

回顾之前进程退出时,有三种情况:

  1. 代码跑完,结果正确

  2. 代码跑完,结果不正确

  3. 代码没跑完,出异常

进程的 waitpid 有一个 status 字段,它复合了退出码和终止信号,能区分"正常退出"和"被信号杀死"。

但线程的 pthread_join 为什么只有一个返回值,没有类似 status 的复合字段?

原因是:

  • 线程是进程的执行分支,如果某个线程出异常(如除零、野指针),触发信号后,整个进程会退出,包括主线程。

  • 所以 pthread_join 等不到"异常退出"的线程------因为进程已经没了。

  • 因此 pthread_join 是基于"线程健康跑完"的场景设计的,它只需要拿到线程的退出码即可。

  • 异常信号是进程层面要处理的话题,不属于线程等待的范畴。

一句话:线程没有独立的异常隔离边界,线程出异常 = 进程出异常,进程直接终止。所以 pthread_join 不需要也不可能有类似 status 的复合退出信息。

图解:pthread_join 为什么需要二级指针

为什么必须用二级指针

因为 pthread_join 要修改的是指针变量本身,所以必须传指针变量的地址,也就是二级指针。
一句话:子线程返回的 (void*)100 先被 pthread 库暂存。主线程调用 pthread_join 时传入 &ret,函数内部通过 *value_ptr 把暂存的值写进主线程的 ret 变量。要修改 void* 类型的 ret,就必须传它的地址 void**

3-6 分离线程

概念

默认情况下,新创建的线程是 joinable 的(线程默认是要被等待的)。线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统泄漏。

如果不关心线程的返回值,join 是一种负担。这个时候,我们可以告诉系统:当线程退出时,自动释放线程资源,无需其他线程等待。

pthread_detach 函数

cpp 复制代码
int pthread_detach(pthread_t thread);

功能:分离一个线程。

参数

  • thread:要分离的线程 ID。

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

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:

cpp 复制代码
pthread_detach(tid);                  // 主分离:其他线程调用
pthread_detach(pthread_self());       // 自分离:线程自己调用
注意事项
  • joinable 和分离是冲突的,一个线程不能既是 joinable 又是分离的。

  • 即便线程分离了,当前这个线程依旧在进程的地址空间内。进程的所有资源,被分离线程依旧可以访问、可以操作,只是主线程不等待新线程了。

示例代码

cpp 复制代码
void *thread_run(void *arg)
{
    pthread_detach(pthread_self());   // 自分离
    printf("%s\n", (char *)arg);
    return NULL;
}
int main()
{
    pthread_t tid;
    if (pthread_create(&tid, NULL, thread_run, (void*)"thread1 run...") != 0)
    {
        printf("create thread error\n");
        return 1;
    }
    int ret = 0;
    sleep(1); // 很重要,要让线程先分离,再等待

    // pthread_detach(tid);  // 主分离

    if (pthread_join(tid, NULL) == 0)
    {
        printf("pthread wait success\n");
        ret = 0;
    }
    else
    {
        printf("pthread wait failed\n");
        ret = 1;
    }    
    return ret;
}

运行结果

bash 复制代码
xqq@ubuntu-server:~/linux/module6$ ./test
thread1 run...
pthread wait failed

可以看到,分离后的线程不能被等待 ,会自动释放资源。对它调用 pthread_join 会失败。

4. 线程 ID 及进程地址空间布局

4-1 两种"线程 ID"

pthread_create 函数会产生一个线程 ID,存放在第一个参数指向的地址中。但该线程 ID 和前面说的"线程 ID"不是一回事。

前面讲的线程 ID 属于进程调度范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。

pthread_create 第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程 ID,属于 NPTL 线程库范畴。线程库的后续操作,就是根据该线程 ID 来操作线程的。

线程库 NPTL 提供了 pthread_self 函数,可以获得线程自身的 ID:

cpp 复制代码
pthread_t pthread_self(void);

pthread_t 到底是什么类型?取决于实现。对于 Linux 目前实现的 NPTL 实现而言,pthread_t 类型的线程 ID,本质就是一个进程地址空间上的一个地址。

pthread_setname_np / pthread_getname_np

功能

函数 作用
pthread_setname_np 设置线程名称
pthread_getname_np 获取线程名称

np 表示 non-portable(非可移植),这是 Linux 特有的扩展,不是 POSIX 标准。


原型

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

int pthread_setname_np(pthread_t thread, const char *name);
int pthread_getname_np(pthread_t thread, char *name, size_t len);

参数

参数 说明
thread 目标线程 ID
name 线程名称(最多 15 个字符)
len 接收名称的缓冲区大小

返回值

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


示例

cpp 复制代码
void* routine(void* args)
{
    pthread_setname_np(pthread_self(), "worker-1");

    char name[16] = {0};
    pthread_getname_np(pthread_self(), name, sizeof(name));
    std::cout << "线程名: " << name << std::endl;
    return nullptr;
}

4-2 pthread 库与线程控制块

我们自己写的可执行程序是 ELF 可执行程序,而 pthread 库也是动态库,同样是 ELF 格式。当我们的可执行程序加载形成进程时,pthread 库会被动态链接加载到内存,并映射到当前进程的地址空间中。

这意味着,我们自己的代码可以访问到 pthread 库内部的函数和数据。也就是说,线程概念是在库中维护的

在库内部,一定会有多个创建好的线程。那么库就要对线程进行管理,怎么管理?先描述,再组织 。所以在 pthread 库内部,一定有类似 struct tcb 的结构体,用来描述线程属性:

TCB 描述内容 说明
线程状态 是否分离、是否已退出等
线程 ID pthread_t
独立栈结构 线程自己的栈空间
线程栈大小 栈空间的尺寸

其他与调度相关的属性,像优先级、时间片、上下文等,则被写到 LWP(也就是内核的 PCB / task_struct)里面。也就是说:线程概念一部分在内核层实现,一部分在用户层实现。

4-3 pthread_create 做了什么

调用 pthread_create 创建一个线程,就要在库内部 new 一个 TCB 来描述这个线程。而 pthread_t tid 其实就是线程库中对应管理块的起始虚拟地址

TCB 包含了三个核心模块:

其中,struct pthread 结构体里面包含了 void* ret 字段。将来对应子线程执行 return 等返回时,返回值就会写到对应 TCB 里 pthreadret 字段中。

子线程结束后,TCB 数据块并没有立即释放 ,所以主线程需要 pthread_join。我们要传递子线程的 tid,也就是管理块的起始地址(父子线程共享地址空间,天然可以拿到)。

于是二级指针就可以理解了:pthread_join 从对应子线程结构体的字段中,通过二级指针把退出信息带出来,就获取了子线程的退出信息。最后再将整个数据块释放,解决了内存泄漏。

这也解释了为什么看不到内存泄漏:线程结束后,内核的 LWP 会自动释放,但库里面的 TCB 没有释放,所以 ps 查不到 LWP 了,但内存泄漏实际发生在了用户态库层。

4-4 线程栈的位置

每创建一个线程,就有一个线程栈,存放于这个管理块内部。这就是为什么每个线程都有自己独立的栈空间。

  • 主线程:使用进程地址空间里的栈区

  • 子线程:使用管理块内的栈


虽然 Linux 将线程和进程不加区分地统一到了 task_struct,但对待其地址空间的 stack 还是有些区别的。

主线程(进程)的栈

对于 Linux 进程或者说主线程,简单理解就是 main 函数的栈空间。在 fork 的时候,实际上就是复制了父亲的 stack 空间地址,然后通过写时拷贝(COW)以及动态增长来实现。

如果扩充超出该上限,则栈溢出,会报段错误(发送段错误信号给该进程)。进程栈是唯一可以访问未映射页而不一定会发生段错误的------超出扩充上限才报。

子线程的栈

然而对于主线程生成的子线程而言,其 stack 将不再是向下生长的,而是事先固定下来的

线程栈一般是调用 glibc/uclibc 等的 pthread 库接口 pthread_create 创建的线程,位于文件映射区(或称之为共享区)。其中使用 mmap 系统调用,这个可以从 glibc 的 nptl/allocatestack.c 中的 allocate_stack 函数中看到:

cpp 复制代码
mem = mmap(NULL, size, prot,
           MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);

此调用中的 size 参数的获取很是复杂,可以手工传入 stack 的大小,也可以使用默认的,一般而言就是默认的 8M。这些都不重要,重要的是:

这种 stack 不能动态增长,一旦用尽就没了 ,这是和生成进程的 fork 不同的地方。

在 glibc 中通过 mmap 得到了 stack 之后,底层将调用 sys_clone 系统调用:

cpp 复制代码
int sys_clone(struct pt_regs *regs)
{
    unsigned long clone_flags;
    unsigned long newsp;
    int __user *parent_tidptr, *child_tidptr;

    clone_flags = regs->bx;
    // 获取了 mmap 得到的线程的 stack 指针
    newsp = regs->cx;
    parent_tidptr = (int __user *)regs->dx;
    child_tidptr = (int __user *)regs->di;
    if (!newsp)
        newsp = regs->sp;
    return do_fork(clone_flags, newsp, regs, 0, parent_tidptr, child_tidptr);
}
主线程栈 vs 子线程栈
注意事项

对于子线程的 stack,它其实是在进程的地址空间中 map 出来的一块内存区域 。原则上是线程私有的,但是同一个进程的所有线程生成的时候,会浅拷贝生成者的 task_struct 的很多字段。如果愿意,其它线程也还是可以访问到的(拿虚拟地址访问,但其他线程不知道它的存在),于是一定要注意。

4-5 pthread_create 的两部分工作

pthread_create 函数的工作是:

  1. 在库内部创建线程控制管理块(TCB)

  2. 在内核中创建轻量级线程 ,即调用系统调用 clone

clone 的原型是:

cpp 复制代码
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
          /* pid_t *parent_tid, void *tls, pid_t *child_tid */ );

对应关系:

当 CPU 调度到这个轻量级进程时,就会去执行用户自定义方法(fn)。同时,函数执行过程中形成的临时数据会自动入栈到用户传入的栈结构里。于是内核数据和用户数据在一定程度上就联动起来了。

4-6 用户级线程的理解

我们在 Linux 下的用户级线程,意思就是:线程实现是在库里的,而库映射到用户空间的 0~3GB,用户拿到虚拟地址可以直接访问。

4-7 线程分离的原理

所谓的线程分离,原理就是在我们的线程控制块里面有一个线程状态字段,比如:

cpp 复制代码
int joinable;   // 默认为 1,表示当前线程没有被分离
  • 默认情况下为 1,表示当前线程是 joinable 的,必须被 pthread_join

  • 如果调用 pthread_detach,就会将该字段设置为 0

一旦识别到线程退出,读取该字段:

  • 如果为 0(已分离),就自动将线程控制块释放

  • 如果为 1(未分离),就等待 pthread_join 来释放

4.8线程局部存储(TLS)

之前说创建线程时,会在库里面创建描述线程的对应结构体 TCB。该结构体我们已经了解了 struct pthread 和线程栈,但其中还有一个重要组成部分------线程局部存储(TLS)

概念

线程局部存储(Thread Local Storage,TLS)是一种机制:允许每个线程拥有自己变量的独立副本。这些变量在每个线程中独立存在、互不影响。

这种机制确保了线程数据的独立性,从而避免了全局变量或静态变量在并发环境下产生竞态条件和数据不一致的问题。

优点

优点 说明
数据隔离 每个线程独立访问自己的副本,互不影响
减少同步开销 不需要加锁保护
提高性能 避免锁竞争,提升并发效率

__thread 关键字

用于声明线程局部存储变量。使用 __thread 关键字声明的变量,在每个线程中都有一个独立的副本,这些副本互不影响,有助于避免线程间的竞态条件或数据不一致问题,提高线程安全性。

语法

bash 复制代码
__thread 数据类型 变量名;

实验对比

cpp 复制代码
int count = 0;
// __thread int count = 0;// 可切换为线程局部存储

std::string Addr(int &c)
{
    char buff[64] = {0};
    snprintf(buff, sizeof(buff), "%p", &c);
    return buff;
}

void *Routine1(void *args)
{
    (void)args;
    while (true)
    {
        std::cout << "(修改+打印) thread--1, count=" << count
                  << " Addr1:" << Addr(count) << std::endl;
        count++;
        sleep(1);
    }
}

void *Routine2(void *args)
{
    (void)args;
    while (true)
    {
        std::cout << "(只打印) thread--2, count=" << count
                  << " Addr2:" << Addr(count) << std::endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid1, tid2;
    pthread_create(&tid1, nullptr, Routine1, nullptr);
    pthread_create(&tid2, nullptr, Routine2, nullptr);

    sleep(5);
    return 0;
}

全局变量版本运行结果

cpp 复制代码
(只打印) thread--2, count=0 Addr2:0x55818ab73154
(修改+打印) thread--1, count=0 Addr1:0x55818ab73154
(只打印) thread--2, count=1 Addr2:0x55818ab73154
(修改+打印) thread--1, count=1 Addr1:0x55818ab73154
(只打印) thread--2, count=2 Addr2:0x55818ab73154
(修改+打印) thread--1, count=2 Addr1:0x55818ab73154
(只打印) thread--2, count=3 Addr2:0x55818ab73154
(修改+打印) thread--1, count=3 Addr1:0x55818ab73154

__thread 版本运行结果

cpp 复制代码
(修改+打印) thread--1, count=0 Addr1:0x7fe77579d63c
(只打印) thread--2, count=0 Addr2:0x7fe774f9c63c
(修改+打印) thread--1, count=1 Addr1:0x7fe77579d63c
(只打印) thread--2, count=0 Addr2:0x7fe774f9c63c
(修改+打印) thread--1, count=2 Addr1:0x7fe77579d63c
(只打印) thread--2, count=0 Addr2:0x7fe774f9c63c
(修改+打印) thread--1, count=3 Addr1:0x7fe77579d63c
(只打印) thread--2, count=0 Addr2:0x7fe774f9c63c

现象分析

我们发现,使用 __thread 修饰后,两个线程打印的虚拟地址不一样了。很明显,两个线程不再共享同一块空间(已初始化数据段)。被修饰的 count 叫做线程的局部存储------也就是说,原本在共享地址空间中的全局变量,变成了存储在每个 TCB 里的线程局部存储,各自拷贝一份。名字相同,但并不是同一个变量。

用途

有时我们希望变量在每个线程中都有一份,但又不希望这个全局变量被其他线程看到,此时就可以使用线程局部存储。

注意:__thread 只能修饰内置类型(如 intfloat 等),不能修饰自定义类类型。

5.线程封装

Main.cc

cpp 复制代码
#include "Thread.hpp"

// int main()
// {
//     ThreadModule::Thread t([]()
//                            {
//         while (true)
//         {
//             std::cout<<"我是一个新线程"<<std::endl;
//             sleep(1);
//         } });
//     void* ret=nullptr;
//     t.Detach();
//     t.Start();
//     sleep(5);

//     t.Stop();
//     sleep(5);

//     t.Join(&ret);
//     std::cout<<(long long)ret<<std::endl;

//     return 0;
// }

void Routine(int count)
{
    while (count--)
    {
        std::cout << "我是一个新线程" << std::endl;
        sleep(1);
    }
}
int main()
{
    int cnt = 10;
    ThreadModule::Thread<int> t(Routine, cnt);
    void* ret=nullptr;
    t.Start();
    t.Join(&ret);
    std::cout<<(long long)ret<<std::endl;
    return 0;
}

Thread.hpp

cpp 复制代码
#ifndef _THREAD_H_
#define _THREAD_H_
#include <iostream>
#include <pthread.h>
#include <string>
#include <cstring>
#include <cstdio>
#include <functional>
#include <unistd.h>
namespace ThreadModule
{
    static uint32_t number = 1;
    template <class T>
    class Thread
    {
        using func_t = std::function<void(T)>;

    public:
        Thread(func_t func,T data)
            : _tid(0), _isdetach(false), _isrunning(false), _func(func),_data(data)
        {
            _name = "thread - " + std::to_string(number++);
        }

        ~Thread()
        {
        }

        void Detach()
        {
            if (_isdetach)
                return;
            if (_isrunning)
            {
                pthread_detach(_tid);
            }
            EnableDetacch();
        }

        bool Start()
        {
            if (_isrunning)
                return false;
            int n = pthread_create(&_tid, nullptr, Routine, this);
            if (n > 0)
            {
                std::cerr << "create thread fail,the err is:" << strerror(n) << std::endl;
                return false;
            }
            // EnableRunning();放到 Routine 里
            // if (_isdetach)
            // {
            //     Detach();
            // }
            else
            {
                std::cout << _name << "create success" << std::endl;
            }
            return true;
        }

        bool Stop()
        {
            if (_isrunning)
            {
                int n = pthread_cancel(_tid);
                if (n != 0)
                {
                    std::cerr << "pthread_cancel err:" << strerror(n) << std::endl;
                    return false;
                }
                else
                {
                    std::cout << "pthread_cancel:" << _name << "success" << std::endl;
                }
                _isrunning = false;
            }

            return true;
        }

        bool Join(void **retval)
        {
            if (_isdetach)
            {
                std::cout << "线程为分离状态,不能join" << std::endl;
                return false;
            }

            int n = pthread_join(_tid, retval);
            if (n != 0)
            {
                std::cerr << "pthread_join err:" << strerror(n) << std::endl;
                return false;
            }
            _isrunning = false; // 线程已退出
            std::cout << "pthread_join success" << std::endl;
            return true;
        }

    private:
        pthread_t _tid;
        std::string _name;
        bool _isdetach;
        bool _isrunning;
        func_t _func;
        T _data;
        void EnableDetacch()
        {
            _isdetach = true;
        }
        void EnableRunning()
        {
            _isrunning = true;
        }
        //"void *(ThreadModule::Thread::*)(void *args)" 类型的实参与 "void *(*)(void *)" 类型的形参不兼容
        // 这个意思就是由于我们在类内定义成员函数,成员函数有隐藏的 this 指针,类型不匹配。
        // 解决方法是把 Routine 改成 static,再通过 pthread_create 的第四个参数把 this 传进去,在 Routine 里转回对象指针。这是 C++ 封装 pthread 的标准做法
        // void *Routine(void*args)
        static void *Routine(void *args) // static成员函数没有this指针也就无法访问当前成员对应成员属性
        {
            Thread<T> *Self = static_cast<Thread<T> *>(args);

            Self->EnableRunning(); // 先标记运行状态

            if (Self->_isdetach)
            {
                Self->Detach(); // 任务执行前先分离
            }

            Self->_func(Self->_data); // 最后执行任务(进行回调)
            Self->_isrunning = false; 
            return nullptr;

            // 放到 Routine 里,是为了让"线程状态更新"和"分离检查"
            // 发生在子线程自己的执行流中,而不是主线程创建时。这样状态更准确,
            // 时序更符合直觉,也减少了主线程和子线程之间的竞态。
        }
    };
}

#endif

*附录:源码阅读,理解线程

module6/Thread · 光电笑映/Linux - 码云 - 开源中国

相关推荐
qinzechen1 小时前
本周科技行业热点汇总·2026第36周(2026年8月31日-9月6日)
c++·科技·算法
wzg20161 小时前
本地部署Deepseek-coder + Vscode + Continue 插件(3)启动与关闭deepseek-coder大模型
linux·运维·服务器
凯哥Java1 小时前
接口采集工具自动化验证文章(测试发布,可删除)
运维·自动化
aqiu1111111 小时前
【C++ 代码分析与重构】常见 DFS 逻辑错误解析与修正
c++·重构·深度优先
H_oRIZoN_2 小时前
Linux入门DAY37(IO 多路复用、TCP 并发服务器与数据库 SQLite3 详解)
linux·服务器·数据库
zuozong_2 小时前
C++类与对象
开发语言·c++·算法
hansang_IR2 小时前
【代码】分层最短路模板
c++·算法·最短路
新时代牛马2 小时前
Linux 块设备驱动完整篇:从gendisk、submit_bio 到blk-mq queue_rq 与排障
linux·运维·服务器
暖焰核心2 小时前
C++模板进阶——特化全解
javascript·c++·jquery