4-3. 락(Lock) & 뮤텍스(Mutex)

1. 락(Lock)과 뮤텍스(Mutex)의 소개

멀티 스레드 프로그래밍 환경에서 여러 스레드가 동시에 동일한 자원에 접근하려고 할 때, 데이터의 일관성을 유지하고 예기치 않은 오류를 방지하기 위해 사용되는 동기화 메커니즘이 있습니다. 락(Lock)과 뮤텍스(Mutex)는 이러한 동기화를 위한 핵심적인 도구입니다.

1) 동기화의 필요성: 경쟁 조건(Race Condition)

여러 스레드가 공유 자원에 동시에 접근하려고 할 때 발생하는 문제를 경쟁 조건(Race Condition)이라고 합니다. 예를 들어, 두 개의 스레드가 하나의 변수를 증가시키려고 하는 상황을 생각해 보겠습니다.

// 공유 자원
int count = 0;

// 스레드 1
void increment_count() {
    count++;
}

// 스레드 2
void increment_count() {
    count++;
}

이 상황에서, count를 증가시키는 연산은 여러 단계로 이루어집니다.

  1. count의 값을 읽어옴.
  2. 읽어온 값에 1을 더함.
  3. 계산된 값을 count에 다시 저장함.

만약 두 스레드가 이 연산을 동시에 수행한다면, count의 최종 값이 예상과 다를 수 있습니다.

예를 들어, 두 스레드가 모두 count의 값을 0으로 읽어온 후, 각 스레드가 1을 더하고, 다시 count에 저장한다면, 최종적으로 count는 2가 아닌 1이 될 수 있습니다. 이는 데이터의 일관성이 깨지는 문제를 보여줍니다.

2) 락과 뮤텍스의 역할

락과 뮤텍스는 이러한 경쟁 조건을 해결하기 위해 사용됩니다. 락은 공유 자원에 접근하기 전에 획득하고, 사용이 끝나면 해제하는 메커니즘을 제공합니다. 뮤텍스는 락의 일종으로, 한 번에 하나의 스레드만 공유 자원에 접근하도록 보장합니다.

락을 사용하여 위 예제를 수정하면 다음과 같습니다.

#include <mutex>

// 공유 자원
int count = 0;

// 뮤텍스 (Lock)
std::mutex mtx;

// 스레드 1
void increment_count() {
    mtx.lock(); // 락 획득
    count++;
    mtx.unlock(); // 락 해제
}

// 스레드 2
void increment_count() {
    mtx.lock(); // 락 획득
    count++;
    mtx.unlock(); // 락 해제
}

이제 mtx.lock()을 호출하여 락을 획득한 스레드만이 count에 접근할 수 있습니다. 다른 스레드는 락이 해제될 때까지 대기하게 됩니다. 이를 통해 데이터의 일관성을 보장하고 경쟁 조건을 방지할 수 있습니다.

2. 락(Lock)의 동작 방식

락은 공유 자원에 대한 배타적인 접근 권한을 제공하는 메커니즘입니다. 락의 동작 방식은 다음과 같습니다.

1) 락 획득 (Lock Acquisition)

스레드가 공유 자원에 접근하기 전에 락을 획득해야 합니다. 락 획득 과정은 다음과 같이 이루어집니다.

  1. 락 상태 확인: 락이 현재 사용 중인지 확인합니다. 만약 락이 다른 스레드에 의해 이미 사용 중이라면, 현재 스레드는 락이 해제될 때까지 대기합니다.
  2. 락 획득: 락이 사용 가능하면, 현재 스레드가 락을 획득하고 락의 상태를 '사용 중'으로 변경합니다.

2) 임계 구역(Critical Section)

락을 획득한 스레드는 임계 구역(Critical Section), 즉 공유 자원에 접근하는 코드 블록을 실행합니다. 이 구역 안에서는 다른 스레드에 의해 공유 자원이 변경되는 것을 걱정할 필요가 없습니다.

3) 락 해제 (Lock Release)

임계 구역의 실행이 완료되면, 스레드는 락을 해제해야 합니다. 락 해제 과정은 다음과 같이 이루어집니다.

  1. 락 상태 변경: 락의 상태를 '사용 가능'으로 변경합니다.
  2. 대기 중인 스레드에게 알림: 만약 락을 기다리고 있는 스레드가 있다면, 해당 스레드에게 락이 해제되었음을 알리고, 해당 스레드가 락을 획득할 수 있도록 합니다.

락의 동작 방식 설명 뒤

4) 데드락(Deadlock)의 위험

락을 사용할 때는 데드락(Deadlock)의 위험을 항상 고려해야 합니다. 데드락은 두 개 이상의 스레드가 서로 락을 획득하기 위해 무한정 대기하는 상황을 의미합니다. 예를 들어, 두 개의 스레드가 두 개의 락을 획득하는 순서가 서로 다를 경우 데드락이 발생할 수 있습니다. 데드락에 대한 자세한 내용은 다음 포스트에서 다룹니다.

3. 뮤텍스(Mutex)의 구현

뮤텍스는 락의 한 종류로, 한 번에 하나의 스레드만 접근할 수 있도록 보장하는 락입니다. 뮤텍스는 락의 기본적인 기능을 제공하며, 추가적인 기능(예: 재귀적인 락, 타임아웃 등)을 제공하기도 합니다.

1) 기본적인 뮤텍스 구현

가장 기본적인 뮤텍스는 다음과 같은 두 가지 상태를 가집니다.

  • unlocked: 뮤텍스를 사용할 수 있는 상태
  • locked: 뮤텍스가 이미 사용 중인 상태

뮤텍스는 일반적으로 운영체제(OS)의 지원을 받아 구현됩니다. OS는 뮤텍스의 상태를 관리하고, 락 획득 및 해제 요청을 처리합니다.

#include <atomic>
#include <thread>
#include <chrono>

class Mutex {
private:
    std::atomic<bool> isLocked; // 뮤텍스 상태 (true: locked, false: unlocked)

public:
    Mutex() : isLocked(false) {}

    void lock() {
        // CAS (Compare-And-Swap) 연산을 사용하여 락 획득 시도
        while (isLocked.exchange(true, std::memory_order_acquire)) {
            // 락을 획득하지 못하면 대기 (Spinning 또는 Sleep)
            std::this_thread::yield(); // CPU 양보 (Spinning) 또는 sleep() 사용 가능
            // std::this_thread::sleep_for(std::chrono::microseconds(1)); // sleep 사용 시
        }
    }

    void unlock() {
        isLocked.store(false, std::memory_order_release); // 락 해제
    }

    bool try_lock() {
      return !isLocked.exchange(true, std::memory_order_acq_rel); // 락 획득 시도 (non-blocking)
    }
};

// 사용 예시
int shared_data = 0;
Mutex mtx;

void thread_function() {
    mtx.lock();
    shared_data++;
    mtx.unlock();
}

위 코드는 간단한 뮤텍스 구현의 예시입니다. 실제 구현에서는 CAS (Compare-And-Swap) 연산을 사용하여 락 획득을 원자적으로 처리합니다.

2) 스핀락(Spin Lock) vs. 뮤텍스

뮤텍스는 스핀락(Spin Lock)과 비교되기도 합니다. 스핀락은 락을 획득하기 위해 CPU를 계속 사용하면서 대기하는 방식입니다. 뮤텍스는 락을 획득할 수 없을 경우, 해당 스레드를 대기 상태로 전환하여 CPU 자원을 절약합니다.

특징 스핀락 뮤텍스
대기 방식 락을 획득할 때까지 CPU를 사용하며 대기 (Spinning) 락을 획득할 수 없을 경우, 스레드를 대기 상태로 전환
CPU 사용량 락을 획득하는 동안 CPU를 계속 사용 락을 획득할 수 없을 경우, CPU 사용량 감소
문맥 교환 문맥 교환이 발생하지 않음 문맥 교환이 발생
사용 시나리오 락을 짧게 사용하고, 락 획득에 대한 대기 시간이 짧을 때 효율적 락을 오래 사용하거나, 락 획득에 대한 대기 시간이 길 때 효율적
구현 간단하게 구현 가능 (CAS 연산 사용) OS의 지원이 필요

스핀락은 락을 짧게 사용하는 경우에 유용합니다. 락을 획득하는 데 오랜 시간이 걸리지 않으므로, 스레드를 대기 상태로 전환하는 것보다 스핀락이 더 효율적일 수 있습니다. 하지만, 락을 오래 사용하거나, 락 획득에 대한 대기 시간이 길 경우에는 CPU 자원을 낭비할 수 있으므로, 뮤텍스를 사용하는 것이 더 효율적입니다.

4. 락과 뮤텍스의 응용 및 활용

락과 뮤텍스는 다양한 멀티 스레드 프로그래밍 시나리오에서 활용됩니다.

1) 공유 자원 접근 제어

가장 기본적인 사용 사례는 공유 자원에 대한 접근을 제어하는 것입니다. 여러 스레드가 동시에 공유 자원에 접근하여 데이터를 변경하는 것을 막기 위해 락 또는 뮤텍스를 사용하여 접근을 동기화합니다.

#include <mutex>
#include <vector>

std::mutex mtx;
std::vector<int> shared_data;

void add_to_vector(int value) {
    mtx.lock(); // 락 획득
    shared_data.push_back(value);
    mtx.unlock(); // 락 해제
}

2) 조건 변수(Condition Variable)와 함께 사용

뮤텍스는 조건 변수(Condition Variable)와 함께 사용되어, 특정 조건이 충족될 때까지 스레드를 대기시키고, 조건이 충족되면 스레드를 깨우는 방식으로 활용될 수 있습니다.

#include <mutex>
#include <condition_variable>
#include <queue>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;
bool data_ready = false;

void producer() {
    // ... 데이터 생성 ...
    mtx.lock();
    data_queue.push(10);
    data_ready = true;
    cv.notify_one(); // 조건 변수에 시그널 전송
    mtx.unlock();
}

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return data_ready; }); // 조건이 충족될 때까지 대기
    int value = data_queue.front();
    data_queue.pop();
    // ... 데이터 처리 ...
    lock.unlock();
}

3) 락 기반의 데이터 구조 구현

락은 스레드 안전한 데이터 구조(Thread-safe Data Structure)를 구현하는 데 사용됩니다. 예를 들어, 스레드 안전한 큐, 스택, 맵 등을 락을 사용하여 구현할 수 있습니다.

#include <mutex>
#include <queue>

template <typename T>
class ThreadSafeQueue {
private:
    std::mutex mtx;
    std::queue<T> data_queue;

public:
    void push(const T& value) {
        mtx.lock();
        data_queue.push(value);
        mtx.unlock();
    }

    T pop() {
        mtx.lock();
        T value = data_queue.front();
        data_queue.pop();
        mtx.unlock();
        return value;
    }
};

5. 락(Lock)과 뮤텍스(Mutex) 사용 시 주의사항

락과 뮤텍스를 사용할 때는 몇 가지 주의해야 할 사항이 있습니다.

1) 데드락(Deadlock) 회피

데드락은 락 사용 시 가장 주의해야 할 문제입니다. 데드락을 방지하기 위해서는 다음과 같은 방법들을 사용할 수 있습니다.

  • 락 획득 순서 일관성 유지: 모든 스레드가 락을 획득하는 순서를 동일하게 유지합니다.
  • 락 획득 제한: 한 번에 획득할 수 있는 락의 개수를 제한합니다.
  • 타임아웃(Timeout) 사용: 락을 획득하는 데 일정 시간 이상 소요될 경우, 락 획득을 포기하고 다른 작업을 수행합니다.

2) 락의 과도한 사용 방지

락은 성능 저하의 원인이 될 수 있습니다. 락을 과도하게 사용하면, 여러 스레드가 락을 획득하기 위해 대기하면서 병렬성이 저하될 수 있습니다.

  • 락 범위 최소화: 락의 범위를 가능한 한 최소화합니다. 즉, 락으로 보호해야 하는 코드 영역을 최소화합니다.
  • 락 프리(Lock-free) 프로그래밍 고려: 락을 사용하지 않고, 원자적 연산을 사용하여 동기화를 구현하는 락 프리 프로그래밍 기법을 고려합니다.

3) 락 해제 누락 방지

락을 획득한 후 반드시 락을 해제해야 합니다. 락 해제가 누락되면, 해당 락을 사용하는 다른 스레드가 무한정 대기하게 되어 데드락이 발생할 수 있습니다.

  • RAII(Resource Acquisition Is Initialization) 기법 사용: 락 획득과 해제를 객체의 생성자와 소멸자에 묶어, 예외 발생 시에도 락이 자동으로 해제되도록 합니다.

6. 락(Lock)과 세마포어(Semaphore)의 비교

락과 뮤텍스는 공유 자원에 대한 배타적인 접근을 제공하는 데 사용되지만, 세마포어(Semaphore)는 이보다 더 일반적인 동기화 메커니즘입니다.

1) 세마포어의 개념

세마포어는 정수 값을 가지는 동기화 객체입니다. 세마포어의 값은 공유 자원에 접근할 수 있는 스레드의 수를 나타냅니다.

  • wait() (또는 P()): 세마포어의 값을 1 감소시킵니다. 만약 세마포어의 값이 음수이면, 호출한 스레드는 대기합니다.
  • signal() (또는 V()): 세마포어의 값을 1 증가시킵니다. 대기 중인 스레드가 있다면, 해당 스레드를 깨웁니다.

2) 락과 세마포어의 차이점

특징 락(Lock) / 뮤텍스(Mutex) 세마포어(Semaphore)
사용 목적 배타적인 접근 제어, 한 번에 하나의 스레드만 접근 허용 자원 개수 제어, 여러 스레드가 동시에 접근 가능
없음 (상태: locked/unlocked) 정수 값 (초기 값 설정 가능)
접근 권한 락을 획득한 스레드만 락 해제 가능 임의의 스레드가 signal() 호출 가능
자원 관리 단일 자원 (일반적으로) 여러 자원 (자원 개수 제한)
예시 파일에 대한 쓰기 권한, 공유 변수에 대한 접근 제한된 개수의 자원 (예: 버퍼, 연결 풀)
사용 사례 임계 구역 보호, 상호 배제 생산자-소비자 문제, 자원 풀 관리, 제한된 자원 접근 제어
추가 기능 재귀 락, 타임아웃 락 등 카운팅 세마포어, 이진 세마포어
구현 복잡도 상대적으로 간단 (OS 지원) 락보다 복잡 (카운트 관리 필요)

3) 세마포어의 활용

세마포어는 다양한 동기화 문제 해결에 활용될 수 있습니다.

  • 생산자-소비자 문제: 생산자는 데이터를 생성하고, 소비자는 데이터를 소비하는 문제에서, 버퍼의 크기를 제한하는 데 사용될 수 있습니다.
  • 자원 풀 관리: 제한된 개수의 자원(예: 데이터베이스 연결)을 관리하는 데 사용될 수 있습니다.
  • 상호 배제: 세마포어의 값을 1로 설정하여, 뮤텍스와 유사하게 상호 배제를 구현할 수 있습니다.

락과 세마포어의 비교 설명 뒤

7. 결론

락과 뮤텍스는 멀티 스레드 프로그래밍에서 데이터의 일관성을 유지하고, 경쟁 조건을 방지하기 위한 핵심적인 도구입니다. 락과 뮤텍스를 올바르게 사용하면, 스레드 간의 상호 작용을 안전하게 관리하고, 복잡한 동기화 문제를 해결할 수 있습니다. 락과 뮤텍스를 사용할 때는 데드락, 락의 과도한 사용, 락 해제 누락과 같은 문제에 주의해야 합니다. 세마포어는 락보다 더 일반적인 동기화 메커니즘으로, 자원 개수를 제어하고, 다양한 동기화 문제를 해결하는 데 사용될 수 있습니다.

비슷한 글 추천

Comments (0)

No comments yet. Be the first to comment!