멀티스레드 입출력에 최적화된 버퍼 템플릿 - CSwappableCollector

Programming/C/C++ Programming
2008. 8. 24. 22:56, Posted by ScottRhee

산발적으로 여러 곳에서 발생하는 이벤트나 데이터를 처리할 때에는, 일반적으로 데이터를 순차적으로 쌓아둘 수 있는 자료구조를 하나 운영하면서 프로세스/스레드 동기화 과정을 이용해 입출력을 하게 됩니다.


그런데, 어떤 애플리케이션에서는, 데이터를 입력하는 곳과 출력하는 곳이 완전히 분리되어 있기도 합니다. 예를 들어, 국회의원 선거를 할 때, 유권자들은 투표용지를 투표함에 넣기만 하지, 투표함에서 다시 용지를 빼오지는 않습니다. 반대로, 투표를 집계하는 사람은 표를 빼내기만 하지, 다시 투표함에 표를 넣는 일도 없습니다. 이런 식으로 입출력 방향이 명확한 데이터에 대해서는, 프로세스 동기화를 분리하는 것이 효율적입니다. 다시 말해서, 위의 예에서는 표를 넣는 사람과 빼오는 사람이 동시에 일을 해도 문제가 없다는 것이지요.


아시다시피 프로세스 동기화 작업은 가능한 적을 수록 좋습니다. 동기화 자체의 오버헤드도 문제고, 특히 현재 시스템의 CPU 코어 수에 맞춰서 스레드를 생성시킨 경우, 동기화때문에 스레드가 대기하게 되면 고효율과는 상당히 거리가 멀어집니다.  


하지만, 대부분의 자료구조 클래스는 입력과 출력의 동기화를 구분없이 함께 해주지 않으면 Fault를 일으킵니다. 데이터를 관리하는 내부 구조체나 플래그 등이 이를 구분하지 않기 때문입니다. 이렇게 되니 동기화를 따로 해주고 싶어도 그렇게 할 방법이 존재하지 않는 셈입니다. 결국 데이터를 입력받는 오브젝트와, 데이터를 출력하는 오브젝트를 분리하지 않으면 데이터를 입력하는 동안에 출력모듈까지 대기를 하게 될 가능성이 생기게 되므로 (반대의 경우도 마찬가지) 비효율적이라고 할 수 있습니다.


헌데 입/출력 오브젝트를 분리하게 되면, 동기화 효율은 높아지지만, 입력데이터를 모아서 출력데이터로 전달하는 과정이 추가로 필요해지게 됩니다. 당연한 얘기지만 이 과정에서도 또 동기화가 필요해지게 되고요. 위의 투표상황의 예를 들면, 투/개표 중간에 작업을 올스톱시키고 투표용 투표함에서 표를 모두 꺼내서 개표용 투표함에 쏟아넣는 과정이 필요해지는 셈이죠. 데이터량이 많을 경우, 이 작업때문에 타 작업스레드가 오랜시간 블러킹될 확률이 높아집니다. 결국 이런 식으로 작업을 하면 역시 고효율과는 거리가 멀어지게 됩니다.


그렇다면 이 방법은 어떨까요? 투표용 투표함과 개표용 투표함을 두기는 하되, 일정시간마다 개표용 투표함과 투표용 투표함을 바꿔치기하는 겁니다. 기표된 투표용지를 꺼내서 옮기는게 아니고, 통의 위치만 바꿔치기하는 거지요. 투표함을 바꿔치기할 때에는 물론 다른 작업을 모두 올스톱시켜야 하지만, 바꿔치기만 하는 것은 굉장히 고속으로 할 수가 있거든요. C에는 이런 작업에 쓸 수 있는 유용한 도구가 존재합니다. 포인터지요. 이 작업을 할 수 있는 템플릿을 작성해 보도록 합시다.


우선, 템플릿 선언과 Attributes입니다.

template <class objT>
class CSwappableCollector
{
private:

 objT  m_coll[2];
 objT *m_pcollInput;
 objT *m_pcollOutput;


 CCriticalSection m_csInput;
 CMutex    m_mtOutput;

템플릿 클래스이므로 입력받을 데이터형이 존재해야 합니다. 메모리 컬렉터 타입은 아무 것이나 쓸 수 있는데, MFC로 치면 CStringList라든지, CMapPtrToPtr 등이 될 수 있겠네요. 지정받은 타입의 인스턴스를 두 개 생성합니다. 또한, 해당 타입에 대한 input 포인터, output포인터를 선언하고, 초기화 루틴에서 각 인스턴스에 대한 포인터로 초기값을 넣어주면 되겠지요.


그리고 입력과 출력 동기화 오브젝트를 따로 선언하여, 입력과 출력 상황을 별도로 동기화하게 합시다. 이 때 입력 동기화 함수는 크리티컬 섹션을 사용했지만, 출력 동기화 함수는 뮤텍스를 사용했습니다. (Windows에서, 크리티컬 섹션의 경우 커널 오브젝트보단 부하가 덜하지만, 대기시간에 제한을 걸 수 없습니다. 뮤텍스는 크리티컬 섹션과 매우 유사한 커널 오브젝트이지만, 대기시간에 제한을 줄 수 있는 점이 다릅니다.) 이것은, 일반적인 프로그램에서 데이터를 수집하는 것은 여러곳에서 동시다발적으로 이루어지지만, 수집된 데이터를 실제로 처리하는 것은 동시다발적으로 일어나지 않거나, 여러 스레드에서 작업을 하더라도 굳이 스레드 대기까지 시켜가며 수집된 데이터를 처리할 필요는 없는 경우가 많기 때문입니다. 결국 m_mtOutput는 아예 사용하지 않거나, 사용하더라도 다른 스레드에서 데이터를 처리하고 있다면 대기하지 않고 바로 리턴하도록 처리하면 대부분의 경우 문제가 없습니다. 이것을 위의 투/개표 상황에 응용하면, 투표는 꼭 여러사람이 한꺼번에 하게 되지만, 개표의 경우는 (충분한 속도만 나온다면) 한 명이 전담해도 문제없는 것과 마찬가지입니다. 추가 인원이 필요하다 하여도, 꼭 두 사람에게 균등하게 데이터가 배분되어야 할 필요는 없는 것입니다. 노는 인원은 다른 일을 시켜야지, 할일없이 놀게(=블러킹 상태) 하면 안되겠지요.


물론 프로그램 특성에 따라, 데이터 입력보다 출력에 대부분의 부하가 집중되는 경우에는, 다른 전략을 사용해야 하겠지요. 이것은 나중에 기회가 되면 더 다루어 보겠습니다.


다음으로, 생성자, 소멸자, Lock 메서드들입니다.

  CSwappableCollector()
 {
  m_pcollInput = &m_coll[0];
  m_pcollOutput = &m_coll[1];
 }
 virtual ~CSwappableCollector(){};

 void LockInput() {m_csInput.Lock();};
 void UnlockInput() {m_csInput.Unlock();};


 // 아래 두 method는 Output Queue를 동시에 사용하는 일이 없을 경우 쓰지 않아도 됨
 bool LockOutput() {return !!m_mtOutput.Lock(0);};
 bool UnlockOutput() {return !!m_mtOutput.Unlock();};

생성자에서는, Attributes로 선언된 메모리 오브젝트 인스턴스 두 개의 포인터를 각각 데이터 입력, 출력 용 포인터로 초기화시킵니다.

동기화 Methods에서는 위에서 설명한 것처럼 각각 크리티컬 섹션과 뮤텍스 오퍼레이션을 통해 동기화를 구현합니다. 특히 LockOutput과 UnlockOutput의 리턴값이 bool인데, 이것은 다른 스레드에서 출력데이터 처리를 하고 있는지를 알기 위해서입니다. VC의 크리티컬 섹션으로는 이런 처리를 할 수 없음을 위에서 밝혔습니다. LockInput이 실패하면, 타 스레드에서 출력데이터 처리를 하고 있다는 의미이므로, 현재의 스레드는 그냥 즉시 다른 일을 하면 된다는 뜻이지요. m_mtOutputLock(0)에서 "0"이 대기시간입니다. 전혀 대기를 하지 않고, 동기화 오브젝트 소유의 가능성만을 타진한다는 것.


마지막으로, 포인터 교환 및 포인터 리턴 메서드들입니다.

  void Swap()
 {
  LockInput();
  if (m_pcollInput == &m_coll[0])
  {
   m_pcollInput = &m_coll[1];
   m_pcollOutput = &m_coll[0];
  }
  else
  {
   m_pcollInput = &m_coll[0];
   m_pcollOutput = &m_coll[1];
  }
  UnlockInput();
 }


 objT* GetInputColl() {return m_pcollInput;};
 objT* GetOutputColl() {return m_pcollOutput;};


};

Swap()메서드가 바로, 위에서 언급한 투표함을 바꿔치기하는 행위입니다. 데이터 복사를 전혀 하지 않고, 포인터가 지시하는 메모리 주소만 바꿔치기하는 것입니다. 투표함 바꿔치기라기보단, 투표함에 붙은 "유권자 투표용 투표함", "개표자 처리용 투표함"의 라벨만 바꿔치기하는 쪽에 더 가까울지도 모르겠네요.


GetInputColl(), GetOutputColl()은 각각의 메모리 컬렉터의 포인터를 얻고자 할 때 쓰는 메서드입니다. 포인터 Attributes를 public으로 선언하면 필요없는 메서드이지만 개인적으로 public attributes를 최소화하는 쪽을 선호하고 있어서 이렇게 적었습니다.


자, 이제 템플릿은 완성되었습니다. (가져다 쓰실 분은 첨부파일을 받으시면 됩니다.) 사용법을 알아보겠습니다. 편의상 템플릿의 데이터형이 될 메모리 컬렉터는 CStringList를 사용하였습니다.

// 선언

CSwappableCollector<CStringList> scString;


// 데이터 입력 Thread

 scString.LockInput();
 scString.GetInputColl()->AddTail("TTT");
 scString.UnlockInput();


// 데이터 출력(처리) Thread

 if (scString.LockOutput())
 {
  scString.Swap();

  while (scString.GetOutputColl()->GetCount() > 0)
  {
   CString &strTmp = scString.GetOutputColl()->GetHead();
   printf(strTmp);
   scString.GetOutputColl()->RemoveHead();
  }

  scString.UnlockOutput();
 }

당연한 얘기지만, 데이터 출력(처리)가 동시에 여러 스레드에서 이루어지지 않는다면, 동기화 관련 메서드(LockOutput, UnlockOutput)를 호출하는 부분은 사용하지 않는 것이 좋습니다. 쌓인 데이터를 타이머 등으로 일정시간마다 처리하는 형태가 보통 그런 경우입니다. 일반적인 타이머는 핸들러 함수가 중복호출되지 않기 때문입니다.  



자, 그럼 이것을 어디에 이용하느냐? 저는 이것을 UDP 동영상 스트리밍 처리에 이용했었습니다. UDP데이터는 TCP와는 다르게 여러 곳으로부터 데이터를 받아들일 수 있습니다. 특히 IOCP등을 이용할 경우에는 적절히 여러 스레드로 데이터가 분배되어 들어오지요. 이렇게 들어오는 데이터들을 순차적으로 가공한 뒤 동영상 플레이어에 전달하고자 할 경우, 데이터 입력 상황과 데이터 출력(처리)상황이 뚜렷하게 갈리게 되므로 이 템플릿을 적용하는 데에 무리가 없다 하겠습니다.



결론


용도 : 제한된 수의 자료구조를, 입출력 상태구분이 뚜렷한 여러 스레드에서 한꺼번에 이용하고자 할 경우.

장점 : 입력과 출력에 대해 별도의 프로세스 동기화를 하므로, 동일한 동기화 오브젝트를 사용하는 것보다 처리효율이 높다. 출력 데이터를 동시에 여러 스레드에서 다루지 않는 경우, 동기화 회수 및 블러킹 시간을 줄일 수 있다. 입출력 메모리를 서로 교환할 때 실제 데이터 복사를 하지 않는다. 

단점 : MFC 동기화클래스를 이용하므로 MFC 환경에서만 쓸 수 있다. 데이터 처리부에서 호출해야 하는 메서드의 수가 많다.  

향후 개선점 : 1. 동기화 오브젝트까지 템플릿으로 입력받게 하면 다른 플랫폼에서도 쓸 수 있다. 2. 데이터 출력용 동기화 오브젝트의 사용여부 또는 대기시간도 템플릿으로 입력받도록 처리하면 쓰임새를 더 넓힐 수 있다. 3. LockOutput 메서드와 Swap메서드를 결합하여 좀 더 사용법이 쉬워지게 할 수 있다.

: