android

15. 안드로이드 동시성 코루틴 공부

코코넛딩 2023. 12. 14. 14:28

이 글은 챗지피티와 아래의 블로그들을 참고하였습니다.

https://gngsn.tistory.com/207

https://medium.com/hongbeomi-dev/%EC%BD%94%ED%8B%80%EB%A6%B0%EC%9D%98-%EC%BD%94%EB%A3%A8%ED%8B%B4-4-coroutine-context-and-dispatchers-1eab8f175428

 

Kotlin, 코루틴 제대로 이해하기 - (1)

kotlin의 Coroutine을 이해하는 것이 해당 포스팅의 목표입니다. 🔗 Kotlin 시리즈 모아보기 사실, 순서대로라면 Class에 대한 내용을 다뤄야하는데, 추석이 끝나고 마음이 급해져서 코루틴이라도 파보

gngsn.tistory.com

 

코루틴이 뭘까요?

  • 코루틴은 경량 스레드와 비슷한 실행단위로, 비동기처리가 가능하다.
  • 장점
    • 코드의 가독성 유지
    • 코루틴은 스레드 보다 가볍다.
    • 멈추거나 재개할 수 있다.

 

코루틴컨텍스트와 코루틴빌더에 대한 설명

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!
*/
  • 위 코드의 설명
    • Hello 다음에 World!로 나오는 이유는 delay 함수에서 코루틴을 1초 동안 중단할 때 다른 코루틴 또는 메인쓰레드의 작업은 계속 진행되기 때문이다.
//예제 1번

import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
    launch { // 부모의 context. main runblocking context입니다.
        println("main runBlocking      : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Unconfined) { // not confined -- 메인 스레드에서 동작합니다.
        println("Unconfined            : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Default) { // DefaultDispatcher로 작업이 보내집니다.
        println("Default               : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(newSingleThreadContext("MyOwnThread")) { // 새로운 스레드로 작업이 전달됩니다.
        println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
    }
}

 

//예제 2번

import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
    launch(Dispatchers.Unconfined) {
        println("Unconfined      : I'm working in thread ${Thread.currentThread().name}")
        delay(500)
        println("Unconfined      : After delay in thread ${Thread.currentThread().name}")
    }
    launch { 
        println("main runBlocking: I'm working in thread ${Thread.currentThread().name}")
        delay(1000)
        println("main runBlocking: After delay in thread ${Thread.currentThread().name}")
    }  
}

/*
Unconfined      : I'm working in thread main
main runBlocking: I'm working in thread main
Unconfined      : After delay in thread kotlinx.coroutines.DefaultExecutor
main runBlocking: After delay in thread main
*/
  • 주의 !
    • 예제는 메인스레드가 사용되었습니다.
    • 메인 스레드에서 runBlocking{}을 수행하면 메인 쓰레드가 정지되어 ANR이 발생할 수 있으므로 메인 쓰레드에서는 사용하지 않는 것이 좋다.
  • launch {}
    • launch{}를 매개 변수 없이 실행 하는 경우 실행 중인 CouroutineScope에서 dispatcher와 함께 context를 상속 받는다.
    • 이 예제의 경우 메인 스레드에서 실행되는 main의 runBlocking 코루틴의 context를 받습니다.
  • Dispatchers.Unconfined
    • 메인쓰레드(runBlocking{}의 영향을 받음)에서 실행되는 것 처럼 보이는 특수한 dispatcher 같지만 사실은 다른 메커니즘이다.
    • Unconfined dispatcher VS Confined dispatcher
      • Dispatchers.Unconfined
        • 이 dispatcher는 호출한 쓰레드에서 코루틴을 시작하지만 
        • 첫번째 suspension 지점(코루틴이 멈추는 시점, delay())까지만 시작합니다. 
        • suspension이 끝나면 호출된 suspension 함수에 의해 다른 스레드로 코루틴을 재개합니다.
        • CPU의 시간을 소비하지 않고 특정 스레드에 제한된 공유 데이터(UI)를 업데이트 하지 않는 코루틴에는 unconfined dispatcher가 적합
        • 코루틴의 일부 연산을 즉시 수행해야해서 나중에 실행할 필요가 없거나(delay를 사용하지 않을 때를 말하는 듯)
        • side effect가 발생하는 특정 사례에서 도움이 될 수 있다.
        • unconfined dispatcher는 일반 코드에서 사용해서는 안된다.
      • confined dispatcher
        • 기본적으로 외부 CoroutineScope로 부터 dispatcher를 상속받는다.
        • 위의 예제 2번 참고
          • launch {}는 실행중인 코루틴 스코프의 dispatcher의 스레드에 제한되므로 이를 상속받으면 예측 가능한 FIFO 스케줄링을 통해 이 스레드의 실행을 제한할 수 있는 효과가 있다.
            • 같은 스레드에서 실행되는 코루틴은 순차적으로(FIFO) 실행된다.
      • 예제 로그 분석
        • launch
          • runBlocking의 dispatcher는 호출한 스레드에 제한된다.
          • runBlocking에서 context를 상속 받은 코루틴은 main 스레드에서 계속 실행된다.
        • unconfined dispatcher
          • delay 함수가 사용 중인 기본 excutor 스레드에서 다시 실행된다.
  • Dispatcher.Default
    • GlobalScope에서 코루틴이 실행될 때 dispatcher는 기본적으로 Dispatcher.Default 혹은 백그라운드 스레드 풀을 공유하여 사용합니다. 그러므도 lauch(Dispatcer.Default) {} 와 GlobalScope.launch {} 는 같은 dispatcher 입니다.
    • 메인 스레드와 별개로 동작하며, 백그라운드 쓰레드에서 작업이 실행된다.
    • 코루틴은 Dispatcher.Default를 사용할 때, 공유된 백그라운드 스레드 풀에서 스레드를 가져온다.
    • CPU 집약적 작업에 최적화 되어있음
      • CPU 집약적 작업
        • CPU가 대부분 연산을 수행하며, 입출력(I/O)작업은 거의 없는 경우
        • 계산 중심적
          • 복잡한 계산, 알고리즘, 데이터 처리
          • 행렬 연산, 암호화, 머신러닝 모델 학습
  • Dispatcher.IO
    • I/O(파일, 네트워크) 작업에 최적화된 Dispatcher
    • 기본적으로 Default 스레드 풀 공유
  • newSingleThreadContext
    • newSingleThreadContext는 코루틴을 실행할 새로운 스레드를 생성합니다.
    • 이 스레드는 매우 비싼 자원입니다.
    • 앱에서는 더이상 필요하지 않을 때 close 함수를 사용하여 해제하거나 최상위 변수에 저장한 후 애플리케이션 전체에 걸쳐 사용해야한다.
  • 코루틴 컨텍스트(CoroutineContext)
    • 컨텍스트를 명시적으로 지정하지 않으면 기본적으로 부모 코루틴의 컨텍스트를 상속받아 실행된다. 
    • 코루틴 컨텍스트에 포함되는 요소
      • Dispatchers
        • 코루틴이 실행될 쓰레드 또는 쓰레드 풀을 결정합니다. 
          • Dispatcher.Main
            • 메인 쓰레드에서 실행
          • Dispatcher.IO
            • I/O 작업을 위한 백그라운드 스레드에서 실행
          • Dispatcher.Default 
            • CPU 집약적 작업을 위한 백그라운드 스레드에서 실행
      •  Job
        • 코루틴 생명주기를 관리합니다.
        • Job은 코루틴의 시작, 취소, 완료 등의 상태를 나타낸다.
        • 아래에 자세한 설명 있습니다.
      • CoroutineExceptionHandler
        • 코루틴 내에서 발생하는 예외를 처리하는데 사용된다.
  • launch{} 코루틴 빌더
    • Job 반환
      • launch 블록은 Job 객체를 반환한다.
      • 이 객체를 사용하여 코루틴의 생명 주기를 관리할 수 있다.
    • 컨텍스트 지정
      • launch 블록은 코루틴 컨텍스트를 지정할 수 있으며 이를 통해 코루틴이 실행될 스레드를 결정할 수 있다.
    • 주요 사항
      • launch 블록 안에서 실행되는 코드는 비동기적이므로, 블록 바깥의 코드는 launch 블록의 완료를 기다리지 않고 즉시 실행된다.
      • launch에 전달된 람다 표현식 내부에서는 코루틴 관련 함수를 사용할 수 있습니다. (예: delay)
      • 코루틴의 완료를 기다리기 위해 Job.join() 메서드를 사용할 수 있습니다. 이는 해당 코루틴이 완료될 때 까지 현재 스레드의 실행을 중단합니다.
      • CoroutineContext를 launch에 전달하여 코루틴의 동작 방식을 조정할 수 있습니다. 예를 들어 Dispatchers.Main은 메인 스레드에서 코루틴을 실행하도록 지정합니다.
  • delay()
    • 일시정지 기능
    • 특정 시간 동안 코루틴을 일시정지 시킨다.
    • 이 일시정지 동안 다른 코루틴이 실행될 수 있다.
    • 현재 쓰레드를 차단하지 않는다.
  • runBlocking{}
    • 코루틴에서 사용되는 블록 
    • 코루틴을 직접 생성하지는 않지만 코루틴 스코프를 생성하여 그 안에서 코루틴을 실행할 수 있는 환경을 제공한다.
    • 새로운 코루틴을 시작하고 해당 블록 내의 모든 코루틴이 완료될 때까지 현재 스레드를 차단한다.
    • runBlocking의 dispatcher는 호출한 스레드에 제한된다.
    • 주로 테스트나 메인함수에서 코루틴 코드를 동기적으로 실행하기 위해서 사용한다.
    • runBlocking은 코루틴이 완료될 때까지 프로그램의 실행을 중지 시키기 때문에 일반적인 앱에서는 사용을 자제하는 것이 좋다.
  • async
    • 코루틴 빌더 중 하나로 비동기 작업을 시작하고 결과를 나중에 받을 수 있게 해주는 함수이다.
    • async는 Deferred 객체를 반환한다. 이 Deferred 객체를 통해 비동기 작업의 결과를 나중에 가져올 수 있다.
    • 특징
      • 비동기 실행
        • async를 사용하여 시작된 코루틴은 호출자 스레드를 차단하지 않고 백그라운드에서 비동기적으로 실행된다. 
      • 결과 반환
        • async는 Deferred 객체를 반환한다. 
        • 이 객체의 await()메서드를 호출하여 코루틴이 완료될 때까지 기다린 후 결과를 받을 수 있습니다.
      • 값 반환 
        • launch와 달리 async는 결과값을 반환할 수 있습니다.
        • 코드 블록의 마지막 표현식이 Deferred 객체에 의해 반환됩니다.
      • 예외 처리 
        • async에서 발생한 예외는 Deferred 객체에 저장되며, await() 호출시에 예외가 발생합니다.
//async관련 코드

import kotlinx.coroutines.*

fun main() = runBlocking {
    val deferred: Deferred<Int> = async {
        // 비동기로 실행할 작업
        delay(1000L) // 비동기 대기
        return@async 42 // 결과값 반환
    }

    println("작업 중...")
    val result: Int = deferred.await() // 작업 완료를 기다리고 결과를 받음
    println("결과: $result") //await()가 끝나야 이 코드가 실행됨
}

 

import kotlinx.coroutines.*

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 비동기 작업 시작
        lifecycleScope.launch {
            val result = async(Dispatchers.IO) {
                // 네트워크 호출 또는 파일 읽기
                fetchDataFromNetwork()
            }
            // 결과 기다림
            val data = result.await()
            // UI 업데이트 (메인 스레드)
            updateUI(data)
        }
    }

    private suspend fun fetchDataFromNetwork(): String {
        delay(1000L)  // 네트워크 지연 시뮬레이션
        return "Hello, World!"
    }

    private fun updateUI(data: String) {
        // UI 업데이트
        findViewById<TextView>(R.id.textView).text = data
    }
}
  • async{}가 완료되어야 await() 뒤에 코드를 실행할 수 있다.
  • lifecycleScope.launch {}로 async를 감싼다.

 

 

Structured Concurrency

import kotlinx.coroutines.*

fun main() = runBlocking { // 상위 코루틴 스코프
    launch { // 자식 코루틴
        // 여기에 작업을 정의합니다.
    }
    // runBlocking 스코프는 내부의 모든 코루틴이 완료될 때까지 대기합니다.
}

 

  • 위의 코드에 대한 설명
    • runBlocking은 상위 코루틴 스코프를 제공하며, 이 스코프 내에서 시작된 모든 코루틴은 runBlocking 스코프에 의해 관리된다.
  • 주요 특징
    • 스코프에 의한 관리
      • 모든 코루틴은 특정 스코프 내에서 실행됩니다.
      • 이 스코프는 코루틴의 실행, 취소, 완료를 관리합니다.
    • 부모-자식 관계
      • 코루틴 스코프 내에서 시작된 모든 코루틴은 부모 코루틴과 연결됩니다.
      • 부모 코루틴이 종료되면 자식 코루틴도 함께 종료됩니다.
    • 오류 전파
      • 부모 코루틴 또는 스코프 내에서 발생한 오류는 자식 코루틴으로 전파된다.
    • 생명주기 관리
      • 코루틴 스코프는 해당 스코프 내의 모든 코루틴이 완료될 때까지 종료되지 않습니다.
      • 이로 인해 코루틴의 실행이 잘 관리되며, 누락되거나 고아화된 코루틴을 방지합니다.

 

suspend

  • suspend 키워드는 suspend 함수가 코루틴의 실행을 중단(suspend)하고 suspend 함수가 완료되면 코루틴을 재개할 수 있음을 나타낸다.
  • 이러한 suspend 함수는 코루틴 내에서만 호출될 수 있으며, 비동기 suspend 작업을 수행하는 동안 현재 코루틴을 중단시키고 해당 작업이 완료될때까지 기다린후 다시 재개합니다.
  • suspend 함수의 특징
    • 비동기 작업 수행
      • suspend 함수는 네트워크 요청, 데이터베이스 접근과 같은 비동기 작업을 쉽게 처리할 수 있도록 도와준다.
    • 코루틴의 중단 및 재개
      • suspend함수는 현재 코루틴의 실행을 중단 시킬 수 있으며, suspend가 완료된 후 코루틴을 재개한다.
    • 스레드 차단 방지
      • 일반적인 비동기 작업과 달리 suspend 함수는 스레드를 차단하지 않고 비동기 작업을 수행한다.

 

import kotlinx.coroutines.*

suspend fun fetchData(): String {
    // 비동기로 데이터를 가져오는 작업
    delay(1000) // 비동기 대기를 시뮬레이션
    return "데이터"
}

fun main() = runBlocking {
    val data = fetchData() // suspend 함수 호출
    println(data)
}

 

Coroutine Scope

import kotlinx.coroutines.*

fun main() {
    println("Main Scope Started")

    CoroutineScope(Dispatchers.Default).launch { // 상위 CoroutineScope에서 launch
        println("Task 1 started in main scope")

        // 하위 coroutineScope 생성
        coroutineScope {
            println("Entered inner coroutineScope")

            // 하위 coroutineScope 안에서 launch
            launch {
                delay(1000)
                println("Task 1 in inner coroutineScope completed")
            }

            launch {
                delay(500)
                println("Task 2 in inner coroutineScope completed")
            }
        }

        println("Exited inner coroutineScope, back to main scope")
    }

    Thread.sleep(2000) // 메인 스레드가 종료되지 않도록 대기
    println("Main Scope Completed")
}
  • 코루틴 스코프는 코루틴이 실행되고 관리되는 환경을 제공한다.
    • 환경
      • 코루틴의 수명주기, 실행 컨텍스트(Dispatcher), 예외 처리 등을 관리한다.
    •  코루틴 스코프의 주요 역할
      • 코루틴 관리
        • 코루틴 스코프 안에서 실행된 코루틴은 스코프가 종료될 때 함께 정리 된다.
      • 컨택스트 제공
        • Dispatcher롸 같은 컨택스트를 코루틴에 제공해 실행을 제어한다.
      • 구조적 동시성
        • 자식 코루틴 들이 스코프를 벗어나지 못하도록 제한하여 안정적으로 작업을 완료한다.
  • 코루틴 스코프의 생성 방법
    • runBlocking
      • main함수(onCreate())에서 자주 사용됨
        • 현재 스레드를 차단하면서 코루틴 환경을 생성하기 때문에, 프로그램 진입점(main(), onCreate())에서 코루틴을 동기적으로  실행하기에 편리하기 때문이다.
    • CoroutineScope
      • 명시적으로 스코프를 생성한다.
    • coroutineScope
      • 일시 중단 함수로, 기존 코루틴 스코프 내에서 하위 스코프를 만든다.
  • 코루틴 빌더
    • 코루틴 스코프 안에서 코루틴 빌더를 사용하여 여러 코루틴을  실행할 수 있다.
    • launch
    • async
    • runBlocking

 

 

coroutineScope

import kotlinx.coroutines.*

fun main() = runBlocking {
    coroutineScope { // 새로운 코루틴 스코프 생성
        launch { //(1번)
            delay(1000L)
            println("first 1000")
        }
		
        coroutineScope { //(2번)
              launch { //(2-1번)
               UtilFunction.log("no delay")
              }
              launch { //(2-2번)
                delay(1000L)
               	UtilFunction.log("delay 1000")
              }
          }
                
        delay(500L) //(3번)
        println("Task from coroutine scope") 
    }
    println("Coroutine scope is over") //(4번)
}

/*
no delay
first 1000
delay 1000
Task from coroutine scope
Coroutine scope is over
*/

 

  • 특징
    • coroutineScope{}는 비동기적이다. coroutineScope{}안의 코루틴들은 동시에 시작된다.
      • 1번과 2번이 동시에 시작된다.
        • 1번이 2번의 coroutineScope 보다 먼저임으로 2번의 coroutineScope로 1번을 멈출수 없다.
      • 3번은 2번이 끝난 후에 시작된다.
        • 3번은 coroutineScope의 뒤에 있기때문에 일시정지가 되었다.
    • coroutineScope는 일시중단함수(suspend function)이다.
      • 일시 중단 함수는 반드시 코루틴 컨텍스트가 있어야 호출가능하며, 일반 함수처럼 호출할 수 없다.
    • 코루틴 스코프 생성
      • coroutineScope는 상위 코루틴 컨텍스트를 기반으로 새로운 코루틴 스코프를 생성한다.
      • 즉 코루틴 안에서만 호출 가능한 하위 스코프 생성 도구이다.
    • 자식 코루틴 관리
      • coroutineScope가 생성한 새로운 스코프 내에서 시작된 모든 자식 코루틴이 완료될 때까지 현재 코루틴의 실행을 일시 중지합니다.
    • 오류 전파
      • 스코프 내의 어떤 코루틴에서 오류가 발생하면, 해당 스코프 내의 모든 코루틴이 취소됩니다.
  • 주의 사항
    • coroutineScope는 호출된 코루틴을 일시중지 시킬수 있으므로 이를 사용할 때는 현재 코루틴의 실행 흐름을 고려해야합니다.
    • runBlocking과 다른 점
      • runBlocking은 주로 테스트나 메인함수에서 사용되며 현재 스레드를 차단한다.
      • coroutineScope는 현재 코루틴을 일시 중지 시키고 새로운 코루틴 스코프를 생성한다.

 

Job

1. Job의 상태

코루틴의 Job은 다음과 같은 6가지 상태를 갖는다.
이 상태를 바탕으로 제어할 수 있다.

 

 

isActive, isCompleted, isCancelled는 다음과 같이 확인 가능

val job = launch{
	//coroutine
}

job.isActive // isActive(Boolean)반환
job.isCancelled // isCancelled(Boolean)반환
job.isCompleted // isCompleted(Boolean)반환

 

2. Job의 메소드

1. start()

fun main() {
     val job = CoroutineScope(Dispatchers.IO).launch(start = CoroutineStart.LAZY) {
        print("print!!")
      }

    job.start()
}

 

  • job을 실행한다.
  • start()의 반환값은 Boolean이다.
    • 호출했을 때 코루틴이 동작 중이면 true
    • 준비, 완료상태이면 false
  • start로 생성된 job은 중단(suspend)없이 실행된다. 즉, CouroutineScope 안에서 실행하지 않아도 됨
  • start를 호출한 스레드가 종료되면 job도 같이종료됨
  • 위 코드는 job.start() 함수를 호출한 MainThread가 종료되면 IO스레드도 같이 종료되기 때문에 "print!!"가 출력되지 않는다.

 

2. join()

suspend fun main() {
     val job = CoroutineScope(Dispatchers.IO).launch(start = CoroutineStart.LAZY) {
        print("print!!")
      }

    job.join()
}

 

  • job을 실행한다.
  • Job의 동작이 완료될 때 까지 join을 호출한 코루틴을 일시중단합니다.
  • MainThread가 job이 끝날 때 까지 일시중단되기 때문에 "print!!"가 출력된다.

3. canel()

  • 현재 코루틴에 종료를 유도하고 start()와 마찬가지로 대기하지 않는다.
  • CouroutineScope 밖에서 호출해도 무관
  • 만약 타이트하게 동작하는 반복문에 delay가 없다면 종료하지 못한다.

4. cancelAndJoin()

  • 현재 코루틴을 즉시 종료하라는 신호를 보낸 후 정상 종료될 때 까지 대기한다.

5. cancelChildren()

  • CoroutineScope내에 작성한 자식 코루틴들을 종료한다.
  • cancel()과 다르게 하위아이템만 종료하며, 상위 코루틴은 취소하지 않습니다.

3. Job 활용하기

val job = CoroutineScope(Dispatcher.IO).launch{
   // coroutine
}
  • Job은 위와 같이 1개의 launch를 관리할 수 있다.
val job = Job() // 하나의 Job 생성
  • 여러 Job을 관리하려면 하나의 Job을 생성하고 N개의 laucher와 actor를 관리하면된다.
CoroutineScope(Dispatchers.Default + job)

 

  • CoroutineScope에 job을 함께 초기화하면 CoroutineScope의 자식까지 모두 관리할 수 있습니다.
.launch(Dispatchers.IO + job)
  • launch에만 적용할 수 있습니다.

 

디버깅 코루틴과 스레드

import kotlinx.coroutines.*

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

fun main() = runBlocking<Unit> {
    val a = async {
        log("I'm computing a piece of the answer")
        6
    }
    val b = async {
        log("I'm computing another piece of the answer")
        7
    }
    log("The answer is ${a.await() * b.await()}")
}

/*
[main @coroutine#2] I'm computing a piece of the answer
[main @coroutine#3] I'm computing another piece of the answer
[main @coroutine#1] The answer is 42
*/
import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
    println("My job is ${coroutineContext[Job]}")
}
/*
My job is "coroutine#1":BlockingCoroutine{Active}@6d311334
*/
  • 코루틴은 한 스레드에 의해 중지되고 다른 스레드에 의해 개재 될 수 있다.
  • 싱글 스레드 dispatcher로는 코루틴이 어디에 있는지 파악이 어려울 수 있다.
  • 스레드를 사용해서 앱을 디버깅하는 방법은 각 로그에 스레드 이름을 출력하는 것이다.
  • 이 기능은 로깅 프레임워크에 의해 지원된다.
  • 코루틴을 사용할 때 스레드의 이름만으로는 context를 판단하기 어렵기 때문에 kotlinx.coroutines에서는 디버깅에 대한 지원이 제공된다.
  • Job은 context의 일부로서 coroutineContext[Job] 식을 사용하여 검색할 수 있다.
  • 위의 예제를 터미널이나 gradle에서 -Dkotlinx.coroutines.debug 옵션을 사용해 실행해보자