(Kotlin Coroutines) Part 1 - Basic

Kotlin Coroutines Part 1 - Basic

Kotlin Coroutine이란?

원활한 비동기 프로그래밍 개발을 위하여 Kotlin은 언어 레벨에서 Coroutine 이라는 도구를 제공한다.

Coroutine은 비동기적으로 실행되는 코드를 간소화하기 위해 Android에서 사용할 수 있는 동시 실행 설계 패턴으로, Kotlin 버전 1.3에 추가되었으며 다른 언어에서 이미 확립된 개념을 기반으로 한다.

Coroutine이라는 개념은 1963년에 이미 출판본에서 확인할 수 있으며, subroutine이나 thread 등과 비교한 글들도 쉽게 찾아 볼 수 있다.

참고 Design of a Separable Transition-Diagram Compiler(1963)

Android의 비동기 프로그래밍 관점에서의 Coroutine 기능

  • Lightweight : 경량
    • 코루틴을 실행 중인 스레드를 차단하지 않는 정지를 지원하므로 단일 스레드에서 많은 코루틴을 실행할 수 있다.
    • 정지는 많은 동시 작업을 지원하면서도 차단보다 메모리를 절약할 수 있다.
  • Fewer memory leaks : 메모리 누수 감소
    • 구조화된 동시 실행을 사용하여 범위 내에서 작업을 실행한다.
  • Built-in cancellation support : 기본으로 제공되는 취소 지원
    • 실행 중인 코루틴 계층 구조를 통해 자동으로 취소가 전달된다.
  • Jetpack integration : Jetpack 통합
    • 많은 Jetpack 라이브러리에 코루틴을 완전히 지원하는 확장 프로그램이 포함되어 있다.
    • 일부 라이브러리는 구조화된 동시 실행에 사용할 수 있는 자체 코루틴 범위도 제공한다.

Coroutines Basic

이번 포스트에서는 코루틴의 기초에 대해 알아보도록 하자.

1
2
3
4
5
6
7
8
9
10
import kotlinx.coroutines.*

fun main() {
GlobalScope.launch { // launch a new coroutine in background and continue
delay(1000L) // non-blocking delay for 1 second (default time unit is ms)
println("World!") // print after delay
}
println("Hello,") // main thread continues while coroutine is delayed
Thread.sleep(2000L) // block main thread for 2 seconds to keep JVM alive
}

출력 결과는 아래와 같다.

1
2
Hello,
World!

launch 블록은 일종의 Builder로 특정 coroutineScope에서 코루틴을 실행시킬 수 있다.

위의 예제의 경우 GlobalScope에서 실행하는 것이다.

GlobalScope는 애플리케이션의 라이프사이클을 따라가기 때문에 sleep()을 통해 launch의 내부 블록을 실행한다.

Android의 경우 Activity가 기준이 아닌 Process가 기준이므로 sleep()이 없어도 정상적으로 출력된다.

Bridging blocking and non-blocking worlds

위의 예제를 보면 delay()sleep()을 쓰고 있는데, delay()는 non-blocking이고, sleep()은 blocking 메서드이다.

이 blocking과 non-blocking을 연결해보자.

1
2
3
4
5
6
7
8
9
10
11
12
import kotlinx.coroutines.*

fun main() {
GlobalScope.launch { // launch a new coroutine in background and continue
delay(1000L)
println("World!")
}
println("Hello,") // main thread continues here immediately
runBlocking { // but this expression blocks the main thread
delay(2000L) // ... while we delay for 2 seconds to keep JVM alive
}
}

출력 결과는 아래와 같다.

1
2
Hello,
World!

runBlocking 블럭을 이용하면 코루틴의 내부 코드가 종료될때까지 메인 Thread를 bloack시킬 수 있다.

위의 코드는 아래와 같이 바꿔쓸 수 도 있다.

1
2
3
4
5
6
7
8
9
10
import kotlinx.coroutines.*

fun main() = runBlocking<Unit> { // start main coroutine
GlobalScope.launch { // launch a new coroutine in background and continue
delay(1000L)
println("World!")
}
println("Hello,") // main coroutine continues here immediately
delay(2000L) // delaying for 2 seconds to keep JVM alive
}

출력 결과는 아래와 같이 동일하다.

1
2
Hello,
World!

runBlocking을 통해 main() 메서드에서 바로 메인 Thread를 block시키고 코루틴을 실행시키는 것이다.

runBlocking<Unit> 블럭의 경우 주로 코틀린의 top-level에서 코루틴을 시작할때 어댑터로서 동작한다.

<E>Unit이 부여된 이유는 main() 메서드가 Unit을 반환하기 때문이다.

Waiting for a job

코루틴을 통해 메인 스레드를 block 시키는 방법에 대해 알아보았지만, 사실 코루틴을 처리하는 동안 작업이 명시적으로 지연되는 것이나 마찬가지이다.

이를 방지하기 위해선 non-blocking 방식으로 코루틴을 처리하면 된다.

non-blocking 방식이란 코루틴의 내부 동작이 완료되는 시점까지 대기하도록 처리하는 것을 말한다.

별도의 코루틴에서 일어나는 작업을 코틀린에서는 Job 이라고 호칭한다.

참고 kotlinx.coroutines#Job

아래 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
10
import kotlinx.coroutines.*

fun main() = runBlocking {
val job = GlobalScope.launch { // launch a new coroutine and keep a reference to its Job
delay(1000L)
println("World!")
}
println("Hello,")
job.join() // wait until child coroutine completes
}

출력 결과는 아래와 같다.

1
2
Hello,
World!

launch에서 반환되는 job 객체에서 join() 메서드를 호출하여 해당 job을 반환한 코루틴의 내부 동작이 끝날때까지 대기하도록 처리하는 방식이다.

delay() 메서드를 통한 명시적인 지연보다는 join()을 통한 non-blocking 방식의 처리가 좀 더 권장된다.

Structured concurrency

GlobalScope.launch { }를 통한 코투린 생성은 항상 top-level에 위치한 코루틴을 생성하게 된다.

GlobalScope는 애플리케이션의 process와 라이프사이클을 같이하기 때문에, 참조 관리를 정확하게 처리하지않는다면 메모리 관리 차원에서 오류를 발생시킬 여지가 있다.

이를 방지하기 위한 방법으로 코루틴은 Structured concurrency 라는 개념을 제공하고 있다.

아래 코드를 보자.

1
2
3
4
5
6
7
8
9
import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
launch { // launch a new coroutine in the scope of runBlocking
delay(1000L)
println("World!")
}
println("Hello,")
}

출력 결과는 아래와 같다.

1
2
Hello,
World!

JVM 환경에서 일반적으로 Thread를 관리하고 처리하듯이 특정 범위 내에서 코루틴을 생성하여 사용할 수 있다.

위의 코드에서 launch로 생성되는 코루틴의 동작 범위는 runBlocking { ... } 에 한정되게 된다.

runBlocking을 위시로한 모든 코루틴의 Builder는 블럭 범위 내에서의 코루틴의 범위인 CoroutineScope를 같이 생성해준다.

Scope builder

다른 Builder들을 통하지 않고 명시적으로 coroutineScope를 생성해서 범위를 지정할 수 있도 있다.

coroutineScope를 통해 생성한 경우, 모든 내부 코드 동작이 끝날떄까지 종료되지 않는다.

여기까지만 보면 runBlockingcoroutineScope의 동작이 유사하다고 볼 수 있지만 runBlocking은 현재 thread를 아예 block 시켜버리는 일반 메서드이고,

coroutineScope는 잠시 중지시키고, 다른 작업의 수행을 위해 기본 thread를 해재해주는 것이다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
launch {
delay(200L)
println("Task from runBlocking")
}

coroutineScope { // Creates a coroutine scope
launch {
delay(500L)
println("Task from nested launch")
}

delay(100L)
println("Task from coroutine scope") // This line will be printed before the nested launch
}

println("Coroutine scope is over") // This line is not printed until the nested launch completes
}

출력 결과는 아래와 같다.

1
2
3
4
Task from coroutine scope
Task from runBlocking
Task from nested launch
Coroutine scope is over

Extract function refactoring

코루틴에서 사용되는 코드를 외부로 뽑아서(Extract) 사용하려면 suspend 키워드를 사용하면 된다.

메서드 선언시 suspend 키워드를 추가하여 suspending function으로 지정할 수 있으며, 코루틴 내부에서 사용할 수 있다.

suspending function들을 차례로 사용하면서 아래 예제의 delay() 메서드처럼 코루틴을 중단 시킬 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
import kotlinx.coroutines.*

fun main() = runBlocking {
launch { doWorld() }
println("Hello,")
}

// this is your first suspending function
suspend fun doWorld() {
delay(1000L)
println("World!")
}

출력 결과는 아래와 같다.

1
2
Hello,
World!

Coroutines ARE light-weight

아래 코드를 실행해보면 10만개의 코루틴을 생성하고 5초 후에 각각 .를 출력하게 된다.

만약 thread로 아래 동작을 정의하여 실행하는 경우 메모리 부족이 발생할 가능성이 높다.

즉 아래 예제는 코루틴의 경량성을 보여주는 예제라고 볼 수 있다.

1
2
3
4
5
6
7
8
9
10
import kotlinx.coroutines.*

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

Global coroutines are like daemon threads

GlobalScope를 가진 코루틴은 데몬 thread와 비슷하다.

아래 예제 코드를 실행해보자.

1
2
3
4
5
6
7
8
9
10
11
import kotlinx.coroutines.*

fun main() = runBlocking {
GlobalScope.launch {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
}
delay(1300L) // just quit after delay
}

출력 결과는 아래와 같다.

1
2
3
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...

delay(1300L)이후에 메인 메서드가 종료되면서 GlobalScope를 가진 코루틴도 종료되는 것을 알 수 있다.