【Linux线程】线程到底是什么?从地址空间、LWP 到 pthread_create 与 TID(上篇)
🔥 本文定位 :这是 Linux 线程系列上篇。我们先不急着背 API,而是从虚拟地址空间和内核调度实体出发,回答"一个进程为什么能拥有多条执行流",再完整走通
pthread_create()与线程标识体系。💡 学习目标 :读完本文,你应该能准确区分 进程与线程、并发与并行、共享资源与线程上下文、
pthread_t与 Linux TID,并能写出生命周期正确的线程创建代码。📌 系列导航:上篇讲"线程是什么、如何创建";下篇继续讲"线程如何退出、回收、取消,以及 NPTL 如何实现"。

文章目录
- 一、线程到底是什么
- [二、Linux 如何表示线程](#二、Linux 如何表示线程)
- 三、为什么理解线程必须先理解地址空间
- 四、同一进程的线程共享什么、独享什么
- 五、多线程的收益、代价与适用场景
- [六、Pthreads 编译、返回值与创建接口](#六、Pthreads 编译、返回值与创建接口)
- [七、pthread_t、PID、TGID 与 TID](#七、pthread_t、PID、TGID 与 TID)
- 上篇总结
一、线程到底是什么
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 线程的主要优势
- 通信直接:共享内存中的对象可以直接访问,不需要序列化和 IPC 搬运;
- 创建与切换通常较轻:复用地址空间和进程资源;
- 可利用多核:独立计算任务可以在多个 CPU 上并行;
- 覆盖 I/O 等待:一个线程阻塞时,其他线程继续处理任务;
- 职责拆分: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 栈上仍然安全?因为 main 在 pthread_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_join、pthread_cancel、pthread_equal |
| Linux TID | 当前 PID namespace 中的内核任务标识 | 日志、/proc、跟踪、系统级诊断 |
| TGID/PID | 线程组/进程 | 进程管理、普通 kill() 目标语义 |
不要把当前 glibc 某个版本里 pthread_t 的具体表示写进协议或持久化文件。实现可以变化,POSIX 也没有要求它等于 TID。
上篇总结
本文建立了 Linux 线程最重要的认知主线:
text
进程提供资源容器
↓
线程是可独立调度的执行流
↓
同组线程共享地址空间和进程级资源
↓
每个线程保留独立寄存器、栈、mask、errno 与调度状态
↓
pthread_create 创建新执行流
↓
pthread_t、TGID、TID 分别服务于不同观察层
请牢牢记住下面六点:
- 线程共享地址空间,但不共享寄存器上下文和栈的使用权;
- "线程私有栈"不代表其他线程在硬件上无法访问它;
- 编译线程程序应使用
-pthread; - 多数 Pthreads 函数直接返回错误号,不通过
errno报错; pthread_t是不透明库标识,不能当成 Linux TID 使用;- 创建成功不代表父线程或子线程一定先运行,执行顺序必须靠同步建立。
到这里,我们解决了"线程是什么"和"线程如何创建"。但线程创建只是生命周期的起点:它怎样返回结果?退出后谁回收资源?detached 为什么仍有生命周期风险?pthread_cancel() 为什么不是立即杀死线程?这些问题将在下篇继续展开。
📖 下篇预告:《Linux 线程控制全解析:终止、join/detach、cancel、线程栈与 NPTL(下)》
发布上下篇后,可以在这里补充下篇链接,形成完整系列导航。
权威参考
- pthreads(7):POSIX 线程共享属性、独立属性与 NPTL
- pthread_create(3):线程创建、继承属性与默认栈说明
- pthread_self(3):pthread_t 的作用域与比较方式
- gettid(2):Linux 内核线程 ID
如果本文对你有帮助,欢迎点赞、收藏。下篇将继续拆解线程终止、join/detach、取消、线程栈与 NPTL 实现。