一个进程可以简单想成:
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
在 Bash:
echo $$
会输出例如:
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
SIGHUP
SIGINT:Ctrl+C
你在终端运行:
./robot_control
然后按:
Ctrl + C
终端通常会给前台进程发送:
SIGINT
INT:
Interrupt
所以:
Ctrl+C
→ SIGINT
它的意思大概是:
用户请求中断程序。
终端发 SIGINT
↓
ROS2 runtime 收到
↓
开始 shutdown
↓
executor 退出
↓
node 析构
↓
进程结束
所以:
Ctrl+C
其实就是 signal 机制在工作。
SIGTERM:请求进程正常终止
执行:
kill 4321
默认发:
SIGTERM
TERM:
Terminate
可以理解:
请你尽快正常结束。
进程可以捕获 SIGTERM,然后:
停止接收新任务
↓
停止控制循环
↓
关闭 socket
↓
断开 EtherCAT
↓
保存日志
↓
释放资源
↓
退出
所以对于机器人服务:
SIGTERM
通常比 SIGKILL 更友好。
SIGKILL:真正的"强杀"
执行:
kill -9 4321
这里:
9
通常就是:
SIGKILL
SIGKILL 非常特殊:
进程不能捕获、不能忽略、不能处理。
Linux 直接终止它。
所以:
kill -9
确实很强,程序没有机会:
释放资源
保存数据
关闭设备
safe disable
写最后日志
join 线程
SIGSEGV:段错误
这个你前面已经见过。
SIGSEGV
通常表示:
非法访问内存。
例如:
int* p = nullptr;
*p = 10;
程序可能:
SIGSEGV
↓
Segmentation fault
↓
core dump
然后你:
gdb
core dump
ASan
排查。
所以现在能串起来:
非法内存访问
↓
内核发现
↓
向进程发送 SIGSEGV
↓
默认动作:终止
↓
可能生成 core dump
SIGABRT
SIGABRT:
Abort
一般意味着:
程序主动或运行库要求"异常终止"。
例如:
std::abort();
会触发:
SIGABRT
另外某些严重运行时错误,也可能最终导致:
SIGABRT
SIGSEGV
更多想到:
非法内存访问
而:
SIGABRT
更多想到:
程序/运行库主动终止
SIGHUP
Hangup
历史上和终端断开有关。
现代 Linux 服务里经常把它用作:
重新加载配置
例如某些 daemon:
kill -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
你应该反应:
SIGSEGV
kill -TERM 有什么区别?
Ctrl+C:
SIGINT
kill PID:
SIGTERM
它们都可以请求退出,但来源和语义稍有不同。
通常:
SIGINT
= 交互用户中断
SIGTERM
= 系统/管理工具请求终止
例如:
systemd stop
通常更接近:
SIGTERM
所以服务程序一定应该认真处理 SIGTERM。
怎么找到某个进程 PID?
最基本:
ps -ef | grep robot_control
也可以:
pgrep robot_control
如果想看详细:
pgrep -a robot_control
例如:
4321 ./robot_control --config robot.yaml
signal 和 core dump 连起来
有些 signal 默认动作会:
终止进程
+
生成 core
例如常见:
SIGSEGV
SIGABRT
所以:
程序崩了
↓
看日志里的 signal
↓
判断大方向
↓
coredumpctl
↓
gdb
这就是一条完整故障分析链。
3、socket
socket = 进程和网络协议栈之间的一个通信端点。
例如:
你的程序
↓
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",pid=4321,fd=7))
这里你一下就看到:
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 descriptor
fd = 进程用来引用某个已打开内核对象的整数编号。
例如:
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 了。
如果一直不 close:
fd 会一直占用
这就叫:
fd leak
文件描述符泄漏
怎么查看一个进程打开了哪些 fd?
Linux 非常好用:
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 4321
lsof:
list open files
它可以列出进程打开的:
普通文件
socket
pipe
设备
共享库
非常适合查:
这个进程到底打开了什么?
fd 是:
进程内部编号。
所以:
fd 3
单独拿出来没有意义。
必须结合:
哪个进程
一起看。
PID + fd 才能真正定位资源
所以你以后经常看到:
PID 4321
fd 7
意思是:
进程 4321 的第 7 号文件描述符。
这就是为什么 ss -lunp 可能显示:
pid=4321,fd=7
现在你应该能完全看懂了。
同一个进程的线程共享 fd 表。
比如:
Thread A
创建 socket fd=7
Thread B 通常也可以:
使用 fd=7
因为:
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、poll
poll = 把一组 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 秒。
情况 2:1 秒都没事件
poll 返回 0
表示:
timeout。
情况 3:出错
poll 返回负数
再看:
errno
epoll
epoll 是 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 和 ET
epoll 里以后很容易听到:
Level Triggered
LT
和:
Edge Triggered
ET
先用大白话理解。
LT:只要还有数据,就一直提醒你
假设 socket 里:
有 1000 bytes
你只读:
100 bytes
还剩:
900 bytes
LT 模式下:
下次 epoll_wait
↓
还会告诉你
这个 fd 可读
因为:
里面还有数据
这比较容易写对。
ET:状态变化时重点提醒一次
ET 更像:
刚才没数据
↓
现在有数据
↓
提醒你
如果你收到事件以后:
只读了一点
剩下的数据可能不会像 LT 那样持续提醒你。
所以 ET 常要求:
non-blocking
+
一直读到 EAGAIN
也就是:
一次事件来了
↓
把当前能读的数据尽量读干净
LT
= 只要条件还成立,就继续提醒
ET
= 更强调状态边沿变化,需要更谨慎处理
实际项目里:
先把 LT 写对,比为了"高级"强上 ET 更重要。
poll / epoll 和 blocking/non-blocking
poll/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
于是:
数据来了
→ 处理数据
停止事件来了
→ 退出线程
这个设计非常漂亮。
timerfd
Linux 还有:
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
= idle
CPU 空闲。
wa
= I/O wait
CPU 在等待某些 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
SHR
VIRT:
Virtual Memory
简单理解:
进程拥有或映射的虚拟地址空间总量。
例如:
VIRT = 8 GB
不代表:
真的用了 8 GB 物理内存
因为里面可能包括:
共享库
mmap
预留地址空间
尚未实际触碰的内存
所以:
不要看到 VIRT 很大就直接判断内存泄漏。
RSS:
Resident Set Size
top 里经常叫:
RES
它更接近:
当前真正驻留在物理 RAM 里的进程内存。
所以如果你排查:
程序是不是实际越来越吃内存
通常:
RSS / RES
比:
VIRT
更有参考价值。
SHR:
Shared Memory
简单理解:
其中有一部分内存是可共享的。
例如:
动态库代码页
共享内存
多个进程可能共同使用。
所以:
RES
也不能简单相加就认为等于整机实际独占内存。
内存统计比 CPU 稍微复杂。
OOM:
Out Of Memory
内存耗尽。
Linux 在极端情况下可能启动:
OOM Killer
选择某些进程杀掉,以保证系统继续运行。
日志里可能看到:
Killed process xxxx
Out of memory
机器人上如果控制程序被 OOM Killer 干掉:
控制进程突然消失
所以生产系统要关注内存边界。
swap
如果 RAM 不够,Linux 可能把部分内存页交换到:
swap
可以简单理解:
RAM 不够
↓
把一部分暂时不用的页面放到磁盘
普通桌面系统这是正常机制。
但实时控制非常不喜欢:
page 被 swap out
↓
实时线程突然访问
↓
需要从磁盘调回来
↓
延迟巨大
这就是后面为什么要学:
mlockall
CPU cache
CPU 比 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
= 更接近实际驻留物理内存