C++17 shared_mutex: 읽기-쓰기 락으로 동시 읽기 허용하기와 쓰기 기아

이 글의 핵심

읽기 요청이 압도적으로 많은데 모든 접근을 std::mutex로 직렬화하면 읽기끼리도 서로를 기다리게 됩니다. shared_mutex는 읽기에는 공유 잠금, 쓰기에는 배타 잠금을 주지만 쓰기 기아가 생길 수 있고 shared_lock을 unique_lock으로 올리는 순간 데드락이 납니다. 언제 mutex보다 이득인지 판단하는 기준까지 정리합니다.

shared_mutex란?

std::shared_mutex(C++17)는 뮤텍스처럼 쓰기 시 배타 잠금을 쓰면서, 읽기 시에는 shared_lock으로 여러 스레드가 동시에 읽을 수 있게 합니다. scoped_lock은 배타 잠금용이며, 데이터 레이스·뮤텍스·스레드 기초를 먼저 보면 좋습니다.

shared_mutex vs mutex 비교

구분mutexshared_mutex
읽기 동시성❌ 한 번에 1개✅ 여러 스레드 동시
쓰기 동시성❌ 한 번에 1개❌ 한 번에 1개
읽기-쓰기 충돌❌ 블로킹❌ 블로킹
사용 시나리오읽기/쓰기 비슷읽기 >> 쓰기
오버헤드낮음약간 높음
C++ 버전C++11C++17
#include <shared_mutex>
#include <map>
using namespace std;

class ThreadSafeMap {
private:
    map<int, string> data;
    mutable shared_mutex mtx;
    
public:
    // 읽기 (공유)
    string get(int key) const {
        shared_lock<shared_mutex> lock(mtx);
        auto it = data.find(key);
        return it != data.end() ? it->second : "";
    }
    
    // 쓰기 (배타)
    void set(int key, const string& value) {
        unique_lock<shared_mutex> lock(mtx);
        data[key] = value;
    }
};

이 클래스에서 눈여겨볼 부분은 mutable shared_mutex입니다. get()은 const 멤버 함수라서 멤버를 바꿀 수 없는데, 잠금을 거는 것은 뮤텍스의 내부 상태를 바꾸는 동작입니다. mutable은 “이 멤버는 객체의 논리적 상태가 아니다”라고 선언해 const 함수 안에서도 잠글 수 있게 합니다. 또 get()이 string을 값으로 돌려주는 것도 의도된 설계입니다. const string&로 내부 원소의 참조를 돌려주면 잠금은 함수가 끝날 때 풀리는데 호출자는 계속 그 참조를 들고 있게 되고, 그 사이 다른 스레드가 set()으로 값을 바꾸면 호출자는 잠금 없이 수정 중인 메모리를 읽게 됩니다. 락으로 보호하는 컨테이너에서 참조나 반복자를 밖으로 내보내면 보호가 사라진다는 점은 shared_mutex든 mutex든 똑같이 적용됩니다.

락 동작 다이어그램

graph TD
    A[Thread Access] --> B{Operation}
    
    B -->|Read| C[shared_lock]
    C --> D{Other Thread?}
    D -->|Reading| E[✅ Acquire]
    D -->|Writing| F[⏳ Wait]
    D -->|None| E
    
    B -->|Write| G[unique_lock]
    G --> H{Other Thread?}
    H -->|Reading| I[⏳ Wait]
    H -->|Writing| I
    H -->|None| J[✅ Acquire]

기본 사용법

#include <shared_mutex>

shared_mutex mtx;
int counter = 0;

void reader() {
    shared_lock<shared_mutex> lock(mtx);  // 공유 락
    cout << "값: " << counter << endl;
}

void writer() {
    unique_lock<shared_mutex> lock(mtx);  // 배타 락
    counter++;
}

shared_lock vs unique_lock

락 타입 비교

락 타입용도동시 접근다른 shared_lock다른 unique_lock
shared_lock읽기✅ 여러 스레드✅ 허용❌ 블로킹
unique_lock쓰기❌ 배타적❌ 블로킹❌ 블로킹
lock_guard쓰기❌ 배타적❌ 블로킹❌ 블로킹
// shared_lock: 읽기 (여러 스레드 동시 가능)
shared_lock<shared_mutex> slock(mtx);

// unique_lock: 쓰기 (배타적)
unique_lock<shared_mutex> ulock(mtx);

// lock_guard도 사용 가능 (배타)
lock_guard<shared_mutex> lock(mtx);

shared_mutex는 두 벌의 인터페이스를 가집니다. lock()/unlock()/try_lock()은 배타 잠금이고, lock_shared()/unlock_shared()/try_lock_shared()는 공유 잠금입니다. unique_lock과 lock_guard는 앞의 것을, shared_lock은 뒤의 것을 호출하는 RAII 래퍼일 뿐입니다. 그래서 shared_lock은 C++14부터 있었고 shared_timed_mutex와도 함께 쓸 수 있습니다. 직접 lock_shared()를 호출하는 코드는 예외가 나면 해제를 놓치므로 항상 래퍼를 쓰는 것이 원칙입니다.

한 가지 주의할 점은 같은 스레드에서 공유 잠금을 두 번 거는 것입니다. 공개 함수 get()이 내부에서 다른 공개 함수 size()를 호출하고, 두 함수가 모두 shared_lock을 건다면 “읽기끼리는 동시에 되니 괜찮다”고 생각하기 쉽습니다. 하지만 표준은 이미 잠금을 가진 스레드가 같은 shared_mutex를 다시 잠그는 것을 정의되지 않은 동작으로 둡니다. 실제로 쓰기 우선 구현에서는 두 잠금 사이에 쓰기 스레드가 대기열에 들어오면 두 번째 공유 잠금이 쓰기를 기다리고, 쓰기는 첫 번째 공유 잠금이 풀리길 기다리는 교착이 생깁니다. 잠금을 거는 공개 함수와 잠금 없이 동작하는 내부 함수(size_unlocked() 등)를 나누는 것이 일반적인 해결책입니다.

동시 접근 시나리오

gantt
    title shared_mutex 동시성 타임라인
    dateFormat X
    axisFormat %L
    
    section 스레드1
    읽기 (shared_lock) :0, 100
    
    section 스레드2
    읽기 (shared_lock) :0, 100
    
    section 스레드3
    읽기 (shared_lock) :0, 100
    
    section 스레드4
    쓰기 대기 :0, 100
    쓰기 (unique_lock) :100, 150
    
    section 스레드5
    읽기 대기 :100, 150
    읽기 (shared_lock) :150, 200

실전 예시

예시 1: 캐시

#include <unordered_map>

class Cache {
private:
    unordered_map<string, string> data;
    mutable shared_mutex mtx;
    
public:
    // 읽기 (동시 접근 가능)
    optional<string> get(const string& key) const {
        shared_lock<shared_mutex> lock(mtx);
        
        auto it = data.find(key);
        if (it != data.end()) {
            return it->second;
        }
        return nullopt;
    }
    
    // 쓰기 (배타적)
    void put(const string& key, const string& value) {
        unique_lock<shared_mutex> lock(mtx);
        data[key] = value;
    }
    
    // 읽기 후 쓰기
    string getOrCompute(const string& key) {
        // 먼저 읽기 시도
        {
            shared_lock<shared_mutex> lock(mtx);
            auto it = data.find(key);
            if (it != data.end()) {
                return it->second;
            }
        }
        
        // 없으면 계산 후 쓰기
        string value = "computed_" + key;
        
        {
            unique_lock<shared_mutex> lock(mtx);
            data[key] = value;
        }
        
        return value;
    }
};

int main() {
    Cache cache;
    
    // 쓰기 스레드
    thread writer([&]() {
        for (int i = 0; i < 10; i++) {
            cache.put("key" + to_string(i), "value" + to_string(i));
            this_thread::sleep_for(chrono::milliseconds(100));
        }
    });
    
    // 읽기 스레드 (여러 개)
    vector<thread> readers;
    for (int i = 0; i < 5; i++) {
        readers.emplace_back([&, i]() {
            for (int j = 0; j < 20; j++) {
                auto value = cache.get("key" + to_string(j % 10));
                if (value) {
                    cout << "스레드 " << i << ": " << *value << endl;
                }
                this_thread::sleep_for(chrono::milliseconds(50));
            }
        });
    }
    
    writer.join();
    for (auto& t : readers) {
        t.join();
    }
}

getOrCompute()는 읽기 잠금으로 먼저 확인하고, 없을 때만 쓰기 잠금을 잡는 전형적인 패턴입니다. 값을 계산하는 동안 아무 잠금도 잡지 않기 때문에, 계산이 오래 걸려도 다른 스레드의 읽기를 막지 않는다는 장점이 있습니다. 대신 이 틈에 두 스레드가 같은 키를 동시에 못 찾으면 둘 다 계산하고 둘 다 쓰게 됩니다. 계산 결과가 같고 비용이 크지 않다면 문제없지만, 계산이 비싸거나 결과가 매번 다르다면 쓰기 잠금을 잡은 뒤 한 번 더 확인해야 합니다. auto [it, inserted] = data.try_emplace(key, value); return it->second;처럼 쓰면 먼저 넣은 스레드의 값이 유지되고 늦은 스레드는 그 값을 돌려받습니다.

예제의 main에서 출력하는 cout도 공유 자원입니다. 여러 스레드가 <<를 이어 호출하면 줄이 섞여 출력될 수 있는데, 이는 스트림 연산자가 호출 단위로만 원자적이기 때문입니다. 줄 단위로 깔끔하게 찍으려면 C++20의 std::osyncstream을 쓰거나 별도의 출력 뮤텍스를 둡니다.

예시 2: 설정 관리자

class ConfigManager {
private:
    unordered_map<string, string> config;
    mutable shared_mutex mtx;
    
public:
    // 읽기
    string get(const string& key) const {
        shared_lock<shared_mutex> lock(mtx);
        auto it = config.find(key);
        return it != config.end() ? it->second : "";
    }
    
    // 쓰기
    void set(const string& key, const string& value) {
        unique_lock<shared_mutex> lock(mtx);
        config[key] = value;
    }
    
    // 일괄 읽기
    unordered_map<string, string> getAll() const {
        shared_lock<shared_mutex> lock(mtx);
        return config;
    }
    
    // 일괄 쓰기
    void setAll(const unordered_map<string, string>& newConfig) {
        unique_lock<shared_mutex> lock(mtx);
        config = newConfig;
    }
};

설정 관리자는 shared_mutex가 가장 잘 맞는 경우입니다. 설정은 거의 바뀌지 않고, 요청마다 여러 스레드가 읽습니다. getAll()이 전체 맵을 복사해 돌려주는 것도 앞에서 말한 “참조를 밖으로 내보내지 않는다” 원칙을 따른 것입니다. 다만 설정 항목 여러 개를 한꺼번에 일관되게 읽어야 한다면(예: host와 port가 같은 버전이어야 함) get()을 두 번 호출하는 사이에 setAll()이 끼어들 수 있으므로 getAll()로 한 번에 읽어야 합니다.

읽기가 압도적으로 많고 설정 크기가 작다면 shared_mutex 대신 불변 스냅샷 교체 방식도 좋은 대안입니다. 설정을 std::shared_ptr<const Config>로 들고 있다가, 변경할 때 새 객체를 만들어 포인터만 원자적으로 바꾸는 방식입니다(C++20의 std::atomic<std::shared_ptr<T>> 또는 C++11의 std::atomic_load/atomic_store). 읽는 쪽은 포인터를 복사해 두면 그 스냅샷이 끝까지 유지되므로 잠금을 오래 잡지 않아도 일관된 설정을 볼 수 있습니다.

예시 3: 통계 수집기

class Statistics {
private:
    long long totalRequests = 0;
    long long successCount = 0;
    long long errorCount = 0;
    mutable shared_mutex mtx;
    
public:
    // 쓰기
    void recordRequest(bool success) {
        unique_lock<shared_mutex> lock(mtx);
        totalRequests++;
        if (success) {
            successCount++;
        } else {
            errorCount++;
        }
    }
    
    // 읽기
    double getSuccessRate() const {
        shared_lock<shared_mutex> lock(mtx);
        if (totalRequests == 0) return 0.0;
        return 100.0 * successCount / totalRequests;
    }
    
    void printStats() const {
        shared_lock<shared_mutex> lock(mtx);
        cout << "총 요청: " << totalRequests << endl;
        cout << "성공: " << successCount << endl;
        cout << "실패: " << errorCount << endl;
    }
};

int main() {
    Statistics stats;
    
    // 요청 처리 스레드
    vector<thread> workers;
    for (int i = 0; i < 10; i++) {
        workers.emplace_back([&]() {
            for (int j = 0; j < 1000; j++) {
                bool success = (rand() % 10) < 9;  // 90% 성공
                stats.recordRequest(success);
            }
        });
    }
    
    // 모니터링 스레드
    thread monitor([&]() {
        for (int i = 0; i < 10; i++) {
            this_thread::sleep_for(chrono::milliseconds(500));
            cout << "성공률: " << stats.getSuccessRate() << "%" << endl;
        }
    });
    
    for (auto& t : workers) {
        t.join();
    }
    monitor.join();
    
    stats.printStats();
}

이 예제는 shared_mutex의 사용법을 보여 주지만, 실제로는 shared_mutex가 맞지 않는 워크로드의 좋은 예이기도 합니다. 워커 10개가 요청마다 recordRequest()로 배타 잠금을 잡고, 읽기는 모니터링 스레드가 0.5초마다 한 번 하는 것이 전부입니다. 즉 쓰기가 압도적으로 많습니다. 이런 경우 shared_mutex는 mutex보다 무거운 잠금을 매번 잡는 셈이라 이득이 없습니다. 카운터 세 개라면 std::atomic<long long> 세 개로 바꾸고 fetch_add(1, std::memory_order_relaxed)로 올리는 편이 훨씬 빠릅니다. 대신 원자 변수 세 개는 서로 독립적으로 갱신되어, 읽는 순간 successCount + errorCount가 totalRequests와 잠깐 맞지 않을 수 있다는 트레이드오프가 있습니다. 모니터링용 통계라면 대개 허용할 수 있는 수준입니다. 또 rand()는 스레드 안전이 보장되지 않는 함수라 여러 스레드에서 호출하면 안 되고, 스레드마다 std::mt19937을 따로 두어야 합니다.

성능 비교

읽기 중심 워크로드 성능

성능 차이는 하드웨어, 표준 라이브러리 구현, 임계 구역 길이에 따라 크게 달라지므로 고정된 수치로 말하기 어렵습니다. 경향으로 정리하면 다음과 같습니다.

시나리오mutex 대비 shared_mutex
읽기 스레드 1개이득 없음, 약간의 오버헤드
읽기 여러 개 + 임계 구역이 긺 (큰 맵 탐색, 복사)동시 읽기로 처리량이 크게 늘어남
읽기 여러 개 + 임계 구역이 아주 짧음 (값 하나 읽기)카운터 갱신 비용 때문에 이득이 작거나 오히려 느림
읽기 90% / 쓰기 10%대체로 이득, 쓰기 대기 시간은 늘 수 있음
읽기 50% / 쓰기 50%대체로 mutex와 비슷하거나 느림

임계 구역이 짧을 때 이득이 사라지는 이유는 공유 잠금도 결국 “현재 읽는 스레드 수” 카운터를 원자적으로 올리고 내려야 하기 때문입니다. 여러 코어가 이 카운터 하나를 동시에 갱신하면 그 캐시 라인이 코어 사이를 계속 오가며(캐시 라인 핑퐁), 읽기끼리는 논리적으로 막지 않아도 하드웨어 수준에서 서로를 느리게 만듭니다. 제가 처음 shared_mutex를 도입할 때 기대만큼 빨라지지 않았던 이유도 이것이었습니다. 보호하는 작업이 해시맵 조회 한 번 정도로 짧아서, 잠금 자체의 비용이 작업 비용과 비슷했던 것입니다. 교체하기 전에 실제 워크로드로 두 버전을 측정해 보는 것이 가장 확실합니다.

// mutex: 읽기도 배타적 (느림)
mutex mtx;

void reader() {
    lock_guard<mutex> lock(mtx);  // 한 번에 하나만
    // 읽기
}

// shared_mutex: 읽기는 동시 가능 (빠름)
shared_mutex smtx;

void reader() {
    shared_lock<shared_mutex> lock(smtx);  // 여러 스레드 동시
    // 읽기
}

성능 특성 다이어그램

graph LR
    A[Workload Analysis] --> B{Read Ratio}
    
    B -->|90%+| C[shared_mutex]
    C --> D[High Perf Gain]
    
    B -->|70-90%| E[shared_mutex]
    E --> F[Medium Gain]
    
    B -->|<50%| G[mutex]
    G --> H[Low Overhead]
    
    B -->|Write Heavy| I[mutex/atomic]
    I --> J[Simplicity]

자주 발생하는 문제

문제 1: 데드락

// ❌ 락 순서 불일치
void func1() {
    unique_lock<shared_mutex> lock1(mtx1);
    unique_lock<shared_mutex> lock2(mtx2);
}

void func2() {
    unique_lock<shared_mutex> lock2(mtx2);
    unique_lock<shared_mutex> lock1(mtx1);  // 데드락
}

// ✅ 락 순서 일치
void func1() {
    unique_lock<shared_mutex> lock1(mtx1);
    unique_lock<shared_mutex> lock2(mtx2);
}

void func2() {
    unique_lock<shared_mutex> lock1(mtx1);
    unique_lock<shared_mutex> lock2(mtx2);
}

잠금 순서를 코드 전체에서 지키는 것은 규모가 커지면 사람의 주의력에 의존하게 됩니다. 두 개 이상의 뮤텍스를 한 번에 배타 잠금해야 한다면 C++17의 std::scoped_lock lock(mtx1, mtx2);를 쓰면 인자 순서와 상관없이 교착 없이 모두 잠가 줍니다. 공유 잠금과 배타 잠금을 섞어야 한다면 std::shared_lock<std::shared_mutex> r(mtx1, std::defer_lock); std::unique_lock<std::shared_mutex> w(mtx2, std::defer_lock); std::lock(r, w);처럼 지연 잠금 후 std::lock으로 한 번에 잡을 수 있습니다. 공유 잠금도 데드락에서 자유롭지 않다는 점을 기억해야 합니다. A 스레드가 mtx1을 공유로, B 스레드가 mtx2를 공유로 잡은 상태에서 서로 상대 뮤텍스를 배타로 요청하면 똑같이 교착됩니다.

문제 2: 쓰기 기아

// 읽기가 많으면 쓰기가 대기할 수 있음
// 해결: 쓰기 우선 정책 또는 타임아웃

표준은 shared_mutex가 읽기와 쓰기 중 무엇을 우선하는지 정하지 않았고, 구현마다 다릅니다. 예를 들어 Linux의 libstdc++는 pthread_rwlock_t 위에 구현되어 있는데, glibc의 기본 rwlock 정책은 읽기를 우선하므로 읽기가 끊임없이 들어오면 쓰기가 한참 기다릴 수 있습니다. 같은 코드가 다른 플랫폼에서는 쓰기를 우선해 반대로 읽기 지연이 늘기도 합니다. 그래서 “쓰기가 늦게 반영된다”거나 “설정 변경 API가 가끔 수 초씩 걸린다”는 증상이 특정 환경에서만 나타난다면 잠금 정책을 의심해 볼 만합니다.

쓰기 기아 시나리오:

sequenceDiagram
    participant R1 as Reader 1
    participant R2 as Reader 2
    participant W as Writer
    participant M as shared_mutex
    
    R1->>M: shared_lock
    R2->>M: shared_lock
    W->>M: unique_lock (wait)
    
    Note over W: waiting...
    
    R1->>M: unlock
    
    Note over W: R2 still reading
    
    R2->>M: unlock
    W->>M: acquire unique_lock
    W->>M: unlock

해결 방법 비교:

방법장점단점구현 난이도
타임아웃데드락 방지실패 처리 필요낮음
쓰기 우선 정책공정성 보장커스텀 구현높음
읽기 제한균형 유지복잡도 증가중간

타임아웃은 std::shared_timed_mutex의 try_lock_for()로 쓰기 시도에 제한 시간을 두는 방식입니다. 기아 자체를 해결하지는 않지만 쓰기 쪽이 무한정 멈추지 않고 실패를 보고할 수 있게 해 줍니다. 근본적인 해결은 읽기 임계 구역을 짧게 만드는 것입니다. 공유 잠금 안에서 I/O나 긴 계산을 하지 않고, 필요한 데이터만 복사한 뒤 바로 잠금을 푸는 습관만으로도 쓰기 대기 시간은 크게 줄어듭니다.

문제 3: 락 업그레이드

// ❌ shared_lock에서 unique_lock으로 변경 불가
shared_lock<shared_mutex> slock(mtx);
// unique_lock<shared_mutex> ulock(mtx);  // 데드락

// ✅ 락 해제 후 재획득
{
    shared_lock<shared_mutex> slock(mtx);
    // 읽기
}
{
    unique_lock<shared_mutex> ulock(mtx);
    // 쓰기
}

락 업그레이드 문제:

graph TD
    A[Thread: has shared_lock] --> B{Request unique_lock}
    B --> C[Wait for other shared_lock]
    C --> D[Other threads waiting]
    D --> E[❌ Deadlock]
    
    A --> F[Release shared_lock]
    F --> G[Request unique_lock]
    G --> H{Other Thread?}
    H -->|Reading| I[Wait]
    H -->|None| J[✅ Acquire]
    I --> K[Other complete]
    K --> J

업그레이드가 교착되는 이유는 단순합니다. 공유 잠금을 가진 스레드가 배타 잠금을 요청하면, 배타 잠금은 모든 공유 잠금이 풀리기를 기다리는데, 그 공유 잠금 중 하나가 바로 자기 자신입니다. 두 스레드가 동시에 업그레이드를 시도하면 서로의 공유 잠금을 기다리는 고전적인 교착이 됩니다. 이 때문에 표준 shared_mutex는 업그레이드 연산 자체를 제공하지 않습니다(Boost의 upgrade_mutex는 업그레이드 가능한 잠금을 한 번에 한 스레드에게만 주는 방식으로 이 문제를 풉니다).

해제 후 재획득 방식에서 반드시 챙겨야 할 것은 다시 확인입니다. 공유 잠금을 풀고 배타 잠금을 잡기까지의 틈에 다른 스레드가 먼저 쓰기를 끝냈을 수 있으므로, 읽기 단계에서 내린 판단(“키가 없다”)은 배타 잠금을 잡은 뒤에는 더 이상 유효하지 않습니다. 배타 잠금 안에서 조건을 한 번 더 검사하고 쓰는 것이 올바른 패턴이며, 앞의 캐시 예제에서 try_emplace를 권한 것도 같은 이유입니다.

FAQ

Q1: shared_mutex는 언제 사용하나요?

A:

  • 읽기가 쓰기보다 훨씬 많고, 읽기 임계 구역이 충분히 길 때
  • 캐시, 설정, 라우팅 테이블처럼 자주 읽고 가끔 바뀌는 데이터
  • 반대로 요청마다 갱신하는 통계 카운터는 atomic이 더 맞습니다

Q2: 성능 향상은?

A: 동시 읽기가 많고 읽기 작업이 길수록 크게 향상됩니다. 쓰기가 많거나 읽기 구간이 아주 짧으면 공유 카운터 갱신 비용 때문에 mutex보다 느릴 수 있습니다.

Q3: shared_mutex vs mutex?

A:

  • shared_mutex: 읽기 동시 가능
  • mutex: 모두 배타적

Q4: C++14 vs C++17?

A:

  • C++14: shared_timed_mutex
  • C++17: shared_mutex (더 가벼움)

Q5: 락 업그레이드는?

A: 직접 지원 안함. 락 해제 후 재획득.

Q6: shared_mutex 학습 리소스는?

A:

  • “C++ Concurrency in Action”
  • cppreference.com
  • “Effective Modern C++”

관련 글: 뮤텍스·lock_guard, scoped_lock, 데이터 레이스·뮤텍스, 스레드 기초.


같이 보면 좋은 글