Win32 SetTimer 같은 C 타이머 API는 콜백에 타이머 id만 넘겨준다. 시계 객체 여러 개가 각자 타이머를 걸면, static 콜백 안에서는 어느 객체의 타이머가 울렸는지 알 수 없다. 1편의 Thread는 void*로 this를 넘길 수 있었지만 여기서는 그 통로마저 없다. 이 글에서는 id와 객체를 잇는 매핑 테이블로 Clock 클래스를 만들고, 해제 누락으로 생기는 use-after-free와 재사용 가능한 Timer 클래스까지 g++ 13.3으로 확인한다.
이 시리즈의 다른 글
- C++ 예제로 배우는 소프트웨어 설계 기법 (1) — 스레드 클래스(Thread Class)
- C++ 예제로 배우는 소프트웨어 설계 기법 (3) — 윈도우 클래스와 메시지 처리(Window)
- C++ 예제로 배우는 소프트웨어 설계 기법 (4) — 싱글톤(Singleton)
콜백에 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를 직접 넘긴다.