티스토리 뷰

임계 영역과 뮤텍스

데이터 레이스(data race)라는 개념을 이전 글에서 언급한 바있다. 이는 두개 이상의 스레드가 하나의 변수를 사용할 경우 변수에 대한 IO 주도권이 모호해지기에 데이터 충돌이 날 수 있기 때문이다.

 

이러한 상태를 경쟁 상태라고도 불리기도 하며 경쟁 상태는 여러 방법으로 해결할 수 있다.

 

 


뮤텍스 (Mutex)

경쟁 상태를 해결하는 대표적인 방법이 뮤텍스(mutex)이다. 상호 배제(mutural exclusion)의 준말인 뮤텍스의 개념은 굉장히 간단하다.

 

스레드에서 어떤 정보를 사용하는 동안 다른 스레드는 해당 정보에 접근이 불가능하다.

 

 

즉, 하나의 스레드가 특정 데이터를 사용할때, 뮤텍스의 시작을 선언("락을 건다"라고 표현한다.)하면 뮤텍스가 선언된 명령줄부터 뮤텍스가 해제(언락)되는 명령줄까지 다른 스레드가 접근이 불가능하게 만든다. 여기서 헷갈릴 수도 있는 부분은 뮤텍스는 데이터를 접근하기 전에 선언하는 것이다.

 

이러한 병렬처리의 정리하기 위해 뮤텍스 클래스는 lock과 unlock 함수를 지원하며 다음과 같이 사용된다.

 

뮤텍스 사용 방식
  1. 특정 기능 구역을 보호하는 뮤텍스 변수를 선언한다.
  2. 스레드가 해당 기능을 사용하기 위해 뮤텍스 변수에 "사용하겠다."는 요청(lock)을 한다.
  3. 먼저 뮤텍스 변수에 권한을 부여받은 스레드가 없다면 요청한 스레드가 액세스한다.
  4. 모든 기능 수행이 완료되면 뮤텍스의 사용권을 반환(unlock)한다.
C++ : lock & unlock
함수 사용처
lock 보호하려는 변수들을 위한 뮤텍스용 객체 mutex 생성 (std::recursive_mutex나 std::mutex 사용)
mutex 객체를 lock() 함수로 잠금
unlock 뮤텍스 사용이 끝나고 unlock() 함수로 잠금 해제
C# : lock & unlock
함수 사용처
lock 보호하려는 변수 자체를 뮤텍스 처럼 사용하여 잠금
변수를 가리키는 임의의 객체를 따로 만들어 객체를 잠금
unlock 뮤텍스 사용이 끝나고 잠금 해제

 

 

 

뮤텍스 선언하기
#include <iostream>
#include <thread>
#include <mutex>

using namespace std;


char queue[32] { 'a', 'b', 'c' };
int head = 0;
int tail = 0;
mutex keymutex;	// Mutex

char getChar(char t)
{
    int pos;
    char ret;
    
    cout << "Thread : " << t << endl;
    
    /* entry section */
    keymutex.lock();     // mutex lock
    
    /* critical section begin */
    pos = head;
    ret = queue[pos];
    pos = (pos + 1) % 32;
    head = pos;
    
    cout << head << endl;
    cout << pos << endl;
    
    /* critical section end */
    keymutex.unlock();	// exit section
    
    return ret;			// remainder section
}

int main() {
    thread a(getChar, 'a');
    thread b(getChar, 'b');
    
    // 스레드들이 종료될 때까지 대기
    a.join();
    b.join();
    
    return 0;
}
스레드 동작 설명
A lock keymutex; A 스레드가 뮤텍스 사용권 요청 성공
A pos = head = 0; 전역 변수 head 대입
A ret = queue[0] = 'a'; 배열의 첫 번째 원소값 대입
Context Switch   B 스레드로 컨텍스트 스위치
B lock keymutex;
Blocked
B 스레드가 뮤텍스 사용권을 요청하지만 거부되어 대기상태로 돌입
Context Switch   A 스레드로 컨텍스트 스위치
A pos = 1; → head = 1; pos 값과 head 값 대입연산
A unlock keymutex; A 스레드의 뮤텍스 사용권 반환
Context Switch   B 스레드로 컨텍스트 스위치
B returned from lock B 스레드를 대기상태에서 사용권 획득
B ... 작업
B pos = 2; → head = 2; 결과 적용
B unlock keymutex; B 스레드의 뮤텍스 사용권 반환
Thread : a
Thread : a, head : 1
Thread : a, pos : 1
Thread : b
Thread : b, head : 2
Thread : b, pos : 2
Thread : Thread : b
a, head : 1
Thread : a, pos : 1
Thread : b, head : 2
Thread : b, pos : 2
Thread : b
Thread : a
Thread : a, head : 1
Thread : a, pos : 1
Thread : b, head : 2
Thread : b, pos : 2
3번째 결과 진행 순서
  1. B 스레드가 getChar 함수 실행
  2. B 스레드가 로컬변수 할당 중 컨텍스트 스위치 발생
  3. A 스레드가 getChar 함수 실행
  4. A 스레드가 뮤텍스에 대한 사용권 선점
  5. A 스레드가 getChar 함수 사용
  6. B 스레드가 뮤텍스 사용권을 받기위해 대기 상태 돌입
  7. A 스레드가 뮤텍스 사용권 반환
  8. B 스레드가 뮤텍스 사용권 획득
  9. B 스레드가 getChar 함수 사용

 

위 코드는 위키에서 추출한 뮤텍스 예제 코드이다. (코드와 출력결과가 잘못되어있으니 조심하자) 전제 조건은 A와 B 스레드가 존재하고 둘 모두 getChar함수를 사용하려 한다.

 

여기서 중요한 부분은 lock, unlock 함수이다. getChar 함수에 lock함수가 선언된 명령줄부터는 뮤텍스가 진행되는 것으로 먼저 들어온 스레드가 사용권을 가져가고 다음 명령줄들을 수행한다.

 

그 증거로 결과들 중 3번째 결과에서 B 스레드가 먼저 함수에 들어왔지만 로컬변수 할당 중 컨텍스트 스위치가 발동하여 A 스레드로 변경되어 A 스레드가 먼저 뮤텍스의 사용권을 가지고 함수를 수행하게 된다.

 

 


뮤텍스 예외 처리 (Mutex exception)

앞서 뮤텍스에 대한 기본적인 개념을 공부했다. 이제 뮤텍스가 사용될때 불편할 수 있는 부분에 대해 알아보자.

다음은 뮤텍스가 실행되다가 예외 처리가 발생하여 함수가 끝까지 실행되지 않고 예외를 던지는(throw) 상황이다.

 

뮤텍스 진행 중 예외 처리 발생
#include <iostream>
#include <thread>
#include <mutex>

using namespace std;


const int QUEUE_SIZE = 2;

char queue[32] { 'a', 'b', 'c' };
mutex keymutex;	// Mutex

char selectInQueue(int pos)
{
    if (pos > QUEUE_SIZE) throw pos;
    return queue[pos];
}

char getChar(char t, int p)
{
    char ret;
    
    cout << "Thread Enter : " << t << endl;
    
    /* entry section */
    keymutex.lock();     // mutex lock
    
    cout << "Mutex Start : " << t << endl;
    
    /* critical section begin */
    ret = selectInQueue(p);
    
    cout << "Mutex End : " << t << endl;
    
    /* critical section end */
    keymutex.unlock();	// exit section
    
    return ret;			// remainder section
}

int main() {
    thread a(getChar, 'a', 99999);
    thread b(getChar, 'b', 0);
    
    // 스레드들이 종료될 때까지 대기
    a.join();
    b.join();
    
    cout << "Thread out" << endl;
    
    return 0;
}
Thread Enter : b
Mutex Start : b
Mutex End : b
Thread Enter : a
Mutex Start : a
terminate called after throwing an instance of 'int'
Thread Enter : a
Mutex Start : a
Thread Enter : b
terminate called after throwing an instance of 'int'
Thread Enter : a
Mutex Start : a
terminate called after throwing an instance of 'int'
Thread Enter : b
Thread Enter : a
Mutex Start : b
Mutex End : b
Mutex Start : a
terminate called after throwing an instance of 'int'

 

위 코드는 반드시 예외처리가 나는 A 스레드와 정상 동작하는 B 스레드가 존재한다.

 

이 상황에서 흥미로운 결과로는 반드세 예외가 발생하는 A 스레드가 먼저 실행되면 컴파일러에서 시스템이 종료한다. 하지만, B 스레드가 컨텍스트 스위치나 먼저 함수를 실행하게 되면 순조롭게 모든 동작을 완료한다.

 

즉, 예외처리가 발생하는 스레드 이후 시스템이 중단되어 다른 스레드들도 모두 취소된다. 이는 메인스레드도 마찬가지로 main에 작성되어 있는 문자열 출력 명령어조차 실행되지 않는다.

 

또한 이렇게 함수가 취소되면 unlock 명령어를 실행할 수 없게되어 A 스레드 이후로 해당 뮤텍스는 사용할 수 없게 된다. 하여 try~catch 구문으로 unlock함수를 예외처리하도록 하는 방법과 뮤텍스를 로컬변수로 선언하여 스코프를 벗어나면 소멸자에서 자동으로 unlock이 되는 lock_guard 클래스를 사용할 수 있다.

 

 

lock_guard

lock_guard는 앞선 예제에서 설명한 바와 같이 로컬 변수로 뮤텍스가 시작되고 로컬 변수 lock 객체가 사라지면 소멸자에서 자동으로 unlock 함수를 실행한다.

 

lock_guard
#include <iostream>
#include <mutex>
#include <thread>

using namespace std;


mutex myMutex;

void sharedFunction(int id) {
    lock_guard<mutex> lock(myMutex);  // 락 획득

    // 공유 데이터에 대한 안전한 접근
    cout << "Thread " << id << " is inside the critical section." << endl;

    // 락이 범위를 벗어나면 자동으로 락이 해제됨
}

int main() {
    const int numThreads = 3;
    thread threads[numThreads];

    for (int i = 0; i < numThreads; ++i) {
        threads[i] = thread(sharedFunction, i);
    }

    for (int i = 0; i < numThreads; ++i) {
        threads[i].join();
    }

    return 0;
}
Thread 1 is inside the critical section.
Thread 0 is inside the critical section.
Thread 2 is inside the critical section.

 

위와 같이 선언을 하면 로컬 변수 lock 객체가 사라지는 동시에 소멸자에서 mx.unlock() 함수가 실행된다.

 

 

lock 구문 블록

lock 구문 블록은 중괄호를 통해 lock이 가능한 새로운 스코프를 생성하는 것이고 따로 unlock을 수행하지 않아도 된다. 이는 C#과 C++가 조금 다른 형태로 구현된다.

 

C# lock 구문 블록
TestClass t = new TestClass();
lock(t)
{
    // do something 1
    // do something 2
    // do something 3
}
C++ lock 구문 블록
std::mutex mx;
{
    std::lock_guard<std::mutex> lock(mx);
    // do something 1
    // do something 2
    // do something 3
}

 

 

recursive_mutex

recursive는 재귀라는 의미를 지닌다. 코드를 짜다보면 재귀함수라는 개념을 접할 텐데, 이는 문제의 해답을 찾을때까지 자기자신을 호출하는 함수를 말한다. 그렇다면 recursive_mutex는 뮤텍스는 스스로를 계속 호출한다는 말이 되는데, 이는 다음 예제와 같다.

 

recursive_mutex
#include <iostream>
#include <mutex>
#include <thread>

using namespace std;

recursive_mutex myMutex;

void sharedFunction(int id, int depth) {
    lock_guard<recursive_mutex> lock(myMutex);  // 락 획득

    // 재귀적으로 함수 호출
    if (depth > 0) {
        sharedFunction(id, depth - 1);
    }

    // 공유 데이터에 대한 안전한 접근
    cout << "Thread " << id << " is inside the critical section. with depth of " << depth << endl;

    // 락이 범위를 벗어나면 자동으로 락이 해제됨
}

int main() {
    const int numThreads = 3;
    thread threads[numThreads];

    for (int i = 0; i < numThreads; ++i) {
        threads[i] = thread(sharedFunction, i, 3);
    }

    for (int i = 0; i < numThreads; ++i) {
        threads[i].join();
    }

    return 0;
}
Thread 0 is inside the critical section. depth : 0
Thread 0 is inside the critical section. depth : 1
Thread 0 is inside the critical section. depth : 2
Thread 0 is inside the critical section. depth : 3
Thread 2 is inside the critical section. depth : 0
Thread 2 is inside the critical section. depth : 1
Thread 2 is inside the critical section. depth : 2
Thread 2 is inside the critical section. depth : 3
Thread 1 is inside the critical section. depth : 0
Thread 1 is inside the critical section. depth : 1
Thread 1 is inside the critical section. depth : 2
Thread 1 is inside the critical section. depth : 3

 

위 코드는 recursive_mutex를 활용하여 재귀함수에서 동일한 함수에 대한 lock 권한을 반복하여 받는 예제이다.

 

메인스레드에서 3개의 스레드가 각각 3번의 재귀함수를 호출하도록 만들고 그 결과 값을 출력하는 예제인데, 왜 일반 mutex를 사용하면 안될까?

 

일반적으로 mutex는 lock을 두 번 호출하면 무한 대기 상태가 된다고 한다. 반대로 recursive mutex는 lock을 여러 번 호출하여도 문제가 없다.

 

lock을 호출하는 동안은 계속 소유권을 가지다가 lock을 호출한 횟수 만큼 unlock 호출이 필요하기  때문이다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함