본문 바로가기

Kotlin

[Kotlin] CoRoutines

코루틴 개념과 코틀린에서의 코루틴 사용법에 대해서 알아보겠습니다.

(CoroutineBuilder, CoroutineContext, CoroutineScope, Dispatcher, Job...)

#코루틴 (Co + Routines)

  • Co는 Cooperation의 약자로 '서로 자발적으로 상호협력하는 Routines(함수)' 의 뜻
  • 즉, 번갈아가면서 서로 다른 함수들이 실행되는, 새로운 Thread가 만들지 않고도 단일 스레드에서도 비순차적 흐름을 프로그래밍 할 수 있는 기능 
  • 여러 개의 함수가 동시에 시작 or 동시에 끝나는 처리를 할 때도 편리하게 사용할 수 있음
  • 이런 코루틴의 개념을 지원하는 언어 중 하나가 Kotlin이다

#코루틴 한 줄 정의

  • 정의 : '비선점형 멀티태스킹을(non-preemptive multitasking) 수행하는 일반화한 서브루틴(subroutine, 일반 함수)' 라고 하면 와 닿지 않겠지만 찬찬히 뜯어보면
    • 비선점형 :  A코루틴 실행 중 우선순위등과 같은 조건/상황에 따라 실행중이던 A코루틴을 스케쥴러가 중지시키고 B코루틴을 실행 시킬 수 없고,  A코루틴 실행 중에 B코루틴을 실행하려먼 A가 자발적으로 blocking 상태로들어 가야 한다는 뜻이다.
    • 서브루틴 :  A함수 안에 B함수가 있을 경우 안쪽의 B함수를 서브루틴이라고 하며, 서브루틴은(B함수) 자신을 호출한 메인루틴(A함수)로 돌아가야 한다. 우리가 아는 일반 함수라고 보면 된다.
    • 즉 코루틴은 자발적으로 서로 양보,협력 하면서(비선점형) 함께 수행하는 서브루틴 이다

# 어떻게 양보, 협력이 가능한가?

  • 코루틴은 실행을 일지 중지(suspend)하고 재개(resume)할 수 있기때문이다
  • 이런 point를 진입지점, entry point라고 한다
  • 예를 들어보면  A라는 함수에서 B라는 코루틴을 실행  -> B 코루틴 실행하다가 어느 포인트에서 일시 중지하고 A함수로 실행 흐름을 넘겨줌 -> A함수 다시 실행하다가 B 코루틴 다시 호출하면, B의 코드가  처음부터 실행되는 것이 아니라 일시 정지 했던 point에서 다시 실행 된다 
  • 일시 중지시 해당 코루틴에 대한 정보를 저장해 두고 재개 할 때 사용한다 ( heap 메모리 영역에 저장)

#코루틴 특징

  • 단일 스레드에서도 다중 스레드에서도 사용할 수 있다(Thread와 1:1 매핑이 아님!!!)
  • 루틴은 stackless/stackful 타입으로 나눠지는데, 코틀린 코루틴은 stackless coroutine이다
  • Thread의 경우 자체 Stack을 가지므로 전환 시 Context Swiching이 일어나면서 많은 비용이 발생하는데, 코루틴은 자체 스택이 없고, 꼭 Thread와 1:1 매핑 될 필요 없으므로 1개의 Thread안에서만 사용한다면 Thread Context Switching이 없이도 사용할 수 있다(비용이 많이 드는 Context Switching 없이! 일시정지(suspend)후 다른 루틴을 실행 할 수 있다) 
  • 그래서 실제로 스레드는 아니지만 가벼운 스레드(Light-weight thread)라고 불리고, 성능 측면에서 부담이 거의 없어서 수천개의 코루틴 생성이 가능하다

#코루틴 시작하기 : dependency 추가

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
}

#코루틴 시작하기 : Coroutine Builder

시작하기에 앞서 처음에 이해할 수 없는 개념들이 나오니 아래의 CouroutinContext / CoroutineScope / CoroutinDispatcher 등의 설명을 찾아 가며서 읽기를 추천드립니다...

  • 코루틴을 실행하려면 Couroutine Builder를 사용한다
  • Coroutine Builder 종류로는 launch와 async가 있다
  • Coroutine Builder에 코루틴으로 만들고자 하는 동작을 람다로 넘겨서 실행한다
  • Coroutine Builder는 람다 함수 본문을 실행을 해당하는 Dispatcher에 전달 한다

launch

  • 새 코루틴을 생성/시작하고 코루틴 수행 결과를 반환하지 않는다
  • 대신 Job객체를 반환하는데 Job의 cancel()을 호출해서 코루틴 실행을 중단시킬 수 있다
  • 만들어진 코루틴은 기본적으로 즉시 실행된다

async

  • 새 코루틴을 생성/시작하고 코루틴 수행 결과를 반환한다
  • Deffered<T> 을 반환하며 T은 코루틴이 수행수 돌려주는 값의 타입이다
  • launch와 동일한 일을 하지만 다른점은 Deffered(연기된, 유예중인)를 반환한다는 점이다. Deffered는 Job을 상속하기 때문에 launch를 async로 대체해도 아무 문제가 없다
  • Deffered의 await()를 이용해 코루틴이 결과 값을 반환할 때까지 기다린 후 결과값을 얻어 낼 수 있다

 

launch 예제 1

private fun mainFun() {
    Log.i("syTest", "Main Start, ThreadInfo = " + getThreadInfo())
    launchInGlobalScope()
    Log.i("syTest", "main Terminate")
}

private fun launchInGlobalScope() {
    //코루틴 빌더
    GlobalScope.launch() {
        Log.d(
            "syTest",
            "GlobalScope Coroutine Started, CoroutineContext Info : " + this.coroutineContext.toString()
        )
        Log.d(
            "syTest",
            "GlobalScope, ThreadInfo = " + getThreadInfo()
        )
    }
}

 

launch 예제 2

runBlocking?

  • 코루틴의 실행이 끝날 때 까지 Thread를 Blocking
  • 코루틴 내에서 사용 할 수 없음
  • Main Thread에서 사용할 시 Blocking 하게 되므로 조심

delay() 

  • Thread.sleep()의 경우 Thread 자체를 정지하지만 delay는 해당 코루틴만 정지 한다
  • Delays coroutine for a given time without blocking a thread and resumes it after a specified time.

delay()나 yield() 호출 순간,  다른 코루틴으로 제어권이 넘어감(그래서 delay()는 without blocking a thread)

private fun yieldExample() {
    Log.w("syTest", "yieldExample Start")
    runBlocking {
        launch {
            Log.d("syTest", "[launch 1] ThreadInfo = " + getThreadInfo())
            Log.d("syTest", "1")
            yield()
            Log.d("syTest", "3")
            yield()
            Log.d("syTest", "5")
        }

        Log.d("syTest", "*****End 1111111*****")

        launch {
            Log.i("syTest", "[launch 2] ThreadInfo = " + getThreadInfo())
            Log.i("syTest", "2")
            delay(1000L)
            Log.i("syTest", "4")
            delay(1000L)
            Log.i("syTest", "6")
        }
        Log.i("syTest", "*****End 22222222*****")
    }
    Log.w("syTest", "yieldExample End")
}

  • launch는 즉시 반환되는 것을 볼 수 있다
  • runBlocking은 내부 코루틴이 모두 끝난 다음에 반환되는 것을 볼 수 있다
  • 1~6까지 순서대로 로깅 되지 않은 이유는?  첫 번째 코루틴이 '3'을 로깅 후 yield()를 했지만 두 번째 코루틴이 여전히 delay()상태에 있었기 때문에 제어가 다시 첫 번째 코루틴에게 돌아옴
  • delay()  대신 yield()를 사용하면 1~6이 순서대로 표시 될 것!

 

async 예제

private fun asyncTest() {
    runBlocking {
        Log.d("syTest", "start1")
        val result1: Deferred<String> = async {
            delay(1000L)
            Log.w("syTest", "delay1 finish")
            "async 결과1"
        }

        Log.d("syTest", "start2")
        val result2: Deferred<String> = async {
            delay(2000L)
            Log.w("syTest", "delay2 finish")
            "async 결과2"
        }

        Log.d("syTest", "start3")
        val result3: Deferred<String> = async {
            delay(3000L)
            Log.w("syTest", "delay3 finish")
            "async 결과3"
        }
        Log.d("syTest", "end")
        val finalResult =
            result1.await() + " , " + result2.await() + " , " + result3.await()
        Log.i("syTest", "finalResult  = $finalResult")
    }
}

  1. 코루틴이 아니고 순서대로 진행했다면 6초 이상이 걸려야 하지만 코루틴으로 실행 시 3초 가량 걸리는 것을 볼 수 있다
  2. 그럼 일반 Thread 병렬 처리랑 같은 거 아닌가?
    • 3개의 코루틴 모두 Main Thread 내부에서 실행 되었다(병렬 처리와 다른 점이다. Thread Context Swtiching없이 비동기 실행 가능~)
    • 실행하는 작업의 시간이 얼마 안걸리거나, I/O에 의한 대기 시간이 크거나, CPU 코어 수가 적어 동시 실행 가능한 Thread 개수가 적을 때 '코루틴 vs 일반 Thread 비동기' 의 차이가 커진다 (async-await 사용이 효율적이다)

 

#CouroutinContext / CoroutineScope / CoroutinDispatcher

1) CoroutineContext

  • CoroutineContext는 코루틴이 실행 중인 여러 작업(Job 타입)과 Dispatcher를 저장하는 일종의 맵이라고 할 수 있다
  • CoroutineContext는 Job과 Dispatcher를 통해 코루틴의 동작을 정의하는데..
  • Job은 코루틴의 수명 주기를 제어하고
  • Dispatcher는 적절한 스레드에 작업을 전달 한다

코틀린 런타임은 이런 CoroutineContext를 이용해서 다음에 실행할 작업을 선정하고 어떤 스레드에 어떻게 배정할지 방법을 결정 한다

 

CoroutineScope은 CoroutineContext 하나만을 멤버 속성으로 정의하고 있는 인터페이스

public interface CoroutineScope {
    /**
     * The context of this scope.
     * Context is encapsulated by the scope and used for implementation of coroutine builders that are extensions on the scope.
     * Accessing this property in general code is not recommended for any purposes except accessing the [Job] instance for advanced usages.
     *
     * By convention, should contain an instance of a [job][Job] to enforce structured concurrency.
     */
    public val coroutineContext: CoroutineContext
}

 

CoroutineScope의 확장 함수인 launch와 async

public fun CoroutineScope.launch(...): Job {
    ...
    return coroutine
}

public fun <T> CoroutineScope.async(...): Deferred<T> {
    ...
    return coroutine
}
  • launch와 async와 같은 코루틴 빌더들은 CouroutinScope의 확장함수 이고, 코루틴 빌더들은 해당 CoroutineSope 내의 CoroutineContext를 기반으로 코루틴을 생성하게 된다
  • CoroutineSope은 매개체 역할만 할 뿐, 중요한 것은 CoroutineContext 이다

 

2) CoroutineScope 

  • 모든 코루틴은 Scope 내에서 실행 되어야 한다

CoroutineScope 사용 예

val jobParent: Job = Job()
Log.i("syTest", "jobImpl = $jobParent") //JobImpl{Active}@a90684a

val customScope = CoroutineScope(jobParent + Dispatchers.Main)
val jobChild = customScope.launch {
    Log.i(
        "syTest",
        "" + this.coroutineContext.toString()
    ) //[StandaloneCoroutine{Active}@27f23bb, Dispatchers.Main]
}

//*****jobParent을 parent로 하는 job이 생성 된다*****
Log.i("syTest", "job = $jobChild") //StandaloneCoroutine{Active}@27f23bb

//*****jobChild가 jobParent의 child가 맞는지 확인*****
val iterator: Iterator<Job> = jobParent.children.iterator()
if (iterator.hasNext()) {
    val childJob = iterator.next()
    Log.i("syTest", "children of jobImpl = $childJob") //StandaloneCoroutine{Active}@27f23bb
    Log.i("syTest", "job1 == job2 ?  " + (childJob == jobChild)) //true
    Log.i("syTest", "job1 === job2 ?  " + (childJob === jobChild)) //true
}

/**
 * 'customScope'에서 실행한 코루틴은 'jobParent'의 수명주기와 함께하게 되는데...
 * 즉, jobParent.cancel()을 호출하게 되면 실행중인 코루틴이 취소가 된다
 * 참고로 parent.cancel()은 모든 child job 역시 cancel이란 의미
 */
jobParent.cancel() //customScope.cancel()
  • CoroutineScope은 Dispatcher와 달리 코루틴 실행을 담당하지는 않으며 코루틴 실행 생명 주기를 주로 담당
  • CoroutineScope은 launch나 async를 사용해서 만든 코루틴을 추적하면서 실행 중인 코루틴은 언제든지 scope.cancel()을 호출하여 취소할 수 있게끔 한다
  • 일부 KTX 라이브러리는 특정 수명 주기 클래스 자체 CouroutinScope를 제공하는데, 예를 들면 viewModelScope이 있는데, 이 Scope을 사용하면 해당 뷰모델이 destroy시 실행중인 모든 코루틴이 취소 되므로 뷰모델이 활성화 상태인 경우에만 실행해야 할 작업이 있을 때 유용 (Lifecycle의 경우 lifecycleScope 이 있다)
  • GlobalScope.launch()의 경우 GlobalScope의 CoroutineScope은 EmptyCoroutineContext인데 이는 기본 컨텍스트로, 어떤 생명주기에 바인딩 된 Job이 정의되어 있지 않아서 앱의 process와 동일한 생명주기를 갖게 된다

 

viewModelScope 사용법

implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0' // ViewModelScope
    private fun scopeTest() {
        viewModelScope.launch { }//Dispatchers.Main이 Default 
        viewModelScope.launch(Dispatchers.IO) { }
        viewModelScope.launch(Dispatchers.Default) { }
        
        //viewModelScope.cancel() 
    }
//CouroutinScope 타입의 확장 프로퍼티로 되어 있다
public val ViewModel.viewModelScope: CoroutineScope
    get() {
        ...
    }

 

3) Dispatcher?

  • 코루틴은 Dispatcher를 보고 코루틴이 실행될 Thread를 결정
  • 코루틴은 자체적으로 suspend 될 수 있고,  Dispatcher 는 다시 resume을 담당
  • Dispatcher 종류는 3가
    1. Dispatcher.Main : Main Thread에서 코루틴이 실행 됨
      • UI 작업이나 짧은 작업을 실행하기 위해서만 사용
    2. Dispatchers.IO :  Worker Thread에서 코루틴이 실행 되며, 이 Thread는 Disk작업이나 Network I/O 작업에 최적화 되어 있다
      • DB 접근, 파일 쓰기, 네트워크 작업 등에 사용
    3. Dispatchers.Default : Worker Thread에서 코루틴이 실행 되며, 이 Thread는 CPU 사용량이 많은 작업에 최적화되어 있다
      • 리스트 정렬, JSON 파싱 등에 사용

 


*코루틴 context-and-dispatchers 공식문서

https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html

* 스택오퍼플로우 참고 - Thread vs Kotlin Coroutine

https://stackoverflow.com/questions/43021816/difference-between-thread-and-coroutine-in-kotlin

*포스팅 참고

https://myungpyo.medium.com/reading-coroutine-official-guide-thoroughly-part-1-7ebb70a51910

 

 

잘못된 내용이나 궁금한 내용 있으시면 댓글 달아주세요

좋은 하루 되세요!

 

출처 : Kotlin In Action - 에이콘 출판사

(위 도서를 학습하고 개인 학습용으로 정리한 내용입니다)