线程基础:为什么要有线程

上一组我们已经把进程讲透了——进程是带着独立地址空间、资源和 PCB 的执行实体,操作系统靠它来隔离和调度任务。既然进程已经能表示"一个运行中的程序",操作系统为什么还要再搞出一个"线程"?这不是叠床架屋吗?

要回答这个问题,得先回到上一组结尾埋的那个矛盾:进程把资源分配执行调度两个角色绑死在一起了。一个进程 = 一片地址空间 + 一条执行流。可现实里,我们经常需要在同一片地址空间、同一套资源上,跑多条执行流。比如一个服务器进程,想一边接收新连接、一边处理已有请求、一边刷日志——这些活儿共享同一份内存数据和连接,却需要各自独立推进。

如果用多进程来做,每条执行流都得背一整套独立地址空间,又重、又难共享数据。线程就是为拆解这个矛盾而生的。这一篇我们先看清线程在机制上到底是什么、它和进程共享什么、私有什么,再回答它到底解决了什么问题、又带来了什么新麻烦。

一、机制核心:线程到底是什么

直接给出最关键的一句话,后面都是对它的展开:

线程是进程内部的一条独立执行流,是 CPU 调度的基本单位。一个进程可以包含多个线程,它们共享进程的资源,但各自拥有独立的执行现场

把上一组的进程拆开看,进程其实由两部分构成:一部分是"资源容器"(地址空间、打开的文件等),一部分是"执行流"(PC、栈、寄存器现场)。线程做的事,就是把这两部分解耦:

  • 资源容器仍然属于进程,由进程内所有线程共享
  • 执行流则被独立出来,成为线程,一个进程里可以有多条。

于是角色重新分工:进程成了"资源分配的单位",线程成了"CPU 调度的单位"。CPU 实际调度执行的,不再是"进程"这个笼统的东西,而是一条条具体的线程。上一篇讲的进程状态机(运行/就绪/阻塞)和上下文切换,在现代系统里其实更准确地是发生在线程这一级。

二、机制:哪些是共享的,哪些是私有的

这是线程最核心、也最容易出错的知识点,必须分得清清楚楚。同一进程内的多个线程:

共享(属于进程、所有线程都能看到同一份):

  • 地址空间——这是重中之重。代码段、全局变量、堆(malloc/new 出来的内存)全是共享的。一个线程改了某个全局变量或堆对象,别的线程立刻看得到。
  • 打开的文件描述符表——一个线程打开的文件、socket,别的线程也能用同一个 fd 访问。
  • 进程的其他资源——信号处理设置、当前工作目录、用户权限等。

私有(每个线程各有一份):

  • ——每个线程有自己独立的栈,存放它自己的函数调用链、局部变量。这保证了不同线程可以同时调用函数、各自有各自的局部变量而不打架。
  • 寄存器现场——尤其是程序计数器(PC)和栈指针(SP)。每个线程"执行到哪了"是独立的,这正是它们能各自独立推进的根本。
  • 线程本地存储(TLS)——如果用到,每个线程有自己的一份。

一句话记牢:线程之间共享数据(堆、全局变量),私有执行(栈、寄存器、PC)。共享是它的威力来源,也是它一切麻烦的根源。

这张共享/私有的清单,是理解后面所有并发问题的钥匙。为什么多线程能轻松共享数据?因为地址空间共享。为什么多线程会有数据竞争?也因为地址空间共享。为什么局部变量天生线程安全、全局变量却危险?因为栈私有、堆与全局共享。

三、机制:线程的创建与切换为什么比进程"轻"

理解了共享/私有,就能解释线程为什么轻量。对比一下创建和切换的成本:

创建一个进程:要复制/建立一整套地址空间、页表、打开文件表,开销大。

创建一个线程:因为地址空间、文件表都共享、不用新建,只需要分配一个新的栈和一组寄存器现场、新建一个轻量的线程控制结构。开销小得多。

进程间切换:要切换页表基址(CR3),导致 TLB 大量失效(回顾上一篇的间接成本)。

同进程内线程切换:因为地址空间相同、页表不用换,TLB 大部分仍然有效,间接成本明显更小。只需保存/恢复寄存器现场和切换栈。

线程"轻"的本质,是它复用了进程已经建好的地址空间和资源。创建时不用重建这些,切换时不用切页表,省下的正是进程操作里最贵的那部分。

这就解释了为什么需要进程内多并发时,线程几乎总是比多进程更高效。当然,"轻"是相对的——线程仍然是内核要调度的实体,开太多照样会让上一篇说的切换成本压垮系统,这点后面会提。

四、为什么需要线程:用一个服务器例子说透

机制清楚了,回到设计动机。我们用一个后端最熟悉的场景,看线程到底解决了什么。

设想一个 Web 服务器,要同时处理上千个客户端请求。看看几种方案:

  • 单进程单线程、串行处理:一次只能处理一个请求,处理这个的时候,正在等数据库返回(阻塞),CPU 就空着,别的请求全得排队。吞吐量极低。
  • 多进程,每个请求一个进程:能并发了,但每个进程一套地址空间,内存开销大;进程间想共享缓存、连接池等数据非常麻烦(要走共享内存/IPC);创建销毁也慢。
  • 多线程,每个请求(或一批请求)一个线程:所有线程共享同一份缓存、连接池、配置(因为地址空间共享,直接读写内存就行),创建切换都轻。一个线程阻塞在数据库 I/O 时,其它线程照常处理别的请求,CPU 不空转。

第三种方案的优势全部来自线程的两个特性:共享资源(省内存、易协作)和轻量(能开很多、切换便宜)。

线程的价值,是在"需要共享同一份资源"的前提下,提供"多条可并发、可独立调度、又足够轻量"的执行流。当任务天然共享大量数据时,多线程比多进程优雅得多。

这也回答了开头的疑问:线程不是叠床架屋,它是把进程过于"重"和"粗"的执行能力,细化成了可以共享资源的轻量并发单位。

五、为什么线程能提升性能:两种不同的收益

线程带来的性能提升,其实来自两个完全不同的机制,分清它们对工程决策很重要。

收益一:重叠 I/O 等待(对 I/O 密集型有效,单核也有效)

当一个线程因为读数据库、读磁盘进入阻塞态时,CPU 可以立刻调度另一个就绪线程去干活,而不是干等。多个线程的"等待时间"被重叠起来了。这种收益即使只有一个 CPU 核也成立,因为它省的是"空等"。Web 服务、数据库客户端这类 I/O 密集型程序,靠的主要是这个。

收益二:真正的并行计算(对 CPU 密集型有效,需要多核)

当有多个 CPU 核时,多个线程可以真正同时在不同核上执行计算。这种收益需要多核硬件,针对的是 CPU 密集型任务(如并行计算、批量数据处理)。

把这两种收益分开:I/O 密集型靠"重叠等待",单核也受益、可以开远超核数的线程;CPU 密集型靠"并行计算",受核数限制、线程开太多反而因切换成本受损。混淆这两者,是线程数配置出错的常见原因。

六、共享是把双刃剑:线程的代价

机制和收益都讲了,必须同时讲清线程的代价——而它最大的代价,恰恰来自它最大的优点:共享地址空间

因为多个线程能同时读写同一份内存数据,于是:

  • 数据竞争:两个线程同时改同一个变量,结果取决于谁先谁后这种不可控的时序,可能算错(这正是下一篇"并发为什么会出错"的主题)。
  • 需要同步:为了不出错,得用锁、信号量等手段约束访问顺序,而这又带来死锁、性能下降等新问题。
  • 一个线程崩溃可能拖垮整个进程:因为共享地址空间,一个线程的内存越界写可能破坏别的线程的数据,甚至让整个进程崩溃。这点和多进程"一个崩了不影响别人"恰好相反——这也是为什么有些追求稳定隔离的场景仍然选多进程。

线程把"隔离"换成了"共享",换来了协作便利与轻量,却也交出了进程间那道天然的保护墙。共享带来的数据竞争与同步难题,是并发编程一切复杂性的源头。

这就是为什么后面要花大量篇幅讲同步——线程的便利不是白来的,它把一部分"保证正确性"的责任,从操作系统转嫁给了写程序的你。

七、和工程实践 / 后端开发的联系

线程模型直接决定了你怎么设计后端服务:

  • 线程池:频繁创建销毁线程仍有开销,于是用线程池复用固定数量的线程。池的大小要按任务类型定——I/O 密集可以远超核数,CPU 密集贴近核数(呼应第五节)。这是后端最基础也最常配错的参数。
  • 线程安全:因为堆和全局变量共享,多线程访问的共享对象必须保证线程安全(加锁、用并发容器、或设计成无共享)。理解"共享什么"才能判断"哪里需要保护"。
  • 线程本地存储(ThreadLocal):当你想让每个线程有自己独立的一份数据(如每个线程独立的数据库连接、上下文),就用 TLS——它利用的正是"线程私有"这一机制。误用 ThreadLocal(如线程池里不清理)会导致内存泄漏或数据串台,根子还是没分清共享与私有。
  • 崩溃影响面:理解"一个线程崩溃可能带垮整个进程",才能在稳定性要求极高的场景做出"用多进程隔离"的合理取舍(如浏览器、Nginx)。

学习这一部分最容易踩的坑

1. 分不清线程共享什么、私有什么

这是线程一切问题的源头。共享:地址空间(堆、全局变量)、文件描述符;私有:栈、寄存器、PC。分不清这个,就判断不了"哪个变量需要同步"“为什么局部变量安全”,并发代码必然出错。

2. 以为线程轻量就可以无限开

线程比进程轻,但仍是内核调度实体,开太多照样会因上下文切换成本(上一篇)拖垮系统。"轻"是相对的,不是免费的。线程数要按任务类型和核数规划,不能无脑堆。

3. 以为多线程一定比单线程快

I/O 密集型靠重叠等待获益,CPU 密集型靠多核并行获益。如果是单核上的纯 CPU 计算,多线程不仅不快,反而因切换更慢。多线程的收益有前提,不是万能加速器。

4. 把线程和协程混为一谈

线程通常由操作系统内核参与调度,切换要进内核;协程是用户态的并发抽象,在用户态切换、更轻。它们不在一个层面。(用户态/内核态线程的区别,正是下一篇的主题。)

总结

线程的出现,是为了拆解"进程把资源分配和执行调度捆死"这个矛盾——它让多条执行流共享同一片资源,从而实现进程内部灵活、轻量、高效的并发。但线程带来的共享,也把系统带入了更复杂的数据竞争与同步问题。

  • 线程是进程内的独立执行流、CPU 调度的基本单位;进程退化为资源分配单位;
  • 线程共享地址空间(堆、全局变量)和文件描述符,私有栈、寄存器和 PC——这是理解一切并发问题的钥匙;
  • 线程"轻"在复用了进程已建好的地址空间:创建不用重建资源,切换不用换页表、TLB 失效少;
  • 线程解决了"需要共享资源的多并发",I/O 密集型靠重叠等待获益、CPU 密集型靠多核并行获益;
  • 共享是双刃剑:它带来协作便利与轻量,也带来数据竞争、同步难题,以及"一个线程崩溃可能拖垮整个进程"。

当你能脱口而出"线程共享什么、私有什么",并理解它的收益与代价各自来自哪里,下一篇——线程到底由用户态库实现还是由内核实现——就有了坚实的基础。

参考资源

  • 《现代操作系统》(Andrew S. Tanenbaum)线程章节
  • 《操作系统导论》(OSTEP)并发与线程章节
  • 《深入理解计算机系统》(CSAPP)并发编程章节
  • POSIX Threads (pthreads) 文档