나만의 작은 도서관

[TIL][C++] 250527 MMO 서버 개발 26일차: shared_ptr은 thread-safe한가?, atomic<shared_ptr<T>> (C++20~) 등등… 본문

Today I Learn

[TIL][C++] 250527 MMO 서버 개발 26일차: shared_ptr은 thread-safe한가?, atomic<shared_ptr<T>> (C++20~) 등등…

pledge24 2025. 5. 28. 14:02
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다. 

shared_ptr은 thread-safe 한가?

  • C++에서는 자원(동적 할당한 메모리)의 할당 및 해제를 알맞은 정책으로 관리하기 위해 포인터를 래핑 한 클래스, 즉, 스마트 포인터를 사용한다.
  • 스마트 포인터는 자원의 생명 주기를 관리해 주기 때문에 몇 가지 조심하기만 한다면(ex. 순환 참조 문제) 자원을 사용하는 데 있어 모든 게 해결된 것만 같다. 그렇다면 멀티 스레드 환경에서도 스마트 포인터는 안전할까?

일부만 thread-safe 한 스마트 포인터

  • 스마트 포인터(이하 shared_ptr이라고 부름)는 자원을 가리키는 포인터와 refCount로 구성되어 있다(엄밀히 따지면 아니긴 하지만).
  • 스마트 포인터는 refCount를 atomic 연산으로 처리하기 때문에 refCount에 대한 멀티스레드 이슈는 발생하지 않는다. 즉, 여러 스레드가 접근하여 refCount가 동시에 0이 되어도 정확히 한 번만 0이 되므로, 더블 프리가 발생하지는 않는다는 것이다.
  • 그렇다면 자원을 가리키는 포인터에 대한 접근은 어떨까? 아쉽게도, 포인터 값의 읽기/쓰기 작업을 하는 것은 thread-safe 하지 않다. 즉, 여러 스레드가 동시에 같은 shared_ptr 객체를 사용하는 상황에서, 어느 스레드가 포인터를 교체해 버린다면 race condition이 발생할 수 있다.
// 위험한 코드
shared_ptr<int> global_ptr = make_shared<int>(42);

// Thread 1
global_ptr = make_shared<int>(100);  // 새로운 객체로 교체

// Thread 2  
auto local_ptr = global_ptr;  // 데이터 레이스 가능성

포인터 교체에 thread-safe 하지 않는다면, 어떻게 해야 할까?

  • 크게 두 가지 방법이 있다. 첫 번째는 늘 그랬듯 락을 잡아 해결하는 것이다. 락을 잡아 해당 shared_ptr에 접근하여 포인터를 읽거나(포인터 복사), 쓰거나(대입하여 포인터 교체)하는 작업은 한 번에 한 스레드만 할 수 있도록 한다.
shared_ptr<int> global_ptr = make_shared<int>(42);
USE_GLOBAL_PTR_LOCK

// Thread 1
WRITE_GLOBAL_PTR_LOCK;
global_ptr = make_shared<int>(100);  // 새로운 객체로 교체

// Thread 2  
WRITE_GLOBAL_PTR_LOCK;
auto local_ptr = global_ptr;  // 데이터 레이스 가능성

atomic <shared_ptr <T>> (C++20~)

  • 두 번째는 atomic 객체로 shared_ptr객체를 래핑 하는 것으로, shared_ptr에 대한 모든 연산을 원자적으로 처리한다.
atomic<shared_ptr<int>> atomic_ptr = make_shared<int>(42);

// Thread 1
atomic_ptr.store(make_shared<int>(100));  // 안전한 교체

// Thread 2
auto local_ptr = atomic_ptr.load();  // 안전한 읽기
  • 위처럼 atomic <shared_ptr>>을 사용하면 포인터 자체의 load/store 연산이 원자적으로 처리되어, 자원을 가리키는 포인터 자체가 교체되더라도 thread-safe 하게 사용할 수 있다.

예시

class Player
{
public:
//	weak_ptr<Room> room; ?
	atomic<weak_ptr<Room>> room;
}
  • 게임 서버에서 게임에 존재하는 Player에 대해 현재 Player가 속한 방이 어디인지 알아야 할 때가 있다. 그래서 Player 클래스에는 속한 방의 참조 값을 들고 있게 되는데, 이때 보통 스마트 포인터로 들고 있게 된다.
  • 여기서는 순환 참조 문제를 해결하기 위해, shared_ptr 대신, weak_ptr을 들고 있다.
  • 플레이어는 게임을 진행하며, 여러 필드로 이동할 수 있을 것이다. 즉, 게임이 진행됨에 따라 속한 방이 달라질 수 있다는 것인데, 이럴 때마다 참조하고 있던 room의 포인터 값을 교체해야 할 것이다.
  • 즉, room에 대한 weak_ptr의 포인터 값의 교체가 발생하고, 이는 non-thread-safe 하므로, 위처럼 atomic 객체로 래핑 하여 원자적 연산을 수행해야 한다.

요약

  • shared_ptr이 관리하는 refCount는 thread-safe 하지만, 자원을 가리키는 포인터 값에 대해선 thread-safe 하지 않다.
  • 따라서, 포인터 값 교체가 발생한 shared_ptr 인스턴스에 대해 여러 스레드가 접근하는 상황이라면(멀티 스레드 환경) 경쟁 상태가 발생하며, 이를 해결하기 위해 1) 락을 걸거나, 2) atomic객체로 shared_ptr을 래핑 한다.

결론

  • 멀티 스레드 환경 + 포인터를 교체할 일이 있음 ⇒ atomic으로 래핑
  • 포인터 교체할 일 없음 ⇒ atomic 래핑 안 해도 됨


weak_ptr에 shared_from_this()를 넣어도 되는가?

  • 안될 건 없다. 하지만, 효율적이지 않고, 명확하지도 않다.
class MyClass : public enable_shared_from_this<MyClass> {
public:
    void someMethod() {
        // shared_ptr을 먼저 얻고 weak_ptr로 변환
        weak_ptr<MyClass> weak = shared_from_this();
        // 또는
        weak_ptr<MyClass> weak2(shared_from_this());
    }
};
  • C++17부터 weak_from_this()라는 함수가 따로 생겼기 때문에 17 아래 버전을 사용하는 게 아니라면, 굳이 굳이 shared_from_this를 사용해서 성능 손해를 볼 필요는 없어 보인다.
class MyClass : public enable_shared_from_this<MyClass> {
public:
    void someMethod() {
        // 직접 weak_ptr 반환
        weak_ptr<MyClass> weak = weak_from_this();
    }
};

언리얼 에디터의 Netmode 세 가지(5.3 기준)

Play StandAlone

  • Netmode: NM_Standalone
  • 네트워크 기능이 완전히 비활성화되고, 각 인스턴스가 순수한 싱글 플레이어 모드로 실행된다.
  • 별도의 네트워크 연결 코드가 있으면 실행되기 때문에 간단한 서버 연결 테스트에도 사용할 수 있다.

Play as Listen Server

  • Netmode: NM_ListenServer
  • 에디터가 서버 역할을 하면서 동시에 플레이어로 참여한다.
  • 다른 클라이언트들이 접속할 수 있도록 포트를 열어둔다.
  • 멀티플레이어 기능을 혼자서 테스트할 때 유용하며, 서버 권한과 클라이언트 동작을 동시에 확인할 수 있다.

Play as Client

  • Netmode: NM_Client
  • 순수한 클라이언트로만 실행되며, 별도의 서버에 연결을 시도한다.
  • 연결을 시도하는 서버의 기본값은 127.0.0.1(localhost)이다.
  • 서버가 먼저 실행되어 있어야 정상적으로 작동한다.

정리

Play StandAlone ⇒ 순수한 싱글 플레이어 모드

Play as Listen Server ⇒ 현재 에디터가 서버 역할을 추가로 한다.

Play as Client ⇒ 별도의 서버에 연결을 시도(기본값 127.0.0.1)


UPROPERTY(EditAnywhere) 주요 용도

  • 에디터 노출: 해당 프로퍼티가 언리얼 에디터의 디테일 패널에 표시되어 수정 가능 ⇒ 언리얼 디테일 = 유니티 인스펙터?
  • 블루프린트 접근: 블루프린트에서 해당 변수에 접근이 가능해진다.
  • 직렬화: 에디터에서 설정한 값들이 저장되고 로드될 때 자동으로 직렬화된다.

다른 옵션들

EditDefaultsOnly

  • 클래스 기본값(블루프린트 에디터)에서만 편집 가능, 인스턴스별 편집 불가

EditInstanceOnly

  • 레벨에 배치된 개별 인스턴스에서만 편집 가능, 클래스 기본값 편집 불가

EditAnywhere

  • 클래스 기본값과 인스턴스 모두에서 편집 가능

사용했던 경험? GameInstance를 BP로 만들어 쓰기

  • GameInstance는 C++ class로 만들어져 있었고, 이를 블루프린트로 들고 와 캐릭터를 대신하는 게임 오브젝트를 Instantiate 하고 싶었다. 이때, 인스턴스화할 게임 오브젝트를 따로 BP로 만들고, GameInstance에 BP를 넣을 변수를 아래와 같이 설정하니 디테일 패널에 표시되어 넣을 수 있게 되었다.
class P1_API UP1GameInstance : public UGameInstance
{
...
public:
	UPROPERTY(EditAnywhere)
	TSubclassOf<AActor> PlayerClass; // 디테일 패널!

	TMap<uint64, AActor*> Players;
}