Curieux.JY
  • JungYeon Lee
  • Post
  • 🕸️ Graph
  • Lecture
  • Note

On this page

  • 1. 무엇을 재는가
  • 2. 첫 그림
  • 3. GIL — 마이크가 하나뿐인 회의실
  • 4. “스레드 수를 안 맞췄잖아”
  • 5. 5번씩 다시 — 편차
  • 6. 뒤집힌 결론 두 개
    • 6.1 “스레드를 더 주면 파이썬이 23% 나아진다” — 노이즈였다
    • 6.2 “C++의 0.33초 멈춤은 4 스레드라서” — 아니었다
  • 7. 삽질 기록: 시뮬레이터가 안 뜨기 시작했다
  • 8. 무엇을 가져갈 것인가
  • 9. 한계

📝 ROS 2 멀티스레드: 100 Hz 제어 루프 옆에서 파이썬이 무너지는 과정

ros2
cpp
python
gil
robotics
benchmark
2026
rclcpp와 rclpy에 완전히 같은 일을 시키고 20번 측정했다. GIL 때문에 파이썬이 느리다는 건 알려진 이야기지만, 실제로 재보니 느린 것보다 예측 불가능한 게 더 문제였다. 그리고 단발 측정으로 내렸던 결론 두 개가 반복 측정에서 뒤집혔다.
Published

August 3, 2026

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 타임스탬프에서 그대로 뽑았다.

0 ms200 ms400 ms600 ms
C++ (rclcpp)파이썬 (rclpy)

파이썬도 무거운 계산이 시작되기 전까지는 정확히 100 Hz를 지킨다. 첫 줄과 둘째 줄의 차이가 전부 스캔 콜백 하나 때문에 생긴 것이다. 셋째 줄은 같은 부하를 받는 C++이다.

3. GIL — 마이크가 하나뿐인 회의실

파이썬에는 GIL(Global Interpreter Lock)이 있다. 스레드를 몇 개 만들든 파이썬 바이트코드를 실제로 실행할 수 있는 건 한 번에 하나다. 회의실에 열 명이 있어도 마이크가 하나면 한 명만 말할 수 있다.

평소엔 티가 안 난다. 대부분의 작업은 마이크를 잠깐 잡았다 놓기 때문이다. 문제는 한 스레드가 몇 초 동안 놓지 않을 때 생기고, for i in range(50000000): result += math.sin(i)가 정확히 그런 코드다. 타이머를 별도의 MutuallyExclusiveCallbackGroup에 넣고 MultiThreadedExecutor를 써도 소용없다. 제약이 콜백 스케줄링이 아니라 바이트코드 실행에 있기 때문이다.

Important이전 글의 실험과 결과가 다른 이유

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로 다시 돌렸다.

ros2 run ros2_multithread_benchmark benchmark_node --ros-args -p num_threads:=4
ros2 run benchmark_python benchmark_node --ros-args -p num_threads:=32

결과부터 말하면 스레드 수는 답이 아니었다. 양쪽 모두 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번을 하나도 빼놓지 않고 점으로 찍으면 평균이 숨기던 게 보인다. 가로선은 그 조건의 평균이다.

0전체 눈금 0–105 회/초 · 점선은 목표 100105
0파이썬만 확대 · 눈금 0–13 회/초13
C++파이썬

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 디스커버리를 막았다.

$ ls /dev/shm | wc -l
946
$ ls /dev/shm | sed 's/_[0-9a-f]*$//' | sort | uniq -c | sort -rn | head -2
    113 fastrtps
      1 sem.fastrtps_port7683_mutex

고친 정리 루틴은 이렇다. 요점은 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. 한계

  1. 머신이 놀고 있지 않았다. 측정 내내 같은 컴퓨터에서 무관한 강화학습 훈련이 CPU 168%, 메모리 12 GB를 쓰며 돌고 있었다. 모든 조건에 동일하게 걸린 부하이고 코어가 32개라 비교 자체는 유효하지만, 절대 수치는 유휴 머신 기준이 아니다. 파이썬의 큰 편차 일부도 여기서 왔을 수 있다.
  2. C++ 바이너리가 최적화 없이 빌드됐다. 원본 CMakeLists.txt에 CMAKE_BUILD_TYPE이 비어 있어 -O2가 안 붙는다. 즉 계산 속도 약 4.9배 차이는 C++에 유리하게 부풀린 값이 아니라 하한이다.
  3. Gazebo GUI를 켠 채로 측정했다. turtlebot4_gz_bringup의 sim.launch.py가 gz_args를 하드코딩해서 -s(헤드리스)를 전달할 수 없다. 역시 모든 조건에 동일하다.
  4. 조건당 n = 5. “23%” 주장을 기각하고 파이썬 편차의 크기를 가늠하기에는 충분하지만, 정밀한 구간 추정에는 부족하다.
  5. C++ / 4 조건에서 로그에 찍힌 고유 스레드 ID가 4개가 아니라 4~7개였다. 실행기에 준 수보다 많다. 원인은 확인하지 않았고 속도 비교에는 영향이 없지만, 이 칸을 실행기 풀 크기로 읽으면 안 된다. C++ / 32는 다섯 번 모두 정확히 32였다.
Note재현 메모

원본 벤치마크의 C++ 패키지 실행 이름은 README에 적힌 benchmark_cpp가 아니라 ros2_multithread_benchmark다(package.xml 기준).

빌드가 ModuleNotFoundError: No module named 'catkin_pkg'로 실패하면 CMake의 FindPython3가 시스템 인터프리터 대신 다른 파이썬을 잡은 것이다. 내 경우 ~/.local/bin/python3.10이었다.

colcon build --symlink-install --cmake-args -DPython3_EXECUTABLE=/usr/bin/python3

빌드가 find_package 단계에서 깨지는 다른 사례는 ROS2 Build Issue에 모아두고 있다.

두 노드 모두 CSV를 현재 작업 디렉토리에 쓴다(benchmark_cpp.csv / benchmark_python.csv 하드코딩). 실행 전에 원하는 위치로 cd 할 것.

C++ 노드는 타임스탬프를 기본 정밀도 std::ofstream으로 쓰기 때문에 epoch 초가 1.78572e+09로 뭉개진다. 이 글의 모든 수치는 period / processing 컬럼과 행 순서로만 계산했고, C++ 타임스탬프 컬럼은 쓰지 않았다.

Copyright 2026, JungYeon Lee