1. 先把概念地基打牢:内核、核心与逻辑处理器到底是几回事
聊多线程这件事,我踩过的第一个坑不是代码写错,而是概念先混了。别人问你机器几核,你答 8 核;再问能跑几个线程不卡,你答 8 个。结果一跑压力测试,卡得比预期早得多,也快得多。问题就出在“核”“内核”“逻辑处理器”“线程”这几个词被当成同义词用了,而它们说的其实是不同层级的东西。
先把最容易混的一组拆开。CPU 核心(Core)是芯片里真实存在的物理运算单元,一个核心里有独立的算术逻辑单元、寄存器组、一二三级缓存通路,它能同时执行一条指令流。逻辑处理器(Logical Processor)是操作系统“看得见”的调度单元,在开启超线程(Simultaneous Multithreading)的机器上,一个物理核心对外暴露两个逻辑处理器。超线程的实质是让两套寄存器状态共享同一组执行单元,当一条线程因取数、访存而停顿的间隙,另一条线程的指令可以插进来填坑。它提升的是执行单元的利用率,不是凭空多出一个核心。所以实测里经常出现“8 核 16 线程,跑满后性能只比 8 核高 20%~30%”这种情况,尤其在纯浮点计算负载下,超线程的收益会明显缩水。
还有一个更容易被忽略的歧义:中文里“内核”既指 CPU 的核心,也指操作系统的Kernel。Linux 内核、FreeRTOS 内核、实时补丁内核,说的是操作系统那一层;而“多核 CPU”里的核,说的是硬件层。这两个“内核”在讨论调度、中断、线程时经常同时出现,一不留神就把“内核线程”理解成“核心上的线程”了。事实上内核线程(Kernel Thread)指的是由内核直接创建和调度、不挂载到用户进程地址空间的执行流,跟物理核心没有一对一关系。
| 概念 | 所属层级 | 谁看得见 | 典型数量关系 |
|---|---|---|---|
| 物理核心 Core | 硬件 | 内核 + 固件 | 芯片固有 |
| 逻辑处理器 LP | 硬件/固件对外暴露 | 操作系统调度器 | 核心数 × 每核线程数 |
| 内核 Kernel | 操作系统 | 硬件之上的软件层 | 每系统一份 |
| 线程 Thread | 执行流抽象 | 内核或运行时 | 远多于逻辑处理器 |
理清这张表,后面所有讨论才有共同语言。很多人调优失败,就是因为拿“物理核数”去配线程池,却忘了操作系统给的是“逻辑处理器数”,而这俩在超线程机器上差一倍。我个人习惯是:任何并发参数计算,第一件事就是lscpu或“任务管理器-性能”里把逻辑处理器数量截图存下来,别凭印象。
2. 线程究竟轻在哪:从进程模型到线程模型的实现流派
2.1 进程与线程在内存视角下的根本差别
进程和线程的区别,教科书喜欢从“资源分配单位”和“调度单位”来讲,太抽象。换成内存视角就直白多了:进程 = 一套独立的虚拟地址空间 + 一组资源句柄,线程 = 进程内部共享同一地址空间的一条执行流。你 fork 一个新进程,操作系统要给它复制页表、建立独立的读写映射;你 create 一个线程,它和兄弟线程共用同一份页表,切换时不用换地址空间基址寄存器,这就是线程“轻”的核心原因。
但“轻”是有代价的。线程共享堆、全局变量、文件描述符,任何一处没加保护的数据,都可能被另一条线程在半路改掉。进程之间天然隔离,出问题最多崩自己;线程之间不隔离,一条线程把堆踩坏,整个进程跟着一起躺。所以选进程还是线程,本质是拿通信便利度换隔离安全性。需要高频共享大块内存、追求低延迟通信的,用线程;需要强隔离、可独立重启的,用进程。服务端常见的做法是“多进程 + 每个进程内多线程”,把隔离和并发都拿到手。
2.2 一对一、多对多:线程模型背后的调度权归属
线程模型这块,业界长期是三派。
一对一(1:1):每条用户线程对应一个内核调度实体。Linux 的 NPTL、Windows 原生线程、C++std::thread默认走的都是这条。优点是内核能看见所有线程,可以真正并行、可以单独抢占;缺点是创建和调度开销由内核承担,线程数不能无限涨,几千条就接近上限了。
多对一(N:1):多条用户态线程映射到一条内核线程,用户态自己调度。协程、早期的绿色线程属于这类。切换几纳秒,极快,但内核只认一条线程,多核用不上,一条线程阻塞整个挂起。适合 IO 少、任务碎的场景。
多对多(M:N):多条用户线程映射到少量内核线程,运行时动态调配。Go 的 goroutine、Erlang 进程、Java 21 的虚拟线程都是这个思路。它想同时拿到“用户态切换快”和“多核能并行”两个好处,代价是运行时实现复杂,遇到系统调用、本地方法时会“钉住”承载的内核线程。
选型时我一般这样判断:如果任务以 CPU 计算为主、线程数可控,直接 1:1 最省心;如果有海量并发连接、每条连接大部分时间在等待,M:N 或事件驱动更划算;如果语言运行时已经内置了高效模型(Go、Java 虚拟线程),别自己造轮子。
2.3 主流语言里的线程映射与各自的脾气
同样是“开一个线程”,不同语言背后的代价差得远,这点很多人没意识到。
Java 里new Thread()走的是 1:1,一条 Java 线程对应一条 OS 线程,默认栈 1MB(-Xss可调),所以开一万条线程意味着约 10GB 虚拟内存。Java 21 的虚拟线程换成了 M:N,栈是堆上的可增长对象,开百万级都扛得住,但遇到synchronized里的阻塞或 JNI 调用会把载体线程钉住,收益打折。
Python 的情况更特殊,CPython 有GIL(全局解释器锁),同一时刻只允许一条线程执行字节码。这意味着 Python 多线程在 CPU 密集任务上基本拿不到并行加速,只能用multiprocessing或多进程绕过;但在 IO 密集任务上,线程等待时会释放 GIL,多线程反而很好用。我见过太多人写 Python 爬虫、做接口聚合,多线程跑得飞快,然后拿同一套代码去做图像处理,发现比单线程还慢,就是没搞清 GIL 的边界。
C++ 的std::thread是对 OS 线程的薄封装,几乎零运行时开销,但线程的创建销毁、同步原语全得自己管,库层面没有线程池。实际项目我一般用线程池 + 任务队列,避免频繁创建。
Flutter/Dart 的Isolate是独立内存的隔离体,和传统共享内存的线程不同,通信靠消息传递,天然避免了数据竞争。真正共享内存的只有Isolate.run之外的少数场景。Qt 里想要把耗时的曲线刷新从主线程挪走,正确的做法是工作线程算完数据、通过信号槽把结果投递给主线程的 UI 对象,而不能在工作线程里直接操作控件——GUI 框架的控件对象基本都不是线程安全的。
3. 多核并行为什么常常事与愿违:加速比、伪共享与锁
3.1 用 Amdahl 定律算清楚加速的上限
多核能不能线性加速,取决于任务里“可并行部分”占多少。Amdahl 定律给了一个特别实用的估算式:
加速比 S(n) = 1 / ((1 - p) + p / n)其中 p 是可并行占比,n 是逻辑处理器数。假设你的任务 95% 可并行,用 8 个逻辑处理器跑,S = 1 / (0.05 + 0.95/8) ≈ 1 / 0.16875 ≈ 5.93 倍,离 8 倍差了不少。如果可并行占比只有 70%,S = 1 / (0.3 + 0.7/8) ≈ 2.5 倍,加到这个程度基本没意义了。
我用这个公式做过一次接口聚合服务的容量评估:单次请求要串行做 3 次远程调用(各 120ms,等待可并行)和 1 次本地计算(40ms,不可并行)。串行总耗时约 400ms;把 3 次调用并行后,理论耗时 = 40 + 120 = 160ms,加速比 2.5 倍。实测在 4 核机器上做到 2.2 倍左右,差值来自线程调度和连接建立开销。先算理论值再定目标,比拍脑袋说“加机器就能扛住”靠谱得多。
注意:Amdahl 定律假设任务规模固定。如果把问题规模一起放大(强扩展 vs 弱扩展),结论会变,这就是 Gustafson 定律讨论的范畴。做容量规划时要说清用的是哪个前提。
3.2 伪共享:看不见的缓存行争抢
伪共享(False Sharing)是多核编程里最隐蔽的性能杀手之一。CPU 缓存以缓存行(Cache Line)为单位加载,x86 上通常是 64 字节。假设两个线程分别频繁写两个变量a和b,它们恰好落在同一条缓存行里,那么每次一个核改了a,这条缓存行在另一个核里就失效了,另一个核写b时又要把整行抢回来。两个线程明明操作的是不同变量,却在缓存行级别上打得不可开交,这就是伪共享。
它在高并发计数器、环形缓冲区读写指针这类场景里特别常见。排查方法是看perf c2c,它会直接报出有 cache line 争抢的地址。
# 用 perf 检测缓存行争抢,看到 HITM 计数高的地址就要警觉 perf c2c record -a -- sleep 5 perf c2c report --stdio解决办法是填充(Padding),让两个热点变量各自独占一条缓存行。Java 8 起可以用@Contended注解(需加-XX:-RestrictContended),C/C++ 里手动填到 64 字节,或使用alignas(64)。填充代价是内存占用变大,所以只对确认有争抢的热点做,别全局铺开。
3.3 锁竞争与上下文切换:并发的两项隐性税
只要涉及共享可变状态,锁就绕不开。锁带来两项开销:竞争时的自旋或阻塞,以及上下文切换。上下文切换要保存恢复寄存器、切换栈指针,还会污染 TLB 和缓存,单次开销通常在几微秒量级,看着小,但每秒几十万次切换时,CPU 大量时间就耗在调度上而不是干活上。
我做过一次压测:把线程池从 200 调到 16(机器是 8 核 16 逻辑处理器),吞吐反而涨了。原因是原配置下大量线程在抢锁,vmstat里cs(上下文切换次数)每秒几十万,sy(内核态占比)高得离谱。线程池不是越大越好,这一点后面细讲。
减少锁竞争的常用手段,我按实际收益排序:
- 缩小临界区,只锁真正共享的那几行操作;
- 用无锁结构(
AtomicLong、CAS、LongAdder)替代互斥锁做计数; - 分段锁,把一个大锁拆成按 key 分布的多个锁(
ConcurrentHashMap的思路); - 读写分离,读多写少时用读写锁甚至不可变快照;
- 线程本地存储(ThreadLocal),把能私有的状态彻底私有化。
3.4 线程池容量怎么定:CPU 密集与 IO 密集的公式
线程数配错是并发问题的重灾区。业界比较通行的一套估算来自 Brian Goetz 的经验公式:
- CPU 密集型:线程数 ≈ 逻辑处理器数 + 1。多出来的 1 条是为了在偶发页错误等停顿间隙顶上。
- IO 密集型:线程数 ≈ 逻辑处理器数 × (1 + 平均等待时间 / 平均计算时间)。
举个例子,16 个逻辑处理器,任务平均 IO 等待 90ms,CPU 计算 10ms,那么线程数 ≈ 16 × (1 + 9) = 160。这个数字是理论上限,实际还要看内存、下游服务能承受多少并发连接,通常我会先取下限(比如 48 或 64),再根据压测逐步上调。
| 任务类型 | 公式 | 16 LP 举例 | 备注 |
|---|---|---|---|
| CPU 密集 | N + 1 | 17 | 超过意义不大,反增切换 |
| IO 密集(1:9) | N × (1 + W/C) | 160 | 需结合下游限流 |
| 混合型 | 分池隔离 | CPU 池 17 + IO 池 64 | 避免相互拖累 |
配线程池时我坚持一件事:不同性质的任务用不同的池。把耗时计算的活儿和快速响应的活儿混在一个池里,慢任务会把队列堵死,快任务排不上队,表现出来就是随机超时,特别难查。
4. 操作系统内核这一侧:调度、同步与中断上下文
4.1 调度器眼里只有“可运行线程”
从内核调度器角度看,进程和线程的差别被抹平了,Linux 统一用任务(Task)描述,线程和进程在内核里都是task_struct,只是是否共享地址空间不同。调度器能看到的永远是“就绪队列里的可运行任务”,它不关心你的业务逻辑,只按优先级和时间片分配 CPU。
Linux 默认的 CFS(完全公平调度器)追求的是公平,而不是低延迟。它按虚拟运行时间排任务,谁的虚拟时间少谁先上。这对服务器吞吐友好,但对实时性不友好。所以实时场景要用SCHED_FIFO、SCHED_RR这类实时调度策略,配合实时补丁内核(PREEMPT_RT 系列)把内核里那些不可抢占的临界区尽量缩短,让高优先级任务能及时抢上来。
这解释了一个常见困惑:为什么在普通 Linux 上做实时控制,抖动总是压不下去。不是代码问题,是调度器本身的设计取向决定的。
4.2 内核同步的几把常用武器
内核态的同步手段比用户态更丰富,也更危险,因为内核里没人为你兜底。
- 自旋锁(spinlock):忙等,适合锁持有时间极短且不能睡眠的场景,比如中断处理里。持有期间不能睡眠,否则死锁。
- 互斥量(mutex):可以睡眠,适合可能阻塞的较长临界区。
- 读写锁(rwlock / rwsem):读多写少时提升并发。
- RCU(读-拷贝-更新):读侧几乎零开销,写侧延迟释放旧数据。网络协议栈、路由表大量用它。
- 原子操作与内存屏障:最底层,编译器和 CPU 都可能重排指令,屏障用来约束顺序。
其中最常踩的坑是中断上下文里调用可能睡眠的函数。中断处理程序运行在特殊的上下文里,不允许调度,一旦调用了会睡眠的接口,轻则报内核警告,重则系统卡死。写驱动时如果要在中断里做耗时操作,正确做法是用“上半部 + 下半部”拆分,中断里只做最紧急的登记,剩下的交给软中断或工作队列。
4.3 内核线程、工作队列与中断的协作关系
内核线程是内核自己拉起来的后台执行流,比如刷脏页的kworker、负责内存回收的kswapd。它们不挂在用户进程上,ps里显示成方括号包着的名字。很多新手看top发现kworker占了大量 CPU,以为是异常,其实那往往是磁盘 IO 或某段驱动的延后处理在干活。
内核里的“线程池”对应的是工作队列(workqueue)。驱动把延后要做的事打包成一个 work 结构挂到队列上,由内核的工作线程去执行。colcon build里调的并行构建线程数、make -j的并行度,本质上也是同一类“用并发把等待和计算填满”的思路,只不过发生在用户态构建系统里。理解内核这套协作模型,对排查“系统卡在高sy、高wa”这类问题是必修课。
5. 动手实操:把多线程从能跑变成跑得好
5.1 手写一个带监控的线程池骨架
下面这段 Java 示例,我刻意把线程池参数外置并加了活跃度统计,方便压测时观察。
import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class MonitoredPool { private static final AtomicInteger active = new AtomicInteger(0); public static void main(String[] args) throws Exception { int lp = Runtime.getRuntime().availableProcessors(); // 逻辑处理器数 int core = lp + 1; // CPU 密集基准 int max = lp * 4; // 上限,别一步到位设成理论最大值 BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1024); ThreadPoolExecutor pool = new ThreadPoolExecutor( core, max, 60L, TimeUnit.SECONDS, queue, new ThreadFactory() { private final AtomicInteger idx = new AtomicInteger(1); public Thread newThread(Runnable r) { Thread t = new Thread(r, "biz-" + idx.getAndIncrement()); t.setDaemon(false); // 非守护线程,保证任务跑完 return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时由提交者执行,形成天然背压 ); for (int i = 0; i < 500; i++) { pool.execute(() -> { active.incrementAndGet(); try { Thread.sleep(20); // 模拟 IO 等待 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { active.decrementAndGet(); } }); } // 周期性打印,观察是否长期打满 ScheduledExecutorService mon = Executors.newSingleThreadScheduledExecutor(); mon.scheduleAtFixedRate(() -> System.out.printf( "active=%d poolSize=%d queue=%d completed=%d%n", pool.getActiveCount(), pool.getPoolSize(), pool.getQueue().size(), pool.getCompletedTaskCount() ), 0, 1, TimeUnit.SECONDS); Thread.sleep(5000); pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); mon.shutdownNow(); } }几个设计点值得说清。队列选了ArrayBlockingQueue而不是无界队列,无界队列在突发流量下会无限堆积,直到 OOM 才暴露问题,有界队列能在早期把压力顶回给调用方,配合CallerRunsPolicy形成背压。线程数上限没直接设成公式算出的 160,而是先设成lp * 4,留出观察余地。守护线程标志设成false,避免主线程退出时任务被腰斩。
提醒:如果任务提交后需要等待全部完成,别用轮询
while加sleep硬等。用CountDownLatch或CompletableFuture.allOf(...),前者适合固定数量任务,后者在结果需要聚合时更顺手。
Python 侧对应的写法用concurrent.futures更省心:
from concurrent.futures import ThreadPoolExecutor, as_completed import os LP = os.cpu_count() # 逻辑处理器数 def fetch(item): # IO 等待型任务,Python 多线程在此能释放 GIL,是合适的 return item * 2 with ThreadPoolExecutor(max_workers=LP * 4) as ex: futures = [ex.submit(fetch, i) for i in range(200)] for f in as_completed(futures): pass # 拿到结果就处理,避免一次性全部驻留内存5.2 死锁的复现、定位与预防
死锁的产生要同时满足四个条件,破坏任何一个就能避免:互斥、持有并等待、不可剥夺、循环等待。工程上最容易破的是“循环等待”,做法是全局约定加锁顺序,比如所有地方都先锁 A 再锁 B,绝不允许反向。
复现一个经典死锁很简单:
Object a = new Object(), b = new Object(); Thread t1 = new Thread(() -> { synchronized (a) { try { Thread.sleep(50); } catch (InterruptedException ignored) {} synchronized (b) { System.out.println("t1 done"); } } }); Thread t2 = new Thread(() -> { synchronized (b) { synchronized (a) { System.out.println("t2 done"); } } }); t1.start(); t2.start();定位用jstack,它会直接给出“Found one Java-level deadlock”和涉及的两条线程及各自持有的锁。
# 先找到进程号,再抓线程栈 jps -l jstack <pid> | grep -A 30 "Found one Java-level deadlock"C++ 侧,gdb调试多线程时用info threads看所有线程,thread apply all bt一次打印全部调用栈,能快速找出谁卡在pthread_mutex_lock上。Python 侧faulthandler.dump_traceback_later()能在超时后自动打印所有线程栈,是排查卡死的利器。
| 死锁条件 | 破坏手段 | 代价 |
|---|---|---|
| 互斥 | 尽量改用无锁结构 | 实现复杂 |
| 持有并等待 | 一次性申请全部资源 | 资源利用率低 |
| 不可剥夺 | 加超时,tryLock(timeout) | 需处理获取失败重试 |
| 循环等待 | 全局统一加锁顺序 | 需要团队约束 |
5.3 多线程下载与断点续传的场景拆解
多线程下载是个把“并行 IO”用到极致的经典案例。HTTP 协议支持Range 请求,客户端可以指定只取文件的一段:请求头加Range: bytes=0-999999,服务端返回206 Partial Content和Content-Range。于是可以把一个文件切成 N 段,分给 N 条线程并发拉取,各自写入本地文件的对应偏移。
几个实操要点必须说清。第一,先发一个HEAD请求拿到Content-Length和是否支持Accept-Ranges: bytes,不支持就退回单线程,别硬拆。第二,分块大小要权衡,太小则请求数暴涨、连接开销吃掉收益,太大则单块失败重传成本高,一般每块 1~8MB 比较稳。第三,写入本地文件时多条线程用RandomAccessFile按偏移写不同区间,彼此不重叠,可以不加锁;但进度统计这个共享计数器要么用原子变量,要么每条线程局部累加后汇总。第四,断点续传要把每块的完成状态记下来(比如落盘一个进度文件),重启后只拉未完成的部分。
注意:并发下载的总线程数要克制,超过服务端单连接限速或本地带宽时,段越多越慢。我一般先测单线程速度,再按 4~8 段起步,观察是否真有提升再调。
6. 常见问题与排查技巧实录
6.1 典型症状与可能原因的对照表
并发问题最大的难点在于症状和原因常常不在一处。下面这张表是我这些年高频用到的对照。
| 症状 | 常见原因 | 首查手段 |
|---|---|---|
| 线程越多越慢 | 锁竞争、上下文切换过多 | vmstat 1看 cs/sy,perf c2c |
| CPU 占用高但吞吐低 | 自旋锁、忙等、伪共享 | perf top、火焰图 |
| 随机超时、时快时慢 | 线程池混合任务、队列堵塞 | 打印 activeCount/queueSize |
| 内存持续上涨不降 | 线程本地变量未清理、线程泄漏 | jmap -histo、jstack看线程数 |
| 程序卡死不退出 | 死锁、非守护线程未结束 | jstack找 deadlock |
| IO 密集却用满 CPU | 每次读一点点,系统调用过多 | 合并读写、增大缓冲 |
6.2 命令行排查工具速查
排查并发问题,光看日志不够,得会读系统指标。我最常用的几条:
# 看每条线程的 CPU 占用,-H 打开线程模式 top -H -p <pid> # 每秒采样,重点看 cs(上下文切换)和 sy(内核态占比) vmstat 1 # 按线程维度的 IO 与切换统计 pidstat -t -p <pid> 1 # 看到高 CPU 线程号后,转成 16 进制去线程栈里找 printf '%x\n' <tid> jstack <pid> | grep -A 20 <hex_tid>这套流程下来,90% 的“某个线程把 CPU 跑满了”类问题都能定位到具体代码行。关键在于“系统层指标 → 线程号 → 线程栈 → 业务代码”这条链路要熟。
6.3 我踩过的几个坑
第一个坑,线程安全地发布对象。我有次把共享配置对象在启动时一条线程写、运行中多条线程读,没做任何内存屏障,结果读线程偶尔读到半初始化的对象。后来才明白,final字段有特殊的可见性保证,非final字段必须靠锁或volatile发布。发布可变对象时,用volatile或放到ConcurrentHashMap里,别裸着共享。
第二个坑,把 Future 当成同步调用。提交完任务立刻future.get(),等于串行执行,白搭了线程池。要并行就得先批量提交、再统一收集。
第三个坑,ThreadLocal没清理。线程池里的线程是复用的,ThreadLocal不放回会一直挂在旧线程上,既泄漏内存又可能导致上下文串味。正确做法是try/finally里调用remove()。
第四个坑,构建并行度盲目调高。像make -j、colcon build这类构建,线程数设成逻辑处理器数的 1~2 倍通常最优,设太高反而因为内存带宽和磁盘 IO 成为瓶颈而变慢。我一般是先按nproc试,再按内存容量估上限,别一味拉满。
第五个坑,误以为加了volatile就线程安全。volatile只保证可见性和禁止重排,不保证复合操作的原子性,i++加了它照样会丢更新。计数要用原子类或加锁。
6.4 一张图理解可观测性的三个层次
说到最后,我总结出一套分层观察的习惯,能显著缩短定位时间:
- 系统层:
vmstat、pidstat、perf,看的是全局 CPU、上下文切换、缓存争抢; - 进程/线程层:
top -H、jstack、gdb info threads,看的是哪条线程在干什么; - 代码层:日志、埋点、火焰图,看的是业务逻辑的时间分布。
三层对上,问题基本就现形了。很多人卡在只盯着代码改,却从不看系统指标,结果改了半天要么没效果,要么把没问题的代码也改坏了。我实际项目里最大的教训不是不会写并发,而是发现问题后不知道先看哪一层,白走了很多弯路。这也是为什么我现在做任何多线程相关的改动,第一步永远是先把监控指标加好,再动并发参数。