[Coroutine] CoroutineContext 와 CoroutineScope 에 대해

Coroutine Study

Posted by JungHoon-Park on January 23, 2023

CoroutineContext 와 CoroutineScope 에 대해


CoroutineContext

자 우선 CoroutineContext 내부를 살펴보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public interface CoroutineContext {
    /**
     * Returns the element with the given [key] from this context or `null`.
     * Keys are compared _by reference_, that is to get an element from the context the reference to its actual key
     * object must be presented to this function.
     */
    public operator fun <E : Element> get(key: Key<E>): E?
    /**
     * Accumulates entries of this context starting with [initial] value and applying [operation]
     * from left to right to current accumulator value and each element of this context.
     */
    public fun <R> fold(initial: R, operation: (R, Element) -> R): R
    /**
     * Returns a context containing elements from this context and elements from  other [context].
     * The elements from this context with the same key as in the other one are dropped.
     */
    public operator fun plus(context: CoroutineContext): CoroutineContext = ...impl...
    /**
     * Returns a context containing elements from this context, but without an element with
     * the specified [key]. Keys are compared _by reference_, that is to remove an element from the context
     * the reference to its actual key object must be presented to this function.
     */
    public fun minusKey(key: Key<*>): CoroutineContext
}

/**
 * Key for the elements of [CoroutineContext]. [E] is a type of element with this key.
 * Keys in the context are compared _by reference_.
 */
public interface Key<E : Element>

/**
 * An element of the [CoroutineContext]. An element of the coroutine context is a singleton context by itself.
 */
public interface Element : CoroutineContext {
    /**
     * A key of this coroutine context element.
     */
    public val key: Key<*>

    ...overrides...
}
  • get() : 연산자 (operator) 함수로서 주어진 Key 에 해당하는 Context 요소를 반환
  • fold() : 초기값 (initialValue) 를 시작으로 제공된 병합 함수를 이용하여 대상 컨텍스트 요소들을 병합한 후 결과를 반환
  • plus() : 현재 컨텍스트와 파라미터를 주어진 다른 컨텍스트가 갖는 요소들을 모두 포함하는 컨텍스트를 반환, 현재 컨텍스트 요소 중 파라미터로 주어진 요소에 이미 존재하는 요소는 버린다.(중복이면 버림)
  • minusKey() : 현재 컨텍스트에서 주어진 키를 갖는 요소들을 제외한 새로운 컨텍스트를 반환한다.

그리고 Key 에 대한 인터페이스 정의가 있는데 Key 는 Element 타입을 제네릭 타입으로 가져야 한다.
Element 는 CoroutineContext 를 상속하며 앞서 이야기 한 Key 를 멤버 속성으로 갖는다.

CoroutineContext 를 구성하는 Element 들

  • CoroutineId
  • CoroutineName
  • CoroutineDispatcher
  • CoroutineExceptionHandler
  • ContinuationInterceptor

이런 Element 들은 각각의 Key 를 기반으로 CoroutineContext 에 등록 된다.
-> CoroutineContext 에는 CoroutineContext 를 상속한 Element 들이 등록될 수 있고, 각 요소들이 등록 될 때는 고유한 키를 기반으로 등록됨

CoroutineContext 는 인터페이스로써 이를 구현한 구현체로는 다음과같은 3가지 종류가 있다

  • EmptyCoroutineContext: 특별히 컨텍스트가 명시되지 않을 경우 이 singleton 객체가 사용됩니다.
  • CombinedContext: 두개 이상의 컨텍스트가 명시되면 컨텍스트 간 연결을 위한 컨테이너역할을 하는 컨텍스트 입니다.
  • Element: 컨텍트스의 각 요소들도 CoroutineContext 를 구현합니다.

blog1

위 이미지는 우리가 GlobalScope.launch{} 를 수행할 때 launch 함수의 첫번째 파라미터CoroutineContext 에 어떤 값을 넘기는지에 따라서 변화되어 가는 코루틴 컨텍스트의 상태를 보여줍니다.
각각의 요소를 + 연산자를 이용해 연결하고 있는데 이는 앞서 설명한 것처럼 CoroutineContext 가 plus 연산자를 구현하고 있기 때문입니다.
Element + Element + … 는 결국 하나로 병합 된 CoroutineContext (e.g. CombinedContext)를 만들어냅니다.

CoroutineScope

CoroutineScope 의 내부를 보자

1
2
3
4
5
6
public interface CoroutineScope {
    /**
     * Context of this scope.
     */
    public val coroutineContext: CoroutineContext
}

CoroutineScope 은 기본적으로 CoroutineContext 하나만 멤버 속성으로 정의하고 있는 인터페이스 이다.

우리가 사용하는 모든 코루틴 빌더들 (코루틴빌더-launch, async 스코프빌더 - coroutineScope, withContext 등등..) 은 CroutineScope의 확장 함수로 정의 된다.

다시말해, 이 빌더들은 CoroutineScope 의 함수들인 것이고, 이들이 코루틴을 생성할 때는 소속된 CoroutineScope 에 정의된 CoroutineContext 를 기반으로 필요한 코루틴들을 생성해 낸다.

왜 코루틴은 스레드보다 가볍다고 하나?

코루틴 하나가 새로 생성되어 실행된다는 것 ? .. 그와 동시에 새로운 스레드 또한 생성되는 것을 의미하는 것은 아니다 ❌ 
( 정확히 말하자면 이것은 코루틴 생성 시 스케줄러 설정에 따라 다르다. )

사실, 코루틴은 스케줄링 가능한 코드 블록 혹은 이러한 코드 블록들의 집합이라고 볼 수 있다.

blog2

제일 왼쪽 CoroutineScope 이 있다. 우리가 어떤 코루틴을 실행하기 위해서는 어떤 코루틴 스코프에 속해 있어야 한다.

현재 코루틴 스코프가 갖는 컨텍스트(CoroutineContext) 에서 Dispatcher 는 UI Dispatcher 라고 되어 있다.

이것은 현재 스코프에서 실행되는 중단 함수들은 UI Thread 에서 수행 된다는 것을 의미한다.

이 스코프 안에서 코루틴을 하나 생성 했다. ( 보라색 이미지 ) 이 코루틴은 자신이 실행되는 스코프 (부모) 의 컨텍스트를 그대로 상속하고 Dispatcher 만 ThreadPoolDispatcher 로 재정의 하였다.

—> 재정의 하지 않으면 기본적으로 속해 있는 스코프로부터 모두 상속한다. 이제 이 코루틴에서 수행하는 함수는 ThreadPoolDispatcher 를 이용하여 워커스레드에서 수행된다.

이때, launch { } 와 같이 빌더를 실행했을 경우 마지막으로 넘긴 코드 블록 { code block }

즉, 실제 수행하고자 하는 로직이 담긴 코드 블록은 Continuation 이라는 단위로 만들어진다.

→ 이렇게 Continuation 으로 변경 된 코드 블록은 최초에 suspend 상태로 생성 되었다가 resume() 요청으로 인해 resumed 상태로 전환되어 실행 된다.

Continuation 의 재개 (resume) 가 요청될 때마다 현재 컨텍스트의 dispatcher 에게 dispatch(스레드전환) 가 필요한지 isDispatchNeeded() 함수를 이용해 확인 한 후 dispatch 가 필요하면 dispatch() 함수를 호출하여 적합한 스레드로 전달하여 수행된다.

지금은 전반적인 구조에 대한 이해를 돕기 위해 코루틴 생성 시 Dispatcher 를 달리 가져갔지만 만약 Dispatcher 를 재정의하지 않고 UI Dispatcher 를 그대로 상속받아 사용한다면 어땟을까 ?

그러면 일반적인 함수 호출과 동일하게 수행된다.

바로 이것이 코루틴이 경량 스레드라고 불리는 이유이다. 코루틴은 Dispatcher 에 의해 실행되는 환경 (Thread)가 결정될 수 있지만, 그 자체로는 환경을 새로 구성하거나 변경하지 않는다.

이 코드가 OOM 없이 동작하는 이유 ?

1
2
3
4
5
6
7
8
fun main() = runBlocking {
    repeat(100_000) {
        launch {
            delay(1000L)
            print(".")
        }
    }
}
OOM 없이 동작하는 이유 ?

위 코드는 코루틴을 10만개 수행하는 코드이다.
위 예제에서 launch { } 코루틴 빌더는 Dispatcher 를 재정의 하지 않았기 때문에 현재 스코프(runBlocking) 의 Dispatcher 를 그대로 사용한다. runBlocking 코루틴 빌더는 내부적으로 GlobalScope 를 사용하며 Dispatcher 는 BlockingEventLoop 를 사용하는대, 이는 큐를 이용한 이벤트 루프 형태의 Dispatcher 구현이다. 그래서 위 코드는 실행 스레드에서 이벤트 루프 기반으로 10만번의 이벤트를 발생하여 점(”.”) 을 출력하게 되면 스레드 부하는 없으므로 OOM 을 피할 수 있다.


출처 : https://myungpyo.medium.com/reading-coroutine-official-guide-thoroughly-part-0-20176d431e9d