$_yOHan
  • About
  • All posts

코틀린의 동시성 - Sat, Jun 11, 2022

알렉세이 세두노프 저자의 ‘코틀린 완벽 가이드’를 학습하고 정리한 글입니다.

코틀린은 자바와의 호환성을 고려해 만들어진 언어인 만큼 자바의 동시성 요소를 쉽게 사용할 수 있다. 하지만 자바의 동시성 요소들은 대부분 블러킹 연산으로 문맥 전환(context switch)과 스레드마다 상당한 양의 시스템 자원을 유지해야 하기 때문에 비실용적이거나, 불가능할 수 있었다.

이보다 더 효율적인 방식으로 비동기 프로그래밍 방식이 있는데, 작업을 실행하고 완료될 때까지 기다리지 않고, 완료되면 수행될 람다를 제공한다. 단, 이렇게 프로그래밍한 경우 실행흐름이 예측하기 어렵고, 디버깅에 많은 지식이 필요하게 돼 코드 복잡도가 높아진다.

코틀린은 이 두 접근 방법의 장점을 함께 취한 코루틴이라는 강력한 메커니즘을 제공하는데, 개발자는 익숙한 명령형 스타일 코드를 작성하며 컴파일러가 효율적인 비동기 코드로 자동 변환해주게 된다. 이 매커니즘은 실행을 잠시 중단했다가 이후 중단한 지점에서 다시 시작될 수 있는 중단 가능한 함수 개념을 중심으로 이뤄진다.

일시 중단 함수

일시 중단 함수는 본문의 원하는 지점에 현재 실행에 필요한 모든 정보를 담고 있는 런타임 문맥을 저장하고 중단된다. 이후 다시 실행되면 저장했던 런타임 문맥을 가져와 마치 연속적으로 호출되는 것 처럼 동작하게 된다. 이렇게 동작하는 함수를 코틀린에선 suspend 변경자를 붙여 선언한다.

suspend fun happykotlin() {
    println("started")
    delay(123)
    println("finished")
}

delay는 자바의 Thread.sleep()과 비슷한 역할을 하지만, 현재 스레드를 블럭시키지 않고, 호출한 함수를 일시중단시키고 스레드를 풀어준다.

여기서 일시 중단 함수는 일반 함수, 일시 중단 함수를 모두 호출할 수 있지만, 일반 함수는 일시 중단 함수를 일반적으로 호출할 수 없다. 일반 함수의 경우 함수가 종료되기 전에 호출한 함수에 제어권을 넘겨줄 수 없기 때문에 일시 중단 함수를 호출할 수 없다.

코루틴 빌더

동시성 코드의 동작을 제어하기 위해선 공통적인 생명 주기와 문맥이 정해진 구체적인 영역 안에서 호출하기 원할 경우 CoroutineScope 인스턴의 확장 함수인 코루틴 빌더를 통해 제어 가능한 Job 객체를 만들 수 있다.

import kotlinx.coroutines.*
import java.lang.System.*

fun main() { // suspend 함수가 아님!
    val time = currentTimeMillis()
    
    GlobalScope.launch {
        delay(1000)
        println("[${currentTimeMillis() - time}ms] task-1")
    }
    
    GlobalScope.launch {
        delay(1000)
        println("[${currentTimeMillis() - time}ms] task-2")
    }
    
    Thread.sleep(2000)
}

/*
[1186ms] task-1
[1188ms] task-2
 */

위 코드를 실행하면 병렬적으로 실행돼 거의 동시에 두 코드가 실행이 끝났다. 마지막 Tread.sleep(2000)의 경우 메인 스레드 실행을 잠시 중단하는데, 코루틴을 처리하는 스레드는 데몬 모드(daemon mode)로 메인 스레드가 끝나면 자동으로 종료되기 때문에 작성되었다.

또한 만약 delay()가 아닌 sleep()을 코루틴 내부에서 호출하게 된다면 코루틴의 목적이 위배된다는 점을 유의하자. IntelliJ의 경우 코루틴 내부에서 블러킹 함수를 호출하면 경고를 표시해준다.

앞서 예제에서는 launch 코루틴 빌더를 활용했는데, launch의 경우 반환값이 없는 경우 적합한 빌더다. 결과값을 기대할 경우 async 빌더를 사용해야 하는데, async의 경우 Deferred 인스턴스를 돌려준다. Deferred는 Job의 하위 타입으로 await() 메서드를 통해 결과에 접근할 수 있게 해준다. await()는 계산이 완료되거나, 작업이 취소될 때까지 현재 코루틴을 일시 중단시키고 만약 실패한 경우 예외를 발생시키며 실패한다. 이처럼 async는 자바의 future에 해당하는 코루틴 빌더로 생각할 수 있다.

import kotlinx.coroutines.*
import java.lang.System.*

suspend fun main() { // suspend 함수
    val time = currentTimeMillis()
    
    val age = GlobalScope.async {
        delay(1000)
        10
    }
    
    val name = GlobalScope.async {
        delay(1000)
        "Yohan"
    }
    
    delay(2000) // Thread.sleep()이 아님!
    
    println("${name.await()} (${age.await()})")
}

또 다른 빌더로 runBlocking이 있는데, 앞서 설명한 launch와 async 빌더가 백그라운드 스레드를 공유하는 풀(pool)을 통해 실행됐다면, runBlocking 빌더의 경우 현재 스레드에서 실행되는 코루틴을 만들고 현재 스레드를 블럭시킨다.

import kotlinx.coroutines.*

suspend fun main() { // suspend 함수
    GlobalScope.launch {
        delay(100)
        println("launch::${Thread.currentThread().name}")
    }
    
    runBlocking {
        println("runBlocking::${Thread.currentThread().name}")
        delay(200)
    }
}

/*
runBlocking::main @coroutine#2
launch::DefaultDispatcher-worker-1 @coroutine#1
 */

따라서 runBlocking()을 다른 코루틴 안에서 사용하면 안 된다. 테스트나 메인 함수에서 최상위 빌더로 사용하여 블러킹 호출과 넌 블로킹 호출 사이의 다리 역할로 사용되어야 한다.

Back to Home


hugo.386 theme by Max le Fou | © yohanio 2020 | yohan’s notepad | Built on Hugo

GitHub