시작하며
우리는 이전 글에서 ReentrantLock을 사용해 락을 걸고 해제해보면서, 여러 스레드가 동시에 접근해도 안전한 임계영역(Critical Area)을 만드는 방법에 대해 살펴봤다.
이번 글에서는 ReentrantLock을 사용해 코루틴에서 임계 영역을 만들 때의 문제점과 ReentrantLock을 사용해도 되는 경우와 안 되는 경우에 대해 알아본다.
코루틴에서 ReentrantLock을 사용해 임계 영역 만들 때의 문제
ReentrantLock은 코루틴에서 사용할 때 중요한 문제를 일으킬 수 있다. 한 번 어떤 문제를 일으키는지 다음 코드를 통해 확인해보자.
fun main() = runBlocking<Unit> {
val reentrantLock = ReentrantLock()
repeat(10) {
launch(Dispatchers.IO) {
println("Started >> ${Thread.currentThread().name}")
reentrantLock.lock()
delay(1000L)
reentrantLock.unlock()
}.invokeOnCompletion {
println("Completed >> ${Thread.currentThread().name}")
}
}
}
위 코드에서는 10개의 코루틴이 Dispatchers.IO를 사용해 실행된다. 각 코루틴은 내부에서 ReentrantLock을 사용해 락을 만들고 1초간 대기 후 해제하므로, 위 코드는 문제없이 실행될 수 있을 것처럼 보인다. 하지만 실제로 위 코드를 실행해 보면 다음과 같이 코루틴의 실행이 끝나지 않는 것을 볼 수 있다.
*멀티스레드 환경에서 실행되는 코루틴이기 때문에 결과는 매번 다르다.
왜 이런 일이 일어날까? 이 문제를 이해하기 위해서는 ReentrantLock이 동작하는 방식에 대해 알아야 한다. ReentrantLock 객체는 같은 스레드에서 lock을 여러 번 걸 수 있는 객체이며, 하나의 스레드에서 락을 걸 경우 같은 스레드에서 unlock을 호출할 때까지 다른 스레드의 접근을 차단한다. 이를 통해 임계 영역(Critical Area)을 만든다.
하지만, 코루틴은 하나의 스레드에서만 동작하지 않을 수 있다. 코루틴 내부에 delay 같은 '일시 중단 지점'이 있는 경우 자신의 스레드를 양보하고, 재개될 때는 CoroutineDispatcher에 의해 다른 스레드에서 재개될 수 있다. 이것이 바로 위의 문제가 일어나는 이유이다. 위의 코드에서는 10개의 코루틴이 생성됐지만, 2개의 코루틴만 완료되고 나머지 코루틴은 누군가 걸어놓은 락으로 인해 데드락에 걸려버려 완료되지 못한다.
위와 같이 글로만 설명을 들으면, 정확한 문제를 떠올리기 어려울 수 있다. 이런 동시성 문제는 예를 통해 알아보는 것이 가장 이해가 빠르다. 예를 들어 coroutine#1이 DefaultDispatcher-worker-1 스레드에서 시작돼 ReentrantLock 객체의 lock을 걸었다고 해보자. coroutine#1이 delay 함수로 인해 일시 중단되고, 재개될 때 DefaultDispatcher-worker-3 스레드에서 재개된다면 DefaultDispatcher-worker-1 스레드에서 unlock을 호출하는 다른 코루틴이 없는 한 DefaultDispatcher-worker-1 스레드에 걸린 락은 풀리지 않는다. 이것이 바로 위의 문제를 만든다.
코루틴 내부에서 ReentrantLock을 사용해도 문제 없는 경우
코루틴은 '일시 중단 지점' 에서만 스레드의 점유를 해제하고 재개될 때 스레드가 바뀔 수 있다. 따라서 만약 코루틴 내부에 일시 중단 지점이 없는 경우에는 ReentrantLock을 사용해도 별다른 문제가 생기지 않는다. 한 번 위에서 다룬 코드를 일시 중단을 일으키는 delay 대신 스레드 블로킹을 일으키는 Thread.sleep으로 바꿔보자. 바꾼 코드의 모양은 다음과 같다.
fun main() = runBlocking<Unit> {
val reentrantLock = ReentrantLock()
repeat(10) {
launch(Dispatchers.IO) {
println("Started >> ${Thread.currentThread().name}")
reentrantLock.lock()
Thread.sleep(1000L) // 스레드 블로킹
reentrantLock.unlock()
}.invokeOnCompletion {
println("Completed >> ${Thread.currentThread().name}")
}
}
}
이 코드를 실행해보면 다음과 같이 각 코루틴이 문제 없이 시작되고 재개되는 것을 볼 수 있다.
여기서 각 코루틴과 실행 스레드를 주의 깊게 보자. 확인해보면 각 코루틴 실행 스레드가 바뀌지 않는 것을 볼 수 있다. 따라서 같은 스레드에서 락이 걸리더라도 작업 후에 해제됨으로써 위와 같은 데드락 상황이 발생하지 않는다.
마치며
코루틴 내부에 일시 중단 지점이 있는 경우 ReentrantLock을 사용하면 심각한 문제가 생길 수 있지만, 일시 중단 지점이 없는 경우에는 ReentrantLock 객체는 잘 동작한다.
하지만, 자신이 ReentrantLock을 사용하는 코드에 일시 중단 지점을 넣지 않았더라도 누군가 실수 할 수 있다. 이런 문제를 해결하기 위해서는 ReentrantLock 객체 대신 코루틴에서 안전하게 사용할 수 있는 락인 Mutex 클래스를 사용해야 한다. 다음 글에서는 Mutex 객체에 대해 자세히 다룬다.
정리
- 코루틴 내부에 일시 중단 지점이 있는 경우 ReentrantLock을 사용하면 시작 스레드와 재개 스레드가 다를 수 있기 때문에 문제가 생길 수 있다.
- 코루틴 내부에 일시 중단 지점이 없는 경우 ReentrantLock을 사용하면 시작 스레드와 재개 스레드가 다를 수 있기 때문에 문제가 생길 수 있다.