java.util.concurrent 안에는 뭐가 들었을까
이전 블로그 포스팅에서 동시성 제어의 원초적 접근법으로 synchronized 가 유효한지 살펴보았습니다. 이 후, java.util.concurrent 패키지에서 synchronized 의 단점을 보완할 수 있는 방법을 찾게되었습니다.
synchronized 에 치명적 단점은 두 가지가 있습니다. synchronized 키워드로 이루어진 영역에 스레드가 접근하려면 모니터 락을 획득해야합니다. 스레드는 모니터 락을 획득할 때까지 무한 대기합니다. 이때 해당 스레드의 상태는 BLOCKED 입니다. BLOCKED 상태는 interrupted() 로도 다시 RUNNABLE 상태로 전이시킬 수 없습니다. 락을 얻기 전까지는 계속 BLOCKED 상태로 머무릅니다. 그래서 '무한' 대기 상태에 빠지게됩니다.
심지어 lock 획득의 순서는 보장되지않습니다. 무한 대기에 한 몫할 것 같습니다. 순서표없이 lock 을 부여받기를 오매불망기다리는 것이 synchronized 의 한계 입니다. 무한대기와 공정성, 두 가지 단점을 LockSupport 로 커버할 수 있을 것 같았습니다.
첫번째 실험. LockSupport
LockSupport 에는 park(), unpark(), parkNanos() 기능이 있습니다.
park() 는 스레드를 멈추는 기능입니다. WAITING 상태로 변경됩니다. unpark() 는 반대로 스레드를 다시 실행하는 기능입니다. RUNNABLE 상태로 전환됩니다. 각각 sleep() 이나 interrupt() 과 비슷한 역할을 수행하지만 의미있는 차이점이 있습니다.
sleep() 은 시간 기반 대기가 목적입니다. 스레드 간의 락/자원 관리는 별도로 처리해야합니다. 반면에, park() 은 이벤트 기반 대기가 목적입니다. 스레드가 특정 이벤트나 자원 조건이 만족될 때까지 대기합니다. 락과 연관지어 생각해보면, 특정 스레드가 락을 해제하는 경우 동작하게 할 수 있겠네요. 그러면서도 park()는 락과는 독립적으로 동작합니다. WAITING 상태일지라도, 다른 스레드가 락을 획득하도록 설계할 수 있습니다.
직접 사용해보았습니다.
세 개의 스레드가 각각 CustomLock 을 사용하여 자원 접근을 시도합니다. 각 스레드는 타임아웃 값을 설정하여, 주어진 시간 내에 락을 획득하려고 합니다.
각 스레드는 customLock.lock(timeoutMillis) 를 호출하여 락킹을 시도합니다. 락 획득에 성공한다면 임계 영역에 진입하여 작업(2초 대기)을 수행한 후 락을 해제합니다. 락 획득에 실패했을 때에는, 설정된 타임아웃 내에 다시 락을 얻지 못하면 메시지를 출력하고 작업을 종료합니다.
윤지, 우사인볼트, 선재 모두 락 획득을 시도하면서 출발합니다. 윤지 먼저 락 선점에 성공합니다. 우사인볼트 는 짧은 타임아웃을 가진 탓에 타임아웃 초과되어 락 획득에 실패합니다. 작업을 수행할 수 없게 되었네요. 반면에, 긴 타임아웃을 가진 선재는 윤지가 락을 반환할 때까지 기다릴 수 있는 여유가 됩니다. 결국 윤지와 선재만 락획득에 성공하며 작업을 수행하게 되었습니다.
이 과정에 LockSupport 의 parkNanos(), unpark()를 사용하게 되었는데요. CustomLock 클래스를 유심히 들어다보겠습니다.
요약하면, 락 구현 클래스 입니다. 여러 스레드가 락을 경쟁적으로 사용하려할 때, 타임아웃 기반으로 락 획득과 대기 스레드 관리, 락 해제를 수행합니다. parkNanos() 로 타임아웃 기반 락 획득을 구현할 수 있었습니다. 주어진 시간 안에 락 획득을 하지 못하면 작업이 수행되지않기 때문에 무한 대기하지 않을 수 있습니다. unpark()으로는 락 해제시 대기중인 다음 스레드를 깨웁니다. 윤지가 락을 반납할 때, 대기 중인 우사인볼트를 깨워 실행을 재개하게 되는 그림입니다. 정해진 타임아웃을 지나가서 락 획득에는 실패하지만요.
LockSupport + 자료구조를 이용하여 무한대기와 공정성 이슈를 해결해보았습니다. 근데...사실 이렇게 까지 구현할 거면 Lock 라이브러리를 사용하거나 아싸리 그냥 redission 을 사용하는 게 효율적일 것 같다는 생각이 들었습니다.
자바 진영도 같은 생각을 했는지, LockSupport 의 고급 버전을 탄생시켰습니다. ReentrantLock 입니다. 저는 굳이 굳이 Lock 을 구현했지만, ReentrantLock은 더 다양한 인터페이스가 있습니다.
두 번째 실험. ReentrantLock
race condition 이 발생할 수 있는 계좌 출금 로직입니다. ReentrantLock 을 이용해 동시성 이슈를 해결해보았습니다.
thread2 보다 나중에 접근한 thread1 이 거래 검증에 막혀 계좌 출금에 실패하였습니다. synchronized 로 구현했을 때와 같이 잘 동작하는 것을 확인할 수 있었습니다. ReentrantLock 의 내부 로직을 좀 더 살펴보았습니다.
생성자로 boolean 값을 넘기고 있습니다. synchronized 의 단점 중 하나가 공정성이었습니다. 특정 스레드가 계속해서 락을 획득하지 못해 실행되지않을 수 있습니다. 락 선점에 대기중엔 스레드가 이후에 대기한 스레드보다 늦게 락을 부여받을 수 있다는 말인데요. 이런 기아현상을 컨트롤 할 수 있는게 저 boolean 값이었습니다. true 인 경우 FairSync로 생성되고, false 인 경우 NonFairSync 로 생성됩니다.
차례차례 질서있게 운용하는 것이 중요한 서비스라면 FairSync 로 락을 사용해야되겠네요. NonFairSync 과 비교해보았습니다.
어느 쪽에 fair한걸까요? 두 로직 중 하나는 FairSync, 남은 하나는 NonFairSync 입니다. 비슷한 것 처럼 보이지만, 아주 큰 차이점이 있습니다.
FairSync 의 263 번 라인 hasQueuedThreads() 메소드 입니다. 메소드 명에서 냄새가 났습니다. Queue..? 여기서 순서를 컨트롤하겠구나. 그럼 NonFairSync 는 해당 메소드가 없는 것이 자연스럽게 이해갔습니다. 공정하지 않아도되면 queue 가 불필요해지니까요. hasQueuedThreads() 를 살펴보겠습니다.
현재 락 또는 동기화를 기다리는 스레드가 있는지 확인하는 메소드입니다. 대기 큐를 순회하며, status값이 양수(대기 상태)인 노드가 존재하는지 확인하네요. 그런데 해당 메소드를 갖고있는 이 클래스.. 제가 어디서 봤습니다. 기억을 더듬어 보기 위해 AbstractQueuedSynchronizer 를 구체적으로 살펴보았습니다.
뭔지 잘 모르겠지만, 동기화 프레임워크를 구현하는 기본 토대를 지칭하는 클래스로 보입니다. 구현체들을 확인하는 순간 무릎을 탁! 쳤습니다.
CountDownLatch 가 해당 클래스를 구현하고 있었습니다. 자주는 아니지만 저도 CountDownLatch 를 사용하는데요. 여러 스레드가 동시 실행되어 race condition 인 상황을 만들고 락킹이 제대로 동작하는지 확인해보고 싶을 때, 사용해본 적이 있습니다. 동시 실행할 스레드 수를 지정해두고 startLatch.await(); 를 통해 해당 스레드를 모두 대기상태로 만들어두었다가, startLatch.countDown(); 로 카운트를 0으로 설정하여 모든 스레드 실행시키는 방식이었습니다. 그 과정에서 락을 획득하거나 대기하거나 깨우거나 뭐 이런 일련의 일들이 모두 AbstractQueuedSynchronizer 의 구현이라는 것을 확인할 수 있었습니다. 이 맛에 디버깅하지
다른 구현체들에 대한 대략적인 구현은 저보다 똑똑한 GPT님께 물어보았습니다.
위 구현 클래스들의 공통 키워드를 뽑자면 동시입니다. java.util.concurrent 라는 패키지명이 다시 한번 이해가 갑니다. 해당 패키지의 모든 면을 낱낱히 파헤칠 수는 없지만, 조금 가까워진 것 같습니다. 그 중에서도 자바 동기화의 핵심 AbstractQueuedSynchronizer 를 알게된 것이 가장 기쁩니다. 알게된 내용을 요약하면 대략 이렇습니다. "java.util.concurrent는 고성능 멀티스레드 환경에서 동시성 유틸리티 클래스들을 제공한다. 이 클래스들의 최상위 클래스가 AbstractQueuedSynchronizer 다. 결국 AbstractQueuedSynchronizer 는 java.util.concurrent 에서 동기화 클래스의 핵심 기반 클래스다."
이제 어디가서 아는 체 할 수 있을 것 같습니다 ㅎ