[Kotlin] 동시성(Concurrency) 프로그래밍 #1 : 스레드 & 코루틴
안드로이드 애플리케이션과 같은 응용 프로그램들을 개발하다 보면 비즈니스 로직을 짤 때 스레드가 굉장히 많이 쓰이곤 한다. 특히 구글이 코틀린을 안드로이드의 메인 언어로 채택한 이후, 코틀린은 안드로이드 진영에서 이제 선택이 아닌 필수가 되었다. 그러므로 코틀린에서 어떻게 스레드를 다루고, 동시성 프로그래밍은 어떻게 하는지 정리해보도록 하겠다.
1. 프로세스와 스레드의 기본 개념
2. Kotlin에서 스레드와 코루틴
3. 코루틴의 우수성
프로세스와 스레드의 기본 개념
프로세스(Process)
프로세스(Process)는 보통 한 프로그램(Program)의 커다란 하나의 태스크(Task)를 말한다. 프로그램이 하나의 프로세스로 동작할 수도 있고, 여러 프로세스로 동작할 수도 있다. 다만 개념을 이해하기 위해선 1 프로그램 = 1 프로세스라고 생각하고 넘어가는 게 일단 편하다.
프로세스의 메모리 영역은 크게 코드(Code), 데이터(Data), 힙(Heap), 스택(Stack) 등의 영역으로 나뉜다. 코드(Code)는 프로그램의 실제 코드 영역, 데이터는 프로그램이 사용하는 전역 데이터 영역, 힙(Heap)은 메모리 동적 할당에 의해 프로그래머가 사용하는 공간, 마지막으로 스택은 함수의 호출에 따라 필요한 주소 영역이다.
문맥 교환(Context Switch)
이 각각의 영역의 진행 상황을 OS에서는 문맥(Context)이라 부르고, A 프로그램에서 B 프로그램으로 전환 시, A의 문맥을 저장하고 B의 문맥을 불러온다. 이것을 OS에서는 문맥 교환(Context Switch)이라 부른다. 보통 프로세스 간 문맥 교환 시 많은 정보를 교환하므로 비용이 높다고 한다. (스레드 간 문맥 교환에 비해)
스레드(Thread)
스레드는 한 프로세스 내에서 작동하는 실행 흐름으로 같은 프로세스에 속한 스레드들은 프로세스의 대부분의 영역(코드, 데이터, 힙 등)을 공유한다. 공유하지 않는 것은 각 스레드마다 독립적인 스택 영역을 갖는다는 것이다. 따라서 프로세스 내에서 스레드 간 문맥 교환이 발생할 때, 대부분의 영역은 공유를 하고 있는 상황이므로 문맥 교환의 비용이 낮다. 또한 공유하고 있는 영역으로 스레드 간 데이터를 주고받는 것도 매우 간단해진다.
Kotlin에서 스레드와 코루틴
Kotlin에서의 스레드
JVM에서 10단계로 우선순위 설정을 제공하므로 개발자가 우선순위를 설정할 수 있다. 멀티스레딩 시, 우선순위에 따라 OS의 스케줄링이 개입하여 작동한다. 따라서 스케줄링에 따른 문맥 교환이 존재한다. 물론 이 문맥 교환은 독립 스택 영역만 교환하므로 프로세스의 문맥 교환에 비해 비용은 상당히 적은 편이다. 스레드는 비동기 작업을 수행하기 위한 기본적인 방법이다. 성능은 우수하지만 코드가 복잡해 개발자가 굉장히 꼼꼼히 코딩해야 한다.
fun main(){
// Kotlin의 익명 객체를 이용
object : Thread() {
override fun run() {
println("현재 스레드 : ${currentThread()}")
}
}.start()
// Kotlin Runnable을 전달하는 람다식 방식
Thread {
println("현재 스레드 : ${Thread.currentThread()}")
}.start()
}
출력. 여기서는 스레드의 이름(Thread-0), 우선순위(5), 스레드 그룹(main)을 알 수 있다.
현재 스레드 : Thread[Thread-0,5,main]
현재 스레드 : Thread[Thread-1,5,main]
스코프 함수를 이용해 직접 우선순위를 설정할 수도 있다.
Thread {
println("현재 스레드2 : ${Thread.currentThread()}")
}.apply {
priority = 6
name = "내 스레드"
}.start()
스레드의 단점
첫 번째, 코드를 작성하기 다소 복잡하다. A스레드의 결과와 B스레드의 결과를 통합해 어떤 결과를 도출하는 작업이 있다고 하면, 우리는 A, B 스레드 작업의 완료 여부 및 결과를 공유 자원 또는 콜백(Callback) 함수를 만들어 해결해야 한다. 이것은 많은 양의 코드를 필요로 하고 필요 이상으로 복잡하게 만든다.
두 번째, 문맥 교환의 비용이 발생한다. 물론 프로세스 간 문맥 교환에 비하면 비용이 적은 편이지만, 스레드 전환 (작업 중단, 다른 작업 실행) 이 많고, 잦아질 경우 비용이 커지고 이에 대응할 최적화에 대한 고민이 필요하다.
코루틴은 위의 두 단점을 보완한다.
코루틴 (Coroutine)
하나의 작업(Job)을 루틴이라 하고, 이 루틴들이 서로 협력하여 멀티태스킹을 수행하는 행위를 코루틴이라 한다. 코루틴은 스레드 내에서 작동하는 더 작은 꼬마 스레드라고 봐도 무방하다. 코루틴은 하나의 스레드에서 여러 개가 동작할 수 있다.작업이 작다면 한 스레드에 수 천 개의 코루틴이 작동할 수도 있다. 경량화된 꼬마 스레드 '코루틴'은 이리저리 중단되어도, 다른 스레드에서 재실행되어도 비용이 매우 적게 든다.
Dispatcher 라 불리는 녀석이 있다. 이 친구는 자기가 관리하는 이미 초기화된 스레드 풀을 가지고 있는데, 이 중 쉬고 있는 스레드에 코루틴을 할당한 후 백그라운드에서 동작하도록 한다.
이미 초기화된 스레드를 사용한다는 점으로 인해 스레드 문맥 교환이 발생하지 않아 비용을 매우 줄일 수 있다. 그리고 개발자가 설정 가능한 일시 중단 가능한(Suspendable) 함수에 의해 코루틴이 블로킹(Blocking) 될 수 있다. 이는 즉 넌블로킹/블로킹을 개발자가 손쉽게 제어 가능하다는 것을 의미한다. 이것은 스케줄링을 하기 위한 OS 개입을 줄일 수 있다.
위와 같은 이야기가 복잡할 수 있지만, 코틀린은 이를 매우 간결한 코드로 사용 가능하도록 코루틴 라이브러리를 제공한다. 스레드를 이용할 때 처럼 콜백(Callback) 지옥에 빠지지 않을 수 있게 돕고, 코드의 흐름도 매우 직관적으로 잘 보인다. 코루틴 라이브러리의 사용은 아래 포스팅을 참고할 것.
https://full-stack.tistory.com/16?category=1017239
일시 중단 가능한 함수(suspend function)
무엇을 일시 중단한다는 것일까? 아래의 그림을 보자. 프로세스나 스레드는 일시 중단될 경우 막대한 문맥 교환의 비용이 든다. 일시 중단 가능한 함수는 특정 코루틴 내에서만 호출 가능하며, 이 함수는 자신을 호출한 코루틴을 중단할 수도 있다. 때문에 메인 스레드에서는 호출할 수 없는 함수이다.
보통 스레드에서는 이러한 중단은 다루기 어렵고 코드를 복잡하게 만드는 원인이었다. 그러나 코루틴에서의 중단은 스레드와는 다르게 앞서 설명했듯 전혀 유해하지 않다!
기존에 스레드에서 어떤 변수가 비동기 작업의 결과를 받아 다음 작업을 진행할 때, 콜백 함수 또는 공유 자원 등을 이용해야 했으나 코루틴을 이용하면 아래와 같이, 직관적으로 반환된 값을 사용하면 된다.
fun main() { // 메인스레드
GlobalScope.launch { // 코루틴 A
val one = doWork1() // suspend 함수 1
val two = doWork2() // suspend 함수 2
val combine = one + "_" + two
print(combine)
}
}
suspend fun doWork1(): String {
delay(500)
println("doWork1 Start!")
delay(1000)
println("doWork1 end!")
return "Work1"
}
suspend fun doWork2(): String {
delay(200)
println("doWork2 Start!")
delay(3000)
println("doWork2 end!")
return "Work2"
}
위의 예시는 일정 시간이 걸리는 문자열 결합 작업을 수행하기 위해, 메인 스레드에서 비동기적으로 동작하는 코루틴A를 하나 만들었고, 그 내부에서 직렬로 수행되는 suspend 함수의 결과를 반환하여 출력하는 작업을 하고 있다.
suspend 함수는 자신의 값을 반환하기 전까지 코루틴A를 중단 시키는 것을 다음의 결과 로그에서 확인할 수 있다. (만약 코루틴 A가 중단되지 않았다면 병렬적으로 수행되어, doWork2 Start가 더 짧은 딜레이를 가졌으므로 먼저 출력되었을 것이다.)
doWork1 Start!
doWork1 end!
doWork2 Start!
doWork2 end!
Work1_Work2
위의 두 함수가 병렬적으로 수행하게 하려면 코루틴을 활용해 비동기적으로 작동하도록 하면 된다. 여기서는 suspend 키워드를 써도 되고 안써도 동일하게 작동한다. 어차피 코루틴을 반환하므로 메인 스레드에 영향을 주지 않는다. 즉 안전 함수(중단을 발생시키지 않는)인 것이다.
fun main() = runBlocking { // 코루틴 A
val one = doWork1() // 코루틴 B 생성
val two = doWork2() // 코루틴 C 생성
val combine = one.await() + "_" + two.await()
print(combine)
}
fun doWork1() = GlobalScope.async { // 작업 결과를 반환하는 코루틴, suspend 키워드를 쓰지 않았다.
delay(500)
println("doWork1 Start!")
delay(1000)
println("doWork1 end!")
"Work1"
}
fun doWork2() = GlobalScope.async {
delay(200)
println("doWork2 Start!")
delay(3000)
println("doWork2 end!")
"Work2"
}
doWork2 Start!
doWork1 Start!
doWork1 end!
doWork2 end!
Work1_Work2
코루틴의 우수성
코루틴은 경량 스레드라고 봐도 무방하다. JVM 에서 다루는 스레드에 비해 한참 가벼운데 어느정도 일까? Kotlin 공식 문서에서 보여주는 예시를 살펴보면 다음과 같다. 다음은 100,000개의 코루틴을 만들어내는 코드이다. 문제없이 작동하고 빠르다.
suspend fun reapetCoroutine() = coroutineScope {
repeat(100_000) { // launch a lot of coroutines
launch {
delay(3000L)
print(".")
}
}
}
fun main() {
var startTime = System.currentTimeMillis()
reapetCorutine()
var endTime = System.currentTimeMillis()
print("\n걸린시간:${endTime - startTime}")
}
걸린시간:3334
3초의 딜레이를 갖는 코루틴 10만 개를 비동기적으로 수행하는데, 약 3.334초가 소요되었다. 비동기적으로 수행하기에 시간은 스레드와 얼마 차이 안 날 수 있으므로 메모리 사용량을 보자. 103MB 정도의 메모리를 차지한다.
위와 똑같은 기능을 수행하는 코드를 아래와 같이 스레드 100,000개로 작성했다면 어떻게 될까?
fun repeatThread() {
repeat(100_000) {
Thread {
Thread.sleep(3000L)
print(".")
}.start()
}
}
일단 인텔 i9-10850K 16 코어의 나름 준수한 CPU로 수행한 결과는 다음과 같다. 우선 프로그램이 끝나질 않는다.
이제 메모리를 살펴보도록 하겠다.
메모리와 CPU 점유율이 기하급수적으로 증가하고 있다. 그만 살펴봐도 될 것 같다.
끝
'Language > Kotlin' 카테고리의 다른 글
[Kotlin] 코루틴 (Coroutine) #2 - 기본기 2 (0) | 2022.02.24 |
---|---|
[Kotlin] 스코프 함수(Scope Functions) #1 (0) | 2022.01.17 |
[Kotlin] 코루틴 (Coroutine) #1 - 기본기 1 (2) | 2022.01.10 |
댓글