为什么下面的代码不打印"已取消".我判断任务取消的方式有误吗?

import UIKit

class ViewController: UIViewController {
    
    private var task: Task<Void, Never>?
    
    override func viewDidLoad() {
        let task = Task {
            do {
                try await test()
            } catch {
                if Task.isCancelled {
                    print("cancelled in catch block..")
                }
                if let cancellationError = error as? CancellationError {
                    print("Task canceled..")
                }
            }
        }
        self.task = task
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
            task.cancel()
        }
    }
    
    func test() async throws {
        while true {
            if Task.isCancelled {
                print("cancelled..")
                throw URLError(.badURL)
            }
            // OUTPUT:
            // "cancelled.." will not be printed
            // "Task canceled.." will not be printed
            // "cancelled in catch block.." will not be printed
        }        
    }
}


但是,如果我将if Task.isCancelled { print("cancelled in catch block..") }放入CATCH块中,cancelled in catch block..将按预期执行.

推荐答案

这里最重要的问题是,视图控制器被隔离为@MainActor,因此test也被隔离到主要参与者.但是这个函数继续快速旋转,没有悬浮点(即第await号),所以你是在无限期地阻挡主要演员.因此,您也阻塞了主线程,因此永远不会到达task.cancel()线.

要解决此问题,您可以执行以下任一操作:

  1. 从主演身上减go test分:

    nonisolated func test() async throws { … }
    

    值为asyncnonisolated方法不会在当前执行元上运行.请参见SE-0338.

    (注意,将其从主要参与者中移除充其量只是部分解决方案,因为您真的不应该阻止Swift并发协作线程池中的any个线程.但稍后会有更多内容).

    或,

  2. 在你的循环中使用非阻塞的Task.sleep(它引入了一个await悬挂点),而不是一个紧密旋转的循环.这将避免主要参与者的阻塞,并将其转换为定期判断取消的东西:

    func test() async throws {
        while true {
            try? await Task.sleep(for: .seconds(0.5))
            try Task.checkCancellation()
    
            // or
            //
            // try? await Task.sleep(for: .seconds(0.5))
            // if Task.isCancelled {
            //     print("cancelled..")
            //     throw CancellationError()       // very misleading to throw URLError(.badURL) !!!
            // }
        }
    }
    

    事实上,因为Task.sleep实际上已经判断了取消,所以你根本不需要checkCancellationisCancelled测试:

    func test() async throws {
        while true {
            try await Task.sleep(for: .seconds(0.5))
        }
    }
    

永远不要在SWIFT并发中使用长时间运行的同步代码(如您的while循环).我们与SWIFT并发签订了一项协议,永远不阻止当前参与者(特别是主要参与者).

参见SE-0296,上面写着:

异步函数应避免调用可能实际阻塞线程…的函数

或观看WWDC 2021视频Swift concurrency: Behind the scenes

我们希望您能够编写易于推理的直线代码,并为您提供安全、受控的并发.为了实现我们所追求的这种行为,操作系统需要一个运行时契约,该契约的线程不会阻塞…

最后,最后要考虑的是运行时契约,它是Swift中高效线程模型的基础.回想一下,在Swift中,语言允许我们维护一个运行时契约,线程总是能够向前推进.当你采用Swift并发时,重要的是要确保你在代码中继续维护这个契约,这样协作线程池就可以最佳地运行.…

归根结底,如果test真的像您的示例一样在CPU上旋转是很耗时的,那么您应该:

  • 把它从主演身上go 掉;
  • 定期try Task.checkCancellation();并且
  • 定期拨打try Task.yield().

但我必须承认,这个while循环是在准备您的示例时引入的(因为它确实有点不太靠谱).例如,如果test实际上只是调用处理取消的某个async函数(例如,网络调用),则通常不需要这种手动判断取消的愚蠢行为.它不会阻止主要演员,而且很可能已经处理了取消.我们需要看看test到底在做什么,才能提供进一步的建议.


抛开代码片段的特性不谈,在回答您最初的问题"如何判断当前任务是否被取消?"时,有四种基本方法:

  1. 调用async throws个已经支持取消的函数.Apple的async throws个函数中的大多数本机支持取消,例如Task.sleepURLSession等.如果编写您自己的async函数,请使用以下三点中概述的任何一种技术.

  2. 使用withTaskCancellationHandler(operation:onCancel:)来包装您的 可取消的异步进程.

    当调用可取消的遗留API并将其包装在Task中时,这很有用.这样,取消任务可以主动停止遗留API中的异步进程,而不是等到手动checkCancellation调用.

  3. 当我们使用循环执行一些手动的、计算密集型的过程时,我们只需要try Task.checkCancellation().但前面提到的关于不要阻塞线程、屈服等的所有警告仍然适用.

  4. 或者,您可以测试Task.isCancelled,如果是这样,则手动抛出CancellationError.这是最麻烦的方法,但它是有效的.

许多关于处理协作取消的讨论往往停留在后两种方法上,但在处理遗留的可取消API时,前述withTaskCancellationHandler通常是更好的解决方案.

Swift相关问答推荐

什么是Swift Concurrency中任务组的正常退出

在SWIFT中使用Objective-C struct 时出错(在作用域中找不到类型)

文件命名&NumberForMatter+扩展名&:含义?

相互取消任务

如何绑定环境变量ios17

TimeZone 背后的目的

使用变量(而非固定)的字符串分量进行Swift正则表达式

如何判断一个值是否为 Int 类型

UUID 哈希值是不确定的吗?

在 Vapor 4 中使用协议的通用流利查询

Swift 非参与者隔离闭包

为什么更改属性后动画会加速? SwiftUI

Swift - 如何更新多目录中的对象

临时添加到视图层次 struct 后,Weak view引用不会被释放

我将哪种 Swift 数据类型用于货币

否定 #available 语句

Swift 2 中的新 @convention(c):我该如何使用它?

如何使用 Swift 将文本文件逐行加载到数组中?

如何快速格式化用户显示(社交网络等)的时间间隔?

Switch 语句中的字符串:String不符合协议IntervalType