近几年做后端和中间件相关的工作,被问得最多的一个基础问题就是:线程切换到底在干什么,为什么一说到并发性能,大家都先提“上下文切换开销”。这个问题看起来简单,但真要落到“计算机原理”层面,牵扯到操作系统调度器、CPU寄存器、内核栈、缓存亲和性,甚至能一路聊到线程池怎么配置、锁怎么设计。我觉得这是整个并发编程里最值得讲透的一个点。
这篇文章就围绕“线程”和“上下文”两个关键词展开。你可以把它当成一份从原理到实战的笔记,适合刚学完操作系统、想搞清楚线程到底怎么跑起来的同学,也适合写了好几年业务代码、但遇到线上性能问题只能靠猜的开发者。我会尽量把底层的原理讲明白,再给出能直接用的排查命令和配置思路。
1. 从一块CPU开始:为什么需要线程这个概念
1.1 进程和线程的本质区别
很多人背过“进程是资源分配的最小单位,线程是CPU调度的最小单位”,但这句话到底什么意思,能解释清楚的人不多。我换个角度说:进程像一条生产线,有自己的厂房、设备、原料仓库,而线程是这条生产线上的工人。厂房和设备是进程的资源,包括内存空间、文件句柄、打开的网络连接等,工人则是真正干活的角色,它拿着CPU去执行代码。
从计算机原理的视角看,CPU本身并不知道“线程”的存在,它只认识指令流。早期的操作系统,比如DOS,同一时刻只能跑一个程序,CPU从头到尾执行一条指令流,根本不需要线程和上下文切换的概念。后来大家发现一个程序在等待磁盘I/O时CPU就闲着,太浪费了,于是操作系统引入了“并发”机制:让多个程序轮流使用CPU,谁等待I/O了,就切到另一个程序继续算。
进程在这个阶段就是并发的基本单位。但由于进程之间内存空间是隔离的,切换进程时不仅要保存CPU的寄存器状态,还要切换整个虚拟内存地址空间,也就是要换页表。页表一换,TLB(快表)直接失效,后续访存几乎都要重新查页表,代价非常大。所以后来的操作系统引入了线程,让同一进程内的多个线程共享地址空间,切换时不用换页表,成本低得多。
1.2 为什么不能只靠进程
如果并发只需要“多进程”,那线程是不是多余?现实是,多进程方案虽然隔离性好,但有两个硬伤。
第一是通信成本高。进程间通信要用管道、消息队列、共享内存、Socket这些机制,每次都要经过内核或做额外的同步,写起来也麻烦。而同一个进程内的线程天然共享内存,直接读写同一块变量就行,开发效率高很多。
第二是切换开销。一次进程切换,轻则几十微秒,重则上百微秒。对于高并发的网络服务,每秒可能要切换几十万次上下文,这个开销会被放大到不可接受。线程切换虽然也不便宜,但比进程切换省掉了地址空间切换这一步,整体开销可能只有进程切换的几分之一。
所以现代操作系统里,几乎所有大规模并发应用都是多线程模型,而不是多进程模型。比如Nginx的worker进程内部用事件循环和线程池配合,Redis虽然是单线程,但其实是“单线程 + 事件驱动”,它刻意避开线程切换的开销。这些设计的背后,本质上都是在权衡上下文切换带来的成本。
注意:并不是线程越多越好,也不是“多线程一定比单线程快”。如果任务以计算为主、CPU已经饱和,多线程反而会因为上下文切换变慢。线程的意义在于让CPU和I/O设备都能忙起来,而不是单纯“更多线程等于更高并发”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 上下文切换的完整拆解:保存、切换、恢复
2.1 上下文到底是什么
“上下文切换”里的“上下文”,英文是Context。很多人一听这个术语就发怵,其实它的含义非常朴素:CPU正在执行某条指令流,要想中途切走、之后再切回来继续跑,就必须把当前指令流的所有状态保存下来,这个状态集合就叫上下文。
具体来说,上下文至少包括这几类信息:
- CPU寄存器的值。包括通用寄存器(eax、ebx、ecx等)、程序计数器PC、栈指针SP、状态寄存器等。
- 线程的栈信息。每个线程有自己的内核栈和用户栈,函数调用链条、局部变量都在栈里。
- 浮点寄存器和SIMD寄存器状态。如果程序用到浮点运算或向量指令,这部分也必须保存。
- 如果是进程切换,还要保存页表基址、文件描述符表、信号处理信息等。
所以在操作系统的调度器眼里,每个线程就是一个“上下文实例”。调度器做的核心工作,就是从就绪队列里选一个线程,把它上次保存的上下文恢复进CPU,让指令流接着往下跑。
2.2 一次切换到底发生了什么
线程A正在CPU上执行用户代码,此时发生了时钟中断,或者A主动让出CPU(比如调用sleep、等待锁),操作系统切入内核态,调度器开始工作。大致步骤如下:
- 保存当前线程A的所有寄存器状态到A的内核栈或线程控制块中。
- 更新A的状态为阻塞或就绪,从运行队列中移除。
