Mutex & Semaphore

ym56·2025년 11월 3일

OS

목록 보기
8/14
post-thumbnail

1. Definition

Mutex

  • Mutual Exclusion(상호 배제)의 약자. 공유 자원에 오직 하나의 스레드만 접근하도록 허용하는 잠금 장치

  • 화장실 열쇠

    • 화장실(자원)은 딱 하나.

    • 사람(스레드)은 들어갈 때 열쇠(Lock)를 쥐고 들어가 문을 잠금.

    • 볼일을 다 보면 열쇠를 쥔 사람(Ownership)만이 나와서 열쇠를 반납(Unlock)함.

    • 다른 사람은 밖에서 줄을 서서 기다려야 함(Queue & Sleep).

      핵심: 열쇠를 쥔 사람만이 문을 열 수 있다. (소유권 있음).

  • 이걸 모르면..
    • Race Condition(경쟁 상태; 두 개 이상의 프로세스가 공유 자원을 병행적으로 읽거나 쓸 때, 타이밍에 따라 결과가 달라지는 상태) 발생으로 데이터가 파괴된다..

Semaphore

  • 공유 자원에 접근할 수 있는 스레드의 개수를 제한하거나, 스레드 간의 순서를 조율하는 신호 체계

  • 식당의 빈 테이블 / 릴레이 바통

    • 식당에 자리가 3개(Value = 3)가 있다.
    • 손님이 들어올 때마다 숫자가 줄어든다(Wait). 숫자가 0이면 밖에서 대기한다.
    • 손님이 나갈 때 숫자를 늘려준다(Signal).
    • 손님이 직접 숫자를 늘리지 않고, 알바생(다른 스레드)이 숫자를 올려줄 수도 있다 (소유권 없음).
  • 이걸 모르면.. 순서가 꼬여서 데이터를 읽기도 전에 삭제하는 등의 논리적 오류가 발생한다.


2. Code

import java.util.concurrent.locks.ReentrantLock; // Mutex의 Java 구현체
import java.util.concurrent.Semaphore;  // Semaphore 구현체

public class SyncDemo {
    // Mutex (Java에서는 ReentrantLock 사용)
    private final ReentrantLock mutex = new ReentrantLock();

    // Semaphore (허용량 3명)
    private final Semaphore semaphore = new Semaphore(3);

    public void accessResourceWithMutex() {
        // -- Mutex 구간 -- 
        mutex.lock();   // 문 잠그기 (Lock 획득 시도)
        try {
            // Critical Section (임계 구역)
            System.out.println("나만 사용 중!");
        } finally {
            mutex.unlock(); // 문 열기 (반드시 finally 에서!)
        }
    }

    public void accessResourceWithSemaphore() {
        try {
            semaphore.acquire();   // 번호표 뽑기(Value 감소)
            // Critical Section
            System.out.println("입장!");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            semaphore.release();    // 번호표 반남/ 신호 보내기 (Value 증가)
        }
    }
 }
  • ReentrantLock (재진입 락; 내가 잠근 방문은 내가 다시 열고 들어갈 수 있는 기능이 있는 자물쇠): Java에서 Mutex의 기능을 완벽하게 구현한 클래스. 소유권(Ownership) 개념이 있어, 락을 건 스레드만 풀 수 있다.

  • Semaphore (신호기): acquire()wait(감소), release()signal(증가)에 해당된다. 소유권이 없으므로 A 스레드가 acquire하고 B 스레드가 release해도 된다.

  • try-finally: 중간에 에러가 터져도 반드시 unlock이나 release를 해서, 뒷사람이 영원히 기다리는 (Deadlock) 사태를 막아야 한다.


3. 리눅스 환경

운영체제 레벨에서 Mutex와 Spinlock의 차이?

# 스레드별 상태 확인 (H 옵션으로 스레드까지 봄)
top -H -p [JAVA_PID]

상황 1: Mutex 대기 중(Sleep)

PID USER      PR  NI    VIRT    RES    SHR S  %CPU  TIME+ COMMAND
 1234 backend   20   0   20.5g   1.2g  24000 S   0.0   0:05 java-worker-1
  • S (Status): S(Sleeping). Mutex를 못 얻어서 큐에 들어가 잠자고 있는 상태. CPU를 쓰지 않는다.

상황 2: Spinlock 대지 중 (Busy Waiting)

PID USER      PR  NI    VIRT    RES    SHR S  %CPU  TIME+ COMMAND
 1235 backend   20   0   20.5g   1.2g  24000 R  99.9   2:15 java-worker-2
  • S (Status): R(Running). 자원을 얻지 못했지만, while(check)문을 계속 돌며 CPU를 100% 쓰고 있는 상태.

4. 작동 원리

  1. User Land (Java): mutex.lock() 호출.

  2. Bytecode: JVM이 실행하면서 내부적으로 Unsafe.compareAndSwap 같은 메서드 호출.

  3. JVM:

    • 먼저 Spinlock을 살짝 시도해본다. (어..? 금방 끝날 것 같은데.. 잠들지 말고 좀 기다려볼까?) --> 이를 Adaptive Spinning이라고 한다.

    • 그래도 락을 못 얻으면 OS에게 "나 재워줘"라고 요청.

  1. OS Kernel (Linux):
    • futex() (Fast Userspace Mutex; 일단 유저 레벨에서 해결해보고, 안 되면 커널을 부르는 하이브리드 락 (리눅스에서 제공하는 가볍고 빠른 잠금 메커니즘)): 이 시스템 콜이 호출되어 스레드를 대기 큐(Wait Queue)에 넣고 재운다. (Context Switch).
  2. Hardware (CPU):
    • CAS(Compare-And-Swap): CPU 레지스터 수준에서 LOCK CMPXCHG 명령어를 통해 원자적으로 값을 변경한다.

5. Semaphore로 실행 순서 동기화

상황: 결제 시스템

  1. Task A: 결제 로그를 DB에 저장한다. (반드시 먼저 실행)

  2. Task B: 사용자에게 "결제 완료" 푸시 알림을 보낸다. (로그 저장 후 실행되어야 함) 스레드가 달라도 B는 A가 끝날 때까지 기다려야 한다.

import java.beans.IntrospectionException;
import java.util.concurrent.Semaphore;  // Semaphore 구현체

import javax.swing.event.SwingPropertyChangeSupport;

public class PatmentProcessor {
    // 0으로 시작하는 것이 핵심
    private final Semaphore orderingSem = new Semaphore(0);

    public void saveLog(String userId) {
        System.out.println("[Task A] DB 저장 중...");
        // DB 저장 로직 수행
        System.out.println("[Task A] 저장 완료");

        // Signal 보내기 (Value: 0 -> 1)
        orderingSem.release();
        System.out.println("[Task A] 신호 보냄: 이제 알림 보내도 됨");
    }

    public void sendPush(String userId) {
        try {
            System.out.println("[Task B] 알림 준비... (A가 끝날 때까지 대기)");

            // Wait 하기 (Value가 1이 될 때까지 멈춤)
            orderingSem.acquire();

            System.out.println("[Task B] 신호 받음. 알림 발송 시작");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
  1. new Semaphore(0): 초기값이 0이다. 즉, 누군가 release(신호)를 해주지 않으면 acquire(대기) 하는 쪽은 여우언히 통과할 수 없다.

  2. Task B가 먼저 실행되어도 orderingSem.acquire()를 만나는 순간, 값이 0이므로 Sleep 상태로 들어간다.

  3. Task A가 작업을 마치고 orderingSem.release()를 호출하면 값이 1이 된다.

  4. 이때 OS는 잠자던 Task B를 깨운다. B는 이제야 임계 구역을 통과하여 알림을 보낸다.

  5. 결론: Mutex는 소유권 때문에 A가 잠그고 A가 열어야 하므로, 이렇게 "A가 하고 B가 한다"는 순서 제어(Ordering)에는 사용할 수 없다. 이 역할은 Semaphore가 제격이다.


6. 장애 대응

Priority Inversion (우선순위 역전)

상황:

  • High (P1): 긴급 재난 문자 발송 (우선순위 높음)
  • Low (P2): 로그 파일 압축 (우선순위 낮음)
  • Medium (P3): 유튜브 동영상 재생 (우선순위 중간)
  1. Low (P2)가 Mutex를 잡고 로그를 쓰고 있다.

  2. High (P1)이 나타나서 Mutex를 달라고 요청하지만, P2가 잡고 있어서 대기한다.

  3. 이때 갑자기 Medium (P3)이 나타난다. P3는 P2보다 우선순위가 높으므로, OS 스케줄러는 P2에게서 CPU를 뺏어 P3에게 준다. (Preemption)

  4. 문제 발생: P2가 CPU를 뺏겼으니 Mutex를 해제하러 못 간다. 결국 P2를 기다리는 High (P1)은 엉뚱하게 Miidum (P3)이 끝날 때까지 무한정 기다리게 된다.. (우선순위 역전)

Solution: Priority Inheritace (우선순위 상속)

  • 해결책: OS는 Mutex를 쥐고 있는 Low(P2)의 우선순위를 일시적으로 High(P1)만큼 격상시켜 준다.

  • 결과: P2는 파워를 얻어 P3에게 CPU를 뺏기지 않고 빠르게 작업을 마친 뒤 Mutex를 반납한다. 그 후 P1이 실행된다.

  • Java/Linux: Java의 ReentrantLock이나 리눅스 커널의 rt_mutex는 이 프로토콜을 지원하여 역전 현상을 방지한다. 단, Semaphore는 소유권이 없어서 누가 락을 쥐고 있는지 OS가 모르기 때문에 이 기능을 자동 적용하기 어렵다.


7. 면접 대비 질문

Q1: Mutex와 Semaphore의 차이는?

  • Answer: 가장 큰 차이는 소유권 (Ownership)이다. Mutex는 락을 건 스레드만이 락을 해제할 수 있어 상호 배제 (Mutual Exclusion)에 적합하다. 반면, Semaphore는 소유권이 없어 다른 스레드가 신호(Signal)를 보낼 수 있으며, 이를 통해 스레드 간의 실행 순서 동기화 (Ordering)나 자원 수 제어에 사용된.

Q2: Spinlock은 언제 사용해야 하며, Mutex보다 항상 비효율적인가?

  • Answer: 항상 비효율적이지 않다. 멀티코어 환경에서 임계 구역(Critical Section)의 작업이 매우 짧다면, Context Switching 비용보다 잠깐 기다리는(Spinning) 비용이 더 저렴할 수 있다. 반면 싱글 코어이거나 작업이 길다면 CPU만 낭비하므로 Mutex(Sleepig)가 유리하다. 현대의 JVM이나 OS는 이 둘을 혼합한 Adaptive Spinning을 사용한다.

Q3: Priority Inheritance가 무엇이며, 왜 Semaphore에서는 이것이 불가능한가?

  • Answer: 우선순위 역전 문제를 해결하기 위해, 자원을 점유한 낮은 우선순위 스레드의 우선순위를 대기 중인 높은 우선순위 스레드만큼 일시적으로 높이는 기법이다. 이 기법이 작동하려면 OS가 누가 자원을 쥐고 있는가를 알아야 하는데, Semaphore는 소유권 개념이 없어서 OS가 어떤 스레드의 우선순위를 높여야 할지 특정할 수 없기 때문에 지원하지 않는다.

Summary

  • Mutex: "내 방 열쇠", 소유권 O, 상호 배제(동시 접근 방시)용, Priority Inheritance 가능.

  • Semaphore: "식당 대기표", 소유권 X, 순서 제어(A 끝나면 B시작) 및 용량 제어용, Prority Inheritance 불가능.

  • Spinlock: "문 두드리기", 문맥 교환 비용보다 기다리는 게 쌀 때(짧은 시간, 멀티코어) 유용.

**상호 배제 (Mutual Exclusion)만 필요하면 Mutex를, 실행 순서(Oredeing)를 제어해야 한다면 Semaphore를 사용.. 그 기준은 '소유권'의 유무이다.

0개의 댓글