C++ 11 메모리 모델
C++ 표준 자체가 싱글스레드 환경을 염두에 둔 모델이다
그러다 보니 멀티스레드 환경에서 작업을 하기 불편했다
그래서 C++ 11에 메모리모델이 확립되면서
멀티스레드 환경도 프로그래밍하기 편해졌다
멀티스레드 환경의 문제 (복습)
여러 스레드가 동일한 메모리에 동시 접근 (대표적으로 write 연산)
-> Race Condition (경합 조건) 발생
-> Undefined Behavior (정의되지 않은 행동)
이러한 상황의 해결책은
Lock (mutex)을 이용한 상호 배타적 (Mutual Exclusive) 접근을 하거나
Atomic (원자적) 연산을 이용하는 것이다
C++ 원자적 연산의 절대법칙
Memory Model을 학습하기 전
C++에서 원자적 연산의 절대 법칙을 알고 가는 것이 좋다
"atomic한 연산"에 한해,
모든 스레드가 "동일 객체"에 대해서 "동일한 수정 순서"를 보장한다
- 동일한 수정순서
스레드가 알고 있는 값을 기준으로
앞으로 수정되는 값들이 들어오지
역으로 수정된 값들이 들어오진 않는다
예를 들어,
값이 1 -> 2 -> 3으로 수정이 된 상태라면
특정 스레드가 값을 2로 알고 있다 하더라도
추후 값이 2에서 3으로 (앞으로) 수정되지
값이 2에서 1로 (뒤로) 수정되지는 않는다는 것이다
이처럼 수정순서가 역으로는 가지 않는다는 뜻이다
참고로, 값의 스킵은 당연히 가능하다
예를 들어,
값이 1 -> 2 -> 3 -> 4로 수정이 된 상태라면
특정 스레드가 값을 2로 알고 있다 하더라도
추후 값이 2 -> 3 or 2 -> 3 -> 4 or 2 -> 4로 수정된다
- atomic한 연산
atomic 키워드를 사용한 것뿐 아니라
원자적인 연산을 포괄적으로 의미한다
원자적 연산이란 여러 단계가 아닌 한 번에 CPU에서 처리하는 것을 의미한다
atomic 키워드를 사용하지 않아도 원자적인 상황으로
아래와 같은 코드가 있다
int64 num;
void Thread_1()
{
num = 1;// 실행 과정에서 한번에 처리된다
}
void Thread_2()
{
num = 2;// 실행 과정에서 한번에 처리된다
}
참고로,
atomic 키워드를 이용해 원자성을 보장할 때
해당 데이터가 현재 환경에서
자체적으로 원자적으로 실행되는지
혹은 atomic 클래스로 인해 강제로 원자적인 환경으로 만들어졌는지에 대해서는
is_lock_free를 통해 알 수 있다
atomic<int64> v;
cout << v.is_lock_free() << endl;// 1 (강제로 만들어지지 않아서 lock free한 상태 true)
struct Player
{
int32 level;
int32 hp;
int32 mp;
};
atomic<Player> p;
cout << p.is_lock_free() << endl;// 0 (강제로 만들어져서 lock free한 상태 false)
- 동일 객체
atomic 변수들이 여러 개인 경우에
각각에 대해서는 동일한 수정순서가 보장되지만
변수들 간에는 동일한 수정순서가 보장되지 않는다
atomic의 옵션
store, load와 같이 atomic 기능을 이용할 때
옵션을 정해줘야 한다
정하지 않는다면, 기본값은 아래와 같다
사실 기본값은 atomic의 가시성과 코드 재배치 해결이 가능한 옵션이라서
굳이 옵션을 정해주지 않아도 된다
num.store(1);// Memory Order 정해주지 않으면
num.store(1, memory_order::memory_order_seq_cst);// 이와같은 기본값이 들어간다
atomic 기능 4가지
atomic의 대표적인 기능에는
store, load, exchange, compare_exchange가 있다
atomic<bool> flag;
int main()
{
flag = false;
// flag = true;
flag.store(true, memory_order_seq_cst);
// bool val = flag;
bool val = flag.load(memory_order_seq_cst);
// bool prev = flag; flag = true;
bool prev = flag.exchange(true);
// CAS(Compare-And-Swap) 조건부
{
bool expected = false;
bool desired = true;
flag.compare_exchange_strong(expected, desired);
// 내부 구조
//if (flag == expected)
//{
// expected = flag;
// flag = desired;
// return true;
//}
//else
//{
// expected = flag;
// return false;
//}
while (true)
{
bool expected = false;
bool desired = true;
flag.compare_exchange_weak(expected, desired);
}
}
}
특히, compare_exchange weak와 strong의 차이를 알아야 한다
weak가 기본버전으로 주의점이 있다
다른 스레드의 intrerruption으로 인해
flag와 expected가 같아야 하지만 다른 상황이 발생하는
Spurious Failure가 생길 수 있다
이러한 상황이 오는 것을 해결하기 위해서
weak는 while문과 함께 사용해야 한다
반면, strong은
Spurious Failure가 끝날 때까지 자체적으로 반복하기 때문에
while문을 쓰지 않고 편하게 사용하면 된다
Memory Model의 3종류
Memory Model은 크게 3종류로 구분된다
1) Sequentially Consistent (seq_cst)
가장 엄격하고 컴파일러 최적화 여지가 매우 적다
따라서, 가시성 문제와 코드 재배치 문제가 해결된다
2) Acquire-Release (consume, acquire, release, acq_rel)
1번과 3번의 중간이며 컴파일러 최적화 여지가 있고 조심해야 한다
release 기준으로 앞 뒤를 확실히 구분할 수 있게 한다
그래서 release 기준 앞의 메모리 명령들이
release 기준 뒤의 명령 이후로 재배치되는 것을 막는다
acquire을 하면,
release 기준 앞에 있었던 코드들의 실행을 무조건 보장하며
가시성이 보장된다
참고로,
consume은 C++17에서 안정성 이슈가 있어서 잘 사용되지 않는다
acq_rel은 acquire + release 조합이다
3) Relaxed (relaxed)
컴파일러 최적화 여지가 매우 높고 자유롭다
따라서, 코드 재배치도 발생하고 가시성 문제도 해결이 되지 않는다
그저 동일 객체에 대한 동일 관전 순서만 보장한다
코드 예시 - seq_cst
atomic<bool> ready;
int32 value;
void Producer()
{
value = 10;
ready.store(true, memory_order_seq_cst);
}
void Consumer()
{
while (ready.load(memory_order_seq_cst) == false)
{
}
cout << value << endl;
}
int main()
{
ready = false;
value = 0;
thread t1(Producer);
thread t2(Consumer);
t1.join();
t2.join();
}
코드 예시 - release, acquire
atomic<bool> ready;
int32 value;
void Producer()
{
value = 10;
ready.store(true, memory_order_release);
// ----------절취선-------------
}
void Consumer()
{
// ----------절취선-------------
while (ready.load(memory_order_release) == false)
{
}
cout << value << endl;
}
int main()
{
ready = false;
value = 0;
thread t1(Producer);
thread t2(Consumer);
t1.join();
t2.join();
}
코드 예시 - relaxed
atomic<bool> ready;
int32 value;
void Producer()
{
value = 10;
ready.store(true, memory_order_relaxed);
}
void Consumer()
{
while (ready.load(memory_order_relaxed) == false)
{
}
cout << value << endl;
}
int main()
{
ready = false;
value = 0;
thread t1(Producer);
thread t2(Consumer);
t1.join();
t2.join();
}
CPU의 MemoryModel
인텔과 AMD의 경우 자체적으로 순차적 일관성을 보장을 하기에
seq_cst를 사용해도 별다른 부하가 거의 없다
(AMD의 경우는 인텔에 비해서는 조금 부하가 있다)
atomic_thread_fence()
Memory Model의 release 기능(절취선 기능)은
atomic 변수를 생성해 활용하는 것이 아닌
atomic_thread_fence를 통해서도 쉽게 사용이 가능하다

'게임 서버' 카테고리의 다른 글
[게임서버 섹션2 Note] Thread Local Storage (0) | 2024.12.31 |
---|---|
[게임서버 섹션2 Note] CPU 파이프라인 (0) | 2024.12.30 |
[게임서버 섹션2 Note] 캐시 (0) | 2024.12.30 |
[게임서버 섹션2 Note] Future (0) | 2024.12.30 |
[게임서버 섹션2 Note] Condition Variable (0) | 2024.12.30 |