Today I Learn

[TIL][C++] 250422 MMO 서버 개발 2일차: 서버 설계에 대한 고민사항, 멀티 쓰레드 환경에서의 쓰레드 배치에 대한 고찰, 모든 쓰레드들이 균등하게 JobQueue를 실행하도록 설계하기 + 예약된 Job

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

 

멀티 쓰레드 환경에서의 쓰레드 배치에 대한 고찰

쓰레드를 어떻게 배치해야 효율적인가?

서버 프로그램을 제작한다고 했을때, 아래 4가지 역할을 맡는 쓰레드가 있을 것이다.

  • 리스너: 새로운 유저의 연결(Connect)를 담당
  • 패킷 처리: 네트워크를 통해 온 패킷을 받아 역직렬화
  • 인게임 로직 처리: 역직렬화된 데이터를 토대로 게임 컨텐츠 코드를 실행
  • DB: 데이터 베이스(DB)와 통신

하나의 서버 프로그램에서는 쓰레드를 30개 정도 배치할 정도로 쓰레드를 많이 배치하는데, 위 4가지 역할을 맡는 쓰레드의 비율이 어떻게 되어야하는지가 중요하다. CPU 부하를 많이 주는 작업은 1) 패킷 처리와 2) 인게임 로직 처리로, 이 둘 중에서도 인게임 로직 처리가 훨씬 CPU 부하를 많이 준다.

따라서, 인게임 로직 처리를 하는 쓰레드들을 가장 많이 배치해야 할 것이다.

하나의 엄청 큰 맵(seamless game)이라면 서버를 어떻게 운영해야할까?

포탈을 타고 맵을 이동하는 상황처럼, 각 맵이 상호작용하지 않는 독립적인 작은 공간이라면 하나의 쓰레드가 하나의 맵에 대한 처리를 해도 부담이 되지 않을 것이다.

그런데 오픈 월드의 유형처럼 엄ㅁㅁㅁㅁㅁㅁ청 큰 맵이 존재하고, 이 맵에 돌아다니는 엔티티나 플레이어가 엄청 많다면 하나의 쓰레드가 이 맵에서 이루어지는 모든 이벤트를 제때 처리할 수는 없을 것이다.

결국 여러 쓰레드가 붙어서 이 엄ㅁㅁㅁ청 큰 맵의 이벤트를 처리해야하는데, 처리하는 방식은 굉장히 다양하다.

가장 편한(?) 방식으로는 맵을 적당한 크기의 지역으로 쪼갠 다음, 각 지역을 하나의 쓰레드가 맡는 것이다. 이 방식의 경우, 기본적으로 하나의 쓰레드가 처리했을때 부담이 되지 않는다는 장점이 있지만, 지역을 이탈하여 다른 지역으로 넘어갈때 추가 작업들이 발생하면서 약간의 렉이 걸린다는 단점이 있다.

다른 방식으로는 엑터 단위로 jobqueue를 내장시키고 각 쓰레드가 jobqueue들을 처리하는 방식인데, 이 경우 액터간의 상호작용이 발생했을때 각 엑터가 서로 다른 쓰레드가 담당하던 엑터인 경우, 쓰레드간 통신이 추가적으로 필요하다는 문제 + 동기화 문제가 있어서 굉장히 어렵다고 한다.

 


모든 쓰레드들이 균등하게 JobQueue를 실행하도록 설계하기 + 예약된 Job

현재 상황에서의 문제점

  • JobQueue에 Job을 push했을때, 아무도 JobQueue의 작업을 실행하지 않는다면, 현재 쓰레드가 해당 JobQueue에 대한 실행을 맡게되는데,
  • JobQueue의 실행이 다른 JobQueue의 실행으로 연결되는 순간, 하나의 쓰레드가 여러 JobQueue에 대한 실행 역할을 맡게되면서 독박을 쓰게된다.

해결방법

  • 하나의 쓰레드는 하나의 JobQueue만 담당한다.
  • Job을 밀어넣는 JobQueue를 누군가 처리하고 있지 않은 상태더라도, 현재 쓰레드가 이미 당담하는 JobQueue가 있다면 해당 JobQueue를 당담하지 않는다.

이 경우 작업이 있음에도 실행되지 않은 JobQueue가 생긴다. 이를 누가 어떻게 꺼내가야하는가?

  • 각 JobQueue의 레퍼런스를 저장하는 GlobalJobQueue를 만들어서 여유있는 쓰레드가 JobQueue를 꺼내가서 실행하도록 한다.
  • 전체 과정: 네트워크 처리 → JobQueue를 GlobalQueue에서 꺼내감. → 여유가 될때까지 반복 → timeout이 되거나 더이상 일이 없으면, 처음부터 반복

현재까지의 정책

인게임 로직 producer

  • 현재 쓰레드가 실행 중인 jobQueue가 없다면 jobQueue를 실행한다.(consumer 역할)
  • 현재 쓰레드가 실행 중인 jobQueue가 있다면, GlobalQueue에 jobQueue를 등록한다.

네트워크 입출력 처리 consumer

  • 인게임 로직 producer
  • CP가 비어있으면, dispatch를 빠져나온다.
    • 패킷을 받지 않더라도, 주기적으로 상태를 업테이트 해야하는 데이터가 있다(ROOM 자체의 로직. ex. 몬스터, 투사체 등등…)
    • 따라서, 패킷 처리되 되지 않아 CP가 비어있어도, 쓰레드는 Room과 같은 객체의 상태 업테이트가 필요하므로, 백날천날 패킷이 올때까지 재우면 안된다.

일감이 너무 밀리게 되면 빠져나와야한다.

  • 하나의 쓰레드가 특정 JobQueue만 계속 죽어라 패고 있으면, 다른 JobQueue가 쓰레드 점유권을 가지기 어려워지기 때문에, 정해진 시간을 넘어가면 빠져나와야한다. ⇒ 균등하게 처리하기 위한 방법으로 글로벌 큐로 일을 넘기는 방법이 있다.

GlobalQueue의 역할

  • Dispatch에서 미처 처리하지 못한 결과물을 처리하기 위해 재등록
  • 인게임과 관련된 부분들을 실행

JobTimer

  • 지금 당장 실행해야하는 Job이 아니라, 일정 시간 뒤에 처리해야하는 Job이라면 이를 어떻게 처리해야할까?
  • while을 뺑뺑 돌면서 시간이 되었을때 Job을 처리하게 된다면, CPU 낭비가 매우 심할 것이다.
  • 따라서, Job을 실행할 시간을 우선순위 큐에 집어넣고, 여유있는 쓰레드가 해당 우선순위 큐의 Job들을 하나씩 빼서 JobQueue에 집어넣어준다.
    • 당연하게도 해당 우선순위 큐는 전역으로 존재한다.

예약 시간이 된 Job들을 뿌리는 역할을 하나의 쓰레드가 독박쓰는게 옳은가?

  • 이 부분은 잘 모르겠다…

왜 우선순위 큐를 한 쓰레드만 통과시키도록 했는가

글로벌로 밀어넣고 여러 쓰레드가 실행했다면, 순서가 어긋나 앞서 실행되는 경우가 발생할 수 있다

락을 잡았지만, 그 뒤에는 락을 안 걸어놨기 때문에 락이 풀린뒤에서 렉이 걸리면 정말 극악의 확률로 일감 순서가 꼬일 수 있다.

  • 한 번에 한 쓰레드만 통과할 수 있도록 코드를 추가했다고 함.