뮤텍스와 락
지난 장에서 std::thread
를 사용하여 여러 실행 흐름(스레드)을 생성하고 관리하는 기본적인 방법을 학습했습니다.
이제 프로그램이 여러 스레드를 통해 동시에 실행될 때 발생하는 가장 흔하고 위험한 문제 중 하나인 경쟁 조건(Race Condition) 과 이를 해결하기 위한 동기화(Synchronization) 메커니즘인 뮤텍스(Mutex) 및 락(Lock) 에 대해 심층적으로 다루겠습니다.
경쟁 조건 (Race Condition)
경쟁 조건은 여러 스레드가 동시에 공유 자원(변수, 데이터 구조, 파일 등)에 접근하여 변경하려고 할 때 발생하는 문제입니다.
스레드들의 실행 순서가 예측 불가능하기 때문에, 최종 결과가 스레드의 실행 순서에 따라 달라질 수 있습니다.
이는 논리적 오류이며, 디버깅하기 매우 어렵습니다.
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
volatile int counter = 0; // 여러 스레드가 공유할 전역 변수 (volatile은 최적화 방지)
void increment_counter() {
for (int i = 0; i < 100000; ++i) {
counter++; // 이 부분에서 경쟁 조건 발생
}
}
int main() {
std::cout << "Initial counter value: " << counter << std::endl;
// 10개의 스레드를 생성하여 counter를 10만 번씩 증가
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment_counter);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter value: " << counter << std::endl;
// 예상 값: 10 * 100000 = 1,000,000
// 실제 출력 값: 1,000,000보다 작을 가능성이 높음 (매번 다를 수 있음)
return 0;
}
실행 결과 분석:
예상 값은 1,000,000이지만, 실제 실행해보면 매번 다른 값이 나오거나 1,000,000보다 작은 값이 나올 것입니다. 왜 그럴까요?
counter++
연산은 실제로는 여러 단계로 이루어집니다.
counter
의 현재 값을 레지스터로 로드.- 레지스터의 값을 1 증가.
- 증가된 값을 다시
counter
에 저장.
만약 스레드 A가 counter
를 로드한 후 아직 저장하기 전에 스레드 B가 counter
를 로드하고 증가시키면, 스레드 A의 증가된 값이 스레드 B의 증가된 값을 덮어쓰게 되어 갱신이 누락될 수 있습니다. 이를 손실된 갱신(Lost Update) 문제라고 합니다.
임계 영역 (Critical Section)
경쟁 조건이 발생하는 코드 영역, 즉 여러 스레드가 동시에 접근해서는 안 되는 공유 자원 접근 코드를 임계 영역(Critical Section) 이라고 합니다.
위의 예시에서 counter++
가 임계 영역입니다.
임계 영역에는 한 번에 하나의 스레드만 접근하도록 보장해야 합니다.
뮤텍스 (Mutex)
뮤텍스(Mutex) 는 Mutual Exclusion(상호 배제) 의 약자로, 임계 영역에 대한 접근을 제어하여 경쟁 조건을 방지하는 가장 기본적인 동기화 도구입니다.
뮤텍스는 잠금(lock)과 해제(unlock) 기능을 제공합니다.
작동 방식
- 스레드가 임계 영역에 진입하기 전에 뮤텍스를 잠급니다(lock).
- 뮤텍스가 이미 잠겨 있다면, 해당 스레드는 뮤텍스가 해제될 때까지 대기합니다.
- 스레드가 임계 영역 작업을 마친 후 뮤텍스를 해제합니다(unlock).
C++ 표준 라이브러리는 <mutex>
헤더에 std::mutex
를 제공합니다.
#include <iostream>
#include <thread>
#include <vector>
#include <mutex> // std::mutex를 위해
int counter_mutex = 0; // 공유 변수
std::mutex mtx; // 뮤텍스 객체
void increment_counter_safe() {
for (int i = 0; i < 100000; ++i) {
mtx.lock(); // 임계 영역 진입 전에 뮤텍스 잠금
counter_mutex++; // 임계 영역 (오직 하나의 스레드만 접근 가능)
mtx.unlock(); // 임계 영역 벗어난 후 뮤텍스 해제
}
}
int main() {
std::cout << "Initial counter_mutex value: " << counter_mutex << std::endl;
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment_counter_safe);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter_mutex value: " << counter_mutex << std::endl;
// 이제 예상 값인 1,000,000이 정확히 출력될 것입니다.
return 0;
}
이제 counter_mutex
의 최종 값은 항상 1,000,000이 될 것입니다.
뮤텍스가 임계 영역에 대한 접근을 직렬화(serialize)하여 한 번에 하나의 스레드만 counter_mutex++
연산을 수행하도록 보장했기 때문입니다.
락 (Lock) - RAII 기반의 안전한 뮤텍스 관리
std::mutex
의 lock()
과 unlock()
을 직접 호출하는 것은 매우 중요합니다.
만약 lock()
후에 unlock()
을 호출하는 것을 잊거나, 임계 영역 내에서 예외가 발생하면 unlock()
이 호출되지 않아 뮤텍스가 영구적으로 잠겨버리는 교착 상태(Deadlock) 나 프로그램 정지 상태에 빠질 수 있습니다.
이러한 문제점을 해결하기 위해 C++는 RAII(Resource Acquisition Is Initialization) 원칙을 기반으로 하는 락 클래스들을 제공합니다.
락 객체들은 생성자에서 뮤텍스를 잠그고, 소멸자에서 자동으로 해제합니다.
따라서 스코프를 벗어나거나 예외가 발생하여 스택 풀기(Stack Unwinding)가 일어나더라도 뮤텍스가 항상 안전하게 해제됨을 보장합니다.
주요 락 클래스는 다음과 같습니다.
-
std::lock_guard
- 가장 기본적인 RAII 락입니다.
- 생성 시 뮤텍스를 잠그고, 소멸 시 잠금을 해제합니다.
- 뮤텍스의 소유권을 이전하거나 잠금을 해제할 수 없습니다. (RAII 목적에 충실)
- 단순한 임계 영역 보호에 가장 적합합니다.
-
std::unique_lock
std::lock_guard
보다 훨씬 더 유연합니다.- 뮤텍스를 잠그거나 해제하는 시점을 개발자가 제어할 수 있습니다 (
lock()
,unlock()
). - 뮤텍스 소유권을 다른
unique_lock
객체로 이동(std::move
)할 수 있습니다. std::condition_variable
과 함께 사용될 때 필수적입니다.- 약간의 오버헤드가 더 있습니다.
#include <iostream>
#include <thread>
#include <vector>
#include <mutex> // std::mutex, std::lock_guard를 위해
int counter_lock_guard = 0;
std::mutex mtx_guard;
void increment_counter_lock_guard() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx_guard); // 생성 시 mtx_guard 잠금
counter_lock_guard++;
// 스코프를 벗어나면 lock 객체 소멸, 자동으로 mtx_guard 잠금 해제
}
}
int main() {
std::cout << "Initial counter_lock_guard value: " << counter_lock_guard << std::endl;
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment_counter_lock_guard);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter_lock_guard value: " << counter_lock_guard << std::endl;
// 항상 1,000,000이 출력될 것입니다.
return 0;
}
std::lock_guard
는 unlock()
을 명시적으로 호출할 필요가 없어 코드가 간결하고, 예외 안전성을 보장합니다.
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx_unique;
void process_data(int id) {
std::unique_lock<std::mutex> lock(mtx_unique); // 생성 시 잠금
std::cout << "Thread " << id << " acquired lock.\n";
// 필요한 경우, 잠금을 일시적으로 해제하고 다른 작업을 수행할 수 있음
lock.unlock(); // 수동으로 잠금 해제
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 다른 스레드에게 기회
lock.lock(); // 다시 잠금
std::cout << "Thread " << id << " re-acquired lock.\n";
// 스코프를 벗어나면 lock 객체 소멸, 자동으로 mtx_unique 잠금 해제
}
int main() {
std::thread t1(process_data, 1);
std::thread t2(process_data, 2);
t1.join();
t2.join();
return 0;
}
std::unique_lock
은 std::condition_variable
과 함께 사용될 때 더욱 강력해집니다.
다양한 종류의 뮤텍스 및 락
C++11/14/17은 다양한 동기화 시나리오를 위한 추가 뮤텍스 및 락을 제공합니다.
-
std::timed_mutex
/std::recursive_mutex
std::timed_mutex
:try_lock_for()
(시간 제한),try_lock_until()
(특정 시점까지)을 통해 일정 시간 동안만 잠금을 시도할 수 있습니다.std::recursive_mutex
: 동일 스레드 내에서 여러 번 잠글 수 있는 뮤텍스입니다. 일반적으로는 권장되지 않으며, 재귀적 잠금이 필요하다면 디자인을 재고하는 것이 좋습니다.
-
std::shared_mutex
(C++17):- 읽기/쓰기 락(Read-Write Lock) 또는 공유/배타적 락(Shared/Exclusive Lock)이라고도 합니다.
- 여러 스레드가 동시에 읽기 접근을 허용하지만(공유 락), 쓰기 접근은 한 번에 하나의 스레드만 허용합니다(배타적 락).
- 읽기 작업이 쓰기 작업보다 훨씬 많은 시나리오에서 성능 향상에 도움이 됩니다.
-
std::shared_lock
(C++14):std::shared_mutex
와 함께 사용하여 공유(읽기) 락을 획득할 때 사용합니다.
#include <iostream>
#include <thread>
#include <vector>
#include <shared_mutex> // std::shared_mutex, std::shared_lock을 위해
#include <chrono>
int shared_data = 0;
std::shared_mutex shared_mtx; // 공유 뮤텍스
void reader(int id) {
for (int i = 0; i < 5; ++i) {
std::shared_lock<std::shared_mutex> lock(shared_mtx); // 읽기 락 획득 (여러 스레드 동시 가능)
std::cout << "Reader " << id << ": " << shared_data << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
}
void writer(int id) {
for (int i = 0; i < 2; ++i) {
std::unique_lock<std::shared_mutex> lock(shared_mtx); // 쓰기 락 획득 (단독 접근)
shared_data++;
std::cout << "Writer " << id << ": Increment to " << shared_data << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
int main() {
std::vector<std::thread> readers;
for (int i = 0; i < 3; ++i) {
readers.emplace_back(reader, i + 1);
}
std::vector<std::thread> writers;
for (int i = 0; i < 2; ++i) {
writers.emplace_back(writer, i + 1);
}
for (auto& t : readers) t.join();
for (auto& t : writers) t.join();
std::cout << "Final shared_data: " << shared_data << std::endl;
return 0;
}
이 예시에서는 여러 reader
스레드가 동시에 shared_data
를 읽을 수 있지만, writer
스레드는 단독으로 접근하여 shared_data
를 수정합니다.
데드락 (Deadlock)
데드락(Deadlock) 은 두 개 이상의 스레드가 서로가 점유하고 있는 자원을 얻기 위해 영원히 대기하는 상태를 말합니다.
가장 흔한 시나리오는 두 스레드가 서로 다른 뮤텍스를 잠그고, 상대방이 가진 뮤텍스를 기다리는 경우입니다.
데드락 발생 조건
- 상호 배제 (Mutual Exclusion): 자원은 한 번에 하나의 스레드만 사용할 수 있습니다.
- 점유 및 대기 (Hold and Wait): 자원을 점유한 상태에서 다른 자원을 기다립니다.
- 비선점 (No Preemption): 자원을 강제로 뺏을 수 없습니다.
- 순환 대기 (Circular Wait): 스레드들이 자원을 순환적으로 대기합니다 (A가 B를 기다리고, B가 A를 기다리는 등).
데드락 방지/해결 전략
- 잠금 순서 일치: 모든 스레드가 동일한 순서로 뮤텍스를 잠그도록 합니다. (가장 효과적)
std::lock()
사용: 여러 뮤텍스를 동시에 잠글 때std::lock(mtx1, mtx2, ...)
를 사용하면 데드락 없이 모든 뮤텍스를 잠그거나, 모두 잠그지 않습니다(all-or-nothing).std::unique_lock
+std::defer_lock
:unique_lock
을 생성하되 바로 잠그지 않고,std::lock()
과 함께 사용합니다.- 타임아웃 잠금:
try_lock_for()
,try_lock_until()
을 사용하여 일정 시간 동안만 잠금을 시도하고, 실패하면 다른 작업을 수행하거나 재시도합니다. - 계층적 잠금: 자원에 우선순위를 부여하여 낮은 우선순위 자원을 잠그기 전에 높은 우선순위 자원을 먼저 잠그도록 합니다.
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
std::mutex mtx1;
std::mutex mtx2;
void thread_func1() {
// std::lock(mtx1, mtx2)는 두 뮤텍스를 동시에 잠그려고 시도하며,
// 데드락 없이 안전하게 잠금을 획득하거나 실패합니다.
// lock_guard를 사용하여 자동으로 해제되도록 defer_lock을 사용합니다.
std::unique_lock<std::mutex> lk1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lk2(mtx2, std::defer_lock);
std::lock(lk1, lk2); // 두 뮤텍스를 동시에 잠금 시도 (데드락 방지)
std::cout << "Thread 1: Acquired mtx1 and mtx2.\n";
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 작업
std::cout << "Thread 1: Released mtx1 and mtx2.\n";
} // 스코프 벗어나면 lk1, lk2 소멸하며 자동 해제
void thread_func2() {
std::unique_lock<std::mutex> lk2(mtx2, std::defer_lock);
std::unique_lock<std::mutex> lk1(mtx1, std::defer_lock);
std::lock(lk2, lk1); // 두 뮤텍스를 동시에 잠금 시도
std::cout << "Thread 2: Acquired mtx2 and mtx1.\n";
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 작업
std::cout << "Thread 2: Released mtx2 and mtx1.\n";
} // 스코프 벗어나면 lk2, lk1 소멸하며 자동 해제
int main() {
std::cout << "Starting threads...\n";
std::thread t1(thread_func1);
std::thread t2(thread_func2);
t1.join();
t2.join();
std::cout << "Threads finished.\n";
return 0;
}
만약 thread_func1
과 thread_func2
에서 std::lock()
대신 mtx1.lock(); mtx2.lock();
과 같이 순서대로 잠그려 했다면, 데드락이 발생할 가능성이 매우 높습니다.
std::lock()
은 이러한 위험을 줄여줍니다.