본문 바로가기

안드로이드/Kotlin

[Kotlin] Introduction to Coroutines and Channels-4

개요

해당 게시글은 Welcome to Kotlin hands-on (kotlinlang.org)을 번역한 게시글입니다.

 

Structured Concurrency(구조화된 동시성)

Coroutine scope은 서로 다른 코루틴 사이에서 구조(structure)를 담당하고 부모-자식 관계를 맺는다. Scope 내부에서 언제는 코루틴을 생성할 수 있는 것을 확인했다. Coroutine context는 담당하는 코루틴이 동작하는데 사용되는 기술적인 정보를 저장한다. (코루틴 이름, 코루틴이 스케쥴링 되어야 하는 스레드를 명시한 dispatcher 등)

 

새로운 코루틴을 시작할 때 launch, async, runBlocking을 사용하고 자동적으로 상응하는 scope을 생성하게 된다. 이들은 리시버를 인자로 받는 람다 형식을 취하고 암시적으로 리시버의 타입은 CoroutineScope이다.

launch { /* this: CoroutineScope */
}

 

새로운 코루틴은 scope 내부에서만 시작될 수 있다. launch와 async는  CoroutineScope을 확장하는 형태로 선언되었으므로 호출할 때 암시적/명시적 리시버를 항상 전달해야 한다. runBlocking으로 시작되는 코루틴만 예외이다. runBlocking은 최상위 함수로 정의된다. 그러나 현재 스레드를 차단하기 때문에 주요 기능 및 테스트에서 사용하게 된다.

 

새로운 코루틴을 runBlocking, launch, async 내부에서 시작하면 scope 내에서 자동으로 시작된다. 

import kotlinx.coroutines.*

fun main() = runBlocking { /* this: CoroutineScope */
    launch { /* ... */ }
    // the same as:    
    this.launch { /* ... */ }
}

runBlocking 내부에서 lauch를 호출했을 때 CoroutineScope 타입의 암시적 리시버에 대한 확장의 형태로 호출한다. (CoroutineScope.launch{...} 라는 의미) 또는 명시적으로 this.launch를 활용할 수도 있다.

 

이런 중첩된 코루틴을 runBlocking으로 실행된 외부 코루틴의 자식이라고 한다. 자식 코루틴이 부모 코루틴의 scope에서 시작되는 것과 같은 이런 부모-자식 관계는 scope를 통해 동작한다.

 

새로운 코루틴을 실행하지 않고 scope를 생성하는 것도 가능하다. CoroutineScope 함수가 이를 가능케 한다. 외부 scope를 통해 접근하지 않고 suspend 함수 내에서 구조적인 방식으로 새로운 코루틴을 시작하려 할 때, 예를 들면 loadContributorsConcurrent와 같은 경우에 이 suspend 함수가 호출된 외부 scope의 자식이 되는 새로운 코루틴 scope를 만들 수 있다.

launch{ //parent scope
    loadContributorsConcurrent()
}

suspend loadContributorsConcurrent(){
    launch{ //child scope
    	...
    }
}

 

GlobalScope.launch/GlobalScope.async를 통해 global scope레서 새로운 코루틴을 생성하는 것도 가능하다. 이는 최상위의 "독립적인(independant)" 코루틴을 생성한다.

 

코루틴의 구조를 제공하는 이러한 메커니즘을 구조적 동시성(Structured Concurrency)라고 한다. Global scope에 비해 구조적 동시성의 장점은 아래와 같다.

  • scope는 일반적으로 자식 코루틴을 담당하고 이들의 생명 주기는 scope의 생명주기에 연결된다.
  • scope는 뭔가 잘못되거나 유저의 변심으로 작업을 멈추려할 때 자식 코루틴을 자동적으로 취소할 수 있다
  • socpe는 모든 자식 코루틴이 완료될 떄 까지 자동으로 기다린다. 따라서 scope가 코루틴에 해당하는 경우 부모 코루틴은 해당 scope에서 시작된 모든 코루틴이 종료될 때까지 완료되지 않는다.

 

GlobalScope.async를 사용하면 여러 코루틴이 더 작은 scope에 바인딩할 수 있는 구조가 없다. Global scope에서 시작된 코루틴은 모두 독립적이어서 이 코루틴들의 생명주기는 전체 앱의 생명주기에만 제한된다. Global scope에서 시작된 코루틴은 참조를 저장하는 것이 가능하고 명시적으로 완료하거나 취소할 수 있지만 구조화된 경우처럼 자동으로 발생하는 것은 아니다.

 

Cancellation of contributors loading

loadContributorsConcurrent에서 CoroutineScope을 사용하는 버전과 GlobalScope을 사용하는 버전을 비교해보자. 부모 코루틴을 취소했을 때 각각이 어떻게 동작하는지 확인해 볼 것이다.

 

우선 요청을 보내는 모든 코루틴에 3초의 delay를 추가하여 코루틴이 시작된 이후 요청이 전송되기 전에 로딩을 취소될 충분한 시간을 갖게 한다.

suspend fun loadContributorsConcurrent(
    service: GitHubService,
    req: RequestData
): List<User> = coroutineScope {
    // ... 
    async {
        log("starting loading for ${repo.name}")
        delay(3000)
        // load repo contributors
    }
    // ...
    result
}

loadContributorsConcurrentloadContributorCancellation에 복사하고 coroutineScope을 생성하는 것을 지운다. async의 호출이 불가능해졌으므로 이를 GlobalScope.async로 바꾼다.

suspend fun loadContributorsNotCancellable(
    service: GitHubService,
    req: RequestData
): List<User> {   // #1
    // ... 
    GlobalScope.async {   // #2
        log("starting loading for ${repo.name}")
        delay(3000)
        // load repo contributors
    }
    // ...
    return result  // #3
}

이 함수 이제 람다 내부의 마지막 표현식이 아닌 결과를 직접 반환하게 되고(#1,#3), coroutine의 자식 scope가 아닌 GlobalScope 내에서 "contributors" 코루틴이 모두 시작된다(#2).

 

프로그램을 실행시키고 CONCURRENCT 버전을 통해 "contributors" 로딩을 선택하여 시작할 수 있다. 모든 "contributors" 코루틴이 시작될 때까지 기다린 뒤 "cancel"을 시도해야 한다. 로그를 보면 모든 요청이 취소되어 어느 결과도 로그에 기록되지 않은 것을 볼 수 있다.

2896 [AWT-EventQueue-0 @coroutine#1] INFO Contributors - kotlin: loaded 40 repos
2901 [DefaultDispatcher-worker-2 @coroutine#4] INFO Contributors - starting loading for kotlin-koans
...
2909 [DefaultDispatcher-worker-5 @coroutine#36] INFO Contributors - starting loading for mpp-example
/* click on 'cancel' */
/* no requests are sent */

 

이제 이와 똑같은 절차를 NOT_CANCELLABLE에서 수행해보자:

2570 [AWT-EventQueue-0 @coroutine#1] INFO Contributors - kotlin: loaded 30 repos
2579 [DefaultDispatcher-worker-1 @coroutine#4] INFO Contributors - starting loading for kotlin-koans
...
2586 [DefaultDispatcher-worker-6 @coroutine#36] INFO Contributors - starting loading for mpp-example
/* click on 'cancel' */
/* but all the requests are still sent: */
6402 [DefaultDispatcher-worker-5 @coroutine#4] INFO Contributors - kotlin-koans: loaded 45 contributors
...
9555 [DefaultDispatcher-worker-8 @coroutine#36] INFO Contributors - mpp-example: loaded 8 contributors

취소를 했지만 아무 일도 일어나지 않았다. 어떤 코루틴도 취소되지 않았고 모두 요청을 전송했다.

 

"contributors" 프로그램에서 취소가 어떻게 구현되었는지 확인해보자. cancel 버튼이 클릭됐을 때, 명시적으로 "loading" 코루틴을 취소해야 한다. 그럼 자동적으로 자식 코루틴도 취소되게 된다.

 

아래가 "loading" 코루틴을 버튼 클릭을 통해 취소할 수 있는 방법이다.

interface Contributors {

    fun loadContributors() {
        // ...
        when (getSelectedVariant()) {
            CONCURRENT -> {
                launch {
                    val users = loadContributorsConcurrent(req)
                    updateResults(users, startTime)
                }.setUpCancellation()      // #1
            }
        }
    }

    private fun Job.setUpCancellation() {
        val loadingJob = this              // #2

        // cancel the loading job if the 'cancel' button was clicked:
        val listener = ActionListener {
            loadingJob.cancel()            // #3
            updateLoadingStatus(CANCELED)
        }
        addCancelListener(listener)

        // update the status and remove the listener after the loading job is completed
    }
}

launch 함수는 Job 인스턴스를 반환한다. Job은 "loading coroutine"에 대한 참조를 저장하고 이는 모든 데이터를 로딩하고 UI에 결과를 갱신한다. setUpCancellation 확장 함수를 Job 에 대해 호출하고(#1), Job 인스턴스를 리시버로 전달한다. 다른 방법으로는 아래와 같이 명시적으로 작성하는 것이다.

val job = launch { ... }
job.setUpCancellation()

가독성을 위해선, setUpCancellation 함수 내부에서 loadingJob이라는 변수를 통해 리시버를 명시적으로 참조할 수 있다(#2). 그러면 cancel 버튼에 리스너를 추가할 수 있게 되어 버튼을 클릭하면 loadingJob이 취소되게 된다(#3).

 

구조화된 동시성을 통해 부모 코루틴을 취소하는 것만으로 자동으로 자식 코루틴에 취소를 전파하는 것이 가능하다. 위에서 결국 GlobalScope은 구조가 없는 독립적인 코루틴이기 때문에 전파가 안되는 것이고, CoroutineScope는 외부 scope에 대한 context를 상속받는 구조적인 특성이 존재하기 때문에 전파가 가능하다.

 

Using the outer socpe's context

주어진 scope 내에서 새로운 코루틴을 시작하면 모든 코루틴이 동일한 context로 실행되게 하는 것이 훨씬 쉬워진다. 그리고, 필요한 경우에는 context를 대체하는 것이 더 쉽다.

 

이제 이전 섹션의 마지막 질문으로 되돌아가보자. "외부 scope에서 dispatcher를 사용하는 것"이 정확히 어떻게 동작할 까? 더 정확히 표현하면 "외부 scope의 context에서 dispatcher를 사용하는 것"은 어떻게 동작하락?

 

새로운 scope가 coroutineScope이나 coroutineBuilder에 의해 생성되면 항상 외부 scope의 context를 상속하게 된다. 이 경우에 외부 scope는 suspend loadContributorsConcurrent가 호출된 scope인 launch(Dispatchers.Default){ ... }이다.

launch(Dispatchers.Default) {  // outer scope
    val users = loadContributorsConcurrent(service, req)
    // ...
}

 

모든 중첩 코루틴은 context를 상속한 채 자동으로 시작되고 dispatcher도 이 context의 일부이다. 이게 async로 시작된 모든 코루틴이 default dispatcher의 context로 시작되는 이유이다.

suspend fun loadContributorsConcurrent(
    service: GitHubService, req: RequestData
): List<User> = coroutineScope {
    // this scope inherits the context from the outer scope 
    // ... 
    async {   // nested coroutine started with the inherited context
        // ...
    }
    // ...
}

구조적 동시정을 통해 최상위 코루틴을 만들 때 dispatcher와 같은 주요 context 요소를 한번에 명시할 수 있게 된다. 모든 중첩된 코루틴은 이런 context를 상속하고 필요한 경우에는 수정할 수 있다.

 

안드로이드와 같은 UI 앱에서 코루틴을 활용하여 코드를 작성할 때 CoroutineDispatcher.Main을 최상위 코루틴에 사용하고 다른 스레드에서 동작하는 코드가 필요한 경우 다른 dispatcher를 명시적으로 설정하는게 일반적인 관행이다.

 

 

[ Kotlin ] Coroutine 기본 정리 및 Android에서의 사용 방법 (oasisfores.com)

코루틴 공식 가이드 자세히 읽기 — Part 4. 공식 가이드 읽기 (4 / 8) | by Myungpyo Shim | Medium

코루틴(Coroutine)이란 -2 (tistory.com)