If you use self-hosted GitHub Actions runners, you may have noticed they can be surprisingly CPU-intensive, even when idle. A closer look reveals that a single runner can pin a CPU core at 100% utilization. This is not a bug in your setup; it is a deliberate design choice in the runner's sleep mechanism.
#The problem: Busy-waiting
Instead of using efficient OS-provided sleep functions, the GitHub Actions runner uses a "busy-waiting" loop: it continuously checks the system clock in a tight loop until the desired duration has elapsed. While this ensures broad compatibility, even on systems without traditional sleep functions, it is highly inefficient on standard Linux or Windows VMs. The constant polling keeps a CPU core fully active, wasting resources, generating heat, and potentially degrading performance for other processes on the same machine.
I run 4 self-hosted runners on a single physical server in my homelab. When investigating unexpectedly high CPU usage with no workflows running, I found each idle runner was consuming nearly 10% of a CPU core.
#The solution: A smarter sleep script
We can fix this by replacing the runner's default sleep script with a smarter version that uses the most efficient sleep method available while still falling back gracefully for compatibility.
Navigate to the folder where your runner is installed and edit the safe_sleep.sh file. Replace its content with the following:
Shell
#!/bin/bash
# Use the native 'sleep' command if available (most efficient)
if [ -x "$(command -v sleep)" ]; then
sleep $1
exit 0
fi
# Fallback to 'read' with a timeout, a bash built-in
if [[ -n "$BASH_VERSINFO" && "${BASH_VERSINFO[0]}" -ge 4 ]]; then
read -rt "$1" <> <(:) || :
exit 0
fi
# A creative fallback using 'ping'
if [ -x "$(command -v ping)" ]; then
ping -c $1 127.0.0.1 > /dev/null
exit 0
fi
# Original busy-waiting loop as a last resort
SECONDS=0
while [[ $SECONDS != $1 ]]; do
:
done
This script first tries to use the standard sleep command. If that's not available, it attempts to use read with a timeout, which is a shell built-in and still more efficient than busy-waiting. As another fallback, it uses ping. Only if none of these are available does it revert to the original busy-waiting loop.
#A word of caution
GitHub Actions runners can update automatically, and an update may overwrite your changes to safe_sleep.sh. To keep the fix in place, consider setting up a cron job or scheduled task to re-apply it periodically.
#Additional resources
For more technical details and community discussion on this topic, see the following GitHub issues and pull requests:
Do you have a question or a suggestion about this post? Contact me!