
멀티코어 프로세서가 당연해진 시대에서 멀티코어를 활용하지 않는다는 것은 죄악이다. 죄를 지은 자를 회사에서 채용하지 않는 것은 당연한 일이고... 그러므로 현대 개발자라면 당연히 멀티코어를 사용하고 작업을 병렬로 처리하거나 비동기로 빼는 방법을 잘 알고 적용할 수 있어야 할 것이다.
이처럼 현대 개발자에게는 매우 중요한 병렬 처리 개념을 다시 짚어보는 것을 목적으로 하는 이 글에서는, 병렬 처리에서 가장 기초적인 개념인 스레드와 스레드 풀, 그리고 최근 Android의 기반이 되는 Kotlin에서 병렬 처리를 위해 제공하는 유틸리티인 Coroutines 그리고 Dispatchers에 대해 살펴볼 것이다.
스레드란 프로세스의 2가지 특징인 자원 소유권과 스케줄링/수행 중 후자에 해당하는 단위이다.
학부 전공 교재에는 위처럼 정의를 내리고 있다. 다소 어렵게 느껴질 수 있다. 그러나, 프로세스가 코드나 전역 변수 등 공유 자원(자원 소유권)에 대한 소유권을 가지고, 프로세스에 속한 스레드들이 해당 공유 자원을 사용하면서 작업을 처리(스케줄링/수행)하는 것을 생각해보면 비교적 이해하기 편할 것이다. 쉽게 말하면, 프로세스 내에서 실행되는 가장 작은 단위의 작업 흐름인 것이다.
어쨌든 핵심은 프로세스는 하나의 작업을 더 빨리 끝내기 위해 여러 개의 스레드를 두고 이들이 동시에 작업을 병렬로 처리하도록 한다는 것이다. 이처럼 하나의 프로세스 내에서 수행되는 여러 개의 스레드가 동시에 존재하는 경우를 멀티스레딩(multithreading)이라고 한다. 물론 단일 프로세스에 단일 스레드만 존재하는 경우도 가능하기는 하지만, 앞에서 말한 대로 일꾼이 10명 있는데 굳이 1명에게만 일을 시킬 이유는 없다. 따라서 이 글에서는 멀티스레딩 환경을 전제하고 내용을 전개하도록 하겠다.
스레드가 아무 이유 없이 등장하지는 않았을 것이다. 따라서 장점이 있을 것인데, 그 내용은 다음과 같다:
특히 주목해야 할 것은 3번째 "프로세스 간 교환보다 스레드 간 교환이 더 가벼움"이다. 이게 중요한 이유는 공평한 병렬 처리를 보장하기 위해서 보통의 운영체제는 프로세스 또는 스레드를 계속 바꾸어가며 작업을 실행하기 때문이다. 프로세스 A와 B가 있다고 가정해보자. CPU는 A의 작업을 먼저 처리하고 있었다. 이 때 계속 기다리던 B가 나타나서 "왜 계속 A의 것만 처리해줘? 내 작업은 언제 처리해 줄 거야?"라고 항의한다. CPU는 알았다고 하면서 A의 작업을 잠시 치워 두고 B의 작업을 처리하게 될 것이다. 이를 익숙한 용어로는 문맥 교환(context switching)이라고 한다. 현대 CPU에서는 문맥 교환이 자주 발생하는 만큼, 성능을 올리려면 그 비용을 최대한 줄여야 한다. 그리고 이 목적 달성에 적합한 수단이 바로 스레드인 것이다.
스레드에는 크게 2가지 종류가 있다. 사용자 수준 스레드(ULT, User-Level Thread)와 커널 수준 스레드(KLT, Kernel-Level Thread)이다.
사용자 수준 스레드(이하 ULT)는 스레드와 관련한 모든 작업이 모두 커널 위 응용 수준에서 실행되는 것을 말한다. 여기서 말하는 모든 작업이란 스레드 생성, 유지, 교환 및 파괴 등 스레드의 유지/관리에 관한 모든 일들을 의미한다.
커널 수준 스레드(이하 KLT)는 스레드와 관련한 모든 작업이 모두 커널에서 실행되는 것을 말한다. 커널은 아무래도 프로세서를 직접 다루기 때문에, 사용자는 응용에서 스레드 관련 작업을 처리하지 않으며 대신 운영체제의 API를 호출할 뿐이다.
ULT와 KLT는 각각의 장단점을 가지고 특성도 다소간 다르다. 이 부분을 집중적으로 살펴보자.
ULT는 병렬 처리가 이루어지지 않을 수도 있으나 KLT는 멀티스레드를 사용하고 있다면 무조건 병렬 처리임이 보장된다. 왜냐하면 ULT는 응용 위에서 스레드를 나누어 둔 것이기 때문이다. 커널은 응용이 스레드를 나눈 사실조차 모를 것이며, 만약 커널이 여러 스레드를 가진 프로세스를 하나의 처리기에 할당한다면, 스레드가 있기는 해도 사실상 단일 프로세서에서의 처리이기 때문에 진정한 병렬 처리라고 볼 수 없을 것이다.
ULT는 온전히 응용 위에서만 동작하기 때문에 커널 모드로의 전환이 필요하지 않다. 반면에 KLT는 문맥 교환 등 일부 작업을 위해 커널 모드로 전환해야 하고, 이 과정에서 비용이 발생할 가능성이 있다.
ULT는 프로그램의 특성에 맞는 스케줄링 전략을 취할 수 있다. 선입선출이 필요한 프로그램이 있을 수 있고, 라운드 로빈 방식이 필요한 프로그램이 있을 수 있다. ULT를 사용하는 경우 이처럼 프로그램의 특징에 맞는 최적화된 스케줄링 기법을 채택할 수 있다. 그러나 KLT는 아무래도 저수준인 만큼 사용자가 개입할 여지가 적다.
ULT는 응용에서 동작하는 만큼 운영체제와 독립적이다. 코드 하나 짜 두면 어떤 운영체제에서도 손쉽게 실행할 수 있다. 반면에 KLT는 운영체제의 API를 직접 건드려야 하므로, 운영체제마다 병렬 처리 코드를 따로 작성해주어야 한다. 생각만 해도 귀찮다.
위에서 살펴본 것처럼 ULT와 KLT는 각각의 고유한 장단점을 지닌다. 그래서 일부 똑똑한 선배 개발자들은 이 둘을 합쳐서, 둘의 장점을 최대한으로 활용할 수 있게 개선하고자 시도했다. 그 대표적인 사례가 바로 Kotlin의 Dispatchers이다. 이 부분은 뒤에서 살펴볼 것이므로 여기서는 설명은 생략하고, 아무튼 잘 섞어두면 양쪽의 장점을 최대한 취할 수 있다는 점만 알고 가면 되겠다.
스레드나 프로세스에 대해 공부하다 보면 가끔 '경량 스레드(lightweight thread)' 등의 표현을 접해볼 수 있다. 그러나 나는 지금까지 '경량'이라는 표현의 의미를 직관적으로만 파악해왔지, 왜 경량이라고 부르는지에 대해서 명확한 이유를 찾아본 적이 없었다는 점을 깨달았다. 그래서 스레드에서 무게가 구체적으로 무엇을 의미하는지, 그리고 경량 스레드와 중량 스레드를 구분하는 기준은 무엇인지에 대해 찾아보았다.
스레드를 무게에 따라 구분한다면, 크게 아래와 같이 중량 스레드(heavyweight thread)와 경량 스레드(lightweight thread)로 구분할 수 있다:
스레드를 가벼운 것과 무거운 것으로 나눌 수 있다면, 왜 무거운지에 대해서도 설명이 가능할 것이다. 그러나 KLT와 ULT에 대한 비교는 이미 위에서 자세히 진행했기 때문에, 여기서는 간단하게만 설명하고 넘어가도록 하겠다:
사실 경량과 중량의 가장 큰 차이는 단순하게도 스택의 크기로 설명할 수 있다. 이러한 관점에서 KLT와 ULT를 비교해보면, KLT는 생성 시 메모리의 일부 공간을 직접 할당받게 될 뿐더러 그 크기도 보통 1 MB 정도로 큰 편이지만, ULT는 보통 힙에서 관리되는 경우가 많고 크기도 KLT에 비해서는 작다. 요약하면 ULT가 KLT에 비해 메모리 크기도 작으며 유지/관리도 편하다는 것이다.
KLT는 문맥 교환 시 커널 모드로 전환도 필요하고 하드웨어 수준에서 메모리와 레지스터 및 캐시 교환이 이루어져야 하지만, ULT는 이 모든 것이 이미 할당된 프로세스의 메모리 공간 안에서 이루어지므로 상대적으로 비용이 낮다.
KLT는 메모리나 보조기억장치 등에 I/O 요청 시 그대로 블록되어 아무 작업도 수행하지 않는다. ULT는 조금 갈리는데, 전통적인 ULT의 경우에는 여전히 KLT와는 완전히 분리되어 있기 때문에, 프로세스 내부에서 스레드를 나누었다고 하더라도 그 스레드를 가진 프로세스가 I/O 요청을 수행할 경우 어쩔 수 없이 프로세스 전체가 블록되는 것은 동일하다. 반면, Kotlin Coroutines와 같은 현대적인 ULT의 경우, 중단된 작업을 스냅샷의 형태로 따로 빼 두고 그 자리에 다른 작업을 대신 올리기 때문에, 비는 시간 없이 처리기를 최대한으로 활용할 수 있다.
스레드 풀은 재사용 가능한 스레드 여러 개의 집합을 의미한다.
이 정의는 전에 들었던 멀티코어컴퓨팅 강의 자료에서 가져왔다. 스레드 풀은 여러 스레드로 구성되어 있어서, 사용자가 작업을 스레드에 던져 주면 스레드 풀이 가용한 스레드에 해당 작업을 맡기는 방식이다.
표면적으로 보면 '단순히 스레드 여러 개 모아둔 거에 불과한데 그렇게 큰 성능 향상이 있을까?'라고 생각하기 쉽다. 그러나 우리는 스레드 풀의 스레드가 재사용 가능하다는 점에 주목해야 한다. 스레드를 생성하고 파괴하는 데에는 비용이 필요하다. 그러나 스레드 풀은 한 번 생성해두면 (일반적으로는) 다음 작업을 위해 즉시 파괴되지 않고 유휴(idle) 상태로 남는다. 즉, 스레드 생성과 파괴에 드는 비용이 크게 절약되는 것이다.
스레드 풀은 여러 개의 스레드를 동시에 관리한다. 또한 스레드 풀은 보통 스케줄링 등 여러 스레드를 유지하고 작업을 처리하는 데 필요한 일들을 대신 처리해준다. Java의 ExecutorService가 대표적이다. 따라서 사용자 입장에서는 스레드 관리에 필요한 고민을 할 필요가 없다. 작업만 던져주면 된다. 왜? 유지/관리는 유틸리티가 알아서 처리해주니까.
단순히 여러 스레드를 무한히 생성할 경우 종국에는 메모리 공간이 꽉 차서 메모리 오버플로우가 발생할 수 있다. 이를 방지하기 위해, 개발자는 필요에 따라 최대로 생성 가능한 스레드 수를 제한할 수 있다.
만약 스레드 풀의 모든 스레드가 전부 작업하고 있을 경우, 스레드 풀은 새로 제출된 작업을 작업 큐에 담아둔다. 이후 작업을 마친 스레드 하나가 유휴 상태로 전환되면, 큐 관리 정책에 따라 처리해야 할 작업을 선별하여 꺼내서 가용한 스레드에 하나씩 배분해준다.
정리하면 스레드 풀은 아래와 같은 장점을 가진다:
그러나 동시에 아래와 같은 단점도 지닌다:
Coroutines are lightweight alternatives to threads.
Coroutines는 Android 개발에 주로 사용되는 언어 Kotlin에서 지원하는 병렬 프로그래밍 도구이다. 공식 문서에 다양한 설명과 정의가 혼재하고 있으나, 지금까지 글에서 스레드에 대해 주로 다루어왔기에, 스레드와 밀접한 정의를 하나 가져왔다. 그렇다면 이제 각 표현을 자세히 살피며 해체 분석을 해 보자.
이 표현은 '경량'으로 번역이 가능하겠다. 위에서 설명한 대로 경량 스레드, 즉 ULT를 의미하는 것으로 볼 수 있다. 다시 말해, Coroutines는 ULT에 기반한 동시성(concurrency) 처리를 지원해주는 도구라는 의미이다.
이 표현은 "스레드에 대한 대안"으로 번역이 가능하겠다. 가벼운 의미에서는 맞는 말이라고 본다. Coroutines를 사용하게 된다면, 기존에 주로 사용하던 Java의 Thread나 Runnable을 직접 다룰 필요가 없어지기 때문이다. 이러한 (상대적) 저수준 도구를 직접 사용하는 대신, 어느 정도 추상화가 되어 있는 Coroutines를 사용하는 게 편할 것이라는 의미에서 대안이라는 단어를 사용한 것 같다.
그러나 조금 더 깊게 들어가보면 사실 '스레드 - 엄밀히는 여기서 말하는 스레드란 KLT일 것이다 - 에 대한 대안'보다는 '스레드와 함께 사용할 수 있다'는 표현이 더 정확하다고 볼 수 있다. 왜냐하면 ULT인 Coroutines는 내부적으로 KLT를 사용하기 때문이다. 그래서 Coroutines를 더 정확히 표현하자면 "KLT를 더 효율적으로 사용할 수 있게 해주는 사용자 수준(user-level)의 스레드 관리 도구"로 소개할 수 있을 거라고도 생각이 든다.
Coroutines는 내부적으로 Continuation라고 하는 일종의 문맥 데이터를 가지고 있다. 또한, Coroutine에서 다른 Coroutine으로 전환할 경우 이 Continuation 문맥을 저장해 두고 추후 복구에 활용한다는 점에서, 각 스레드마다 지역 변수 등을 저장하는 별도 공간이 마련되어 있는 KLT와 유사한 방식으로 동작한다고 볼 수 있다.
스레드와 프로세스 전략에는 크게 선점형 전략과 비선점형 전략이 있다:
Coroutines는 비선점형 전략을 취하기 때문에, 외부 Coroutines에서는 지금 동작하고 있는 Coroutines를 억지로 멈출 수 없다. 이는 어쨌든 문맥 교환의 횟수를 줄여주기 때문에, 그 비용을 조금이나마 줄여줄 수 있다는 점에서 효율적이다.
Coroutines 공식 문서에 따르면, Coroutines는 운영체제에 의해 관리되는 스레드 위에서 동작한다고 설명되어 있다. 즉, 위에서 말했던 KLT와 ULT의 혼합형인 것이다. Coroutines라는 ULT는 기본적으로 응용 수준의 스케줄러와 디스패처에 의해 관리되며, 이 스케줄러와 디스패처는 필요에 따라 ULT에 해당하는 Coroutine을 실제 물리 스레드인 KLT에 할당하는 식이다. 따라서, KLT와 ULT의 장점을 모두 취하는 형태라고 볼 수 있다.
위에서 설명했듯 KLT는 I/O 요청 시 그대로 작업이 끝나기 전까지 중지한다. 즉, 그 대기 시간 동안에는 아무 일도 하지 않는다는 것이다. 반면에 Coroutines 스케줄러는 특정 Coroutine이 I/O 등의 사유로 중지될 경우, 해당 Coroutine의 진행 상황을 저장한다. 그리고 기존 Coroutine을 그것이 실행되던 KLT에서 즉시 내리고, 작업을 기다리고 있는 다른 Coroutine을 대신 올린다. 즉, 하드웨어를 비는 시간 없이 최대한 사용할 수 있다는 것이다. 그래서 Coroutines 위에서 돌아가는 함수는 suspend라는 키워드를 붙여야 한다.
조금 더 부연 설명을 하자면, Coroutines이 중지될 때 그 작업 맥락을 Continuation의 형태로 저장하게 된다. Continuation은 중단 후 다음에 실행할 코드 위치와 현재 지역 변수의 상태를 담고 있는 자료구조인데, suspend 함수 컴파일 시에 함수 파라미터 맨 뒤에 이 Continuation 객체가 덧붙여지게 된다. 이처럼 현재 상태를 Continuation의 형태로 suspend 함수에 전달해준다는 점에서 이러한 방식을 CPS(Continuation-Passing Style)라고 부른다.
이 부분을 설명하기 위해서는 동시성과 병렬성의 차이에 관해 알아야 할 필요가 있다. 찾아보니까 면접 단골 질문 주제라는 것 같더라. 간단히 설명하면 아래와 같다:
Coroutines는 기본적으로 하나의 KLT에 여러 개의 Coroutine를 대체하면서 올릴 수 있게 되어 있다. 아까 설명한 것처럼 suspend된 작업을 기다리는 것보다는, 그 시간에 놀고 있는 작업을 올려주는 게 효율적이기 때문이다. 그래서 논리적으로 동시에 처리되는 것처럼 보이는 동시성을 만족하게 되는 것이다.
반면 병렬성의 경우에는 동작 환경이 싱글코어라면 달성할 수 없지만, 멀티코어라면 자연스럽게 달성이 된다. 그래서 조건이 충족되어야 한다고 언급한 것이다.
지금까지의 설명으로 스레드가 무엇이고, 스레드 풀은 무엇이며, Kotlin의 Coroutines가 어떻게 스레드 대신 사용 가능한 효율적인 동시성 구현 도구를 제공하는지에 대해 설명했다. 그런데 단 한 가지, 빼먹은 설명이 있다. 다시 한 번 내가 부연한 정의를 살펴보자:
"Coroutines는 KLT를 더 효율적으로 사용할 수 있게 해주는 사용자 수준(user-level)의 스레드 관리 도구"
여기서 주목할 표현은 '더 효율적으로 사용할 수 있게'이다. 방법론이 빠져 있지 않은가? 어떻게 효율적으로 관리할지에 대한 답이 없다. 그 답이 바로 Dispatchers이다.
The coroutine context includes a coroutine dispatcher that determines what thread or threads the corresponding coroutine uses for its execution.
정의를 살펴보면, 가장 먼저 "Coroutine 문맥은 Dispatcher를 포함한다"고 설명하고 있다. 이 말은 아래 코드를 보면 쉽게 이해가 가능하다:
fun main() = runBlocking {
launch(Dispatchers.IO) { // <- 여기가 중요하다
println("IO 작업 시작: ${Thread.currentThread().name}")
delay(1000L)
println("IO 작업 완료: ${Thread.currentThread().name}")
}
}
Kotlin에서는 Coroutine 사용 시에 필요한 경우 Dispatchers를 명시해줄 수 있다. Dispatchers에는 여러 종류가 있는데, 상황에 맞는 Dispatcher를 적절한 Coroutine 문맥에 지정해 작업을 처리할 수 있다는 말이다.
다음으로는, "Coroutine dispatcher는 각각의 Coroutine이 자신의 수행을 위해 어떤 스레드 혹은 스레드들을 사용할지 결정한다"고 설명하는 부분이다. 즉, Dispatchers는 스레드처럼 명확히 실재하는 어떤 개념이 아니라, 그저 스레드 풀을 관리하고 작업을 효율적으로 수행하기 위한 일종의 전략을 의미한다는 것이 이 표현에서 잘 드러나 있다.
Dispatchers는 Kotlin의 Coroutines(ULT)를 실제 스레드(KLT)에 배분하는 스케줄러 역할을 담당한다. 이 때 다수의 ULT를 다수의 KLT에 배분하는 구조가 성립하므로, 스레드 모델 중에서는 M:N 모델에 해당한다.
기본적으로 모든 Dispatchers는 동일한 스레드 풀을 공유한다. 즉, Dispatchers.Default와 Dispatchers.IO가 완전히 동일한 스레드를 서로 차례를 바꾸어가며 쓸 수 있다는 말이다.
이는 바로 위의 특징에 의해 발생하는 장점이다.
예를 들어, Dispatchers.Default에서 연산을 진행하다가 파일에 있는 값이 필요해서 Dispatchers.IO로 전환했다고 가정해보자. 이 상황에서는 Dispatchers.Default에서 돌던 작업을 Dispatchers.IO로 문맥 전환해줘야 할 것이다. 그러나 이 경우에서는 작업의 내용과 내부 Coroutine 문맥은 Dispatcher를 바꾸기 전이나 후나 완전히 동일하다. 그러니까 그냥 이 작업을 실행하던 스레드의 라벨만 Dispatchers.Default에서 Dispatchers.IO로 바꿔주면 되는 것이다. 그 외의 다른 문맥 전환에 필요한 작업은 전혀 필요하지 않다.
이러한 원리로 Coroutine은 신속하고 효율적인 문맥 전환을 달성할 수 있게 되는 것이다.
Dispatchers에는 다양한 형태가 있는데, 그 목록은 다음과 같다:
Dispatchers.DefaultDispatchers.IODispatchers.MainDispatchers.UnconfinedDispatchers.Default기본값으로 사용되는 Dispatcher이다. Coroutine 생성 시 별도의 Dispatcher가 명시되지 않으면 얘를 사용하게 된다. 백그라운드에서 실행되며, 공식 문서의 설명으로는 연산 집약적인 작업에 적절하다고 한다. 기본적으로, 이 Dispatcher에서 사용 가능한 최대 스레드 개수는 보통 CPU의 코어 개수와 동일하며, 최소값은 2이다.
Dispatchers.IOI/O 작업에 사용되는 Dispatcher이다. Dispatchers.Default와 마찬가지로 백그라운드에서 실행되며, 블로킹(blocking)을 동반하는 I/O 집약적인 작업에 적절하다. 필요에 따라 생성되는 스레드를 사용하기는 하나, 64개 또는 CPU의 총 코어 수 중 큰 값으로 제한된다.
Dispatchers.MainUI 렌더링에 사용되는 Dispatcher이다. Android에서는 화면을 그리는 작업이 이 스레드에서 실행된다. 다르게 말하면, 만약 오래 걸리거나 블로킹(blocking)이 동반되는 작업을 Dispatchers.Main에서 실행한다면, 화면이 멈춘다는 말이다. 그러므로 반드시 이러한 작업들은 백그라운드에서 실행되는 Dispatcher에 맡기도록 하자.
Dispatchers.Unconfined'confine'이라는 단어는 Oxford 사전에 따르면 'to limit an activity, person, or problem in some way'를 의미한다. 즉, (특정한 무언가에) 국한한다는 뜻이다. 이 뜻에 따르면, Dispatchers.Unconfined는 특정 스레드에 국한되지 않고 어느 스레드에서든 실행이 가능하도록 하는 Dispatcher를 의미한다.
이 Dispatcher는 다음과 같이 동작한다:
delay 등의 함수로 인해 중단이 발생할 경우, 해당 중단 함수가 작업을 마친 스레드 - 다시 말해 중단된 함수를 깨워준 스레드 - 에서 계속 작업을 이어간다.Dispatchers.Unconfined 호출은 스택 오버플로우를 막기 위해 내부 큐(event loop)에 작업을 넣어 실행한다.일반적인 비동기 작업 호출 시에는, 비동기 작업 처리를 위한 스레드를 생성하거나 할당해준 후에야 해당 작업을 수행하기 시작한다. 그러나 Dispatchers.Unconfined가 지정된 작업에 한해서는 별도 스레드 생성 없이 즉시 작업을 시작한다. 다시 말하면 일반적인 경우보다도 더 신속하고 빠르게 작업 처리가 가능할 '가능성'이 있다는 뜻이다.
그러나 이 전략의 가장 큰 문제점은 스택 오버플로우가 발생할 수 있다는 점이다. 예를 들어, 아래와 같은 코드가 있다고 치자:
launch(Dispatchers.Unconfined) { // <- 1번
launch(Dispatchers.Unconfined) { // <- 2번
launch(Dispatchers.Unconfined) { // <- 3번
// ...
}
}
}
일단 기본적으로 현재 이 비동기 작업을 호출한 문맥이 존재할 것이다. 그리고 이 문맥에서 사용 가능한 메모리 공간에는 하드웨어나 Kotlin 정책 상의 제약으로 인해 한계(보통 1 MB)가 있을 것이다. 자, 그렇다면 이 코드가 어떻게 스택 오버플로우를 일으키는지 순서대로 따라가보자:
launch 함수가 실행 요청됨.Dispatchers.Unconfined이므로 별도 문맥 전환 없이 현재 문맥에서 호출.launch 함수에 필요한 스택 프레임 생성.launch 함수와 3번 launch 함수에서도 동일한 일이 발생함.따라서 Kotlin은 이러한 문제를 예방하기 위해, 중첩된 Dispatchers.Unconfined 호출을 확인했을 경우 해당 작업들을 곧바로 실행하는 게 아니라 큐에 넣어둔 채로 보관함으로써 스택 오버플로우를 예방한다. 이 방식을 트램펄린(trampoline) 기법이라고 부른다던데, 이에 대한 Gemini의 대략적인 설명은 아래와 같다:
CoroutineStart.UNDISPATCHED비슷하게, 호출 즉시 현재 스택 프레임에서 작업을 시작할 수 있는 방법으로는 CoroutineStart.UNDISPATCHED 옵션이 있다. 사용 방법은 아래와 같다:
launch(
context = Dispatchers.Default,
start = CoroutineStart.UNDISPATCHED
) {
// 작업 진행...
}
이 경우 첫 실행은 Dispatchers.Unconfined와 동일하게 비동기 함수를 호출한 스택 프레임에서 즉시 실행한다. 다음 중단점을 만나기 전까지는. 차이점이 있다면 중단이 풀려도 아무 스레드에서나 동작하는 Dispatchers.Unconfined와는 달리, CoroutineStart.UNDISPATCHED는 중단이 풀린 후에는 지정된 Dispatcher 정책에 따라 동작한다는 점이다. 따라서 빠른 실행 후에는 어느 정도 여유 있게 처리해도 되는 UI 초기화 등에 이 옵션을 사용할 수 있다.
Dispatchers에서는 범용적으로 사용 가능한 위의 4가지 - 사실상 사용하기 어려운 Dispatchers.Unconfined를 뺀다면 3가지이긴 하지만 - 옵션을 제공한다. 그러나 경우에 따라 위의 기본 옵션 말고 사용자가 원하는 Dispatcher 정책을 수립하여 사용하고 싶을 수도 있다. 예를 들어, 동형 암호(FHE)의 곱셈 연산처럼 매우 큰 부하의 연산이 오랜 시간 지속되는 경우에는 Dispatchers.Default로는 해결이 어렵다. 만약 FHE 곱셈 연산을 진행하던 중에 금방 끝나는 JSON 파싱 요청이 Dispatchers.IO로 들어온다고 해도, 모든 Dispatchers가 하나의 스레드 풀을 공유하는 Dispatchers의 특성상 모든 스레드를 이미 다 Dispatchers.Default에서 사용하고 있기 때문에 JSON 파싱을 요청받은 Dispatchers.IO에 돌아갈 스레드가 없기 때문이다. 기아(starvation) 현상의 발생이다.
따라서 Dispatchers는 이를 위해 아래의 3가지 수단을 제공한다:
newSingleThreadContext("identifier") 함수 사용newFixedThreadPoolContext(THREAD_NUMBER, "identifier") 함수 사용Dispatchers.IO.limitedParallelism(THREAD_NUMBER) 함수 사용필요에 따라 3가지 함수 중 하나를 사용하면 된다. 그러나, 각각의 스레드의 생성 및 종료가 관리되는 스레드 풀과는 달리, newSingleThreadContext를 통해 생성한 단일 스레드는 작업 종료 후 반드시 명시적으로 해제해 주어야 함을 기억하자. 또한, 마지막 방법인 기존 Dispatchers의 스레드 풀을 재사용하는 전략은 새로운 스레드를 만들지 않고 기존 스레드를 재활용한다는 점에서 경제적일 수는 있으나, 여전히 Dispatchers의 한계 이상의 작업을 요구하는 경우 대처하기 어려울 수 있음을 알고 있어야 할 것이다. (물론 그 정도로 부하가 큰 작업을 Android 단말기에서 실행할 일이 실질적으로 있을지는 잘 모르겠다.)
이 글을 통해 나는 병렬 처리의 최소 단위인 스레드와 스레드 풀에 대해 알아보았으며, 이 개념을 Kotlin에서 구현해 낸 Coroutines와 이를 유지/관리하기 위한 전략인 Dispatchers가 어떻게 동작하는지에 대해 간략히 알아보았다. 장장 6시간 정도 이 글에 시간을 쏟았으니, 아마 이 부분 관련해서는 까먹을 일은 없을 것 같다.
뿌듯한 하루다. 굿.