본문 바로가기
Kotlin/Coroutine

(Coroutine) 1. Coroutine Basic

by jaesungLeee 2022. 9. 12.

1. Simple Coroutine

코루틴을 만드는 함수는 코루틴 빌더라고 부른다.

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

runBlocking 함수는 코루틴을 만들고, 블록 수행이 끝날 때 까지 다음 코드를 수행하지 못하게 막는 코루틴 빌더이다.

runBlocking 함수는 반환 값을 지정해줄 수 있다. 이때, 기본 반환타입은 Unit이다.

 

fun test() = runBlocking<Int> {  // 반환 타입 : Int
    ...
    3  // 최종 반환 값
}

 

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

runBlocking 블록 안에서 this를 출력했을 때 결과를 살펴보자.

 

fun main() = runBlocking {
    println(this)
}

>> "coroutine#1":BlockingCoroutine{Active}@3fa77460

 

위 예제를 통해 코루틴은 수신 객체임을 알 수 있다. 

여기서 Active는 출력될 때 해당 코루틴이 활성화(Active) 상태임을 뜻한다.

또한, BlockingCoroutineCoroutineScope 인터페이스의 구현체이다.

즉, CoroutineScope 인터페이스는 CoroutineContext를 갖고 모든 코루틴에 위치하기 때문에, 코루틴을 사용하는 모든 곳에는 CoroutineScope가 있다고 볼 수 있다.

코루틴의 시작은 CoroutineScope이다.

 

아래 코드를 통해 확인할 수 있다.

 

// CoroutineScope.kt
public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

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


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

 

3. CoroutineContext

앞서 언급한 것 처럼 CoroutineScopeCoroutineContext를 갖는다.

 

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

 

또한, runBlocking 빌더는 코루틴을 수신 객체로 갖기 떄문에, 블록 내에서 코루틴 객체안에 있는 것 처럼 코드를 작성할 수 있다.

따라서 CoroutineScope의 멤버인 CoroutineContext를 출력할 수 있다.

 

아래 예시를 살펴보자.

 

fun main() = runBlocking {
    println(coroutineContext)
}

>> [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@1ed6993a, BlockingEventLoop@7e32c033]

 

CoroutineId(1)은 코루틴의 ID를 의미한다.

"coroutine#1":BlockingCoroutine~~ 는 현재 출력된 코루틴의 정보를 의미한다.

BlockingEventLoop@~~ 는 현재 코루틴이 출력된 쓰레드를 의미한다.

 

4. launch

코루틴 빌더로 runBlocking 말고도 launch를 사용할 수 있다.

runBlocking은 블록 수행이 끝날 때 까지 다른 코드의 수행을 막는다고 알고있다.

반면, launch는 스코프 내에 새로운 코루틴을 만들어 다른 코드도 함께 수행할 수 있도록 한다.

 

아래 예시를 살펴보자.

 

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

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

 

출력 결과를 보면, runBlocking이 먼저 수행이 된 후 launch가 수행 됨을 알 수 있다.

그 이유는, runBlocking이 수행될 때, 다른 코드가 수행되지 못하도록 쓰레드를 점유하고 있기 때문에 launch는 대기 중이라고 볼 수 있다.

마찬가지로, runBlockinglaunch 블록의 수행이 마무리될 때 까지 대기해야 한다.

 

5. delay

일반적으로 Java에서는 지연을 위해 Thread.sleep()을 사용한다.

반면, 코루틴에서는 지연을 위해 delay를 사용할 수 있다.

 

delay를 사용하게 되면 점유중인 쓰레드를 잠시 다른 코루틴에게 양보하게 된다.

즉, delay가 호출된 코루틴이 지연되는 동안 다른 코루틴이 쓰레드를 점유하여 사용하게 된다.

이렇게, 다른 코루틴에게 쓰레드를 양보하는 지점을 suspension point (중단점) 이라고 부른다.

뒤에 자세히 설명하겠지만 이러한 suspension point는 코루틴이거나 suspend 함수 내부에서만 호출할 수 있다.

 

아래 예시를 살펴보자.

 

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

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

 

위 예시에서도 runBlocking이 먼저 수행된다.

하지만, delay를 만나면서 점유중인 쓰레드를 launch 블록에 양보하게 된다.

runBlocking은 500ms 이후 다시 쓰레드를 점유하여 수행을 재개하게 된다.

runBlockinglaunch 모두 main 쓰레드를 사용하기 때문에 앞서 설명한 것처럼 쓰레드 점유를 해제하고 양보하는 것을 직접 확인할 수 있다.

 

다음 예시를 살펴보자.

 

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

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

 

앞서 살펴본 예시와 유사하지만 이번에는 launch 블록 안에도 delay가 있다.

delay(500L) 시점에 launch 블록이 쓰레드를 점유할 것이다.

이후, launch 블록에서 delay(100L)을 만나게 되지만 runBlocking이 대기하는 500ms보다 더 짧은 지연이기 때문에 launch 블록은 그대로 수행된다.

 

launch 블록이 그대로 수행되었다고 해서 delay가 안된 것은 아니다.

delay(100L)을 통해 launch 블록의 코루틴도 쓰레드 점유를 해제하지만 다른 코루틴인 runBlocking의 지연이 더 길기 때문에 launch 블록이 그대로 수행된 것이다.

 

아래 예시의 출력 결과를 예상할 수 있다면 delay를 통한 쓰레드 양보에 대해 완벽히 이해할 수 있을 것이다.

 

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

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

 

6. 코루틴 내부에서의 sleep

앞서 설명한 것 처럼, Java에서는 Thread.sleep()을 통해 지연을 할 수 있다.

그렇다면, 코루틴 블록 안에서 delay 대신 Thread.sleep()을 사용하여 비슷한 효과를 얻을 수 있을까?

그렇지 않다.

 

delayThread.sleep()은 엄연히 다른 개념이다.

코루틴과 쓰레드를 혼동하면 안되듯이, Thread.sleep()은 쓰레드 상에서의 지연이다. 

즉, 실행중인 쓰레드를 잠시 멈춘다는 의미이다.

 

delay는 쓰레드를 점유하지 않고 다른 코루틴에 양보한다고 설명하였다.

반면, Thread.sleep()은 쓰레드를 그대로 점유한 상태로 지연되기 때문에 다른 코루틴에게 쓰레드를 양보하지 않는다.

 

아래 예시의 결과를 통해 확인할 수 있다.

 

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

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

 

7. 한번에 여러 launch

바로 예시를 살펴보자.

 

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!")
}

>> runBlocking: main @coroutine#1
>> launch1: main @coroutine#2
>> launch2: main @coroutine#3
>> 1!
>> 2!
>> 3!

 

위 예시가 컴파일되면서 첫번째 launch 블록과 두번째 launch 블록은 작업 Queue에 예약된다.

runBlocking 블록이 수행되다가 delay(500L) 지점에서 첫번째 launch 블록이 수행될 것이다.

이후, delay(1000L)을 만나 중단되고 두번째 launch 블록의 1!이 수행된다.

500ms의 지연이 지난 후 2!, 1000ms의 지연이 지난 후 3!이 수행되고 함수는 종료된다.

 

위 예시를 통해 알 수 있는 것은 코루틴은 delay를 만날 때 마다 suspension point가 되어 다른 코루틴에게 쓰레드를 양보한다는 것이다.

 

8. 코루틴의 계층적 구조 (Hierarchical Structure)

코루틴은 계층적 구조를 갖는다.

이 말은 상위 코루틴은 하위 코루틴을 끝까지 책임진다는 의미이다.

앞선 예시들에서 runBlocking 블록들은 내부에 launch 블록이 종료될 때 까지 종료되지 않았다.

계층적 구조를 갖기 때문이다.

 

아래 예시를 살펴보자.

 

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!")
    }

    println("4!")
}

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

 

위 예시에서 생성되는 코루틴은 3개이다.

runBlocking을 통해 생성되는 코루틴과 두 개의 launch를 통해 생성되는 코루틴이다.

runBlocking의 코루틴은 자식 코루틴 2개를 가질 것이다.

결과에서도 볼 수 있듯이 runBlocking2!가 호출되고 나서도 종료되지 않았다.

즉, 자식 코루틴의 일이 끝나지 않으면 부모 코루틴은 종료되지 않는다.

따라서, 모든 launch가 종료될 때 runBlocking도 종료되고 최종적으로 4!가 호출됨을 알 수 있다.

 

9. suspend를 통한 로직 분리

코루틴이 계층적 구조를 갖는다고 해서 모든 로직을 runBlocking 내부에 작성한다면 분명히 가독성이 떨어질 것이다.

runBlocking의 일부 동작을 함수로 분리하려 한다면 suspend 키워드를 사용할 수 있다.

suspend는 중단 가능한 함수이며 suspension point이다. 

즉, 해당 코루틴 내부에서 다른 코루틴이나 suspend 함수를 호출할 수 있게 한다.

 

아래 예시를 살펴보자.

 

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

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()
}

 

바로 이전에 살펴본 예시와 동일한 출력을 보일 것이다.

이 예시에서 중요한 점은 suspend 함수는 코루틴 내에서 호출 가능하다는 점이다. 

 

doOne은 다른 함수들과는 달리 suspend 키워드가 붙지 않았다.

그 이유는, 코루틴 내부에서만 호출 가능한 delay가 쓰이지 않았기 때문이다.

 

delay의 내부 구현을 보면 suspend가 붙어있다.

즉, 앞서 살펴본 것 처럼 delaysuspend 또는 코루틴 내부에서만 호출될 수 있다는 것이다.

 

public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
        // if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
        if (timeMillis < Long.MAX_VALUE) {
            cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
        }
    }
}

 

10. suspend 함수에서 코루틴 빌더 호출

/* 동작하지 않는 코드 */
suspend fun doOneTwoThree() {
    launch {
        println("launch1: ${Thread.currentThread().name}")
        delay(1000L)    // suspension point
        println("3!")
    }

    launch {
        println("launch2: ${Thread.currentThread().name}")
        println("1!")
    }

    launch {
        println("launch3: ${Thread.currentThread().name}")
        delay(500L)    // suspension point
        println("2!")
    }
    println("4!")
}

fun main() = runBlocking {    // this : 코루틴
    doOneTwoThree()    // suspension point
    println("runBlocking: ${Thread.currentThread().name}")
    println("5!")
}

 

위 예시는 실제로 동작하지는 않고 두 가지 에러를 발생시킨다.

 

Unresolved reference: launch

launch는 새로운 코루틴을 만들기 위해 반드시 코루틴 내부에서 실행되어야 한다.

suspend 함수는 단순히 suspension point임을 나타내는 것이지 이 자체가 코루틴 스코프를 만드는 것은 아니다.

 

Suspension functions can be called only within coroutine body

delay는 위에서 살펴봤듯이 이 자체가 suspend 함수이기 때문에 코루틴 안에서 호출되어야 한다.

현재 launch 블록은 새로운 코루틴을 만들지 못하기 때문에 delay에서 위 에러가 발생한 것이다.

 

이를 해결하기 위해 doOneTwoThree() 함수를 코루틴으로 만들어 준다.

이를 위해 coroutineScope 함수를 사용하여 코루틴으로 만들 수 있다.

 

11. 코루틴 스코프

아래 예시를 살펴보자.

 

suspend fun doOneTwoThree() = coroutineScope {    // this : 코루틴
    this.launch {    // this : 코루틴
        println("launch1: ${Thread.currentThread().name}")
        delay(1000L)
        println("3!")
    }

    launch {    // this : 코루틴
        println("launch2: ${Thread.currentThread().name}")
        println("1!")
    }

    launch {    // this : 코루틴
        println("launch3: ${Thread.currentThread().name}")
        delay(500L)
        println("2!")
    }
    println("4!")
}

fun main() = runBlocking {    // this : 코루틴
    doOneTwoThree()
    println("runBlocking: ${Thread.currentThread().name}")
    println("5!")
}

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

 

coroutineScope를 통해 suspend 함수 내에 코루틴을 만들 수 있고, 마찬가지로 launch 블록도 수행할 수 있게 되었다.

coroutineScope도 빌더들과 마찬가지로 수신 객체로 코루틴을 갖기 때문에 this.launch 형태의 사용이 가능하다.

 

이 예제를 통해 이번 포스팅에서 살펴본 코루틴의 기본 개념을 정리할 수 있을 것이다.

 

Reference

https://kotlinlang.org/docs/coroutines-overview.html

 

Coroutines | Kotlin

 

kotlinlang.org

 

'Kotlin > Coroutine' 카테고리의 다른 글

(Coroutine) 5. Scope  (0) 2022.11.01
(Coroutine) 4. CoroutineContext, Dispatcher  (0) 2022.09.14
(Coroutine) 3. async  (0) 2022.09.14
(Coroutine) 2. Light weight Thread, Job  (0) 2022.09.12
(Coroutine) 0. 개요 및 특징  (0) 2021.12.20