进程与线程:从概念到线程池、死锁与僵尸进程实战

📅 发布时间:2026/9/18 19:55:58
进程与线程:从概念到线程池、死锁与僵尸进程实战
昨天下午三点多监控面板上某台应用服务器的 CPU 瞬间顶到 100%内存占用也一路往上爬页面响应慢得像拨号上网。登上去用 top 一看几十个同名进程排在那里排第一的那个已经跑了十一个小时没退出。当时我第一反应是线程池是不是配错了改完参数重启问题照旧。后来才发现真正的病根在进程层面——一个本该退出的子进程卡成了僵尸父进程还在傻等。这件事让我重新回去把进程和线程的基础又啃了一遍。进程和线程这两个词几乎每个写代码的人都挂在嘴边但真正能把它们的边界在哪什么时候该用哪个为什么会有这个区别讲清楚的人并不多。这篇就从头捋把操作系统层面的概念和平时写代码、调参数、排故障的实际场景连起来让你看完之后不只是记住一张对比表而是知道遇到问题时该往哪个方向想。不管你是刚学操作系统的大二学生还是写了几年业务代码一直能跑就行的开发或者是天天被线上告警追着跑的运维这一层想明白了后面线程池配置、死锁、进程通信、句柄占用这些事都会顺很多。1. 一次线上卡顿引出的问题进程和线程到底是干嘛的1.1 那台 CPU 跑满的服务器暴露了什么先说那句被说烂了但确实准确的话进程是操作系统分配资源的基本单位线程是 CPU 调度的基本单位。教科书上这句话你可能看过八百遍但要让它变成脑子里的直觉得配具体场景。那台服务器上我看到的几十个同名进程其实是一个主进程反复 fork 出来的工作进程。每个进程有自己独立的内存空间互不干扰所以某个进程把内存写坏了不会直接把别人带崩这是好处代价是它们之间交换数据必须走进程通信而且每次创建、销毁、切换的开销都不小。如果当时我把这套模型换成线程那么所有工作单元共享同一份地址空间数据交换几乎零成本但反过来任何一个线程把指针写飞了整个进程一起挂。这就是进程和线程最核心的一组权衡隔离性换性能。我后来复盘那个卡死的子进程本质上是一个只申请不释放的资源泄漏它持有数据库连接不放手父进程又用了阻塞式的回收逻辑于是一起僵住。如果我当时脑子里有进程之间是独立的父进程需要显式回收子进程这个基本认知排查方向就会完全不同不至于在业务代码里绕两个小时。所以别小看什么是进程、什么是线程这个问题。它不是面试八股它是你后面所有并发决策的地基。1.2 为什么先搞懂概念比先学线程池更重要我见过太多人是这么学的先搜线程池参数怎么配抄一份corePoolSize8, maxPoolSize16, queue1024回去跑起来没报错就觉得自己掌握了。直到某天流量翻三倍任务全堵在队列里接口大面积超时才发现自己根本不知道那个 8 是怎么来的。线程池的本质是什么是一组预先创建好的线程加一个任务队列加一套拒绝策略。要理解它你得先知道线程创建为什么贵因为要申请栈空间、初始化寄存器上下文、在内核里登记调度实体线程为什么不能无限开因为每个线程都要占内存、抢 CPU 时间片线程一多调度开销本身就会把性能吃掉。这些结论全都建立在线程是什么这个前提上。顺序应该是这样先搞清楚进程和线程各自的资源边界再理解并发为什么能提升吞吐接着才去学同步原语、线程池、异步模型。反过来学就像还没学会走路就去研究怎么跑马拉松能跑但一摔就不知道摔在哪。2. 进程操作系统分配资源的基本单位2.1 一个进程里到底装着什么把一个进程拆开看它主要由四部分构成程序段代码段编译出来的机器指令通常是只读的多个进程如果是同一个可执行文件启动可以共享同一份物理内存里的代码。数据段全局变量、静态变量又细分已初始化数据和未初始化数据BSS。堆动态分配的内存也就是malloc、new拿到的那些。堆从低地址往高地址长。栈函数调用的现场局部变量、返回地址、参数都在这。栈从高地址往低地址长。光有这四块还不够真正让进程活起来的是内核里的进程控制块PCB。进程 ID、父进程 ID、打开的文件描述符表、当前工作目录、信号处理表、内存映射表、优先级、运行状态——全存在 PCB 里。可以这么理解内存里的代码和数据是身体PCB 是身份证 病历本 行程单。操作系统调度时看的从来不是你的代码而是 PCB。注意很多人以为进程就是一个正在运行的程序这个说法不精确。程序是硬盘上的静态文件进程是这份文件被加载进内存并配上 PCB 之后的动态实体。同一个程序启动两次就是两个互不相干的进程各自有独立的 PID 和内存空间。这也是为什么多开软件IDE 多开实例能同时跑——它们是同一份代码的不同进程实例彼此不共享堆和栈。2.2 五种状态和它们之间的迁移一个进程从生到死会在几个状态之间来回切换。主流教材一般讲五种状态含义典型触发新建进程刚被创建PCB 已分配但还没进入就绪队列fork 刚返回就绪万事俱备只差 CPU时间片用完被剥夺 CPU运行正在 CPU 上执行被调度器选中阻塞在等某个事件比如磁盘 IO、信号量、网络包发起 read 系统调用终止执行完毕或被杀掉等待父进程回收exit 被调用状态迁移里有两条路最容易记混第一就绪到运行是被调度器选中运行到就绪是时间片用完被抢走这两个方向不一样。前者是主动等待被叫号后者是被强行请下台。第二阻塞只能从运行态进入而且不能直接从阻塞回到运行。进程等到了 IO 完成是被唤醒进入就绪还得重新排队等 CPU。这一点在排查进程明明没卡住却不出结果的时候特别有用——它可能只是从阻塞变回了就绪正在排队。2.3 动手看Linux 下观察进程的几条常用命令概念讲完落到手上。我日常最常用的几条# 看当前所有进程的快照这个是我最常敲的 ps aux # 按 CPU 占用排序找吃 CPU 的元凶 ps aux --sort-%cpu | head -20 # 动态监控M 键按内存排序P 键按 CPU 排序k 键直接杀进程 top # 更好看的版本没有的话装一个 htop # 看某个进程的详细信息PID 换成你的目标 cat /proc/12345/status # 看这个进程打开了哪些文件排查文件被占用神器 lsof -p 12345 # 看谁占用了 8080 端口 lsof -i:8080/proc这个目录值得单独说一句。它是内核给用户态开的一扇窗每个进程对应一个以 PID 命名的子目录里面的status看基本信息和内存fd看打开的文件描述符environ看环境变量cmdline看启动命令。你想知道一个进程到底在干嘛/proc/pid比任何图形化工具都直接。实操心得Windows 这边对应的是任务管理器里的详细信息页加上资源监视器在任务管理器性能页下方打开。资源监视器的磁盘标签能把哪个进程正在读写哪个文件列出来U 盘弹出时提示请先结束占用进程用它一抓一个准比瞎猜快得多。3. 线程进程内部真正干活的执行流3.1 线程私有什么又共享什么线程常被叫做轻量级进程这个轻是相对进程说的。一个进程里的多个线程共享的东西非常多代码段同一个程序指令当然一样。数据段全局变量、静态变量这也是为什么全局变量在多线程下是天然的共享数据容易出竞态。堆new出来的对象别的线程拿着指针就能直接访问。打开的文件描述符一个线程打开的文件另一个线程能直接读写。进程 ID、信号处理方式、当前工作目录。而每个线程私有的只有很少几样线程 ID。程序计数器 PC记录下一条要执行哪条指令这是线程能各自跑在不同位置的关键。寄存器集合包括栈指针所以每个线程要有自己的栈。栈局部变量、函数调用链都在这里线程之间不能混。看下来你就明白了线程的私有部分本质上就是执行现场共享部分本质上就是资源。这也解释了线程最经典的两面性——切换快因为执行现场小容易出并发 bug因为资源是共享的。3.2 用户级线程、内核级线程与多对多模型这里有个很多人忽略的层次问题线程有两种实现方式。用户级线程由线程库在用户态管理内核根本不知道你开了几个线程它眼里只有那一个进程。优点是切换完全在用户态完成不需要陷入内核极快缺点是内核按照一个进程一个调度实体来分时间片所以你的某个线程发起阻塞式系统调用时整个进程都会被挂起其他线程全跟着停摆。而且没法利用多核。内核级线程由操作系统直接管理内核知道每个线程的存在能真正在多核上并行调度一个线程阻塞不影响别人。代价是线程的创建、销毁、切换都要陷入内核开销比用户级大。现实中的系统大多是两者的组合。经典的三种映射模型是多对一多个用户线程映射到一个内核线程Python 早期、早期的绿色线程属于这类思路、一对一每个用户线程对应一个内核线程Linux 的 NPTL、Windows 都属于这类、多对多M 个用户线程映射到 N 个内核线程兼顾两者优点实现最复杂。提到 Python 就不得不说GIL全局解释器锁。CPython 里任意时刻只有一个线程能执行 Python 字节码所以多线程在 CPU 密集型任务上几乎拿不到多核收益但在 IO 密集场景下依然有用——因为线程发起 IO 时会释放 GIL。近两年 CPython 在推进自由线程模式也就是可选的去掉 GIL 的构建这是个值得关注的方向但短期内绝大多数生产环境还是带 GIL 的。3.3 亲手跑一把单线程、多线程、多进程对比光讲不练容易虚。下面这段 Python 代码可以直接跑观察三种方式在 CPU 密集任务下的差异import time import threading import multiprocessing def burn(n): total 0 for i in range(n): total i * i return total N 20_000_000 def run_single(): t time.time() burn(N) burn(N) return time.time() - t def run_thread(): t time.time() ts [threading.Thread(targetburn, args(N,)) for _ in range(2)] for x in ts: x.start() for x in ts: x.join() return time.time() - t def run_process(): t time.time() ps [multiprocessing.Process(targetburn, args(N,)) for _ in range(2)] for p in ps: p.start() for p in ps: p.join() return time.time() - t if __name__ __main__: print(single :, run_single()) print(thread :, run_thread()) print(process :, run_process())我的机器上实测下来结论大概是单线程和多线程耗时接近GIL 在作祟而多进程明显更快能吃到多核。这不是说线程没用而是说用错了场景。如果你把burn换成time.sleep(1)这类 IO 等待多线程立刻就有优势了——因为 sleep 会释放 GIL两个线程几乎同时醒来。这个实验我建议每个人都亲手跑一遍。看数字比看一百篇文章都管用。4. 进程与线程的区别别只背那张对比表4.1 从资源、开销、通信、健壮性四个角度拆那张经典的对比表我放在这里但我更想讲表背后的东西维度进程线程资源分配拥有独立地址空间共享所属进程的地址空间创建开销大要建页表、复制或共享上下文小只需建栈和寄存器现场切换开销大可能涉及页表切换、TLB 刷新小同一地址空间内换现场通信方式管道、共享内存、消息队列、socket 等直接读写共享变量配合同步原语健壮性一个进程崩溃不影响其他进程一个线程崩溃可能拖垮整个进程调度单位资源拥有者CPU 调度的基本单位四个维度里我认为健壮性是最容易被低估的。浏览器就是典型的多进程架构每个标签页、渲染逻辑、网络模块、GPU 加速各占一个进程。为什么这么设计因为网页里的脚本是不可信的某一个页面把渲染进程搞崩其他标签页照常工作用户顶多看到这个页面崩溃了。如果全部塞进一个进程用线程实现一个页面崩了就整个浏览器一起挂。反过来通信成本是进程方案的明显软肋。两个线程交换一个结构体几乎就是赋值两个进程交换得序列化、拷贝、走内核。所以选型时我会这么问自己这两个执行单元需要频繁共享大数据吗需要就倾向线程不需要且希望互相隔离就倾向进程。4.2 切换开销到底差多少怎么自己测网上经常说进程切换比线程切换慢十倍进程切换几十微秒、线程切换几微秒。这些数字是量级参考具体数值高度依赖硬件和内核版本不要当成铁律背。差别主要来自哪里进程切换时如果两个进程属于不同地址空间CPU 需要切换页表基址寄存器这会导致TLB快表里缓存的地址映射大面积失效之后的一段时间内存访问会变慢。线程切换因为共享地址空间页表不用换TLB 基本保留所以快。这是核心原因其他都是次要因素。想自己测其实有现成的工具Linux 上用perf stat -e context-switches跑你的程序能直接看到上下文切换次数vmstat 1里的cs列是系统每秒的上下文切换总数pidstat -w -p pid 1能看某个进程的主动/被动切换情况。实操心得如果vmstat里的cs数值长时间居高不下同时us用户态 CPU也很高说明系统在做大量无效调度多半是线程数开太多或者锁竞争激烈。你不需要知道精确的微秒数只要看到这个趋势就该去减线程数或者拆锁了。4.3 三个最常见的理解偏差偏差一线程是进程的一部分所以线程一定比进程轻。前半句对后半句要看场景。如果是短生命周期任务创建线程确实比创建进程便宜得多但如果你管理不善线程泄漏的后果往往比进程泄漏更难查——进程泄漏你能在ps里数出来线程泄漏你得去/proc/pid/task里翻。偏差二多线程一定比单线程快。只在有真正并行空间且同步开销可控时才成立。加锁、抢锁、缓存一致性带来的伪共享都可能让多线程比单线程还慢。别急着优化先测。偏差三进程之间不能共享数据。可以共享内存就是干这个的只是你需要自己处理同步。操作系统提供能力麻烦留给应用层。5. 从概念到实操创建、同步与通信5.1 fork 与写时复制进程是怎么生出来的在类 Unix 系统上创建进程的经典系统调用是fork。它的行为很反直觉调用一次返回两次——父进程里返回子进程的 PID子进程里返回 0。#include unistd.h #include stdio.h int main() { pid_t pid fork(); if (pid 0) { perror(fork failed); return 1; } else if (pid 0) { printf(I am child, pid%d\n, getpid()); } else { printf(I am parent, child pid%d\n, pid); } return 0; }那问题来了如果每次 fork 都要把父进程的整个地址空间复制一遍那开销不得爆炸所以现代内核用的是写时复制Copy-On-Writefork 之后父子进程先共享同一份物理页并且把这些页标记为只读。谁要写谁触发页错误内核再给它真正复制一份。很多时候 fork 之后立刻exec加载新程序压根没写几个页这招省下大量拷贝。这个机制解释了一个常见困惑为什么 fork 出来的进程内存占用看起来没翻倍因为物理内存确实还没复制只是页表多了一份。fork之后还有两个必须知道的配套操作exec系列函数用新程序替换当前进程的代码和数据PID 不变。shell 执行命令就是 fork exec。wait/waitpid父进程回收子进程的退出状态。不调用它子进程退出后会变成僵尸进程占用 PCB 不释放。这就是我开头那个坑。5.2 互斥、同步与死锁的四个必要条件线程共享内存是好事也是麻烦的来源。只要有两个以上线程同时读写同一块数据就必须考虑同步。最基础的三种原语互斥锁Mutex保证同一时刻只有一个线程进入临界区。适合保护一小段数据操作。信号量Semaphore一个计数器控制同时访问某个资源的线程数量比如最多 5 个线程同时连数据库。条件变量Condition Variable让线程在某个条件不满足时等待条件满足时被唤醒。典型用法是生产者-消费者队列。死锁这个词大家都不陌生但死锁发生的四个必要条件值得背下来因为排查时你就按这四条去找互斥资源同一时刻只能被一个线程持有。请求与保持拿着一个锁的同时还在等另一个锁。不可剥夺锁不能被强行抢走只能持有者自己释放。循环等待A 等 B 持有的锁B 等 A 持有的锁绕成一圈。四个条件必须同时成立才会死锁所以打破任意一个就能预防。工程上最常用的两招一是统一加锁顺序所有线程都按资源 ID 从小到大加锁直接破坏循环等待二是用带超时的锁比如 Java 的tryLock(timeout)超时后放弃并回退破坏不可剥夺。提醒最容易写出死锁的地方是锁里再调一个会加锁的函数。写代码时养成习惯——加锁之后只做最简单的数据操作绝不调用外部函数、绝不发起网络或磁盘 IO。这两条能帮你避开八成的死锁。5.3 进程通信 IPC 的几种方式与选型进程之间地址空间独立想交换数据就得靠操作系统提供的通道。常见的六种方式特点适合场景匿名管道单向只能用于有亲缘关系的进程shell 里的|父子进程间小数据命名管道 FIFO单向有文件路径任意进程可访问不相关进程间的简单消息消息队列有边界内核维护支持按类型读结构化消息异步解耦共享内存最快零拷贝但需自己做同步大数据量高频交换如视频帧、行情数据信号量不是传数据的是做同步的配合共享内存使用本地 socket双向可传文件描述符跨机器也能用通用首选尤其跨主机时选型我的一般原则优先 socket本地用 Unix domain socket需要极高吞吐才上共享内存简单父子通信就用管道。socket 的好处是接口统一、双向、可靠本地 Unix domain socket 还能直接传文件描述符是很多高性能服务的标配。共享内存的优势是写完就能被另一个进程直接看到省掉了两次拷贝代价是你得自己设计一套同步机制稍不留神就是数据错乱。顺便说一句消息队列 多进程是模拟分布式系统最常见的土办法。想练手分布式训练、并行计算不用真的开几台机器在本机起若干个进程用 socket 或消息队列互相通信一样能把一致性、故障处理、负载均衡这些问题体验一遍。5.4 线程池为什么不该每次 new 一个线程现在回到最实际的问题既然线程便宜那我每次来任务就new Thread不就行了不行原因是创建和销毁线程本身也要成本而且是无意义的重复成本。线程池做的事就是把这部分成本摊薄预创建一批线程放在池子里任务来了丢进队列空闲线程取走执行执行完继续待命。一个标准线程池包含这几块核心线程数常驻线程数量即使空闲也不销毁。最大线程数核心线程忙不过来、队列也满了才临时扩到这个数。任务队列缓冲突发流量。队列选型很关键有界队列如ArrayBlockingQueue能防止内存被任务撑爆无界队列如LinkedBlockingQueue不设容量则可能吃光内存。空闲存活时间超过核心数的临时线程空闲多久销毁。拒绝策略队列满、线程也满时怎么办。常见的有直接抛异常、丢弃、丢弃最老的、交给提交者线程自己执行。我的经验线上环境不要用无界队列。无界队列会让最大线程数形同虚设——任务全堆在队列里排队线程数永远到不了上限看起来很稳实际上延迟一直在涨等队列撑爆内存就是雪崩。宁可配有界队列加一个明确的拒绝策略让问题早点暴露出来。和线程池对应的还有进程池。当你需要的是隔离性而不是共享性时比如跑不可信的第三方代码、跑容易内存泄漏的任务进程池更合适。它同样预创建一批进程复用避免反复 fork 的开销。Python 的multiprocessing.Pool、concurrent.futures.ProcessPoolExecutor都是现成的。顺便提几个实际常见的问题。Java 里想等一批线程全部跑完别去手写计数器用CountDownLatch或者CompletableFuture.allOf(...).join()更清晰想获取当前线程名用Thread.currentThread().getName()排查问题打日志时非常有用。C 这边标准库提供了std::thread但std::thread析构前必须join或detach否则直接terminate这是个高频崩溃点线程池则通常要自己写或借第三方库。还有一个容易忽略的线程池的大小不是越大约好也不是和 CPU 核数固定绑定得看任务类型下一节细说。6. 踩坑实录那些和进程线程有关的真实报错6.1 文件被占用、端口被占用、软件包被加锁这类报错是运维和开发的日常本质都指向同一个问题操作系统的某个资源同一时刻只允许一个占用者。U 盘无法弹出提示请先结束占用进程——U 盘上的某个文件被某个进程打开了。Windows 下用资源监视器的磁盘标签页找Linux 下用lsof /media/usb或fuser -m /media/usb。找到之后要么让那个程序关闭文件要么直接结束它。别硬拔正在写入的文件很可能损坏。VMware 提示另一个程序已锁定文件的一部分——虚拟机异常关闭时留下的锁文件没被清理。进虚拟机目录找到.lck后缀的文件夹和.lock文件确认没有虚拟机进程在跑之后删掉即可。这类问题的通用思路就是先确认没有活着的进程持有再清理残留锁。包管理器报错另外一个进程已经为前端锁加锁——这个和 dpkg 的场景类似是包管理器的全局锁被另一个实例持有。可能确实有一个安装进程在后台跑等它跑完就好也可能是上次异常中断留下的。用ps找一下有没有残留的包管理进程确认没有的话再按官方文档的方式清理锁。千万别在锁存在的情况下强删数据库式的暴力操作容易把包状态搞坏。6.2 僵尸进程、孤儿进程与句柄泄漏僵尸进程是子进程先退出、父进程还没调用wait回收导致子进程的 PCB 一直留在内核里。特征是状态显示为Zps里能看到但kill不掉。解决方式是让父进程正确回收或者结束父进程让 init 接管。孤儿进程是父进程先退出子进程还在跑会被 init或 systemd收养问题不大。句柄泄漏比僵尸进程更隐蔽。进程一直活着但它打开的文件描述符越来越多最后超过上限报 Too many open files。排查方法就一条# 看这个进程当前打开了多少 fd ls /proc/pid/fd | wc -l # 看 fd 上限 ulimit -n # 看具体都是些什么文件 lsof -p pid | head -50实操心得如果你的服务跑几天就报 Too many open files八成是某个异常分支里忘了关流。写代码时用 try-with-resourcesJava或 with 语句Python强制释放比事后排查省事一百倍。6.3 线程池参数怎么定CPU 密集与 IO 密集的算法这是被问得最多的问题。有一个被广泛引用的经验公式CPU 密集型任务线程数 ≈ CPU 核数 1。多出来的 1 是为了在某线程偶尔缺页中断时顶上保证 CPU 不空闲。IO 密集型任务线程数 ≈ CPU 核数 × (1 等待时间 / 计算时间)。比如一个任务计算 10ms、等待 IO 90ms等待占比 90%那么 8 核理论上是 8 × (1 9) 80 个线程。这个公式只是起点不是终点。真正的参数应该这么定先测用压测工具JMeter、wrk 都行在预发环境打观察吞吐量和响应时间。找到拐点逐步加线程数看 QPS 什么时候不再涨、延迟什么时候开始飙升那个点就是上限。留余量线上按拐点的 70%~80% 配给突发流量留空间。配合队列队列大小和线程数是连动的。线程少队列大延迟高但吞吐稳线程多队列小响应快但容易被拒绝策略打回来。关于压测还有个细节如果用 JMeter 做参数化压测同一个 CSV 文件被多个线程读默认每个线程都会从头读一遍导致参数重复。需要在 CSV 数据配置里选每个线程独立读取还是所有线程共享按业务需求来。这个配置选错了压测结果会骗你。6.4 常见问题速查表现象可能原因排查动作CPU 100% 且进程数异常子进程卡死、循环失控top找进程再看是哪个线程top -H -p pid内存持续上涨不回落内存泄漏、句柄泄漏valgrind、堆快照对比、/proc/pid/fd计数进程状态为 Z 且无法 kill僵尸进程检查父进程是否调用 wait接口响应慢但 CPU 不高线程都在等 IO 或锁抓线程栈看是否大量线程处于等待状态报 Too many open files文件描述符泄漏lsof -p pid看泄漏的 fd 类型任务卡住无输出死锁抓栈找循环等待的锁持有链程序退出时报 terminateC 线程未 join检查所有std::thread的销毁路径服务启动后无响应无报错主线程被阻塞或界面线程卡死看是否有阻塞式初始化是否在 UI 线程做重活7. 几个容易踩的框架细节7.1 定时器的槽函数到底在哪个线程跑用 Qt 做开发的同事问过我一个高频问题定时器的槽函数是在子线程执行的吗答案取决于定时器对象创建在哪个线程。Qt 有一套线程亲和性规则一个 QObject 属于创建它的那个线程它的槽函数默认在那个线程的事件循环里执行也就是队列连接。所以在主线程创建的 QTimer超时槽函数在主线程跑。在子线程创建的 QTimer超时槽函数在子线程跑但前提是这个子线程跑起了事件循环调用了exec()。如果你在子线程创建定时器却在主线程启动Qt 会警告无法为不同线程的对象启动定时器。至于曲线刷新能不能放在另一个线程能但要守规矩所有 GUI 绘制都必须在主线程做子线程只负责算数据算完通过信号槽把结果发回主线程由主线程更新界面。直接绕过主线程去操作控件轻则界面错乱重则随机崩溃而且这类崩溃极难复现。数据库读写这种耗时操作同理放子线程用连接池注意每个线程用自己独立的数据库连接别跨线程共享。7.2 UI 线程不能阻塞这条铁律Android 上也是一样的逻辑。主线程也叫 UI 线程负责处理用户输入和绘制任何耗时操作扔上去超过几秒系统就会弹应用无响应。所以网络请求、数据库查询、大文件读写统统要放到子线程然后用 Handler、协程或者runOnUiThread把结果切回主线程更新界面。桌面端同理。我见过一个案例某软件启动之后只有进程没有窗口任务管理器里明明有它就是不出界面。最后定位到主线程在初始化阶段做了一次同步的网络请求恰好在那个时刻把自己卡死了窗口自然画不出来。这类进程活着但界面不出来的问题第一反应就是去查主线程是不是被某个同步调用挡住了。7.3 并行编译与后台进程的那些事最后说两个日常会碰到的。构建工具的并行参数make -j、colcon build的并行选项之类本质就是控制同时启动多少个编译进程或线程。这个数值不是越大越好——超过了 CPU 核数编译进程会互相抢核加上内存占用叠加反而更慢甚至触发 OOM。我一般设成核数或者核数的 1.5 倍然后看实际用时再微调。碰到编译内存爆掉第一件事就是把这个并行数降下来。后台进程的查找和关闭也是基础功。Linux 下ps aux | grep 关键词、pgrep -f 关键词、htop里按 F4 过滤、pkill -f 关键词批量结束都比手敲 kill 高效。桌面环境里那些名字看着眼熟的组件进程比如各种 indicator、面板、输入法框架大多数是可以关的但关掉可能丢功能——托盘图标没了、剪贴板同步失效之类的。关之前先搜一下它是什么别为了省点内存把桌面搞瘸了。至于那些带内核驱动保护的进程普通权限确实结束不了这是设计如此别想着绕按正规方式处理。另外很多软件启动后进程数远超一个原因就是前面提到的多进程架构渲染进程、GPU 进程、网络进程、插件进程各占一份。看到某软件怎么开了这么多进程时先别慌看看是不是正常的架构设计而不是真的出问题了。我个人在踩过这些坑之后最大的体会是进程和线程的知识不是学一次就完事的它会在你每次调参数、查故障、做架构选择的时候重新冒出来。我现在养成了一个习惯遇到任何卡慢崩的问题先在脑子里过一遍——这是资源问题、调度问题还是同步问题是发生在进程边界上还是线程内部想清楚这一层再去翻文档效率会高特别多。下一篇我打算把线程同步的具体原语锁、条件变量、信号量和几种经典并发模型拆开讲配合能直接跑起来的代码把这一块彻底钉死。