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!” 는 한번도 출력하지 못하고 프로그램 또한 정상적으로 종료되지 못한 채 계속 실행중일 것이다.
그 이유는 launch
→ runBlocking
→ launch
로 이어지는 코드 중 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 을 유발하려면 최소 두가지 조건은 만족해야한다.
- 특정 CoroutineContext 에서 코루틴을 실행하고, 내부에서
runBlocking
을 실행 - 해당
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 을 활용할 경우 한 번 정도 생각해보면 좋겠다.