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 효과가 잠시 멈추어있는 것을 확인할 수 있다.
문제점을 확인해보자
이 코드의 동작 방법은 위에서 설명했다.
그럼 왜 그럴까를 알아보려고 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 관련 글은 태그로 모아 링크를 추가한다.
Comments