2010년 10월 22일 금요일

Overlapped IO와 IOCP 이야기 - 3-2

♤ Waiting Thread Queue부터 알아보자
먼저 Waiting Thread Queue 에 대해 알아봅시다. 이건 제가 저번에 그림까지 그려가면서 말씀 드렸던 쓰레드 풀이라고도 불 수 있겠습니다. IOCP Worker Thread 들이 생성되면 여기에 차곡차곡 저장이 되는 겁니다. 아니 저장이라고 말하긴 좀 그럴까요? 하지만 그렇게 하는 것이 더 이해가 쉬울 것 같네요.
일단 이 쓰레드 풀이 어떻게 생성되는지부터 살펴봅시다. IOCP 소스를 한번이라도 분석해보신 분들은 알겠지만 쓰레드 함수에 위쪽 근처에 있는 코드에서 가장 먼저 불리는 함수가 GetQueuedCompletionStatus라는 함수가 라는 걸 기억하실 겁니다. 이 함수 이름을 보니 어떤 생각이 드세요? 이전에 말했던 PostQueuedCompletionStatus라는 함수와 이름이 대비된다고 느끼실 겁니다. 네 그렇습니다. 이 두 함수의 작용은 서로 대비됩니다.
제가 PostQueuedCompletionStatus 함수는 큐에다 어떤 레코드를 집어넣어 주는 거라고 말씀 드렸습니다. 그럼 GetQueuedCompletionStatus 함수는 반대로 큐에서 빼는 거라고 생각하시겠죠? 맞습니다. 큐에서 IO 완료가 오면 이 함수가 리턴 되면서 하나를 뺴오는 겁니다. 그럼 레코드의 정보가 GetQueuedCompletionStatus 함수의 인자로 나오는 거죠. 그럼 GetQueuedCompletionStatus 함수를 한번 볼까요? (이하, GetQueuedCompletionStatus 함수는 GQCS함수라고 부르겠습니다.)

BOOL GetQueuedCompletionStatus(
HANDLE CompletionPort, // handle to completion port
LPDWORD lpNumberOfBytes, // bytes transferred
PULONG_PTR lpCompletionKey, // file completion key
LPOVERLAPPED *lpOverlapped, // buffer
DWORD dwMilliseconds // optional timeout value
);

자 GQCS함수입니다. 보시면 두 번째에 나온 자료구조의 레코드 내용이 있다는 것을 볼 수 있으실 겁니다. 2,3,4번째가 되겠네요. 2,3,4번째가 포인터로 되어 있는 이유는 아시겠죠? Out Parameter이기 때문입니다. 이 강좌 보실 분들이라면 이 정도는 아실 거라고 생각합니다. 물론 첫 번째 인자는 빼내올 IOCP 큐를 지니고 있는 IOCP 객체에 대한 핸들이 되겠습니다. 그리고 마지막 인자는 얼만큼 함수가 기다릴 건지에 대한 내용입니다. 보통 INFINITE를 집어넣습니다. 그러니까 완료 레코드가 없다면 이 함수는 영원히 블럭되어 있다는 얘기입니다. 그리고 리턴값은 MSDN 을 참조하시길 바랍니다. IOCP에서 가장 중요한 함수이기 때문에 꼭 MSDN 을 살펴보세요. 리턴 값은 뭐가 되는지 어떻게 될 때 에러가 뭐고 하는지를 살펴보시길 바랍니다.

자 그럼 이 함수를 왜 쓰레드 풀 얘기하는 데서 말할까요? 그 이유가 뭐냐고 하면 이것이 쓰레드에서 불릴 때 Waiting Thread Queue(WTQ) 에 들어가는 겁니다. 즉 GQCS함수가 쓰레드에서 불린다면 이 쓰레드는 쓰레드 풀로 들어간다고 얘기할 수 있게 되는 겁니다.



(위 그림의 상황같이 이렇게 된다는 겁니다.)

그래서 이 GQCS 함수가 쓰레드에서 불린다면 INFINITE 옵션 시에 블로킹이 됩니다. 그리고는 쓰레드 풀에 들어가죠. 그래서 IO가 요청되어서 완료되고 IO Completion Queue 에 레코드 들어가면 IOCP가 이를 알아채어서 쓰레드 중 하나를 깨웁니다. 그리고는 GQCS함수를 리턴 시킵니다. 좀 전에 얘기했듯이 리턴 할 때는 레코드를 하나 빼서 돌아옵니다. 그리고는 처리하고 다시 GQCS를 부릅니다. 그럼 또 블로킹이 되겠지요?
자 이렇게 한다면 IO가 완료되기를 기다리는 대부분의 시간 동안 쓰레드가 suspend 되어서 CPU도 안 잡아먹고 있게 되죠? 그럼으로써 CPU도 안 잡아먹는 훌륭한 쓰레드 풀이 완성되는 겁니다.

♤ GetQueuedCompletionStatus 함수가 리턴 될 때의 제약 사항
자 그런데 리턴 될 때 제약되는 사항이 있습니다. 그게 뭘까요? 이미 눈치채신 분도 있겠지만 Concurrent Thread 수에 대한 내용입니다. 이 GQCS함수는 큐에 내용이 있으면 IOCP가 쓰레드를 깨우고 그리고는 레코드 하나 가지고 리턴 된다고 했습니다. 그런데 리턴 되는 조건 중에 하나가 더 있습니다. 즉 현재 GQCS함수가 리턴 되어 돌아가고 있는 쓰레드 수가 지정해준 Concurrent Thread 수를 넘어서면 이 함수는 큐에 내용이 있더라도 리턴 이 되질 않습니다. 이 점 때문에 Thread Switching을 효율적으로 관리한다는 것이죠. 즉 Concurrent Thread 수를 넘어서지 않도록 IOCP가 조절하여서 쓰레드를 깨운다는 말입니다. 하지만 이렇다고 하여서 돌아가고 있는 쓰레드 수가 Concurrent Thread 수보다 항상 작지는 않습니다. 이보다 클 수도 있죠. 그것이 IOCP 가 Smart하게 처리된다는 장점이죠. 이 얘기는 좀 있다 하겠습니다.

♤ 정리
그럼 언제 이 WTQ에 쓰레드가 들어오고 또 언제 나가는 지를 한번 정리해볼까요?

WTQ로 들어올 때: ① 쓰레드함수가 GetQueuedCompletionStatus()를 불렀을 때
(즉 IOCP 서버 시작 시에 쓰레드를 만들어 놓을 때에 이 함수가 불리죠?
이 함수가 불림으로써 쓰레드 풀이 만들어지는 겁니다.
그럼 저번 강좌에서 그 쓰레드 풀이 어느 코드에서 만들어지는 거야
하고 궁금하셨던 분들은 이제 풀리실 겁니다.)
② GetQueuedCompletionStatus 함수가 리턴 되어 IO 완료 레코드를
처리한 후에 다시 GetQueuedCompletionStatus함수를 불렀을 때
(이것 또한 별로 어려움 없이 이해되시겠죠?)

WTQ에서 나갈 때: IO Completion Queue가 비어있지 않고(And)
Release Thread List에 있는 쓰레드 수가 지정해준 Concurrent
Thread 수를 넘지 않았을 때
(이런 조건을 만족하여 나가면 IO Completion Queue에서 레코드
entry가 하나 제거되고, WTQ에 있던 dwThreadID값은 Release
Thread List로 옮겨 가게 됩니다. 그리고 나서는 GQCS함수가
리턴 하면서 그 레코드 값을 GQCS함수의 Out Parameter로 내용들을
내보내게 되는 거죠.)

그럼 정리가 끝났죠? 정리가 끝나긴 했는데 뭔가 빼 먹은 것 같은 느낌이 들지 않으세요? 아마 읽으시는 분들 중에는 왜 이건 이렇지? 하는 궁금증이 생기시는 부분이 있으실 겁니다. 그걸 얘기해보죠.

♤ 왜 Waiting Thread Queue가 LIFO 구조를 가질까?
위에서 보면 Waiting Thread Queue해놓고 LIFO 라고 적어 놓았습니다. Queue라고 해놓으면서 왜 LIFO지 저도 궁금합니다만, 그 사람들 그렇게 해놓은 건 저도 알 수가 없군요. 여하튼 이 Queue라면서 LIFO 라고 해 놓은 것도 좀 고개를 기웃거리게 하지만 왜 LIFO가 되야 하는 지가 더 궁금하시죠? 그럼 이걸 연구해 보죠.

LIFO 라고 하면 다들 Stack 이 머리에 떠 오르실 겁니다. 안 떠오르시는 분이 있다면 어디 가서 자료 구조 책을 한번이라도 살펴 보고 오시기 바랍니다. 그런데 제가 WTQ가 쓰레드 풀이라고 얘기를 했습니다. 그럼 쓰레드 풀이 FIFO를 가지고 리스트를 가지든 별로 상관없는 게 아닌가 하는 의문도 가지실 분이 있으시겠네요? 메모리 풀을 구현해보셨던 분들이라면 그거 리스트로 하는 것이 아닌가? 하는 분들도 있지 않으실까 합니다.
Stack 의 구조를 생각해보세요. 가장 위의 것이 먼저 나가고 또 그 다음 것이 나가죠? 이런 구조와 풀이라는 특성을 결합시켜 보세요. 쓰레드가 Stack으로 쌓여 있다고 봅시다. 그럼 쓰레드가 하나 필요하다면 Stack이니까 가장 위에 있던 쓰레드(실제로는 dwThreadID만 거기에 있겠죠?)가 나갈 겁니다. 그리고는 쓰고 나서는 다시 돌아오면 또 가장 위에 쌓이게 됩니다. 그럼 이런 작업이 여러 번 반복되다 보면 항상 쓰던 쓰레드만 쓰일 겁니다. 그렇죠? 가장 위에 있는 것부터 쓰고 또 가장 위로 다시 들어오니까요. 그림으로 그려보시면 가장 쉽게 파악이 되실 겁니다.




(이렇게 그림 그려보았습니다. 정리할 때 도움이 되실런지요?)

자 그럼 이렇게 하는 이유는 뭘까요? IOCP 쓰는 이유랑 비슷하겠죠? 네 바로 성능 때문입니다. 항상 쓰는 쓰레드만 쓰게 처리된다면 쓰레드 풀에 있는 쓰레드들 중에서 Stack 아래 쪽에 있는 쓰레드들은 거의 쓸 일이 없을 겁니다. 즉 Scheduling될 일이 없다는 겁니다. 그럼 OS에서는 이런 쓰레드들이 가지고 있는 자원들 뭐 그러니까 메모리 같은 거 말입니다. (쓰레드 마다 stack 이 따로 있죠? 그 stack 메모리 등등을 가리킵니다.) 이런 메모리들의 내용이 하드로 Swap 되게 됩니다. 그러니까 안의 내용은 하드 디스크에 다 써지고 실제 메모리에 있던 것들은 없어진다는 거죠. 그리고 또한 프로세서의 캐쉬에서도 flush되고요.
이러면 쓰레드 풀에 아무리 많은 쓰레드 들이 있다고 해도 실제로 메모리 차지하는 건 자주 쓰이는 위에 몇 개의 메모리 뿐이지요. 그래서 대비로 쓰레드 풀에 여러 개를 많이 넣어 놨다고 해서 그게 성능에 영향을 미치지 않는다는 말입니다. 만약에 IO가 완료되는 것이 아주 느려서 쓰레드 하나만 사용된다고 해보세요. 실제로 우리가 thread들을 100개 만들어 놓는다고 하더라도 쓰이는 건 하나가 되 버리니까 메모리에 유지하고 있는 건 쓰레드 하나면 된다는 겁니다.
그런데 아직 잘 이해가 되시지 않는 분들도 있으실 지도 모르겠군요. 그럼 거꾸로 생각해봅시다. 만약에 LIFO 가 아니라 FIFO 구조로 있다고 생각해보세요. 그럼 쓰레드가 큐 front 에서 하나씩 빠져나가겠네요. 그럼 LIFO처럼 쓰던 것이 계속 쓰이는 것이 아니라 그 큐에 들어 있는 모든 쓰레드들이 언젠가는 다 한번씩은 쓰여지는 것이 되겠죠? 그럼 쓰레드들이 좀 많다고 생각해보면 큐 저 뒤 쪽에 있던 것들은 오랜 시간 쓰이지 않았으니까 OS에 의해 Swap이 됩니다. 그런데 FIFO 구조이니까 이 쓰레드들이 반드시 한번은 쓰여지게 되는 겁니다. 그럼 하드로 Swap된 메모리 내용을 다시 불러와야 하겠죠? 그럼 디스크 IO가 이루어지게 됩니다. 디스크 IO가 이루어진다는 뜻은 곧 성능이 떨어진다는 것을 의미합니다. 하드가 메모리나 CPU보다 느린 거는 아시죠? 첫 번째 강좌에서도 얘기를 해드렸습니다. 그럼 문제가 되는 겁니다. 이것이 계속 진행되다 보면 계속 Disk IO가 이루어지게 되는 거죠. IO가 이루어져서 하나 빼오고, 그리고 또 다음 번에는 또 하드에 있을 것이므로 또 빼오고요. 운영체제를 배우신 분들이 보면 LRU 등등이 나올 때 FIFO 가 나오죠? 그 때의 FIFO의 단점을 생각해보셔도 좋을 듯 합니다.
이것을 LIFO로 해보면 아무리 많이 있다고 해도 최악의 상황이 아니고서는(물론 이 최악의 상황을 위해서 쓰레드 풀에 쓰레드 들을 만들어 놓는 것이지만요.) Stack 아래쪽에 있는 것은 거의 쓰이지 않을 겁니다. FIFO에 비해서 Disk IO가 상대적으로 많이 줄어들겠죠? 한번 두 자료 구조를 그림을 그려놓고 생각해보시면 아실 거라고 봅니다.
제가 한번 그림을 그려 보았는데 이해가 되게 잘 그렸는지 모르겠군요. 한 번 참고 삼아 보시길……



자 이런 얘기입니다. 그런데 전달이 제대로 되는지는 모르겠네요 ^^:

이번 3번째 강좌는 여기까지 하죠. 얘기가 더 이어져야 하는데 끊겼으니까 다음 쓰는 대로 바로 올리겠습니다. 그럼 다음 강좌에서 ……

댓글 없음:

댓글 쓰기