| |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
-> 移动开发 -> iOS结构化并发---喵神出品。 -> 正文阅读 |
|
[移动开发]iOS结构化并发---喵神出品。 |
前言学如逆水行舟,不进则退。共勉!! async/await 所引入的异步函数的简单写法,可以在暂停点时放弃线程,这是构建高并发系统所不可或缺的。但是异步函数本身,其实并没有解决并发编程的问题。结构化并发 (structured concurrency) 将用一个高效可预测的模型,来实现优雅的异步代码的并发。 iOS资料|地址 什么是结构化“结构化” (structured) 这个词天生充满了美好的寓意:一切有条不紊、充满合理的逻辑和准则。但是结构化并不是天然的:在计算机编程的发展早期,所使用的汇编语言,甚至到 Fortran 和 Cobol 中,为了更加契合计算机运行的实际方式,只有“顺序执行”和“跳转”这两种基本控制流。使用无条件的跳转 (goto 语句) 可能会让代码运行杂乱无状。在戴克斯特拉的《GOTO 语句有害论》之后,关于是否应该使用结构化编程的争论持续了一段时间。在今天这个时间点上,我们已经可以看到,结构化编程取得了全面胜利:大部分的现代编程语言已经不再支持 goto 语句,或者是将它限制在了极其严苛的条件之下。而基于条件判断 (if),循环 (for/while) 和方法调用的结构化编程控制流已经是绝对的主流。 goto 语句goto 语句是非结构化的,它允许控制流无条件地跳转到某个标签。虽然现在看来 goto 语句已经彻底失败,完全不得人心,但是受限于编程语言的发展,goto 语句在当时是有其生存土壤的。在还没有发明代码块的概念 (也就是 { … }) 之前,基于顺序执行和跳转的控制流,不仅是最简单的天然选择,也完美契合 CPU 执行指令的方式。顺序执行的语句非常简单,它总可以找到明确的执行入口和出口,但是跳转语句就不一定了: 程序开发的初期,控制流的设计更多地选择了贴近实际执行的方式,这也是 goto 语句被大量使用的主要原因。不过 goto 的缺点也是相当明显的:不加限制的跳转,会导致代码的可读性急剧下降。如果程序中存在 goto,那么就可能在任何时候跳转到任何部分,这样一来,程序就并不是黑匣子了:程序的抽象被破坏,你所调用的方法并不一定会把控制权还给你。另外,多次来回跳转,往往最后会变成面条代码,在调试程序时,这会是每个程序员的噩梦。 结构化编程在代码块的概念出现后,一些基本的封装带来了新的控制流方式,包括我们今天最常使用的条件语句、循环语句以及函数调用。由它们所构成的编程范式,即是我们所熟悉的结构化编程: 非结构化的并发不过,程序的结构化并不意味着并发也是结构化的。相反,Swift 现存的并发模型面临的问题,恰恰和当年 goto 的情况类似。Swift 当前的并发手段,最常见的要属使用 Dispatch 库将任务派发,并通过回调函数获取结果:
bar 和 baz 通过派发,以非阻塞的方式运行任务,并通过 completion 汇报结果。对于调用者的 foo 来说,它作为一段程序,本身是结构化的:在调用 bar 和 baz 后,程序的控制权,至少是当前线程的控制权,会回到 foo 中。最终控制流将到达 foo 的函数块的出口位置。但是,如果我们将视野扩展一些,就会发现在并发角度来看,这个控制流存在很大隐患:在 bar 和 baz 中的派发和回调,事实就是一种函数间无条件的“跳转”行为。bar 和 baz 虽然会立即将控制流交还给 foo,但是并发执行的行为会同时发生。这些被派发的并发操作在运行时中,并不知道自己是从哪里来的,这些调用不存在于,也不能存在于当前的调用栈上。它们在自己的线程中拥有调用栈,生命周期也和 foo 函数的作用域无关: 结构化并发并发程序是很难写好的,想正确地设计一个复杂并发更是难上加难。不过,你有没有怀疑过,这可能并不是我们智商上有什么问题,而是我们所使用的工具并不那么趁手如意?并发难写的原因,也许只是和当年 goto 一样,是我们没有发明合适的理论。 goto 最大的问题,在于它破坏了抽象层:当我们封装一个方法并进行调用时,我们所做的事情是相信这个方法会为我们完成它所声称的事情,把它看作一个黑盒。但是如果存在 goto,这个抽象假设就不再有效。你必须仔细深入到黑盒里面,去研究它的跳转方式:因为黑盒并不一定会乖乖把控制权还给你,而是会把调用控制流引到其他任意地方去。 这个函数会不会产生一个后台任务?这个函数虽然返回了,但是它所产生的后台任务可能还在运行,它什么时候会结束,它结束后会产生怎么样的行为? 作为调用者,我应该在哪里、以怎样的方式处理回调?我需要保持这个函数用到的资源吗?后台任务会自动去持有这些资源吗?我需要自己去释放它们吗? 这些答案并没有通用的约定,也没有编译器或运行时的保证。你很可能需要深入到每个函数的实现去寻找答案,或者只能依赖于那些脆弱且容易过时的文档 (前提还得有人写文档!) 然后不断自行猜测。和 goto 一样,派发回调破坏了并发的黑盒。它让我们所希冀和依赖的抽象大厦轰然坍塌,让我们原本可以用来在并发程序的天空中自由翱翔的双翼霎时折断。 结构化并发并没有很长的历史,它的基本概念由 Martin Sústrik 在 2016 年首次提出,之后 Nathaniel Smith 用一篇《Go 语句有害论》笔记“致敬”了当年对 goto 的批评,并从更高层阐明了结构化并发的做法,同时给出了一个 Python 库来证明和实践这些概念。我相信 Swift 团队在设计并发模型时,或多或少也参考了这些讨论,并吸收了相关经验。就算不是唯一,Swift 现在也是少数几个在原生层面上将结构化并发加入到标准库的语言之一。 那么,到底什么是结构化并发?如果要用一句话概括,那就是即使进行并发操作,也要保证控制流路径的单一入口和单一出口。程序可以产生多个控制流来实现并发,但是所有的并发路径在出口时都应该处于完成 (或取消) 状态,并合并到一起。
为了将并发路径合并,程序需要具有暂停等待其他部分的能力。异步函数恰恰满足了这个条件:使用异步函数来获取暂停主控制流的能力,函数可以执行其他的异步并发操作并等待它们完成,最后主控制流和并发控制流统合后,从单一出口返回给调用者。这也是我们在之前就将异步函数称为结构化并发基础的原因。 基于 Task 的结构化并发模型在 Swift 并发编程中,结构化并发需要依赖异步函数,而异步函数又必须运行在某个任务上下文中,因此可以说,想要进行结构化并发,必须具有任务上下文。实际上,Swift 结构化并发就是以任务为基本要素进行组织的。 当前任务状态Swift 并发编程把异步操作抽象为任务,在任意的异步函数中,我们总可是使用 withUnsafeCurrentTask 来获取和检查当前任务:
withUnsafeCurrentTask 本身不是异步函数,你也可以在普通的同步函数中使用它。如果当前的函数并没有运行在任何任务上下文环境中,也就是说,到 withUnsafeCurrentTask 为止的调用链中如果没有异步函数的话,这里得到的 task 会是 nil。 使用 Task 的初始化方法,可以得到一个新的任务环境。在上一章中我们已经看到过几种开始任务的方式了。 对于 foo 的调用,发生在上一步的 Task 闭包作用范围中,它的运行环境就是这个新创建的 Task。 对于获取到的 task,可以访问它的 isCancelled 和 priority 属性检查它是否已经被取消以及当前的优先级。我们甚至可以调用 cancel() 来取消这个任务。 要注意任务的存在与否和函数本身是不是异步函数并没有必然关系,这是显然的:同步函数也可以在任务上下文中被调用。比如下面的 syncFunc 中,withUnsafeCurrentTask 也会给回一个有效任务:
使用 withUnsafeCurrentTask 获取到的任务实际上是一个 UnsafeCurrentTask 值。和 Swift 中其他的 Unsafe 系 API 类似,Swift 仅保证它在 withUnsafeCurrentTask 的闭包中有效。你不能存储这个值,也不能在闭包之外调用或访问它的属性和方法,那会导致未定义的行为。 因为检查当前任务的状态相对是比较常用的操作,Swift 为此准备了一个“简便方法”:使用 Task 的静态属性来获取当前状态,比如:
虽然被定义为 static var,但是它们并不表示针对所有 Task 类型通用的某个全局属性,而是表示当前任务的情况。因为一个异步函数的运行环境必须有且仅会有一个任务上下文,所以使用 static 变量来表示这唯一一个任务的特性,是可以理解的。相比于每次去获取 UnsafeCurrentTask,这种写法更加简单。比如,我们可以在不同的任务上下文中使用 Task.isCancelled 检查任务的取消情况:
任务层级上例中虽然 t1 和 t2 是在外层 Task 中再新生成并进行并发的,但是它们之间没有从属关系,并不是结构化的。这一点从 t: false 先于其他输出就可以看出,t1 和 t2 的执行都是在外层 Task 闭包结束后才进行的,它们逃逸出去了,这和结构化并发的收束规定不符。 想要创建结构化的并发任务,就需要让内层的 t1 和 t2 与外层 Task 具有某种从属关系。你可以已经猜到了,外层任务作为根节点,内层任务作为叶子节点,就可以使用树的数据结构,来描述各个任务的从属关系,并进而构建结构化的并发了。这个层级关系,和 UI 开发时的 View 层级关系十分相似。 通过用树的方式组织任务层级,我们可以获取下面这些有用特性:
当任务的根节点退出时,我们通过等待所有的子节点,来保证并发任务都已经退出。树形结构允许我们在某个子节点扩展出更多的二层子节点,来组织更复杂的任务。这个子节点也许要遵守同样的规则,等待它的二层子节点们完成后,它自身才能完成。这样一来,在这棵树上的所有任务就都结构化了。 任务组典型应用在任务运行上下文中,或者更具体来说,在某个异步函数中,我们可以通过 withTaskGroup 为当前的任务添加一组结构化的并发子任务:
解释一下上面注释中的数字标注。使用 withTaskGroup 可以开启一个新的任务组,它的完整的函数签名是:
这个签名看起来十分复杂,有点吓人,我们来解释一下。childTaskResultType 正如其名,我们需要指定子任务们的返回类型。同一个任务组中的子任务只能拥有同样的返回类型,这是为了让 TaskGroup 的 API 更加易用,让它可以满足带有强类型的 AsyncSequence 协议所需要的假设。returning 定义了整个任务组的返回值类型,它拥有默认值,通过推断就可以得到,我们一般不需要理会。在 body 的参数中能得到一个 inout 修饰的 TaskGroup,我们可以通过使用它来向当前任务上下文添加结构化并发子任务。 addTask API 把新的任务添加到当前任务中。被添加的任务会在调度器获取到可用资源后立即开始执行。在这里的例子里,for…in 循环中的三个任务会被立即添加到任务组里,并开始执行。 在实际工作开始时,我们进行了一次 print 输出,这让我们可以更容易地观测到事件的顺序。 group 满足 AsyncSequence,因此我们可以使用 for await 的语法来获取子任务的执行结果。group 中的某个任务完成时,它的结果将被放到异步序列的缓冲区中。每当 group 的 next 会被调用时,如果缓冲区里有值,异步序列就将它作为下一个值给出;如果缓冲区为空,那么就等待下一个任务完成,这是异步序列的标准行为。 for await 的结束意味着异步序列的 next 方法返回了 nil,此时group 中的子任务已经全部执行完毕了,withTaskGroup 的闭包也来到最后。接下来,外层的 “End” 也会被输出。整个结构化并发结束执行。 调用上面的代码,输出结果为:
由 work 定义的三个异步操作并发执行,它们各自运行在独自的子任务空间中。这些子任务在被添加后即刻开始执行,并最终在离开 group 作用域时再汇集到一起。用一个图表,我们可以看出这个结构化并发的运行方式: 隐式等待为了获取子任务的结果,我们在上例中使用 for await 明确地等待 group 完成。这从语义上明确地满足结构化并发的要求:子任务会在控制流到达底部前结束。不过一个常见的疑问是,其实编译器并没有强制我们书写 for await 代码。如果我们因为某种原因,比如由于用不到这些结果,而导致忘了等待 group,会发生什么呢?任务组会不会因为没有等待,而导致原来的控制流不会暂停,就这样继续运行并结束?这样是不是违反了结构化并发的需要? 好消息是,即使我们没有明确 await 任务组,编译器在检测到结构化并发作用域结束时,会为我们自动添加上 await 并在等待所有任务结束后再继续控制流。比如,在上面的代码中,如果我们将 for await 部分删去:
输出将变为:
虽然 “Task ended” 的输出似乎提早了,但代表整个任务组完成的 “End” 的输出依然处于最后,它一定会在子任务全部完成之后才发生。对于结构化的任务组,编译器会为在离开作用域时我们自动生成 await group 的代码,上面的代码其实相当于:`
它满足结构化并发控制流的单入单出,将子任务的生命周期控制在任务组的作用域内,这也是结构化并发的最主要目的。即使我们手动 await 了 group 中的部分结果,然后退出了这个异步序列,结构化并发依然会保证在整个闭包退出前,让所有的子任务得以完成:
任务组的值捕获任务组中的每个子任务都拥有返回值,上面例子中 work 返回的 Int 就是子任务的返回值。当 for await 一个任务组时,就可以获取到每个子任务的返回值。任务组必须在所有子任务完成后才能完成,因此我们有机会“整理”所有子任务的返回结果,并为整个任务组设定一个返回值。比如把所有的 work 结果加起来:
每次 work 子任务完成后,结果的 result 都会和 value 累加,运行这段代码将输出结果 3。 一种很常见的错误,是把 value += result 的逻辑写到 addTask 中:
这样的做法会带来一个编译错误
将给出错误: await withTaskGroup(of: Int.self) { group in // var value = 0 let value = 0
不过,如果我们把 value 再向上提到类的成员一级的话,这个静态检查将失去作用:
在 Swift 5.5 中,虽然它可以编译 (而且使用起来,特别是在本地调试时也几乎不会有问题),但这样的行为是错误的。和 Rust 不同,Swift 的堆内存所有权模型还无法完全区分内存的借用 (borrow) 和移动 (move),因此这种数据竞争和内存错误,还需要开发者自行注意。 Swift 编译器并非无法检出上述错误,它只是暂时“容忍”了这种情况。包括静态检测上述错误在内的完全的编译器级别并发数据安全,是未来 Swift 版本中的目标。现在,在并发上下文中访问共享数据时,Swift 设计了 actor 类型来确保数据安全。我们在介绍后面关于 actor 的章节,以及并发底层模型和内存安全的部分后,你会对这种情况背后的原因有更深入的了解。 任务组逃逸和 withUnsafeCurrentTask 中的 task 类似,withTaskGroup 闭包中的 group 也不应该被外部持有并在作用范围之外使用。虽然 Swift 编译器现在没有阻止我们这样做,但是在 withTaskGroup 闭包外使用 group 的话,将完全破坏结构化并发的假设:
通过 g?.addTask 添加的任务有可能在 start 完成后继续运行,这回到了非结构并发的老路;但它也可能让整个任务组进入到难以预测的状态,这将摧毁程序的执行假设。TaskGroup 实际上并不是用来存储 Task 的容器,它也不提供组织任务时需要的树形数据结构,这个类型仅仅只是作为对底层接口的包装,提供了创建任务节点的方法。要注意,在闭包作用范围外添加任务的行为是未定义的,随着 Swift 的升级,今后有可能直接产生运行时的崩溃。虽然现在并没有提供任何语言特性来确保 group 不被复制出去,但是我们绝对应该避免这种反模式的做法。 async let 异步绑定除了任务组以外,async let 是另一种创建结构化并发子任务的方式。withTaskGroup 提供了一种非常“正规”的创建结构化并发的方式:它明确地描绘了结构化任务的作用返回,确保在闭包内部生成的每个子任务都在 group 结束时被 await。通过对 group 这个异步序列进行迭代,我们可以按照异步任务完成的顺序对结果进行处理。只要遵守一定的使用约定,就可以保证并发结构化的正确工作并从中受益。 但是,这些优点有时候也正是 withTaskGroup 不足:每次我们想要使用 withTaskGroup 时,往往都需要遵循同样的模板,包括创建任务组、定义和添加子任务、使用 await 等待完成等,这些都是模板代码。而且对于所有子任务的返回值必须是同样类型的要求,也让灵活性下降或者要求更多的额外实现 (比如将各个任务的返回值用新类型封装等)。withTaskGroup 的核心在于,生成子任务并将它的返回值 (或者错误) 向上汇报给父任务,然后父任务将各个子任务的结果汇总起来,最终结束当前的结构化并发作用域。这种数据流模式十分常见,如果能让它简单一些,会大幅简化我们使用结构化并发的难度。async let 的语法正是为了简化结构化并发的使用而诞生的。 在 withTaskGroup 的例子中的代码,使用 async let 可以改写为下面的形式:
async let 和 let 类似,它定义一个本地常量,并通过等号右侧的表达式来初始化这个常量。区别在于,这个初始化表达式必须是一个异步函数的调用,通过将这个异步函数“绑定”到常量值上,Swift 会创建一个并发执行的子任务,并在其中执行该异步函数。async let 赋值后,子任务会立即开始执行。如果想要获取执行的结果 (也就是子任务的返回值),可以对赋值的常量使用 await 等待它的完成。
需要特别强调,虽然这里我们顺次进行了 await,看起来好像是在等 v0 求值完毕后,再开始 v1 的暂停;然后在 v1 求值后再开始 v2。但是实际上,在 async let 时,这些子任务就一同开始以并发的方式进行了。在例子中,完成 work(n) 的耗时为 n 秒,所以上面的写法将在第 0 秒,第 1 秒和第 2 秒分别得出 v0,v1 和 v2 的值,而不是在第 0 秒,第 1 秒和第 3 秒 (1 秒 + 2 秒) 后才得到对应值。
如果是考察每个子任务实际完成的时序,那么答案是没有变化:在 async let 创建子任务时,这个任务就开始执行了,因此 v0、v1 和 v2 真正执行的耗时,依旧是 0 秒,1 秒和 2 秒。但是,使用 await 最终获取 v0 值的时刻,是严格排在获取 v2 值之后的:当 v0 任务完成后,它的结果将被暂存在它自身的续体栈上,等待执行上下文通过 await 切换到自己时,才会把结果返回。也就是说在上例中,通过 async let 把任务绑定并开始执行后,await v1 会在 1 秒后完成;再经过 1 秒时间,await v2 完成;然后紧接着,await v0 会把 2 秒之前就已经完成的结果立即返回给 result0: 隐式取消在使用 async let 时,编译器也没有强制我们书写类似 await v0 这样的等待语句。有了 TaskGroup 中的经验以及 Swift 里“默认安全”的行为规范,我们不难猜测出,对于没有 await 的异步绑定,编译器也帮我们做了某些“手脚”,以保证单进单出的结构化并发依然成立。
它等效于:
和 TaskGroup API 的不同之处在于,被绑定的任务将先被取消,然后才进行 await。这给了我们额外的机会去清理或者中止那些没有被使用的任务。不过,这种“隐藏行为”在异步函数可以抛出的时候,可能会造成很多的困惑。我们现在还没有涉及到任务的取消行为,以及如何正确处理取消。这是一个相对复杂且单独的话题,我们会在下一章中集中解释这里的细节。现在,你只需要记住,和 TaskGroup 一样,就算没有 await,async let 依然满足结构化并发要求这一结论就可以了。 对比任务组既然同样是为了书写结构化并发的程序,async let 经常会用来和任务组作比较。在语义上,两者所表达的范式是很类似的,因此也会有人认为 async let 只是任务组 API 的语法糖:因为任务组 API 的使用太过于繁琐了,而异步绑定毕竟在语法上要简洁很多。 但实际上它们之间是有差异的。async let 不能动态地表达任务的数量,能够生成的子任务数量在编译时必须是已经确定好的。比如,对于一个输入的数组,我们可以通过 TaskGroup 开始对应数量的子任务,但是我们却无法用 async let 改写这段代码:
除了上面那些只能使用某一种方式创建的结构化并发任务外,对于可以互换的情况,任务组 API 和异步绑定 API 的区别在于提供了两种不同风格的编程方式。一个大致的使用原则是,如果我们需要比较“严肃”地界定结构化并发的起始,那么用任务组的闭包将它限制起来,并发的结构会显得更加清晰;而如果我们只是想要快速地并发开始少数几个任务,并减少其他模板代码的干扰,那么使用 async let 进行异步绑定,会让代码更简洁易读。 结构化并发的组合在只使用一次 withTaskGroup 或者一组 async let 的单一层级的维度上,我们可能很难看出结构化并发的优势,因为这时对于任务的调度还处于可控状态:我们完全可以使用传统的技术,通过添加一些信号量,来“手动”控制保证并发任务最终可以合并到一起。但是,随着系统逐渐复杂,可能会面临在一些并发的子任务中再次进行任务并发的需求。也就是,形成多个层级的子任务系统。在这种情况下,想依靠原始的信号量来进行任务管理会变得异常复杂。这也是结构化并发这一抽象真正能发挥全部功效的情况。 通过嵌套使用 withTaskGroup 或者 async let,可以在一般人能够轻易理解的范围内,灵活地构建出这种多层级的并发任务。最简单的方式,是在 withTaskGroup 中为 group 添加 task 时再开启一个 withTaskGroup:
任务本地值指的是那些仅存在于当前任务上下文中的,由外界注入的值。我们会在后面的章节中针对这个话题展开讨论。 相对于 withTaskGroup 的嵌套,使用 async let 会更有技巧性一些。async let 赋值等号右边,接受的是一个对异步函数的调用。这个异步函数可以是像 work 这样的具体具名的函数,也可以是一个匿名函数。比如,上面的 withTaskGroup 嵌套的例子,使用 async let,可以简单地写为:
这里在 v02 等号右侧的是一个匿名的异步函数闭包调用,其中通过两个新的 async let 开始了嵌套的子任务。特别注意,上例中的写法和下面这样的 await 有本质不同:
await work(0) + work(2) 将会顺次执行 work(0) 和 work(2),并把它们的结果相加。这时两个操作不是并发执行的,也不涉及新的子任务。
大部分时候,把子任务的部分提取成具名的函数会更好。不过对于这个简单的例子,直接使用匿名函数,让 work(0)、work(2) 与另一个子任务中的 work(1) 并列起来,可能结构会更清楚。 因为 withTaskGroup 和 async let 都产生结构性并发任务,因此有时候我们也可以将它们混合起来使用。比如在 async let 的右侧写一个 withTaskGroup;或者在 group.addTask 中用 async let 绑定新的任务。不过不论如何,这种“静态”的任务生成方式,理解起来都是相对容易的:只要我们能将生成的任务层级和我们想要的任务层级对应起来,两者混用也不会有什么问题。 非结构化任务TaskGroup.addTask 和 async let 是 Swift 并发中“唯二”的创建结构化并发任务的 API。它们从当前的任务运行环境中继承任务优先级等属性,为即将开始的异步操作创建新的任务环境,然后将新的任务作为子任务添加到当前任务环境中。
这类任务具有最高的灵活性,它们可以在任何地方被创建。它们生成一棵新的任务树,并位于顶层,不属于任何其他任务的子任务,生命周期不和其他作用域绑定,当然也没有结构化并发的特性。对比三者,可以看出它们之间明显的不同: TaskGroup.addTask 和 async let - 创建结构化的子任务,继承优先级和本地值。 Task.detached - 创建非结构化的任务根节点,不从当前任务中继承优先级和本地值等运行环境,完全新的游离任务环境。 有一种迷思认为,我们在新建根节点任务时,应该尽量使用 Task.init 而避免选用生成一个完全“游离任务”的 Task.detached。其实这并不全然正确,有时候我们希望从当前任务环境中继承一些事实,但也有时候我们确实想要一个“干净”的任务环境。比如 @main 标记的异步程序入口和 SwiftUI task 修饰符,都使用的是 Task.detached。具体是不是有可能从当前任务环境中继承属性,或者应不应该继承这些属性,需要具体问题具体分析。 创建非结构化任务时,我们可以得到一个具体的 Task 值,它充当了这个新建任务的标识。从 Task.init 或 Task.detached 的闭包中返回的值,将作为整个 Task 运行结束后的值。使用 Task.value 这个异步只读属性,我们可以获取到整个 Task 的返回值:
一旦创建任务,其中的异步任务就会被马上提交并执行。所以上面的代码依然是并发的:t1 和 t2 之间没有暂停,将同时执行,t1 任务在 1 秒后完成,而 t2 在两秒后完成。await t1.value 和 await t2.value 的顺序并不影响最终的执行耗时,即使是我们先 await 了 t2,t1 的预先计算的结果也会被暂存起来,并在它被 await 的时候给出。 用 Task.init 或 Task.detached 明确创建的 Task,是没有结构化并发特性的。Task 值超过作用域并不会导致自动取消或是 await 行为。想要取消一个这样的 Task,必须持有返回的 Task 值并明确调用 cancel:
这种非结构化并发中,外层的 Task 的取消,并不会传递到内层 Task。或者,更准确来说,这样的两个 Task 并没有任何从属关系,它们都是顶层任务:
单是这样的多个 Task,看起来还很简单。但是考虑到 Task.value 其实也是一种异步函数,如果我们将结构化并发和非结构化的任务组合起来使用的话,事情马上就会变得复杂起来。比如下面这个“简单”的例子,它在 async let 右侧开启新的 Task:
t1 和 t2 确实是结构化的,但是它们开启的新任务,却并非如此:虽然 t1 和 t2 在超出 start 作用域时,由于没有 await,这两个绑定都将被取消,但这个取消并不能传递到非结构化的 Task 中,所以两个 isCancelled 都将输出 false。 不过确实也有一些情况我们会倾向于选择非结构化的并发,比如一些并不影响异步系统中其他部分的非关键操作。像是下载文件后将它写入缓存就是一个好例子:在下载完成后我们就可以马上结束“下载”这个核心的异步行为,并在开始缓存的同时,就将文件返回给调用者了。写入缓存作为“顺带”操作,不应该作为结构化任务的一员。此时使用独立任务会更合适。 小结历史已经证明了,完全放弃 goto 语句,使用结构化编程,有利于我们理解和写出正确控制流的程序。而随着计算机的发展和程序设计的演进,现在我们来到了另一个重要的时间节点:我们是否应该完全使用结构化并发,而舍弃掉原有的非结构化并发模型呢?现在有这个趋势,但是大家也都还保留了原来的并发模型。即使要完全转变,可能也还需要一些时间。 Swift 是当前少数几个在语言和标准库层面对结构化并发进行支持的语言之一。得益于 Swift 语言默认安全的特性,只要我们遵循一些简单的规定 (比如不在闭包外传递和持有 task group 等),就可以写出正确、安全和非常易于理解的结构化并发代码。这为简化并发复杂度提供了有效的工具。withTaskGroup 和 async let 在创建结构化并发上是等效的,但是它们并非可以完全互相代替。两者有各自最适用的情景,在超出作用域的隐式行为细节上也略有不同。切实理解这些不同,可以帮助我们在面对任务时选取最合适的工具。 本章中我们只讨论了结构化并发的完成特性:父任务在子任务全部完成之前,是不会完成的。对于结构化并发来说,这只是其中一部分内容,对于另一个大的话题,任务取消,本章中鲜有涉及。在下一章里,我们会仔细探讨任务取消的相关话题,这会让我们对结构化并发在简化并发编程模型中所带来的优势,有更加深刻的理解。 最后求个点赞关注。有什么想法都可以在评论区留言讨论。 iOS资料|地址 推荐阅读:Swift 并发初步 |
|
移动开发 最新文章 |
Vue3装载axios和element-ui |
android adb cmd |
【xcode】Xcode常用快捷键与技巧 |
Android开发中的线程池使用 |
Java 和 Android 的 Base64 |
Android 测试文字编码格式 |
微信小程序支付 |
安卓权限记录 |
知乎之自动养号 |
【Android Jetpack】DataStore |
|
上一篇文章 下一篇文章 查看所有文章 |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 | -2024/11/24 8:30:54- |
|
网站联系: qq:121756557 email:121756557@qq.com IT数码 |