출처 : http://blog.joins.com/media/index.asp?uid=jacklee2



기초적인 IOCP 서버 개발 팁. 연결에서 종료까지...

0. 성능 향상을 위한 참고사항
a. AcceptEx(), GetAcceptExSockaddrs(), TransmitFile(), ConnectEx() 함수를 가급적 mswsock.lib 링크를 통해 mswsock.dll 에서 import하여 호출하지 말고 직접 함포를 얻어내 처리할 것.
환경에 따라선 mswsock.dll 이 해당 함수를 export하지 않을 수 있으며(XP PRO SP2의 경우 뭐 물론 이걸로 서버 돌릴 일은 그리 없다만서두...),
mswsock.dll에 실제 코드가 있는게 아니라, 매 호출시마다 WSAIoctl() 로 함포를 꺼내 호출하는 wrapper라서 씨잘데기 없이 오버헤드가 발생한다.
예제)
LPFN_ACCEPTEX lpfnAcceptEx=NULL; // 타입은 MSWSOCK.H 에 정의되어있다.
GUID GuidAcceptEx=WSAID_ACCEPTEX; // WSAID_ACCEPTEX 또한 MSWSOCK.H 에 정의되어있다.
WSAIoctl(s,
SIO_GET_EXTENSION_FUNCTION_POINTER,
&GuidAcceptEx,
sizeof(GuidAcceptEx),
&lpfnAcceptEx,
sizeof(lpfnAcceptEx),
&dwBytes, NULL, NULL );

b. IFS 와 socket provider 간의 모드 전환에 의한 오버헤드가 발생하는걸 막기 위해 ReadFile()/WriteFile() 대신 WSARecv()/WSASend() 함수를 사용한다.
AcceptEx(), ConnectEx() 로 연결된 소켓은 별도의 처리 없이도 WSARecv()/WSASend() 를 쓸 수 있다.
가끔은 msdn.microsoft.com 에서 최신정보도 찾아보자. 옛날 MSDN 보고 질문부터 하지 말고-_-;

c. 메모리 풀을 쓰고, 임계영역을 최소화하여 개발하라.
동시성을 가진 코드는 빠른 속도보다는 임계영역을 최소화할 수 있도록 코딩하고 스핀락을 걸어라. 특히 임계 영역 내에서 작업을 하도록 하지 말고, reserve 후 commit 하는 구조로 제작하라.
환형 큐는 이러한 조건을 만족하는 가장 이상적인 자료구조다. 그러나 STL의 선택은 최악이다. 귀찮더라두 병렬처리용 자료구조 템플릿을 만들어 쓰는게 좋다....
스핀락 정수 영역은 가급적 다른 데이터와 떨어진 주소공간에 배치하는게 좋다.
임계영역에서 작업중 동시에 접근하는 타 프로세서의 간섭을 피하기 위해 가급적 임계영역 데이터와는 떨어진 별도의 4k 주소공간 영역에 따로 모아놓는다.
물론 물리적 페이지가 논리적 주소와 별도로 인접했다면 소용 없을지 모르지만, 모든 메모리를 순차적으로 한꺼번에 초기화하는 서버의 특성상 그럴 일은 거의 없다.

d. 설치될 서버 환경에 맞게 각 쓰레드의 processor affinity 를 적절히 배치하고, 개별 쓰레드가 접근하는 메모리 영역을 각각 집중시켜 캐쉬 사용을 최적화하라.
가급적 포인터/인덱스 등의 집약된 데이터로써 대부분의 처리를 수행하도록 하고, 분산된 데이터는 최종적으로 접근하게 한다.
그리고 각 쓰레드의 시간상 메모리 접근이 분산되지 않도록 주의한다. 되도록 서비스 프로세스에서는 주소공간상 순차적으로 메모리 접근이 진행되도록 한다.
이를 위해 가급적 힙 상의 동적 할당 대신 전역변수 및 풀로써 모든 객체를 생성하라.
또한 프로세서간의 캐시 동기화 간섭이 발생하는걸 최소화하기 위해, 서로 다른 프로세서가 접근할 데이터들 사이엔 64k 이상의 간격이 발생되도록 배치한다.
이 부분은 코딩이 아니라 아키텍쳐 설계시부터 고려되어야 할 사항이다.
(이게 싫으면 C#, JAVA, PHP 쓰자... 아니 이 좋은 세상에 무슨 큰 죄를 지었다고 c++ 쓰는데?)

e. 분기문 작성시 파이프라인 초기화를 최소화하도록 하라.
분기문 없애란 소리가 아니다. IA-32는 분기문도 예측한다. 그 예측의 적중률이 높도록 코딩한다. (물론 말은 쉽지-_-;)
(조건 전방점프 외엔 모두 예측대상으로 간주하다. 예를 들어 if() {} 의 경우 블럭 내의 코드가 실행되리라 예측한다. for/while 루프는 루프가 반복되리라 예상한다. )
간접호출이 아닌 함수는 예측 대상이 된다면 depth가 너무 깊지 않도록(대략 10단계 정도) 한다.
switch() 문에 사용될 case 문 상수는 가급적 0부터 연속으로 정의되도록 한다.
virtual method, 함포의 호출 횟수는 가급적 최소한으로 줄인다. 간접호출/점프는 대부분 분기 예측 실패를 발생시킨다.
암튼 하고 싶으면 하고, 싫으면 말고-_-; 근데 부하가 순간적으로 몰릴 땐 확실히 차이가 있긴 있다.

f. 서버 개발은 아키텍쳐에서 80을 먹고 버그까지 잡은 후 들어간다. 서버 개발자가 이런저런 컴포넌트나 라이브러리 줏어모으며 코딩부터 하는 스크립터 흉내를 내고 있다면 대략 낭패-_-;



1. 소켓 생성과 연결 처리
소켓 풀을 생성한다. 미리 최대 동접자만큼의 소켓을 만들며, 모든 소켓을 미리 WSA_FLAG_OVERLAPPED 로 생성하고 iocp 에 등록한다.
소켓 풀에 생성할 소켓의 수를 정할 땐 TransmitFile(TF_DISCONNECT | TF_REUSE_SOCKET) 후 TIME_WAIT 상태로 graceful closing 완료통보를 대기하게 될 경우도 고려하여 여유분을 둔다.
만약 ConnectEx() 로 연결할 클라이언트 소켓이라면 반드시 setsockopt(SO_REUSEADDR) 로써 포트를 재사용시키도록 한다. 이유는 '연결 종료 처리' 에서 설명한다.

그리고 AcceptEx() 를 사용하여 비동기로 처리한다. 이 때 포트당 AcceptEx()로 너무 많이 삽입하지 않고 적절한 수를 유지시키도록 한다.(보통 32개 정도)
이렇게 한번 iocp에 등록된 소켓은 나중에 TransmitFile(TF_DISCONNECT | TF_REUSE_SOCKET) 로 재사용한다 하더라도 다시 iocp에 등록할 필요가 없다.

이렇게 연결되어 초기화되는 순간 소켓 초기화 처리보다 먼저 해야 할 일이 바로 AcceptEx() 에 의한 다른 연결자 소켓의 공급이다.
만약 msdn의 AcceptEx()나 ConnectEx() 후 사용 가능한 API 목록에 없는 일반적 socket api를 사용할 계획이라면 반드시 setsockopt( SO_UPDATE_ ACCEPT_CONTEXT ) 로써 초기화해줘야 한다.

ConnectEx()로 연결했다면 getsockopt(SO_CONNECT_TIME) 으로 연결 성공 여부를 확인해야만 한다.

tip) 만약 프록시같은 서버에서 다수의 ConnectEx()를 통한 클라이언트 소켓을 확보해야 한다면 아래의 레지스트리 값을 등록하여 ephemeral port 범위를 증가시키도록 한다.
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters
변수형 : DWORD
변수명 : MaxUserPort
범위 : 10진수 5000 ~ 65534
참고 사항 : 등록 후 재부팅해야 적용된다. ephemeral port 는 각각 커널의 메모리를 할당하므로, 너무 큰 값으로 올리면 커널의 메모리를 소진하게 된다. 필요한 만큼만 올릴 것.


2. WSARecv, WSASend 함수는 항상 요청한 순서대로 송수신한다. (디스크 IO를 하더라도 항상 먼저 요청된놈이 앞쪽의 데이터를 읽거나 쓴다.)
그러므로 tcp window를 제거하는 대신 미리 WSARecv()를 여러번 호출하여 버퍼를 공급할 수 있으며, 송신 또한 미리 블러킹 없이 요청할 수 있다.
하지만 이들 송수신 요청에 대한 완료통보는 항상 순서대로 오지 않는다.
그러므로 완료통보의 순서를 재정렬할 매커니즘이 필요하다. 이는 수신 뿐만 아니라, 송신 실패시 송신을 순서대로 재개할 경우에도 필요하다.


3. WSASend 는 일부만 전송할 가능성이 있다.
IOCP 를 쓰면 zero-copy를 구현하기 위해 socket 송수신 윈도우를 0으로 맞춘다.
그러므로 WSASend 로 공급된 버퍼가 바로 window가 된다.
만약 WSASend 로 공급된 송신측 윈도우보다 수신측 윈도우가 작은 상태에서 수신 윈도우를 채우자 마자 shutdown(recv|send)을 하여 ack과 함께 fin을 보낸다면 일부만 전송된다.
물론 전송된 양에 대한 ack이 오며, 이 ack으로 온 송신량이 GQCS 함수의 전송량 파라미터로 들어온다.
연결 종료시 전송을 재개하여 완전히 마무리지어야 한다면 반드시 염두해둘 것.


4. 연결 종료를 감지하는 시점.
WSARecv 완료통보가 0바이트 수신으로 올 때. 일반적으로 이게 먼저 온다.
WSASend 완료통보의 송신량이 0이거나 요청량보다 작을 때. 이런 경우는 거의 발생하지 않지만, 절대 발생하지 않는건 아니다. 적어도 당신이 교통사고 당할 확률보단 높게 발생한다.


5. 연결 종료 처리
연결 종료를 처리 절차.
a. 요청한 WSARecv() 완료통보가 모두 돌아왔는가?
b. 요청한 WSASend() 완료통보가 모두 돌아왔는가?
c. 위의 조건이 충족되지 않았다면 매 완료통보마다 위의 조건을 검사한다(오래 걸리지 않아 모든 통보가 돌아온다).
d. 위의 조건이 충족되었다면 TransmitFile( s,NULL,0,0,&Overlapped,NULL, TF_DISCONNECT | TF_REUSE_SOCKET ) 로써 연결 종료 및 재사용을 요청한다.
이 때 Overlapped 없이 블러킹 콜을 하는 대신, 반드시 overlapped 포인터를 넘겨주고, 완료통보에서 소켓을 풀로 반납하도록 코딩해야 한다.
만약 remote peer보다 먼저 연결종료를 시도하는 경우라면 TransmitFile(TF_DISCONNECT|TF_REUSE_SOCKET)은 fin_1 송신 후 상대방의 ack과 fin_2 를 기다리며 TIME_WAIT 상태에 들어가게 된다. 즉 graceful closing을 시도한다.
만약 overlapped 포인터를 넘기지 않고 블러킹 콜을 시도하는 상황에서 remote peer에 이상이 생겨 fin_2와 ack를 보내지 못한다면, 기본적으로 최대 240초동안 블러킹을 하게 되어 서버 성능에 치명적 영향을 끼치게 된다.
그러므로 반드시 overlapped를 넘겨주어 ack과 fin_2가 도착하거나 TIME_WAIT 상태가 끝나고 완료통보가 왔을 때 풀로 반납해야만 한다. (이건 DisconnectEx()를 써도 마찬가지)
물론 이상이 발생한다면 기본적으로 240초 뒤에 완료통보가 오게 된다.
e. 만약 ConnectEx()로 연결한 클라이언트 소켓을 TransmitFile(TF_DISCONNECT | TF_REUSE_SOCKET) 로 재사용한다면, 이 소켓은 이미 포트에 바인딩되어 있으며 서버로부터 연결 종료 완료통보가 오기 전엔 일정시간 TIME_WAIT 상태에 있음을 명심하라. 그러므로 소켓 풀은 반드시 FIFO 구조로 만들어져서 TIME_WAIT 시간이 지나 할당되어야 한다.
물론 바인딩되어있다 하더라도, 바로 ConnectEx()에 넘겨 재사용하는건 가능하지만 TIME_WAIT 상태에 있을 때 재사용한다면 에러를 발생시킨다.

주의) TIME_WAIT 직접 때려 잡겠다고 절대로 shutdown 쓰지 말 것. 그럼 TF_REUSE_SOCKET 을 통한 소켓의 재사용이 불가능해진다.

tip) TransmitFile() 을 통한 소켓의 재사용은 치명적 단점이 있다.
악의적 클라이언트가 고의적으로 연속적인 비정상 연결종료를 시도할 경우 240초 내에 소켓 풀 내의 모든 소켓이 고갈될 가능성이 있다.
이를 해결하기 위해 TIME_WAIT 시간을 기본값 240초에서 줄이고 싶다면 아래의 레지스트리 값을 등록한다.
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters
변수형 : DWORD
변수명 : TcpTimedWaitDelay
범위 : 10진수 30 ~ 300 (초)
참고 사항 : 등록 후 재부팅해야 적용된다. 0으로 잡을 경우 네트워크 딜레이에 의한 지연 전달을 전혀 잡지 못하므로 3초 ~ 5초 정도로 잡자.
분산 DOS 공격을 걸어온다면... 일단 뭐 서버 내리던가...
공격 감지하면 모드 전환해서 소켓 풀 포기하고 shutdown(snd|rcv) -> 캔슬 -> closesocket() 초필콤보 후 다시 생성하는거 말곤... 길이 없다.
저놈의 TransmitFile이 엄~ 뷰우리플하고 판~타스틱하고 엘레가앙~스~한데다 원더풀하고 굉~장히 그레이스한 연결종료를 포기하는 어우~ 저질스런 몰상식 옵션을 지원하기 전엔... 줴길...