C++ 예제로 배우는 소프트웨어 설계 기법 (2) — 시계와 타이머 콜백(Clock)

Win32 SetTimer 같은 C 타이머 API는 콜백에 타이머 id만 넘겨준다. 시계 객체 여러 개가 각자 타이머를 걸면, static 콜백 안에서는 어느 객체의 타이머가 울렸는지 알 수 없다. 1편의 Thread는 void*로 this를 넘길 수 있었지만 여기서는 그 통로마저 없다. 이 글에서는 id와 객체를 잇는 매핑 테이블로 Clock 클래스를 만들고, 해제 누락으로 생기는 use-after-free와 재사용 가능한 Timer 클래스까지 g++ 13.3으로 확인한다.

이 시리즈의 다른 글

콜백에 id만 넘겨주는 C 타이머 API

예제를 재현 가능하게 돌리려고 Win32 SetTimer와 같은 형태의 API를 가상 시간으로 흉내 냈다.

// Win32 SetTimer처럼 콜백에 타이머 id만 넘겨주는 C 타이머 API (가상 시간으로 동작)
#pragma once
#include <vector>

typedef void (*TIMERPROC)(int id);

struct TimerSlot { int id, period, next; TIMERPROC fn; bool alive; };
inline std::vector<TimerSlot> g_timers;
inline int g_next_id = 1;
inline int g_now = 0;                            // 가상 시각(ms)

inline int set_timer(int period_ms, TIMERPROC fn)
{
    g_timers.push_back({g_next_id, period_ms, g_now + period_ms, fn, true});
    return g_next_id++;
}
inline void kill_timer(int id)
{
    for (auto& t : g_timers) if (t.id == id) t.alive = false;
}
inline void run_timers(int duration_ms)           // 이벤트 루프: 1ms씩 시간을 흘린다
{
    for (int end = g_now + duration_ms; g_now < end; ) {
        int now = ++g_now;
        for (std::size_t i = 0; i < g_timers.size(); ++i)
            if (g_timers[i].alive && g_timers[i].next == now) {
                g_timers[i].next += g_timers[i].period;
                g_timers[i].fn(g_timers[i].id);
            }
    }
}
#include <cstdio>
#include "ctimer.h"

void on_fast(int id) { std::printf("  fast (id=%d)\n", id); }
void on_slow(int id) { std::printf("  slow (id=%d)\n", id); }

int main()
{
    set_timer(100, on_fast);
    set_timer(250, on_slow);
    run_timers(500);
}
$ g++ -std=c++20 -Wall -o cs c_style.cpp
$ ./cs
  fast (id=1)
  fast (id=1)
  slow (id=2)
  fast (id=1)
  fast (id=1)
  fast (id=1)
  slow (id=2)

static 포인터 하나로는 객체 하나만 된다

#include <cstdio>
#include <string>
#include "ctimer.h"

class Clock {
    std::string name_;
    static Clock* self_;                         // 콜백이 this를 찾기 위한 static 포인터 하나
    static void on_timer(int) { self_->tick(); }
    void tick() { std::printf("  [%s] tick\n", name_.c_str()); }
public:
    explicit Clock(std::string n) : name_(std::move(n)) {}
    void start(int ms) { self_ = this; set_timer(ms, &Clock::on_timer); }
};
Clock* Clock::self_ = nullptr;

int main()
{
    Clock seoul("Seoul"), paris("Paris");
    seoul.start(100);
    paris.start(150);
    run_timers(300);
}
$ g++ -std=c++20 -Wall -o st clock_static.cpp
$ ./st
  [Paris] tick
  [Paris] tick
  [Paris] tick
  [Paris] tick
  [Paris] tick

self_가 마지막에 start한 paris로 덮여서 Seoul 타이머가 울려도 Paris가 출력된다.

타이머 id와 객체를 잇는 매핑 테이블

#include <cstdio>
#include <map>
#include <string>
#include "ctimer.h"

class Clock {
    std::string name_;
    int id_ = 0;
    static inline std::map<int, Clock*> table_;  // 타이머 id -> 객체
    static void on_timer(int id)
    {
        if (auto it = table_.find(id); it != table_.end()) it->second->tick();
    }
    void tick() { std::printf("  [%s] tick\n", name_.c_str()); }
public:
    explicit Clock(std::string n) : name_(std::move(n)) {}
    void start(int ms)
    {
        id_ = set_timer(ms, &Clock::on_timer);
        table_[id_] = this;
    }
};

int main()
{
    Clock seoul("Seoul"), paris("Paris");
    seoul.start(100);
    paris.start(150);
    run_timers(300);
}
$ g++ -std=c++20 -Wall -o mp clock_map.cpp
$ ./mp
  [Seoul] tick
  [Paris] tick
  [Seoul] tick
  [Seoul] tick
  [Paris] tick

콜백이 받은 id로 table_에서 객체를 찾으면 시계마다 제 이름이 나온다. 100ms·150ms 주기에 맞춰 Seoul 3번, Paris 2번이다.

객체가 먼저 사라지면

#include <cstdio>
#include <map>
#include <string>
#include "ctimer.h"

class Clock {
    std::string name_;
    int id_ = 0;
    static inline std::map<int, Clock*> table_;
    static void on_timer(int id) { if (auto it = table_.find(id); it != table_.end()) it->second->tick(); }
    void tick() { std::fprintf(stderr, "  [%s] tick\n", name_.c_str()); }
public:
    explicit Clock(std::string n) : name_(std::move(n)) {}
    void start(int ms) { id_ = set_timer(ms, &Clock::on_timer); table_[id_] = this; }
#ifdef FIXED
    ~Clock() { if (id_) { kill_timer(id_); table_.erase(id_); } }   // 등록 해제
#endif
};

int main()
{
    Clock seoul("Seoul");
    seoul.start(100);
    {
        auto* tmp = new Clock("Temporary-clock-name-long-enough");
        tmp->start(100);
        delete tmp;                              // 타이머는 그대로 남아 있다
    }
    run_timers(200);
}
$ g++ -std=c++20 -g -fsanitize=address -o dg clock_dangling.cpp
$ ./dg 2>&1 | grep -E "tick|ERROR|READ of|#2 " | head -4 | sed -E "s#/tmp/[^ ]*/##; s# at pc .*##; s#0x[0-9a-f]+ in ##"
  [Seoul] tick
==3821==ERROR: AddressSanitizer: heap-use-after-free on address 0x504000000050
READ of size 8 at 0x504000000050 thread T0
    #2 Clock::tick() clock_dangling.cpp:11
$ g++ -std=c++20 -g -fsanitize=address -DFIXED -o dg2 clock_dangling.cpp
$ ./dg2
  [Seoul] tick
  [Seoul] tick

delete 뒤에도 타이머와 테이블 항목이 남아 해제된 객체의 tick()이 불린다. 소멸자에서 kill_timer와 erase를 하는 -DFIXED 빌드는 Seoul만 정상 동작한다.

재사용 가능한 Timer 클래스

#include <cstdio>
#include <functional>
#include <map>
#include "ctimer.h"

// 매핑 테이블을 한 곳에 모은 재사용 가능한 기반 클래스
class Timer {
    int id_ = 0;
    std::function<void()> fn_;
    static inline std::map<int, Timer*> table_;
    static void dispatch(int id) { if (auto it = table_.find(id); it != table_.end()) it->second->fn_(); }
public:
    Timer(int ms, std::function<void()> fn) : fn_(std::move(fn))
    {
        id_ = set_timer(ms, &Timer::dispatch);
        table_[id_] = this;
    }
    ~Timer() { kill_timer(id_); table_.erase(id_); }
    Timer(const Timer&) = delete;                // 테이블에 this가 들어 있으므로
    Timer& operator=(const Timer&) = delete;
};

class Clock {
    const char* name_;
    int sec_ = 0;
    Timer timer_;                                // 수명이 Clock과 같다
public:
    Clock(const char* n, int ms) : name_(n), timer_(ms, [this] { std::printf("  [%s] %d\n", name_, ++sec_); }) {}
};

class Blinker {
    bool on_ = false;
    Timer timer_{120, [this] { on_ = !on_; std::printf("  LED %s\n", on_ ? "on" : "off"); }};
};

int main()
{
    Clock c("Seoul", 100);
    Blinker b;
    {
        Clock tmp("Tmp", 70);
        run_timers(150);
    }                                            // tmp 소멸 -> 자동 해제
    std::puts("  -- Tmp destroyed --");
    run_timers(150);
}
$ g++ -std=c++20 -Wall -o tb timer_base.cpp
$ ./tb
  [Tmp] 1
  [Seoul] 1
  LED on
  [Tmp] 2
  -- Tmp destroyed --
  [Seoul] 2
  LED off
  [Seoul] 3

매핑 테이블과 등록 해제를 Timer에 모으고 할 일은 std::function으로 받는다. Clock, Blinker는 Timer를 멤버로 두기만 하면 되고, 소멸 시 자동으로 해제되어 Tmp가 사라진 뒤에는 울리지 않는다.

void*를 넘겨주는 API라면

#include <csignal>
#include <cstdio>
#include <ctime>
#include <unistd.h>
#include <atomic>

// POSIX timer_create는 sigev_value에 void*를 실어 주므로 매핑 테이블이 필요 없다
class Clock {
    const char* name_;
    std::atomic<int> ticks_{0};
    timer_t tid_{};
    static void on_timer(union sigval v) { static_cast<Clock*>(v.sival_ptr)->tick(); }
    void tick() { std::printf("  [%s] tick %d\n", name_, ++ticks_); }
public:
    Clock(const char* n, long ms) : name_(n)
    {
        sigevent sev{};
        sev.sigev_notify = SIGEV_THREAD;
        sev.sigev_notify_function = &Clock::on_timer;
        sev.sigev_value.sival_ptr = this;        // this를 직접 전달
        timer_create(CLOCK_MONOTONIC, &sev, &tid_);
        itimerspec its{{0, ms * 1'000'000}, {0, ms * 1'000'000}};
        timer_settime(tid_, 0, &its, nullptr);
    }
    ~Clock() { timer_delete(tid_); }
};

int main()
{
    Clock a("A", 100), b("B", 250);
    usleep(560'000);
}
$ g++ -std=c++20 -Wall -o pt posix_timer.cpp
$ ./pt
  [A] tick 1
  [A] tick 2
  [B] tick 1
  [A] tick 3
  [A] tick 4
  [A] tick 5
  [B] tick 2

POSIX timer_create는 sigev_value.sival_ptr로 포인터를 넘겨주므로 1편처럼 this를 바로 실어 보낸다. SIGEV_THREAD 콜백은 별도 스레드에서 실행되므로 공유 상태는 동기화해야 한다.

콜백 API가 넘겨주는 것객체를 찾는 방법예
———
void* 사용자 데이터static_cast로 this 복원pthread_create, timer_create, qsort_r
핸들/id만static 매핑 테이블 (id → 객체)Win32 SetTimer, 일부 HAL 인터럽트 API
아무것도 없음전역/static 포인터 (객체 1개만)시그널 핸들러, 오래된 C 라이브러리

주의사항

상황문제대응
———
객체 소멸 후 타이머 유지use-after-free소멸자에서 kill_timer + 테이블 항목 삭제
테이블에 등록된 객체 복사·이동테이블이 옛 주소를 가리킴복사·이동 금지 또는 테이블 갱신
콜백 안에서 다른 타이머 등록·해제순회 중인 컨테이너 변경인덱스로 순회하거나 지연 처리
콜백이 다른 스레드에서 실행테이블·멤버 경쟁 상태mutex 또는 atomic
static 포인터 하나로 구현마지막 객체만 동작id → 객체 매핑

마무리

  • 콜백이 id만 넘겨주면 static std::map<int, T*>로 id와 객체를 이어 멤버 함수로 돌아온다.
  • 등록과 해제를 RAII 클래스 하나에 모으면 해제 누락으로 인한 use-after-free를 막을 수 있다.
  • void* 사용자 데이터를 받는 API라면 테이블 없이 this를 직접 넘긴다.

참고

답글 남기기