비동기 프로그래밍의 기초: Async, Await, Futures, Streams
비동기 프로그래밍의 기초: Async, Await, Futures, Streams
컴퓨터에 시키는 작업 중에는 끝나기까지 시간이 꽤 걸리는 것들이 많아요. 그런 오래 걸리는 프로세스가 끝나기를 기다리는 동안 다른 뭔가를 할 수 있다면 좋겠죠. 현대 컴퓨터는 한 번에 둘 이상의 작업을 처리하는 두 가지 기법을 제공합니다. 바로 병렬성(parallelism)과 동시성(concurrency)이에요. 하지만 우리가 작성하는 프로그램의 로직은 대체로 선형적으로 쓰여 있어요. 우리가 원하는 건 프로그램이 수행할 연산과 함수가 잠시 멈출 수 있는 지점·그 대신 프로그램의 다른 부분이 실행될 수 있는 지점을 지정하는 것이지, 각 코드 조각이 정확히 어떤 순서로 어떻게 돌아야 하는지를 미리 정하는 게 아니에요. 비동기 프로그래밍(asynchronous programming) 은 잠재적인 일시정지 지점과 최종 결과의 관점에서 코드를 표현하게 해 주는 추상화로, 조정(coordication)의 세부 사항을 우리 대신 처리해 줍니다.
출처: The Rust Book - Fundamentals of Asynchronous Programming
이번 장은 병렬성과 동시성을 위해 스레드를 사용한 16장을 바탕으로, 코드를 작성하는 대안적인 접근법을 소개합니다. Rust의 futures · streams · async·await 문법은 연산이 어떻게 비동기일 수 있는지 표현하게 해 주고, 비동기 런타임(asynchronous runtime)을 구현한 서드파티 크레이트는 비동기 연산의 실행을 관리하고 조정하는 코드입니다.
예시를 하나 들어 볼게요. 가족 행사를 담은 영상을 내보내기(export)한다고 해 봅시다. 이 작업은 몇 분에서 몇 시간까지 걸릴 수 있어요. 영상 내보내기는 쓸 수 있는 CPU·GPU 성능을 최대한 사용합니다. CPU 코어가 하나뿐이고 운영체제가 그 내보내기 작업이 끝날 때까지 멈추지 않는다면 —— 즉 그 작업을 동기적(synchronously) 으로 실행한다면 —— 그 작업이 도는 동안 컴퓨터에서 다른 건 아무것도 할 수 없겠죠. 꽤 답답한 경험이 될 거예요. 다행히 운영체제는 보이지 않게 내보내기 작업을 자주 중단해서 다른 작업도 동시에 진행되게 해 줍니다.
이번에는 다른 사람이 공유한 영상을 다운로드한다고 해 볼게요. 이것도 시간이 걸리지만 CPU 시간을 그렇게 많이 차지하진 않아요. 이 경우 CPU는 네트워크에서 데이터가 도착하기를 기다려야 합니다. 데이터가 도착하기 시작하면 읽기 시작할 수 있지만, 전부 도착하기까지는 시간이 걸릴 수 있어요. 데이터가 전부 다 와도, 영상이 꽤 크다면 전부 불러오는 데 최소 1~2초는 걸릴 수 있어요. 별것 아닌 것처럼 들릴지 몰라도 초당 수십억 연산을 수행하는 현대 프로세서에게는 아주 긴 시간이에요. 역시나 운영체제가 보이지 않게 프로그램을 중단해, 네트워크 호출이 끝나기를 기다리는 동안 CPU가 다른 작업을 하게 해 줍니다.
영상 내보내기는 CPU 바운드(CPU-bound) 또는 연산 바운드(compute-bound) 연산의 예시예요. 컴퓨터가 CPU나 GPU 안에서 처리할 수 있는 데이터 처리 속도, 그리고 그 속도 중 이 연산에 전용할 수 있는 양에 의해 제한되죠. 영상 다운로드는 I/O 바운드(I/O-bound) 연산의 예시입니다. 컴퓨터의 입출력(input and output) 속도에 의해 제한돼서, 데이터가 네트워크를 통해 전송될 수 있는 만큼만 빠르게 진행될 수 있어요.
두 예시 모두에서, 운영체제의 보이지 않는 중단은 일종의 동시성을 제공해요. 다만 그 동시성은 프로그램 전체 단위에서만 일어납니다. 운영체제가 한 프로그램을 중단시켜 다른 프로그램이 작업을 진행하게 하는 거죠. 많은 경우 우리는 자신의 프로그램을 운영체제보다 훨씬 세밀한 수준으로 이해하기 때문에, 운영체제가 보지 못하는 동시성 기회를 발견할 수 있어요.
예를 들어 파일 다운로드를 관리하는 도구를 만든다면, 하나의 다운로드를 시작해도 UI가 멈추지 않고, 사용자가 여러 다운로드를 동시에 시작할 수 있게 프로그램을 작성할 수 있어야 합니다. 그런데 네트워크와 상호작용하는 운영체제 API 상당수는 차단적(blocking) 이에요. 즉 그들이 처리하는 데이터가 완전히 준비될 때까지 프로그램의 진행을 막아버리죠.
참고: 생각해 보면 대부분의 함수 호출이 이렇게 동작해요. 다만 차단(blocking) 이라는 용어는 보통 파일·네트워크·컴퓨터의 다른 자원과 상호작용하는 함수 호출에만 붙습니다. 그런 경우가 개별 프로그램이 비차단(non-blocking) 연산에서 이득을 보는 상황이니까요.
각 파일을 다운로드하기 위해 전용 스레드를 하나씩 띄우면 메인 스레드를 차단하는 일은 피할 수 있어요. 하지만 그 스레드들이 사용하는 시스템 자원의 오버헤드가 결국 문제가 될 거예요. 차라리 호출이 처음부터 차단하지 않으면 좋겠죠. 그리고 프로그램이 완료하기를 원하는 여러 작업을 정의하고, 런타임이 그것들을 최적의 순서와 방식으로 실행하도록 맡길 수 있으면 더 좋고요.
그것이 바로 Rust의 async(비동기(asynchronous) 의 줄임말) 추상화가 주는 것입니다. 이번 장에서는 다음 주제를 다루면서 async에 대해 전부 배울 거예요:
- Rust의
async와await문법을 쓰고 런타임으로 비동기 함수를 실행하는 법 - 16장에서 봤던 것과 같은 과제 일부를 async 모델로 해결하는 법
- 멀티스레딩과 async가 어떻게 서로 보완적인 해법을 제공하고, 많은 경우 둘을 결합할 수 있는지
다만 async가 실제로 어떻게 동작하는지 보기 전에, 병렬성과 동시성의 차이를 간단히 짚고 넘어갈 필요가 있어요.
병렬성과 동시성 (Parallelism and Concurrency)
지금까지 우리는 병렬성과 동시성을 거의 같은 의미로 다뤄왔어요. 이제부터는 이 둘을 더 정확히 구분해야 합니다. 작업을 시작하면 그 차이가 드러나거든요.
팀이 소프트웨어 프로젝트의 작업을 나누는 여러 방식을 떠올려 보세요. 한 명에게 여러 작업을 맡길 수도 있고, 각 구성원에게 작업 하나씩 맡길 수도 있으며, 두 방식을 섞을 수도 있어요.
한 사람이 어떤 작업도 끝나기 전에 여러 다른 작업을 진행하는 경우가 동시성(concurrency) 입니다. 동시성을 구현하는 한 가지 방법은 컴퓨터에 서로 다른 프로젝트 두 개를 체크아웃해 놓고, 한 프로젝트가 지루해지거나 막히면 다른 프로젝트로 전환하는 것과 비슷해요. 당신은 한 사람이니까 두 작업을 정확히 동시에 진행할 수는 없지만, 전환하면서 하나씩 진행하는 멀티태스킹은 가능하지요(그림 17-1 참고).
팀이 작업 그룹을 나눌 때 각 구성원이 작업 하나를 맡아 혼자 진행하는 경우가 병렬성(parallelism) 입니다. 팀의 각 사람은 정확히 동시에 진행할 수 있어요(그림 17-2 참고).
이 두 작업 흐름 모두에서 서로 다른 작업 간에 조정이 필요할 수 있어요. 어떤 사람에게 맡긴 작업이 다른 사람들의 작업과 완전히 독립적이라고 생각했는데, 사실은 팀의 다른 사람이 자기 작업을 먼저 끝내야 하는 경우일 수 있죠. 일부 작업은 병렬로 수행될 수 있지만, 일부는 실제로 직렬(serial) 입니다. 즉 한 작업이 끝난 다음에야 다음 작업이 실행될 수 있는 형태로, 그림 17-3과 같아요.
마찬가지로, 자신의 작업 하나가 다른 자신의 작업에 의존한다는 걸 깨닫게 될 수도 있어요. 그러면 당신의 동시성 작업도 직렬이 되어 버립니다.
병렬성과 동시성은 서로 교차할 수도 있어요. 동료가 당신의 작업 하나가 끝나기 전까지 막혀 있다는 사실을 알게 되면, 아마 동료를 "막힘(block)" 해제하기 위해 그 작업에 모든 노력을 집중할 거예요. 그러면 당신과 동료는 더 이상 병렬로 작업할 수 없고, 당신은 자신의 작업들도 더 이상 동시에 진행할 수 없게 됩니다.
같은 기본 역학이 소프트웨어와 하드웨어에도 적용됩니다. CPU 코어가 하나인 머신에서 CPU는 한 번에 하나의 연산만 수행할 수 있지만, 그래도 동시적으로 작업할 수 있어요. 스레드·프로세스·async 같은 도구를 사용하면 컴퓨터는 어떤 활동을 일시정지하고 다른 활동으로 전환하다가 결국 다시 첫 번째 활동으로 돌아올 수 있습니다. CPU 코어가 여러 개인 머신에서는 병렬로 작업할 수도 있어요. 한 코어가 어떤 작업을 수행하는 동안 다른 코어가 완전히 무관한 작업을 수행할 수 있고, 그 연산들은 실제로 동시에 일어나죠.
Rust에서 async 코드를 실행하는 것은 보통 동시적으로 일어납니다. 하드웨어, 운영체제, 그리고 우리가 사용하는 async 런타임(자세한 건 곧 다룰게요)에 따라, 그 동시성은 내부적으로 병렬성을 사용할 수도 있어요.
이제 Rust에서 async 프로그래밍이 실제로 어떻게 동작하는지 본격적으로 들어가 봅시다.