1296.21 ThreadedAction의 스레드 관리 메커니즘

1. 스레드 관리의 개요

ThreadedAction의 스레드 관리 메커니즘은 tick() 스레드의 생성, 상태 추적, 합류(join), 정리를 포함하는 생명주기 관리 체계이다. BehaviorTree.CPP 라이브러리(버전 4.x)는 ThreadedAction 내부에서 std::thread 객체를 관리하며, executeTick()halt() 메서드를 통해 스레드의 생성과 종료를 자동으로 처리한다.

2. 스레드 생명주기

ThreadedAction의 스레드 생명주기는 다음의 네 단계로 구성된다.

2.1 생성 단계

executeTick()이 IDLE 상태에서 호출되면, 새로운 std::thread가 생성된다.

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 (...)
        {
            setStatus(NodeStatus::FAILURE);
        }
    });

    return NodeStatus::RUNNING;
}

스레드 생성 전에 이전 스레드가 아직 합류되지 않은 경우(joinable()), join()을 호출하여 이전 스레드를 완전히 정리한다. 이는 std::thread 소멸자에서 합류되지 않은 스레드가 존재하면 std::terminate()가 호출되는 것을 방지하기 위한 것이다.

2.2 실행 단계

스레드가 생성된 후 tick() 메서드가 별도 스레드에서 실행된다. 이 기간 동안 메인 스레드의 executeTick() 호출은 현재 상태(status())를 반환한다.

if (status() == NodeStatus::RUNNING)
{
    // 스레드가 실행 중: 현재 상태를 그대로 반환
    return status();
}

tick() 스레드가 실행 중인 동안 status()는 RUNNING을 반환한다. tick()이 완료되면 스레드 내부에서 setStatus()가 호출되어 상태가 SUCCESS 또는 FAILURE로 변경된다.

2.3 완료 단계

tick() 스레드가 반환하면, 스레드는 종료되나 아직 합류되지 않은 상태이다. 다음 executeTick() 호출에서 변경된 상태(SUCCESS/FAILURE)가 감지되고, 이 값이 부모에 반환된다. 스레드의 합류는 다음 번 스레드 생성 시 또는 소멸자에서 수행된다.

2.4 정리 단계

노드의 소멸자 또는 다음 실행의 스레드 생성 시에 join()이 호출되어 스레드 자원이 완전히 해제된다.

ThreadedAction::~ThreadedAction()
{
    halt();
    if (thread_.joinable())
    {
        thread_.join();
    }
}

소멸자에서 halt()를 호출하여 실행 중인 스레드의 종료를 요청하고, join()으로 스레드의 완전한 종료를 대기한다.

3. halt_requested_ 플래그 메커니즘

halt_requested_ 플래그는 std::atomic<bool> 타입의 멤버 변수로, 메인 스레드와 tick() 스레드 간의 협력적 중단 메커니즘을 구현한다.

class ThreadedAction : public ActionNodeBase
{
    std::atomic<bool> halt_requested_{false};

public:
    bool isHaltRequested() const
    {
        return halt_requested_.load();
    }

    void halt() override
    {
        halt_requested_.store(true);

        if (thread_.joinable())
        {
            thread_.join();
        }

        setStatus(NodeStatus::IDLE);
    }
};

3.1 플래그의 동작 과정

  1. 초기화: executeTick()에서 스레드 생성 전에 halt_requested_false로 설정한다.
  2. 설정: halt()가 호출되면 halt_requested_true로 설정한다.
  3. 확인: tick() 내부에서 isHaltRequested()를 주기적으로 호출하여 플래그를 확인한다.
  4. 응답: 플래그가 true이면 tick()은 실행을 중단하고 반환한다.

std::atomic<bool>의 사용은 두 스레드 간의 메모리 가시성(memory visibility)을 보장한다. halt_requested_.store(true)에 의한 변경은 halt_requested_.load()에 의해 즉시 관찰된다.

4. 스레드 합류(Join) 전략

std::thread::join()은 호출 스레드를 차단하여 대상 스레드의 종료를 대기한다. ThreadedAction에서 스레드 합류가 발생하는 시점은 다음과 같다.

4.1 halt()에서의 합류

void ThreadedAction::halt()
{
    halt_requested_.store(true);
    if (thread_.joinable())
    {
        thread_.join();  // tick() 스레드 종료 대기
    }
    setStatus(NodeStatus::IDLE);
}

halt()에서 join()이 호출되면, 메인 스레드는 tick() 스레드가 완전히 종료될 때까지 차단된다. 이는 Halt 처리의 완전성을 보장하나, tick() 내부의 차단 호출이 장시간 지속되면 메인 스레드도 함께 차단되는 위험이 있다.

4.2 재실행 시의 합류

if (status() == NodeStatus::IDLE)
{
    if (thread_.joinable())
    {
        thread_.join();  // 이전 스레드 정리
    }
    // 새 스레드 생성 ...
}

이전 실행의 스레드가 아직 합류되지 않은 경우, 새 스레드 생성 전에 합류를 수행한다. 이는 이전 스레드의 자원이 완전히 해제된 후에 새 스레드를 생성함을 보장한다.

4.3 소멸자에서의 합류

노드 객체의 소멸 시, 실행 중인 스레드가 있으면 합류를 수행한다. 이는 합류되지 않은 std::thread 객체의 소멸에 의한 std::terminate() 호출을 방지한다.

5. 상태 전이와 스레드의 관계

스레드의 상태와 노드의 상태 전이의 관계를 정리한다.

노드 상태 전이스레드 상태트리거
IDLE → RUNNING생성됨, 실행 중executeTick()
RUNNING → RUNNING실행 중executeTick() (후속 Tick)
RUNNING → SUCCESS종료됨 (미합류)tick() 반환 (SUCCESS)
RUNNING → FAILURE종료됨 (미합류)tick() 반환 (FAILURE)
RUNNING → IDLE종료됨 (합류 완료)halt()
SUCCESS/FAILURE → IDLE합류 대기부모에 의한 초기화

스레드의 종료와 노드의 상태 변경은 비동기적으로 발생한다. tick() 스레드가 setStatus(SUCCESS)를 호출하면 노드의 상태가 즉시 변경되나, 스레드 자체의 합류는 나중에 수행된다.

6. 스레드 풀 부재와 그 영향

BehaviorTree.CPP 4.x의 ThreadedAction은 스레드 풀(thread pool)을 사용하지 않으며, 매 실행마다 새로운 std::thread를 생성한다. 이 설계 선택의 영향을 분석한다.

6.1 장점

  1. 단순성: 스레드 풀 관리의 복잡성이 없다.
  2. 격리성: 각 실행이 독립적인 스레드에서 수행되므로, 이전 실행의 잔여 상태에 의한 오염이 없다.

6.2 단점

  1. 생성 비용: 스레드 생성의 오버헤드가 매 실행마다 발생한다.
  2. 확장성 제한: 다수의 ThreadedAction이 동시에 실행되면 스레드 수가 증가하여 시스템 자원을 소비한다.
  3. 스택 메모리 소비: 각 스레드는 기본 스택 크기(통상 1~8 MB)의 메모리를 할당받는다.

빈번히 실행되는 경량 작업에 ThreadedAction을 사용하면, 스레드 생성과 정리의 반복적 비용이 성능에 영향을 미칠 수 있다. 이러한 경우 StatefulActionNode에서 std::async를 사용하여 스레드를 관리하거나, 외부 스레드 풀을 활용하는 것이 대안이다.

7. 다중 ThreadedAction의 동시 실행 관리

Parallel 노드의 자식으로 다수의 ThreadedAction이 배치되면, 각각 별도의 스레드를 생성하여 동시에 실행된다.

Parallel
├── ThreadedAction_A (스레드 A)
├── ThreadedAction_B (스레드 B)
└── ThreadedAction_C (스레드 C)

활성 스레드: 메인 + A + B + C = 4

이 경우 스레드 관리에서 고려하여야 하는 사항은 다음과 같다.

  1. 동시 합류: Parallel의 정책이 충족되면, 나머지 RUNNING 자식이 Halt된다. 각 ThreadedActionhalt()가 순차적으로 호출되고, 각각 join()에서 스레드 종료를 대기한다. 합류 시간은 가장 오래 차단되는 스레드에 의해 결정된다.

  2. 블랙보드 경합: 다수의 tick() 스레드가 동시에 블랙보드에 접근하면 경합이 발생한다. BehaviorTree.CPP의 블랙보드는 내부적으로 뮤텍스로 보호되나, 빈번한 접근은 성능 저하를 유발할 수 있다.

  3. 시스템 자원 소비: 활성 스레드 수의 증가는 컨텍스트 스위칭 오버헤드와 메모리 소비를 증가시킨다.

8. 스레드 관리의 설계 지침

  1. 합류 보장: 모든 코드 경로에서 스레드가 합류되도록 보장한다. 합류되지 않은 std::thread의 소멸은 프로세스를 종료시킨다.

  2. Halt 응답 시간 제한: tick() 내부에서 isHaltRequested() 확인 간격을 Halt 응답 시간의 상한으로 설정한다. 100 ms 이내의 응답이 권장된다.

  3. 동시 스레드 수 제한: Parallel 노드에서 다수의 ThreadedAction을 사용하는 경우, CPU 코어 수를 초과하지 않도록 설계한다.

  4. 자원 정리의 완전성: tick() 스레드가 정상 종료 또는 Halt에 의해 종료되는 모든 경우에서 할당된 자원이 적절히 해제되도록 한다.

  5. 스레드 안전한 상태 전이: 노드의 상태 변경(setStatus())은 원자적 연산에 의해 보호된다. 사용자 정의 멤버 변수의 접근에는 별도의 동기화를 적용하여야 한다.