关于进程和线程通信方式这道题,我最早是当八股来背的。管道、消息队列、共享内存、信号、信号量、Socket,背得滚瓜烂熟,但到了实际开发里照样翻车——写采集程序时用管道给子进程发数据,数据量一大整个父进程就卡死;后来换成共享内存加信号量,又踩进 IPC 对象残留的坑。花了好几个通宵才明白,通信方式从来不是一个名词清单,而是并发程序里最难做的一组权衡。
这篇文章想用工程视角把这些方式重新梳理一遍:先弄清楚进程和线程的差异为什么决定了通信路线,再把进程间 IPC 和线程间同步通信分别拆开讲清楚,最后用一次真实的系统改造复盘不同方案的代价。不管你是正在准备面试、还是项目里真正遇到了多进程/多线程数据交换问题,看完应该都能拿得出自己的判断,而不是继续对着概念死记硬背。
1. 先替进程和线程把这层关系讲清楚:为什么通信方式完全不一样
记住一句话就够了:进程是资源隔离的边界,线程是共享资源的执行流。通信方式的分水岭,就是有没有这条隔离边界。
1.1 独立地址空间和共享内存模型带来的本质不同
进程之间默认看不到对方的任何变量。每个进程都有独立的虚拟地址空间、页表和文件描述符表,即使两个进程执行的是同一份代码,你在进程 A 里写一个全局变量,进程 B 也读不到。所以跨进程通信必须由操作系统当“中间人”,把数据从一个地址空间搬到另一个地址空间,或者由内核协助把同一块物理内存映射到多个进程。
线程不一样。一个进程内的所有线程共享代码段、数据段、堆和大多数文件描述符,唯一有较大差异的是栈和寄存器状态。这意味着两个线程天生就能看见同一个地址里的内容。你在线程 A 中往全局数组写入一行数据,线程 B 理论上随时可以读它。
所以线程之间真正麻烦的不是“传数据过去”,而是“数据什么时候算写好了”“你现在读到的是不是旧值”“两个线程同时改怎么办”。
1.2 调度和切换成本差异,直接影响你的通信设计
进程切换需要切换页表,也要处理 TLB 失效,这个成本天然比线程切换高。线程切换只是在同一个进程空间里换一套寄存器和栈,不需要切换地址空间。
这个差异对通信设计的影响很实际:如果 A、B 两个执行单元需要高频、低频小数据交换,你却发现它们被设计成了独立进程,每次交换都要经历用户态到内核态再到用户态,那基本是在拿大炮打蚊子。反之,如果两个模块强依赖不同第三方库、一个崩溃不希望连累另一个,那即使通信成本高,你也应该用进程隔离,保证故障边界。
1.3 面试官真正想看你回答的那个点:通信和同步的分界
很多人聊线程通信时会说“用 Queue”,但这正好踩了概念混淆的坑。Queue 本身不是通信通道,它只是一个被多个线程共享的内存对象;真正管用的是 Queue 内部的锁和条件变量,用来保证“你放的时候我不能取”“队列空了我就睡,别空转”。
因此更准确的说法是:进程间通信解决的是数据如何跨隔离边界传输,线程间通信的重点同时落在共享数据的可见性、临界区保护,以及事件通知上。跨进程也需要同步,比如信号量和文件锁,但在纯跨进程场景下,你得先解决介质问题,再来谈同步顺序。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 进程间通信(IPC)的全路线盘点:管道、共享内存、消息队列,各有各的性格
操作系统教科书上列出来的 TCP/IP 只是其中一种路线。几十年来最常用的 IPC 手段各有明确适用场景,也有明显的短命板。
2.1 管道:写起来最顺手,也最容易低估背压问题
管道本质上是内核里的一个环形缓冲区,数据从写端写入,从读端读出后就不再保留,天然模拟了“水流”的效果。匿名管道 pipe() 创建后会返回一对文件描述符,分别代表读端和写端;在 fork 之后,父子进程各自关闭自己不需要的一端,就能形成单向数据流。
管道最常见的误解是它的容量。Linux 上早期默认是 64KB 左右,虽然可以通过 fcntl(fd, F_SETPIPE_SZ, size) 修改,但它并不是一个越改越大的橡皮筋。如果数据生产速度长期大于消费速度,写端最终会阻塞在 write 调用上,整个调用方的执行流都会停下来。我经历的一次生产事故就源于此:某个采集服务源源不断地把数据塞给一个处理比较慢的子进程,处理方一忙,管道写满,父进程阻塞在 write 上,后续新请求全部卡住。
另一个容易被忽视的是原子性限制。POSIX 规定写入不超过 PIPE_BUF 的单个数据块是原子的,超过这个值,多个写者同时写管道时字节流可能会交错。日志、结构化指令这类讲究“单条完整”的数据,如果多个生产者同时写一个管道,就必须自己加锁或分包。
命名管道(FIFO)解决了“无亲缘关系进程也能通信”的问题,用 mkfifo 建一个文件路径,任何进程都能打开。但它的 open 行为有个常见的坑:只以只读方式打开 FIFO 会阻塞,直到另一端出现写者。如果不注意加 O_NONBLOCK,程序很容易诡异地在 open 阶段卡住,却完全看不出来是在等管道另一端。
2.2 消息队列:有边界、有类型,但生命周期总让人头疼
System V 消息队列和 POSIX 消息队列提供的是另一种模型:消息是 struct 一样带类型的数据块,不再是无边界的字节流。msgget 创建队列,msgsnd 发送,msgrcv 可以按 msgtyp 选择接收特定类型的消息。这比管道灵活,因为它天然支持按消息边界读取,还能做到最简单的一对多分发,而不需要传输双方自己解析帧格式。
但它有个绕不过去的麻烦:内核对象不随进程退出而自动消失。如果代码里没显式调用 msgctl(..., IPC_RMID, ...) 清理,重启服务后 ipcs -q 会看到一堆残留队列。旧队列占用的 key 还在,新进程用同一个 key 创建队列时拿到的可能是老对象,里面还留着上次没消费完的老数据。生产中我见过不止一次因为残留消息,新服务启动后莫名其妙消费到半年前的历史数据。
所以,如果只是本地少量进程、低频传输结构化小消息,消息队列还能用;一旦系统做大,消息格式演进和队列清理都很难受。后来大家更愿意把这类需求直接交给 Redis Stream、RabbitMQ、Kafka 这类消息中间件,本质还是用的生产者消费者模型,只是把队列从内核态搬到了业务侧,可观测性和持久化能力都强得多。你去面试时如果能把“消息中间件是消息队列在分布式时代的延伸”讲清楚,比干巴巴背分类要加分很多。
2.3 共享内存:延迟最低,但你必须和同步问题硬碰硬
共享内存是另一种思路:既然跨进程搬运数据贵,那就别搬了,让两块虚拟内存映射到同一块物理内存。通过 shmget 或 mmap 把一段内存映射到多个进程的用户态之后,任何进程的普通读写都会直接落在这块共享物理页上,不需要 read/write 系统调用、不需要内核拷贝,延迟自然是最低的。
代价是共享内存本身不提供任何同步机制。这件事经常被刚接触的人忽略:你以为 shmget + shmat 完事就能读写了,实际上两个进程同时写临界区,或者一个进程正在写、另一个已经读了半个结构,数据立刻花掉。你必须在共享内存之上自己架信号量、POSIX 互斥锁、或实现无锁环形队列。
共享内存还有一个隐蔽的生命周期问题。进程异常退出时,如果你挂在共享内存上的同步锁不是健壮的 robust 锁,锁的拥有者不在了,其他进程可能永远等下去。而且 shared memory 的 key 是全局可见的,多个不同服务如果不小心用了同一个 key,会出现严重的数据污染。我在实验环境里就曾经用 ftok 生成 key 时路径写错,结果两个项目共享了同一段内存,调试了半天才意识到自己读的是别家进程的数据。
如果要追求跨进程低延迟,同时不打算做太复杂的锁恢复和生命周期管理,我建议尽量用无锁环形队列配合细粒度的原子变量,并且让队列通过文件映射到专用路径,把这些资源都集中在一个目录下,方便统一清理。
2.4 信号和信号量:概念只差一个字,用途完全是两码事
信号是进程之间异步事件的“踹一脚”。比如在命令行用 kill -USR1 12345 通知一个守护进程重新加载配置;Nginx、Redis 这些服务收到 SIGHUP、SIGTERM 后重新读配置或优雅退出,本质就是在用信号通信。信号的定位是轻量通知,不适合传业务数据,而且异步信号处理函数里你能调用的东西非常有限:printf、malloc、pthread_mutex_lock 这类“非异步信号安全”函数很可能直接造成死锁或堆损坏。
信号量则是一种计数器同步原语。System V 信号量可以用来实现多进程对临界资源访问的互斥和限流:生产者做 P 操作减计数,拿不到就阻塞;消费者做完 V 操作增计数,唤醒等待者。但这里有个容易掉进去的坑:信号量的生命周期同样独立于进程,如果一个进程持有信号量时崩溃,计数不会自动恢复,程序重启后要小心地检查并重置初值,否则会出现“明明没有进程占用资源,信号量却永远不足”的诡异状态。
简单总结一下:信号用于通知“有事件发生”,信号量用于控制“同时有多少实体能访问某个资源”,这两个概念别混着用。
2.5 Socket 和 socketpair:真正具备跨机器扩展能力的方案
Socket 是适用范围最广的进程通信方式,因为它既能跑在 localhost 上,也能跑在不同机器之间,接口还一模一样。本地 IPC 可以优先考虑 Unix domain socket,它不走物理网卡,没有 TCP/IP 协议栈的组包、校验、分包开销,在同一台机器上延迟很低,文件系统权限也能作为访问控制的一部分。
对于父子进程之间需要双向通信的场景,socketpair(AF_UNIX, SOCK_STREAM, 0, fd) 是一个非常顺手的工具。它返回两个连在一起的套接字描述符,父子进程各持一端,可以全双工地读写,比管道单独建两个方向的流程简洁不少。
Socket 模式下你还要处理流式数据的“粘包”和“拆包”问题:一次 send 的内容在接收端可能需要分多次 read 才能读完,也可能一次 read 读到多条消息。常见做法是约定一个简单帧头,比如前面 4 字节存消息长度,后面跟着正文。这个设计虽小,却是工程上最容易出错的地方。
2.6 选型对照:没有最好的 IPC,只有风险最可控的 IPC
| 通信方式 | 实时性 | 数据量 | 复杂度 | 生命周期管理 | 典型用途 |
|---|---|---|---|---|---|
| 匿名管道/FIFO | 中 | 小/中 | 低 | 文件描述符自动释放 | 父子进程流式传输、Shell 串联 |
| System V 消息队列 | 中 | 小 | 中 | 需要显式清理 | 低频结构化小消息 |
| 共享内存 + 信号量 | 高 | 大 | 高 | 需要进程异常兜底 | 高频大块数据、图像帧共享 |
| 信号 | 较高 | 几乎不传 | 低 | 不需要 | 通知重载、退出 |
| Socket | 中/高 | 中/大 | 中 | 连接断开易发现 | 本地/跨机器通用 |
| 文件/数据库 | 低 | 任意 | 低 | 持久 | 配置同步、异步任务表 |
选型时我的习惯是先问三个问题:数据量多大?延迟容忍度是多少?通信双方崩溃后系统能不能自愈?如果延迟敏感数据量大,共享内存值得冒险;如果业务逻辑分散在多个服务,Socket 或消息中间件反而省心。刻意追求 IPC 拉满性能而不考虑生命周期,后续运维会很难受。
3. 线程通信真正的战场在同步:锁、条件变量、队列与阻塞选型
线程之间通信问题通常没有“跨进程搬数据”的负担,地址空间本来就是共享的。你把一个任务对象 push 进一个全局 deque,另一个线程去 pop,本质上就是在通信。因此线程通信的核心矛盾永远是同步。
3.1 互斥锁只能保护临界区,不能搬运事件
锁解决的是“同一时刻只有一个人改共享数据”的互斥问题。它不解决“你等的数据好了没有”“我怎么知道数据好了”这类通知问题。就算某个线程往队列里塞了一条消息,然后立刻释放锁,另一个线程如果不去主动查看队列,它永远不知道这条消息已经来了。
条件变量就是为此设计的。它允许一个线程在某个条件不满足时主动睡眠,并原子地释放互斥锁;另一个线程修改共享状态后发出 notify,唤醒等待者重新获取锁并检查条件。经典的 C++ 生产者消费者代码大概是这样的:
cpp复制std::mutex mtx;
std::queue<Job> jobs;
std::condition_variable cv;
void producer(Job job) {
std::lock_guard<std::mutex> lock(mtx);
jobs.push(std::move(job));
cv.notify_one();
}
Job consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return !jobs.empty(); });
Job job = std::move(jobs.front());
jobs.pop();
return job;
}
这里 cv.wait(lock, predicate) 第二个参数很关键。它把条件判断包进去了,wait 被唤醒后会再检查一次条件;如果是假唤醒,或者竞争者把队列里的任务抢走了,线程会继续睡,不会误取空队列。有人习惯用 if (!jobs.empty()) 来包,这是典型的并发隐患,必须用 while 或谓词重查。
3.2 把条件变量封装成阻塞队列:最通用的多线程信息通道
实际工程里,我很少让业务代码直接面向裸的条件变量,因为很容易漏唤醒、死锁、或写错 predicate。更稳妥的做法是把锁和条件变量封装成一个 BlockingQueue<T>,对外只暴露 Push 和 Pop,内部把条件判断、唤醒、互斥全部收干净。
Java 世界这个模式已经做成了标准库的一部分,BlockingQueue 接口下面一堆实现直接用就行。Python 的 queue.Queue 同样简单。C++ 没有标准线程安全队列,但可以抄一个 50 行的小类,或者引入 moodycamel::ConcurrentQueue 这类经过充分测试的无锁队列。Go 更彻底一些,channel 直接把这种“队列 + 同步 + 消息语义”做成了语言级内建类型。
无论哪个语言,我都建议业务代码不要暴露底层锁对象,而是把一个专门的阻塞队列当作线程间消息总线。这样通信双方只依赖队列的接口,底层无论是条件变量、无锁 CAS 还是别的机制,都可以在队列内部调整,而不改动上下游。
3.3 线程池的阻塞队列怎么选:这不是调度配置,是通信容量设计
这类热搜词里“线程池的阻塞队列选择”其实问的就是生产者和消费者模型。往线程池提交任务的生产者线程,和从队列里取任务执行的 worker 线程,本质是通过一个阻塞队列通信。这个队列容量决定了整个系统能吸收多大的瞬时流量冲击。
ArrayBlockingQueue:有界、基于数组,容量固定。任务堆积超过容量后,会触发拒绝策略。适合希望系统对突发流量有明确上限,宁可拒绝也不积压成山的场景。LinkedBlockingQueue:默认无界链表。无界队列看起来永远不会拒绝任务,但一旦生产速度持续高于消费速度,任务对象堆积可能导致内存膨胀,最终把服务压垮。Java 的newFixedThreadPool默认就用的它,很多人踩过这个坑。SynchronousQueue:不缓存任务,每个put都必须等一个take,相当于把任务直接交给 worker。这要求 worker 必须有空闲或能快速新建,适合严格背压、不想让任务积压的场景。DelayQueue:元素有延迟时间,任务到了指定时间才能被取出,适合定时任务、连接池过期回收。
选队列前一定要算一下任务的到达速率、峰值速率和处理耗时,然后给队列设一个有界容量。比如平均每秒提交 500 个任务,每个任务处理 50ms,单 worker 每秒只能消费 20 个,那至少需要 25 个并发 worker 才能让队列不增长;如果任务会突发到每秒 5000 个,队列容量按允许积压 1 秒来算,就设为 5000,再设拒绝策略把多余请求降级或落库。
3.4 线程队列之外,还有几类不算通信、但经常被误当通信的机制
ThreadLocal 和编程语言里的 thread_local 表面上是给每个线程一份独立副本,好像是在“隔离”。它不是通信,但经常被误用成隐式通信。比如有人把一个用户上下文塞进 ThreadLocal,然后异步线程池里的任务却拿不到,排查半天发现数据“没传过去”。其实你需要的不是线程通信,而是把上下文显式作为一个对象参数传给任务。
另外一类是 future/promise 模型。线程 A 往线程 B 提交一个异步任务,B 完成后把结果写进一个 future,A 在需要时 get 阻塞等待。这看起来像一次“通信”,本质上是共享一个结果对象并用同步原语保证一次性写入、一次性读取。它跟消息队列的差异在于,future 是“一对一、一次结果”,消息队列是“任意多个、流式数据”,业务代码里别选错。
4. 一次采集系统改造:从多进程管道到共享内存,再到线程队列的真实复盘
讲再多理论不如还原一个踩坑过程。有一年我负责一套工业数据采集服务,要同时从几类传感器读取原始数据,做完基础校验后交给算法模块,然后再落库或上报。最初考虑稳定性和故障隔离,把每个采集通道单独做成了独立子进程。
4.1 第一版:多进程加匿名管道,稳定但喂不饱高速采集
最开始的方案是父进程统一接收所有硬件数据,通过子进程数量做负载均衡,把每个数据分片通过匿名管道发给处理子进程。运行头几天很顺利,因为数据流量低。等接入高速传感器后,问题爆发了:数据处理模块偶尔产生 50ms 的卡顿,管道写端就迅速积压,父进程在 write 调用上被阻塞,新的硬件中断数据无法及时读取,采集线程因此丢数据。
一开始我以为是管道容量不够,试过调大 F_SETPIPE_SZ,顶多是把故障时间往后推迟了几秒,并没有解决本质矛盾。消费端处理慢,无论管道多大,最终都会填满。这个问题不是换个更大管道能治的。
4.2 第二版:共享内存加信号量,吞吐上去了,但崩溃恢复成了灾难
既然消费端慢,那就把“把数据塞给消费者”改成“把数据放在一个消费者主动来取的地方”。我设计了一段共享内存作为环形缓冲区,每个槽位用命名信号量表示是否空闲、是否有新数据。生产者往槽位写数据,消费者从槽位取数据,理论上一旦消费者追上来,系统就不会因为写阻塞而丢采集数据。
实测下来吞吐提升非常明显,复制和系统调用少了一个数量级。但稳定性测试时只要强行 kill -9 某个消费者进程,立刻出问题:消费者持有的信号量没有释放,槽位永远被标记成占用,生产者只能一直等。即使后来加了进程退出清理逻辑,SIGKILL 和机器掉电你是没法来得及处理的,共享内存里的数据也变成了不可恢复的半成品。
这次踩坑让我意识到,共享内存并不适合那种进程随时可能被外部杀掉的业务。它适合高可靠专职进程,比如操作系统层面的数据库缓冲池、消息中间件的页缓存,那种场景进程生命周期有严格的监督和恢复机制。我的采集系统并不是那么需要极限性能。
4.3 第三版:放弃过度进程隔离,单进程内多线程加阻塞队列
后来我重新审视了业务:那几个“算法模块”并不需要很强的故障隔离,真正需要隔离的是硬件设备驱动的部分。于是我把算法和处理模块合并进主进程,启动多个 worker 线程,每个 worker 从一个全局有界队列里取任务;主线程只负责从硬件读取数据并 push 到队列。队列作为一个显式的后台指标,每分钟记录一次积压长度和处理耗时。
改动之后,故障点明显减少了。首先是没了一堆管道和共享内存对象要清理,服务重启后不会留下任何内核级残留,进程退出即自动回收;其次是任务积压成为可以监测的东西,我可以从积压长度判断当前是不是要扩容 worker 或降低硬件采集频率。虽然单进程的隔离性比多个子进程弱,但对这套系统来说,最核心的诉求是持续高速采集,不是各自崩溃后互不干扰。
如果将来某个模块真的需要强隔离,我会把隔离层放在网络边界上:那部分作为独立部署的服务,通过 Socket 或消息中间件与主进程交互,而不是继续用成本极高的进程内共享内存 + 信号量去硬抗。
4.4 复盘总结:通信方式选型不是越底层越快越好
这个系统最终跑得稳,靠的并不是“更高级”的通信方式,而是把通信边界放到了最合理的位置。同进程内高频协作,就优先线程队列;无法接受同生共死的模块,才拆成进程并用 Socket 通信;跨机器分布式的部分,才引入消息系统。
我后来很少主动推荐在业务系统里使用 System V 共享内存。跨进程共享内存的同步和生命周期管理复杂度,一定会从开发期一路拖到运维期。除非你清楚知道自己在做超低延迟基础设施,否则它带来的收益大概率抵消不了维护成本。
5. 面试和排障中被问烂的通信问题:把这几类彻底说透
5.1 面试高频题:进程间通信方式有哪些?以及答完之后的“临门一脚”
这道题本身不难,大多数人都能列出管道、消息队列、共享内存、信号、信号量、Socket。真正的分数差距在后面那句“你来说说哪种最快,为什么”。如果我面试,我会希望听到这样的拆解逻辑:
共享内存最快,因为它省去了用户态和内核态之间反复 copy,读写操作直接落在物理内存。但它没有同步,你还得承担信号量、锁、崩溃恢复的复杂度。管道和消息队列都有系统调用和内核拷贝成本,但买的是结构化、可靠性、简单生命周期。Socket 虽然在这些机制里可能不是最低延迟的,但它最大的价值是接口跨机器统一,从本地 IPC 平滑迁移到网络 RPC 时服务端代码几乎不用重写。
5.2 面试高频题:线程之间几种通信方式,注意别答成进程那套
线程通信经常有人回答“共享变量、锁、消息队列”,这只能算一半。我更倾向于把线程通信按协作模式分为三类:
- 并发修改同一份共享数据,用锁、原子变量保护读写,保证互斥和可见性。
- 跨线程传递数据流,用一个线程安全队列或 Channel,生产者只 push,消费者只管 pop。
- 跨线程通知“某个任务完成了”,用条件变量、future / CompletableFuture / 回调机制来传递结果状态,而不是自己写一堆轮询判断。
实际代码里很少只用其中一种。比如一个线程池执行任务,提交方把任务放进队列,worker 执行完把 Future 结果置为完成状态,提交方阻塞在 Future.get 上等待。这就是典型的“队列 + 状态同步”混用,不会有人只用一把锁从头传到尾。
5.3 死锁定位:通信加锁顺序不一致导致的经典问题
多线程通信最容易出的隐蔽问题就是死锁。我遇到过最典型的一种是:两个服务通过线程池互相发送请求并等待对方响应。服务 A 持有资源锁 X,等待服务 B 的响应;服务 B 因处理线程池全部阻塞,等待持有队列消费者,最后谁都拿不到自己需要的资源,整条链路上的线程全部 hang 住。
定位时不要猜。Java 服务直接 jstack <pid>,出现 deadlock 时会在线程栈后面显示 Found one Java-level deadlock,并列出循环等待的关系。C++ 服务可以用 gdb attach <pid> 后执行 thread apply all bt 查看栈,Linux 下没有 gdb 时可以先 pstack <pid> 拿一份当前线程快照。
死锁已经发生时,光靠日志很难还原完整等待链,一定要靠线程栈。预防层面最有效的两招:严格固定全局锁顺序,所有线程都按同一个顺序加锁;给加锁操作设计超时,拿不到锁就回滚释放已有资源,而不是无限等下去。
5.4 通信阻塞不一定代表通信方式错了,先测量再优化
如果是线上出现任务积压,不要第一时间怀疑通信方式不够快。很多时候阻塞队列里的积压长度上升,反映的是消费端本身性能下滑或者生产速率突增,比如某个 CPU 密集任务把 worker 拖住了。你应该关注两个指标:任务在队列里平均等待时间、worker 实际处理耗时。如果处理耗时波动不大,等待时间却一直飙升,说明 worker 数量不足或任务已经堆积到接近有界队列上限;如果处理耗时本身就很大,那再换队列、再优化 IPC 也没有意义。
大规模并发共享一个全局队列时,锁竞争也可能成为瓶颈。一个简单办法是分片队列:把队列拆成 N 份,每个 worker 固定优先消费自己分片的队列,拿不到时再随机扫其他分片。这种思想在 Java 的 ConcurrentHashMap 分段锁和 Disruptor 的 RingBuffer 设计里都能看到。但分片也意味着消息不再保证严格的 FIFO 顺序,如果业务要求每条数据按原顺序处理,就要回到单队列模型并接受单点吞吐上限。
最后分享一个我调试通信模块时特别常用的习惯:改代码之前先看系统调用。用 strace -f -e trace=read,write,connect,poll 跟着进程跑一小段,基本立刻能看出它是不是阻塞在等待写管道、等待信号量,还是在 Socket 上等读事件。数据看不到就用 ipcs -p 对照 PID 查 IPC 对象,线程栈看不到就用 jstack/pstack 抓快照。观察工具用熟了,多线程和多进程的通信问题并不会像想象中那么难定位。
