안녕하세요
이번 게시글에서는 Task Cancel에 대해서 알아보려 합니다
Task 취소과정에서 놓칠 수 있는 부분들을 살펴볼 예정입니다!
보통 아래코드를 보면
task = Task { await _excute() }
task.cancel()
취소완료~!
하고 끝낼 수 있습니다.
하지만
cancel()을 호출했음에도 불구하고
내부적으로 Task는 아직 실행 중일 수 있습니다.
Concurrency는 Structured / Unstructured 로 크게 나눌 수 있습니다.
구조화된 작업은 async let, TaskGroup으로 만들수 있고
비구조화된 작업은 Task { }, Task.detached { } 로 만들 수 있습니다.
구조화된 작업은 로컬 변수처럼 작업이 선언된 스코프에서 끝까지 살아남고, 스코프 밖으로 나가면 자동으로 취소됨에 따라
작업의 수명을 명확하게 알 수 있는 장점이 있어서
애플도 구조화된 작업을 사용하라고 권장하고있습니다.
Concurrency함수를 호출하기위한 최상위 함수부분에서는 비구조화된 작업을 많이 사용하고 있습니다.
Task, Task.detached의 차이를 context의 상속/비상속으로 목적에 맞게 구분하여 사용합니다.
이때 Task가 context를 상속한다해서
Task.cancel()의 호출도 상속받는게 아니라는 점에서 주의해야합니다
최상위 함수부분에 정의될 부모 Task를 취소시켰을때
만약 내부에 또 비구조화된 Task가 정의돼있다면
Task {
Task {
// something...
}
}
내부의 Task는 별도로 취소시켜야합니다.
Task.cancel()은 구조화된 작업에선 부모가 취소되면 자식에게 전파가 잘 일어나며
이때 곧바로 작업을 중지하는게 아니라
작업에 isCancelled 플래그를 true로 만들어 줄뿐입니다.
그래서 취소작업은 개발자가 코드로 직접 작업해줘야합니다.
취소를 확인하는 방법에는 3가지가 있습니다
1. Task.checkCancellation()
2. Task.isCancelled
3. withTaskCancellationHandler(opertaion:, onCancel:)
1번은 취소상태일때 error를 반환하는 형식입니다.
2번은 bool값으로 컨트롤할 수 있습니다.
1,2번은 polling방식으로 취소시점이후에 확인해야 취소작업을 실행시킬 수 있습니다.
비싼 작업을 하기전에 취소상태를 항상 검사하는게 좋습니다.
3번은 앞선 1,2번과 다르게 Event-Driven방식으로
취소됬을때 옵저빙되서 실행시킬수 있는 방식입니다.
여기까지 Task Cancel에 대해 개념적으로 알아봤으니
이제 코드를 보면서
전파되는 과정을 살펴볼까요?!
5가지 동작을 테스트해보려합니다
Task
Task.detached
TaskGroup
withTaskCancellationHandler
AsyncSequence
var task: Task<Void, Never>?
func cancel() {
task?.cancel()
}
func excuteTask() {
task = Task { await _excute() }
}
기본적으로 취소와 실행을 구현했고
_excute()함수에서 동작을 구현할 거예요
먼저 Task, Task.deatched와 보통사용시를 비교해보겠습니다.
private func _excute() async {
// 비구조 Task
Task { [weak self] in
await self?.calculate(prefix: "🔴 task")
print("-- 🔴 new task end")
}
// 비구조 Task.detached
Task.detached { [weak self] in
await self?.calculate(prefix: "🟠 detach")
print("-- 🟠 detach end")
}
async let _ = calculate(prefix: "🟡 async let")
await calculate(prefix: "🟢 task")
print("-- end")
}
private func calculate(prefix: String) async {
for i in 0..<5 {
if Task.isCancelled {
print(prefix, "cancel!")
return
}
try? await Task.sleep(for: .seconds(1))
print(prefix, i, "...")
}
}
calculate함수를 실행시켜서 1초씩 5번 실행되도록 딜레이를 줄거구요
이때 1초간격으로 isCancelled를 체크해서
취소됬다면 바로 끝내도록 구현했습니다.
🟢 task 0 ...
🟡 async let 0 ...
🔴 task 0 ...
🟠 detach 0 ...
🟢 task 1 ...
🟡 async let 1 ...
🟡 async let cancel!
🟢 task cancel!
-- end
🔴 task 1 ...
🟠 detach 1 ...
🟠 detach 2 ...
🔴 task 2 ...
🟠 detach 3 ...
🔴 task 3 ...
🟠 detach 4 ...
-- 🟠 detach end
🔴 task 4 ...
-- 🔴 new task end
0초이후 바로 cancel을 동작시켰고
위에 로그처럼
구조화된 Task들은 취소가되서 정의해둔 코드가 실행됬고
비구조화된 Task들은 취소가 되지않는것을 알 수 있습니다.
여기서 또 새롭게 주의할 점으로
위에서 구조화된 작업의 특징으로 '스코프 밖으로 나가면 자동으로 취소' 가 있었습니다.
private func _excute() async {
async let _ = calculate(prefix: "🟡 async let")
// await calculate(prefix: "🟢 task")
print("-- end")
}
/*
-- end
🟡 async let cancel!
*/
만약 위의코드처럼 실행한다면
await가 없어서 아래코드가 바로 실행되고
스코프가 바로 끝나기때문에
취소를 하지않아도 시작과 동시에 바로 종료되는것도 볼 수 있습니다.
밑에서 진행할 TaskGroup, 등 모든 작업에 대해 동일합니다!
async let 사용시 주의해주세요!
다음으로 TaskGroup도 테스트해보면
private func _excute() async {
await withTaskGroup(of: Void.self) { group in
group.addTask { [weak self] in
await self?.calculate(prefix: "🔵 withTaskGroup1")
}
group.addTask { [weak self] in
await self?.calculate(prefix: "🔵🔵 withTaskGroup2")
}
group.addTask { [weak self] in
await self?.calculate(prefix: "🔵🔵🔵 withTaskGroup3")
}
}
}
/*
🔵🔵 withTaskGroup2 0 ...
🔵 withTaskGroup1 0 ...
🔵🔵🔵 withTaskGroup3 0 ...
🔵 withTaskGroup1 1 ...
🔵 withTaskGroup1 cancel!
🔵🔵 withTaskGroup2 1 ...
🔵🔵 withTaskGroup2 cancel!
🔵🔵🔵 withTaskGroup3 1 ...
🔵🔵🔵 withTaskGroup3 cancel!
*/
취소를 누르면 취소코드가 바로 동작하는것을 볼 수 있습니다.
다음으로 withTaskCancellationHandler
private func _excute() async {
await withTaskCancellationHandler { [weak self] in
await self?.calculate(prefix: "🟣 withTaskCancellationHandler")
} onCancel: {
print("🟣 on cancel!!")
}
}
/*
🟣 withTaskCancellationHandler 0 ...
🟣 withTaskCancellationHandler 1 ...
🟣 on cancel!!
🟣 withTaskCancellationHandler 2 ...
🟣 withTaskCancellationHandler cancel!
*/
취소했을때
onCancel(Event-Driven)이 먼저호출되고,
그 뒤에 취소됬는지 검사하는 로직에서 취소가 되는걸 볼 수 있습니다.
마지막으로 AsyncSequence
AsyncSequence를 테스트하기위해 간단하게 타입을 하나 만들어줬구요
AsyncSequence내부적으로 Task가 취소되면 끝나도록 구현했습니다.
private func _excute() async {
let sequence = Counter(max: 10)
for await i in sequence {
print("🟤 AsyncSequence", i, "...")
}
}
struct Counter: AsyncSequence {
typealias Element = Int
private var max: Int
init(max: Int) {
self.max = max
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(max: max)
}
struct AsyncIterator: AsyncIteratorProtocol {
typealias Element = Int
private var current: Int = 0
private var max: Int
init(max: Int) {
self.max = max
}
mutating func next() async -> Int? {
guard current < max else { return nil }
if Task.isCancelled {
print("🟤 AsyncSequence cancel!")
return nil
}
try? await Task.sleep(for: .seconds(1))
let value = current
current += 1
return value
}
}
}
/*
🟤 AsyncSequence 0 ...
🟤 AsyncSequence 1 ...
🟤 AsyncSequence cancel!
*/
AsyncSequence도 마찬가지로 취소를 누르면
취소코드가 동작하는것을 볼 수 있습니다.
이렇듯 구조화된 Task에서는 잘 전달되서 원하는 취소로직을 동작시킬 수 있을 거라고 기대할 수 있습니다.
Task를 사용하면서 취소작업시 고려해볼만한 점에 대해서 정리를 한번해봤습니다.
도움이 되셨으면 좋겠습니다!!
감사합니다
'iyOmSd > Title: Swift' 카테고리의 다른 글
| [Swift] WWDC25 Wake up to the AlarmKit API (3) | 2025.07.22 |
|---|---|
| [Swift] Vision OCR 인식 (0) | 2025.06.25 |
| [Swift] Mergeable Libraries, 병합된 라이브러리 (0) | 2025.04.16 |
| [Swift] Core Bluetooth Basic (0) | 2025.03.18 |
| [Swift] WWDC24 Demystify explicitly built modules (0) | 2024.11.26 |