一个进程可以简单想成Process │ ├── PID ├── 虚拟内存空间 ├── 文件描述符 ├── socket ├── 环境变量 ├── 当前工作目录 ├── 信号状态 └── 一个或多个线程所以以后你排查一个机器人节点时其实是在看这个进程 用了多少 CPU 用了多少内存 开了多少 socket 开了多少文件 有多少线程 收到了什么 signal这就是 Linux 系统工程的核心视角。1、进程之间怎么通信既然两个进程内存默认隔离那mocap_driver_node怎么把数据给gmr_retarget_node就需要IPC Inter-Process Communication 进程间通信常见pipe socket shared memory message queue而 ROS2 Topic 本质上也是在帮不同进程之间进行通信只不过它通过 DDS / RMW 做了大量封装。所以进程隔离 ↓ 不能直接随便访问对方内存 ↓ 需要 IPC对比进程线程内存空间相对独立同进程共享创建开销较大较小通信IPC直接共享内存故障隔离较强较弱数据共享麻烦一些容易但要同步崩溃影响通常本进程可能拖垮整个进程举个最直观的例子Thread A 野指针崩了整个Process通常都会崩。不是只死 Thread A。例如你看到CPU 100%先问哪个进程用top ps再问这个进程哪个线程用top -H然后再哪个函数用perf于是完整链路系统异常 ↓ 进程 ↓ 线程 ↓ 函数这就是前面 perf 那课已经碰到的思路。查看自己的 shell PID在 Bashecho $$会输出例如2450意思当前 shell 进程的 PID 2450这个$$以后写 shell 脚本也会见到。查看 PID / PPID可以ps -ef看到UID PID PPID CMD skr 3000 2000 bash skr 3100 3000 ./robot_control说明robot_control PID 3100 父进程 PID 3000也就是 bash。join()为什么重要假设std::thread worker(receiveLoop);main 要退出前worker.join();意思大概就是等 worker 真正结束。之前 TSan 课讲过通知停止 ↓ 线程退出 ↓ join ↓ 释放资源现在你应该理解得更具体了。进程状态ps/top中可能看到R S D T Z现在先认识最常见几个。R Running / Runnable正在跑或者准备被调度。S Sleeping等待事件比如socket数据 condition variable timer非常正常。D Uninterruptible Sleep通常和某些 I/O 等待有关。Z Zombie 僵尸进程后面会讲。什么时候更适合进程什么时候线程先简单记更适合多进程如果你重视故障隔离 模块边界 独立重启 独立部署例如mocap_driver_node gmr_node robot_output_node更适合多线程如果你重视低延迟共享数据 同一模块内并发 避免进程间序列化例如同一个 driver 内 receive thread processing thread现实系统经常多进程 每个进程内部多线程。而不是二选一。2、signal一句话signal Linux 内核或其他进程给某个进程发送的一种异步通知。重点掌握SIGINT SIGTERM SIGKILL SIGSEGV SIGABRT SIGHUPSIGINTCtrlC你在终端运行./robot_control然后按Ctrl C终端通常会给前台进程发送SIGINTINTInterrupt所以CtrlC → SIGINT它的意思大概是用户请求中断程序。终端发 SIGINT ↓ ROS2 runtime 收到 ↓ 开始 shutdown ↓ executor 退出 ↓ node 析构 ↓ 进程结束所以CtrlC其实就是 signal 机制在工作。SIGTERM请求进程正常终止执行kill 4321默认发SIGTERMTERMTerminate可以理解请你尽快正常结束。进程可以捕获 SIGTERM然后停止接收新任务 ↓ 停止控制循环 ↓ 关闭 socket ↓ 断开 EtherCAT ↓ 保存日志 ↓ 释放资源 ↓ 退出所以对于机器人服务SIGTERM通常比 SIGKILL 更友好。SIGKILL真正的“强杀”执行kill -9 4321这里9通常就是SIGKILLSIGKILL 非常特殊进程不能捕获、不能忽略、不能处理。Linux 直接终止它。所以kill -9确实很强程序没有机会释放资源 保存数据 关闭设备 safe disable 写最后日志 join 线程SIGSEGV段错误这个你前面已经见过。SIGSEGV通常表示非法访问内存。例如int* p nullptr; *p 10;程序可能SIGSEGV ↓ Segmentation fault ↓ core dump然后你gdb core dump ASan排查。所以现在能串起来非法内存访问 ↓ 内核发现 ↓ 向进程发送 SIGSEGV ↓ 默认动作终止 ↓ 可能生成 core dumpSIGABRTSIGABRTAbort一般意味着程序主动或运行库要求“异常终止”。例如std::abort();会触发SIGABRT另外某些严重运行时错误也可能最终导致SIGABRTSIGSEGV更多想到非法内存访问而SIGABRT更多想到程序/运行库主动终止SIGHUPHangup历史上和终端断开有关。现代 Linux 服务里经常把它用作重新加载配置例如某些 daemonkill -HUP PID不会完全退出而是重新读取配置文件是不是这样取决于具体程序。所以signal 的名字是一回事程序怎么处理又是另一回事。怎么查看 signal 编号可以kill -l会看到1 SIGHUP 2 SIGINT ... 9 SIGKILL ... 11 SIGSEGV 15 SIGTERM你不需要全部记。重点2 SIGINT 9 SIGKILL 11 SIGSEGV 15 SIGTERM以后看到exit signal 11你应该反应SIGSEGVkill -TERM有什么区别CtrlCSIGINTkill PIDSIGTERM它们都可以请求退出但来源和语义稍有不同。通常SIGINT 交互用户中断 SIGTERM 系统/管理工具请求终止例如systemd stop通常更接近SIGTERM所以服务程序一定应该认真处理 SIGTERM。怎么找到某个进程 PID最基本ps -ef | grep robot_control也可以pgrep robot_control如果想看详细pgrep -a robot_control例如4321 ./robot_control --config robot.yamlsignal 和 core dump 连起来有些 signal 默认动作会终止进程 生成 core例如常见SIGSEGV SIGABRT所以程序崩了 ↓ 看日志里的 signal ↓ 判断大方向 ↓ coredumpctl ↓ gdb这就是一条完整故障分析链。3、socketsocket 进程和网络协议栈之间的一个通信端点。例如你的程序 ↓ socket ↓ Linux 内核网络协议栈 ↓ 网卡 ↓ 网络程序不会直接“控制网卡发 UDP”。它通常是创建 socket ↓ bind / connect ↓ send / recv ↓ 内核帮你处理协议和网卡socket() ↓ 内核创建 socket 对象 ↓ 返回一个 fd ↓ 程序以后通过 fd 操作它例如recv(fd, ...); send(fd, ...); close(fd);这就是 Linux 很经典的Everything is a file-like interface不是说 socket 真的是磁盘文件而是 Linux 用统一的 fd 接口管理很多内核资源。怎么查谁占用了端口以后非常实用。例如ss -lunp看 UDP。或者ss -ltnp看 TCP。例如可能看到udp UNCONN 0 0 0.0.0.0:7014 ... users:((mocap,pid4321,fd7))这里你一下就看到port 7014 PID 4321 fd 7这三个概念全串起来了。socket buffer 是什么Linux 内核会给 socket 维护发送缓冲区 接收缓冲区例如 UDP 包到了以后不是一定立刻进你的用户代码。而是先进入内核 socket receive buffer然后你recvfrom()再把数据从内核拿到用户空间。如果你处理不够快buffer 越积越多。最后满可能开始丢包。EINTR一个阻塞系统调用被 signal 打断了。例如recv() ↓ 正在阻塞 ↓ SIGINT到达 ↓ recv返回错误 ↓ errno EINTR所以signal和socket blocking I/O也会互相影响。这就是为什么 Linux 系统知识必须串着学。Network ↓ NIC ↓ Kernel Network Stack ↓ Socket Receive Buffer ↓ fd ↓ recvfrom() ↓ User Buffer ↓ Queue ↓ Worker这就是机器人系统工程师应该逐渐形成的层次感。socket 内核提供的通信对象socket() → 返回 fd以及blocking 没数据时线程等待 non-blocking 没数据立即返回4、file descriptorfd 进程用来引用某个已打开内核对象的整数编号。例如int fd open(config.txt, O_RDONLY);可能返回fd 3或者int sock socket(AF_INET, SOCK_DGRAM, 0);可能返回sock 4这里3 4都叫file descriptor简称fd假设fd 5这个5本身不是文件也不是 socket。它只是当前进程里的一个“索引号”。可以想象成Process │ ├── fd 0 → stdin ├── fd 1 → stdout ├── fd 2 → stderr ├── fd 3 → config.txt ├── fd 4 → UDP socket └── fd 5 → device所以fd 进程内部访问内核资源的句柄open比如int fd open(/tmp/data.txt, O_RDONLY);大概可以理解应用调用 open() ↓ 进入内核 ↓ 内核找到文件 ↓ 建立打开状态 ↓ 给进程分配一个 fd ↓ 返回整数以后read(fd, ...)Linux 就知道这个 fd 对应哪个打开的对象。用完以后close(fd);意思我这个进程不再需要这个 fd 了。如果一直不 closefd 会一直占用这就叫fd leak 文件描述符泄漏怎么查看一个进程打开了哪些 fdLinux 非常好用ls -l /proc/PID/fd例如ls -l /proc/4321/fd可能看到0 - /dev/pts/0 1 - /dev/pts/0 2 - /dev/pts/0 3 - socket:[123456] 4 - /tmp/robot.log 5 - socket:[123789]这非常直观。如果你怀疑程序 fd 泄漏可以ls /proc/4321/fd | wc -l得到28过 10 分钟120再过 10 分钟300那就非常可疑。如果数目一直增长不下降说明可能有 fd 泄漏。另一个非常常用的工具lsof -p 4321lsoflist open files它可以列出进程打开的普通文件 socket pipe 设备 共享库非常适合查这个进程到底打开了什么fd 是进程内部编号。所以fd 3单独拿出来没有意义。必须结合哪个进程一起看。PID fd 才能真正定位资源所以你以后经常看到PID 4321 fd 7意思是进程 4321 的第 7 号文件描述符。这就是为什么ss -lunp可能显示pid4321,fd7现在你应该能完全看懂了。同一个进程的线程共享 fd 表。比如Thread A 创建 socket fd7Thread B 通常也可以使用 fd7因为fd table属于进程资源。这也意味着Thread A close(fd)而 Thread B 正在recv(fd)可能产生复杂的并发行为。所以 fd 生命周期也要同步。shutdown()和close()TCP socket 经常见shutdown(fd, SHUT_RDWR); close(fd);简单理解shutdown 关闭 socket 通信方向例如不再读 不再写而close 当前进程释放这个 fd两者不是同一个概念。现阶段知道区别即可不需要深入 TCP 状态机。5、pollpoll 把一组 fd 交给内核问“这里面哪些现在可以读、可以写、或者出错了”例如fd 3 → UDP A fd 4 → UDP B fd 5 → TCP fd 6 → pipe你不需要轮流 recv 轮流试 轮流空转而是poll() ↓ 线程睡眠 ↓ 某个 fd 有事件 ↓ 内核唤醒线程 ↓ 你处理对应 fd没有任何 fd 有事 ↓ 线程睡眠只有数据来了 连接断了 超时了才醒。所以poll/epoll本质是在做“事件驱动等待”。POLLIN这个 fd 现在有数据可以读。比如UDP 包到了 ↓ POLLIN或者 TCP对端数据到了 ↓ POLLIN程序看到if (fds[i].revents POLLIN) { recv(...); }就去读。poll timeout例如poll(fds, n, 1000);情况 1某个 fd 很快有事件比如 20 ms 后来了数据20ms ↓ poll 立即返回不会傻等 1 秒。情况 21 秒都没事件poll 返回 0表示timeout。情况 3出错poll 返回负数再看errnoepollepoll 是 Linux 专门为大量 fd 事件管理设计的一套机制。你可以先理解poll 每次把整组 fd 拿去问内核 epoll 先把要关心的 fd 注册给内核 以后只等“哪些 fd 真有事件”这是最重要的区别之一。epoll 常见三步① epoll_create ↓ 创建 epoll 实例 ② epoll_ctl ↓ 把 fd 注册进去 ③ epoll_wait ↓ 等待事件也就是说创建一个“事件管理器” ↓ 告诉它我关心哪些 fd ↓ 以后一直问 谁有事fd 数量少 逻辑简单 → poll 很合适 大量 fd 高并发连接 → epoll 更适合最常见EPOLLIN表示可读还有EPOLLOUT表示可写以及EPOLLERR表示错误EPOLLHUP表示挂断 / 对端关闭相关事件现在先认识即可。LT 和 ETepoll 里以后很容易听到Level Triggered LT和Edge Triggered ET先用大白话理解。LT只要还有数据就一直提醒你假设 socket 里有 1000 bytes你只读100 bytes还剩900 bytesLT 模式下下次 epoll_wait ↓ 还会告诉你 这个 fd 可读因为里面还有数据这比较容易写对。ET状态变化时重点提醒一次ET 更像刚才没数据 ↓ 现在有数据 ↓ 提醒你如果你收到事件以后只读了一点剩下的数据可能不会像 LT 那样持续提醒你。所以 ET 常要求non-blocking 一直读到 EAGAIN也就是一次事件来了 ↓ 把当前能读的数据尽量读干净LT 只要条件还成立就继续提醒ET 更强调状态边沿变化需要更谨慎处理实际项目里先把 LT 写对比为了“高级”强上 ET 更重要。poll / epoll 和 blocking/non-blockingpoll/epoll non-blocking socket为什么因为先用 poll/epoll 等待 ↓ 确认 fd 有事件 ↓ 再 read/recv如果某些边界情况下状态已经变了non-blocking 可以避免线程意外卡死在 read尤其 ET 模式里更常见。eventfd先认识概念。Linux 可以创建一种eventfd它本身也是fd可以用来线程间通知事件例如main thread ↓ 要停止 receiver ↓ 向 eventfd 写一下而 receiver 线程epoll_wait()同时等socket fd eventfd于是数据来了 → 处理数据 停止事件来了 → 退出线程这个设计非常漂亮。timerfdLinux 还有timerfd它也是 fd。你可以创建一个定时器 ↓ 定时到期 ↓ 这个 fd 变成可读于是 epoll 可以同时等socket timer event全部统一成 fd。这就是 Linux fd 抽象很强的地方。只解决怎么高效等多个 I/O 事件。如果收到一帧 ↓ 处理 50ms那你的 event loop 还是被拖住。常见架构epoll thread ↓ 快速收数据 ↓ 投递 queue ↓ worker thread处理所以I/O multiplexing和task parallelism是两件事。I/O multiplexing这就是 poll/epoll 所属的大类I/O multiplexing I/O 多路复用意思不是同时传很多路数据而是一个执行线程同时管理多个 I/O fd 的就绪事件。这个词面试很常见。select 老牌方案有 fd 数量/接口方面限制poll 接口更直接没有 select 那种固定 fd_set 限制epoll Linux 特有更适合大量 fd 高并发事件管理不要现在深挖每个内核复杂度。怎么判断 poll/epoll 问题比如进程CPU 100%strace 发现一直recvfrom(...) -1 EAGAIN recvfrom(...) -1 EAGAIN recvfrom(...) -1 EAGAIN疯狂刷。这通常说明non-blocking socket busy polling可能需要poll/epoll而不是不停重试。6、CPU以后top里可能看到us sy id wa先认识最重要几个。us user用户态代码占用。比如GMR Eigen protobuf 你的 C 逻辑sy system内核态 CPU。比如socket 系统调用 文件 I/O 网络栈 驱动id idleCPU 空闲。wa I/O waitCPU 在等待某些 I/O 完成相关状态。现阶段先有这个概念即可。上下文切换是什么假设 CPU 只有一个核。现在Thread A Thread B Thread C都想运行。CPU 不可能真正同时跑三个。于是跑 A ↓ 保存 A 状态 ↓ 跑 B ↓ 保存 B 状态 ↓ 跑 C这就叫context switch 上下文切换多线程不是免费的。线程太多可能带来调度开销 cache miss 上下文切换所以线程不是越多越快。memory程序内存一个进程的内存可以粗略想成Process Memory │ ├── code ├── stack ├── heap ├── shared libraries ├── mmap └── shared memory你现在不用背内存布局。重点是理解几个常见指标VIRT RES / RSS SHRVIRTVirtual Memory简单理解进程拥有或映射的虚拟地址空间总量。例如VIRT 8 GB不代表真的用了 8 GB 物理内存因为里面可能包括共享库 mmap 预留地址空间 尚未实际触碰的内存所以不要看到 VIRT 很大就直接判断内存泄漏。RSSResident Set Sizetop里经常叫RES它更接近当前真正驻留在物理 RAM 里的进程内存。所以如果你排查程序是不是实际越来越吃内存通常RSS / RES比VIRT更有参考价值。SHRShared Memory简单理解其中有一部分内存是可共享的。例如动态库代码页 共享内存多个进程可能共同使用。所以RES也不能简单相加就认为等于整机实际独占内存。内存统计比 CPU 稍微复杂。OOMOut Of Memory内存耗尽。Linux 在极端情况下可能启动OOM Killer选择某些进程杀掉以保证系统继续运行。日志里可能看到Killed process xxxx Out of memory机器人上如果控制程序被 OOM Killer 干掉控制进程突然消失所以生产系统要关注内存边界。swap如果 RAM 不够Linux 可能把部分内存页交换到swap可以简单理解RAM 不够 ↓ 把一部分暂时不用的页面放到磁盘普通桌面系统这是正常机制。但实时控制非常不喜欢page 被 swap out ↓ 实时线程突然访问 ↓ 需要从磁盘调回来 ↓ 延迟巨大这就是后面为什么要学mlockallCPU cacheCPU 比 RAM 快很多。所以现代 CPU 有L1 L2 L3 cache如果数据刚好在 cache访问快如果总是cache miss就要去更慢的内存层级。所以性能问题不只是“这个函数做了多少次加法”还包括数据布局 内存访问模式 cache locality这就是为什么连续数组通常很高效。怎么查看进程内存很简单。例如ps -o pid,%cpu,%mem,rss,vsz,cmd -p 4321可能看到PID %CPU %MEM RSS VSZ 4321 80.0 2.5 ... ...其中RSS通常单位是 KB。CPU / memory 排障可以形成这个流程程序变慢① 看 CPU ↓ 哪个进程高 ② 看线程 ↓ 哪个线程高 ③ perf ↓ 哪个函数高如果CPU 不高 但机器很卡再看memory swap I/O wait page fault如果内存持续增长再RSS趋势 ↓ ASan/LSan/Valgrind ↓ 代码生命周期CPU 100% ≈ 一个逻辑核满载CPU 100% ≈ 多线程用了多个核VIRT 虚拟地址空间RSS / RES 更接近实际驻留物理内存