你在浏览器里点开一个很大的网页时,页面往往还能滚动,下载进度也在变化,地址栏照样能接受输入。看上去,浏览器像是在同一时刻做很多件事。可如果负责界面、网络、图片解码和脚本执行的工作全挤在一条执行路线里,只要其中一次磁盘读取或图片解码停得久一点,整个窗口就会像被冻住一样。
线程要解决的正是这个矛盾:一个应用需要共享同一批数据和资源,又希望不同工作能各自向前推进。线程把进程内部的一条执行路线拆成多条,每条路线都有自己的执行现场,但仍住在同一个进程里。这样做比创建多个彼此隔离的进程轻便,也让交换数据变得直接;代价是隔离变弱了,一个线程写错共享内存,可能把整个进程一起拖垮。
这一章不急着背接口。我们先弄清线程到底独有和共享什么,再看并发如何落到多核硬件上,最后把线程池、Fork-Join、取消、信号和线程本地存储串成一套完整的机制。
把进程想成一间工作室。工作室里有公共图纸、材料柜和已经打开的设备,所有工人都能使用;每名工人则有自己的任务编号、做到哪一步的记录和一张私人工作台。线程就是工人,进程提供那间共享的工作室。
操作系统要暂停一条线程、改跑另一条线程,至少得知道它下次从哪条指令继续,还要恢复通用寄存器、栈指针等执行现场。因此,一条线程通常有自己的线程标识、程序计数器、寄存器状态和栈。栈里放着这一条调用链的局部变量、返回地址和函数参数。两个线程即使执行同一个函数,也会沿各自的栈进入和返回。
同一进程内的线程通常共享代码、全局数据、堆、打开的文件描述符以及其他进程级资源。共享让线程之间传递一个对象引用就能合作,不必先序列化再跨进程发送;但共享也意味着它们看到的是同一份可变状态。两个线程同时修改一个计数器时,读、改、写三个动作可能彼此穿插,最后得到一个谁也没预料到的结果。共享本身不是同步,正确性仍要靠锁、原子操作、不可变数据或消息队列来建立。

线程崩溃通常不是“只坏掉这一条线程”。如果它发生非法内存访问、破坏了堆结构,或者触发了进程级终止条件,同一进程里的其他线程也可能一起结束。线程换来了低成本共享,却没有进程那样强的故障隔离。
创建新进程时,系统要建立独立的地址空间和一组进程级管理信息。创建同一进程里的线程,不需要再造一份完整地址空间,线程切换也通常不必更换整个进程的内存映射。于是,线程的创建、销毁和切换往往更便宜。
这里的“更便宜”不等于“没有成本”。每条线程仍需要栈和内核管理数据;切换时要保存与恢复执行现场,还可能打乱处理器缓存。线程数量远超处理器核心数后,系统会花更多时间在调度和切换上,内存也会被大量线程栈吃掉。为了每个短任务临时创建一条线程,往往比任务本身还贵,这正是线程池出现的原因。
多线程常见的收益可以拆成三个不同目标。界面程序把耗时工作移出事件线程,是为了响应性;服务器让多个请求交错等待网络和磁盘,是为了吞吐量;计算程序把独立数据块交给不同核心,是为了并行加速。它们都用了线程,但优化目标不一样。
如果任务主要在等待 I/O,一条线程阻塞时让另一条线程运行,单核机器也能受益。如果任务一直占用 CPU,多条线程只有落在多个核心上才可能缩短总计算时间。把“用了多线程”直接等同于“程序更快”,会把这两类情况混在一起。
并发关心的是,多项任务能否在同一段时间内都取得进展;并行关心的是,某个瞬间是否真的有多项任务同时执行。单核处理器可以在几条线程之间快速切换,让下载、界面和脚本都不至于一直饿着,这叫并发,但任一瞬间仍只有一条指令流在核心上运行。多核处理器则可能把几条可运行线程放到不同核心上,形成真正的并行。

下面的演示把四项任务放到一个或四个核心上。逐步推进时,观察“本步同时执行的任务数”:单核模式会轮流前进,多核模式才可能在同一步推进多项任务。
多核不会自动理解业务。程序要先找出可以独立推进的工作,再决定怎样分配。常见的拆法有两种。
数据并行把一大批数据切成若干块,让不同核心执行同一种操作。给一百万张像素做相同滤镜、把大数组分段求和,都属于这一类。它的难点通常在切分是否均匀、数据是否连续,以及最后怎样合并局部结果。
任务并行让不同核心承担不同工作。例如浏览器的一条线程处理网络响应,另一条线程解码图片,渲染线程准备画面。任务之间可能有先后依赖:图片没下载完就不能完整解码,布局没算好也不能最终绘制。此时,多开线程并不能消除依赖,只是让没有依赖的部分尽量重叠。
实际程序常把两者混合。视频编辑器可以按帧做数据并行,也可以让读取、解码、特效和写出形成任务流水线。设计时真正要问的是:哪些数据会被共同修改,哪些步骤必须等前一步,最慢的阶段在哪里。
假设一段程序中必须串行执行的比例是 ,其余部分可以平均分到 个核心上。理想加速比的上界是:
如果有 25% 的工作必须串行,四核时的理想加速比只有约 2.29 倍。即使核心数量趋近无穷大,上界也只是 4 倍。这个公式刻意忽略了线程创建、同步、通信和负载不均等额外成本,所以真实结果通常更低。
它提醒我们,优化并行程序时不能只盯着核心数量。减少串行瓶颈、让任务大小合适、降低共享数据争用,往往比继续加线程更有效。任务切得太粗,部分核心会早早空闲;切得太细,调度开销又会淹没计算收益。
第一道难题是依赖。两个任务都要写同一份数据时,就不能假装它们彼此独立。第二道难题是负载均衡:四块数据大小相同,不代表计算量相同。第三道难题是局部性:线程在不同核心间迁移,或者反复访问远处内存,会让缓存优势消失。
还有一项更隐蔽的成本是可重复性。线程调度顺序每次都可能不同,竞态条件也许运行几千次才出现一次。日志本身会改变时间关系,调试器暂停某条线程后,错误甚至会暂时消失。并发程序需要把不变量、同步边界和失败路径写得比单线程程序更清楚。
程序员说的“线程”不一定就是内核直接调度的对象。语言运行时可以在用户空间保存许多任务的状态,内核则只看见较少的可调度线程。用户线程与内核线程怎样对应,会决定阻塞行为、并行能力和管理成本。

现代桌面和服务器操作系统通常为原生线程提供一对一的内核支持,Pthreads、Windows 线程以及常见 JVM 平台线程最终都会落到操作系统线程上。与此同时,协程、纤程、虚拟线程和任务调度器又在应用层采用类似多对多的思路:程序可以创建大量轻量任务,运行时只用有限的内核线程承载它们。
这两层并不矛盾。内核负责把内核线程放到 CPU 核心上,运行时负责把更轻的任务放到内核线程上。真正棘手的是阻塞:如果运行时不知道某个任务即将做阻塞调用,它可能连承载这个任务的内核线程也一起卡住。成熟运行时会用异步 I/O、阻塞补偿或专用线程池处理这种情况。
较早的多对多实现常在用户线程与内核线程之间放一层轻量级进程,可以把它理解为运行时可使用的“虚拟处理器”。用户线程库把任务调度到这些虚拟处理器上,内核再把对应的执行实体调度到物理核心。
调度器激活试图解决内核与用户线程库彼此看不见的问题:内核发现某条执行路径即将阻塞或已经唤醒时,通过上行通知告诉用户空间线程库;线程库据此保存状态、改派其他用户线程,或请求新的执行承载。它能让多对多映射更聪明,但内核与运行时的接口十分复杂,工程上并没有成为今天通用操作系统的主流方案。
理解这段历史仍有价值。今天的任务运行时虽然接口不同,仍在处理同一组矛盾:任务数量可以很大,内核线程不能无限增加;阻塞不能拖住整个运行时;调度器还要尽量让各核心有活可做。
线程库通常提供四类基本能力:创建执行路线、传入参数、等待完成、取得结果或结束状态。不同平台的名字不同,生命周期却很相似。
Pthreads 是 POSIX 线程接口。下面的例子让工作线程计算从 1 到给定上界的和,主线程用 pthread_join() 等待它结束,并取得返回值。
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
static void *sum_to(void *arg) {
int upper = *(int *)arg;
long long *result = malloc(sizeof(*result));
if (result == NULL) return
pthread_create() 成功返回,只说明线程已经进入可调度状态,并不保证它已经执行到哪一步。pthread_join() 建立了明确的等待关系:调用者在目标线程终止前不能继续越过这个点。可连接线程如果结束后一直没人 join,其部分系统资源不能及时回收;若调用者确实永远不需要等待结果,可以把线程设为分离状态。
参数指针的生命周期也很重要。示例里的 upper 在主线程栈上,但主线程会等待工作线程完成,因此指针一直有效。如果创建线程后立刻从当前函数返回,工作线程再解引用这个地址就可能读到失效内存。线程共享地址空间让传参很方便,也要求我们对对象寿命负责。
Windows 原生接口使用线程句柄创建、等待和关闭内核线程。句柄是内核对象的引用,等待线程结束后仍要关闭句柄,否则会泄漏系统资源。生产代码通常还会优先考虑更高层的线程池或任务接口,而不是为每项短工作直接调用底层创建函数。
Java 把“要做的任务”和“在哪条线程上做”分开表达。Runnable 只执行动作,没有返回值;Callable<V> 可以返回结果或抛出异常;提交任务后得到的 Future<V> 代表尚未完成的结果。
import java.util.concurrent.*;
public class SumDemo {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
Callable<Long> task = () -> {
long sum = 0;
for (long
submit() 把任务交给执行器,调用者可以继续做别的事;future.get() 才会在结果未完成时等待。这里的同步点不是“创建任务”而是“读取结果”。把等待放得太早,会把本可并行的程序重新写成串行;完全不等待,又可能让后续代码在结果尚未准备好时使用它。
调用 Java 线程对象的 run() 只是一次普通方法调用,代码仍在当前线程执行;调用 start() 才会启动新的执行路线。这个区别很小,却是初学线程时最常见的误判之一。
父线程创建工作线程后立即继续,属于异步启动。服务器接到请求后把任务交给后台工作者,接收线程马上回来接下一个请求,就是典型例子。
父线程在某个阶段必须等待所有子任务完成,再合并结果,这个等待点叫汇合。Pthreads 的 pthread_join()、Java 的 Future.get() 或并行框架里的 join 都在表达同一个依赖:后面的步骤缺少前面结果就不能继续。好的并行结构会把汇合放在真正需要结果的地方,而不是每创建一个任务就立刻等待。
如果服务器每收到一个请求就创建一条线程,请求突增时,线程数会跟着无边界上涨。最后系统可能还没处理业务,就把内存耗在线程栈上,把 CPU 耗在上下文切换上。任务抽象要解决的是:业务提交“要做什么”,运行时决定用哪条工作线程、什么时候做,以及系统满载时怎么办。
一个可控线程池至少有三个边界:工作线程数决定同时执行多少任务,任务队列容量决定能暂存多少等待工作,拒绝或回压策略决定两者都满时怎样处理新任务。只配置线程数而留下无限队列,压力只是从线程转移到了内存,延迟仍可能越积越长。

CPU 密集任务通常从接近可用核心数的工作线程开始调优,因为再多线程只会争抢 CPU。I/O 密集任务在等待期间不占核心,可以容纳更多并发,但上限仍要结合连接数、内存、下游容量和超时来定。线程池大小不是一个脱离场景的固定公式。
下面的实验允许你调整工作线程数和队列容量。连续点击“突发 6 个任务”,观察满载时新任务被拒绝,以及扩大队列为什么只会暂时吸收突发,并不能提高处理速度。
有些计算天然适合递归分解。求大数组之和时,可以把范围一分为二,子任务继续拆,直到每块足够小就顺序计算,再逐层合并。Fork 表示产生可并行的子任务,Join 表示等待并组合结果。
真正让它保持忙碌的是工作窃取。每条工作线程维护自己的待办队列;某条线程先做完时,会从其他线程的队列中拿走尚未执行的任务。它不保证每个任务一定并行,只是在可用工作线程范围内尽量平衡负载。

class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 10_000;
private final long[] values;
private final int from, to;
SumTask(long[] values, int from, int to) {
this.values = values;
this.from =
阈值决定任务粒度。阈值太大,并行机会不足;太小,任务对象、队列操作和合并成本会变得突出。Fork-Join 更适合计算密集、能递归拆分且子任务相对独立的工作。让大量任务长期阻塞在网络或磁盘上,会占住工作线程,削弱窃取调度的效果。
OpenMP 为 C、C++ 和 Fortran 提供指令与运行时接口。开发者标出并行区域或可拆分循环,运行时建立线程团队并分配迭代。例如:
#pragma omp parallel for
for (int i = 0; i < n; ++i) {
output[i] = transform(input[i]);
}这段写法成立的前提是各次迭代之间没有未处理的数据依赖。OpenMP 不会替程序员证明共享访问安全,也不会自动发现所有竞态或死锁。它降低的是线程管理的样板代码,不是并发正确性的难度。
Grand Central Dispatch 使用调度队列接收工作。串行队列一次只执行一个任务,很适合保护某份状态;并发队列允许多项任务重叠执行,具体使用多少线程由系统根据负载决定。主队列是绑定主线程的串行队列,界面更新通常回到这里完成。
let queue = DispatchQueue.global(qos: .userInitiated)
queue.async {
let result = computeReport()
DispatchQueue.main.async {
show(result)
}
}async 提交后立即返回,sync 则等待任务完成。在主线程上对主队列做同步提交会形成自我等待:主线程等队列任务结束,而队列任务又只能由正在等待的主线程执行,于是死锁。队列抽象让顺序关系更清楚,却不能消除错误的依赖环。
oneTBB 面向 C++ 提供并行循环、任务组和并发容器。开发者描述迭代范围和操作,任务调度器把计算拆成任务,再映射到工作线程上。它同样会根据可用工作线程调整实际并行度,所以“可以并行”不是“保证同时运行”。
这类运行时的共同思路很一致:任务可以很多,工作线程必须有限;任务说明逻辑,运行时负责复用线程与平衡负载。它们减少手工管理线程的负担,但程序员仍要给出正确的依赖、共享边界和取消方式。
线程共享进程资源,所以一些在单线程程序里很直接的操作,到了多线程环境就会出现“到底影响哪一条线程”的问题。fork()、信号、取消和线程本地存储尤其容易被误解。
POSIX 多线程进程调用 fork() 后,子进程只复制调用 fork() 的那条线程。地址空间看似来自父进程,其他线程却不会出现在子进程里。这会留下危险状态:父进程里某把互斥锁可能正被另一条线程持有,子进程复制到了“已加锁”的内存,却没有复制能够解锁它的线程。
因此,多线程程序通常让子进程在 fork() 后尽快调用 exec(),用新程序映像替换整个进程。在 fork() 与 exec() 之间只能非常谨慎地调用允许的安全操作。pthread_atfork() 可以在复制前后安排锁处理,但它很难覆盖大型程序和第三方库里的全部内部锁。
任何线程成功调用 exec() 后,当前进程的程序映像都会被新程序替换,原来的其他线程也不再存在。exec() 不是“只替换调用线程”,因为线程共享的地址空间已经整体换掉了。
同步信号由当前执行动作直接触发,例如某条线程进行了非法内存访问,它自然与触发线程相关。异步信号来自外部事件,例如终端中断或其他进程发送通知,目标通常先描述为进程,再由系统结合各线程的信号屏蔽字选择可接收线程。
多线程程序常在工作线程里屏蔽一组异步信号,再安排专门线程同步等待并处理它们,把难以控制的异步入口变成普通控制流。pthread_kill() 可以把信号定向到同一进程中的指定线程,但信号的处理动作仍可能具有进程级效果。信号处理函数里可安全调用的函数非常有限,不能把普通业务逻辑随意搬进去。
用户点下“停止下载”时,我们希望工作线程尽快结束,但它可能正持有锁、写一半文件或修改共享结构。如果在任意指令位置把线程强行终止,资源与不变量都会停在半完成状态。
更稳妥的方式是协作式取消:发起者只设置取消请求,目标线程在定义好的安全点检查请求,先释放资源、恢复共享状态,再退出。Pthreads 默认使用延迟取消,目标线程在取消点响应请求,并可执行清理处理程序;Java 的 interrupt() 设置中断状态或让可中断阻塞操作抛出异常,也需要任务代码正确传播和处理。
static void *worker(void *unused) {
for (;;) {
do_one_small_piece();
pthread_testcancel();
}
}取消点要布置在“工作已经推进一小步,但共享状态仍完整”的位置。循环完全不检查,请求就迟迟不能生效;检查得过密,又会增加分支和清理设计的负担。超时、取消标志和资源释放应当作为同一条失败路径一起设计。
线程本地存储让同一个键在不同线程上得到不同值。日志请求编号、解析器状态或不适合共享的缓存,都可以放在线程本地空间。它和普通局部变量不同:局部变量跟随一次函数调用,线程本地值可以跨越这条线程上的多层函数调用;它也和全局变量不同:每条线程看到的是自己的副本。

线程池让这里多了一层陷阱。工作线程会被下一个请求复用,如果任务结束时没有清理线程本地值,下一个请求可能读到上一个请求留下的身份或追踪编号。线程本地存储提供的是访问隔离,不会自动替你管理业务生命周期。最稳妥的做法是在 finally 或等价清理路径中移除上下文。
下面的实验有两个页签。取消实验展示“立即终止”为什么会留下锁;上下文实验则让同一工作线程连续处理两个请求,比较是否清理线程本地值的结果。
现在回到开头的浏览器。界面线程要保持响应,网络和解码工作要能在后台推进,这是并发;解码任务落到多个核心同时计算,才是并行。线程共享浏览器进程的堆和文件,因此传递页面数据很方便,也必须控制写入顺序。任务多到一定程度后,运行时用有界队列和工作线程限制资源;任务可以递归拆分时,工作窃取帮助各核心分担负载;用户取消页面加载时,工作任务在安全点清理连接和缓冲区再退出。
这套设计没有免费午餐。线程比进程轻,却牺牲隔离;共享数据传递快,却带来竞态;并行能缩短计算,却受串行部分、同步和调度开销限制;线程池复用资源,却必须处理队列满载与上下文残留。真正可靠的并发程序,不是“尽可能多地开线程”,而是把任务、资源边界、依赖和失败路径一起设计。
一台四核服务器处理两类工作:一类是持续计算图片滤镜,另一类是等待远程接口返回。它们应该使用同样大小的线程池吗?
为什么说“把共享变量换成线程本地变量”不能自动修复所有并发问题?