跳转至

第 4 章 进程、服务与设备

上位机上同时跑着内核线程、systemd 与其管理的 unit、桌面会话、ROS 2 节点、串口 daemon、图形应用——排"服务起不来"、"进程被杀"、"设备打不开"三类问题,需要一张从进程模型 → 服务生命周期 → 设备与权限的连续视图。本章从 fork / execve 讲到 systemctl、从 /dev 的 uid / gid / mode 讲到 sudoers;systemd-analyze 与 journalctl 作为日常运维两条主线在此立住;udev 规则语法与调试留给第 9 章、cgroups 深层留给第 7 章实时调优,此处只把概念边界画清。

1. 进程模型、信号与调度

1.1 进程、线程与 PID

Linux 内核眼中只有 task:进程与线程是同一种 task_struct,差别在于 clone 时共享哪些资源(地址空间、文件描述符表、信号处理)。PID 是 task 的整数标识,init(Ubuntu 上是 systemd)是 PID 1,负责收尸僵尸与传播信号。

ps -ef                              # 全系统进程,System V 格式
ps aux                              # BSD 格式,含 CPU/MEM 占比
pstree -ap                          # 树状视图,看进程父子链
ls /proc/<pid>/                     # 单个进程的内核视图(fd/、maps/、status、cgroup)
top                                 # 交互式实时视图

僵尸(Zombie, Z)与孤儿(Orphan):僵尸是子进程已 exit 但父进程还没 wait() 收尸,ps state 显 Z——僵尸不占资源(只留退出码在进程表)但会堆积。孤儿是父进程先没了,进程被 PID 1 收养;systemd 的 Restart=always 语义正依赖这一点。判据:僵尸数持续增长说明父进程没在处理 SIGCHLD,kill -TERM <parent> 让父进程退出后 init 自动收尸。

1.2 语言级并发:线程怎么开、共享了什么、代价是什么

「进程、线程与 PID」的内核视角只回答"线程是什么";写代码时的问题是"语言把哪些风险摊到开发者头上"。C++:标准库自 C++11 起自带线程与内存模型(跨编译器行为一致),线程是真 OS 线程(task_struct,即「进程、线程与 PID」的实现层),同地址空间共享一切——代价是未同步的共享写即数据竞争(data race,未定义行为):

##include <atomic>
##include <mutex>
##include <thread>
##include <vector>

long counter = 0;                        // 无保护共享:++counter 是「读-改-写」三步,线程交错即丢更新
std::atomic<long> acounter = 0;          // 原子:单变量计数用这个,无锁
std::mutex mu;                           // 锁:保护跨变量的不变式(多字段一起改)

void bump() {
    for (int i = 0; i < 200000; ++i) {
        ++counter; ++acounter;
        { std::lock_guard<std::mutex> lk(mu); /* 临界区 */ }
    }
}

int main() {
    std::vector<std::thread> ts;
    for (int i = 0; i < 4; ++i) ts.emplace_back(bump);
    for (auto& t : ts) t.join();
}

把这段用 -std=c++17 -O2 -pthread 编成多线程程序跑起来:无保护的 counter 多次运行都稳定少加几个百分点(期望 800000,实际落在 79~79.9 万),而 acounter 恒等于 800000——竞态的表现形式是波动与丢更新,不是每次都错。锁的两个高频坑:死锁(两线程按相反顺序抢同一对锁)与实时回路内加锁(优先级翻转——PREEMPT_RT 用优先级继承缓解,机制在第 7 章;控制线程运行期禁分配与锁策略见 ROS2-note 第 6 章「驱动开发与运动控制」的实时性一节)。

Python:threading.Thread 底层同样是 OS 线程,但 GIL(全局解释器锁,Global Interpreter Lock)——CPython 解释器级的互斥锁,同一时刻只有一个线程执行字节码,阻塞系统调用期间自动释放。因此 CPU 密集任务并行在线程里不会加速(拿不到多核并行),按任务类型选 concurrency 模型(并发模型三选一):

模型 适用 代价
threading I/O 并发(串口、网络、传感器读取——阻塞时 GIL 释放) CPU 密集无效;共享数据仍需 threading.Lock
multiprocessing CPU 密集并行(绕开 GIL,每进程独立解释器) 数据经 pickle 跨进程传递,大数据量时传输成本主导;进程启动开销
asyncio 海量 I/O 连接(单线程事件循环) 阻塞调用会卡住整个循环,需全链路一致

定性结论:拿一个每轮成本恒定的纯计算任务,串行跑一遍记时,再把总量拆成 4 份分给 4 线程与 4 进程各跑一次——4 线程不会比串行快(因 GIL 争用与上下文切换反而略慢),4 进程接近线性提速。选型看总墙钟时间而非单任务耗时;进程侧另含 fork / 启动开销。注意测试负载要选与分块无关的定宽运算,若用会不断变大的大整数(如斐波那契),单任务拆小后总量随之变小,比值会被超线性成本污染而说明不了 GIL。绝对值随硬件变化,比值才是稳定的定性结论。自由线程(free-threading,PEP 703)在 3.13+ 是可选构建的试验特性;Ubuntu 两基线仓库的 Python(3.10 / 3.12)默认构建不含它。

两章之外的接口:ROS 2 的 MultiThreadedExecutor 以回调而非裸线程为并发单位,共享状态(传感器缓冲、map)在 callback group 间并行即竞态——框架侧的互斥与重入判据归 ROS2-note 第 2 章「通信机制与计算图」的 executor 与 callback group 一节,本节是它的语言地基。

1.3 信号

信号是内核给进程的唯一异步通信。日常要认的六个:

信号 编号 语义
SIGHUP 1 终端断开;守护进程约定用它重载配置
SIGINT 2 Ctrl+C,请求中断
SIGQUIT 3 Ctrl+\,中断并产生 core dump
SIGKILL 9 强杀,不可捕获
SIGTERM 15 优雅终止,默认可捕获
SIGSTOP 19 暂停,不可捕获;配 SIGCONT 恢复
kill -TERM 12345          # 默认信号(15)
kill -9 12345             # 最后手段
pkill -f 'ros2 run demo'  # 按命令行匹配

判据:SIGKILL 无法被捕获、无法释放资源,用之前先试 SIGTERM;systemd 单元的 TimeoutStopSec 就是给 SIGTERM→SIGKILL 之间的宽限期。

1.4 nice 与调度策略

Linux 的调度器(EEVDF 于 6.6 起、此前 CFS)默认按 nice 值加权:-20(最高优先)到 19(最低)。nice 只影响 SCHED_OTHER 的相对权重,不改变策略。

nice -n 10 ./batch_job           # 起新进程时降权
renice -n -5 -p 12345            # 改现有进程(需 root)
chrt -p 12345                    # 查看当前调度策略与优先级
chrt -f 50 ./rt_loop             # 用 SCHED_FIFO 优先级 50 起实时任务

三种常用策略:

  • SCHED_OTHER(0):默认,非实时。
  • SCHED_FIFO(1):实时,同优先级按 FIFO 排队、不让出 CPU 除非阻塞或被更高优先级抢占。
  • SCHED_RR(2):实时,同优先级轮转时间片。

实时策略下用户态可让整机失联:SCHED_FIFO 优先级 50 的忙循环不阻塞就永远不释放 CPU,同核上优先级低的包括桌面、部分内核线程都会饿到。第 7 章的 CPU 隔离与优先级配置正是为这个约束兜底。默认 RLIMIT_RTTIME 与 /proc/sys/kernel/sched_rt_runtime_us 提供 safety net——超支时内核强制抢占实时任务。

进程、服务与设备三层视图及纵切关系:

flowchart LR
    task["内核 task_struct<br/>进程 / 线程"]
    subgraph svc["systemd(PID 1)"]
        unit["unit:.service / .timer"]
        dep["依赖与 target<br/>Wants / After"]
    end
    dev["/dev 设备节点<br/>uid / gid / mode"]
    task -->|"systemd 拉起并监管"| svc
    svc -->|"打开设备"| dev
    sudo["sudoers / udev 规则"] -.->|"决定谁能打开"| dev

2. systemd 单元模型

2.1 单元类型与依赖

systemd 用单元(unit)描述"要维持的状态",每类单元一种文件扩展名:

类型 后缀 用途
Service .service 一个或多个进程的启停
Socket .socket 内核 socket 由 systemd 持有,激活时拉起对应服务(socket activation)
Timer .timer cron 的现代替代,依赖持久化更明确
Target .target 分组锚点(如 multi-user.target),替代旧 runlevel
Mount .mount 一个 fstab 条目对应一个 mount 单元
Device .device 由 udev 生成,反映 /sys 中的设备
Slice .slice cgroups 层级分组,为资源限制铺路

依赖关系有六种,日常只关心:

  • Requires= / Wants=:硬依赖 / 软依赖(Wants 失败不拖倒本单元)。
  • After= / Before=:顺序依赖,不含"必须",与 Requires 独立。常见错例:只写 After=network-online.target 不写 Wants=,服务会在网络还没起时就启动。

单元在四种稳态之间迁移,失败态是第五种:

stateDiagram-v2
    state "inactive" as I
    state "activating" as A
    state "active" as AC
    state "deactivating" as D
    state "failed" as F
    [*] --> I
    I --> A: start / 被依赖拉起
    A --> AC: ExecStart 成功
    AC --> D: stop / restart
    D --> I
    AC --> F: 退出码非零
    F --> A: restart

2.2 最小 service 单元

完整单元文件:my-node.service(点击展开)
## /etc/systemd/system/my-node.service
[Unit]
Description=Robot Perception Node
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
User=robot
Group=robot
WorkingDirectory=/home/robot/ws
EnvironmentFile=-/etc/default/my-node
Environment="ROS_DOMAIN_ID=42"
ExecStart=/usr/bin/bash -c 'source /opt/ros/humble/setup.bash && exec ros2 run my_pkg my_node'
Restart=on-failure
RestartSec=2s
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

要点:

  • Type=:simple(默认,进程就是 ExecStart 本身)、exec(等 execve 成功即认为启动)、forking(传统 daemon 双 fork)、oneshot(跑一次就退,配 timer 常用)、notify(配合 sd_notify)。ROS 2 launch 一般 simple 或 exec。
  • ExecStart= 前加 -:ExecStart=-/bin/foo 表示该命令失败不视为单元失败。
  • exec 内建:bash -c '... exec cmd' 让 shell 被 cmd 替换、只留一个 PID,systemd 信号能直达。不加 exec 时 TERM 给 shell、cmd 收不到。
  • WantedBy=multi-user.target + systemctl enable:enable 建立软链到 /etc/systemd/system/multi-user.target.wants/,与 start 是两回事。
  • 服务不 source .bashrc(写成服务就起不来/取不到变量的高频成因):ExecStart 跑在非登录、非交互 shell 里,.bashrc 与 .profile 都不生效。环境变量走 Environment= / EnvironmentFile=(如上面的 ROS_DOMAIN_ID),PATH 精简需写绝对路径或 Environment="PATH=..."。一个易错点:即便在 ExecStart 里显式 source ~/.bashrc 也拿不到变量——Ubuntu 的 .bashrc 顶部有 case $- in *i*) ;; *) return;; esac 守卫,非交互 shell 一进来就 return,根本走不到后面的 export。所以服务的环境只应靠单元里的 Environment=,不依赖用户 rc 文件。

2.3 systemctl 日常命令

sudo systemctl daemon-reload             # 改单元文件后必须 reload
sudo systemctl enable --now my-node      # 开机自启 + 立即启动
systemctl status my-node                 # 摘要 + 最近日志片段
systemctl is-active my-node              # 只回 active/inactive/failed,脚本友好
sudo systemctl restart my-node           # 停再启
sudo systemctl edit --full my-node       # 打开编辑器改单元
journalctl -u my-node -f                 # 追这条单元的实时日志
systemctl list-dependencies my-node      # 依赖树
systemd-analyze verify my-node.service   # 静态检查

排障判据:服务起不来先按双线看——systemctl status 与 journalctl -u <name> -e 拿用户态;dmesg 拿内核态(权限、SELinux、AppArmor、OOM)。第 10 章「排障方法论」把这条链固化。

判据:服务起不来先看两处

systemctl status <unit> 看状态与退出码,journalctl -u <unit> -b 看本次启动的日志;先定位失败发生在哪个阶段,再改配置。

3. 定时器、目标与 journal

3.1 .timer 替代 cron

## /etc/systemd/system/cleanup.service
[Unit]
Description=Weekly cleanup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/cleanup.sh

## /etc/systemd/system/cleanup.timer
[Unit]
Description=Run cleanup weekly
[Timer]
OnCalendar=Mon *-*-* 03:00:00
RandomizedDelaySec=600
Persistent=true            # 错过时刻(比如机器关机期间)下次开机即补跑
[Install]
WantedBy=timers.target

OnCalendar= 语法支持 hourly / daily / weekly / Mon,Fri 09:00 / *:-*-1,15 00:00:00 等。systemd-analyze calendar 'Mon *-*-* 03:00:00' 校验写法。相比 cron 的优势:Persistent 补跑、日志走 journal、失败有 unit 状态可查。

3.2 target 与运行级

multi-user.target ≈ 旧 runlevel 3(无 GUI)、graphical.target ≈ runlevel 5。日常只需知道:

  • systemctl get-default 看当前默认。
  • systemctl isolate multi-user.target 临时切运行级(调试 GUI 卡死有用)。
  • rescue.target / emergency.target 用于修复。

3.3 journal 查询与持久化

journalctl 读 systemd 的 journald 后端。默认是易失的(放 /run/log/journal、tmpfs、重启即空);开启持久化写 /var/log/journal:

sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald

## 常用查询
journalctl -u my-node -b              # 本次启动该单元
journalctl -u my-node --since "1 hour ago" --until "10 min ago"
journalctl -p err -b                   # 本次启动 priority ≥ error
journalctl -k                           # 内核消息(等价 dmesg 但更完整)
journalctl --disk-usage                 # 占了多少
sudo journalctl --vacuum-size=200M      # 缩到 200 MB
sudo journalctl --vacuum-time=7d        # 只留 7 天

配置在 /etc/systemd/journald.conf:SystemMaxUse=、MaxFileSec=、Storage=persistent|auto|volatile|none。判据:vacuum 定期跑或用 logrotate 之外的第二道防线;Storage=auto 在有 /var/log/journal 时持久化、没有则易失(第 10 章维护节再回接)。

4. 设备节点与权限

4.1 /dev 里的三类设备

  • 字符设备:按字节流访问,crw-。串口 /dev/ttyUSB0、看门狗 /dev/watchdog、GPIO 字符设备。
  • 块设备:整块读写,brw-。磁盘 /dev/sda、分区 /dev/sda1。
  • 伪设备:内核虚拟出来的,无对应硬件。/dev/null /dev/zero /dev/random /dev/urandom。
ls -l /dev/ttyUSB0 /dev/sda /dev/null
## crw--w---- 1 root dialout 188, 0 ... /dev/ttyUSB0     ← 字符设备,group dialout
## brw-rw---- 1 root disk     8, 0 ... /dev/sda          ← 块设备,group disk
## crw-rw-rw- 1 root devnull   1, 3 ... /dev/null

读写权限位就是访问控制:普通用户要读 /dev/ttyUSB0,进 dialout 组即可,不需要 root。同理:

设备类型 常见组 用途
USB 串口 dialout 底盘、传感器串口
输入设备 input 键盘、手柄、/dev/input/event*
视频设备 video /dev/video* 相机(第 9 章)
GPIO gpio(若装 libgpiod) 板端 IO(第 9 章)
I²C / SPI i2c / spi(板端) 传感器总线

加组不生效要重登:sudo usermod -aG dialout $USER 只写组数据库,当前 shell 的 supplementary group 列表不会刷新;要么新开 SSH 会话、要么 newgrp dialout。这是"我明明加了组怎么还是权限拒绝"的高频成因。

4.2 udev 概览

/dev 里的节点是 udev(systemd 组件)依据内核 uevent + 规则动态创建的。规则文件在 /etc/udev/rules.d/*.rules(自定义)与 /lib/udev/rules.d/*.rules(发行版默认),数字前缀小者优先。写规则的完整语法与 udevadm test 调试在第 9 章展开;本章只需知道:设备能访问与否是"规则文件 + 组的组合结果",chmod 666 是错误解——每次插拔后权限都会回到规则里的默认值。

4.3 sudo 与 sudoers

sudo 让普通用户以他身份(默认 root)执行命令。配置在 /etc/sudoers(编辑必须用 visudo,语法错会导致所有 sudo 失效):

## 常规:wheel 组全体可 sudo 但要密码
%sudo   ALL=(ALL:ALL) ALL

## 现场排障:允许 robot 用户免密重启某个服务,其他仍需密码
robot   ALL=(root) NOPASSWD: /usr/bin/systemctl restart my-node.service
robot   ALL=(root) NOPASSWD: /usr/bin/journalctl -u my-node*

权限最小化判据:无人值守上位机不希望任何操作都要密码,但绝不给 NOPASSWD: ALL——那等于 root。写成具体命令白名单 + 具体参数模式,配合第 10 章 SSH 密钥登录做双闸门。

sudo -l 查自己能用哪些 sudo;sudo --non-interactive cmd 在无 tty 场景(脚本、systemd ExecStart 前置检查)常用。

坑:串口权限靠组,不靠 chmod 777

设备节点的权限是 root:dialout 660 这类约定,把自己加进 dialout 组再重新登录即可;chmod 777 既不持久也绕过了审计。

5. cgroups v2 资源限制概览

Ubuntu 22.04 起默认 cgroup v2(unified)、到 24.04(systemd 255)已完成全量切换;systemd 从 249 到 255 单元语法保持兼容,但 user@.service 默认选项与 journal 上限实现细节有变化,双基线写结论时一并注明。cgroups 是"给进程分组并限制 CPU / 内存 / IO"的机制,systemd 的 Slice=、Docker、Kubernetes 都建在这上面。

systemd-cgls                         # 树状看层级
systemd-cgtop                        # top 视图
cat /sys/fs/cgroup/system.slice/memory.current

在单元文件里直接限:

[Service]
CPUQuota=200%         # 相当于最多吃两个核
MemoryHigh=1G         # 超过时开始回收
MemoryMax=2G          # 硬上限,触发 OOM
IOWeight=50           # 相对权重(1–100)

判据:给 ROS 2 节点服务加 CPUQuota 与 MemoryMax 能防止单节点跑飞吃光整机;实时控制回路所在的核心不能同时被 cgroups 限到,否则第 7 章的隔离会失效。资源限制的深层机制、实时任务的豁免规则在第 7 章展开。

6. 要点

  • 进程 = task,PID 1 是 systemd;僵尸不占资源但堆积要收尸,SCHED_FIFO 忙循环能饿死同核的一切;chrt 与 RLIMIT_RTTIME 是实时策略的入门与兜底。
  • 语言级并发按任务类型选模型:C++ std::thread 是真 OS 线程、共享即需 mutex / atomic(未同步的共享写是数据竞争);Python 的 GIL 让线程只适合 I/O 并发,CPU 并行用 multiprocessing;实时回路内加锁需考虑优先级翻转。
  • systemd 单元 6 类(Service / Socket / Timer / Target / Mount / Device),依赖分 Requires/Wants 与 After/Before 两维;改完单元 daemon-reload,enable 是开机自启、start 是立即起。
  • .timer 用 OnCalendar= 与 Persistent=true 替代 cron,日志走 journal、失败有 unit 状态可查。
  • journal 默认易失;开持久化建 /var/log/journal,--vacuum-size / --vacuum-time 定期收缩。
  • /dev 权限看 uid / gid / mode:串口进 dialout、输入进 input、视频进 video;加组要重登才生效,chmod 666 每次插拔会失效;udev 规则语法在第 9 章、sudo 白名单最小化在第 10 章现场运维。
  • cgroups v2 由 systemd 挂载与调度:单元里 CPUQuota= / MemoryMax= 是最小成本防跑飞;实时任务与资源限制的冲突在第 7 章解决。

参考文献与延伸阅读

  • systemd. 手册与文档(unit、cgroup 属性). 文档
  • Linux Kernel. Control Group v2 文档. 文档
  • Linux Kernel. 管理指南. 文档