Skip to content
난이도 면접 빈출문제은행 3

동기화 2 ​

동기화에서 본 뮤텍스(mutex)와 세마포어(semaphore)에 이어, 스레드(thread)가 어떻게 기다리고 언제 다시 시도하는지를 다룬다. 잠들지 않고 도는 잠금, 잠금과 조건을 한데 묶은 구조, 잠금 없이 값을 한 번에 바꾸는 원자 명령을 차례로 본다. 마지막으로 세마포어 세 개로 생산자-소비자 문제(producer-consumer problem)를 풀며 순서를 틀리면 무엇이 멈추는지 확인한다.

스핀락과 바쁜 대기 ​

스핀락(spinlock)은 잠금이 풀릴 때까지 잠들지 않고 반복문을 돌며 계속 확인하는 잠금이다. 이렇게 CPU를 놓지 않고 조건을 확인하며 기다리는 방식을 바쁜 대기(busy waiting)라고 한다.

바쁜 대기는 CPU 시간을 쓰지만 잠들고 깨어나는 문맥 교환(context switch) 비용이 없다. 그래서 여러 코어(core)가 있고 잠금을 아주 잠깐만 쥐는 경우, 예를 들어 커널(kernel) 내부의 짧은 구간에서는 스핀락이 더 빠르다. 반대로 코어가 하나뿐이면 기다리는 스레드가 반복문을 도는 동안 그 코어를 차지한다. 그래서 잠금을 쥔 스레드는 실행되지 못해 잠금을 풀 수도 없고, 기다리는 스레드는 자기 타임 슬라이스(time slice)를 통째로 낭비한다. 잠금을 오래 쥐는 구간에는 잠드는 뮤텍스가 낫다.

모니터와 조건 변수 ​

세마포어는 값을 1 줄이고 0이면 기다리는 wait와, 값을 1 늘려 기다리는 쪽을 깨우는 signal로 동작한다. 강력하지만 이 둘의 짝이나 순서를 하나만 틀려도 버그가 난다. 모니터(monitor)는 공유 데이터, 그 데이터를 다루는 함수, 잠금을 하나로 묶어 "모니터 안에는 한 번에 한 스레드만 있다"를 언어가 보장하게 한 구조다.

모니터 안에서 "버퍼가 빌 때까지"처럼 조건을 기다려야 할 때는 조건 변수(condition variable)를 쓴다. 조건 변수에서 기다리는 스레드는 잠금을 내려놓고 잠든다. 다른 스레드가 조건을 바꾼 뒤 신호를 주면 깨어나 잠금을 다시 잡는다. 잠금을 내려놓아야 다른 스레드가 들어와 조건을 바꿀 수 있다.

Java는 모든 객체에 모니터 잠금이 하나씩 붙어 있다. synchronized 메서드나 블록에 들어가면 그 잠금을 잡는다. wait()는 잠금을 내려놓고 잠들며, notify()와 notifyAll()은 기다리는 스레드를 깨운다. 잠금을 잡지 않은 상태에서 wait()를 부르면 모니터 잠금을 쥐지 않았다는 뜻의 IllegalMonitorStateException 예외가 난다. 또 wait()는 반드시 조건을 다시 확인하는 while 반복문 안에서 불러야 한다. 깨어났다고 해서 조건이 참이라는 보장은 없다. 신호를 받은 뒤 잠금을 다시 잡기 전에 다른 스레드가 먼저 들어와 조건을 바꿀 수 있고, 신호 없이 깨어나는 가짜 깨어남(spurious wakeup)도 있기 때문이다.

원자적 연산과 CAS ​

잠금 없이 경쟁 상태(race condition)를 막는 방법도 있다. 원자적 연산(atomic operation)은 중간에 다른 실행 흐름이 끼어들 수 없이 한 번에 끝나는 연산이다. CPU가 제공하는 대표적인 원자 명령이 CAS(Compare-And-Swap, 비교 후 교환)다. CAS는 "메모리 값이 내가 예상한 값과 같으면 새 값으로 바꾸고, 다르면 바꾸지 않는다"를 한 명령으로 수행한다.

count++를 CAS로 구현하면 현재 값을 읽고, "아직 그 값이면 +1한 값으로 바꿔라"를 요청한다. 그 사이 다른 스레드가 값을 바꿨다면 CAS가 실패하므로 다시 읽어서 재시도한다. Java의 AtomicInteger는 이런 CAS 연산을 compareAndSet 같은 메서드로 제공한다. 잠든 스레드가 없으니 경쟁이 적을 때 빠르지만, 경쟁이 심하면 재시도가 늘어 CPU를 낭비한다.

생산자-소비자 문제 ​

생산자 스레드는 데이터를 만들어 크기 N인 버퍼(buffer, 데이터를 잠시 담아 두는 공간)에 넣고, 소비자 스레드는 버퍼에서 꺼내 쓴다. 버퍼가 가득 차면 생산자가, 비면 소비자가 기다려야 한다. 세마포어 세 개로 푸는 것이 교과서 풀이다.

text
empty = N   // 빈 칸 수
full  = 0   // 찬 칸 수
mutex = 1   // 버퍼 자체를 보호하는 잠금

생산자: wait(empty); wait(mutex); 넣기; signal(mutex); signal(full)
소비자: wait(full);  wait(mutex); 꺼내기; signal(mutex); signal(empty)

empty와 full은 개수를 세는 카운팅 세마포어(counting semaphore)이고, mutex는 버퍼 조작을 한 번에 하나로 제한한다. 순서가 중요하다. 생산자가 wait(mutex)를 먼저 잡은 채 wait(empty)에서 잠들면, 소비자는 mutex를 못 잡아 칸을 비우지 못한다. 둘 다 영원히 기다리는 교착 상태가 된다.

면접 포인트 ​

  • "스핀락은 언제 쓰나요?" → 여러 코어에서 잠금을 아주 짧게 쥘 때. 문맥 교환보다 잠깐 도는 게 싸다. 코어가 하나면 손해.
  • "모니터가 세마포어보다 나은 점은?" → 잠금과 데이터·함수를 묶어 한 번에 한 스레드만 들어감을 언어가 보장한다. 잠금을 손으로 잡고 푸는 실수가 줄어든다.
  • "wait()를 왜 while 안에서 부르나요?" → 깨어난 시점에 조건이 다시 거짓일 수 있어서.
  • "CAS는 잠금과 무엇이 다른가요?" → 잠들지 않고 실패하면 재시도. 경쟁이 적으면 빠르고, 심하면 재시도로 CPU를 낭비한다.
  • 생산자-소비자에서 wait(mutex)를 wait(empty)보다 먼저 잡으면 교착 상태(deadlock)가 된다.

관련 문서