hello, robot

로봇 프로세스가 왜 멀티스레드인지부터 보면 이 주제가 왜 필요한지가 선명해져요. 노드 하나에는 시리얼이나 소켓에서 센서를 읽는 수신 스레드, 일정 주기로 명령을 내보내는 제어 루프, 통신과 로깅을 처리하는 실행기가 같이 살아요. 센서는 자기 속도로 들어오고 제어는 자기 주기를 지켜야 해서 한 줄로 세울 수가 없어요.

데이터 레이스는 “가끔 이상함”이 아니에요

두 스레드가 같은 메모리를 동기화 없이 동시에 접근하고 그중 하나라도 쓰기이면 데이터 레이스예요. C++ 표준에서 이건 성능 문제나 간헐적 오류가 아니라 정의되지 않은 동작이에요. 컴파일러와 CPU가 동기화 없는 코드를 마음대로 재배치하고 캐싱할 수 있어서, 어떤 결과가 나와도 표준 위반이 아니라는 뜻이에요.

증상이 잡기 어려운 이유도 여기서 나와요. 레이스는 타이밍이 맞아야 터지니까 디버그 빌드에서는 멀쩡하다가 최적화를 켜거나 실기체에서 부하가 걸리면 나타나요. “가끔 관절 값이 튄다”, “하루에 한 번 죽는다”가 전형적인 얼굴이에요. 그래서 증상을 쫓는 대신 공유 지점마다 동기화를 설계로 보장하는 쪽으로 접근해요.

잠금 안에서는 복사만 해요

이 주제에서 실전을 가르는 건 자물쇠를 잡는 구간의 길이예요. 제어 루프가 1kHz로 돈다는 건 한 바퀴에 1ms 예산이라는 뜻인데, 수신 쪽이 자물쇠를 쥔 채 파싱이나 파일 기록 같은 긴 일을 하면 제어 루프가 그 앞에서 기다리다 주기를 놓쳐요.

// 나쁜 형태 — 잠근 채로 오래 작업
{
    std::lock_guard<std::mutex> lk(m_);
    parse(raw_);          // 파싱을 잠금 안에서
    log_to_file(raw_);    // 파일 IO까지 — 제어 루프가 이걸 기다림
}
 
// 좋은 형태 — 잠금 안에서는 스냅샷만
std::vector<uint8_t> snapshot;
{
    std::lock_guard<std::mutex> lk(m_);
    snapshot = raw_;      // 복사만 하고 즉시 해제
}
parse(snapshot);          // 긴 일은 전부 잠금 밖에서

우선순위가 낮은 스레드가 자물쇠를 쥔 채 밀려나서 높은 쪽이 같이 멈추는 우선순위 역전까지 가면, 겉보기 증상은 “제어가 가끔 덜컥거림”이라 원인 추적이 오래 걸려요.

자물쇠가 여러 개일 때는 잠그는 순서가 스레드마다 다르면 서로가 서로를 기다리는 교착이 나요. 두 개를 같이 잡아야 하면 std::scoped_lock lk(m1, m2); 한 줄로 잡으면 순서 문제를 라이브러리가 처리해 줘요.

공유하는 건 대개 최신값 하나예요

센서 스레드와 제어 루프가 주고받는 데이터는 대부분 지나간 값 전부가 아니라 지금 값이에요. 최신 관절각, 최신 포즈, 최신 스캔이요. 그래서 큐가 아니라 한 칸짜리 상자에 덮어쓰는 형태가 기본 패턴이 돼요.

template <typename T>
class Latest {
    T value_{};
    bool has_{false};
    mutable std::mutex m_;
public:
    void set(T v) {                  // 수신 스레드가 덮어씀
        std::lock_guard<std::mutex> lk(m_);
        value_ = std::move(v);
        has_ = true;
    }
    std::optional<T> get() const {   // 제어 루프가 복사해 감
        std::lock_guard<std::mutex> lk(m_);
        if (!has_) return std::nullopt;
        return value_;
    }
};

get()이 참조가 아니라 복사본을 돌려주는 게 요점이에요. 참조를 돌려주면 잠금을 푼 뒤에도 호출자가 내부 데이터를 계속 읽게 되어 레이스가 부활하거든요. 포인트클라우드처럼 복사가 무거우면 shared_ptr<const T>를 상자에 넣어 포인터만 교체하는 변형을 써요. 읽는 쪽 여럿이 복사 없이 같은 프레임을 보고 수명은 참조 카운트가 지켜 주는데, 이건 C++ 소유권과 수명 — 스마트 포인터에서 본 공유 소유가 스레드 경계에서 그대로 쓰이는 자리예요.

아직 센서가 한 번도 안 들어온 상태를 optional로 표현한 것도 실전 디테일이에요. 첫 프레임 전에 제어 루프가 돌면 “값 없음”을 명시적으로 처리해야 하니까요.

기다릴 때는 조건자를 붙여요

프레임이 와야 움직이는 비전 처리 스레드처럼 데이터를 기다리는 쪽도 있어요. 반복문으로 계속 확인하면 CPU를 태우니 조건 변수로 재웠다가 데이터가 오면 깨워요.

std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); });   // 조건자 필수
Frame f = std::move(q.front()); q.pop();
lk.unlock();                                // 꺼냈으면 풀고, 처리는 밖에서
process(f);

함정은 하나예요. wait는 신호 없이도 깨어날 수 있어요. 운영체제 사정으로 일어나는 가짜 기상인데, 그래서 조건자 없는 cv.wait(lk)는 쓰지 않고 항상 깨어난 김에 조건을 다시 확인하는 형태를 써요. waitlock_guard가 아니라 unique_lock을 받는 이유도 여기 있어요. 자는 동안 자물쇠를 풀었다가 깨면서 다시 잡아야 하니 중간에 풀 수 있는 자물쇠가 필요하거든요.

atomic은 플래그와 카운터까지

std::atomic<T>는 변수 하나에 대한 읽기와 쓰기를 쪼개지지 않는 연산으로 만들어요. 로봇 코드에서 실제로 쓰는 자리는 두 개예요. 정지 플래그와 통계 카운터요.

std::atomic<bool> running{true};
 
void rx_loop() {
    while (running.load()) { read_serial_once(); }
}

일반 bool로 쓰면 데이터 레이스이고, 실제로 최적화가 “이 변수는 이 스레드에서 안 바뀌네”라고 판단해 확인을 반복문 밖으로 빼 버리면 영원히 안 멈추는 스레드가 나와요.

경계도 분명해요. 원자적인 건 연산 하나씩이지 연산의 조합이 아니에요. if (x.load() == 0) x.store(1);은 확인과 쓰기 사이에 다른 스레드가 끼어들 수 있고, 필드 여러 개를 함께 바꾸는 것도 atomic으로는 안 돼요. 그건 mutex의 영역이에요.

주기는 sleep_for가 아니라 sleep_until로

주기 루프를 sleep_for(1ms)로 짜면 주기가 조금씩 밀려요. 한 바퀴 작업에 0.2ms가 걸렸다면 실제 주기는 1.2ms가 되고 오차가 바퀴마다 쌓여요. 마감 시각을 등차로 정해 두고 그 시각까지 자는 형태가 표준 관용구예요.

auto next = clock::now() + period;
while (running.load()) {
    control_step();                       // 작업 시간이 들쭉날쭉해도
    std::this_thread::sleep_until(next);  // 마감 시각은 등차로 고정
    next += period;
}

시계는 steady_clock을 써요. system_clock은 시각 동기화로 뒤로도 갈 수 있는 벽시계라서, 주기 제어에 쓰면 시간이 튀는 날 루프도 같이 튀어요. 실제 하드웨어 제어에서는 여기에 마감을 넘긴 횟수를 세는 계측을 붙이는데, 그게 실시간성을 재는 가장 기본적인 방법이에요.

다섯 개로 좁혀져요

로봇 동시성의 실전은 결국 패턴 몇 개예요. 잠금은 복사와 교환만 하고 짧게, 최신값은 한 칸 상자로, 대기는 조건자 있는 조건 변수로, 플래그와 카운터만 atomic으로, 주기는 마감 시각 기준으로요.

실패가 났을 때 이 구조가 어떻게 반응해야 하는지는 C++ 에러 처리 — 로봇 노드의 실패 정책에서 이어져요. 재시도를 어느 스레드에 둘지가 여기서 나눈 구조와 바로 붙거든요.