왜 비동기가 필요한가?

우리 모두는 러스트로 빠르고 안전한 소프트웨어를 작성할 수 있음을 잘 알고 있습니다. 그렇다면, 어떻게 해야 비동기 프로그래밍으로도 빠르고 안전한 소프트웨어를 만들 수 있을까요?

비동기 프로그래밍, 줄여서 비동기는 동시적 프로그래밍 모델 중 하나로서, 갈수록 많은 프로그래밍 언어가 지원하고 있습니다. 비동기는 async/await 문법을 통해, 평범한 동기 프로그래밍의 형태와 느낌을 비슷하게 가져가면서도, 적은 수의 운영체제 스레드 위에서 많은 수의 동시적 태스크를 실행할 수 있게 해줍니다.

비동기 vs 다른 동시성 모델

동시적 프로그래밍은 통상적인 절차적 프로그래밍보다 덜 성숙되었고, 덜 "표준화"되었습니다. 그 결과, 우리는 사용하는 언어가 어떤 동시적 프로그래밍 모델을 지원하는 지에 따라 동시성을 다르게 표현합니다.

가장 인기있는 동시성 모델에 대해 간단하게 알아보면서, 왜 비동기 프로그래밍이 좀더 폭넓은 동시적 프로그래밍 분야에 적합한지 이해해 봅시다.

  • 운영체제 스레드는 프로그래밍 모델에 어떠한 변경도 필요하지 않아 동시성을 표현하기 매우 쉽습니다. 하지만, 스레드들을 동기화 하는 것이 어려울 수 있고, 성능 오버헤드가 큽니다. 스레드풀이 이러한 단점들을 완화해 줄 수 있지만, 대규모 입출력이 필요한 워크로드에 적용하기에는 역부족입니다.
  • 이벤트-드리븐 프로그래밍콜백 과 연계하여 고성능을 낼 수 있지만, 종종 번잡한 "비선형" 흐름제어가 필요하게 됩니다. 보통 데이터 흐름과 에러 전파를 추적하기 어려워집니다.
  • 코루틴은 스레드처럼 프로그래밍 모델을 변경할 필요가 없어 사용하기 쉽습니다. 또, 비동기처럼 많은 수의 태스크를 지원할 수도 있습니다. 하지만, 코루틴은 시스템 프로그래밍과 커스텀 런타임 구현에 필요한 저수준의 디테일을 추상화해 버립니다.
  • 행위자 모델은 분산 시스템과 매우 흡사하게, 모든 동시적 연산을 행위자라는 기본 단위로 나누고, 행위자들이 실패가능한 메시지 전달체계를 통해 통신하게 합니다. 행위자 모델은 효율적으로 구현될 수 있지만, 흐름제어와 재시도 로직과 같은 해결되지 않은 많은 현실적인 문제들이 남아 있습니다.

요약하면, 비동기 프로그래밍은 스레드와 코루틴의 인간공학적 이득(역주: 편이성)을 제공하면서도, 러스트 같은 저수준 언어에 적합한 고성능의 구현을 가능하게 해줍니다.

러스트의 비동기 vs 다른 언어의 비동기

비록 비동기 프로그래밍이 많은 언어에서 지원된다고 하더라도, 몇몇 디테일들은 구현에 따라 다릅니다. Rust의 비동기 구현은 몇 가지 측면에서 다른 대다수의 언어들과 다릅니다.

  • 러스트에서는 Future가 불활성(inert)이므로 오직 poll되었을 때에만 진행됩니다. future를 drop하면 더 이상 진행되지 않습니다.
  • 러스트에서는 비동기가 제로 코스트이므로(런타임 오버헤드가 없으므로) 사용자가 실제 사용한 연산에 대해서만 비용을 지불하면 됩니다. 특히, 힙 할당과 동적 디스패치 없이도 비동기를 사용할 수 있으므로, 성능에 큰 이점이 됩니다.
  • 러스트가 기본 제공하는 내장 런타임이 없고, 대신에 커뮤니티가 관리하는 크레잇을 통해 런타임이 제공됩니다.
  • 러스트에서는 싱글과 멀티스레드 런타임 모두를 지원하며, 각각은 장단점이 있습니다.

러스트의 비동기 vs 스레드

러스트에서 비동기를 대체할 수 있는 첫번째는 선택지는 운영체제 스레드로, std::thread를 이용해 직접적으로 사용하거나 또는 스레드풀을 이용하여 간접적으로도 사용할 수 있습니다. 스레드에서 비동기로 전환하거나 그 반대의 경우에도 보통 많은 양의 리팩토링이 필요합니다. 이 리팩토링 범위에는 구현과 (라이브러리를 만들고 있다면) 노출된 퍼블릭 인터페이스 둘 다 포함됩니다. 그렇기 때문에, 개발초기에 요구사항에 적합한 모델을 제대로 선정하여야 많은 개발 시간을 단축할 수 있습니다.

운영체제 스레드는 CPU와 메모리 오버헤드를 수반하기 때문에, 적은 수의 태스크에 적합합니다. 아이들 상태의 스레드도 시스템 자원을 소비하기 때문에 스레드 생성과 전환은 꽤 비쌉니다. 스레드풀 라이브러리는 이 비용들 중 일부만 줄여줄 뿐입니다. 하지만, 스레드를 이용하면 별다른 코드 변경없이 기존의 동기 코드를 재사용할 수 있으므로, 별도의 프로그래밍 모델이 필요 없습니다. 몇몇 운영체제에서는 스레드의 우선순위를 바꿀 수 있어서 장치 드라이버와 다른 지연시간에 민감한 어플리케이션을 만들 때 유용합니다.

비동기는 CPU와 메모리 오버헤드를 꽤 줄여주는 데, 특히, 서버와 데이터베이스 같은 대량의 입출력에 의존하는 태스크에서 효과가 큽니다. 나머지는 동일하며, 비동기 런타임은 태스크를 다룰 때 스레드보다 적은 비용을 사용하기 때문에, 운영체제 스레드보다 한 자릿수는 더 많은 태스크를 사용할 수 있습니다. 하지만, 비동기 러스트는 상태기계가 비동기 함수들로부터 생성되고, 실행파일마다 비동기 런타임이 포함되어야 하기 때문에, 바이너리 사이즈 증가를 초래합니다.

한 가지 더 말씀드리자면, 비동기 프로그래밍은 스레드보다 더 좋은 것이 아니라 다른 것입니다. 성능 측면에서 굳이 비동기가 필요하지 않다면, 보통 스레드가 보다 간단한 대안이 될 것입니다.

예제: 동시적 다운로드

이 예제의 목표는 두 개의 웹 페이지를 동시적으로 다운로드 하는 것입니다. 전형적인 스레드 어플리케이션이라면, 동시성을 위해 스레드를 생성하여야 합니다.

fn get_two_sites() {
    // 태스크에 사용될 두 개의 스레드 생성
    let thread_one = thread::spawn(|| download("https://www.foo.com"));
    let thread_two = thread::spawn(|| download("https://www.bar.com"));

    // 두 개의 스레드가 완료될 때까지 기다림
    thread_one.join().expect("thread one panicked");
    thread_two.join().expect("thread two panicked");
}

하지만, 웹 페이지를 다운로드는 작은 태스크이므로 스레드를 생성하는 것은 많은 낭비이고, 병목을 일으킬 수 있습니다. 비동기 러스트에서는, 이런 태스크들을 추가 스레드 없이 동시적으로 실행할 수 있습니다.

async fn get_two_sites_async() {
    // 완성될때까지 실행된다면, 웹페이지를 비동기적으로 다운로드 할 두 개의 다른
    // "future"를 만들기
    let future_one = download_async("https://www.foo.com");
    let future_two = download_async("https://www.bar.com");

    // 두 개의 future를 완성될때까지 동시에 실행하기
    join!(future_one, future_two);
}

여기서는, 추가적인 스레드를 생성하지 않았습니다. 게다가, 모든 함수 호출들이 정적으로 디스패치되었고, 힙 할당도 일어나지 않았습니다! 하지만, 처음 부분에서 코드를 비동기적으로 작성해야 했는데, 바로 이 책이 여러분에게 도움을 줄 부분입니다.

러스트의 커스텀 동시성 모델

마지막으로, 러스트는 스레드와 비동기 둘 중에 하나를 선택하도록 강요하지 않습니다. 한 어플리케이션에서 두 개의 모델 모두를 사용할 수 있으므로, 여러분이 스레드와 비동기에 복합적으로 의존할 때에 유용할 것입니다. 사실, 여러분은 (구현한 라이브러리만 있다면) 이벤트-드리븐 프로그래밍 같은 또 다른 동시성 모델도 사용할 수 있습니다.