AEGLOSLAB ROS 2 Jazzy 대전

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

2.4 콜백 그룹·동시성·재진입

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

MultiThreadedExecutor를 쓰기로 했다면, 동시성을 실제로 결정하는 것은 executor가 아니라 콜백 그룹이다. executor는 "스레드를 몇 개 쓸 수 있는가"를 정하고, 콜백 그룹은 "무엇이 무엇과 동시에 실행돼도 되는가"를 정한다. 이 둘을 분리해 이해하지 못하면 멀티스레드 도입은 데드락 아니면 경쟁 조건으로 끝난다.

배우는 것:

  • 두 종류의 그룹 — MutuallyExclusive와 Reentrant의 정확한 보장.
  • 기본 그룹의 함정 — 아무것도 설정하지 않으면 어떻게 되는가.
  • 그룹 할당 규칙 — 엔티티가 어느 그룹에 들어가는가.
  • 동시성 매트릭스 — 어떤 조합이 동시에 실행될 수 있는가.
  • 데드락 해소의 정확한 처방 — 1.7.4의 ②를 코드로.
  • 실전 패턴 — 조정 노드·드라이버·액션 서버의 전형적 그룹 구성.
  • 그룹이 만드는 새로운 위험 — 재진입 콜백의 상태 관리.
  • 매트릭스·데드락 재현(2.4.12) — 실제 겹침 측정과, 처방에 빠져 있던 다섯째 조건.
  • 스레드 수와 호출 깊이(2.4.13) — 실제 임계값과, 그룹 수명이라는 조용한 함정.

선수 지식: 2.3(executor 종류), 1.7(데드락), 2.2(실행 루프), 1.8(액션 서버). 스레드 안전 일반론은 2.9에서 이어받는다.

정직: 콜백 그룹의 의미론은 rclcpp 설계에 근거하며 배포판 간 안정적이다. 다만 executor가 그룹 정책을 적용하는 세부(스레드 배정 방식·기아 방지)는 구현 의존이며 (확인 필요)로 표시했다. 이 환경에서 실행 검증하지 않았다(목차 3). 다만 동시성 매트릭스와 데드락 구성은 직접 재현했다(실행 확인 — 2.4.12).

2.4.2 두 종류의 그룹

auto me_group = create_callback_group(
    rclcpp::CallbackGroupType::MutuallyExclusive);
auto re_group = create_callback_group(
    rclcpp::CallbackGroupType::Reentrant);
종류보장의미
MutuallyExclusive이 그룹의 콜백은 동시에 하나만 실행된다그룹 안에서는 순차 — 락이 필요 없다
Reentrant이 그룹의 콜백은 제한 없이 동시 실행 가능같은 콜백이 여러 개 동시에 실행될 수도 있다

Reentrant의 "같은 콜백도 동시에"가 핵심이자 위험이다. 구독 콜백을 Reentrant 그룹에 넣으면, 메시지 2개가 빠르게 도착했을 때 같은 콜백 함수가 두 스레드에서 동시에 실행될 수 있다. 그 콜백이 멤버 변수를 조작한다면 즉시 경쟁 조건이다.

MutuallyExclusive 그룹 Reentrant 그룹 동시 불가 동시 가능 동시 가능 콜백 A 콜백 B 콜백 C (인스턴스 1) 콜백 C (인스턴스 2) 콜백 D

서로 다른 그룹의 콜백은 항상 동시 실행 가능하다(스레드가 있다면). 이것이 그룹을 나누는 이유다.

2.4.3 기본 그룹 — 아무것도 안 하면 어떻게 되나

모든 노드는 기본 콜백 그룹 하나를 갖고, 그것은 MutuallyExclusive다. 그룹을 지정하지 않고 만든 엔티티는 전부 여기로 들어간다.

// 그룹 미지정 → 전부 노드의 기본 MutuallyExclusive 그룹
sub_a_ = create_subscription<A>("a", 10, cb_a);
sub_b_ = create_subscription<B>("b", 10, cb_b);
timer_ = create_wall_timer(100ms, cb_timer);

결과: MultiThreadedExecutor를 써도 이 셋은 동시에 실행되지 않는다. 스레드가 8개여도 하나만 일한다.

MultiThreadedExecutor 스레드 8개 노드의 기본 그룹 MutuallyExclusive sub_a · sub_b · timer 전부 여기 동시에 하나만 실행 = 사실상 단일 스레드

함정: 이것이 "멀티스레드로 바꿨는데 변화가 없다"의 정확한 원인이다(2.3.4). executor 교체는 필요조건일 뿐이고, 그룹 분리가 충분조건이다.

노드마다 기본 그룹이 따로다. 한 executor에 노드 A·B를 넣으면 A의 콜백과 B의 콜백은 서로 다른 기본 그룹에 속하므로 동시 실행이 가능하다. 노드 단위로는 병렬성이 있고, 노드 안에서는 없는 셈이다.

2.4.4 그룹 할당 방법

class MyNode : public rclcpp::Node
{
public:
  MyNode() : Node("my_node")
  {
    cb_group_sensor_ = create_callback_group(
        rclcpp::CallbackGroupType::MutuallyExclusive);
    cb_group_service_ = create_callback_group(
        rclcpp::CallbackGroupType::Reentrant);

    // 구독 — SubscriptionOptions로 지정
    rclcpp::SubscriptionOptions sub_opt;
    sub_opt.callback_group = cb_group_sensor_;
    sub_ = create_subscription<sensor_msgs::msg::LaserScan>(
        "scan", rclcpp::SensorDataQoS(), cb, sub_opt);

    // 타이머 — 마지막 인자
    timer_ = create_wall_timer(100ms, cb_timer, cb_group_sensor_);

    // 서비스 서버 — 마지막 인자
    srv_ = create_service<std_srvs::srv::Trigger>(
        "trigger", handle, rclcpp::ServicesQoS(), cb_group_service_);

    // 서비스 클라이언트 — 마지막 인자
    client_ = create_client<std_srvs::srv::Trigger>(
        "other", rclcpp::ServicesQoS(), cb_group_service_);

    // 액션 서버 — options 구조체
    action_server_ = rclcpp_action::create_server<Calibrate>(
        this, "calibrate", handle_goal, handle_cancel, handle_accepted,
        rcl_action_server_get_default_options(), cb_group_service_);
  }
private:
  rclcpp::CallbackGroup::SharedPtr cb_group_sensor_, cb_group_service_;
};

API가 엔티티마다 다르다 — 구독은 options 구조체, 타이머·서비스는 마지막 인자. 일관성이 없어 자주 혼동을 일으킨다. 컴파일은 되지만 그룹이 적용되지 않는 실수는 없다(인자 위치가 틀리면 컴파일 오류) — 다만 지정을 빠뜨리면 조용히 기본 그룹으로 간다.

견해: 그룹 멤버 변수를 노드 상단에 모아 선언하고, 각 엔티티 생성 시 반드시 명시하는 습관을 들이면 누락이 줄어든다. "기본 그룹에 들어가도 되는 엔티티"를 의식적으로 결정하는 것이 좋다.

정정(90차 실행 확인) — 이 절의 예시는 원래 rmw_qos_profile_services_default를 넘겼는데, Jazzy에서 그 오버로드는 [[deprecated]]다. 컴파일러가 직접 말한다: "use rclcpp::QoS instead of rmw_qos_profile_t". 그래서 rclcpp::ServicesQoS()로 바꿨다(값은 같다 — 1.6.11.1). 같은 형태가 1.7에도 한 곳 있어 함께 고쳤다.

2.4.5 동시성 매트릭스

콜백 X와 Y가 동시에 실행될 수 있는가?

X의 그룹Y의 그룹동시 실행
기본(ME)같은 기본(ME)❌
ME 그룹 G1같은 G1❌
ME 그룹 G1다른 ME 그룹 G2✅
ME 그룹 G1Reentrant 그룹 R✅
Reentrant R같은 R (다른 콜백)✅
Reentrant R같은 R, 같은 콜백✅ 주의
노드 A의 기본노드 B의 기본✅

단, 위 ✅는 전부 "스레드가 남아 있다면"이 전제다. MultiThreadedExecutor의 스레드가 전부 사용 중이면 동시 실행되지 않고 대기한다. SingleThreadedExecutor에서는 그룹이 무엇이든 전부 순차다 — 그룹은 멀티스레드 executor에서만 의미가 있다.

2.4.6 데드락 해소 — 정확한 처방

1.7.4의 해법 ②를 여기서 완성한다. 콜백 안에서 서비스를 동기 호출해도 안전한 구성:

class Orchestrator : public rclcpp::Node
{
public:
  Orchestrator() : Node("orchestrator")
  {
    // ① 타이머(호출하는 쪽)는 자기 그룹에
    timer_group_ = create_callback_group(
        rclcpp::CallbackGroupType::MutuallyExclusive);
    // ② 클라이언트(응답 받는 쪽)는 다른 그룹에
    client_group_ = create_callback_group(
        rclcpp::CallbackGroupType::MutuallyExclusive);

    client_ = create_client<std_srvs::srv::Trigger>(
        "target", rclcpp::ServicesQoS(), client_group_);
    timer_ = create_wall_timer(
        1s, std::bind(&Orchestrator::on_timer, this), timer_group_);
  }

private:
  void on_timer()
  {
    auto req = std::make_shared<std_srvs::srv::Trigger::Request>();
    auto future = client_->async_send_request(req);

    // ✅ 안전: 응답 콜백은 client_group_에서 다른 스레드가 처리한다
    auto status = future.wait_for(std::chrono::seconds(3));
    if (status == std::future_status::ready) {
      auto res = future.get();
      RCLCPP_INFO(get_logger(), "결과: %s", res->message.c_str());
    } else {
      RCLCPP_WARN(get_logger(), "타임아웃");
    }
  }
};

// main
rclcpp::executors::MultiThreadedExecutor exec;   // ★ 필수
exec.add_node(node);
exec.spin();

성립 조건 네 가지 — 하나라도 빠지면 멈춘다:

  1. MultiThreadedExecutor를 쓴다.
  2. 호출하는 콜백과 클라이언트가 서로 다른 그룹에 있다.
  3. 두 그룹 중 최소한 클라이언트 쪽이 다른 스레드를 얻을 수 있다(스레드 수 여유).
  4. 타임아웃이 있다(무한 대기 금지).
  5. 응답을 만들어 내는 쪽도 막힌 그룹 밖에 있다 — 서버가 다른 프로세스면 자동으로 만족되지만, 같은 executor 안이면 서버도 따로 빼야 한다(실행 확인, 2.4.12.4).
future를 완료시킴 스레드 1 timer_group on_timer 실행 중 future 대기 스레드 2 client_group 응답 콜백 실행

함정: Reentrant를 쓰면 더 안전할 것 같지만 그렇지 않다. 문제는 재진입이 아니라 그룹 분리다. 위 예에서 두 그룹 모두 MutuallyExclusive여도 정상 동작한다. Reentrant는 "같은 그룹의 콜백이 동시에 여러 개 필요할 때" 쓰는 것이지, 데드락 해소의 필수 요소가 아니다.

연쇄 호출 시 스레드 수: 언뜻 보면 "호출 깊이만큼 동시에 블로킹되니 그만큼 스레드가 필요하다"고 생각하기 쉽다. 실측은 그렇지 않았다 — 깊이 1~6 전 구간에서 3개면 충분했다(2.4.13.1). 확실한 것은 1개면 반드시 멈춘다는 것이며, 깊이에 맞춰 늘리지 말고 측정해서 정하라.

2.4.7 실전 패턴

2.4.7.1 조정(orchestration) 노드

여러 서비스·액션을 순차 호출하는 노드.

엔티티그룹이유
상태 기계 타이머ME 그룹 A순차 실행 보장
서비스 클라이언트들ME 그룹 BA와 분리
액션 클라이언트들ME 그룹 B 또는 C동일
상태 조회 서비스 서버ME 그룹 D언제든 응답 가능하게

2.4.7.2 센서 드라이버

엔티티그룹이유
하드웨어 읽기 타이머ME 그룹 A(전용)다른 것에 막히면 안 됨
파라미터 콜백기본드묾
진단 타이머ME 그룹 BA를 막지 않게
설정 변경 서비스ME 그룹 B동일

핵심은 하드웨어 읽기 타이머를 격리하는 것이다. 이 타이머가 지연되면 데이터가 유실되거나 하드웨어 버퍼가 넘친다.

2.4.7.3 액션 서버

1.8.4에서 "세 콜백은 즉시 반환해야 한다"고 했는데, 콜백 그룹으로 대안 구성이 가능하다.

구성설명
A. 스레드 분리handle_accepted에서 워커 스레드로. executor 구성 무관
B. Reentrant 그룹 + MT executorexecute를 콜백 안에서 직접 실행해도 다른 콜백(취소 등)이 처리됨

B의 위험: execute가 실행되는 동안 그 그룹의 스레드 하나가 계속 점유된다. 동시 목표가 여럿이면 스레드가 고갈된다. 그리고 Reentrant이므로 같은 액션 서버의 콜백들이 실제로 동시에 실행되어 상태 관리가 어려워진다.

견해: A(스레드 분리)가 executor 구성에 의존하지 않아 더 견고하다. B는 executor 설정이 바뀌면(예: 누군가 컨테이너를 component_container로 바꾸면) 조용히 깨진다. 라이브러리·재사용 컴포넌트라면 A를 택하라 — 사용자가 어떤 executor를 쓸지 알 수 없기 때문이다.

2.4.7.4 처리 파이프라인

엔티티그룹이유
입력 구독(고빈도)Reentrant여러 프레임 병렬 처리
상태·설정 구독ME 기본순차
결과 발행(발행은 그룹 무관)—

Reentrant 입력 구독은 강력하지만 조건이 있다: 콜백이 상태를 공유하지 않아야 한다. 순수 함수처럼 "입력 → 출력"만 하면 안전하다. 공유 상태가 있으면 락이 필요하고, 락을 획득하면 병렬성 이득이 사라진다.

2.4.8 그룹이 만드는 새로운 위험

① 재진입 콜백의 상태 오염

// ☠️ Reentrant 그룹의 콜백이 멤버를 조작한다
void on_image(Image::UniquePtr msg) {
  buffer_ = std::move(msg);       // 두 스레드가 동시에 쓰면?
  process(buffer_);               // 다른 스레드가 바꾼 것을 볼 수도
}

② 순서 보장 상실

Reentrant 구독은 메시지 순서를 보장하지 않는다. 프레임 1과 2가 동시에 처리되면 2가 먼저 끝날 수 있다. 순서가 중요한 데이터(적분·상태 갱신)에는 쓸 수 없다.

③ 락 순서 역전

그룹 A의 콜백이 락 L1→L2 순으로 획득하고, 그룹 B의 콜백이 L2→L1 순으로 획득하면 고전적 교착이다. 콜백 그룹을 나눈다는 것은 이런 문제를 열어 두는 것이다.

④ 콜백 안에서 락을 쥔 채 서비스 호출

// ☠️ 락을 쥔 채로 응답을 기다리면, 응답 콜백이 같은 락을 원할 때 교착
std::lock_guard<std::mutex> lk(mtx_);
auto f = client_->async_send_request(req);
f.wait();      // 응답 콜백이 mtx_를 획득하려 하면 끝

견해: "락을 쥔 채로 대기하지 않는다" 를 절대 규칙으로 두라. 데이터를 복사해 락을 풀고, 그 다음에 대기한다. 이 규칙 하나가 멀티스레드 executor 관련 교착의 상당 부분을 예방한다.

2.4.9 진단 — 무엇이 어느 그룹에 있는지 확인하기

ROS 2는 콜백 그룹 구성을 조회하는 CLI를 제공하지 않는다. 코드가 정본이다. 그래서 다음 습관이 필요하다.

// 초기화 로그에 그룹 구성을 남긴다
RCLCPP_INFO(get_logger(),
  "콜백 그룹: sensor=ME, service=ME, image=Reentrant / executor=MultiThreaded(%zu)",
  thread_count);

동작 확인:

# 스레드가 실제로 여러 개 실행되는가
ps -T -p $(pgrep -f my_node)
top -H -p $(pgrep -f my_node)     # 여러 스레드에 CPU가 분산되는가

한 스레드에만 CPU가 몰려 있다면 그룹 분리가 안 됐거나 병목이 다른 곳이다.

정밀 진단: ros2_tracing으로 콜백 실행 시각과 스레드 ID를 함께 기록하면 어떤 콜백이 어느 스레드에서 언제 실행됐는지 정확히 볼 수 있다(11.6).

2.4.12 동시성 매트릭스와 데드락을 실제로 재현했다 — 실측

2.4.5의 매트릭스와 2.4.6의 처방은 구현 근거로 쓴 것이다. 콜백마다 진입·이탈 시각과 스레드 ID를 기록해 실제로 겹치는지 확인했다(실행 확인, Jazzy, MultiThreadedExecutor 스레드 4개).

방법: 각 콜백이 20 ms를 소모하게 하고 2.5초 실행한 뒤, 구간이 겹치는 쌍을 세고 최대 동시 실행 수를 구했다.

2.4.12.1 매트릭스 일곱 행이 그대로 재현됐다

구성매트릭스최대 동시A↔B 겹침A↔A 겹침
기본(ME) 타이머 2개❌1아니오아니오
같은 ME 그룹 2개❌1아니오아니오
다른 ME 그룹(G1·G2)✅2예아니오
ME 그룹 · Reentrant 그룹✅2예아니오
같은 Reentrant, 다른 콜백✅2예아니오
같은 Reentrant, 같은 콜백✅ 주의4—예
(매트릭스 밖) 같은 ME, 콜백 > 주기—1—아니오

일곱 행 모두 예상대로였다. 특히 마지막 두 줄을 나란히 보면 Reentrant의 성질이 분명해진다.

2.4.12.2 Reentrant는 처리량을 4배로 만들고, 그만큼 위험하다

주기 10 ms · 콜백 40 ms(주기의 4배)로 같은 조건에서 그룹만 바꿨다.

그룹2.5초 동안 호출 수최대 동시
MutuallyExclusive63회1
Reentrant249회4

약 4배다 — 스레드 4개가 전부 같은 콜백을 동시에 실행했다. 2.4.2가 "같은 콜백이 여러 개 동시에 실행될 수도 있다"고 한 것이 숫자로 보인다.

이 4배에도 비용이 든다. 콜백이 멤버 변수를 조작한다면 네 개의 인스턴스가 같은 상태를 동시에 조작한다. MutuallyExclusive에서는 락이 필요 없었던 코드가 Reentrant로 바꾸는 순간 락 없이 경쟁 조건이 된다(2.9). 그룹 변경은 한 줄이지만 스레드 안전성 요구 전체가 바뀐다.

2.4.12.3 단일 스레드에서는 그룹이 정말 무의미하다

같은 구성을 SingleThreadedExecutor로 실행하면:

구성최대 동시사용 스레드
다른 ME 그룹11
같은 Reentrant, 다른 콜백11
같은 Reentrant, 같은 콜백11

전부 순차이고 호출 수도 MutuallyExclusive와 같다(63회). 2.4.5의 단서("그룹은 멀티스레드 executor에서만 의미가 있다")가 확인된다 — Reentrant로 바꿔도 단일 스레드에서는 아무 일도 일어나지 않는다.

2.4.12.4 데드락 — 처방에 조건이 하나 더 있다

2.4.6의 예제는 서비스 서버가 다른 프로세스에 있는 경우다. 서버가 같은 노드(=같은 executor)에 있으면 이야기가 달라진다. 일곱 구성을 측정해 봤다.

구성결과
타이머·클라이언트 둘 다 기본 그룹데드락
클라이언트만 별도 ME 그룹데드락
클라이언트만 Reentrant 그룹데드락
서버만 별도 ME 그룹데드락
서버만 Reentrant 그룹데드락
단일 스레드 executor데드락
서버·클라이언트 둘 다 각각 별도 그룹✅ 응답 받음

클라이언트만 빼는 것으로는 풀리지 않는다. 타이머가 기본 그룹을 쥐고 있으면 그 그룹에 있는 서비스 서버도 실행되지 못하므로 응답 자체가 생성되지 않는다. 클라이언트를 아무리 잘 배치해도 받을 것이 없다.

그래서 2.4.6의 네 조건에 다섯째를 더해야 한다.

  1. 응답을 만들어 내는 쪽까지 막힌 그룹 밖에 있어야 한다. 서버가 다른 프로세스면 자동으로 만족되지만, 같은 executor 안이면 서버도 따로 빼야 한다.

일반화하면 이렇다 — 기다림을 풀어 줄 콜백이 무엇이든, 그것이 실행될 수 있어야 한다. 클라이언트의 응답 처리만 보지 말고 대기를 끝내는 사슬 전체를 보라.

함정: 컴포지션이 이 조건을 조용히 깬다. 개발 중에는 서버와 클라이언트가 각자 프로세스라 잘 동작하던 코드가, 한 컴포넌트 컨테이너에 묶는 순간 executor를 공유하게 되어(1.13) 같은 노드에 있는 것과 같아진다. "배치를 바꿨더니 멈춘다"의 전형이며, 2.7.4의 const_cast 함정과 같은 계열의 위험이다 — 코드는 그대로인데 배치가 성질을 바꾼다.

2.4.12.5 확인하지 못한 것

  • 스레드 수를 줄여 가며 임계점을 찾지는 않았다(전부 4스레드)(확인 필요) — 2.4.6의 "호출 깊이 + 여유만큼 스레드가 필요하다"는 검증하지 않았다.
  • rclpy의 콜백 그룹 동작은 측정하지 않았다(확인 필요).

2.4.13 스레드 수와 호출 깊이 — 실측

2.4.6은 "호출 깊이 + 여유만큼 스레드가 필요하다"고 적었다. 연쇄 서비스 호출을 깊이 1~6으로 만들고 스레드 수를 줄여 가며 임계점을 찾았다(실행 확인, Jazzy).

구성: 타이머 → s1 → s2 → … → sD. 각 서버는 다음 서비스를 동기 호출하고 기다린다. 2.4.12.4의 조건을 만족하도록 모든 클라이언트·서버·타이머를 각각 다른 MutuallyExclusive 그룹에 뒀다.

2.4.13.1 결과 — 요구가 깊이에 비례하지 않는다

호출 깊이1스레드23456최소 스레드
1멈춤✅✅✅✅✅2
2멈춤✅✅✅✅✅2
3멈춤멈춤✅✅✅✅3
4멈춤멈춤✅✅✅✅3
5—멈춤✅✅✅—3
6—멈춤✅✅✅—3

깊이 3에서 3으로 오른 뒤 6까지 그대로다. "깊이 + 여유"라면 깊이 6에는 7개 안팎이 필요해야 하는데 3개로 충분했다.

정정한다 — 스레드 요구는 호출 깊이에 비례하지 않는다. 적어도 위 구성(모든 엔티티가 별도 그룹)에서는 깊이 1~6 전 구간에서 3개면 됐다.

이 결과에 이르기 전 ⌈D/2⌉+1이라는 공식을 세워 봤다가 깊이 5·6에서 반증됐다. 네 점에 맞춘 식이었고 두 점을 더 측정하자 무너졌다 — 적은 표본에 공식을 붙이지 않는다는 뜻으로 이 과정을 남긴다.

확실한 것 하나는 유지된다 — 스레드가 1개면 무조건 멈춘다. 깊이 1에서도 그렇다. 2.4.6의 첫째 조건(MultiThreadedExecutor를 쓴다)이 그것이다.

2.4.13.2 왜 비례하지 않는가 — 추정

블로킹된 콜백이 스레드를 점유하는 것은 맞지만, 그 스레드가 놀고 있지는 않다. rclcpp의 future.wait_for는 조건 변수 대기이고, executor의 다른 스레드가 그동안 응답 콜백을 처리한다. 응답이 한 단계씩 되돌아오면 블로킹이 순차적으로 풀리므로, 동시에 새로 필요한 스레드가 깊이만큼 늘지는 않는 것으로 보인다.

이것은 추정이다(확인 필요: 내부 메커니즘). 확인한 것은 표의 숫자까지이며, rclcpp 내부의 스레드 배분을 추적하지는 않았다. 실무 지침은 숫자로 충분하다 — 아래.

2.4.13.3 실무 지침

상황스레드 수
콜백 안에서 블로킹 대기가 전혀 없다1개(단일 executor)로 충분 — 오히려 꼬리 지연이 낫다(2.5.13.3)
블로킹 대기가 있다최소 2개, 안전하게 3~4개
연쇄 호출이 깊다깊이에 맞춰 늘릴 필요 없다 — 3~4개에서 시작해 측정해 보라

견해: 스레드를 넉넉히 주는 것이 비용이 없다고 생각하지 말라. 2.5.13.3에서 측정한 대로 MultiThreadedExecutor는 스레드가 많을수록 타이머 꼬리 지연이 악화된다(4스레드에서 최대 +1.161 ms 대 단일 +0.528 ms). 필요한 만큼만 주고, 그 수를 실측으로 정하라.

2.4.13.4 실험 중 만난 함정 — 콜백 그룹을 보관하지 않으면 콜백이 영영 실행되지 않는다

이 실험은 처음에 모든 구성에서 실패했다. 깊이 1·스레드 8에서도 서버 콜백이 한 번도 실행되지 않았다.

원인은 콜백 그룹의 수명이었다.

// ❌ 그룹을 인라인으로 만들고 버린다 — 콜백이 영영 실행되지 않는다
auto cli = node->create_client<Trig>("s",
    rclcpp::ServicesQoS(),          // .get_rmw_qos_profile() 은 폐기 예정 오버로드를 부른다
    node->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive));

// ✅ 그룹을 멤버로 보관한다
group_ = node->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);
auto cli = node->create_client<Trig>("s", qos, group_);

노드는 콜백 그룹을 약한 참조로만 들고 있다. 사용자가 shared_ptr를 놓아 버리면 그룹이 사라지고, executor가 그 그룹의 엔티티를 스케줄하지 않는다.

오류도 경고도 없다. 서비스는 wait_for_service()에서 정상적으로 발견되고, 요청도 나간다. 다만 서버 콜백이 영원히 실행되지 않는다. 겉보기 증상은 "서비스가 응답하지 않는다"이므로 QoS·이름·네트워크를 먼저 의심하게 되는 최악의 형태다.

2.4.4(그룹 할당 방법)의 예제가 그룹을 멤버 변수에 두는 이유가 이것이다 — 스타일이 아니라 필수다.

2.4.10 함정 카탈로그

#함정증상대처
1그룹 미지정 + MT executor병렬화 안 됨명시적 그룹
2SingleThreaded에서 그룹 설정효과 없음executor 확인
3데드락 해소에 Reentrant 사용 시도여전히 멈춤문제는 그룹 분리(2.4.6)
4스레드 수 < 호출 깊이연쇄 호출에서 교착깊이 + 여유만큼
5Reentrant 콜백이 멤버 변수 사용경쟁 조건상태 없는 콜백 또는 락
6Reentrant로 순서 의존 처리순서 뒤바뀜ME 그룹 사용
7락 순서 불일치교착락 순서 규약
8락 쥔 채 서비스 대기교착복사 후 해제
9라이브러리가 executor 구성 가정사용자 환경에서 깨짐스레드 분리 방식(2.4.7.3)
10하드웨어 타이머를 공용 그룹에데이터 유실전용 그룹
11그룹 구성이 코드에만 존재파악·리뷰 어려움초기화 로그·문서화
12그룹 분리 후 측정 안 함효과 불명top -H·트레이싱
13서버가 같은 executor인데 클라이언트만 분리여전히 데드락응답을 만드는 쪽도 빼라(2.4.12.4)
14컴포지션으로 묶은 뒤 서비스 호출이 멈춤executor 공유로 같은 노드가 됨그룹을 다시 설계(2.4.12.4)
15Reentrant로 바꾸고 락을 안 넣음같은 콜백 4개가 상태 동시 접근스레드 안전성 요구가 바뀐다(2.4.12.2)
16콜백 그룹을 인라인 생성 후 안 보관콜백이 영영 안 돎(오류 없음)멤버로 보관(2.4.13.4)
17연쇄 호출 깊이만큼 스레드를 늘림불필요 — 깊이 6도 3개면 됐다실측으로 정하라(2.4.13.1)

2.4.11 체크포인트

  • 두 그룹 종류의 보장을 정확히 진술할 수 있는가. Reentrant의 "같은 콜백도 동시에"가 왜 위험한가.
  • 그룹을 지정하지 않은 엔티티는 어디로 가는가. 그것이 멀티스레드 executor에서 어떤 결과를 낳는가.
  • 노드 A의 콜백과 노드 B의 콜백은 동시에 실행될 수 있는가. 왜인가.
  • 동시성 매트릭스를 재구성할 수 있는가. 모든 ✅에 붙는 전제는 무엇인가.
  • 콜백 안에서 서비스를 동기 호출해도 안전하려면 갖춰야 할 네 조건은 무엇인가.
  • 데드락 해소에 Reentrant가 필수가 아닌 이유를 설명할 수 있는가.
  • 서비스를 3단계로 연쇄 호출하면 스레드가 몇 개 필요한가.
  • 센서 드라이버에서 하드웨어 읽기 타이머를 전용 그룹에 두는 이유는?
  • 액션 서버의 실행 분리에서 스레드 방식이 그룹 방식보다 견고한 이유는? 라이브러리라면 어느 쪽인가.
  • Reentrant 구독이 쓸 수 없는 데이터 종류는 무엇인가. 왜인가.
  • "락을 쥔 채로 대기하지 않는다"가 왜 절대 규칙인가.
  • 그룹 분리가 실제로 동작하는지 확인하는 방법 두 가지를 말할 수 있는가.
  • 같은 Reentrant 그룹의 같은 콜백은 최대 몇 개까지 동시에 실행됐는가. 그 대가는(2.4.12.2).
  • 클라이언트만 별도 그룹으로 빼도 데드락이 남는 경우는 언제이며, 왜 그런가(2.4.12.4).
  • 컴포지션이 왜 데드락 조건을 조용히 깨뜨리는가(2.4.12.4).
  • 연쇄 호출 깊이가 6일 때 필요한 최소 스레드는 몇 개였는가. "깊이 + 여유"와 어떻게 다른가(2.4.13.1).
  • 콜백 그룹을 보관하지 않으면 무슨 일이 생기며, 증상이 왜 오해를 부르는가(2.4.13.4).

다음 장 2.5는 executor가 다루는 엔티티 중 가장 미묘한 것 — 타이머의 정밀도와 드리프트를 분석한다. 주기가 왜 정확하지 않은지, 지연된 주기를 따라잡는지 버리는지, 그리고 제어 루프에 어떤 의미인지.