2006-05-16

IOCP Tips

I/O completion port 를 개발할 때 주의해야 할 사항들.

1-Recv Rule

하나의 소켓에 대해서 특정 순간에 1개 이하의 입력만이 요청되어야 한다는 규칙을 1-recv 규칙이라고 부른다. 물론 버퍼를 여러 개 준비한 다음 미리 요청해두는 N-recv 방식도 그럴듯하지만, 요청한 순서대로 항상 완료되지는 않는다는 소문(?)에 그다지 많이 사용되고 있지는 않은 듯하다.

그런데, 입력 재요청은 GQCS()를 호출하는 입출력 쓰레드에서 할 수도 있지만, 로직 처리 쓰레드에서 하는 경우도 많다. MMORPG가 아닌 경우에 한정해서 후자의 방식을 권장하는 편인데, 코드는 약간 복잡하지만 버퍼 복사 및 동기화 부담도 줄어들기 때문이다. 다음 두 예제를 살펴보자.

# ex1
class ideal_session:
def end_read(self,buf,len):
"""callback for input completion. called by i/o thread"""
while 1:
msg = self.get_msg() # parse & copy!!
if msg:
msgq.append((self,msg))
self.begin_read()

def ideal_process_sessions():
"""logic process thread"""
while 1:
session, msg = msgq.pop()
if session and msg:
msg_handler(session,msg)

# ex2
class real_session:
def end_read(self,buf,len):
"""callback for input completion. called by i/o thread"""
sessions.append(self) # only notify

def real_process_sessions():
"""logic process thread"""
while 1:
session = sessions.pop()
if session:
while 1:
msg = session.get_msg() # parse but no copy!
if not msg: break
msg_handler(session,msg)
session.begin_read()

[예제1]에서는 입출력 쓰레드에서 메시지 파싱 - 큐잉 - 입력 재요청이 이루어지고 로직 처리 쓰레드는 메시지큐로 동작하는데, 전체적으로 코드는 단순하지만 메모리 복사 및 할당이 많아진다. 또한 입력 버퍼가 가득 찬 후의, 다음 입력 재요청을 과연 어디서 할 것인지도 애매하다. 반면, [예제2]에서는 대부분의 작업을 로직 처리 쓰레드에 위임시킴으로써 입출력 처리 쓰레드의 부하를 최소화하면서, 버퍼 복사 역시 극단적으로 줄일 수 있게 된다.

단, [예제1]이 입력 완료 후 재요청이 더 빠르지만, 전체적인 입출력 처리를 감안한다면 그다지 빠르다고 보기는 힘들 것으로 추측한다.

1-Send Rule

입력과 달리 출력 관리 방법에 대해서는 많은 이견이 존재한다.

N-send 는 보통 출력 버퍼 관리를 하기보다는 필요할 때마다 즉시 보내는 것을 의미하는데, 테스트해 본 결과 출력 완료시 bytes_transferred != bytes_written 인 경우를 거의 못봤다는 이야기도 종종 들린다. 그러나 만에 하나 예외 상황이 발생한다면 TCP 스트림이 꼬이게 되므로 1-send 로 구현하는 것을 보통 권장한다. 좀 더 자세한 설명은 GPGStudy에서 sparrowhawk님이 쓰신 글이 글을 참고하도록 할 것.

고전적인 출력 버퍼 관리 방식에서는 큰 출력 버퍼를 마련해 두고, 보낼 데이터를 복사한 후 한꺼번에 flush 하게 된다. 여기에 버퍼 복사를 줄이기 위해 큰 버퍼 대신 버퍼의 리스트로 구현한 것을 바로 gather-send 라고 하는데, 대부분이 broadcast 인 게임 서버에서는 Lock과 참조카운팅, 메모리 풀링을 함께 사용해야 하기 때문에, unicast가 많은 미들웨어에 한정해서 사용해야 할 것이다. 가능하면 출력 버퍼의 관리 방식을 손쉽게 교체할 수 있는 프레임워크를 만들거나 사용하는 것을 추천한다.

출력 완료 처리 및 재요청은 당연히 로직 처리 쓰레드의 몫이 된다. 1-Recv Rule과 같은 방식으로 하면 간단하므로 따로 예제는 준비하지 않았다.

NumberOfCurrentThresds

CreateIoCompletionPort() 의 마지막 파라미터인 NumberOfConcurrentThreads 에 대한 MSDN의 설명을 보자.

Maximum number of threads that the operating system allows to concurrently process I/O completion packets for the I/O completion port. If this parameter is zero, the system allows as many concurrently running threads as there are processors in the system.

Although any number of threads can call the GetQueuedCompletionStatus function to wait for an I/O completion port, each thread is associated with only one completion port at a time. That port is the port that was last checked by the thread.

When a packet is queued to a port, the system first checks how many threads associated with the port are running. If the number of threads running is less than the value of NumberOfConcurrentThreads, then one of the waiting threads is allowed to process the packet. When a running thread completes its processing, it calls GetQueuedCompletionStatus again, at which point the system can allow another waiting thread to process a packet.

The system also allows a waiting thread to process a packet if a running thread enters any wait state. When the thread in the wait state begins running again, there may be a brief period when the number of active threads exceeds the NumberOfConcurrentThreads value. However, the system quickly reduces the number by not allowing any new active threads until the number of active threads falls below the specified value.

즉 이 파라미터는 GQCS()에서 리턴되어 I/O completion 을 처리하는 쓰레드의 최대 동시 실행 개수를 의미한다. GQCS()를 호출하는 사용자 쓰레드의 개수에는 제한이 없지만, IOCP 자체에서 리턴시키는 개수를 이 파라미터로 제한하게 된다. 따라서 이 개수가 커지면 CPU 의 부하가 심해지므로 보통 0 을 넣어 자동화하거나 CPU 개수의 2배를 사용한다.

리소스 관리

Network Programming for Microsoft Windows 2000에 나오는 리소스 관련 부분을 적당히 번역해봤다.

IOCP 를 사용한 Windows 서버를 개발할 때에는 locked pages 제한과 non-paged pool 제한에 유의해야 한다. 그 중 locked pages 제한이 non-paged pool 제한보다는 덜 위험하고 해결하기도 더 쉽다.

Locked Pages Limit

모 든 Overlapped I/O 에 있어서 WSABUF 의 메모리 공간은 locked 된다. 메모리가 lock 되면, 이넘들은 page out 되지 않는다. OS 레벨에서 locked page 의 최대 제한이 존재한다. 이 제한에 도달하면 이후의 Overlapped I/O 에서는 WSANOBUFS 에러가 리턴된다.

이때, 최대 연결의 개수가 중요한 서버에서는 0 bytes overlapped I/O 기법을 사용할 수 있다. 이 기법에서는, 버퍼를 제공하지 않으므로 locked 되는 메모리가 발생하지 않는다. (읽기 요청이 완료될 때 nonblocking receive 를 하는 방식이다.) 단, 클라이언트와 서버간의 통신 방식에 따라서 좋은 방법을 골라야 한다. 만약 클라이언트가 데이터를 열라 보낸다면, 잦은 Overlapped Input 호출이 있을테니..

(WSABUF[0].buf 가 가리키는 메모리 공간에 lock 이 걸리는 것은 당연하다만, OVERLAPPED 구조체가 존재하는 메모리에도 마찬가지일까? 윗 글에 따르면 그건 아닌 듯한데.. 냠..)

또 중요하게 고려해야 하는 것은, 시스템의 페이지 사이즈이다. lock 을 걸 때에는 페이지 단위로 걸게 되는데, x86 에서의 페이지 크기는 4K 의 배수이다. (조사해보니 x86 에서는 4K 였다) GetSystemInfo() 를 사용하면 페이지 크기를 알 수 있다.

Non Paged Pool Limit

non-paged pool 제한은 보다 위험하고, 복구하기도 어렵다. non-paged pool 은 항상 물리적 메모리에 존재하고, 절대 page out 되지 않는 메모리 영역이다. 커널과 각종 드라이버들이 이 영역에 속한다. 또한 각 소켓 역시 non-paged pool 에 소켓 상태를 저장하기 위한 약간의 메모리를 소비하게 된다. 특정 주소에 bind 될 경우, 주소 정보를 위한 추가적인 메모리가 더 필요하다. 소켓이 연결되면 remote address 관련된 메모리도 필요해진다. 모두 합하면, 연결된 소켓은 2k 정도, accept/AcceptEx 로 리턴된 소켓은 1.5k 정도의 non-paged pool 공간을 차지한다. (accepted 소켓은 단지 리모트 주소만 필요하기 때문에 약간 더 작다) 또한, 각 overlapped I/O 연산은 500 바이트 정도의 메모리 공간을 필요로 한다. (500 바이트는 혹시 OVERLAPPED 구조체를 위한 공간이 아닐까?)

일 반적으로 non-paged pool 은 물리적 메모리의 1/4 이며, W2k 에서는 256M, NT4 에서는 128M 의 최대 제한을 가진다. 256M 의 경우라면 5만 정도의 연결을 처리할 수 있지만, Accept/Send/Receive Overlapped I/O 의 개수가 제한된다는 점에 주의하라. accepted 소켓당 1.5k 이라고 하면, 소켓 자체로만 75M( 50000 x 1.5k )를 차지하며, 0 bytes receive 기법을 사용한다면 25M의 메모리를 차지한다.

만 약 시스템의 non-paged pool 이 한계에 도달하면, 운이 좋다면 WSANOBUFS 에러를 리턴하지만 그렇지 않으면 crash 될 것이다. 이를 복구하거나 예측(혹은 모니터링)한다는 것은 거의 불가능하기 때문에 개발자 스스로 최대 동시 접속수라든지, 최대 overlapped 연산의 숫자 등을 테스트해볼 필요가 있다. 만약 WSAENOBUFS 에러를 만난다면 연결을 닫는다든지 해서 줄이는 것이 좋을 것이다...

see also:

  • http://pl.atyp.us/content/tech/servers.html

comments powered by Disqus