관리 메뉴

HAMA 블로그

[코틀린 코딩 습작] coroutine & channel 본문

Kotlin

[코틀린 코딩 습작] coroutine & channel

[하마] 이승현 (wowlsh93@gmail.com) 2021. 6. 7. 11:53


Coroutine

// THREAD 방식 

fun main() {
  
  val startTime = System.currentTimeMillis()
  val counter = AtomicInteger(0)
  val numberOfCoroutines = 100_00
  val jobs = List(numberOfCoroutines) {
    thread(start = true) {
      Thread.sleep(100L)
      counter.incrementAndGet()
    }
  }
  jobs.forEach { it.join() }

  val timeElaspsed = System.currentTimeMillis() - startTime
  println(timeElaspsed)
}
// Coroutine 방식

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.concurrent.atomic.AtomicInteger
fun main() {
  val startTime = System.currentTimeMillis()
  runBlocking<Unit> {
    val counter = AtomicInteger(0)
    val numberOfCoroutines = 100_00

    val jobs = List(numberOfCoroutines) {
      launch {
        delay(100L)
        counter.incrementAndGet()
      }
    }
    jobs.forEach { it.join() }
  }

  println(System.currentTimeMillis() - startTime)
}

Thread를 통해서 작업을 하면 2130밀리초가 걸리는 작업을 Coroutine을 통해서하면 209 밀리초 밖에 걸리지 않는다. Coroutine은 경량쓰레드를 제공하기 때문에 가능한데, 이 말은 코루틴 수만개가 단 몇개의 쓰레드를 다시 나누어 사용 한다는 의미이다. 즉 코루틴은 놀고 있는 쓰레드에 부착되어 작동 하므로 시작되는 쓰레드와 종료 시점의 쓰레드가 달라질 수 있다. Go언어에서 제공되는 경량쓰레드(goroutine)와는 달리 내부적으로 자바 쓰레드풀을 사용하기 때문에, 진정한 경량쓰레드를 쓰냐에 관련된 말이 있지만, 어차피 Go언어도 자신만의 추상층을 가지고 있기 때문에 별 의미는 없어 보인다.

스택오버플로우에서 설명된 go goroutine vs kotlin Coroutine차이

주요 차이점에 대한 요약을 정리하자면 아래와 같다.

1. 코틀린 코루틴은 Go 고루틴보다 간단한 인스턴스당 더 적은 메모리를 필요로 한다. 코틀린에 있는 단순한 코루틴은 힙 메모리의 수십 바이트만 차지하고, 고 고루틴은 스택 공간의 4KiB로 시작한다. 말 그대로 수백만 개의 코루틴을 가질 계획이라면 코틀린의 코루틴은 Go 에 비해 우위를 점할 수 있습니다. 또한 코틀린 코루틴은 생성기 및 게으른 시퀀스와 같은 매우 짧고 작은 작업에 더 적합하게 만든다.

2. 코틀린 코루틴은 임의의 스택 깊이까지 갈 수 있지만, 함수를 일시 중단할 때마다 힙에 개체를 할당한다. 코틀린 코루틴의 호출 스택은 현재 힙 객체의 링크된 목록으로 구현되어 있다. 반대로 Go의 고루틴은 선형 스택 공간을 사용한다. 이것은 Go에서 딥 스택의 서스펜션을 더 효율적으로 만든다. 따라서, 당신이 쓰고 있는 코드가 매우 깊게 구성되어 있다면, 고루틴이 당신에게 더 효율적이라는 것을 알게 될 것이다.

3. 효율적인 비동기 IO는 매우 다차원적인 설계 문제이다. 한 종류의 애플리케이션에 효율적인 접근 방식은 다른 종류의 애플리케이션에 최상의 성능을 제공하지 못할 수 있다. 코틀린 코루틴의 모든 IO 연산은 코틀린이나 자바 언어로 작성된 라이브러리에 의해 구현된다. 코틀린 코드에서 사용할 수 있는 IO 라이브러리는 매우 다양하다. In Go에서 비동기 IO는 일반 Go 코드에서 사용할 수 없는 기본 요소를 사용하여 Go 런타임에 구현된다. IO 작업 구현에 대한 Go 접근 방식이 애플리케이션에 적합하다면 Go 런타임과의 긴밀한 통합이 이점을 제공할 수 있다. 한편, Kotlin에서는 라이브러리를 찾거나 애플리케이션에 가장 적합한 방식으로 비동기 IO를 구현하는 라이브러리를 직접 작성할 수 있다.

4. Go runtime은 물리적 OS 스레드에서 실행 일정을 완전히 제어한다. 이 접근 방식의 장점은 모든 것을 생각할 필요가 없다는 것이다. 코틀린 코루틴을 사용하면 코루틴의 실행 환경을 세밀하게 제어할 수 있다. 이는 오류가 발생하기 쉽다(예: 단순히 너무 많은 다른 스레드 풀을 만들고 이들 사이의 컨텍스트 전환에 CPU 시간을 낭비할 수 있다). 그러나 응용 프로그램에 대한 스레드 할당 및 컨텍스트 스위치를 미세 조정할 수 있다. 예를 들어 Kotlin에서는 단일 OS 스레드(또는 스레드 풀)에서 전체 응용 프로그램 또는 코드의 하위 집합을 실행하는 것이 쉬워서 적절한 코드를 작성하는 것만으로 OS 스레드 간에 컨텍스트를 완전히 전환하는 것을 피할 수 있다.

ps)
코틀린의 코루틴은 Golang의 코루틴과는 다른 방식으로 구현되기 때문에 어떤 것이 더 '빠른' 지는 푸는 문제와 작성하는 코드의 종류에 따라 달라진다.  즉 여러분이 당면한 문제에 대해 어떤 것이 더 잘 작동할지 미리 말하는 것은 매우 어렵다. 결국 특정 워크로드에 대한 벤치마크를 실행하여 이를 파악해야 한다. 

 

(* python의 코루틴에서 주로 사용하는 generator / send / yield 같은 기능은 없어 보인다. 다만 아래서 설명할 channel로 비슷하게 만들 순 있다.)

fun main() {
  runBlocking<Unit> {

    val time = measureTimeMillis {
      // given
      val one = async {
        delay(1000L)
      }
      val two = async {
        delay(2000L)
      }
      
      // when
      runBlocking {
        one.await()
        two.await()
      }
    }
    println(time) // 2013 mills
  }
}

launch와 async의 주요 차이점은 launch는 job을 리턴하고, async는 deffered를 리턴하는 것이다. 
job은 코루틴 자체를 의미하므로, launch로 실행되는 코루틴을 취소 할 수도 있고 기다릴 수도 있다. 즉 라이프 사이클에 관심이 있으며, deffered는 future처럼 미래의 결과 값을 의미하므로 완료 되길 기다리다가 리턴되는 값에 관심이 있다.
deffered는 job을 상속받으므로 job의 역할도 할 수 있다. 즉 non-blocking cancellable future 라 볼 수 있다.  

따라서 결과값에 관심이 있으면 async를 사용하고, 아니면 launch를 사용하면 된다. 


Channel 

개인적으로 golang을 매우 좋아하는데, 그 이유는 오로지 심플한 goroutine과 go channel의 존재에 있다. CSP기법의 하나인 이것은 동시성 프로그래밍을 매우 간단하고 직관적으로 만들어 준다. 세상은 단순한 기술이 승리하더라. 그리고 중요한건 재밌다는 사실!!  kotlin에도 go channel식으로 개발하는 것을 지원해 주니 사용 안 할 이유가 없다. 

https://www.baeldung.com/kotlin/channels 에서 코드를 가져왔다. 별다른 설명이 필요 없을 정도로 코드가 깔끔하다.

import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals

internal class ChannelTest {
  
  @Test
  fun `should_pass_data_from_one_coroutine_to_another`(){
    runBlocking {
      // given
      val channel = Channel<String>()
      
      // when
      launch { // coroutine1
        channel.send("Hello World!")
      }
      val result = async { // coroutine2
        channel.receive()
      }
      // then
      assertEquals(result.await(),"Hello World!")
    }
    
  }
}

하나의 코루틴에서 다른 코루틴으로 데이터를 전송하고 받는 기본적인 코드이다. 

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() {
  runBlocking {
    // given
    val channel = Channel<Channel<String>>()
    val eventChannel = Channel<String>()
    
    // when
    launch { // coroutine1
      channel.send(eventChannel)
      print(eventChannel.receive())
    }
    launch { // coroutine2
      val eventChannel = channel.receive()
      eventChannel.send("hi there")
    }
  }
}

go channel에서는 채널을 채널에 전송 할 수 있어서, 해 보았는데 코틀린도 잘 된다.

아래는 Pub-Sub패턴의 코드이다. 

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.produce
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun CoroutineScope.producePizzaOrders(): ReceiveChannel<String> = produce {
  var x = 1
  while (true) {
    send("Pizza Order No. ${x++}")
    delay(100)
  }
}

fun CoroutineScope.pizzaOrderProcessor(id: Int, orders: ReceiveChannel<String>) = launch {
  for (order in orders) {
    println("Processor #$id is processing $order")
  }
}

fun main() = runBlocking {
  val pizzaOrders = producePizzaOrders()
  repeat(3) {
    pizzaOrderProcessor(it + 1, pizzaOrders)
  }
  
  delay(1000)
  pizzaOrders.cancel()
}

produce는 ReceiveChannel<T>을 리턴하는 코루틴이다. (async는 deffered, launch는 job)

마지막으로 채널을 파이프라이닝으로 연결 할 수도 있다.
병렬로 파이프라이닝/체이닝/필터링 패턴을 적용 할 때 좋을 거 같다. 

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.produce

fun CoroutineScope.baking(orders: ReceiveChannel<PizzaOrder>) = produce {
  for (order in orders) {
    delay(200)
    println("Baking ${order.orderNumber}")
    send(order.copy(orderStatus = BAKED))
  }
}

fun CoroutineScope.topping(orders: ReceiveChannel<PizzaOrder>) = produce {
  for (order in orders) {
    delay(50)
    println("Topping ${order.orderNumber}")
    send(order.copy(orderStatus = TOPPED))
  }
}

fun CoroutineScope.produceOrders(count: Int) = produce {
  repeat(count) {
    delay(50)
    send(PizzaOrder(orderNumber = it + 1))
  }
}

fun main() = runBlocking {
  val orders = produceOrders(3)
  
  val readyOrders = topping(baking(orders))
  
  for (order in readyOrders) {
    println("Serving ${order.orderNumber}")
  }
  
  delay(3000)
  coroutineContext.cancelChildren()
}


더 제대로된 코틀린 채널 예제와 설명은 아래 링크를 참고하자.
https://proandroiddev.com/kotlin-coroutines-channels-csp-android-db441400965f

아무튼 Golang 사용하다가 코틀린 코루틴 스터디해보면 정말 지져분해서 사용하기 싫다는 기분이 많이 든다...
언어 자체 제공 vs 라이브러리인것을 훨씬 넘어서는 복잡함
비록 kotlinx.coroutines은 리치라이브러리로써 많은 기능을 제공해 주고 있긴 하지만...
빌트인 기능을 좀 더 깔끔하게 만들 순 없었을까?  코틀린 코루틴 라이브러리 vs 빌트인 

Comments