EP.07 — 락-프리 스마트 포인터: mutex 없이 포인터를 바꿀 수 있다고?
이 글은 『서버 개발 수기』 시리즈의 일곱 번째 글이다.
지난 회에서는 Write 버퍼 병합을 다뤘다.
오늘은 조금 다른 세계다. 락 없이 포인터를 바꾸는 이야기.
※ 이 시리즈에서 다루는 서버 코드는 Project-AO(Ancient Origin)라는 가명으로 부른다.
게시판 이야기
게임서버에 "현재 이벤트 설정"이라는 공유 데이터가 있다. 경험치 2배, 드롭률 1.5배, PK 페널티 감소 같은 설정.
스레드 8개가 패킷 처리할 때마다 이 설정을 읽는다. 초당 수십만 번. 읽기만 하니까 문제없다.
락도 필요 없다. 읽기끼리는 충돌이 안 나니까.
회사 게시판이라고 생각하면 된다. 교실 앞에 붙어있는 게시판을 지나가면서 읽는 거다.
줄 서서 읽을 필요가 없다. 누가 읽는 동안 옆에서 같이 읽어도 아무 문제없다.
그런데 문제가 생기는 순간이 있다.
게시판을 바꿀 때
운영자가 "경험치 2배 이벤트 시작!" 버튼을 눌렀다. 설정을 바꿔야 한다.
순진하게 생각하면 값을 직접 바꾸면 된다. 그런데 이게 위험하단다.
스레드 3이 바로 그 순간에 설정을 읽고 있을 수 있다. 경험치는 바뀌었는데 드롭률은 아직 안 바뀐, 반쯤 바뀐 상태를 읽을 수 있단다.
게시판으로 치면, 누가 게시판을 읽고 있는데 옆에서 누가 글자를 지우고 새로 쓰고 있는 꼴이다.
읽는 사람은 지워진 글자와 새 글자가 섞인 혼란스러운 걸 보게 된다.
그러면 락을 걸면?
당연히 그 생각을 했다.
lock();
config->expRate = 2.0;
config->dropRate = 1.5;
config->pkPenalty = 0.5;
unlock();
안전하다. 근데 문제가 있단다. 읽는 쪽도 전부 락을 걸어야 한다.
lock();
rate = config->expRate; // 읽기인데도 락
unlock();
읽기가 초당 수십만 번이다. 설정 변경은 하루에 몇 번이다. 하루에 몇 번 바꾸는 것 때문에 초당 수십만 번의 읽기가 전부 느려진다.
게시판 비유로 하면. 게시판을 하루에 한 번 바꾸는데, 읽을 때마다 줄 서서 기다려야 하는 상황이다.
Claude한테 "이거 말이 안 되지 않아?" 하고 물었다.
게시판을 통째로 갈아끼운다
답을 듣고 무릎을 쳤다.
기존 게시판의 글자를 지우고 새로 쓰는 게 아니라, 새 게시판을 만들어서 통째로 갈아끼운다는 거다.
1단계: 새 설정 만들기
newConfig = new Config();
newConfig->expRate = 2.0;
newConfig->dropRate = 1.5;
newConfig->pkPenalty = 0.5;
2단계: 포인터를 원자적으로 교체
oldConfig = InterlockedExchangePointer(&g_config, newConfig);
3단계: 이전 설정 해제
oldConfig->Release();
게시판을 수정하는 게 아니라, 새 게시판을 만들어서 탁 끼운다. 읽는 사람은 이전 게시판을 다 읽거나, 새 게시판을 읽거나. 반쯤 수정된 걸 보는 일이 없다.
코드를 열었다
XIOAutoVar.h를 열었다.
template <class T>
class XIOAutoVar {
T* operator=(T* p) {
if (p) p->AddRefVar(); // 새 객체 참조 증가
T* pOld = static_cast<T*>(
InterlockedExchangePointer(
reinterpret_cast<void* volatile*>(&m_p), p
)); // 원자적 교체
if (pOld) pOld->ReleaseVar(); // 이전 객체 참조 감소
return p;
}
};
짧다. 놀라울 정도로 짧다. 근데 이 짧은 코드에 핵심이 다 들어있단다.
InterlockedExchangePointer. 이게 핵심이라고 Claude가 알려줬다. CPU 명령어 하나로 포인터 값을 바꾸는 거란다.
명령어 "하나"라는 게 왜 중요하냐면. EP.03에서 락 문제가 생기는 이유가 읽기 → 계산 → 쓰기가 3단계라서 중간에 끼어드는 거였다. 이건 1단계다. 중간이 없다. 바뀌기 전이거나, 바뀐 후이거나. 반쯤 바뀐 상태가 존재할 수 없단다.
읽는 쪽은 락이 필요 없다
여기서 소름이 돋았다.
포인터가 항상 유효한 객체를 가리키고 있으니까, 아무 때나 읽어도 안전하다. 락 비용 0.
// 읽는 쪽 — 락 없음!
rate = g_config->expRate; // 그냥 읽으면 됨
락 방식은 읽을 때마다 락을 잡고 풀어야 했다. 초당 수십만 번. 포인터 교체 방식은 그냥 읽는다. 비용이 없다.
읽기가 99.99%, 쓰기가 0.01%인 상황에서 읽기 비용을 0으로 만드는 기법이다.
게시판 비유로 돌아가면. 이전 방식은 게시판을 읽을 때마다 줄을 서야 했다. 새 방식은 그냥 지나가면서 읽으면 된다. 게시판이 바뀌는 순간에도, 이전 게시판을 온전히 읽거나 새 게시판을 온전히 읽거나. 반쯤 수정된 걸 보는 일이 없다.
그런데 이전 객체는 언제 삭제하나
여기서 EP.05가 다시 나온다.
포인터를 바꿨다. 그런데 스레드 5가 이전 설정을 아직 읽고 있을 수 있다.
g_config → [새 설정]
스레드 5: 아까 받아둔 [이전 설정] 포인터로 아직 작업 중...
이전 설정을 바로 delete 하면? 스레드 5가 터진다. EP.05에서 다룬 "먹고 있는데 누가 내 접시를 치워버린 꼴".
그래서 코드에 AddRefVar()와 ReleaseVar()가 있는 거다.
if (p) p->AddRefVar(); // 새 객체: "나 쓸 거야" +1
// ... 포인터 교체 ...
if (pOld) pOld->ReleaseVar(); // 이전 객체: "나 다 썼어" -1
이전 객체의 참조 카운트가 0이 되면 그때 삭제된다. 스레드 5가 아직 쓰고 있으면 카운트가 0이 아니니까 삭제되지 않는다. 스레드 5가 다 쓰고 Release하면 그때 0이 되고 그때 삭제된다.
안전하다.
EP.05의 참조 카운팅이 여기서 쓰이고 있었다. AddRefVar라는 이름이 EP.05에서 본 7개 중 하나다. "변수 참조". 이 스마트 포인터가 가리키는 객체를 추적하기 위한 전용 카운터였다.
shared_ptr와 뭐가 다른가
여기서 EP.05에서 남겨둔 이야기가 나온다.
shared_ptr는 카운터가 포인터 쪽에 있다. 객체는 자기가 몇 번 참조되는지 모른다.
XIOObject는 카운터가 객체 자체에 내장되어 있다. 객체가 스스로 "나를 누가 쓰고 있는지" 알고 있다.
이 차이가 여기서 중요해진다. InterlockedExchangePointer로 포인터를 바꿀 때, 객체 안에 카운터가 있으니까 포인터 교체와 참조 관리를 깔끔하게 분리할 수 있다. 포인터는 포인터대로, 참조는 참조대로.
shared_ptr로 이걸 하려면? C++20에서야 std::atomic<std::shared_ptr>이 나왔는데, 내부적으로 락을 쓸 수도 있단다. 2006년에 락-프리로 해결한 걸, 14년 뒤에야 표준이 따라잡은 셈이다.
shared_ptr은 "안전하지만 눈이 없고", XIOObject는 "위험하지만 눈이 7개". EP.05에서 느꼈던 그 차이가 여기서도 드러난다.
SI에서는 왜 필요 없었나
내가 델파이로 개발할 때는 "여러 스레드가 동시에 하나의 데이터를 읽는다"는 상황 자체가 없었다.
웹을 할 때도 마찬가지다. 요청 하나가 들어오면 그 안에서 다 처리되니까.
공유 데이터를 여러 스레드가 동시에 읽는 상황 자체가 발생하지 않았다.
근데 게임서버는 스레드 8개가 같은 설정, 같은 유저 객체, 같은 맵 정보를 동시에 읽고 있다.
그리고 가끔 바꿔야 한다. 이 "읽기는 많고 쓰기는 적은" 상황에서 락 없이 해결하는 기법이 필요했다.
지금이라면 어떻게 할까
C++11의 std::atomic<T*>::exchange()가 InterlockedExchangePointer와 똑같은 역할을 한단다. 플랫폼에 종속된 함수 대신 표준 코드를 쓸 수 있게 된 거다.
C++20에서는 std::atomic<std::shared_ptr>도 나왔다. 근데 내부적으로 락을 쓸 수 있어서 성능 확인이 필요하단다.
Project-AO의 패턴 자체는 그대로 유효하다. "새로 만들어서 포인터만 바꾸고, 이전 객체는 참조 카운팅으로 관리한다." 도구만 바뀐 것이지 발상은 똑같다.
일곱 개 기법이 하나로
여기까지 오면서 시리즈 전체가 연결되는 게 보인다.
스핀락(EP.03)이 락을 빠르게 잡아준다. 버퍼 풀(EP.04)이 락 경합을 줄여준다. 참조 카운팅(EP.05)이 객체를 안전하게 관리해준다. Write 병합(EP.06)이 보내는 비용을 줄여준다. 락-프리 포인터(EP.07)가 읽기 비용을 0으로 만들어준다.
그리고 EP.05의 참조 카운팅이 EP.07의 포인터 교체를 안전하게 만들어준다. 기법끼리 서로를 지킨다.
하나씩 따로 보면 그냥 기법이다. 같이 보면 설계다.
다음 회에서는
Part 2 "해독"이 마무리된다. 다섯 개 기법을 하나씩 들여다봤다.
마지막으로 하나 남았다. 읽기와 쓰기를 동시에 하는 방법. OVERLAPPED 분리.
→ EP.08: OVERLAPPED 분리 — 읽기와 쓰기를 동시에 하는 방법