멀티 스레드 환경에서 공유 자원은 항상 mutex의 lock - unlock 을 통해 임계 구역을 만든 후 다뤘었습니다. 그러다가 Lock-free 라는 개념을 접했고 std::atomic 객체를 사용해 보면서 여러 스레드가 동시에 공유 자원에 접근하게 된다면 어떻게 보일지 다시 생각해 보게 됐습니다.
🔍 원자성
💡 Quotation 원자성(原子性, atomicity)은 어떤 것이 더 이상 쪼개질 수 없는 성질을 말한다. 어떤 것이 원자성을 가지고 있다면 원자적(atomic)이라고 한다. 어떠한 작업이 실행될 때 언제나 완전하게 진행되어 종료되거나, 그럴 수 없는 경우 실행을 하지 않는 경우를 말한다. 원자성을 가지는 작업은 실행되어 진행되다가 종료하지 않고 중간에서 멈추는 경우는 있을 수 없다. - Wikipedia
프로그래밍을 하면 그 내용들이 컴파일 되어 CPU 가 처리하는데, 이 때 여러 명령어 조합으로 처리를 하게 됩니다. 예를 들면 count++;
같은 한 줄 짜리 코드라고 해도 CPU 는 여러 명령어(ex: 어디 레지스터에서 값을 꺼내와서 거기에 1을 더하고 다시 어느 레지스터에 써라…)를 처리하게 됩니다.
원자성은 여러 명령어가 아닌, 한 개의 명령어로 처리하여 다른 스레드가 간섭하지 못합니다. 따라서 mutex 없이 공유 자원을 경쟁 상태 없이 다룰 수 있습니다. 👍
🔍 명령어 재배치와 메모리 가시성
스레드가 두 개 있습니다. 첫 번째 스레드에서는 값을 변경하고 플래그를 true
로 변경시켜줍니다.
두 번째 스레드에서는 플래그 값이 true
인 것을 계속 확인하다가 값을 확인하는 스레드입니다.
코드는 아래와 같습니다. (MSVC++143 x64-Release build)
|
|
당연히 1로 값을 세팅한 후 플래그 세팅했습니다. 결과는 항상 1이 나올까요?
|
|
Debug 모드로 빌드했을 때는 1만 나왔을지 모르겠지만, Release 모드로 빌드했을 때는 0이 나와버리는 충격적인 결과가 나왔습니다. 😨
이유는 바로 명령어 재배치와 메모리 가시성 문제 때문입니다.
💡 Quotation (명령어 재배치 관련) 비순차적 명령어 처리 또는 비순차적 실행(out-of-order execution, 줄여서 OoOE, dynamic execution)은 고성능 마이크로프로세서가 특정한 종류의 지연으로 인해 낭비될 수 있는 명령 사이클을 이용하는 패러다임이다. 명령 실행 효율을 높이기 위해 순서에 따라 처리하지 않는 기법이며 수많은 프로세서가 채용하고 있다. _- Wikipedia*
✅ 메모리 가시성은 _CPU* 코어마다 캐시가 있기 때문에 공유 자원이라고 해도 결국 각 코어 별 캐시에 담기기 때문에 여러 스레드가 같은 자원을 본다고 할지라도 같은 자원을 보고 있다고 할 수 없음
|
|
🚀 std::atomic
따라서 의도에 맞게 하려면 공유 자원에 mutex 를 사용하여 임계 구역을 설정하던지, 명령어 재배치와 가시성을 고려하여 원자성 명령어를 사용하면 됩니다. 처음 멀티 스레드하고 mutex 만 접했을 때도 머리 아팠는데 지금은 mutex가 엄청 편리한 도구였네요.
C/C++11 부터는 std::atomic 객체를 사용하여 위와 같은 상황을 쉽게 처리할 수 있습니다.
원자성
std::atomic 은 기본적으로 CPU 원자적인 명령어를 사용하므로 기본적으로 Thread-safety 합니다. mutex lock-unlock 없이 말이죠. 😄
명령어 재배치와 가시성
memory order 옵션을 두어 재배치에 대한 제한과 가시성에 대한 동기화를 할 수 있습니다. 옵션은 아래와 같이 있습니다.
- relaxed: 원자성만 보장, 재배치 허용
- acquire: consumer 측면, acquire 이후의 쓰기/읽기를 acquire 이전으로 재배치 금지
- release: producer 측면, release 이전의 쓰기/읽기를 release 이후로 재배치 금지
- acq_rel: CAS(Compare-And-Swap), 읽기 후 쓰기 연산
- seq_cst: std::atomic의 기본 옵션, 순차 일관성 보장
5번이 제일 강력한 제한이며 1~4 는 최적화를 위해 사용되는 옵션입니다.
그럼 위 예제 코드에서 결과가 의도에 맞게 출력 되려면 어떻게 해야할까요?
flag
를 std::atomic
으로 바꾸고, memory order 옵션으로 flag
가 true
가 됐을 때 value
가 1이 보이도록 하면 되겠죠?
|
|
|
|
정리
- mutex lock-unlock 없이도 원자적 명령어를 이용하여 멀티 스레드 환경에서 공유 자원을 경쟁 상태 없이 다룰 수 있다. (Lock-free)
- CPU 최적화에 따라 명령어 재배치가 될 수 있다. (프로그래밍 한 순서대로 동작하지 않을 수 있다.)
- 동시에 다수의 스레드가 하나의 공유 자원을 바라본다고 해도 관찰되는 값이 다를 수 있다. (CPU Cache)
- 원자적 명령을 사용하기 위해 std::atomic 을 사용하며, 명령어 재배치 및 가시성의 문제는 memory_order 옵션을 이용하여 해결한다.
- release/acquire 는 서로 짝을 이루어 사용하며 seq_cst 는 전체적인 일관성을 강제한다.
고려해야 할 것이 많고 디버깅이 어렵다.