ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

进程与线程深度解析:从生命周期到线上问题排查实战

进程与线程深度解析:从生命周期到线上问题排查实战 经常有人问我进程和线程到底有什么区别我一般会先甩个通俗类比进程是独立的厨房线程是厨房里干活的厨师但每当线上真出问题时这个类比根本不够用。过去十几年里我见过因为没 wait 而堆积的僵尸进程、被线程池队列撑爆的内存、被 Windows 后台守护进程搞得 CPU 打满的服务器也亲手在 Linux 下折腾过进程改名和 GPU 驱动。所以这篇文章不是教科书式的概念复述而是从进程生命周期、线程互斥、IPC、线程池一路讲到线上问题排查的实操记录。想彻底搞懂进程和线程或者正被各种并发问题折磨的开发者都适合往下看。1. 进程和线程到底差在哪一张表说清核心区别1.1 进程是资源边界线程是执行单元从操作系统的视角看进程的第一身份是“资源容器”。内核里描述每个进程的是一个 task_struct里面记录着地址空间、打开的文件描述符、信号处理函数、环境变量、当前目录这些私人财产。每个进程都有独立的虚拟地址空间进程 A 读不到进程 B 的数据这不是靠自觉而是通过 MMU 的页表隔离实现的。所以把一个进程想象成一栋独立房子更准确水电煤齐全自己管自己的账单。线程就住在这栋房子里干活。同一进程里的多个线程共享地址空间、堆、全局变量和文件描述符但每个线程有自己的栈、程序计数器和寄存器上下文。因为线程之间切换不需要换页表所以线程切换的代价明显低于进程切换。这是“线程比进程轻量”最底层的含义而不是单纯指创建速度快。这个模型带来的一个直接后果是进程之间天然隔离一个进程崩了不会拉垮别人但同一进程里的线程共享地址空间一个线程写出野指针整栋楼都跟着塌。这也是为什么浏览器宁可做多进程而像 Nginx 这类服务器选择用少量进程加大量线程。1.2 一张表说清进程和线程的核心区别很多文章用一长篇话讲区别我比较倾向直接上表核心差异一目了然对比项进程线程本质资源分配的单位CPU 调度/执行单位地址空间独立共享所属进程通信成本需要 IPC成本高共享内存成本低崩溃影响进程相互隔离线程会影响整个进程切换开销高涉及页表、缓存相对低创建销毁较重较轻适用场景隔离保护、分布部署高并发、任务共享数据但这里要补一个重要事实在 Linux 眼里线程和进程并没有本质区别。Linux 用 clone() 创建任务线程不过是在克隆时加了 CLONE_VM 等标志共享了地址空间而已内核统一把它们叫任务都用 task_struct 描述。所以很多在 Linux 下的“进程管理”手段比如用 jps 看 Java 进程、用 ps 查线程其实操作的是同一套机制。明白这一点后面讲 wait、改进程名、线程池时就不会觉得割裂。1.3 三种常见错误用法踩过的人不在少数第一种是把进程当线程用。以前写 CGI 程序就是典型的每来一个请求就起一个进程创建销毁开销大并发一高直接把系统拖垮。现在很多人仍然会把“多开浏览器、多开服务”理解成并发但真正的高并发应用基本都在进程内起线程。第二种是把线程当进程用无节制地开线程。默认线程栈大约 8MB如果你开 10 万个线程光栈就要吃掉近 800GB 虚拟内存即使不马上宕机也会因为频繁上下文切换让系统陷入“线程风暴”。线程池和进程池都是为了解决这个问题而生的。第三种是不够重视线程安全多个线程直接读写同一个 ArrayList 或同一个计数器。这类问题的表象千奇百怪数据错乱、偶发崩溃、性能忽高忽低。等到排查时才发现共享变量没有加锁也没有用原子类最终绕回到锁、CAS 和并发模型的选择上也就是后面第三章要展开的内容。2. 进程生命周期实操等待 wait、改名与守护化2.1 子进程一不注意就变僵尸wait 不是为了收尸用 fork 创建子进程太简单了难的是怎么处理子进程的“善后”。子进程退出时内核不会立刻回收它的全部资源至少要保留 task_struct目的就是让父进程通过 wait/waitpid 读取它的退出状态。如果父进程一直不去 wait退出后的子进程就会变成僵尸进程占着一个进程表项。少量僵尸不可怕可怕的是无限堆积——PID 是有限的耗尽之后新的 fork 会直接失败。wait 的行为会阻塞直到有子进程退出waitpid 更灵活可以指定子进程也可以加 WNOHANG 做非阻塞轮询。服务端常见的写法是在 SIGCHLD 信号处理器里循环调用 waitpid(-1, NULL, WNOHANG)把已经结束的子进程全部回收掉。#include sys/wait.h #include unistd.h #include stdlib.h int main(void) { pid_t pid fork(); if (pid 0) { // 子进程去执行真正的任务 execl(/bin/echo, echo, child running, NULL); _exit(0); } int status; waitpid(pid, status, 0); // 等子进程结束并回收 return 0; }我在实际项目里遇到过一种情况父进程每处理一批消息就 fork 一个子进程去跑脚本但代码只在日志里打了“子进程已退出”就完事从没调用 wait。线上跑了两个月/proc 下密密麻麻全是 defunct 进程连新的运维命令都执行不出来了。所以记住一句话要么 wait要么设 SIGCHLD 忽略二选一但别不处理。2.2 Linux 修改进程名称的两种姿势与 15 字符的坑运维侧经常需要按进程名做监控和过滤所以“修改进程名称”是很多人搜过的需求。最简单的方式是改 argv[0]。进程启动时内核通过 argv[0] 把名字记到进程的 cmdline 里C 语言里直接改 argv[0] 指向的字符串ps 里看到的命令就会变。但副作用也明显命令行参数会被一起污染而且这种方式要求你有权限修改自己的 argv 区域对别人家的进程不生效。更正统的方式是调用 prctl#include sys/prctl.h prctl(PR_SET_NAME, myworker, 0, 0, 0);对 Java 进程同样可以用反射或 native 手段改但更常见的做法是直接写 /proc/self/comm。这个文件就是进程名对应的内核字段写入后 top 和 ps 看到的 NAME 列就会变化。坑就在这内核里记录进程名的 comm 字段长度只有 16 字节其中 1 个字节是终止符所以实际最多 15 字符。你写个“my_ai_worker_process”ps 里只会显示前 15 个字符。想显示完整名称就得牺牲 argv[0] 方案或者在观测端做映射关系。我建议生产环境把进程名控制在 15 字节以内别给自己留隐患。2.3 守护进程与会话解绑进程池复用进程很多人把“进程常驻后台”理解成守护进程其实并不严谨。真正变成守护进程需要完成几件事fork 一次并让父进程退出、调用 setsid 创建新会话、把工作目录切到 /、把标准输入输出重定向到 /dev/null。这样进程就脱离了终端和会话不再受终端关闭、挂断信号影响。现在主流做法是交给 systemd 这类进程管理器来管它们天然具备守护化能力自己写 daemon 反而容易在环境变量、日志转发上踩坑。另一个常见概念是进程池。和线程池思路类似进程池提前创建好一定数量的工作进程任务分发到空闲进程去执行。Python 的 concurrent.futures.ProcessPoolExecutor 是我用过比较顺手的CPU 密集型任务用它能避开 GIL 的限制from concurrent.futures import ProcessPoolExecutor def heavy_compute(data): # 纯 CPU 计算进程池并行 return [x * x for x in data] with ProcessPoolExecutor(max_workers4) as pool: results list(pool.map(heavy_compute, [range(1000)] * 4))但要记住进程池有额外成本进程间通信需要序列化任务和结果必须能 pickle进程越多内存开销越大。线程池适合 IO 密集进程池适合 CPU 密集这是我在选型时默认的第一原则。3. 线程协作的底层逻辑互斥、IPC 与死锁定位3.1 AtomicInteger 线程安全吗从锁到 CAS 的取舍直接回答AtomicInteger 是线程安全的它的自增操作 incrementAndGet() 具有原子性。底层依赖的是 CAS也就是比较并交换线程先读出当前值计算期望值再尝试把新值写回如果内存里的值已经被别人改过就重新读取并重试。这个过程在处理器层面由一条原子指令保证不需要加锁这也是它在并发计数器场景下比 synchronized 快的原因。AtomicInteger counter new AtomicInteger(0); counter.incrementAndGet(); // 原子自增但“线程安全”不等于“业务正确”。比如两个线程分别做“检查再设置”这种复合操作即使每一步原子组合起来仍然可能出错。ABA 问题也是个经典坑CAS 只关心值是不是和预期相等不关心期间是否被改过又改回来一些对历史状态敏感的场景要用版本号或 AtomicStampedReference。所以我在实际编码里的取舍是简单的计数器、唯一 ID 生成器、状态开关用 CAS 原子类涉及多个变量联动的复合状态老老实实加互斥锁。线程互斥的本质是保护临界区Java 里 synchronized 和 ReentrantLock 最终都会陷入内核的 futex 等待锁竞争激烈时反而比 CAS 更慢。3.2 进程通信 IPC管道、共享内存、消息队列与 Socket 怎么选线程之间靠共享内存加锁通信但进程之间地址空间隔离要交换数据必须走 IPC。新手最容易犯的错是把 IPC 等同于“内存共享”忽略了两点一是不同 IPC 的开销差异很大二是选错会直接影响系统吞吐。方式特点适合场景管道pipe单向字节流匿名管道仅父子进程可用父子进程间简单消息消息队列内核维护队列按类型读取有大小限制进程间结构化短消息共享内存mmap直接读写同一块物理内存性能极高大数据量、低延迟交换Socket双向、跨主机可基于 TCP分布式系统、跨语言我做过一个数据采集服务最开始图省事把所有采集结果都往消息队列里塞下游消费不过来消息积压把内存搞爆了。后来改成共享内存环形缓冲单机吞吐直接翻了几倍。共享内存虽然快但它要自己处理同步问题信号量、原子变量一个都少不了。反过来跨主机的场景就别指望共享内存老老实实用 gRPC 或 TCP 协议。一句话总结数据传输量越大、实时性要求越高越应该往共享内存方向靠系统越复杂、节点越多越应该用标准化的消息通道。3.3 死锁实录四条件、现场还原与排查工具死锁这个词已经被讲烂了但每到我给团队排障还是会有人写出教科书级死锁。死锁必须同时满足四个条件互斥、持有并等待、不可剥夺、循环等待。一个简单例子线程 A 持有锁 L1 想拿 L2线程 B 持有锁 L2 想拿 L1两个线程互相等对方释放系统卡死。线程死锁在 Java 里很容易定位。先 jps 找到进程 PID再执行 jstack PID输出末尾如果出现 “Found one Java-level deadlock”紧接着就会列出两个线程各自持有的锁和等待的锁。数据库的死锁类似MySQL 下只要执行 SHOW ENGINE INNODB STATUS就能看到事务与锁的等待关系通常会明确指出回滚了哪个事务。预防手段比排查更重要。我给自己定的三条铁律第一多个锁总是按固定顺序获取谁先谁后写进代码规范第二能用 tryLock 带超时就别用无超时 lock超时后做降级第三线程池任务里不要再往同一个池里提交并等待其完成否则核心线程都被卡住任务队列里的活没人干这其实是一种非常容易混淆视听的“线程池死锁”。如果你确实想让一批线程都完成后继续正确姿势是 join、CountDownLatch 或者 CompletableFuture.allOf而不是手动写循环去轮询线程状态。4. 线程池配置实战参数、队列选择与虚拟线程4.1 ThreadPoolExecutor 核心参数与阻塞队列怎么选Java 的 ThreadPoolExecutor 总共就七个参数核心是四个corePoolSize 核心线程数线程池保持存活的最少线程maximumPoolSize 最大线程数忙不过来时最多扩到多少keepAliveTime 非核心线程的空闲存活时间workQueue 阻塞队列用来缓冲暂时处理不过来的任务。队列选择是最容易被忽略的一环。我见过很多人用 Executors.newFixedThreadPool这背后的队列是 LinkedBlockingQueue默认无界。任务量一大队列无限增长堆内存先被撑爆然后频繁 Full GC最后整台机器进入假死状态。生产环境我优先用有界队列比如 ArrayBlockingQueue任务超过上限就触发拒绝策略让调用方明确感知压力而不是默默堆积。队列是否有界特点ArrayBlockingQueue有界容量固定排队公平可控LinkedBlockingQueue默认无界吞吐高但易 OOMSynchronousQueue无存储每次提交都需线程接管适合并发量波动大的短任务拒绝策略也有讲究。默认的 AbortPolicy 会直接抛异常CallerRunsPolicy 会让提交任务的线程自己执行相当于把压力反馈给调用端我比较推荐这个因为它不会丢任务只是把并发转成调用方的等待。DiscardPolicy 和 DiscardOldestPolicy 会因为静默丢弃而埋雷日志里根本看不到。4.2 CPU 密集与 IO 密集任务配多少线程一个可用公式线程池最经典的问题就是“配多少线程”。没有万能数字但可以按任务类型推算下限。CPU 密集型任务线程数可以设为 CPU 核数 N最多 N1因为线程在等待一点系统操作时多出的一两个线程能帮忙补位。IO 密集型任务比如 HTTP 请求、数据库读写、文件传输线程大部分时间在等 IO 返回此时线程数可以按 N * (1 等待时间/计算时间) 估算或者粗略给到 2N。下面是我项目里常用的一个模板int nCpu Runtime.getRuntime().availableProcessors(); new ThreadPoolExecutor( nCpu, // 核心线程 nCpu * 2, // 最大线程IO 密集可再放大 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new ThreadFactoryBuilder().setNameFormat(trade-pool-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() );有两个细节是常规文档不会写的。第一线程工厂一定要命名否则 jstack 出来全是一堆 “pool-1-thread-1”你根本分不清是哪个业务排查时用 Thread.currentThread().getName() 也能拿到这个自定义名字。第二别把 maximumPoolSize 设置成上万线程数过多反而会因为频繁上下文切换拖慢整体性能。另外 C# 里线程池用 ThreadPool.SetMinThreads 调整下限思路类似但 CLR 会自适应调度通常不需要手动改太大。4.3 虚拟线程与自由线程传统线程池会被颠覆吗Java 21 正式带来了虚拟线程它不再是 1:1 映射系统线程而是由 JVM 在少数平台线程上调度的大量轻量线程。虚拟线程的阻塞不会占住平台线程成本很低可以轻松创建几十万个。这让“一个请求一个线程”的模型重新变得可行很多为线程池优化而设计的手写调参反而显得多余。真实原理就是协程式调度和 Go 的 goroutine 思路相似。记住核心虚拟线程要的是阻塞廉价化而不是让线程数量无限膨胀。Python 这边也有新动作3.13 引入了自由线程构建允许在去掉 GIL 的模式下运行让多线程真正利用多核。不过生态还在过渡很多 C 扩展尚未兼容。这给我们的信号是过几年线程模型会越来越“自由”但线程池作为控制资源、保护系统稳定的手段短期内不会被淘汰虚拟线程也不能解决所有问题CPU 密集任务终究要绑定真实核数。Java 守护线程setDaemon(true)则是一个容易混淆的辅助概念——主线程结束了它会被自动终止一般适合做监控清理任务别拿它执行关键业务。5. GPU 并行启示录线程块、网格与 Linux 驱动安装5.1 理解线程、线程块、网格和 warp如果你是做传统后端第一次写 CUDA 代码时一定会被“线程块、网格”这些词绕晕。简单说GPU 上的线程不是像 CPU 那样由操作系统调度而是以“线程→线程块→网格”三层结构组织整个内核函数由一个网格调度执行。一个线程块包含多个线程块内线程可以使用共享内存并同步一个网格包含多个线程块warp 是 GPU 实际调度的最小单位通常是 32 个线程绑定在一起执行同一指令。一个kernel256, 256(a)的内核里块索引和线程索引会组合成全局唯一 ID。写代码时常见错误是只改 blockDim 忘了算 gridDim导致数据没覆盖全另一个是过度依赖线程同步让 warp 内线程产生分支分歧性能骤降。理解了这套模型你就知道为什么 GPU 程序追求“大量轻线程”而 CPU 程序追求“少量重线程”。热词里的“理解线程、线程块、网格和 warp”背后真正要理解的是两个并行世界完全不同的组织逻辑。5.2 摩尔线程 S80 安装 Linux 驱动的一次记录这几年国产 GPU 设备逐渐多了起来摩尔线程 S80 就是一款面向桌面与工作站的产品。它的驱动生态不如 NVIDIA 成熟安装踩坑概率高我把实际操作步骤记下来供参考。第一步确认环境查看内核版本 uname -r去官网下载对应内核版本的驱动包通常是一个 .run 文件。第二步清理旧驱动如果系统里装过 NVIDIA 驱动最好先卸载干净同时确认没有加载冲突模块。第三步进入命令行模式安装驱动时不能有图形桌面抢占显卡可以先进入 TTY 文本界面。第四步执行安装脚本sudo sh 驱动包名.run安装过程可能会提示缺少 DKMS 或编译工具先 apt 装上 build-essential 和 dkms。第五步加载并验证用 mthreads-smi类似 nvidia-smi查看 GPU 状态。我这次踩的两个坑一是 Secure Boot 开启状态下模块签名不过会直接加载失败后来进 BIOS 关掉才能正常使用二是安装后升级了内核驱动因为 DKMS 未配置好没有“跟着”内核一起重编译重启后 GPU 又找不到了。如果你也遇到类似问题建议先检查 Secure Boot 和内核版本再动手别急着重装系统。6. 线上问题排查进程线程问题的现场还原6.1 Java 进程排查三板斧jps、jstack、jmap排查 Java 异常我的固定流程就是三板斧。先 jps 列出当前 Java 进程拿到 PID再 jstack PID 输出线程快照重点看 BLOCKED、DEADLOCK 状态和线程名必要时 jmap 看堆内存使用。很多“卡死”问题在 jstack 里一眼就能看到线程在等什么锁。还有个很容易被忽略的提示语像“jps 增量注解进程已禁用。部分重新编译的编译结果可能不准确。使用构建进程”。这本质上是 IDE 或 Gradle 的增量编译线程没有正常启动并不代表系统坏了解决办法通常是清理缓存、重启 IDE 里的构建进程或者直接命令行编译来验证。IDEA 编译 OOM 是另一个高频问题。有人设置了“进程堆大小调整为 8000”还是报 OutOfMemoryError多半没搞明白是谁的堆。IDEA 的构建进程和 IDE 主进程是两个不同 JVM光在 vmoptions 里改主进程不够要去 Build Tools 的设置里单独调整构建进程堆。而且 OOM 未必是堆不够Metaspace、Native Memory 也可能是元凶最好先看日志里的异常描述再决定调哪个参数。6.2 Windows 上的顽固后台进程处理实录Windows 上的进程问题经常和杀毒、后台守护连在一起。先说 msmpeng.exe这是 Windows Defender 的底层扫描进程它瞬间 CPU 打满常见于首次全盘扫描或杀毒规则更新后。正确处理不是结束进程而是把项目目录、缓存目录加入 Defender 排除列表并给计划扫描安排非业务高峰时段。msedgewebview2.exe 则是 WebView2 运行时很多桌面软件都用它嵌入网页。它不会随 Edge 关闭而退出正确做法是找到使用 WebView2 的宿主进程在设置里关闭对应功能而不是直接 taskkill杀完还会被拉起。还有一类“杀一个换一个”的进程比如某种下载工具的后台进程、或者 com.vortex.helper 这种带随机后缀的守护进程。它们通常有父进程看守单杀子进程没有意义。用 Process Explorer 打开进程树找到根父进程结束整棵树或者去启动项、计划任务里禁用自启才能一劳永逸。Windows 的 System 进程占用高也别急着杀先看是否磁盘占用或驱动问题关闭 Windows Search 索引、Defender 扫描往往更有效。微软 Surface 设备上偶尔出现的 Contemporary 进程与 Modern Standby 电源模式有关占用过高查驱动和电源策略。6.3 系统异常场景碎片MySQL 1067、ChatGPT 没画面、终端 conpty 问题MySQL 在 Windows 上服务启动失败时常见报错就是“1067 进程意外终止”。不要反复点启动直接去 data 目录看 .err 日志多半是 my.ini 配置错误、端口被占用或者权限不足。改完配置后先 net stop mysql 再 net start很多时候就恢复了。ChatGPT 桌面端“有进程没画面”一般是 GPU 渲染与窗口状态出了问题。可以看看进程是否占用显存、试着切换硬件加速开关、重启应用并清理本地缓存。如果还是没有画面检查显卡驱动版本和系统更新状态基本能解决。Windows Terminal 启动报“终端进程启动失败无法启动 conpty”并提示已移除 winpty通常是因为旧版 VSCode 集成的 winpty 组件与新终端冲突。升级或重装终端组件即可不用折腾系统文件。还有一类“未找到某个后台进程”的提示最常见原因是软件被安全工具误杀或卸载残留重装并恢复启动项即可。日常监控前台进程可以先 ps -eo pid,stat,cmd --sort-%cpu | grep 关键字再确认它是不是你关心的那个进程。7. 项目场景中的线程取舍Android、Python 与异构调度7.1 Android Fragment 里开启线程的坑与正确姿势在 Android 的 Fragment 里用 new Thread 是最容易踩的坑。Fragment 可能因为屏幕旋转、返回键而被销毁重建但线程一旦启动不会自动停止它可能还会尝试访问已销毁的视图直接抛 IllegalStateException 或造成内存泄漏。正确姿势是让线程生命周期跟随界面的生命周期。现在主流方案是 ViewModel 协程UI 事件进入 ViewModel 处理网络和数据操作在 dispatcher 上执行Fragment 用 viewLifecycleOwner 观察数据页面销毁时协程能通过 LifecycleScope 自动取消。如果还在维护老项目也要优先用 HandlerThread 或 ExecutorService并在 onDestroy 中显式关闭而不是让线程自生自灭。一句话手机端的内存和 CPU 比服务器紧张得多线程数更不能乱开。项目里出现过“为了提升列表流畅度开线程池预加载图片结果线程没回收导致卡顿”的经典失败案例最终把线程池收敛到固定大小加上生命周期绑定才算稳定。7.2 Python 线程里还能再开线程吗什么时候会出事Python 的 threading 支持在线程里再创建线程嵌套本身不会报错但要注意两个问题GIL 让纯 Python 计算多线程几乎无法并行嵌套更多是心理安慰而线程之间的依赖一旦形成环路就可能死锁。比如外层线程 join 内层线程内层线程又等待外层线程持有的锁释放两边谁都不让谁。更合适的方式是让任务结构化用 concurrent.futures.ThreadPoolExecutor 把任务提交到池子用 Future 管理结果CPU 密集计算换用 ProcessPoolExecutor。有人提“线程方程组”时其实说的是把一个大计算拆成一组可并行的小任务这正好可以用 executor.map 来做批量提交。from concurrent.futures import ThreadPoolExecutor def solve_one(item): # 每个子问题独立 return compute(item) with ThreadPoolExecutor(max_workers8) as pool: results list(pool.map(solve_one, tasks))如果你确实需要动态嵌套建议用 asyncio 线程池的混合模型外层协程调度底层真正阻塞的操作才丢给线程池。这样既避免了线程无限嵌套也让并发模型清晰可维护。7.3 异类线程调度策略大小核带来的延迟抖动“异类线程调度”这个词听着像学术论文其实现在手机、服务器里全是异构核高性能大核加低功耗小核。操作系统默认调度器倾向于把线程放到小核上节能结果高优先级的请求偶尔被安排到低频核延迟出现“时好时坏”的抖动。应对思路按平台而异。Linux 下可以用 taskset 把关键进程绑定到特定核心Java 也可以通过线程亲和性库绑定线程到指定 CPU。但绑核要谨慎绑死大核会损失小核的弹性极端场景反而退化成单核性能。我的经验是先测量再绑定。用 perf 或 pidstat 观察线程实际运行在哪个核上确认存在跨核调度导致的明显延迟再决定绑核策略。真正理解“异类线程调度策略”本质是理解操作系统如何权衡能耗、延迟和吞吐而不是一味教操作系统怎么做。写到这里我想起自己第一次被僵尸进程干趴下的那个下午线上服务突然 fork 不出新进程PID 一个都不剩查了半天才发现所有父进程都在忙着处理消息压根没人调用 wait。从那以后我养成了一个习惯凡是代码里出现进程、线程、线程池先问三个问题它被谁创建它由谁回收它挂了会影响谁。把这三个问题想清楚大多数并发故障都能提前化解。这也是我写这篇内容的初衷——概念可以很简单但背后的责任链永远要自己想明白。
返回列表