IO Completion Port Introduction

2022. 2. 15. 20:17게임서버/win socket 프로그래밍

320x100

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

 

저번 글에서는 Overlapped IO Model2라고 불리는 CALLBACK 방식으로 완료를 통지하는 오버랩 모델에 대해서 논의를 해봤습니다.

이번 글에서는 IOCP에 대한 내용을 다뤄볼 건데요.

IOCP는 직전의 글과 같이 동일한 오버랩 모델 중 하나입니다.

먼저 큰 틀에서 말씀을 드린다면 완료 통지를 받는 방법으로 하나의 큐를 통해서 완료 통지를 받고 그 완료 통지를 받았다면 하나의 스레드를 깨워서 작업을 하는 방식을 의미하고 있습니다.

 

우선 그럼 Overlapped IO Model2에서 IO의 통지를 APC큐를 통해서 했을 경우 문제가 되는 부분은 다음과 같이 말씀드렸습니다.

하나의 APC큐와 IO는 스레드에 종속적이기 때문에 하나의 스레드는 박터져라 일을 하고 있어도 다른 스레드는 놀고 있기 때문에 결국 최악의 경우에는 작업이 그냥 직렬화되어 버리는 상황이 발생할 수 있을 것 같다고 했습니다.

 

만약 우리의 IO를 다른 스레드에 전해줄 수 있다면 성능 개선이 이뤄질 수 있을 것 같은 느낌이 듭니다.

물론 IOCP라는 기술 자체가 다른 스레드에 전달해준다는 개념은 아닙니다.

특정 큐를 바라보고 있는 스레드에 대해서 작업이 완료되었을 시 알려주겠다는 의미입니다.

하지만 이 의미는 다른 부분으로 해석해보면 하나의 큐를 바라보는 여러 스레드들이 있다면 

다른 스레드에서는 내가 요청한 IO 작업의 완료에 대한 부분을 처리할 수 있다는 의미가 되는 것입니다.

 

우선 제가 지난 글에서 의문을 가졌던 점을 어느 정도 해소하고 가야 될 것 같습니다.

제가 궁금했던 부분은 유저 APC큐와 커널 APC큐 중에 유저 APC큐는 자신이 원하는 스레드의 APC큐에 넣을 수 있습니다.

반면에 오버랩 io모델 2에서 작업 완료 통지 완료 루틴이 들어가는 커널 APC큐의 경우에는 IO가 완료된 경우 그 IO를 요청한 스레드의 APC큐로만 통지를 해준다는 점이었습니다.

 

여기서 제가 의문이 들었던 겁니다.

유저는 타 APC큐로 넣을 수 있게 설계가 되어있는데 왜 커널은 그렇게 하지 않는가였습니다.

커널 또한 타 APC큐에 넣을 수 있게 우리에게 API로 제공을 해준다면 혹은 내부적으로 그렇게 구현을 해줬더라면,

다른 스레드의 APC큐에 완료 루틴을 전달할 수 있었을 거고 전달이 되었다면 스레드 간 업무 분산이 효율적으로 되었을 것입니다.

 

지금부터는 추론의 영역입니다.

아마 커널에서도 이러한 기능이 가능할 것이라고 판단됩니다.

하지만 여기에는 사전 제약이 몇 가지 붙을 것 같습니다.

 

첫 번째로 아무 스레드의 APC큐에 넣어서는 안 될 것입니다.

왜냐면 그 스레드가 alertable wait상태로 가지 않는 스레드임에도 불구하고 APC큐에 넣어버린다면 그 스레드는 영원히 그 작업을 처리하지 않게 되기 때문입니다.

 

두 번째는 첫 번째와 연결되는 부분입니다.

alertable wait상태로 가는 스레드에 대한 존재를 알아야 합니다.

어떤 스레드가 alertable wait상태로 진입하는지 알 수 없다면 다른 스레드에게 전달한다는 것 자체가 불가능합니다.

 

세 번째는 다른 스레드라는 걸 어떻게 알 것이냐입니다.

즉 이대목에서 다른 스레드들은 어떠한 하나의 객체로부터 관리가 되고 있고 IO를 마친 스레드가 직접 다른 스레드들에게 전달하는 것이 아니라 그 객체에게 IO에 대한 완료 통지를 해달라고 요청할 수 있을 것 같습니다.

 

네 번째 버려지는 완료 통지가 없어야 됩니다.

어떤 하나의 객체로부터 관리가 되고 있고 IO 완료된 요청들을 하나의 스레드를 깨워서 전달한다고 하겠습니다.

모든 스레드가 다 깨어나 있는 상황이라면 깨울 수 있는 스레드가 없게 될 겁니다.

당연히 하나의 버퍼가 나와야 될 것 같습니다.

완료된 작업을 들 보관할 수 있는 버퍼가 필요하게 되겠죠.

작업의 순서가 보장이 되어야 된다면 큐와 같은 형태로 구현이 될 것이고 보장이 될 필요가 없다면 어떤 자료구조든 상관없을 것 같습니다.

 

다음과 같은 조건이 붙는다면 다른 스레드로의 전달이 가능해질 것 같습니다.

 

그리고 웃기겠지만 여기까지만 정리를 하고 다음은 IOCP에 대한 설명을 할 겁니다.

대신 위에서 봤던 조건들을 그대로 기억하신 채로 보셨으면 좋겠어요.

 

IOCP는 완료 통지에 방법을 효율적으로 개선한 케이스입니다.

그리고 IOCP는 커널 오브젝트이죠... 하지만 생성과정에서 SECURITY_ATTRIBUTE와 같은 객체가 들어가지 않는 몇 되지 않는 커널 객체 중 하나입니다.

핸들 값을 통해서 접근이 가능하기 때문에 모든 스레드에서 접근이 가능하게 될 겁니다.

HANDLE WINAPI CreateIoCompletionPort(
  _In_     HANDLE    FileHandle,
  _In_opt_ HANDLE    ExistingCompletionPort,
  _In_     ULONG_PTR CompletionKey,
  _In_     DWORD     NumberOfConcurrentThreads
);

IOCP를 생성하는 함수이면서 동시에 HANDLE값을 IOCP에 등록해주는 함수입니다.

FileHandle이라고 적혀있지만 우리는 SOCKET을 넣게 될 겁니다.

 

저는 이 함수가 사실 이렇게 두 개의 함수로 쪼개져 있었으면 하는 개인적인 바람이 좀 있습니다.

HANDLE AddHandleToIOCompletionPort(HANDLE h_socket, HANDLE hiocp, 
                                     ULONG_PTR completionKey, DWORD numOfThread);
{
    return CreateIOCompletionPort(h_socket,hiocp,completionKey,numOfThread);
}

HANDLE CreateNewIOCompletionPort()
{
    return CreateIOCompletionPort(INVALID_HANDLE_VALUE,nullptr,nullptr,0);
}

왜냐하면 하나의 함수를 이용해서 두 가지 작업을 할 수 있기 때문에 다음과 같이 명시가 되었으면 좋겠다는 바람이 있습니다.

뭐 이 API가 크게 중요한 부분은 아닙니다.

계속 다음으로 가보겠습니다.

 

IOCP는 그럼 어떤 방법으로 blocking 되어있는 스레드들 중 하나를 깨워서 작업을 시키는가입니다.

IOCP는 자신이 관리하고 있는 스레드들을 모두 기억하고 있습니다.

GetQueuedCompletionStatus()(이하GQCS) 라는 함수를 통해서 이 함수의 파라미터로 들어온 iocp는 지금 이 함수를 호출한 스레드를 자신의 워커 스레드로 영원히 기억하게 될 겁니다.

기억에서 사라지는 방법은 두 가지가 있습니다.

첫 번째로 스레드가 종료되거나 두 번째로 iocp가 파괴되었을 때입니다.

즉 IOCP라는 객체는 어떤 스레드가 IO를 요청할 것인지 혹은 blocking상태에 걸리게 될지에 대한 정보를 관리하고 있습니다.

구글에서 가장 간단한 사진 한 장을 퍼왔습니다.

다음과 같이 IO Completion Queue와 Waiting Thread Queue라는 두 개의 버퍼를 이용해서 처리하게 될 겁니다.

물론 스레드의 상태중 Waiting Thread만이 있는 그림을 가져왔기 때문에 모든 것을 설명하는 그림은 아니라는 것은 알아두셔야 할 것 같습니다.

WaitingThreadQueue에 있는 스레드 중 가장 마지막에 들어온 스레드를 꺼내고,

IO Completion Event가 담겨있는 큐의 데이터와 매칭을 시켜 통지를 하게 될 겁니다.

어떤 이벤트가 어떤 식으로 도착했는데 그때 작업이 어떤 작업인지는 OVERLAPPED객체를 통해서 얻을 수 있을 겁니다.

 

어떤 객체를 대상으로 스레드가 대기한다는 점만 미뤄 봤을 땐 IOCP와 Event의 일부 유사한 부분이 존재하는 것처럼 보입니다.

하지만 여기서 차이점은 다음과 같을 겁니다.

Event는 호출했을 때 그 Event에 단발적으로 등록이 된다.

   -> Event는 이번 한건만을 관리만을 하고 있다는 의미가 될 겁니다.

IOCP의 경우에는 한 번이라도 GQCS를 호출한 스레드는 그 스레드를 관리하게 되고, 한번 호출 이후 호출하지 않으면 그 스레드는 running상 태거나 blocking상태로 분류합니다.

 

IOCP는 일반적으로 스레드 풀과 함께 이용되는 경우가 대다수입니다.

여기서 스레드 풀은 스택 구조입니다.

당연히 스레드라면 콘텍스트 스위칭에 대한 염려를 해야 되고 그 콘텍스트 스위칭 횟수를 줄이기 위해서는 방금 썼던 스레드를 또 사용하는 편이 좋을 겁니다.

그렇기 때문에 방금 사용했던 스레드를 한 번 더 사용하는 스택 방식으로 구현이 되어있습니다.

그리고 IOCP는 동시에 작업 수행이 가능한 running thread의 갯수를 지정할 수 있습니다.

위에서 말씀드렸던 thread에 대한 관리 부분이 이런 부분에 해당할 수 있겠습니다.

어떤 스레드가 현재 업무를 수행 중이고 어떤 스레드가 일을 하기 위해 대기 중인지 어떤 스레드가 현재 IO를 하며 GQCS를 제외한 블로킹에 걸려있는 상태인지 확인을 하고 있다는 의미가 되겠습니다.

 

뭐... running thread를 지정할 수 있다고는 하지만 100% 지켜지지는 않습니다.

만약 running thread가 Sleep이나 혹은 다른 IO(printf 같은) GQCS를 제외한 blocking상태에 걸리게 된다면 이때 이 스레드를 running에서 blocking으로 옮겨놓고 새로운 GQCS에서 대기 중이던 스레드를 하나 깨우게 됩니다.

 

4개의 스레드를 가진 스레드 풀이 존재하고 그중 3개의 스레드만이 running이 되길 희망한다면 IOCP는 3개의 스레드만을 깨워서 running을 3으로 waiting thread를 1로 두겠지만 running thread 중 하나가 blocking에 걸린다면 그 순간 running을 2로 수정하고 blocking을 1로 증가시키게 될 것이고요.

그러면 waiting thread에 있는 스레드를 하나 뽑아 running상태로 전환시키고 running을 3으로 바꾸게 될 겁니다.

 

여기까지 본다면 잘 지켜지고 있는 것 같습니다만,

아까 말씀드렸듯이 100% 지켜지지는 않습니다.

blocking에 걸린 스레드가 다시 깨어나는 상황인데요.

현재 blocking에 걸린 스레드가 깨어날 조건이 되었을 때 깨우지 않는 방법은 존재하지 않습니다.

그렇다면 그때 blocking thread가 0이 되고 running thread가 4가 될 것입니다.

이 경우에는 다음번 GQCS를 호출한 스레드가 존재한다면 그 스레드에 대한 완료 통지를 하지 않는 방법으로 running thread를 조절하게 됩니다.

 

그리고 일반적으로 running thread의 갯수는 가용한 논리 프로세서의 갯수 이하 개로 세팅을 합니다.

대부분의 프로세스는 당연히 worker thread만으로 이뤄져 있지는 않을 것이기 때문입니다.

그리고 thread갯수를 초과하는 숫자만큼 있다면 당연히 running thread가 가용 논리 프로세서의 갯수 이상 되기 때문에 더 잦은 컨텍스트 스위칭이 발생할 겁니다.

당연히 이런 건 성능상 좋지 않은 결과를 보이게 될 겁니다.

 

그리고 IOCP의 장점은 이뿐만이 아닙니다.

CreateIOCompletionPort함수를 호출할 때 CompletionKey라는 하나의 값을 전달할 수 있는데요.

우리의 상황에서는 소켓과 맵핑이 되는 하나의 키값이라고 보시면 될 것 같습니다.

이 CompletionKey값은 GQCS의 아웃 파라미터로도 전달받을 수 있습니다.

즉 Overlapped객체를 통해 하나의 파라미터를 받아왔었던 OverlappedIO model1,2와 다르게 우리는 하나의 파라미터를 더 얻을 수 있게 된 겁니다.(개꿀)

 

그리고 running thread갯수와 동시에 중요한 건으로는 WorkerThread를 몇 개를 생성해야 되는 부분이 또 되게 중요할 겁니다.

당연히 이 부분은 정확히 몇 개 해라고 정해진 건 없지만 일반적으로 많은 사람들의 글을 보게 되면 3개 정도로 나눠지는 것 같습니다.

1. 스레드갯수

2. 스레드갯수*1.5

3. 스레드갯수*2

정도로요.

 

뭐... 일반적으로 저 정도 선에서 만드는 것이 효율적이라는 의견은 저도 일부 동의합니다.

하지만 몇 개를 생성할지는 당연히 로직에 따라 달라져야 하는 부분이라서 저도 몇 개를 해야 된다라고 말씀드릴 수가 없는 부분입니다.

 

예시로 과한 블로킹(길거나, 잦은)이 있다면 워커 스레드가 모두 깨어날 가능성을 내제하고 있습니다.

모두 깨어난다는 것은 당연히 좋지 않은 상황입니다.

반대로 만약 컨텍스트 스위칭의 가능성이 전혀 없고(blocking이 되지 않고) 100% 연산자로만 이루어진 케이스라면 WorkerThread숫자 == RunningThread숫자 여도 좋을 듯합니다.

 

우선 로직에 블로킹이 존재하지 단 한건도 존재하지 않는다는 건 거의 불가능할 겁니다.

그럼 우리로써는 과한 블로킹이 생기지 않게 하거나 혹은 블로킹이 생긴다면 거의 로직의 최하단부에 몰아서 두는 것이 좋을 겁니다. 왜냐면 로직이 끝나기 직전에 blocking이 되어서 다른 thread가 running이 된다면 blocking이 끝나고 running스레드 갯수가 틀어졌더라 하더라고 그 시간이 아주 짧게 지나갈 거기 때문입니다.

 

뭐... 이건 제가 정확히 확답을 드릴수가 없습니다.

모든 프로그램은 각각의 컨셉이 다 다르기 때문입니다.

모두가 깨어났을 때 비효율적인 코드가 나올 수도 있는 반면에,

블로킹이 너무 길기 때문에 그동안 다른 스레드라도 일을 시키게  하는 게 효율적인 코드가 존재할 테니까요.

 

좋습니다.

여러분들은 아마 이상한걸 못 느끼셨겠지만

위의 아까 제가 사전 조건이라고 제시한 4가지 조건과

IOCP를 정리하며 보라색으로 줄 그었던 부분을 비교해놓고 보신다면 제가 말씀드린 조건을 충족하는 게 곧 IOCP라는 걸 아시게 될 거예요 ㅋㅋㅋ...

원래 이걸 위에다가 올려 쓰려고 했는데 저도 깜빡하고 이제 쓰게 되었어요.

 

째뜬 오늘의 글은 여기서 마무리를 짓고 다음 글을 위해서 또 준비해야 될 것 같아요.

오늘도 긴 글 읽어주셔서 감사합니다.

그럼 다음 글에서 더 좋은 정보로 돌아오도록 하겠습니다.

그럼 안녕히 계세요

320x100

'게임서버 > win socket 프로그래밍' 카테고리의 다른 글

IOCP 사용중 주의사항  (0) 2022.02.19
IOCP + Overlapped IO는 비동기IO인가  (0) 2022.02.17
Overlapped IO Model2  (0) 2022.02.15
Overlapped IO Model + heap  (0) 2022.02.11
Overlapped IO Introduction  (0) 2022.02.10