【Linux线程】线程到底是什么?从地址空间、LWP 到 pthread_create 与 TID(上篇)
🔥本文定位:这是 Linux 线程系列上篇。我们先不急着背 API,而是从虚拟地址空间和内核调度实体出发,回答“一个进程为什么能拥有多条执行流”,再完整走通
pthread_create()与线程标识体系。💡学习目标:读完本文,你应该能准确区分进程与线程、并发与并行、共享资源与线程上下文、
pthread_t与 Linux TID,并能写出生命周期正确的线程创建代码。📌系列导航:上篇讲“线程是什么、如何创建”;下篇继续讲“线程如何退出、回收、取消,以及 NPTL 如何实现”。
文章目录
- 一、线程到底是什么
- 二、Linux 如何表示线程
- 三、为什么理解线程必须先理解地址空间
- 四、同一进程的线程共享什么、独享什么
- 五、多线程的收益、代价与适用场景
- 六、Pthreads 编译、返回值与创建接口
- 七、pthread_t、PID、TGID 与 TID
- 上篇总结
一、线程到底是什么
1.1 线程是进程内部的一条执行流
一个正在运行的程序至少有一条执行路线。只有main执行流时,它是单线程进程;通过线程库创建更多执行流后,同一个进程中就可以同时推进多个任务。
更准确地说:
- 进程承载地址空间、文件描述符、权限、信号处理动作等资源;
- 线程承载程序计数器、寄存器、栈和调度状态,是 CPU 调度的执行实体;
- 多个线程在同一个进程资源容器内执行同一份程序代码;
- 每条线程可以从不同函数入口、不同指令位置继续运行。
进程 = 资源容器 + 至少一条线程 单线程进程:资源容器 + 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,同时通过共享内存描述、文件描述符表、信号处理动作等方式组成线程组。
从不同观察角度,会得到三个层级:
应用/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 缓存近期地址转换结果。虚拟地址连续,不要求物理内存连续。
简化流程:
CPU 产生虚拟地址 ↓ MMU 查询 TLB ├─ 命中 → 得到物理页框 └─ 未命中 → 查询多级页表 → 回填 TLB ↓ 物理页框 + 页内偏移 → 实际物理地址页大小不是“Linux 永远 4 KiB”。4 KiB 在许多系统上很常见,但具体值取决于体系结构和配置,可以查询:
getconf PAGESIZE3.2 同一进程的线程为什么天然能看到同一个指针
因为它们共享同一进程地址空间。一个全局变量、堆对象或内存映射在这个地址空间中只有一套虚拟地址语义,同组线程通过同一映射关系访问它。
这与两个独立进程不同:两个进程里数值相同的虚拟地址,通常会经各自页表映射到不同物理页。
同进程线程 A:0x7f...1000 ─┐ ├─ 同一地址空间映射 → 同一对象 同进程线程 B:0x7f...1000 ─┘ 进程 P:0x400000 → 物理页 X 进程 Q:0x400000 → 物理页 Y3.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
gcc main.c-O2-Wall-Wextra-pthread-oapp g++ main.cc-O2-Wall-Wextra-pthread-oapp相比只写-lpthread,-pthread既处理链接,也允许编译器/头文件启用线程相关选项,是 Linux 上更推荐的用法。
6.2 Pthreads 错误码不通过 errno 返回
绝大多数 Pthreads 函数:
- 成功返回
0; - 失败直接返回错误号;
- 不要期待它把错误写入当前线程的
errno; - 不应直接
perror("pthread_create")。
正确模式:
interr=pthread_create(&tid,NULL,worker,arg);if(err!=0){fprintf(stderr,"pthread_create: %s\n",strerror(err));return1;}6.3 pthread_create() 参数
#include<pthread.h>intpthread_create(pthread_t*restrict thread,constpthread_attr_t*restrict attr,void*(*start_routine)(void*),void*restrict arg);| 参数 | 含义 |
|---|---|
thread | 成功后写入新线程的pthread_t |
attr | 创建属性;NULL使用默认属性 |
start_routine | 新线程的入口函数 |
arg | 原样传给入口函数的单个void *参数 |
6.4 一个生命周期正确的最小示例
#include<pthread.h>#include<stdio.h>#include<stdlib.h>#include<string.h>structtask_arg{intvalue;constchar*message;};staticvoid*worker(void*opaque){conststructtask_arg*arg=opaque;int*result=malloc(sizeof(*result));if(result==NULL)returnNULL;printf("worker: %s, value=%d\n",arg->message,arg->value);*result=arg->value*2;returnresult;}intmain(void){pthread_ttid;structtask_argarg={21,"hello"};interr=pthread_create(&tid,NULL,worker,&arg);if(err!=0){fprintf(stderr,"pthread_create: %s\n",strerror(err));return1;}void*raw=NULL;err=pthread_join(tid,&raw);if(err!=0){fprintf(stderr,"pthread_join: %s\n",strerror(err));return1;}if(raw!=NULL){int*result=raw;printf("result=%d\n",*result);free(result);}return0;}为什么把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 属于未定义行为。
if(pthread_equal(pthread_self(),tid)){/* 当前线程就是 tid 表示的线程 */}7.2 TID 是 Linux 内核线程 ID
Linux 上:
#define_GNU_SOURCE#include<unistd.h>pid_ttid=gettid();pid_ttgid=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 线程最重要的认知主线:
进程提供资源容器 ↓ 线程是可独立调度的执行流 ↓ 同组线程共享地址空间和进程级资源 ↓ 每个线程保留独立寄存器、栈、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 实现。