线程概念与控制

线程概念与控制

文章目录

  • 线程概念与控制
    • [1. Linux 线程概念](#1. Linux 线程概念)
      • [1.1 什么是线程](#1.1 什么是线程)
      • [1.2 分页式存储管理](#1.2 分页式存储管理)
        • [1.2.1 虚拟地址和页表的由来](#1.2.1 虚拟地址和页表的由来)
        • [1.2.2 物理内存管理](#1.2.2 物理内存管理)
        • [1.2.3 页表](#1.2.3 页表)
        • [1.2.4 页目录结构](#1.2.4 页目录结构)
        • [1.2.5 页表项](#1.2.5 页表项)
        • [1.2.6 地址转换](#1.2.6 地址转换)
        • [1.2.7 TLB](#1.2.7 TLB)
        • [1.2.8 缺页异常](#1.2.8 缺页异常)
      • [1.3 线程的优点](#1.3 线程的优点)
      • [1.4 线程的缺点](#1.4 线程的缺点)
      • [1.5 线程 VS 进程](#1.5 线程 VS 进程)
    • [2. Linux 线程控制](#2. Linux 线程控制)
      • [2.1 POSIX 线程库](#2.1 POSIX 线程库)
        • [2.1.1 创建线程](#2.1.1 创建线程)
        • [2.1.2 终止线程](#2.1.2 终止线程)
          • [2.1.2.1 pthread_exit](#2.1.2.1 pthread_exit)
          • [2.1.2.2 pthread_cancel](#2.1.2.2 pthread_cancel)
        • [2.1.3 等待线程](#2.1.3 等待线程)
        • [2.1.4 分离线程](#2.1.4 分离线程)
        • [2.1.5 补充 --- 从内核机制到用户接口:线程的抽象层次](#2.1.5 补充 --- 从内核机制到用户接口:线程的抽象层次)
    • [3. 线程 ID 及地址空间布局](#3. 线程 ID 及地址空间布局)
    • [4. 线程封装](#4. 线程封装)

1. Linux 线程概念

1.1 什么是线程

  1. 线程是什么?

    • 核心定义: 线程是进程内部的一个独立的执行序列(执行流) 。它是操作系统进行 CPU 调度的基本单位
  2. 线程与进程的关系:

    • 包含关系: 线程存在于进程之中。一个进程至少包含一个线程(主线程),也可以包含多个线程
    • 资源分配:
      • 进程: 是操作系统分配和拥有系统资源(如内存地址空间、文件描述符、I/O 设备、信号等)的基本实体。进程创建时,操作系统为其分配这些资源。
      • 线程: 共享 其所属进程的绝大部分资源 (内存空间、打开的文件、全局变量等)。线程自身主要拥有少量私有资源,用于维持其独立的执行流:
        • 线程ID (Thread ID)
        • 程序计数器 (PC): 记录当前执行指令的位置
        • 寄存器集合: 存储线程当前的计算状态
        • 栈 (Stack) : 用于存储局部变量、函数调用参数和返回地址。每个线程拥有自己独立的栈空间
    • 注意:
      • 进程: 承担分配系统资源的责任。它是资源管理的容器
      • 线程: 承担执行代码和 CPU 调度的责任 。它是 CPU 实际工作的最小单位。操作系统的调度器决定哪个线程在哪个 CPU 核心上运行、运行多久
  3. 不同操作系统视角的实现差异:

    • 内核数据结构:

      • Windows: 明确区分了进程和线程的数据结构
        • 进程 (Process): 对应 EPROCESS (Executive Process) 块(可理解为高级 PCB),主要负责资源管理(地址空间、句柄表等)
        • 线程 (Thread): 对应 ETHREAD (Executive Thread) 块(可理解为 TCB),主要负责执行状态(寄存器、栈、优先级等)。每个线程有独立的 TCB
      • Linux: 采用更统一的设计理念,"轻量级进程 (Light Weight Process, LWP) 是线程在内核的表示
        • Linux 的设计哲学是:既然线程共享进程的绝大部分资源,那么在内核表示执行单元时,就不需要为线程单独定义一套与进程几乎平行的、包含大量重复字段(如内存映射、文件表)的数据结构。一个 task_struct 既能表示进程(资源独立),也能表示线程(资源共享),通过指针指向共享的资源结构体(如 mm_struct)来实现。这极大地简化了内核设计,提高了效率(创建线程开销接近创建进程开销,但远小于 Windows 创建线程开销),历史证明这种设计是高效且成功的
        • 内核使用一个核心数据结构 task_struct 来管理所有执行上下文 。无论是进程还是线程,在内核看来都是一个 task_struct 实例
        • 关键区别在于资源共享
          • 同一个进程下的多个线程(即多个 task_struct)会共享 同一个内存地址空间 (mm_struct)、文件描述符表、信号处理等资源
          • 不同进程下的 task_struct 则拥有各自独立的资源
    • 硬件 (CPU) 视角:

      • 无论操作系统如何抽象,CPU 核心最终执行的都是指令序列。它看到的是一个个需要执行上下文的 "任务"
      • 线程 (包括 Linux LWP) 在 CPU 看来就是一个个独立的、轻量级的执行上下文CPU 根据操作系统的调度切换这些上下文(保存/恢复寄存器、栈指针、PC 等)

总结:线程的初步认识

  1. 线程是执行流: 它是程序代码执行的一条独立路径,是 CPU 调度的最小单位
  2. 线程属于进程: 线程在进程内部创建和运行。一个进程包含一个或多个线程
  3. 共享资源: 同进程下的线程共享进程的内存空间、文件、信号等系统资源。这是实现高效协作的基础,但也带来了同步(如锁)的复杂性
  4. 私有资源: 每个线程拥有自己独立的线程ID、程序计数器、寄存器集合和栈空间,用于维持其独立的执行状态
  5. 核心分工:
    • 进程 = 资源所有者: 操作系统分配资源(内存、文件等)的基本单位
    • 线程 = 执行工作者: 操作系统分配 CPU 时间片、进行调度的基本单位
  6. 操作系统实现差异:
    • Windows: 明确区分 PCB (EPROCESS) 和 TCB (ETHREAD)。进程管理资源,线程负责执行
    • Linux: 采用统一的任务模型 (task_struct)。线程被视为共享资源的轻量级进程 (LWP)

1.2 分页式存储管理

在前面提到过页表的概念,不过都只是说页表是用作虚拟地址和物理内存之间的映射,在前面的部分,有这样的认识足够了,在线程部分,需要对资源有更加系统的认识,离不开对页表的理解

1.2.1 虚拟地址和页表的由来

想象一下没有虚拟内存和分页机制

的情况下(早期操作系统确实是这样的)

  • 程序员编写的程序(编译链接后)直接使用物理内存地址
  • 程序必须精确知道它将被加载到内存的哪个物理位置(起始地址)
  • 程序中的所有地址(指令跳转地址、数据访问地址)都是绝对的物理地址

这对于编程的难度就骤然提高了不少,并且会有一系列的问题,如果程序A正在运行,程序B想启动,但当前没有足够大的连续空闲块 容纳B,即使总空闲内存比B大也不行(外部碎片),B只能等待或无法运行、程序的大小不能超过物理内存容量、一个程序的崩溃(如访问非法地址)极容易导致整个系统崩溃......😱

问题就是------极其复杂且易错

要是能将物理内存不连续的加载,并且使用的时候却是连续就好了,这时候,虚拟内存和页表就出现了

虚拟地址空间:给每个进程一个 "美好的幻觉"

  • 核心: 操作系统为每个进程 提供一个独立的、私有的、巨大的、从 0 开始的连续 地址空间。这就是虚拟地址空间
  • 程序员视角: 程序员编写程序时(编译链接后生成的可执行文件),使用的地址都是相对于这个虚拟空间起始地址 0 的偏移量(逻辑地址/虚拟地址)。程序员感觉自己在独占整个(巨大的)地址空间(本质是进程认为自己拥有一个连续的大内存块)

这样就不需要再关心物理内存的碎片问题,并且编程的难度也降低了不少

但是如何实现虚拟 ↔ 物理的转换呢?答案就是页表

在物理内存 (RAM)中,划分单元是页框 (Page Frame);而在虚拟地址空间中,划分单元则是页 (Page)。两者的本质都是划分的单元

  • 页框本身是连续的,但分配给不同进程的页框(物理地址不连续)
  • 页在虚拟地址空间中连续编号
  • 页框存储实际数据(可能是任何进程的页)
  • 页储进程的代码、数据或堆栈
  • 操作系统内核使用页框
  • 进程使用页(通过操作系统的页表映射)

有了这种机制,CPU 便不用直接访问物理内存地址,而是直接通过虚拟地址空间间接访问物理内存地址,操作系统通过虚拟地址空间和物理内存地址之间建立映射关系,在页表中维护这样的关系

1.2.2 物理内存管理

物理内存在操作系统中会被分割成一个个固定长度的页框,比如说一个可用的 4GB 的物理内存,按照一个页框的大小是 4KB 来划分,那么就会有 4GB / 4KB = 1048576 个页框,操作系统管理这些页框的内核数据结构是 struct page

c 复制代码
// include\linux\mm.h
struct page {
	unsigned long flags;    // 存储页的状态和属性位
	atomic_t _count;    // 表示页的 引用计数或使用计数
	atomic_t _mapcount; //  记录该物理页被映射到进程页表中的次数(即有多少个页表项 PTE 指向这个物理页)
	union {
	    struct {
		unsigned long private;
		struct address_space *mapping;

	    };  // 联合体(Union),意味着里面的字段共享同一块内存空间 。具体使用哪个字段取决于页的上下文(由 flags 中的标志位指示)
#if NR_CPUS >= CONFIG_SPLIT_PTLOCK_CPUS
	    spinlock_t ptl;
#endif
	};
	pgoff_t index  // 表示该页在所属映射(mapping)中的偏移量(以页为单位);
	struct list_head lru;   // 链表头 ,用于将该页链接到 内存回收(Memory Reclaim)子系统管理的各种 LRU(Least Recently Used)链表上

#if defined(WANT_PAGE_VIRTUAL)
	void *virtual;  // 存储该物理页对应的 内核虚拟地址(Kernel Virtual Address, KVA)
#endif
};

其中比较重要的字段有:

  1. unsigned long flags;
    • 特点: "Atomic flags",意味着这些标志位的设置/清除操作需要是原子的(防止并发访问导致状态不一致)
    • 内容: 使用预定义的宏(如 PG_locked, PG_dirty, PG_uptodate, PG_lru, PG_active等)来表示页当前的状态(例如:是否被锁定、是否脏、是否在 LRU 链表上、是否属于 slab 分配器、是否在交换缓存中、是否是大页的一部分等)
    • 内核通过检查这些标志位来快速确定页的当前状态并做出相应操作(如回收、刷新到磁盘等)
  1. atomic_t _mapcount;

    • 作用: 记录该物理页被映射到进程页表(Page Tables)中的次数
    • 解释:
      • _mapcount = -1:该页没有被任何页表映射(可能是匿名页刚分配但还未映射,或是页缓存中的页未被任何进程映射访问)
      • _mapcount = 0:该页被精确地一个页表映射(通常是私有的匿名页或共享库的私有数据映射)
      • _mapcount > 0:该页被多个页表映射(例如,共享内存 SHM、文件映射 mmap 被多个进程共享、或 KSM 合并的页)
  2. void *virtual; (条件编译 #if defined(WANT_PAGE_VIRTUAL))

    • 作用: 存储该物理页对应的内核虚拟地址(Kernel Virtual Address, KVA)
    • 解释:
      • 通常情况下,其就是页在虚拟内存中的地址,有些内存(比如高端内存)并不永久的映射到内核地址空间上,这种情况下,其值位 NULL,需要的时候,动态映射

在64位系统下,粗略计算一下struct page 的大小(具体值取决于内核配置和架构),flags(8) + _count(4) + _mapcount(4) + 填充(4) + union(16) + index(8) + lru(16) = 56字节,算60字节的话。假定操作系统的页框大小为 4KB,物理内存为 4GB,那么有 1048576 个页框,最终管理这些 page 结构体只需要 1048576 × 60 = 62914560 字节 = 60MB 。对于 4GB 的系统而言,是很小的一部分,所以,管理这些物理页面的代价并不是太大

要注意,页框的大小对于内存利用和系统开销来说很重要

  • 页框太大,页框内必然会剩下很多不能利用的空间,造成页面碎片,本质上是程序需求页框尺寸 的 "空隙 " 更大
    • "碎片"特指 内部碎片(Internal Fragmentation),而非外部碎片(分页机制已解决外部碎片)
  • 页框太小,虽然可以减少页面碎片,但是页框太多,会导致页表太长而占用内存,并且,系统频繁地进行页转化,加重系统开销

因此,页的大小应该适中,通常为 512B ~ 8KBWindows/Linux 系统的页框大小为 4KB(实践证明,4KB 非常合适)

谈完物理内存的管理,下面我们来看一下虚拟地址空间的管理

1.2.3 页表

页表(Page Table)本质是一种数据结构,用于操作系统内存管理虚拟地址空间物理内存的地址映射就由它完成。分页的机制也离不开页表

在虚拟地址空间中,划分的单元是页 (Page),众多单元就组成了进程使用的虚拟地址空间,也就是程序看到的内存空间。页的大小通常是 4KB(常见于 x86/x86-64 架构),但也可能是其他值(如 2MB, 1GB 的大页)

32 位系统下,虚拟内存的最大量空间是 4GB,每个程序都拥有这样的地址空间,页表要能够表示这么多的空间,就需要有 4GB / 4KB = 1048576 个页表项,如图所示

  • 看上去,虚拟内存被虚线 "分割" 成一个个单元,其实不是真的分割,虚拟内存仍然是连续的。图中的虚线表示的是对应大小的单元与页表中的每一个表项的映射关系,最终映射到物理内存页上
  • 页表中的物理地址,与物理内存之间,是随机映射 的,哪里有可用的就只想哪里的物理页。这样使用的物理内存是离散的 ,但是与虚拟内存对应的线性地址是连续的
  • 处理器在访问数据、获取指令时,使用的都是线性地址,所以也就相当于程序使用的都是连续的,因为只要能够通过页表找到实际的物理地址就没问题

现在再来看当初为什么使用页表,最开始的时候我们希望将物理内存不连续的加载,使用的时候却是连续,但是现在还是没有绕开 "连续" 🤔

  • 32 位系统下,地址的长度是 4 个字节,那么页表中的每一个表项就占用 4 字节,所以页表占据的总空间大小是 1048576 × 4 = 4MB
  • 在虚拟内存中以页为单位划分,存储进程的代码和数据,就可以不用连续的存放在物理内存中,因为不连续的物理地址已经被页表连续的做映射了
  • 但是页表自身作为一个巨大的映射表,本身也需要一大块连续的物理内存来存放,这就 "遗传" 了它本想解决的问题(对连续大内存的需求)
  • 这也就意味着,每个新进程创建时,操作系统都需要立刻为其分配一块连续的4MB物理内存来存放页表

这就导致一个进程可能只使用了其 4GB 虚拟空间中的几 MB 代码和数据(比如一个hello world程序),但它却需要占用完整的 4MB 物理内存来存放一个几乎全是 "无效条目" 的页表

因此,解决大容量页表最好的方法就是:将一个大而连续的线性页表,拆分成一个树状的、层次化的结构,并且只分配那些实际需要的部分,由此形成多级页表 ,比如说将单一页表拆分成 1024 个体积更小的映射表,如下图,这样就可以,1024(页表) × 1024(页表项) × 1024×4(页表项大小),就可以覆盖 4GB 的物理内存空间

其中有 1024 个页表,一个页表本省占用 4KB1024 个页表就占用 4MB 的物理内存空间,和之前没啥区别? 🤔 (因为我们并没有减少页表的数量,重点在我们将空间分层了,就像是建立了一个索引)

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

计算过程:

每一个页表项指向一个 4KB 的物理页,一个页表中有 1024 个页表项,一共能覆盖 4KB × 1024 = 4MB 的物理内存空间

那么 10MB 的程序,对齐一下就是 12MB,三个页表就可以满足了

1.2.4 页目录结构

前面介绍了多级页表,下面看看多级页表是如何管理起来的

  • 图中管理页表的表被称为页目录表
  • 页目录的物理地址被 CR3 寄存器指向,这个寄存器保存的是当前正在执行任务的页目录地址
  • 页目录中的表项 指向的是页表的物理地址

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

1.2.5 页表项

当然!页表项(Page Table Entry, PTE) 是页表中最基本的组成单元,可以把它理解为页表这条"地址翻译目录"中的一个"词条"或"一行记录"

每个页表项都负责记录一个虚拟内存页的映射信息和状态信息

一个页表项通常是一个 32 位或 64 位的数字,它包含以下几个关键部分:

1. 物理页框号(Physical Frame Number, PFN)

  • 这是页表项最核心、最重要的信息
  • 作用: 它指明了这个虚拟页当前被映射到了哪个物理页框
  • 工作原理: CPU 将虚拟地址拆分为虚拟页号 + 页内偏移。用虚拟页号找到页表项,取出其中的物理页框号,然后与页内偏移组合,就得到了最终的物理地址

2. 状态位和控制位(Flags/Bits)

操作系统和 CPU 硬件可以利用这些位管理和保护内存

  • 有效位/存在位(Present bit, P) (第 0 位)

    • 作用: 指示该虚拟页当前是否在物理内存中
    • 1: 该页已加载到物理内存中,映射有效
    • 0缺页(Page Fault) !该页不在物理内存中,可能被换出到磁盘的交换空间了。此时会触发一个中断,由操作系统负责将其从磁盘读入物理内存,然后重新设置该位为 1
  • 读写位(Read/Write bit, R/W) (第 1 位)

    • 作用: 控制对该页的访问权限
    • 0: 只读。尝试写入会引发权限错误
    • 1: 可读可写
    • 用途: 保护代码段(如程序的指令)不被意外修改,实现内存保护
  • 用户/管理位(User/Supervisor bit, U/S) (第 2 位)

    • 作用: 控制访问该页所需的CPU特权级
    • 0: 管理模式(Supervisor mode),通常只有操作系统内核可以访问
    • 1: 用户模式(User mode),应用程序可以访问
    • 用途: 防止用户程序访问或破坏内核数据
    • 注意: 并不直接等于内核态/用户态的,内核态与用户态是 CPU 执行应用程序代码时的状态(但两者是协同工作的,此处不展开)
  • 访问位/引用位(Accessed bit, A) (第 5 位)

    • 作用: 记录该页是否被访问过(读或写)
    • 用途: 帮助操作系统判断哪些页是 "活跃" 的,优先淘汰不常访问的页
  • 脏位(Dirty bit, D) (第 6 位)

    • 作用: 记录该页自从被调入内存后,是否被写入过
    • 0: 该页内容与磁盘上保持一致(是 "干净的")
    • 1: 该页已被修改,内容比磁盘上的新(是 "脏的")
    • 用途: 当操作系统需要淘汰这个页以腾出空间时,如果脏位为 1,则必须先将该页内容写回磁盘;如果为 0,直接丢弃即可(因为磁盘上有副本),这提升了性能
  • 其他位: 还可能包括用于缓存策略的位、用于支持物理地址扩展(PAE)或更大地址空间的位等

1.2.6 地址转换

下面以一个逻辑地址为例,将地址 0000 0000 00,0000 0000 01,1111 1111 1111,以 32 位系统为例,4KB 的页大小

  • 最高10位 - 页目录索引(PDI)
  • 中间10位 - 页表索引(PTI)
  • 低12位 - 页内偏移(Offset)

地址转换过程

  1. MMUCR3 寄存器找到页目录的物理基地址
  2. 用虚拟地址的高 10 位(页目录索引)作为索引,在页目录中找到对应的目录项
  3. 目录项中含有下一级页表的物理地址
  4. 用虚拟地址的中间 10 位(页表索引)作为索引,在页表中找到最终的页表项
  5. 页表项中含有物理页框号,与偏移量组合得到物理地址
  • 物理内存中每个页框的起始地址必须是4KB 的整数倍,这是一个硬性规定
    • 所以它的最低12位二进制位永远是0 (4KB = 4096 = 2¹²)
    • 既然这 12 位永远是 0,就没有必要在页表项中浪费空间存储它们。页表项只需存储其高20位 的有效部分,这 20 位被称为物理页框号
    • 但是低 12 位并不是一无是处,也就是前面页表项中 介绍的控制位和状态位
  • 所以说,计算物理地址时,只需要做"拼接" 即可,将页表项中的 20 位物理页框号直接放在高 20 位,将虚拟地址中的 12 位偏移量直接放在低 12 位,就组合成了 32 位的物理地址,速度非常快

图中其实就是 MMU(Memory Manage Unit)的工作流流程,它是一个通常集成在 CPU 内部的专用硬件电路(有时也在单独的芯片上),速度很快,主要核心职责是负责 CPU 所发出内存地址的转换和管理

1.2.7 TLB

引入多级页表会不会有什么问题呢?肯定是有的,MMU 在进行一次地址转换时,需要先进行多次内存访问来查询页表。在标准的 32 位四级页表系统中,转换一个虚拟地址需要四次内存访问:

  1. 访问页目录表以获取页目录项
  2. 访问页中间目录表以获取页中间目录项
  3. 访问页表以获取页表项
  4. 最终,才能访问物理内存目标地址以获取数据

这变成了 N 次页表查询 + 1 次数据读写 。虽然内存控制器和 CPU 缓存会进行一些优化,但理论上,页表级数越多,查询的步骤就越多,MMU 需要等待内存访问的时间就越长,整体效率就会下降

至此,我们面临的问题可以总结为:

  • 单级页表 :对物理内存的连续性要求高,空间效率低,但转换速度快(一次访问)
  • 多级页表 :极大提升了空间效率,降低了对连续内存的要求,但牺牲了时间效率(多次访问)

与解决单级页表问题的思路一脉相承,计算机体系结构中的另一个经典解决方案再次应验:"几乎所有问题都可以通过增加一个间接中间层来解决"

为此,MMU 中引入了一个专用的高速缓存,用于存储最近使用过的地址转换结果。这个中间层就是人称 "快表"TLB(Translation Lookaside Buffer)

TLB 是集成在 CPU 内存管理单元(MMU)中的一块小型的高速缓存 。它的唯一目的就是缓存最近使用过的虚拟页号(Virtual Page Number, VPN, 前20位)物理页框号(Physical Frame Number, PFN) 的映射关系,以及对应的权限位

CPU 需要转换一个虚拟地址时,MMU 的工作流程变为下面这样:

  1. TLB 查询MMU 首先将虚拟地址中的 虚拟页号 部分发送给 TLB
  2. TLB 命中(TLB Hit) :如果 TLB 中缓存了该 虚拟页号 对应的映射,则立刻返回 物理页框号和权限位 。随后,MMU 将物理页框号与偏移量组合成物理地址。整个过程仅需一个时钟周期,极其高效
  3. TLB 缺失(TLB Miss) :如果 TLB 中没有找到对应的映射,MMU 就必须进行多次内存访问的 "页表遍历" 过程。在得到 PFN 后,除了完成地址转换,还会将这个新的 VPN-PFN 映射关系载入 TLB 中,以备下次使用
1.2.8 缺页异常

现在回过头重新理解缺页异常,当 CPU 试图访问一个虚拟地址,而该地址对应的页表项(PTE)中的 "存在位"(Present Bit)0,表明其对应的物理页面当前不在物理内存(RAM) 中时,就会由硬件(MMU)触发一个缺页异常

此时,CPU 会暂停当前用户程序的执行,保存现场,并从用户态切换到内核态 ,将控制权交给操作系统内核中一个专门的例程------缺页异常处理程序(Page Fault Handler)

Page Fault Handler 会根据缺页异常发生的具体原因(存储在 CPU 的特定寄存器中)进行不同的处理。这些原因主要分为三种类型:

  • 主要缺页(Major Page Fault / Hard Page Fault)

    • 原因 :请求的页面不在物理内存,也不在文件系统缓存 中,必须从磁盘 (如交换分区swap、可执行文件或内存映射文件)中加载
  • 次要缺页(Minor Page Fault / Soft Page Fault)

    • 原因 :请求的页面不在当前进程的页表映射中,但已经在物理内存的其他地方 (如文件系统缓存,或因fork()而与其他进程共享)。处理程序只需建立新的页表映射即可
  • 无效缺页(Invalid Page Fault)

    • 原因 :程序访问了一个非法的虚拟地址(如访问未分配的内存、空指针解引用、或试图写只读页面)
    • 注意 :无法恢复。处理程序通常会向引发异常的进程发送一个 SIGSEGV(段错误)信号,默认情况下会终止该进程

1.3 线程的优点

对资源有了基本的认识之后,回到线程部分,来看看线程有哪些优点

线程作为"轻量级进程",其所有优点都源于一个最根本的特性:同一个进程内的多个线程共享大部分进程资源,尤其是内存地址空间

1. 低开销的创建与销毁

  • 原因:创建新线程无需分配新的内存地址空间、无需初始化页表、无需重建大部分内核数据结构(如文件描述符表)。线程主要独立拥有的是其执行上下文(如栈、寄存器状态)
  • 结果:线程的创建和销毁速度比进程快一个数量级,允许程序更频繁地执行并发任务

2. 极高效的上下文切换

这是线程相比进程最显著的优势,其高效性体现在多个层面:

  • 无需切换地址空间 :线程切换发生在同一虚拟内存空间内。最关键的是TLB(快表)无需刷新CPU 缓存(Cache)的利用率更高。而进程切换必然导致 TLB 被全部刷新和 Cache 的大量失效,这是巨大的性能损失
  • 减少内存访问:由于资源共享,切换时需要保存和恢复的上下文状态量更少(主要是指针、栈、寄存器),减少了内存读写操作
  • 结果:线程切换的开销远小于进程切换,使得程序可以更平滑、快速地在多个任务间切换

3. 数据共享与通信的简便性

  • 原因:线程默认共享进程的全局变量、堆内存、文件描述符等资源
  • 结果 :线程间通信无需借助复杂的 IPC(进程间通信)机制(如管道、消息队列、共享内存)。直接读写共享内存 即可,非常简单高效(但存在线程安全的问题,后续会说到)

4. 充分利用多核处理器性能

  • 原因 :一个单线程进程只能在一个 CPU 核心上运行。一个多线程进程可以将它的多个线程真正地同时调度到多个CPU核心上执行 (并行计算),因为在线程的层次上:线程本身既是共享资源的协作单元,又是可被独立调度的执行单元
  • 结果
    • 对于计算密集型应用(如科学计算、图像渲染):可以将一个大任务分解成多个小任务,由多个线程并行计算,极大缩短总计算时间
    • 对于I/O密集型应用 (如 Web 服务器、数据库):当一个线程因等待慢速 I/O(如网络请求、磁盘读写)而阻塞时,其他线程可以继续使用 CPU 进行计算,最大限度地 "压榨" CPU 资源,提高系统的整体吞吐量和响应能力

1.4 线程的缺点

和线程的优点一样,线程的缺点同样可以归结为一个根本原因:同一个进程下的多个线程共享内存地址空间和大部分系统资源

1. 性能损失

这并非指线程本身慢,而是指设计不当或场景不匹配时带来的额外开销

  • 调度与同步开销

    • 计算密集型线程的瓶颈 :如果一个计算密集型线程(一直占用CPU)的数量超过了可用的处理器核心数,操作系统就需要频繁地进行线程切换。这种切换虽然比进程切换快,但依然需要保存/恢复寄存器状态等操作,会产生额外的调度开销
    • 锁竞争 :当多个线程需要访问共享资源时,必须使用锁(如互斥锁)来同步。如果锁竞争激烈,会导致大量线程在锁上忙等待或阻塞,实际上变成了串行执行,严重削弱了并行带来的性能收益
  • 注意 :线程并非越多越好。过多的活跃线程会导致操作系统将大量时间花在线程调度和上下文切换上,而不是执行有效工作,从而造成性能下降

2. 健壮性降低

缺乏进程级别的隔离是线程最致命的弱点之一

  • "一损俱损" :进程间是相互隔离的,一个进程的崩溃通常不会影响其他进程。但是所有线程都生活在同一个进程地址空间内
    • 一个线程的致命错误(如非法访问内存导致的段错误)会终止整个进程,从而杀死所有其他线程
    • 一个线程中的未处理异常同样会导致整个进程崩溃
  • 结果 :多线程程序的容错能力远低于多进程程序。单个线程的问题可以轻易地摧毁整个应用程序

3. 缺乏访问控制

  • 安全边界在进程层面 :操作系统的访问权限控制(如对文件、系统资源的访问)是以进程为基本粒度的。所有线程共享进程的权限和凭证
  • 问题 :这意味着一个线程可以执行任何其他线程有权执行的操作。无法从系统层面限制某个线程的行为。如果一个线程被恶意代码利用,它就可以访问进程内的所有敏感数据(如另一个线程的栈、堆数据),其破坏性可能是全局的

4. 编程与调试难度急剧提高

这是多线程开发中最常遇到的实践性困难

  • 数据竞争(Race Conditions)与死锁(Deadlocks)
    • 程序员必须极其小心地使用锁、信号量等同步原语来保护所有共享数据。稍有不慎就会导致:
      • 数据竞争:未正确同步导致的结果不确定性
      • 死锁:多个线程互相等待对方持有的锁,导致所有线程永久阻塞
    • 这些问题往往在高负载或特定时序下才会重现,难以测试和捕捉
  • 非确定性行为
    • 线程的执行顺序由操作系统调度器决定,每次运行的结果可能因调度时机而异。这种非确定性 使得重现和调试 Bug 变得异常困难
  • 对开发者要求高
    • 编写正确的多线程代码要求开发者对并发编程、内存模型、硬件内存屏障等有一定的理解

1.5 线程 VS 进程

特性维度 进程 (Process) 线程 (Thread)
基本定义 资源分配和拥有的基本单位 操作系统为一个程序的执行分配资源(内存、文件等)的独立实体 CPU 调度的基本单位 进程内部的一个执行流,是进程的实际运作单位
独立性 强隔离性 每个进程拥有独立的虚拟地址空间和资源。一个进程的崩溃通常不会影响其他进程。 弱隔离性 线程共享进程的所有资源和地址空间。一个线程的非法操作(如段错误)会导致整个进程及其所有线程终止
资源分配 系统在创建进程时为其分配独立的内存、I/O 端口、文件描述符等资源 不拥有系统资源,只"借用"其所属进程的资源
通信方式 复杂、开销大 (IPC) 需要借助操作系统提供的进程间通信(IPC)机制,如管道、消息队列、共享内存、信号量等 简单、高效 直接读写共享的全局变量和数据即可。但必须使用同步机制(如互斥锁)来避免竞态条件
上下文切换 开销巨大 需要切换虚拟地址空间(导致TLB和Cache大量失效)、寄存器、内核栈等 开销很小 只需切换私有数据(寄存器、栈指针等)。地址空间不变,TLB和Cache有效
创建与销毁 开销大、速度慢 需要独立分配和初始化页表、内存空间、文件描述符表等大量数据结构 开销小、速度快 只需分配一个栈和设置少量线程控制结构(TCB),共享进程的已有资源

线程私有 (Thread-Private) 数据

每个线程都拥有自己独立的副本,其他线程不能直接访问

  1. 线程ID (TID):操作系统内核用于标识线程的唯一编号
  2. 寄存器组和程序计数器:保存线程的执行上下文。切换时需保存/恢复
  3. 栈 (Stack) :这是最重要的私有数据。用于存储局部变量、函数调用链、返回地址
  4. 错误码 (errno):防止一个线程的系统调用错误码覆盖另一个线程的
  5. 信号屏蔽字 (Signal Mask):每个线程可以独立设置阻塞或接收哪些信号
  6. 调度优先级:可以单独设置线程的调度策略和优先级

线程共享 (Thread-Shared) 数据

所有线程共享进程的整个资源环境,这也是线程间通信如此高效的原因

  1. 内存地址空间
    • 代码段 (Text Segment):可执行的机器指令。是只读的,因此共享是安全的
    • 数据段 (Data Segment)全局变量和静态变量。这是线程间通信最主要、最直接的渠道,但也因此需要同步机制来保护
    • 堆 (Heap) :动态分配的内存(malloc, new)。所有线程都可以自由访问,但也必须同步
  2. 进程资源和环境
    • 文件描述符表:一个线程打开文件,其他线程都可以读写
    • 信号处理方式:对某个信号的默认处理、忽略或自定义处理函数,对整个进程生效
    • 当前工作目录 :一个线程chdir,会改变进程所有线程的工作目录
    • 用户ID和组ID:进程的权限身份信息

主线程栈 vs 子线程栈

  1. 主线程栈(进程栈)

    • 位置 :位于进程地址空间的最高区域(在 x86-64 架构中),通常向低地址方向增长
    • 创建方式:由内核在进程创建时自动分配和管理
    • 特性
      • 可以通过写时拷贝(COW)机制在 fork 时继承父进程的栈空间
      • 具有动态增长的能力 - 当栈空间不足时,内核会自动扩展它(直到达到 RLIMIT_STACK 限制)
      • 访问未映射的栈页会触发页错误,内核会尝试分配新页来扩展栈
      • 只有超出最大限制时才会导致段错误(Segmentation Fault)
  2. 子线程栈

    • 位置 :位于进程的内存映射区域(mmap 区域),通过mmap()系统调用分配
    • 创建方式 :由线程库(如 pthread)在调用pthread_create()时分配
    • 特性
      • 大小固定,在创建线程时确定(默认大小通常为 2-8MB,可通过属性设置)
      • 不会动态增长 → 如果栈溢出,会直接导致段错误
      • 通过mmap()在文件映射区分配的内存区域
      • 原则上线程私有,但同一进程的所有线程共享相同的地址空间

2. Linux 线程控制

2.1 POSIX 线程库

在进程间通信部分,提到过 POSIX IPC,但是并没有具体地说 POSIX 是什么

POSIX (Portable Operating System Interface,可移植操作系统接口)是一系列由 IEEE 制定的标准的总称,它定义了应用程序和操作系统之间的兼容性接口

简单来说,POSIX 是一个标准规范 ,它告诉操作系统(如 Linux, macOS, BSD)的开发者:"如果你想自称是'类Unix'系统并运行大量现成的软件,你的系统必须提供这些特定的函数、命令和工具"

最终目的就是一句话,促进软件的可移植性 :也就是说,一个遵循 POSIX 标准编写的程序,应该可以在任何同样遵循 POSIX 标准的操作系统上编译和运行,而无需进行大量修改

POSIX 标准涵盖了很多方面,主要包括:

  1. 系统调用(System Calls) : 定义了诸如 fork(), exec(), open(), close(), read(), write() 等核心 C 语言函数
  2. 命令行工具和实用程序(Shell & Utilities) : 规定了必须存在的标准命令及其选项,例如 ls, cp, grep, awk, sed 等。这就是为什么在 LinuxmacOS 上都能使用这些相似命令的原因
  3. Shell 标准 : 主要基于 Bourne Shell (/bin/sh),定义了脚本编程的基本语法和环境
  4. 环境变量 : 规定了一些标准环境变量,如 PATH, HOME
  5. 文件系统结构 : 对目录结构提出了一些建议,例如 /tmp, /dev 目录的作用

进程部分没有介绍 POSIX IPC,更多的是介绍 System V IPC,线程部分离不开 POSIX 线程库,下面来看看 POSIX 线程库到底是什么

POSIX 线程库,通常简称为 pthreads ,是 POSIX 标准中非常重要的一部分,它定义了创建管理同步 多个线程的 API。这些线程存在于同一个进程中,共享全部的进程资源(如全局变量、堆内存、文件描述符等),但每个线程拥有自己独立的栈和寄存器状态

下面我们看一下一些常用的接口

2.1.1 创建线程
c 复制代码
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);

功能:创建一个新的线程

参数

  • thread:指向线程标识符的指针,用于返回新线程的 ID
  • attr:线程属性,通常设为 NULL 表示使用默认属性
  • start_routine:线程函数的指针,该函数接受一个 void* 参数并返回 void*
  • arg:传递给线程函数的参数

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

示例

c 复制代码
#include <iostream>
#include <pthread.h>

void *Routine(void *args)
{
    std::cout << "new thread" << std::endl;
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, Routine, nullptr);
    return 0;
}

运行

bash 复制代码
$ make
g++ -o thread thread.cpp -lpthread  # 使用pthread的时候,编译时要注意显示链接(link) pthread库。高版本的glibc(如2.34)版本开始直接集成到了主 C 库 libc.so中
$ ./thread 
$ 

运行之后会发现,创建的新线程并不会输出预期的语句,这是因为主线程创建新线程之后就立即返回了(退出),进程终止,所有线程(包括新创建的线程)被强制结束,新线程没有机会执行 Routine 函数,也从侧面验证了前面说的 "一个线程的错误会终止整个进程"

后面等待线程就不会出现这样的问题,也可以简单的通过休眠主线程看到预期的结果

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

void *Routine(void *args)
{
    std::cout << "new thread" << std::endl;
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, Routine, nullptr);
    sleep(1);
    std::cout << "main thread" << std::endl;
    return 0;
}

运行

bash 复制代码
$ make
g++ -o thread thread.cpp -lpthread
$ ./thread 
new thread
main thread
$ 
2.1.2 终止线程
2.1.2.1 pthread_exit
c 复制代码
#include <pthread.h>
void pthread_exit(void *retval);

功能 :终止调用线程,并返回一个值。注意是线程主动、自愿地结束自己的执行

参数

  • retval:线程的返回值,可以被其他线程通过 pthread_join() 获取

注意 :在线程函数中调用 return 语句也会隐式调用 pthread_exit()

cpp 复制代码
void *worker_thread(void *arg)
{
    // ... 做一些工作 ...
    int time = 10;
    if (time)
    {
        // 任务完成,提前退出并返回结果
        int *result = (int *)malloc(sizeof(int));
        *result = 42;
        pthread_exit(result); // 等价于 return (void*)result;
        time--;
    }
    // ... 更多工作 ...
    return nullptr; // 这也是另一种形式的 pthread_exit
}

主线程退出而不终止进程

main 函数中调用 pthread_exit,主线程会结束,但进程会等待所有其他线程结束后才终止(仅针对 main 函数所在的进程)。这用于让后台线程继续完成工作

c 复制代码
void *thread(void *args)
{
    sleep(1);
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, thread, nullptr);

    // 主线程无事可做,直接退出。进程会等待 thread 结束
    pthread_exit(nullptr);
    return 0;  // 如果这里用 return,进程会立即结束,可能杀死 thread
}
2.1.2.2 pthread_cancel
c 复制代码
#include <pthread.h>
int pthread_cancel(pthread_t thread);

不同于允许pthread_exit()pthread_cancel一个线程向另一个线程发送一个取消请求 ("他杀"),要求目标线程在某个合适的时间点结束自己

注意:

  • 被动行为:由另一个线程发起,目标线程被动响应
  • 协作式取消 :取消请求不会立即强行杀死线程(这样会非常危险,导致资源死锁、状态不一致)。目标线程需要同意在所谓的取消点 (Cancellation Points)检查并处理这个请求
  • 不确定性:使用的时候无法精确预知目标线程会在哪个取消点响应请求

功能:向指定线程发送取消请求

参数

  • thread:要取消的线程 ID

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

补充 :线程可以通过 pthread_setcancelstate() 设置是否响应取消请求

示例

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

void *task(void *args)
{
    // 任务......
    sleep(8);
    std::cout << "Finish task" << std::endl;
    return nullptr;
}

int main()
{
    pthread_t worker_tid;
    pthread_create(&worker_tid, nullptr, task, nullptr);

    sleep(5); // 等待5秒超时
    std::cout << "Timeout" << std::endl;
    pthread_cancel(worker_tid);        // 5秒后还没完成就取消
    pthread_join(worker_tid, nullptr); // 等待它实际结束清理
    return 0;
}

运行

bash 复制代码
$ ./thread 
Timeout
$ 
2.1.3 等待线程

目的

  • 确保一个线程在另一个线程完成其工作之后才继续执行,类似于进程间的同步
  • 确保线程资源(如栈空间、线程描述符等)被正确释放。如果不等待可连接(joinable)的线程,会导致资源泄漏,类似于僵尸进程
  • 获取线程的执行结果或状态信息
c 复制代码
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);

功能:等待指定线程终止,并获取其返回值

参数

  • thread:要等待的线程 ID
  • retval:指向指针的指针,用于存储线程的返回值
    • retval 中存放的内容取决于目标线程是如何退出的
      • return 返回:存放目标线程的返回值
      • 自己调用 pthread_exit 终止:存放 pthread_exit参数
      • 被别的线程调用 pthread_cancel 终止:存放常数 PTHREAD_CANCELED
      • 若是不关心目标线程的终止状态,可以传递 NULL/nullptr

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

注意pthread_join 本质上是阻塞调用 。调用线程会被挂起,直到目标线程终止(虽然 pthread_join 本身没有非阻塞选项,但可以通过其他机制实现类似效果,此处不展开)

示例

c 复制代码
#include <iostream>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
#include <pthread.h>

void *thread1(void *args)
{
    std::cout << (char *)args << std::endl;
    int *ret = (int *)malloc(sizeof(int));
    *ret = 666;
    return (void *)ret;
}

void *thread2(void *args)
{
    std::cout << (char *)args << std::endl;
    char *ret = (char *)malloc(64);
    strcpy(ret, "finish task!");
    pthread_exit(ret);
}

void *thread3(void *args)
{
    while (true)
    {
        std::cout << (char *)args << std::endl;
        sleep(1);
    }
    return nullptr;
}

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

    // thread-1 return返回
    pthread_create(&tid, nullptr, thread1, const_cast<char *>("thread-1"));
    pthread_join(tid, &ret);
    std::cout << "thread[" << tid << "] returned, return code:" << *(static_cast<int *>(ret)) << std::endl;
    free(ret);

    // thread-2 pthread_exit退出
    pthread_create(&tid, nullptr, thread2, const_cast<char *>("thread-2"));
    pthread_join(tid, &ret);
    std::cout << "thread[" << tid << "] exited, return message:" << (char *)ret << std::endl;
    free(ret);

    pthread_create(&tid, nullptr, thread3, const_cast<char *>("thread-3"));
    sleep(3);
    pthread_cancel(tid);
    pthread_join(tid, &ret);
    if (ret == PTHREAD_CANCELED)
    {
        std::cout << "thread[" << tid << "] canceled, return code: PTHREAD_CANCELED" << std::endl;
    }
    else
    {
        std::cout << "thread[" << tid << "]canceled, return code:" << ret << std::endl;
    }
    return 0;
}

运行

bash 复制代码
$ ./thread 
thread-1
thread[133899717969600] returned, return code:666
thread-2
thread[133899717969600] exited, return message:finish task!
thread-3
thread-3
thread-3
thread[133899717969600] canceled, return code: PTHREAD_CANCELED
$ 
2.1.4 分离线程

分离线程 是指将其属性设置为"分离状态(detached state)"的线程。这种线程在终止时,其资源(如线程 ID、栈空间等)会由操作系统自动回收 ,而不需要 其他线程调用 pthread_join 来等待和回收它

注意

  1. 自动回收:分离线程结束后,系统立即自动清理其资源,防止资源泄漏
  2. 不可连接 (Unjoinable) :一旦线程被分离,其他线程就无法再使用 pthread_join 来等待它或获取它的返回值。调用 pthread_join 对一个已分离的线程会产生错误(通常返回 EINVAL)
  3. 无法逆转 :线程一旦被分离,就无法再恢复为"可连接(joinable)"状态
c 复制代码
#include <pthread.h>
int pthread_detach(pthread_t thread);

功能:将指定线程标记为分离状态

参数

  • thread:要分离的线程 ID

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

示例

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

void *thread1(void *args)
{
    std::cout << static_cast<char *>(args) << pthread_self() << std::endl;
    // 线程主动分离
    pthread_detach(pthread_self());
    return nullptr;
}

void *thread2(void *args)
{
    std::cout << static_cast<char *>(args) << pthread_self() << std::endl;
    return nullptr;
}

int main()
{
    pthread_t tid1, tid2;
    pthread_create(&tid1, nullptr, thread1, const_cast<char *>("New thread 1 :"));
    sleep(1);
    pthread_create(&tid2, nullptr, thread2, const_cast<char *>("New thread 2 :"));
    // 主线程分离新线程,要注意执行:顺序是不确定的,先分离,再等待!
    pthread_detach(tid2);
    sleep(2);

    if (pthread_join(tid1, nullptr) != 0)
    {
        std::cout << "thread 1 is not joinable!" << std::endl;
    }

    if (pthread_join(tid2, nullptr) != 0)
    {
        std::cout << "thread 2 is not joinable!" << std::endl;
    }

    return 0;
}

运行

bash 复制代码
$ ./thread 
New thread 1 :135909871716032
New thread 2 :135909871716032 #线程id是可以复用的
thread 1 is not joinable!
thread 2 is not joinable!
$ 
2.1.5 补充 --- 从内核机制到用户接口:线程的抽象层次

Linux 系统中,所谓的 "线程" 在内核视角下根本不存在 。内核调度和管理的唯一实体是任务(Task) ,由 task_struct 结构体描述。我们通常理解的线程,在内核中是通过一种特殊的进程------轻量级进程(Light Weight Process, LWP)------来模拟实现的

这种设计带来了两个层面的抽象:

  1. 内核机制层(Kernel Mechanism)

    • 内核提供创建 "共享资源进程" 的低级系统调用,主要是 clone()。通过给 clone() 传递不同的标志位(如 CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND),可以指定新创建的 LWP 与父进程共享虚拟内存空间、文件系统信息、文件描述符表和信号处理程序,从而在表现行为上与我们理论上的 "线程" 完全一致
    • vfork 也是一个创建共享进程的系统调用(较老),但现在更通用、更强大的接口是 clone()
  2. 用户接口层(User Interface) - POSIX Threads (pthreads)

    • 操作系统内核提供的 clone() 等接口非常底层且复杂,不适合普通开发者直接使用
    • 为了向程序员提供一套统一、标准、易用的线程操作接口,pthread 库(libpthread) 应运而生。它作为用户态的运行时库 ,封装了底层创建 LWP、管理线程同步等所有复杂操作
    • 当我们调用 pthread_create() 时,pthread 库在内部会使用 clone() 系统调用并设置好正确的参数来创建一个 LWP,然后为其维护额外的用户态数据结构(如线程 ID、特定数据等),最终呈现出的是一个符合 POSIX 标准的线程模型。因此,pthread 线程是 1:1 模型 (一个用户线程对应一个内核调度实体 LWP)

在这个框架下,需要认识清楚 "两个层次":

  • 用户认知层:程序员看到的是《操作系统》教科书里定义的理想线程------共享地址空间、拥有私有栈和上下文的执行流
  • 操作系统实现层 :内核看到的是一个通过 clone() 创建的、共享了大部分资源的轻量级进程(LWP)
  • 中间桥梁pthread 库处于两者之间,填补了这两者之间的鸿沟,隐藏了内核的实现细节,提供了标准的 API

回过头再看语言层,不同操作系统对线程的原生支持接口迥异(如 LinuxpthreadsWindowsCreateThread``,macOS 的基于 pthreads 的实现等)。为了保障 C++ 程序的可移植性,必须消除这种平台依赖性

  • 实现C++ 标准库 std::thread 及其配套的同步原语(如 std::mutex, std::condition_variable)正是为了解决这个问题而生的。标准委员会定义了统一的线程操作语义,各编译器厂商(如 GCCMSVCClang)则负责在其标准库实现中(libstdc++, libc++, MSVC STL)通过条件编译等技术,在底层调用当前平台的原生线程 API
    • Linux/macOS 上,std::thread 的底层实现通常封装了 pthread_create
    • Windows 上,则封装了 CreateThread

这样带来的好处就是,我们只需使用 #include <thread> 并遵循 C++ 标准语法,编写的多线程代码就无需修改即可在各个平台上编译运行。编译器替我们处理了所有平台相关的细节

3. 线程 ID 及地址空间布局

在认识了线程的相关操作后,下面我们具体了解一下线程 ID,以及系统内核是如何管理线程的

线程 ID 是一个用于在进程内唯一标识一个线程 的数据类型,在 POSIX 线程(pthreads)库中定义为 pthread_t

既然线程是通过轻量级进程(LWP)模拟实现的,那么内核中管理线程和进程的核心数据结构都是相同的,都使用 struct task_struct (定义在 /include/linux/sched.h)来表示。在进程部分,我们通过 pid_t getpid() 获取进程 ID。在线程中,可以通过 pthread_t pthread_self() 获取线程 ID。一个进程包含多个线程,那么如何区分线程 ID 和进程 ID 呢?实际上,task_struct 中已经明确定义了相关字段:

  1. 标识符 (Identifiers)
    • pid_t pid;线程 ID (Thread ID) 。在内核视角,这就是调度器所看到的任务 ID,也称为 TID 。由于每个线程都拥有一个独立的 task_struct,因此每个线程都有一个唯一的 pid
    • pid_t tgid;线程组 ID (Thread Group ID) 。这就是用户空间调用 getpid() 系统调用所返回的进程 ID (PID) 。同一个进程下的所有线程共享同一个 tgid,该值等于进程中第一个线程(即主线程)的 pid

因此,之前所说的进程 ID,更准确的说法是线程组 ID(TGID)。举例来说 :假设启动一个进程(PID=1000),它包含一个主线程和两个新线程:

  • 线程1(主): pid (TID) = 1000, tgid (PID) = 1000
  • 线程2: pid (TID) = 1001, tgid (PID) = 1000
  • 线程3: pid (TID) = 1002, tgid (PID) = 1000

用户空间调用 getpid() 返回的是 tgid(1000),而通过 gettid() 系统调用返回的才是内核视角的线程 ID pid(1001, 1002)。不过,通常更多使用的是 getpid()

在调用 pthread_create() 创建线程时,需要传入一个 pthread_t* 参数来获取新线程的 ID。从用户的角度来看,无需了解线程是通过轻量级进程模拟实现的,也不应感知内核中的 LWP ID。虽然内核统一使用 task_struct 来管理所有线程,并用其中的 pid 字段作为 TID,但直接暴露这个概念会破坏抽象层次。这时,用户态线程库(如 NPTL)的作用就体现出来了。当我们调用 pthread_self() 获取线程 ID 时,返回的是库内部管理的标识符,而非内核的 TID。使用的时候只需与线程库接口保持一致即可,无需关心内核的实现细节

回过头来看线程库中线程 ID 的类型 pthread_t。它是一个实现定义的类型,没有固定的标准格式,并且应被视为一个不透明的数据类型 。也就是说我们不应该去解读其内部内容,而只应将其作为一个整体传递给相关的 pthreads 函数

LinuxNPTL 实现中,pthread_t 类型的线程 ID,本质上是一个进程地址空间内的地址 。它被定义为一个指向线程控制结构(struct pthread)的指针。该结构体存储在用户空间,由线程库维护,包含了线程的所有管理信息,例如状态、属性、调度策略、栈信息等

总结

对于线程 ID 的使用,我们无需关心内核中 task_struct 的具体管理方式,但了解其背后的机制对于深入理解多线程编程很有必要。在实际应用中,线程库提供的接口(如 pthread_self()pthread_create())已完全足够

4. 线程封装

后面会用到的小组件,先封装一下 📄

cpp 复制代码
// Thread.hpp
#pragma once

#include <iostream>
#include <thread>
#include <string>
#include <cstring>
#include <functional>

namespace ThreadModule
{
    void Error(std::string msg, int error_num)
    {
        std::cerr << msg << strerror(error_num) << std::endl;
    }
    static uint32_t count = 1;
    pthread_mutex_t CountLock = PTHREAD_MUTEX_INITIALIZER;
    class Thread
    {
        using func_t = std::function<void(void *)>; // 参数可以为任意类型
    private:
        void SetName(std::string *name)
        {
            pthread_mutex_lock(&CountLock);
            *name = "Thread[" + std::to_string(count) + "]";
            count++;
            pthread_mutex_unlock(&CountLock);
        }
        // 修改标志位接口(内部使用),只进行修改接口
        void SetDetach()
        {
            _is_detach = true;
            std::cout << _name << "has been detached" << std::endl; // debug
        }
        void SetRunning()
        {
            _is_running = true;
        }
        // 真正的运行调用
        static void *Routine(void *args)
        {
            Thread *self = static_cast<Thread *>(args);
            // 接口化的好处
            // 1. 设置运行标志位
            self->SetRunning();
            // 2. 设置分离状态(如果是在运行前分离)
            if (self->_is_detach)
                self->SetDetach();
            // 回调函数,执行外部的逻辑(将线程名字传递给外部)
            self->_routine(const_cast<char *>(self->_name.c_str()));
            return static_cast<void *>(&self->_name);
        }

    public:
        Thread(func_t func)
            : _thread(0),
              _is_detach(false),
              _is_running(false),
              _routine(func) // 线程要执行的方法是外部传递进来的
        {
            SetName(&_name);
        }
        Thread(const Thread &) = delete;
        Thread &operator=(const Thread &) = delete;
        bool Start()
        {
            int ret = pthread_create(&_thread, nullptr, Routine, static_cast<void *>(this));
            return ret == 0;
        }
        // 分离线程
        void Detach()
        {
            // 已经是分离状态就直接返回
            if (_is_detach)
                return;
            // 运行时分离,直接调用pthread_detach
            if (_is_running)
            {
                pthread_detach(_thread);
            }
            // 运行前分离,修改标志位,运行的时候会进行检查
            SetDetach();
        }
        // 等待线程
        void Join()
        {
            // 不能对已分离的线程等待
            if (_is_detach)
            {
                std::cout << "Cannot wait on detached threads" << std::endl;
            }
            int ret = pthread_join(_thread, nullptr);
            if (ret == 0)
            {
                std::cout << "Joined " << _name << std::endl;
            }
            else
                Error("pthread_join failed: ", ret);
        }
        // 取消线程
        void Cancel()
        {
            int ret = pthread_cancel(_thread);
            if (ret == 0)
            {
                std::cout << _name << " canceled" << std::endl;
            }
            else
                Error("pthread_cancel failed: ", ret);
        }
        // 线程退出
        void Exit()
        {
            pthread_exit(nullptr);
        }
        ~Thread()
        {
            // 如果还在运行并且没有被分离,将线程分离给系统管理
            if (_is_running && !_is_detach)
            {
                pthread_detach(_thread);
            }
        }
        std::string GetName()
        {
            return _name;
        }
        pthread_t GetId()
        {
            return _thread;
        }
        bool GetDetachStatus()
        {
            return _is_detach;
        }
        bool GetRunningStatus()
        {
            return _is_running;
        }

    private:
        pthread_t _thread; // 线程ID
        std::string _name; // 线程名
        bool _is_detach;   // 是否分离标志位
        bool _is_running;  // 是否运行标志位
        func_t _routine;
    };
}

前面的抢票程序实现可以写为:

cpp 复制代码
// main.cc:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include <vector>
#include "Thread.hpp"

using namespace ThreadModule;

int finish = 0;
int tickets = 1000;

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void BuyTickets(void *args)
{
    const std::string name(static_cast<const char *>(args));
    int count = 0;
    while (true)
    {
        // 临界区
        pthread_mutex_lock(&lock);
        // 100张票已经抢完,但是还没有结束,就等待票数更新
        while (tickets == 0 && !finish)
        {
            pthread_cond_wait(&cond, &lock);
        }
        // 票已经抢完并且结束,就结束
        if (tickets == 0 && finish)
        {
            pthread_mutex_unlock(&lock);
            break;
        }
        // usleep(1000);
        printf("%s get ticket-%d\n", name.c_str(), tickets);
        tickets--;
        count++;
        pthread_mutex_unlock(&lock);
    }
    printf("%s get %d tickets\n", name.c_str(), count);
}

int main()
{
    int count = 10;
    Thread tid1(BuyTickets);
    Thread tid2(BuyTickets);
    Thread tid3(BuyTickets);

    tid1.Start();
    tid2.Start();
    tid3.Start();

    pthread_cond_broadcast(&cond);

    while (count > 0)
    {
        // 主线程访问临界资源,也要注意加锁
        pthread_mutex_lock(&lock);
        // 等待票数变为0
        while (tickets > 0)
        {
            pthread_mutex_unlock(&lock);
            // 让线程将票抢完
            // sleep(1);
            // 注意加锁
            pthread_mutex_lock(&lock);
        }
        // usleep(1000);
        tickets = 1000;
        count--;
        // 通知所有线程
        pthread_cond_broadcast(&cond);
        pthread_mutex_unlock(&lock);
    }

    pthread_mutex_lock(&lock);
    finish = 1;
    pthread_cond_broadcast(&cond);
    pthread_mutex_unlock(&lock);

    sleep(2);

    tid1.Join();
    tid2.Join();
    tid3.Join();

    return 0;
}

也可以将其实现为一个模板形式的:

cpp 复制代码
// Thread.hpp
#pragma once

#include <iostream>
#include <thread>
#include <string>
#include <cstring>
#include <functional>

namespace ThreadModule
{
    void Error(std::string msg, int error_num)
    {
        std::cerr << msg << strerror(error_num) << std::endl;
    }
    static uint32_t count = 1;
    pthread_mutex_t CountLock = PTHREAD_MUTEX_INITIALIZER;
    // 模板类版本
    template <class T>
    class Thread
    {
        using func_t = std::function<void(T)>; // 参数类型为T

    private:
        void SetName(std::string *name)
        {
            pthread_mutex_lock(&CountLock);
            *name = "Thread[" + std::to_string(count) + "]";
            count++;
            pthread_mutex_unlock(&CountLock);
        }
        // 修改标志位接口(内部使用),只进行修改接口
        void SetDetach()
        {
            _is_detach = true;
            std::cout << _name << "has been detached" << std::endl; // debug
        }
        void SetRunning()
        {
            _is_running = true;
        }
        // 真正的运行调用
        static void *Routine(void *args)
        {
            Thread<T> *self = static_cast<Thread<T> *>(args);
            // 接口化的好处
            // 1. 设置运行标志位
            self->SetRunning();
            // 2. 设置分离状态(如果是在运行前分离)
            if (self->_is_detach)
                self->SetDetach();
            // 回调函数,执行外部的逻辑(将线程名字传递给外部)
            self->_routine(const_cast<char *>(self->_name.c_str()));
            return static_cast<void *>(&self->_name);
        }

    public:
        Thread(func_t func)
            : _thread(0),
              _is_detach(false),
              _is_running(false),
              _routine(func) // 线程要执行的方法是外部传递进来的
        {
            SetName(&_name);
        }
        Thread(const Thread &) = delete;
        Thread &operator=(const Thread &) = delete;
        bool Start()
        {
            int ret = pthread_create(&_thread, nullptr, Routine, static_cast<void *>(this));
            if (ret != 0)
            {
                Error("pthread_create failed", ret);
                return false;
            }
            return true;
        }
        // 分离线程
        void Detach()
        {
            // 已经是分离状态就直接返回
            if (_is_detach)
                return;
            // 运行时分离,直接调用pthread_detach
            if (_is_running)
            {
                pthread_detach(_thread);
            }
            // 运行前分离,修改标志位,运行的时候会进行检查
            SetDetach();
        }
        // 等待线程
        void Join()
        {
            // 不能对已分离的线程等待
            if (_is_detach)
            {
                std::cout << "Cannot wait on detached threads" << std::endl;
            }
            int ret = pthread_join(_thread, nullptr);
            if (ret == 0)
            {
                std::cout << "Joined " << _name << std::endl;
            }
            else
                Error("pthread_join failed: ", ret);
        }
        // 取消线程
        void Cancel()
        {
            int ret = pthread_cancel(_thread);
            if (ret == 0)
            {
                std::cout << _name << " canceled" << std::endl;
            }
            else
                Error("pthread_cancel failed: ", ret);
        }
        // 线程退出
        void Exit()
        {
            pthread_exit(nullptr);
        }
        ~Thread()
        {
            // 如果还在运行并且没有被分离,将线程分离给系统管理
            if (_is_running && !_is_detach)
            {
                pthread_detach(_thread);
            }
        }
        std::string GetName()
        {
            return _name;
        }
        pthread_t GetId()
        {
            return _thread;
        }
        bool GetDetachStatus()
        {
            return _is_detach;
        }
        bool GetRunningStatus()
        {
            return _is_running;
        }

    private:
        pthread_t _thread; // 线程ID
        std::string _name; // 线程名
        bool _is_detach;   // 是否分离标志位
        bool _is_running;  // 是否运行标志位
        func_t _routine;
    };
}

对应的抢票程序

cpp 复制代码
// main.cc
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include <vector>
#include "Thread.hpp"

using namespace ThreadModule;

int finish = 0;
int tickets = 1000;

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void BuyTickets(std::string name)
{
    int count = 0;
    while (true)
    {
        // 临界区
        pthread_mutex_lock(&lock);
        // 100张票已经抢完,但是还没有结束,就等待票数更新
        while (tickets == 0 && !finish)
        {
            pthread_cond_wait(&cond, &lock);
        }
        // 票已经抢完并且结束,就结束
        if (tickets == 0 && finish)
        {
            pthread_mutex_unlock(&lock);
            break;
        }
        // usleep(1000);
        printf("%s get ticket-%d\n", name.c_str(), tickets);
        tickets--;
        count++;
        pthread_mutex_unlock(&lock);
    }
    printf("%s get %d tickets\n", name.c_str(), count);
}

int main()
{
    int count = 10;
    Thread<std::string> tid1(BuyTickets);
    Thread<std::string> tid2(BuyTickets);
    Thread<std::string> tid3(BuyTickets);

    tid1.Start();
    tid2.Start();
    tid3.Start();

    pthread_cond_broadcast(&cond);

    while (count > 0)
    {
        // 主线程访问临界资源,也要注意加锁
        pthread_mutex_lock(&lock);
        // 等待票数变为0
        while (tickets > 0)
        {
            pthread_mutex_unlock(&lock);
            // 让线程将票抢完
            // sleep(1);
            // 注意加锁
            pthread_mutex_lock(&lock);
        }
        // usleep(1000);
        tickets = 1000;
        count--;
        // 通知所有线程
        pthread_cond_broadcast(&cond);
        pthread_mutex_unlock(&lock);
    }

    pthread_mutex_lock(&lock);
    finish = 1;
    pthread_cond_broadcast(&cond);
    pthread_mutex_unlock(&lock);

    sleep(2);

    tid1.Join();
    tid2.Join();
    tid3.Join();

    return 0;
}
相关推荐
YQ_012 小时前
【Linux】解决两个USB设备VID/PID/序列号全一样无法区分的问题 (udev物理端口绑定)
linux·运维·服务器
峥嵘life2 小时前
Android16 EDLA中GMS导入和更新
android·linux·学习
haluhalu.2 小时前
深入理解Linux信号机制:中断、用户态与内核态
linux·运维·服务器
二哈喇子!2 小时前
Linux系统配置jdk
linux·运维·服务器·jdk
dddddppppp1232 小时前
linux 块设备驱动程序之helloworld
linux·服务器·网络
一颗青果3 小时前
DNS | ICMP
linux·网络
Linux蓝魔3 小时前
内网搭建阿里源的centos7系统源arm和x86
linux·运维·服务器
qiuiuiu4133 小时前
正点原子RK3568学习日志21-实验1-字符设备点亮led
linux·学习
fai厅的秃头姐!3 小时前
01-python基础-day01Linux基础
linux