1293.20 Tick과 ROS2 콜백 실행의 인터리빙

1. 인터리빙의 개념

인터리빙(interleaving)이란 행동 트리(Behavior Tree)의 Tick 실행과 ROS2 콜백 처리가 시간적으로 교대하며 수행되는 실행 패턴을 의미한다. 행동 트리의 Tick은 트리 전체를 순회하며 의사 결정을 수행하는 연산이고, ROS2 콜백은 토픽 메시지 수신, 서비스 응답 처리, 타이머 만료 등의 이벤트에 대응하는 연산이다. 이 두 종류의 연산이 단일 스레드 또는 복수의 스레드에서 시간을 분할하며 실행되는 양상이 인터리빙이다(Macenski et al., 2022).

인터리빙의 패턴은 행동 트리의 데이터 최신성, 의사 결정의 정확성, 그리고 시스템의 전반적인 응답성에 직접적인 영향을 미치므로, 그 동작 특성을 정확히 이해하고 적절히 설계해야 한다.

2. 단일 스레드에서의 인터리빙

SingleThreadedExecutor를 사용하거나 spin_some() 기반 수동 루프를 사용하는 경우, Tick과 ROS2 콜백은 동일한 스레드에서 순차적으로 실행된다. 이 경우의 인터리빙은 명시적이고 결정론적(deterministic)이다.

2.1 타이머 콜백 기반 인터리빙

SingleThreadedExecutor에서 Tick이 타이머 콜백으로 구현된 경우, 실행기의 내부 스케줄링에 의해 인터리빙 순서가 결정된다. 전형적인 실행 순서는 다음과 같다.

[시간축] ─────────────────────────────────────────►
         │콜백A│콜백B│  Tick  │콜백C│ 대기 │콜백D│  Tick  │

실행기는 각 반복(iteration)에서 준비된 콜백 중 우선순위가 높은 것을 선택하여 실행한다. 타이머 콜백과 구독 콜백이 동시에 준비된 경우, 실행기의 내부 정책에 따라 실행 순서가 결정되며, 이 순서는 ROS2 버전과 실행기 구현에 따라 다를 수 있다.

2.2 spin_some 기반 명시적 인터리빙

spin_some() 기반 수동 루프에서는 개발자가 인터리빙 순서를 명시적으로 제어한다.

[시간축] ─────────────────────────────────────────►
         │spin_some│  Tick  │ sleep │spin_some│  Tick  │
         │(콜백처리)│(트리순회)│(대기) │(콜백처리)│(트리순회)│

이 패턴에서 Tick과 콜백 처리의 경계가 명확하므로, Tick 실행 중에 센서 데이터가 변경되지 않는다는 불변 조건(invariant)이 자연스럽게 성립한다.

3. 다중 스레드에서의 인터리빙

MultiThreadedExecutor를 사용하거나 행동 트리가 전용 스레드에서 실행되는 경우, Tick과 ROS2 콜백이 동시에(concurrently) 실행될 수 있다. 이는 진정한 의미의 병렬 인터리빙(parallel interleaving)이며, 비결정론적(nondeterministic) 특성을 가진다.

스레드1: ─│──── Tick 실행 ────│──── Tick 실행 ────│─
스레드2: ─│콜백A│콜백B│  콜백C  │콜백D│    콜백E    │─

이 경우 Tick 실행 중에도 구독 콜백이 병렬로 실행되어 데이터를 갱신할 수 있으므로, 행동 트리의 노드가 접근하는 공유 데이터에 대한 동기화가 필수적이다.

4. 인터리빙이 행동 트리에 미치는 영향

4.1 데이터 일관성 문제

Tick 실행 중에 ROS2 콜백이 센서 데이터를 갱신하면, 동일한 Tick 내에서 서로 다른 노드가 상이한 시점의 데이터를 참조할 수 있다. 예를 들어, ReactiveSequence의 첫 번째 조건 노드는 이전 데이터를 기반으로 판단하고, 두 번째 조건 노드는 Tick 중간에 갱신된 데이터를 기반으로 판단하는 상황이 발생할 수 있다.

이 문제는 단일 스레드 인터리빙에서는 발생하지 않으나, 다중 스레드 인터리빙에서는 다음과 같은 불일치를 초래할 수 있다.

Tick 시작 → 조건A 평가(거리=5.0m) → [콜백: 거리=1.0m으로 갱신]
         → 조건B 평가(거리=1.0m) → 불일치 발생

4.2 Tick 내 스냅샷 기법

다중 스레드 환경에서의 데이터 일관성을 보장하기 위해, Tick 시작 시점에 필요한 모든 데이터의 스냅샷(snapshot)을 취하고, Tick 실행 중에는 이 스냅샷만을 참조하는 기법을 적용할 수 있다.

void on_tick() {
    // Tick 시작 시 데이터 스냅샷 취득
    {
        std::lock_guard<std::mutex> lock(data_mutex_);
        snapshot_.laser_range = latest_laser_range_;
        snapshot_.robot_pose = latest_robot_pose_;
        snapshot_.battery_level = latest_battery_level_;
    }
    
    // 블랙보드에 스냅샷 데이터 기록
    tree_.rootBlackboard()->set("laser_range", 
                                snapshot_.laser_range);
    tree_.rootBlackboard()->set("robot_pose", 
                                snapshot_.robot_pose);
    
    // 스냅샷 데이터 기반 Tick 실행
    tree_.tickOnce();
}

이 기법은 Tick 내 데이터 일관성을 보장하지만, Tick 실행 중에 발생하는 환경 변화가 현재 Tick에 반영되지 않는다는 트레이드오프가 있다.

5. 콜백 유형별 인터리빙 특성

ROS2의 다양한 콜백 유형은 행동 트리 Tick과의 인터리빙에서 각기 다른 특성을 보인다.

콜백 유형발생 빈도Tick과의 관계인터리빙 고려 사항
토픽 구독발행 빈도 의존데이터 공급원최신 데이터 보장 필요
타이머주기적Tick 자체가 타이머일 수 있음타이머 간 우선순위
서비스 응답요청 시동기적 대기 회피 필요응답 지연의 Tick 영향
액션 피드백진행 중 주기적비동기 노드 상태 갱신피드백 지연 허용 범위
액션 결과완료 시 1회비동기 노드 완료 감지결과 수신과 Tick 동기화

5.1 토픽 구독 콜백의 인터리빙

토픽 구독 콜백은 가장 빈번하게 발생하는 콜백 유형이다. 고주파 센서 데이터(LiDAR, IMU 등)의 경우 수백 Hz로 콜백이 발생할 수 있으므로, Tick 간격 동안 다수의 구독 콜백이 누적된다. spin_some() 패턴에서는 Tick 전에 이 누적된 콜백을 일괄 처리하여, Tick이 최신 데이터를 기반으로 실행되도록 한다.

5.2 서비스 응답 콜백의 인터리빙

행동 트리의 액션 노드가 ROS2 서비스를 호출하는 경우, 서비스 응답 콜백이 처리되어야 결과를 확인할 수 있다. 단일 스레드 인터리빙에서 Tick 실행 중에 서비스 응답을 동기적으로 대기하면, 대기 중에 다른 콜백이 처리되지 못하여 교착 상태(deadlock)가 발생할 수 있다. 따라서 행동 트리 노드에서 서비스를 호출할 때는 비동기 패턴을 사용하고, 응답은 다음 Tick에서 확인하는 설계가 권장된다.

6. 인터리빙 순서의 최적화

행동 트리의 정확한 동작을 보장하면서 성능을 최적화하기 위해, 인터리빙 순서를 다음과 같이 설계하는 것이 효과적이다.

[최적화된 인터리빙 순서]
1. spin_some() - 대기 중인 구독/서비스/액션 콜백 처리
2. 블랙보드 데이터 갱신 - 콜백에서 수신된 최신 데이터 반영
3. tree.tickOnce() - 최신 데이터 기반 트리 평가
4. spin_some() - Tick 중 발생한 요청에 대한 응답 처리
5. sleep/wait - 다음 Tick까지 대기

이 순서는 각 Tick이 가능한 한 최신의 환경 정보에 기반하여 실행되도록 하면서, Tick 중 발생한 통신 요청(액션 목표 전송, 서비스 호출 등)에 대한 응답도 가능한 한 빠르게 처리되도록 한다.

7. 인터리빙과 QoS 설정의 관계

ROS2 QoS(Quality of Service) 설정은 인터리빙 특성에 영향을 미친다. KeepLast(1) 설정의 구독은 항상 최신 메시지만 유지하므로, spin_some() 호출 시 단일 콜백만 처리되어 처리 시간이 예측 가능하다. 반면 KeepAll 또는 큰 큐 크기의 구독은 누적된 다수의 메시지를 모두 처리해야 하므로, spin_some()의 실행 시간이 가변적이다.

행동 트리 Tick과의 인터리빙에서는 일반적으로 KeepLast(1) QoS를 사용하여, 항상 최신 데이터만 활용하고 spin_some()의 실행 시간을 최소화하는 것이 권장된다. 메시지 이력이 필요한 경우에는 콜백 내에서 별도의 버퍼에 누적하되, 구독 큐 자체는 최소한으로 유지한다.


참고 문헌

  • 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/
  • Macenski, S., Foote, T., Gerkey, B., Lalancette, C., & Woodall, W. (2022). Robot Operating System 2: Design, architecture, and uses in the wild. Science Robotics, 7(66), eabm6074.