코틀린 코루틴 (9장 정리 - 취소)

윤성현·2024년 12월 19일

코틀린 코루틴

목록 보기
9/11
post-thumbnail

9장. 취소

코루틴을 어떻게 취소할 수 있는지 살펴보자!

기본적인 취소 ☑️

  • Job 인터페이스는 cancel 메서드를 제공
  • 코루틴 취소 시:
    • 첫 번째 중단점(suspension point)에서 잡을 종료
    • 자식 코루틴 모두 취소, 하지만 부모에는 영향 없음
    • 취소된 잡은 새로운 코루틴의 부모로 사용 불가
suspend fun main(): Unit = coroutineScope {
	val job = launch {
		repeat(1000) { i ->
			delay(200)
			println("Printing $i")
		}
	}
	
	delay(1100)
	job.cancel()
	job.join()
	println("Cancelled successfully")
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully
  • cancel 함수에 각기 다른 예외를 인자로 넣으면 취소된 원인을 명확히 할 수 있음
  • cancel이 호출된 후 취소 과정이 완료되는 것을 기다리기 위해 join을 사용
  • join을 호출하지 않으면 경쟁 상태(race condition)이 될 수 있음
// join을 호출하지 않는 경우
suspend fun main(): Unit = coroutineScope {
	val job = launch {
		repeat(1000) { i ->
			delay(100)
			Thread.sleep(100) // 오래 걸리는 연산이라 가정
			println("Printing $i")
		}
	}
	
	delay(1000)
	job.cancel()
	println("Cancelled successfully")
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Cancelled successfully
// Printing 4
// join을 호출하는 경우
suspend fun main(): Unit = coroutineScope {
	val job = launch {
		repeat(1000) { i ->
			delay(100)
			Thread.sleep(100) // 오래 걸리는 연산이라 가정
			println("Printing $i")
		}
	}
	
	delay(1000)
	job.cancel()
	job.join()
	println("Cancelled successfully")
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully
  • canceljoin을 함께 호출할 수 있는 cancelAndJoin 확장함수를 사용할 수 있음
  • Job() 팩토리 함수로 생성된 잡도 같은 방법으로 취소될 수 있으며, 잡에 딸린 많은 코루틴을 한번에 취소할 때 사용할 수 있음
suspend fun main(): Unit = coroutineScope {
	val job = Job()
	launch(job) {
		repeat(1000) { i ->
			delay(100)
			println("Printing $i")
		}
	}
	delay(1100)
	job.cancelAndJoin()
	println("Cancelled successfully")
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully
  • 작업 그룹 전체를 취소시킬 때 유용
    • (안드로이드) 사용자가 뷰 창을 나갔을 때 뷰에서 시작된 모든 코루틴을 취소하는 경우
class ProfileVievModel : ViewModel() {
	private val scope =
		CoroutineScope(Dispatchers.Main + SupervisorJob())
	
	fun onCreate() {
		scope.launch { loadUserData() }
	}

	override fun onCleared() { 
		scope.coroutineContext.cancelChildren()
	}
	// ...
}

취소는 어떻게 작동하는가? 🤔

  • 잡 취소 시 상태가 ‘Cancelling’으로 바뀌고 첫 중단점에서 CancellationException 발생
suspend fun main(): Unit = coroutineScope {
	val job = Job()
	launch(job) {
		try {
			repeat(1000) { i ->
				delay(100)
				println("Printing $i")
			}
		} catch (e: CancellationException) {
			println(e)
			throw e
		}
	}
	delay(1100)
	job.cancelAndJoin()
	println("Cancelled successfully")
	delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// JobCancellationException ...
// Cancelled successfully
  • 코루틴 내부는 예외를 통해 취소 처리
  • 자원 정리나 후처리는 finally 블록 활용
suspend fun main(): Unit = coroutineScope {
	val job = Job()
	launch(job) {
		try {
			delay(Random.nextLong(2000))
			println("Done")
		} finally {
			println("Will always be printed")
		}
	}
	delay(1000)
	job.cancelAndJoin()
}
// Will always be printed
// (또는)
// Done
// Will always be printed

취소 중 코루틴을 한 번 더 호출하기 💼

  • 코루틴은 모든 자원을 정리할 필요가 있는 한 계속해서 실행될 수 있음
  • 하지만 정리 과정 중에 중단을 허용하지 않음
  • Job은 이미 ‘Cancelling’ 상태가 되었기 때문에 중단되거나 다른 코루틴을 시작하는 건 절대 불가능
    (다른 코루틴을 시작하려고 하면 그냥 무시)
  • 중단하려고 하면 CancellationException을 던짐
suspend fun main(): Unit = coroutineScope {
	val job = Job()
	launch(job) {
		try {
			delay(2000)
			println("Job is done")
		} finally {
			println("Finally")
			launch { // 무시됨
				println("Will not be printed")
			}
			delay(1000) // 예외 발생 지점
			println("Will not be printed")
		}
	}
	delay(1000)
	job.cancelAndJoin()
	println("Cancel done")
}
// (1초 후)
// Finally
// Cancel done
  • 코루틴이 이미 취소되었을 때 중단 함수를 반드시 호출해야 하는 경우가 발생
  • 예) 데이터베이스의 변경 사항을 롤백하는 경우 등
  • 이런 경우 함수 콜을 withContext(NonCancellable)로 포장하여 사용
  • 코드 블록의 컨텍스트를 바꾸고, 취소될 수 없는 JobNonCancellable 객체를 사용
  • 따라서, 블록 내부에서 잡은 액티브 상태를 유지하고, 중단 함수를 호출할 수 있음
suspend fun main(): Unit = coroutineScope {
	val job = Job()
	launch(job) {
		try {
			delay(2000)
			println("Coroutine finished")
		} finally {
			println("Finally")
			withContext(NonCancellable) { 
				delay(1000L)
				println("Cleanup done")
			}
		}
	}
	delay(100)
	job.cancelAndJoin()
	println("Done")
}
// Finally
// Cleanup done
// Done

invokeOnCompletion 📣

  • 자원을 해제하는 또 다른 방법은 JobinvokeOnCompletion 메서드를 호출하는 것
  • invokeOnCompletion 메서드는 잡이 ‘Completed’나 ‘Cancelled’와 같은 마지막 상태에 도달했을 때 호출될 핸들러를 지정
suspend fun main(): Unit = coroutineScope {
	val job = launch {
		delay(1000)
	}
	job.invokeOnCompletion { exception: Throwable? ->
		println("Finished")
	}
	delay(400)
	job.cancelAndJoin()
}
// Finished

핸들러의 파라미터 중 하나인 예외의 종류

  1. 잡이 예외 없이 끝나면 null
  2. 코루틴이 취소되었으면 CancellationException
  3. 코루틴을 종료시킨 예외
  • 잡이 invokeOnCompletion이 호출되기 전에 완료되었으면 핸들러는 즉시 호출됨
  • onCancellinginvokeImmediately 파라미터를 사용하면 핸들러의 동작방식을 변경할 수 있음
suspend fun main(): Unit = coroutineScope {
	val job = launch {
		delay(Random.nextLong(2400))
		println("Finished")
	}
	delay(800)
	job.invokeOnCompletion { exception: Throwable? ->
		println("Will always be printed")
		println("The exception was: $exception")
	}
	delay(800)
	job.cancelAndJoin()
}
// Will always be printed
// The exception was:
// kotlinx.coroutines.JobCancellationException
// (또는)
// Finished
// Will always be printed
// The exception was null

중단될 수 없는 걸 중단하기 🔒

  • 취소는 중단점에서 일어나기 때문에 중단점이 없으면 취소 불가
// 예) 취소불가능한 경우
suspend fun main(): Unit = coroutineScope {
	val job = Job()
	launch(job) {
		repeat(1000) { i ->
			Thread.sleep(200) // 여기서 복잡한 연산이나 IO 작업이 있다고 가정
			println("Printing $i")
		}
	}
	delay(1000)
	job.cancelAndJoin()
	println("Cancelled successfully")
	delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// ... (1000까지)
  • 해결책 1) yield()를 주기적으로 호출
    • yield는 코루틴을 중단하고 즉시 재실행
suspend fun main(): Unit = coroutineScope {
	val job = Job()
	launch(job) {
		repeat(1000) { i ->
			Thread.sleep(200)
			yield()
			println("Printing $i")
		}
	}
	delay(1000)
	job.cancelAndJoin()
	println("Cancelled successfully")
	delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfull
  • 해결책2) 잡의 상태를 추적
    • 코루틴 빌더 내부에서 this는 빌더의 스코프를 참조
    • CoroutineScopecoroutineContext 프로퍼티를 사용해 참조할 수 있는 컨텍스트를 가지고 있음
    • 코루틴 잡에 접근해 현재 상태가 무엇인지 확인할 수 있음
public val CoroutineScope.isActive: Boolean 
	get() = coroutineContext[Job]?.isActive ?: true
suspend fun main(): Unit = coroutineScope {
	val job = Job()
	launch(job) {
		do {
			Thread.sleep(200)
			println("Printing")
		} while (isActive)
	}
	delay(1100)
	job.cancelAndJoin()
	println("Cancelled successfully")
}
// Printing
// Printing
// Printing
// Printing
// Printing
// Printing
// Cancelled successfully
// CancellationException을 던지는 ensureActive() 함수 사용 방식
suspend fun main(): Unit = coroutineScope {
	val job = Job()
	launch(job) {
		repeat(1000) { i ->
			Thread.sleep(200)
			ensureActive()
			println("Printing $i")
		}
	}
	delay(1100)
	job.cancelAndJoin()
	println("Cancelled successfully")
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully

두 방식의 차이점

  • ensureActive() 함수는 CoroutineScope에서 호출되어야 함
  • 함수가 하는 일은 잡이 액이브 상태가 아니면 예외를 던지는 것
  • yield 함수는 최상위 중단 함수
  • 스코프가 필요하지 않기 때문에 일반적인 중단 함수에서도 사용될 수 있음
  • 중단하고 재개하기 때문에 스레드 풀을 가진 디스패처를 사용하면 스레드가 바뀔 수 있음

suspendCancellableCoroutine 🔄

  • suspendCancellableCoroutine를 사용하면 CancellableContinuation<T>를 통해 취소 시점에 자원 해제 가능
  • invokeOnCancellation 콜백으로 취소 시 추가 처리 가능
suspend fun someTask() = suspendCancellableCoroutine { cont ->
	cont.invokeOnCancellation {
		// 정리 작업 수행
	}
	// 나머지 구현 부분
}
// Retrofit의 Call 함수도 중단 함수로 래핑되어 있음
suspend fun getOrganizationRepos(
	organization: String
): List<Repo> = suspendCancellableCoroutine { continuation ->
	val orgReposCall = apiService
		.getOrganizationRepos(organization)
	orgReposCall.enqueue(object : Callback<List<Repo>> {
		override fun onResponse(
			call: Call<List<Repo>>,
			response: Response<List<Repo>>
		){
			if (response.isSuccessful) {
				val body = response.body()
				if (body != null) {
					continuation.resume(body)
				} else {
					continuation.resumeWithException(ResponseWithEmptyBody)
				}
			} else { 
				continuation.resumeWithException(
					ApiException(
						response.code(),
						response.message()
					)
				)
			}
		}
		
		override fun onFailure(
			call: Call<List<Repo>>,
			t: Throwable
		){
			continuation.resumeWithException(t)
		}
	})
	continuation.invokeOnCancellation {
		orgReposCall.cancel()
	}
}

0개의 댓글