락을 얻을 때까지 잠들지 않고 계속 확인하며 도는 락(spin lock). "바쁜 대기"라고도 한다.
일반 락과의 차이
뮤텍스 (블로킹) lock 실패 → 커널에 "재워 줘" 요청 → 컨텍스트 스위칭 → 잠듦
- 해제되면 깨워짐 → 컨텍스트 스위칭 → 재개
스핀락
- lock 실패 → while (잠겨있음) { } — ← CPU를 태우며 계속 확인
- 해제되는 순간 즉시 진입
언제 유리한가 — 계산으로 판단한다
- 컨텍스트 스위칭 비용 — ≈ 1~5μs (왕복 2번이면 더)
- 락을 쥐는 시간 — ≈ ?
| 락 유지 시간 < 컨텍스트 스위칭 비용 | → | 스핀이 이득 |
|---|---|---|
| 락 유지 시간 > 컨텍스트 스위칭 비용 | → | 잠드는 게 이득 |
임계 구역이 극도로 짧을 때만 스핀락이 유리하다. 카운터 증가나 포인터 교체처럼 수십 나노초로 끝나는 작업이 그렇다.
반드시 멀티코어여야 한다
단일 코어에서 스핀락은 재앙이다.
코어 1개, 스레드 A가 락을 쥐고 있고 B가 스핀 중
→ B가 CPU를 점유하며 돈다
→ A는 CPU를 못 받아 락을 풀 수가 없다
→ B의 타임 슬라이스가 끝날 때까지 아무 진전이 없다
락을 쥔 스레드가 다른 코어에서 동시에 실행 중이어야 스핀이 의미가 있다.
어디에 쓰이나
주로 커널 내부다. 인터럽트 핸들러는 잠들 수 없으므로(스케줄러를 부를 수 없으므로) 스핀락 말고 선택지가 없다.
응용 프로그램에서는 직접 쓸 일이 드물지만, JVM이 내부적으로 쓴다 —
synchronized가 중량 락으로 승격되기 전에 적응적 스피닝을 시도한다.
"최근에 이 락은 금방 풀렸으니 조금 더 돌아 보자"고 판단하는 것이다.
CAS와 논블로킹
스핀의 사촌 격으로 CAS(Compare-And-Swap)가 있다.
// AtomicInteger.incrementAndGet() 내부
do {
old = get();
next = old + 1;
} while (!compareAndSet(old, next)); // 실패하면 다시 시도 = 스핀
CPU의 원자적 명령 하나로 "예상값과 같으면 바꾸기"를 수행한다. 락을 아예 안 쓰므로 데드락이 없고 경쟁이 적으면 매우 빠르다.
경쟁이 심하면 실패-재시도가 반복돼 오히려 나빠진다.
그래서 LongAdder는 여러 칸에 나눠 더한 뒤 합치는 방식으로 경쟁을 분산한다.
ABA 문제 — CAS의 함정
- 스레드 A가 값 X를 읽음
- 다른 스레드가 X → Y → X 로 되돌림
- A의 CAS는 "X 그대로네" 하고 성공 ← 하지만 그 사이 상태가 바뀌었다
값만 봐서는 변경 여부를 알 수 없다. 버전 번호를 함께 비교해 해결한다
(AtomicStampedReference). 낙관적 락의 version 컬럼과 같은 발상이다.