예전에 소켓을 만들 때 비동기 처리(?)를 select() 함수를 이용해서 처리했었는데, 이 함수를 사용한 서버는 많은 동시 접속자 및 고성능에는 적합하지 않다고 해서 다른 방법을 찾아보기로 했습니다.
Blocking 이 있으면 모두 동기식입니다.
왜 적합하지 않을까❓#
FD_SET 비효율적인 사용select() 호출 때마다 파일 디스크립터 배열을 다시 세팅해서 전달
- polling 방식
select() 함수 내부에서는 파일 디스크립터를 순회하며 감지함- 접속한 클라이언트가 많아질수록 성능이 떨어짐
- 파일 디스크립터 최대 개수
따라서 고성능 서버에서는 사용되지 않는다.
🔬 IOCP#
💡I/O 완성 포트는 다중 프로세서 시스템에서 여러 비동기 I/O 요청을 처리하기 위한 효율적인 스레딩 모델, 많은 동시 비동기 I/O 요청을 처리하는 프로세스는 I/O 요청을 수신할 때 스레드를 만드는 것보다 미리 할당된 스레드 풀과 함께 I/O 완료 포트를 사용하여 더 빠르고 효율적으로 수행할 수 있습니다. - MSDN
Windows 는 I/O 완료 처리를 커널이 할 수 있도록 만들어 놨고, 그걸 사용하기만 하면 됩니다. 👍
🚀 핵심#
IOCP 관련#
CreateIOCompletePort(): I/O 완료 포트를 만들고 지정된 파일 핸들에 연결하거나, 아직 파일 핸들에 연결되지 않은 I/O 완료 포트를 만들어 나중에 연결할 수 있습니다.GetQueuedCompletionStatus(): 지정된 I/O 완료 포트에서 I/O 완료 패킷을 큐에서 제거하려고 시도합니다. 대기 중인 완료 패킷이 없으면 함수는 완료 포트와 연결된 보류 중인 I/O 작업이 완료될 때까지 기다립니다.PostQueuedCompletionStatus(): I/O 완료 패킷을 I/O 완료 포트에 게시합니다.
비동기 소켓 관련#
WSASocket(): 비동기 소켓을 생성WSARecv()/WSASend(): 비동기 송/수신 처리OVERLAPPED: 비동기 처리 식별자
WSAIoctl(): AcceptEx() 함수를 불러올 때 사용
💡 OVERLAPPED 는 비동기 요청할 때마다 생성 및 초기화하여 같이 요청을 보낸다.
I/O 요청이 완료되어 Callback 되었을 때 어떤 I/O 요청인지 구별할 수 있게 해줍니다.
동적으로 생성하면 오버헤드가 있기 때문에 메모리풀을 사용한다고 합니다. 😄
⌨️ 실사용 예#
IOCP 핸들을 생성하고 스레드풀을 미리 만들어 놓습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // Server.cc
...
bool ServerImpl::init(size_t workerCount) {
bool result = false;
do {
if(WSAStartup(MAKEWORD(2, 2), &_wsa) != 0) {
break;
}
// 처음 서버를 초기화할 때 IOCP 핸들을 만듭니다.
_iocp = CreateIoCompletionPort(
INVALID_HANDLE_VALUE, NULL,0, 0);
// 만들지 못했다면 NULL을 반환합니다.
if(_iocp == NULL) {
break;
}
// 위 설명대로 미리 스레드풀을 만들어 효율적으로 처리합니다.
for(size_t i = 0; i < workerCount; i++) {
try {
_threads.emplace_back(&ServerImpl::worker, this);
} catch(...) {
break;
}
}
...
|
스레드풀(worker())에서는 I/O 완료를 기다립니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // Server.cc
...
int ServerImpl::worker() {
DWORD bytesTransferred = 0; // 전송된 바이트 수
ULONG_PTR completionKey = 0; // Completion Key (소켓 등록 시 전달했던 값)
LPOVERLAPPED overlapped = NULL; // OVERLAPPED 구조체 포인터
BOOL ret = FALSE;
for(;;) {
// IO 완료가 있을 때까지 대기
ret = GetQueuedCompletionStatus(
_iocp,
&bytesTransferred,
&completionKey,
&overlapped,
INFINITE
);
// IO 완료가 되었으면 처리 시작
if(overlapped == NULL && completionKey == NULL) {
// 스레드 종료 신호
break;
}
...
|
클라이언트가 연결을 요청하면 accept 후 미리 만들어 놓았던 acceptSock 에 담겨서 오게 됩니다.
이 때 연결된 소켓은 다시 IOCP 에 등록하여 송/수신 완료 신호를 받게 됩니다.
⚠️ 이 예제에서는 accept() 함수 또한 비동기 처리를 위해 IOCP에 등록해서 사용했습니다.
따라서 Accept I/O 도 worker()로 옵니다.
IOCP 에 클라이언트를 등록한 후 비동기 수신을 위해 WSARecv() 를 요청하고, 다음 클라이언트를 accept하기 위해 AcceptEx()를 다시 호출한다.
1
2
3
4
5
6
7
8
9
10
11
12
| // Server.cc
...
switch(ov->ioType) {
case(IO_TYPE::ACCEPT):
{
// 세션 생성
ACCEPT_OVERLAPPED_CONTEXT *acceptOv = reinterpret_cast<ACCEPT_OVERLAPPED_CONTEXT *>(ov);
Session *newSession = new SessionImpl(acceptOv->acceptedSock);
// 연결이 되었으면 IOCP에 연결된 소켓을 등록
registerSoketToIOCP(newSession);
...
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Server.cc
...
bool ServerImpl::registerSoketToIOCP(Session *session) const {
if(session) {
// worker에서 세션을 식별할 수 있도록 completionKey에 session 세팅
ULONG_PTR completionKey = reinterpret_cast<ULONG_PTR>(session);
// IOCP 에 연결된 소켓 등록
if(CreateIoCompletionPort(
(HANDLE)session->getSocket(),
_iocp,
completionKey,
0) == _iocp) {
return true;
}
}
return false;
}
...
|
프로그램이 종료될 때 스레드도 종료될 수 있도록 신호를 보냅니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // Server.cc
...
void ServerImpl::destroy() {
closeListenSock();
// 스레드 종료 시그널
// 워커 수 만큼(IO 대기) CompletionKey 및 Overlapped 에 NULL을 전달
for(std::thread &th : _threads) {
PostQueuedCompletionStatus(_iocp, 0, NULL, NULL);
}
for(std::thread &th : _threads) {
if(th.joinable()) {
th.join();
}
}
_threads.clear();
// IOCP 핸들 해제
if(_iocp != NULL) {
CloseHandle(_iocp);
}
WSACleanup();
}
|
- Windows 고성능 서버 구현 시
select()가 아닌, 커널의 I/O 완료 프로세스인 IOCP 를 사용한다. - Linux 에서는
epoll 을 사용하여 고성능 서버를 구현하고 epoll 은 I/O 완료 신호가 아닌, I/O 준비 신호가 온다. - 고정된 길이의 데이터를 받더라도 TCP 는 데이터가 끊겨 들어올 수 있기 때문에 처리를 해야함
- I/O 완료 신호라 끊겨서 들어오더라도 소켓 버퍼에서 꺼내서 스레드에 던짐 (4KB 를 받겠다고 해도 3 KB 를 받을 수 있음)
- 동기로 처리했을 때는 MSG_PEEK 를 이용해서 용량이 차지 않으면 꺼내지 않았었는데, 고민해 봐야 함
- Session 에서 읽기 및 쓰기를 비동기로 구현해야 함
- 어려움