Kotlin Coroutines의 runBlocking은 언제 써야 할까? 잘 알고 활용하자!



개인 광고 영역

코루틴을 활용하다 보면 문서에 나오는 것 외에도 다양하게 활용될 수 있다. 하지만 모든 Scope이 정답은 아니고, 상황에 맞는 게 있다.

안드로이드에서는 당연하게도 CoroutineScope을 사용해 lifecycle을 따르도록 개발하는 걸 권장하는데 onDestroy 시점에 cancel() 할 수 있는 형태로 만들어져있다.

Lifecycle을 따르는 건 매우 자연스럽다.

이 글에서는 다양하게 사용할 수 있는 Scope 중 먼저 runBlocking을 알아본다.


이 글에서 알아볼 내용

  • runBlocking을 UI에서 사용해도 괜찮을까?
  • runBlocking의 내부 코드를 알아보자.
  • 그럼 runBlocking은 언제 쓰는 게 좋을까?


Coroutines 관련

블로그에 작성한 Coroutines 관련한 내용 모음이다.

그리고 코루틴 관련 발표 자료이다.

이런 글들로 Coroutines 사용에 필요한 내용은 모두 다루었다.

2020 드로이드 나이츠에서는 이 글에 작성한 내용을 다루고 있다.


샘플에 흔히 나오는 runBlocking?

runBlocking은 코루틴 문서에 매우 흔하게 나오는 내용이다.

이 runBlocking의 위험한 점은 아래와 같다.

사용하는 runBlocking의 위치가 UI라면 UI를 Blocking 시키고 Coroutines이 끝나길 대기한다.

UI에서 버튼을 하나 달고 runBlocking을 사용해 코루틴을 사용했다면 이 코루틴이 끝날 때까지 무한정 기다리고, 응답 올 때까지 대기시킨다.

결국 blocking의 의미에 주의해야 하는데 차근차근 따라가보자.


runBlocking을 살펴보자

runBlocking으로 작성하고, 10초의 delay를 걸어주었다. 그리고 이 코드는 Android UI에서 호출해보았다.

class MainViewModel : ViewModel() {

    fun load() = runBlocking {
        android.util.Log.w("TEMP", "Thread ${Thread.currentThread()}")
        delay(10000)
    }
}

무슨 일이 생길까?

  • 저 코드를 UI에서 호출했다면 UI가 멈추게 된다.
  • UI에서 오랜 시간 응답이 없다면 ANRs(Application Not Responding)이 발생할 수 있다.


좀 더 알아보도록 하자

위에서 작성한 코드에 updateView라는 콜백을 하나 추가하고, UI 갱신도 기다리도록 만들었다.

이 코드는 아래의 순서대로 동작한다.

  • btn_load_data를 누르면 viewModel.load()를 호출한다.
  • viewModel.load()의 runBlocking을 한다.
  • 참고로 runBlocking 내부의 코드가 모두 동작할 때까지 blocking 한다.
  • count++를 한다.
  • delay(500ms)를 대기한다.
  • updateView를 호출하고, view를 갱신한다.
  • view 갱신까지 끝나면 runBlocking의 blocking을 종료한다.
class MainViewModel : ViewModel() {

    lateinit var updateView: (message: String) -> Unit

    private var count = 0

    fun load() = runBlocking {
        android.util.Log.w("TEMP", "Thread ${Thread.currentThread()}")
        count++
        delay(500)
        updateView("count $count Thread!!!!!! ${Thread.currentThread()}")
    }
}

// Fragment.kt
view.findViewById<Button>(R.id.btn_load_data).setOnClickListener {
    viewModel.load()
}

delay가 500ms라서 길지는 않지만, ripple 효과가 잠시 멈추어있는 것을 확인할 수 있다.

sample_01


문제점을 확인해보자

이 코드의 동작 방법은 위에서 설명했다.

그럼 왜 그럴까를 알아보려고 Thread.currentThread()를 출력해보았다.

fun load() = runBlocking {
    android.util.Log.w("TEMP", "Thread ${Thread.currentThread()}")
}

이 출력 결과는 아래와 같이 main이다. runBlocking 이전에도 main이고, 이후에도 main이다.

자연스럽게 상위의 context를 따르도록 만들어져있어 main인데, runBlocking 내부의 코드는 별도의 launch에서 500ms 및 View 갱신에 따라 대기 후 return 하도록 만들어져있다.

Thread Thread[main,5,main]

이 코드는 일반적으로 튼 클릭 후 별도의 Thread에서 동작 시키고, 이에 대한 응답 역시 비동기로 들어오는 게 아닌 모든게 동기로 동작하게 되어있다.

runBlocking을 걸어주는 순간 이름 그대로 Blocking을 시키고 대기 시킨다.

아래는 기존 500ms를 5초로 수정했을 때 아래와 같이 ANR이 발생함을 확인할 수 있다.

Reason: Input dispatching timed out (Waiting to send non-key event because the touched window has not finished processing certain input events that were delivered to it over 500.0ms ago.  Wait queue length: 5.  Wait queue head age: 5939.5ms.)
Parent: com.example.myapplication/.MainActivity
Load: 0.75 / 1.38 / 1.47
----- Output from /proc/pressure/memory -----
some avg10=0.17 avg60=0.11 avg300=0.10 total=131211478
full avg10=0.00 avg60=0.00 avg300=0.00 total=23030835
----- End output from /proc/pressure/memory -----


그럼 UI와 백그라운드 처리를 구분할 수 있을까?

코루틴은 Dispatchers를 이용해 스케쥴러를 변경할 수 있는데, 아래와 같이 수정해도 문제가 없을까?

그렇지는 않다. runBlocking 내부의 코드를 IO로 변경했을 뿐 UI를 blocking 하는 건 달라지지 않는다.

fun load() = runBlocking(Dispatchers.IO) { // 1번
    android.util.Log.w("TEMP", "Thread ${Thread.currentThread()}")
    count++
    delay(500)
    withContext(Dispatchers.Main) { // 2번
        updateView("count $count Thread!!!!!! ${Thread.currentThread()}")
    }
}

여기선 1 번에서 IO를 지정했지만 runBlocking 이후의 코드에 대한 부분이고,

2 번은 updateView를 하기 위해 Main으로 변경했을 뿐이다.


내부 코드를 좀 더 살펴보자.

앞에서도 언급은 했지만 내부 코드를 보면 좀 더 이해하기 쉽다.

어떻게 호출하든 BlockingCoroutine을 호출해 사용한다.

결국 기본적인 context는 currentThread = Thread.currentThread()를 이용해 context를 만들고, 사용하도록 만들어져있다.

public fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    val currentThread = Thread.currentThread()
    val contextInterceptor = context[ContinuationInterceptor]
    val eventLoop: EventLoop?
    val newContext: CoroutineContext
    if (contextInterceptor == null) {
        // create or use private event loop if no dispatcher is specified
        eventLoop = ThreadLocalEventLoop.eventLoop
        newContext = GlobalScope.newCoroutineContext(context + eventLoop)
    } else {
        // See if context's interceptor is an event loop that we shall use (to support TestContext)
        // or take an existing thread-local event loop if present to avoid blocking it (but don't create one)
        eventLoop = (contextInterceptor as? EventLoop)?.takeIf { it.shouldBeProcessedFromContext() }
            ?: ThreadLocalEventLoop.currentOrNull()
        newContext = GlobalScope.newCoroutineContext(context)
    }
    val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
    coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
    return coroutine.joinBlocking()
}

BlockingCoroutine을 좀 더 보면 joinBlocking이라는 함수에 while (true)를 처리하는 걸 볼 수 있다.

어떠한 응답이 들어오기 전까지 멈추지 않고 대기하게 된다.

결국 runBlocking은 아래 코드에 따라 자연스럽게

  • 완료나 cancel이 일어나기 전까지 대기한다.
private class BlockingCoroutine<T>(
    parentContext: CoroutineContext,
    private val blockedThread: Thread,
    private val eventLoop: EventLoop?
) : AbstractCoroutine<T>(parentContext, true) {
    override val isScopedCoroutine: Boolean get() = true

    override fun afterCompletion(state: Any?) {
        // wake up blocked thread
        if (Thread.currentThread() != blockedThread)
            LockSupport.unpark(blockedThread)
    }

    @Suppress("UNCHECKED_CAST")
    fun joinBlocking(): T {
        registerTimeLoopThread()
        try {
            eventLoop?.incrementUseCount()
            try {
                while (true) {
                    @Suppress("DEPRECATION")
                    if (Thread.interrupted()) throw InterruptedException().also { cancelCoroutine(it) }
                    val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE
                    // note: process next even may loose unpark flag, so check if completed before parking
                    if (isCompleted) break
                    parkNanos(this, parkNanos)
                }
            } finally { // paranoia
                eventLoop?.decrementUseCount()
            }
        } finally { // paranoia
            unregisterTimeLoopThread()
        }
        // now return result
        val state = this.state.unboxState()
        (state as? CompletedExceptionally)?.let { throw it.cause }
        return state as T
    }
}

다시 돌아가 주석을 통해 확인해보면 아래와 같이 새로운 코루틴을 실행하는데, 완료할 때까지 현재 스레드를 정지시킨다는 뜻이 포함되어 있다.

Runs a new coroutine and blocks the current thread interruptibly until its completion.


runBlocking은 그럼 언제 쓰고, 언제 쓰지 않는게 좋을까?

무조건 쓰지 말라는 것은 아니다.

본인이 잘 이해하고, 몇 가지 조건에 필요한 케이스를 찾았다면 활용해도 좋다. 대략 예를 들면 아래와 같을 수 있으나, 언제나 용도가 명확하지 않다면 쓰지 않는 것이 좋다.

  • IO 스케쥴러로 들어오는 데이터를 Flow를 활용해 데이터를 처리하고 싶을 때

이 케이스는 필자가 이전에 작성한 글을 통해 확인할 수 있으니 참고해서 고민하시길

Callback으로 받은 데이터를 Coroutines에서 활용하는 방법! Flow 활용

  • Unit test 코드를 작성할 때
class TestCode {

  @Test
  fun run() = runBlocking {
      delay(1000)
      assert(true)
  }
}

적은 2가지 케이스를 제외하면 그냥 runBlocking이라는 건 존재하지 않는 것이라고 생각하는 게 좋다고 생각한다.

정말 꼭 필요한 케이스(위에 적은 케이스는 동기화와 관련이 있다.)를 제외한다면 사실 필요치 않다.

Lifecycle을 따르도록 작업하거나, CoroutineScope().launch {}를 통해 충분히 해결할 수 있다.


마무리

이번 글에서는 runBlocking의 위험성을 알아보았다. runBlocking은 꼭 활용해야 할 시점은 있을 것이다. 다만 기본 동작 상태를 잘 알고 써야 한다.

잘 모르고, UI에 runBlocking을 활용했다면 누가 보아도 잘못된 활용으로 보이고, 테스트도 안 해보고 쓴 것인가?라는 이야기를 할 수 있다.

사실 테스트할 때의 환경은 응답이 얼마나 빠르게 오는지에 따라 모르고 넘어갈 수 있다. 이런 예외가 발생하는 것 자체가 문제이기에 runBlocking을 사용치 않는 것이 좋다.

이 글의 내용 정리

  • runBlocking은 호출한 위치를 Blocking 시킨다.
  • runBlocking 내부의 응답이 종료되기 전까지 응답을 주지 않는다.
  • runBlocking은 비동기가 아닌 동기화로 동작한다.
  • UI에서 사용하는 runBlocking은 사용하지 않아야 한다.
  • runBlocking이 필요한 케이스를 찾아야 하는데, 명확한 IO를 보장하고, 데이터의 동기화가 필요한 경우와 UnitTest에서 활용하자.


이 글만 보기 아쉽죠. Effective Kotlin 더 보기

지금까지 작성한 EffectiveKotlin 관련 글은 태그로 모아 링크를 추가한다.

더 보기 - Effective Kotlin 관련 글



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments