[Kotlin] 코루틴이 Deadlock을 유발하는 경우

allocProc
8 min readDec 10, 2023

--

kotlinx.coroutines 패키지에는 runBlocking 이라는 메소드가 있다. 코루틴을 배울 때 가장 처음 접할 수 있는 메소드 중 하나이고 코루틴을 한 번이라도 활용해본 경험이 있는 개발자라면 다들 그 역할을 너무나 잘 알고 있을 메소드이다.

하지만 runBlocking 메소드는 사용에 있어서 크게 주의해야할 점이 하나 있는데, 이는 코틀린 공식 문서에도 언급되어있다.

  • runBlocking 은 코루틴 내부에서 사용하지 말 것 🔥
  • 이 메소드는 블로킹 코드와 suspend 코드를 이어주는 다리역할
  • 메인 함수나 테스트 코드에서의 사용을 목적으로 개발된 메소드

그것은 runBlocking 메소드를 코루틴 내부에서 사용하지 말라는 것이다. 하지만 공식 문서에서도 경고만 하고 그 이유에 대해서는 자세히 언급하지 않았는데, 아마 아래와 같은 이유 때문이지 않을까 추측한다.

  • 문서에서도 말했듯 애초에 블로킹 스타일의 코드와 suspend 스타일의 코드를 이어주기 위해 만들어진 메소드이고
  • runBlocking 메소드는 자신을 호출한 부모 스레드를 block 하기 때문에 코루틴 내부에서 사용한다면 성능상의 불이익이 크다.

처음 공식 문서를 봤을때는 위 두가지 이유정도로만 생각하고 넘어갔었다. 하지만 최근에 runBlocking 을 코루틴 내부에서 사용하면 안되는 더 치명적인 이유를 하나 경험했고 이 또한 위 이유에 추가하고자한다.

  • runBlocking 메소드는 코루틴 내부에서 잘못 사용하면 코루틴간의 deadlock 을 유발한다!

혹시 이 말만 듣고 어떤 경우인지 바로 머리 속에서 그려지는가? 위 문제를 유발하는 예시 코드를 살펴보자.

suspend fun main() {
val threadPoolSize = 2
val threadPool = Executors.newFixedThreadPool(threadPoolSize)
val customDispatcher = threadPool.asCoroutineDispatcher()

withContext(customDispatcher) {
repeat(threadPoolSize) {
launch(customDispatcher) {
runBlocking {
launch(customDispatcher) {
println("Hello!")
}
}
}
}
}

threadPool.shutdown()
}

위 코드를 실행해보면 아마 “Hello!” 는 한번도 출력하지 못하고 프로그램 또한 정상적으로 종료되지 못한 채 계속 실행중일 것이다.

그 이유는 launchrunBlockinglaunch 로 이어지는 코드 중 runBlocking 내부에서 launch 를 실행할 때 deadlock 이 발생했기 때문이다.

어떻게 deadlock 이 발생했는지 그 과정을 자세히 살펴보자.

  • 스레드 2개(스레드 A,B)로 이루어진 스레드풀을 CoroutineDispatcher 로 사용하는 CoroutineContext 생성
  • 해당 CoroutineContext 내부에서 launch 2번 실행 → 코루틴 2개 생성
  • 각각의 코루틴 내부에서 runBlocking 메소드 실행 → 스레드 A,B 모두 block
  • 각각의 runBlocking 메소드 내부에서 launch 실행
  • println(”Hello”) 코루틴을 실행할 스레드가 Dispatcher 에 남아있지 않음

정리하자면, 처음 runBlocking 으로 스레드 A 가 block 되고 내부에서 launch 로 코루틴을 실행하기 위해 customDispatcher 에서 스레드 B 를 가져와서 사용하려는데 이미 스레드 B 도 두번째 runBlocking 으로 block 된 상황이다.

물론 코루틴 내부에서 runBlocking 을 사용한 것이 deadlock 을 유발한 유일한 원인이라고 말할 수는 없다. 위 같은 deadlock 을 유발하려면 최소 두가지 조건은 만족해야한다.

  1. 특정 CoroutineContext 에서 코루틴을 실행하고, 내부에서 runBlocking 을 실행
  2. 해당 runBlocking 내부에서 코루틴을 생성하고 실행하되, 외부 CoroutineContext 를 부모 Context 로 사용

2번이 무슨 말인지 보려면, 위 코드에서 제일 내부의 launch 메소드에서 customDispatcher 를 빼보면 된다.

suspend fun main() {
val threadPoolSize = 2
val threadPool = Executors.newFixedThreadPool(threadPoolSize)
val customDispatcher = threadPool.asCoroutineDispatcher()
withContext(customDispatcher) {
repeat(threadPoolSize) {
launch(customDispatcher) {
runBlocking {
launch {
println("Hello!")
}
}
}
}
}
threadPool.shutdown()
}

이렇게 실행하면 deadlock 은 발생하지 않고 “Hello!” 는 두 번 다 출력된 후 프로그램도 정상 종료된다. launch 메소드는 Context 를 따로 지정해주지 않으면 부모 Context 를 그대로 사용하고 위 코드에서 부모는 runBlocking 이다. runBlocking 은 Context 를 지정하지 않으면 자신을 호출한 스레드를 eventLoop 형태로 사용하여 내부적으로 코루틴을 실행한다. 이는 runBlocking 내부 코드를 보면 쉽게 확인할 수 있다.

그래서 제일 내부 launch 메소드 실행 시 Context 를 지정해주지 않으면 deadlock 이 발생하지 않고 따로 지정하면 deadlock 이 발생할 수 있는 것이다.

이해하기 쉽게 각 코루틴이 실행되는 Context 와 스레드에 대한 정보를 같이 출력해보자.

suspend fun main() {
val threadPoolSize = 2
val threadPool = Executors.newFixedThreadPool(threadPoolSize)
val customDispatcher = threadPool.asCoroutineDispatcher()

withContext(customDispatcher) {
println("Outer CoroutineContext: $coroutineContext, thread: ${Thread.currentThread().name}")
repeat(threadPoolSize) {
launch(customDispatcher) {
println("Inner CoroutineContext $it: $coroutineContext, thread: ${Thread.currentThread().name}")
runBlocking {
launch {
println("Inner Inner CoroutineContext $it: $coroutineContext, thread: ${Thread.currentThread().name}")
println("Hello!")
}
}
}
}
}

threadPool.shutdown()
}

각 코루틴이 어떤 Dispatcher 와 어떤 thread 에서 실행되었는지 확인할 수 있다.

코루틴간의 deadlock 이 발생하는 경우를 예시 코드와 함께 살펴봤다. 예시 코드는 최대한 짧고 이해하기 쉽게 작성하려다 보니 환경이 다소 억지같아보일 수 있으나 위 같은 사고는 주의하지 않으면 생각보다 발생하기 쉽다. deadlock 은 아직 발생하지 않았더라도 deadlock 을 발생할 여지가 있는 코드가 이미 쓰이고 있을 수도 있다. 코루틴과 runBlocking 을 활용할 경우 한 번 정도 생각해보면 좋겠다.

--

--