Kotlin, 코루틴 제대로 이해하기 - (2)

2022. 9. 13. 23:04Spring/Kotlin

kotlin의 Coroutine을 이해하는 것이 해당 포스팅의 목표입니다.

🔗 Kotlin 시리즈 모아보기

 

사실, 순서대로라면 Class에 대한 내용을 다뤄야하는데,

추석이 끝나고 마음이 급해져서 코루틴이라도 파보자는 심정으로,,, 정리했습니다 😌

Kotlin in action, Kotlinland official, Kotlin coroutines (TaeHwan) 을 종합적으로 정리한 내용입니다.

 


 

Coroutines VS Async

async/await

kotlinx.coroutines.CoroutineScope.async

 

- async : 코드 블락을 정의

- await : 코드 블락의 종료를 대기

- async(/* Thread type 지정 */) : launch와 동일하며, 지정하지 않으면 상위 scope thread type을 따른다.

- 1개 이상의 coroutine 동기화가 필요한 경우 유용하게 사용할 수 있다.

 

async는 사실상 launch와 같은 일을 한다.

유일한 차이는 launch가 Job을 반환하는 반면 async는 Deffered를 반환한다는 점뿐이다.

Deffered는 Job을 상속한 클래스이기 때문에 launch 대신 async를 사용해도 항상 아무 문제가 없다.

실제로 두 함수의 구현을 보면 다음 코드와 같다.

.launch(..): Job { ... }
.async(..): Differed {}

 

 

Deffered vs Job

- Job은 아무 타입 파라미터가 없는데 Deffered 타입 파라미터가 있는 제네릭 타입

- Deffered 안에는 await() 함수가 정의되어 있다는 점

 

 

둘의 정의는 아래와 같이 요약하여 확인할 수 있다.
public interface Deferred<out T> : Job { ... }
public interface Job : CoroutineContext.Element { ... }

 

Defferred의 타입 파라미터는 코루틴이 계산을 하고 돌려주는 값의 타입이다.

Job은 Unit을 돌려주는 Defferred<Unit>이라고 생각할 수도 있을 것이다.

따라서 async는 코드 블록을 비동기로 실행할 수 있고,

async가 반환하는 Deffered의 await을 사용해서 코루틴이 결과 값을 내놓을 때까지 기다렸다가 결과 값을 얻어낼 수 있다.

제공하는 코루틴 컨텍스트에 따라 여러 스레드를 사용하거나 한 스레드 안에서 제어만 왔다 갔다 할 수도 있다

 

다음은 1부터 3까지 수를 더하는 과정을 async/await을 사용해 처리하는 모습을 보여준다.

 

fun sumAll() {
    runBlocking {
        val d1 = async { delay(1000L); 1 }
        log("after async(dl)")
        val d2 = async { delay(2000L); 2 }
        log(" after async(d2)")
        val d3 = async { delay(3000L); 3 }
        log("after async(d3)")

        log("1+2+3 = ${dl.await() + d2.await() + d3.await()}")
        log ("after await all & add")
    }
} // 5sec 136ms

fun now() = ZonedDateTime.now().toLocalTime().truncatedTo(ChronoUnit.SECONDS)
fun log(msg: String) = println("${now()}:${Thread.currentThread()}:${msg}")

 

22:45:25.208:Thread[main @coroutine#1,5,main]:after async(d1)
22:45:25.232:Thread[main @coroutine#1,5,main]: after async(d2)
22:45:25.232:Thread[main @coroutine#1,5,main]:after async(d3)
22:45:28.247:Thread[main @coroutine#1,5,main]:1+2+3 = 6
22:45:28.247:Thread[main @coroutine#1,5,main]:after await all & add

 

잘 살펴보면 d1, d2 ,d3를 하나하나 순서대로 실행하면 총 6초(6000밀리초) 이상이 걸리지만,

병렬 처리에서 이런 경우를 직렬화해 실행한다고 말한다

실제로 총 3초가 걸렸음을 알 수 있다. 

 

또한 async로 코드를 실행하는 데는 시간이 거의 걸리지 않았다. 

이 예제에서는 겨우 3개의 비동기 코드만을 실행했지만,

비동기 코드가 늘어날 수록 async/await을 사용한 비동기의 성능은 굉장히 뛰어날 것이다.

 

스레드를 여럿 사용하는 병렬 처리와 달리, 모든 async 함수들이 메인 스레드 안에서 실행됨을 볼 수 있다. 

이 부분이 async/await과 스레드를 사용한 병렬 처리의 큰 차이이다.

 

실행하려는 작업이 시간이 얼마 걸리지 않거나 I/O에 의한 대기 시간이 크고,

CPU 코어 수가 작아 동시에 실행할 수 있는 스레드 개수가 한정된 경우에는

특히 코루틴과 일반 스레드를 사용한 비동기 처리 사이에 차이가 커진다.

 

 

참고로 launch를 사용한 코드는 아래와 같고, async와 동일한 효과를 볼 수 있다.

 

fun sumAllLaunch() = run {
    var d1 = 0
    var d2 = 0
    var d3 = 0

    runBlocking {
        launch { delay(1000L); d1 = 1 }
        log("after launch(dl)")
        launch { delay(2000L); d2 = 2 }
        log("after launch(d2)")
        launch { delay(3000L); d3 = 3 }
        log("after launch(d3)")
    }

    log("1+2+3 = ${d1 + d2 + d3}")
    log("after await all & add")
}

 

22:45:28.263:Thread[main @coroutine#5,5,main]:after launch(d1)
22:45:28.264:Thread[main @coroutine#5,5,main]:after launch(d2)
22:45:28.265:Thread[main @coroutine#5,5,main]:after launch(d3)
22:45:31.267:Thread[main,5,main]:1+2+3 = 6
22:45:31.267:Thread[main,5,main]:after await all & add

 

 

 

Coroutine Context

: 확장 함수 내부에서 사용하기 위한 매개체 역할

 

launch, async 등은 모두 CoroutineScope의 확장 함수다. 

그런데 CoroutineScope 에는 CoroutineContext 타입의 필드 하나만 들어있다. 

사실 CoroutineScopeCoroutineContext 필드를 launch 등의 확장 함수 내부에서 사용하기 위한 매개체 역할만을 담당한다. 

원한다면 launch 등에 CoroutineContext를 넘길 수도 있다는 점에서 실제로 CoroutineScope보다 CoroutineContext가 코루틴 실행에 더 중요한 의미가 있음을 유추할 수 있을 것이다.

 

 

Dispatchers

: 코루틴을 실행하는 Thread 타입을 지정

Main thread, Work thread 등등 


CoroutineContext는 실제로 코루틴이 실행 중인 여러 작업(Job 타입)과 디스패처를 저장하는 일종의 맵이라 할 수 있다.

코틀린 런타임은 CoroutineContext를 사용해서 다음에 실행할 작업을 선정하고,

어떻게 스레드에 배정할지 대한 방법을 결정한다. 

 

fun dispatcherTest() = runBlocking {
    launch { // context of the parent, main runBlocking coroutine
        println("main runBlocking : I'm working in thread '${Thread.currentThread().name}'")
    }
    launch(Dispatchers.Unconfined) { // 특정 스레드에 종속되지 않음. 메인 스레드 사용
        println("Unconfined : I’m working in thread '${Thread.currentThread().name}'")
    }
    launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher
        println("Default : I'm working in thread '${Thread.currentThread().name}'")
    }
    launch(newSingleThreadContext("MyOwnThread")) { // will get its own new thread
        println("newSingleThreadContext: I'm working in thread '${Thread.currentThread().name}'")
    }
}

 

Unconfined : I’m working in thread main @coroutine#3
Default : I'm working in thread 'DefaultDispatcher-worker-1 @coroutine#4'
main runBlocking : I'm working in thread 'main @coroutine#2'
newSingleThreadContext: I'm working in thread 'MyOwnThread @coroutine#5'

 

같은 launch를 사용하더라도 전달하는 컨텍스트에 따라 서로 다른 스레드상에서 코루틴이 실행됨을 알 수 있다.

 

 

 

 

📌 @file Annotation

참고로, asynclaunchBuilders.common.kt 파일에 정의되어 있는데,
Builders.common.kt 는 아래의 두 어노테이션을 정의한다.

@file:JvmMultifileClass     ... (1)
@file:JvmName("BuildersKt")     ... (2)

(2) 코틀린 컴파일러가 JVM 컴파일 시 class 파일의 이름을 BuildersKt로 지정하며,
(1)은 지정한 class 파일명이 여러개일 수 있는데, 여러 파일을 같은 class 명으로 컴파일 할 수 있다는 의미이다.

참고로 다음과 같은 어노테이션을 가질 수 있다.
@file:JvmMultifileClass: JAVA에서 호출되는 Kotlin의 함수, 변수, 파일명을 Renamed(변경)
@file:JvmStatic : static 변수의 Getter/Setter 함수를 자동으로 생성하라는 애노테이션, 즉 static Getter/Setter 
@file:JvmField : Getter/Setter를 자동으로 생성하지 말라는 애노테이션
@file:Throws : 해당 코틀린 함수가 예외를 던질 수 있다는 의미의 애노테이션
@file:JvmOverloads : 인자의 기본값(Default Value)이 없는 Java를 위해, 오버로딩 메서드를 자동으로 생성하라는 애노테이션

 

 

 

Coroutine Builder

지금까지 살펴본 launch나 async, runBlocking은 모두 코루틴을 만들어주는 함수로, 코루틴 빌더라고 불린다. 

kotlinx-coroutines-core 모듈이 제공하는 코루틴 빌더를 추가로 두 개 - produce, actor - 만 더 확인해보자.

 

produce

🔗 kotlin.github.io: produce

produce를 통해 ReceiveChannel에 데이터를 send 할 수 있다

일정시간, 일정 이벤트를 ReceiveChannel로 전송할 수 있다.


정해진 채널로 데이터를 스트림으로 보내는 코루틴을 만든다.

이 함수는 ReceiveChannel<>을 반환한다. 그 채널로부터 메시지를 전달받아 사용할 수 있다. 

 

 

Actor

🔗 kotlin.github.io: actor

- actor에 메시지를 전송하고, 이를 처리할 수 있다.
- actor<T> {} 형태로 데이터를 보낸다
  - send(coroutine scope)
  - offer(no coroutine scope)

 

actor 정해진 채널로 메시지를 받아 처리하는 액터를 코루틴으로 만든다.

이 함수가 반환하는 SendChannel<> 채널의 send() 메서드를 통해 액터에게 메시지를 보낼 수 있다.

 

 

Suspending functions

delay()와 yield()는 코루틴 안에서 특별한 의미를 지니는 함수들이다.

이런 함수를 일시 중단suspending 함수라고 부른다.

예제에서 살펴본 delay ()와 yield() 외에 아래 함수들이 kotlinx-coroutines-core 모듈의 최상위에 정의된 일시 중단 함수들이다

 

- withContext: 다른 컨텍스트로 코루틴을 전환한다.
- withTixneout: 코루틴이 정해진 시간 안에 실행되지 않으면 예외를 발생시키게 한다.
- withTimeoutOrNull: 코루틴이 정해진 시간 안에 실행되지 않으면 null을 결과로 돌려준다.
- awaitAll: 모든 작업의 성공을 기다린다. 작업 중 어느 하나가 예외로 실패하면 awaitAll도 그 예외로 실패한다.
- joinAll:  모든 작업이 끝날 때까지 현재 작업을 일시 중단시킨다

 

 

Channel

channel은 지정한 버퍼 사이즈를 가지는 channel을 생성한다.

기본값은 버퍼사이즈가 없다.

 

fun channelTest() = runBlocking {
    val channel = Channel<Int>()
    launch {
        // this might be heavy CPU-consuming computation or async logic, we'll just send five squares
        for (x in 1..5) channel.send(x + x)
    }  // here we print five received integers:
    repeat(5) { print("${channel.receive()} ") }
    println("Done!")
}

 

2 4 6 8 10 Done! 

 

 

 

 

| Ref |

https://speakerdeck.com/taehwandev/kotlin-coroutines