buttercrab's profile image

buttercrab

February 17, 2023 00:00

다양한 언어에서의 async/await

async , C++ , Javascript , Python , Rust

동시성과 병렬성, 멀티쓰레드와 멀티프로세스

무어의 법칙이란 반도체 성능이 24개월마다 2배씩 증가하게 된다는 법칙입니다. 이 법칙은 최근까지 잘 맞아떨어지지만 이제는 한계에 가까워졌다고 합니다. 그래서 최근에는 CPU의 코어 수를 늘리는 추세입니다. 즉, 프로그래머는 CPU의 많은 코어를 모두 활용해야 프로그램이 돌아가는데 많은 이점을 볼 수 있습니다.

그래서 최근에 많은 프로그래밍 언어에서 동시성과 병렬 프로그래밍을 지원하고 있습니다. 병렬 프로그래밍에는 다양한 방법이 있는데, 그 중 오늘은 Async IO, 비동기적 IO에 대해 설명하겠습니다.

Async IO란?

Async IO의 정의

그럼 Async IO란 무엇일까요? 한국말로 풀어쓰면 비동기적인 입력/출력 (IO는 Input/Output) 입니다. 컴퓨터는 단순하게 생각해보면 데이터를 연산하는 도구입니다. 필요한 데이터를 CPU로 가져와서 연산을 한 뒤, 다시 연산된 결과를 원하는 위치에 넣는 것입니다.

예시를 들면, 우리가 주로 프로그래밍 언어 내에서 사용하는 변수는 CPU 레지스터나 캐시, 메모리상에 존재합니다. 각 위치에 있는 데이터를 가지고 와서 CPU에서 연산을 해서 다시 레지스터, 캐시, 메모리에 넣게 됩니다. 우리가 파일을 읽으려고 하면 하드디스크나 SSD에서 데이터를 가져와서 CPU가 연산을 하는 것입니다. 우리가 인터넷을 사용하는 것은 멀리 떨어진 서버에서 데이터를 가져오는 것이고, 프린트나 소리를 듣는 것도 프린터와 스피커에 데이터를 보내는 것입니다.

데이터의 대기 시간

이렇게 데이터를 옮길 때는 시간이 많이 걸리게 됩니다. 빠른 캐시조차도 CPU의 연산 속도에 비해서는 느립니다. 아래는 대기 시간에 대한 대략적인 속도를 나타내는 표입니다. (출처)

항목 시간 (nanosecond) 시간 (microsecond) 시간 (millisecond)
L1 캐시 참조 0.5 ns    
분기 예측 실패 5 ns    
L2 캐시 참조 7 ns    
뮤텍스(Mutex) 잠금/해제 25 ns    
메모리 접근 100 ns    
Zippy로 1KB 압축하기 3,000 ns 3 us  
1Gbps 네트워크에서 1KB 보내기 10,000 ns 10 us  
SSD에서 4KB 랜덤하게 읽기 150,000 ns 150 us  
메모리에서 1MB 순차적으로 읽기 250,000 ns 250 us  
SSD에서 1MB 순차적으로 읽기 1,000,000 ns 1,000 us 1 ms
하드디스크에서 1MB 순차적으로 읽기 20,000,000 ns 20,000 us 20 ms
캘리포니아 -> 네럴란드 -> 캘리포니아로 패킷 보내기 150,000,000 ns 150,000 us 150 ms

CPU의 연산 속도는 어떨까요? CPU는 클럭이라는 것이 있습니다. 제품을 설명할 때 3GHz 등으로 표기되는 것입니다. 이는 CPU가 연산을 하는 빈도수입니다. 3Ghz이면 1초에 30억번 연산을 하는 것입니다. 그러면 1번 연산, 즉 1 클럭의 시간은 대략 0.3 ns가 됩니다. 클럭이 올라가면 시간은 줄어들게 됩니다.

이제 위의 표에 있는 시간들이 감이 오시나요? 보기에는 매우 빠르게 느낄 수도 있지만, CPU의 연산 속도에 비해서는 매우 느립니다.

프로그래밍 언어에서의 적용

다음 의사 코드를 볼까요?

function download_files(files) do
    for each file in files do
        download file
    end
end

여러 파일들을 다운로드 받는 함수입니다. 파일 한 개의 다운로드가 끝나면 그 다음 파일을 다운받게 됩니다. 그런데 파일을 다운로드 받는 동안에는 CPU는 아무것도 하지 않습니다. 다음 그림은 설명하는 과정을 나타냅니다.

이렇게 CPU가 일을 하지 않을 때 다른 작업을 하는 방법이 Async IO입니다.

CPU가 기다리는 모든 일은 IO 작업입니다. 어떤 데이터를 요청하고 기다리고 있기 때문이죠. 그래서 이를 합쳐서 비동기적 IO라고 합니다.

Async IO는 항상 어떤 값을 기다립니다. 미래에 그 값을 CPU의 연산 없이 얻을 수 있는 의미입니다. 그래서 대부분의 언어에서는 Future(Promise)라는 개념이 있습니다. 대부분의 언어에서는 Future 객체를 곧바로 반환을 한 뒤, 그 객체에서 기다릴 수 있는 방법을 제공합니다. 그럼 다음과 같이 의사 코드를 작성할 수 있습니다.

async function download_files(files) do
    for each file in files do
        future <- async_download file
        wait for future
    end
end

그런데, 바뀐 코드를 자세히 보면 실제로 다운로드 받는 함수는 달라졌지만 결국 순차적으로 기다리게 됩니다. 우리는 한번에 모든 Future를 기다릴 수 있어야 합니다.

여러 Future를 기다리기

대부분의 언어에서는 여러 Future를 한 번에 기다릴 수 있는 방법을 제공합니다. 이는 커널에서 제공되는 기능이기도 합니다. IO Multiplexing으로 Linux에서는 epoll, BSD에서는 kqueue 등의 함수가 제공됩니다. 이를 사용해서 Async IO를 구성할 수 있습니다.

그럼 다음과 같이 의사 코드를 작성할 수 있습니다.

async function download_files(files) do
    task_list <- []

    for each file in files do
        future <- async_download file
        append future to task_list
    end

    wait for task_list
end

다음 그림은 위의 코드가 작동하는 과정을 설명합니다.

이렇게 실제로 CPU가 일하지 않는 시간에 다른 작업을 함으로써 전체적인 코드의 실행 시간이 빨라지게 됩니다.

Thread, Coroutine과의 비교

그럼 결국에 쓰레드(Thread), 코루틴(Coroutine)과의 차이점이 무엇인지에 대해서 궁금해 하실수도 있습니다. 겉보기에는 그저 병렬로 실행시킨 것이고 CPU가 안돌아갈 때 context-switching을 한 것이 아니냐고 생각할 수 있습니다.

하지만 가장 큰 차이점은 쓰레드와 코루틴은 CPU가 일을 하고 있을 때도 context-switching을 할 수 있다는 것입니다. 그래서 쓰레드와 코루틴은 CPU가 일을 하고 있을 때도 다른 작업을 할 수 있습니다. 하지만 Async IO는 CPU가 일을 하고 있을 때는 다른 작업을 할 수 없습니다.

쓰레드와 코루틴은 새로운 실행 흐름을 만드는 것이고 Async IO는 기존의 실행 흐름에서 미래에 전달받을 값을 기다릴 때 다른 작업을 하는 역할을 합니다.

Async IO의 장단점

그럼 어떤 장단점이 있을까요? Async IO는 새로운 실행 흐름을 만들지 않기 때문에 쓰레드와 코루틴에 비해서 메모리를 적게 사용합니다. 그래서 Stackless 디자인이 가능합니다. Stackless란 현재 프로그램이 실행될 때의 인자, 변수 등의 상태를 저장하지 않는 방식입니다. 그래서 속도가 빠르고 메모리 사용량이 적게 됩니다.

하지만 Async IO의 병렬성은 IO 대기에서 오기 때문에 CPU 연산의 병렬처리로 사용하기에는 적합하지 않습니다. 그래서 Async IO는 대부분의 경우에는 IO 작업을 병렬로 처리하기 위해서 사용됩니다.

Async IO의 구현

Javascipt

먼저 가장 많이 사용되는 언어 중 하나인 Javascript에서의 Async IO를 살펴보겠습니다. Javascript는 비동기적 IO를 위해서 Promise를 제공합니다. 하지만 Javascript에서의 동작 원리는 다른 언어들과는 다르게 동작합니다. Async 함수를 호출한 순간부터 그 함수가 실행됩니다.

아래 예시를 보면 이해가 가실 겁니다.

// t초만큼 기다리는 함수입니다.
async function wait(t) {
  return new Promise((resolve) => {
    setTimeout(resolve, t * 1000);
  });
}

async function print_hello() {
  console.log("Hello");
  await wait(1);
}

async function main() {
  let promise = print_hello();
  await wait(1);
  console.log("World");
  await promise;
}

main();

위 코드를 실행하면 다음과 같이 출력됩니다.

Hello
(1초후..)
World

promise를 나중에 await하지만 이미 print_hello 함수가 실행되어서 Hello가 출력되었습니다. 이는 Javascript가 Async 함수를 호출한 순간부터 그 함수가 실행되기 때문입니다.

우리가 위에서 살펴본 여러 파일들을 다운로드 하는 함수는 다음과 같이 구현할 수 있습니다.

async function download_files(files) {
  let futures = [];
  for (let file of files) {
    let future = fetch(file);
    futures.push(future);
  }
  return await Promise.all(futures);
}

Python

Python에서는 Async IO를 위해서 asyncio 모듈을 제공합니다. asyncio 모듈은 Python 3.4부터 기본으로 제공되는 모듈입니다. 이 모듈을 사용하면 쉽게 Async IO를 구현할 수 있습니다.

다음 예시를 볼까요?

import asyncio


async def print_hello():
    print("Hello")
    await asyncio.sleep(1)


async def main():
    future = print_hello()
    await asyncio.sleep(1)
    print("World")
    await future


asyncio.run(main())

실행을 시켜보면 Javascript와 다음과 같이 다르게 World가 먼저 출력되는 것을 확인할 수 있습니다.

(1초후..)
World
Hello
(1초후..)

다음은 asyncio 모듈을 사용해서 여러 파일들을 다운로드 하는 함수를 구현한 예시입니다.

import asyncio
import aiohttp
from typing import List

async def download(file: str) -> str:
    async with aiohttp.ClientSession() as session:
        async with session.get(file) as response:
            return await response.read()

async def download_files(files: List[str]) -> List[str]:
    futures = []
    for file in files:
        future = download(file)
        futures.append(future)
    return await asyncio.gather(*futures)

파이썬에서 신기한 점은 바로 Async IO를 코루틴으로 처리한다는 점입니다. 위에서는 코루틴과 Async IO를 비교했는데, 사실 파이썬은 성능을 중요시하지 않기 때문에 코루틴으로 비동기 IO를 처리합니다. 그럼 성능을 중요하시 하는 언어들에서는 어떻게 Async IO를 구현할까요?

Rust

Rust에서는 Async IO를 위해서 std::future 모듈을 제공합니다. 하지만 Rust는 기본적으로 Async IO를 위한 런타임을 제공하지 않습니다. 런타임을 제공하지 않기 때문에 Async IO를 구현하기 위해서는 런타임을 직접 구현해야 합니다. 가장 많이 사용되는 런타임은 tokio입니다.

Rust는 뼈대만 제공을 하고 런타임은 직접 유저가 구현하도록 하여 유저가 런타임을 선택할 수 있도록 합니다. 다음은 tokio를 사용해서 여러 파일들을 다운로드 하는 함수를 구현한 예시입니다.

use std::time::Duration;
use tokio::time::sleep;

async fn print_hello() {
    println!("Hello");
    sleep(Duration::from_secs(1)).await;
}

#[tokio::main]
async fn main() {
    let handle = print_hello();
    sleep(Duration::from_secs(1)).await;
    println!("World");
    handle.await;
}

파이썬과 같이 World가 먼저 출력되는 것을 확인할 수 있습니다.

(1초후..)
World
Hello
(1초후..)

하지만 다음 아래 코드들을 볼까요?

  1. tokio::spawn을 사용한 코드

    use std::time::Duration;
    use tokio::time::sleep;
    
    async fn print_hello() {
        println!("Hello");
        sleep(Duration::from_secs(1)).await;
    }
    
    #[tokio::main]
    async fn main() -> tokio::io::Result<()> {
        let handle = tokio::spawn(async move { print_hello().await });
        sleep(Duration::from_secs(1)).await;
        println!("World");
        handle.await?;
        Ok(())
    }
    

    결과:

    Hello
    (1초후..)
    World
    
  2. tokio::spawn을 사용하면서 single threaded worker를 사용한 코드

    use std::time::Duration;
    use tokio::time::sleep;
    
    async fn print_hello() {
        println!("Hello");
        sleep(Duration::from_secs(1)).await;
    }
    
    #[tokio::main(flavor = "current_thread")]
    async fn main() -> tokio::io::Result<()> {
        let handle = tokio::spawn(async move { print_hello().await });
        sleep(Duration::from_secs(1)).await;
        println!("World");
        handle.await?;
        Ok(())
    }
    

    결과:

    Hello
    (1초후..)
    World
    
  3. tokio::spawn을 사용하면서 single threaded worker + std::thread::sleep을 사용한 코드

    use std::thread::sleep;
    use std::time::Duration;
    
    async fn print_hello() {
        println!("Hello");
        sleep(Duration::from_secs(1));
    }
    
    #[tokio::main(flavor = "current_thread")]
    async fn main() -> tokio::io::Result<()> {
        let handle = tokio::spawn(async move { print_hello().await });
        sleep(Duration::from_secs(1));
        println!("World");
        handle.await?;
        Ok(())
    }
    

    결과:

    (1초후..)
    World
    Hello
    (1초후..)
    

왜 이런 결과가 나타날까요?

우선 Async의 작동원리에 대해서 알아야 합니다. Async는 Task라는 것으로 이루어져 있습니다. Task는 비동기로 실행되는 함수를 의미합니다. 쓰레드, 프로세스, 코루틴과 비슷한 개념입니다. 하지만 Task만의 차이점은 yield 되는 시점이 정해져 있다는 것입니다. 바로 IO 대기를 할 때 yield가 됩니다. 그 후 IO가 완료되면 다시 Task가 실행될 준비가 됩니다.

자 그럼 다시 1번 코드부터 볼까요? 1번 코드는 tokio::spawn을 사용해서 Task를 만들었습니다. tokio::spawn은 Task를 만들고 바로 실행을 시키는 큐에 넣습니다. 지금 멀티쓰레드 런타임이 돌아가고 있기 때문에, 비는 worker가 존재하고, 그 worker가 바로 Task를 실행합니다. 그러면 바로 Hello가 출력되고, 후에 메인 함수에서 World가 출력됩니다.

그럼 2번 코드는 어떨까요? 2번 코드는 1번 코드와 다르게 tokio::spawn을 사용했지만, tokio::mainflavor = "current_thread"를 추가했습니다. flavor = "current_thread"는 런타임을 single threaded로 만들어주는 옵션입니다. 그러면 앞서 말했던 것처럼, 비는 worker가 없기 때문에, 메인 쓰레드에서 Task를 실행합니다. 그런데, 출력 결과가 1번 코드와 같습니다. 이유는 await를 할 때 yield되기 때문입니다. 그러면 메인 함수에서 sleep을 할 때 메인 함수가 yield되고, print_hello가 실행됩니다. 그래서 1번과 같은 출력 결과가 나옵니다.

3번 코드는 2번 코드와 다르게 std::thread::sleep을 사용했습니다. std::thread::sleeptokio::time::sleep과는 다르게 yield되지 않습니다. 그래서 메인 함수에서 sleep을 하고, print_hello가 실행되지 않고, 메인 함수가 다시 실행됩니다. 그래서 World가 먼저 출력되고, Hello가 나중에 출력됩니다.

다음은 tokio를 사용해서 여러 파일들을 다운로드 하는 함수를 구현한 예시입니다.

async fn download_file(file: &str) -> Result<String> {
    let resp = reqwest::get(file).await?;
    let content = resp.text().await?;
    Ok(content)
}

async fn download_files(files: &'static [&str]) -> Result<Vec<String>> {
    let futures = FuturesUnordered::new();
    for file in files {
        futures.push(tokio::spawn(async move { download_file(file).await }));
    }
    let result = futures.map(|x| x?).try_collect().await?;
    Ok(result)
}

결론

Async IO는 IO를 많이 하는 프로그램에서 성능을 획기적으로 향상시킬 수 있습니다. 그래서 서버에서는 Async IO를 사용하는 것이 일반적입니다. 하지만 Async IO가 프로세스, 쓰레드, 코루틴보다 가볍다고 무조건 쓰면 안됩니다. IO를 많이 하는 프로그램이 아니라 CPU를 많이 사용하는 프로그램이라면, Async IO를 사용하는 것이 성능에 오히려 더 안좋을 수 있습니다. 그래서 Async IO를 사용할지 말지는 프로그램의 특성에 따라 다르게 결정해야 합니다.