Rust 비동기 프로그래밍: async를 들추고 Future를 살펴보자

· by 박승재

비동기(Asynchronous) 프로그래밍: 프로그램 코드가 순차적으로 실행됨을 보장하지 않음

일반적으로, 프로그램의 코드는 순차적으로 실행됩니다.

한 함수가 실행되면, 그 다음 함수는 실행 중인 함수가 종료되고 결과를 반환할 때까지 대기해야 합니다.

하지만 파일 읽기/쓰기, 데이터베이스 동기화 등, CPU 연산 시간보다 다른 대상(운영체제 등)에 맡겨두고 결과를 기다리는 시간이 더 긴 작업까지도 함수를 순차적으로 실행하기 위해 무작정 기다리는 것은 너무 비효율적일 겁니다.

차라리 현재 함수가 완료되기를 기다리면서 놀고 있는 CPU를 다른 함수를 실행하는데 사용할 수 있다면, 프로그램은 더 효율적으로 동작할 것입니다.

“함수를 기다리지 말고 계속 진행하자!”

이것이 비동기 프로그래밍의 핵심입니다.

Future

Rust에서는 Futureasync/await를 이용해 비동기 함수를 작성합니다.

Future아직 결과를 얻지 못했을 수도 있는 객체입니다.

비동기 함수는 동기 함수와 다르게 언제 결과를 받을 수 있지 모릅니다.

예시를 보여주면,

let sum = add(1, 9);
let total = multiply(sum, 2);

위 코드에서 add 함수는 multiply 함수가 실행되기 전까지는 결과를 반환해 sum에 저장할 것입니다.

그렇다면 비동기 함수일 때는 어떻게 되는지 생각해보죠.

let sum = async_add(1, 9);
let total = multiply(sum, 2);

만약 async_add가 비동기 함수라면, 개념상으로는 async_add 계산이 끝나기도 전에 multiply 함수가 실행될 수 있습니다.

이는 논리적으로 말이 안되는 상황입니다.

multiplyasync_add의 결과인 sum이 필요하기 때문이죠.

따라서 Future를 이용해 sum은 아직 async_add로부터 결과를 받지 못했을 수도 있다는 것을 명시해야 합니다.

그렇다면 async_add 함수는 아래와 같이 정의될 것입니다.

fn async_add(a: i32, b: i32) -> impl Future<Output=i32>

당연하게도 multiply 함수도 비동기 함수가 되어야 합니다.

sum을 언제 받을지 모른다면, total를 언제 반환할 수 있을지도 모르기 때문입니다.

async/await

위 예시처럼 한 번 만들어진 Future계속 전파되기 때문에, 언젠가는 모든 코드가 Future로 뒤덮일지도 모릅니다.

그렇게 되면, 코드를 알아보기 매우 어려울 것이기에 비동기 프로그래밍을 위한 키워드, async/await가 있습니다.

모든 함수를 비동기로 작성하는 것 자체가 잘못된 것은 아닙니다.

오히려 더 좋은 성능이 나올지도 모르죠.

문제는 여기저기 복잡하게 얽혀있는 Future 때문에 코드를 읽기 어려워지는 것입니다.

async는 함수가 암시적으로 Future를 반환하게 만듭니다.

async fn async_add(i32, i32) -> i32

위 코드는 아래 코드와 동일합니다.

fn async_add(i32, i32) -> impl Future<Output=i32>

await는 비동기 함수가 완료되어 결과를 반환할 때까지 비동기적으로 기다립니다.

let sum: i32 = async_add(1, 9).await;
let total = multiply(sum, 2);

await를 이용해 sum의 값을 받아냈으므로, 이 경우는 multiply를 비동기 함수로 만들지 않아도 됩니다.

await비동기 함수와 동기 함수를 함께 사용할 수 있게 하는 역할을 한다고 생각하면 됩니다.

impl Future

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

Futuretrait으로, 함수를 비동기로 만들기 위해서는 trait직접 구현해야 합니다.

poll 함수가 그 중심에 있는데, 작업 완료 여부에 따라 Poll::ReadyPoll::Pending을 반환합니다.

아직 작업이 완료되지 않은 상태라면, Poll::Pending을 반환합니다. 그리고, ContextWaker를 이용해 완료 예상시점에 다시 poll을 호출하여 완료 여부를 확인합니다.

작업이 끝났다면 Poll::Ready(value)값이 준비(ready)되었다고 알림과 동시에 값을 반환합니다.

여기서 await최초로 poll 함수를 호출Future에 등록된 작업을 실행하는 역할을 합니다.

Delay Future 예제

지정한 시각 이후에 다시 작업을 시작하는 딜레이 Future를 구현해봅시다.

struct Delay {
    when: Instant,
}

// Future 구현
impl Future for Delay {
    type Output = (); // 반환 값 없음

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>
    {
        if Instant::now() >= self.when { // 지정한 시각 이후
            Poll::Ready(()) // 완료, Future 종료
        } else { // 지정한 시각이 안 됐음
            cx.waker().wake_by_ref(); // 다시 poll 실행해서 확인할 것
            // 다음 poll에는 현재보다 조금 시간이 흘렀을 것이므로, 지정한 시각을 넘었을 수도 있음
            Poll::Pending // 아직 안 끝남
        }
    }
}

println과 같은 동기 코드와 함께 Future를 사용할 때는 아래와 같이 await를 통해 실행하면 됩니다.

async fn main() {
    let when = Instant::now() + Duration::from_millis(10);
    let delay = Delay { when };
    delay.await;
    println!("after 10ms");
}

참고: Asynchronous Programming Book

참고: Tokio - Async in depth

참고: A stack-less Rust coroutine library under 100 LoC

참고: The Waker API I: what does a waker do?

참고: Async 공부