메뉴 바로가기 검색 및 카테고리 바로가기 본문 바로가기

한빛출판네트워크

IT/모바일

러스트 메모리 순서에 대한 자주 발생하는 오해 6가지

한빛미디어

|

2024-02-13

|

by 마라 보스

5,214

프로세서와 컴파일러는 프로그램을 빠르게 동작시키기 위해 모든 방법을 동원합니다. 

만일 프로그램에서 두 개의 연속된 명령이 서로에게 영향을 주지 않는다면, 프로세서는 둘의 순서를 바꿔 실행하는 것이 더 빠르다고 판단할 수 있습니다. 주 메모리에서 값을 가져오는 동안 잠시 휴식 상태가 되어 기다리는 명령이 있다고 생각해보겠습니다. 프로그램의 결과에 영향을 주지 않는다면 이 명령의 다음에 있는 명령들이 먼저 실행되고, 심지어는 기다리고 있는 명령이 끝나기 전에 모두 실행이 종료될 수도 있습니다. 프로그램의 실행 속도를 빠르게 할 수 있다면 컴파일러도 프로그램의 순서 일부분을 바꾸거나 재작성할 수 있습니다.

 

다음 함수를 통해 예를 들어봅시다.

 

fn f(a: &mut i32, b: &mut i32) {
    *a += 1;
    *b += 1;
    *a += 1;
}

 

컴파일러는 연산의 실행 순서가 크게 상관없다는 것을 알아챌 것입니다. 세 번의 덧셈 연산 순서가 *a와 *b의 결괏값에 영향을 주지 않기 때문이죠. (이때 오버플로 검사를 하지 않는다고 가정합니다.) 따라서 두 번째와 세 번째 연산의 순서를 바꾸고, 첫 번째와 두 번째 덧셈 연산을 하나로 합치게 됩니다.

 

fn f(a: &mut i32, b: &mut i32) {
    *a += 2;
    *b += 1;
}

 

최적화된 컴파일 과정으로 만들어진 함수를 실행할 때 프로세서는 여러 이유로 두 번째 덧셈을 첫 번째 덧셈보다 먼저 수행할 수도 있습니다. *b가 캐시에 존재하고 *a는 주 메모리에서 가져와야 하는 경우가 바로 그런 상황입니다.

 

이런 최적화와 상관없이 결과는 항상 같습니다. 

*a는 값이 2만큼, *b는 값이 1만큼 증가합니다. 두 값이 어떤 순서로 증가하는지는 프로그램의 나머지 부분에서는 알 수 없습니다. 

 

특정한 메모리 순서를 재배치하는 것 또는 최적화가 프로그램의 다른 부분에 영향을 주지 않는지 확인하는 로직은 다른 스레드를 고려하지 않습니다. 위의 예제에서 유일한 레퍼런스인 &mut i32를 사용하지 않으면 값에 접근할 수 없기 때문에 다른 스레드의 존재 여부가 영향을 주지 않습니다. 하지만 스레드 간에 공유된 값이 변경될 때, 즉 아토믹을 사용하는 경우에는 문제가 될 수 있습니다. 그렇기 때문에 컴파일러와 프로세스가 아토믹 연산과 관련해서 할 수 있는 작업과 없는 작업을 명시적으로 알려줘야 합니다. 컴파일러와 프로세서의 일반적인 로직은 스레드 간의 상호작용을 무시하거나 최적화 과정에서 프로그램의 결과가 달라지게 만들 수 있기 때문입니다.


컴파일러와 프로세서에게 이 사실을 어떻게 알려줘야 할까요? 

아래와 같이 허용되는 것과 허용되지 않는 것을 구체적으로 설명하면 동시성 프로그래밍의 문법이 지나치게 장황해져 개발자가 실수하기 쉬워집니다. 심지어 문법이 아키텍처에 따라 달라질 수도 있습니다.

 

let x = a.fetch_add(1,
    컴파일러와 프로세서 여러분,
    b에 대한 연산 순서를 자유롭게 변경해도 되지만,
    동시에 f를 실행하는 다른 스레드가 있다면,
    c에 대한 연산과의 순서를 바꾸지 마세요!
    또한 프로세서, 스토어 버퍼를 플러시하는 것을 잊지 마세요!
    하지만 b가 0이라면 상관없습니다.
    무엇이든 가장 빠른 방법을 자유롭게 사용하세요.
    고마워요~ ^^
);

 

이렇게 장황한 설명 대신 간단하게 표현하기 위해 메모리 순서Memory Ordering가 등장하였는데요. 모든 아토믹 연산이 파라미터로 사용하는 std::sync::atomic::Ordering 열거형의 몇 가지 옵션을 사용하면 사용 가능한 옵션의 범위는 매우 제한적이지만 대부분의 사용 사례에 적합하도록 신중하게 선택되도록 할 수 있습니다. 순서는 매우 추상적이고 명령어 재정렬과 같은 실제 컴파일러 및 프로세서 메커니즘을 직접 반영하지 않습니다. 따라서 동시성 코드가 아키텍처에 독립적이고, 미래에 이러한 메커니즘의 동작 방식이 바뀌더라도 잘 동작할 수 있도록 설계되었습니다. 현재 존재하는 그리고 미래의 모든 프로세서 및 컴파일러 버전에 대해서 알지 못하더라도 아토믹 연산을 제대로 검증할 수 있습니다.

 

이번에는 러스트의 메모리 순서에 대한 많은 오해 몇 가지를 살펴보겠습니다.

 

오해 1: 변경 사항을 ‘바로’ 확인하려면 강력한 메모리 순서가 필요하다

흔히 오해하는 것은 Relaxed와 같이 메모리 순서를 약하게 지정하면 아토믹 변수에 대한 변경 사항이 다른 스레드에 전달되지 않거나 상당한 시간이 지난 후에야 전달될 수 있다는 것입니다. ‘느슨한’이라는 이름 때문에 하드웨어가 휴식 상태에서 깨어나 작업을 시작할 때까지 아무 일도 일어나지 않는 것처럼 들릴 수 있습니다.


사실 메모리 모델은 언제 작업이 일어나는지에 관한 개념이 아닙니다. 어떤 일이 어떤 순서로 일어나는지만 정의할 뿐, 이 일이 일어나기까지 얼마나 오래 기다려야 하는지는 말하지 않습니다. 한 스레드에서 다른 스레드로 데이터를 가져오는 데 몇 년이 걸리는 가상의 컴퓨터가 있다고 생각해보면, 이 컴퓨터는 메모리 모델의 규칙을 완벽하게 따릅니다. 실제로 사용할 수는 없겠지만요.


실제로 메모리 순서는 일반적으로 나노초 단위로 발생하는 명령어 재정렬과 관련이 있습니다. 메모리 순서를 강력하게 지정한다고 해서 데이터가 더 빨리 이동하는 것은 아니며, 오히려 프로그램의 속도가 느려질 수도 있습니다.

 

오해 2: 최적화를 비활성화하면 메모리 순서에 신경 쓸 필요가 없다

컴파일러와 프로세서는 우리 예상과 다른 순서로 작업이 수행되기도 합니다. 컴파일러 최적화를 비활성화한다고 해서 컴파일러에서 가능한 모든 변환이 비활성화되는 것은 아닙니다. 명령어 재정렬 및 이와 유사한 잠재적으로 문제가 될 수 있는 동작을 초래하는 프로세서 기능도 비활성화되지 않습니다.

 

오해 3: 명령어 순서를 바꾸지 않는 프로세서를 사용하면 메모리 순서에 신경 쓸 필요가 없다

소형 마이크로컨트롤러와 같은 일부 간단한 프로세서는 코어가 하나만 있고 한 번에 하나의 명령어만 순서대로 실행합니다. 이러한 디바이스에서는 메모리 순서가 잘못되어 문제가 발생할 가능성이 현저히 낮은 것은 사실이지만, 컴파일러가 잘못된 메모리 순서를 기반으로 잘못된 가정을 내려 코드가 올바르게 실행되지 않을 수 있습니다. 그 외에도 프로세서가 명령어를 순서대로 실행하지 않더라도 메모리 순서와 관련이 있을 수 있는 다른 기능을 가지고 있을 수 있다는 점도 중요합니다.

 

오해 4: 느슨한 연산들은 추가 비용이 없다

이 오해의 사실 여부는 ‘추가 비용’에 대한 정의에 따라 다릅니다. Relaxed가 가장 효율적인 메모리 순서 지정 방식이고, 다른 방식보다 훨씬 빠르다는 것은 사실입니다. 심지어 모든 최신 플랫폼에서 느슨한 load and store 연산은 아토믹이 아닌 일반 읽기 및 쓰기 연산과 동일한 프로세서 명령어로 컴파일된다는 것도 사실입니다. 

아토믹 변수가 단일 스레드에서만 사용되는 경우 비아토믹 변수와의 속도 차이는 대부분 컴파일러가 더 자유롭게 비아토믹 연산을 최적화하는 데 더 효과적이기 때문일 가능성이 높습니다. (컴파일러는 아토믹 변수에 대부분의 최적화를 적용하지 않는 경향이 있습니다.)
 

하지만 여러 스레드에서 동일한 메모리에 접근하는 것은 일반적으로 단일 스레드에서 접근하는 것보다 훨씬 느립니다. 다른 스레드가 아토믹 변수를 반복적으로 읽기 시작하면, 해당 변수에 지속적으로 값을 기록하는 스레드는 프로세서 코어와 캐시가 모두 사용되면서 속도가 눈에 띄게 느려질 수 있습니다.

 

오해 5: 순차적으로 일관된 메모리 순서는 항상 정확하며 모든 경우에 적용 가능하다

순차적으로 일관된 메모리 순서는 강력한 보장성 때문에 언제 활용해도 완벽한 메모리 순서 유형으로 여겨지는 경우가 많습니다. 물론 다른 메모리 순서가 정확하다면 SeqCst도 정확하다는 것은 사실입니다. 그러나 메모리 순서와 상관없이 동시성 알고리즘 자체가 단순히 틀릴 수도 있습니다.


코드를 읽을 때 SeqCst는 다음과 같이 이야기합니다. ‘이 연산은 프로그램 내 모든 단일 SeqCst 연산의 전체 순서에 종속됩니다.’ 이는 엄청나게 광범위한 표현입니다. 같은 코드라도 더 약한 메모리 순서를 사용하면 코드를 검토하거나 검증하는 과정이 더 간단해집니다. 예를 들어 Release는 다음과 같이 말합니다. ‘이 연산은 동일한 변수에 대한 acquire 연산과 관련이 있습니다.’ 어떤 변수와 관련이 있는지를 명확히 설명하기 때문에 코드를 이해할 때 고려해야 할 사항이 훨씬 줄어듭니다.


SeqCst는 경고 신호로 보는 것이 좋습니다. 실제 코드에서 SeqCst가 사용되는 경우는 정말 복잡한 문제가 발생했거나 작성자가 메모리 순서 관련 가정을 분석하는 데 시간을 들이지 않았음을 의미합니다. 이 두 가지 모두 추가적인 검토가 필요합니다.

 

오해 6: 순차적으로 일관된 메모리 순서는 ‘release-load’ 또는 ‘acquire-store’에 사용할 수 있다

SeqCst는 Acquire 또는 Release를 대신할 수는 있지만, acquire-store 또는 release-load를 만드는 방법은 아닙니다. 이 연산들은 만들어지지 않습니다. 해제는 저장 작업에만 적용되고, 획득은 읽기 작업에만 적용됩니다.


예를 들어 Release의 저장 연산은 SeqCst의 저장 연산과 release-acquire 관계를 형성하지 않습니다. 두 연산이 전역적으로 일관된 순서의 일부가 되어야 하는 경우, 두 연산 모두 SeqCst를 사용해야 합니다.

 


 

위 콘텐츠는 『Rust Atomics and Locks 러스트 동시성 프로그래밍』의 내용을 재구성하여 작성하였습니다.

Rust 라이브러리팀 리더가 직접 소개하는 동시성 프로그래밍에 대해 더 자세한 내용이 궁금하다면 아래 도서를 확인해 보세요.

Rust Atomics and Locks 러스트 동시성 프로그래밍

댓글 입력
자료실

최근 본 책0