[C++20 모듈과 IOCP로 완성하는 MMORPG 서버] 3. Reader-Writer Lock

MIN·2025년 8월 25일
0

C++20 & IOCP

목록 보기
3/5

깃허브 주소 : CPP20_GameServer

이번에는 Reader-Writer Lock 모듈을 만들어볼 차례이다.
이 모듈은 원자적 CAS에 기반한 스핀락 방식으로 구현할 생각이다.

일반 mutex로도 쉽게 만들 수 있지만, 짧은 보유 시간 구간에서 스핀락의 장점을 보여줄 예정이다. 물론 보유 시간이 길어질수록 스핀은 낭비가 커지므로 상황에 따라 mutex가 더 유리할 수 있기에, 용도에 맞게 유연하게 선택하는 태도가 중요하다.

Lock 모듈 인터페이스 파일

구현에 앞서 락의 상태를 표현하기 위한 enum을 정의하고 각 비트의 의미를 부여했다.

분석 결과

Lock.ixx 파일

ACQUIRE_TIMEOUT_TICK은 일정 시간 동안 락을 획득하지 못했을 때 진단을 위한 한계치로 쓰이며, 디버그 환경에서 크래시를 유발하거나 로깅을 트리거하는 용도로 사용하면 좋다.
MAX_SPIN_COUNT는 스핀으로 바쁘게 대기하는 최대 횟수를 뜻하며, CPU 낭비를 막기 위해 적절한 백오프와 함께 사용하면 효과적이다.
WRITE_THREAD_MASK와 READ_COUNT_MASK는 각각 상위 비트로 writer 보유 스레드의 ID를, 하위 비트로 reader의 개수를 표현하는 데 사용한다.
이 비트 레이아웃은 한 개의 원자 변수로 상태를 관리할 수 있어 캐시 친화적이라는 장점이 있다.

핵심 상태 변수로는 _lockFlag와 _writeCount를 사용하고, Write와 Read 계열 함수는 이들을 통해 락의 소유와 공유 상태를 판단한다.

이번에도 C++20을 사용해서 atomic 정수형 원자에 memory_order_acquire/release/acq_rel을 명시해 일관된 메모리 가시성을 확보할 것이다.

Lock 구현 파일

WriteLock

WriteLock은 어떤 스레드도 소유하지도 공유하지도 않는 상태에서 경합하여 소유권을 얻는 함수이다.

예시 이미지

WriteLock(1)

예시 이미지

WriteLock(2)


우선 _lockFlag의 상위 비트를 확인해 현재 스레드가 이미 writer인지 검사한다.
재진입 가능한 설계라면 동일 스레드가 다시 WriteLock을 요청했을 때 _writeCount만 증가시키고 즉시 반환하도록 처리한다.

새로 획득해야 하는 경우에는 데드라인을 계산해 과도한 스핀이 길어지지 않도록 한다.
그리고 기대값 expected를 읽고, 현재 상태가 “빈 상태”인지 점검한다.
들어갈 수 있다면 상위 비트에 “현재 스레드 ID를 인코딩한 값”을 얹은 desired를 만들고 CAS를 시도한다.

스핀락을 돌다가 안되면 스레드를 wait로 잠깐 재우는 방식을 채택했다.
이렇게 기다리는 스레드는 나중에 나올 WriteUnlock 함수나 ReadUnlock 함수를 통해서 스레드를 깨우면 된다.

WriteUnlock

예시 이미지

WriteUnlock


여기서 중요한 사실이 하나 있는데, WriteLock을 다 풀기 전에 ReadUnlock은 가능하지만, 반대로 ReadLock을 다 풀기 전에는 WriteUnlock은 불가능하게 설계했다.
이 부분만 참고해서 보면 된다.

그래서 _lockFlag의 값을 가져왔을 때 Read한 흔적이 없어야되고 lockCount가 0이라면 Write할 수 있는 환경을 만들어주기 위해서 _lockFlag에 Empty값을 넣어준다.
그리고 아까 재웠던 스레드를 깨워주기 위해서 notify_all을 사용하면 된다.

ReadLock

ReadLock은 아무도 소유하고 있지 않을 때 공유 카운트를 높이는 함수이다.

예시 이미지

ReadLock(1)

예시 이미지

ReadLock(2)


먼저 현재 스레드가 writer를 보유 중인지 확인하고, 보유 중이라면 read 재진입을 허용하는 정책이라면 _lockFlag의 하위 비트를 1 증가시키면 된다.

나머지 코드는 WriteLock하고 비슷하지만 다른 부분을 한번 보자.

일반 경로에서는 _lockFlag를 읽어 상위 비트에 writer가 있는지 조사한다.
writer가 있으면 진입을 금지하고 적절히 대기하며, writer가 없으면 하위 비트에서 reader 수를 꺼내 expected를 만들고, expected + 1을 desired로 CAS를 시도한다.

나머지는 WriteLock과 같기 때문에 생략하겠다.

ReadUnlock

예시 이미지

ReadUnlock

ReadUnlock은 하위 비트의 reader 수를 1 감소시키고, 마지막 reader가 나갈 때 대기 중인 스레드를 깨운다.
여기서 깨알로 C++20에 나오는 unlikely는 저 코드는 흔하게 나오는 코드가 아니라는 힌트를 던져줘서 최적화에 도움을 주는 코드이다.

LockGuard

RAII를 통해 락의 생애를 자동으로 관리하는 LockGuard는 필수이다.

예시 이미지

LockGuard

Lock 모듈 인터페이스 파일에 구현한 코드이다.

Read용과 Write용 가드를 분리해 생성자에서 Lock()을, 소멸자에서 Unlock()을 호출하게 하면 예외 경로에서도 안정적으로 락이 해제된다.

이 객체를 사용해서 우리가 Reader-Writer 기능을 사용하고 싶을때 간단하게 매크로를 선언하면 사용할 수 있게 만들 것이다.

사실 이부분에서 우리가 C++20의 기능을 사용하면서 모듈에 최적화하게 만들고 싶었지만 아직 그런 기능을 제공해주지 않기도 하고, 아직 매크로가 현업에서도 사용되기 때문에 이번에도 매크로 기능을 사용할 예정이다.

예시 이미지

CoreMacro.h 파일

이렇게 매크로를 선언해서 사용자가 Reader-Writer를 사용하고싶다면 USE_LOCK 매크로만 선언하면 된다.

예시 이미지

GameServer 구현파일

이렇게 GameServer에서 TestLock을 만들어서 사용 예시처럼 사용하면 된다.
반드시 USE_LOCK을 먼저 선언하고, Read하고 싶은 부분에는 READ_LOCK을 쓰면 되고, Write하고 싶은 부분에는 WRITE_LOCK을 사용하면 된다.

결과

예시 이미지

GameServer 구현파일

이렇게 테스트 코드를 만들고 사용하면 다음과 같은 결과가 나온다.

예시 이미지

실행 결과

아주 잘 작동하는 걸 볼 수 있다.
이제 이 기능을 사용해서 Lock 관련 일을 처리하면 된다.

참고

Inflearn [Rookiss][C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버

profile
게임개발자(진)

0개의 댓글