AEGLOSLAB ROS 2 Jazzy 대전

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

2.3 Executor 종류와 선택

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

2.2가 "executor가 어떻게 동작하는가"였다면 이 장은 "어느 executor를 쓸 것인가" 다. 선택지는 넷이고, 잘못 고르면 데드락·지터·CPU 낭비가 생긴다. 그런데 이 선택은 콜백 그룹 설계(2.4)와 분리할 수 없다 — executor 종류만 바꾸고 그룹을 그대로 두면 대개 아무것도 나아지지 않거나 새 버그가 생긴다.

배우는 것:

  • 네 가지 executor의 동작·장단·적합 상황.
  • spin_until_future_complete의 정확한 의미와 executor별 차이.
  • executor를 여러 개 쓰는 구성 — 언제, 어떻게.
  • 선택 절차 — 시스템 특성에서 executor를 도출하는 순서.
  • 성능 비교를 읽는 법 — 벤치마크가 왜 그대로 적용되지 않는가.
  • 마이그레이션 — 단일 → 멀티로 옮길 때의 체크리스트.

선수 지식: 2.2(wait set·루프), 1.7(데드락), 1.13(컨테이너 종류). 콜백 그룹은 2.4에서 상세히 다루지만 여기서 함께 언급된다.

정직: executor 구현 라인업은 배포판마다 변해 왔다. Jazzy 기준으로는 네 가지가 모두 rclcpp에 실재하며 컴파일된다(실행 확인). 다만 EventsExecutor만 experimental 네임스페이스다(2.3.6). 성능 비교 수치는 인용하지 않는다 — 하드웨어·엔티티 수·메시지 크기에 따라 결론이 뒤집히기 때문이며, 그 부분은 여전히 미검증이다(BK-03). 이 장은 어떻게 측정해 판단할지를 가르친다. 타이머·구독·서비스·클라이언트·waitable 다섯 종류가 동시에 준비될 때의 실행 순서는 93~95차에 걸쳐 SingleThreadedExecutor에서 전부 실행 확인했다(2.3.14) — TIMER→SUB→SRV→CLIENT→WAITABLE. 다만 MultiThreadedExecutor에서는(95차, spin() 기반 실제 동시 실행) 이 순서가 16~47% 확률로 깨진다 — spin_some()으로 부르면 스레드 풀이 전혀 관여하지 않아 이 비결정성이 숨는다는 함정도 함께 확인했다. 커스텀(guard condition) waitable이 완전히 유실되는 원인은 96차가 소스와 최소 재현으로 확정했다 — 다른 엔티티와 같은 MutuallyExclusive 콜백 그룹을 공유하면(스레드 2개 이상) 결정적으로 유실되고, 전용 그룹으로 분리하면 결정적으로 안전하다(2.3.14). 97차는 EventsExecutor에서 이 다섯 엔티티의 순서를 재측정해 SUB→CLIENT→SRV→TIMER(타이머가 맨 뒤)라는 정반대 결과를 얻었고, Reentrant 콜백 그룹이 waitable 유실은 막지만 별도의 데이터 경쟁을 새로 낳는다는 것도 확인했다(2.3.14·2.10.11.3). *108차는 여기서 한 걸음 더 나아가, 같은 타입 엔티티 여러 개(구독 6~10개) 사이의 순서까지 측정했다 — SingleThreadedExecutor에서도 등록 순서와 무관한 순서가 나오고, 그 순서조차 60회 중 4회(6.7%)는 실행마다 바뀐다(std::unordered_map 기반 내부 컬렉션, Humble엔 없는 구조). Humble은 같은 조건에서 40/40 예외 없이 등록 순서를 지켰다(2.3.14·15.1.3). 109차는 이 결과를 구독 밖의 네 엔티티 종류(타이머·서비스·클라이언트·waitable)로 확장하고 MultiThreadedExecutor의 실제 동시 실행에서도 측정해 봤다 — 다섯 종류 전부 Jazzy에서는 등록 순서를 지키지 않고 Humble에서는 지켰지만, 종류별 이탈률은 0%(타이머·waitable)부터 45%(서비스)까지 크게 갈렸다. 더 중요한 것은 MultiThreadedExecutor::spin()의 실제 동시 실행에서는 std::unordered_map이 없는 Humble도 20~30% 순서가 흔들린다는 것 — 해시 버킷 순서와는 무관한, 스레드 경합이라는 별개의 비결정성 원인이 있다는 뜻이다(2.3.14). 112차는 이 미확인 셋을 전부 닫았다 — ① 미들웨어 리스너 콜백(set_on_new_request_callback 등)으로 도착 순서를 executor와 독립적으로 측정해 보니, 서비스는 도착이 40/40·25/25 완벽히 결정적인데도 디스패치 순서는 80%·60% 이탈해(16종의 서로 다른 순서) 이탈이 도착 잡음이 아니라 순수 디스패치 쪽임을 확정했다. ② MultiThreadedExecutor를 타이머·서비스·클라이언트·waitable·스레드 2/4/8로 확장하니, guard condition waitable은 Jazzy·Humble 양쪽에서 스레드 2개 이상이면 80회 전부 100% 유실돼(96차가 찾은 메커니즘이 두 배포판이 공유하는 코드에 있다는 뜻) 서비스가 두 배포판 다 53~80%로 가장 불안정하고 클라이언트가 가장 안정적임을 보였다. ③ 구독·스레드4의 Jazzy·Humble 이탈률 차이(45.0% 대 8.3%, 각 60회)를 두-비율 z-검정한 결과 $p=6\times10^{-6}$로 통계적으로 유의함을 확정했다(2.3.14). 113차는 타이머·서비스로 같은 검정을 확대했는데 — 구독과 달리 유의하지 않았다($p=0.65$·$0.22$, 각 60회) — 타이머의 스레드 수 비단조 패턴도 표본을 50회로 늘리자 사라져(전 구간 $p>0.29$), 이 두 축이 소표본 우연이었음을 확정했다. Humble 서비스 콜백이 종종 두 번 반복 호출되는 112차의 관찰은 rclcpp의 의도된 2단계 등록 패턴(Jazzy와 동일)까지는 소스로 확인했지만 그 메커니즘의 타이밍(~30마이크로초)이 실제 증상(~150ms 지연)과 4~5자릿수 어긋나 원인으로 배제됐고, 66회의 통제 재현 시도도 전부 실패했다(2.3.14). 114차는 서비스 통계 검정을 150회로 늘려 유의성을 확정했다($p=1.87\times10^{-4}$, 113차의 경계선 $p=0.072$가 표본 부족이었음이 드러났다) — 반면 이중 호출 재현은 다른 엔티티를 먼저 실행하는 조건까지 포함해 111회째 실패했다(2.3.14). 115차는 타이머도 같은 방식(150회급)으로 재검정해 유의함을 확정했다(Jazzy 8.7% 대 Humble 26.7%, $p=4.36\times10^{-5}$) — 구독·서비스·타이머 세 엔티티 전부 표본을 충분히 늘리자 Jazzy·Humble 차이가 유의했지만 방향은 갈렸다(구독은 Jazzy가, 서비스·타이머는 Humble이 더 나쁨). 또한 --privileged 컨테이너로 108차부터 막혀 있던 ASLR을 실제로 껐다 — 껐을 때도 서비스 디스패치 이탈률이 0%로 떨어지지 않아(30.0%, 켬은 40.0%, $p=0.251$) "ASLR이 유일한 원인"이라는 가설을 반증했다(2.3.14). 116차는 그 잔여 이탈의 원인을 주소로 직접 확인했다 — ASLR을 꺼도 디스커버리 매칭이 섞이면 핸들 주소가 12.5%(5/40) 확률로 달라졌다(백그라운드 RMW 스레드와의 힙 경합 타이밍으로 추정). 같은 회차가 Humble rclcpp 16.0.19 소스로 AllocatorMemoryStrategy의 엔티티 수집이 (콜백 그룹 하나일 때는) 해시·정렬 없이 순수 등록 순서 벡터임을 확인해, Humble의 MultiThreadedExecutor 이탈이 96차가 Jazzy에서 확정한 것과 같은 스레드 스케줄링 문제임을 좁혔다(다만 Humble이 더 불리한 정도의 이유는 못 좁힘). Humble 서비스 콜백 이중 호출은 누적 101회의 재현 시도와 환경 포렌식이 전부 실패해 재현 불가로 명시적으로 종결한다(2.3.14). 117차는 ASLR 켬·끔 비교를 300회로 완결해 무관하다고 최종 결론지었고(60→150회의 "켬이 더 불안정" 추세는 착시였다, $p=0.369$), 핸들 주소와 디스패치 순서를 같은 프로세스에서 동시에 재 완전한 1:1 상관을 확인했으며, CHG-122 계열의 마지막 항목인 stress-ng 통제 실험으로 스레드 경합 가설을 처음 정량 확인했다(구독·서비스 모두 워커 수가 코어 수에 도달하면 이탈률이 극적으로, 유의하게 상승한다 — Jazzy 구독 35.0%→72.5%, $p=0.00077$; Humble 구독은 4.0%→4.0%로 무반응)(2.3.14). 118차는 이 임계값을 16~20워커 사이로 좁혔고, Humble 서비스·타이머에서는 부하가 이탈률을 유의하게 낮춘다는(정반대 방향) 것을 발견했다(서비스 44.0%→4.0%, $p=0.00093$) — Jazzy·Humble executor 소스를 대조하니 "선택"은 둘 다 결정적이지만 방식이 다르고(Jazzy: 캐시된 스냅숏 순회, Humble: 벡터 순회+erase+추가 뮤텍스), "Humble의 매 사이클 재구성이 더 비용이 크다"는 가설은 실제 타임스탬프 계측으로 반증됐다(Jazzy가 오히려 1.3~1.8배 더 비용이 컸다) — Humble이 서비스·타이머에서 불리한 실제 이유는 여전히 열려 있다(2.3.14). 119차는 중대한 정정이다 — 91~98차(gap109~118)의 MultiThreadedExecutor 실험이 "실제 동시 실행"이라 불러 온 것에서, 동시 실행이 단 한 번도 없었다는 것을 직접 계측으로 확인했다 — 모든 테스트 엔티티가 명시적 그룹 지정 없이 노드 하나에 등록돼 기본(MutuallyExclusive) 콜백 그룹에 들어갔고, 이 그룹 자체가 "한 번에 하나만 실행"을 강제한다(Jazzy·Humble·구독·서비스·타이머·부하 유무 전 조건에서 동시성 비율 0.0000). 측정된 FIRE_ORDER 이탈률 수치 자체는 유효하지만, 그것을 "OS 스케줄러가 병렬 실행 중인 스레드를 어떤 순서로 깨우는가"로 설명해 온 96차 이후의 메커니즘은 이 테스트 계열에는 적용되지 않는다 — 이탈은 선택 자체(및 그것이 매 사이클 재구성과 얽히는 방식)의 비결정성에서 와야 한다(2.3.14). 120차는 119차가 남긴 "재구성-대기 연쇄" 가설(바쁜 그룹에 막힌 스레드가 wait_mutex_를 쥔 채 블로킹되고, 깨어나면 재구성된 목록을 받는다)을 직접 계측했다 — 락 스코프가 실제로 블로킹 대기 전체를 감싼다는 것은 소스·480회 실측(겹침 비율 0.0000) 모두로 확정했고, Humble은 이 재구성이 항상(100%) 일어나는 반면 Jazzy는 조건부(45~74%)라는 배포판 차이도 확인했다 — 다만 이 연쇄의 빈도가 이탈률을 결정하는 인과적 손잡이라는 것은 확정하지 못했다(엔티티 종류를 고정하면 상관이 거의 사라진다) — Humble이 서비스·타이머에서 불리한 실제 이유는 여전히 열려 있다(2.3.14). 121차는 세 갈래를 더 조사했다 — rcl wait.c를 읽어 구독·서비스가 완전히 같은 방식(rmw_wait() 후 널 검사)으로 처리됨을 확인해 rcl 계층에서의 구조적 차이 가설을 반증했고, Jazzy의 재구성을 완전히 억제해도(entities_need_rebuild_ 무시) 서비스 이탈률이 줄지 않아(오히려 소폭 증가, 비유의) 재구성-대기 연쇄의 반복 자체는 이탈의 필요조건이 아님을 확인했으며, 재구성-대기 계측을 구독으로 확장하다가 117차의 "Humble 구독은 CPU 부하에 완전히 무반응"이라는 결론이 이번 세션에서는 재현되지 않는다는 것(오히려 유의하게 감소, 계측 부작용 아님을 순정 빌드로 확인)을 발견했다 — 세션 간 재현성 문제로 명시하고 다음 회차로 이월한다(2.3.14). 122차는 그 재현성 문제를 직접 검증했다 — 이 호스트에 함께 실행 중인 36개 무관한 컨테이너의 배경 부하가 원인일 것이라는 가설을, /proc/loadavg 동시 기록(자연 변동, $r=-0.0149$)과 통제된 저강도 stress-ng 스윕(0·2·4·8·20워커, 매끄러운 용량-반응 없음, 같은 세션의 앞 배치와도 재현 안 됨) 둘 다로 검증했지만 지지되지 않았다 — 단순 CPU 경합량으로는 설명되지 않는다. rmw_fastrtps_shared_cpp까지 내려가 봐도(네 계층째) 구독·서비스의 코드 경로 차이는 없었지만, 서비스가 구조적으로 구독의 두 배(요청+응답 DDS 엔티티 쌍 대 리더 하나)라는 새 후보를 찾았다. 121차의 "재구성 억제가 이탈을 소폭 늘린다"는 경향도 n=100에서는 완전히 사라졌다($p=0.88$, 소표본 우연이었다)(2.3.14). 123차는 이 "엔티티 두 배" 가설을 클라이언트 재분석과 직접 조작 둘 다로 검증했지만, 둘 다 가설에 불리했다 — 클라이언트도 엔티티 둘(요청 라이터+응답 리더)을 갖지만 이 책의 네 엔티티 종류 중 가장 안정적이었고(서비스와 정반대), 구독의 로컬 라이터를 별도 프로세스로 옮겨 제거해도 이탈률 감소는 통계적으로 유의하지 않았다(Jazzy 65.0%→57.5%, $p=0.49$; Humble 42.5%→32.5%, $p=0.36$) — "DDS 엔티티 개수"는 서비스·구독 비대칭의 주된 설명이 아닐 가능성이 크다(2.3.14). 124차는 서비스·클라이언트 비대칭의 새 후보(도착 시간 폭 — 서비스는 좁고 클라이언트는 5배 넓다)를 도착-시각 직접 계측으로 찾았고, Humble 구독의 디스커버리 타이밍 가설은 직접 계측(상관 $r=0.07$)으로 기각했으며(오히려 ARRIVAL_ORDER가 FIRE_ORDER보다 불안정하다는 뜻밖의 발견도 했다), 엔티티 개수 조작 실험을 n=100으로 늘리니 Jazzy는 유의해졌지만($p=0.013$) Humble은 방향이 뒤집혔다 — 여러 가설이 부분적으로만 맞고 전체를 설명하지 못하는 상태가 이어진다(2.3.14). 125차는 124차의 도착 시간 폭 가설을 직접 조작해 반증했다 — 서비스 테스트의 요청 사이 지연을 0~200µs로 늘려도(클라이언트의 자연 도착 창보다 3배 넓게) 이탈률이 유의하게 바뀌지 않았고 Jazzy는 오히려 (비유의하게) 늘었다 — 103~104차의 상관관계는 인과관계가 아니었을 가능성이 크다. Humble AllocatorMemoryStrategy의 "정돈" 메커니즘은 소스에서 정확히 찾았다 — remove_null_handles()가 매 rcl_wait() 이후 준비 안 된 항목을 걷어내 남은 벡터는 등록 순서 그대로이고, get_next_subscription()은 항상 처음부터 순회하므로 N개가 같은 배치에서 준비되면 도착 순서와 무관하게 항상 등록 순서로 나간다. "엔티티 개수"와 "도착 시간 폭"의 관계도 직접 측정했다 — 라이터가 있으면 도착 시간 폭이 90배 가까이 넓었지만(지연 샘플 패턴, 균일한 확장이 아님) 방금 도착 시간 폭 자체는 이탈률을 바꾸지 않는다는 것을 보였으므로, 둘은 "같은 효과의 두 이름"이 아니라 라이터 유무가 만드는 별개의 두 현상으로 보인다(2.3.14). 126차는 서버 사전 스핀을 직접 조작해 Humble의 이탈을 완전히(82.1%→0.0%) 지웠다 — 이 실험 전체에서 가장 큰 단일 효과다(Jazzy는 무변화). 그 과정에서 116차의 "Humble 서비스 이중 호출, 재현 불가로 종결"이 틀렸다는 것이 우연히 드러났다 — 사전 스핀 실험 도중 이중 호출이 다시 나타났고(Humble 기준 22.0%, 사전 스핀 8.0%; Jazzy는 반대로 0%→44.0%), 113차의 "Jazzy는 이중 호출 없음" 확정과도 배치된다. execute_service()가 execute_client()보다 4.7배 더 걸린다는 것도 실측했고(103차의 견해가 확인됨), "지연 샘플"(125차) 패킷을 tcpdump로 캡처하니 하트비트·재전송이 전혀 안 보여 네트워크 수준이 아니라는 것도 확인했다(2.3.14). 127차는 사전 스핀 메커니즘의 두 가설(캐스케이드 횟수, 스레드 지역성)을 실측으로 반증했다 — 총 호출 수도 스레드 전환율도 사전 스핀·기준 조건에서 근본적으로 다르지 않은데 이탈률은 3.3% 대 73.3%로 극단적으로 다르다. Jazzy 이중 호출의 원래 후보 원인("2단계 set_on_new_request_callback 등록")도 전제 자체가 틀렸다는 것을 확인했다 — 이번 이중 호출을 낸 도구들은 그 API를 전혀 안 쓴다(113차의 반증은 다른 도구를 겨냥한 것이었다). "지연 샘플" 패턴은 FastDDS 경고 로그(Log::SetVerbosity)로도 침묵을 확인해 배제 목록이 늘었다(2.3.14). 128차는 마침내 사전 스핀 메커니즘을 사건 단위 타임스탬프로 찾아냈다 — 재구성 총량은 두 조건이 거의 같지만, 마지막 도착부터 마지막 디스패치까지의 "디스패치 구간"에 몇 번이 몰리는지를 측정하니 사전 스핀 7~11회 대 기준 23~28회로 분포가 전혀 겹치지 않았다($t=49.19$) — 기준 조건은 스핀 시작 순간 스레드 4개가 이미 준비된 데이터를 두고 뮤텍스를 경합하며 몰아치는 반면, 사전 스핀은 그 다툼을 400ms에 걸쳐 미리 분산해 소화한다. Jazzy 이중 호출은 127차의 새 후보 원인을 계측하려고 240회를 실행했지만 이번 세션엔 단 한 번도 재현되지 않았다. "지연 샘플"은 gdb 반복 attach(1회 ~295ms, 프로브 효과가 현상보다 크다는 것을 확인)를 버리고 저오버헤드 /proc 스레드 상태 폴링으로 세 번째 배제(활동 없음)를 확인했다(2.3.14). 129차는 세 항목 모두에서 실질적 진전을 냈다 — 재구성-몰림이 등록 순서를 깨는 정확한 경로를 소스+사건 단위 계측으로 찾았다(wait_for_work()의 재구성·필터링과 별도 함수의 선택이 서로 다른 세 번의 락 획득이라 스레드 사이에서 자유롭게 개입하고, 특히 무잠금 상태의 rcl_wait() 도중 다른 스레드가 같은 공유 wait_set_을 다시 짓밟을 수 있다 — 이탈 실행 22/22 전부에서 발산 순간에 겹침이 확인됐다). Jazzy 이중 호출은 86차식 결정론적 결함 주입(경합 창을 800배 확대)으로도 재현하지 못해 127차의 구체적 가설에 불리한 증거가 쌓였다. "지연 샘플"은 strace -T로 futex 인자를 직접 봐 타임드 웨이트가 웨이크업 신호 없이 자기 타임아웃(~62~64ms)만으로 깨어난다는 것을 확인했다(2.3.14). 130차는 뜻밖의 정정 하나와 확실한 진전 하나를 냈다 — 사전 스핀의 400ms idle 구간이 "계속 순환"이 아니라 "시작 직후 935µs 몰아치기 + 이후 399ms 단일 블록"이었음을 재분석으로 발견해 127·128차 서술을 정정했지만, 이를 반영한 "첫 PICK 시점 스레드 위상 폭" 지표 자체는 사전 스핀·기준 간 유의한 차이를 못 냈다. Jazzy 이중 호출은 busy 플래그가 선택 시점부터 처리 완료까지 전 구간을 감싼다는 소스 구조를 확인해, 지연 주입 지점을 더 이르게 옮겨도(누적 220회) 재현이 안 되는 이유가 설명됐다 — 127차 가설은 사실상 배제되고 새 후보(rmw unread-count 플래그)로 넘어갔다. "지연 샘플"은 strace -k로 완전히 심볼화돼 FastDDS의 FlowControllerImpl::run()(라이터 쪽 흐름 제어 스레드)이 원인으로 좁혀졌다(2.3.14). 131차는 락 A 충돌 지표를 다시 설계해 명확한 신호를 얻었다 — "구간이 실제로 겹치는 횟수"(뮤텍스로 원천 불가능, 방법론적 실패)를 "재구성-끝부터 선택까지 다른 스레드의 재구성-시작이 몇 번 개입하는가"로 바꾸니 사전 스핀 30회 전부 0회 대 기준 평균 4.00회(최대 18회)로 완벽하게 갈렸다($t=4.708$) — 129차 메커니즘을 마침내 직접 지표로 확인했다(다만 락 A 개입이 이탈의 유일한 경로는 아니었다). Jazzy 이중 호출은 rmw의 unread-count가 캐시가 아니라 실시간 조회임을 소스로 확인해 129차 가설이 성립하지 않는다는 것을 알았고, 스레드 16개 스트레스 테스트로도 재현 못 해(누적 270회 0건) 남은 창이 매우 좁거나 특정 호스트 조건 의존일 가능성이 남았다. "지연 샘플"은 FlowControllerImpl 소스를 직접 읽어 "~150ms 페이싱 주기"라는 130차의 해석이 틀렸음을 확인했다 — 고정 주기 없는 순수 무기한 대기이며, "동기 전달 실패" 샘플이 큐에 들어올 때만 깨어난다(2.3.14). 132차는 세 항목 모두에서 소스를 더 깊이 조사해 앞선 회차의 결론을 다듬었다 — "배치 분할"(104차)이 사전 스핀·기준 두 조건 모두에서 상수에 가깝게(N에 가깝게) 나타나 독립 변수가 아니라 락 A 현상의 증상이었음을 확인했고, 그럼에도 락 A 개입=0인 기준 실행의 64%가 여전히 이탈해 아직 검출하지 못한 잔차가 남았다. Jazzy의 unread-count는 rmw 계층에서만 보면 실시간 조회였지만 더 깊이 내려가니 RTPSReader::total_unread_라는 유지되는 카운터였고(131차 정정), 리스너 경로와 실제 take 경로가 서로 다른 기준으로 같은 카운터를 독립적으로 갱신한다는 구조적 경합 여지를 찾았다. "지연 샘플"의 동기 전달 실패는 StatefulReader::processDataMsg()의 반환값으로 정체가 좁혀졌고, KeepLast(1) QoS와 관련된 후보 분기까지 찾았지만 실제 깊이 제한 구현(DataReaderHistory)까지는 내려가지 못했다(2.3.14). 133차는 세 항목 모두에서 한 걸음씩 더 좁혔다 — 132차가 남긴 잔차(락 A 개입 없는 이탈)를 개별 조사하니 전부 "국소 창 회전"이라는 뚜렷한 패턴이었고, 103·124차의 도착 미세 순서 어긋남과 같은 원인으로 보이는 새 독립 경로를 확인했다. Jazzy의 unread-count 경합 창은 실제로 읽어 보니 "드문 경합"이 아니라 "매 알림마다 결정론적으로 미리 당겨지는" 설계라, 드문 이중 호출과 안 맞아 오히려 원인 후보에서 멀어졌다. 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차는 그 게이트가 실제로 이중 준비 신호를 낼 수 있는지 결함 주입으로 시험했는데 — 두 관련 함수가 같은 리더 뮤텍스로 완전히 직렬화돼 있어 9084건 중 0건, 구조적으로 불가능함을 확인했다. GDB를 걷어내고 원자적 카운터로 재계측하니 "지연 샘플" 재현율이 0%에서 40%대로 상승했고(GDB 오버헤드가 억누르고 있었다는 134차의 우려가 확인됨), 재현된 33건에서도 후보 분기는 여전히 안 실패했다 — 더 나아가 130·131차가 "동기 실패→비동기 재시도"로 지목한 함수(enqueue_new_sample_impl)가 실은 0번 불렸고, 대신 무관해 보이는 재전송 함수(add_old_sample_impl)가 매 실행 일정하게 불렸다는 것을 발견해 그 인과 사슬이 틀렸음을 확인했다. 개별 스왑을 락 A 개입과 사건 단위로 대조하니 스왑의 79.5%가 개입과 동반돼(정상 픽은 6.6%) 108·110차의 회차-단위 상관을 사건 단위로 재확인했다. 136차는 그 잔차(9/44)를 개별 재검토해 100% "자기 재구성 없는 빠른 경로 픽"임을 밝혔고(get_next_ready_executable()이 wait_for_work()보다 먼저 시도되기 때문에 다른 스레드가 남긴 벡터를 자기 재구성 없이 바로 선택할 수 있다 — 락 A와 합치면 스왑의 100%가 설명된다), 픽 위치별로 나눠 스왑률이 60.0%(픽#0)에서 0%(픽#4·5)로 단조 감소함을 확인했다(빠른 경로 스왑도 전부 픽#0~2에만 몰려 있다). 재전송(add_old_sample_impl)과 지연 샘플의 겹침도 사건 단위로 측정해 보니 "우연"이 아니라 지연 샘플 16/16건 전부에서 갭 창 안에 정확히 들어와 있어 135차의 판단을 정정했다(다만 인과 방향은 여전히 열려 있다). 137차는 결함 주입으로 그 인과를 완전히 밝혔다 — 재전송을 억제하니 지연 샘플이 아니라 6개 메시지 전부가 사라졌다(정상 40/40 fired=6, 억제 40/40 fired=0) — TRANSIENT_LOCAL 구독이 발행보다 늦게 생기는 이 테스트에서는 재전송 자체가 유일한 전달 경로였다. "빠른 경로" 픽도 100%(29/29)가 남의 재구성 상태를 물려받는다는 것을 사건 단위로 확인해 메커니즘을 완전히 닫았다. Jazzy의 선택 경로를 처음 직접 계측해 Humble과 같은 세션에서 비교하니(절대 이탈률은 계측 오버헤드로 기존 기준과 방향이 반대라 신뢰 못 하지만) Jazzy 스왑의 94.2%가 Humble의 두 메커니즘(락 A·빠른 경로) 어느 쪽으로도 안 설명돼, 108차의 해시 순서 가설이 스레드 경합과 무관한 별개 메커니즘일 가능성을 사건 단위로 뒷받침했다. 138차는 계측 없는 재비교로 그 역전이 계측 오버헤드가 아니라 세션 변동성임을 확인했고(Jazzy는 계측을 걷어내도 여전히 60/60 이탈), 해시 순서 가설을 update_entities()의 std::unordered_map 순회 코드까지 함수·줄 단위로 확정하고, 60회 중 61.7%가 정확히 같은 순서 하나로 수렴한다는 것을 실측으로도 확인했다(순수 레이스로는 설명 안 되는, 결정론적 편향의 증거). processDataMsg()에 토픽 식별자를 남기니 134·135차 측정의 80.3%가 우리 테스트와 무관한 배경 트래픽이었고, 우리 자신의 6개 토픽(19.7%)은 0건 실패가 재확인됐다. 139차는 그 배경 트래픽의 정체를 토픽 이름까지 확정했다(ros_discovery_info), Jazzy의 "비지배적" 순서 23/60이 순수 잡음이 아니라 소수의 추가 안정적 순서로 수렴한다는 것을 확인했다(81.7%가 단 4종의 순서로 수렴). 32회차(108~139차)에 걸친 이 조사 전체를 실무 지침으로 정리한 것이 2.3.15다* — 증명된 것(같은 타입 엔티티 순서는 두 배포판 다 안 지켜진다, Humble은 사전 스핀으로 크게 완화되지만 Jazzy는 안 된다, TRANSIENT_LOCAL 선발행은 별도 지연 경로를 거친다)과 증명되지 않은 것(이중 호출의 정확한 원인, ASLR의 부분적 역할, Humble이 Jazzy보다 불리한 정량적 이유)을 정직하게 가른다(2.3.14·2.3.15).

2.3.2 네 가지 executor 개관

Executor 선택 SingleThre aded 단순·예측 가능 MultiThrea ded 병렬 실행 StaticSingl eThreaded 수집 비용 절감 Events 재구성 없는 구조
executor스레드대기 방식주 이점주 위험
SingleThreadedExecutor1wait set예측 가능·락 불필요한 콜백이 전부를 막음
MultiThreadedExecutorNwait set병렬 실행·데드락 회피스레드 안전 부담
StaticSingleThreadedExecutor1wait set(캐시)엔티티 수집 비용 감소동적 변경에 약함
EventsExecutor(experimental)1~이벤트 큐엔티티 많을 때 확장성성숙도·동작 차이

2.3.3 SingleThreadedExecutor

int main(int argc, char ** argv) {
  rclcpp::init(argc, argv);
  auto node = std::make_shared<MyNode>();
  rclcpp::spin(node);              // 내부적으로 SingleThreadedExecutor
  rclcpp::shutdown();
}

// 명시적으로
rclcpp::executors::SingleThreadedExecutor exec;
exec.add_node(node_a);
exec.add_node(node_b);
exec.spin();

성질:

  • 모든 콜백이 한 스레드에서 순차 실행된다.
  • 콜백 간 락이 필요 없다. 두 콜백이 동시에 실행될 수 없으므로 공유 상태 접근이 안전하다.
  • 실행 순서는 executor가 정하지만, 동시성은 없다. 타이머와 구독이 동시에 준비되면 무엇이 먼저 실행되는지는 2.3.14에서 실측한다.

적합한 경우:

상황이유
콜백이 전부 짧고 부하가 작다순차 실행으로 충분
공유 상태가 많다락 없이 안전
결정성이 중요하다동시성 없음 → 재현 가능(12.5)
임베디드·코어 부족스레드 하나
디버깅 중가장 단순한 모델

부적합한 경우:

  • 콜백 중 하나라도 오래 걸린다(블로킹 I/O·부하가 큰 계산).
  • 서비스·액션 호출이 콜백 안에서 필요하다(1.7의 데드락).
  • CPU 코어가 여럿인데 처리량이 부족하다.

견해: 기본값으로 시작하기에 가장 좋은 선택이다. 문제가 측정으로 확인되기 전에 멀티스레드로 가는 것은 이르다 — 스레드 안전 부담은 실제 비용이고, 그로 인한 버그는 재현이 어렵다. "단일 스레드로 시작해, 측정으로 병목이 확인되면 옮긴다" 가 안전한 순서다.

2.3.4 MultiThreadedExecutor

rclcpp::executors::MultiThreadedExecutor exec;   // 기본: 하드웨어 동시성만큼
// 스레드 수 지정
rclcpp::executors::MultiThreadedExecutor exec(rclcpp::ExecutorOptions(), 4);
exec.add_node(node);
exec.spin();

성질:

  • 여러 스레드가 wait set 결과를 나눠 실행한다.
  • 콜백 그룹이 동시성을 통제한다 — 이것이 핵심이다(2.4).
    • MutuallyExclusive 그룹(기본): 그 그룹의 콜백은 동시에 하나만 실행.
    • Reentrant 그룹: 같은 콜백도 동시에 여러 개 실행 가능.
  • 기본적으로 모든 콜백이 같은 기본 그룹에 들어간다 → MutuallyExclusive → 멀티스레드인데 순차 실행.

함정: MultiThreadedExecutor로 바꾸기만 하고 콜백 그룹을 설정하지 않으면 아무 변화가 없다. 모든 콜백이 노드의 기본 MutuallyExclusive 그룹에 속하기 때문이다. "멀티스레드로 바꿨는데 여전히 느리다·여전히 데드락이다"의 압도적 다수가 이것이다. executor 교체와 그룹 설계는 한 세트다.

적합한 경우:

상황이유
콜백 안에서 서비스·액션 호출데드락 회피(1.7.4의 ②)
독립적인 부하가 큰 처리 여럿병렬화
느린 콜백과 빠른 콜백 혼재서로 막지 않게
코어가 남는다처리량

대가:

대가설명
스레드 안전공유 상태에 락·원자 연산이 필요하다(2.9)
비결정성실행 순서가 실행마다 달라진다. 재현이 어렵다
컨텍스트 스위치짧은 콜백이 많으면 오버헤드가 이득을 넘을 수 있다
디버깅 난이도경쟁 조건은 재현이 어렵다
스레드 수 부족 시 교착모든 스레드가 대기 중이면 여전히 멈춘다

마지막 항목이 중요하다. 스레드 4개인데 콜백 4개가 모두 서로를 기다리면 멈춘다. 스레드 수는 "동시에 블로킹될 수 있는 콜백 수"보다 커야 한다.

2.3.5 StaticSingleThreadedExecutor

엔티티 수집(2.2.5의 ①)을 매 루프마다 하지 않고 캐시해 오버헤드를 줄이려는 구현이다.

  • 이점: 엔티티가 많을 때 루프 오버헤드 감소.
  • 제약: 엔티티가 동적으로 변하는 상황에 약하다 — 노드·구독을 실행 중에 추가·제거하면 갱신이 필요하다.

정직: Jazzy에는 그대로 있고, 헤더에 폐기(deprecated) 표시도 없다(실행 확인) — rclcpp::executors::StaticSingleThreadedExecutor로 컴파일된다. 다만 표준 executor에 캐싱 최적화가 흡수되면서 별도 static executor의 이점이 줄었다는 서술은 소스 대조까지만 한 것이고 성능 비교를 측정한 것은 아니다(확인 필요). 이미 쓰고 있고 잘 동작한다면 굳이 바꿀 이유는 없다.

2.3.6 EventsExecutor

접근이 근본적으로 다르다. wait set으로 폴링하듯 기다리는 대신, 미들웨어의 리스너 콜백으로 이벤트를 받아 큐에 넣고, executor는 그 큐를 소비한다.

wait set 방식 events 방식 엔티티 수집 wait 준비된 것 선별 실행 미들웨어 리스너 → 이벤트 큐에 push executor: 큐에서 pop 실행

이론적 이점:

이점이유
엔티티 수에 대한 확장성매 루프 $O(N)$ 수집이 없다(2.2.7)
지연 감소 가능성깨우기 경로가 짧을 수 있다
CPU 감소불필요한 순회 제거

주의할 점:

주의설명
동작 차이실행 순서·타이머 처리가 wait set 방식과 다를 수 있다
큐 관리이벤트 큐가 무한정 자라지 않도록 정책이 필요
성숙도·지원 범위모든 기능이 동등하게 지원되는지 확인 필요

Jazzy에서의 정확한 위치(실행 확인). EventsExecutor는 rclcpp 본체에 포함되어 있다(rclcpp 28.1.21에서 확인). 다만 experimental 네임스페이스에 있다 — 이름이 곧 API 안정성 보장 수준이다.

#include <rclcpp/experimental/executors/events_executor/events_executor.hpp>

rclcpp::experimental::executors::EventsExecutor ex;   // ← executors:: 가 아니다
ex.add_node(node);
ex.spin();

흔한 오류가 네임스페이스다. 다른 셋과 달리 rclcpp::executors:: 아래가 아니라 rclcpp::experimental::executors:: 아래다. 위 코드가 Jazzy 헤더로 컴파일되는 것까지 확인했다.

정직: 97차가 순서·waitable 등록 동작까지 실행 확인했다(2.3.14·2.10.11.3) — SUB→CLIENT→SRV→TIMER 순서, 그리고 폴링형 커스텀 Waitable이 add_node() 시점에 runtime_error로 즉시 실패하는 것까지. 성능 이점은 여전히 실행 검증하지 않았다(확인 필요). experimental이 붙어 있다는 사실 자체가 경고다 — 배포판이 바뀌면 경로나 시그니처가 움직일 수 있다. 엔티티가 수백 개 규모라 wait set 오버헤드가 측정으로 확인된 경우에 검토할 가치가 있으며, 그렇지 않다면 표준 executor로 충분하다. 도입 전 자신의 워크로드로 A/B 측정을 권한다. (참고로 rclpy에도 rclpy.experimental.events_executor.EventsExecutor로 동급 기능이 있다 — rclpy.executors가 아니라 이 숨은 경로에 있다는 것과 거동 둘 다 99차가 확인했다: 액션 피드백 유실 계열(1.8.13)에서 C++이 가장 심하게 무너지는 조건까지 포함해 240회 전부 무손실이었다. 다만 96·97차가 C++에서 확정한 다른 두 실험(다섯 엔티티 순서 역전, MultiThreadedExecutor waitable 유실)을 rclpy의 EventsExecutor로도 재현했는지는 아직 측정하지 않았다(확인 필요).)

2.3.7 spin_until_future_complete의 정확한 의미

auto ret = exec.spin_until_future_complete(future, 5s);
switch (ret) {
  case rclcpp::FutureReturnCode::SUCCESS:     break;   // future 완료
  case rclcpp::FutureReturnCode::TIMEOUT:     break;   // 시간 초과
  case rclcpp::FutureReturnCode::INTERRUPTED: break;   // shutdown 등
}

이 함수는 "기다리는" 것이 아니라 "spin하면서 기다리는" 것이다. future가 완료될 때까지 executor 루프를 실행한다 — 그 사이 다른 콜백들도 실행된다.

그래서 executor 종류에 따라 의미가 달라진다:

executor동작
SingleThreaded, main 문맥✅ 정상 — 그 스레드가 spin하며 응답 콜백도 실행
SingleThreaded, 콜백 안☠️ 데드락 — 이미 spin 중인 스레드가 다시 spin(1.7.3)
MultiThreaded, 콜백 안, 같은 MutuallyExclusive 그룹☠️ 데드락
MultiThreaded, 콜백 안, 다른 그룹 + 여유 스레드✅ 동작

반환값을 반드시 확인해야 한다. TIMEOUT을 무시하고 future.get()을 호출하면 블로킹된다.

// ❌ 반환값 무시
exec.spin_until_future_complete(future, 5s);
auto result = future.get();          // 타임아웃이었다면 여기서 멈춘다

// ✅
if (exec.spin_until_future_complete(future, 5s) == rclcpp::FutureReturnCode::SUCCESS) {
  auto result = future.get();
} else {
  RCLCPP_ERROR(logger, "응답 없음");
}

2.3.8 executor 여러 개 쓰기

한 프로세스에 executor를 여럿 두고 각각 별도 스레드에서 실행할 수 있다.

auto rt_node = std::make_shared<ControlNode>();      // 고빈도 제어
auto aux_node = std::make_shared<DiagnosticNode>();  // 저빈도 진단

rclcpp::executors::SingleThreadedExecutor rt_exec, aux_exec;
rt_exec.add_node(rt_node);
aux_exec.add_node(aux_node);

std::thread rt_thread([&]() {
  // 이 스레드에 실시간 우선순위 부여 (12.2)
  set_realtime_priority(90);
  rt_exec.spin();
});
std::thread aux_thread([&]() { aux_exec.spin(); });

rt_thread.join();
aux_thread.join();

이 구성이 강력한 이유:

이점설명
간섭 차단진단 노드의 느린 콜백이 제어 루프를 막지 못한다
OS 우선순위 분리executor 스레드마다 다른 스케줄링 정책(12.2)
$O(N)$ 분리각 executor의 엔티티 수가 작게 유지된다(2.2.7)
락 범위 축소같은 executor 안에서는 여전히 순차

이것이 component_container_isolated가 하는 일이다(1.13.4) — 노드마다 executor를 둔다.

견해: "큰 멀티스레드 executor 하나"보다 "작은 단일 스레드 executor 여럿"이 대체로 다루기 쉽다. 각 executor 안에서는 순차 실행이라 락이 필요 없고, executor 사이의 경계는 명확하다. 스레드 안전 문제가 executor 경계를 넘는 공유 상태로만 국한되므로 추론이 쉬워진다. 멀티스레드 executor는 "한 노드 안에서 병렬성이 필요할 때"로 범위를 좁히는 편이 좋다.

2.3.9 선택 절차

예 아니오 아니오 아니오 예 예 시작 콜백 안에서 서비스·액션 호출? MultiThreaded + 콜백 그룹 설계 모든 콜백이 주기의 절반 이내? SingleThreaded 느린 작업을 분리 가능? SingleThreaded + 워커 스레드
예 아니오 SingleThreaded 엔티티 수백 개 + 측정된 병목? EventsExecutor 검토
(계속)

추가 판단축:

조건방향
실시간 제어 루프가 있다별도 executor + 별도 스레드 + RT 우선순위(12.2)
결정성이 요구된다SingleThreaded 또는 rclc(12.5)
노드가 많다executor 분리 또는 isolated 컨테이너
팀이 동시성에 익숙하지 않다SingleThreaded + 워커 스레드

견해: "느린 작업을 워커 스레드로 빼는 것"이 멀티스레드 executor보다 나은 경우가 많다. 부하가 큰 계산을 콜백 밖 전용 스레드에 두고 결과만 주고받으면, executor는 단순하게 유지하면서 병목을 없앨 수 있다. 동기화 범위도 그 한 지점으로 좁혀진다.

// 콜백은 큐에 넣기만 하고 즉시 반환
void on_image(sensor_msgs::msg::Image::UniquePtr msg) {
  {
    std::lock_guard<std::mutex> lk(mtx_);
    if (queue_.size() < MAX_Q) queue_.push(std::move(msg));   // 넘치면 버림
  }
  cv_.notify_one();
}
// 워커 스레드가 부하가 큰 처리를 담당
void worker() {
  while (running_) {
    std::unique_lock<std::mutex> lk(mtx_);
    cv_.wait(lk, [this]{ return !queue_.empty() || !running_; });
    …
  }
}

큐에 상한을 두는 것이 중요하다 — 처리보다 입력이 빠르면 무한정 자란다. 상한을 넘으면 버리는 것이 메모리를 태우는 것보다 낫다(1.6.4의 depth 논리와 같다).

2.3.10 성능 비교를 읽는 법

executor 벤치마크 결과를 볼 때 그대로 적용하면 안 되는 이유:

변수영향
엔티티 수wait set 오버헤드가 여기 비례(2.2.7)
메시지 크기직렬화·복사가 지배적이면 executor 차이가 묻힌다
콜백 실행 시간콜백이 길면 executor 오버헤드는 무의미
코어 수멀티스레드의 이득이 결정됨
콜백 그룹 구성같은 executor라도 결과가 완전히 달라진다
RMW 벤더하위 대기 메커니즘이 다름(3.6)
커널·스케줄러깨우기 지연

견해: "어느 executor가 빠른가"는 잘못된 질문이다. 올바른 질문은 "내 워크로드에서 어디가 병목인가" 이며, 답은 대개 executor가 아니라 콜백 내용·메시지 크기·QoS·네트워크에 있다. executor를 바꿔 얻는 이득은 그 병목들을 제거한 뒤에야 보인다. 측정 방법은 11.7, 도구는 11.6.

2.3.11 단일 → 멀티 마이그레이션 체크리스트

  • [ ] 콜백 그룹을 설계했는가? 안 했다면 아무 효과가 없다(2.4).
  • [ ] 콜백 간 공유되는 멤버 변수를 전부 찾았는가?
  • [ ] 각 공유 변수에 락·원자 연산을 적용했는가(2.9)?
  • [ ] 락 순서가 일관되는가(교착 방지)?
  • [ ] 스레드 수가 동시 블로킹 가능 콜백 수보다 큰가?
  • [ ] 콜백 안에서 락을 쥔 채 다른 콜백을 유발하는 경로는 없는가?
  • [ ] TSAN(ThreadSanitizer)으로 빌드해 테스트했는가?
  • [ ] 실행 순서에 의존하던 로직은 없는가(2.2.11의 8번)?
  • [ ] 성능이 실제로 개선됐음을 측정했는가?
  • [ ] 같은 타입 엔티티(서비스·구독 등) 여러 개의 콜백 실행 순서를 코드 어딘가가 가정하고 있지 않은가? Humble·Jazzy 둘 다 이 순서를 보장하지 않는다(2.3.15).
  • [ ] 서비스·구독 콜백이 멱등한가(같은 요청·메시지가 두 번 들어와도 안전한가)? 드문 이중 호출이 양쪽 배포판에서 관측됐다(2.3.15).
# ThreadSanitizer로 경쟁 조건 탐지
colcon build --cmake-args -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_CXX_FLAGS="-fsanitize=thread -g"

견해: TSAN은 멀티스레드 executor를 도입할 때 사실상 필수다. 경쟁 조건은 테스트에서 통과하고 현장에서 드러난다. TSAN은 실제로 충돌이 일어나지 않아도 충돌 가능성을 검출해낸다. 성능 부하가 커서 상시 실행은 어렵지만, CI의 별도 잡으로 두면 값을 한다(4.10).

2.3.12 함정 카탈로그

#함정증상대처
1MultiThreaded로 바꾸고 그룹 미설정변화 없음·데드락 유지콜백 그룹 설계(2.4)
2스레드 수 부족여전히 교착동시 블로킹 수보다 크게
3공유 상태 락 누락간헐적 오동작TSAN·설계 검토
4spin_until_future_complete 반환값 무시타임아웃 후 블로킹반환 코드 분기
5벤치마크를 그대로 적용기대와 다른 결과자기 워크로드로 측정
6측정 없이 멀티스레드 도입복잡도만 증가병목 확인 먼저
7워커 큐에 상한 없음메모리 폭증상한 + 폐기 정책
8실시간 노드를 공용 executor에지터별도 executor + RT 우선순위
9실행 순서 의존멀티스레드에서 깨짐순서 비의존 설계
10static executor를 동적 구성에엔티티 갱신 누락구성 안정성 확인
11스레드가 많으면 빠르다고 가정타이머 하나에 Multi는 꼬리 2배기준은 "콜백이 서로 막는가"(2.5.13.3)

2.3.13 체크포인트

  • 네 executor의 동작·이점·위험을 표로 재구성할 수 있는가.
  • SingleThreadedExecutor에서 콜백 간 락이 필요 없는 이유는? 그것이 주는 설계상 이점은?
  • MultiThreadedExecutor로 바꿨는데 아무 변화가 없다면 무엇을 안 한 것인가.
  • 멀티스레드 executor에서 스레드 수를 정하는 기준은 무엇인가.
  • spin_until_future_complete가 executor 종류·호출 문맥에 따라 어떻게 달라지는지 네 경우로 말할 수 있는가.
  • "작은 단일 스레드 executor 여럿"이 "큰 멀티스레드 executor 하나"보다 다루기 쉬운 이유를 설명할 수 있는가.
  • 부하가 큰 계산을 워커 스레드로 빼는 접근이 멀티스레드 executor보다 나은 경우는 언제인가. 워커 큐에 반드시 필요한 것은?
  • 타이머와 구독이 동시에 준비되면 SingleThreadedExecutor는 어느 쪽을 먼저 실행되는가, 그리고 그것이 "보장"과 어떻게 다른가(2.3.14).
  • executor 벤치마크를 그대로 믿으면 안 되는 이유 일곱 가지를 들 수 있는가.
  • 단일 → 멀티 마이그레이션 체크리스트 중, 빠뜨리면 가장 위험한 항목 셋은 무엇인가.
  • EventsExecutor를 검토할 만한 조건은 무엇인가.
  • Humble과 Jazzy는 같은 타입 엔티티 여러 개의 디스패치 순서를 각각 어떤 서로 다른 메커니즘으로 깨는가, 그리고 그 차이가 "사전 스핀" 같은 완화책의 효과를 어떻게 가르는가(2.3.15).

2.3.14 타이머·구독·서비스가 동시에 준비되면 무엇이 먼저 도나 — 실측

0.3.5는 "executor의 콜백 실행 순서가 일관되게 보장되지 않는다"고 적었고, 11.1은 "실행 순서는 문서화된 계약이 아니다"라고 명시한다. 둘 다 "보장 없음"을 말할 뿐, 지금 Jazzy에서 실제로 어떻게 실행되는지는 이 책 어디에도 없었다.

실행 확인(2026-08-18, ros:jazzy-ros-base, rclpy, SingleThreadedExecutor) — 타이머 하나(주기 0.1 ms)와 TRANSIENT_LOCAL 구독 하나(메시지를 미리 발행해 즉시 ready 상태로 만듦)를 동시에 준비시킨 뒤 첫 두 콜백의 실행 순서를 25회 반복 관측했다.

시나리오반복결과
구독을 먼저 등록, 타이머를 나중에 등록15회TIMER → SUB, 15/15
등록 순서를 반대로(타이머 등록에 영향 없도록 구독을 먼저 생성)10회TIMER → SUB, 10/10

25번 모두 타이머가 먼저 실행됐다. 등록(생성) 순서를 바꿔도 결과가 바뀌지 않았다 — 이는 엔티티 등록 순서가 아니라 엔티티 종류(타이머 vs 구독)에 따른 내부 우선순위임을 시사한다.

이 결과가 0.3.5·11.1의 "보장되지 않는다"를 반박하는 것은 아니다. "문서화된 계약이 아니다"는 "다음 마이너 릴리스에서 바뀔 수 있다"는 뜻이지 "매 실행마다 무작위로 바뀐다"는 뜻이 아니다 — 실제로 현재 구현은 이 조건에서 100% 결정적이었다. 0.3.5가 인용한 "Iron 이전엔 구독이 타이머보다 먼저"라는 옛 관찰과 정반대 순서가 지금 관측된 것 자체가, "배포판이 바뀌면 순서가 바뀔 수 있다"는 책의 핵심 경고를 뒷받침한다.

실행 확인(2026-08-18, ros:jazzy-ros-base, 94차) — 위 "확인하지 못한 것" 중 두 가지(rclcpp 쪽·서비스까지 섞였을 때)를 마저 측정했다.

실험언어·executor반복결과
타이머 vs 구독, 등록 순서 양쪽rclcpp·SingleThreadedExecutor(spin_some 폴링)25회TIMER가 25/25 — rclpy와 동일
구독 vs 서비스(둘 다 사전에 ready 상태로 준비 — 구독은 TRANSIENT_LOCAL 선발행, 서비스는 별도 노드가 스핀 없이 call_async만 미리 보냄)rclpy·SingleThreadedExecutor25회SUB가 25/25 — 등록 순서 무관

두 결과를 이으면 TIMER → SUB → SRV 세 단계 순서가 확인된다. 12.5.4·2.2.9가 인용하는 "타이머 → 구독 → 서비스 → 클라이언트 → waitable" 순서 중 앞의 세 단계는 rclcpp·rclpy 양쪽에서 실측으로 확정됐다.

읽기 전에: 아래(95차~118차)에 나오는 "실제 동시 실행"·"병렬로 실행"·"스레드 경합"이라는 표현을 119~120차가 정정했다. 이 절의 MultiThreadedExecutor 실험은 전부 명시적 콜백 그룹 지정 없이 한 노드에 여러 엔티티를 등록했고, 이는 예외 없이 그 노드의 기본 MutuallyExclusive 그룹에 들어간다 — 이 그룹의 정의 자체가 "한 번에 하나만 실행"이므로, 아래에서 "동시 실행"·"병렬 실행"·"스레드 경합"이라고 부르는 것들은 실제로는 단 한 번도 동시에 일어난 적이 없다(119차가 두 가지 독립된 방법으로 직접 계측, 동시성 비율 예외 없이 0.0000). 측정된 FIRE_ORDER 이탈률 수치 자체는 유효하다 — 바뀌는 것은 그것을 "OS 스케줄러가 병렬 실행 중인 스레드를 어떤 순서로 깨우는가"로 설명하는 메커니즘 쪽이다. 실제 메커니즘 후보(선택·재구성의 비결정성)는 119~120차 문단(이 절의 끝 부분)에 있다 — 아래 회차별 서술은 당시 시점의 원래 설명을 그대로 남겨 두되, 이 정정을 배경 지식으로 전제하고 읽어야 한다.

실행 확인(2026-08-18, ros:jazzy-ros-base, rclcpp, 95차) — 남은 두 단계(클라이언트·waitable)와 MultiThreadedExecutor를 마저 측정했다. colcon build로 실제 실행 파일을 빌드해(syntax-only가 아니라 링크·실행) SingleThreadedExecutor에 타이머·구독·서비스·클라이언트·waitable 다섯을 동시에 준비시켰다 — 클라이언트는 별도 노드가 스핀 중인 서버에 미리 요청을 보내 응답을 대기 상태로 만들고, waitable은 rclcpp::Waitable을 상속한 커스텀 guard condition을 노드의 NodeWaitablesInterface에 등록해(2.10.11.3) 미리 trigger()했다.

조건반복결과
SingleThreadedExecutor, 등록 순서 정방향15회TIMER,SUB,SRV,CLIENT,WAITABLE — 15/15 완전 일치
SingleThreadedExecutor, 등록 순서 역방향10회동일 순서 — 10/10
MultiThreadedExecutor 객체에 spin_some() 호출25회(정·역방향 각 15·10)동일 순서 — 25/25(!)

세 번째 줄이 함정이다. MultiThreadedExecutor로 spin_some()을 부르면 스레드 풀이 전혀 관여하지 않는다 — multi_threaded_executor.hpp(Jazzy)를 보면 이 클래스는 spin()만 오버라이드하고 spin_some()은 기반 Executor의 단일 스레드 구현을 그대로 물려받는다. 즉 "MultiThreadedExecutor 인스턴스를 썼다"는 것만으로는 동시 실행이 전혀 검증되지 않는다 — spin_some()을 쓰는 테스트·벤치마크는 스레드 수와 무관하게 SingleThreadedExecutor와 똑같이 순서가 고정된다.

실제 동시성은 spin()으로 스레드 풀을 실행해야 나온다. 백그라운드 스레드에서 exec->spin()을 부르고 일정 시간 뒤 exec->cancel()하는 방식으로 다시 측정했다.

조건반복TIMER가 항상 1번SUB→SRV→CLIENT 순서 위반WAITABLE 실행 성공
MultiThreadedExecutor(threads=5)·spin()25회25/254/25(16%) — 예: TIMER,SUB,CLIENT,SRV·TIMER,CLIENT,SUB,SRV9/25(36%) — 실행되면 항상 TIMER 바로 다음, 아니면 완전히 유실
MultiThreadedExecutor(threads=2)·spin()15회15/157/15(47%) — 스레드가 적을수록 경합이 잦음2/15
MultiThreadedExecutor(threads=1)·spin()3회3/30/3(위반 없음)3/3

TIMER가 항상 먼저인 것은 40/40 모두 재현됐지만, SUB·SRV·CLIENT 사이의 순서는 실제 동시 실행에서 깨진다 — SingleThreadedExecutor·spin_some() 기반 테스트에서는 결코 보이지 않던 비결정성이다. threads=1로 주면 MultiThreadedExecutor is used with a single thread. Use the SingleThreadedExecutor instead. 경고를 내고 SingleThreadedExecutor와 정확히 같은 순서로 되돌아간다 — 스레드가 하나인 멀티스레드 executor는 이름만 다를 뿐 동작은 단일 스레드다.

커스텀 waitable(guard condition)은 별도의 문제를 보였다. SingleThreadedExecutor·spin_some() 기반 실험에서는 매번(15/15, 25/25) 정확히 실행됐지만, 실제 spin() 동시 실행에서는 9/25·2/15로 자주 완전히 유실됐고, 실행될 때는 항상 TIMER 바로 다음 자리였다(다른 위치로 옮겨진 적은 0건). 95차는 이 원인을 규명하지 못하고 물음표로 남겼다. (95차의 이 5-엔티티 테스트가 명시적 콜백 그룹을 지정했는지는 이번(119~120차) 재검토에서 소스가 남아 있지 않아 직접 확인하지 못했다(확인 필요) — 다만 바로 아래 96차가 이 유실을 "다른 엔티티와 같은 MutuallyExclusive 그룹을 공유하는가"로 정확히 설명한다는 것 자체가, 이 테스트도 단일 기본 그룹을 썼을 가능성을 강하게 시사한다. 만약 그렇다면 위 "SUB·SRV·CLIENT 순서 위반"·"실제 동시 실행" 서술도 119차 정정의 적용 대상이다.)

실행 확인(2026-08-19, ros:jazzy-ros-base, rclcpp, 96차) — 원인을 소스와 최소 재현으로 확정했다. rclcpp 소스(rclcpp/src/rclcpp/executors/multi_threaded_executor.cpp·executor.cpp, Jazzy 브랜치)를 읽으면 두 가지가 겹친다.

  1. MultiThreadedExecutor::run()은 "다음에 무엇을 실행할지 고르는 것"만 wait_mutex_로 직렬화한다 — get_next_executable() 호출은 한 번에 스레드 하나만 들어갈 수 있지만, 실제 콜백 실행(execute_any_executable())은 락 밖에서 실행된다. 즉 스레드는 "하나씩 순서대로 선택하고, 선택한 뒤에는 병렬로 실행"한다(정확히는 "병렬로 실행할 수 있는 위치로 나간다" — 실제로 병렬 실행이 일어나는지는 콜백 그룹 구성에 달려 있다. 이 책의 gap109~gap118 테스트는 전부 단일 기본 MutuallyExclusive 그룹을 썼으므로, 이 락 구조 자체는 정확하지만 그로부터 "그래서 실제로 병렬 실행됐다"로 이어진 91~98차의 추론은 119차가 정정했다).
  2. Executor::get_next_ready_executable()(executor.cpp)은 엔티티 종류마다 "바쁜 콜백 그룹은 건너뛴다"는 같은 규칙을 쓰지만, 복구 방식이 다르다. 타이머는 건너뛴 항목을 인덱스로 기억해 다음 호출에서도 다시 시도하지만(current_timer_index), 구독·서비스·클라이언트·waitable은 next_ready_X()라는 전진 전용 반복자를 쓴다 — 바쁜 그룹이라 건너뛰면 그 항목은 이번 wait 배치에서 다시 돌아오지 않는다. 구독·서비스·클라이언트는 그래도 안전하다 — 밑바탕 데이터(메시지·요청)가 DDS reader에 그대로 남아 있어 다음 rcl_wait()에서 다시 ready로 판정된다(그래서 CHG-118이 관측한 건 유실이 아니라 순서 위반이었다). guard condition은 다르다 — 엣지 트리거라서 rcl_wait()가 그것을 한 번 "ready"로 수집하는 순간 내부 트리거 플래그가 즉시 소비된다. 서비스되지 못한 채 건너뛰어도 트리거는 이미 사라졌으므로, 다음 rcl_wait()는 그것을 다시는 ready로 보지 않는다 — 완전히, 조용히 사라진다.

즉 유실의 필요충분조건은 스레드 수나 콜백 그룹의 종류(Reentrant 대 MutuallyExclusive)가 아니라 "guard condition 기반 waitable이 다른 바쁜 엔티티와 같은 MutuallyExclusive 콜백 그룹을 공유하는가"이다. 최소 재현(타이머 하나 + guard condition waitable 하나, docker run --rm ros:jazzy-ros-base)으로 확인했다.

콜백 그룹 구성스레드 수타이머 콜백 소요반복waitable 실행 성공
타이머·waitable 같은 그룹(기본값)25 ms150/15(0%) — 전부 유실
〃45 ms150/15(0%)
〃20 ms(즉시 반환)150/15(0%) — 콜백이 순간적이어도 유실
〃21 ms·20 ms300/30(0%)
타이머·waitable 같은 그룹, 스레드 1개(경합 불가)15 ms1515/15(100%) — 전부 성공
타이머·waitable 다른 그룹(waitable을 별도 MutuallyExclusive 그룹으로)25 ms1515/15(100%) — 전부 성공
〃45 ms1515/15(100%)

스레드가 하나면(경합 자체가 불가능) 100% 성공하고, 둘 이상인데 같은 그룹이면 100% 유실이며, 그룹만 분리하면 스레드 수와 무관하게 다시 100% 성공한다 — 중간값이 없는 이진 스위치다. 콜백 소요 시간을 0 ms로 낮춰도(즉시 반환) 결과가 바뀌지 않는 것은, 문제가 "콜백이 오래 걸려서"가 아니라 두 번째 스레드가 wait_mutex_ 앞에서 이미 대기 중이라 첫 스레드가 그룹을 바쁘게 만드는 순간과 거의 동시에 재확인을 시도하기 때문임을 보여 준다. 95차가 측정한 9/25·2/15라는 중간 확률은, 5개 엔티티가 뒤섞인 원래 실험에서 이 이진 스위치가 매번 같은 쪽으로 떨어지지 않고 타이밍에 따라 갈렸기 때문으로 설명된다 — 근본 메커니즘은 확정적이고, 관측된 확률성은 더 복잡한 계에서 이 스위치가 어느 쪽으로 넘어가는지의 타이밍 잡음이다.

견해: guard condition 기반 커스텀 waitable을 MultiThreadedExecutor와 함께 쓴다면, 그 waitable을 다른 엔티티와 절대 같은 콜백 그룹에 두지 말라. 전용 콜백 그룹 하나를 만들어 waitable만 등록하면 이 유실은 구조적으로 발생하지 않는다. 97차가 Reentrant 그룹의 효과까지 실측으로 닫았다 — 상세는 2.10.11.3.

이 결과가 뒤집는 것과 뒤집지 않는 것. 12.5.4·2.2.9가 "MultiThreadedExecutor에서도 같은지 확인하지 않았다"고 적어 둔 자리는 이제 답이 있다 — 확인했고, 같지 않다. 다만 이것이 "MultiThreadedExecutor가 고장"이라는 뜻은 아니다. 멀티스레드 executor는 애초에 병렬 실행을 위해 쓰는 것이지, 순서를 보장하려고 쓰는 것이 아니다 — 오히려 이번 실측은 "순서에 의존하지 말라"(12.5.4의 견해)는 이 책의 기존 권고가 SingleThreadedExecutor에서도 필요하지만 MultiThreadedExecutor에서는 필수임을 수치로 보여 준다.

실행 확인(2026-08-19, ros:jazzy-ros-base, rclcpp, 97차) — EventsExecutor에서는 순서 자체가 바뀐다. 94~96차가 TIMER→SUB→SRV→CLIENT→WAITABLE을 확정한 것은 전부 wait set 기반(SingleThreadedExecutor·MultiThreadedExecutor) executor였다. EventsExecutor는 폴링이 아니라 미들웨어 리스너 콜백이 이벤트 큐에 push하는 구조(2.3.6)라 같은 실험을 해 보면 결과가 다를 것으로 예상됐다 — 정말 달랐다.

TIMER(1 ms 주기)·TRANSIENT_LOCAL 구독·(사전 호출된) 서비스·(사전 응답이 도착한) 클라이언트 넷을 앞선 회차와 같은 방식으로 "이미 준비된 상태"로 만든 뒤, 실행에 쓸 executor만 바꿔 첫 콜백 순서를 측정했다(discovery는 별도 임시 executor로 먼저 끝내고, 측정은 대상 executor에 노드를 새로 추가해 시작).

executor반복순서
SingleThreadedExecutor(대조군, 기존 결과 재현)6TIMER SUB SRV CLIENT — 6/6
EventsExecutor14SUB CLIENT SRV TIMER — 14/14

SingleThreadedExecutor는 기존에 확정된 순서를 6/6 그대로 재현했고(하네스 검증), EventsExecutor는 14/14 똑같이 다른 순서를 냈으며 결정적으로 TIMER가 맨 앞이 아니라 맨 뒤다. 소스로 원인을 좁히면: events_executor.cpp는 타이머를 TimersManager가 자체적으로 추적하다 실제로 주기가 만료돼야 TIMER_EVENT를 큐에 넣지만, 구독·서비스·클라이언트는 set_on_ready_callback으로 등록되는 순간 이미 쌓여 있던 준비 상태("unread count")를 즉시 콜백으로 통지한다(GuardCondition::set_on_trigger_callback과 같은 패턴, 2.10.11.3). 즉 이 실험처럼 SUB·SRV·CLIENT를 executor가 기동되기 전에 미리 준비시켜 두면, executor가 시작하자마자(t≈0) 그 셋의 이벤트가 큐에 먼저 삽입되고, 1 ms 주기 타이머는 실제로 1 ms가 지나야 큐에 들어간다 — wait set executor에서 TIMER가 구조적으로 항상 우선하는 것과 정반대 이유로, EventsExecutor에서는 구조적으로 항상 진다.

견해: "executor 실행 순서에 의존하지 말라"는 원칙이 여기서 가장 강하게 확인된다. SingleThreadedExecutor·MultiThreadedExecutor·EventsExecutor가 같은 코드베이스, 같은 배포판 안에서 정반대의 결정적 순서를 낸다 — "느리게 보이던 것"이 아니라 "설계상 이렇게 될 수밖에 없는 것"이다. executor 종류를 바꾸는 리팩터링(예: 확장성을 위해 EventsExecutor로 전환)이 타이밍에 은근히 의존하던 코드를 조용히 깨뜨릴 수 있다는 뜻이다.

EventsExecutor에서 커스텀 waitable(guard condition)이 어떻게 되는지 — 등록 자체가 실패한다 — 는 2.10.11.3에서 다룬다.

*실행 확인(2026-08-19, ros:jazzy-ros-base·ros:humble-ros-base, 108차) — 여기까지는 전부 서로 다른 엔티티 종류 사이의 순서였다. 같은 종류 엔티티 여러 개끼리는 어떨까? SingleThreadedExecutor에 구독 6~10개(같은 타입, 전부 사전 발행으로 동시에 ready)를 등록해 측정해 보니, Jazzy는 등록 순서와 무관한 고정된 듯 보이는 순서를 내다가 60회 중 4회(6.7%)는 그 순서마저 바뀌었고, Humble은 40/40 항상 등록 순서 그대로였다.* 원인은 executor_entities_collection.hpp의 EntityCollection이 rcl 핸들 포인터를 키로 쓰는 std::unordered_map이기 때문이다(PR #2142가 도입) — 상세 수치·소스 추적·재현 코드는 15.1.3의 정직 문단, 하네스는 tools/gap_harness/gap108/.

실행 확인(2026-08-19, ros:jazzy-ros-base·ros:humble-ros-base, 109차) — 같은-타입 순서를 구독 밖의 네 엔티티 종류(타이머·서비스·클라이언트·waitable)로 확장하고, MultiThreadedExecutor의 실제 동시 실행에서도 측정해 봤다. 108차가 남긴 두 제안을 이었다. 먼저 소스로 범위를 확인했다 — executor_entities_collection.hpp의 ExecutorEntitiesCollection 구조체는 timers·subscriptions·clients·services·guard_conditions·waitables 여섯 개의 독립된 멤버를 갖고, 각각 별도의 EntityCollection<K, V>(즉 별도의 std::unordered_map 인스턴스, 키 타입만 rcl_timer_t*·rcl_subscription_t* 등으로 다름) 인스턴스다 — 같은 해시맵을 공유하는 것이 아니라, 같은 템플릿을 다섯~여섯 번 인스턴스화한 것이다. 이 사실 자체가 "왜 다섯 종류가 정도는 다르지만 질적으로 같은 현상을 보이는가"의 답이다 — 메커니즘(원시 포인터를 키로 쓰는 unordered_map)은 동일하되 인스턴스가 분리돼 있어, 종류마다 실제 버킷 배치(따라서 이탈률)는 독립적으로 달라질 수 있다.

gap108과 같은 방식(사전에 전부 ready로 만들어 두고 SingleThreadedExecutor::spin_some() 한 번으로 첫 실행 순서를 기록, 프로세스를 새로 기동하며 반복)을 타이머(주기 5 ms인 N개를 동시에 만료시킴)·waitable(guard condition N개를 스핀 전에 전부 trigger(), 2.10.11.3 방식)·서비스(별도 클라이언트 노드가 N개 요청을 미리 전송)·클라이언트(별도 서버 노드가 백그라운드에서 즉시 응답, N개 응답이 미리 도착한 뒤 스핀)로 확장했다(tools/gap_harness/gap109/).

엔티티조건배포판반복최빈 순서 비율이탈률
타이머N=6, 정방향Jazzy2020/200%
타이머N=6, 역방향Jazzy1515/150%
타이머N=10, 정방향Jazzy2020/200%
waitableN=6, 정방향Jazzy2020/200%
waitableN=6, 역방향Jazzy1515/150%
waitableN=10, 정방향Jazzy2020/200%
서비스N=6, 정방향Jazzy2011/2045%(서로 다른 순서 5종)
서비스N=6, 역방향Jazzy1512/1520%
클라이언트N=6, 정방향Jazzy1512/1520%
클라이언트N=6, 역방향Jazzy109/1010%
타이머·waitable·서비스·클라이언트N=6, 정·역방향Humble각 1010/10(전부)0%(등록 순서 그대로)

세 가지가 드러났다. ① 다섯 종류 모두 Jazzy에서 등록 순서를 지키지 않는다 — 최빈 순서 자체가 등록 순서(0,1,2,3,4,5)와 무관했다(예: 타이머 정방향의 최빈 순서는 4,3,2,5,1,0). Humble은 네 종류(구독은 108차가 이미 확인) 모두 40회 전부 등록 순서 그대로였다 — CHG-132가 구독 하나로 확정한 것이 다섯 엔티티 종류 전부로 일반화된다. ② 이탈률(같은 조건을 반복했을 때 최빈 순서와 달라지는 빈도)은 엔티티 종류마다 크게 다르다 — 타이머·waitable은 이번 표본(N=6 40회·N=10 20회씩)에서 이탈이 한 번도 없었던 반면, 서비스·클라이언트는 10~45%로 뚜렷하게 흔들렸다. ③ 다만 이 차이를 전부 "해시 버킷 분포가 종류마다 다르다"로 결론짓기는 이르다 — 구독·타이머·waitable은 스핀 전에 이미 결정론적으로 ready 상태를 만들 수 있지만(사전 발행·주기 만료·수동 트리거), 서비스·클라이언트는 실제 DDS 왕복(요청 전송 → 서버 수신, 또는 응답 전송 → 클라이언트 수신)이 필요해 400 ms 대기가 "충분히 길기를 바라는" 근사일 뿐이다. 즉 서비스·클라이언트의 높은 이탈률 중 일부는 순수한 해시 순서 비결정성이 아니라 어떤 요청·응답이 첫 rcl_wait() 배치에 실제로 늦게 도착했는가라는 별개의 타이밍 잡음일 수 있다 — 이번 실험은 둘을 분리하지 못했다(확인 필요).

MultiThreadedExecutor의 실제 동시 실행(spin()을 백그라운드 스레드에서 실행하고 일정 시간 뒤 cancel(), 구독 N=6)에서는 이탈률이 훨씬 커졌다 — SingleThreadedExecutor에서 이탈이 전혀 없었거나(타이머·waitable) 낮았던(구독, 108차 기준 4~10%) 것과 대조적이다.

조건배포판스레드반복최빈 순서 비율이탈률
구독 N=6, 정방향Jazzy42014/2030%
구독 N=6, 역방향Jazzy4157/1553.3%
구독 N=6, 정방향Jazzy21510/1533.3%
구독 N=6, 정방향Humble4108/1020%
구독 N=6, 역방향Humble4107/1030%

가장 중요한 관찰은 Humble 행이다. SingleThreadedExecutor에서는 다섯 엔티티 종류 전부 40회 중 0건 이탈했던 바로 그 Humble이, MultiThreadedExecutor::spin()으로 실제 동시 실행을 시키면 20~30% 이탈한다. Humble에는 EntityCollection도 std::unordered_map도 없으므로, 이 이탈은 108차가 확정한 "해시 버킷 순서" 메커니즘과는 완전히 다른 원인이다 — 여러 워커 스레드가 wait_mutex_로 직렬화된 "다음 실행 대상 선택"(96차가 이미 확인한 구조, 2.3.14 위 문단)에서 선택된 뒤에는 각자 병렬로 실행되므로, 콜백 진입 시점에 공유 벡터에 순서를 기록하는 이 실험의 계측 자체가 "executor가 정한 디스패치 순서"와 "OS 스케줄러가 그 순간 어느 스레드를 먼저 깨우는가"를 뒤섞는다(95차가 다른-종류 엔티티 실험에서 이미 지적한 것과 같은 한계). Jazzy의 이탈률(30~53%)이 Humble(20~30%)보다 전반적으로 높은 것은, 두 메커니즘(해시 버킷 기반 정적 순서 비결정성 + 스레드 경합에 의한 동적 순서 비결정성)이 Jazzy에서는 겹쳐서 작용하고, Humble에서는 후자만 작용하기 때문으로 보인다 — 다만 표본이 각 10~20회뿐이라 이 차이가 통계적으로 유의한지는 확인하지 않았다(확인 필요). (→ 119차 정정: "선택된 뒤에는 각자 병렬로 실행"·"OS 스케줄러가 어느 스레드를 먼저 깨우는가" 부분은 이 테스트 계열에 대해 반증됐다 — 이 실험도 단일 기본 MutuallyExclusive 그룹을 썼으므로 실제 병렬 실행 자체가 없었다. 이탈률 수치는 유효하지만 설명은 아래 119~120차 문단을 따른다.)

확인하지 못한 것. ① 서비스·클라이언트 실험은 DDS 왕복 타이밍에 의존해 구독·타이머·waitable과 실험 통제 수준이 다르다 — 관측된 높은 이탈률 중 얼마가 순수 해시 순서 때문이고 얼마가 도착 타이밍 잡음 때문인지 분리하지 못했다. ② 타이머·waitable·서비스·클라이언트는 N=6(일부 N=10)에서만 측정했다 — 108차가 구독 N=10에서 본 "N이 클수록 이탈률도 커진다"는 경향이 다른 종류에도 적용되는지는 타이머·waitable의 N=10 결과(0/20, 0/20)가 오히려 반증하는 것처럼 보이지만, 표본이 작아 결론짓기는 이르다. ③ MultiThreadedExecutor 실험은 구독에만, 스레드 2·4개에만 했다 — 타이머·서비스·클라이언트·waitable의 MultiThreadedExecutor 거동, 더 많은 스레드 수에서의 경향은 측정하지 않았다. ④ 108차가 남긴 ASLR 관련 미확인(6.7% 이탈의 원인)은 이번에도 다루지 않았다.

새 하네스는 tools/gap_harness/gap109/(timer_order_test.cpp·waitable_order_test.cpp·service_order_test.cpp·client_order_test.cpp·multi_sub_order_test.cpp).

실행 확인(2026-08-19, ros:jazzy-ros-base·ros:humble-ros-base, 112차) — 109차가 남긴 세 가지(해시 순서 대 도착 잡음 분리, MultiThreadedExecutor 확장, 통계적 유의성)를 전부 처리했다.

① 서비스·클라이언트의 이탈이 해시 순서 때문인지 도착 잡음 때문인지 분리했다. rclcpp::ServiceBase::set_on_new_request_callback()·ClientBase::set_on_new_response_callback()는 미들웨어 리스너 스레드에서 직접 호출된다(executor 스핀과 무관 — 97차가 확인한 EventsExecutor의 원리와 같다). 각 서비스·클라이언트 생성 직후 이 콜백을 등록해 ARRIVAL_ORDER(도착 순서, executor 개입 없음)를 독립적으로 기록하고, 기존과 같은 400 ms 대기 뒤 spin_some()으로 FIRE_ORDER(디스패치 순서)를 측정하는 방식은 유지했다.

엔티티배포판방향반복ARRIVAL_ORDER 이탈률ARRIVAL=REG_ORDERFIRE_ORDER 이탈률FIRE=ARRIVAL
서비스Jazzy정400%(40/40 동일)40/40(100%)80%0/40(0%)
서비스Jazzy역250%(25/25 동일)25/25(100%)60%1/25(4%)
클라이언트Jazzy정4050%0/40(0%)15%0/40(0%)
클라이언트Jazzy역2548%0/25(0%)20%0/25(0%)
클라이언트Humble정+역320%(32/32 동일)32/32(100%)0%32/32(100%)

서비스(Jazzy)가 가장 명확한 분리를 준다. 요청을 보내는 클라이언트는 executor를 전혀 거치지 않는 순차 루프이므로, 서버가 받는 ARRIVAL_ORDER는 65회 전부 전송 순서와 완벽하게 일치한다 — 도착은 100% 결정적이다. 그런데 그 위에서 FIRE_ORDER는 최빈값조차 40회 중 8회(20%)뿐이고 서로 다른 순서가 16종(6! = 720가지 중) 나왔으며, FIRE_ORDER가 ARRIVAL_ORDER와 같았던 적은 0/40이다. 도착이 완벽히 결정적인데 디스패치가 거의 매번 다르다는 것은, 109차가 분리하지 못한 이탈이 도착 타이밍 잡음이 아니라 순수하게 디스패치 쪽(해시 버킷 순서)임을 확정한다. 표본을 20 → 40회로 늘리자 이탈률(45% → 80%)도 서로 다른 순서 가짓수(5종 → 16종)도 더 커졌다는 것은, "가끔 다른 순서"가 아니라 매 프로세스 실행마다 사실상 새로 선택되는 순서에 가깝다는 뜻으로 읽힌다 — ASLR이 매 프로세스의 힙 주소를, 따라서 포인터 키 해시 버킷 배치를 그때그때 바꾼다는 108차 이후의 추정과 들어맞지만 직접 계측하지는 못했다(확인 필요).

클라이언트(Jazzy)는 반대로 도착 자체가 이미 흔들린다 — 이 응답은 서버 노드의 SingleThreadedExecutor::spin()이 만드는데, 그 서버 쪽 디스패치 자체가 108차가 확정한 해시 버킷 비결정성의 대상이라 클라이언트가 보는 "도착 순서"에는 서버 쪽 비결정성이 이미 섞여 있다 — 서비스만큼 명확한 분리가 되지 않는다는 것도 이번 실험이 새로 확인한 한계다. FIRE_ORDER(15~20% 이탈)가 오히려 ARRIVAL_ORDER(48~50% 이탈)보다 안정적인 것은, 400 ms 대기로 N개를 전부 ready로 만든 뒤 스핀하므로 한 프로세스 실행 안에서는 해시 버킷 배치가 고정돼 있기 때문으로 보인다. Humble 클라이언트는 도착·디스패치·전송이 32/32 전부 일치한다 — 해시맵이 없다는 108차 확정과 정확히 들어맞는다. (Humble 서비스는 set_on_new_request_callback이 정방향 20회 중 14회 콜백 전체를 두 번 반복 호출하는 원인 미상의 결함이 섞여 있어 표에서 뺐다 — 상세는 tools/gap_harness/gap112/README.md.)

② MultiThreadedExecutor의 실제 동시 실행을 타이머·서비스·클라이언트·waitable로, 스레드 2·4·8로 확장했다(109차는 구독·스레드 2·4에만 했다).

엔티티배포판스레드반복(정+역)이탈률
타이머Jazzy2250~20%
타이머Jazzy4250%(양방향 모두)
타이머Jazzy82513.3~30%
타이머Humble41830~62.5%
waitableJazzy2·4·854100%(54/54 전부 유실)
waitableHumble2·4·826100%(26/26 전부 유실)
서비스Jazzy2·4·87553.3~70%
서비스Humble41875~80%
클라이언트Jazzy2·4·8756.7~13.3%
클라이언트Humble4180%
구독(확장)Jazzy82550~53.3%
구독(확장)Humble8100%

waitable이 가장 명확하고 예상 밖인 결과다. 스레드 2·4·8·정역방향·Jazzy·Humble 80회 전부 fired=0이었다 — 단 하나도 실행되지 못했다. 96차는 "guard condition waitable이 다른 바쁜 엔티티와 그룹을 공유하면" 유실된다고 확정했는데, 이번 실험은 같은 종류(waitable)끼리만 그룹을 공유해도 스레드 2개 이상이면 100% 유실됨을 보인다. 그리고 이 유실은 Humble에서도 정확히 재현된다 — Humble엔 108차가 확정한 EntityCollection/std::unordered_map이 전혀 없는데도 26/26 100% 유실이다. 즉 waitable 유실 메커니즘(전진 전용 반복자가 바쁜 그룹을 건너뛰고, 엣지 트리거 guard condition은 건너뛴 순간 트리거가 사라짐)은 PR #2142의 Jazzy 전용 캐시 최적화와 무관하게, 두 배포판이 공유하는 더 오래된 executor.cpp 코드에 있다 — 96차가 원인을 확정할 때는 이 부분(Jazzy 전용인지 공유인지)까지는 밝히지 않았었다.

서비스는 스레드 경합이 해시 순서보다 크게 작용한다 — Jazzy(53~70%)·Humble(75~80%) 둘 다 매우 높고 Humble이 근소하게 더 높다(표본이 작아 통계적으로 구분되지 않는다, 아래 참고). 클라이언트는 넷 중 가장 안정적이다(Jazzy 6.7~13.3%, Humble 0%) — 같은 엔티티가 ①에서는 도착 자체가 흔들렸지만, ②는 도착 이후 스레드 풀 분배 순서를 측정하는 다른 실험이기 때문이다. 타이머는 배포판별 방향이 갈렸다 — Jazzy는 스레드 4에서 오히려 0%(2·8에서는 이탈, 스레드 수에 비례하지 않는 비단조 패턴)인 반면 Humble은 스레드 4에서 30~62.5%로 훨씬 높다.

③ 구독·스레드 4의 Jazzy·Humble 이탈률 차이를 각 60회 표본으로 통계 검정했다(109차는 10~20회로만 관측하고 검정 없이 "겹쳐 작용하는 것으로 보인다"고만 적었다). 정방향만 각 60회 새로 표집한 두-비율 z-검정(pooled) 결과:

배포판반복이탈이탈률
Jazzy602745.0%
Humble6058.3%

$$z = \frac{\hat p_1 - \hat p_2}{\sqrt{\hat p(1-\hat p)\left(\frac{1}{n_1}+\frac{1}{n_2}\right)}} = 4.541, \quad p = 6\times10^{-6}$$

유의수준 0.001에서도 "두 배포판의 이탈률이 같다"는 귀무가설을 기각한다 — Jazzy·Humble의 MultiThreadedExecutor 이탈률 차이는 통계적으로 유의하다. 역방향까지 합친 표본(각 80회, Jazzy 45.0%·Humble 7.5%)으로도 $z=5.390$, $p=7\times10^{-8}$로 결론은 바뀌지 않는다. 다만 이번 새 표본의 점추정치는 109차의 관측 범위(Jazzy 30~53%, Humble 20~30%)를 벗어나는 방향으로 움직였다(Jazzy는 상단 쪽, Humble은 하단보다도 낮은 쪽) — 109차의 표본(10~20회)이 두 배포판 다 큰 표본 오차를 안고 있었다는 뜻으로 읽힌다. 같은 방식으로 검정한 타이머·서비스는 표본이 훨씬 작아(각 8~15회) 결론이 잠정적이다 — 타이머는 $z=-2.261$, $p=0.024$로 명목상 유의하되 방향이 반대(Humble이 더 나쁨)이고, 서비스는 $z=-0.727$, $p=0.467$로 유의하지 않다.

확인하지 못한 것을 명시한다. ① Humble의 set_on_new_request_callback이 서비스에서 종종(정방향 20회 중 14회) 콜백을 두 번 반복 호출하는 원인을 rmw/rcl 소스까지 들어가 확정하지 못했다(확인 필요). ② ASLR이 매 프로세스 실행마다 해시 버킷 순서를 재추첨한다는 설명은 여전히 견해다 — 이 도커 환경이 personality()를 막아 108차부터 직접 계측하지 못하고 있다(확인 필요). ③ 타이머의 스레드 수에 대한 비단조 패턴, 서비스·타이머의 Jazzy·Humble 차이의 통계적 유의성은 표본이 작아(각 8~15회) 결론짓지 못했다. ④ CHG-122 계열의 나머지(실제 다중 호스트, stress-ng 통제 실험)는 이번에도 다루지 않았다.

새 하네스는 tools/gap_harness/gap112/(gap112_order/ 패키지 — service_arrival_test.cpp·client_arrival_test.cpp·multi_timer_order_test.cpp·multi_waitable_order_test.cpp·multi_service_order_test.cpp·multi_client_order_test.cpp·multi_sub_order_test.cpp, 원시 로그 포함).

실행 확인(2026-08-19, ros:jazzy-ros-base·ros:humble-ros-base, 113차) — 112차가 남긴 세 제안(Humble 이중 호출 원인, 타이머 스레드 패턴 재검정, 타이머·서비스 통계 검정 확대)을 전부 처리했다.

① Humble 서비스의 set_on_new_request_callback 이중 호출 — 실재하는 메커니즘은 찾았지만 증상은 재현하지 못했다. rclcpp::ServiceBase::set_on_new_request_callback()(Jazzy·Humble 소스 둘 다 확인, 완전히 동일)은 콜백을 등록할 때마다 rmw 계층 함수를 의도적으로 두 번 부른다 — "임시 콜백으로 먼저 등록 → 영구 저장소로 옮긴 뒤 다시 등록"하는 2단계 패턴으로, 콜백 교체 도중 미들웨어가 낡은 포인터를 참조하는 틈을 막기 위한 설계다. rmw_fastrtps_shared_cpp의 ServiceListener::set_on_new_request_callback()은 등록 시점에 적체된 미확인 요청이 있으면 즉시 캐치업 호출을 하는 로직을 갖고 있어, 이론상 이 2단계 등록이 이중 호출을 유발할 가능성은 있다. 그러나 락 없는 링 버퍼로 계측해 보니 1·2단계 호출은 서비스 생성 직후 ~30마이크로초 간격으로 연이어 일어나는 반면, 112차가 관측한 이중 도착은 첫 배치와 둘째 배치 사이가 ~140~210밀리초였다 — 자릿수가 4~5단 다르다. 결정적 반증은 Jazzy도 정확히 같은 2단계 등록 코드를 쓰는데 Jazzy 서비스는 65회 전부 이중 도착이 없었다는 것이다 — 즉 이 메커니즘은 실재하고 항상 일어나지만 112차의 증상을 설명하지 못한다. 재현 자체도 실패했다 — 순정 빌드 20회, 계측 빌드 21회, stress-ng --cpu 16 부하 중 계측 빌드 15회, 총 66회 전부 이중 도착이 재현되지 않았다(확인 필요). 112차 세션에 특유했던 일시적 조건(동시에 실행 중이던 다른 컨테이너 부하, 그 시점의 디스커버리 타이밍 등)에 의존했을 가능성으로 남긴다.

② 타이머의 스레드 수 비단조 패턴은 표본을 50회로 늘리니 사라졌다. 112차가 10~15회로 관측한 "스레드 4에서 0%, 2·8에서는 이탈"이라는 모양을, MultiThreadedExecutor·타이머·N=6·스레드 2/4/8·정역방향 각 50회로 재표집했다.

스레드정방향(50회)역방향(50회)
26.0%10.0%
412.0%10.0%
88.0%8.0%

비단조 패턴이 사라졌다 — 세 스레드 수 모두 6~12%로 고르게 분포하고, 인접 조건 쌍의 두-비율 z-검정이 전부 유의하지 않다($p=0.29\sim1.0$). 112차의 "스레드 4에서 0%"는 작은 표본의 우연이었다.

③ 타이머·서비스의 Jazzy·Humble MultiThreadedExecutor 통계 검정을 구독과 같은 표본 크기(정방향 각 60회, 역방향 각 20회)로 확대했다 — 구독과 달리 유의하지 않다.

엔티티Jazzy 이탈률(정방향 60회)Humble 이탈률(정방향 60·타이머는 정 60/역 20)$z$(정방향)$p$(정방향)
타이머12.0%(50회)15.0%$-0.457$$0.648$
서비스68.3%78.3%$-1.239$$0.216$

둘 다 유의수준 0.05에서 "두 배포판이 같다"는 귀무가설을 기각하지 못한다. 특히 타이머는 112차의 소표본 검정(정방향 15 대 10, $z=-2.261$, $p=0.024$, "명목상 유의")이 뒤집혔다 — 표본을 50~60회로 늘리자 유의성이 사라졌다. 서비스는 정+역 합산(각 80회)에서도 $p=0.072$로 관례적 기준을 근소하게 넘지 못한다(Humble이 수치상 계속 높긴 하다 — 68~68% 대 78~80%). 구독은 108~109차가 확정한 해시맵(정적) 원인과 96차가 확정한 스레드 경합(동적) 원인이 겹쳐 Jazzy가 통계적으로 유의하게 불리하지만, 타이머·서비스는 스레드 경합 쪽이 워낙 압도적이라(타이머 6~19%, 서비스 68~85%) 해시맵이 보태는 차이가 통계로 드러날 만큼 크지 않은 것으로 보인다(견해, 정확한 비중 분해는 하지 않았다).

확인하지 못한 것을 명시한다. ① Humble 서비스 콜백 이중 호출의 정확한 트리거는 66회의 통제 재시도가 전부 실패해 이번에도 확정하지 못했다(확인 필요). ② 서비스의 Jazzy·Humble 차이는 $p=0.072$로 관례적 기준에 근접했다 — 표본을 더 늘리면 유의해질 수도 있다(확인 필요). ③ 타이머·서비스의 "해시맵 대 스레드 경합 비중" 분해는 견해로만 남겼다. ④ ASLR 관련 미확인은 이번에도 다루지 않았다.

새 하네스는 tools/gap_harness/gap113/(계측된 rmw_fastrtps_shared_cpp 헤더·등록 트레이스 예시·원시 로그·README.md — multi_timer_order_test·multi_service_order_test는 gap112의 실행 파일을 무수정 재사용).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 114차) — 113차의 "경계선" 결과를 표본 확대로 마무리했다.

서비스의 Jazzy·Humble MultiThreadedExecutor 이탈률 차이 — 표본을 150회로 늘리니 유의해졌다. 113차(60회)에 90회를 더해(150개 원시 순서를 하나의 모집단으로 다시 집계) 재검정한 결과, Jazzy 54.0%(69/150 최빈 순서 적중) 대 Humble 74.7%(38/150), $z=-3.736$, $p=1.87\times10^{-4}$로 유의수준 0.001에서 귀무가설을 기각한다 — 113차의 정방향-단독 검정($p=0.072$)은 표본 부족으로 경계선에 걸려 있었을 뿐, 실제로는 유의한 차이(Humble이 더 불리하다)였다. 새 표본(90회)만 단독 검정해도 $p=1.57\times10^{-4}$로 같은 결론이 독립 재현된다.

Humble 서비스 콜백 이중 호출은 111회째 재현에 실패했다. 113차 자신이 "서비스 테스트를 고립시킨 것 자체가 원인일 수 있다"고 제안한 가설을 검증하려고, service_arrival_test 전에 다른 엔티티 종류(타이머·waitable·클라이언트·구독 45회) 를 먼저 실행하는 조건을 시도했다 — 정방향 30회·역방향 15회 전부 이중 도착이 없었다. 스크립트를 다시 대조해 보니 112차의 원래 배치도 이미 service_arrival_test가 맨 처음이었다는 것도 이번에 확인했다 — 113차 자신의 가설 전제가 애초에 틀렸다는 뜻이다. 그럼에도 새로 시도한 "다른 엔티티 선행 실행" 조건도 재현에는 실패했다 — 순정·계측·stress-ng 부하·엔티티 선행 실행 네 조건, 총 111회가 전부 무사했다(확인 필요).

새 하네스는 tools/gap_harness/gap114/(원시 로그·분석 출력·README.md — multi_service_order_test는 gap112 실행 파일 무수정 재사용). py::set 순회 순서 500회 완결(CHG-134 이월 항목)은 2.8.12.14를 참고.

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 115차) — 타이머 통계 검정이 서비스처럼 뒤집혔고, ASLR을 6회차 만에 실제로 껐다.

타이머의 Jazzy·Humble MultiThreadedExecutor 이탈률 차이도 표본을 150회로 늘리니 유의해졌다. Jazzy 8.7% 대 Humble 26.7%(각 150회, 정방향), $z=-4.087$, $p=4.36\times10^{-5}$(정+역 합산 300회씩은 $p=1.22\times10^{-8}$). 113차의 "유의하지 않다"($p=0.65$, 각 50~60회)는 표본 부족이었다 — 113차 이후 추가한 표본만 봐도(Humble 90회에서 34.4%) 원래 추정치(15.0%, 60회)보다 훨씬 높았다. 150회급으로 늘린 세 엔티티(구독·서비스·타이머) 전부 Jazzy·Humble 차이가 유의했지만 방향은 엔티티마다 다르다 — 구독은 Jazzy가 더 불리하고(해시맵 원인이 겹침), 서비스·타이머는 Humble이 더 불리하다(원인은 소스로 추적하지 않음, 확인 필요).

ASLR을 108차 이후 처음으로 실제로 껐다. docker run --privileged는 이 환경의 seccomp가 막던 personality()를 풀어 줬다 — setarch -R로 힙 주소를 반복 출력하면 완전히 동일한 주소가 나와(ASLR 켬에서는 매번 달랐다) 실제로 꺼졌음을 확인했다. service_arrival_test의 디스패치 이탈률을 ASLR 끔·켬 각 60회로 측정해 보니 끔 30.0%, 켬 40.0%($z=-1.148$, $p=0.251$, 방향은 예상대로지만 이 표본에서 유의하지 않음)였다 — 가장 중요한 결과는 ASLR을 완전히 꺼도 이탈률이 0%로 떨어지지 않았다는 것이다. "ASLR이 매 프로세스마다 해시 버킷 순서를 재추첨한다"는 108차 이후의 가설은 유일한 원인으로서는 반증됐다 — 힙 주소가 고정돼도 프로세스 시작 시점 스레드 스케줄링·DDS 참가자 초기화의 할당 순서 비결정성이 남아 있는 것으로 보인다(견해, 미계측).

Humble 서비스 콜백 이중 호출은 재현을 멈추고 환경 포렌식으로 접근했다 — 112차(이중 호출 관측) 배치 실행 창에는 무관한 컨테이너 스택("sobo")의 실제 기동 이벤트(sobo-api-rs-1·sobo-bootstrap-1)가 겹쳐 있었고, 113~115차의 재현 시도 창에는 그런 이벤트가 전혀 없었다는 것을 journalctl·docker ps -a 타임스탬프로 확인했다 — 유일하게 찾아낸 구체적 환경 차이이지만, 배경 소음 지표(도커 로그 줄 수)는 오히려 반대 방향을 가리켜 인과관계를 주장하지 못한다(확인 필요).

새 하네스는 tools/gap_harness/gap115/(타이머 원시 로그·ASLR 토글 원시 로그·README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 116차) — 할당 순서 비결정성을 주소로 직접 확인했고, Humble의 엔티티 수집이 왜 결정적인데도 이탈이 나는지 소스로 좁혔고, 이중 호출을 재현 불가로 종결했다.

할당 순서 비결정성 가설을 주소로 직접 검증했다. 서비스 6개만 만드는 최소 프로그램(addr_probe)은 ASLR을 꺼도 10/10회 완전히 동일한 핸들 주소를 냈다 — 이 최소 시나리오는 완벽히 결정적이다. service_arrival_test와 같은 구조(클라이언트 노드 + 실제 wait_for_service() 디스커버리 매칭)를 더한 addr_probe_full은 달랐다 — ASLR을 꺼도 40회 중 5회(12.5%)는 핸들 주소가 달라졌다(전부 16바이트 단위의 작은 이동). 디스커버리 매칭을 처리하는 백그라운드 RMW 스레드가 메인 스레드의 할당과 힙 위에서 경합하는 타이밍이 실행마다 약간 다르다는 것이 원인으로 보인다 — 다만 이 12.5%가 실제 디스패치 이탈률(ASLR 끔 30~40%)의 전부를 설명하는지는 분리하지 못했다(확인 필요). ASLR 켬·끔 비교 자체는 150회로 늘려도 $p=0.082$로 유의성을 확정하지 못했다(방향은 계속 일관됨).

Humble이 서비스·타이머에서 Jazzy보다 이탈률이 높은 이유를 rclcpp 16.0.19 소스로 추적했다. AllocatorMemoryStrategy::collect_entities()가 순회하는 바깥 맵(WeakCallbackGroupsToNodesMap)은 std::map(owner_less 정렬)이지만, 이 실험(콜백 그룹 하나)에서는 원소가 하나뿐이라 영향이 없다. 실제 엔티티를 모으는 CallbackGroup::service_ptrs_는 std::vector(등록 순서 그대로) — 즉 Humble의 엔티티 수집 경로는 해시도 정렬도 없이 처음부터 끝까지 결정적이다. 이는 Humble의 MultiThreadedExecutor 이탈이 데이터 구조 문제가 아니라 96차가 Jazzy에서 이미 확정한 것과 같은 순수 스레드 스케줄링 문제(선택은 결정적, 실행은 병렬)임을 확정한다 — 다만 Humble이 Jazzy보다 이탈률이 높은 정도의 이유는 못 좁혔다(매 사이클 엔티티 컬렉션 전체를 재구성하는 Humble의 비용이 "다음 선택" 타이밍의 창을 더 넓힌다는 가설, 미계측)(확인 필요). (→ 119차 정정: "실행은 병렬"이라는 부분은 이 테스트 계열에 대해 반증됐다 — 실행은 매번 완전히 직렬이었다. "선택은 결정적"이라는 부분만 남는다. 118차가 이어서 세운 "매 사이클 재구성 비용" 가설도 118차 자신이 직접 계측으로 반증했다.)

Humble 서비스 콜백 이중 호출을 재현 불가로 결론짓고 닫는다. 112차의 관측 이후 113~115차가 순정 빌드·계측 빌드·stress-ng 부하·다른 엔티티 선행 실행 네 조건(누적 101회)과 환경 포렌식까지 시도했지만 전부 실패했다. 116차가 새로 떠올릴 수 있는 조건은 기존 네 가지의 변형에 지나지 않아, 다섯 번째 시도 대신 이 미확인을 명시적으로 종결한다 — 원인이 없다는 뜻이 아니라, 이 책이 가진 도구(도커 컨테이너, 소스 추적, 환경 포렌식)로는 더 이상 좁힐 수 없다는 뜻이다(확인 필요: 재현 불가로 종결). (→ 126차 정정: 이 "종결"은 틀렸다 — 105번째 회차(갭 점검 126차)가 서비스 사전-스핀 실험을 하다가 우연히 다시 만났다. 상세는 이 절 끝의 126차 단락.)

새 하네스는 tools/gap_harness/gap116/(gap116_order/ 패키지 — addr_probe.cpp·addr_probe_full.cpp, ASLR·주소 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 117차) — ASLR은 최종적으로 무관함이 확정됐고, 주소 변동과 디스패치 순서는 완전히 상관됐고, stress-ng 통제 실험이 스레드 경합 가설을 처음으로 정량 확인했다.

ASLR 켬·끔 비교를 300회로 늘리니 방향이 뒤집혔다. 끔 49.3% 대 켬 45.7%, $z=0.899$, $p=0.369$ — 60→150회에 걸쳐 "켬이 더 불안정하다"고 보였던 추세는 300회의 완전한 표본에서 사라졌다. ASLR은 이 디스패치 이탈률과 사실상 무관하다는 것으로 최종 결론짓는다.

주소 변동과 디스패치 이탈률을 같은 프로세스 안에서 동시에 측정하니 완전히(예외 없이) 상관됐다. ASLR 끈 상태로 60회 실행해 핸들 주소와 FIRE_ORDER를 함께 기록하니, 같은 주소 패턴을 가진 실행은 항상 같은 FIRE_ORDER를 냈고 다른 주소 패턴은 항상 다른 FIRE_ORDER를 냈다(기준 패턴 48/48, +0x10 이동 8/8, −0x10 이동 4/4 — 예외 없음). 116차의 "12.5% 대 30~40%"라는 겉보기 불일치는 별도 세션에서 집계량만 비교한 데서 온 착시였다 — 같은 실행 안에서 측정하면 둘은 하나의 현상이다.

CHG-122 계열의 마지막 하위 항목(stress-ng 통제 실험)을 실제로 설계·실행했다. 96차가 확정한 "선택은 결정적, 실행은 병렬"이라는 메커니즘이 맞다면 CPU 경합을 늘려 스케줄러 지터를 키우면 이탈률도 커져야 한다는 가설을, 호스트에 stress-ng --cpu N(N=0·8·20·40, 코어 수 20)을 실행한 채 검증했다.

워커 수구독 이탈률(n=40)서비스 이탈률(n=30)
035.0%20.0%
820.0%36.7%
20(코어 수)72.5%86.7%
4070.0%93.3%

워커 수가 코어 수(20)에 도달·초과하는 순간부터 이탈률 증가가 강하게 유의하다(0 대 20워커: 구독 $p=0.00077$, 서비스 $p=2.3\times10^{-7}$; 0 대 8워커는 둘 다 유의하지 않음, $p=0.13$·$0.15$). 이것이 96차 이후 반복 확인해 온 "스레드 경합이 동적 원인"이라는 가설을 원인 변수(CPU 경합)를 직접 조작해 처음으로 정량 확인한 것이다. Humble 대조군(구독, 0·20워커 각 25회)은 전혀 반응하지 않았다(4.0% → 4.0%, $p=1.0$) — Jazzy가 구독에서는 CPU 경합에 훨씬 민감하고 Humble은 거의 무반응이라는, 서비스·타이머(Humble이 더 나쁨)와는 반대 방향의 배포판 비대칭이 새로 드러났다(원인은 소스로 추적하지 않음, 확인 필요). (→ 119차 정정: CPU 경합이 "동적 원인"이라는 것 자체는 부정되지 않았지만 — stress-ng가 이탈률을 실제로 바꾸는 것은 이번 회차도 유효하다 — 그 경로가 "병렬 실행 중인 콜백들 사이의 OS 스케줄러 경쟁"이라는 그림은 아니다. 이 테스트에 병렬 실행 자체가 없었으므로, CPU 경합은 선택·재구성 타이밍에 간접적으로 영향을 주는 경로로 다시 읽어야 한다 — 120차가 시작한 재구성-대기 연쇄 계측이 이 경로의 유력한 후보다.)

새 하네스는 tools/gap_harness/gap117/(gap117_order/ 패키지 — addr_probe_dispatch.cpp, ASLR·stress-ng 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 118차) — Jazzy·Humble executor 소스를 나란히 읽었고, stress-ng 임계값을 좁혔더니 Humble에서는 정반대 방향을 발견했고, "재구성 비용" 가설은 직접 계측으로 반증됐다.

소스 대조. MultiThreadedExecutor::run()의 스레드 풀·wait_mutex_ 직렬화 구조는 두 배포판이 거의 동일하지만, "다음 실행 대상 선택"의 방식이 다르다 — Jazzy는 wait_for_work()가 한 번에 만든 스냅숏(wait_result_)을 적은 비용으로 순회하고, Humble은 매번 std::vector를 앞에서부터 순회해 erase()하며 wait_mutex_와 별개인 두 번째 뮤텍스(mutex_)를 추가로 포착한다. 두 배포판 다 "선택 자체는 완전히 결정적"이라는 95·116차의 결론과 일치한다.

stress-ng 임계값을 12·16워커로 메우니 정확히 16과 20워커(코어 수) 사이로 좁혀졌다 — 8~16워커는 전부 20~23%대로 평평하고 20워커에서 급격히 증가한다(12 대 20워커, $p=1.1\times10^{-5}$). Humble 서비스·타이머에 같은 실험을 하니 Jazzy와 정반대였다 — 부하가 이탈률을 유의하게 낮췄다(서비스 44.0%→4.0%, $p=0.00093$; 타이머 24.0%→4.0%, $p=0.042$). Humble의 선택 순서가 항상 등록 순서(순수 벡터)이므로, CPU 경합이 심해져 실제 병렬 실행의 창이 좁아지면(직렬화가 강해지면) 기록되는 순서가 등록 순서에 더 잘 수렴하는 것으로 보인다(견해) — Jazzy는 선택 순서 자체가 힙 주소·해시 버킷에 의존해(116~117차) 배경 스레드의 스케줄링 지터가 커질수록 오히려 더 불안정해지는 것과 대조적이다.

"Humble의 매 사이클 전체 재구성이 더 비용이 크다"는 95차의 가설은 실제 타임스탬프 계측으로 반증됐다. wait_for_work()에 std::chrono 계측을 추가해 rclcpp를 재빌드하고 측정한 결과, Jazzy의 호출당 평균 비용이 Humble보다 오히려 1.3~1.8배 높았다(서비스 44,454ns 대 24,988ns; 타이머 71,984ns 대 55,838ns). Jazzy도 캐시 무효화 플래그(entities_need_rebuild_)가 이 테스트에서는 68~70%나 자주 걸려 캐싱 이점이 크게 줄어드는 것으로 보인다. Humble이 서비스·타이머에서 Jazzy보다 불리한 실제 이유는 이 가설이 반증된 뒤에도 여전히 열려 있다(확인 필요).

새 하네스는 tools/gap_harness/gap118/(계측된 executor.cpp 2종, stress-ng 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 119차) — 중대한 정정: 이 절이 91~98차(gap109~118)에 걸쳐 "MultiThreadedExecutor의 실제 동시 실행"이라고 불러 온 것에서, 실제 동시 실행이 단 한 번도 일어난 적이 없었다.

CHG-142가 남긴 "CPU 부하가 직렬화를 강제한다"는 가설을 검증하려고, rclcpp::Executor::execute_any_executable()에 [시작,끝] 구간을 기록하는 계측을 넣어(기존 gap112_order 테스트 바이너리는 무수정 재사용) 실제 동시성 비율(2개 이상의 콜백이 동시에 실행 중인 시간의 비율)을 직접 측정했다. Jazzy·Humble 양쪽, 구독·서비스·타이머 전부, stress-ng 부하 유무와 무관하게 동시성 비율은 예외 없이 0.0000이었다 — 별도의 독립된 계측 방법(커스텀 프로그램의 busy-wait 구간 기록)으로도 같은 결과가 나왔다.

원인은 명백하다. gap109~gap118의 multi_*_order_test는 N개의 같은-타입 엔티티를 명시적 콜백 그룹 지정 없이 하나의 노드에 등록한다 — 이는 그 노드의 기본 콜백 그룹에 들어가는데, 이 기본 그룹은 MutuallyExclusive다(rclcpp 소스 확인: "Default callback group is mutually exclusive"). MutuallyExclusive 그룹의 정의 자체가 "이 그룹의 콜백은 한 번에 하나만 실행된다"는 것이므로, MultiThreadedExecutor에 스레드를 몇 개 주든 같은 그룹 안의 콜백은 태생적으로 절대 동시에 실행되지 않는다 — 버그가 아니라 MutuallyExclusive의 정의 그 자체다(2.4가 이미 문서화한 사실). 91~98차의 실험 설계가 정확히 이 조건(단일 노드, 명시적 그룹 미지정) 위에서 "실제 동시 실행"을 측정하고 있다고 착각한 채 진행돼 왔다.

뒤집히는 것과 뒤집히지 않는 것을 구분한다. 96차(CHG-133)부터 이 절이 반복해 온 설명 — "선택은 wait_mutex_로 직렬화되지만 선택된 뒤에는 각 스레드가 병렬로 실행되고, 콜백 진입 시점을 기록하는 계측이 그 병렬 실행의 타이밍과 뒤섞인다" — 는 메커니즘은 최소한 이 테스트 계열에는 적용되지 않는다(병렬 실행 자체가 없었으므로). 118차가 "CPU 부하로 직렬화가 강해지면 Humble이 등록 순서에 수렴한다"고 추정한 것도, "이미 100% 직렬화돼 있어 강해질 여지가 없다"는 이번 실측과 모순된다. 반면 지금까지 측정한 FIRE_ORDER 이탈률 수치 자체(구독 45%·72.5%, 서비스 4.0%~93.3% 등)는 전부 실제로 측정된 값이며 유효하다 — 바뀌는 것은 그 수치를 설명하는 메커니즘이다. 이탈은 "동시 실행 타이밍 경쟁"이 아니라 선택 자체(그리고 그것이 매 사이클 재구성과 얽히는 방식)의 비결정성에서 와야 한다.

부수적으로 entities_need_rebuild_가 68~70%나 자주 걸리는 이유도 완전히 확정했다. Executor 생성자가 notify_waitable_에 interrupt_guard_condition_을 등록해 두고(add_guard_condition), MultiThreadedExecutor::run()은 MutuallyExclusive 그룹 콜백이 끝날 때마다 이 guard condition을 트리거한다 — 그 트리거가 다음 wait_for_work()에서 entities_need_rebuild_를 true로 만든다. 우리 테스트는 N개 전부가 MutuallyExclusive인 기본 그룹이므로, 콜백이 끝날 때마다 거의 매번 이 트리거가 걸린다 — 다른 스레드를 깨우기 위한 필수 메커니즘이 정확히 설계대로 작동한 것이다.

유력한 새 후보(다음 회차로 이월): 바쁜 그룹 때문에 건너뛴 스레드가 wait_for_work()를 다시 호출하면, 그 블로킹 rcl_wait() 호출 자체가 wait_mutex_를 획득한 채로 일어난다(run()의 바깥 락이 get_next_executable() 전체를 감싸므로). 그 스레드가 다른 스레드의 interrupt_guard_condition_ 트리거에 깨어나면 완전히 새로 재구성된 엔티티 목록을 받는다 — 이 재구성-대기-재구성의 연쇄가 매번 같은 순서로 일어난다는 보장이 없다. 이것이 Humble(그리고 아마 Jazzy에도 별도로)에서 이탈이 나타나는 실제 메커니즘일 가능성이 있지만, 이번 회차는 이 연쇄를 직접 계측해 증명하지는 못했다(확인 필요).

새 하네스는 tools/gap_harness/gap119/(gap119_order/ 패키지 — overlap_probe.cpp, 계측된 executor.cpp 2종, 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 120차) — 119차가 새 후보로 남긴 "재구성-대기 연쇄" 가설을 직접 계측했다. MultiThreadedExecutor::run()의 wait_mutex_ 잠금 구간과 Executor::wait_for_work()의 재구성 발생 여부를 소스 수준에서 확인하고, 480회(서비스·타이머 × 스레드 2·4·8 × 각 40회, Jazzy·Humble)의 실측으로 뒷받침했다.

바쁜 그룹에 막힌 스레드는 wait_for_work()의 블로킹 대기 내내 wait_mutex_를 쥐고 있다 — 확정됐다. MultiThreadedExecutor::run()은 get_next_executable() 호출 전체를 wait_mutex_로 감싸고, 그 안에서 호출되는 wait_for_work()의 블로킹 대기(wait_set_.wait()/rcl_wait())도 이 락 스코프 밖으로 나가지 않는다(Jazzy·Humble 소스 동일). 480회 실행에서 모은 13,397개의 [잠금,해제] 구간을 스윕 라인으로 검사하니 겹침 비율은 예외 없이 0.0000이었다 — 소스가 말하는 것과 실측이 정확히 일치한다.

Humble은 이 연쇄가 예외 없이 매번 일어난다 — Jazzy는 조건부다. wait_for_work()가 호출될 때마다 Humble은 memory_strategy_->collect_entities()를 무조건 실행하는 반면(240회 전부 rebuild_per_wfw = 100.0%, 예외 없음), Jazzy는 entities_need_rebuild_ 플래그가 참일 때만 재구성한다(설정에 따라 45.1%~74.1%, 119차의 68~70%와 같은 자릿수). 연쇄 자체의 존재(②의 답)는 두 배포판이 공유하는 코드에 있지만, "항상"이냐 "조건부"냐는 118차가 이미 짚은 캐싱 유무의 차이 그대로다.

다만 이 연쇄의 빈도가 이탈률을 결정하는 깔끔한 손잡이라는 것은 확정하지 못했다 — 오히려 반증에 가깝다. busy_skip_rate(재시도 필요 비율)와 이탈률을 12개 설정 전체로 상관 지으면 $r=0.9617$로 강하지만, 엔티티 종류(서비스 대 타이머)를 고정하고 스레드 수만 바꿔 보면 상관이 거의 사라진다 — 서비스만(n=6) $r=-0.31$, 타이머만(n=6) $r=+0.31$로 약하고 방향도 일관되지 않는다. 즉 $r=0.9617$의 대부분은 "서비스는 원래 busy_skip도 이탈도 둘 다 높고, 타이머는 둘 다 낮다"는 엔티티 종류 자체의 차이를 반영하는 교란변수 효과였다 — 같은 엔티티 안에서 연쇄 빈도를 변화시켜도 이탈률이 따라오지 않는다는 뜻이다. 배포판 간 직접 비교(서비스·스레드4, Jazzy 45.0% 대 Humble 65.0%, $z=-1.798$, $p=0.072$)는 113차가 n=60에서 얻은 것과 거의 같은 경계선을 n=40에서 대략 재현하는 것처럼 보이지만, 이번 표본 자체로는 유의성을 새로 확정하지 못했다.

② Jazzy에도 같은 연쇄가 있다 — 락 스코프 확인·겹침 0.0000 결과는 Jazzy에도 동일하게 적용되고, busy_skip_rate도 60~95%로 같은 자릿수다. 다만 힙 주소·해시 버킷 불안정성(116~117차)과 이 연쇄의 기여도를 정량적으로 분리하는 것은 이번 회차도 못 했다(확인하려면 ASLR을 끈 채 이 계측을 다시 실행하거나, entities_need_rebuild_를 강제로 고정하는 실험용 빌드가 필요하다)(확인 필요).

결론(정직하게): 재구성-대기 연쇄는 실재하고(소스·실측 모두 확인) Humble에서 구조적으로 더 자주(항상) 일어나지만, 이것이 Humble의 서비스·타이머 이탈률이 Jazzy보다 높은 "직접적 원인"이라는 인과 관계는 이번 회차의 데이터로 증명되지 않았다 — 엔티티 종류 자체(서비스가 원래 더 불안정하다는 108~117차의 반복 관측)가 여전히 더 강한 단일 예측 변수로 남는다. Humble이 서비스·타이머에서 Jazzy보다 불리한 실제 이유는 이 회차 이후에도 미해결이다(확인 필요).

새 하네스는 tools/gap_harness/gap120/(계측된 executor.cpp·multi_threaded_executor.cpp 각 2종, run_batch.sh, analyze.py, 원시 로그 12개 설정 × 40회, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 121차) — rcl 계층엔 구조적 차이가 없었고, 재구성을 완전히 꺼도 이탈은 줄지 않았고, Humble 구독의 "무반응"은 이번 세션에서 재현되지 않았다.

① rcl 계층 비교. rcl 9.2.11의 wait.c를 읽으면 rcl_wait()는 rmw_wait()를 한 번 불러 구독·guard condition·클라이언트·서비스·이벤트 다섯 배열을 전부 넘기고, 반환 후 다섯 종류를 완전히 동일한 패턴(rmw_X.X[i] != NULL이면 유지, 아니면 rcl 핸들을 NULL로)으로 순회한다 — 구독과 서비스 사이에 rcl 계층에서의 구조적 차이는 없다. 유일하게 다른 것은 타이머다(rmw_wait()를 거치지 않고 rcl_timer_is_ready()로 별도 판정) — 하지만 이는 이 책이 실측으로 찾은 "서비스·타이머(Humble이 나쁨) 대 구독·waitable(Jazzy가 나쁨)"이라는 축과 맞지 않는다. 지금까지 살펴본 세 계층(119차의 rclcpp 엔티티 컬렉션, CHG-143의 선택 함수, 이번 rcl 대기집합) 전부 서비스·구독을 구조적으로 구분하지 않는다 — 남은 후보는 rmw/DDS 계층이거나, "구조적 차이"라는 틀 자체가 틀렸을 가능성이다(확인 필요).

② 재구성-대기 연쇄와 힙 주소 효과의 분리. Jazzy의 재구성 조건을 entities_need_rebuild_ 무시·current_collection_.empty()만으로 바꿔(최초 1회 이후 절대 재구성하지 않는 실험용 빌드) 표준 빌드와 ASLR 켬·끔을 2×2로 교차해 서비스(N=6, 스레드4, 각 30회)를 측정했다. rebuild_per_wfw=0.0%로 재구성이 완전히 억제된 것은 확인했지만 이탈률은 줄지 않았다 — 오히려 소폭 늘었다(통계적으로 유의하지 않음: 표준 대 재구성억제, ASLR켬 $z=-1.127$·$p=0.260$, ASLR끔 $z=-0.812$·$p=0.417$). 재구성-대기 연쇄를 구조적으로 완전히 차단해도 디스패치 이탈이 사라지지 않으므로, 반복되는 재구성 자체는 이탈의 필요조건이 아니다 — 남은 설명은 최초 1회 수집 시점의 힙 주소·해시 버킷 배치(116~117차)가 이미 이탈에 충분하다는 것이다.

③ 재구성-대기 계측을 구독으로 확장. multi_sub_order_test(N=6, 스레드4)에 stress-ng --cpu 0·20을 양쪽 배포판에서 25회씩 측정했다. Jazzy는 부하에 이탈이 느는 기존 방향을 유지했지만(56.0%→68.0%, 이번 표본은 유의하지 않음), Humble은 "무반응"이 아니라 부하에 뚜렷하게(그리고 유의하게, $z=2.828$·$p=0.0047$) 이탈이 줄었다(36.0%→4.0%) — 117차의 "4.0%→4.0%, 완전 무반응" 결론과 다르다. 계측 부작용인지 확인하려고 순정 빌드로도 측정해 보니 24.0%→0.0%(동일 방향, $p=0.0090$)로 계측 탓이 아님을 확인했다. 다만 117차의 기준값(4.0%)과 이번 세션의 기준값(24~36%)이 6~9배 차이 나는 원인은 밝히지 못했다 — 무관한 컨테이너 부하가 섞였을 가능성은 있지만 직접 확인하지 않았다(확인 필요). "Humble 구독은 부하에 완전히 무반응"이라는 117차의 구체적 문구는 이번 세션에서 재현되지 않았다 — 세션 간 재현성 문제로 명시하고 더 많은 세션에서 반복해야 확정할 수 있다.

새 하네스는 tools/gap_harness/gap121/(jazzy_executor_norebuild.cpp — 재구성 억제 변형, run_batch2.sh, 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 122차) — 호스트 부하 가설은 직접 검증해 기각했고, rmw 계층까지 네 계층 전부 구조적 차이가 없었고, 재구성 억제 효과는 n=100에서 노이즈로 확인됐다.

① 호스트 부하 가설을 직접 검증했다 — 지지되지 않는다. 이 호스트에는 이 세션 동안에도 36개의 무관한 컨테이너(다른 개발 스택들)가 실행 중이었다. Humble 구독(부하 없음) 60회를 매 반복 직전 /proc/loadavg 기록과 함께 측정하니, loadavg는 6.85~11.30 사이에서 흔들렸지만 이탈한 19회(평균 8.791)와 이탈하지 않은 41회(평균 8.832)의 loadavg가 사실상 같았다(점이연 상관 $r=-0.0149$). 워커 0·2·4·8·20의 통제된 저강도 stress-ng 스윕(각 20회)도 40.0%·40.0%·40.0%·20.0%·30.0%로 매끄러운 용량-반응이 아니었고, 같은 세션의 바로 앞 배치(구독 부하0 36.0%·부하20 4.0%, 121차)와도 재현되지 않았다 — 세션 안에서도 배치마다 불안정하다는 뜻이다. loadavg와 이탈의 상관이 거의 0이므로 단순 CPU 경합량으로는 이 불안정성을 설명할 수 없다 — 디스커버리 타이밍 같은 더 미세한 요인이 후보로 남는다(확인 필요).

② rmw/DDS 계층도 구조적 차이가 없다 — 네 계층째다. rmw_fastrtps_shared_cpp의 구독 리스너(CustomDataReaderListener)와 서비스 리스너(ServiceListener)는 둘 다 DataReaderListener를 상속하고 on_data_available()이 뮤텍스+get_unread_count(true)+콜백이라는 동일한 패턴이다. rmw_wait.cpp의 has_triggered_condition()·wait-set 부착도 구독·클라이언트·서비스가 완전히 같은 코드(get_first_untaken_info()·get_statuscondition())를 쓴다. rclcpp 엔티티 컬렉션(119차)·선택 함수(CHG-143)·rcl 대기집합(121차)에 이어 이번(rmw 리스너·wait 구현)까지 네 계층 전부에서 코드 경로 차이를 찾지 못했다 — "코드 경로가 다르다"는 가설은 사실상 소진됐다. 대신 새 후보를 봤다 — 서비스는 구조적으로 구독의 두 배다(요청 리더 + 응답 라이터라는 별개의 DDS 엔티티 쌍을 갖는 반면 구독은 리더 하나뿐) — 코드는 같아도 관리해야 할 엔티티 수가 다르다는 것이 다음 유력한 후보다(확인 필요).

③ 재구성 억제 효과 — n=100으로 늘리니 사라졌다. 121차가 n=30에서 본 "재구성을 억제하면 이탈률이 오히려 소폭 는다"는 경향을 서비스(N=6, 스레드4)로 각 100회 측정하니 표준(재구성 활성) 34.0% 대 재구성 억제 33.0%, $z=0.150$·$p=0.8809$로 완전히 유의하지 않고 점추정치도 거의 같아졌다 — 121차의 경향은 소표본 우연이었다. "재구성 반복은 이탈의 필요조건이 아니다"라는 121차의 핵심 결론이 더 굳어졌다.

새 하네스는 tools/gap_harness/gap122/(run_batch3.sh — loadavg 동시 기록, 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 123차) — "서비스는 구독의 두 배 DDS 엔티티" 가설을 클라이언트 재분석과 직접 조작 둘 다로 검증했지만, 둘 다 가설에 불리했다.

클라이언트 재분석(먼저)이 이미 가설을 반증하고 있었다. 클라이언트도 서비스처럼 DDS 엔티티 둘(요청 라이터+응답 리더)을 갖지만, 109·112차 자료에서 클라이언트는 이 책이 측정한 네 엔티티 종류 중 가장 안정적이었다(MultiThreadedExecutor: Jazzy 6.7~13.3%·Humble 0% — 서비스의 53~80%·75~80%와 극명하게 대조된다). 두 테스트(multi_service_order_test·multi_client_order_test)의 소스를 나란히 읽어 보면 구조도, "N개 항목이 스핀 시작 전에 이미 전부 준비되게 한다"는 설계 의도도 대칭이다 — 그런데 결과는 극단적으로 다르다.

엔티티 개수를 직접 조작한 실험도 약한(비유의) 결과만 냈다. 지금까지 구독 테스트는 전부 같은 참가자 안에 로컬 퍼블리셔(라이터)를 N개 함께 갖고 있었다는 것을 확인하고, 이 라이터를 별도 프로세스(publisher_helper, 다른 DDS 참가자)로 옮겨 측정 대상 노드(subscriber_only)엔 리더만 남겼다.

조건이탈률(n=40)
Jazzy 기준(라이터 동일 참가자)65.0%
Jazzy 라이터 제거(참가자 분리)57.5%($p=0.491$)
Humble 기준42.5%
Humble 라이터 제거32.5%($p=0.356$)

방향은 가설과 일치했지만(라이터를 없애니 이탈이 줄었다) 어느 쪽도 통계적으로 유의하지 않았다 — 이 차이는 이 실험 계열이 반복적으로 보여 온 세션 간 자연 변동 폭 안에 있다. 결론: 클라이언트라는 반례와 이 비유의 결과를 함께 보면, "DDS 엔티티 개수"는 서비스·구독 비대칭의 주된 설명이 아닐 가능성이 크다 — 다음 후보는 엔티티 개수가 아니라 종류별 rmw/DDS 구현 세부나 이 테스트 계열 고유의 타이밍 특성이다(확인 필요). Humble 구독의 세션 간 불안정성(100~102차가 반복 관측)은 이번 회차도 새 메커니즘을 계측하지 못하고 다음 회차로 이월한다(확인 필요).

새 하네스는 tools/gap_harness/gap123/(gap123_order/ 패키지 — publisher_helper.cpp·subscriber_only.cpp, run_batch4.sh, 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 124차) — 서비스·클라이언트 비대칭의 새 후보(도착 시간 폭)를 찾았고, Humble 구독의 디스커버리 타이밍 가설은 직접 계측해 기각했고, 엔티티 개수 효과는 Jazzy에서 유의해졌다.

① execute_service()·execute_client()는 다섯 계층째도 코드가 동일했다 — 요청 헤더 생성, take_and_do_error_handling() 호출까지 완전히 같은 패턴이다(rclcpp 엔티티 컬렉션·선택 함수·rcl 대기집합·rmw 리스너에 이은 다섯 번째로 배제된 계층). 대신 112차의 도착-시각 계측 도구(service_arrival_test·client_arrival_test)로 도착 시각 자체를 측정하니, 클라이언트의 응답 도착 시간 폭이 서비스의 요청 도착 시간 폭보다 약 5배 넓다(중앙값: 서비스 Jazzy 13,875ns·Humble 11,652ns 대 클라이언트 Jazzy 63,846ns·Humble 60,872ns, 두 배포판 일관). 원인은 테스트 구조다 — 서비스 테스트는 서버가 스핀을 시작하기도 전에 N개 요청을 반복문으로 한꺼번에 쏘지만, 클라이언트 테스트는 서버가 이미 SingleThreadedExecutor로 상시 스핀 중이라 요청을 하나씩 순차 처리·응답해 응답들이 시간에 걸쳐 퍼진다. 좁은 도착 창은 wait-set 수준에서 "실제 동시 준비" 상태를 더 자주 만들어 이탈을 늘리고, 넓은 도착 창은 그 동시성을 줄이는 것으로 보인다 — 다만 이는 엔티티 종류 자체의 속성이 아니라 테스트가 요청·응답을 만드는 방식의 산물이라는 점을 밝혀 둔다(확인은 다음 회차)(확인 필요).

② Humble 구독의 세션 간 불안정성을 디스커버리 타이밍으로 직접 계측했다 — 원인이 아니었다. 미들웨어 리스너 도착 채널(sub_arrival_test, 112차 기법을 구독에 적용)로 50회 측정하니, 도착 시각 폭과 이탈 여부의 상관은 $r=0.0712$로 무시할 수준(이탈군 평균 216,417ns·비이탈군 201,004ns, 사실상 같음)이었다. 오히려 뜻밖의 것을 발견했다 — ARRIVAL_ORDER(32.0% 최빈)가 FIRE_ORDER(62.0% 최빈)보다 훨씬 불안정하다 — executor의 디스패치가 어수선한 도착 순서를 그대로 반영하는 것이 아니라 오히려 더 정돈하고 있다는 뜻이다(ARRIVAL_ORDER와 FIRE_ORDER가 완전히 일치한 회차는 50회 중 8회뿐). 디스커버리 타이밍은 이탈의 직접 원인이 아니다 — 남은 이탈은 도착 이후 단계에서 온다(확인 필요).

③ 엔티티 개수 조작(123차)을 n=100으로 확대하니 Jazzy는 유의해졌고 Humble은 방향이 또 바뀌었다.

조건이탈률(n=100)
Jazzy 기준71.0%
Jazzy 라이터 제거54.0%($p=0.0130$)
Humble 기준34.7%(n=98)
Humble 라이터 제거42.0%($p=0.2905$)

Jazzy는 이번엔 통계적으로 유의해졌다 — "엔티티 개수" 가설이 Jazzy에 한해 실재하는 부분적 효과로 승격됐다. Humble은 방향마저 뒤집혀 세션 간 불안정성이 이 실험에도 그대로 나타났다. 클라이언트라는 반례(엔티티 둘인데 가장 안정적)가 여전히 있으므로 엔티티 개수는 전체 그림의 일부일 뿐이다 — ①에서 찾은 도착 시간 폭 가설과 결합될 가능성이 다음 회차 과제로 남는다(확인 필요).

새 하네스는 tools/gap_harness/gap124/(gap124_order/ 패키지 — sub_arrival_test.cpp, run_batch5.sh, 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 125차) — 도착 시간 폭 가설을 직접 조작하니 무너졌고, Humble의 "정돈" 메커니즘은 소스에서 정확히 찾았으며, 엔티티 개수와 도착 시간 폭은 같은 효과가 아니었다.

①(최우선) 도착 시간 폭을 직접 조작 — 인과관계가 성립하지 않았다. service_spread_test(gap112 multi_service_order_test에 요청 사이 인위적 지연을 추가한 변형)로 도착 창을 0·10·50·200µs로 늘려 봤다(N=6, 스레드4, 각 30회). 어느 조건도 유의하지 않았고, Jazzy는 지연을 늘릴수록 이탈이 오히려 (비유의하게) 늘었다(43.3%→58.6%). 200µs는 클라이언트의 자연 도착 창(61~64µs)보다 3배 넓은데도 이탈이 줄지 않았다 — 103~104차의 상관관계는 인과관계가 아니었을 가능성이 크다. 실제 차이는 도착 시간 폭 자체가 아니라 그것을 만들어 내는 다른 구조적 요인(서버 사전 스핀 여부, 콜백 내용)에 있을 가능성이 높다(확인 필요, 다음 회차 후보).

② Humble의 "정돈" 메커니즘을 소스에서 확인했다. AllocatorMemoryStrategy의 remove_null_handles()가 매 rcl_wait() 이후 subscription_handles_(std::vector)에서 이번에 준비되지 않은 항목을 걷어내고, get_next_subscription()은 항상 begin()부터 순회한다 — N개가 전부 같은 rcl_wait() 배치에서 준비되면 디스패치 순서는 도착 순서와 무관하게 항상 등록 순서가 된다. 이것이 103차가 발견한 "정돈" 효과(ARRIVAL_ORDER 32% 대 FIRE_ORDER 62% 최빈 비율)의 정체다. 잔여 이탈은 N개가 여러 rcl_wait() 배치로 분할돼 도착할 때, 그 뒤의 다중 스레드 경합으로 생긴다.

③ "엔티티 개수"와 "도착 시간 폭"의 관계 — 방향은 같지만 ①의 반증과 충돌한다. 라이터가 있는(같은 참가자) 구독과 없는(별도 프로세스) 구독의 도착 시간 폭을 직접 비교하니, 라이터가 있으면 도착 시간 폭이 90배 가까이 넓었다(Jazzy 중앙값 36ms 대 0.4ms) — 방향은 "라이터 있음(103차의 높은 이탈률) = 넓은 창"이라는 상관과 들어맞는다. 다만 폭의 정체는 균일한 확장이 아니라 N개 중 일부가 ~70ms 늦게 도착하는 "지연 샘플" 패턴이었다(Jazzy 40회 중 20회, Humble은 40회 전부에서 관찰). ①이 도착 시간 폭 자체를 조작해도 이탈률이 바뀌지 않는다는 것을 이미 보였으므로, 엔티티 개수와 도착 시간 폭이 "같은 효과의 두 이름"이라고 결론지을 수 없다 — 라이터 유무가 이탈률과 도착 시간 폭(지연 샘플 패턴) 둘 다에 개별적으로 영향을 주는 제3의 원인일 가능성이 있지만, 이번 회차는 완전히 분리하지 못했다(확인 필요).

새 하네스는 tools/gap_harness/gap125/(gap125_order/ 패키지 — service_spread_test.cpp·sub_arrival_colocated.cpp, 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 126차) — 서버 사전 스핀이 Humble의 이탈을 완전히 지웠고, 그 과정에서 116차의 "재현 불가로 종결"이 틀렸다는 것이 드러났다.

①(최우선) 서버 사전 스핀을 직접 조작했다. service_prespin_test(측정 대상 MultiThreadedExecutor를 요청을 보내기 전에 미리 스핀시키는 변형)로 서버 사전 스핀 여부를 조작했다(N=6, 스레드4, 각 50회). Humble에서 사전 스핀이 이탈을 완전히(82.1%→0.0%, $z=7.780$, $p\approx 0$) 지웠다 — 이 실험 전체를 통틀어 가장 큰 단일 효과다. Jazzy는 변화가 없었다(64.0%→67.9%, 비유의). 서버가 요청을 받기 전부터 이미 wait_for_work()에서 블로킹 대기 중이면 Humble의 선택은 등록 순서를 완벽하게 지킨다 — 다만 정확히 왜 Humble에만 이렇게 극적으로 작용하는지는 메커니즘까지 조사하지는 못했다(확인 필요, 다음 회차 최우선).

뜻밖의 재발견 — 116차의 "재현 불가로 종결"이 틀렸다. 이 실험 도중 fired가 6이 아닌 경우가 반복됐다 — 확인해 보니 116차가 101회 실패 끝에 "재현 불가로 명시적으로 종결"한 Humble 서비스 콜백 이중 호출이었다. Humble 기준(스핀 나중) 22.0%, 사전 스핀 8.0%($p=0.0499$, 경계선)로 사전 스핀이 이중 호출도 줄였다 — FIRE_ORDER 이탈이 지워진 것과 같은 방향이다. 반대로 Jazzy는 사전 스핀이 이중 호출을 0%에서 44.0%로 만들어 냈다 — 113차의 "Jazzy는 이중 도착이 없다"는 확정과 정면으로 배치된다. 의도적으로 쫓은 것이 아니라 우연히 마주친 것이며, 정확한 트리거는 이번에도 확정하지 못했다(확인 필요).

② execute_service()·execute_client()의 실제 소요 시간을 직접 계측했다. 타이밍 계측을 넣어 재빌드하고 기존 테스트 바이너리를 무수정 재사용해 각 20회 측정했다 — execute_service()가 execute_client()보다 약 4.7배 더 걸린다(중앙값: 서비스 Jazzy 62,832ns·Humble 60,363ns 대 클라이언트 Jazzy 12,992ns·Humble 12,921ns, 두 배포판 거의 동일). 103차가 견해로 남긴 "서비스는 응답 데이터를 채워 반환, 클라이언트는 미래 객체만 받음"이 실측으로 확인됐다 — 이 ~50µs는 해당 콜백 그룹이 "바쁨" 상태로 남는 시간이기도 해 120차의 재구성-대기 연쇄와 연결될 수 있지만 직접 검증하지는 않았다(확인 필요).

③ "지연 샘플" 패턴을 tcpdump로 조사했다 — 네트워크 재전송이 아니었다. 125차의 sub_arrival_colocated를 tcpdump -i lo(포트 제한 없음) 아래서 실행해 ~70ms 지연 샘플이 나온 실행을 캡처했다 — 캡처된 패킷은 6개뿐이었고 전부 100ms 간격의 SPDP 참가자 발견 공지였다, 지연 샘플 도착 시점 전후로 하트비트·ACKNACK 재전송은 전혀 관측되지 않았다. 퍼블리셔·구독이 같은 참가자 안에 있어 루프백 인터페이스 자체를 거치지 않을 가능성이 있다 — ~70ms 지연은 네트워크·프로토콜 수준이 아니라 DDS 미들웨어 내부(스레드 잠금·내부 타이머 등)에서 와야 한다(확인 필요, 다음 회차 후보).

새 하네스는 tools/gap_harness/gap126/(gap126_order/ 패키지 — service_prespin_test.cpp, 계측된 executor.cpp 2종, 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 127차) — 사전 스핀 메커니즘의 두 가설을 반증했고, Jazzy 이중 호출의 원래 후보 원인이 틀렸다는 것을 확인했고, "지연 샘플"의 배제 목록에 FastDDS 경고 로그를 추가했다.

①(최우선) 사전 스핀 메커니즘 — 두 가설을 실측으로 반증했다. 120차의 캐스케이드 계측(total_gne·rebuild_calls)을 재사용해 "사전 스핀이 재구성-대기 캐스케이드 횟수를 줄여서 효과를 낸다"는 가설을 측정했다(Humble, 각 30회) — 총 호출 수가 두 조건에서 거의 같았다(사전 스핀 28.0 대 기준 27.8) — 그런데 이탈률은 3.3%와 73.3%로 극단적으로 다르다. 가설이 반증됐다. "사전 스핀이 항상 같은 스레드가 반복 디스패치하게 만든다"는 스레드-지역성 가설도 새 계측(std::this_thread::get_id() 해시 기록)으로 측정했지만 — 오히려 사전 스핀 쪽이 스레드 전환율이 더 높았다(97.0% 대 84.0%) — 이 가설도 반증됐다. 캐스케이드 횟수도 스레드 패턴도 두 조건에서 근본적으로 다르지 않은데 결과는 극단적으로 다르다 — 실제 메커니즘은 더 미세한 시점 수준에 있는 것으로 보인다(확인 필요, 다음 회차 최우선).

② Jazzy 이중 호출 — "2단계 등록" 가설의 전제 자체가 틀렸다. 113차가 조사했던 2단계 set_on_new_request_callback 등록을 다시 확인했다 — 결정적으로, 이 2단계 등록은 set_on_new_request_callback()을 명시적으로 호출할 때만 일어나는데, multi_service_order_test·service_prespin_test(이번에 이중 호출을 낸 도구들)는 이 API를 전혀 호출하지 않는다. 113차의 반증은 다른 도구(service_arrival_test)를 겨냥한 것이었다는 것이 드러났다 — 이번 이중 호출과는 애초에 무관한 조사였다. 대신 새 후보 원인을 소스에서 찾았다 — get_next_executable()은 캐시된 wait_result_를 먼저 쓰는데, 서비스가 선택되면 콜백 그룹은 즉시 busy가 되지만 실제 DDS "take"는 execute_service() 안(락 밖, ~60µs 소요)에서 일어난다 — 그 사이 다른 스레드의 캐스케이드가 새 wait_result_를 만들면 아직 안 치워진 데이터가 다시 "준비됨"으로 보일 여지가 있다. 소스로는 그럴듯하지만 직접 계측으로 확정하지 못했다(확인 필요).

③ "지연 샘플" 패턴 — FastDDS 경고 로그도 원인이 아니었다. Log::SetVerbosity(Info)를 켜고 ~70ms 지연 샘플이 나온 실행의 로그를 시간순으로 보니, 모든 경고가 프로세스 시작 직후 ~8ms 안에 몰려 있고 지연 샘플 도착 구간(2.4ms~70.4ms)에는 로그가 단 한 줄도 없었다 — tcpdump(126차)에 이어 FastDDS 경고 로그도 이 구간에서 침묵한다. Info 수준 로그는 릴리스 빌드에서 컴파일 타임에 빠졌을 가능성이 크다 — 디버그 빌드나 gdb 스레드 계측이 다음 후보다(확인 필요).

새 하네스는 tools/gap_harness/gap127/(gap127_order/ 패키지 — sub_arrival_fdlog.cpp, 계측된 executor.cpp(캐스케이드+스레드 추적), 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 128차) — 사전 스핀 메커니즘을 사건 단위 타임스탬프로 마침내 찾아냈고, Jazzy 이중 호출은 이번 세션에서 재현 자체가 안 됐고, "지연 샘플"은 세 번째로 배제가 확인됐다.

①(최우선) 사전 스핀 메커니즘 — 집계가 아니라 사건 단위로 측정하니 실제 차이가 드러났다. 계측된 executor.cpp가 wait_for_work()(=Humble의 재구성) 호출마다 ns 타임스탬프를 남기고, 테스트 도구가 set_on_new_request_callback()(순수 측정용)으로 요청 도착 시각과 실제 디스패치 시각을 같은 프로세스·같은 steady_clock로 함께 남기게 했다(Humble, 각 30회). 마지막 도착부터 마지막 디스패치까지의 "디스패치 구간"에 몇 번의 재구성이 몰리는지를 측정하니, 사전 스핀 7~11회 대 기준 23~28회로 완전히 겹치지 않는 분포가 나왔다(Welch $t=49.19$). 총량(~27회)은 두 조건이 거의 같지만(126·127차가 이미 확인), 기준 조건은 스핀 시작 순간 스레드 4개가 이미 준비된 데이터를 두고 동시에 뮤텍스를 경합하며 ~1.3~1.8ms 안에 25회 안팎을 몰아치는 반면, 사전 스핀은 그 다툼을 400ms에 걸쳐 미리(~16회) 분산해 소화해서 실제 디스패치는 ~10회로 차분하게 끝난다.(→ 130차 정정: "400ms에 걸쳐 분산"은 부정확했다 — 실제로는 스핀 시작 직후 ~935µs 안에 17회를 몰아치고, 그 뒤로는 각 스레드가 단 한 번의 긴 rcl_wait() 블록에 들어가 남은 ~399ms 동안 추가 재구성이 없다, 아래 참고) "재구성이 얼마나 자주"가 아니라 "얼마나 몰려서(경합해서) 일어나는가"가 핵심이었다 — 다만 조건 내부에서는 재구성 개수가 개별 실행의 이탈 여부를 예측하지 못해, 몰림이 정확히 어떻게 등록 순서를 깨뜨리는 인과 경로로 이어지는지는 이번 회차도 완전히 닫지 못했다(확인 필요, 다음 회차 후보).

② Jazzy 이중 호출 — 이번 세션에서는 재현 자체가 안 됐다. 127차의 새 후보 원인(캐시된 wait_result_의 경합 창)를 직접 계측하려고 세 가지 도구(도착 리스너 포함 40회, 2단계 등록 API 없는 순수 버전을 계측 rclcpp 위에서 50회+순정 rclcpp 위에서 50회+계측 rclcpp 위에서 추가 100회, 전부 사전 스핀 조건)로 총 240회를 실행했지만 이중 호출이 단 한 번도 재현되지 않았다(FIRE_ORDER 중복 0/240) — 바로 앞 회차(127차)가 44.0%(22/50)를 본 것과 대비된다. 113·116차의 반복된 재현율 요동과 같은 종류의 세션 의존성으로 보인다 — 경합 창 가설은 이번 회차에 확정도 반증도 못했다, 표본 자체가 없었기 때문이다(확인 필요, 표본 확보가 급선무).

③ "지연 샘플" 패턴 — 세 번째 "배제" 판정, 그리고 gdb가 이 스케일엔 안 맞는다는 방법론적 발견. gdb -p <pid> -batch 반복 attach를 먼저 시도했으나, 한 번의 attach+backtrace+detach가 평균 ~295ms 걸린다는 것을 실측했다 — 관측 대상(~70ms)보다 프로브 자체가 4배 더 크다. 대신 /proc/<pid>/task/<tid>/{wchan,stack}를 반복 읽는 경량 폴러를 새로 만들어(ptrace로 멈추지 않아 프로브 효과가 훨씬 작다) 지연 샘플이 나온 4회 실행을 ~200µs 간격(실행당 5,700~27,000개 표본)으로 샀다 — 어느 실행에서도 어느 스레드도 지연 샘플 구간 내내 지속적으로 실행 중인 것은 없었다(전부 futex_do_wait류의 대기), 실행 중으로 보이는 표본은 전체의 ~1.5%뿐이고 200µs 미만의 일회성 블립이라 도착 시각과 뚜렷이 맞물리지 않았다. tcpdump(126차)·FastDDS 로그(127차)에 이어 세 가지 독립된 방법 전부 이 구간에서 활동을 못 찾았다 — 남은 설명은 타이밍 기반의 잠(어떤 스레드가 정해진 시간만큼 자다가 깨는 것)일 가능성이 가장 유력하지만, 정확히 어느 타이머·조건변수인지는 이번에도 확정하지 못했다(확인 필요, 다음 회차 후보 — strace -T나 eBPF/bpftrace).

새 하네스는 tools/gap_harness/gap128/(gap128_order/ 패키지 — service_prespin_evt.cpp·service_prespin_pure.cpp·sub_arrival_colocated_gdb.cpp, 계측된 executor.cpp(사건 단위 REBUILD·EXEC_SVC 타임스탬프), /proc 폴러(proc_sample.py), 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 129차) — 재구성-몰림이 등록 순서를 깨는 정확한 경로를 찾았고, Jazzy 이중 호출은 결함 주입으로도 재현 못 했고, "지연 샘플"의 정체가 strace로 드러났다.

①(최우선) 재구성-몰림의 정확한 경로. Humble 소스를 다시 읽어, wait_for_work()·get_next_ready_executable_from_map()이 서로 다른 세 번의 별개 mutex_ 획득(재구성/락 A, 무잠금 rcl_wait(), 필터링/락 B, 선택/락 C)으로 이뤄져 있고 그 사이(특히 무잠금 rcl_wait() 도중)에 다른 스레드가 같은 공유 wait_set_을 자유롭게 다시 짓밟을 수 있다는 것을 확인했다. 사건 단위 계측(REBUILD_START/END·RCLWAIT_RETURN·REMOVE_NULL·PICK·EXEC_SVC)으로 측정하니 — 기준 조건의 이탈 22건 전부(22/22, 100%)에서, 등록 순서가 처음 깨지는 그 PICK 순간에 다른 스레드의 재구성 또는 실행 창이 겹쳐 있었다. 스핀 시작 직후 여러 스레드가 REBUILD를 반복하면서도 한동안 아무도 PICK에 성공 못 하는 구간이 실제로 관측됐다 — 한 스레드의 필터링 결과를 다른 스레드의 clear_handles()가 곧바로 지워 버리는 것과 정확히 들어맞는다(실측 사례: 첫 PICK이 등록 순서 0이 아니라 3이었다). 사전 스핀이 이 경합 자체를 원천적으로 왜 막는지는 이번에도 완전히 분리하지 못했다(확인 필요, 다음 회차 후보).

② Jazzy 이중 호출 — 결함 주입으로도 재현하지 못했다. 127차의 wait_result_ 경합 창 가설을 86차(CHG-131) 방식의 결정론적 결함 주입으로 검증했다 — execute_service() 맨 앞에 환경변수로 제어하는 인위적 지연을 넣어 자연 발생 창(~60µs)을 최대 800배(50ms)까지 넓혔다. 주입 없음·5ms·50ms 세 조건, 총 140회 전부 이중 호출이 재현되지 않았다 — 지연 자체는 time으로 실제 걸림을 확인했다. 극단적으로 넓혀도 재현이 안 된다는 것은 이 특정 경합 창 가설에 불리한 증거지만, 이 세션 자체가 무주입 조건에서도 재현이 안 됐던(128차의 44%와 대조) 세션 의존성이 있어 완전한 반증으로 단정하지는 않는다(확인 필요, 다음 회차 후보).

③ "지연 샘플" 패턴 — strace -T로 정체가 드러났다: 타임드 웨이트였다. strace -f -tt -T -e trace=futex로 처음부터 끝까지 감싸(gdb의 ~295ms attach 프로브 문제를 피했다) futex 호출 인자를 직접 봤다 — 지연 샘플이 나온 독립된 두 실행 모두 같은 패턴이었다: 어떤 스레드가 FUTEX_WAIT_BITSET_PRIVATE(절대 시각 데드라인, CLOCK_MONOTONIC 기준 타임드 웨이트)로 블로킹된 뒤 다른 어떤 스레드의 FUTEX_WAKE도 못 받고 자기 데드라인이 만료될 때까지 기다렸다가(64.4ms·62.2ms, 관측된 도착 갭 ~69ms와 거의 일치) ETIMEDOUT으로 깨어난다. "지연 샘플"은 데이터가 늦게 오는 것이 아니라, 그 스레드가 도착 신호를 못 받은 채 자기 타임아웃만 믿고 기다리는 것이다 — 126·127·128차의 세 배제 판정(네트워크·로그·CPU 활동 없음)과 완전히 들어맞는다. 정확히 어느 코드가 이 futex를 "웨이크업 연결 없이 타임드로만" 대기시키는지는 futex 주소를 심볼로 역추적하지 못해 확정하지 못했다(확인 필요, 다음 회차 후보 — 그 futex가 속한 라이브러리·오프셋 특정).

새 하네스는 tools/gap_harness/gap129/(gap129_order/ 패키지 — service_prespin_pure.cpp(mode 지원)·sub_arrival_colocated_gdb.cpp, 계측된 executor.cpp 2종(Humble: 세 단계 잠금 사건 추적, Jazzy: 결정론적 지연 주입), strace 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 130차) — 사전 스핀 지표 재검증은 부분 확인에 그쳤지만 뜻밖의 정정을 낳았고, Jazzy 이중 호출은 busy 플래그의 소스 구조로 배제됐고, "지연 샘플" futex의 정체가 strace -k로 완전히 심볼화됐다.

①(최우선) 사전 스핀이 세 단계 락 경합을 피하는 이유 — 부분 확인. 129차의 원시 사건 로그를 재분석해 "사전 스핀 조건에서 스레드들이 서로 다른 시점에 깨어나는가"를 측정했다. 뜻밖의 발견: 사전 스핀의 400ms idle 기간은 계속 순환하는 것이 아니었다 — 스핀 시작 직후 ~935µs 안에 17회를 몰아치고, 그 뒤로는 각 스레드가 단 한 번의 긴 rcl_wait() 블록에 들어가 남은 ~399ms 동안 재구성이 전혀 없다(127·128차의 "400ms에 걸쳐 분산" 서술을 정정한다, 위 참고). 이를 반영해 "첫 PICK 시점의 스레드별 위상 폭"을 다시 측정했지만 — 사전 스핀 271.4µs 대 기준 361.0µs로 통계적으로 유의하지 않았고(Welch $t=-1.93$), 방향도 가설과 안 맞았다(기준이 오히려 크고 변동성도 3배 컸다, 관측 가능한 스레드 수라는 교란변수도 있었다). 이 지표로는 129차의 메커니즘을 직접 확인하지 못했다(확인 필요, 다음 회차 후보).

② Jazzy 이중 호출 — busy 플래그를 세우는 지점에 직접 주입해도 재현 안 됐다. Jazzy get_next_ready_executable() 소스를 다시 읽어, busy 플래그(can_be_taken_from())가 execute_any_executable()이 execute_service()(실제 take 포함)를 완전히 마친 뒤에야 풀린다는 것을 확인했다 — 129차가 지연을 넣은 지점(execute_service() 진입)은 이미 busy로 보호된 구간 안이었다. 선택 반복문 안, busy를 세우기 직전으로 지연 지점을 옮겨 재실험했지만(무주입 30회·50ms 주입 50회) 다시 0/80 — 이 세션 누적 220회 사전 스핀 시도에서 이중 호출 0건이 됐다. 소스로 보면 당연한 결과다 — busy 플래그가 선택 시점부터 처리 완료까지 전 구간을 감싸므로 그 안 어디에 지연을 넣어도 재차 선택은 막힌다. 127차의 wait_result_ 경합 창 가설은 이 세션의 실험 범위에서 사실상 배제됐다 — 새 후보는 rmw/DDS 계층의 unread-count 플래그 자체가 잠깐 오래된 상태로 남는 것이다(확인 필요, 다음 회차 후보).

③ "지연 샘플" futex의 정체 — strace -k로 완전히 심볼화했다: FastDDS의 FlowControllerImpl이었다. bpftrace는 이 컨테이너 커널의 BTF 타입 파싱 실패로 폐기하고, 대신 strace -f -k -tt -e trace=futex(라이브러리 unwind 기반, BTF 불필요)로 처음부터 끝까지 감쌌다. 지연 샘플 갭(~159ms) 직후, eprosima::fastrtps::rtps::FlowControllerImpl<FlowControllerSyncPublishMode, FlowControllerFifoSchedule>::run()(FastDDS 라이터 쪽의 흐름 제어·전송 페이싱 스레드)이 ~152.6ms 동안 블로킹돼 있던 무기한 대기에서 뮤텍스 언락으로 깨어났고, 1.8ms 뒤 ResourceEvent::event_service()(FastDDS의 범용 내부 타이머 스레드)도 깨어난 직후 지연 샘플 데이터가 도착했다. "지연 샘플"은 소켓도 rclcpp의 wait-set도 아니라, FastDDS 자신의 라이터 쪽 흐름 제어 스레드가 큐에 쌓인 샘플을 내보낼 자기 차례를 몇십~백여 밀리초 기다리는 것이었다 — 같은 로그의 다른 지점에서 DataWriterImpl::write()→...→FlowControllerImpl::run()이라는 발행 경로 전체도 확인해, 같은 참가자 안의 배달(intra-process)도 이 흐름 제어 큐를 거친다는 것을 확정했다. FlowControllerImpl::run()이 왜 하필 ~150ms를 블로킹돼 있었는지는 이번에도 소스까지 완전히 추적하지 못했지만, 주소만 있던 129차와 달리 정확한 클래스·함수명까지 확보한 것 자체가 진전이다(확인 필요, 다음 회차 후보)(→ 131차 정정: "흐름 제어·전송 페이싱"이라는 표현은 오해의 소지가 있다 — 실제로는 고정 주기가 전혀 없는 순수 무기한 대기이고, "동기 전달이 실패한 샘플"이 큐에 들어올 때만 깨어난다, 아래 참고).

새 하네스는 tools/gap_harness/gap130/(129차 원시 로그 재분석 스크립트 2종, 계측된 Jazzy executor.cpp(새 지연 주입 지점), strace -k 완전 심볼화 원시 로그, README.md).

실행 확인(2026-08-20, ros:jazzy-ros-base·ros:humble-ros-base, 131차) — 락 A 충돌 지표를 다시 설계해 명확한 신호를 얻었고, Jazzy 이중 호출은 소스와 스트레스 테스트 둘 다로 새 후보를 좁혔고, "지연 샘플"의 "페이싱"이라는 표현이 틀렸음을 소스로 확인했다.

①(최우선) 락 A 충돌 — 방법론적 실패를 거쳐 명확한 신호를 얻었다. 첫 시도("두 스레드의 [REBUILD_START, REBUILD_END] 구간이 실제로 겹치는 횟수")는 사전 스핀·기준 둘 다 예외 없이 0이었다 — 당연했다, 그 구간 자체가 mutex_로 보호돼 원천적으로 겹칠 수 없다. 지표를 "재구성-끝(락 B)부터 실제 선택(PICK) 성공까지의 구간에 다른 스레드의 재구성-시작(락 A)이 몇 번이나 개입하는지"로 바꾸니 — 사전 스핀은 30회 전부 예외 없이 개입 0회, 기준은 평균 4.00회(최대 18회)로 완벽하게 갈렸다(Welch $t=4.708$, 구간 단위로는 기준의 16.4%가 개입을 겪었다 대 사전 스핀 0%, $t=5.755$). 129차의 세 단계 락 경합 메커니즘이 사전 스핀에서 사라진다는 것을 이제 직접 지표로 확인했다. 다만 기준 조건 안에서 개입 횟수와 실제 이탈 여부를 대조하니 방향은 맞지만(이탈 평균 4.77 대 무이탈 1.88) 완전한 1:1은 아니었다(개입 0인데도 이탈한 실행이 7/12) — 락 A 개입은 이탈의 유력한 경로 중 하나이지 유일한 경로는 아니다(확인 필요, 다음 회차 후보).

② Jazzy 이중 호출 — unread-count가 캐시가 아니라 실시간 조회임을 확인, 스트레스로도 재현 못 함. rmw_fastrtps_shared_cpp 8.4.4 소스를 읽으니 ServiceListener::get_unread_resquests()가 request_reader_->get_unread_count(true)를 그때그때 직접 호출한다 — rmw 계층이 캐시해 두는 "읽지 않음" 플래그 자체가 없다. 129차가 제시한 "unread-count 플래그 staleness" 가설은 이 형태로는 성립하지 않는다. 남은 창(busy가 true로 풀린 이후, FastDDS 자신의 내부 상태가 방금 끝난 take를 완전히 반영했는지)을 직접 열려면 FastDDS 내부 패치가 필요해 이번 회차 범위를 벗어난다고 판단하고, 대신 스레드 수를 극단적으로 늘린(N=6에 스레드 16개) 스트레스 테스트를 사전 스핀 조건 50회 했다 — 여전히 0/50, 127~131차 누적 270회에서 이중 호출 0건. 콘텐션을 극단적으로 늘려도 안 걸린다는 것은 이 창이 매우 좁거나 106차가 우연히 관측한 특정 호스트 조건에 의존한다는 뜻일 수 있다(확인 필요, 다음 회차 후보)(→ 132차 정정: "실시간 조회일 뿐 캐시가 아니다"는 rmw 호출부만 본 결론이었다 — 그 답 자체는 RTPSReader::total_unread_라는 유지되는 카운터였고, 서로 다른 두 경로가 그 값을 독립적으로 갱신한다, 아래 참고).

③ FlowControllerImpl 소스 직접 확인 — "~150ms 페이싱 주기"가 아니라 "무기한 대기"였다. Humble fastrtps 2.6.12 소스를 읽으니 FlowControllerSyncPublishMode는 FlowControllerPureSyncPublishMode와 FlowControllerAsyncPublishMode 둘 다를 상속하고, 129·130차가 검출한 futex는 후자의 wait()인데 그 구현은 cv.wait(lock) — 타임아웃이 전혀 없는 순수 무기한 대기다(고정 페이싱 주기는 대역폭 제한 모드에만 있고 이 테스트는 그 모드를 쓰지 않는다). 깨우는 지점(enqueue_new_sample_impl())의 주석이 정확히 설명한다 — // Sync delivery failed. Try to store for asynchronous delivery. 즉 이 스레드는 "동기 전달이 실패한" 샘플이 큐에 들어올 때만 깨어난다. 관측된 ~62~150ms는 FastDDS의 고정 상수가 아니라 이 스레드가 마지막 활동 이후 다음 알림을 받기까지 우연히 유휴했던 시간이었다 — 실제 남은 질문은 "동기 전달이 왜 그 샘플에서만 실패했는가"로 완전히 바뀐다(확인 필요, 다음 회차 후보).

새 하네스는 tools/gap_harness/gap131/(129차 원시 로그 재분석 스크립트 2종, Jazzy 스트레스 테스트 원시 로그, README.md — ③은 순수 소스 조사라 새 코드 없음).

실행 확인(2026-08-20, 소스 조사 위주, 132차) — "배치 분할"이 독립 변수가 아니라 락 A 현상의 증상이었음을 확인했고, Jazzy unread-count가 실은 캐시였다는 것을 더 깊은 소스에서 발견했고, "동기 전달 실패"의 정확한 함수와 후보 분기를 찾았다.

① 104차의 "배치 분할" 경로 — 독립 변수가 아니라 증상이었다. 129차 원시 로그를 재분석해 "N개가 한 rcl_wait() 배치로 도착하는가, 여러 배치로 분할되는가"를 사건 단위로 셌다 — 배치 수가 사전 스핀(평균 5.93/6)·기준(평균 5.17/6) 둘 다 N에 가까웠고, 오히려 이탈이 없는 사전 스핀 쪽이 더 높았다 — 104차의 "한 배치=정돈됨" 그림과 방향이 안 맞는다. 한 스레드가 자기 재구성 사이클 안에서 2개 이상을 연달아 선택한 경우가 이 데이터셋엔 사실상 없었다 — "배치 분할"은 두 조건을 가르는 독립 변수가 아니라, 두 조건 모두에서 항상 최댓값에 가깝게 나타나는 상수에 가까운 현상이었다. 131차의 락 A 개입과 2×2로 대조하니, 개입=0인데도 기준의 이탈률이 64%로 여전히 높아 — 락 A 개입 하나만으로는 기준 이탈의 상당 부분을 설명하지 못하는 잔차가 남았다(확인 필요, 다음 회차 후보).

② Jazzy 이중 호출 — unread-count가 실은 캐시였다(131차 정정), 두 개의 독립된 감소 경로도 찾았다. Jazzy fastrtps 2.14.6 소스를 rmw 호출부부터 RTPSReader까지 따라갔다 — 131차의 "실시간 조회일 뿐 캐시가 아니다"는 rmw 계층만 본 결론이었다. 그 답 자체는 RTPSReader::total_unread_라는 유지되는 카운터였다. 이 카운터를 갱신하는 지점이 최소 네 곳이었는데, 그중 get_unread_count(mark_as_read=true)(rmw 리스너 경로) 자체가 get_last_notified() 기준으로 직접 isRead를 세우는 것과, change_read_by_user()(실제 애플리케이션 take 경로)가 독자적으로 같은 카운터를 갱신하는 것이 서로 다른, 독립된 두 경로였다. 경합 창이 존재할 구조적 여지는 실재하지만, get_last_notified()가 정확히 무엇을 추적하는지와 두 경로가 실제로 어긋나는 시나리오는 이번 회차에 완전히 추적하지 못했다(확인 필요, 다음 회차 후보)(→ 133차 정정: get_last_notified()를 직접 읽으니 "드문 경합"이 아니라 "매 알림마다 결정론적으로 미리 당겨지는" 설계였다 — 이중 호출이 드문 것과 안 맞아 이 경로가 실제 원인일 가능성은 오히려 낮아졌다, 아래 참고).

③ 동기 전달 실패의 정확한 함수와 후보 분기를 찾았다. deliver_sample_to_intraprocesses() → intraprocess_delivery() → StatefulReader::processDataMsg()가 false를 반환하는 것이 "동기 전달 실패"의 정체다. 실패 분기 중 유력한 후보는 !mp_history->can_change_be_added_nts(...)와 !change_pool_->reserve_cache(...)(둘 다 "이 리더의 QoS가 허용하는 최대 샘플 수에 도달"이라는 같은 로그 메시지)다 — 이 책의 지연 샘플 재현 테스트가 KeepLast(1)을 쓰므로 유력해 보이지만, 기반 클래스 ReaderHistory::can_change_be_added_nts()를 실제로 읽으니 깊이 제한 자체는 구현돼 있지 않고 페이로드 크기 검사만 했다 — 실제 깊이 제한은 찾지 못한 파생 클래스(DataReaderHistory)에 있을 것으로 보인다(확인 필요, 다음 회차 후보).

새 하네스는 tools/gap_harness/gap132/(129차 원시 로그 재분석 스크립트, README.md — ②③은 순수 소스 조사라 새 코드 없음).

실행 확인(2026-08-20, 소스 조사 위주, 133차) — 잔차의 정체가 "국소 창 회전"이라는 것을 밝혔고, Jazzy의 경합 창 가설은 "매번 결정론적으로 일어나는 순서"임이 드러나 오히려 원인에서 멀어졌고, KeepLast(1) 가설은 이 테스트 구조에는 전혀 적용되지 않는다는 것을 확인해 배제했다.

①(최우선) 잔차의 정체 — 무작위 뒤섞임이 아니라 "국소 창 회전"이었다. 132차가 남긴 잔차(락 A 개입=0인데도 이탈한 기준 실행 7건)를 개별적으로 다시 보니 7건 전부가 등록 순서 안의 어떤 연속 구간(창)에서, 그 창의 마지막 원소가 창의 맨 앞으로 이동하고 나머지는 정확히 원래 상대 순서를 유지하는 순수한 회전이었다 — 무작위 뒤섞임은 하나도 없었다(예: [3,0,1,2,4,5]는 창 [0,1,2,3]이 회전한 것뿐). 해석: 이 창은 그 시점까지 실제로 도착해 있던 엔티티들의 부분집합이고, 창의 마지막(가장 늦게 등록된) 원소가 먼저 준비된 것으로 보이는 것은 103·124차가 이미 확립한 "동시에 보내도 실제 도착 순서는 등록 순서와 미세하게 어긋난다"는 현상의 재현으로 보인다 — 락 A 개입과는 서로 다른, 독립된 이탈 경로였다(확인 필요, 다음 회차 후보)(→ 134차 정정: 도착 시각을 미들웨어 리스너로 독립 계측하니 이탈 실행 27/27(100%) 전부 도착 순서는 완벽히 등록 순서였다 — "도착 미세 순서 어긋남" 가설은 직접 계측으로 반증됐다, 아래 참고).

② Jazzy get_last_notified() — 경합이 아니라 "결정론적으로 항상 그렇게 되는" 설계였다. RTPSReader::update_last_notified()의 호출부(StatefulReader::NotifyChanges())를 찾으니, *"notified" 워터마크가 개별 변경을 리스너에게 알리기 전에 이미 그 라이터의 최신 변경(max_seq)까지 미리 당겨진다("Update state before notifying"라는 주석이 그대로 있다). 이것은 드문 타이밍 경합이 아니라 모든 알림에서 매번 결정론적으로 일어나는 순서다. 이 발견은 오히려 129~132차의 경합 창 가설에 불리한 증거다 — 이 순서가 이중 호출의 원인이라면 항상 일어나야 하는데, 실제 이중 호출은 드물다(127~132차 누적 270회 사전 스핀 시도 중 0건) — total_unread_/isRead가 실제로 rcl_wait()의 준비 판정을 좌우하는 값이 아닐 가능성이 커졌다(확인 필요, 다음 회차 후보)(→ 134차 정정: rmw_wait.cpp를 다시 읽으니 실제 게이트는 DataReader::get_first_untaken_info()였다 — total_unread_와는 완전히 별개의 메커니즘이다, 아래 참고*).

③ KeepLast(1)은 이 테스트 구조에는 전혀 안 걸린다(가설 배제). DataReaderHistory::can_change_be_added_nts()는 전체 리소스 한도(max_samples)만 검사했고, KeepLast(depth)의 실제 강제는 received_change_keep_last()에 있었다 — 같은 인스턴스(토픽)에 이미 depth개가 차 있을 때만 "교체 시도" 분기가 발동한다. 이 책의 지연 샘플 재현 테스트는 토픽마다 정확히 한 번만 발행하므로, 그 한 번이 도착하는 순간 인스턴스는 항상 비어 있어 이 분기 자체가 발동할 일이 없다 — KeepLast(1) 깊이 제한 가설은 소스로 배제됐다. "동기 전달 실패"의 실제 원인은 여전히 열려 있다(확인 필요, 다음 회차 후보).

새 하네스는 tools/gap_harness/gap133/(129차 원시 로그 재분석 스크립트, README.md — ②③은 순수 소스 조사라 새 코드 없음).

실행 확인(2026-08-20, 134차) — 133차의 "국소 창 회전" 잔차가 도착 미세 순서 어긋남이라는 가설을 도착 시각 직접 계측으로 반증했고(이탈 28/28 전부 도착은 완벽히 정상이었다), rmw_wait.cpp를 다시 읽어 rcl_wait()의 실제 준비 판정 게이트가 total_unread_와 무관한 get_first_untaken_info()임을 확정했고, processDataMsg()의 후보 분기들을 GDB로 직접 계측해 1566회 중 단 한 번도 실패하지 않았음을 확인했다.

①(최우선) 잔차는 도착 잡음이 아니었다 — 미들웨어 리스너로 독립 계측하니 이탈 28/28 전부 도착 순서가 완벽했다. 133차는 "국소 창 회전"을 103·124차의 도착 미세 순서 어긋남과 같은 원인으로 추정만 했다. 128차의 service_prespin_evt(set_on_new_request_callback()으로 executor 디스패치와 완전히 독립적으로 도착 시각을 측정하는 도구)를 gap129의 계측 executor.cpp 위에서 다시 빌드해 사전 스핀·기준 각 30회를 새로 실행하니, 이탈 실행 28건(사전 스핀 1 + 기준 27) 전부에서 ARRIVAL_ORDER가 예외 없이 완벽한 등록 순서였다 — FIRE_ORDER가 [5,4,3,0,1,2]처럼 크게 뒤섞인 경우조차 그랬다. "도착 미세 순서 어긋남" 가설은 직접 계측으로 반증됐다 — 이탈은 도착 잡음이 전혀 아니고, 데이터가 이미 다 정상적으로 도착한 이후 executor의 선택/재구성 경합(129·131차의 락 A) 안에서 전부 만들어진다. 다만 이번 세션의 기준 조건 이탈률(90.0%)은 133차의 잔차 모집단보다 훨씬 높고 회전 형태도 복잡해(단순 회전이 아니라 블록 반전에 가까움), "국소 창 회전"이라는 형태론적 서술 자체를 이 회차가 재검증한 것은 아니다 — 세션 간 재현율 변동(121·122차 기존 패턴)으로 보인다(확인 필요, 다음 회차 후보)(→ 135차 보강: 개별 스왑을 락 A 개입과 사건 단위로 직접 대조하니 스왑의 79.5%가 개입과 동반됐다 — 아래 참고).

② rcl_wait()의 실제 준비 판정 게이트는 get_first_untaken_info()였다 — total_unread_와는 완전히 별개 메커니즘. rmw_wait.cpp(122차가 이미 읽었으나 133차가 "다음 회차 제안"에서 실재하지 않는 이름을 표적으로 잘못 지목했다)를 다시 읽어 확정한다 — 서비스·구독·클라이언트 각각에 대해 대기 전·후 두 번 불리는 함수는 DataReader::get_first_untaken_info()다. DataReaderHistory::get_first_untaken_info()(fastdds/subscriber/history/DataReaderHistory.cpp)는 129~133차가 판 total_unread_ 카운터를 전혀 갱신하지 않고, data_available_instances_라는 별도 맵을 순회한다 — 이 맵은 received_change()가 새 변경을 받을 때 채워지고, 실제 애플리케이션 take로 그 인스턴스의 캐시가 완전히 비었을 때만(check_and_remove_instance()) 비워진다. 즉 rcl_wait()의 준비 판정은 리스너의 카운터 셈이 아니라 "실제로 안 가져간 캐시 변경이 남았는가"라는, DDS 의미론에 훨씬 가까운 완전히 별개의 자료구조로 결정된다 — total_unread_/get_last_notified() 경로는 리스너 콜백이 유저 콜백을 부를지만 결정할 뿐, 대기집합 준비 판정과는 무관하다. 122차가 이미 확인한 대로 이 경로 역시 구독·클라이언트·서비스 셋 다 완전히 같은 코드라, 구독-서비스 비대칭은 여전히 설명하지 못한다(확인 필요, 다음 회차 후보)(→ 135차 검증: 이 게이트가 실제로 이중 준비 신호를 낼 수 있는지 결함 주입으로 직접 시험했다 — 구조적으로 불가능하다는 것이 확인됐다, 아래 참고).

③ processDataMsg()의 후보 분기들을 GDB로 직접 계측 — 1566회 측정에서 단 한 번도 실패하지 않았다. ros-humble-fastrtps-dbgsym으로 재빌드 없이 디버그 심볼만 확보해, StatefulReader::processDataMsg()의 각 실패 분기(can_change_be_added_nts·reserve_cache·get_payload·change_received)에 어셈블리 수준 중단점을 설정했다(최적화 빌드의 "동일 코드 접기"로 소스 줄번호 기준 중단점이 여러 분기를 뒤섞는 함정을 발견하고, 호출 직후 고유 주소로 우회했다). sub_arrival_colocated_gdb(125·128·129차)를 29회 실행해 총 1769회의 processDataMsg() 호출을 관찰했다 — 132·133차가 후보로 지목한 분기 전부(can_change_be_added_nts·reserve_cache·get_payload·change_received·필터·datasharing)가 실제 동기 전달 1566회에서 단 한 번도 실패하지 않았다. 유일한 실패는 acceptMsgFrom()(87회, 우리 테스트 토픽과 무관한 다른 참가자 트래픽)뿐이었다. 다만 이 세션에서는 "지연 샘플"(129차의 futex 타임드 웨이트) 자체가 한 번도 재현되지 않아 — GDB 오버헤드가 그 경합 창을 억눌렀을 가능성이 있다(128차의 계측-현상 문제 재확인) — "정상 배달은 이 분기들에서 안 실패한다"까지는 확정했지만 "지연 샘플이 실제로 나는 순간에도 안 실패한다"까지는 확정 못 했다(확인 필요, 다음 회차 후보)(→ 135차 정정: GDB를 걷어내고 재계측하니 지연 샘플이 40%대로 재현됐고, 재현된 33건에서도 이 분기들은 여전히 안 실패했다 — 게다가 130·131차가 지목한 "동기 실패→비동기 재시도" 인과 사슬 자체가 틀렸다는 것도 확인했다, 아래 참고).

새 하네스는 tools/gap_harness/gap134/(service_prespin_evt.cpp 재빌드·재실행, branch_instrument_gdb.py, README.md).

실행 확인(2026-08-20, 135차) — get_first_untaken_info()가 이중 준비 신호를 낼 수 있는지 결함 주입으로 시험해 구조적으로 불가능함을 확인했고, GDB를 걷어내고 원자적 카운터로 재계측하니 "지연 샘플"이 40%대로 재현됐으며 그 과정에서 130·131차의 "동기 실패→비동기 재시도" 인과 사슬 자체가 틀렸다는 것을 발견했고, 개별 스왑을 락 A 개입과 사건 단위로 대조해 79.5% 대 6.6%의 1:1 대응을 확인했다.

①(최우선) get_first_untaken_info()의 이중 준비 신호 — 구조적으로 불가능함을 결함 주입으로 확인했다. data_available_instances_를 채우는 add_to_instance()(수신 스레드)와 비우는 check_and_remove_instance()(애플리케이션 take 스레드)의 타이밍을 GAP135_REMOVE_DELAY_US(erase 직전 지연)로 어긋나게 해 결함 주입했다 — 지연 0·1ms·10ms·50ms 각 15회 + 지연 0 추가 30회, 디스커버리 트래픽까지 포함해 총 9084건의 add/remove 사건을 관찰했다. 50ms까지 지연을 넓혀도(자연 창의 약 700배) 지연된 check_and_remove_instance()의 실제 erase 창 안으로 다른 add_to_instance()가 개입한 경우는 단 한 번도 없었다. 이유를 소스에서 확인했다 — 두 함수는 같은 리더-레벨 RecursiveTimedMutex(reader_->getMutex()/mp_mutex) 아래서 호출돼 애초에 완전히 직렬화돼 있다. 134차가 찾은 게이트가 실재하는 것과 그 게이트가 이중 호출을 낼 수 있는 것은 별개의 질문이었는데, 이번 회차로 후자는 구조적으로도 실측으로도 부정됐다.

② GDB를 벗어나 재계측하니 "지연 샘플"이 40%대로 재현됐고, 130·131차의 인과 사슬이 틀렸다. StatefulReader::processDataMsg()에 std::atomic 카운터(printf 없음)를 심어 재빌드했다 — GDB 없이 40+40회를 실행하니 "지연 샘플" 재현율이 134차의 GDB 세션 0%(0/29)에서 40.0%(16/40)·42.5%(17/40)로 상승했다, 134차의 우려("GDB 오버헤드가 경합 창을 억눌렀을 가능성")가 확인됐다. 재현된 스트래글러 33건을 포함해도 processDataMsg()의 후보 분기(can_change_be_added_nts·reserve_cache·get_payload·change_received)는 여전히 단 한 번도 실패하지 않았다. 더 깊이 조사해, 131차가 "동기 전달 실패" 시에만 불린다고 지목한 enqueue_new_sample_impl()에 카운터를 심으니 40회(지연 샘플 17건 포함) 전부 0이었다 — 대신 add_old_sample_impl()(기존 변경 재전송, 디스커버리 관련으로 보임)이 지연 샘플 유무와 무관하게 매 실행 정확히 13회 불렸다. 즉 129·130차가 strace로 찾은 FlowControllerImpl::run() 스레드는 우리 샘플의 동기 실패를 처리하느라 깨어난 것이 아니라 정기적인 구식 샘플 재전송을 처리하고 있었다 — 이 스레드가 지연 샘플 시점 근처에서 관측된 것은 인과관계가 아니라 우연한 시간적 겹침일 가능성이 크다`(확인 필요, 다음 회차 후보 — 재전송에도 타임스탬프를 남겨 지연 샘플 도착과 직접 겹치는지 확인)(→ 136차 정정: 사건 단위로 겹쳐 보니 "우연"이 아니었다 — 지연 샘플 16/16건 전부에서 재전송이 그 갭 창 안에 정확히 들어왔다, 아래 참고).

③ 개별 스왑을 락 A 개입과 사건 단위로 대조 — 79.5% 대 6.6%로 확인했다. 128차의 도착 시각 도구를 gap129의 계측 executor.cpp 위에서 다시 빌드해 기준 조건 30회를 새로 실행하고, 각 PICK 사건마다 "이 스레드 자신의 직전 REBUILD_END부터 이 PICK까지 다른 스레드의 REBUILD_START가 몇 번 개입했는가"를 사건 단위로 계산했다 — 스왑된 픽(44건)의 79.5%(35건)는 실제로 다른 스레드의 재구성 시작이 그 순간에 개입해 있었고, 정상 순서로 선택된 픽(136건)은 6.6%(9건)만 그랬다(약 12배 차이). 108·110차의 회차-단위 상관을 개별 스왑 하나하나에 대해 직접 확인한 것이다. 나머지 9/44(20.5%)의 스왑은 개입 없이 일어났다 — 134차가 이미 이 잔차가 도착 잡음이 아님을 확인했으므로 여전히 설명되지 않은 채 남는다(확인 필요, 다음 회차 후보)(→ 136차: 이 잔차의 정체를 개별 재검토해 100% 설명했다 — "자기 재구성을 전혀 안 거친 빠른 경로 픽"이었다, 아래 참고).

새 하네스는 tools/gap_harness/gap135/(DataReaderHistory.cpp·StatefulReader.cpp·FlowControllerImpl.hpp 계측 사본, 분석 스크립트 4종, README.md).

실행 확인(2026-08-20, 136차) — 재전송(add_old_sample_impl())이 지연 샘플 갭 창 안에 정확히 겹친다는 것을 사건 단위로 확인해 135차의 "우연" 판단을 정정했고, 잔차 스왑(개입 없는 9/44)을 개별 재검토해 100% "자기 재구성 없는 빠른 경로 픽"임을 밝혔고, 픽 위치별로 나눠 스왑률이 60.0%(픽#0)에서 0%(픽#4·5)로 단조 감소함을 확인했다.

①(최우선) 재전송과 지연 샘플 — "우연한 겹침"이 아니라 갭 창 안에 정확히 들어와 있었다. FlowControllerImpl.hpp에 사건 단위(steady_clock ns) 로그를 추가하고 테스트 프로세스의 도착 시각을 절대 시각으로 환산해 직접 겹쳐 봤다 — 45회 실행(지연 샘플 16건, 35.6%) 중 지연 샘플 16/16건 전부에서, add_old_sample_impl()의 재전송 사건이 그 ~70ms 갭 창 안에 2~8회씩 정확히 들어와 있었다(총 86회). 우연이라면 실행당 약 0.3회가 기대되는데(13회/실행이 3초에 고르게 퍼졌다고 가정) 실제로는 지연 샘플 실행당 평균 5.4회로 약 18배 더 많다. 135차의 "우연한 시간적 겹침일 가능성이 크다"는 판단을 정정한다 — 재전송과 지연 샘플은 실제로 강하게 동시에 일어난다. 다만 인과 방향(재전송 폭주가 지연을 유발하는지, 둘이 같은 상위 원인의 별개 증상인지)은 이번 회차로도 가리지 못했다(확인 필요, 다음 회차 후보).

② 잔차 스왑 개별 재검토 — 28/28이 "자기 재구성 없는 빠른 경로 픽"이었다. 60회 표본으로 늘려 락 A 개입=0인 스왑마다 "그 스레드 자신의 직전 REBUILD_END가 로그에 있는지"를 추가로 확인했다 — 75건의 스왑 중 28건(잔차) 전부에서 그 픽을 만든 스레드는 이 실행 안에서 자기 REBUILD_START/END 사이클을 단 한 번도 기록하지 않았다(개입 있던 47건과 정상 픽 285건은 예외 없이 자기 REBUILD_END가 있었다). 이유를 소스에서 확인했다 — Executor::get_next_executable()은 wait_for_work()(락 A 재구성)보다 get_next_ready_executable()을 먼저 시도한다: 다른 스레드가 이미 채워 둔 service_handles_에 안 선택된 항목이 남아 있으면 이 스레드는 자기 재구성을 한 번도 안 거치고 곧바로 선택한다 — 이것이 락 A 개입 지표가 애초에 적용될 수 없는(비교 기준점인 "자기 REBUILD_END"가 없는) 빠른 경로다. 108·110차의 락 A와 이번 회차의 빠른 경로를 합치면 지금까지 관측된 스왑의 100%(47+28=75/75)가 설명된다.

③ 픽 위치별 분리 — 스왑률이 60.0%(픽#0)에서 0%(픽#4·5)로 단조 감소했다. 같은 60회 데이터를 픽 순번으로 나눠 스왑률을 다시 셌다.

픽 순번스왑(빠른 경로)정상스왑률
pick#03692460.0%
pick#12273836.7%
pick#215124525.0%
pick#320583.3%
pick#400600.0%
pick#500600.0%

스왑률이 픽 순번에 따라 단조 감소하고, ②의 "빠른 경로" 스왑 28건 전부가 픽#0~2에만 몰려 있다(픽#3~5에는 0건). 자연스러운 결과다 — "자기 재구성을 한 번도 안 한 스레드"는 스핀이 막 시작된 시점에만 존재할 수 있고, 각 스레드가 최소 한 번씩 wait_for_work()를 거치고 나면 이 경로 자체가 더는 발동할 수 없다. 결론: 첫 픽의 스왑은 중간 픽 스왑의 "극단값"이 아니라, 락 A 경합이 가장 심하다는 것과 빠른 경로가 존재할 수 있는 유일한 구간이라는 두 가지가 정확히 겹치는 스핀-시작 특유의 현상이다.

새 하네스는 tools/gap_harness/gap136/(FlowControllerImpl.hpp·sub_arrival_colocated_gdb.cpp 계측 사본, 분석 스크립트 2종, README.md).

실행 확인(2026-08-20, 137차) — 재전송 억제 결함 주입으로 인과관계를 완전히 확인했고("재전송이 곧 유일한 전달 경로"), "빠른 경로" 픽의 100%가 남의 재구성 상태를 물려받는다는 것을 사건 단위로 확인했고, Jazzy의 선택 경로를 처음으로 직접 계측해 Humble의 두 메커니즘과는 다른 구조적 잔차를 발견했다.

①(최우선) 재전송 억제 결함 주입 — 재전송이 이 테스트의 유일한 전달 경로였다. add_old_sample_impl()에 GAP137_SUPPRESS_RESEND 환경변수로 실제 재전송을 건너뛰는 결함 주입을 넣고 정상·억제 각 40회를 실행했다 — 정상 조건은 40/40 모두 6개 메시지 전부 수신(fired=6)했지만, 억제 조건은 40/40 모두 단 하나도 수신하지 못했다(fired=0). 이유는 테스트 구조에 있었다 — sub_arrival_colocated_gdb는 TRANSIENT_LOCAL QoS로 구독을 만들기 전에 6개 메시지를 전부 발행한다, 즉 발행 시점엔 매칭된 리더가 없어 동기 전달 시도(deliver_sample_to_intraprocesses()) 자체가 무의미하고, 나중에 매칭된 구독에게 라이터 히스토리의 오래된 샘플을 채워 주는 add_old_sample_impl() 경로가 이 테스트의 유일한 전달 수단이었다. 136차의 "사건 단위 겹침"은 우연도 부작용도 아니라, 지연 샘플이 바로 그 재전송 경로를 타는 6개 샘플 중 비동기 스레드(FlowControllerImpl::run(), 129·130차가 특정)의 스케줄링 지연을 물려받은 하나였다는 뜻이다. 134·135차의 "processDataMsg() 1566회 중 0건 실패" 측정 자체는 정확했지만, 그 호출 대부분이 디스커버리 트래픽이었을 뿐 우리 6개 테스트 토픽의 실제 전달은 동기 경로가 아니라 이 비동기 재전송 경로였다는 것이 이번 회차로 드러났다(확인 필요, 다음 회차 후보 — processDataMsg() 호출마다 토픽 식별자를 남겨 실제 비율을 확정한다)(→ 138차: 실제로 남겨 확정했다 — 우리 테스트 토픽은 전체 호출의 19.7%뿐이었다, 아래 참고).

② "빠른 경로" 픽의 상속 상태 — 100%가 남의 재구성을 물려받았다. wait_for_work()의 REBUILD_END마다 "마지막 재구성자" 스레드를 전역 원자값에 기록하고 매 PICK마다 함께 남겼다 — 빠른 경로 픽 29건 전부(100%)에서 선택한 스레드가 아니라 다른 스레드가 마지막으로 재구성한 상태를 물려받았다(그 외 픽은 331건 중 78건, 23.6%만 그랬다). 136차의 메커니즘("자기 재구성 없이 남의 벡터를 스캔")이 정확히 이렇게 작동한다는 것을 사건 단위로 완전히 확인했다 — 빠른 경로 픽이 항상 스왑을 만드는 것은 아니다(29건 중 19건, 65.5%만 스왑 — 남의 상태를 물려받아도 마침 기대 인덱스가 남아 있으면 정상으로 보일 수 있다). "자기 재구성 없이 선택한다"에서 "왜 남의 상태인가"까지 100% 닫혔다.

③ Humble·Jazzy 같은 세션 직접 비교 — Jazzy 소스에서 구조적 차이를 확인했지만, 절대 이탈률 비교는 계측 자체에 흔들렸다. Jazzy rclcpp 28.1.21의 get_next_ready_executable()을 처음으로 직접 읽었다 — Humble의 "빠른 경로"와 비슷해 보이지만 게이트가 다르다: Jazzy는 공유 std::optional 멤버 wait_result_를 wait_for_work() 맨 앞에서 매번 reset()한 뒤 다시 채운다(Humble의 service_handles_는 점진적으로 걸러지는 벡터로, 결코 "빈 상태"로 리셋되지 않는다). 이 차이를 확인하려 Humble과 같은 형식(gap129_evt)의 계측을 Jazzy에도 심어 같은 세션에서 60회씩 나란히 실행했다 — Humble 44/60(73.3%) 이탈·66개 스왑(락 A 48, 빠른 경로 18) 대 Jazzy 60/60(100%) 이탈·242개 스왑(락 A 14, 나머지 228은 own_rebuild_end가 있는데도 개입 없는 — Humble의 빠른 경로와는 다른 형태의 잔차). 정직하게 밝힌다 — 이 절대 수치는 113·114차의 기존 기준(서비스 이탈률 Jazzy 54.0% 대 Humble 74.7%, Humble이 더 나쁨)과 방향이 반대다. 이번 회차가 처음으로 Jazzy의 선택 경로 자체에 사건 단위 계측을 심었다는 것이 유력한 원인으로 보인다(113·114차는 계측 없는 순정 빌드였다) — 그래서 이 절대 비교는 기존 결론을 뒤집는 근거로 못 쓴다. 유효한 것은 Jazzy 스왑의 94.2%(228/242)가 Humble에서 확립한 두 메커니즘(락 A·빠른 경로) 어느 쪽으로도 안 설명된다는 구조적 사실이다 — 108차가 원래 지목한 std::unordered_map 해시 순서가 스레드 경합과 거의 무관하게 매 wait() 호출마다 결정론적으로 순서를 흩뜨리는, Humble과는 다른 근본 메커니즘일 가능성을 사건 단위로 뒷받침한다(확인 필요, 다음 회차 후보 — 계측 없는 순정 빌드로 같은 세션 비교를 다시 해 오늘의 역전이 계측 오버헤드 때문인지 확정한다)(→ 138차 정정: 계측 없이 다시 측정하니 Jazzy는 여전히 60/60(100%)이었다 — 역전은 계측 오버헤드가 아니라 세션 변동성이었다, 그리고 이 해시 순서 가설을 소스와 실측 둘 다로 완전히 확정했다, 아래 참고).

새 하네스는 tools/gap_harness/gap137/(FlowControllerImpl.hpp·humble_executor.cpp·jazzy_executor.cpp 계측 사본, 분석 스크립트 2종, README.md).

실행 확인(2026-08-20, 138차) — 계측 없는 Humble·Jazzy 재비교로 역전이 계측 오버헤드가 아니라 세션 변동성임을 확인했고, Jazzy의 선택 순서가 std::unordered_map<const rcl_service_t*, ...>의 해시 버킷 순서라는 것을 소스와 60회 실측(61.7%가 정확히 같은 순서로 수렴)으로 완전히 확정했고, processDataMsg() 호출에 토픽 식별자를 남겨 134·135차 측정의 80.3%가 우리 테스트와 무관한 트래픽이었음을 밝혔다.

①(최우선) 계측 없는 재비교 — 역전은 계측 오버헤드 때문이 아니었다. service_prespin_evt를 어떤 오버레이도 없이 시스템 rclcpp에 그대로 빌드해 같은 세션에서 기준 조건 60회씩 실행했다 — Jazzy는 계측을 걷어내도 여전히 60/60(100%) 이탈이었다(137차 계측 세션과 동일). Humble은 계측 유무에 따라 실제로 바뀌었다(73.3%→50.0%) — 하지만 그래도 113·114차 기존 기준과는 방향이 다르다. 결론: 오늘 세션의 역전은 계측 오버헤드가 아니라 121·122차가 이미 확립한 세션 간 변동성으로 보는 것이 타당하다.

② Jazzy 선택 순서의 정체 — 해시 버킷 순서였다, 소스와 실측 둘 다로 확정했다. next_ready_service()(rclcpp/wait_result.hpp)는 rcl_wait_set.services[]를 단순 선형 스캔한다 — 순서는 이 배열이 채워지는 순서에서 온다. Executor::collect_entities()를 추적하니 current_collection_.services.update(...)가 ExecutorEntitiesCollection::ServiceCollection(std::unordered_map<const rcl_service_t*, ...>를 상속)의 update_entities()를 부르고, 이 함수는 update_from.begin()부터 end()까지 순회한다 — 이것이 바로 해시 버킷 순회 순서다. 108차의 원래 가설이 함수·줄 단위로 완전히 확정됐다. 137차가 이미 모은 Jazzy 60회 원시 로그로 직접 검증하니 — 60회 중 37회(61.7%)가 정확히 같은 FIRE_ORDER(5,3,2,1,0,4) 하나로 수렴했다. 순수 레이스라면 매번 다른 순서가 나와야 하는데, 720가지 순열 중 하나가 이렇게 압도적으로 반복되는 것은 거의 항상 같은 해시 버킷 순서로 귀결되는 결정론적 메커니즘과 정확히 들어맞는다. Jazzy의 실패 모드는 Humble과 근본적으로 다르다 — Humble은 스레드가 서로를 밟는 동적·확률적 경합이고, Jazzy는 거의 항상 같은 순서로 스크램블되는 정적·결정론적 편향이다.

③ processDataMsg() 호출의 실제 토픽 구성 — 우리 테스트 토픽은 20%뿐이었다. StatefulReader::processDataMsg() 맨 앞에서 리더의 GUID 엔티티ID를 사건 단위로 남기고 sub_arrival_colocated_gdb를 30회 실행했다 — 총 1830회 호출 중 우리 6개 테스트 토픽(엔티티ID 6종, 각 60회=실행당 2회)은 360회(19.7%)뿐이었고, 나머지 80.3%는 엔티티ID 0x00000204 하나가 73.8%를 차지하는 배경 트래픽이었다. 이 엔티티ID는 빌트인 SPDP/SEDP 종류 코드가 아니라 일반 사용자 리더 종류 코드를 써서, rmw_fastrtps의 ROS 그래프 상태 전파용 일반 DDS 토픽(ros_discovery_info류)일 가능성이 크다(확인 필요, 다음 회차 후보 — 토픽 이름을 직접 남겨 확정한다)(→ 139차: 실제로 확정했다 — ros_discovery_info가 맞았다, 아래 참고). 결론: 134·135차의 측정 자체는 정확했지만 그 대부분은 배경 트래픽이었다 — 우리 자신의 360회는(실행당 토픽당 정확히 2회, 137차의 결함 주입 결과와 일관) 이번 로그로도 0건 실패로 재확인됐다.

새 하네스는 tools/gap_harness/gap138/(StatefulReader.cpp 계측 사본, 분석 스크립트 2종, README.md).

실행 확인(2026-08-20, 139차) — 배경 트래픽의 정체를 토픽 이름까지 확정했고, Jazzy의 "비지배적" 순서가 순수 잡음이 아니라 소수의 추가 안정적 순서로 수렴한다는 것을 확인했다.

① 배경 트래픽 정체 확정 — ros_discovery_info였다. DataReaderHistory의 topic_name_(원래 private)에 공개 접근자를 추가해 processDataMsg()에서 토픽 이름까지 함께 남겼다 — 138차가 73.8%로 지목한 엔티티는 정확히 ros_discovery_info(122차가 이미 언급한, rmw_fastrtps의 ROS 그래프 상태 전파용 일반 DDS 토픽)였고, 6.6%였던 두 번째는 rt/parameter_events(모든 노드가 기본으로 갖는 파라미터 변경 알림 토픽)로 확인됐다. 우리 6개 토픽은 예상대로 실행당 정확히 2회씩이었다.

② Jazzy의 "비지배적" 순서 — 순수 잡음이 아니라 소수의 추가 안정적 순서였다. 137차의 Jazzy 60회 로그를 재분석하니, 4개의 서로 다른 순서가 반복적으로 나타나 전체의 81.7%(49/60)를 차지했다(1위 5,3,2,1,0,4 37회, 2위 2,1,5,0,4,3 7회, 3위 5,4,3,2,1,0 3회, 4위 5,3,4,2,1,0 2회) — "지배적 순서 하나 + 순수 잡음"이라는 138차의 단순화를 다듬는다. 소수의 안정적인 해시 버킷 순서 후보군이 있고, 실행마다(ASLR에 따른 힙 배치 차이로 추정) 그중 하나로 수렴하는 것으로 보인다. 다만 반복 순서와 단발 순서(11/60, 18.3%)를 REBUILD_START 총 횟수로 갈라 보려는 시도는 명확하게 갈리지 않아, 단발 순서가 순수 락 A 잡음인지 그 자체도 안정적인데 표본이 작아 한 번씩만 걸린 것인지는 가르지 못했다(확인 필요, 다음 회차 후보).

새 하네스는 tools/gap_harness/gap139/(DataReaderHistory.hpp·StatefulReader.cpp 계측 사본, 분석 스크립트 1종, README.md).

2.3.15 실무 지침 — 이 장의 실측이 실제로 말해 주는 것

108~139차(누적 32회차)에 걸친 executor 디스패치 순서 조사를 요약한다. 2.3.14의 실측 전체를 읽지 않고도 바로 쓸 수 있는 결론만 추린다 — 각 항목은 위 실측의 어느 회차에서 나왔는지 표시했으니, 근거가 필요하면 그 번호로 거슬러 올라가면 된다.

증명된 것 — 그대로 믿고 설계에 반영해도 된다.

  1. 같은 타입 엔티티(예: 서비스 여러 개) 사이의 콜백 실행 순서는 등록 순서와 무관하다 — Humble·Jazzy 둘 다, SingleThreadedExecutor·MultiThreadedExecutor 둘 다에서. 이것은 "가끔 깨지는 버그"가 아니라 두 배포판 모두의 구조적 설계다(108차). 순서에 의존하는 코드(예: "서비스 0번이 항상 먼저 응답한다")는 처음부터 잘못된 가정 위에 서 있다.
  2. Humble과 Jazzy는 서로 다른, 이제는 완전히 규명된 메커니즘으로 이 순서를 깬다. Humble은 동적·확률적 경합이다 — 여러 스레드가 공유 service_handles_ 벡터를 두고 서로의 재구성을 밟는 "락 A 개입"(108·129·131차)와, 아직 자기 재구성을 한 번도 안 거친 스레드가 남의 벡터를 그대로 스캔하는 "빠른 경로"(136·137차) — 둘을 합치면 관측된 이탈의 100%가 설명된다. Jazzy는 정적·결정론적 편향이다 — std::unordered_map<const rcl_service_t*, ...>의 해시 버킷 순회 순서(108·138차)이며, 스레드 경합과 거의 무관하게 매 실행 소수의 안정적인 순서 중 하나로 수렴한다(139차). 이 차이가 실무적으로 중요하다: Humble의 이탈은 경합을 줄이면(아래 3번) 크게 줄지만, Jazzy의 이탈은 스레드를 하나로 줄여도 사라지지 않는다 —애초에 스레드 경합이 원인이 아니기 때문이다.
  3. Humble에서 executor를 "사전 스핀"하면(트래픽이 도착하기 전에 spin()을 먼저 시작해 자리잡을 시간을 준다) 서비스 디스패치 이탈이 극적으로 줄어든다 — 이 조사 전체에서 측정된 가장 큰 단일 효과다(82.1% → 0.0%, 126차). 스레드들이 트래픽 도착 순간에 한꺼번에 재구성을 경합하는 대신, 그 다툼을 트래픽 도착 전 유휴 시간에 미리 분산해 소화하기 때문으로 보인다(128·136·137차가 메커니즘까지 규명). Jazzy에는 이 완화책이 통하지 않는다 — 2번에서 말했듯 Jazzy의 이탈은 경합이 아니라 해시 순서이므로, 사전 스핀이 경합 창을 줄여도 근본 원인은 그대로 남는다.
  4. TRANSIENT_LOCAL QoS로 구독보다 먼저 발행하면, 그 데이터의 실제 전달은 "동기" 코드 경로가 아니라 백그라운드 스레드(FastDDS의 FlowControllerImpl::run())를 거치는 별도 경로를 거친다(129~137차가 전 과정을 소스·strace·결함 주입으로 규명). 이 경로는 새로 매칭되는 순간까지 대기하다 한꺼번에 처리되는 방식이라, 같은 프로세스 안에서도 나머지 데이터보다 수십 ms(이 책의 재현 조건에서 최대 ~70ms) 늦게 도착하는 샘플이 섞일 수 있다 — "이미 매칭된 구독에 새로 쓰는 것"과 "먼저 써 두고 나중에 구독이 따라잡는 것"은 지연 특성이 다른 별개의 경로라고 생각하고 설계해야 한다.

증명되지 않은 것 — 정직하게 열어 둔다.

  • 서비스 콜백이 한 번의 요청에 두 번 불리는 현상("이중 호출")은 양쪽 배포판에서 간헐적으로 관측됐지만(113·126차), 재현율이 세션마다 크게 흔들리고(0%~44%) 원인이 되는 정확한 경합 창은 26개 이상의 회차에 걸친 결함 주입(86·127·129·130·131·135차 등)으로도 특정하지 못했다. 실무적으로는 서비스·구독 콜백을 멱등하게 작성하는 것을 방어적으로 권한다 — 같은 요청이 두 번 들어와도 안전하게.
  • ASLR은 부분적인 역할만 한다. Humble에서 핸들 주소가 디스패치 순서와 1:1 상관관다는 것은 확인됐지만(117차), ASLR을 꺼도 이탈이 0%가 되지 않아(115·117차) "ASLR이 유일한 원인"이라는 가설은 반증됐다 — 힙 배치·디스커버리 타이밍 등 다른 요인도 함께 작용한다.
  • Humble이 서비스·타이머에서 Jazzy보다 얼마나/왜 더 불리한지의 정량적 이유는 여전히 완전히 열려 있다 — 엔티티 개수(122·123차)·도착 시간 폭(124·125차)·재구성 비용(118차)·CPU 부하(117·118차) 등 여러 가설이 부분적으로만 맞고 전체를 설명하지 못했다. 배포판별 메커니즘 자체(2번)는 규명됐지만, Humble이 "왜 더 불리한 숫자로" 나타나는지는 아직 답이 없다.

견해: 이 32회차의 실제 교훈은 "어느 executor가 더 안전한가"가 아니라 "안전한 순서를 executor에게 기대하지 말라"는 것이다. 같은 타입 엔티티가 여럿이면 콜백 실행 순서를 애플리케이션 로직에 넣지 마라 — 필요하면 요청 안에 명시적 시퀀스 번호나 우선순위를 넣고 애플리케이션 계층에서 정렬하라. executor는 "언젠가는 전부 실행된다"만 보장하지, "이 순서로 실행된다"는 보장하지 않는다.

다음 장 2.4는 이 선택과 한 세트인 콜백 그룹을 다룬다. 멀티스레드 executor의 동시성을 실제로 통제하는 장치이며, 데드락 해법 ②의 정확한 사용법이 여기 있다.