티스토리 뷰

반응형

크리티컬 섹션이란?

  1. 일반적인 프로그래밍 용어로서의 크리티컬 섹션: 다중 쓰레드 환경에서 여러 쓰레드가 동시에 접근할 수 있는 공유 자원을 사용하는 코드 영역을 지칭합니다. 크리티컬 섹션은 데이터의 일관성을 유지하기 위해 특정 시점에 한 쓰레드만 접근할 수 있어야 하는 부분입니다. 이러한 크리티컬 섹션을 보호하기 위해 뮤택스, 세마포어, 락 등의 동기화 메커니즘을 사용할 수 있습니다.
  2. Windows API에서의 크리티컬 섹션 객체: windows OS에서는 "크리티컬 섹션"이라는 특정 동기화 객체를 제공합니다. 이 객체는 windows 내에서 경량 동기화를 제공하며, 특히 같은 프로세스 내의 쓰레드들 사이의 동기화에 사용됩니다. windows의 크리티컬 섹션 객체는 뮤택스보다 빠르고 효율적이지만, 프로세스 간 동기화는 지원하지 않습니다. (Linux나 macOS 환경에서는 사용할 수 없습니다.)

본 문서에서는 주로 Windows API에서의 크리티컬 섹션 객체에 대해 설명합니다.

크리티컬 섹션 객체를 사용하는 이유

#include <iostream>
#include <thread>
#include <vector>
#include <windows.h>

// 공유 자원
int counter = 0;

void IncrementCounter() {
    static std::random_device rd;
    static std::mt19937 gen(rd());
    static std::uniform_int_distribution<> dis(1, 100);
    std::this_thread::sleep_for(std::chrono::milliseconds(dis(gen))); // 랜덤 딜레이

    int localCounter = ++counter; // 증가된 값을 로컬 변수에 저장
    printf("Thread %d incremented counter to %d\n", std::this_thread::get_id(), localCounter);
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 100; ++i) {
        threads.push_back(std::thread(IncrementCounter));
    }

    for (auto& thread : threads) {
        thread.join();  // 모든 쓰레드가 종료될 때까지 대기
    }

    printf("Final counter value: %d\n", counter);

    return 0;
}

 

공유 자원 인 counter에 여러 쓰레드가 동시에 접근하여 카운트를 1씩 올려주는 코드를 작성했습니다. 실제 해당 코드를 실행한다면,

//...
Thread 51880 incremented counter to 95
Thread 40472 incremented counter to 96
Thread 21416 incremented counter to 97
Thread 21380 incremented counter to 98
Final counter value: 98

 

위와 같이 final 값이 100이 아닌 98이 나오는 것을 확인 할 수 있습니다.

이 이유는 여러 쓰레드가 동시에 counter 변수를 증가시키려고 할 때 발생하는 경쟁 조건(race condition) 때문입니다. 경쟁 조건은 두 개 이상의 쓰레드 또는 프로세스가 공유 자원(counter 변수)에 대해 동시에 읽기 또는 쓰기 작업을 수행하려고 할 때 발생하며, 이 작업들의 실행 순서가 보장되지 않아 결과의 일관성(counter의 결과가 98로 세팅)이 무시될 수 있습니다.

경쟁 조건의 발생 과정

  1. 동시성: 쓰레드는 동시에 IncrementCounter 함수를 실행하고, 각각이 counter 값을 증가시키려고 합니다.
  2. 읽기-수정-쓰기 작업: counter 변수의 증가 연산(++counter)은 세 개의 다른 작업으로 분해될 수 있습니다:
    • 읽기: 현재 counter의 값을 읽습니다.
    • 수정: 읽은 값에 1을 더합니다.
    • 쓰기: 수정된 값을 다시 counter에 저장합니다.
  3. 중첩 실행: 이 작업들 사이에 다른 쓰레드의 동일한 작업이 중첩될 수 있습니다. 예를 들어, 두 쓰레드가 거의 동시에 counter의 값을 읽을 때, 두 쓰레드 모두 같은 값을 읽을 수 있습니다. 이후 각 쓰레드는 값을 1 증가시키고 저장합니다. 이런 경우 실제로는 counter가 두 번 증가해야 하지만, 실제로는 한 번만 증가하게 됩니다.

이런 문제를 방지하기 위해서 크리티컬 섹션, 뮤택스, 세마포어 등의 동기화 기술을 사용합니다.

크리티컬 섹션 객체의 사용 방법

  1. 초기화 및 삭제
    • InitializeCriticalSection 함수를 사용하여 크리티컬 섹션 객체를 초기화합니다.
    • DeleteCriticalSection 함수로 크리티컬 섹션 객체를 삭제하고 관련 리소스를 해제합니다.
  2. 진입 및 해제
    • EnterCriticalSection 함수를 사용하여 크리티컬 섹션에 진입합니다. 이 함수를 호출할 때, 해당 크리티컬 섹션을 이미 소유하고 있는 쓰레드가 있으면 호출한 쓰레드는 대기 상태로 들어갑니다.
    • LeaveCriticalSection 함수로 크리티컬 섹션을 해제합니다. 이 함수 호출로 해당 섹션을 대기하고 있던 다른 쓰레드가 진입할 수 있게 됩니다.
  3. 사용 예시
#include <iostream>
#include <thread>
#include <vector>
#include <windows.h>

CRITICAL_SECTION cs; // 크리티컬 섹션 객체

void accessSharedResource(int threadNum) {
    EnterCriticalSection(&cs); // 크리티컬 섹션에 진입
    // 공유 자원 접근 코드
    std::cout << "Thread " << threadNum << " is accessing shared resource." << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(1)); // 자원 사용 시뮬레이션
    std::cout << "Thread " << threadNum << " has finished accessing shared resource." << std::endl;
    LeaveCriticalSection(&cs); // 크리티컬 섹션을 떠남
}

void threadFunction(int threadNum) {
    accessSharedResource(threadNum);
}

int main() {
    InitializeCriticalSection(&cs); // 크리티컬 섹션 초기화

    std::vector<std::thread> threads;
    for (int i = 0; i < 5; ++i) {
        threads.emplace_back(threadFunction, i);
    }

    for (auto& thread : threads) {
        thread.join(); // 각 쓰레드의 종료를 기다림
    }

    DeleteCriticalSection(&cs); // 크리티컬 섹션 삭제

    return 0;
}

 

결과 화면

Thread 3 is accessing shared resource.
Thread 3 has finished accessing shared resource.
Thread 0 is accessing shared resource.
Thread 0 has finished accessing shared resource.
Thread 1 is accessing shared resource.
Thread 1 has finished accessing shared resource.
Thread 2 is accessing shared resource.
Thread 2 has finished accessing shared resource.
Thread 4 is accessing shared resource.
Thread 4 has finished accessing shared resource.

 

EnterCriticalSection을 사용하지 않았을 경우 출력 화면

Thread Thread 0 is accessing shared resource.Thread 4 is accessing shared resource.

3 is accessing shared resource.
Thread 1 is accessing shared resource.
Thread 2 is accessing shared resource.
Thread 0 has finished accessing shared resource.Thread 1 has finished accessing shared resource.

Thread 4 has finished accessing shared resource.
Thread 3 has finished accessing shared resource.Thread 2 has finished accessing shared resource.

참고로 CRITICAL_SECTION을 사용할 때 RAII 패턴을 활용하여 사용하는 경우가 일반적이므로 RAII에 대한 설명을 아래에 추가로 작성합니다.

RAII(Resource Acquisition Is Initialization)

RAII(Resource Acquisition Is Initialization) 패턴은 C++ 창시자인 비야네 스트롭스트룹이 제안한 자원 관리 방법입니다. 많은 현대 프로그래밍 언어들, 예를 들어 Java는 가비지 컬렉션(Garbage Collection, GC)이 내장되어 있어 프로그램에서 더 이상 사용되지 않는 리소스를 자동으로 해제해줍니다. 이로 인해 개발자들은 자원 해제에 신경 쓸 필요가 적어집니다.

하지만 C++은 다릅니다. C++에서는 한 번 획득한 자원을 프로그래머가 직접 해제해주지 않으면 프로그램이 종료될 때까지 자원이 계속 유지됩니다. 프로그램이 종료되면 운영 체제가 자원을 해제해주긴 하지만, 프로그램 실행 중에 발생한 메모리 누수는 버그의 일반적인 원인이 될 수 있습니다.

C++에서는 이 문제를 해결하기 위해 RAII 패턴을 사용합니다. 이 패턴은 리소스 관리를 스택에 할당된 객체를 통해 수행합니다. 객체가 생성될 때 필요한 리소스를 획득하고, 객체의 수명이 끝날 때 소멸자를 통해 리소스를 자동으로 해제합니다. 만약 리소스가 너무 크거나 스택에 적합하지 않은 경우, 해당 리소스는 객체에 의해 '소유'되며, 이 객체는 스택에 선언됩니다. 이처럼 객체가 리소스를 '소유한다'는 원칙이 바로 "리소스 획득은 초기화" 또는 RAII라고 불립니다.

이 방법을 통해, C++ 개발자들은 메모리 관리를 더 안전하고 효율적으로 수행할 수 있으며, 자원 해제를 잊어버리는 실수를 방지할 수 있습니다. RAII는 또한 예외 발생 시에도 자원이 안전하게 정리될 수 있도록 해, 프로그램의 안정성을 높이는 데 기여합니다.

핵심 개념

  1. 자원의 획득은 객체의 초기화와 함께 수행됩니다: 생성자에서 자원을 획득함으로써, 객체의 초기화 코드와 자원 획득 코드를 동일하게 관리할 수 있습니다. 이로 인해 자원이 성공적으로 획득되었는지 쉽게 확인할 수 있으며, 객체 사용이 가능한 상태에서 항상 자원이 유효하다는 것을 보장합니다.
  2. 자원의 해제는 객체의 소멸과 함께 자동으로 수행됩니다: 소멸자에서 자원을 해제함으로써, 객체가 스코프를 벗어날 때 자동적으로 자원이 반환됩니다. 이는 메모리 누수를 방지하고, 예외가 발생해도 안정적으로 자원을 정리할 수 있도록 도와줍니다.

예시 코드

#include <cstdio>

class File final {
public:
    explicit File(std::FILE* file) : m_file{ file } {}
    ~File() { reset(); }

    // 복사 생성자와 복제 대입을 허용하지 않는다.
    File(const File& src) = delete;
    File& operator=(const File& rhs) = delete;

    // 이동 생성자의 이동 대입을 허용한다.
    File(File&& src) noexcept = default;
    File& operator=(File& rhs) noexcept = default;

    // get(), release(), reset()
    std::FILE* get() const noexcept { return m_file; }

    [[nodiscard]] std::FILE* release() noexcept {
        std::FILE* file{ m_file };
        m_file = nullptr;
        return file;
    }

    void reset(std::FILE* file = nullptr) noexcept {
        if (m_file) { fclose(m_file); }
    }
private:
    std::FILE* m_file{ nullptr };
};

int main() {
    try {
        // 파일 핸들러 객체가 스코프를 벗어나면서 파일이 자동으로 닫힘
        File myFile {fopen("input.txt", "r")};
    } catch (const std::exception& e) {
        std::cerr << "An error occurred: " << e.what() << std::endl;
    }
    return 0;
}

 

myFile 인스턴스가 스코프를 벗어나면 즉시 소멸자가 호출되면서 해당 파일이 자동으로 닫힙니다.

RAII 클래스를 사용할 때 흔히 저지르는 실수가 있는데 잘 알아 둘 필요가 있습니다. 특정한 스코프 안에서 RAII 인스턴스를 제대로 생성했다고 생각하는 코드 행을 실수로 작성하여, 실제로는 임시 객체를 생성해서 그 줄이 끝나면 곧바로 제거되는 경우가 있습니다. 이 문제는 표준 라이브러리 RAII 클래스인 std::unuque_lock을 사용해보면 확실히 드러납니다. unique_lock의 정상적인 실행 방법은 다음과 같습니다.

#include <mutex>

class Foo {
public:
    void setData() {
        std::unique_lock<std::mutex> lock(m_mutex); // 이름이 있는 RAII 객체
        // lock 객체의 스코프가 끝나면 mutex는 자동으로 해제된다.
    }
private:
    std::mutex m_mutex;
};

 

위 setData() 메서드는 RAII인 unique_lock의 객체를 이용하여 m_mutex 데이터 멤버에 락을 걸었다가 메서드가 끝날 즈음 자동으로 락을 해제하는 로컬 lock 객체를 생성합니다.

그런데 이 lock 객체를 정의한 뒤 직접 사용하지 않기 때문에 다음과 같은 실수를 저지르기 쉽습니다.

unique_lock<mutex>(m_mutex);    // 이름이 없는 RAII 객체, 이 줄을 실행하고 난 직후 바로 m_mutex가 해제된다.

 

unique_lock에 이름을 짓지 않았습니다. 이렇게 해도 컴파일은 되지만 의도와 다르게 실행됩니다. 이렇게 하면 m_mutex란 로컬 변수를 선언하고 unique_lock의 디폴트 생성자를 호출해서 이 변수를 초기화합니다. 따라서 데이터 멤버인 m_mutex에 대해 락이 걸리지 않습니다. 이에 대해 컴파일러가 경고 메시지를 출력하지만 경고 수준을 충분히 높여야 볼 수 있습니다. 예를 들면 아래와 같습니다.

waring c4458: declaration if 'm_mutex' hides class member

 

다음과 같이 균일 초기화 구문을 적용하면 컴파일러는 경고 메시지를 출력하지 않을 뿐만 아니라 의도와 다르게 작동합니다. 해당 코드도 마찬가지로 해당 줄을 실행하고 난 직후 m_mutex가 해제 됩니다.

unique_lock<mutex> {m_mutex};

 

아래는 이름을 짓지도 않고 인수 이름도 잘못 적은 코드입니다. 이에 대해 컴파일러는 m이 참조되지 않는 로컬 변수라는 경고문도 띄우지 않습니다. 균일 초기화 구문을 적용하면 선언한 적 없는 m이란 식별자에 대한 컴파일 에러는 발생 시킬 수 있습니다.

unique_lock<mutex> (m);

RAII 패턴을 이용한 크리티컬 섹션 관리

RAII 패턴을 사용하지 않을 때, 개발자는 직접 크리티컬 섹션의 진입과 탈출을 관리해야 합니다. 이는 코드의 복잡성을 증가시키고, 오류 발생의 가능성을 높일 수 있습니다.

#include <windows.h>

CRITICAL_SECTION cs;

void someFunction() {
    EnterCriticalSection(&cs);  // 크리티컬 섹션 진입
    // 공유 자원을 사용하는 코드
    LeaveCriticalSection(&cs);  // 크리티컬 섹션 탈출
}

int main() {
    InitializeCriticalSection(&cs);
    someFunction();
    DeleteCriticalSection(&cs);
    return 0;
}

 

위 예제에서 someFunction 함수 내에서 크리티컬 섹션을 직접 관리하고 있습니다. 만약 someFunction 함수 내의 코드에서 예외가 발생하면 LeaveCriticalSection가 호출되지 않을 수도 있습니다. 이는 크리티컬 섹션의 락이 해제되지 않아 다른 스레드가 영구적으로 차단될 수 있는 상황을 초래할 수 있습니다. 또한 크리티컬 섹션을 사용할 경우, 각 함수마다 진입과 탈출 코드를 반복적으로 작성해야 합니다.

 

RAII 패턴을 적용하면, 크리티컬 섹션의 lock(EnterCriticalSection)과 unlock(LeaveCriticalSection)을 자동화와 해주고 중복 코드도 방지할 수 있습니다.

#include <mutex>

class CriticalSection {
public:
    CriticalSection() {
        InitializeCriticalSection(&m_cs);  // CRITICAL_SECTION 초기화
    }

    ~CriticalSection() {
        DeleteCriticalSection(&m_cs);      // 자원 해제
    }

    void lock() {
        EnterCriticalSection(&m_cs);       // 임계 영역 진입
    }

    void unlock() {
        LeaveCriticalSection(&m_cs);       // 임계 영역 해제
    }

    // 복사 생성자와 할당 연산자 삭제하여 복사 방지
    CriticalSection(const CriticalSection&) = delete;
    CriticalSection& operator=(const CriticalSection&) = delete;

private:
    CRITICAL_SECTION m_cs;                // 내부 CRITICAL_SECTION 객체
};


CriticalSection critical_section;
void someFunction() {
    std::lock_guard<CriticalSection> lock(critical_section); // RAII 기반 자동 잠금
    // 여기에 임계 영역 코드 작성
} // lock 객체의 스코프가 끝나면 자동으로 CriticalSection의 unlock 메소드 실행

int main() {
    someFunction(); // 함수 호출
    return 0;
}

 

이 코드에서 CriticalSection 클래스는 크리티컬 섹션의 생성, 파괴 및 잠금 관리를 자동으로 처리합니다. std::lock_guard를 사용함으로써 함수가 종료되거나 예외가 발생할 때 자동으로 크리티컬 섹션을 해제시켜 줍니다.

 

std::lock_guard란?

template <class _Mutex>
class _NODISCARD lock_guard { // class with destructor that unlocks a mutex
public:
    using mutex_type = _Mutex;

    explicit lock_guard(_Mutex& _Mtx) : _MyMutex(_Mtx) { // construct and lock
        _MyMutex.lock();
    }

    lock_guard(_Mutex& _Mtx, adopt_lock_t) : _MyMutex(_Mtx) {} // construct but don't lock

    ~lock_guard() noexcept {
        _MyMutex.unlock();
    }

    lock_guard(const lock_guard&) = delete;
    lock_guard& operator=(const lock_guard&) = delete;

private:
    _Mutex& _MyMutex;
};

 

std::lock_guard는 에 정의된 간단한 락으로서, 다음 두 가지 생성자를 제공합니다.

  • exlicit lock_guard(mutex_type& m);
    • 뮤텍스에 대한 래퍼런스를 인수로 받는 생성자
    • 이 생성자는 전달된 뮤텍스에 락을 걸기 위해 시도하고, 완전히 락이 걸릴 때까지 블록
  • lock_guard(mutex_type& m, adopt_lock_t);
    • 뮤텍스에 대한 래퍼런스와 std::adopt_lock_t의 인스턴스를 인수로 받는 생성자
    • std::adopt_lock이라는 이름으로 미리 정의된 adopt_lock_t 인스턴스가 제공됩니다. 이때 호출하는 측의 스레드는 인수로 지정한 뮤텍스에 대한 락을 이미 건 상태에서 추가로 락을 겁니다. 락이 제거되면 뮤텍스도 자동으로 해제됩니다.

참고

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2024/07   »
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
글 보관함