std::shared_ptr & std::weak_ptr
std::shared_ptr 및 std::weak_ptr은 thread safe 합니다. 그런데 왜 Atomic Smart Pointer가 추가되었을까요?
std::shared_ptr은 스레드로부터 '안전' 하면서 '안전하지 않기' 때문입니다.
std::shared_ptr은 reference counter와 resource로 구성되어 있습니다.
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 Pointer가 thread-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;
*this를 desired로 변경한다. store 함수의 내부에 보면 *this는 실제로 desired와 swap 이 일어나게 된다. 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_flag는 std::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 |