| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
Tags
- R 그래프
- 트라이
- Binary Lifting
- ccw 알고리즘
- Express.js
- 비트필드를 이용한 dp
- map
- LCA
- trie
- Spin Lock
- 그래프 탐색
- Behavior Design Pattern
- 게임 서버 아키텍처
- 강한 연결 요소
- 최소 공통 조상
- localstorage
- 분리 집합
- 이분 탐색
- Prisma
- Lock-free Stack
- SCC
- PROJECT
- 벨만-포드
- Strongly Connected Component
- 비트마스킹
- 자바스크립트
- 2-SAT
- DP
- JavaScript
- Github
Archives
- Today
- Total
dh_0e
[C++/Game Server] 동기화 방식과 메모리 정책 (memory_order) 본문
데이터 충돌(Race Condition)을 방지하는 3가지 방식
1. Mutex(Lock)를 이용한 동기화
- 가장 고전적이고 확실한 방법
- 특징: 코드가 직관적이고 복잡한 로직도 안전하게 보호할 수 있음
- 단점
- 줄을 서는 과정에서 Context Switch(문맥 교환) 비용이 발생해 성능 저하가 생길 수 있음
- 데드락(Deadlock) 위험성 존재
int counter = 0;
std::mutex mtx; // 자물쇠 역할
void increase() {
for (int i = 0; i < 10000; ++i) {
// 코드 블록 전체를 잠금 (임계 영역 시작)
std::lock_guard<std::mutex> lock(mtx);
counter++;
// 함수를 나갈 때 lock이 자동으로 해제됨 (임계 영역 끝)
}
}
int main() {
std::thread t1(increase), t2(increase);
t1.join(); t2.join();
std::cout << "Final: " << counter << std::endl; // 정확히 20000 출력
}
2. Atomic 변수 사용
- std::atomic을 사용하여 변수 자체를 쪼갤 수 없는 원자 단위로 만드는 방식
- 동작: CPU 수준에서 지원하는 원자적 명령어를 사용하여 데이터를 읽고 쓰는 도중에 다른 스레드가 끼어들지 못하게 보장
- 특징
- lock을 걸지 않기 때문에 Lock-free 하며 성능이 빠름
- 단순히 값을 대입하거나 가져오는(load, store) 수준의 작업에 적합
std::atomic<bool> is_ready(false);
void producer() {
// 무거운 작업 수행...
is_ready.store(true); // "준비 완료" 신호를 원자적으로 기록
}
void consumer() {
while (!is_ready.load()) {
// ready가 될 때까지 대기
}
// 작업 시작...
}
3. Exchange, CAS(Compare-And-Swap) 함수 사용
- exchange(무조건 교체)
- 동작: 현재 값을 읽어오면서 동시에 새로운 값으로 덮어버림
- 의미: "이전 값이 뭐였든 상관없어. 일단 나한테 주고, 넌 이 값을 가져"
- 용도: 소유권을 이전하거나, 상태를 무조건 변경할 때 사용
std::atomic<bool> flag(false);
void try_ownership() {
// 이전 값을 가져오면서 무조건 true로 변경
bool prev = flag.exchange(true);
if (prev == false) {
// 내가 처음으로 false에서 true로 바꾼 사람이라면!
// 여기서 "독점적인 작업" 수행 가능
}
}
- compare_exchange (조건부 교체 = CAS)
- 동작: 현재 값이 내가 예상한 값(expected)과 같을 때만 새로운 값으로 바꿈
- 의미: "지금 값이 false라면 true로 바꿔줘. 만약 누가 벌써 true로 바꿨으면 내버려 두고."
- 특징: 락 없이 정교한 로직(ex. lock_free queue)을 짤 때 핵심이 되는 기술
std::atomic<int> balance(100);
void withdraw(int amount) {
int expected = 100; // 예상되는 현재 잔액
int desired = 100 - amount; // 성공 시 바꿀 잔액
// balance가 expected(100)와 같다면, desired로 바꾸고 true 반환
// 만약 그새 누가 돈을 써서 balance가 100이 아니라면, false 반환
if (balance.compare_exchange_strong(expected, desired)) {
// 출금 성공
} else {
// 출금 실패 (중간에 balance가 바뀌었으므로 expected에 현재 balance가 담김)
}
}
CAS 함수의 strong과 weak의 차이
- Spurious failure(가짜 실패)를 잡아주는지의 차이
- Spurious failure: 다른 스레드의 interruption을 받아서 원래 성공해야 하는 상황에서 중간에 실패하는 경우
- compare_exchange_weak(): original CAS 코드
- 가짜 실패가 발생할 수 있기 때문에 보통 "while(!flag.compare_exchange_weak(expected, desired))"와 같이 반복문 안에서 성공할 때까지 시도하는 구조로 사용됨
- 어차피 실패하면 다시 시도해야 하므로, 보통 while 루프 안에서 성공할 때까지 반복하는 구조로 사용
- compare_exchange_strong(): CAS 코드에 가짜 실패를 잡는 코드를 추가한 버전
- 루프 없이 한 번의 체크로 로직을 끝내야 하거나,
루프 내부의 연산이 너무 무거워서 가짜 실패조차 허용하고 싶지 않을 때 사용
- 루프 없이 한 번의 체크로 로직을 끝내야 하거나,
- 일부 머신을 제외하고, 성능 차이가 크게 나지 않음
| 방식 | 제어 대상 | 특징 | 비유 |
| Mutex | 코드 블록 전체 | 가장 안전, 느림 | 화장실 문 잠그기 |
| Atomic (L/S) | 변수 하나 | 빠름, 단순 대입용 | 한 명만 들어가는 회전문 |
| CAS (Compare) | 상태 변화 | 정교함, 논리적 판단 | 비밀번호가 맞을 때만 문 열기 |
| 방식 | 성능 | 난이도 | 주요 용도 |
| Mutex | 낮음 | 쉬움 | 복잡한 로직, 긴 임계 영역 보호 |
| Atomic | 높음 | 보통 | 단순 카운터, 플래그 변경 |
| CAS | 최고 | 높음 | 락 프리(Lock-free) 자료구조 구현 |
메모리 정책 (Memory Model)
- 스레드 간의 통신을 목적으로 두 스레드 사이에 동기화 지점을 관리할 수 있음
atomic<bool> ready;
int32 value;
void Producer()
{
value = 10;
ready.store(true/*,메모리 정책*/);
}
void Consumer()
{
while (ready.load(/*메모리 정책*/) == false);
cout << value << endl;
}
int main()
{
ready = false;
value = 0;
thread t1(Producer);
thread t2(Consumer);
t1.join();
t2.join();
return 0;
}
- 다음과 같이 atomic 변수 사용 시에 매개 변수로 메모리 정책을 추가하여 사용할 수 있음
1. Sequentially Consistent (memory_order::memory_order_seq_cst )
- 메모리 정책을 따로 추가하지 않으면 default로 적용되는 버전으로 가장 엄격하게 원자성을 유지함
- 컴파일러 최적화 여지가 적으며, 가시성 문제와 코드 재배치 문제가 해결됨
- 직관적으로 코드를 관리할 수 있어 가장 일반적으로 사용됨
2. Acquire-Release (memory_order::memory_order_acquire, consume, release, acq_rel)
- 엄격함을 통제할 수 있는 중간 느낌의 메모리 정책
- release 명령 이전의 메모리 명령어들이 해당 release 명령 뒤로 재배치되는 것을 금지 시킴
- 하지만 명령어 이전이나 이후의 명령어들끼리 재배치되는 것은 관여 안 함
- Release, Acquire로 짝을 이뤄 한 방향 배리어(One-way Barrier)를 구성할 수 있음
- Release(Store): 이 명령 이전의 모든 쓰기 작업이 이 명령 이후로 밀려나지 않게 막아줌
- 이후의 명령이 앞으로 오는 건 막지 않음
- Acquire(Load): 이 명령 이후의 모든 읽기 작업이 이 명령 이전으로 당겨지지 않게 막음
- 이전의 명령이 뒤로 가는 건 막지 않음
- Release(Store): 이 명령 이전의 모든 쓰기 작업이 이 명령 이후로 밀려나지 않게 막아줌
void Producer()
{
value = 10;
value2 = 11; // value = 10; 명령과 재배치 가능성 존재
ready.store(true, memory_order::memory_order_release);
// ------------------- 절취선 -----------------------------
}
void Consumer()
{
// ------------------- 절취선 -----------------------------
while (ready.load(memory_order::memory_order_acquire) == false);
cout << value << endl; // 10
}
- 다음과 같이 release 전에 value에 넣어준 10의 값은 acquire 이후 출력에 적용됨 (가시성 보장, seq_cst와 동일하게 적용)
3. Relaxed (memory_order::memory_order_relaxed)
- 가장 자유로운 버전으로 컴파일러가 최적화를 위해 코드 재배치도 멋대로 가능하며 가시성이 해결되지 못함
- 가장 기본적인 조건(동일 객체에 대한 동일 관전 순서)만 보장
- 적어도 하나의 변수에 대해서는 코드 재배치로 인해 과거로 돌아가는 불상사 X
- 직관적이지 못하며, 멀티 스레드 환경에서 재앙적인 존재로 거의 사용하지 않음
다양한 메모리 정책들이 있지만 인텔(x86), AMD CPU의 경우 애당초 순차적 일관성을 보장하며 하드웨어 수준에서 강력한 메모리 모델을 사용하기 때문에 기본 정책인 seq_cst를 써도 별다른 부하 차이가 없음
※ ARM(모바일/맥 실리콘) 환경에서는 이 차이를 엄격히 구분하므로 성능 최적화를 위해 위 정책들을 잘 써주는 것이 중요해짐
'C++ > Game Server' 카테고리의 다른 글
| [C++/Game Server] Lock-Based Stack/Queue (+delete, IN/OUT) (0) | 2026.02.05 |
|---|---|
| [C++/Game Server] Thread Local Storage (0) | 2026.02.05 |
| [C++/Game Server] future, promise, packaged_task (1) | 2025.08.08 |
| [C++/Game Server] Spin Lock, Event Lock, Condition Variable (6) | 2025.08.05 |
| [C++/Game Server] Spin Lock, Volatile 변수 (1) | 2025.07.26 |