본문 바로가기
도서/코틀린 코루틴

2장 코루틴 라이브러리 - 디스패쳐

by 안스 인민군 2024. 2. 9.

코루틴 라이브러리가 제공하는 중요한 기능은 코루틴이 실행되어야 할 스레드를 결정할 수 있다는 것이다.

이 결정은 디스패쳐를 이용하여 사용할 수 있다.

디스패쳐의 사전적 의미 : 사람이나 차량, 특히 긴급 차량을 필요한 곳에 보내는 것을 담당하는 사람

코루틴 코틀린에서 코루틴이 어떤 스레드에서 실행될지 정하는 것은 coroutineContext이다.

 

기본 디스패쳐

디스패쳐를 설정하지 않으면 기본적으로 설정되는 디스패쳐는 CPU 집약적인 연산을 수행하도록 설계된 Dispatcher.Default 이다.

이 디스패쳐는 코드가 실행되는 컴퓨터의 CPU 개수와 동일한 수(최수 두개 이상)의 스레드 풀을 가지고 있다.

(필자의 컴퓨터는 12개의 CPU를 가지고 있으므로 풀의 스레드 수는 12개이다.)

suspend fun main() = coroutineScope {
    repeat(1000) {
        launch { // 또는 launch(Dispatchers.Default) { // 바쁘게 만들기 위해 실행합니다.
            List(1000) { Random.nextLong() }.maxOrNull()
            val threadName = Thread.currentThread().name println ("Running on thread: $threadName")
        }
    }
}

//Running on thread: DefaultDispatcher-worker-1
//Running on thread: DefaultDispatcher-worker-5
//Running on thread: DefaultDispatcher-worker-7
//Running on thread: DefaultDispatcher-worker-6
//Running on thread: DefaultDispatcher-worker-11
//Running on thread: DefaultDispatcher-worker-2
//Running on thread: DefaultDispatcher-worker-10
//Running on thread: DefaultDispatcher-worker-4
runblocking은 디스패쳐가 설정되어 있지 않으면 자신만의 디스패쳐를 사용하기 때문에 Dispatcher.Default 가 자동으로 선택되지 않는다. 위 예제에서 CoroutineScope 대신 runBlocking을 사용하면 모든 코루틴은 "main"에서 실행된다.

기본 기스패쳐 제한하기

비용이 많이 드는 작업이 Dispatcher.Default의 스레드를 다 써버려서 같은 디스패쳐를 사용하는 다른 코루틴이 실행될 기회를 제한하고 있다고 상상해보자.

이런 상황을 마주할 때 Dispatchers.Default의 limitedParallelism을 사용하면 디스패쳐가 같은 스레드풀을 사용하지만 같은 시간에 특정 수 이상의 스레드를 사용하지 못하도록 제한 할 수 있다.

데스패쳐의 스레드 수를 제한하는 방법은Dispatchers.Default에서만 사용되는 것은 아니기 때문에 limitedParallelism을 기억하고 있어야 한다.

private val dispatcher = Dispatchers.Default.limitedParallelism(5)

메인 디스패쳐

일반적으로 안드로이드를 포함한 애플리케이션 프레임워크는 가장 중요한 스레드인 메인 또는 UI 스레드 개념을 가지고 있다.

안드로이드에서 메인 스레드에서 코루틴을 실행하려면 Dispatcher.Main을 사용하면 된다.

 

안드로이드에서는 기본 디스패쳐로 메인 디스패쳐를 주로 사용한다. 블로킹 대신 중단하는 라이브러리를 사용하고 복잡한 연산을 하지 않는다면 Dispatcher.Main으로 충분하다. 또 CPU에 집약적인 작업을 수행한다면 Dispatcher.Default로 실행하면 된다.

대부분의 애플리케이션은 두 개의 디스패쳐만 있어도 충분하지만 스레드를 블로킹하는 경우 어떻게 해야 할까? 예를 들어 시간이 오래걸리는 I/O 작업(용량이 큰 파일을 읽는 등)이나 블로킹 함수가 있는 파이브러리가 필요할 때가 있다. 이럴때는 Dispatcher.IO는 이런 상황에서 필요한 디스패쳐이다.

IO 디스패쳐

Dispatcher.IO는 파일을 읽고 쓰는 경우, 안드로이드의 Shared Preference를 사용하는 경우, 블로킹 함수를 호출하는 경우처럼 I/O연산으로 스레드를 블로킹할때 사용하기 위해 설계되었다.

다음 코드는 Dispatcher.IO가 같은 시간에 50개가 넘는 스레드를 사용 할 수 있도록 만들어졌기 때문에 1초밖에 걸리지 않는다.

suspend fun main() {
    val time = measureTimeMillis {
        coroutineScope {
            repeat(50) {
                launch(Dispatchers.IO) {
                    Thread.sleep(1000)
                }
            }
        }
    }
    println(time) // ~1000
}

왜 1초밖에 걸리지 않을까?

스레드가 무한한 풀을 생성하는 것을 생각해보자.

처음에는 풀이 비어있지만 더 많은 스레드가 필요해지면 스레드를 생성되고 작업이 끝날 때까지 활성회된 상태로 유지된다.

이렇게 되면 활성화된 스래드가 너무 많아 성능이 점점 떨어지게 되고 결국 메로리 부족 에러가 나타날 것이다.

따라서 같은 시간에 사용할 수 있는 스레드 수를 제한하는 디스패쳐가 필요하다.

Dispacher.IO는 64개(또는 더 많은 코어가 있다면 해당 코어의 수)로 제한 한다.

suspend fun main() = coroutineScope {
    repeat(1000) {
        launch(Dispatchers.IO) {
            Thread.sleep(200)
            val threadName = Thread.currentThread().name
            println ("Running on thread: $threadName")
        }
    }
}

// Running on thread: DefaultDispatcher-worker-1
// ...
// Running on thread: DefaultDispatcher-worker-53 
// Running on thread: DefaultDispatcher-worker-14

또한 Dispatcher.Default와 Dispatcher.IO는 같은 스레드 풀을 공유한다. 이는 최적화 측면에서 중요한 사실이다. 스레드를 재사용하고 다시 재분배할 필요가 없다.

아래의 예시는 Dispatcher.Default로 실행하는 도중에 withContext(Dispatcher.IO) { } 까지 도달 한 경우를 예를 들어보자.

대부분은 같은 스레드로 실행이 되지만 스레드 수가 Dispatcher.Default의 한도가 아닌 Dispacher.IO 한도로 적용 된다.

스레드의 한도는 독립적이기 때문에 다른 디스패쳐의 스레드를 고갈 시키는 경우는 없다.

suspend fun main(): Unit = coroutineScope {
    launch(Dispatchers.Default) {
        println(Thread.currentThread().name)
        withContext(Dispatchers.IO) {
            println(Thread.currentThread().name)
        }
    }
}
// DefaultDispatcher-worker-2
// DefaultDispatcher-worker-2

 

또한 limitedParallelism 함수를 통해 IO디스패처의 스레드 수의 제한을 커스텀할 수 있다. 

 

 

작업의 종류에 따른 각 디스패쳐의 성능 비교

아래의 표는 같은 작업을 수행하는 100개의 독립적인 코루틴을 실행하여 각 디스패쳐를 비교하였다.

  중단 (1초 동안) 블로킹 (1초 동안) CPU 연산 (1초 동안) 메모리 연산 (1초 동안)
싱글 스레드 1002 100003 39103 94358
디폴트 디스패쳐 1002 13003 8473 21461
IO 디스패쳐 1002 2003 9893 20776
스레드 100개 1002 1003 16379 21004

주목할 만한 중요한 사항은 다음과 같다.

  • 단지 중단할 경우에는 사용하고 있는 스레드 수가 얼마나 많은지는 문제가 되지 않는다.
  • 블로킹할 경우에는 스레드 수가 많을 수록 모든 코루틴이 종료되는 시간이 빨라진다.
  • CPU 집약적인 연산에는 Dispacher.Default 가 가장 좋은 선택지이다.
  • 메모리 집약적인 연산에서는 Dispatcher.Default가 가장 좋은 선택지이다.

요약

  • Dispatcher.Default는 CPU 집약적인 연산에 사용한다.
  • Dispatcher.Main 은 메인 스레드에 접근할때 사용된다.
  • Dispatcher.IO는 블로킹 연산을 할 필요가 있을때 사용한다.
  • 병렬 처리를 제한한 Dispatcher.IO 나 특정 스레드 풀을 사용하는 커스텀 디스패쳐는 브로킹 호출 양이 아주 많을 때 사용한다.
  • Dispatcher.Main.immediate 는 Dispatcher.Main 이 사용하는 스레드에서 실행되지만 꼭 필요할 때만 재배정된다.
  • Dispatcher.Unconfinded는 코루틴이 실행될 스레드에 대해서 신경 쓸 필요 없을때 사용한다.