SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++/Concurrency

[Concurrency] race condition 예방 방법 : Mutex와 Semaphore

보리남편 김 주부 2023. 9. 26. 09:00
728x90
멀티 스레드로 동작 시 공유 데이터를 동시에 접근하면 경쟁상태(race condition)가 된다.
이전글 참조: 2023.09.19 - [언어/C++] - race condition : thread를 병렬로 그냥 동작시키면 안 되는 이유
데이터를 일관된 상태로 유지하려면 동시 액세스로부터 데이터를 보호해야 하는데 (즉, 데이터에 대한 액세스는 직렬화되어야 한다.) 그 방법을 알아보자.

 

Mutex(뮤텍스)


https://www.rtos.be/2013/05/mutexes-and-semaphores-two-concepts-for-two-different-use-cases 에서 참조

 

위 그림에 표현처럼 중요한 섹션은 상호 배제를 보장(critical sections ensure mutual exclusion)하는 방법이 뮤텍스이다. 바로 특정 순간에 하나의 중요 섹션만 공유 데이터에 접근하게 하는 방법이며 이는 데이터 접근에 대한 직렬화를 보장한다. 예제 코드로 좀 더 자세히 확인해 보자.

 

lock() / unlock()

예제
아래 프로그램은 A, B 스레스에서 전역 변수 x의 값을 동시에 접근하여 1씩 증가시키려고 시도하지만 mutex를 이용하여 한번에 하나의 섹션(스레드)만 접근하게 한다.(직렬화 보장)
#include <iostream>
#include <thread>
#include <chrono>
#include <string>
#include <mutex>
using namespace std::literals;

void delay() { std::this_thread::sleep_for(20ms); }

std::mutex m;

void foo(const std::string& name)
{
    static int x = 100;

    for (int i = 0; i < 10; i++)
    {
        // 임계영역(critical section) start
        m.lock();
        //---------------------------------
        x = x + 1; delay();
        std::cout << name << " : " << x << std::endl; delay();
        //---------------------------------
        m.unlock();
        // 임계영역(critical section) end
    }
}

int main()
{
    std::thread t1(foo, "A");
    std::thread t2(foo, "\tB");
    t1.join();
    t2.join();
}

중요코드 설명

m.lock()
처음 lock()을 건 스레드가 소유권을 가지며, 다른 스레드는 lock() 시도 시 대기(sleep)를 한다.

 

m.unlock()
소유권을 가진 스레드에서 unlock()을 하면 대기(sleep)하고 있는 스레드가 깨어난다.

 

실행 결과
더보기
A : 101
            B : 102
A : 103
            B : 104
A : 105
            B : 106
            B : 107
A : 108
A : 109
A : 110
            B : 111
A : 112
            B : 113
            B : 114
A : 115
            B : 116
A : 117
            B : 118
A : 119
            B : 120

하나의 스레드에서 lock()을 하면 다른 스레드는 저 지점에서 대기(sleep)를 하게 된다. 그리고 unlock()을 하게 되면 두 스레드 모두 접근 가능한 상태가 되지만 두 스레드 중 하나의 스레드가 lock()을 하면

다시 다른 스레드는 이 지점에서 대기(sleep)를 하여, 결국 공유 데이터 접근은 하나의 스레드만 가능하여 상호 배제가 보장된다.

 

try_lock()

프로그램에 따라 대기를 하지 않고 다른 수행을 해야 하는 경우도 있을 텐데 그럴 땐 try_lock() 함수를 사용하면 된다. (lock 상태를 보고 lock을 걸 수 있으면 true, lock을 걸 수 없으면 false을 리턴한다.

        if (m.try_lock()) // 뮤텍스를 획득하지 못한 경우 false 반환
        {        
            x = x + 1;
            std::cout << name << " : " << x << std::endl;
            m.unlock();
        }
        else
        {
            // 뮤텍스 획득에 실패한 경우 다른 작업 수행
            std::cout << name << " : false" << std::endl;
        }

 

Semaphore(세마포어) : 뮤텍스와의 차이


세마 : 그리스어로 신호, 포어 : 전달자라는 뜻으로 '신호를 전달한다'는 의미라고 한다.

 

1) 용도의 차이

'세마포어'는 공유된 자원의 데이터를 여러 프로세스가 접근하는 것을 막는 개념에서는 뮤텍스와 같으나

https://www.rtos.be/2013/05/mutexes-and-semaphores-two-concepts-for-two-different-use-cases 에서 참조

 위 그림처럼 하나의 '제공자'와 복수 개의 '소비자' 모델의 경우, 뮤텍스처럼 한 번에 하나만 접근이 가능하다면 '소비자의 수'에 따라 최대 N 배의 지연이 되는데 세마포어는 허용되는 수만큼 동시에 접근이 가능하다.(뮤텍스는 프로세스 안 스레드 간 접근 제어, 세마포어 복수 스레드 및 프로세스 간 접근 제어)

 

2) 사용방법의 차이

뮤텍스는 소유개념이 있어서 lock을 호출한 사람이 소유권을 가지고 있어서 소유를 한 사람만이 lock을 해제할 수 있는데 세마포어는 위 용도에서도 설명했다시피 공유 자원의 데이터를 누구든지(프로세스던 스레드던 접근가능한 만큼 wait(뮤텍스의 lock의 개념)을 시도할 수 있고 접근가능한 최대치가 넘으면 이후 접근하는 스레드(혹은 프로세스)는 sleep 모드가 되고 어느 스레드(혹은 프로세스)에서 post(뮤텍스의 unlock)가 되면 기다리고 있던 sleep에서 깨어나 wait 이후 코드가 실행이 된다.

 

세마포어 개념은 이렇지만 나중에 좀 더 자세히 다루기로 하고, 여기서는 세마포어를 이용하여 경쟁상태를 예방하는 방법에 초점을 맞춰 알아보자.

 

Binary Semaphores


뮤텍스와 용도 및 사용 방법의 차이가 있지만 세마포어를 1개만 허용하면 뮤텍스와 같은 동작이 된다. 이를 바이너리 세마포어라고 하며 예제 코드로 좀 더 자세히 확인해 보자.

 

예제
위 세마포어 설명 그림을 프로그램으로 만들면 다음과 같다. '생산자(producer)'는 공유자원(shared_resource) 10개를 제공한다는 의미로 1씩 증가시키고, '소비자(consumer)'는 자원을 읽었다는 의미로 공유자원을 1씩 감소시킨다.
#include <chrono>
#include <iostream>
#include <thread>
#include <semaphore.h>

using namespace std;
using namespace std::literals;

void delay() { std::this_thread::sleep_for(20ms); }

int shared_resource = 0;
sem_t sem;

void producer() {
    for (int i = 0; i < 10; i++) {
        delay();
        sem_wait(&sem);
        shared_resource++;
        cout << "생산자: " << shared_resource << endl;
        sem_post(&sem);
        delay();
    }
}

void consumer() {
        delay();
    while (true) {
        delay();
        sem_wait(&sem);
        shared_resource--;
        if(shared_resource < 0 )
        	break;
        cout << "소비자: " << shared_resource << endl;
        sem_post(&sem);
        delay();

    }
}

int main() {
    sem_init(&sem, 0, 1);

    thread producer_thread(producer);
    thread consumer_thread(consumer);

    producer_thread.join();
    consumer_thread.join();

    sem_destroy(&sem);

    return 0;
}

 

중요코드 설명

sem_init(&sem, 0, 1)
첫 번째 매개변수 세마포어 자료구조(sem_t)
두 번째 매개변수 프로세스 간 공유 여부(0이면 공유하지 않음, 그 외 공유함)
세 번째 매개변수 동시접근 가능 세마포어 개수

: 위 코드의 의미는 공유자원에 1개만 접근이 가능하다.

 

sem_wait(&sum)
위 코드의 의미는 세마포어의 값을 -1 한다.
만약 세마포어의 값이 음수라면 스레드를 sleep 상태로 전환한다.

 

sem_post(&sum)
세마포어의 값을 +1 한다.
만약 sleep 상태의 스레드가 있다면 한 개만 깨운다.

 

sem_destroy(&sum)
세마포어 객체를 삭제하고 할당된 자원을 해제

 

세마포어가 1개 이다보니 처음 wait을 호출한 생산자 스레드는 세마포어 -1 해서 0개로 만들고 이후 코드를 실행한다. 소비자 스레드는 wait 호출 시 세마포어 -1을 하면서 세마포어 개수가 -1개 되어 sleep이 되고 생산자 스레드에서 post로 세마포어의 값이 +1 하면서 세마포어 값이 0이 되어 sleep 되어 있던 소비자 스레드가 이어 실행이 되어 뮤텍스와 동일하게 동작하게 된다.

 

실행 결과
더보기
생산자: 1
소비자: 0
생산자: 1
소비자: 0
생산자: 1
소비자: 0
생산자: 1
소비자: 0
생산자: 1
소비자: 0
생산자: 1
소비자: 0
생산자: 1
소비자: 0
생산자: 1
소비자: 0
생산자: 1
소비자: 0
생산자: 1
소비자: 0

 

728x90
728x90