IOCP + Overlapped IO는 비동기IO인가

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

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

 

저번 글에서는 IOCP에 대한 내용을 정리했습니다.

 

글에서 핵심 내용만 몇개 뽑아본다면 다음과 같을겁니다.

  1. Overlapped IO Model2에서 IO를 타스레드로 전달하기 위한 조건을 걸다보면 결국 IOCP와 거의 흡사한 모습이된다.
  2. IOCP를 효율적으로 사용하기 위해 스레드풀을 사용할것이고 스레드풀은 컨텍스트 스위칭과 캐시히트 때문에 스택형태로 되어져있다.
  3. CreateIoCompletionPort함수에 파일 핸들과함께 하나의 키값을 등록할 수 있고 그 키는 GQCS함수를 호출할 때 다시 얻을 수 있다.(일반적으로 세션포인터를 전달하고 세션을 달고다니는 셈이된다)
  4. Running Thread와 Worker Thread는 우리가 원하는 만큼 세팅할 수 있으며 WorkerThread의 갯수는 정확히 정할수 없는 부분이다. 왜냐하면 GQCS이후 어떤 코드가 진행되느냐에 따라서 완전 다른 컨셉이 나오기때문이다.
  5. Running Thread는 일반적으로 가용 논리 코어 갯수 이하개로 세팅한다.

정도 였던것 같습니다.



  • 그리고 이번글에서는 IOCP를 사용할때 주의해야하는 점을 위주로 글이 전개될 것 같습니다.

그럼 시작하도록 하겠습니다.

 

일반적으로 인터넷에서 볼수있는 IOCP에코 서버와 같은 코드에서는 따로 세션 정보를 관리하고 있지 않습니다.

음... 뭐 일반적으로 파일핸들과 함께 등록하는 키값을 세션포인터로 전달하는 경우가 많기 때문인데요.

그 뿐만 아니라 Overlapped객체와 함께 전달될 수도있기 때문에 예제 코드에서는 세션을 관리하지 않는 모습으로 코드를 많이 작성하고있습니다.

 

우리가 주의할 점은 다음과 같습니다.

 

  • 게임서버에는 상호작용이 존재합니다.

에코서버의 경우 1개의 클라이언트가 요청을하면 그 클라이언트에게 다시 돌려주는 방식으로 설계가 될것입니다.

하지만 게임서버의 경우에는 그런식으로 설계가 되지 않을것인데요.

일반적으로 게임서버는 A라는 유저가 어떤 작용을 했으면 당연히 근쳐에 있는 B라는 유저에게도 그 정보를 전달해줘야 할 것입니다.

하지만 이상황에서 세션정보를 관리하고 있지 않다면,  우리는 B유저에게는 정보를 전달할 수 없게됩니다.

 

  • 다음으로 GQCS에대한 Time out을 INFINITE로 둔다라는 점인데요.

그렇기 때문에 절대로 Time out을 통한 GQCS함수의 return인 케이스가 존재하지 않습니다.

그런 상황을 기반으로 코드가 나와있기 때문에 많은 코드에서 GQCS함수 이후에 lpOverlapped에 대한 값을 검사하지 않습니다.

에코서버의 경우에는 뭐 큰 문제가 없을 것 같습니다만 서버는 다릅니다.

모니터링 서버는 당연히 각 스레드들의 상태를 확인해야하고, 스레드들이 블로킹에 걸려있다면 상태를 당연히 확인할 수 없습니다.

그렇기 때문에 TimeOut으로 나오는 케이스를 둬서 그 케이스에 상태를 전달해줘야 할겁니다.

TimeOut으로 GQCS함수가 return하게 되는 경우에는 파라미터로 전달했던 OVERLAPPED포인터는 nullptr로 나오게 될겁니다.

그리고 OVERLAPPED객체가 nullptr로 돌아 나왔다면 byteTransfered와 CompletionKey는 들어갔던 값 그대로 손대지 않은채로 나오게 될겁니다.

ret = GQCS(...);

if(transfered == 0)
    Disconnect(CompletionKey);

다음과 같은 코드는 당연히 IOCP는 완벽히 고장날일이 없고 TimeOut이 INFINITE라는 가정하에 짜여진 코드일겁니다.

하지만 TimeOut이 INFINITE가 아니라면 저 함수는 리턴할 수 있고 리턴이 된다면 당연히 저 코드는 문제가 생길것입니다.

그러므로 우선적으로 우리는 CompletionKey와 byteTransfered값을 초기화하고 GQCS함수를 호출하는것이 좋을 것입니다.

그까지 되었다면 우리는 저 앞에 OVERLAPPED포인터 값이 nullptr인지 확인하는 과정도 들어가야 될겁니다.

이유는 GQCS함수가 실패했을 때 잘못된 포인터와 키값, 바이트수를 사용하지 않게하기 위함입니다.



  • 다음으로 비동기 IO요청함수에 대한 주의점에대한 언급입니다.

WSARecv와 WSASend를 가장 많이 호출할 것 같으니 이 함수를 중심으로 정리하도록 하겠습니다.

우선 이글의 제목과도 같이 WSARecv, WSASend함수는 비동기 함수인가에 대한점을 논의해봐야 할 것 같습니다.

우선 그전에 비동기라는게 무엇인지 좀더 자세히 들어가 볼 필요가있습니다.

 

우선 윈도우즈에서 제공하는 모든 IO는 오버랩 IO으로 진행이됩니다.

대신 오버랩을 수행하기위한 SrcBuffer제공을 어디서 하느냐에 따라 유저 입장에서 동기와 비동기로 구분 할 수 있을것입니다.

만약 우리가 블로킹함수 recv같은 동기함수를 호출하게 된다면 커널의 스트림버퍼에 복사를하고 디바이스(드라이버)에 그 포인터를 전달함으로써 디바이스에 전달을 하게될 것이고 우리는 커널의 스트림 버퍼에 값을 복사하는 과정을 거치고 디바이스가 그 커널의 포인터를 읽기를 기다리게 될겁니다.

이 경우에는 유저 입장에서는 동기적으로 기다리게 될겁니다.

우리입장에서는 동기적이고 Overlapped IO가 아닌것 처럼 보이지만 커널입장에서는 Overlapped IO입니다.

 

하지만 우리의 버퍼를 직접 디바이스에 포인터로 전달하면 우리는 커널 스트림 버퍼에 값을 복사하는 과정을 생략하게 될겁니다.(ZeroCopy라고 부릅니다.)

그리고 우리는 바로 리턴해버리는거죠

당연히 IO는 언제될지 알 수 없습니다.

그리고 알 필요도 없습니다.

하지만 우리가 여기서 중요하게 생각해야되는 대목은 비동기작업 이라는것은 IO가 언제완료될지는 모르지만 일단 나는 그 디바이스가 참조할 수 있는 포인터변수 하나를 걸어두고 왔다는게 될겁니다.

그리고 그 작업은 언젠가는 수행될겁니다.

그 디바이스는 작업을 마무리하고 os에게 인터럽트를 줄것이고 그렇게 되면 우리의 IO가 마무리되었다는 완료 통지를 받게될겁니다.

위와 같은 과정을 보고 우리는 비동기 IO라고 부르는것입니다.

 

 

L7에서 L4로의 카피가 줄었습니다.

즉 카피에 대한 오버헤드가 줄었으므로 효율적일 것 같고 우리는 비동기 IO를 계속 요청하게될 것 같습니다.

그럼 우리는 이걸 생각해봐야 될 것 같습니다.

우리가 비동기로 IO를 하기를 바란다면 무조건 OS는 비동기로 처리해 주느냐입니다.

이걸 알기위해서는 WSARecv함수와 WSASend함수에 대해서 조금 정리가 되어야되니 그부분으로 넘어가도록 하겠습니다.

 

우선 WSARecv와 WSASend의 건은 다른건입니다

WSARecv는 일단 받을게 없어도 걸어놓는것이고 WSASend는 보낼게 있을때 우리가 걸어 두는 건이기때문에 서로 다른건으로 생각하고 먼저 WSARecv건에 대해서 보겠습니다.

 

WSARecv함수는 동기함수처럼 사용될 수 있냐는겁니다.

일단 정답은 가능하다입니다.

동기함수처럼 호출했을때를 말하는게 아닙니다.

비동기 함수처럼 호출하기 위한 코드적인 조건을 갖춘상태에서를 의미합니다.

우선 우리는 accept를 호출하여 세션을 생성한 직후 바로 WSARecv를 걸어둘겁니다.

그 세션으로부터 데이터를 수신해야되기 때문에 우리는 비동기적인 요청을 미리 걸어두는겁니다.

그리고 하나의 작업이 끝나면 바로 다시 WSARecv함수를 걸어둬서 다시 IO완료통지를 받기위해 비동기로 들어가게될겁니다.

위의 경우는 전부 비동기적인 IO로 작동이 될겁니다.

 

하지만 이런경우에는 WSARecv또한 동기적으로 호출될 수 있습니다.

WSARecv를 호출하는 순간 이미 L4버퍼에 데이터가 있었다면 우리는 새로운 버퍼를 바로 디바이스에 제공하지 못할겁니다.

왜냐면 그렇게 되는 순간 순서가 틀어져버리니까요.

TCP는 과도할 정도로 순서에 예민한 친구입니다.

SEQ번호와 ACK번호를 체크하면서 확인할 정도니까요

그렇기때문에 이미 들어와있는 L4버퍼를 비우게 될겁니다.

그리고나서 L4 버퍼가 비었다면 우리의 버퍼로 다이렉트로 송신을 하게 꽂아버릴겁니다.

 

이 건은 동기recv함수의 건에서도 똑같습니다.

L4버퍼에 데이터가 차있는 상태에서 recv함수를 호출했다면 호출한 순간 데이터를 쭉 L7버퍼로 긁어오기 시작할겁니다.

우리는 블로킹 소켓으로 동기recv함수를 호출했을때 할게 없으면 블로킹에 걸리게될겁니다.

그리고 블로킹에 걸리게 되었을 때 네트워크 장비를 통해 인터럽트가 오면 그 인터럽트는 즉시 내가 제공한 버퍼로 데이터를 꽂아주게 될겁니다.

이때는 L4버퍼를 거치지 않습니다.

recv함수를 호출했지만 L4를 거치지 않는다는 의미는 동기적으로 Overlapped IO가 이뤄진다는 의미가 되겠습니다.

여기서 차이점은 블로킹에 걸리냐 안걸리냐 차이가 되겠습니다.

 

 

WSARecv또한 이부분은 마찬가지입니다.

호출당시에 L4버퍼에 데이터가 있다면 동기함수와 정확하게 동일하게 행동할것입니다.

하지만 비동기 함수이기 때문에 L4버퍼에 공간이 없다면 내 버퍼를 L4버퍼 대신에 끼워넣고 거기다가 바로 넣어주기를 희망할겁니다.

그리고 당연히 드라이버는 L4버퍼를 먼저 긁어갈거구요(순서보장때문입니다)

 

그럼 위의 글에서는 두가지의 상황이 나왔습니다.

1. 호출과 동시에 읽을 데이터가 들어와있는경우
2. 호출은 했지만 읽을 데이터가 안들어와있는경우.

첫 번째 케이스는 WSARecv는 0을 리턴하게 될겁니다.

그리고 GQCS를 호출하고있던 스레드 하나를 깨워서 작업을 실행시킬겁니다.

이경우에는 당연히 동기 IO와 동일하게 행동을 하게될겁니다.

 

두 번째 케이스는 WSARecv는 SOCKET_ERROR를 리턴하게될겁니다.

이게 에러인가..? 할수있지만 WSAGetLastError함수를 호출,

그 값을 확인했을시 WSA_IO_PENDING이라는 값이 나오게 된다면 정상적으로 요청이 되었다는 의미입니다.

그리고 WSAGetlastError값이 WSA_IO_PENDING이거나 return이 0으로 떨어졌다는 의미는 작업 요청은 완료가 되었다는 의미일겁니다.(작업이 완료되었는지에서 갈라지는겁니다)

즉 위의 경우에는 어떻게든 GQCS에서 OVERLAPPED포인터로 이 컨텍스트가 떨어지게 된다는 의미입니다.

반대로 해석하자면 WSA_IO_PENDING이 아니거나 혹은 return이 0이 아니라면 이 컨텍스트로는 IO완료가 오지 않는다는 의미입니다.

즉 이경우에는 Disconnect가 되겠습니다.

일반적인 나가는 경우 10054, 10063같은 경우가 아니라면 로그를 찍어서 확인해보시고 그 경우라면 정상 종료케이스로 보내면 될것입니다.

 

그리고 이 경우에는 당연히 비동기IO가 될것입니다.

WSARecv를 비동기조건으로 호출했음에도 불구하고 동기 IO가 불가능할때만 비동기로 처리한다는 의미가 되겠습니다.

 

여기까지 봤다면 WSASend건도 거의 동일하게 행동합니다.

다만 WSASend건은 조금 다른부분이 있습니다.

왜냐하면 WSARecv의 경우에는 우리가 필요할때 요청하는게 아니라 항상 요청이 되어있는겁니다.

그렇기 때문에 거의 대부분의 순간 L4버퍼는 비어있을것이고 그렇기 때문에 읽을것이 없으므로 비동기로 전환이 되는 케이스였습니다.

 

반대로 WSASend건은 무엇인지 봐야합니다.

그전에 send가 무엇인지부터 다시 짚어볼 필요가있습니다.

 

send는 L4버퍼에 복사를 하는 행동입니다.

즉 L4버퍼가 비어있다는 조건이 걸려있다면 비동기IO를 요청해도 동기IO로 처리가 될겁니다.

왜냐면 즉시 동기IO로 처리가 가능하기 때문입니다.

 

하지만 우리는 동기 IO를 바라지 않습니다.

 

아마 예전에 제가 이런 말씀을 드린적이 있을겁니다.

"송수신 버퍼는 0으로 만들수 없다."

0이라고 나오지만 실제 데이터는 보내고 받아진다.

API가 구라를 치고있는것이다 라고 말씀드린적이 있을겁니다.

 

그럼 우리는 WSASend를 해도 무조건 동기IO밖에 수행할수 없는건가라고 물으신다면...

OverlappedIO 의 경우에는 SendBuffer의 사이즈를 0으로 줄일 수 있게 Windows에서 지원하고 있습니다.

즉 SendBuffer가 0이 되었기 때문에 우리는 동기적인 IO를 수행할 수 없는것이고 실제 IO가 필요할 때마다 비동기로 전환되어서 우리의 버퍼를 직접 디바이스에 꽂아버리는 행동을 하게될겁니다.

중간의 L4버퍼로의 복사가 사라지는겁니다.

상당한 오버헤드를 줄일 수 있을것으로 판단됩니다.

 

물론 비동기IO라고 한들 완벽하진 않습니다.
이에대한 논의는 글의 마지막쯤에서 정리를 해볼예정입니다.

 

다시 주의점으로 돌아오겠습니다.

버퍼의 크기를 0으로 만드는 구간에서 많은분들이 잘못알고 계신부분이 있습니다.

SendBuffer와 RecvBuffer를 0으로 만들어서 ZeroCopy를 만들겠다는 것입니다.

Send건에서는 맞는 말입니다.

하지만 Recv건에 대해서는 불가능합니다.

당연히 Recv는 네트워크 장비에의해 인터럽트로 도착하는 것일겁니다.

인터럽트는 블로킹에 걸릴 수 없고 당연히 그자리에서 바로 처리가 되어야됩니다.

하지만 이상황에서 버퍼가 없다는 이유로 처리를 하지 못한다면... 문제가 될겁니다.

그러므로 실제로 Overlapped IO모델이라고 한들 RecvBuffer의 크기는 0으로 줄여지지 않습니다.

그리고 줄일 수도없습니다.

RecvBuffer에 한해서는 API는 여전히 구라를 치고있는겁니다.

 

즉 짧게 정리를 해본다면

WSASend또한 두가지로 나눠볼 수 있을겁니다.

1. 보낼때 송신버퍼가 비었을경우
2. 보낼때 송신버퍼가 꽉차있을경우

 

첫번째 케이스라면 당연히 동기IO로 전환이 될겁니다.

그리고 리턴으로 0이 떨어지게 될겁니다.

그와 동시에 WorkerThread중 하나의 GQCS함수가 리턴을 하게되겠죠.

 

두번째 케이스라면 SOCKET_ERROR를 리턴할 것입니다.

WSAGetLastError는 WSA_IO_PENDING이 나올것이구요.

IO가 완료되는 즉시 WorkerThread중 하나의 GQCS함수가 리턴을 하게될겁니다.

 

 

글이 너무 길기때문에 간단하게 정리를좀 해드리겠습니다.

그럼 WSA_IO_PENDING의 의미에 완벽하게 이해를 하실 수 있을것이라고 판단합니다.

정도로 축약해서 정리할 수 있겠습니다.

  • WSA_IO_PENDING이라는 것은 비동기IO의 요청 완료를 의미하고있습니다.
  • WSASend,WSARecv에서 WSA_IO_PENDING이 아니라면 Overlapped IO가 아니다. 이경우에는 동기recv, send와 동일한 작업을 수행한다.
  •  Overlapped IO는 1차적으로 동기IO 시도후에 불가능하면 비동기로 작업을 걸어둔다.
  •  WSARecv는 거의다 WSA_IO_PENDING이 나온다.
  •  WSASend는 SendBuffer를 0으로 만들지 않는이상 거의다 return이 0으로 떨어진다.


음... 여기서 아마 눈치가 매우 빠르신 분이라면 이부분을 캐치 하셨을 수 있습니다.

비동기 작업을 하기위해서는 당연히 메모리를 할당해서 전달해야 될거구요.

(당연히 함수가 리턴되기 때문에 스택은 아닐거구요)

전달하게되었을때 문제는 이 메모리를 오래사용하지 않으면 페이지 아웃 확률이 존재한다는겁니다.

그리고 페이지 아웃이 되면 네트워크 장비들은 인터럽트 기반으로 작동하기 때문에 문제가 될것이고 이 메모리는 항상 물리 메모리에 상주시켜야 될것입니다.

즉 이메모리는 NPPool이거나 PageLock이 걸려야 한다는 의미가되겠고 유저 메모리이기 때문에 PageLock에 걸리게 될겁니다.

 

그리고 이 문제는 예전부터 존재해 왔습니다.

하지만 Overlapped IO를 공부하시다 보면 ZeroByteRecv라는 말을 들어보시게 될텐데...

페이지락을 거는 행위 자체의 오버헤드가 너무 크고 PageLock을 걸기때문에 위험하다는 의견으로부터 나온 아이디어입니다.

 

하지만 잘생각해본다면 동기recv의 경우에도 블로킹이 걸려버린다면 바로 내 버퍼로 데이터를 받으라고 했습니다.

당연히 비동기 IO와 동일하게 이 버퍼는 물리메모리에 항상 상주해야될것이고, 물리 메모리에 상주하지 않는다면 인터럽트 상황에서 페이지폴트를 유발, 이는 문제가 될것입니다.

 

하지만 우리는 이경우에는 오버헤드가 있고 PageLock을 거는 행위에 대한 지적을 하지 않았습니다.

그 이유는 잠깐 고민해보면 알수있을겁니다.

 

이유는 다음과 같습니다.

일반적으로 Overlapped IO라고 한다면 최소 몇천, 많으면 몇만개의 IO를 요청할겁니다.

그럼 즉 IO를 요청하기위해 최대 몇만개의 페이지락을 걸어야 될수도 있다는 의미가됩니다.

 

하지만 동기IO를 생각해보신다면 우리는 스레드 하나를 이용해서 하나의 페이지 락을 만들어낼겁니다.

우리가 만약 스레드를 몇만개를 만들지 않는다면 이런 경우는 발생할 수 없겠죠.

그렇기 때문에 아얘 염두조차를 안하고 하는 경우인겁니다.



그리고 두번째 논의입니다.

아까 "비동기 IO또한 완벽하지 않다"고 했습니다.

글의 마지막부분쯤에 논의를 해본다고 했었는데요.

아까와 동일하게 페이지락에대한 오버헤드건 때문입니다.

 

이부분은 사실 코드에따라 충분히 성능이 바뀔수도 있고 성능 검증은 직접 코드를 짜서 해봐야되는 부분이기때문에 판단은 여러분들의 몫이라고 생각합니다.

 

WSABUF의 len을 0으로 세팅해서 비동기 IO이지만 우리는 하나의 시그널로써의 역할만 받겠다는 겁니다.

그리고 0바이트가 GQCS의 아웃파라미터로 전달이 된다면 그때 우리는 동기recv함수를 사용해서 데이터를 뽑아낼겁니다.

그리고 그 경우에는 당연히 데이터가 있을 수 밖에 없겠죠.

이렇게 함으로써 페이지 락을 최소화 시킬 수 있다는 장점이 있습니다.

하지만 이렇게 하게된다면 결국 동기로 돌아와버리게 되고 오버랩 IO의 장점인 IO를 커널이 다 처리해서 완료통지를 해준다는 부분의 이점을 살리지 못하게 될겁니다.

 

음... 어떠신가요 이부분에 대한 고민을 좀 해보시고 어떤게 더 좋을지는 여러분들의 판단에 달려있습니다.

만약 더 좋은 내용이 떠오르시거나 한다면 댓글로 남겨주셔도 좋고 저한테 직접 알려주셔도 좋습니다 ㅋㅋㅋ...


자 이번글은 여기서 마무리 짓도록 하겠습니다.

글이 참 길고 지저분합니다.

하지만 내용이 너무나도 중요하기 때문에 중간에서 끊기가 너무 애매하다는점... 양해부탁드립니다.

그럼 다음번에는 더 좋은 내용으로 돌아도록하겠습니다.

안녕히계세요

320x100

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

IOCP 사용중 주의사항  (0) 2022.02.19
IO Completion Port Introduction  (0) 2022.02.15
Overlapped IO Model2  (0) 2022.02.15
Overlapped IO Model + heap  (0) 2022.02.11
Overlapped IO Introduction  (0) 2022.02.10