
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 原生线程、Cstd::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:-RestrictContendedC/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 等待 90msCPU 计算 10ms那么线程数 ≈ 16 × (1 9) 160。这个数字是理论上限实际还要看内存、下游服务能承受多少并发连接通常我会先取下限比如 48 或 64再根据压测逐步上调。任务类型公式16 LP 举例备注CPU 密集N 117超过意义不大反增切换IO 密集1:9N × (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; // 上限别一步到位设成理论最大值 BlockingQueueRunnable 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_workersLP * 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 deadlockC 侧gdb调试多线程时用info threads看所有线程thread apply all bt一次打印全部调用栈能快速找出谁卡在pthread_mutex_lock上。Python 侧faulthandler.dump_traceback_later()能在超时后自动打印所有线程栈是排查卡死的利器。死锁条件破坏手段代价互斥尽量改用无锁结构实现复杂持有并等待一次性申请全部资源资源利用率低不可剥夺加超时tryLock(timeout)需处理获取失败重试循环等待全局统一加锁顺序需要团队约束5.3 多线程下载与断点续传的场景拆解多线程下载是个把“并行 IO”用到极致的经典案例。HTTP 协议支持Range 请求客户端可以指定只取文件的一段请求头加Range: bytes0-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/syperf c2cCPU 占用高但吞吐低自旋锁、忙等、伪共享perf top、火焰图随机超时、时快时慢线程池混合任务、队列堵塞打印 activeCount/queueSize内存持续上涨不降线程本地变量未清理、线程泄漏jmap -histo、jstack看线程数程序卡死不退出死锁、非守护线程未结束jstack找 deadlockIO 密集却用满 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看的是哪条线程在干什么代码层日志、埋点、火焰图看的是业务逻辑的时间分布。三层对上问题基本就现形了。很多人卡在只盯着代码改却从不看系统指标结果改了半天要么没效果要么把没问题的代码也改坏了。我实际项目里最大的教训不是不会写并发而是发现问题后不知道先看哪一层白走了很多弯路。这也是为什么我现在做任何多线程相关的改动第一步永远是先把监控指标加好再动并发参数。