책정리/윈도우 API 정복2

42장 동기화

GONII 2019. 5. 7. 22:51
01 동기화
. 멀티 스레드의 문제점
멀티 스레드는 동시에 여러 가지 작업을 매끄럽게 수행할 있는 아주 멋진 메커니즘이다. 운영체제의 지원도 무척 안정적이며 우선 순위 관리가 아주 지능적이어서 여타의 방법보다 훨씬 매끄럽게 동시 작업을 있다. 게다가 고성능 CPU 덕분에 웬만큼 스레드를 생성해도 성능상 문제가 거의 없다.
그러나 멀티 스레드만큼 잘못 사용하면 위험한 것도 드물다. 동시에 복수 개의 코드가 같은 주소공간에서 실행됨으로써 서로 간섭하고 영향을 주는 경우가 빈번하여 주소 공간 분리의 이점이 없다. 또한 스레드간의 실행 순서를 전혀 예측할 없다는 점도 문제가 된다. 물론 운영체제는 이런 문제를 해결할 있는 방법을 제공하기느 ㄴ하지만 아주 사소한 부분에서도 민감한 문제가 발생할 있으며 이런 문제는 디버깅하기도 아주 어렵다. 그래서 멀티 스레드 기능을 활용하기 위해서는 문제점을 정확하게 파악해야 하며 해결책도 신중하게 결정해야 한다.
멀티스레드의 가장 문제점은 공유 자원을 보호하기 어렵다는 점이다. 공유자원이란 직렬 포트, 사운드 카드 등의 하드웨어가 수도 있지만 주로 메모리 영역의 전역변수인 경우가 대부분이다. 동일한 프로세스에 속한 스레드는 같은 주소 공간에서 실행되며 전역변수를 공유하므로 문제가 발생할 소지가 많다. 스레드가 같은 전역변수에 값을 대입할 경우 앞쪽 스레드가 대입해 놓은 값은 뒤쪽 스레드가 대입한 같에 의해 지워진다. 이런 식으로 스레드가 공유 자원을 서로 사용하려는 상태를 경쟁 상태(race condition)라고 한다.
또한 스레드간의 실행 순서를 제어하는 것도 쉽지 않은 문제이다. 어떤 스레드가 언제 실행될 것인가는 순전히 CPU 마음대로이기 때문에 순서를 지켜서 실행되어야 코드에는 여분의 복잡한 코드가 필요하기 마련이다. A스레드가 먼저 실행되고 후에 B 스레드가 실행되어야 한다면 B 스레드는 A스레드의 실행이 끝날때까지 기다려야 한다. 그것도 CPU 시간을 축내지 않으면서 아주 효율적으로 말이다. 경쟁 상태를 해소하기 위해서는 실행 순서를 통제해야 하며 그러다 보면 최악의 경우 스레드끼리 서로를 기다리는 교착상태(deadlock) 발생하기도 한다.
이런 여러 가지 문제를 해결하기 위해 스레드간의 실행 순서를 제어할 있는 기술들을 동기화(Synchronization)이라고 한다. 주로 경쟁 상태와 교착 상태 해결을 위한 기술들이되 때로는 작업 완료보고나 작업 시작 지시 스레드간의 통신을 위해서도 사용된다. 동기화란 쉽게 말해 스레드끼리 서로 방해하지 않고 보조를 맞추어 질서 정연하게 실행되게끔 하는 기술이다.
int x;
DWORD WINAPI ThreadFunc1(LPVOID Param)
{
HDC hdc;
hdc = GetDC(hWndMain);
for (int i = 0; i < 100; i++)
{
x = 100;
Sleep(1);
TextOut(hdc, x, 100, "강아지", 6);
}
ReleaseDC(hWndMain, hdc);
return 0;
}
 
DWORD WINAPI ThreadFunc2(LPVOID Param)
{
HDC hdc;
hdc = GetDC(hWndMain);
for (int i = 0; i < 100; i++)
{
x = 200;
Sleep(1);
TextOut(hdc, x, 200, "고양이", 6);
}
ReleaseDC(hWndMain, hdc);
return 0;
}
 
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
switch (iMessage)
{
case WM_CREATE:
hWndMain = hWnd;
return 0;
case WM_LBUTTONDOWN:
DWORD ThreadID;
HANDLE hThread;
CreateThread(NULL, 0, ThreadFunc1, NULL, 0, &ThreadID);
CloseHandle(hThread);
CreateThread(NULL, 0, ThreadFunc2, NULL, 0, &ThreadID);
CloseHandle(hThread);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return(DefWindowProc(hWnd, iMessage, wParam, lParam));
}
Sleep(1) 결과를 분명하게 보기 위해 삽입되었다. 스위칭을 자주 발생시키는 역할을 하는데 실제 예에서도 스레드간의 스위칭 시점은 불규칙하다. 선점형 멀티 태스킹 환경에서는 스케줄러가 스레드의 동의없이도 언제든지 스위칭할 있다.
의도대로라면 대각선으로 출력되어야 하지만 그렇지 못하게 출력되고 있다.


x 고유 하드웨어에 비유되는 존재이다. 하드웨어는 본질적으로 유일하며 전역적인 속성을 가질 밖에 없다. 양쪽 스레드의 Sleep(1) 대기문을 제거하면 x 값을 대입하는 코드와 TextOut 한묶으로 실행될 확률이 높아지므로 경쟁 상태의 문제가 감소할 있다. 그러나 확률이 아무리 낮다 하더라도 안전을 장담할 수는 없다.
. 해결 방법
동기화란 복수 개의 스레드가 보조를 맞추어 실행하도록 함으로써 경쟁 상태나 교착 상태를 해소하는 것이다.
동기화 문제를 해결하는 방법은 여러 가지가 있는데 도스에서 해왔던 것처럼 전통적인 방법으로도 해결할 있다. 전통적인 방법이란 다른 전역변수를 두고 스레드가 공유 자원을 사용할 때는 다른 스레드가 공유자원을 사용하지 못하게 하는 것이다.
int x;
BOOL wait = FALSE;
 
DWORD WINAPI ThreadFunc1(LPVOID Param)
{
HDC hdc;
for (int i = 0; i < 100; i++)
{
while (wait == TRUE) { ; }
wait = TRUE;
x = 100;
Sleep(1);
TextOut(hdc, x, 100, "강아지", 6);
wait = FALSE;
}
ReleaseDC(hWndMain, hdc);
return 0;
}
 
DWORD WINAPI ThreadFunc2(LPVOID Param)
{
HDC hdc;
hdc = GetDC(hWndMain);
for (int i = 0; i < 100; i++)
{
while (wait == TRUE) { ; }
wait = TRUE;
x = 200;
Sleep(1);
TextOut(hdc, x, 200, "고양이", 6);
wait = FALSE;
}
ReleaseDC(hWndMain, hdc);
return 0;
}
이렇게 하면 의대한대로 출력되었지만 비효율적이다. 왜냐하면 스레드가 while(wait==TRUE){;} 루프를 실행하는 동안에도 계속 CPU 시간을 낭비하고 있기 때문이다. 루프 회수를 100만으로 늘리고 Sleep 제거하면 CPU 점유율은 100%까지 치솟을 것이다. 어차피 대기만 것이라면 자기에게 주어진 CPU시간을 바쁜 다른 스레드에게 양보하는 것이 훨씬 효율적이다. 그리고 방법은 100% 안전하지도 않다.
예제가 안전하지 않은 이유는 while문과 wait 변경하는 문장이 묶음이 아니기 때문이다. while 루프를 벗어나 자신이 제어를 가지는 즉시 다른 스레드가 끼어들지 못하게 신속하게 wait TRUE 바꿔야 하는데 중간에 스위칭이 발생해 버리면 동기화의 목적을 이룰 없다. 대기를 하는 문장과 다른 스레드를 블록시키는 문장이 이상 분리할 없는 원자성을 가지지 못하기 때문이다. 동기화를 위해 전역 변수를 사용하는 것은 효율상의 문제뿐만 아니라 100% 안전을 보장할 없느 ㄴ한계가 있으며 그래서 확실한 동기화 방법이 필요해진 것이다.
. 크리티컬 섹션
운영체제가 지원하는 동기화 방법은 근본적으로 wait 전역변수를 사용하는 방법과 유사하다. 스레드가 실행될 있는 상황인가를 판단해서 조건이 맞을 때까지 대기한다. 게다가 전역변수를 쓰는 단순한 방법에 비해 훨씬 복잡한 상황에도 대처할 있으며 효율성에 있어서도 비교가 되지 않을 만큼 유리하다.
크리티컬 섹션은 공유 자원의 독점을 보장하는 코드의 영역이라고 있으며 wait=TRUE ~ wiat=FALSE 사이의 코드가 좋은 예이다. 다른 스레드에 의해 방해받지 말아야 작업을 영역을 크리티컬 섹션으로 둘러싸 놓으면 전역 자원의 독점권이 주어진다.
크리티컬 섹션은 다음 함수로 초기화 파괴한다.
VOID InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
VOID DeleteCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
 
실제로 크리티컬 섹션을 구성하는 함수이다.
VOID EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
VOID LeaveCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
함수 사이의 코드가 바로 크리티컬 섹션이 된다.
 
EnterCriticalSection 함수는 코드가 크리티컬 섹션을 소유하도록 하며 이후부터 다른 스레드는 같은 크리티컬 섹션에 들어올 없다.
사용하는 논리는 wait 전역변수와 완전히 동일하다. 그러나 Enter 함수가 대기할 할당된 CPU 시간을 즉시 포기함으로써 다른 스레드에게 실행 시간을 양보하며 그래서 while 루프로 무작정 대기하는 것보다는 훨씬 효율적이다. 또한 크리티컬 섹션 점검과 상태 변경이 묶음이므로 작업을 하는 동안에는 절대로 스위칭이 발생하지 않는다.
주의할 점은 크리티컬 섹션에 일단 들어간 후는 반드시 빨리 Leave 호출해야 한다는 점이다. 만약 함수를 호출하지 않고 스레드를 빠져나와 버리면 이후부터 다른 스레드는 크리티컬 섹션에 들어갈 없을 것이다. 실수로 함수 호출문을 빼먹는 경우야 없겠지만 크리티컬 섹션에서 예외가 발생하여 Leave 함수가 호출되지 못하는 사고가 발생하는 경우가 있을 있다. 그래서 반드시 구조적 예외 처리 구문에 포함 시키는 것이 좋다.
__try {
EnterCriticalSection(&cs);
}
__finally{
LeaveCriticalSection(&cs);
}
. 교착 상태
교착 상태(deadlock) 대기 상태가 종료되지 않아 무한정 대기만 하는 상태이다. Enter함수는 다른 스레드가 크리티컬 섹션을 소유하고 있으면 대기 상태로 들어간다. Enter에서 대기중인 스레드는 Enter 밑의 코드를 실행할 없으므로 스스로 대기 상태를 없다. 대기 상태는 스레드 외부에서 풀어 주어야 하는데 만약 논리적인 오류로 대기 상태를 풀지 못하면 교착 상태가 된다.
교착 상태는 보통 이상의 동기화 방법이 서로 얽혀서 발생한다.
ThreadFunc1
EnterCriticalSection(&cs1); //1
EnterCriticalSection(&cs2); //3
// 공유 자원 2 액세스 한다.
LeaveCriticalSection(&cs2);
LeaveCriticalSection(&cs1);
 
ThreadFunc2
EnterCriticalSection(&cs2);
EnterCriticalSection(&cs1); //2
// 공유 자원 2 액세스 한다.
LeaveCriticalSection(&cs1);
LeaveCriticalSection(&cs2);
t1 EnterCriticalSection(&cs1); 실행하다가 스위칭이 발생했다고 하자. 이때 t1 이미 cs1 소유한 상태이다. t2 제어권을 받았을  
EnterCriticalSection(&cs1);에서 멈추게 된다. 왜냐하면 cs1 이미 다른 스레드가 소유했기 때문에 대기 상태로 들어가야 하기 때문이다. t1 제어권을 받았을
EnterCriticalSection(&cs2);에서 교착상태가 된다. t1 cs2 기다리게 되고 t2 cs1 기다리는 상태가 되므로 어느 하나도 이상 진행을 하지 못하고 무한 대기만 하는 것이다.
. 인터락 함수
스위칭이 발생할 레지스터의 값은 물론 온전히 보존된다. 그러나 공유하고 있는 메모리 영역까지 보존하는 것으 아니므로 계산중에 스위칭된 경우는 다른 스레드에서 변경한 값을 다시 써버리는 문제점이 있다. 이런 문제를 해결하려면 크리티컬 섹션으로 감싸주던가, 별도의 증가 함수를 사용해야 한다. 멀티 스레드에서 안전하게 변수값을 조작하는 함수를 인터락 함수라고 부르며 다음과 같은 것들이 있다.
LONG InterlockedIncrement(IN PLONG Addend);
LONG InterlockedDecrement(IN PLONG Addend);
LONGLONG InterlockedIncrement64(LONGLONG volatile* Addend);
LONGLONG InterlockedDecrement64(LONGPLONG Addend);
InterlockedIncrement 함수는 Addend 변수의 값을 증가시키는 동안 스위칭이 발생하지 않도록 함으로써 스레드간에 공유하는 변수를 안전하게 증가시킨다. 나머지 함수들도 마찬가지이다. 함수들이 어떤 식으로 스위칭을 금지하는가는 플랫폼마다 구현이 다른데 인텔 계열 CPU 메모리에 락을 거는 기법을 사용하며 알파CPU CPU 특정 비트를 세트하여 스위칭을 잠시 금지하는 기법을 쓴다.
다음 함수는 값을 대입하거나 증감시킨다.
LONG InterlockedExchange(IN OUT PLONG Target, IN LONG Value);
LONG InterlockedExchangeAdd(IN OUT PLONG Addend, IN LONG Value);
LONG InterlockedCompareExchange(IN OUT PLONG Destination, IN LONG Exchange, IN LONG Comparand);
함수
설명
InterlockedExchange
Target Value 대입하여 이전 값을 리턴
Target = value; 인데 스레드에 의해 방해받지 않도록 한다.
InterlockedExchangeAdd
Target += value;
value 음수를 주면 뺄셈도 가능하다.
InterlockedCompareExchange

Destination Comparand 같을 경우 Exchange 대입한다.
if (Destination == Comparand) Destination = Exchange;
함수 모두 포인터 버전과 64비트 버전도 존재한다. 64비트 버전은 LONGLONG타입을 인수로, 포인터 버전은 PVOID형을 다루고 64비트에서는 64비트의 값을 다룬다. 포인터를 안전하게 그리고 플랫폼에 상관없이 증감시키려면 함수를 사용해야 한다.
공유 자원, 전역변수일 때만 이런 처리가 필요하며 지역 변수는 단순한 연산자로 직접 증가, 대입해도 안전하다. 공유되는 전역 변수를 조작할 그것도 읽을 때는 상관없지만 변경할 때만 함수를 사용하면 되는데 사실 그것보다 좋은 방법은 스레드끼리 통신할 때는 가급적 전역 변수를 쓰지 않는 것이다.
02 뮤텍스
. 동기화 객체
동기화 객체(Synchronization Object) 그대로 동기화에 사용되는 개체이다. 프로세스, 스레드처럼 커널 객체이며 프로세스 한정적인 핸들을 가진다. 동기화 객체는 유저 모드에서 동작하는 크리티컬 섹션보다 느리기는 하지만 훨씬 복잡한 동기화에 사용할 있다. 동기화 객체는 일정 시점에서 가지 상태중 상태를 가진다.
신호상태(Signaled)
스레드의 실행을 허가하는 상태이다. 신호 상태의 동기화 객체를 가진 스레드는 계속 실행될 있다. 파란불
비신호상태(Nonsignaled)
스레드의 실행을 허가하지 않는 상태이며 신호상태가 때까지 스레드는 블록된다. 빨간불
동기화 객체는 대기 함수와 함께 사용되는데 대기 함수는 일정한 조건에 따라 스레드의 실행을 블록하거나 실행을 허가하는 함수이다. 여기서 일정한 조건이란 주로 동기화 객체의 신호 여부가 된다.
DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);
함수는 hHandle 지정하는 하나의 동기화 객체가 신호상태가 되기를 기다린다. dwMilliseconds인수는 타임 아웃 시간을 1/1000 단위로 지정하는데 시간이 경과하면 설사 동기화 객체가 비신호상태이더라도 즉시 리턴함으로써 무한 대기를 방지한다. 타임 아웃을 INFINITE 지정하면 신호상태가 때까지 무한정 대기한다. WaitForSingleObject 함수는 동기화 객체가 신호상태가 되거나 타임 아웃 시간이 경과할 때까지 스레드의 실행을 블록하는 역할을 한다고 정리할 있다. 함수의 리턴값을 검사해보면 어떤 이유로 대기 상태를 종료했는지를 있다.
설명
WAIT_OBJECT_0
hHandle 객체가 신호상태가 되었다.
WAIT_TIMEOUT
타임 아웃 시간이 경과하였다.
WAIT_ABANDONED
포기된 뮤텍스
WaitForSingleObject 함수는 리턴하기 전에 hHandle 동기화 객체의 상태를 변경한다. 그래서 스레드가 동기화 객체를 소유하면 동기화 객체는 비신호상태가 되므로 다른 스레드가 객체를 중복하여 소유하지 못한다.
. 뮤텍스
뮤텍스는 프로세스간에도 사용할 있다는 점에서 크리티컬 섹션과 다르지만 속도가 크리티컬 섹션 보다는 느리다.
Mutex라는 이름은 Mutual Exclusion 줄임말인데 스레드가 동시에 소유할 없다는 뜻이며 한국어로 '상호배제'라고 번역하기도 한다. 뮤텍스는 스레드에 의해서만 소유될 있으며 일단 어떤 스레드에게 소유되면 비신호상태가 된다. 반대로 어떤 스레드에도 소유되어 있지 않은 상태라면 신호상태가 된다. 뮤텍스를 사용하려면 우선 함수로 생성해야 한다. 함수는 뮤텍스를 생성한 핸들을 리턴한다.
HANDLE CreateMutext(LPSECURITY_ATTRIBUTES lpMutexAttributes, BOOL bInitialOwner, LPCTSTR lpName);
인자
설명
lpMutextAttributes
보안 속성을 지정, 보통 NULL 설정
bInitialOwner
뮤텍스를 생성함과 동시에 소유할 것인지를 지정, TRUE이면 함수를 호출한 스레드가 뮤텍스를 소유하며 비신호상태로 생성됨
lpName

뮤텍스의 이름을 지정하는 문자열, 뮤텍스는 프로세스끼리의 동기화에도 사용되므로 이름을 가지는데 이름은 프로세스간에 뮤텍스를 공유할 사용된다. 커널 객체들은 이런식으로 문자열로 이름을 가짐으로써 공유 가능하다.
뮤텍스에 이름이 있을 경우 다른 프로세스가 뮤텍스의 이름만 알면 OpenMutex함수로 뮤텍스의 핸들을 얻을 있다. 또는 같은 이름으로 CreateMutex 호출해도 된다.
생성한 뮤텍스를 파괴할 때는 모든 커널 객체와 마찬가지로 CloseHandle 함수를 사용한다. CloseHandle 뮤텍스 핸들을 닫으며 핸들이 대상 뮤텍스를 가리키는 마지막 핸들이라면 뮤텍스 객체도 파괴한다. 뮤텍스는 스스로 카운트를 관리하며 모든 핸들이 닫힐 객체도 닫힌다.
일단 뮤텍스가 생성되면 대기 함수에서 뮤텍스를 사용할 있다. 대기함수는 뮤텍스가 신호상태가 때까지 대기한다. 뮤텍스가 신호상태가 되면 대기 함수는 즉시 뮤텍스를 비신호상태로 만들어 같은 뮤텍스를 대기하는 다른 스레드를 블록시킨다. 그래서 뮤텍스를 소유한 스레드는 전역 자원을 독점으로 안전하게 액세스 있다. 비손호상태의 뮤텍스를 다시 신호상태로 만들 때는 다음 함수를 호출한다.
BOOL ReleaseMutex(HANDLE hMutex);
int x = 0;
HANDLE hMutex = CreateMutex(NULL, FALSE, NULL);
 
DWORD ThreadFunc(LPVOID param)
{
WaitForSingleObject(hMutex, INFINITE);
// 해야 작업
x++;
ReleaseMutex(hMutex);
}
 
// 다른 스레드, 또는 ThreadFunc 2 이상일
크리티컬 섹션과 별로 다르지 않다. Enter ~ Leave Wait ~ Release 바뀐 것과 같다.
. 대기 함수
스레드를 동기화하는 주요 수단은 동기화 객체와 대기 함수이다.
대기 함수는 다음과 같은 특징을 가진다.
  1. 대기 함수는 스레드의 실행을 블록하여 대기시키는 역할을 한다. 여기서 블록한다는 말은 조건을 만족할 때까지 실행하지 못하게 한다는 뜻이다. 블록 조건은 대기 함수에 따라 다르다.
  2. 대기 중에는 CPU시간을 거의 소비하지 않음으로써 효율적으로 대기한다.
  3. 대기를 풀면서 동기화 객체의 상태를 변경한다. 어떻게 변경하는가는 동기화 객체에 따라 달라지는데 보통 비신호상태로 만들어 다른 스레드의 실행을 블록한다.
 
DWORD WaitForMultipleObjects(DWORD nCount, CONST HANDLE* lpHandles, BOOL fWaitAll, DWORD dwMilliseconds);
WaitForSingleObject 하나의 동기화 객체를 기다리는 대기 함수인데 비해 함수는 복수 개의 동기화 객체를 대기할 있다.
인자
설명
lpHandles
동기화 객체의 핸들 배열
nCount
배열의 크기, 동기화 객체의 개수
fWaitAll

TRUE : 모든 동기화 객체가 신태상태가 될때까지 대기
FALSE : 하나라도 신호상태가 되면 대기상태 종료
AND 대기인가 OR대기인가를 지정
dwMilliseconds
WaitForSingleObject 마찬가지로 타임아웃
리턴값의 의미를 경우에 따라 달리진다.
리턴값
설명
WAIT_TIMEOUT
지정한 시간이 경과했다는
bWaitAll TRUE WAIT_OBJECT_0
모든 동기화 객체가 신호상태가
bWaitAll FALSE   lpHandles 인덱스
lpHandle배열에서 신호상태가 동기화 객체
이경우 lpHandles[리턴값-WAIT_OBJECT_0] 식으로 신호상태가 동기화 객체의 핸들을 구할 있다.
. 프로세스간의 동기화
윈도우즈는 프로그램의 여러 인스턴스를 허용하지만 번만 실행되어야 하거나 실행될 필요가 없는 프로그램들이 훨씬 많다. 디바이스 드라이버, 서비스 류의 프로그램은 실행되어서는 안된다. 이런 프로그램을 만들 뮤텍스가 흔히 사용된다.
이럴 경우 mutex 이름을 주고 이상의 프로세스를 실행하면서 동기화를 있다.
HANDLE hMutex = CreateMutex(NULL, FALSE, "OnceMutex");
if (GetLastError() == ERROR_ALREADY_EXISTS)
{
CloseHandle(hMutex);
// 이미 다른 프로세스가 실행중입니다.
return 0;
}
첫번째 프로세스가 실행중인 상태에서 두번째 프로세스가 실행되면 CreateMutex 함수는 이미 존재하는 "OnceMutex" 핸들을 리턴한다. 그리고 GetLastError ERROR_ALREADY_EXISTS 리턴함으로써 뮤텍스가 새로 만들어진 것이 아니라 이미 만들어진 뮤텍스의 다른 핸들이라는 것을 알린다.
실행 중인 이전 인스턴스를 찾는 방법에는 여러 가지가 있는데 뮤텍스를 사용하는 방법도 있다.
주의할 점은  뮤텍스의 이름은 대소문자를 구분한다는 것이다. 또한 뮤텍스는 이벤트, 세마포어, 파일 맵핑 객체들과 같은 네임 스페이스를 공유하므로 커널 객체와도 이름이 중복되어서는 안된다. 만약 이름이 중복된다면 CreateMutex 뮤텍스를 생성하지 못하고 에러를 낸다.
. 포기된 뮤텍스
구조적 예외가 발생한 경우나 ExitThread 스레드를 종료했거나 TerminateThread 스레드를 강제로 죽였을 경우 스레드에서 뮤텍스를 소유한채 끝날 잇다.
크리티컬 섹션의 경우 블록된 스레드를 깨울 있는 방법이 없지만 뮤텍스의 경우는 가지 안전장치가 있다.
뮤텍스는 자신을 소유한 스레드가 누구인지를 기억하고 있는데 시스템은 뮤텍스의 소유 스레드가 뮤텍스를 풀지않고 종료되었을 경우 강제로 뮤텍스를 신호상태로 만드는 시스템 차원의 예외 처리를 적용한다. 이때의 뮤텍스를 포기된 뮤텍스(Abandoned Mutex)라고 한다. 뮤텍스가 포기되면 대기중인 스레드중 하나가 뮤텍스를 가지게 것이다. 스레드는 WaitForSingleObject 함수의 리턴값으로 WAIT_ABANDONED값을 전달받음으로써 뮤텍스가 정상적인 방법으로 신호상태가 것이 아니라 포기된 것임을 있다.
포기된 뮤텍스를 받았다는 것은 스레드 코드에 뭔가 버그가 있다는 뜻이다. 관련 스레드가 정상 종료하지 못했음을 있는데 이때 뮤텍스에 의해 보호되는 공유 자원의 상태는 없다.
시스템이 포기된 뮤텍스를 인식할 있는 이유는 뮤텍스 내부에 소유한 스레드의 ID 정보가 있기 때문인데 이는 다른 동기화 객체에는 없는 뮤텍스만의 독특한 기능이다. 소유 스레드의 ID 정보에 의해 뮤텍스는 중복 소유도 가능하다. 스레드가 뮤텍스를 소유하고 있는 상황에서 다른 스레드는 뮤텍스를 소유하지 못하는데 만약 같은 스레드가 뮤텍스를 두번 소유하고자 하면 어떻게 될까?
DWORD WINAPI ThreadFunc(LPVOID param)
{
WaitForSingleObject(hMutex, INFINITE);
MyFunc();
// 작업
ReleaseMutex(hMutex);
}
 
void MyFunc()
{
WaitForSingleObject(hMutext, INFINITE);
// 작업
ReleaseMutex(hMutex);
}
ThreadFunc에서 hMutex 기다리며 전역 함수 MyFunc hMutex 기다린다. 그런데 ThreadFunc에서 MyFunc 호출하면 스레드는 hMutex 두번 소유하게 된다. 경우 첫번째 대기 함수는 hMutex 소유하고 뮤텍스는 비신호상태로 만들 것이다. 그럼 번째 대기 함수를 호출할 때는 어떻게 될까? 뮤텍스가 비신호상태이므로 무한정 대기해야겠지만 만약 그렇게 된다면 상태는 교착상태와 같아진다. 자신이 소유한 뮤텍스를 자신이 기다리고 있기 때문에 대기 상태를 결코 풀리지 않을 것이다. 그러나 다행히 실제로는 그렇지 않다.
뮤텍스는 자신의 소유주를 기억함은 물론이고 소유된 횟수도 기억하고 있다. 그래서 이미 자신을 소유한 스레드가 다시 자신을 소유하고자 경우는 해당 스레드를 블록시키지 않고 소유 횟수만 증가시킨다. 스레드는 같은 뮤텍스를 여러 소유할 있다. 뮤텍스를 다시 신호상태로 만들기 위해서는 ReleaseMutex 소유한 횟수만큼 호출해야 한다. 중복 소유 관해서는 크리티컬 섹션에도 똑같이 규칙이 적용된다.
WaitForSingleObject함수는 뮤텍스가 비신호상태이면 스레드를 블록시킨다. 그런데 대기하는 동안에도 뭔가 다른 일을 하고 싶다면 어떻게 할가. 물론 여기서 말하는 뭔가 다른 일은 공유 자원과는 전혀 상관없는 일이어야 한다. 이때 필요한 함수는 대기는 하지 않고 단순히 뮤텍스가 신호상태인지 아닌지 조사만 하는 함수면 된다.
WaitForSingleObject함수는 타임 아웃 인자를 0으로 넘기면 곧바로 신호상태를 조사해 리턴한다.
while(WaitForSingleObject(hMutex, 0) == WAIT_TIMEOUT)
{
// 다른
}
 
// 공유 자원 액세스
이때 타임 아웃값을 10정도로 주면 다른 스레드에게 시간을 양보할 수도 있다.
03 세마포어
. 제한된 자원
뮤텍스는 하나의 공유 자원을 보호하기 위해 사용하지만 세마포어는 제한된 일정 개수를 가지는 자원을 보호하고 관리한다. 자원의 개수가 제한되어 있으므로 자원을 사용할 있는 스레드의 실행도 제약을 바든ㄴ다. 만약 스레드당 하나의 자원이 필요한데 자원의 개수가 5개밖에 되지 않으며 스레드는 10개가 있다고 하자. 그러면 5개의 스레드는 자원을 사용할 있지만 나머지 5개의 스레드는 자원이 사용가능해질 때까지 대기해야 한다.
세마포어는 사용 가능한 자원의 개수를 카운트하는 동기화 객체이다. 유효 자원이 0이면 하나도 사용할 없으면 세마포어는 비신호상태가 되며, 1이상이면 하나라도 사용할 있으면 신호상태가 된다. 뮤텍스가 스레드의 실행 여부만을 통제하는 BOOL형읟 ㅗㅇ기화 객체라면 세마포어는 실행 가능한 스레드의 개수를 관리하는 int형의 동기화 객체라고 있다.
예를 들어, 네트워크를 통해 프로그램을 다운로드 받는 프로그램을 만든다고 해보자. 동시에 여러 개의 자료를 다운로드 받는 것이 가능하기는 하지만 별로 효율적이지 않다. 어차피 네트워크의 대역폭은 정해져 있는 것이므로 한꺼번에 다운로드받는다고 해서 빨라지는 것도 아니고 그렇다고 순서대로 받는 것은 불편하다. 그래서 동시에 받되 최대 3개까지만 동시 다운로드가 가능하도록 하고 싶다. 실제 다운로드 툴이나 브라우저도 일정개수까지만 동시 다운로드를 지원한다.
경우 자원이란 다운로드 권한이라고 표현할 있을 것이며 세마포어는 권한을 카운트 한다. 세마포어의 초기값을 3으로 설정하고 사용자의 요구가 있을 때마다 다운로드 스레드를 생성한다. 스레드에서 WaitForSingleObject 함수를 호출하여 자신이 사용할 있는 권한이 아직 남아 있는지 검사해 보고 가능하다면 다운로드 루프로 진입한다. 이때 대기 함수는 세마포어의 카운트를 1 감소시켜 자원이 하나 줄어들었음을 기록한다. 두번째, 세번째 스레드가 다운로드를 시작하면 세마포어의 카운트는 0 되며 비신호상태가 된다. 그러면 이후부터 생성되는 스레드는 다운로드 루프로 진입하지 못하고 자원이 사용가능해질 때까지 대기하게 된다. 대기 중에 첫번째 다운로드가 완료되면 세마포어를 것이고 그러면 세마포어는 1증가하여 신호상태가 된다. 대기 중인 다운로드 스레드는 즉시 다운로드를 시작한다.
세마포어에 관련된 함수를 살펴보자.
HANDLE CreateSemaphore(LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, LONG lInitialCount, LONG lMaximumCount, LPCTSTR lpName);
HANDLE OpenSemaphore(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSTR lpName);
CreateSemaphore 함수로 세마포어를 생성하되 lMaximumCount 최대 사용 개수를 지정하고 lInitialCount 초기값을 지정한다. 아주 특별한 경우를 제외하고 보통 값은 동일하다. 세마포어는 뮤텍스와 마찬가지로 이름을 가질 있고 일므을 알고 있는 프로세스는 언제든지 OpenSemaphore 함수로 세마포어의 핸들을 구할 있다. 세마포어는 커널 객체이므로 CloseHandle 파괴한다.
다음 함수는 세마포어의 카운트를 증가시키는 함수이다.
BOOL ReleaseSemaphore(HANDLE hSemaphore, LONG lReleaseCount, LPLONG lpPreviousCount);
자원의 사용이 끝난 스레드는 함수를 호출하여 사용 종료를 세마포어에 알려야한다. lReleaseCount 자신이 사용한 자원의 개수를 알리는데 하나만 사용했으면 1이고, 여러개의 자원을 사용했다면 사용한만큼 자원을 풀어야 한다. 스레드가 여러 개의 자원을 한꺼번에 사용하는 것이 가능한데 필요한만큼 대기함수를 호출하면 된다. 세번째 인수는 세마포어의 이전 카운트를 리턴받기 위한 참조 인수이다. 값에 관심이 없으면 NULL 넘긴다.
. SemDown 예제
HANDLE hSem;
int Y = 0;
DWORD WINAPI ThreadDownLoad(LPVOID Param)
{
HDC hdc;
int i, y, j, s;
TCHAR str[256];
 
srand(GetTickCount());
s = rand() % 5 + 1;
hdc = GetDC(hWndMain);
 
Y += 20;
y = Y;
TextOut(hdc, 10, y "대기중", 6);
WaitForSingleObject(hSem, INFINITE);
for (i = 0; i < 100; i++)
{
wprintf(str, "다운로드중 : %d%%완료", i);
for (j = 0; i < j; j++)
strcat(str, "|");
TextOut(hdc, 10, y, str, strlen(str));
Sleep(20 * s);
}
lstrcpy(str, "다운로드를 완료햇습니다");
TextOut(HDC, 10, y, str, strlen(str));
ReleaseSemaphore(hSem, 1, NULL);
ReleaseDC(hWndMain, hdc);
return 0;
}
 
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
switch(iMessage)
{
case WM_CREATE:
hWndMain = hWnd;
hSem = CreateSemaphore(NULL, 3, 3, NULL);
return 0;
case WM_LBUTTONDOWN:
hThread = CreateThread(NULL, 0, ThreadDownLoad, NULL, 0, &ThreadID);
CloseHandle(hThread);
return 0;
}
}
. SemaphoreThree 예제
void main()
{
HANDLE hSem;
hSem = CreateSemaphore(NULL, 3, 3, "OnlyThreeOfTheseProgramsCanBeRun");
if (WaitForSingleObject(hSem, 0) == WAIT_TIMEOUT)
{
CloseHandle(hSem);
// 세개까지만 실행 가능
return 0;
}
 
// 프로그램
ReleaseSemaphore(hSem, 1, NULL);
CloseHandle(hSem);
return 0;
}
WaitForSingleObject함수를 호출하되 타임 아웃을 0으로 주어 핸들이 신호상태인가만 조사한다. 만약 비신호상태라면 이미 개의 세마포어가 모두 사용중이라는 뜻이며 개의 인스턴스가 이미 실행되었다는 뜻이므로 종료된다.
04 이벤트
. 이벤트
이벤트(Event) 어떤 사건이 일어났음을 알리는 동기화 객체이다. 크리티컬 섹션, 뮤텍스, 세마포어는 주로 공유 자원을 보호하기 위해 사용되는데 비해 이벤트는 그보다도 스레드간의 작업 순서나 시기를 조정하고 신호를 보내기 위해 사용한다. 특정한 조건이 만족될 때까지 대기해야 하는 스레드가 있을 경우 스레드의 실행을 이벤트로 제어할 있다.
이벤트는 윈도우즈의 메시지와 유사하다. 사용자가 키보드를 누를 WM_KEYDOWN 메시지를 윈도우 프로시저에게 보내 처리하게 하는 것처럼 정렬이나 다운로드가 끝났을 이벤트를 보내 관련된 다른 작업을 하도록 지시할 있다.스레드간의 통신에 메시지를 사용하지 못하는 이유는 작업 스레드는 윈도우를 만들지 않으며 메시지는 윈도우끼리만 주고 받을 있기 때문이다. 그래서 스레드간의 통신을 위해 이벤트 동기화 객체를 사용해야 한다.
이벤트를 기다리는 스레드는 이벤트가 신호상태가 때까지 대기하며 신호상태가 되면 대기를 풀고 작업을 시작한다. 이때 대기함수가 대기를 풀면서 이벤트 객체를 어떻게 처리하는가에 따라 가지 종류로 구분된다.
  • 자동 리셋 이벤트 : 대기 상태가 종료되면 자동으로 비신호상태가된다.
  • 수동 리셋 이벤트 : 스레드가 비신호상태로 만들때까지 신호상태를 유지한다.
 
다음  함수는 이벤트를 만들거나 오픈하는 함수이다.
HANDLE CreateEvent(LPSECURITY_ATTRIBUTES lpEventAttributes, BOOL bManualReset, BOOL bInitialState, LPCTSTR lpName);
HANDLE OpenEvent(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSTR lpName);
bManualReset 이벤트가 수동 리셋 이벤트인지 자동 리셋 이벤트인지를 지정하는데 TRUE이면 수동 리셋 이벤트가 된다. bInitialState TRUE이면 이벤트를 생성함과 동시에 신호상태로 만들어 이벤트를 기다리는 스레드가 곧바로 실행을 하도록 한다. 이벤트도 이름을 가지므로 프로세스간의 동기화에 사용될 있다. 이벤트가 뮤텍스나 크리티컬 섹션과 또다른 점은 대기 함수를 사용하지 않고도 임의적으로 신호상태와 비신호상태를 설정할 있다는 점이다. 이때는 다음 함수를 사용한다.
BOOL SetEvent(HANDLE hEvent);
BOOL ResetEvent(HANDLE hEvent);
SetEvent 이벤트를 신호상태로 만들고 ResetEvent 이벤트를 비신호상태로 만든다. 이런 상태 변화가 대기중인 스레드에게는 일종의 신호로 전달된다.
. BackEvent
멀티 스레드가 진가를 발휘할 때는 백그라운드 작업을 때이다. 예를 들어 통계와 같이 오랜 시간이 걸리는 수학적 계산, 인쇄, 다운로드, 워크시트의 재계산, 자료 정렬, 렌더링 등을 스레드에게 작업을 분담할 있다.
void Calculate(void)
{
int i;
for(i=0;i<100;i++) {
Sleep(30);
buf[i]=i
}
}
스레드에서 함수를 호출하여 계산을 시켰다면 함수는 계산이 완료되기 전에는 리턴하지 못하므로 동안 주스레드는 무작정 기다려야만 한다. 스레드는 함수에 의해 블록(Block)되며 스레드는 어떠한 동작도, 심지어 사용자의 입력을 처리하는 것도 없다. 아주 짧은 시간에 계산이 완료된다면 상관이 없겠으나 심지어 시간이 걸릴지도 모르는 작업이라면 이렇게 해서는 안된다. 이럴 계산 함수를 별도의 스레드로 분리한다.
DWORD WINAPI ThreadFunc(LPVOID temp)
{
int i;
for(i=0;i<100;i++) {
Sleep(30);
buf[i]=i
}
return 0;
}
스레드는 새로 스택을 생성하고 자신만의 실행 흐름을 가지며 스레드와는 따로 스케줄링된다.
그러나 주스레드가 계산 결과를 화면에 출력해야 한다면 문제는 달라진다. 결국 주스레드는 계산 스레드가 작업을 완료할 때까지 기다린 후에야 화면 출력을 있으며 이렇게 되면 결국 함수를 호출하는 것과 별반 차이가 없다. 그러나 계산이 완전히 종료될 때까지 기다려야 하는 것은 아니다. 화면에 출력될 분량만큼만 계산이 완료되면 스레드는 즉시 작업이 가능하다.
예를 들어 DB에서 수만건을 읽어와 출력한다고 모든 데이터를 읽을 때까지 스레드가 기다릴 필요없이 당장 출력할 정도만 되면 일단 화면에 보이고 나머지 작업은 백그라운드 스레드가 하도록 맡겨 놓으면 된다. 작업 스레드가 어디까지 계산을 했는지 스레드에게 알릴 필요가 있으면 이런 통신에 이벤트가 사용된다.
. 수동 리셋 이벤트
자동 리셋이라는 말의 의미는 대기 상태를 자동으로 이벤트를 비신호사아태로 만든다는 뜻이다. 하나의 스레드만을 위해 이벤트를 사용할 때는 자동 리셋 이벤트를 사용하는 것이 훨씬 편리하며 논리적으로도 문제가 없다. 그러나 하나의 이벤트를 여러 개의 스레드가 대기해야 때는 문제가 달라진다.
예를 들어 계산 스레드는 열심히 계산을 하고 있고 계산 스레드가 계산을 마치기를 가디른 스레드가 5개찜 있다고 하자. 계산된 결과를 화면으로 보여주는 스레드, 인쇄하는 스레드, 누군가에게 메일을 보내는 스레드 등등을 생각할 있다. 계산 스레드가 계산을 완료하고 이제 계산 결과를 사용해도 좋다는 뜻으로 이벤트를 신호상태로 만들것이다. 그러면 대기하던 5개의 스레드가 일제히 작업을 시작해야 하는데 자동 리셋 이벤트에서는 이것이 불가능하다.
이벤트가 신호상태가 직후에 제일 먼저 스위칭되는 스레드가 이벤트를 받아 대기 상태에서 풀려나게 되는데 대기 상태에서 풀려나면서 이벤트를 다시 비신호상태로 만들어 버리기 때문이다. 그러면 나머지 4개의 스레드는 계산이 완료되었음에도 불구하고 사실을 없다. 결국 자동 리셋 이벤트는 한번의 신호만 보낼 있을 뿐이다.
수동 리셋 이벤트는 대기 함수가 리턴될 신호상태를 그대로 유지하며 ResetEvent 함수로 일부러 비신호상태로 만들 때만 상태가 변경된다. 그래서 여러 개의 스레드가 하나의 이벤트를 기다리고 있더라도 번의 신호로 대기하던 모든 스레드가 일제히 작업을 시작할 있다. 수동 리셋 이벤트를 다시 비신호상태로 만들어야 하는 시점은 이벤트를 기다리는 모든 스레드가 대기 상태에서 풀려났을 때이다.
HANDLE hEvent;
DWORD WINAPI ThreadSend(LPVOID temp)
{
WaitForSingleObject(hEvent, INFINITE);
HDC hdc = GetDC(hWndMain);
TextOut(hdc, 210, 100, "전송완료", 8);
ReleaseDC(hWndMain, hdc);
return 0;
}
DWORD WINAPI ThreadSave(LPVOID temp)
{
WaitForSingleObject(hEvent, INFINITE);
HDC hdc = GetDC(hWndMain);
TextOut(hdc, 110, 100, "저장완료", 8);
ReleaseDC(hWndMain, hdc);
return 0;
}
DWORD WINAPI ThreadPrint(LPVOID temp)
{
WaitForSingleObject(hEvent, INFINITE);
HDC hdc = GetDC(hWndMain);
TextOut(hdc, 10, 100, "인쇄완료", 8);
ReleaseDC(hWndMain, hdc);
return 0;
}
DWORD WINAPI ThreadCalc(LPVOID temp)
{
HDC hdc = GetDC(hWndMain);
for (int i = 0; i < 10; i++) {
TextOut(hdc, 10, 50, "계산중", 6);
Sleep(300);
}
TextOut(hdc, 10, 50, "계산완료", 8);
ReleaseDC(hWndMain, hdc);
SetEvent(hEvent);
return 0;
}
 
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
DWORD ThreadID;
TCHAR* Mes = "마우스 왼쪽 버튼 클릭시 계산 시작";
 
switch (iMessage)
{
case WM_CREATE:
hWndMain = hWnd;
hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
// 자동 이벤트
// hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
return 0;
case WM_LBUTTONDOWN:
ResetEvent(hEvent);
CloseHandle(CreateThread(NULL, 0, ThreadCalc, NULL, 0, &ThreadID));
CloseHandle(CreateThread(NULL, 0, ThreadPrint, NULL, 0, &ThreadID));
CloseHandle(CreateThread(NULL, 0, ThreadSave, NULL, 0, &ThreadID));
CloseHandle(CreateThread(NULL, 0, ThreadSend, NULL, 0, &ThreadID));
return 0;
case WM_PAINT:
hdc = BeginPaint(hWnd, &ps);
TextOut(hdc, 10, 10, Mes, lstrlen(Mes));
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
CloseHandle(hEvent);
PostQuitMessage(0);
return 0;
}
return (DefWindowProc(hWnd, iMessage, wParam, lParam));
}
계산 스레드가 계산을 완료했다는 사실이 이벤트로써 다른 스레드에게 통보된다.
계산이 완료되면 SetEvent함수를 호출하여 이벤트를 신호상태로 만든다. 작업 스레드는 처음부터 hEvent 기다리는데 스레드가 생성되기 전에 이벤트는 비신호상태이므로 누군가가 이벤트를 신호상태로 변경하기 전에는 실행되지 않는다. 계산 드레드가 계산을 완전히 마친 후에야 이벤트가 신호상태가 되므로 결국 작업 스레드들은 계산 완료를 대기하고 있는 것이다.
만약 자동 리셋 이벤트나 기타 다른 동기화 오브젝트를 사용한다면 문제를 해결할 없다. 왜냐하면 자동 리셋 이벤트는 WaitForSingleObject 대기 함수에 의해 자동으로 다시 비신호상태로 되어버리기 때문이다.
. DownEvent 예제
05 그외의 동기화 객체
. 모달 프로세스
. 대기가능 타이머
대기가능 타이머(Waitable Timer) 주기적으로 신호상태가 되는 객체이다. 커낼 객체이며 여러 개의 스레드가 동시에 객체를 대기할 있으며 WM_TIMER메시지처럼 우선 순위가 늦지도 않아 정확하고 신뢰할 있다.
다음 함수로 생성한다.
HANDLE CreateWaitableTimer(LPSECURITY_ATTRIBUTES lpTimerAttributes, BOOL bManualReset, LPCTSTR lpTimerName);
lpTimerAttributes
보안속성
bManualReset
리셋 방식(자동, 수동)
lpTimerName
객체 이름
리턴 값은 대기 타이머의 핸들이며 다른 커널 객체와 마찬가지로 CloseHandle 닫는다. 대기 가능 타이머는 항상 비신호상태로 생성되는데 언제 어떤 주기로 신호상태가 것인지, 신호상태가 되었을 어떤 동작을 것인지는 다음 함수로 지정한다.
BOOL SetWaitableTime(HANDLE hTimer, const LARGE_INTEGER* pDueTime, LONG lPeriod, PTIMERAPCROUTINE pfnCompletionRoutine, LPVOID lpArgToCompletioinRoutine, BOOL fResume);
hTimer
타이머 핸들
pDueTime
타이머가 최초고 신호상태가 시간을 지정, 절대 시간과 상대 시간으로 지정 가능하다.
절대 시간은 FILETIME포캣으로 지정하되 세계표준시인UTC시간
SYSTEMTIME구조체로 원하는 식나을 먼저 설정한 FILETIME으로 변경하고 UTC 변환한 상대 시간으로 지정할 때는 부호를 음수로 주는데 현재 시간에서 얼마 후에 신호 상태가 것인지를 지정한다.
FILETIME 해상도와 같은 1/10000000 단위를 사용한다. 앞으로 10 후에 최초로 신호상태가 되도록 하고 싶다면 -1억으로 지정하면 된다.
수동인 경우 시간을 재설정하기 전에는 신호상태를 유지한다.
lPeriod
 
타이머가 다시 신호상태로 주기를 1/1000 단위로 지정한다.
자동 리셋 타이머는 대기가 풀리면서 비신호상태가 되었다가 lPeriod시간마다 다시 신호상태가 된다. 타이머가 한번만 신호상태가 되도록 하려면 0으로 설정한다.
pfnCompletionRoutine
신호상태가 될때 호출할 콜백 함수
lpArgToCompletioinRoutine
신호상태가 호출할 콜백함수에 넘길 인자
fResume
시스템이 대기모드로 잠들어 있을 시스템을 재가동할 것인지를 지정, TRUE이면 지정한 시간이 되었을 대기 모드를 풀고 작업 시작
BOOL CancelWaitableTimer(HANDLE hTimer);
함수는 타이머의 동작을 멈추기만 타이머의 현재 신호상태를 변경하지는 않는다.


반응형

'책정리 > 윈도우 API 정복2' 카테고리의 다른 글

51장 서비스  (0) 2020.03.26
41장 멀티 스레드  (0) 2019.04.16
39장 메모리  (0) 2019.04.04
40장 프로세스  (0) 2019.04.04
39장 메모리  (0) 2019.04.03