很多大学操作系统课或者技术面试,都会把进程和线程划分得无比清晰:
“进程是资源分配的基本单位,线程是 CPU 调度的基本单位。”
我刚入行那几年,一直把这句话当成毋庸置疑的铁律。在 Windows 或者 Solaris 系统的早期教科书里,你确实能看到极其清晰的层次结构——进程是个大容器,里面装着独立的地址空间,然后容器内部再跑着若干个轻量级的线程。
但如果你去翻 Linux 系统的内核源码,或者去调 Linux 的调度器,你会发现一个极其“奇葩”的事实:
在 Linux 内核眼里,根本就没有什么专门叫“线程”的数据结构。
不管是你用 fork() 创建出来的独立进程,还是用 pthread_create() 搞出来的线程,在内核里最后都是一个叫 task_struct 的结构体。
刚接触内核源码的时候,我其实挺疑惑的。甚至觉得 Linus Torvalds 是不是当年懒得写两套调度逻辑,才强行把线程和进程搞成同一种东西。
但后来深入看了 UNIX 的演进史和 Linux 早期的内核邮件组(LKML),我才明白,这根本不是什么偷懒,而是 Linus 对“复杂性”一次近乎偏执的拒绝。
我们可以把时间倒回到 1990 年代初。
当时 UNIX 世界里已经有一套非常成熟、也很优雅的理念:一个进程对应一个独立的虚拟地址空间,进程之间通过页表彻底隔离。
这种隔离性极好,一个进程崩溃了,绝对不会把隔壁进程的数据搞脏。
但随着多核 CPU 的出现和高性能网络服务器的兴起,问题出来了。如果你要处理 1000 个并发连接,用传统 fork() 创建 1000 个进程,内核需要复制 1000 份页表、信号表、文件描述符表。切换上下文的时候,TLB(页表缓存)要频繁刷掉,性能开销大得让人肉疼。
于是,当时的 Unix 阵营(比如 Sun 公司的 Solaris,还有 Mach 内核)提出了极其宏大的架构设计:
必须在内核层引入“一级公民”级别的线程概念。
在他们的设计里,内核要同时维护“进程”结构体和“线程”结构体。进程负责管内存页表、打开的文件、信号处理;线程只管寄存器状态、内核栈、调度优先级。为了支持 POSIX 线程标准,内核调度器还要写两套极其复杂的调度队列和同步机制。
这套设计在理论上极其工整,符合所有教科书对面向对象和模块化的审美。
但在工程实现上,它带来了一个灾难性的后果:内核变得异常臃肿,而且进程与线程之间的资源绑定规则被死死硬编码在了内核深处。
当 1996 年前后,社区开始热烈讨论 Linux 到底该怎么支持多线程时,Linus Torvalds 在邮件列表里发出了非常著名的反驳。
他的逻辑简单粗暴到让人有点猝不及防:
为什么一定要把“资源”和“执行流”硬性绑定成层级关系?
在 Linus 看来,不管是所谓的进程还是线程,它们本质上都只有一个身份:一个正在被 CPU 执行的上下文(An Execution Context)。
真正区分“进程”和“线程”的,根本不是什么高深莫测的内核结构,而仅仅是它们之间到底共享了哪些资源。
- 如果两个上下文共享页表、共享文件描述符表,那在用户态看来,它们就是同一个进程里的“两个线程”。
- 如果两个上下文拥有各自独立的页表,那它们就是“两个独立的进程”。
- 如果它们共享页表,但有自己独立的文件描述符表呢?或者共享文件系统信息,但不共享页表呢?
如果在内核里搞一套死板的“进程包含线程”层次,上面这些灵活的组合就全被堵死了。
于是,Linux 搞出了一条极度不合常规的技术路线:写了一个叫clone()的系统调用。
clone() 不像传统 UNIX 的 fork() 那样只能硬拷贝资源,它允许你传入一堆标志位:CLONE_VM(共享内存)、CLONE_FS(共享文件系统)、CLONE_FILES(共享打开的文件)、CLONE_SIGHAND(共享信号处理函数)。
你想搞传统进程?那就啥标志都不传,全套隔离。
你想搞高效率线程?那就把 CLONE_VM | CLONE_FS | CLONE_FILES 全传进去,共享一切。
对于内核调度器(Scheduler)来说,它根本不需要关心当前跑的是进程还是线程。它眼里只有一个个 task_struct。CPU 给谁分配时间片,谁就拿着自己的 task_struct 去跑。如果两个 task_struct 里的指针指向同一个页表(mm_struct),那 CPU 切换上下文时甚至连 TLB 都不用刷新。
Linux 没有去创造一种新的“线程”实体,而是把“进程”拆解成了“一个执行流 + 一组可选择共享的资源指针”。
这就是 Linux 历史上著名的“轻量级进程(LWP, Light Weight Process)”模式。
这种设计在当时并不是没有代价的。
最典型也最痛苦的代价,就是早期 Linux 上的 POSIX 线程库(LinuxThreads)简直就是一场噩梦。
因为 POSIX 标准规定:一个进程里的所有线程必须共享同一个 PID,给这个 PID 发信号,应该能被任何一个线程响应;一个线程调用 exit(),整个进程的所有线程都得跟着死掉。
但在早期的 Linux 内核里,每个 task_struct 都有独立的 PID。在用户态看来,用 LinuxThreads 跑出来的线程,在 ps 命令里赫然显示为一堆完全不相干的独立进程,给其中一个发 SIGINT 信号,其他线程根本收不到。
那几年,Solaris 和 Windows 的开发者经常拿这件事嘲笑 Linux,觉得 Linux 的实现是个“土法造车”的半吊子,根本不符合正式的操作系统工业标准。
如果故事停在这里,Linux 可能真的会被摒弃在企业级高性能服务之外。
但接下来的事情很有意思。
Linus 和内核社区依然死活拒绝在内核里加入单独的“线程”结构体。他们选择的解决办法是:在不改变task_struct一元化设计的前提下,给内核做微创手术。
2002 年前后,Ingo Molnar 和 Ulrich Drepper 等工程师发起了著名的 NPTL(Native POSIX Thread Library)项目。
他们在 task_struct 里引入了 TGID(Thread Group ID,线程组 ID)的概念,引入了 sys_exit_group() 系统调用,并在内核的信号处理逻辑里加上了针对线程组的路由逻辑。
也就是说,在内核调度器层面,它依然只认 task_struct,依然保持着极度的简洁和扁平;但在与用户态打交道的系统调用接口上,它优雅地履约了 POSIX 规范的所有要求。
工程界最终接受了这个代价。
因为人们发现,这种“一元化”的内核设计,带来了意想不到的巨大灵活性。
在很多年后,当我们站在容器技术(Docker / Kubernetes)暴发的时代重新回头看,才会出冷汗地意识到 Linus 当年的设计有多可怕。
容器的核心技术之一叫 Namespace(命名空间),用来做资源隔离。
因为 Linux 的 task_struct 资源指针是极其松散、高度可配置的,社区只需要在 clone() 系统调用里再加入几个新的标志位——比如 CLONE_NEWPID、CLONE_NEWNET、CLONE_NEWNS——就能以极低的代价,在内核里凭空创造出一个个相互隔离的“容器”。
如果当年 Linux 像 Solaris 那样,把“进程”和“线程”这两个概念在内核深处写死成一套固化的硬编码层级,今天实现 Docker 这种级别的轻量级隔离,代价会高得难以想象。
技术发展到今天,很多上层概念其实都在不断地轮回。
就像 Go 语言的 Goroutine、Java 21 的 Virtual Threads(协程/虚拟线程),本质上也是在用户态重新做了一次同样的事情:不依赖操作系统的重量级概念,把“执行上下文”与“底层的调度实体”解耦。
回到最初的问题:为什么 Linux 不用“线程”来实现进程?
或者说,为什么 Linux 坚持不把线程当成内核的一级公民?
我认为最核心的原因只有一句话:当一个系统试图用不同的固有概念去描述相似的事情时,它就距离被自身的复杂性拖垮不远了。
把复杂的世界打碎成最基础的原子指针,然后把选择权交给调用者。这可能就是 Linux 能够干掉无数优雅的传统 UNIX,活到今天的真正秘密。