백엔드 개발자라면 비동기프로그래밍에 대해서 정말 많은 이야기를 들어보셨을거에요. 면접 단골 질문인 Node.js의 Event Loop부터 GoLang의 Goroutine까지 정말
비동기 라는 말을 안 들어봤으면 백엔드 개발자가 아니라는 말도 있을 정도니 정말 중요한 주제임을 틀림이 없는것 같습니다. 그럼 과연 비동기 프로그래밍은 무엇이고 왜 알아야하고 어떻게 사용하는게 좋을까요? 저는 이 시리즈에서 비동기 프로그래밍에 대해서 간략한소개를 해볼까 합니다. 비동기 프로그래밍을 소개한 다른 글은 많지만 이 시리즈는 개념부터 실제로 비동기 런타임을 코드로 구현을 해서 최대한 자세하기 설명 하는것을 목표로 합니다. 제가 지금 현업에서 러스트를 사용하고 있고 러스트를 좋아하기 때문에 이 글에서의 코딩 부분은 러스트로 할 예정입니다~
먼저 시리즈의 순서는
비동기 프로그래밍은 무엇인가?
Scheduler 이해하기
Rust에서의 비동기 프로그래밍
Rust Tokio 이해하기
입니다. 이 중 오늘은 Rust Tokio에 대해서 알아보겠습니다. 틀린 부분이나 설명이 모호한 부분이 있다면 댓글 혹은 이메일로 알려주시면 감사하겠습니다~
Rust가 처음에 탄생했을 당시 Rust std에는 비동기 프로그래밍에 대한 공식적인 지원이 없었습니다. 물론 2010년에는 비동기 프로그래밍이 지금처럼 보편적으로 사용되지 않았고 Rust가 웹 서버 전용 언어로 탄생하지는 않았기에 이에 대한 관심도 부족했습니다. 그러나 점점 Rust의 Ecosystem이 커지고 비동기 프로그래밍이 서버 프로그래밍에서 큰 비중을 차지하게 되면서 이에 대한 요구가 생기기 시작했습니다. 이러한 요구에 발맞추어 2017년 즈음, tokio 라는 async runtime 라이브러리가 등장하면서 Rust에서도 비동기 프로그래밍 관련 오픈소스 라이브러리가 속속 만들어지기 시작되었습니다. 2021년 기준, 많은 비동기 런타임 중 Tokio 와 Async-std가 독보적인 쌍두마차를 이루고 있습니다. 비동기를 지원하는 다른 오픈소스들도 기본적으로 위 두 프레임워크는 지원하는 편입니다. Async-std는 Tokio에 비해 후발주자로 시작했으나 std 의 api 들을 비동기로 지원하기 때문에 빠른 성장을 한편입니다. 그럼에도 불구하고 제가 이 시리즈에서는 Tokio를 다루기로 결정한 이유는 아직까지 Rust Async Ecosystem에서 Tokio는 de facto 프레임워크로 여겨지고 있고 많은 오픈소스 기여자들이 지금도 개선을 하고 있기 때문입니다. 단적인 예시로 Tokio의 경우 2019년 전까지는 기존의 Round robin 방식의 Task Scheduling Algorithm을 사용했지만 2019년 이후 2편에서 설명한 Work-Stealing Algorithm을 적용함으로써 성능적 이점을 이루었습니다. 또한 최근에 드디어 1.0 버젼을 출시함과 동시에 어느정도 std 와 api 를 비슷하게 맞추어서 사용자 측면에서도 동기로 쓰여져 있는 코드를 비동기로 전환하기 쉽게 되었습니다.
이처럼 최근에도 활발히 유지되고 있는 Tokio를 이번 글에서 살펴봄으로써 Rust에서는 어떤 방식으로 비동기 프로그래밍을 할수 있는지 배워보겠습니다.
이 그림이 익숙하신가요? 네 맞습니다. 사실 3편에서 저희가 직접 Runtime을 구현할때 위 그림에서 Futures 를 제외한다면 한번씩은 보았던 것들입니다. Tokio는 근본적으로 Mio를 통해 OS 들의 Event queue 를 사용하는 방식을 취하고 있기 때문에 큰 틀에서는 저희가 3편에서 만들었던 Runtime과 같은 구조를 가지고 있습니다. 다만 여기서 Futures는 아마 처음 보실것니다. Java의 CompletableFuture를 보신분들이라면 Future라는 단어가 익숙하실텐데요. Futures는 크게 futures, streams, sinks, executor 를 가지고 있는데 간단하게 설명으로 드리자면 futures 은 Js의 Promise와 대응되는 개념으로 비동기 상황에서 언젠가는 끝날 작업을 나타내고 streams 비동기로 생성된 series of values라고 할수 있습니다. Tokio는 이 Futures crate를 사용해서 비동기 작업들을 표현하게 됩니다.
Tokio는 내부적으로 여러가지 모듈들을 가지고 있는데 저희가 이 글에서 살펴볼 모듈들은 다음과 같습니다.
3편에서 살펴보았듯이 비동기 프레임워크에서 런타임은 가장 핵심적인 역할을 수행합니다. Tokio 런타임은 I/O Event Loop, Scheduler, Timer(일정 시간 후 진행되는 작업들을 스케쥴하기 위해 필요합니다. 3편에서 저희는 이 부분을 생략한 Runtime을 만들었습니다) 세 가지 기능을 모두 가지고 있어서 간단하게 Runtime만 initialize 한다면 어렵지 않게 비동기 프로그래밍을 할수 있습니다.
Tokio 런타임을 initialize하는 방법은 크게 두 가지가 있습니다.
Runtime::new();
use tokio::runtime::Runtime;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create the runtime
let rt = Runtime::new()?;
// Spawn the root task
rt.block_on(async {
//do some async work here
})
}
위의 예시처럼 직접 runtime을 만들고 block_on 안에 root task 만들어서 비동기로 구현을 할수 있습니다. 여기서 root task에는 tcp socket 같은 웹 서버의 중심이 되는 것들이 오게 됩니다. 여기서 짚고 넘어가야 할점은 block_on 함수는 blocking이기 때문에 그 뒤로 어떤 코드를 쓰던 block_on 이 return 하지 않으면 계속 blocking 상태로 남아있습니다.
main macro 사용하기
use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
1번과는 다르게 간단히 tokio::main macro를 호출하는것만으로도 런타임의 entrypoint를 만들어서 런타임으로 진입할수 있습니다. 다만 이렇게 되면 main함수 전체가 runtime안으로 들어온것이기 때문에 1번에서와는 달리 runtime이 종료 된 후, 혹은 runtime 을 두 개 이상 만드는 등의 작업은 진행할수 없게 됩니다. 그러나 일반적인 경우에서는 runtime을 한개이상 만들일이 극히 드물기 때문에 대부분의 경우 macro로 runtime을 만들게 됩니다.
Tokio에서 net 모듈은 정말 간단합니다! 이는 Tokio가 web-server자체를 목표로 개발된 프레임워크가 아니기 때문입니다. Tokio는 처음 만들어질때 async runtime을 제공함으로써 다른 라이브러리들의 building block 이 되는 방향을 지원했습니다. 그래서 net 모듈에는 TCP/UDP/Unix 네트워킹만을 지원합니다. 물론 tcp socket 만을 사용한다면 Tokio만으로도 웹 서버를 만드는데 다른 라이브러리들을 필요하지 않습니다.
Tcp socket 서버를 만드는 간단한 예시는 다음과 같습니다.
use tokio::net::TcpListener;
use std::io;
async fn process_socket<T>(socket: T) {
// do work with socket here
}
#[tokio::main]
async fn main() -> io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
loop {
let (socket, _) = listener.accept().await?;
process_socket(socket).await;
}
}
Tokio의 io 모듈은 std::io 같은 api 를 가지고 있습니다. 예를 들어 read()는 read().await 이런식으로 사용되게 말이죠. 간단하게 생각해서 std::io가 지원하는 api 는 모두 가지고 있다고 생각하시면 편합니다. std에 사용하는 buffered reader, writer또한 동일하게 가지고 있어서 어렵지 않게 사용할수 있습니다. io 모듈은 아래에 간단한 예시만 하나 보고 넘어가도록 하겠습니다.
use tokio::io::{self, AsyncReadExt};
use tokio::fs::File;
#[tokio::main]
async fn main() -> io::Result<()> {
let mut f = File::open("foo.txt").await?;
let mut buffer = [0; 10];
// read up to 10 bytes
let n = f.read(&mut buffer).await?;
println!("The bytes: {:?}", &buffer[..n]);
Ok(())
}
sync를 이해하기 위해서는 task 에 대해 알아야 하고 task를 설명할때 sync가 필요한 부분이 많아서 두 모듈은 한번에 설명을 하도록 하겠습니다.
위에서도 설명했듯이 tokio는 OS Native Thread를 생성하고 이 스레들에다가 task들을 맡기는 식으로 이루어집니다. 즉 task란 1편에서 살펴본 green thread의 역할을 하는거죠. 그렇다면 이 task 들을 만들때 조심해야 하는점들은 다음과 같습니다.
task는 짧은 시간안에 끝이 나는 하나의 작업이어야 합니다. 이는 생각을 조금 해보면 명확한데 만약 task 에 무한 loop이 있으면 어떻게 될까요? 네 맞습니다. 이 task 를 처리하는 OS Native Thread는 이 task에서 blocking이 거려서 다른 task 처리하지 못하는 상태가 됩니다. 이를 방지하기 위해 task를 spawn 할때는 non-blocking 인 task 들만을 하는것이 좋습니다. 만약 blocking 이거나 cpu 를 많이 사용해야하는 하는 task가 있는 경우, spawn_blocking으로 task를 만들게 되면 이 task들은 blocking tasks 를 위한 dedicated thread pool 에서 처리됩니다. 여기서 짧은 질문이 있습니다. 만약 다음과 같은 코드가 있을때 이는 blocking task 일까요?
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
while let Some(message)= rx.recv().await {
println!("this is the message: {:?}", message);
}
while let 패턴 때문에 이 코드가 무한 loop라고 생각하기 쉽습니다. 그러나 자세히 보면 rx.recv()뒤에 await 가 있는것을 볼수 있고 이를 통해 저희는 이 loop는 tx 를 통해 message가 보내졌을때만 rx 가 recv 할 데이터가 생기고 이 경우 이 task 는 run queue 에 올라가게 되므로 이 코드는 사실 blocking이 아니라는 사실을 알수 있습니다(sync 모듈에서 mpsc channel은 task간에 데이터를 주고받을때 std::sync 의 channel 역할을 합니다!)
task를 만들때 두 개의 다른 task 가 서로 같은 데이터를 공유한다면 무슨 일이 발생할까요? 네 맞습니다. 동시성 공부를 할때 자주 들었던 data race 상황이 발생하게 됩니다. 그래서 이를 방지하기 위해서 여러 방법이 있을텐데 가장 간단한 방법은 std의 Mutex를 사용하는 방법입니다. 그러나 먼저 Mutex는 다음과 같은 문제점을 가지고 있습니다. 1. 한번에 하나의 task 만 mutex lock을 가질수 있다. 2. async 를 지원하지 않는다. 1번은 사실 Mutex가 제대로 기능하는거기 때문에 정확히는 문제점이라고 말하기는 어렵습니다. 다만 만약에 제가 Mutex 내부의 값을 변환하는것이 아닌 서로 다른 tasks에서 값들을 읽기만 한다면 굳이 Mutex를 사용해서 하나의 task만 값을 읽게 하는것은 매우 비효율적입니다. 그래서 sync모듈에서는 RwLock을 지원해서 Write lock 은 동시에 한명만 읽을수 있지만 Read Lock 의 경우에 동시에 여러 명이 사용해서 값을 읽을수 있게 합니다. 또한 당연하게도 async 를 지원하기 때문에 lock 을 얻을때까지 무한 루프를 도는것이 아니라 lock 을 얻을수 있게 되면 그 때 다시 cpu 가 처리를 할수 있게 해서 concurrency의 이점을 가져갈수 있습니다. Rust의 소유권 규칙으로 인해 일반적으로 RwLock 은 Arc에 감싸져서 여러 task 들이 가지고 있게 됩니다. 아래는 간단한 예시입니다.
use std::ops::{Add, AddAssign};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
#[tokio::main]
async fn main() {
let value = Arc::new(RwLock::new(1));
let value_copy = value.clone();
tokio::spawn(async move {
let read_lock = value_copy.read().await;
println!("this is the value: {:?}", read_lock);
});
tokio::time::sleep(Duration::from_secs(1)).await;
tokio::spawn(async move {
let mut write_lock = value.write().await;
write_lock.add_assign(1);
println!("this is the value after write: {:?}", write_lock);
});
tokio::time::sleep(Duration::from_secs(1)).await;
}
이처럼 tokio에서 task는 green thread의 역할을 하며 sync 모듈은 이 task들 사이에서 자원이 공유될때 data race 들을 해결해줄수 있는 Tool들을 제공하고 있습니다.
https://tokio.rs/blog/2019-10-scheduler
모든 언어의 비동기 Runtime 은 필연적으로 비슷한 구조를 가질수 밖에 없습니다. Task 를 처리하는 threadpool 과 event loop을 유지하는 이 thread 이 둘을 기반으로 삼고 OS가 지원하는 event queue를 사용하기 때문에 언어가 다르다고 해도 런타임의 큰 구조는 크게 다르지 않습니다. 그렇다면 좋은 Runtime은 어떻게 Runtime 일까요? 네 맞습니다. 바로 많은 task 들을 여러 thread 에다가 어떻게 효율적으로 분배하냐가 Runtime의 영향을 미치게 됩니다. 그래서 2,3편에서 task scheduling algorithm 에 관해서도 간단히 언급을 드렸던 것이고요. 물론 task scheduling algorithm 은 아직도 많은 연구가 이루어지고 있고 유저 케이스마다 "최적의" 분배 시스템은 다르기 때문에 어떤 algorithm이 "최고"라고는 말하기 어렵습니다. 다만 현재 Goroutine이랑 Tokio에서 사용하는 Work-stealing algorithm은 보편적인 상황에서 성능이 좋다고 여겨지기 때문에 Tokio에서는 어떻게 구현되어 있는지 공부하고 넘어가면 좋습니다.
이번 마지막으로 Rust로 공부하는 비동기 프로그래밍 시리즈가 드디어 끝이 났습니다. 4월에 처음 시작했을때는 막막해 보였는데 한편씩 글을 쓰다보니 어느새 이 시리즈를 마무리하게 되었습니다. 비록 부족한 부분이 많지만 재밌게 읽어주시고 혹시 잘못된 내용이 있다면 언제든지 dongun.lee@qraftec.com 주소로 알려주신다면 바로 수정하도록 하겠습니다. 비동기 프로그래밍, 그리고 Rust를 좋아하시는 분들께 재밌는 글이 되었기를 바라겠습니다~ 감사합니다!
이 글은 제가 근무하고 있는 크래프트테크놀로지스의 지원을 받아 작성되었습니다. 저희 회사에서는 여러 제품들에서 rust를 사용하고 있으며 Rust를 사용해서 더 멋있는 제품을 만드려고 노력중입니다. 같이 빠르고 안전한 금융 서비스분들을 아래 링크를 참조해주세요!
좋은 글 감사합니다.