회사 면접을 보면서, 다소 헷갈렸던 부분이 있었어서 글을 정리하게 되었다.
지금 생각해보면 이걸 왜 헷갈렸나 싶지만 나중에 봤을때도 헷갈릴 수 있을 것이라 생각되어 정리해보려고 한다.
Coroutine 은 Thread 위에서 돌아가는 경량화된 실행 단위이자, 쓰레드가 직접 점유하지 않고 재개 및 중단을 가능하게 하는 비동기 실행 모델
즉, Coroutine 은 Thread 위에서 돌아가므로 Thread 에 종속된다고 생각하면 된다.
이러한 코루틴은 Dispatchers 를 통해 어느 스레드 및 스레드풀에서 처리할 지 전략을 설정할 수 있다. 또한 코루틴은, 상태 머신 (State Machine) 이라는 것에 의해 특정 지점에서의 중단 및 재개가 가능해지는데 그렇다면 메인 쓰레드 내에서 Thread.sleep 과 delay 를 사용하게 되면 어떻게 될까?
이번에 확인하려는 것은 Thread.sleep + delay + cancelAndJoin() 를 사용했을 때의 결과를 확인해보려고 한다.
이를 테스트하기 위해 다음 코드를 기본적으로 사용하고, 코드를 변경할 부분은 for 문 내부이다.
runBlocking {
val job = launch {
for (i in 0 until 7) {
...
}
}
delay(1300L)
Log.d("테스트 ", "Cancel")
job.cancelAndJoin()
}
여기서 runBlocking 의 Contex 를 지정해주지 않았기에 현재 스레드로 진행하므로 Main Thread 에서 진행되며, Start 는 DEFAULT 이기에 즉시 스케줄링 및 실행된다.
Job.cancelAndJoin() 의 경우 job 을 취소하고 완료될 때까지 대기하는 함수이다.
실제 UI 환경 Main Thread 에서 runBlocking 을 호출해서 테스트해보자.
CoroutineContext 와 CoroutineStart 는 미지정한다.
runBlocking {
val job = launch {
for (i in 0 until 7) {
Log.d("테스트 ", "실행 - $i")
delay(500L)
}
}
delay(1300L)
Log.d("테스트 ", "Cancel")
job.cancelAndJoin()
}
위의 코드는 Dispatchers.Main 을 중심으로 코루틴을 실행시킨다.
이 코드를 실행시킨다면 어떤 결과가 나올까?

작업은 위와 같이 나오며 실행 순서는 다음과 같다.
- launch { ... } 를 통해
job을 등록한다.- 다음 줄로 진행되어
delay(1300)에서 재개 예약을 남기고 일시중단한다.- 스케줄링된
job을 실행하고 println 후delay(500)에서 반복적으로일시중단(재개 예약)한다.- 스케줄러의 시간이 1300ms 만큼 진행되면 부모가 재개되어 "Cancel"을 출력하고
job.cancelAndJoin()을 호출한다.- 자식 job은 취소 상태가 되고, 다음 취소 체크 지점(delay) 에서
CancellationException를 throw 하면서취소 완료된다. 현재는 1.0초와 1.5초 사이의 일시중단 상태이기에 delay 가 취소되면서 즉시 Cancel 한다.cancelAndJoin()은 자식 job이 완료(취소 완료 포함) 될 때까지 기다린 뒤 리턴한다.
ensureActive() 는 현재 코루틴 스코프의 isActive 상태를 확인하고, isActive 가 아니라면 CancellationException 을 throw 하는 함수이다.
이를 활용해서 코드를 변경해보자.
runBlocking {
val job = launch {
for (i in 0 until 7) {
ensureActive()
Log.d("테스트 ", "실행 - $i")
Thread.sleep(500L)
}
}
delay(1300L)
Log.d("테스트 ", "Cancel")
job.cancelAndJoin()
}
자, 코드를 변경해보았다.
Thread.sleep 은 suspendCancellableCoroutine 이 아니고, ensureActive() 은 현재 Coroutine 의 isActive 에 따라 CancellationException 을 Throw 한다.
이제 결과를 확인해보기에 앞서 어떤 결과가 나올지 상상해보자.
.
.
.
결과는 다음과 같다.

작업 실행 순서는 다음과 같다.
- launch { ... } 를 통해
job을 등록한다.- 다음 줄로 진행되어
delay(1300)에서 재개 예약을 남기고 일시중단한다.- 스케줄링된
job을 실행하고 println 후Thread.sleep(500)에서 반복적으로 스레드를 차단한다.delay(1300)은 1.3초가 지나면 재개 작업을 메인 큐에 올리지만, 메인 스레드가 약 3.5초 동안 Thread.sleep으로 블로킹되어 큐를 처리하지 못해 재개가 지연된다.Thread.sleep이 모두 끝나고 나서 delay 작업을 Thread 가 꺼내, 작업이 재개되면서 Cancel 을 프린트한다.job.cancelAndJoin()을 실행하지만, 이미 Job 은 끝났기에 작업이 리턴된다.
-> 이 과정에서 ensureActive() 가 확인하는 당시에는 isActive 가 true 이므로 취소 완료되지 않는다.
runBlocking {
val job = launch {
for (i in 0 until 7) {
Log.d("테스트 ", "실행 - $i")
Thread.sleep(500L)
}
}
delay(1300L)
Log.d("테스트 ", "Cancel")
job.cancelAndJoin()
}

이것 또한 2번과 같이 표시되며 실행과정도 비슷하다.
이제 Dispachers 미지정 -> IO 로 변경해보자
runBlocking(Dispatchers.IO) {
val job = launch {
for (i in 0 until 7) {
Log.d("테스트 ", "실행 - $i")
delay(500L)
}
}
delay(1300L)
Log.d("테스트 ", "Cancel")
job.cancelAndJoin()
}

실행 순서는 기존 Dispatcher.Main 과 비슷하게 출력되었지만 Dispatchers.IO 는 스레드풀 기반이기에 부모/자식이 서로 다른 스레드에서 실행될 수 있으니 유의하자.
runBlocking(Dispatchers.IO) {
val job = launch {
for (i in 0 until 7) {
ensureActive()
Log.d("테스트 ", "실행 - $i")
Thread.sleep(500L)
}
}
delay(1300L)
Log.d("테스트 ", "Cancel")
job.cancelAndJoin()
}

이번에는 Main Thread 에서 진행했을때와 결과가 다르게 나온다.
그 이유는 launch 내부에서 차단하는 Thread 와 runBlocking 의 Thread 가 서로 다르기 때문이다.
테스트를 위해 스레드를 확인해보면, 실제로 서로 다른 스레드를 사용하는 것을 확인할 수 있다.

실행 순서
- launch 를 Scheduling (이 때, launch 는
Dispatchers는 미지정이므로 부모 Dispatcher 를 따라간다.)- 다음 줄로 넘어가
delay(1300L)실행 및 일시중단 (병렬이므로 2, 3 순서 변경 가능성 존재)- launch 가 실행되면서 print 및 Thread 를 500ms 만큼 차단을 반복
- 1.3초가 지났을때
Coroutine Scheduler에서 재개 작업을 꺼내어 실행 (가능한 이유는 launch 와 별도의 스레드이기 때문)job.cancelAndJoin()을 호출, 현재 Job 에서의 sleep 이 끝나는 경우 다음 순회에서ensureActive()로 취소 완료된다.
runBlocking(Dispatchers.IO) {
val job = launch {
for (i in 0 until 7) {
Log.d("테스트 ", "실행 - $i")
Thread.sleep(500L)
}
}
delay(1300L)
Log.d("테스트 ", "Cancel")
job.cancelAndJoin()
}

실행 순서는 다음과 같다.
- launch 를 Scheduling (이 때, launch 는
Dispatchers는 미지정이므로 부모 Dispatcher 를 따라간다.)- 다음 줄로 넘어가
delay(1300L)실행 및 일시중단 (병렬이므로 2, 3 순서 변경 가능성 존재)- launch 가 실행되면서 print 및 Thread 를 500ms 만큼 차단을 반복
- 1.3초가 지났을때
Coroutine Scheduler에서 재개 작업을 꺼내어 실행 (가능한 이유는 launch 와 별도의 스레드이기 때문)job.cancelAndJoin()을 호출하지만suspendCancellationCoroutine이 내부에 없으므로 취소 가능한 상태가 아니기 때문에 끝까지 진행하고 완료 처리
이 경우는 Dispatchers.IO 와 다른 상황이 발생하지 않지만, Dispatchers.Default 의 경우 CPU 코어 개수와 같은 수의 스레드를 가진 스레드풀을 보유하기에, 코루틴을 그보다 많이 띄우는 경우 병목/지연 현상이 발생할 수 있다.
Dispatcher 별로 어느 스레드에서 돌아가고, 어느 상황에서 사용하는 것인지는 알고 있었으나 Thread.sleep 를 함께하는 케이스는 생각해본 적이 없던터라 좀 당황했었다.
이제라도 확실하게 알게되었으니 이를 계기로 다음에는 관련 문제를 틀리지 않기를 ...!!