Study/kotlin

[코틀린 프로그래밍] Chapter.15 코루틴 탐험하기

에디개발자 2021. 6. 22. 07:00
반응형
ㅏㅁ수나를 닮았다고 한다...

이번 글에서는 코루틴의 기본에 대해 작성하고 코루틴을 순차적으로 또는 동시성으로 실행하는 방법을 알아보고 스레드와 코르틴의 관계를 이해하고 스레드 실행의 제어와 코루틴을 디버깅하는 법을 작성하겠습니다.


코루틴과 동시 실행

코루틴은 모든 경우에 사용되는 것은 아닙니다. 로직에 있어 순차적 로직이 필수적일 경우에는 코루틴은 비효율적입니다. 코루틴의 동시 실행은 병령 실행과 다릅니다. 병렬 실행과 동시실행의 차이점을 명확히 이해하여야합니다. 왜냐하면 머맅 코어 프로세서의 멀티 스레드는 일반적으로 병렬로 실행되고 코루틴은 일반적으로 병렬실행보다는 동시실행에 더 많이 사용됩니다.

병렬 vs 동시성

하나의 예를 들어보겠습니다. 사람1이 사람2에게 말을 하고 있습니다. 사람1은 말을 하고 사람2는 말을 듣습니다. 이 때 사람들은 음식을 먹으며 이야기를 하고 있습니다. 하지만 음식이 입에 있을 땐 말을 할 수 없습니다. 여기서 병렬과 동시성은 아래와 같습니다.

  • 병렬 : 사람(1 )이 먹고 듣는다. 사람(2)이 먹고 듣는다. 이처럼 한 번에 두가지 행위를 하는 것을 뜻한다.
  • 동시성 : 사람(1)이 먹고 말한다. 사람(2)이 먹고 말한다.

함수의 협력, 코루틴

범용프로그래밍에서 코루틴보다 서브루틴이 일반적입니다. 서브루틴은 호출 사이에서 아무런 상태도 관리하지 않습니다. 코루틴 역시 함수입니다. 하지만 서브루틴과는 다르게 작동합니다.

서브루틴이란?? 실행이 완료된 이후에 호출자에게 반환되는 함수

엔트리가 하나인 서브루틴과는 다르게 코루틴은 여러 엔트리를 가지고 있습니다. 또한 호출 사이에 상태를 기억할 수 있고 코루틴을 호출하면 이전 호출에서 중단된 코루틴의 중간으로 들어갈 수 있습니다. 즉 함수들이 연결되어서 작업을 수행합니다.


코루틴을 이용한 동시 실행

코루틴은 서브루틴에서 불가능한 기능을 제공합니다. 무한 시ㅝㄴ스, 이벤트 루프, 협력함수에서 사용 등..

순차적 실행으로 시작하기

fun task1() { println("start task1 in Thread ${Thread.currentThread()}") println("end task1 in Thread ${Thread.currentThread()}") } fun task2() { println("start task2 in Thread ${Thread.currentThread()}") println("end task2 in Thread ${Thread.currentThread()}") } println("start") run { task1() task2() println("called task1 and task2 from ${Thread.currentThread()}") } println("done")

task1과 task2 두 개의 함수는 각각 함수 실행이 시작될 때와 끝날 때의 스레드 정보를 출력합니다. 결과는 아래와 같습니다.

start start task1 in Thread Thread[main,5,main] end task1 in Thread Thread[main,5,main] start task2 in Thread Thread[main,5,main] end task2 in Thread Thread[main,5,main] called task1 and task2 from Thread[main,5,main] done

결과에서 볼 수 있듯이 함수 호출은 순차적으로 일어났습니다.

코루틴 만들기

먼저 의존성을 추가합니다.

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3") // add

위 코드를 코루틴으로 변경해보겠습니다.

fun task1() { println("start task1 in Thread ${Thread.currentThread()}") println("end task1 in Thread ${Thread.currentThread()}") } fun task2() { println("start task2 in Thread ${Thread.currentThread()}") println("end task2 in Thread ${Thread.currentThread()}") } println("start") runBlocking { // 코루틴 task1() task2() println("called task1 and task2 from ${Thread.currentThread()}") } println("done")

위 코드와 다른점은 run 메소드를 runBlocking 메소드로 변경했습니다. 이 차이점은 블록안에 로직을 코루틴으로 실행시키겠다라는 의미입니다. 위 코드를 실행하기 위해서는 코루틴 확장 .jar 파일의 위치를 kotlinc-jvm 명령어에 지정해놔야합니다.

kotlinc-jvm -classpath /Users/limyongtae/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-coroutines-core/1.2.2/6ff48bdfc38a8c22e3fc37605b6a6afaed3b6dbd/kotlinx-coroutines-core-1.2.2.jar -script coroutine

결과를 살펴보겠습니다.

start start task1 in Thread Thread[main,5,main] end task1 in Thread Thread[main,5,main] start task2 in Thread Thread[main,5,main] end task2 in Thread Thread[main,5,main] called task1 and task2 from Thread[main,5,main] done

결과는 코루틴을 적용하지 않은 값과 동일합니다. 코루틴은 코드를 동시에 실행시킵니다. 따라서 호출 이전의 코드와 해당함수 호출 이후 코드 사이에서 인터리브된 메인 스레드에서 실행됩니다.

Task 실행

위 코드에서 task 메소드들을 launch해보겠습니다.

runBlocking { launch { task1() } launch { task2() } println("called task1 and task2 from ${Thread.currentThread()}") } println("done")

launch 메소드는 주어진 람다를 실행시키기 위해서 새로운 코루틴을 시작시킵니다. 또한 launch 메소드는 job을 리턴합니다. job은 완료를 위해 기다리는데 사용되거나 작업을 취소하는데 사용됩니다.

start called task1 and task2 from Thread[main,5,main] start task1 in Thread Thread[main,5,main] end task1 in Thread Thread[main,5,main] start task2 in Thread Thread[main,5,main] end task2 in Thread Thread[main,5,main] done

결과값이 달라졌습니다. called가 먼저 출력되었습니다. 마지막에 선언한 println이 실행되고 task1, task2가 실행된 것을 알 수 있습니다.

서스펜션 포인트(중단점)과 인터리빙 호출

서스펜션 포인트란 현재 실행중인 작업을 중지시키고 다른 작업을 실행시키는 함수를 말합니다. 이런 행동을 위해서 코루틴은 delay()와 yield()를 제공합니다.

  • delay(): 현재 실행중인 작업을 지정된 밀리초만큼 멈추게 한다.
  • yield(): 메소드는 명시적인 지연을 만들지 않습니다.

두 메소드는 모두 대기중인 다른 작업을 실행할 기회를 줍니다.

yield를 사용하면 현재 작업이 더 중요한 작업들의 실행을 기다립니다. task1, task2에 yield를 적용하고 suspend 키워드로 어노테이트된 함수에서만 서스펜션 포인트를 사용할 권한을 줍니다. 그러나 suspend로 만들어진 함수가 자동으로 함수를 코륀에서 실행되거나 동시 실행으로 실행되게 만들지는 않습니다.

suspend fun task1() { println("start task1 in Thread ${Thread.currentThread()}") yield() println("end task1 in Thread ${Thread.currentThread()}") } suspend fun task2() { println("start task2 in Thread ${Thread.currentThread()}") yield() println("end task2 in Thread ${Thread.currentThread()}") } println("start") runBlocking { launch { task1() } launch { task2() } println("called task1 and task2 from ${Thread.currentThread()}") } println("done")

다음으로 결과를 살펴보겠습니다.

start called task1 and task2 from Thread[main,5,main] start task1 in Thread Thread[main,5,main] start task2 in Thread Thread[main,5,main] end task1 in Thread Thread[main,5,main] end task2 in Thread Thread[main,5,main] done

이전과의 차이가 명확하게 나타났습니다. task1()이 첫 번째 라인을 실행하고 실행 흐름을 넘기고 task2()에 들어와서 첫 번째 라인을 실행하고 다시 넘겨줍니다.

yield는 어디에 쓰일까? 작업들이 사용하는 공유자원의 경쟁 때문에 병렬로 실행시킬 수 없는 여러 개의 작업이 있다고 가정!! 작업을 순차적으로 하나씩 시키는 것은 몇몇 작업을 제외하고 다른 작업들은 전부다 자원을 사용하지 못한다! 순차적 실행은 작업이 아주 길거나 끝나지 않는 작업인 경우엔 특히 적합하지 않다. 이럴 경우엔 코루틴을 사용하면 어러개의 작업들이 상호 협력적으로 실행시킬 수 있기 때문에 모든 작업을 안정적으로 진행할 수 있따.

코루틴의 컨텍스트와 스레드

launch 함수와 runBlocking 함수를 호출하면 호출자의 코루틴 스코프와 같은 스레드에서 코루틴을 실행합니다. 왜냐하면 함수들이 함수의 스코프에서 코루틴 컨텍스트를 옮기기 때문입니다. 하지만 코루틴의 실행의 컨텍스트와 스레드를 원하는 곳으로 변경할 수 있습니다.

컨텍스트 명시적 세팅

코루틴컨텍스트를 launch와 runBlocking 함수에 전달해서 이 함수들이 실행시킬 코루틴의 컨텍스트를 설정할 수 있습니다.

coroutineContext 타입의 아규먼트인 Dispatchers.Default 값이 코루틴에게 DefaultDisplatcher 풀의 스레드안에서 실행을 시작하라고 지시합니다. 풀 안의 스레드 숫자는 2개이거나 시스템의 코어 숫자 중 높을 것을 사용합니다.

Dispatchers.IO의 값은 IO 작업을 실행을 위한 풀 안의 코루틴을 실행시키는데 사용될 수 있습니다. 이 풀은 스레드가 IO에 블록될 경우와 작업이 더 생성된 경우 사이즈가 커질 수 있습니다.

Dispatchers.Default를 적용해보겠습니다.

runBlocking { launch(Dispatchers.Default) { task1() } // 적용 launch { task2() } println("called task1 and task2 from ${Thread.currentThread()}") }

task1 메소드는 다른 스레드에서 실행됩니다. task1을 제외한 모든 코드는 main 스레드에서 실행됩니다. 아래는 결과값입니다.

start start task1 in Thread Thread[DefaultDispatcher-worker-1,5,main] // no main thread end task1 in Thread Thread[DefaultDispatcher-worker-3,5,main] // no main thread called task1 and task2 from Thread[main,5,main] start task2 in Thread Thread[main,5,main] end task2 in Thread Thread[main,5,main] done

커스텀 풀에서 실행시키기

코루틴을 싱글 스레드 풀에서 실행시킬 수 있습니다. 풀 안에 싱글 스레드가 있기 때문에 이 컨텍스트를 사용하는 코루틴은 병렬 실행이 아닌 동시 실행으로 진행될 것입니다. 이 옵션은 작업을 코루틴으로 실행시킬 때 작업들 간에 자원 경쟁에 대해서 고려할 때 사용할 수 있습니다.

싱글 스레드 풀 컨텍스트를 설정하기 위해 실행자를 만듭니다. java.util.concurrent 패키지의 JDK Executors 컨쿼런시 API를 사용합니다. 그리고 asCoroutineDispatcher() 확장함수를 이용하여 실행자로부터 CoroutineContext를 가지고 올 수 있습니다. 싱글스레드 실행자에서 디스패처를 만들고 launch()에 직접 전달하고 싶을 때 사용됩니다.

주의사항

실행자를 닫지 않으면 프로그램이 영원히 멈추지 않는다.
이유 : 실행자의 풀에는 main 스레드 외에도 액티브 스레드가 있고 액티브 스레드가 JVM을 계속 살려둔다.
이것을 방지하기 위해 use() 메소드를 사용한다. Java의 try-with-resources 기능과 유사하다.

// 실행자 생성 // CoroutineContext 가지고 온다. // use.. Executors.newSingleThreadExecutor().asCoroutineDispatcher().use { context -> println("start") runBlocking { launch(context) { task1() } launch { task2() } println("called task1 and task2 from ${Thread.currentThread()}") } println("done") }

결과값으로 task1은 DefaultDispatcher 풀이 아닌 생성한 풀에서 실행된 것을 확인할 수 있습니다.

start start task1 in Thread Thread[pool-1-thread-1,5,main] end task1 in Thread Thread[pool-1-thread-1,5,main] called task1 and task2 from Thread[main,5,main] start task2 in Thread Thread[main,5,main] end task2 in Thread Thread[main,5,main] done

single 스레드가 아닌 멀티플 스레드를 가지는 풀을 사용하고 싶을 경우 아래 처럼 작성하면 시스템의 코어 숫자와 동일한 스레드 숫자를 가지는 커스텀 풀에서 실행됩니다.

Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()).asCoroutineDispatcher().use { context -> println("start") runBlocking { launch(context) { task1() } launch { task2() } println("called task1 and task2 from ${Thread.currentThread()}") } println("done") }

서스펜션 포인트 이후에 스레드 스위칭하기

코루틴을 호출자의 컨텍스트에서 시작하지만 서스펜션 포인트 이후에 다른 스레드로 스위치하고 싶다면 어떤 방법으로 처리할까?

- 작업이 빠른 연산을 포함하는 스레드는 현재 스레드에서 실행시키고 싶다.
- 작업이 느린 연산을 하는 인스턴스라면 다른 스레드로 실행을 델리게이트 하고 싶다!

CoroutineStart 아규먼트와 CoroutineContext 아규먼트를 사용하여 처리할 수 있습니다.

launch()의 두번째 옵셔널 전달자인 CoroutineStart 타입 옵셔널 전달인자 값을 DEFAULT로 설정해야합니다. CoroutineStart엔 DEFAULT, LAZY, ATOMIC, UNDISPATCHED 중 선택 가능합니다.
- LAZY : 명시적으로 start()가 호출되기 전까지 실행 연기
- ATOMIC : 중단할 수 없는 모드로 실행
- UNDISPATCHED : 처음엔 현재 컨텍스트에서 실행되지만 서스펜션 포인트 이후엔 스레드를 스위치해서 실행

이전 코드에서 launch호출을 우리의 스레드풀에서 실행시키도록 변경하고 두 번째 아규먼트로 UNDISPATCHED 옵션을 줘보겠습니다.

suspend fun task1() { println("start task1 in Thread ${Thread.currentThread()}") yield() println("end task1 in Thread ${Thread.currentThread()}") } suspend fun task2() { println("start task2 in Thread ${Thread.currentThread()}") yield() println("end task2 in Thread ${Thread.currentThread()}") } Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()) .asCoroutineDispatcher().use { context -> println("start") runBlocking { @UseExperimental(ExperimentalCoroutinesApi::class) launch(context = context, start = CoroutineStart.UNDISPATCHED) { task1() } launch { task2() } println("called task1 and task2 from ${Thread.currentThread()}") } println("done") }

CoroutineStart.UNDISPATCHED 옵션은 실험적 기능입니다. 이 코드를 사용하기 위해서 @UseExperimental 어노테이션을 선언하고 커맨드라인에도 -Xuse-experimental을 호출할 때 플래그로 설정해야합니다.

kotlinc-jvm -Xuse-experimental=kotlin.Experimental \ -classpath /Users/limyongtae/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-coroutines-core/1.2.2/6ff48bdfc38a8c22e3fc37605b6a6afaed3b6dbd/kotlinx-coroutines-core-1.2.2.jar \ -script coroutinestart.kts

결과값은 아래와 같습니다.

start start task1 in Thread Thread[main,5,main] end task1 in Thread Thread[pool-1-thread-1,5,main] called task1 and task2 from Thread[main,5,main] start task2 in Thread Thread[main,5,main] end task2 in Thread Thread[main,5,main] done

task1의 스레드는 아래와 같이 변경되었습니다.

task1 시작 -> main task1 서스펜션 포인드 ( yield() ) task1 end -> pool-1-thread-1

코루틴 컨텍스트 변경

코루틴을 한 컨텍스트에서 실행하다가 중간에 컨텍스트를 바꾸고 싶다면 withContext() 메소드를 사용합니다.

suspend fun task1() { println("start task1 in Thread ${Thread.currentThread()}") yield() println("end task1 in Thread ${Thread.currentThread()}") } suspend fun task2() { println("start task2 in Thread ${Thread.currentThread()}") yield() println("end task2 in Thread ${Thread.currentThread()}") } runBlocking { println("starting in Thread ${Thread.currentThread()}") withContext(Dispatchers.Default) { task1() } launch { task2() } println("ending in Thread ${Thread.currentThread()}") }

결과는 아래와 같습니다.

starting in Thread Thread[main,5,main] start task1 in Thread Thread[DefaultDispatcher-worker-1,5,main] end task1 in Thread Thread[DefaultDispatcher-worker-1,5,main] ending in Thread Thread[main,5,main] start task2 in Thread Thread[main,5,main] end task2 in Thread Thread[main,5,main]

코루틴 디버깅

디버그 보다는 테스트를 해야합니다. 하지만 테스트 주도 개발 도중 예상한대로 동작하지 않았을 때 디버깅을 시도합니다. 코틀린은 코루틴이 실행중인 함수의 세부사항을 보여주기 위해 커맨드라인 옵션 -Dkotlinx.coroutines.debug를 제공합니다.

kotlinc-jvm -Dkotlinx.coroutines.debug \ -classpath opt/kotlin/kotlinx-coroutines-core-1.2.2.jar \ -script withcontext.kts

async와 await

launch() 메소드는 Job 객체를 리턴합니다. 이때 결과를 리턴받을 방법이 없습니다. 만약에 작업을 비동기로하고 실행결과를 받고 싶다면 async()를 사용합니다.

async()는 launch()와 동일한 파라미터를 받지만 결과값을 리턴합니다.

async() - Deferred<T> 퓨처 객체를 리턴한다. - 퓨처 객체는 코루틴의 상태체크, 취소를 할 수 있는 await() 메소드 존재

async를 적용해보자!

이번 예제는 싱글스레드 스코프를 사용하고 코루틴은 호출자와 동일한 스레드에서 동작할 것 입니다. 하지만 멀티플 스레드 디스패처의 스코프에서 동작한다면 코루틴은 해당 디스패처의 스레드 중 하나에서 실행되게 됩니다.

runBlocking { val count: Deferred<Int> = async(Dispatchers.Default) { println("fetching in ${Thread.currentThread()}") Runtime.getRuntime().availableProcessors() } println("Called the function in ${Thread.currentThread()}") println("Number of cores is ${count.await()}") }

1. main 쓰레드가 async() 호출 이후에 print문을 실행시킨다.
2. await() 메소드를 호출하면 async()에 의해서 실행된 코루틴이 완료되기를 기다리게 만든다.
3. 마지막 print문은 코루틴의 응답을 받은 후 출력한다.

결과값은 아래와 같습니다.

Called the function in Thread[main,5,main] fetching in Thread[DefaultDispatcher-worker-1,5,main] Number of cores is 16

위 코드에서 async(Dispatcher.Default)를 async로 수정한다면 코루틴은 main에서 작동할 것이다.!!


연속성 살펴보기

suspend 어노테이션으로 표시된 메소드는 데이터를 리턴할 수 있습니다. 하지만 코루틴은 작동을 중단시킬 수 있고 스레드를 변경할 수 있습니다. 예제로 살펴보겠습니다.

class Compute { fun compute1(n: Long): Long = n * 2 suspend fun compute2(n: Long): Long { val factor = 2 println("$n received : Thread : ${Thread.currentThread()}") delay(n * 1000) val result = n * factor println("$n returning $result Thread : ${Thread.currentThread()}") return result } }

compute1(): 일반 메소드

compute2(): suspend 어노테이션이 표시된 메소드

위에서 작성한 두 메소드를 사용해보자!

fun main() = runBlocking<Unit> { val compute = Compute() launch(Dispatchers.Default) { compute.compute2(2) } launch(Dispatchers.Default) { compute.compute2(1) } }

위 코드를 실행하면 아래와 같이 나타납니다.

2 received : Thread : Thread[DefaultDispatcher-worker-1,5,main] // compute2 1 received : Thread : Thread[DefaultDispatcher-worker-2,5,main] // compute1 1 returning 2 Thread : Thread[DefaultDispatcher-worker-2,5,main] // compute1 2 returning 4 Thread : Thread[DefaultDispatcher-worker-4,5,main] // compute2

입력값 2로 compute2()를 실행시키는 코루틴은 스레드가 변경되었습니다. delay() 메소드 이후 부분인 두 번째 파트는 다른 스레드에서 도작합니다. 하지만 factor의 값은 delay() 전부터 후까지 동일합니다. 이것을 컨티뉴에이션이라고 합니다.

컨티뉴에이션을 사용하여 프로그램은 한 스레드에서 실행 상태를 포착하고 보존할 수 있습니다. 그리고 그 상태를 다른 스레드에서 필요로 할 때 불러올 수 있습니다. 또한 렉시컬 스코프를 캡처하는 클로저라고 생각해봅니다.

두 메서드를 바이트코드로 비교해보겠습니다.

public final long compute1(long n) @Nullable public final Object compute2(long n, @NotNull Continuation var3)

compute1() 메서드는 특별한게 없습니다. 하지만 suspend 어노테이션을 선언한 compute2() 메서드는 완전히 다릅니다. compute2() 메서드는 코틀린에서 작성할 때 하나의 파라미터만 받지만 바이트 코드에서는 Continuation 파라미터를 하나 더 받는 것을 확인할 수 있습니다. 그리고 return 타입이 Object 입니다.

Continuation은 함수의 부분적인 실행의 결과를 캡슐화할 수 있습니다. 그래서 결과가 Continuation 콜백을 통해서 호출자에게 전달될 수 있습니다. 컴파일러는 코루틴의 작동을 위해서 컨티뉴에이션을 사용합니다.


무한 시퀀스 만들기

코루틴은 무한한 값을 생성하고, 생성된 값을 처리하는 것을 동시에 할 수 있습니다. 함수는 연속적인 값을 만들 수 있고 예상되는 값을 코드로 내보낼 수 있습니다.

시퀀스 사용

무한한 소수의 시리즈를 만듭니다.

fun primes(start: Int): Sequence<Int> = sequence { // sequence 사용 println("Starting to look") var index = start while (true) { if (index > 1 && (2 until index).none { i -> index % i == 0 }) { yield(index) println("Generating next after $index") } index++ } }

sequence() 메소드에 전달된 람다에서 다음 소수를 찾고 yield() 메소드를 사용하여 내보낼 수 있습니다.

for (prime in primes(start = 17)) { println("Received $prime") if (prime > 30) break }

위 코드는 소수를 30보다 작을 때까지 생성하는 코드입니다. 결과는 아래와 같습니다.

Starting to look Received 17 Generating next after 17 Received 19 Generating next after 19 Received 23 Generating next after 23 Received 29 Generating next after 29 Received 31

sequence() 함수는 3개의 장점을 제공!
1. 콜렉션을 미리 만들 필요가 없다. 연산에 얼마나 많은 값을 사용하게 될지 알 필요가 없고 필요할 때마다 값을 생성한다.
2. 시간이 흐를수록 값을 생성하느라 들어간 시간을 아낄 수 있고 이미 생성된 값을 사용하면 된다.
3. 시리즈 값의 생성이 필요할 때만 생성되기 때문에(lazy) 우리는 사용도 안될 값을 생성하는 상황을 피할 수 있다.


정리

- 코루틴은 컨티뉴에이션의 개념을 기반으로 태어남
- 코루틴은 동시성 프로그래밍을 만들기 위한 좋은 방법
- 코루틴은 다중 엔트리포인트를 가지고 있는 함수
- 코루틴은 호출들 사이에서 상태를 전달 가능. 이런 함수들은 서로 호출할 수 있고 이전 호출에서 중단된 부분부터 다시 실행 가능
- 코루틴이 실행되고 있는 스레드를 변경할 수 있고, async()/await()를 사용하면 병렬로 실행한 후 나중에 결과를 받을 수 있음

반응형