7 분 소요

  • [MEC++40] 동시성에는 atomic을 사용하고, volatile은 최적화를 하면 안되는 특별한 메모리에 사용하라.(atomic, volatile 참고)

개요

쓰레드의 경쟁 상태 해결을 위해 mutex를 이용했는데요,

m_Val = m_Val + 1(혹은 ++m_Val) 같은 단순한 연산도 사실은

  1. 메모리에서 m_Val의 값을 읽고,
  2. 값을 수정하고,
  3. 메모리의 m_Val의 값을 저장하는

여러 명령 단위로 쪼개져 구성되어 있기 때문에, 여러 쓰레드에서 사용하는 값이 서로 달라질 수 있으므로, mutexlock()을 걸어 한개의 쓰레드만 접근 가능하게 해야 한다고 말씀드렸습니다.

C++11 STL 에서는 atomic을 사용하여 메모리에서 값을 읽고, 수정하고, 저장하는 작업을 단일 명령 단위(더이상 쪼개지지 않는 단위, 원자 단위)로 구성할 수 있습니다. 따라서 mutex 없이 쓰레드 경쟁 상태 해결할 수 있습니다.

image

다음은 원자 단위의 명령을 지원하기 위한 유틸리티 개체와 함수들입니다.

항목 내용
atomic (C++11~) atomic은 메모리에서 값을 읽고, 수정하고, 저장하는 작업을 단일 명령 단위로 구성합니다.
atomic_flag (C++11~) atomic<bool>의 특수한 변형입니다.
atomic_ref (C++20~) 참조 타입을 atomic으로 사용할 수 있습니다.
atomic_init() (C++11~C++20) C언어 호환성을 유지하며 atomic개체를 초기화 합니다.
ATOMIC_VAR_INIT() (C++11~C++20) atomic 개체의 초기값을 설정합니다.
ATOMIC_FLAG_INIT (C++11~) atomic_flag의 초기값입니다.
memory_order (C++11~) atomic에서 명령을 실행할 때 순차적 일관성 처리 방식을 지정하는 열거형입니다.
kill_dependency() (C++11~) (작성중)
atomic_thread_fence() (C++11~) (작성중)
atomic_signal_fence() (C++11~) (작성중)
_Atomic() (C++23~) C언어 호환용 매크로 함수입니다.

atomic

atomic 개체는 메모리에서 값을 읽고, 수정하고, 저장하는 작업을 단일 명령 단위로 구성합니다. 따라서 mutex 없이 쓰레드 경쟁 상태 해결를 해결할 수 있습니다.

다음은 mutex - 경쟁 상태(Race Condition) 해결에서 소개한 코드를 atomic으로 수정한 예입니다.

  1. 기존의 int m_Val{0};atomic<int> m_Val{0}; 으로 수정하였습니다.
  2. 기존의 m_Val = m_Val + 1;++m_Val;로 수정하였습니다.
  3. int GetVal() const {return m_Val;} 와 같이 atomic 개체의 값 타입이 암시적으로 형변환됩니다.

그러면, 기존처럼 mutex를 사용하지 않고도, 경쟁 상태에 빠지지 않고 200을 계산하는 것을 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class A {
    // int m_Val{0}; // 일반 변수는 mutex를 사용해야 합니다.
    std::atomic<int> m_Val{0};
public:
    int GetVal() const {return m_Val;} // atomic 개체의 값이 형변환되어 리턴됩니다.
    
    // i를 m_Val을 100번 증가시킵니다.
    void Increase() {
        for (int i{0}; i < 100; ++i) {
            // m_Val = m_Val + 1; // atomic은 + 를 지원하지 않습니다. 
            ++m_Val;
            std::this_thread::sleep_for(std::chrono::milliseconds{1}); // 1 밀리초 만큼 쉽니다.
        }
    }
};

A a{};
std::thread worker1{std::mem_fn(&A::Increase), std::ref(a)};
std::thread worker2{std::mem_fn(&A::Increase), std::ref(a)};

worker1.join(); 
worker2.join(); 

EXPECT_TRUE(a.GetVal() == 200); // (O) 경쟁 상태에 빠지지 않고 잘 계산합니다.

(C++20~) atomic_ref가 추가되어 참조 타입을 atomic으로 사용할 수 있습니다.

atomic 멤버 함수

항목 내용
T operator =(T desired) (C++11~) atomic 개체는 복사 가능하지 않으며, store(desired) 와 같습니다.
is_lock_free() (C++11~) 잠금이 없는지(lock-free) 확인합니다. lock-free면 CPU의 지원이 안되므로 mutex등의 lock()으로 잠금 처리 됩니다.
is_always_lock_free (C++17~) 항상 잠금이 없는지(lock-free) 확인합니다.
store() (C++11~) atomic 개체에 값을 저장합니다.(atomic 쓰레드 동기화 참고)
load() (C++11~) atomic개체의 값을 불러옵니다.(atomic 쓰레드 동기화 참고)
operator T() (C++11~) atomic개체의 값타입으로 암시적으로 형변환합니다.
exchange() (C++11~) atomic 개체의 값을 바꾸고, 이전 값을 가져옵니다.
compare_exchange_week() (C++11~)
compare_exchange_strong() (C++11~)
(작성중)
fetch_add() (C++11~) atomic 개체의 값에 주어진 값을 더합니다.
fetch_sub() (C++11~) atomic 개체의 값에 주어진 값을 뺍니다.
fetch_and() (C++11~) atomic 개체의 값에 주어진 값을 비트 AND 합니다.
fetch_or() (C++11~) atomic 개체의 값에 주어진 값을 비트 OR 합니다.
fetch_xor() (C++11~) atomic 개체의 값에 주어진 값을 비트 XOR 합니다.
++, -- (C++11~) 증감합니다.
+=, -= (C++11~) 증감후 대입합니다.
&=, |=, ^= (C++11~) 비트 AND, 비트 OR, 비트 XOR 후 대입합니다.
notify_one() (C++20~) 대기중인 한개의 쓰레드에게 알립니다.
notify_all() (C++20~) 대기중인 모든 쓰레드에게 알립니다.
wait(old) (C++20~) Notify가 왔을때 old와 값이 달라지면 대기를 해제합니다.

순차적 일관성(sequential consistency)

다음 코드 조각에서 a = 1; 이 먼저 실행될까요? 아니면 b = 1; 이 먼저 실행될까요?

1
2
a = 1;
b = 1;

놀랍게도 알 수 없다 입니다. 컴파일러나 CPU 마음대로 입니다. 이는 컴파일러가 속도 향상을 위해서 명령 순서를 바꿀 수 있고, CPU가 캐쉬 상황에 따라 캐쉬된 정보를 먼저 접근할 수 있기 때문입니다.

보통은 뭐가 먼저 실행되던 상관없습니다. 어쨋든 ab가 모두 1이 되니까요.

하지만 멀티 쓰레드 환경에서는 문제가 됩니다. a = 1; 만 실행되고 쓰레드가 a에 접근하느냐, b = 1; 만 실행되고 쓰레드가 b에 접근하느냐, a = 1; b = 1; 이 모두 실행되고 쓰레드가 접근하느냐에 따라 다른 결과가 나옵니다.

따라서 멀티 쓰레드 환경에서는 명령 실행의 순차적 일관성을 확보하며 프로그래밍할 필요가 있습니다.

memory_order

memory_orderatomic에서 명령을 실행할 때 순차적 일관성 처리 방식을 지정하는 열거형입니다.

항목 내용
memory_order_relaxed (C++11~) 순서에 관여하지 않고, 원자 단위 명령만 보장됩니다.
memory_order_consume (C++11~) (작성중)
memory_order_acquire (C++11~) 읽기시 이후 명령들이 해당 명령 이전에 오는 것을 금지합니다.(atomic 쓰레드 동기화 참고)
memory_order_release (C++11~) 쓰기시 이전 메모리 명령들이 해당 명령 이후로 가는 것을 금지합니다.(atomic 쓰레드 동기화 참고)
memory_order_acq_rel (C++11~) 읽기/쓰기/변경시 이후 명령들이 해당 명령 이전에 오는 것을 금지하고, 이전 명령들이 해당 명령 이후로 가는 것을 금지합니다.
memory_order_seq_cst (C++11~) 기본적으로 사용됩니다.
순차적 일관성을 보장해 줍니다.

atomic 쓰레드 동기화

atomic 개체의 순차적 일관성을 조정하면, condition_variable 처럼 쓰레드간 동기화가 가능합니다.

다음 예제에서,

  1. m_IsCompleted.store(true, std::memory_order_release);을 이용하여 m_Val1m_Val2를 수정하고,
  2. while(!m_IsCompleted.load(std::memory_order_acquire)) {}을 이용하여 m_Completedtrue가 될때까지 대기 합니다.
  3. #1, #2#3, #5에 의해 #6, #7 보다 먼저 실행됨을 보장합니다.

즉, #1, #2#6, #7이 동기화되어 동작합니다.

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
class A {
    int m_Val1{0};
    int m_Val2{0};
    int m_Val3{0};

    std::atomic<bool> m_IsCompleted{false};
public:
    void Producer() {

        m_Val1 = 1; // #1
        m_Val2 = 2; // #2

        // #1, #2가 나중에 실행되지 않도록 보증합니다.
        m_IsCompleted.store(true, std::memory_order_release); // #3

        m_Val3 = 3; // #4
    }

    void Consumer() {

        // m_IsCompleted가 true가 될때까지 while 문을 반복하며 대기합니다.
        // #6, #7, #8 이 먼저 실행되지 않도록 보증합니다.
        while(!m_IsCompleted.load(std::memory_order_acquire)) {} // #5
        
        EXPECT_TRUE(m_Val1 == 1); // #6
        EXPECT_TRUE(m_Val2 == 2); // #7
        // #4가 #3보다 먼저 실행될 수도 있고, 아닐 수도 있으므로 m_Val3은 0 또는 3입니다.
        EXPECT_TRUE(m_Val3 == 0 || m_Val3 == 3); // #8
    }
};
A a{};
std::thread producer{std::mem_fn(&A::Producer), std::ref(a)};
std::thread consumer{std::mem_fn(&A::Consumer), std::ref(a)};

producer.join(); 
consumer.join(); 

(C++20~) notify_one(), notify_all(), wait()atomic에 추가되어 condition_variable과 같은 인터페이스로 쓰레드 동기화를 구현할 수 있습니다.

atomic 유틸리티 함수

항목 내용
atomic_store() (C++11~)
atomic_store_explicit() (C++11~)
(작성중)
atomic_load() (C++11~)
atomic_load_explicit() (C++11~)
(작성중)
atomic_exchange() (C++11~)
atomic_exchange_explicit() (C++11~)
(작성중)
atomic_compare_exchange_weak() (C++11~)
atomic_compare_exchange_weak_explicit() (C++11~)atomic_compare_exchange_strong() (C++11~)
atomic_compare_exchange_strong_explicit() (C++11~)
(작성중)
atomic_fetch_add() (C++11~)
atomic_fetch_add_explicit() (C++11~)
(작성중)
atomic_fetch_sub() (C++11~)
atomic_fetch_sub_explicit() (C++11~)
(작성중)
atomic_fetch_and() (C++11~)
atomic_fetch_and_explicit() (C++11~)
(작성중)
atomic_fetch_or() (C++11~)
atomic_fetch_or_explicit() (C++11~)
(작성중)
atomic_fetch_xor() (C++11~)
atomic_fetch_xor_explicit() (C++11~)
(작성중)
atomic_notify_one() (C++20~) (작성중)
atomic_notify_all() (C++20~) (작성중)
atomic_wait() (C++20~)
atomic_wait_explicit() (C++20~)
(작성중)

atomic 타입 별칭

atomic<bool>과 같은 기본 타입에 대한 atomic 타입들은 다음과 같이 별칭이 선언되어 있습니다.

항목 내용
atomic_bool (C++11~) atomic<bool>
atomic_char (C++11~) atomic<char>
atomic_schar (C++11~) atomic<signed char>

그외 다른 별칭들은 cppreference를 참고하시기 바랍니다.

다음의 특수 목적 별칭이 있습니다.

항목 내용
atomic_signed_lock_free (C++20~) (작성중)
atomic_unsigned_lock_free (C++20~) (작성중)

atomic_flag

atomic_flagtrue, false를 저장하는 atomic<bool>의 특수한 변형이며 load(), store()를 제공하지 않습니다.

atomic_flag 멤버 함수

항목 내용
clear() (C++11~) 플래그를 false로 설정합니다.
test_and_set() (C++11~) true로 설정하고 이전값을 리턴합니다.
test() (C++20~) 현재값을 리턴합니다.
notify_one() (C++20~) 대기중인 한개의 쓰레드에게 알립니다.
notify_all() (C++20~) 대기중인 모든 쓰레드에게 알립니다.
wait(old) (C++20~) Notify가 왔을때 old와 값이 달라지면 대기를 해제합니다.

atomic_flag 유틸리티 함수

항목 내용
atomic_flag_test_and_set() (C++11~)
atomic_flag_test_and_set_explicit() (C++11~)
(작성중)
atomic_flag_clear() (C++11~)
atomic_flag_clear_explicit() (C++11~)
(작성중)
atomic_flag_test() (C++20~)
atomic_flag_test_explicit() (C++20~)
(작성중)
atomic_flag_notify_one() (C++20~) (작성중)
atomic_flag_notify_all() (C++20~) (작성중)
atomic_flag_wait() (C++20~)
atomic_flag_wait_explicit() (C++20~)
(작성중)

(C++20~) atomic_ref

기존의 atomic은 복사가 가능한 타입만 가능했는데요, C++20 부터는 atomic_ref가 추가되어 참조 타입을 atomic으로 사용할 수 있습니다.

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
class A {
    // std::atomic<int&> m_Val; // (X) 컴파일 오류. 복사 가능해야 합니다.
    std::atomic_ref<int> m_Val; // (O) 
public:
    explicit A(int& val) : m_Val{val} {}
    int GetVal() const {return m_Val;}
    
    // i를 m_Val을 100번 증가시킵니다.
    void Increase() {
        for (int i{0}; i < 100; ++i) {
            ++m_Val;
            //!! 테스트 속도 때문에 임시 주석
            std::this_thread::sleep_for(std::chrono::milliseconds{1}); 
        }
    }
};

int val{0};
A a{val};
std::thread worker1{std::mem_fn(&A::Increase), std::ref(a)};
std::thread worker2{std::mem_fn(&A::Increase), std::ref(a)};

worker1.join(); 
worker2.join(); 

EXPECT_TRUE(a.GetVal() == 200); // (O) 경쟁 상태에 빠지지 않고 잘 계산합니다.
EXPECT_TRUE(val == 200); // (O) 참조한 값이 잘 반영되어 있습니다.    

(C++20~) notify_one(), notify_all(), wait()

기존에는 store()load()이용하여 atomic으로 쓰레드를 동기화 했는데요(atomic 쓰레드 동기화참고),

C++20 부터 atomicnotify_one(), notify_all(), wait()가 추가되어 condition_variable과 같은 인터페이스로 쓰레드 동기화를 구현할 수 있습니다.

다음을 보면 notify_one(), notify_all(), wait()이 좀더 직관적입니다.

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
class A {
    int m_Val1{0};
    int m_Val2{0};

    std::atomic<bool> m_IsCompleted{false};
public:
    void Producer() {

        m_Val1 = 1; 
        m_Val2 = 2; 

        // store-load 방식
        // m_IsCompleted.store(true, std::memory_order_release); 
        
        // notify-wait 방식
        m_IsCompleted = true;
        m_IsCompleted.notify_all(); // 값이 수정되었음을 통지합니다.
    }

    void Consumer() {
        // store-load 방식
        // while(!m_IsCompleted.load(std::memory_order_acquire)) {} 

        // notify-wait 방식
        m_IsCompleted.wait(false); // 값이 false 이면 대기합니다.
        
        EXPECT_TRUE(m_Val1 == 1); 
        EXPECT_TRUE(m_Val2 == 2); 
    }
};
A a{};
std::thread producer{std::mem_fn(&A::Producer), std::ref(a)};
std::thread consumer{std::mem_fn(&A::Consumer), std::ref(a)};

producer.join(); 
consumer.join(); 

(C++20~) atomic의 shared_ptr과 weak_ptr의 템플릿 부분 특수화

C++20 부터 atomicshared_ptrweak_ptr템플릿 부분 특수화 버전이 추가되었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
std::atomic<std::shared_ptr<int>> val{std::make_shared<int>(0)}; // shared_ptr 부분 특수화 버전입니다.

std::thread t1{
    [&val] {
        val.store(std::make_shared<int>(*val.load() + 1));
    }
};
std::thread t2{
    [&val] {
        val.store(std::make_shared<int>(*val.load() + 2));
    }
};
std::thread t3{
    [&val] {
        val.store(std::make_shared<int>(*val.load() + 3));
    }
};

t1.join();
t2.join();
t3.join();

EXPECT_TRUE(*val.load() == 1 + 2 + 3); // 쓰레드 경쟁없이 연산을 수행합니다.

(C++20~) atomic 특수화 추가

float, double, long double의 특수화가 추가되었습니다.

태그:

카테고리:

업데이트:

댓글남기기