제2부 실행 모델·클라이언트 라이브러리 내부
2.5 타이머의 정밀도와 드리프트
2.5.1 이 장에서 배우는 것 / 선수 지식
create_wall_timer(10ms, cb)라고 쓰면 콜백이 10 ms마다 실행되는가? 아니다. 평균은 대략 맞을 수 있지만, 매 주기는 흔들리고, 부하가 걸리면 지연되고, 지연된 것을 따라잡거나 버린다. 제어 루프를 ROS 2 타이머로 실행하는 시스템이라면 이 흔들림의 크기와 성질을 아는 것이 안전 문제다.
배우는 것:
- 타이머의 내부 동작 — 다음 발화 시각이 어떻게 계산되는가.
- 드리프트와 지터의 구분 — 두 가지 다른 문제이며 대처가 다르다.
- 지연된 주기의 처리 — 따라잡는가, 버리는가, 무엇이 바람직한가.
- 지연의 원인 사슬 — OS 스케줄러부터 콜백 실행까지 어디서 얼마나 늘어나는가.
- 측정 방법 — 주기를 실제로 측정하고 분포를 보는 법.
- 시뮬레이션 시간과 타이머 — 1.10의 함정을 정밀하게.
- 대안 — 타이머로 안 되는 정밀도가 필요할 때.
- 드리프트·지터의 실제 값(2.5.13) — executor별 꼬리 지연과 콜백 예산의 급변 지점.
선수 지식: 1.10(시계·use_sim_time), 2.2(executor 루프), 2.3(executor 선택), 2.4(콜백 그룹). 실시간 커널·스케줄링은 12.2, 지연 예산은 12.4에서 이어받는다.
정직: 타이머 구현 세부(다음 발화 시각 계산 방식·지연된 주기 처리)는
rcl_timer·rclcpp구현에 근거하나 배포판에 따라 달라질 수 있어(확인 필요)로 표시했다. 아래 수치 예시는 설명을 위한 것이며 측정값이 아니다. 실제 값은 하드웨어·커널·부하에 따라 자릿수가 다르다(BK-03). 자신의 시스템에서 2.5.6의 방법으로 재기 바란다.
2.5.2 타이머는 어떻게 발화하는가
타이머는 wait set의 한 엔티티이며(2.2.3), 다른 엔티티와 달리 시각으로 준비된다.
핵심은 F단계 — next_call_time을 어떻게 갱신하는가이다. 두 가지 방식이 있다.
| 방식 | 계산 | 성질 |
|---|---|---|
| 절대 기준 | next += T | 드리프트 없음. 지연되면 따라잡기 발생 |
| 상대 기준 | next = now + T | 드리프트 누적. 따라잡기 없음 |
$T = 10\,\mathrm{ms}$, 콜백이 3 ms 걸리고 발화가 2 ms 늦었다고 하자.
- 절대 기준: 0 → 10 → 20 → 30 … (실제 발화가 늦어도 목표는 고정)
- 상대 기준: 0 → 12 → 24 → 36 … (매번 늦은 만큼 뒤로 밀림)
실행 확인(2026-08-16): Jazzy는 절대 기준이다 — 350 ms 블로킹으로 세 주기를 놓친 뒤에도 예정 시각이 100 ms 격자를 유지했다(2.5.12.2). 아래 서술은 그 결과를 전제로 읽으라. 다른 배포판·
rclpy에서도 같은지는 확인하지 않았다(확인 필요). 절대 기준을 쓰되 지나치게 지연된 경우 재동기화하는 혼합 방식도 가능하다. 이 차이는 장시간 운용에서 크게 벌어지므로(1시간에 수백 ms~수 초), 정밀 주기가 필요하면 반드시 자신의 환경에서 확인해야 한다. 확인 방법은 2.5.6.
2.5.3 드리프트 vs 지터 — 다른 문제, 다른 대처
| 축 | 드리프트 | 지터 |
|---|---|---|
| 정의 | 장기 평균 주기의 체계적 편차 | 개별 주기의 분산 |
| 증상 | 1시간에 몇 초씩 밀림 | 주기가 8~15 ms 사이에서 흔들림 |
| 영향 | 시간 기준 계산의 누적 오차, 로그 동기 어긋남 | 제어 성능 저하, 미분항 노이즈 |
| 대처 | 절대 기준 갱신, 외부 시각 동기(1.10.8) | RT 우선순위, CPU 격리, executor 분리 |
제어 루프에서는 지터의 영향이 더 크다. PID의 미분항이 $\Delta t$로 나누는데, $\Delta t$가 8 ms와 15 ms 사이에서 흔들리면 미분항이 그만큼 요동친다. 그래서 dt를 실측해 쓰는 것이 중요하다(1.10.4) — 그러면 지터가 제어 이득의 변동이 아니라 정확한 계산으로 흡수된다.
$$u_d = K_d \frac{e_k - e_{k-1}}{\Delta t_{\text{측정}}} \quad \text{(고정 } T \text{ 대신 실측값)}$$
견해: "타이머 주기 = dt"라고 쓴 제어 코드는 지터에 취약하다. 실측 dt를 쓰는 것은 몇 줄이고, 지터를 상당 부분 무해화한다. 다만 실측 dt가 이상값일 때(1.10.4의 방어) 처리를 함께 넣어야 한다.
2.5.4 지연된 주기 — 따라잡을 것인가 버릴 것인가
콜백이 15 ms 걸리는데 주기가 10 ms라면? 또는 시스템이 잠깐 멈춰 5주기를 놓쳤다면?
① 따라잡기(catch-up) — 지연된 만큼 연속 발화.
- 장점: 총 실행 횟수가 보존된다. 계수 기반 로직에 맞다.
- 단점: 폭주. 5주기를 놓쳤으면 콜백이 5번 연속 실행되고, 그동안 다른 콜백이 기아 상태가 된다(2.2.9). 제어에서는 위험하다 — 같은 명령이 연속으로 나간다.
② 건너뛰기(skip) — 지연된 주기를 버리고 다음 정상 시각으로.
- 장점: 폭주 없음. 실시간 제어에 적합.
- 단점: 실행 횟수가 줄어든다.
실행 확인(2026-08-16): Jazzy의 기본 동작은 ②(건너뛰기)다 — 세 주기를 놓쳤을 때 발화는 한 번뿐이었고 연속 폭주가 없었다(2.5.12.2). 다만 안전한 접근은 어느 쪽이든 콜백이 스스로 방어하는 것이며, 그 이유는 2.5.12.2 끝에 적었다.
void on_timer() {
auto now = clock_->now();
auto dt = (now - last_).seconds();
last_ = now;
// 정상 범위를 벗어나면 이번 주기를 건너뛴다
if (dt < period_s_ * 0.5) {
// 따라잡기로 인한 연속 발화 — 이번은 무시
RCLCPP_DEBUG(get_logger(), "따라잡기 발화 감지 (dt=%.4f) — 건너뜀", dt);
return;
}
if (dt > period_s_ * 3.0) {
// 크게 밀림 — 상태를 재초기화하고 이번 계산은 생략
RCLCPP_WARN(get_logger(), "주기 %.0f배 지연 (dt=%.4f) — 상태 재설정",
dt / period_s_, dt);
reset_state();
return;
}
control_step(dt);
}
이 패턴은 제어 노드의 표준 방어다. 타이머의 정확한 정책을 몰라도 안전하게 동작한다.
2.5.5 지연의 원인 사슬
콜백이 목표 시각보다 늦게 실행되는 원인을 발생 순서대로 나열하면 어디를 고쳐야 할지 보인다.
| # | 원인 | 전형적 크기 | 대처 |
|---|---|---|---|
| ① | 스케줄러 해상도 | 커널 설정 의존 | PREEMPT_RT·고해상도 타이머(12.2) |
| ② | 다른 콜백 실행 중 | 그 콜백의 실행 시간만큼 | executor·그룹 분리(2.3·2.4) |
| ③ | CPU 경합 | 부하 의존, 최악은 큼 | RT 우선순위·CPU 격리(12.2) |
| ④ | 컨텍스트 스위치 | 마이크로초 수준 | 코어 고정(affinity) |
| ⑤ | executor 오버헤드 | 엔티티 수 의존(2.2.7) | executor 분리 |
| ⑥ | 락·할당·페이지 폴트 | 최악의 경우 매우 큼 | 무할당 경로·메모리 잠금(12.3) |
견해: 대부분의 시스템에서 지배적 원인은 ②와 ③이다. 즉 executor 구성과 CPU 경합이다. ①·④를 걱정하기 전에 "이 타이머가 다른 콜백과 executor를 공유하고 있는가" 를 먼저 확인하라. 전용 executor + 전용 스레드로 옮기는 것만으로 지터가 크게 줄어드는 경우가 많다(2.3.8).
⑥의 페이지 폴트가 숨은 위험 요인이다. 처음 접근하는 메모리 페이지에서 폴트가 나면 수십 마이크로초~밀리초가 소요될 수 있다. 실시간 경로에서는 mlockall()로 메모리를 잠그고 미리 워밍업하는 것이 표준 대응이다(12.3).
2.5.6 측정 — 실제 주기 분포 보기
평균만 봐서는 안 된다. 평균 10.0 ms인데 최악이 80 ms일 수 있고, 제어에서 중요한 것은 그 최악값이다.
class TimerMonitor {
public:
void tick() {
auto now = std::chrono::steady_clock::now(); // 단조 시계(1.10)
if (last_.time_since_epoch().count() != 0) {
double dt_ms = std::chrono::duration<double, std::milli>(now - last_).count();
samples_.push_back(dt_ms);
if (dt_ms > max_) max_ = dt_ms;
if (dt_ms < min_) min_ = dt_ms;
}
last_ = now;
}
void report(const rclcpp::Logger & log) {
if (samples_.empty()) return;
std::sort(samples_.begin(), samples_.end());
auto pct = [this](double p) {
return samples_[static_cast<size_t>(p * (samples_.size() - 1))]; };
RCLCPP_INFO(log,
"주기(ms) n=%zu min=%.2f p50=%.2f p99=%.2f p99.9=%.2f max=%.2f",
samples_.size(), min_, pct(0.50), pct(0.99), pct(0.999), max_);
samples_.clear(); min_ = 1e9; max_ = 0;
}
private:
std::chrono::steady_clock::time_point last_{};
std::vector<double> samples_;
double min_ = 1e9, max_ = 0;
};
보고할 것은 백분위수다. p50(중앙값)은 "보통 이렇다", p99·p99.9는 "가끔 이렇게까지 악화된다", max는 "최악". 제어 안전성을 논할 때 쓰는 값은 p99.9와 max이지 평균이 아니다.
함정:
samples_를 실시간 콜백 안에서push_back하면 재할당이 일어난다(12.3). 측정 코드가 측정 대상을 방해하는 전형적 사례다.reserve()로 미리 할당하거나 고정 크기 링버퍼를 쓴다.
외부 측정:
ros2 topic hz /some_topic --window 1000 # 발행 주기(CLI 측정 한계 있음 — 0.7.6)
가장 정확한 것은 트레이싱이다. ros2_tracing은 커널 수준에서 콜백 진입·이탈 시각을 기록하므로 측정 코드가 대상을 방해하지 않는다(11.6).
지연 분포를 보는 법:
| 지표 | 의미 |
|---|---|
| p50이 T보다 크다 | 드리프트 — 구조적으로 주기를 못 지킨다 |
| p50 ≈ T, p99가 크다 | 지터 — 가끔 지연된다 |
| max가 p99의 수 배 | 드문 대형 지연 — GC·페이지 폴트·다른 프로세스 |
| 시간에 따라 증가 | 누수·큐 적체 |
2.5.7 시뮬레이션 시간과 타이머
1.10.3에서 언급한 함정을 여기서 정밀하게 본다.
| 타이머 종류 | 기준 시계 | 시뮬 배속 영향 |
|---|---|---|
create_wall_timer(T, cb) | 벽시계(steady) | 없음 — 실시간 주기 유지 |
rclcpp::create_timer(node, clock, T, cb) | 지정한 시계 | use_sim_time이면 시뮬 시간 |
0.1배속 시뮬에서 무슨 일이 일어나는가:
wall timer 100 ms:
실제 시간 100 ms마다 발화 → 시뮬 시간으로는 10 ms마다 발화
→ 시뮬 시간 기준으로 10배 빠르게 실행되는 셈
ROS clock timer 100 ms:
시뮬 시간 100 ms마다 발화 → 실제로는 1초마다
→ 의도한 동작
제어 노드가 wall timer를 쓰면 시뮬에서 제어 주기가 실제와 완전히 달라진다. 시뮬로 튜닝한 게인이 실기에서 안 맞는 원인 중 하나가 이것이다(10.6).
견해: 시뮬레이션을 쓰는 시스템이라면 "제어·주기 로직에는 노드 시계 기반 타이머, 벽시계가 꼭 필요한 것(하드웨어 폴링·워치독)에만 wall timer" 를 규칙으로 두라. 그리고 이 규칙이 지켜지는지 코드 리뷰에서 확인하라 —
create_wall_timer를 grep하는 것만으로 점검된다.
grep -rn "create_wall_timer" src/ | grep -v "watchdog\|hardware"
2.5.8 타이머로 안 되는 정밀도가 필요할 때
| 요구 | 접근 |
|---|---|
| 수 ms 지터 허용 | 일반 타이머 + executor 분리(2.3.8) |
| 1 ms 이하 지터 | PREEMPT_RT + RT 우선순위 + CPU 격리(12.2) |
| 수십 µs | 위 + 무할당 경로 + 메모리 잠금(12.3), ROS 2 밖 스레드 검토 |
| 마이크로초 이하 | ROS 2 타이머로는 불가. 하드웨어 타이머·인터럽트·FPGA·MCU(6.8) |
"ROS 2 밖 스레드" 패턴: 제어 루프를 ROS 2 executor가 아니라 자체 RT 스레드로 실행하고, ROS 2와는 락프리 큐로만 통신한다. ros2_control이 정확히 이 구조다(6.6) — 제어 루프는 별도 스레드에서 실행되고, ROS 인터페이스는 그 주변에 배치된다.
// 개념 — RT 스레드가 직접 주기를 관리
void rt_loop() {
set_realtime_priority(90); // 12.2
mlockall(MCL_CURRENT | MCL_FUTURE); // 12.3
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
while (running_) {
next.tv_nsec += period_ns_;
normalize(next);
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, nullptr); // 절대 기준
control_step(); // 할당·락·로깅 금지
}
}
clock_nanosleep에 TIMER_ABSTIME을 쓰는 것이 핵심이다 — 절대 시각 기준이라 드리프트가 누적되지 않는다(2.5.2의 절대 기준 방식).
견해: 이 경로로 가기 전에 요구사항을 다시 확인하라. "제어 주기가 1 ms여야 한다"는 요구가 실제로는 "제어가 안정적이어야 한다"인 경우가 많고, 후자는 dt 실측(2.5.3)과 executor 분리로 해결되는 경우가 흔하다. RT 경로는 개발·검증·유지 비용이 크다.
2.5.9 타이머 관련 실무 팁
① 여러 주기가 필요하면 타이머를 나눈다 — 하나의 빠른 타이머 안에서 카운터로 분주하는 것보다, 주기별 타이머를 두는 편이 명확하고 executor가 스케줄링을 돕는다. 단, 엔티티 수가 늘어나는 비용은 있다(2.2.7).
② 타이머를 일시 정지·재개할 수 있다.
timer_->cancel(); // 정지
timer_->reset(); // 재시작 (다음 발화 시각 재계산)
bool stopped = timer_->is_canceled();
라이프사이클 노드에서 필수다(1.12.3).
③ 일회성 타이머 — 타임아웃 구현에 쓴다.
timeout_timer_ = create_wall_timer(3s, [this]() {
timeout_timer_->cancel(); // 스스로 정지 → 일회성
handle_timeout();
});
④ 콜백 안에서 주기를 바꿀 수 없다 — 타이머를 다시 만들어야 한다(1.9.6의 post_set 예).
⑤ 첫 발화 시각 — 타이머는 생성 시점부터 한 주기 뒤에 처음 발화한다. 즉시 한 번 실행이 필요하면 명시적으로 호출한다.
2.5.12 실측 — TimerInfo로 확인한 Jazzy 타이머의 실제 정책
번호에 대하여: 이 절은 나중에 추가되었다. 규약(CLD-03)에 따라 번호는 끝에 붙이고, 읽는 흐름을 위해 함정 카탈로그 앞에 놓았다.
2.5.2와 2.5.4가 각각 (확인 필요)로 남긴 두 질문 — 절대 기준인가 상대 기준인가, 지연된 주기를 따라잡는가 버리는가 — 을 실측으로 확정한다.
2.5.12.1 rclcpp::TimerInfo — Jazzy가 준 측정 수단
Jazzy에서 타이머 콜백이 예정 시각과 실제 호출 시각을 함께 받을 수 있게 됐다(0.3.4). 헤더는 이렇다 — 원문 확인.
namespace rclcpp {
struct TimerInfo {
Time expected_call_time; // 이 발화가 "예정되어 있던" 시각
Time actual_call_time; // 실제로 콜백이 불린 시각
};
}
콜백을 인자 없는 형태로 쓰던 것을 const TimerInfo &를 받는 형태로 바꾸기만 하면 된다(rclcpp::TimerInfoCallbackType).
timer_ = create_timer(100ms, [this](const rclcpp::TimerInfo & info) {
auto lateness = (info.actual_call_time - info.expected_call_time).seconds();
if (lateness > 0.010) {
RCLCPP_WARN(get_logger(), "타이머 %.1f ms 지연", lateness * 1000);
}
control_step();
});
이 한 줄이 2.5.6의 측정 코드를 상당 부분 대체한다. 직접 이전 시각을 들고 있으며 dt를 계산하는 대신, 프레임워크가 "예정과 실제"를 알려 준다 — 지연을 측정하는 정확한 방법이다. 다만 이것은 발화 지연을 측정하는 것이지 콜백 소요 시간이 아니다(그것은 여전히 직접 측정해야 한다).
2.5.12.2 실측 — 3주기를 일부러 놓쳐 보면
100 ms 타이머를 설정하고 다섯 번째 콜백에서 350 ms를 블로킹해 세 주기를 놓치게 했다.
#01 expected=0.100 actual=0.100 (지연 0.000)
…
#05 expected=0.500 actual=0.500 (지연 0.000)
↳ 350ms 블로킹 (3주기 유실 유도)
#06 expected=0.600 actual=0.850 (지연 0.250)
#07 expected=0.900 actual=0.900 (지연 0.000)
#08 expected=1.000 actual=1.000 (지연 0.000)
실행 확인(2026-08-16). Jazzy 컨테이너에서 위 코드를 컴파일·실행한 결과다.
세 가지가 한꺼번에 드러난다.
| 질문 | 답 | 근거 |
|---|---|---|
| 절대 기준인가 상대 기준인가 | 절대 기준 | expected가 0.100·0.200…0.900·1.000으로 100 ms 격자를 유지한다. 350 ms 블로킹 뒤에도 격자가 어긋나지 않았다 |
| 지연된 주기를 따라잡는가 | 아니다 — 버린다 | 0.600·0.700·0.800 세 주기가 지났는데 발화는 한 번(#06)뿐이고, 다음은 0.900이다. 연속 폭주가 없다 |
지연된 발화의 expected는? | 가장 오래된 놓친 주기의 시각 | #06의 expected가 0.600(놓친 첫 주기)이고 actual이 0.850이다 — 지연 0.250이 그대로 보인다 |
즉 Jazzy의 타이머는 "절대 기준 + 재동기화" 로 동작한다 — 2.5.2가 "혼합 방식도 가능하다"고 적어 둔 바로 그 형태다.
실무적으로 좋은 소식이다. 2.5.4가 경고한 따라잡기 폭주는 기본 동작에서 일어나지 않는다. 5주기를 놓쳐도 콜백이 5번 연속 실행되지 않으므로, 제어 루프가 지연된 뒤 같은 명령을 연달아 대량 송출하는 사고는 최소한 타이머 계층에서는 생기지 않는다.
그럼에도 2.5.4의 방어 코드는 여전히 값을 한다. 이유 셋. ① 이 결과는 Jazzy 기준이며 다른 배포판·
rclpy·다른 실행기 구성에서 같다는 보장이 없다(확인 필요). ② 건너뛰기라도 한 번의 발화가 크게 늦게 올 수 있다(위의 0.250 s) —dt > period × 3분기는 그것을 검출한다. ③ 이제는TimerInfo로 지연을 직접 알 수 있으므로, 방어 조건을 추측이 아니라 실제 지연값으로 쓸 수 있다.
견해: 제어 노드라면
TimerInfo콜백을 기본으로 쓰라. 인자 하나 추가하는 비용으로 "이 주기가 얼마나 늦었나"를 항상 알 수 있고, 그 값을 진단으로 내보내면(6.9) 현장에서 주기 문제를 사후에 재구성할 수 있다. 11.7.12의 토픽 통계가 수신 쪽 주기를 보여 준다면,TimerInfo는 실행 쪽 주기를 보여 준다.
2.5.13 드리프트·지터에 숫자를 부여했다 — 실측
2.5.12는 타이머의 정책(절대 기준·건너뛰기)을 확인했다. 이 절은 그래서 실제로 얼마나 정확한가를 측정한다. 목표 주기 10 ms, 1500회(15초), -O2(실행 확인, Jazzy).
2.5.13.1 결과
| 구성 | 평균 | p50 | p99 | 최대 | 누적 오차 |
|---|---|---|---|---|---|
SingleThreadedExecutor · 빈 콜백 | 10.000 | 10.000 | 10.047 | 10.528 | +0.0 ms (0.000%) |
MultiThreadedExecutor(4) · 빈 콜백 | 10.000 | 10.000 | 10.048 | 11.161 | +0.0 ms |
StaticSingleThreadedExecutor · 빈 콜백 | 10.000 | 9.999 | 10.040 | 10.125 | −0.0 ms |
| Single · 콜백이 5 ms 소모 | 10.000 | 9.999 | 10.075 | 10.179 | +0.0 ms |
| Single · 콜백이 12 ms 소모 | 12.072 | 12.066 | 12.123 | 12.176 | +3,105 ms (+20.7%) |
(단위 ms. 누적 오차 = 실제 총 경과 − 이상적 총 경과)
2.5.13.2 드리프트는 정말 0이었다
1500 주기 동안 누적 오차가 ±0.0 ms, 백분율로 0.000%다. 2.5.2의 "절대 기준 → 드리프트 없음"이 수치로 확인된다.
이 값을 상대 기준과 비교해 보면 차이가 실감된다. 매 주기 평균 0.05 ms씩만 지연돼도 상대 기준이라면 1시간에 $0.05\,\text{ms} \times 360{,}000 = 18\,\text{초}$가 어긋난다. Jazzy 타이머에서는 그 항이 전혀 없다.
그러므로 "장시간 운용에서 로그 타임스탬프가 조금씩 어긋난다" 같은 증상은 타이머 드리프트가 아니다. 시계 동기(1.10.8)나 스탬프 생성 지점(2.5.7)을 보라.
2.5.13.3 지터의 실제 크기 — 그리고 executor가 그것을 바꾼다
2.5.6은 "p50·p99·max를 보라"고 했지만 기준값이 없었다. 평범한 컨테이너·비-RT 커널에서의 값이 위 표다.
| executor | p99 − 목표 | 최대 − 목표 |
|---|---|---|
StaticSingleThreadedExecutor | +0.040 ms | +0.125 ms |
SingleThreadedExecutor | +0.047 ms | +0.528 ms |
MultiThreadedExecutor(4) | +0.048 ms | +1.161 ms |
세 executor의 p50·평균은 같고 꼬리만 다르다. 순서도 분명하다 — Static이 가장 좁고, Multi가 가장 넓다.
MultiThreadedExecutor가 타이머 하나에는 손해다. 스레드가 넷이어도 할 일은 하나인데, 깨우기·경합 비용이 꼬리에 더해진다(최대 지연이 Single의 2배). 2.3의 선택 기준에 이 축을 더한다 — "콜백이 서로 블로킹하지 않는가"가 기준이지 "스레드가 많으면 빠른가"가 아니다.
StaticSingleThreadedExecutor의 이득이 여기 있었다. 12.3.12.1은 이 executor가 할당 횟수에서는 이득이 없다(회당 9회로 동일)고 적었는데, 꼬리 지연에서는 가장 좋다. 두 축의 결과가 다르므로 함께 읽어야 한다.
2.5.13.4 콜백 실행 시간의 영향은 급격한 붕괴다
| 콜백 소모 | 달성 주기 | 누적 오차 |
|---|---|---|
| 0 ms | 10.000 ms | 0.000% |
| 5 ms(주기의 50%) | 10.000 ms | 0.000% |
| 12 ms(주기의 120%) | 12.072 ms | +20.7% |
주기의 절반을 써도 아무 영향이 없다. 그러다 주기를 넘는 순간 주기가 콜백 시간에 눌린다 — 12 ms 콜백이면 12.07 ms 주기가 되고, 따라잡기는 일어나지 않는다(2.5.12.2의 건너뛰기 정책과 일치).
예산 기준을 이렇게 세우라. "콜백이 주기의 몇 %를 쓰는가"를 측정하고, 100%를 넘기지 않는 한 주기는 지켜진다. 다만 넘는 순간의 저하가 완만하지 않으므로(주기가 곧바로 콜백 시간이 된다) 여유를 얇게 두지 말라 — 12.4의 지연 예산이 이 지점을 다룬다.
2.5.13.5 확인하지 못한 것
- RT 커널·
chrt적용 시의 값은 측정하지 않았다(확인 필요)— 위는 일반 커널·컨테이너 값이고, 12.2의 설정을 적용하면 꼬리가 좁아질 것으로 보이나 확인하지 않았다. - 부하가 걸린 시스템에서의 값도 측정하지 않았다 — 측정 중 다른 작업이 거의 없었다.
rclpy타이머는 측정하지 않았다(확인 필요).
2.5.10 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | 주기 상수를 dt로 사용 | 지터가 제어에 그대로 유입 | 실측 dt(2.5.3) |
| 2 | 시뮬에서 wall timer | 제어 주기가 실제와 다름 | 노드 시계 타이머 |
| 3 | 평균 주기만 확인 | 최악 지연을 못 봄 | p99·max 측정 |
| 4 | 측정 코드가 할당 | 측정이 대상을 방해 | reserve·링버퍼 |
| 5 | 지연된 주기 미방어 | 따라잡기 폭주 또는 상태 오염 | dt 범위 검사 |
| 6 | 제어 타이머를 공용 executor에 | 지터 | 전용 executor·스레드 |
| 7 | 콜백이 주기보다 김 | 다른 콜백 기아(2.2.9) | 경량화·분리 |
| 8 | 페이지 폴트 미고려 | 드문 대형 지연 | mlockall·워밍업(12.3) |
| 9 | 라이프사이클에서 타이머 미정지 | 비활성인데 동작 | cancel()(1.12.4) |
| 10 | 주기 변경을 파라미터로 시도 | 반영 안 됨 | 타이머 재생성 |
| 11 | µs 정밀도를 ROS 타이머에 기대 | 요구 미달 | RT 스레드·하드웨어 |
| 12 | 절대/상대 기준 확인 없이 장시간 운용 | 누적 드리프트 | 실측 확인(2.5.6) |
| 13 | 지연된 주기를 따라잡을 것으로 기대 | Jazzy는 건너뛴다(절대 기준 격자 유지) | 실측 표 2.5.12 |
| 14 | 타이머 하나에 MultiThreadedExecutor | 꼬리 지연이 2배 | 콜백이 서로 막지 않으면 단일로(2.5.13.3) |
| 15 | 장시간 어긋남을 타이머 드리프트로 의심 | 드리프트는 0.000% | 시계 동기·스탬프 지점을 보라(2.5.13.2) |
2.5.11 체크포인트
- 타이머의 다음 발화 시각 계산 방식 두 가지와, 그 차이가 장시간 운용에서 어떻게 나타나는지 말할 수 있는가.
- 드리프트와 지터를 정의로 구분하고, 각각의 원인과 대처를 댈 수 있는가. 제어에서 어느 쪽이 더 아픈가.
- 실측 dt를 쓰는 것이 지터를 무해화하는 원리를 수식으로 설명할 수 있는가.
- 지연된 주기를 따라잡는 방식과 버리는 방식의 장단은? 어느 쪽이든 안전한 콜백 방어 코드를 쓸 수 있는가.
- 타이머 지연의 원인 여섯 단계를 순서대로 말하고, 대부분의 시스템에서 지배적인 둘이 무엇인지 답할 수 있는가.
- 주기를 측정할 때 평균이 아니라 백분위수를 봐야 하는 이유는? 제어 안전성 논의에 쓰는 값은?
- 측정 코드 자체가 측정을 방해하는 전형적 실수는 무엇인가.
- 0.1배속 시뮬레이션에서 wall timer와 노드 시계 타이머가 각각 어떻게 실행되는지 설명할 수 있는가.
- 코드베이스에서 시뮬 관련 타이머 문제를 한 줄 명령으로 점검하는 방법은?
- 마이크로초 수준 정밀도가 필요할 때의 접근과, 그 경로로 가기 전에 확인할 것은 무엇인가.
clock_nanosleep에TIMER_ABSTIME을 쓰는 이유는?- 1500 주기 동안 누적 드리프트는 얼마였는가. 그 값이 진단에 뜻하는 바는(2.5.13.2).
- 세 executor의 p50은 같은데 무엇이 달랐는가. 타이머 하나에 어느 것을 쓸 것인가(2.5.13.3).
- 콜백이 주기의 50%를 쓸 때와 120%를 쓸 때 달성 주기는 각각 어떻게 되는가(2.5.13.4).
다음 장 2.6은 지터의 숨은 원인 중 하나였던 메모리 — allocator, 할당이 일어나는 지점, loaned message — 를 다룬다.