👩💻 오늘의 할 일
정말 오랜만에 책을 피는 것 같습니다. 얼레벌레 바빠서 책을 볼 시간이 없었는데 일주일에 한 번씩 스터디 팀원분들에게 이 책을 가지고 코루틴에 대해 알려드릴 수 있는 좋은 기회가 생겨서 꾸준히 읽을 수 있게 되었습니다. 어떤 개념을 내가 정확히 알고 있다고 말하기 위해선 이 개념을 다른 사람에게 설명할 수 있어야 한다고 생각하는데, 감사하게도 부족한 제 이야기를 들어주실 사람이 생겨서 너무 감사하네요. 이번 챕터의 주제는 CoroutineDispatcher입니다. 그동안 제가 알고 있던 CoroutineDispatcher는 작업 스레드를 지정하기 위한 용도라고 알고 있었는데 이외에도 어떤 역할을 하는지 한번 알아보겠습니다.
📖 CoroutineDispatcher란?
우선 CoroutineDispatcher란 단어에서부터 분석을 해보겠습니다. Dispatch의 사전적 뜻은 어떤 것을 보낸다는 뜻을 가졌습니다. CoroutineDispathcer를 어딘가로 보낸다는 의미가 되겠네요! 그렇다면 CoroutineDispathcer를 는 어디로 보내질까요? 코루틴은 스레드 내부에서 동작하기 때문에 스레드로 보내게 됩니다.
📖 CoroutineDispatcher의 동작 원리
위 이미지처럼 한 가지 상황을 가정해 CoroutineDispatcher의 동작 원리를 살펴보겠습니다.
- 현재 2개의 스레드를 사용할 수 있는 ThreadPool이 있습니다.
- 1개의 스레드에선 현재 코루틴이 하나 실행 중입니다.
- 현재 상태에서 다른 코루틴 실행이 요청이 됩니다.
- CoroutineDispatcher는 새로 요청받은 코루틴 2를 작업 대기열에 대기시켰다가 사용 가능한 Thread가 있다면 해당 Thread로 코루틴을 전송해 실행시킵니다.
- 만약 다음과 같이 현재 스레드가 모두 사용 중이라면 작업 대기열에서 대기하도록 만듭니다.
- 그 후 작업이 끝난 스레드가 있다면 해당 스레드로 전송해 실행합니다.
🔥 결론
CoroutineDispatcher의 역할은 코루틴의 실행을 관리합니다. 실행 요청된 코루틴을 작업 대기열에 적재 후, 스레드가 새로운 작업을 할 수 있는 상태라면 스레드에 코루틴을 보내 실행 합니다.
📖 CoroutineDispatcher의 종류
CoroutineDispatcher의 종류는 제한된 디스패처, 무제한 디스패처 두 가지가 있습니다. 무제한 디스패처는 사용할 수 있는 스레드나 스레드풀이 제한되지 않은 디스패처입니다. 책에서는 이후에 다룬다고 하니 이번엔 제한된 디스패처에 대해 알아보겠습니다.
📜 제한된 디스패처란?
- 사용할 수 있는 스레드나 스레드 풀이 제한된 디스패처입니다.
📜 단일 스레드 디스패처 만들기
- 단일 스레드 디스패처 : 사용할 수 있는 스레드가 하나인 CoroutineDispatcher
- 단일 스레드 디스패처는 코루틴 라이브러리의 newsingleThreadContext 함수로 만들 수 있습니다.
- name을 인자로 넘겨주면 디스패처에서 관리하는 스레드의 이름이 됩니다.
val dispatcher = newSingleThreadContext(name = "고구마 스레드")
📜 멀티 스레드 디스패처 만들기
- 멀티 스레드 디스패처 : 2개 이상의 스레드를 사용할 수 있는 CoroutineDispatcher
- 멀티 스레드 디스패처는 코루틴 라이브러리의 newFiexedThreadPoolContext 함수로 만들 수 있습니다.
- 스레드의 개수(nThread), 스레드의 이름을 매개변수로 받습니다.
val multiDispatcher = newFixedThreadPoolContext(
nThreads = 2,
name = "감자 스레드"
)
newSingleThreadContext는 내부적으로 newFixedThreadPoolContext를 사용하도록 구현되어 있습니다.
그러므로 newFixedThreadPoolContext에 스레드의 개수(nThread)에 인자를 1로 넘기면 newSingleThreadContext와 같은 함수가 됩니다.
📜 newFixedThreadPoolContext 내부 구현
@DelicateCoroutinesApi
public actual fun newFixedThreadPoolContext(nThreads: Int, name: String): ExecutorCoroutineDispatcher {
require(nThreads >= 1) { "Expected at least one thread, but $nThreads specified" }
val threadNo = AtomicInteger()
// Executor 프레임웍 사용해 스레드풀 생성
val executor = Executors.newScheduledThreadPool(nThreads) { runnable ->
// nThread 값이 1이면 name 스레드의 이름 설정
// nThread 값이 2 이상 이면 '-'과 숫자가 붙여져 이름 설정
val t = Thread(runnable, if (nThreads == 1) name else name + "-" + threadNo.incrementAndGet())
// 데몬 스레드로 생성
t.isDaemon = true
t
}
return executor.asCoroutineDispatcher()
}
✅ actual 키워드
- Kotlin Multiplatform(KMM) 프로젝트에서 사용되는 특별한 키워드입니다.
- actual 이외에도 expect가 있습니다.
- 이는 Kotlin이 여러 플랫폼에서 사용되도록 설계되었습니다.
- KMM 프로젝트에서는 코드를 여러 플랫폼 ( Ex. JVM, Android, iOS, JavaScript)에 공유하는데, 각 플랫폼마다 특정한 구현이 필요한 경우 플랫폼별로 다른 구현을 제공할 수 있습니다.
- expect
- 특정 인터페이스, 클래스, 또는 함수가 플랫폼에서 어떻게 구현되어야 하는지를 선언하는 데 사용됩니다.
- 즉, 예상되는 구현을 정의합니다. 특정 플랫폼에서 제공되어야 하는 API를 정의하는 데 사용됩니다.
- actual
- actual 키워드는 expect 키워드와 매핑되어 실제로 해당 인터페이스, 클래스, 또는 함수의 구체적인 구현을 제공합니다.
📖 CoroutineDispatcher 사용해 코루틴 실행하기
단일 스레드 디스패처로 코루틴 실행하기
멀티 스레드 디스패처로 코루틴 실행하기
- 실행 환경에 따라 각 코루틴이 다른 속도로 처리될 수 있어 사용되는 스레드가 다른 순서로 나올 수 있습니다.
- 코루틴을 각각 다른 스레드에서 실행하기 위해 delay( )를 주면 각기 다른 스레드에서 동작하는 것을 확인할 수 있습니다.
📖 부모 코루틴의 CoroutineDispatcher 사용해 자식 코루틴 실행하기
- 코루틴은 내부에 코루틴을 만들어 계층 구조를 만들 수 있습니다.
- 바깥쪽의 코루틴은 부모 코루틴, 안쪽의 코루틴을 자식 코루틴이라고 칭합니다.
- 부모 코루틴의 실행 환경은 자식 코루틴에도 영향을 주며 만약 자신 코루틴에 CoroutineDispather가 설정되지 않으면 부모 코루틴의 CoroutineDispatcher 객체를 사용합니다.
fun main() = runBlocking<Unit>{
val multiThreadDispatcher = newFixedThreadPoolContext(
nThreads = 2,
name = "멀티 스레드"
)
launch(context = multiThreadDispatcher) {
println("부모 코루틴 : [${Thread.currentThread().name}] 실행")
launch {
println("자식 코루틴1 : [${Thread.currentThread().name}] 실행")
}
launch {
println("자식 코루틴2 : [${Thread.currentThread().name}] 실행")
}
}
}
이 코드의 경우 가장 상단의 launch로 생성된 코루틴이 부모 코루틴이 됩니다. 그리고 내부엔 2개의 코루틴이 추가로 생성된어 있는데 이들이 바로 자식 코루틴이 됩니다. 자식 코루틴에는 별도의 CoroutineDispatcher를 설정해주지 않았기 때문에 부모 코루틴의 CoroutineDispatcher를 사용하게 됩니다.
🤔 왜 자식 코루틴이 모두 멀티 스레드 2에서 실행될까요?
현재 생성된 스레드의 개수가 2개이고 첫 번째 스레드는 부모 코루틴에서 사용 중 이기 때문에 두 번째 스레드에서만 작업이 가능하기 때문입니다.
이처럼 특정 CoroutineDispatcher에서 여러 작업을 실행해야 한다면 부모 코루틴에 CoroutineDispatcher를 설정하고 자식 코루틴을 생성하면 됩니다.
📖 미리 정의된 CoroutineDispatcher
눈치 빠르신 분들은 아시겠지만 newFixedthraedPoolContext 함수를 사용해 CoroutineDispatcher를 만들면 IDE에선 경고가 출력됩니다.
This is a delicated API and its use requires care. Make sure you fully read and understand documentation of the declaration that is marked as a delicate API
"이는 섬세하게 다뤄져야 하는 API이다. 섬세하게 다뤄져야 하는 API는 문서를 모두 읽고 제대로 사용돼야 한다."
무슨 말인고 하니 newFixedthraedPoolContext를 사용해 CoroutineDisaptcher를 만드는 것이 비효율적이라고 합니다.
이 경우 특정 CoroutineDisaptcher에서만 사용되는 스레드풀이 생성되며, 스레드풀에 속한 스레드의 수가 너무 적거나 많이 생성되 비효율적으로 동작할 수 있다고 합니다.
또한 여러 개발자가 함께 개발할 경우 특정 용도를 이 헤 만들어진 Coroutinedispatcher가 이미 메모리 상에 있어도 해당 객체의 존재를 몰라 다른 Coroutinedispatcher를 만들어 리소스를 낭비한다고 합니다.
사실 무슨 말인지 이해가 잘 안돼서 제 나름대로 생각을 해봤는데 스레드 풀이라는 것 자체가 미리 스레드를 사용해 놓고 대기하기 때문에 이 스레드를 사용하지 않으면 그 자체로 메모리를 낭비한다고 생각합니다. 안 그래도 생성 비용이 비싼 스레든데 만들어 놓고 안 쓰면 무슨 소용이람...?
그래서 코루틴 라이브러리는 미리 정의된 CoroutineDispatcher를 제공합니다.
📑 Dispatchers.IO
네트워크 통신, API 호출 Database 작업 등에 사용되는 Dispatcher입니다.
fun main() = runBlocking<Unit>{
launch(Dispatchers.IO) {
println("[${Thread.currentThread().name}] 실행")
}
}
📑 Dispatchers.Default
대용량 데이터 처리처럼 CPU 연산이 필요한 작업(CPU Bound)을 할 때 사용합니다.
fun main() = runBlocking<Unit>{
launch(Dispatchers.Default) {
println("[${Thread.currentThread().name}] 실행")
}
}
🌈 CPU Bound vs 입출력 작업
두 작업의 가장 큰 차이는 작업이 실행됐을 때 스레드를 지속적으로 사용하는지에 있습니다.
입출력 작업은 실행 후 결과 반환 시까지 스레드를 사용하지 않고 일시 중단해 다른 작업을 수행할 수 있습니다.
반면, CPU 바운드 작업은 작업을 하는 동안 스레드를 지속적으로 사용합니다.
이로 인해 두 작업 간에 스레드를 사용할 때 와 코루틴을 사용했을 때 효율성에 차이가 생깁니다.
입출력 작업 시 코루틴을 사용하면 입출력 작업 후 스레드가 대기하는 동안 해당 스레드에서 다른 입출력 작업을 동시에 실행할 수 있습니다. 반면 CPU Bound는 코루틴을 사용하더라도 스레드가 지속적으로 사용되어 스레드와 처리 속도가 큰 차이가 없습니다.
입출력(I/O) 작업 | CPU BOUND | |
Thread | 느림 | 비슷 |
Coroutine | 빠름 |
📑 공유 스레드풀을 사용하는 Dispathcers.IO, Dispatchers.Defalult
위 Dispachers의 실행 결과를 보면 모두 코루틴을 실행시킨 스레드의 이름이 DefaultDispatcher-worker-1입니다. 이것이 가능한 이유는 두 Dispatchers 모두 코루틴 라이브러리에서 제공하는 같은 공유 스레드 풀을 사용하기 때문입니다.
코루틴 라이브러리는 효율적인 스레드의 생성, 관리를 위해 어플리케이션 레벨의 공유 스레드 풀을 제공합니다. 이 공유 스레드 풀에선 원하는 만큼 스레드를 생성하고 사용할 수 있습니다.
📜 limitedParallelism을 사용해 스레드 사용 제한하기
- Dispatchers.Default 의 limitedparalleism
- Dispatchers.Default를 사용해 무겁고 오래 걸리는 연산을 처리하면 Dispatchers.Default의 모든 스레드가 사용될 수 있습니다.
- 이 경우 연산이 종료될 때까지 Dispatcher.default를 사용하는 다른 연산이 실행되지 못하게 됩니다.
- 이를 방지하기 위해 Dispatchers.Default의 일부 스레드만 사용해 특정 연산을 실행하게 만들기 위해 limitedparalleism 함수를 사용합니다.
fun main() = runBlocking<Unit>{
launch(Dispatchers.Default.limitedParallelism(2)) {
repeat(10){
launch {
println("[${Thread.currentThread().name}] 실행")
}
}
}
}
- Dispatchers.IO의 limitedparalleism
- Dispatchers.IO의 limitedparalleism은 공유 스레드 풀의 스레드로 구성된 새로운 스레드 풀을 만들어내며 스레드의 수에 제한이 없습니다.
fun main() = runBlocking<Unit>{
launch(Dispatchers.IO.limitedParallelism(100)) {
repeat(100){
launch {
println("[${Thread.currentThread().name}] 실행")
}
}
}
}
📑 Dispatchers.Main
안드로이드의 Main Thread(UI Thread)의 작업 즉, UI와 관련한 작업을 할 때 사용합니다.
fun main() = runBlocking<Unit>{
launch(Dispatchers.Main) {
println("[${Thread.currentThread().name}] 실행")
}
}
📕 후기
역시 3번째 장부터 본격적인 내용이 시작되네요. 그동안 Dispatcher를 어떻게 사용하는지는 알고 있었지만 이런 식으로 공유 스레드 풀을 사용해서 구현되어 있다는 걸 배웠습니다. 아마 제가 코루틴에 대해 아무것도 모르고 처음 공부한다면 절대 이해 못 했을 것 같은데 이걸 이해할 수 있다는 게 많이 성장한 것 같아 뿌듯합니다💪
'KOTLIN > 코루틴의 정석' 카테고리의 다른 글
코틀린 코루틴의 정석, 두 걸음 (0) | 2024.03.07 |
---|---|
코틀린 코루틴의 정석, 첫 걸음 (0) | 2024.03.02 |