1293.60 Tick과 스레드 안전성
1. 스레드 안전성의 개념
스레드 안전성(thread safety)이란, 다중 스레드 환경에서 공유 데이터에 대한 동시 접근이 발생하더라도 프로그램의 정확성이 보장되는 속성을 의미한다. 행동 트리의 Tick 메커니즘에서 스레드 안전성은 Tick 스레드와 외부 스레드(ROS2 콜백 스레드, 비동기 작업 스레드 등) 간의 데이터 공유에서 핵심적 설계 요소이다(Faconti, 2022).
2. Tick 실행의 스레드 컨텍스트
BehaviorTree.CPP에서 Tick은 tree.tickOnce() 또는 tree.tickWhileRunning()을 호출하는 스레드에서 실행된다. 이 스레드를 Tick 스레드라 한다. Tick 스레드에서 수행되는 작업은 다음과 같다.
Tick 스레드에서 실행:
- 제어 노드의 자식 Tick 순서 결정
- 조건 노드의 조건 평가
- SyncActionNode의 tick() 실행
- StatefulActionNode의 onStart(), onRunning(), onHalted() 실행
- 블랙보드 읽기/쓰기
- 노드 상태 전이
기본적으로 하나의 Tick 실행은 단일 스레드에서 순차적으로 수행되므로, Tick 스레드 내에서의 데이터 접근은 스레드 안전하다.
3. 스레드 안전성 위협 요소
Tick과 관련된 스레드 안전성 문제는 Tick 스레드와 다른 스레드 간의 데이터 공유에서 발생한다.
3.1 ROS2 콜백 스레드와의 경쟁
ROS2의 멀티스레드 실행기(MultiThreadedExecutor)를 사용하는 경우, 토픽 구독 콜백이나 액션 결과 콜백이 Tick 스레드와 다른 스레드에서 실행될 수 있다. 이 콜백이 Tick 중 접근되는 데이터를 수정하면 데이터 경쟁이 발생한다.
Tick 스레드: latest_scan_ 읽기 (onRunning에서)
콜백 스레드: latest_scan_ 쓰기 (토픽 콜백에서)
← 동시 접근: 정의되지 않은 동작
3.2 ThreadedAction의 별도 스레드
ThreadedAction의 tick()은 라이브러리가 생성한 별도 스레드에서 실행된다. 이 스레드에서 블랙보드나 노드 멤버 변수에 접근하면, Tick 스레드의 접근과 경쟁 조건이 발생할 수 있다.
3.3 비동기 작업 스레드
개발자가 직접 생성한 작업 스레드(std::thread, std::async 등)에서 노드의 멤버 변수나 블랙보드에 접근하는 경우에도 동일한 경쟁 조건이 발생한다.
4. 데이터 경쟁의 유형
4.1 읽기-쓰기 경쟁
한 스레드가 데이터를 읽는 동안 다른 스레드가 해당 데이터를 수정하는 경우이다.
// 노드 멤버 변수
geometry_msgs::msg::Pose current_pose_; // 비원자적 타입
// 콜백 스레드에서 쓰기
void poseCallback(const geometry_msgs::msg::PoseStamped::SharedPtr msg) {
current_pose_ = msg->pose; // 쓰기 (콜백 스레드)
}
// Tick 스레드에서 읽기
BT::NodeStatus onRunning() override {
auto pose = current_pose_; // 읽기 (Tick 스레드) ← 경쟁 조건
return checkGoalReached(pose)
? BT::NodeStatus::SUCCESS
: BT::NodeStatus::RUNNING;
}
geometry_msgs::msg::Pose는 복합 구조체이므로, 읽기와 쓰기가 원자적이지 않다. 읽기 도중 쓰기가 발생하면 부분적으로 갱신된 불일치 데이터가 읽힐 수 있다.
4.2 쓰기-쓰기 경쟁
두 스레드가 동시에 동일 데이터를 수정하는 경우이다. 최종 값이 비결정적으로 결정된다.
5. 스레드 안전성 확보 기법
5.1 뮤텍스(Mutex) 보호
공유 데이터에 대한 접근을 뮤텍스로 보호하여 상호 배제를 보장한다.
class SafePoseMonitor : public BT::StatefulActionNode {
public:
void poseCallback(const geometry_msgs::msg::PoseStamped::SharedPtr msg) {
std::lock_guard<std::mutex> lock(mutex_);
current_pose_ = msg->pose;
}
BT::NodeStatus onRunning() override {
geometry_msgs::msg::Pose pose;
{
std::lock_guard<std::mutex> lock(mutex_);
pose = current_pose_;
}
return checkGoalReached(pose)
? BT::NodeStatus::SUCCESS
: BT::NodeStatus::RUNNING;
}
private:
std::mutex mutex_;
geometry_msgs::msg::Pose current_pose_;
};
5.2 원자적 변수(Atomic Variable)
단순 타입의 공유 데이터에는 std::atomic을 사용하여 락 없이 스레드 안전성을 확보할 수 있다.
class BatteryMonitor : public BT::ConditionNode {
public:
void batteryCallback(const sensor_msgs::msg::BatteryState::SharedPtr msg) {
battery_level_.store(msg->percentage, std::memory_order_release);
}
BT::NodeStatus tick() override {
double level = battery_level_.load(std::memory_order_acquire);
double threshold;
getInput("threshold", threshold);
return (level > threshold)
? BT::NodeStatus::SUCCESS
: BT::NodeStatus::FAILURE;
}
private:
std::atomic<double> battery_level_{1.0};
};
5.3 데이터 복사 전략
콜백에서 수신한 데이터를 원자적으로 복사하고, Tick에서는 복사본에 접근하는 전략이다. std::shared_ptr의 원자적 교환을 활용한다.
class LaserScanNode : public BT::StatefulActionNode {
public:
void scanCallback(const sensor_msgs::msg::LaserScan::SharedPtr msg) {
std::atomic_store(&latest_scan_, msg);
}
BT::NodeStatus onRunning() override {
auto scan = std::atomic_load(&latest_scan_);
if (!scan) {
return BT::NodeStatus::RUNNING;
}
// scan은 스냅샷이므로 안전하게 사용 가능
return processScan(scan);
}
private:
std::shared_ptr<sensor_msgs::msg::LaserScan> latest_scan_;
};
6. 블랙보드의 스레드 안전성
BehaviorTree.CPP v4의 블랙보드는 내부적으로 뮤텍스 보호를 제공한다. getInput()과 setOutput()은 블랙보드의 내부 락을 획득한 후 데이터를 읽기/쓰기하므로, Tick 스레드에서의 블랙보드 접근은 스레드 안전하다. 그러나 ThreadedAction의 별도 스레드에서 블랙보드에 접근하는 경우, 개별 읽기/쓰기는 안전하지만 복합 연산(읽기-수정-쓰기)의 원자성은 보장되지 않는다(Faconti, 2022).
7. ROS2 실행기 선택과 스레드 안전성
| 실행기 유형 | 콜백 스레드 | Tick과의 경쟁 | 보호 필요성 |
|---|---|---|---|
| SingleThreadedExecutor | Tick과 동일 스레드 | 없음 (인터리빙) | 불필요 |
| MultiThreadedExecutor | 별도 스레드 가능 | 있음 | 필수 |
| StaticSingleThreadedExecutor | Tick과 동일 스레드 | 없음 | 불필요 |
SingleThreadedExecutor를 사용하면 ROS2 콜백과 Tick이 동일 스레드에서 인터리빙 방식으로 실행되므로, 콜백과 Tick 간의 데이터 경쟁이 구조적으로 방지된다. 이는 행동 트리의 스레드 안전성 관리를 단순화하는 권장 방식이다.
8. Tick 중 콜백 실행 시점
SingleThreadedExecutor 환경에서 spinSome()이 Tick 실행 전후에 호출되면, 콜백은 Tick 사이에서만 실행된다. Tick 내에서 콜백이 실행되지 않으므로, Tick 실행 중 데이터의 일관성이 보장된다.
void tickLoop() {
rclcpp::executors::SingleThreadedExecutor executor;
executor.add_node(ros_node);
while (rclcpp::ok()) {
executor.spin_some(); // 콜백 처리 (Tick 외부)
tree.tickOnce(); // Tick 실행 (콜백 없음)
rate.sleep();
}
}
이 패턴에서 콜백에 의한 데이터 갱신과 Tick에 의한 데이터 읽기가 시간적으로 분리되어, 락 없이도 스레드 안전성이 달성된다.
참고 문헌
- Colledanchise, M., & Ogren, P. (2018). Behavior Trees in Robotics and AI: An Introduction. CRC Press.
- Faconti, D. (2022). BehaviorTree.CPP documentation and API reference. https://www.behaviortree.dev/