莫负休息(Morph Rest)是一款 MacOS休息提醒应用程序。通过定时休息,可以预防视力疲劳、腰间盘突出、颈椎疼痛等职业病,当然也可以辅助提醒喝水,避免尿酸过高,引发肾结石、痛风等疾病。
莫负休息的主要特性:
下载地址——
莫负时钟(Morph Clock)是一款 MacOS屏幕保护程序。它采用了一种你从未见过的动态时钟效果,让你的 Mac成为办公室中最靓的仔~
莫负时钟的主要特性:
下载地址——
计算机图形学中,我们可能会对图形进行各种变换(Transform),如:
首先,我们来介绍一下 2D变换,以便了解变换是如何通过矩阵变换来实现的。
对于缩放变换,它主要包含两种:等比例缩放、非等比缩放。
上图所示,为等比例缩放的示意图。根据等比例缩放的规则,我们可以根据缩放前\(x\) 和
根据此关系式,我们可以进一步推导出缩放矩阵及关系式,如下所示。
\[\begin{aligned}\left(\begin{matrix}x' \\y'\end{matrix}\right)=\left(\begin{matrix}s & 0 \\0 & s \\\end{matrix}\right)\left(\begin{matrix}x \\y\end{matrix}\right)\end{aligned}\]上图所示,为非等比缩放的示意图。根据非比缩放的规则,我们可以根据缩放前\(x\) 和
根据此关系式,我们可以进一步推导出缩放矩阵及关系式,如下所示。对比一下,非等比缩放与等比例缩放的关系式非常相似。
\[\begin{aligned}\left(\begin{matrix}x' \\y' \\\end{matrix}\right)=\left(\begin{matrix}s_x & 0 \\0 & s_y \\\end{matrix}\right)\left(\begin{matrix}x \\y \\\end{matrix}\right)\end{aligned}\]上图所示,为镜像变换的示意图。我们可以根据原始的
根据此关系式,我们可以进一步推导出镜像矩阵及关系式,如下所示。本质上,镜像变换是一种特殊的缩放变换。
\[\begin{aligned}\left(\begin{matrix}x' \\y' \\\end{matrix}\right)=\left(\begin{matrix}-1 & 0 \\0 & 1 \\\end{matrix}\right)\left(\begin{matrix}x \\y\end{matrix}\right)\end{aligned}\]上图所示,为切变变换的示意图。切变变换相对复杂一点,其
根据此关系式,我们可以进一步推导出镜像矩阵及关系式,如下所示。本质上,镜像变换是一种特殊的缩放变换。
\[\begin{aligned}\left(\begin{matrix}x' \\y' \\\end{matrix}\right)=\left(\begin{matrix}1 & a \\0 & 1 \\\end{matrix}\right)\left(\begin{matrix}x \\y \\\end{matrix}\right)\end{aligned}\]上图所示,为旋转变换的示意图。旋转变换的坐标推导需要借助三角函数,最终可得到如下一组关系式。
\[\begin{aligned}x' = cos{\theta}x - sin{\theta}y \\y' = sin{\theta}x + cos{\theta}y \\\end{aligned}\]根据此关系式,我们可以进一步推导出旋转矩阵及关系式,如下所示。
\[\begin{aligned}\left(\begin{matrix}x' \\y' \\\end{matrix}\right)=\left(\begin{matrix}cos\theta & -sin\theta \\sin\theta & cos\theta \\\end{matrix}\right)\left(\begin{matrix}x \\y \\\end{matrix}\right)\end{aligned}\]截止目前位置,所有的的变换都可以通过推导得出一个变换矩阵,以此矩阵乘以任意点(以矩阵表示),都可以得到转换后的点(以矩阵表示),符合线性变换。
下面,我们来看一下比较特殊的平移变换。
上图所示,为平移变换的示意图,同样,我们也可以可得到如下一组关系式。
\[\begin{aligned}x' = x + t_x \\y' = y + t_y \\\end{aligned}\]但是,我们进一步推导,得到的关系式与之前的变换不同,它有额外的偏移量,不符合线性变换,如下所示。
\[\begin{aligned}\left(\begin{matrix}x' \\y' \\\end{matrix}\right)=\left(\begin{matrix}1 & 0 \\0 & 1 \\\end{matrix}\right)\left(\begin{matrix}x \\y \\\end{matrix}\right)+\left(\begin{matrix}t_x \\t_y \\\end{matrix}\right)\end{aligned}\]我们总是希望能使用一个统一的关系式来描述各种变换,然而,平移变换打破了我们的美好预期。那么该如何解决呢?为此,我们引入了齐次坐标。
为了能够统一表示所有变换,我们引入了齐次坐标(HomogenousCoordinates)。这里的核心思想是为每一个点或向量添加一个额外的
此时,我们再来尝试推导平移变换矩阵以及其关系式,可以得到如下所示内容。很显然,原来关系式中的偏移量没有了。
\[\begin{aligned}\left(\begin{matrix}x' \\y' \\w' \\\end{matrix}\right)=\left(\begin{matrix}1 & 0 & t_x \\0 & 1 & t_y \\0 & 0 & 1 \\\end{matrix}\right)\left(\begin{matrix}x \\y \\1 \\\end{matrix}\right)=\left(\begin{matrix}x+t_x \\y+t_y \\1 \\\end{matrix}\right)\end{aligned}\]我们将线性变换和平移变换的组合,称为仿射变换(AffineTransform),如下所示。在未引入齐次坐标之前,我们推导出来的平移变换就是一种仿射变换。
\[\begin{aligned}\left(\begin{matrix}x' \\y' \\\end{matrix}\right)=\left(\begin{matrix}a & b \\c & d \\\end{matrix}\right)\left(\begin{matrix}x \\y \\\end{matrix}\right)+\left(\begin{matrix}t_x \\t_y \\\end{matrix}\right)\end{aligned}\]当引入齐次坐标之后,所有的变换都可以统一使用线性变换来表示,如下所示。
\[\begin{aligned}\left(\begin{matrix}x' \\y' \\1 \\\end{matrix}\right)=\left(\begin{matrix}a & b & t_x \\c & d & t_y \\0 & 0 & 1 \\\end{matrix}\right)\left(\begin{matrix}x \\y \\1 \\\end{matrix}\right)\end{aligned}\]如下所示,是引入齐次坐标后,缩放变换,旋转变换,平移变换所对应的变换矩阵。
\[\begin{aligned}缩放变换:&S(s_x, s_y) =\left(\begin{matrix}s_x & 0 & 0 \\0 & s_y & 0 \\0 & 0 & 1 \\\end{matrix}\right)\\\\旋转变换:&R(\alpha) =\left(\begin{matrix}cos\alpha & -sin\alpha & 0 \\sin\alpha & cos\alpha & 0 \\0 & 0 & 1 \\\end{matrix}\right)\\\\平移变换:&T(t_x, t_y) =\left(\begin{matrix}1 & 0 & t_x \\0 & 1 & t_y \\0 & 0 & 1 \\\end{matrix}\right)\end{aligned}\]我们将所有的反向变换都称为 逆变换(InverseTransform),比如:我们将从 A 平移到 B 称为平移变换,那么从 B 平移到 A则可称为逆变换,其他的缩放变换、旋转变换同样如此。
上一节,我们引入了齐次坐标后,所有的变换都可以转换成线性变换,其中以\(M\)为变换矩阵。而这些变换的逆变换,同样可以使用线性变换来表示,并以
在真实情况下,我们遇到的变换大多数都是组合变换,也就是同时包含了缩放、旋转、平移等多种变换。
多种变换组合时,变换的顺序其实是非常重要的,我们以如下一个例子来进行介绍。
对于上面这种变换,如果我们先平移,再旋转,那么最终会变成如下所示的。这里的根本原因在于旋转变换时,仍然是以坐标原点为锚点进行旋转。
对此,正确的顺序应该是先旋转,后平移,这样才能达到预期的效果。
不同的顺序,矩阵变换的结果完全不同。前一篇文章我们提到过矩阵乘法不符合交换律,从这一点其实也能够解释这个现象。
在实际开发中,遇到这种类似的情况,我们一般都会先将目标平移至原点,然后进行各种其他变换,然后再通过逆变换平移回去。
关于 3D 变换,本质上与 2D变换一样,只不过在矩阵表示上多了一个维度而已。
当我们引入齐次坐标之后,3D 的点和向量可以采用如下方式表示。
\[\begin{aligned}3D点的齐次坐标表示:&\left(\begin{matrix}x \\y \\z \\1 \\\end{matrix}\right)\\\\3D向量的齐次坐标表示:&\left(\begin{matrix}x \\y \\z \\0 \\\end{matrix}\right)\end{aligned}\]与此对应,3D 变换的矩阵变换关系式为如下所示。
\[\begin{aligned}\left(\begin{matrix}x' \\y' \\z' \\1 \\\end{matrix}\right)=\left(\begin{matrix}a & b & c & t_x \\d & e & f & t_y \\g & h & i & t_z \\0 & 0 & 0 &1 \\\end{matrix}\right)\left(\begin{matrix}x \\y \\z \\1 \\\end{matrix}\right)\end{aligned}\]如下所示,为 3D 空间中的缩放变换的变换矩阵的定义。
\[\begin{aligned}S(s_x, s_y, s_z)=\left(\begin{matrix}s_x & 0 & 0 & 0 \\0 & s_y & 0 & 0 \\0 & 0 & s_z & 0 \\0 & 0 & 0 & 1 \\\end{matrix}\right)\end{aligned}\]如下所示,为 3D 空间中的平移变换的变换矩阵的定义。
\[\begin{aligned}T(t_x, t_y, t_z)=\left(\begin{matrix}1 & 0 & 0 & t_x \\0 & 1 & 0 & t_y \\0 & 0 & 1 & t_z \\0 & 0 & 0 & 1 \\\end{matrix}\right)\end{aligned}\]如下所示,为 3D空间中的旋转变换的变换矩阵的定义,沿着不同的轴旋转,变换矩阵的定义也有所不同。
\[\begin{aligned}R_x(\alpha)=\left(\begin{matrix}1 & 0 & 0 & 0 \\0 & cos\alpha & -sin\alpha & 0 \\0 & sin\alpha & cos\alpha & 0 \\0 & 0 & 0 & 1 \\\end{matrix}\right)\\\\R_y(\alpha)=\left(\begin{matrix}cos\alpha & 0 & sin\alpha & 0 \\0 & 1 & 0 & 0 \\-sin\alpha & 0 & cos\alpha & 0 \\0 & 0 & 0 & 1 \\\end{matrix}\right)\\\\R_z(\alpha)=\left(\begin{matrix}cos\alpha & -sin\alpha & 0 & 0 \\sin\alpha & cos\alpha & 0 & 0 \\0 & 0 & 1 & 0 \\0 & 0 & 0 & 1 \\\end{matrix}\right)\end{aligned}\]本文我们简单梳理了一下缩放、旋转、平移几种变换对应的矩阵关系式。其中,平移变换比较特殊,为了能够统一关系式,我们引入了齐次坐标,在点、向量的矩阵表示中增加了一个维度。然后,我们介绍了一下在组合变换中变换顺序的重要性。最后,我们简单总结了3D 变换的矩阵关系式。
点(Point)表示坐标系中的一个特定位置,其具体表示和抽象表示分别如下。
在具体表示中,数字序列的顺序很重要。按照惯例,在 2D 平面中依次表示\(x\)、
向量(Vector)表示两个点所构成线段的长度和方向,其具体表示和抽象表示分别如下。
在定义中我们提到向量包含了两个点之间的长度和方向两种信息。对此,我们可以各自使用一种方式来表示这两种信息。
^
上标 的方式表示单位向量(Unit Vector),即长度等于 1 的向量,如:单位向量可以通过向量除以向量长度的方式计算得到,如下所示。
\[\begin{aligned}\widehat{a} = \vec{a} / |\vec{a}|\end{aligned}\]在计算机图形学中,单位向量的应用非常多,比如:法线向量。在计算光线的折射和反射时,法线必不可少。
向量的加减运算可以使用 平行四边形法则 或三角形法则 进行计算,如下图所示。
向量的加减运算非常简单,只需要把两个向量的对应坐标的值进行加减运算即可,如下所示。
\[\begin{aligned}\vec{a} + \vec{b}=\left(\begin{matrix}a_x & a_y\end{matrix}\right)+\left(\begin{matrix}b_x & b_y\end{matrix}\right)=\left(\begin{matrix}a_x + b_x & a_y + b_y\end{matrix}\right)\\\vec{a} - \vec{b}=\left(\begin{matrix}a_x & a_y\end{matrix}\right)-\left(\begin{matrix}b_x & b_y\end{matrix}\right)=\left(\begin{matrix}a_x - b_x & a_y - b_y\end{matrix}\right)\end{aligned}\]向量的乘法运算比较特殊,它有两种乘法运算,分别是:
两个向量之间的点积是一个数值,一般使用 点运算符表示。
点积的运算非常简单,只要将每个向量对应的坐标值相乘并求和即可,如下所示为一个点积的示例。
\[\begin{aligned}\vec{a} \cdot \vec{b}=\left(\begin{matrix}a_x & a_y & a_z\end{matrix}\right)\left(\begin{matrix}b_x \\b_y \\b_z \\\end{matrix}\right)=a_x \cdot b_x + a_y \cdot b_y + a_z \cdot b_z\end{aligned}\]向量点积的特性
在计算机图形学中,点积的应用非常广泛,主要包括:
下面,我们来看一下这几种应用是如果通过计算实现的。
首先,如何计算两个向量之间的夹角?在几何上,两个向量的点积与它们的长度以及它们之间的夹角\(a\)有关,确切的公式巧妙地将线性代数和三角函数联系在了一起,如下所示。
其次,如何计算一个向量在另一个向量上的投影?如下所示,求向量
接着,如何计算一个向量正交分解后的两个向量?上面我们在计算一个向量在另一个向量上的投影时,已经计算得到了一个方向的分解向量,另一个方向的分解向量我们只需通过向量减法即可得到,如下所示。
最后,如何判断一个向量相对于另一个向量是正向还是反向?判断两个向量的方向关系,本质上是看两者之间的夹角,如果是锐角,则认为是正向,如果是钝角,则认为是反向,如下所示。
两个向量之间的叉积是一个向量,一般使用 叉乘符号表示。
叉积是一个垂直于两个向量的向量,其方向可以通过右手螺旋定则 确定。
向量叉积的特性
在计算机图形学中,乘积的应用主要包括一下这些:
那么,如何判断一个向量相对于另一个向量的左右关系?可以直接判断两个向量叉积的正负值。如下所示,在一个3D 坐标中,\(\vec{a}\) 和
以及,如何判断一个向量相对于一个三角形的内外关系?事实上,我们可以利用上面这种左右关系判断的方法来组合判断。如下所示,我们可以分别判断\(\overrightarrow{AP}\) 和
矩阵是一个 \(m\) 行
矩阵的乘法必须满足一个前提:矩阵 (M x N)(N x P) = (M x P)
。
矩阵 \(A\) 乘以矩阵 (i, j)
的值等于 \(A\) 中第 i
行与\(B\) 中第 j
列的点积,如下所示是一个矩阵乘法的示例。
矩阵乘法的特性
在计算机图形学中,向量也会使用矩阵(行矩阵或列矩阵)来表示,向量之间的乘法以及向量与矩阵的乘法都符合矩阵乘法的基本规则。
矩阵的转置本质上就是沿着主对角线(从左上角至右下角)的对角线将 i x j的矩阵翻转成 j x i 的矩阵。一般我们使用一个 上标 T表示一个矩阵的转置,如:\(A^T\)。
如下所示,是一个矩阵转置运算的示例。
\[\begin{aligned}\left(\begin{matrix}1 & 2 \\3 & 4 \\5 & 6 \\\end{matrix}\right)^T=\left(\begin{matrix}1 & 3 & 5 \\2 & 4 & 6 \\\end{matrix}\right)\end{aligned}\]矩阵转置的特性
下面,我们来介绍各种不同类型的矩阵。
对角矩阵,其主对角线(从左上角到右下角)上的元素都是非0,其他元素都为 0。
单位矩阵,其主对角线(从左上角到右下角)上的元素都为 1,其余元素都为0,一般使用大写字母 \(I\)来表示。单位矩阵是一个特殊的对角矩阵。如下所示,是一个单位矩阵实例。
\[\begin{aligned}I_{3 \times 3}=\left(\begin{matrix}1 & 0 & 0 \\0 & 1 & 0 \\0 & 0 & 1 \\\end{matrix}\right)\end{aligned}\]给定一个矩阵
上述我们介绍的向量的两种运算,其实完全可以使用矩阵的乘法来实现。
关于向量的点积,我们可以使用如下矩阵乘法来表示。
\[\begin{aligned}\vec{a} \cdot \vec{b}=\vec{a}^T \cdot \vec{b}=\left(\begin{matrix}a_x & a_y & a_z\end{matrix}\right)\left(\begin{matrix}b_x \\b_y \\b_z \\\end{matrix}\right)=a_x \cdot b_x + a_y \cdot b_y + a_z \cdot b_z\end{aligned}\]关于向量的叉积,我们可以使用如下矩阵乘法来表示,如下所示。其中
本文介绍了点、向量、矩阵的基本定义和运算方法。向量的乘法包含两种:点积和叉积,两者被广泛应用在了在计算机图形学中。
点积和叉积的具体运算可以通过矩阵运算来实现,这也是为什么我们常说计算机图形学中包含了大量矩阵运算。
下文,我们将探讨矩阵在图形的变换中的应用,敬请期待吧~
每年总是要例行回顾一下过去一年,看看自己做了什么,收获了什么。
今年是作为移动客户端负责人的第 2年,自己基本已经适应了这个角色。一开始,我和团队中很多成员都是一线的研发,后面被提拔到这个位置。那时候,在技术决策、任务分配、会议沟通时,经常会想自己的决策和做法是否被认可?是否被信服?团队成员是否认可自己?总之,心理负担一直都是有的。
在业务的迭代和发展过程中,我会回顾自己做的决策。从结果看来,整体都是符合预期的,比如:
正确的决策会带来正向的激励,从而产生正反馈效应。于是,之前心理负担开始慢慢的消失,自己对于这个角色也开始逐步适应,慢慢开始变得得心应手起来。
在工作产出方面,今年主要做了一些工程能力和技术调研等工作,比如:
今年,在 iOS同学外派支援期间,我做了一些业务需求,其他时间基本都没有参与复杂业务和模块的具体开发。因为团队内Android 和 iOS的研发人员数量对等,所以不需要我来承担额外的开发任务。只有当出现临时需求或者排期时没有分配的需求时,为了不打乱既定的排期,一般会由我来兜底做这些需求,一个人同时写Android 和 iOS。
整体而言,今年开始逐步退居二线,做一些技术决策和工程能力等相关工作。不过,在日常中我仍然坚持写代码,因为我始终觉得一旦自己脱离一线太久,很容易会作出一些不符合现实的决策和排期。
从 2021 年下半年到 2022年上半年,我一直有着眼睛疲劳的症状,具体表现就是眼睛无法准确对焦。当我在观察 3米以外的物体时,大脑中呈现的视觉效果是有两个物体(两个眼镜各自成像的物体),两个物体无法合成到一个画面中,需要非常努力的弄眉挤眼才能对焦上,但是过不了多久又会失焦。
这个问题我一直都没有发现,因为在工作生活中,眼睛对焦基本都在 3米以内。最后是在电影院观影时才发现的,画面有重影,观影感极差。从那时起,我才开始重视视力问题。
在 4月份,我预约了同仁医院的眼科挂号。在医院里,我看到了各种饱受眼科疾病困扰的患者,青光眼、视网膜脱落、近视手术后遗症等等,这让我视力恢复之前都非常焦虑。在经历一系列眼科诊断之后,医生得出的结论是眼睛疲劳+近视度数上涨。于是,在同仁医院配了一副眼镜,自己额外配了一副隔蓝光的镜片,配合着服用叶黄素,进行修养。总体来说,是有效果的,但是效果还是有点慢。
在 5月份,五一长假休假在家,我尝试尽量不使用电脑和手机。即使使用电脑,也是投屏到电视上,然后坐在沙发上观看电视屏幕,尽量保持远距离观看。经过一个多星期的调养。眼睛疲劳改善非常明显。假期结束后,我期望着眼睛能完全恢复,可惜大概一个月作用的时间,眼睛又开始疲劳。特别是中午遇到强光时,症状会更加严重。
在 8月份,我开始意识到眼睛疲劳可能是因为睡前和醒后躺在床上刷手机导致的。每次睡觉前我都会不由自主地刷一个多小时的手机,早上醒来也是躺在床上刷一个多小时手机,加上姿势不正确,导致视力疲劳。于是,我开始强制自己在床上玩手机不超过20 分钟。坚持了半年了,现在视力明显恢复了。
在视力恢复之前,我一度非常焦虑,经常思考程序员的职业给我带来了什么?如果视力无法治疗该怎么办?...好在现在恢复了,这次经历让我明白了身体健康的重要性。一定要注意身体,不要让打工挣的钱成为身体的医疗费!从而言之,身体是革命的本钱。
8 月份,在 @昱总的安利下,我办了天奥的健身卡,怕自己坚持不下来,先办了一年的年卡。由于 8月份期间参加各种篮球赛,所以真正开始规律健身应该是从 9月份开始,周一练背,周四练肩,周五练手臂,偶尔练练卧推。目前卧推能 60KG做组,左右手力量也均衡了很多。除此之外,双十一配了肌酸和蛋白粉,喝的不算多,佛系健身。于我而言,健身的目的是为了自己变得壮一点,而不是看起来像细狗,仅此而已,什么健体、健美并不是我的目标。
今年算是工作以来打篮球最多的一年了,首先是固定每周二中午打 2小时篮球。另外就是篮球赛,8 月参加了 CBD 篮球联赛,9 月参加了 CYBA篮球联赛,这两个月周末总有一天是在打篮球。
2022年终总结时给自己定了一个目标——参加一次半程马拉松。因此,我计划参加4月份的北京半程马拉松。结果,等到报名时发现要求必须三年内参加过其他马拉松,并提供相关证明。没办法,没有资格参加,只能选择参加奥森马拉松。
我从 2.25 开始备战,从 5 公里开始,每周跑一次,每次比上一次增加 2.5公里左右,最终达到 21 公里。练了一次 21公里后,参加比赛。最终成绩还不错,用时 2:01:52
。定一个 2024年的小目标——半马破 2 小时。
参加半马之后,我开始坚持每周末都跑一次 10公里,偶尔还会参加一下线上马拉松,收集了不少奖牌。最终坚持到了 10月底,11 月份室外跑步属实太冷了,打算 2 月份重新开始。
正是办了健身卡之后,我和媳妇开始调整生活作息,拒绝熬夜,晚上 11:20之前睡觉。早上差不多能 6、7点起床,起来后去四得公园,媳妇跑步,我则散步。在公园大概 40分钟,期间能呼吸一下新鲜空气,放空一下大脑。当然在一个人散步的时候会思考很多,比如:职业规划、业余项目、技术问题等。散步结束回来大概8 点左右,还能有两个小时看会儿书或写会儿代码。
调整作息之后,感觉自己的精神状态好了很多,下班时间的使用效率也变得更高了。当然,周末也不再是没有上午的周末,时间也变得更加充足。作息调整是今年个人转变的最大成就,为了健康和效率,未来一直要继续保持下去。
上半年因为眼睛问题,有意减少电脑使用时间,下半年业余时间主要在项目,因此整体而言,2023年在学习上投入的时间并不是很多。关于学习方面的成就主要有以下几部分。
因为尽量不过度用眼,今年看的书并不多,只有以下几本:
今年写的博客也不多,年初的时候产出了几篇编程语言相关的博客:
年中的时候研究 Homebrew 和 fishhook 产出了两篇原理分析博客:
最后就是十一那会儿写了两篇关于差分算法的博客:
今年业余时间总共做了三个半项目,相比之前几年,产出高出了不少,希望明年继续保持。
第一个项目是
第二个项目是 Morph Clock(中文名:莫负时钟)屏幕保护程序。这是一款MacOS 屏幕保护程序,采用一种数字变形的艺术效果实现。
第三个项目是
Morph Clock 和 Morph Rest 则是面向普通 Mac用户的独立产品。项目代码并没有开源,因为我希望能够通过它们创造收入,这里定一个小目标:在未来2024 年内通过独立产品创造 99美元的收入,回收的开通苹果开发者账号的成本。如果有用户支持,欢迎下载使用。
其他方面的学习收获也是有的,首先是 Android开发,春节假期期间,在家学习了一下 Android 开发,重写了海豚 AI学中的一个 Flutter 页面,算是入门了 Android。鉴于此,下半年能够做一些Android 小需求。但是没有深入研究 Android开发,也没有做过一些复杂业务开发,这一方面希望 2024 年能够有所改善。
其次,在下半年做独立产品期间,系统性地学习了 Sketch相关技巧和理论。Morph Rest 和 Morph Clock 相关的 UI设计和切图也都是自己完成的,算是额外掌握了一个 Indie Hacker必备的技能吧。
最后,系统性地学习了一下 MacOS 开发,它与 iOS开发在整体上一致的,在一些实现细节上有所不同。如果按照自己所认知的 iOS原理来开发 MacOS 应用会遇到很多奇怪的 BUG。在系统性学习之后,再来开发MacOS 应用会简单很多,这一点我深有体会。
今年是工作以来第一个没有债务的年份,因此不再考虑紧巴巴地生活了,该吃吃,该喝喝,该玩玩。不过因为疫情三年养成了一种「宅」感,所以还需要继续调整和适应。
五一假期回合肥休假,为了调养眼睛疲劳,没怎么学习,主打的就是休假。期间主要在滨湖转悠,骑上共享电驴,环游了一些景点和公园,渡江战役纪念馆、岸上草原、安徽名人馆、塘西河公园、金斗公园等。比较可惜的是,没约上安徽美术馆,不过以后有的是机会。
在家期间,用闲置的 Mac Mini配上电视,效果很不错,也很护眼。用这一套装置在家看完了《漫长的季节》!《漫长的季节》成为了我心中国产剧的No.1,墙裂推荐!
7月份,我在朋友圈看到有同事去了廊坊的只有红楼梦·梦幻戏剧城,感觉很不错,加上自己很喜欢《红楼梦》,所以抽了一个周末去了一趟廊坊。园区非常大,网上的评价大多是一天的游玩时间不够,于是我们就订了2 日通票。不得不说,里面的建筑和剧场都非常惊艳!绝对值得去玩一次!
不过很可惜,我们去的那个周末天气不太好。周六阴天,周日暴雨。因为暴雨园区闭园,给我们退了一半的票,算下来也就是玩了一天时间,差不多玩了大半个园区吧,只不过话剧和情景剧没看够。
今年 10月份原本打算去哈尔滨,结果跟我弟了解了一下情况后,决定等到冰雪大世界开放之后再去。最终在元旦前请了几天假提前出发,主要是为了避开假期旅游高峰。好巧不巧,哈尔滨旅游今年出圈了,游客非常多,几个热门项目排队时间都超长,几乎每个都要排队3个小时起步,比如:大滑梯、摩天轮、哈冰秀。我们在冰雪大世界整一天就是佛系游玩,毕竟在零下十度的室外排队几个小时的体验可不是那么好。不过有一说一,冰雪大世界里的冰雕、雪雕确实都非常精美、壮观,绝对值得去参观一次!
今年 10 月份搬了一次家,离开了住了 6年的高家园。高家园附近环境其实很不错,小区门口很多街边商店,很繁华;马路对面就是丽都,是一个相对比较高端的街区;500米远处是四得公园,疫情期间翻修了一次,环境非常不错。因为生活很方便,所以在这里住了6年。搬家期间,特别是对面的室友搬走的时候,内心非常感慨:岁月匆匆,人生匆匆,北漂生活何时终了?
2023 年,我感觉自己最大的变化是思维的转换,主要是两点:
第一点不用多说,是眼睛疲劳期间非常焦虑,那会儿才真正体会和理解这一点。第二点是因为今年8月开始早起散步,散步期间开始思考未来的打算。这两年各种裁员消息层出不穷,即使你学历再好,技术再厉害,当公司不需要你时,无外乎其他任何因素,随时都可能裁你。一旦失业,你再就业的难度会与你的年龄正比,这是非常现实的问题。
于是,我开始逛一下独立开发者相关的网站,比如:Indie Hacker,ProductHunt,w2solo。在这些论坛中,我看到了很多独立开发者的成功案例,这也激励了我尝试使用业余时间来走这条道路。11月份,我开始着手做一款 iOSApp,期间自己做产品调研,画设计稿,代码实现。期间感觉自己对于产品的最终效果还是有点不确定,而且担心战线太长,所以果断暂停了项目,转而开发形态更加确定的一款MacOS App——
未来一年,我应该还会继续尝试做一些独立产品,努力成为 IndieHacker。当然,技术博客也会被不定期更新,毕竟这是热爱,而不是生活。
最后,祝新年快乐~
]]>我的博客文章配图基本上都是使用 Sketch绘制的,但是绘制方法仅限我的自我认知而已。由于没有系统性地学习过Sketch,因此在遇到一些复杂场景时,绘制的效率非常低。于是最近业余时间在 B站上学习了一套 Sketch 教程——
这里记录一下教程中提到的快捷键技巧,便于后续参考。经过实测,这些技巧确实能够提升效率,文章封面图就是学完教程结合技巧绘制的图标。
功能 | 快捷键 |
---|---|
新建画板(New Artboard) | A |
插入矩形(Rectangle) | R |
插入原型(Oval) | O |
插入文本(Text) | T |
钢笔工具(Vector Point) | V |
放大(Zoom | 按住 Z,然后框选想放大的区域 |
查看间距(Guides) | Alt(选中一个图层,按住Alt,鼠标移动移动到另一个图层,可查看选中图层到指向图层的间距) |
创建分组(Group) | ⌘ G |
取消分组(Ungroup) | ⌘ ⇧ G |
复制上一步操作(Duplicate | ⌘ D |
编辑(Edit) | Enter |
功能 | 快捷键 |
---|---|
吸取颜色(Color Picker) | ⌃ C |
功能 | 快捷键 |
---|---|
复制图层样式(Copy Style) | ⌘ ⌥ C |
粘贴图层样式(Paste Style) | ⌘ ⌥ V |
功能 | 快捷键 |
---|---|
演示模式(Presentation Mode) | ⌘ . |
以画布为中心放大(Center Canvas) | ⌘ 1 |
以选择的图层为中心放大(ZoomSelection) | ⌘ 2 |
视图放大 | ⌘ + |
视图缩小 | ⌘ - |
恢复到画布实际大小 | ⌘ 0 |
功能 | 快捷键 |
---|---|
在图层面板从上往下选择图层(SelectingLayer Below) | tab |
在图层面板从下往上选择图层(SelectingLayer Below) | ⇧ tab |
上移图层(Bring Forward) | ⌘ ] |
下移图层(Bring Backward) | ⌘ [ |
置顶图层(Bring to Front) | ⌘ ⌥ ] |
置底图层(Bring to End) | ⌘ ⌥ [ |
功能 | 快捷键 |
---|---|
隐藏图层 | ⌘ ⇧ H |
锁定图层 | ⌘ ⇧ L |
查找图层 | ⌘ F |
变换工具 | ⌘ ⇧ T |
旋转工具 | ⌘ ⇧ R |
将字体转换成轮廓 | ⌘ ⌥ O |
显示/取消填充 | F |
显示/取消描边 | B |
将当前图层用作蒙版 | ⌘ ⌃ M |
改变形状尺寸 | ⌘ 键盘上/下/左/右 |
切换不同的 Sketch 文件 | ⌘ ~ |
功能 | 快捷键 |
---|---|
打开设置 | ⌘ , |
设备 | 屏幕尺寸 | 屏幕分辨率(px) | 逻辑分辨率(pt) | PPI | 倍率 | 换算 |
---|---|---|---|---|---|---|
iPhone14 | 6.1寸 | 1170x2532 | 390x844 | 460 | @3x | 1pt=3px |
iPhone12 Pro Max | 6.7寸 | 1284x2778 | 428x926 | 458 | @3x | 1pt=3px |
iPhone12 Pro | 6.1寸 | 1170x2532 | 390x844 | 460 | @3x | 1pt=3px |
iPhone11 Pro Max | 6.5寸 | 1242x2688 | 414x896 | 458 | @3x | 1pt=3px |
iPhone11 Pro | 6.1寸 | 1125x2436 | 375x812 | 458 | @3x | 1pt=3px |
iPhone11 | 5.8寸 | 828x1792 | 414x896 | 326 | @2x | 1pt=2px |
iPhone8 Plus | 5.5寸 | 1242x2208 | 414x736 | 401 | @3x | 1pt=3px |
iPhone8 | 4.7寸 | 750x1334 | 375x667 | 326 | @2x | 1pt=2px |
iPhoneSE | 4.0寸 | 640x1136 | 320x568 | 326 | @2x | 1pt=2px |
iPhone3GS | 3.5寸 | 320x480 | 320x480 | 163 | @1x | 1pt=1px |
UI 设计一般以 390x844 或 375x812 为尺寸进行绘制。
]]>通过前一篇文章,我们知道 Myers 差分算法主要用于解决特定设定下的最小编辑距离问题,即:当编辑操作只支持插入 和 删除 时,计算一个文件从A
状态转换成 B
状态所需的最少编辑次数(或编辑方式)。算法特别适用于对编辑次数敏感,但是对速度和内存不敏感的系统,比如版本控制系统。
对比而言,Paul Heckel 差分算法则主要用于解决最小化差异问题,即:当一个文件从 A
状态转换成 B
状态时,两种状态之间的数据差异。算法会为每一项差异定义一个对应的类型(操作),比如:删除、插入、移动等,其侧重点在于最小化差异,而不是最小化编辑。PaulHeckel 差分算法特别适合对计算速度敏感,但是对于差异不敏感 的系统,比如实时数据分析系统,UI框架数据差分。
Paul Heckel算法使用三种类型来表示两个文件之间的差异结果,分别是:删除、插入、移动。基于此,算法的核心思路其实非常简单,分别是:
很显然,根据算法的核心思想,新旧两个文件中的所有行最终都将被分类为三种类型(如果行的内容和位置都没有变化,则分类为移动类型,只不过移动的行号保持不变而已)。
Paul Heckel 差分算法使用两个数组 NA
(New Array)和OA
(OldArray)分别记录新旧两个文件的每一行的信息。数组元素分为两种类型:指针或行号。通过这种方式,我们可以对新旧两个文件中的所有行进行分类:
OA
中对应的元素为指针时,表示这一行是待删除的。NA
中对应的元素为指针时,表示这一行是待插入的。NA
(OA
)中对应的元素为行号时,表示这一行是待移动的。行号记录了它在旧文件(新文件)的位置。两两配对。这里提到了数组元素可能是指针类型,那么它到底指向什么类型的数据呢?事实上,这是Paul Heckel 算法预定义的一种数据类型,这里我们称之为 entry 类型。entry的定义如下所示:
1 | struct entry { |
同时,为了方便快速查找某一行所对应的entry,这里还定义了一个哈希表,算法中称为 符号表(SymbolTable),其中 Key 为行内容,Value 为 entry。
如下所示,为算法所定义的相关数据结构。
算法的实现主要分为三个部分,分别是:
下面依次进行介绍。
在构建阶段,将遍历新文件和旧文件的每一行,同时构建数组、符号表表项(Key为行内容,Value 为 Entry)。
nc
字段加 1。oc
字段加 1,并设置olno
为当前的行号。在构建数组( NA
和 OA
)时,其元素均为指针类型,指向当前行对应在符号表中的 entry。
很显然,构建阶段需要进行两次遍历,其对应在论文中分别是 Pass 1 和 Pass2。
在构建阶段完成后,数组 NA
和 OA
中的所有元素都是指针类型,指向符号表中的某个entry。接下来,只要我们把属于 移动类型的行筛选出来,也就是把数组中的某些元素修改为行号,即可完成对三种类型的分类。
在继续介绍之前,我们先说明一种常见的情况:一个文件中行内容可能是唯一项,也可能是重复项。比如,在下图所示的两个文件中,THE
在新旧两个文件中都重复出现了两次,A
、MASS
、OF
等内容在各自的文件中都是唯一项。对此,算法的处理方式也有所不同。
对于唯一项,很显然,如果符合移动类型的话,行所对应的 entry 中的oc
和 nc
的值均为1,表示它们在新旧文件中各自只出现了一次。
由于移动类型是对等的,所以我们只需要遍历 NA
数组就可以找到所有唯一项的移动类型。具体的做法是:
NA
数组,根据元素(指针类型)找到对应的entry。oc
和 nc
字段是否均为 1。NA
对应的位置的元素设置成行号类型,值为olno
(即该行对应在旧文件中的位置);同时将 OA
的 olno
位置的元素设置成行号类型,值为i
(即当前遍历到的行号)。此时,两者实现了移动匹配(各自记录了彼此的位置)。NA
。在遍历完成后,数组 NA
和 OA
中均可能有一部分元素变成了行号类型,值为对方的某个行号。本轮遍历对应在论文中是Pass 3。
对于重复项,算法采用了一种模糊处理的方式。这里主要遵循了下面这个设定:
If a line has been found to be unaltered, and the lines immediatelyadjacent to it in both files are identical, then these lines must be thesame line. This information can be used to find blocks of unchangedlines.
译:如果某一行没有发生改变,并且在新旧两个文件中与它紧邻的行都是相同的,那么这些行必须是相同的行。这个信息可以用来找到未改变的行块。
上述设定需要寻找某一行前后邻近的行,很显然,需要进行两次遍历,分别是正向遍历和反向遍历。同样,这里只需要遍历NA
即可。
对于正向遍历,会进行以下处理:
NA[i]
是否指向 OA[j]
(当NA[i]
的元素为行号时,且行号为 j
,即表示NA[i]
指向 OA[j]
)NA[i+1]
是否与 OA[j+1]
指向同一个 entry。NA[i+1]
的元素设置成行号类型,值为j+1
;将 OA[j+1]
的元素设置成行号类型,值为i+1
。NA
。对于反向遍历,其处理与正向遍历类似,只不过方向相反:
NA[i]
是否指向 OA[j]
(当NA[i]
的元素为行号时,且行号为 j
,即表示NA[i]
指向 OA[j]
)NA[i-1]
是否与 OA[j-1]
指向同一个 entry。NA[i-1]
的元素设置成行号类型,值为j-1
;将 OA[j-1]
的元素设置成行号类型,值为i-1
。NA
。经过这一番操作后,算法会处理筛选出重复项,下图所示红色标识。这些重复项的移动 类型,可以与某些唯一项连成移动块。
重复项的筛选,经历了两次遍历,分别对应论文中的 Pass 4 和 Pass 5。
经过上述一系列处理之后,数组 NA
和 OA
将所有行分成了三种类型,分别是删除、插入、移动。根据这些信息,我们可以遍历NA
和OA
,输出差异的分析结果。这一步,对应在论文中则是 Pass6。
至此,仔细的同学可能会发现,在重复项筛选的过程中,算法会遗漏一部分属于移动 类型的行,而将它们误判为 删除 或插入 类型。
如下所示为算法误判的一个例子。我们将新文件中的第一个 THE
前后相邻的两个行改成两个唯一项 UNIQUE1
和UNIQUE2
。由于旧文件中不存在 UNIQUE1
和UNIQUE2
,所以它们是属于插入类型的项。此时,我们再看上述Pass 4 和 Pass 5 的执行逻辑,可以看出新文件中的第一个 THE
并不会并筛选为移动类型,而是被错误地认为是插入类型。所以说,Paul Heckel算法对于重复项的处理采用了一种模糊处理的方式。
事实上,这也是 Paul Heckel差分算法的特点,它牺牲了一部分差异精准度,换来了更快的分析速度。这也是为什么我们在「解决了什么问题」这一节中说Paul Heckel 算法适合速度敏感、差异不敏感的系统。
基于 Paul Heckel 算法变种的差分算法应用其实非常多,在 iOS开发中就有很多相关的框架,比如:IGListKit、DifferenceKit、RxDataSources、FlexibleDiff、DeepDiff等。下面,我们来看看 IGListKit 是如何应用并优化 Paul Heckel差分算法的。
由于绝大多数应用都对 Paul Heckel算法进行了一定程度的优化,因此,我们必须要先了解它们到底优化了什么。
事实上,原始的 Paul Heckel 算法只对 位置 和内容 两个维度进行差分检测(符号表的 Key和数据判等都是基于 内容),从而产生 3种分类,如下表所示。
位置 | 内容 | 分类 |
---|---|---|
相同 | 相同 | move(起始位置不变) |
相同 | 不同 | insert/delete |
不同 | 相同 | move |
不同 | 不同 | insert/delete |
但是在实际应用中,为了支持更加复杂多变的场景,一般会额外支持标识 或 ID 的维度。此时,算法将基于标识、位置、内容三个维度进行差分检测(符号表的 Key 基于标识,数据判等基于 内容),对此将产生4 种分类,如下表所示。
标识(ID) | 位置 | 内容 | 分类 |
---|---|---|---|
相同 | 相同 | 相同 | move(起始位置不变) |
相同 | 相同 | 不同 | update |
相同 | 不同 | 相同 | move |
相同 | 不同 | 不同 | update & move |
不同 | 相同 | 相同 | insert/delete |
不同 | 相同 | 不同 | insert/delete |
不同 | 不同 | 相同 | insert/delete |
不同 | 不同 | 不同 | insert/delete |
在数据结构定义上,IGListDiff与原始算法类似,也定义了一个符号表和两个数组,如下图所示。
对于符号表,IGListDiff 使用数据的标识(ID)作为 Key,以IGListEntry
作为 Value。IGListEntry
的定义如下所示,很显然,它与原始算法中的 entry
结构非常类似:
oldCounter
和 newCounter
对应的是oc
和 nc
字段。oldIndex
对应olno
,但是它支持记录多个位置信息,支持处理重复项。olno
只能记录一个位置。IGListEntry
额外还有一个 updated
字段,用于标记扩展的 更新(update)类型。1 | /// Used to track data stats while diffing. |
对于两个数组,IGListDiff同样具备,区别在于元素的表示形式。在原始算法中,NA
和OA
数组存储的元素可能是指针或行号,而 IGListDiff 则使用IGListRecord
类型来记录两种信息,其定义如下所示。其中,entry
用于存储指针,index
用于存储行号。
1 | /// Track both the entry and algorithm index. Default the index to NSNotFound |
除此之外,IGListDiff 定义一个 IGListDiffable
协议,所有希望调用 IGListDiff 差分算法的数据都必须支持该协议。如下所示为IGListDiffable
的定义。
1 | NS_SWIFT_NAME(ListDiffable) |
算法会使用数据的 diffIdentifier
作为标识(ID),符号表也将以此为 Key 记录对应的 entry。另一个协议方法isEqualToDiffableObject:
则用于进行内容判等。
关于输出,原始算法并没有做相关说明。对此,IGListDiff自定义了两种输出结构,分别是 IGListIndexSetResult
和IGListIndexPathResult
,如下所示。
1 | NS_SWIFT_NAME(ListIndexPathResult) |
两者均携带了所有差异数据的位置信息,包括:插入、删除、更新、移动。区别则在于IGListIndexPathResult
用于表示位置信息的类型是NSIndexPath
类型,适用于 iOS中的列表数据,IGListIndexSetResult
用于表示位置信息的类型是NSInteger
类型,更适合通用的数组数据。
IGListDiff 中 IGListDiff.m
文件的IGListDiffing
方法实现了差分算法的核心逻辑,大概可分为三个步骤:
在构建阶段,IGListDiff分别正向遍历新数据和反向遍历旧数据,从而构建数组元素NA
、OA
、符号表,这一点与原始算法类似。这里反向遍历旧数据是因为这里使用栈来记录所有的olno
,以便后续正向地出栈旧数据的位置信息。具体代码如下所示:
1 | unordered_map<id<NSObject>, IGListEntry, IGListHashID, IGListEqualID> table; |
构建完成之后,开始对移动类型和更新类型进行标记,具体如下代码所示:
1 | // pass 3 |
从上述代码中可以看出,每次迭代会从根据 NA
数组元素IGListRecord
的 entry
字段索引到符号表中对应的IGListEntry
。然后从 IGListEntry
的oldIndexes
字段出栈一个旧文件中存在的位置。
如果 originalIndex
存在,则表示 NA
和OA
均存在,此时进一步判断内容,如果内容发生了变化则标记updated
字段为YES
。判断内容变化的方式有两种,用户可以选择配置:
在遍历过程中,还会记录移动类型的位置信息。它的前提条件包含两部分:
newCounter > 0 && oldCounter > 0
:表示新旧数据均存在originalIndex != NSNotFound
:表示新旧数据可以进行移动类型的匹配,每一次匹配,oldIndexes
都会出栈一次,消耗一次对等的匹配。举个例子:newCounter = 3
,oldCounter = 2
时,NA
中第三个数据会被归为插入类型,前两个数据会被归为移动类型。newCounter = 2
,oldCounter = 3
时,OA
中的第三个数据会被归为删除类型,前两个数据会被归为移动类型。首先,遍历 OA
数组,确定删除类型,具体代码如下所示。数组元素的 index
类型为 NSNotFound
类似于原始算法中 OA
元素的类型为指针。当符合这个条件时,表示数据类型为删除类型。
1 | // iterate old array records checking for deletes |
其次,遍历 NA
数组,确定插入、更新、移动等类型,具体代码如下所示。数组元素的index
类型为 NSNotFound
类似于原始算法中NA
元素的类型为指针。当符合这个条件时,表示数据类型为插入类型。否则,均属于移动类型。
这里由于 IGListDiff 额外扩展了一个 updated
字段,所以有一部分元素为同时标记为更新类型。这一点其实很容易理解,当新旧数据中有一个数据标识相同,但是位置不同,且内容不同,我们可以认为它做了一次移动操作修改了位置,又做了一次更新操作修改了内容。
1 | for (NSInteger i = 0; i < newCount; i++) { |
整体而言,IGListDiff 在 Paul Heckel差分算法的基础上扩展了差分类型,从原来的插入、删除、移动 3种类型扩展成插入、删除、移动、更新4 种类型。类型的扩展本质上是通过增加检测维度实现的,Paul Heckel差分算法只支持 位置、内容 2个维度进行检测,而 IGListDiff 则支持标识、位置、内容 3个维度进行检测。
此外,IGListDiff 实现了精准检测,而 Paul Heckel算法实现的是模糊处理,会存在移动类型误判为插入或删除等情况。这种精准检测的能力,适用于对差异敏感的系统,使得算法的应用场景进一步扩大。
本文首先介绍了原始的 Paul Heckel 差分算法的实现原理。原始的 PaulHeckel 差分算法基于位置、内容 2个维度进行差分检测,支持检测插入、删除、移动等 3种类型。但是,其实现的是一种模糊处理的差分检测,会存在将移动类型归为插入或删除的情况。
其次,我们介绍了 IGListKit 中的 IGListDiff 模块,其在 Paul Heckel差分算法的基础上进行了优化,基于标识、位置、内容 3个维度进行差分检测,支持检测插入、删除、移动、更新等 4种类型。IGListDiff 实现了一种精确分析的差分算法,这更符合我们在 UI框架中对于数据差异精准检测的需求。
通过上文的介绍,我们可以举一反三猜想地其他应用是如何实现数据差分检测的。对此,如果你感兴趣的话,可以研究一个其他的框架,来印证一下你的猜想,甚至可以考虑自己实现差分算法。
升级后的博客支持了以下这些特性:
在升级过程中,我在 LiveRe后台看到了博客里的很多评论,还是蛮开心的,承蒙大家的喜欢和支持[手动抱拳]。之前评论系统非常不稳定,导致我一直都不太关注评论,因为我也经常加载不出来(除非挂代理)[手动狗头]。而且我也没有看过评论后台,加上评论系统也不支持评论通知,所以很多留言和问题都没有及时回复,感到十分抱歉。
为了能有更好的交流体验,这次我换了个评论系统,并将历史评论导入了进来,可惜的是历史评论时间无法修改。同时,我也支持了评论通知功能。如果有评论,我会立即收到微信通知;如果我回复了,你也会收到邮件。所以留言时,请正确填写你的邮箱。
后续,我会持续关注博客中的留言,并及时予以回复(毕竟支持了评论通知能力)。Waline评论系统支持匿名评论和登录评论,如果后续会有回复,我强烈建议你点击登录按钮注册一个账号。
当然,除了在指定文章下面留言,这里还提供了一个独立的留言区
最后,再解答一个重复度比较高的问题:
Q:文章中的图是用什么画的?
A:绝大部分的彩色配图都是使用 Sketch 画的,少部分类图使用 draw.io画的。
差分算法(DifferenceAlgorithm)是一种数值计算方法,其主要用于解决两个数据集之间的差异问题,通过计算两个数据集之间的差异,取代对整个数据集的处理。
差分算法的应用非常广泛,主要有以下这些应用领域:
本文,我们将深入探讨广泛应用在各种版本控制工具中的差分算法——Myers差分算法。
在介绍 Myers 算法之前,我们先来了解一下著名的最长公共子序列(Longest Common Subsequence,LCS)问题。我们引用一下 LeetCode 中的问题描述,如下所示。
1 | 给定两个字符串 text1 和 text2,返回这两个字符串的最长「公共子序列」的长度。如果不存在「公共子序列」,则返回 0 。 |
对于 LCS 问题,经典思路是使用动态规划来解决。动态规划的核心思想是将一个大问题拆分成多个子问题,分别求解各个子问题,基于各个子问题的解推断出大问题的解。与分治、递归相比,动态规划会记录各个子问题的解,避免重复运算,以空间换时间,从而实现对时间复杂度的优化。下面,我们来介绍一下LCS 的动态规划解法。
假设字符串 \(text1\) 和text2
的长度分别为 m
和n
,对此创建一个 m+1
行 n+1
列的二维数组 dp
,其中 dp[i][j]
表示text1[0:i]
和 text2[0:j]
的最长公共子序列的长度。
上述表示中,
text1[0:i]
表示text1
的长度为i
的前缀,text2[0:j]
表示text2
的长度为j
的前缀。
考虑动态规划的边界情况:
i = 0
时,text1[0:i]
为空,空字符串和任何字符串的最长公共子序列的长度都是0
,因此对任意 0 ≤ j ≤ n
,有dp[0][j] = 0
;j = 0
时,text2[0:j]
为空,同理可得,对任意 0 ≤ i ≤ m
,有dp[i][0] = 0
。因此动态规划的边界情况是:当 i = 0
或 j = 0
时,dp[i][j] = 0
。
当 i > 0
且 j > 0
时,考虑dp[i][j]
的计算:
text1[i-1] = text2[j-1]
时,将这两个相同的字符称为公共字符,考虑 text1[0:i-1]
和text2[0:j-1]
的最长公共子序列,再增加一个公共字符即可得到text1[0:i]
和 text2[0:j]
的最长公共子序列,因此 dp[i][j] = dp[i-1][j-1] + 1
。text1[i-1] != text2[j-1]
时,考虑一下两种情况:text1[0:i-1]
和 text2[0:j]
的最长公共子序列text1[0:i]
和 text2[0:j-1]
的最长公共子序列text1[0:i]
和 text2[0:j]
的最长公共子序列,应取两项中长度较大的一项,因此dp[i][j] = max(dp[i-1]][j], dp[i][j-1])
。最终得到如下所示的状态转移方程:
\[dp[i][j] =\begin{cases}dp[i-1][j-1] + 1, & \text{text1[i-1] = text2[j-1]} \\max(dp[i-1][j], dp[i][j-1]), & \text{text1[i-1]$\neq$text2[j-1]}\end{cases}\]
根据状态转移方程,我们可以得到如下代码实现:
1 | // C++ |
下图所示为二维数组 dp[i][j]
的存储内容,大问题的解由子问题的解推导而出,数组整体从左到右,从上到下推导构建。我们在图中使用黄色标识了text1[i-1] == text2[j-1]
的情况。此时将从左上角相邻的位置取值并加1;否则,取左边或上边的相邻值中的最大值。整个二维数组中保存的最大值就是LCS 问题的解。
至此我们计算得到了最长公共子序列的长度,然而在实际情况中,我们倾向于得到最长公共子序列本身。此时,可以借助我们构建的二维数组进行回溯。
回溯的方法是:从二维数组的右下角向左上角遍历,当i = m+1
,j = n+1
时可能会遇到三种情况:
text1[i] = text2[j]
,那么向左上角遍历。text1[i] != text2[j]
,判断 dp[i][j]
和 dp[i-1][j]
的值。dp[i][j] = dp[i-1][j]
,则向上遍历;由此,我们可以得到如下的遍历路径。
在回溯得到遍历路径之后,我们对路径中向左上角遍历的起始位置进行染色(黄色),即可得到最长公共子序列CABA
,如下图所示。
当然,细心的同学可能会对上述的回溯方法产生疑问:为什么dp[i][j] = dp[i-1][j]
时向上遍历,而非向左遍历?事实上,如果我们也可以修改回溯方法,得到如下的遍历路径。
同样,我们对路径中向左上角遍历的起始位置进行染色(黄色),即可得到最长公共子序列BABA
,如下图所示。
事实上,在特定设定下,最长公共子序列问题可以等价为最小编辑距离(Minimum Edit Distance,也称 Levenshtein)问题。
具体设定为:在最小编辑距离问题中,如果编辑操作只有删除 和 插入,没有替换 操作,且每个操作的代价是 1,那么从字符串 A转换成字符串 B 的最小编辑距离就可以转换成如下公式。
\[med(A, B) = length(A) + length(B) - 2 * lcs(A, B)\]
以上述 text1 = CBABAC
,text2 = ABCABBA
为例,寻找最长公共子序列问题,我们可以视为将 text2
转换成text1
的最小编辑距离问题。
此时,我们可以将向左遍历的起始位置染成红色,将向上遍历的起始位置染成绿色,如下所示是分别对CABA
和 BABA
遍历路径的染色图。
这里,我们对已经染色的路径进行编辑规则的定义,如下:
此时,我们就可以得到最小编辑距离的实际操作步骤,即最短编辑脚本(Shortest EditScript,SES),如下所示。
上面两图的右半部分是两个符合预期的最短编辑脚本。然而,在实际过程中,对某一个原始文本进行编辑得到另一个目标文本,可能会存在非常多的最短编辑脚本。此时我们该如何选择?根据实际经验,我们认为先删除旧内容,后插入新内容,具有更直观的体验。比如:CodeReview的差异比较也都是按照先删除后插入的方式进行展示,如下所示。因此,上述第一种最短编辑脚本更加直观,符合预期。
1986 年 Eugene W.Myers 发表了一篇论文《An O(ND) Difference Algorithmand ItsVariations》,提出一种基于广度优先搜索和贪心策略的算法,优化了最短编辑路径问题。该算法在求解大规模字符串编辑距离问题时比传统的动态规划更加有效。
下面,我们以 source = ABCABBA
为原始字符串,target = CBAABAC
为目标字符串,基于 Myers差分算法来查找最短编辑距离和最小编辑脚本。
与上述算法类似,Myers差分算法的基本思想仍然是查找一条从左上角至右下角的路径。在路径遍历时,这里有几个基本定义:
除了对比原始字符串和目标字符串所建立的 X-Y 坐标系外,Myers还建立了一个 K-D 坐标系,如下图所示。
K 来源于 X 与 Y 的关系式 y = x - k
,即偏移量。根据 X 和K 的值,我们可以计算得到 Y 的值。
D 表示遍历的深度。由于向右或向下移动一步,深度加1;右下移动一步,深度加 0。因此,D 轴并不是完全垂直于 K轴,而是类似于等高线,向多方向增长,相同 D值所连成的线可能是折线,而不一定是直线。
Myers 差分算法是基于贪心策略实现的,对此它定义了一个最佳位置 的概念,作为贪心的基准值。在 K-D坐标系中,同一条 K 线上,X 值越大的位置(根据 K 的值可以计算得到 Y值),则越靠近右下角的终点。
那么如何记录最佳位置?很显然,每一个 K值需要单独记录各自的最佳位置,因此,需要有一个 Map 来进行存储,其中 Key是 K 值,Value 则是 X 值。根据 K 和 X,我们可以计算出 Y 值。
在了解了算法的基本定义、K-D坐标系以及最佳位置等概念之后,我们来看一下算法具体原理。
算法整体包含两层循环:
[0, M+N)
。下面,我们来图解一下算法的运行过程,以下每张图表示一次完整的内层循环。
首先,为 k(0)
查找所有深度为 d0
的最佳位置,很显然,只有起点符合,如下图所示。
其次,为 k(-1)
和 k(1)
查找所有深度为d1
的最佳位置。由于 d1
是基于 d0
宽度优先搜索查找的,而 d0
只有一个,所以由此向两个方向搜索的 K 线只有 k(-1)
和k(1)
。
然后,我们继续基于 d1
的各个最佳位置进行宽度优先搜索,为(k-2)
、k(0)
、k(2)
查找d2
的各个最佳位置。由于每一轮内层循环的 K 线数量都会外扩1,因此,首尾的两条 K 线,只能基于内侧 K线的上一轮循环最佳位置来查找本轮最佳位置;对于中间的 K 线,它两侧的 K线都有上一轮的最佳位置,它可以从中选择更优的最佳位置(即 X值更大的最佳位置)来查找本轮的最佳位置。如下图所示,k(-2)
的最佳位置 d2
只能基于 k(-1)
的最佳位置d1
来查找得到;k(2)
的最佳位置 d2
只能基于 k(1)
的最佳位置 d1
来查找得到。对于中间的 K 线,这里只有k(0)
,它会在左右两边的 K 线中选择一个最佳位置,显然k(1)
上的 d1
的 X值更大,因此选择它作为搜索的起点。
从图中,我们还可以看到,当到达深度加 1的位置后,算法还会进一步判断是否可以向右下移动,因为右下移动时,深度不会增加。此时,我们发现这几个位置都能沿着黄色的线移动,于是d2
就到达了图中所示的位置。
接着,为k(-3)
、k(-1)
、k(1)
、k(3)
查找 d3
的各个最佳位置。注意,这里我们看一下k(-1)
的处理,此时它两侧 k(-2)
和k(0)
的最佳位置的 X 值相同。此时,我们选择基于k(-2)
右移,很明显,这样必然会比基于 k(0)
下移能找到 X 值更大的位置。
然后,为k(-4)
、k(-2)
、k(0)
、k(2)
、k(4)
查找 d4
的各个最佳位置。注意,此时会存在部分 K线的最佳位置不包含 X-Y 坐标系中真实存在的位置,比如 k(-4)
的最佳位置。
最后,我们在为k(-35)
、k(-3)
、k(-1)
、k(1)
、k(3)
、k(5)
查找最佳位置时,发现其中一条 K线的最佳位置已经到达终点,那么此时我们已经找到了最短编辑距离,那么可以结束遍历。
在上述过程中,Myers 算法使用一个 Map 记录每条 K 线的最佳位置,其中Key 为 K 值,Value 为最佳位置。当对同一条 K 线多次更新最佳位置时,Map只会记录最新的最佳位置。为了便于复现完整的编辑路径,Myers 算法还使用一个Map 用于记录每个深度的所有最佳位置,其中 Key 为深度值 D,Value 是一个子Map,记录了该深度时各个 K 线的最佳位置。通过回溯这个包含全部最佳位置的Map,我们可以重建遍历路径,如下图所示。
下面,我们使用代码来实现一下 Myers 算法。
1 | # ruby |
传统动态规划的时间复杂度为 O(mn)
,空间复杂度为O(mn)
,其中 m
和 n
分别是两个字符串的长度;Myers 算法的时间复杂度为O((m+n)D)
,D
是最小编辑距离,当最短编辑距离相对较小时,Myers算法的时间效率是优于传统动态规划的,Myers 算法的空间复杂度为O(m+n)
。因此,当面对大规模且较为相似的字符串比较任务时,Myers算法相比动态规划更具优势。
后续,我们将阅读一些开源软件或框架,来学习一下 Myers差分算法在其中的应用。
如果你是一位 MacOS 用户,那么你一定知道 Homebrew。Homebrew 是 MacOS下的包管理工具,类似 apt-get/apt 之于 Linux,yum 之于CentOS。如果一款软件发布时支持了 homebrew 安装渠道,那么我们就可以通过homebrew 一键安装,省时省力省心。
本文,我们将来探索一下 homebrew 的底层工作原理。
通过学习其工作原理,我们可以举一反三,推测并理解其他平台的包管理工具的设计思想。此外,我们还能借此理解开源软件的设计范式,从而为软件设计提供思路和指导。当然,最直接的收益则是加深对于homebrew 的理解,可以基于其原理来解决日常工作中的相关问题。
Homebrew 的作者 Max Howell借用了西方的酿酒文化,为软件定义了一系列的术语。因此,想要捋清楚各个术语及其之间的关系,我们有必要先简单了解一下酿酒文化中的相关术语。
对于工厂而言,酒一般会以 木桶(Cask)的形式存放在规模较大的厂房中,即酒桶房(Caskroom)。通常,木桶可以直接安装酒龙头(Tap) 来打酒或装罐。具体如下图所示。
对于家庭而言,酒一般会以 瓶装酒(Bottle) 或罐装酒(Keg) 的形式存放在规模较小的屋子里,即酒窖(Cellar)。由于瓶装酒和罐装酒体积较小,同时为了便于分类和存取,一般会摆放在酒架(Rack) 上。具体如下图所示。
Homebrew将软件比喻成酒,对于不同类型的软件,其管理(保存)方式有所不同:
什么是 MacOS 原生应用? MacOS 原生应用是指为 MacOS操作系统专门设计和开发的应用程序。通常使用 Apple 提供的软件开发工具(如Xcode)和编程语言(Swift 或Objective-C)进行开发,直接调用操作系统提供的 API 进行各种操作。 每个MacOS 原生应用都会有一个唯一的BundleIdentifier,系统以此标识符来管理和区分不同应用。
上图所示,为 MacOS 系统中 homebrew对于软件管理的层级结构。这里有一个细节,我们发现 Caskroom 和 Casks构建了一个两层关系,而 Cellar、Racks、Kegs/Bottles则构建了一个三层关系。对此,我的理解是:
综合上述原因,非原生应用需要三层结构进行管理,而原生应用只需两层结构进行管理。从这个角度来看,正好与Cellar 和 Caskroom 的层级结构相匹配。
从图中,我们还可以看到 Cellar 的管理下包含了两种类型的软件,分别使用罐装酒(Keg) 和 瓶装酒(Bottle)来描述,它们是存在一些细微的区别的:
既然 homebrew将软件比喻成酒,那么很显然,软件的安装过程则对等比喻成酿酒。对此,homebrew使用 木桶(Cask) 和 配方(Formula)作为软件安装的两个基本元素,它们分别作为原生应用的包定义和非原生应用的包定义。为了便于管理,homebrew统一将它们放在 酒龙头(Tap) 下进行管理,如下所示。
对于 homebrew 这样设计 Tap 和 Formula、Cask之间的关系,我个人认为,从语义上来说属实有点牵强,因为它们在任何维度上都不是包含关系。这里,我们只要知道它们之间存在着包含关系即可,无需深究。
通过上文,我们大致了解了 homebrew中术语的含义与关系。下面,我们来看一下它们具体在文件系统中的存储结构。
对于 MacOS 系统,homebrew 在 ARM 架构(Apple Silicon)和 X86架构(Intel)中的存储位置所有不同,但是术语之间相对关系是一致的。作出这种区分的主要原因是,当从X86 架构迁移至 ARM 架构时,支持在 Rosetta 模式下继续运行在 X86架构下安装的软件应用。
对于 X86 架构,Caskroom 的路径是/usr/local/Caskroom
,Cellar 的路径是/usr/local/Cellar
,Taps 的路径是/usr/local/Homebrew/Library/Taps
。
对于 ARM 架构,Caskroom 的路径是/opt/homebrew/Caskroom
,Cellar 的路径是/opt/homebrew/Cellar
,Taps 的路径是/opt/homebrew/Library/Taps
。
Caskroom主要负责管理原生应用,由于原生应用无法同时维护多个版本,所以在 Caskroom下对应只会存在一个版本目录。如下所示,以 aerial
为例,在两次安装时,后一次会覆盖前一次的版本数据。
Cellar 主要负责管理非原生应用,由于是通过软链接进行版本管理,所以在Cellar 下对应会存在多个版本目录。如下所示,以 git
为例,它会保存多个版本的数据。
对于非原生应用,我们还可以在/usr/local/bin
(Intel)目录下看到 homebrew为命令行应用创建的所有软链接,如下所示。
由于 PATH
环境变量包含了/usr/local/bin
,所以系统能查找 homebrew所安装的软件的软链接,进而找到真正的可执行文件。不过在某些情况下,我们可能需要让homebrew安装的软件对于用户不可见,比如:避免版本冲突、仅用于依赖构建等。这时候,homebrew不会为这些软件创建软链接,对于这种类型的软件,homebrew 称之为keg-only,比如:openjdk
。
Taps 主要负责管理 包定义 和外部命令。
Taps 目录维护了多个 Git 仓库(Tap仓库å),包括开发者自建的仓库,以及官方维护的仓库,比如:homebrew/homebrew-core
和 homebrew/homebrew-cask
等。
如下所示,这些仓库大多数都维护了一个 Formula
或Casks
目录,其中存放了软件的包定义。这些包定义本质上是一个Ruby 类定义,图中的 muesli/homebrew-tap
中虽然没有定义Formula
或 Casks
目录,但其保存的 Ruby文件都是包定义。
除此之外,部分仓库维护了一个 cmd
目录,其中存放了一些外部命令的定义,我们可以以文件名作为子命令进行调用,比如:cmd
目录中有一个 check-ci-status.rb
文件,我们可以通过brew check-ci-status
命令来调用执行。通过这种方式,我们可以对 homebrew 的命令进行扩展。
作为一个软件包管理工具,homebrew 中最核心的设计便是包定义(PackageDefinition)。通过包定义,homebrew 才能够正确地安装对应的软件。
在上文 Taps 一节中,我们知道 homebrew支持两种管理方式,具体而言分别是:
homebrew/homebrew-core
(用于 Formula)或homebrew/homebrew-cask
(用于 Cask)提交 PullRequest。这种方式会对包定义有着严格的规范和约束。对于自建仓库,我们可以使用brew tap-new <user>/<repo>
命令来创建一个模板仓库。如下所示,我们使用brew tap-new baochuquan/homebrew-nox
创建了一个 Tap仓库。命令会在 Taps 目录下创建一个仓库,并默认创建一个 Formula目录用于存放 Formula 包定义。如果希望存放 Cask包定义,我们可以再手动创建一个 Casks 目录。
Homebrew 中的包定义有两种:Formula 和 Cask。
Formula 是非原生应用的包定义,如下所示是 CocoaPods 的 Formula包定义。
1 | class Cocoapods < Formula |
Formula 包定义本质上是定义一个 Formula
的子类,将子类的名称转换成小写,以 -
代替驼峰命名,即可得到homebrew 对应的应用名称,比如:brew install cocoapods
。
Formula包含一些必须的属性设置,比如:desc
、homepage
、url
、sha256
、license
等,用于描述应用的基本信息,源码下载地址,完整性校验值等。
此外,它支持了非常多的属性和方法,通过配置这些属性和方法,我们可以自定义应用的安装方式。比如:bottle
可以指定预编译二进制(针对不同系统)的相关配置;depends_on
可以指定应用安装所需的依赖;install
方法可以指定安装的具体操作,等等。关于 Formula定义的更多细节,我们可以参考 homebrew/homebrew-core
中的其他示例,或者参考官方文档
总而言之,对于非原生应用,homebrew 会根据对应的 Formula包定义,去下载对应的二进制或源码,然后在本地进行构建、安装。
Cask 是原生应用的包定义,如下所示是 SourceTree 的 Cask 定义。
1 | cask "sourcetree" do |
Cask 包定义本质上是初始化一个 cask实例。它同样包含了一系列基本属性,如:token
、name
、desc
、homepage
、app
、url
、sha256
等。
Cask 的安装逻辑基上和 Formula 是类似的。示例中,SourceTree针对不同平台提供了不同的下载地址和 sha256 校验值,如:sierra、highsierra、mojave、catalina 等。
除此之外,Cask 也包含了大量的属性和方法,关于 Cask的更多细节,我们可以参考 homebrew/homebrew-cask
中的其它示例,或者参考官方文档
类似于 git,homebrew 也支持外部命令,通过这种方式可以允许用户对 brew进行定制和扩展,其运行方式如下所示,extcmd
可以替换成任意自定义的子命令。 1
$ brew extcmd --option1 --option2 <formula>
Homebrew支持外部命令,从编程语言实现角度而言,可以分两种,分别是:Ruby和其他语言。从本质上而言,它们都是可执行的(chmod+ x)脚本,存放在PAHT
环境变量的路径中,支持系统索引。
由于 homebrew 是使用 Ruby 实现的,因此基于 Ruby的外部命令会比较特殊。只要我们将脚本命名为brew-extcmd.rb
(extcmd
可替换成任意自定义的子命令),homebrew 通过 require
加载后,brew-extcmd.rb
会进入 homebrew的执行环境,因此可以访问 homebrew定义的所有环境变量和功能模块,开发者可使用的工具和模块会非常多。
对于其他语言实现的脚本,脚本实现中必须使用 #!
来指定脚本的解释器,因此可支持 Python、Bash、Perl 等各种脚本语言。不同于Ruby 脚本,对于其他语言的脚本,homebrew要求脚本的名称不能有后缀,比如:brew-extcmd.sh
脚本必须命名为 brew-extcmd
。在运行时,homebrew会导入脚本参数和一部分环境变量。相比 Ruby 脚本而言,homebrew对于其他语言的脚本,在功能上支持会相对弱一些。
上述两种方式都是在本地扩展外部命令,如果我们希望外部命令能给其他用户使用,那应该怎么办?对此,我们仍然可以通过Taps 来实现。 类似于 Formula 使用 Formula
目录管理,Cask使用 Casks
目录管理,对于外部命令,我们使用cmd
管理外部命令的实现脚本。当然,外部命令的维护也分为官方仓库和自建仓库,只是官方仓库的要求和规范会更加严格。
关于外部命令的具体细节,我们可以参考homebrew/homebrew-core/cmd
中的例子,也可以参考官方文档
整体而言,homebrew 的设计架构是比较清晰的。下面,我们来介绍 homebrew中的一些重要设计的工作原理,主要包括:
命令分发是所有命令行工具的核心功能之一,绝大部分的设计思路是:通过入口脚本对命令进行解析,一个子命令匹配一个脚本,最终由对应的脚本来解析参数、选项,并执行。
Homebrew 也不例外,我们执行的 brew
命令本质上是一个指向Homebrew/bin/brew
脚本的软链接,子命令、参数、选项都会作为脚本的输入进行解析。
Homebrew/bin/brew
脚本的核心作用是初始化一系列环境变量,并将导入 homebrew的执行环境。它并没有对命令的参数和选项进行解析,而是直接转发给了Homebrew/brew.sh
脚本。
Homebrew/brew.sh
脚本的职责相对而言更多,主要包括以下几分部:
Homebrew/cmd/
目录下对应的 Shell脚本,比如:shellenv.sh
、--cellar.sh
等。brew ls
识别为brew list
。Homebrew/cmd/
目录下对应的 Shell脚本,如果加载成功,则执行匹配的方法;否则将转发至Homebrew/brew.rb
脚本继续解析。Homebrew/brew.rb
脚本的职责相对简单,主要是负责处理Homebrew/brew.sh
未识别的命令、选项、参数。上文,我们提到brew 支持外部命令。因此,这里 Homebrew/brew.rb
处理了一部分的逻辑:
cmd
和 dev-cmd
目录下对应的脚本,如果有则执行,否则进入下一步。PATH
路径和 Taps 路径下查找的符合brew-<cmd>.rb
或 brew-<cmd>
模式的脚本,如果有则执行,否则报错。命令分发的整体流程如下图所示。
当我们希望安装某个应用时,我们会使用brew search <formula>
或brew search <cask> --cask
来搜索一下 homebrew是否支持安装该应用。
通过上述的介绍,我们很容易猜到软件搜索的逻辑,其核心原理就是借助 Taps进行搜索和查找,包括官方的 homebrew/homebrew-core
和homebrew/homebrew-cask
,以及其他自定义的 Tap仓库,从中查找各种 Formula 和 Cask的定义,从而显示精确匹配或模糊匹配的应用。
上文提到包定义是用于辅助完成软件安装,在安装过程中,homebrew会使用包定义中指定的 url 下载对应的源码或预编译二进制,并根据对应的sha256
值校验其完整性,防止被替换或篡改。
如果包定义中指定了安装过程所需要的依赖,那么 homebrew会先下载并安装对应的依赖。
然后,执行 install
方法进行安装,对于源码则需要编译、构建,对于预编译二进制则可以执行安装。
最后,为应用创建软链接,软链接的存储路径加入了 PATH
环境变量,因此可以被系统索引。
本文,我们首先介绍了 homebrew中酿酒术语与软件术语的对应关系,从而理清了术语之间的关系,建立对homebrew 的基本认知。然后介绍了 homebrew安装的软件在文件系统中的存储结构、两种包定义的基本概念,以及外部命令的实现方式。最后介绍了homebrew中几个关键设计的工作原理,包括:命令分发、软件搜索、软件安装等。
这里我们没有深入探讨 homebrew中的各种实现细节,而是着重介绍了整体的实现结构和理念。如果你有兴趣的话,可以自行探索其中的各种实现细节,相信也能获益不少。
通过学习开源软件的设计,我们能学到很多学习系统设计的方法,包括:如何规划软件的各个部分,比如,在什么地方存储日志,什么地方存储文件,软件的更新策略,软件的调度方式等。当然也能学到很多编程技巧,比如homebrew 中对于 Shell的使用,这些技巧简洁高效,能体现出作者深厚的编程功力。
后面,我还会继续学习各种开源软件的设计,总结并分享我的看法和理解~
在日常开发中,我们经常会使用一些定时任务来辅助完成某些事情。对此,绝大多数人都会选择使用crontab 来配置定时任务。
不可否认,crontab的确是管理定时任务的经典利器,但是你是否和我一样,踩过不少 crontab的坑呢?
下面,我将介绍一下个人认为 crontab的一些痛点和坑。最终,给出另一种优化的解决方案。
提到 crontab,这里必须要介绍一下它的配置规则,如下所示。1
2
3
4
5
6
7.---------------- 分 (0 - 59)
| .------------- 时 (0 - 23)
| | .---------- 日 (1 - 31)
| | | .------- 月 (1 - 12)
| | | | .---- 星期 (0 - 6) (星期日可为0或7)
| | | | |
* * * * * 执行的命令
整体而言,crontab对于不同的单位(除了星期),均支持了三种配置规则:
通过组合这些配置规则,crontab 可以实现非常多的定时配置。
在使用 crontab 很长时间之后,我发现 crontab还是存在着一些使用痛点的,主要有以下几点,下面分别进行介绍。
默认为情况下,crontab会将任务输出默认写入到执行用户的邮件中。如果任务有大量输出,则会大量占用磁盘资源,甚至导致系统宕机。
如下所示,我们配置一个输出当前日志的定时任务。
1 | * * * * * date |
我们可以查看当前用户的邮件,如下所示。 1
2
3$ cat /var/mail/$USER
...
Tue Aug 1 22:11:22 CST 2023
关于这个问题,实践经验都是建议采用如下的方式对任务的输出进行重定向。很显然,这对于新手是非常不友好的。
1 | * * * * * date >> /dev/null/ 2>&1 |
在实践中,我们可以发现 crontab的环境变量与控制台的环境变量是存在差异的。因此,经常会出现这样的情景:在控制台中调试完成的任务,在cron中执行时,其结果会与预期不相符。事实上,产生这种差异的根本原因就是环境变量。
此外,crontab中环境变量不会全局共享。因此,当我们配置多个任务时,可能需要为每个任务单独配置环境变量。很显然,这是一个重复而又繁琐的问题。
关于 crontab的规则语法,这是个仁者见仁智者见智的问题。对于老手来说,可能比较简单;对于新人来说,在使用时得去查询各个位置的单位以及不同规则的写法。我觉得crontab的规则语法不容易理解的根本原因是缺少语义。如果能优化其规则语法的语义,那就更好不过了。
另一方面,对于某些极客来说,crontab的规则可能还不够完备。比如:预期一个定时任务从某个时刻开始或停止执行,或者,预期一个任务循环执行n 次后结束。对于这种规则,crontab无法一次性满足,只能通过配置多个任务来辅助完成。
在实际应用中,我们经常需要借助任务的运行日志来排查问题。此时,我们就需要修改crontab,将任务的输出重定向至某个文件,从而方便后续进行查看。当任务非常多的时候,我们很难记住每个任务对应的日志文件是哪个。这也是crontab 的一个痛点。
为了解决 crontab的诸多痛点,我在业余时间开发了一款优化版的定时任务管理器——
taskloop 底层运行在 cron 守护进程之上,基于 crontab配置了最小粒度的调度规则,实现了一个中间层,从而解决了 crontab的诸多痛点。
taskloop提供了一系列的命令,实现了一个相对完整(如有缺失,补充实现)的工作流,其主要包含以下这些特性。
taskloop env
命令提供了查看、导入、删除环境变量的功能。
如下所示,为环境变量查看的使用示例。 1
2
3
4
5
6
7
8
9
10
11
12$ taskloop env
PATH=/Users/baochuquan/.rvm/gems/ruby-2.6.5/bin:/Users/baochuquan/.rvm/gems/ruby-2.6.5@global/bin:/Users/baochuquan/.rvm/rubies/ruby-2.6.5/bin:/usr/local/texlive/2023basic/bin/universal-darwin:/Users/baochuquan/.nvm/versions/node/v18.16.0/bin:/usr/local/opt/sqlite/bin:/usr/local/sbin:/usr/local/opt/gettext/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/Users/baochuquan/.rvm/bin:/Users/baochuquan/Flutter/bin:/Users/baochuquan/Library/Android/sdk/tools
RUBY_VERSION=ruby-2.6.5
GEM_PATH=/Users/baochuquan/.rvm/gems/ruby-2.6.5:/Users/baochuquan/.rvm/gems/ruby-2.6.5@global
GEM_HOME=/Users/baochuquan/.rvm/gems/ruby-2.6.5
IRBRC=/Users/baochuquan/.rvm/rubies/ruby-2.6.5/.irbrc
NOX_ROOT=/Users/baochuquan/Develop/nox
NOX_NAME=nox
NOX_COMMON=/Users/baochuquan/Develop/nox/common
NOX_CONFIG=/Users/baochuquan/Develop/nox/config
NOX_SCRIPTS=/Users/baochuquan/Develop/nox/scripts
如下所示,为环境变量导入的使用示例。示例中,我导入了两个环境变量JAVA_HOME
和 GROOVY_HOME
。 1
2
3
4
5
6
7
8$ taskloop env --import=JAVA_HOME,GROOVY_HOME
importing JAVA_HOME ...
JAVA_HOME=/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home
importing GROOVY_HOME ...
GROOVY_HOME=/usr/local/opt/groovy/libexec
import global environment variables complete.
如下所示,为环境变量删除的使用示例。示例中,我删除了GROOVY_HOME
环境变量。 1
2
3$ taskloop env --remove=GROOVY_HOME
remove global environment variables complete.
经过一系列导入、删除操作之后,我们可以通过 taskloop env
命令来查看导入结果是否正确。
taskloop 具有一个全局的开关,即启动和关闭的能力。前面我们提到taskloop 底层是运行在 cron 守护进程之上,对此,启动功能的本质就是将taskloop 注册至 crontab;关闭功能的本质就是将 taskloop 从 crontab注销。
如下所示,为启动 taskloop 的使用示例。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19$ taskloop launch
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@ @@@@@ @@@ @@ @@ @@@@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@ @@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@@@@@@ @@@ @@@@@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@@@@ @@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@ @@@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@ @@@ @@ @@ @@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
taskloop has launched successfully.
如下所示,为关闭 taskloop 的使用示例。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20$ taskloop shutdown
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@ @@@@@ @@@ @@ @@ @@@@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@ @@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@@@@@@ @@@ @@@@@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@@@@ @@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@ @@@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@ @@@ @@ @@ @@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
taskloop has shutdown successfully.
byeeeeeeeeeeeeeeeee !
taskloop 通过读取注册的 Taskfile 来执行所有的任务,Taskfile中可以定义一系列用户自定义的任务。为了便于使用,taskloop提供了一个初始化命令,可以自动创建一个 Taskfile模板,从而供用户进行修改和定制。
如下所示,为初始化的使用示例。taskloop init
方法创建了一个 Taskfile模板,并定义了所有支持的属性,我们可以自定义任务,包括任务的路径、名称、执行规则等。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22$ cd my-job-project
$ taskloop init
$ cat Taskfile
# env to set environment variables which are shared by all tasks defined in the Taskfile. <Optional>
# env "ENV_NAME", "ENV_VALUE"
TaskLoop::Task.new do |t|
t.name = 'TODO: task name. <Required>'
t.path = 'TODO: task job path. For example, t.path = "./Job.sh". <Required>'
t.week = 'TODO: week rule. <Optional>'
t.year = "TODO: year rule. <Optional>"
t.month = "TODO: month rule. <Optional>"
t.day = "TODO: day rule. <Optional>"
t.hour = "TODO: hour rule. <Optional>"
t.minute = "TODO: minute rule. <Optional>"
t.time = "TODO: time list rule. <Optional>"
t.date = "TODO: date list rule. <Optional>"
t.loop = "TODO: loop count. <Optional>"
t.start_point = "TODO: start point boundary rule. <Optional>"
t.end_point = "TODO: end point boundary rule. <Optional>"
end
当我们完成了对 Taskfile的定义之后,可以进行发布。发布过程中,taskloop 会检查 Taskfile中的语法规则,如果不符合将抛出异常,并提示错误;如果符合规则,则完成发布。Taskfile将正式生效,后续的任务执行将以此为准。
如下所示,为发布的使用示例。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29$ cd my-job-project
$ taskloop deploy
(@&/////%@@@@@@@@@@@@@#
@@&@&////////////////(@@#
/@(///////////////////////%@/
*@////////////////////////////#@,
@&///////////////////////////////@%
@&//////////////(@////@&#/////////(@
@////////////@@ @////@ ,.@////@@
/@//////////&@ ,@@////@@@&. *///@@
,@/////////(@@@ @///&@ /@@///@@
@%////////@@ @@@& @@/@#
@#///////@ ,
@@//////@& @(//@
@@/////%@ @////@
@@/////#@@@////@
&@@@&#((&@@
/&# @///@@
,///,*. @////@
&# %/,@ @@///@
(, .*/&*%/*%///& (@@@ ,*/////*.
%/#@, ////& @ % .& /@%/(/ /#/#@( #@#/&
(/& . .////, &//
&(//@ &//////& @///@
(@%//////#&@@@@@@@@@@%///////@@ @@///////%@@@@@@@@@@&#//////#@#
%@@@@&@@@@@&&@@@@/ /@@@@&&@@@@@&@@@@&
Taskfile deploy success!
当然,在某些情况下,我们需要撤销已经发布的Taskfile。此时,我们可以执行如下命令进行撤销。
1 | $ cd my-job-project |
为了便于查看当前已发布的任务,taskloop提供了一个命令方便用户进行查询。如下所示,为任务查看的使用示例。
1 | $ taskloop list |
为了解决 crontab 的日志查询问题,taskloop同样提供了一个命令支持查询不同维度的日志,包括:系统日志(即 taskloop运行日志)、任务日志。
如下所示,为查看系统日志的使用示例。 1
2
3
4
5
6
7
8
9
10$ taskloop log --cron
=============================
Log of cron:
Trigger Time: <2023-08-03 08:24:00 +0800>
Checking: <Task.name: haha, sha1: 637d1f5c6e6d1be22ed907eb3d223d858ca396d8> does not meet the execution rules, taskloop will skip its execution.
Checking: <Task.name: baocq, sha1: 7cc14c1bffcd559180d9906377bfaa41a4f9a980> does not meet the execution rules, taskloop will skip its execution.
Checking: <Task.name: chuquan, sha1: d461e86c07d232ceebcd2d024ea4b4c33d0f7b4b> does not meet the execution rules, taskloop will skip its execution.
=============================
如下所示,为查看任务日志的使用示例。 1
2
3
4
5
6
7
8$ taskloop log --task-name=baocq
=============================
Project of </Users/baochuquan/Github/taskloop>
Log of <Task.name: haha> above:
<Trigger Time: 2023-08-03 08:27:16 +0800>
Test0101
=============================
taskloop init
命令会创建一个 Taskfile 文件,我们可以在Taskfile 文件中自定义不同的任务与规则。这里,taskloop定义了一套语法规则,我们将基于如下所示的 Taskfile 模板进行介绍。
1 | TaskLoop::Task.new do |t| |
模板中列出了任务支持的所有属性,首先有两个必要属性 name
和 path
。
name
:用于指出任务的名称,同一个 Taskfile中不能有同名的任务,主要用于日志查询时指定名称。path
用于指出任务的路径,taskloop会根据此路径加载并执行任务脚本。模板中的其他属性均为非必要属性,用于描述执行规则。关于执行规则,taskloop中主要定义如下几种规则。
at
。week
、year
、month
、day
、hour
、minute
,其中week
、month
、day
属性需要使用预定义的符号,其余属性可以直接使用数值。week
,需要使用星期符号,如::Sun
、:Mon
等。month
,需要使用月份符号,如::Jan
、:Feb
等。day
,需要使用表示月份中第几天的符号,如::day1
,:day2
等。t.week = at :Mon, :Sub, :Tue
t.month = at :Feb, :Aug
t.day = at :day2, :day8, :day30, day:31
t.year = at 2023, 2024
t.hour = at 10, 11
t.minute = at 59
before
、between
、after
。before
语法表示在小于等于某个值时执行。between
语法表示在大于等于某个值,且小于等于另一个值时执行。after
语法表示在大于等于某个值时执行。week
、year
、month
、day
、hour
、minute
。t.year = before 2026
t.week = between :Mon, :Fri
t.hour = after 12
interval
。year
、month
、day
、hour
、minute
。t.minute = interval 5
t.day = interval 1
loop
。loop
。t.loop = loop 10
time
。其与 hour
、minute
属性冲突,不能同时使用。time
。t.time = time "10:00:00", "7:00:00"
date
。其与year
、month
、day
属性冲突,不能同时使用。date
。t.date = date "2023-10-1, "2023-5-1
from
和to
。from
语法表示任务从某一个时刻开始执行,支持的属性只有start_point
。to
语法表示任务在某一时刻之后不在执行,支持的属性只有end_point
。t.start_point = "2023-10-1 10:00:00"
t.end_point = "2023-10-30 23:59:00
taskloop 的工作流程可以分为三个步骤:
启动/关闭步骤是一个全局开关,对应分别有两个命令,如上所述。关于启动,一般只在最开始使用taskloop的时候使用启动命令。如果希望停止所有已注册任务的执行,则可以执行关闭命令。
taskloop建议用户能够使用一个目录统一管理所有的定时任务,当希望为这些定时任务创建定时规则时,可以在目录下执行初始化命令,从而生成一个Taskfile文件。之后,即可自定义定时规则。如果用户本地维护了多个目录管理定时任务,则需要在不同的目录下分别执行一次初始化命令,从而完成任务规则自定义。
发布/撤销步骤相对而言会比较频繁,当初始化并自定义 Taskfile之后,我们就可以执行发布命令,使得 Taskfile 真正在 taskloop中生效。当然,有时候我们会在发布后发现一些错误,我们可以修改后重新发布,或者为了避免产生副作用,可以执行撤销命令。注意,发布/撤销命令必须在Taskfile 的同级目录下执行。
本文简单介绍了一下我最近业余时间写的一个定时任务管理工具——taskloop。同时,解释了为什么做这个工具的原因(即解决crontab 的痛点)。
关于软件logo,我花了两晚设计了这样一个形象。两个圈组成一个莫比乌斯环,象征着循环。任务执行抽象为海豚跳圈,海豚在两个圈中循环穿越则象征着taskloop 在永不停止地运行任务。
忘了说,其实写这篇文章的另一个重要目的是为了推广一下我的作品,也希望有兴趣的朋友能够给一些意见,甚至可以一起参与软件的开发和完善。
Fishhook 是 Facebook 开源的一款面向 iOS/macOS 平台的符号动态重绑定 工具,允许开发者在运行时修改 Mach-O中的符号(函数),从而实现 动态库 的函数 hook能力。
Fishhook 提供了两个用于符号重绑定的接口,分别是:
1 | int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel); |
其中,rebind_symbols
可以在所有动态库范围内进行符号重绑定,而rebind_symbols_image
则限制了动态库的范围,只能指定某一个动态库。
这里,我们先预设几个问题,后面会逐步进行解答:
为了能介绍清楚 fishhook的实现原理,本文我将重点介绍程序的链接原理,包括:静态链接、动态链接。其中,涉及到的术语和概念主要是基于ELF 可执行文件(或目标文件),在真正介绍 fishhook 的原理时,我会将Mach-O 中的术语与 ELF 进行比较和映射,从而达到一个举一反三的效果。
在介绍链接原理之前,我们有必要先了解一下可执行文件(目标文件)的基本格式,不同的平台有着不同的格式,分别是:
尽管不同平台的可执行文件格式不同,但是它们的组织结构和规则是基本类似的。如下图所示,不同格式的可执行文件基本都包含如下几个部分:
文件头用于描述可执行文件的元信息,包括:文件类型、系统版本、segment表的位置和大小、section 表的位置和大小等等。Section表本质上是一个索引表,其存储了每一个 section 的元信息,比如对应 section在文件中的位置和大小。至于 section,它是可执行文件的基本组成单元,常见section有:.text
、.data
、.bss
、.symtab
、.strtab
等。
那么 segment 表的作用又是什么呢?
事实上,两者的区别主要在于:section用于描述可执行文件的静态存储布局,segment用于描述可执行文件的装载内存布局。
我们知道可执行文件是以 section 为基本单元存储的,section的类型非常多,如:.data
、.text
、.rodata
等。假如,我们的可执行文件中有两个 section,分别是 .init
和.text
,两者的大小分别是 3500B 和4100B。假设系统的页面大小为 4KB,我们来分别看一下基于 section 装载和基于segment 装载的内存占用情况。
下图右部所示为基于 section 装载的内存占用情况,其中.init
单独占用一个页,且页没有全部使用;.text
会单独占用两个页,且第二页绝大多数内存空间没有使用,总共浪费内存 3 x 4KB- 3500B - 4100B = 4688B。
下图左部所示为基于 segment 装载的内存占用情况,.text
占用了两个页,且与 .init
共享了一个页,总共浪费内存 2 x 4KB- 3500B - 4100B = 592B。
很显然,相比于基于 section 装载,基于 segment装载对于内存占用的优化非常明显,内存碎片更少。在实际中,程序在装载时会将相同权限的section 合并在一个 segment 中,比如:.init
和.text
都合并成为可读可执行权限的segment,作为代码段;可读可写的 section 合并在为一个segment,作为数据段。
链接(Linking)的本质是把多个目标文件相互拼接到一起,使得函数调用、变量访问等指令能够找到正确的内存地址。然而,这一切都是围绕着符号(Symbol) 完成的。
那么到底什么是符号?举个例子,目标文件 B 调用了目标文件 A 中的函数foo
。对此,我们认为目标文件 A 定义了函数foo
,目标文件 B 引用了函数foo
。在链接过程中,我们将函数和变量统称为符号(Symbol),函数名和和变量名统称为符号名(Symbol Name)。因此,我们也可以认为目标文件 A包含了函数 foo
的 符号定义(SymbolDefinition),目标文件 B 包含了函数 foo
的符号引用(Symbol Reference)。
这时候问题来了,链接过程是如何基于符号完成对二进制指令中内存地址的修正呢?对此,我们可以先来了解一下静态链接。
静态链接会在编译期将多个目标文件合并为一个可执行文件。因此,里面包含了所有的符号、重定位项、字符串等。
在编译过程中,编译器会为每一个变量或函数生成一个符号项,符号项包含的信息主要有:
foo
在字符串表中的偏移量。此外,编译器还会为每个变量引用或函数引用生成一个重定位项。由于每一个重定位项记录了每一次对于符号的引用,因此,我们可以将其称为符号引用项。这样也就构成了符号定义和符号引用的一对多关系,毕竟,我们可以在不同的地方引用同一个变量或函数。
基于如下示意图,静态链接的整体工作原理大概可以分为以下三个步骤:
由于静态链接时,程序所依赖的所有目标文件都已经合并在了一个可执行文件中,因此几乎不存在符号项中的符号值(内存地址)不确定的情况,对此,静态链接器只需要基于重定位表进行重定位即可。这其实就是大家常说『静态链接的重点是重定位』的原因。
动态链接的基本思想是将程序按照模块拆分成各个独立的部分,在运行时将它们链接在一起形成一个完整的程序,而不是像静态链接一样在编译时把所有的模块都链接成一个独立的可执行文件。因此,动态链接可以有效解决静态链接存在的内存空间浪费 和 程序更新困难的问题。
那么对于动态链接,我们是否可以直接采用静态链接的做法呢?这种方案理论上可以,但却不是最优解,因为静态链接会修改代码段,我们很难让共享对象在被多次重定位之后也能继续安全稳定的运行。
举一个例子,如下所示,一个动态共享对象 X
内部会引用外部的一个变量 a
。当程序 A
与动态共享对象 X
完成重定位后,X
代码段中的某个指令的访存地址可能是一个值;当程序 B
与动态共享对象 X
完成重定位后,X
代码段中同位置的访存地址可能会被修改成另一个值。这时候,必然会出现其他程序无法正常执行的情况。
关于如何解决多进程之间的重定位冲突问题,我们可以引用下图所示的经典名言来描述动态链接的解决方案。当然,在具体的实现中,动态链接根据链接的时机,还可以分为装载时链接(Load-Time Linking) 和延迟链接(LazyLinking)。两者的实现思路只有略微的差异,下面我们将分别进行介绍。
下图所示为装载时链接的工作原理示意图。对于共享对象而言,其代码段会被多个进程所共享,因此不能直接在代码段中进行重定位,修改内存地址。考虑到多进程共享对象时,共享对象会为每个进程拷贝一份数据段,支持修改。因此,一种称为地址无关代码(PIC,Position-Independent Code)的技术诞生了,其基本思想是:在编译时配置 PIC编译选项,将指令部分中需要被修改的部分分离出来,跟数据部分放在一起。这样指令部分可以保持不变,而数据部分可以在每个进程中有一个独立的副本。
对于 PIC技术,代码运行性能会比静态链接要差一点。因为指令在访问外部变量或外部函数时,必须先通过指针去数据段找到对应的位置,再从中取出真实的内存地址,很显然多了一次间接操作,损耗了性能。
在装载前,共享对象 X
的符号表中的外部符号bar
的内存地址是未定义的。但是,程序 A
的符号表中的符号 bar
的内存地址是确定的(因为符号bar
的符号定义位于程序 A
中)。因此,在装载时我们就可以决议出共享对象 X
的外部符号bar
的地址。这个过程,我们称之为装载时绑定(Load-Time Binding) 或装载时符号绑定(Load-Time Symbol Binding)。
当外部符号 bar
的内存地址绑定完成后,我们就可以进行后续的重定位了。其步骤和静态链接的重定位类似,主要包括以下几步:
在 PIC技术中,编译器会在数据段中为每一个符号存储一个占位桩(stub),用于存储符号的真实内存地址。这些占位桩组成了一个表,我们称之为全局偏移表(GOT,Global Offset Table)。
综上述可以看出,装载时链接包含了两个重要的步骤,分别是装载时绑定和重定位。虽然中间多了一步间接索引内存地址,损耗了一些性能,但是程序的灵活性和复用性提高了很多。
考虑到程序运行的局部性,实际上在进程生命周期中很多变量或函数并不会被调用。于是,诞生了延迟链接技术,可以支持进程只在第一次调用符号时才进行链接。
下图所示为延迟链接的工作原理示意图,本质上与装载时链接差不多,主要区别在于:装载时链接在数据段中使用了GOT 存储符号地址,延迟链接则在数据段中使用了过程链接表(PLT,Procedure Linkage Table)存储符号地址。当 PLT 表项中符号的内存地址未决议时,PLT表项中的占位桩(stub)存储的是一段代码的地址。当这段代码完成符号绑定和重定位后,会将符号的真实内存地址回填到占位桩中,覆盖默认的代码地址,从而实现仅在第一次调用符号时才进行链接。
延迟链接的关键是如何实现在第一次调用符号时进行链接,这个过程包含了延迟绑定(Lazy Binding) 和重定位。关于 PLT的存储,很多目标文件会将其存储在命名为 got.plt
的 section中,Mach-O 和 ELF 都是如此,这一点需要注意。
上述介绍了程序的链接原理,尤其是在理解了动态链接之后,如果你细想思考一下,很容易就能想到fishhook 的设计思想。
下图展示了 fishhook 的设计思想,非常简单巧妙,核心思想就是将目标符号(函数)对应的 GOT 表项或 PLT表项中存储的符号值(内存地址),替换成 hook函数的内存地址。通过这种方式,无论是装载时链接还是延迟链接,我们都可以实现对动态共享库函数的hook。
下面,我们来介绍一下 fishhook 实现细节中与 Mach-O 的相关概念。
如下所示为《Mach-O Programming Topics》中对两者的解释:
Non-lazy symbol references are resolved (bound to their definitions)by the dynamic linker when a module is loaded.A non-lazy symbolreference is essentially a symbol pointer—a pointer-sized piece of data.The compiler generates non-lazy symbol references for data symbols orfunction addresses.
Lazy symbol references are resolved by the dynamic linker the firsttime they are used (not at load time). Subsequent calls to thereferenced symbol jump directly to the symbol’s definition.Lazy symbolreferences are made up of a symbol pointer and a symbol stub, a smallamount of code that directly dereferences and jumps through the symbolpointer. The compiler generates lazy symbol references when itencounters a call to a function defined in another file.
Non-lazy Symbol Pointer 存储的是指向符号定义的指针,它与 GOT中的表项定义非常类似。由 Non-lazy Symbol Pointer 组成的表,在 Mach-O中我们称为 Non-lazy Symbol Pointer Table。
Lazy Symbol Pointer包含一个指向符号定义的指针、一个占位桩以及一段代码(可用于延迟绑定和重定位),它与PLT 中的表项定义非常类似。由 Lazy Symbol Pointer 组成的表,在 Mach-O中我们称之为 Lazy Symbol Pointer Table。
上述的 Non-lazy Symbol Pointer 和 Lazy Symbol Pointer并没有包含符号名相关的信息,然而在实际的符号查找、绑定的过程是需要用到的。因此,对于Non-lazy Symbol Pointer Table 和 Lazy Symbol Pointer Table各自有一个同步的间接符号表,可以用于配合完成链接工作。Fishhook 也是借助Indirect Symbol Table间接获取符号名,然后与目标符号进行判等比较,从而最终完成 hook 工作。
Indirect Symbol Table 与 Symbol Pointer Table的表项是一一对应的,比如:Indirect Symbol Table 中第 1601 项存储的就是Symbol Pointer Table 中第 1601 项的符号索引,如下图所示。
Fishhook 的核心是 完成 Symbol Pointer的地址替换,无论是 Non-lazy Symbol Pointer 还是 Lazy SymbolPointer。其实现的关键步骤主要包括以下几步:
SEG_DATA
或SEG_DATA_CONST
LAZY_SYMNBOL_POINTERS
和NON_LAZY_SYMBOL_POINTERS
类型的 sectionLAZY_SYMBOL_POINTERS
和NON_LAZY_SYMBOL_POINTERS
section 进行 Symbol Pointer目标符号地址替换Symbol Pointer 目标符号地址替换的过程主要有以下几步:
LAZY_SYMBOL_POINTERS
或NON_LAZY_SYMBOL_POINTERS
section 获取其对应的 IndirectSymbol Table这里涉及到了 fishhook 中的两个函数实现,分别是rebind_symbols_for_image
函数和perform_rebinding_with_section
函数,有兴趣的朋友可以自行阅读,本文就不粘贴代码了。
至此,我们从链接原理的角度介绍了 fishhook的设计思路。通过这种自顶向下的方法来分析,我们很快就可以联想到如何去实现一个针对ELF 格式的 hook 工具。
最后,我们再来回顾一下本文开头预留的几个问题。
问题一:fishhook 是在什么时候完成函数 hook 的?fishhook 会在调用rebind_symbols
或 rebind_symbols_image
方法时去遍历镜像,从而完成对目标符号的地址替换。
问题二:fishhook 为什么只支持 hook 动态库函数?动态库的 PIC技术支持在数据段进行重定位,因此允许我们进行目标地址修改。而 fishhook的整个机制就是建立在动态链接原理的基础上,因此仅支持 hook动态库函数。
除此之外,有一些辅助继承的实现方式,比如:接口继承和类型混入,一般用于实现多类型复用,可以达到类似多继承的效果。
本文,我们来简单介绍一下其中基于原型的继承模式。
在基于类继承的语言中,对象是类的实例,类可以从另一个类继承。从本质上而言,类相当于模板,对象则通过这些模板来进行创建。
下图所示,为基于类的继承实现示意图。每个类都有一个类似superclass
的指针指向其父类。每个对象都有一个类似isa
的指针指向其所属的类。
此外,每个类都存储了一系列方法,可用于其实例进行查找和共享。关于方法存储方式,不同语言的实现有所不同。- 对于 C++等语言,每个类会保存所有祖先类的方法地址。因此,在方法查找时,无需沿着继承链进行查找- 对于 Ruby、Objective-C等语言,每个类只会保存其所定义的方法地址,而不保存祖先类的方法地址。因此,在方法查找时,会沿着继承链进行查找,这种模式也被称为消息传递(Message Passing)。
在基于原型继承的语言中,没有类的概念,对象可以直接从另一对象继承。中间省略了通过模板创建对象的过程。
下图所示,为基于原型的继承实现示意图。每个对象都有一个类似prototype
的指针指向其原型对象。
每个对象存储了一系列方法,基于原型链,对象之间可以实现方法共享,当然也可以共享属性。方法和属性的查找过程,类似于上述的消息传递,会沿着原型链进行查找。
前面,我们简单对比了两种继承模式的实现原理。下面,我们来讨论一下原型继承的优缺点。
对比而言,原型继承的优点主要有一下这些:
当然,凡事都具有两面性,以下罗列了一些原型继承的缺点:
下面,我们来看看不同编程语言中,基于原型的继承模式的实现细节。
JavaScript原型实现存在着很多矛盾,它使用了一些复杂的语法,使其看上去类似于基于类的语言,这些语法掩盖了其内在的原型机制。JavaScript不直接让对象继承其他对象,而是提供了一个中间层——构造函数,完成对象的创建和原型的串联,从而间接完成对象继承。由于构造函数的定义类似于类定义,但又不是真正意义的类,因此我们可以称之为伪类(Pseudo Class)。
默认情况下,伪类包含一个 prototype
指针指向原型,对象包含一个 constructor
指针指向伪类(构造函数),两者之间的关系如下所示。
为了实现新的对象继承其他对象,一般会先修改伪类中prototype
的指针,然后再调用伪类进行对象构造和原型绑定。如下所示,为一段代码实例。
1 | function AType() { |
其中 BType.prototype = new AType()
修改了BType
伪类的 prototype
指针,使其指向AType
对象。当我们调用 BType
构造函数时,所构造的对象自动继承 AType
对象。如下所示,为基于原型的继承关系示意图,其中每个伪类的prototype
指针都发生了变化,指向了其所继承的父对象。最终,生成的对象中会包含一个__proto__
指针指向父对象。根据 __proto__
指针我们可以构建一个完整的原型链。
当然,在原型继承模式中,原型链中的父对象可能会被多个子对象所共享,因此子对象之间的状态同步问题需要格外注意。一旦,某个子对象修改了父对象的状态,那么会同时影响其他子对象。关于如何解决这个问题,JavaScript中有很多解决方法,具体细节可以阅读相关书籍和博客,这里不作详细赘述。
Lua 中的 表(table)是一种非常强大且常用的数据结构,它类似于其他编程语言中的字典或哈希表,可以以键值对的方式存储数据,包括方法定义。通常会使用table来解决模块(module)、包(package)、对象(object)等相关实现。
与此同时,Lua 还提供了 元表(metatable)的概念,其本质上仍然是一个表结构。但是元表可以对表进行关联和扩展,允许我们改变表的行为。
元表中最常用的键是 __index
元方法。当我们通过键来访问表时,如果对应的键没有定义值,那么 Lua会查找表的元表中的 __index
键。如果 __index
指向一个表,那么 Lua 会在这个表中查找对应的键。
如下所示,我们为表 a
设置一个元表,其中定义元表的。__index
键为表 b
。当查找表 a
时,对应的键没有定义,那么会去查找元表。判断元表是否定义了__index
键,这里定义为另一个表 b
。于是,会在表b
中查找对应的键。
1 | setmetatable(a, { __index = b }) |
Lua 中的继承模式正是基于元表和 __index
元方法而实现的。如下所示,分别是 Lua中继承模式的实现示意图,以及对应的代码实现。
1 | RootType = { rootproperty = 0 } |
RootType
是一个对象,其实现了一个 new
方法用于完成几项工作:
RootType
对象设置为新对象的元表。RootType
对象的 __index
指向RootType
对象自身。最终形成图中所示的对象继承关系。由于 Lua中的继承实现没有类的概念,而只有对象的概念。因此也被归类成基于原型的继承模式。当SubType
对象中没有找到对应的键时,会根据metatable
指针找到对应的元表,并根据元表的__index
指针找到进一步查找的表对象SuperType
。如果 SuperType
中仍然没有,那么继续根据 metatable
和 __index
指针进行查找。
Io 的继承模式也是基于原型实现的,它的实现相对而言更加简单、直观。
在 Io中,一切都是对象(包括闭包、命名空间等),所有行为都是消息(包括赋值操作)。这种消息传递机制其实与Objective-C、Ruby 是一样的机制。在 Io中,对象的组成非常关键,其主要包含两个部分:
Io 使用克隆的方式创建对象,对应提供了一个 clone
方法。当对父对象进行克隆时,新对象的 protos
数组中会加入对父对象的引用,从而建立继承关系。如下所示,为 Io中继承模式的实现示意图,以及对应的代码实现。
1 | RootType := Object clone |
相比于 JavaScript 和 Lua 的链表式单继承模式,Io是支持多继承的,其采用了多叉树的模式来实现的,其中最关键的就是protos
数组。很显然,protos
数组可以存储多个原型对象。因此,可以实现多继承。如下所示,是 Io中多继承模式的实现示意图。
因此,Io 中方法和属性的查找方式也有所不同,其基于 protos
数组,使用深度优先搜索的方式来进行查找。在这种模式下,如果一个对象继承的对象越多,那么方法和属性的查找效率也会越低。
本文,我们首先简单对比了基于类的继承模式与基于原型的继承模式,其核心区别在于是否基于类来进行构建继承关系。对于后者,没有类的概念,即使有,那也是一种语法糖,为了与基于类的语言靠拢降低开发者的学习成本和理解成本。
其次,我们简单介绍了基于原型继承的优缺点。当我们对编程语言进行技术选型时,也可以从这方面进行考虑和权衡,判断是否适用于特定的场景。
最后,我们介绍了三种编程语言中基于原型的继承实现,分别是:JavaScript、Lua、Io。三种语言各有其实现特点,但核心思想基本是一致的,即直接在对象之间建立引用关系,从而便于进行方法和属性的查找。
对于异步与并发,一直以来,业界都有着非常广泛的研究,针对特定场景提出了很多相关的技术,如:Future/Promise、Actor、CSP、异步函数等等。本文,我们来介绍一个近些年才出现的一个概念——结构化并发(StructuredConcurrency)。
2016 年,ZeroMQ 的作者 Martin Sústrik 于在其 C 语言结构化并发库libdill中首次提出了“结构化并发”的概念。事实上,这个概念其实是受到了更早期Dijkstra 所提出的 结构化编程(Structured Programming)的启发。
为了引出结构化并发,我们首先来介绍一下什么是结构化编程,这一切要从GOTO 有害论说起。
计算机发展的早期,程序员使用汇编语言进行编程,在之后的一段时期,诞生了比汇编略微高级的编程语言,如FORTRAN、FLOW-MATIC等。这些语言虽然在一定程度上提高了可读性,但是仍然存在很大的局限性。如下所示就是一段FLOW-MATIC 代码。
由于当时块语法还没有发明,因此 FLOW-MATIC 不支持 if
块、循环块、函数调用、块修饰符等现代语言必备的基础特性。整段代码就是一系列按顺序排列并打平的命令。关于控制流,程序支持两种方式,分别是:
顺序执行的逻辑非常简单,它总是能够找到执行入口与出口。与之相反,跳转执行则充满了不确定性。如果程序中存在GOTO 语句,那么它可以在任何时候跳转至任何指令位置。一旦程序大量使用了 GOTO语句,那么最终将变成 面条式代码(Spaghetti code)。
如下所示,我们对 FLOW-MATIC代码的控制流使用箭头进行变标记,可以发现整个逻辑变成了一团糟,如同面条一般。
在发表
结构化编程在现在看来是理所当然的,但是在当时并不是。结构化编程的核心是基于块语句,实现代码逻辑的抽象与封装,从而保证控制流具有单一入口和单一出口。现代编程语言中的条件语句、循环语句、函数定义与调用都是结构化编程的体现。
相比 GOTO语句,基于块的控制流有一个显著的特征:控制流从程序入口进入,中途可能会经历条件、循环、函数调用等控制流转换,但是最终控制流都会从程序出口退出。这种编程范式使得代码结构变得更加结构化,思维模型变得更加简单,也为编译器在低层级提供了优化的可能。
因此,完全禁用 GOTO语句已经成为了大部分现代编程语言的选择。虽然,少部分编程语言仍然支持GOTO,但是它们大都支持高德纳(Donald ErvinKnuth)所提出的前进分支和后退分支不得交叉的理论。类似break
、continue
等控制流命令,依然遵循结构化的基本原则:控制流拥有单一的入口与出口。
如今,我们基于现代编程语言所编写的程序,绝大部分都是结构化的,结构化编程范式早已深入人心。
在单线程编程模型中,编程语言通过代码块避免控制流随意跳转,从而实现程序的结构化。但是,在多线程编程(并发编程)模型中,线程之间控制和归属关系仍然存在很多问题,其面临的问题与GOTO 的问题非常相似,这也是结构化并发所要解决的问题。
下面,我们先来看一下非结构化并发的问题。
我们首先来看一个使用 Swift 编写的非结构化并发的例子,如下所示。
1 | func main() { |
上述代码中,主线程执行 main
方法是一个结构化的过程。而main
和 foo
内部则以非阻塞的方式执行并发任务,并通过 completion
获取结果。bar
内部则以阻塞的方式执行计算任务,并调用completion
返回结果。
进一步分析这段代码中各个方法,可以发现 main
和foo
中的并发任务派发其实是一种函数间的无条件 “跳转”行为。虽然,main
和 foo
都会立即将控制流返回至调用者,但是它们各自生成了新的并发任务。这些并发任务并不知道自己从哪里来,它们的初始调用不存在于其所属线程的调用栈中,其生命周期也与调用者的作用域完全无关。
这样的非结构化并发不仅使得代码的控制流变得非常复杂,而且还会带来了一个致命的后果:由于和调用者具有不同的调用栈,因此无法得知原始的调用者,进而无法以抛出的方式向上传递错误。
在非结构化并发的编程范式下,我们在调用任意一个方法,我们都会存在很多担忧:
这些问题都是非结构化并发可能存在的问题,而结构化并发正是为了解决这些问题而提出的。
那么,到底什么是结构化并发呢?结构化并发的核心是在并发模型下,也要保证控制流的单一入口和单一出口。程序可以产生多个控制流来实现并发,但是所有并发控制流在出口时都应该处于完成或取消状态,控制流最终在出口处完成合并。
在结构化并发的编程范式下,foo
方法将所产生的并发控制流最终都会收束至 foo
方法中,main
方法也是如此,实现了真正的自包含。此时,我们调用黑盒方法,能够确信即使方法会产生额外的并发任务,控制流最终也会回归到方法调用的位置,一切尽在掌握之中!
大多数情况下,结构化并发的实现技术栈如下图所示。从上层到底层可以分为五个部分,分别是:
结构化编程是以 代码块(Code Block)为基本要素进行组织的,而结构化并发则是以作用域(Scope)为基本要素进行组织的。在不同的编程语言或技术框架中,对于作用域的命名所有不同,如:Kotlin称为 Scope
,Swift 称为 Task
,Python trio 称为nursery
,C libdll 称为 bundle
。
类似于块语法用于标注结构化编程中的代码逻辑块,作用域则用于标注并发操作的执行范围。下图所示,为作用域标识并发操作作用域的示意图。另外,作用域之间的关系只有包含和并列关系,而没有部分重叠关系,这一点与块语法规则相同。这使得作用域之间的关系变得非常清晰,而易于管理。
在基于作用域的实现模式下,很多并发问题变得简单很多,比如:
很多编程语言都支持了异步函数,为了与同步函数进行区分,基本都提供了特定的关键词来进行声明或调用,比如:async
/await
,suspend
、yield
/resume
等。通过异步函数,我们可以通过同步调用的方式来编写代码,从而避免出现低于回调,进而提高代码的可读性。
如果编程语言只提供了异步函数,而不支持作用域,事实上也能够避免内部异步任务生命周期超出外部调用方法声明周期,因为异步函数的调用是通过以阻塞式的方式执行的。
相比而言,作用域的作用则在于管理多任务并发执行,解决多任务取消,值/异常传递等问题,这些是异步函数所无法解决的。
对于并发任务本身,其运算调度则由线程来支持。但是在高并发的场景下,基于传统意义上的线程池可能会面临性能瓶颈,如:线程爆炸、线程切换等。
为了解决性能和效率问题,大部分支持结构化并发的编程语言都以协程(Coroutine)作为运算调度的最小单元。那么到底什么是协程?协程本质上就是用户态线程,关于协程的进一步介绍可以阅读我之前写过的一篇博客——
我们知道操作系统线程模型主要有 3种,如下图所示。其中,纯用户态线程模型是早期单核 CPU的产物,纯内核态线程模式是多核 CPU下相对高效的模型。现代操作系统普遍采用的是组合式线程模型,支持提供远超CPU核心数量的用户态线程池。用户态线程的切换不涉及线程资源(包括寄存器、栈指针、栈内存等)切换,因此性能开销相对较小。
在传统的同步编程模式下,我们始终维护一个线性的调用栈,而在基于作用域和协程实现的并发编程模式下,我们可以维护一个树形的调用栈,如下图所示。基于树形调用栈,我们可以有效记录父子并发任务之间的调用关系,便于问题定位与追踪。注意,协程可以根据是否基于调用栈实现,分为有栈协程和无栈协程,这里我们以有栈协程为例,介绍结构化并发的优势。
在代码层面,异步函数之后的代码是怎么实现等待异步函数执行完成之后再执行的呢?试想一下如下这段伪代码,为什么print result
会等待异步函数 asyncOperation
完成之后才会执行的呢?怎么做到的?
val result = await asyncOperation()print result
事实上,这样要归功于计算续体(Continuation)。知道回调函数的人很多,但是知道计算续体的人并不多。当一个计算过程在中间被打断,其剩余部分可以使用一个对象进行表示,这个对象就是计算续体。当然,操作系统暂停一个线程时保存的那些数据快照,也可以看成是一个计算续体。基于计算续体,我们就能实现从上次中断的地方继续执行。
既然可以利用续体来等待异步操作执行完成,那么执行过程中运行时系统是如何选择哪些部分作为续体呢?对此,大多数编程语言会提供相关的关键词进行修饰,最常见的就是async
和 await
。一般 async
用于声明一个异步函数,await
用于挂起(执行)一个异步函数。事实上,计算续体就是异步函数的底层实现技术。
当使用 await
调用一个异步函数时,那么编译器会将后续部分的代码转换成续体,当异步任务执行完毕之后,再将值传递至续体中继续执行,有点类似于方法回调。
在不同的编程语言中,计算续体的表示也有所不同。Kotlin 和 Swift 使用Continuation
表示,Lua 使用 Coroutine Object
表示,JavaScript 使用 Generator
表示,Dart 使用Async Generator
表示。
事实上,计算续体与函数栈帧有着非常紧密的关系,前者是保存和恢复栈帧的一种机制。
在函数调用时,每个函数都会创建一个栈帧,其包含函数的局部变量、参数以及返回地址等信息。栈帧被存放在进程空间的栈区,当函数返回时,对应的栈帧会从栈中弹出,程序恢复到调用该函数的地方。
计算续体则是将 当前栈帧 以及程序计算器等信息保存至一个对象中,然后将该对象传递给一个续体函数。续体函数可以在需要时将保存的状态恢复,从而继续执行程序。
因此,从运行时层面看,计算续体就是当前函数的栈帧与现场状态;从代码层面看,计算续体就是等待异步操作完成的后续代码。
提到计算续体,我们就不得不提一下 CPS变换(Continuation-Passing-Style Transformation)。
CPS 变换本质上就是将等待执行的代码转换成一个函数,计算续体作为函数的参数,参数名通常命名成Continuation
。
下面,我们以 Swift 为例进行介绍。假如,我们有一个旧版oldLoad
方法,通过闭包进行异步回调。此时,我们希望设计基于异步函数的新版newLoad
方法,但是内部仍然使用旧版 oldLoad
方法进行复用。在这种场景下,我们就可以利用 CPS 变换来实现预期目标。
1 | # 旧版方法 |
下图所示,当我们使用新版 newLoad
方法时,等待异步执行的代码被封装成了一个函数,函数的参数是一个Continuation
。我们可以根据不同的情况向Continuation
传递值或错误,从而让等待异步执行的代码继续执行。
事实上,Kotlin 协程就是通过 CPS转换实现的,其在编译期间对调用挂起函数的上下文进行拆分,完成 CPS转换。这也是为什么 Kotlin 可以不用修改 VM 或 OS就能够支持协程的原因。
通过上一节我们知道了结构化并发所涉及的各种技术。下面,我们来通过一段Swift 代码,介绍一下并发任务的调度模型。
1 | func save(_ contents: [Contents]) async throws -> [ID] { ... } |
假设一个线程调用了一个 handle
方法。在这个阶段,最近的堆栈将是 handle
。当handle
遇到内部的 await
关键词修饰的异步操作时,运行时会将 handle
方法的计算续体存储至堆中,从而等待异步操作完成。
当运行时发现存在空闲的线程时,则将异步操作 save
加入对应线程的栈中并开始执行。但是 save
方法内部又存在异步I/O 操作,因此 save
方法的计算续体又会被存储至堆中,从而等待 I/O 操作完成。
在等待 I/O操作的过程中,线程会被让出,从而允许其他任务进行复用。下图中,运行时会将otherWork1
方法加入线程并执行。
当 save
所等待的 I/O操作完成之后,运行时会寻找空闲的线程,并将 save
的计算续体加入栈中并执行。
当 save
执行完毕,运行时会将与 save
计算续体关联等待的 handle
续体取出,选择一个空闲的线程来执行。
在 handle
计算续体执行过程中,会调用同步方法如zip
,那么栈上将会正常加入 zip
的栈帧。
zip
执行完毕之后,对应的栈帧出栈,继续执行hanle
计算续体。由于这里是一个 for
循环,zip
栈帧的入栈和出栈会循环往复多次。最终,handle
计算续体也执行完毕。
本文通过 GOTO有害论引出编程历史中结构化编程的演化。以结构化编程作为类比,介绍了结构化并发的核心观点,以及结构化并发的设计理念。结构化并发主要包括作用域、异步函数、计算续体、协程等技术,此外还需要运行时系统的调度,才能最终实现理想的结构化并发。
关于高级编程语言中结构化并发的实践,后续我们将继续在其他文章中进行讨论。目前原生支持结构化并发的编程语言并不多,幸运的是移动端开发的编程语言Kotlin、Swift是支持的,后面我们会研究一下这两者对于结构化并发的实现。另外,有时间的话,我们也会介绍一些结构化并发的辅助框架,比如:trio、libdll等,进而加深对于结构化并发的理解。
作为入门,本文我们来简单聊一聊 actor 模型。
一个 actor 定义为一个计算单元。所谓麻雀虽小,五脏俱全,每个 Actor包含了存储、通信、计算等能力。在分布式系统中,通常包含了非常多的服务器集群,每一台服务器又包含了大量actor 实例,它们共同构成了强大的并行计算能力。
Actor 的核心思想是独立维护隔离状态,并基于消息传递实现异步通信。围绕其进行实现,actor通常包含以下特征:
为了便于通信,actor 模型使用 异步消息传递。消息传递不使用任何中间实体,如:通道(channel)。由于 actor模型的消息是异步传递的,中间可能会经过很长时间,甚至丢失,因此无法保证消息到达目标actor 时的顺序。每个 actor 都完全独立于任何其他实例,actor之间的交互完全基于异步消息,因此能够在很大程度上避免共享内存的存在问题。
Actor 模型根据任务调度的方式可以分为两种,分别是:
基于线程的 actor 模型,其本质是为每一个 actor分配一个独立的“线程”。这里的“线程”并不是严格意义的操作系统线程,而是广泛意义的执行过程,它可以是线程、协程或虚拟机线程。
在基于线程的 actor 模型中,每个 actor 独占一个线程,如果当前 actor的邮箱为空,actor 会阻塞当前线程,等待接收新的消息。在实现中,一般使用receive
原语。
这种 actor模型实现起来比较简单,但是缺点也非常明显,由于线程数量受到系统的限制,因此actor 的数量也会受到限制。现阶段,只有少部分 actor模型采用基于线程的实现方式,如:Erlang、Scala Actor、Cloud Haskell。
Erlang 是第一种实现基于线程的 actor 模型的编程语言。Erlang提供三种基本操作以实现 actor 模型,分别是:
spawn
:创建一个进程(process)。在 Erlang中,进程属于虚拟机,而非操作系统。send
:发送消息至一个线程。receive
:接收消息。如下所示,为一个基于 Erlang actor 的使用示例。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28% area_server.erl
-module(area_server).
-export([start/0, area/2, loop/0]).
start() -> spawn(area_server, loop, []).
area(Pid, What) ->
rpc(Pid, What).
rpc(Pid, Request) ->
Pid ! {self(), Request},
receive
{Pid, Response} ->
Response
end.
loop() ->
receive
{From, {rectangle, Width, Ht}} ->
From ! {self(), Width * Ht},
loop();
{From, {circle, R}} ->
From ! {self(), 3.14159 * R * R},
loop();
{From, Other} ->
From ! {self(), {error, Other}},
loop()
end.erl
解释执行。当进入 Erlang REPL环境后,我们可以执行如下代码进行测试。 1
2
3
4
5
6
7
8
9
10
111> c(area_server).
{ok,area_server}
2> Pid = area_server:start().
<0.94.0>
3> area_server:area(Pid, {rectangle, 10 * 8}).
{error,{rectangle,80}}
4> area_server:area(Pid, {circle, 5}).
78.53975
Scala Actor 同样也实现了基于线程的 actor 模型,它将 Erlang风格的轻量级消息传递并发性待到了JVM,并将其集成到了重量级的线程/进程并发模型中。如下所示,为 Scala Actor的使用示例,其实现语法也与 Erlang 非常相似。不过,从 Scala 2.11开始,scala actors不再作为标准库,示例中的代码我们需要进行一番改造才能运行。但是,从实现上来看,ScalaActor 和 Erlang Actor 非常相似,均采用 receive
原语接收消息,阻塞线程。
1 | // pingpong.scala |
在事件驱动的 actor 模型,actor并不直接与线程耦合,只有在事件触发(即接收消息)时,才为 actor的任务分配线程并执行。这种方式使用续体闭包(Continuation Closure)来封装actor及其状态。当事件处理完毕,即退出线程。通过这种方式,我们可以使用很少的线程来执行大量actor 产生的任务。在实现中,一般使用 react
原语。
事件驱动的 actor模型在消息触发时,会自动创建并分配线程。在这种过程中,一般的优化是将actor执行建立在底层的线程池之上,这些线程可以是线程、协程或虚拟机线程。从概念上讲,这种实现与run loop、event loop 机制非常相似。
现阶段,大部分 actor 模型采用事件驱动的调度方式。
Dart Isolate 本质上是一种事件驱动的 actor 模型,一个 Isolate 对应一个Actor。一个 IsolateGroup 管理多个Isolate,基于此可以实现结构化并发。Dart VM底层实现了一个线程池,管理操作系统线程。当接收到一个消息时,会自动创建一个线程来执行对应的处理方法。
如下所示,是一个 Dart Isolate 的使用示例。ReceivePort
和SendPort
本质上就是 Isolate的地址,只不过从语义上进行区分,定义了接收者和发送者。spawn
方法创建一个新的 Isolate,续体闭包 _readAndParseJson
即新创建的 actor。执行完毕之后,通过 SendPort
将结果返回给主 Isolate。
1 | void main() async { |
Groovy 的 Gpars Actor 也是一种事件驱动的 actor 模型,并发的 actor共享一个线程池,底层使用 fork/join 进行线程调度,其使用了react
原语。
如下所示,为一个 Gpars Actor 的使用示例。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23import groovyx.gpars.actor.Actor
import groovyx.gpars.actor.Actors
final def doubler = Actors.reactor {
2 * it
}.start()
Actor actor = Actors.actor {
(1..10).each {doubler << it}
int i = 0
loop {
i += 1
if (i > 10) stop()
else {
react {message ->
println "Double of $i = $message"
}
}
}
}.start()
actor.join()
doubler.stop()
doubler.join()
Actor模型是分布式/并发编程中常用的一种解决方案,其基本的设计结构非常简单,其核心思想是“独立维护隔离状态,并基于消息传递实现异步通信”。
根据 actor 的底层调度方式,其又可以分为:基于线程的 actor和事件驱动的actor。两者在底层的线程使用方式上有所区别。目前,绝大多数编程语言采用的事件驱动的actor模型,其在资源分配方面更加合理,执行效率也更高;缺点在于底层实现复杂度高。
后续有机会我们来探索一下 actor 的实现源码,加强一下对于 actor实现的整体认知。
又到了一年一次例行总结的时候,写篇文章来回顾一下这一年的经历、收获和成长吧。
今年年初,部门内出现了不少人员变动,印象中有 @周剑、@向南、@孝发、@全义等都提了离职。一方面,考虑到公司因政策影响无法上市,未来非常不明朗;另一方面,我工作4年至今未曾接触自由市场。因此,想了解一下自己在市场中属于什么水平,看看有没有好的机会。
我从 1 月 20 日第一次面试,到 3 月 17日最后一次面试,中间经历了过年,有两个多星期没有安排面试,总共历时 2个月。简历差不多投递了十几家公司,基本都是知名的互联网大厂和中厂,最终差不多面试了30场,也是心力交瘁,自我介绍和项目介绍都背麻了...不过,好在结果还是挺好的,除了有一家技术面面挂了,其他的基本上要么是流程到了HR 面,要么是我主动结束面试流程,还有几家是给了书面 Offer 或口头Offer。
这三个月业余时间一直在复习基础、刷算法题、复盘面试,期间和
当然,最终我没有选择跳槽,主要原因还是 @碧峰 说的“有更大的发挥空间和成长空间”。另外,还有几个次要原因:
在作出决定之前,@李哲转岗去了搜题,大哥找我聊了一次;在作出决定之后,我找大哥又聊了一次。在得到了大哥的一些承诺之后,开始逐步接管客户端团队,负责Android/iOS/Cocos/Flutter 的日常事务和工作安排,这时候差不多 4月份了。
今年的工作角色发生了转变,从 iOS Owner转变成客户端负责人。这一年来,自己也推动了一些事情的落地。
第一件事是优化 Zeta Math 上课路径效果。经过综合考虑后,设计了一套基于Cocos View复用,支持代码热更新、内容增量更新的技术方案。相比于第一版基于 Native实现的方案,新版本的视觉效果和用户体验好了很多。项目整体开发经历了一个月,最终顺利上线。
第二件事是 GitLab CICD 建设,搭建了一套基于 Danger 的Lua/Swift/Kotlin 的静态代码检查能力。由于 Lua较为小众,没有现成的插件可供使用,对此,开发了一个 ruby gem——
当然,CICD能做的事远远不止这些,包括:单元测试、依赖分析、编译检查、代码扫描等等。等到后续人手充足,时间充裕,再捡起来。
第三件事是去 Flutter 化。海豚自习 App 的首页四个Tab,包括一部分二级页都是用 Flutter 写的。在需求迭代过程中,Flutter开发的很多痛点逐步暴露出来了,主要包括以下这些:
一方面,考虑到后续几个 Tab首页的需求迭代会越来越多;另一方面,希望能够让更多的同学参与到这些需求的开发中。于是,开始规划Tab 重写。很幸运地,在国庆前找到了一个空档期,所有同学都参与了 Tab重写,同样也花了一个月的时间,顺利上线。上线后的优化效果的确很明显,白屏没有人报了,崩溃率也下降了不少,页面流畅度也提升了不少。
第四件事是基于 page scheme 的动态弹窗管理能力建设。构想并建设 pagescheme 主要基于几个事实:
最终,我构想了一套 page scheme 能力,通过项目代码生成不同版本的 pagescheme 文件,后台可以根据 page scheme配置指定的页面显示弹窗或一些其他能力。服务器将后台配置结合消息数据,通过MQTT 或 HTTP 的方式下发至 App,App 再进行处理。
期望的项目终极形态是,支持在运营在后台配置任意弹窗,包括:WebLayer、Native弹窗等,支持指定页面显示(或不显示)。其中,WebLayer支持类似弹窗、新手引导、红包雨、气泡等各种运营效果。
目前,page scheme能力的搭建还在进行中,客户端的相关能力基本已经完成,包括:弹窗管理器、pagescheme 定义、配置文件生成、MQTT 分派、WebLayer管理器等。后续,还要继续推动后台相关的基础设施的搭建。整体来讲,我个人觉得这个项目还是比较有意义的,并且具有一定的创新性,期待上线后的效果。
今年阅读了四个开源项目的代码,分别是:getopt、git、Aspects、PromiseKit。
关于 getopt 源码,主要是出于两个契机。一个是自己看完了《C语言教程》;一个是出于优化 nox 的目的。最终,产出了一篇源码解读文章
关于 git 源码,我首先精读了创世版的代码,也就是 Linus Torvalds写的那个版本。然后看了下 2.0.0版本的代码,核心的数据结构和设计原理没有太大的变化。同时又看了《ProGit》的部分章节,最终对 Git的底层设计原理有了一个比较清晰的认识。对此,写了篇文章
关于 Aspects源码,框架的实现非常精简,只有一份代码文件。当然,也是非常仔细的阅读了一遍,整理了一下思路,并写了篇文章
关于 PromiseKit源码,是因为年底的时候对于异步编程比较感兴趣,想到辅导的代码里也用到了,于是想来学习了一下。最后也产出了两篇相关的文章:
今年自己读书相关的计划完成的还可以,主要看了以下这么些:
除了技术书籍之外,其他的就看了毛选的卷一,看了一半,确实能感受到教员的伟大。后面,有时间有心境的时候,还是要好好读读毛选。
今年总共写了 20 来篇博客,有一半是对于《Kaleidoscope: Implementing aLanguage withLLVM》的章节翻译,当然也包含了一部分自己精读后的理解。除此之外,一部分是跟编译原理工具有关,一部分是跟异步编程有关,其他的还有一些琐碎的主题。整体来说,涉及的技术广度还不是很大。
因为今年年初的面试经历,了解到外企的一些高级岗位还是有英语要求的,特别是口语。考虑到未来失业了能多一个选择🐶,于是想着从现在开始好好练练口语。苦于没有语言环境,最后抱着试试看的心态,买了个流利说的课。想着坚持一年,看看效果。从4月份到现在,总体感觉是有点帮助的,至少语感,语言组织要比以前好很多。后续再看看,要不要考虑一下真人一对一。
今年生活中最大的里程碑就是还完了房贷,好歹少还了大几十万的利息。
其次,便是休了一个长假,国庆节 + 8 天年假 + 1 个周末,总共休假 17天。假期计划分三段行程:湖州长兴、南京、合肥。
在长兴的几天,见了假期最后一天的老弟,走了几家亲戚,和发小看了场"乡BA"、吃了顿夜宵。在家的几天,和老姐、外甥去了次太湖龙之梦。龙之梦确实非常大,比环球影城大不少,但是有些主题公园还没有完全造好。我们只体验了动物世界主题公园,整体感觉还是挺不错的,比北京动物园强多了。
因为疫情原因,本来去南京玩的计划取消了,直接去了合肥。在合肥待的时间比较久,于是定了计划,把《SoftwareEngineering atGoogle》看完了。由于国庆期间天气太极端,不是极端热就是寒潮降温,周边玩也没玩好。
国庆前两天,偶然看了下北京健康宝,发现弹窗3。因为可能会耽误回京,导致后面几天每天都在尝试消弹窗,也没什么心情出去玩。
一整年来,因为疫情反复,也没有出去好好玩玩。2023年放开了之后,希望一切都能恢复正常吧。
今年一直在保持运动,跟 @龙哥一伙人组了局,同时部门内部又组了个局,基本上每周都能打次球,甚至打两次。
另外,今年算是把跑步坚持了下来。从 7月份开始,每周跑一次十公里。配速从 6:00 提高到了最快5:16。可惜,到了十一月份,因为疫情,再加上室外过于寒冷,风又太大,就暂停了。希望明年能够从开春就坚持跑步,保持每周十公里,争取能参加一次马拉松。
2022年一直处于较为紧张和规律的状态,收获也挺多,看了些书,读了些源码,写了些博客,希望2023 年能够继续保持吧。
另外,身体才是革命的本钱。少熬夜,多喝水。保护视力,坚持运动。
最后,祝新年快乐~
]]>本文,我们来简单聊一聊 Future 和 Promise历史和设计,以及两者之间的关系与区别。
关于 Future 和 Promise 的起源,最早可以追溯到 1961 年的Thunk。根据创造者 P.Z. Ingerman 的描述,Thunk是提供地址的一段代码。
Thunk 被设计为一种将实际参数绑定到 Algol-60过程调用中的正式定义的方法。如果用表达式代替形式参数调用过程,编译器会生成一个thunk,它将执行表达式并将结果的地址留在某个标准位置。
目前,thunk 的用法仍然非常广泛,我在
1977 年,Henry C. Baker 和 Hewitt 在论文《The Incremental GarbageCollection of Process》中首次提到 Future。
他们提出了一个新的术语 call-by-future
,用于描述一种基于Future 的调用形式。当将表达式提供给执行器时,将返回该表达式的.future
。如果表达式返回类型为值类型,那么当未来表达式计算得到值时,会将值返回。这里会为每一个future
都会创建一个进程,并立即执行表达式。如果表达式已完成,则值立即可用;如果表达式未完成,则请求进程等待表达式执行完成。
在论文中,Future 主要由三部分组成:
从 Future 的概念我们可以看出,论文所提到的 Future 几乎已经和现代的Future 概念非常接近了。
1985 年,Robert H. Halstead 在论文《Multilisp: A Language forConcurrent Symbolic Computation》中提出的 Multilisp 语言支持了基于future
注解的 call-by-future
能力。
在 Multilisp 中,如果变量绑定到 Future的表达式,则会自动创建一个新的进程。表达式会在新的进程中执行,一旦执行完成,则将计算结果保存至变量引用中。通过这种方式,Multilisp支持在新进程中同时计算任意表达式的能力。因此,也支持无需等待 Future完成,继续执行其他计算的能力。这样的话,如果 Future的值从未使用过,那么整个进程就不会被阻塞,从而消除了潜在的死锁源。
相比于 1977 年提出的 Future,Mutilisp 实现的 Future支持在特定情况下不阻塞进程,从而一定程度上优化了程序的执行效率。
1988 年,Liskov 和 Shrira 在论文《Distributed Programming inArgus》中提出的 Argus 语言设计了一种称为 Promises 的结构。
与 Multilisp 中的 Future 类似,Argus 中的 Promise也提供一个用于存储未来值的占位符。Promise 的特别之处在于,当调用 Promise时,会立即创建并返回一个 Promise,并在新进程中进行类型安全的异步 PRC调用。当异步 PRC 调用执行完毕,由调用者设置返回值。
经过数十年的发展,Future 和 Promise的设计理念整体上非常相似,但是在不同的语言和框架实现中又存在一定的区别,对此,这里我们基于最广泛的定义进行介绍。
在 Scala、C++ 等编程语言中,同时包含两种结构分别对应 Future 和Promise。作为整体实现,Future 和 Promise可被视为同一异步编程技术中的两个部分:
在同时包含 Future 和 Promise 的实现中,一般 Promise对象会有一个关联的 Future 对象。当 Promise 创建时,Future对象会自动实例化。当异步任务执行完毕,Promise在内部设置结果,从而将值绑定至 Future 的占位符中。Future则提供读取方法
将异步操作分成 Future 和 Promise 两个部分的主要原因是为了实现读写分离,对外部调用者只读,对内部实现者只写。
下面,我们以几种语言中的实现来分别进行介绍。
在 C++ 中,Future 和 Promise 是一个异步操作的两个部分。
std::future
:作为异步操作的消费者。std::promise
:作为异步操作的生产者。1 | auto promise = std::promise<std::string>(); |
从上述代码中可以看出,C++ Promise 包含了 Future,可以通过get_future
方法获取 Future 对象。两者有明确的分工,Promise提供了 set_value
方法支持写操作,Future 提供了get
方法支持读操作。
在 Scala 中,同样如此,Future 和 Promise可作为同一个异步操作的两个部分。
Future
作为一个可提供只读占位符,用于存储未来值的对象。Promise
作为一个实现一个Future,并支持可写操作的单一赋值容器。1 | import scala.concurrent.{ Future, Promise } |
从上述代码中可以看出,Scala Promise 同样包含了 Future,可以通过future
属性获取 Future 对象。Promise 提供了success
、failure
等方法来更新状态。Future提供了 onSuccess
、onFailure
等方法来监听未来值。
其他很多编程语言中,并不同时包含 Future 和 Promise两种结构,比如:Dart 只包含 Future,JavaScript 只包含Promise,甚至有些编程语言混淆了 Future 和 Promise 的原始区别。
在独立实现中,Future 和 Promise各自都有着相对比较统一的表示形式,在实现方面的差异也相对比较一致,主要包括以下几个方面区别:
在状态表示方面,Future 只有两种状态:
uncomplete
:表示未完成状态,即未来值还未计算出来。completed
:表示已完成状态,即未来值已经计算出来。当然计算结果可以分为值或错误两种情况。对于 Promise,一般使用三种状态进行表示:
pending
:待定状态,即 Promise 的初始状态。fulfilled
:满足状态,表示任务执行成功。rejected
:拒绝状态,表示任务执行失败。无论是 Future 还是 Promise,状态转移的过程都是不可逆的。
在状态更新方面,Future 的状态由内部进行自动管理。当异步任务执行完成或抛出错误时,其状态将隐式地自动从uncomplete
状态更新为 completed
状态。
对于 Promise,其状态由外部进行手动管理。通常由开发者根据控制流逻辑,执行特定的状态更新方法显式地从pending
状态更新为 fulfilled
或rejected
状态。
在返回机制方面,Future 以传统的 return
方式返回结果。如下所示为 Dart 中 Future的返回机制示例,其返回正如普通的方法一样,通过 return
完成。
1 | Future<String> _readFileAsync() async { |
而 Promise通常将结果作为闭包参数进行传递,并执行闭包从而实现返回。如下所示为JavaScript 中 Promise 的返回机制示例,resolve
是一个只接受成功值的闭包,其参数为 Image
类型;reject
是一个只接受错误值的闭包,其参数为Error
类型。
1 | function loadImageAsync(url) { |
下面,我们来看一下各种编程语言是如何独立实现 Future 或 Promise的。
Dart 内置提供了标准 Future
实现,其同时提供了async
和 await
关键字分别用于描述异步函数和等待异步函数。如下所示,为 Dart 中的 Future应用示例。
1 | Future<String> createOrderMessage() async { |
C# 提供了 Task
,其本质上类似于一种 Future 实现。此外,C#还提供了异步函数关键字 async
和await
,分别用于描述异步函数和等待异步函数。如下所示,为 C#中的使用示例。
1 | async Task<int> AccessTheWebAsync() { |
Swift 提供了 Task
,其本质是一种加强版的 Future实现。Swift 通过提供额外的 TaskGroup
的概念,使其同时支持结构化并发和非结构化并发。此外,Swift 也提供的async
await
关键字支持异步函数,基于此,Swift也能够实现和其他语言一样的 Future 实现。如下所示,为 Swift 中类似于Future 的使用示例。
1 | let newPhoto = // ... some photo data ... |
Java 1.5 提供了 Future
和 FutureTask
,其中Future
是一个接口,FutureTask
是一种实现,它们提供了一种相对标准的 Future 实现。其通过Runnable
和 Callable
进行实例化,有一个无参构造器,Future
和FutureTask
支持外部只读,FutureTask
的 set方法是protected
,未来值只能由内部进行设置。如下所示,为基于FutureTask
的一个应用示例。
1 | public class Test { |
Java 8 提供了 CompletableFuture
,其本质上是一种 Promise的实现。按照我们之前的定义,Future 是只读的,Promise 是可写的,而CompletableFuture
提供了可由外部调用的状态更新方法,因此可以将其归类为Promise。另一方面,CompletableFuture
又实现了 Future的读取方法 get
。整体上,CompletableFuture
混合了 Future 和 Promise 的能力。如下所示,为CompletableFuture
的一个应用示例。
1 | Supplier<Integer> momsPurse = ()-> { |
从 ES6 开始,JavaScript 支持了 Promise 的经典实现,同时支持了async
和 await
关键字用于描述异步任务。使用async
关键字修饰函数的返回值是一个 Promise
对象。await
关键字修饰一个 Promise
对象,表示等待异步任务的值,有点类似等待 Future。如下所示,为 JavaScript中 Promise
的使用示例。
1 | class Sleep { |
本文简单介绍了一下 Future 和 Promise的发展历史。然后,分别介绍了两者在实现中的关系和区别。同时,介绍了Future 和 Promise 在各种编程语言中的实现。
后续有时间,我们在来深入研究一下编程语言层面是如何支持 Future 和Promise 。
Aspects 是一款轻量且简易的面向切面编程的框架,其基于 Objective-CRuntime 原理实现。Aspects 允许我们对类的所有实例的实例方法 或单个实例的实例方法添加额外的代码,并且支持设置代码的执行时机,包括:before
、instead
、after
三种。
注意:Aspects无法为类方法提供面向切面编程的能力。
对象类型 | 目标方法类型 | Aspects 是否支持 hook | hook 效果 |
---|---|---|---|
类对象(UIViewController) | 类方法(“+”开头的方法) | 不支持 | - |
类对象(UIViewController) | 实例方法(“-”开头的方法) | 支持 | 对类的所有实例对象生效 |
实例对象(vc) | 类方法(“+”开头的方法) | 不支持 | - |
实例对象(vc) | 实例方法(“-”开头的方法) | 支持 | 对单个实例对象生效 |
这里我们提出第一个问题:为什么 Aspects 仅支持对实例方法进行hook?
另一方面,Aspects 的作者在框架的 README中明确表示不要在生产环境中使用Aspects。这里我们提出第二个问题:在项目中使用 Aspects 进行 hook是否有什么坑?
Aspects 巧妙利用了 Objective-C 的消息传递和消息转发机制,实现了一套与KVO 类似的技术方案。为了能够更加清晰地理解 Aspects的设计,这里我们简单地回顾一下 Objective-C的消息传递和消息转发机制。
Objective-C 是一门动态语言,其 方法调用在底层的实现是 消息传递(MessagePassing)。本质上,消息发送是沿着一条引用链依次查找不同的对象,判断该对象是否能够处理消息。在Objective-C中,一切都是对象,包括类、元类,消息就是在这些对象之间进行传递的。
因此,我们需要了解这些对象之间的关系。下图所示,为 Objective-C对象在内存中的引用关系图。
在 Objective-C中,涉及消息传递的方法主要有两种:实例方法、类方法。下面,我们来分别介绍。
对于实例方法,消息传递时,根据当前实例对象的 isa
指针,找到其所属的类对象,并在类对象的方法列表中查找。如果找到,则执行;否则,根据superclass
指针,找到类对象的超类对象,并在超类对象的方法列表中查找,以此类推,如下所示。
虽然 Aspects 不支持 hook类方法,但是为了方便进行对照,这里我们也介绍一下类方法的查找。
对于类方法,消息传递时,根据当前类对象的 isa
指针,找到其所属的元类对象,并在元类对象的方法列表中查找。如果找到,则执行;否则,根据superclass
指针,找到元类对象的元超类对象,并在元超类对象的方法列表中查找,以此类推,如下所示。
如果消息传递无法找到可以处理消息的对象,那么,Objective-C runtime将进入消息转发(Message Forwarding)。
消息转发包含三个阶段:
当对象接收到未知消息时,首先会调用所属类的实例方法+ (BOOL)resolveInstanceMethod:(SEL)sel
或类方法+ (BOOL)resolveClassMethod:(SEL)sel
。我们可以在方法内部动态添加一个“处理方法”,通过class_addMethod
函数动态添加到类中。比如:
1 | void dynamicMethodIMP(id self, SEL _cmd) { |
如果上一步无法处理消息,则 runtime 会继续调用forwardingTargetForSelector:
方法。
如果一个对象实现了这个方法,并返回一个非 nil
(也不能是self
)的对象,则这个对象会作为消息的新接收者,消息会被分发到这个对象。比如:
1 | - (id)forwardingTargetForSelector:(SEL)aSelector { |
这一步合适于我们只想将消息转发到另一个能处理该消息的对象上。但这一步无法对消息进行处理,如操作消息的参数和返回值。
如果在上一步还不能处理未知消息,则唯一能做的就是启用完整的消息转发机制了。
这步调用 methodSignatureForSelector:
进行方法签名,这可以将函数的参数类型和返回值进行封装。如果返回nil
,则说明消息无法处理并报错unrecognized selector sent to instance
。
1 | - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { |
如果返回 methodSignature
,则进入forwardInvocation
。对象会创建一个表示消息的NSInvocation
对象,把与尚未处理的消息有关的全部细节都封装在anInvocation
中,包括selector
,target
,参数。在这个方法中可以修改实现方法,修改响应对象等,如果方法调用成功,则结束。如果依然不能正确响应消息,则报错unrecognized selector sent to instance
。
1 | - (void)forwardInvovation:(NSInvocation)anInvocation { |
Aspects 的核心原理主要包括三个部分:
AspectsContainer
。当 hook 实例方法时,Aspects 会为 实例对象 或类对象 注册关联对象AspectsContainer
。AspectsContainer
保存了用户hook 的目标方法、执行闭包、闭包参数、执行时机等信息。下图所示,为AspectsContainer
引用关系图。
关联对象注册的目标分两种情况,这种设计策略是有原因的:
当且仅当 hook 实例对象的实例方法时,Aspects 会为实例的所属类TestClass
创建一个子类TestClass_Aspects_
(同时创建对应的元类),并修改实例的isa
指针,使其指向 TestClass_Aspects_
子类,同时 hook TestClass_Aspects_
的 class
方法,使其返回实例的所属类 TestClass
,如下图所示。
整体的实现方式与 KVO 原理一致,尤其是修改动态类 class
方法的实现,使得在外部看来,实例的所属类并没有发生任何变化。
这里,我们可以思考一下第三个问题:为什么在 hook实例对象的实例方法时要创建动态类?
当 hook 实例方法时,最重要的一步是对动态创建的类对象(下文简称:动态类对象) 或原始继承链中的类对象(下文简称:目标类对象)的两个核心方法与 Aspects 提供的方法进行交换。这两个方法分别是:目标selector 和forwardInvocation:
。具体的交换逻辑如下图所示。
Aspects 会将目标 selector 的实现设置为 Aspects 提供的aspect_getMsgForwardIMP
方法的返回值。aspects_getMsgForwardIMP
的返回值本质上是一个能够直接触发消息转发机制的方法。更加特殊的地方在于,这里会直接进入消息转发的最后一步forwardInvocation:
。
与此同时,Aspects 会将动态类对象或目标类对象的forwardInvocation:
的实现设置为 Aspects 提供的__ASPECTS_ARE_BEING_CALLED__
方法实现。__ASPECTS_ARE_BEING_CALLED__
内部会从实例对象 或 类对象 中取出关联对象AspectsContainer
,并根据其所保存的 hook 信息执行闭包和目标selector 的原始实现。
注意:对于核心方法交换,Aspects支持幂等。即如果对同一个实例方法 hook 多次,Aspects会保证对这两个方法只交换一次。
下面,我们来通过源码,具体分析一下 Aspects 中的设计细节。
首先,简要介绍一下 Aspects 定义的数据结构,主要包括三种数据结构:
AspectsContainer
AspectIdentifier
AspectInfo
如下所示,为 AspectsContainer
的数据结构定义。AspectsContainer
是 Aspects所有信息的根容器,其包含了三个数组,用于保存三种类型的AspectIdentifier
。
beforeAspects
:用于保存执行时机为AspectPositionBefore
的AspectIdentifier
。insteadAspects
:用于保存执行时机为AspectPositionInstead
的AspectIdentifier
。afterAspects
:用于保存执行时机为AspectPositionAfter
的 AspectIdentifier
。除此之外,AspectsContainer
还提供了对于数组进行增删操作的方法。
1 | // Tracks all aspects for an object/class. |
如下所示,为 AspectIdentifier
的数据结构定义。AspectIdentifier
是用于表示一个 aspect的相关信息,其包含了目标selector、执行闭包、闭包签名、目标对象、执行时机等。
1 | // Tracks a single aspect. |
如下所示,为 AspectInfo
的数据结构定义。AspectInfo
的作用是保存目标 selector的原始实现的执行环境。由于目标 selector 会被交换方法实现,因此originalInvocation
的 selector
其实是 Aspects交换的 selector,即 aspects__SEL
。
1 | @interface AspectInfo : NSObject <AspectInfo> |
如下所示,Aspects 对外提供两个接口,分别用于 hook类方法和实例方法,即添加 aspect。
1 | /// Adds a block of code before/instead/after the current `selector` for a specific class. |
两者的内部实现都只调用了同一个方法aspect_add
,其内部实现逻辑如下所示。
1 | static id aspect_add(id self, SEL selector, AspectOptions options, id block, NSError **error) { |
在 aspect_add
方法内部实现中,首先通过aspect_isSelectorAllowedAndTrack
方法判断是否允许添加aspect。如果允许,则初始化AspectsContainer
,并将其设置成实例对象或类对象的关联对象。一个selector 对应一个 container,一个实例对象或类对象可包含多个container。最后通过 aspect_prepareClassAndHookSelector
执行核心方法交换,对于实例对象,还会创建动态类。
aspect_isSelectorAllowedAndTrack
Aspects 通过 aspect_isSelectorAllowedAndTrack
方法来判断是否允许添加aspect,如果允许则进行追踪。具体实现逻辑如下所示。
1 | static BOOL aspect_isSelectorAllowedAndTrack(NSObject *self, SEL selector, AspectOptions options, NSError **error) { |
aspect_isSelectorAllowedAndTrack
的内部逻辑可以分为两部分:方法黑名单检查、对象类型检查。
对于方法黑名单检查,可细分为三个步骤:
retain
、release
、autorelease
等,如果是,则不允许 hook。dealloc
,则只允许 hookbefore
时机,其他时机,则不允许 hook。对于对象类型检查,如果对象类型是实例对象,则允许hook。如果对象类型是类对象,则进一步判断。根据目标类对象,遍历继承链,对于继承链中的每一个类对象,从全局字典swizzledClassesDict
中读取对应的追踪器AspectTracker
。根据追踪器的记录,我们可以处理两种情况:
如下图所示,为追踪器工作原理示意图。
当对 SubClass
类对象 hook 实例方法 SEL01
时,Aspects 会从 SubClass
类对象开始,遍历其继承链,读取继承链上的每一个类对象所对应的追踪器(如果没有则创建),将目标方法SEL01
保存至其内部的 selectorNames
数组中作为记录。
后续,如果对 Class
类对象 hook 实例方法SEL01
时,由于其子类 SubClass
已经 hook过同名方法,则不允许 Class
对其再次hook。根据消息传递的原理,对 Class
进行 hook是不会生效的,因为子类 SubClass
会在消息传递链中提前返回SEL01
。所以,Aspects 的设计不允许在这种情况下再次 hook同名方法。
当然,如果对 Class
类对象 hook 实例方法SEL02
时,由于所有其子类均没有 hook 过同名方法,因此允许Class
对其再次 hook。
本质上,Aspects 利用了 正向的类对象继承链 和反向的追踪器链,通过全局字典swizzledClassDict
进行绑定,形成了一个双向链表,便于判断是否允许对类对象的实例方法进行hook。
aspect_prepareClassAndHookSelector
如下所示,为 aspect_prepareClassAndHookSelector
的实现逻辑。
1 | static void aspect_prepareClassAndHookSelector(NSObject *self, SEL selector, NSError **error) { |
其中 aspect_hookClass
将判断对象类型,如果是实例对象,则创建一个动态类对象返回;如果是类对象,则返回对应的类对象。
基于 aspect_hookClass
返回的对象,Aspects将修改该对象的两个方法,使其指向 Aspects的两个方法实现,即上述我们介绍的 核心方法交换。
在 aspect_prepareClassAndHookSelector
的实现中,Aspects会在进行方法交换之前进行检查,避免重复交换,从而实现幂等。
aspect_hookClass
如下所示,为 aspect_hookClass
的实现逻辑。
1 | static Class aspect_hookClass(NSObject *self, NSError **error) { |
aspect_hookClass
方法主要用于选择对哪个对象的目标方法执行 hook,这里面包含了 4种具体的情况,依次为:
forwardInvocation:
方法的实现设置为 Aspects 提供的_aspects_forwardInvocation:
,并返回该类对象。forwardInvocation:
方法的实现设置为 Aspects 提供的_aspects_forwardInvocation:
,并返回 KVO 的动态类对象。Aspects_Class_
,同时包括元类对象,并对动态类对象的forwardInvocation:
方法执行方法交换,并且设置动态类与原始类之间的关系,最终返回动态类对象。本节,我们将来介绍上文所提出的几个问题。
在 Aspects 的实现中,在判断能够添加 aspect 的逻辑中,会通过aspect_isCompatibleBlockSignature
方法来判断 block 与selector 的方法签名是否匹配,如下所示。其中,它会通过类的instanceMethodSignatureForSelector
方法获取 selector的方法签名。对于类方法,通过这种方式必然返回nil
,从而导致判断条件无法满足,因此无法 hook 类方法。
1 | static BOOL aspect_isCompatibleBlockSignature(NSMethodSignature *blockSignature, id object, SEL selector, NSError **error) { |
如果我们真正理解了 Aspects的设计原理,很容易明白为什么作者不推荐在生产环境中使用Aspects。事实上,在实际的项目开发中,我们经常会用到对已有方法进行hook。当然,我们可以保证自己写的代码只使用 Aspects 进行hook,但是我们无法确定引入的第三方库是否使用其他方式对方法进行hook。那么,这时候埋下了未知的风险。
如上图所示,假如我们对 SEL
与 bcq_SEL
进行了 swizzle。那么,bcq_SEL
的实现将指向 SEL
的实现 aspect_getMsgForwardIMP
;SEL
的实现将指向 bcq_SEL
的实现 bcq_IMP
。
在有些情况下,比如:hook viewWillAppear:
方法。bcq_IMP
里会再次调用bcq_SEL
,从而再次调用原始实现。这时候,我们调用SEL
,它最终仍然会调用aspect_getMsgForwardIMP
,Aspects 的设置不受影响。
但是有些情况下,bcq_IMP
的内部逻辑可能只在特定条件下调用原始实现,其他条件下调用自定义实现。这时候,我们调用SEL
,在某些条件下将不会触发aspect_getMsgForwardIMP
,最终导致 Aspects的设置不生效。
显而易见,在生产环境在使用 Aspects的确可能会出现不确定的异常问题。因此,作者不建议我们在生产环境中使用Aspects。
对于实例对象的实例方法,我们显然不能直接 hook继承链中的类对象,否则将影响类的所有实例的实例方法。因此,Aspects选择了一种类似于 KVO 的设计,动态创建一个子类,并将实例对象的isa
指针指向动态子类。动态子类的 class
方法则指向实例对象的声明类,从而是外部看来没有任何变化。
这种做法,为实例对象单独开辟了一条继承链分支,如下图所示。只有被 hook的实例对象才会走这条分支继承链,因此不影响其他实例。
如果对同一个类的多个实例进行Aspects,那么会怎么样?从上图中,我们也能猜到,Aspects会复用动态子类。只不过 hook 的闭包由各个实例对象自己管理而已。
通过分析 Aspects 的源码及其设计原理,我们同时加深了对于 Objective-CRuntime 的理解。从中,我们也了解到 Aspects 的局限性,引入需谨慎。
在 Aspects 中,我们看到了很多 Objective-C 的黑魔法 API,比如:
_objc_msgForward
/_objc_msgForward_stret
:直接触发forwardInvocation:
objc_allocateClassPair
:动态创建类对象和元类对象objc_registerClassPair
:注册类对象和元类对象object_setClass
:设置 isa
指针指向。除此之外,Aspects使用了非常底层的方式实现了闭包的参数检查与匹配,这一块非常值得我们深入学习,后续有机会我们再来研究一下。
最后,向作者表达一下敬意!如果对 Objective-C底层原理没有如此深刻的理解,一般人是写不出来这样的框架的!
PromiseKit 是一款基于 Swift 的 Promise 异步编程框架,作者是大名鼎鼎的Max Howell,Max Howell 同时也是 Homebrew 的作者,因在面试 Google时写不出算法题反转二叉树而走红。
最近,我在研究 Promise 异步编程,一开始尝试从阅读 PromiseKit源码上手,但是发现里面的一些设计理念难以理解。因此,转而去研究 Promise核心原理,并产出了一篇文章——
注:本文分析的 PromiseKit 版本是 6.18.1
。
Thenable
是 PromiseKit的核心协议之一,其声明了一个关键方法pipe(to:)
,并实现了一系列链式操作符(方法),具体如下所示。
1 | public protocol Thenable: AnyObject { |
遵循 Thenable
协议的有两个泛型类型,分别是:
Promise<T>
:支持异步任务的成功和失败状态。Guarantee<T>
:仅支持异步任务的成功状态,不接受失败状态。两者的主要区别在于 Promise
支持失败状态,而Guarantee
不支持失败状态。因此,PromiseKit 定义了另一个协议CatchMixin
,该协议声明并实现了错误处理相关的方法,如:
1 | public protocol CatchMixin: Thenable {} |
CatchMixin
协议继承自 Thenable
协议,从而限制 PromiseKit 只为遵循 Thenable
协议的类型支持catch
和 recover
等能力。
从
pending
、fulfilled
、rejected
。在 PromiseKit 的实现中,使用两个类型来表示 4 个部分:
Box
:表示一个容器,包含了执行状态、执行结果、回调任务列表。Resolver
:执行器。在 Promise
强关联了回调任务列表和执行器,执行器同时反向强引用了 Promise。然而,在PromiseKit 中,Promise
仅强关联了Box
,而弱依赖了Resolver
,Resolver
则强关联了Box
。当然,两种设计在内存管理中的作用是一样的。如下所示为Promise
与 Box
、Resolver
的引用关系。
下面,我们来分别介绍一下 Box
和 Resolver
的设计。
PromiseKit 通过两种具体的 Box
子类来表示不同状态下的容器,分别是:
EmptyBox
:表示 pending
状态下的容器。SealedBox
:表示 resolved
状态下的容器,具体可以是 fulfilled
或 rejected
状态。Promise 通过 Sealant
枚举类型将三种执行状态(pending
、fulfilled
、rejected
)分成两种执行状态:
pending
状态。resolved
状态,具体可以是fulfilled
或 rejected
。Sealant
的定义如下所示:
1 | enum Sealant<R> { |
其中,pending
状态的关联值存储了回调任务列表Handlers
;resolved
状态的关联值存储了两种细分的状态 Result
。
Result
枚举类型则用于进一步表示 fulfilled
和 rejected
状态,具体定义如下所示:
1 | public enum Result<T> { |
通过关联对象,分别存储两种 执行结果:返回值T
和错误码 Error
。
Box
抽象类定义了三个方法,分别是:
1 | class Box<T> { |
inspect()
方法用于检查内部状态,返回值为Sealant
值。对于 SealedBox
,其返回始终为resolved<T>
。
inspect(_ body: (Sealant<T>) -> Void)
方法将内部结果作为参数传递给闭包并执行。
seal(_: T)
方法非常关键,当 Box
为pending
状态时,seal
方法可以将内部状态更新为resolved
,同时执行 回调任务列表中的所有任务。Resolver
就是通过 seal
方法来更新状态的。具体如下所示:
1 | class EmptyBox<T>: Box<T> { |
Resolver
的核心作用是更新执行状态和执行结果,并执行回调任务列表中的任务。由于Box
封装了执行状态、执行结果、回调任务列表,并且提供了更新状态的方法seal
。因此,Resolver
只需提供针对不同状态的便利方法,内部调用 Box
的seal
方法进行更新即可。具体如下所示。
1 | public final class Resolver<T> { |
Promise 的核心能力之一是串联任务(异步或同步)。从Thenable
的方法中,我们可以看到几乎所有的串联方法内部都是通过pipe(to:)
方法实现任务串联的。下面,我们来看一下Promise
中该方法的定义。
1 | public final class Promise<T>: Thenable, CatchMixin { |
从实现中可以看到,pipe(to:)
方法会先判断Box
的状态,如果是 pending
状态,则将闭包加入回调任务列表;如果是 resolved
状态,则立即执行闭包。
PromiseKit 所实现的 Promise
的内存管理是非常清晰。我们可以通过阅读各种链式操作符的内部实现来梳理其内存引用关系。如下所示,为链式操作符下产生的线性内存引用关系。
当对 promise 0
执行链式操作符时,链式操作符会创建一个promise 1
,并在内部创建一个匿名闭包对用户闭包进行封装。同时,匿名闭包将引用promise 1
,以处理 rejected
状态。
当 promise 0
为 fulfilled
时,执行匿名闭包,进而执行用户闭包,从而创建一个临时的promise m
。此时,promise m
通过pipe(to:)
方法将 promise 1
的状态更新方法(任务)box.seal
加入回调任务列表,最终形成上图所示的内存引用关系。
Guarantee
其实是裁剪版的Promise
,不支持错误处理,即 保证有返回值的含义。
Guarantee
遵循 Thenable
协议,并重新实现了一套链式操作符(方法),不同的是,返回值为Guarantee
类型,从而避免交错使用 Promise
和Guarantee
。
为了便于使用,PromiseKit 还提供了几个常用的全局方法,包括:
after
:延时任务,Guarantee
类型。firstly
:语法糖,让代码更具可读性,立即执行闭包。race
:当有一个 Promise 为 fulfilled
状态时,执行回调任务列表。when
:当所有 Promise 均为 fulfilled
状态时,执行回调任务列表。整体而言,PromiseKit 以 Thenable
和CatchMixin
协议为基础,实现了两种类型的 Promise,分别是Promise
和 Guarantee
。
Promise
的工作流程主要涉及了 Box
和Resolver
,两者有各自的职责。Box
包含了执行结果、执行状态、回调任务列表,并提供了状态更新方法seal
。Resolver
作为执行器,提供给用户来更新状态。
Thenable
提供的 pipe(to:)
方法是串联任务的关键,几乎所有的链式操作符均使用了pipe(to:)
方法进行任务串联。
从编码角度而言,PromiseKit在职责单一、方法命名方面的设计与实践,还是非常值得我们来学习的。
后续,我们将继续阅读一些不错的开源源码,学习其中的设计思想。
Promise的核心思想是:实现一个容器,对内管理异步任务的执行状态,对外提供同步编程的代码结构,从而具备更好的可读性。本文,我们将通过分析Promise 的设计思想,并实现 Promise 核心逻辑,从而深入理解 Promise实现原理。
本文所实现的 Promise 代码已在 Github 开源——
Future 和 Promise是异步编程中经常提到的两个概念,两者的关系经常用一句话来概括——A Promiseto Future。
我们可以认为 Future 和 Promise 是一种异步编程技术的两个部分:
以如下一段 Dart 代码为例,getUserInfo
方法体是一个Promise,其定义了值的生产过程,getUserInfo
方法返回值是一个Future,其定义了一个未来值。
1 | Future<UserInfo> getUserInfo(BuildContext context) async { |
Future 和 Promise来源于函数式编程语言,其目的是分离一个值和生产值的方法,从而简化异步编程。本质上,两者是一一对应的。
很多语言都有 Future 和 Promise 的实现,比如:Swift Task、C# Task、C++std::future、Scala Future 对应的是 Future 的实现;C++std::promise、JavaScript Promise、Scala Promise 对应的是 Promise的实现。
Promise支持以同步代码结构编写异步代码逻辑,其提供一系列便利方法以支持链式调用,如:then
、done
、catch
、finally
等。注意,不同的编程语言或库实现中,方法命名有所不同。
如下所示,是一个以 JavaScript 编写的 Promise 的基本用法。
1 | getJSON("/post/1.json") |
本质上,Promise 是一个对象,其包含三种状态,分别是:
pending
:表示进行中状态。fulfilled
:表示已成功状态状态。此时,Promise得到一个结果值 value
。rejected
:表示已失败状态。此时,Promise得到一个错误值 error
,用于表示错误原因。pending
是起始状态,fulfilled
和rejected
是结束状态。一旦 Promise的状态发生了变化,它将不会再改变。因此,Promise 是一种单赋值 的结构。
Promise 内部的状态由 执行器(executor) 或解析器(resolver) 来进行更新。Promise创建时的状态默认为 pending
,用户为 Promise提供状态转移逻辑,比如:网络请求成功时将状态设置为fulfilled
,网络请求失败时将状态设置为rejected
。通常,执行器会提供两个方法 resolve
和 reject
分别用于设置 fulfilled
和rejected
状态。
此外,Promise还支持通过链式操作符实现回调任务的链式执行,其原理是在内部维护一个回调任务列表,当Promise到达结束状态时,自动执行内部的回调任务,从而整体实现异步任务的链式执行。
下面,我们来手动实现 Promise 的核心逻辑,编程语言为 Swift。
首先,定义 Promise 的三个状态,如下所示。 1
2
3
4
5enum State {
case pending
case fulfilled
case rejected
}
Promise的核心目标是为了解决异步(或同步)任务的相关问题。首先,要解决两个问题:
对于第一个问题,很简单,我们可以提供一个闭包,让用户在闭包中自定义任务即可。
对于第二个问题,同样,我们可以提供两个状态更新方法,让用户在任务的特定阶段调用即可。
这里,我们定义的执行器如下所示。 1
2
3
4
5class Promise<T> {
typealias Resolve<T> = (T) -> Void
typealias Reject = (Error) -> Void
typealias Executor = (_ resolve: @escaping Resolve<T>, _ reject: @escaping Reject) -> Void
}
可以看到,上述定义的执行器是一个闭包,闭包的参数是两个状态更新方法,分别是resolve
和reject
,可供用户在任务的特定阶段调用,以更新任务的状态。
由于 resolve
和 reject
方法分别用于设置fulfilled
和 rejected
状态,两个状态分别对应两个值:value
和error
,从方法的入参可以看出两者的区别。因此,除了状态之外,还需定义两个字段,分别用于保存value
和 error
,具体定义如下所示。
1 | class Promise<T> { |
Promise 的核心功能之一是 链式执行异步任务。那么,如何实现链式执行异步任务呢?很简单,我们将后一个 Promise的异步任务存储在前一个 Promise 的回调任务列表中,当前一个 Promise达到结束状态(fulfilled
或rejected
)时,执行其内部保存的下一个(组)回调任务即可。
对此,我们可以在 Promise 内部保存两个数组,分别用户存储fulfilled
状态和 rejected
状态时要执行的回调任务。除此之外,我们还需要对 resolve
和reject
方法进行进一步加工,方法调用时,分别设置当前异步任务的返回值、状态,并执行回调任务。具体定义如下所示。
1 | class Promise<T> { |
可以看到,我们分别使用 onFulfilledCallbacks
和onRejectedCallbacks
保存回调任务。同时定义了resolve
和 reject
两个方法,内部分别设置异步任务的返回值、状态,并执行回调任务。
Promise初始化时,执行器会立即执行,从而触发异步任务的执行,同时将两个状态更新方法作为参数传入闭包,以供用户在任务的特定阶段调用。
Promise 通过 then
方法来串联任务,即让前一个 Promise保存下一个 Promise 的任务。then
方法包含两个闭包onFulfilled
和onRejected
,分别表示不同状态的回调任务,其在前一个 Promise的状态为 fulfilled
和 rejected
时分别执行。
当 then
串联任务时,我们需要考虑前一个 Promise的状态。这里,我们分三种情况进行考虑:
pending
时,我们创建一个Promise,其任务的核心是将 onFulfilled
和onRejected
分别加入前一个 Promise 的回调任务队列中。fulfilled
时,我们创建一个Promise,其任务的核心是立即执行 onFulfilled
任务。rejected
时,我们创建一个Promise,其任务的核心是立即执行 onRejected
任务。then
方法的具体实现如下所示。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37extension Promise {
// Functor
@discardableResult
func then<R>(onFulfilled: @escaping (T) -> R, onRejected: @escaping (Error) -> Void) -> Promise<R> {
switch state {
case .pending:
// 将普通函数应用到包装类型,并返回包装类型
return Promise<R> { [weak self] resolve, reject in
// 初始化时即执行
// 在 curr promise 加入 onFulfilled/onRejected 任务,任务可修改 curr promise 的状态
self?.onFulfilledCallbacks.append { value in
let r = onFulfilled(value)
resolve(r)
}
self?.onRejectedCallbacks.append { error in
onRejected(error)
reject(error)
}
}
case .fulfilled:
let value = value!
// 将普通函数应用到包装类型,并返回包装类型
return Promise<R> { resolve, _ in
let r = onFulfilled(value)
resolve(r)
}
case .rejected:
let error = error!
// 将普通函数应用到包装类型,并返回包装类型
return Promise<R> { _, reject in
onRejected(error)
reject(error)
}
}
}
}
注意,onFulfilled
和 onRejected
闭包的入参和返回值,这是 then
能够实现异步任务的值传递的关键。
上一节的 then
方法主要是 Functor 实现,为了进一步扩展then
方法的,我们来实现 Monad then
方法,具体实现如下所示。
关于 Functor 和 Monad 的概念,可以阅读
1 | extension Promise { |
通常 Promise还具有一系列遍历方法,如:fistly
、catch
、done
、finally
等。下面,我们依次实现。
firstly
方法本质上是语法糖,表示异步任务组的第一步。我们实现一个全局方法,通过闭包实现任务的具体逻辑,如下所示。1
2
3func firstly<T>(closure: @escaping () -> Promise<T>) -> Promise<T> {
return closure()
}
catch
方法仅用于处理错误,其可通过 then
方法实现,关键是实现 onRejected
方法,如下所示。1
2
3
4
5extension Promise {
func `catch`(onError: @escaping (Error) -> Void) -> Promise<Void> {
return then(onFulfilled: { _ in }, onRejected: onError)
}
}
done
方法仅用于处理返回值,其可通过 then
方法实现,关键是实现 onFulfilled
方法,如下所示。1
2
3
4
5extension Promise {
func done(onNext: @escaping (T) -> Void) -> Promise<Void> {
return then(onFulfilled: onNext)
}
}
finally
方法用于 Promise链式调用的末尾,其并不接收之前任务的返回值和错误,支持用户在任务结束时执行状态无关的任务,具体实现如下所示。1
2
3
4
5extension Promise {
func finally(onCompleted: @escaping () -> Void) -> Void {
then(onFulfilled: { _ in onCompleted() }, onRejected: { _ in onCompleted() })
}
}
类似 Rx,Promise 的内存管理十分巧妙,其核心原理是通过闭包强引用对象。下面,我们来分别介绍一下 Functorthen
和 Monad then
的内存管理。
then
如下所示,为 Functor then
方法产生的内存管理示意图。
在初始化 Promise 时,resolve
和 reject
方法必须强引用 Promise,否则等到异步任务执行完成时,Promise早已释放,根本无法通过 Promise 执行回调任务。
当调用 Functor then
方法时,Promise的两个回调任务列表将引用 then
方法所传入的两个闭包onFulfilled
和 onRejected
,同时引用then
方法内部创建的 Promise 的 resolve
和reject
方法。新创建的 Promise 又被自身的resolve
和 reject
方法所引用,从而实现线性的内存引用关系。
then
如下所示,为 Monad then
方法产生的内存管理示意图。
同样,当调用 Monad then
方法是,Promise的两个回调任务数组将引用 then
方法所传入的两个闭包onFulfilled
和 onRejected
,同时引用then
方法内部创建的 Promise 的 reject
方法。从而实现线性的内存引用关系。
区别于 Functor then
,Monad then
方法的onFulfilled
闭包会返回一个包装类型Promise<R>
。因此,当 Promise 状态为fulfilled
或 rejected
时,then
会立即返回由该闭包生成的 Promise;当 Promise 状态为 pending
时,then
会将闭包生成的 Promise 作为中间层Promise,由中间层 Promise 调用 Functorthen
,从而产生一个间接的线性内存引用。
下面,我们来编写一个网络请求的例子来对我们实现的 Promise进行测试。
1 | enum NetworkError: Error { |
我们定义了一个 TestAPI
的类,其提供两个方法,分别请求用户信息和头像信息,返回值均为Promise。其内部我们使用 GDC延迟进行模拟,使用随机数设置网络请求的成功和失败情况。
接下来,我们来进行功能测试,依次请求用户信息和头像信息,如下所示。1
2
3
4
5
6
7
8
9
10
11let api = TestAPI()
firstly {
api.user()
}.then { user in
print("user name => \(user)")
api.avatar()
}.catch { _ in
print("request error")
}.finally {
print("request complete")
}
当网络请求成功时,我们会得到如下内容: 1
2
3
4request user info
user name => User(name: "chuquan", avatarURL: "avatarurl")
request avatar info
request complete1
2
3request user info
request error
request complete
从执行顺序和结果而言,是符合我们的预期的。当然,我们还可以编写更多测试用例来进行测试,本文将不再赘述。
本文,我们介绍了一种常见的异步编程技术Promise,深入分析其设计原理,并最终手动实现一套简易的 Promise框架。此外,我们还对 Promise的内存管理进行了简要的分析,以深入了解内部的运行机制。
后续,有机会的话,我们来分析一款流行的 Promise 开源框架,以进一步验证Promise 的设计。
本章是本系列教程的最后一章。通过本教程,我们实现并扩展 Kaleidoscope编程语言,使其语言的特性和功能不断增强。
在这个过程中,我们构建了词法分析器、解析器、AST、代码生成器、REPL、JIT,并为可执行文件支持了调试信息。所有的功能仅仅用了1000 行左右代码就实现了。
我们的语言支持几个有趣的特性,比如:支持自定义的二元运算符和一元运算符,支持JIT 编译并执行,支持构造控制流等。
本教程的初衷是为了向开发者展示定义、构建、使用语言是如此简单和有趣,编译器的实现也并不是难如登天!现在,我们已经了解了自制编译器的一些基础知识,这里强烈建议开发者能够使用代码对其进行魔改。比如,可以尝试支持以下这些特性:
GlobalVariable
类。double
。由于只支持一种类型,因此无需指定变量类型。如果要支持多种数据类型,最简单的方法是要求用户为每个变量定义指定类型,并在符号表中记录变量的类型及其值。getelementptr
进行实现。printd
、putchard
等。当我们扩展语言以支持更高级的特性时,可以考虑实现运行时。比如:对于实现哈希表,哈希表底层封装了一系列实现,如果将这些实现内联至代码中,那么每定义一个哈希表会生成底层的实现代码,如果我们将哈希表的底层实现作为一个子程序定义在运行时,那么将会非常具有优化意义。malloc
/free
接口或使用垃圾收集器来分配堆内存,那么也能够极大地增强语言的能力。对此,LLVM是完全支持精准垃圾收集(Accurate GarbageCollection)功能的,包括对象移动、栈扫描与更新等算法。setjmp
/longjmp
。对于 LLVMIR,我们经常会有一些疑问。本章,我们梳理了一些常见的问题,并进行解答。
Kaleidoscope 是可移植语言 的一个例子:任何用Kaleidoscope编写的程序都可以在它运行的任何目标上以相同的方式工作。绝大多数编程语言都具有此属性,如:lisp、java、haskell、javascript、python等。但需要注意的是,虽然这些语言是可移植的,但并非所有的库都是如此。
LLVM 有一个特性是它通常能够在 IR 中保持目标独立:我们可以将 LLVM IR用于 Kaleidoscope 编译的程序并在 LLVM支持的任何目标上运行它。简而言之,Kaleidoscope编译器生成与目标无关的代码,因为它在生成代码时不会查询任何特定于目标的信息。
上面提到的一些编程语言,很多都是 安全的语言。比如,用 Java 编写的程序不可能破坏其地址空间并使进程崩溃(假设JVM 没有错误)。安全性是一个有趣的属性,它需要结合语言设计、运行时支持以及操作系统支持。
在 LLVM 中实现安全语言是完全可以的,但 LLVM IR本身并不能保证安全。LLVM IR允许不安全的指针转换、释放错误后使用、缓冲区溢出和各种其他问题。要实现安全的特性,我们需要在LLVM 之上构建一个层来实现。
和其他工具一样,LLVM不能在一个系统中解决所有的问题。对此,很多开发者会抱怨 LLVM无法执行高级语言的特定优化,因为 LLVM丢失了太多信息。对此,本章给出了如下的一些看法。
首先,LLVM 确实会丢失信息。例如,在撰写本教程时,在 LLVM IR中无法区分 SSA 值是来自 ILP32 机器上的 C int
还是 Clong
(调试信息除外)。两者都被编译为 i32
值,并且关于它来自什么的信息丢失了。这里更普遍的问题是 LLVM 类型系统使用“结构等价” 而不是“命名等价”。另一个让人感到惊讶的地方是,如果我们在高级语言中有两种具有相同结构的类型(例如,两个具有单个int
字段的不同结构),那么这些类型将被编译成单个 LLVM类型。
其次,虽然 LLVM 确实会丢失信息,但 LLVM并不是一个固定的目标:我们会继续以许多不同的方式增强和改进它。除了添加新功能(LLVM并不总是支持异常或调试信息)外,我们还扩展了 IR以捕获重要信息以进行优化(例如,参数是符号扩展还是零扩展、指针别名信息等)。许多增强功能都是用户驱动的:开发者希望LLVM 包含一些特定功能,为此,开发者们一直在对它进行扩展。
第三,添加特定于语言的优化是可能且容易的。举一个简单的例子,我们可以很容易地添加特定于语言的优化通道,从而为一种语言编译的代码。对于C 系列,有一个标准 C 库函数的优化通道。如果我们在 main()
中调用 exit(0)
,它会知道将其优化为 return 0;
是安全的。
此外,还可以将各种其他语言特定的信息嵌入到 LLVM IR中。即使在最坏的情况下,我们也可以将 LLVM视为纯粹的代码生成器,并在特定于语言的 AST上在编译前端实现我们想要的高级优化。
在使用 LLVM之后,我们会了解到许多有用的提示与技巧,这些技巧和技巧乍一看并不明显。这里,我们只讨论其中的一些问题。
如果我们希望让编译器生成的代码保持目标独立,那么会出现一件有趣的事情,那就是我们经常需要知道某些LLVM 类型的大小或 llvm 结构中某些字段的偏移量。例如,我们可能需要将类型的大小传递给分配内存的函数。
不幸的是,这在不同目标之间可能会有很大差异:例如,指针的宽度是特定于目标的。不过,有一种巧妙的方法,即使用getelementptr
指令,它允许我们以可移植的方式计算它。
某些语言想要显式地管理栈帧,通常是为了支持垃圾收集栈帧或允许实现闭包。事实上,通常有比显式管理栈帧更好的方法来实现这些功能,但如果我们执意这么做,LLVM也是支持的。这需要我们的编译前端将代码转换为连续传递样式并使用尾调用(LLVM也支持)。
本系列教程通过基于 LLVM 自制一款针对 Kaleidoscope编程语言的编译器,在这个过程中,展示了自制编程语言或编译器所涉及的一些相关概念和知识,从而产生一个系统的认知。至此,本教程结束了!如果希望有更进一步探索,建议大家着手开始LLVM,毕竟代码才是真理!