brunch

You can make anything
by writing

C.S.Lewis

by 서준수 Oct 22. 2024

코루틴 디스패처 (Dispatcher)

코틀린 코루틴 (10)

코루틴 디스패처 (Dispatcher)


코루틴 취소에서 다룬 예제들은 launch의 파라미터로 Dispatchers.Default라는 것을 넘겨주고 있다. 그에 대해서는 일단 고려하지 않았으나 사실 정확한 이해를 위해서는 해당 부분도 무시할 수 없다. Dispatchers.Default는 코루틴 디스패처의 하나이다. 그러면 코루틴 디스패처가 무엇인지 또 의문이 생긴다. 코루틴 디스패처는 CoroutineContext의 요소(Element) 중 하나이다.


그러면 CoroutineContext는 뭘까? CoroutineContext는 코루틴이 실행되는 환경에 대한 요소들의 집합이다. 이미 보았던 Job이 바로 CoroutineContext의 요소 중 하나이다. 그리고 코루틴 디스패처 또한 CoroutineContext의 요소인 것이다.


CoroutineContext는 CoroutineContext.kt 파일에 정의되어 있는 인터페이스이다. 해당 인터페이스는 get(), fold(), plus(), minusKey()라는 4개의 메서드를 가진다. 그중에서 get()은 파라미터에 주어지는 key에 해당하는 컨텍스트를 반환하는 메서드이다. key라는 파라미터의 타입은 Key이다. Key 역시 인터페이스이고 제네릭 타입 파라미터는 Element 타입이다.


Element는 CoroutineContext를 상속하는 인터페이스이다. key를 멤버 속성으로 가지고 해당 key를 기반으로 CoroutineContext에 등록된다. Element의 예로 CoroutineId, CoroutineName, CoroutineDispatcher, CoroutineScope, Job 등이 있고 각 요소들은 고유의 key를 기반으로 CoroutineContext에 등록되는 것이다.


여기까지 이해를 했다면 다음 예제를 보고 launch의 파라미터로 코루틴 디스패처 Dispatchers.Default가 전달 가능한 이유를 알 수 있을 것이다. 먼저 launch의 첫 번째 인자(=context)의 타입이 무엇인지 알아야 한다.

사실 이전에 코루틴 스코프를 이야기할 때 launch를 살펴본 적이 있다. 다시 보면 다음과 같다.

context의 타입이 CoroutineContext이다. Dispatchers.Default는 코루틴 디스패처이다. 코루틴 디스패처는 CoroutineContext의 Element 중 하나이다. Element는 CoroutineContext를 상속하는 인터페이스이다. 따라서 Dispatchers.Default는 CoroutineContext 타입인 context로 전달될 수 있는 것이다.


그러면 CoroutineContext의 Element 중 하나인 코루틴 디스패처는 도대체 무엇을 하는 녀석일까? 코루틴 디스패처는 코루틴이 실행될 스레드 또는 스레드풀을 결정하는 역할을 한다. launch 뿐만 아니라 모든 코루틴 빌더들은 CoroutineContext을 옵셔널 파라미터로 갖는다. 따라서 코루틴 빌더를 통해 새로운 코루틴을 만들 때 코루틴 디스패처는 물론이고 다른 CoroutineContext 요소들도 지정할 수 있다. 일단 여기서는 코루틴 디스패처에 집중한다.


코루틴 디스패처는 특정 목적에 맞게 최적화된 다양한 종류가 존재한다. 주요 디스패처의 종류는 다음과 같다.

Dispatchers.IO: I/O 작업에 최적화된 디스패처다. 파일 읽기/쓰기, 네트워크 요청 등 블로킹 I/O 작업에 사용된다. 일반적으로CPU 코어 수보다 많은 스레드를 처리할 수 있다.
Dispatchers.Unconfined: 코루틴을 호출한 스레드에서 시작하고, 첫 번째 중단 지점까지 실행한다. 이후에는 중단된 지점에서 재개될 때 사용 가능한 스레드에서 실행된다. 이는 특정 스레드에 바인딩되지 않으며, 주로 테스트나 특정 상황에서만 사용된다.
Dispatchers.Default: CPU 집약적인 작업에 최적화된 디스패처이다. 일반적으로 CPU 코어 수와 동일한 수의 스레드를 사용한다. 하지만 필요 시 증감한다. 복잡한 계산이나 데이터 처리 작업에 사용된다.
newSingleThreadContext: 새로운 단일 스레드를 생성하여 코루틴을 실행한다. 특정 작업을 하나의 스레드에서 순차적으로 실행해야 할 때 사용된다.

각 디스패처가 실제로 어떤 스레드에서 실행되는지 확인하는 방법은 간단하다.

실행 결과는 다음과 같다.

Unconfined                        : thread main
IO                                         : thread DefaultDispatcher-worker-1
Default                                : thread DefaultDispatcher-worker-1
main runBlocking             : thread main
newSingleThreadContext: thread MyThread

특정 디스패처를 지정하지 않은 launch { }의 결과가 thread main인 이유는 실행된 코루틴 스코프로부터 context를 상속받기 때문이다. 즉 부모 코루틴의 context를 상속받는다. 이 경우에 부모 코루틴은 runBlocking 코루틴이고 기본적으로 메인 스레드에서 실행된다.


Dispatchers.IO와 Dispatchers.Default는 동일한 스레드풀을 공유한다. 따라서 실행 결과를 보면 둘 다 DefaultDispatcher-worker-1 스레드에서 실행된 것을 확인할 수 있다. 그러면 왜 굳이 둘을 구분하는지 의문이 생길 수 있다. Dispatchers.IO는 I/O 작업에 최적화되어 더 많은 스레드를 사용할 수 있도록 설계되어 있다.


실제로 그렇게 동작하는지 확인하기 위해서 아래와 같은 코드를 작성해 볼 수 있다.

실행 결과는 다음과 같다. (실행할 때마다 결과가 다르다는 것은 알고 있으리라 믿고 추후에 따로 언급하지 않는다.)

IO threads: 44
Default threads: 11
IO thread names: [DefaultDispatcher-worker-1, DefaultDispatcher-worker-3, DefaultDispatcher-worker-2, DefaultDispatcher-worker-7, DefaultDispatcher-worker-5, DefaultDispatcher-worker-6, DefaultDispatcher-worker-10, DefaultDispatcher-worker-9, DefaultDispatcher-worker-4, DefaultDispatcher-worker-11, DefaultDispatcher-worker-12, DefaultDispatcher-worker-8, DefaultDispatcher-worker-15, DefaultDispatcher-worker-14, DefaultDispatcher-worker-18, DefaultDispatcher-worker-19, DefaultDispatcher-worker-22, DefaultDispatcher-worker-16, DefaultDispatcher-worker-13, DefaultDispatcher-worker-23, DefaultDispatcher-worker-17, DefaultDispatcher-worker-20, DefaultDispatcher-worker-21, DefaultDispatcher-worker-28, DefaultDispatcher-worker-24, DefaultDispatcher-worker-31, DefaultDispatcher-worker-30, DefaultDispatcher-worker-29, DefaultDispatcher-worker-25, DefaultDispatcher-worker-34, DefaultDispatcher-worker-32, DefaultDispatcher-worker-27, DefaultDispatcher-worker-37, DefaultDispatcher-worker-33, DefaultDispatcher-worker-38, DefaultDispatcher-worker-40, DefaultDispatcher-worker-35, DefaultDispatcher-worker-36, DefaultDispatcher-worker-43, DefaultDispatcher-worker-42, DefaultDispatcher-worker-44, DefaultDispatcher-worker-26, DefaultDispatcher-worker-41, DefaultDispatcher-worker-39]
Default thread names: [DefaultDispatcher-worker-45, DefaultDispatcher-worker-25, DefaultDispatcher-worker-15, DefaultDispatcher-worker-11, DefaultDispatcher-worker-21, DefaultDispatcher-worker-14, DefaultDispatcher-worker-5, DefaultDispatcher-worker-38, DefaultDispatcher-worker-22, DefaultDispatcher-worker-12, DefaultDispatcher-worker-29]

Dispatchers.IO에서 확실히 더 많은 스레드를 사용한 것을 실행 결과로 직접 확인할 수 있다.


이렇게 코루틴 디스패처가 코루틴이 실행될 스레드 또는 스레드풀을 결정하는 것을 확인했다. 그러면 이제 앞서 언급했던 내용 중에서 어쩌면 이해하지 못했을 내용에 대한 퍼즐을 맞출 수 있다.


코루틴(Coroutine) 생성 및 실행에서 코루틴은 특정한 스레드에 종속되지 않고 일시정지를 통해 동시성을 제공한다고 했다.


코루틴 delay 함수에서 다시 한번 코루틴은 특정한 스레드에 종속되지 않는다고 강조했다. 또한 다른 스레드(또는 스레드풀)에서 실행되는 launch가 두 개라면 실행 순서를 보장할 수 없다고 했다. 그리고 이 내용이 이해되지 않으면 그냥 넘어가고 추후 Dispatcher라는 개념을 다룰 때 다시 이야기하게 될 것이라고 했다.


코루틴 취소에서 Dispatchers.Default를 사용했을 때 cancel()이 호출되기 전에 launch 블록이 실행될 가능성이 높다고 했다.


(다 계획이 있었죠?)


그럼 하나씩 확인해 보자.


코루틴이 특정한 스레드에 속하지 않는다는 것은 좀 더 명확히 보려면 다음과 같은 JVM 옵션을 주면 된다.

-Dkotlinx.coroutines.debug

인텔리제이를 사용한다면 아래와 같은 경로에 추가하면 된다.

이렇게 하면 실행 결과에서 스레드 이름에 코루틴 정보가 추가된다. 예를 들면 다음과 같이 보인다.

 DefaultDispatcher-worker-2 @coroutine#3

@coroutine#3라는 코루틴 정보가 추가된 것을 확인할 수 있다.


1) 코루틴은 특정한 스레드에 종속되지 않는다.

그럼 실제로 동일한 코루틴이 특정 스레드에 종속되지 않고 동시성을 가지는 예제 코드를 보자.

실행 결과는 다음과 같다.

IO threads: 3
Default threads: 3
IO thread names: [DefaultDispatcher-worker-2 @coroutine#2, DefaultDispatcher-worker-1 @coroutine#2, DefaultDispatcher-worker-3 @coroutine#2]
Default thread names: [DefaultDispatcher-worker-3 @coroutine#3, DefaultDispatcher-worker-2 @coroutine#3, DefaultDispatcher-worker-1 @coroutine#3]

@coroutine#2는 DefaultDispatcher-worker-1, DefaultDispatcher-worker-2, DefaultDispatcher-worker-3 모두에서 실행되었다. @coroutine#3도 마찬가지다. delay()를 통해 다른 코루틴이 실행된 후 다시 돌아온 코루틴은 이전과 다른 새로운 스레드에서 실행된 것이다.


2) 다른 스레드에서 실행되는 launch가 두 개라면 실행 순서를 보장할 수 없다.

앞선 예제를 다음과 같이 조금 바꿔보자.

이 경우는 먼저 선언된 launch가 항상 먼저 실행된다는 보장이 없다. 몇 번 반복하여 실행하다 보면 다음과 같이 Dispatchers.IO가 먼저 실행되는 경우와 Dispatchers.Default가 먼저 실행되는 경우가 모두 나타난다.

././/.././
/./../././

launch의 인자에 디스패처를 지정하지 않고 메인 스레드를 사용하면 ./././././로 반복된다. 먼저 선언된 launch가 먼저 실행된다. 다른 스레드에서 실행되는 경우가 아니기 때문이다.


혹여나 newSingleThreadContext("MyThread")라는 것을 두 launch의 인자로 선언하면 같은 스레드라고 생각할 수 있다. 하지만 String()처럼 해당 객체가 생성될 때는 서로 다른 객체이다. 이름이 같다고 같은 스레드가 아니다. 스레드는 각각의 고유 id를 가진다.


같은 스레드를 넘겨주려면 아래와 같이 한 번만 생성하고 이것을 공유해야 한다.

val myThread = newSingleThreadContext("MyThread")

그리고 항상 새로운 코루틴을 위해서 새로운 스레드를 생성하는 것은 리소스가 많이 든다. 따라서 사용하지 않을 경우 close() 함수로 해제를 해야 한다.


3) Dispatchers.Default를 사용했을 때 cancel()이 호출되기 전에 launch 블록이 실행될 가능성이 높다.

이제 디스패처가 다른 스레드나 스레드풀에서 코루틴이 실행되도록 할 수 있다는 것을 안다. 그러면 아래와 같은 예제에서 launch 블록은 메인 스레드가 아닌 다른 스레드를 이용한다는 것도 쉽게 알 수 있다.

실행 결과는 다음과 같다.

Hello, main @coroutine#1
World! DefaultDispatcher-worker-1 @coroutine#2

runBlocking에서 사용하는 메인 스레드가 아닌 별개의 스레드를 사용하니 메인 스레드의 job.cancel()이 호출되기 전에 launch 코루틴이 실행될 수 있다. 그렇기 때문에 취소가 되지 않는 것이다. launch 블록에 delay(1)만 해주어도 취소가 된다.


반대로 별도의 디스패처를 사용하지 않고 메인 스레드에서 launch를 실행할 때 cancel() 전에 delay(1)만 주어도 cancel()이 동작하지 않는다. 찰나의 순간에 따른 차이다.


왜 디스패처가 필요해?

이제 코루틴 디스패처가 코루틴이 실행될 스레드 또는 스레드풀을 결정하는 것까진 알겠다. 그래서 코루틴 디스패처를 사용하면 어떤 장점이 있을까?


코루틴 디스패처는 코루틴의 장점을 극대화하는 중요한 요소이다.


만약 UI 업데이트와 같은 작업을 수행해야 할 메인 스레드에서 코루틴을 사용하여 IO 작업을 한다면 어떨까? 비효율적이고 성능 문제가 발생할 수 있다. 적절한 스레드에서 동작할 수 있도록 하는 것은 성능 최적화에 영향을 주는 것이다.


다른 상황을 들어보자. 컴퓨터 공학을 전공했거나 운영체제 관련 지식이 있다면 컨텍스트 스위칭이란 용어를 들어봤을 것이다. 컨텍스트 스위칭을 간단히 말하면 프로세스나 스레드를 다른 프로세스나 스레드로 CPU의 제어권을 넘기는 것이다. 이때 기존 프로세스나 스레드의 정보를 저장하고 복원하는 과정에서 리소스가 소모된다. 따라서 스레드 전환이 자주 일어나면 리소스가 낭비되는 것이다.


코루틴을 경량 스레드라고도 표현한다. 프로세스 내부에서 스레드가 동작하듯이 스레드 내부에서 코루틴이 동작한다. 따라서 코루틴을 사용하면 스레드의 전환을 줄일 수 있다. 특히 적절한 디스패처를 사용하면 사용자가 좀 더 최적화할 수 있는 여지가 있다. Dispatchers.IO와 Dispatchers.Default의 큰 차이점이 무엇이었는가? 사용하는 스레드의 수 차이였다. Dispatchers.Default는 CPU 집약적인 작업에 최적화되어 있다고 했는데 좀 더 풀어서 말하면 외부 자원에 접근하는 빈도가 낮고 CPU의 연산 능력을 많이 사용한다는 의미다. 그것이 컨텍스트 스위칭이 무조건 적다는 의미는 아니지만 Dispatchers.IO와 비교했을 때 스레드를 적게 사용한다는 것을 이미 확인했다. 그렇다는 것은 컨텍스트 스위칭이 적게 발생한다는 것이다.


Dispatcher.kt

Dispatchers.IO 조차도 최대 스레드의 수를 64개로 제한하고 있다. (사용 가능한 프로세서가 64개 보다 많다면 그 값을 따른다. 병렬 처리를 하기 위한 제한이기 때문에 프로세서가 여유롭다면 64개로 제한할 필요가 없기 때문이다.) 이것 역시 과도한 컨텍스트 스위칭을 막기 위한 것이다.


요약하자.

- CoroutineContext는 코루틴이 실행되는 환경에 대한 Element들의 집합이다.
- 코루틴 디스패처는 CoroutineContext의 Element 중 하나이다.
- 코루틴 디스패처는 코루틴이 실행될 스레드 또는 스레드풀을 결정한다.
- 코루틴은 특정한 스레드에 종속되지 않는다.
- 코루틴 디스패처를 통해 성능을 최적화할 수 있다.
브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari