티스토리 뷰
크리티컬 섹션이란?
- 일반적인 프로그래밍 용어로서의 크리티컬 섹션: 다중 쓰레드 환경에서 여러 쓰레드가 동시에 접근할 수 있는 공유 자원을 사용하는 코드 영역을 지칭합니다. 크리티컬 섹션은 데이터의 일관성을 유지하기 위해 특정 시점에 한 쓰레드만 접근할 수 있어야 하는 부분입니다. 이러한 크리티컬 섹션을 보호하기 위해 뮤택스, 세마포어, 락 등의 동기화 메커니즘을 사용할 수 있습니다.
- 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로 세팅)이 무시될 수 있습니다.
경쟁 조건의 발생 과정
- 동시성: 쓰레드는 동시에
IncrementCounter
함수를 실행하고, 각각이counter
값을 증가시키려고 합니다. - 읽기-수정-쓰기 작업:
counter
변수의 증가 연산(++counter
)은 세 개의 다른 작업으로 분해될 수 있습니다:- 읽기: 현재
counter
의 값을 읽습니다. - 수정: 읽은 값에 1을 더합니다.
- 쓰기: 수정된 값을 다시
counter
에 저장합니다.
- 읽기: 현재
- 중첩 실행: 이 작업들 사이에 다른 쓰레드의 동일한 작업이 중첩될 수 있습니다. 예를 들어, 두 쓰레드가 거의 동시에
counter
의 값을 읽을 때, 두 쓰레드 모두 같은 값을 읽을 수 있습니다. 이후 각 쓰레드는 값을1
증가시키고 저장합니다. 이런 경우 실제로는counter
가 두 번 증가해야 하지만, 실제로는 한 번만 증가하게 됩니다.
이런 문제를 방지하기 위해서 크리티컬 섹션, 뮤택스, 세마포어 등의 동기화 기술을 사용합니다.
크리티컬 섹션 객체의 사용 방법
- 초기화 및 삭제
InitializeCriticalSection
함수를 사용하여 크리티컬 섹션 객체를 초기화합니다.DeleteCriticalSection
함수로 크리티컬 섹션 객체를 삭제하고 관련 리소스를 해제합니다.
- 진입 및 해제
EnterCriticalSection
함수를 사용하여 크리티컬 섹션에 진입합니다. 이 함수를 호출할 때, 해당 크리티컬 섹션을 이미 소유하고 있는 쓰레드가 있으면 호출한 쓰레드는 대기 상태로 들어갑니다.LeaveCriticalSection
함수로 크리티컬 섹션을 해제합니다. 이 함수 호출로 해당 섹션을 대기하고 있던 다른 쓰레드가 진입할 수 있게 됩니다.
- 사용 예시
#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는 또한 예외 발생 시에도 자원이 안전하게 정리될 수 있도록 해, 프로그램의 안정성을 높이는 데 기여합니다.
핵심 개념
- 자원의 획득은 객체의 초기화와 함께 수행됩니다: 생성자에서 자원을 획득함으로써, 객체의 초기화 코드와 자원 획득 코드를 동일하게 관리할 수 있습니다. 이로 인해 자원이 성공적으로 획득되었는지 쉽게 확인할 수 있으며, 객체 사용이 가능한 상태에서 항상 자원이 유효하다는 것을 보장합니다.
- 자원의 해제는 객체의 소멸과 함께 자동으로 수행됩니다: 소멸자에서 자원을 해제함으로써, 객체가 스코프를 벗어날 때 자동적으로 자원이 반환됩니다. 이는 메모리 누수를 방지하고, 예외가 발생해도 안정적으로 자원을 정리할 수 있도록 도와줍니다.
예시 코드
#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 인스턴스가 제공됩니다. 이때 호출하는 측의 스레드는 인수로 지정한 뮤텍스에 대한 락을 이미 건 상태에서 추가로 락을 겁니다. 락이 제거되면 뮤텍스도 자동으로 해제됩니다.
참고
- OpenAI의 ChatGPT :https://chatgpt.com/
- RAII 패턴
- Total
- Today
- Yesterday
- java11
- ThreadPool
- spring
- JAVA8
- thread priority
- spring-security
- JUnit
- hot-deploy
- jdk12
- 카멜 표기법
- Visual Studio 2022
- JetBrains Mono
- Redis
- chmod
- Executor
- Mockito
- aspectj
- 한글깨짐
- 파스칼 표기법
- jdk13
- junit5
- gradle
- IntelliJ
- Thread
- JPA
- java
- sgw
- codepoint
- Jenkins
- 확인창
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |