AEGLOSLAB ROS 2 Jazzy 대전

제2부 실행 모델·클라이언트 라이브러리 내부

2.2 wait set과 executor의 원리

2.2.1 이 장에서 배우는 것 / 선수 지식

제1부 내내 "콜백은 executor 스레드에서 실행된다"는 사실이 문제의 근본 원인으로 반복 등장했다 — 콜백이 실행되지 않는다(1.4), 서비스 데드락(1.7), 액션 실행이 전체를 막는다(1.8). 이제 그 executor를 분해한다.

핵심 질문은 하나다. "엔티티는 수십 개인데 스레드는 하나다. 무엇을 언제 실행할지 어떻게 정하는가?" 이 장은 그 메커니즘 — wait set과 executor 루프 — 을 원리 수준에서 세운다. 종류별 선택은 2.3, 동시성 제어는 2.4에서 이어받는다.

배우는 것:

  • 근본 문제 — 왜 폴링도 스레드-per-엔티티도 답이 아닌가.
  • wait set의 정체 — 무엇이 들어가고, rmw_wait가 아래에서 무엇을 하는가.
  • executor 루프의 정확한 단계 — 수집·대기·선별·실행.
  • guard condition — 대기 중인 executor를 깨우는 장치.
  • spin 계열 함수들의 차이 — spin·spin_some·spin_all·spin_once.
  • wait set 재구성 비용 — 엔티티를 자주 만들고 없애면 왜 느려지는가.
  • 공정성과 기아(starvation) — 어떤 콜백이 지연되는가, 왜 그런가.

선수 지식: 2.1(계층), 1.4(토픽 경로), 1.7(데드락 — 여기서 원리적으로 설명된다).

정직: executor 내부 구현은 배포판을 거치며 상당히 변경되어 왔다(엔티티 수집 방식·wait set 재사용·spin_all 도입 등). 아래 서술은 개념적 모델이며, Jazzy의 정확한 구현 세부는 rclcpp/executor.cpp가 정본이다(확인 필요). 이 환경에서 대부분 소스 대조·실행 검증하지 않았다(목차 3) — 예외는 guard condition API로, 89차에 헤더와 실행 확인해 이 장의 예시를 정정했다(2.10.11.3). 다만 관찰 가능한 동작(무엇이 언제 실행되는가)은 여기 서술과 대체로 일치한다.

2.2.2 근본 문제 — 왜 wait set인가

노드 하나에 구독 5개, 타이머 3개, 서비스 2개, 액션 1개가 있다고 하자. 데이터는 언제 올지 모르고, 타이머는 각자 다른 주기다. 가능한 접근 셋:

① 폴링(polling) — 루프를 반복하며 각 엔티티에 "데이터 있니?"를 묻는다.

  • 문제: 데이터가 없어도 CPU를 100% 쓴다. 임베디드·배터리 시스템에서 즉시 탈락.

② 엔티티마다 스레드 — 각 구독이 자기 스레드에서 블로킹 대기.

  • 문제: 스레드 수십~수백 개. 컨텍스트 스위치 비용, 메모리, 그리고 동기화 지옥. 실시간성도 악화된다.

③ wait set — 모든 엔티티를 하나의 집합에 넣고, "이 중 아무거나 준비되면 깨워 달라" 고 OS·미들웨어에 부탁한다.

  • 준비된 것이 없으면 스레드가 잠든다(CPU 0%).
  • 하나라도 준비되면 깨어나 준비된 것들의 목록을 받는다.

select·poll·epoll의 개념과 같다. ROS 2는 ③을 택했고, 그 구현체가 wait set이다.

구독 A wait set 구독 B 타이머 1 서비스 X guard condition rmw_wait() 준비될 때까지 잠듦 준비된 엔티티 목록 executor가 하나씩 실행

2.2.3 wait set에 무엇이 들어가나

엔티티준비 조건
구독(subscription)수신 큐에 메시지가 있다
타이머(timer)만료 시각이 지났다
서비스 서버요청이 도착했다
서비스 클라이언트응답이 도착했다
guard condition명시적으로 신호됐다
이벤트(QoS 이벤트)이벤트가 발생했다(1.4.6)
waitable복합 엔티티(액션·구독 옵션 등)가 자체 판단

waitable이 흥미롭다. 액션(1.8)은 서비스 3개 + 토픽 2개의 묶음인데, executor에게는 하나의 waitable로 보인다. waitable은 "나는 이런 하위 엔티티들을 갖고 있다"고 스스로 등록하고, 준비 여부를 스스로 판단한다. 이 확장 지점 덕분에 executor가 액션의 내부 구조를 몰라도 된다.

타이머는 특별하다. 다른 엔티티는 "데이터가 오면" 준비되지만 타이머는 시각으로 준비된다. 그래서 rmw_wait에는 타임아웃이 함께 전달된다 — 가장 가까운 타이머 만료까지의 시간이다. 데이터가 안 와도 그 시각에는 깨어나야 하기 때문이다.

데이터 도착 타임아웃 guard condition executor가 대기 준비 가장 이른 타이머 만료 시각 계산 rmw_wait(엔티티들, 타임아웃) 무엇으로 깨어났나 해당 엔티티 준비 타이머 만료 구성 변경·종료

2.2.4 rmw_wait가 아래에서 하는 일

rmw_wait는 벤더 구현이 제공하며, 전형적으로 DDS의 WaitSet + StatusCondition 또는 조건변수 기반으로 구현된다.

계층하는 일
rclcpp::Executor엔티티 수집, 콜백 그룹 정책 적용, 콜백 실행
rcl_waitrcl 엔티티를 rmw 핸들로 변환
rmw_wait벤더 대기 메커니즘 호출, 준비된 것 표시
DDSDataReader의 상태 변화를 조건으로 알림

중요한 결과: 데이터가 도착하는 것과 콜백이 실행되는 것 사이에는 최소한 한 번의 스레드 깨우기가 있다. 이것이 ROS 2의 기본 지연 요소이며, 마이크로초 단위 제어가 필요한 경우 이 경로가 병목이 된다(12.1·12.4).

대안이 존재한다 — 리스너 기반(events) 방식. DDS의 리스너 콜백을 직접 받아 큐에 넣고, executor는 그 큐만 소비하는 구조다. wait set 재구성이 없어 엔티티가 많을 때 유리하다. 이것이 EventsExecutor의 접근이며 2.3에서 다룬다.

2.2.5 executor 루프의 정확한 단계

예 아니오 spin() 시작 ① 엔티티 수집 노드들의 콜백 그룹 순회 ② wait set 구성 ③ rmw_wait — 잠듦 ④ 준비된 엔티티 선별 ⑤ 콜백 그룹 정책 확인 ⑥ 콜백 실행 종료? 반환

각 단계에서 알아야 할 것:

① 엔티티 수집 — executor에 등록된 모든 노드의 모든 콜백 그룹을 순회해 엔티티를 모은다. 노드나 엔티티가 추가·제거되면 다시 해야 한다. 이것이 비용이다(2.2.7).

③ 대기 — 준비된 것이 없으면 스레드가 잠든다. CPU를 쓰지 않는다.

④ 선별 — 준비된 엔티티가 여럿일 수 있다. 여기서 실행 순서가 결정된다.

⑥ 실행 — 콜백이 동기적으로 호출된다. 콜백이 끝나야 다음으로 간다(단일 스레드 기준).

핵심: ⑥에서 콜백이 오래 걸리면 그동안 ①~⑤가 진행되지 않는다. 데이터는 계속 도착해 큐에 쌓이고(1.4.4의 ③), 타이머는 만료되고, 서비스 요청은 대기한다. 1.7의 데드락은 이 구조의 필연적 결과다 — 콜백 안에서 다른 콜백의 실행을 기다리면 ⑥이 끝나지 않아 ①로 돌아갈 수 없다.

2.2.6 guard condition — 대기 중인 executor 깨우기

executor가 rmw_wait에서 자고 있는데, 다음이 필요할 때 어떻게 깨우는가?

  • 새 노드·구독이 추가됐다 → wait set을 다시 만들어야 한다.
  • shutdown()이 호출됐다 → 루프를 끝내야 한다.
  • 다른 스레드가 executor에 무언가 시켰다.

guard condition이 그 장치다. 데이터 없이 "깨어나라"는 신호만 전달하는 엔티티이며, wait set에 항상 포함된다.

상황guard condition 신호
노드 추가·제거executor의 내부 guard condition
엔티티 생성·소멸노드의 guard condition
rclcpp::shutdown()context의 guard condition
사용자 정의 깨우기직접 만든 guard condition
// 사용자 코드에서 executor를 깨우고 싶을 때
auto gc = std::make_shared<rclcpp::GuardCondition>();
wait_set.add_guard_condition(gc);   // Executor 에는 이 메서드가 없다 — WaitSet 쪽이다 (89차 실행 확인, 2.10.11.3)
// 다른 스레드에서
gc->trigger();

함정: 엔티티를 콜백 안에서 만들고 없애면 매번 guard condition이 신호되고 wait set이 재구성된다. 고빈도로 하면 성능이 급격히 악화된다. 엔티티는 초기화 시점에 만들고 유지하는 것이 원칙이며, 동적 생성이 필요하면 빈도를 통제해야 한다.

2.2.7 wait set 재구성 비용

엔티티 수를 $N$이라 하면, wait set 구성은 대략 $O(N)$이다. 매 루프마다 이것을 다시 한다면, 콜백 하나 실행할 때마다 $O(N)$을 지불하는 셈이다.

$$\text{루프당 오버헤드} \approx c_{\text{collect}} \cdot N + c_{\text{wait}}$$

$N$이 수십이면 무시할 만하지만, 노드 수십 개를 한 executor에 넣고 각각 엔티티가 여럿이면 $N$이 수백이 되고 오버헤드가 눈에 띈다. 이것이 다음 설계 지침의 근거다.

지침이유
엔티티는 초기화 때 만들고 유지재구성 회피
한 executor에 노드를 무한정 넣지 않는다$O(N)$ 누적
고빈도 노드는 별도 executor·프로세스로다른 노드의 $N$에 영향받지 않음
컨테이너는 isolated 고려노드별 executor(1.13.4)
엔티티가 아주 많으면 EventsExecutor 검토재구성 없는 구조(2.3)

정직: 최신 배포판들은 이 비용을 줄이는 최적화를 도입해 왔다(수집 결과 캐시·wait set 재사용 등). Jazzy에서 실제 오버헤드가 얼마인지는 (확인 필요)이며, 문제가 의심되면 ros2_tracing으로 executor 루프 자체를 측정해야 한다(11.6). 읽기 전에: 아래의 "실제 동시 실행"·"스레드 경합"이라는 표현은 뒤쪽 119차 정정 대상이다 — MultiThreadedExecutor 실험은 전부 단일 기본 MutuallyExclusive 그룹을 썼기 때문에 실제로는 한 번도 동시에 실행되지 않았다(동시성 비율 0.0000). 이탈률 수치는 유효하다. 108차가 이 최적화의 정체와 부작용을 소스로 확정했다 — Jazzy는 ros2/rclcpp PR #2142로 기본 Single/MultiThreaded executor를 "정적 executor처럼" 캐시된 엔티티 컬렉션으로 바꿨는데, 그 캐시 컨테이너가 std::unordered_map(rcl 핸들 포인터 키)이라 재구성 비용은 줄지만 같은 타입 엔티티 여러 개 사이의 콜백 순서가 등록 순서와 무관해지고 실행마다 소수 확률로 흔들린다 — 성능과 순서 보장을 맞바꾼 사례다. 109차가 이 대가를 구독 외 네 엔티티 종류(타이머·서비스·클라이언트·waitable)로 확장 확인했다 — ExecutorEntitiesCollection은 이 여섯 컨테이너(타이머·구독·클라이언트·서비스·guard condition·waitable)를 독립된 인스턴스로 각각 갖고 있어, 다섯 종류 모두 등록 순서를 잃긴 하지만 이탈 빈도는 종류마다(0%~45%) 다르다. 또한 이 재구성 최적화와 무관한 MultiThreadedExecutor의 실제 동시 실행에서는 std::unordered_map이 없는 Humble에서도 20~30% 순서가 흔들린다 — 스레드 경합이라는 별개의 순서 비결정성 원인이 있다는 뜻이다. 112차가 이 차이(구독·스레드4, Jazzy 45.0% 대 Humble 8.3%, 각 60회)를 두-비율 z-검정으로 통계적 유의성까지 확정했고($p=6\times10^{-6}$), MultiThreadedExecutor를 타이머·서비스·클라이언트·guard condition waitable로 확장하니 waitable은 해시맵 유무와 무관하게 Jazzy·Humble 양쪽에서 스레드 2개 이상이면 100% 유실됐다 — 이 재구성 최적화(해시맵)와 스레드 경합이 서로 다른 축의 원인임을 다시 확인한다. 113차가 같은 검정을 타이머·서비스로 확대하니 구독과 달리 유의하지 않았다($p=0.65$·$0.22$, 각 60회) — 스레드 경합이 두 엔티티 종류에서는 워낙 압도적이라(타이머 6~19%, 서비스 68~85%) 해시맵이 보태는 차이가 통계로 드러날 만큼 크지 않은 것으로 보인다(견해). 114차가 서비스만 150회로 재검정하니 이 판단이 뒤집혔다 — Jazzy 54.0% 대 Humble 74.7%, $p=1.87\times10^{-4}$로 유의했다. 113차의 $p=0.072$(정+역 합산)는 표본 부족이 만든 경계선이었을 뿐이다. 115차가 타이머도 150회로 재검정하니 똑같이 뒤집혔다 — Jazzy 8.7% 대 Humble 26.7%, $p=4.36\times10^{-5}$. 즉 두 판단(서비스·타이머 "유의하지 않다") 전부 표본 부족이었던 것으로 최종 정정됐다. 같은 회차가 --privileged로 ASLR을 실제로 꺼서(108차 이후 최초) "ASLR이 해시 버킷 순서를 재추첨한다"는 가설도 반증했다 — 꺼도 이탈률이 0%가 되지 않았다(30.0%, 켬은 40.0%). 116차가 Humble rclcpp 소스로 확인했다 — 콜백 그룹 하나인 구성에서 AllocatorMemoryStrategy의 엔티티 수집은 해시·정렬 없는 순수 등록 순서 std::vector다. 즉 Humble의 MultiThreadedExecutor 이탈은 이 절이 다루는 최적화(해시맵)와 무관하고, 96차가 Jazzy에서 이미 확정한 스레드 스케줄링 문제와 같은 원인이다 — 다만 Humble이 Jazzy보다 더 불리한 정도의 이유(매 사이클 전체 재구성 비용 가설)는 소스만으로 확정하지 못했다. 117차가 CHG-122 계열의 stress-ng 통제 실험으로 이 스레드 경합 가설을 처음 정량 확인했다 — 호스트 CPU 부하를 코어 수까지 높이면 Jazzy의 구독·서비스 MultiThreadedExecutor 이탈률이 극적으로(예: 구독 35.0%→72.5%) 유의하게 상승한다($p=0.00077$). 같은 조작에 Humble의 구독은 전혀 반응하지 않아(4.0%→4.0%) — "스레드 경합"이라는 원인이 두 배포판에 똑같이 작용하지는 않는다는 것도 함께 드러났다. 118차가 소스를 직접 재구성 비용을 타임스탬프로 계측하니, "Humble이 매 사이클 전체를 재구성해서 더 비용이 크다"는 가설이 반증됐다 — wait_for_work() 호출당 평균 비용은 Jazzy가 오히려 1.3~1.8배 더 높았다(캐시 무효화 플래그가 이 테스트에서는 자주 걸려 캐싱 이점이 줄어든다). 같은 회차가 stress-ng 부하를 Humble 서비스·타이머로 확장하니 부하가 이탈률을 오히려 낮췄다(서비스 44.0%→4.0%) — Jazzy와 정반대 방향이다. 119차가 중대한 정정을 냈다 — 실제 실행 구간을 계측해 보니 이 실험 계열(gap109~118)에서 MultiThreadedExecutor의 실제 동시 실행은 한 번도 일어나지 않았다(동시성 비율 0.0000, 전 조건). N개 엔티티가 노드의 기본 MutuallyExclusive 콜백 그룹에 들어가 있어 애초에 병렬 실행이 불가능했기 때문이다 — 측정된 이탈률은 유효하지만 "스레드 스케줄링 경쟁"이라는 설명 메커니즘은 이 테스트 계열에 적용되지 않는다. 120차가 선택 비결정성의 새 후보(재구성-대기 연쇄)를 직접 계측했다 — 바쁜 그룹에 막힌 스레드가 wait_mutex_를 쥔 채 블로킹된다는 것은 480회 실측(겹침 비율 0.0000)으로 확정했고 Humble은 이 재구성이 항상(100%), Jazzy는 조건부(45~74%)라는 차이도 확인했지만, 이 빈도가 이탈률을 결정하는 인과적 손잡이라는 것은 확정하지 못했다 — Humble이 불리한 실제 이유는 여전히 열려 있다. 121차가 세 갈래를 더 조사했다 — rcl wait.c를 읽어 구독·서비스가 완전히 같은 방식으로 처리됨을 확인해 rcl 계층 구조 차이 가설을 반증했고, 재구성을 완전히 억제해도 이탈률이 줄지 않아 재구성 반복 자체는 필요조건이 아님을 확인했으며, 계측을 구독으로 확장하다가 117차의 "Humble 구독 무반응" 결론이 이번 세션에서는 재현되지 않는다는 것(오히려 유의하게 감소, 계측 부작용 아님을 순정 빌드로 확인)을 발견해 세션 간 재현성 문제로 이월했다. 122차가 그 재현성 문제를 직접 검증했다 — 호스트 배경 부하 가설을 /proc/loadavg 상관($r=-0.0149$)과 저강도 stress-ng 스윕으로 검증했지만 지지되지 않았다. rmw_fastrtps_shared_cpp까지 내려가도(네 계층째) 구독·서비스 코드 경로는 같았지만, 서비스가 요청+응답 DDS 엔티티 쌍을 갖는다는(구독은 하나) 구조적 비대칭을 새 후보로 찾았다. 재구성 억제 효과도 n=100에서는 사라졌다($p=0.88$). 123차가 이 가설을 클라이언트 재분석과 직접 조작으로 검증했지만 둘 다 불리했다 — 클라이언트도 엔티티 둘을 갖지만 가장 안정적이었고, 구독의 라이터를 제거해도 이탈 감소가 유의하지 않았다($p=0.49$·$0.36$) — 엔티티 개수는 주된 설명이 아닐 가능성이 크다. 124차가 도착 시각을 직접 재 새 후보(도착 시간 폭)를 찾았다 — 클라이언트 응답 도착 폭이 서비스 요청 도착 폭보다 약 5배 넓다(중앙값 12~14µs 대 61~64µs) — 서버가 상시 스핀하며 순차 응답하는 테스트 구조가 원인으로 보인다. Humble 구독 디스커버리 타이밍은 직접 계측(상관 $r=0.07$)으로 기각했지만, ARRIVAL_ORDER가 FIRE_ORDER보다 불안정하다는(32.0% 대 62.0% 최빈 비율) 뜻밖의 사실을 발견했다 — executor 디스패치가 어수선한 도착을 부분적으로 정돈한다는 뜻이다. 엔티티 개수 조작을 n=100으로 늘리니 Jazzy는 유의해졌지만($p=0.013$) Humble은 방향이 뒤집혔다($p=0.29$). 125차가 이 도착 시간 폭 가설을 직접 조작해 반증했다 — 서비스 테스트의 요청 사이 지연을 0~200µs로 늘려도 이탈률이 바뀌지 않았다(Jazzy는 오히려 비유의하게 증가). Humble의 "정돈" 메커니즘은 소스에서 찾았다 — AllocatorMemoryStrategy::remove_null_handles()가 매 rcl_wait() 후 벡터를 준비된 항목만 등록 순서로 남기고 get_next_subscription()은 항상 처음부터 순회해, 같은 배치에서 준비되면 항상 등록 순서로 나간다. 엔티티 개수와 도착 시간 폭도 직접 연결해 봤다 — 라이터가 있으면 도착 시간 폭이 90배 넓었지만(지연 샘플 패턴) 도착 시간 폭 자체는 이탈률을 안 바꾸므로 둘은 같은 효과가 아니다. 126차가 서버 사전 스핀을 직접 조작해 Humble의 이탈을 완전히(82.1%→0.0%) 지웠다 — 이 실험 전체에서 가장 큰 단일 효과다. 그러다 116차의 "이중 호출 재현 불가 종결"이 틀렸다는 것을 우연히 발견했다(사전 스핀 도중 다시 나타남, Humble 22.0%→8.0%, Jazzy는 반대로 0%→44.0%). execute_service()가 execute_client()보다 4.7배 더 걸린다는 것도 실측했고, "지연 샘플" 패킷은 tcpdump로 봐도 재전송이 없어 네트워크 수준이 아님을 확인했다. 127차가 사전 스핀 메커니즘의 두 가설(캐스케이드 횟수, 스레드 지역성)을 반증했다 — 총 호출 수·스레드 전환율 모두 두 조건에서 거의 같은데 이탈률만 3.3% 대 73.3%로 극단적으로 다르다. Jazzy 이중 호출의 원래 후보 원인("2단계 등록")도 전제가 틀렸다는 것을 확인했다 — 그 API를 전혀 안 쓰는 도구에서 이번 이중 호출이 났다. "지연 샘플"은 FastDDS 경고 로그로도 침묵을 확인했다. 128차가 마침내 사전 스핀 메커니즘의 실제 차이를 찾았다 — 재구성 총량은 두 조건이 거의 같지만, 사건 단위 타임스탬프로 "마지막 도착부터 마지막 디스패치까지" 구간에 재구성이 몇 번 몰리는지 측정하니 사전 스핀 7~11회 대 기준 23~28회로 분포가 전혀 안 겹쳤다 — 기준 조건은 스핀 시작 순간 스레드 4개가 이미 준비된 데이터를 두고 몰아치고, 사전 스핀은 그 다툼을 400ms에 걸쳐 미리 분산해 소화한다. Jazzy 이중 호출의 새 후보 원인(wait_result_ 경합)를 계측하려 240회를 실행했지만 이번 세션엔 재현 자체가 안 됐다. "지연 샘플"은 gdb 반복 attach(프로브 자체가 현상보다 큰 것을 확인, 부적합)를 버리고 /proc 저오버헤드 폴링으로 세 번째 배제를 확인했다. 129차가 세 항목 모두에서 진전을 냈다 — 재구성-몰림의 정확한 경로를 찾았다(재구성·필터링·선택이 서로 다른 세 번의 별개 락 획득이고, 무잠금 rcl_wait() 도중 다른 스레드가 같은 공유 wait_set_을 다시 짓밟을 수 있다 — 이탈 22/22 전부에서 발산 순간에 겹침 확인). Jazzy 이중 호출은 결정론적 결함 주입(경합 창을 800배 확대)으로도 140회 전부 재현 안 돼 127차 가설에 불리한 증거가 쌓였다. "지연 샘플"은 strace -T로 futex 인자를 직접 봐, 타임드 웨이트가 웨이크업 신호 없이 자기 타임아웃(~62~64ms, 관측 갭과 일치)만으로 깨어난다는 것을 확인했다. 130차는 정정 하나(사전 스핀의 idle 구간은 "400ms 분산"이 아니라 "935µs 몰아치기 + 399ms 단일 블록")와 진전 둘을 냈다 — 이를 반영한 위상 폭 지표는 유의한 차이를 못 냈지만, Jazzy 이중 호출은 busy 플래그가 선택 시점부터 처리 완료까지 전 구간을 감싼다는 소스 구조로 지연 지점을 옮겨도(누적 220회) 재현 안 되는 이유가 설명됐고, "지연 샘플"은 strace -k로 완전히 심볼화돼 FastDDS의 FlowControllerImpl::run()(라이터 흐름 제어 스레드)이 원인으로 좁혀졌다. 131차는 락 A 충돌 지표를 재설계해 명확한 신호를 얻었다 — "구간이 실제로 겹치는 횟수"(뮤텍스로 원천 불가능)를 "재구성-끝부터 선택까지 다른 스레드의 재구성-시작이 몇 번 개입하는가"로 바꾸니 사전 스핀 30회 전부 0회 대 기준 평균 4.00회로 완벽하게 갈렸다(다만 유일한 이탈 경로는 아니었다). Jazzy 이중 호출은 unread-count가 실시간 조회임을 소스로 확인해 129차 가설이 이 형태로는 성립하지 않는다는 것을 알았고 스레드 16개 스트레스로도 재현 못 했다(누적 270회 0건). "지연 샘플"은 FlowControllerImpl 소스로 "~150ms 페이싱"이 틀렸음을 확인했다 — 고정 주기 없는 무기한 대기이며 "동기 전달 실패" 샘플이 큐에 들어올 때만 깨어난다. 132차는 세 항목 모두에서 소스를 더 깊이 조사했다 — "배치 분할"(104차)이 사전 스핀·기준 둘 다 N에 가까워(5.93/6·5.17/6) 독립 변수가 아니라 락 A 현상의 증상이었음이 드러났고, 락 A 개입=0인데도 64%가 이탈하는 잔차가 남았다. Jazzy unread-count는 RTPSReader::total_unread_라는 유지되는 카운터였고(131차 정정), 리스너 경로와 실제 take 경로가 독립적으로 같은 카운터를 갱신하는 구조적 경합 여지를 찾았다. "지연 샘플"의 동기 전달 실패는 StatefulReader::processDataMsg()의 반환값으로 정체가 좁혀졌지만 KeepLast(1) 깊이 제한의 실제 구현까지는 못 내려갔다. 133차는 세 항목 모두에서 한 걸음씩 더 좁혔다 — 132차의 잔차(락 A 개입 없는 이탈)를 개별 조사하니 전부 "국소 창 회전"(등록 순서 안 어떤 구간의 마지막 원소가 그 구간 앞으로 이동하고 나머지는 원래 순서 유지)이었고, 103·124차의 도착 미세 순서 어긋남과 같은 원인으로 보이는 독립 경로임을 확인했다. Jazzy get_last_notified()는 직접 읽으니 알림 전에 워터마크를 미리 당기는 결정론적 설계라(드문 경합이 아니다), 드문 이중 호출과 안 맞아 원인 후보에서 멀어졌다. KeepLast(1)은 이 테스트가 토픽마다 한 번만 발행해 깊이 제한 분기 자체가 발동할 일이 없다는 것을 확인해 소스로 배제했다. 134차는 이 세 갈래를 직접 계측으로 더 추적했다 — "국소 창 회전=도착 미세 순서 어긋남"이라는 133차의 추정을 리스너 콜백 기반 도착 시각 직접 계측으로 검증하니 이탈 실행 28/28 전부 도착 순서가 완벽한 등록 순서였다(가설 반증, 이탈은 순수 디스패치 쪽). rmw_wait.cpp를 다시 읽어 rcl_wait()의 실제 준비 판정 게이트가 total_unread_와 무관한 get_first_untaken_info()(data_available_instances_, 실제 take로만 비워짐)임을 확정했다. processDataMsg()의 후보 분기들도 GDB로 재빌드 없이 직접 계측해 1566회의 실제 동기 전달에서 단 한 번도 실패하지 않았다는 것을 확인했다(다만 이 세션엔 "지연 샘플" 자체가 재현되지 않았다). 135차는 이 세 갈래 모두를 실제로 추적했다 — 134차가 찾은 게이트가 실제로 이중 준비 신호를 낼 수 있는지 add_to_instance()·check_and_remove_instance()의 타이밍을 결함 주입(erase 직전 지연 최대 50ms)으로 시험했는데, 9084건의 add/remove 사건에서 개입한 경우가 0건이었다 — 두 함수가 같은 리더 뮤텍스로 완전히 직렬화돼 구조적으로 불가능했다. GDB를 걷어내고 원자적 카운터로 재계측하니 "지연 샘플" 재현율이 0%에서 40%대로 상승했고(GDB 오버헤드가 억누르고 있었다는 것이 확인됨), 재현된 33건에서도 분기는 안 실패했지만 — 131차가 "동기 실패 시에만 불린다"고 지목한 enqueue_new_sample_impl()이 40회 전부 0번 불렸다는 것을 발견해, FlowControllerImpl::run() 스레드가 실은 우리 샘플과 무관한 정기 재전송(매 실행 13회, 지연 샘플 유무 무관)을 처리하고 있었을 가능성을 열었다. 개별 스왑을 락 A 개입과 사건 단위로 대조하니 스왑의 79.5%가 개입과 동반됐다(정상 픽은 6.6%). 136차는 이 세 갈래를 더 추적했다 — 재전송에 사건 단위 타임스탬프를 남겨 지연 샘플 도착 시각과 직접 겹쳐 보니, "우연한 겹침"이라던 135차의 판단과 달리 지연 샘플 16/16건 전부에서 재전송이 그 갭 창 안에 정확히(우연 기대치의 약 18배) 들어와 있었다(인과 방향은 여전히 열려 있다). 락 A 개입 없는 잔차 스왑(9/44)을 60회 표본으로 재검토하니, 잔차 스왑 28건(75건 중) 전부에서 그 스레드가 자기 재구성 사이클을 이 실행 안에서 한 번도 기록하지 않았다 — get_next_ready_executable()이 wait_for_work()보다 먼저 시도되기 때문에 다른 스레드가 남긴 벡터를 자기 재구성 없이 바로 선택할 수 있는 "빠른 경로"였다(락 A와 합치면 스왑의 100%가 설명된다). 픽 순번으로 나누니 스왑률이 60.0%(픽#0)에서 0%(픽#4·5)로 단조 감소했고, 빠른 경로 스왑도 전부 픽#0~2에만 몰려 있었다 — 첫 픽의 스왑은 락 A 경합과 빠른 경로가 겹치는 스핀-시작 특유의 현상이었다. 137차는 세 갈래 모두를 더 추적했다 — 재전송(add_old_sample_impl())을 실제로 억제하는 결함 주입을 넣으니 정상 40/40은 6개 메시지 전부 수신했지만 억제 40/40은 단 하나도 수신하지 못했다 — 구독이 발행보다 늦게 생기는 이 테스트에서는 재전송이 지연 샘플만이 아니라 전달 자체의 유일한 경로였다는 것이 결함 주입으로 완전히 확인됐다(135차의 "우연" 의심, 136차의 "겹침"을 넘어 인과가 닫혔다). "빠른 경로" 픽에 "마지막 재구성자"를 사건 단위로 남기니 29건 전부(100%)가 남의 재구성 상태를 물려받았다(그 외 픽은 23.6%만) — "왜 남의 상태인가"까지 완전히 닫혔다. Jazzy의 선택 경로를 처음 직접 계측해 Humble과 같은 세션에서 비교하니(Humble 44/60·66스왑 대 Jazzy 60/60·242스왑, 다만 이 절대 수치는 계측 오버헤드로 113·114차 기존 기준과 방향이 반대라 신뢰 못 함), Jazzy 스왑의 94.2%가 Humble의 두 메커니즘(락 A·빠른 경로) 어느 쪽으로도 안 설명돼 108차의 해시 순서 가설이 별개 메커니즘일 가능성을 사건 단위로 뒷받침했다. 138차가 세 갈래를 마무리했다 — 계측 없는 시스템 rclcpp로 같은 세션을 다시 측정하니 Jazzy는 여전히 60/60(100%) 이탈이었다(Humble은 73.3%→50.0%로 실제 바뀌었지만 방향은 안 바뀐다) — 137차의 "계측 오버헤드" 추정을 정정한다, 역전은 121·122차의 세션 변동성이었다. 해시 순서 가설은 collect_entities()→ExecutorEntitiesCollection::ServiceCollection(std::unordered_map<const rcl_service_t*, ...> 상속)→update_entities()의 begin()~end() 순회까지 함수·줄 단위로 확정했고, 137차의 Jazzy 60회 로그를 재분석하니 61.7%가 정확히 같은 FIRE_ORDER 하나로 수렴했다(순수 레이스가 아닌 결정론적 편향의 증거). processDataMsg()에 토픽 식별자를 남기니 134·135차 측정의 80.3%가 우리 테스트와 무관한 배경 트래픽이었다(우리 6개 토픽은 19.7%, 0건 실패 재확인). 139차는 이 배경 트래픽이 ros_discovery_info·rt/parameter_events였음을 토픽 이름으로 확정했고, Jazzy의 "비지배적" 순서 23/60이 순수 잡음이 아니라 4개의 추가 안정적 순서로 81.7%를 채운다는 것을 확인한 뒤, 코디네이터의 판단대로 32회차(108~139차) 조사 전체를 실무 지침(2.3.15)으로 정리하는 쪽으로 무게중심을 옮겼다 — 같은 타입 엔티티 순서는 두 배포판 다 안 지켜진다는 것, Humble은 사전 스핀으로 크게 완화되지만 Jazzy는 안 된다는 것을 증명된 사실로 못 박고, 이중 호출의 정확한 원인 등은 미해결로 명시했다. 상세 수치·재현은 15.1.3·2.3.14·2.3.15.

2.2.8 spin 계열 함수의 차이

함수동작용도
spin()무한 루프. shutdown까지일반 노드의 main
spin_once(timeout)준비된 콜백 하나만 실행하고 반환수동 제어 루프
spin_some(max_duration)지금 준비된 것들을 실행. 새로 도착한 것은 다음에주기적 처리
spin_all(max_duration)준비된 것을 계속 실행(도중에 새로 준비된 것도)큐 비우기
spin_until_future_complete(future, timeout)future 완료까지 spinmain에서의 동기 호출(1.7)
spin_node_once/some(node)임시 executor로 해당 노드만간편 함수

spin_some과 spin_all의 차이가 미묘하고 중요하다.

  • spin_some: 루프 진입 시점에 이미 준비된 것만 처리한다. 처리 중 새로 도착한 메시지는 다음 호출로 미룬다.
  • spin_all: max_duration 안에서 계속 준비되는 것을 모두 처리한다. 데이터가 계속 대량 유입되면 시간 제한까지 반복한다.
// 외부 루프에서 ROS를 실행하는 전형적 패턴 (게임 루프·GUI 통합 등)
while (running) {
  executor.spin_some(std::chrono::milliseconds(10));   // ROS 처리
  render_frame();                                      // 다른 일
}

함정: spin_some을 너무 드물게 호출하면 큐가 넘친다(1.4.4). 외부 루프와 통합할 때는 호출 빈도가 발행 빈도를 감당하는지 확인해야 한다.

함정: spin_once()를 루프에서 실행하는 것으로 spin()을 대체하려 하면 타이머 정밀도와 공정성이 악화된다. 특별한 이유가 없으면 spin()을 쓴다.

2.2.9 공정성과 기아 — 무엇이 지연되는가

준비된 엔티티가 여럿일 때 executor는 어떤 순서로 실행하는가. 이것이 "왜 이 콜백만 지연되지"의 답이다.

관찰되는 일반적 성질:

  • 엔티티 종류 간에 암묵적 우선순위가 있다 — Jazzy SingleThreadedExecutor에서 타이머 → 구독 → 서비스 → 클라이언트 → waitable 다섯 단계 전부를 93~95차에 실행 확인했다(2.3.14). 다만 이 우선순위는 SingleThreadedExecutor에만 해당한다 — MultiThreadedExecutor로 실제(spin()) 동시 실행을 시키면 95차 실측에서 16~47% 확률로 순서가 깨졌다(스레드가 적을수록 자주, 2.3.14). EventsExecutor는 97차에 실측했는데 순서 자체가 정반대다(SUB→CLIENT→SRV→TIMER, 타이머가 맨 뒤, 14/14 재현 — 구독·서비스·클라이언트는 준비 상태가 등록 즉시 큐에 삽입되지만 타이머는 실제로 주기가 만료돼야 큐에 들어가기 때문, 2.3.14). 배포판별 차이는 여전히 (확인 필요)다.
  • 같은 종류 안에서는 등록 순서·수집 순서의 영향을 받는다.
  • 한 번의 wait 결과에서 준비된 것들을 처리한 뒤 다시 wait로 돌아간다.

기아가 발생하는 조건:

상황결과
고빈도 토픽 + 저빈도 토픽 혼재저빈도 쪽이 밀릴 수 있다
한 콜백이 계속 오래 걸림나머지 전부가 지연된다
타이머 주기 < 콜백 실행 시간타이머가 계속 만료 상태 → 다른 것이 기아 상태가 된다
데이터가 처리 속도보다 빠름큐가 차고, 오래된 데이터를 처리하며 더 뒤처짐

세 번째가 특히 위험하다. 10 ms 주기 타이머의 콜백이 15 ms 걸리면, 콜백이 끝나자마자 타이머는 이미 만료 상태다. executor는 계속 그 타이머만 실행하게 되고 구독 콜백은 실행 기회를 거의 못 얻는다.

// 진단: 콜백 실행 시간을 직접 측정한다
void on_timer() {
  auto t0 = std::chrono::steady_clock::now();      // 1.10: 단조 시계
  do_work();
  auto dt = std::chrono::steady_clock::now() - t0;
  if (dt > period_ * 0.8) {
    RCLCPP_WARN_THROTTLE(get_logger(), *get_clock(), 1000,
      "콜백이 주기의 %.0f%%를 사용", 
      100.0 * dt.count() / period_.count());
  }
}

견해: "콜백은 주기의 절반 안에 끝나야 한다" 를 설계 규칙으로 두면 대부분의 기아 문제가 예방된다. 넘어가면 executor를 늘리거나(2.3), 콜백 그룹으로 병렬화하거나(2.4), 작업을 워커 스레드로 옮겨야 한다.

우선순위가 필요하면? ROS 2의 기본 executor들은 콜백 우선순위를 직접 지원하지 않는다. 우선순위가 정말 필요하면:

방법설명
executor 분리중요한 콜백을 별도 executor·스레드에, OS 우선순위 부여(12.2)
프로세스 분리가장 강한 격리
콜백 그룹 + 멀티스레드병렬화로 대기 시간 감소(2.4)
rclc executor실행 순서 명시 지정 가능(2.1.7·12.5)
커스텀 executorrclcpp::Executor 상속 구현

2.2.10 executor 동작 관찰하기

# 스레드별 CPU — 어느 스레드가 바쁜가
top -H -p $(pgrep -f my_node)
ps -T -p $(pgrep -f my_node)

# 콜백 수준 추적 (가장 정확)
ros2 trace start my_session
# … 실행 …
ros2 trace stop

ros2_tracing은 콜백 진입·이탈 시각을 직접 기록하므로, 어느 콜백이 얼마나 걸렸고 무엇이 지연됐는지를 정확히 볼 수 있다. executor 문제 진단의 정본이며 11.6에서 다룬다.

간이 진단:

// 모든 콜백 앞뒤에 시간을 기록하는 래퍼로 감싸 본다
auto wrapped = [this, cb](auto msg) {
  auto t0 = std::chrono::steady_clock::now();
  cb(msg);
  record_duration("on_scan", std::chrono::steady_clock::now() - t0);
};

2.2.11 함정 카탈로그

#함정증상대처
1콜백 안에서 대기executor 정지·데드락비동기화(1.7)
2콜백이 주기보다 김타이머가 다른 것을 기아 상태로 만듦경량화·병렬화
3엔티티를 콜백에서 생성·소멸wait set 재구성 폭증초기화 때 생성
4한 executor에 노드 수십 개$O(N)$ 오버헤드executor·프로세스 분리
5spin_some 호출 빈도 부족큐 넘침·유실빈도 확인
6spin_once 루프로 spin 대체정밀도·공정성 저하spin() 사용
7콜백 우선순위를 기대지원 안 됨executor·프로세스 분리
8실행 순서를 가정한 로직배포판·구성 따라 깨짐순서 비의존 설계
9CPU 사용률만으로 진단원인 파악 불가트레이싱(11.6)
10블로킹 I/O를 콜백에서전체 정지워커 스레드

8번을 강조한다. executor의 실행 순서는 문서화된 계약이 아니다. "구독 A가 항상 구독 B보다 먼저 처리된다"에 의존하는 로직은 배포판 업그레이드나 구성 변경에서 깨진다. 순서가 필요하면 데이터 자체에 순서 정보를 넣거나(타임스탬프 — 1.10), 명시적 동기화 도구를 쓴다(message_filters — 7.7).

2.2.12 체크포인트

  • 폴링·스레드-per-엔티티·wait set 세 접근의 문제와 장점을 비교할 수 있는가.
  • wait set에 들어가는 엔티티 종류를 나열하고, 타이머가 왜 특별한지 설명할 수 있는가.
  • waitable이라는 추상이 왜 필요한가. 액션과 어떻게 연결되는가.
  • executor 루프의 여섯 단계를 순서대로 말하고, 콜백이 오래 걸릴 때 각 단계에 무슨 일이 생기는지 설명할 수 있는가.
  • 1.7의 데드락을 이 루프 그림으로 다시 설명할 수 있는가.
  • guard condition이 없으면 불가능해지는 일 세 가지를 들 수 있는가.
  • 엔티티를 콜백 안에서 생성·소멸하면 왜 느려지는가. 오버헤드는 무엇에 비례하는가.
  • spin_some과 spin_all의 차이를 정확히 말할 수 있는가. 외부 루프와 통합할 때 주의점은?
  • 타이머 콜백이 주기보다 오래 걸릴 때 다른 콜백이 기아 상태가 되는 메커니즘을 설명할 수 있는가.
  • "콜백은 주기의 절반 안에" 규칙의 근거는 무엇인가.
  • ROS 2 기본 executor가 콜백 우선순위를 지원하지 않을 때, 우선순위를 얻는 방법 다섯 가지를 들 수 있는가.
  • 실행 순서에 의존하는 로직이 왜 위험한가. 순서가 필요하면 무엇으로 푸는가.

다음 장 2.3은 이 원리 위에 세워진 executor 구현체들 — SingleThreaded·MultiThreaded·Static·Events — 를 비교하고 선택 기준을 세운다.