1296.19 ThreadedAction의 별도 스레드 실행
1. 별도 스레드 실행의 개요
ThreadedAction의 별도 스레드 실행(separate thread execution)이란, tick() 메서드가 행동 트리의 메인 스레드가 아닌 새로 생성된 별도의 스레드에서 실행되는 것을 의미한다. 이 설계는 tick() 내부의 차단 호출이 트리의 메인 루프를 정지시키지 않도록 하기 위한 것이다.
메인 스레드는 tick() 스레드를 생성한 후 즉시 RUNNING을 반환하여 트리 순회를 계속하며, tick() 스레드는 독립적으로 행동을 수행한다. 두 스레드는 노드의 상태와 블랙보드를 통해 통신한다.
2. 스레드 실행의 시간적 구조
별도 스레드 실행의 시간적 구조를 도식화한다.
메인 스레드:
─────┬──────────┬──────────┬──────────┬──────────┬────
Tick1│RUNNING반환│ Tick2 │ Tick3 │ Tick4 │ ...
│ │RUNNING반환│RUNNING반환│SUCCESS반환│
─────┴──────────┴──────────┴──────────┴──────────┴────
tick() 스레드:
┌─────────────────────────────────────┐
│ tick() 실행 (차단 호출 포함) │
│ 시작 ... 완료 │
└─────────────────────────────────────┘
메인 스레드의 Tick 1에서 tick() 스레드가 생성되고, RUNNING이 즉시 반환된다. Tick 2, 3에서 메인 스레드는 tick() 스레드가 아직 실행 중임을 확인하고 RUNNING을 반환한다. tick() 스레드가 완료되면, Tick 4에서 메인 스레드가 tick()의 반환값(이 경우 SUCCESS)을 감지하여 반환한다.
3. 스레드 생성 메커니즘
ThreadedAction의 executeTick() 메서드 내부에서 스레드가 생성되는 과정을 분석한다.
NodeStatus ThreadedAction::executeTick()
{
auto pre_result = checkPreConditions();
if (pre_result.has_value())
return pre_result.value();
if (status() == NodeStatus::IDLE)
{
setStatus(NodeStatus::RUNNING);
halt_requested_.store(false);
// 별도 스레드 생성
if (thread_.joinable())
{
thread_.join(); // 이전 스레드 정리
}
thread_ = std::thread(
[this]()
{
try
{
auto result = tick();
if (!halt_requested_.load())
{
setStatus(result);
}
}
catch (const std::exception& ex)
{
setStatus(NodeStatus::FAILURE);
}
});
return NodeStatus::RUNNING;
}
return status();
}
스레드 생성의 핵심 동작은 다음과 같다.
- 상태 초기화: 상태를 RUNNING으로 설정하고,
halt_requested_플래그를false로 초기화한다. - 이전 스레드 정리: 이전 실행의 스레드가 아직 합류(join)되지 않은 경우,
join()을 호출하여 정리한다. - 새 스레드 생성:
std::thread를 생성하여 람다 함수 내에서tick()을 호출한다. - RUNNING 즉시 반환: 스레드 생성 후 메인 스레드는 RUNNING을 즉시 반환한다.
스레드 내부의 람다 함수는 tick()의 반환값을 받아 노드의 상태를 설정한다. Halt가 요청된 경우에는 상태를 설정하지 않는다(Halt에 의한 IDLE 전이가 우선).
4. 메인 스레드와 tick() 스레드의 관계
두 스레드의 관계를 정리한다.
메인 스레드 tick() 스레드
│ │
│── executeTick() 호출 ──→ │
│ │
│── std::thread 생성 ──────────→ tick() 시작
│ │
│── RUNNING 반환 │── 차단 호출 수행
│ │
│── (다른 노드 Tick) │── (차단 대기 중)
│ │
│── executeTick() 호출 │── (차단 대기 중)
│── status() == RUNNING │
│── RUNNING 반환 │
│ │
│── (다른 노드 Tick) │── 차단 호출 완료
│ │── setStatus(SUCCESS)
│── executeTick() 호출 │── 스레드 종료
│── status() == SUCCESS │
│── SUCCESS 반환 │
두 스레드는 비동기적으로 실행되며, 노드의 상태(status())를 통해 간접적으로 통신한다. tick() 스레드가 setStatus()를 호출하면, 메인 스레드의 다음 executeTick()에서 변경된 상태를 감지한다.
5. 상태 변경의 원자성
tick() 스레드와 메인 스레드가 동일한 상태 변수에 접근하므로, 상태 변경의 원자성이 보장되어야 한다. BehaviorTree.CPP는 노드의 상태를 std::atomic<NodeStatus> 또는 뮤텍스로 보호하여 경합 조건(race condition)을 방지한다.
// BehaviorTree.CPP 내부 구현 (개념적)
class TreeNode
{
std::atomic<NodeStatus> status_;
void setStatus(NodeStatus new_status)
{
NodeStatus prev = status_.exchange(new_status);
// 상태 변경 통지 (로거 등)
}
NodeStatus status() const
{
return status_.load();
}
};
std::atomic을 사용하면 setStatus()와 status() 간의 경합 조건이 원자적 연산에 의해 방지된다. 그러나 상태의 원자성이 보장되더라도, tick() 내부에서 블랙보드에 대한 복합 연산(읽기-수정-쓰기)은 별도의 동기화가 필요할 수 있다.
6. 메인 스레드의 비차단 보장
별도 스레드 실행의 핵심 이점은 메인 스레드의 비차단(non-blocking) 보장이다.
메인 스레드 없이 (SyncActionNode에서 차단 호출 시):
──┬─────────────────────────────────────┬──
│ tick() 차단 (3초) │ ← 트리 전체 3초 정지
──┴─────────────────────────────────────┴──
별도 스레드 사용 시 (ThreadedAction):
메인 ──┬──┬──┬──┬──┬──┬── ← Tick 주기 유지
tick() ─┬──────────────┬─ ← 별도 스레드에서 3초 차단
메인 스레드는 tick() 스레드의 실행과 무관하게 Tick 주기를 유지하므로, 트리의 다른 노드(조건 재평가, 다른 액션의 onRunning() 등)가 정상적으로 Tick된다.
7. 다수의 ThreadedAction이 동시에 실행되는 경우
Parallel 노드의 자식으로 다수의 ThreadedAction이 배치되면, 각각 별도의 스레드에서 동시에 실행된다.
<Parallel success_count="2" failure_count="1">
<ThreadedTaskA/> <!-- 스레드 A -->
<ThreadedTaskB/> <!-- 스레드 B -->
<ThreadedTaskC/> <!-- 스레드 C -->
</Parallel>
이 경우 메인 스레드를 포함하여 최대 4개의 스레드가 동시에 활성화된다. 스레드 수의 증가는 다음의 영향을 미친다.
- 스레드 생성 비용 누적: 각 스레드의 생성 비용이 누적된다.
- 동시 접근 복잡성 증가: 블랙보드에 대한 동시 접근이 증가하여 경합 가능성이 높아진다.
- 시스템 자원 소비: CPU 코어 수를 초과하는 스레드 생성은 컨텍스트 스위칭 오버헤드를 유발한다.
8. 스레드 생성 비용 분석
std::thread 생성의 비용은 운영 체제와 하드웨어 환경에 따라 다르나, 일반적으로 다음의 범위에 해당한다.
| 환경 | 스레드 생성 시간 |
|---|---|
| Linux (x86_64) | 50~200 μs |
| Linux (ARM, Raspberry Pi) | 200~500 μs |
| RTOS (임베디드) | 10~100 μs |
10 ms Tick 주기에서 스레드 생성에 200 μs가 소요되면, Tick 예산의 2%를 스레드 생성 오버헤드에 소비하게 된다. 빈번히 시작과 종료를 반복하는 경량 행동에 ThreadedAction을 사용하면 이 오버헤드가 누적되어 성능에 영향을 미칠 수 있다.
스레드 풀(thread pool)을 사용하면 스레드 생성 비용을 절감할 수 있으나, BehaviorTree.CPP의 현재 구현(4.x)은 스레드 풀을 내장하지 않으며, 매 실행마다 새로운 std::thread를 생성한다.
9. 예외의 스레드 간 전파
tick() 스레드에서 발생한 예외는 해당 스레드 내부에서 처리되어야 한다. 예외가 tick() 외부로 전파되면 스레드가 비정상적으로 종료(std::terminate)되며, 이는 프로세스 전체의 종료를 유발할 수 있다.
// BehaviorTree.CPP 내부: 스레드 래퍼에서의 예외 처리
thread_ = std::thread([this]()
{
try
{
auto result = tick();
if (!halt_requested_.load())
{
setStatus(result);
}
}
catch (const std::exception& ex)
{
// 예외를 FAILURE로 변환
setStatus(NodeStatus::FAILURE);
}
catch (...)
{
setStatus(NodeStatus::FAILURE);
}
});
BehaviorTree.CPP의 내부 스레드 래퍼는 tick() 내부의 모든 예외를 포착하여 FAILURE 상태로 변환한다. 그러나 사용자의 tick() 구현에서도 예외를 적절히 처리하여 진단 정보를 보존하는 것이 권장된다.
BT::NodeStatus tick() override
{
try
{
// 차단 호출 수행
auto result = legacy_api_->performAction();
if (isHaltRequested())
return BT::NodeStatus::FAILURE;
return result.success ? BT::NodeStatus::SUCCESS
: BT::NodeStatus::FAILURE;
}
catch (const LegacyException& ex)
{
RCLCPP_ERROR(logger_, "레거시 API 오류: %s", ex.what());
return BT::NodeStatus::FAILURE;
}
}
10. 별도 스레드 실행의 제약
별도 스레드 실행에는 다음의 제약이 수반된다.
-
스레드 안전성 책임:
tick()내부에서 접근하는 모든 공유 자원에 대한 스레드 안전성은 개발자의 책임이다. -
디버깅 복잡성: 멀티스레드 환경에서의 디버깅은 단일 스레드 환경보다 복잡하다. 경합 조건, 교착 상태(deadlock), 메모리 가시성(memory visibility) 문제가 발생할 수 있다.
-
Halt 응답 지연:
tick()내부의 차단 호출이 완료될 때까지isHaltRequested()확인이 지연된다. -
자원 소비: 스레드 생성과 컨텍스트 스위칭에 의한 오버헤드가 발생한다.
이러한 제약으로 인해 BehaviorTree.CPP 4.x에서는 StatefulActionNode를 비동기 행동의 기본 선택으로 권장하며, ThreadedAction은 차단 API의 래핑이 불가피한 경우에만 사용하도록 안내한다(Faconti, 2022).