IT/Kotlin

[Kotlin/Coroutine] 4. Coroutine의 구성요소: Coroutine의 생성과 활용

Hodie! 2023. 4. 22. 19:51
반응형

Coroutine의 구성요소

CoroutineScope 은 제일 큰 범위의 인터페이스이다

Coroutine은 3가지 요소로 이루어져있다고 볼 수 있다.

  1. CoroutineScope
    • Coroutine제어할 수 있는 범위, Coroutine이 활동할 수 있는 범위를 뜻하며,
      여기서 제어는 어떤 작업을 취소하거나, 끝날 때까지 기다리는 것을 뜻한다.
  2. CoroutineContext
    • Coroutine이 실행된 Context Coroutine실행 목적에 맞게, 실행될 특정 Thread Pool 지정
  3. Builder
    • Coroutine을 실행하는 함수. 종류로는 위에서 언급한 launch, async등이 있다

CoroutineScope

  • Coroutine실행되는 범위Coroutine 블록을 묶음으로 제어할 수 있는 단위이다.
  • 모든 Coroutine Scope 내에서 실행되어야 한다.
    따라서 이를 통해 Activity 또는 FragmentLifecycle에 따라 소멸될 때, 관련 Coroutine을 한번에 취소할 수 있고, 이는 곧 Memory Leak을 방지한다.
    • 즉, CoroutineScope 객체는 (1) Coroutine을 시작하는 곳이기도 하지만, (2) 모든 Coroutine을 관리하는곳이기도 하고, (3) Coroutine을 끝내는 곳이기도 하다.
      → 언제 끝날지 모르는 어떤 비동기 코드도, Scope가 끝나면 다 끝이다.
      • 설사, 다른 라이브러리가 Coroutine을 실행시켰고, 그것이 비동기로 돌아가고 있더라도 그 라이브러리가 존재하는 Scope가 끝나버리면, 그 안의 Coroutine 작업도 다 cancel되어버린다.
        Android와 같이 LifeCycle에 따라 Resource에 대한 Release가 중요한 Framework에서는 매우 편리한 부분이기도 하다. Scope가 끝나면 모든 작업이 끝난다는 것을 보장해주기 때문.
    • Memory Leak을 피하기 위해서는, Activity가 파괴될 때(onDestroy에서) Coroutine을 취소해줘야 한다. (scope.cancel())
    • 모든 Coroutine은 항상 자신이 속한 Scope를 참조해야 하고, cancel로 모두 취소가 가능하다

CoroutineScope의 종류

  • Scope커스텀된 또는 이미 내장된 범위를 사용할 수 있다.
    • CoroutineScope는 크게 GlobalScope사용자 지정 CoroutineScope로 나눌수 있다.
    • GloablScopeApplication 프로세스의 Lifecycle를 따라간다
    • 사용자 지정 CoroutineScopeCoroutinScope(ConroutineContext)
      • CoroutineScope(Dispatchers.Main) ...
  • 이외 Android에서의 Scope
    • MainScopeUI 관련 작업을 처리하는 용도
      • 결국 CoroutineScope를 활용하고 있음
    • ViewmodelScopeViewModel의 Lifecycle를 따라간다.
      • 따라서, 해당 Scope로 실행되는 CoroutineViewModel Instance가 소멸될 때 자동으로 취소된다.
      • 이는 Jetpack Architecture의 ViewModel Component 사용시, ViewModel Instance에서 사용하기 위해, 제공되는 Scope이다.
      • viewModelScope은 스택오버플로우를 찾아본 결과, 메인 쓰레드를 사용하게끔 설계되어있었다고 한다.
    • LifecycleScopeActivity, Fragment의 Lifecycle를 따라간다.
      • Lifecycle별로 Callback이 다르다.
    • ViewModelScopeLifecycleScopeAndroid Lifecycle용 해당한다
      (Lifecyce-Aware Coroutine Scope)
      • Andorid Jetpack 라이브러리(AAC)에서 Coroutine을 쉽게 사용할 수 있도록 각 Lifecycle에 맞는 Scope를 제공해주고 있는 것이다.
      • Activity가 종료될 때 실행중인 Coroutine도 함께 종료되길 원하면, Activity의 Lifecycle일치하는 ScopeCoroutine을 실행시키면 된다.
  • 이외 CoroutineScope Interface를 구현해 Custom한 CoroutineScope를 만들수도 있다.
    → 공식 문서에서 추천하는 방법은 아니라고 한다.

GlobalScope

GlobalScope.launch { //DO SOMETHING 
}
binding.button.setOnclickListener {
    CoroutineScope(Dispatchers.IO).launch { // DO SOMETHING
    }
}
  • Application의 LifeCycle에만 제한을 받는다. 즉 Application의 Lifecycle과 함께 동작하기에 실행 도중에 별도의 생명주기 관리가 필요 없고, 시작-종료 까지 긴 기간 실행되는 Coroutine의 경우에 적합하다.
  • 따라서 이 ScopeApplication이 종료될 때까지 Coroutine을 실행시킬 수 있다.
    • Android의 경우 만약 Acitivity에서 CoroutineGlobalScope영역에서 실행시킬 경우, Activity가 종료되어도, Coroutine은 작업이 끝날때까지 동작한다
      • 그럼에도, 저사양 기기에서는 메모리 문제로, finish()와 함께 process가 kill될수도 있으니, 반드시 coroutine 동작이 유지된다고 판단할 수도 없다.
    • GlobalScope에서 수행하는 CoroutineDaemon Thread와 같다. 이는 Process가 kill되면, 같이 멈춘다는 것을 의미한다. (DaemonThread처럼 일반 Thread 가 모두 종료되면 함께 종료)
      • 예시
fun main() = runBlocking {
    GlobalScope.launch {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
    }
    delay(1300L) // just quit after delay
}
  • 위 코드 실행시, I'm sleeping $i ... 은 3개만 찍히고 끝난다.
  • runBlocking은 내부에서 발생한 모든 자식 corouitne의 동작을 보장한다.
  • 내부에서 GlobalScope을 이용하여 launch 했기 때문에 runBlocking과는 다른 scope을 갖게된다.(runBlocking 자식 coroutine이 아님)
  • 따라서 runBlocking1.3초만 대기하고 종료하고, main 함수가 종료되면서 application process 역시 종료된다.
  • 이에 따라 GlobalScope의 로직도 종료된다.
  • GlobalScope에서 launchactive Coroutineprocess살아있게 하는 역할을 하지 않는다.

사용자 지정 CoroutinScope

버튼을 누르는 등의 동작을 통해 서버에서 이미지를 여는 등의 필요할 때만 열고 완료되면 닫아주는 것

  • 매번 원하는 형태CoroutineContext를 정의할 수 있다.
  • Coroutine생명 주기를 관리할 수 있다.
  • 필요할 때만 선언하고 종료시키고자 할때, 현재화면을 벗어나면 더이상 필요하지 않을때 사용할 수 있다.

Coroutine ContextDispatcher

CoroutineContext의 중요성

  • launchasync모두 CoroutineScope의 확장함수이다.
  • 그런데 CoroutineScope에는 CoroutinCotext 타입의 필드 1개만 들어있는데, 사실 CoroutineScopeCoroutineContext 필드를 launch등의 확장 함수 내부에서 사용하기 위한 매개체 역할만을 담당한다.
  • 원한다면, launch 등에 CoroutineContext를 넘길수도 있다는 점에서 실제로, CoroutineScope보다 CoroutineContext Coroutine 실행에 더 중요한 의미가 있음을 유추할 수 있다.

CoroutineContext란?

  • CoroutineContextContext를 어떻게 처리할 것인지에 대한 정보 집합(Element Set)으로,
    종류는 Dispatcher(Main Element)와 Job, ExceptionHandler가 여기에 속한다
    • 각각의 Elementget 혹은 fold를 통해 추가하거나 꺼내올 수 있다
    • CoroutineContextCoroutine어떻게 실행되고 동작해야 하는지를 정의할 수 있게 해주는 요소들의 그룹이다.

CoroutineContext의 의미와 Dispatcher

  • CoroutineContext는 실제로 Coroutine이 실행중인 여러 작업(Job 타입)과 Dispatcher를 저장하는, 일종의 맵이라 할 수 있다.
    • Context는 결합이 될 수 있고, 분리하여 제거할 수도 있다.
      (→ plus, minus 연산하여 특정 Element를 합치거나 삭제할 수 있음)
      • Combinding context Elements (CoroutineContext 결합)
        • CoroutineContext 여러 개의 Element를 정의할 필요가 있을 때 + operator를 사용한다.
        • 예를 들어 명시적으로 특정 dispatcher를 사용하며 명시적으로 이름을 지정한 coroutinelaunch 하려면
launch(Dispatchers.Default + CoroutineName(“test”)){
    println(“I’m working in thread ${Thread.currentThread().name}”)
}

/* OUTPUT */
I’m working in the thread DefaultDispatcher-worker-1 @test#2
  • Kotlin Runtime은 이 CoroutineContext를 사용해서 다음에 실행할 작업을 선정하고, 어떻게 스레드에 배정할지에 대한 방법을 결정한다.
  • 아래 예시를 통해 같은 launch를 사용하더라도, 전달하는 Context에 따라 서로 다른 스레드상에서 Coroutine이 실행됨을 알 수 있다.NewSingleThreadContext
launch { // 부모 컨텍스트를 사용(이경우main)
	printin ("main runBlocking : I'm working in thread ${Thread.currentThread().name}")
}

launch (Dispatchers.Unconfined) { // 특정 스레드에 종속되지 않음 ? 메인 스레드 사용
	printin ("Unconfined : I ’m working in thread ${Thread.currentThread().name}")
}

launch (Dispatchers.Default) { // 기본 디스패처를 사용
	printin ("Default : I’m working in thread ${Thread.currentThread().name}")
}

launch (newSingleThreadContext ("MyOwnThread”)) { // 새 스레드를 사용
	printin ("newSingleThreadContext: I 'm working in thread ${Thread.currentThread().name}")
}
  • NewSingleThreadContext는 새로운 thread를 생성해서 그곳에서 수행한다.
  • 이는 새로운 Thread를 만들기 때문에 resource 측면에서 가장 비싸다.
  • 실제 앱에서 더이상 사용하지 않으면, close function으로 release 시켜줘야한다. 그렇지 않으면, memory leak이 발생할 수 있다.
  • 또는 Top-Level로 정의해서 Application Lifecycle 전반에서 재사용해야한다.

Coroutine의 생성과 Dispatcher

  • Kotlin은 쓰레드 생성과정을 단순화해서 쉽고 간단하게 쓰레드를 생성할 수 있다.
  • CoroutineThreadThread pool을 쉽게 만들순 있지만, 직접 Access하거나 제어하지 않는다.
    • 해당 Thread pool 제어는 모두 Dispatcher 맡긴다.
      • 우리가 DispatcherCoroutine을 보내기만 하면, Dispatcher Thread Coroutine을 분산시킨다. 이는 Dispatcher에서 Thread에 할당하는 과정을 통해 알 수 있다.
      • Coroutine dispatcherCoroutine의 실행을 특정 하나의 Thread에 한정시킬 수 있고, Thread Pool에 던질수도 있고, 정의되지 않은채로 실행시킬 수도 있다.
      • 따라서 모든 CoroutineBuilder(launch, async 등)들은 Dispatcher를 지정할 수 있으며, CoroutineContext paramoptional로 받는다
        • param이 없는 launch의 경우 실행되는 CoroutineScopecontext
          (그 안의 dispatcher도 당연히)를 상속한다
    • 결국 Coroutine에서의 핵심은 Light-Weight Thread를 어떻게 관리할 것인지 인데,
      이를 담당하는 것이 바로 Dispatcher이라는 것이다.
      • CoroutineContext를 상속받아, 어떤 쓰레드를 이용해 동작할 것인지 미리 정의되어 있다.
        (Coroutine 실행에 사용하는 쓰레드를 결정한다)

Coroutine적당한 Thread에 할당하는 과정

  1. 유저가 Coroutine 생성 후, Dispatcher에 전송
  2. Dispatcher자신이 잡고있는 Thread pool에서 자원이 남는 Thread어떤 Thread인지 확인한 후, 해당 ThreadCoroutine을 전송한다
  3. 분배받은 Thread해당 Coroutine을 수행한다
  • 여기서 CoroutineDispatcher을 만들어야 하는데, 기본적으로 가용성, 부하, 설정을 기반으로 **Thread간에** Coroutine을 분산하는 orchestrator이다.
    (Coroutine에 대한 Task 수행 분배를 어떻게 할것인지 결정하는 역할)
public abstract class CoroutineDispatcher :
    AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {

    /** @suppress */
    @ExperimentalStdlibApi
    public companion object Key : AbstractCoroutineContextKey<ContinuationInterceptor, CoroutineDispatcher>(
        ContinuationInterceptor,
        { it as? CoroutineDispatcher })

    public open fun isDispatchNeeded(context: CoroutineContext): Boolean = true

    public abstract fun dispatch(context: CoroutineContext, block: Runnable)

    @InternalCoroutinesApi
    public open fun dispatchYield(context: CoroutineContext, block: Runnable): Unit = dispatch(context, block)

    public final override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
        DispatchedContinuation(this, continuation)

    public final override fun releaseInterceptedContinuation(continuation: Continuation<*>) {
        val dispatched = continuation as DispatchedContinuation<*>
        dispatched.release()
    }
}
  • 추상 클래스로 지원하고 있다.
  • dispatch라는 메소드를 통해 실행가능한(Runnable)한 task구현체에서 구현된 방식을 토대로 task를 던짐으로써 Coroutine에 대한 동작을 실행하게 되는 것이다.
  • Task를 관리하는 구현체 중 하나인 EventLoop 코드를 보게되면
     
public final override fun dispatch(context: CoroutineContext, block: Runnable) = enqueue(block)

public fun enqueue(task: Runnable) {
    if (enqueueImpl(task)) {
        // todo: we should unpark only when this delayed task became first in the queue
        unpark()
    } else {
        DefaultExecutor.enqueue(task)
    }
}
  • Dispatcher는 정말 말 그대로 taskEventLoop Queue에 넣고 끝내는 작업만 진행한다.
  • 그러면 Event Loop를 관리하는 Thread에서 하나씩 Deque하여 Task를 꺼내 실행하게 되는 것이다.

즉 Disptacher는 Coroutine이 동작하는 방식하고 연관이 있는 것이 아닌 Task에 대한 분배에 대한 책임만 있다.

  • 또한, Custom Threadpool을 위한 Dispatcher도 생성할 수 있다.
    • Executors 라이브러리를 이용해, Custom으로 만든 Thread pool에도 지정이 가능
    • 예시 (우선순위가 높은 Thread pool에서 동작하는 CoroutineScope를 만든 예)
val customExecutor: Executor = Executors.newCachedThreadPool { r ->
	 Thread(r, "CustomThread").apply {
		 priority = Thread.MIN_PRIORITY 
	}
} 
val customDispatcher = object : CoroutineDispatcher() {
	 override fun dispatch(context: CoroutineContext, block: Runnable) {
		 customExecutor.execute(block) 
	} 
} 
CoroutineScope(customDispatcher).launch { }
  • Dispatcher가 만들어지면 이를 사용하는 Coroutine을 시작할 수 있다.

대표적인 Dispatchers

  • Android에서는 상황에 맞는 Dispacther가 이미 생성되어있다.
    1. Dispatchers.Default
      • 복잡하고 오래 걸리는 작업 (CPU를 많이 사용하는 작업 - 데이터 정렬, 복잡한 연산)에 최적화되어 있다.
      • 공유된 Background Thread Pool을 사용한다. (RxComputation Scheduler을 생각)
      • launch(Dispatacher.Default) { ... } 와 GlobalScope.launch{ .. }는 동일한 Default Dispatcher를 사용한다.
    2. Dispatchers.IO
      • 파일 입출력, API Call(네트워크, 디스크, DB 작업)에 최적화되어 있다.
    3. Dispatchers.Main
      • Main Thread 작업(UI와 상호작업) 및 Non-Blocking 코드에 최적화되어 있다.
      • Android Main Thread에서 Coroutine을 실행하는 Dispatcher이다.
    4. Dispatchers.Unconfined
      • 호출한 Context기본으로 사용하는데, 중단 후 다시 실행될 때 Context가 바뀌면, 바뀐 Context따라가는 특이한 Dispatcher이다.
      • (1) CoroutineCPU 시간을 사용하지 않고
        (2) UI를 비롯한 특정 쓰레드에 제한된 공유 데이터(shared data)를 업데이트 하지 않는 경우에 사용하는 것이 적절하다.
      • 호출한 Thread에서 Coroutine을 수행(시작)하지만 1번째 suspension 지점까지만 유효하다.
      • suspension이 끝나면, 호출된 suspension 함수에 의해 다른 Thread Coroutine을 재개한다.
        (suspension이 resume될 때는 suspending function을 수행된 thread에서 resume된다)
      • 예시 
runBlocking{
    launch(Dispatchers.Unconfined){
        print(“Unconfined : I’m working in thread ${Thread.currentThread().name}”)
        delay(500L)
        print(“Unconfined : After delay in thread ${Thread.currentThread().name}”)
    }
 
    launch{
        print(“Main : I’m working in thread ${Thread.currentThread().name}”)
        delay(1000L)
        print(“Main : Aftrer delay  in thread ${Thread.currentThread().name}”)
    }
}

/* OUTPUT */
  Unconfined : I’m working in thread main
  Main : I’m working in thread main
  Unconfined : After delay in thread kotlinx.coroutines.DefaultExecutor
  Main : After delay in thread main
  • Unconfind의 경우, delay가 DefaultExecutor에 의해 호출되기 떄문에, resume은 DefaultExecutor에서 된다.
  • Unconfined Dispatcher는 고급 Machanism으로 특정 case에 유효하다. 따라서 일반적인 코드에서는 쓰지 않는 것이 좋다.
  1.  

Dispatcher 전환하면서 Coroutine 붙이기

  • Main Thread에서 Coroutine을 실행하되, 몇몇 Coroutine다른 Thread에서 실행하도록 할 수도 있다.
CoroutineScope(Dispatchers.Main).launch { // 0. Main Dispatcher를 기본으로 설정
    // 1. 데이터 입출력을 해야 하므로 IO Dispatcher에 배분
    val deferredInt: Deferred<Array<Int>> = async(Dispatchers.IO) { 
        println(1)
        arrayOf(3, 1, 2, 4, 5) // 마지막 줄 반환
    }
    
    // 2. Sort해야 하므로 CPU작업을 많이 해야하는 Default Dispatcher에 배분
    val sortedDeferred = async(Dispatchers.Default) { 
        val value = deferredInt.await()
        value.sortedBy { it }
    }

    // 3. 설정하지 않으면 기본 Dispatcher인 Main Dispatcher에 보내진다. 
    // 3. TextView에 세팅하는 것은 UI 작업이므로 Main Dispatcher에 배분
    val textViewSettingJob = launch {  
        val sortedArray = sortedDeferred.await()
        setTextView(sortedArray)
    }
}

→ Coroutine 생성시, Disptacher을 설정하는 것만으로도 Dispatcher 전환이 가능하다

ExceptionHandler

  • 만약 아래처럼, Coroutine 안에서 Exception이 발생하면?
GlobalScope.launch(Dispatchers.IO) {
    launch {
        throw Exception()
    }
}

Application이 갑자기 죽을 것이다.

  • 이는 CoroutineExceptionHandler를 이용하여 예외처리 할 수 있다.
  • 자세한 내용은 이후의 예외처리 내용을 참고한다.

Coroutine 생성 과정

  1. 사용할 Dispatcher 결정
  2. Dispatcher를 이용해 CoroutineScope를 만든다
  3. CoroutineScopelaunchasync수행할 코드블럭을 넘긴다
  • 예시
GlobalScope.launch {
	delay(1000L)
	println("World!")
}
println("Hello,")
Thread.sleep(2000L)

CoroutineBuilder와 일시중단 함수

CoroutineBuilder

  • 이전에 나온 launchasync, runBlocking은 모두 CoroutineBuilder라고 불린다. 이들은 Coroutine을 만들어주는 함수이다.
  • 이외에도 kotlinx-coroutines-core 모듈이 제공하는 CoroutineBuilder는 아래와 같이 2가지가 더있다
    1. produce
      • 정해진 채널로 데이터를 Stream으로 보내는 코루틴을 만든다
      • 이 함수는 ReceiveChannel<>을 반환한다.
      • 채널로부터 메시지를 전달받아 사용할 수 있다.
    2. actor
      • 정해진 채널메시지를 받아 처리하는 Actor를 코루틴으로 만든다.
      • 이 함수가 반환하는 SendChannel<> 채널의 send() 메소드를 통해 Actor에게 메시지를 보낼 수 있다.

Coroutine Builder의 2가지 flavor

  1. Exception을 자동으로 전파
    • launch, actor
    • 자동 전파하는 케이스는 JavauncaughtExceptionHandler와 비슷하게 unhandled exception
    • launch로 생성한 Coroutine의 경우 자식에서 catch하지 않은 예외는 부모를 취소시킨다
    • 기본적으로 Job 내부에서 발생하는 예외Job을 생성한곳까지 전파되기 때문에, 완료되기를 기다리지 않아도 발생한다.
  2. 유저에게 노출
    • async, produce
    • 유저에게 노출되는 경우는 유저가 직접 exception을 직접 핸들링할 수 있다 (try-catch할수있다)
    • 대표적 API는 awaitreceive
    • async로 생성한 Coroutine은 결과에 캡슐화되기 때문에, Catch되지 않는 예외가 없다.
    • 예시
      • 예시 (예외를 Throw하는 Function인 doSomeThing()async()를 통해 task를 실행하는 코드)
         
runBlocking {
  val task = GlobalScope.async {
    doSomething()
  }

//(1) task.join()
//(2) task.await()
}

fun doSomething() {
  throw UnsupportedException("Can't do")
}
  • join()을 통해, task를 실행하면 에러가 발생하지 않고 성공적으로 실행되지만,
    await()를 통해 실행 하게되면, 에러가 발생하면서 종료하게 된다.
  • await()의 경우 예외를 감싸지 않고, 전파하기 때문에, upwrapping deferred라고 불린다.
  • 이처럼 join()으로 대기한 후, 검증하고 오류를 처리하는 것과
    await()를 직접 호출하는 방식의 차이는 예외 전파의 유무라고 볼 수 있다.

일시중단 함수(Suspending Function)

  • 해당 함수들은 Coroutine 내부 혹은 runBlocking()과 같은 Routine의 대기가 가능한 구문안에서만 동작이 가능하다.
  • delay()yield()와 같은 함수들도 일시중단 함수라고 부른다.
  • 이외에도 kotlinx-coroutines-core 모듈의 최상위에 정의된 일시 중단 함수는 아래와 같이 더 있다.
    1. withContext
      • 부모 Coroutine에서 사용되던 Context다른 Context Coroutine전환한다
      • Coroutine에서 결과를 반환할 때 async 대신 유용하게 사용할 수 있다.
    2. withTimeout
      • Coroutine정해진 시간안에 실행되지 않으면 예외를 발생시키도록 한다.
    3. withTimeoutOrNull
      • Coroutine정해진 시간안에 실행되지 않으면 null을 결과로 돌려준다.
    4. awaitAll
      • 모든 작업의 성공을 기다린다.
      • 작업 중 어느 하나가 예외로 실패하면 awaitAll그 예외로 실패한다.
    5. joinAll
      • job이 여러개 인 경우, 이를 이용해 모든 Coroutine이 완료되는 것을 기다릴 수 있다.
      • 모든 작업이 끝날 때 까지 현재 작업을 일시 중단시킨다.

withContextjoin(), await() 대체

  • withContextThread간 Jump에 사용된다.
    따라서, withContext를 사용함으로써 사용하는 Thread 변경 가능하다
  • Coroutine쓰레드로부터 독립적(Thread independent)이기에, Main Thread에서 하나의 Coroutine을 시작하고, 이것다른 SubThread1으로 보내고, 또 SubThread2로 보냈다가, 다시 MainThread에서 작업하는게 가능하다.
    → 이렇게 Context Switching을 해주는 것이 withContext의 역할이다
  • 기존에 다른 Coroutine보내진 작업을 수신하려면 다음과 같은 코드가 필요했다
     
suspend fun main() {
    val deferred: Deferred<String> = CoroutineScope(Dispatchers.IO).async {
        "Async Result"
    }
    
    val result = deferred.await()

    println(result)
}


Deferred로 결과값을 감싼 다음, await() 메소드를 통해, 해당 값이 수신될때 까지 기다려야한다

 

suspend fun main() {
    val result: String = withContext(Dispatchers.IO) {
        "Async Result"// 반환 값
    }
    
    // result = "Async Result"

    println(result)
}

위와 같이 withContext를 통해 코드양을 줄일수 있게 되었다.

  • withContext()async와 동일한 역할을 하는 키워드인것을 알 수 있다.
  • 차이점은 (1) await()를 호출할 필요가 없으며
    (2) 마지막 구문에 해당하는 결과return될 때까지 기다린다.
  • 즉 프로세스에 Job을 포함시키지 않고도 다른 Context로 전환할 수 있게 해주는 일시중단 함수이다. (참고로 이는, withContext 내부적으로 async{}. await()로 구현되어 있어 내부 코드가 모두 실행된 다음 다음 코드로 넘어가기 때문이다.)

withContext()의 효용

suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T (source)
  • 예시를 통해 알 수 있는, withContext2가지 특성
    1. withContext 블럭의 마지막 줄의 값이 반환값이 된다
    2. withContext가 끝나기 전에 해당 Coroutine은 일시 정지
  • 이러한 특성으로 withContext를 이용하면, 비동기 작업을 순차코드처럼 작성할 수 있다.
  • 기본적으로 부모 CoroutineDispatcher을 사용하지만, withContextDispatcher를 달리 사용할 수 있게 되는 것이다.
    • 예시 1
CoroutineScope(Dispatchers.Main).launch { 
            // ui 처리 코드
            // ...
            val result = withContext(Dispatchers.IO){
                readFile()
            }
            Log.d("코루틴", "$result")
        }
    • 예시2 (I/O Thread에서 작업후, 순차적으로 Main Thread에서 보여주기 위한것)
    • 예시 3
GlobalScope.launch(Dispatchers.IO) {
    Log.d(TAG, "Do something on IO thread")   //  1 IO 쓰레드에서 실행
    val name = withContext(Dispatchers.Main) {    // 2 Main 쓰레드에서 실행
        sleep(2000)
        "My name is Android"
    }   
    // 3 withContext() 다음 코드를 수행하지 않음. 
		//await()을 호출한 것처럼 결과가 리턴되기를 기다림
    Log.d(TAG, "Result : $name")// 4 withContext()의 코루틴이 모두 수행되면 이 코드가 수행
}

/* OUTPUT */
  10-26 22:44:16.488  9723  9752 D MainActivity: Do something on IO thread
  10-26 22:44:18.649  9723  9752 D MainActivity: Result : My name is Android
  • 주의할 점은 Main, I/O, Default Dispatcher들이 suspendResume을 할때, suspend한 시점의 thread에서 정확하게 resume하지 않을 수 있으므로,
    thread안에서만 유효한 변수를 사용할 때는 주의를 기울어야 한다. 

withTimeoutOrNull

  • 수동으로 Job을 직접 Referencing하면서, 특정 시간 이후에 취소시킬수도 있지만, Coroutine은 이럴때, 사용하기 쉬운 함수인 withTimeoutOrNull 을 제공해준다.
  • 만약 네트워크 호출을 했는데, 서버의 상태가 좋지 못하거나, 디스크 입출력을 하다 에러가 발생하는 경우,
    해당 작업을 취소하고 유저에게 알려주는 등의 작업을 해야한다
    • 이러한 이유로 실제 Product에서 중요한 부분은 Timeout과 관련된 부분이기에 이 함수를 활용한다
  • 인자에 시간을 넣어주고, 메소드를 실행시켜준다
    • 해당 시간이 지나도 정상적으로 종료되지 않으면, null을 반환하므로,
      null값을 이용해 유저에게 정보를 알려줄 수 있다.

withTimeout

  • 마찬가지로 실행시간이 timeout을 넘겨, Coroutine의 취소하는 경우에 사용된다.
     
/* Coroutine에 Timeout을 설정하고 이 시간을 넘어설 경우 Coroutine이 취소되도록 구현하는 것을 Coroutine 기본 함수로 구현할 경우 */
fun main(args: Array<String>) = runBlocking<Unit> {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("I'm sleeping $i ...")
                delay(500L)
            }
        } finally {
            println("main : I'm running finally!")
        }
    }
    
    launch {
        delay(1300L)
        println("main : I'm tired of waiting. Cancel the job!")
        if (job.isActive) {
            job.cancelAndJoin()
        }
    }
}

// 1. 제한 시간을 설정할 대상이 되는 코루틴을 생성한다.
// 2. 일정 시간(Timeout) 지연 후 전달 받은 Job이 끝나지 않았으면 취소하는 동작을 하는 코루틴을 생성하고, 1번에서 만들고 실행한 코루틴의 Job 객체를 전달한다. 
// 3. 테스트를 위해 1번 코루틴은 2번 코루틴에서 설정한 시간보다 긴 수행시간을 갖도록 구현한다.
  fun main() = runBlocking{
      withTimeout(1300L){
          repeat(1000){ i ->
              println("I'm sleeping $i ...")
              delay(500L)
          }
      }
  }

  // job: I'm sleeping 0 ...
  // job: I'm sleeping 1 ...
  // job: I'm sleeping 2 ...
  // Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms
  • TimeoutCancellationExceptionwithTimeout에서 던지며, 이 녀석은 CancellationExceptionsubclass이다.
  • 이런 stack trace를 이전에 본 적이 없는데, 그것은 취소된 coroutine에서 던지는 CnacellationExceptionCoroutine 종료의 일반적인 동선으로 보기 때문이다.
  • 하지만 withTimeout 함수를 main Function안에서 직접 사용했기 때문에 Exception이 발생하는 것이다.
  • 취소는 단순히 Exception이기 때문에, res들을 추가로 정리할 것이 있다면, timeout 코드를 try{ .. } catch(e:TimeoutCancellationException) { ... } 블럭으로 감쌀 수 있다.
  • 그러나 특별히 추가적으로 할 일이 없다면, withTiemoutOrNull로 감싸주면되며, 이는 withTimeout과 비슷하지만, timeout시 exception을 던지는 대신 null을 return 하기 때문이다.

newSingleThreadContext를 만들어 Thread간 Jumping

newSingleThreadContext(“Ctx1”).use{ ctx1 ->
    newSingleThreadContext(“Ctx2”).use{ ctx2 ->
        runBlocking(ctx1){
            log(“Started in ctx1”)
            withContext(ctx2){
                log(“Working in ctx2”)
            }
            log(“Back to ctx1”)
        }
    }
}
/* OUTPUT */
[Ctx1 @coroutine#1] Started in ctx1
[Ctx2 @coroutine#1] Working in ctx2
[Ctx1 @coroutine#1] Back to ctx1
  • withContext 블록은 같은 coroutine 안에 머물러 있다는 것을 눈여겨보자.
  • newSingleThreadContext 로 만든 것은 close 를 불러주어야 하는데, kotlin 의 use를 사용하면 이를 자동으로 할 수 있다.
반응형