"synchronized"는 자바 1.0부터 제공되는 매우 편리한 기능이지만, 다음과 같은 한계가 있다.
synchronized 단점
- 무한 대기: "BLOCKED" 상태의 스레드는 락이 풀릴 때까지 무한 대기한다.
- 특정 시간까지만 대기하는 타임아웃 X
- 중간에 인터럽트 X
- 공정성: 락이 돌아왔을 때 "BLOCKED" 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜 기간 락을 획득하지 못할 수 있다.
결국 더 유연하고, 더 세밀한 제어가 가능한 방법들이 필요하게 되었다. 이런 문제를 해결하기 위해 자바 1.5부터 java.util.concurrent라는 동시성 문제 해결을 위한 라이브러리 패키지가 추가된다. 이 라이브러리에는 수많은 클래스가 있지만, 가장 기본이 되는 LockSupport에 대해서 먼저 알아보자. LockSupport를 사용하면 synchronized의 가장 큰 단점인 무한 대기 문제를 해결할 수 있다.
LockSupport 기능
LockSupport는 스레드를 "WAITING" 상태로 변경한다. "WAITING" 상태는 누가 깨워주기 전까지는 계속 대기한다. 그리고 CPU 실행 스케줄링에 들어가지 않는다.
LockSupport의 대표적인 기능은 가능과 같다.
- park() : 스레드를 "WAITING" 상태로 변경한다.
- 스레드를 대기 상태로 둔다. 참고로 park 의 뜻이 "주차하다", "두다"라는 뜻이다.
- parkNanos(nanos) : 스레드를 나노초 동안만 "TIMED_WAITING" 상태로 변경한다.
- 지정한 나노초가 지나면 "TIMED_WAITING" 상태에서 빠져나오고 "RUNNABLE" 상태로 변경된다.
- unpark(thread) : "WAITING" 상태의 대상 스레드를 "RUNNABLE" 상태로 변경한다.



- main 스레드가 Thread-1 을 start() 하면 Thread-1 은 "RUNNABLE" 상태가 된다.
- Thread-1 은 Thread.park() 를 호출한다. Thread-1 은 "RUNNABLE" -> "WAITING" 상태가 되면서 대기한다.
- main 스레드가 Thread-1 을 unpark() 로 깨운다. Thread-1 은 대기 상태에서 실행 가능 상태로 변한다.
- "WAITING" -> "RUNNABLE" 상태로 변한다.
이처럼 LockSupport는 특정 스레드를 "WAITING" 상태로, 또 "RUNNABLE" 상태로 변경할 수 있다. 그런데 대기 상태로 바꾸는 LockSupport.park()는 매개변수가 없는데, 실행 가능 상태로 바꾸는 LockSupport.unpark(thread1)는 왜 특정 스레드를 지정하는 매개변수가 있을까? 왜냐하면 실행 중인 스레드는 LockSupport.park()를 호출해서 스스로 대기 상태에 빠질 수 있지만, 대기 상태의 스레드는 자신의 코드를 실행할 수 없기 때문이다. 따라서 외부 스레드의 도움을 받아야 깨어날 수 있다.
인터럽트 사용


- "WAITING" 상태의 스레드에 인터럽트가 발생하면 "WAITING" 상태에서 "RUNNABLE" 상태로 변하면서 깨어난다.
- 실행 결과를 보면 스레드가 "RUNNABLE" 상태로 깨어난 것을 확인할 수 있다. 그리고 해당 스레드의 인터럽트의 상태도 true 인 것을 확인할 수 있다.
- 이처럼 "WAITING" 상태의 스레드는 인터럽트를 걸어서 중간에 깨울 수 있다.
ParkNanos
이번에는 스레드를 특정 시간 동안만 대기하는 parkNanos(nanos)를 호출해 보자.
- parkNanos(nanos) : 스레드를 나노초 동안만 "TIMED_WAITING" 상태로 변경한다. 지정한 나노초가 지나면 "TIMED_WAITING" 상태에서 빠져나와서 "RUNNABLE" 상태로 변경된다.
- 참고로 밀리초 동안만 대기하는 메서드는 없다. parkUntil(밀리초)라는 메서드가 있는데, 이 메서드는 특정 에포크(Epoch) 시간에 맞추어 깨어나는 메서드이다. 정확한 미래의 에포크 시점을 지정해야 한다.



- 여기서는 스레드를 깨우기 위한 unpark()를 사용하지 않는다.
- parkNanos(시간)를 사용하면 지정한 시간 이후에 스레드가 깨어난다.
- 1초 = 1000밀리 초(ms)
- 1밀리 초 = 1,000,000
- 나노초(ns) 2초 = 2,000,000,000 나노초(ns)
- Thread-1 은 parkNanos(2초)를 사용해서 2초간 "TIMED_WAITING" 상태에 빠진다.
- Thread-1 은 2초 이후에 시간 대기 상태("TIMED_WAITING")를 빠져나온다.
BLOCKED vs WAITING
"WAITING" 상태에 특정 시간까지만 대기하는 기능이 포함된 것이 "TIMED_WAITING"이다. 여기서는 둘을 묶어서 "WAITING" 상태라 표현하겠다.
인터럽트
- "BLOCKED"상태는 인터럽트가 걸려도 대기 상태를 빠져나오지 못한다. 여전히 "BLOCKED" 상태이다.
- "WAITING", "TIMED_WAITING" 상태는 인터럽트가 걸리면 대기 상태를 빠져나온다. 그래서 "RUNNABLE" 상태로 변한다.
용도
- "BLOCKED" 상태는 자바의 synchronized에서 락을 획득하기 위해 대기할 때 사용된다.
- "WAITING" , "TIMED_WAITING" 상태는 스레드가 특정 조건이나 시간 동안 대기할 때 발생하는 상태이다.
- "WAITING" 상태는 다양한 상황에서 사용된다. 예를 들어, Thread.join() , LockSupport.park() , Object.wait()와 같은 메서드 호출 시 "WAITING" 상태가 된다.
- "TIMED_WAITING" 상태는 Thread.sleep(ms), Object.wait(long timeout), Thread.join(long millis), LockSupport.parkNanos(ns) 등과 같은 시간제한이 있는 대기 메서드를 호출할 때 발생한다.
대기("WAITING") 상태와 시간 대기 상태("TIMED_WAITING")는 서로 짝이 있다.
- Thread.join() , Thread.join(long millis)
- Thread.park() , Thread.parkNanos(long millis)
- Object.wait() , Object.wait(long timeout)
"BLOCKED" , "WAITING" , "TIMED_WAITING" 상태 모두 스레드가 대기하며, 실행 스케줄링에 들어가지 않기 때문에, CPU 입장에서 보면 실행하지 않는 비슷한 상태이다.
- "BLOCKED" 상태는 synchronized에서만 사용하는 특별한 대기 상태라고 이해하면 된다.
- "WAITING", "TIMED_WAITING" 상태는 범용적으로 활용할 수 있는 대기 상태라고 이해하면 된다.
LockSupport 정리
LockSupport를 사용하면 스레드를 "WAITING", "TIMED_WAITING" 상태로 변경할 수 있고, 또 인터럽트를 받아서 스레드를 깨울 수도 있다. 이런 기능들을 잘 활용하면 synchronized의 단점인 무한 대기 문제를 해결할 수 있을 것 같다.
synchronized 단점
- 무한 대기: "BLOCKED" 상태의 스레드는 락이 풀릴 때까지 무한 대기한다.
- 특정 시간까지만 대기하는 타임아웃 X -> parkNanos()를 사용하면 특정 시간까지만 대기할 수 있음
- 중간에 인터럽트 X -> park() , parkNanos()는 인터럽트를 걸 수 있음
이처럼 LockSupport를 활용하면, 무한 대기하지 않는 락 기능을 만들 수 있다. 물론 그냥 되는 것은 아니고 LockSupport를 활용해서 안전한 임계 영역을 만드는 어떤 기능을 개발해야 한다. 락( lock )이라는 클래스를 만들고, 특정 스레드가 먼저 락을 얻으면 "RUNNABLE"로 실행하고, 락을 얻지 못하면 park()를 사용해서 대기 상태로 만드는 것이다. 그리고 스레드가 임계 영역의 실행을 마치고 나면 락을 반납하고, unpark()를 사용해서 대기 중인 다른 스레드를 깨우는 것이다. 물론 parkNanos()를 사용해서 너무 오래 대기하면 스레드가 스스로 중간에 깨어나게 할 수도 있다.
하지만 이런 기능을 직접 구현하기는 매우 어렵다. 예를 들어 스레드 10개를 동시에 실행했는데, 그중에 딱 1개의 스레드만 락을 가질 수 있도록 락 기능을 만들어야 한다. 그리고 나머지 9개의 스레드가 대기해야 하는데, 어떤 스레드가 대 기하고 있는지 알 수 있는 자료구조가 필요하다. 그래야 이후에 대기 중인 스레드를 찾아서 깨울 수 있다. 여기서 끝이 아니다. 대기 중인 스레드 중에 어떤 스레드를 깨울지에 대한 우선순위 결정도 필요하다.
한마디로 LockSupport는 너무 저수준이다. synchronized처럼 더 고수준의 기능이 필요하다. 하지만 걱정하지 말자! 자바는 Lock 인터페이스와 ReentrantLock이라는 구현체로 이런 기능들을 이미 다 구현해 두었다. ReentrantLock 은 LockSupport를 활용해서 synchronized의 단점을 극복하면서도 매우 편리하게 임계 영역을 다룰 수 있는 다양한 기능을 제공한다.
'멀티스레드와 동시성' 카테고리의 다른 글
| Ch08. 생산자 소비자 문제 - 소개 (0) | 2024.08.20 |
|---|---|
| Ch07. 고급 동기화(concurrent.Lock) - ReentrantLock (0) | 2024.08.15 |
| Ch06. 동기화 - Synchronized 정리 (0) | 2024.08.12 |
| Ch06. 동기화 - 문제와 풀이 (0) | 2024.08.12 |
| Ch06. 동기화 - 출금 (0) | 2024.08.11 |