C++/Concepts

C++ lock ownership 제대로 이해하기

Enchantée 2026. 7. 25. 21:26
728x90
반응형

C++에서 mutex를 직접 lock하고 unlock하는 코드는 짧아 보이지만, 예외나 early return이 끼어드는 순간 실수하기 쉽습니다.

그래서 실무 C++에서는 mutex를 직접 관리하기보다 RAII object로 lock ownership을 관리하는 방식을 자주 사용합니다.

대표적인 도구가 std::lock_guard와 std::unique_lock입니다.

둘 다 scope가 끝날 때 unlock을 보장하지만, 사용할 수 있는 상황과 표현하는 의도는 다릅니다.

 

std::lock_guard는 단순한 scope lock이고, std::unique_lock은 lock ownership을 상태로 들고 다니는 더 유연한 RAII lock object입니다.

 

lock_guard와 unique_lock의 차이는 성능보다 lock ownership을 얼마나 유연하게 다뤄야 하는지에서 시작됩니다.

 


1. 들어가며

mutex는 공유 자원에 동시에 접근하는 코드를 보호하기 위한 기본 도구입니다.

하지만 mutex.lock()과 mutex.unlock()을 직접 호출하면 unlock을 빠뜨리는 실수가 생길 수 있습니다.

예외가 발생하거나 함수가 중간에 return되면 더 위험합니다.

C++의 RAII lock object는 이런 문제를 줄이기 위해 lock과 unlock을 객체의 lifetime에 묶습니다.

 

구분 std::lock_guard std::unique_lock
기본 성격 가볍고 단순한 scope lock lock ownership을 상태로 관리하는 lock object
생성 시 lock 기본적으로 즉시 lock 즉시 lock, 지연 lock, 이미 lock된 mutex 인수 가능
중간 unlock 불가능 가능
ownership 이동 불가능 move 가능
대표 용도 짧고 단순한 critical section condition_variable, try_lock, unlock/relock이 필요한 흐름

둘 중 무엇을 써야 하는지는 “어느 쪽이 더 고급인가”가 아니라 “critical section이 단순한가”로 판단하는 편이 좋습니다.

 


2. 왜 중요한가?

lock 코드는 버그가 나면 재현이 어렵습니다.

unlock 누락은 deadlock으로 이어질 수 있고, 너무 넓은 critical section은 성능 저하와 contention을 만들 수 있습니다.

또 lock을 잡은 상태에서 logging, callback, I/O 같은 작업을 오래 수행하면 다른 thread가 불필요하게 대기할 수 있습니다.

  1. lock 범위를 코드로 명확하게 표현할 수 있습니다.
  2. 예외나 early return이 있어도 scope 종료 시 unlock을 보장할 수 있습니다.
  3. critical section을 짧게 유지하는 습관을 만들 수 있습니다.
  4. condition_variable처럼 lock ownership 이동이 필요한 API와 자연스럽게 연결할 수 있습니다.
  5. 코드 리뷰에서 lock이 잡힌 구간과 풀린 구간을 더 쉽게 확인할 수 있습니다.

std::lock_guard는 “여기서부터 scope 끝까지 lock을 잡는다”는 의도를 가장 짧게 표현합니다.

std::unique_lock은 “lock을 잡을 수도 있고, 나중에 잡거나 중간에 풀 수도 있다”는 상태 변화를 표현합니다.

 


3. 핵심 개념: lock ownership

두 타입의 공통점은 destructor에서 unlock을 수행한다는 점입니다.

차이는 lock ownership을 얼마나 명시적으로 다룰 수 있는지입니다.

std::lock_guard는 생성되면 lock을 잡고, scope가 끝날 때까지 ownership이 고정됩니다.

반면 std::unique_lock은 owns_lock()으로 현재 lock ownership을 확인할 수 있고, lock(), unlock(), try_lock()을 통해 상태를 바꿀 수 있습니다.

 

상황 권장 타입 이유
함수 시작부터 끝까지 같은 lock 보호 std::lock_guard 가장 단순하고 의도가 명확함
lock을 나중에 잡아야 함 std::unique_lock std::defer_lock 사용 가능
중간에 unlock 후 오래 걸리는 작업 수행 std::unique_lock unlock()과 relock이 가능함
std::condition_variable 사용 std::unique_lock wait 중 lock release/reacquire가 필요함
lock object를 다른 함수로 넘겨야 함 std::unique_lock move-only ownership 전달 가능

실무에서는 먼저 std::lock_guard로 충분한지 확인하고, lock 상태를 바꿔야 하는 요구가 있을 때 std::unique_lock으로 넘어가는 순서가 안전합니다.

 


4. 예제 코드

다음 예제는 같은 BankAccount 객체에서 std::lock_guard와 std::unique_lock을 각각 사용하는 모습을 보여줍니다.

std::lock_guard는 deposit처럼 lock 범위가 단순한 함수에 사용하고, std::unique_lock은 withdraw 후 audit을 lock 밖에서 수행하기 위해 중간 unlock을 사용합니다.

#include <iostream>
#include <mutex>
#include <string>

class BankAccount {
public:
    void deposit_with_lock_guard(int amount)
    {
        std::lock_guard<std::mutex> lock(mutex_);
        balance_ += amount;
        std::cout << "lock_guard deposit: " << balance_ << '\n';
    }

    bool withdraw_with_unique_lock(int amount)
    {
        std::unique_lock<std::mutex> lock(mutex_, std::defer_lock);

        std::cout << std::boolalpha;
        std::cout << "unique_lock owns before lock: "
                  << lock.owns_lock() << '\n';

        lock.lock();
        std::cout << "unique_lock owns after lock: "
                  << lock.owns_lock() << '\n';

        if (balance_ < amount) {
            return false;
        }

        balance_ -= amount;

        lock.unlock();
        std::cout << "unique_lock owns after unlock: "
                  << lock.owns_lock() << '\n';

        audit("withdraw finished");
        return true;
    }

    int balance() const
    {
        std::lock_guard<std::mutex> lock(mutex_);
        return balance_;
    }

private:
    void audit(const std::string& message) const
    {
        std::cout << "audit: " << message << '\n';
    }

    mutable std::mutex mutex_;
    int balance_ = 0;
};

int main()
{
    BankAccount account;

    account.deposit_with_lock_guard(100);
    const bool ok = account.withdraw_with_unique_lock(40);

    std::cout << "withdraw result: " << ok << '\n';
    std::cout << "final balance: " << account.balance() << '\n';
}

 

실행 결과

lock_guard deposit: 100
unique_lock owns before lock: false
unique_lock owns after lock: true
unique_lock owns after unlock: false
audit: withdraw finished
withdraw result: true
final balance: 60

 

deposit_with_lock_guard()에서는 lock object가 만들어지는 순간 mutex를 잡고, 함수가 끝나면 자동으로 unlock됩니다.

withdraw_with_unique_lock()에서는 std::defer_lock으로 lock을 미루고, lock.lock() 시점에 ownership을 얻습니다.

balance_를 수정한 뒤 lock.unlock()을 호출하므로 audit()은 mutex를 잡지 않은 상태에서 실행됩니다.

 


5. 실행 결과에서 봐야 할 것

출력에서 가장 중요한 부분은 owns_lock() 값의 변화입니다.

std::unique_lock은 현재 자신이 mutex ownership을 갖고 있는지 상태로 추적합니다.

 

상태 변화

std::unique_lock 생성
std::defer_lock 때문에 아직 mutex를 잡지 않음
owns_lock() == false

lock.lock() 호출
mutex ownership 획득
owns_lock() == true

balance_ 수정
lock.unlock() 호출
mutex ownership 해제
owns_lock() == false

audit() 실행
mutex를 잡지 않은 상태에서 오래 걸릴 수 있는 작업 수행

 

std::lock_guard에는 이런 상태 변화 API가 없습니다.

그 제한이 단점만은 아닙니다. 오히려 단순한 critical section에서는 lock 범위를 바꿀 수 없다는 점이 코드의 의도를 더 분명하게 만듭니다.

 


6. 그림으로 이해하기

std::lock_guard는 scope 시작부터 끝까지 단순하게 mutex를 잡고, std::unique_lock은 필요할 때 lock ownership 상태를 바꿀 수 있습니다.

 

그림에서 봐야 할 차이는 unlock 시점입니다.

std::lock_guard는 scope가 끝날 때 unlock되는 구조라 중간에 critical section을 줄일 수 없습니다.

std::unique_lock은 필요한 작업만 mutex 안에서 수행하고, audit이나 I/O처럼 오래 걸릴 수 있는 작업은 unlock 이후로 뺄 수 있습니다.

 


7. 자주 하는 오해

  1. std::unique_lock이 항상 더 좋은 선택이다.
    더 유연한 도구일 뿐입니다. 단순한 scope lock에는 std::lock_guard가 더 읽기 쉽고 의도가 분명합니다.
  2. std::lock_guard는 예외가 나면 unlock하지 못한다.
    std::lock_guard는 RAII object입니다. stack unwinding 중 destructor가 호출되면 mutex를 unlock합니다.
  3. std::unique_lock을 쓰면 lock 범위가 자동으로 최적화된다.
    unlock 시점은 개발자가 직접 설계해야 합니다. unique_lock은 도구일 뿐 critical section을 줄여주지는 않습니다.
  4. owns_lock()이 true이면 공유 데이터가 항상 안전하다.
    현재 lock object가 mutex를 소유한다는 뜻입니다. 보호해야 할 데이터와 mutex의 대응 관계가 코드에서 일관되어야 안전합니다.
  5. mutex를 오래 잡아도 correctness만 맞으면 괜찮다.
    correctness는 필요조건입니다. lock을 오래 잡으면 contention이 늘고 tail latency가 나빠질 수 있습니다.

lock 관련 코드는 “동작한다”보다 “어디까지가 critical section인지 분명한가”를 기준으로 읽어야 합니다.

 


8. 실무에서는 어떻게 볼까?

실무 코드 리뷰에서는 먼저 lock 범위를 봅니다.

공유 데이터 접근만 critical section 안에 있고, logging, callback, file I/O, network I/O 같은 작업이 lock 밖에 있는지 확인해야 합니다.

단순히 “thread-safe하다”는 말만으로는 부족합니다.

코드 리뷰 질문 확인할 내용 권장 방향
lock 범위가 짧은가? 공유 데이터 접근 외 작업이 critical section에 들어갔는지 확인 필요하면 std::unique_lock으로 중간 unlock
unlock 누락 가능성이 있는가? 직접 mutex.lock(), mutex.unlock()을 호출하는지 확인 RAII lock object 사용
condition_variable을 쓰는가? wait 중 lock release/reacquire가 필요한지 확인 std::unique_lock 사용
lock object를 함수 밖으로 넘기는가? ownership 이동이 필요한지 확인 std::unique_lock을 move-only object로 다룸
성능 문제가 있는가? lock 횟수보다 lock을 잡는 시간과 contention을 먼저 측정 critical section 축소, shard, lock-free 구조 검토

기본 원칙은 단순합니다.

lock을 잡고 바로 공유 데이터를 수정한 뒤 scope가 끝나면 std::lock_guard가 적합합니다.

lock을 늦게 잡거나, 중간에 풀거나, condition_variable과 함께 써야 한다면 std::unique_lock이 적합합니다.

 


9. 정리

std::lock_guard와 std::unique_lock은 모두 mutex unlock을 RAII로 보장하는 도구입니다.

std::lock_guard는 짧고 단순한 critical section에 가장 잘 맞습니다.

std::unique_lock은 lock ownership을 상태로 관리해야 할 때 사용합니다.

condition_variable, defer_lock, try_lock, 중간 unlock이 필요하면 std::unique_lock을 검토합니다.

실무에서는 lock 타입보다 critical section 범위와 unlock 시점을 명확히 하는 것이 더 중요합니다.

 


728x90
반응형