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

2022. 9. 12. 23:59Spring/Kotlin

반응형

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

🔗 Kotlin 시리즈 모아보기

 

 

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

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

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

이번 달 내에 Kotlin 부실 수 있을까요..? 🥲

 

 

 

 


Coroutine

🔗 Kotlinlang official

A coroutine is an instance of suspendable computation. It is conceptually similar to a thread, in the sense that it takes a block of code to run that works concurrently with the rest of the code. However, a coroutine is not bound to any particular thread. It may suspend its execution in one thread and resume in another one.

 

코루틴은 일시 중단 가능한 계산의 하나의 인스턴스이다. 코드 블록을 다른 나머지 코드들과 동시에 실행한다는 점에서 스레드와 개념적으로 유사하다. 하지만, 코루틴은 특정 스레드에 한정되지 않다. 한 스레드에서 실행을 일시 중단하고 다른 스레드에서 다시 시작할 수 있다.

 

일반 함수와 비교하여 코루틴에 대한 이해를 더해보자.

 

 

일반 함수 VS Coroutine

📌 일반 함수

- 호출한 곳으로 돌아오기 위해서는 return이 필요하다.
- return 이전에는 메인 함수의 다음 라인을 실행하지 않는다.
- 일반적인 함수를 서브루틴Subroutine이라 한다.

- 서브루틴은 하나의 단위로 패키징된 특정 업무를 수행하는 프로그램 지침instructions의 연속sequence이다.

 

 

📌 Coroutine

코루틴을 정의한 위키피디아의 내용을 정리해보면 아래와 같다.

 

- 프로그램의 구성 요소 중 하나(computer-program components)

- 비선점형 멀티태스킹(non-preemptive multasking): 먼저 수행될, 혹은 먼저 예약된 실행의 존재에 상관없이 실행된다.

- 일반화한 서브루틴(subroutine): 메인이 되는 실행의 부분이 되는 작은 실행

 

여러 개의 코루틴을 실행할 수 있으며, 각각의 실행에 대해 일시적인 중단이 가능하며 중단한 실행을 다시 재개시킬 수 있다.

코루틴의 키워드를 정리하자면 아래와 같다.

 

✔️ Entry point 여러 개 허용하는 subroutine
✔️ 언제든 일시 정지, 실행 가능

 

코루틴은 경량 스레드라고 생각할 수 있지만, 실제 사용 시 스레드와 굉장히 중요한 다른 차이점들이 있다.

코루틴의 샘플 코드로 구조를 파악해보자.

 

fun main() = runBlocking { // this: CoroutineScope
    launch { // launch a new coroutine and continue
        delay(1000L) // non-blocking delay for 1 second (default time unit is ms)
        println("World!") // print after delay
    }
    println("Hello") // main coroutine continues while a previous one is delayed
}

 

Hello
World!

 

# launch { ... }

launch 코루틴 빌더로 나머지 코드와 동시 실행을 위해 새로운 코루틴을 생성하며 작업을 독립적으로 진행한다.

떄문에 Hello가 먼저 출력되었다.

 

# delay()

delay 는 특별한 일시 정지 기능으로, 특정 시간동안 코루틴을 일시정지 시킨다.

코루틴을 일시 중단하면 기본 스레드가 차단되지 않지만, 다른 코루틴이 코드를 위해 기본 스레드를 실행하고 사용할 수 있다.

 

# runBlocking { ... }

runBlocking 도 코루틴 빌더로 fun main()의 코루틴이 아닌 영역과 runBlocking 중괄호 내의 코루틴 코드를 연결한다. 

IDE는 this: CoroutineScope라는 힌트로 하이라이트 표시된다.

 

그래서 runBlocking을 안적거나 까먹으면 Unresolved reference: launch 라는 오류가 발생한다.

 

runBlocking의 이름은 runBlocking {...} 내부의 모든 코루틴이 실행을 완료할 때까지 이 스레드를 실행하는 스레드(이 경우 주 스레드)가 호출 기간 동안 차단됨을 의미한다. 

애플리케이션의 최상위 레벨에서 이와 같이 사용되는 runBlocking을 종종 볼 수 있고, 실제 코드 내부에서는 거의 볼 수 없다.

스레드는 고가의 리소스이고 스레드를 차단하는 것이 비효율적이며, 종종 바람직하지 않기 때문이다.

 

 

📌 Coroutine Builder
kotlinx.coroutines 패키지에 정의되며, 코루틴을 만들어주는 역할을 한다.
코루틴을 사용하기 위해서는 필수적으로 코루틴 빌더인 launch, runBlocking, async 는 반드시 알아야한다.
코틀린에서는 코루틴 빌더에 원하는 동작을 람다로 넘겨서 코루틴을 만들어 실행하는 방식으로, 코루틴을 활용한다.

 

 

Structured concurrency

코루틴은 structured concurrency의 원칙을 따르는데,

코루틴의 lifetime을 제한하는 특정 CoroutineScope 에서만 새 코루틴을 시작할 수 있음을 의미한다.


위의 예제 코드에서는 runBlocking이 해당 범위를 설정한다는 것을 보여주는데,

메인 스레드가 종료되지 않고 대기하여 World!가 프린트 될 수 있었던 이유이다.

 

실제 애플리케이션에서는 많은 코루틴을 생성할텐데,

구조화된 동시성이 생성된 코루틴이 손실되지 않고 누출되지 않도록 보장한다.

상위 범위에서의 실행은 모든 하위 코루틴이 완료될 때까지 완료할 수 없다.
또한 구조화된 동시성을 통해 코드의 모든 오류가 제대로 보고되고 손실되지 않는다.

 

 

suspend 키워드를 사용하여 최상위 레벨에서 runBlocking 을 적용하여 위의 코드를 리팩터링한 코드이다.

 

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

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

 

 

suspend

✔️ suspend 키워드를 추가하여 함수 분할

suspend 정의한 함수는 사용하기 전까지 동작하지 않으며, CoroutineScope 내에서 만 사용할 수 있다.

 

 

Scope builder

coroutineScope

✔️ 코루틴 정의를 위한 Scope 제공
✔️ CoroutineContext 형태를 지정; Main, Default, IO …
✔️ launch, async 등을 통해 scope 실행 형태 정의

 

여러 빌더가 제공하는 코루틴 범위 외에도 coroutineScope 빌더를 사용하여 자신의 범위를 선언할 수 있다.
코루틴 스코프가 생성되고 실행된 모든 하위 실행이 완료complete될 때까지 완료complete되지 않는다.

runBlocking과 coroutineScope 빌더는 둘 다 바디 내용과 모든 하위 실행이 완료될 때까지 기다리기 때문에 비슷하게 보일 수 있다. 

 

주요 차이점은 runBlocking 메서드는 대기하기 위해 현재 스레드를 차단block하는 반면,

coroutineScope일시 중단suspend되어 다른 용도로 사용되기 위해 코루틴을 실행한underlying 스레드를 해제한다는 것이다.

이 차이 때문에 runBlocking은 정규 함수이고 coroutineScope는 suspending function이다.

 

모든 suspending function에서 coroutine Scope를 사용할 수 있다.

예를 들어 suspend 함수인 fun doWorld() 함수로 이동시켜 Hello와 World를 동시에 출력할 수 있다.

 

fun coroutineScopeEx() = runBlocking {
    suspendDoWorld()
}

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

 

 

concurrency

coroutineScope 빌더는 여러 동시 작업을 수행하기 위해 모든 suspending function 내에서 사용될 수 있다.

suspending function인 doWorld 함수 내에서 두 개의 동시 코루틴을 실행할 수 있다.

 

// Sequentially executes doWorld followed by "Done"
fun main() = runBlocking {
    doWorld()
    println("Done")
}

// Concurrently executes both sections
suspend fun doWorld() = coroutineScope { // this: CoroutineScope
    launch {
        delay(2000L)
        println("World 2")
    }
    launch {
        delay(1000L)
        println("World 1")
    }
    println("Hello")
}

 

Hello
World 1
World 2
Done

 

실행 블록  { ... } 안에 있는 두 개의 코드가 동시에 실행되며,

시작부터 1초 후 World 1이 먼저 인쇄되고 시작부터 2초 후 World 2가 출력된다. 

doWorld의 coroutineScope는 둘 다 완료된 후에만 완료된다. 

따라서 DoneWorld는 반환되고 Done 문자열이 출력되도록 한다.

 


GlobalScope

✔️ CoroutineScope을 상속 받아 구현해둔 object 구현체

✔️ GlobalScope으로 시작하며 launch(/* thread type */), actor 등을 활용

 

fun now() = ZonedDateTime.now().toLocalTime().truncatedTo(ChronoUnit.MILLIS)

fun log(msg: String) = println("${now()}:${Thread.currentThread()}:${msg}")

fun launchInGlobalScope() {
    GlobalScope.launch {    // main과 GlobalScope.launch가 만들어낸 코루틴이 서로 다른 스레드에서 실행
        log("coroutine started.")
    }
}

@Test
fun launchTest() {
    log("main () started.")
    launchInGlobalScope()
    log("launchlnGlobalScope() executed")
    Thread.sleep(5000L)
    log("main() terminated")
}

 

00:36:03:Thread[main,5,main]:main () started.
00:36:03.063:Thread[main,5,main]:launchlnGlobalScope() executed
00:36:03.068:Thread[DefaultDispatcher-worker-1 @coroutine#1,5,main]:coroutine started.
00:36:08.071:Thread[main,5,main]:main() terminated

 

GlobalScope는 메인 스레드가 실행 중인 동안만 코루틴의 동작을 보장한다.

Thread.sleep(5000L)을 없애면 코루틴이 아예 실행되지 않을 것이다.

lanuchlnGlobalScope()가 호출한 launch는 스레드가 생성되고 시작되기 전에 메인 스레드의 제어를 main() 에 돌려주기 때문이다.

 

따로 sleep ()을 하지 않으면 main() 이 바로 끝나고, 메인 스레드가 종료되면서 바로 프로그램 전체가 끝나 버린다.

그래서 GlobalScope ()를 사용할 때는 조심해야 한다.

 

 

runBlocking

이를 방지하려면 비동기적으로 launch를 실행하거나, launch가 모두 다 실행될 때까지 기다려야 한다. 

특히 코루틴의 실행이 끝날 때까지 현재 스레드를 블록시키는 함수로 runBlocking() 이 있다.

runBlocking은 CoroutineScope의 확장 함수가 아닌 일반 함수이기 때문에 별도의 코루틴 스코프 객체 없이 사용 가능

 

fun launchInRunBlocking() = runBlocking {
	launch { log("GlobalScope.launch started.") }
}

 

@Test
fun runBlockingLaunchTest() {
    log("main () started.")
    launchInRunBlocking()
    log("launchlnGlobalScope() executed")
    Thread.sleep(5000L)
    log("main() terminated")
}

 

16:34:05.610:Thread[main,5,main]:main () started.
16:34:05.734:Thread[main @coroutine#2,5,main]:GlobalScope.launch started.
16:34:05.734:Thread[main,5,main]:launchlnGlobalScope() executed
16:34:10.740:Thread[main,5,main]:main() terminated

 

한 가지 흥미로운 것은 스레드가 모두 main 스레드라는 점이다.
이 코드만 봐서는 딱히 스레드나 다른 비동기 도구와 다른 장점을 찾아볼 수 없을 것이다. 

 

하지만 코루틴들은 서로 yield()를 해주면서 협력할 수 있다.

fun yieldExample() = runBlocking {
	launch {
		log("1")
		yield()
		log("3")
		yield()
		log("5")
	}
	log("after first launch")
	launch {
		log("2")
		delay(1000L)
		log("4")
		delay(1000L)
		log("6")
	}
	log("after second launch")
}

 

16:37:15.084:Thread[main @coroutine#1,5,main]:after first launch
16:37:15.107:Thread[main @coroutine#1,5,main]:after second launch
16:37:15.109:Thread[main @coroutine#2,5,main]:1
16:37:15.111:Thread[main @coroutine#3,5,main]:2
16:37:15.119:Thread[main @coroutine#2,5,main]:3
16:37:15.119:Thread[main @coroutine#2,5,main]:5
16:37:16.119:Thread[main @coroutine#3,5,main]:4
16:37:17.125:Thread[main @coroutine#3,5,main]:6

 

로그를 보면 다음 특징을 알 수 있다

 

- launch는 즉시 반환된다.
- runBlocking은 내부 코루틴이 모두 끝난 다음에 반환된다.
- delay()를 사용한 코루틴은 그 시간이 지날 때까지 다른 코루틴에게 실행을 양보한다. 앞 코드에서 delay(1000L) 대신 yield() 를 쓰면 차례대로 1, 2, 3, 4, 5, 6이 표시될 것이다. 한 가지 흥미로운 것은 첫 번째 코루틴이 두 번이나 yield()를 했지만 두 번째 코루틴이 delay() 상태에 있었기 때문에 다시 제어 가 첫 번째 코루틴에게 돌아왔다는 사실이다.

 

 

 

 

Job

코루틴 빌더 launch { ... } 는 실행된 코루틴에 핸들인 Job 객체를 반환하며,

Job 객체의 완료를 명시적으로 기다리는 데 사용할 수 있다.

 

- CoroutineScope(/* thread type */)으로 정의하며, launch, actor 등 활용

- launch {}의 return job을 통해 동작 지정 가능

- join() : scope 동작이 끝날 때까지 대기하며, CoroutinScope 안에서 호출 가능

- cancel() : 작업 중인 동작을 종료 유도

- start() : Scope 상태를 확인하며, 아직 시작하지 않았을 경우 start

 

 

 

하위 코루틴이 완료될 때까지 기다린 다음 "Done" 문자열을 출력한다.

 

val job = 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
println("Done")

 

Hello
World!
Done

 

 

Light-weight

코루틴은 JVM 스레드보다 resource-intensive가 낮다. 
스레드를 사용할 때 JVM의 사용 가능한 메모리가 소진되는 코드는 리소스 제한에 도달하지 않고 코루틴을 사용하여 표현할 수 있다. 

예를 들어, 다음 코드는 각각 5초 동안 대기한 다음 마침표('.')를 인쇄하는 100000개의 고유한 코루틴을 실행한다.

 

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

 

동일한 코드를 일반 Thread 로 실행하면 어떻게 될까?

굉장히 무거운 작업으로 인해 OOM이 발생하면서 프로세스가 종료된다.

 

@Test
fun threadMemoryTest() {
    repeat(100_000) { // launch a lot of coroutines
        Thread {
            Thread.sleep(5000L)
            print(".")
        }.start()
    }
}

 

[2.087s][warning][os,thread] Failed to start thread - pthread_create failed (EAGAIN) for attributes: stacksize: 1024k, guardsize: 4k, detached.
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached

Process finished with exit code 1
........................................................................................................................................................................

 

 

dependency

 

코틀린은 특정 코루틴을 언어가 지원하는 형태가 아니라, 코루틴을 구현할 수 있는 기본 도구를 언어가 제공하는 형태이다.

[2022-09] Kotlin에서 coroutine을 사용하기 위해서는 아래와 같은 의존성이 필요하다.

 

Maven

<properties>
    <kotlin.version>1.6.21</kotlin.version>
</properties>

<!-- ... -->

<dependency>
    <groupld>org.jetbrains.kotlinx</groupld> 
    <artifactld>kotlinx-coroutines-core</artifactld>
    <version>1.6.4</version>
</dependency>

 

Gradle

kotlin("jvm") version "1.6.21"
// ...
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
}

 

참고

Android 는 추가적으로 아래를 추가해야한다.

 

// maven
<!-- https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-android -->
<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-android</artifactId>
    <version>1.6.4</version>
    <scope>runtime</scope>
</dependency>

// gradle
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'

 

 

 

 

 

| Ref |

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

반응형

Backend Software Engineer

Gyeongsun Park