스레드 동기화 기법1
스레드 동기화란 무엇인가?
동기화는 2가지 측면이 있다.
메모리 접근에 대한 동기화
A, B 스레드가 존재 이 두 스레드가 동시에 한 메모리에 접근하면 문제가 발생함
메모리에 접근하기 위해서는 프로그램 코드를 통해 접근한다.
그래서 둘 이상의 스레드가 동시에 메모리에 접근하도록 원인을 제공하는 코드 블럭을 가리켜 임계영역이라 한다.
이러한 임계영역은 한순간에 하나의 스레드만 실행할 수 있도록 허용해야 한다.
그래서 둘 이상의 스레드가 동시에 접근하는 거를 막아야 한다. 이것이 바로 동기화다.
임계영역 ⇒ 메모리 접근 동기화가 필요
임계영역에 접근하는 것을 동기화한다는 것은 한순간에 하나의 스레드만 접근하도록 하겠다.
이것이 바로 임계영역에 대한 메모리 접근 동기화다.
실행순서의 동기화
메모리에 접근하는 스레드의 실행순서 동기화
스레드를 실행시키면 스레드의 실행 흐름을 컨트롤 가능한가? ⇒ 불가능하다
근데 프로그램을 하다 보면 A 스레드보다 B 스레드를 빨리 실행했으면 좋은 경우가 있다.
예를 들어 A 스레드가 데이터를 입력받고 해당 데이터를 B 스레드에 전달하면 B 스레드는 그것을 가지고 연산하는 구조라고 해보자. 그러면 A 스레드가 B 스레드보다 먼저 실행이 되야한다. A 스레드가 실행되고 난 다음 B 스레드가 실행이 되야 한다. 즉 순서가 중요하다.
순서를 정해서 스레드가 실행이 되도록 컨트롤하는 것을 가리켜 순서 동기화라 한다.
이번 장에서는 임계영역 접근 동기화에 대해 알아본다.
동기화 기법
유저 모드 동기화
커널의 도움을 받지 않고 기능이 제공되는 방법을 가리켜 유저 모드 동기화라 한다.
장점: 유저모드 ↔ 커널 모드 간 빈번한 이동은 없어 빠르다.
단점: 커널 모드 동기화가 제공해 주는 만큼 기능은 다양하지 않다.
커널 모드 동기화
커널 레벨에서 직접 제공하는 동기화 기법이다.
커널 모드 동기화를 쓸 경우 하나의 프로세스 내에 존재하는 스레드들 간의 동기화뿐만 아니라 별도의 프로세스에 존재하는 스레드간의 동기화도 할 수 있다.
스레드 동기화 기법의 두 가지 구분
유저 모드 동기화
- 크리티컬 섹션 기반 동기화
- 크리티컬 섹션은 우리말로 임계영역이다.
- 임계영역을 동기화하자는 게 아니라 윈도우에서 제공하는 기능 중에 하나로서 크리티컬 섹션이라는 방법이 있다.
- 인터락 함수 기반 동기화
- 보편적으로 동기화시킬 영역이 작다. 이렇게 작은 경우 인터락 함수 기반을 쓰면 좋다.
커널 모드 동기화
뮤텍스, 세마포어, 이름 있는 뮤텍스는 메모리에 대한 접근을 동기화하는 거고 이벤트 같은 경우 실행순서를 동기화시킬 때 주로 사용한다.
- 뮤텍스 기반 동기화
- 세마포어 기반 동기화
- 이름 있는 뮤텍스 기반 동기화
- 이벤트 기반 동기화
임계 영역 접근 동기화
크리티컬 섹션 기반 동기화
동기화 기법은 단순히 제공되는 거다. 동기화 기법의 적절한 사용에 대해 고려하는 것도 중요하다.
일단 모든 동기화 기법은 key를 생각하면 된다.
둘 이상의 스레드가 접근하는 것을 막기 위해서는 일종의 key를 가지고 접근할 때 허용하게 하면 된다.
위 내용이 내부적으로 무슨 일을 하는지 아는 것보다 위 과정을 통해 동기화 기법을 적용하기 위한 최소한의 기본 작업을 위해 요청을 하는 거다라고 이해하면 된다. 제공하는 방법대로 쓰고 어떻게 그 방법을 잘 쓸지 고민하면 된다.
- CRITICAL_SECTION을 키로 보자
- InitializeCriticalSection() 키를 얻기 위한 초기화 작업이다.
- EnterCriticalSection()은 임계영역에 들어가기 위한 키를 획득하는 메서드이다.
- LeaveCriticalSection()은 키를 반환하는 메서드이다.
따라서 모든 임계영역의 접근은 열쇠의 획득과 반환의 연속이다라고 이해하면 된다.
A 스레드가 EnterCriticalSection()을 호출하고 LeaveCriticalSection()을 호출할 때까지 B 스레드는 Blocking 상태로 대기 중에 있다.
임계영역은 넓게 잡을수록 안정적이다. 예를 들어 p()라는 함수가 있다 하자. 이 p라는 함수를 처음부터({) 끝까지(}) EnterCriticalSection과 LeaveCriticalSection으로 묶었다고 하자. 이렇게 하면 매우 안정적이다 다만 이렇게 하면 성능 상의 문제가 있다. 왜냐면 둘 이상의 스레드가 동시에 접근할 수 있는 범위가 그만큼 제한된다. p 메서드 영역 중에 어떤 부분은 동시에 접근해도 상관이 없는 부분이 있다고 할 때 처음부터 끝까지 제한해 버리면 다른 스레드는 해당 스레드가 작업할 때까지 대기상태에 존재한다. 근데 작업이 길어지면 B 스레드는 계속 대기 상태에 있어 굉장히 비효율적이게 돼버린다. 따라서 임계영역을 구성할 때는 최소한의 영역으로 필요한 부분만 감싸야한다. 임계영역을 어떻게 최소화시킬지가 중요하다.
void p() {
...
}
크리티컬 섹션 기반 동기화: 예제 확인
CriticalSectionSync.cpp
#include "stdio.h"
#include "stdlib.h"
#include "tchar.h"
#include "windows.h"
#include "process.h"
#define NUM_OF_GATE 6
LONG gTotalCount = 0;
CRITICAL_SECTION hCriticalSection;
void IncreaseCount() {
EnterCriticalSection(&hCriticalSection);
gTotalCount++;
LeaveCriticalSection(&hCriticalSection);
}
unsigned int WINAPI ThreadProc(LPVOID lpParam)
{
for (DWORD i=0; i<1000; i++)
{
IncreaseCount();
}
return 0;
}
int _tmain(int argc, TCHAR * argv[])
{
DWORD dwThreadId[NUM_OF_GATE];
HANDLE hThread[NUM_OF_GATE];
InitializeCriticalSection(&hCriticalSection);
for (DWORD i=0; i<NUM_OF_GATE; i++)
{
hThread[i] = (HANDLE)
_beginthreadex(
NULL,
0,
ThreadProc,
NULL,
CREATE_SUSPENDED,
(unsigned *) &dwThreadId[i]
);
if (hThread[i] == NULL)
{
_tprintf( _T("thread creation fault ! \\n"));
return -1;
}
}
for (DWORD i = 0; i<NUM_OF_GATE; i++)
{
ResumeThread(hThread[i]);
}
WaitForMultipleObjects(
NUM_OF_GATE , hThread , TRUE , INFINITE
);
_tprintf(_T("total count : %d \\n"),gTotalCount);
for (DWORD i=0; i<NUM_OF_GATE; i++)
{
CloseHandle(hThread[i]);
}
DeleteCriticalSection(&hCriticalSection);
return 0;
}
위 코드를 보면 gTotalCount 변수 하나를 위해서 동기화 작업을 하니 굉장히 복잡하게 느껴진다.
그래서 쓰기 편하기 위해 제공된 게 인터락 함수 기반의 동기화이다.
인터락 함수 기반의 동기화
void IncreaseCount() {
// gTotalCount++;
InterLockedIncrement(&gTotalCount);
}
- 원자적 접근(Atomic access)을 보장
- Atomic이라는 것은 한순간에 하나의 의해서만 연산되는 것을 얘기한다. 즉 저 함수는 한순간에 하나의 스레드에서만 호출이 완료되도록 허용하는 함수다.
- 위에서 InterLockedIncrement 함수는 gTotalCount값이 증가되는 연산은 결코 둘 이상의 스레드에 의해서 동시에 진행되지 않도록 보장한다.
- 크리티컬 섹션 기반 동기화의 5가지 번거로운 작업을 하나의 함수 호출로 끝낸다. 그래서 임계영역이 단순하다면 원자적인 접근을 보장해 주는 인터락 함수를 사용하는 것도 좋다.
과연 어떻게 둘 이상의 스레드가 동시 접근하는 것을 막을 수 있을까?
임계영역에 A, B 스레드가 동시 접근했는데 A가 먼저 작동하고 B가 대기상태에 되게 하려면 어떻게 해야 할까
- 무식한 방법
- 스케줄러를 막아버린다 스케줄러를 막아버리면 스레드 간에 컨텍스트 스위칭이 발생하지 않는다.
- 그러면 A 스레드가 자기 혼자 실행이 될 수 있다.
- A 스레드가 타이머 인터럽트를 disable 시킨다는 것은 OS에게 가는 인터럽트 신호를 막겠다는 의미다.
- 그러면 OS는 시간이 안 흘러간다고 인지해버리게 되고 스케줄러는 동작하지 않는다.
- 그래서 A 스레드 혼자 동작되고 임계영역을 나갈 때 다시 인터럽트 신호가 가게끔 하면 된다.
- 타이머 인터럽트
- 클록을 기준으로 CPU가 일하는데 이렇게 클록으로 CPU가 동작하고 있다는 신호를 OS에게 보내야 하는데 타이머 인터럽트로 보낸다.
- H/W가 있고 OS가 있는데 H/W는 어떠한 현상이 일어나면 OS에게 인터럽트를 통해 알려준다. 즉 타이머도 클록이 발생했다는 신호를 인터럽트라는 것으로 알려준다. 이러한 과정을 타이머 인터럽트가 발생했다고 한다. 그러면 OS는 시간이 흘러가는 것을 타이머 인터럽트를 통해 안다. 즉 H/W가 운영체제에게 시간을 알려주는 알려주는 방식이 인터럽트를 통해 알려준다.
- 스케줄러는 시간이 흘러가는 것을 인지해야 한다. 그러기 위해 타이머 인터럽트를 가지고 시간을 체크한다.
커널 모드 동기화
- 세마포어 중에서 바이너리 세마포어를 뮤텍스라 한다.
- 세마포어의 일부로써 뮤텍스라는 녀석이 존재한다.
- 운영체제에서 얘기하는 세마포어와 유사한 기법이어서 윈도우에서도 세마포어라 부른다.
- 세마포어 기법의 일부인 뮤텍스 기법의 특성을 그래도 살린 거를 윈도우에서 뮤텍스라 부른다.
임계영역에 접근하기 위해서는 키가 필요하다. 세마포어는 키가 여러 개 존재하는 방식이다.
뮤텍스는 오직 하나의 키만 존재한다. 즉 세마포어와 뮤텍스의 가장 큰 차이점은 키의 개수다.
다시 얘기해서 세마포어는 임계영역에 들어오는 스레드의 개수를 제한할 수 있다.
예를 들어 스레드가 동시 접근하는 것을 5개까지 허용하겠다고 하면 키를 5개 두면 된다.
그러나 뮤텍스 같은 경우 키가 하나여서 임계영역에 동시 접근할 수 있는 스레드의 개수가 하나다.
즉 키의 개수에 따라서 세마포어인지 뮤텍스인지 나눠진다.
뮤텍스
- CreateMutex() 함수는 뮤텍스라는 열쇠를 생성하는 함수다.
- 뮤텍스는 커널 모드 동기화 기법이라서 커널 오브젝트 생성을 동반한다라는 것을 기억하면 보안 설정에 대해서 알 수 있다.
- 첫 번째 인자는 핸들 테이블의 키가 상속될지 말지 결정한다.
- 두 번째 인자는 열쇠를 만든 사람이 소유하게 할지 아니면 열쇠를 만든 사람도 소유의 권한을 주지 않고 누구나 소유하게 할지 결정한다.
- 세 번째 인자는 뮤텍스 이름을 지정한다.
뮤텍스 기반의 동기화
- WaitForSingleObject 함수를 사용해서 뮤텍스라는 열쇠를 획득한다.
- ReleaseMutex 함수를 사용해서 열쇠를 반납한다.
- 위 그림에서 뮤텍스를 커널 오브젝트라고 생각하자.
- WaitForSingleObject 함수가 빠져나오기 위해서는 뮤텍스(커널 오브젝트)가 signaled 상태여야 한다.
- WaitForSingleObject 함수는 signaled 상태가 되기만을 기다리는 함수다.
- ReleaseMutex 함수는 키를 signaled 상태가 되게끔 한다.
- 반면에 WaitForSingleObject 함수는 signaled 상태에서 빠져나오면서 nonsignaled 상태로 바꾸게 한다.
아래 예시를 살펴보자
스레드가 키(뮤텍스)를 가지고 WaitForSingleObject 함수를 호출한다.
만약 현재 뮤텍스가 signaled 상태이면 WaitForSingleObject 함수를 빠져나오면서 뮤텍스를 nonsignaled 상태로 바꿔버린다. 현재 뮤텍스가 nonsignaled 상태가 됐는데 두 번째 스레드가 WaitForSingleObject 호출하면 뮤텍스가 nonsignaled 상태 이므로 기다리게 된다. 대기 중인 스레드는 뮤텍스가 signaled 상태가 될 때까지 기다린다.
앞서 진입한 스레드가 임계영역을 나오면 ReleaseMutex 함수를 호출해서 뮤텍스를 signaled 상태가 되게끔 한다. 그러면 대기 중인 스레드는 뮤텍스가 signaled 상태가 돼서 WaitForSingleObject 함수에서 벗어나고 임계영역에 진입한다. 이때 WaitForSingleObject 함수를 벗어나면서 뮤텍스를 nonsignaled 상태로 바꾼다. 뮤텍스의 signaled, nonsignaled 상태를 이용해서 임계영역의 동기화를 처리한다.
코드예시
#include "stdio.h"
#include "stdlib.h"
#include "tchar.h"
#include "windows.h"
#include "process.h"
#define NUM_OF_GATE 6
LONG gTotalCount = 0;
// CRITICAL_SECTION hCriticalSection;
HANDLE hMutex;
void IncreaseCount() {
// EnterCriticalSection(&hCriticalSection);
WaitForSingleObject(hMutex, INFINITE);
gTotalCount++;
// LeaveCriticalSection(&hCriticalSection);
ReleaseMutex(hMutex);
}
unsigned int WINAPI ThreadProc(LPVOID lpParam)
{
for (DWORD i=0; i<1000; i++)
{
IncreaseCount();
}
return 0;
}
int _tmain(int argc, TCHAR * argv[])
{
...
// DeleteCriticalSection(&hCriticalSection);
CloseHandle(hMutex);
return 0;
}
세마포어
세마포어의 생성
세마포어는 자체적으로 값을 가진다. 그 값이 열쇠의 개수라고 이해하면 된다.
- InitialCount
- 열쇠의 개수 초기값을 지정한다.
- MaximumCount
- 세마포어가 가질 수 있는 열쇠의 최대값이다.
- MaximumCount는 InitialCount보다 항상 이상이어야 한다.
세마포어 기반의 동기화
- ReleaseSemaphore
- 세마포어 카운트가 하나 증가한다.
- WaitForSingleObject
- 세마포어 카운트가 하나 감소한다.
- WaitForSingleObject 함수가 여러 번 호출이 돼도 세마포어 카운트는 nonsignaled 상태가 되지 않는다.
- 즉 세마포어 카운트가 값을 가지고 있는 순간에는 계속해서 signaled 상태가 된다.
- signaled 상태로 남아있기 때문에 WaitForSingleObject 함수가 호출이 되어도 블로킹 상태로 빠지지 않고 계속 진행된다.
- 현재 세마포어 카운트 값이 10이어서 WaitForSingleObject 함수의 호출을 10번 견딜 수 있다. 그리고 10번이 지나면 세마포터 카운트는 nonsignaled 상태가 돼서 그다음에 호출하는 스레드 블로킹 상태가 된다 즉 총 10개의 스레드가 임계영역에 접근이 가능하다.
이름 있는 뮤텍스 기반의 프로세스 동기화
위 그림은 서로 다른 프로세스에 있는 스레드 간에도 동기화가 가능한가를 보고 있다.
결론은 가능하다. 두 스레드는 서로 뮤텍스를 공유해야 한다.
하나의 프로세스 내에 존재하는 스레드들 간에 동기화를 할 때는 Handle을 이용했다.
근데 Handle이 핸들 테이블에 등록이 안되어 있으면 핸들을 직접적으로 얻을 수 없다.
그럼 프로세스 B는 뮤텍스를 찾아야 하는데 이때 이름을 가지고 찾는다. 아래 그림은 뮤텍스를 생성하는 코드이다.
보면은 lpName이라는 변수가 있는데 이게 뮤텍스 이름을 지정하는 변수이다.
그래서 이 변수를 통해 뮤텍스 이름을 지정한다는 의미는 다른 프로세스에 있는 스레드와도 동기화를 하겠다는 의미다.
세마포어에도 이름을 줄 수 있다. 즉 서로 다른 프로세스에 속해 있는 스레드들 간에도 동기화가 가능하다.
그래서 결론은 이름을 통해서 핸들 테이블에 등록을 하면 된다.
뮤텍스의 소유와 WAIT_ABANDONED
- 소유의 관점에서 세마포어와 뮤텍스의 차이점은?
뮤텍스에서는 A라는 스레드가 열쇠를 얻으면 A 스레드가 열쇠를 반환해야 한다.
근데 A 스레드가 열쇠를 얻었는데 반환을 하지 않고 종료가 돼버리면 OS가 대신 반환을 해서 대기 중인 B 스레드가 열쇠를 소유할 수 있도록 도와준다. 세마포어는 소유의 개념이 없다. 세마포어 같은 경우는 열쇠를 여러 개 가진다. 세마포어 카운트 값을 기준으로 공유를 한다. 그래서 그 값을 +1, -1 하는 대상이 일치하지 않아도 문제가 되지 않는 특성이 있다. 그렇다고 A 스레드가 +1 하고 B 스레드가 -1 하는 구조는 좋지 않다.
'운영체제 > 뇌를 자극하는 윈도우즈 시스템 프로그래밍' 카테고리의 다른 글
뇌를 자극하는 윈도우즈 시스템 프로그래밍 15장 (0) | 2024.09.21 |
---|---|
뇌를 자극하는 윈도우즈 시스템 프로그래밍 14장 (0) | 2024.09.21 |
뇌를 자극하는 윈도우즈 시스템 프로그래밍 12장 (0) | 2024.09.07 |
뇌를 자극하는 윈도우즈 시스템 프로그래밍 11장 (0) | 2024.09.07 |
뇌를 자극하는 윈도우즈 시스템 프로그래밍 10장 (0) | 2024.09.07 |