코틀린 코루틴

YounDitt·2020년 9월 28일
2

[Kotlin] 기본

목록 보기
3/3

코틀린&안드로이드 관점(?)으로 코루틴에 대해 정리하였다.

코루틴이란?

  1. 협력형 멀티태스킹
    • return이나 }를 만나지 않아도 진입점도, 탈출점도 여러개이다.
    • 탈출점 : suspend로 선언된 함수를 만나면 코루틴 밖으로 잠시 나간다.
  2. 동시성(시분할) 프로그래밍 지원
    • 스레드는 하나이지만 빠르게 왔다갔다해서 마치 동시에 작업되는 것처럼 보이는 것.
      (병행성과는 다르다.)
    • 멀티스레드환경에서는 OS가 일정 시점에서 흐름을 끊지만, 코루틴은 프로그램이 블로킹되는 지점에서 흐름을 끊는다.

👀 Stackless와 Stackful 코루틴 참고
코루틴은 stack의 유무에 따라 두가지 종류로 나뉘며, 코틀린의 경우 stackless하게 구현되어 있다.

  • Stackful : 일반적인 함수처럼 내부에서 다른 함수를 호출했을때 해당 코루틴은 suspend할 수 있음.
  • Stackless : 호출하려는 함수를 다시한번 코루틴 객체로 묶어서 중첩호출을해야 이전 코루틴과 내부코루틴은 suspend를 통해 연결할 수 있음.
  • 만약 두개의 스레드에서 작업할 경우, CPU가 매번 스레드를 점유했다가 놓아주는 것을 반복해야 해서 컨텍스트 스위칭 비용이 꽤 발생한다.

코루틴의 장점

  1. 경량 : 코루틴을 실행중인 스레드를 차단하지 않는 정지를 지원하므로 단일스레드에서 많은 코루틴을 실행할 수 있다.
  2. 메모리 누수 감소 : 구조화된 동시실행을 사용하여 범위 내에서 작업을 실행한다.
  3. 처리 도중 취소 가능 : 실행중인 코루틴의 계층구조를 통해 자동으로 취소가 전달된다.
    • 다른 처리를 중단(blocking)시키지 않고 중지(suspend)하는 형태로 가볍게 동작

👀 경량?

기존의 스레드는 생성, 해제(GC), Context-switching시 CPU와 메모리를 소모하기 때문에 많은 수의 스레드를 갖기 어렵다.
코루틴은, 스레드가 아닌 서브루틴을 일시중단(suspend)하는 방식이기 때문에 Context-switching에 비용이 들지 않는다.

👀 Context-switching

스레드 실행 혹은 종료시 스레드의 상태를 저장하고 복구하는 프로세스
= 두개의 스레드에서 작업 시 CPU가 매번 스레드를 점유했다가 놓아주는 것을 반복할때 비용 발생

1. 코루틴 생성

새로운 코루틴을 생성, 시작 실행하기 위해서 Coroutine Scope와 Coroutine Builder가 필요하며 생성시 코루틴에 대한 정보는 Coroutine Context에 담는다.

  1. 사용할 Dispatcher 결정
  2. Dispatcher를 이용하여 Scope생성
  3. Scope의 launch나 async에 수행할 코드블록 작성

1. Coroutine Scope

  • 제어범위와 실행범위를 지정할 수 있다.
  • 스코프 내에서 생성된 코루틴을 주시하며 실행을 취소하거나, 실패 시 예외를 처리할 수 있다.
  • 스코프의 Job을 취소하면 해당 스코프 안에서 시작된 코루틴(자식 job)은 모두 취소된다.
    단, 스코프 안에 다른 스코프를 생성하면, 분리가 되기때문에 외부 스코프의 코루틴을 취소해도 해당 별도의 스코프는 중지되지 않는다.
runBlocking {
        val parent = launch {
            GlobalScope.launch {
                println("job1 : GlobalScope에서 코루틴 실행")
                delay(1000)
                println("job1 : cancel 이후에도 호출될까?")
            }

            launch(Dispatchers.IO) {
                println("job2 : 부모의 스코프에서 자식 코루틴 실행")
                delay(1000)
                println("job2 : cancel 이후에도 호출될까?")
            }
        }
        delay(500)
        parent.cancel()
        delay(1000)
        println("어디까지 출력이 될까?")
    }

--------------------------------------
job1 : GlobalScope에서 코루틴 실행
job2 : 부모의 스코프에서 자식 코루틴 실행
job1 : cancel 이후에도 호출될까?
어디까지 출력이 될까?

parent를 중지하더라도 job1은 GlobalScope에서 실행한 코루틴이기 때문에 parent의 자식이 아니다.
따라서, parent을 취소해도 job1은 취소되지 않는다.
job2의 디스패처가 다르더라도 parent의 자식job이기 때문에 같이 취소된다.

1. GlobalScope

프로그램 어디서나 제어 동작이 가능한 범위

  • 안드로이드의 경우 어플리케이션 라이프사이클을 따른다.
  • 싱글톤으로 최상위 레벨에서 코루틴을 시작한다.
  • Dispatchers.Default의 스레드를 사용한다.
public object GlobalScope : CoroutineScope {
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

2. CoroutineScope

특정한 목적의 dispatcher를 지정하여 제어 및 동작이 가능한 범위

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

👀 Android환경에서 생명주기에 맞게 제공하는 Custom Scope

  1. ViewModelScope : viewmodel이 삭제되면 자동으로 취소된다.
  2. LifecycleScope : Lifecycle객체에서 정의되며 Lifecycle이 끝날때 취소된다.
  3. Livedata : Livedata Builder에서 suspend 함수를 사용할 수 있으며, 비활성화가 되면 구성가능한 제한시간 후 자동으로 취소된다.
fun <T> liveData(
    context: CoroutineContext = EmptyCoroutineContext,
    timeout: Duration,
    @BuilderInference block: suspend LiveDataScope<T>.() -> Unit
): LiveData<T> = CoroutineLiveData(context, timeout.toMillis(), block){...}

2. Coroutine Context

코루틴을 어떻게 처리할 것인지에 대한 여러가지 정보의 집합

1. Dispatchers

코루틴의 실행을 특정 스레드로 제한하거나, 스레드풀에 보내거나, 제한을 받지 않는 상태로 실행할 수 있다

1. Dispatchers.Default (📌기본)

  • CPU를 많이 사용하는 무거운 작업에 사용 (Read/Wrtie, json파싱 등)
  • JVM의 공유된 백그라운드 스레드의 commonPool에서 동작한다.
  • 동시작업 가능한 최대 개수는 CPU코어수와 같으며 최소 2개이다.
  • (Android) Application의 생명주기에 따른다.
  • GlobalScope.launch{...}와 동일하지만 취소시 동작이 다르다.

    GlobalScope는 재귀적으로 Job이 취소되지 않는다.

2. Dispatchers.IO

  • 가볍고 빈번한 IO작업에 사용 (Network등)
  • Blocking IO용 공유 스레드풀에서 동작한다.
  • 필요에 따라 추가적으로 스레드를 더 생성하거나 줄일수 있다.
    (최대 64 or 코어 수 중 큰 수까지 생성가능)
  • Dispatchers.Default와 스레드를 공유하므로 withContext에서 Dispatcher변경시 스레드가 전환되지 않고 동일한 스레드에서 실행이 계속된다.

3. Dispatchers.Main

  • suspend 함수 호출, LiveData 업데이트, UI작업 등에 사용
  • 단일스레드에서 동작한다.
  • 메인(UI)스레드에서 동작한다.

    👀 헷갈리는 동작순서
    내부에서 launch등 새로운 코루틴을 생성할 경우 같은 스레드라도 순서가 하나 뒤로 밀릴 수 있다.(한 프레임 늦게 실행됨)

    runBlocking<Unit> {
    // MainThread에서 1 2 3 4 5 순서대로 출력된다.
        println("${Thread.currentThread().name} 1")
        launch{
            println("${Thread.currentThread().name} 3")
            delay(1000)
            println("${Thread.currentThread().name} 5")
        }
        println("${Thread.currentThread().name} 2")
        delay(1000)
        println("${Thread.currentThread().name} 4")
    }
    • launch가 아닌 withContext로 실행할 경우, 순서를 보장한다.(suspend)
    • Dispatchers.Main.Immediate 를 사용하면 바로 실행된다.

4. Dispatchers.Unconfined

  • 첫번째 지연점까지만 실행된다.
  • 메인스레드에서 동작한다.

👀 CommonPool 사용
이미 초기화되어 있는 스레드 중 하나를 선택함으로써 스레드를 별도로 생성하는 오버헤드가 없어 빠르다.

  • 하나의 스레드에 다수의 코루틴이 동작할 수 있다.
  • 특정 스레드개수를 직접 지정할 수 있다.

👀 Dispatchers.IO와 Dispatchers.Default
특정 Dispatchers가 더 빠르다라고 정의할 수 없다.
코어수만큼 스레드를 생성하는 Default에는 CPU연산이 많은 무거운 작업을 수행하는 것이 효율적이다.
응답대기시간이 있고, 가벼우면서, 동시에 처리해야하는 작업의 개수가 많은 네트워크 입출력의 작업등은 IO에서 수행하는 것이 효율적이다.

2. Job(& Deferred)

  • 스코프와 코루틴의 생명주기를 지정할 수 있다
  • 원하는 대로 예외처리를 할 수 있다.

👀 Structured concurrency(구조화된 동시성)

  • scope로 생성된 job에서 해당 scope를 사용하여 생성된 모든 코루틴은 job의 자식이 되며, 자식코루틴 중 하나가 처리되지 않은 예외를 던지면 부모job이 취소되어 결국 모든 자식 코루틴이 취소되는 것.
  • cancel 또는 예외로 인한 실패로 부모가 취소되면 모든 자식이 즉시 취소된다.
  • 부모는 자식이 완료 또는 취소상태로 완료될때까지 기다린다.
  • launch로 생성한 코루틴의 경우 자식에서 catch하지 않은 예외는 부모를 취소시킨다.
    (async로 생성한 코루틴은 결과에 캡술화 되기 때문에 캐치되지 않는 예외가 없다.)

👀 SupervisorJob

  • 한방향으로만 취소를 전달한다.
  • 한 자식에서 취소가 발생해도 다른 자식들에게는 영향을 미치지 않는다
  • Structured concurrency의 예외사항이다.

3. CoroutineName

디버깅에 유용한 코루틴의 이름

4. CoroutineExceptionHandler

포착되지 않은 예외처리

3. Coroutine Builder

👀 코루틴 블락을 생성하는 방법(스레드를 기준으로)
1. launch : non-blocking
2. async : non-blocking
3. runBlocking : blocking

1. launch

  • job객체 반환한다.
  • 결과를 반환하지 않는다.
  • 그 자리에서 바로 예외를 발생시킨다.
  • join()을 사용하여 job의 작업이 완료될때 까지 기다릴 수 있다.

2. async

  • Deferred객체 반환한다.
  • 결과를 반환한다.
  • await()를 만나면 실행 or 예외를 발생시킨다.
  • await()는 deffered객체가 완료 된 후 그 값으로 다음을 진행한다.

    launch와 async의 공통점

    CoroutineScope의 확장으로, Coroutine Scope가 있어야 새로운 코루틴을 생성할 수 있다.

    1. 새로운 코루틴을 만든다.
    2. 하나의 Dispatcher를 갖는다.
    3. 스코프 안에서 실행된다.
    4. suspend함수가 아니다.
    public fun CoroutineScope.launch(...):Job {...}
    public fun <T> CoroutineScope.async(...):Deferred<T> {...}

3. runBlocking

  • 코루틴을 생성한 후 코루틴이 끝나고 그 결과값을 반환할 때까지 현재 스레드를 블락한다.
  • runblocking의 기본 dispatcher는 이를 시작한 thread이다.

4. withContext

(빌더의 하나로 보는게 맞을까..?)

  • CoroutineContext를 변경할 때(컨텍스트 스위칭) 사용한다.
  • 새로운 dispatcher가 지정되면 해당 블럭을 다른 스레드로 이동하고, 완료되면 원래의 디스패처로 돌아온다.(원래의 스레드로 돌아오는 것은 아니다. 스레드풀에서 알아서 슝슝?)
  • 최소한으로 묶는걸 추천한다.
  • 항상 suspend fun안에서 사용된다.
  • async와 동일한 역할을 한다. 차이점은 await를 호출할 필요가 없이 결과가 리턴될때까지 기다린다는 것이다.

2. 코루틴 지연

코루틴 내부나 루틴의 대기가 가능한 곳(runBlocking)에서만 사용이 가능하다.

  1. delay : milisecond단위로 루틴을 잠시 대기시키는 함수(suspend fun)
  2. join : Job의 실행이 끝날때까지 대기하는 함수.(suspend fun)
  3. await : Deferred의 실행이 끝날때까지 대기하는 함수(결과값 반환)(suspend fun)
// 동기적으로 실행하기.
job.join()
job.start()
deferred.await() //해당 코루틴이 종료될때까지 대기 후 다음 라인 실행
deferred.start() //동시에실행?

3. 코루틴 중단

cancel()을 이용하여 중단할 수 있다.

  1. 코루틴 내부의 dealy() / yield()함수가 사용된 위치까지 수행된 뒤 종료됨
  2. 코루틴 내부에서 CoroutineScope의 확장프로퍼티인 isActive가 false로 변하면 코드에서 수동으로 종료하도록 처리

👀 코루틴 중단시 마무리 처리

  • 코루틴을 취소할때, resource를 닫는다거나 마지막으로 해야할 작업이 필요하면 try-finally 구문을 사용하거나 onCancellationException를 사용한다.
  • 부득이하게 finally에서 suspending func을 사용해야하면 withContext(NonCancellable)로 감싸준다.

4. Exception

  • launch, actor : exception발생 시 바로 예외가 발생함.
  • async, produce : 중간에 exception이 발생해도 await를 만날때 exception이 발생함.
  • 코루틴은 취소를 제외한 다른 exception이 발생하면 부모의 코루틴까지 모두 취소시킨다. 이는 structured concurrency를 유지하기 위함으로 CoroutineExceptionHandler를 설정해도 막을 수 없다.
  • CoroutineExceptionHandler를 이용하여 코루틴 내부의 기본 catch block으로 사용할 수 있다.
  • 자식 코루틴에서 exception이 발생하면 다른 자식 코루틴 및 부모코루틴이 다 취소되버리기 때문에, 문제가 생긴 코루틴만 exception 처리할 수 있도록 하기 위해 CoroutineExceptionHandler를 설정한다.
  • 단, CancellationException는 handler에서 무시된다.
  • (참고) 여러개의 exception이 발생하면 가장 먼저 발생한 exception이 handler로 전달되며 나머지는 무시된다.

5. Suspend

코루틴은 잠시 실행을 멈추거나(suspend) 다시 실행될(resume) 수 있기 때문에 코루틴에서 실행할 수 있는 메소드를 만들때 suspend 키워드를 사용한다..

  • 비동기 환경에서 사용될 수 있다는 의미를 내포한다.
  • 코루틴 내에서만 호출할 수 있고 코루틴 스코프 안에서 실행된다.
  • 코틀린 컴파일러에게 이 함수는 코루틴 안에서 실행되어야 한다고 알려주는 역할
  • fun이 동작되는 동안 thread-safe환경을 유지해준다.
  • 콜백을 대체하여 순차적인 함수호출로 변환할 수 있다.(continuation)
  • 기본적으로 메인스레드에서 동작한다.

    👀 CPS(Continuation Passing Style)
    Kotlin의 코루틴은 suspend 키워드로 마킹된 함수를 CPS(Continuation Passing Style)로 변환하고, 이를 Coroutine Builder를 통해 적절한 스레드 상에서 시나리오에 따라 동작하도록 구성된다.
    주의해야 할 점은 suspend function은 스레드와 스케쥴의 관리를 수행하는 것이 아니라 비동기 실행을 위한 중단(suspension) 지점의 정의라는 점이다. 코루틴은 중단 지점까지 비선점형으로 동작하기 때문에 실행 스케쥴이 OS에 의해 온전히 제어되는 스레드와는 다른 관점에서 보아야 한다.

6. 스레드와 비교

코루틴이 하나의 실행-종료되어야 하는 일(Job)이라면 스레드는 그 일이 실행되는 곳이다.

  • 코루틴과 스레드는 모두 동시성을 보장하기 위한 기술이다.

  • 하나의 스레드에 여러개의 코루틴이 동시에(concurrency) 실행될 수 있다.

  • 스레드는 여러스레드가 있다면 병렬로(Parallelism) 실행된다.

  • 코루틴의 경우 스레드풀을 사용하는 dispatcher로 코루틴들을 실행하면 스레드처럼 병렬프로그래밍을 할 수 있다.

    👀 동시성(Concurrency)과 병렬성(Parallelism)

    • 동시성 : 시분할, 각 task들을 조금씩 나누어서 실행
    • 병렬성 : 병렬수행, 각 task들을 동시에 수행

🙋‍♀️ ThreadLocal을 사용할 때 주의사항
코루틴 내부에서 사용되는 localdata를 지정할 수 있다.
하나의 로직이 같은 스레드 내에서 실행된다는 보장이 없으므로 동기화에 유의하여야 한다.

Exception 참고
SupervisorJob 참고
suspend 참고

참고 1
참고 2

참고 3
참고 4
참고 5
참고 6
참고 7
참고 8
참고 9
참고 10
참고 11
참고 12

profile
Hello, Android

0개의 댓글