Rust 高级特性秀场:spawn 闭包的类型约束

文章来源声明: 原文作者:十万公里通票; 来源站点:掘金; 原文链接:https://juejin.cn/post/7688653993503145993; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合 Rust 多线程与闭包进阶读者,一文打通 spawn 约束、Send 与 'static 的关联,帮助写出线程安全且可编译的并发代码。

![](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/027dcb7c671a4c72add14c2fcbfdac73~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Y2B5LiH5YWs6YeM6YCa56Wo:q75.awebp?rk3s=f64ab15b&x-expires=1790836179&x-signature=snRoyQvTzeiIM5PVMoRFkd9xo%2B8%3D)

我们已经在本博客的数篇文章中接连讨论了 Rust 的闭包、Fn / FnMut / FnOnce 特质、多线程、Send 特质和 'static 约束等高级特性,在具备了这些知识储备后,我们终于可以来拆解 spawn 闭包的类型约束了,这是一个集以上众多高级特性于一身,生动诠释 Rust 语言表现力的经典场景。友情提示:本文牵涉知识点众多,但每一个关键知识点我们都已专门介绍过,在它们出现的地方会给出对应链接。本文原文链接:laurence.blog.csdn.net/article/det… 禁止转载。

  1. 方法签名

我们知道,thread::spawn() 用于孵化一个单独的线程,用户将打算在线程中执行的代码写成一个闭包并传给这个函数,就完成了一个多线程任务,得益于 Rust 诸多高级特性的支持,这种线程的定义和执行方式简洁而优雅,更重要的是,它还提供了诸多的线程安全保护机制,除了始终在发挥作用的所有权和借用检查之外(这里不是单单指它们在内存安全中的常规作用,更多的是指它们在线程安全上发挥的作用,具体可参考《无为而治,Rust 多线程的处世哲学》的第 2 节),还有一部保护机制就来自于我们今天要介绍的闭包类型约束上。让我们正式切入主题,看一下 thread::spawn() 方法的签名:

<span>pub</span> <span>fn</span> <span>spawn</span><F, T>(f: F) <span>-></span> JoinHandle<T>
<span>where</span>
    F: <span>FnOnce</span>() <span>-></span> T + <span>Send</span> + <span>'static</span>,
    T: <span>Send</span> + <span>'static</span>

我们重点关注的是对闭包类型 F 的约束 F: FnOnce() -> T + Send + 'static,这一行容易被误读,Send + 'static 不是返回值,而是对 F 的类型约束的一部分,正确的分组是:

F: <span>FnOnce</span>() <span>-></span> T + <span>Send</span> + <span>'static</span>
<span>//  ^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^</span>
<span>//  F 的第一个 bound      F 的另外两个 bound</span>

这一行同时声明了三件事:

• F 是一个无参数、返回类型为 T 的一次性闭包,即 FnOnce() -> T 部分 • F 必须是一个 Send,即:F 跨线程 move 是线程安全的 • F 必须是一个 'static 类型,即:F 不得含有任何外部引用('static 引用除外)下图是对以上三部分声明的详细解释,我们将在接下来的章节逐一解释这三个约束。

  1. 闭包的类型约束:FnOnce() -> T

我们先来看 F 的第一个类型约束:FnOnce() -> T,它明确地告诉了我们 spawn 函数需要一个什么样的函数。这里先消除一个误解,spawn 并没说只能接受闭包,FnOnce() -> T 是对函数类型的描述,实际上,我们完全可以将一个函数传给 spawn,就像这样:

<span>use</span> std::thread;

<span>fn</span> <span>main</span>() {
    <span>fn</span> <span>f</span>() { <span>println!</span>(<span>"Hello, world!"</span>); }
    <span>let</span> <span></span><span>handle</span> = thread::<span>spawn</span>(f);
    handle.<span>join</span>().<span>unwrap</span>();
}

只是因为 FnOnce() -> T 限制了函数不可以有参数,这就直接回绝了绝大多数函数以函数指针或函数项的形式传入 spawn 的可能,而闭包则不受此限制,因为闭包可以通过自由变量引入外部值,不必非得使用参数,这就是为什么人们几乎总是给 spawn 传入一个闭包的原因。

很多人在初次看到 F: FnOnce() -> T 时会误认为 spawn 只接受一次性闭包,并认定这是最严格的闭包约束。殊不知,是完全搞反了!实际上,这是最宽松的约束,只要参数和返回值的类型匹配,你可以将任何函数和闭包传给 spawn,不管这个闭包是否捕获自由变量以及捕获的是不可变引用、可变引用还是值,都可以传给 spawn(当然,还有其他的约束会起作用,比如 'static,但这是另一个话题)。如果你对此感到诧异或无法理解,那说明你对 Fn、FnMut、FnOnce 三个 trait 的理解还不够透彻,推荐你阅读《你理解的 Fn、FnMut、FnOnce 可能从一开始就错了...》。

  1. 闭包的类型约束:Send

Send 约束与线程息息相关,最表层的解释是:如果一个类型要 move 进线程中,它必须是 Send 的。因为只有被标记为 Send 的类型才可以安全地在线程间 move。这里有两个更深入的问题需要解释:一个是到底什么样的类型可以被标记为 Send?另一个是为什么要求闭包得是 Send 的?我们先回答第二个问题,要求闭包必须是 Send 的原因是:闭包可能会捕获外部值,编译器会把这个值封装进闭包对应的匿名结构体中,对闭包施加 Send 约束的目的其实是通过自动传导机制把 Send 约束传播到闭包捕获的外部值上,因为此时,闭包是结构体,它捕获的值是结构体的一个字段,对结构体的 Send 约束会自动作用到字段上,也就是说:如果一个闭包捕获了一个不是 Send 的值(比如一个 Rc 实例),那 spawn 会拒绝这个闭包,报类型不匹配错误!

至于到底什么样的类型可以被标记为 Send?这是一个非常复杂的话题,我们已经在《万物皆可多线程?深入解读 Rust 的 Send 和 Sync》一文中把这个问题解释得非常透彻了。解释这个问题通常会用 Rc 类型作为示例来解释,因为 Rc 是一个非常典型的非 Send 类型,但是,要完全理解 Rc 为什么是非 Send 类型又要对共享所有权有清晰的认识,于是,我们又专门撰写了《Rust 所有权进阶必读:为什么 Rc 共享所有权是个伪概念?》来进行解释。

  1. 闭包的类型约束:'static

准确地说,'static 是生命周期约束,但是生命周期约束也是泛型类型约束的一种。我们曾在《困惑是正常的,'static 生命周期约束本来就不简单》一文中系统地介绍过它,而 spawn 闭包的这个 'static 约束是一个非常“妙”的案例,我们曾在《无为而治,Rust 多线程的处世哲学》一文中专门点评过这个 'static 约束: 由于闭包中的代码会在一个新线程中执行,如果闭包捕获了一个外部值的引用,那闭包中的引用和外部值谁先失效就变成了一件不确定的事情。如果是单线程环境,靠所有权和借用检查分析静态代码(它们最后一次使用的地方)是可以确定的,但是在多线程环境中,如果值和引用分属于两个线程中,那谁先失效确实是无法预判的,一旦另一个线程中的值先于当前线程中的引用失效,那当前线程中的引用就是“悬垂引用”了。怎么才能避免这种情况呢?spawn 的做法就是:给闭包添加上 'static 约束,禁止闭包依赖外部引用('static 引用除外)。这里顺带解释一个偶尔会有人使用的概念:短期借用、短生命周期引用,其实它们的意思就是:除 'static 引用之外的所有引用,因为每次都说:禁止依赖引用('static 引用除外)会比较麻烦,所以有人把排除掉 'static 引用后的引用类型叫:短期借用或短生命周期引用,这个“短”也不是真的有多短,只是相对于 'static 而言的。 我们前面提到过:闭包会被编译成匿名结构体,它捕获的外部值和引用都在这个结构体中,如果捕获了引用,结构体必须要添加生命周期约束,保证结构体和它捕获的引用的生命周期一致,下面的示例代码分别展示了捕获不可变和可变引用闭包时生成的匿名结构体的样子:

• 捕获不可变引用的闭包

<span>/* 闭包 */</span>

<span>let</span> <span></span><span>x</span> = <span>String</span>::<span>from</span>(<span>"hello"</span>);
<span>let</span> <span></span><span>c</span> = || <span>println!</span>(<span>"{}"</span>, x);

<span>/* 编译器生成的匿名结构体(伪代码)*/</span>

<span>struct</span> <span>Closure</span><<span>'a</span>> {
    x: &<span>'a</span> <span>String</span>, <span>// 字段是引用,带生命周期'a</span>
}

• 捕获可变引用的闭包

<span>/* 闭包 */</span>

<span>let</span> <span>mut </span><span>x</span> = <span>0</span>;
<span>let</span> <span>mut </span><span>c</span> = || x += <span>1</span>;

<span>/* 编译器生成的匿名结构体(伪代码)*/</span>

<span>struct</span> <span>Closure</span><<span>'a</span>> {
    x: &<span>'a</span> <span>mut</span> <span>i32</span>, <span>// 字段是可变引用</span>
}

不管是捕获不可变引用的闭包还是可变引用的闭包,因为它们捕获了引用,所以结构体必须添加生命周期标注以确保它和被捕获引用的生命周期一致!但是,现在,由于多线程导致的不确定性,spawn 给闭包添加上了 'static 约束,实际上也就是给这个结构体添加上了 'static 约束,这个约束会自动传导给闭包中的每一个字段,那就意味着:结构体不得含有引用类型('static 引用除外),考虑到并不是什么值都能借出 'static 引用,所以,实际上,这一“招”直接把闭包捕获外部引用的路给“堵死”了!但是,闭包总是需要从环境中获得类似参数性质的数据,如果不允许捕获引用,那唯一可行的做法就是:把所有依赖的引用的值全部 move 进闭包!

为什么把依赖到的引用的值 move 进闭包后,就不违反 'static 约束了呢?因为移动进闭包里之后,闭包捕获的就是值了,不再依赖任何引用,没有了引用,'static 约束就等于被“架空”了。下面的代码演示了这种关系:

<span>/* 闭包 */</span>

<span>let</span> <span></span><span>x</span> = <span>String</span>::<span>from</span>(<span>"hello"</span>);
<span>let</span> <span></span><span>c</span> = <span>move</span> || <span>println!</span>(<span>"{}"</span>, x);

<span>/* 编译器生成的匿名结构体(伪代码)*/</span>

<span>struct</span> <span>Closure</span> {
    x: <span>String</span>, <span>// 字段是完整的值!不是引用。所有权 move 进结构体</span>
}

至此,spawn 闭包的类型约束: F: FnOnce() -> T + Send + 'static 就全部介绍完了,最后,我们再来总结一下 spawn 到底需要什么样的闭包:

① FnOnce 型闭包(注意:Fn 继承自 FnOnce,无任何外部依赖的 Fn 闭包或函数也满足条件);

② 不得依赖任何非 'static 引用;

③ 若确实需要外部依赖,可使用 move 关键字将依赖的值直接移动到闭包内,且值必须是 Send 的