SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++/Concurrency

[Concurrency] ❇ std::atomic ❇의 이해

보리남편 김 주부 2024. 1. 16. 09:00
발행 : 2014/01/16
수정 : 2014/01/20
- atomic에 대한 전체 내용에 대한 링크 추가
- 메모리 오더 옵션에 대한 정보 추가
- gcc에서 is_lock_free 가 에러 나는 이유 추가

 

본 글을 atomic 중 lock free에 대해 중점적으로 설명한 내용입니다. (상세 설명은 제일 밑 참고에 링크를 추가하였습니다.)

 

atomic의 사전적 의미


원자성(原子性, atomicity)은 어떤 것이 더 이상 쪼개질 수 없는 성질을 말한다. 어떤 것이 원자성을 가지고 있다면 원자적(atomic)이라고 한다.


생각해 봅시다.

C++ 코드에서 원자적인 것은 어떤 것이 있을까요? 한 줄짜리 코드인 x++ 는 원자적일까요?

 

정답은 아니다. 한 줄짜리 x++을 기계어로 변환하면 아래와 같이 3개의 명령(mov, add, mov)으로 수행되기에 원자적이라고 할 수 없다.

x++ 기계어 코드

 

위 내용을 어셈 명령어 inc로 변경을 하면 동일한 동작을 하는데 이렇게 더 이상 쪼개질 수 없는 명령을 '원자적'이라고 한다.

__asm
{
    inc x; 
}

 


왜 원자적 인지가 중요하나?


아래 글에서 한번 정리했지만 '멀티 스레드' 환경에서는 원자적이지 않은 x++은 원하는 대로 동작을 하지 않는다. (10개 스레드로 각각 100,000번 동안 1 증가를 시켰는데 결과가 953,983가 나왔음 )

2023.09.19 - [L C++] - race condition : 스레드를 병렬로 그냥 동작시키면 안 되는 이유

 

 실행 중 context switch가 일어나 예상과 다른 결과를 얻게 된 것인데 만약 원자적이었다면 실행 중에 context switch가 발생할 일이 없었을 테니 원하는 결과를 얻었을 것이다.

명령이 원자적이었다 하더라도 이것 또한 CPU가 하나일 때만 안전하고, 멀티 코어가 생기면서부터 안전하지 않게 되었다. 왜냐하면 각각의 CPU가 inc x 명령을 가져가서 수행한다면 다른 CPU에 의해 그 결괏값이 원복이 되기에 원하는 결과 값을 얻을 수 없게 된다.

동시에 명령을 가져가서 CPU 마다 각각 수행하기에 원치하는 결과가 나올 수 있다.



이때는 멀티 코어 CPU라고 하더라도 안전하게 메모리 접근이 가능한 OPCODE를 사용하면 된다. (각 CPU에서 제공하는 명령어 사용)

예) 인텔 호환 "lock" 접두어 : 하나의 CPU가 사용하는 메모리를 다른 CPU가 접근 못하게 한다.
__asm
{
    lock inc x; 
}


이처럼 OS의 lock이 아닌 CPU가 제공하는 기술을 사용하는 것을 "lock-free"라고 불리며 (lock-free 말 그대로 lock에 대해 자유롭다. 즉 CPU가 제공하는 명령사용으로 OS의 lock이 필요 없다는 뜻이다.) 이 lock-free를 제공하게 된 C++ 기술이 atomic 인 것이다.

 

그럼 어떻게 사용하면 되는지 예제로 확인해 보자.

예제 1
전역변수 x를 스레드 10개로 각각 100,000번씩 atomic을 이용해서 1을 증가하는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <atomic>
#include <vector>

//1.
std::atomic<long> x = 0; 

void foo()
{
    for (int i = 0; i < 100000; ++i)
    {
        //2.
         x.fetch_add(1);
        // x.fetch_add(2);
        // ++x;
        //x.fetch_add(1, std::memory_order_relaxed);
    }
}

int main()
{
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; i++) {
        threads.emplace_back(foo);
    }
    for (auto& thread : threads) {
        thread.join();
    }

    std::cout << x << std::endl;
}
결과
1000000


코드리뷰

std::atomic <long> x = 0;
atomic로 변수는 멀티 스레드에 안전하게 사용가능하다.

x.fetch_add(1);
함수 명에서도 그 용도를 알 수 있는데, fetch는 memory에서 CPU 레지스터로 읽어갈 때의 용어이고 add는 이와 함께 더하겠다는 의미이다.  CPU lock으로 안전하게 1 증가, 아래와 같이 2를 넣으면 2가 증가가 된다.
//x.fetch_add(2);
그리고 ++x를 하면 x.operator++() 호출되는데 결국  x.fetch_add(1)가 수행된다.

x.fetch_add(1, std::memory_order_relaxed);
fetch_add 함수는 두 번째 인자에 옵션을 넣을 수 있는데 위 옵션의 경우 ++x와 동일한 결과이지만 상황에 따라 약간 더 빠를 수 있다. (memory order는 별도로 정리하겠다.)

 

💡 메모리 오더 인자 관련으로는 ARM이나 특정 cpu용 옵션이고, 대중적인 PC에 들어가는 x86_64 계열은 strong order 규칙으로 프로그래머의 개입을 허용하지 않는 구성이어서 일반 PC 상에서의 C++코딩에서는 의미 없는 값이다. 
C++ Korea 김정택 님 정보 감사합니다. ╰(*°▽°*)╯

 

 

그 외 함수들도 정리하면 다음과 같다.

Specialized member functions

원자 객체에 저장된 값에 인수를 원자적으로 추가하고 이전에 보유한 값을 얻는다.
(public member function)
원자 객체에 저장된 값에서 인수를 원자적으로 빼고 이전에 보유한 값을 얻는다.
(public member function)
인수와 원자 객체의 값 사이에 비트별 AND를 원자적으로 수행하고 이전에 보유한 값을 얻는다.
(public member function)
인수와 원자 객체의 값 사이에 비트별 OR을 원자적으로 수행하고 이전에 보유한 값을 얻는다.
(public member function)
인수와 원자 객체의 값 사이에 비트별 XOR을 원자적으로 수행하고 이전에 보유한 값을 얻는다.
(public member function)
원자 값을 1씩 증가 또는 감소시킨다.
(public member function)
원자 값을 사용하여 비트 단위 AND, OR, XOR을 더하거나 빼거나 수행한다.
(public member function)

 

여기서 눈여겨봐야 하는 점은 mutex 도구를 이용하지 않았음에도 원하는 결과가 나온 부분이다. (mutex를 사용하지 않았을 때 분명히 경쟁상태(race condition)가 발생하여 원하는 결과가 나오지 않았는데 말이다.) 이 결과의 차이점은 기계어로 변환하면 확인할 수 있는데 lock 명령이 사용되었고 레지스터에 넣고 더하는 것이 한 명령에서 이뤄졌기 때문이다.


그럼 mutex를 이용하면 안전하지 않은가?

아니다. 아래와 같이 mutex 도구를 통해 안전은 하나, 단지 1을 증가하기 위해 mutex를 쓰는 것은 오버헤드가 크다.

2023.09.26 - [L C++] - [Concurrency] race condition 예방 방법 : Mutex와 Semaphore

m.lock();
x = x + 1;
m.unlock();


그럼 모두 atomic으로 선언하여 쓰면 안 되나?


std::atomic은 템플릿 클래스이기에 기본 타입(primitive type) 이외에 사용자 정의타입도 지원을 하기에 사용은 할 수 있으나 lock-free는 안될 수 있다. lock-free가 안된다는 말은 CPU가 제공하는 명령을 사용하지 않고 spin lock으로 동기화를 하는 것으로, CPU 사용 오버헤드가 발생한다.   

spin lock 💡
Spin Lock 은 이름에서 알 수 있듯이 만약 다른 스레드가 lock을 소유하고 있다면 그 lock이 반환될 때까지 계속 확인하며 기다리는 것이다. 지금 작업을 다른 CPU 양보하는 콘텍스트 스위칭을 낭비를 막기 위해 현재 CPU에서 루프를 돌며 반환되었는지 재확인하는 방법이다.


그렇기에 lock-free를 사용하기 위해 확인해야 하는 2가지를 알아보자.

 

lock-free를 사용할 수 있는 크기

일반적인 개발환경에서는 atomic은 8바이트 이하에서만 지원을 한다. 정확히 확인하기 위해서는 is_lock_free를 이용하면 되는데 다음 예제를 보자.

예제 2
8byte, 12byte의 사이즈를 가진 구조체를 is_lock_free 함수를 이용해 lock-free를 지원하는지 확인해 보는 프로그램 코드이다.
#include <iostream>
#include <atomic>

struct Point   { int x, y; };    // 8byte 구조체
struct Point3D { int x, y, z; }; //12byte 구조체

std::atomic<int>   a1;
std::atomic<Point> a2;
std::atomic<Point3D> a3;

int main()
{
    std::cout << "size = " << sizeof(int) << "bytes, is_lock_free = " << a1.is_lock_free() << std::endl;
    std::cout << "size = " << sizeof(Point) << "bytes, is_lock_free = " << a2.is_lock_free() << std::endl;
    std::cout << "size = " << sizeof(Point3D) << "bytes, is_lock_free = " << a3.is_lock_free() << std::endl;
}

 

결과:
size = 4 bytes , is_lock_free = 1
size = 8 bytes , is_lock_free = 1
size = 12 bytes, is_lock_free = 0

 

Point3D 구조체는 12byte 이기에 lock-free를 지원하지 않음을 확인할 수 있다.

 

GCC 13.2.0에서는 위 예제가 아래와 같이 링크에러가 발생하여 빌드가 안 되었다.
MinGW64/include/c++/13.2.0/atomic:259:(.text$_ZNKSt6atomicI7Point3DE12is_lock_freeEv[_ZNKSt6atomicI7Point3DE12is_lock_freeEv]+0x19): undefined reference to `__atomic_is_lock_free'

Msbuild는 되고 GCC에서는 안 되는 이유가 알고 싶다면 더보기로 확인
더보기

GCC의 경우 __atomic_is_lock_free() 함수는 8바이트 단위로 공유 변수의 잠금 해제 가능 여부를 확인한다. 그래서 12바이트 구조체 타입을 사용하는 경우 8바이트 단위로 나눌 수 없고, 잠금 해제 가능 여부를 확실하게 알 수 없으므로, undefined reference to `__atomic_is_lock_free' 문제를 발생시킨다.

 

이와 달리 MSBuild의 경우는 std::atomic_is_lock_free() 함수를 사용하여 공유 변수의 잠금 해제 가능 여부를 확인하는데 이 함수는 사용자 정의 타입의 경우 잠금 해제 가능 여부를 직접 확인하고 있어 에러가 발생하지 않는다.

 

trivial 하지 않으면 atomic으로 정의할 수 없다?

trivial 하다는 의미 💡
컴파일러가 자동 제공하는 special member function과 똑같이 동작하는 것을 말한다.
예 1) trivial constructor /Destructors => 사용자가 만든 생성자/소멸자가 없고, 아무 일도 하지 않는 생성자
예 2) trivial copy constructor  => 가상함수가 없고, 사용자가 만든 복사 생성자 없는 경우  즉, 모든 멤버를 얕은 로 복사 할 수 있는 컴파일러가 제공한 복사 생성자


예제를 보면서 확인해 보자.

예제 3
사용자가 정의한 복사 생성자가 있는 구조체 타입으로 만든 atomic 인 코드이다.
#include <iostream>
#include <atomic>

struct Point
{
    int x, y;
    Point(const Point& other ) : x(other.x), y(other.y) {}
};

std::atomic<Point> pt;

int main()
{
    std::cout << "Point is lock_free ? " << pt.is_lock_free() << std::endl;
}
결과
atomic:213:21: error: static assertion failed: std::atomic requires a trivially copyable type
  213 |       static_assert(__is_trivially_copyable(_Tp),
      |                     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~

 

사용자가 정의한 복사 생성자는 trivial 하지 않기에 이 타입으로 atomic을 선언하면 컴파일 에러가 난다. 다시 말해 atomic으로 선언조차 할 수 없다.

사전에 trivial 한 지 확인하는 방법은 없을까?

유저 정의 타입을 atomic으로 만들기 전에 확인할 수 있는 함수 템플릿이 있다.

예제 4
trivial 한 복사 생성자인지, trivial 한 디폴트 생성자 인지 확인하는 프로그램 코드이다.
#include <iostream>
#include <atomic>
#include <type_traits>

struct Point
{
    int x, y;

    Point() = default;
//  Point() {};

    Point(const Point&) = default;
    //Point(const Point& other ) : x(other.x), y(other.y) {}
};

int main()
{
    std::cout << std::is_trivially_default_constructible_v<Point> << std::endl;
    std::cout << std::is_trivially_copy_constructible_v<Point> << std::endl;
}
결과
1
1

 

사용자 정의 타입이지만 디폴트 생성자 복사 생성자를 컴파일에서 자동 생성하는 것과 동일하게 처리되기에 trivial 함 타입이 되었다.

 

as-is 코드를 to-be처럼 수정하여 결과를 확인해 보면

//as-is
    Point() = default;
//  Point() {};

    Point(const Point&) = default;
    //Point(const Point& other ) : x(other.x), y(other.y) {}
    
//to-be    
//  Point() = default;
    Point() {};

//  Point(const Point&) = default;
    Point(const Point& other ) : x(other.x), y(other.y) {}
결과
0
0

디폴트 생성자와 복사 생성자 둘 다 trivial 하지 않다고 false를 리턴된 것을 확인할 수 있다.

 

trivial 하면 1 아니면 0을 리턴하기에 두 조건을 만족할 때 atomic으로 선언하는 코드로 활용하면 되겠다.

 

참고


* 본 글에 예제 코드는 코드누리 교육을 받으면서 제공된 샘플코드를 활용하였습니다.

 

* 본 글은 atomic에 대한 일부 내용이라 전체적인 내용이 보고 싶을 땐, (atomic type에 대해) 상세 설명이 되어있는 아래 링크를 참조하기 바란다.

https://github.com/CppKorea/CppConcurrencyInAction/wiki/Chapter-05,-The-Cpp-memory-model-and-operations-on-atomic-types

 

Chapter 05, The Cpp memory model and operations on atomic types

2015년 하반기에 진행하는 C++ Concurrency in Action 스터디 관련 자료입니다. - CppKorea/CppConcurrencyInAction

github.com

C++ Korea 박동하 님 감사합니다. ╰(*°▽°*)╯

 

 

728x90