ARTICLE DETAIL

资讯详情

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

进程、线程、协程:执行单位选型与并发问题排查实战

进程、线程、协程:执行单位选型与并发问题排查实战 1. 先把三者的定位说清楚进程、线程、协程这几个关键词每隔一段时间就会被翻出来讨论一遍从面试题到线上故障排查总绕不开它们。可现实是很多同学能把“进程是资源分配单位线程是调度单位协程是用户态调度”背得滚瓜烂熟一遇到实际问题就抓瞎多线程程序越优化越慢协程里调用个阻塞接口直接把整个服务卡死生产环境误删进程导致图形界面黑屏。这背后的问题不是记不住定义而是没有把三个执行单位放进真实的运行环境和资源约束里理解。这篇文章我会从三者的本质差异切入结合实操中常见的进程等待、线程死锁、协程挂起、线程池配置这些真实场景讲清楚怎么选型、怎么写、怎么排查。1.1 从“执行单位”的角度看三兄弟用车间干活来类比最直观。进程是整个车间拥有独立的厂房地址空间、设备清单文件描述符、水电额度内核资源车间之间互相看不到对方的内部情况。线程是车间里的工人大家共享同一个厂房和设备可以随手拿到对方手里的工具但正因为太熟了两个人同时用一个扳手就可能打架。协程就是一个工人手里的多张工序卡干完一步主动把工位让给下一个工序不需要车间主任操作系统插手调度。这个类比能解释三个高频问题。第一为什么进程之间互相崩溃不影响因为每个进程有独立地址空间A进程越界写内存不会污染B进程的数据这也是浏览器每个标签页开一个进程的核心原因。第二为什么线程切换比进程切换成本低线程切换时地址空间保持不变只需要保存和恢复寄存器、栈指针等少量上下文相当于工人换岗车间设备不用动。第三为什么协程切换比线程还轻协程切换完全发生在用户态由程序自己决定挂起点和恢复点不进入内核态连系统调用那部分开销都省了相当于工人从笔记本上撕下一页记录直接开始下一道工序。但这里有个容易踩的认知坑协程并不具备真正的并行能力。协程是挂在某个线程上的它只能并发执行不能同时使用多个CPU核心。真正能跨核并行的是进程和线程。所以遇到CPU密集型任务指望用协程把4核吃满是不现实的你得开多进程或多线程。1.2 一张表看懂三种执行单位怎么选先解决最常被问的“进程和线程的区别”再把协程放进来做对比。维度进程线程协程资源隔离强独立地址空间弱共享进程地址空间最弱依赖承载线程上下文切换成本高涉及内核态和地址空间切换中内核态调度但不换地址空间低用户态自行切换跨CPU核并行可以可以不行受宿主线程限制数据共享需要通过IPC麻烦共享内存但需同步机制天然方便变量直接共享崩溃影响互不影响一个线程崩溃可能导致整个进程崩溃协程异常通常只影响自身任务典型适用场景多租户隔离、沙箱、独立服务CPU密集型、需要跨核并行I/O密集型、高并发连接处理选型看三点就够了。第一看重隔离还是看重性能要隔离选进程要低延迟高吞吐选线程或协程。第二任务是CPU密集还是I/O密集CPU密集优先多进程或多线程I/O密集优先协程。第三团队驾驭能力协程虽然轻但事件循环模型一旦被阻塞很难排查如果团队不熟老老实实用线程池反而更稳。1.3 别把“线程安全”和“并发提速”画等号还有一个常见误区很多人搜“线程安全”觉得加了锁、用了AtomicInteger就万事大吉甚至以为线程安全能让程序跑得更快。实际上线程安全代码往往比单线程慢因为它引入了锁、CAS循环和内存屏障。AtomicInteger线程安全吗安全它靠的是CAS比较交换和volatile内存可见性但没有锁竞争时它快锁竞争激烈时它会有大量自旋重试性能可能不如直接上synchronized。记住核心原则并发不是目的吞吐率和响应时间才是。后面我会在实战对比里具体展示这个结论。2. 进程资源分配的基本单位进程是操作系统里最古老的抽象谈并发永远绕不开它。进程相关的热词里出现频率最高的几个是“进程等待wait”“守护进程与会话”“进程通信IPC”“linux修改进程名称”还有各种进程占用CPU过高的问题。这一节我按生命周期、通信、后台化、排查四条线展开。2.1 进程的一生创建、等待、退出Linux上创建进程最常见的是fork和vfork。fork会复制父进程的完整地址空间子进程从fork调用处继续执行。真正干活之前得先搞清楚“谁等谁”的问题。父进程如果不等子进程子进程退出后会变成僵尸进程long long地躺在进程表里占着PID直到父进程调用wait或waitpid把它回收。#include stdio.h #include sys/wait.h #include unistd.h int main(void) { pid_t pid fork(); if (pid 0) { // 子进程 printf(child pid%d\n, getpid()); return 42; } // 父进程阻塞等待子进程结束 int status; if (waitpid(pid, status, 0) -1) { perror(waitpid); return 1; } if (WIFEXITED(status)) { printf(child exit code %d\n, WEXITSTATUS(status)); } return 0; }为什么必须wait因为子进程退出时只释放了用户态资源内核进程表里还留着一个记录专门让父进程读取退出码。父进程不读这个记录就一直在。生产环境里如果发现大量僵尸进程优先检查父进程是不是没有正确wait。另外一个方向是“孤儿进程”父进程先退出子进程会被1号进程收养自动回收这倒不用太担心。2.2 进程通信IPC家族盘点进程之间地址空间隔离意味着不能像线程那样直接读写一块共享变量必须走操作系统提供的IPC通道。按性能从低到高排个序管道适合单向流式数据比如shell里的ps -ef | grep java信号适合通知事件比如SIGTERM让进程优雅退出消息队列适合小报文共享内存适合大数据吞吐但需要自己处理同步socket最普适跨机跨进程都行。实操场景里最典型的是“父进程和子进程传大量数据”。用管道传几十MB的数据会把GB级内存邮局直接mmap一块共享内存配合信号量互斥性能提升是数量级的。还有一个容易被忽略的坑很多语言的多进程库比如Python的multiprocessing底层数据传输依赖pickle和pipe传大对象会非常慢。这时候就要评估是不是该用共享内存或换架构。2.3 守护进程与会话后台任务的底层逻辑守护进程是长驻后台、脱离终端控制的进程。经典做法是fork一次或两次然后调用setsid建立新会话让进程摆脱控制终端。现在生产环境很少再手写双forksystemd直接管理服务单元更干净。但排查老机器时你还是得认识nohup、setsid、这些后台启动方式的区别。nohup不负责脱离会话它只是让进程忽略挂断信号真正的守护化还得靠setsid。很多网盘、下载器、更新器就是守进程形式搜“未找到baidunetdiskhost进程”这类问题本质就是守护进程没起来需要检查自启服务和安装目录。记住守护进程崩溃后经常会有另一个守护进程重新拉起它android系统里fragments怎么监听前台进程也是一个道理拉活机制背后一般都有常驻进程配合。2.4 生产环境里那些进程问题顺手盘点几个热搜里反复出现的进程故障都是我实际遇到过的类型。修改进程名是个实用技巧。Linux上prctl(PR_SET_NAME, worker)只能改15个字符再长会被截断。Java应用想改进程名干脆不考虑prctl常见做法是用exec -a newname java ...重命名整个命令行。搜索里“linux修改进程名称大于15个字符”说的就是那个限制这一点踩过的人都知道。CPU占用100%的排查套路是固定的先top -c看哪个进程抢占CPU再top -Hp pid看是哪个线程然后perf top或者jstack抓线程栈最后定位到业务代码还是GC还是死循环。Windows上msmpeng.exe进程过大的问题多半是Defender实时扫描正在全盘扫或者和第三方杀毒软件冲突。处理方案是去“病毒和威胁防护”里排除指定目录别直接kill进程系统会把它拉起来而且可能让防护状态异常。还有一个深刻的教训不要乱删你没搞清楚的进程。有人想释放内存把图形会话或者桌面进程直接kill了结果屏幕上直接黑屏只能重启。搜索里“误删了一个进程任务电脑黑屏怎么办”就是这类事故。动手删进程前先查准PID和父进程多确认一步能省一晚上折腾。3. 线程共享空间里的并发流线程是代码里最常见的并发单元。搜“线程和进程的区别”的人最后都会落到同步互斥、线程池配置、死锁排查这些具体问题上。这一节我会讲清楚线程模型的底层逻辑然后结合高频问题逐个击破。3.1 线程模型再看深一层不同语言实现的线程模型差异很大。Java的Thread我们用pthread_create的时候每个用户线程对应一个内核线程这属于1:1模型用户线程绿色线程和内核线程多对一切换更快但一个线程阻塞全部遭殃M:N模型则是在用户态和内核态之间做多对多映射兼顾调度灵活性和隔离性C的协程库和某些早期线程库爱用这种模型。理解线程模型的现实意义在于不要以为开了1000个线程系统就能同时执行1000个任务。同一时刻能跑的线程数受CPU核数和调度策略限制。多核机器上开太多线程会产生大量无谓的上下文切换CPU全耗在线程调度上业务吞吐反而下降。这也是为什么线程池成了几乎人人必备的基础设施。3.2 线程安全锁、CAS、ThreadLocal线程安全问题的根源是多个线程同时读写共享变量发生了竞态条件。解决思路无非四条加锁、用原子操作、线程隔离、设计上不可变。AtomicInteger线程安全吗这是热搜常客答案是安全且无锁但它的CAS在竞争激烈时可能自旋好几轮。之前我在高并发计数器场景里对比过80线程并发自增AtomicInteger的吞吐明显低于分段锁设计但代码复杂度高得多。业务能忍受千分之一的误差直接LongAdder更香。synchronized和ReentrantLock怎么选JDK层面synchronized经过锁升级后性能已经不差优先用它需要超时等待、公平锁、可中断时再换ReentrantLock。线程互斥体现在哪里临界区同一时刻只允许一个线程进入其他线程只能阻塞等待。这里有一个容易被忽视的点读多写少场景用StampedLock可以实现乐观读避免读锁加锁的损耗。但注意StampedLock不支持重入嵌套调用容易死锁手写时要格外小心。ThreadLocal是解决线程隔离的神器但也有坑。线程池里线程是复用的一个线程的ThreadLocal值会跟着线程活到天荒地老如果不显式remove会产生内存泄漏和脏数据。所以用完必须清理最好在finally里remove。3.3 线程池配置核心参数与阻塞队列的取舍线程池配置是搜索高频中的高频。Java的ThreadPoolExecutor有七个参数核心线程数、最大线程数、空闲存活时间、工作队列、拒绝策略、线程工厂、处理器。给出一个真实可复用的配置模式ThreadPoolExecutor pool new ThreadPoolExecutor( 8, // corePoolSize 16, // maximumPoolSize 60L, TimeUnit.SECONDS, // 空闲线程回收时间 new ArrayBlockingQueue(1000), // 有界队列 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );核心线程数怎么定网上公式很多但我更推荐结合实测。CPU密集型的经验值是“CPU核数1”I/O密集型的经验值是“核数 * 2”。从JVM线程池原理来讲I/O密集任务大多数时间阻塞在等待磁盘和网络真正占CPU的时间很少所以能容纳更多线程。最粗暴也最准的方法是通过压测和监控指标来调整看CPU利用率和队列积压量。“线程池的阻塞队列选择”也经常搜到。LinkedBlockingQueue默认无界队列会无限堆积任务内存迟早爆ArrayBlockingQueue有界队列满时按拒绝策略处理更安全。实际生产我推荐有界队列加CallerRunsPolicy即让提交任务的线程自己执行任务相当于反向限流这是最不容易压垮服务的兜底策略。3.4 子线程操作UI控件一个绕不开的规则搜“易语言子线程怎么让主线程操作ui控件”这类问题本质不是语言问题是GUI框架的线程亲和性约束。所有主流的UI框架——WinForms、WPF、JavaFX、Android的View系统——都有同一个要求UI控件只能在创建它的线程通常是主线程里更新。为什么UI控件的内部数据结构不是线程安全的加上很多控件依赖窗口消息队列而消息排队是按线程划分的。你在子线程里直接textBox.setText(hello)轻则闪退重则内存结构错乱。正确做法是子线程发消息给主线程由主线程执行UI更新。Java里用runOnUiThreadC#用Dispatcher.InvokeC/MFC用PostMessage。核心思路都一样让UI操作回到主线程的队列里执行。3.5 线程死锁产生、定位和预防死锁是并发编程最经典的坑。产生死锁需要四个条件互斥、持有并等待、不可剥夺、循环等待。实际代码里最常见的原因是锁的获取顺序不一致。Java里排查死锁有一套标准流程先jps找到进程ID再jstack pidJVM会直接输出“Found one Java-level deadlock”字样的信息并列出相关线程的锁依赖链。比如线程A持有锁1等待锁2线程B持有锁2等待锁1jstack会把它们的关系打印得清清楚楚。修复手段有三个方向锁顺序全局一致给锁加超时等待或者用更高层的并发工具替代手动锁。很多“线程等待都完成”的问题本质也是线程互相持有资源不放最后谁也没完成。这里推荐用CountDownLatch或CompletableFuture.allOf去等待多线程结果语义清晰还不容易写出死锁。4. 协程用户态的自己调度自己协程这些年从Python到Golang再到Java火得不行。热搜词里“python协程”“虚拟线程原理”“自由线程”都是同一波趋势的不同表现。这一节讲透协程的原理再聊Java虚拟线程和Python自由线程这些新动向。4.1 协程原理状态机加挂起点协程最底层的本质是一个可以被挂起和恢复的函数。传统函数一旦开始就必须执行到底而协程可以在某个“挂起点”保存当前状态让出执行权等后续某个时刻再恢复继续执行。这个机制由状态机驱动保存的是栈或寄存器里的局部状态。Python里最早接触协程是yield生成器。看一个朴素例子def counter(): n 1 while True: value yield n print(received:, value) n 1 c counter() print(next(c)) # 启动到第一个yield返回1 print(c.send(100)) # 传值进去继续跑到下一个yield协程保存的是这个函数的执行位置和局部变量再次调用next时直接回到上次挂起的地方。现代async/await就是在这个基础上做了更漂亮的语法包装。协程是非抢占式的意思是挂起和恢复都得由代码自己显式触发不像线程那样由内核时钟打断调度。好处是切换开销极小坏处是如果协程内部写了阻塞调用整个事件循环都会被卡住。4.2 Python asyncio 实战Python的asyncio批量发起IO请求是协程性价比最高的场景之一。比如并发抓取10个网页import asyncio async def fetch_url(i): print(fstart {i}) await asyncio.sleep(1) print(fdone {i}) return i async def main(): tasks [fetch_url(i) for i in range(10)] results await asyncio.gather(*tasks) print(results) asyncio.run(main())这个脚本全跑完大约1秒如果是串行就要10秒。关键点是asyncio.sleep会把控制权还给事件循环让其他协程继续执行。但你要是改成time.sleep(1)整个程序会卡10秒因为time.sleep不会让出事件循环。这就是协程最大的坑任何同步阻塞调用都会挂起整个事件循环。排查协程卡顿的思路和排查线程池满不同。线程池满看不出来协程卡顿却经常会表现为“整体延迟飙升”。我遇到过的最隐蔽问题是协程里用了某个第三方SDK该SDK内部用了requests同步请求一下就把事件循环堵住了。修复方式是把同步请求丢到线程池执行用asyncio.to_thread包装或者换异步版SDK。4.3 Java虚拟线程、Python自由线程最近的新变化Java 21正式把虚拟线程推向生产使用编程模型还是Thread那套但虚拟线程有本质区别。普通线程是操作系统线程几十万个线程会耗尽内存因为每个线程栈默认就是1MB虚拟线程则由JVM调度栈可以动态伸缩创建成本极低适合“一个请求一个线程”的阻塞式编程模型。用Thread.startVirtualThread或Executors.newVirtualThreadPerTaskExecutor()直接替换普通线程池对有大量IO阻塞的Web服务来说性能提升显著。Python自由线程对应的是3.13以后的no-GIL构建核心目的是让Python多线程真正利用多核CPU而不是像现在这样被全局锁摁住只能跑一个线程。这对此后Python并发生态影响会很大但当前生态里的C扩展很多还没适配生产环境贸然切过去风险不小。顺带提一下GPU编程里的“线程、线程块、网格和warp”。CUDA里的线程模型是另一套体系多个线程组成线程块线程块组成网格硬件以warp为单位调度32个线程。理解这些概念时不要和CPU线程模型混为一谈CPU线程可以随时挂起GPU线程大部分时间是同步执行固定流程调度权完全在硬件手里。4.4 协程多开也要控制并发协程虽然轻但不代表可以无限开。我见过有人一次性创建上万个协程去请求外部接口好家伙直接把对方服务打崩了。事件循环里每个协程都占内存和socket连接数一样会耗尽。正确姿势是引入信号量限制并发数import asyncio sem asyncio.Semaphore(200) async def safe_fetch(i): async with sem: # 这里只允许200个并发 await asyncio.sleep(0.1)线程池里有maximumPoolSize事件循环里同样需要并发上限这是很多人忽略的点。还有一个相关问题是“python线程嵌套线程”线程里再开线程不是不行但每个后台线程都要谨慎管理生命周期和异常否则线程泄漏和异常丢失会让你排查到怀疑人生。5. 实战对比同样任务三种方案差多少光讲理论没说服力我用常见的两类任务来对比进程、线程、协程的实际差异。一台4核8线程的开发机器任务分别是计算密集的质数统计和IO密集的批量HTTP请求。通用经验如下质数统计这类CPU密集型任务多进程方案最稳能直接吃满CPU核线程方案次之因为GIL或锁竞争会拖后腿协程方案如果不做多进程配合只能运行在一个核上速度最差。反向的情况发生在IO密集场景。批量请求100个URL协程方案几乎原地起飞因为绝大多数时间都在等网络响应线程切换和进程切换的成本变成了纯开销。线程池方案次之但不如协程轻量多进程方案最慢还要处理进程间的数据回传。这些结论只代表通用场景具体数字会因机器、语言和任务规模不同变化很大。但选型方向不会变CPU密集面向并行IO密集面向并发。这也解释了为什么“摩尔线程s80安装linux驱动”这类GPU问题会和进程、线程摆在一起出现——计算密集场景把并行性发挥到极致靠的就是硬件线程模型和驱动调度这跟CPU进程线程的抽象又不同但底层逻辑相通。6. 常见故障与排查速查表最后把热搜里高频出现的各类故障整理成一张速查表再挑几个典型问题展开讲。症状可能原因排查方向进程在但窗口没有画面GUI进程与显示服务/GPU驱动异常检查窗口管理器、显卡驱动、远程会话Windows CPU占用率一直100杀毒扫描、系统更新或驱动异常用Process Explorer看谁在消耗CPUmsedgewebview2.exe频繁占用宿主应用利用WebView2渲染界面关闭对应的宿主应用不要手动kill进程网盘、下载器进程找不到守护进程未自启或被杀查看计划任务和服务项调整启动策略MySQL 1067进程意外终止配置文件错误、端口冲突查看mysql错误日志用mysqld --console启动观察IDEA编译进程堆大小调到8000仍OOM编译进程与IDE运行进程堆是分开设置的修改“Build Tools”里的编译JVM堆参数jps增量注解进程已禁用Java增量编译功能被关闭启用来宾注解处理和增量编译选项Windows终端启动失败报conpty错误终端底层伪终端组件损坏更新终端版本重置默认配置文件AtomicInteger在高并发下反而慢CAS自旋竞争激烈换LongAdder或分段锁线程池任务堆积不执行队列和线程数配置不合理监控队列积压量调整核心线程数或换拒绝策略协程里卡住整体变慢协程内出现了阻塞调用用async profiler或日志定位阻塞SDK6.1 进程类故障实录先说“有进程没画面”。这问题最常见于Electron类应用或者远程桌面环境进程列表里有名字窗口却死活不弹。排查重点不是进程本身而是图形栈是不是跑在了只支持命令行会话里是不是GPU驱动崩溃导致渲染失败是不是窗口管理器异常吸收了弹窗。改用裸金属桌面会话跑或者重启窗口管理器基本能解决。Windows上“msedgewebview2.exe进程如何关闭”是常客。WebView2是嵌入式浏览器运行时很多桌面软件拿它做界面。直接右键结束进程下一次宿主软件又会拉起来而且可能造成界面白屏。正确做法是把宿主软件退出如果占用极高去控制面板修复WebView2 Runtime或者把应用迁移到原生GUI。“thunderplatform占用进程”这类第三方常驻进程同理只杀pid没用必须找到源头服务和对应的计划任务再清理否则会反复拉起。6.2 线程与协程类故障实录死锁是最经典的线程故障。经典场景是两个线程各自持有锁又都想拿对方的锁。JVM的jstack会自动识别并打印“Found one Java-level deadlock”。看到这个输出直接锁依赖链按顺序代码逐层改锁顺序问题就解了。另一个低配方案是加锁时使用tryLock加超时避免永久等待。线程池满的问题比死锁隐蔽。监控里发现任务提交线程大量堆积第一反应是调整队列长度或线程数但最该做的是看业务入队是否有瞬时尖峰。有些时候线程池配置完全合理是上游调用方发了大量并发请求把队列打满。这时候CallerRunsPolicy能起到限流作用让调用方线程自己干活相当于反向背压。协程相关的高频问题是“协程里卡住整体变慢”。ASGI服务上一旦有协程内阻塞比如调用了requests或同步数据库ORM整个事件循环的延迟都会涨。从现象上看像是CPU跑满了实际是事件循环被堵住了。排查时打一个线程栈如果能在一堆事件循环线程里看到requests库调用基本实锤。6.3 调试工具清单最终把这些问题的通用工具列一下方便直接抄作业看进程ps -ef、pstree -a、pgrep -af看线程top -Hp pid、jstack pid、pstack pid看协程Python用asyncio.get_event_loop().debug或直接上py-spy dump看调度pidstat -t -p pid 1能看线程级CPU分配perf top能看热点函数看系统负载vmstat 1、iostat -x 1技多不压身但最紧要的还是先把进程、线程、协程的模型建立起来。模型对了排查思路就顺了。7. 我的个人体会与最后建议根据我这些年排查线上并发问题的经验最有价值的建议是选型不要追新先判断瓶颈在哪里。新项目里如果只是普通业务接口默认用线程池已经够稳如果遇到高并发的IO密集型服务比如网关、消息推送、长连接代理那就认真考虑协程或者Java虚拟线程。没必要为了“炫技术”硬迁到协程模型团队维护成本也是成本。另外一个小技巧送给所有被并发问题折磨的朋友动手写代码前先在一张纸上画出当前程序里进程、线程、协程各自的作用范围和交互路径。这个动作花不了5分钟但能帮你避开大半死锁和事件循环阻塞的坑。最后再强调一遍那句话并发解决的是吞吐和响应不是炫技工具搞清楚执行单位的定位比记住再多的API都有用。
返回列表