본문 바로가기
WORK/Sotfware

다중 쓰레드와 C++

by KANG Stroy 2008. 6. 2.
728x90
728x90
 

다중 쓰레드와 C++ Study

2004/12/13 12:09

 http://blog.naver.com/truemonpark/40008750474


프로젝트에 다중 쓰레드를 도입하면 동기화나 종료 처리 등의 문제로 고려해야 할 사항들이 곱절 이상이나 늘어나게 된다. 그럼에도 불구하고 다중 쓰레드를 사용하는 이유는 프로그램의 성능을 향상시켜 주기 때문이다. 여기서 성능을 향상시켜 준다는 말이 무엇을 의미하는지는 좀더 생각해 볼 필요가 있다. 또한 어떻게 해야 성능이 향상되는지도 알아 볼 필요가 있다.

 

이현창 (아주대학교)


다중 쓰레드에 대해 공부해 본 적이 있다면 많은 책과 기사에서 다중 쓰레드의 사용을 될 수 있으면 자제하라고 권장한다는 사실을 알고 있을 것이다. 일단 프로젝트에 다중 쓰레드를 도입하면 동기화나 종료 처리 등의 문제로 고려해야 할 사항들이 곱절 이상이나 늘어나게 된다.

상식적으로 생각해도 알 수 있듯이 코드 한 줄을 고치는 데도 많은 고민을 해야 하며, 문제 상황이 항상 재현되는 것이 아니기 때문에 디버깅 시에도 많은 고생을 하게 된다. 그럼에도 불구하고 다중 쓰레드를 사용하는 이유는 프로그램의 성능을 향상시켜 주기 때문이다. 그런데 여기서 성능을 향상시켜 준다는 말이 무엇을 의미하는지는 좀더 생각해 볼 필요가 있다.

또한 어떻게 해야 성능이 향상되는지도 알아 볼 필요가 있다. 실제로 다중 쓰레드를 사용한 프로젝트 중에는 성능 향상은 별로 보이지 않고, 다중 쓰레드 도입으로 인한 문제점만 고스란히 떠안고 있는 경우가 많기 때문이다.

다중 쓰레드의 도입 시기
컴퓨터에 설치된 MSDN이나 웹에서 ‘Win32 Multithreading Performance’라는 제목의 기사를 찾아보자. 이 기사는 1996년도 1월에 작성된 기사이지만 언제 다중 쓰레드를 적용하는 것이 좋은지 아주 훌륭하게 설명하고 있다. 그렇기 때문에 필자는 새로운 설명 방식과 예제를 구상하는 시간에 차라리 이 기사의 주요 내용에 자세한 설명과 그림을 추가해 여러분의 이해를 돕는 것이 더 효율적일 것이라는 결정을 내렸다. 이 단락을 읽고 나면 여러분은 어떤 상황에서 다중 쓰레드를 사용해야 하는지 판단할 수 있는 능력을 지니게 될 것이다. 크게 세 가지의 주요 관점을 기준으로 설명을 하려고 한다.

전체 작업의 완료 시간
CPU가 하나만 존재하는 시스템에서 실제로 두 작업이 동시에 실행되는 것은 불가능하다. CPU가 아주 짧은 시간 간격으로 두 작업을 번갈아가며 실행시키기 때문에 우리에게 마치 동시에 실행되는 것처럼 느껴지는 것뿐이다. 이 때 우리가 간과해서는 안될 사실은 두 작업 사이를 오가는 과정(context switching)에서 오버헤드가 발생한다는 점과 쓰레드를 생성하는 일 역시 시간을 소모하는 작업이라는 점이다. 예를 들어 두 개의 작업 A, B가 있다고 하자. 이 경우 다중 쓰레드를 사용한 경우와 그렇지 않은 경우의 전체 작업의 완료시간은 다음과 같다. 다중 쓰레드를 사용한 경우가 보다 비효율적이라는 것을 알 수 있다

◆ 다중 쓰레드를 사용한 경우의 전체 작업 완료 시간
= A의 작업 시간 + B의 작업 시간 + Context Switching 소요 시간 + 쓰레드 생성 소요 시간

◆ 다중 쓰레드를 사용하지 않은 경우의 전체 작업 완료 시간
= A의 작업 시간 + B의 작업 시간


하지만 이는 A, B가 작업 시간 내내 순수하게 CPU만 사용한다는 가정에서만 성립된다. A, B의 작업 시간 중에 I/O 요청을 기다리는 시간이 포함된다면 문제가 달라진다. 예들 들어 A의 주요 작업 내용이 메모리의 데이터를 특정 파일에 기록하는 것이라고 가정하자. 파일에 기록하는 작업은 하드디스크와 같은 하드웨어에 기록을 요청한 후에 하드웨어에서 완료 응답이 도착할 때까지 기다리는 방식으로 진행된다. 그런데 이렇게 하드웨어의 응답을 기다리는 동안에는 CPU를 사용하지 않기 때문에 다른 작업이 CPU를 사용할 수 있다. 다시 말해 A가 I/O 요청을 기다리는 동안에는 B가 자신의 작업을 수행할 수 있다는 말이다. 그래서 다중 쓰레드를 사용한 경우 전체 작업의 완료 시간은 다음과 같이 다시 계산될 수 있다.

다중 쓰레드를 사용한 경우의 전체 작업 완료 시간
= A의 작업 시간 - A의 I/O 요청 대기 시간 + B의 작업 시간 + Context Switching 소요 시간 + 쓰레드 생성 소요 시간


결론적으로 I/O 작업은 다중 쓰레드의 좋은 대상이 된다고 할 수 있다. 운영체제의 발전 역사를 보더라도 하나의 시스템에 여러 사용자 프로그램을 동시에 적재해 실행하기 시작한 계기도 한 프로세스가 I/O 요청을 기다리는 동안에 다른 프로세스가 CPU를 사용할 수 있다는 사실을 운영체제 설계자들이 깨달았기 때문이다.

평균 처리 시간
앞서 CPU 연산의 경우 다중 쓰레드는 전체 작업 완료 시간을 단축하는 데 도움이 되지 않는다는 점을 확인했다. 그러나 우리의 관심사를 평균 처리 시간으로 옮기게 되면 CPU 연산의 경우에도 다중 쓰레드를 사용해 성능을 향상시킬 수 있다는 점을 확인할 수 있다. <그림 1>을 보면서 설명하도록 하겠다.

작업 A, B, C는 각각 1, 4, 3의 작업 시간을 갖으며 CPU 연산이다. 이 때 A, B, C의 순서대로 작업을 실행시킨다고 해보자. 그러면 작업 A는 바로 시작해서 작업 시간인 1만큼의 시간이 경과한 후에 완료될 것이다. 작업 B의 경우는 A가 끝나는 시간인 1만큼 기다렸다가 작업 시간인 4만큼의 시간이 경과한 후에 완료될 것이다. 전체적으로 볼 때 작업 B는 5만큼의 시간이 지난 후에 완료되는데 이 시간을 처리 시간(Turnaround Time)이라고 부르도록 하자.

처리 시간 = 대기 시간 + 작업 시간


같은 식으로 C의 처리 시간은 8이 된다. 이 글에서는 이러한 처리 시간의 평균을 ‘평균 처리 시간’이라고 부르도록 하겠다. 이제부터 우리는 다중 쓰레드를 사용하는 것이 어떻게 평균 처리 시간을 줄일 수 있는지 확인시켜 줄 예제 하나를 보게 될 것이다.

<그림 2> 평균 처리 시간의 감소 예


이런 종류의 문헌에서 자주 등장하는 슈퍼마켓에서의 계산 예를 들어보자. 슈퍼마켓에서 여러 손님들이 자신이 필요한 물건을 고른 후에 계산을 하려고 한다. 그리고 점원은 딱 한 명밖에 없다. 한 명의 점원이 여러 손님의 물품을 계산하는 방법으로 크게 두 가지를 생각해 볼 수 있다. 하나는 사람들을 한 줄로 세우고 차례로 계산을 해주고, 다른 하나는 여러 개의 계산대에 사람들을 여러 줄로 세우고 한 번에 한 계산대씩 돌아가면서 차례로 계산을 해주는 방법이다. 그림을 좀더 설명하면 이렇다.

각 손님마다 계산해야 할 물품의 개수가 적혀져 있는데 점원은 한 번에 하나씩 물품 계산을 한다고 가정한다. 즉, 10개의 물품을 가진 손님은 5개의 물품을 가진 손님보다 계산 시간이 두 배가 길어지게 된다. 마찬가지로 두 번째 방법의 경우에도 점원이 한 번에 한 물품만 계산하게 된다. 즉, 손님이 가지고 온 물품 중에 하나만 계산하고 다른 계산대로 이동하는 방식이다. 두 방법은 각각 다중 쓰레드를 사용한 경우와 사용하지 않은 경우를 비유하기 위한 것이다. 각 손님은 작업에 해당하며, 손님이 가지고 있는 물품은 작업 시간에 해당한다. 계산대 혹은 손님들의 줄은 하나의 쓰레드에 해당하고, 점원은 CPU에 해당한다. 따라서 두 번째 방법은 다중 쓰레드를 비유하고 있다는 점을 쉽게 알 수 있을 것이다.

그림을 보면 물품을 한 개만 가지고 있는 손님이 보일 것이다. 여러분이 그 손님이라고 상상해 보자. 첫 번째 그림에서 계산을 끝내고 슈퍼마켓을 나오기 위해서는 10 + 5 + 2 + 8 + 1 = 26만큼의 시간이 걸릴 것이다. 반면에 두 번째 그림에서는 5만큼의 시간 후에 슈퍼마켓을 나올 수 있다. 즉, 전체 손님을 놓고 볼 때는 이득이 없지만 여러분 입장에서는 많은 시간을 벌 수 있는 것이다. 그러면 두 그림의 경우에서 평균 처리 시간은 어떻게 되는지 계산해 보자. 첫 번째 그림은 직관적이지만 두 번째 그림의 경우는 연습장에 그려가면서 계산해야 이해가 쉽다.

첫 번째 그림에서의 평균 처리 시간 = 10 + (10 + 5) + (10 + 5 + 2) + (10 + 5 + 2 + 8) + (10 + 5 + 2 + 8 + 1) = 93 / 5
두 번째 그림에서의 평균 처리 시간 = 26 + 12 + 8 +24 + 5 = 75 / 5


예상했던 것처럼 두 번째 그림의 경우에 평균 처리 시간이 적은 것을 볼 수 있다. 이제는 다중 쓰레드를 사용하는 것이 어떻게 평균 처리 시간을 줄일 수 있는지 이해할 수 있을 것이다.

그런데 앞의 예처럼 작업 시간을 미리 알 수 있는 경우라면 다중 쓰레드를 사용하지 않더라도 평균 처리 시간을 최소로 만들 수 있다. 작업 시간이 작은 작업을 먼저 수행하면 최소의 평균 처리 시간을 얻을 수 있는 것이다. 다시 말해 작업을 1, 2, 5, 8, 10의 순서로 진행하면 평균 처리 시간이 1 + (1+2) + (1+2+5) + (1+2+5+8) + (1+2+5+8+10) = 54/5로 가장 작은 값을 갖는다. 이런 방식으로 정렬하는 경우에 최소의 평균 처리 시간을 얻을 수 있다는 사실은 직관적으로도 알 수 있으며 쉽게 증명할 수 있다.

또 한 가지 간과하기 쉬운 사실이 있다. <그림 2>의 두 번째 그림에서 5명의 손님이 같은 셔틀 버스를 타고 집에 가야 하는 경우를 생각해 보자. 아무리 일찍 계산을 마치고 나왔다 하더라도 결국은 마지막 손님이 계산을 마치고 셔틀버스에 탈 때까지 기다려야 한다. 바꿔 말해 다중 쓰레드를 사용해 처리된 작업들의 결과가 결국은 한 데 모여야지만 된다면 평균 처리 시간을 줄이는 것은 아무런 의미가 없게 되는 것이다.

반응성
지금까지 살펴본 것 외에 다중 쓰레드를 적용해 달성할 수 있는 요소 중에 한 가지는 반응성이다. 이는 실제적인 예를 들어 설명하는 것이 더 이해하기 쉬울 것 같다. 압축 프로그램을 만든다고 생각해 보자. 만약에 다중 쓰레드를 적용하지 않고 압축 해제 루틴을 구현하게 된다면 사용자는 압축 해제가 끝날 때까지는 작업을 취소할 수 없을 것이다. 사용자가 예상 시간이 30분이나 되는 압축 파일을 풀다가 취소를 하려고 했다면 사용자는 프로그램을 강제로 종료시켜 버리고는 다시는 쓰지 않을 것이다. 반면에 별도의 쓰레드에서 압축 해제 작업을 하는 경우에는 사용자의 입력을 받을 수 있기 때문에 취소 기능을 구현하는 것이 가능하다. 즉, 사용자의 요청에 빠르게 반응하는 프로그램을 만들 수가 있다.

요약 : I/O 연산은 다중 쓰레드를 적용하기에 아주 좋은 후보가 된다(경우에 따라 단일 쓰레드를 사용한 비동기 I/O가 다중 쓰레드를 사용한 동기 I/O보다 나을 수도 있다). CPU 연산의 경우에 평균 처리 시간을 줄이는 것이 중요하지 않다면 하나의 작업 쓰레드에서 모든 작업을 하는 것이 좋다. 마지막으로, 처음에 언급했던 것처럼 같은 효과라면 다중 쓰레드의 사용을 자제하는 것이 좋다.


volatile 키워드의 사용

안타깝게도 C++를 사용해 쓰레드를 생성하고 제어하기 위한 코드는 플랫폼마다 다른 형식을 취하고 있다. 이는 쓰레드를 사용하는 프로젝트의 이식성을 떨어뜨리는 주요 원인 중 하나이다. 그러나 지난 번 기사에서 소개한 boost 라이브러리에는 플랫폼에 독립적인 쓰레드 클래스를 가지고 있으니 한번 확인해 보는 것도 좋을 것이다.

C++에는 volatile 키워드가 있는데, 사전 그대로 말하자면 ‘휘발성의’, ‘변덕이 심한’이라는 뜻이다. 이 키워드는 C++를 사용해 다중 쓰레드를 구현하는데 있어서 아주 중요한 역할을 한다. <리스트 1>은 하나의 작업 쓰레드를 가지고 있는 간단한 프로그램인데, 이를 릴리즈 버전으로 컴파일한 경우 올바르게 동작하지 않는다. 설명을 보기 전에 스스로 문제점을 찾아보도록 하자.

   <리스트 1> volatile 키워드를 사용하지 않은 경우에 문제가 발생하는 코드

#include
#include
using namespace std

bool finish = false;

DWORD WINAPI WorkerThread(LPVOID);

void main()
{
      // 작업 쓰레드 시작
      DWORD dwID
      HANDLE h = CreateThread(NULL, 0, WorkerThread, NULL, 0, &dwID);

      Sleep(1000);

      // 작업 쓰레드가 종료하도록 유도
      finish = true

      // 쓰레드 종료를 기다림
      WaitForSingleObject(h, INFINITE);
      CloseHandle(h);
}

DWORD WINAPI WorkerThread(LPVOID)
{
      cout <<"Worker Thread started" <<endl

      int i = 0
      while(!finish)
      {
            ++i
      }

      cout <<"Worker Thread finished : " <<i <<endl
      return 0
}



힌트는 디버그 버전에서는 문제없이 돌아가고 릴리즈 버전에서만 문제가 발생한다는 점이다. 릴리즈 버전에서는 기본적으로 최적화를 수행하게 된다. 안타깝게도 최적화를 수행하는 컴파일러는 우리의 의도를 완벽하게 이해할 수 없다. WorkerThread 함수 안에서 finish 변수의 값을 true로 설정하는 곳이 없기 때문에 while(finish)을 while(true)처럼 바꿔도 아무런 상관이 없을 것이라고 생각하는 것이다. 물론 매번 finish 변수에 접근하지 않아도 되기 때문에 속도 향상은 있겠지만 <리스트 1>의 경우엔 잘못된 판단이 아닐 수 없다.

결론적으로 main() 함수에서 finish 변수에 true 값을 넣어도 작업 쓰레드는 종료되지 않고 메인 쓰레드 역시 작업 쓰레드를 계속 기다리기 때문에 프로그램이 종료될 수 없다. 이 문제를 해결하는 방법은 매우 간단하다. volatile bool finish = false;과 같이 volatile 키워드를 넣어서 finish 변수를 선언하면 컴파일러는 함부로 finish 변수와 관련된 코드를 최적화하지 않는다.

HANDLE과 쓰레드 핸들
CreateThread() 함수가 성공적으로 호출되면 쓰레드의 핸들을 반환하게 된다. 아마도 여러분은 HANDLE 타입이 쓰레드 핸들을 담는 용도 외에 여러 곳에서 사용되는 것을 보았을 것이다. 파일 핸들이나 프로세스 핸들, 뮤텍스나 이벤트 같은 동기화 객체의 핸들 역시 HANDLE 타입으로 다뤄진다. 이렇게 HANDLE 타입으로 다뤄지는 객체들의 공통점은 커널 객체라는 점이다. 커널 객체라는 공통점을 가지고 있기 때문에 몇 가지 특징을 공유하고 있다.

기본적으로 이러한 핸들은 레퍼런스 카운팅 방식으로 동작한다. 이는 COM 객체가 레퍼런스 카운트를 유지하다가 마지막 클라이언트가 릴리즈했을 때 레퍼런스 카운트가 0이 되어 소멸되는 것과 같은 방식이다. 이벤트 객체를 처음 생성한 경우에는 레퍼런스 카운트가 1이 된다. 그리고 나서 CloseHandle()을 사용해 핸들을 닫아주면 레퍼런스 카운트가 하나 줄어 들어 0이 되고, 실제로 객체가 소멸된다. 커널 객체는 프로세스가 아닌 커널의 소유이다.

그렇기 때문에 커널 객체를 생성한 프로세스가 종료되더라도 다른 프로세스에서 해당 커널 객체를 사용중이라면 커널 객체는 살아있는 채로 유지된다. 한 가지 알아둘 점은 윈도우 98 계열의 운영체제에서는 이미 소멸된 커널 객체에 대해 CloseHandle()을 호출해도 아무런 반응이 없는 반면에 윈도우 NT 계열의 운영체제에서는 예외가 발생한다는 점이다. 만약에 다중 쓰레드를 사용하는 프로그램이 윈도우 98에서 잘 동작하다가 윈도우 2000에서 예외를 발생시킨다면 쓰레드의 종료 처리를 의심해 볼 필요가 있다.

쓰레드 핸들의 경우에는 초기의 레퍼런스 카운트가 2가 되는 특징을 가지고 있다. 하나는 CreateThread를 호출한 클라이언트에게 반환되는 핸들을 위한 레퍼런스이고, 다른 하나는 쓰레드 스스로를 위한 레퍼런스이다. 후자의 레퍼런스는 쓰레드가 종료한 경우에 감소한다. 이러한 사실을 통해 다음과 같은 점을 생각해 볼 수 있다.

쓰레드 핸들의 특징
쓰레드를 생성하자마자 CloseHandle()을 호출하는 것이 쓰레드를 종료하게 만들지는 못한다. 단순히 클라이언트에 주어진 레퍼런스만 하나 감소하고 쓰레드는 여전히 동작하게 된다. 나중에라도 이 클라이언트가 쓰레드의 상태를 검사할 일이 없다면, 굳이 HANDLE을 보관할 필요가 없기 때문에 오히려 쓰레드를 생성하자마자 CloseHandle()을 호출하는 것이 바람직할 수도 있다.

마찬가지로 쓰레드가 종료된 이후에도 클라이언트가 아직 쓰레드 핸들을 닫지 않았다면 핸들의 레퍼런스 카운트는 1이 되어 아직 살아있게 된다. 이 경우에 쓰레드는 종료되었지만 쓰레드 핸들을 가지고 작업을 하는 것은 가능하다. 예를 들어 GetExitCodeThread()를 사용해 쓰레드의 반환 값을 확인할 수 있다. 물론 쓰레드 핸들을 닫은 후에는 이러한 작업이 불가능하다.

HANDLE로 다뤄지는 커널 객체들의 또 한 가지 공통점은 신호를 받은 상태 혹은 신호를 받지 않은 상태를 가지고 있다는 점이다. 예를 들어 이벤트의 경우에는 SetEvent()를 했을 때 신호를 받은 상태가 되고 ResetEvent()를 했을 때 신호를 받지 않은 상태가 된다. 프로세스나 쓰레드 핸들의 경우에는 처음 생성시에는 신호를 받지 않은 상태이고 종료된 경우에 신호를 받은 상태가 된다. 이러한 상태를 활용하는 대표적인 API가 바로 WaitForSingleObject(), WaitForMultipleObjects(), MsgWaitForMultipleObjects()이다.

이러한 API는 HANDLE을 입력 인자로 가지고 있는데, 입력된 핸들이 신호를 받을 때까지 기다리는 기능을 수행한다. 예를 들어 이벤트 핸들의 경우에는 이벤트가 셋(SET)될 때까지 기다렸다가 번환하며, 프로세스나 쓰레드의 경우에는 종료할 때까지 기다렸다가 반환한다. <리스트 1>처럼 main() 함수의 끝부분에 WorkerThread가 종료할 때까지 기다리는 코드를 볼 수 있을 것이다.

CRT를 올바르게 사용하자
CRT(C Run-Time Library)는 우리가 처음 C++ 언어를 배울 때부터 사용하던 많은 함수와 매크로가 포함되어 있다. 대표적인 printf() 함수부터 시작해서 메모리를 할당/해제 함수, 전역 변수의 생성자를 호출해 주는 기능까지 모두가 CRT의 기능이다.

 
<화면 1> CRT의 종류를 보여주는 등록정보 창


이 CRT는 lib나 dll의 형태로 프로그램에 링크되는데, 성능상의 이유로 인해 다중 쓰레드 버전과 단일 쓰레드 버전으로 나눠져 있다. 다중 쓰레드 버전에서는 단일 쓰레드 버전과는 달리 동기화 코드가 추가되어 있는데, 프로그램이 단일 쓰레드만 가지고 있다면 굳이 이런 동기화 코드로 인한 부담을 껴안을 필요는 없다. 반면에 다중 쓰레드를 사용하는 경우에는 이런 동기화 코드가 반드시 필요하다. 즉, 자신의 쓰레드 사용 여부에 맞는 버전의 CRT를 링크할 필요가 있다는 것이다. <화면 1>과 <화면 2>는 비주얼 스튜디오 닷넷에서 CRT의 종류를 고를 수 있는 등록정보 창과 CRT의 종류를 보여주고 있다.

<리스트 2>는 CRT를 올바르게 선택하는 것이 중요하다는 사실을 보여주기 위해 준비한 예제다. GetLastError() API와 비슷하게 CRT에도 현재 쓰레드의 최근 에러 값을 보관하고 있는 errno 변수가 존재한다. <리스트 2>를 단일 쓰레드 버전의 CRT와 링크한 경우에는 작업 쓰레드의 최근 에러 값이 엉뚱하게 나오는 것을 확인할 수 있다.

   <리스트 2> CRT의 사용 예

#include
#include
#include
using namespace std

DWORD WINAPI WorkThread(LPVOID);

void main()
{
      // Result too large(34) 에러를 발생시킨다.
      pow( INT_MAX, INT_MAX);

      // 메인 쓰레드의 최근 에러 번호 출력
      cout <<"Main : " <<errno <<endl

      // 작업 쓰레드 시작
      DWORD dwID
      HANDLE h = CreateThread(NULL, 0, WorkThread, NULL, 0, &dwID);

      // 종료 처리
      WaitForSingleObject(h, INFINITE);
      CloseHandle(h);
}

DWORD WINAPI WorkThread(LPVOID)
{
      // 작업 쓰레드의 최근 에러 번호 출력
      cout <<"WorkThread : " <<errno <<endl
      return 0
}

// 결과 1 : 다중 쓰레드 버전의 CRT와 링크한 경우
Main : 34
WorkThread : 0

// 결과 2 : 단일 쓰레드 버전의 CRT와 링크한 경우
Main : 34
WorkThread : 34



마이크로소프트에서 제공하는 CRT에는 _beginthread와 _beginthreadex 두 가지의 쓰레드 생성 함수가 있다. 이 함수들이 성공적으로 호출되면 정수 값을 반환하는데, 타입은 다르지만 쓰레드의 핸들이다. _beginthread를 사용한 경우에 생성된 쓰레드에서는 _endthread를 호출해 자기 자신을 종료시킬 수 있는데 이때는 자동으로 CloseHandle()을 호출해 쓰레드 핸들을 닫아준다. 반면에 _beginthreadex와 _endthreadex를 사용한 경우에는 직접 CloseHandle()을 호출해 줄 필요가 있다. 물론 MSDN에 잘 설명되어 있지만 모르고 지나치기 쉬운 부분이니 주의해서 사용하길 바란다.

쓰레드 간의 데이터 전송
이번에는 쓰레드 간에 데이터를 전송하는 시나리오를 상상해 보자. 우리 주변에서 찾아 볼 수 있는 아주 흔한 예로는 동영상 파일을 읽어서 화상을 출력하는 것이 있다. 첫 번째 쓰레드(A)는 동영상 파일에서 특정 단위만큼씩 읽어서 다음 쓰레드(B)로 보내는 일만 열심히 한다. 다음 쓰레드(B)는 받은 데이터를 디코딩해 그 다음 쓰레드(C)로 보내는 일만 열심히 한다. 마지막 쓰레드(C)는 받은 데이터를 화면에 출력하는 일만 열심히 한다. 일반적으로 파이프라인이라고 부르는 작업 방식과 동일한 것이다.

다만 하드웨어 상에서의 파이프라인은 실제로 동시에 실행될 수 있어서 확실한 성능 향상을 가지고 오지만, 쓰레드를 사용한 파이프라인은 결국 한 CPU가 컨텍스트 스위칭을 해가면서 구현하는 것이기 때문에 I/O 연산이 연관된 경우에만 성능 향상을 기대할 수 있다. 파일에서 데이터를 읽는 것이나 화면에 이미지를 출력하는 것 모두 I/O 연산이기 때문에 동영상 파일을 읽어서 화상을 출력하는 경우는 다중 쓰레드를 적용하기에 아주 좋은 후보라고 볼 수 있다. 다이렉트X SDK에는 멀티미디어 데이터와 관련된 다이렉트쇼 SDK를 포함하고 있으며 실제로 다이렉트쇼는 이와 같은 방식으로 동작하고 있다.

이 시나리오에서 관심을 가지고 생각해 볼 부분은 데이터를 전송하는 방법이다. 쓰레드 간에는 함수 호출과 같은 방식으로 데이터를 넘겨주는 것이 불가능하다. 함수 호출을 해보았자 결국은 자신의 쓰레드이기 때문이다. 별도의 버퍼를 두어서 그것을 공유하는 방법이 일반적이다. 앞서 예를 든 쓰레드 A가 동영상 파일의 일부를 버퍼에 복사해 두면 쓰레드 B가 그 버퍼에서 읽어서 작업하는 방식이다(<그림 3-a>).

그러나 쉽게 예상할 수 있듯이 쓰레드 B가 버퍼에서 데이터를 읽는 도중에 쓰레드 A가 버퍼에 데이터를 쓰게 되면 쓰레드 B는 망가진 데이터를 읽게 된다. 그렇기 때문에 이 버퍼는 CriticalSection이나 Mutex와 같은 동기화 객체를 사용해 보호될 필요가 있다(CriticalSection이 더 적은 오버헤드를 갖기 때문에 프로세스간의 동기화가 아니라면 CriticalSection을 쓰는 것이 일반적이다).

이렇게 동기화를 추가한 후에는 한 쓰레드가 버퍼에 작업 중인 동안에 다른 쓰레드가 작업할 수 없는 문제가 생긴다. 다른 쓰레드의 작업을 기다리는 만큼의 시간을 손해보는 것이다. 이런 문제를 해결하기 위해서는 버퍼를 두 개로 늘리는 방법을 사용하면 된다(보통 이중 버퍼링이라고 불린다)(<그림 3-b>). 버퍼가 두 개로 늘어나면 한 쓰레드가 한 쪽 버퍼에 작업하는 동안에도 다른 쓰레드가 다른 쪽 버퍼에 작업할 수 있기 때문에 기다리는 시간을 줄일 수 있다. 물론 이런 경우에도 문제는 남아 있다. 두 쓰레드가 언제나 맞아 떨어지게 작업하는 것은 아니기 때문이다. 예를 들어 순간적으로 쓰레드 B의 작업이 지체되면 두 개의 버퍼가 다 차버리고, 쓰레드 A는 다음 번 데이터를 버퍼에 쓰기 위해 쓰레드 B가 버퍼의 내용을 읽어가기를 기다려야 하기 때문이다.

이중 버퍼링의 장단점
이러한 문제점을 해결하기 위한 방법으로 다수 개의 버퍼를 사용하는 방법을 생각할 수 있다. 익히 알고 있는 큐(Queue)가 바로 그것이다. 그런데 큐를 사용한다고 하더라도 크게 두 가지의 정책을 세울 수가 있다. 하나는 최대 버퍼의 수를 제한하는 방식이고 다른 하나는 무한으로 버퍼의 수를 증가시키는 방식이다. 두 가지 방식은 각각 장단점을 가지고 있다. 우선 최대 버퍼의 수를 제한하는 방식의 장점은 무한으로 메모리를 요구하는 것을 막을 수 있다는 점이다.

또한 버퍼의 수가 한계에 도달해 쓰레드 A가 중단된 상태로 기다리는 동안 CPU에 그만큼의 여유가 생겨서 쓰레드 B가 훨씬 많은 CPU 자원을 사용할 수 있게 된다. 그렇게 되면 자동으로 쓰레드 B가 큐에 쌓인 데이터를 읽어서 처리하는 속도가 빨라지고 프로그램의 전체적인 부하가 해소된다. 무한으로 버퍼를 제공하는 경우에는 쓰레드 B가 소화하지 못할 만큼의 데이터가 계속해서 큐에 쌓이므로 프로그램이 원활하게 동작하지도 않을 뿐더러 결국에는 메모리가 부족해 작업을 계속 할 수 없게 될 수도 있다. 물론 쓰레드 B가 I/O 요청을 기다린다거나 특정 조건을 기다리는 등의 이유로 작업이 정체되는 경우라면 쓰레드 A가 중단되는 것이 도움이 되지는 않을 것이다.

최대 버퍼의 수를 제한하는 경우에도 버퍼의 수가 한계에 도달했을 때의 처리 방식에 따라 크게 두 가지 정책을 세울 수가 있다. 하나는 여유분의 버퍼가 생길 때까지 쓰레드 A가 중단되어 기다리는 방식이며, 또 다른 하나는 버퍼 중에 하나를 강제로 비워버리는 방식이다. 선택의 기준은 달성하고자 하는 목적과 관계가 있다. 예를 들어 지금 사용하고 있는 예제인 동영상의 경우에, 그 중에서도 화상인 경우에는 버퍼 중에 하나를 버리는 방식을 사용하기에 적당하다.

화상의 경우에는 중간에 몇 프레임이 없어져도 사용자에게 크게 불편함을 주지 않기 때문이다. 또한 화상의 디코딩은 CPU를 많이 소모하는 작업이기 때문에 프로그램의 부하를 낮추는데도 많은 도움을 준다. 반면에 음성인 경우에는 버퍼 중에 하나를 버리는 방식을 사용하기에 적절하지 않다. 음성 데이터 한 프레임이 버려진다는 것은 영화의 배경음악이 끊긴다거나 주인공의 말소리가 잘리는 결과를 가져오기 때문에 화상에 비해 크게 거슬리게 된다. 물론 이러한 것들은 필자가 주로 하던 작업과 관련된 아주 단편적인 예이며 여러분의 환경에 맞는 적당한 정책을 수립할 필요가 있을 것이다.

이렇게 큐를 사용해 쓰레드간의 데이터 전송을 구현할 때 데이터를 매번 큐 내부의 버퍼에 복사하고, 복사해 오는 작업은 많은 부하를 발생시킨다. 그래서 큐에는 단순히 포인터만 유지하는 것이 일반적이다. 버퍼를 매번 생성하고 소멸시키는 것도 시간을 소모하는 작업이므로 미리 버퍼를 만들어 두어서 버퍼 풀(buffer pool)에 넣어두고 꺼내 쓰는 방법도 일반적이다. <그림 4>를 보면서 살펴보자. 쓰레드 A는 버퍼 풀에서 버퍼를 하나 얻어서 동영상의 일부를 읽는다. 그리고는 해당 버퍼의 포인터를 큐에 넣는다. 쓰레드 B는 큐에서 버퍼의 포인터를 얻어서 디코딩 작업을 한다. 사용이 끝난 버퍼는 다시 버퍼 풀로 반환해서 재사용할 수 있도록 한다.

 
<그림 4> 버퍼 풀의 사용


UpdateWindow() API

이번 기사의 마무리를 장식할 주제는 UpdateWindow()라는 API이다. 이 API는 윈도우 프로그래밍을 처음 시작할 때부터 등장한다. 기본적인 WinMain() 함수의 구현에서 CreateWindow()와 메시지 루프 사이에 위치한다. 처음 공부할 때 윈도우가 바로 화면에 그려지게 하는 역할을 한다고 배웠지만 UpdateWindow() 를 호출하지 않더라도 윈도우는 잘 그려졌기 때문에 의아해 하면서 넘어갔던 기억이 난다. 하지만 올해 초에 GUI 환경에서 단일 쓰레드를 사용하는 경우에 UpdateWindow()를 매우 유용하게 사용할 수 있음을 알게 되었다. 우선 <화면 3>을 살펴보자.

‘Test’ 버튼을 누르면 텍스트 박스에 1부터 100까지의 정수를 차례로 넣는 예제이다. 이 때 UpdateWindow()를 사용할 것인지 결정할 수 있는 옵션을 가지고 있는데, 그에 따라 서로 다른 결과를 보여준다. 옵션을 체크한 경우에는 예상대로 1부터 100이 차례로 보여지지만 체크하지 않은 경우에는 한참 후에 100만 보여진다.

코드를 보면 알 수 있지만 UpdateWindow()가 에디트 컨트롤이 바로 바로 자신을 다시 그리게 해주기 때문이다(이 예제는 MFC 프로젝트이다). SetWindowText()는 에디트 컨트롤의 내용을 바꾸고 WM_PAINT를 발생시키는 역할을 하지만 그 메시지는 바로 처리될 수 없다. OnBnClickedBtnTest() 함수는 메인 쓰레드에서 실행되기 때문에 이 함수가 완료될 때까지 메시지 루프가 실행될 수 없고, 그에 따라 WM_PAINT가 윈도우 프로시저에 전달될 수 없기 때문이다. 그러나 UpdateWindow()를 호출해 주면 WM_PAINT 메시지를 바로 윈도우 프로시저에 전달해 주기 때문에 에디트 컨트롤에 바로 그려질 수 있다.


void CUpdateWindowDlg::OnBnClickedBtnTest()
{
      TCHAR temp[16];
      CWaitCursor wait

      UpdateData(TRUE);

      // 천천히 숫자를 증가시킨다.
      for (int i = 0 i <= 100 ++i)
      {
            m_edit.SetWindowText( _itot( i, temp, 10) );

            // 옵션에 따라 UpdateWindow() 호출
            if (m_bUseUpdateWindow)
               m_edit.UpdateWindow();

            // 테스트 용도
            Sleep(20);
      }
}


필자가 UpdateWindow()를 사용하기 전에는 컨트롤이 바로 바로 갱신될 수 있도록 하기 위해 별도의 작업 쓰레드를 생성해 사용했다. 그러나 앞서 설명한 반응성이 중요하지 않다면 단일 쓰레드를 사용할 것을 권장하고 싶다. 이 기사를 제대로 읽은 독자라면 그 이유를 알 수 있을 것이라 생각한다. 쓰레드를 즐겨 사용하던 필자는 UpdateWindow()를 사용해 같은 문제를 해결할 수 있다는 점을 알았을 때 많은 교훈을 얻을 수 있었다. 그것이 어떤 종류의 교훈이었을지는 여러분의 상상에 맡기도록 하겠다.

소스 코드를 통해 배워라
필자는 다른 사람의 소스 코드를 분석하는 것을 좋아하지 않는다. MSDN 라이브러리나 잡지, 책을 읽는 것이 더 쉽고, 더 많은 지식을 준다고 생각하기 때문이다. 처음에도 말했듯이 필자는 이번 학기에 오픈소스 프로젝트 하나를 분석하고 있는데, 이 과정에서 얻은 교훈이 한 가지 있다. 나쁜 책을 통해 배울 수 있는 것보다는 나쁜 소스 코드를 통해 배울 수 있는 것이 더 많다는 점이다. 자신의 결점보다는 다른 사람의 결점이 더 잘 보이는 것이 사람이기 때문인 것 같다. 이 기사에서 부족한 점을 많이 느꼈다면 다음 번에 여러분이 기사를 쓸 때 좋은 거름이 될 거라는 생각으로 이해를 구한다.

출처 : www.zdnet.co.kr[출처] 다중 쓰레드와 C++|작성자 truemonpark

728x90

'WORK > Sotfware' 카테고리의 다른 글

[통신]시리얼 통신 프로그램 관련..  (0) 2008.06.08
클래스/Class  (0) 2008.06.03
Serial Port - RS232C  (0) 2008.06.02
AfxBeginThread 사용하기  (0) 2008.06.02
Edit Clear  (0) 2008.06.02

댓글