coroutine builder라는 말이 공식문서에서 위와 같이 사용되고는 있지만, 코루틴 빌더에 대해 별도의 상세 설명은 공식문서에 따로 존재하지 않는다. 개발자들이 공식문서에서 coroutine builder라는 용어가 사용된 부분들을 살펴보고 코루틴 빌더라는 용어에 대해 정의한 게 아닌가 싶다.
코루틴 빌더는 새로운 코루틴을 생성하는 데 사용하는 함수로 launch()
, async()
, runBlocking()
이 대표적인 코루틴 빌더 함수이다.
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}
launch()는 Job 객체를 리턴하는 코루틴 빌더이다. 이 Job을 통해서 코루틴의 현재 상태를 알 수 있고 코루틴을 제어가능하다.
먼저 newCoroutineContext()를 통해 새로운 코루틴컨텍스트를 만든다. 만약 start가 기본값인 CoroutineStart.DEFAULT이면 StandaloneCoroutine객체를 리턴하고 CoroutineStart.LAZY이면 start.isLazy
가 true가 되므로 StandaloneCoroutine 클래스를 상속한 LazyStandaloneCoroutine 객체를 리턴한다.
StandaloneCoroutine과 LazyStandaloneCoroutine은 Job 인터페이스를 구현하는 AbstractCoroutine 클래스를 상속하는 클래스들이다. launch()를 호출하면 Job 인터페이스 타입으로 업캐스팅되어 반환되는 것이다.
그리고 coroutine.start(start, coroutine, block)
이렇게 코루틴을 시작하는 코드가 보이는데
/**
* Starts this coroutine with the given code [block] and [start] strategy.
* This function shall be invoked at most once on this coroutine.
*
* * [DEFAULT] uses [startCoroutineCancellable].
* * [ATOMIC] uses [startCoroutine].
* * [UNDISPATCHED] uses [startCoroutineUndispatched].
* * [LAZY] does nothing.
*/
public fun <R> start(start: CoroutineStart, receiver: R, block: suspend R.() -> T) {
start(block, receiver, this)
}
start() 함수의 주석 설명을 보면 Lazy 때는 아무것도 하지 않는다고 되어있다. 그래서 launch()를 호출할 때 CoroutineStart.Lazy를 인자로 전달하면 코루틴이 생성만 되고 실행하지 않는다.
public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T> {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyDeferredCoroutine(newContext, block) else
DeferredCoroutine<T>(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}
async()는 Deferred<T> 객체를 리턴하는 코루틴 빌더이다.
참고로 Deferred 인터페이스는 public interface Deferred<out T> : Job
이렇게 Job을 상속하는 인터페이스이다. 그래서 Deferred 객체는 Job 객체의 모든 함수와 변수를 사용할 수 있으며, 그래서 Deferred를 통해서도 현재 코루틴의 상태를 알 수 있고 코루틴을 제어가능하다.
함수 내부는 launch() 함수 내부와 크게 다를 바 없다.
Deferred 객체는 값을 가지고 있는 Job 객체이다. 그래서 launch() 함수는 단순히 Job 객체를 리턴하지만 async() 함수의 리턴 타입을 보면 Deferred<T>를 리턴하고 있다. 그리고 block 인자도 비교해보면, launch()에서는 suspend CoroutineScope.() -> Unit
이렇게 Unit 타입을 리턴하는 함수이지만, async()에서는 suspend CoroutineScope.() -> T
이렇게 특정 타입을 리턴하는 함수이다.
Deferred 객체는 미래의 어느 시점에 결과값을 리턴할 수 있음을 표현하는 객체이다. 그리고 결과값 수신의 대기를 위해 await 함수를 제공한다. 그런데 복수의 코루틴을 결과값을 수신할 때 주의할 점이 있다.
fun main() = runBlocking {
val firstDeferred: Deferred<String> = async(Dispatchers.IO) {
delay(1000L)
return@async "First"
}
val first = firstDeferred.await() // 결과가 수신될 때까지 대기
val secondDeferred: Deferred<String> = async(Dispatchers.IO) {
delay(1000L)
return@async "Second"
}
val second = secondDeferred.await() // 결과가 수신될 때까지 대기
}
예를 들어서 async(), await(), async(), await() 이러한 순서로 코드가 작성되었다고 가정해보자. 그러면 모든 코드가 실행될 때까지 대략 2000ms(2초) 정도가 소요될 것이다. 중간에 await()가 끼어있기 때문에 첫번째 async() 함수의 결과값이 수신될 때까지 기다리기 때문이다. 이 코드는 사실상 비동기적으로 이루어지는 코루틴의 이점을 잘 살리지 못한 것이다.
fun main() = runBlocking {
val firstDeferred: Deferred<String> = async(Dispatchers.IO) {
delay(1000L)
return@async "First"
}
val secondDeferred: Deferred<String> = async(Dispatchers.IO) {
delay(1000L)
return@async "Second"
}
val first = firstDeferred.await() // 결과가 수신될 때까지 대기
val second = secondDeferred.await() // 결과가 수신될 때까지 대기
// awaitAll(firstDeferred, secondDeferred) // awaitAll()을 통한 결과값 수신
}
그래서 효율적으로 복수의 코루틴 결과값을 수신하려면 이렇게 async()가 모두 호출된 이후에 await()를 호출하여 비동기적으로 잘 동작하게 만들어야 한다. 혹은 예시에서 주석으로 작성해놓은 것처럼 awaitAll()
을 통해서 한꺼번에 값을 수신해도 된다. 하지만 awaitAll()을 사용하면 List<T>형태로 리턴되므로 이 점을 주의하도록 하자.
runBlocking() 새로운 코루틴을 생성 및 실행하고 완료될 때까지 현재 스레드를 차단하는 함수이다. 즉 runBlocking() 함수로 생성된 코루틴이 실행 완료될 때까지, 다른 작업이 현재 스레드를 점유하지 못하게 막는다. 이름에 blocking이라는 단어가 들어가 있는 것도 그러한 이유이다.
그래서 보통 실제 앱을 만들 때는 잘 사용하지 않고, 주로 main()
함수와 테스트를 할 때 사용되곤 한다.
public actual fun <T> runBlocking(context: CoroutineContext, block: suspend CoroutineScope.() -> T): T {
// ...
val currentThread = Thread.currentThread()
// ...
val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
return coroutine.joinBlocking()
}
runBlocking() 함수를 보면 coroutine.joinBlocking()
을 리턴한다. Job 객체에 대해 공부를 해봤다면 join()
함수가 해당 Job이 완료될 때까지 join()
을 호출한 코루틴을 일시중단하는 함수라는 것을 알 것이다. joinBlocking()
은 join과 Blocking이 합쳐진 단어이므로, joinBlocking()
은 작업이 완료될 때까지 스레드를 차단하는 함수라는 것을 예측할 수 있다.
fun joinBlocking(): T {
registerTimeLoopThread()
try {
eventLoop?.incrementUseCount()
try {
while (true) {
@Suppress("DEPRECATION")
if (Thread.interrupted()) throw InterruptedException().also { cancelCoroutine(it) }
// ...
}
}
// ...
}
// ...
return state as T
}
joinBlocking() 내부를 살펴보면 Thread가 방해를 받으면 (다른 작업이 스레드를 사용하려고 하면) Exception을 발생시키고 코루틴을 취소한다는 것을 알 수 있다.
하지만 runBlocking의 스레드 차단은 우리가 흔히 알고 있는 스레드 블로킹과는 다르다. 일반적인 의미의 스레드 블로킹은 스레드가 아무런 작업도 할 수 없는 상태인 것을 의미한다.
하지만 runBlocking의 스레드 차단은 runBlocking 코루틴과 그 자식 코루틴을 제외한 다른 작업이 스레드를 사용할 수 없음을 의미한다. 만약 runBlocking이 일반적인 스레드 블로킹처럼 스레드를 차단했다면 runBlocking 블록 내에 있는 코드와 그 자식 코루틴은 모두 실행되지 않았을 것이다.
추가로 withContext()
를 코루틴 빌더 함수로 분류해놓은 블로그 글을 간혹 볼 수 있는데, 내 생각에 withContext()
는 코루틴 빌더 함수가 아니라고 생각한다.
코루틴 빌더 함수의 정의는 새로운 코루틴을 생성하는 데 사용하는 함수인데, withContext()
함수는 새로운 생성하지 않고 실행중이던 코루틴을 그대로 유지한 채 코루틴의 실행 환경만 변경하여 작업을 처리하기 때문이다.
그 증거로 withContext() 함수는 suspend 함수이다. suspend 함수는 코루틴 내에서 혹은 다른 suspend 함수에서 사용할 수 있는데, 만약 코루틴을 새로 생성하는 함수라면 suspend 식별자가 붙을 필요가 없을 것이다. withContext()에 대해서는 따로 글을 작성하였다.