비동기 프로그래밍의 기초: Async, Await, Futures, Streams
우리가 컴퓨터에게 시키는 작업 중에는 끝나기까지 시간이 꽤 걸리는 것이 많습니다. 오래 걸리는 작업이 끝나기를 기다리는 동안 다른 일도 할 수 있다면 좋겠지요. 현대의 컴퓨터는 한 번에 둘 이상의 작업을 다루기 위한 두 가지 기법, 즉 병렬성(parallelism)과 동시성(concurrency)을 제공합니다. 하지만 우리 프로그램의 논리는 대체로 선형적으로 작성됩니다. 우리는 프로그램이 수행해야 할 작업과, 함수가 잠시 멈추고 다른 부분이 대신 실행될 수 있는 지점을 표현하고 싶어 합니다. 그 과정에서 각 코드 조각이 정확히 어떤 순서와 방식으로 실행될지를 미리 전부 지정할 필요는 없습니다. 비동기 프로그래밍(asynchronous programming) 은 잠재적인 일시 중지 지점과 나중에 도착할 결과를 기준으로 코드를 표현하게 해 주는 추상화이며, 조율에 필요한 세부 사항은 그 추상화가 대신 처리합니다.
이 장은 16장에서 스레드를 이용해 병렬성과 동시성을 다뤘던 내용을 바탕으로,
코드를 작성하는 또 다른 접근법을 소개합니다. 바로 Rust의 퓨처(future),
스트림(stream), 그리고 연산이 비동기적으로 수행될 수 있음을 표현하는 async,
await 문법, 그리고 비동기 런타임을 구현하는 서드파티 크레이트입니다.
비동기 런타임은 비동기 연산의 실행을 관리하고 조율하는 코드입니다.
예를 하나 생각해 봅시다. 가족 행사를 찍은 영상을 내보내기(export)한다고 해보죠. 이 작업은 몇 분이 걸릴 수도 있고 몇 시간이 걸릴 수도 있습니다. 영상 내보내기는 가능한 한 많은 CPU와 GPU 성능을 사용하려 할 것입니다. 만약 CPU 코어가 하나뿐이고 운영체제가 그 작업이 끝날 때까지 중간에 멈추지 않는다면, 즉 내보내기를 동기적으로(synchronously) 실행한다면, 그 작업이 돌아가는 동안 컴퓨터에서 다른 일을 전혀 할 수 없을 것입니다. 꽤 답답한 경험이 되겠지요. 다행히도 실제 운영체제는 내보내기 작업을 보이지 않게 자주 끊어 주기 때문에, 그동안에도 다른 작업을 동시에 할 수 있습니다.
이번에는 다른 사람이 공유한 영상을 다운로드한다고 해봅시다. 이것도 시간이 꽤 걸릴 수 있지만 CPU 시간을 그렇게 많이 쓰지는 않습니다. 이 경우 CPU는 네트워크로 부터 데이터가 도착하기를 기다려야 합니다. 데이터가 도착하기 시작하면 바로 읽기 시작할 수는 있지만, 전부 도착하기까지는 시간이 걸릴 수 있습니다. 데이터가 다 도착한 뒤에도 영상이 크다면 전부 읽어 들이는 데 1~2초는 족히 걸릴 수 있습니다. 짧게 들릴지 모르지만, 초당 수십억 번의 연산을 수행하는 현대 프로세서에게는 아주 긴 시간입니다. 이 경우에도 운영체제는 네트워크 호출이 끝나기를 기다리는 동안 프로그램을 보이지 않게 중단시켜 CPU가 다른 일을 할 수 있도록 합니다.
영상 내보내기는 CPU-bound 또는 compute-bound 연산의 예입니다. 이 작업은 CPU나 GPU 내부에서 데이터를 처리할 수 있는 잠재적인 속도, 그리고 그 속도 중 얼마나 많은 부분을 해당 작업에 할당할 수 있는지에 의해 제한됩니다. 반면 영상 다운로드는 I/O-bound 연산의 예입니다. 이 작업은 컴퓨터의 입출력(input and output) 속도에 의해 제한되며, 네트워크를 통해 데이터가 전달되는 속도 이상으로 빨라질 수 없습니다.
이 두 예 모두에서 운영체제가 보이지 않게 수행하는 인터럽트는 동시성의 한 형태를 제공합니다. 다만 이 동시성은 전체 프로그램 수준에서만 일어납니다. 운영체제가 한 프로그램을 멈추고 다른 프로그램이 일을 하게 만드는 방식이지요. 하지만 많은 경우에 우리는 운영체제보다 훨씬 더 세밀한 수준에서 우리 프로그램을 이해하고 있기 때문에, 운영체제가 볼 수 없는 동시성의 기회를 직접 발견할 수 있습니다.
예를 들어 파일 다운로드를 관리하는 도구를 만든다고 하면, 하나의 다운로드를 시작했다고 해서 UI가 멈추면 안 되고, 사용자는 여러 다운로드를 동시에 시작할 수 있어야 합니다. 그런데 네트워크와 상호작용하는 많은 운영체제 API는 blocking 입니다. 즉, 처리 중인 데이터가 완전히 준비될 때까지 프로그램의 진행을 가로막는 방식입니다.
Note: 생각해 보면 대부분의 함수 호출이 원래 이런 식으로 동작합니다. 다만 blocking 이라는 용어는 보통 파일, 네트워크, 혹은 컴퓨터의 다른 자원과 상호작용하는 함수 호출에 주로 사용됩니다. 그런 경우에야말로 개별 프로그램이 non-blocking 방식에서 실제 이점을 얻기 때문입니다.
메인 스레드가 막히는 것을 피하려고 각 파일 다운로드마다 전용 스레드를 하나씩 만들 수도 있습니다. 하지만 그렇게 하면 결국 그 스레드들이 사용하는 시스템 자원의 오버헤드가 문제가 됩니다. 더 바람직한 방법은 호출 자체가 처음부터 블로킹되지 않고, 대신 프로그램이 완료하고 싶은 작업들의 집합을 정의해 두면 런타임이 그것을 어떤 순서와 방식으로 실행하는 것이 최선인지 선택하게 하는 것입니다.
바로 이것이 Rust의 async(asynchronous 의 줄임말) 추상화가 제공하는 것입니다. 이 장에서는 다음 내용을 통해 async를 전반적으로 배워 보겠습니다.
- Rust의
async,await문법을 사용하는 방법과, 런타임으로 비동기 함수를 실행하는 방법 - 16장에서 살펴본 몇 가지 문제를 async 모델로 해결하는 방법
- 멀티스레딩과 async가 서로 보완적인 해결책을 제공하며, 많은 경우 함께 조합할 수 있다는 점
하지만 async가 실제로 어떻게 동작하는지 보기 전에, 먼저 병렬성과 동시성의 차이를 짚고 넘어가야 합니다.
병렬성과 동시성
지금까지는 병렬성과 동시성을 거의 같은 의미로 다뤄 왔습니다. 하지만 이제부터는 두 개념을 더 정확하게 구분해야 합니다. 실제로 작업을 진행해 나가다 보면 그 차이가 드러나기 때문입니다.
소프트웨어 프로젝트에서 팀이 일을 나누는 여러 방식을 생각해 봅시다. 한 사람에게 여러 작업을 맡길 수도 있고, 각 사람에게 하나의 작업만 맡길 수도 있으며, 두 방식을 섞어 쓸 수도 있습니다.
어떤 개인이 여러 작업을 오가며, 그중 어느 것도 끝내지 않은 상태에서 계속 진행한다면 이것은 동시성(concurrency) 입니다. 동시성을 구현하는 한 가지 방식은 컴퓨터에 서로 다른 두 프로젝트를 체크아웃해 두고, 하나가 지루해지거나 막히면 다른 프로젝트로 옮겨 가는 것과 비슷합니다. 당신은 한 사람이므로 두 작업을 정확히 같은 순간에 동시에 진행할 수는 없지만, 둘 사이를 오가며 한 번에 하나씩 진척을 만들 수 있습니다(그림 17-1).
반대로 팀이 작업 묶음을 나누어 각 구성원이 하나의 작업만 맡아 독립적으로 수행한다면, 이것은 병렬성(parallelism) 입니다. 팀의 각 사람은 정확히 같은 시간에 진척을 만들 수 있습니다(그림 17-2).
이 두 워크플로 모두에서 서로 다른 작업 사이 조율이 필요할 수 있습니다. 어떤 사람에게 맡긴 작업이 완전히 독립적이라고 생각했는데, 실제로는 팀의 다른 사람이 자기 작업을 먼저 끝내야만 진행될 수도 있습니다. 어떤 일은 병렬로 처리할 수 있지만, 또 어떤 일은 실제로 직렬적(serial) 입니다. 즉 그림 17-3처럼 하나의 작업이 끝나야 다음 작업이 이어지는 순차적인 흐름에서만 진행될 수 있습니다.
마찬가지로, 자신이 맡은 작업 중 하나가 다른 작업에 의존한다는 사실을 깨달을 수도 있습니다. 그러면 원래는 동시적으로 처리하던 일도 직렬적으로 바뀝니다.
병렬성과 동시성은 서로 교차할 수도 있습니다. 동료가 당신의 작업 하나가 끝나기를 기다리며 막혀 있다는 사실을 알게 되면, 아마 그 동료의 막힘을 풀어 주기 위해 그 작업에 집중하게 될 것입니다. 그러면 당신과 동료는 더 이상 병렬로 일할 수 없고, 당신 자신도 여러 작업을 동시적으로 오가며 처리하기 어려워집니다.
소프트웨어와 하드웨어에서도 같은 기본 원리가 적용됩니다. CPU 코어가 하나뿐인 머신에서는 CPU가 한 번에 하나의 연산만 수행할 수 있지만, 여전히 동시적으로 작업할 수는 있습니다. 스레드, 프로세스, async 같은 도구를 사용하면 컴퓨터는 하나의 작업을 잠시 멈추고 다른 작업으로 전환한 뒤, 나중에 다시 처음 작업으로 돌아올 수 있습니다. CPU 코어가 여러 개인 머신이라면 병렬 작업도 가능합니다. 한 코어가 한 작업을 수행하는 동안 다른 코어는 전혀 관계없는 다른 작업을 수행할 수 있으며, 이 연산들은 실제로 같은 시간에 일어납니다.
Rust에서 async 코드를 실행할 때는 보통 동시성이 발생합니다. 그리고 사용하는 하드웨어, 운영체제, 비동기 런타임(곧 자세히 살펴봅니다)에 따라, 그 동시성은 내부 구현에서 병렬성을 활용할 수도 있습니다.
이제 Rust의 비동기 프로그래밍이 실제로 어떻게 동작하는지 살펴봅시다.