POSIX sleep에 의존하지 않고 Bash와 Zig로 휴면 유틸리티를 바닥부터 구현하며, kqueue와 timerfd 같은 OS 타이머를 활용한 크로스플랫폼 접근을 살펴본다.
얼마 전, GitHub Actions 러너를 위한 크로스플랫폼 sleep의 이 구현이 눈에 들어왔고, 최대한 크로스플랫폼을 염두에 두면서 sleep을 바닥부터 다시 구현하려면 무엇이 필요할지 직접 해 보고 싶어졌다.

이 구현의 동기는 sleep 유틸리티에 의존하지 않으려는 데 있는 듯하다. 하지만 이는 POSIX 표준의 일부다. 대신 SECONDS라는 bash 내장 기능을 사용하는 순수 bash 해법을 택했다(manpage 참고).
bash 소스를 들여다보면 이 내장이 gettimeofday 호출을 이용해 구현되어 있음을 알 수 있다(assign_seconds, get_seconds). 어차피 gettimeofday에 의존할 거라면, 왜 sleep에는 의존하지 않는 걸까?
실제로 Matthew Lugg는 지적하길 이 접근은 더 복잡할 뿐 아니라 버그도 있고, 러너의 부하가 아주 크거나 시스템 시계가 조정되는 등의 상황에 취약하다고 한다. 교착 상태에 대한 단순한 수정은 결국 병합되었는데, 동등 비교 대신 미만 비교를 쓰는 방식이었다.
하지만 우리는 엔지니어다. 그러니 우리만의 sleep 유틸리티를 바닥부터 만들어서 이 모든 걸 오버엔지니어링해 보자.
bash우선 gettimeofday 같은 실시간 시계 호출 대신, bash 내장인 BASH_MONOSECONDS가 제공하는 단조 증가(monotonic) 시계를 쓰도록 비켜 가 보자. 이는 bash 5.3에 도입되었고(릴리스 노트), 새롭고 아직 보편적으로 사용 가능하진 않다. 이런 해법은 다음처럼 보일 것이다:
#!/usr/bin/env bash
duration=$1
start=$BASH_MONOSECONDS
end=$((start + duration))
while [[ $BASH_MONOSECONDS -lt end ]]; do
:
done
앞선 구현과 마찬가지로 이건 바쁜 대기(busy-wait) 루프다. 더 나은 방법을 찾아 다른 대안을 활용해 보자.
Zig는 이런 작업에 아주 유용한 언어다. 컴파일이 빠르고 크로스플랫폼 작업이 쉽기 때문에, 휴대성 좋은 자체 sleep을 만들기에 딱이다.
먼저 바쁜 대기 루프를 피하고 비동기 접근을 택하자. 리눅스에는 timefd_create, BSD에는 kqueue, 윈도우에는 CreateWaitableTimer가 있다. kqueue 구현부터 시작해 보자.
const std = @import("std");
fn sleepKqueue(ms: u64) !void {
const posix = std.posix;
const kq = try posix.kqueue();
defer posix.close(kq);
const kev = posix.Kevent{
.ident = 0,
.filter = posix.system.EVFILT.TIMER,
.flags = posix.system.EV.ADD | posix.system.EV.ONESHOT,
.fflags = 0,
.data = @as(isize, @intCast(ms)),
.udata = @intFromPtr(@as(?*anyopaque, null)),
};
_ = try posix.kevent(kq, &[_]posix.Kevent{kev}, &[_]posix.Kevent{}, null);
var out: [1]posix.Kevent = undefined;
const n = try posix.kevent(kq, &[_]posix.Kevent{}, &out, null);
if (n != 1) return error.TimerWaitFailed;
}
kqueue는 리눅스의 epoll에 해당하며, 여러 파일 디스크립터를 확장성 있게 모니터링할 수 있게 해 준다. 여기서는 타이머 이벤트를 기다리는 데 사용한다. 약간의 CLI 래핑을 더하면:
$ time ./ksleep 2
real 0m2.019s
user 0m0.003s
sys 0m0.008s
앞선 bash 구현과 비교해 보자:
$ time /tmp/badsleep.sh 2
real 0m1.875s
user 0m1.854s
sys 0m0.016s
ksleep 구현은 CPU 사용량 면에서 더 효율적일 뿐 아니라, 타이밍 정확도도 더 높다. 밀리초 정밀도로 지정할 수 있다:
$ time ./ksleep 2.5
real 0m2.523s
user 0m0.003s
sys 0m0.010s
다음으로, timerfd를 이용한 리눅스 구현을 보자:
fn sleepTimerFd(seconds: f64) !void {
const sys = std.os.linux;
const flags = sys.TFD{ .CLOEXEC = true };
const rawfd = sys.timerfd_create(sys.timerfd_clockid_t.MONOTONIC, flags);
const fd = @as(i32, @intCast(rawfd));
if (fd < 0) return error.TimerFdCreateFailed;
defer std.posix.close(fd);
const sec: i64 = @intFromFloat(@floor(seconds));
const nsec: i64 = @intFromFloat((seconds - @as(f64, @floatFromInt(sec))) * 1_000_000_000);
var new_value = sys.itimerspec{
.it_interval = .{ .sec = 0, .nsec = 0 },
.it_value = .{ .sec = sec, .nsec = nsec },
};
const no_flags = sys.TFD.TIMER{};
if (sys.timerfd_settime(fd, no_flags, &new_value, null) < 0)
return error.TimerSetFailed;
var buf: [8]u8 = undefined;
const n = try std.posix.read(fd, &buf);
if (n != 8) return error.TimerReadFailed;
}
앞과 마찬가지로, 이 구현도 효율적이고 정확하다:
root@13738b47c4e3:/ksleep# time ./ksleep 2.5
real 0m2.507s
user 0m0.001s
sys 0m0.002s
timerfd_create는 리눅스 2.6.25부터 제공되어, 널리 지원된다.
윈도우 머신이 없어서, 이 연습은 독자에게 맡기겠다.
기존 sleep 명령에 의존하지 않고도 꽤 현대적인 sleep 유틸리티를 만드는 건 그리 어렵지 않다. 여기서 보인 예시는 아직 완전히 프로덕션 준비가 된 건 아니지만, 정적으로 빌드되고 크기도 128KB 미만으로 작다. 지독한 bash 바쁜 대기 루프는 필요 없다.
또 다른 구현은 그냥 nanosleep을 직접 호출하는 것이다(감사: Jihyeon Kim):
fn clockNanosleep(seconds: f64) void {
const sec: usize = @intFromFloat(@floor(seconds));
const nsec: usize = @intFromFloat((seconds - @floor(seconds)) * 1_000_000_000.0);
const posix = std.posix;
posix.nanosleep(sec, nsec);
}
하지만 그게 무슨 재미가 있겠는가. 그건 그냥 sleep이 하는 일일 뿐이다!
최종 코드는 다음과 같다:
// MIT License
// Copyright (c) 2025 Matthew Blewitt
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
const std = @import("std");
const builtin = @import("builtin");
pub fn main() !void {
var buf: [512]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
const allocator = fba.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len != 2) {
std.debug.print("Usage: {s} <seconds>\n", .{args[0]});
std.process.exit(1);
}
const seconds = std.fmt.parseFloat(f64, args[1]) catch {
std.debug.print("Invalid number: {s}\n", .{args[1]});
std.process.exit(1);
};
if (seconds < 0) {
std.debug.print("Duration must be >= 0\n", .{});
std.process.exit(1);
}
const millis = @as(u64, @intFromFloat(seconds * 1000.0));
switch (builtin.os.tag) {
.linux => try sleepTimerFd(seconds),
.macos, .ios, .freebsd, .netbsd, .openbsd, .dragonfly => try sleepKqueue(millis),
else => {
std.debug.print("Unsupported OS: {s}\n", .{builtin.os.tag});
std.process.exit(1);
},
}
}
fn sleepKqueue(ms: u64) !void {
const posix = std.posix;
const kq = try posix.kqueue();
defer posix.close(kq);
const kev = posix.Kevent{
.ident = 0,
.filter = posix.system.EVFILT.TIMER,
.flags = posix.system.EV.ADD | posix.system.EV.ONESHOT,
.fflags = 0,
.data = @as(isize, @intCast(ms)),
.udata = @intFromPtr(@as(?*anyopaque, null)),
};
_ = try posix.kevent(kq, &[_]posix.Kevent{kev}, &[_]posix.Kevent{}, null);
var out: [1]posix.Kevent = undefined;
const n = try posix.kevent(kq, &[_]posix.Kevent{}, &out, null);
if (n != 1) return error.TimerWaitFailed;
}
fn sleepTimerFd(seconds: f64) !void {
const sys = std.os.linux;
const flags = sys.TFD{ .CLOEXEC = true };
const rawfd = sys.timerfd_create(sys.timerfd_clockid_t.MONOTONIC, flags);
const fd = @as(i32, @intCast(rawfd));
if (fd < 0) return error.TimerFdCreateFailed;
defer std.posix.close(fd);
const sec: i64 = @intFromFloat(@floor(seconds));
const nsec: i64 = @intFromFloat((seconds - @as(f64, @floatFromInt(sec))) * 1_000_000_000);
var new_value = sys.itimerspec{
.it_interval = .{ .sec = 0, .nsec = 0 },
.it_value = .{ .sec = sec, .nsec = nsec },
};
const no_flags = sys.TFD.TIMER{};
if (sys.timerfd_settime(fd, no_flags, &new_value, null) < 0)
return error.TimerSetFailed;
var buf: [8]u8 = undefined;
const n = try std.posix.read(fd, &buf);
if (n != 8) return error.TimerReadFailed;
}