멀티 스레드 환경에서 공유 자원은 항상 mutexlock - unlock 을 통해 임계 구역을 만든 후 다뤘었습니다. 그러다가 Lock-free 라는 개념을 접했고 std::atomic 객체를 사용해 보면서 여러 스레드가 동시에 공유 자원에 접근하게 된다면 어떻게 보일지 다시 생각해 보게 됐습니다.

🔍 원자성

💡 Quotation 원자성(原子性, atomicity)은 어떤 것이 더 이상 쪼개질 수 없는 성질을 말한다. 어떤 것이 원자성을 가지고 있다면 원자적(atomic)이라고 한다. 어떠한 작업이 실행될 때 언제나 완전하게 진행되어 종료되거나, 그럴 수 없는 경우 실행을 하지 않는 경우를 말한다. 원자성을 가지는 작업은 실행되어 진행되다가 종료하지 않고 중간에서 멈추는 경우는 있을 수 없다. - Wikipedia

프로그래밍을 하면 그 내용들이 컴파일 되어 CPU 가 처리하는데, 이 때 여러 명령어 조합으로 처리를 하게 됩니다. 예를 들면 count++; 같은 한 줄 짜리 코드라고 해도 CPU 는 여러 명령어(ex: 어디 레지스터에서 값을 꺼내와서 거기에 1을 더하고 다시 어느 레지스터에 써라…)를 처리하게 됩니다.

원자성은 여러 명령어가 아닌, 한 개의 명령어로 처리하여 다른 스레드가 간섭하지 못합니다. 따라서 mutex 없이 공유 자원을 경쟁 상태 없이 다룰 수 있습니다. 👍

🔍 명령어 재배치와 메모리 가시성

스레드가 두 개 있습니다. 첫 번째 스레드에서는 값을 변경하고 플래그를 true 로 변경시켜줍니다.

두 번째 스레드에서는 플래그 값이 true 인 것을 계속 확인하다가 값을 확인하는 스레드입니다.

코드는 아래와 같습니다. (MSVC++143 x64-Release build)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include<iostream>
#include<thread>
#include<vector>

int value = 0;
bool flag = false;

int main() {
    std::vector<std::thread> threads;

    // Enter 입력 시 반복실행, x 입력 종료
    while(getchar() != 'x') {
        // 첫 번째 스레드 (값 세팅)
        threads.emplace_back([] {
            value = 1;
            flag = true;
        });

        // 두 번째 스레드 (값 확인 후 초기화)
        threads.emplace_back([] {
            while(!flag);
            std::cout << value << "\n";
        });

        // 스레드 종료 대기
        for(std::thread &th : threads) {
            if(th.joinable()) {
                th.join();
            }
        }

        // 초기화
        value = 0;
        flag = false;
    }
    return 0;
}

당연히 1로 값을 세팅한 후 플래그 세팅했습니다. 결과는 항상 1이 나올까요?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
1
0
0
1
1
1
0
1
0
...

Debug 모드로 빌드했을 때는 1만 나왔을지 모르겠지만, Release 모드로 빌드했을 때는 0이 나와버리는 충격적인 결과가 나왔습니다. 😨

이유는 바로 명령어 재배치메모리 가시성 문제 때문입니다.

💡 Quotation (명령어 재배치 관련) 비순차적 명령어 처리 또는 비순차적 실행(out-of-order execution, 줄여서 OoOE, dynamic execution)은 고성능 마이크로프로세서가 특정한 종류의 지연으로 인해 낭비될 수 있는 명령 사이클을 이용하는 패러다임이다. 명령 실행 효율을 높이기 위해 순서에 따라 처리하지 않는 기법이며 수많은 프로세서가 채용하고 있다. _- Wikipedia*

✅ 메모리 가시성은 _CPU* 코어마다 캐시가 있기 때문에 공유 자원이라고 해도 결국 각 코어 별 캐시에 담기기 때문에 여러 스레드가 같은 자원을 본다고 할지라도 같은 자원을 보고 있다고 할 수 없음

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 1. flag 부터 세팅하는 경우 (명령어 재배치)
// (최적화를 위한 명령어 재배치)
// thread1
flag = true;
// thread2
while(!flag);
std::cout << value << "\n"; // 0 출력
// thread1
value = 1;
//----------------------------------------

// 2. thread2 에서 바뀐 value 를 포착하지 못함 (가시성)
// (thread1 에서 value를 변경했지만 전파 못함)
// thread1
value = 1;
flag = true;
// thread2
while(!flag);
std::cout << value << "\n"; // 0 출력
//----------------------------------------

🚀 std::atomic

따라서 의도에 맞게 하려면 공유 자원에 mutex 를 사용하여 임계 구역을 설정하던지, 명령어 재배치와 가시성을 고려하여 원자성 명령어를 사용하면 됩니다. 처음 멀티 스레드하고 mutex 만 접했을 때도 머리 아팠는데 지금은 mutex가 엄청 편리한 도구였네요.

C/C++11 부터는 std::atomic 객체를 사용하여 위와 같은 상황을 쉽게 처리할 수 있습니다.

원자성

std::atomic 은 기본적으로 CPU 원자적인 명령어를 사용하므로 기본적으로 Thread-safety 합니다. mutex lock-unlock 없이 말이죠. 😄

명령어 재배치와 가시성

memory order 옵션을 두어 재배치에 대한 제한과 가시성에 대한 동기화를 할 수 있습니다. 옵션은 아래와 같이 있습니다.

  1. relaxed: 원자성만 보장, 재배치 허용
  2. acquire: consumer 측면, acquire 이후의 쓰기/읽기를 acquire 이전으로 재배치 금지
  3. release: producer 측면, release 이전의 쓰기/읽기를 release 이후로 재배치 금지
  4. acq_rel: CAS(Compare-And-Swap), 읽기 후 쓰기 연산
  5. seq_cst: std::atomic의 기본 옵션, 순차 일관성 보장

5번이 제일 강력한 제한이며 1~4 는 최적화를 위해 사용되는 옵션입니다.

그럼 위 예제 코드에서 결과가 의도에 맞게 출력 되려면 어떻게 해야할까요?

flagstd::atomic 으로 바꾸고, memory order 옵션으로 flagtrue 가 됐을 때 value 가 1이 보이도록 하면 되겠죠?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include<iostream>
#include<atomic> // std::atomic 을 사용하기 위함
#include<thread>
#include<vector>

int value = 0;
std::atomic<bool> flag(false); // 비원자 변수 -> 원자 변수

int main() {
    std::vector<std::thread> threads;

    while(getchar() != 'x') {
        threads.emplace_back([] {
            value = 1;                                   // memory_order_release 이전의 쓰기
            flag.store(true, std::memory_order_release); // 쓰기/읽기를 이후로 재배치 금지
        });

        threads.emplace_back([] {
            while(!flag.load(std::memory_order_acquire)); // 이후의 쓰기/읽기 이전으로 재배치 금지
            std::cout << value << "\n";                   // memory_order_acquire 이후의 읽기
        });

        // 스레드 종료 대기
        for(std::thread &th : threads) {
            if(th.joinable()) {
                th.join();
            }
        }

        // 초기화
        value = 0;
        flag = false;
    }
    return 0;
}
1
2
3
4
5
6
7
8
// 결과
1
1
1
1
1
1
...

정리

  1. mutex lock-unlock 없이도 원자적 명령어를 이용하여 멀티 스레드 환경에서 공유 자원을 경쟁 상태 없이 다룰 수 있다. (Lock-free)
  2. CPU 최적화에 따라 명령어 재배치가 될 수 있다. (프로그래밍 한 순서대로 동작하지 않을 수 있다.)
  3. 동시에 다수의 스레드가 하나의 공유 자원을 바라본다고 해도 관찰되는 값이 다를 수 있다. (CPU Cache)
  4. 원자적 명령을 사용하기 위해 std::atomic 을 사용하며, 명령어 재배치 및 가시성의 문제는 memory_order 옵션을 이용하여 해결한다.
  5. release/acquire 는 서로 짝을 이루어 사용하며 seq_cst 는 전체적인 일관성을 강제한다.
  6. 고려해야 할 것이 많고 디버깅이 어렵다.