Dispatch는 dispatch와 -er의 합성어로 무언가를 보내는 주체이다.
그렇다면 CoroutineDispatcher는 코루틴을 보내는 주체? 라고 생각해볼 수 있다.
CoroutineDispatcher는 코루틴을 스레드로 보내는데 사용할 수 있는 스레드나 스레드풀을 가지며, 실행 요청이 들어온 코루틴을 스레드 풀에서 실행 가능한 스레드로 보내는 역할을 한다.
CoroutineDispatcher에는 실행 요청이 들어오는 코루틴을 임시 보관하는 작업 대기열이 존재한다.
❗️CoroutineDispatcher 구현하는 객체마다 작업 대기열하지 않는 존재하지 않는 CoroutineDispatcher도 있지만 일반적으로 있다고 가정하겠다.
CoroutineDispatcher에는 2개의 스레드를 사용할 수 있는 스레드 풀이 있다고 가정하겠다. 그리고 스레드 풀에는 a 스레드, b 스레드가 있다고 가정하겠다.
1. A 코루틴을 실행 요청
2. CoroutineDispatcher의 작업 대기열에 적재
3. CoroutineDispatcher가 A 코루틴을 사용 가능한 a 스레드로 전달
4. B 코루틴을 실행 요청
5. CoroutineDispatcher의 작업 대기열에 적재
6. CoroutineDispatcher가 B 코루틴을 사용 가능한 b 스레드로 전달
4. C 코루틴을 실행 요청
5. CoroutineDispatcher의 작업 대기열에 적재
6. CoroutineDispatcher가 관리하는 스레드 풀의 모든 스레드가 작업을 실행하고 있기에(모든 스레드 꽉참) C 코루틴을 전달하지 못함
7. a 스레드의 작업 더 빨리 끝나 실행 가능한 상태로 변경됨
8. CoroutineDispatcher가 작업 대기열에 적재된 C 코루틴을 a 스레드로 전달
CoroutineDispatcher에는 사용할 수 있는 스레드나 스레드 풀이 제한된 제한된 디스패처인 Confined Dispatcher와 사용할 수 있는 스레드나 스레드 제한되지 않은 무제한 디스패처인 Unconfined Dispatcher**가 있다.
우리가 흔히 알고 있는 CoroutineDispatcher.IO, CoroutineDispatcher.Default 는 제한된 디스패처이다.
CoroutineDispatcher에 사용가능한 스레들의 수를 설정해서 사용자가 만들 수 있다.
// single thread dispatcher
// 스레드 이름: SingleThread
val dispatcher: CoroutineDispatcher = newSingleThreadContext(name = "SingleThread")
// multi thread dispatcher
// 스레드 이름: MultiThread-1, MultiThread-2
val dispatcher: CoroutineDispatcher = newFixedThreadPoolContext(nThread = 2, name = "MultiThread")
참고로 newSingleThreadContext()는 내부적으로 newFixedThreadPoolContext() 함수를 사용하도록 구현되어 있어 실행 동작 과정이 동일하다.
public fun newSingleThreadContext(name: String): CloseableCoroutineDispatcher = newFixedThreadPoolContext(1, name)
launch 함수를 호출해 만든 코루틴을 특정 CoroutineDispatcher 객체에 실행 요청하기 위해서는 launch 함수의 context 인자로 CoroutineDispatcher를 넘기면 된다.
fun main() = runBlocking<Unit> {
val dispatcher = newSingleThreadContext(name = "SingleThread")
launch(context = dispatcher) {
println("[${Thread.currentThread().name}] 실행"
}
}
코루틴은 구조화를 제공해 코루틴 내부에서 새로운 코루틴을 실행할 수 있다. 이 때 바깥쪽의 코루틴을 부모 코루틴이라고 하고, 내부에서 생성되는 새로운 코루틴을 자식 코루틴이라고 한다.
fun main() = runBlocking<Unit> {
val multiThreadDispatcher = newFixedThreadPoolContext(nThread = 2, name = "MultiThread")
launch(multiThreadDispatcher) { // 부모 코루틴
println("[${Thread.currentThread().name}] 부모 코루틴 실행")
launch { // 자식 코루틴
println("[${Thread.currentThread().name}] 자식 코루틴 실행")
}
launch { // 자식 코루틴
println("[${Thread.currentThread().name}] 자식 코루틴 실행")
}
}
}
/* 결과
[MultiThread-1 @coroutine#2] 부모 코루틴 실행
[MultiThread-2 @coroutine#3] 부모 코루틴 실행
[MultiThread-1 @coroutine#4] 부모 코루틴 실행
*/
자식 코루틴들을 기본적으로 부모 코루틴의 CoroutineDispatcher 객체를 사용한다. 따라서 CoroutineDispatcher에서 여러 작업을 실행해야 한다면 부모 코루틴에 CoroutineDispatcher를 설정하고, 그 아래에 자식 코루틴을 여러개 설정하면 된다.
사용자가 newSingleThreadContext() 나 newFixedThreadPoolContext()를 통해 CoroutineDispatcher를 만들 수 있다.
그러나 이 방법은 비효율적이다.
여러 개발자가 함께 개발할 경우 특정 용도를 위해 만들어진 CoroutineDispatcher 객체가 이미 메모리상에 있음에도 해당 객체의 존재를 몰라 CoroutineDispatcher 객체를 만들어 리소를 낭비할 수 있다.
스레드의 생성 비용은 비싸다는 것을 명심하자!, 그리고 이는 앱을 무겁고 느리게 만들 수 있다.
코루틴 라이브러리는 개발자가 직접 CoroutineDispatcher 객체를 생성하는 문제의 방지를 위해 미리 정의된 CoroutineDispatcher 목록을 제공한다.
kotlinx-coroutine-android 라이브러리를 별도로 추가해야 사용할 수 있다.❗️ Dispatcher.IO, Dispatcher.Default, Dispatcher.Main는 싱글톤 인스턴스이다.
입출력 작업은 네트워크 요청, DB 조회 요청을 하고 결과를 반환 받을 때까지 스레드를 사용하지 않는다.
그러나 CPU 바운드 작업 작업을 하는 동안 스레드를 지속적으로 사용한다.
이것은 비동기 처리를 스레드로 실행하는지 코루틴으로 하는지에 대한 큰 차이가 있다.
입출력 작업을 코루틴을 사용해 실행하면 입출력 작업 실행 후 스레드가 대기하는 동안 해당 스레드에서 다른 입출력 작업을 동시에 실행할 수 있어서 효율적이다. 반면에 CPU 바운드 작업은 코루틴을 사용해 실행하더라도 스레드가 지속적으로 사용되기 때문에 스레드 기반 작업을 사용했을 때와 처리 속도에 큰 차이가 없다.
| 입출력(I/O) 작업 | CPU 바운드 작업 | |
|---|---|---|
| 스레드 기반 작업 사용 | 느림 | 비슷 |
| 코루틴 사용 | 빠름 | 비슷 |
Dispatcher.Default를 사용해 무겁고 오래 걸리는 연산을 처리하면 특정 연산을 위해 Dispatcher.Default의 모든 스레드가 사용될 수 있다. 이 경우 해당 연산이 실행되는 모든 스레드르 사용하기 때문에 Dispatcher.Default를 사용하는 다른 연산이 실행되지 못한다. 이를 방지하기 위해 Dispatcher.Default의 일부 스레드만 사용할 수 있도록 limitedParallelism() 함수를 지원한다.
fun main() = runBlocking<Unit> {
launch(context = Dispatcher.Default.limitedParallelism(2)) {
repeat(10) {
launch {
println("[${Thread.currentThread().name}] 실행")
}
}
}
}
위의 코드는 Dispatcher.Default.limitedParallelism(2)를 사용해 여러 스레드 중 2개의 스레들 가지고 10개의 코루틴을 실행시킨다.
Dispatcher.IO와 Dispatcher.Default는 코루틴 라이브러리의 같은 공유 스레드 풀을 사용한다.
코루틴 라이브러리는 스레드의 생성과 관리를 효율적으로 할 수 있도록 어플리케이션 레벨의 공유 스레드풀을 제공한다.
이 공유 스레드 풀에서는 스레드를 무제한으로 생성할 수 있으며, 코루틴 라이브러리는 공유 스레드풀에 스레드를 생성하고 사용할 수 있는 API를 제공한다.
Dispatcher.IO와 Dispatcher.Default는 모두 이 API를 사용해 구현됐기 때문에 같은 스레드 풀을 사용한다.
물론 공유 스레드풀에서 Dispatcher.IO가 사용하는 스레드와 Dispatcher.Default가 사용하는 스레드는 구분되어 있다.
⚠️ 참고
Dispatcher.IO.limitedParallelism()는Dispatcher.Default.limitedParallelism()은 다르다!
Dispatcher.Default.limitedParallelism()는 해당 코루틴을 실행할 수 있는 스레드의 수를 제한하는 반면에Dispatcher.IO.limitedParallelism()는 공유 스레드 풀에서 새로운 스레드 풀을 만들어낸다.
특정한 작업이 다른 작업에 영향을 받지 않을 때 별도 스레드 풀에서 실행되는 것이 필요할 때 사용해야 된다. 다만 이 함수는 공유 스레드 풀에서 새로운 스레드를 만들어내고, 새로운 스레드를 만드는 것은 비싼 작업이므로 남용하지 말자!
kotlinx-coroutine-cores)에는 Dispatcher.IO, Dispatcher.Default 가 있다.Dispatcher.Main을 사용하기 위해서 (kotlinx-coroutine-android) 라이브러리를 별도로 추가해야 한다.Dispatcher.IO는 네트워크 처리, DB 조회 하는 경우 사용, Dispatcher.Default는 CPU 바운드 작업 하는 경우 사용Dispatcher.Main은 일반적으로 UI가 있는 애플리케이션에서 UI를 업데이트 하는데 사용Dispatcher.IO와 Dispatcher.Default는 공유 스레드 풀을 사용Dispatcher.Default.limitedParallelism()을 통해 Dispatcher.Default를 사용하는 코루틴들의 스레드 사용 개수를 제한할 수 있다.