본문으로 바로가기

C++20) Atomic Smart Pointers / std::atomic_flag

category C++/Modern 2021. 7. 23. 02:35

std::shared_ptr & std::weak_ptr

std::shared_ptr 및 std::weak_ptr은 thread safe 합니다. 그런데 왜 Atomic Smart Pointer가 추가되었을까요?

std::shared_ptr은 스레드로부터 '안전' 하면서 '안전하지 않기' 때문입니다.

 

std::shared_ptr은 reference counterresource로 구성되어 있습니다.

reference counter 자체는 스레드로부터 '안전' 합니다.

그러나 resource의 접근은 안전하지 않습니다. 

즉, reference counter 수정원자적(atomic)작업이며, resource가 정확히 한 번 삭제된다는 보장이 있습니다.

 

Boost에서는 std::shared_ptr에 대한 설명을 다음과 같이 하고 있습니다.

  • shared_ptr 인스턴스는 여러 스레드에 의해 동시에 '읽기' (상수 연산만 사용) 작업을 할 수 있습니다.
  • shared_ptr 인스턴스는 여러 스레드에 의해 동시에 '쓰기' ( operator= 이나 reset() 같은 작업 가능) 작업을 할 수 있습니다.

이 설명을 뒷받침 하기 위해, 예를 작성해봅시다. 스레드에서 std::shared_ptr을 복사하면 아무 문제가 없습니다.

std::shared_ptr<int> ptr{ std::make_shared<int>(1234) };

for (int i = 0; i < 10; ++i) {
	std::thread([ptr] { // 1
		std::shared_ptr<int> localPtr{ ptr }; // 2
		localPtr = std::make_shared<int>(5678); // 3
	}).detach();
}

1 은 ptr 을 캡쳐하여 복사합니다.

2 는 ptr을 명시적으로 복사하여 localPtr을 생성합니다.

3 은 localPtr이 기존에 공유하던 ptr에서 벗어난 후, 새로운 shared_ptr을 생성합니다.

 

이 모든 것은 멀티스레딩에서 전혀 문제가 없습니다.

다 복사거나 해제이며, localPtr은 복사본이기 때문에 data race가 아닙니다.

 

하지만 std::shared_ptr의 ptr을 참조로 캡쳐하면 말이 달라집니다.

std::shared_ptr<int> ptr { std::make_shared<int>(1234) };

for (int i = 0 ; i < 10 ; ++i){
   std::thread([&ptr]{ // 1
     ptr = std::make_shared<int>(2014); // 2
   }).detach(); 
}

1 은 ptr을 캡쳐하여 주소를 가져옵니다. (참조, 바인딩합니다.)

2 는 참조로 되어있는 ptr에 값을 대입합니다.

 

ptr은 바인딩 되어있으므로, 모든 스레드에서 접근할 수 있습니다. ptr의 resource를 수정하는 것은 thread safe 하지 않기 때문에 data race 입니다. 

 

std::shared_ptr을 위한 원자적 연산

std::shared_ptr을 위한 부분 템플릿 특수화 함수인 load, store, compare, exchange 가 있습니다.

명시적으로 사용하여 메모리 모델도 변경할 수 있습니다. std::shared_ptr을 위한 atomic operation은 다음과 같습니다.

std::atomic_is_lock_free(std::shared_ptr)
std::atomic_load(std::shared_ptr)
std::atomic_load_explicit(std::shared_ptr)
std::atomic_store(std::shared_ptr)
std::atomic_store_explicit(std::shared_ptr)
std::atomic_exchange(std::shared_ptr)
std::atomic_exchange_explicit(std::shared_ptr)
std::atomic_compare_exchange_weak(std::shared_ptr)
std::atomic_compare_exchange_strong(std::shared_ptr)
std::atomic_compare_exchange_weak_explicit(std::shared_ptr)
std::atomic_compare_exchange_strong_explicit(std::shared_ptr)

더 자세한 정보는 cppreference에서 Non-member functions를 확인해주세요.

 

이제 스레드로부터 안전한 방식으로 std::shared_ptr을 수정할 수 있습니다.

std::shared_ptr<int> ptr { std::make_shared<int>(1234) };

for (int i = 0 ; i < 10 ; ++i){
   std::thread([&ptr]{ // 1
     std::atomic_store(&ptr, std::make_shared<int>(2014)); // 1
   }).detach(); 
}

이제 1 에서 ptr의 데이터를 업데이트 하는 것은 스레드로부터 안전합니다.

 

Atomic Smart Pointer

atomic smart pointer에 대한 제안서 'N4162'는 위에서 설명한 원자적 연산에 대한 결함을 해결합니다.

이 결함은 다음과 같은 세 가지인데, 이에 대한 개요입니다. 자세한 사항은 문서를 확인해보세요.

  • 일관성 (Consistency) : std::atomic_*(std::shared_ptr) 은 non atomic resource에 대한 유일한 원자적 연산(atomic operation) 입니다. atomic_* 함수가 아닌 std::atomic<> 유형을 사용하도록 해야합니다.
  • 정확성 (Correctness) : 원자적 연산들(atomic operations)의 사용은 규율(규칙, discipline)을 기반으로 하기 때문에 오류가 발생하기 쉽습니다.
    즉 원자적 연산을 사용하는것을 잊기가 쉬운데, 우리는 ptr = localPtr 같은 구문을 사용하지 std::atomic_store(&ptr, localPtr) 같은 구문을 잘 사용하진 않습니다.
    만약 이러한 구문을 사용하면 data race로 인해 undefined behaviour 가 발생합니다. 
  • 성능 (Performance) : std::atomic<std::shared_ptr<T>> 가 std::atomic_* 함수들보다 더 큰 장점을 가지고 있습니다.
    멀티스레딩을 위해 특수하게 설계되었습니다. 예를 들어 std::atomic_flag 같은 걸로 저렴한(cheap) Spinlock을 구현할 수 있습니다.
    물론 std::atomic_flag를 std::shared_ptr과 std::weak_ptr에 추가하여 thread-safe 하게 만드는 것은 어울리지 않습니다. 즉 std::shared_ptr 과 std::weak_ptr이 특수 사용 사례에 최적화되었을 수 있습니다.

이 중 Correctness가 가장 중요하다고 나타나는데, Proposal을 보면 lock-free한 단일 연결 리스트를 구현해놓은것을 볼 수 있다.

template<typename T> class concurrent_stack {
    struct Node { T t; shared_ptr<Node> next; };
    std::atomic<std::shared_ptr<Node>> head;

    concurrent_stack( concurrent_stack &) = delete;
    void operator=(concurrent_stack&) = delete;

public:
    concurrent_stack() = default;
    ~concurrent_stack() = default;
    class reference {
        shared_ptr<Node> p;
    public:
       reference(shared_ptr<Node> p_) : p{p_} { }
       T& operator* () { return p->t; }
       T* operator->() { return &p->t; }
    };

    auto find( T t ) const {
        auto p = head.load();  // in C++11: atomic_load(&head)
        while( p && p->t != t )
            p = p->next;
        return reference(move(p));
    }
    auto front() const {
       return reference(head); // in C++11: atomic_load(&head)
    }
    void push_front( T t ) {
      auto p = make_shared<Node>();
      p->t = t;
      p->next = head;         // in C++11: atomic_load(&head)
      while( !head.compare_exchange_weak(p->next, p) ){ }
      // in C++11: atomic_compare_exchange_weak(&head, &p->next, p);
    }
    void pop_front() {
       auto p = head.load();
       while( p && !head.compare_exchange_weak(p, p->next) ){ }
       // in C++11: atomic_compare_exchange_weak(&head, &p, p->next);
    }
};

 

실제로 Atomic Smart Pointerthread-safe 한지 확인해보자. 다음은 직접 작성한 예제이다.

std::atomic<std::shared_ptr<int>> ptr{ std::make_shared<int>(0) }; // global

int main() {
	
	uint32_t maxThread{ std::thread::hardware_concurrency() };

	std::cout << maxThread << " 개의 스레드 구동" << std::endl;

	for (int i = 0; i < maxThread; ++i) {
		std::thread([]{
				for (int j = 0; j < 200'0000; ++j) {
					ptr = std::make_shared<int>(*ptr.load() + 1);
				}
			}).join();
	}
	
	std::cout << *ptr.load() << std::endl;
    // 본인의 컴퓨터는 12코어(하이퍼 스레딩)이다. 그러므로 24000000의 값이 나온다
}

std::atomic<std::shared_ptr<T>> 는 기존의 std::atomic 템플릿 변수들과 동일한 함수를 제공하는데, 간략하게 설명한다.

 

construct

constexpr atomic() noexcept = default;	// 1
atomic(std::shared_ptr<T> desired) noexcept;	// 2
atomic(const atomic&) = delete;		//3

std::atomic<std::shared_ptr<int>> a{};	// 1
std::atomic<std::shared_ptr<int>> b{a.load()};	// 2
std::atomic<std::shared_ptr<int>> c{b};	// 3, error

1. 기본 생성자 이다.

2. shared_ptr로 복사 생성한다. 물론 다른 std::atomic 타입들과 마찬가지로, 초기화는 atomic operation이 아니다.

3. atomic은 복사/이동을 할 수 없다.

 

이 이후로 atomic 타입간의 복사 / 이동은 금지되어 있다는 것을 알아두고, 따로 작성하지도 않겠다.

 

operator=

void operator=(std::shared_ptr<T> wanted) noexcept;

b = a.load();

wanted 값으로 값을 할당한다. store() 함수와 동일하다.

 

is_lock_free

bool is_lock_free() const noexcept;

std::atomic<std::shared_ptr<T>> 가 lock-free 연산을 한다면 true이다. 아니면 false

 

store

void store(std::shared_ptr<T> desired,
           std::memory_order order = std::memory_order_seq_cst) noexcept;

*thisdesired로 변경한다. store 함수의 내부에 보면 *this는 실제로 desiredswap 이 일어나게 된다. desired 는 복사본이기 때문에, 기존의 *this는 소멸자가 알아서 불리게 된다. (reference count가 감소하게 된다.)

order의 경우 std::memory_order_consume, std::memory_order_acquire, std::memory_order_acq_rel 이 가능하다.

 

load

std::shared_ptr<T> load(std::memory_order order = std::memory_order_seq_cst) const noexcept;

a = b.load();

std::atomic 인스턴스가 가지고 있는 std::shared_ptr<T>의 복사본을 반환한다. 

order의 경우 std::memory_order_release, std::memory_order_acq_rel이 가능하다.

 

operator std::shared_ptr<T>

operator std::shared_ptr<T>() const noexcept;

b.load() = ptr;

return load()와 동일하다. (위의 예시는 atomic이 실제로 std::shared_ptr<T>로 변환되는 것을 보여주기 위해 만든 예시이다. 쓸모 없는 행위)

 

exchange

std::shared_ptr<T> exchange(std::shared_ptr<T> desired,
                            std::memory_order order = std::memory_order_seq_cst) noexcept;
                            
b.exchange(ptr); // store와 동일하다.
ptr = b.exchange(ptr);

store와 동일하게 swap이 일어나는데, 복사본을 반환하는 점을 주의

그냥 b.exchange(ptr)을 하고 반환값을 받아내지 않으면, store와 동일하다. 교환한 값을 다시 반환값에 대입 해줘야한다.

 

compare_exchange_weak, compare_exchange_strong

bool compare_exchange_strong(std::shared_ptr<T>& expected, std::shared_ptr<T> desired,
                             std::memory_order success, std::memory_order failure) noexcept;
bool compare_exchange_weak(std::shared_ptr<T>& expected, std::shared_ptr<T> desired,
                           std::memory_order success, std::memory_order failure) noexcept;
bool compare_exchange_strong(std::shared_ptr<T>& expected, std::shared_ptr<T> desired,
                             std::memory_order order = std::memory_order_seq_cst) noexcept;
bool compare_exchange_weak(std::shared_ptr<T>& expected, std::shared_ptr<T> desired,
                           std::memory_order order = std::memory_order_seq_cst) noexcept;

기존 atomic의 compare_exchange_weak, compare_exchange_strong 과 동일하다.

즉,

compare_exchange_weak :  atomic 값이 expected와 같다면 desired로 바꾸고 true, 다르다면 바꾸지 않고 false

가볍게(비트단위) 검사함, strong보다 빠름

compare_exchange_strong : atomic 값이 expected와 같다면 desired로 바꾸고 true, 다르다면 바꾸지 않고 false

무겁게(여러 표기법.. float-point NaN같은, 트랩비트, 패딩비트) 검사함. weak보다 느림

C++20 기준으로 패딩비트는 무시하도록 strong 버전이 개선되었음

 

그 외 success, failure에 대한 설명은 cppreference를 보시라.

 

wait

void wait(std::shared_ptr<T> old
          std::memory_order order = std::memory_order_seq_cst) const noexcept;

old와 값이 동일하다면, atomic은 계속 블락됩니다.

notify_one() 또는 notify_all() 함수가 호출되면 그때 깨어나서 플로우를 계속 진행한다.

 

notify_one, notify_all

void notify_one() noexcept;
void notify_all() noexcept;

wait에 의해 블락된 스레드가 있는경우 하나 (notify_one)를 깨우거나, 전체 (notify_all) 깨웁니다.

 

is_always_lock_free

static constexpr bool is_always_lock_free = /*implementation-defined*/;

위의 is_lock_free 함수와 동일한데, 항상 lock-free로 연산하는지 확인하는 함수이다.

 

std::atomic_flag

bool값의 atomic이라고 보면 됩니다.

스핀락을 구현할 때 매우 빠르고, 유용하게 작성이 가능한데 기존의 bool과 사용하는 방법이 조금 다릅니다.

 

std::atomic_flag는 두 가지 흥미로운 속성이 있습니다.

  • std::atomic의 모든 특수화 타입과 달리 항상 lock-free 입니다.
  • 높은 스레드 추상화를 위한 빌딩 블록

std::atomic_flagstd::atomic<bool>과 달리 load 나 store 연산이 없습니다.

 

std::atomic_flag는 ATOMIC_FLAG_INIT이라는 상수로 초기화 하여야 합니다.

std::atomic_flag lock = ATOMIC_FLAG_INIT;

 

test_and_set

while (lock.test_and_set(std::memory_order_acquire)){
 //...
}

test_and_set 함수는 현재 flag 의 값을 true로 변경하고, 변경 전 값을 가져 옵니다.

즉, 이번 test_and_set 함수에서 flag값이 false -> true로 변경되었다면, flag를 true로 변경하고 false를 반환합니다.

 

test

lock.test(std::memory_order_relaxed)

test 함수는 현재 플래그의 값을 반환합니다.

 

clear

flag.clear();

clear 함수는 현재 플래그의 값을 false로 변경합니다.

 

다음은 std::atomic_flag를 이용한 spinlock 입니다.

// spinLock.cpp

#include <atomic>
#include <thread>

class Spinlock{
  std::atomic_flag flag;
public:
  Spinlock(): flag(ATOMIC_FLAG_INIT) {}

  void lock(){
    while( flag.test_and_set() );
  }

  void unlock(){
    flag.clear();
  }
};

Spinlock spin;

void workOnResource(){
  spin.lock();
  // shared resource
  spin.unlock();
}


int main(){

  std::thread t(workOnResource);
  std::thread t2(workOnResource);

  t.join();
  t2.join();

}

'C++ > Modern' 카테고리의 다른 글

C++20) std::format  (2) 2022.01.11
C++20) 우주선 연산자를 이용한 3방향 비교 (three-way comparison, spaceship operator)  (2) 2021.07.25
C++20) Ranges - 2  (0) 2021.07.19
C++20) Ranges - 1  (0) 2021.06.15
C++20) Modules ( 모듈 ) - 2  (5) 2021.03.14