Kotlin 코루틴(Coroutines)


Coroutine

Kotlin은 버전 1.1부터 Coroutine을 도입되었다.

Kotlin은 언어로서 다른 다양한 라이브러리 coroutine를 사용할 수 있도록 표준 라이브러리에서 최소한의 낮은 level API 만 제공한다. Kotlin에서 async와 await은 키워드에 포함되지 않으며 표준 라이브러리의 일부가 아니다. 또한 Kotlin의 suspend 기능 개념은 futures 및 promise 보다 안전하며 오류가 발생하기 어려운 비동기 처리의 추상화를 제공한다.

Coroutine은 경량 Thread

Coroutine은 가벼운 쓰레드(Light-weight thread)라고 할 수 있다. 쓰레드는 아니지만 비동기적인(asynchronous) 프로그래밍이 가능하게 만들어준다.
Thread와 같이 다른 처리를 블로킹하지 않고 병렬로 처리를 실시할 수 있다. 그러나 매우 가볍습니다.
Thread는 시작과 종료에 무시할 수 없는 비용이 들지만, Coroutine는 거의 신경 쓸 필요가 없는 정도의 비용 밖에 들지 않는다.
동시에 수천, 수만의 Coroutine을 쉽게 실행할 수 있다.

Coroutines은 Co + Routines 약자로, Co 는 Cooperation을 의미하고, Routines는 functions를 의미한다. 서로 협력하는 함수들이라는 의미인데, 이걸 간단히 Coroutine이라고 한다.

Gradle dependency 설정

코루틴을 사용하기 위해서는 다음 의존성을 설정해야 한다.

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2")
}

https://github.com/Kotlin/kotlinx.coroutines 에서 추가적인 의존성을 설정할 수 있다.

Coroutines 기초

coroutine을 이해하기 위해 suspend(중단)개념에 대해 알아야 한다. coroutine에서는 thread를 block(점유)하는 대신 처리를 suspend(중단)한다. block과 suspend의 차이점을 살펴 보겠다.

block
block은 스레드를 점유한다. 점유를 하게 되면 thread로 처리를 진행할 수 없게 된다.

fun main(){
    println("Coroutine")
    Thread.sleep(3000L) // 3초 동안 처리가 중지된다. 이 시간은 동안 처리는 전혀 진행되지 않는다.
    println("Hello")
}

Output:

Coroutine
Hello

suspend
suspend는 coroutine 처리를 중단하고 thread를 해제한다. 해제하는 동안 다른 처리에 리소스를 활용할 수 있다.

import kotlinx.coroutines.*

fun main(): Unit = runBlocking {
    launch {
        delay(3000L) // 3초간 처리를 중단. 스레드가 해제되기 때문에 먼저 Hello가 표시된다.
        println("Coroutine")
    }
    println("Hello")
}

Output:

Hello
Coroutine

block의 경우에는 3초간, thread가 점유되어 버리는 문제가 있다. 예를 들어, application의 사용중에 thread를 점유해 버리면 freeze이 발생하여 user가 화면을 조작할 수 없게 된다.
한편, suspend의 경우는 중단하는 것만 이므로 3초간, user는 조작이 가능하다.

coroutine에 사용하기 위해서는 suspend(중단)에 대한 이해를 제대로 해야만 한다.

첫번째 코루틴

coroutine는 suspendable computation의 instance이다. 다른 code와 동시에 동작하는 code block를 실행한다고 하는 의미로써 thread와 개념적으로 닮아 있다. 그러나 coroutine은 특정 스레드에 바인딩되지 않는다. 어느 스레드에서 실행을 suspend 하고, 다른 스레드에서 다시 시작할 수 있다.

coroutine은 경량의 thread라고 생각할 수 있지만, 실제의 사용 방법을 thread와는 크게 다른 점이 몇가지 있다.

fun main() = runBlocking {  // this: coroutineScope
    launch { // 새로운 coroutine을 시작 新しいcoroutineを起動
        delay(1000L) // non-blocking으로 1초 지연
        println("World!") // 1초후에 표시
    }
    println("Hello") // 앞에 스레드가 지연되는 동안 기본 스레드가 실행된다.
}

Output:

Hello
World!

이어서 이 코드가 무엇을 하는지를 분석해 보자.

  • launch는 coroutine builder 이다. 나머지 코드와 동시에 새로운 coroutine을 시작하고 독립적으로 계속 작동한다. 따라서 Hello가 처음으로 표시되었다.
  • delay는 특수한 suspend 함수한다. coroutine을 특정 시간 동안 처리를 중단하고 기본 스레드를 해제한다. delay가 있는 스레드는 block되지만 기본 스레드는 차단되지 않는다(non-blocking).
  • runBlockingfun main()의 coroutine가 아닌 세계와 runBlocking {...}의 block 내의 coroutine을 포함하여 코드의 중계하는 coroutine이기도 한다. 예를 들어, launch는 coroutineScope에서만 선언된다.

runBlocking의 이름은 runBlocking {...}내의 모든 coroutine가 실행을 완료할 때까지, 그것을 실행하는 thread(이 경우는 main thread)가 call 중에 block 되는 것을 의미한다.

값비싼 resource인 thread를 block 하는 것은 비효율적이며, 바람직하지 않기 때문에 실제의 code내에서 runBlocking가 사용되는 경우는 거의 없습니다.

구조적 동시성 (Structured concurrency)

coroutine는 Structured concurrency의 원칙을 따른다. 즉, 새로운 coroutine은 coroutine의 생명 주기를 구분하는 특정 coroutineScope에서만 시작할 수 있다. 실제 응용 프로그램에서는 많은 coroutine이 시작되지만 Structured concurrency를 사용하면 손실되거나 누락되지 않는다.

추출 함수 리팩토링 (Extract function refactoring)

launch {...} 내의 코드 block을 다른 함수로 추출해 보자. coroutineScope에서 코드를 추출하는 경우에는 suspend 한정자를 사용한다.

import kotlinx.coroutines.*

fun main() = runBlocking {  // this: coroutineScope
    launch { doWorld() }
    println("Hello")
}

suspend fun doWorld() {
    delay(1000L)
    println("World!")
}

Output:

Hello
World!

스코프 빌더 (Scope builder)

다양한 빌더가 제공하는 coroutine scope 외에도 coroutineScope 빌더를 사용하여 자신의 scope를 선언 할 수 있다. coroutine scope를 작성하면 기동된 모든 처리가 완료할 때까지 완료하지 않는다.

runBlocking 빌더와 coroutineScope 빌더는 둘다 자신의 처리와 모든 자식 스레드 처리가 완료되기를 기다리기 때문에 닮은 것처럼 보인다. 주요 차이점은 다음과 같다.

  • runBlocking은 자식 스레드가 완료될 때 까지 현재 스레드를 block한다.
  • coroutineScope는 자식 스레드가 완료될 때 까지 현재 스레드를 block 하지 않는다.

이 차이로 인해 runBlocking은 일반 함수이고, coroutineScope는 suspend 함수로 실행된다.

fun main() = runBlocking {
    doWorld()
}

suspend fun doWorld() = coroutineScope {  // this: coroutineScope
    launch {
        delay(1000L)
        println("World!")
    }
    println("Hello")
}

Output:

Hello
World!
fun main() = runBlocking { // serial
    countToDown()
    println("fire!")
}

suspend fun countToDown() = coroutineScope { // concurrent
    launch {
        delay(2000L)
        println("1")
    }
    launch {
        delay(1000L)
        println("2")
    }
    println("3")
}

Output:

3
2
1
fire!

명백한 직업 : An explicit job

launch coroutine 빌더는 시작된 coroutine에 대한 핸들이며, 완료를 명시적으로 대기하는 데 사용할 수있는 Job객체를 반환한다. 예를 들어, 아래 예제는 하위 coroutine이 완료 될 때까지 기다렸다가 문자열 “Done"이 출력된다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch { // 새로 coroutine을 시작하고, job에 대한 참조를 유지한다.
        delay(1000L)
        println("World!")
    }
    println("Hello")
    job.join() // 하위 coroutine이 완료될 때까지 기다린다.
    println("Done")
}

Output:

Hello
World!
Done

코루틴은 가볍다 (Coroutines are light-weight)

100K coroutine을 시작하고, 5초 후에 각 coroutine이 점(.)을 출력한다.

경량의 coroutine에서의 실행은 성공하지만, Thread에서의 실행은 java.lang.OutOfMemoryError가 발생한다.

import kotlinx.coroutines.*

fun main() {
    launchCoroutines() // success!
    runThreads() // java.lang.OutOfMemoryError
}

private fun launchCoroutines() = runBlocking {
    repeat(100_000) { // launch a lot of coroutines
        launch {
            delay(5000L)
            print(".")
        }
    }
}

private fun runThreads() {
    repeat(100_000) { // launch a lot of threads
        object : Thread() {
            override fun run() {
                sleep(5000L)
                print(".")
            }
        }.start()
    }
}

요약

이번에 배운 내용으로 중요하다고 생각하는 점을 정리해 보았다.

  • 코루틴는 가볍고 안전하게 처리할 수 있는 비동기 처리 중 하나이다.
  • thread는 block하지 않고 suspend(중단)를 사용한다.
  • 특정 스레드에 바인딩되지 않으므로 다른 스레드에서 다시 시작할 수 있다.
  • coroutineScope 내에서 시작된 coroutine은 모든 하위 처리가 완료 될 때까지 완료되지 않는다.

참조