스핀락 vs 뮤텍스 vs 세마포어 (with Java)

haazz·2025년 9월 5일
post-thumbnail

멀티 프로세스/멀티 스레드 환경에서는 동시에 공유 자원에 접근할 경우 데이터의 일관성(consistency)이 깨질 수 있습니다. 이러한 구간을 임계영역(critical section)이라 하며, 여러 스레드가 동시에 진입하지 못하도록 제어해야 합니다.

이때 사용하는 동작이 동기화(synchronization)이며, 대표적으로 락(lock)을 활용하여 한 번에 하나의 스레드만 진입하도록 보장합니다.

본 글에서는 자바(Java 17 기준)에서 자주 사용되는 세 가지 락 기법, 스핀락(spinlock), 뮤텍스(mutex), 세마포어(semaphore)를 소개합니다.

스핀락 (spinlock)

스핀락은 임계영역에 동시에 접근했을 때 while 루프를 돌며 락이 풀릴 때까지 바쁘게 대기(busy-wait)하는 방식입니다. 그렇기에 while 루프를 돌리는 과정에서 CPU 낭비가 심할 수 있습니다.

아래 수도코드를 보시면 처음으로 임계영역에 도착한 스레드는 lock을 얻고 그다음으로 도착하는 스레드는 while 루프에서 대기하는 것을 확인할 수 있습니다.

class Executor {
		void run() {
				while (락이 반환 될 때까지 확인 하고 락 취득);
				... critical section;
				락 반환;
		}
}

여기서 주의해야 할 점은 락 상태를 나타내는 변수 자체도 공유자원이므로, CAS(Compare-And-Set) 같은 원자적 연산을 통해 값을 변경해 줄 필요가 있습니다.

Java 코드 구현

위에서 말했듯이 현재 lock이 된 상태를 표시하는 locked 변수도 공유자원이므로, AtomicBoolean을 활용하여 원자적 연산을 통해 일관성을 보장해 주었습니다.

public class SpinLock {
    private final AtomicBoolean locked = new AtomicBoolean(false);

    public void lock() {
        while (!locked.compareAndSet(false, true)) {
            Thread.onSpinWait();    // Java 9부터 추가된 JVM이 CPU에게 하드웨어적 최적화 힌트를 주는 메소드
        }
    }

    public void unlock() {
        locked.set(false);
    }
}

public class Executor {
		private final ExecutorService threadPool = Executors.newFixedThreadPool(3); // 스레드 풀 크기 = 3
		private final SpinLock spinLock = new SpinLock();
		
		public void run() throws InterruptedException {
	    for (int i = 0; i < CRITICAL_SECTION_EXECUTIONS; i++) {
	        threadPool.execute(() -> {
	            spinLock.lock();
	            try {
	                criticalSection.run();
	            } finally {
	                spinLock.unlock();
	            }
	        });
	    }
	 }
}


뮤텍스 (mutex)

뮤텍스는 한 번에 하나의 스레드만 임계영역에 진입할 수 있도록 하는 락입니다. 스핀락과 달리 락을 얻지 못한 스레드는 대기 큐에 들어가 블록 상태로 전환 되며, 락이 해제될 때 OS 스케줄러에 의해 다시 깨워집니다. 또한 이 과정에서 context switching이 발생합니다.

아래 수도코드를 보시면 락이 걸려있다면 현재 스레드를 큐에 담고 unlock 시 다음 스레드를 깨우는 방식으로 동작하는 것을 확인할 수 있습니다.

class Executor {
    void run() {
				lock();
				... critical section;
				unlock();
		}

    void lock() {
        if (락이 걸려있다면) {
		        ... 현재 스레드를 큐에 넣음;
        } else {
		        ... 락 취득;
        }
    }

    void unlock() {
        if (큐에 스레드가 존재하면) {
		        ... 다음 스레드를 깨움;
        } else {
		        ... 락 반환;
        }
    }
}

Java 코드 구현

Java에서는 ReentrantLock을 통해 내부적으로 뮤텍스를 지원합니다.

public class Executor {
		private final ExecutorService threadPool = Executors.newFixedThreadPool(3);
		private final Lock mutex = new ReentrantLock();  // Java 내부적으로 구현해 놓은 뮤텍스
		
		public void run() throws InterruptedException {
	    for (int i = 0; i < CRITICAL_SECTION_EXECUTIONS; i++) {
	        threadPool.execute(() -> {
	            mutex.lock();
	            try {
	                criticalSection.run();
	            } finally {
	                mutex.unlock();
	            }
	        });
	    }
	 }
}


스핀락 vs 뮤텍스

뮤텍스는 while 루프를 돌며 CPU 낭비를 하지 않기 때문에 대부분의 일반적인 상황에서 스핀락 보다 뮤텍스가 더 효율적입니다.

하지만 만약 멀티코어 환경에서 임계영역의 작업 실행 시간이 context switching 시간보다 작은 경우 뮤텍스 보다 스핀락이 더 효율적일 수 있습니다.



세마포어 (semaphore)

세마포어는 하나 이상의 프로세스/스레드가 임계영역에 접근할 수 있도록 허용하는 동기화 도구입니다. 기존의 스핀락과 뮤텍스와 같이 하나의 프로세스/스레드만 접근 가능하게 하는 방식은 이진 세마포어(binary semaphore)라고 하며, 그 외에는 카운팅 세마포어(Counting Semaphore)라고 합니다.

아래 수도코드를 보시면 락 취득과 반환이 아닌 permit 수를 변경하는 것을 확인할 수 있습니다. 또한 다수의 스레드가 접근할 수 있기 때문에 메소드 이름도 lock(), unlock()이 아닌 acquire(), release()인 것을 확인할 수 있습니다.

class Executor {
    void run() {
				acquire();  // permit 얻기  
				... critical section;
				release();
		}

    void acquire() {
        if (남은 permit이 없다면) {
		        ... 현재 스레드를 큐에 넣음;
        } else {
		        permit--;
        }
    }

    void release() {
        if (큐에 스레드가 존재하면) {
		        ... 다음 스레드를 깨움;
        } else {
		        permit++;
        }
    }
}

Java 코드 구현

Java에서는 뮤텍스와 마찬가지로 세마포어를 지원합니다.

public class Executor {
		private final ExecutorService threadPool = Executors.newFixedThreadPool(3);
		private static final Semaphore semaphore = new Semaphore(1);  // permits=1로 하여 스핀락, 뮤텍스와 같은 동작을 하는 이진 세마포어 생성
		
		public void run() throws InterruptedException {
	    for (int i = 0; i < CRITICAL_SECTION_EXECUTIONS; i++) {
	        threadPool.execute(() -> {
	            semaphore.acquire();
	            try {
	                criticalSection.run();
	            } finally {
	                semaphore.release();
	            }
	        });
	    }
	 }
}

뮤텍스 VS 이진 세마포어

언뜻 보기에는 뮤텍스와 이진 세마포어는 똑같이 동작하는 것으로 보이지만, 중요한 두가지 차이가 있습니다.

  1. 뮤텍스는 락을 획득한 프로세스/스레드만이 락을 해제할 수 있지만, 세마포어는 다른 프로세스/스레드도 release()를 호출할 수 있습니다.
  2. 뮤텍스는 우선순위 역전 문제를 완화하기 위해 priority inheritance 속성을 가지고 세마포어는 이 속성을 갖지 않습니다.

따라서 단순히 상호배제만 필요하다면 뮤텍스를, 자원 슬롯 제한이나 실행 순서 제어가 필요하다면 세마포어를 사용하는 것이 적절합니다.

최종 정리

  • 스핀락 (spinlock): 짧은 임계영역, 문맥 전환 비용이 큰 상황에 유리하지만 일반적으로는 CPU 낭비 큼
  • 뮤텍스 (mutex): 프로세스/스레드를 블록 시키므로 일반적으로는 CPU 효율적
  • 세마포어 (semaphore): 동시 허용량을 제어할 수 있는 동기화 도구, 뮤텍스보다 유연
profile
Developers who create benefit social values

0개의 댓글