쓰레드 처리를 위한 Auto Lock/Unlock 클래스
오늘 공부의 목표는 Lock/Unlock의 자동화였다.
EnterCriticalSection/LeaveCriticalSection을 일일이 수동으로 호출하는 것은 귀찮을 뿐만 아니라, 중간에 return하거나 예외가 던져지면 언락이 누락되어 문제가 생길 소지가 크다. 그래서 그걸 아예 자동화해주는 게 Auto Lock/Unlock 클래스다.
2026년 수정 안내 이 글에는 두 가지 잘못된 서술이 있었다. 하나는 “블록이 시작될 때 크리티컬 섹션이 초기화되고 블록이 끝날 때 삭제된다”는 설명인데, 실제로는 락/언락만 일어난다. 다른 하나는 “이렇게 하면 데드락을 막을 수 있다”는 결론인데, RAII는 데드락을 막아주지 않는다. 둘 다 바로잡고, 원래 코드에 있던 헤더 중복 정의 문제와 락 단위 문제도 함께 정리한다.
원리
C++의 RAII(Resource Acquisition Is Initialization) 패턴이다. 객체가 생성될 때 자원을 얻고, 스코프를 벗어나 소멸자가 불릴 때 자원을 놓는다. 소멸자는 return으로 빠져나가든 예외가 던져지든 반드시 호출되므로, 언락 누락이 구조적으로 불가능해진다.
lock.h
크리티컬 섹션의 수명을 관리하는 클래스다. 이 객체가 만들어질 때 한 번 초기화되고, 파괴될 때 한 번 삭제된다.
#pragma once
#include <Windows.h>
class Lock
{
public:
Lock()
{
InitializeCriticalSection( &cs_ );
}
~Lock()
{
DeleteCriticalSection( &cs_ );
}
// 복사되면 같은 크리티컬 섹션을 두 번 삭제하게 된다.
Lock( const Lock& ) = delete;
Lock& operator=( const Lock& ) = delete;
void lock()
{
EnterCriticalSection( &cs_ );
}
void unlock()
{
LeaveCriticalSection( &cs_ );
}
private:
CRITICAL_SECTION cs_;
};
AutoLockUnlock.h
스코프 동안 락을 잡아주는 클래스다. 락 객체를 직접 소유하지 않고 참조만 받는다. 이렇게 해야 보호하려는 데이터마다 별도의 락을 쓸 수 있다.
#pragma once
#include "lock.h"
class AutoLockUnlock
{
public:
explicit AutoLockUnlock( Lock& target )
: lock_( target )
{
lock_.lock();
}
~AutoLockUnlock()
{
lock_.unlock();
}
AutoLockUnlock( const AutoLockUnlock& ) = delete;
AutoLockUnlock& operator=( const AutoLockUnlock& ) = delete;
private:
Lock& lock_;
};
처음 작성했던 코드는 AutoLockUnlock 안에 static Lock lock_;을 두고 헤더에서 정의했는데, 두 가지 문제가 있었다.
- 헤더에 비인라인 정의를 두면 두 개 이상의
.cpp에서 include할 때 중복 정의 링크 에러가 난다. - 락이 프로그램 전체에 단 하나뿐이라, 서로 무관한 데이터를 보호하는 코드끼리도 전부 직렬화된다. 멀티스레드로 만든 의미가 사라진다.
사용
보호할 데이터 옆에 락을 하나 두고, 임계 영역을 블록으로 감싼다.
Lock g_target_lock;
int g_target = 0;
unsigned int WINAPI ThreadFunc( void* arg )
{
for ( int i = 0; i < 50000; ++i )
{
AutoLockUnlock autolock( g_target_lock ); // 여기서 락
g_target = g_target + 1;
} // 블록을 벗어나며 언락
for ( int i = 0; i < 50000; ++i )
{
AutoLockUnlock autolock( g_target_lock );
g_target = g_target - 1;
}
return 0;
}
중요한 것은 락의 범위를 필요한 만큼만 좁게 잡는 것이다. 처음 작성했던 코드처럼 함수 맨 앞에서 락을 걸어버리면 함수 전체가 임계 영역이 되어, 스레드들이 순서대로 한 줄로 서서 실행된다.
RAII가 막아주는 것과 막아주지 못하는 것
이 부분이 원래 글에서 가장 크게 틀렸던 지점이다.
막아주는 것: 언락 누락. 중간에 return하거나 예외가 던져져도 소멸자가 불리므로 락이 반드시 풀린다.
막아주지 못하는 것: 데드락. 락을 두 개 이상 잡을 때 순서가 엇갈리면 RAII를 써도 그대로 데드락에 빠진다.
// 두 함수 모두 AutoLockUnlock을 쓰지만 데드락이 난다.
void Func1()
{
AutoLockUnlock a( g_lock_a );
AutoLockUnlock b( g_lock_b ); // B를 기다린다
}
void Func2()
{
AutoLockUnlock b( g_lock_b );
AutoLockUnlock a( g_lock_a ); // A를 기다린다
}
데드락을 피하려면 락 획득 순서를 전역으로 통일하거나, 여러 락을 한 번에 잡아야 한다. 자세한 내용은 데드락 현상이란? 글에 정리해두었다.
지금이라면 표준 라이브러리를 쓴다
이 글을 쓸 당시에는 직접 만들어야 했지만, C++11부터는 표준에 다 들어와 있다. 크로스 플랫폼이기도 하니 새로 짤 이유가 없다.
#include <mutex>
std::mutex g_target_mutex;
int g_target = 0;
void ThreadFunc()
{
for ( int i = 0; i < 50000; ++i )
{
std::lock_guard<std::mutex> lock( g_target_mutex );
g_target = g_target + 1;
}
}
std::lock_guard— 가장 단순한 형태. 위AutoLockUnlock과 같은 역할이다.std::unique_lock— 중간에 풀었다 다시 잡거나, 조건 변수와 함께 쓸 때.std::scoped_lock(C++17) — 락을 여러 개 동시에 잡을 때. 데드락을 피하는 순서로 획득해준다.
std::scoped_lock lock( g_mutex_a, g_mutex_b ); // 순서가 달라도 안전하다
참고로 단순한 카운터 증감이라면 락 대신 std::atomic<int>를 쓰는 편이 훨씬 빠르다.
댓글 남기기