Rust가 동시성 버그를 방지하는 방법

동시성 프로그램은 현재 21세기 개발에서 가장 큰 화두 중 하나다.
시스템의 규모가 커질수록 동시성을 활용하는 부분이 기하급수적으로 커지는데, 그에 따른 버그의 추적이나 최적화가 굉장히 어렵기 때문이다.

C/C++, Go, Java, C# 등의 native thread를 지원하는 대부분의 언어들은 이러한 고질병을 늘 안고 있고, Python, Node.js 등의 싱글스레드 only 런타임들도 로직에 의한 동시성 버그는 늘 발생할 수 있다.

Rust는 매우 강력한 타입시스템을 기반으로 이러한 문제를 사전에 방지할 수 있는 구조를 갖추고 있다.
unsafe 도배하는 이상한 짓만 안하면 컴파일타임에 상당수의 동시성 버그를 감지할 수 있다.
물론 이것이 완벽하다는 말은 아니다. 데드락 같은 논리적인 버그들은 직접 신경을 쓰긴 해야 한다. 하지만 자주 발생하는 일반적인 함정들에 대해서는 보장을 해준다.

그렇기 때문에 꽤나 높은 난이도에도 불구하고 각광을 받고 있는 것이다.




스레드에 값 전달하기

대부분의 언어들은 스레드 루틴에 변수를 전달하는 것에 별다른 제약을 두지 않는다.

하지만 Rust는 변수를 넘기는 것부터 빡세게 제약을 건다.
만약 아래와 같이 그냥 변수 n을 스레드에서 사용하려 시도하면

에러가 발생한다.
move 클로저를 사용해서 move 시맨틱을 통해 값을 이동시켜 사용하라는 것이다.

새로 만든 스레드는 스레드를 생성한 호출자보다 오래 생존할 수 있기 때문에, 스레드의 캡쳐는 단순히 복사하는 형태로는 이루어질 수 없다.
그냥 문제를 원천차단하는 것이다.

근데 Rust는 포인터를 제공하는 low-level 언어다.
그럼 포인터를 넘기는건 안되는걸까?

일반적으로는 안된다.
"lifetime" 시스템을 통해서, 참조하는 대상이 포인터 변수보다 일찍 죽을 수 있음을 감지해서 에러를 던진 것이다.

Rust의 타입 시스템에는 단순 타입 정보만 있는게 아니라, 이런 추적을 위한 lifetime 정보도 항상 붙어있다.

다시 돌아가서, 스레드에 일회성 값을 전달하려면 move 클로저를 사용해서 값을 이동시키면 된다.

그럼 잘 동작할 것이다.




스레드 공유 변수 사용하기

문제가 되는건 보통 여기부터다.

대부분의 언어에서는 스레드 루틴에 참조변수를 막 넣어서 공유하는 것이 허용되고, 개발자가 직접 동기화를 신경을 써줘야한다. 작은 실수라도 중대한 버그가 될 수 있다.

앞서 설명했듯 Rust에서 스레드에는 포인터를 그대로 넘기는게 불가능하고, 이동시키기만 할 수 있다.
그럼 여러 스레드에 걸쳐 공유되는 변수를 어떻게 만들어야 할까?

해결법은 간단하다.
하나의 값을 공유하는 형태이면서도 옅은 복사가 가능한 형태의 타입을 사용하면 되는 것이다. 복사시켜서 이동시키면 되니까!

Rust에서는 이를 내부 가변성 패턴이라고 부르는데, 이에 대한 대표적인 표준 구현체는 Mutex, Channel, Atomic 타입 등이 있다.

아래는 Arc와 Mutex를 통해 공유 변수를 정의하고 사용하는 간단한 예제다.
Arc는 atomic reference count 스마트 포인터로, 그냥 복사를 하기 위한 용도로 감싼 것이다.

각각의 스레드는 Mutex의 복제된 버전을 전달받고, 그걸 통해 공유 변수를 읽거나 쓸 수 있다.

근데 Mutex는 또 lock을 걸어야만 내부에 있는 값을 가져올 수 있기 때문에, 결국 스레드 safety가 신택스 수준에서 보장된다고 할 수 있다.

실행해보면

기대한대로 동작할 것이다.

atomic과 channel 같은 방법도 사용법이 조금 다를 뿐이지, 원리 자체는 거의 비슷하다.
https://m.blog.naver.com/sssang97/223131767231
https://m.blog.naver.com/sssang97/222272690659

unsafe를 사용하면 대충 포인터를 넘기는 식으로 해서 제약을 무시할 수도 있지만, 권장되는 방법은 아니다.