📝 ROS 2 멀티스레드: 100 Hz 제어 루프 옆에서 파이썬이 무너지는 과정
ROS2 Executor 이해하기의 후속. 그 글이 Executor와 Callback Group이 무엇인지를 정리했다면, 이 글은 같은 구조에 CPU를 오래 붙잡는 작업을 얹었을 때 C++과 파이썬이 얼마나 달라지는지를 실측한다. 결론부터 말하면 이전 글의 실험이 보여준 “Reentrant로 바꾸면 병렬 실행된다”가 여기서는 통하지 않는데, 이유는 3절에 있다.
로봇의 제어 루프는 1초에 100번, 즉 10 ms마다 명령을 내려야 한다. 그 옆에서 CPU를 오래 붙잡는 콜백 하나가 돌기 시작하면 어떻게 될까. C++(rclcpp)과 파이썬(rclpy)에 완전히 동일한 구조를 주고 재봤다.
GIL 때문에 파이썬이 불리하다는 건 새로운 이야기가 아니다. 이 글에서 건질 만한 건 세 가지다. 얼마나 차이 나는지의 실측치, 단발 측정으로 내렸던 결론 두 개가 반복 측정에서 뒤집힌 과정, 그리고 그 과정에서 내가 낸 인프라 버그 하나다.
1. 무엇을 재는가
원본 벤치마크는 노드 하나에 콜백 두 개를 붙인다. 둘은 각각 별도의 MutuallyExclusive 콜백 그룹에 들어가고, 노드는 MultiThreadedExecutor로 돌린다.
- 제어 콜백 — 10 ms 주기 타이머.
/cmd_vel을 발행하고, 직전 콜백으로부터 실제로 몇 초가 지났는지를 CSV에 기록한다. 이 “실제 간격”이 측정 대상이다. - 스캔 콜백 —
/scan(LaserScan) 구독. 들어올 때마다sin()을 5천만 번 더한다. SLAM이나 물체 인식처럼 CPU를 오래 붙잡는 작업의 대역이다.
C++판과 파이썬판은 이 구조가 동일하다. 상식적인 기대는 “무거운 계산은 한 스레드가 맡고 제어 루프는 다른 스레드가 계속 돌린다”이다. C++에서는 그렇게 된다.
측정 환경은 i9-14900K(24코어 / 32스레드), Ubuntu 24.04, ROS 2 Jazzy, Gazebo Harmonic 8.11.0, 터틀봇4 warehouse 월드. /scan은 약 60 Hz로 들어온다. 조건당 60초씩 돌렸고, 매 실행 전에 시뮬레이터를 새로 띄웠다.
2. 첫 그림
세로선 하나가 제어 명령 한 번이다. 0.6초 구간을 실제 CSV 타임스탬프에서 그대로 뽑았다.
파이썬도 무거운 계산이 시작되기 전까지는 정확히 100 Hz를 지킨다. 첫 줄과 둘째 줄의 차이가 전부 스캔 콜백 하나 때문에 생긴 것이다. 셋째 줄은 같은 부하를 받는 C++이다.
3. GIL — 마이크가 하나뿐인 회의실
파이썬에는 GIL(Global Interpreter Lock)이 있다. 스레드를 몇 개 만들든 파이썬 바이트코드를 실제로 실행할 수 있는 건 한 번에 하나다. 회의실에 열 명이 있어도 마이크가 하나면 한 명만 말할 수 있다.
평소엔 티가 안 난다. 대부분의 작업은 마이크를 잠깐 잡았다 놓기 때문이다. 문제는 한 스레드가 몇 초 동안 놓지 않을 때 생기고, for i in range(50000000): result += math.sin(i)가 정확히 그런 코드다. 타이머를 별도의 MutuallyExclusiveCallbackGroup에 넣고 MultiThreadedExecutor를 써도 소용없다. 제약이 콜백 스케줄링이 아니라 바이트코드 실행에 있기 때문이다.
ROS2 Executor 이해하기의 실험에서는 MultiThreadedExecutor + ReentrantCallbackGroup 조합으로 두 콜백이 병렬로 실행됐다. 여기서는 스레드를 32개 줘도 안 된다. 코드가 다르기 때문이다.
- 그 글의 느린 콜백은
time.sleep(1.0)이다.sleep은 GIL을 놓는다. 그래서 자는 동안 다른 스레드가 마이크를 잡을 수 있고, 병렬로 보인다. - 이 글의 느린 콜백은 순수 파이썬
for루프다. GIL을 쥐고 안 놓는다. Callback Group을 어떻게 배치하든 소용없다.
정리하면 Callback Group과 Executor는 “언제 콜백을 시작할지”를 정할 뿐, “동시에 실행될 수 있는지”는 그 콜백이 GIL을 놓느냐가 정한다. I/O 대기(sleep, 네트워크, 파일)나 NumPy·PyTorch 같은 C 확장은 GIL을 놓으므로 이전 글의 결론이 그대로 통하고, 순수 파이썬 계산은 통하지 않는다.
로그에 그대로 찍힌다. 60초 실행에서 각 워커가 처리한 작업 건수다.
| 워커 1 | 워커 2 | 워커 3 | 워커 4 | 합계 | |
|---|---|---|---|---|---|
| C++ (4 스레드) | 1,625 (26.6%) | 1,546 (25.3%) | 1,514 (24.8%) | 1,418 (23.2%) | 6,103 |
| 파이썬 (4 스레드) | 236 (53.9%) | 202 (46.1%) | 0 | 0 | 438 |
C++은 넷이 고르게 나눠 가졌고, 파이썬은 둘이 한 번도 쓰이지 않았다. 마이크가 하나니 나머지는 줘봐야 할 일이 없다.
4. “스레드 수를 안 맞췄잖아”
여기서 첫 실험에 허점이 있었다. 원본 코드가 C++에는 실행기 스레드 기본값(이 머신에서 32)을, 파이썬에는 하드코딩된 4를 준다. “파이썬이 진 건 스레드가 8배 적어서 아니냐”는 반박이 성립한다.
정당한 지적이라 두 노드에 num_threads 파라미터를 추가하고(기본값은 원본 동작 유지) 2 × 2로 다시 돌렸다.
결과부터 말하면 스레드 수는 답이 아니었다. 양쪽 모두 4로 맞춘 칸에서 C++ 98.97 Hz, 파이썬 7.10 Hz였다.
그런데 이때 표를 보고 “32 스레드를 주면 파이썬이 7.10 → 8.77 Hz로 23% 나아진다”고 적었다. 이게 나중에 틀린 것으로 판명된다.
5. 5번씩 다시 — 편차
조건당 한 번씩만 돌린 데이터로는 “13배 차이” 같은 큰 결론은 몰라도 그보다 작은 주장은 뒷받침되지 않는다. 4개 조건을 각각 5번씩, 총 20번 다시 돌렸다. 조건별로 몰아서 돌리면 온도나 백그라운드 부하 드리프트가 한 조건에 통째로 실리므로 라운드로빈으로 섞었다.
| 지표 | C++ / 4 | C++ / 32 | 파이썬 / 4 | 파이썬 / 32 |
|---|---|---|---|---|
| 1초당 제어 명령 (목표 100) | 98.84 ± 1.09 | 99.48 ± 0.37 | 7.62 ± 1.19 | 7.95 ± 2.52 |
| 최소~최대 | 97.86–100.02 | 98.97–100.00 | 5.70–8.96 | 5.94–12.12 |
| 실행 간 편차 (CV) | 1.1% | 0.4% | 15.6% | 31.7% |
| 명령 간격 p99 (목표 10 ms) | 10.1 ms | 10.1 ms | 281.4 ms | 281.3 ms |
| 무거운 계산 1회 | 0.631 s | 0.629 s | 3.07 s | 3.13 s |
| 실제로 쓰인 스레드 | 4~7 | 32 | 3~4 | 3~4 |
20번을 하나도 빼놓지 않고 점으로 찍으면 평균이 숨기던 게 보인다. 가로선은 그 조건의 평균이다.
6. 뒤집힌 결론 두 개
6.1 “스레드를 더 주면 파이썬이 23% 나아진다” — 노이즈였다
- 파이썬 / 4 스레드: 7.62 Hz ± 1.19
- 파이썬 / 32 스레드: 7.95 Hz ± 2.52
- 차이 +0.33 Hz, Welch t = 0.27, \text{df} \approx 5.7
차이의 신뢰구간이 대략 ±3 Hz로 효과 크기의 열 배다. 단발 측정에서 나온 8.77 Hz는 12.12 Hz짜리 이상치 하나가 끌어올린 값이었다. 위 확대 그림에서 두 조건의 점 무리가 완전히 겹치는 걸로 확인된다.
실행기 스레드 수는 rclpy의 제어 루프 속도에 측정 가능한 영향이 없다. 원래 주장(GIL이 병목)의 더 강한 버전이지 약한 버전이 아니다. 다만 “23%”라는 숫자는 인용하면 안 된다.
6.2 “C++의 0.33초 멈춤은 4 스레드라서” — 아니었다
단발 측정에서 C++ / 4에만 ~330 ms 멈춤이 두 번 보였고 32에는 없었다. 스레드 부족으로 해석했다. C++ 10회 실행에서 보면:
| run 1 | run 2 | run 3 | run 4 | run 5 | |
|---|---|---|---|---|---|
| C++ / 4 — 최대 간격 | 12.5 ms | 10.6 ms | 332.0 ms | 331.5 ms | 330.9 ms |
| C++ / 32 — 최대 간격 | 328.7 ms | 329.5 ms | 330.5 ms | 10.3 ms | 329.6 ms |
10번 중 8번에서, 양쪽 스레드 설정 모두 나타난다. 그리고 전부 제어 콜백 인덱스 16~377 사이 — 즉 시작 후 4초 이내이고, /scan 수신이 시작되는 시점 근처다. 한 실행에서 15 ms를 넘긴 명령은 5900번 중 0~4번뿐이고 p99는 모든 실행에서 10.1 ms다. 스레드 수 효과가 아니라 기동 시 일시적 현상이다.
이 항목의 표준편차가 ±175 ms처럼 값보다 큰 이유도 여기 있다. 분포가 양봉이다 — 약 330 ms거나 약 11 ms거나, 중간이 없다.
7. 삽질 기록: 시뮬레이터가 안 뜨기 시작했다
20회 스윕 첫 실행이 /scan이 안 올라와서 실패했다. 두 번째도 실패했다. 그때까지 잘 되던 것이었다.
원인은 내 정리 로직 버그 두 개였다.
하나. 시뮬레이터를 내릴 때 gz sim, turtlebot4, parameter_bridge, robot_state_publisher 패턴만 죽였는데, 터틀봇4 런치가 띄우는 irobot_create_* 노드들이 여기 안 걸렸다. 실행할 때마다 살아남아 95개까지 쌓였다.
둘. kill -9만 썼다. Fast DDS는 SIGKILL을 받으면 /dev/shm의 공유메모리 세그먼트를 반납하지 못한다. 946개가 쌓였고 이게 gz transport 디스커버리를 막았다.
고친 정리 루틴은 이렇다. 요점은 SIGINT를 먼저 줘서 DDS가 스스로 정리할 시간을 주고, 남은 것만 SIGKILL한 뒤, 그러고도 남은 고아 세그먼트를 쓸어내는 것이다.
# 패턴을 조각으로 조립한다. 통으로 쓰면 pgrep -f 가 이 스크립트 자신의
# 명령줄에도 매칭돼서 스스로를 죽인다 (실제로 두 번 당했다).
ROSLIB='/opt/ros/jazzy/li'; ROSLIB="${ROSLIB}b/"
GZBIN='gz si'; GZBIN="${GZBIN}m"
sim_pids() { pgrep -f "$ROSLIB|$GZBIN" | grep -v "^${PROTECT_PID}$"; }
kill_sim() {
local pids
pids=$(sim_pids | tr '\n' ' ')
if [ -n "$pids" ]; then
kill -INT $pids 2>/dev/null # DDS가 shm을 반납할 기회
sleep 10
pids=$(sim_pids | tr '\n' ' ')
[ -n "$pids" ] && kill -9 $pids 2>/dev/null
sleep 4
fi
# 우리 DDS 참여자가 모두 사라진 뒤에만 안전하다
if [ -z "$(sim_pids)" ]; then
find /dev/shm -maxdepth 1 \( -name "fastrtps*" -o -name "sem.fastrtps*" \) \
-user "$(id -un)" -delete 2>/dev/null
fi
}$PROTECT_PID는 같은 머신에서 돌던 무관한 장기 작업의 PID다. /opt/ros/jazzy/lib/로 패턴을 넓히는 순간 남의 프로세스까지 쓸어버릴 수 있으니 명시적으로 제외했다.
고친 뒤 20회를 재시도 없이 전부 1회차에 통과했다. 반복이 끝날 때마다 shm entries / stray procs / mem avail을 로그에 찍게 해서 같은 문제가 재발하면 바로 보이게 했다.
8. 무엇을 가져갈 것인가
“파이썬은 느리다”는 잘못된 교훈이다. 이 실험이 보여준 건 시간을 정확히 지켜야 하는 루프와 CPU를 오래 붙잡는 계산을 같은 파이썬 프로세스 안에 함께 두면 안 된다는 것이다. 둘 중 하나만 있으면 파이썬도 100 Hz를 정확히 지킨다 — 2절의 첫 줄이 그 증거다.
느린 것보다 예측 불가능한 게 더 나쁘다. C++은 실행 간 편차가 0.4~1.1%인데 파이썬은 15.6~31.7%다. 파이썬 / 32 조건은 같은 코드·같은 조건에서 5.94부터 12.12 Hz까지 튀었다. 제어 설계에서 “항상 조금 느림”은 보정할 수 있어도 “언제 얼마나 느려질지 모름”은 감당하기 어렵다. 이건 평균만 봐서는 절대 안 보이는 성질이다.
피하는 방법은 있다. 무거운 계산을 별도 프로세스로 떼어내면 GIL을 공유하지 않는다. NumPy나 PyTorch처럼 내부가 C로 된 라이브러리는 계산 중 GIL을 놓아주므로 순수 파이썬 반복문보다 훨씬 낫다. 이 벤치마크가 일부러 순수 파이썬 for 루프를 쓴 건 GIL 효과를 가장 선명하게 드러내기 위해서다.
섞어 쓰는 게 정답이라면 패키지는 어떻게 나누나. 제어·SLAM은 C++로, 인지·학습은 파이썬으로 가되 하나의 패키지로 묶고 싶다면 Python과 C++ 노드를 모두 포함하는 ROS2 패키지 생성에 CMakeLists.txt와 package.xml을 어떻게 쓰는지 정리해 뒀다. 이 글은 왜 나눠야 하는지의 근거고, 그 글은 어떻게 나누는지의 방법이다.
단발 측정으로 작은 결론을 내리지 말 것. 이 글에서 뒤집힌 두 주장 모두 한 번씩만 돌린 데이터에서 나왔다. 13배짜리 격차는 한 번만 재도 안전했지만, 23%짜리 차이는 그렇지 않았다.
9. 한계
- 머신이 놀고 있지 않았다. 측정 내내 같은 컴퓨터에서 무관한 강화학습 훈련이 CPU 168%, 메모리 12 GB를 쓰며 돌고 있었다. 모든 조건에 동일하게 걸린 부하이고 코어가 32개라 비교 자체는 유효하지만, 절대 수치는 유휴 머신 기준이 아니다. 파이썬의 큰 편차 일부도 여기서 왔을 수 있다.
- C++ 바이너리가 최적화 없이 빌드됐다. 원본
CMakeLists.txt에CMAKE_BUILD_TYPE이 비어 있어-O2가 안 붙는다. 즉 계산 속도 약 4.9배 차이는 C++에 유리하게 부풀린 값이 아니라 하한이다. - Gazebo GUI를 켠 채로 측정했다.
turtlebot4_gz_bringup의sim.launch.py가gz_args를 하드코딩해서-s(헤드리스)를 전달할 수 없다. 역시 모든 조건에 동일하다. - 조건당 n = 5. “23%” 주장을 기각하고 파이썬 편차의 크기를 가늠하기에는 충분하지만, 정밀한 구간 추정에는 부족하다.
- C++ / 4 조건에서 로그에 찍힌 고유 스레드 ID가 4개가 아니라 4~7개였다. 실행기에 준 수보다 많다. 원인은 확인하지 않았고 속도 비교에는 영향이 없지만, 이 칸을 실행기 풀 크기로 읽으면 안 된다. C++ / 32는 다섯 번 모두 정확히 32였다.
원본 벤치마크의 C++ 패키지 실행 이름은 README에 적힌 benchmark_cpp가 아니라 ros2_multithread_benchmark다(package.xml 기준).
빌드가 ModuleNotFoundError: No module named 'catkin_pkg'로 실패하면 CMake의 FindPython3가 시스템 인터프리터 대신 다른 파이썬을 잡은 것이다. 내 경우 ~/.local/bin/python3.10이었다.
빌드가 find_package 단계에서 깨지는 다른 사례는 ROS2 Build Issue에 모아두고 있다.
두 노드 모두 CSV를 현재 작업 디렉토리에 쓴다(benchmark_cpp.csv / benchmark_python.csv 하드코딩). 실행 전에 원하는 위치로 cd 할 것.
C++ 노드는 타임스탬프를 기본 정밀도 std::ofstream으로 쓰기 때문에 epoch 초가 1.78572e+09로 뭉개진다. 이 글의 모든 수치는 period / processing 컬럼과 행 순서로만 계산했고, C++ 타임스탬프 컬럼은 쓰지 않았다.