제2부 실행 모델·클라이언트 라이브러리 내부
2.9 스레드 안전성과 흔한 경쟁 조건
2.9.1 이 장에서 배우는 것 / 선수 지식
2.3·2.4에서 멀티스레드 executor와 콜백 그룹을 다루며 "스레드 안전은 직접 책임져야 한다"고 반복했다. 이 장이 그 책임의 내용이다.
경쟁 조건은 ROS 2 시스템에서 가장 비용이 큰 버그 유형이다. 이유가 명확하다 — 테스트에서 통과하고, 개발 머신에서 재현되지 않고, 현장에서 몇 시간에 한 번 발생하고, 로그에 아무것도 안 남는다. 그리고 로봇에서는 그 한 번이 물리적 사고가 될 수 있다.
배우는 것:
- rclcpp에서 무엇이 스레드 안전한가 — 보장되는 것과 아닌 것.
- 경쟁 조건이 생기는 지점 — ROS 2 특유의 패턴들.
- 동기화 도구의 선택 — 뮤텍스·원자·이중 버퍼·락프리, 각각 언제.
- 실시간 경로에서의 동기화 — 우선순위 역전과 그 대응.
- 탐지 도구 — TSAN·헬그라인드·코드 리뷰 체크리스트.
- 설계로 회피하기 — 동기화가 필요 없게 만드는 구조.
- 경쟁 조건 재현과 TSAN 실측(2.9.10) — 얼마나 잃는가, 도구가 정말 검출하는가.
선수 지식: 2.3(executor), 2.4(콜백 그룹), 2.6(메모리), 1.9(파라미터 콜백), 1.8(액션 서버 스레드).
정직: rclcpp의 스레드 안전 보장은 문서화가 완전하지 않은 영역이다. 아래 표는 소스 구조·설계 의도·커뮤니티 관행에 근거하며, 일부 항목은
(확인 필요)로 남긴다. 보장되지 않은 것에 의존하지 않는 것이 안전한 태도다(목차 3).
2.9.2 무엇이 스레드 안전한가
| 대상 | 스레드 안전 | 비고 |
|---|---|---|
Publisher::publish() | ✅ 여러 스레드에서 호출 가능 | 일반적으로 안전하다고 간주 |
Node::now(), 시계 조회 | ✅ | |
| 로깅 매크로 | ✅ | 내부 락. 그래서 실시간에 부적합(1.11.7) |
get_parameter() | ✅ | 내부 락(확인 필요) |
set_parameter() | ✅ | 콜백이 다른 스레드에서 실행될 수 있음 |
엔티티 생성(create_publisher 등) | ⚠️ | 실행 중 동적 생성은 신중히(2.2.6) |
Node 소멸 | ❌ | spin 중 소멸 금지 |
| 사용자 멤버 변수 | ❌ | 전적으로 사용자 책임 |
| 콜백 간 공유 상태 | ❌ | 동일 |
핵심: 라이브러리 API는 대체로 안전하고, 사용자 코드는 전적으로 사용자 책임이다. 경쟁 조건의 99%는 사용자 멤버 변수에서 나온다.
정직: "
publish()는 스레드 안전하다"는 널리 통용되는 이해이나, 모든 상황에서 명시적으로 보장된다고 단정하기는 어렵다(확인 필요). 특히 intra-process 경로·loaned message와 결합된 경우는 확인이 필요하다. 안전한 접근은 한 발행자를 한 스레드에서만 쓰는 것이며, 여러 스레드에서 써야 한다면 자신의 구성에서 검증하기 바란다.
2.9.3 경쟁 조건이 생기는 지점
2.9.3.1 ① 콜백 그룹 간 공유 상태
// ☠️ 두 콜백이 다른 그룹 → 동시 실행 가능
class BadNode : public rclcpp::Node {
void on_scan(LaserScan::ConstSharedPtr msg) { // 그룹 A
latest_ranges_ = msg->ranges; // 쓰기
}
void on_control_timer() { // 그룹 B
for (auto r : latest_ranges_) { … } // 읽기 — 도중에 바뀔 수 있다
}
std::vector<float> latest_ranges_; // 보호되지 않음
};
std::vector를 다른 스레드가 동시에 읽고 쓰면 미정의 동작이다. 재할당 중이면 읽는 쪽이 해제된 메모리를 본다 — 크래시 또는 쓰레기 값.
2.9.3.2 ② Reentrant 콜백의 자기 경쟁
// ☠️ Reentrant 그룹 → 같은 콜백이 두 스레드에서 동시에
void on_image(Image::UniquePtr msg) {
frame_count_++; // 원자적이지 않다
buffer_ = std::move(msg); // 동시 쓰기
process(buffer_); // 다른 스레드가 바꾼 것을 볼 수도
}
2.4.8에서 경고한 그대로다. Reentrant 그룹의 콜백은 상태를 갖지 않아야 한다.
2.9.3.3 ③ 파라미터 콜백과 제어 루프
// 파라미터 콜백은 다른 스레드에서 올 수 있다 (1.9.6)
auto handle = add_post_set_parameters_callback(
[this](const std::vector<rclcpp::Parameter> & params) {
for (const auto & p : params)
if (p.get_name() == "kp") kp_ = p.as_double(); // 쓰기
});
void control_loop() {
double u = kp_ * error; // 읽기 — 동시에 바뀔 수 있다
}
double 하나여도 안전하지 않다. C++ 표준상 데이터 경쟁은 미정의 동작이며, 컴파일러가 최적화로 값을 캐시하거나 재정렬할 수 있다.
여러 값이 함께 바뀌어야 하면 더 불리하다:
// ☠️ kp만 바뀌고 ki는 아직 옛 값인 상태로 제어가 실행될 수 있다
kp_ = new_kp;
ki_ = new_ki;
kd_ = new_kd;
2.9.3.4 ④ 액션 execute 스레드
void handle_accepted(std::shared_ptr<GoalHandle> gh) {
std::thread{[this, gh]() { execute(gh); }}.detach(); // 별도 스레드
}
void execute(std::shared_ptr<GoalHandle> gh) {
state_ = RUNNING; // 이 스레드에서 쓰기
}
void on_status_request(req, res) { // executor 스레드에서 읽기
res->state = state_; // 경쟁
}
1.8.4의 스레드 분리는 필수지만, 그 스레드와 콜백 사이의 공유 상태는 사용자가 보호해야 한다.
2.9.3.5 ⑥ 소멸 중인 객체 접근
// ☠️ 노드가 소멸 중인데 워커 스레드가 아직 실행된다
~MyNode() {
// running_ = false; 를 누락하면 스레드가 소멸된 멤버를 조작한다
}
소멸 순서가 경쟁 조건의 숨은 원천이다. 특히 detach()한 스레드는 객체 수명과 무관하게 계속 실행된다.
// ✅ 안전한 소멸
~MyNode() {
running_ = false;
cv_.notify_all();
if (worker_.joinable()) worker_.join(); // 반드시 join
}
2.9.4 동기화 도구의 선택
2.9.4.1 std::atomic — 스칼라 하나
std::atomic<double> kp_{1.0};
std::atomic<bool> enabled_{false};
std::atomic<uint64_t> error_count_{0};
// 쓰기
kp_.store(new_value, std::memory_order_relaxed);
// 읽기
double k = kp_.load(std::memory_order_relaxed);
// 카운터
error_count_.fetch_add(1, std::memory_order_relaxed);
memory_order_relaxed는 카운터·독립 값에 적합하다. 여러 변수 사이의 순서가 중요하면 기본(seq_cst)을 쓴다 — 성능 차이는 대개 무시할 만하고, 잘못된 완화 순서는 찾기 어려운 버그를 만든다.
견해: memory order를 최적화하려는 시도는 대부분 조기 최적화다. 확실한 이유와 측정이 없으면 기본값을 쓰라.
relaxed가 명백히 맞는 경우는 독립적인 카운터 정도다.
2.9.4.2 std::mutex — 여러 값·복합 자료구조
class SafeState {
public:
void update(double kp, double ki, double kd) {
std::lock_guard<std::mutex> lk(mtx_);
kp_ = kp; ki_ = ki; kd_ = kd;
}
Gains get() const {
std::lock_guard<std::mutex> lk(mtx_);
return {kp_, ki_, kd_}; // 복사해서 반환 — 락 범위를 좁게
}
private:
mutable std::mutex mtx_;
double kp_{}, ki_{}, kd_{};
};
규칙:
- 락 범위를 최소화한다 — 데이터를 복사해 나오고, 처리는 락 밖에서.
- 락을 쥔 채 대기하지 않는다(2.4.8의 절대 규칙).
- 락을 쥔 채 콜백·사용자 코드를 호출하지 않는다 — 그 코드가 같은 락을 원할 수 있다.
- 여러 락을 획득할 때 순서를 고정한다 — 교착 방지.
// ✅ 락 범위 최소화
std::vector<float> local_copy;
{
std::lock_guard<std::mutex> lk(mtx_);
local_copy = shared_ranges_; // 복사만
}
heavy_processing(local_copy); // 락 밖에서 처리
2.9.4.3 이중 버퍼 — 읽기가 실시간일 때
// 쓰기는 드물고, 읽기는 실시간 루프에서 매 주기
class DoubleBuffer {
public:
void write(const Gains & g) { // 비실시간 스레드
size_t next = 1 - active_.load(std::memory_order_acquire);
buf_[next] = g;
active_.store(next, std::memory_order_release);
}
Gains read() const { // 실시간 스레드 — 락 없음
return buf_[active_.load(std::memory_order_acquire)];
}
private:
std::array<Gains, 2> buf_{};
std::atomic<size_t> active_{0};
};
실시간 읽기 측이 절대 블로킹되지 않는다는 것이 이 구조의 가치다. 대가는 쓰기 측이 이전 버퍼가 읽히는 중일 수 있다는 것 — 위 단순 구현은 쓰기가 충분히 드물다는 가정에 의존한다. 엄밀한 구현은 3중 버퍼나 시퀀스 락(seqlock)을 쓴다.
정직: 위 코드는 개념 설명용 최소 구현이며, 엄밀한 무잠금 정확성을 보장하지 않는다. 실제 실시간 시스템에서는 검증된 라이브러리(
realtime_tools의 관련 유틸 등(확인 필요: Jazzy에서의 제공 형태))를 쓰는 편이 안전하다. ros2_control 생태계가 이런 도구를 제공한다(6.6).
2.9.4.4 락프리 큐 — 스레드 간 데이터 전달
콜백에서 워커 스레드로 데이터를 넘길 때(2.3.9). 실시간 경로가 아니면 std::mutex + condition_variable로 충분하고, 실시간이면 SPSC 링버퍼 같은 락프리 구조를 쓴다.
2.9.5 실시간 경로에서의 동기화 — 우선순위 역전
실시간 스레드가 뮤텍스를 기다리는 것은 위험하다.
대응:
| 방법 | 설명 |
|---|---|
| 락을 안 쓴다 | 이중 버퍼·원자·락프리(최선) |
| 우선순위 상속 뮤텍스 | PTHREAD_PRIO_INHERIT 설정(12.2) |
try_lock | 실패하면 이번 주기를 건너뛴다 |
| 락 범위 극소화 | 포인터 교환만 |
// try_lock 패턴 — 실시간 루프에서 절대 블로킹하지 않는다
void control_step() {
if (mtx_.try_lock()) {
gains_cache_ = gains_; // 갱신 성공
mtx_.unlock();
}
// 실패해도 이전 캐시로 계속 — 제어는 멈추지 않는다
use(gains_cache_);
}
견해: 실시간 경로에서는 "락을 획득하려 시도하되 실패해도 계속 진행"이 안전한 기본 패턴이다. 설정 값이 한 주기 늦게 반영되는 것은 대개 문제가 아니지만, 제어 주기를 놓치는 것은 문제다.
2.9.6 탐지 — 경쟁 조건을 실제로 찾기
코드 리뷰만으로는 부족하다. 도구를 쓴다.
① ThreadSanitizer(TSAN) — 가장 효과적
colcon build --packages-select my_pkg \
--cmake-args -DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_FLAGS="-fsanitize=thread -g -O1" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=thread"
# 실행
TSAN_OPTIONS="halt_on_error=0 second_deadlock_stack=1" ros2 run my_pkg my_node
TSAN은 실제로 충돌이 일어나지 않아도 "충돌 가능한 접근"을 검출해낸다. 두 스레드가 같은 메모리를 동기화 없이 접근한 사실 자체를 탐지하므로, 운이 좋아 지나간 버그도 찾는다.
| 항목 | TSAN |
|---|---|
| 탐지력 | 매우 높음 |
| 성능 부하 | 5~15배 느려짐, 메모리 5~10배 — 단, 실측 한 점에서는 약 2배였다(2.9.10.3) |
| 오탐 | 드묾(단, 서드파티 라이브러리에서 나올 수 있음) |
| 실시간 테스트 | 불가(타이밍이 완전히 달라짐) |
견해: TSAN을 CI의 별도 잡으로 두라(4.10). 상시 실행은 부하 때문에 어렵지만, 야간 빌드나 PR 검증에서 한 번 실행되는 것만으로 값을 한다. 멀티스레드 executor를 쓰는 패키지에는 사실상 필수다.
② Helgrind(Valgrind) — TSAN 대안. 더 느리지만 재빌드가 필요 없다.
③ 스트레스 테스트 — 부하를 가해 경쟁 창을 넓힌다.
stress-ng --cpu 8 --timeout 300s & # 배경 부하
# 그 상태로 시스템 테스트 실행
경쟁 조건은 스케줄링이 불규칙할수록 잘 드러난다. CPU를 포화시키고, 코어 수를 줄이고(taskset), 우선순위를 섞으면 재현 확률이 높아진다.
④ 코드 리뷰 체크리스트
- [ ] 이 멤버 변수를 쓰는 콜백들이 같은
MutuallyExclusive그룹에 있는가? - [ ]
Reentrant그룹의 콜백이 멤버 변수를 조작하는가? - [ ] 파라미터 콜백이 갱신하는 값을 다른 스레드가 읽는가?
- [ ] 액션
execute스레드와 콜백이 공유하는 상태가 있는가? - [ ] 소멸자가 워커 스레드를
join하는가? - [ ] 락을 쥔 채 대기·콜백 호출하는 곳이 있는가?
- [ ] 여러 락의 획득 순서가 일관되는가?
2.9.7 설계로 회피하기 — 최선의 전략
동기화 코드를 잘 쓰는 것보다, 동기화가 필요 없게 만드는 것이 낫다.
| 전략 | 방법 | 대가 |
|---|---|---|
| 단일 스레드 executor | 콜백 간 공유가 자동으로 안전(2.3.3) | 병렬성 없음 |
| 같은 콜백 그룹에 배치 | 공유하는 콜백들을 하나의 ME 그룹에 | 그 그룹 안에서 순차 |
| 상태 없는 콜백 | 입력→출력만. 상태는 메시지로 전달 | 설계 제약 |
| 소유권 이전 | 데이터를 공유하지 말고 넘긴다(unique_ptr) | — |
| 노드 분리 | 공유 대신 토픽으로 통신 | 통신 비용 |
| 불변 데이터 | shared_ptr<const T>로 교체만 | 갱신 시 전체 재생성 |
"같은 콜백 그룹에 배치"가 가장 저렴하고 효과적이다.
// 상태를 공유하는 콜백들을 같은 ME 그룹에 → 동기화 불필요
state_group_ = create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);
sub_ = create_subscription<Scan>("scan", qos, cb, opts_with(state_group_));
timer_ = create_wall_timer(10ms, cb2, state_group_);
// 두 콜백은 동시에 실행되지 않는다 → latest_ranges_ 보호 불필요
견해: "이 상태를 공유하는 콜백들을 한 그룹에 넣을 수 있는가"를 먼저 묻는 습관이 뮤텍스를 절반 이상 줄인다. 병렬성이 정말 필요한 곳만 그룹을 나누고, 나머지는 묶어 두면 추론이 단순해진다. 이것이 2.3.8에서 "작은 단일 스레드 executor 여럿"을 권한 이유와 같은 논리다.
불변 데이터 교체 패턴:
// 읽는 쪽은 락 없이 shared_ptr를 복사(원자적)
std::shared_ptr<const MapData> map_; // atomic 접근이 필요할 수 있음
// 쓰기: 새로 만들어 완전히 교체
void update_map(...) {
auto new_map = std::make_shared<const MapData>(build());
std::atomic_store(&map_, new_map); // 또는 std::atomic<std::shared_ptr<>>
}
// 읽기
auto m = std::atomic_load(&map_); // 이 스냅숏은 끝까지 유효
use(*m);
읽기가 많고 갱신이 드문 데이터(지도·설정·캘리브레이션)에 매우 적합하다. 읽는 쪽은 자기가 획득한 스냅숏이 도중에 바뀌지 않음을 보장받는다.
2.9.10 경쟁 조건을 실제로 만들어 보이고, TSAN으로 검출했다 — 실측
2.9.3.2는 Reentrant 그룹의 자기 경쟁을 코드로만 보였다. 2.4.12.2에서 같은 콜백이 네 개 동시에 실행되는 것을 확인했으니, 그 위에서 공유 상태가 실제로 깨지는지 측정해 봤다(실행 확인, Jazzy, -O2, 4스레드).
구성: 타이머 콜백이 락 없는
long과std::atomic<long>을 같은 횟수만큼 증가시킨다. 둘의 차이가 곧 유실된 증가 횟수다. 그룹만 바꿔 3초씩 실행했다.
2.9.10.1 그룹 한 줄 차이로 절반 이상이 사라진다
| 그룹 | 최대 동시 | 원자 카운터 | 락 없는 카운터 | 유실 |
|---|---|---|---|---|
MutuallyExclusive | 1 | 314,240,000 | 314,240,000 | 0 (0.00%) |
Reentrant | 4 | 85,000,000 | 38,469,640 | 46,530,360 (54.7%) |
MutuallyExclusive에서는 정확히 0이고, Reentrant에서는 절반 이상이 유실됐다. 코드는 완전히 같다 — 바뀐 것은 콜백 그룹 인자 하나다.
*이것이 2.4.12.2가 말한 "그룹 변경 한 줄이 스레드 안전성 요구 전체를 바꾼다"의 실물이다.
MutuallyExclusive에서 락 없이 정상적으로 동작하던 코드가, 그룹을 바꾸는 순간 증가의 절반을 잃는다. 그리고 아무 오류도 나지 않는다* — 카운터가 조용히 작아질 뿐이다.
함께 바뀌어야 하는 두 값의 불변식도 깨졌다. a = v; b = v; 사이에 다른 인스턴스가 개입해 a != b인 순간이 관측됐다(3초에 1회). 빈도가 낮다는 것이 더 불리하다 — 시험에서 안 나오고 현장에서 나온다(2.9.1의 첫 문장 그대로다).
2.9.10.2 TSAN이 정확히 검출했다
같은 프로그램을 -fsanitize=thread로 다시 빌드해 실행했다.
WARNING: ThreadSanitizer: data race (pid=409)
Read of size 8 at 0x… by main thread:
#0 operator() /tmp/race.cpp:33
#1 execute_callback_delegate<> …/rclcpp/timer.hpp:310
#2 execute_callback …/rclcpp/timer.hpp:296
#3 rclcpp::Executor::execute_any_executable(…)
Previous write of size 8 at 0x… by thread T18:
| 항목 | 결과 |
|---|---|
경고 건수(Reentrant) | 5건 |
| 지목한 소스 줄 | race.cpp:33(카운터) · :39(두 값 쌍) — 둘 다 |
경고 건수(MutuallyExclusive) | 0건 — 오탐 없음 |
| 스택 | rclcpp 타이머 콜백 경로가 그대로 보인다 |
두 가지가 확인된다. ① TSAN은 문제가 있는 구성만 지목했다(ME에서 0건). ② 스택이 Executor::execute_any_executable → timer.hpp → 내 람다로 이어져 어느 콜백이 원인인지 바로 안다.
*2.9.6의 평가("탐지력 매우 높음, 오탐 드묾")가 확인됐다. 특히 락 없는 카운터처럼 "돌아는 가는" 코드를 검출해 준다는 점이 중요하다 — 이 프로그램은 TSAN 없이는 크래시도 오류도 없이* 그대로 틀린 값을 낸다.
2.9.10.3 TSAN의 부하 — 이 작업부하에서는 약 2배였다
같은 3초 동안 처리한 콜백 수로 비교했다(MutuallyExclusive, 결정적 구성).
| 3초 동안 콜백 호출 수 | |
|---|---|
일반 빌드(-O2) | 7,366회 |
TSAN 빌드(-O1 -g) | 3,737회 |
약 1.97배 느려졌다. 2.9.6의 표는 "5~15배"라고 적었는데 이 측정은 그보다 훨씬 부하가 작았다.
표의 값을 지우지는 않는다 — TSAN의 부하는 접근하는 주소의 다양성에 크게 좌우되고, 이 실험은 같은 변수 몇 개를 반복해 조작하는 형태라 그림자 메모리 적중률이 높다. 주소가 넓게 흩어지는 실제 노드에서는 훨씬 느려질 수 있다. 다만 "5~15배라 CI에 못 넣는다"고 지레 포기할 일은 아니라는 것이 이 숫자의 뜻이다 — 자신의 노드에서 한 번 측정해 보라.
2.9.10.4 확인하지 못한 것
- Helgrind는 쓰지 않았다
(확인 필요)— TSAN만 확인했다. get_parameter()의 내부 락(2.9.2의(확인 필요))은 이 실험 범위 밖이다.- 실제 규모의 노드에서의 TSAN 부하는 측정하지 않았다 — 위 2배는 한 점의 값이다.
2.9.8 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | 다른 그룹 콜백이 멤버 공유 | 간헐적 크래시·이상값 | 같은 그룹 또는 동기화 |
| 2 | Reentrant 콜백이 상태 보유 | 자기 경쟁 | 상태 제거 또는 ME |
| 3 | 파라미터 콜백 값 무보호 | 드문 오동작 | atomic·이중 버퍼 |
| 4 | double 하나는 안전하다고 가정 | 미정의 동작 | std::atomic |
| 5 | 연관 값들을 개별 갱신 | 중간 모순 상태 | 뮤텍스·구조체 전체 교체 |
| 6 | 워커 스레드 detach 후 미정리 | 소멸 후 접근 | join |
| 7 | 락 쥔 채 대기·콜백 호출 | 교착 | 복사 후 해제 |
| 8 | 락 순서 불일치 | 교착 | 순서 규약 |
| 9 | 실시간 경로에서 블로킹 락 | 우선순위 역전 | try_lock·락프리 |
| 10 | TSAN 미사용 | 버그가 현장에서 발견 | CI 잡 추가 |
| 11 | memory order 조기 최적화 | 미묘한 버그 | 기본값 사용 |
| 12 | 개발 머신에서만 테스트 | 재현 안 됨 | 부하·코어 제한 테스트 |
| 13 | ME에서 잘 동작하던 코드를 Reentrant로 이동 | 증가의 54% 유실, 오류 없음 | 그룹 변경 = 락 재검토(2.9.10.1) |
| 14 | TSAN이 느려서 미리 포기 | 실측은 약 2배였다 | 자신의 노드에서 측정해 보라(2.9.10.3) |
2.9.9 체크포인트
- rclcpp에서 스레드 안전이 보장되는 것과 사용자 책임인 것을 구분할 수 있는가. 경쟁 조건의 대부분은 어디서 나오는가.
- 경쟁 조건이 생기는 여섯 지점을 말하고, 각각에 해당하는 코드 예를 들 수 있는가.
double하나를 두 스레드가 읽고 쓰는 것이 왜 안전하지 않은가.std::atomic·뮤텍스·이중 버퍼를 선택하는 기준을 흐름도로 그릴 수 있는가.- 뮤텍스 사용의 네 가지 규칙을 말할 수 있는가. 그중 절대 규칙은?
- 우선순위 역전이 발생하는 시나리오를 설명하고, 대응 네 가지를 들 수 있는가.
- 실시간 루프에서
try_lock패턴이 안전한 이유는? - TSAN이 검출하는 것이 "실제로 발생한 충돌"이 아니라 무엇인가. 그것이 왜 더 강력한가.
- 경쟁 조건 재현 확률을 높이는 테스트 방법 세 가지를 들 수 있는가.
- 동기화를 회피하는 여섯 가지 설계 전략 중 가장 저렴한 것은 무엇이며 왜인가.
- 불변 데이터 교체 패턴이 적합한 데이터 종류는 무엇인가.
- 같은 코드를 ME → Reentrant 그룹으로 옮기면 락 없는 카운터에 무슨 일이 생기는가(2.9.10.1).
- TSAN이
MutuallyExclusive구성에서 낸 경고는 몇 건인가. 그것이 뜻하는 바는(2.9.10.2).
다음 장 2.10은 제2부의 마지막 — NodeOptions·노드 인터페이스·확장 포인트다. 지금까지 다룬 실행 모델을 조립하고 제어하는 설정 표면 전체와, ROS 2 위에 자기 프레임워크를 세울 때 쓰는 확장 지점들을 정리한다.