출처 : http://fattarzan.tistory.com/entry/win-api-%EB%A9%80%ED%8B%B0%EC%8A%A4%EB%A0%88%EB%93%9C-%EA%B0%9C%EC%9A%94
1. 소개
메인프레임이나 워크스테이션에서는 멀티스레딩을 오래 전부터 사용해 왔지만, 개인용 컴퓨터에서는 이제서야 새로운 기능으로 주목받고 있다. 윈도우 NT 이전의 16비트 버전의 마이크로소프트 윈도우는 협동적(cooperative) 멀티태스킹이라는 조잡한 형태의 멀티태스킹만을 제공하였다. 이런 협동적 멀티태스킹 환경에서는 모든 프로그램들이 “착한 시민들”<절대 오동작 하는 것은 용납이 안된다는 의미> 이어야 하고, 사용자가 동시에 하나 이상의 프로그램을 동작시키기 위해서 CPU 를 공유해야만 했다. 하지만 불행하게도 모든 소프트웨어가 항상 제대로 동작하는 것은 아니기 때문에 동시에 하나 이상의 프로그램을 동작시키는 것은 문제를 일으킬 수 있었다. 그러나 윈도우 NT 나 윈도우 95 와 같은 선점형(preemptive) 멀티태스킹과 멀티스레딩을 지원하는 32비트 운영체제는 이런 문제점을 많은 부분 해결했다. 애플리케이션은 이제 시스템에 단지 하나의 프로그램만 동작하고 있는 것 처럼 작성할 수 있게 되었고, 모든 프로그램이 CPU를 공유하여 동작하게 되었다.
기존의 PC프로그래머들은 Dos나 다른 간단한 운영체제에 익숙해져 있기 때문에 이러한 멀티스레딩 기능은 프로그래머에게 도전할 만한 프로그래밍 방식이다. Win32 프로그래밍으로 넘어가면서 32비트 운영체제의 향상된 모든 기능을 능숙하게 사용한다는 것은 상당히 어려운 일이다. 심지어 이런 고급 시스템을 접해보지 못한 프로그래머는 멀티태스킹이나 멀티스레딩에 대한 기본적인 개념도 파악하지 못할 수도 있다.
독자들에게 좀더 확실한 기초를 세워주기 위해서 이 장에서는 멀티스레딩이란 어떤 것인지와 멀티스레딩을 왜 사용해야 하는지에 대해서 다루려고 한다. 그 다음에는 멀티스레딩을 사용하면 안 되는 경우에 대해서 구체적으로 소개한다. 멀티스레딩이 모든 형태의 문제에 대한 해답은 아니기 때문에 이런 논의는 중요한 의미가 있다. 그리고 정확한 멀티스레드 프로그램을 작성하기 위해서 프로그래머가 가져야 하는 기본적인 마음가짐에 대해 이야기 하는 것으로 이 장을 마무리한다.
멀티스레드 프로그래밍이란 무엇인가 ?
도대체 멀티스레드 프로그래밍이란 무엇인가? 멀티스레드 프로그래밍이란 간단히 말해서 동일한 애플리케이션 안에서 두 개 이상의 기능이 병렬적으로 수행될 수 있도록 소프트웨어를 작성하는 것이다. 이것은 별도의 스레드로 각각의 기능을 수행해서 이루어진다. 스레드(thread)란 일반적으로 자신의 콜스택(call stack)과 CPU상태(state)를 갖는 소프트웨어의 수행 경로를 의미한다. 스레드는 프로세스의 문맥(context)상에서 동작한다. 프로세스의 문맥에는 코드와 데이터가 존재하고, 스레드가 수행되는 주소 공간이 정의되어 있다. 이것이 일반적으로 대부분의 사람들이 “멀티스레드 프로그래밍”을 가리킬 때 생각하는 것이다. 그러나 멀티스레드 환경에서 프로그래밍하기 위해서는 훨씬 더 많은 것을 다루어야 한다.
좋은 형태의 멀티스레드 프로그래밍은 단순한 스레드 생성보다 훨씬 더 많은 것을 필요로 한다. 좋은 스레드 프로그래밍을 위해 고려해야 할 것은 크게 두가지이다. 첫째는 효율적이고 유용한 방법을 사용해서 멀티스레드를 사용하는 프로그램을 작성하는 것이다. 주의깊게 작성한 멀티스레드 프로그램은 일반적으로 수행 시간, 사용자의 응답성, 프로그램 구조 중 적어도 하나 이상은 싱글스레드 프로그램보다 우수하다.
두 번째는 운영체제의 규칙에 대해서 정확히 파악하고 있어야 한다. 이런 운영체제 규칙은 프로그램이 동시에 수행되는 다른 프로그램과의 직접적 또는 간접적으로 어떻게 상호 작용을 하는지를 나타낼 뿐만 아니라 프로그램이 동작하는 형태를 제어하게 된다. 따라서 운영체제가 자신의 프로그램을 어떤 식으로 제어하는지와 다른 프로그램이 동시에 동작하고 있을 때 자신의 프로그램을 어떻게 다루어야 하는지를 아는 것이 중요하다. 마찬가지로 자신의 프로그램이 다른 프로그램과 동시에 수행될 때 다른 프로그램이 자신의 프로그램에 어떤 영향을 미치는지를 파악하는 것도 매우 중요하다.
멀티스레드 프로그래밍의 이런 양면성에 대해 정확히 파악하고 숙련되도록 노력하는 것은 윈도우 95나 윈도우 NT 환경에서 프로그래머로 성공하기 위해서 필수적이다. 이 책에서는 좋은 형태의 멀티스레드 프로그램을 작성하기 위한 두가지 측면 모두를 깊이 있게 다룬다.
왜 멀티스레드 프로그램을 작성하는가 ?
그렇다면 프로그램에서 스레드를 사용하는 이유는 무엇일까? 지금까지 윈도우가 제공하는 멀티스레드 기능을 사용하지 않고 잘 지내왔는데 왜 그래야 할까? 이것은 멀티스레드를 사용해서 프로그램을 작성하는 것이 여러가지 뛰어난 장점이 있기 때문이다. 장점은 다음과 같다.
- 병렬화의 증가. 대부분의 프로그램은 몇 가지 이벤트(사용자가 버튼을 누른다든지, 네트워크를 통해 다른 컴퓨터에서 서비스 요청을 받는다든지)에 의해서 하나 이상의 작업을 수행한다. 이런 기능들이 본질적으로 독립적이라면 각각의 작업들을 수행하는 별도의 스레드를 사용해서 프로그램의 성능을 상당히 증가시킬 수가 있다. 싱글스레드 애플리케이션에서 아래의 (세가지 작업이 있는 그림) 에서의 세가지 작업을 수행하는데 필요한 총 수행 시간은 각각의 작업을 순차적으로 수행하는데 걸리는 시간의 합과 같다.
- 빠른 처리. 멀티스레드 프로그램에서 세가지 작업을 수행하기 위한 총 수행 시간은 개별적인 작업 중에서 가장 긴 시간을 요하는 작업을 수행하는데 걸리는 시간과 같다. 아래의 (작업 세가지가 병렬로 나열되어 있는 그림) 는 멀티스레드 프로그램의 경우 수행 시간을 나타내고 있다.
- 병렬성의 극대화. 각각의 스레드가 I/O 동작이 끝나기를 대기하면서 대부분의 시간을 보내거나 시그널되기 위한 커널 개체를 사용하는 것과 같은 경우에는 멀티스레드를 사용해서 성능을 극대화 할 수 있다. 다시 말해서 A 작업을 수행하는 스레드가 상당한 시간을 I/O 동작이 종료되기를 기다리면서 보내게 된다면, B작업을 수행하는 스레드는 스레드 A가 블로킹(blocking)되어 있는 동안에 유용한 작업을 할 수 있다. 특히 멀티프로세서를 사용하는 운영체제 시스템은 각각의 CPU에 스레드를 매핑(mapping)하여 실제로 병렬적으로 수행하는 것이 가능하다.
- 단순한 설계. 훌륭하게 설계된 멀티스레드 프로그램은 명확하게 정의된 독립된 작업을 전담하는 개별적인 스레드를 사용함으로써 프로그램의 설계를 단순화 할 수 있다. 예를 들면, 음악 재생 프로그램은 다음과 같이 설계할 수 있다. 하나의 스레드는 디스크의 파일에서 압축된 음악 데이터를 읽어오게 하고, 다른 스레드는 데이터의 압축을 푸는 작업을 시키고, 마지막으로 셋째 스레드는 음악 데이터를 스피커에 보내는 일을 하게 된다. 이렇게 멀티스레드 프로그램을 설계한다면, 압축을 푸는 스레드가 읽어온 음악 데이터를 푸는 동안 싱글 스레드 프로그래밍의 형태에서는 대부분의 시간 동안 블로킹 상태인 파일을 읽는 스레드에게 다음에 읽을 입력 파일을 미리 읽어오게 할 수 있다. 마찬가지로 음악 재생 스레드는 다른 두 스레드의 동작과 상관없이 압축이 풀린 데이터를 스피커로 보낼 수 있다. 만약에 이런 프로그램을 싱글스레드로 작성한다면 디스크를 읽고, 압축을 풀고, 재생하는 일련의 과정을 적절히 나누어 배열해야 한다. 특히 사용자에게 멀티스레드 프로그램과 동일한 음질을 제공하기 위해서는 불필요하게 복잡한 방법을 사용해야 할 지도 모른다.
- 향상된 안정성. 중요한 기능을 담당하는 스레드를 분리해서 특정 기능을 전담하는 스레드를 만들 수 있고, 이와 같은 멀티스레딩을 통하여 프로그램의 안정성을 향상시킬 수 있다. 예를 들면, 사용자의 부적절한 입력에 의해서 서브시스템A가 동작을 멈추더라도 서브시스템B(예를들면, 핵 발전소 기기의 온도를 모니터링(mornitoring)하는 스레드)는 영향을 받지 않고 동작하도록 할 수 있다.
- 사용자에 대한 향상된 응답성. 사용자 인터페이스를 구성하는 부분을 프로그램의 나머지 부분으로부터 분리해서 스레드를 사용함으로써 프로그램이 실행 중인 경우에도 사용자에 대한 응답성을 극대화할 수 있다. 예를 들어, 사용자가 용량이 큰 웹페이지에서 데이터를 가져오는 것을 취소할 수 있는 기능을 제공하는 웹 브라우저를 싱글스레드로 작성했다고 하자. 아마도 이 브라우저는 주기적으로 PeekMessage를 호출하거나 또는 데이터 전송을 멈출 수 있는 다른 방법을 찾아야 할 것이다. 하지만 네트워크를 통한 데이터 전송은 백그라운드 스레드에 의해서 수행되고, 더 높은 우선 순위의 스레드를 사용해서 사용자 인터페이스를 동작하게 한다면 사용자 인터페이스 스레드는 언제든지 시간이 오래 걸리는 데이터 전송 취소 동작을 처리할 수 있게 된다.
- CPU 사용률의 증가. 윈도우 프로그램에 의한 작업의 대부분은 특정한 일이 발생하는 것을 오랜 시간 기다려서 단기간에 프로그램을 처리하는 형태로 이루어진다. CD-ROM 드라이브에서 데이터 블록을 읽기 위해서 대기하는 것이나 COM포트에 버퍼 데이터가 쓰여지기를 대기하는 것 같은 경우가 좋은 예이다. 이런 경우 CPU보다 훨씬 느린 속도로 동작하는 기기들이 작업을 끝내기 위해서 상당한 양의 대기 시간이 필요하게 된다. 이런 작업을 하는 별도의 스레드를 사용함으로써 주로 I/O 작업을 수행하는 스레드가 느린 속도로 동작하는 기기들이 작업을 완료하는 것을 대기하는 동안에 운영체제가 CPU를 좀 더 효율적으로 활용할 수 있다.
애플리케이션에서 멀티스레드를 사용하는 이유는 여기서 제시한 것 이외에도 많이 있다. 하지만 여기서 제시한 것은 가장 기본적이고 중요하다.
스레드를 사용하면 안 되는 경우
운영체제가 멀티스레드 기능을 제공한다고 해서 프로그램을 작성하는 경우에 반드시 멀티스레드를 사용해야 한다고 의미하는 것은 아니다. 특히 멀티스레드를 사용함으로써 불이익이 발생하는 경우가 있기 때문에 이 점을 유의해야 한다. 멀티스레드 프로그램을 처음으로 사용하는 대부분의 프로그래머들은 흥분하게 된다. 프로그램으로 풀 수 있는 모든 문제를 스레드로 나눌 수 있다고 생각하는 것이다. 누가 이들을 탓할 수 있겠는가? 마치 공장에서 자동화된 로봇들이 쉴새 없이 일하는 것을 보는 것 처럼, 한번에 한가지 이상의 작업을 하는 프로그램의 동작을 구경하는 것은 꽤 재미있는 일이기 때문이다. 프로그램에서 멀티스레드를 사용함으로써 얻을 수 있는 장점도 많이 있는 것이 사실이지만 부가적으로 스레드를 추가하는 것은 프로그램을 복잡하게 하고 새로운 형태의 에러(교착 상태(deadlock), 궁핍 현상(starvation) 같은 문제)를 발생시킬 수가 있다. 특히 이런 에러는 싱글스레드로 작성된 프로그램에서는 발생하지 않는다는 점이 중요하다. 아래에서는 애플리케이션에 멀티스레드를 사용하면 안 되는 경우를 판단하기 위한 몇 가지 지침을 제시하고 있다.
- 확실한 이유를 가지고 있지 않은 경우에는 스레드를 사용하면 안 된다. 이것은 어떤 프로그램에서든지 스레드의 사용을 결정하는 가장 중요한 잣대가 된다. 초보 멀티스레드 프로그래머들은 종종 명확한 이유 없이 프로그램에 새로운 스레드를 추가하여 사용하곤 한다. 그러면 무엇이 문제인가? 정답은 “잠정적으로 문제의 가능성이 많다”는 것이다. 싱글 스레드로 작성된 애플리케이션에 단 하나의 스레드를 추가함으로써 이 프로그램은 설계, 구현, 디버깅에 이르기까지 모든 것을 다시 생각해야 된다.
예를 들어, 두개의 스레드가 명확하게 독립적이지 않은 경우에는 하나의 작업을 둘로 나누면 안 된다. 다시 말하면 두개의 스레드가 작업 X를 하는 데 있어서 연관되어 있고 둘 중 하나의 스레드가 없거나 작업 X가 완료될 수 없는 경우에는 두 개의 스레드를 사용할 명확한 이유가 없다.
- 스레드에 의한 작업의 빈도를 고려할 때, 운영체제가 스레드 스케줄링을 하고 스레드를다루는 데 발생하는 부하(overhead)가 실제 스레드에 의해 수행되는 작업량보다 클 경우에는 스레드를 사용해서는 안 된다. 다시 말하면 스레드가 매우 적은 양의 작업을 하지만, 그 작업이 매우 빈번해서 운영체제가 매 초마다 수없이 스레드를 스케줄링하기 위해서 발생시키는 부하가 스레드를 프로그램에 사용함으로써 얻는 이득보다 크다면 스레드를 사용할 필요가 없다. 하지만 각각의 스레드가 동작할 때 많은 양의 일을 처리하도록 해야한다는 뜻은 절대 아니다. 아주 적은 빈도로 수행되고 또 수행될 때 적은 양의 일을 하는 스레드는 시스템의 성능에 거의 영향을 주지 않는다. 애플리케이션뿐만 아니라 시스템 전체의 성능을 저하시키는 스레드는 매우 빈번하게 수행되면서 적은 양의 작업을 하는 형태의 스레드이다.
예를 들면, 스레드를 추가해서 발생하는 부하는 순차적으로 스레드를 수행했을 때 실제로 수행된 일보다 커질 수 있다. 애플리케이션의 스레드가 항상 다른 스레드와 동기화 되어서 동작해야 한다면 각각의 스레드를 동작하도록 스케줄링하고 스레드 사이의 정보를 주고 받도록 할 때 발생하는 부하는 상당히 크다.
애플리케이션이 성공하기 위해서 프로그램의 설계에 스레드를 사용하는 것이 가장 좋은 방법은 아니며, 여기에 몇 가지 이유가 더 있다. 멀티스레드를 사용하는 프로그램은 새로운 형태의 문제점들을 발생시키기 때문에 실제로 소프트웨어를 구현하는 개발자의 기술 수준이 스레드의 사용 여부를 결정짓게 된다. 멀티스레드 소프트웨어를 작성하는데 필요한 모든 함수를 설명하는 Win32 API 문서가 멀티스레드 프로그램을 성공적으로 개발하는데 필요한 모든 것을 알려주는 것은 아니다.
멀티스레드 프로그래밍으로 넘어가기
싱글스레드 프로그래밍 환경에서 멀티스레드 환경으로 넘어가려면 문제를 보는 시야를 좀 더 넓혀야 한다. 단순히 데이터 구조를 조작하는 방법을 안다거나 정확하게 매개변수를 넘기는 방법을 아는 것만으로는 불충분하다. 예를 들면, 함수가 구현될 때에 함수가 정확하게 필요한 작업을 수행할 수 있는지 확인을 해야하며, 멀티스레드를 사용할 경우에도 정확하게 동작하는지 이해하고 있어야 한다.
특히 가장 주된 관심사는 여러 개의 스레드가 동시에 하나의 함수를 사용할 수 있는 지의 여부를 파악하는 것이다. 만약 스레드들이 동시에 하나의 함수를 사용할 수 있다면 각각의 스레드가 이함수를 실행할 때 정확한 기능을 수행하도록 해야한다. 이 질문에 대한 대답은 처음에 생각했던 것보다 많은 내용을 포함하고 있다. 함수가 하나 이상의 스레드에게 접근 가능한 데이터를 변경한다면 이 함수가 데이터를 사용하기 전에 적절한 주의 조치를 취했는지를 확인해야 할 뿐 아니라 데이터에 접근하는 다른 모든 스레드 역시 동기화 되어 변경된 데이터에 접근하도록 적절한 순서를 따랐는지 정확하게 이해해야 한다.
아래의 예제에서는 멀티스레드 프로그래밍의 세계로 넘어가면서 발생하는 여러 가지 문제점 중에서 한가지 경우를 살펴본다. 미리 할당된 GADGET 객체의 단일 연결 리스트를 관리하는 기능을 가진 윈도우 3.x 용 애플리케이션이 있고, 첫째 프리 GADGET 객체에 대한 포인터는 전역 변수로 되어 있다고 가정한다.
typedef struct tagGADGET {
{
// GADGET의 구조체의 필드를 여기서 설명한다.
// 리스트에 있는 다음 GADGET 의 포인터
struct tagGADGET *pNext;
}
GADGET, *PGADGET;
PGADGET gpFreeGadgets = NULL; // 프리 GADGET 구조체의 리스트에 대한 포인터
// gpFreeGadgets == NULL 이면 이 리스트는 비어 있다.
( gpFreeGadgets -> Gadget1 -> Gadget2 -> Gadget3 (프리 리스트) 라는 이미지) 은 연결 리스트의 구조와 비어있는 첫째 GADGET 구조체의 포인터를 나타내고 있는 그림이다. 애플리케이션에는 GADGET 구조체의 리스트에서 한 노드를 제거하는 함수와 프리 리스트에서 더 이상 필요하지 않은 GADGET 구조체를 가져다 쓰는 함수가 미리 구현되어 있다고 가정한다.
PGADGET GetAGadget(void)
{
// 프리 리스트의 맨 앞에 있는 GADGET 구조체를 하나 꺼낸다.
PGADGET pFreeGadget = gpFreeGadgets;
if( pFreeGadget ) {
// 이 리스트가 비어있지 않으므로 리스트의 헤드(gpFreeGadgets)를
// 리스트의 다음 노드를 가리키게 함으로써 이 노드를 떼어낸다.
gpFreeGadgets = gpFreeGadgets->pNext;
}
return (pFreeGadget);
}
void FreeAGadget( PGADGET pGadget )
{
// 주어진 GADGET 구조체를 리스트의 헤드에 다시 넣는다.
pGadget->pNext = gpFreeGadgets;
gpFreeGadgets = pGadget;
}
윈도우 95나 윈도우 NT로 이 애플리케이션을 포팅(porting)하기로 했고, GADGET 객체를 프리 리스트에서 제거하거나 삽입하는 기능을 두 개의 스레드로 나누어서 프로그램을 작성하기로 했다고 가정한다면, 프로그래머는 이제 곤경에 빠질 수 밖에 없다. 여기에서 문제는 GADGET을 리스트에서 제거하는 스레드와 삽입하는 스레드가 운영체제에 의해서 독립적으로 스케줄링 될 수 있다는 점에서 발생한다. 스레드의 수행이 인터럽트되고 다른 스레드가 수행될 수 있는 경우에는 각각의 스레드는 결과적으로 위의 함수의 일부분이 섞여서 수행될 수 있다. 위의 코드는 수행되면 버그를 발생시키겠지만, 이 버그는 타이밍에 따라 발생하는 문제이기 때문에 일시적으로 발생하고, 따라서 버그의 재현이 어려우므로 분석하기가 매우 어렵고 수정하기도 쉽지 않다.
다음과 같은 예를 생각해 보자. GADGET 을 리스트에 삽입하는 기능을 하는 스레드B가 FreeAGadget 함수를 부르고 첫번째 문장이 수행되었다고 하자.
pGadget->pNext = gpFreeGadgets;
바로 이 때 운영체제가 제어권을 뺏어서 스레드 A가 리스트에서 GADGET을 제거하는 기능을 수행하도록 하는 상황을 생각해보자. [그림 1-4]처럼 스레드 B에 의해서 프리 리스트의 긑으로 들어간 GADGET 구조체는 gpFreeGadgets 구조체에 의해서 참조되고 있는 GADGET 구조체를 가리키는 pNext 를 멤버로 가지고 있다. 스레드 A가 여기서 GetAGadget이라는 함수를 호출하면, gpFreeGadgets가 가리키는 GADGET구조체와 같은 포인터가 리턴되고, 스레드 A는 이 GADGET 구조체를 사용하게 된다. 위의 상황은 [그림 1-5] 에서 확인할 수 있다. 이제 스레드 B가 다시 수행하게 되면 다른 GADGET 구조체를 가리키도록 변경된 gpFreeGadgets의 값을 읽은 후에 이전에 그만 두었던 작업을 계속하게 된다. 그러나 스레드 B는 이 사실을 모르기 때문에 gdFreeGadgets 는 제거된 GADGET 구조체를 가리키게 된다. 문제는 이 GADGET 구조체의 pNext 멤버는 이미 스레드 A가 GetAGadget 을 호출하기 전에 프리 리스트의 맨 앞에 있는 GADGET 구조체를 가리키도록 되어있다는 데서 발생한다. 이것은 [그림 1-6] 에서 확인할 수 있다. 리스트는 싱글스레드 프로그램에서는 절대로 만들어질 수 없는 형태로 변형되어 있다. 프리 리스트에 있던 GADGET 구조체는 이제 리스트의 둘째 노드가 되어 있고, 또 GADGET 구조체를 적절히 할당하고 있는 프로그램의 다른 스레드에 의해서 사용되고 있다.
위에서 보듯이 싱글스레드 애플리케이션에서 멀티스레드로 넘어가면서 상대적으로 간단한 코드가 복잡하게 되었다. 이제 애플리케이션 속에 있는 알고리즘이 정확한가를 확인하는 것 뿐만 아니라, 멀티스레드 프로그램 속에서 동작하는 알고리즘이 환경적인 영향을 받는지에 대해서도 고려해야 된다. 그리고 위에서 볼 수 있는 데이터 구조의 조작성을 단순히 보장하는 차원을 넘어서 궁핍 현상(starvation), 교착 상태(deadlock), 적절한 동기화를 심각하게 고려해서 애플리케이션을 설계해야 한다. 이런 에러를 발생시킬 수 있는 원인을 제거하는 것은 전적으로 프로그래머의 책임이다. 멀티스레드 프로그래밍 환경에서 살아남기 위해서는 프로그래머의 설계, 구현, 디버깅 기술 각각이 모두 향상되어야 한다. 이 책은 이런 기술을 향상시키고, 안전하고 효율적으로 애플리케이션에서 멀티스레드를 사용하기 위해 필요한 도구를 제시한다.
'Programing' 카테고리의 다른 글
프로세스&스레드 (0) | 2011.09.14 |
---|---|
IPC : 프로세스 간 통신 (0) | 2011.09.14 |