Skip to content

线程 ​

  • 写作时间:2026-03-04 首次提交,2026-07-13 最近修改
  • 当前字符:9781

Cgroups完成了以进程为单位的资源管理:进程可以被创建、调度、隔离和限制。接下来把观察单位缩小到一个进程内部。一个进程只有一条执行流时,同一时刻只能在一个 CPU 上执行自己的代码;要让进程内部的多个任务同时推进,就需要线程。

来看一个服务进程。它要同时处理多个请求,又要共享内存中的缓存、连接表和统计信息。如果把每个请求交给独立进程,共享这些数据需要额外的进程间通信;如果在同一进程内建立多条执行流,它们可以直接访问同一地址空间。不过,共享并不会消除成本:线程仍要被创建和调度,共享数据还会引入竞态、同步与缓存通信。

本课先定义 线程,区分并发与并行,以及线程共享和独占的状态。随后用 Linux 线程解释 task_struct、线程组与 glibc 线程库,再通过 线程模型比较用户级执行流和内核调度实体的三种映射。共享地址空间中的私有状态由 TLS 提供;并行化的理论上限由 Amdahl 定律计算;多核之间传递共享数据的代价则落在 缓存一致性与伪共享 上。

这一章沿共享状态的正确性继续推进。同步原语解决竞态,但多个同步对象又可能形成死锁;I/O 密集程序还可以用事件驱动并发减少阻塞线程数量;最后,内核同步机制把同类问题放进中断、抢占和多核内核环境中处理。

线程 ​

线程是进程内部可被独立调度的一条执行流。

线程必须保存“下一条执行哪条指令”所需的状态,因此每个线程都有自己的程序计数器、通用寄存器和栈。栈保存函数调用帧、返回地址和局部变量;两个线程执行同一个函数时,各自在自己的栈上建立调用帧,互不覆盖。

同一进程中的线程又共享进程级资源。下面列出最容易影响后续理解的边界:

状态或资源同一进程中的线程
虚拟地址空间、代码、全局数据、堆共享
文件描述符表、当前目录、根目录、默认权限掩码(umask)共享
信号处置共享
寄存器、栈、线程 ID每线程独立
信号屏蔽字、发给特定线程的待处理信号每线程独立
调度状态、CPU 亲和性每线程独立
线程专用数据与 errno每线程独立

共享地址空间意味着线程 A 可以通过普通指针访问线程 B 分配在堆上的对象;如果知道地址,甚至可以访问 B 的栈。所谓“线程有独立栈”是调用约定和分配方式,不是内存保护边界。一个线程越界写内存可能破坏整个进程,进程内线程不能像不同地址空间那样互相隔离。

并发(concurrency)表示多个执行流在一段重叠时间内都取得进展,单核 CPU 交替运行多个线程也属于并发。并行(parallelism)表示多个执行流在同一时刻分别占用不同处理器执行。线程提供可独立调度的执行流,但是否并行还取决于可用 CPU、调度策略、任务是否可运行以及程序本身能否拆分。

线程切换与进程切换也不能简单概括成“一个便宜、一个昂贵”。CPU 调度介绍的 TLB 会缓存近期的虚拟地址翻译结果;同一地址空间的线程切换通常不需要更换页表根,并可继续使用该地址空间的 TLB 项,但仍要进入调度器、保存和恢复寄存器,工作集也可能互相挤占缓存。现代处理器还可能在地址空间切换后保留部分翻译缓存,因此实际代价必须结合硬件和工作负载测量。

共享数据降低了传递数据所需的隔离成本,却要求程序明确规定多个线程可以在什么时间、以什么顺序访问它。没有这项规则,直接共享只是把通信问题转成并发正确性问题。

Linux 线程 ​

Linux 线程是与同一线程组中的其他 task 共享进程资源、但由内核单独调度的 task。

Linux 为每条可调度执行流维护一个 task_struct。它没有为 POSIX 线程另建完全不同的调度对象,但内核并非“不区分进程和线程”:线程组、共享资源指针、信号投递和退出语义都会区分进程级与线程级状态。

每个 task 有自己的 TID,同一进程中的 task 共享线程组 ID(Thread Group ID, TGID)。线程组组长的 TID 与 TGID 相同:

text
线程组 TGID 4200
├── task: TID 4200, TGID 4200
├── task: TID 4201, TGID 4200
└── task: TID 4202, TGID 4200

Linux 的 getpid() 返回调用线程所在组的 TGID,所以三个线程都得到 4200;gettid() 返回调用线程自己的 TID。POSIX 的 pthread_t 是线程库使用的不透明标识,不保证等于 Linux TID,程序应使用 pthread_equal() 比较两个 pthread_t。

clone() 的 CLONE_* 标志决定新 task 与调用者共享哪些对象。线程库创建符合 POSIX 语义的线程时,会组合下面几项共享关系:

标志共享内容
CLONE_VM虚拟地址空间,即 mm_struct
CLONE_FS当前目录、根目录和 umask
CLONE_FILES文件描述符表
CLONE_SIGHAND信号处置表
CLONE_THREAD线程组和 TGID

其中,CLONE_THREAD 才是把新 task 放入同一线程组的标志;CLONE_FS 与 CLONE_FILES 是 POSIX 线程所需的共享关系,却不是加入线程组的内核前提。内核要求 CLONE_SIGHAND 与 CLONE_VM 配合,并要求 CLONE_THREAD 与 CLONE_SIGHAND 配合。线程仍保留自己的寄存器上下文、内核栈、调度实体、信号屏蔽字和 TID。

应用通常不直接拼接这些标志。POSIX 定义了类 Unix 系统通用的 POSIX 线程(POSIX threads, Pthreads)接口,原生 POSIX 线程库(Native POSIX Thread Library, NPTL)是现代 glibc 对这套接口的 Linux 实现。NPTL 为新线程准备栈、每线程存储区域和线程库状态,再请求内核创建具有相应共享关系的 task。

pthread_create() 与 pthread_join() 的核心签名如下:

c
#include <pthread.h>

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

int pthread_join(pthread_t thread, void **retval);

pthread_create() 成功返回 0,失败时直接返回错误号,不使用“返回 -1 并设置 errno”的系统调用包装惯例。创建成功后,调用者与新线程谁先继续执行没有保证。线程从 start_routine 返回等价于调用 pthread_exit();默认创建的可连接(joinable)线程退出后,其他线程需要 pthread_join() 取得返回值并完成资源回收。分离(detached)线程退出后由线程库自动释放相关资源,不能再 join。

任一线程调用 exit(),或者初始线程从 main() 返回,都会终止整个进程;pthread_exit() 只终止调用线程。进程内“某条执行流结束”和“整个地址空间结束”因而是两个不同事件。

线程栈通常由线程库通过内存映射分配,并设置一段不可访问的保护区(guard region)来捕获部分栈溢出。默认大小受线程属性、程序启动时的资源限制和体系结构影响,不是固定的 8 MiB;pthread_attr_setstacksize() 可以在创建前指定满足平台约束的大小。

线程模型 ​

线程模型(threading model)是用户可见执行流与内核可调度线程之间的映射方式。

用户级线程(user-level thread, ULT)由语言运行时或线程库在用户空间保存栈、寄存器和状态,内核不知道每个 ULT 的存在。内核级线程(kernel-level thread, KLT)在这里指内核可独立调度、能够承载用户代码的执行实体;它不是专门在内核中执行后台工作的 Linux kthread。

ULT 不能直接占用 CPU,必须运行在某个 KLT 上。运行时在用户空间选择下一个 ULT,保存当前上下文并恢复目标上下文;内核调度器只决定哪个 KLT 获得 CPU。由此产生三种经典映射:

模型映射阻塞行为多核并行主要代价
多对一(M:1)多个 ULT 共用一个 KLT该 KLT 阻塞时,其上的 ULT 都不能运行不能超过一个核心运行时切换简单,但内核不可见
一对一(1:1)每个用户线程对应一个 KLT一个线程阻塞不妨碍其他线程被调度可以每个线程都占用内核任务资源
多对多(M:N)多个 ULT 复用多个 KLT一个载体阻塞时,其他载体仍可运行上限由 KLT 数量决定运行时调度、系统调用和抢占协作更复杂

多对一运行时可以使用非阻塞 I/O,减少某次系统调用阻塞整个载体的机会,但单个 KLT 仍不能在多个核心上同时执行。多对多运行时可以在某个 ULT 阻塞时调度其他 ULT,也可以让不同 KLT 在多个核心并行;代价是运行时必须协调用户态调度与内核调度,并处理抢占、每线程存储、信号和阻塞系统调用。

Linux NPTL 采用一对一模型,每个 pthread_create() 成功后都有一个对应的内核 task,内核直接调度它。Linux 同时允许语言运行时在这些 task 之上实现自己的 M:N 调度;这时内核仍只看见承载运行时任务的 KLT。

模型选择改变了调度责任,不改变并行的物理条件。无论执行流由内核还是运行时管理,同一时刻真正执行多少条指令流,最终仍受可运行 KLT 与处理器数量限制。

TLS ​

线程本地存储(Thread-Local Storage, TLS)是让同一个变量声明在每个线程中拥有独立实例的机制。

TLS 适合保存只属于当前线程、却需要被多层函数访问的状态,例如错误号、当前请求上下文或线程级缓存。2011 版 C 语言标准(C11)用 _Thread_local 声明静态 TLS 变量:

c
_Thread_local unsigned long request_count;

每个线程访问 request_count 时得到自己的实例。一个线程写入不会修改另一个线程的副本,因此这类数据不需要用锁保护;但 TLS 也不会自动合并各线程结果,跨线程汇总仍属于共享数据访问。

errno 展示了 TLS 为什么必要。Linux 系统调用的底层返回值可以携带负错误码;glibc 包装函数检测到错误后,通常向调用者返回 -1,并把正错误号写入当前线程的 errno。内核并不是直接修改用户空间的全局 errno。如果所有线程共享一个普通变量,线程 A 的失败原因可能在读取前被线程 B 覆盖,因此现代 C 库把 errno 实现成对当前线程错误号槽位的访问。

编译器会把具有静态存储期的 TLS 数据放入 .tdata 或 .tbss 等 ELF TLS 区域,并为访问生成相对线程指针的寻址。系统调用介绍过 ELF 是 Linux 可执行文件和共享库使用的对象格式;这里的 .tdata 保存有初值的 TLS 模板,.tbss 描述零初始化 TLS 数据。

在线程启动时,动态加载器或线程库为它建立 TLS 块。以 x86-64 Linux 为例,FS base 通常指向线程控制块附近,编译器生成的 TLS 访问根据 FS base 和变量偏移计算实际地址。NPTL 创建线程时可通过 CLONE_SETTLS 把新线程指针交给内核;线程切换时,内核保证恢复正确的线程指针上下文。

text
同一条 TLS 访问指令
├── 线程 A 的线程指针 + offset ── request_count(A)
├── 线程 B 的线程指针 + offset ── request_count(B)
└── 线程 C 的线程指针 + offset ── request_count(C)

具体寻址序列取决于体系结构、链接方式和 TLS 模型,动态加载的共享库还可能需要运行时解析,不能把所有 TLS 访问都等同于固定的一条 fs:[offset] 指令。TLS 的共同语义是“每线程一个实例”,而不是某个特定寄存器。

Amdahl 定律 ​

Amdahl 定律是在固定问题规模下,根据程序串行比例估算并行加速上限的公式。

Gene Amdahl

Gene Amdahl

Gene Amdahl(1922-2015)是计算机体系结构研究者,参与并主导过 IBM System/360 的设计。他在 1967 年讨论大规模并行计算时提出了后来以其姓氏命名的加速上限;这里采用的照片来自 Wikipedia API 返回的页面缩略图。

设单处理器执行时间为 T(1) ,其中不能并行的比例为 f ,其余 1−f 可以理想地平均分给 N 个处理器。串行部分仍耗时 fT(1) ,并行部分耗时 (1−f)T(1)/N ,因此:

T(N)=fT(1)+(1−f)T(1)N

加速比是单处理器时间与并行时间之比:

S(N)=T(1)T(N)=1f+1−fN

当处理器数量趋近无穷时,并行部分的理想耗时趋近于零,但串行部分仍存在:

Smax=limN→∞S(N)=1f

若程序有 5% 不能并行,即 f=0.05 ,8 个处理器的理想加速比约为 5.93 ,无限多个处理器的上限也只有 20。减少串行比例可能比继续增加处理器更有效。

这个公式已经采用了有利假设:可并行部分能够均匀拆分,且没有线程创建、同步、调度、数据传输和缓存通信成本。真实执行时间还包含这些开销,也可能因负载不均而出现空闲处理器,所以实测加速通常低于公式结果。Amdahl 定律描述的是固定工作量的强扩展(strong scaling),扩大问题规模会得到不同的分析前提。

缓存一致性与伪共享 ​

缓存一致性(cache coherence)是多核硬件为同一内存位置的缓存副本维持一致写入顺序的机制;伪共享(false sharing)是不同数据位于同一缓存行并被不同核心频繁写入时产生的非必要一致性通信。

处理器缓存以缓存行为传输和一致性单位,而不是按 C 变量操作。缓存行大小由硬件决定,现代通用处理器上常见 64 字节。多个核心可以同时缓存同一行的只读副本;某个核心要写入时,通常必须先取得该行的独占所有权,并使其他核心的副本失效。

MESI 是说明这一过程的经典协议模型,名称来自四种状态:

状态含义
Modified本核心持有已修改的独占副本,内存中的副本可能较旧
Exclusive本核心持有未修改的独占副本
Shared多个核心可以持有未修改副本
Invalid本核心的副本不可使用

不同处理器可能采用 MESI 的扩展或其他协议,但写入共享行时都要解决所有权和失效传播。若 CPU 核 0 与 CPU 核 1 交替写同一行,该行的可写所有权会在核心之间反复转移,这种现象称为缓存行抖动(cache line bouncing)。传输经过缓存一致性互连,不一定真的先写回主存再由另一核心读取。

缓存一致性只解决同一地址的副本问题,不自动提供程序所需的跨地址顺序。一个核心先写数据再写“就绪”标志,另一个核心观察到标志时,不应仅凭缓存一致性假设数据访问已经按期望顺序可见。编译器和 CPU 都可能在允许范围内重排操作;C 程序若让两个线程无同步地并发访问同一非原子对象,并且至少一方写入,就产生数据竞争(data race),行为未定义。同步原语会用原子操作与内存序建立正确的可见性和顺序。

伪共享不要求两个线程访问同一个变量。下面两个独立计数器若落在同一行,CPU 核 0 写 a 仍会使 CPU 核 1 包含 b 的整行副本失效:

text
同一缓存行
┌─────────────────────────────────────────────┐
│ counter_a │ counter_b │ 其他字节            │
└─────────────────────────────────────────────┘
      ↑ CPU 核 0 写     ↑ CPU 核 1 写

即使计数器使用原子类型,结果在逻辑上正确,缓存行仍可能持续抖动。伪共享因此是性能问题,不等同于数据竞争。对已知缓存行为 64 字节的目标,可以让高频写入槽位分别占据独立对齐单元:

c
#include <stdatomic.h>

struct counter_slot {
    _Alignas(64) _Atomic unsigned long value;
};

struct counter_slot per_thread[4];

结构体对齐会使数组元素按相应边界排列;在 Linux 内核中,____cacheline_aligned_in_smp 等宏承担类似用途。对齐和填充会增加对象大小、缓存占用与 TLB 压力,因此不应对所有字段无差别使用。只有多个核心频繁访问、且至少一方写入的共享缓存行,才可能形成有害伪共享;优化前需要用真实性能数据确认热点。

小结 ​

概念说明
线程进程内可独立调度的执行流,共享进程资源并保留独立执行上下文
并发 / 并行并发是重叠时间内共同推进,并行是同一时刻在多个处理器执行
Linux 线程线程组中的独立 task,具有自己的 TID 并共享 TGID 与进程资源
NPTLglibc 的 POSIX 线程实现,采用一对一映射
线程模型用户级执行流与内核调度线程之间的 M:1、1:1 或 M:N 映射
TLS让同一声明在每个线程中拥有独立实例的存储机制
errno由 C 库错误路径写入当前线程槽位,而不是共享全局变量
Amdahl 定律固定问题规模下,串行比例决定理想并行加速上限
缓存一致性维持同一内存位置的缓存副本和写入顺序,不替代同步
缓存行抖动可写缓存行所有权在核心之间反复转移
伪共享独立数据落在同一缓存行而产生不必要一致性通信

线程把可调度单位缩小到共享地址空间中的执行流,但共享只提供直接访问能力,不提供正确顺序,也不保证并行收益;线程模型、TLS、同步协议和缓存布局必须共同决定程序的正确性与扩展性。


Linux 源码与接口入口: