비동기 프로그래밍의 기초: Async, Await, Futures, 그리고 Streams
컴퓨터가 수행하는 많은 작업은 완료까지 시간이 걸릴 수 있다. 이러한 오래 걸리는 프로세스가 완료될 때까지 기다리는 동안 다른 작업을 할 수 있다면 좋을 것이다. 현대 컴퓨터는 한 번에 여러 작업을 처리하기 위해 두 가지 기술을 제공한다: 병렬성(parallelism)과 동시성(concurrency). 그러나 병렬 또는 동시 작업을 포함하는 프로그램을 작성하기 시작하면, 작업이 시작된 순서대로 완료되지 않을 수 있는 비동기 프로그래밍의 고유한 문제에 직면하게 된다. 이 장에서는 16장에서 다룬 스레드를 통한 병렬성과 동시성에 이어, 비동기 프로그래밍을 위한 대안적인 접근 방식을 소개한다. Rust의 Futures, Streams, 그리고 이를 지원하는 async
와 await
문법, 그리고 비동기 작업을 관리하고 조율하는 도구들에 대해 알아볼 것이다.
예를 들어, 가족 행사를 담은 동영상을 내보내는 작업을 한다고 가정해 보자. 이 작업은 몇 분에서 몇 시간까지 걸릴 수 있다. 동영상 내보내기는 가능한 한 많은 CPU와 GPU 자원을 사용할 것이다. 만약 CPU 코어가 하나뿐이고 운영체제가 내보내기 작업이 완료될 때까지 이를 일시 중지하지 않는다면, 즉 동기적으로(synchronously) 실행한다면, 그 작업이 실행되는 동안 컴퓨터에서 다른 작업을 할 수 없을 것이다. 이는 매우 불편한 경험이 될 것이다. 다행히, 운영체제는 내보내기 작업을 자주 눈에 띄지 않게 중단시켜 다른 작업을 동시에 수행할 수 있게 해준다.
이제 다른 사람이 공유한 동영상을 다운로드하는 경우를 생각해 보자. 이 작업도 시간이 걸리지만 CPU 시간을 많이 차지하지는 않는다. 이 경우, CPU는 네트워크에서 데이터가 도착하기를 기다려야 한다. 데이터가 도착하기 시작하면 읽을 수는 있지만, 모든 데이터가 도착하려면 시간이 걸릴 수 있다. 데이터가 모두 도착하더라도, 동영상이 매우 크다면 이를 모두 로드하는 데 최소 1~2초가 걸릴 수 있다. 이는 짧은 시간처럼 들릴 수 있지만, 현대 프로세서가 1초에 수십억 번의 연산을 수행할 수 있다는 점을 고려하면 매우 긴 시간이다. 다시 말해, 운영체제는 네트워크 호출이 완료될 때까지 기다리는 동안 프로그램을 눈에 띄지 않게 중단시켜 CPU가 다른 작업을 수행할 수 있게 해준다.
동영상 내보내기는 CPU-bound 또는 compute-bound 작업의 예시이다. 이는 CPU 또는 GPU 내에서 컴퓨터의 잠재적 데이터 처리 속도와 그 작업에 할당할 수 있는 속도에 의해 제한된다. 반면, 동영상 다운로드는 IO-bound 작업의 예시이다. 이는 컴퓨터의 입출력(input and output) 속도에 의해 제한되며, 네트워크를 통해 데이터를 보낼 수 있는 속도만큼 빠르게 진행된다.
이 두 예시에서 운영체제의 눈에 띄지 않는 중단은 동시성의 한 형태를 제공한다. 그러나 이 동시성은 전체 프로그램 수준에서만 발생한다: 운영체제는 한 프로그램을 중단시켜 다른 프로그램이 작업을 수행할 수 있게 한다. 많은 경우, 우리는 운영체제보다 더 세부적인 수준에서 프로그램을 이해하기 때문에, 운영체제가 볼 수 없는 동시성의 기회를 발견할 수 있다.
예를 들어, 파일 다운로드를 관리하는 도구를 만든다면, 하나의 다운로드를 시작해도 UI가 멈추지 않도록 프로그램을 작성할 수 있어야 하며, 사용자가 동시에 여러 다운로드를 시작할 수 있어야 한다. 그러나 네트워크와 상호작용하는 많은 운영체제 API는 블로킹(blocking) 방식이다. 즉, 처리 중인 데이터가 완전히 준비될 때까지 프로그램의 진행을 막는다.
참고: 대부분의 함수 호출이 이렇게 동작한다고 생각할 수 있다. 그러나 블로킹이라는 용어는 일반적으로 파일, 네트워크, 또는 컴퓨터의 다른 리소스와 상호작용하는 함수 호출에 사용된다. 왜냐하면 이러한 경우에 개별 프로그램이 작업이 **논블로킹(non-blocking)**으로 수행되는 것에서 이점을 얻을 수 있기 때문이다.
우리는 각 파일을 다운로드하기 위해 전용 스레드를 생성함으로써 메인 스레드가 블로킹되는 것을 피할 수 있다. 그러나 이러한 스레드의 오버헤드는 결국 문제가 될 수 있다. 처음부터 호출이 블로킹되지 않는 것이 더 바람직하다. 또한 블로킹 코드에서 사용하는 것과 같은 직접적인 스타일로 작성할 수 있다면 더 좋을 것이다. 예를 들어 다음과 같다:
let data = fetch_data_from(url).await;
println!("{data}");
이것이 바로 Rust의 async(비동기의 줄임말) 추상화가 제공하는 것이다. 이 장에서는 다음과 같은 주제를 다루며 async에 대해 자세히 알아볼 것이다:
- Rust의
async
와await
문법을 사용하는 방법 - 16장에서 다룬 동일한 문제를 해결하기 위해 async 모델을 사용하는 방법
- 멀티스레딩과 async가 상호 보완적인 솔루션을 제공하며, 많은 경우에 이를 결합할 수 있는 방법
그러나 async가 실제로 어떻게 동작하는지 살펴보기 전에, 병렬성과 동시성의 차이에 대해 짧게 논의할 필요가 있다.
병렬성과 동시성
지금까지 병렬성(parallelism)과 동시성(concurrency)을 거의 같은 개념으로 다뤘다. 하지만 이제는 이 둘을 더 정확히 구분해야 한다. 차이점을 명확히 이해해야 실제 작업을 시작할 때 혼란을 피할 수 있다.
소프트웨어 프로젝트에서 팀이 작업을 나누는 다양한 방식을 생각해 보자. 한 멤버에게 여러 작업을 할당하거나, 각 멤버에게 하나의 작업을 할당하거나, 두 방식을 혼합할 수 있다.
한 사람이 여러 작업을 동시에 진행하되, 아직 완료되지 않은 상태에서 작업을 전환하는 것을 동시성이라고 한다. 예를 들어, 컴퓨터에서 두 개의 프로젝트를 동시에 진행하다가 한 프로젝트에서 막히거나 지루해지면 다른 프로젝트로 전환하는 경우를 생각해 보자. 한 사람이기 때문에 두 작업을 정확히 동시에 진행할 수는 없지만, 작업을 번갈아 가며 진행하면서 각각의 작업에 조금씩 진전을 이룰 수 있다(그림 17-1 참조).
반면, 팀이 작업을 나누어 각 멤버가 하나의 작업을 맡아 독립적으로 진행하는 것을 병렬성이라고 한다. 이 경우 팀의 각 멤버는 정확히 동시에 작업을 진행할 수 있다(그림 17-2 참조).
이 두 워크플로우에서도 서로 다른 작업 간의 조정이 필요할 수 있다. 예를 들어, 한 사람에게 할당된 작업이 다른 사람의 작업과 완전히 독립적이라고 생각했지만, 실제로는 팀의 다른 사람이 먼저 작업을 완료해야 할 수도 있다. 일부 작업은 병렬로 수행할 수 있지만, 일부는 순차적으로만 수행할 수 있다. 즉, 한 작업이 끝난 후에야 다음 작업을 시작할 수 있다(그림 17-3 참조).
마찬가지로, 자신의 작업 중 하나가 다른 작업에 의존한다는 사실을 깨달을 수도 있다. 이제 동시에 진행하던 작업이 순차적으로 바뀌게 된다.
병렬성과 동시성은 서로 교차할 수도 있다. 동료가 자신의 작업 중 하나를 완료할 때까지 막혀 있다는 사실을 알게 되면, 아마도 그 작업에 모든 노력을 집중해 동료를 ’차단 해제’하려 할 것이다. 이제는 더 이상 병렬로 작업할 수 없고, 자신의 작업을 동시에 진행할 수도 없다.
이러한 기본적인 역학은 소프트웨어와 하드웨어에서도 동일하게 적용된다. 단일 CPU 코어를 가진 머신에서는 CPU가 한 번에 하나의 작업만 수행할 수 있지만, 여전히 동시성을 활용할 수 있다. 스레드, 프로세스, 비동기(async)와 같은 도구를 사용하면 컴퓨터는 하나의 작업을 일시 중지하고 다른 작업으로 전환한 후 다시 원래 작업으로 돌아올 수 있다. 반면, 여러 CPU 코어를 가진 머신에서는 병렬로 작업을 수행할 수 있다. 하나의 코어가 한 작업을 수행하는 동안 다른 코어는 완전히 무관한 작업을 동시에 수행할 수 있다.
Rust에서 비동기(async) 작업을 다룰 때는 항상 동시성을 다룬다. 사용하는 하드웨어, 운영체제, 비동기 런타임(나중에 자세히 설명)에 따라, 이 동시성은 내부적으로 병렬성을 활용할 수도 있다.
이제 Rust에서 비동기 프로그래밍이 실제로 어떻게 동작하는지 자세히 알아보자.