3 분 소요

오늘 공부의 목표는 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_;을 두고 헤더에서 정의했는데, 두 가지 문제가 있었다.

  1. 헤더에 비인라인 정의를 두면 두 개 이상의 .cpp에서 include할 때 중복 정의 링크 에러가 난다.
  2. 락이 프로그램 전체에 단 하나뿐이라, 서로 무관한 데이터를 보호하는 코드끼리도 전부 직렬화된다. 멀티스레드로 만든 의미가 사라진다.

사용

보호할 데이터 옆에 락을 하나 두고, 임계 영역을 블록으로 감싼다.

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>를 쓰는 편이 훨씬 빠르다.

댓글 남기기