Coroutine

Dev·2023년 1월 24일
0

1. Coroutine이란?

  • 어떠한 코루틴이 발동될 때 마다 해당 코루틴은 이전에 자신의 실행이 마지막으로 중단되었던 지점 다음의 장소에서 실행을 재개한다.
  • kotlin의 coroutine은 suspend 키워드로 마킹된 함수를 CPS(Continuation Passing Style)로 변환하고, 이를 Coroutine Builder를 통해 적절한 스레드 상에서 시나리오에 따라 동작하도록 구성된다.
  • 주의할 점은 suspend function은 스레드와 스케줄의 관리를 수행하는 것이 아니라, 비동기 실행을 위한 중단(suspencion) 지점의 정의라는 점이다. 코루틴은 중단 지점까지 비선점형으로 동작하기 때문에 실행 스케줄이 os에 의해 온전히 제어되는 스레드와는 다른 관점에서 보아야 한다.
  • 스레드는 잘 실행하다가 갑자기 os가 나오라면 제어권을 양도하고 비켜야 하는데, 코루틴은 중단 지점을 만나지 않는 한 제어권을 양도하지 않고 이어서 계속 실행한다.
  • 코루틴은 스레드와 기능적으로 같지만, 스레드에 비교하면 좀 더 가볍고, 유연하게 동시성 프로그래밍을 지원한다. 즉, 하나의 스레드 내에서 여러 개의 코루틴이 실행되는 개념이다.
  • 코루틴은 코틀린만의 것이 아니다. 이름이 비슷해서 코틀린의 것이라고 생각할 수 있지만 파이썬, C#, Go, Javascript 등 여러 언어에서 지원하고 있는 개념이다.

몇 가지 질문

  • 병렬 I/O를 위해서는 멀티스레드를 사용하거나, 이벤트큐 & 큐를 사용하는 방법 뿐이었는데 코루틴 CPS를 이용하면 단일 스레드 하에서 병렬 작업이 가능하다.

    • 코루틴이 스레드를 대체할 수 있나?
      • 코루틴은 스레드 위에서 돌아가는 것이지만, 멀티스레드 대체 관점에서는 부분적으로는 가능. (1 스레드 위에 n개 코루틴)
      • I/O Bound 작업은 가능한데(단일 스레드에서도 소화 가능한 수준이라면), CPU Bound 작업은 코루틴이 돌아가는 기반 스레드 풀 수를 늘려줘야한다.
    • 다중 스레드 환경에서 코루틴은?
      • 코루틴의 A 부분은 1번 스레드에서 실행 -> suspend 마주치고 대기하닥 실행 -> B번은 2번 스레드에서 실행 요렇게!
      • 이건 Dispatcher에 따라 다르다. unconfined vs confined
    • Dispatchers.Default 스레드 풀은 CPU core 수와 동일하게 구성되며, core가 1개인 경우는 2개로 구성된다.
  • Blocking Function을 사용해야 하는 경우, 어떻게 coroutine으로 wrapping해야되나요?

    • Cpu bound 작업이면 Dispatchers.Default, I/O Bound 작업이면 Dispatchers.IO에 던진다.
    • 아마 별도의 스레드로 던지겠지?

2. 특징

[1] 협력형 멀티 태스킹

  • Co[협력/함께] + Routine[태스크/함수] : 협력하는 함수
  • 코루틴도 routine이기 때문에, 하나의 함수로 생각하자. 그런데 이 함수에 진입할 수 있는 진입점도 여러개고, 함수를 빠져나갈 수 있는 탈출점도 여러개다. 즉, 코루틴 함수는 꼭 'return'문이나 마지막 닫은 괄호를 만나지 않더라도 언제든지 중간에 빠져나갈 수 있고, 언제든지 다시 나갔던 그지점으로 돌아올 수 있다.

예시

fun drawPerson() {
    startCoroutine {
        drawHead()
        drawBody()
        drawLegs()
    }
}

suspend fun drawHead() {
    delay(2000)
}

suspend fun drawBody() {
    delay(2000)
}

suspend fun drawLegs() {
    delay(2000)
}
  1. 쓰레드의 Main함수가 drawPerson()을 호출하면 startCoroutine블럭을 만나 코루틴이 된다(정확하게는 하나의 코루틴을 만들어 시작한다). 위에도 말했듯이 이제 drawPerson()은 진입점과 탈출점이 여러개가 되는 자격이 주어진 것이다.
  2. 코루틴이 실행되었지만, suspend를 만나기 전까지는 그다지 특별한 점은 없다. suspend로 정의된 함수가 없다면 그냥 마지막 괄호를 만날 때 까지 계속 실행된다. 그러나 drawHead()는 suspend 키워드로 정의되어진 함수다. 따라서 drawHead() 부분에서 더 이상 아래 코드를 실행하지 않고 drawPerson()이라는 코루틴 함수를 (잠시)탈출한다.
  3. 메인 스레드가 해당 코루틴을 탈출했다. 그렇다고 쓰레드가 놀고 있을리는 없다. 우리가 짜 놓은 다른 코드들을 실행할 수도 있고, 안드로이드라면 UI 애니메이션을 처리 할 수도 있다. 그러나 Head는 어디선가 계속 그려지고 있다. drawHead()는 2초가 걸리는 suspend 함수였음을 기억해보자. drawHead()라는 suspend를 만나 코루틴을 탈출했지만, drawHead() 함수의 기능은 메인쓰레드에서 동시성 프로그래밍으로 작동하고 있을수도 있고, 다른 쓰레드에서 돌아가고 있을 수도 있다. 그것은 개발자가 자유롭게 선택할 수 있다.
  4. 그렇게 메인쓰레드가 다른 코드들을 실행하다가도, drawHead()가 제 역할을 다 끝내면 다시 아까 탈출했던 코루틴 drawPerson()으로 돌아온다. 아까 멈추어놓았던 drawHead() 아래인 drawBody()부터 재개(resume)된다.

[2] 동시성 프로그래밍 지원

  • 함수를 중간에 빠져나왔다가, 다른 함수에 진입하고, 다시 원점으로 돌아와 멈추었던 부분부터 다시 시작하는 이 특성은 동시성 프로그래밍을 가능하게 한다. 여기서 어떻게 함수를 중간에 왔다 갔다 할수 있는거지에 대한 내용은 CPS(Continuation Passing Style)를 참고하자.
    • 동시성 프로그래밍이란 오른쪽 손에만 펜을 쥐고서 왼쪽 도화지에 사람 일부를 조금 그리고, 오른쪽 도화지에 가서 잠시 또 사람을 그리고, 다시 왼쪽 도화지에 사람을 찔끔 그리고… 이 행위를 아주 빨리 반복하는 것이다. 사실 내가 쥔 펜은 한 순간에 하나의 도화지에만 닿는다. 그러나 이 행위를 멀리서 본다면 마치 동시에 그림이 그려지고 있는 것 처럼 보일 것이다. 이것이 동시성 프로그래밍이다.
    • 병렬성 프로그래밍은 이 것과 다르다. 병렬성은 실제로 양쪽 손에 펜을 하나씩 들고서 왼쪽과 오른쪽에 실제로 동시에 그리는 것이다. 같은 시간동안 두 개의 그림을 그리는 것이다.
  • 코루틴은 개념자체로만 보면 병렬성이아니라 동시성을 지원하는 개념이다
  • 코루틴을 생성해서 동시성 프로그래밍을 하는 것은, 쓰레드를 사용해서 동시성 프로그래밍을 하는 것과 차원이 다른 효율성을 제공한다.
  • context-switching 비용, 스레드 경량화의 이유로 -> 2,000개 미만의 스레드에는 1.5GB이상의 메모리가 필요한데 100만 개의 코루틴은 700MB 미만의 메모리가 필요하다. 결론은 코루틴은 매우 가볍다는 것이다.

예시

  • 코루틴도 루틴이다. 즉, 스레드가 아니라 일반 서브루틴과 비슷한 루틴이기 때문에 하나의 스레드에 여러개가 존재할 수 있다.
suspend fun drawPersonA() {
    startCoroutine {
        drawHead()
        drawBody()
        drawLegs()
    }
}

suspend fun drawPersonB() {
    startCoroutine {
        drawHead()
        drawBody()
        drawLegs()
    }
}

suspend fun drawHead() {
    delay(2000)
}

suspend fun drawBody() {
    delay(2000)
}

suspend fun drawLegs() {
    delay(2000)
}
  1. 메인 스레드에서 drawPersonA, drawPersonB 두개의 함수를 순서대로 호출한다고 해보자. (메인 스레드에 코루틴이 2개 있다.)
  2. 먼저 drawPersonA를 실행하면 startCoroutine {} 블럭으로 인해 코루틴이 되고, 함수를 중간에 나갔다가 다시 들어올 수 있는 힘을 얻게된다. 이후 suspend함수인 drawHead()를 만나게 되면 이 코루틴을 잠시 빠져나간다.
  3. drawPersonA 코루틴을 빠져나갔지만 그렇다고 메인 스레드가 가만히 놀고있진 않는다. 다른 suspend 함수들을 찾거나 resume되어진 다른 코드들을 찾는다. drawPersonA 코루틴의 경우 2초 동안 drawHead()작업을 하게된다. 그러나 delay(2000)는 쓰레드를 블락시키지 않으므로 다른 일들을 할 수가 있다. 뿐만 아니라 drawHead() 함수 안에서 다른 스레드를 실행시킨다면 병행적으로 실행이 가능하다. drawPersonB() 함수를 만나게 되어 또 한번 suspend 함수를 만나게 되면 같은 현상이 발생한다. 즉, drawPersonA, drawPersonB에 대한 코루틴이 아주 빠르게 왔다 갔다하면서 실행된다. 이렇게 코루틴을 이요하면 하나의 스레드에서 동시성 프로그래밍이 가능해진다.

[3] 비동기 처리를 쉽게 도와준다.


suspend goCompany(person: Person) {
   val wakeupPerson = wakeup(person)
   val takeShowerPerson = takeShower(wakeupPerson)
   val putOnShirtPerson = putOnShirt(takeShowerPerson)
   ...
  • 언제 끝날지 모르는 비동기 작업들이지만 각자 함수들의 순서는 정확히 지켜진다. takeShower()함수는 wakeUp()함수가 끝나야만 실행되고, putOnShirt()함수는 takeShower()함수가 끝나야만 실행된다.
  • 이게 가능한 이유는, goCompany라는 함수가 코루틴이기에 wakeUp을 만나면 wakeUp함수를 실행함과 동시에(여기서는 백그라운드 스레드에서 동시에 실행될 것이다.) 잠시 goCompay를 빠져나간다. 그러다가 wakeUp이 자신의 일을 끝마치면 다시 goCompany로 돌아올 수 있기 때문이다. 이게 코루틴으로 비동기 처리를 할 때 생기는 장점이다.
  • 만약 코루틴이 아닌 callback이라면 'callback hell'현상이 나오고, 만약 rxkotlin을 활용한다면 우리가 알고자하는 것 이외에 여러 기능을 추가적으로 학습해야 코드 파악이 될 것이다.
  • 추가로 비동기 처리의 궁극적인 모습은 마치 비동기 코드가 아닌것 처럼 짜는 모습이 아닐까 생각한다.

https://wooooooak.github.io/kotlin/2019/08/25/%EC%BD%94%ED%8B%80%EB%A6%B0-%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B0%9C%EB%85%90-%EC%9D%B5%ED%9E%88%EA%B8%B0/

3. Coroutine Sope

  • 코루틴은 코루틴 스코프 안에서만 동작한다. 특히 일시 중단 함수 즉 await(), join(), delay(), suspend 메소드는 코루틴 스코프 안에서만 호출이 가능하다. 위 api를 코루틴 스코프가 아닌 다른 영역에서 사용하면 컴파일 에러가 발생한다.

[1] GlobalScope

  • GlobalScope는 CoroutineScope의 한 종류로써 가장 큰 특징은 Application이 시작하고 종료될 때까지 계속 유지가 된다. Singletone 이기 때문에 따로 생성하지 않아도 되며 어디에서든 바로 접근이 가능하여 간단하게 사용하기 쉽다는 장점이 있다.
  • 단점으로는 GlobalScope를 사용하면 메모리 누수의 원인이 될 수 있기 때문에 신중히 사용해야 한다. 즉 다시 말해 앱이 실행된 이 후 계속 수행이 되어야 한다면 GlobalScope 를 사용해야 하는 것이고 특정 Activity나 Service 에서만 잠깐 사용하는 것이라면 GlobalScope를 사용하면 안된다.
  • 해당 스코프 내에 실행되는 것들은 비동기로 실행된다.
GlobalScope.launch {
    //run coroutine
}

[2] RunBlocking

  • runBlocking 에 포함된 코루틴 로직을 모두 수행 한 후 넘어간다. (blocking) 다른 말로 하면 runBlocking의 코루틴이 모두 실행될 때까지 쓰레드가 대기하기 때문이다.
  • 아래 예시에서는 runblocking이기 때문에 'step 1' -> 'step 2'가 출력될 것이다. 만약 RunBlocking 대신 GlobalScope.launch로 바꾸면 Step 2 만 출력이 된다. 왜나하면 GlobalScope.launch 는 메인쓰레드를 블록킹하지 않으며 코루틴과 main() 로직을 병렬적으로 실행이 된다. 다만 코루틴이 종료되기 전에 main() 이 먼저 종료되기 때문에 step1이 출력되지 않는다.
  • runBlocking() 같은 경우 코루틴의 장점인 병렬/비동기 처리에 대한 장점을 활용하지 못한다.
fun main() {
    runBlocking {
        delay(5000)
        println("step 1")
    }
    println("step 2")
}

[3] CoroutineScope

  • runblocking과 달리 비동기로 couroutine scope를 생성한다.
coroutineScope {
    //이런식으로 필요할 때 coroutine scope를 생성할 수 있다.
}

CoroutineScope(Dispatchers.IO).async {
    // Coroutine Scope Context를 지정할 수 있다.
}

CoroutineScope Context

  • Dispatchers.IO
    • 코루틴이 백그라운드 스레드에서 작동한다.
    • db, network, 파일 작업 등에서 사용된다.
  • Dispatchers.Main
    • 코루틴이 메인 스레드에서 실행된다.
    • suspending 함수, 수정사항을 가져오는 함수 등 가벼운 작업들만 실행시킨다.
  • Dispatchers.Default
    • 리스팅 정렬과 같이 CPU 부하가 많으 작업들을 할 때 사용한다.
  • Dispatchers.Unconfined
    • GlobalScope와 함께 사용되며, 코루틴이 현대 스레드에서 작동되다 중단되고 다시 시작하면 중단된 스레드에서 시작한다.

[4] Coroutine Builder

  • 하위 스코프를 만들지는 않지만 코루틴 스코프 내에서 비동기적으로 작업을 수행할 때 사용한다. 반환값을 이용해 완료될 때까지 대기가 가능하다.
  • launch : 비동기 실행 결과가 필요 없는 경우
    • 리턴 값 job, 대기는 join(), 다중 대기는 joinAll()
  • async : 비동기 실행 결과가 외부에서 필요한 경우
    • 리턴 값 Deffered(Future, Promise와 같은 개념이다)
    • 대기는 await(), 다중 대기는 awaitAll()
    • async 블럭을 만나자 마자 해당 코루틴을 비동기로 실행해버리는데, 원하는 시점에 실행하려면 start=LAZY 주고 나중에 start() 호출하면 된다.
  • 이런 coroutine builder로 작업을 감싸지 않고 그냥 사용하는 경우 해당 코루틴은 순차적으로 한 라인 씩 대기하면서 실행한다.

4. 사용 코드 예시

# Controller
fun getUser(Param param) = RunBlocking {
    val user = userService.getUser(param)
    return @runBlocking user
}
// RunBlocking으로 Coroutine Scope 생성


# Service
suspend fun getUser(Param param) = coroutineScope {
    
    val user1 = async { user1Repository.getUser(param)}
    val user2 = async { user2Repository.getUser(param)}

    return @coroutineScope getUser(user1.await(), user2.await())
}
// 비동기로 코루틴을 실행하기 위해 coroutine scope를 만든 후, 각각 aysnc를 통해 비동기로 메서드를 호출한다. 이후 .await 부분에서 다른 코루킨을 실행할 것이 있으면 그것부터 실행한다.

# Repository

suspend fun getUser(Param param): User {
    return userApi.get()
            .uri {...}
            ...
            .awaitSingleOrNull()
}
// awaitSingleOrNull에서 대기하여 다른 코루틴으로 넘어간다.

/**
1. controlller로 요청이 오고, RunBlocking으로 scope를 만든 후, service로 넘어간다.
2. user1부터 차례대로 실행되며, Repository의 'awaitSingleOrNull'을 마주치면 다른 코루틴으로 넘어가야된다. 그럼 user2 부분이 실행되고 user1과 동일하게 'awaitSingleOrNull'에서 멈춘다.
3. user1 Repository가 완료되었으면 이제 다음 코드를 실행하는데, user2.await이 있어 다른 코루틴 실행할 것부터 처리한다.
4. user2 Repository가 완료되었으면 이제 결과물을 반환한다.
**/
profile
성장하는 개발자가 되고싶어요

0개의 댓글