2010년 10월 22일 금요일

Overlapped IO와 IOCP 이야기 - 3-1

3번째 시간이네요. 변함없이 좋다고 해주시니 몸 둘 바를 모르겠네요. ^^: 여하튼 오늘은 별 잡담 없이 시작하죠.

♣ IOCP의 동작 원리
저번 강좌까지는 IOCP 커널 객체를 어떻게 생성시키고 이걸 socket 과 연결시키는 것과 이와 관련된 간단한 쓰레드 동작에 대해 알아 봤습니다. IOCP 초기에 시작하면서 하는 동작이 될 수 있지요. 이번에는 이제 동작원리에 대해 얘기를 해보고자 합니다. 아마 이번 강좌부터 좀 어렵게 느껴질 수도 있을 수도 있을 것 같네요. 좌우지간 최대한 쉽게 이해가 되도록 노력해보겠습니다.

IOCP 가 동작하기 위해서는 여러 가지 자료구조들이 필요합니다. 일전에 보았던 CreateIoCompletionPort 함수에서 Completion Key 와 socket 과의 연결이 있었지요? 그럼 이렇게 연결했다 라고 끝나면 안되겠죠? 그래서 이런 정보들을 저장하기 위한 자료구조들이 필요합니다. IOCP 동작에서 필요한 자료구조는 총 5가지 입니다. 일단 하나하나 보면서 살펴 가봅시다.

1) Device List



자 우선 Device List 입니다. 이것은 위에서 말씀 드렸던 연결에 관련된 자료구조입니다.
List라고 이름 붙어 있는 거 보니 링크드 리스트로 관리 하지 않을까 추측해 볼 수도 있을 것 같네요. 그리고 이 리스트에 저장되는 정보들, 즉 레코드들의 내용은 위 그림과 같습니다. 이 레코드들이 리스트 형태로 주렁주렁 달려 있을 겁니다.
이 레코드의 내용을 가만히 들여다 보세요. 잘 보셨죠? 그럼 위에서 했던 얘기가 기억나실 겁니다. hDevice는 socket, dwCompletionKey는 socket과 연결된 Completion Key가 되겠지요? 자 대충 연결이 되시죠? 그럼 이런 생각의 연결 고리를 떠올리면서 이해해봅시다.
자 보죠. 이렇게 두 내용을 연결 해놓아야만 hDevice와 관련된 IO가 완료되었을 때 그에 관련된 Completion Key, 즉 dwCompletionKey를 우리한테 던져주겠죠? 만약 이렇게 안되고 따로 놀아버리면 어떻게 될까요? IOCP는 IO가 하나 완료되어서 처리하려고 하긴 하는데 이걸 누구에게 요청되었던 작업이 완료되었다고 하고 넘겨줘야 하는 거지? 하고 고민할 겁니다. 그렇겠죠?
그래서 우리는 위 그림과 같이 연결된 내용을 가지고 있다면 IOCP가 이 Completion Key를 찾고 우리에게 넘겨줄 수 있습니다. 그럼 우리는 어떤 device 가 작업을 완료했는지를 구분할 수 있게 되는 거죠.
그럼 Completion Key 에 대한 예를 한번 들어 볼까요? CK-SOCK라고 단지 #define CK-SOCK 1 이라고 해서 이걸 m_sock과 연결했다고 합시다. 그럼 위의 구조는 어떻게 되죠?

(m_sock, CK-SOCK)

이렇게 레코드가 하나 생성되겠죠? 그리고는 IOCP가 이 내용을 관리할 겁니다. 뭐 리스트에 뒷부분에 추가하던지 할 겁니다. 그럼 “hDevice에 어떤 IO가 완료되었다.” 라고 상황이 발생하게 된다고 해봅시다. 그럼 IOCP가 이 내용을 찾아서 이 지정된 CK- SOCK 와 함께 우리한테 던져주는 겁니다. 그럼 우리는 아 CK- SOCK 라는 Completion Key가 넘어왔네? m_sock에서 작업이 완료되었나 보다 라고 생각할 수 있는 겁니다.
여기까진 그냥 예로써의 상황이었습니다.

♤ Completion Key를 실제로는 어떻게 정하는가?
실제적인 프로그래밍에서는 어떻게 써 볼 수 있는지를 보겠습니다. 이 Completion key라는 것이 내용의 제한이 없습니다. 그래서 여러 가지로 쓸 수가 있지요. 뭐 Completion Key 에 accept 된 Socket 을 지정했다고 합시다. 그럼 CreateIoCompletionPort 함수에는 소켓 변수가 두 번 쓰이겠죠? 그럼

(m_sock, m_sock)

이런 레코드가 생성될 수 있겠네요. 그리고는 리스트에 달려 있겠네요. 그럼 m_sock 에 어떤 IO를 요청한다고 해봅시다. 그럼 이 IO가 완료된다면 m_sock의 내용을 그대로 던져 주겠죠? 그럼 우리는 이걸 가지고 어떤 소켓이 IO 가 완료되었는데? 아하 m_sock 이었구나! 라고 알 수 있는 겁니다.

그럼 이 부분은 어떻게 되는지 대충 이해가 되셨지 않았을까 하는군요. Completion Key에 대한 얘기를 조금만 더 해보죠. Completion Key가 인자에 들어갈 때 ULONG_PTR이라고 보셨을 겁니다. 이것의 정의는 각자 찾아보시고, 이것은 크기로 따지자면야 DWORD랑 틀리지 않습니다. 즉 4bytes 라는 거죠. 그리고 IOCP는 여기에 어떤 내용이 들어가든 관계하지 않는다고 말했습니다. 그럼 우리는 활용을 생각해 볼 수 있겠네요. 여기다 포인터를 넘겨줘도 되겠죠? 제가 예를 하나 들어보죠.
struct PerHandleData
{
SOCKET sock;
char clientid[8];
};

자 이런 식의 구조체가 있다고 생각해봅시다. 그럼 이것을 new로 하고 나온 포인터를 이 Completion Key 에 넘길 수 있겠죠? 어차피 포인터도 4bytes 이니까요. 그럼 완료되어 연결되어 나오는 Completion Key도 Pointer 값이고, 이거 가지고 접근하면 어느 소켓에서 그랬는지 이 소켓과 관련된 client의 id는 뭔지 등등의 정보를 얻어낼 수 있습니다. 그래서 나름대로 PerHandleData 라고 이름을 붙여봤습니다. PerSessionData라고도 해도 될 듯 하군요. ^^ 뭐 여하튼 이렇게 한다면 나름대로 유용하겠죠? 아니라고요? 동의 못하시겠다면 어쩔 수 없지만요. ^^::
물론 위에서 말했던 바대로 socket만 집어넣고 나머지는 다른 곳에서 얻는 방법도 있습니다. 뭐 여하튼 결론을 말해보자면 프로그래밍 하는 사람 맘이라는 거죠. 하지만 위와 같은 구조체를 사용한다면 얼마던지 필요한 내용을 꺼내서 쓸 수 있도록 할 수 있을 것입니다.

♤ 정리
그럼 슬슬 이 자료구조가 도대체 언제 생성되고 언제 없어지는 지도 알아봐야 할 것 같네요.
간단히 정리해보자면 다음과 같습니다.

생성: CreateIoCompletionPort가 호출될 때
(기존 IOCP 포트와 socket등의 device 객체를 연결할 때를 말합니다.)

제거: hDevice에 지정된 핸들이 Close될 때
(소켓이라면 closesocket을 할 때이고 그 외라면 CloseHandle() 할 때입니다.)





2) IO Completion Queue

2번째 자료구조는 IO Completion Queue 입니다. 뭐 좀 줄이고 바꿔서 IOCP 큐라고 부르겠습니다.(이래도 되겠죠? ^^:)
이 자료구조는 IO가 완료가 되면 그 관련 정보를 저장하는 자료구조입니다. FIFO라고 적혀있고 또 큐라고 하니 처음 들어간 내용이 처음 나오는 그런 구조겠지요? 그리고 이 큐에서는 레코드 인스턴스들이 유지 됩니다. 무슨 레코드인데 라고 물으신다면 물론 방금 말한 대로 IO하나가 완료되면 이 IO가 끝났을 때 그 끝난 결과를 레코드로 만들어서 가지고 있게 되는 겁니다. 그럼 그 내용을 한번 보죠.





위 그림의 첫 번째에 보세요.
dwBytesTransferred, 즉 얼마만큼 전송이 되었는가 입니다. 조금 바꿔 말하자면 IO가 이루어진 바이트 수는 얼마인가? 라는 겁니다. 여기서 이 값이 만약 0이 넘어온다면 어떻게 될까요? IO를 요청해서 완료했는데 0이더라? 그럼 소켓의 경우는 연결이 끊겨버린 경우겠네요? 그럼 이것을 보고 소켓 끊김을 체크해줘서 closesocket 해주시면 됩니다.
다음 두 번째, dwCompletionKey 입니다. 이름에서도 예상하셨듯이 이건 Completion Key 입니다. 첫 번째 자료구조에서 말했던 바대로 우리가 IO 완료 상황을 처리하려면 어떤 device 에서 완료가 되었는지를 알아야 할 겁니다. 그래서 Completion Key 가 넘어온다면 우리는 어떤 디바이스에서 또는 Winsock 에서는 어떤 소켓에서 완료가 되었는지를 알 수 있는 겁니다. 그러면 이 완료된 내용에 따라 그에 따른 적절한 처리를 해 줄 수가 있겠죠?
pOverlapped, 이거 제가 누누이 얘기했던 그 Overlapped 구조체 입니다. IOCP가 동작하려면 overlapped IO가 필요하고, 이것은 기본적으로 이 overlapped 구조체를 사용하기 때문에 이것이 넘어오는 겁니다. 이 Overlapped 구조체는 보통 확장하여서 사용합니다. 그 얘기는 조금 있다 하죠.
dwError, 말이 필요 없겠죠? 에러에 대한 내용입니다.
자 그럼 이 자료구조와 관련되는 동작들을 봅시다. Overlapped IO를 하나 요청했습니다.
그리고는 그것이 완료되었죠. 그럼 커널이 IOCP와 연결된 device, winsock 에서는 socket 이겠죠? 이걸 찾습니다. 만약에 IOCP와 연결된 것이 존재한다면, 이 완료되는 IO에 대한 정보를 모아서 위 그림과 같은 레코드를 만듭니다. 그리고는 그 IOCP와 관련되는 IOCP 큐, 즉 IO Completion Queue에다 집어넣습니다. 물론 FIFO니까 뒤에다 집어넣습니다. 그럼 IO가 완료된 차례대로 들어가게 될 겁니다. 그럼 우리가 이것을 하나하나 빼서 처리하는 겁니다. 물론 이 처리는 저번 강좌에서 말했던 IOCP Worker Thread 에서 합니다.
예를 들어 볼까요? 예를 들어 sock이라는 소켓에 recv작업이 완료되었다고 하죠. 정보를 한번 정해볼까요? 한 10bytes recv 되었다고 하고, 에러는 없었다고 합시다. 아 그리고 sock과 관련된 Completion Key는 그냥 sock 이라고 합시다. 그럼

(10, sock, pOverlapped, 0)

이런 레코드가 하나 만들어 집니다. 그리고는 이 레코드 내용이 IOCP 큐로 들어가는 거죠.
그럼 IOCP Worker Thread 중 하나 이 레코드를 큐에서 꺼냅니다. 그럼 이 내용을 처리하는 거죠. 보자 10바이트가 전송되었네? 그리고 sock 라는 소켓에 요청한 recv 작업이 그렇게 된 거고, 에러는 없네. 라고 해석이 가능한 겁니다. 자 이렇게 예를 들었으니 어느 정도 이해하셨지 않았나 싶네요. 그럼 슬슬 궁금한 게 생기시는 분들이 있으실 겁니다. 그럼 정작 중요한 데이터는 어떻게 알아내지 하는 거 말입니다. 그게 이제 말할 Overlapped 구조체 확장에 관련된 내용입니다.

♤ OVERLAPPED 구조체의 확장
보통 IOCP 프로그래밍 하면서 Overlapped 구조체를 확장 많이 합니다. 물론 IOCP로 하지 않고도 그렇게 쓸 수 있는 통보 방식도 있습니다. 뭐 그건 중요한 것이 아니고 일단 예를 하나 들어보죠.

struct PerIoOperationData
{
OVERLAPPED ov;
WSABUF buf;
char buffer[4096];
};

자 이렇게도 가능하고 또 다음과 같이 해도 좋습니다.

struct PerIoOperationData: public OVERLAPPED
{
WSABUF buf;
char buffer[4096];
};

어느 쪽을 하셔도 좋습니다. 이건 프로그래밍 스타일에 관련된 겁니다. 단 아래 것은 C++ 에서만 되겠죠?
그래서 위와 같이 이렇게 확장해서 쓸 수 있다는 겁니다. 그래서 여기 있는 버퍼를 WSARecv 나 WSASend할 때 지정해 주는 겁니다. 만약 recv 동작이고 IO 완료 레코드가 IOCP 큐에 들어가 있는 상황이라면, 이미 여기에 외부에서 온 데이터들이 저장되어 있는 것이 되죠. 그래서 이 내용을 가지고 IOCP Worker Thread 에서 처리해주면 됩니다.
그럼 위에서 한 얘기에서 Completion Key는 socket으로 하고 그 외 정보는 다른 곳에서 얻는 방법이 있다고 한 말 기억나시죠? 이 다른 곳이 여기 입니다. 여기에다 필요한 내용을 더 저장하고 뽑아 쓸 수 있습니다. 참 그리고 이거 인자로 지정해줄 때는 위의 구조체라면 내부 엑세스를 하여 Overlapped 구조체를 지정해 줄 수 있고, 아래의 구조체라면 캐스팅을 한다면 가능하겠죠? 이 정도는 금방 이해가시리라 봅니다.

♤ PostQueuedCompletionStatus?
자 그럼 IO가 완료되고 이것이 어떻게 내용이 우리에게 알려질 수 있는 가에 대한 얘기를 했습니다. 물론 이와 관련된 자료구조들도 얘기했죠? 그럼 한가지 생각이 떠오르시는 분도 있지 않을까 하네요. IOCP 큐가 있는데 여기에 우리가 내용을 직접 넣을 수는 없을까 하는 그런 의문이요. 물론 저번 강좌를 열심히 읽으셨던 분이라면 제가 InterThread Communication 에 대한 얘기를 하면서 이에 대한 얘기를 조금 내비쳤던 걸 기억하실 겁니다. 물론 이것에 대한 대비가 되어 있죠. 이에 관련된 API가 PostQueuedCompletionStatus 입니다. 이름을 봐도 Post한다. 보낸다 라는 느낌이 들지요?
그럼 이 API도 한 번 살펴보죠.

BOOL PostQueuedCompletionStatus (
HANDLE CompletionPort, // handle to an I/O completion port
DWORD dwNumberOfBytesTransferred, // bytes transferred
ULONG_PTR dwCompletionKey, // completion key
LPOVERLAPPED lpOverlapped // overlapped buffer
);

MSDN에 있는 내용 가져왔습니다. 보시면 어떤 느낌이 드시나요? 왠지 위에 있던 그림에 있는 레코드 내용이랑 같다고 생각이 들지 않으시나요? 예 그렇습니다. 거의 같습니다. 이렇게 되야 큐에 레코드를 만들어 집어넣는 것이 되죠. 인자도 위에 있는 설명이니까 말씀드릴 필요가 없겠죠? 단 첫 번째 인자는 설명을 해야 되겠네요. 아마 다 이해하시지 않을까 합니다만 큐에다 집어넣는데 어느 IOCP 객체의 큐냐를 말해주는 인자입니다. 저기다 집어넣을 IOCP 객체의 핸들을 집어넣으면 그 IOCP 객체와 관련된 큐로 다음 3개의 인자와 관련된 내용이 레코드로 만들어져서 가는 겁니다.


♤ PostQueuedCompletionStatus를 어떻게 활용할까?
그럼 이것의 활용을 조금만 더 생각해볼까요? 저번 강좌에서 말했던 경우에는 뭐 예를 들어 Completion Key 부분에 보낼 내용을 집어넣고 반대쪽의 처리 쓰레드에서 이 Completion Key의 내용만을 처리한다면 훌륭한 쓰레드간의 큐가 될 수 있을 겁니다.
또 다른 활용을 생각한다면 IOCP Worker Thread의 종료 처리에 쓰일 수 있습니다. 서버 종료 시에 이 API로 어떤 특정한 Completion Key를 보낸다면 이것을 읽은 Thread 는 리턴 하여 쓰레드를 종료하게 만들어 버리는 겁니다. 그러면 안전하고도 멋지게 쓰레드를 종료시킬 수 있겠죠? 단 잊지 않으셔야 할 점은 다음에 말하겠지만 레코드가 하나 빠지면 그걸로 끝이라는 겁니다. 계속 남아 있지 않는다는 거죠? 그렇다면 쓰레드 여러 개면 당연히 종료 레코드도 쓰레드 개수만큼 보내 야겠죠?

♤ 정리
자 이제 어느 정도 얘기가 끝난 것 같군요. 이제 이것이 생성되고 삭제되는 경우에 대해 알아볼 시간이네요.

생성: IO 요청이 완료되었을 때
PostQueuedCompletionStatus를 호출하였을 때
제거: Waiting Thread Queue로부터 Entry를 하나 제거할 때
(이 WTQ는 뒤에 말하겠습니다. 즉 말하자면 IOCP Worker Thread를 하나 깨우고
이 쓰레드에서 레코드 하나를 꺼낼 때 라고 말할 수 있겠습니다.)


3) Waiting Thread Queue, Released Thread List, Paused Thread List

한꺼번에 3개를 말해야 할 것 같군요. 이것은 서로 연관되어 있거든요.
이 세 개는 Concurrent Thread 숫자와 관계가 있습니다. 정확히 말해서는 이 3가지 자료구조를 IOCP Worker Thread 들이 옮겨 다니는 도중에 이 숫자가 관여한다고 말해야 할 것 같네요. 참 자료 구조에 Thread, Thread 들어가 있는 것 보니까 다 내부에 Thread랑 관련된 내용이 들어있을 것 같으시죠?

일단 자료구조와 그 레코드 내용을 봅시다.





자 이렇게 되어 있습니다. 레코드 내용은 아주 간단하네요. dwThreadID 즉 IOCP Worker Thread의 Thread ID 군요. 즉 아 아이디로만 모든 쓰레드를 관리한다는 뜻이겠죠?

댓글 없음:

댓글 쓰기