c++ std::atomic

3 분 소요

std::atomic

아토믹 타입(atomic type)을 사용하면 동기화 기법을 적용하지 않고 읽기와 쓰기를 동시에 처리하는 아토믹 접근(atomic access)이 가능하다. 아토믹 연산을 사용하지 않고 변수의 값을 증가시키면 스레드에 안전하지 않다. 컴파일러는 먼저 메모리에서 이 값을 읽고 레지스터로 불러와서 값을 증가시킨 다음, 그 결과를 메모리에 다시 저장한다. 그런데 이 과정에서 같은 메모리 영역을 다른 스레드가 건드리면 데이터 경쟁이 발생한다. 변수에 std::atomic타입을 적용하면 'mutex'에서 설명할 뮤텍스 객체와 같은 동기화 기법을 따로 사용하지 않고도 스레드에 안전하게 만들 수 있다. 즉 아래의 변수를
int counter = 0;
++counter;
다음과 같이 첫번째 라인을 고치면 된다.
std::atomic<int> counter;
++counter;
C++ 표준은 언어에서 제공하는 모든 기본 타입마다 네임드(이름이 지정된) 정수형 아토믹 타입(named integral atomic type)을 정의하고 있다. 그중 몇 가지만 끄적여보면..
네임드 아토믹 타입 동등 std::atomic 타입
atomic_bool atomic<bool>
atomic_char atomic<char>
atomic_uchar atomic<unsigned char>
atomic_int atomic<int>
atomic_long atomic<long>
atomic_wchar_t atomic<wchar_t>
아토믹 타입을 사용할 때는 동기화 메커니즘을 명시적으로 사용하지 않아도 된다. 하지만 특정 타입에 대해 아토믹연산으로 처리할 때는 뮤텍스와 같은 동기화 메커니즘을 내부적으로 사용하기도 한다. 예를들어 연산을 아토믹 방식으로 처리하는 인스트럭션을 타깃 하드웨어에서 제공하지 않을 수 있다. 이럴 때는 아토믹 타입에 대해 is_lock_free() 메서드를 호출해서 잠그지 않아도 되는지(lock-free 인지), 즉 명시적으로 동기화 메커니즘을 사용하지 않고도 수행 할 수 있는지 확인한다.
std::atomic 클래스 템플릿은 정수 타입뿐만 아니라 다른 모든 종류의 타입에 대해서도 적용 할 수 있다. 예를 들어 atomic<double>이나 atomic<MyType>과 같이 쓸 수 있다. 단, MyType을 쉽게 복제 할 수 있어야 한다. 아래 예를 봐보자!
#include <atomic>
#include <memory>
#include <type_traits>
#include <iostream>

class Foo { private: int mArray[123]; };
class Bar { private: int mInt; };

int main(int argc, char *argv[])
{
    std::atomic<Foo> f;
    std::cout << std::is_trivially_copyable<Foo>() << " " << f.is_lock_free() << std::endl;
    std::atomic<Bar> b;
    std::cout << std::is_trivially_copyable<Bar>() << " " << b.is_lock_free() << std::endl;
    return 0;
}
[build] Undefined symbols for architecture arm64:
[build]   "___atomic_is_lock_free", referenced from:
[build]       std::__1::__atomic_base<Foo, false>::is_lock_free() const volatile in ex1.cpp.o
[build] ld: symbol(s) not found for architecture arm64
[build] clang: error: linker command failed with exit code 1 (use -v to see invocation)
[build] make[2]: *** [atomic_ex1] Error 1
[build] make[1]: *** [CMakeFiles/atomic_ex1.dir/all] Error 2
[build] make: *** [all] Error 2
[build] Build finished with exit code 2
참고한 책 기준으로 빌드를 하면 성공적으로 되어야 하지만 본좌의 컴터로는 lock_free 확인하는 부분에서 위와 같이 빌드 에러가 나는데.. 안되는 관계로.. 결과만 확인하면 다음과 같다.
1 0 # copy 가능하고 lock free 가 아니다.
0 1 # copy 가능하고 lock free 다.
NOTE_ 일정한 데이터를 여러 스레드가 동시에 접근할 때 아토믹을 사용하면 메모리 순서, 컴파일러 최적화 등과 같은 문제를 방지할 수 있다. 기본적으로 아토믹이나 동기화 메커니즘을 사용하지 않고서 동일한 데이터를 여러 스레드가 동시에 읽고 쓰는것은 위험하다.
NOTE_ lock-free 는 임의의 여러 스레드가 다른 위치에서 공유 자원을 블록킹 없이 접근 할 수 있다는 말

Example

아래 예를 봐보자
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>

void increment(int &counter)
{
    for (int i = 0; i < 100; ++i)
    {
        ++counter;
        std::this_thread::sleep_for(std::chrono::microseconds(1));
    }
}

int main(int argc, char *argv[])
{
    int counter = 0;
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i)
        threads.push_back(std::thread{increment, std::ref(counter)});

    for (auto &t : threads)
        t.join();

    std::cout << "Result = " << counter << std::endl;
}
위 코드는 스레드 10개을 생성하고, 모든 작업이 끝날 때까지 기다리도록 각 스레드마다 join을 호출한다. 예상하자면 1000 이라는 값을 마지막에 결과값으로 내놓지 않을 것이다. 역시 아닌 결과가 나왔다. 실제 결과값은 아래와 같다.
Result = 977 # 첫번째 결과
Result = 991 # 두번째 결과
위와 같이 아토믹이나 스레드 동기화 매커니즘을 사용하지 않고 단순하게 구현하면 데이터 경쟁이 발생한다. 데이터 경쟁이 확실히 발생하는것을 알 수 있다. 그럼 아토믹 타입으로 바꿔보자. counter변수를 int 타입에서 std::atomic<int> 또는 std::atomic_int로 변경하면 된다.
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>

void increment(std::atomic_int &counter)
{
    for (int i = 0; i < 100; ++i)
    {
        ++counter;
        std::this_thread::sleep_for(std::chrono::microseconds(1));
    }
}

int main(int argc, char *argv[])
{
    std::atomic_int counter(0);
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i)
        threads.push_back(std::thread{increment, std::ref(counter)});

    for (auto &t : threads)
        t.join();

    std::cout << "Result = " << counter << std::endl;
}
결과는 모든 실행마다 1000을 보였다. 이처럼 코드에 동기화 메커니즘을 따로 추가하지 않고도 스레드에 안전하고 데이터 경쟁이 발생하지 않게 만들 수 있다.
++counter 연산을 수행하는데 필요한 load, ++, save 작업을 하나의 아토믹 트랜잭션으로 처리해서 중간에 다른 스레드가 개입할 수 없기 때문이다.
그런데 이렇게 모든 연산에 아토믹을 사용하면 성능 문제가 발생할 수 있다. 아토믹이나 동기화 메커니즘을 사용할 때 동기화를 위한 작업으로 인해 성능이 떨어지기 때문에 이 부분을 처리하는데 걸리는 시간을 최소화 하도록 구성해야 한다. 로컬 변수로 값을 초기화하고 마지막 결과값을 레퍼런스로 추가하도록 작성하는 것이 바람직하다.

참고