Server/C++

메모리 모델과 원자적(atomic) 연산

Juzdalua 2024. 7. 31. 00:18

여러 스레드가 동일한 메모리에 동시에 접근하는 경우가 있다.

read 연산에서는 문제가 발생하지 않지만, write 연산에서는 문제가 발생한다.

문제는 Race Contidion(경합조건) 이라고도 한다.

이런 현상을 Undefined Behavior (정의되지 않은 행동)이라고 한다.

이런 문제 예방을 위해서 두가지 해결방법이 존재한다.

1. Lock(mutex)를 이용한 상호 배타적 접근(Mutual Exclusive)

2. Atomic(원자적) 연산을 이용


원자적 연산은 atomic 클래스를 사용하는걸 뜻하는게 아니다.

원자적 연산은 CPU가 한 번에 실행할 수 있는 것을 뜻한다.

연산 중간에 다른 스레드가 침입할 수 없는 것을 의미한다.

 

int64 a = 1;
a = 2;

위 코드에서 a를 수정할 때, CPU의 성능에 따라서 원자적 연산이 이루어진다.

64비트 CPU A에서는 한번에 a를 수정한다.

32비트 CPU B에서는 64비트의 a를 32비트씩 2개로 쪼갠 후 두번 연산을 진행하고 그 값을 a에 저장한다.

즉 A에서는 원자적 연산이 이루어지고, B에서는 원자적 연산이 이루어지지 않았다.

 

atomic<int64> v;
cout << v.is_lock_free() << endl; // 1 -> true

struct Knight {
    int32 level;
    int32 hp;
    int32 mp;
};
atomic<Knight> k;
cout << k.is_lock_free() << endl; // 0 -> false

위 코드를 실행하면 컴퓨터마다 다른 출력이 나올 것이다.

64비트 CPU를 사용하고있는 내 컴퓨터에서는 1과 0이라는 결과가 나왔다.

 

첫 번째 출력 1은 CPU 연산 자체로 원자적 연산이 가능하다는 뜻이다.

두 번째 출력 0은 원자적 연산이 불가능해 atomic 클래스 내부적으로 mutex를 활용하여 lock을 걸고 원자적 연산을 진행했다는 것을 의미한다.


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

 

#include <atomic>

atomic<int64> num = 0;

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

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

void Thread_Observer(){
	while(true){
    	int64 value = num.load();
    }
}

int main(){
    thread t1(Thread_1);
    thread t2(Thread_2);
    thread t3(Thread_Observer);

    t1.join();
    t2.join();
    t3.join();
}

위 코드에서 t3가 확인한 value는 0, 1, 2 중 하나일 것이다.

만약 num이 변경된 순서가 0 -> 2 -> 1이라고 가정해보겠다.

그렇다면 t3가 관찰한 num의 value의 변화 순서는 다음과 같은 경우의 수가 존재한다.

0 - 0 - 0
0 - 0 - 2
0 - 2 - 2
0 - 2 - 1
0 - 0 - 1
0 - 1 - 1

2 - 2 - 2
2 - 2 - 1
2 - 1 - 1

1 - 1 - 1

어떤 스레드가 해당 데이터를 확인하면 이후에는 과거의 값을 확인할 수 없다.


원자적 연산의 가시성

 

일반적으로 코드를 아래처럼 작성한다.

#include <iostream>

int main()
{
	bool flag = false;

	flag = true;
	bool val = flag;
}

 

원자적 연산에서는 해당 변수가 원자적 연산을 수행하고 있다고 가시성 확보를 위해 아래와 같이 작성한다.

#include <iostream>
#include <atomic>

int main()
{
	atomic<bool> flag = false;
	cout << flag.is_lock_free() << endl;

	flag.store(true);
	bool val = flag.load();
}

주로 사용하는 원자적 연산 함수

atomic.store(저장값); // 저장
atomic.load(); // 로드
atomic.exchange(변경할 값); // 로드 후 변경
atomic.fetch_add(정수); // 정수값 덧셈
atomic.compare_exchange_strong(예상값, 예측값);
/*
    if(flag == expected){
        expected = flag;
        flag = desired;
        return true;
    }
    else{
        expected = flag;
        return false;
    }
*/
#include "pch.h"
#include "CorePch.h"
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>

atomic<bool> flag;

int main()
{
	flag = false;
	cout << flag.is_lock_free() << endl;

	flag.store(true);
	bool val = flag.load(memory_order::memory_order_seq_cst); // == flag.load();

	{
		// 1. 원자적 연산이 아님
		bool prev = flag;
		flag = true;

		// 2. 원자적 연산
		bool prev = flag.exchange(true);
	}

	// CAS (Compare-And-Swap) 조건부 수정
	{
		bool expected = false;
		bool desired = true;
		flag.compare_exchange_strong(expected, desired);
	}
}

메모리 모델(정책)

Memory Model (정책)
1. Sequentially Consistent (seq_cst)
2. Acquire-Release (consume, acquire, release, acq_rel)
3. Relaxed (relaxed)

1) sqe_cst (가장 엄격 = 컴파일러 최적화 여지 적음 = 직관적) - 기본값
	-> 가시성 문제 바로 해결! 코드 재배치 바로 해결!
2) acquire-release
	-> release 명령 이전의 메모리 명령들이 해당 명령 이후로 재배치 되는 것을 금지
	-> acquire로 같은 변수를 읽는 스레드가 있다면, release 이전 명령들이 acquire 순간에 관찰 가능 (가시성 보장)
3) relaxed (자유롭다 = 컴파일러 최적화 여지 많음 = 직관적이지 않음) - 거의 사용하지 않음.
	-> 동일 객체에 대한 동일 순서 변화만 보장함.

인텔, AMD의 경우 CPU가 순차적 일관성을 보장해서 seq_cst를 사용해도 별다른 부하가 없다.
ARM의 경우 의미 있는 차이가 있다고 한다.

사실 메모리 모델 기본값이 가시성과 코드 재배치 문제를 해결해주고 있기 때문에, 원자적 연산을 사용하면 메모리 모델을 모르고 사용해도 문제가 없었을 것이다.

 

#include "pch.h"
#include "CorePch.h"
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>

atomic<bool> ready;
int32 value;

void Producer() {
	value = 10;
	ready.store(true, memory_order::memory_order_seq_cst);
}

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

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

void Consumer_Acquire() {
	// ------------- 절취선 --------------------
	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();
}

 

메모리 정책은 atomic 클래스에서만 사용할 수 있는게 아니다.

atomic_thread_fence(memory_order::memory_order_release);
// ------------- 절취선 --------------------

...

// ------------- 절취선 --------------------
atomic_thread_fence(memory_order::memory_order_acquire);

하지만 굳이 사용하진 않는다고 한다.

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

L Value, R Value, Reference  (0) 2024.07.31
TLS(Thread Local Storage)  (0) 2024.07.31
CPU 파이프라인 - 명령어 파이프라인  (0) 2024.07.30
캐시(Cache)와 CPU  (1) 2024.07.30
Future와 Asynchronous  (0) 2024.07.30