메모리모델

원래벌레·2022년 7월 1일
0
post-custom-banner

들어가기에앞서..
CPU와, 주기억장치의 속도차이로 인한 처리 속도 저하를 줄이기 위하여 캐시라는 것을 이용하게 됐다. 이 캐시는 주기억장치보다 빠른 메모리로 CPU 내부와 외부에 여러개가 존재한다. 이러한 캐시는 읽기신호를 보내는데에는 문제가 없지만, 쓰기 신호를 보내는데에는 문제가 발생한다. 이 이유는 캐시마다 공통된 값을 가지고 있을 수도, 없을 수도 있기 때문이다. 하위 캐시들은 캐시미스가 일어 날 때, 상위 캐시 또는 주기억장치에서 필요한 값을 가져옵니다. 이렇게 가져온 값은 하위 캐시에서 쓰기 신호를 통해 값이 바뀔 수 있습니다. 이렇게 바뀐 신호를 공유캐시에 바로 저장을 하고, 또 다른 캐시들은 해당 값을 가져오면 문제가 생기지 않지만 이런식으로 캐시를 이용하면 캐시의 속도가 저하됩니다. 따라서 캐시들은 각각 다른 값을 가질 수 있는 상황이 생겨납니다. 이럴 경우 단일쓰레드 환경에서는 어차피 하나의 코어를 사용하기 때문에 문제가 생기지 않지만, 멀티쓰레드 환경에서는 값이 달라지는 문제가 생겨납니다. 가시성의 문제 즉, 현재 입력된 값을 반환하지 않고 과거의 값을 가져오는 문제가 생겨나는 것입니다.

그리고 컴파일에서도 문제가 있는데, 컴파일러는 단일쓰레드 환경에 최적화가 되어있습니다. 따라서 단일쓰레드 환경에서 보았을 때 논리적으로 결과를 유지하며 CPU가 처리하기에 더 효율적인 구조로 코드를 재배치합니다. 하지만 사용자가 멀티쓰레딩 환경을 이용하며 해당 코딩이 사용자의 의도가 있는 부분이었다면 문제가 발생하게 됩니다.

이렇듯 개발자는 코드를 작성함에 있어서 무엇을 믿어야 하는걸까요?

💍 원자적 연산에서의 값

  • atomic 연산에 한해, 모든 쓰레드가 동일 객체에 대해서 동일한 수정 순서를 관찰한다.

  • 위의 말은 현재 보고 있는 값이 현재의 값은 아닐지 모르지만, 현재의 값이 되기 전 거쳐 온 값일 것이고, 해당 값에 대하여 해당 값의 이전 값들은 이제는 확인 할 수 없다는 것을 의미한다. 하지만 이를 통해서 위에서 말한 코드 재배치, 가시성의 문제는 해결하지 못한다.

atomic<int64> num;

void Thread_1()
{
	num.store(1);
}

void Thread2()
{
	num.store(2);
}

void Thread_Observer()
{
	while(true)
    {
    	int64 value = num.load();
    }
}
  • 위의 코드를 살펴보면, num을 atomic으로 선언하여 num을 atomic 함수들을 통해서 처리를 하고 있다. 이를 통해 위에서 말했던 모든 쓰레드가 동일 객체에 대해서 동일한 수정 순서를 관찰한다. 라는 점을 만족시키게 해준다. 여기서 메모리모델 정책 중 memory_order::memory_order_seq_cst 정책을 이용을 하면, 가시성과 코드 재배치의 문제를 해결 할 수 있는데 이는 뒤에서 자세히 살펴 보겠다. 더불어 이야기 하자면 intel또는 AMD CPU에서는 해당 정책의 기능을 기본적으로 제공한다. 따라서 intel 또는 AMD의 CPU를 이용하고 있다면, 아무것도 건들이지 않고 default인 상태로 두면 문제가 발생하지 않는다.
atomic<bool> flag;

int main()
{
	flag=false;
    
    flag.store(true, memory_order::memory_order_seq_cst);
    
    bool var = flag.load(memory_oreder::memory_order_seq_cst);
    
    {
    	bool prev = flag.exchange(true);
        // bool prev = flag;
        // flag = true;
    }
    
    //CAS (Compare-And-Swap) 조건부 수정
    {
    	bool expected = false;
        bool desired = true;
        flag.compare_exchange_strong(expected, desired); 
    }
}
  • 위의 코드에서 exchange 메소드는 읽기와 쓰기를 atomic하게 처리를 해준다. 저렇게 해주는 이유는 main에서 값을 읽고 있을 때, 다른 쓰레드에서 flag에 접근하여 값을 써버리게 되면 flag의 값이 더이상 유효하지 않기 때문이다.

  • CAS 코드 compare_exchange_strong은 멀티 쓰레딩 환경에서 일관된 값을 가지는 것을 보장해줍니다.

  • compare_exchange_weak 라는 메소드도 존재 하는데, 해당 메소드는 strong보다 더 베이직한 코드이다. 이게 무슨 말이냐 하면 다른 외부 환경에 의해서 thread가 interrupt가 됐을 때 Spurious Failure을 발생시켜 값을 false로 return한다. 이에비해 strong은 이러한 interrupt가 발생하면 return 대신에 한바퀴를 더 돌게 된다. strong이 좀 더 부하가 있는 작업이다.

💍 atomic memory_order 정책

atomic<bool> ready;
int32 value;

void Producer()
{
	value = 10;
    ready.store(true, memory_order::memory_order_realese);
    // ------------------ 절취선 ------------------
}

void Consumer()
{
	while(ready.load(memory_order::memory_order_acquire) == false)
    	;
        
    cout << value << endl;
}

int main()
{
	ready = false;
    value = 0 ;
    thread t1(Producer);
    thread t2(Consumer);
    t1.join();
    t2.join();
}
  • Memory Model(정책)
    1) Sequentially Consistent (seq_cst)
    2) Acquired-Release (acquire, release)
    3) Relaxed (relaaxed)

seq_cst :

가장 엄격한 모델로 컴파일러 최적화 여지가 적다. 즉 직관적이다라는 뜻으로 가시성, 코드재배치의 문제를 모두 해결 한다.

acquire-release :

seq_cst와 relaxed에 딱 중간으로 위의 코드 예시가 acquire-release의 코드 예시이다.
acquire와 release 서로 짝을 맞추는데, release를 기준으로 밑에 코드부터 절취선을 그어 절취선 밑으로는 코드 재배치가 일어나지 않는다.
또한 acquire도 마찬가지로 밑에 절취선을 그어 밑에 있는 변수들이 위로 못올라가게 한다. 또한 release 후에 acquire이 이루어지도록 순서를 보장하여 가시성 문제를 해결한다.

relaxed :

가장 자유로운 모델로 컴파일 최적화 여지가 많아 직관적이지 않다.
너무 나도 자유로워 코드 재배치, 가시성의 문제를 해결하지 못한다.
가장 기본 조건인 동일 객체에 대한 동일 관전 순서만 보장한다.

cf) atomic_thread_fence

std::atomic_thread_fence(memory_order::memory_order_release)

위의 방법을 통하여 atomic을 이용하지 않고서 모드를 이용 할 수 있다.

profile
학습한 내용을 담은 블로그 입니다.
post-custom-banner

0개의 댓글