2023 年也是充实的一年。
全民制作人们大家好,又到了一年一度的汇报时间。
年初做了一件比较重要的事情,就是账号更名,主要涉及到以下几个平台:
这其中涉及到几个小问题,为了不给大家造成困扰,我简单再做下解释。
2016 年,我注册了一个名为“Kotlin”的微信公众号,用来发布 Kotlin 的推广视频。2020 年前后,JetBrains 开始在国内投入专人负责社区的建设和运营,微信公众号 “Kotlin” 则主要用于转发官方文章。当时我的主要精力 B 站账号的建设上,输出形式也以视频为主。2023 年,有一些朋友问我为什么视频不发公众号,因为我很少会将与 Kotlin 无关的视频同步到微信公众号 “Kotlin” 上。
思考再三,我跟 JetBrains 的朋友提了一下我的想法:我们可能需要一个真正意义上的 Kotlin 官方号。这就是我的所有账号更名的由来。
如果大家希望持续关注 Kotlin 的发展,强烈建议大家关注官方微信公众号 Kotlin 开发者。
如果你平常也喜欢在 Bilibili 上看视频,也建议关注 JetBrains 的官方账号 JetBrains中国:
我个人也会持续关注 Kotlin 的发展,并且在 Kotlin 发布新版本之后,也会以视频的形式为大家介绍其中的新特性。
我最初确实想统一成 “bennyhuo 不是算命的”,但微信公众号的名称中禁止出现“算命”,因此只好再选一个名字。想来想去,干脆用自己的真名好了,省事儿。
顺便提一句,“bennyhuo” 是我在腾讯打工的时候的“代号”。
今年在 B 站、YouTube、抖音、微信公众号同步发视频,欢迎在你喜欢的平台搜索:霍丙乾 bennyhuo 并持续关注。
其中,B 站仍然是我视频发布的最主要的平台。2023 年全年发布视频 127 个,总计播放 60 万次。其中,所有视频累计播放在 6 月份突破了 100 万次,单稿播放最高纪录靠鸿蒙刷到了 7 万+。订阅量从年初的 17500 左右增长到接近 23500,平均每天净增长 15,谢谢大家的关注和一键三连。
2023 年的视频仍然以读书视频为主。
此外,还有一些编程语言版本新特性的视频:
年底的时候新开鸿蒙的坑,共计发布视频 6 个:
2024 年计划将《Rust 程序设计语言》按照现在的形式继续更完。而《现代 C++ 特性解析》《Java 核心技术》就不再按照现有的形式继续读下去了,因为这几本书的内容都很多,想要更完并不容易。C++ 和 Java 相关的视频将会参考这些书籍,以一种更加紧凑的形式更新下去。至于为什么要坚持更完 Rust 那本,显然,它最简单,内容也最少。
后面会有鸿蒙开发教程吗?答案是:不会。成体系的内容做起来费时费力,时间精力跟不上。不过,不出意外的话,我大概率会在 2024 年投入很大的精力参与鸿蒙 NEXT 的适配工作,因此遇到一些有意思的内容还是会不断与大家分享的。
还会开新坑吗?答案是:可能会有,但可能不会有类似于 Rust 读书这种成体系的视频了。因为我自己的想法天天变,也实在是不太想被自己挖的坑束缚了。
其实核心就一个:更新视频是一件令人开心的事儿,它不能成为我的负担。
今年文章写得不多,笑死,因为根本就没写。
好消息是 《深入实践 Kotlin 元编程》 在 2023 年 8 月出版了。这是我的第二本书,内容有一定的难度,适合有进阶需求的 Kotlin 开发者。
今年的社区活动,一共有八次,分享了六个不同的主题。其中线上活动五次,线下活动三次。顺便提一句,12 月参加完天津 GDG 的活动之后自驾去海边吹冷风,特别爽。
在天津 GDG 的活动上,有位现场的朋友提问,K2 编译器中还会有 PSI 吗?我当时一时没有反应过来,就说回去查证一下再来答复,结果忘了留他的联系方式,只好在这里做一下回答。
其实 FIR 的所有语法节点都有一个 source 的字段用来获取原始的 PSI,如果我们想要分析代码的注释,还是需要通过 PSI 来获取的。所以 PSI 不能说完全去掉了,只是被降级了。
2023 年在开源项目上的投入不大,主要还是以维护之前的项目为主。
其中新开源项目 kanyun-inc/Kudos,解决了 Gson 等 JSON 框架在反序列化时不支持 Kotlin 类型空安全、构造器参数默认值等问题。
最近在适配鸿蒙时,还尝试为 Ktor 提供了鸿蒙的 Client 实现,参见:kotlin-for-ohos/ktor。
全年的统计数据可以参考:
知识星球试运营一年,发布了一些会员视频,包括:
之前想开个新栏目,会员朋友们建议更新一些 LeetCode 的刷题视频。结果因为年底事儿太多,一直拖更,隔壁 AB 老师都已经更到停更了,我还没开始。这个专栏争取要在 2024 年更起来。
感谢各位星球会员朋友们的支持。
十多年前,我刚开始学习编程的时候,网传的各类视频教程给予了我非常大的帮助。我并不是天赋型选手,花了很长时间才学会如何通过翻阅文档和源码来学习新知识。正因为如此,我觉得技术分享是非常有意义的,对于一部分人来说会有很大的帮助。
当然,做技术分享也是有很多好处的,我自己也非常乐于分享。
因此,我也非常建议大家尝试一下做一些分享,这仍然是一个自媒体的时代,镜头和舞台可以给到每一个人。
我看视频评论的时候遇到好几次,“听声音好熟悉,跟慕课网讲 C 语言的老师是同一个人吗” 的疑问。其实之前我在慕课网发布过几门课程,不过因为个人精力和身体原因,我已经决定不再慕课网继续发布新课了。
说起来,今年还花时间更新了一下 C 语言课,优化了一下视频的内容,增加了常见问题的答疑,之前学习过这门课的朋友可以回去看一下更新的内容是否有帮助。
如果有朋友有兴趣在慕课网做讲师,我可以帮忙推荐给慕课网。
说回 B 站,我在 2023 年把视频的收益关掉了,因为实在太少了,指望靠这个买 mac studio,我估计差不多都可以买到 M10 MAX 了。
细心的朋友们可能还会发现,我的视频里面几乎都没有明示或暗示过大家投币三连,早期还有一个统一的求关注的结尾,后来我也干脆去掉了。
我希望被更多人关注吗?当然。
我希望我的视频播放量上涨吗?当然。
不过,我更希望我的视频纯粹一些。因此我应该不会在视频里面添加视频内容无关的引导。希望大家通过我的视频有所收获,大家投币点赞也是因为觉得这些视频值得。
至于后面怎么恰饭,如果真的到了需要向生活低头的时候,我大概会把我的知识星球发出来。
为了方便交流,我也拉了一些微信群。熟悉的朋友们知道,我一直坚持要求大家在群里做技术交流,不要高屋建瓴的关心世界,更不要输出负能量。
为什么呢?因为我们的眼界有限,很多见解自以为精妙绝伦,实际上无比稚嫩;退一步讲,就算我们能讨论出有建设性的意见,我们又有什么渠道能够让意见落实呢?更多的,往往是群友们互相断章取义,发展到对人口诛笔伐,最终搞得群里乌烟瘴气。
技术群是为了交流技术,哪怕交流如何跟无良领导作斗争我觉得都可以接受,我甚至会帮忙出出主意,但如果只是想要宣泄不满,建议换个地方。我们都难免遭遇不公,也都难免经历委屈,除非你成为强者,否则谁又愿意听你哭诉呢?
先想办法成为强者,再想怎么改变世界。
希望大家在 2024 年身体健康,工作顺心。
2024 继续加油。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
类型推断是现代编程语言必备的能力,我们现在很少能够看到不支持类型推断的主流编程语言了。当然,C 语言是个例外。
类型推断就是编译器根据上下文信息对类型进行推算的能力。类型推断是一个极其复杂的话题,从工程应用的角度而言,我们不用过多关注其背后的数学原理。为了方便讨论,我们将类型的推断分为变量类型推断和泛型类型推断。
变量的类型推断就是在变量声明时省略类型,编译器通过变量的初始化来推断其类型。
变量类型推断最典型的例子莫过于 C++ 当中的这个场景了:
1 | std::vector<std::map<std::string, std::vector<int>>> values; |
请注意,for 循环中的 i 的类型非常长,写起来繁琐之外,还很难写对这一度让 C++ 的开发者极度难受。不过,从 C++ 11 开始,类型推断的引入让事情变得简单了起来:
1 | for (auto i = values.begin(); i < values.end(); ++i) { |
i 的类型使用 auto
关键字替代,这样编译器就会根据 i 的初始化表达式 values.begin()
的类型推断出来。
Java 从 Java 10 开始新增了 var
关键字来简化变量定义时的类型。
例如:
1 | var list = new ArrayList<String>(); |
这里的 var
相当于 ArrayList<String>
。
var
只能用于局部变量的定义,不能用于类成员的定义,这一点与 C++ 的 auto
非常相似。
说明 在 Java 正式支持
var
关键字之前,著名的元编程框架 Lombok 就通过编译时修改 Java 语法树为 Java 添加了var
关键字的支持,有兴趣的读者可以参考 Lombok 的官方文档:https://projectlombok.org/features/var。
C++ 和 Java 的变量声明中类型都在变量名前面,通常又被称为类型前置的形式。这类语言的特点是在语言诞生之初并没有类型推断的语法设计。
随着开发者对类型推断的需求的日益增长,业界编程语言设计的优秀实践的不断积累,越来越多的新语言选择了类型后置的形式。
下面是 Kotlin 的变量定义语法,类型后置的形式使得类型推断变得非常自然:
1 | // 完整的变量定义 |
变量 s
的类型可以通过初始化的表达式推断出来,因此可以省略。常见的采用类型后置的语法设计的语言还包括 Scala、Swift、TypeScript、Rust 等等。
绝大多数编程语言在对变量的类型进行推断时,都只对变量定义时的初始化表达式做了分析,Rust 就是个例外。
1 | let s; |
Rust 允许先把变量定义出来,在后面根据对该变量的使用情况进行变量类型的推断。示例代码中变量 s
在定义时并没有声明类型,也没有进行初始化,Rust 编译器通过分析后面对 s
的赋值,推断出 s
的类型是 &str
。这在 Kotlin 当中是不行的。
Rust 编译器通过上下文推断类型的能力在下面的例子当中用处更大。
作为对比,我们先给出 Kotlin 版本的写法:
1 | val multiply2 = { i: Int -> i * 2 } |
在这段 Kotlin 代码中,Lambda 表达式 multiply2
的参数 i
的类型必须显式地写出来,不然编译器就无法推断出 multiply2
的类型了。
接下来我们看一下等价的 Rust 代码:
1 | let multiply2 = |i| i * 2; |
注意 |i|
是 Rust 的 Lambda 表达式(或者闭包)的参数列表,我们发现 i
的类型并不需要明确地写出来,编译器通过分析后面的实参 10
即可推断出 i
的类型为 i32
了。
multiply2
的例子还可以继续延伸。不管是 Kotlin 还是 Rust,multiply2
都是一个确定的类型,也就是说在上述代码之后追加一句 multiply2(30.0)
,编译器就会抱怨说 30.0
是 Double
(Kotlin)/ f64
(Rust) 类型 ,而 multiply2
需要的是 Int
(Kotlin)/i32
(Rust)类型。不过,事情总有例外。
下面是使用 C++ 编写的等价代码:
1 | auto multiply2 = [](auto i) { return i * 2; }; |
multiply2
的参数 i
的类型是 auto
,它自身的类型也是 auto
,这意味着它们的类型需要编译器来推断。接下来我们分别把 10
和 30.0
传给 multiply2
,然后我们就会发现,这都是合法的。这表明 multiply2
针对不同的类型会有不同的实现。对于 C++ 而言,auto
不仅仅是用于类型推断的关键字,很多时候我们把它当做模板的一种特殊形式来看待,似乎更容易理解。
既然提到了 Lambda 表达式的类型推断,那么我们能不能用 var
来定义 Lambda 表达式呢?答案当然是,不能。
1 | var multiply2 = (int i) -> i * 2; |
如果我们在 Java 中试图使用 var
来定义一个变量,并使用 Lambda 表达式来初始化,就会得到上面的错误。不过,这个错误并不是 var
的问题,而是 Java 对函数类型的支持问题。这个话题我们将在后面的文章中详细探讨,这里就不再展开说明了。
分支表达式在现代编程语言中非常常见。C 语言甚至就已经有了分支表达式:
1 | int a = ...; |
没错,?:
可能是最古老的分支表达式之一。
Java 当中除了 ?:
表达式以外,还从 Java 12 开始支持了 Switch 表达式(Java 14 正式支持) ,因此 Java 中的表达式类型推断也是值得探讨的内容。
1 | var x = "..."; |
在这个 switch 表达式中,四个分支表达式的值类型分别为 int
(Integer
)、double
(Double
)、String
、ArrayList<String>
。这意味着整体表达式的返回值 y
的类型只能是其中的一个,从数学的角度来讲,y
的类型为这四种类型的交集,Java 的类型系统中也确实存在交集类型的概念,即:
1 | Integer & Double & String & ArrayList<String> |
交集类型的计算结果其实就是这些类型的公共父类,因此 y
在编译时的类型为 Serializable
。
如果没有公共父类呢?这在 Java 当中是不可能的,因为所有的类型都至少有一个公共父类是 Object
。
顺便提一句,Kotlin 的推断方法也是类似的。作为对比,我们给出 Rust 的代码:
1 | let x = "Hello"; |
Rust 编译器在遇到各个分支的类型不兼容的情况时,会直接报错。实际上,C++ 的行为也是类似的。
为什么会有这样的差别呢?
我稍微做一下猜测,供大家参考。Java 和 Kotlin 的对象都是分配在堆内存上的,栈内存上只需保留一个引用,而这个引用的类型不管是什么,占用的内存大小都是固定的,因此在做分支表达式的类型推断时可以尽可能向开发者友好的方向设计。而 C++ 和 Rust 的编译器需要在编译时确定 y
的类型,以便于给他在栈内存上分配内存,因此遇到不兼容的类型时就只好拒绝编译了。
除了对变量的类型进行推断以外,还有对泛型类型的推断。
我们还是以 ArrayList
为例,在 Java 7 之前的版本,我们需要完整的将类型写出来:
1 | ArrayList<String> list = new ArrayList<String>(); |
从 Java 7 开始,编译器稍微为我们做一点简化,允许我们把初始化表达式中的泛型参数省略掉了:
1 | ArrayList<String> list = new ArrayList<>(); |
理由也很简单,变量的类型已经明确,后面的泛型参数 String
显然是冗余的。
不得不说,这一点 Java 做得比 C# 似乎更好一些,在 C# 中定义一个类似的 List
时必须完整的写出泛型参数。如果省略泛型参数,那么编译器就会报告如下错误:
1 | List<string> list = new List<>(); |
当然,C# 的设计者可能觉得这里使用 var
会更好(就像 Java 10 之后那样)。
定义在方法中的泛型参数也支持类型推断,例如:
1 | public static <T> T identity(T t) { |
identity
在调用时,泛型参数 T
可以通过函数参数 t
推断出来,因此无须显式写出:
1 | String value = identity("Hello"); |
这个特性还有一个更为常见的使用场景:
1 | public static <T> T fromJson(String json, Class<T> cls) { |
注意到 Class
的泛型参数是 fromJson
的泛型参数 T
,因此可以通过 cls
的实参类型来推断 T
的类型。例如:
1 | User user = fromJson("{}", User.class); |
你可能会想,竟然有了泛型参数 T
,我们是不是可以直接使用 T.class
而不用向 fromJson
中传入 Class<T>
了呢?当然不能,这是因为 Java 的泛型会在编译时擦除,也就是说 T
在运行时并不存在。
像绝大多数编程语言一样,Java 也可以通过方法的返回值类型来推断泛型参数,例如:
1 | public static <T> T get(String key) { |
调用时,如果返回值类型已经明确,则无须显式指定泛型参数。不过,C# 却不支持通过返回值类型来推断泛型参数,例如:
1 | public static T get<T>(String key) { |
本文从变量类型和泛型参数类型的推断两方面对 Java 的相关特性进行了介绍。为了方便读者对类型推断有更全面的认识,我们也列举了其他编程语言的相关特性作为参照。
综合来看,Java 在类型推断方面做得中规中矩,虽然没有像常见的现代编程语言那样能够做到极致,但也能够应对绝大多数的场景了。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
2014 年初 Java 8 就发布了,Java 迎来了其历史上继 5 之后的第二次史诗级更新。我们不妨列举一下 Java 5 和 Java 8 的主要特性来感受一下更新的力度。
Java 5 的主要更新:
Java 8 的主要更新:
简单来说,Java 5 的发布使得 Java 的元编程能力和并发能力得到了极大的提升,而 Java 8 的发布又为 Java 带来全新的函数式编程能力,让 Java 再度焕发活力。
从 Java 9 开始,Java 的版本更新改为半年一次,LTS 版本则是三年一次。从 Java 17 开始,LTS 版本又改成两年一次。
非 LTS 版本的生命周期只有半年,而 LTS 的生命周期长达 8 年甚至更久。了解 Java 的 LTS 版本是有意义的,一般而言,我们在生产环境中应尽量使用更加有保障的 LTS 版本,以避免频繁地更改生产环境。
说明:事实上,Java 版本的升级除了语法特性的变化以外,还包含了运行环境及 Java 虚拟机的变化。相比之下,Java 虚拟机的变化对于生产环境的调优带来的影响更大、更抽象,这可能是 Java 开发者不愿意升级版本的一个最为重要原因。
Java 的近几个 LTS 版本如下表所示:
版本号 | 发布时间 | 结束支持时间 | 核心特性 |
---|---|---|---|
Java 8 | 2014.3 | 2030.12 | 函数式 |
Java 11 | 2018.9 | 2026.9 | 局部变量类型推导 |
Java 17 | 2021.9 | 2029.9 | 模式匹配(Pattern Match) |
Java 21 | 2023.9 | 2031.9 | 虚拟线程(Virtual Thread) |
需要注意的是,这里提及的核心特性也可能是该 LTS 版本之前的非 LTS 版本中引入的。例如,局部变量类型推导指使用 var 来定义变量,变量的类型通过初始化表达式来推导。该特性在 Java 10 引入,Java 11 中只是对 Lambda 表达式的参数做了支持。
从语法特性的角度来看,Java 17 是非常有诱惑力的。不过,相较难以量化的开发效率上的提升,架构师们往往更看重程序运行效率的提升。我们有理由相信,携带了虚拟线程和分代 ZGC 的 Java 21 将会是一个更加有说服力的版本。
Java 最近几年的更新无疑是令人兴奋和激动的。尽管比起它的最有力的竞争者 Kotlin 而言,Java 在语法特性上仍显得非常保守,但看看 C++ 近几年的更新就可以知道,语言特性的稳健何尝又不是对现存的 Java 开发者的一种友好呢?
接下来,我准备写一系列文章来介绍一下 Java 8 以来的重要语法特性更新,希望可以在一定程度上帮助大家了解现代 Java 的语法特性的发展。
当然,需要特别说明的是,尽管本系列文章的主角是 Java,但行文之处力求把涉及到的问题解释清楚,难免会经常横向对比其他常见的编程语言,读者如果对涉及到的其他语言不熟悉,只需大致了解即可,无须深究。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
一般这么写的,都不算精通。
朋友们好,我是 bennyhuo。
大家肯定见过写着 “精通 XXX 语言” 的简历,我在过去几年的工作经历中就见过精通 Java、精通 C++ 甚至精通 Kotlin 的简历,这在几年前尤其流行。
当然,现在大家稍微做一些功课就会看到“千万不要在简历中写精通 XXX” 之类的警告,不知道大家有没有想过,为什么写精通就是不行呢?
要搞清楚这个问题,先得搞清楚什么是精通。
精通: 透彻通晓。
晋 左思 《魏都赋》:“硕画精通,目无匪制,推锋积纪,鋩气弥鋭。” 唐 李咸用 《赠陈望尧》诗:“若説精通事艺长,词人争及孝廉郎。”《醒世恒言·刘小官雌雄兄弟》:“不过数月,经书词翰,无不精通。”如:他精通英语。
这个解释非常清楚,但有一个很麻烦的问题,所谓精通其实是一个偏主观感性的描述,而不是一个可以量化的标准。
我举个例子,汉语是我的母语,我虽然天天说汉语,但我如果说我精通汉语,肯定有很多朋友会觉得“你不是在开玩笑吧”,但如果这话我跟火星人说,他们一定觉得没毛病。
“难以量化”就会让表述的结果非常主观,考虑到我们的文化背景讲究谦虚谨慎,于是说自己精通 Kotlin,精通 Java,就让人感觉这位候选人有些自大。
从我的面试筛简历的经历来看,写精通 Kotlin、精通 Java 的候选人,无非是希望证明自己对 Kotlin 或者 Java 的掌握比较不错,能够胜任这份工作。
换个简单的说法,就是希望通过写自己精通 Kotlin 来证明自己会 Kotlin。是不是听起来有些不对劲?这不是典型的循环论证吗?就好像说,bennyhuo 的视频很不错,因为很有趣一样,本质上说的是一件事儿,而没有给出具体的论据。bennyhuo 的视频很不错,因为他的视频一不带节奏,二不贩卖焦虑,三我编不下去了。
总之就是要给出具体的论据来证明你的结论,而不是用一个结论来证明另一个结论,或者用一个主观的描述来证明一个客观的情况。事实上,一个无法量化的描述其实无法证明任何结论。
这道理很难懂吗?当然不是。那为什么前几年那么多人喜欢这么写呢?一方面,互联网快速发展的早期,招聘单位招人很多时候就是为了储备人才,筛选标准方面确实存在比较宽松的情况,这使得敢于自信地在简历上说自己精通 XXX 的候选人确实更容易蒙混过关;另一方面,可能也确实存在一些候选人没有能够拿得出手的项目,就是没得写,只好说自己擅长什么,熟练什么,精通什么了。
精通二字在简历中成为禁忌,除了因为这个词表述的内容难于量化以外,还有一个重要原因就是很多候选人其实甚至连普遍意义上的熟练掌握都达不到,就敢写自己精通。
举个例子,我曾经在面试过程中碰到过说自己精通 Java 的候选人,于是我问他 final 关键字有什么作用,他说修饰变量不可变,修饰类不能被继承,修饰方法不能被覆写。非常标准的八股答案是吧。
但作为一个精通 Java 的开发者,我们的回答必须融入自己的理解。修饰的变量不可变,那么
这些都可以聊,这绝对不是网上随便搜到的八股题目所能够涵盖的,也最能体现自己的水平。
这样的例子太多了,于是最后”精通“二字与狼来了一样,让人看到只能联想到一个字:
当然,也不排除确实有些候选人真的精通某一项技术。
讲个小故事。我之前打算从腾讯出来,找工作的时候有个面试官看了我的简历准备聊点儿 Kotlin 协程的内容,结果他犹豫了,他欲言又止最后来了一句,Kotlin 协程方面应该没啥好问的了,看你还出了一门 C 语言的课程,咱们要不聊聊 C 吧。为啥会这样呢?因为那会儿是 21 年 3 月,他看《深入理解 Kotlin 协程》这本书已经写完快一年了,而 C 语言的课程刚上线几个月,估计我已经把协程快忘光了,就别问了吧,给点儿面子。
哈哈,开个玩笑。程序员的圈子里面,高手是非常多的,总有大佬是真的精通某些技术的,人家要写简历会怎么证明自己精通呢?
在讨论这个问题之前,我想先跟大家聊一下“武松打虎”的故事。武松上山前在店里吃酒,店小二说山上有只老虎非常凶狠,已经伤了多人性命,晚上千万不能上山。结果武松不听劝阻,,执意上山。作者又在武松与老虎打斗时着重描述了老虎是如何如何厉害,却很少提及武松精通什么功法。结果呢?老虎被武松打死了,武松的武力值大家瞬间就能理解,无需多言。
类似地,如果需要证明你精通什么技术,你需要列举你做过的相关的具体事情,例如想要证明自己精通 Kotlin,那就说说自己使用 Kotlin 做过哪些公司项目,做过哪些与 Kotlin 语言强有关的技术专项,参与过哪些开源的 Kotlin 项目,或者给 Kotlin 官方提过 PR 等等。通过介绍这些项目的细节,来展示你对 Kotlin 的掌握程度,让面试官自己推断出你“精通 Kotlin”的结论,而不是自己把结论说出来。
同样是那次出来找工作,有一个面试官对编译器比较感兴趣,于是问我有没有研究过 Kotlin 的编译器,我说这块儿还涉猎的比较少。不过现在不一样了,这段时间我在 GitHub 上开源了好几个 Kotlin 元编程相关的项目:
不仅如此,经过两年的努力,我又写了一本书:《深入实践 Kotlin 元编程》。
这本书的内容可以说我是躺在 Kotlin 和 Java 编译器的源码上写完的。如果几年后有机会再出去面试,不管是问我 Java、Kotlin 的反射的工作机制,还是 APT 和 KSP 的实现细节,甚至 Kotlin 编译器插件的内部原理,我都能说得头头是道。
既然如此,我觉得我在简历上写个熟练掌握 Kotlin 应该问题不大,不过显然让面试官自己得出结论效果会更好,而我只会说:
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
想法很多,需要慢慢花时间去落实。
朋友们好,我是 bennyhuo。
这篇文章我准备聊聊接下来我想做的一些事情,让关注我的朋友们能够大概知道我在公众号、B站等平台接下来会输出哪些方面的内容。
内容选题会尽量专注于编程语言特性。
首先,最重要的就是 Kotlin 相关的专题内容。目前在考虑中的一个专题是 Kotlin 与 Swift 核心特性的对比和分析。Kotlin 方向的内容我会花比较多的时间研究,内容也会偏向于进阶。
其次,我会对 Java、C++ 的新特性保持关注,除了更新《Java 核心技术》的读书视频以外,目前计划中有一个专题是 Java 18-21 新特性介绍(Java 17 的新特性专题文章已经发布)。相比之下,C++ 的内容做起来会更耗费精力,毕竟 C++ 比 Java 的新特性更多,也更复杂,因此暂时会优先考虑把《现代 C++ 核心特性解析》的读书视频做完。
此外,《Rust 官方电子书》和 《Effective Python》 的读书视频也在持续更新。
暂时没有开新坑的计划,所以大家可以踏实地等我更新这些视频。
前面其实已经提到了,内容的形式主要分为文章和视频。我会优先考虑视频的更新,尽量保证两天一更,不过,可能每个月会休息几天。在不适合录制视频的情况下(比如嗓子不舒服的时候),我会抽空撰写专题文章,所以文章的发布频率可能会相对低一些。
以上都是公开的内容。知识星球也会每月发布一期会员视频,会员视频的内容以学习思考、工作方法之类的方向为主,与公开的内容有一定的定位差异。我也在考虑将一部分专题文章放到知识星球作为会员专题文章,不过暂时还没有确定具体操作形式。
在催更微信群的朋友应该知道我最近这几天一直在打磨批量发布视频的工具。这个工具目前已经支持了将视频批量发布到 B站、YouTube、抖音、微信公众号(也会同步到视频号)四个平台,因此后续的视频发布终于可以轻松实现多平台同时发布了。
不管你习惯使用哪个平台,你都可以通过搜索 “霍丙乾 bennyhuo” 找到我的账号,收看我的视频内容。近期,我会陆续将存量的视频发布到抖音和微信公众号上,尽快与 B 站和 YouTube 逐步实现同步。
文章则会按照优先级在我的个人网站(https://www.bennyhuo.com)、微信公众号和掘金发布。
我不是计算机专业毕业的,我学到的很多计算机相关的知识都来自于互联网上各位前辈的无私奉献。其中对我影响最大的是 oeasy 老师,他现在还在 B 站持续发布新的视频教程来帮助大家。我一直觉得能坚持做这样一件事实在是太酷了。
我希望把事情做得纯粹一些,因此你不会在我的公众号看到 “几年心血总结出这篇八股文”、“阿里 P8 教你面试” 之类的广告,也不会在我的视频里面看到我明示大家投币三连求关注的转场动画。
希望我现在发布的内容也能对大家有所帮助。十几年后说起来,有过这么个人曾经影响过你。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
微信公众号 “Kotlin” 即将更名为 “霍丙乾 bennyhuo”,作为我的个人公众号为大家分享技术内容。
过去的几年里,我一直专注于研究和推广 Kotlin。从 2016 年开始,我注册了微信公众号 “Kotlin”,用来推送我录制的 Kotlin 入门视频,并且在这个号上面定期发布 Kotlin 相关的技术文章以及组织的各类活动。
最初,JetBrains 没有在国内投入专门的资源来推广 Kotlin。后来圣佑加入 Kotlin,成为 JetBrains 唯一的中文技术布道师,国内的活动、社区组织也才慢慢走上正轨。同时,在 Google 的助力下,Kotlin 成为大家熟知的现代编程语言,愿意学习 Kotlin 并且愿意输出 Kotlin 内容的开发者也越来越多。
2020 年写完书之后,我开始研究一些与 Kotlin 关联不大的内容,顺便出了一门 C 语言课。这门课的同学经常催我出一门 C++ 的课,于是我开始在 B 站发 C++ 视频,还写了一系列“渡劫 C++ 协程”的文章。随着我涉猎的内容方向就越来越广泛,这些内容与微信公众号 “Kotlin” 的名字产生了比较大的违和感。
经过两年时间的探索和思考,我终于下定决心做出一些改变。我向 JetBrains 提了一下我的想法,得到了他们的赞同,于是现在我们终于有了官方的 Kotlin 微信公众号啦!如果大家希望关注 Kotlin 官方的动态,请一定关注官方公众号 “Kotlin开发者”。
至此,以我个人名义注册的微信公众号 “Kotlin” 已经完成了它的使命。接下来,它将作为我的个人公众号,继续向关注我的朋友们推送我的个人思考和技术分享。
当然!
毫不客气甚至有些骄傲地说,所有编程语言里面,我最擅长的就是 Kotlin。我会持续关注 Kotlin 的最新动态和发展方向,也会与 JetBrains 的朋友们保持联系,在需要的时候参与 Kotlin 相关的活动。
今年,我还将出版一本 Kotlin 的新书,书名暂定为《深入实践 Kotlin 元编程》,内容主要包括注解处理器、编译器插件等。这些内容基本上是我通过反复阅读 Kotlin 编译器、Jetpack Compose 等项目的源码整理出来的。与《深入理解 Kotlin 协程》类似,这本书的内容对于知识的剖析较为深入,阅读起来有一定的挑战性,相信一定能够给读者带来一些启发。届时,公众号也会推送相关的内容作为补充,方便读者了解其中的细节。
哦对了,我还是一名 Kotlin GDE,组织上也会要求我们定期输出相关的视频、文章。
除了 Kotlin 相关的内容以外,我也会输出一些 C/C++、Java、JavaScript 甚至 Rust 相关的内容。具体什么内容可能取决于我那一段时间的兴趣和投入。
之前有朋友跟我说,其实这些跟 Kotlin 都有关系,因为 Kotlin 要支持多平台嘛。
目前我输出内容的形式主要是文章和视频,在公众号更名之后,发布的平台主要是以下几个:
当然,也欢迎大家收藏我的个人主页和 GitHub:
感谢大家一如既往的支持。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
也还是闲不住的一年呢。
一年又过去了。这一年也还是做了很多事情的。
今年继续在 B 站发视频,欢迎关注:bennyhuo 不是算命的。
相比去年(2021 年),今年的视频大多数是读书视频。
Kotlin 版本更新的视频:
还有一些散装的视频,比如“神奇的 C 语言”系列,说起来 C 语言的视频播放量是真的高。
订阅量从年初的 5900 左右增长到接近 17500,基本达到了我年初的预期(12000),谢谢大家的关注和一键三连。不瞒各位说,我当时还定了一个挑战目标:18000,还真是恰到好处的差一点儿。
2023 年的想法,未更新完的读书视频当中,这几本是确定要更完的:
此外应该也会有一些 Java 相关的视频。
小伙伴们问得最多的是 Flutter 不更了吗,Compose 不更了吗?统一回复:对,不更了。
今年文章写得不多,也就两个合集:
尽管写完《深入理解 Kotlin 协程》之后我就对协程这个话题比较麻木了,不过对于其他语言的协程我还是很好奇的。C++ 和 Swift 的协程的实现与 Kotlin 协程有很多相似之处,有兴趣的朋友不妨读一下这几篇文章了解一下。
今年也在写书。“深入实践 Kotlin 元编程”(暂定书名)这本书目前底稿已经全部完成,我目前正在逐字逐句做校对,预计还需要一个多月才能全部交付到编辑侧完成第一遍审稿。参考《深入理解 Kotlin 协程》的时间,2023 年内出版是没什么问题的。
这本书的内容大概涵盖了 Kotlin 开发实践当中另一个难点。大家常听说的 APT、KSP、KCP 在这本书当中都有详细介绍。如果大家想要提前了解书的内容,可以看一下我的 GitHub 2022 年的提交记录。经常有朋友我问有没有好的 KSP 或者 KCP 的示例代码,其实我近一年维护的所有项目基本上都与之相关。
写书是一件苦差事,每次写书的时候都在心里说写完这本就再也不写了。也许明年可以尝试写一些小册子,搞轻松一些。“渡劫 C++ 协程” 这种我觉得就挺好。
今年的社区活动,一共有三次,其中还包括一次线下活动,真是不容易。
2022.7 Kotlin 炉边漫谈 Podcast 第二期:“Kotlin 炉边漫谈” 是 Kotlin 社区推出的一款节目,每期都会邀请一些有趣的嘉宾参与闲聊。据说我是那个最不会聊天的。
2022.9.1 使用 Kotlin 多平台特性统一 JS 调用 Native 函数的体验:这期分享主要介绍了 Klue 这个项目的实现思路和细节。Klue 是 Kotlin 多平台特性的一个很好的实践场景。这个项目明年应该会继续探索下去,今年之所以搁浅,实在是因为精力有限。
2022.10.30 小猿口算 Android 项目的优化实践:这期分享主要介绍了我在猿辅导公司小猿口算团队与团队其他同学一起做的各方面的工程优化。能把有趣的技术应用到公司项目当中,也是一件快事。
2022 年还有一些开源项目在维护。比较重要的就是下面的几个:
今年因为写书,一直在做 Kotlin 编译器相关的尝试。明年的重心可能会放到 Klue 上面。
欢迎大家在 B 站关注我:bennyhuo 不是算命的。
也欢迎大家关注微信公众号:bennyhuo。
2023 继续加油。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
渡劫 C++ 协程系列文章本来不在我的计划范围内。
相较于我常用的几门语言(可能是 Kotlin、Java、Python、JavaScript)来讲,我对 C++ 的熟悉程度并不是特别高。因此尽管群里的小伙伴们经常提议讲点儿 C++ 的内容(都出了一门 C 语言课了,还讲不了 C++?),考虑到精力和经验有限,我都拒绝了。
不过,人生就是那么奇妙,前不久我刚好有点儿时间,也正遇到群里有小伙伴们提到 C++ 协程,就硬着头皮看了一下午,录了一期视频 协程上手经典案例:实现一个序列生成器。既然有了第一期,那就得有第二期,结果越往后越感觉我这个讲法不太对。
过了一段时间之后,我的闲话 Swift 协程系列文章完结,同时我对 C++ 协程的认知也在不断加深,那么好吧,再写一系列 C++ 协程的文章吧。在学习 Swift 协程时,我可以翻阅 Swift 的语言设计文档以及源码,整个过程还算轻松,因此称为闲话 Swift 协程;C++ 的情况就不太一样了,设计文档看着令人头疼,标准库的源码又看得我眼花缭乱,于是得名渡劫 C++ 协程。
我在最初做 Kotlin 协程的介绍的时候,很多朋友都在问我协程究竟能做什么,再后来不少读者在发现 Kotlin 协程的默认调度器居然是个线程池,于是就认为 “Kotlin 协程的本质就是个线程池” —— 这真的是让人哭笑不得。
为了让读者能够更加深刻的认识协程,我在《深入理解 Kotlin 协程》 这本书当中花了整整一章的篇幅介绍了常见语言对协程的支持情况,对比彼此之间的差异,甚至用 Kotlin 协程的基本 API 来模拟其他语言的协程特性。
Kotlin 的协程和 C++ 的协程在设计分层上有着惊人的相似之处,二者在标准库当中仅仅提供了为数不多的基本 API,想要将协程运用到业务实践当中还需要有协程框架的支持。C++ 20 已经走出了第一步,这大概相当于 Kotlin 1.1 时的状态。
我当时为了让读者能够深入理解 Kotlin 协程框架的设计,干脆自己动手实现了一个简版的协程框架 CoroutineLite。而渡劫 C++ 协程的核心内容也是在尝试通过自己实现 C++ 的协程框架来深入理解 C++ 协程。按照这个思路,我们还可以继续深入探索,例如实现 Task
的取消,为 Task
添加父子关系以实现结构化并发等等能力,只是受限于时间和精力,我决定暂时停止这一次奇妙的探索历程。
如果想要在生产环境当中使用 C++ 20 提供的协程,我们可能还需要持续关注 C++ 委员会后续对协程的规划和设计。让我们期待将来 C++ 新标准对协程提供更多的支持吧。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
截止目前,我们一直专注于构建基于协程 API 的框架支持,这次我们用这些框架来写个简单的示例,并以此来结束整个系列的内容。
在本文当中,我将使用前文实现好的 Task
来发起一个简单的网络请求。
我会借助一些已有的框架来完成这次的目标:
1 | cpp-httplib/0.10.4 |
这些框架可以通过 conan 很轻松的完成安装。
首先我们给出发起网络请求的核心代码:
1 | // 用协程包装网络请求,请求的处理调度到 std::async 上 |
使用 httplib 来完成网络请求的处理非常简单直接,我们只需要把 url 传入即可。通常我们的网络请求都会在 io 线程当中发起,因此我们将其调度到 AsyncExecutor
上。
接下来,我们再定义一个协程来调用 http_get
:
1 | Task<void, LooperExecutor> test_http() { |
程序运行结果如下:
1 | 22:10:54.046 [Thread-08056] (main.cpp:27) test_http: send request... |
在这个示例当中,我们没有使用协程来解决阻塞的问题,而是将一个异步的请求封装成同步的代码。test_http
当中的代码全程在 Looper 线程当中执行,尽管中间穿插了一个异步网络请求,但这看上去丝毫没有影响程序的连贯性和简洁性。
本文的内容相对轻松,因为我们终于停止了基于协程的基础 API 的探索。
实际上,如果你发现你用到的某些 API 提供了异步回调,你完全可以使用 Awaiter
对其提供 co_await
的支持。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
每次我们新增功能时,都需要修改 TaskPromise 增加对应的 await_transform 函数,这显然是个设计问题。
我们前面在实现无阻塞 sleep 和 Channel 的时候都需要专门实现对应的 Awaiter 类型,并且在 TaskPromise 当中添加相应的 await_transform
函数。增加新类型这没什么问题,但如果每增加一个新功能就要对原有的 TaskPromise
类型做修改,这说明 TaskPromise
的扩展性不够好。
当然,有读者会说,如果我们把所有的 await_transform
函数都去掉,改成给对应的类型实现 operator co_await
来获取 Awaiter(例如 sleep 的例子当中通过 duration 转 Awaiter) 或者干脆就自己就定义成 Awaiter(例如 Channel
当中的 ReadAwaiter
),这样我们就不用总是修改 TaskPromise
了。话虽如此,但完全由外部定义 Awaiter 对象的获取会使得调度器无法被包装正确使用,甚至我们在定义 TaskPromise
的时候把调度器定义成私有成员,因为我们根本不希望外部能够轻易获取到调度器的实例。
使用 await_transform
本质上就是为了保证调度器的正确应用,却带来了扩展上的问题,那这是说 C++ 协程的设计有问题吗?当然也不是。我们完全可以定义一个 Awaiter 类型,外部只需要继承这个 Awaiter 在受限的范围内自定义逻辑,完成自己的需求同时也能保证调度器的调度。
了解了需求背景之后,我们只需要在 TaskPromise
当中定义一个更加通用版本的 await_transform
,来为 Awaiter 提供调度器:
1 | template<typename ResultType, typename Executor> |
你看得没错,我们真的只是给这个通用的 Awaiter
添加了当前协程的调度器。
既然 Awaiter
的核心是调度器,我们可以直接给出它的基本定义:
1 | template<typename R> |
作为 Awaiter 本身,当然也得有标准当中定义的基本的三个函数要求:
1 | template<typename R> |
这几个函数是协程在挂起和恢复时调用的。我们将协程 handle
的保存和结果的返回逻辑固化,因为几乎所有的 Awaiter 都有这样的需求。不过协程的挂起后和恢复前是两个非常重要的时间点,扩展 Awaiter 时经常需要在这两个时间点实现定义化的业务逻辑,因此我们需要定义两个虚函数让子类按需实现:
1 | template<typename R> |
剩下的就是协程的恢复了,这时候我们要求必须使用调度器进行调度。为了防止外部不按要求处理调度逻辑,我们将调度器和协程的 handle
都定义为私有成员,因此我们也需要提供相应的函数来封装协程恢复的逻辑:
1 | template<typename R> |
这样一来,如果我们想要扩展新功能,只需要继承 Awaiter
,在 after_suspend
当中或者之后找个合适的时机调用 resume/resume_unsafe/resume_exception
三个函数当中的任意一个来恢复协程即可。如果在恢复前有其他逻辑需要处理,也可以覆写 before_resume
来实现。
接下来我们使用 Awaiter
对现有的几个 awaiter 类型做重构,之后再尝试基于 Awaiter
做一点小小的扩展。
SleepAwaiter
是最简单的一个。我们当初为了让无阻塞的 sleep 看上去更加自然,直接对 duration
做了支持,于是可以写出下面的代码:
1 | Task<void, LooperExecutor> task() { |
对 duration
的支持源自于在 TaskPromise
当中添加了 duration
转 SleepAwaiter
的 awaiter_transform
函数:
1 | template<typename _Rep, typename _Period> |
如果不要求对 duration
直接支持的话,我们其实也可以这么设计:
1 | template<typename _Rep, typename _Period> |
这与我们前面给出的通用 Awaiter
版本的 await_transform
如出一辙:
1 | template<typename AwaiterImpl> |
因此我们可以使用通用的 Awaiter
重构 SleepAwaiter
,下面我们给出重构前和重构后的对比:
重构前
1 | struct SleepAwaiter { |
重构后
1 | struct SleepAwaiter : Awaiter<void> { |
重构之后,我们无需单独为 SleepAwaiter
添加 await_transform
的支持,就可以写出下面的代码:
1 | Task<void, LooperExecutor> task()) { |
如果觉得不够美观,也可以定义一个协程版本的函数 sleep_for:
1 | template<typename _Rep, typename _Period> |
这样写出来的代码就变成了:
1 | Task<void, LooperExecutor> task()) { |
Channel 有两个 Awaiter,分别是 ReaderAwaiter
、WriterAwaiter
,以前者为例:
重构前:
1 | template<typename ValueType> |
这代码大家已经见过,这里同样贴出来只是为了让大家能够直接对比:
重构后:
1 | template<typename ValueType> |
可以看到,调度的逻辑统一抽象到父类 Awaiter
当中,代码的逻辑更加紧凑了。不仅如此,之前在 TaskPromise
当中定义的 await_transform
也不需要了:
1 | // 不再需要 |
WriterAwaiter
同理,不再赘述。
TaskAwaiter
是用来等待其他 Task
的执行完成的。它同样可以用前面的通用 Awaiter
改造:
重构前:
1 | template<typename Result, typename Executor> |
作为对比,重构后的代码同样变得简洁:
1 | template<typename R, typename Executor> |
改造完成之后,如果不希望为 Task
增加特权支持的话,之前对 TaskAwaiter
的 await_transform
同样可以删除掉:
1 | // 直接删掉 |
然后为 Task
类型增加一个函数来获取 TaskAwaiter
:
1 | template<typename ResultType, typename Executor = NoopExecutor> |
一旦调用 as_awaiter
,我们就会将 Task
的内容全部转移到新创建的 TaskAwaiter
当中,并且返回给外部使用:
1 | Task<int, LooperExecutor> simple_task() { |
当然,在我们自己实现的这套 Task
框架当中,Task
自然是“特权阶层”,我们不会真的删除为 Task
定制的 await_transform
。但也不难看出,经过改造的 Awaiter
的子类代码量和复杂度都有降低;同时也不再需要定义专门的 await_transform
函数来明确支持 TaskAwaiter
,避免了扩展性不强的尴尬。
按照 C++ 标准的发展趋势来看,std::future
应该在将来会支持类似于 Task::then
这样的函数回调,那时候我们完全不需要自己独立定义一套 Task
,只需要基于 std::future
进行扩展即可。
当然这都是后话了。现在 std::future
还不支持回调,我们可以另起一个线程来阻塞得等待它的结果,并在结果返回之后恢复协程的执行,这样一来,我们的 Task
框架也就能够支持形如 co_await as_awaiter(future)
这样的写法了。
想要做到这一点,我们只需要基于前面的 Awaiter
来依样画葫芦:
1 | template<typename R> |
FutureAwaiter
与 TaskAwaiter
除了 after_suspend
和 before_resume
处有些不同之外,几乎完全一样(当然除了这俩函数以外也基本上没有其他逻辑了)。
如果你愿意,你也可以定义一个 as_awaiter
函数:
1 | template<typename R> |
这样我们在协程当中就可以使用 co_await
来等待 std::future
的返回了:
1 | Task<void> task() { |
本文给出的通用的 await_transform
有个小小的漏洞,我们不妨再次观察一下这个函数的定义:
1 | template<typename AwaiterImpl> |
不难发现,只要 AwaiterImpl
类型定义了协程的 Awaiter
类型的三个函数,并且定义有 install_executor
函数,在这里就可以蒙混过关,例如:
1 | struct FakeAwaiter { |
这个 FakeAwaiter
的定义符合前面的模板类型 AwaiteImpl
的要求,但却不符合我们的预期。为了避免这种情况发生,我们必须想办法要求 AwaiterImpl
只能是 Awaiter
或者它的子类。
这如果是在 Java 当中,我们可以很轻松地指定泛型的上界来达到目的。但 C++ 的模板显然与 Java 泛型的设计相差较大,不能直接在定义模板参数时指定上界。不过 C++ 20 的 concept 可以用来为模板参数限定父类。
我们需要定义一个用来检查类关系的 concept:
1 | template<typename AwaiterImpl, typename R> |
接下来我们只需要在 await_transform
的模板声明后面加上这个 concept 即可:
1 | template<typename AwaiterImpl> |
不过这里有个问题,我们其实并不知道 AwaiterImpl
的实际类型在继承 Awaiter
时到底用了什么类型的模板参数,这怎么办呢?
有一个简单的办法,那就是为 Awaiter
声明一个内部类型 ResultType
:
1 | template<typename R> |
这样我们就可以使用 Awaiter::ResultType
来获取这个类型:
1 | template<typename AwaiterImpl> |
这样像前面提到的 FakeAwaiter
那样的类型,就不能作为 co_await
表达式的参数了。即便我们为 FakeAwaiter
声明 ResultType
也不行,co_await FakeAwaiter()
的报错信息如下:
1 | candidate template ignored: constraints not satisfied [with AwaiterImpl = FakeAwaiter] |
可见 FakeAwaiter
并不能满足与 Awaiter
的父子类关系,因此无法作为 AwaiterImpl
的模板实参。
本文介绍了一种实现较为通用的 Awaiter 的方法,目的在于增加现有 Task
框架的扩展性,避免通过频繁改动 TaskPromise
来新增功能。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
之前我们主要关注的是协程与外部调用者的交互,这次我们也关注一下对等的协程之间的通信。
Go routine 当中有一个重要的特性就是 Channel。我们可以向 Channel 当中写数据,也可以从中读数据。例如:
1 | // 创建 Channel 实例 |
这个例子是我写 《深入理解 Kotlin 协程》 这本书时用到过的一个非常简单的 Go routine 的例子,它的运行输出如下:
1 | wait for read |
Go 当中的 Channel 默认是没有 buffer 的,我们也可以通过 make chan
在初始化 Channel 的时候指定 buffer。在 buffer 已满的情况下,写入者会先挂起等待读取者后再恢复执行,反之亦然。等待的过程中,所处的协程会挂起,执行调度的线程自然也会被释放用于调度其他逻辑。
Kotlin 协程当中也有 Channel,与 Go 的不同之处在于 Kotlin 的 Channel 其实是基于协程最基本的 API 在框架层面实现的,并非语言原生提供的能力。C++ 的协程显然也可以采用这个思路,实际上整个这一系列 C++ 协程的文章都是在介绍如何使用 C++ 20 标准当中提供的基本的协程 API 在构建更复杂的框架支持。
我们来看一下我们最终的 Channel 的用例:
1 | Task<void, LooperExecutor> Producer(Channel<int> &channel) { |
我们的 Channel 也可以在构造的时候传入 buffer 的大小,默认没有 buffer。
想要支持 co_await
表达式,只需要为 Channel 读写函数返回的 Awaiter 类型添加相应的 await_transform
函数。我们姑且认为 read
和 write
两个函数的返回值类型 ReaderAwaiter
和 WriterAwaiter
,接下来就添加一个非常简单的 await_transform
的支持:
1 | // 对于 void 的实例化版本也是一样的 |
由于 Channel
的 buffer 和对 Channel
的读写本身会决定协程是否挂起或恢复,因此这些逻辑我们都将在 Channel
当中给出,TaskPromise
能做的就是把调度器传过去,当协程恢复时使用。
Awaiter 负责在挂起时将自己存入 Channel
,并且在需要时恢复协程。因此除了前面看到需要在恢复执行协程时的调度器之外,Awaiter 还需要持有 Channel
、需要读写的值。
下面是 WriterAwaiter
的实现:
1 | template<typename ValueType> |
相对应的,还有 ReaderAwaiter
,实现类似:
1 | template<typename ValueType> |
简单说来,Awaiter 的功能就是:
接下来我们给出 Channel
当中根据 buffer 的情况来处理读写两端的挂起和恢复的逻辑。
我们先来看一下 Channel
的基本结构:
1 | template<typename ValueType> |
通过了解 Channel
的基本结构,我们已经知道了 Channel
当中存了哪些信息。接下来我们就要填之前埋下的坑了:分别是在协程当中读写值用到的 read
和 write
函数,以及在挂起协程时 Awaiter 当中调用的 try_push_writer
和 try_push_reader
。
这两个函数也没什么实质的功能,就是把 Awaiter 创建出来,然后填充信息再返回:
1 | template<typename ValueType> |
这当中除了 operator>>
的实现需要多保存一个变量的地址以外,大家只需要注意一下对于 check_closed
的调用即可,它的功能很简单:在 Channel
关闭之后调用它会抛出 ChannelClosedException
。
try_push_writer
和 try_push_reader
这是 Channel
当中最为核心的两个函数了,他们的功能正好相反。
try_push_writer
调用时,意味着有一个新的写入者挂起准备写入值到 Channel
当中,这时候有以下几种情况:
Channel
当中有挂起的读取者,写入者直接将要写入的值传给读取者,恢复读取者,恢复写入者Channel
的 buffer 没满,写入者把值写入 buffer,然后立即恢复执行。Channel
的 buffer 已满,则写入者被存入挂起列表(writer_list)等待新的读取者读取时再恢复。了解了思路之后,它的实现就不难写出了,具体如下:
1 | void try_push_writer(WriterAwaiter<ValueType> *writer_awaiter) { |
相对应的,try_push_reader
调用时,意味着有一个新的读取者挂起准备从 Channel
当中读取值,这时候有以下几种情况:
Channel
的 buffer 非空,读取者从 buffer 当中读取值,如果此时有挂起的写入者,需要去队头的写入者将值写入 buffer,然后立即恢复该写入者和当次的读取者。Channel
当中有挂起的写入者,写入者直接将要写入的值传给读取者,恢复读取者,恢复写入者Channel
的 buffer 为空,则读取者被存入挂起列表(reader_list)等待新的写入者写入时再恢复。接下来是具体的实现:
1 | void try_push_reader(ReaderAwaiter<ValueType> *reader_awaiter) { |
至此,我们已经完整给出 Channel
的实现。
说明:我们当然也可以在
await_ready
的时候提前做一次判断,如果命中第 1、2 两种情况可以直接让写入/读取协程不挂起继续执行,这样可以避免写入/读取者的无效挂起。为了方便介绍,本文就不再做相关优化了。
截止目前,我们给出的 Channel
仍然有个小小的限制,即 Channel
对象必须在持有 Channel
实例的协程退出之前关闭。
这主要是因为我们在 Channel
当中持有了已经挂起的读写协程的 Awaiter
的指针,一旦协程销毁,这些 Awaiter
也会被销毁,Channel
在关闭时试图恢复这些读写协程时就会出现程序崩溃(访问了野指针)。
为了解决这个问题,我们需要在 Awaiter
销毁时主动将自己的指针从 Channel
当中移除。以 ReaderAwaiter
为例:
1 | template<typename ValueType> |
我们在 ReaderAwaiter
的析构函数当中主动检查并移除了自己的指针,避免后续 Channel
对自身指针的无效访问。
对应的,Channel
当中也需要增加 remove_reader
函数:
1 | template<typename ValueType> |
WriterAwaiter
的修改类似,不再赘述。
这样修改之后,即使我们把正在等待读写 Channel
的协程提前结束销毁,也不会影响 Channel
的继续使用以及后续的正常关闭了。
我们终于又实现了一个新的玩具,现在我们来给它通电试试效果。
1 | using namespace std::chrono_literals; |
例子非常简单,我们用一个写入者两个接收者向 Channel
当中读写数据,为了让示例更加凌乱,我们还加了一点点延时,运行结果如下:
1 | 08:39:58.129 [Thread-26004] (main.cpp:15) Producer: send: 0 |
结果我就不分析了。
本文给出了 C++ 协程版的 Channel
的 demo 实现,这进一步证明了 C++ 协程的基础 API 的设计足够灵活,能够支撑非常复杂的需求场景。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
如果你想要等待 100ms,你会怎么做?sleep_for(100ms) 吗?
在以往,我们想要让程序等待 100ms,我们只能调用线程的 sleep 函数来阻塞当前线程 100ms。
这样做确实可以让程序等待 100ms,但坏处就是这 100ms 期间,被阻塞的当前线程什么也做不了,白白占用了内存。协程出现之后,我们其实完全可以让协程在需要 sleep 的时候挂起,100ms 之后再来恢复执行,完全不需要阻塞当前线程。
想法不错,马上把用例给出来:
1 | Task<int, AsyncExecutor> simple_task2() { |
这个例子大家已经见过多次了,之前用的是 sleep_for
让线程睡眠 1 秒,这次我们直接用 co_await 1s
,看上去是不是特别的厉害?
如果大家对于 C++ 11 不熟悉,可能会比较疑惑 co_await 1s
当中的 1s
是个什么东西。实际上这是 C++ 11 对字面值的一种支持,本质上就是一个运算符重载,这里的 1s
的类型是 duration<long long>
。除了秒以外,时间的单位也可以是毫秒、纳秒、分钟、小时等等,这些 C++ 11 的 duration
都已经提供了完善的支持,因此我们只要对 duration
做支持即可。
1 | template<typename ResultType, typename Executor> |
这里引入了一个新的类型 SleepAwaiter
,它的任务有两个:
不难想到,std::chrono::duration_cast<std::chrono::milliseconds>(duration).count()
实际上就是把任意单位的 duration
转换成毫秒。
SleepAwaiter
的实现也很简单,我们直接给出:
1 | struct SleepAwaiter { |
这当中最为关键的就是 Scheduler
的实现了,这个类实际上本身就是一个独立的定时任务调度器。
定时任务调度器,本质上就是一个时间管理大师。任何交给它的任务都需要有优先级,优先级的计算规则当然就是延时的长短,于是我们需要用到优先级队列来存储待执行的任务。
等下,任务队列?这让我想起上一篇文章当中的 LooperExecutor
,如果我们给它加上计时执行的能力,Scheduler
的功能就差不多完成了。换个角度看,LooperExecutor
其实就是 Scheduler
的一个特化版本,它的所有任务的延时都是 0。
为了方便管理定时任务,我们需要定义一个类型 DelayedExecutable
,它包含一个函数和它要执行的绝对时间:
1 | class DelayedExecutable { |
定时任务的描述类 DelayedExecutable
非常简单,相信大家一看就明白。
为了将 DelayedExecutable
存入优先级队列当中,我们还需要给它提给一个比较大小的类:
1 | class DelayedExecutableCompare { |
这个类就很简单了,直接将对 DelayedExecutable
的比较转换成对它们的执行时间的比较。使用这个类对 DelayedExecutable
进行排序时,会使得时间靠前的对象排到前面。
接下来我们直接给出 Scheduler
的实现,由于这个类与前面的 LooperExecutor
很像,我们只给出不同的部分:
1 | class Scheduler { |
通过对代码和注释的阅读,相信大家能够明白延时的实现其实是通过阻塞一个专门用于调度延时任务的线程来做到的。
相信有读者会有疑问:这不还是有阻塞吗?
没错,阻塞是免不了的。通常而言,我们也不会用一个线程去严格对应一个协程,当一个协程挂起时,执行这个协程的线程就会被空闲出来有机会去调度执行其他协程,进而让线程的利用率得到充分提升。如果有 10 个协程都需要执行延时,相较于阻塞这 10 个协程当前所在的 10 个线程而言,阻塞一个线程显然是更加经济的。
我们又一次在文章的最后把要实现的功能做好,现在是收获的时刻了。
我们先来一个开胃菜。前面我们提到过,Scheduler
实际上是一个完整独立的功能模块,因此我们先写个简单的用例来测试一下它的功能:
1 | auto scheduler = Scheduler(); |
打印的数字是按照时间顺序排列的,但任务的添加却是乱序的。运行结果如下:
1 | 22:12:54.611 [Thread-16076] (main.cpp:12) main: start |
可以看到 1-6 的顺序是可以保证的,前面的时间信息也可以看到延时能力基本上是符合预期的。
接下来,我们把前面用了好几次的 Task
的 demo 拿出来,加上延时,顺便也验证一下 AsyncExecutor
和 NewThreadExecutor
的效果:
1 | Task<int, AsyncExecutor> simple_task2() { |
运行结果如下:
1 | 22:14:49.531 [Thread-15596] (main.cpp:41) simple_task: task start ... |
我们把所有的 sleep_for
都替换成了本文实现的无阻塞的 sleep,运行效果上来看确实可以按照要求实现延时执行。
另外,由于这里的 co_await 1s
这样的操作都是挂起点,因此恢复时也会用协程的调度器去调度。可以看到,simple_task2
的两行日志的线程都是 26892
,这大概是因为 std::async
背后是一个线程池,两次调度都调度到了同一个线程上,当然这个完全取决于 std::async
的实现。而 simple_task3
的两行日志就分别运行在 16816
和 26756
,因为它的调度器是 NewThreadExecutor
,每次都会新起一个线程来实现调度。
本文结合前面的 Task
的内容进一步给出了无阻塞式的 sleep 实现。通过本文的探讨,相信大家族在感慨 C++ 协程的设计真的是如此的灵活的同时,也进一步深入了解了 C++ 协程的用法。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
协程想要实现异步,很大程度上依赖于调度器的设计。
为了实现协程的异步调度,我们需要提供调度器的实现。调度器听起来有些厉害,但实际上就是负责执行一段逻辑的工具。
下面我们给出调度器的抽象设计:
1 | class AbstractExecutor { |
是的,你没看错,调度器本身就是这么简单。
现在我们已经知道了调度器的样子,那么问题来了,怎么才能把它接入到协程当中呢?这个问题换个说法,那就是什么情况下我们需要调度,或者说什么情况下我们可以实现调度。
这个问题如果你不知道答案,让你随便蒙,你大概也没有什么其他的选项可以选。因为协程的本质就是挂起和恢复,因此想要实现调度,就必须在挂起和恢复上做文章。想要在 C++ 的协程的挂起和恢复上做文章,那我们就只能考虑定制 Awaiter 了。我们再来回顾一下前面提到的 TaskAwaiter 的定义:
1 | template<typename Result> |
我们只保留了最核心的三个函数,其他的代码都略去了。可以看到,想要实现调度,就只能在 await_suspend
上面做文章,因为其他两个函数都要求同步返回。
实际上,按照 C++ 协程的设计,await_suspend
确实是用来提供调度支持的,由于这个时间点协程已经完全挂起,因此我们可以在任意一个线程上调用 handle.resume()
,你甚至不用担心线程安全的问题。这样看来,如果有调度器的存在,代码大概会变成下面这样:
1 | // 调度器的类型有多种,因此专门提供一个模板参数 Executor |
TaskAwaiter
当中的调度器实例是从外部传来的,这样设计的目的是希望把调度器的创建和绑定交给协程本身。换句话说,调度器应该属于协程。这样设计的好处就是协程内部的代码均会被调度到它对应的调度器上执行,可以确保逻辑的一致性和正确性。
这么看来,调度器应该与 Task
或者 TaskPromise
绑定到一起。
当协程创建时,我们可以以某种方式执行一个调度器,让协程的执行从头到尾都调度到这个调度器上执行。例如:
1 | Task<int, LooperExecutor> simple_task() { |
我们通过模板参数为 Task
绑定了一个叫做 LooperExecutor
的调度器(我们现在先不去管 LooperExecutor
的具体实现,这个我们后面会讲),这样一来,我们希望 simple_task
当中所有的代码都会被调度到 LooperExecutor
上执行。
请大家参考注释的说明,我们了解到所有挂起的位置都需要在恢复时拿到同一个 LooperExecutor
的实例,因此我们考虑首先对 TaskPromise
的定义做一下修改,引入 Executor
:
1 | // 增加模板参数 Executor |
由于我们在 TaskPromise
当中定义了 await_transform
,因此协程当中只支持对 Task
类型的 co_await
操作,这样可以保证所有的 co_await <task>
都会在恢复执行时通过 TaskAwaiter
来确保后续逻辑的正确调度。
剩下的就是协程在启动时的 initial_suspend
了,这个也比较容易处理,我们给出 DispatchAwaiter
的定义:
1 | struct DispatchAwaiter { |
如此一来,协程内部的所有逻辑都可以顺利地调度到协程对应的调度器上了。
Task
的改动不大,只是增加了模板参数 Executor
:
1 | // NewThreadExecutor 是 AbstractExecutor 的子类,作为模板参数 Executor 的默认值 |
我们还可以默认给 Task
指定一个调度器的实现 NewThreadExecutor
。这些调度器可以通过指定类型在 TaskPromise
当中执行初始化,因为我们会保证他们都会有默认的无参构造器实现。
接下来我们给出几种简单的调度器实现作为示例,读者有兴趣也可以按照自己的需要设计调度器的实现。
看名字相比大家也能猜个八九不离十,这就是个什么都不干的调度器:
1 | class NoopExecutor : public AbstractExecutor { |
如果我们给 Task
搭配这个调度器,Task
的执行线程就完全取决于调用者或者恢复者所在的线程了。
顾名思义,每次调度都创建一个新的线程。实现非常简单:
1 | class NewThreadExecutor : public AbstractExecutor { |
这个在思路上与 NewThreadExecutor
差别不大,只是调度时交给了 std::async
去执行:
1 | class AsyncExecutor : public AbstractExecutor { |
相比之下,这个调度器可以利用 std::async
背后的线程调度,提升线程的利用率。
LooperExecutor 稍微复杂一些,它通常出现在主线程为事件循环的场景,例如 UI 相关应用的开发场景。
考虑到我本身不希望引入 UI 相关的开发概念,这里直接给出一个简单的单线程事件循环,并以此来实现 LooperExecutor:
1 | class LooperExecutor : public AbstractExecutor { |
各位读者可以参考代码注释来理解其中的逻辑。简单来说就是:
wait
来实现阻塞等待。notify_one
来通知 run_loop
继续执行。这个其实就是 LooperExecutor
的一个马甲,它的作用就是让各个协程共享一个 LooperExecutor
实例。
1 | class SharedLooperExecutor : public AbstractExecutor { |
这次我们基于上一篇文章当中的 demo 加入调度器的支持:
1 | // 使用了 Async 调度器 |
这个例子的代码跟上次不能说完全没有修改吧,那也是几乎没有修改,除了加了调度器的类型作为 Task
的模板参数。运行结果如下:
1 | 11:46:03.305 [Thread-32620] (main.cpp:40) simple_task: task start ... |
请大家仔细观察,所有 simple_task
函数的日志输出都在 id 为 32620 的线程上,这实际上就是我们的 Looper 线程。当然,由于 simple_task2
和 simple_task3
当中没有挂起点,因此它们只会在 initial_suspend
时调度一次。
本文我们终于给 Task
添加了调度器的支持。如此一来,我们就可以把 Task
绑定到合适的线程调度器上,来应对更加复杂的业务场景了。
读者也可以发挥自己的想象力,按照类似的方式定义出更加有用或者有趣的调度器。当然,本文给出的调度器没有做调度优化,有兴趣的读者也可以自己尝试
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
协程主要用来降低异步任务的编写复杂度,异步任务各式各样,但归根结底就是一个结果的获取。
为了方便介绍后续的内容,我们需要再定义一个类型 Task
来作为协程的返回值。Task
类型可以用来封装任何返回结果的异步行为(持续返回值的情况可能更适合使用序列生成器)。
实现的效果如下:
1 | Task<int> simple_task2() { |
我们定义以 Task<ResultType>
为返回值类型的协程,并且可以在协程内部使用 co_await
来等待其他 Task
的执行。
外部非协程内的函数当中访问 Task
的结果时,我们可以通过回调或者同步阻塞调用两种方式来实现:
1 | int main() { |
按照这个效果,我们大致可以分析得到:
Task
定义相应的 promise_type
类型来支持 co_return
和 co_await
。Task
实现获取结果的阻塞函数 get_result
或者用于获取返回值的回调 then
以及用于获取抛出的异常的回调 catching
。描述 Task
正常返回的结果和抛出的异常,只需要定义一个持有二者的类型即可:
1 |
|
其中,Result
的模板参数 T
对应于 Task
的返回值类型。有了这个结果类型,我们就可以很方便地在需要读取结果的时候调用 get_or_throw
。
promise_type 的定义自然是最为重要的部分。
基于前面几篇文章的基础,我们能够很轻松地给出它的基本结构:
1 | template<typename ResultType> |
光有这些还不够,我们还需要为 Task
添加 co_await
的支持。这里我们有两个选择:
Task
实现 co_await
运算符promise_type
当中定义 await_transform
从效果上来看,二者都可以做到。但区别在于,await_transform
是 promsie_type
的内部函数,可以直接访问到 promise
内部的状态;同时,await_transform
的定义也会限制协程内部对于其他类型的 co_await
的支持,将协程内部的挂起行为更好的管控起来,方便后续我们做统一的线程调度。因此此处我们采用 await_transform
来为 Task
提供 co_await
支持:
1 | template<typename ResultType> |
代码很简单,返回了一个 TaskAwaiter
的对象。不过再次请大家注意,这里存在两个 Task
,一个是 TaskPromise
对应的 Task
,一个是 co_await
表达式的操作数 Task
,后者是 await_transform
的参数。
下面是 TaskAwaiter
的定义:
1 | template<typename R> |
当一个 Task
实例被 co_await 时,意味着它在 co_await 表达式返回之前已经执行完毕,当 co_await
表达式返回时,Task
的结果也就被取到,Task
实例在后续就没有意义了。因此 TaskAwaiter
的构造器当中接收 Task &&
,防止 co_await
表达式之后继续对 Task
进行操作。
为了防止 result
被外部随意访问,我们特意将其改为私有成员。接下来我们还需要提供相应的方式方便外部访问 result
。
先来看一下如何实现同步阻塞的结果返回:
1 | template<typename ResultType> |
既然要阻塞,就免不了用到锁(mutex)和条件变量(condition_variable),熟悉它们的读者一定觉得事情变得不那么简单了:这些工具在以往都是用在多线程并发的环境当中的。我们现在这么写其实也是为了后续应对多线程的场景,有关多线程调度的问题我们将在下一篇文章当中讨论。
异步回调的实现稍微复杂一些,其实主要复杂在对于函数的运用。实际上对于回调的支持,主要就是支持回调的注册和回调的调用。根据结果类型的不同,回调又分为返回值的回调或者抛出异常的回调:
1 | template<typename ResultType> |
同样地,如果只是在单线程环境内运行协程,这里的异步回调的作用可能并不明显。这里只是先给出定义,待我们后续支持线程调度之后,这些回调支持就会非常有价值了。
现在我们已经实现了最为关键的 promise_type
,接下来给出 Task
类型的完整定义。我想各位读者一定明白,Task
不过就是个摆设,它的能力大多都是通过调用 promise_type
来实现的。
1 | template<typename ResultType> |
现在我们完成了 Task
的第一个通用版本的实现,这个版本的实现当中尽管我们对 Task
的结果做了加锁,但考虑到目前我们仍没有提供线程切换的能力,因此这实际上是一个无调度器版本的 Task
实现。
前面讨论的 Task
有一个作为返回值类型的模板参数 ResultType
。实际上有些时候我们只是希望一段任务可以异步执行完,而不关注它的结果,这时候 ResultType
就需要是 void
。例如:
1 | Task<void> Producer(Channel<int> &channel) { |
但很快你就会发现问题。编译器会告诉你模板实例化错误,因为我们没法用 void
来声明变量;编译器还会告诉你协程体里面如果没有返回值,你应该提供为 promise_type
提供 return_void
函数。
看来情况没有那么简单。C++ 的模板经常会遇到这种需要特化的情况,我们只需要对之前的 Task<ResultType>
版本的定义稍作修改,就可以给出 Task<void>
的版本:
1 | template<> |
你会发现变化的只是跟结果相关的部分。相应的,TaskPromise
也需要做出修改:
1 | template<> |
还有 Result
也有对应的 void
实例化版本,其实就是把存储返回值相关的逻辑全部删掉,只保留异常相关的部分:
1 | template<> |
至此,我们进一步完善了 Task
对不同类型的结果的支持,理论上我们可以使用 Task
来构建各式各样的协程了。
接下来我们可以试着把文章开头的代码运行一下了。为了更仔细地观察程序的执行,我们也在一些节点打印了日志:
1 | Task<int> simple_task2() { |
其中 debug
是我自定义的一个宏,可以在打印日志的时候附加上时间、线程、函数等信息,运行结果如下:
1 | 16:46:30.448 [Thread-25132] (main.cpp:40) simple_task: task start ... |
由于我们的任务在执行过程中没有进行任何线程切换,因此各个 Task
的执行实际上是串行的,就如同我们调用普通函数一样。当然,这显然不是我们的最终目的,下一篇我们就来介绍如何给 Task
增加调度器的支持。
本文我们详细介绍了无调度器版本的 Task
的实现。尽管程序尚未真正实现异步执行,但至少从形式上,我们已经非常接近协程最神奇的地方了。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
我们还可以对序列生成器产生的数据流做进一步的筛选和处理,而这一切都可以基于协程去实现。
我们已经有了一个 int 版本的 Generator,实际上我们也很容易把它泛化成模板类型,改动的地方不多,基本上把原 Generator 类型当中的 int
替换成模板参数 T
即可,如下:
1 | template<typename T> |
这样原来生成斐波那契数列的函数也需要稍作调整:
1 | Generator<int> fibonacci() { |
其实不过就是给 Generator 加了个模板参数而已。
现在我们知道,想要创建 Generator 就需要定义一个函数或者 Lambda。不过从输出的结果上看, Generator 实际上就是一个“懒”序列,因此我们当然可以通过一个数组就能创建出 Generator 了。
使用数组创建 Generator 的版本实现比较简单,我们直接给出代码:
1 | template<typename T> |
注意到 C++ 的数组作为参数时相当于指针,需要传入长度 n。用法如下:
1 | int array[] = {1, 2, 3, 4}; |
显然,这个写法不能令人满意。
我们把数组改成 std::list 如何呢?
1 | template<typename T> |
相比数组,std::list
的版本少了一个长度参数,因为长度的信息被封装到 std::list
当中了。用法如下:
1 | auto generator = Generator<int>::from_list(std::list{1, 2, 3, 4}); |
这个虽然有进步,但缺点也很明显,因为每次都要创建一个 std::list
,说得直接一点儿就是每次都要多写 std::list
这 9 个字符。
这时候我们就很自然地想到了初始化列表的版本:
1 | template<typename T> |
这次我们就可以有下面的用法了:
1 | auto generator = Generator<int>::from({1, 2, 3, 4}); |
不错,看上去需要写的内容少很多了。
不过,如果这对花括号也不用写的话,那就完美了。想要做到这一点,我们需要用到 C++ 17 的折叠表达式(fold expression)的特性,实现如下:
1 | template<typename T> |
注意这里的模板参数包(template parameters pack)不能用递归的方式去调用 from,因为那样的话我们会得到非常多的 Generator 对象。
用法如下:
1 | auto generator = Generator<int>::from(1, 2, 3, 4); |
这下看上去完美多了。
熟悉函数式编程的读者可能已经意识到了,我们定义的 Generator 实际上已经非常接近 Monad 的定义了。那我们是不是可以给它实现 map 和 flat_map 呢?
map 就是将 Generator 当中的 T 映射成一个新的类型 U,得到一个新的 Generator<U>
。下面我们给出第一个版本的 map 实现:
1 | template<typename T> |
参数 std::function<U(T)>
当中的模板参数 U(T)
是个模板构造器,放到这里就表示这个函数的参数类型为 T
,返回值类型为 U
。
接下来我们给出用法:
1 | // fibonacci 是上一篇文章当中定义的函数,返回 Generator<int> |
通过 map 函数,我们将 Generator<int>
转换成了 Generator<std::string>
,外部使用 generator_str
就会得到字符串。
当然,这个实现有个小小的缺陷,那就是 map 函数的模板参数 U 必须显式提供,如上例中的 <std::string>
,这是因为我们在定义 map 时用到了模板构造器,这使得类型推断变得复杂。
为了解决这个问题,我们就要用到模板的一些高级特性了,下面给出第二个版本的 map 实现:
1 | template<typename T> |
注意,这里我们直接用模板参数 F
来表示转换函数 f 的类型。map 本身的定义要求 F
的参数类型是 T
,然后通过 std::invoke_result_t<F, T>
类获取 F
的返回值类型。
这样我们在使用时就不需要显式的传入模板参数了:
1 | Generator<std::string> generator_str = fibonacci().map([](int i) { |
在给出实现之前,我们需要先简单了解一下 flat_map 的概念。
前面提到的 map 是元素到元素的映射,而 flap_map 是元素到 Generator 的映射,然后将这些映射之后的 Generator 再展开(flat),组合成一个新的 Generator。这意味如果一个 Generator 会传出 5 个值,那么这 5 个值每一个值都会映射成一个新的 Generator,,得到的这 5 个 Generator 又会整合成一个新的 Generator。
由此可知,map 不会使得新 Generator 的值的个数发生变化,flat_map 会。
下面我们给出 flat_map 的实现:
1 | template<typename T> |
为了加深大家的理解,我们给出一个小例子:
1 | Generator<int>::from(1, 2, 3, 4) |
这个例子的运行输出如下:
1 | * |
我们来稍微做下拆解。
Generator<int>::from(1, 2, 3, 4)
得到的是序列 1 2 3 4
0 0 1 0 1 2 0 1 2 3
由于我们在 0 的位置做了换行,因此得到的输出就是 * 组成的三角形了。
序列的最终使用,往往就是遍历:
1 | template<typename T> |
Generator 会生成很多值,如果我们需要对这些值做一些整体的处理,并最终得到一个值,那么我们就需要折叠函数 fold:
1 | template<typename T> |
它需要一个初始值,函数 f 接收两个参数,分别是 acc 和序列生成器当前迭代的元素,每次经过 f 做运算得到的结果会作为下次迭代的 acc 传入,直到最后 acc 作为 fold 的返回值返回。
我们可以很方便地使用 fold 求和或者求取阶乘,例如:
1 | // result: 720 |
求和本身可以用前面的 fold 来实现,当然我们也可以直接给出 sum 函数的定义:
1 | template<typename T> |
用例:
1 | // result: 21 |
你几乎可以在任何看到 map/flat_map 的场合看到 filter,毕竟有些值我们根本不需要。
想要实现这个过滤,只需要一个条件判断,下面我们给出 fitler 的实现:
1 | template<typename T> |
序列生成器往往与懒序列同时出现,因为懒序列之所以懒,往往是因为它的长度可能很长(甚至无限,例如斐波那契数列),一次性将所有的值加载出来会比较影响性能。
对于这种很长的懒序列,我们最终能用到的值可能并不多,因此我们需要一个函数 take(n)
对序列的前 n
个做截取。
它的实现也是显而易见的:
1 | template<typename T> |
take_while 的实现就好像是 filter 与 take(n) 的一个结合:
1 | template<typename T> |
例如我们想要截取小于 100 的所有斐波那契数列:
1 | fibonacci().take_while([](auto i){ |
就会得到:
1 | 0 1 1 2 3 5 8 13 21 34 55 89 |
前面给出了这么多函数的实现,目的主要是为了凑字数让大家充分理解 C++ 协程的妙处。为了进一步确认大家对于前面例子的理解程度,我们再给出一个例子,请大家思考这当中的每一个 lambda 分别调用几次,以及输出什么:
1 | Generator<int>::from(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) |
大家在分析的时候,请牢记 Generator 生成的序列是懒序列,只要最终访问到的时候才会生成。
这意味着中间的 map 其中根本不会主动消费 Generator,flat_map 也不会,filter 也不会,take 也不会。只有 for_each 调用的时候,才会真正需要知道 Generator 当中都有什么。
输出的结果如下:
1 | filter: 1 |
提示:大家可以返回去再看一下我们给出的函数的实现,找一下哪些当中用到了
co_yield
,哪些没有用到,以及这两类函数有什么区别。
本文我们对前文当中的序列生成器做了泛化,使它能够支持任意类型的序列生成。此外,我们也针对序列生成器添加了一系列的函数式的支持,以帮助读者进一步深入理解协程的工作机制。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
序列生成器是一个非常经典的协程应用场景。
现在我们已经了解了绝大部分 C++ 协程的特性,可以试着来实现一些小案例了。
简单的说,序列生成器通常的实现就是在一个协程内部通过某种方式向外部传一个值出去,并且将自己挂起,外部调用者则可以获取到这个值,并且在后续继续恢复执行序列生成器来获取下一个值。
显然,挂起和向外部传值的任务就需要通过 co_await
来完成了,外部获取值的任务就要通过协程的返回值来完成。
由此我们大致能想到最终程序的样子:
1 | Generator sequence() { |
注意到 generator 有个 next 函数,调用它时我们需要想办法让协程恢复执行,并将下一个值传出来。
好了,接下来我们就带着这两个问题去寻找解决办法,顺便把剩下的一点点 C++ 协程的知识补齐。
截止到目前我们都没有真正尝试去调用过协程,现在是个很好的机会。我们观察一下 main 函数当中的这段代码:
1 | int main() { |
generator
的类型就是我们即将实现的序列生成器类型 Generator
,结合上一篇文章当中对于协程返回值类型的介绍,我们先大致给出它的定义:
1 | struct Generator { |
代码当中有两处我们标注为 ???,表示暂时还不知道怎么处理。
第一个是我们想要在 Generator 当中 resume 协程的话,需要拿到 coroutine_handle,这个要怎么做到呢?
这时候我希望大家一定要记住一点,promise_type 是连接协程内外的桥梁,想要拿到什么,找 promise_type 要。标准库提供了一个通过 promise_type 的对象的地址获取 coroutine_handle 的函数,它实际上是 coroutine_handle 的一个静态函数:
1 | template <class _Promise> |
这样看来,我们只需要在 get_return_object
函数调用时,先获取 coroutine_handle,然后再传给即将构造出来的 Generator 即可,因此我们稍微修改一下前面的代码:
1 | struct Generator { |
接下来就是如何获取协程内部传出来的值的问题了。同样,本着有事儿找 promise_type 的原则,我们可以直接给它定义一个 value 成员:
1 | struct Generator { |
现在的问题就是如何从协程内部传值给 promise_type 了。
我们再来观察一下最终实现的效果:
1 | Generator sequence() { |
特别需要注意的是 co_await i++;
这一句,我们发现 co_await
后面的是一个整型值,而不是我们在前面的文章当中提到的满足等待体(awaiter)条件的类型,这种情况下该怎么办呢?
实际上,对于 co_await <expr>
表达式当中 expr
的处理,C++ 有一套完善的流程:
promise.await_transform(expr)
来对 expr 做一次转换,得到的对象称为 awaitable;否则 awaitable 就是 expr 本身。operator co_await
运算符重载,那么等待体就是 operator co_await(awaitable)
,否则等待体就是 awaitable 对象本身。听上去,我们要么给 promise_type 实现一个 await_tranform(int)
函数,要么就为整型实现一个 operator co_await
的运算符重载,二者选一个就可以了。
这个方案就是给 int 定义 operator co_await 的重载:
1 | auto operator co_await(int value) { |
当然,这个方案对于我们这个特定的场景下是行不通的,因为在 C++ 当中我们是无法给基本类型定义运算符重载的。
不过,如果我们遇到的情况不是基本类型,那么运算符重载的思路就可以行得通。operator co_await
的重载我们将会在后面给出例子。
运算符重载行不通,那就只能通过 await_tranform 来做转换了。
代码比较简单:
1 | struct Generator { |
定义了 await_transform
函数之后,co_await expr
就相当于 co_await promise.await_transform(expr)
了。
至此,我们的例子就可以运行了:
1 | Generator sequence() { |
运行结果如下:
1 | 0 |
虽然我们的协程已经能够正常工作,但它仍然存在缺陷。
当外部调用者或者恢复者试图调用 next
来获取下一个元素的时候,它其实并不知道能不能真的得到一个结果。程序也可能抛出异常:
如下例:
1 | Generator sequence() { |
程序的结果是什么呢?
1 | 0 |
最后一个输出的 4 实际上是恰好遇到协程销毁之前的状态,此时 promise 当中的 value 值还是之前的 4。而当我们试图不断的去读取协程的值,程序就抛出 SIGSEGV 的错误。错误的原因你可能已经想到了,当协程体执行完之后,协程的状态就会被销毁,如果我们再访问协程的话,就相当于访问了一个野指针。
为了解决这个问题,我们需要增加一个 has_next 函数,用来判断是否还有新的值传出来,has_next 函数调用的时候有两种情况:
这里我们需要有一种有效的办法来判断 value 是不是有效的,单凭 value 本身我们其实是无法确定它的值是不是被消费了,因此我们需要加一个值来存储这个状态:
1 | struct Generator { |
我们定义一个成员 state 来记录协程执行的状态,状态的类型一共三种,只有 READY 的时候我们才能拿到值。
接下来改造 next
函数,同时增加 has_next
函数来描述协程是否仍然可以有值传出:
1 | struct Generator { |
这样外部使用时就需要先通过 has_next 来判断是否有下一个值,然后再去读取了:
1 | ... |
我们前面提到过,协程的状态在协程体执行完之后就会销毁,除非协程挂起在 final_suspend
调用时。
我们的例子当中 final_suspend
返回了 std::suspend_never
,因此协程的销毁时机其实比 Generator 更早:
1 | auto generator = sequence(); |
这看上去似乎问题不大,因为我们在前面通过 has_next
的判断保证了读取值的安全性。
但实际上情况并非如此。我们在 has_next
当中调用了 coroutine_handle::done
来判断协程体是否执行完成,判断之前很可能协程已经销毁,coroutine_handle
这时候都已经是无效的了:
1 | bool has_next() { |
因此为了让协程的状态的生成周期与 Generator
一致,我们必须将协程的销毁交给 Generator
来处理:
1 | struct Generator { |
这个问题确切地说是问题 2的解决方案不完善引起的。
我们在 Generator 的析构函数当中销毁协程,这本身没有什么问题,但如果我们把 Generator 对象做一下复制,例如从一个函数当中返回,情况可能就会变得复杂。例如:
1 | Generator returns_generator() { |
这段代码乍一看似乎没什么问题,但由于我们把 g
当做返回值返回了,这时候 g
这个对象就发生了一次复制,然后临时对象被销毁。接下来的事儿大家就很容易想到了,运行结果如下:
1 | 0 |
为了解决这个问题,我们需要妥善地处理 Generator 的复制构造器:
1 | struct Generator { |
我们只提供了右值复制构造器,对于左值复制构造器,我们直接删除掉以禁止使用。原因也很简单,对于每一个协程实例,都有且仅能有一个 Generator 实例与之对应,因此我们只支持移动对象,而不支持复制对象。
序列生成器这个需求的实现其实有个更好的选择,那就是使用 co_yield
。co_yield
就是专门为向外传值来设计的,如果大家对其他语言的协程有了解,也一定见到过各种 yield
的实现。
C++ 当中的 co_yield expr
等价于 co_await promise.yield_value(expr)
,我们只需要将前面例子当中的 await_transform
函数替换成 yield_value
就可以使用 co_yield
来传值了:
1 | struct Generator { |
可以看到改动点非常少,运行效果与前面的例子一致。
尽管可以实现相同的效果,但通常情况下我们使用 co_await
更多的关注点在挂起自己,等待别人上,而使用 co_yield
则是挂起自己传值出去。因此我们应该针对合适的场景做出合适的选择。
接下来我们要使用序列生成器来实现一个更有意义的例子,即斐波那契数列。
1 | Generator fibonacci() { |
我们看到这个实现非常的直接,完全不需要考虑 fib(N - 1) 和 fib(N - 2) 的存储问题。
如果没有协程,我们的实现可能是这样的:
1 | class Fibonacci { |
使用时先构造一个 Fibonacci 对象,然后调用 next 函数来获取下一个值。对比之下,协程的实现带来的好处是显而易见的。
本文围绕序列生成器这个经典的协程案例介绍了协程的销毁、co_await 运算符、await_transform 以及 yield_value 的用法。
说出来你可能不信,如果这篇文章你能够完全理解,那么相信你对 C++ 协程特性的了解已经比较全面了。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
C++ 20 当中正式对协程做出了初步的支持,尽管这些 API 并不是很友好。
协程就是一段可以挂起(suspend)和恢复(resume)的程序,一般而言,就是一个支持挂起和恢复的函数。
这么说比较抽象,我们下面看一个例子:
1 | void Fun() { |
Fun 是一个非常普通的函数,大家对它的直观印象是什么呢?
作为一个合格的程序员,我们的眼睛就是编译器,我们的脑子就是运行时。相信大家在看完这个函数的定义之后脑子里面已经不自主的把它运行过了:这个函数一旦开始,就无法暂停。
如果一个函数能够暂停,那它就可以被认为是我们开头提到的协程。所以挂起你就可以理解成暂停,恢复你就理解成从暂停的地方继续执行。
下面我们给出一段 C++ 协程的不完整的例子:
1 | Result Coroutine() { |
Result 的定义我们后面再谈论,大家只需要知道 Result 是按照协程的规则定义的类型,在 C++ 当中,一个函数的返回值类型如果是符合协程的规则的类型,那么这个函数就是一个协程。
请大家留意一下这个函数体当中的 co_await std::suspend_always{};
,其中 co_await
是个关键字,它的出现,通常来说就会使得当前函数(协程)的执行被挂起。也就是说我们在控制台看到输出 1 以后,很可能过了很久才看到 2,这个“很久”也一般不是因为当前执行的线程被阻塞了,而是当前函数(协程)执行的位置被存起来,在将来某个时间点又读取出来继续执行的。
很多读者在初次接触到协程这个概念的时候,总是会想得太过于复杂,以至于觉得挂起和恢复充满了神秘色彩而无法理解。这确实大可不必,你只要能理解听歌的时候可以暂停继续,能理解下载的时候可以断点续传,那你就必然可以理解协程的挂起和恢复。
那么问题来了,在我们现有的语言特性框架下,如何实现所谓的挂起和恢复呢?
我们以音频文件的播放为例,我们将其与协程的执行做对比,例如整个音频文件对比协程的函数体(即协程体),完整的对比见下表:
音频 | 协程 |
---|---|
音频文件 | 协程体 |
音频播放 | 协程执行 |
播放暂停 | 执行挂起 |
播放恢复 | 执行恢复 |
播放异常 | 执行异常 |
播放完成 | 协程返回 |
音频暂停的时候需要记录音频暂停的位置,同时之前正在播放的音频也不会销毁(即便销毁重建,也要能够完全恢复原样)。
类似地,协程挂起时,我们需要记录函数执行的位置,C++ 协程会在开始执行时的第一步就使用 operator new
来开辟一块内存来存放这些信息,这块内存或者说这个对象又被称为协程的状态(coroutine state)。
协程的状态不仅会被用于存放挂起时的位置(后称为挂起点),也会在协程开始执行时存入协程体的参数值。例如:
1 | Result Coroutine(int start_value) { |
这里的 start_value
就会被存入协程的状态当中。
需要注意的是,如果参数是值类型,他们的值或被移动或被复制(取决于类型自身的复制构造和移动构造的定义)到协程的状态当中;如果是引用、指针类型,那么存入协程的状态的值将会是引用或指针本身,而不是其指向的对象,这时候需要开发者自行保证协程在挂起后续恢复执行时参数引用或者指针指向的对象仍然存活。
与创建相对应,在协程执行完成或者被外部主动销毁之后,协程的状态也随之被销毁释放。
看到这里,大家也不必紧张,协程的状态的创建和销毁都是编译器帮我们处理好的,不需要我们显式的处理。
协程的挂起是协程的灵魂。C++ 通过 co_await
表达式来处理协程的挂起,表达式的操作对象则为等待体(awaiter)。
等待体需要实现三个函数,这三个函数在挂起和恢复时分别调用。
1 | bool await_ready(); |
await_ready 返回 bool 类型,如果返回 true,则表示已经就绪,无需挂起;否则表示需要挂起。
标准库当中提供了两个非常简单直接的等待体,struct suspend_always
表示总是挂起,struct suspend_never
表示总是不挂起。不难想到,这二者的功能主要就是依赖 await_ready 函数的返回值:
1 | struct suspend_never { |
await_ready 返回 false 时,协程就挂起了。这时候协程的局部变量和挂起点都会被存入协程的状态当中,await_suspend 被调用到。
1 | ??? await_suspend(std::coroutine_handle<> coroutine_handle); |
参数 coroutine_handle 用来表示当前协程,我们可以在稍后合适的时机通过调用 resume 来恢复执行当前协程:
1 | coroutine_handle.resume(); |
注意到 await_suspend 函数的返回值类型我们没有明确给出,因为它有以下几种选项:
可见,await_suspend 支持的情况非常多,也相对复杂。实际上这也是 C++ 协程当中最为核心的函数之一了。
协程恢复执行之后,等待体的 await_resume 函数被调用。
1 | ??? await_resume(); |
同样地,await_resume 的返回值类型也是不限定的,返回值将作为 co_await
表达式的返回值。
了解了以上内容以后,我们可以自己定义一个非常简单的等待体:
1 | struct Awaiter { |
1 | Result Coroutine() { |
程序运行结果如下:
1 | 1 |
其中 “1000” 在 “1” 输出 1 秒之后输出。
说明:co_await 后面的对象也可以不是等待体,这类情况需要定义其他的函数和运算符来转换成等待体。这个我们后面再讨论。
我们前面提到,区别一个函数是不是协程,是通过它的返回值类型来判断的。如果它的返回值类型满足协程的规则,那这个函数就会被编译成协程。
那么,这个协程的规则是什么呢?规则就是返回值类型能够实例化下面的模板类型 _Coroutine_traits
:
1 | template <class _Ret, class = void> |
简单来说,就是返回值类型 _Ret
能够找到一个类型 _Ret::promise_type
与之相匹配。这个 promise_type
既可以是直接定义在 _Ret
当中的类型,也可以通过 using
指向已经存在的其他外部类型。
此时,我们就可以给出 Result
的部分实现了:
1 | struct Result { |
我们再看一下协程的示例:
1 | Result Coroutine(int start_value) { |
这时你已经了解 C++ 当中如何界定一个协程。不过你可能会产生一个新的问题,返回值是从哪儿来的?协程体当中并没有给出 Result 对象创建的代码。
实际上,Result 对象的创建是由 promise_type 负责的,我们需要定义一个 get_return_object
函数来处理对 Result 对象的创建:
1 | struct Result { |
不同于一般的函数,协程的返回值并不是在返回之前才创建,而是在协程的状态创建出来之后马上就创建的。也就是说,协程的状态被创建出来之后,会立即构造 promise_type
对象,进而调用 get_return_object
来创建返回值对象。
promise_type
类型的构造函数参数列表如果与协程的参数列表一致,那么构造 promise_type
时就会调用这个构造函数。否则,就通过默认无参构造函数来构造 promise_type
。
在协程的返回值被创建之后,协程体就要被执行了。
为了方便灵活扩展,协程体执行的第一步是调用 co_await promise.initial_suspend()
,initial_suspend
的返回值就是一个等待对象(awaiter),如果返回值满足挂起的条件,则协程体在最一开始就立即挂起。这个点实际上非常重要,我们可以通过控制 initial_suspend 返回的等待体来实现协程的执行调度。有关调度的内容我们后面会专门探讨。
接下来执行协程体。
协程体当中会存在 co_await、co_yield、co_return 三种协程特有的调用,其中
对于返回一个值的情况,需要在 promise_type 当中定义一个函数
1 | ??? return_value(); |
例如:
1 | struct Result { |
此时,我们的 Coroutine 函数就需要使用 co_return 来返回一个整数了:
1 | Result Coroutine() { |
1000 会作为参数传入,即 return_value 函数的参数 value 的值为 1000。
这时候读者可能会疑惑,这个值好像没什么用啊?大家别急,这个值可以存到 promise_type 对象当中,外部的调用者可以获取到。
除了返回值的情况以外,C++ 协程当然也支持返回 void。只不过 promise_type 要定义的函数就不再是 return_value 了,而是 return_void 了:
1 | struct Result { |
这时,协程内部就可以通过 co_return 来退出协程体了:
1 | Result Coroutine() { |
协程体除了正常返回以外,也可以抛出异常。异常实际上也是一种结果的类型,因此处理方式也与返回结果相似。我们只需要在 promise_type 当中定义一个函数,在异常抛出时这个函数就会被调用到:
1 | struct Result { |
当协程执行完成或者抛出异常之后会先清理局部变量,接着调用 final_suspend 来方便开发者自行处理其他资源的销毁逻辑。final_suspend 也可以返回一个等待体使得当前协程挂起,但之后当前协程应当通过 coroutine_handle 的 destroy 函数来直接销毁,而不是 resume。
本文我们介绍了一些 C++ 协程的各种概念和约定,看似介绍了非常多的内容,但因为示例较少又感觉什么都没介绍。大家不要着急,C++ 协程的概念基本上就这么多,剩下的文章我们都将基于一个或多个具体的场景展开来介绍如何运用 C++ 协程来解决问题。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
C++ 20 标准发布之后,协程终于正式成为 C++ 特性当中的一员。
作为一门本身极其复杂的语言,C++ 秉承着不劝退不开心的原则,将协程的 API 设计得非常复杂。以至于有开发者甚至发出了“这玩意根本就不是给人用的”这样的感叹。
等等,我们是不是搞错了,C++ 协程的 API 确实不是设计给业务开发者直接使用的。实际上,标准当中给出的 API 足够的灵活,也足够的基础,框架的开发者可以基于这些 API 将过去异步的函数改造成协程风格的版本。
没错,这就是 C++。
一门不造轮子就让人不舒服的语言,它总是在用它自己的方式逼着开发者进步。为了帮助大家认识和了解 C++ 协程的设计思路以及基本用法,我计划写几篇文章来介绍一下 C++ 协程的相关特性。
本人 C++ 水平有限,文章内容的安排将尽可能以介绍特性为主,涉及到的框架实现不建议在生产环境当中直接使用。
另外,为了方便读者阅读和实验,文章涉及到的所有源码均已上传于 GitHub: bennyhuo/CppCoroutines。
相信大家读完这一系列文章之后,也还是不一定会 C++ 协程 :)
说明:C++ 23 有望基于协程提供不少有用的支持,例如与异步任务密不可分的 executor、network 等等,不过这些内容我暂时不会在后面的文章当中涉及,等 C++ 23 正式发布之后再做补充。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
现在很多 iOS APP 还是用 Objective-C 写的,异步函数在 Objective-C 当中怎么调用也是个问题。
截止目前,我们已经详细探讨了 Swift 协程当中的绝大多数语法设计,这其中最基本也是最重要的就是异步函数。
在异步函数出现之前,我们通常会为函数添加回调来实现异步结果返回,以 Swift 的网络请求库 Alamofire 为例,它的 DataRequest 有这样一个函数:
1 | public func responseData( |
这个函数有很多参数,不过我们只需要关心最后一个:completionHandler,它是一个闭包,接收一个参数为 AFDataResponse<Data>
的类型作为请求结果。
从 Swift 5.5 开始,我们可以将其包装成异步函数,添加对结果的异步返回、异常的传播以及对取消响应的支持:
1 | func responseDataAsync( |
从异步回调到异步函数总是要经过这样一个包装的过程,这个过程实际上并不轻松。因此我们也更希望第三方开发者在提供异步回调的时候同时提供异步函数的版本来方便我们按需使用。
在以前的 iOS SDK 当中,接收形如 completionHandler 这样的回调的 Objective-C 函数有 1000 多个。例如:
1 | - (void)signData:(NSData *)signData |
这个函数相当于 Swift 的如下函数声明:
1 | func sign(_ signData: Data, |
如果我们对这些函数一个一个完成包装,那必然会耗费大量的时间和精力。因此,Swift 对接收类似的回调并符合一定条件的 Objective-C 函数自动做了一些转换,以上述 signData 函数为例,可以被自动转换为:
1 | func sign(_ signData: Data, using secureElementPass: PKSecureElementPass) async throws -> (Data, Data) |
我们来简单分析一下这个转换过程。
NSError *
之外的两个参数。需要注意的是,回调当中的 signedData 和 signature 的类型均为 NSData *
,它们实际上是可以为 nil 的,单纯考虑类型的映射,它们应该映射成 Swift 的 Data?
类型,而在转换之后的异步函数当中则为 Data
类型,这是因为逻辑上如果这俩个 Data
返回 nil,则应该通过参数 NSError *
来使得异步函数抛出异常。这个细节一定要注意。NSError *
表示结果有可能会出现异常,因此转换后的异步函数是会抛出异常的,声明为 throws。那这个转换需要符合什么条件呢?
我们再给一个例子,请大家注意它的函数名:
1 | -(void)getUserAsync:(NSString *)name completion:(void (^)(User *, NSError *))completion; |
转换后:
1 | func userAsync(_ name: String!) async throws -> User? |
对于以 get 开头的 Objective-C 函数,转换之后函数名当中的 get 被去除了。除此之外其他规则与前面提到的一致。
有了这个转换,很多旧 SDK 当中的 Objective-C 回调函数都可以当成 Swift 的异步函数来调用,可以极大的简化我们的开发流程。
相反地,如果我们定义了 Swift 的异步函数,并且希望在 Objective-C 当中调用,则可以声明成 @objc 异步函数,例如:
1 | @objc class GitHubApiAsync: NSObject { |
GitHubApiAsync 类当中的 listFollowers 函数相当于:
1 | @interface GitHubApiAsync : NSObject |
了解了 Swift 的异步函数如何与 Objective-C 互调用的细节之后,再来看一下 Kotlin 的挂起函数是如何支持被 Swift 调用的。当然这个特性还在实验当中,后续也可能会发生变化。
Kotlin 1.4 开始引入了挂起函数对 Swift 的支持,支持的方式就是讲挂起函数转成回调,例如:
1 | // kotlin |
编译之后会生成 Objective-C 头文件,如下:
1 | __attribute__((objc_subclassing_restricted)) |
生成的类名为 SharedGreeting
,其中 Shared 是模块名。__attribute__((swift_name("Greeting")))
使得这个 Objective-C 类映射到 Swift 当中的名字是 Greeting
。
我们重点关注一下 greetingAsync 函数,它映射成了下面的回调形式:
1 | - (void)greetingAsyncWithCompletionHandler:(void (^)(NSString * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("greetingAsync(completionHandler:)"))); |
Kotlin 挂起函数对于 Objective-C 回调的支持,正好命中了前面讨论的回调自动转换成 Swift 异步函数的条件,因此理论上在 Swift 5.5 当中,我们也可以直接把 Kotlin 的挂起函数当成 Swift 的异步函数去调用:
1 | // swift |
当然这里还有一些细节的问题。Kotlin 1.5.30 当中也对此做了一点点跟进,在生成的 Objective-C 头文件当中添加了对 _Nullable_result
的支持,这使得 Kotlin 的挂起函数在返回可空类型时,能够正确被转化成返回 optional 类型的 Swift 异步函数,例如:
1 | suspend fun greetingAsyncNullable(): String? { |
注意到这个例子的返回值类型声明为 String?
,生成的 Objective-C 函数如下:
1 | - (void)greetingAsyncNullableWithCompletionHandler:(void (^)(NSString * _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("greetingAsyncNullable(completionHandler:)"))); |
仔细对比与 greetingAsync 的差异不难发现,返回值的类型在 greetingAsyncNullable 当中被映射成了 NSString * _Nullable_result
,而在 greetingAsync 当中则映射成了 NSString * _Nullable
。这就不得不提一下 _Nullable_result
与 _Nullable
的差异了,前者可以令转化之后的 Swift 异步函数返回 optional 类型(对应于 Kotlin 的可空类型,nullable type),而后者则返回非 optional 类型(对应于 Kotlin 的不可空类型,nonnull type)。
如果 Kotlin 的挂起函数没有声明为 @Throws
,则只有 CancellationException
会被转换为 NSError
抛到 Swift 当中,其他的都会作为严重错误使程序退出,因此如果需要暴露给 Swift 调用,我们通常建议对于可能有异常抛出的 Kotlin 函数添加 @Throws
注解,例如:
1 | // kotlin |
这样在 Swift 调用时也可以直接捕获到这个异常:
1 | //swift |
程序输出如下:
1 | Error Domain=KotlinException Code=0 "error from Kotlin" UserInfo={NSLocalizedDescription=error from Kotlin, KotlinException=kotlin.IllegalArgumentException: error from Kotlin, KotlinExceptionOrigin=} |
尽管目前 Kotlin 的挂起函数可以被当做 Swift 的异步函数去调用,但 Kotlin 侧仍没有专门仔细地针对 Swift 异步函数调用的场景进行专门的设计和定制。因此像 Swift 侧的取消状态(在 Kotlin 挂起函数中获取 Swift 的 Task 的取消状态)、调度器(Swift 的 actor 以及与 Task 绑定的调度器)、TaskLocal 变量以及 Kotlin 侧挂起函数执行时的调度器、协程上下文等状态都是没有实现传递的。
基于这一点,大家在使用过程中应当尽可能将函数的设计进行简化,避免场景过于复杂而引发令人难以理解的问题。
本文我们探讨了 Swift 协程当中的异步函数(async function)与 Objective-C 的互调用问题,其中介绍了 Objective-C 回调自动映射成 Swift 异步函数的条件和细节,以及 Kotlin 挂起函数对 Swift 异步函数的支持。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导
如果我想要定义一个变量,它的值只在 Task 内部共享,怎么做到呢?
TaskLocal 值就是 Task 私有的值,不同的 Task 对于这个变量的访问将得到不同的结果。
下面我们给出示例演示如何定义一个 TaskLocal 值:
1 | class Logger { |
TaskLocal 值必须定义为静态的存储属性,并使用 TaskLocal 这个属性包装器(property wrapper)来包装。TaskLocal 值也受限于属性包装器的支持范围,不能定义为顶级属性。
变量 tag 的初始值为 default
,属性包装器 TaskLocal 的构造器会接收这个值并存起来备用:
1 | public final class TaskLocal<Value: Sendable>: Sendable, CustomStringConvertible { |
了解属性包装器的读者应该也能想到初始值的定义还可以写:
1 | class Logger { |
通过观察 TaskLocal 的定义,我们也发现它对于被包装的类型是有要求的,即要实现 Sendable 协议。
有关 Swift 属性包装器的介绍,可以参考我之前的一篇文章:Kotlin 的 Property Delegate 与 Swift 的 Property Wrapper。
了解了定义之后,接下来看用法。
首先要写入值,我们只需要调用属性包装器的 withValue 函数,它的声明如下:
1 | final public func withValue<R>( |
调用示例如下:
1 | await Logger.$tag.withValue("MyTask") { |
其中 $tag 就是 tag 的属性包装器的 projectedValue,这个值正是 TaskLocal 这个属性包装器对象本身。
1 | public final class TaskLocal<Value: Sendable>: Sendable, CustomStringConvertible { |
withValue 有两个参数,一个是要绑定给 tag 的值,即 MyTask
;另一个就是一个闭包,这个绑定的值只有在这个闭包当中有效,一旦闭包执行结束,tag 绑定的值的生命周期也就结束了。
接下来我们尝试去读取它:
1 | func logWithTag(_ message: Any) async { |
读取的方式就显得普通而又枯燥了。写法非常直接,不过大家要明白,这个读的行为实际上是通过 TaskLocal 属性包装器完成的。
作为对比,我们给出一个稍微完整的例子:
1 | await Logger.$tag.withValue("MyTask") { |
运行结果如下:
1 | (MyTask): in withValue |
上一篇文章当中我们通过示例演示了 init
和 detach
构造的 Task 实例对 actor 上下文的继承,这次我们给大家再演示一下对 TaskLocal 的继承,以进一步加深大家的理解:
1 | await Logger.$tag.withValue("MyTask") { |
这个例子相比之前的调度器的例子就更显得普通而又枯燥了,程序输出如下:
1 | (MyTask): Task.init |
可以看到,通过 detached
创建的 Task 实例可谓是“六亲不认”,不仅不继承 actor 的上下文,也对 TaskLocal 不管不顾。另外不难想到的是,Swift 并没有提供修改外部 TaskLocal 值的 API,因此外部的 TaskLocal 值只能被继承,不能被修改。
TaskLocal 值虽然看起来就是个静态存储属性,但它的值实际上是存储在 Task 相关的内存当中的。它的读写性能自然也与它的存储方式有关,因此为了确保能够正确合理的使用 TaskLocal,我们有必要了解一下它究竟是如何存储的。
1 | public final class TaskLocal<Value: Sendable>: Sendable, CustomStringConvertible { |
这时候我们注意到有几个关键的函数,它们的定义如下:
1 | "swift_task_localValuePush") ( |
通过 _silgen_name 的值,我们可以找到他们在 C++ 当中的定义,以 _taskLocalValueGet
为例,我们给出 swift_task_localValueGet
的代码:
1 | SWIFT_CC(swift) |
AsyncTask::localValueGet
本质上调用的就是 TaskLocal::Storage::getValue(AsyncTask *,const HeapObject *)
,我们同样可以找到它的实现:
1 | OpaqueValue* TaskLocal::Storage::getValue(AsyncTask *task, |
可见,查找过程其实就是链表的遍历查找,时间复杂度为 O(n)。
我们再稍微观察一下插入和删除的代码:
1 | void TaskLocal::Storage::pushValue(AsyncTask *task, |
不难发现这实际上就是一个采用头插法的单链表。为什么选择这样的设计呢?
显然,绝大多数情况下 TaskLocal 值的数量都不会很多,同时插入的值只在 withValue 函数范围内有效也使得绝大多数查找的值都排在链表前面,因此线性查找的效率并不会存在性能问题。
而链表的结构也使得增删节点非常容易,使用头插法使得 withValue 函数退出时释放销毁对应的值也变得非常容易,时间复杂度只需要 O(1)。
另外,使用单链表来存储 TaskLocal 值还有一个好处,那就是变量遮蔽,例如:
1 | await Logger.$tag.withValue("Task1") { |
运行结果如下:
1 | (Task1): 1 |
简单总结一下,TaskLocal 值是存在链表当中的,我们在使用过程中应当避免使用过多的 TaskLocal 值,也应该适当地减少对 TaskLocal 值的访问次数,以避免性能上最坏的情况出现。
本文我们对 TaskLocal 值的使用和实现机制做了剖析。
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导