와와

[OS/ C++] 동기화를 위한 전략: 스핀락(spinlick), 뮤택스(mutex), 세마포(semaphore) 본문

개발/C++

[OS/ C++] 동기화를 위한 전략: 스핀락(spinlick), 뮤택스(mutex), 세마포(semaphore)

정으주 2024. 2. 8. 17:00

 

 

 

 테이블이 한 개만 있는 식당이 있다고 가정해보자!

 식당에 여러 손님이 동시에 들어와 한 테이블에 앉으려고 하면 굉장한 혼란이 발생할 것이다. 여기서 벌어질 수 있는 혼란 중의 하나로, 경쟁 조건( race condition )을 뽑을 수 있다.

 경쟁 조건은 여러 프로세스/스레드가 동시에 같은 데이터를 조작할 때 타이밍이나 접근 순서에 따라 결과가 달라질 수 있는 상황을 의미한다. 그럼 경쟁 조건을 막기 위해선 어떻게 해야할까?

 

 아주 간단하다. 하나의 스레드의 진입만 허용하고 그동안 다른 스레드의 진입을 금지하면 된다.

이것을 상호배제( mutual exclusion )라고 한다.

 

완벽한 동기화를 위해 상호 배제를 위한 방법 3가지를 알아보도록 하겠다!

 

 

 

 


 

 

 

식당 비유를 다시 생각해보자면

완벽한 동기화를 위해서는 손님이 한꺼번에 들이닥치는 것을 막고 한 손님씩 차례대로 받으면 된다.

즉, 임계영역을 정해두고 허용된 스레드를 제외한 다른 스레드의 진입을 금지해야한다.

 

임계 영역이란, 공유 데이터의 일관성을 보장하기 위해 하나의 프로세스/ 스레드만 진입해서 실행 가능한 영역을 뜻한다.

여기서 임계영역은 손님이 식당에 들어와서 밥을 먹고 나가는 부분에 해당할 것이다.

 

 

 

다음과 같은 멀티 스레드 환경을 구성해보았다.

#include <iostream>
#include <thread>
#include <vector>

void dine(int guestNumber) {
    std::cout << "손님 " << guestNumber << " 식당에 도착.\n";
    
    // 임계구역 - 위험한 코드: 경쟁조건 발생 가능성 있음 -
    std::cout << "손님 " << guestNumber << " 식사 시작.\n";
    std::this_thread::sleep_for(std::chrono::seconds(1)); // 식사 시간 시뮬레이션
    std::cout << "손님 " << guestNumber << " 식사 완료.\n";
}

int main() {
    const int guests = 3; // 식당에 올 손님 수
    std::vector<std::thread> guestThreads;
    
    for(int i = 1; i <= guests; ++i) {
        guestThreads.emplace_back(std::thread(dine, i));
    }
    
    for(auto& t : guestThreads) {
        t.join(); // 모든 손님(스레드)의 식사가 끝날 때까지 기다림
    }

    std::cout << "모든 손님이 식사를 마쳤습니다.\n";
    
    return 0;
}

 

 

총 3명의 손님(스레드)가 dine()함수를 실행시켜 식당을 방문할 것이고, 식당에는 아직 아무런 동기화 장치도 사용하지 않기 때문에, 모든 손님이 동시에 "식사 시작"을 출력할 수 있다.

 

사실 위의 예시는 그냥 1초 기다리고 로그를 출력하기 때문에 실행 순서에 따라 결과가 달라지는 부분은 없지만 실제 멀티 환경에서 공유 데이터를 읽거나 쓸 때 동시에 접근하여 문제가 생길 수 있다!

 

자 그래서 어떻게 식당에 손님을 한 명씩 받을 수 있을까?

 

여기서 알아볼 방법은 총 3가지이다.

 

1. 스핀락

2. 뮤텍스

3. 세마포어

 

 


1. 스핀락

- 자리 있나요? 자리 있나요? 자리 있나요? ...  계속해서 물어보는 것 (Busy Waiting)

 

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

class SpinLock {
private:
    std::atomic_flag lockFlag = ATOMIC_FLAG_INIT; // 데이터 멤버의 이름 변경

public:
    void lock() {
        while (lockFlag.test_and_set(std::memory_order_acquire)) {
            // 바쁜 대기 (Busy Waiting)
        }
    }

    void unlock() {
        lockFlag.clear(std::memory_order_release);
    }
};

SpinLock diningLock;

void dine(int guestNumber) {
    std::cout << guestNumber << " 식당에 도착.\n";

    diningLock.lock();
    std::cout << guestNumber << " 식사 시작.\n";
    std::this_thread::sleep_for(std::chrono::seconds(1)); // 식사 시간 시뮬레이션
    std::cout << guestNumber << " 식사 완료.\n";
    diningLock.unlock();
}

int main() {
    const int guests = 3;
    std::vector<std::thread> guestThreads;

    for (int i = 1; i <= guests; ++i) {
        guestThreads.emplace_back(std::thread(dine, i));
    }

    for (auto& t : guestThreads) {
        t.join(); // 모든 손님(스레드)의 식사가 끝날 때까지 기다림
    }

    std::cout << "모든 손님이 식사를 마쳤습니다.\n";

    return 0;
}

 

 

결과

 

  스핀락 클래스를 임의로 생성하여 lock(), unlock()를 만들었다.

처음으로 임계 구역에 접근한 스레드는 lock() 메서드를 실행하고 통과되어 식사를 시작한다. 이 스레드가 식사를 종료하는 동안 다른 스레드들은 atomic flag의 test_and_set 메서드에 의해 while문에 갇혀있다.

식사가 끝나면 lockFlag.clear를 해주게되고 그제서야 while문에 갇혀있던 스레드 하나가 통과될 수 있다.

이걸 모든 스레드가 통과할 때까지 반복하는 것이다.

 

여기서 열린 상태의 lock() 메서드를 여러 스레드가 동시에 실행하게 되면 어떡하지라는 의문을 품을 수도 있다!

test_and_set 은 atomic 명령어이기 때문에 CPU에서 관리된다. 실행 중간에 간섭받거나 중단되지 않고, 같은 메모리 영역에 대해 동시에 실행되지 않기 때문에 걱정하지 않아도 된다.

 

스핀락을 통해 간단한 동기화 작업을 수행할 수 있었는데, 이 스핀락은 기다리면서 계속 while문을 수행한다는 점에서 효율적이지 못하다. 그래서 다음 방법을 알아야한다!

 

 

 

2. 뮤텍스

 

- 자리가 있는지 물어보고 없으면 큐에 저장 후 휴식

- 손님이 나가면 큐 확인 후 호출

#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

std::mutex diningMutex;

void dine(int guestNumber) {
    std::cout << guestNumber << " 식당에 도착.\n";
    
    // 식당(임계 구역)에 들어가기 위해 뮤텍스 잠금
    diningMutex.lock();
    
    std::cout << guestNumber << " 식사 시작.\n";
    std::this_thread::sleep_for(std::chrono::seconds(1)); // 식사 시간 시뮬레이션
    std::cout << guestNumber << " 식사 완료.\n";
    
    // 식사 완료 후 뮤텍스 잠금 해제
    diningMutex.unlock();
}

int main() {
    const int guests = 3; // 식당에 올 손님 수
    std::vector<std::thread> guestThreads;
    
    for(int i = 1; i <= guests; ++i) {
        guestThreads.emplace_back(std::thread(dine, i));
    }
    
    for(auto& t : guestThreads) {
        t.join(); // 모든 손님(스레드)의 식사가 끝날 때까지 기다림
    }

    std::cout << "모든 손님이 식사를 마쳤습니다.\n";
    
    return 0;
}

 

 mutex는 스레드가 대기해야 한다는 사실을 알았으면 차례가 다가올때까지 잠들어있는다. spinlock과 마찬가지로 임계구역 전 후에 lock, unlock 메서드를 호출하면 된다.

 

- lock_guard 사용하여 뮤텍스 자동 관리

 lock_guard를 이용하여 뮤텍스를 자동으로 잠그고 해제할 수 있다. 아래 코드에서는 std::lock_guard 객체를 생성할 때 diningMutex를 인자로 전달하여 뮤텍스를 잠근다. 이후, 해당 스코프를 벗어날 때 std::lock_guard 객체가 소멸되면서 자동으로 뮤텍스가 해제된다. 이 방식은 코드의 안전성을 향상시키고, 뮤텍스의 잠금과 해제를 잊어버려 발생할 수 있는 문제를 방지한다.

 

#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

std::mutex diningMutex;

void dine(int guestNumber) {
    std::cout << guestNumber << " 식당에 도착.\n";
    
    // std::lock_guard를 사용하여 뮤텍스 자동 잠금 및 해제
    {
        std::lock_guard<std::mutex> lock(diningMutex);
    
        std::cout << guestNumber << " 식사 시작.\n";
        std::this_thread::sleep_for(std::chrono::seconds(1)); // 식사 시간 시뮬레이션
        std::cout << guestNumber << " 식사 완료.\n";
    }
    // 여기서 lock_guard 객체가 소멸하면서 diningMutex가 자동으로 해제됨
}

int main() {
    const int guests = 3; // 식당에 올 손님 수
    std::vector<std::thread> guestThreads;
    
    for(int i = 1; i <= guests; ++i) {
        guestThreads.emplace_back(std::thread(dine, i));
    }
    
    for(auto& t : guestThreads) {
        t.join(); // 모든 손님(스레드)의 식사가 끝날 때까지 기다림
    }

    std::cout << "모든 손님이 식사를 마쳤습니다.\n";
    
    return 0;
}

 

 

결과

 

 

3. 세마포어


- 테이블을 여러개를 두어 한번에 여러 스레드 받기 가능

- 손님이 입장하며 count++, 퇴장하며 count--를 수행하고, count에 제한을 두어 여러 손님을 받음

 

 여기서는 2개의 스레드를 동시에 받을 수 있도록 하였다.

#include <iostream>
#include <thread>
#include <vector>
#include <semaphore>

// 동시에 2개의 스레드가 임계 구역에 접근할 수 있도록 세마포어 설정
std::counting_semaphore<2> diningSemaphore(2);

void dine(int guestNumber) {
    std::cout << guestNumber << " 식당에 도착.\n";
    
    // 세마포어를 사용하여 임계 구역 접근 시도
    diningSemaphore.acquire();
    std::cout << guestNumber << " 식사 시작.\n";
    std::this_thread::sleep_for(std::chrono::seconds(1)); // 식사 시간 시뮬레이션
    std::cout << guestNumber << " 식사 완료.\n";
    
    // 식사 완료 후 세마포어 해제
    diningSemaphore.release();
}

int main() {
    const int guests = 3; // 식당에 올 손님 수
    std::vector<std::thread> guestThreads;
    
    for(int i = 1; i <= guests; ++i) {
        guestThreads.emplace_back(std::thread(dine, i));
    }
    
    for(auto& t : guestThreads) {
        t.join(); // 모든 손님(스레드)의 식사가 끝날 때까지 기다림
    }

    std::cout << "모든 손님이 식사를 마쳤습니다.\n";
    
    return 0;
}

 

 

결과

 

 

*C++20 이후부터 <semaphore>헤더를 통해 제공되는 counting_semaphore, binary_semaphore를 사용할 수 있다.

나 같은 경우는 c++ 언어 표준을 최신으로 조절해주었다.

 

 

 

std::counting_semaphore

  • 일반적인 세마포어로, 초기 카운트 값을 설정할 수 있으며, 이 값은 동시에 리소스에 접근할 수 있는 스레드의 최대 수를 의미
  • acquire() 메서드로 리소스를 요청하고, release() 메서드로 리소스 사용이 끝났음을 알림

 

std::binary_semaphore

  • std::counting_semaphore의 특수한 경우로, std::counting_semaphore<1>과 같으며, 이진 세마포어로 사용됨

 

 

 

 

이진세마포어 vs 뮤텍스

이진 세마포어 (Binary Semaphore)

  • 카운트가 0 또는 1의 값을 가질 수 있는 세마포어
  • 일반적으로 리소스의 잠금 및 해제를 위해 사용될 수 있으며, 1은 리소스가 사용 가능함을, 0은 리소스가 사용 중임을 나타냄
  • 이진 세마포어는 잠금을 획득한 스레드와 다른 스레드가 잠금을 해제할 수 있음 ( 잠금을 획득한 스레드가 반드시 그 잠금을 해제할 필요는 없다 )

뮤텍스 (Mutex)

  • 소유권 개념을 가짐 ( 뮤텍스를 잠금한 스레드만이 잠금을 해제 가능 )
  • 일반적으로 스레드 간의 상호 배제를 위해 설계되었으며, 잠금과 해제의 일관성을 유지하기 위한 추가적인 기능(예: 잠금 상태의 소유권, 잠금을 시도한 스레드의 대기 큐 관리 등)을 제공할 수 있음

차이점

  • 소유권: 뮤텍스는 잠금을 획득한 스레드가 소유권을 가지며, 해당 스레드만이 잠금을 해제할 수 있다. 반면, 이진 세마포어는 소유권 개념이 없어 잠금을 획득한 스레드와는 다른 스레드가 잠금을 해제할 수 있다.
  • 용도: 뮤텍스는 상호 배제와 관련된 문제에 특화되어 있으며, 스레드 간의 동기화와 데이터의 일관성을 유지하는 데 사용된다. 이진 세마포어는 좀 더 일반적인 동기화 메커니즘으로 사용될 수 있으며, 뮤텍스와 같은 상호 배제 뿐만 아니라 다양한 동기화 문제를 해결하는 데 사용될 수 있다.

 

 

 

 

참고 자료

https://www.youtube.com/watch?v=gTkvX2Awj6g&list=PLcXyemr8ZeoQOtSUjwaer0VMJSMfa-9G-&index=5