-
코루틴에서 java 패키지의 ReentrantLock 의 문제점개발 흉내 2024. 7. 6. 14:31
@Service class ProductService { companion object { val lock = ReentrantLock() // are you sure? } suspend fun task() { lock.lock() try { // ... do task // ... do task // ... do task } finally { lock.unlock() } } }
동료의 코드를 리뷰하던 중
ReentrantLock
을 사용하는 코드를 보았다.ReentrantLock
는 코루틴 작업들의 동시 접근을 제어하고 있었는데,ReentrantLock
의 동작이 코루틴과 잘 맞는 지 의문이 들었고 이에 대해 더 조사해보기로했다.ReentrantLock
는 다른 스레드의 접근을 막지만, 같은 스레드가 여러번 접근하는 것은 허용한다는 Lock 구현체이다. Lock 의 진입점을 여러 곳에 두고 접근한다는 것 자체가 Clean 하지 못하다는 느낌은 들지만 분명 쓰임새가 있는 Lock 이다.하지만 같은 스레드는 허용한다는 점에서 코루틴에서 사용한다면 문제가 될 수 있다.
코틀린의 코루틴은 보통 여러 Thread 를 생성하지않고, Dispatcher 라는 Thread Pool 위에서 동작한다. Dispatcher 는 코루틴을 적절한 스레드에 할당하여 효율적인 동시성이 가능하도록 도와준다. 구체적으로는 스레드가 하나의 작업이 끝내면 스레드가 종료되는 게 아니라 그대로 다음 작업을 시작한다. 즉, 하나의 스레드가 여러 작업을 수행하는 것이다.
그렇다면 코루틴 내에서
ReentrantLock
을 사용할 경우 어떤 일이 일어날까? 서로 다른 사용자 요청에 대해 코루틴이 동작했지만 Dispatcher 가 "우연히" 같은 스레드에게 코루틴을 전달하면ReentrantLock
는 동시에 접근을 허용해버린다.예제 코드를 보자.
val lock = ReentrantLock() suspend fun task(name: String) { lock.lock() try { (1..3).forEach { println("$name is running... $it") delay(1000) } } finally { lock.unlock() } } fun main() { runBlocking { val singleThreadDispatcher = newFixedThreadPoolContext(1, "a-single-thread") launch(singleThreadDispatcher) { task("firstTask") } launch(singleThreadDispatcher) { task("SecondTask") } } }
task 함수는 두 번의
launch
에 의해 동시에 실행되었지만, task 는ReentrantLock
에 의해 한번에 하나씩 순차적으로 실행될 것을 기대했을 것이다.하지만 결과는 다르다.
firstTask is running... 1 SecondTask is running... 1 firstTask is running... 2 SecondTask is running... 2 firstTask is running... 3 SecondTask is running... 3
두 코루틴 함수는 동시에 실행되었다.
ReentrantLock
가 같은 스레드라고 판단했기 때문이다. 이렇듯 코루틴에서ReentrantLock
과 같이 스레드에 민감하게 동작하는 코드들은 주의가 필요하다.대신에
Mutex
와 같이 kotlinx.coroutines 패키지에서 지원하는 lock 을 쓰면 올바르게 동작한다.val lock = Mutex()
firstTask is running... 1 firstTask is running... 2 firstTask is running... 3 SecondTask is running... 1 SecondTask is running... 2 SecondTask is running... 3
전체 코드는 아래에서 실행해볼 수 있다.