vector
, list
这类容器来说,由于其底层实现很简单直接,我们可以较为容易地分析出什么时候多线程并发操作时可以不用加锁,什么时候需要加锁 —— 一般来说纯粹的并发读操作是可以不用加锁的。然而 string
是个很奇特的异类,在 5.x 之前的老版本 GCC 上,由于 string
的实现使用了 COW 优化,这使得 string
的线程安全问题变得极为玄学。这也是 GCC 5.x 后引入了新的 string
实现放弃了 COW 的重要原因之一。
本文就来讨论下 string
在 COW 机制下线程安全方面的一些坑。
string
的 COW 实现下,其自身的成员变量很简单,只有一个指针,指向堆上的一片内存区域。这片内存的结构是这样的:
除了实际的字符串内容及相关的长度记录外,还有一个引用计数字段,这就是 COW 机制的核心字段。
当且仅当使用拷贝构造函数或赋值运算符生成一个新的 string
时,新旧两个 string
会指向同一片内存,且其上的引用计数会加一;当某个 string
调用有修改字符串内容可能性的成员函数时,会检查引用计数,若引用计数大于 1,则将此片内存 copy 一份并将原来的引用计数减一,若引用计数降低至 0 则释放这片内存,这一行为的伪代码如下:
1 | void COW() { |
rc
引用计数本身是个原子变量,然而整个 COW()
函数执行过程中是不加锁的。实际的 libstdc++
的代码中,COW()
函数的名称是 _M_leak()
。
除了这种 COW 优化外,新版本的 string
使用的都是 SSO 优化,可以参考我之前写过的另一篇文章:C++ 中 std::string 的 COW 及 SSO 实现
若某个 string
的多个引用或指针在不同线程中同时访问了此 string
就会产生非预期的内存非法访问行为。考虑以下测试代码:
1 |
|
这段代码使用老版本的 GCC 编译运行会直接 core 掉,一般错误会是 double free or corruption (fasttop)
(若只有新版本的 GCC,可以通过增加编译选项 -D_GLIBCXX_USE_CXX11_ABI=0
强制指定不使用 C++11 ABI 来复现);使用新版本的 GCC 正常编译则不会有任何问题。
这段代码中,L17 ~ L20
启动了多个线程,每个线程中持有的都是 s1
的一个引用,多个线程同步地去读取 s1
中不同位置的字符,这个行为从常理上分析应该是没有数据竞争的,然而实际情况是它 core 了!
更为玄学的是,若将 L12
的 s2
去掉,或者是加上 L13
注释里那行看上去没有任何用处的代码,这个程序就可以正常运行了!
这一切玄学行为的根源都是 COW 机制搞的鬼。L12
使用拷贝构造生成 s2
时不会为 s2
真的新分配一片内存空间,而是简单的将原有 s1
堆上的内存引用计数加一,这样这个 string
的引用计数就是 2 了。多个线程使用的是引用的方式捕获 s1
,因此不会修改引用计数;而 L19
调用了 operator []()
,由于此处 s1
不是常引用,编译器不会选择 opeartor []() const
,而非 const
版本的 operator []()
返回的是一个可修改的引用,故此行为在库函数看来其实是一个写操作,会触发 COW;从上一节 COW()
的伪代码中可以看到,多个引用同时执行 COW()
很大概率会 Copy 多份并将引用计数减为负数进而产生 double free
错误。
若没有 L12
,s1
的引用计数为 1,不会触发 COW,自然也不会有问题;若加上了 L13
,同理 s2
已经触发了 COW,后续 s1
的引用计数也为 1 了;此外还可以通过将 lambda 内捕捉的 s1
修改为常引用或直接按值传递来解决此问题,将 [i, &s1]
改为 [i, &s1 = static_cast<const std::string&>(s1)]
或 [i, s1]
即可。
在实际复杂一些的程序中,很难确定一个字符串的引用计数到底是多少,因此在多个线程中使用字符串引用的做法始终会存在风险,解决方案一般有两个:
string
底层都使用 COW 了,那就不要用 string
的引用了,所有地方都按值传递即可;operator =()
来构造新 string
,始终使用 string new_str = string(old_str.data(), old_str.size())
的方式来构造。根据指针和长度来构造时永远不会也不可能使用 COW 机制,肯定会新分配一片内存做 memcpy。上述两种方案其实都很不优雅,优雅的方案是直接用新版 GCC 的 SSO 实现,默认情况下 5.x 以后版本的 GCC 都会使用此行为的。然而某些时候为了兼容历史遗留第三方库,需要保证 ABI 兼容,此时就只能通过这些方案来绕开 COW 机制的坑了……
最后来提一下 _GLIBCXX_USE_CXX11_ABI
这个编译器预定义宏,此宏代表是否使用 C++ 11 新版本的 ABI,主要区别就两个:
string
实现由 COW 改为 SSO;std::list::size()
的实现由 O(N)
复杂度改为 O(1)
复杂度,本质就是在 list
结构中增加了一个表示链表长度的字段。默认情况下 5.x 之后的 GCC 版本都会预定义此宏,即 -D_GLIBCXX_USE_CXX11_ABI=1
,可以手动的加上 -D_GLIBCXX_USE_CXX11_ABI=0
来禁用此行为使用原来老的实现,这主要是在处理 ABI 兼容问题时会使用。
]]>参考资料:
操作很简单,直接截图备忘吧。
这一过程是自动的,等脚本跑完就 OK 了。
]]>Update 2022-12-03:
PVE 会在快要到期时自动更新证书的,都不需要手动去点了,配置好就可以一劳永逸了~
最简单的家庭网络结构就是直接使用电信联通等运营商提供的光猫即可,现在的光猫都自带了无线 WiFi 功能,这就可以满足最基础的需求了。然而这显然无法满足爱折腾的我们诸多高级需求,因此我们需要充分发挥折腾精神,打造一套强大的网络出来。
直接上整体拓扑结构图吧:
电视这类终端设备图中就省略了,反正遵循能接有线尽量用有线的原则就好。图中的设备下文会一一进行介绍,绝大部分设备都是放在所谓的机房中,当然家里面不会有真正的机房啦,实际上这只是一个隐秘的小角落:)为了方便放这些现有的设备,再考虑到将来东西只会越来越多,于是买了个机柜:
这是个标准 32U 机柜,尺寸 1600 x 600 x 600。机柜的使用体验其实是比一般的柜子好很多的,散热,理线,安装都很方便,毕竟是专门为此类应用设计的。机柜的样子和功能都大同小异,在淘宝上随便找一家销量高点靠谱的就好,32U 的价格在 ¥600~1000 不等。不过一般标配的隔板数量都比较少,而机柜的隔板是可以任意添加的,因此可以联系卖家多加几块隔板。
机柜里面诸多设备供电当然也可以用普通插线板,不过更好的选择是使用 PDU 专用电源插座,这种插座可以直接固定在机柜背后:
插座选正规大品牌一般都不会有什么问题,不过需要注意的是,16A 插座的插头是要更粗一些的,就是和某些空调专用插头一样,没法插到普通三孔插座里去,选择的时候要考虑到此问题。
机柜什么都好,唯一的问题是放在家里看着不那么温馨和谐,然而这不是问题,可以在外面做个可移动的柜子把这个隐秘的小角落围起来!因为有了一个隔离的小角落,设备噪音等问题就不是很关键了;家庭使用设备再多也是比不过正经机房的,而且家里夏天也都会开空调,因此整体散热一般来说也不是大问题。
下面就来具体介绍下这些小宝贝们吧。
外网接入没有太多可折腾的,光纤入户的话基本只能用运营商提供的光猫,而且现在越来越多的运营商提供的设备是所谓的 SDN 光猫了。这个光猫那是真的垃圾难用啊,我手头上的是电信提供的烽火通信 HG7143D,基本什么可配置的东西都没有全被阉割了,唯一能配置的就只有无线 WiFi 的开关,连个 DHCP 都不能配置,强制开启且固定为 192.168.1.0/24 网段。
最早尝试过把光猫的 LAN 口接到交换机上,再通过交换机连接自己的其他设备,这方案勉强也能用,然而经常出现奇怪各种问题。于是目前的方案是把这个光猫和家里的其他设备彻底隔离开来,自带的 WiFi 啥的当然是第一时间全关了,只通过一条网线和软路由服务器点对点连接。所谓的软路由服务器其实就是一台普通台式机,安装了 PVE 作为虚拟化平台,在虚拟机中运行 OpenWRT 等软路由系统;硬件上是有多个网口的,因此可以实现单独一个网口用于连接光猫。这台服务器的情况下文再来详细介绍。
软路由上将家庭内网的网段换一个不要使用 192.168.1.0/24,这样隔离一下光猫就彻底不会来干扰其他设备了,老老实实的只需要提供外网接入就好了。这个方案也减少了对光猫功能的依赖性,将来无论再换个什么设备都可以直接替换,可谓是我目前想到的最完美的方案了。
目前还剩下未能解决的痛点问题是,由于家里入户光纤只有一条,没法简单的同时接入电信和联通宽带。二者的波长都是一样的,显然不能直接简单的共用一条光纤。单独再拉一条新的光纤看起来也不太现实。所以唯一的解决方案就只能是使用波分复用器等设备进行分离了,先不说设备很贵的问题,这方案在自己家这端还好说,想怎么搞就怎么搞,然而单元接入点那端基本是没法搞定的,此类设备都是有源设备,也就是必须要单独供电的,放在单元接入点那怎么供电呢……
这是网络中最复杂的部分了,从上面的拓扑图中也可以看到,我选择了网线 + 光纤混合在一起的解决方案。主要原因是想要享受超过 1Gbps 的网络带宽,而 10Gbps 的万兆网络方案基本都以光网络为主,电口只有少数 2.5GbE 的,而且价格上比光口还要贵。当然,实际上绝大部份场景下 1Gbps 是足够用的,能用到超过 1Gbps 的情况是很少的,不过折腾嘛,总要搞点看上去很厉害的东西喽,而且难说未来就能用上了呢……
光网络的核心设备当然是光交换机了,我选择的是 TP-Link 的 TL-ST1008F,这是一款很小巧优雅的 8 口全光交换机,所有口均为 SFP+ 万兆接口,采用无风扇设计。选择这个型号的原因只有一个,它足够便宜,基本是目前最便宜的万兆光交换机了。这款交换机是 2 年前买的,然而直到现在也是最佳的选择,没有比它更便宜的了。
这个交换机最大的缺点自然是它只有光口没有电口了,然而这正符合我的需求~
显然目前还做不到家里面的设备全用光纤,因此自然要有支持光电转换的交换机了,这就是拓扑图中的 8 口光电交换机和 24 口交换机了。为什么有两个呢,因为家里有两层,楼上一个楼下一个。
24 口交换机选用的是 TP-Link 的 TL-SH1226,这款交换机有两个 SFP+ 万兆光口和 24 个标准千兆 RJ45 端口,也是属于比较便宜的产品。
另一个 8 口光电交换机则是 QNAP 威联通的 QSW-308-1C,这是一款完全针对家用设计的小巧美观的交换机。为什么选择这一款交换机呢,主要是因为它足够小,而且采用了无风扇设计十分静音,很适合放在客厅的弱电箱旁边。这台交换机虽然小,然而接口却十分丰富 —— 8 个 1GbE RJ45 接口,2 个 10GbE SFP+ 光口,还有一个极为特别的 10GbE/5GbE/2.5GbE/1GbE SFP+/RJ45 组合自适应端口。市面上 10GbE 的电口产品已经很少了,这种全速率兼容的光电两用口更是独一无二的设计。
目前 1GbE 电口用了 6 个 —— 3 个 RJ45 面板及 3 个 AP 面板;2 个 10GbE SFP+ 光口都用到了 —— 1 个用于连接 TL-ST1008F 全光交换机,另一个用于连接 PC 主机;至于那个组合端口,目前是当作 2.5 GbE 电口来用的,使用了 Cat 7 网线来连接笔记本电脑。整体资源使用率还是很高的,并没有浪费那么多的接口~
不同于 RJ45 接口,光纤并不是直接插到交换机等设备上的,SFP+ 接口需要插入的是 SFP+ 光模块,光模块再和光纤连接。光模块的型号相对比较复杂,有不同的接口(LC,SC,电口 等),不同的传播模式(多模 SR,单模 LR 等),此外还存在一定的兼容性问题(和网卡或交换机的兼容性),因此选购的时候需要去先补充些基础知识,再多和商家交流下才行。不过一般不兼容的话都是可以退货的,所以可以先买来看看再说。同样是考虑到兼容性,一条光纤两端用的模块通常都是选同一型号的,不同型号的能不能混着用就不确定了。
这里还有一个坑,一般光纤都会考虑长距离通信问题,因此很多光模块的宣传重点是我们的模块 10km 还能用云云,然而家里面就几十米,10km 能用反而几十米难说就不能用了……原因是光强度太强的话接收端有可能会出现饱和问题,此时通信会很不稳定。之前买过一对光模块就发现这个问题了,ping 丢包率实在太高,和厂家的工程师沟通了好久才找到原因。解决方案也很简单粗暴,加上一个几块钱的衰减器即可。当然不是所有光模块都有这问题的,也有些模块短距离通信不加衰减器也不会出现饱和问题。
除入户光纤使用的是 SC 接口外,其他内网线路一般都使用双芯 LC 接口的多模光纤。囤积的各种光模块和衰减器:
目前电脑主板基本是没有自带光口的,因此需要买单独的网卡。万兆网卡最经典的选择就是 Intel 的 82599ES 了,大量互联网公司机房里使用的万兆网卡都是这型号的,稳定性啥的无需担心,模块兼容性也相对较好。82599ES 是芯片型号,因此有不同厂家的产品,同时有单口和双口两种型号可供选择。
然而 82599 也有翻车的时候,实际测试下来 82599 不能和威联通的 QSW-308-1C 交换机配合使用。威联通官网上有个兼容性列表,然而里面列出来的是模块不是网卡,并没有提到网卡也有兼容性问题。然而功夫不负有心人,我还是找到了一款能正常使用且相对便宜的网卡,这就是博通 Broadcom 57810S。
台式机可以装 PCIE 网卡来支持万兆网络,笔记本怎么办呢?除了极少数原生支持 2.5GbE 或者更高速率 RJ45 电口的笔记本外,只能通过 Type-C 口来外接适配器实现了。
绿联有一款 2.5GbE 的外置网卡,同时有 Type A 和 Type C 两种接口的版本,用下来体验还行,就是发热比较严重,不过也没感觉到对稳定性有影响。写这篇文章的时候发现绿联又出了一款 5GbE 的产品,同样是使用 RJ45 电口的,最重要的是,这两款产品的价格都比较亲民,感觉可以搞来试试。
至于 10GbE 的外置网卡,目前只发现了威联通的 QNA-T310G1S,这是一款 SFP+ 光口的外置网卡,至于价格嘛,当然是比较贵了,笔记本其实对 10GbE 没有太高需求,因此暂时没考虑入手。
网线目前基本用 Cat 6 的网线就差不多了,也可以选择 Cat 7 的,价格差不了多少,至于 Cat 8 的嘛,哪时候可以搞一根来看看。其实严格来说并不存在 Cat X 的网线对应什么速率的关系,信号衰减程度和距离关系很大,如果就几米距离 Cat 5e 的网线一样可以达到万兆的水平。距离长了 Cat 7 的网线也是会有问题的,当然在等距离下,Cat 级别越高的网线肯定更好些喽。不过越好的网线也会越硬,如果要穿线的话就更困难了,这时候扁线的优势就体现出来了,而且扁线看起来也要高级那么一些呢。
至于光纤的选择,入户光纤没得选,就一条单模光纤;局域网内部使用的光纤一般用双芯多模光纤,与网线类似,光纤也是分等级的,从 OM1~OM5,不同等级的光纤外壳颜色不一样,比网线更好区分。一般来说,使用水蓝色的 OM3 光纤就可以了,详细信息可以看看这篇文章:
与成品网线相对应的光纤叫做光纤跳线,就是两端都有 LC 或 SC 接口的光纤线,长这样的:
这种光纤使用起来十分稳定且方便,成品买来直接插上就好了,然而和网线一样的,由于接口体积比较大,长距离穿线时都是用没有两端接口的线的。网线的话买些水晶头和接线钳来自己练习下就可以搞定 RJ45 接口的安装了,光纤可就没这么简单了。
光纤一般是买尾纤来连接起来的,所谓的尾纤就是只有一端做好接口的光纤,两条尾纤接起来就是完整的光纤了。然而要怎么接呢?有两种方法,冷接和熔接。最初我以为自己可以搞定冷接的,买了一堆工具来尝试冷接,坑爹的是接是接起来了,然而不是一拉就断了,就是插入损耗太大简直没法用……
最终还是认清现实了,乖乖的去淘宝上找了同城提供光纤熔接服务的商家上门来进行光纤熔接。光纤熔接后需要一个熔接盒/熔接盘一类的东西来进行一些保护,这些可以自己去单独买,也可以请熔接的商家带一些来,最终成品的效果:
除了这种普通光纤外,还有一种很特殊的光纤——隐形透明光纤,这种光纤一般用于在室内拉明线时使用,不注意看基本看不到,十分美观。这种光纤都是单模光纤,没有多模光纤,因此对应的光模块也要选择单模模块。客厅交换机到工作室 PC 的连接我就使用了这种光纤。
两个 SFP+ 接口在短距离连接时更好的选择是使用 DAC 线而非光模块 + 光纤的形式。DAC 线实际上就是铜线,两端都是 SFP+ 接口,像网线那样直接插上去就可以用了,十分方便。DAC 线只能用于短距离连接,然而它的稳定性和兼容性比模块 + 光纤好太多了。
来测试下万兆网络的性能吧,在软路由服务器上运行 iperf3 作为服务器端,PC 上运行 iperf3 客户端:
可以达到 8Gbps 的速度,已经比较满意了。
由于家里墙体较多,因此选择了无线 AP 的方式来提供 WiFi 服务。AP 分为两种,带管理功能的 FAT 模式胖 AP 及单纯的接入点瘦 AP,有不少 AP 是二合一的,可以自行选择使用哪种模式。考虑到多个 AP 要能较为方便的进行统一管理,使用瘦 AP 模式 + 单独的 AC 控制器是最优选择。
从家居美观的角度来看,86 面板型 AP 无疑是一个很好的选择,一般是一个房间或相邻几个房间放一个。当然更严谨的做法是简单算算无线覆盖情况,以此来决定把面板放哪,这在装修阶段比较实用。TP-Link 提供了一款小工具来计算 WiFi 无线场强分布情况:
Mac 上有另一款类似工具,张大妈上已经有人安利了:
86 面板型 AP 一般都是 PoE 供电的,常用做法是直接选择带 PoE 功能的交换机或 AC 控制器即可,然而为了选择带万兆 SFP+ 接口的交换机已经把范围缩小了很多,此类交换机基本都是没有 PoE 接口的。此时可以选择再买几个单独的 PoE 交换机来,先不说搞一堆交换机来不优雅的问题,这么做有个缺点,两个交换机之间只能通过单条网线进行串联,因此相当于多个 AP 接入点是共享了 1GbE 的带宽。虽然实际上这并不会对使用造成什么影响,然而总是觉得不爽啊。
于是去搞了一对一的 PoE 供电器来,即每路 AP 都是一条输入线一条输出线。此类模块单路的比较常见,然而家里面有 6 个 AP,买 6 个供电器来实在太丑陋了,所以当时找了好久,终于在闲鱼上发现了一款 4 路供电模块,就是上文机柜 PDU 电源图中电源上面那个东西。这东西现在在淘宝上都搜不到的,也算是捡到好货了。
至于 AP 面板的选择,这套无线是两年前搭建的,当时 WiFi 6 才刚出来没多久,因此支持 WiFi 6 的 86 型 AP 面板就没几款,基本没有选择,所以目前家里面用的是 TP-Link 的 XAP1800GI-PoE,这是款 AX1800 AP,两年多用下来稳定性还不错,也就整体重启过 1,2 次,属于可以接受的范畴。
现在有些速率更高的产品出来了,比如 XAP30000GI-PoE,XAP5400GI-PoE 等,其中 XAP30000GI-PoE 的性价比不错,如果是现在重新安装估计就选这一款了。当然了,已经装好的显然没有足够动力去把它换掉的,等将将来 WiFi 7 出来再看看有没有啥吸引我更换设备的点吧。
至于单独的 AC 管理器,选 TP 最便宜的 TL-AC100 就可以了。不过这里又遇到一个坑啊,当初从闲鱼上买了个二手的 TL-AC100 来,发现不能用,然后才发现同样的型号有不同的硬件版本号,老版本的不支持新的 AP……
这类 AP 都可以提供很多个 SSID 供接入的,目前使用了 3 个,一个主要自用的;一个供访客使用,打开了 AP 隔离开关;前两个都是 2.4G 5G 双频合一的,然而有些智能家居设备不支持双频合一,于是又单独搞了个纯 2.4G 的 SSID,专供各种智能设备使用。
AP 接入方案的优点自然就是信号覆盖好了,现在家里面卫生间角落也能保证有稳定的 2.4G 网络可用,实现了无死角全覆盖。然而这方案理完美还有不少距离的,最大的痛点就是移动过程中 AP 切换问题,自动切换当然是可以切换的,然而这一过程并不能做到完全无感。看视频这类应用因为有本地缓冲,基本没啥影响,但如果在使用着微信语音之类实时应用就会感到切换时会卡几秒……
据说 WiFi 7 已经在着手解决此问题了,方案是同时建立和多个 AP 的连接,而非现在这样断了一个连另一个,感觉可以期待下未来的实际表现。
至于 WiFi 速率问题,其实只有距离 AP 面板或路由器很近时才能发挥出 5G WiFi 的能力,高频信号衰减很快,基本穿一堵混凝土墙后无线速率就会大幅下降。来看看实际测试结果吧:
家里面有这么多有趣的东西,在外面的时候当然会想着翻回家来看看喽~出于方便性及安全考虑,只把一个单点对外暴露,通过这个单点统一认证接入家庭内网后再访问其他服务。
最早的方案是用软路由实现的 L2TP 或 OpenVPN,然而此方案稳定性不佳,经常会连不上,折腾来折腾去都会有各种奇怪问题。再加上换了电信新的光猫后,DMZ,端口映射这些功能还一直不太正常……于是放弃了这条路,选择了使用蒲公英旁路路由方案,就是这货:
蒲公英 P5 最大的优点是,它是以旁路路由的形式工作的,可以很完美的接入现有网络架构中,没有附加多余功能,专注于远程组网。使用起来也很简单,Oray 提供了各平台的客户端,直接下载使用即可。一般情况下都可以打通 P2P 隧道,这样就是点对点通信了,不需要中转,带宽仅取决于两端网络情况。
不过蒲公英 P5 有个很奇葩的设计,免费版本只能接入 2 台终端设备,不够的话要额外购买服务,我想着要买就买吧,价格正常也可以接受,然后就看到奇葩东西了,要买的话一个终端 ¥78 一年,先不说这价格贵不贵,关键是,我要加第 3 个设备,不是按正常人类的想法,再买一个就好了,而是要把之前两个免费的也算上,一共买 3 个终端才行
算算价格,我每年要花 200 多的服务费,然而这设备本身才 200 多块钱,我为什么不再买一个?于是乎,就有了两个蒲公英 P5……
蒲公英 P5 目前只能算满足基本需求能用了,目前正准备升级这部分,搞一套飞塔 Fortinet 的 SDWAN 防火墙来。Fortinet 算是国际大厂了,大量中小型企业甚至是大型企业的硬件防火墙使用的都是 Fortinet 的产品,在 Gartner 的魔力象限中也遥遥领先。目前主推的桌面型无风扇设备是下面这三款:
当然了,如此先进的企业级设备价格也是企业级的喽,全新机器基本都是 5000 以上的。这么贵的全新机器当然是买不起的,由于有大量企业在使用 Fortinet 的产品,自然想到去闲鱼上找一些二手设备来用用了。反正现在魔都也发不了快递,正好好好研究下选那个型号好。
实际上这就是一台正常 PC 机,硬件上没有什么特别的,处理器是多年前的 AMD 1700,64G 内存,2 块 SATA SSD + 1 块 NVME,网卡就是前文提到的双口 82599,此外还有主板自己板载的 RJ45 口。
机器安装了 PVE 作为虚拟化平台,PVE 同时支持虚拟机和 LXC 容器,软路由使用的是虚拟机,其他的 LXC 容器居多。当然说到容器的话 Docker 的生态会更好些,因此也同时安装了 Docker,LXC 和 Docker 混合着使用,能用 LXC 的优先选 LXC,LXC 没有的用 Docker。
软路由的选择上也折腾过很多,目前用的是 HomeLede 这个基于 OpenWRT 的定制版本:
软路由的稳定性上还是有所欠缺,基本每个月都要重启一次系统才行,不过好在一般重启了也就都正常了。
这台机器当然不仅仅是作为软路由来使用的,只当软路由也太浪费了一些,上面还运行着各种不同服务:
家庭中心化存储的核心自然就是 NAS 啦,目前家里面有两台 NAS,一台用蜗牛星际搭的黑群晖,另一台是威联通的 TS-532X。来一张合影:
蜗牛星际就不多说了,懂的都懂,多年前垃圾佬最爱的东西,几百块钱就有 NAS 了。然而垃圾毕竟是垃圾,使用起来问题不少,最严重的问题是,蜗牛星际机器的散热太差了,装满 4 块硬盘同时工作的时候,经常触发超温报警,甚至是直接过热关机因此目前重要的资料都没放在这个 NAS 上,也正在想办法改进下散热问题……
威联通的 TS-532X 还是很好用的,可以装 3 块 3.5 寸盘 + 2 块 2.5 寸盘,一般那两块 2.5 寸盘都是放 SSD 的,QNAP 有 QTier 自动分层技术,可以自动把经常使用的数据移到 SSD 上。
RAID 的配置上,3 块 HDD 使用 RAID 0,2 块 SSD 使用 RAID 1。至于怎么解决 RAID 0 数据安全性问题呢,当然是备份喽,其实 RAID 并不是解决数据安全性良好的方案,更大的意义是提供高可用和高性能。
QNAP 的数据备份功能做得还是不错的,用的是 HBS3 这款软件,前几个月刚刚添加了百度网盘支持,因此目前备份比较好的选择是百度网盘和阿里的 OSS 对象存储。两个我都试用了一下,HBS3 对阿里 OSS 的支持似乎有些 Bug,太大的单体文件(比如几十 GB 的文件)上传会失败,给他们提了工单不知道目前解决了么。因此目前备份选择的是百度网盘。在初始几次全量备份的时候失败率比较高,会有些文件上传失败,不过不用管它,多来几次,后面的定期增量备份数据量不大基本都是全部成功的。
QNAP 提供了数据加密功能,上传上去的数据可以都预先加密一次,因此无需过多担心隐私安全问题。如果想要从百度网盘里下载个别文件怎么解密呢,此时可以使用 QENC Decrypter 工具:
再来说下硬盘选择问题吧,大部分 NAS 玩家都喜欢使用希捷酷狼,西数红盘等,其实这里还有一个性价比更高的选择,那就是选企业级硬盘。企业级硬盘在可靠性,性能等方面是完全可以满足要求的,质保也要更长(一般都是 5 年),单位 GB 价格其实还要更低一些,唯一的缺陷是企业级盘不太考虑噪音,功耗等问题。威联通自己的推荐磁盘列表里就有不少希捷银河系列企业盘。目前我用的是希捷的 ST8000NM000A & ST3000NM0033,分别是 8T 和 3T 的盘。当前性价比比较高的是 16T 的 ST16000NM000J:
最后再来说下供电问题吧,为了避免突然断电对硬盘造成不可逆损坏,一般 NAS 前都会加 UPS 不间断电源。为 NAS 设计的 UPS 有个 USB 输出口,发生断电后会通知 NAS 关机。不过这个 USB 口只有一个,只能连接到一台 NAS 上,那有多台 NAS 怎么办呢?让连接 USB 接口的 NAS 通过网络向其他 NAS 进行广播就好了。
从 NAS 中拷贝大文件至 PC:
这个速度在维持了一段时间后会有所降低,此时的性能瓶颈是在 NAS 的 HDD 读取速度上的,要是买个盘更多的 NAS 或者直接用 SSD 那就可以充分发挥出万兆网络的优势了。
]]>Gossip 协议最有名的应用包括 Redis-Cluster,Bitcoin,Cassandra 等。
Gossip 协议最早发布于 1989 年的 ACM 会议论文 Epidemic Algorithms for Replicated Database Maintenance 中,论文中是用来解决分布式数据库多副本一致性问题的,这是一个最终一致性算法。
由于此算法仅能保证最终一致性而非强一致性,而且达到一致的时间不可控,因此目前更多的是被用于各种 P2P 应用中。
如同其名字一样,消息的传播就像谣言的传播一样,一传十,十传百,百传千千万。
若一个节点需要向其他节点广播发送消息,则:
当一个节点收到其他节点的广播消息时,更新自己的数据,并向除源节点外其他节点广播数据。
网络上诸多文章对 Gossip 协议的介绍基本就到此为止了,基本还会配上这样一张传播示意图:
然而根据上述如此抽象的描述可以说是没法搭建出实际有用的系统来的。粗略想想就会发现几个核心问题没有被解决:
其实在 Gossip 的原始论文中对这些问题是有更细致的讨论和处理的,Márk Jelasity 在 Self-organising software. From natural to artificial adaptation 一书中也有详细论述,二者基本是一致的。
实际的消息或数据的形式是由具体应用决定的,不过不妨将其抽象为 K-V-T
集合,即若干条 Key -> Value + Time,这里的 Time 可以是实际的时间戳,也可以是其他类似 Version 的值。
更新数据时并不会直接无脑的去覆盖数据,而是会比较 Time:
1 | Update(k, v, t) { |
Gossip 的论文中就是使用了时间戳来作为这个 Time,不过这感觉会存在一个问题:
如何保证全局时间戳的一致性呢?如果无法保证,那会不会造成节点间的不平等?即时间较晚那个节点相当于就被赋予了更高的优先级?
Gossip 算法有三种基本策略:
在后两种策略中,消息的交互通信模式又可以分为三种:
在下文描述中,使用 S 表示某一节点的邻接节点集合。
当某一节点有数据发生更新时,触发通知其他所有节点的流程:
1 | for(s : S) { |
收到数据的节点更新自己的数据,若成功更新继续重复上述流程通知自己的邻接节点。
此策略由于只发送一次数据,所以并不一定可靠。
此策略在 Márk Jelasity 的书中被称为 SI 模型,其工作流程是每个节点均周期性的选择部分邻接节点同步更新数据:
1 | while(true) { |
三种消息交互模式的区别就在于 ResolveDifference
的实现方式不同:
K-V-T
数据发送给节点 B,节点 B 更新自己的数据;K-T
数据发给节点 B,节点 B 收到后与自己的数据进行比较,将更新或节点 A 没有的 K-V-T
数据发送给节点 A,节点 A 更新自己的数据;K-V-T
至节点 B,节点 B 更新完后把需要节点 A 更新的 K-V-T
再推送回节点 A,节点 A 再更新自己的数据。上述描述中 Push/Pull 模式的流程与大部分文章中写的有所区别,在大部分文章中,Push/Pull 模式就是简单的 Pull + Push,此时会存在 3 次网络交互,此处进行了一些优化,如果先做 Push 再做 Pull 则可以减少一次网络交互。
在 Direct mail 形式下使用的就是 Push 通信模式,因此 Direct mail 策略可以视为是只执行一次的使用 Push 模式的 Anti-entropy 策略;
一般而言,显然是 Push/Pull 模式收敛得最快。
此方法中,发送的 K-V-T
根据论文的描述应该是数据全集,这就导致需要交互的数据量极大,甚至是完全不可行的。而且这个交互过程是一直都在进行永不停止的,这也会造成较大的带宽浪费。
针对 Anti-entropy 策略做了些改进,每个节点加入一个状态,可能取值有:
S
: Susceptible, 不存在数据更新I
: Infective, 存在数据更新且需要广播R
: Removed, 存在数据更新,然而不广播故此策略在 Márk Jelasity 的书中被称为 SIR 模型。
其中 S
状态是节点的初始值,仅当没有数据更新时才会处于此状态中,一旦发生了数据更新就永远也不可能回到此状态中。因此感觉此状态是为了理论的完美性才引入的,从实际实现的角度来看完全可以忽略此状态。
此外还需要再引入一条 Feedback 消息,当某个节点 B 收到节点 A 发来的 Push 消息后会回复节点 A 此消息。
此时状态迁移就会变得很简单,一旦发生数据更新(通过与其他节点交互或系统其他部分修改数据)就会成为 I
状态;一旦收到 Feedback 消息,则有一定概率会变成 R
状态;
当节点处于 I
状态时,行为与 Anti-entropy 策略相同;当节点处于 R
状态时,不进行周期性的节点同步更新。此策略的目的正是通过引入 R
状态来让节点间的交互可以在一段时间内停止。
上述变成 R
状态的一定概率在论文中用 $1/k$ 表示,此概率是怎么确定的呢?学术点的做法是根据集群规模理论计算出来,当然实际使用的时候一般都是试出来的,此概率越高则系统收敛速度越快,然而也越容易无法保证最终一致性。
论文的内容就到此为止了,然而仔细揣摩下上述策略,其实有一些点是可以进一步优化的。
首先在消息的设计上,由于 Push/Pull 模式是最为完善的,可以均选用此策略,此时消息就可以简化为 PushReq
& PullRes
两条。
Rumor mongering 策略中引入的 Feedback 消息在此设计下其实是多余的,一旦收到 PullRes
即等同于收到了 Feedback 消息。
另一个可能的优化点在于,PushReq
中携带的 K-V-T
是否可能不是全量数据,只发送增量数据,这么做需要思考以下一些难点:
PushReq
中加入特殊的字段表明此请求是增量单向 Push,PullRes
中不需要回复缺失 K-V-T
。然而加入此字段后会不会让整个系统只有 Push 而没有 Pull 的能力呢?还是除了初始化时其他时候不需要 Pull 也可以正常工作呢?K-V
是变化了的呢?一个想法是是否可以利用时间信息,比较上一次同步的时间与数据中的时间戳 TR
状态而导致数据不一致?新节点加入时,只需要发送空的 PushReq
即可,此时相当于 Pull 模式去主动拉数据。
Gossip 协议的适用场景相对比较确定:
第 2 点是 Gossip 协议最显著的优点,在这样的去中心化拓扑结构下,其他很多一致性算法都是没法正常工作的,因此在 P2P 网络中 Gossip 协议取得了广泛的应用;而第 3 点则是 Gossip 协议最大的局限性,即它不是强一致性协议。
如果系统具有特性 1 & 2 而又要求强一致能否实现呢?好像还没有听过这样的算法……感觉可以搞个 Paxos + Gossip 的混合算法出来?这估计是个蛮有意思的东西吧……
Gossip 协议还有哪些显著优点呢:
至于它的缺点,主要就是前文所述的,它不是强一致协议,甚至都不能保证最终一致性。
]]>P2P 网络核心技术:Gossip 协议
Gossip 协议详解
一万字详解 Redis Cluster Gossip 协议
分布式系列 Gossip协议
此论文为多伦多大学与 NetApp 合作发表在 FAST’22 上的论文,统计分析了 NetApp 线上两百多万块 SSD 在过去 4 年内的运行数据,以此总结了 SSD 寿命,写放大这两个核心运维特性在大规模线上环境下的真实情况及其影响因素。
看完最大的感想是,SSD 间差异是巨大的,垃圾厂家的 SSD 真是不能用……
这篇论文作为一篇统计分析类文章之所以能发表在 FAST 上的重要原因在于其统计规模巨大且广泛,而且是真实线上环境,比之前同类研究中选取某个小范围特定场景来说有价值得多。NetApp 是一家美国的云存储公司,由于其客户的多样性,其底层存储系统同时提供了 NFS,iSCSI,NVMe_oF,S3 等多种接口,因此整体的应用负载特性相对较能反映各种不同应用的平均水平,而非某种特定场景。NetApp 使用的 SSD 规模也很大,总量约 200 万块,包括 3 个厂家,20 余个系列,同时存在 SAS & NVME 接口的(以 SAS 居多),具体情况见下表:
I,II,III 代表三家生产商,论文里面并没有披露具体是哪三家,然而根据某些型号不太常见的容量,如 I-D 的 3.8T,II-H 的 30T,可以推测出 I 是 Sandisk,II 是 Samsung,至于 III 由于只有一个 III-A 数量太少,就不知道是哪家了。上表除最后 II-X, II-Y, II-Z 为 NVME 接口外其余 SSD 均为 SAS 接口的,可以推测从数量上看 SAS SSD 也占了绝大部分。不过 SSD 的接口类型对其寿命,写放大应该是没有什么影响的,故尽管未来 NVME SSD 会越来越多,这篇论文依然会有参考价值。
上层应用从使用场景上来看可以分为两大类:
这两类系统的读写特性差异较大,因此论文中基本都是将其分开进行讨论。
论文想要回答的核心问题包括:
下面我们就跟随着作者的脚步来回答下这些问题吧。
Facebook,Microsoft,Alibaba 的统计数据中也是读比写多,不过 NetApp 这次公开的统计数据读写量远高于其余几家公开的统计数据:
SSD 的寿命和写入量有密切关系,DWPD 这一指标就是厂家给出的推荐写入速度上限,其含义为每天可以写入 SSD 容量几倍的数据(Drive Writes Per Day),大部分企业级 SSD 的 DWPD 值为 1 或 3,部分使用 MLC 颗粒的 SSD 会更高,可达到 10 以上。未来使用 QLC 颗粒的 SSD DWPD 大概率会下降到 1 以下。那实际生产环境中 DWPD 会到多少呢?来看看数据吧。
以上两部分内容其实并没有太多干货。读多写少是大部分互联网业务的典型场景;不同负载的写入量差异很大也是很正常的。
SSD 除了 DWPD 外还有个重要指标是 PE Cycle Limit,即最大擦写循环次数(Program-Erase Cycle),这是决定 SSD 寿命的根本性因素,SSD 寿命预测基本就是根据当前 PE Cycle 与最大 PE Cycle Limit 的比值得出的。因此作者引入了一个年损耗率的概念:
显而易见,此值的含义就是每年会用去 SSD 总寿命的百分比。实际统计结果如下:
根据以上数据我们可以来评估下未来 QLC 的寿命是否能满足数据中心及企业级应用的需求了,这在论文作者的 PPT 里面有计算结果:
写放大系数(Write Amplification Factor, WAF)是影响 SSD 寿命及性能的关键指标,SSD 存在写放大的原因是其内部存在诸多背景任务(housekeeping tasks),如垃圾回收(GC),损耗平均(Wear Leveling)等。学术界和产业界对于如何控制写放大这一问题都做了很多工作,然而实际 SSD 产品这一点做得究竟如何呢?已有的统计研究都不够广泛,规模也相对较小,因此这篇论文的研究就显得比较有价值了。下面就来看下作者的统计结果吧:
这感觉是全文最大的亮点啊,看到这简直是惊呆了,消费级不知名小厂做的 SSD 很垃圾不能用也就罢了,Sandisk 好歹也是个国际大厂了,做的企业级 SSD 也能这么垃圾简直是万万没想到啊……这些 SSD 还都是 MLC 颗粒的,这么糟糕的表现是 Flash 颗粒用得太差还是 FTL 固件写得水平太差就不得而知了……
最后来看下应用负载特性与 WAF 的关系:
学术界由于没有那么多真实 SSD 可以用,因此在研究 SSD WAF 时往往使用仿真的方法,然而根据本文的结论,这些仿真得到的 WAF 都太小了,绝大部分已有理论研究论文中 WAF 最高也就 10 左右,这仅仅只是 NetApp 真实环境中的 60% 分位值。作者认为造成这一差异的主要原因有:
损耗平均(Wear Leveling)就是把写入尽量平均的打散到所有 Block 上,以此避免某些 Block 被频繁的擦写,造成其寿命下降及性能降低。损耗平均技术是以整体的 PE Cycle 增加来换取长尾减少的,因此实际实现上需要做一个平衡——太过激进的损耗平均会导致 SSD 整体寿命衰减得太快。为了衡量损耗平均的效果,作者引入了擦除率(Erase Ratio)及擦除均匀度(Erase Difference)这两个指标:
Erase Ratio 的理想值是 1,对应的 Erase Difference 的理想值是 0。实际当然不会这么理想:
这一部分没太大意思,作者分析了一下不同使用年限,不同大小的 SSD 在 AFF 应用场景下空间使用率有啥区别(WBC 应用空间使用率无意义,基本都是 100%):
直接给出作者在 PPT 中的总结表格吧:
结论很简单,FTL 固件算法做得如何是决定性因素;写入负载也有明显影响;其余因素基本没影响。
作者此处选用了 III-A 这款 SSD 为例进行分析,这款 SSD 最初的固件版本是 FV2,后面升级到了 FV3:
可以看到这次纯软件固件升级有显著优化作用,明显改善了其 WAF;更不要说前面分析过的 Sandisk 产品和 Samsung 产品的差距了。由此可见,SSD 远远不只是搞一些 Flash 颗粒芯片来组装下就好了的,主控软件算法的水平同样是决定性的;甚至可以说,主控的影响有时候比颗粒类型更重要——Samsung 用消费级 TLC 颗粒做出来的 SSD 整体寿命比 Sandisk 用企业级 MLC 颗粒做出来的 SSD 还要好。
直接进行 IO Trace 是记录负载特性最有效的方法,然而对于大规模系统来说这是基本不可行的。因此作者用 DWPD,SSD 容量,SSD 接口类型这几个指标来对负载类型进行了一个简单分类。SSD 容量和接口类型能用于区分负载类型同样是由于不同容量和接口类型的 SSD 被用于了不同产品和客户上。
这部分的图不是那么直观就不放了,直接给结论吧:
作者的结论是没有明显影响
这是指 SSD 厂商在内部预留的空间(Over-Provisioning,OP),通常认为这会对 WAF 有明显影响。然而实际看下来影响不大,甚至是有轻微的负相关关系,即预留空间越大的 SSD 反而 WAF 也越大。比如 Sandisk 的那几款 SSD,预留空间达到了惊人的 44%,然而还是被 Samsung 预留空间只有 7% 的 SSD 吊打。
这仔细一想其实也很好理解,预留多少空间当然是根据 FTL 固件的需求决定的,写得越差的 FTL 固件很有可能就需要越多的预留空间……
多流写(Multi-stream Writes, MSW)技术需要主机端在写入的时候指定一个 Stream ID,支持此特性的 SSD 会根据此 Stream ID 将具有相同或相似生命周期的数据写入到相同的 Block 中去,以此实现冷热数据分离,预期可以大幅提高 GC 时的效率,减少 WAF。此技术的详细介绍可参考文末参考资料中的几篇文章。
论文中对于多流写技术的影响结论是不确定,原因是缺乏足够的数据进行判断。
看完全文主要收获有:
]]>参考资料:
Increasing SSD Performance and Lifetime with Multi-Stream Write Technology
此论文为 Facebook 发表在 FAST’21 上的论文,回顾了 RocksDB 在过去 8 年的演进中设计上核心关注点的变化及相应的优化措施,以及在性能,功能,易用性上所做的探索工作;此外还总结了将 RocksDB 应用于大规模分布式系统及系统错误处理上需要考虑的一些问题及经验教训。论文中没有论述具体的技术细节,更多的是从宏观的面上讨论了核心设计思想及工程实现上的各种权衡。
下面就来看下此论文具体讲了些什么,引用部分为我自己的笔记。
RocksDB 是 Facebook 基于 Google 的 LevelDB 于 2012 年开发的一款高性能 KV 存储引擎,在设计之初 RocksDB 就是针对 SSD 来进行设计及优化的,且 RocksDB 的定位一直都很清晰——只做一个核心存储引擎而不是完整的应用,作为一个嵌入式的库集成到更大的系统中去。
RocksDB 是高度可定制的,因此它作为一个核心 KV 存储引擎能适应各种工作负载,用户可以根据自己的需要对它进行针对性调优,如为读性能优化,为写性能优化,为空间使用率优化,或它们之间的某个平衡点。正是因为如此灵活的可配置性,RocksDB 可以作为很多不同数据库的存储引擎使用,如 MySQL,CockroachDB,MongoDB,TiDB 等,也可以满足流式计算(Stream processing),日志服务(Logging/queuing services),索引服务(Index services),缓存服务(Caching on SSD)等多种特性完全不同的业务需求。这些不同业务场景特性总结如下:
这么多不同应用共用一个相同的存储引擎与每个应用都去搭一套自己的存储子系统相比有很多优势:
文中说这些基本就是用一个统一轮子的好处了,其实对于个人来说还有些其他额外好处,由于不同公司不同 Team 都选用了同样的底层引擎,人员流动就会变得更方便了:)
引言最后照例介绍了下文章结构:
RocksDB 设计之初就是为 SSD 及现代硬件优化的。SSD 的 IOPS 及读写带宽都大幅优于传统机械硬盘,不过其寿命有限,且写入性能会由于擦除而恶化,因此设计一款针对 SSD 的嵌入式 KV 存储引擎就变得更有必要了,这就是 RocksDB 的设计初衷——设计一款极具灵活性可用于各类应用的,使用本地 SSD 并为其优化的 KV 存储引擎。
RosksDB 的核心数据结构是 LSM-tree,其基本结构如下:
几种基本操作的流程简述如下:
写入(Writes)
先写 Memtable 及 WAL 文件(Write Ahead Log),Memtable 通常使用跳表(Skip List)作为其数据结构。Memtable 写满后转换为不可变的 Memtable(immutable Memtable),并同时生成一个新的 Memtable 供写入;随后 immutable Memtable 会被写入到磁盘上变成 SST 文件(Sorted String Table)。
压缩(Compaction)
Compaction 有多种不同算法,RocksDB 最古老的 Compaction 算法是源于 LevelDB 的 Leveled compaction。其基本原理如上图所示,Memtable dump 生成的 SST 文件位于 Level-0,当某个 Level 中所有 SST 大小超过阈值后选择其中的一部分文件 Merge 到下一个 Level 中。除 Level-0 外,其他 Level 内不同 SST 文件间 key 的范围没有重合。
读取(Reads)
读取时从小到大遍历所有 Level,直到读到为止。可使用布隆过滤器(Bloom filters)来避免不需要的 IO。Scan 时需要扫描所有 Level。
RocksDB 支持多种不同的 Compaction 算法:
Tiered compaction 与 Leveled compaction 的核心区别在于每个 Level 中不同 SSTable 间 Key 的范围是否有重叠(overlap),leveled compaction 策略下同一 Level 内 SSTable 间 Key 是不会有重叠的,因此读的时候只会读一个 SSTable,IO 放大是可控的。Tiered compaction 则没有此性质,不同 SSTable 间 Key 范围是有重叠的。这两种 compation 策略的选择其实也是读放大与写放大间的权衡。
可以进一步参考下此文:LSM Tree的Leveling 和 Tiering Compaction
可以使用多种不同的 Compaction 策略使得 RocksDB 可以适用于广泛的应用场景,通过配置 RocksDB 可以变成为读优化,为写优化或极度为写优化(用于 Cache 等应用中)。不同的配置其实是读写放大间的平衡问题,一些实际的测试结果如下:
RocksDB 的优化目标最初是减少写放大,之后过渡到减少空间放大,目前重点则是优化 CPU 使用率。
这是刚开始开发 RocksDB 时的重点优化目标,一个重要原因是为了减少 SSD 的写入量以延长其寿命,这对某些写入很多的应用来说至今仍然是首要优化目标。
Leveled compaction 的写放大系数一般在 10~30 左右,这在大部分情况下是优于 B-tree 的,与 MySQL 中使用的 InnoDB 引擎相比,RocksDB 的写数量仅为其的 5% 左右。然而对于某些写入量很大的应用来说,10~30 的写放大系数还是太大了,所以 RocksDB 引入了 Tiered compaction,其放大倍数通常在 4~10 左右。
在经过若干年开发后,RocksDB 的开发者们观察到对于绝大多数应用来说,空间使用率比写放大要重要得多,此时 SSD 的寿命和写入开销都不是系统的瓶颈所在。实际上由于 SSD 的性能越来越好,基本没有应用能用满本地 SSD,因此 RocksDB 开发者们将其优化重心迁移到了提高磁盘空间使用率上。
RocksDB 开发者们引入了所谓的 Dynamic Leveled Compaction 策略,此策略下,每一层的大小是根据最后一层的大小来动态调整的。
Tiered compaction 的空间放大和 Level compaction 根本没法比,极端情况下需要预留一半空间才能顺利进行完整的 compaction,因此这里就直接不讨论了。
Dynamic Leveled Compaction 的具体介绍可参考:
Dynamic Level Size for Level-Based Compaction
核心思想就是让稳态情况下更多的做一些 compaction。
此策略的效果如下:
随着 SSD 的速度越来越快,一种普遍的担心是,系统的瓶颈会不会由磁盘 IO 转移到 CPU 上呢?然而作者认为无需担心此问题,原因如下:
为了证明此观点是正确的,作者给出了若干个 ZippyDB & MyRocks 的实际测试结果用以论证空间才是瓶颈所在:
虽然说了这么多无需太担心 CPU 成为瓶颈,作者认为我们还是要去优化 CPU 使用率,为什么呢?因为其他更重要的优化,如空间放大优化都做完了没有可做的了……(这段真不是我瞎写的,原文就是这么说的:Nevertheless, reducing CPU overheads has become an important optimization target, given that the low hanging fruit of reducing space amplification has been harvested.)此外,CPU 和内存还越来越贵了,优化 CPU 使用可以让我们用一些更便宜的 CPU……
一些有助于优化 CPU 使用率的早期尝试包括:前缀布隆过滤器(Prefix bloom filters),在查找索引前就先通过 bloom filter 进行过滤,还有其他一些 bloom filter 的优化。
对于上述论述不甚赞同……
- 作者这是假设这台机器上只跑 RocksDB,上层应用是很轻量级的,把全部 CPU 资源都给 RocksDB 用才没有瓶颈,这显然是很有问题的,有可能上层应用本身就需要很多 CPU 啊,而且作为一个 KV Engine 就把大部分 CPU 资源用完了不太合理吧。这还不要说在共有云等场景下大规模混部的情况了,此时所有空余的 CPU 都是可以用来干其他事的。
- 不知作者为什么认为一台机器上只配一块 SSD 才是最优搭配,根据我的经验这分明是不太好的搭配吧,正因为 SSD 少了存储空间才成瓶颈的,一台机器上使用 2~4 块 NVME SSD 才是目前更主流且合理的方案吧,要是机器上还有多个 RocksDB 实例在同时工作,此时 CPU 显然很容易成为瓶颈吧。
作者列举了一些存储领域的新技术,如 open-channel SSD,mulit-stream SSD,ZNS(Zone namespace,类似抽象得更好的 open-channel SSD),这些都能降低 IO 延迟,然而作者又抛出了绝大部分应用都是受空间制约的这一论据,认为这些技术并没有什么实际用处……此外如果要让 RocksDB 这一层直接用上这些技术会破坏 RocksDB 统一一致的体验,因此更值得尝试的方向是下层文件系统去适配这些新技术,RocksDB 专注于 KV 引擎层该做的工作,而不是去做底层 FS 存储层该做的事。
前面空间制约这一说法不敢苟同……后面的说法倒是的确很有道理,每一层就专注做这一层该做的事吧。
存算一体(In-storage computing)也是个很有潜力的技术,然而尚不确定 RocksDB 要如何用它,以及能从中获得多大的收益,后续会继续关注研究此技术。
远端存储(Disaggregated/remote storage)是目前阶段更有意义的优化目标,上文也提到了 CPU 和本地 SSD 盘很难都同时用满,然而若用的是存算分离的架构则不存在此问题了。对于 RocksDB 来说,对远端存储的优化主要集中在聚合 IO 及并行化 IO 上,此外还有对瞬时网络错误的处理,将 QoS 需求下传至底层系统,提供 Profiling 信息等。
资源使用率优化是存算分离架构最大的优点之一。
持久性内存(Persistent Memory, PMem,又称 Storage Class Memor, SCM)是另一个极有前途的技术,对于 RocksDB 来说有以下这些值得尝试的方向:
经过了这么多年的发展,LSM-tree 这一基本数据结构是否还合适呢?作者给出的答案是,Yes!LSM-tree 至今还很适合 RocksDB 使用。主要原因是 SSD 的价格还没有降到足够低的程度,即可以让大部分应用都不在意其寿命损耗的程度,此类应用只是很少一部分应用。然而,当 Value 很大时,分离 KV (如 WiscKey)可以显著降低系统写入放大,所以此功能也被加入到了 RocksDB 中(被称为 BlobDB)。
在大规模分布式数据存储系统中,通常都会将数据分为若干个 Shard,再把不同 Shard 分配到不同存储节点上。单个 Shard 的大小通常是有上限的,原因是 Shard 是负载均衡和多副本的基本单位,需要能在不同节点间自动拷贝。每个服务节点上通常都会有数十个或数百个 Shard。在使用 RocksDB 的通常实践中,一个 RocksDB 实例只用于管理一个 Shard,因此一个服务节点上也就会有很多 RocksDB 实例在同时运行,这些实例可以运行在同一地址空间下,也可运行在不同地址空间下。
这里分别就是多线程和多进程模型吧。
一台 Host 上会运行多个 RocksDB 实例的事实让资源管理变得更为复杂,因为资源需要同时在全局(整个 Host)和局部(单个 RocksDB 实例)两个维度上进行管理。
当不同实例运行在同一个进程内时资源管理相对较为简单,只要限制好各种全局资源,如内存,磁盘带宽等的使用即可。RocksDB 支持对每类资源都创建一个资源控制器(resources controller),并将其传递个各实例,以实现实例内的局部资源管理,这个资源管理还是支持优先级的,可以灵活的在不同实例间分配资源。
然而若不同实例时运行在不同进程上时,资源管理就会变得更有挑战性。解决此问题有两个思路:
此外,另一个经验教训是,随意使用独立的线程会导致很多问题,这会使得总的线程数变得很多且不可控,进而导致线程切换开销增大及很痛苦的 debug 过程。一个更好的选择是使用线程池,这可以根据实际情况去控制总的线程数量。
传统的数据库都是使用 WAL 文件来保证数据持久性的,然而大规模分布式存储系统更多的是依赖于不同节点上的多副本来保证这一点的,单节点的数据损坏可以通过其他节点来进行修复,对于这类系统来说,RocksDB 的 WAL 文件就没那么重要了。进一步,很多一致性协议(如 Paxos,Raft 等)有其自己的 Log,这种情况下 RocksDB 的 WAL 文件就完全没用了。
因此 RocksDB 提供了三种不同的 WAL 策略可供选择:
RocksDB 底层的文件系统通常是选用 SSD-aware 的文件系统,如 XFS,此类文件系统在删除文件时可能会显式的向底层 SSD 固件发送 TRIM 命令。此行为通常有助于提高 SSD 的性能及寿命,然而某些时候也会导致一些性能问题。TRIM 命令其实是没那么轻量级的,SSD 固件在收到 TRIM 命令后会更新其地址映射关系,此行为有可能需要写入 FTL 日志(journal),而 FTL journal 是位于 Flash 上的,这又有可能会触发 SSD 内部的 GC,进而导致大量的数据迁移,此行为会干扰前台写入造成写入延迟的上升。为解决此问题,RocksDB 引入了一个速率限制器来限制 compaction 后并发删除的速度。
大规模的分布式系统之所以叫大规模了,当然是因为整个系统中的机器数很多喽,此时升级肯定是增量式进行的,没有任何实际生产系统会对所有节点做同步升级。因此需要保证两种基本兼容性:
如果不保证这些兼容性就会给运维带来极大的困难。对于向后兼容性(backward compatibility)来说,RocksDB 要能识别之前版本的数据,这的代价通常是软件复杂度;而向前兼容性(forward compatibility)通常是更难保证的,这要求老版本要能识别新版本的数据,RocksDB 通过 Protocol Buffer 等技术来一定程度的保证了至少一年的向前兼容。
backward compatibility 相对比较好做,顶多就是代码写得复杂点;然而 forward compatibility 要困难的多,甚至是在很多时候根本就是不可行的,一个系统的 forward compatibility 如何很大程度上是取决于设计之初设计者的远见与前瞻性的。
RocksDB 的一大特色就是其高度可配置性,这也是它能用于满足各种工作负载需求的原因所在,然而此时配置管理也就变得很有挑战性了。最初 RocksDB 的配置方式类似于 LevelDB,有哪些配置项及其默认值等都是写死在代码中的,这种方式有两个问题:
为解决此问题,RocksDB 支持针对每个数据库使用不同的配置文件,而非一个 RocksDB 实例只能用一个统一的配置文件。此外还提供了一些辅助工具:
另一个更严峻的问题是 RocksDB 的配置项实在是太多了,这是 RocksDB 早期之所以能得到广泛应用的一个原因,然而过多的配置项也让配置的复杂性和混乱程度变得很高,要弄清楚每个配置项是干嘛的基本是不可能的。这些配置项如何配置才是最优的不仅取决于 RocksDB 的运行环境,还取决于其上层应用,还有上层应用更上层的负载情况等等,这些都会让调参变得极为困难。
这些真实世界中遇到的问题让 RocksDB 的开发者们重新检视了其最初的配置支持策略,开始努力提高 RocksDB 开箱即用性能及简化配置项。目前开发的重点是在保持高度可配置性的基础上提供更强大的自适应性(automatic adaptivity),要同时做到这两点会显著增加代码维护的负担,然而开发者们认为这是值得的~
RocksDB 本身是一个单节点的库,使用 RocksDB 的应用需要自己处理多副本及备份问题,不同应用的处理方法不尽相同,因此 RocksDB 需要对此提供恰当的支持。
在新节点上重新拉起一个副本有两种策略:
备份也是很多数据库或其他应用所需的一个重要功能。备份与多副本一样也有逻辑和物理两种方式,然而与多副本不同的是,应用通常需要管理多个版本的备份数据。尽管大部分应用都实现了其自己的备份策略,RocksDB 也提供了一个简单基本的备份引擎。
由于性能原因,RocksDB 一般不使用 DIF/DIX 等 SSD 提供的数据保护功能,而是使用最为通用的校验和策略。根据作者的观测,RocksDB 层面的错误在 100PB 规模下大概每 3 个月就会出现一次,更糟糕的是,大约 40% 的情况下,错误已经被扩散到多个副本里去了。
数据损坏越早被检出系统的可用性及可靠性就会越好,大部分基于 RocksDB 的应用都使用不同机器上的多副本策略,此时检测到一个副本校验和错误后可以根据其他副本进行修复,前提是正确的副本还存在。
目前的 RocksDB 校验和保护机制可分为 4 层(含计划中的应用层校验和):
RocksDB 遇到的大部分错误都是底层文件系统返回的错误,最初 RocksDB 处理这些错误的方式就是不处理,即直接将这些错误抛给上层应用或永久停止写入。目前开发者们更倾向仅在 RocksDB 自身无法处理或恢复时才中断 RocksDB 的正常流程,实现这的基本方法就是对某些暂时性错误在 RocksDB 层面就进行重试。上层收到 RocksDB 的错误后一般处理方法都是进行实例迁移,RocksDB 自身进行了重试后上层因此造成的实例迁移就会少很多。
核心的 KV 接口是如此的通用,以至于基本所有的存储负载都可以被 KV 接口所满足,这就是 KV 存储这么流行的原因了。然而对某些应用来说,这么简单的接口可能会制约其性能。比如要想基于 KV 接口做 MVCC(Multiversion concurrency control,多版本并发控制)的开销就会很大。
RocksDB 内部是有一个 56-bit 的序列号用于区分不同版本的 KV 对的,也支持快照(Snapshot)功能,生成了一个 Snapshot 后此时的所有 KV 对都是不会被删除的,直到显式的释放了此快照,因此同一个 Key 是可以有多个序列号不同的 Value 的。
然而此种简单的多版本机制是没法完全满足很多应用需求的,原因在于此机制存在一些局限性:
应用想要绕开这些限制只能在 Key 或 Vaule 中自行编码加入时间戳,然而这会导致性能下降:
因此在 KV 接口层面就支持指定时间戳会是一个更好的解决方案,目前 RocksDB 对此已经提供了基本的支持。以应用自行在 Key 中编码时间戳的性能为基准,原生带时间戳的 KV 接口性能如下:
可以看到至少有 1.2 倍性能提升,原因在于查询操作可以使用正常 Query 接口而非 Scan 接口了,此时 Bloom Filter 等就都可以起作用了。此外 SSTable 包含的时间戳范围可以加入到其元信息中了,这就有助于在读的时候直接跳过不符合要求的 SSTable 文件。
开发者们认为,此功能有助于上层应用实现 MVCC 或其他分布式事务功能,然而并不考虑开发更复杂的多版本功能,原因是更复杂的多版本功能使用起来并不那么直观,也可能会被误用;且为了保存时间戳需要更多的磁盘空间,也使得接口上与其他 KV 系统间的可移植性变差。
这部分主要就是介绍在存储引擎库,基于 SSD 的 KV 存储系统,LSM-tree 优化,大规模存储系统这几方面上还有些什么研究,感兴趣的可以去看看原文。
除了上文提及的支持远端存储,KV 分离,多层次校验和,应用指定时间戳外,还计划统一 Leveled 及 Tiered compaction 策略和增强自适应性,此外还有些开放问题:
很不错的图~可以看到 RocksDB 性能上的优化主要聚焦于 Compaction 及 Bloom Filter 展开~
]]>参考资料:
听上去是不是很牛逼啊,感觉我们马上就要能写出 bug free 的程序来了呢~然而理想很丰满,现实很骨感,实际问题远远不会是这么简单的,要是形式化验证真这么好用那它就不至于至今还这么小众了,事实上形式化验证存在着很多局限性与不 work 的时候的,这个后面再来细说。
关于形式化方法的实际应用及其强大之处可以进一步读读下面这篇布道文:
Don’t Test, Verify —— 哪个故事真正符合你对形式化验证的想象?
当初也是因为偶然看了此文章知道了形式化验证这个东西,后面也陆续去深入了解学习了下,最近也用它解决了一些实际工作中的问题。本文就打算分享下入门学习的一些心得体会。
进行形式化验证的具体工具有很多,目前实际软件开发中最为常用的是由 Leslie Lamport 开发的 TLA+,这是一种用于形式化验证的语言,主要用于验证并行及分布式系统的正确性。
由于 TLA+ 写的代码并不是用来实际运行的,故一般将其代码称为模型(Model)而非程序(Program)。
TLA+ 是基于数理逻辑而非经典的软件开发思想设计出来的,故其代码与其他编程语言有着显著区别,其中的基本元素是集合,逻辑运算,映射等东西,来个例子感受下:
1 | Next == \/ \E b \in Ballots : Phase1a(b) \/ Phase2a(b) |
这段代码看上去完全不像在编程,实际上写 TLA+ 代码的确也不是在编程而是在用数理逻辑定义一些东西。
这学习曲线对于大部分码农来说实在是太过于陡峭了,Programer 并不是数学家,Lamport 大神也知道这一点,于是他又搞了个叫 PlusCal 的东西出来。PlusCal 是一种类似 C/Pascal 的高级语言,其目的同样不是为了生成机器代码来运行,而是依靠 TLA+ 解释器来生成对应的 TLA+ 模型代码。
来一段实际的 PlusCal 代码感受下:
1 | (* --algorithm EuclidAlg { |
这看上去就很像经典编程语言了,因此对于程序员来说,可以使用 PlusCal 来快速进行形式化验证。不过 PlusCal 毕竟是 TLA+ 的上层高级语言,其能实现的功能只是 TLA+ 的一个子集,不过一般来说此问题不大,这个子集对于简单应用来说足够用了。
有了代码后如何运行 TLA+ 或 PlusCal 模型呢,Lamport 为此开发了一个 IDE,即 TLA Toolbox. 然而此 IDE UI 界面并不是很好用,更建议使用 VSCode 中的 TLA 插件 来进行开发。
入门学习建议从下面这个教程开始:
此教程完全从实用角度出发,立足点是如何用 PlusCal 来解决日常编程中需要关注的并发,一致性等问题,因此十分简单易学,也比较短,看完后基本就能实际上手做些事情了。
在实际写 PlusCal 代码的时候需要参考下其语法手册,PlusCal 有两种语法风格,类似 Pascal 的 P-Syntax 及类似 C 语言的 C-Syntax,语法手册分别如下:
A PlusCal User’s Manual C-Syntax Version 1.8
A PlusCal User’s Manual P-Syntax Version 1.8
网上的例子中使用 P-Syntax 的居多,不过我个人更喜欢 C-Syntax 一些。
如果看完上述简单教程后还想进一步系统的学习一下,那建议从 Lamport 的 TLA+ 项目主页开始:
此外 Lamport 还有一本系统的讲形式化验证的书:
观千剑而后识器,看看其他人是如何写代码的对于入门来说也是很有用的,下面这两个 Github 项目中收集整理了很多 TLA+ 模型,如果想要提高水平可以仔细学习揣摩下:
形式化验证是用来验证算法是正确的,那什么叫“正确”呢?如何定义“正确”是形式化验证中最重要的问题之一。比较符合程序员习惯的方法是在 PlusCal 中加入 assert
来检查是否满足某些条件。不过更好的方法是使用不变量(Invariants)检查,如何正确的定义算法中需要检查的 Invariant 是十分重要的,如果检查条件的定义本身就是不完备的,那形式化验证的结果自然也是不完备的。
PlusCal 中使用 Label 来定义原子操作,一个 Label 下若干条语句会被视为是一个原子操作,如果把本来不是原子操作的行为错误的定义为了原子操作,那最终得到的结果显然就会是不完备的。
如果把本来可以视为一个原子操作的行为定义为若干条原子操作,则会让验证的计算量大幅增加,导致验证所需时间变长。PlusCal 翻译成 TLA+ 后验证原理是穷举不同进程间执行时序的所有可能性,若原子操作或分支过多,会造成解空间的急剧膨胀。
对于学习各种人为创造的东西基本都可以按相同的方法进行,不过对于学习自然科学的概念方法会有所不同,本文就不去讨论了。
学习一个新东西基本可以分为三个阶段:初步理解,即会用;深入理解,即懂原理;融会贯通。
这一阶段的目的是学会如何使用这个新东西,即学习如何用轮子的阶段,对于只需要应用的情况来说达到这一阶段就够了。此阶段的核心就是搞清楚三个问题:是什么(What),为什么(Why),怎么用(How),这十分类似于 3W 法则,因为这本来就是人类自然思维过程的抽象总结。
最基本的第一步,搞清楚这是一个什么东西。这一步说简单也简单,说复杂也复杂。简单在于只要随便看看介绍就会对这是什么有个初步感觉了;复杂在于要想给一个东西下一个精确的定义来描述它是什么会是极为复杂的。
要理解一个东西是什么往往伴随着理解它不是什么同步进行。
在学习新东西时一开始只需要对它是什么有个基本认识就好了,后续随着学习过程的深入自然会对此问题有越来越深入精确的认识。
搞清楚为什么要创造出这么一个东西?这个东西的作用是什么?它可以用来解决什么问题?
对于某些东西来说要搞清楚此问题并不简单,特别是一些源于数学的抽象概念和方法。
学会如何用这个东西。一些基础小东西会相对较为单纯,其使用方法也自然很简单。然而很多时候一个东西会提供若干不同功能来满足不同需求与解决不同问题,各功能都会有自己不同的使用方法。且达到同样目的也可以选择不同优劣有异的功能组合。
一般而言一个东西的复杂度很大程度上取决于其提供功能的多样性。相对比较简单纯粹的东西就只有会用和不会用两种状态,而更多复杂的东西则存在连续的中间状态。
这一步通常会是一个逐步深化、逐步探索的过程,开始只会用其最基本的功能,随着使用的深入会发掘出越来越多的使用方法及功能来。
即学习其内部实现原理,这一阶段也就是学习如何造轮子的阶段,由浅到深可以继续分为三步:
一般而言,一个相对较复杂的技术及概念都是基于一系列更基础的技术及概念组合而成的,要充分理解其实现原理就要先理解其用到的各种底层技术或概念。
实现原理与此东西的功能及使用方法是密切相关的,内部实现是为了支撑其外部功能,在不知道其功能与使用方法时是很难理解其实现原理的。
在这一点上是很容易走弯路的,就像大学的很多课程为什么会感觉无用和难学就是因为这些课程的设计不是从应用出发自顶向下而是从原理出发自底向上的。根据我个人的经验,自底向上的学习方式并不是完全不可行,然而学习过程会很痛苦和迷茫,往往也会事倍功半。从应用出发自顶向下的学习路线相对会自然很多,先会用,再去研究它是怎么工作的,这更加的符合人类认识事物的规律。
当然也不是说非要精通其使用方法后再去研究其实现原理,二者其实是一个相辅相成相互促进的关系,会用了再去研究其内部实现会自然很多;理解了其内部实现后会有助于更好的去应用。
这一阶段主要是在一个更大的框架下来思考理解这个东西,以达到融会贯通的目的。可以从两个维度来入手。
一般来说解决一个问题的方法都不止一个,因此可以进一步深入思考下这些问题:
人类发展至今基本所有东西都是渐进式发展的,没有太多东西是全新发展出来的,因此可以从时间的维度上来进行下思考:
使用 PoSpace 空间证明而非 PoW 工作量证明是 Chia 项目宣称的最大优点。据他们的开发者宣称,PoW 耗费太多能源了,不环保,我们来搞点更环保的东西吧,不用 PoW 了,改用 PoSpace,即谁有的硬盘空间多谁的投票权就更大。因此他们还把通常称为白皮书(White Paper)的文档改名叫做绿皮书(Green Paper)。
然而仔细想想这哪里环保了,把一堆硬盘搞来塞满毫无意义的数据比比特币矿机还要更邪恶吧……Chia 的官网上还可笑的宣称硬盘更不容易被垄断,因此个人还有小玩家可以更好的入场,简直是更荒谬的说法,哪个个人会去囤积一堆存不了有用数据的硬盘?
IPFS 好歹还可以存一些实际有用的数据,看起来还真能促进下社会发展,至于 Chia 简直是除了圈钱和泡沫看不到任何其他意义。不过抛开实际意义,由于最近也研究了下 Chia 的文档和代码,就单纯的来和大家分享下 Chia 的技术实现吧。
Chia 的整体架构图如下:
整个系统中主要有 3 种类型的参与者:
绝大部分参与者都是农民 Farmer,如何成为农民也很简单,直接去下载个打包好的客户端运行就好了。
至于为什么叫 Farmer 呢?当然是为了凸显 Chia 的绿色环保喽,我们不是在浪费能源的挖矿,我们是在环保的种田!
Farmer 的工作也很简单,基本就是两步:
Farmer 生成的 Plots file(P 盘文件) 可能会分布在很多台机器上,因此需要在这些机器上都部署上用来支持摸奖的服务,这个服务就被称为 Harvester 收割机。Farmer 接收到来自 TimeLord 的 Challenge(质询) 后,会将此 Challenge 转发到所连接的所有 Harvester 上。
Plotting 的目的是在磁盘上生成一大堆 Plots file,根据其实现代码,这一过程可分为 4 步:
最后,若最终路径(--final_dir
)与临时文件 2 的路径(--tmp2_dir
)是一样的,简单的把临时文件 2 做个 Rename 重命名即可;否则做一次数据拷贝生成最终 Plots file。
此处之所以要 Copy 或 Rename 一下而不是直接把临时文件 2 作为最终文件的主要原因是为了分离临时文件及最终文件的存储位置。
根据 Chia 的设计,Plots file 越多越好,因此显然要把它们存放在廉价的大容量存储系统,如本地机械盘或云端的低价存储中。然而此类系统通常随机读写能力不佳,甚至是直接不支持随机写入。可在生成临时文件 2 时是需要随机写入的,且写入的 IOPS 对生成文件的速度有显著影响,因此在通常实践中,会把临时文件写到 SSD 上。
Plotting 的逻辑是由 DiskPlotter
这个类来实现的。
Farming 的过程就是对一系列 Challenges 的证明响应,每一轮证明过程都是以一个 256 bit 的 Challenge 为输入,输出是一个 PoSpace 结构,其中包括 Plots file 的公钥,Pool 的公钥, Proof 结果等。其中最重要的就是 Proof 结果。
生成一个区块的过程中会产生 64 次 Challenges,这一过程在客户端 Farming 的界面中可以看到:
每一个这样的点被称为一个 Signage Point。
为减少 IO 次数及所需网络带宽,目前的实现中采用了一种类似于预筛选的方法,先用较少次数的读计算出一个 Quality 值,并根据特定算法评估此 Quality 对于当前 Signage Point 来说够不够好,如果够好的话再去获取完整 Proof 结果。获取 Proof & Quality 的过程是由 Harvester 完成的,评估 Quality 质量则是 Farmer 的工作。
Harvester 负责管理某台机器上的所有 Plots file,并接收来自 Farmer 的 Challenge,返回每个 Plots file 的 PoSpace 及 PoSpace Quality。
这部分代码是由 DiskProver
类实现的。
Challenge 的高 $k$ bit 表示 f7 要满足的一些性质,通过对 C1, C2, C3 表的一通查询最终可以确定 Table 7 中有几项满足要求,可能有若干项满足要求,也可能一项都没有,平均期望是存在 1 项满足要求。
这里有几项满足要求就意味着这个 Plots file 中存在多少个最终的 Proof 证明结果。
之后的查找过程示意如下:
不过要注意的是,从 Table 7 中的一项表项只能找到 Table 6 中对应的一项,这样依次找下去就可以得到 Table 1 中的 32 项,每项是由 2 个 $k$ bit 的整数构成的,因此最终结果就有 $k*64$ bit。对这 64 个数进行重新排序(排序规则是由 PoST 算法决定的),最终就可以生成一个长长的字符串,这就是 PoSpace 的证明结果。
至于 Quality 是怎么来的呢?Challenge 最低 5 bit 的含义是 Table 6 ~ Table 2 在生成 Quality 时应该选择左边的值还是右边的值,按此规则进行选取后 Table 7 中的一项就会对应得到 Table 1 中的一项,即 2 个整数。将 Challenge 与这两个整数简单的二进制连起来,并计算 SHA-256,得到的结果就是 Quality 值。
Timelord 一般被翻译为时间领主,它负责向 Farmer 发起质询(Challenge)并计算 VDF,计算完成后打包成新的区块,实际上整个 Chia 链中的区块都是由 TimeLord 计算生成的。TimeLord 最终决定了哪个 Farmer 的某个 Plots file 赢得了当前区块,即摸中奖了可以获得 XCH 奖励。
那如何保证 TimeLord 是公平的而不是邪恶的始终选取自己的 plots file 呢?这就是由 Chia 的 PoST 算法决定的了。离 Challenge 越近(越优)的 PoSpace 会使得 TimeLord 计算 VDF 的速度越快。系统中不止有一个 TimeLord,而是有很多 TimeLord 在互相竞争,哪个 TimeLoad 先计算完成 VDF 成功打包区块,那整个链就会沿此区块继续延伸,其他在计算同一高度区块的 TimeLord 就会失败。
此过程与传统 Bitcoin 的运行模式基本一模一样,可以猜想,对于分支情况的处理也应该和比特币基本相同。然而一个显著区别是,Bitcoin 奖励的是矿工,即最终成功生成新区块的参与者,而在 Chia 中,TimeLord 是没有任何奖励的,完全是自愿劳动:) 被奖励的是 Farmer。
那无偿劳动为啥有人来干呢?根据某个 Chia 核心开发人员的说法是,当 TimeLord 好处多多,大家都会争着来干的,最显著的好处是,自己部署一个 TimeLord 与自己的 Harvester 离得近网络延迟小,避免自己由于网络延迟太大而成为炮灰。即由于网络延迟导致自己的 PoSpace 很久之后才被送到某个遥远的 TimeLord 上,导致根本没有机会被打包到区块中,即使自己的 PoSpace 比其他人更优。
TimeLord 是 CPU 密集型任务,目前的开源实现强制要求运行平台支持 AVX512-IFMA 指令集。如果某个 TimeLord 的运行速度能压倒性的快于其他 TimeLord,那它理论上是可以凭借算力而非磁盘空间来控制整个链的,因此按照 Chia 开发者的说法,要把运行得最快的 TimeLord 算法开源出来,而且使得 ASIC 的运算速度没法超过通用 CPU,这样才能避免邪恶 TimeLord 的出现。
Full Node 的作用是广播中转各种消息,创建区块,保存和维护历史区块,与系统的其他参与者通信等。不同参与者之间的通信就是靠 Full Node 来完成的。
Full Node 间的一致性使用的是与比特币一样的 Gossip 协议。
算法部分没有仔细研究,此处更多的是给出一些深入研究的链接。
Chia 最重要的算法当然要数 PoST 算法了,PoST 算法是由两部分构成的,PoSpace + VDF。
为什么只有 PoSpace 是不够的还需要 VDF 呢?因为整个区块链网络是个 P2P 网络,产生一个 Challenge 后需要去收集所有 Farmer 的 Proof,区块链设计的核心哲学就是没有邪恶的中心节点,那怎么确定哪个 Proof 是最优的呢?如果这个判断进行得很快,比如简单的比比差值,那所有的 TimeLoad 都可以马上宣称某个 Proof 为最优,此时区块如何增长就完全不可控了,所以 PoT 也是必不可少的。需要通过计算 VDF 的过程让全网能够就哪个 Proof 是最优的达成共识。
一致性协议中主要介绍的是链的延伸过程,在 Chia 的绿皮书中对此有说明,不过目前有一份更新的 Google Doc:
所有区块链技术的最底层基石都是密码学,特别是各种数字签名技术。Chia 中签名用的算法是 BLS12-381。
BLS 算法是 2003 年由斯坦福大学的 Dan Boneh,Ben Lynn 以及 Hovav Shacham 提出的一种基于 ECC 的数字签名算法,和 ECDSA 的用处是一样的。该方案是一个基于双线性映射且具有唯一确定性的签名方案。BLS的主要思想是待签名的消息散列到一个椭圆曲线上的一个点,并利用双线性映射 e 函数的交换性质,在不泄露私钥的情况下,验证签名。BLS的算法在签名合并,多签,m/n 多签有丰富的应用。
而 BLS12-381 则一种具体的 BLS 签名算法,此算法由 Sean Bowe 于 2017 年提出,最早被用于一个叫 Zcash 的数字货币项目中,现在不少其他区块链项目也用了此算法。
在 Chia 的实现中需要用到不止一对密钥,比如钱包的密钥,Farmer 用的农民密钥等。这些密钥不是独立的,而是由一个主私钥通过私钥派生算法得到的,对于 BLS12-381 算法来说怎么生成这些密钥可以参考这个:
EIP-2333: BLS12-381 Key Generation
至于主私钥怎么来的呢,第一次启动 Chia 客户端时会创建一个由 24 个单词组成的助记词,这些助记词就是用来生成主私钥的。
Plotting 时会生成一个随机主私钥,通过它可以派生出一个本地私钥,这个本地私钥又可以导出一个本地公钥,最终,本地公钥与农民公钥(Farmer Public Key)融合,生成了绘图公钥(Plot Public Key),最后矿池公钥(Pool Public Key)和绘图公钥(Plot Public Key)会被组合到一起,并进行一次哈希,哈希的结果被称为绘图 ID(Plot ID)。
上述提及的绘图 ID,随机主私钥,农民公钥与矿池公钥均会被记录到 Plots file 的 Header 中。
生成区块时,需要用与 Plot file 匹配的矿池私钥(Pool Private Key)进行一次签名。
Chia 的业务逻辑,网络,一致性算法等是用 Python 写的,即 chia-blockchain 这个项目。这个项目也被视为 Chia 的主项目在 GitHub 上获得了最多的 Star。最终各平台上能运行的完整的程序也是在这个项目中发布 Release 版本的。
至于 GUI 部分是基于 Electron 开发的,对应项目为 chia-blockchain-gui。
核心的 PoST 算法则是 C++ 写的,分为两个项目:
Chia 中使用的 BLS12-381 数字签名算法的实现为:bls-signatures
此外 Chia 还开发了一个叫 Chialisp 的智能合约语言,相关项目有:
]]>参考资料:
volatile
关键字,std::atomic
变量及手动插入内存屏障指令(Memory Barrier)均是为了避免内存访问过程中出现一些不符合预期的行为。这三者的作用有些相似之处,不过显然它们并不相同,本文就将对这三者的应用场景做一总结。这三者应用场景的区别可以用一张表来概括:
volatile | Memory Barrier | atomic | |
---|---|---|---|
抑制编译器重排 | Yes | Yes | Yes |
抑制编译器优化 | Yes | No | Yes |
抑制 CPU 乱序 | No | Yes | Yes |
保证访问原子性 | No | No | Yes |
下面来具体看一下每一条。
所谓编译器重排,这里是指编译器在生成目标代码的过程中交换没有依赖关系的内存访问顺序的行为。
比如以下代码:
1 | *p_a = a; |
编译器不保证在最终生成的汇编代码中对 p_a
内存的写入在对 p_b
内存的读取之前。
如果这个顺序是有意义的,就需要用一些手段来保证编译器不会进行错误的优化。具体来说可以通过以下三种方式来实现:
volatile
的,C++ 标准保证对 volatile
变量间的访问编译器不会进行重排,不过仅仅是 volatile
变量之间, volatile
变量和其他变量间还是有可能会重排的;atomic
的, 与 volatile
类似,C++ 标准也保证 atomic
变量间的访问编译器不会进行重排。不过 C++ 中不存在所谓的 “atomic pointer” 这种东西,如果需要对某个确定的地址进行 atomic 操作,需要靠一些技巧性的手段来实现,比如在那个地址上进行 placement new 操作强制生成一个 atomic
等;此处的编译器优化特指编译器不生成其认为无意义的内存访问代码的优化行为,比如如下代码:
1 | void f() { |
在较高优化级别下对变量 a
的内存访问基本都会被优化掉,f()
生成的汇编代码和一个空函数基本差不多。然而如果对 a
循环若干次的内存访问是有意义的,则需要做一些修改来抑制编译器的此优化行为。可以把对应变量声明为 volatile
或 atomic
的来实现此目的,C++ 标准保证对 volatile
或 atomic
内存的访问肯定会发生,不会被优化掉。
不过需要注意的是,这时候手动添加内存屏障指令是没有意义的,在上述代码的 for
循环中加入 mfence
指令后,仅仅是让循环没有被优化掉,然而每次循环中对变量 a
的赋值依然会被优化掉,结果就是连续执行了 1000 次 mfence
。
上面说到了编译器重排,那没有了编译器重排内存访问就会严格按照我们代码中的顺序执行了么?非也!现代 CPU 中的诸多特性均会影响这一行为。对于不同架构的 CPU 来说,其保证的内存存储模型是不一样的,比如 x86_64 就是所谓的 TSO(完全存储定序)模型,而很多 ARM 则是 RMO(宽松存储模型)。再加上多核间 Cache 一致性问题,多线程编程时会面临更多的挑战。
为了解决这些问题,从根本上来说只有通过插入所谓的 Memory Barrier 内存屏障指令来解决,这些指令会使得 CPU 保证特定的内存访问序及内存写入操作在多核间的可见性。然而由于不同处理器架构间的内存模型和具体 Memory Barrier 指令均不相同,需要在什么位置添加哪条指令并不具有通用性,因此 C++ 11 在此基础上做了一层抽象,引入了 atomic
类型及 Memory Order 的概念,有助于写出更通用的代码。从本质上看就是靠编译器来根据代码中指定的高层次 Memory Order 来自动选择是否需要插入特定处理器架构上低层次的内存屏障指令。
关于 Memory Order,内存模型,内存屏障等东西的原理和具体使用方法网上已经有很多写得不错的文章了,可以参考文末的几篇参考资料。
所谓访问原子性就是 Read,Write 操作是否存在中间状态,具体如何实现原子性的访问与处理器指令集有很大关系,如果处理器本身就支持某些原子操作指令,如 Atomic Store, Atomic Load,Atomic Fetch Add,Atomic Compare And Swap(CAS)等,那只需要在代码生成时选择合适的指令即可,否则需要依赖锁来实现。C++ 中提供的可移植通用方法就是 std::atomic
,volatile
及 Memory Barrier 均与此完全无关。
从上面的比较中可以看出,volatile
,atomic
及 Memory Barrier 的适用范围还是比较好区分的。
atomic
;volatile
;atomic
,只有当不需要保证原子性,而且很明确要在哪插入内存屏障时才考虑手动插入 Memory Barrier。]]>参考资料:
double free or corruption (fasttop)
,从堆栈看是由于 _dl_fini
函数多次重复释放了某些 STL 容器导致的,此时就算在 main
函数中只保留个简单 return 0
也会出错,因此猜想肯定和某些全局变量有关。后面经过各种修改尝试,终于发现这是由于引用的 .so
动态库和主程序中定义了同名的全局 STL 容器导致的,此时的行为简直就是一个神坑,很有必要记录一下……先说最终结论吧:
.so
及 .a
来说,如果遇到相同的全局变量是不会报错的,此时 GCC 会默默的选择第一个,这种情况连 Warning 都不会有。这与多个 .o
是不同的,在多个 .o
中定义相同的全局变量无法正常链接。class
,那虽然此变量只有一个内存地址,然而其构造与析构函数会被调用多次,若其中有动态分配的内存,多次 delete
就会导致 double free
异常。_init()
及 _fini()
中添加 hook 函数实现的,构造顺序是链接顺序,析构顺序是其逆序。前文提到的 _dl_fini()
函数应该是 _fini()
的动态库版本。上面这些行为看上去已经很坑了吧,然而这并不是全部……以上行为仅适用于编译时直接指定需要链接库的情况,若是在程序运行过程中使用 dlopen
动态加载 .so
时行为不太一样;若通过 LD_PRELOAD
指定动态库那行为又不一样了……
使用 dlopen
加载时的行为可以简要归纳如下:
dlopen
加载多个动态库,若这些动态库间存在相同的全局符号,则它们之间是有可能相互覆盖的,这取决于 dlopen
的 flag
。若使用 RTLD_GLOBAL
,则后面加载进来的动态库会使用已有的全局符号;若使用 RTLD_LOCAL
,则每个动态库间的符号是独立的。以上很多行为显然应该都不是预期行为的,那如何解决这些问题呢,大概有这些方法:
.so
时加上编译选项 -Wl,-Bsymbolic
,这会强制采用本地的全局变量定义。__attribute__ ((visibility("xxx")))
来控制符号可见性,并通过编译选项 -fvisibility=xxx
来控制默认符号可见性。static
的。最后给出几个简单测试程序,可以对照着理解上面的各种行为。
my_calss.h
:
1 |
|
my_lib1.cc
:
1 |
|
my_lib2.cc
:
1 |
|
app1.cc
:
1 |
|
app2.cc
:
1 |
|
app3.cc
:
1 |
|
Makefile
:
1 | mylib1: my_class.h my_lib1.cc |
测试程序运行结果为:
app1
:
1 | ./app1 |
app2
:
1 | ./app2 |
app3
:
1 | ./app3 |
本文只是一个简单的总结,关于此问题的更多深入讨论可以参考以下文章:
]]>控制共享库的符号可见性 第 1 部分 - 符号可见性简介
浅谈动态库符号的私有化与全局化
linux动态库的种种要点
Linux动态链接库so版本兼容
浅析静态库链接原理
全局符号
Linux下全局符号覆盖问题
What exactly does -Bsymblic do?
-O3
优化级别下,很多局部变量是会被优化掉的,此时只能通过人工分析反汇编代码来获取所需信息,而这么做的前提是保存下来的寄存器中的值是准确的。绝大部分情况下 coredump 是由于 segment fault 或 assert 触发的,segment fault 情况下 Kernel 保存下来的 registers 信息是准确的,GDB 中直接用 info registers
就可以看到。然而若是由 assert 触发,由于 assert 会进行多层函数调用后最终执行 raise()
,错误现场的寄存器信息是不准确的,这时候就需要一些其他手段来解决此问题。下面用一个具体例子来说明此问题。测试程序代码:
1 | volatile int final = 0; |
运行此程序肯定会发生 assert failed,我们用 gdb 来看下调用栈:
1 | Program terminated with signal SIGABRT, Aborted. |
切换到 fun()
的栈帧:
1 | gef> f 4 |
可以看到 a
与 b
都被优化掉了,到底是哪个值触发了 assert 就不能直接确定了。当然并不是就彻底没办法知道了,来看下 fun()
函数的反汇编:
1 | gef> disassemble |
在 -O3
优化下 fun()
直接被内联到 main()
里面了,不过这不影响基本分析,重点关注 <+16>
~ <+32>
这几行,这就对应 fun()
的前几行逻辑,if (b > 0)
是通过 test
+ jg
来实现的,b
的值此时就是 %esi
寄存器中的值。看下 gdb 分析出来的当前栈帧的寄存器值:
1 | gef> info registers |
是不是其中 %rsi
的值就是我们需要的 b
了呢?非也!注意到 <+68>
行,在调用 __assert_fail()
前 %esi
又被重新赋值用于传递参数了,且由于 %esi
属于 caller save 的寄存器,在 __assert_fail()
内有可能会被再次改写。因此 使用 GDB 分析 coredump 文件不同栈帧的 register 信息时,只有为数不多的几个 callee save 寄存器的值是可靠的,其他的都是不可靠的。 那如何才能得到可靠的寄存器值呢?一般来说只有靠我们自己保存了,一个简单思路是只要在调用 __assert_fail()
前把所有寄存器的值保存到一个全局数组中就可以了。
在 assert()
前添加如下一段内联汇编代码即可实现此目的:
1 | __asm__ __volatile__("movq $0, %%r15;\n\t" |
再来看下此时的反汇编代码:
1 | gef> disassemble |
<+59>
~ <+180>
行就是我们新加的逻辑,可以看到这段代码紧接在 <+32>
行之后,理论上分析的确是可以保存准确的寄存器信息。来看下实际效果:
1 | p registers_data |
registers_data[4]
与 final
的值完全相同,而从源代码和反汇编 <+26>
行可以看到,final
中保存的就是 b
的真实值。
]]>
perf
是 Linux 下重要的性能分析工具,perf
可以通过采样获取很多性能指标,其中最常用的是获取 CPU Cycles,即程序各部分代码运行所需的时间,进而确定性能瓶颈在哪。不过在实际使用过程中发现,简单的使用perf record -g
获取到的调用栈是有问题的,存在大量 [Unknown]
函数,从 perf report
的结果来看这些部分对应地址大部分都是非法地址,且生成的火焰图中存在很多明显与代码矛盾的调用关系。最初怀疑是优化级别的问题,然而尝试使用 Og
或 O0
优化依然存在此问题,仔细阅读 perf record
的手册后发现,perf
同时支持 3 种栈回溯方式:fp
, dwarf
, lbr
,可以通过 --call-graph
参数指定,而 -g
就相当于 --call-graph fp
.
fp
就是 Frame Pointer,即 x86 中的 EBP
寄存器,fp
指向当前栈帧栈底地址,此地址保存着上一栈帧的 EBP
值,具体可参考此文章的介绍,根据 fp
就可以逐级回溯调用栈。然而这一特性是会被优化掉的,而且这还是 GCC 的默认行为,在不手动指定 -fno-omit-frame-pointer
时默认都会进行此优化,此时 EBP
被当作一般的通用寄存器使用,以此为依据进行栈回溯显然是错误的。不过尝试指定 -fno-omit-frame-pointer
后依然没法获取到正确的调用栈,根据 GCC 手册的说明,指定了此选项后也并不保证所有函数调用都会使用 fp
…… 看来只有放弃使用 fp
进行回溯了。
dwarf
是一种调试文件格式,GCC 编译时附加的 -g
参数生成的就是 dwarf
格式的调试信息,其中包括了栈回溯所需的全部信息,使用 libunwind
即可展开这些信息。dwarf
的进一步介绍可参考 “关于DWARF”,值得一提的是,GDB 进行栈回溯时使用的正是 dwarf
调试信息。实际测试表明使用 dwarf
可以很好的获取到准确的调用栈。
最后 perf
还支持通过 lbr
获取调用栈,lbr
即 Last Branch Records,是较新的 Intel CPU 中提供的一组硬件寄存器,其作用是记录之前若干次分支跳转的地址,主要目的就是用来支持 perf
这类性能分析工具,其详细说明可参考 “An introduction to last branch records” & “Advanced usage of last branch records”。此方法是性能与准确性最高的手段,然而它存在一个很大的局限性,由于硬件 Ring Buffer 寄存器的大小是有限的,lbr
能记录的栈深度也是有限的,具体值取决于特定 CPU 实现,一般就是 32 层,若超过此限制会得到错误的调用栈。
实际测试下以上 3 种栈回溯方式得到的结果,测试程序是一个调用深度为 50 的简单程序,从 f0()
依次调用至 f50()
。
--call-graph fp
:
--call-graph lbr
:
--call-graph dwarf
:
可以看到,的确只有 dwarf
获取到了正确的调用栈。
优点 | 缺点 | |
---|---|---|
fp | None | 1. 默认 fp 被优化掉了根本不可用。 |
lbr | 1. 高效准确 | 1. 需要较新的 Intel CPU 才有此功能;2. 能记录的调用栈深度有限。 |
dwarf | 1. 准确 | 1. 开销相对较大;2. 需要编译时附加了调试信息。 |
]]>参考资料:
Detected Hardware Unit Hang
的错误提示。Google一下这个错误提示,还是有不少类似问题的:
Proxmox: enp0s31f6: Detected Hardware Unit Hang
解决FreeNAS under KVM使用Virtio网卡导致宿主机网卡Hang的问题
e1000e Reset adapter unexpectedly / Detected Hardware Unit Hang
基本所有文章都提到此问题与TCP checksum offload
特性有关,解决方案就是关掉checksum offload
。具体方法是使用ethtool
工具:
1 | ethtool -K enp0s25 tx off rx off |
如果要重启后永久生效的话将此命令写入/etc/network/if-up.d/ethtool2
文件中并为此文件加上x
权限即可:
1 |
|
除此之外上述第2篇文章的情况和我遇到的很像,里面提到这与Virtio
虚拟化有很大关系,而我使用的也正是Vritio
,根据作者的说法,更应该在OpenWRT而不是Proxmox中关闭checksum offload
。然而实际试了下却发现一个蛋疼的问题,OpenWRT中是无法把tx checksum offload
给关掉的……
此外作者还提到,将网卡的虚拟化方式从Virtio
改为E1000
也可以解决此问题,不过会有CPU占用率上升的副作用。
综合以上几种方法,我最后采用的解决办法是:禁用Proxmox宿主机上的TCP checksum offload
,并将OpenWRT使用的网卡虚拟化方式改为E1000
。实际测试下来没有再发生网卡hang的问题,满速率下载(250Mbps左右)时CPU占用率50%左右,比之前使用Virtio
时CPU占用率要高10%左右,还是可以接受的。
问题算是解决了,最后顺带去进一步学习了下相关的知识,首先是TCP checksum offload
,此技术的作用是将计算TCP checksum的工作由CPU软件实现改为由NIC设备(即网卡等)硬件实现,以此达到节约CPU资源的目的。
另外就是Virtio
与E1000
,这是两种不同的网络虚拟化技术,Virtio
是半虚拟化而E1000
是全虚拟化。对于全虚拟化方案来说,虚拟机是完全感知不到自己是运行在一个虚拟环境中的;而半虚拟化则是虚拟机知道自己就是运行在一个虚拟环境中,此时IO驱动就可以做一些针对性的修改优化,以此降低虚拟化层进行转换带来的开销及性能损失。显而易见,半虚拟化技术的隔离度是没有全虚拟化好的,而且要是虚拟机驱动有问题会导致宿主机也出问题。这就是为什么在使用Virtio
时,OpenWRT网络出现问题会导致整个Proxmox的网络都不能用了的原因。除了这两种虚拟化方式外,还有些更为先进的虚拟化技术,如SR-IVO
等,有兴趣的话可以看看下面这篇文章的总结:
]]>
为纪念下我离Academy最近的时刻,这里把我这篇论文的摘要及pdf版本的全文贴一下吧。
全文下载链接:用于嵌入式车载安全预警的交通标志检测若干关键技术研究与验证
论文摘要:
车载安全预警系统可及时为驾驶员提供必要的行车安全预警信息以提高驾驶安全性,其包含若干子系统,如交通标志识别、超速预警等,而交通标志检测则是支撑诸多子系统的重要基础技术之一;本文就针对交通标志检测中基于颜色分割的定位算法及多线程任务调度策略这两项关键技术进行了研究,提出了适用于性能有限嵌入式系统的混合颜色分割策略及混合切换任务调度策略,并通过搭建嵌入式原型样机在实际道路环境中验证了方法的有效性。此外为更好的验证及评估交通标志检测算法的效果,本文建立了中国道路交通标志视频数据集,并将此数据集公开发布以供其他研究人员使用,这也是此领域目前唯一的中国公开数据集。目前主流成熟的交通标志检测定位方法基本均是基于颜色及几何形状局部特征的,本文在此框架下对用于车载安全预警的交通标志检测中最为重要的红色及黄色分割方法展开了深入研究,针对已有主流颜色分割方法的不足提出了混合颜色分割策略,此策略通过若干线性分类器的组合实现了对红色及黄色准确高效的分割,分割效果优于目前常用的各方法且其算法执行速度与最简单的RGB阈值法相似,可保证安全预警算法在性能有限的小型嵌入式车载设备上依然有较好的实时性;在颜色分割基础上本文采用经典的Hough变换实现了对红色圆形交通标志的检测定位并在数据集上评估了算法的效果。本文通过对交通标志检测识别问题进行建模分析提出可用采样间隔时间作为定量衡量此类系统实时性的指标,进而针对目前广泛使用的多核CPU提出了理论最优的理想多线程任务调度算法,此算法可显著降低采样间隔时间以提高系统实时性;不过理想任务调度算法实际无法实现,因此本文进一步提出了实际可实现的混合切换任务调度策略及动态更新参数估计策略;通过控制系统模型数值仿真及实际嵌入式原型样机上的测试验证均表明本文提出的方法可有效优化采样间隔时间分布以此提高系统实时性。本文同时开发了基于Qt的算法验证平台软件及基于Intel Joule模块的嵌入式原型样机,并在其上验证了上述各方法的有效性,最后在校园环境及城市道路上分别进行了静态及动态系统集成测试;测试结果表明本文提出的方法可在小型嵌入式设备上满足系统实时性要求,在天气光照条件较好时检出率也相对较高,不过算法鲁棒性依然需要加强。
]]>hello-world
,这个image运行起来的效果也很简单直接,仅仅是在屏幕上输出一段Docker的使用说明就结束了。这个镜像虽然简单,然而仔细分析下还是涉及不少底层机制的。我之所以会对这个镜像感兴趣,是发现它的大小仅仅只有1.84kB,这实在是太小了,写一个printf("Hello Wolrd\n");
的程序编译出来大小就远超1.84kB了,所以很好奇这个镜像是如何构建出来的。
Docker的镜像构建过程是由其镜像描述文件Dockerfile决定的,所以就先找到其Dockerfile来看看。hello-world
用于AMD64
架构的Dockerfile可以在Github上找到,只有简单的3行:
1 | FROM scratch |
第1行导入了一个名为scratch
的东西,这并不是一个真正的image,可以把它视为是所有image的最底层虚拟镜像,类似于一个基本抽象类,Docker官方对其的说明如下:
This image is most useful in the context of building base images (such as
debian
andbusybox
) or super minimal images (that contain only a single binary and whatever it requires, such ashello-world
).As of Docker 1.5.0 (specifically,
docker/docker#8827
),FROM scratch
is a no-op in theDockerfile
, and will not create an extra layer in your image (so a previously 2-layer image will be a 1-layer image instead).……
You can use Docker’s reserved, minimal image,
scratch
, as a starting point for building containers. Using thescratch
“image” signals to the build process that you want the next command in theDockerfile
to be the first filesystem layer in your image.
后面两行的含义也很直接,把一个名为hello的程序copy到根目录下,在运行image的时候运行此程序。下面就来看下这个如此小的hello world程序是如何实现的。
hello.c文件的源码也在同一个Github仓库中,省略掉过长的字符串常量后很简单:
1 |
|
这个最简版本的Hello World和C语言教科书中第一个Hello World是有不小差别的。首先是程序入口点上,众所周知正常C/C++程序的入口点是main()
,然而这里使用的是_start()
。
我们的程序是运行在Linux系统上的,程序的加载与运行必然是由OS发起的,对于Linux来说,OS层面的程序入口点就是_start()
而不是main()
函数,一个程序要能正常运行在main()
之前是有一些准备工作要做的,比如建立程序运行环境(初始化.bss全局变量等);在main()
返回之后也有些收尾工作要处理,比如调用exit()
通知系统等。这些工作正常情况下是由语言标准库来完成的,也就是所谓的Runtime运行环境,对于C语言来说就是crt0.o
。大部分程序的_start()
就位于其中,在建立好运行环境后_start()
会调用main()
跳转到用户定义的入口点处。当main()
返回后程序又将回到ctr0.o
中,最终调用exit()
通知OS回收进程资源。
这里为了缩小程序体积和简单起见,没有使用标准的ctr0.o
Runtime,事实上这一个简单的程序也不需要什么Runtime。程序最后直接通过syscall
函数调用了SYS_exit
系统调用结束了自身的运行。
将字符串输出到屏幕上也没有使用标准库中的printf()
,同样是直接调用了SYS_write
这个系统调用,其第一个参数显式的写为了1,其实就是STDOUT_FILENO
,Linux系统在unistd.h
中定义了stdin
, stdout
, stderr
这几个标准文件描述符。
可以看到,这样一个程序是可以不依赖于任何其他的库在Linux上独立运行的,为了实现不链接C标准库的目的,需要使用一些特殊的编译选项。从编译这个hello-world
程序使用的Makefile中可以找到使用的编译选项为:
1 | CFLAGS := -static -Os -nostartfiles -fno-asynchronous-unwind-tables |
-static
表示静态链接,虽然对这个程序来说无所谓动态链接还是静态链接……-Os
表示为空间进行-O2
级别的优化,专门用于减少目标文件大小;-nostartfiles
是关键编译选项,此选项表示不使用标准C语言运行库(即crt0.o
),也不链接C标准库;-fno-asynchronous-unwind-tables
选项也是用于减少代码空间的,其大概含义是不产生C++异常处理机制中使用的.eh_frame
段,关于什么是unwind-tables
和.eh_frame
是个比这篇文章复杂多了的问题,文末有几篇参考资料,之后有空可以深入学习下C++的底层机制……进行了以上诸多特殊优化处理后,终于可以得到一个只有1k多的可以正常运行于Linux上的Hello World程序了。
]]>参考资料:
What is the use of _start() in C?
When is the gcc flag -nostartfiles used?
sudo
命令是需要输入密码的,连续输入多条sudo
只用输一次密码就行,不过若干分钟后又需要输入密码了。对于自己使用的本地桌面环境来说,其实是可以配置成sudo
免输入密码的,这样可以减少一些麻烦。以Ubuntu 18.04
为例说明设置方法,其他发行版可能会有区别。Ubuntu Desktop
默认已经将安装系统时配置的用户加入了admin
用户组,且admin
用户组中的用户都是有sudo
权限的,因此无需修改sudo
用户组。若需要将某用户添加到sudo
用户组中,可参考文末链接。
输入su -
命令切换到root
下,修改/etc/sudoers
文件,找到:
1 | # Allow members of group sudo to execute any command |
修改为:
1 | # Allow members of group sudo to execute any command |
即可。
这样就可以允许sudo
用户组中的用户免密码执行sudo
命令了。
]]>参考资料:
我使用的主题是基于Yelee做了些修改得到的,Yelee又是基于Yilia的,添加Gitment的过程可以参考这篇文章:
Yiila主题也添加了Gitment支持,其Commit也是很有参考价值的。
与以上教程有区别的是,无需安装Gitment npm插件,添加修改的代码我也改了下,有兴趣的话可以看这个Commit。
其中Gitment的CSS & JS文件改为了本地压缩后的版本,评论框的显示效果也调整了下。
终于评论系统又可以用啦~
Update:
2021-06-12: Gitment 的 Github OAuth 是依赖于外部服务器的,目前公共的挂得差不多了,需要自己搭一个,参考下文:
Update at 2022-05-21:
gitment 需要一个中间服务器的原因在于 Github OAuth Response 是不支持 CORS 的,因此若直接由浏览器发起请求会被拒绝。Github 的文档中也明确指出由前端直接发起 OAuth 请求的方式是不被支持的。
所以需要一个代理服务,即上文提到的 gh-oauth-server。之前是找了台云服务器来做这个事,然而对于这种一天就没几次请求的业务来说,单独用一台服务器来部署实在是太浪费了,更好的方法是使用 Serverless 服务来做这个事。
因此又对 Gitment 评论系统做了些升级改造:
假设系统中有3个文件:
file1.c
:
1 | int array[] = {1, 2, 3}; |
header1.h
:
1 | extern int array[]; |
main.c
:
1 |
|
在main.c
中期望通过sizeof
运算符获取array
中元素个数,然而这么做是错误的,编译时无法通过,错误提示类似incomplete type not allowed
这类。
造成这一问题的原因在于,**sizeof
是在编译时计算的,而C/C++的编译是以文件为基本单位的**。在编译main.c
文件时,编译器是不可能知道定义在file1.c
文件中array
数组具体信息的,只根据header1.h
文件中的声明是无法确定array
的具体大小的,因此,就算某些编译器编译时不报错,得到的结果也是不正确的。
分析清楚原因后来看下解决方案,基本解决方法有4种:
array
数组的同一个文件中;'\0'
一样,这样就可以在运行阶段动态确定数组大小;这几种方法都有其缺点:
sizeof
就是不想固定数组长度,因为使用宏定义固定数组长度不够灵活,要是想添加数组元素也要同时修改宏定义,否则尽管编译不会报错,然而运行时新添加的元素其实是无效的,这会导致将来维护时一些潜在Bug发生的可能性增加;static
全局变量的原因就是多个源文件需要使用这个变量,这时显然无法做到这一点,多次重复定义链接时会出错的。实际使用中,需要根据具体问题具体分析采用哪种方法最恰当,一般而言不经常变化的数组就使用宏定义确定其大小,会经常变化的第2种方法最常用,此时还可以用一些宏定义简化编程,以上代码可修改为:
file1.c
:
1 |
|
header1.h
:
1 |
|
main.c
:
1 |
|
]]>参考资料:
comp.lang.c FAQ list · Question 1.24
C: How to determine sizeof(array) / sizeof(struct) for external array?
自从找工作拿到几个Offer可以选择时就开始各种纠结与困惑了,大疆、阿里、Intel、华为、拼多多、乐鑫、网易……有幸能拿到这么些优秀公司的Offer,然而每一家公司都同时有吸引我和令我踌躇的地方,鱼和熊掌终不可兼得,选择也变得十分困难。虽然最终选择了大疆,然而这一选择并不是那么顺理成章,当时在犹豫,本以为选了之后就不会困惑了,现在才发觉,困惑的东西并不会随着时间推移而自然而然的变得清晰起来。
人生有很多选择,选择和努力哪个更重要呢?这个问题的标准答案在准备面试时都背得滚瓜烂熟了,选择与努力互为因果,选择是为了决定之后努力的方向,努力是为了将来能有更多选择。然而,记住了所谓的标准答案并无济于事,该困惑的时候还是一样困惑。
其实想想,所有困惑的根源都来自于两点:不知道自己真正想要的是什么;不知道未来会是怎样。
与其说是不知道自己想要什么,不如说是不知道自己愿意放弃什么,选择之所以困难,是因为选择与放弃总是如影相随的,选择了此就注定要放弃彼。人总是什么都想要的,但事实是我们注定要放弃大多数东西的,人生在不断的做出选择,同时也是在不断放弃。然而,我究竟愿意放弃什么呢?愿意选择什么呢?这并不是那么确定的啊……什么都不想放弃,也就注定什么都无法得到。
上面那点也许还能随着年岁与阅历的增长思考得越来越清楚,那对未来不可知的迷茫更是让人觉得无能为力。生命的精彩源于不可知,生命的痛苦也源于不可知。时代的洪流滚滚前进,顺之者昌逆之者亡,然而时代的车轮碾向何方又有谁知?
可供选的路总是越来越少的,我们都终有一日会无路可选,到那时,认命也罢,不认也罢,是非成败转头空,唯余夕阳照青山。在我们还有得可选的时候,还是多想想吧,就算是一条咸鱼也还是要挣扎下看看的。虽然路最终总是越走越窄的,还是要努力下让它窄得不要那么快吧,毕竟啊,谁又能说自己走的一定是那条自己想要同时又不会被时代湮没的道路呢?
瞎扯了这么多似乎还是多想清楚了那么一丝东西吧,脚踏实地亦要仰望星空,不要让天天加班和生活琐事的忙碌成为一种错觉蒙蔽了双眼。自己的未来何在,尽管想不清还是要去找的吧,在坚信自己找到之前,努力让未来的路宽广一些,努力让自己不要失去有选择的能力,虽然选择是困难和纠结的,然而没选择的走投无路是更大的悲哀。
然而,要维持像学校里那样站在四通八达的十字路口近乎是不可能完成的事,两条路经常是越来越远的,刚开始时尚有可能跳过去,越到后面越难跳过去了吧。所以啊,还是要尽快想清楚自己想去哪条路上才行啊,然而,谁知道哪时候能想清楚呢……不过在想清楚自己要跳去哪条路上之前,还是要多练练自己跳跃的能力,培养些通用的技能,让自己还是有路可跳有路可选吧。
]]>