C++ sleep_for·sleep_until로 주기 작업 만들기: 누적 드리프트와 짧은 sleep의 부정확함

이 글의 핵심

1초마다 실행하려고 sleep_for(1s)를 반복하면 작업 시간만큼 주기가 조금씩 밀립니다. 이 글은 이런 드리프트를 다음 목표 시각 기준 sleep_until로 줄이는 방법, 운영체제 스케줄러 때문에 짧은 sleep이 요청보다 길어지는 이유, 리트라이와 타임아웃을 조합하는 패턴을 코드와 함께 보여줍니다.

타이머 유틸리티란?

C++ 표준 라이브러리에는 “몇 초 뒤에 이 함수를 호출하라” 같은 타이머 객체가 따로 없습니다. 대신 <thread>의 std::this_thread::sleep_for, sleep_until, yield 세 함수와 <chrono>의 시계·기간 타입을 조합해 지연, 주기 실행, 타임아웃을 직접 만듭니다. 세 함수 모두 현재 스레드를 멈추는 동작이라는 점이 핵심입니다. 스레드가 멈춰 있는 동안 그 스레드에서는 아무 일도 일어나지 않으므로, UI 스레드나 네트워크 이벤트 루프에서 호출하면 전체가 멈춥니다. 이 글은 이 함수들의 정확한 의미와 한계, 그리고 한계를 넘어야 할 때 조건 변수나 Asio 타이머로 넘어가는 기준을 다룹니다.

#include <thread>
#include <chrono>
using namespace std::chrono_literals;

// 지연
std::this_thread::sleep_for(1s);

// 특정 시간까지
auto wakeup = std::chrono::system_clock::now() + 5s;
std::this_thread::sleep_until(wakeup);

sleep_for는 “지금부터 최소 이만큼”, sleep_until은 “이 시각이 될 때까지”를 뜻합니다. 두 함수는 비슷해 보이지만 시계를 다루는 방식이 다릅니다. sleep_for는 표준에 따라 단조 시계(steady clock) 기준으로 측정하도록 권장되어 시스템 시간이 바뀌어도 영향을 받지 않습니다. 반면 sleep_until은 넘겨준 time_point의 시계를 따르므로, 위 예제처럼 system_clock을 쓰면 대기 중에 사용자가 시계를 바꾸거나 NTP가 시간을 크게 보정할 때 깨어나는 시각이 앞당겨지거나 늦어집니다. 이 차이는 뒤의 “시계 선택” 절에서 다시 다룹니다.

sleep_for

using namespace std::chrono_literals;

// 밀리초
std::this_thread::sleep_for(100ms);

// 초
std::this_thread::sleep_for(2s);

// 분
std::this_thread::sleep_for(1min);

100ms, 2s, 1min 같은 리터럴은 std::chrono_literals 네임스페이스에 있고, 각각 milliseconds, seconds, minutes 타입의 duration 값을 만듭니다. sleep_for(100)처럼 정수를 직접 넘기면 컴파일 에러가 나는데, 이는 설계상 의도된 것입니다. 옛 C API의 sleep(1)(초)과 usleep(1000)(마이크로초), Windows의 Sleep(1000)(밀리초)처럼 단위가 함수마다 달라 생기던 실수를 타입으로 막기 위해서입니다. 서로 다른 단위의 duration끼리는 정밀도를 잃지 않는 방향이면 자동 변환되므로, 1s + 500ms는 1500ms로 계산됩니다.

실전 예시

예시 1: 주기적 작업

using namespace std::chrono_literals;

void periodicTask() {
    while (true) {
        // 작업 수행
        std::cout << "작업 실행" << std::endl;
        
        // 1초 대기
        std::this_thread::sleep_for(1s);
    }
}

이 코드는 “1초마다”가 아니라 “작업이 끝나고 1초 뒤마다” 실행됩니다. 작업에 50ms가 걸리면 실제 주기는 1.05초가 되고, 여기에 스케줄러가 스레드를 깨우는 지연까지 더해져 한 시간이면 수 분이 밀릴 수 있습니다. 로그 수집이나 헬스 체크처럼 정확한 주기가 중요하지 않다면 이 방식이 가장 단순하고 충분하지만, 초당 샘플 수가 정해진 측정이나 게임 틱처럼 주기가 중요하다면 뒤의 sleep_until 기반 고정 주기 패턴을 써야 합니다. 또 while (true)에는 종료 방법이 없어서 프로그램을 끝낼 때 이 스레드를 join할 수 없습니다. 실제 코드에서는 종료 플래그를 확인하도록 만들어야 합니다.

예시 2: sleep_until

using namespace std::chrono;
using namespace std::chrono_literals;

void scheduledTask() {
    auto now = system_clock::now();
    auto nextRun = now + 5s;
    
    std::cout << "5초 후 실행 예정" << std::endl;
    std::this_thread::sleep_until(nextRun);
    
    std::cout << "작업 실행" << std::endl;
}

예시 3: 정밀 타이머

using namespace std::chrono;
using namespace std::chrono_literals;

class PrecisionTimer {
    steady_clock::time_point start;
    
public:
    PrecisionTimer() : start(steady_clock::now()) {}
    
    void waitFor(milliseconds duration) {
        auto target = start + duration;
        
        while (steady_clock::now() < target) {
            std::this_thread::yield();  // CPU 양보
        }
    }
    
    auto elapsed() const {
        return duration_cast<microseconds>(
            steady_clock::now() - start
        );
    }
};

int main() {
    PrecisionTimer timer;
    timer.waitFor(100ms);
    
    std::cout << "경과: " << timer.elapsed().count() << "μs" << std::endl;
}

이 클래스의 waitFor는 “지금부터 100ms”가 아니라 객체가 생성된 시각부터 100ms가 지날 때까지 기다립니다(start + duration). 생성 후 바로 호출하면 차이가 없지만, 생성과 호출 사이에 다른 작업이 있으면 대기 시간이 그만큼 짧아지거나 아예 기다리지 않습니다. 의도가 “호출 시점부터”라면 steady_clock::now() + duration으로 바꿔야 합니다.

yield 루프는 sleep_for보다 목표 시각에 훨씬 가깝게 깨어난다는 장점이 있지만, 그 대가로 대기 내내 CPU 코어 하나를 거의 100% 사용합니다. yield는 “다른 스레드가 준비되어 있으면 양보하라”는 힌트일 뿐이라, 다른 할 일이 없는 시스템에서는 곧바로 돌아와 루프를 다시 돕니다. 100ms 동안 이렇게 도는 것은 대부분의 경우 낭비입니다. 실무에서 쓰이는 절충안은 목표 시각 1~2ms 전까지는 sleep_until로 자고, 남은 짧은 구간만 yield나 스핀으로 기다리는 하이브리드 방식입니다. 오디오나 게임 프레임 페이싱처럼 서브밀리초 정밀도가 필요한 곳에서 이 방식을 씁니다.

예시 4: 타임아웃

using namespace std::chrono;
using namespace std::chrono_literals;

bool waitForCondition(milliseconds timeout) {
    auto deadline = steady_clock::now() + timeout;
    
    while (steady_clock::now() < deadline) {
        if (checkCondition()) {
            return true;
        }
        
        std::this_thread::sleep_for(10ms);
    }
    
    return false;  // 타임아웃
}

int main() {
    if (waitForCondition(5s)) {
        std::cout << "조건 충족" << std::endl;
    } else {
        std::cout << "타임아웃" << std::endl;
    }
}

데드라인을 루프 밖에서 한 번만 계산하는 것이 이 패턴의 핵심입니다. 반복할 때마다 남은 시간을 timeout -= 10ms처럼 차감하면 checkCondition() 실행 시간과 sleep 오차가 빠져서 실제 대기 시간이 설정보다 길어집니다. 절대 시각을 기준으로 비교하면 루프 안에서 무슨 일이 일어나든 전체 대기 시간은 timeout을 크게 넘지 않습니다.

다만 이 방식은 폴링이라 조건이 충족되어도 최대 10ms 늦게 알아챕니다. 조건을 바꾸는 쪽이 같은 프로그램의 다른 스레드라면 폴링 대신 std::condition_variable::wait_until(lock, deadline, pred)을 쓰는 것이 정답입니다. 조건이 바뀌는 즉시 깨어나고, 기다리는 동안 CPU를 전혀 쓰지 않습니다. 폴링은 파일 생성, 외부 프로세스 상태, 하드웨어 레지스터처럼 알림을 받을 방법이 없는 대상을 기다릴 때 쓰는 방법입니다.

yield

// CPU 양보
std::this_thread::yield();

// 바쁜 대기에서 사용
while (!ready) {
    std::this_thread::yield();
}

여기서 ready는 반드시 std::atomic<bool>이어야 합니다. 일반 bool을 한 스레드가 쓰고 다른 스레드가 읽으면 데이터 경쟁이라 정의되지 않은 동작이고, 실제로 최적화 컴파일러는 루프 안에서 ready가 바뀌지 않는다고 가정해 값을 한 번만 읽고 무한 루프로 만들 수 있습니다. 디버그 빌드에서는 잘 되다가 릴리스 빌드에서만 멈추는 전형적인 증상입니다. volatile은 이 문제의 해결책이 아닙니다. C++에서 volatile은 스레드 간 동기화를 보장하지 않습니다.

자주 발생하는 문제

문제 1: 정확도

using namespace std::chrono_literals;

// sleep은 최소 시간 보장
// 실제로는 더 길 수 있음
auto start = std::chrono::steady_clock::now();
std::this_thread::sleep_for(100ms);
auto end = std::chrono::steady_clock::now();

auto actual = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "실제: " << actual.count() << "ms" << std::endl;
// 출력: 실제: 102ms (예시)

표준은 sleep이 최소한 요청한 시간만큼 멈춘다는 것만 보장하고, 언제 깨어나는지에 대한 상한은 없습니다. 운영체제는 타이머가 만료되면 스레드를 “실행 가능” 상태로 바꿀 뿐이고, 실제로 CPU를 받는 시점은 스케줄러가 정합니다. 그래서 초과 지연은 시스템 부하에 따라 달라집니다. Linux는 기본적으로 타이머 만료를 약간 늦춰 묶어서 처리하는 timer slack(일반 스레드 기본 50µs)이 있고, Windows는 전통적으로 시스템 타이머 해상도가 약 15.6ms라서 sleep_for(1ms)가 15ms 넘게 걸리는 일이 흔했습니다. Windows에서 1ms 단위가 필요하면 timeBeginPeriod(1)로 해상도를 올리거나 고해상도 대기 타이머를 쓰지만, 시스템 전체 전력 소모가 늘어난다는 대가가 있습니다.

문제 2: 시계 선택

// ❌ system_clock (시간 변경 영향)
auto wakeup = std::chrono::system_clock::now() + 5s;
std::this_thread::sleep_until(wakeup);
// 시스템 시간 변경 시 문제

// ✅ steady_clock
auto wakeup = std::chrono::steady_clock::now() + 5s;
std::this_thread::sleep_until(wakeup);

system_clock은 벽시계 시간이라 사용자 설정, NTP 동기화, 가상 머신 일시 정지 후 재개 등으로 앞뒤로 점프할 수 있습니다. 대기 중에 시계가 1시간 뒤로 돌아가면 5초 대기가 1시간 5초 대기가 됩니다. steady_clock은 한 방향으로만 일정하게 증가하도록 보장되므로 타임아웃과 주기 작업에는 항상 이쪽을 씁니다. system_clock이 맞는 경우는 “매일 새벽 3시에 실행”처럼 달력상의 시각이 의미를 갖는 경우뿐이고, 그때도 긴 시간을 한 번에 자기보다 짧게 끊어 자면서 현재 시각을 다시 확인하는 편이 안전합니다. 두 예제 모두 같은 스코프에서 wakeup을 두 번 선언하므로 그대로 붙여 넣으면 재정의 에러가 나는 점은 비교를 위한 것이니 감안하고 보면 됩니다.

문제 3: 바쁜 대기

// ❌ CPU 100% 사용
while (!ready) {
    // 바쁜 대기
}

// ✅ yield 사용
while (!ready) {
    std::this_thread::yield();
}

// ✅ sleep 사용
while (!ready) {
    std::this_thread::sleep_for(1ms);
}

문제 4: 짧은 sleep

// 매우 짧은 sleep은 부정확
std::this_thread::sleep_for(std::chrono::microseconds(1));
// 실제로는 더 오래 걸림

// OS 스케줄러 한계

1µs sleep을 요청해도 스레드를 재우고 깨우는 과정 자체에 시스템 호출과 컨텍스트 스위치 두 번이 필요해서, 이것만으로도 수 µs에서 수십 µs가 걸립니다. 저도 처음 고주기 루프를 만들 때 sleep_for(microseconds(100))로 10kHz 주기를 기대했다가 실제로는 그보다 한참 낮은 빈도로 돌아서 원인을 찾느라 시간을 쓴 적이 있는데, 알고 보니 요청 시간보다 sleep 자체의 고정 비용이 더 큰 구간이었습니다. 이런 구간에서는 sleep 시간을 줄여도 주기가 빨라지지 않습니다. 요구 정밀도가 수십 µs 이하라면 sleep이 아니라 스핀 대기, 실시간 스케줄링(SCHED_FIFO), 하드웨어 타이머 같은 다른 수단을 검토해야 합니다.

시간 측정

using namespace std::chrono;

auto start = steady_clock::now();

// 작업
std::this_thread::sleep_for(100ms);

auto end = steady_clock::now();
auto elapsed = duration_cast<milliseconds>(end - start);

std::cout << "경과: " << elapsed.count() << "ms" << std::endl;

duration_cast는 버림으로 변환합니다. 99.9ms는 99ms가 되므로, 반올림이 필요하면 C++17의 std::chrono::round<milliseconds>()를 쓰고, 소수점까지 보고 싶으면 duration<double, std::milli>로 변환하면 됩니다. 실행 시간 측정에 high_resolution_clock을 쓰는 예제도 많은데, 이 시계는 구현에 따라 system_clock의 별칭일 수 있어서(libstdc++가 그렇습니다) 측정 중 시계 보정의 영향을 받을 수 있습니다. 경과 시간 측정에는 steady_clock을 쓰는 것이 이식성 면에서 안전합니다.

타이머 구현 패턴

표준 스레드 API만으로도 다음 패턴이 자주 사용됩니다.

  1. 상대 지연: sleep_for(duration) — “지금부터 최소 d만큼” (OS 스케줄러에 따라 더 길어질 수 있음).
  2. 절대 시각까지: sleep_until(time_point) — 특정 steady_clock 또는 system_clock 시각까지. 주기 작업에서 드리프트를 줄일 때 유용합니다.
  3. 폴링 + 짧은 sleep: 조건을 주기적으로 확인하며 CPU를 나누 씁니다. sleep_for(0) 또는 yield만으로는 스핀에 가까울 수 있어, 밀리초 단위 폴링이 일반적입니다.
  4. 데드라인 기반: auto deadline = steady_clock::now() + timeout 후 now() < deadline 루프 — steady_clock 글과 동일한 원칙입니다.
using namespace std::chrono_literals;

void fixed_rate_loop() {
    auto next = std::chrono::steady_clock::now();
    const auto interval = 1s;
    for (int i = 0; i < 10; ++i) {
        next += interval;
        do_tick();
        std::this_thread::sleep_until(next);  // 누적 시각 기준으로 드리프트 완화
    }
}

이 패턴이 드리프트를 없애는 원리는 “다음 깨어날 시각”을 이전 목표 시각에 간격을 더해 계산한다는 데 있습니다. 어떤 틱에서 스케줄러 때문에 3ms 늦게 깨어났다면, 다음 sleep_until은 그만큼 짧게 자서 원래 일정으로 돌아옵니다. 오차가 누적되지 않고 틱마다 상쇄되는 것입니다.

주의할 점은 do_tick()이 간격보다 오래 걸리는 경우입니다. 예를 들어 한 번 3초가 걸리면 next는 이미 과거 시각이 되어 sleep_until이 즉시 반환되고, 밀린 틱 두세 개가 연달아 실행됩니다. 측정 데이터를 빠짐없이 모아야 한다면 이 “따라잡기”가 맞는 동작이지만, 화면 갱신처럼 최신 상태만 중요하다면 밀린 틱은 건너뛰는 편이 낫습니다. 그럴 때는 if (next < steady_clock::now()) next = steady_clock::now(); 같은 보정을 넣어 과거로 밀린 목표 시각을 현재로 당깁니다.

Asio(Boost.Asio) 타이머와의 연계

비동기 I/O를 쓰는 서버·클라이언트에서는 스레드 sleep 대신 asio::steady_timer / asio::system_timer로 이벤트 루프에 타이머를 등록하는 방식이 일반적입니다.

  • steady_timer: 내부적으로 단조 시계에 대응. 타임아웃·재전송 간격·하트비트에 적합합니다.
  • system_timer: wall-clock 기준. 특정 시각에 실행하거나 로그와 맞춘 스케줄이 필요할 때 사용합니다.

개념적으로는 async_wait에 핸들러를 넘기고, 취소는 timer.cancel()입니다. 네트워크 가이드와 연결해 보려면 C++ 네트워크 가이드 — post·dispatch·defer에서 실행 스트랜드와 함께 읽는 것이 좋습니다.

// 개념 스케치 (실제 코드는 io_context, executor 설정 필요)
// asio::steady_timer t(io, std::chrono::steady_clock::now() + 5s);
// t.async_wait([](std::error_code ec) { if (!ec) { /* 타임아웃 처리 */ } });

스레드 sleep과의 차이: Asio 타이머는 해당 스레드를 블록하지 않고 다른 연결 처리를 계속할 수 있습니다(단일 스레드 io_context 모델에서 특히).

주기적 작업 (심화)

  • 고정 간격(fixed rate): 루프마다 sleep_for(interval)만 쓰면 작업 시간만큼 누적 지연이 생깁니다. 위의 next += interval; sleep_until(next) 패턴이 더 균일합니다.
  • 백오프: 네트워크 재시도에서는 지수 백오프 + 상한(jitter 포함)을 두어 서버·클라이언트 모두 보호합니다.
  • 정지: 장시간 루프는 std::stop_token(C++20) 또는 원자적 bool로 종료를 받아 sleep을 중단할 수 있게 설계합니다.

정지 설계에서 흔히 놓치는 점은 sleep_for가 중간에 깨울 수 없다는 것입니다. 종료 플래그를 세워도 스레드가 1분짜리 sleep에 들어가 있으면 1분 뒤에야 플래그를 확인하고, 그동안 프로그램 종료가 멈춘 것처럼 보입니다. 즉시 깨울 수 있어야 한다면 sleep 대신 condition_variable::wait_for(lock, interval, [&]{ return stop; })를 쓰고, 종료할 때 notify_all()을 호출합니다. C++20에서는 std::condition_variable_any의 wait_for가 stop_token을 직접 받아서, std::jthread가 소멸될 때 요청되는 정지로 대기가 바로 풀립니다(stop_token 참고).

실전: 리트라이와 타임아웃

전체 데드라인 하나 + 시도마다 짧은 대기가 읽기 쉽습니다.

#include <chrono>
#include <thread>

template <class Fn>
bool retry_with_deadline(Fn&& fn, std::chrono::steady_clock::time_point deadline,
                         std::chrono::milliseconds backoff) {
    while (std::chrono::steady_clock::now() < deadline) {
        if (fn()) return true;
        std::this_thread::sleep_for(backoff);
    }
    return false;
}

이 함수는 한 가지를 개선할 여지가 있습니다. 마지막 시도 직전의 sleep_for(backoff)가 데드라인을 넘어서까지 잘 수 있다는 점입니다. 데드라인이 100ms 남았는데 백오프가 500ms라면 400ms를 더 기다린 뒤에야 false를 반환합니다. sleep_until(std::min(now + backoff, deadline))처럼 대기 시각을 데드라인으로 잘라 주면 전체 제한 시간이 정확히 지켜집니다. 백오프를 시도마다 두 배로 늘리고 무작위 지터를 더하면, 서버 장애 뒤 모든 클라이언트가 같은 순간에 재시도해 다시 서버를 쓰러뜨리는 현상을 줄일 수 있습니다.

  • HTTP/gRPC 클라이언트: 연결 타임아웃과 읽기 타임아웃을 분리하며, 재시도는 멱등한 요청에만 적용하는 것이 안전합니다.
  • 조건 변수: 대기 가능하면 wait_until(lock, deadline)이 폴링보다 효율적입니다.

성능 고려사항

  • sleep_for(1µs) 같은 극단: 많은 OS에서 실제 최소 수십~수백 µs로 올라갑니다. 고주기 타이머가 필요하면 OS별 타이머 API나 오디오/게임 루프용 전용 스레드를 검토합니다.
  • 짧은 루프 + sleep: 폴링 간격이 너무 짧으면 깨어나는 빈도가 높아 전력·CPU 비용이 커집니다. 요구 정밀도에 맞춰 최소 폴링 간격을 정합니다.
  • 바쁜 대기: yield만 반복하면 여전히 코어를 점유할 수 있습니다. 짧은 sleep이나 락·조건 변수로 블록하는 편이 낫습니다.
  • 다수 스레드: 각 스레드가 자기만 sleep하는 것은 괜찮지만, 수천 개 스레드가 각각 sleep을 깨우면 스케줄러 부담이 커집니다. 이벤트 루프 + 타이머 큐 한 개로 합치는 방식을 고려합니다.

FAQ

Q1: sleep_for는 언제 쓰나요?

A: “잠깐 기다렸다가 다시 시도” 같은 상대 지연에 씁니다. 반복 주기를 맞춰야 한다면 누적 오차가 생기므로 sleep_until을 쓰는 것이 낫습니다.

Q2: sleep_until에는 어떤 시계를 넘겨야 하나요?

A: 타임아웃과 주기 작업에는 steady_clock을 넘깁니다. system_clock은 시스템 시간 변경의 영향을 받으므로 달력상 특정 시각에 실행해야 하는 경우에만 씁니다.

Q3: yield는 sleep과 무엇이 다른가요?

A: yield는 스레드를 재우지 않고 스케줄러에 양보 기회만 줍니다. 다른 실행할 스레드가 없으면 바로 돌아오므로 CPU 사용량은 거의 줄지 않습니다. 매우 짧은 대기에서만 의미가 있습니다.

Q4: sleep은 얼마나 정확한가요?

A: 요청한 시간 이상 멈추는 것만 보장되고 초과 지연의 상한은 없습니다. 부하가 없는 데스크톱 기준 보통 수십 µs~수 ms 초과가 일반적이며, 플랫폼과 부하에 따라 더 커질 수 있습니다.

Q5: 짧은 sleep이 왜 부정확한가요?

A: 스레드를 재우고 깨우는 시스템 호출과 컨텍스트 스위치 비용, 그리고 OS 타이머 해상도 때문입니다. 요청 시간이 이 고정 비용보다 짧으면 sleep 시간을 줄여도 실제 대기 시간은 줄지 않습니다.


같이 보면 좋은 글