러스트로 구현하는 동시성: std::thread에서 Tokio까지

August 9, 2024 김백기 조회수 3,218

이번 포스팅에서는 백엔드 개발자라면 꼭 알아야 할 러스트(Rust)의 동시성 제어 방법을 소개합니다. 러스트는 소유권 시스템과 async/await 키워드를 통해 동시성 문제를 안전하고 효율적으로 해결합니다. std::thread 모듈과 Tokio 크레이트를 활용하면 스레드를 손쉽게 생성하고 제어할 수 있으며, 비동기 프로그래밍으로 메인 스레드의 반응성을 유지하면서 복잡한 작업도 수행할 수 있습니다. 이번 포스팅이 러스트의 강력한 동시성 제어 기능을 이해하고 실제 개발에 적용하는 데 도움이 되기를 바랍니다.

동시성 제어하기



백엔드 개발자라면 비동기/동기 프로그래밍에 대해 많이 들어보고 찾아보셨을 것입니다. 면접 단골 질문이기도 한 ‘비동기’ 프로그래밍은 중요한 주제임에 틀림없는데요. 이번 블로그 포스팅에서는 비동기 프로그래밍이 무엇이고 동시성 제어란 무엇인지 그리고 러스트(Rust)의 async/await와 Tokio 크레이트를 통해 스레드 문제를 해결해보는 것까지 살펴보는 시간을 갖겠습니다.


러스트는 async/await와 같이 현대 언어가 제공하는 동시성 기능들을 지원하고 있으며, 이를 통해 동시성을 쉽고 간편하고 안전하게 제어할 수 있습니다. 먼저 동시성과 병렬성에 대한 정의부터 짚고 가겠습니다.


1.1. 동시성



이미지 출처: Unsplash의 Markus Spiske


동시성(concurrency)과 병렬성(parallelism)은 비슷하지만 조금은 다른 개념입니다. 동시성은 시스템이 여러 작업을 동시에 수행할 수 있는 능력을 말합니다. 예를 들어, 회사에서 이메일을 보내고 답변을 기다리는 동안 웹서핑을 하는 상황과 비슷합니다. 실제로는 한 번에 한 가지 일만 하지만, 개념적으로는 여러 작업이 동시에 실행되는 것처럼 보이게 하는 것이 동시성입니다.


병렬성은 여러 작업을 실제로 동시에 수행하는 것을 말합니다. 동시성은 작업이 수행되는 시간만 겹치면 성립하며, 반드시 병렬로 실행될 필요는 없습니다. 반면에 병렬성은 반드시 모든 작업이 물리적으로 병렬로 실행되어야 합니다. 따라서 동시성은 논리적 개념이고 병렬성은 물리적 개념입니다.


순차(sequential) 실행은 한 번에 한 가지 일만 할 수 있습니다. 즉, 하나의 프로세스가 끝나야 다음 프로세스를 실행합니다. 동시(concurrent) 실행은 두 프로세스가 하나의 코어를 공유하고 작업 시간을 분할하여 동시에 수행하는 구조입니다. 동시에 수행되는 것처럼 보이지만 하나의 CPU 코어를 공유하여 사용하기 때문에 실제로는 한 번에 한 가지 작업만 가능합니다. 두 프로세스의 전체 수행 시간은 순차 실행과 동일합니다. 병렬(parallel) 실행은 두 프로세스가 각각 독립적인 CPU 코어를 할당 받아 병렬로 작업을 수행합니다. 이를 위해 충분한 CPU 코어가 필요하며, 전체 수행 시간은 병렬 실행 방식이 가장 빠릅니다.


러스트는 동시성에서 발생하는 다양한 문제들을 사전에 예방하여 동시성 프로그래밍을 안전하게 수행할 수 있도록 설계된 언어입니다. 메모리 안정성은 러스트 언어의 가장 큰 특성이자 동시성 원칙에서 최우선시되는 속성이기 때문에 개발자가 복잡한 동시성 문제를 안전하게 처리할 수 있도록 도와줍니다. 이 동시성을 보장하기 위해 러스트가 갖고 있는 몇 가지 룰은 다음과 같습니다.


        -  데이터 경쟁(Data Race) 방지

        -  소유권(Ownership) 시스템
        -  빌림(Borrowing), 참조 수명(Lifetimes)
        -  메시지 전달(Message Passing) 방식
        -  추상화


특히 러스트의 대표적인 소유권(Ownership) 원칙은 변수의 공유를 막아 여러 스레드가 동시에 자료에 접근할 수 없도록 강제합니다. 이는 동기화 제어 기법이 불필요하다는 의미이며 교착 상태나 경쟁 상태와 같은 동시성 문제들의 발생 가능성이 다른 언어에 비해 현저히 낮습니다.


러스트는 동시성과 병렬성을 보장하기 위해 다양한 기능을 제공합니다. 개발자가 안전하고 효율적으로 동시성 프로그래밍을 할 수 있도록 스레드, 채널, async/await 등 다양한 기능을 제공합니다. 또한, tokio와 같은 훌륭한 외부 크레이트도 쉽게 사용할 수 있습니다. 이제 러스트의 스레드 관련 기능과 특징에 대해 자세히 알아보겠습니다.


1.1.1. std::thread 사용하기

std::thread 모듈은 러스트의 동시성 프로그래밍의 기본 구성 요소 중 하나입니다. 러스트는 std::thread 모듈을 사용하여 스레드를 생성하고 제어할 수 있습니다. thread::spawn 함수를 사용하면 새로운 스레드에서 코드 블록을 실행할 수 있으며, 스레드 간 통신은 채널 모듈을 사용합니다. 채널에서 제공하는 다양한 방법을 통해 스레드 간 데이터를 주고받을 수 있습니다.


아래는 std::thread를 사용하는 간단한 예제입니다. spawn 함수를 사용해 스레드에서 실행할 함수를 전달할 수 있습니다. ‘||’ 키워드는 람다(lambda) 함수를 의미하며, join 함수를 사용해 스레드가 종료될 때까지 대기할 수 있습니다.



예제 1.1 스레드 생성 예제


위 예제에서는 메인 스레드를 생성해서 “스레드에서 실행” 메시지를 출력합니다. thread::spawn 함수를 사용하여 새 스레드를 생성하고, join 메서드를 호출하여 스레드가 종료될 때까지 기다립니다.


• 스레드를 사용하여 파일 읽기


I/O 작업은 시간이 오래 걸리는 경우가 많아 메인 스레드에서 I/O 작업을 수행할 경우 반응성이 떨어질 수 있습니다. 이번에는 스레드를 사용하여 파일을 읽는 예제를 작성해 보겠습니다. file.txt가 없으면 panic!이 발생하므로, echo 등의 명령어로 file.txt를 준비합니다. 윈도우OS를 사용하신다면 메모장이나 VS Code를 사용하여 file.txt를 생성할 수 있습니다.



예제 1.2 스레드를 사용하여 파일을 읽는 예제


• 스레드에서 발생하는 panic! 처리


다시 예제 1.2로 돌아가 보겠습니다. 만일 file.txt이 없다면 panic!이 발생합니다. 미리 만들어둔 file.txt를 삭제한 후 RUST_BACKTRACE=full을 사용하여 cargo를 실행해 보겠습니다.



thread ‘’에서 panic!이 발생했고 panic!이 thread ‘main’에까지 전파되어 프로그램이 종료되었습니다. std::thread 모듈의 join 함수는 Result를 반환합니다. 스레드 내부에서 panic!이 발생할 경우 복구 가능한 오류가 반환되기에 아래와 같이 match 구문을 사용하여 오류를 처리할 수 있습니다.




채널은 std::sync::mpsc의 channel() 함수를 사용하여 생성합니다. mpsc는 “multiple producer, single consumer”의 약자로, 여러 생산자가 하나의 소비자에게 데이터를 보낼 수 있음을 의미합니다. 채널이 생성되면 송신자(transmitter)와 수신자(receiver)의 인스턴스가 튜플의 형태로 반환됩니다. 송신자는 수신자에게 값을 전달하는 데 사용되며, 수신자는 송신자가 보낸 값을 읽는 데 사용됩니다. 송신자는 복수가 될 수 있으나 소비자는 단 하나만 존재합니다.


러스트는 비동기 프로그래밍을 위해 async/await 키워드를 제공합니다. 이를 통해 비동기 작업을 마치 동기 작업처럼 간단하게 작성할 수 있습니다. async/await 구문은 메인 스레드를 멈추지 않으면서도 동기식 프로그래밍과 유사한 방식으로 비동기 프로그래밍을 할 수 있는 기법을 제공합니다. 이로 인해 코드의 가독성을 높여 유지 보수성을 크게 개선할 수 있습니다.


async/await을 사용하려면 의존성에 future 크레이트를 추가해야 합니다. Cargo.toml 파일을 열어 [dependency] 아래에 future 크레이트를 추가합니다.




다음으로 async/await를 사용하는 간단한 예제를 작성해 보겠습니다. 방법은 매우 간단합니다. 비동기 호출을 원하는 함수 앞에 async 키워드를 사용하면 됩니다. future의 실행은 futures 크레이트가 제공하는 block_on() 함수를 사용합니다.



예제 1.5 async/await 예제


hello_world의 호출을 시도하였으나 해당 함수가 실행되지 않았습니다. async로 지정된 함수는 바로 실행되지 않고 future를 반환합니다. future가 executor에 의해 구동되는 순간 실행됩니다.

아래 예제 1.6에서는 async/await를 사용하여 1부터 100까지의 합을 반환합니다. async 함수도 일반 함수와 동일한 방식으로 값을 반환할 수 있습니다.



예제 1.6 async/await을 사용하여 1부터 100까지의 합을 반환하는 예제


• await 키워드: async 함수 안에서 async 함수를 호출하기


await 키워드를 사용하면 async 함수 안에서 다른 async 함수를 호출할 수 있습니다. await 키워드를 사용하면 현재 동작 중인 작업이 일시 중단되고 해당 스레드의 이벤트 루프에 제어권이 반환됩니다. 이벤트 루프는 대기 중인 future를 실행하고 실행 결과를 await를 사용한 async 함수에 전달합니다.


다음은 await를 사용하는 간단한 예제입니다. async로 지정된 calc_sum을 호출하는 예제를 살펴보겠습니다.



예제 1.7 await를 사용하여 다른 async 함수 호출하기


async/await를 조합하면 동기식 코드와 비슷하게 비동기 코드를 작성할 수 있습니다. 이는 비동기 코드를 직관적으로 구현할 수 있게 하며, 실행 흐름을 이해하기 쉽게 만듭니다.

하지만 장점만 있는 것은 아닙니다. future들은 몇몇 이벤트 루프를 공유하기 때문에 async 함수 안에서 스레드 관련 작업을 수행하면 이벤트 루프 안에 대기 중인 future의 실행에 영향을 줄 수 있습니다.

아래 예제 1.8은 async 함수 사용 중 발생할 수 있는 문제를 보여줍니다. sleep_10sec과 calc_sum이 동시에 실행되도록 join! 매크로를 사용했습니다. 기대했던 동작과 달리, sleep_10sec이 실행되는 순간 thread::sleep이 호출되어 이벤트 루프 스레드는 모든 동작이 멈춥니다. 그래서 calc_sum이 실행되지 않습니다. 동시성의 기준에 맞게 적어도 sleep_10sec과 calc_sum이 동시에 수행되는 것처럼 보여야 하지만, 그렇지 않습니다.



예제 1.8 비동기 함수 내에서 스레드 관련 작업을 할 때 발생하는 문제


이러한 이유로 async 함수 안에서는 스레드 관련 API 호출을 신중하게 처리해야 합니다. std::thread api를 직접 사용하는 대신 tokio 크레이트를 사용하는 방법이 있습니다. tokio 크레이트는 async/await를 사용하기 쉽게 만드는 다양한 기능을 제공합니다. 문제가 된 thread::sleep 대신 tokio::time::sleep을 사용하면 간단히 해결됩니다.


tokio 크레이트를 사용하기 위해서는 Cargo.toml 파일에 tokio 의존성을 추가해야 합니다.




그리고 async 키워드를 사용하여 main함수를 비동기 함수로 만들고 #[tokio::main]이라는 지시자를 추가합니다.




이제 tokio 크레이트를 사용할 준비가 되었습니다. 아래 예제 1.9는 예제 5.8의 문제를 수정한 것입니다.



예제 1.9 tokio 크레이트를 사용하여 스레드 문제 해결


우리가 기대했던 대로 sleep_10sec과 calc_sum이 동시에 수행되었습니다.


1.1.2. 이벤트 루프

앞서 async/await와 채널 기능을 설명하면서 이벤트 루프(event loop)를 언급했습니다. 이벤트 루프는 애플리케이션이 종료될 때까지 무한 루프를 돌며 발생하는 이벤트를 처리하는 구조입니다.

비동기 프로그래밍에서 매우 중요한 역할을 하며, 다양한 이벤트 소스(I/O, 타이머, 사용자 입력 등)를 처리할 수 있습니다. 이벤트 루프는 이벤트가 발생할 때마다 해당 이벤트에 대한 콜백 함수를 호출하여 처리합니다.

아래는 이벤트 루프를 설명하는 간단한 예제입니다. 사용자로부터 “quit” 메시지를 수신할 때까지 루프를 무한히 반복하여 이벤트를 처리합니다.



예제 1.10 간단한 이벤트 루프 예제


이벤트 루프 모델과 스레드 모델은 각각의 장단점이 분명하기 때문에 만들고자 하는 시스템에 맞게 선택하는 것이 중요합니다. 아래 표 1.1은 이벤트 루프 모델과 스레드 모델의 장단점을 설명합니다.




이벤트 루프


스레드


특징


단일 스레드를 사용하고 이벤트 큐에 수신된 이벤트를 애플리케이션이 종료될 때까지 무한 반복하여 처리하는 방식


스레드별로 작업이 할당되는 방식


장점


최소한의 오버헤드로 많은 이벤트를 처리할 수 있음
복잡한 동기화 메커니즘이 필요하지 않음


CPU 코어가 충분할 경우 여러 작업을 병렬로 처리하여 빠르게 전체 작업을 수행할 수 있음


단점


단일 스레드를 사용하기에 CPU 연산이 많은 작업에는 적합하지 않음


CPU 코어 개수를 넘어서는 많은 작업을 동시에 수행하면 컨텍스트 스위칭 비용이 발생하여 성능이 지연될 가능성 있음
복잡한 동기화 메커니즘이 필요


사용 예


UI가 있는 애플리케이션, I/O 작업이 많은 서비스 등


CPU 연산량이 많은 작업 (알고리즘 등)


표 1.1 이벤트 루프 모델과 스레드 모델의 장단점

결론

이번 포스팅에서는 러스트의 동시성 원칙과 이를 보장하는 다양한 도구와 기능을 살펴보았습니다. 러스트는 동시성 문제를 해결하기 위한 다양한 기법들을 제공하며, std::thread와 async/await를 통해 강력하고 효율적인 동시성 제어 방법을 지원합니다. 각 방법은 고유한 장단점을 가지고 있으며 사용자의 필요에 따라 적절한 방법을 선택하는 것이 중요합니다. 이벤트 루프와 스레드 모델을 적절히 활용하면 최상의 성능과 안전성을 제공하는 애플리케이션을 개발할 수 있습니다.


또한 소유권(Ownership) 원칙을 통해 공유 자원을 최소화하고 Mutex와 Arc등을 사용하여 동시성 제어를 할 수 있습니다. 러스트의 안전한 동시성 기능으로 성능, 개발 편의성, 안정성을 모두 충족시킬 수 있었습니다.


러스트의 동시성 제어 원칙, 스레드, 비동기 프로그래밍에 대한 이 정보가 많은 개발자에게 도움이 되었으면 좋겠습니다.


감사합니다.




저자

김백기

S/W Platform Lab(VD)

이메일 문의하기