2013. 8. 2. 10:51

Thread에서 SendMessage를 통한 deadlock(UI Hang, 먹통)과 SendMessageTimeout으로 해결하기 (MFC Message Loop / 메시지 핸들러)

일반적인 UI 프로그램은, 다음과 같이 Main Thread(Primary thread)를 가지게 됩니다.

이러한 Main Thread는,

- WM_.... 와 같은 메시지 핸들러 처리

- Message pumping / dispatch

- ...

와 같은 역할을 수행하게 됩니다.


만일,

와 같은 UI도 Main Thread를 당연히 가지고 있습니다.

시계 그림을 1초에 한번 Update하기 위해, Timer 메시지(WM_TIMER)등을 사용할 것이고, 그때마다 UI를 다시 그려야 할 것입니다. 이러한 것을 정리하면, Main Thread 에서는,

- 타이머 처리 : WM_TIMER ==> OnTimer

- 다른 프로그램 UI(예, notepad)에 의해 Hide 되었다가 다시 그려짐 : WM_PAINT ==> OnPaint / OnDraw

- 임의의 버튼 처리 : IDC_XXXX ==> OnButtonXXXX

- 1초에 한번 시계 다시 그리기 : WM_PAINT ==> OnPaint / OnDraw (InvalidateRect)

- 확인 / 취소 처리 : OnOK / OnCancel

- UI 종료 : OnClose / ...

- ...

와 같은 함수들이 처리(다시 말해, Message Handler에 의한 dispatch)됩니다.


위 함수들이 Main Thread에서 처리된다는 말은, 동시에 실행되지 않는다는 뜻입니다. 즉, 만일, OnButtonXXXX() 함수에서 ::Sleep(10분)을 하게 되면, 다른 함수들, 예를 들어, OnPaint등이 실행되지 못하게 되는데, 이로 인해 10분동안 UI의 Hang을 경험하게 됩니다.


그래서 당연히 실행시간이 아주 긴 함수의 실행은, Main Thread가 아닌, Worker Thread에서 진행하게 됩니다. _beginthreadex와 같은 함수는 금방 return되는 함수이므로, thread를 만든다고 해서 UI가 먹통되지는 않습니다. 오래동안 시간이 걸리는 함수는 thread 내부에서 실행하는 것입니다.


Thread를 만든다는 것은 생각보단 귀찮은 일이 많이 생기는데,

1) UI thead(즉, Main Thread)와 연동 (정보 교환)

2) Life time 관리 (thread 종료와 handle 처리)

와 같은 이슈가 발생합니다.


일반적인 thread 함수는 다음과 같습니다. (_beginthreadex 호출시 this를 context로 전달한 경우)

unsigned __stdcall CTestDlg::FnThread(void* pArg)

{

CTestDlg* pcThis = NULL;


pcThis = (CTestDlg*)pArg;

if (NULL == pcThis)

{

return 0;

}


{

// 아주 길고 지루한 잡업들...

}


return 0;

}


UI 연동을 위해 다음과 같이 진행할 수 있습니다.

unsigned __stdcall CTestDlg::FnThread(void* pArg)

{

CTestDlg* pcThis = NULL;

CHAR    buf[1024]= {0,};


pcThis = (CTestDlg*)pArg;

if (NULL == pcThis)

{

return 0;

}


{

// 아주 길고 지루한 잡업들...

...

pcThis->PostMessage(WM_REFRESH_UI);

...

pcThis->SendMessage(WM_SENDMYDATA, (WPARAM)buf);

...

}


return 0;

}

와 유사할 것입니다.

PostMessage는 말 그대로 Message Queue에 추가만하고 바로 리턴되는 함수로, 쉽게 얘기해서, "Main Thread야, 지금 바쁘겠지만, 나중에라도 WM_REFRESH_UI 처리를 해줘"라는 뜻으로 해석됩니다. 이는 나중에 설명될 SendMessage에 의한 Deadlock 해결을 위한 일차적인 해결방법입니다.

문제는 SendMessage인데, 이는 "Main Thread야, 지금 바쁘겠지만, 지금 하고 있는일을 다 끝내면 WM_SENDMYDATA 처리를 해줘. 그리고 나는 그 처리가 완료될 때까지 여기서 기다릴께."가 됩니다.


여기까지 간략하게 봐 왔을 때는 아무런 문제가 없어 보입니다.

하지만, 앞선 "2) Life time 관리"가 들어가게 되면 문제 발생 확률이 높아집니다.

(참고로, Life time 관리란, thread를 생성한 주체가 thread 종료까지 기다리고 clean-up을 해출 책임이 있다는 것으로 가정하겠습니다.)


static volatile BOOL g_bStop = FALSE;


void CTestDlg::OnClose()

{

...

g_bStop = TRUE;

if (NULL != m_hThread)

{

::WaitForSingleObject(m_hThread, INFINITE);

::CloseHandle(m_hThread);

m_hThread = NULL;

}

...

}

...

unsigned __stdcall CTestDlg::FnThread(void* pArg)

{

...

{

if (TRUE == g_bStop) goto FINAL;

...

// 아주 길고 지루한 작업들

...

pcThis->SendMessage(WM_SENDMYDATA, (WPARAM)buf);

...

memset(buf, 0, sizeof(buf));

...

}

...

FINAL:

...

}

일반적인 코드는 위와 유사할 것입니다.


worker thread와 main thread간의 공유 메모리(예, g_bStop)을 이용하여 종료 체크를 하게됩니다. 만일, UI가 종료되어야 한다면, g_bStop은 TRUE로 세팅되며, thread가 종료될때 까지 무한대로 대기하게 됩니다. thread에서는 g_bStop이 TRUE가 되면, thread가 종료되도록 goto FINAL 처리를 하고 있습니다.


아무런 문제가 없어 보이지만, 여기서의 문제는 하나의 "우연"에 기인하게 됩니다.

각각 다른 Thread에서 실행되어, CTestDlg::OnClose()와 CTestDlg::FnThread는 동시에 실행되는 것으로 보이게 되는데, 예를 들어, Main Thread인 CTestDlg::OnClose()의 Thread Id는 1 그리고 CTestDlg::FnThread는 2라고 가정하겠습니다.


(1) g_bStop = TRUE 실행

(2) // 아주 길고 지루한 작업들 실행

(3) ::WaitForSingleObject 실행

(4) pcThis->SendMessage 실행


과 같은 순서로 진행된다면, Thread 1은 (3)에 의해 Block이 되며, Thread 2는 (4)에 의해 Block됩니다. 그래서 deadlock이 발생하게 됩니다. 앞선 얘기 처럼, SendMessage는 "메시지 처리 완료까지 대기"로 인해 Block 됩니다. Block되는 이유는, CTestDlg::OnClose 함수가 종료가 되지 않아서 입니다. 즉, CTestDlg::OnClose가 종료되면 Message Loop에 의해 WM_SENDMYDATA가 처리될 것이고, WM_SENDMYDATA가 완료되면 비로소 SendMessage가 종료되기 때문입니다.


만일, PostMessage를 사용하게 된다면 문제가 발생하지 않습니다. 즉, (4)가 pcThis->PostMessage가 된다면, 대기 없이 리턴되기 때문입니다.


그런데, SendMessage와 PostMessage 사용의 구별은 꽤나 주의해야 합니다.

즉, 만일 위 예제에서 PostMessage를 사용하게 된다면, 메시지 핸들러인 OnSendMyData()에서 수신된 (WPARAM)buf 값의 안정성이 보장되지 않습니다. 왜냐하면, PostMessage를 호출하고, 그 이후에 FndThread에서 buf를 변경하게 되면, 수신측에서는 전송할 당시의 buf 값을 읽을 수 없게 됩니다. 즉, 위 예에서, 메시지 핸들러에서는 memset(0)가 처리된 buf 값을 읽을 수 있습니다.

따라서 PostMessage는 전송할 Data가 sizeof(WPARAM) / sizeof(LPARAM)의 범위내에 있는 경우에만 유효하다고 할 수 있습니다. 물론, 설계를 잘 하면, PostMessage로도 해결할 수 있습니다. 즉, buf를 Thread 내부에서 malloc하고, 메시지 핸들러에서 free하면 문제가 없습니다. 허나, 호출 빈도가 높다면 그다지 아름답지 못한 설계로 보여집니다.


다시 deadlock으로 돌아와서, 이것을 해결하기 위해, PostMessage 사용은 위험하다고 설명하였습니다. 그렇다면, 그 대안은, ::SendMessageTimeout이 해결해 줍니다. 말 그대로, SendMessage에 Timeout을 부여할 수 있는 함수입니다.


unsigned __stdcall CTestDlg::FnThread(void* pArg)

{

...

DWORD dwResult = 0;

...

{

if (TRUE == g_bStop) goto FINAL;

...

// 아주 길고 지루한 작업들

...

if (0 == ::SendMessageTimeout(pcThis->GetSafeHwnd(), WM_SENDMYDATA, (WPARAM)buf, 0, SMTO_NOTIMEOUTIFNOTHUNG, 1*1000, &dwResult))

{

if (TRUE == g_bStop) goto FINAL;

}

...

memset(buf, 0, sizeof(buf));

...

}

...

FINAL:

...

}

일반적인 코드는 위와 유사할 것입니다.


SendMessageTimeout은 "Main Thread야, 지금 바쁘겠지만, 지금 WM_SENDMYDATA 처리를 해줘. 그리고 나는 실행이 완료될 때까지 기다릴께. 단, 지정된 기간 동안 만이야."으로 해석됩니다. Timeout 여부는 리턴값으로 확인됩니다. 물론, ::GetLastError()를 통해 ERROR_TIMEOUT임을 확인해야 합니다. 여하튼, Timeout이 발생하면, 메시지 핸들러 처리를 동기적으로 처리하지 못한 경우가 됩니다. 위 예에서는 종료 flag 체크를 시도하게 되는데, 설정되었다면 Thread를 종료하여 Deadlock을 회피하게 됩니다. SMTO_XXXX 값들은 msdn을 통해 확인해 보시기 바랍니다. 주로, SMTO_NORMAL이 사용될 수 있습니다.


WM_NULL이라고 있는데, 이를 통해 Main Thread의 Hang 상태를 체크할 수 있습니다. 즉,

::SendMessageTimeout(hwnd, WM_NULL, 0, 0, SMTO_NORMAL, 100, &dwResult)

와 같이 호출하여 Timeout이라면 UI가 Hang 상태임을 체크할 수 있습니다. 그래서 만일 Hang 상태라면, SendMessage같이 UI 연동하는 코드는 Skip하거나, UI가 살아날 때 까지 Sleep으로 대기할 수 ㅇ있습니다.