멀티스레드를 이용하는 프로그램에서 스레드 2개 이상이 공유 데이터에 접근하면 다양한 문제가 발생할 수 있다.
이러한 멀티스레드 환경에서 발생하는 문제를 해결하기 위해 일련의 작업을 스레드 동기화(thread synchronization)라 한다. 윈도우 운영체제는 프로그래머가 상황에 따라 적절한 동기화 기법을 선택할 수 있도록 다양한 API 함수를 제공한다.
윈도우 운영체제에서 사용할 수 있는 대표적인 스레드 동기화 기법
종류 | 기능 |
---|---|
임계 영역(critical section) | 공유자원에 대해 오직 한 스레드의 접근만 허용 (한 프로세스에 속한 스레드 간에만 사용 가능) |
뮤텍스(metex) | 공유 자원에 대해 오직 한 스레드의 접근만 허용 (서로 다른 프로세스에 속한 스레드 간에도 사용 가능) |
이벤트(event) | 사건 발생을 알려 대기 중인 스레드를 깨운다. |
세마포어(semaphore) | 한정된 개수의 자원에 여러 스레드가 접근할 때, 자원을 사용할 수 있는 스레드 개수를 제한한다. |
대기 가능 타이머(waitable timer) | 정해진 시간이 되면 대기 중인 스레드를 깨운다. |
여기에서는 네트워크 응용 프로그램에서 사용 빈도가 높은 임계 영역과 이벤트를 다룰 것이다.
스레드 동기화가 필요한 상황은 크게 다음 2가지 경우이다.
두 경우 모두 각 스레드가 독립적으로 실행하지 않고 다른 스레드와의 상호 작용을 토대로 자신의 작업을 진행한다는 특징이 있다. 스레드 동기화를 하려면 스레드가 상호작용해야 하므로 중간 매개체가 필요하다. 두 스레드가 동시에 진행하면 안되는 상황이 있을 때, 두 스레드는 매개체를 통해 진행 가능 여부를 판단하고 이에 근거해 자신의 실행을 계속할지를 결정한다.
윈도우 운영체제에서 이러한 매개체 역할을 할 수 있는 것을 통틀어 동기화 객체(synchronization object)라고 한다. 동기화 객체의 특징은 아래와 같다.
Create*()
함수를 호출하면 커널(kernel: 운영체제의 핵심 부분을 뜻함) 메모리 영역에 동기화 객체가 생성되고, 이에 접근할 수 있는 핸들(HANDLE 타입)이 리턴된다.Wait*()
함수를 사용해 감지할 수 있다.CloseHandle()
함수를 호출한다.Wait*()
함수는 스레드 동기화를 위한 필수 함수로, 동기화를 진행할때 비신호 -> 신호, 신호 -> 비신호 상태 변화 조건을 잘 이해해야 하며, 상황에 맞게 Wait*()
함수를 사용할 수 있어야 한다.
임계 영역(critical section)은 둘 이상의 스레드가 공유 자원에 접근할 때, 오직 한 스레드만 접근을 허용해야 하는 경우에 사용한다. 임계 영역은 대표적인 스레드 동기화 기법이지만, 생성과 사용법이 달라서 앞에서 소개한 동기화 객체로 분류하지는 않는다.
임계영역 사용 예는 다음과 같다.
#include <windows.h>
CRIRICAL_SECTION cs; // 1
DWORD WINAPI MyThread1(LPVOID arg)
{
...
EnterCriticalSection(&cs); // 3
// 공유 자원 접근
LeaveCriticalSection(&cs); // 4
...
}
DWORD WINAPI MyThread2(LPVOID arg)
{
...
EnterCriticalSection(&cs); // 3
// 공유 자원 접근
LeaveCriticalSection(&cs); // 4
...
}
int main(int argc, char **argv)
{
...
InitializeCriticalSection(&cs); // 2
// 스레드를 두개 이상 생성해 작업을 진행
// 생성한 모든 스레드가 종료할 때까지 기다린다.
DeleteCriticalSection(&cs); // 5
...
}
CRITICAL_SECTION
구조체 변수를 전역 변수로 선언한다. 일반 동기화 객체는 Create*()
함수를 호출해 커널 메모리 영역에 생성하지만, 임계 영역은 유저 메모리 영역에 (대개는 전역 변수 형태로) 생성한다.InitializeCriticalSection()
함수를 호출해 초기화한다.EnterCriticalSection()
함수를 호출한다. 공유 자원을 사용하고 있는 스레드가 없다면 EnterCriticalSection()
함수는 곧바로 리턴한다. 하지만 공유 자원을 사용하고 있는 스레드가 있다면 EnterCriticalSection()
함수는 리턴하지 못하고 스레드는 대기 상태가 된다.LeaveCriticalSection()
함수를 호출한다. 이때 EnterCriticalSection()
함수에서 대기 중인 스레드가 있다면 하나만 선택되어 깨어난다.DeleteCriticalSection()
함수를 호출해 삭제한다.임계 영역 사용 시 주의점
임계 영역을 이용할 때 임계 영역을 이용해 공유 자원 접근을 제한하는 것으로 스레드 동기화 문제를 해결했다고 생각하는 것을 주의해야 한다. 반드시 기억해야 하는 것은 임계 영역만으로는 어느 스레드가 먼저 리소스를 사용할지 결정할 수 없다는 것이다. 즉, 어떤 스레드가 먼저 접근할 지 알 수 없다.
임계 영역을 사용하지 않을 경우 문제가 발생하는 극단적인 상황을 예제를 작성하여 임계 영역의 효과를 연습해보자
#include <windows.h>
#include <stdio.h>
#define MAXCNT 100000000
int g_count = 0;
DWORD WINAPI MyThread1(LPVOID arg)
{
for (int i = 0; i < MAXCNT; i++)
g_count += 2;
return 0;
}
DWORD WINAPI MyThread2(LPVOID arg)
{
for (int i = 0; i < MAXCNT; i++)
g_count -= 2;
return 0;
}
int main(int argc, char **argv)
{
// 스레드 2개 생성
HANDLE hThread[2];
hThread1 = CreateThread(NULL, 0, MyThread1, NULL, 0, NULL);
hThread1 = CreateThread(NULL, 0, MyThread1, NULL, 0, NULL);
// 스레드 2개 종료 대기
WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
// 결과 출력
printf("g_count = %d\n", g_count);
return 0;
}
실행 결과는 다음과 같다. 예상한 g_count가 0이 되어야 하지만 공유 자원 접근 제한을 하지 않아 매번 다른 값이 출력된다.
위에 코드를 바탕으로 임계 영역을 사용해 g_count 변수에 한 스레드만 접근하게 만들면 아래와 같다.
#include <windows.h>
#include <stdio.h>
#define MAXCNT 100000000
int g_count = 0;
CRITICAL_SECTION cs;
DWORD WINAPI MyThread1(LPVOID arg)
{
for (int i = 0; i < MAXCNT; i++)
{
EnterCriticalSection(&cs);
g_count += 2;
LeaveCriticalSection(&cs);
}
return 0;
}
DWORD WINAPI MyThread2(LPVOID arg)
{
for (int i= 0; i < MAXCNT; i++)
{
EnterCriticalSection(&cs);
g_count -= 2;
LeaveCriticalSection(&cs);
}
return 0;
}
int main(int argc, char **argv)
{
// 임계 영역 초기화
InitializeCriticalSection(&cs);
// 스레드 2개 생성
HANDLE hThread[2];
hThread[0] = CreateThread(NULL, 0, MyThread1, NULL, 0, NULL);
hThread[1] = CreateThread(NULL, 0, MyThread2, NULL, 0, NULL);
// 스레드 2개 종료 대기
WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
// 임계 영역 삭제
DeleteCriticalSection(&cs);
// 결과 출력
printf("g_count = %d\n", g_count);
return 0;
}
실행 결과는 다음과 같다. 동기화로 인한 오버헤드 때문에 결과가 나오는 시간이 걸리겠자만 항상 올바른 값(0)을 출력하는 것을 확인할 수 있다.
위 예제는 극단적인 상황에서 지나치게 세밀한 단위로 스레드 동기화를 하고 있어 성능 저하가 많이 느껴지지만, 실전에서 이와 같은 상황은 거의 발생하지 않기 때문에 스레드 동기화의 성능이 크게 문제 되지는 않는다.
이벤트(event)는 사건 발생을 다른 스레드에 알리는 동기화 기법이다.
이벤트를 사용하는 전형적인 절차는 다음과 같다.
Wait()
함수를 호출해 이벤트가 신호 상태가 될 때가지 대기한다.(sleep)이벤트는 대표적인 동기화 객체로, 신호와 비신호 2가지 상태를 가진다. 또한 상태를 변경할 수 있도록 다음과 같은 함수가 제공된다.
BOOL SetEvent(HANDLE hEvent); // 비신호 -> 신호
BOOL ResetEvent(HANDLE hEvent); // 신호 -> 비신호
이벤트는 특성에 따라 2종류가 있으며, 용도에 맞게 선택할 수 있어야 한다.
ResetEvent()
함수를 사용할 필요가 없다.ResetEvent()
함수를 호출해야 한다.이벤트는 아래 이벤트 생성 함수 CreateEvent()
를 사용해 생성한다.
// 성공: 이벤트 핸들, 실패: NULL
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes,
BOOL bManualReset,
BOOL bInitialState,
LPCTSTR lpName
);
데이터를 생성해 공유 버퍼에 저장하는 스레드 1개와 공유 버퍼에서 데이터를 읽어서 처리하는 스레드 2개를 생성할 것이다. 이 경우 한 스레드만 버퍼에 접근할 수 있게 해야하고, 접근 순서도 정해야한다. 스레드 실행 순서에 대한 제약 사항은 다음과 같다.
#include <windows.h>
#include <stdio.h>
#define BUFSIZE 10
HANDLE hReadEvent;
HANDLE hWriteEvent;
int buf[BUFSIZE];
DWORD WINAPI WriteThread(LPVOID arg)
{
DWORD retval;
for (int i = 0; i <= 500; i++)
{
// 읽기 완료 대기
// 읽기 이벤트가 신호 상태가 되기를 기다린다. 최초에는 읽기 이벤트가 신호 상태로 시작하기 때문에 곧바로 리턴해 다음 코드로 진행할 수 있다.
retval = WaitForSingleObject(hReadEvent, INFINITE);
if (retval != WAIT_OBJECT_0)
break;
// 공유 버퍼에 데이터 저장
for (int j = 0; i j < BUFSIZE; j++)
buf[j] = i;
// 쓰기 완료 알림
// 쓰기 이벤트를 신호 상태로 만들어 두 읽기 스레드 중 하나을 대기 상태에서 깨운다.
SetEvent(hWriteEvent);
}
return 0;
}
DWORD WINAPI ReadThread(LPVOID arg)
{
DWORD retval;
while (1)
{
// 쓰기 완료 대기
// 쓰기 이벤트가 신호 상태가 되기를 기다린다. 최초에는 비신호 상태로 시작하기 때문에 이 지점에서 읽기 스레드는 대기 상태가 된다.
retval = WaitEventSingleObject(hWriteEvent, INFINITE);
if (retval != WAIT_OBJECT)
break;
// 읽은 데이터 출력
printf("Thread %4d: ", GetCurrentThreadId());
for (int i = 0; i < BUFSIZE; i++)
printf("%3d\n", buf[i]);
printf("\n");
// 버퍼 초기화
// 만약 데이터를 새로 쓰지 않은 생태에서 다시 읽게 된다면 0을 출력될 것이므로 오류 여부를 확인할 수 있다.
ZeroMemory(buf, sizeof(buf));
// 읽기 완료 알림
// 읽기 이벤트를 신호 상태로 만들어 쓰기 스레드를 대기 상태에서 깨운다.
SetEvent(hReadEvent);
}
return 0;
}
int main(int argc, char **argv)
{
// 자동 리셋 이벤트 2개 생성(각각 비신호, 신호 상태)
hWriteEvent = CreateEvent(NULL, FALSE, FALSE, NULL); // 비신호
if (hWriteEvent == NULL)
return 1;
hReadEvent = CreateEvent(NULL, FALSE, TRUE, NULL); // 신호
if (hReadEvent == NULL)
return 1;
// 스레드 3개 생성
HANDLE hThread[3];
hThread[0] = CreateThread(NULL, 0, WriteThread, NULL, 0, NULL); // 쓰기 스레드
hThread[1] = CreateThread(NULL, 0, ReadThread, NULL, 0, NULL); // 읽기 스레드
hThread[2] = CreateThread(NULL, 0, ReadThread, NULL, 0, NULL); // 읽기 스레드
// 스레드 3개 종료 대기
// 스레드 3개가 종료하기를 기다린다. 읽기 스레드는 별도의 루프 탈출 조건이 없어 사실상 영원히 리턴하지 못한다.
WaitForMultipleObjects(3, hThread, TRUE, INFINITE);
// 이벤트 제거
CloseHandle(hWriteEvent);
CleseHandle(hReadEvent);
return 0;
}
실행 결과는 다음과 같다.
참고 자료
김성우 저, "TCP/IP 윈도우 소켓 프로그래밍", 한빛아카데미, 2018