AEGLOSLAB ROS 2 Jazzy 대전

제12부 실시간·성능

12.3 메모리 — 잠금·풀·무할당 경로

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

RT 커널을 설치하고 우선순위를 주고 코어를 격리했는데도 지터가 남는다면, 원인은 대개 메모리다.

이유는 단순하다. 동적 할당은 시간이 예측 불가능하다. new 한 줄이 보통은 수십 나노초에 끝나지만, 힙이 부족하면 커널에 메모리를 요청하고, 그 페이지가 아직 물리 메모리에 없으면 페이지 폴트가 나고, 스왑이 걸려 있으면 디스크를 읽는다. 평소 100 ns가 가끔 수 밀리초가 되는 것 — 이것이 실시간을 깨뜨린다.

배우는 것:

  • 왜 할당이 위험한가 — 비결정성의 원천.
  • 메모리 잠금 — mlockall과 프리폴트.
  • 힙 조율 — 커널에 돌려주지 않기.
  • 커스텀 할당자 — rclcpp의 지원.
  • 제로 카피와 대여 메시지 — 복사 자체를 없애기.
  • 숨은 할당 — 코드에서 찾기 어려운 것들.
  • 경계 있는 메시지 — 크기를 고정하기.
  • 검증 — 정말 할당이 없는지 확인.
  • 할당 후크 실측(12.3.12) — 구간별 실제 할당 횟수와, "무할당"이 어디까지 가능한가.

선수 지식: 12.1(실시간), 12.2(OS 설정), 1.3(직렬화), 1.4(발행 경로), 3.2(RTPS 전송).

정직: rclcpp의 할당자 지원·대여 메시지 API·제로 카피 조건은 배포판과 RMW 구현에 따라 다르다(확인 필요)(→ 170차 확인: 커스텀 할당자 API·대여 메시지의 고정/가변 크기 조건·TLSF 가용성을 Jazzy 기본 RMW(Fast DDS)에서 실측했다 — 12.3.4·12.3.5·12.3.7 실행 확인(170차) 참고. 다른 RMW 구현(Cyclone DDS 등)에서는 검증하지 않았다). 여기서는 개념과 전형적 형태를 다루며, 실제 지원 여부와 API는 문서·소스로 확인해야 한다. "무할당"을 주장하려면 반드시 검증(12.3.9)이 필요하다. 이 환경에서 검증하지 않았다(목차 3). 다만 할당 횟수는 직접 후킹해 측정했다(실행 확인 — 12.3.12).

12.3.2 왜 할당이 위험한가

아니오 아니오 예 예 아니오 new / malloc 힙에 여유 블록? 빠름 — 수십 ns brk / mmap 시스템 콜 물리 페이지 매핑됨? 페이지 폴트 스왑? ★ 디스크 I/O — 밀리초 페이지 할당 · 0 채우기
비결정성 원천영향
힙 조각화탐색 시간 변동
시스템 콜(brk·mmap)커널 진입
페이지 폴트큰 지연
스왑매우 큰 지연
할당자 내부 락다른 스레드와 경합
0 채우기크기 비례

견해: "평소엔 빠른데 가끔 느린" 것이 실시간에서 가장 불리한 성질이다. 항상 느리면 예산에 반영할 수 있지만, 가끔 느린 것은 최악값을 예측 불가로 만든다. 할당은 정확히 그런 성질을 갖는다.

견해: 해제(free·delete)도 마찬가지로 위험하다. 할당만 조심하고 해제를 간과하는 경우가 있는데, 해제도 락을 획득하고 병합을 하고 커널에 반환할 수 있다. RT 경로에서는 할당도 해제도 하지 않는 것이 원칙이다.

12.3.3 메모리 잠금과 프리폴트

두 단계로 페이지 폴트를 제거한다.

#include <sys/mman.h>
#include <malloc.h>

void configure_memory()
{
  // ① 현재·미래의 모든 페이지를 물리 메모리에 고정
  if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) {
    // 권한(memlock rlimit) 확인 — 12.2.5
    perror("mlockall");
  }

  // ② 힙을 커널에 반환하지 않도록
  mallopt(M_TRIM_THRESHOLD, -1);
  mallopt(M_MMAP_MAX, 0);        // 큰 할당도 mmap 대신 힙에서

  // ③ 힙 프리폴트 — 미리 크게 할당했다 해제 (풀에 남음)
  const size_t kPrefault = 100 * 1024 * 1024;
  void * p = malloc(kPrefault);
  if (p) {
    memset(p, 0, kPrefault);     // ★ 실제로 접근해야 페이지가 매핑된다
    free(p);                     // 힙에 남음 (M_TRIM_THRESHOLD 덕)
  }

  // ④ 스택 프리폴트
  const size_t kStack = 8 * 1024;
  unsigned char dummy[kStack];
  memset(dummy, 0, kStack);
}
단계목적
① mlockall스왑·페이지 아웃 방지
② mallopt힙을 커널에 반환하지 않음
③ 힙 프리폴트미리 접근해 페이지 매핑
④ 스택 프리폴트스택 페이지 매핑

함정: malloc만으로는 페이지가 매핑되지 않는다. Linux는 지연 할당(lazy allocation)을 하므로, 실제로 써야(memset) 물리 페이지가 할당된다. 프리폴트에서 이것을 빠뜨리면 아무 효과가 없다.

함정(170차 실측 발견): memset을 코드에 적어도, 결과를 아무도 읽지 않으면 컴파일러가 전부 지울 수 있다. mlockall 없이 malloc→memset→free만 있는 격리된 벤치마크를 GCC -O2로 컴파일하면, 그 시퀀스가 관찰 가능한 부작용이 전혀 없다고 판단해 호출 자체를 전부 제거한다(디스어셈블에서 malloc/memset/free 호출이 0개로 확인됨). 이 장의 실제 초기화 코드는 안전하다 — mlockall() 자신이 "미래의 모든 매핑에 영향을 준다"는 실제 부작용을 가지므로 컴파일러가 뒤따르는 프리폴트를 지우지 못한다(실측: 그대로 -O2로 컴파일해도 minflt delta=25695, -O0의 25697과 일치). 하지만 프리폴트 효과만 따로 검증하려고 mlockall 없이 벤치마크를 작성하면, 최적화가 조용히 그 벤치마크를 무효화해 "효과가 없다"는 잘못된 결론에 이를 수 있다. 아래 실행 확인(170차) 참고.

함정: mlockall은 memlock rlimit이 충분해야 한다(12.2.5). 권한이 없으면 실패하고, 반환값을 안 보면 잠긴 줄 알면서 안 잠긴 상태가 된다.

견해: 프리폴트 크기를 정하는 것이 어렵다. 너무 작으면 나중에 폴트가 나고, 너무 크면 메모리를 낭비한다. 실제로 쓰는 최대량을 측정해(RSS 추이 — 11.7.6) 여유를 더한 값이 실용적이다.

견해: MCL_FUTURE가 중요하다. 초기화 이후에 생기는 매핑(동적 로딩·스레드 스택)도 잠근다. 다만 잠글 수 없으면 할당이 실패하게 되므로, 메모리가 부족한 시스템에서는 주의해야 한다.

실행 확인(170차, Jazzy) — 프리폴트의 컴파일러 최적화 위험을 실제로 발견하고, 이 장의 실제 코드는 안전함을 확인했다. /proc/self/stat·getrusage로 페이지 폴트를 측정하려다가, mlockall 없이 malloc→memset(0x42)→free만 있는 격리된 테스트를 gcc -O2로 빌드하면 minflt 증가가 0임을 발견했다 — 디스어셈블(objdump)로 확인한 결과 malloc·memset·free 호출 자체 전체가 사라져 있었다. 같은 소스를 -O0(최적화 없음)으로 다시 빌드하니 정상적으로 minflt 증가 25,602(≈100MB/4KB)가 나왔다 — GCC의 "관찰 가능한 부작용 없는 할당-쓰기-해제 시퀀스 제거" 최적화였다. 이 장의 실제 configure_memory() 코드(mlockall+mallopt+프리폴트, 위 코드 블록 그대로)를 똑같이 -O2로 빌드해 재확인하니 minflt 증가 25,695(-O0의 25,697과 일치)로 정상 동작했다 — mlockall(MCL_FUTURE)가 이후 모든 매핑에 실제로 영향을 주는 부작용을 갖기 때문에 컴파일러가 그 뒤의 프리폴트를 안전하게 제거할 수 없었다. 결론: 책의 프리폴트 코드 자체는 안전하지만, mlockall 없이 프리폴트 단독 효과를 벤치마크하면 컴파일러가 그 벤치마크를 조용히 무효화할 수 있다. 원본: tools/gap_harness/gap170/mlockall_test/(test_malloc_zero.c·test_book_prefault.c·book_prefault_optimization_test.log).

초기화 순서:

프로세스 시작 mlockall · mallopt 프리폴트 ROS 초기화 · 노드 생성 여기서 할당이 일어남 (정상) 사전 예약 · 풀 생성 RT 우선순위 설정 (12.2.5) ★ 여기부터 무할당 경로

견해: "초기화 단계에서는 마음껏 할당하고, 그 이후로는 하지 않는다"가 실용적 원칙이다. 노드 생성·구독 설정·버퍼 예약을 모두 시작 시에 끝내면, 정상 운전 중에는 할당이 없다. 문제는 "정상 운전 중 할당"이 코드에 숨어 있는 것이다(12.3.6).

12.3.4 커스텀 할당자

rclcpp는 할당자를 주입받을 수 있다.

// 개념 — 확인 필요: 정확한 API·타입(→ 170차 확인: 아래 API 이름·네임스페이스 전부 정확했다 — 다만 AllocatorMemoryStrategy는 별도 include가 필요했다. 아래 실행 확인(170차) 참고)
using Alloc = MyPoolAllocator<void>;
auto alloc = std::make_shared<Alloc>();

rclcpp::PublisherOptionsWithAllocator<Alloc> pub_options;
pub_options.allocator = alloc;
auto pub = node->create_publisher<MsgT>("topic", qos, pub_options);

rclcpp::SubscriptionOptionsWithAllocator<Alloc> sub_options;
sub_options.allocator = alloc;
auto sub = node->create_subscription<MsgT>("topic", qos, cb, sub_options);

// 실행기에도
rclcpp::ExecutorOptions exec_options;
exec_options.memory_strategy =
  rclcpp::memory_strategies::allocator_memory_strategy::
    AllocatorMemoryStrategy<Alloc>::make_shared(alloc);
rclcpp::executors::SingleThreadedExecutor exec(exec_options);
할당자 종류특성
풀 할당자고정 크기 블록 — 결정적
TLSF실시간 지향 범용 할당자(확인 필요: 가용성)(→ 170차 확인: ros-jazzy-tlsf-cpp 패키지로 실재, 실행 예제까지 정상 동작 — 아래 실행 확인(170차) 참고)
스택 할당자프레임 단위
계측 할당자할당 발생을 탐지(12.3.9)

정직: "할당자를 주입하면 전 경로가 그 할당자를 쓴다"는 보장은 없다. rclcpp의 일부 경로, rcl·rmw·DDS 내부는 별도이며, 구현·버전에 따라 다르다(확인 필요). 그래서 검증이 필수다.

실행 확인(170차, Jazzy) — 커스텀 할당자 API를 실제 ament_cmake 패키지로 컴파일하고, TLSF의 가용성을 확인했다.

  • API 확인 — 이름·네임스페이스는 정확했지만 누락된 include가 있었다. PublisherOptionsWithAllocator+create_publisher, SubscriptionOptionsWithAllocator+create_subscription은 <rclcpp/rclcpp.hpp> 하나만으로 즉시 컴파일됐다. AllocatorMemoryStrategy는 처음엔 컴파일 실패했다(rclcpp::memory_strategies::allocator_memory_strategy has not been declared) — 네임스페이스 자체는 책과 정확히 일치했지만(rclcpp/strategies/allocator_memory_strategy.hpp에서 확인), umbrella 헤더가 이 헤더를 자동으로 끌어오지 않았다. 그 헤더를 명시적으로 #include하자 즉시 컴파일·실행 성공했다.
  • TLSF — 실재, 패키지·실행 예제 모두 확인. apt-cache search tlsf로 ros-jazzy-tlsf-cpp(rclcpp 호환 TLSF 래퍼)가 실재함을 확인하고 설치했다. /opt/ros/jazzy/lib/tlsf_cpp/tlsf_allocator_example 실행 파일이 함께 설치돼 있어 직접 실행하니 정상 기동했다("Intra-process pipeline is OFF.", EXIT 0).

컨테이너 gap170j. 원본: tools/gap_harness/gap170/loan_alloc_test/(test_custom_allocator.cpp·custom_alloc_result.log·build2.log·tlsf_example_run.log).

견해: 커스텀 할당자보다 "할당 자체를 없애는 것"이 우선이다. 할당자를 빠르게 만드는 것은 차선이고, RT 경로에서 아무것도 할당하지 않는 것이 최선이다. 할당자는 완전히 없앨 수 없는 부분에 대한 보완으로 본다.

12.3.5 제로 카피와 대여 메시지

복사 자체를 없애는 접근이다.

일반 경로 앱 메시지 생성 직렬화 (복사) 전송 버퍼 (복사) 수신 버퍼 (복사) 역직렬화 (복사) 제로 카피 공유 메모리 블록에 직접 작성 구독자가 같은 블록을 읽음
// 대여 메시지 (개념 — 확인 필요: API·조건)(→ 170차 확인: API 그대로 컴파일·실행 성공, 조건은 아래 실행 확인(170차) 참고)
auto loaned = pub->borrow_loaned_message();
loaned.get().data = 42;          // 미들웨어 소유 메모리에 직접 작성
pub->publish(std::move(loaned));
조건설명
고정 크기 메시지가변 길이(문자열·시퀀스) 불가(확인 필요)(→ 170차 확인: can_loan_messages()로 정량 확인 — 아래 실행 확인(170차) 참고)
RMW 지원구현에 따라 다름
공유 메모리 전송같은 기기 내
설정DDS 공유 메모리 활성화

정직: 제로 카피의 지원 범위·조건은 RMW 구현마다 다르다(확인 필요). 지원하지 않으면 대여 메시지 API가 내부적으로 일반 경로로 되돌아가 투명하게 동작하지만 이득은 없을 수 있다. 실제로 제로 카피가 일어나는지 측정으로 확인해야 한다.

실행 확인(170차, Jazzy, 기본 RMW Fast DDS) — can_loan_messages()로 고정/가변 크기의 대여 메시지 조건을 직접 확인했다. rclcpp::PublisherBase::can_loan_messages()를 고정 크기(std_msgs/Float64)와 가변 크기(std_msgs/Float64MultiArray, 시퀀스 필드 보유)에 각각 호출했다 — Float64(고정): can_loan_messages() = 1(참), borrow_loaned_message()·publish(std::move(loaned)) 둘 다 성공. Float64MultiArray(가변): can_loan_messages() = 0(거짓). 이 깔끔한 참/거짓 대비가 위 표의 "고정 크기 메시지만 대여 지원" 조건을 정량으로 확인한다. 다른 RMW 구현(Cyclone DDS 등)에서는 검증하지 않았다. 원본: tools/gap_harness/gap170/loan_alloc_test/(test_loan.cpp·loan_result.log).

노드 내 통신(2.7의 컴포넌트):

방식복사
인트라프로세스 통신unique_ptr 이동 시 복사 없음
프로세스 간 공유 메모리직렬화 없음
일반 네트워크전체 경로 복사
// 인트라프로세스 (2.7.5)
rclcpp::NodeOptions().use_intra_process_comms(true);

// unique_ptr로 발행해야 이동이 일어난다
auto msg = std::make_unique<MsgT>();
msg->data = 42;
pub->publish(std::move(msg));         // ★ 복사 없음

견해: 대용량 데이터(이미지·포인트클라우드)에서 제로 카피의 이득이 크다(7.8.5의 반복). 작은 메시지에서는 복사 비용이 작아 복잡도만 늘 수 있다. 크기와 빈도를 보고 판단하라.

함정: 인트라프로세스에서 shared_ptr로 발행하면 복사가 남을 수 있다. 구독자가 여럿이면 공유되지만, 단일 소비자 이동 최적화를 받으려면 unique_ptr 여야 한다(2.7.5). 발행 후 메시지를 재사용하려는 코드는 이 최적화를 못 받는다.

12.3.6 숨은 할당

가장 어려운 부분이다. 할당은 눈에 안 보이는 곳에 있다.

숨은 할당설명
std::string짧은 문자열 최적화 초과 시
std::vector·컨테이너push_back·resize
std::function캡처가 크면 힙
std::shared_ptr제어 블록(make_shared는 1회)
로깅포맷 문자열·버퍼(1.11)
예외던질 때
메시지 생성시퀀스·문자열 필드
iostream내부 버퍼
동적 다형성일부 구현
스레드 생성스택
// ✗ RT 콜백에서 하면 안 되는 것들
void rt_callback(const MsgT::SharedPtr msg)
{
  RCLCPP_INFO(logger, "value: %f", msg->data);      // ✗ 포맷·버퍼
  std::vector<double> tmp;                          // ✗ 할당
  tmp.push_back(msg->data);
  std::string s = "state_" + std::to_string(i);     // ✗ 할당
  auto v = compute();                               // ✗ 반환값이 컨테이너면
}

// ✓ 사전 예약·고정 크기
class RtNode {
  std::array<double, 64> buf_;         // 스택·고정
  std::vector<double> pre_;            // 생성자에서 reserve
  RtNode() { pre_.reserve(1024); }     // 초기화 시 1회
};

견해: RCLCPP_INFO를 RT 콜백에 넣지 말라. 로깅은 문자열 포맷·버퍼·I/O를 수반하며, 그중 무엇이 할당하는지 통제할 수 없다. RT 경로에서 상태를 알리려면 카운터를 증가시키고 비RT 스레드가 그것을 읽어 로깅하게 한다.

견해: realtime_tools의 실시간 퍼블리셔가 이 문제의 표준 해법이다(6.5.6). RT 스레드는 락프리 버퍼에 값을 넣기만 하고, 별도 비RT 스레드가 실제 발행·로깅을 한다. 경계를 명확히 하는 구조다.

함정: 라이브러리 함수 내부의 할당은 코드를 봐도 모른다. Eigen·PCL·표준 라이브러리 알고리즘이 내부에서 임시 버퍼를 만들 수 있다. 문서를 확인하거나, 검증(12.3.9)으로 탐지하는 수밖에 없다.

함정: 예외를 던지면 할당이 일어난다. RT 경로에서 예외를 던지지 않는 것이 원칙이며, 오류는 반환값·상태 코드로 다룬다.

12.3.7 경계 있는 메시지

메시지 크기가 고정이면 많은 것이 쉬워진다.

# 가변 — 할당 필요
float64[] values
string name

# 경계 있음 (bounded) — 최대 크기 지정
float64[<=100] values
string<=32 name

# 고정 크기 (fixed)
float64[100] values
종류특성
가변임의 크기 — 동적 할당
경계 있음최대 지정 — 상한 예측 가능
고정항상 같은 크기 — 제로 카피 가능(확인 필요)(→ 170차 확인: Float64(고정)에서 can_loan_messages()=1(참)로 확인 — 12.3.5 실행 확인(170차) 참고)

견해: RT 경로의 메시지는 고정 크기로 설계하라. 관절 상태·명령처럼 개수가 정해진 데이터는 float64[6] 같은 고정 배열이 자연스럽고, 할당도 제로 카피 제약도 해결된다. 1.2에서 메시지를 설계할 때 이 관점을 미리 반영하면 나중에 고치는 비용이 없다.

함정: 표준 메시지 대부분은 가변 크기다. sensor_msgs/JointState도 string[] name·float64[] position으로 가변이다. 표준 타입을 쓰면서 완전 무할당을 기대할 수 없다 — 커스텀 고정 크기 타입이 필요하다면 상호운용성(1.2.6)과의 절충이다.

견해: 이 절충이 실무의 핵심 판단이다. 고정 크기 커스텀 타입은 실시간에 유리하지만 생태계 도구(RViz2·rosbag2 분석·표준 노드)와의 호환을 잃는다. RT 경로만 커스텀 타입을 쓰고, 경계에서 표준 타입으로 변환하는 구조가 흔한 절충이다.

12.3.8 스택과 스레드

항목고려
스택 크기RT 스레드에 충분히·프리폴트
재귀 회피깊이 예측 불가
큰 지역 변수스택 오버플로
스레드 생성RT 중 금지 — 초기화 시에만
TLS초기화 시점 주의
// 스레드 스택 크기 지정 (개념)
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 1024 * 1024);
pthread_create(&tid, &attr, thread_fn, nullptr);

함정: RT 스레드를 운전 중에 만들지 말라. 스레드 생성은 스택 할당·커널 자료구조를 수반한다. 필요한 스레드는 초기화 단계에서 모두 생성하고, 이후에는 재사용한다.

12.3.9 검증 — 정말 무할당인가

주장하지 말고 확인하라.

방법설명
할당 후크malloc을 가로채 RT 구간의 호출을 탐지
계측 할당자rclcpp에 주입해 카운트
/proc/<pid>/stat 폴트 수minflt·majflt 증가 감시
지터 측정간접 증거(11.7.5)
정적 분석제한적
// 개념: RT 구간에서 할당이 일어나면 즉시 표시
// 이 방식이 실제로 동작하는 것을 확인했다 — 결과는 12.3.12
std::atomic<bool> in_rt_section{false};
std::atomic<size_t> rt_alloc_count{0};

void * operator new(size_t sz) {
  if (in_rt_section.load(std::memory_order_relaxed)) {
    rt_alloc_count.fetch_add(1, std::memory_order_relaxed);
  }
  return std::malloc(sz);
}
# 페이지 폴트 감시 — 정상 운전 중 major fault는 0이어야
watch -n1 "grep -E 'minflt|majflt' /proc/$(pgrep my_node)/stat || \
  awk '{print \"minflt=\"\$10\" majflt=\"\$12}' /proc/$(pgrep my_node)/stat"

견해: "운전 중 major fault가 0인가"가 실용적인 첫 지표다. mlockall이 제대로 됐다면 major fault는 없어야 한다. 증가한다면 잠금이 실패했거나 잠기지 않은 매핑이 있다는 뜻이다.

견해: 할당 탐지를 개발·시험 빌드에만 넣으라. 운영 빌드에 후킹을 남기면 그 자체가 오버헤드다. CI에서 "RT 구간 할당 0"을 시험으로 검증하면(4.10), 회귀를 막을 수 있다.

견해: 완전한 검증은 어렵다는 것을 인정하라. 라이브러리·DDS 내부까지 모두 확인하기는 현실적으로 어렵다. 측정된 지터가 요구를 만족하는지가 최종 판정 기준이며, 무할당은 그것을 달성하기 위한 수단이다.

12.3.12 할당 후크를 실제로 걸어 세 봤다 — 실측

12.3.9가 "주장하지 말고 확인하라"며 제시한 전역 operator new 후킹을 그대로 구현해, 구간별 할당 횟수를 셌다(실행 확인, Jazzy, -O2).

방법: operator new를 재정의해 플래그가 켜진 동안만 센다. 측정 구간은 각 500회 반복이고, 디스커피리·초기화가 끝난 뒤 예열을 거쳐 측정한다.

12.3.12.1 가장 중요한 결과 — spin_some 자유 함수를 RT 루프에 쓰지 말라

방식반복 1회당 new
rclcpp::spin_some(node)(자유 함수)143회
executor.spin_some()(지속 executor)9회
발행 + 지속 executor spin_some()7회
StaticSingleThreadedExecutor::spin_some()9회

자유 함수가 지속 executor보다 약 16배 할당한다. 이유는 단순하다 — rclcpp::spin_some(node)는 호출할 때마다 executor를 새로 만들고 버린다. 노드를 등록하고 wait set을 구성하는 비용이 매 호출에 붙는다.

함정: 예제 코드의 관용구가 그대로 RT 루프에 들어간다. 튜토리얼과 짧은 예제는 rclcpp::spin_some(node)를 쓰는데, 그것이 주기 루프 안으로 옮겨지는 순간 회당 140여 회의 할당이 생긴다. 제어 루프를 직접 실행한다면 executor를 한 번 만들어 들고 있으라.

// ❌ 주기 루프 안 — 매번 executor 생성
while (rclcpp::ok()) { rclcpp::spin_some(node); do_control(); }

// ✅ 한 번 만들고 재사용
rclcpp::executors::SingleThreadedExecutor exec;
exec.add_node(node);
while (rclcpp::ok()) { exec.spin_some(); do_control(); }

12.3.12.2 그래도 완전 무할당은 아니다

지속 executor로 바꿔도 회당 7~9회의 할당이 남는다. 즉 이 장이 말한 "초기화 이후로는 할당하지 않는다"는 원칙은 사용자 코드에 대한 것이지, rclcpp 실행 경로 전체에 대한 보장이 아니다.

정직하게 적으면 이렇다 — 기본 구성의 rclcpp로는 "할당 0"에 도달하지 못한다. 12.3.4의 커스텀 할당자·12.3.7의 경계 있는 메시지·2.6.5의 대여 메시지가 필요한 이유가 여기 있고, 그것들을 다 동원해도 DDS 내부까지는 이 후크로 보이지 않는다. 12.3.9의 마지막 견해("완전한 검증은 어렵다")가 실측으로 확인된 셈이다.

StaticSingleThreadedExecutor가 이 측정에서는 이득이 없었다(9회로 동일). 실시간 문맥에서 자주 권장되지만, 적어도 할당 횟수 축에서는 기본 executor와 같았다. 다만 다른 축에서는 이득이 있다 — 타이머 꼬리 지연이 세 executor 중 가장 좁았다(최대 +0.125 ms 대 Single의 +0.528 ms, 2.5.13.3).

12.3.12.3 발행 방식에 따른 할당 — 2.7과 이어진다

sensor_msgs/Imu(고정 크기) 200회 발행:

발행 방식회당 new
publish(std::move(unique_ptr))1회
publish(const&) — 메시지 재사용0회

const&로 재사용 발행하면 할당이 없다. make_unique가 매번 힙을 할당하기 때문에 unique_ptr 쪽이 1회 더 든다.

2.7.13과 나란히 놓고 읽어야 한다. 거기서는 unique_ptr 발행이 intra-process 무복사의 조건이었고, 여기서는 const& 재사용이 무할당의 조건이다. 둘은 동시에 만족되지 않는다.

목표발행 방식대가
같은 프로세스 안 무복사unique_ptr회당 할당 1회
RT 루프의 무할당const& 재사용복사 발생(2.7.13.2)

선택 기준은 "무엇이 병목인가"다. 6 MB 이미지를 같은 프로세스에서 넘긴다면 복사가 압도적이므로 unique_ptr이고, 100 byte 명령을 1 kHz로 내보내는 제어 루프라면 할당의 지터가 문제이므로 재사용이다.

12.3.12.4 초기화 구간의 규모

구간new 횟수
노드 1개 + 발행자 1개 + 구독자 1개 생성13,088회(약 4.4 MB)

"초기화 단계에서는 마음껏 할당한다"의 실제 규모가 이 정도다. 12.3.3의 mlockall + 프리폴트가 왜 초기화 직후에 와야 하는지가 여기서 분명해진다 — 그 전에 잠그면 이 4 MB가 폴트로 되돌아온다.

12.3.12.5 이 후크를 CI에 넣는 법

12.3.9의 견해("CI에서 'RT 구간 할당 0'을 시험으로")는 위 숫자를 알고 나면 목표를 바꿔야 한다 — 0은 불가능하므로 회귀 감시로 쓴다.

// 시험: 정상 운전 구간의 회당 할당이 기준선을 넘지 않는가
counting = true;
for (int i = 0; i < 1000; ++i) exec.spin_some();
counting = false;
EXPECT_LE(n_new / 1000.0, BASELINE_PER_SPIN);   // 예: 12

절대값이 아니라 기준선 대비로 보라. rclcpp 버전이 바뀌면 기준선도 바뀐다. 중요한 것은 "우리가 뭔가를 넣었더니 회당 할당이 9에서 40이 됐다"를 검출하는 것이다.

12.3.12.6 확인하지 못한 것

  • DDS 내부의 할당은 이 후크에 포착되지만 구분되지 않는다 — 위 숫자에는 Fast DDS가 하는 할당도 섞여 있다. 층별 분해는 하지 않았다(확인 필요).
  • mlockall 적용 전후의 페이지 폴트 수는 측정하지 않았다(확인 필요) — 컨테이너에서 RLIMIT_MEMLOCK 조정이 필요하다.
  • 커스텀 할당자(12.3.4)를 주입했을 때 이 숫자가 어떻게 변하는지는 측정하지 않았다(확인 필요).

12.3.10 함정 카탈로그

#함정증상대처
1프리폴트에서 memset 누락효과 없음실제로 접근하기
2mlockall 실패 미검사잠긴 줄 알지만 아님반환값·rlimit
3해제만 간과지터해제도 금지
4RT 콜백에서 로깅할당·I/O카운터·비RT 스레드
5std::string 조립할당고정 버퍼
6컨테이너 push_back재할당reserve
7라이브러리 내부 할당보이지 않음검증으로 탐지
8예외 던지기할당반환값
9운전 중 스레드 생성큰 지연초기화 시
10할당자 주입으로 전 경로 가정미보장검증
11제로 카피 미확인이득 없음측정
12shared_ptr 발행복사 잔존unique_ptr
13표준 가변 메시지로 무할당 기대불가고정 타입·절충
14major fault 미감시문제 방치상시 확인
15검증 후킹을 운영에오버헤드시험 빌드만
16RT 루프에 rclcpp::spin_some(node)회당 143회 할당(executor 매번 생성)executor를 들고 있으라(12.3.12.1)
17"초기화 뒤 무할당"을 rclcpp 전체로 기대회당 7~9회는 남는다기준선 대비 회귀 감시(12.3.12.2)
18mlockall 없이 프리폴트 단독 벤치마크(170차 실측 발견)컴파일러가 malloc/memset/free 전체를 제거부작용 있는 호출(mlockall 등)과 함께 두거나 -O0으로 검증

12.3.11 체크포인트

  • 동적 할당이 비결정적인 여섯 가지 이유를 말할 수 있는가.
  • "평소엔 빠른데 가끔 느린" 성질이 왜 실시간에서 최악인가.
  • 해제도 위험한 이유는?
  • 메모리 설정 네 단계와 각각의 목적을 말할 수 있는가.
  • 프리폴트에서 memset이 필요한 이유는?
  • MCL_FUTURE의 역할과, 그 위험은?
  • "초기화에서는 할당하고 이후로는 안 한다"는 원칙과, 그 실무적 어려움은?
  • 커스텀 할당자 네 종류와, "할당자보다 할당 제거가 우선"인 이유는?
  • 대여 메시지의 조건 네 가지와, 지원되지 않을 때 무슨 일이 일어나는가.
  • 인트라프로세스에서 unique_ptr을 써야 하는 이유는?
  • 숨은 할당 열 가지를 들 수 있는가. RT 콜백에서 로깅을 금하는 이유와 대안은?
  • 메시지 크기 세 종류와, 표준 메시지로 무할당이 불가능한 이유는?
  • 고정 크기 커스텀 타입의 절충과, 흔한 해결 구조는?
  • 무할당 검증 방법 다섯 가지와, "실용적인 첫 지표"는 무엇인가.
  • 완전한 검증이 어렵다는 것을 인정할 때, 최종 판정 기준은 무엇이 되는가.
  • rclcpp::spin_some(node) 자유 함수와 지속 executor의 회당 할당은 각각 몇 회인가. 왜 다른가(12.3.12.1).
  • "무복사"와 "무할당"이 왜 동시에 만족되지 않는가(12.3.12.3).

다음 장 12.4는 이 모든 조치를 설계로 묶는다 — 종단 지연 예산을 세우고 배분하는 방법.