이 게시글은 'TCP/IP 윈도우 소켓 프로그래밍 (김선우 저, 한빛아카데미)'를 공부한 내용을 기반으로 작성됨
멀티스레드를 이용하는 경우, 두 개 이상의 스레드가 공유 데이터에 접근하면 여러 문제가 발생할 수 있다.
예를 들어 공유 변수 int money = 1000이 존재한다고 할 때, 스레드 #1이 money에 2000을 더하여 money = 3000이 되었다고 해보자. 이때 스레드 #2가 money = 3000이 되기 전에 이미 money를 read 해놓은 상태라고 해보자. 스레드 #2 입장에서는 money = 1000이기 때문에, 만약 이에 3000을 더해준다면 money = 4000이 된다.
이러한 상황이 발생하면 스레드 #1의 명령이 무시되는 것이므로, 오류로 볼 수 있다. 이러한 멀티스레드 환경에서의 문제를 해결하기 위한 일련의 작업을 스레드 동기화(Thread synchronization)라고 한다.
스레드 동기화 기법
종류 | 기능 |
임계 영역 (critical section) | 공유 자원에 대해 오직 한 스레드의 접근만 허용 (한 프로세스에 속한 스레드 간에만 적용 가능) |
뮤텍스 (mutex) | 공유 자원에 대해 오직 한 스레드의 접근만 허용 (서로 다른 프로세스에 속한 스레드 간에도 적용 가능) |
이벤트 (event) | 사건 발생을 알려 대기 중인 스레드를 깨움 |
세마포어 (semaphore) | 한정된 개수의 자원에 여러 스레드가 접근할 때, 자원을 사용할 수 있는 스래드 개수를 제한 |
대기 가능 타이머 (waitable timer) | 정해진 시간이 되면 대기 중인 스레드를 깨움 |
스레드 동기화가 필요한 상황
- 두 개 이상의 스레드가 하나의 공유 자원에 접근할 때
- 한 스레드가 작업을 완료한 후, 대기중인 다른 스레드에 알려준다.
임계영역 (Critical Section)
어떠한 스레드 동기화 기법도 적용하지 않는다면?
#include <windows.h>
#include <stdio.h>
#define MAXCNT 10000000
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[])
{
// 스레드 두 개 생성
HANDLE hThread[2];
hThread[0] = CreateThread(NULL, 0, MyThread1, NULL, 0, NULL);
hThread[1] = CreateThread(NULL, 0, MyThread2, NULL, 0, NULL);
// 스레드 두 개 종료 대기
WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
// 결과 출력
printf("g_count = %d\n", g_count);
return 0;
}
- MyThread1, MyThread2라는 두 개의 스레드를 생성하여 각각 실행될 때마다 count 값에 +2, -2를 더하도록 하였다.
- 두 개의 스레드가 번갈아서 실행되도록 코드를 작성했기 때문에, MAXCNT번 만큼 스레드가 수행된 이후의 g_count는 0이어야 할 것이다.
- 결과를 출력해보면 0이 나오지 않는다. 심지어 0에서 아주 먼 값이 나왔다!
- 스레드 동기화가 적용되지 않은 상황이기 때문에 Thread1의 동작이 무시되었을 가능성이 높아보인다.
- 이렇게 간단한 예제에서도 스레드 동기화가 이루어지지 않으면 아주 큰 오류가 발생함을 알 수 있다.
- 이제 같은 예제에 임계영역을 적용해본다.
임계영역 적용
#include <windows.h>
#include <stdio.h>
#define MAXCNT 10000000
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);
// 스레드 두 개 생성
HANDLE hThread[2];
hThread[0] = CreateThread(NULL, 0, MyThread1, NULL, 0, NULL);
hThread[1] = CreateThread(NULL, 0, MyThread2, NULL, 0, NULL);
// 스레드 두 개 종료 대기
WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
DeleteCriticalSection(&cs);
// 결과 출력
printf("g_count = %d\n", g_count);
return 0;
}
- 임계영역을 적용했다.
- 스레드가 호출될 때마다 EnterCriticalSection, LeaveCriticalSection을 수행하여 임계영역에 들어갔다가 나온다.
- 이에 따라 한 스레드가 공유자원에 접근하여 작업중일 때 다른 스레드는 자원에 접근할 수 없게 된다.
- 하지만 임계영역 기법은 지나치게 세밀한 단위로 동기화를 수행하기 때문에 성능저하가 두드러진다는 단점이 있다. (가장 극단적인 동기화 기법이다.)
이벤트 (Event)
이벤트는 한 스레드가 작업을 완료한 후에 대기중엔 스레드에게 이를 알리는 기법이다. 이벤트의 절차는 다음과 같다.
- 이벤트를 비신호 상태로 생성
- 한 스레드가 작업을 진행하고, 나머지 스레드는 Wait*() 함수를 호출해 이벤트가 신호상태가 될 때까지 대기함(sleep)
- 스레드가 작업을 완료하면 이벤트를 신호 상태로 변경
- 기다리고 있던 스레드 중 하나 혹은 전부가 깨어남 (wakeup)
#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 k = 1; k <= 500; k++) {
// 읽기 완료 대기
retval = WaitForSingleObject(hReadEvent, INFINITE); // hReadEvent(읽기 이벤트)가 신호상태가 될 때 까지 기다림
if (retval != WAIT_OBJECT_0) break; //
// 공유 버퍼에 데이터 저장
for (int i = 0; i < BUFSIZE; i++)
buf[i] = k;
// 쓰기 완료 알림
SetEvent(hWriteEvent); // hWriteEvent(쓰기 이벤트)를 신호 상태로 만들어 나머지 스레드를 wakeup
}
return 0;
}
DWORD WINAPI ReadThread(LPVOID arg)
{
DWORD retval;
while (1) {
// 쓰기 완료 대기
retval = WaitForSingleObject(hWriteEvent, INFINITE); // hWriteEvent가 신호 상태가 되기를 기다림
if (retval != WAIT_OBJECT_0) break;
// 읽은 데이터 출력
printf("Thread %4d: ", GetCurrentThreadId()); // 현재 어떤 스레드가 데이터를 읽고있는지 표시하기 위함
for (int i = 0; i < BUFSIZE; i++) // 공유 버퍼에서 데이터를 읽어서 출력함.
printf("%3d", buf[i]);
printf("\n");
// 버퍼 초기화
ZeroMemory(buf, sizeof(buf));
// 읽기 완료 알림
SetEvent(hReadEvent);
}
return 0;
}
int main(int argc, char* argv[])
{
// 자동 리셋 이벤트 두 개 생성(각각 비신호, 신호 상태)
hWriteEvent = CreateEvent(NULL, FALSE, FALSE, NULL); // WriteEvent는 비신호 상태로 시작
if (hWriteEvent == NULL) return 1;
hReadEvent = CreateEvent(NULL, FALSE, TRUE, NULL); // ReadEvent는 신호 상태로 시작
if (hReadEvent == NULL) return 1;
// 스레드 세 개 생성
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);
// 스레드 세 개 종료 대기
WaitForMultipleObjects(3, hThread, TRUE, INFINITE);
// 이벤트 제거
CloseHandle(hWriteEvent);
CloseHandle(hReadEvent);
return 0;
}
- hReadEvent(읽기 이벤트), hWriteEvent(쓰기 이벤트)가 서로 번갈아서 sleep / wakeup 되는 구조이다.
- ReadEvent가 신호 상태로 시작한다. Read가 끝난 상태이니 Write를 시작하라는 의미이다.
- 예를들어 Thread #1이 ReadEvent가 신호 상태임을 확인하고 Write를 수행하여 데이터를 버퍼에 저장한다. 이때 WriteEvent를 신호 상태로 만든다.
- 그러면 Thread #2 또는 #3이 버퍼(공유 자원)에 접근하여 데이터를 읽어온다. 이때 Read를 마치면 ReadEvent를 신호 상태로 만든다.
'윈도우 소켓 프로그래밍' 카테고리의 다른 글
[소켓프로그래밍] 5. 스레드, 멀티스레드, 스레드 제어 (0) | 2024.08.25 |
---|---|
[소켓프로그래밍] 4. 고정길이, 가변길이 데이터 TCP 통신 / 데이터 전송 후 종료 (0) | 2024.08.24 |
[소켓프로그래밍] 3. TCP 서버 클라이언트 통신 (0) | 2024.08.24 |
[소켓프로그래밍] 1. 소켓 생성과 닫기, 바이트 정렬 (0) | 2024.08.16 |