【Linux】线程概念与控制:从地址空间、LWP 到 pthread_create、join、detach 与 NPTL

【Linux线程】线程到底是什么?从地址空间、LWP 到 pthread_create 与 TID(上篇)

🔥 本文定位 :这是 Linux 线程系列上篇。我们先不急着背 API,而是从虚拟地址空间和内核调度实体出发,回答"一个进程为什么能拥有多条执行流",再完整走通 pthread_create() 与线程标识体系。

💡 学习目标 :读完本文,你应该能准确区分 进程与线程、并发与并行、共享资源与线程上下文、pthread_t 与 Linux TID,并能写出生命周期正确的线程创建代码。

📌 系列导航:上篇讲"线程是什么、如何创建";下篇继续讲"线程如何退出、回收、取消,以及 NPTL 如何实现"。


文章目录


一、线程到底是什么

1.1 线程是进程内部的一条执行流

一个正在运行的程序至少有一条执行路线。只有 main 执行流时,它是单线程进程;通过线程库创建更多执行流后,同一个进程中就可以同时推进多个任务。

更准确地说:

  • 进程承载地址空间、文件描述符、权限、信号处理动作等资源;
  • 线程承载程序计数器、寄存器、栈和调度状态,是 CPU 调度的执行实体;
  • 多个线程在同一个进程资源容器内执行同一份程序代码;
  • 每条线程可以从不同函数入口、不同指令位置继续运行。
text 复制代码
进程 = 资源容器 + 至少一条线程

单线程进程:资源容器 + main 执行流
多线程进程:资源容器 + main + worker 1 + worker 2 + ...

"进程是资源分配单位、线程是调度单位"是一个有用的入门概括,但不要理解成内核里存在一道绝对分割线。Linux 内核以可调度任务描述执行实体,并通过共享地址空间、文件表等资源关系组成线程组。

1.2 并发与并行不是一回事

  • 并发:多个任务在一段时间内交替推进;单核也能并发;
  • 并行:多个任务在同一时刻运行在不同 CPU 核上;需要多核或多处理器;
  • 线程数量超过可用 CPU 数量,并不会凭空增加算力,反而可能增加调度、缓存失效和同步成本。

对于 I/O 密集任务,即使线程没有同时占用多个核心,也能通过"一个线程等待 I/O,另一个线程继续工作"改善吞吐与响应性。对于 CPU 密集任务,则要结合可用核心数、任务粒度和共享状态量决定线程数。

1.3 一个线程崩溃为什么常常拖垮整个进程

线程共享同一地址空间。某个线程越界写内存,可能破坏其他线程使用的堆对象、函数指针或同步结构。某个线程触发 SIGSEGV,默认处理动作通常终止整个进程,进程中的其他线程也随之结束。

所以线程之间的隔离远弱于进程。多线程换来了低成本共享,也把故障影响面扩大到了整个进程。


二、Linux 如何表示线程

2.1 Linux 线程不是一套完全独立于进程的内核对象

在现代 Linux NPTL 中,每个用户线程映射到一个内核可调度实体。它们拥有各自的内核任务描述、调度状态和 TID,同时通过共享内存描述、文件描述符表、信号处理动作等方式组成线程组。

从不同观察角度,会得到三个层级:

text 复制代码
应用/Pthreads:pthread_t
Linux 线程组:TGID + 每线程 TID
内核调度器:每个线程都是可独立调度的 task

教材常把 Linux 线程称为 LWP(Lightweight Process,轻量级进程),强调它与传统进程使用相近的调度基础设施,但共享更多资源。

2.2 为什么"轻量"

创建新进程通常要建立新的进程地址空间视图、页表关系和独立资源语义;创建同进程线程时,新执行流复用已有地址空间和大量进程级资源。

线程切换通常不需要切换到另一个进程地址空间,因此避免了一部分内存管理上下文变化。但"线程切换一定非常便宜"也不是绝对真理:寄存器保存、调度器开销、CPU cache/TLB 行为、NUMA 位置和锁竞争仍可能很昂贵。

2.3 线程组中的 leader

Linux 线程组有一个组长:

  • 线程组 ID(TGID)通常就是用户看到的进程 PID;
  • 线程组 leader 的 TID 等于 TGID;
  • 其他线程拥有各自系统范围内唯一的 TID;
  • 同组线程调用 getpid() 得到相同 TGID;
  • Linux 上调用 gettid() 才得到当前线程的内核 TID。

这解释了 ps -L 中同一 PID 下出现多个不同 LWP/TID 的现象。


三、为什么理解线程必须先理解地址空间

3.1 CPU 使用虚拟地址,不直接把程序指针当物理地址

用户程序看到的是连续虚拟地址空间。MMU 根据页表把虚拟页映射到物理页框,TLB 缓存近期地址转换结果。虚拟地址连续,不要求物理内存连续。

简化流程:

text 复制代码
CPU 产生虚拟地址
        ↓
MMU 查询 TLB
   ├─ 命中 → 得到物理页框
   └─ 未命中 → 查询多级页表 → 回填 TLB
        ↓
物理页框 + 页内偏移 → 实际物理地址

页大小不是"Linux 永远 4 KiB"。4 KiB 在许多系统上很常见,但具体值取决于体系结构和配置,可以查询:

bash 复制代码
getconf PAGESIZE

3.2 同一进程的线程为什么天然能看到同一个指针

因为它们共享同一进程地址空间。一个全局变量、堆对象或内存映射在这个地址空间中只有一套虚拟地址语义,同组线程通过同一映射关系访问它。

这与两个独立进程不同:两个进程里数值相同的虚拟地址,通常会经各自页表映射到不同物理页。

text 复制代码
同进程线程 A:0x7f...1000 ─┐
                             ├─ 同一地址空间映射 → 同一对象
同进程线程 B:0x7f...1000 ─┘

进程 P:0x400000 → 物理页 X
进程 Q:0x400000 → 物理页 Y

3.3 缺页异常不等于非法访问

MMU 找不到有效映射时触发 page fault,内核检查虚拟内存区域和权限:

  • 合法但尚未驻留:分配匿名页、从文件调页或完成写时复制;
  • 页已在内存但当前页表未映射:建立映射;
  • 地址不属于合法区域,或权限不允许且无法修复:可能向当前线程产生 SIGSEGV/SIGBUS

因此,缺页是虚拟内存正常工作的组成部分;只有无法修复的故障才会演变成进程可见的崩溃。

3.4 线程共享地址空间,但拥有不同栈

典型布局中:

  • 代码段、全局数据、BSS、堆和大多数映射区由线程共享;
  • 初始线程使用进程启动时建立的主栈;
  • pthread_create() 创建的线程通常由线程库准备独立栈映射和保护页;
  • "线程栈私有"描述的是使用约定和执行上下文,而不是硬件访问隔离。

只要知道另一个线程栈上对象的地址,同进程线程原则上也能访问它;但把栈地址跨线程传递极易造成生命周期错误和数据竞争。


四、同一进程的线程共享什么、独享什么

4.1 共享的进程资源

多个线程通常共享:

  • 虚拟地址空间中的代码、全局数据、BSS、堆和映射;
  • 打开的文件描述符;
  • 当前工作目录和根目录;
  • 用户 ID、组 ID、进程组、会话和控制终端;
  • 信号处理动作(默认、忽略、捕获);
  • 资源限制、umask 和多种进程级计时属性;
  • 进程 ID,也就是线程组 ID。

一个线程调用 close(fd),其他线程对同一描述符的后续使用也会受到影响;一个线程修改当前工作目录,整个进程随后使用相对路径的行为都会变化。

4.2 每个线程独立的执行上下文

每个线程拥有或维护自己的:

  • pthread_t 线程标识;
  • Linux TID;
  • 程序计数器与寄存器上下文;
  • 栈和栈保护区域;
  • 信号掩码;
  • errno
  • 备用信号栈;
  • 调度策略、实时优先级;
  • Linux 特有的 CPU affinity、能力集等线程属性;
  • 线程局部存储(TLS)。

4.3 "线程私有"不等于"其他线程访问不到"

寄存器上下文和内核调度状态确实属于单个线程;但用户空间里的栈、TLS 实现区域仍处于共享地址空间。其他线程如果拿到地址,硬件页表不会因为"这是线程 B 的栈"就自动拒绝线程 A。

这也是多线程安全的本质:

地址可达性由共享地址空间提供,访问秩序必须由程序自己建立。

如果两个线程在没有同步关系时并发访问同一普通对象,且至少一个是写操作,在 C/C++ 内存模型中可能构成数据竞争并导致未定义行为。后续学习互斥锁、条件变量和原子操作,就是在解决这个问题。


五、多线程的收益、代价与适用场景

5.1 线程的主要优势

  1. 通信直接:共享内存中的对象可以直接访问,不需要序列化和 IPC 搬运;
  2. 创建与切换通常较轻:复用地址空间和进程资源;
  3. 可利用多核:独立计算任务可以在多个 CPU 上并行;
  4. 覆盖 I/O 等待:一个线程阻塞时,其他线程继续处理任务;
  5. 职责拆分:UI、网络、磁盘、后台计算可由不同执行流负责。

5.2 线程的真实成本

  • 每个线程需要栈、线程控制信息和内核调度资源;
  • 线程数过多导致上下文切换和调度开销;
  • 共享数据需要锁、原子操作或无锁协议;
  • 锁竞争会把并行代码重新串行化;
  • false sharing 会让多个核心反复争用 cache line;
  • 调试时执行顺序不稳定,竞态可能难以复现;
  • 任一线程的内存破坏可能影响整个进程。

5.3 什么时候不应"一个任务一个线程"

当连接数或任务数很大时,无限制创建线程通常不是好设计。更常见的方案是:

  • 固定大小或可伸缩线程池;
  • 工作队列 + 少量 worker;
  • 非阻塞 I/O + 事件循环;
  • 分阶段流水线;
  • 对故障隔离要求高时使用多进程或服务边界。

线程只是并发执行工具,不是自动变快的开关。


六、Pthreads 编译、返回值与创建接口

6.1 编译请使用 -pthread

bash 复制代码
gcc main.c -O2 -Wall -Wextra -pthread -o app
g++ main.cc -O2 -Wall -Wextra -pthread -o app

相比只写 -lpthread-pthread 既处理链接,也允许编译器/头文件启用线程相关选项,是 Linux 上更推荐的用法。

6.2 Pthreads 错误码不通过 errno 返回

绝大多数 Pthreads 函数:

  • 成功返回 0
  • 失败直接返回错误号;
  • 不要期待它把错误写入当前线程的 errno
  • 不应直接 perror("pthread_create")

正确模式:

c 复制代码
int err = pthread_create(&tid, NULL, worker, arg);
if (err != 0) {
    fprintf(stderr, "pthread_create: %s\n", strerror(err));
    return 1;
}

6.3 pthread_create() 参数

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

int pthread_create(pthread_t *restrict thread,
                   const pthread_attr_t *restrict attr,
                   void *(*start_routine)(void *),
                   void *restrict arg);
参数 含义
thread 成功后写入新线程的 pthread_t
attr 创建属性;NULL 使用默认属性
start_routine 新线程的入口函数
arg 原样传给入口函数的单个 void * 参数

6.4 一个生命周期正确的最小示例

c 复制代码
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct task_arg {
    int value;
    const char *message;
};

static void *worker(void *opaque)
{
    const struct task_arg *arg = opaque;
    int *result = malloc(sizeof(*result));
    if (result == NULL)
        return NULL;

    printf("worker: %s, value=%d\n", arg->message, arg->value);
    *result = arg->value * 2;
    return result;
}

int main(void)
{
    pthread_t tid;
    struct task_arg arg = {21, "hello"};

    int err = pthread_create(&tid, NULL, worker, &arg);
    if (err != 0) {
        fprintf(stderr, "pthread_create: %s\n", strerror(err));
        return 1;
    }

    void *raw = NULL;
    err = pthread_join(tid, &raw);
    if (err != 0) {
        fprintf(stderr, "pthread_join: %s\n", strerror(err));
        return 1;
    }

    if (raw != NULL) {
        int *result = raw;
        printf("result=%d\n", *result);
        free(result);
    }
    return 0;
}

为什么把 arg 放在 main 栈上仍然安全?因为 mainpthread_join() 返回前不会离开其作用域,且示例没有并发修改 arg。如果创建后立刻返回、复用同一参数对象或让多个线程修改它,就需要重新设计所有权和同步。

6.5 创建成功后谁先运行没有保证

pthread_create() 返回前后,新线程和创建者谁先获得 CPU 通常是不确定的。不要通过 sleep(1) 猜测时序,也不要假定创建者一定先完成下一条语句。

如果新线程必须等待初始化完成,应使用互斥锁、条件变量、屏障等明确同步机制,而不是延时碰运气。


七、pthread_t、PID、TGID 与 TID

这是本章最容易被旧资料带偏的地方。

7.1 pthread_t 是 POSIX 线程库标识

pthread_t 是不透明类型:

  • 只保证在当前进程和有效生命周期内标识线程;
  • 不保证一定是整数或指针;
  • 不应用 %x%ld 等格式猜测其内部类型;
  • 两个 pthread_t 是否相等应使用 pthread_equal()
  • 线程被 join 或 detached 线程退出后,标识可能被复用;继续使用已经失效的 ID 属于未定义行为。
c 复制代码
if (pthread_equal(pthread_self(), tid)) {
    /* 当前线程就是 tid 表示的线程 */
}

7.2 TID 是 Linux 内核线程 ID

Linux 上:

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

pid_t tid = gettid();
pid_t tgid = getpid();
  • getpid() 返回线程组 ID(TGID);
  • gettid() 返回当前内核线程 ID;
  • 主线程通常 TID == TGID
  • 其他线程 TID != TGID,但 getpid() 仍相同。

7.3 pthread_self() 不是 gettid()

二者面向不同接口层:

标识 作用域 主要用途
pthread_t 进程内、有效线程生命周期内 pthread_joinpthread_cancelpthread_equal
Linux TID 当前 PID namespace 中的内核任务标识 日志、/proc、跟踪、系统级诊断
TGID/PID 线程组/进程 进程管理、普通 kill() 目标语义

不要把当前 glibc 某个版本里 pthread_t 的具体表示写进协议或持久化文件。实现可以变化,POSIX 也没有要求它等于 TID。



上篇总结

本文建立了 Linux 线程最重要的认知主线:

text 复制代码
进程提供资源容器
        ↓
线程是可独立调度的执行流
        ↓
同组线程共享地址空间和进程级资源
        ↓
每个线程保留独立寄存器、栈、mask、errno 与调度状态
        ↓
pthread_create 创建新执行流
        ↓
pthread_t、TGID、TID 分别服务于不同观察层

请牢牢记住下面六点:

  1. 线程共享地址空间,但不共享寄存器上下文和栈的使用权
  2. "线程私有栈"不代表其他线程在硬件上无法访问它
  3. 编译线程程序应使用 -pthread
  4. 多数 Pthreads 函数直接返回错误号,不通过 errno 报错
  5. pthread_t 是不透明库标识,不能当成 Linux TID 使用
  6. 创建成功不代表父线程或子线程一定先运行,执行顺序必须靠同步建立

到这里,我们解决了"线程是什么"和"线程如何创建"。但线程创建只是生命周期的起点:它怎样返回结果?退出后谁回收资源?detached 为什么仍有生命周期风险?pthread_cancel() 为什么不是立即杀死线程?这些问题将在下篇继续展开。

📖 下篇预告:《Linux 线程控制全解析:终止、join/detach、cancel、线程栈与 NPTL(下)》

发布上下篇后,可以在这里补充下篇链接,形成完整系列导航。

权威参考


如果本文对你有帮助,欢迎点赞、收藏。下篇将继续拆解线程终止、join/detach、取消、线程栈与 NPTL 实现。

相关推荐
Lam Tang1 小时前
APS 系列文章 09
java·代理模式
Escalating_xu1 小时前
【Linux】进程信号:从产生、阻塞与递达到 sigaction、可重入与内核返回路径
linux·运维·面试·职场和发展
2603_966437261 小时前
格式化之后数据还能恢复吗?说清恢复条件
linux·运维·数据库
evans在进步1 小时前
LeetCode 198:打家劫舍——Java 动态规划详解
java·leetcode·动态规划
RisunJan1 小时前
Linux命令-traceroute(IP 路由路径追踪)
linux·运维·tcp/ip
2601_963870221 小时前
【计算机毕业设计】基于Spring Boot的考研信息交流平台的设计与实现
java·spring boot·后端
大模型丫丫2 小时前
Spring Boot 入门指南:从零开始构建你的第一个应用
java·spring boot·后端
拾光Ծ2 小时前
Linux五种I/O模型与fcntl非阻塞控制
java·linux·服务器·i/o模型
半旧5182 小时前
【Java Web02】WEB服务器内部实现技术揭秘
java·服务器·前端