(Kotlin Coroutines) 002. Coroutines basics

Kotlin Coroutines - Coroutines basics

모든 프로그래밍 언어익힐 때 찍어보는 문장으로 “Hello, World!”가 있다.

마찬가지로 먼저 “Hello, World” 를 코루틴으로 찍어보도록 하자.

1. runBlocking

코루틴을 만드는 가장 간단한 방법으로 runBlocking 함수가 있다.

이름에 blocking이 들어가있는 것에서 알 수 있듯 runBlocking 함수는 코루틴을 만들고, 해당 코드 블록의 수행이 끝날 때까지

다음 코드를 수행하지 않도록 막아준다.

아래 예제를 먼저 보자.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println(Thread.currentThread().name)
    println("Hello, World!")
}

출력 결과

1
2
main @coroutine#1
Hello, World!

위의 코드에서 Thread.currentThread().name는 현재 스레드아 무엇인지 출력해주는 역할을 한다.

코루틴이 스레드를 사용하는 것인만큼 앞으로도 자주 쓰게 될 것이다.

2. 코루틴 빌더의 수신 객체

runBlocking 블록 안에서 this를 출력하면 무엇이 찍힐까?

아래 예제를 실행해보자.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println(this)
    println(Thread.currentThread().name)
    println("Hello, World!")
}

출력 결과

1
2
3
"coroutine#1":BlockingCoroutine{Active}@36d64342
main @coroutine#1
Hello, World!

위와 같이 출력이 된다.

여기서 this가 출력된 "coroutine#1":BlockingCoroutine{Active}@36d64342 을 통해 코루틴이 수신 객체인 것을 추론할 수 있다.

Active는 출력 당시 해당 코루틴이 현재 활성화 상태임을 뜻한다.

참고 코틀린에서 수신 객체란 확장 함수가 호출되는 대상이 되는 값 또는 객체 이다. Extension Lambda라고 부르기도 한다.

여기서 BlockingCoroutineCoroutineScope 인터페이스의 구현체이다.

CoroutineScope 인터페이스는 coroutineContext를 가지고 모든 코루틴에 위치하므로,

코틀린 코루틴을 사용하는 모든 곳에 CoroutineScope가 있다고 볼 수 있다.

아래의 코드를 참조하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// CoroutineScope.kt
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}

// AbstractCoroutine.kt
@InternalCoroutinesApi
public abstract class AbstractCoroutine<in T>(
parentContext: CoroutineContext,
initParentJob: Boolean,
active: Boolean
) : JobSupport(active), Job, Continuation<T>, CoroutineScope { // HERE
// ...
}

// Builders.kt
private class BlockingCoroutine<T>(
parentContext: CoroutineContext,
private val blockedThread: Thread,
private val eventLoop: EventLoop?
) : AbstractCoroutine<T>(parentContext, true, true) { // HERE
// ...
}

3. CoroutineContext

CoroutineScope는 코루틴을 제대로 처리하기 위한 정보인 CoroutineContext를 가지고 있다.

상술했듯 runBlocking은 수신 객체를 가지고 있기때문에, 블록 내에서 코루틴 객체 안에 있는 것처럼 코드를 작성할 수 있고

CoroutineContext 속성을 출력할 수도 있다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println(coroutineContext)
    println(Thread.currentThread().name)
    println("Hello, World!")
}

출력 결과

1
2
3
[CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@340f438e, BlockingEventLoop@30c7da1e]
main @coroutine#1
Hello, World!

출력 결과 [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@340f438e, BlockingEventLoop@30c7da1e] 가 출력되었다.

CoroutineId(1)는 코루틴의 id값를

"coroutine#1":BlockingCoroutine{Active}@340f438e는 현재 코루틴의 정보를

BlockingEventLoop@30c7da1e는 현재 코루틴이 호출된 스레드를 출력해준다.

참고 Android는 Thread.currentThread()로 코루틴 id를 출력할 수 없다. CoroutineName을 지정해주어야 한다.

참고 runBlocking에 지정되는 Dispatcher의 기본값은 Dispater.Main이 아니다.

4. launch

이번엔 runBlocking이 아닌 launch 코루틴 빌더를 사용해보자.

launch는 새로운 코루틴을 생성하므로, runBlocking과 달리 코루틴 블록 이후의 코드도 같이 실행한다.

launchrunBlocking으로 코루틴을 만든 후, 새로운 코루틴을 생성하므로 아래 코드와 같이 이중 구조를 가지게 된다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        println("launch: ${Thread.currentThread().name}")
        println("World!")
    }
    println("runBlocking: ${Thread.currentThread().name}")
    println("Hello, ")
}

출력 결과

1
2
3
4
runBlocking: main @coroutine#1
Hello,
launch: main @coroutine#2
World!

출력 결과를 보면 runBlocking이 먼저 수행되고 이후 launch가 실행되었음을 알 수 있다.

현재 예제 코드상으로 runBlockinglaunch 둘다 메인 스레드를 사용하고 있기때문에 runBlocking 블록의 코드들이 메인 스레드를 다 사용할 때까지 launch 블록의 코드들이 모두 기다리게 된다.

runBlocking 블록 또한 launch 블록의 코드가 모두 수행될때까지 기다리게 된다.

5. delay

일반적으로 Java에서 스레드를 사용하는 경우, 지연을 위해 Thread.sleep()을 사용한다.

코루틴은 지연을 위해 delay를 사용한다.

delay를 사용하면 해당 스레드를 해제하고 잠시 지연되는 형태로, 지연되는 동안 다른 코루틴이 스레드를 사용할 수 있게 해준다.

아래 예제를 보자.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        println("launch: ${Thread.currentThread().name}")
        println("World!")
    }
    println("runBlocking: ${Thread.currentThread().name}")
    delay(500L)
    println("Hello, ")
}

출력 결과

1
2
3
4
runBlocking: main @coroutine#1
launch: main @coroutine#2
World!
Hello,

이번에도 runBlocking 블록이 먼저 수행되지만, delay를 만나며 launch 블록이 수행된 후 delay 이후에 println("Hello, ")가 호출되었음을 확인할 수 있다.

이때 스레드를 해제하고 다른 코루틴에 양보하는 것은 출력 결과에서 메인 스레드를 사용하는 것으로 확인할 수 있다.

만약 delay 없이 수행하면 어떻게 될까?

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        println("launch: ${Thread.currentThread().name}")
        println("World!")
    }
    println("runBlocking: ${Thread.currentThread().name}")
    // delay(500L)
    println("Hello, ")
}

출력 결과

1
2
3
4
runBlocking: main @coroutine#1
Hello,
launch: main @coroutine#2
World!

runBlocking이 끝나고 launch가 수행되므로 예상대로 위의 출력 결과를 얻을 수 있다.

이번엔 launch 블록에도 delay를 추가해보자.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        println("launch: ${Thread.currentThread().name}")
        delay(100L)
        println("World!")
    }
    println("runBlocking: ${Thread.currentThread().name}")
    delay(500L)
    println("Hello, ")
}

출력 결과

1
2
3
4
runBlocking: main @coroutine#1
launch: main @coroutine#2
World!
Hello,

첫 번째 delay(500L)을 만나 메인스레드에 launch 코루틴이 가져간다.

이후 delay(100L)을 만나게 되지만 0.5초보다 지연이 적으므로, 수행이 완료된 후 runBlocking의 나머지 코드를 수행하게 된다.

6. Thread.Sleep() in Coroutines

코루틴 블록 안에서 delay가 아닌 Thread.Sleep()을 호출하면 동일한 결과가 나올까?

답은 “아니다” 이다.

delay는 스레드를 양보하지만, Thread.Sleep()은 스레드를 점유한 채로 지연하기 때문이다.

아래 예제를 통해 비교해보자.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        println("launch: ${Thread.currentThread().name}")
        println("World!")
    }
    println("runBlocking: ${Thread.currentThread().name}")
    Thread.sleep(500)
    println("Hello")
}

출력 결과

1
2
3
4
runBlocking: main @coroutine#1
Hello
launch: main @coroutine#2
World!

Thread.Sleep()은 스레드를 점유하기때문에 runBlocking의 코드 수행이 먼저 되고 launch 블록이 수행되었음을 알 수 있다.

다시 delay로 교체해보자.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        println("launch: ${Thread.currentThread().name}")
        println("World!")
    }
    println("runBlocking: ${Thread.currentThread().name}")
    delay(500L)
    println("Hello")
}

출력 결과

1
2
3
4
runBlocking: main @coroutine#1
launch: main @coroutine#2
World!
Hello

이땐 launch 블록이 먼저 수행되고 runBlocking 블록이 마저 수행되었을 알 수 있다.

7. Multiple launch

이제 launch를 이용해 여러 개의 코루틴을 생성하고 각각 다른 delay를 부여해보자.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        println("launch1: ${Thread.currentThread().name}")
        delay(1000L)
        println("3!")
    }
    launch {
        println("launch2: ${Thread.currentThread().name}")
        println("1!")
    }
    println("runBlocking: ${Thread.currentThread().name}")
    delay(500L)
    println("2!")
}

출력 결과

1
2
3
4
5
6
runBlocking: main @coroutine#1
launch1: main @coroutine#2
launch2: main @coroutine#3
1!
2!
3!

먼저 runBlocking 블록이 수행되다가 delay(500L)을 만나 지연되고, 첫 번째 launch 블록을 수행하게 된다.

이후 delay(1000L)을 만나 첫 번째 launch 블록도 지연되고, 두 번째 launch 블록의 1!을 출력한다.

이후 delay(500l)의 지연이 끝나 2!를, 마지막으로 delay(1000L)의 지연도 끝나 3!을 호출하면서 마무리된다.

또한 모두 메인 스레드에서 수행되었음을 확인할 수 있다.

이렇게 delay 등으로 인해 지연되는 포인트 등을 중단점(Suspension point) 라고 한다.

8. Hierarchical structure

본 포스팅의 예제들은 runBlocking 안에 launch 블록을 작성되었다.

코루틴은 계층적 구조를 가지고 있어 runBlocking 블록은 내부의 모든 launch 블록들이 종료될 때까지 종료되지않는다.

즉, 상위 코루틴은 하위 코루틴을 끝까지 기다리는 구조이다.

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        launch {
            println("launch1: ${Thread.currentThread().name}")
            delay(1000L)
            println("3!")
        }
        launch {
            println("launch2: ${Thread.currentThread().name}")
            println("1!")
        }
        println("runBlocking: ${Thread.currentThread().name}")
        delay(500L)
        println("2!")
    }
    print("4!")
}

출력 결과

1
2
3
4
5
6
7
runBlocking: main @coroutine#1
launch1: main @coroutine#2
launch2: main @coroutine#3
1!
2!
3!
4!

runBlocking의 지연보다 더 큰 지연 시간을 첫 번째 launch 블록이 가지고 있지만, 모든 launch가 종료될때까지 기다렸다가 4!가 제일 마지막에 출력됨을 알 수 있다.

9. suspend

코루틴이 계층적 구조를 제공한다고 해서, 모든 처리 로직을 runBlocking 블록 내에서 작성한다면 가독성이 떨어지게 된다.

코루틴의 내부 동작 혹은 일부 동작을 함수로 분리하고 싶은 경우 suspend 키워드를 사용할 수 있다.

suspend는 중단 가능하단 뜻으로, 코루틴 내에서 호출하거나 다른 코루틴을 호출할 수 있게 해준다.

import kotlinx.coroutines.*

suspend fun doThree() {
    println("launch1: ${Thread.currentThread().name}")
    delay(1000L)
    println("3!")
}

suspend fun doOne() {
    println("launch1: ${Thread.currentThread().name}")
    println("1!")
}

suspend fun doTwo() {
    println("runBlocking: ${Thread.currentThread().name}")
    delay(500L)
    println("2!")
}

fun main() = runBlocking {
    launch {
        doThree()
    }
    launch {
        doOne()
    }
    doTwo()
}

출력 결과

1
2
3
4
5
6
runBlocking: main @coroutine#1
launch1: main @coroutine#2
launch1: main @coroutine#3
1!
2!
3!

doOne() 함수의 경우 코루틴 내에서만 호출 가능한 delay 함수를 쓰지않았기때문에 suspend를 붙이지 않아도 호출할 수 있다.

반대로 아래와 같이 delay 함수에 suspend가 붙어있기에 suspend 혹은 코루틴 내에서만 호출할 수 있는 것이다.

1
2
3
4
public suspend fun delay(time: Long) {
if (time <= 0) return // don't delay
return suspendCancellableCoroutine { scheduleResumeAfterDelay(time, it) }
}

외부로 분리할 수 있음을 알고나서 한 가지 더 확인해봐야할 것이 있다.

바로 runBlocking 블럭에 <>를 이용해 반환 타입을 아래와 같이 지정할 수 있다.

기본값은 Unit이며, 반환값이 없다는 뜻이기에 상술한 예제들에서 별도의 타입을 명시하지않았다.

1
2
3
4
5
6
7
8
9
fun main() = runBlocking<Unit> {
launch {
doThree()
}
launch {
doOne()
}
doTwo()
}

만약 Unit 대신 Int로 변경하고 반환값을 지정하는 경우 오류가 발생하는데, 코틀린의 main 함수는 무조건 Unit을 반환해야하기 때문이다.

10. Use builder within scope

suspend 키워드를 가진 함수를 main에서 호출해보도록 하자.

import kotlinx.coroutines.*

suspend fun doSomething() {
    launch {
        println("launch: ${Thread.currentThread().name}")
        delay(100L)
    }
}

fun main() = runBlocking {
    doSomething()
}

출력 결과

1
2
Unresolved reference: launch
Suspension functions can be called only within coroutine body

위의 예제는 출력 결과와 같은 오류 메시지를 출력하며 동작하지않는다.

하나씩 살펴보자.

1
Unresolved reference: launch

suspend 키워드는 “중단점 역할을 할 수 있다”를 의미하는 것이기 코루틴 스코프를 생성해주는 키워드가 아니다.

launch를 통해 새로운 코루틴을 만들기 위해 코루틴 안에서 실행되어야 함을 명심하자.

1
Suspension functions can be called only within coroutine body

이 오류는 delay로 인해 발생하였다. delay 또한 suspend 키워드를 가지고 있기에 코루틴안에서 호출해야 한다.

이를 해결하려면 어떻게 해야할까? 답은 간단하다.

doSomething() 함수를 코루틴으로 만들어주면 되는 것이다.

이때 coroutineScope 함수를 사용해 코루틴으로 만들어줄 수 있다.

import kotlinx.coroutines.*

suspend fun doSomething() = coroutineScope {
    launch {
        println("launch: ${Thread.currentThread().name}")
        delay(100L)
    }
    println("coroutineScope")
    println("coroutineScope: ${Thread.currentThread().name}")
}

fun main() = runBlocking {
    doSomething()
}

출력 결과

1
2
3
coroutineScope
coroutineScope: main @coroutine#1
launch: main @coroutine#2

coroutineScope를 통해 코루틴을 부여하였으니, 내부에서 launch를 호출할때마다 새로운 코루틴을 만들게 되었다.

coroutineScope으로 처리하게 되면 반환값이 필요해지므로 println을 추가해 Unit 타입으로 만들어주었다.

언뜻 보기엔 runBlocking과 별반 차이가 없어보이지만 Blocking이 없다는 것에 주목하자.

coroutineScope는 현재 스레드를 멈추게 하지않고 호출한 쪽이 suspend 되고, 지연 혹은 수행이 완료되면 다시 이어서 수행하게 된다.

References