제2부 실행 모델·클라이언트 라이브러리 내부
2.8 rclpy 심층 — GIL·executor·비동기
2.8.1 이 장에서 배우는 것 / 선수 지식
지금까지 제2부는 C++ 중심이었다. 그런데 실무 ROS 2 시스템의 상당 부분은 Python으로 쓰인다 — 그리고 Python에서는 실행 모델의 규칙이 일부 달라진다. 가장 큰 차이는 GIL(Global Interpreter Lock)이다. "멀티스레드 executor를 쓰면 병렬로 실행된다"는 C++에서의 통념이 Python에서는 조건부로만 참이다.
이 장은 rclpy를 rclcpp와의 차이 중심으로 다룬다. 같은 부분은 짧게 넘어가고, 다른 부분과 함정에 집중한다.
배우는 것:
- rclpy의 구조 — Python 코드 아래에 무엇이 있는가.
- GIL이 바꾸는 것 — 무엇이 병렬화되고 무엇이 안 되는가.
- rclpy의 executor·콜백 그룹 — rclcpp와 같은 점과 다른 점.
- 비동기 패턴 —
call_async·add_done_callback, 그리고 asyncio와의 관계. - 성능 특성 — 어디까지 Python으로 해도 되는가, 판단 기준.
- numpy 연동 — 대용량 데이터를 Python에서 다루는 현실적 방법.
- Python 특유의 함정 — 참조 유지, 종료 처리, 예외.
- 미확인 항목 셋 확정(2.8.11) — 포인트클라우드 API·콜백 예외·로그 레벨 조회.
- C++과의 비대칭 모음(2.8.12) — 로깅 비용·
throttle·인자 평가·예외 처리. - Python 예시 이름 감사(2.8.13) — 컴파일러가 없을 때 무엇을 어떻게 대조하는가, 그리고 그 한계.
선수 지식: 2.2~2.4(executor·콜백 그룹), 1.7(서비스 데드락), 1.3.7(rclpy 비용의 계층적 원인).
정직: rclpy 내부 구조(
_rclpyC 확장, executor 구현)는 소스 저장소 기준으로 서술한다. GIL 해제 구간의 정확한 범위는 구현 세부이며 버전에 따라 다를 수 있다(확인 필요). Python 3.13의 free-threaded 빌드 같은 변화가 이 장의 전제를 바꿀 수 있으나, Jazzy의 Tier 1 플랫폼(Ubuntu 24.04, Python 3.12) 기준에서는 GIL이 존재한다고 전제한다(목차 3).
2.8.2 rclpy의 구조
rclcpp와의 구조적 차이:
| 축 | rclcpp | rclpy |
|---|---|---|
| 언어 | C++ | Python + C 확장 |
| 메시지 표현 | C 구조체와 호환되는 C++ 객체 | Python 객체 ↔ C 구조체 변환 필요 |
| executor | C++ 구현 | Python 구현 |
| 콜백 실행 | 네이티브 호출 | Python 인터프리터 |
| 공유 층 | rcl | rcl(동일) |
rcl을 공유하므로 이름 해석·리매핑·파라미터 파일 처리는 완전히 동일하다(2.1.7). 반면 executor는 각 언어가 따로 구현하므로 세부 동작이 다를 수 있다.
2.8.3 GIL — 무엇이 병렬화되고 무엇이 안 되나
GIL은 "한 순간에 하나의 스레드만 Python 바이트코드를 실행할 수 있다"는 제약이다.
| 작업 | 병렬화 | 이유 |
|---|---|---|
| Python 루프·산술 | ❌ | GIL 보유 |
| 리스트·딕셔너리 조작 | ❌ | GIL 보유 |
| numpy 배열 연산 | ✅ | C 구현이 GIL 해제 |
| OpenCV 처리 | ✅ | 동일 |
| 파일·네트워크 I/O | ✅ | 대기 중 해제 |
time.sleep() | ✅ | 대기 중 해제 |
| 메시지 직렬화(C 확장) | ✅(부분) | C 구간(확인 필요) |
실무 결론:
rclpy에서
MultiThreadedExecutor의 이득은 "콜백이 GIL을 해제하는 작업을 할 때"만 나온다. 순수 Python 계산이 병목이면 스레드를 늘려도 총 처리량은 늘지 않는다 — 오히려 GIL 경합과 컨텍스트 스위치로 악화될 수 있다.
그럼에도 MultiThreadedExecutor가 필요한 경우가 있다: 1.7의 데드락 회피다. 콜백 안에서 서비스 응답을 기다리려면 다른 스레드가 응답 콜백을 처리해야 하는데, 그 스레드는 대기 중이라 GIL을 획득하고 있지 않으므로 이 구조는 성립한다. 병렬 처리량이 아니라 블로킹 회피가 목적인 셈이다.
2.8.4 rclpy의 executor와 콜백 그룹
API는 rclcpp와 유사하다.
import rclpy
from rclpy.node import Node
from rclpy.executors import SingleThreadedExecutor, MultiThreadedExecutor
from rclpy.callback_groups import (MutuallyExclusiveCallbackGroup,
ReentrantCallbackGroup)
class MyNode(Node):
def __init__(self):
super().__init__('my_node')
self.timer_group = MutuallyExclusiveCallbackGroup()
self.client_group = MutuallyExclusiveCallbackGroup()
self.cli = self.create_client(Trigger, 'target',
callback_group=self.client_group)
self.timer = self.create_timer(1.0, self.on_timer,
callback_group=self.timer_group)
def main():
rclpy.init()
node = MyNode()
executor = MultiThreadedExecutor(num_threads=4)
executor.add_node(node)
try:
executor.spin()
except KeyboardInterrupt:
pass
finally:
executor.shutdown()
node.destroy_node()
rclpy.shutdown()
같은 규칙이 적용된다:
- 그룹을 지정하지 않으면 노드의 기본
MutuallyExclusive그룹으로 간다(2.4.3). - 데드락 회피에는 그룹 분리 + MultiThreadedExecutor가 필요하다(2.4.6).
Reentrant그룹은 같은 콜백의 동시 실행을 허용한다.
다른 점:
| 항목 | 차이 |
|---|---|
| 병렬 처리량 | GIL 때문에 제한적(2.8.3) |
| 성능 특성 | executor 오버헤드가 Python이라 더 크다 |
| 구현 세부 | 대기·깨우기 메커니즘이 다를 수 있음(확인 필요) |
견해: rclpy에서는 "부하가 큰 계산을 병렬화"보다 "블로킹을 피하고 GIL 해제 작업으로 밀어내는" 전략이 맞다. 계산 자체의 부하가 크면 numpy·OpenCV로 내리거나(GIL 해제) C++ 노드로 옮기는 것이 근본 해법이다.
2.8.5 비동기 패턴 — 무엇을 쓰고 무엇을 피하나
# ❌ 절대 하지 말 것 — 콜백 안에서 동기 호출
def on_timer(self):
response = self.cli.call(request) # 데드락 위험
# ❌ 콜백 안에서 spin
def on_timer(self):
future = self.cli.call_async(request)
rclpy.spin_until_future_complete(self, future) # 데드락
# ✅ 콜백 체인
def on_timer(self):
future = self.cli.call_async(request)
future.add_done_callback(self.on_response)
def on_response(self, future):
try:
result = future.result()
self.get_logger().info(f'결과: {result.message}')
except Exception as e:
self.get_logger().error(f'호출 실패: {e}')
# ✅ 콜백 그룹 분리 + MultiThreadedExecutor 라면 대기도 가능
def on_timer(self):
future = self.cli.call_async(request)
rclpy.spin_until_future_complete(self, future, timeout_sec=3.0)
if future.done():
result = future.result()
견해: 팀 규칙으로 "rclpy에서
call()(동기)은 쓰지 않는다"를 세우는 것이 안전하다.call()은 편해 보이지만 실행 문맥에 따라 조용히 멈추고, 그 원인을 초심자가 파악하기 어렵다.call_async+add_done_callback만 허용하면 이 부류의 사고가 사라진다.
future.result()는 예외를 던질 수 있다. 반드시 try로 감싼다 — 콜백에서 던진 예외가 executor를 어떻게 처리하는지는 상황에 따라 다르고, 최악의 경우 조용히 콜백이 종료된다.
asyncio와의 관계:
rclpy의 Future는 asyncio.Future가 아니다. 이름과 개념이 비슷하지만 다른 객체이며, await로 직접 기다릴 수 없다.
| 접근 | 상태 |
|---|---|
rclpy Future를 await | ❌ 직접 불가 |
| asyncio 이벤트 루프와 rclpy executor 통합 | 가능하나 수동 작업 필요(확인 필요: 권장 방법) |
| 별도 스레드에서 각각 실행 + 큐로 연결 | ✅ 가장 단순하고 견고 |
# asyncio와 섞어야 한다면 — 스레드 분리가 실용적
import threading
def ros_spin():
executor.spin()
threading.Thread(target=ros_spin, daemon=True).start()
# 메인 스레드에서 asyncio.run(...) — 둘 사이는 큐로 통신
정직: rclpy와 asyncio의 통합은 깔끔한 공식 해법이 확립되어 있다고 보기 어렵다
(확인 필요). 웹 서버·비동기 라이브러리와 ROS 2를 함께 써야 한다면 스레드 분리 + 큐가 가장 예측 가능한 구조다.
2.8.6 성능 — 어디까지 Python으로 해도 되나
1.3.7에서 본 비용 구조를 판단 기준으로 바꾼다.
| 워크로드 | Python 적합성 |
|---|---|
| 저빈도 제어 로직(1~20 Hz), 작은 메시지 | ✅ 문제없음 |
| 상태 기계·조정(orchestration) | ✅ 오히려 적합 — 로직이 복잡하고 빈도가 낮다 |
| 파라미터 관리·설정 노드 | ✅ |
| 진단·모니터링·기록 | ✅ |
| 이미지 파이프라인(30 Hz, 6 MB) | ⚠️ numpy·OpenCV 필수, 그래도 한계 |
| 포인트클라우드 점별 처리 | ❌ Python 루프는 불가능한 수준 |
| 고빈도 제어(100 Hz 이상) | ❌ 지터가 크다 |
| 실시간 제어 루프 | ❌ GC와 GIL 때문에 부적합 |
GC(가비지 컬렉션)가 실시간에 치명적이다. Python의 순환 참조 수집기가 임의의 시점에 개입해 수십~수백 ms의 정지를 만들 수 있다. 제어 루프에서 이것은 허용 불가다.
# 완화책 — 완전한 해결은 아니다
import gc
gc.disable() # 자동 수집 중지 (메모리 증가 위험)
gc.freeze() # 초기화 후 객체를 영구 세대로
# 안전한 시점에 수동 수집
gc.collect()
견해: "제어 평면은 Python, 데이터 평면은 C++" 가 실무에서 가장 잘 작동하는 분리다. 상태 기계·시퀀싱·설정·진단은 Python이 개발 속도에서 압도적으로 유리하고 빈도가 낮다. 센서 처리·제어 루프·대용량 파이프라인은 C++로 간다. 이 경계를 노드 단위로 명확히 긋는 것이 좋은 설계다(14.1).
2.8.7 numpy 연동 — 대용량 데이터의 현실적 처리
Python 루프로 배열을 순회하는 순간 끝이다. 반드시 numpy로 내려야 한다.
import numpy as np
from sensor_msgs.msg import Image
def on_image(self, msg: Image):
# ❌ 절대 금지 — 백만 번의 Python 루프
# for i in range(len(msg.data)): ...
# ✅ numpy 뷰로 변환 (복사 최소화)
arr = np.frombuffer(msg.data, dtype=np.uint8)
img = arr.reshape((msg.height, msg.width, 3))
# 이후 연산은 C 레벨 — GIL도 해제된다
mean = img.mean(axis=(0, 1))
np.frombuffer는 복사하지 않고 뷰를 만든다 — 이것이 핵심이다. np.array(msg.data)는 복사한다.
함정: 뷰는 원본 버퍼의 수명에 의존한다. 콜백이 끝나 메시지가 해제되면 뷰가 무효가 된다. 콜백 밖으로 넘길 때는
.copy()가 필요하다.
cv_bridge:
from cv_bridge import CvBridge
bridge = CvBridge()
cv_img = bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
cv_bridge는 편리하지만 인코딩 변환이 개입하면 복사가 일어난다. desired_encoding='passthrough'로 변환을 피할 수 있는 경우가 있다(7.2).
포인트클라우드:
from sensor_msgs_py import point_cloud2
# 구조화 배열로 읽기 (실행 확인 — Jazzy에 그대로 있다)
points = point_cloud2.read_points_numpy(msg, field_names=('x', 'y', 'z'))
Jazzy에서 확인함(실행 확인).
read_points_numpy(cloud, field_names=None, skip_nans=False, uvs=None, …)가 실재한다 — 위 예시가 그대로 동작한다. 함께 있는 함수는 2.8.11.1. 배포판이 다르면 여전히 확인이 필요하지만 일반 원칙은 확실하다 — 점별 Python 반복은 피하고 배열 단위로 처리한다.
2.8.8 Python 특유의 함정
① 참조를 유지하지 않으면 사라진다
# ❌ 지역 변수 — 함수가 끝나면 GC 대상
def setup(self):
sub = self.create_subscription(String, 'topic', self.cb, 10)
# sub이 소멸되어 구독이 끊길 수 있다
# ✅ 멤버로 보관
def setup(self):
self.sub = self.create_subscription(String, 'topic', self.cb, 10)
같은 문제가 타이머·서비스·파라미터 콜백 핸들에도 적용된다. rclcpp에서도 유사한 문제가 있지만(1.9.6), Python에서는 GC 타이밍이 예측 불가라 증상이 더 산발적이다.
② 종료 처리
def main():
rclpy.init()
node = MyNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node() # 명시적 정리
rclpy.shutdown()
try/finally로 정리를 보장하지 않으면 노드가 그래프에 남거나 자원이 정리되지 않을 수 있다. KeyboardInterrupt를 포착하는 것도 중요하다 — 포착하지 않으면 스택 트레이스가 출력되어 로그가 지저분해진다.
③ 콜백 안의 예외
def on_message(self, msg):
try:
self.process(msg)
except Exception as e:
self.get_logger().error(f'처리 실패: {e}', throttle_duration_sec=1.0)
# 예외를 묵살하되 로그는 남긴다
콜백에서 예외가 나가면 spin() 밖으로 그대로 전파되고 노드가 종료된다(Jazzy 실행 확인, 2.8.11.2) — "예측하기 어렵다"가 아니라 확실히 멈춘다. 단일·다중 스레드 executor 모두 같았다. 모든 콜백을 방어적으로 감싸는 것이 안전하다.
④ 로깅 매크로 대신 메서드
self.get_logger().info('메시지')
self.get_logger().warn('경고', throttle_duration_sec=1.0) # THROTTLE 상당
self.get_logger().debug('상세', once=True) # ONCE 상당
C++의 매크로와 달리 인자가 항상 평가된다 — f-string 안의 비용이 큰 호출은 레벨이 꺼져 있어도 실행된다(1.11.5의 문제가 Python에서는 더 확실하게 발생).
# ❌ expensive_dump()가 항상 실행된다
self.get_logger().debug(f'상태: {expensive_dump()}')
# ✅ 조건 확인
if self.get_logger().get_effective_level() <= LoggingSeverity.DEBUG:
self.get_logger().debug(f'상태: {expensive_dump()}')
위 예시는 Jazzy에서 그대로 동작한다(실행 확인). 더 짧은 is_enabled_for()도 있다 — 2.8.11.3.
⑤ 패키지 구조
ament_python 패키지는 setup.py의 entry_points로 실행 파일을 등록한다(4.3). setup.py를 고치고 재빌드하지 않으면 반영되지 않는다 — Python이라 "저장하면 바로 반영"을 기대하기 쉬운데, 설치 경로로 복사되는 구조라 그렇지 않다.
# 개발 중에는 심볼릭 링크 설치가 편리하다
colcon build --symlink-install
--symlink-install을 쓰면 Python 소스 수정이 재빌드 없이 반영된다(단, setup.py·엔트리포인트 변경은 재빌드 필요). rclpy 개발의 필수 옵션에 가깝다(4.5).
2.8.11 rclpy의 미확인 항목 셋을 닫았다 — 실측
이 장이 (확인 필요)로 남겼던 것 가운데 컨테이너에서 바로 확인되는 셋을 확인했다(Jazzy).
2.8.11.1 sensor_msgs_py.point_cloud2 — Jazzy의 실제 API
read_points_numpy(cloud, field_names=None, skip_nans=False, uvs=None, …)
read_points(cloud, field_names=None, skip_nans=False, uvs=None, …)
read_points_list(...) create_cloud(header, fields, points, point_step=None)
create_cloud_xyz32(...) dtype_from_fields(...)
structured_to_unstructured(...) unstructured_to_structured(...)
본문 예시의 read_points_numpy가 그대로 있다. 곁들여 알아 둘 것 둘:
read_points와read_points_numpy는 다르다. 전자는 구조화 배열(필드 이름으로 접근), 후자는 일반 2차원 배열을 준다.x·y·z만 추출해 행렬 연산할 것이라면 후자가 곧바로 쓰기 좋다.skip_nans=False가 기본이다. 실제 센서 데이터에는NaN이 흔하므로(7.4), 평균·최소 같은 연산을 적용하기 전에 명시적으로 켜거나 직접 걸러야 한다. 켜지 않은 채min()을 부르면 결과가NaN으로 오염된다.
2.8.11.2 콜백 예외 — "예측 불가"가 아니라 "노드가 종료된다"
타이머가 200 ms마다 발행하고 구독 콜백이 매번 예외를 던지게 해 두고 실행했다.
| executor | spin() 결과 | 타이머 실행 | 콜백 실행 |
|---|---|---|---|
SingleThreadedExecutor | 예외를 밖으로 던짐 | 1회 | 1회 |
MultiThreadedExecutor(4스레드) | 예외를 밖으로 던짐 | 1회 | 1회 |
둘 다 첫 예외에서 멈췄다. rclpy가 예외를 묵살하지도, 스택트레이스를 출력하고 넘어가지도 않는다 — 호출자에게 그대로 전파한다. main()이 포착하지 않으면 프로세스가 종료된다.
이 결과는 본문의 조언을 더 강하게 만든다. "조용히 멈출 수도 있다" 정도가 아니라, 콜백 하나의 예외가 노드 전체를 세운다. 센서 콜백에서
IndexError하나가 나면 로봇의 그 기능 전체가 중단된다 — 그래서 방어적으로 감싸는 것이 선택이 아니다.
함정: 다중 스레드라고 다른 콜백이 계속 동작하지 않는다.
MultiThreadedExecutor를 쓰면 한 콜백이 종료돼도 나머지가 실행될 것 같지만, 실측은 그렇지 않았다 — executor 자체가 내려간다.
2.8.11.3 로그 레벨 조회 — 본문 예시가 맞고, 더 짧은 길이 있다
>>> logger.get_effective_level()
<LoggingSeverity.INFO: 20>
>>> logger.is_enabled_for(LoggingSeverity.DEBUG)
False
>>> logger.set_level(LoggingSeverity.DEBUG)
>>> logger.is_enabled_for(LoggingSeverity.DEBUG)
True
get_effective_level() ≤ 비교가 그대로 동작한다(LoggingSeverity가 정수 열거형이므로). 다만 의도를 그대로 적는 is_enabled_for()가 더 낫다.
# ✅ 가장 읽기 쉬운 형태
if self.get_logger().is_enabled_for(LoggingSeverity.DEBUG):
self.get_logger().debug(f'상태: {expensive_dump()}')
RcutilsLogger가 제공하는 것: debug·info·warn·warning·error·fatal·log·set_level·get_effective_level·is_enabled_for·get_child·name.
warn과warning이 둘 다 있다 — 어느 쪽을 써도 되지만 한 코드베이스 안에서는 하나로 통일하는 편이 검색에 낫다.
2.8.12 C++과 무엇이 다른가 — 실측
이 책은 여러 장에서 같은 개념이 두 언어에서 다르게 동작하는 것을 하나씩 확인해 왔다. 한자리에 모은다 — 두 언어를 오가는 독자에게 필요한 것은 개별 사실이 아니라 차이의 목록이다. 모두 실행 확인이며 출처를 함께 적는다. 측정 환경은 공통이다 — Jazzy · ros:jazzy-ros-base 컨테이너 · rmw_fastrtps_cpp · rclcpp 28.1.21 / CPython 3.12 · 20코어 호스트.
2.8.12.1 로깅 비용 — Python이 한 자릿수 이상 비용이 크다
각 2,000회, 마이크로초.
| 호출 | C++(1.11.12.1) | Python | 배수 |
|---|---|---|---|
| 기준선(빈 호출) | 0.029 | 0.048 | 1.7× |
| 억제되는 로그 | 0.053 | 0.713 | 13× |
| 출력되는 로그 | 7.508 | 127.554 | 17× |
| 출력 로그의 최대 | 187.7 | 7,964(약 8 ms) | 42× |
억제된 로그조차 Python은 0.7 µs다 — C++의 13배지만 절대값으로는 여전히 비용이 낮다. 문제는 출력 쪽이다.
Python 노드의 제어 루프에서 로그 한 줄은 128 µs다. 100 Hz(주기 10,000 µs)라면 1.3%지만 최대 8 ms가 관측됐다 — 주기 전체를 넘긴다. Python으로 주기 루프를 실행한다면 출력 로그를 루프 밖으로 빼야 한다.
2.8.12.2 throttle이 C++처럼 비용이 낮지 않다
C++ RCLCPP_INFO_THROTTLE | Python throttle_duration_sec | |
|---|---|---|
| 억제될 때 비용 | 0.128 µs | 127.620 µs |
| 출력 로그 대비 | 1/59 | 거의 같다 |
C++의 THROTTLE은 매크로가 먼저 시각을 확인하고 빠져나가므로 거의 무비용이다. Python의 throttle_duration_sec은 함수 인자이므로, 문자열은 이미 만들어졌고 함수 호출 비용도 이미 치른 뒤에 억제 여부가 결정된다.
그래서 Python에서는
throttle이 성능 대책이 아니다. "주기 루프에throttle을 적용했으니 괜찮다"는 C++에서만 성립한다. Python에서 같은 효과를 내려면 호출 자체를 조건으로 감싸야 한다.# ❌ 억제돼도 128 us 를 낸다 self.get_logger().info(f'상태 {x}', throttle_duration_sec=1.0) # ✅ 호출 자체를 막는다 now = self.get_clock().now() if (now - self._last_log).nanoseconds > 1e9: self._last_log = now self.get_logger().info(f'상태 {x}')
2.8.12.3 인자 평가 — 측정으로 확인
2.8.8의 경고("C++의 매크로와 달리 인자가 항상 평가된다")를 카운터로 셌다.
| 코드 | expensive() 실제 호출 |
|---|---|
logger.debug("상태: %s" % expensive()) — 억제되는 레벨 | 200 / 200회 |
if logger.is_enabled_for(DEBUG): logger.debug(…) | 0 / 200회 |
억제되는 로그의 인자가 전부 평가된다. C++ 매크로는 같은 상황에서 인자를 전혀 변경하지 않는다(1.11.12.1의 할당 0이 그 증거다).
2.8.12.4 레벨 조회 API — 이름이 다르다
C++ (rclcpp::Logger) | Python (RcutilsLogger) | |
|---|---|---|
get_effective_level() | 있다 | 있다 |
is_enabled_for() | 없다 | 있다 |
| 대안 | rcutils_logging_logger_is_enabled_for(name, sev) | — |
출처: 1.11.12.2(C++) · 2.8.11.3(Python).
Python 쪽이 더 편리하다 — 그리고 2.8.12.2·2.8.12.3 때문에 더 필요하다. C++에서는 매크로가 자동으로 막아 주므로
is_enabled_for가 없어도 실무에 지장이 적지만, Python에서는 이것으로 감싸지 않으면 인자와 호출 비용을 그대로 낸다.
2.8.12.5 콜백에서 예외가 나갈 때
| C++ | Python | |
|---|---|---|
| 결과 | 프로세스 종료(SIGABRT, 종료 코드 134) | spin() 밖으로 전파 — 포착하지 않으면 종료 |
| executor 종류의 영향 | — | 단일·다중 모두 동일(전부 멈춘다) |
Restart=on-failure | 잡는다(134) | 미측정(확인 필요) |
출처: 14.8.13.1(C++) · 2.8.11.2(Python).
두 언어 모두 "콜백 예외는 노드를 세운다"는 결론은 같다. 다만 C++은
SIGABRT로 종료돼 systemd가 재시작 대상으로 보고, Python은 예외가main까지 전파된다 —main이 포착해서 정상 종료(코드 0)해 버리면 systemd가 아무 일도 하지 않는다(14.8.13.2). Python 노드의 최상위에서 예외를 묵살하지 말라.
2.8.12.7 executor와 GIL — 통념은 절반만 맞다
"Python은 GIL 때문에 MultiThreadedExecutor가 의미 없다"는 말이 널리 퍼져 있다. 작업의 종류로 갈린다.
3초 동안, 주기 5 ms 타이머 하나에 콜백이 20 ms 걸리도록 두고 측정했다.
| 콜백이 하는 일 | executor | 그룹 | 호출 수 | 최대 동시 |
|---|---|---|---|---|
| CPU 바운드(순수 계산) | Single | ME | 483 | 1 |
| Multi(4) | ME | 433 | 1 | |
| Multi(4) | Reentrant | 408 | 2 | |
대기(time.sleep) | Single | ME | 146 | 1 |
| Multi(4) | ME | 142 | 1 | |
| Multi(4) | Reentrant | 590 | 4 |
① CPU 바운드에서는 통념이 맞다 — 오히려 손해다. Multi+Reentrant가 408회로 가장 낮다(단일 483회 대비 16% 감소). 최대 동시 2가 관측되지만 GIL 때문에 실질 병렬이 아니고 스레드 전환 비용만 더해진다.
② 대기 작업에서는 통념이 틀린다 — 4배다. 146 → 590회, 최대 동시 4. time.sleep·I/O·서비스 응답 대기처럼 GIL을 놓는 구간이 있으면 MultiThreadedExecutor가 확실히 이득이다.
C++과 배수가 거의 같다. 2.4.12.2에서 C++의
Reentrant가 처리량을 4배로 만들었는데(63 → 249회), Python의 대기 작업도 4.04배(146 → 590)다. GIL을 놓기만 하면 두 언어가 같은 이득을 본다.
③ 그룹을 안 바꾸면 아무 일도 안 일어난다. Multi + MutuallyExclusive는 대기 작업에서도 142회로 단일과 같다. 2.4.12.3의 규칙이 Python에도 그대로다 — 동시성을 결정하는 것은 executor가 아니라 콜백 그룹이다.
그래서 판단 기준은 이렇게 정리된다.
콜백이 MultiThreadedExecutor순수 계산만 한다 쓰지 말라 — 손해다 sleep·I/O·응답 대기가 있다쓰라(단 Reentrant또는 별도 그룹과 함께)"Python이니까 소용없다"도, "스레드를 늘리면 빨라진다"도 틀렸다. 콜백이 GIL을 놓는가가 기준이다.
2.8.12.8 타이머 정밀도 — 드리프트는 같고 꼬리는 넓다
목표 10 ms · 800회(각 2회).
| C++(2.5.13.1) | Python | |
|---|---|---|
| 누적 드리프트 | 0.000% | +0.001 ~ +0.005% |
| Single의 p99 − 목표 | +0.047 ms | +0.23 ~ +0.28 ms |
| Single의 최대 − 목표 | +0.528 ms | +0.40 ~ +0.42 ms |
| Multi의 최대 − 목표 | +1.161 ms | +0.93 ~ +3.42 ms |
① 드리프트는 Python도 사실상 0이다 — 절대 기준 정책(2.5.13.2)이 rclpy에도 적용된다. "Python이라 시간이 지연된다"는 드리프트 축에서는 사실이 아니다.
② 꼬리는 Python이 넓다 — p99가 C++의 약 5배다. 절대값으로는 여전히 0.3 ms 수준이라 100 Hz 제어에는 무리가 없지만, 1 kHz라면 예산의 30%다.
③ Multi가 Single보다 꼬리가 넓은 것도 같다 — 최대 13.4 ms까지 급증했다. 타이머 하나에 MultiThreadedExecutor를 쓰지 말라는 2.5.13.3의 지침이 Python에서 더 강하게 적용된다.
2.8.12.9 비용은 발행이 아니라 메시지 생성과 spin에 있다
2.8.6은 "어디까지 Python으로 해도 되나"를 다루면서 수치가 없었다. 같은 프로그램을 두 언어로 만들어 나란히 측정했다(각 2,000회, 마이크로초).
| 호출 | C++ | Python | 배수 |
|---|---|---|---|
| 빈 루프(기준선) | 0.055 | 0.261 | 4.7× |
spin_some / spin_once | 59.865 | 328.457 | 5.5× |
메시지 객체 생성(Imu()) | 0.075 | 17.745 | 237× |
publish(재사용 메시지) | 3.890 | 14.751 | 3.8× |
publish(매번 새 메시지) | 4.744 | 33.780 | 7.1× |
① 실제 비용은 메시지 생성이다 — 237배. C++에서 Imu()는 스택 객체라 0.075 µs로 사실상 무비용인데, Python에서는 17.7 µs다. "Python이 느리다"의 대부분이 여기서 나온다.
② publish 자체의 차이는 3.8배로 작다. 직렬화는 양쪽 모두 C 확장이 하기 때문이다(2.8.6의 "부하가 큰 일은 C로 내려간다"가 이 숫자로 확인된다).
그래서 최적화 지점이 분명하다 — Python 노드에서 줄여야 할 것은 발행 횟수가 아니라 메시지 객체 생성이다. 재사용하면 33.78 → 14.75 µs로 2.3배 빨라진다(C++은 4.74 → 3.89로 1.2배뿐이다). 메시지 재사용은 두 언어 모두에 좋지만 Python에서 훨씬 중요하다.
12.3.12.3에서 C++의 재사용은 무할당의 조건이었는데, Python에서는 속도의 조건이다. 같은 지침이 다른 이유로 성립한다.
③ spin은 둘 다 비용이 크다. C++도 59.9 µs다 — 1 kHz 루프라면 그것만으로 예산의 6%이고, Python은 328 µs로 33%다.
Python으로 1 kHz 제어 루프는 성립하지 않는다 —
spin_once한 번이 주기의 3분의 1이다. 100 Hz(3.3%)까지가 현실적인 선이며, 그 위는 C++로 내리거나 제어 루프를 spin 밖으로 빼야 한다. 2.8.6의 판단에 이 숫자를 근거로 쓰라.
2.8.12.6 요약 — 두 언어를 오갈 때
| 축 | 실무 지침 |
|---|---|
| 로깅 | Python은 출력 로그를 주기 루프에서 빼라(128 µs·최대 8 ms) |
throttle | Python에서는 성능 대책이 아니다 — 호출을 조건으로 감싸라 |
| 인자 | Python은 억제돼도 평가된다 — is_enabled_for로 감싸라 |
| 예외 | 둘 다 노드를 세운다. Python은 최상위에서 묵살하면 재시작도 안 된다 |
| executor | 콜백이 GIL을 놓는가로 갈린다 — 계산만 하면 Multi가 손해, 대기가 있으면 4배(2.8.12.7) |
| 타이머 | 드리프트는 C++과 같고(≈0) 꼬리는 약 5배 넓다(2.8.12.8) |
| 메시지 생성 | 237배 비용이 크다 — Python의 실제 비용. 재사용이 2.3배 이득(2.8.12.9) |
spin | C++ 60 µs · Python 328 µs — 1 kHz는 성립하지 않는다(2.8.12.9) |
| QoS 값 | 차이 없다 — 같은 rmw 상수를 읽는다(2.8.12.10) |
| QoS 이름 | ClockQoS·RosoutQoS가 Python에 없다 · 프리셋 .name이 별칭으로 접힌다(2.8.12.10) |
| 액션 피드백 | rclpy가 이 손실 계열 전체에 면역(240/240) — 프로토콜 결함이 아니라 rclcpp_action의 구현 선택 차이(2.8.12.11) |
2.8.12.10 QoS — 값은 같고 이름이 다르다
이 목록이 마지막으로 남겨 뒀던 축이다. 두 언어의 표준 프로파일을 나란히 출력해 비교했다(실행 확인, Jazzy).
| 프로파일 | C++ | Python | |
|---|---|---|---|
| 기본 | QoS(10) | QoSProfile(depth=10) | 같다 |
| 센서 | SensorDataQoS | qos_profile_sensor_data | 같다(BEST_EFFORT·5) |
| 서비스 | ServicesQoS | qos_profile_services_default | 같다(RELIABLE·10) |
| 파라미터 | ParametersQoS | qos_profile_parameters | 같다(1000) |
| 파라미터 이벤트 | ParameterEventsQoS | qos_profile_parameter_events | 같다(1000) |
| 시스템 기본 | SystemDefaultsQoS | qos_profile_system_default | 히스토리 표기만 다름(아래) |
| 시계 | ClockQoS | 없음 | 비대칭 |
/rosout | RosoutQoS | 없음 | 비대칭 |
| 액션 상태 | rclcpp_action::DefaultActionStatusQoS | qos_profile_action_status_default | 위치가 다름 |
| best available | BestAvailableQoS | qos_profile_best_available | 같다 |
수치는 다르지 않다. QoS는 rmw 아래에서 정의되고 두 언어가 같은 rmw_qos_profile_* 상수를 읽어 오기 때문이다 — 이 축이 얇을 것이라던 예상이 맞았다.
차이는 세 군데다.
① ClockQoS·RosoutQoS가 Python에 없다. rclpy.qos의 qos_profile_* 상수는 아홉 개인데 시계·/rosout용이 빠져 있다. rclpy 내부에서는 time_source.py가 값을 코드에 직접 하드코딩해 쓴다.
# rclpy/time_source.py — 이름이 없으니 값을 적어 넣는다
self._clock_sub = node.create_subscription(
Clock, CLOCK_TOPIC, self.clock_callback,
QoSProfile(depth=1, reliability=ReliabilityPolicy.BEST_EFFORT))
Python으로
/clock을 직접 발행·구독한다면 이 값을 수동으로 맞춰야 한다 —depth=1+BEST_EFFORT. 틀리면 조용히 매칭되지 않는다(1.6.11.2의 함정과 같은 자리다).
② system_default의 히스토리 표기가 다르다 — 그러나 동작은 같다.
| 신뢰성 | 내구성 | 히스토리 | depth | |
|---|---|---|---|---|
C++ SystemDefaultsQoS | SYSTEM_DEFAULT | SYSTEM_DEFAULT | KEEP_LAST | 0 |
Python qos_profile_system_default | SYSTEM_DEFAULT | SYSTEM_DEFAULT | SYSTEM_DEFAULT | 0 |
rclcpp는 QoSInitialization::from_rmw()를 거치며 히스토리를 KeepLast로 바꾸고, rclpy는 rmw 상수를 그대로 통과시킨다. 엔드포인트에 출력되는 값도, 늦은 구독자의 동작도 두 언어가 같았다(1.6.12.1) — 객체 표기만의 차이다.
③ 액션 상태 프로파일의 위치가 다르다. C++은 rclcpp_action 패키지에 있고 Python은 rclpy.qos에 평평하게 있다. 이식할 때 rclcpp::에서 찾다가 없다고 결론짓기 쉽다.
함정 —
QoSPresetProfiles를 순회하면 두 개가 사라진다. Python의 프리셋 열거는Enum이라 값이 같은 항목이 별칭으로 접힌다.len(list(QoSPresetProfiles)) # 7 len(QoSPresetProfiles.__members__) # 9 QoSPresetProfiles.SERVICES_DEFAULT.name # 'DEFAULT' ← 자기 이름이 아니다 QoSPresetProfiles.PARAMETER_EVENTS.name # 'PARAMETERS' ← 자기 이름이 아니다
qos_profile_services_default가qos_profile_default와 값이 같고(RELIABLE·VOLATILE·10),parameter_events가parameters와 같기(1000) 때문이다. 접근은 정상 동작하지만.name이 다른 이름을 돌려준다 — 진단 로그에 프로파일 이름을 출력하는 코드는 틀린 이름을 남긴다. 순회로 목록을 만들 때는__members__를 쓰라.
2.8.12.11 액션 피드백 유실 — rclpy가 이 손실 계열 전체에 면역이다
1.8.13이 다루는 문제 — 액션 목표 수락 직후 발행된 첫 피드백이 조용히 유실되는 현상(96차가 ClientBase::is_ready()의 하드코딩된 우선순위로 원인을 확정)을 두 언어에서 나란히 측정했다(99차, 총 240회 — C++이 거의 결정적으로 유실하던 최악 조건 cold, offset 100 ms까지 포함).
C++(rclcpp_action) | Python(rclpy) | |
|---|---|---|
| 이 손실 계열 전체 | executor·조건에 따라 0~100% 유실 | 240/240 무손실(0%) |
| 등록 시점 | ClientGoalHandle이 응답 콜백 안에서야 만들어져 등록됨 — 그 전에 도착한 피드백은 갈 곳이 없다 | send_goal_async()가 목표를 서버에 보내기도 전에 클라이언트가 스스로 생성한 UUID로 feedback_callback을 미리 등록 |
이 손실은 ROS 2 액션 프로토콜 자체의 결함이 아니라 rclcpp_action의 구현 선택이라는 것을 언어 간 비교가 소스 수준에서 보여 준다 — rclpy로 옮길 수 있는 팀이라면 언어 선택 자체가 이 특정 위험을 없애는 실질적 대안이다(다만 이 절의 다른 아홉 축이 적어 둔 비용 — 로깅·GIL·메시지 생성 비용 등 — 과 맞바꾸는 트레이드오프임을 잊지 말아야 한다). 실험 조건·EventsExecutor와의 관계 등 자세한 내용은 1.8.13.
이로써 이 절이 선언했던 축이 모두 닫혔다.
2.8.12.12 커스텀 Waitable — executor 안에서의 위치와 EventsExecutor 호환성이 다르다
2.10.11.3이 다루는 커스텀 Waitable(guard condition을 감싼 다섯 번째 엔티티 종류)을 rclpy로도 만들어(rclpy.waitable.Waitable을 상속, add_to_wait_set에서 wait_set.add_guard_condition()) 105차 갭 점검이 같은 실험을 재현했다.
SingleThreadedExecutor 안에서의 위치가 언어마다 반대 끝이다. rclpy 소스(executors.py의 _wait_for_ready_callbacks)를 읽으면 커스텀 Waitable은 "wait set이 파괴되기 전"에 먼저 확인·yield되는 별도 루프에 있고, 그 다음에야 노드별로 TIMER→SUB→(raw)GUARD→CLIENT→SERVICE 순서를 순회하는 두 번째 루프가 실행된다 — 즉 Waitable이 구조적으로 가장 먼저 처리된다. TIMER·SUB·SRV·CLIENT·커스텀 Waitable 다섯을 동시에 ready로 만들고 25회 반복하니 25/25 WAITABLE → TIMER → SUB → SRV → CLIENT로 완전히 일관됐다 — rclcpp의 SingleThreadedExecutor(2.3.14, TIMER → SUB → SRV → CLIENT → WAITABLE)와 정확히 반대 끝이다. 나머지 넷의 상대 순서는 두 언어가 같다(다만 이 실험은 클라이언트·서비스가 같은 노드 자기 자신을 호출하는 구조라 CLIENT의 준비 자체가 SRV 실행 뒤에야 일어나므로, SRV가 CLIENT보다 먼저인 것을 "우선순위"로 과잉 해석하지 않는다 — 2.3.14의 C++ 실험도 같은 이유로 SRV가 CLIENT보다 앞이었을 가능성이 있다).
EventsExecutor도 이 방식의 waitable을 거부하지만, 언제·어떻게 실패하는지가 다르다. C++에서는 add_node() 시점에 std::runtime_error로 즉시 실패한다(2.10.11.3). rclpy에서는 add_node()는 조용히 성공하고, 처음 spin_once()를 부르는 순간에야 RuntimeError: Guard conditions not supported가 난다 — 등록과 실패 사이에 지연이 있어, 노드를 만들고 executor에 등록하는 코드만 보고는 문제를 알아챌 수 없다. 예외 자체는 보통의 파이썬 예외이고 except RuntimeError로 정상적으로 포착된다.
함정: 포착해도 프로세스가 종료될 수 있다. 표준 출력이 버퍼링되는 일반적인 실행 방식(
python3 script.py)에서, 이 예외를 포착한 뒤 노드·executor를 정리하고 인터프리터를 종료하면Fatal Python error: Aborted로 프로세스 전체가 강제 종료(코어 덤프)될 수 있다. 105차는 이것을 "버퍼링 여부가 가르는 타이밍 경쟁"으로 추정했지만, 106차가 디버그 심볼을 포함해 재빌드하고 정확한 원인을 확정했다 — 버퍼링과는 무관하다. 자세한 메커니즘은 2.8.12.13. 실무 결론은 바뀌지 않는다 —rclpy에서 이런 커스텀(폴링형) Waitable을 쓰는 노드는 애초에EventsExecutor에 등록하지 않는다.
EventsExecutor의 4-엔티티 순서도 C++과 다르다. Waitable을 뺀 TIMER·SUB·SRV·CLIENT 넷만으로 같은 실험을 25회 반복하면 25/25 SUB → TIMER → SRV → CLIENT였다 — rclcpp의 EventsExecutor(2.3.14, SUB → CLIENT → SRV → TIMER, 타이머가 맨 뒤)와 달리 rclpy에서는 타이머가 두 번째다(SRV/CLIENT 인과 관계 주의사항은 여기도 그대로 적용된다). rclpy의 EventsExecutor가 rclpy.executors.Executor를 상속하지 않는 별도의 pybind11 바인딩(_rclpy.EventsExecutor, 99차)이라는 점을 감안하면, 이 순서 차이는 "같은 C++ EventsExecutor 코드를 언어만 바꿔 부른 것"이 아니라 바인딩 계층에서 실제로 다른 스케줄링 경로를 타는 결과로 보인다 — 다만 이것도 pybind11 바인딩 소스까지 추적하지는 않았다(확인 필요: 바인딩 소스 수준 원인).
재현 하네스는 tools/gap_harness/gap105/(order_test.py·waitable_events_crash.py)에 남겼다.
2.8.12.13 EventsExecutor 크래시의 정확한 원인 — 디버그 빌드로 소스까지 추적
106차 갭 점검이 배포된(스트립된) _rclpy_pybind11...so로는 gdb가 함수 이름을 못 추출하는 것을 확인하고, apt-get source ros-jazzy-rclpy로 설치된 것과 정확히 같은 버전(7.1.11)의 소스를 받아 colcon build --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo로 디버그 심볼을 포함해 재빌드했다(시스템에 이미 설치된 rcl·rmw 등은 그대로 재사용, 약 2분). 이 오버레이 위에서 gdb -batch -ex run -ex 'bt'로 크래시 스레드의 완전한 콜스택을 얻었다.
결론: 이것은 타이밍 경쟁이 아니라 rclpy C++ 구현의 논리 결함이다.
- 아무 것도 만들지 않은
Node도 기본으로 Waitable을 2개 갖고 있다.probe_waitables.py(tools/gap_harness/gap106/)로 확인 — 기본 파라미터 서비스 7종에 딸린rclpy.event_handler.EventHandler(QoS 이벤트 핸들러)다. 앞 절의 커스텀 guard-condition Waitable을 하나 더하면 노드는 총 Waitable 3개를 갖는다. EventsExecutor::UpdateEntitiesFromNodes()가 이 3개를 Pythonset(node.attr("waitables"))으로 모아UpdateEntitySet()에 넘기고,for (py::handle e : added_entities) { added_entity_callback(e); }로 하나씩HandleAddedWaitable()을 호출한다.py::set의 순회 순서는 원소의id()기반 해시에 의존해 실행마다 달라진다(힙 배치·주소 할당 패턴에 따라).HandleAddedWaitable()은num_guard_conditions != 0이면 함수 맨 앞에서 즉시 던진다 — 우리 Waitable이 걸리는 지점이다. 그런데 먼저 순회된 것이 guard condition이 없는EventHandler라면, 그것은 정상 처리돼rcl_callback_manager_.MakeCallback()으로owned_cbs_에 등록된 뒤에 우리 차례가 되어 예외가 던져진다.- 이 예외는
UpdateEntitySet()의 루프 밖으로 전파되면서, 함수 마지막 줄인entity_set = new_entity_set;(등록이 끝난 엔티티들을 추적 목록에 기록하는 줄)을 건너뛴다 — 방금 성공적으로 등록한EventHandler가 추적 목록(waitables_멤버)에는 영영 기록되지 않는다. ex.shutdown()은waitables_(잘못된 상태 — 비어 있음)와 새로 계산한waitables(역시 비어 있음, 노드가 이미 제거됐으므로)를 비교해 "제거할 것이 없다"고 판단한다 — 아무도 그EventHandler의 콜백을 해제하지 않는다.owned_cbs_에 고아 항목이 영구히 남는다.EventsExecutor가 실제로 파괴될 때, 멤버인RclCallbackManager의 소멸자(rclpy소스rcl_support.cpp:35-43)가 실행된다:
RclCallbackManager::~RclCallbackManager()
{
// Should not still have any callbacks registered when we exit, because otherwise RCL can call
// pointers that will no longer be valid. We can't throw an exception here, but we can explode.
if (!owned_cbs_.empty()) {
py::gil_scoped_acquire gil_acquire;
py::print("Destroying callback manager with callbacks remaining");
::abort();
}
}
::abort()는 버그가 아니라 의도된 안전장치다 — 소멸자에서 C++ 예외를 던질 수 없고, 고아 콜백을 방치하면 나중에 RCL·미들웨어가 이미 해제된 포인터를 호출할 수 있으므로 rclpy 개발자는 "조용히 넘어가느니 시끄럽게 종료되는" 쪽을 택했다. 우리가 포착하던 크래시는 이 안전장치가 설계대로 작동한 결과이고, 실제 결함은 그 앞 — UpdateEntitySet()이 부분 실패를 롤백하지 않는 것이다.
이 사슬은 rcl_support.cpp·events_executor.cpp에 stderr 추적(호출마다 키 포인터·스레드 ID를 출력함)을 추가한 계측 빌드로 직접 관찰해 검증했다(tools/gap_harness/gap106/rcl_support_instrumented.cpp·events_executor_instrumented.cpp) — 로그가 정확히 services=7 waitables=3으로 시작해 서비스 7개는 전부 등록 성공, waitables 루프에서 HandleAddedWaitable 진입이 1~3회(실행마다 다름) 나타난 뒤 우리 차례에서 예외가 던져지고, shutdown()에서 서비스 7개는 정상적으로 해제되지만 먼저 처리된 EventHandler의 해제 로그는 한 번도 출력되지 않았다.
python3 -u는 원인이 아니었다. 105차의 "버퍼링 5/5 크래시, -u 5/5 무사"를 표본을 늘려(각 12회) 재확인하니 버퍼링 11/12, -u 9/12로 둘 다 높은 비율로 크래시했다 — -u가 크래시를 막는다는 105차의 관찰은 작은 표본이 우연히 갈린 것으로 보인다. 실제 결정 요인은 표준출력 버퍼링이 아니라 py::set 순회 순서(3개 Waitable 중 우리 것이 몇 번째로 나오는가)다.
정정. 2.8.12.12의 "네이티브 스레드 정리 과정의 타이밍에 민감한 경쟁 조건"이라는 105차의 추정은 틀렸다 — 실제로는 Python
set의 비결정적 순회 순서와EventsExecutor::UpdateEntitySet()의 부분 실패 롤백 부재가 만나는 결정론적(다만 실행마다 다른 순서를 타는) 결함이었다.
106차가 (확인 필요)로 남긴 것 셋 중 ①②는 107차(2.8.12.14)가 이어받아 처리했다. ③ GitHub 이슈 검색(repo:ros2/rclpy, "Guard conditions not supported"·"owned_cbs_"·"UpdateEntitySet")으로는 이 정확한 결함을 보고한 기존 이슈를 찾지 못했다 — 비인증 API의 검색 정밀도 한계일 수도, 정말 미보고 상태일 수도 있다. 이 책은 상류에 이슈를 새로 열지 않았다(범위 밖, 107차도 재검색하지 않았다). 재현 절차·계측 소스·probe_waitables.py는 tools/gap_harness/gap106/에 남겼다.
2.8.12.14 결함의 일반성 — 구독·서비스에서도 재현했다 (결정론적 결함 주입)
107차 갭 점검(CHG-131)이 106차의 제안 ①②를 이어받았다. ① 최우선 — 이 결함이 Waitable만의 문제인지, UpdateEntitySet()을 공유하는 다섯 엔티티 종류(구독·타이머·클라이언트·서비스·Waitable) 전부의 구조적 문제인지.
답: 구조적 문제다 — 구독·서비스로 직접 확인했다. 다만 확률적 재현(우연히 걸리길 기다리는 방식)이 아니라 결정론적 결함 주입을 썼다 — 구독·서비스·클라이언트가 HandleAddedX()에서 저절로 던지려면 rcl_*_set_on_*_callback()이 실제로 실패해야 하는데, 정상적인 단일 스레드 사용에서는 사실상 일어나지 않기 때문이다(아래 표). 대신 events_executor.cpp를 패치해 프로세스 안에서 HandleAddedSubscription/HandleAddedService의 두 번째 호출이 인위적으로 예외를 던지게 했다(tools/gap_harness/gap107/events_executor_instrumented_gap107.cpp) — 이러면 1번째 호출은 항상 실제 RCL 등록에 성공하고, 2번째 호출은 항상 던져 "먼저 성공, 나중 실패"를 매번 결정론적으로 재현한다.
- 구독: 노드에 구독 2개를 등록하고
spin_once()하면, 1번째 구독이MakeCallback으로 정상 등록된 뒤 2번째 구독에서 주입된 예외가 던져진다. 소멸자 로그에 1번째 구독의 콜백 키가owned_cbs_에 고아로 남아 있고(RemoveCallback이 그 키에 대해 한 번도 안 출력됨),Fatal Python error: Aborted로 크래시했다 — 106차가 Waitable에서 본 것과 정확히 같은 패턴이다. 4회 재현, 4/4 크래시(카운터 기반이라 우연이 아니라 매번 같은 결과가 기대되고, 실제로도 그랬다). - 서비스: 커스텀 서비스를 하나도 안 만든 맨 노드도 기본 파라미터 서비스 7종만으로 같은 패턴을 그대로 보였다 — 1번째 서비스 등록 성공, 2번째에서 주입 예외, 소멸자에서 1번째의 키가 고아로 남아 크래시.
- 타이머·클라이언트는 이번 회차(107차)에는 테스트하지 않았다 —
UpdateEntitySet()이 다섯 종류에 완전히 동일한 코드로 적용된다는 소스 사실로 미루어 같은 결함이 있다고 추론만 했다. 110차(2.8.12.15)가 실측으로 채웠다 — 클라이언트는 이 절과 정확히 같은 패턴(abort())이었지만, 타이머는 다른 코드 경로(RclCallbackManager가 아니다)라 결과가 다르다(대부분 조용한 누수, 드물게 use-after-free 세그폴트).
왜 유독 Waitable(guard condition)만 실전에서 걸리는가 — 이제 소스로 답할 수 있다.
| 엔티티 종류 | HandleAddedX()가 던지는 조건 | 정상 사용에서 저절로 발생하나 |
|---|---|---|
| Waitable(guard condition) | 함수 맨 앞, num_guard_conditions != 0이면 무조건 | 그렇다 — guard condition을 감싼 커스텀 Waitable을 등록하기만 하면 매번 100% 걸린다 |
| 구독·서비스·클라이언트 | rcl_*_set_on_*_callback()이 RCL_RET_OK가 아닐 때만 | 정상적인 단일 스레드 사용에서는 사실상 안 일어난다 — 107차는 이 경로를 인위적으로 열어 구조만 확인했고, "저절로" 걸리는 실제 시나리오(예: 핸들 파괴 직후 메모리가 재사용되는 경쟁)는 가설만 세우고 실측하지 않았다 |
톤은 바꾸지 않는다 — 이번 회차 결과가 그 근거를 강화했다. 결함 자체는 다섯 엔티티 종류가 공유하는 코드에 구조적으로 있지만, 평범한 구독·서비스·클라이언트 노드가 저절로 이 결함에 걸릴 현실적 경로는 이번 회차에도 찾지 못했다 — 찾으려면 RCL 콜백 등록이 실제로 실패해야 하는데, 그것은 이미 그 자체로 심각한 별도 문제다. 2.10.11.3·2.8.12.12가 이미 권고하는 "guard condition을 감싼 커스텀 Waitable은 EventsExecutor에 등록하지 않는다"는 실무 결론이 이 결함의 유일하게 현실적인 노출 경로를 정확히 막고 있다. 바뀐 것은 "왜 유독 Waitable만 문제였는지"가 미실험 견해에서 소스 수준 확인으로 격상됐다는 점이다.
② py::set 순회 순서의 확률분포 — 12회 → 50회 재표집. waitable_events_crash.py를 결함 주입 없는 순정 계측 빌드(gap106과 동일)로 50회 반복하니 크래시 36/50(72%), 무사 14/50(28%)로 나왔다 — 3개 중 균등 순회라면 무사 확률이 1/3(33.3%)이어야 하는 것과 106차의 12회 표본(무사 1/12 ≈ 8.3%)보다 훨씬 가까워졌다. z-검정(균등분포 귀무가설)으로는 12회·50회·합산 62회 전부 유의수준 0.05에서 기각되지 않는다(z = −1.84, −0.80, −1.53) — 즉 균등분포와 통계적으로 모순되지 않는다. 다만 62회를 합쳐도 점추정치(24.2%)가 33.3%보다 계속 낮은 쪽에 있어, 표본을 더 늘리면 실제로 약간 비균등할 가능성도 완전히 배제하지 못한다(확인 필요).
하네스·로그 전문·통계 표는 tools/gap_harness/gap107/에 남겼다.
2.8.12.15 결함의 일반성 완결 — 타이머·클라이언트 결함 주입으로 다섯 엔티티 종류 전부를 채우다, 그리고 타이머 use-after-free를 valgrind로 소스 줄 번호까지 확정하다
107차(2.8.12.14)가 "타이머·클라이언트는 이번 회차도 테스트하지 않았다"고 남긴 항목은, 그 뒤 108·109차가 각자 다른 축(같은-타입 엔티티 순서·MultiThreadedExecutor)을 파느라 세 회차째 이월됐다. 110차가 tools/gap_harness/gap107/의 결함 주입 기법을 그대로 타이머·클라이언트에 적용해 마침내 닫았다 — 방법은 동일하다: events_executor.cpp를 패치해 프로세스 안에서 HandleAddedTimer()/HandleAddedClient()의 두 번째 호출이 인위적으로 예외를 던지게 해, "1번째는 항상 실제 등록 성공, 2번째는 항상 실패"를 순서 우연 없이 매번 재현했다.
클라이언트는 예상대로였다 — 구독·서비스와 정확히 같은 패턴, 6/6회 abort(). 클라이언트 등록도 구독·서비스처럼 rcl_callback_manager_(RclCallbackManager)를 거치기 때문이다. 이걸로 RclCallbackManager를 거치는 네 엔티티 종류(Waitable·구독·서비스·클라이언트)는 전부 같은 실패 모드(고아 콜백 → abort())를 공유한다는 것이 실측으로 확정됐다.
타이머는 예상 밖이었다 — 코드 경로 자체가 다르다. HandleAddedTimer()는 rcl_callback_manager_를 전혀 쓰지 않는다. 대신 완전히 별개의 클래스 TimersManager/RclTimersManager(timers_manager.cpp, 자체 std::unordered_map 두 개)로 위임한다. 그리고 TimersManager::~TimersManager()·RclTimersManager::~RclTimersManager()에는 RclCallbackManager의 "아직 등록된 것이 남아 있으면 abort()"라는 안전장치가 없다 — 소스를 읽으면 두 소멸자 모두 몸체가 원래 비어 있다(멤버의 암묵적 소멸에만 의존). 결함 주입 20회 반복에서 abort()는 한 번도 나지 않았다(0/20). 대신:
| 결과 | 횟수 | 비율 |
|---|---|---|
| 조용한 종료(exit 0) — 고아 항목 1개가 소멸자 로그에만 남음 | 17 | 85% |
| 세그폴트(exit 139) | 3 | 15% |
abort() | 0 | 0% |
세그폴트의 정확한 메커니즘을 gdb 콜스택으로 확정했다. 인터프리터 종료 시점에 EventsExecutor → TimersManager → RclTimersManager → (아무도 미리 지우지 않은) ClockManager가 연쇄로 파괴되는데, ~ClockManager()가 "아직 관리 중"으로 착각하는 타이머를 정리하려고 RemoveTimer() → rcl_timer_set_on_reset_callback()을 호출한다. 이 시점엔 node.destroy_node()·rclpy.shutdown()이 이미 지나가 그 rcl_timer_t의 기반 메모리가 이미 해제됐을 수 있다 — 해제된 메모리가 아직 재사용 안 됐으면 조용한 실패로 끝나고(85%), 재사용됐으면 그 자리를 유효한 객체인 것처럼 역참조하다 librcl.so 안에서 종료된다(15%) — 전형적인 use-after-free다.
Thread 1 "python3" received signal SIGSEGV, Segmentation fault.
0x... in rcl_timer_set_on_reset_callback () from /opt/ros/jazzy/lib/librcl.so
#0 rcl_timer_set_on_reset_callback ()
#1 RclTimersManager::ClockManager::RemoveTimer (timers_manager.cpp:170)
#2 RclTimersManager::ClockManager::~ClockManager (timers_manager.cpp:139)
...
#17 RclTimersManager::~RclTimersManager (timers_manager.cpp:305)
#18 TimersManager::~TimersManager (timers_manager.cpp:367)
#19 EventsExecutor::~EventsExecutor (events_executor.cpp:102)
"조용한 종료"도 완전히 무해하지는 않다 — 20회 전부(세그폴트 포함)에서 ~TimersManager 직후 "Failed to remove RCL clock jump callback: jump callback was not found"라는 (비치명적) RCL 오류가 출력됐다. 대조군(타이머 1개만 만들어 결함이 안 걸리는 경우)에는 이 메시지가 전혀 없었으므로, 결함이 걸렸을 때만 나타나는 실제 부작용이다.
다섯 엔티티 종류의 실패 모드를 표로 정리하면:
| 엔티티 종류 | 등록 경로 | 결함 걸리면 | 저절로 걸리나 |
|---|---|---|---|
| Waitable(guard condition) | RclCallbackManager | abort()(무조건, 106차) | 그렇다 — 100% |
| 구독·서비스·클라이언트 | RclCallbackManager | abort()(결함 주입, 107·110차) | 사실상 안 일어남 |
| 타이머 | TimersManager(별도 경로, abort() 안전장치 없음) | 85% 조용한 누수, 15% 세그폴트(UAF), 0% abort()(결함 주입, 110차, n=20) | 사실상 안 일어남 |
책의 실무 결론은 바뀌지 않는다. 결함 자체(부분 실패 롤백 없음)는 다섯 엔티티 종류가 공유하는 UpdateEntitySet()의 구조적 문제이고, 이제 다섯 종류 전부 결정론적 결함 주입으로 실측이 끝났다. "정상적인 단일 스레드 사용에서 저절로 걸릴 현실적 경로"는 여전히 Waitable(guard condition)뿐이며, 기존 권고(guard condition을 감싼 커스텀 Waitable은 EventsExecutor에 등록하지 않는다)가 이 결함의 유일하게 현실적인 노출 경로를 여전히 막는다. 새로 드러난 것은: 만약 이 결함이 어떤 경로로든 타이머에서 걸린다면, 다른 넷과 달리 시끄럽게 종료되는 안전장치가 없다 — abort()는 최소한 코어 덤프와 명확한 메시지를 남기지만, 타이머 쪽은 85%가 아무 흔적 없이 새고 15%만 예측 불가능한 세그폴트로 드러난다. 조용한 쪽이 "더 안전"한 것이 아니라 그저 다른 확률로, 더 디버깅하기 어렵게 위험하다.
111차가 이 use-after-free의 정확한 해제 지점과 재접근 지점을 소스 줄 번호까지 확정했다 — 110차가 (확인 필요)로 남긴 "정확한 메모리 재사용 타이밍"을 ASan(AddressSanitizer)과 valgrind로 추적한 결과다.
ASan으로 rclpy 전체(이벤트 실행기 포함)를 -fsanitize=address로 재빌드하는 데는 성공했다(컴파일·링크 정상 종료). 실행하려면 초기 장벽을 넘어야 했다 — python3 바이너리는 C로만 컴파일돼 libstdc++를 직접 링크하지 않는데, LD_PRELOAD=libasan.so만 주면 ASan의 예외 인터셉터가 초기화되는 시점에 libstdc++가 아직 프로세스에 없어 AddressSanitizer: CHECK failed: ... real___cxa_throw != 0로 곧바로 종료된다. libasan.so를 반드시 첫 번째로, libstdc++.so.6을 그다음에 명시적으로 preload하고 ASAN_OPTIONS=verify_asan_link_order=0으로 순서 검사를 끄면 해결된다. 이후 20/20회 전부 크래시했다 — 110차 네이티브 빌드의 15%(3/20)와 달리 100%다.
다만 ASan의 리포트는 기대한 형태가 아니었다. "해제 스택과 재할당 스택을 짝지어 보여주는" 교과서적 heap-use-after-free 리포트가 아니라 AddressSanitizer: SEGV(DEADLYSIGNAL, "추가 정보 제공 불가")였다 — 이 버그가 다단계 포인터 체인(먼저 timer->impl을 읽고, 그 값을 다시 포인터인 것처럼 역참조해 완전히 매핑 밖의 주소에 쓴다)이라, 자신이 직접 추적하는 단일 힙 블록에 대한 접근만 검출하는 ASan의 redzone·quarantine 탐지와 형태가 맞지 않았다. gdb를 ASan 빌드에 결합해 크래시 시점 레지스터를 확인하니 rdi(첫 인자 timer)가 AddTimer 로그의 원래 포인터 값과 정확히 일치했다 — timer 자체가 아니라 그 안의 impl 필드를 따라간 두 번째 역참조에서 종료된다는 뜻이다. ASan은 (a) 빌드·실행에는 성공하고 (b) 크래시를 15% → 100%로 증폭시켰지만, (c) 이 다단계 포인터 체인 버그에 대해 기대한 "해제 지점 vs 재사용 지점" 페어링 리포트는 주지 못했다 — 이것이 이번 회차가 확인한 ASan의 한계다.
valgrind(memcheck)가 이 버그 형태에는 훨씬 잘 맞았다. ASan과 동시에 쓸 수 없어(둘 다 자체 힙 대체 도구) 별도의 sanitizer-없는 빌드로 같은 결함 주입 소스를 다시 빌드해 실행하니, 4/4회 완전히 동일한 주소(0x78)로 100% 재현됐고, 해제 지점과 재접근 지점이 소스 줄 번호까지 나왔다:
node.destroy_node()가 정상적으로 호출하는rclpy::Timer::destroy()(timer.cpp:31-35)가 이 타이머의rcl_timer_t(33번째 줄,rcl_timer_.reset())와 그rcl_clock_t(34번째 줄,clock_.destroy())를 둘 다 정상적으로 해제한다 —UpdateEntitySet()고아 버그와 무관하게 언제나 일어나는 정상 경로다.rcl_timer_t를 해제하는 커스텀 삭제자는rcl_timer_fini(timer)를 부르는데, rcl 소스(timer.c:249-250)를 보면 이 함수가timer->impl을free()하고timer->impl = NULL로 명시적으로 지운 뒤rcl_timer_t구조체 자신(impl포인터 하나만 담은 8바이트 구조체)도delete한다.EventsExecutor의TimersManager/ClockManager는 이rcl_timer_t*·rcl_clock_t*둘 다를 원시 포인터로 별도로 들고 있고,UpdateEntitySet()의 부분 실패 롤백 부재 때문에 이 타이머가 제거되지 않은 채 고아로 남는다.- 인터프리터 종료 시점,
~ClockManager()가 이미 해제된 두 객체에 순서대로 접근한다 — 먼저rcl_clock_remove_jump_callback()이 이미 해제된 80바이트rcl_clock_t블록을 5곳의 오프셋에서 읽고(valgrind가Invalid read of size 85건으로 포착, 이것이 110차가 본 "Failed to remove RCL clock jump callback" 메시지의 정체다), 그다음RemoveTimer()→rcl_timer_set_on_reset_callback()이 이미 해제된 8바이트rcl_timer_t블록의impl필드를 읽는다(Invalid read1건 더). 둘 다 "단순 읽기"라 즉시 종료되지 않는다 — 해제된 힙 블록은 보통 당장 unmap되지 않는다. - 여기서 rcl 쪽의 결함이 하나 더 겹친다.
rcl_timer_set_on_reset_callback()(rcltimer.c:515-537)은timer가 NULL인지는 검사하지만(RCL_CHECK_ARGUMENT_FOR_NULL)timer->impl이 NULL인지는 검사하지 않고 바로&timer->impl->callback_data를 역참조한다 — 같은 파일의rcl_timer_get_allocator()·rcl_timer_get_guard_condition()은timer->impl을 명시적으로 검사하는 것과 대조적이다. 방금 읽은impl이rcl_timer_fini()가 남긴NULL이므로cb_data는 주소0x78(offsetof산술의 결과)이 되고,cb_data->on_reset_callback = NULL;이 그 주소에 대한 쓰기로 종료된다.
즉 이 결함은 "malloc이 우연히 메모리를 재사용했는가"의 확률 게임이 아니라, rcl_timer_fini()가 정직하게 남긴 NULL을 몇 단계 뒤에서 미검사 상태로 역참조하는 결정론적 이중 해제/NULL 역참조였다. 110차가 네이티브 빌드에서 본 85%(조용한 누수)/15%(세그폴트)의 차이는 도구별 할당자 차이로 설명된다(견해) — glibc의 기본 할당자(tcache/fastbin)는 이런 작은 해제 블록을 인터프리터 종료 시퀀스 중의 다른 할당에 곧바로 재사용하는 경향이 있어, ~ClockManager()가 마침내 읽을 무렵엔 그 자리가 원래의 NULL 대신 다른 내용으로 이미 덮여 있을 수도, 아닐 수도 있다(덮이지 않았으면 조용한 실패 메시지만 남고 끝나는 85%, 덮인 내용이 우연히 무효 포인터처럼 보이면 세그폴트하는 15%). valgrind는 해제된 블록을 프로세스에 곧바로 돌려주지 않아 원래 값(NULL)이 거의 항상 그대로 남고, 그래서 100%에 가깝게 재현된다. 이 설명은 개연성 있는 추론이지 glibc 내부를 직접 계측해 확인한 것은 아니다(확인 필요).
"정확한 메모리 재사용 타이밍"에 대한 111차의 답은 이렇다: 재사용은 원인이 아니라 결과를 가리는 잡음이었다. 근본 결함은 시점과 무관하게 항상 존재하는 "이중 해제 이후 미검사 역참조"이고, "몇 %가 조용히 새고 몇 %가 종료되는가"는 그 사이에 우연히 개입하는 다른 할당의 유무가 결정한다 — 하나의 "정답 타이밍"은 없고, 대신 결함 자체의 결정론적 근본 원인가 확정됐다. ASan에서 관찰된 크래시 주소가 valgrind의 고정된 0x78이 아니라 계속 변하는 다른 작은 값(0x40c, 0x41e, ... 실행마다 18바이트씩 증가)이었던 정확한 이유는 확정하지 못했다 — ASan 자신의 할당자가 이 8바이트 슬롯을 프로세스 실행 중 실제로 재사용한 결과로 보이지만, 정확히 무엇으로 재사용됐는지는 추적하지 않았다(확인 필요).
(정상 사용 중 우연히 걸리는가, 아니면 인위적 결함 주입이 있어야만 발동하는가) 재판단 — 톤은 바뀌지 않는다. 이번 회차의 정밀 추적도 이 질문의 답을 바꾸지 않는다 — 여전히 UpdateEntitySet()의 부분 실패(RCL 콜백 등록이 배치 중간에 실제로 실패)가 먼저 일어나야 하고, 이는 정상적인 단일 스레드 사용에서 사실상 일어나지 않는다(위 표의 "저절로 걸리나" 열은 그대로 유효하다). 바뀐 것은 "만약 걸린다면 무엇이 일어나는가"의 정밀도다 — "메모리가 언젠가 재사용되면 종료될 수도 있다"는 막연한 서술에서, "특정 rcl 함수의 특정 미검사 역참조가 이중 해제 이후 결정론적으로 실행된다"는 소스·도구로 확인한 사실로 바뀌었다. 실무 권고(guard condition을 감싼 커스텀 Waitable을 EventsExecutor에 등록하지 않는다)는 그대로 이 결함의 유일하게 현실적인 노출 경로를 막는다.
하네스·ASan/valgrind 전체 로그·재현 절차는 tools/gap_harness/gap111/에 남겼다.
② py::set 순회 순서 확률분포 — 62 → 212 → 500회로 완결. waitable_events_crash.py를 결함 주입 없는 순정 계측 빌드로 반복 실행해 무사(정상 종료)/크래시(abort()) 비율을 측정했다. 110차(CHG-134) 이후 111~113차가 각자 다른 축을 파느라 네 회차째 이월된 것을 114차(CHG-138)가 288회를 더해 500회로 완결했다.
| 표본 | 크래시 | 무사 | 무사 비율 | z-값 | $p$ |
|---|---|---|---|---|---|
| 106차, 12회 | 11 | 1 | 8.3% | −1.84 | 0.066 |
| 107차, 50회 | 36 | 14 | 28.0% | −0.80 | 0.424 |
| 110차, 150회 | 103 | 47 | 31.3% | −0.52 | 0.603 |
| 114차, 288회 | 207 | 81 | 28.1% | −1.88 | 0.061 |
| 누적 212회(106~110차) | 150 | 62 | 29.2% | −1.26 | 0.207 |
| 누적 500회(전체) | 357 | 143 | 28.6% | −2.25 | 0.0248 |
500회 누적 표본이 처음으로 유의수준 0.05에서 균등분포(1/3) 귀무가설을 기각한다 — 106~110차의 네 회차 내내 "기각하지 못한다"였던 결론이 뒤집혔다. 점추정치는 106차부터 계속 33.3%보다 낮은 쪽(8.3~31.3%)에 있었고, 표본이 커질수록 33.3%로 수렴하기보다 28~29% 근방에서 안정화됐다 — "작은 표본이 우연히 낮게 나왔을 뿐 실제로는 균등하다"던 107~110차의 잠정 해석과 반대다. py::set의 순회 순서는 이 실험 조건(노드 하나에 Waitable 3개, 그중 하나가 guard condition)에서 완전히 균등(각 1/3)이 아니라, "안전한" 순서가 나올 확률이 33.3%보다 유의하게 낮다(약 28~29%)는 결론으로 정정한다. 정확한 이유(py::set이 쓰는 id() 기반 해시와, 이 실험에서 만들어지는 세 Waitable 객체의 힙 주소 할당 패턴·ASLR 특성의 상호작용)는 추정이며 직접 계측하지 않았다(확인 필요).
하네스·로그 전문·gdb 콜스택 전체·통계 표는 tools/gap_harness/gap110/·tools/gap_harness/gap114/에 남겼다.
여기까지가 이 책이 두 언어를 나란히 측정한 전부다. 로깅·
throttle·인자 평가·레벨 API·예외·executor·타이머·메시지 비용·QoS·액션 피드백 유실·커스텀 Waitable의 executor 내 위치와EventsExecutor호환성의 열한 축이며, 78~82차 + 99차(CHG-122) + 105차에 걸쳐 채웠고, 106차가 그 열한 번째 축의 크래시 원인을 소스 수준으로 확정했으며, 107차가 그 결함이 구독·서비스에서도 재현됨을 결함 주입으로 확정했고, 110차가 타이머·클라이언트까지 마저 채워 다섯 엔티티 종류 전부의 실패 모드를 실측했다(타이머는abort()대신 use-after-free라는 별개의 결론). 111차가 그 타이머 UAF를 ASan·valgrind로 추적해 해제 지점("이미rcl_timer_fini()가 지운NULL")과 재접근 지점(rcl_timer_set_on_reset_callback()의 미검사 역참조)을 소스 줄 번호까지 확정했다. 완결은 아니다 —tf2·이미지 전송 쪽은 아직 나란히 측정하지 않았다(확인 필요). 차이를 발견하면 여기 더한다.
2.8.13 Python 예시의 이름을 런타임과 대조했다 — 실측
90차에 C++ 예시 122개를 컴파일해 결함 열 곳을 찾았다. Python에는 컴파일러가 없으므로 다른 방법이 필요하다 — 이 절은 그 방법과 결과다(실행 확인 — Jazzy · ros:jazzy-ros-base · CPython 3.12 · rclpy 7.x).
2.8.13.1 무엇을 어떻게 봤나
Python은 이름이 틀려도 그 줄이 실행될 때까지 조용하다. 그래서 정적으로 이름만 추출해 런타임에 물어보는 방식을 썼다.
| 검사 | 방법 | 대상 |
|---|---|---|
| 문법 | ast.parse | ROS 관련 코드펜스 전부 |
import 이름 | 실제 importlib.import_module 후 hasattr | 68개 |
| 열거 멤버 | ReliabilityPolicy.BEST_EFFORT 같은 접근을 실물과 대조 | 4종 |
| 생성자 키워드 | QoSProfile(depth=…) 등을 시그니처·필드와 대조 | 4종 |
| 폐기 형태 | rclpy가 DeprecationWarning을 내는 API를 쓰는지 | 전수 |
2.8.13.2 결과 — Python 예시는 문제가 없었다
| 검사 | 결과 |
|---|---|
| 문법 오류 | 0 |
없는 import 이름 | 0 / 68 |
| 없는 열거 멤버 | 0 / 4 |
| 없는 생성자 키워드 | 0 / 4 |
폐기된 형태(QoSEventHandler·rclpy.qos_event·RMW_QOS_POLICY_* 별칭) | 0 |
인자가 하나인 declare_parameter | 0 |
마지막 항목이 특히 다행이다 — Jazzy는 declare_parameter('name')처럼 이름만 주는 형태에 DeprecationWarning을 낸다.
when declaring parameter named 'x', declaring a parameter only providing its name is deprecated. You have to either: - Pass a name and a default value different to "PARAMETER NOT SET" … - Pass a name and a parameter type. - Pass a name and a descriptor with `dynamic_typing=True`이 책은 처음부터 기본값을 함께 주는 형태로 써 왔다(1.9.3). 옛 예제를 옮겨 쓸 때 걸리는 자리이므로 알아 둘 만하다.
C++은 열 곳이 틀렸는데 Python은 0이다. 왜 그런지 단정하지는 않겠다 — 다만 *C++ 쪽은 오버로드·템플릿 인자·[[deprecated]] 표시처럼 이름만으로는 판정되지 않는 표면이 넓고, Python 쪽은 이 책이 쓰는 API가 Node 메서드와 몇 개의 열거로 좁다*는 차이가 있다(확인 필요 — 인과는 확인하지 못했다).
2.8.13.3 이 방법의 한계 — 세 가지
① 이름만 본다. "인자 개수가 맞는가", "인자 순서가 맞는가"는 못 본다. Python은 호출되기 전까지 아무 말도 하지 않으므로 C++에서 90차가 검출한 "인자 셋인데 둘만 넘김" 같은 결함은 이 방법으로 검출하지 못한다.
② 오탐이 있었다. self.xxx() 호출을 전부 Node 메서드로 보고 열 건이 걸렸는데, 전부 책이 정의한 자기 클래스의 메서드였다(assertTrue는 unittest.TestCase, read_frame은 예시 드라이버). self가 무엇인지는 정적으로 알 수 없다.
③ 실행하지 않았다. 이름이 있다고 동작이 맞는 것은 아니다 — 82차에 QoSPresetProfiles.SERVICES_DEFAULT가 존재하지만 .name이 'DEFAULT'를 돌려주는 것을 본 것이 그 예다(2.8.12.10).
2.8.13.4 덤으로 찾은 것 — rclpy 자신의 별칭 오타
rclpy/qos.py는 옛 이름을 폐기 별칭으로 남겨 두는데, LivelinessPolicy의 목록에 다른 정책군의 이름이 들어가 있다.
>>> hasattr(LivelinessPolicy, 'RMW_QOS_POLICY_LIVELINESS_UNKNOWN')
False # ← 있어야 할 이름이 없고
>>> hasattr(LivelinessPolicy, 'RMW_QOS_POLICY_DURABILITY_UNKNOWN')
True # ← 잘못된 이름이 붙어 있다
소스에서도 그대로 보인다 — LivelinessPolicy의 별칭 목록 마지막 줄이 ('RMW_QOS_POLICY_DURABILITY_UNKNOWN', 'UNKNOWN')이다. DURABILITY를 복사해 오며 고치지 않은 자국으로 보인다.
실무 영향은 작지만 0은 아니다. 옛 이름으로 쓰던 코드를 Jazzy로 옮기며
LivelinessPolicy.RMW_QOS_POLICY_LIVELINESS_UNKNOWN을 쓰면AttributeError가 난다 — 그런데 오류 메시지는 "그 이름이 없다"고만 말하므로 폐기된 줄 알고 새 이름(UNKNOWN)을 찾느라 시간을 들이게 된다. 답은 간단하다:LivelinessPolicy.UNKNOWN을 쓰라. 애초에 별칭은 전부 폐기 예정이므로 새 이름만 쓰는 것이 옳다.이 항목은 업스트림의 결함이며 이 책이 고칠 수 있는 것이 아니다. 버전이 바뀌면 사라질 수 있다
(확인 필요).
2.8.9 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | 구독·타이머를 지역 변수로 | 조용히 동작 중단 | 멤버로 보관 |
| 2 | call() 동기 호출 | 데드락 | call_async+콜백 |
| 3 | 콜백 안에서 spin_until_future_complete | 데드락 | 그룹 분리 + MT executor |
| 4 | 멀티스레드로 계산 병렬화 기대 | GIL 때문에 이득 없음 | numpy·C++로 |
| 5 | Python 루프로 배열 처리 | 극심한 성능 저하 | numpy 벡터 연산 |
| 6 | np.array(msg.data) | 불필요한 복사 | np.frombuffer |
| 7 | numpy 뷰를 콜백 밖으로 | 무효 메모리 접근 | .copy() |
| 8 | 실시간 제어를 Python으로 | GC 정지·지터 | C++로 이관 |
| 9 | 콜백 예외 미처리 | 조용한 정지 | try/except |
| 10 | 로그 인자가 항상 평가 | 꺼도 비용 | 조건 확인 |
| 11 | --symlink-install 미사용 | 수정이 반영 안 됨 | 개발 시 사용 |
| 12 | 종료 처리 누락 | 노드가 그래프에 남음 | try/finally |
| 13 | asyncio와 직접 통합 시도 | 복잡·불안정 | 스레드 분리 + 큐 |
| 14 | 다중 스레드면 콜백 하나 종료돼도 계속 동작한다고 생각 | executor 전체가 내려간다 | 콜백마다 try(2.8.11.2) |
| 15 | read_points*를 skip_nans 기본값으로 사용 | NaN이 연산을 오염 | 명시적으로 켜라(2.8.11.1) |
| 16 | throttle_duration_sec를 성능 대책으로 사용 | 억제돼도 128 µs — C++과 다르다 | 호출을 조건으로 감싸라(2.8.12.2) |
| 17 | Python 주기 루프 안에 출력 로그 | 최대 8 ms 관측 | 루프 밖으로(2.8.12.1) |
| 18 | 최상위에서 콜백 예외를 삼킴 | 종료 코드 0 — 재시작도 안 된다 | 묵살하지 말 것(2.8.12.5) |
| 19 | 계산 콜백에 MultiThreadedExecutor | GIL 때문에 오히려 16% 손해 | 콜백이 GIL을 놓는지 보라(2.8.12.7) |
| 20 | executor만 바꾸고 그룹은 그대로 | 아무 일도 안 일어난다 | Reentrant 또는 별도 그룹(2.8.12.7) |
| 21 | Python 최적화를 발행 횟수부터 | 실제 비용은 메시지 생성(237배) | 객체를 재사용하라(2.8.12.9) |
| 22 | Python으로 1 kHz 제어 루프 | spin_once 하나가 주기의 33% | 100 Hz까지가 현실적(2.8.12.9) |
2.8.10 체크포인트
- rclpy와 rclcpp가 공유하는 층과 각자 구현하는 층을 구분할 수 있는가. 그것이 실무에 주는 함의는?
- GIL 때문에 병렬화되지 않는 작업과 되는 작업을 각각 세 가지 이상 들 수 있는가.
- rclpy에서
MultiThreadedExecutor가 처리량을 못 늘리는데도 필요한 경우는 무엇인가. - rclpy에서 서비스를 호출하는 올바른 형태를 쓸 수 있는가.
call()을 금지하는 팀 규칙의 근거는? - rclpy
Future와asyncio.Future의 관계는? 비동기 라이브러리와 통합해야 하면 어떻게 하는가. - Python이 적합한 워크로드와 부적합한 워크로드를 각각 네 가지 들 수 있는가.
- Python을 실시간 제어에 쓸 수 없는 이유 중 GIL 외의 것은 무엇인가.
np.frombuffer와np.array의 차이는? 뷰를 콜백 밖으로 넘길 때 주의점은?- 구독을 지역 변수로 만들면 무슨 일이 일어나는가. 같은 문제가 적용되는 다른 객체들은?
- rclpy 로깅이 C++ 매크로와 다른 점과, 그로 인한 성능 함정은?
--symlink-install이 rclpy 개발에서 왜 중요한가.- 구독 콜백에서 예외가 나가면 executor는 어떻게 되는가. 다중 스레드면 달라지는가(2.8.11.2).
- 비용이 큰 디버그 로그를 조건부로 만드는 두 가지 API는(2.8.11.3).
- Python의
throttle_duration_sec이 C++의THROTTLE과 왜 다른가(2.8.12.2). - 억제되는 로그의 인자는 두 언어에서 각각 어떻게 되는가(2.8.12.3).
- Python 노드의 최상위에서 예외를 묵살하면 배포 관점에서 무엇을 잃는가(2.8.12.5).
- "GIL 때문에
MultiThreadedExecutor가 무의미하다"는 어느 경우에 맞고 어느 경우에 틀리는가(2.8.12.7). - Python 타이머의 드리프트와 꼬리는 C++과 각각 어떻게 다른가(2.8.12.8).
- Python에서 C++ 대비 가장 큰 배수가 나는 항목은 무엇이며 몇 배인가(2.8.12.9).
- Python으로 몇 Hz까지 제어 루프가 현실적인가. 근거는(2.8.12.9).
- Python 코드의 이름이 맞는지 실행하지 않고 확인하려면 무엇을 대조하는가. 그 방법이 검출하지 못하는 결함은 어떤 종류인가(2.8.13.3).
다음 장 2.9는 제2부에서 계속 미뤄 온 주제 — 스레드 안전성과 경쟁 조건을 정면으로 다룬다. 무엇이 스레드 안전하고 무엇이 아닌지, 그리고 ROS 2에서 반복되는 경쟁 조건 패턴들.