[코틀린 코루틴] 코루틴을 사용하는 이유

SSY·2023년 12월 17일
0

Coroutine

목록 보기
5/8
post-thumbnail

1. 코루틴이란?

Coroutine은 'Co'라는 '함께하다'라는 의미의 접두사와 'Routine'이라는 '하나의 작업 프로세스'를 합쳐 만들어진 라이브러리입니다. 하지만 여기까지 보면 코루틴이 병렬성으로 작업을 처리하는지 아니면 동시성으로 작업을 처리하는지 의문이 들 수 있는데, 코루틴은 동시성 프로그래밍입니다. 그러기에 기존 스레드를 사용하는 것보다 메모리 성능이 훨씬 좋을 수 있으며, 이것을 아는것 부터가 코루틴을 왜 사용하는지 납득하는 첫 걸음 입니다.

[참고]
개발을 하다보면 메모리와 실행시간의 Trade Off 문제가 발생합니다. 코루틴이 메모리를 절약해준다 해서 실행시간이 늦어질 수 있다고 생각할 수 있지만 전혀 그렇지 않습니다. 이는 아래 글에서 상세히 설명합니다.

2. 동시성 vs 병렬성

동시성과 병렬성은 여러 가지의 작업이 동시에 실행된다는 공통점이 있습니다. 하지만 CPU의 작업 스케줄링 방식에서 그 차이를 알 수 있는데, 동시성은 하나의 스레드안에서 여러 작업들이 Context Switching을 통해 진행되는걸 의미하고, 병렬성은 각각의 스레드가 개별 작업을 할당받아 진행되는걸 의미합니다.

[동시성]

[병렬성]

3. 코드로 확인해보기

그럼 코루틴이 어떻게 동시성 프로그래밍을 지원하며, 메모리를 효율적으로 사용하는지 알아보겠습니다. 우선, 스레드를 사용하여 1000개의 작업을 동시에 실행했을때 입니다.

[스레드 사용 및 1천개 작업 동시 실행]

runBlocking {
    println("시작::활성화 된 스레드 갯수 = ${Thread.activeCount()}")
    val time = measureTimeMillis {
        val jobs = ArrayList<Thread>()
        repeat(1000) {
            jobs += Thread {
                Thread.sleep(1000L)
            }.also { it.start() }
        }
        println("끝::활성화 된 스레드 갯수 = ${Thread.activeCount()}")
        jobs.forEach { it.join() }
    }
    println("Took $time ms")
}

[벤치마크 결과]
시작::활성화 된 스레드 갯수 = 1
끝::활성화 된 스레드 갯수 = 1001
Took 1180 ms

스레드를 사용해 1000개의 작업을 동시에 돌렸을 땐, 말 그대로 스레드 1000개가 생성됨을 확인할 수 있습니다. 즉, 하나의 작업에 1개의 스레드가 할당되었다는 의미입니다. 이제 코루틴을 사용하여 1000개의 작업을 실행해보겠습니다.

[코루틴 사용 및 1천개 작업 동시 실행]

runBlocking {
    println("시작::활성화 된 스레드 갯수 = ${Thread.activeCount()}")
    val time = measureTimeMillis {
        val jobs = ArrayList<Job>()
        repeat(1000) {
            jobs += launch(Dispatchers.Default) {
                delay(1000L)
            }
        }
        println("끝::활성화 된 스레드 갯수 = ${Thread.activeCount()}")
        jobs.forEach { it.join() }
    }
    println("Took $time ms")
}

[벤치마크 결과]
시작::활성화 된 스레드 갯수 = 1
끝::활성화 된 스레드 갯수 = 3
Took 1046 ms

1000개의 작업을 동시에 실행시켰음에도 불구하고 새로 생성된 스레드의 갯수는 고작 2개밖에 되지 않습니다. 이는 2개의 스레드 위에서 1000개의 작업에 Context Switching이 적용되었다는 것이고, 불필요한 스레드의 생성과 해제 작업 또한 없었다는 의미입니다. 이로써 스레드를 사용하는 것보다 코루틴을 사용하는 것이 메모리상 압도적인 성능을 보인다는걸 알 수 있습니다.

그렇다면 실행 시간에 있어선 어떨까요? 위 두 코드에선 실행시간의 차이가 얼마 나지 않습니다.(스레드 : 1180ms, 코루틴 : 1046) 하지만, 이는 동시에 실행시키는 작업의 수를 늘렸을 때 확연히 알 수 있습니다.

[스레드 사용 및 1만개 작업 동시 실행]

runBlocking {
    println("시작::활성화 된 스레드 갯수 = ${Thread.activeCount()}")
    val time = measureTimeMillis {
        val jobs = ArrayList<Thread>()
        repeat(10000) {
            jobs += Thread {
                Thread.sleep(1000L)
            }.also { it.start() }
        }
        println("끝::활성화 된 스레드 갯수 = ${Thread.activeCount()}")
        jobs.forEach { it.join() }
    }
    println("Took $time ms")
}

[벤치마크 결과]
시작::활성화 된 스레드 갯수 = 1
끝::활성화 된 스레드 갯수 = 3948
Took 3037 ms

[코루틴 사용 및 1만개 작업 동시 실행]

runBlocking {
    println("시작::활성화 된 스레드 갯수 = ${Thread.activeCount()}")
    val time = measureTimeMillis {
        val jobs = ArrayList<Job>()
        repeat(10000) {
            jobs += launch(Dispatchers.Default) {
                delay(1000L)
            }
        }
        println("끝::활성화 된 스레드 갯수 = ${Thread.activeCount()}")
        jobs.forEach { it.join() }
    }
    println("Took $time ms")
}

[벤치마크 결과]
시작::활성화 된 스레드 갯수 = 1
끝::활성화 된 스레드 갯수 = 3
Took 1114 ms

동시 실행 작업을 1만개로 늘리니 시간 차이가 확연히 늘어납니다. 스레드를 사용했을 땐, 3초가 걸리고 코루틴을 사용했을 땐 1초밖에 걸리지 않습니다.

여기서 시사하는 바는 매우 큽니다.

  1. 스레드를 적용하여 동시 실행 작업 수를 늘렸을 땐, 스레드의 수가 비례하여 늘어난다.
  2. 실행 시간도 코루틴을 사용했을때에 비해 3배 가까이 늘어남을 확인할 수 있다.

[참고]
스레드를 생성하는데엔 적지 않은 메모리가 할당됩니다. 동시 실행 작업을 10만개로 증가시키면 Memory Out Of Exception이 발생합니다. 반면, 코루틴은 50만개를 생성해도 괜찮습니다. (메모리와 CPU에 따라 달라질 수 있음)

[참고]
스레드의 수는 컴퓨터의 메모리의 한계가 있기에 1만개 까지 늘어나지 않았으며, 최대 생성 갯수인 3948개까지만 늘어났습니다.

반면, 코루틴을 사용했을 때 시사하는 바는 아래와 같습니다.

  1. 스레드의 수가 전혀 늘어나지 않았다. (효율적인 Context Swiching 덕분, 그러기에 코루틴을 동시성 프로그래밍이라 함.)
  2. 실행 시간도 거의 늘어나지 않음. (1천개 작업 시 : 1046ms, 1만개 작업 시 : 1114ms)

[참고]
Context Switching을 사용함으로써 실행 시간이 줄어들지 않을까? 라는 의문이 들 수 있지만, 실행 시간의 증가 또한 거의 없음을 확인할 수 있습니다.

4. 코루틴의 중단과 재개

코루틴의 경우, 동시 실행 작업량이 1천개에서 1만개로 증가하여도 스레드의 갯수는 전혀 증가하지 않았습니다. 이는 코루틴이 한개의 스레드에서 여러 작업을 Context Switching시켰기 때문이라고 설명드렸는데요. 이를 코루틴의 '중단'과 '재개'라고 합니다. 이는 하나의 스레드 위에서 여러 작업(=코루틴)을 적절히 '중단' 및 '재개'를 시킴으로써 하나의 스레드를 공유하며 사용하기 때문입니다.

val scope = CoroutineScope(Dispatchers.Main)

fun onCreate() {
    scope.launch { A_Coroutine() }
    scope.launch { B_Coroutine() }
}

suspend fun A_Coroutine() { ... }
suspend fun B_Coroutine() { ... }

위 코드는 아래 순서로 동작합니다.

  1. A코루틴, 메인스레드 위에서 시작 후 중단(ex. API호출)
  2. A코루틴은 메인스레드를 놓아주며 이를 B코루틴에 넘겨줌
  3. B코루틴, 메인스레드 위에서 시작 후 중단
  4. B코루틴 또한 메인스레드를 놓아줌
  5. A, B 코루틴 중, 응답이 빨리 오는 코루틴이 메인 스레드를 재선점 후 작업 순차적 실행

5. 경량 스레드 코루틴

코루틴은 위와 같은 이유들로 인해 경량 스레드라고 부릅니다. 스레드를 사용한 병렬성 작업은 개별적인 스레드를 생성합니다. 반면, 코루틴을 사용한 동시성 작업은 '중단'과 '재개'(=Context Switching)를 통해 최소한의 스레드만 생성합니다.

마치며

코루틴은 Kotlin 언어 레벨에서 지원해주는 경량 스레드 라이브러리입니다. 따라서 '중단'과 '재개'가 실행되기 위해 그에 맞게 컴파일이 진행됩니다. 이를 CPS(Continuation Passing Style)이라 하며 이는 다음 포스팅때 알아보도록 하겠습니다.

profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글

관련 채용 정보