IOCP 사용중 주의사항

2022. 2. 19. 01:16게임서버/win socket 프로그래밍

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

최근 들어서 거의 2일에 한 번꼴로 글들을 찍어내고 있습니다.

거의 이론위주로 작성을 하고 있는데... 사실 내용이 그렇게 쉬운내용은 아닙니다.

당연히 서버개발을 한다면 이 정도 지식은 함양해야 된다고 생각하지만, 이번 글에서는 잠깐 쉬어가는 느낌으로 IOCP를 사용하면서 주의할 사항이나 문제가 생겼을 때 어떻게 대처할지에 대한 논의를 해보려고 해요.


우선 지난 글에서는

  1. IOCP자체에 대한 내용보다는 Overlapped IO의 시각으로써 IOCP를 사용할 때 주의점을 정리했었습니다.
  2. 비동기 IO에 대한 문제점들과 이를 해결하기 위한 방법, 그리고 비동기 IO가 무엇인지에 대한 논의도 했었습니다.

 

이번 글에서는 

  • IOCP로 에코 서버를 만드는 데 있어서 주의해야 할 점에 대해서 언급을 해보려고 합니다.

 

우선 예전 글에서 언급을 했는지 정확히 기억은 안 나지만.
WSARecv, WSASend에 들어가는 버퍼와 OVERLAPPED객체는 하나의 작업을 대상마다 할당하는 것이라고 했습니다.
또한 이 작업이 끝나기 전까지 이 버퍼와 OVERLAPPED객체는 절대 사라져서는 안 된다고 했습니다.

 

이번 글에서는 이것이 사라졌을 때 문제가 되는 케이스를 놓고 언급을 먼저 해보려고 합니다.
우선 말씀드리고 가야 될 점은 제가 말씀드릴 버퍼란 것은 WSABUF 구조체를 의미하는 것이 아니라,
그 멤버로 존재하는 WSABUF::buf 포인터가 가리키는 진짜 버퍼를 의미한다는 점 참고하셔야 될 겁니다.


전제는 다음과 같습니다.
1. 메인 함수에서 accept를 할 것이고 그 세션은 케이스별로 스택, 힙에 할당될 겁니다.
2. 우리는 세션이 생성되면 그와 동시에 WSARecv를 걸어둘 겁니다.
2-2 1,2 루프는 계속 반복될 것입니다.

3. recv작업은 워커 스레드에서 진행하고 있습니다.
4. 그리고 만약 메모리가 스택에 있었거나 혹은 delete(free) 되었다고 가정해보겠습니다.
  • case stack

먼저 스택에 있었다면 당연하게도 그 변수는 함수의 리턴과 동시에 사라지게될 겁니다.
만약 사라지지 않는다고 해도 다름 루프에서 다른 값을 써버린다면 그 순간 그 세션은 이상한 값을 가지게 될겁니다.
당연히 이상한 세션은 이상한 행동을 할 수밖에 없습니다.
혹여나 closesocket에 전혀 문제없는 소켓이 들어가게 되고 억울한 피해자가 생길 수도 있습니다.
들어오자 말자 쫓겨나는 상황은 그래도 차라리 낫습니다...

한동안 유저가 들어오지 않아서 문제없이 잘하다가 다음 플레이어가 들어오면서 그 값이 이상하게 써져버리는 바람에 문제가 생긴다면... 난감할 겁니다.

  • case delete

이경우는... 프로세스가 터질 가능성을 내제하고 있습니다.
일단은 delete건은 두 개로 또 쪼개서 봐야 할 것 같은데요
우선 디커밋 여부에 따라 한번 달라질 것 같습니다.
만약 디커밋이 안되었다면 삭제했던 메모리에 새로운 객체를 할당받았을 수도 있다는 의미가 될 겁니다.
당연히 이경우는 아까 스택 문제의 경우처럼 다른 세션의 데이터를 쓰는 꼴이 됩니다.
어... 이건 어마어마한 문제가 되겠죠.

디커밋이 된다면 이건은 서버가 터지는 건입니다.
당연히 디커밋이 됐다면 두말할 필요도 없습니다.
실제 물리 메모리에 맵핑되어있지 않기 때문에, 고대로 터지는 것입니다.

다음과 같은 현상이 나오게 설계를 했고 코딩을 했다라면 우리는 사표를 준비하면 될 겁니다.
당연하겠지만 이렇게 하시면 안 됩니다라고 말씀드리는 겁니다.

다음 논의 사항으로는

저번 글에서 언급했었던 PageLockNPPool 메모리에 관한 걸 논의 해려 고합니다.
당연히 NPPool이나 PageLock은 한계가 존재합니다.
이경우 OS는 모든 NPPool할당 혹은 PageLock이 걸리는 함수에서 실패할 것이고 우리에게 GetLastError()를 통해서 WSAENOBUF라는 에러 코드를 주게 될 겁니다.

우리는 이 상황에서 어떻게 해야 되느냐입니다.

당연히 물리적인 메모리가 부족하기 때문에 생긴 일입니다.

그리고 물리 메모리가 부족한 상황이라 그닥 할 수 있는 행동도 없습니다.

그래서 이경우에는 정확히 이렇게 하라는 메뉴얼은 정해져 있지 않습니다.

몇 가지 방법을 나열해 보겠습니다. 이중에 무식한 방법도 있고 괜찮을 거 같은 방법도 있습니다.

선택과 고민은 여러분의 몫이고 저는 선택지를 조금 더 단순화시켜주는 역할을 하는 거겠죠.

  •  일단 모든 클라이언트를 잘라버리고 DB 등 중요한 건만을 저장한 뒤 다시 서버를 켜기 전에 ScaleOut을(메모리를 늘린다) 한다.

위와 같은 경우에는... 참 유감스럽습니다....

하지만 모두를 끊어내고 최소한의 일 만한 뒤 서버를 셧다운 한다... 는 건 좀 너무 과하지 않을까 싶습니다.

과하지 않을까가 아니라 이렇게까지 해야 되나 싶습니다... 하지만 하나의 방법이 될 수 있겠습니다.

 

 

  • 하나의 거대한 메모리를 할당받아 링버퍼의 실제 메모리를 쫙 갖다 붙여버린다.

두 번째 건은 페이지 락을 줄일 수 있는 방법으로 사용될 수도 있겠습니다.

제가 이때까지 썼던 링 버퍼 들은 일반적으로 1만 byte를 사용하고 있습니다.

1만 byte라면 운이 좋으면 3개의 페이지를 운이 좋지 않으면 4개의 페이지를 먹게 될 텐데요.

차라리 10만 바이트를 한 번에 할당해버리고 이걸 10개의 링 버퍼에 나눠주는 겁니다.

원래라면 30~40개의 페이지를 할당받아야 하겠지만 이경우에는 25개의 페이지를 할당받는 케이스가 될 수 있겠습니다.

물론 이렇게 구현을 하려면 당연히 메모리풀에 대한 설계가 되어있어야 할 것입니다.

당연한 거겠지만 이건 예방의 문제지 이 WSAENOBUF상황이 나타난다면 위의 상황과 똑같을 수밖에 없을 것 같습니다.

 

 

  • ZeroByteRecv를 건다.

이 건은 저번 글에서도 언급했었습니다.

당연히 우리가 WSARecv를 걸게 되면 높은 확률로 비동기로 처리될 것입니다.

그리고 이 의미는 저번 글에서 언급했듯 인터럽트로 데이터가 들어오는 족족 우리 버퍼로 쏴주세요라는 의미였을 겁니다.

그리고 당연히 이 버퍼는 항시 물리 메모리 상주를 약속받아야 될 겁니다.

그렇지 않으면 인터럽트가 왔을 때 수행할 수 없기 때문이죠.

이 경우 당연히 PageLock을 걸어야 되는 것이고 우리는 0바이트를 수신하겠다는 의사를 표현해서 PageLock을 걸지 않게 해 버리는 겁니다.

당연히 동기적인 방법으로 작업을 처리해서 느릴 겁니다.

하지만... 지금 상황은 WSAENOBUF상태 즉 커널에 NPPool이나 PageLock을 걸 리소스가 부족하다이지 않습니까?

페이지 락을 걸 수 없는 상황이기 때문에 무조건 동기로만 읽게 만드는 방법을 선택하자는 의미입니다.

뭐... 이경우에는 서버의 퍼포먼스가 살짝 떨어질 수는 있겠지만 나쁜 방법 같지는 않습니다.

 

  • Disconnect 해버리기

마지막 방식인데요.

단순합니다 그냥 서버가 버티지 못하는 거니까 마지막으로 그걸 요청했던 유저를 disconnect 해버리는 겁니다.

아마 OS가 한번 WSAENOBUF를 띄우면 비슷한 WSAENOBUF가 근쳐 몇 초 사이에 자주 나오게 될 겁니다.

그리고 Disconnect를 당한 경우... 억울하기야 하겠지만 이를 통해서 서버는 스스로 안정화를 찾아갈 겁니다.

 

즉 이경우는 지금의 방식과 동일한데요.

에러를 확인했을 때 WSA_IO_PENDING이 아니라면 그냥 closesocket을 해버리는 겁니다.

이 방법은 일부 유저들이 이유 없이 끊어지는 케이스가 발생하겠지만 서버가 스스로 안정을 찾아간다는 점에서는 나쁘지 않은 방법이라고 생각합니다.

 

 

위에서 언급을 했어야 하는데 ZeroByteRecv의 경우에는 비동기로 PageLock을 걸 때 너무 많은 페이지가 락이 걸리는 경우, 즉 대규모 파일 IO에 적합한 구조입니다.

성능 향상 효과는 전혀 없으며 오히려 감소되지 않으면 다행입니다.

순수하게 이 부분은 안정성을 위한 코드인 것이지 성능 향상을 목표로 하는 코드는 아닙니다.

 

정리를 하자면

제가 아직 현업을 겪어보지 않아서 잘 모르지만 대부분의 현업 개발자들은 WSAENOBUF가 나오는 경우를 몇 년 혹은 몇십 년간 본 적이 없다고 했었습니다.

뭐... 문서 같은 곳에 있는 확실한 답변은 아니더라도, 이런 분들이 오랜 테스트를 했을 때 문제가 발생하지 않았다고 한다면 저 문제가 생길 확률은 거의 아주 희 박하 다는 걸 알고 가시되 문제가 생겼을 때 어떻게 처리할지 정도는 머릿속에 담아두고 가는 게 좋을 듯싶습니다.


다음으로는

비동기 IO의 특징에서부터 나오는 얘기를 해볼 건데요.

우리가 File IO를 동시에 수행하겠다고 하고 WriteFileEx함수를 여러 번 호출하며 인덱스만 정확히 전달해준다면 이 작업은 하드디스크가 헤드에서 가까운 순서대로 자신만의 알고리즘을 가지고 작업을 해줄 겁니다.

만약 동일한 인덱스를 주고 file io를 요청하면 이상하게 저장이 되는 걸 확인할 수 있습니다.

즉 외부에서 봤을 땐 병렬적으로 해주는 것처럼 보입니다.

File IO의 경우에는 순서 보장을 해주지 않았습니다.

 

여기까지 들으셨다면 다음 내용을 보고 어떤 결과가 일어날지 유추하실 수 있을 겁니다.

WSASend(100byte);
WSASend(50byte);
WSASend(30byte);
  • 걱정거리 1

저 위의 코드에서 문제는 다음과 같을 겁니다.

우리가 걱정되는 부분은 100byte를 보냈지만 동기로 80바이트만 보내지고 나머지 20바이트가 당장 보내지지 못하고 비동기로 전환이 되었을 때입니다.

그러면 GQCS의 byteTransfered값이 80이 나오는 거 아니냐라고 하실 수 있습니다.

하지만 이경우도 그냥 WSA_IO_PENDING입니다.

우리는 100바이트가 완료되면 알려달라고 했기 때문에 OS는 그 값에 도달하기 전까지 절대로 우리에게 알려주지 않습니다.

즉 80byte만 되었을 땐 GQCS가 리턴하지 않습니다.

그러므로 우리는 저런 방법을 사용해도 상관없을 겁니다.

중요한 건 우리의 걱정거리는 이게 아니란 거죠...

 

  • 걱정거리 2

WSASend를 해도 좋다 이겁니다.

하지만 두 번째 걱정거리는 다음과 같을 겁니다.

우리는 하나의 작업마다 Overlapped 객체를 동적 할당해야 되는 문제에 놓일 겁니다.

그리고 모든 IO작업은 느리다고 말씀드렸습니다.

요청 또한 마찬가지입니다.

그렇기 때문에 IO는 많으면 많을수록 좋지 않습니다.

그래서 단 한건의 IO만을 사용합니다.

아마 이렇게 쓰시는 분들 중 대부분은 여기까지 고려하셨을 겁니다.

 

 

  • 걱정거리 3

제가 책이름이 생각이 안 나는데 윈도우즈 API를 다루는 책에서 소켓에 대한 비동기 IO를 순서 보장을 받기 힘들다고 적어 둔 것을 들은 적이 있습니다.

아까와 같이 File IO의 경우에는 순서 보장이 제대로 되지 않을 수도 있다고 했습니다.

왜냐면 disk의 경우에는 병렬로 보이게끔 쓰이더라도 쓰는데 효율적이라면 그렇게 쓰게 하겠다는 의미입니다.

 

동일한 맥락에서 살펴보겠습니다.

위와 같은 코드가 있을 때 우리는 WSASend로 100바이트를 모두 하고 난 뒤에 50바이트가 전달되어야 할 겁니다.

당연히 여러 세션 간 순서는 보장할 필요도 없을뿐더러 보장하려고 해도 불가능합니다.

하지만 사람이 ASD순서로 스킬을 사용했으면 서버에서는 ASD를 보장해줘야 할 의무가 있습니다.

 

이건 Disk의 특징과 NIC의 특징을 보시면 알 수 있습니다만,

Disk의 경우에는 병렬적인 처리가 가능한 설계가 되어있는 것이고 NIC는 직렬적인 처리밖에 할 수 없습니다.

하나의 네트워크 라인을 통해 동시에 2개의 데이터를 보낸다? 그런 건 불가능합니다.

그렇기 때문에 socket OverlappedIO에서는 순서 보장을 반드시 지켜줍니다.

 

그러면 여기서 보장을 못해준다는 건 무슨 의미인가를 고민해볼 필요가 있습니다.

인터럽트가 걸린 순서대로 버퍼에 채워주게 될 것이고 이 순서는 보장이 됩니다.

GQCS에서 나오는 순서마저도 보장된 상태로 나오게 됩니다.

하지만 GQCS이후에 로직에 의해서 먼저 GQCS를 리턴했음에도 불구하고 context switching 등의 가능성을 염두,

순서가 뒤바뀌게 될 가능성이 있기 때문에 이를 언급하고 있는 것으로 필자는 여겼습니다.


  • 이번 건은 마지막 건이고 그렇게 중요한 건은 아닙니다.
    우리는 그럼 안전한 종료를 어떻게 만들어야 하는가입니다.

아마 아주 간혹 recv와 send가 동시에 IO요청이 되는 경우가 있을 겁니다.

그리고 만약 이 상황에서 문제가 생겨서 recv건의 GQCS 먼저 return이 되었고 당연히 문제가 있었기 때문에 세션을 delete 해버렸다고 가정하겠습니다.

 

당연히 세션을 delete 하기 이전에 closesocket을 호출할 겁니다.

그 순간 send건의 GQCS가 리턴하게 동일하게 세션을 delete 하게 될 겁니다.

이건 당연히 결함입니다.

 

우리는 이걸 해결하기 위해서 참조 카운팅과 같은 개념을 넣을 겁니다.

물론 진짜 참조를 하고 있는지 카운팅을 하는 건 아니고 send, recv를 호출하면서 ioCounts를 증가시키고 GQCS가 리턴되면 ioCounts를 감소시키고 하다가 문제가 생긴다면 그다음 send도 recv도 호출되지 않기 때문에 ioCounts는 0이 될 겁니다.

ioCounts가 0인 순간 우리는 종료할 겁니다.

당연히 IO가 하나 걸려있는 상황에서 GQCS를 나오면 ioCounts가 0이 되는 순간이 존재할 겁니다.

GQCS호출 직후~IO요청함수 이 두함수 사이에 말이죠.

당연히 이값을 가지고 판단을 하자는 건 아닌 거 아시죠?

 

뭐... 결과적으로 본다면 IOCP에서 종료는 카운팅을 세는 방식으로 갑니다.


  • 맺음말

자... 오늘도 긴 글하나 가 마무리되었습니다.

뭐 이번 글은 사실 그냥 술술 읽으면서 아 그렇구나 하면서 내려오면 되는 그런 글이었던 것 같습니다.

크게 엄청난 지식을 전달하는 글은 아니었지만 많은 도움이 되시길 바랍니다.

사실 좋은 프로그래머는 스킬로 구분될 수도 있지만 이런 꼼꼼한 점과 안정성에서 나올 수 있는 거니까요.

 

자 그럼 저는 글을 마무리하겠습니다.

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

안녕히 계세요.

 

320x100

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

IOCP + Overlapped IO는 비동기IO인가  (0) 2022.02.17
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