Thread Design2

2022. 2. 4. 23:44게임서버/멀티쓰레드 이론

320x100

안녕하세요 대학생 개발자입니다.

 

저번 글에서는 쓰레드 디자인에 대해서 좀 정리를 해봤었습니다.

 

우선 이전 글에서는 이런 내용들이 있었습니다.

베이스는 무조건 멀티스레드 IOCP로 송수신 처리를 하는 겁니다.

IO처리(비동기 recv겠죠)가 완료된 후에는 일거리(잡)들을 업데이트 서버의 큐에 넣어주는 방식이었습니다.

그럼 업데이트를 하는 서버는 큐에서 하나씩 꺼내서 사용하는 방식으로 작동했었는데요.

여기서 서버 월드를 돌리는 메인루프 스레드와 메시지 처리부 스레드가 하나였기 때문에 싱글스레드 서버라고 했었습니다.

 

그리고 만약 서버에 부하가 큰 로직(길찾기, 물리엔진, AI등)을 처리할땐 그 쓰레드를 분리한다고 했습니다. 

추가로 그 쓰레드는 메인 루프를 처리하는 쓰레드에게 최소한의 부하를 주기 위해서 조력하는 쓰레드로써 업무를 수행합니다.

그리고 이 분할된 스레드는 공유자원에는 접근하지 않는다고도 말씀드렸습니다.

분할된 스레드가 공유 자원에서 경합을 벌이는 순간 io스레드간 경합을 막기위해서 싱글스레드를 이용하는 목적이 사라지기 때문입니다.

그러므로 조력 스레드들은 메인 스레드를 보조하는 역할로써 업무가 끝나고 나면 그 업무에 해당하는 결과를 또 메인 스레드의 큐에 다시 큐잉을 해서 결과를 알리는 식으로 작동을 하게 될 겁니다.

다음과 같은 그림으로 작동하게 되겠죠.

io작업이 끝나는 즉시 io스레드는 main loop를 처리하는 Update 스레드에 JobQ에 작업을 포스팅 할 것 입니다.

Update스레드는 JobQ에 있는 메시지를 처리하기 위해서 JobQ를 폴링할것입니다.

그리고 작업이 있다면 그 작업을 수행후 Frame Loop를 실행하도록 하겠죠.

그리고 그와 동시에 부하가 큰 작업(위 그림에서는 Path find입니다)을 비동기로 실행 요청을 할 것입니다.

작업이 끝난 길찾기는 결과값을 메인 스레드에게 알려주기 위해서 다시 큐잉을 합니다.

업데이트 스레드에서는 큐를 폴링하다 작업이 완료된 것이 있으면 바로 반영해버립니다.

 

분명 메인스레드의 업무는 줄었습니다.

하지만 cpu의 관점에서 본다면 일은 더 늘어났습니다.

왜냐면 길찾기를 하는데 100의 시간이 걸린다고 한다면 원래 메인스레드에서 실행한다면 100이 걸릴 작업이었습니다만.

PathFind에서 하기위해서 두번의 큐잉과 디큐잉 과정을 거쳐야 하기 때문입니다.

다음과 같은 손해를 안고 가는것입니다.

그러므로 우리는 결정할 때 신중하게 해야될 부분이 있습니다.

 

바로 스레드 분리를 통해서 메인 스레드의 효율성이 더 증가했느냐 입니다.

그리고 여기서 효율성이라는 것은 당연히 동일시간 대비 루프 처리 횟수가 되겠구요.

만약 메인 스레드의 효율성 업그레이드가 보장이 되지 않는 상황에서 굳이 스레드를 뺀다면,

메인 스레드는 딱히 효율을 얻지도 못하는 와중에 Enqueue, Dequeue연산과 그에대한 동기화 처리까지 해줘야 하는 좋지 않은 상황이 발생할 수 있습니다.

 

일단은 여기까지 봐도 썩 그렇게 불만족스러운 그림은 아닙니다.

하지만 현재 우리 서버급 cpu들은 기본으로 코어가 20개 30개 이렇습니다.

이런 서버 구조는 하드웨어 가용률을 현저히 떨어뜨리는 효과를 내고 있다고 생각합니다.

 

이유는 스레드는 하나인데 코어가 너무 많기 때문입니다.

물론 코어를 모조리 100% 써버리는건 당연히 좋은 일은 아닐겁니다.

하지만 너무 노는 코어가 많다는것도 그렇게 개발자 입장에서 아주 만족스러운 상황은 아닐거라고 생각합니다.

 

 

자 그럼 여기서 고민을 해봐야할 부분이 있습니다.

메인 업데이트를 돌리는 스레드를 싱글이 아닌 멀티스레드로 뽑아내면 어떨까입니다.

 

우선은 이전과 동일한 구조입니다.

IO를 마치고나면 하나의 JobQ에다가 모든 데이터를 집어넣을겁니다.

그리고 각 업데이트를 담당하는 스레드들은 JobQ를 제외한 경합을 피하기 위해 Sector로 나누었던것과 같이,

하나의 맵단위로 나눠서 그 맵안에 있는 유저들만의 작업만을 해주게 될것입니다.

그리고 각 스레드당 일부 맵의 파트를 나눠서 작업을 하게 될것입니다.

어느 게임이나 마찬가지로 맵이나 던전의 개념이 있듯이 동일 로직을 수행하는 던전의 스케일 아웃인겁니다.

물론 이경우에는 스레드가 다 동일한 일을하고 공유자원에대한 키가 스케일 아웃이 된거겠죠...

말이좀 이상합니다. 하지만 대충 알아들으세요.

그리고 월드의 프레임을 돌려주는 스레드는 당연히 하나가 따로 나와야할 것입니다.

프레임 스레드는 두개이상이란것 자체가 말이 안되기 때문이죠.

스레드는 모두 JobQ를 바라보고 있다가 JobQ에 일거리가 포스팅이 되면 누구든 먼저 Lock을 걸고 가져가려고 할 것입니다.

그리고 UpdateThread1이 처리해야 할 작업을 가져왔다면, Map1에 대한 CriticalSection키를 들고 공유자원에 접근을 할것입니다.

동시에 UpdateThread2가 아주 조금더 Map1에 있는 플레이어에 대해 접근을 해야되는 작업을 가져왔다고한다면, 블로킹에 걸려버리게 되겠습니다.

 

뭐 이까지는 문제가 없어보입니다.

 

하지만, 지금의 케이스의 경우에는 아주 크리티컬한 에러가 존재합니다.

이유에 대해서 말씀드리기 전에 고민해보셔야 될 부분이 있습니다.

CRITICAL_SECTION g_Cs;

unsigned __stdcall Thread(void * param)
{
    int idx = *(int*)param;
    EnterCriticalSection(&g_Cs);
    printf("hello multithread~ current idx : %d\n", id);
    LeaveCriticalSection(&g_Cs);
}



_beginthread(...,Thread,...); //< idx에 1들어감
_beginthread(...,Thread,...); //< idx에 2들어감

만약 이경우 두 스레드가 동시에 아주 약간의 찰나(나노세컨드 단위의 시간차)를 두고 실행이 된다고 가정하겠습니다.

만약 idx1번 스레드가 EnterCriticalSection에 진입을 먼저 성공했다면 idx에 1이 찍힌 코드가 먼저 실행이 될까요?

단 1나노 세컨드 차이로 idx2번 스레드가 진입을 하게된다면 idx는 1이 찍힌걸까요?

 

정답은 알수없다 입니다.

EnterCriticalSection의 내부 구현은 예전에 따로 정리를 했었기 때문에 따로 설명을 드리진 않겠습니다.

어찌됬든 중요한것은 내부의 인터락을 했을 때 본인이 그 값을 변경해야지 획득을 성공한 것으로 간주하고 넘어갑니다.

사실 그전에 퀀텀타임을 모두 다써서 컨텍스트 스위칭이 유도 되었다면 Thread2가 먼저 출력이 될 수도 있는겁니다.

 

즉 이런 상황을 저위의 스레드 디자인과 동일 선상에서 놓고 본다면 이런 경우가 발생할 수 있을 것 같습니다.

1. JobQ에 동일한 세션의 인풋이 3개 연속 도착한다.

2. 각각 스레드에서 하나씩 뽑아서 사용한다.

3. 이경우 어떤 스레드가 먼저 Map CriticalSection을 우선 점거하게 될 것인지 알 수 없다.

 

이런 문제점이 발생하고 말게 됩니다.

사실 이게 왜 문제인지 조차도 이해 못하시는 분들도 간혹 계십니다.

 

다시한번 정리해 드릴테니 찬찬히 생각해보시고 다음 문장으로 넘어가시길 바라겠습니다.

클라이언트가 인풋을 하는 순간부터 하나씩 설명해 드리겠습니다.

 

1. 클라이언트는 a,b,c를 입력 각각 스킬1,2,3 이라고 가정하겠습니다.

2. TCP를 타고오기 때문에 순서보장은 완벽하게 되어서 a,b,c가 도착할겁니다.

3. 링버퍼에서 꺼냈을때도 a,b,c가 보장된 상태로 왔을겁니다.

4. 그런데 JobQ에 포스팅을 하는 순간 틀어지게 되는겁니다.

 

자 이제 문제점이 보이시나요?

우리는 a,b,c 순서를 무조건 맞춰줄 의무가 있다는 겁니다.

왜냐면 클라이언트는 a,b,c를 입력했는데 우리는 c,a,b 이런순서로 하게 된다면 순서가 틀어지게 되고,

그것이 만약 콤보 스킬이거나 뭐 다른 주요 로직과 겹치게 되는 상황이라면 이 현상때문에 문제가 될 수 있기 때문입니다.

 

즉... 지금과 같은 상황에서는 이 스레드 설계는 실패라는 겁니다.

그럼 우리는 이 문제점을 해결하기 위해서 각 스레드별로 JobQ를 놓을 겁니다.

그리고 Session에 대한 점유도 확실하게 해야겠죠.

1번 스레드로 가는 세션은 항상 1번으로만 가야될겁니다.

2번 스레드로 가는 세션이 1번 스레드로 가게되면 아까와 같은 문제가 발생할 수 있으니까요.

 

 

음... 근데 이런 의문이 드실수도 있습니다.

IO Thread또한 큐에 집어넣는 역할을 하는데 이때 순서가 뒤집어 지지 않느냐 라고 하는겁니다.

하지만 하나의 IOThread에서 나온 정보들은 동일한 세션을 대상으로한 데이터들이고 여러 스레드가 뱉어내는 정보는 여러 세션일 겁니다.

세션과 세션간의 순서 보장은 애초에 L1~L4까지도 보장해주지 못합니다.

왜냐면 실질적으로 먼저 누른 클라이언트가 물리적으로 더 멀리있기 때문에 느리게 서버에 도달할 수 있습니다.

이경우 서버는 먼저누른 클라이언트를 누구로 판단해야되는지 알수 없기때문에, 그냥 서버에 도달한 순서대로 큐잉을 할겁니다.

그리고 순서라는것은 존재하지 않습니다.

지금 5번 세션이 데이터를 보냈지만 지금 반복을 돌면서 6번 세션을 진행중인 상황이라면 5번 세션은 가장 마지막에 처리하는겁니다.

조금더 늦게 데이터가 도착한 7번 세션이 먼저 처리가 되게 되는겁니다.

즉... IO상황에서 순서보장은 불가능하다는겁니다.

 

하지만 컨텐츠적인 순서보장은 반드시 지켜져야할겁니다.

 

 

그럼 위과 같은 그림으로 설계를 해볼 수 있을 것 같습니다.

각각의 UpdateThread들은 JobQ를 가지고있습니다.

각 세션들은 해시 혹은 범위로 묶어서 등 이러한 규칙을 가지고 각각의 스레드에 배정이 될 것입니다.

그 후 배정된 세션의 작업이 UpdateThread에 도착한다면 그 세션의 맵을 기반으로 Map CriticalSection을 들고 동기화 작업을 걸고 공유자원에 접근을 시도할 것입니다.

 

자, 그럼 여기까지 했을때 문제점이 보이시나요?

제가 봤을땐 큰 문제점은 없어 보입니다.

하지만 우리가 예전에 다뤘었던 IOThread가 UpdateThread가 하는 일을 해버리는 구조면서 동시에 Map당 CriticalSection이 하나씩 존재하는 구조와 지금의 상황을 비교해본다면 조금 고민거리가 생기실겁니다.

 

아니 그냥 IO처리한 스레드에서 바로 Map에 있는 CriticalSection을 획득해서 처리해 버리는것이 더 효율적이지 않냐는 겁니다.

왜냐면 루틴은 동일합니다만, 세션맵과 JobQ에 대한 필요하지 않은 동기화 작업이 들어갔기 때문입니다.

 

물론 혹자는 이렇게 말씀하실수도 있습니다.

IO와 Update를 분리하면서 Update의 부하로 인한 IO의 처리속도 저하와의 관계성을 끊지 않았냐고 말씀하실수도 있습니다.

필자는 그말에는 일부 공감합니다.

공감합니다만 하지만 차라리 방금 위에서 말씀드린 그 구조가 더 효율적으로 보인다는 것이죠...

저에게 이 지식을 주셨던 분은 이해도 불가능하다는 스탠스를 취하셨지만요 ㅋㅋㅋ...

 

 

이렇게 생각한 이유에 대해서 서술하겠습니다.

 

우선 아까 말씀드린 UpdateThread의 JobQ에 동기화를 넣는것과 SessionMap에 동기화를 넣는것 자체가 overhead가 될수있다는게 첫번째 의견이었습니다.

 

두번째로는 아까 IO와 Update를 분리하는것에 의의를 두셨던 분들에게 반박하는 글이 될수도 있겠습니다.

어차피 Update가 느리면 IO가 빨리처리되어도 작업이 처리되지 않습니다.

작업이 처리가 되지 않는 와중에 해야할 것들만 늘어나게 되는 그런 꼴이 되어버립니다.

사실 그렇게되면 IO가 빠른것의 의미가 없어진다는 의미입니다.

Update가 처리되지 않으면 IO완료에 대한 작업이 처리가 되지 않기 때문에 IO는 절대적으로 Update와 함께 가야되는 스레드로써 역할을 다해야된다는 것이 제 의견입니다.

즉 다음과 같은 모양이 되는겁니다.

만들기가 참 쉬워진것처럼 보입니다...ㅋㅋㅋㅋㅋ

이때까지 그린게 허무할 정도로 간단해보여서 화가날정도입니다.

 

여기서 Map의 갯수가 늘어나게 된다면 IO Thread들 끼리 경합하는 횟수는 현저히 줄어들게 될겁니다.

만약 맵이 100개고 코어가 10개라면 확률은 매번 IO가 마친뒤 스레드가 작업할 때 최악의경우 9%, 최상의 경우1%확률로 경합에 걸리게 될겁니다.

이정도만되도 아주 성공적이라고 봐도 될 것 같습니다.

쓸데없는? 동기화도 필요 없어지게 되고말이죠.

동시에 IO쓰레드의 스케일 아웃도 획기적으로 늘릴 수 있습니다.

이말은 코어를 100% 다 사용가능하다는 의미가 되겠죠.

물론 당연한거겠지만 이와 동시에 Frame을 돌려주는 메인 루프하나는 따로 스레드가 존재해야될 것입니다.(그부분은 생략하고 안그렸습니다)

 

그리고 방금 그린 모델이 제가 두번째로 만들어낼 서버의 모습입니다.

언제 만들게될진 모르겠지만...

최선을 다해서 만들어봐야죠...

 

흠흠..

오늘은 이론도 아니고 코드와 관련된 것도 아니었습니다.

스레드 설계에 관한 부분이니까요...

근데 사실 스레드를 마냥 많이 만드는것을 멋있다고 생각하시는 분들이 생각보다 많이있습니다.

물론 저도 이 정보를 겟하기 전까지는 다양한 스레드가 많이 뽑힌다는게 더 만들기 어렵고 더 성능도 좋아지는 건줄 알고있었습니다.

하지만 스레드를 떨어뜨리는건 동기화를 요구한다는 것이고, 그 작업을 감내하면서까지고 메인 루프의 속도가 더 향상됨을 보장 받아야 사용할 수 있는 그런 까다로운 기술이란걸 이번글에서 느끼게 되었군요...

 

째뜬, 오늘도 긴글 읽어주신 여러분들 감사드립니다.

그럼 저는 다음글에서 뵙겠습니다.

그럼 안녕히계세요.

320x100

'게임서버 > 멀티쓰레드 이론' 카테고리의 다른 글

Thread Design3과 잡다한얘기  (2) 2022.02.09
Thread Design  (0) 2022.01.27
Out Of Ordering Excution  (0) 2022.01.24
Synchronization2  (0) 2022.01.21
Synchronization  (0) 2022.01.21