在 GitHub Actions Runner 代码库里,有一个看似简单的 Bash 脚本------safe_sleep.sh,它负责让 Runner 在某些场景下"安全地睡眠"一段时间。但这个小脚本却因为一个 subtle 的逻辑缺陷,让许多开发者和 CI 系统管理员困扰不已,甚至引发了性能、资源浪费、Runner 卡死等严重问题。
一、什么是 safe_sleep.sh
GitHub Actions Runner 是负责执行 CI/CD 工作流程的后台服务。某些流程中,Runner 需要暂停一段时间(例如等待自动更新完成),这时就会调用一个名为 safe_sleep.sh 的脚本。
看似是个简单任务:让程序睡眠 N 秒钟 。但设计者并没有调用系统自带的 sleep 命令,而是用 Bash 语言编写了一个自循环脚本。
在旧版本中,脚本内容大致如下:
bash
#!/bin/bash
SECONDS=0
while [[ $SECONDS != $1 ]]; do
:
done
通过 busy-waiting 来实现睡眠,而不是调用标准 sleep。
二、问题核心:循环条件有 Bug
乍看这段代码好像没毛病,但它有一个细节 严重依赖了调度时机:
bash
while [[ $SECONDS != $1 ]]; do ...
这个循环假设 SECONDS 会按 0 → 1 → 2 → ... 逐秒增加并且正好等于目标值。但在真实环境中:
-
如果机器负载高;
-
或者 Runner 进程被系统调度延迟;
-
或者在虚拟化环境里暂停一段时间;
那么 SECONDS 有可能会直接跳过目标值,例如从 0 跳到 2。在这种情况下:
条件永远不会变成 "等于目标值",循环就会永远执行下去 ------ 无限循环。
三、实际观测到的问题
Very rarely on update of github actions runner safe_sleep.sh hangs forever:
bash
$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
...
actions+ 72298 96.3 0.0 4372 3328 ? R Apr02 1988:54 /bin/bash /home/actions-runner/safe_sleep.sh 1
...
详见:safe_sleep.sh rarely hangs indefinitely
四、影响案例:更多比预想严重
不仅仅是某个挂起的新 Runner,某些开源项目(例如 Zig)据反馈在自托管 Runner 上观察到了成堆的死循环实例,这些无限循环的 safe_sleep.sh 不仅占满了 CPU,还导致 Runner 服务无法正常运行数日之久。
五、修复版本
https://github.com/actions/runner/commits/main/src/Misc/layoutroot/safe_sleep.sh
1. 最新修复版本
bash
#!/bin/bash
# try to use sleep if available
if [ -x "$(command -v sleep)" ]; then
sleep "$1"
exit 0
fi
# try to use ping if available
if [ -x "$(command -v ping)" ]; then
ping -c $(( $1 + 1 )) 127.0.0.1 > /dev/null
exit 0
fi
# try to use read -t from stdin/stdout/stderr if we are in bash
if [ -n "$BASH_VERSION" ]; then
if command -v read >/dev/null 2>&1; then
if [ -t 0 ]; then
read -t "$1" -u 0 || :;
exit 0
fi
if [ -t 1 ]; then
read -t "$1" -u 1 || :;
exit 0
fi
if [ -t 2 ]; then
read -t "$1" -u 2 || :;
exit 0
fi
fi
fi
# fallback to a busy wait
SECONDS=0
while [[ $SECONDS -lt $1 ]]; do
:
done
2. 执行逻辑(从"优雅"到"原始")
(1)首选:sleep
bash
if [ -x "$(command -v sleep)" ]; then
sleep "$1"
exit 0
fi
-
检查
sleep是否存在且可执行 -
最准确、最省 CPU
-
正常系统 99% 会走到这里
(2)备用:ping
bash
if [ -x "$(command -v ping)" ]; then
ping -c $(( $1 + 1 )) 127.0.0.1 > /dev/null
exit 0
fi
原理:
-
ping默认 每秒发一次 -
发
N+1个包 ≈ 等N秒
⚠️ 注意点:
-
依赖
ping没被禁用(某些容器里 ping 被移除或需要 CAP_NET_RAW) -
时间精度不如 sleep
-
127.0.0.1保证不会阻塞网络
(3)再退一步:read -t(仅 Bash)
bash
if [ -n "$BASH_VERSION" ]; then
if command -v read >/dev/null 2>&1; then
...
fi
fi
原理
-
read -t N→ 阻塞 N 秒等待输入 -
没输入就超时返回
为什么检查 -t 0 / 1 / 2?
bash
if [ -t 0 ]; then read -t "$1" -u 0; fi
-
read -t必须绑定到 TTY -
stdin/stdout/stderr 只要有一个是 TTY 就能用
-
非交互 shell(CI、cron)通常都不是 TTY
(4)最终兜底:busy wait(CPU 自旋)
bash
SECONDS=0
while [[ $SECONDS -lt $1 ]]; do
:
done
-
SECONDS是 Bash 内建变量(秒级) -
:是 no-op -
100% 占用一个 CPU 核心