5.6 Rust 비동기 생태계 및 런타임 통합

5.6 Rust 비동기 생태계 및 런타임 통합

초보 Rust 개발자가 가장 멘붕에 빠지는 지점은 컴파일러가 아니라 비동기(Async) 생태계다. C++나 Java처럼 언어 스펙에 표준 스레드 엔진이 묶여있지 않고, 개발자가 직접 엔진(Tokio, async-std 등)을 골라 자동차에 얹어야 하기 때문이다.

Zenoh는 네트워크 I/O 성능의 극한을 추구하기 위해 자체 스레드 풀을 우걱우걱 씹어먹는 멍청한 짓을 하지 않고, 사용자 애플리케이션이 띄워 둔 비동기 런타임 위에서 거머리처럼 기생(Piggyback) 하여 동작하도록 설계되었다.
이 챕터에서는 전 세계 Rust 비동기 표준으로 군림한 tokio 생태계와 Zenoh를 어떻게 융합해야 컨텍스트 스위칭(Context Switching) 오버헤드를 제로(0)로 만들 수 있는지, 그 하드코어 런북을 서술한다.

1. Tokio 런타임 위에서의 Zenoh 구동 완벽 가이드

Zenoh 코어는 내부 I/O 타이머와 스레드 매니징을 전적으로 tokio 에 의존한다. 따라서 Cargo.toml에 단순히 tokio를 추가하는 것을 넘어, 서버 코어 수에 맞게 워커 스레드를 할당하는 런타임 이그니션(Ignition) 작업이 필수다.

1.0.1 [Runbook] 프로덕션 레벨 Tokio 엔진 튜닝

단순히 #[tokio::main] 매크로를 붙이는 건 데스크톱 장난감용이다.
10만 개의 센서를 수용(Ingestion)하는 백엔드라면 CPU 토폴로지에 맞춘 하드코어 수동 설정이 필요하다.

[정석] 런타임 수동 빌더 및 스레드 핀(Pin) 할당

use std::thread;
use tokio::runtime::Builder;

fn main() {
    // 16코어 서버에서 12개의 스레드만 비동기 워커로 할당하여, 
    // 나머지 4개 코어는 OS 프로세스를 방해하지 않게 숨통을 틔워준다.
    let cores = 12; 

    let runtime = Builder::new_multi_thread()
        .worker_threads(cores)
        .enable_all() // I/O와 타이머 엔진을 전부 켠다
        .thread_name("zenoh-worker")
        // 워커 스레드의 스택 사이즈를 늘려 복잡한 재귀 연산 시 Stack Overflow 방어
        .thread_stack_size(3 * 1024 * 1024) 
        .build()
        .unwrap();

    // 생성된 하드코어 런타임 속으로 진입
    runtime.block_on(async {
        println!("Tokio 멀티스레드 런타임 기동. 워커 수: {}", cores);
        
        let session = zenoh::open(zenoh::config::Config::default()).res().await.unwrap();
        // 무한 구독 스트림 루프...
        tokio::signal::ctrl_c().await.unwrap();
    });
}

tokio 튜닝을 통해, 당신의 통신 백엔드는 하나의 스레드가 블로킹되더라도 다른 11개의 스레드가 잉여 CPU 연산(Work-Stealing)을 가져가서 전체 레이턴시를 1ms 이하로 강력하게 스케줄링하는 지능형 통신망으로 진화한다.

2. async-await를 활용한 넌블로킹(Non-blocking) 통신 파이프라인 구축

수천 대의 로봇에 명령(Put)을 내릴 때, 1번 로봇의 응답 핸드셰이크가 늦어져 2번, 3번 로봇으로 가는 명령 통로가 막혀버리면 대형 참사가 발생한다. 이를 블로킹(Head-of-Line Blocking) 이라고 부른다.

2.0.1 [Runbook] 비동기 팬아웃(Fan-out) 병렬 격발 전술

await 키워드는 “기다린다“는 뜻이지만, 스레드를 재우는(Sleep) 것이 아니라 “이 작업이 끝날 때까지 내 CPU 코어를 다른 함수에게 양보한다(Yield)“는 뜻이다.

안티 패턴 (직렬 블로킹 발사)

for i in 1..=1000 {
    // 1번 로봇이 패킷을 느리게 빨아먹으면 뒷단 999대가 줄서서 기다린다!
    publisher.put(format!("robot/{}/cmd", i), "MOVE").res().await.unwrap(); 
}

[정석] Tokio Spawn을 활용한 샷건(Shotgun) 병렬 격발
루프 1천 번을 돌면서 tokio::spawn으로 비동기 스레드 파생체(Task)를 1천 개 생성한다.

use std::sync::Arc;

let session = zenoh::open(Config::default()).res().await.unwrap();

// 1천 개의 태스크에 뿌려야 하므로, 하나의 세션을 Arc나 clone 연산으로 던진다.
// (zenoh의 Session은 Clone에 최적화된 저비용 구조체다)
let mut tasks = vec![];

for i in 1..=1000 {
    let s = session.clone();
    let topic = format!("robot/{}/cmd", i);
    
    // 이 블록 안의 작업은 백그라운드 워커 스레드들이 알아서 노나 먹는다 (Work-Stealing)
    let handle = tokio::spawn(async move {
        s.put(&topic, "MOVE").res().await.unwrap();
    });
    tasks.push(handle);
}

// 샷건으로 흩뿌린 1천 개의 탄환이 라우터를 거쳐 모두 꽂힐 때까지 안전하게 대기
for handle in tasks {
    handle.await.unwrap();
}

이 구조 설계를 흔히 넌블로킹 팬아웃(Fan-out) 이라 칭하며, 이 코드가 적용된 Zenoh 백엔드는 단일 2.4GHz 스레드 머신에서 초당 10만 건 이상의 독립적인 토픽 라우팅을 뿜어내는 괴물 같은 Throughput을 달성한다.

3. Zenoh 이벤트와 Tokio 채널(mpsc, broadcast)의 매핑

Zenoh 네트워크에서 데이터(sample)를 받았다고 해서 비동기 스레드 안에서 곧장 수 초짜리 AI 추론을 돌려버리면, 네트워크 I/O 버퍼 큐가 막혀버려 뒷단에 날아오던 패킷들이 증발(TCP Drop)해 버린다.

따라서 최강의 아키텍처는 “통신부가 데이터를 받자마자, 내부 비즈니스 스레드에게 던져버리고 즉시 네트워크 수신 모드로 복귀” 하는 2계층 분리 구조다.
이때 통신부와 비즈니스부를 잇는 파이프관이 바로 Tokio 채널(mpsc)이다.

3.0.1 [Runbook] 생산자(Zenoh) - 소비자(Tokio Channel) 파이프라인 매핑

graph LR
    A[Zenoh Router] -->|UDP/TCP| B(Zenoh Subscriber Stream)
    B -->|tokio::mpsc::channel| C(비즈니스 워커 1)
    B -->|tokio::mpsc::channel| D(비즈니스 워커 2)
    B -->|tokio::mpsc::channel| E(비즈니스 워커 N)

[코드 레벨 매핑] MPSC (Multi-Producer, Single-Consumer)

use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let session = zenoh::open(Config::default()).res().await.unwrap();
    
    // 1. 내부 워커 스레드와 통신할 '버퍼 10000개짜리 파이프' 통로를 뚫는다
    let (tx, mut rx) = mpsc::channel(10000);

    // 2. 비즈니스 소비자(Consumer) 스레드 가동
    tokio::spawn(async move {
        while let Some(sample_data) = rx.recv().await {
            // 외부 네트워크의 오버헤드를 신경 쓸 필요 없이 여기서 무거운 연산(AI 등)을 조진다!
            println!("내부 AI 처리: {}", sample_data);
        }
    });

    // 3. Zenoh 수신부 (Producer) 가동
    let mut subscriber = session.declare_subscriber("robot/*/camera").res().await.unwrap();
    
    // 네트워크 스트림이 떨어지면
    while let Ok(sample) = subscriber.recv_async().await {
        let payload = String::from_utf8_lossy(&sample.value.payload.contiguous()).into_owned();
        
        // mpsc 채널(tx)에 데이터를 박아 넣고 즉시 복귀! (채널 버퍼가 꽉 찼으면 await 에서 블로킹 됨)
        if tx.send(payload).await.is_err() {
            eprintln!("내부 파이프가 끊어졌습니다. 시스템을 종료합니다.");
            break;
        }
    }
}

이 패턴을 통해 Zenoh 네트워크 레이어(I/O 바운드)와 당신의 비즈니스 레이어(CPU 바운드)는 물리적으로 갈라져서 서로의 목을 조르지 않게 된다. 백프레셔(Backpressure)를 제어하는 진일보한 디자인 패턴이다.

4. async-stdsmol 런타임과의 호환성 고려사항

수십 개의 기업들이 tokio 프레임워크를 종교처럼 채택하고 있지만, 간혹 임베디드 펌웨어나 경량화된 컨테이너 환경에서는 tokio의 거대한 바이너리 용량에 부담을 느껴 더 가벼운 async-std나 마이크로 런타임 smol을 타겟으로 잡는 프로젝트가 있다.

Zenoh 코어는 이 지점에서도 타협하지 않고 멀티 런타임 호환성을 제공한다.

4.0.1 [Runbook] 마이너 런타임(Minor Runtime) 호환성 튜닝

1. async-std 런타임 강제 삽입
Zenoh는 기본적으로 async-std 와 호환되도록 설계된 비동기 특성(Stream, Future)을 완벽히 지원한다. async-std 진영의 #[async_std::main] 속성(액자) 안에서도 Zenoh API는 아무런 수정 없이 작동한다.

## Cargo.toml 설정 (용량 다이어트를 극대화하기 위해 tokio를 배제)
[dependencies]
zenoh = "0.10.1"
async-std = { version = "1.12", features = ["attributes"] }
// main.rs
#[async_std::main]
async fn main() {
    // 내부적으로 async-std 의 타이머 스레드를 뽑아먹으며 정상 구동된다!
    let session = zenoh::open(Config::default()).res().await.unwrap();
    
    // tokio::spawn 대신 async_std::task::spawn 을 쓴다.
    async_std::task::spawn(async move {
        // ...
    });
}

[아키텍처 인스펙션: 런타임 충돌 패닉 방어]
만약 당신의 사내 다른 파트에서 만든 암호화 플러그인이 tokio 전용 I/O 함수(tokio::net::TcpStream)를 강하게 결합해 쓰고 있다면, 당신의 메인 앱이 smol이나 async-std 일 때 런타임 의존성 충돌로 데드락(Deadlock)에 걸리거나 “Run in Tokio Context!” 패닉이 터질 수 있다.
Rust 비동기 진영의 생태계 분단(Fragmentation) 문제를 막기 위해, 운영계(Production) 아키텍처에서는 가급적 tokio를 글로벌 표준 엔진으로 채택하는 것이 기술 부채(Tech Debt)를 피하는 가장 지혜로운 전략이다.