제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개가 빠르게 도착했을 때 같은 콜백 함수가 두 스레드에서 동시에 실행될 수 있다. 그 콜백이 멤버 변수를 조작한다면 즉시 경쟁 조건이다.
서로 다른 그룹의 콜백은 항상 동시 실행 가능하다(스레드가 있다면). 이것이 그룹을 나누는 이유다.
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개여도 하나만 일한다.
함정: 이것이 "멀티스레드로 바꿨는데 변화가 없다"의 정확한 원인이다(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 그룹 G1 | Reentrant 그룹 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();
성립 조건 네 가지 — 하나라도 빠지면 멈춘다:
MultiThreadedExecutor를 쓴다.- 호출하는 콜백과 클라이언트가 서로 다른 그룹에 있다.
- 두 그룹 중 최소한 클라이언트 쪽이 다른 스레드를 얻을 수 있다(스레드 수 여유).
- 타임아웃이 있다(무한 대기 금지).
- 응답을 만들어 내는 쪽도 막힌 그룹 밖에 있다 — 서버가 다른 프로세스면 자동으로 만족되지만, 같은 executor 안이면 서버도 따로 빼야 한다(실행 확인, 2.4.12.4).
함정:
Reentrant를 쓰면 더 안전할 것 같지만 그렇지 않다. 문제는 재진입이 아니라 그룹 분리다. 위 예에서 두 그룹 모두MutuallyExclusive여도 정상 동작한다.Reentrant는 "같은 그룹의 콜백이 동시에 여러 개 필요할 때" 쓰는 것이지, 데드락 해소의 필수 요소가 아니다.
연쇄 호출 시 스레드 수: 언뜻 보면 "호출 깊이만큼 동시에 블로킹되니 그만큼 스레드가 필요하다"고 생각하기 쉽다. 실측은 그렇지 않았다 — 깊이 1~6 전 구간에서 3개면 충분했다(2.4.13.1). 확실한 것은 1개면 반드시 멈춘다는 것이며, 깊이에 맞춰 늘리지 말고 측정해서 정하라.
2.4.7 실전 패턴
2.4.7.1 조정(orchestration) 노드
여러 서비스·액션을 순차 호출하는 노드.
| 엔티티 | 그룹 | 이유 |
|---|---|---|
| 상태 기계 타이머 | ME 그룹 A | 순차 실행 보장 |
| 서비스 클라이언트들 | ME 그룹 B | A와 분리 |
| 액션 클라이언트들 | ME 그룹 B 또는 C | 동일 |
| 상태 조회 서비스 서버 | ME 그룹 D | 언제든 응답 가능하게 |
2.4.7.2 센서 드라이버
| 엔티티 | 그룹 | 이유 |
|---|---|---|
| 하드웨어 읽기 타이머 | ME 그룹 A(전용) | 다른 것에 막히면 안 됨 |
| 파라미터 콜백 | 기본 | 드묾 |
| 진단 타이머 | ME 그룹 B | A를 막지 않게 |
| 설정 변경 서비스 | ME 그룹 B | 동일 |
핵심은 하드웨어 읽기 타이머를 격리하는 것이다. 이 타이머가 지연되면 데이터가 유실되거나 하드웨어 버퍼가 넘친다.
2.4.7.3 액션 서버
1.8.4에서 "세 콜백은 즉시 반환해야 한다"고 했는데, 콜백 그룹으로 대안 구성이 가능하다.
| 구성 | 설명 |
|---|---|
| A. 스레드 분리 | handle_accepted에서 워커 스레드로. executor 구성 무관 |
| B. Reentrant 그룹 + MT executor | execute를 콜백 안에서 직접 실행해도 다른 콜백(취소 등)이 처리됨 |
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초 동안 호출 수 | 최대 동시 |
|---|---|---|
MutuallyExclusive | 63회 | 1 |
Reentrant | 249회 | 4 |
약 4배다 — 스레드 4개가 전부 같은 콜백을 동시에 실행했다. 2.4.2가 "같은 콜백이 여러 개 동시에 실행될 수도 있다"고 한 것이 숫자로 보인다.
이 4배에도 비용이 든다. 콜백이 멤버 변수를 조작한다면 네 개의 인스턴스가 같은 상태를 동시에 조작한다.
MutuallyExclusive에서는 락이 필요 없었던 코드가Reentrant로 바꾸는 순간 락 없이 경쟁 조건이 된다(2.9). 그룹 변경은 한 줄이지만 스레드 안전성 요구 전체가 바뀐다.
2.4.12.3 단일 스레드에서는 그룹이 정말 무의미하다
같은 구성을 SingleThreadedExecutor로 실행하면:
| 구성 | 최대 동시 | 사용 스레드 |
|---|---|---|
| 다른 ME 그룹 | 1 | 1 |
| 같은 Reentrant, 다른 콜백 | 1 | 1 |
| 같은 Reentrant, 같은 콜백 | 1 | 1 |
전부 순차이고 호출 수도 MutuallyExclusive와 같다(63회). 2.4.5의 단서("그룹은 멀티스레드 executor에서만 의미가 있다")가 확인된다 — Reentrant로 바꿔도 단일 스레드에서는 아무 일도 일어나지 않는다.
2.4.12.4 데드락 — 처방에 조건이 하나 더 있다
2.4.6의 예제는 서비스 서버가 다른 프로세스에 있는 경우다. 서버가 같은 노드(=같은 executor)에 있으면 이야기가 달라진다. 일곱 구성을 측정해 봤다.
| 구성 | 결과 |
|---|---|
| 타이머·클라이언트 둘 다 기본 그룹 | 데드락 |
| 클라이언트만 별도 ME 그룹 | 데드락 |
| 클라이언트만 Reentrant 그룹 | 데드락 |
| 서버만 별도 ME 그룹 | 데드락 |
| 서버만 Reentrant 그룹 | 데드락 |
| 단일 스레드 executor | 데드락 |
| 서버·클라이언트 둘 다 각각 별도 그룹 | ✅ 응답 받음 |
클라이언트만 빼는 것으로는 풀리지 않는다. 타이머가 기본 그룹을 쥐고 있으면 그 그룹에 있는 서비스 서버도 실행되지 못하므로 응답 자체가 생성되지 않는다. 클라이언트를 아무리 잘 배치해도 받을 것이 없다.
그래서 2.4.6의 네 조건에 다섯째를 더해야 한다.
- 응답을 만들어 내는 쪽까지 막힌 그룹 밖에 있어야 한다. 서버가 다른 프로세스면 자동으로 만족되지만, 같은 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스레드 | 2 | 3 | 4 | 5 | 6 | 최소 스레드 |
|---|---|---|---|---|---|---|---|
| 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 | 병렬화 안 됨 | 명시적 그룹 |
| 2 | SingleThreaded에서 그룹 설정 | 효과 없음 | executor 확인 |
| 3 | 데드락 해소에 Reentrant 사용 시도 | 여전히 멈춤 | 문제는 그룹 분리(2.4.6) |
| 4 | 스레드 수 < 호출 깊이 | 연쇄 호출에서 교착 | 깊이 + 여유만큼 |
| 5 | Reentrant 콜백이 멤버 변수 사용 | 경쟁 조건 | 상태 없는 콜백 또는 락 |
| 6 | Reentrant로 순서 의존 처리 | 순서 뒤바뀜 | 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) |
| 15 | Reentrant로 바꾸고 락을 안 넣음 | 같은 콜백 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가 다루는 엔티티 중 가장 미묘한 것 — 타이머의 정밀도와 드리프트를 분석한다. 주기가 왜 정확하지 않은지, 지연된 주기를 따라잡는지 버리는지, 그리고 제어 루프에 어떤 의미인지.