Study Record

[Kotlin] coroutines 살펴보기 본문

안드로이드/Kotlin

[Kotlin] coroutines 살펴보기

초코초코초코 2023. 8. 8. 14:33
728x90

😶 Thread 와 Android 개요

앱을 만들면서 서버와의 통신(네트워킹), 데이터베이스 작업과 같은 고급 기능을 추가하면서 작업 수행 시간이 길어지면 앱의 UI가 끊겨서 보이거나 버튼을 클릭했을 때 실행되지 않은 것처럼 보이기도 한다.

 

작업 수행이 길면 왜 이런 문제점이 생기느냐를 논하기에 앞서, 스레드의 개념을 알고 있어야 한다. 스레드는 프로그램이 실행되는 가장 작은 코드 단위이며 단순하게 스레드를 앱에서 코드를 실행하는 단일 경로라고 생각할 수 있다. 코드 한 줄이 실행이 끝나야 다음줄로 넘어갈 수 있다.   같은 블록에 작성된 코드는 동일한 스레드에서 순차적으로 실행된다.

 

모든 앱은 메인 스레드(UI 스레드)가 하나씩 있고 개발자가 코드로 스레드를 생성/시작시킬 수 있다. 이렇게 실행 중인 앱에는 여러 스레드가 있을 수 있는데 메인 스레드가 앱의 UI 와 관련된 작업을 담당하기 때문에 메인 스레드에 네트워킹, 데이터베이스 작업과 같은 시간이 오래 걸리는 작업을 수행하면 도중에 다른 코드를 수행할 수 없으므로 사용자의 UI 응답과 같은 작업이 바로 실행되지 못하여 앱이 응답하지 않게 된다.

 

 

 

😶 Thread and unpredictable behavior

Kotlin 에서 스레드를 실행하는 방법 중 하나로 Thread 클래스가 있다.

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}

 

 

여러 스레드를 실행했을 때, 예측할 수 없는 결과를 얻을 수 있는데 다음과 같은 코드를 실행하면,

fun main() {
   var count = 0
   for (i in 1..50) {
       Thread {
           count += 1
           println("Thread: $i count: $count")
       }.start()
   }
}

 

Thread: 50 count: 49
Thread: 49 count: 50 
Thread: 1 count: 1
Thread: 2 count: 2
Thread: 3 count: 3
Thread: 4 count: 4
Thread: 5 count: 5
Thread: 6 count: 6
Thread: 7 count: 7
Thread: 8 count: 8
Thread: 9 count: 9
Thread: 10 count: 10
Thread: 11 count: 11
Thread: 12 count: 13
Thread: 13 count: 13
Thread: 14 count: 14
Thread: 15 count: 15
Thread: 16 count: 16
Thread: 17 count: 17
Thread: 18 count: 18
Thread: 20 count: 19
Thread: 19 count: 20

 

결과를 보면, Thread: 12 count 13 과 같이 출력된 것이나 Thread: 60 count: 49 가 제일 먼저 출력된 것처럼 버그로 보이는 것들이 있다. 이것은 여러 스레드가 동시에 메모리의 동일한 값에 액세스 하려고 할 때 생기는 버그이다. 버그를 발견하는 것도 재현하는 것도 어렵기 때문에 스레드를 직접 사용하는 것을 권장하지 않는 이유 중 하나이다.

 

Kotlin 에서는 스레드의 이런 동시성 문제를 관리/유지하는 데 유용한 Coroutines 를 제공하고 있다.

 

 

😶 Coroutines 

coroutine(코루틴)은 중단 가능한 계산 작업에 대한 인스턴스이다. 코루틴은 스레드와 비슷하지만 특정 스레드에 묶여 있지 않는다. 하나의 스레드에서 실행을 중단에서 다른 스레드에서 코루틴을 다시 시작할 수 있다.

 

코루틴 예시)

fun main() = runBlocking { // this: CoroutineScope
    launch { // launch a new coroutine and continue
        delay(1000L) // non-blocking delay for 1 second (default time unit is ms)
        println("World!") // print after delay
    }
    println("Hello") // main coroutine continues while a previous one is delayed
}

/* 실행 결과
Hello
World!
*/

launch{} 는 coroutine builder 로 새로운 코루틴을 할 수 있는데 launch{} 외 나머지 작업과 독립적으로 실행된다. 따라서, 1초 뒤에 World! 가 찍히기 전, Hello 가 먼저 실행결과에 찍힌다. 

 

 

😶 Coroutines 기본 개념

coroutines 는 구조적 동시성(structured concurrency)의 원칙을 따른다. 이 의미는 새로운 코루틴은 항상 특정 CoroutineScope 에서만 시작할 수 있다. 이 글에서 소개할 CoroutineScope 를 정의할 수 있는 메서드는 runBlocking, coroutineScope, MainScope 가 있다. MainScope 는 밑에서 설명할 예정이다.

 

runBlocking

runBlocking 메서드를 실행하는 스레드(ex. 메인 스레드)는 runBlocking 에 명시된 작업이 끝날 때까지 Block 된다. 일반 함수와 비슷하다. 멀티태스킹을 기대하는 것이라면 이 메서드는 사용하면 안 된다. 

fun main() { // this: CoroutineScope
    runBlocking {
        delay(1000)
        println("Hello")
    }
    println("END")
}

/* 실행 결과
Hello
END
*/

 

coroutineScope

runBlocking 과 비슷하지만 코루틴 혹은 suspending function 에서만 호출할 수 있는 메서드이다. runBlocking 메서드는 현재 메서드를 차단(block)하지만 coroutineScope 는 일시 중단한다.(suspend)

// Sequentially executes doWorld followed by "Done"
fun main() = runBlocking {
    doWorld()
    println("Done")
}

// Concurrently executes both sections
suspend fun doWorld() = coroutineScope { // this: CoroutineScope
    launch {
        delay(2000L)
        println("World 2")
    }
    launch {
        delay(1000L)
        println("World 1")
    }
    println("Hello")
}

/* 실행 결과
Hello
World 1
World 2
Done
*/

coroutineScope 이기 때문에 실행이 끝날 때까지 일시중단된다. 따라서, Hello, World1, World2 가 모두 출력되고 Done 이 마지막에 출력된다. 

 

 

😶 Suspending Function

함수 앞에 suspend 키워드를 붙이면 Suspending Function 이 된다. Suspending Function 은 코루틴 안에서 호출될 수 있으며 이점으로, 안에 delay() 메서드와 같은 suspending function 을 호출할 수 있다. 

fun main() = runBlocking { // this: CoroutineScope
    launch { doWorld() }
    println("Hello")
}

// this is suspending function
suspend fun doWorld() {
    delay(1000L)
    println("World!")
}

/* 실행 결과
Hello
World!
*/

 

 

😶 새로운 코루틴 시작하기(launch)

새로운 코루틴을 실행하는 coroutine builder 로는 launch 가 있다. 리턴값으로 Job 개체를 리턴하는데 이 개체로 코루틴을 관리할 수 있다.

fun main() {
    runBlocking {
        val job = launch { // launch a new coroutine and keep a reference to its Job
            delay(1000L)
            println("World!")
        }
        println("Hello")
        job.join() // wait until child coroutine completes
        println("Done")
    }
    println("END")
}

/* 실행 결과
Hello
World!
Done
END
*/

 

 

😶 Async 와 Suspending Function

한 코루틴 내에서 실행하는 코드들은 순차적으로 실행된다. 코드 한 줄의 실행시간이 얼마나 오래 걸려도 실행을 마치고 다음 줄을 실행한다. 다음 예시에서의 코드에서 doSomethingUsefulOne() 함수가 실행된 다음 doSomethingUsefulTwo() 함수가 실행되고 나머지 작업이 실행된다.

fun main() = runBlocking{
    val time = measureTimeMillis {
        val one = doSomethingUsefulOne()
        val two = doSomethingUsefulTwo()
        println("The answer is ${one + two}")
    }
    println("Completed in $time ms")
}
suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // pretend we are doing something useful here
    println("doSomethingUsefulOne's delay is done")
    return 13
}
suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // pretend we are doing something useful here, too
    println("doSomethingUsefulTwo's delay is done")
    return 29
}

/* 실행 결과
doSomethingUsefulOne's delay is done
doSomethingUsefulTwo's delay is done
The answer is 42
Completed in 2040 ms
*/

이렇게 순차적으로 실행하는 것보다 doSomethingUsefulOne() 함수와 doSomethingUsefulTwo() 함수를 각각 실행한 뒤, 두 함수의 작업이 끝나면 나머지 작업을 실행할 수 있으면 더 효율적일 것이다. 마치, 심부름을 할 때 나와 동생이 같이 마트에 갔다가 빵집에 가는 것보다 내가 마트에 갈 동안 동생이 빵집에 간 뒤 각각 볼일을 마치고 집으로 돌아오는 것이 더 빠른 것과 비슷하다.

 

이럴 때 사용할 수 있는 것은 async(비동기)이다. async 는 launch 와 마찬가지로 coroutine builder 이다. 단순히 작업을 분리하는 것이라면 새로운 코루틴을 생성하는 launch{} 를 사용해도 무방할 것이다. 하지만 launch{} 는 결괏값을 리턴 받을 수 없다. 반면에 async 를 사용하면 리턴 받을 값이 있다면 .await() 함수로 리턴 받을 수 있다.

fun main() = runBlocking{
    val time = measureTimeMillis {
        val one = async {doSomethingUsefulOne()}
        val two = async {doSomethingUsefulTwo()}
        println("The answer is ${one.await() + two.await()}")
    }
    println("Completed in $time ms")
}
suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // pretend we are doing something useful here
    println("doSomethingUsefulOne's delay is done")
    return 13
}
suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // pretend we are doing something useful here, too
    println("doSomethingUsefulTwo's delay is done")
    return 29
}

/* 실행 결과
doSomethingUsefulOne's delay is done
doSomethingUsefulTwo's delay is done
The answer is 42
Completed in 1096 ms
*/

 

늦게 시작하기

async 를 사용할 때 CoroutineStart.LAZY 파라미터를 주면 작업을 바로 시작하지 않고 .start() 함수로 시작할 수 있다.

fun main() = runBlocking{
    val time = measureTimeMillis {
        val one = async(start = CoroutineStart.LAZY) {doSomethingUsefulOne()}
        val two = async(start = CoroutineStart.LAZY) {doSomethingUsefulTwo()}
        two.start() // start the second one
        one.start() // start the first one
        println("The answer is ${one.await() + two.await()}")
    }
    println("Completed in $time ms")
}
...

 

 

suspending function 과 async 함께 사용하기

async() 를 사용하려면 CoroutineScope 가 필요하다.

fun main() = runBlocking{
    val time = measureTimeMillis {
        println("The answer is ${concurrentSum()}")
    }
    println("Completed in $time ms")
}
suspend fun concurrentSum(): Int = coroutineScope {
    val one = async {doSomethingUsefulOne()}
    val two = async {doSomethingUsefulTwo()}
    one.await() + two.await()
}
suspend fun doSomethingUsefulOne(): Int {
    delay(1000L)
    return 13
}
suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L)
    return 29
}

 

 

😶 CoroutineContext

코루틴을 실행하려면 항상 kotlin standrard library 에 정의된  CoroutineContext 가 필요하다. 따라서, coroutine cuilder 인 launch 함수와 async 함수 모두 CoroutineContext 가 필요하다. 

fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext, 
    start: CoroutineStart = CoroutineStart.DEFAULT, 
    block: suspend CoroutineScope.() -> Unit
): Job

fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext, 
    start: CoroutineStart = CoroutineStart.DEFAULT, 
    block: suspend CoroutineScope.() -> T
): Deferred<T>

 

coroutineContext 는 coroutine dispatcher 를 포함하고 있다. coroutine dispatcher 은 코루틴을 실행하는 데 사용되는 스레드(스레드들)를 결정한다. dispather 종류는 다음과 같다.

fun main(): Unit = runBlocking {
    launch { // context of the parent, main runBlocking coroutine
        println("main runBlocking      : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
        println("Unconfined            : I'm working in thread ${Thread.currentThread().name}")
        delay(500)
        println("Unconfined            : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher
        println("Default               : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher
        println("Default2               : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(newSingleThreadContext("MyOwnThread")) { // will get its own new thread
        println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
    }
}

/* 실행 결과
Unconfined            : I'm working in thread main
Default               : I'm working in thread DefaultDispatcher-worker-1
Default2              : I'm working in thread DefaultDispatcher-worker-2
main runBlocking      : I'm working in thread main
newSingleThreadContext: I'm working in thread MyOwnThread
Unconfined            : I'm working in thread kotlinx.coroutines.DefaultExecutor
*/

dispather 를 선언하지 않으면 부모로부터 종속받는다. runBlocking 은 메인 스레드에서 실행된다.

Dispatchers.Default 는 공유된 백그라운드 폴의 스레드폴이 사용된다.

Dispatchers.Unconfined 는 처음에는 호출한 스레드에 의해 실행되지만 delay() 가 호출된 후에는 새로운 스레드의 코루틴을 다시 시작한다. 특정 스레드에 제한된 공유 데이터를 업데이트하지 않는 코루틴에 적합하다.

newSingleThreadContext() 함수로 새로운 스레드를 직접 호출할 수 있지만 이 작업은 비용이 많이 드는 작업이다.

 

 

coroutine 상속과 Job

코루틴이 또다른 코루틴의 CoroutineScope 에 의해 시작될 때 부모의 CoroutineScope.coroutineContext 와 Job 이 상속된다. 따라서 새로운 코루틴은 부모 코루틴의 Job 의 자식이 되고 부모가 작업이 취소되면 자식도 같이 재귀적으로 취소된다.

 

하지만, GlobalScope.launch 에 의해 시작된 코루틴과 같이 시작할 때 다른 범위를 명시적으로 지정하면 상위 범뮈에서 Job 을 상속하지 않는다. 또한, Job 개체가 새 코루틴의 컨텍스트로 전달되면 상위 범위의 Job 을 재정의한다. 다음 예시는 코루틴이 생성될 때 Job 개체를 따로 선언한 예시이다.

fun main() = runBlocking<Unit> {
    // launch a coroutine to process some kind of incoming request
    val request = launch {
        // it spawns two other jobs
        launch(Job()) { 
            println("job1: I run in my own Job and execute independently!")
            delay(1000)
            println("job1: I am not affected by cancellation of the request")
        }
        // and the other inherits the parent context
        launch {
            delay(100)
            println("job2: I am a child of the request coroutine")
            delay(1000)
            println("job2: I will not execute this line if my parent request is cancelled")
        }
    }
    delay(500)
    request.cancel() // cancel processing of the request
    println("main: Who has survived request cancellation?")
    delay(1000) // delay the main thread for a second to see what happens
}

/* 실행 결과
job1: I run in my own Job and execute independently!
job2: I am a child of the request coroutine
main: Who has survived request cancellation?
job1: I am not affected by cancellation of the request
*/

중간에 request.cancel() 함수에 의해 코루틴이 종료되어 자식 코루틴도 종료될 것이다. 하지만 launch(Job()) 으로 선언한 코루틴은 상속된 Job 이 아닌 재정의된 Job 을 가지로 있으므로 종료되지 않고 작업을 마치게 된다.

 

 

이름 붙이기

+ 기호를 사용하여 지정된 이름을 가진 코루틴을 사용할 수 있다.

fun main(): Unit {
    runBlocking {
        launch(Dispatchers.Default + CoroutineName("test")) {
            println("I'm working in thread ${Thread.currentThread().name}")
        }
    }
}
/* 실행 결과
I'm working in thread DefaultDispatcher-worker-1 @test#2
*/

이름이 적용되는 것을 보려면 -Dkotlinx.coroutines.debug 옵션을 사용해야 한다.(JVM 옵션)

 

 

😶 Android 에서의 Coroutines

android 에서 코루틴을 사용한다고 하면, 제일 먼저 화면을 나타내는 Activity, Fragment 등을 떠올릴 것이다. Activity 나 Fragment 는 모두 수명주기를 가지고 있기 때문에 코루틴을 사용한다고 하면 Activity 나 Fragment 가 종료될 때 실행하고 있던 모든 코루틴을 종료해야 할 것이다. 

 

예시) Activity 에서의 coroutines

class MyAndroidActivity {
    private val scope = MainScope()

    override fun onDestroy() {
        super.onDestroy()
        scope.cancel()
    }
}

MainScope() 는 UI 컴포넌트들을 위한 주 CoroutineScope 를 생성한다.

 

 

예시) ViewModel 에서의 coroutines

class BusScheduleViewModel(private val scheduleDao: ScheduleDao) : ViewModel() {
    ...
    fun getSchedule() {
        // viewModelScope
        viewModelScope.launch {
            
        }
    }
    ...
}

 

 

예시) Fragment 에서의 coroutines

class StopScheduleFragment: Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
		
        // coroutineScope
        lifecycle.coroutineScope.launch {

        }
    }
}

 

 

 

 

 

Coroutines basics | Kotlin

 

kotlinlang.org

 

 

Coroutine context and dispatchers | Kotlin

 

kotlinlang.org

 

 

Composing suspending functions | Kotlin

 

kotlinlang.org

 

728x90

'안드로이드 > Kotlin' 카테고리의 다른 글

[Kotlin] Enum Class  (0) 2023.08.10
[Kotlin] Exception Class (예외 처리)  (0) 2023.08.09
[Kotlin] property  (0) 2023.07.21
[Kotlin] object  (0) 2023.07.20
[Kotlin] 람다식 및 고차함수  (0) 2023.07.19