~/tech-blog/posts/spinlock-mutex-semaphore — zsh
지금 --명이 보고 있습니다오늘 --누적 --

스핀락, 뮤텍스, 세마포

이전에 트랜잭션 스케줄과 격리 수준에 대해 작성하였습니다. 그 글에서는 DB가 락으로 트랜잭션끼리의 충돌을 막는 방법을 다뤘는데, 같은 문제가 한 프로그램 안의 여러 스레드 사이에서도 생깁니다. 이번 글에서는 이때 사용하는 스핀락, 뮤텍스, 세마포에 대해 정리하도록 하겠습니다.

먼저: 임계 구역이란

여러 스레드가 동시에 건드리면 안 되는 코드 구간입니다. 화장실 한 칸이라고 생각하세요. 한 번에 한 명만 들어가야 하고, 두 명이 동시에 들어가면 사고(경쟁 상태)가 납니다.

Java
count = count + 1;   // 읽기 → 더하기 → 쓰기, 세 단계

이 한 줄도 두 스레드가 동시에 하면 둘 다 10을 읽고 둘 다 11을 써서 12가 돼야 할 값이 11이 됩니다. DB의 Lost Update와 완전히 같은 문제입니다.

count가 10일 때 두 스레드가 각각 +1을 하는 과정을 단계별로 펼치면 다음과 같습니다. 위는 락 없이 섞인 경우, 아래는 락으로 한 번에 한 스레드만 들어가게 한 경우입니다.

count가 10일 때 스레드 A와 B가 읽기와 쓰기를 섞어 실행해 11이 되는 흐름과, 락으로 차례대로 실행해 12가 되는 흐름눌러서 원본 크기로 보기

락이 없으면 B가 A의 쓰기 전 값 10을 읽는 바람에 A가 더한 1이 사라집니다. 락으로 차례를 지키면 B는 A가 쓴 11을 읽고 더하므로 12가 남습니다.

스핀락: 문 앞에서 계속 손잡이를 돌려보기

화장실이 잠겨 있으면 문 앞에 서서 계속 손잡이를 돌려봅니다. "열렸나? 아직. 열렸나? 아직…"

  • 장점: 열리는 순간 바로 들어갑니다. 반응이 가장 빠릅니다.
  • 단점: 기다리는 동안 CPU를 계속 씁니다. 아무 일도 안 하면서 바쁜 척하는 겁니다(busy waiting).

스레드 A가 락을 쥐고 있는 동안 스레드 B가 무엇을 하는지 시간 순서로 그려 보면 다음과 같습니다.

스레드 A가 락을 쥔 동안 스레드 B가 열렸는지 반복해서 확인하며 CPU를 계속 쓰다가, A가 해제하는 즉시 들어가는 흐름눌러서 원본 크기로 보기

B는 기다리는 내내 "열렸나?"를 되풀이하느라 CPU를 놓지 않고, 그 대가로 풀리는 순간 곧바로 들어갑니다.

언제 쓰나: 안에 있는 사람이 금방 나올 때(몇 마이크로초). 잠깐이면 자리 비웠다가 돌아오는 비용보다 그냥 서 있는 게 쌉니다. CPU 코어가 여러 개일 때만 의미가 있습니다. 코어가 하나면 내가 돌리는 동안 안에 있는 사람이 실행조차 못 하니까요.

뮤텍스: 대기실에 앉아 있다가 호출받기

화장실이 잠겨 있으면 대기실에 가서 앉습니다(sleep). 안에 있는 사람이 나오면서 "다음 분"이라고 부르면 그때 일어납니다.

  • 장점: 기다리는 동안 CPU를 안 씁니다. 다른 스레드가 그 CPU를 쓸 수 있습니다.
  • 단점: 앉았다 일어나는 데 시간이 듭니다(컨텍스트 스위칭). 안에 있는 사람이 1초 뒤에 나올 거면 손해입니다.

언제 쓰나: 안에 있는 사람이 오래 걸릴 수 있을 때. 일반적인 상황 대부분입니다.

뮤텍스에는 중요한 규칙이 하나 더 있습니다. 열쇠는 들어간 사람만 가지고 있고, 그 사람만 문을 열 수 있습니다(소유권). 밖에 있는 사람이 남의 문을 열어줄 수 없습니다.

Java
// Java
synchronized (lock) { ... }         // 뮤텍스. 블록 나갈 때 자동 해제
ReentrantLock lock = new ReentrantLock();
lock.lock();  try { ... } finally { lock.unlock(); }   // 잠근 스레드만 unlock 가능

실제 JVM의 synchronized는 둘을 섞어 씁니다. 처음엔 잠깐 스핀하다가(금방 풀릴 수도 있으니), 안 되면 대기실로 갑니다. 이걸 적응형 스핀(adaptive spinning)이라고 합니다.

앞의 스핀락 그림과 같은 상황을 뮤텍스로 그리면 다음과 같습니다. 아래쪽은 JVM synchronized가 스핀과 대기를 섞는 순서입니다.

스레드 B가 대기 큐에서 잠들어 다른 스레드에게 CPU를 넘기고, 락을 잠근 A가 unlock하며 깨우면 컨텍스트 스위칭을 거쳐 들어가는 흐름과 JVM synchronized의 적응형 스핀 순서눌러서 원본 크기로 보기

B가 잠든 동안 CPU는 다른 스레드가 쓰고, 열쇠를 가진 A가 unlock하며 깨워야 B가 들어갑니다. 깨어나는 순간의 전환이 뮤텍스가 치르는 비용입니다.

결국 스핀락과 뮤텍스 중 어느 쪽이 싼지는 기다리는 시간에 달려 있습니다. 대기가 짧을 때와 길 때를 나란히 놓으면 다음과 같습니다.

대기가 짧을 때는 스핀락이, 대기가 길 때는 뮤텍스가 유리함을 스핀 비용과 전환 비용 막대로 비교한 그림눌러서 원본 크기로 보기

스핀 비용은 기다린 만큼 늘어나고, 뮤텍스의 전환 비용은 대기 시간과 상관없이 거의 일정합니다.

세마포: 입장권 N장

이번엔 화장실이 3칸입니다. 입구에 입장권 3장을 걸어두고, 들어갈 때 한 장 가져가고(acquire) 나올 때 돌려놓습니다(release). 입장권이 0장이면 기다립니다.

  • 입장권이 1장이면 뮤텍스처럼 한 명만 들어갑니다. (이걸 바이너리 세마포라고 합니다)
  • 입장권이 N장이면 N명까지 동시에 들어갑니다. DB 커넥션 풀 10개, 동시 다운로드 3개 제한 같은 데 씁니다.
Java
Semaphore permits = new Semaphore(3);
permits.acquire();  try { download(); } finally { permits.release(); }

스레드 네 개가 입장권 3장을 두고 차례로 acquire하면 다음처럼 흘러갑니다.

입장권 3장인 세마포에 스레드 네 개가 acquire해 셋은 들어가고 넷째는 기다리다가, 첫째가 release하자 들어가는 흐름과 남은 입장권 수의 변화눌러서 원본 크기로 보기

남은 입장권은 3장에서 2, 1, 0장으로 줄고, 네 번째 스레드는 0장이라 기다립니다. 첫 번째 스레드가 한 장을 돌려놓자 기다리던 스레드가 그 장을 가져가 다시 0장이 됩니다.

그런데 세마포에는 뮤텍스와 결정적으로 다른 점이 있습니다. 입장권에 이름이 안 적혀 있습니다(소유권 없음). 그래서 내가 가져간 적이 없어도 입장권을 돌려놓을 수 있습니다.

이게 버그처럼 들리지만, 사실 세마포의 두 번째 용도가 여기서 나옵니다.

세마포의 진짜 쓸모: ‘순서 맞추기’ 신호

입장권을 0장으로 시작하면 어떻게 될까요? 아무도 못 들어갑니다. 그런데 다른 스레드가 입장권을 한 장 놓으면 그때 들어갈 수 있습니다.

Java
Semaphore ready = new Semaphore(0);

// 스레드 A: 데이터를 다 만들면 신호
makeData();
ready.release();        // "준비됐어" 하고 입장권 한 장 놓기

// 스레드 B: 신호 올 때까지 기다림
ready.acquire();        // 입장권이 생길 때까지 대기
useData();              // A가 끝난 뒤에만 실행됨

이건 "화장실 한 칸을 지키는" 게 아니라 "A 끝나면 B 시작"이라는 순서를 강제하는 겁니다. 뮤텍스로는 못 합니다. 뮤텍스는 잠근 사람만 풀 수 있으니, A가 잠그고 B가 푸는 구조가 애초에 안 되기 때문입니다.

위 코드를 시간 순서로 펼치고, 같은 일을 뮤텍스로 하려 할 때와 나란히 놓으면 다음과 같습니다.

입장권 0장으로 시작한 세마포에서 B가 acquire로 기다리다가 A가 makeData 후 release하자 깨어나 useData를 실행하는 흐름과, 뮤텍스에서는 A가 잠근 락을 B가 풀 수 없음을 대비한 그림눌러서 원본 크기로 보기

B는 acquire에서 멈춰 있다가 A가 release로 놓은 한 장을 받고서야 useData를 실행합니다. 뮤텍스였다면 B의 unlock은 거부됩니다.

뮤텍스 vs 세마포, 왜 용도가 갈리나

뮤텍스 세마포
들어갈 수 있는 수 1명 N명 (1이면 뮤텍스처럼)
열쇠 주인 잠근 스레드만 풀 수 있음 누구나 돌려놓을 수 있음
우선순위 상속 가능 불가
잘 맞는 용도 상호 배제 ("한 명만") 순서 조율 ("A 끝나면 B"), 동시 접근 수 제한

우선순위 상속은 이런 상황 때문입니다. 낮은 우선순위 스레드가 락을 쥐고 있고, 높은 우선순위 스레드가 그 락을 기다리는데, 그 사이 중간 우선순위 스레드가 CPU를 계속 차지하면 높은 애가 중간 애 때문에 못 돌아가는 역전이 생깁니다. 뮤텍스는 "지금 누가 락을 쥐고 있는지" 알기 때문에, 그 낮은 애의 우선순위를 잠시 올려줘서 빨리 끝내게 할 수 있습니다. 세마포는 주인이 누군지 모르니 이걸 못 합니다.

높음(H), 중간(M), 낮음(L) 세 스레드로 이 상황을 그려 보면 다음과 같습니다.

낮은 L이 락을 쥔 사이 중간 M이 CPU를 차지해 높은 H가 기다리기만 하는 우선순위 역전과, 뮤텍스가 L의 우선순위를 잠시 올려 락을 빨리 놓게 해 H가 들어가는 우선순위 상속눌러서 원본 크기로 보기

위쪽에서는 락과 상관없는 M이 CPU를 차지하는 탓에 H가 하염없이 기다립니다. 아래쪽에서는 뮤텍스가 락 주인인 L을 잠시 끌어올려 먼저 끝내게 하므로 H가 곧 들어갑니다.

그래서 결론이 "한 명만 들어가게 할 거면 뮤텍스, 순서나 개수를 조율할 거면 세마포"입니다. 세마포 1장으로 뮤텍스 흉내를 낼 수는 있지만, 소유권이 없어서 엉뚱한 스레드가 풀어버리는 실수를 막을 수 없기 때문에 권장하지 않습니다.

이 결론을 고르는 순서대로 그리면 다음과 같습니다.

한 번에 한 명만 들어가야 하는지, 대기가 아주 짧은지에 따라 스핀락·뮤텍스·세마포 중 하나를 고르는 흐름도눌러서 원본 크기로 보기

앞에서 본 것들과 연결하면

앞에서 본 것 정체
Redisson이 "스핀이 아니라 pub/sub으로 기다린다" 스핀락 대신 뮤텍스 방식(대기실에서 호출받기)을 분산 환경에서 구현한 것
Redisson 락은 잠근 스레드만 풀 수 있다 뮤텍스의 소유권 개념. 그래서 스레드 ID를 hash에 저장
DB의 행 X락 뮤텍스. 커밋한 트랜잭션만 풀 수 있고, 기다리는 쪽은 CPU 안 씀
DB 커넥션 풀 10개 세마포 N장. 10개 넘으면 대기
컨슈머 코드의 synchronized 뮤텍스. JVM 하나 안에서만 통함
REQUIRES_NEW로 커넥션 풀 고갈 세마포 입장권을 쥔 채 또 한 장 요구 → 데드락 구조

한 줄 정리

임계 구역은 "한 번에 한 명만 들어가야 하는 화장실"이고, 스핀락은 문 앞에서 손잡이 계속 돌리기(짧을 때), 뮤텍스는 대기실에서 호출받기(일반적, 잠근 사람만 풀 수 있음), 세마포는 입장권 N장(여러 명 허용, 누구나 돌려놓을 수 있어서 "순서 신호"로도 씀)입니다. "한 명만"은 뮤텍스, "순서나 개수 조율"은 세마포입니다.

댓글

GitHub 계정으로 로그인하시면 댓글과 질문을 남기실 수 있습니다. 남겨 주신 글은 이 저장소의 Discussions에 쌓입니다.

● main 231 posts UTF-8 LF © 2026 코징 RSS · GitHub