관리 메뉴

피터의 개발이야기

[kotlin] 코틀린 코루틴의 정석- Async-await와 withContext 비교 본문

Programming/Kotlin

[kotlin] 코틀린 코루틴의 정석- Async-await와 withContext 비교

기록하는 백앤드개발자 2024. 6. 1. 10:10
반응형

 

ㅁ 들어가며

 코틀린 코루틴의 정석 책을 보고 정리한 글입니다.

 

5장 async와 Deferred(async-await와 withContext)

- async-await 사용해 코루틴으로부터 결과값 수신하기
- awaitAll 함수를 사용해 복수의 코루틴으로부터 결과값 수신하기
- withContext 사용해 실행 중인 코루틴의 CoroutineContext 변경하기

 

ㅁ async-await

ㅇ 기본적으로 비동기 코루틴은 생성되자마자 실행을 시작한다. 그러나 Wait 또는 start 함수가 호출될 때까지 실행을 지연시키는 CoroutineStart.LAZY와 같은 CoroutineStart 인수를 전달하여 이 동작을 변경할 수 있다 .

ㅇ async-await 메서드는 결과를 기다리는 동안 여러 비동기 작업을 동시에 수행하는 데 가장 일반적으로 사용된다.

  ㄴ 예를 들어, async-await를 사용하여 두 개의 서로 다른 API에서 데이터를 수집하고 그 결과를 통합할 수 있다.

 

ㅁ async와 Deferred(연기된)

fun main() = runBlocking<Unit> {
  // Deferred을 반환한다. Deferred는 '연기된'이라는 뜻이다. 	
  val networkDeferred: Deferred<String> = async(Dispatchers.IO) {
    println("[${Thread.currentThread().name}] 시작")
    delay(1000L) // 지연
    return@async "Dummy Response" // 결과값 반환
  }
  // 결과값이 반환될 때까지 Blocking
  println(networkDeferred.await()) // 결과출력
  println("[${Thread.currentThread().name}] 끝")
}
/*
[DefaultDispatcher-worker-1 @coroutine#2] 시작
Dummy Response
[main @coroutine#1] 끝
*/

ㅇ 비동기 작업 중 결과를 수신해야하는 경우 async 코루틴 빌더를 통해 결과를 수신받을 수 있는 Deferred 객체를 반환 받는다.

ㅇ 참고로, launch는 결과값을 직접 반환할 수 없다.

 

ㅁ Deferred는 일종의 Job이다.

ㅇ 코루틴의 모두 Job을 하나의 루틴으로 설정하고 작업을 스케줄링한다. 

ㅇ 그렇다면 코루틴의 Deferred는 어떻게 비동기처리가 이루어지는 것일까?

 

ㅇ Job을 상속받고 모든 결과를 전달하기 위해 out T으로 선언되어 있다.

ㅇ Deferred 선언 부분의 주석을 보면 더욱 일종의 Job을 알 수 있다.

ㅇDeferred value is a non-blocking cancellable future — it is a Job with a result.
ㄴ Deferred 값은 비동기로 취소가 가능한 futuer이며, 결과가 있는 Job이다.
ㄴ 정리하면, Deferred는 비동기 처리가 가능한 결과가 있는 Job이다.

ㅇDeferred has the same state machine as the Job with additional convenience methods to retrieve the successful or failed result of the computation that was carried out. 
ㄴ Deferred는 수행된 연산의 성공 또는 실패를 검색하기 위한 추가 편의 메서드를 갖춘 Job과 동일한 상태 머신을 갖는다.

ㅇ Deferred는 Job의 서브타입으로 Job 객체에 결과값을 감싸는 기능이 추가된 객체이다.

ㅇ Deferred 객체에 대해 await 함수를 호출하면 결과값을 반환받을 수 있다.

 

fun main() = runBlocking<Unit> {
  val jobTestDeferred: Deferred<String> = async(Dispatchers.IO) {
    println("[${Thread.currentThread().name}] jobTest start") // job상태 확인
    delay(1000L) // 지연
    "job test" // return을 생략가능..
  }

  // join과 cancel 가능
  println("[${Thread.currentThread().name}] isActive >> ${jobTestDeferred.isActive}") // job상태 확인
  jobTestDeferred.cancelAndJoin() // job을 cancel
  println("[${Thread.currentThread().name}] isCancelled >> ${jobTestDeferred.isCancelled}") // cancel 상태 확인
}

/*
[DefaultDispatcher-worker-1 @coroutine#2] jobTest start
[main @coroutine#1] isActive >> true
[main @coroutine#1] isCancelled >> true
*/

ㅇ 정리하면,
  Deferred는 Job의 모든 함수와 프로퍼티를 이용하며,
  Join을 사용해 Dererred가 순서 보장이 가능하며,
  cancel을 호출하여 작업을 중지 할 수 있다. 

 

ㅁ 여러 Deferred 처리

fun getElapsedTime(startTime: Long): String = "elapsedTime: ${System.currentTimeMillis() - startTime}ms"

fun main() = runBlocking<Unit> {
  val startTime = System.currentTimeMillis() // 기준시간 
  val 병렬결과Deferred1 = async(Dispatchers.IO) { // 1st coroutine
    delay(1000)
    return@async arrayOf("데이터1","데이터2")
  }
  val 결과수신1 = 병렬결과Deferred1.await() // 1초 대기

  val 병렬결과Deferred2 = async(Dispatchers.IO) { // 2ed coroutine
    delay(1000)
    return@async arrayOf("데이터3","데이터4")
  }
  val 결과수신2 = 병렬결과Deferred2.await() // 1초 대기

  println("[${getElapsedTime(startTime)}] 참여자 목록: ${listOf(*결과수신1, *결과수신2)}")
}
/*
[elapsedTime: 1015ms] data: [데이터1, 데이터2, 데이터3, 데이터4]
*/

////////////////////////////
// 병렬처리 스케줄이 잘된 경우
fun main() = runBlocking<Unit> {
  val startTime = System.currentTimeMillis() // 기준시간
  
  // 스케줄될 2개의 루틴은 주입
  val 병렬결과Deferred1 = async(Dispatchers.IO) { // 1st coroutine
    delay(1000)
    return@async arrayOf("데이터1","데이터2")
  }

  val 병렬결과Deferred2 = async(Dispatchers.IO) { // 2ed coroutine
    delay(1000)
    return@async arrayOf("데이터3","데이터4")
  }
  
  // 병렬처리가 가능해진다.
  val 결과수신1 = 병렬결과Deferred1.await() // 1초 대기
  val 결과수신2 = 병렬결과Deferred2.await() // 1초 대기

  println("[${getElapsedTime(startTime)}] data: ${listOf(*결과수신1, *결과수신2)}")
}
/*
[elapsedTime: 1015ms] data: [데이터1, 데이터2, 데이터3, 데이터4]
*/

ㅇ 비동기 결과처리 시 await를 사용하지만 await 시점에 따라 병렬처리가 달라진다.

ㅇ 코드 순서에 따라 병렬결과Deferred1 루틴이 선언되고 await를 통해 루틴이 스케줄되어 처리 될 때가지 다른 스케줄은 하지 않는다.

ㅇ 병렬처리가 필요한 루틴을 await 위에 선언해야 병렬처리가 가능하다.

 

awaitAll

  // 병렬처리가 가능해진다.
//  val 결과수신1 = 병렬결과Deferred1.await() // 1초 대기
//  val 결과수신2 = 병렬결과Deferred2.await() // 1초 대기
  val results: List<Array<String>> = awaitAll(병렬결과Deferred1, 병렬결과Deferred2)

  println("[${getElapsedTime(startTime)}] data: ${listOf(*results[0], *results[1])}")
}
/*
[elapsedTime: 1017ms] data: [데이터1, 데이터2, 데이터3, 데이터4]
*/

awaitAll 함수를 사용해 복수의 Deferred 코루틴이 결과값을 List형태로 받을 수 있다.

 

컬렉션에 대한 확장 함수 awaitAll()

~~~  
  // awaitAll 함수
  val results: List<Array<String>> = awaitAll(병렬결과Deferred1, 병렬결과Deferred2)
  // 컬렉션에 대한 확장 함수
  val results: List<Array<String>> = listOf(병렬결과Deferred1,병렬결과Deferred2).awaitAll()

  println("[${getElapsedTime(startTime)}] data: ${listOf(*results[0], *results[1])}")
}
/*
[elapsedTime: 1017ms] data: [데이터1, 데이터2, 데이터3, 데이터4]
*/

ㅇ awsitAll함수는 컬렉션에 대한 확장 함수로도 제공되어 확장함수로도 사용가능하다.

 

ㅁ withContext

   withContext는 코루틴 Context와 Suspending lambda를 매개변수로 받아들이는 Suspending function이다. 그래서 스레드 차단 없이 Coroutine을 일시 중지할 수 있다. Suspending lambda는 코루틴이 완료되면 값을 반환하는 코드 블록이다.
  withContext 함수는 지정된 컨텍스트를 사용하여 새 코루틴을 생성하고 그 안에서 Suspending lambda를 실행한다. 그런 다음 함수는 새 코루틴이 완료될 때까지 기존 코루틴을 일시 중지하고 그 결과를 반환한다. 또한 이 함수는 현재 코루틴이 취소되거나 Suspending lambda가 예외를 발생시키는 경우 새 코루틴이 취소되도록 보장한다.
  withContext는 백그라운드 스레드에서 메인 스레드로 또는 그 반대로 전환하는 등 다양한 디스패처 간에 전환하는 데 가장 일반적으로 사용된다. 예를 들어, withContext를 사용하여 백그라운드 스레드에서 많은 계산을 수행한 다음 메인 스레드에서 UI를 업데이트할 수 있다.

  withContext를 사용하여 코루틴을 생성하고 실행할 수도 있다. withContext는 코루틴 Context와 Suspending lambda를 매개변수로 받아들이는 Suspending function이다. Suspending lambda는 코루틴이 완료되면 값을 반환하는 코드 블록이다.

  withContext는 지정된 Context를 사용하여 새 코루틴을 생성하고 그 안에서 Suspending lambda를 실행한다. 그런 다음 함수는 새 코루틴이 완료될 때까지 기존 코루틴을 일시 중지하고 그 결과를 반환한다. 또한 이 함수는 현재 코루틴이 취소되거나 Suspending  lambda가 예외를 발생시키는 경우 새 코루틴이 취소되도록 보장한다.

  withContext는 백그라운드 스레드에서 메인 스레드로 또는 그 반대로 전환하는 등 다양한 디스패처 간을 전환하는 데 가장 일반적으로 사용된다. 예를 들어, withContext를 사용하여 백그라운드 스레드에서 많은 계산을 수행한 다음 메인 스레드에서 UI를 업데이트할 수 있습니다.

 

fun main() = runBlocking<Unit> {
  println("[${Thread.currentThread().name}] runBlocking Thread는 main coroutine#1")

  val result1 = async(Dispatchers.IO) {
    delay(1000L)
    println("[${Thread.currentThread().name}] async")
    "async Response"
  }

  val result2 = withContext(Dispatchers.IO) {
    delay(1000L)
    //
    println("[${Thread.currentThread().name}] withContext coroutine#1에 다시 스케줄된다." +
            " 왜냐 동시에 이루어짖 않는 중요한 작업에 할당되기 때문이다.")
    "withContext Response"
  }
  // async의 경우 스케줄에 등록하고 await 시 스레드에 dispatch되어 작업이 수행됨.
  println(result1.await())
  // withContext는 자동 await, async의 간편 버젼
  println(result2)
}
/*
[main @coroutine#1] runBlocking Thread는 main coroutine#1
[DefaultDispatcher-worker-3 @coroutine#1] withContext coroutine#1에 다시 스케줄된다.왜냐 동시에 이루어짖 않는 중요한 작업에 할당되기 때문이다.
[DefaultDispatcher-worker-1 @coroutine#2] async
async Response
withContext Response
*/

ㅇ withContext 함수를 사용해 async-await쌍을 대체할 수 있다.

ㅇ withContext 함수는 코루틴을 새로 생성하지 않는다.

ㅇ 코루틴의 실행환경을 담는 coroutineContext만 변경해 코루틴을 실행하므로 이를 활용해 코루틴이 실행되는 스레드를 변경할 수 있다.

 

fun main() = runBlocking<Unit> {
  val startTime = System.currentTimeMillis()
  // withContext는 병렬처리가 되지 않는다.
  repeat(10){ i ->
    withContext(Dispatchers.IO) {
      println("[${Thread.currentThread().name}] 작업${i}")
      delay(100)
    }
  }
  println("[${getElapsedTime(startTime)}] ")
}
/*
[DefaultDispatcher-worker-1 @coroutine#1] 작업0
[DefaultDispatcher-worker-1 @coroutine#1] 작업1
[DefaultDispatcher-worker-1 @coroutine#1] 작업2
[DefaultDispatcher-worker-1 @coroutine#1] 작업3
[DefaultDispatcher-worker-1 @coroutine#1] 작업4
[DefaultDispatcher-worker-1 @coroutine#1] 작업5
[DefaultDispatcher-worker-1 @coroutine#1] 작업6
[DefaultDispatcher-worker-1 @coroutine#1] 작업7
[DefaultDispatcher-worker-1 @coroutine#1] 작업8
[DefaultDispatcher-worker-1 @coroutine#1] 작업9
[지난 시간: 1057ms]
*/

withContext는 coroutine#1만 사용하기에 병렬처리가 되지 않고, 순차적으로 실행된다.

 

fun main() = runBlocking<Unit> {
  val startTime = System.currentTimeMillis()
  // withContext는 병렬처리가 되지 않는다.
  repeat(2){ i ->
    withContext(Dispatchers.IO) {
      println("[${Thread.currentThread().name}] 케리어${i}")

      // async는 병렬처리가 가능하다.
      repeat(5){ num ->
        async(Dispatchers.IO) {
          delay(1000)
          println("[${Thread.currentThread().name}] 인터셉터${i}-${num}")
        }
      }
      println("[${getElapsedTime(startTime)}] 케리어 지연")
    }
  }
  println("[${getElapsedTime(startTime)}] 최종")
}
/*
[DefaultDispatcher-worker-1 @coroutine#1] 작업0
[DefaultDispatcher-worker-1 @coroutine#1] 작업1
[DefaultDispatcher-worker-1 @coroutine#1] 작업2
[DefaultDispatcher-worker-1 @coroutine#1] 작업3
[DefaultDispatcher-worker-1 @coroutine#1] 작업4
[DefaultDispatcher-worker-1 @coroutine#1] 작업5
[DefaultDispatcher-worker-1 @coroutine#1] 작업6
[DefaultDispatcher-worker-1 @coroutine#1] 작업7
[DefaultDispatcher-worker-1 @coroutine#1] 작업8
[DefaultDispatcher-worker-1 @coroutine#1] 작업9
[지난 시간: 1057ms]
*/

ㅇ 이럴 때는 async를 사용해 병렬로 실행해야 한다.

 

fun main() = runBlocking<Unit> {
  val startTime = System.currentTimeMillis()
  println("[${Thread.currentThread().name}] 케리어 발진")

  withContext(케리어1) {
    repeat(5){ num ->
      async(Dispatchers.IO) {
        delay(1000)
        println("[${Thread.currentThread().name}] 케리어1 인터셉터 공격-${num}")
      }
    }
    println("[${getElapsedTime(startTime)}] 케리어1")

    withContext(케리어2) {
      repeat(5){ num ->
        async(Dispatchers.IO) {
          delay(1000)
          println("[${Thread.currentThread().name}] 케리어2 인터셉터 공격-${num}")
        }
      }
      println("[${getElapsedTime(startTime)}] 케리어2")
    }

  }
  println("[${Thread.currentThread().name}] 공격완료")
  println("[${getElapsedTime(startTime)}]")

}
/*
[main @coroutine#1] 케리어 발진
[지난 시간: 12ms] 케리어1
[지난 시간: 15ms] 케리어2
[DefaultDispatcher-worker-4 @coroutine#10] 케리어2 인터셉터 공격-3
[DefaultDispatcher-worker-2 @coroutine#4] 케리어1 인터셉터 공격-2
[DefaultDispatcher-worker-3 @coroutine#2] 케리어1 인터셉터 공격-0
[DefaultDispatcher-worker-6 @coroutine#5] 케리어1 인터셉터 공격-3
[DefaultDispatcher-worker-5 @coroutine#7] 케리어2 인터셉터 공격-0
[DefaultDispatcher-worker-11 @coroutine#11] 케리어2 인터셉터 공격-4
[DefaultDispatcher-worker-9 @coroutine#9] 케리어2 인터셉터 공격-2
[DefaultDispatcher-worker-1 @coroutine#6] 케리어1 인터셉터 공격-4
[DefaultDispatcher-worker-8 @coroutine#3] 케리어1 인터셉터 공격-1
[DefaultDispatcher-worker-7 @coroutine#8] 케리어2 인터셉터 공격-1
[main @coroutine#1] 공격완료
[지난 시간: 1032ms]
*/

withContext로 인해 실행환경이 변경되는 코루틴은 withContext의 작업을 모두 실행하면 다시 이전의 실행환경으로 돌아온다.

 

ㅁ Async-await와 withContext 비교

 설명에 앞서 이해를 돕기 위해 케리어와 인터셉터 이미지를 예로 들었다. 흔히 main context라고 말하면 중심 맥락을 의미한다. withContext는 많은 스레드 루틴 중 중심맥락을 지탱하는 스레드를 뜻한다. 마치 중요 정보를 수첩에 적어놓듯, withContext는 중요하기에 coroutine#1에 스위칭되어 관리한다. 마치 스타크레프트의 케리어가 인터셉터를 관리 재생하듯, 중요 context는 케리어가 관리하고 단순 연산은 인터셉터가 처리하듯, withConText Async-await를 이해할 수 있다. 

특징 async-await withContext
목적 async-await는 여러 비동기 작업을 실행하고 결과를 기다리려는 경우에 사용 withContext는 백그라운드 스레드에서 메인 스레드로 전환하는 등 여러 디스패처 간에 전환하려는 경우에 사용
반환 유형 async는 비동기 작업의 향후 결과를 보유하는 Deferred 객체를 반환 withContext는 특정 결과를 반환하지 않는다. 단순히 지정된 코드 블록의 컨텍스트를 전환한다. 결과는 블록 내에서 직접 할당되거나 사용될 수 있다.
동시성 서로 독립적인 여러 작업이 있는 경우
비동기를 사용하여 동시에 실행할 수 있다.
본질적으로 동시성을 제공하지 않다. 실행 컨텍스트를 변경하는 것에 대한 자세한 내용이다.
예외 처리 비동기를 사용하면 비동기 본문 내부의 코드 블록 예외를 포착해야 한다. 그렇지 않으면 상위 범위를 종료할 수 있다. withContext에는 명시적인 예외 처리가 필요하지 않다.
일시 중지 기능 일시 중지 기능을 사용하거나 사용하지 않고 사용할 수 있다. 일반적으로 비동기 작업을 수행하기 위해 다른 일시 중지 기능과 함께 사용됩니다. 일반적으로 특히 IO 바인딩 작업이나 기타 컨텍스트별 작업의 경우 일시 중지 기능 내에서 사용됩니다.
사용 사례 병렬로 실행될 수 있는 동시적이고 독립적인 작업에 적합하다. 예를 들어 여러 네트워크 요청을 동시에 수행한다. IO 작업을 차단하기 위한 IO 디스패처 또는 사용자 정의 스레드 풀로 이동하는 등 특정 작업의 컨텍스트를 변경하는 데 적합한다.
취소 처리 모든 하위 항목에 취소를 전파한다. 한 자녀가 취소되면 다른 모든 자녀도 취소됩니다. withContext 내의 현재 코드 블록에만 취소를 전파한다.
코루틴 자체는 취소되지 않는다.

 

ㅁ 마무리

ㅇ async-await와 withContext는 Kotlin에서 코루틴을 생성하고 실행할 수 있는 두 가지 방법이지만 목적과 특성이 다르다.
ㅇ async-await는 각 작업에 대해 새로운 코루틴을 생성하고 작업의 향후 결과를 보유하는 Deferred를 반환한다.

   ㄴ 작업 결과를 얻으려면 각 Deferred에 await를 호출해야 한다.
ㅇ withContext는 단일 코루틴 내에서 서로 다른 디스패처 간에 전환하는 데 사용됩니다. 전체 블록에 대한 새로운 코루틴을 생성하고 블록 결과를 반환 값으로 반환한다. 함수에 의해 직접 반환되므로 블록 결과를 얻기 위해 메서드를 호출할 필요가 없다.
Async-await는 작업을 병렬로 실행하여 동시성을 지원하므로 작업이 어떤 순서로든 대기할 수 있다. 반면, withContext는 블록이 순차적으로 실행되어 블록이 완료될 때까지 원래 코루틴을 일시 중단하기 때문에 동시성을 지원하지 않는다.

 

ㅁ 함께 보면 좋은 사이트

ㅇ 조세영 - 코틀린 코루틴의 정석 

Kotlin withContext() vs Async Await

반응형
Comments