Skip to main content

3 posts tagged with "Coroutine"

View All Tags

Coroutine Dispatcher 분리

· 5 min read
Haekyu Cho
Software Engineer

ThreadPool을 분리해야 한다. 왜? 기존에는 Dispatchers.IO를 활용해 IO 작업을 처리하고 있었으나, 아래와 같은 문제가 발생하면서 ThreadPool을 용도별로 커스텀하여 분리하는 방향으로 개선

Coroutine

· 11 min read
Haekyu Cho
Software Engineer
  • Co + Routine
    • Co : 협력
    • Routine : 함수
  • 즉 서로 협력하는 함수
    • 협력한다 라는 의미는 함수의 블록이 끝나지 않아도 중간에 빠져나와 다른 함수들과 협력할수있다 라는 의미 (아래 예시에서 설명)
  • 비선점 멀티테스킹
  • 경량 스레드
    • 같은 스레드에서 실행되는 코루틴들은 협력적 멀티태스킹을 사용하여 실행을 양보(suspend), 재개(resume) 하는 매커니즘. OS가 개입하지 않고 코루틴 런타임이 직접 관리 하기 때문에 context switching 오버헤드가 없음
  • 일반적인 함수는 종료될때까지 함수 블럭을 못벗어나지만 코루틴은 일시중단하고 블럭을 벗어나 다른 작업을 비동기적으로 수행

caller-coroutine

⇒ 같은 scope내에서 비동기적인 실행 (= caller) 코드로 예시를 들면

@Test
fun `코루틴`() {
runBlocking {
launch {
delay(1000L)
println("World!")
}
println("Hello,")
}
}
//결과
Hello, World!

co1


코루틴은 중간에 빠져나올수있는 협력 함수이다.​

  • runBlocking : 코루틴 Scope은 만들고 내부의 코드가 다 동작할때까지 해당 thread를 blocking
  • runBlocking 으로 감싸여진 launch{} 와 println("Hello, ") 는 같은 scope에 있다.
  • launch로 생성된 코루틴은 비동기 실행이 가능
    • launch가 실행되고 내부에서delay(1000L)가 걸리면 코루틴이므로 해당 블록을 빠져나와 println("Hello, ")를 실행한다.
    • 이후 1000ms 가 지나면 println("World!") 를 실행한다.

-> 코루틴인 launch{} 는 블록이 실행 중에도 같은 스코프 내에서 다른것들과 협력한다!

코루틴의 구성 요소​

코루틴은 기본적으로 세가지로 구성되어있다.

  • coroutineScope
  • coroutineContext
  • Builder

CoroutineScope​

  • CoroutineScope, GlobalScope , ..
  • 말 그대로 코루틴의 범위이다.
  • 같은 scope 내의 코루틴들이 비동기적으로 동작한다.
  • 코루틴은 코루틴 스코프안에서만 동작한다.

CoroutineContext​

  • Dispatcher.IO, Dispatcher.Main, Dispatcher.Default, ...
  • 코루틴을 실행하는 특정 환경
  • 주요 요소는 job과 Dispatcher
    • job : 코루틴을 제어하기위한것 (코루틴의 시작, 종료, 합치기 등등)
    • Dispatcher : 코루틴과 스레드의 연관을 제어
  • Dispatcher를 코루틴이 동작하는 Thread 나 Thread Pool 이라 이해했다.
    • ( Dispatcher 에 대한 자세한 설명은 아래에서 할 예정 )

CoroutineBuilder​

  • async, launch, runBlocking
  • 코루틴 빌더는 스코프에서 실행할 코루틴을 생성하는 역할이다.
    • CoroutineScope에서 코루틴이 생성되어야만 비동기적인 동작을 한다.
    • scope를 생성했어도 builder를 통해 코루틴을 생성하지않았다면 비동기적인 동작은 하지않는다.

(1) launch​

  • 결과 반환이 없는 단순 작업, Job 리턴

(2) Async​

  • 결과 반환이 필요한 작업, Deferred<T> 반환
    • Deferred는 미래에 올수있는 값을 담아놓는 객체
    • Deferred 의 await() 메서드가 수행되면 코루틴의 결과가 반환되기까지 기다림
    • 이것을 코루틴이 일시중단 되었다고 함

(3) runBlocking​

  • 코루틴 빌더 이지만 앞의 두개와는 조금 다름
  • 새로운 코루틴을 생성하고, 생성된 코루틴이 완료될때까지 현재 스레드를 차단한다.
  • 메인 함수나 테스트 함수에서 사용할것을 권장한다. (일반 함수에서는 suspend를 호출하기위한 용도)
  • 중요한 점은 runBlocking은 현재의 스레드를 점유한다라는것이다.

co2

  • 세 구성요소의 포함관계는 그림과 같다.
  • 위의 이미지 처럼 builder를 사용할때는 scope와 context를 명시해야한다.
    • CoroutineScope(Dispatchers.IO).launch { }  이런식으로 사용
    • 하지만 이미 생성된 scope 내에서는 launch { } 이렇게 더 많이 사용한다.
      • 명시를 하지않으면 현재의 context와 scope를 이어받기때문

세가지 기본 구성에 추가로 dispatcher와 suspend fun, withContext에 대해 설명

Dispatcher​

  • coroutineContext의 일부
  • 코루틴 실행시 사용할스레드를 제어하는 역할

종류는 기본적으로 네가지가 있다.

#### Dispatcher.Default

  • IO dispatcher와 스레드 풀을 공유한다.
    • 백그라운드 스레드에서 작동
  • 크기는 기본값으로 CPU 코어 수 만큼 스레드를 생성한다. (최소 2개)
  • CPU 작업을 필요로하는 무거운 작업에 적합
    • 그 이유는 코어수 만큼 스레드를 생성하기때문에 CPU를 많이 점유하는 작업에서 최대 효율을 낸다
  • 내 컴퓨터 코어수 확인
sysctl -n hw.ncpu
# 결과
8
  • 아래의 코드로 default 스레드풀의 스레드를 출력하면 코어개수와 동일한것을 볼수있다.
repeat(50) {
CoroutineScope(Dispatchers.Default).launch {         
println(Thread.currentThread().name)
 }
}```

#### `Dispatcher.IO`

- **Default Dispatcher와 스레드풀을 공유**
- 필요에 따라 스레드를 더 생성하거나 줄일수 있다. (최대 64개)
- 이름에 맞게 주로 **DB, 네트워크, 파일 관련 작업에 적합**
- 위의 방법과 동일하게 thread를 출력해보면 **core의 개수보다 많은것**을 확인할수있다.
- 그 이유는 대기시간이 긴 네트워크 작업의 경우 **더 많은 스레드를 이용해 병렬처리**하는것이 더 효율적이기 때문이다.

#### 프로젝트에 적용시에는 기본적으로 제공하는 Dispatcher를 사용하기보다 각 기능에 맞는 Dispatcher를 생성해 사용하는것이 좋다.

예시)

![co3](/img/wiki/co3.png

#### suspend fun

- 일시 중단이 가능한 함수
- 원하는 지점에서 중단후, 나중에 다시 실행을 진행할 수 있다.
- suspend 함수는 무조건 중단 되는게 아닌 **중단점을 만나야 중단**이 된다. (ex. delay(), WebClient 통신시..)
- suspend fun 만으로는 비동기 처리 X
- suspend fun은 코루틴 내부에 있어야하고, 그 코루틴은 해당 스코프에서 비동기 처리를함
- 비동기에서 중요한점은 **해당 스코프에서 코루틴을 생성**해야한다는 것이다.
- coroutine의 suspend 함수는 thread를 block 하지않는다.

<br/>
헷갈렸던 부분을 예시로 들어보면
<br/>
``` kotlin
@Test
fun `동기`() {
runBlocking {
val time = measureTimeMillis {
val one = first()
println("between")
val two = second()
println("the answer is ${one + two}")
}
println("Completed in $time ms")
}
}
suspend fun first(): Int {
println("first")
delay(1000L)
println("first end")
return 13
}
suspend fun second(): Int {
println("second")
delay(1000L)
println("second end")
return 29
}

나의 착각

  • 처음에는 위 예시를 실행하면 first()와 second()가 비동기 처리되어 결과가 1초가 나올줄알았다.
    • 그렇게 생각한 이유는 당시에는 코루틴(runBlocking)에서 suspend fun 을 호출하므로 비동기적으로 실행될것으로 착각했다.
    • 하지만 해당 코드에서 코루틴은 전체를 감싸는 runBlocking 밖에 없다.
      • 비동기적으로 실행 되려면 같은 스코프에 명령어들과 코루틴이 있어야한다.
      • 그러므로 delay 만큼 기다리고 아래의 코드를 순차적으로 진행하게 된다.
  • 위의 코드는 그냥 순차적으로 출력된다.

실제 결과​

first
first end
between
second
second end
the answer is 42
Completed in 2021 ms //비동기 처리 안됨

first() 나 second()를 비동기 방식인 WebClient를 사용하는 suspend fun 이라고 가정한다면 ​

**코루틴을 생성하지않고 위와같은 방식으로 사용시 비동기 동작이 안된다는것을 알수있다. **

비동기 코드로 변경​

  • 위에서 설명했다시피 비동기를 이용하기위해서는 같은 스코프에서 코루틴을 생성 했을 때 비동기 실행을 한다.
    • 그러기 위해서는 first() 와 second()를 각각 async로 묶는다.
  • 아래 코드를 보면 runBlocking 이 만든 스코프에 async { first() }, println("between"), async { second() } 가 있고
    • aync 로 만들어진 코루틴은 협력하는 함수 이므로 특정 시점(delay)에 일시정지를 하고 코루틴을 빠져나와 같은 스코프의 다른 함수들과 협력할수있다.
@Test
fun `비동기`() {
runBlocking {
val time = System.currentTimeMillis()
val one = async { first() }
println("between")
val two = async { second() }
println("the answer is ${one.await() + two.await()}")

println("Completed in ${System.currentTimeMillis() - time} ms")
}
}

suspend fun first(): Int {
println("first")
delay(1000L)
println("first end")
return 13
}
suspend fun second(): Int {
println("second")
delay(1000L)
println("second end")
return 29
}
between
first
second
first end
second end
the answer is 42
Completed in 1018 ms // 비동기 처리

그림으로 나타내보면 아래와 같다. co4

  • 출력 결과는 그림의 파란색 글씨 순서대로 출력된다.
  • 마름모는 코루틴이 생성되었다는것을 의미
  • 코틀린은 총 runBlocking, async, async 총 세개가 생성됨
  • first()와 second() 는 각각 async로 만든 코루틴 내부에 생기기 때문에 비동기 처리가 가능하다.
    • println("first") 보다 println("between") 이 먼저 출력된것은 async를 call 하기 때문이다.
  • 비동기 처리로 인해 소요시간은 1000ms를 조금 넘는다.
  • 비동기 세상에서는 가장 긴 실행시간이 전체 수행 시간이다!

6) withContext​

  • 코루틴 빌더가 아니기때문에 코루틴을 생성하지않음
  • suspend fun 이기때문에 코루틴, suspend fun 내부에서 사용되어야함
  • withContext는 블록의 마지막 줄 값을 반환한다.
  • withContext의 블록이 끝나기전까지 해당 코루틴은 일시정지
    • 순차적으로 코드가 실행되는것을 구현할 수 있다.
  • 코루틴의 실행환경을 다른 컨텍스트로 전환하는 역할
    • 코루틴이 실행되는 스레드를 변경한다 라고 이해하면된다.
    • coroutineScope와의 차이점은 withContext는 컨텍스트를 전달할수있다.
      • coroutineScope ≡ withContext(this.coroutineContext)

사용 사례​

주문 도메인에서 사용하고 있는transactionTemplate.executeWithContext {}를 살펴보자 이것은 withContext이므로 코루틴 빌더가 아니다. (처음엔 이것도 코루틴 빌더라 착각했다.) 트랜잭션을 만들고 그 동작을 설정한 context에서 한다 라는 의미이다.

실제로 구현된 코드를 보면 co5

  • withContext 선언 후 execute 하는 것을 볼수있다.
  • context는 boundedElasticDispatcher를 사용
    • boundedElasticDispatcher는 Shedulers의 boundedElastic을 코루틴에서 사용하기위해 변환한 디스패처이다.
    • co6
  • boundedElastic()
    • 블로킹 IO 태스크와 같은 생명주기가 긴 태스크에 적합하다.
    • 제한된 수의 worker를 동적으로 생성하여 worker를 재사용하는 스케줄러이다.

예시​

첫 학습 시 코루틴에 대해 확실히 이해하기 어려웠는데 아래 자료를 많이 참고함 https://silica.io/understanding-kotlin-coroutines/5/

싱글 스레드에서의 비동기 실행​

코루틴을 이해하기 위해 먼저 멀티 스레드가 아닌 단일 스레드에서 진행하면서 코루틴을 이해하고 이후 예시에서 멀티 스레드를 다루겠다.

(현재 스레드 의 출력하는 부분은 코드상 생략) co7

  • 결과값을 보면 아래와 같은 순서로 출력
[Test worker]                Starting a coroutine block...    // [Thread 종류 @코루틴번호] 출력,
[Test worker @coroutine#1] Coroutine block started          // 현재는 싱글 스레드에서 실행하므로 test worker에서만 실행
[Test worker @coroutine#1] Two coroutines have been launched
[Test worker @coroutine#2]  1/ First coroutine start
[Test worker @coroutine#3]  2/ Second coroutine start
[Test worker @coroutine#3]  2/ Second coroutine end
[Test worker @coroutine#2]  1/ First coroutine end
[Test worker]               Back from the coroutine block

'Two coroutine have been launched' 가 1/  First 와 2/ Second 보다 왜 먼저 출력될까?


sshot

  • 이전 코루틴 개념에서 설명했던것과 동일
  • launch로 코루틴을 생성하고 바로 내부를 들어가는게 아닌 launch를 call을 한다.
    • 그렇기때문에 가장 마지막에있던 println("Two coroutines have been launched") 가 먼저 실행된것
  • 코루틴의 동시성은 같은 스코프끼리 비동기적으로 동작한다. (너무 많이 반복하는것같지만 가장 중요하다 생각)

비선점 멀티테스킹​

  • 코루틴의 동시성은 비선점 방식이다.
    • 선점 :  기존의 Task가 실행중에 있어도 스케줄러가 강제 중지하고 다른 Task를 실행할수있음
    • 비선점 : 기존의 Task가 종료될때까지 계속 실행을 보장한다.
  • 하나의 작업이 스레드를 Blocking 하면 비선점 방식이므로 제어권을 가져올수없음
  • 코루틴은 동시성은 제공하지만 병렬성은 제공하지않는다.
    • 코루틴은 하나의 스레드에서 동작하기때문이다.

(추가) 동시성 vs 병렬성​

co8

  • 동시성 : 싱글 코어에서 멀티 스레드를 번갈아가며 동작하는 방식, 동시에 실행되는것 같이 보인다.
    • 동시성을 지원하는 경우, 하나의 코어에서 여러 스레드를 변경해가며 실행하므로  context switching 발생
  • 병렬성 : 멀티 코어에서 멀티스레드를 동작하는 방식, 실제로 동시에 실행이됨

코드 예시     co9 결과

First coroutine start, suspend for 50ms
Second coroutine start, suspend for 100ms
First coroutine : starting some computation for 100ms
Computation ended
Second coroutine end after 160ms // 100ms 가 아닌 160ms 가 소요됨

co11

  • 코루틴이 비선점형 멀티태스킹이라는것을 보여주는 예시
  • 첫번째 코루틴에서 Thread.sleep(100) 으로 스레드를 독점함 (delay와 달리 thread가 blocking된다.)
    • 원래 두번째 코루틴은 100ms 만 delay 해야하지만 스레드 자체가 blocking 되어 실제로 150ms 소요
  • 즉 코루틴에 Blocking 작업은 비효율적이다.
  • 단일 스레드에서 코루틴은 Webclient 같은 non-Blocking 작업에 효과적이지만,  Blocking 방식의 RDBMS 작업은 효과적이지 않다 라는 결론.

"그렇다면 우리는 Blocking 방식으로 동작하는 RDBMS(JPA)나 FeignClient 등을 사용할때는 코루틴 사용을 하면 안될까?"​

멀티 스레딩 방식의 코루틴을 사용 해야한다.

멀티 스레딩​

  • 코루틴의 동시성과 멀티 스레딩의 개념이 섞여서 어려웠다.
  • 싱글 스레드 에서의 코루틴 동시성과 헷갈리면 안된다.
  • 코루틴에서는 다른 스레드로 이동하는것이 굉장히 쉬움
  • 위의 개념설명에서 보았던 Dispatcher를 이용하면 스레드를 변경할수있다.

코드 예시 co12

  • Dispatchers.Default :  Default worker 스레드풀을 사용한다.
    • 이전에는 Test 코드의 메인 스레드인 Test worker만을 사용해 단일 스레드로 비동기 처리를 했음
================== 첫번째 ==================
[Test worker]                                 #0 Starting a coroutine block
[DefaultDispatcher-worker-1 @coroutine#1]     #0 Coroutine block started
[DefaultDispatcher-worker-2 @coroutine#2]     #0 First coroutine started
[DefaultDispatcher-worker-3 @coroutine#3]     #0 Second coroutine started
[DefaultDispatcher-worker-3 @coroutine#3]     #0 Second coroutine end
[DefaultDispatcher-worker-2 @coroutine#2]     #0 First coroutine end
[Test worker]                                 #0 Back from the coroutine block in thread [Test worker] after 133ms // 150ms 보다 작음
================== 두번째 ==================
[Test worker]                                 #1 Starting a coroutine block
[DefaultDispatcher-worker-2 @coroutine#4]     #1 Coroutine block started
[DefaultDispatcher-worker-2 @coroutine#6] #1 Second coroutine started
[DefaultDispatcher-worker-1 @coroutine#5]     #1 First coroutine started
[DefaultDispatcher-worker-2 @coroutine#6]     #1 Second coroutine end
[DefaultDispatcher-worker-1 @coroutine#5]     #1 First coroutine end
[Test worker]                                 #1 Back from the coroutine block in thread [Test worker] after 106ms // 150ms 보다 작음

co13

  • Thread.sleep을 총 100 + 50 = 150ms 를 했지만 실제 결과들은 그것보다 짧음
    • 그 이유는 DefaultDispatcher를 이용해 다른 스레드들(worker-2, worker-3)에서 각각 100ms, 50ms 작업을 했기때문이다.
  • 같은 작업을 했지만 첫번째(133ms) 보다 두번째(106ms) 가 더 빠른 이유는 첫 실행시 defaultDispatcher-worker 스레드 풀을 생성 비용 때문
  • 주의 해야할점은 여러 스레드에서 동작을 시키므로 동시성 문제를 신경써야한다.
    • 코루틴은 동시성문제에 대해서는 보장하지않음

컨텍스트 스위칭​

  • 멀티스레드의 동시성 문제 : 여러 스레드가 동시에 동일한 리소스에 접근할때 생기는 문제
    • 해결법은 모든 리소스를 단일 스레드에서만 접근할 수 있게한다.

코드 예제 co14 결과

[Test worker]                               Starting a coroutine block
[DefaultDispatcher-worker-1 @coroutine#1]  Coroutine block

[DefaultDispatcher-worker-2 @coroutine#2]   First coroutine started
[DefaultDispatcher-worker-3 @coroutine#3]   Second coroutine started in thread
[DefaultDispatcher-worker-3 @coroutine#3]   Second coroutine end
[AccessThread @coroutine#4]                 Second coroutine reporting result=13 in Access Thread
[DefaultDispatcher-worker-2 @coroutine#2]   First coroutine end
[AccessThread @coroutine#5]                First coroutine reporting result=29 in Access Thread

[Test worker]                               Back from the coroutine block

exam5

  • accessContext 라는 하나의 스레드에서 리소스에 접근한다.
  • 새로운 스레드를 생성하려면 newSingleThreadContext() 만 사용하면된다.
  • 만약 "First coroutine end" 보다 "First coroutine reporting result = ~ " 를 더 빨리 출력하기 원하면 withContext를 사용하면 된다.
    • withContext는 순차적인 실행을 보장한다.
    • launch(accessContext) => withContext(accessContext)
    • co15

미묘한 차이점​

runBlocking과 coroutinScope의 차이​

  • https://www.baeldung.com/kotlin/coroutines-runblocking-coroutinescope
  • 요약
    • coroutineScope
      • 스레드를 블로킹하지 않음
      • suspend 함수 내에서만 호출 가능
      • 코루틴 범위 밖에서 호출 불가능
      • launch로 job을 반환 받아 코루틴 취소가 가능
    • runBlocking
      • 일시중단 불가능
      • 현재 스레드 차단
      • 메인메소드, 테스트 코드 와같이 기존 코루틴 범위 외부에서 코루틴 시작시 사용
      • launch로 job을 반환 받아도 코루틴 취소가 안된다.

CoroutineScope와 coroutinScope 차이​

Coroutine Diagram​

직접 그려본 다이어그램 diagram

마무리​

  • DB 통신이던 network 통신이던 한 scope에서 여러번 호출이 필요하다면 코루틴을 통한 비동기 통신은 효과적
  • JPA는 Blocking 방식이므로 dispatcher를 이용해 멀티스레딩 방식으로 사용
    • 단 멀티스레딩에대한 동시성 문제는 신경써야함