iyOmSd/Title: Swift

[Swift] Task Cancel

냄수 2025. 5. 23. 20:28
반응형

안녕하세요

이번 게시글에서는 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를 사용하면서 취소작업시 고려해볼만한 점에 대해서 정리를 한번해봤습니다.

도움이 되셨으면 좋겠습니다!!

감사합니다 

 

 

 

 

 

반응형