1. 从一个诡异的崩溃现场出发:线程把整个进程带崩了
先讲一件我实际遇到过的事。某项目里写了一个图像批处理服务,主进程常驻,每来一张图就起一个工作线程去做算法推理。某天线上突然发现整个服务挂了,core dump文件堆了一地,查看日志定位到是一个线程里有个空指针解引用。当时第一反应是"线程崩了,重启线程不就完事了吗",结果翻代码才发现,进程直接没了。
这个经历我后来在讲线程课的时候经常拿出来说:很多同学背过"线程是进程的一条执行流""线程比进程轻量",但真到了排查现场,还是会混淆线程和进程的边界。为什么一个线程段错误,整个进程会退出?为什么进程里某个线程死循环,CPU占用却看起来是"整个进程"在烧?这些问题不搞明白,写多线程程序就是在踩雷。
这篇是"Linux线程"系列的第一篇,我把最基础也最容易被忽略的三件事讲透:线程到底是个什么概念、线程和进程的区别边界在哪里、Linux内核底层是怎么实现线程的。如果你正在学系统编程、准备面试,或者已经在写多线程代码但遇到诡异问题时没有排查头绪,这篇内容值得看完。
先看一个可以自己复现的示例代码。下面这段程序创建一个子线程,子线程里故意解引用空指针:
#include <stdio.h> #include <pthread.h> #include <unistd.h> void *bad_thread(void *arg) { int *p = NULL; *p = 1; // 段错误 return NULL; } int main(void) { pthread_t tid; pthread_create(&tid, NULL, bad_thread, NULL); printf("main thread: pid=%d, sleeping...\n", getpid()); sleep(100); return 0; }对Linux稍微有点直觉的同学可能会想:"主线程还在sleep,子线程段错误应该不影响它吧?"实际编译运行你会看到,进程直接收到SIGSEGV终止,整个程序退出。这就是第一个需要记住的结论:同一进程内的线程共享同一个地址空间,地址空间里发生的致命错误会让整个进程陪葬。
这个现象背后,正是线程定义的核心——线程穿的是同一个进程的"内存衣服",它们能在同一个地址空间里并行跑,但也因此同生共死。下面我从概念到内核,一层层拆开看。
2. 别被"轻量级进程"带偏了:线程本质上是一套资源组合策略
很多人第一次接触Linux线程时,听过一个说法:线程就是轻量级进程(Lightweight Process)。这个说法不算全错,但它特别容易让人误解,以为线程是"缩小版进程"。理解线程的关键,是要换一个角度:进程是资源容器加执行单元,线程只是从资源容器里分出来的执行单元。
2.1 进程和线程的经典比喻,为什么说它不是一回事
把进程想象成一套房子,地址空间就是房子的墙、地板、家具,文件描述符表就是钥匙串,信号处理函数就是房子里的各种约定。线程则是在这套房里一起生活的住客。住客可以各自有手机(栈),可以自己记账(寄存器上下文),但墙和地板是大家共享的,谁也不能独自砸墙,谁砸了整栋楼都要出问题。
这个比喻能解释很多实际问题:
- 线程A修改了全局变量,线程B立刻能看到——因为你们共享起居室。
- 线程A调用chdir改了工作目录,线程B的当前目录也跟着变——因为你们共享厨房和储物间。
- 线程A崩溃了,整个进程退出——因为你们住的是同一套房,承重墙被打穿,谁也跑不掉。
而进程之间是两套独立的房子,各自有墙,互不干扰。一个进程崩溃,另一个进程通常毫发无损。
2.2 从名词根源上理解:process是容器,thread是执行流
我建议把这两个词在脑子里重新读一遍。Process(进程)强调的是"进行中"的资源集合体,它有独立的内存、独立打开的文件、独立的地址空间。Thread(线程)强调的是"一条线",是CPU执行的路径。
在Linux内核的视角里,这种区别更直接:内核不区分所谓的"进程"和"线程",它只认识一个东西,叫做task(任务)。当你调用pthread_create创建一个线程时,内核实际做的事情是——复制了一个task_struct,然后通过参数告诉内核:"新的task和我共享地址空间、共享文件表,只需要给我自己分配的栈和寄存器。"所以"线程轻量"的轻,不是指它个头小,而是指它在创建时不复制一堆资源,只复用父任务的资源。
从这个视角回看"轻量级进程"这个词,你就能明白为什么它容易误导人。它说的是:线程在内核里确实是按"进程"(task)来管理的,但它不是一个完整的独立进程,因为它没有独立的地址空间。
2.3 clone系统调用:线程与进程的分岔口
Linux里创建进程和创建线程,底层走的都是同一个系统调用:clone。不同之处在于传入的参数。这是理解线程内核实现最重要的一把钥匙。
| 创建方式 | 底层系统调用 | 关键标志 | 结果 |
|---|---|---|---|
| fork() | 多数走 clone | 不带共享标志 | 独立地址空间,COW写时复制 |
| vfork() | clone | CLONE_VM | CLONE_VFORK | 共享地址空间,父进程暂挂 |
| pthread_create() | clone | CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | 共享地址空间、文件表、信号处理 |
这里的几个标志位就是线程与进程分离的秘密:
CLONE_VM:新task与父task共享同一个内存描述符(mm_struct),不复制页表。CLONE_FILES:共享同一个文件描述符表。注意,fork出来的进程有自己独立的文件描述符表,父子之间对fd的操作互不干扰;而线程共享,所以线程里close一个fd,其他线程也会受影响。CLONE_FS:共享根目录、工作目录和umask。CLONE_SIGHAND:共享信号处理函数表。CLONE_THREAD:将新task加入同一线程组,这是"成为一个线程"而不是"成为兄弟进程"的关键。
看到这里,你应该能理解一个核心结论了:Linux中的线程不是一个全新的内核对象,而是clone的一种使用方式。所谓线程和进程的区别,本质上是创建时选用了不同的资源复制策略。
3. 内核里的"一线程一进程":task_struct和线程组
上一章说了clone是分岔口,这一章我们钻进内核,看看"线程"在内核里到底长什么样。很多人以为内核里会区分"这个是进程,那个是线程",其实完全不是。
3.1 调度器眼中没有线程进程之分
在Linux的调度器里,所有的可运行实体都叫task,每个task对应一个task_struct结构体。调度器不关心你的task是被fork出来的、还是被pthread_create造出来的,更不关心用户态管它叫"进程"还是"线程"。所有task全都挂在同一条运行队列上,按优先级和时间片被调度。
这就是Linux线程模型中最核心的设计:1:1模型,一个用户态线程对应一个内核态调度实体。
需要说明的是,Linux曾经有过2:1模型(线程在用户态和内核态之间多了一层)的探索,但现代Linux(从NPTL,原生POSIX线程库开始)使用1:1模型,用户线程直接对应内核task。这也是为什么pthread能干的事情,用clone也能干,两者只是封装关系。
3.2 tgid和pid:getpid为什么返回同一个值
如果你在Linux线程里调用getpid(),会发现所有线程返回同一个数字,这个数字其实是线程组ID(TGID)。Linux引入了线程组的概念:
- 每个线程有自己独立的PID,在内核里叫
pid,可以通过gettid()获取。 - 整个线程组共享一个TGID,用户态
getpid()返回的其实是TGID。 - 当用户输入
ps -L查看线程时,能看到每个线程的LWP(轻量级进程)号,它对应的就是这个线程自己的PID。
这也是"线程=轻量级进程"这个说法在工具层面的来源:ps输出里线程显示的列叫LWP,看起来像是一堆进程。
这个机制直接导致了一个面试高频坑:主线程getpid()返回TGID,子线程getpid()也返回同一个TGID,但它们各自的gettid()不同。如果你在代码里用PID区分线程,就会出错。
3.3 task_struct中线程的"独享"与"共享"配件
一个task_struct里既有线程"独享"的部分,也有进程"共享"的部分。现代内核里这两个部分的逻辑关系大致如下:
- 独享部分:内核栈、寄存器上下文(pt_regs)、调度信息(优先级、时间片)、信号掩码、资源使用统计(ru)、清CPU占用、
thread_struct中保存的浮点寄存器等。 - 共享部分:
mm指针指向共享的mm_struct、files指针指向共享的文件表、fs指针指向共享的fs_struct、sighand指针指向共享的信号处理表、signal指针指向共享的信号结构。
注意struct thread_struct,它才是真正意义上"线程自己"的内核数据结构,保存了硬件上下文(寄存器、指令指针等)。它被嵌在task_struct里,每个task一份,用于切换时保存和恢复现场。
这里有个细节值得提:早期内核里thread_info是独立放在内核栈底部的,用来通过栈指针找到task_struct;后来内核重构,thread_info成了task_struct的一个成员,同时内核栈和task_struct之间改用current宏直接推导。你读内核源码时看到这两种说法都正常,是不同年代内核的差异,不必纠结。
3.4 NPTL和1:1模型的代价
NPTL是在Linux 2.6时代引入的,解决了早期LinuxThreads线程库的很多问题:线程管理复杂、信号处理不完整、与POSIX标准不兼容等。NPTL的出现让pthread_create的创建速度大幅提升,也让"线程就是内核task"这个模型彻底固化下来。
但1:1模型不是没有代价。用户态每创建一个线程,内核就多一个task,调度器要维护的队列节点就多一个,上下文切换是全量保存恢复寄存器状态的。所以线程"轻"在创建时的资源复制,不"轻"在调度。线程多了照样吃内核资源,照样可能被调度开销拖垮。写高并发程序时别迷信线程数,线程太多切换开销会反噬。
4. 用一张边界表划清线程和进程的账
概念和内核机制讲了半天,最后都需要落到一张清晰的分界表上。这张表是一个有经验的开发者脑子里的"活地图",排查多线程问题时,我建议你随时对照它。
4.1 全部共享了什么、独享了什么
| 资源类型 | 进程之间 | 线程之间 | 说明 |
|---|---|---|---|
| 地址空间 | 独立 | 共享 | 线程共享全局变量、堆、代码段 |
| 文件描述符表 | 独立 | 共享 | 线程close的fd影响所有人 |
| 工作目录/umask | 独立 | 共享 | 一个线程chdir全线程组变化 |
| 信号处理函数 | 独立 | 共享 | 一个线程设置处理函数全局生效 |
| 栈 | 独立 | 独立 | 每个线程独立栈,大小可设 |
| 寄存器上下文 | 独立 | 独立 | 每条执行流有自己的PC和寄存器 |
| 信号掩码 | 独立 | 独立 | 各线程可以阻塞不同的信号 |
| TLS(线程局部存储) | 不涉及 | 独立 | __thread变量各线程一份 |
| errno | 独立 | 独立 | 线程安全的关键 |
| 优先级/调度策略 | 独立 | 独立 | 但线程组内可设置组内调度 |
这张表最右边的"说明"列,每一行都是一个潜在bug。举个最常见的例子:很多朋友写多线程代码,调用了某个库函数后检查errno,发现结果不对。原因就是errno在NPTL里通过TLS实现,每个线程一份。你在线程A里看到的errno,绝不可能是线程B设置的errno。这其实是个特性,不是bug,但如果你在共享上下文中错误地读写errno,就会踩坑。
4.2 三种"边界事件"谁影响谁
光有静态的表还不够,边界条件下线程之间、进程之间的影响关系,才是排查问题的关键。我挑三个典型场景说。
场景一:主线程return,其他线程会怎样?
主线程从main函数return,等价于调用exit()。而exit()会终止整个进程,所有线程一起死。但如果主线程调用pthread_exit()退出,进程不会立即退出,其他线程可以继续运行。很多人只知道"主线程退出程序就结束",却不清楚这个区别是exit和pthread_exit的区别。
场景二:一个线程段错误,其他线程还能活吗?
不能。段错误的本质是访问了非法地址,意味着共享地址空间被破坏或者访问了不属于进程的地址。内核给整个线程组发送SIGSEGV,不管你是哪个线程,全部终止。这就是本章开头那个崩溃案例的底层解释。
场景三:一个线程调用了fork,会怎样?
这个是最容易被忽略的。线程里调用fork,新进程只复制调用它的那个线程,其他线程不会出现在子进程里。这意味着子进程里可能没有持有锁的线程,锁状态却被复制了过来,直接导致子进程的死锁。这也是为什么多线程程序里fork之后不能随便调用非异步信号安全函数。真要在线程里用fork,记得exec尽快替换进程映像,或者用pthread_atfork注册回调。
4.3 从"共享什么"反推同步需求
以前有同学问我:"线程需要加锁,进程之间为什么不用加锁?"答案就在共享表里:线程之间共享地址空间,所以同一个内存地址可以被多个线程同时读写,不加锁就是数据竞争;进程之间地址空间独立,默认不存在共享内存,所以不需要为普通全局变量加锁。等你用到进程间共享内存(比如mmap共享映射)时,进程之间也需要类似锁的机制。
从此你判断一个多线程问题,不需要背规则,只需要问自己一句:"这个资源,线程之间共享吗?"共享就涉及同步,不共享就各自安好。这是把线程概念用到实际开发中的核心思维方式。
5. 实测线程"快"在哪一步:fork与pthread_create的开销拆解
我一直觉得,理解了概念和内核结构之后,有必要看看真实开销,这样才能把"线程比进程轻量"这种话落到数字上,也才能明白它到底轻在哪。
5.1 创建开销对比的实测范围
我经常用一个简单的压力脚本做对比测试:循环创建N个进程和N个线程,统计总耗时。在通用服务器环境(多核x86,Linux 5.x内核,glibc默认配置)下,一个常见的量级结果是:创建10000个进程大约需要数秒级别,而创建10000个线程大约只需要几百毫秒到1秒以内,差距通常在数倍到十几倍之间。具体数字受内核版本、内存大小、CPU频率影响很大,所以我这里不给你一个精确的"标准答案",而是让你关注一个量级结论:同样数量下,pthread_create比fork快得多,但并没有快到完全不在一个量级。
5.2 fork慢在哪里
fork慢的原因不是"复制内存",现代Linux的fork走COW(写时复制),一开始不真正复制物理页。它慢在以下几个环节:
- 复制
task_struct和内核栈。 - 复制
mm_struct,包括页表层级结构,要拷贝顶级页表项。 - 遍历并复制文件描述符表(
fdtable),每个fd的引用计数要增加。 - 复制信号结构、文件系统结构。
- 如果进程本身内存映射很多(mmap区域多),
fork需要遍历VMA红黑树。
其中页表复制是重头戏:拷贝页表不是拷贝物理页,但页表本身可能已经很大,大型进程的页表拷贝成本相当可观。
5.3 pthread_create快在哪里
pthread_create底层走clone,同时设置了CLONE_VM等共享标志。对内核来说,它不需要复制mm_struct,不需要拷贝页表,不需要复制文件描述符表,只需要:
- 分配一个新的
task_struct和内核栈。 - 复用父task的
mm、files、fs、sighand指针,只增加引用计数。 - 设置新task的启动入口和用户栈(用户栈由pthread库在用户态分配)。
- 将新task加入线程组并唤醒。
省掉的步骤正是fork开销最大的几个环节。所以线程"轻量"一词,从实现角度指的是不复制资源、共享引用,而非"占用内存小"。
5.4 一个额外的组装开销
pthread_create也不是完全没有额外成本:它在用户态要先创建线程栈,默认栈大小通常是8MB的虚拟内存区域(用mmap分配),还要初始化pthread描述符struct pthread。这些都需要用户态到内核态的多次切换。所以如果你比较"裸clone调用"和"pthread_create",pthread_create会增加一层pthread库的管理开销。
实际写多线程程序时,不建议把创建线程放在热路径循环里反复调用。一个比较通用的思路是线程池:预先创建好一组线程,用任务队列分发工作。线程创建一次的成本虽说不算高,但高频创建销毁会带来大量用户态/内核态切换、栈内存反复mmap/munmap,浪费资源不说,还可能让内存碎片情况恶化。
写到这里,关于线程是什么、和进程的区别、内核怎么实现,这三块核心内容基本讲完了。概念是往下走的地基。后续第二篇我会接着讲线程的调度机制、线程切换的具体过程,以及锁和同步背后的内核实现,那些东西仍然要基于这里说的task_struct模型展开。实际工作中遇到线程问题,先别急着加锁或者改代码,先把"共享了什么、独享了什么"这张图画出来,一半的坑,坑底你已经能看清了。