谈笑风生间 2024-01-01T08:40:17.517Z https://makonike.github.io/ 谈笑风生间 Hexo 55200647293087780 62108700386381824 2023 年度总结:成长、探索与沟通 https://makonike.github.io/2023/12/28/2023%E5%B9%B4%E5%BA%A6%E6%80%BB%E7%BB%93%EF%BC%9A%E6%88%90%E9%95%BF%E3%80%81%E6%8E%A2%E7%B4%A2%E4%B8%8E%E6%B2%9F%E9%80%9A/ 2023-12-28T15:17:47.000Z 2024-01-01T08:40:17.517Z 起笔写这篇文章时,距离我校招提前实习已经过去一个月。早在秋招结束时,我就计划着撰写一篇类似的总结文章,但由于一时懒散,最终只是写了一些零散的片段,凑不成一篇完整的文章。因此,在这篇年度总结中,我想稍微记录一下我对秋招的感受。

回顾 2023 年,许多在 2022 年设定的目标都未能实现,包括深入学习 Kubernetes 和掌握 Rust 等。在五月、六月以及秋招后至实习入职期间,感觉自己有些散漫,代码能力也没有显著提升。

幸运的是,我在三月份成功找到了实习机会,度过了五个月的杭漂生活。尽管在后续的秋招中遇到了一些波折,但也有不少收获。整个一年中,我经历了许多事情,走过了许多未曾到过的地方,还有幸与许多素未谋面但一直保持联系的网友线下见面。总体来说,过得还算不错。

我在去年十一月初踏上了求职的征程,第一场面试虽以失败告终,但至少迈出了第一步。到了十二月份,陆续参加了几轮其他小公司的面试,然而最终都未能通过终面。反思的过程中,我逐渐找出了问题所在。在面试中,我发现之前自己琢磨的源码设计难以清晰地表达出来,这引起了我对自身一贯弱点的认识:输出能力较差。在去年的年度总结中,我也提到总结是我的短板。曾经,我未养成在学习时同时输出的习惯,导致很多知识在仔细学过后就随着时间而逐渐遗忘。今年我也在努力改进这方面,但实际上成效并不显著,因为反馈不够明显。直到提前实习期间,在组内做了一次技术分享,并与导师进行了一对一的交流后才恍然大悟。原来技术分享并不一定要非常高深,关键是能够清晰地讲解一个概念,让没有接触过这部分知识的听众也能理解你所分享的内容。在 2023 年的最后一周,我进行了一次新人业务串讲,因为领悟了分享的核心,也提前做足了准备,这次的分享异常顺利,尽管听众都是组里的熟人,但至少没有再磕磕绊绊,没有不知道怎么去表达。

实习&杭漂:走走停停

二月末,我侥幸地收到了杭州城西一家小公司的 offer。当时想着实习六个月到秋招正好是九月份,现在才开始实习好像已经有点赶了,因此就接了去了,也没有再参与接下来的春招实习。回想起来,深感后悔,若当初不那么着急做决定,也许未来的可能性更为广阔。

来之前在脉脉上看到对这家公司的差评很多,但想到脉脉上即便是狗路过也难免被骂两句,于是借此安慰自己。实习的这段日子总的来说并不好受,一方面是资金有限的压力,租房 1450 只能合租到 7 平左右的单间,外卖一顿基本要二三十元,再加上中后期要来回学校参加考试,而作为实习生一个月的工资只有这么微薄,压力显得相当巨大。幸好有豪哥和超哥的支持和鼓励,我才得以挺过来。另一方面,我对实习的内容不太满意,公司整体氛围也不尽如人意。由于公司产品力不足,盈利状况不佳,同事们都承受着巨大的压力。前几天得知我所在的部门已经被遣散,希望曾经的同事们都能够找到更好的去处。

刚入职的那段时间感觉还是比较轻松,但是对实习生的培养几乎没有,我完全找不到自己的主线,也不清楚具体的工作内容。在入职的第一周,我产出了两个 CRUD 的接口,很多代码基本上是照搬的,缺乏实质性的技术力。随后,导师安排了一个接入多云服务商短信的重构需求,我很快就完成了代码编写,也进行了多次自我审查。然而,当请导师进行 code review 时,却硬生生地拖延了一两周,导致我只能在这段时间里自己查看文档和代码进行自学。那时我一直在思考公司为何招聘我这个实习生,既不培养我作为校招苗子,也不是脉脉上所说的招来当短期工抗压 007,这种困惑一直延续到大概六月份。现在回想起来,当时确实有些内耗。

大约在六月初,负责带我的导师和 leader 被调到 AIGC 的部门,由隔壁的 leader 接替带领我所在的部门,而我则被完全放养。正值那段时间接到一个重大需求,同时又到了期末考试月,需要频繁硬卧绿皮往返学校,压力随之而来。当时由于导师被调走导致再也没有人给我派活,于是我尝试向隔壁 leader 要一些活。可能是因为组里人都很忙,也可能是因为隔壁 leader 对我比较信任,于是这个风险较大的大需求就交给了我。我还记得当时隔壁的 leader 问我:“你能当 POC 吗?”,我愣住了,当时我还不知道 POC 是个什么概念,差点答应下来。后来才得知,让一个实习生来担任这种重大需求的 POC,这个风险极大的决定,或许表明当时隔壁 leader 在管理上存在一些问题。

随着大需求的顺利上线,那段充满压力的时光也随之过去,我又回到了放养内耗的时期。在杭州时虽然资金有限,但我还是穷游了一些地方:关于我的三个月杭漂。紧接着陆陆续续有一些应届校招生入职,更没有新的需求分配到我的工作中。直到离职前与隔壁 leader 聊天,我才了解到应届校招生需要更多需求来确保绩效能够挺过试用期。怀揣着实习时的遗憾和不甘,我决定辞去工作,回家准备秋招。

秋招:沉下心,沉住气

辞职回家后,待了将近两周时间,边投简历边笔试,努力调整了自己的状态,恢复了一些元气。九月初回到学校后,我再次开始了面试准备。那段时间其实挺爽的,杭漂五个月后,我愈发珍惜与舍友相处的时光,享受低消费的校园生活,那时候也接触了开源,我也有更充足的时间去追求自己感兴趣的方向。

整个秋招持续了大概三个月,从七月末到十月末,从既期盼又害怕面试的到来,到死猪不怕开水烫狠狠海面。整个人感觉成熟了不少,对自身技术栈、实习产出和项目细节的掌握都更进了一步,更重要的是面试官会充当我的听众,以此来锻炼我的沟通能力。那时候正当牛客小论文流行,感觉非常有意思,就偷了不少发在朋友圈,和认识的一群水友评论区互动,太有意思了。

小作文

实际上,很多人的秋招最终都能够取得不差的结果。前期的抱怨很可能只是因为投递较少、面试机会有限所致。当然,如果面试机会明显不足,可能需要反思自身竞争力是否有待提升,或者所在技术方向的竞争程度较高。此外,努力仍然是至关重要的,整个秋招的关键在于保持沉着冷静的心态,走得慢并不要紧,重要的是要坚持奋斗,每个人的求职进程都有快有慢,找到适合自己的节奏才是最终取得成功的关键。

北漂:进京见世面

北漂了一个月,游览了圆明园、天安门广场、北京动物园和颐和园,还亲眼目睹了北京的初雪,体验到了人生第一场大雪。来北京之前,我无论如何都难以想象“道路结冰”是怎样的场景,也无法体会零下十六度是怎样的寒冷。幸好在北京有暖气,在屋里待着甚至只需穿短袖,在暖气的加持下,室内的温暖相比南方大冬天的时候更为宜人。

在这段时间里,我领略了初雪的美丽,也感受到了北方寒冷的严寒。尽管天气寒冷,但身处温暖的室内,感觉舒适宜人。我还有许多未探索的地方,计划明年再次踏上探险之旅,继续发现这座城市的魅力。

圆明园遗址公园

银杏大道

圆明园

天安门广场

北京动物园

颐和园

由于在技术方向上,以往一直专注于基础架构开发,除了实验室项目外,对业务接触相对较少。而我秋招加入的部门正好是业务方向,因此体验了一波技术到业务的转型。入职一个月以来,小组的氛围非常好,同事们都很实在,组里专门为校招生制定了培养计划,组长和导师也会定期进行一对一的指导,矫正方向并解答疑惑。上手业务开发是一个循序渐进的过程。在入职至今,我已经做了一次技术分享和一次业务串讲,对我个人的正向反馈也很大。总体来说,我的实习生活过得非常舒适。

技术

github 来看中后段(秋招开始后)基本摆了,然后前面也很多是私人 repo,包括学习 Rust 和几个 demo,参与的一些活动的代码等等。

github 热力图

要是拼上 leetcode 的话就差不多了,虽然我刷题也不多。我的 leetcode 从去年十月刷起,全勤大概六个月,然后入职实习后就基本没有再坚持了,直到八月离职参与秋招,又恢复训练了两个月,后面也没再坚持。其实我认为刷题的 ROI 并不高,只要够用就可以了。刷题太多容易产生逃避心理,但在面试中展示代码能力和思维能力还是相当重要的。对于明年,我计划会随缘地继续刷刷,保持一定的状态。

leetcode 热力图

2024 年,我希望延续今年年初立下的 flag,深入学习 Kubernetes 和 Rust,并探索更多业务设计的实践,争取将脑中诸多的想法落地,争取多做技术分享,争取多为开源做贡献。

输出

今年写了包括杂谈的十四篇文章,时间上主要集中在年初和七八月份,内容上主要包括 Golang 常用标准包的解析、一致性知识、容器和网络协议。基本上都是“一文了解”系列,起这个系列的初衷是希望写一些不像网络上同标题大部分文章这么水的技术文章,所以内容上多少会有一些深入,希望不了解/听说过的人在阅读完后能到稍微深入了解这样的程度。emmm 事实上可能也没达到我想要的效果,但也希望读者能多少有些收获。

第一篇是《一文了解事务》,可以说是《DDIA》第七章的阅读笔记,也在学校团队里给师弟分享了一下,但是一讲就讲了很久,以至于讲着讲着都不知道讲到哪里了,反正效果很差。那之后就想着需要更精炼的语言,更简洁的方式去表达我想要表达的东西,于是后续的文章都会有一部分的总结部分,而且文章的模块划分会比较明显。

除了写了几篇技术文章,今年还对去年写过的一些文章进行了重构,包括修复 typo、润色文段、矫正过去的一些错误和加入自己学习过程中的感悟。明年的话,希望能写更多技术文章,兼顾数量和质量,希望能多点画图,减少笔记形式的记录,多转换为技术分享形式。今年后半段属实有点摆了 haha,第一段小厂实习有一段时间干着还挺累人的,那之后也懈怠了不少。

接触开源

契机是学校团队内部使用的就是 Gitea,遇到了一些较于其他代码库稍有不足的地方,刚好和师兄一拍即合,找了一个 issue 就开干,也成就了第一次给 Gitea 贡献代码的经历。非常感谢师兄的鼓励,感谢 lunny 和其他社区伙伴对我拙劣代码能力的包容~ New webhook trigger for receiving Pull Request review requests by Makonike · Pull Request #24481

其后也给 Gitea 提了几个关于 Webhook 的 PR,希望明年也能继续为 Gitea 贡献更多的代码。

下一个接触开源的机会是中科院开源之夏 OSPP,实际上,我个人对这个活动并不太感兴趣。去年我也投递了几个项目,但邮件发出去之后就再也没有收到回应。即使我尝试补发了几封邮件,也没有得到任何回应。更糟糕的是,某些社区以贡献 PR 的数量为 rank score,建立了一个排行榜赛马。基本上,该社区的项目都被排行榜上前列的人包揽了。如果你在排行榜上稍后一些,甚至都没有机会被选中参与这个项目。

尽管对这个活动有所失望,但今年还是借接触开源的机会投了两个项目,一个是傲空间社区的 Golang SDK 开发,另一个是 MOSN 社区基于 MoE 框架为 Envoy 集成 LDAP 认证。刚好那时候五六月份,我在实习时接触了 LDAP 相关的需求,也去了解了一波 LDAP 协议,也很幸运地中选了这个项目,感谢豆浆老师对我的指导和帮助。在我发出邮件后的几天后,两者都发来了邮件回复,我也为傲空间的项目写了一个小的 demo,以及一份相对简单的项目申请书,可惜后来没有中选这个项目。豆浆老师发来的邮件就有些特别,我选择的这个项目因为名额的问题不算入 OSPP 活动中,这意味着即使完成了这个项目,也没法得到 OSPP 的证书和奖金。当时来看,这份奖金额度对我来说还是相当可观的。

豆浆老师的回复

我个人对开源还是保持积极的态度,特别是看了豆浆老师的博文(我与开源的缘分),更是深感认同。他提到的“人人为我,我为人人”,以及分享精神,我认为正是开源精神的核心所在。参与开源项目的人,应该怀揣着助人为乐的精神,而非只追求个人狭利。这是一种开放的协作方式,参与者通常是自愿加入,怀着分享互助的心态共同参与项目的建设。

这种协作方式不仅能够对抗内卷,还能显著提高整个社会的生产效率。至今,我仍然坚守这一观点。尽管我知晓,开源稀缺性的技术会让自己失去核心竞争力,甚至失去赖以生存的基石,那又怎样呢?开源某种程度上确实促进了技术的增长,也为许多人提供了帮助,而我个人,正是乐于分享、乐于助人的信仰者。

阅读

今年对于文学和技术的输入都比去年要少,技术上主要补了年初买的幼麟实验室的《深度探索 Go 语言》和周志明老师的《凤凰架构》。这两本都是很好的书籍,弥补了我对 Go runtime 和技术架构设计的认知缺陷。希望明年能更多输入一些技术书籍。

文学方面最喜欢的还是最近看的《大明王朝 1566》,这是一本非常好看的历史小说,通俗易懂,即使对历史认知不多也能看进去,整本书读下来畅意淋漓,人物形象设计非常经典,看的时候可以让人完全沉浸在故事中。

《生死疲劳》书里除了垃圾话比较多以外,六世轮回的故事还是挺有趣的。但我还是觉得太累了,人活一世足矣。

《道诡异仙》是近两年看过最好看的一本网络小说,脑洞很大,估计很难再看到如此天马行空的小说了。

我的书单

近来,读书无用论者的言论屡见不鲜,这让我深感反思自己读书的目的。一方面,我进行的是应试教育式的读书,简言之,是为了追求升学的目标,为未来争取更多选择。这看似合理,但未来的走向谁又能确切预测呢?就像那些宣扬“量产大学生进厂打螺丝”的读书无用论者所言,学历的价值一直在贬值。然而,他们却忽略了那些未受过教育或学历较低者的实际情况,忽视了“28 定律”无处不在。对于大多数人来说,学历只是你曾学过的证明,究竟学了什么其实并不那么重要。

另一方面,我也沉浸在兴趣驱使的读书中。前几年,我每年能读二三十本书,然而如今许多书的内容早已消逝于记忆之中,留下的只是当初写下的几句批注和几篇读后感。我清楚地认识到,阅读闲书只是我打发时间的一种方式。即便看了一些书,获取了一些知识,拓展了一些见识,也并没有让我突然醒悟,人生变得一片坦途。如果阅读真的能如此简单地让人迅速觉悟,那么为什么我还在原地徘徊呢?如果这种路径如此轻松,早就被前人走烂了才对。

音乐

用的听歌软件依旧是网易云音乐,他家的年度总结挺有意思的。今年也是喜欢凯瑟喵的一年,我在第一段实习离职前抓住机会去了线下 Live,第一次线下见她,感觉真的好可爱啊,她是我不能忘怀的青春里不可或缺的存在,明年和明年的明年也会一直喜欢她。

今年尤其钟意粤语歌和说唱,冯泳是今年发现的宝藏歌手,【风中余烬】、【海风】的词和 hook 都很戳我。

网易云年度总结

听到最多的歌词

凯瑟喵的小卡

设备

今年忙于实习和秋招,资金链也异常紧张,所以设备上没啥变动。七月份我为心爱的键盘换上了一套可爱的玉桂狗键帽,秋招后朋友送了一个 XBOX 手柄和 XSS 主机,感谢朋友的慷慨。

可爱的玉桂狗键帽

XBOX

动画片与电影

动画片今年基本没看了吧,我没啥印象。电影倒是看了几部。

《铃芽之旅》感觉不好看,女主也太恋爱脑了= =,而且剧情挺迷的,也可能是这部电影的内蕴比较深,我在观看的过程中产生了很多疑惑,而这些疑惑直到观影完都没有解开。

《封神第一部》特效做的确实可以,感觉剧情不是很完整,有点像预告片的样子 hh,姬昌声音听起来太🍬了。

《巨齿鲨 2:深渊》好看,很震撼,很刺激。

总结

虽然今年未能完成去年设定的目标,但在沟通能力方面取得了超出预期的提升。与此同时,我敢于勇往直前,涉足了许多之前未曾涉足的领域,拓展了自己的视野,也结识了许多新朋友。展望明年,我期待能够持续自我发展,将更多精力投入到我所钟情的领域,深耕并不断积累经验。希望未来的努力能够为我带来更为丰硕的成果。

]]>
<p>起笔写这篇文章时,距离我校招提前实习已经过去一个月。早在秋招结束时,我就计划着撰写一篇类似的总结文章,但由于一时懒散,最终只是写了一些零散的片段,凑不成一篇完整的文章。因此,在这篇年度总结中,我想稍微记录一下我对秋招的感受。</p> <p>回顾 2023 年,许多在 202
Hello 算法之十大排序 https://makonike.github.io/2023/09/11/Hello%E7%AE%97%E6%B3%95%E4%B9%8B%E5%8D%81%E5%A4%A7%E6%8E%92%E5%BA%8F/ 2023-09-11T08:31:43.000Z 2023-09-11T11:55:19.763Z 写在前面

本文复习自用,因为近期的几次面试都考察了比较基础的排序算法,所以记录一下。内容均来自Hello 算法 - 排序,这本书中详细描述了算法基础、各种数据结构以及各种算法类型,对我的帮助很大。再次感谢作者和各位贡献者的分享。

冒泡排序

通过连续比较与交换相邻元素实现排序。即使是经过优化,最差和最优的平均复杂度也为O(n^2),但是在输入数组完全有序的情况下,可以达到最佳的时间复杂度 O(n),属于稳定排序,在冒泡中遇到的相等元素不交换。

数组从最左端向右遍历,依次比较相邻元素大小,如果左元素>右元素就交换它俩,遍历完成后,最大的元素会被移到数组最右端。比较次数:n-1, n-2, …, 2, 1 总和为 (n - 1)n/2

img

1
2
3
4
5
6
7
nums = [5, 3, 1, 2, 4]
n = len(nums)
for i in range(n - 1, 0, -1): # 外循环,未排序区间[0, i]
for j in range(i): # 内层循环将[0, i]的最大元素交换至区间最右端
if nums[j] > nums[j + 1]:
nums[j + 1], nums[j] = nums[j], nums[j + 1]
print(nums)

优化:标志位记录,如果没有交换,提前结束

1
2
3
4
5
6
7
8
9
10
11
nums = [5, 3, 1, 2, 4]
n = len(nums)
for i in range(n - 1, 0, -1): # 外循环,未排序区间[0, i]
flag = False # 标志位记录是否完成排序
for j in range(i): # 内层循环将[0, i]的最大元素交换至区间最右端
if nums[j] > nums[j + 1]:
flag = True
nums[j + 1], nums[j] = nums[j], nums[j + 1]
if not flag:
break
print(nums)

归并排序

基于分治策略的排序算法,通过划分和合并组成。时间复杂度O(nlogn),空间复杂度 O(n),属于稳定排序,合并过程中,相等元素的次序保持不变。

划分:通过递归将数组从中点处分开,将长数组的排序转换为短数组的排序问题

合并:当子数组长度为 1 时,开始合并,持续将左右两个较短的有序数组组合为一个较长的有序数组,直到结束。

img

可以看到每一层都是 n,合并排序每一层的时间复杂度是 o(n)(可以参考合并两个有序数组),有 logn 层,因此时间复杂度是 O(nlogn)。

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
nums = [5, 3, 1, 2, 4]
n = len(nums)
def merge_sort(nums, left, right):
if left >= right:
return
mid = (left + right) >> 1
merge_sort(nums, left, mid)
merge_sort(nums, mid + 1, right)
merge(nums, left, mid, right)

def merge(nums, left, mid, right):
tmp = list(nums[left:right + 1]) # 辅助数组,记录原先的值
left_start = 0 # 左子数组的起始索引和结束索引
left_end = mid - left
right_start = mid + 1 - left # 右子数组的起始索引和结束索引
right_end = right - left
i = left_start # 左右子数组首元素索引
j = right_start
for k in range(left, right + 1):
if i > left_end: # 只剩右子数组
nums[k] = tmp[j]
j += 1
elif j > right_end or tmp[i] <= tmp[j]: # 只剩左子数组,或者左子数组的值更小
nums[k] = tmp[i]
i += 1
else:
nums[k] = tmp[j]
j += 1
merge_sort(nums, 0, n - 1)
print(nums)

选择排序

开启一个循环,每轮从未排序区间选择最小的数,放到已排序区间的末尾。时间复杂度O(n^2),比较次数包括 n, n-1, n-2, …, 3, 2 总共 (n - 1)(n + 2) / 2。非稳定排序,在经过选择后,相对顺序会更改。

img

流程如下

  1. 初始状态所有元素未排序,未排序区间[0,n-1]
  2. 从[0,n-1]找到最小的数的下标,与 0 交换
  3. 从[1,n-1]找到最小的数的下标,与 1 交换
  4. …直到 n-1 轮

img

1
2
3
4
5
6
7
8
9
10
nums = [5, 3, 1, 2, 4]
n = len(nums)

for i in range(n):
minn = i
for j in range(i, n):
if nums[j] <= nums[minn]:
minn = j
nums[minn], nums[i] = nums[i], nums[minn]
print(nums)

插入排序

在未排序区间选择一个基准元素,将其与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确位置。基于元素赋值实现

时间复杂度O(n^2),最差情况下分别需要比较 n-1, n-2, …, 2, 1 次,总和为 n(n-1)/2。实际遇到有序后会提前终止,当数组完全有序,复杂度为 O(n)。属于稳定排序,插入时会将元素插入到同等元素的右边,不会更改顺序。

流程如下

  1. 初始时第一个元素已完成排序
  2. 选择第二个元素作为 base,将其插入已排序的里面的正确位置
  3. 选择第三个元素作为 base,将其插入已排序区间的正确位置
  4. 直到最后一轮

img

1
2
3
4
5
6
7
8
9
10
11
nums = [5, 3, 1, 2, 4]
n = len(nums)

for i in range(1, n): # 遍历未排序区间[1, n - 1]
base = nums[i]
j = i - 1
while j >= 0 and base < nums[j]: # 将base插到已排序区间[0, i]的某个正确位置
nums[j + 1] = nums[j] # 将nums[j]后移一位,为base腾出空间
j -= 1
nums[j + 1] = base
print(nums)

虽然比快排的复杂度高,但插入排序的优势在于小数据量下通常会更快。

快速排序

基于分治策略的排,核心操作是哨兵划分,选择数组中某个元素作为基准数,将所有小于基准数的元素移到其左侧,将所有大于基准数的元素移到其右侧。时间复杂度为O(nlogn),平均情况下哨兵划分递归层数为 log n,每层总循环 n 次,共 O(nlogn) 时间。最差情况下,每次划分为 0 和 n-1 长度的数组,递归层数为 n,每层循环数为 n,总体就是 O(n^2)。属于非稳定排序,在哨兵划分的最后一步,基准数可能会被交换到相等元素的右侧。

流程如下

  1. 选择数组最左侧元素作为基准数,初始化 i 和 j 指向数组左右两端
  2. 设置一个循环,每轮用 i、j 分别寻找到第一个比基准数大、小的元素,然后交换这俩元素
  3. 重复直到 i 和 j 相遇,交换基准数至俩子数组的分界线。

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
nums = [5, 3, 1, 2, 4]
n = len(nums)

def partition(nums, left, right):
i, j = left, right # 以nums[left]作为基准数
while i < j:
while i < j and nums[j] >= nums[left]: # 找到右区间第一个比基准数小的
j -= 1
while i < j and nums[i] <= nums[left]: # 找到左区间第一个比基准数大的
i += 1
nums[i], nums[j] = nums[j], nums[i] # 交换这俩,保证左边都比基准数小,右边都比基准数大
nums[i], nums[left] = nums[left], nums[i] # 将基准数放中间
return i # 哨兵位置

def quick_sort(nums, left, right):
if left >= right:
return
pivot = partition(nums, left, right) # 哨兵划分
quick_sort(nums, left, pivot) # 递归左、右区间
quick_sort(nums, pivot + 1, right)

quick_sort(nums, 0, n - 1)
print(nums)

快排优点:

大多数情况下都是以 O(nlogn) 复杂度运行,在执行哨兵划分时能用到系统缓存(将整个子数组加载到系统缓存,因为是连续的),快排的比较、赋值、交换次数更少(如果插入排序比冒泡排序快一样)

优化:

如果数组完全倒序,那么每次选择最左元素为基准数都会交换到最右端,导致 O(n^2),退化为冒泡排序。可以优化基准数的选择策略,随机选择一个元素为基准数,或者说从前中后三个数中选择中位数作为基准数。

尾递归优化,某些输入下快排占用空间会很多,比如完全倒序,会占 O(n) 的栈空间。可以在每轮哨兵完成排序后,比较左右俩子数组的长度,仅对长度较小的子数组进行递归。

1
2
3
4
5
6
7
8
9
def quick_sort2(nums, left, right):
while left < right:
pivot = partition(nums, left, right)
if pivot - left < right - pivot:
quick_sort2(nums, left, pivot - 1) # 对左子区间递归
left = pivot + 1 # 剩余未排序区间为[pivot + 1, right]
else:
quick_sort2(nums, pivot + 1, right) # 对右子区间递归
right = pivot - 1

堆排序

基于堆的排序。时间复杂度O(nlogn),其中建堆用 O(n),取最大元素时间复杂度 O(logn),共循环 n-1 轮。非稳定排序,在交换堆顶元素和堆底元素时,相等元素的位置可能会发生变化。

流程如下:

  1. 输入数组并建立大顶
  2. 将堆顶元素与堆底元素(最后一个)交换,完成交换后,堆长度 -1,已排序元素数量 +1
  3. 从堆顶开始,从顶到底执行堆化(sift down),调整堆
  4. 循环执行 2、3 步,直到 n-1 轮完成

img

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
nums = [5, 3, 1, 2, 4]
n = len(nums)

def sift_down(nums, root, k):
t = nums[root] # 父节点
while root << 1 < k: # k指堆大小
child = root << 1 # 左子节点
if child | 1 < k and nums[child | 1] > nums[child]: # 检查右子节点是否有机会
child |= 1
if nums[child] > t: # 如果当前元素更小,则递归向下,使得堆顶元素更大
nums[root] = nums[child]
root = child
else: # 如果到了合适的位置,则赋值
break
nums[root] = t

nums = [0] + nums # 哨兵,基于此可以通过位运算快速定位子节点以及父节点
k = len(nums)

for i in range((k - 1) >> 1, 0, -1): # 建堆
sift_down(nums, i, k)
for i in range(k - 1, 0, -1): # 取最大元素与堆底元素交换,并从顶到底调整堆
nums[1], nums[i] = nums[i], nums[1]
sift_down(nums, 1, i)

print(nums[1:])

桶排序

基于分治,非比较的排序算法,通过设置一些具有大小的桶,每个桶对应一个数据范围,将数据平均分散到桶中,然后对桶内执行排序(往往不需要,因为多数只有一个数),最终按照桶范围合并。时间复杂度一般是线性的,n 个数 k 个桶,O(n + k),假设分布均匀,每个桶内元素 O(n/k),排序耗时 O((n/k)log(n/k)),所有桶排序则 O(nlog(n/k))。当桶足够大,分散足够均匀,每个桶一个数,则趋向于O(n)是否稳定基于桶内排序是否稳定

流程如下

  1. 初始化 k 个桶,将 n 个数分到 k 个桶
  2. k 个桶分别执行排序
  3. 从小到大合并 k 个桶

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
nums = [56, 32, 13, 26, 43]
n = len(nums)

k = 6
bucket = [[] for _ in range(k)] # 分k个桶
for x in nums:
i = x // 10
bucket[i].append(x)
for b in bucket:
b.sort()
i = 0
for x in bucket:
for xx in x:
nums[i] = xx
i += 1
print(nums)

主要是如何去将原始数据平均分散到多个 bucket 中。可以创建递归树来做,对数量多的 bucket 进行再分桶,或者通过概率统计来分桶。

img

img

计数排序

通过统计数量来对元素实现排序,适用于较为密集的数组。时间复杂度O(n + m),包括遍历数组以及计数器。一般 n >> m,趋近于 O(n)。通过倒序遍历可以避免修改元素之间的相对位置正序则非稳定

流程如下

  1. 遍历数组找到最大数 m,创建一个 m+1 辅助数组
  2. 遍历数组计数
  3. 编辑计数器统计出现次数,填充即可

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
nums = [5, 3, 1, 2, 4, 3, 2, 1]
n = len(nums)

maxx = max(nums)
c = [0] * (maxx + 1)
for x in nums:
c[x] += 1
idx = 0
for i, x in enumerate(c): # 遍历计数器
while x > 0: # 递减计数器
nums[idx] = i # 填充nums
x -= 1
idx += 1
print(nums) # [1, 1, 2, 2, 3, 3, 4, 5]

基数排序

通过统计个数实现排序,但是主要利用数字各个位之间的递进关系,依次对每一位排序,适用于数值范围较大的,较分散的数组。时间复杂度 O(nk),n 为数据量,d 为进制,k 为最大位数。对一位执行计数排序是 O(n + d),排序 k 位就是 O(k(n + d)),通常趋向于 O(n)。通过倒序遍历可以避免修改元素之间的相对位置正序则非稳定

流程如下

  1. 初始化位数 k=1
  2. 针对第 k 位进行计数排序,整个数进行变动
  3. 将 k+1,返回第二步,直到结束

img

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
nums = [10546151, 35663510, 21233312, 97422133, 22188831]
n = len(nums)

def digit(x, exp): # 取x的第k位返回,对10 100 1000...整除后取模
return (x // exp) % 10

def counting_sort_digit(nums, exp):
counter = [0] * 10 # 对每一位计数 0~9
n = len(nums)
for i in range(n): # 统计0~9出现次数
d = digit(nums[i], exp)
counter[d] += 1
for i in range(1, 10): # 前缀和, 将个数转换为索引,比如 0 1 1 2 0 1 -> 0 1 2 4 4 5
counter[i] += counter[i - 1]
print(counter)
res = [0] * n # 倒序遍历存入结果数组
for i in range(n - 1, -1, -1):
d = digit(nums[i], exp) # d为nums[i]的第k位值
j = counter[d] - 1 # 获取d所在索引
res[j] = nums[i] # 填入
counter[d] -= 1 # 索引-1,因为可能有重复的 0 1 2 4 4 5 -> 0 1 2 3 4 5
for i in range(n):
nums[i] = res[i] # 覆盖

def radix_sort(nums):
m = max(nums)
exp = 1
while exp <= m:
counting_sort_digit(nums, exp)
exp *= 10
radix_sort(nums)
print(nums)

总结

排序算法各有特点,在选择使用时需要跟据适用的场景来选择,而不是盲目选择快排等更知名的排序。

]]>
<h2 id="写在前面">写在前面</h2> <p>本文复习自用,因为近期的几次面试都考察了比较基础的排序算法,所以记录一下。内容均来自<a href="https://www.hello-algo.com/chapter_sorting/">Hello 算法 - 排序</a>
一文了解 Go 语言 Context 标准库 https://makonike.github.io/2023/08/31/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3Go%E8%AF%AD%E8%A8%80Context%E6%A0%87%E5%87%86%E5%BA%93/ 2023-08-31T08:25:35.000Z 2023-09-05T16:16:37.877Z 笔者所用 Go 版本为:go1.20.5 linux/amd64

context 意为上下文,用于管理子 goroutine 的生命周期,或维护一条调用链路中的上下文,广泛用于微服务、以及各类标准包如 http、sql 中。context 的源代码非常的少且简洁,接下来笔者直接对 context 的源代码进行分析,并对 context 的应用场景做简单的介绍。

一个接口、四种实现、六个函数

Context 定义是一个接口,具体的实现有四种:emptyCtx、cancelCtx、timerCtx 以及 valueCtx。context 包对外暴露的方法主要有六个(实际上更多一点):Background()、TODO()、WithValue()、WithCancel()、WithDeadline() 与 WithTimeout()。

可以简单看出,Context 四个方法决定了 Context 的几个用途:设置 deadline、设置取消信号,以及携带一些值。

1
2
3
4
5
6
7
8
9
10
// A Context carries a deadline, a cancellation signal, and other values across
// API boundaries.
//
// Context's methods may be called by multiple goroutines simultaneously.
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}

从 emptyCtx 起

其中*emptyCtx 只是对这四个方法进行了简单地实现:返回默认值、nil 或者 false。你可能感到很疑惑,*emptyCtx 看起来像是什么也没做,恰如其名,事实上确实是这样。emptyCtx 是以 int 为基础定义的自定义类型,不过即使是其他的类型,我们也能够接受。

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
// An emptyCtx is never canceled, has no values, and has no deadline. It is not
// struct{}, since vars of this type must have distinct addresses.
type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
return
}

func (*emptyCtx) Done() <-chan struct{} {
return nil
}

func (*emptyCtx) Err() error {
return nil
}

func (*emptyCtx) Value(key any) any {
return nil
}

func (e *emptyCtx) String() string {
switch e {
case background:
return "context.Background"
case todo:
return "context.TODO"
}
return "unknown empty Context"
}

而 Background() 与 TODO() 这两个方法返回了构造好的*emptyCtx。

对于 Background,我们很快就通过它的命名能猜测到它的作用:作为上下文中最初的状态存在,像是一幅画最初的状态——一块白板,即是 Background。Background 是直接 new 的 emptyCtx,它本身不带有 deadline 或者其他一些奇怪的值,很符合作为白板。所以可以经常见到在异步处理请求的时候,为了不让带有 deadline 的 context 传入导致异步的 goroutine 被 cancel,就将为其新获取一个 Background 的 context 继续向下展开上下文。你可能不太了解为什么它适合作为白板来使用,我们先放一放,后续才是展现 context 魅力的时刻。

对于 TODO,经常用在还未决定使用哪种 context 或还无法接纳从外部传入的 context 的时候,例如一个函数的调用本应该接纳从外部传入的 context,而外部却没有传递 context 进来,所以暂时使用 TODO(),就像它的命名一样,留下一个 TODO。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)

func Background() Context {
return background
}

func TODO() Context {
return todo
}
func main() {
ctx := context.Background()
...
}

在普通的程序中,我们通过 context.Background() 获取一个初始 context,结合 iface 相关的知识,我们可以知道它的结构如下。通过结构图,我们可以更清晰地理解 context 调用链相关的知识,以及为何它被称为上下文。

再来看看 cancelCtx

我们再来看看 cancelCtx,它是一种可以被取消的 context,当它被取消时,它所有可取消的子 context 也会被取消。其中 done 用于获取 context 的取消通知,children 用于存储以它为根节点的所有可取消的子 context,以便于在根节点 context 取消时可以将它们一并取消,err 用于存储 context 被取消时存储的错误信息,mu 是用于保证这几个字段的锁,以保证 cancelCtx 是线程安全的,cause 则与 err 一样但又有点不同,它是专门用于存储解释 cancel 的 error,当 context 没被取消,则 cause 会是 nil,而 err 却不一定是 nil。

1
2
3
4
5
6
7
8
9
10
11
// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct {
Context // 父级context

mu sync.Mutex // 保护下述字段
done atomic.Value // 用于获取context的取消通知
children map[canceler]struct{} // 用于存储以它为根节点的所有可取消的子context
err error // 用于存储context被取消时存储的错误信息
cause error // 用于存储解释context被取消的信息
}

WithCancel() 函数可以将一个 context 包装为 cancelCtx,并返回一个取消函数,用于取消对应的 context。来看看它的底层,其实是通过 withCancel 进行包装,而 withCancel 则是通过 newCancelCtx 创建了一个 cancelCtx,将之前的 context 设置为了父 context。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancel(parent)
// 返回的是一个匿名函数,用于将所有子的context一并取消
return c, func() { c.cancel(true, Canceled, nil) }
}

func withCancel(parent Context) *cancelCtx {
if parent == nil {
panic("cannot create context from nil parent")
}
c := newCancelCtx(parent)
propagateCancel(parent, c)
return c
}

// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) *cancelCtx {
// 可以看到,只是简单包装了一层
return &cancelCtx{Context: parent}
}

在解读 propagateCancel 之前,我们先来看看 cancelCtx 对 Done() 的实现。d.done 是一个 chan struct{},懒加载并通过 mu 来保证并发安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (c *cancelCtx) Done() <-chan struct{} {
d := c.done.Load()
if d != nil {
return d.(chan struct{})
}
c.mu.Lock()
defer c.mu.Unlock()
d = c.done.Load()
if d == nil {
d = make(chan struct{})
c.done.Store(d)
}
return d.(chan struct{})
}

我们之前提到 children 中存储了所有以它为根节点的所有可取消的 context,propagateCancel 正是用于此处,它根据父级 context 的状态来关联 cancelCtx 的 cancel 行为,在父节点被取消时,会一同取消所有可取消的子 context。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// goroutines counts the number of goroutines ever created; for testing.
var goroutines atomic.Int32

// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
done := parent.Done() // 一次检查:继承父级的done方法,如果父级是*emptyCtx就直接返回
if done == nil {
return
}

select {
case <-done:
// 检查父级是否已经cancel了
child.cancel(false, parent.Err(), Cause(parent))
return
default:
}

// 找到自己第一个父级的cancelCtx
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
// 父级已经cancel了,那么也调用子context的cancel
child.cancel(false, p.err, p.cause)
} else {
// 如果这个父级没有children,则给它分配一个,然后把自己存进去
// 添加关联
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 开启一个goroutine去监听parent是否Done了,相当于监控线程
// 这里应该是测试使用,单独一个协程生命周期运行完会被gc回收,不会泄漏
goroutines.Add(1)
go func() {
// 阻塞式select
select {
// 如果child已经cancel了,parent再cancel会导致child被cancel两次,不过问题不大
case <-parent.Done():
child.cancel(false, parent.Err(), Cause(parent))
case <-child.Done():
}
}()
}
}

// &cancelCtxKey is the key that a cancelCtx returns itself for.
var cancelCtxKey int

func parentCancelCtx(parent Context) (*cancelCtx, bool) {
// 二次检查(removeChild需要)
done := parent.Done()
if done == closedchan || done == nil {
return nil, false
}
// 通过cancelCtxKey获取父级第一个cancelCtx
// 这里我们可以先看看cancelCtx的Value()方法,它通过一个特定的key(cancelCtxKey)来返回自身,否则走value()向上递归查询
// valueCtx的Value()也一样,而valueCtx并不会影响到cancelCtxKey的使用,这是因为cancelCtxKey是一个新的类型,后续讲解valueCtx会涉及到
// 这里如果false,就是获取到了emptyCtx,到达了根节点也没有cancelCtx,直接返回即可
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
if !ok {
return nil, false
}
// 看看父级第一个cancelCtx的done是否与当前传入的parent context的done匹配
// 如果不是,则说明可能在context传播过程中进行了包装,不可直接绕过当前上下文进行取消操作
// 我们返回false即可
pdone, _ := p.done.Load().(chan struct{})
if pdone != done {
return nil, false
}
return p, true
}

func (c *cancelCtx) Value(key any) any {
if key == &cancelCtxKey {
return c
}
return value(c.Context, key)
}

func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key)
}

等了这么久,我们终于可以看看 cancel 了。它在上述我们使用 WithCancel 时包装成一个匿名函数返回,通过调用这个函数,我们可以取消当前的 context,关闭当前 context 的 done,并取消其所有子 context。在取消时,会设置 cause。

前文中 WithCancel 在执行 cancel 时传的参数是 Canceled,Canceled 就是我们熟知的 context canceled 错误。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// Canceled is the error returned by Context.Err when the context is canceled.
var Canceled = errors.New("context canceled")

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancel(parent)
return c, func() { c.cancel(true, Canceled, nil) }
}
// closedchan is a reusable closed channel.
var closedchan = make(chan struct{})

func init() {
close(closedchan)
}

func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
// cancel原因
if cause == nil {
cause = err
}
c.mu.Lock()
// 已经被cancel了
if c.err != nil {
c.mu.Unlock()
return // already canceled
}
c.err = err
c.cause = cause
// 获取done
d, _ := c.done.Load().(chan struct{})
if d == nil {
// 如果done不存在,说明Done还没调用过,懒加载没执行,直接设一个默认关闭的chan即可,读出来的都会是零值
c.done.Store(closedchan)
} else {
// 否则将done关掉,这样被done阻塞的goroutine就会收到零值通知,执行后续代码
close(d)
}
// 对所有子cancel进行cancel
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
// 由于后续c.children置为nil了,而且这些context只会注册到离他最近的父cancelCtx中,就不需要removeFromParent了
child.cancel(false, err, cause)
}
c.children = nil
c.mu.Unlock()

// 查看是否需要从parent处remove
if removeFromParent {
removeChild(c.Context, c)
}
}

而使用 WithCancelCause 相较于 WithCancel 类似,区别是它会传入造成此次 cancel 的自定义 error,这个你可以自己去定义,关键时候使用能更快地排查出相关问题。

1
2
3
4
func WithCancelCause(parent Context) (ctx Context, cancel CancelCauseFunc) {
c := withCancel(parent)
return c, func(cause error) { c.cancel(true, Canceled, cause) }
}

Cause 用于返回 cancel 的 error。

1
2
3
4
5
6
7
8
func Cause(c Context) error {
if cc, ok := c.Value(&cancelCtxKey).(*cancelCtx); ok {
cc.mu.Lock()
defer cc.mu.Unlock()
return cc.cause
}
return nil
}

cancelCtx 的解读到此处就快结束了,我们来收尾看看 removeChild 的逻辑:很简单,它找到第一个父 cancelCtx,将自己从它的 p.children 中移除。

1
2
3
4
5
6
7
8
9
10
11
12
// removeChild removes a context from its parent.
func removeChild(parent Context, child canceler) {
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
delete(p.children, child)
}
p.mu.Unlock()
}

如果上述的解读不够直观,我们可以再来看看调用 WithCancel 后包装一层的结构,以此来加深对 context 上下文调用链的理解:

1
2
3
4
5
6
func main() {
ctx := context.Background()
ctx1, cancel := context.WithCancel(ctx)
defer cancel()
//...
}

timerCtx

timerCtx 基于 cancelCtx,封装了一个定时器和一个截止时间。这样既可以通过 cancelCtx 主动取消,又可以通过到达 deadline 时,通过 timer 来调用取消动作,timer 也由 cancelCtx 的 mu 字段互斥锁来保护,保证了 timerCtx 也是线程安全的。

1
2
3
4
5
6
type timerCtx struct {
*cancelCtx
timer *time.Timer // Under cancelCtx.mu.

deadline time.Time
}

先来看看它的 cancel,非常简洁,只是调用了一下 cancelCtx 的 cancel,并将 timer 暂停。

1
2
3
4
5
6
7
8
9
10
11
12
13
func (c *timerCtx) cancel(removeFromParent bool, err, cause error) {
c.cancelCtx.cancel(false, err, cause)
if removeFromParent {
// Remove this timerCtx from its parent cancelCtx's children.
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}

通过 WithDeadline 和 WithTimeout 都可以创建 timeCtx,区别是 withDeadline 需要指定一个时间点,而 WithTimeout 需要指定一个时间段。WithDeadline 中,先判断之前是否有 timerCtx,如果有,就看看时间是否在自己之前,是的话就不用设置了,包装个 cancelCtx 直接返回,因为父级 context 设置了更早的时间,更早的 cancel,当前 context 肯定会被影响,一并 cancel。然后根据当前给定的 context 又创建了一个新的 cancelCtx 包装 parent context,进行关联关系,并获取当前时间的差值,赋值一个 timer。

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
37
38
39
40
41
42
43
44
45
46
47
// DeadlineExceeded is the error returned by Context.Err when the context's
// deadline passes.
var DeadlineExceeded error = deadlineExceededError{}

type deadlineExceededError struct{}

func (deadlineExceededError) Error() string { return "context deadline exceeded" }
func (deadlineExceededError) Timeout() bool { return true }
func (deadlineExceededError) Temporary() bool { return true }

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
// 如果之前设置的deadline在如今设置的deadline之前,直接返回cancel包装
// 这样遵循了之前的截止日期,因为父级context的deadline取消操作会影响到当前context,所以不用设置了
// 如果这里是false说明是emptyCtx,没有设置deadline,不用管
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
return WithCancel(parent)
}
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
// 设置一些关联
propagateCancel(parent, c)
dur := time.Until(d)
// 如果已经超时了,就直接cancel
// 此处的DeadlineExceeded是"context deadline exceeded"错误
if dur <= 0 {
c.cancel(true, DeadlineExceeded, nil) // deadline has already passed
return c, func() { c.cancel(false, Canceled, nil) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
// 设置timer,超时则调用cancel
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded, nil)
})
}
return c, func() { c.cancel(true, Canceled, nil) }
}

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}

而 WithTimeout 就是简单调用了 WithDeadline:

1
2
3
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}

使用示例如下:

1
2
3
4
5
6
7
8
func main() {
ctx := context.Background()
ctx1, cancel := context.WithCancel(ctx)
defer cancel()
ctx2, cancel2 := context.WithDeadline(ctx1, time.Now().Add(1*time.Second))
defer cancel2()
//...
}

可以看到 ctx2 基于 ctx1,而 ctx1 基于 ctx,而每个 context 都可以延伸出多个子 context,进行不断的扩展和包装,于是就构成了一颗 context 树。

基于这颗 context 树,我们的 cancel 就派上用场了,一个上下文 context 可以取消其与其所有可取消的子 context:

valueCtx

最后来看看 valueCtx,它附加了一个键值对,通过 WithValue() 可以给 context 附加一个键值对的打包。

1
2
3
4
type valueCtx struct {
Context
key, val any
}

WithValue() 简单的将 context 包装了一层,返回 valueCtx。注意此处的 key 和 context 都不能为 nil,且 key 必须是可比较类型。

1
2
3
4
5
6
7
8
9
10
11
12
func WithValue(parent Context, key, val any) Context {
if parent == nil {
panic("cannot create context from nil parent")
}
if key == nil {
panic("nil key")
}
if !reflectlite.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val}
}

valueCtx 的核心函数当然是 Value(),它对比当前 context 的 key 与给定的 key 是否一致,如果一致就返回值,否则继续向上递归调用。

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
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key)
}

func value(c Context, key any) any {
for {
switch ctx := c.(type) {
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context
case *cancelCtx:
if key == &cancelCtxKey {
return c
}
c = ctx.Context
case *timerCtx:
if key == &cancelCtxKey {
return ctx.cancelCtx
}
c = ctx.Context
case *emptyCtx:
return nil
default:
// 向上递归调用
return c.Value(key)
}
}
}

很显然,当 key 值是一样时,最接近当前 context 的 context 的值就会被获取到,而更高层 context 的值就会被掩盖。因此,最好将 key 自定义类型,而不是直接使用基础类型,这样容易导致 key 被覆盖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func main() {
ctx := context.Background()
ctx1 := context.WithValue(ctx, "key", "value111")
ctx2 := context.WithValue(ctx1, "key", "value222")
ctx3, cancel := context.WithCancel(ctx2)
defer cancel()
println(ctx1.Value("key").(string)) // value111
println(ctx2.Value("key").(string)) // value222
println(ctx3.Value("key").(string)) // value222
}
type key string
type keyy string

func main() {
ctx := context.Background()
var key1 keyy = "key"
var key2 key = "key"
ctx1 := context.WithValue(ctx, key1, "value111")
ctx2 := context.WithValue(ctx1, key2, "value222")
ctx3, cancel := context.WithCancel(ctx2)
defer cancel()
println(ctx3.Value(key1).(string)) // value111
println(ctx3.Value(key2).(string)) // value222
}

至此,context 包中大部分源码已经解读完毕,我们可以聊一聊应用了。

应用

上下文数据传递

在 Java 中有 Threadlocal,而 Go 中则有 Context。在一个请求链路中,常常有一定的标识,比如这个请求是用户 A 发起的,那么整条链路中会有很多地方需要去获取用户 A 的一些信息,此时就可以通过 context 去存储相关的信息,然后借助 context 进行传递。当然,这也仅限于当个服务内,我们以 grpc 沟通时,在调用链路中,一个服务中的 context 携带的信息不可能直接在下游服务的 context 就有,通常是在两个服务间通过 grpc metadata 进行传递,通过 client 和 server 注册的 Interceptor 对 context 和 metadata 进行转换。http 的话就可以根据请求头携带相关的值。

超时控制

context 广泛用于微服务、http 以及 sql 包等的超时控制。调用 cancel 能释放与该 context 关联的资源,所以在这个 context 完成它的使命时,应该尽快调用 cancel,通常我们会在调用完成后使用 defer cancel()。在以下 http 请求示例中,为什么defer cancel()不放在resp, err := client.Do(req)下面一行呢,其实这是一个常见的误区,因为这样会导致 cancel 函数的调用在超时情况下失去作用。在网络请求超时的情况下,client.Do 可能会阻塞,而 cancel 函数将无法被调用,无法取消上下文,从而可能导致资源泄漏或意外的等待。

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
package main

import (
"context"
"fmt"
"net/http"
"time"
)

func main() {
// 创建一个具有 5 秒超时的 context
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

req, err := http.NewRequest("GET", "https://anyview.fun", nil)
if err != nil {
fmt.Println("Error creating request:", err)
return
}

// 将 context 与请求关联
req = req.WithContext(ctx)

client := http.DefaultClient
resp, err := client.Do(req)
if err != nil {
fmt.Println("Error sending request:", err)
return
}
defer resp.Body.Close()

fmt.Println("Response status:", resp.Status)
}

在net包中/src/net/http/transport.go:512可以看到通过对ctx.Done的监听来控制context的超时。很常见的一种用法,也说明了一点:context的超时控制不是通过直接中断 Goroutine 来实现的,而是通过在 Goroutine 中检查 context 的状态来实现的。在请求中,当使用一个派生自当前 context 的子 context 时,如果主 context 被取消,context 中的取消信号将传递给请求操作,通知它们停止等待和执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// roundTrip implements a RoundTripper over HTTP.
func (t *Transport) roundTrip(req *Request) (*Response, error) {
...
for {
select {
case <-ctx.Done():
req.closeBody()
return nil, ctx.Err()
default:
}
...
}
...
}

sql 也类似,在多个地方监听和检查 context 的信号状态,以此来控制自己 goroutine 的生命周期。

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
37
38
39
40
41
42
package main

import (
"context"
"database/sql"
"fmt"
"time"

_ "github.com/go-sql-driver/mysql"
)

func main() {
db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/database")
if err != nil {
fmt.Println("Error connecting to database:", err)
return
}
defer db.Close()

// 创建一个具有 2 秒超时的 context
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

// 将 context 与查询关联
rows, err := db.QueryContext(ctx, "SELECT * FROM users")
if err != nil {
fmt.Println("Error executing query:", err)
return
}
defer rows.Close()

for rows.Next() {
var id int
var username string
err := rows.Scan(&id, &username)
if err != nil {
fmt.Println("Error scanning row:", err)
return
}
fmt.Printf("User: ID=%d, Username=%s\n", id, username)
}
}
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

// /src/database/sql/sql.go:1281
// conn returns a newly-opened or cached *driverConn.
func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) (*driverConn, error) {
...
// Check if the context is expired.
select {
default:
case <-ctx.Done():
db.mu.Unlock()
return nil, ctx.Err()
}
...
}

// /src/database/sql/ctxutil.go:46
func ctxDriverQuery(ctx context.Context, queryerCtx driver.QueryerContext, queryer driver.Queryer, query string, nvdargs []driver.NamedValue) (driver.Rows, error) {
...

select {
default:
case <-ctx.Done():
return nil, ctx.Err()
}
...
}

而在异步场景下,context 就不能这样依靠父 context 直接构造子 context 继续传递上下文。最好的方法当然是通过 context.Background() 为其重新开启一段上下文。但当我们有一些特定的场景需要异步且还是需要上下文的时候怎么办呢?最好使用其余的东西去存储你需要的相关上下文信息,而不是想着拷贝 context 继续向下执行,否则代码会变得难以维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
"context"
"fmt"
"sync"
)

func main() {
ctx := context.Background()
add(ctx, 1, 2)
wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
add(context.Background(), 2, 3)
wg.Done()
}()
wg.Wait()
}

func add(ctx context.Context, x, y int) {
fmt.Println(x + y)
}

链路追踪

链路追踪的本质也是通过维护一条链路上同一个 trace-id,在 client 和 server 的 Interceptor、或者一些 log sdk、db 集成的 sdk 进行上报,通过 jaeger 等收集相关的数据进行展示。

笔者写了个基于 Opentelemetry 集成了 Jaeger 的 grpc 中间件作为学习 demo。主要也是处理上下文数据传递中的细节,来达到一条链路上一致的 trace-id 和 span。

在为 dtm 贡献时,笔者发现,基于分布式事务的 dtm 事务管理器在处理请求时以异步的形式来进行更好,能够减少分布式事务中产生的数据不一致的情况,提高分布式事务的效率。这会导致 context 无法作为上下文继续向下传递。而一些数据已经在 Interceptor 处理过放到 context 里了,比如 opentelemetry 中 trace 的 span、trace 啥的,这里真正好的方法是在新的 background 拷贝一些需要继续向下传递的值,然后再将新的 context 继续向下传递。

此外,笔者也借此机会写了完整拷贝 context 值的方法,但是不推荐使用,仅供参考。以下的代码由于在 Go1.19.3-1.20.5 之间某个版本迭代中,timerCtx 的结构从 cancelCtx 更改为了*cancelCtx,所以只适用于后者的版本,相应的改动是将以下 timerCtx 的结构也更改为对应的结构。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
type iface struct {
itab, data uintptr
}

type valueCtx struct {
context.Context
key, value any
}

type cancelCtx struct {
context.Context
}

type timerCtx struct {
cancelCtx *cancelCtx
}

func (*timerCtx) Deadline() (deadline time.Time, ok bool) {
return
}

func (*timerCtx) Done() <-chan struct{} {
return nil
}

func (*timerCtx) Err() error {
return nil
}

func (*timerCtx) Value(key any) any {
return nil
}

func (e *timerCtx) String() string {
return ""
}

// CopyContext copy context with value and grpc metadata
// if raw context is nil, return nil
func CopyContext(ctx context.Context) context.Context {
if ctx == nil {
return ctx
}
newCtx := context.Background()
kv := make(map[interface{}]interface{})
getKeyValues(ctx, kv)
for k, v := range kv {
newCtx = context.WithValue(newCtx, k, v)
}
return newCtx
}

func getKeyValues(ctx context.Context, kv map[interface{}]interface{}) {
rtType := reflect.TypeOf(ctx).String()
if rtType == "*context.emptyCtx" {
return
}
ictx := *(*iface)(unsafe.Pointer(&ctx))
if ictx.data == 0 {
return
}
valCtx := (*valueCtx)(unsafe.Pointer(ictx.data))
if valCtx.key != nil && valCtx.value != nil && rtType == "*context.valueCtx" {
kv[valCtx.key] = valCtx.value
}
if rtType == "*context.timerCtx" {
tCtx := (*timerCtx)(unsafe.Pointer(ictx.data))
getKeyValues(tCtx.cancelCtx, kv)
return
}
getKeyValues(valCtx.Context, kv)
}

由于反射的做法不兼容不同的 Go 版本,所以也可以尝试自定义一个 context 去阻断父节点的 cancel,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type asyncCtx struct {
context.Context
}

func (a *asyncCtx) Deadline() (deadline time.Time, ok bool) {
return
}

func (a *asyncCtx) Done() <-chan struct{} {
return nil
}

// NewAsyncContext create a new async context
// the context will not be canceled when the parent context is canceled
func NewAsyncContext(ctx context.Context) context.Context {
if ctx == nil {
return nil
}
return &asyncCtx{Context: ctx}
}

总结

本文从源码入手解析了精致的 context 包,感叹作者设计优秀的同时,也介绍了相关的应用场景。

]]>
<p>笔者所用 Go 版本为:go1.20.5 linux/amd64</p> <p>context 意为上下文,用于管理子 goroutine 的生命周期,或维护一条调用链路中的上下文,广泛用于微服务、以及各类标准包如 http、sql 中。context 的源代码非常的少且简
一文了解权限访问控制模型 https://makonike.github.io/2023/08/24/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3%E6%9D%83%E9%99%90%E8%AE%BF%E9%97%AE%E6%8E%A7%E5%88%B6%E6%A8%A1%E5%9E%8B/ 2023-08-24T10:37:34.000Z 2023-08-26T14:48:39.002Z 笔者实习过程中,主要负责权限模块,也趁机输出一下相关的知识。

ACL

访问控制列表(ACL,即 Access-control list),将一系列的权限与一个系统资源相关联。ACL 专注于个别许可的列表,想象一下你家门口的门禁系统,门禁系统上有一个名单,上面列出了允许进入你家的人的姓名。这个名单就像是一个 ACL,里面记录了具体的人名和他们被授予的进入权限,比如进入、离开、访问特定房间等。只有名单上的人才能够进入你家,其他人则不能。

一般的表结构设计如:ACL 表(对象标识,主体标识,权限标识),对象指需要访问的对象,比如门禁系统中特定的门口,主体代表进入你家的人,权限则标识进入、离开等。

一些变种包括:

  • ACL with superuser

普通用户的权限管理与之前一致,但引入了一个超级用户,不受权限管理控制,拥有所有权限。有了超级用户,排查些问题会比较方便。

  • ACL without users

适用于没有身份验证或用户登录的系统,比如文件共享系统,希望进行访问控制,但又不想为每个用户分配权限,就可以通过定义文件属性(如文件类型、标签、内容等)来管理文件的访问权限,从而更灵活地控制访问。

  • ACL without resources

与 ACL 不同,它不再关心特定的资源,而是专注于对一类资源或者一种操作的权限控制。比如你可以定义一个权限叫写文章,而不用关心具体的哪篇文章。这意味着你可以将 “写文章” 权限授予多个用户,让他们可以写任何文章,而不用每次新建一篇文章都重新设置权限。

RBAC

基于角色的访问控制(RBAC,Role-based access control),主要专注于角色和权限的管理和分配。想象你是一家公司的主管,有员工、经理和管理员等角色。在 RBAC 中,你为每个角色指定了特定的权限集合。例如,员工可以访问公司资源,经理可以管理项目并授予权限,管理员可以管理整个系统。当某人被分配了某个角色,他们就会自动获得与该角色相关的权限,而不需要单独指定。

RBAC 相对于其他访问控制模型,优势在于它的批量操作非常便捷,只需要为这些用户都分配这个角色即可,它们就会获取到该角色拥有的大量权限。而且更改该角色的权限时,无需为这些用户改动。

一般的表结构是一个角色表,一个权限表,一个用户表,一个角色和权限的关联表,一个用户和角色的关联表。当获取一个用户的权限时,根据角色与权限的关联取到权限交集。

变种有:

  1. RBAC with resource roles

通过为资源分配角色,访问控制变得更加细粒度。与其仅仅确定用户是否能够访问资源不同,这种方法允许定义特定操作或操作,特定角色的用户可以对特定资源执行这些操作。

比如一个开放的在线文档平台,有作者、审阅者、访客等角色。对于一篇文档的不同状态,有不同的权限控制:

  • 正在编辑的文档:只有作者可见,只有作者允许编辑。
  • 正在审阅的文档:只有作者和审阅者可见,审阅者无法编辑但可以反馈,作者无法编辑(如果需要编辑,需要等待审阅打回)。
  • 已发布的文档:所有角色都可见,只有作者允许编辑。

这个例子中,不同的用户和文档角色组合导致了不同级别的权限,同时也考虑了文档的不同状态和用途。这种方法有助于确保适当的权限和访问控制,使用户可以在协作环境中高效地工作。

  1. RBAC with domains/tenants

适用于多租户系统,比如在线网课平台,每个学校都是一个租户,每个用户在不同租户中的角色权限都不一样,可以更细粒度的进行管理。这使得平台能够根据不同的领域或租户需求,提供个性化和定制化的权限控制,确保用户可以在不同环境中执行适当的操作。

ABAC

ABAC 是基于属性的访问控制,通过角色属性和资源属性根据策略来控制访问权限。

访问策略通常指:如果你的某个属性为特定属性时,或者达到了某个条件,那么这个资源你就可以进行对应权限的访问。

一般表结构如下:一个实体表,一个资源表,一个实体属性表,一个策略表,一个策略规则表。其中策略表关联了资源表,相当于为该资源设定了访问策略,策略规则表存储了条件、动作等。

假设你在一个虚拟的游戏世界中。在 ABAC 中,访问权限取决于你的属性。每个玩家都有一些属性,比如级别、任务完成情况、特殊技能等。为了获得访问权限,系统会根据你的属性动态地决定是否允许你进入某个区域或执行某项任务。例如,如果你的级别达到了某个要求,你就可以访问一个高级区域。

Deny-override

Deny-override 支持同时使用允许(allow)和拒绝(deny)授权,其中拒绝授权会覆盖允许授权。这种机制用于确定用户是否有权访问资源,即当允许和拒绝授权同时存在时,拒绝授权将优先于允许授权生效。

关键特点在于

  • 允许授权(Allow Authorization):允许授权确定哪些用户或角色可以访问特定资源。这些授权可能基于角色、属性、上下文等条件。
  • 拒绝授权(Deny Authorization):拒绝授权确定哪些用户或角色不允许访问特定资源,即拒绝访问权限。
  • Deny-override 规则:在 Deny-override 机制下,如果一个用户或角色同时有允许和拒绝授权,那么拒绝授权会覆盖允许授权,即拒绝授权优先于允许授权

这种访问权限控制模型笔者没有用过,适用于一些机密文件,可以确保该资源不被不应该访问的用户访问。

Priority

类似防火墙规则,访问策略规可以根据其优先级进行排序,以决定当多个规则适用于同一资源时,哪个规则将被优先应用。这种机制允许管理员灵活地控制访问权限,确保特定规则在发生冲突时能够正确执行。

像 iptables 就是,从上到下多条规则进匹配,然后决定该数据包是 ACCEPT 还是转发等。

RESTful

以 URI 来唯一标识某个资源,然后根据请求方法 GET、PUT、POST 等对资源进行操作。

比较简单的是用户能访问自己的资源,管理员能访问所有的资源。

1
2
3
4
http://localhost:8080/{user_id}/paper

其中{user_id}是用户的唯一标识
根据请求GET,表示获取用户的paper列表,POST表示添加paper,DELETE表示删除paper等。

实践

笔者所在的部门主要维护一个服务于所有业务线的基座平台,该平台上承载着多个 appfy,每个 appfy 都相当于一个业务的闭环,类似于手机上的 app。在 SaaS 业务中,一个 appfy 可能会有一些高级功能或者定制功能,这些功能只提供给付费用户使用。由于业务方向是 B 端的,一个公司在使用过程中需要进行权限管理。

在公司组织架构管理中,常用的访问控制模型是 RBAC,根据用户的角色来分配权限。针对 RBAC 模型,我们将权限维度抽象为权限点,权限点用于控制某个 appfy 的某个功能。通过用户 - 角色 - 权限点的方式来管理公司内的权限分配。

然而,在过去的版本迭代中存在一些问题:

  1. 纯 RBAC 模型无法兼顾高级功能或定制功能的权限分配。权限点没有公司维度,且独属于一个 appfy。如果添加一个权限点用于控制高级功能或定制功能,所有公司在自己的组织架构中都能看到这个权限点。虽然有禁用权限点的功能,但权限点默认都是不禁用的。

  2. 新建立的公司所拥有的 appfy 是通过模板来分配的,但模板分配的维度仅限于 appfy,而非更细分的权限点。

为了解决这些问题,笔者的设计是将权限点进行分类处理,并通过扩展 RBAC 模型来管理公司所拥有的高级、定制权限点。

角色和资源:

  • 角色:公司
  • 资源:带有高级 tag 的权限点

策略规则:

  • 如果用户属于某一个公司,并且公司付费功能包含该功能权限点,则进而根据 RBAC 模型判断用户是否拥有该权限点。

另一方面,为了简化 CSM 对客户公司的权限管理:

  • 如果权限点关联的 appfy 没有为该公司开启,则该权限点将不会允许该公司用户访问。
  • 将部分有关联的高级权限点打包,CSM 进行分配时可以批量分配。

总结

本文介绍了几种权限访问控制模型:ACL、RBAC、ABAC、Deny-override、Priority 和 RESTful,并通过实际案例展示了它们的应用。ACL 强调对象权限关联,RBAC 基于角色分配权限,ABAC 根据属性动态控制,Deny-override 优先拒绝,Priority 排序规则,RESTful 基于 HTTP 方法。通过灵活选择和组合这些模型,可以实现有效的权限管理系统。

]]>
<p>笔者实习过程中,主要负责权限模块,也趁机输出一下相关的知识。</p> <h2 id="ACL">ACL</h2> <p>访问控制列表(ACL,即 Access-control list),<strong>将一系列的权限与一个系统资源相关联</strong>。ACL 专注于个
初探容器网络 https://makonike.github.io/2023/07/23/%E5%88%9D%E6%8E%A2%E5%AE%B9%E5%99%A8%E7%BD%91%E7%BB%9C/ 2023-07-23T10:44:45.000Z 2023-07-23T15:24:03.249Z 演变历史

容器网络的初期阶段,容器是一种封装技术,将应用和其依赖的环境打包在一起,方便快速部署和运行。对外暴露的方式相对简单:每个容器通过对应的端口暴露,外部程序可以通过不同的端口来区分不同的容器实例和应用实例。这种方案比较直接,没有引入任何网络堆栈,因此被广大开发者采纳。但是管理起来存在一些问题,因为每个容器都至少占用宿主机的一个端口。

为了解决容器规模化部署管理方面的问题,引入了 Overlay 网络方案。在这种方案下,两台主机之间构建一个默认私有的虚拟网络,支持容器与容器之间的通信,每个容器在虚拟网络中都有一个独立的 IP 地址。当容器需要对外暴露时,通过虚拟网络连接主机端口,主机端口对外暴露,外部数据包可以通过主机端口进入虚拟网络,然后路由转发给特定的容器。这也就是 Overlay 网络方案的工作原理。显而易见,Overlay 的优势在于允许无限制地部署容器,并提供虚拟网络中的独立 IP 地址,但通信数据仍然需要通过真实物理网络基础设施传输。然而,企业较少使用 Overlay,因为一些传统的应用程序可能依赖于特定网络拓扑配置和直接的主机间通信方式,而 Overlay 网络方案可能需要对这些应用程序进行适配或修改,以适应虚拟网络的架构。

大约在 17 年左右,基于 Overlay 的 Underlay 网络方案在业界流行起来。在这种网络方案下,虚拟网络与底层网络相连,能够满足虚拟网络中容器与虚拟机、物理机以及数据库等之间互通互连的需求,成为企业级网络方案的主流。与 Overlay 网络方案相比,Underlay 本质上没有变化,只是网络连接方式上有些许不同。然而,Underlay 网络方案有着明显的缺陷,当虚拟网络与真实物理网络打通后,容器每一个 IP 地址的管理都受到物理条件制约。由于底层网络有着多种类型,比如 vlan,IPv6,SDN,VPC 等,Underlay 需要对它们都进行适配,而这种适配可能具有一定的特殊性,比如华为的 SDN 可能无法以适配物理二层的方式进行适配,可能需要调用 SDN 的一个接口去获取可路由的 IP,然后在 SDN 网络控制器中配置转发规则。

网络方案的第三个阶段是多网络框架,它支持底层多变多样的基础网络结构,同时支持应用层面的联通性和管理的便捷性。这也是适配底层网络多样性的通用性解决方案,在容器内实现 Underlay 和 Overlay 网络的互通互联,在负载均衡层实现对外端口暴露,并将数据包转发到 Overlay 和 Underlay。

Overview

容器是独立的,没有存储任何网络相关信息。沿用 Docker 官网的一句话:

A container only sees a network interface with an IP address, a gateway, a routing table, DNS services, and other networking details.

开放端口

默认不开放端口,创建或运行容器时通过指定参数来暴露端口。其实就是在宿主机创建一个防火墙规则,将容器的端口路由到容器宿主机。

1
2
3
4
5
docker create -p 192.168.1.100:8080:80

docker run -p 8080:80

docker run -p 8080:80/udp -p 8080:80/tcp

如果参数重包括 localhost ip,那么只有 Docker 宿主机可以访问到:

1
docker run -p 127.0.0.1:8080:80 nginx

容器互通可以不用开放端口,使用 bridge network 即可。

IP address

容器会从每一个连接到的 Docker 网络中获取一个 ip,Docker 守护进程为容器执行动态子网划分和 ip 地址分配,每个网络都有一个默认的子网掩码和网关。

启动的时候只能指定连接到一个网络,但可以通过命令使得运行中的容器连接到多个网络。

1
docker network connect

DNS

默认继承宿主机的 DNS 配置,定义在/etc/resolv.conf中。容器连接默认网桥的时候会拷一份这个文件。连接自定义网络的时候会使用 Docker 内置的 DNS 服务器,它将外部 DNS 查询转发给宿主机上配置的 DNS 服务器。

同理,在开启和创建的时候能通过参数配置 DNS 解析。

有时候需要在容器中访问宿主机,那么需要将宿主机的地址通过域名映射的方式传给容器。而在宿主机上/etc/hosts中的文件不会被容器继承,可以在启动时这样达到同样的效果。

1
docker run --add-host=docker:93.184.216.34 --rm -it alpine

Network drivers

Docker 的网络子系统是可插拔的,使用默认的驱动程序来提供核心的网络功能。

bridge

默认的网络驱动,如果没有特殊指定一个驱动,就会创建这种网络类型,它常用于应用程序跑在容器中,需要与其他在同一宿主机上的容器进行交流。

在网络方面,网桥是在网段(network segments)之间转发流量的链路层设备,可以是硬件设备,也可以是运行在主机内核的软件设备。就 Docker 而言,网桥使用软件网桥,允许连接到同一个网桥的容器进行通信,同时提供与未连接到该网桥的容器的隔离。Docker 桥接驱动程序会自动在宿主机中安装规则,使得不同网桥上的容器之间不能直接通信。

当然,它只适用于运行在同一个 Docker 守护进程宿主机上的容器,对于不同 Docker 守护进程宿主机上的容器之间的通信,可以在 OS 级别管理路由,或者用覆盖网络(overlay network)。

启动 Docker 时会自动创建一个默认的网桥,名字也叫 bridge,新启动的容器除非特殊指定,否则也会连接到它。也可以创建自定义的网桥,

自定义网桥与默认网桥

  • 自定义网桥提供容器间的自动 DNS 解析。默认网桥的容器只能通过 ip 互相访问,而自定义的可以通过别名来互相解析,灵活度更高且更易迁移。

  • 自定义网桥能做到更好的隔离。所有没指定–network 的容器都会连到默认网桥,不相干的容器间也能进行访问,可能造成不好的影响。

  • 容器能随时与自定义网桥连接或分离。在容器的生命周期内可以随时连接或断开与自定义网桥的连接,但是要从默认网桥中移除容器,得停止容器并用不同的网络选项重新创建容器。

  • 自定义网桥可单独进行配置 比如 MTU 和 iptables 规则,默认网桥的所欲容器都用相同的设置,配置网桥后得重启 Docker。

  • 默认网桥上的容器共享环境变量

通过如下命令来创建或移除自定义网桥、亦或是将容器连接或断开与某个网桥的连接时,实际上发生的是 Docker 守护进程使用操作系统特定的工具去管理底层网络基础设施(如在 Linux 上添加或移除网桥设备或配置 iptables 规则)。

1
2
docker network create my-network
docker network rm my-network

默认网桥是 Docker 的历史遗留,不推荐在生产环境使用,而且配置它还需要额外的步骤,且存在技术缺陷(如上与自定义网络的差异)。

配置默认网桥

更改/etc/docker/daemon.json,更改后需要重启 docker 才会生效。

1
2
3
4
5
6
7
8
9
{
"bip": "192.168.1.1/24",
"fixed-cidr": "192.168.1.0/25",
"fixed-cidr-v6": "2001:db8::/64",
"mtu": 1500,
"default-gateway": "192.168.1.254",
"default-gateway-v6": "2001:db8:abcd::89",
"dns": ["10.20.1.2","10.20.1.3"]
}

看官网说的:Docker network failures with more than 1002 containers · Issue #44973 · moby/moby · GitHub 由于 Linux 内核限制,当一个网桥连了一千多个容器时,会变得不稳定,容器的通讯可能会断开。

Overlay

Overlay 在多个 Docker 守护进程主机之间创建一个分布式的虚拟网络,使得连到该网络的容器能经过加密后相互通讯,Docker 透明处理每个数据包到目标主机以及目标容器的路由。

前文提到过,在 Overlay 网络方案下,容器网络自建虚拟子网且自置一个 IPAM,可以有无限的虚拟 ip 地址分配,每一个在虚拟子网内的容器都具备一个独立可访问的 ip,通过对外暴露端口(即 Kubernetes 的 nodeport),可以访问到任意一个容器。但是虚拟机需要与容器通讯时,当虚拟机需要访问容器时,需要一层 NAT 网络转换,且无法指定某个容器实例,相当于随机网络访问。

Host

移除容器与宿主机的网络隔离,直接使用宿主机的网络。使用这种模式,容器会共享宿主机的网络命名空间(network namespace),这意味着 Docker 守护进程不会为该容器分配 ip,而该容器也不需要手动去暴露端口,在容器中跑了 80 端口的应用,宿主机就能直接通过自己的 80 端口访问到。

主机网络模式(host)主要用来优化性能,能减少大量端口映射导致的网络地址转换(NAT)损耗,而且不需要为每个端口创建代理。

Linux 内核网络

iptables 在 SLB、Container、Istio 以及 Kubernetes 等服务中应用非常广泛,比如容器和宿主机端口映射、Istio 中的透明流量劫持、Kubernetes 核心组件 kube-proxy 的 IPVS 模式等等都是通过 iptables 实现的。此处简要介绍 iptables 以及 Netfilter。

Netfilter

iptables 的底层实现是 Netfilter,它提供一整套 hook 函数管理机制,使得数据包过滤、包处理(设置标志位、修改 TTL)、地址伪装、网络地址转化、访问控制、协议连接追踪等成为可能。五个主要的 hook 链包括:PRE_ROUTING、LOCAL_IN、IP_FORWARD、LOCAL_OUT、POST_ROUTING。主要原理如下:

当网卡接收到一个包送达协议栈时,会在这几个关键 hook 处判断是否有对应的钩子函数,然后进行处理。

在 Linux 接收网络数据包的过程中,IP 层接受数据包的入口处理便是经过了 NF_HOOK 的过滤,如果有复杂的 filter 规则,会在此处加大网络延迟。

1
2
3
4
5
6
7
8
// file: net/ipv4/ip_input.c
int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev)
{
...
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL,
ip_rcv_finish);
...
}

iptables

iptables 是 netfilter 的操作接口,在用户空间管理应用于数据包的自定义规则,netfilter 则根据规则对应的策略处理数据包。实际上,iptables 的规则就是挂在 netfilter 钩子上的函数,用来修改数据包内容或过滤数据包,iptables 的表就是所有规则的逻辑集合。

对 Linux 稍有了解的都知道:iptables 就像繁忙城市交通中的交警,负责管理和控制网络数据包的流动。iptables 分为用户空间和内核空间两部分。用户空间的 iptables 命令向用户提供访问内核 iptables 模块的管理界面,而内核空间的 iptables 模块在内存中维护规则表,实现表的创建以及注册。

在 iptables 中,有四表五链的概念,每个表包含多个数据链,防火墙规则需要写入到具体的数据链中。

四表包括:

  • raw 表:负责控制 nat 表中连接追踪机制的启用状况。

  • managle 表:负责数据包的拆解、修改和再封装。

  • nat 表:负责数据包的网络地址转换。包括 SNAT 和 DNAT,SNAT 解决内网地址访问外部网络的问题,通过在 POSTROUTING 修改源 IP 实现,DNAT 解决内网服务要能被外部访问到的问题,通过 PREPROUTING 修改目标 IP 实现。

  • filter 表:负责数据包过滤功能,包括 drop、reject 等。

一个 IP 包经过 iptables 的处理流程如下,能直观看到各个表影响着不同的链:

通常情况下,一条 iptables 规则包含匹配条件和动作两部分,匹配条件如协议类型、四元组元素等,匹配条件可以组合,动作主要包括:

  • DROP:直接丢弃数据包

  • REJECT:返回 connection refused 或 destination unreachable 报文

  • QUEUE:将数据包放入用户空间队列,供用户空间程序使用

  • RETURN:跳出当前链,不再匹配后续规则

  • ACCEPT:允许数据包通过

  • JUMP:跳转到其他自定义链继续执行

以下是 iptables 防火墙的规则链,Chain INPUT 表示处理进入计算机到数据包,Chain FORWARD 用于处理通过计算机转发到数据包,Chain OUTPUT 处理离开计算机的数据包,Chain DOCKER 用于处理与 Docker 容器相关的数据包,其中 ACCEPT 表示允许任何源的目标端口为 ssh 的 TCP 包通过目标为 172.17.0.3 的主机(容器),即允许 SSH 连接到 Docker 容器。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
[root@harbor ~]# iptables --list
Chain INPUT (policy ACCEPT)
target prot opt source destination
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
ACCEPT all -- anywhere anywhere
INPUT_direct all -- anywhere anywhere
INPUT_ZONES_SOURCE all -- anywhere anywhere
INPUT_ZONES all -- anywhere anywhere
DROP all -- anywhere anywhere ctstate INVALID
REJECT all -- anywhere anywhere reject-with icmp-host-prohibited

Chain FORWARD (policy ACCEPT)
target prot opt source destination
DOCKER-USER all -- anywhere anywhere
DOCKER-ISOLATION-STAGE-1 all -- anywhere anywhere
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
DOCKER all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
DOCKER all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
DOCKER all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
DOCKER all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
DOCKER all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
ACCEPT all -- anywhere anywhere
FORWARD_direct all -- anywhere anywhere
FORWARD_IN_ZONES_SOURCE all -- anywhere anywhere
FORWARD_IN_ZONES all -- anywhere anywhere
FORWARD_OUT_ZONES_SOURCE all -- anywhere anywhere
FORWARD_OUT_ZONES all -- anywhere anywhere
DROP all -- anywhere anywhere ctstate INVALID
REJECT all -- anywhere anywhere reject-with icmp-host-prohibited

Chain OUTPUT (policy ACCEPT)
target prot opt source destination
ACCEPT all -- anywhere anywhere
OUTPUT_direct all -- anywhere anywhere

Chain DOCKER (5 references)
target prot opt source destination
ACCEPT tcp -- anywhere 172.17.0.3 tcp dpt:ssh

网络虚拟化

主要技术是 Network namespace,以及各类虚拟设备如 Veth、Linux Bridge 等,它们彼此协作,将独立的 namespace 连接形成一个虚拟网络。

Network namespace

namespace用于 Linux 内核隔离内核资源,其中 network namespace 则用于隔离网络资源。它能创建多个隔离的网络空间,通过man ip-netns可以知道:该网络空间内的防火墙、网卡、路由表、邻居表、协议栈与外部都是独立的(逻辑上),不管是虚拟机还是容器,当运行在独立的命名空间时,就像是一台单独的主机一样。

“network namespace is logically another copy of the network stack, with its own routes, firewall rules, and network devices.”

创建网络命名空间可以用 ip,此处创建了 ns1 和 ns2 两个命名空间,可以看到只有一个 loopback 的网络设备:

1
2
3
4
5
6
7
8
9
10
11
[root@harbor ~]# ip netns add ns1
[root@harbor ~]# ip netns add ns2
[root@harbor ~]# ip netns
ns2
ns1
root@harbor ~]# ip netns exec ns1 ip link list
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
[root@harbor ~]# ip netns exec ns2 ip link list
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

或者使用 nsenter 进入对应的 netns 查看(不要忘记 exit):

1
2
sudo nsenter --net=/var/run/netns/ns1 bash
sudo nsenter --net=/var/run/netns/ns2 bash

创建一对虚拟网卡,将 veth pair 一端放入 ns1,另一端放入 ns2:

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@harbor ~]# ip link add veth1 type veth peer name veth1-peer
[root@harbor ~]# ip link set veth1 netns ns1
[root@harbor ~]# ip link set veth1-peer netns ns2
[root@harbor ~]# ip netns exec ns1 ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2739: veth1@if2738: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether a2:48:9b:37:b0:37 brd ff:ff:ff:ff:ff:ff link-netnsid 0
[root@harbor ~]# ip netns exec ns2 ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2738: veth1-peer@if2739: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 16:94:b7:58:e9:3e brd ff:ff:ff:ff:ff:ff link-netnsid 0

由于初始时网卡状态为 DOWN,需要启用网卡并配置 ip 地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[root@harbor ~]# ip netns exec ns1 ip addr add 172.16.0.1/24 dev veth1
[root@harbor ~]# ip netns exec ns1 ip link set dev veth1 up
[root@harbor ~]# ip netns exec ns2 ip addr add 172.16.0.2/24 dev veth1-peer
[root@harbor ~]# ip netns exec ns2 ip link set dev veth1-peer up
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2739: veth1@if2738: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether a2:48:9b:37:b0:37 brd ff:ff:ff:ff:ff:ff link-netnsid 1
inet 172.16.0.1/24 scope global veth1
valid_lft forever preferred_lft forever
inet6 fe80::a048:9bff:fe37:b037/64 scope link
valid_lft forever preferred_lft forever
[root@harbor ~]# ip netns exec ns2 ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2738: veth1-peer@if2739: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 16:94:b7:58:e9:3e brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.16.0.2/24 scope global veth1-peer
valid_lft forever preferred_lft forever
inet6 fe80::1494:b7ff:fe58:e93e/64 scope link
valid_lft forever preferred_lft forever

测试两个 namespace 是否互通:

1
2
3
4
5
[root@harbor ~]# ip netns exec ns1 ping 172.16.0.2
PING 172.16.0.2 (172.16.0.2) 56(84) bytes of data.
64 bytes from 172.16.0.2: icmp_seq=1 ttl=64 time=0.072 ms
64 bytes from 172.16.0.2: icmp_seq=2 ttl=64 time=0.070 ms
64 bytes from 172.16.0.2: icmp_seq=3 ttl=64 time=0.051 ms

Veth Pair

“veth devices are virtual Ethernet devices. They can act as tunnels between network namespaces to create a bridge to a physical network device in another namespace, but can also be used as standalone network devices.”

Veth(Virtual Ethernet devices)是 Linux 中通过软件模拟的硬件网卡设备,由于成对出现,也称为 Veth Pair。其实就像一根网线,假设 veth0 和 veth1 是一对 veth 设备,从 veth0 发送数据,那么 veth1 就会收到数据。它常常充当一个桥梁连接着各种网络设备,典型的有:两个 namespace 的连接、Docker 容器之间的连接等,以此构造出各种复杂的虚拟网络。实际上,本机网络 IO 中的 lo 回环设备也是用软件虚拟出来的设备,区别在于 veth 是成对出现的。

root 与 container 交互:

Linux Bridge

由于简单 veth 互联方案难以做到多个容器的互联,因此采用的是 veth pair + bridge,这也是容器互联经典操作。bridge 是软件模拟硬件交换机,它拥有多个虚拟端口,能将多个虚拟网卡连接在一起,通过自己的转发功能让这些虚拟网卡之间进行通信。bridge 与物理虚拟机类似,有自学习功能,在内存中维护了一张转发表,通过目标 MAC 地址来转发数据包。

需要注意的是 veth 在 bridge 端是不需要分配地址的,因为 bridge 是工作在二层上,只会处理以太包,包括 ARP 解析,以太数据包的转发和泛洪,而不会进行三层(IP)的处理,因此不需要三层的 IP 地址。

路由

如果两个 namespace 处于不同的子网中,就无法通过作用于二层的 bridge 进行连接,只能通过路由器进行三层转发。所谓路由其实很简单,就是选择哪张网卡将数据写进去,至于选择哪张网卡呢,规则在路由表中指定。Linux 拥有多张路由表,最常用的是 local 和 main。

local 路由表统一记录本网络命名空间的网卡设备 IP 的路由规则。

1
2
3
[root@harbor ~]# ip route list table local
local 61.174.x.y dev eth0 proto kernel scope host src 61.174.x.y
local 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1

其余基本存在 main 表中,可以用ip route list table local或者route -n看。

除本机外,转发也涉及路由过程,在上文 iptables 中提到,当 Linux 收到数据包发现不是本机的包可以通过查找路由表找到合适的设备将其转发出去。在 ip_rcv 中将包送到 ip_forward 函数中处理,最后在 ip_ouput 函数将包转发出去,整个过程经过了 PREROUTING、FORWARD 和 POSTROUTINE 三个规则。

单主机容器网络问题

有了以上基础,我们可以尝试回答几个问题:

如何虚拟化网络资源,使得容器认为它们每个都拥有专用的网络堆栈?

在 Linux 中,要虚拟化网络资源以使容器认为每个都拥有专用的网络栈,可以通过使用网络命名空间(netns)和虚拟以太网设备(veth)实现。网络命名空间创建了独立的网络栈,每个容器可以与一个网络命名空间关联,从而实现网络隔离。veth 设备成对出现,连接主机的根网络命名空间(root netns)与容器的网络命名空间,使容器在通信时表现得好像拥有独立的网络栈,有效地利用网络资源,确保各个容器之间的网络环境隔离。

如何在容器之间实现隔离与通信,确保良好的容器互操作性?

为了在容器之间实现隔离与通信,我们可以利用 Linux 的网络命名空间(netns)和虚拟以太网设备(veth)。通过网络命名空间,每个容器都可以拥有独立的网络栈,从而实现隔离。然后,使用虚拟以太网设备将这些容器连接到一个虚拟网络交换机(bridge),使它们处于同一以太网段内,实现容器之间的通信。虚拟交换机会转发容器之间的数据包,保证良好的容器互操作性。通过这种方法,我们可以在同一主机上运行多个容器,实现资源共享,并确保它们在隔离的同时能够相互通信。

容器如何与外部网络连接?

为了使容器与外部网络连接,我们需要创建一个虚拟交换机(bridge),并将容器的虚拟以太网设备(veth)连接到该交换机。这样,容器之间可以相互通信,同时也可以通过交换机连接到主机和外部网络。为了实现这一连接,我们需要为交换机分配 IP 地址,并将其设置为容器的默认网关。同时,启用网络地址转换(NAT)功能,以便在容器发送到外部网络的数据包上替换源 IP 地址为主机的外部接口地址,从而使外部网络可以回复数据包给容器。最后,如果需要将容器的某个端口发布到主机的接口上,可以使用 iptables 进行端口转发,将到达主机特定端口的数据包重定向到容器的对应端口。通过这些步骤,我们可以实现容器与外部网络之间的连接,使容器能够与外部服务通信,并让外部网络能够访问容器的特定端口。

总结

本篇文章主要介绍容器网络,从容器网络的演变历史到 Docker 官网的一些描述,再到 Linux 内核网络、网络虚拟化来探究容器的大致底层实现原理,最后回答了单主机容器网络的几个问题。笔者在前一段时间深入学习过 Linux 网络相关知识,本以为容器网络无非是使用到几条命令,官网以及书上的几个简短说明这么简单,在翻阅参考资料与深入学习的过程中,笔者才发现容器网络有着非常庞大且复杂的体系,由于阅历与经验有限,文中多数部分都只是简单介绍就略过了。当然,此篇也只是初探开篇,后续会更深入理解容器网络相关的知识。

参考

]]>
<h2 id="演变历史">演变历史</h2> <p>容器网络的初期阶段,容器是一种封装技术,将应用和其依赖的环境打包在一起,方便快速部署和运行。对外暴露的方式相对简单:每个容器通过对应的端口暴露,外部程序可以通过不同的端口来区分不同的容器实例和应用实例。这种方案比较直接,没有引
一文了解 LDAP https://makonike.github.io/2023/07/04/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3LDAP/ 2023-07-04T15:50:17.000Z 2023-07-27T15:32:01.125Z LDAP(轻量级目录访问协议,Lightweight Directory Access Protocol),是一个开放的、中立的、工业标准的应用协议,经常用于身份验证以及存储关于用户、群组和应用程序的信息。LDAP Directory Server 是一个相对通用的数据存储,可在各种应用程序中使用。

为什么选择 LDAP

众所周知,数据存储有诸多类型,包括但不限于 NoSQL 类如 Redis、MongoDB 等,RDBMS 类如 MySQL、PostgreSQL 等,那么为什么要选择 LDAP 呢?

如果选择 NoSQL,基本上就将自己限定在了某种类型的数据库中,因为每种 NoSQL 数据库都有自己的协议。在 NoSQL 数据库中,包括多个种类,例如键值存储、文档数据库、列存储和图形数据库等。而每种类型的数据库都有自己独特的工作方式和协议。选择其中一种数据库意味着你将与这种数据库的特定协议绑定在一起,不同类型的 NoSQL 数据库之间的协议可能不兼容。

以 Redis 和 MongoDB 为例,Redis 使用一种简单而高效的文本协议与客户端进行通信,这个协议被称为 Redis 协议或 RESP(Redis Serialization Protocol)协议,MongoDB 则使用一种称为 MongoDB 协议的二进制协议与客户端进行通信。假设你的应用程序现在使用 Redis 作为数据存储,客户端与 Redis 之间的通信依赖于 Redis 的协议。如果你要将 Redis 服务器替换为 MongoDB 服务器,你需要确保所有的客户端应用程序都能够适应 MongoDB 的协议,否则它们没办法和 MongoDB 服务器进行通信。

为了解决这个问题,可以通过写一层 API 兼容层来兼容客户端应用程序与多种 NoSQL 服务器的通信,但不同的 NoSQL 存储的应用场景不同,写个兼容层还不如直接选择一个满足需求的存储来使用。

如果选择关系型数据库,虽然使用了 SQL,更加标准化,但仍然需要更新客户端应用程序来确保与新的数据库进行通信。此外,不同的关系型数据库的数据类型、特定函数、事务处理可能不同,而且大部分都对 SQL 有独特的解释和实现方式,所以在迁移数据库时可能还需要进行调整和优化

RFC 4511 明确定义了 LDAP 协议,其中包括客户端如何编码请求、服务端如何编码响应等,其中提供了很多种客户端和服务端的 API,所以不会绑死在一种客户端或服务端的 API 上。

LDAP 的特性

LDAP 经过多年的迭代,已经非常成熟,但它还在不断发展。最近的一个版本 LDAP v3 官方发行于 1997 年的十二月。

轻量

LDAP 是一个 X.500 的轻量级版本,使用 ASN.1BER 编码(一种用于高效编码和解码的紧凑的二进制格式),比起 HTTP 用的 JSON 或 XML 等还要更精简。

LDAP 使用持久连接与 directory server 通信,而许多现代基于 HTTP 的协议只是使用相对短暂的连接,LDAP 的连接却可以存活数小时或数天更长时间。基于 xxx,我们可以认为 LDAP 的连接更轻量级。

安全

LDAP Directory Server 通常被用作为认证库,和存储敏感信息,比如密码和账号信息等。LDAP 中包含了大量密码策略功能,如 strong envoding mechanisms 和 contraints,能够防止用户选择弱类型密码,也包括通过 SASL 对各种认证类型的支持,还包括通过 one-time passwords 等实现 two-factor 选项等。

此外,LDAP 还提供细粒度的访问控制支持,限制任何个人用户能够以哪些方式访问哪些 entries、attributes 和 values。基于 SQL 和 NoSQL 的应用通常用单个账户直接对数据存储操作,而 LDAP 应用程序通常作为终端用户来操作,能更加方便地限制访问和审计追踪。

基础概念

Directory Servers

一种网络数据库,以条目树形式存储数据。关系型数据库用的是由行列组成的表格,所以 Directory Servers 可以认为是一种 NoSQL 数据库。

虽然所有 Directory Servers 都支持 LDAP,但由于 LDAP 是一个开放的标准协议,所以有些服务器提供了对其他附加协议的支持,能够用于与数据进行交互,包括 X.500、命名服务协议如 DNS 和 NIS、基于 HTTP 的协议如 DSML 和 SCIM 以及其他专有的协议如 Novell 的 DNS。

Directory Servers - ldap.com

Entries

一个 LDAP 条目(entry)是关于一个实体信息的集合,每个条目由三个主要部分组成:一个分区名(distinguished name),一个属性集合(attributes)和一个对象类的集合(object classes)。

DNs 和 RDNs

一个条目的专有名称被称为DN,唯一表示该条目以及该条目在目录信息树DIT,directory information tree)中的位置,有点像文件系统中的文件路径。

一个 LDAP DN 由零个或多个元素组成,称为相对专有名称(RDN)。每个 RDN 由一个或多个属性 - 值对组成,如uid=john.doe表示一个 RDN 由一个叫 uid 的属性组成,它的值为 john.doe,如果有多个键值对组成,则用加号分开,如uid=john.doe+sn=Doe

不包含键值对的 DN 被称为 null DN,它引用了一种特殊类型的条目,称为根 DSE,提供关于 Directory Servers 的内容和能力的信息。

如果一个专有名称由多个相对专有名称组成,那么这些 RDN 的顺序决定了它们在目录信息树中的位置。RDN 之间用逗号分隔,每个 RDN 代表层次结构中的一个级别,按照从上到下的顺序(也就是离树根更近的位置)。如果从一个 DN 中移除一个 RDN,就相当于得到了该 DN 的父级 DN。举个🌰,DN“uid=john.doe,ou=People,dc=example,dc=com”包含了四个 RDN,其中父级 DN 是“ou=People,dc=example,dc=com”。

LDAP DNs and RDNs

Attributes

属性(Attributes)保存了一个条目的数据。每个属性都有一个属性类型(attribute type),零个或多个属性选项(attribute options),以及一组值(values)组成的实际数据。

属性类型是规定 LDAP 客户端和服务器如何处理属性的规则。属性类型必须有一个唯一的标识符(OID)和一个或多个名称来引用该属性。属性类型还定义了属性的数据类型和比较规则。它还可以指定属性是否可以有多个值,以及属性是用于保存用户数据还是服务器操作。

属性选项用于提供一些关于属性的额外信息,但并不经常使用。例如,属性选项可以用于在不同语言中提供属性值的不同版本。

Object Classes

对象类(Object classes)是一种模式元素,用来定义一组可能与特定类型的对象、过程或其他实体相关联的属性类型集合。每个条目都有一个主要对象类,它表示该条目所代表的对象类型(例如,人员信息、群组、设备、服务等),还可以有零个或多个附加对象类,用于提供该条目的其他特征。

就像属性类型一样,对象类也需要一个唯一的标识符,还可以有一个或多个名称。对象类还可以定义一组必需的属性类型(表示具有该对象类的条目必须包含这些属性)和/或一组可选的属性类型(表示具有该对象类的条目可以选择性地包含这些属性)。

想象一下,对象类就像一个描述对象类型及其属性的模板。每个对象都可以根据所属的对象类来确定应该具备哪些属性。

Object Identifiers (OIDs)

对象的唯一标识,是一个字符串。OID 由一系列用句点分隔的数字组成(例如,“1.2.840.113556.1.4.473”是表示服务器端排序请求控件的 OID)。在 LDAP 中,OID 用于标识模式元素(如属性类型、对象类、语法、匹配规则等)、控件以及扩展请求和响应。对于模式元素,还可以使用用户友好的名称来代替 OID。

简而言之,OID 是用于在 LDAP 协议和相关系统中唯一标识和区分不同组件和功能的特定标签。

Search Filters

搜索过滤器(Search Filters)用于定义标识包含特定信息的条目的条件。有很多种:

  • Presence filters,标识指定属性至少有一个值的条目
  • Equality filters,标识指定属性有特定值的条目
  • Substring filters,标识指定属性至少有一个值与被给定子字符串相匹配的条目
  • Greater-or-equal filters,标识指定属性至少有一个值被视为大于或等于给定值的条目
  • Less-or-equal filters,标识指定属性至少有一个值被视为小于或等于给定值的条目
  • Approximate match filters,标识指定属性的值与给定值近似相等的条目。近似相等的判断取决于服务器,可能是发音相等或其它的
  • Extensible match filters,用于提供更高级的匹配类型,包括使用自定义匹配规则和/或匹配条目 DN 中的匹配属性
  • AND filters,标识与 AND 内部封装的所有过滤器都匹配的条目
  • OR filters,标识与 OR 内部封装的至少一个过滤器匹配的条目
  • NOT filters,对封装的过滤器结果进行否定,相当于取反

匹配规则是用于执行匹配操作的逻辑,在属性类型定义中进行指定。不同的匹配规则可能使用不同的逻辑来进行确定。匹配规则定义了如何根据特定的逻辑规则进行匹配,以便在搜索和过滤操作中进行正确的比较和匹配。

Search Base DNs and Scopes

所有搜索请求都包括一个基本的 DN 元素,用于指定在哪个 DIT 部分查找匹配的条目,以及一个范围(scope),用于指定应考虑该子树的程度,不同的范围决定了搜索操作的深度和范围。定义的搜索范围包括:

  • baseObject 范围(base)表示只应考虑搜索基本 DN 指定的条目
  • singleLevel 范围(one/onelevel)表示只应考虑搜索基本 DN 的直接下级条目(但不包括基本条目本身)
  • wholeSubtree 范围(sub)表示应考虑搜索基本 DN 指定的条目以及所有在其下方的条目(到任意深度)
  • subordinateSubtree 范围表示应考虑搜索基本 DN 下方的所有条目(到任意深度),但不包括搜索基本条目本身

Modifications and Modification Types

修改请求用于向条目中添加、删除或替换属性值。每个修改都有一个类型,用于指定进行何种操作,例如添加、删除、替换或增量。通过修改请求,可以对 LDAP 服务器中的数据进行更新和修改。

  • 添加(add)修改类型表示应将一个或多个属性值添加到条目中。这可以用于添加全新的属性,或向现有属性添加新值。对于添加修改类型,必须至少指定一个属性值
  • 删除(delete)修改类型表示应从条目中删除一个或多个属性值,或者整个属性。如果删除修改包括一个或多个属性值,那么只会删除这些值。如果删除修改不包括任何值,则会删除整个属性
  • 替换(replace)修改类型表示应使用新集合(可能包含条目中已有的值)替换指定属性的值集合。如果替换修改有一个或多个属性值,则这些值将用于相关的属性。如果替换修改没有任何值,且如果存在该属性,它将从条目中移除。
  • 增量(increment)修改类型表示应将指定属性的整数值增加指定的数量(如果增量值为负,则减少)。

LDAP URLs

LDAP URL 是一种用于定位目录服务器和条目以及搜索条件的标识符,在 LDAP 协议中被广泛用于引用和链接到目录服务器中的数据。它封装了信息,可以用于引用目录服务器、特定条目和搜索标准,以便在目录服务器中识别匹配的条目。

Controls

控制项是 LDAP 请求或响应中的一段额外信息,用来提供更多关于请求或响应的信息,或者改变服务器(对于请求)或客户端(对于响应)对其的处理方式。就像我们可以在请求中附带一张“便条”,告诉服务器我们需要对数据进行特殊处理,或者在响应中附带一些额外的指示。比如,服务器端排序请求控制项可以告诉服务器在将搜索结果返回给我们之前,先按照特定的方式对结果进行排序。

控制项有三个元素:

  • 唯一标识符(OID),用于标识控制项类型
  • 关键性,用于指示控制项是否是请求的关键部分。关键性为“true”表示控制项是请求的关键部分,如果服务器无法支持控制项,应拒绝请求。关键性为“false”表示控制项更像是请求的“额外要求”部分,如果服务器无法支持控制项,则应继续处理操作,就好像没有包含该控制项一样。如果服务器确实支持请求范围内的控制,那么关键性就不会起作用
  • 可选值,用于提供控制项处理所需的附加信息。例如,对于服务器端排序请求控制项,控制值应指定期望的排序顺序。控制项的具体编码方式会根据控制项的类型而有所不同

Referrals

简单来说,引荐(referral)是一种 LDAP 响应,表示服务器无法处理所请求的操作,但建议在其他地方尝试(例如在不同的服务器或 DIT 中的不同位置),可能会成功。引荐可能出现的原因包括:

  • 客户端请求的操作针对的条目在连接的服务器中不存在,但服务器能够建议该条目可能存在的位置
  • 客户端请求的操作针对的条目在服务器中确实存在,但由于某种原因,服务器目前无法处理该请求。例如,客户端向只读副本发送写入请求,副本可以将请求重定向到可写服务器
  • 数据中包含一种特殊类型的引荐条目(有时称为“智能引荐”),每当客户端请求该条目或其下属内容时,服务器根据该条目的内容生成引荐

Alias Entries

简单来说,别名条目是一种特殊类型的条目,类似于符号链接在文件系统中指向另一个文件

别名条目主要用于搜索操作,它可以使位于 DIT 中的一个位置的条目在另一个位置上出现。在某些情况下很有用,例如,当一个条目在特定子树中的存在用于确定群组成员身份或表示某种授权目的时。搜索请求包括一个元素,指示如何处理搜索过程中遇到的任何别名。

针对别名条目的非搜索操作不会跟随别名进行操作。别名不能作为绑定操作的目标标识。别名必须是叶子条目,因为无法在别名条目下添加条目。

需要注意的是,并非所有目录服务器都支持别名。如果应用程序打算与广泛范围的目录服务器兼容,应避免使用别名。

LDAP 模式

在关系型数据库中,模式包含了数据库结构的信息,包括表结构的信息、关于每个表的列的信息以及每个列的数据类型和约束。在 LDAP 中,模式提供了很多类似的信息,但是由于信息的排列方式关系型数据库不同,表达方式也不同。

一个 LDAP 模式包含多个类型的元素,每个模式都必须包含以下几个:

  • Attribute Syntaxes 定义了能在目录服务器中表示的数据类型
  • Matching Rules 定义了能对 LDAP 数据进行比较的种类
  • Attribute Types 定义了可存储在条目中的命名信息单位
  • Object Classes 定义了可以在包含该对象类的条目中使用的属性类型的命名集合,以及这些属性类型哪些是可选的,哪些是必选的

一些额外的元素:

  • Name Forms,可用于限制可作为特定类型条目的命名属性的属性种类
  • DIT Content Rules,可用于增强对象类的定义,进一步指出必须、可选和不能出现在特定类型的条目中的属性种类
  • DIT Structure Rules,可用于定义服务器中允许存在的层次关系的信息
  • Matching Rule Uses,可用于对可使用特定匹配规则的属性种类加限制

操作类型

Bind

绑定操作(Bind)是用于让客户端在目录服务器上进行身份认证的方式。它确保客户端能够证明自己的身份,并建立一个用于后续操作的授权身份,并指定客户端将使用的 LDAP 协议版本。

认证通常包括两个部分:确定是谁进行认证以及提供一些证据来证明身份(通常是只有用户自己知道的密码、证书、硬件或软件令牌或生物特征信息等)。在进行绑定操作时,服务器可能还会执行其他步骤,如检查密码策略和满足其他限制条件,以确保绑定成功。

LDAP 绑定请求提供了两种认证方式:简单认证SASL 认证。简单认证是通过使用帐户的唯一标识(DN)密码来进行身份验证。密码以明文形式传输,因此强烈建议只在加密连接下使用简单认证。匿名简单绑定(anonymous simple bind)是通过提供空的 DN 和密码来进行的。

SASL 认证使用了一种名为简单认证和安全层(SASL)的标准。它是一个可扩展的框架,可以将各种认证机制集成到 LDAP 中。SASL 认证通过使用一种特定的机制和编码的凭据来完成。某些机制可能需要多次请求和响应来完成完整的认证过程。

LDAP 绑定请求包含三个要素:

  • 客户端希望使用的 LDAP 协议版本。目前大部分新的应用都是使用 v3,少部分非常老旧的客户端使用 v2。
  • 认证用户的 DN。对于匿名简单认证,这应该为空;对于 SASL 认证,由于大多数 SASL 机制在编码的凭据中标识目标帐户,因此通常为空。对于非匿名简单认证,它必须为非空值。
  • 认证用户的凭据(一般是密码)。对于简单认证,这是指定绑定 DN 的用户的密码(或匿名简单认证的空字符串)。对于 SASL 认证,这是一个编码值,其中包含SASL 机制名称可选的编码 SASL 凭据集

如果 LDAP 客户端在没有进行绑定的情况下发出其他类型的请求,则客户端将被视为未经身份验证。这与匿名简单绑定(使用空绑定 DN 和空密码)导致的身份验证状态相同,也是绑定操作失败导致的身份验证状态。

当简单绑定操作完成时,服务器将返回一个基本响应,其中包括结果代码、可选的匹配 DN、诊断消息、引荐和/或响应控制。SASL 绑定响应还可以包括编码的服务器 SASL 凭据,用于后续处理。对于需要多个请求/响应周期的 SASL 机制,除了最后一个响应之外,所有响应都将包含一个“SASL 绑定正在进行中”的结果代码,以指示身份验证过程尚未完成。

LDAP Search 操作能检索匹配给定条件的条目的数据。请求参数包括:

  • base DN:检索的起始点,必须提供,但可能是 null DN。操作将会在该指定子树检索。
  • search scope:目标子树的检索范围,支持以下几个选项
    • baseObject(base):搜索操作只会在指定的条目上进行,不会扩展到它的子条目。
    • singleLevel(one):搜索操作仅限于基础条目的直接子级,不包括它的其余子级和它自己。
    • wholeSubtree(sub):整个子树搜索将包括搜索基础条目以及其下的所有子孙级条目,但不包括根 DSE(如果搜索基础 DN 为空)。
    • subordinateSubtree(subordinates):从搜索基础的下一级开始,包括其所有子级和子孙级条目,但不包括搜索基础条目本身。
  • DerefAliases:别名解引用行为,指明了服务器在检索过程中应该如何处理遇到的别名。以下是支持的解引用行为:
    • neverDerefAliases:表示服务器在处理搜索时应解引用遇到的任何别名。
    • dereflnSearching:表示服务器不应尝试解引用搜索基础条目,但应解引用范围内遇到的任何别名。
    • derefFindingBaseObj:表示如果作为搜索基础的条目是一个别名,则服务器应解引用该别名,但不应解引用范围内遇到的任何别名。
    • derefAlways:表示如果作为搜索基础的条目是一个别名,则服务器应解引用该别名,并且应解引用范围内遇到的任何别名。
  • Size limit:限制检索结果中返回条目的最大数量。如果为 0 则说明不限制。服务端可能也会对检索结果进行限制,此时取二者较小的那个。
  • Time limit:检索过程的时间限制,相当于请求超时,单位为秒。服务端可能也对检索时间进行限制,此时同上取二者较小的那个。
  • typesOnly:一个 flag,如果设置为 true,则表示匹配搜索条件的条目应只返回包含属性描述的条目,而不包含这些属性的值。如果将其设置为 false,则表示返回的条目应包含属性值
  • search filter:过滤器,用于检索匹配条件,上文有提到过。
  • attributes:一个属性集合,返回的条目只包含这些属性。然后还有些特定值:

当目录服务器接收到一个有效且经过授权的搜索请求时,它将识别出在指定范围内与给定过滤器匹配的任何条目。所有这些条目(或者至少是请求者有权限检索的条目)将以搜索结果条目消息的形式返回给客户端。每个搜索结果条目消息将包含匹配条目的 DN,以及该条目中包含的零个或多个属性,这取决于搜索请求中请求的属性集合以及请求者有权限检索的属性集合。如果搜索请求的 typesOnly 值为 true,则这些属性将返回而不包含其值;否则,属性将返回与请求者有权限检索的所有值

比较有意思的是如果服务器在搜索过程中确定可能有其他服务器中与搜索条件匹配的条目,那么服务器还可以返回一个或多个搜索结果引用消息,其中包含引荐 URI,客户端可以选择是否跟随。

一旦服务器返回了所有适当的搜索结果条目和搜索结果引用消息,它将返回一个搜索结果完成消息,表示该搜索操作的所有处理已完成。这个搜索结果完成消息是一个基本的 LDAP 响应,包括一个结果代码,以及可选的匹配 DN诊断消息引荐和/或响应控制

实践

envoy-go-ldap-auth

GitHub - mosn/envoy-go-ldap-auth: Envoy golang filters that will be provided in golang hub

笔者近期在研究MOSN时,为MOSN社区贡献了一个基于MoE框架的LDAP过滤器,旨在为Envoy集成LDAP认证功能,感兴趣可以看一下。基于MoE框架的Go Filter有着以下几点好处:

  • 动态加载Go Filter,无需重新编译Envoy,节省开发与部署时间。
  • 可以使用Go的全部特性,享受Go生态带来的便利。
  • 保证了内存安全、兵法安全以及沙箱安全。

总结

本文介绍了 LDAP 协议,为什么选择 LDAP,以及 LDAP 的特性、基础概念、模式和操作类型等。LDAP 在各行各业中得到广泛应用,包括企业组织、教育机构、政府部门等。它被用于集中管理用户身份和权限,实现单点登录、身份验证和访问控制等功能,加上跨平台的特性使得 LDAP 成为跨多个系统和应用集成的标准选择。关于 LDAP 的内容还有很多本文没有介绍,详情可以见参考。

参考

]]>
<p>LDAP(轻量级目录访问协议,Lightweight Directory Access Protocol),是一个开放的、中立的、工业标准的应用协议,经常用于身份验证以及存储关于用户、群组和应用程序的信息。LDAP Directory Server 是一个相对通用的数据存储
关于我的三个月杭漂 https://makonike.github.io/2023/07/01/%E5%85%B3%E4%BA%8E%E6%88%91%E7%9A%84%E4%B8%89%E4%B8%AA%E6%9C%88%E6%9D%AD%E6%BC%82/ 2023-07-01T07:55:13.000Z 2023-07-04T15:58:43.196Z 来杭州差不多三个月了,简单写一写杭漂三个月以来的感悟。

对杭州的第一印象

记得刚来杭州的时候很冷,刚下绿皮就哆嗦着穿多一件卫衣,现在杭州却热得有点受不了了,感觉和广州不分高下。杭州给笔者的第一印象是干净,到杭州那一天的下午,笔者去办杭州银行的银行卡,一路上发现和广州的最大区别是杭州非常干净,没有看到广州街道上随处可见的垃圾,但是沿路上都没见到垃圾桶(

打破第一次

打破了很多人生第一次

  • 第一次一个人来到完全陌生的城市

  • 第一次独立生活

  • 第一次参与全职工作

  • 第一次正式参与开源

  • 。。。

工作方面,笔者是第一次实习。虽然是几百人的小公司,但是实习体验还是 OK 的。脱离学校后,真正地接触到生产环境,对各种利弊权衡有了更深的把握,对互联网公司的工作流程也有了真实的体验。

社交方面

虽说职场上不得与同事交心,但是组里的人都挺好的,一起工作也很快乐,至少在前两个月没什么压力。只可惜五月初 Leader 和导师都调去带新组了,其实还是挺想念他们的。

笔者早在一个玩具开源项目 GoFound 的微信交流群中认识了一个在杭州工作的群友,在来到杭州后也成功线下面基了,一起搓了好几顿。此外,笔者也有个高中同学在杭电就读,期间也约出来到处逛了几次。在实习期间也认识了几个同实习的朋友,一起交流学习、娱乐吹水和吃饭。在五月中旬,也有两个认识的同校好友来杭州实习,六月时更是有一个师弟也来杭州实习了,因此总体来看其实也没那么孤单。

穷游

其实实习的薪资并不高,在高物价的杭州自然也攒不下什么钱,索性来杭州旅游了。现在回想起来,其实开销大处都在于约饭,由于有地铁直达,想要穷游杭州各个景点其实花费不过几块钱地铁交通费而已。

西湖

印象中是到了杭州的第二天就去看了,第一次去坐地铁 + 公交,可惜路线和时间规划有点问题,到了西湖比较偏的地方,虽然人比较少,但是太阳已经要下山了,手机也因为奔波快没电了,所以错过了西湖日落= =,匆匆观望一下就走了。

西湖 1

第二次去看西湖也是晚上,本想着是去湖滨步行街那边散散步的,发现西湖在附近,走着走着就到了。湖滨步行街的人太多了==,特别是龙翔站那里,不想再去体验第二次。走在西湖边,走了一路吃了一路狗粮。

西湖 2

西湖 3

西湖 4

良渚

由于笔者对古历史的认知匮乏,笔者对良渚的感觉是“啥也没有”。很多东西虽然有介绍,但经过太长时间的迭代后,最终也只是以土坡的形式呈现。历史的奥妙也在于此,让人只觉时光流逝。

里面的小鹿真可爱,鸽舍的鸽子走起路来脑袋一晃一晃的,感觉太傻了。

良渚 1

良渚 2

良渚 3

大明山

没想到五一假期这里的人还挺少的,唯一的遗憾是同行伙伴的钱包身份证全在路上掉了。由于出行的计划没安排好,大巴站点的时间有点对不上,所以只能坐大巴到一半后只能打车去,回杭州也是直接打车的,在打车上就花了好几百米。

山上挺多矿洞的,里面很冷,吹的风很凉快。大明湖看起来真不错,可惜貌似没有路能走下去。

大明山 1

大明山 2

大明山 3

大明山 4

大明山 5

开源与学习

这段时间主要学了一下 Rust 和一些关于权限控制方面的知识,也正式踏出了开源贡献的第一步。其实在接触 Github 至今我已经为多个 repo 贡献了文档或 typo 修复,尽管看的代码也很多,但是却很少去贡献代码。

五月份的时候为学校团队使用已久的 Gitea 提出了第一个 PR,被合并的时候是真的开心,也希望后面能继续为 Gitea 以及其它开源项目贡献代码。

New webhook trigger for receiving Pull Request review requests by Makonike · Pull Request #24481

开源 1

开源 2

]]>
<p>来杭州差不多三个月了,简单写一写杭漂三个月以来的感悟。</p> <h2 id="对杭州的第一印象">对杭州的第一印象</h2> <p>记得刚来杭州的时候很冷,刚下绿皮就哆嗦着穿多一件卫衣,现在杭州却热得有点受不了了,感觉和广州不分高下。杭州给笔者的第一印象是干净,到杭州那一
一文了解 WebSocket 协议 https://makonike.github.io/2023/05/06/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3WebSocket%E5%8D%8F%E8%AE%AE/ 2023-05-06T14:56:30.000Z 2023-05-07T07:31:00.598Z 为什么需要 WebSocket

短轮训->长轮训->基于流->WebSocket

TCP 长连接就是 WebSocket 的基础,但是如果是 HTTP 的长连接,本质上还是 Request/Response 消息对,仍然会造成资源的浪费、实时性不强等问题

特点

  • 建立在 TCP 之上

  • 与 HTTP 有良好兼容性

  • 没有同源限制,客户端可以与任意服务器通信

  • 标识符是ws/wss,服务器地址是URL

  • 可以发送文本和二进制数据

  • 数据格式轻量,性能开销小,通信高效。连接创建后,ws 客户端、服务端进行数据交换时,协议控制的数据包头部较小。在不包含头部的情况下,服务端到客户端的包头只有 2~10 字节(取决于数据包长度),客户端到服务端的的话,需要加上额外的 4 字节的掩码。而 HTTP 协议每次通信都需要携带完整的头部;

建立连接

WebSocket 的目的是取代 HTTP 在双向通信的场景下使用,所以有些实现方式也是基于 HTTP 的(比如默认端口为 80/443),有向下兼容的意思。

客户端发起协议升级

1
2
3
4
5
6
7
8
9
GET / HTTP/1.1
Host: localhost:8080 // 前两行都和HTTP的Request起始行一样
Origin:http://127.0.0.1:3000 // 标明原始域,防止跨站攻击
Connection: Upgrade // 表示升级协议。
Upgrade: websocket // 表示升级到websocket。upgrade是HTTP1.1用来定义转换协议的header域
Sec-WebSocket-Version: 13 // 表示websocket的版本
// 如果服务端不支持该版本,则返回一个Sec-WebSocket-Versionheader,包含服务端支持的版本号
Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==
// 与后文服务端响应首部的Sec-WebSocket-Accept配套,提供基本的防护。如恶意连接或无意义连接

服务端响应协议升级

101 表示服务器收到了客户端切换协议的请求,并且同意切换到此协议。

1
2
3
4
HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=

服务端回应的 HTTP 状态码只能在握手阶段使用。过了握手阶段后,就只能采用特定的错误码。

Sec-WebSocket-Accept 计算

根据客户端请求首部的 Sec-WebSocket-Key 进行计算。

  • 将 Sec-WebSocket-Key 跟 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接

  • 通过 SHA1 计算出摘要,并转成 base64 字符串

toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) )

Sec-WebSocket-Key/Accept 作用

主要用于提供基础防护,减少恶意连接和意外连接

  • 为了避免服务端收到非法 websocket 连接(如 http 客户端不小心请求 websocket 服务)。

  • 确保服务端能理解 websocket 连接,客户端能通过 Sec-WebSocket-Key 来确保认识 ws 协议(服务端不仅需要处理 Sec-WebSocket-Key,还需要实现 ws 协议,否则没有意义)。

  • 在浏览器中发起 ajax 请求,设置 Header 时,Sec-WebSocket-Key 以及其他相关 header 是被禁止的,防止 ajax 请求意外请求升级升级。

    • 以“Sec-”开头的 Header 可以避免被浏览器脚本读取到,这样攻击者就不能利用 XMLHttpRequest 伪造 WebSocket 请求来执行跨协议攻击,因为 XMLHttpRequest 接口不允许设置 Sec-开头的 Header。
  • 可以防止反向代理返回错误的数据。

  • Sec-WebSocket-Key 主要目的不是为了确保数据安全性,因为转换公式时公开的,主要预防一些常见的非故意意外情况。

只能带来基本保证,无法确保连接安全、数据安全、客户端/服务端是否合法。

Sec-WebSocket-Protocol 子协议

详情见:WebSocket API: Sec-WebSocket-Protocol (Subprotocol) header support

在笔者初识子协议时,曾用其作为 Token 的存放位置 qwq,因为 WebSocket 无法自定义请求头。实际生产场景中,可以通过 SubProtocol 区分不同的应用场景。比如笔者的应用场景有推送、协同两种,就能自定义 SubProtocol 去区分。

数据帧格式

通信的最小单位是帧 frame,由一个或多个帧组合为一条完整的消息 message

  • 发送:将消息切割为多个帧,并发送给服务端

  • 接收:组成一个完整消息

详细定义:RFC ft-ietf-hybi-thewebsocketprotocol: The WebSocket Protocol

数据帧统一格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
  • FIN 1bit

如果为 1,表示这是该消息的最后一个分片。否则为 0

  • RSV1, RSV2, RSV3 3bit

一般情况下为全 0,用于客户端与服务端协商采用 WebSocket 扩展,值由扩展进行定义,如果出现非 0 值且没有使用扩展,则连接出错

  • Opcode 4bit

操作代码,决定该如何解析后续 payload。如果操作代码未知,则接收端应断开连接。可选如下

1
2
3
4
5
6
7
8
%x0:表示一个延续帧。当Opcode为0时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。
%x1:表示这是一个文本帧(frame)
%x2:表示这是一个二进制帧(frame)
%x3-7:保留的操作代码,用于后续定义的非控制帧。
%x8:表示连接断开。
%x9:表示这是一个ping操作。
%xA:表示这是一个pong操作。
%xB-F:保留的操作代码,用于后续定义的控制帧。
  • Mask 1bit

表示是否要对 data payload 进行掩码操作。从客户端发向服务端要,从服务端发向客户端不用。当服务端接收到未进行掩码操作的数据,服务端应该断开连接

Mask 为 1,Masking-key 中应该定义一个掩码键 masking key,并用其对 data payload 进行反掩码。所有客户端发到服务端的帧,mask 都为 1

  • Payload length 7bit

data payload 的长度,单位为字节。如果 patload length == x

1
2
3
x为0~126:数据的长度为x字节。
x为126:后续2个字节代表一个16位的无符号整数,该无符号整数的值为数据的长度。
x为127:后续8个字节代表一个64位的无符号整数(最高位为0),该无符号整数的值为数据的长度。

payload length 下面有扩展的长度。如果他超过 1 个字节,payload length 的二进制表达采用网络序(big endian,重要的位在前)

  • Masking-key 32bit

Data playload length 不包含 masking-key

  • Playload data x+y bytes

包含扩展数据和应用数据,其中扩展数据 x 字节,应用数据 y 字节

扩展数据:不协商使用就为 0。所有扩展数据都要声明扩展数据的长度,或者声明如何能计算出扩展数据长度。都在握手阶段协商好,如果扩展数据存在,那么 data payload length 就包含扩展数据长度

应用数据:任意的应用数据,在扩展数据之后。data payload length - 扩展数据长度即为应用数据长度

掩码算法

Masking-key 是客户端挑出的 32bit 随机数。

掩码和反掩码操作都用如下算法

定义:

1
2
3
4
original-octet-i:为原始数据的第i字节;
transformed-octet-i:为转换后的数据的第i字节;
j:为i mod 4的结果;
masking-key-octet-j:为mask key第j字节。

original-octet-imasking-key-octet-j异或后得到transformed-octet-i

1
2
j = i mod 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j

掩码操作不会影响数据载荷的长度。

数据掩码作用

主要作用是增强协议安全性,但不是为了保护数据本身,因为算法本身就是公开的,且不复杂。实际作用是为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks)等问题。

详细如下:Talking to Yourself for Fun and Profit

整个攻击过程分为两步:

攻击步骤一:

  1. 攻击者浏览器 向 邪恶服务器 发起 WebSocket 连接。根据前文,首先是一个协议升级请求。

  2. 协议升级请求 实际到达 代理服务器

  3. 代理服务器 将协议升级请求转发到 邪恶服务器

  4. 邪恶服务器 同意连接,代理服务器 将响应转发给 攻击者

由于 upgrade 的实现上有缺陷,代理服务器 以为之前转发的是普通的 HTTP 消息。因此,当 邪恶服务器 同意连接,代理服务器 以为本次会话已经结束。

攻击步骤二:

  1. 攻击者 在之前建立的连接上,通过 WebSocket 的接口向 邪恶服务器 发送数据,且数据是精心构造的 HTTP 格式的文本。其中包含了 正义资源 的地址,以及一个伪造的 Host(指向 正义服务器)。

  2. 请求到达 代理服务器。虽然复用了之前的 TCP 连接,但 代理服务器 以为是新的 HTTP 请求。

  3. 代理服务器邪恶服务器 请求 邪恶资源(script.js)。

  4. 邪恶服务器 返回 邪恶资源代理服务器 缓存住 邪恶资源(url 是对的,且 Host 是 正义服务器 的地址)。

到这里,受害者可以登场了:

  1. 受害者 通过 代理服务器 访问 正义服务器正义资源

  2. 代理服务器 检查该资源的 url、host,发现本地有一份缓存(伪造的)。

  3. 代理服务器邪恶资源 返回给 受害者

  4. 受害者 卒。

整个过程最大的 Bug 点是“在 upgrade 协议实现上有缺陷的代理服务器(错把 Websocket 当做是普通的 HTTP 消息)”,而数据掩码也就是为了针对这类“愚蠢”的代理服务器。因为数据掩码在每次消息传输中都由客户端随机生成,经过掩码算法后,明文消息变成了不被识别的字节,代理服务器发现每次传来的消息都不同,那它只好选择通过,而不是做缓存处理

如果没有这个掩码限制,攻击者只需要在网上放个钓鱼网站骗人去访问,就可以在短时间内展开大范围攻击。安全的范围很大,防止代理缓存污染攻击也算在安全范畴内,所以不要局限于一点,这样容易进死胡同。

数据传输

连接建立后,后续操作都是基于数据帧传递,根据 opcode 来区分操作类型

数据分片

WebSocket 接收方每收到一个数据帧都会根据 FIN 来判断是否已经收到消息的最后一个数据帧。此时 opcode 在数据交换的场景下表示数据类型,如 0x01 位文本,0x02 为二进制。0x00 表示延续帧 continuation frame,表示完整消息对应的数据帧还没接收完。

🌰:Writing_WebSocket_server

第一条消息表示发送的是文本类型,第二、三条消息表示完整消息未被接收完,第四条消息表示文本类型。

1
2
3
4
5
6
7
8
Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (process complete message) Happy new year to you too!

心跳

引入TCP/IP的心跳、KeepAlive问题

对应 WebSocket 操作中的 ping 和 pong,opcode 分别为 0x9、0xA。

一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等

在绝大部分场景中,由客户端来发送心跳是最佳实践。考虑到 Session 残留的情况:TCP 的保活机制非常不灵敏,完全不适用于正常业务场景,所以使用底层代码库实现的 max_idle_time 来限制。

心跳保活与 idle_time

通过心跳保活与 WebSocket 底层实现库设置 idle_time 来使残余 Session 关闭。在笔者的设计中,在 WS 握手成功后的连接回调函数中,会为该连接对应的 Session 设置一个 max_idle_time,后续过程中,由客户端来发送心跳保活。

笔者业务中使用的是原生的 websocket 包,可以看到核心代码如下:

在建立连接后,如果当前 endpoint 对应的 SessionMap 没有 Session(意味着当前连接是第一个连接),会创建一个后台线程,然后注册当前 Session 到内存中的 sessions 中,这是一个 map,key 和 value 存放的都是 WsSession 实例。

其实上图走的是BackgroundProcessManager.getInstance().register(this);

在调用BackgroundProcessManager.getInstance().register(this)时,会开一个 WsBackgroundThread 线程,它的主要任务是每秒跑一次manager.process(),它会获取所有 process,并一一执行process.backgroundProcess()

对应到 WsWebSocketContainer 的实现,即获取每个 Session 的实例,调用 checkExpiration,下面代码贴在一起:

核心判断语句是:(timeout > 0 && (currentTime - lastActiveRead) > timeout && (currentTime - lastActiveWrite) > timeout),它检查上次读/写活跃时间是否已经超过了设定的最长空闲时间 maxIdleTimeout,如果超过,则调用 doClose 回调函数,主动关闭 Session。

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
37
38
39
@Override
public void backgroundProcess() {
// This method gets called once a second.
backgroundProcessCount ++;
if (backgroundProcessCount >= processPeriod) {
backgroundProcessCount = 0;

for (WsSession wsSession : sessions.keySet()) {
wsSession.checkExpiration();
}
}
}

protected void checkExpiration() {
// Local copies to ensure consistent behaviour during method execution
long timeout = maxIdleTimeout;
long timeoutRead = getMaxIdleTimeoutRead();
long timeoutWrite = getMaxIdleTimeoutWrite();

long currentTime = System.currentTimeMillis();
String key = null;

if (timeoutRead > 0 && (currentTime - lastActiveRead) > timeoutRead) {
key = "wsSession.timeoutRead";
} else if (timeoutWrite > 0 && (currentTime - lastActiveWrite) > timeoutRead) {
key = "wsSession.timeoutWrite";
} else if (timeout > 0 && (currentTime - lastActiveRead) > timeout &&
(currentTime - lastActiveWrite) > timeout) {
key = "wsSession.timeout";
}

if (key != null) {
String msg = sm.getString(key, getId());
if (log.isDebugEnabled()) {
log.debug(msg);
}
doClose(new CloseReason(CloseCodes.GOING_AWAY, msg), new CloseReason(CloseCodes.CLOSED_ABNORMALLY, msg));
}
}

至于更新 lastActiveRead 与 lastActiveWrite,自然会在读写时做更新。

1
2
3
4
5
6
7
8
protected void updateLastActiveRead() {
lastActiveRead = System.currentTimeMillis();
}


protected void updateLastActiveWrite() {
lastActiveWrite = System.currentTimeMillis();
}

以上就是 WebSocket 代码库 idle_time 实现清除参与 Session 的原理。

一句话来说:就是底层代码库会为 endpoint 创建 background 线程,每秒一次去 checkExpiration,主要检查 lastActiveRead 与 lastActiveWrite 是否距离当前时间已经超过了 maxIdleTimeout。

长连接实时推送网关

解决痛点

  1. 技术栈不统一,开发和维护困难

  2. WebSocket 实现分散在各个工程,与业务系统强耦合,其余业务需要集成时,会有重复开发、浪费成本的情况,效率低下

  3. WebSocket 是有状态协议,集群需要解决共享会话功能,如果单节点部署无法水平扩展支撑更高负载,有单点风险

  4. 缺乏监控和报警。虽然可以通过 LinuxSocket 连接数大致估计,但是不准确,且没有业务指标数据,无法与现有微服务框架整合

技术目标和价值

  1. 封装 WebSocket 通信细节,与业务系统节藕,双方可以独立迭代,避免重复开发,便于开发和维护

  2. 提供了 HTTP 接口,便于各个其他开发语言接入,便于系统集成和使用

  3. 采用分布式架构,实现服务水平扩展、负载均衡和高可用

  4. 网关集成监控和报警,便于及时排查和解决问题

技术选型

选用:Netty:高性能,易扩展,社区活跃

WebSocket 是有状态的,无法像直接 HTTP 以集群方式实现负载均衡,长连接建立后即与服务端某个节点保持着会话,因此集群下想要得知会话属于哪个节点有点困难。

一般有如下两种解决方案:

  • 使用类似微服务的注册中心维护全局的会话映射问题

  • 使用事件广播由各节点自行判断是否持有会话

广播实现方案,其实就是选 mq:

在笔者所参与的项目中,由于业务场景中消息无需保证可靠,因此选用:Redis 的 Pub/Sub,少一个依赖的 mq,维护方便,实现简单。如果需要保证消息可靠,还是推荐 RocketMQ 或者 Kafka。

实现思路

架构图:

网关整体流程:

  1. 客户端与网关任意一个 node 握手建立长连接,节点将其加入到内存维护的长连接队列(集合)中。客户端定时向服务端发送心跳,如果超过设定时间仍没有收到心跳,则认为客户端与服务端的长连接已断开,服务端回关闭连接,清理内存中会话。详见心跳

  2. 当业务系统需要给客户端推送数据时,通过网关提供的 HTTP 接口向网关发送请求

  3. 将数据信息写到 mq 里

  4. 网关作为消费者,以广播模式消费消息,所有节点都会接收到消息。

  5. 节点接收到消息后判断消息目标是否在自己内存中维护的长连接队列里,如果存在则通过长连接推送数据,否则忽略

当面对海量连接时,可以通过网管多节点,增加节点的方式分摊压力,实现水平扩展。如果节点宕机,客户端会尝试和其他节点握手建立长连接,保证服务整体可用。

会话管理:

每个 node 内存中维护一个哈希表,哈希表维护了 UID 和 UserSession 的关系。

UID 即用户 ID,UserSession 表示用户纬度的会话,一个用户困难会同时建立多个长连接,因此 UserSession 内部同样适用了一个哈希表维护一个 Channel 与 ChannelSession 的关系。

为了避免无止尽创建长连接,当内部 ChannelSession 数量超过一定限制后,会将最早建立的 ChannelSession 关闭,减少资源占用。

通过心跳保活与 WebSocket 底层实现库设置 idle_time 来使残余 Session 关闭。详见心跳

监控和报警:

网关接入micrometer,将连接数和用户数作为指标暴露,供prometheus收集,使用Grafana来展示和报警

负载均衡策略

负载均衡策略五花八门,需要选择合适自己的业务场景来设定。简单的做法是直接 ip hash 分到不同 node 上。也可以为 WebSocket node 设一个接口获取负载情况,再做相应的决定。

笔者尝试了一种比较有意思的方案:一文了解一致性哈希,仅供参考。

压测

由于笔者设备能力有限,此处借用原文爱奇艺的测试数据,从下文数据可以看到,该长连接网关的性能非常优秀,足以支撑笔者业务中大部分场景,而且易横向扩展。

压测选择两台配置为 4 核 16G 的虚拟机,分别作为服务器和客户端。压测时选择为网关开放了 20 个端口,同时建立 20 个客户端,每个客户端使用一个服务端端口建立起 5 万连接,可以同时创建百万个连接。连接数与内存使用情况如图 3 所示。

给百万个长连接同时发送一条消息,采用单线程发送,服务器发送完成的平均耗时在 10s 左右。

一般同一用户同时建立的长连接都在个位数。以 10 个长连接为例,在并发数 600、持续时间 120s 条件下压测,推送接口的 TPS 大约在 1600+。

参考与推荐阅读

]]>
<h2 id="为什么需要-WebSocket">为什么需要 WebSocket</h2> <p>短轮训-&gt;长轮训-&gt;基于流-&gt;WebSocket<br> <img src="https://makonike-blog.oss-cn-guangzhou.aliy
Linux 笔记 https://makonike.github.io/2023/03/17/Linux%E7%AC%94%E8%AE%B0/ 2023-03-17T15:05:18.000Z 2023-07-04T16:46:08.925Z 数据结构

链表

相比普遍的链表实现方式,Linux 内核的实现比较独树一帜。普通的实现是数据通过在内部添加一个指向数据的 next 或 prev 节点指针,才能串联在链表中,存储这个结构到链表里的通常方法是在数据结构中嵌入一个链表指针,如 next 或 prev。而 Linux 内核中,是将链表节点塞入数据结构

1
2
3
4
5
6
7
8
9
10
11
struct list_head{
struct list_head *next;
struct list_head *prev;
};

struct fox {
unsigned long tail_length;
unsigned long weight;
bool is_fantastic;
struct list_head list; // 所有fox结构体形成链表
}

内核还提供了一组链表操作接口,比如 list_add() 加入一个新节点到链表中,他们都有一个统一的特点,就是只接受 list_head 结构作为参数。通过使用宏 container_of()我们可以很方便地从链表指针找到父结构包含的任何变量,因为在 c 语言中,一个给定的结构中的变量偏移在编译时地址就被 ABI 固定下来了。

通过宏 container_of(),定义一个简单的函数就可以返回包含 list_head 的父类型结构体。

1
2
#define list_entry(ptr, type, member) \
container_of(ptr, type, member)

节约两次纲领(dereference)

如果已经获得了 next 和 prev 指针,就可以直接调用内部链表函数,省下提领指针的时间。前面的所有函数仅仅是找到 next 和 prev 指针,再去调用内部函数而已。内部函数和它们的外部包装函数(上面提到的,比如 list_del(list))同名,只是前面多加了两条下划线。比如用__list_del(prev, next) 替代 list_del(list)。

队列

Linux 中通用队列实现是 kfifo,和多数的其他队列实现类似,主要提供两个操作:enqueue(入队)和 dequeue(出队)。kfifo 对象维护了两个偏移量:入口偏移出口偏移。入口偏移是指下一次入队时的位置,出口偏移是指下一次出队列时的位置。出口偏移总是小于入口偏移。

enqueue 操作拷贝数据到队列入口偏移位置,拷贝完后,入口偏移会加上推入的元素数目。dequeue 操作类似。当出口偏移等于入口偏移时,说明队列为空,在新数据被推入前,不可以摘取任何数据了。当入口偏移等于队列长度时,说明在队列重置前,不可再有新数据推入队列。

创建和初始化 kfifo 对象时,它将使用由 buffer 指向的 size 字节大小的内存,size 必须是 2 次幂。

映射

映射是由唯一键组成的集合,每个键必然关联一个特定的值。主要支持三个操作:Add、Remove、Lookup。

映射不仅可以由散列表组成,还可以通过自平衡二叉搜索树来存储数据,散列表可以提供更好的平均的渐进复杂度,但是二叉搜索树在最坏情况能有更好的表现(对数复杂性相比线性复杂性),且二叉搜索树同时满足顺序保证,给用户的按序遍历带来很好的性能,二叉搜索树还不需要散列函数,需要的键类型可以通过定义<=操作算子就能满足条件。

Linux 内核用的不是通用的映射,它的目标是:映射一个唯一的标识数(UID)到一个指针。除了提供三个标准的映射操作外,还在 add 基础上实现了allocate操作,这个操作不但向 map 中加入了键值对,而且还可以产生 UID。

idr 数据结构用于映射用户空间的 UID,如将 inodify watch 的描述符或 POSIX 的定时器 ID 映射到内核中相关联的数据结构上,如 inotify_watch 或 k_itimer 结构体。

二叉树

  • 二叉搜索树

  • 自平衡二叉搜索树

一个节点的深度是从根节点算起,到达它一共需经过的父节点数目。处于树底层的节点称为叶子结点。一个树的高度是指书中的处于最底层节点的深度。而一个平衡的二叉搜索树是一个所有叶子结点深度差不超过 1的二叉搜索树。一个自平衡二叉搜索树指在操作中都维持半平衡状态的二叉搜索树。

  • 红黑树

红黑树是典型的自平衡二叉搜索树,也是 Linux 中主要的平衡二叉树数据结构。红黑树具有特殊的着色属性,遵循着六个特性,维持着半平衡结构。

  1. 所有的节点要么着红色,要么着黑色。
  2. 叶子节点都是黑色。
  3. 叶子节点不包含数据
  4. 所有非叶子节点都有两个子节点。
  5. 如果一个节点是红色,则它的子节点都是黑色
  6. 在一个节点到其叶子节点的路径中,如果总是包含同样数目的黑色节点,则该路径相比其他路径是最短的。

这意味着树中最长的路径是红黑交替节点路径,从根节点到叶子节点的最长路径不会超过最短路径的两倍。

Linux 实现的红黑树称为 rbtree,除了一定的优化外,rbtree 类似于前面描述的经典红黑树,即保持了平衡性,所以插入效率和树中节点数目呈对数关系( O(logN) )。rbtree 的根节点为 rb_root,其他节点由 rb_node 描述,给定一个 rb_node,可以跟踪同名节点指针来找到它的左右子节点。

数据结构的选择

  • 如果对数据集合的主要操作是遍历数据,就用链表。
  • 当性能非首要考虑因素,或者只需要存储相对较少的数据项,或者当你要和内核中其他使用链表的代码交互时,优先选择链表。
  • 如果需要存储一个大小不明的数据集合,选择链表更合适。
  • 如果需要存储大量数据,但没有执行太多次时间紧迫的查询操作,最好使用链表
  • 如果代码符合生产者/消费者模式,则使用队列,特别是如果你想要一个定长缓冲
  • 如果需要映射一个UID到一个对象,就使用映射。
  • 如果需要存储大量数据,并且检索迅速,最好使用红黑树

内存管理

内核把物理页作为内存管理的基本单位。MMU 通常以页为单位来管理系统中的页表,从虚拟内存角度看,页就是最小单位。大多数 32 位体系结构支持 4KB 的页,64 位体系结构一般支持 8KB 的页。

内核用 struct page 结构表示系统中的每个物理页。

1
2
3
4
5
6
7
8
9
10
struct page {
unsigned long flags;
atomic_t _count;
atomic_t _mapcount;
unsigned long private;
struct address_space *mapping;
pgoff_t index;
struct list_head lru;
void *virtual;
}

flags 用于存放页的状态,比如这个页是不是脏的,是不是被锁定在内存中等,flags 的每一位都单独表示一个状态,至少可以同时表示 32 种不同状态。

_count 存放页的引用计数,为 -1 时标识没有被当前内核引用,那么在新的分配中就可以使用它。一般不会直接访问这个字段,而是调用 page_count() 来检查。对 page_count() 来说,当_count 为负数时,返回 0,表示页空闲,如果返回一个正整数表示页在使用。这个页可以被页缓存使用(mapping 字段指向这个页关联的 address_space 对象),或者作为私有数据(由 private 指向),或者作为进程页表中的映射。

virtual 字段存放了页的虚拟地址。有些内存不永久映射到内核地址空间上,这种情况下这个字段为 NULL,在需要的时候才动态映射这些页。

这个结构只是描述了物理页的信息,而不是描述包含在其中的数据。内核用这个结构来管理系统中的所有页,因为内核需要知道一个页是否空闲,如果页已经被分配,还需要知道谁拥有这个页。拥有者可能是用户空间进程,也可能是动态分配的内核数据,还可能是静态内核代码或页高速缓存等。

区的出现是为了处理一些特定的限制:由于硬件限制,内核不能对所有页一视同仁,有些页位于内存中特定物理地址上,所以不能将其用于一些特定任务。此外,Linux 将系统的页划分为区后,形成不同的内存池,就可以根据用途来进行分配了。区的划分并没有任何物理意义,只是内核为了管理页面而采取的一种逻辑上的分组。

  • 一些硬件只能用特定内存地址来访问 DMA。
  • 一些体系结构的内存的物理寻址范围比虚拟寻址范围大得多,这样就有一些内存不能永久映射到内核空间上。

Linux 主要使用四种区:

  • ZONE_DMA 这个区包含的页能用来执行 DMA 操作。在 x86 体系结构上,由于某些 PCI 设备只能在 24 位地址空间内执行 DMA 操作,所以 ISA 设备不能在整个 32 位的地址空间中执行 DMA,因为 ISA 设备只能访问物理内存的前 16MB。因此 ZONE_DMA 在 x86 上包含的所有页都在 0~16MB 的内存访问内。
  • ZONE_DMA32 和 ZONE_DMA 类似,但是只能被 32 位设备访问
  • ZONE_NORMAL 包含的都是能正常映射的页
  • ZONE_HIGHEM 包含高端内存,其中的页并不能永久映射到内核地址空间。能否直接映射取决于体系结构,在 32 位 x86 系统上,ZONE_HIGHMEM 为高于 896M 的所有物理内存,其所在内存也被称为高端内存,而其余内存就是低端内存。在其他体系结构上,由于所有内存都被直接映射,所以 ZONE_HIGHMEM 为空。
描述物理内存
ZONE_DMADMA 使用的页<16MB
ZONE_NORMAL正常可寻址的页16~896MB
ZONE_HIGHMEM动态映射的页>896MB

当可供分配的资源不够时,内核会去占用其他可用区的内存。在 x86-64 体系结构下,由于它可以寻址和处理的 64 位内存空间较大,所以没有 ZONE_HIGHMEM 区,所有的物理内存都处于 ZONE_DMA 和 ZONE_NORMAL 中。

页的分配和释放接口

底层页分配的核心函数是 alloc_pages,它分配 1<<order 个连续的物理页,并返回指向第一个页的 page 结构体的指针。

1
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)

可以通过 page_address 获取它的逻辑地址,这个函数返回一个指向给定页当前所在的逻辑地址的指针。

1
void * page_address(struct page *page)

__get_free_pages 的作用类似,但是它是直接返回请求的第一个页的逻辑地址。

1
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)

获取全是填充零的页,可以调用 get_zeroed_page,它的实现与__get_free_pages 类似,但是在返回前,会将所有数据填充为 0,保障系统安全。

1
unsigned long get_zeroed_page(unsigned int gfp_mask)

释放页可以使用以下函数。如果传递了错误的 struct page 或地址,用了错误的 order 值,这些都可能导致系统崩溃。

1
2
3
void __free_pages(struct page *page, unsigned int order)
void free_pages(unsigned long addr, unsigned int order)
void free_page(unsigned long addr)

kmalloc()

kmalloc() 常用于以字节为单位分配内核内存,它与用户空间的 malloc() 一族函数非常类似,但是多了个 flags 参数。函数返回一个指向内存块的指针,至少有 size 大小,而且所分配的内存在物理上是连续的。

1
void * kmalloc(size_t size, gfp_t flags)

gfp_mask 标志常见于低级页分配函数和 kmalloc() ,可以分为三类:行为修饰符、区修饰符以及类型。

  • 行为修饰符表示内核应该如何去分配所需的内存,在某些特定场景下只能使用特定方式分配内存。如中断处理程序要求内核在分配内存的时候不能睡眠。
  • 区修饰符表示从哪分配内存。由于内核将物理内存分成了若干个区,每个区用于不同的目的,区修饰符指明了要从哪个区中分配。
  • 类型标志组合了行为修饰符和区修饰符,将各种可能用到的组合归纳为不同类型,相当于已经加工好了,简化了修饰符的使用。

因为一般只使用类型修饰符就够了,所以只展示常见的类型标志:

标志描述
GFP_ATOMIC这个标志用在中断处理程序、下半部、持有自旋锁以及其他不能睡眠的地方
GFP_NOWAIT与 GFP_ATOMIC 类似,不同之处在于,调用不会推给紧急内存池。这就增加了内存分配失败的可能性。
GFP_NOIO这种分配可以阻塞,但是不会启动磁盘 I/O。这个标志在不能引发更多磁盘 I/O 时能阻塞 I/O 代码,会导致不好的递归
GFP_NOFS这种分配在必要时可能阻塞,也可能启动磁盘 I/O,但是不会启动文件系统操作。这个标志在你不再启动一个文件系统的操作时,用在文件系统部分的代码中
GFP_KERNEL这是一种常规的分配方式,可能会阻塞。这个标志在睡眠安全时用于进程上下文 diamante 中,为了获得调用者所需的内存,内核会尽力而为。这个标志应当是首选标志
GFP_USER这是一种常规的分配方式,可能会阻塞。这个标志用于为用户空间进程分配内存
GFP_HIGHUSER这是从 ZONE_HIGHMEM 进行分配,可能会阻塞。这个标志用于为用户空间分配内存
GFP_DMA这是从 ZONE_DMA 进行分配,由获取能供 DMA 使用的内存的设备驱动程序使用,通常与以上某个标志组合使用。

内核中最常见的标志是 GFP_KERNEL,是普通优先级,由于调度可能阻塞,所以只能用在可以重新安全调度的进程上下文中,这个标志没有对请求的内存进行约束,所以内存分配成功的可能性比较高。

另一个极端的标志是 GFP_ATOMIC,表示不能睡眠的内存分配,它的调用需要满足很严格的限制。即使没有足够的内存块可以获取,内核也不能让调用者睡眠,因此它分配成功的机会较小。即使如此,在当前代码不能睡眠时(如中断处理程序,软中断和 tasklet),也只能选择 GFP_ATOMIC。

夹在俩中间的是 GFP_NOIO 和 GFP_NOFS,以这两个标志进行的分配可能会阻塞,但是他们会避免某些其他操作的执行。它们分别用于某些低级块 I/O 或文件系统的代码中,设想,如果文件系统的代码中需要分配内存,没有使用 GFP_NOFS,这种分配可能会引起你更多文件系统的操作,又导致了更多的分配,引发不期望发生的递归,最终导致死锁。总的来说,这两个标志用的还是极少的。

GFP_DMA 标志表示分配器必须满足 ZONE_DMA 进行分配的请求,这个标志用于需要 DMA 的内存的设备驱动中,一般和 GFP_ATOMIC 或 GFP_KERNEL 结合使用。

kfree() 用于释放 kamlloc() 分配的内存块,但是不可用于释放其余分配函数分配的内存,或者已经释放过的内存,否则会造成严重后果。

1
void kfree(const void *ptr)

vmalloc()

类似于 kmalloc(),但是 vmalloc() 分配的内存虚拟地址是连续的,而物理地址则无须连续。这也是用户空间分配函数的工作方式:由 malloc() 返回的页在进程的虚拟地址空间内是连续的,但并不能保证它们在物理 RAM 中也是连续的。vmalloc() 通过分配非连续的物理内存块,再修正页表,把内存映射到逻辑地址空间的连续区域中来实现这一点。

大多数情况下,只有硬件设备需要得到物理地址连续的内存,很多体系结构上,硬件设备都存在于内存管理单元之外,根本不能理解什么是虚拟地址,所以它们用到的任何内存去都必须是物理上和逻辑地址上都连续的块。对内核而言,所有内存看起来都是逻辑上连续的。

很多内核代码都用 kmalloc() 来获得内存,主要出于性能的考虑。vmalloc() 为了讲物理上部连续的页转换为虚拟地址空间上连续的页,必须专门建立页表项。而且必须一个一个进行映射(因为物理上是不连续的),这会导致比直接内存映射大得多的 TLB 抖动。因为这些原因,vmalloc() 仅有在不得已时才会使用,典型的是为了获取大块内存。如当模块被动态插入内核时,就把模块装载到由 vmalloc() 分配的内存上。

1
void * vmalloc(unsigned long size)

函数返回一个指向 size 大小逻辑上连续的内存块的指针。要释放的话需要调用 vfree()

1
void vfree(const void *addr)

slab 分配器

为了便于数据的频繁分配和回收,通常会使用空闲链表——相当于对象高速缓存,快速存储频繁使用的对象类型。

在内核中,空闲链表的主要问题之一是不能全局控制。当可用内存变得紧缺时,无法通知到每个空闲链表,让其收缩缓存大小以释放一些内存。实际上,内核对空闲链表无感(因为是编程人员实现,而不是内核中实现的)。为了解决这一问题,Linux 内核提供了 slab 分配器,它扮演了通用数据结构缓存层的角色。

slab 层将不同的对象划分为所谓的高速缓存组,其中每个高速缓存组都存放不同类型的对象,每种对象类型对应一个高速缓存,如一个高速缓存用于存放进程描述符(task_struct 结构的一个空闲链表),另一个高速缓存存放索引节点对象(struct inode)。kmalloc() 接口也建立于 slab 层之上,使用了一组通用高速缓存。

这些高速缓存又被划分为 slab。slab 由一个或多个物理上连续的页组成。一般情况下 slab 也就一页,每个高速缓存可以由多个 slab 组成。

每个 slab 都包含一些对象成员(被缓存的数据结构)。每个 slab 处于三种状态之一:满、部分满或空。一个满的 slab 没有空闲的对象,一个空的 slab 没有被分配的对象。而部分满的 slab 中有些对象已分配,有些对象还是空闲的。当内核的某一部分需要一个新对象时,先从部分满的 slab 中分配,如果没有部分满的 slab,就从空的 slab中进行分配。如果没有空的 slab,就需要创建一个 slab了。这种策略能减少内存碎片

以 inode 为例,它是磁盘索引节点在内存中的体现,会被频繁创建和释放,很有必要使用 slab 分配器来管理,因此 struct inode 就由 inode_cachep 高速缓存来分配。这种高速缓存由一个或多个 slab 组成,每个 slab 包含尽可能多的 struct inode 对象。当内核需要时,就从部分满或空的 slab 中返回一个指向已分配但是未使用的结构的指针,当内核用完后,slab 分配器会将该对象标记为空闲。

结构表示

每个高速缓存都使用 kmem_cache 结构表示,该结构包含三个链表:slabs_full、slabs_partial 和 salbs_empty,都存放在 kmem_list3 结构中。这些链表包含了高速缓存中的所有 slab。

1
2
3
4
5
6
7
struct slab{
struct list_head list; // 满、部分满或空链表
unsigned long colouroff; // slab着色的偏移量
void *s_mem; // 在slab中的第一个对象
unsigned int inuse; // slab中已分配的对象数
kmem_bufctl_t free; // 第一个空闲对象(如果有的话)
}

值得一提的是,slab 的描述符也可以放在 slab 里。此外,slab 分配器还可以创建新的 slab,具体是调用__get_free_pages(),以下是一个简化版的函数:

1
2
3
4
5
6
static void *kmem_getpages(struct kmem_cache *cachep, gfp_t flags){
void *addr;
flags |= cachep->gfpflags;
addr = (void *) __get_free_pages(flags, cachep->gfporder);
return addr;
}

函数使用__get_free_pages 为高速缓存分配足够多的内存,通过或运算将高速缓存需要的缺省标志传递到 flags 参数上,分配的大小为 2 的幂次方,存储在 cachep->gfporder 中。当需要释放内存时,针对特定的高速缓存页调用的是 kmem_freepages,最终调用的是 free_pages。

slab 层的关键在于避免频繁分配和释放页,因此,只有在当给定的高速缓存部分中既没有满也没有空的 slab 时才会调用页分配函数,只有在以下情况时才会调用释放函数:

  • 当可用内存变得紧缺时,系统试图释放出更多内存以供使用。
  • 当高速缓存显式地被撤销时。

slab 层的管理是基于每个高速缓存的基础上,通过提供给整个内核一系列简单的接口完成的,通过这些接口可以创建和撤销新的高速缓存,并在高速缓存中分配和释放对象。高速缓存和其内的 slab 的复杂管理完全可以通过 slab 层的内部机制来处理。

如果要创建一个新的高速缓存,调用的接口应该是 kmem_cache_create,它的第一个参数存放了高速缓存的名字,第二个参数是高速缓存中每个元素的大小,第三个参数是 slab 内第一个对象的偏移,用于确保在页内进行特定的对齐,通常使用 0 来标准对齐就行了。flags 用于控制高速缓存的行为,具体的标志下文再介绍。最后一个参数 ctor 是高速缓存的构造函数,当有新的页追加到高速缓存时,它才会被调用。实际上这个参数已经被抛弃了,Linux 内核的高速缓存不使用构造函数,所以可以设为 NULL。

1
2
3
struct kmem_cache * kmem_cache_create(const char *name, size_t size, 
size_t align, unsigned long flags,
void (*ctor)(void *));

控制高速缓存的标志:

  • SLAB_HWCACHE_ALIGN 这个标志命令 slab 层把一个 slab 内的所有对象按高速缓存行对齐。这样做的好处是防止了伪共享问题:两个或多个对象尽管位于不同的内存地址,但映射到相同的高速缓存行。这样设置可以提高性能,但是很吃内存,对齐越严格,浪费的内存就越多,因此只有在对于频繁使用的高速缓存,且代码本身对性能又要求严格的情况下才会设置。
  • SLAB_POISON 将 slab 用已知的值(a5a5a5a5,一个神秘值,常用于检测未初始化的内存,当一个指针指向 a5a5a5a5 时,很可能这个指针没初始化或已损坏)填充,即所谓的"中毒",有利于对未初始化内存的访问。
  • SLAB_RED_ZONE 导致 slab 层在已分配的内存周围插入"红色警戒区"来探测缓存越界。
  • SLAB_PANIC 当分配失败时会提醒 slab 层,在要求分配只能成功时使用,比如系统启动初分配一个 VMA 结构的高速缓存。
  • SLAB_CACHE_DMA 命令 slab 层使用可以 DMA 的内存给每个 slab 分配空间,只有分配的对象用于 DMA,且常驻于 ZONE_DMA 区时才需要这个标志。

kmem_cache_destroy 用于撤销一个高速缓存,通常在创建了自己的高速缓存的模块注销的代码中被调用。在调用前必须保证高速缓存中的所有 slab 都为空,以及在调用这个函数的过程中和调用结束后都不会访问这个高速缓存。

1
int kmem_cache_destroy(struct kmem_cache *cachep)

kmem_cache_alloc 用于获取对象,这个函数从给定的 cachep 中返回一个指向空闲对象的指针,如果 slab 中没有,那么 slab 必须通过 kmem_getpages 获取新的页,并用 flags 标记,此处应该用到 GFP_KERNEL 或 GFP_ATOMIC。

1
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags);

kmem_cache_free 用于释放对象,并将其返回给原先的 slab,给定的 objp 会被重新标记为空闲。

1
void kmem_cache_free(struct kmem_cache *cachep, void *objp);

书中给定了一个 task_struct 的例子,取自 kernel/fork.c。首先内核用一个全局变量来存放指向 task_struct 高速缓存的指针,在内核初始化期间,fork_init() 会创建高速缓存。

1
2
3
4
struct kmem_cache *task_struct_cachep;

task_struct_cachep = kmem_cache_create("task_struct", sizeof(struct task_struct),
ARCH_MIN_TASKALIGN, SLAB_PANIC | SALB_NOTRACK, NULL);

ARCH_MIN_TASKALIGN 定义的值与体系结构相关,通常是 L1 高速缓存的字节大小。SLAB_PANIC 在这用是因为这是系统操作必不可少的高速缓存,如果没有 SLAB_PANIC,就得自己检查返回值了。

当 fork 被调用时,do_fork->dup_task_struct 中会使用 kmem_cache_alloc 的获取一个 task_struct 对象。当进程描述符要被释放时,在 free_task_struct 中会调用 kmem_cache_free。

高端内存映射

高端内存是 32 位体系结构下,物理内存中的概念,指的是物理内存中 896M 直接映射区以上的内存区域,具体可以见一文了解 OS-内存布局

永久映射

要映射一个 page 结构到内核地址空间,可以用 kmap,它在高端内存或低端内存都能用,如果 page 结构对应的是低端内存中的一页,函数只会单纯返回该页的虚拟地址。如果页在高端内存,还得简历一个永久映射,再返回地址。

1
void *kmap(struct page *page)

由于允许永久映射的数量是有限的,在不需要高端内存时,应该使用 kunmap 解除映射。

1
void kunmap(struct page *page)

临时映射

当必须创建一个映射,而当前上下文又不能睡眠时,可以使用临时映射,也叫原子映射。有一组保留的映射,可以存放新创建的映射,内核可以原子地把高端内存中的一个页映射到某个保留的映射中。通常用于中断处理程序,因为获取映射时不会阻塞。

可以通过 kmap_atomic 创建一个临时映射

1
void *kmap_atomic(struct page *page, enum km_type type)

通过 kunmap_atomic 取消映射,实际上这个函数没做什么事,因为 kmap 禁止内核抢占,且映射对每个处理器都是唯一的,只有在下一个临时映射到来前上一格临时映射才有效,内核完全可以忘记这个临时映射,下一个原子映射将自动覆盖前一个映射。

1
void kunmap_atomic(void *kvaddr, enum km_type type)

分配函数选择

如果需要连续的物理页,可以使用某个低级页分配器或 kmalloc。对于中断处理程序或其他不能睡眠的代码段,应该传入标志 GFP_ATOMIC,对于其余的代码,可以使用 GFP_KERNEL。

如果想从高端内存分配,就使用 alloc_pages,它返回一个指向 struct page 结构的指针,而不是一个虚拟地址,因为这个高端内存可能没被映射,要获得真正的指针,就要调用 kmap,将高端内存映射到内核的逻辑地址空间。

如果不需要物理上连续的页,只需要逻辑上连续的页,应该使用 vmalloc,虽然它有一定的性能损失。

如果要创建和撤销很多大的数据结构,考虑建立 slab 高速缓存,slab 层给每个处理器维护一个对象高速缓存,能极大地提高对象分配和回收的性能,slab 层会预先分配内存,当需要对象时可以直接获取,而不需要再去分配内存。

进程地址空间

Linux 是一个基于虚拟内存的操作系统,内核除了管理本身的内存外,还得管理用户空间中进程的内存,也就是系统中每个用户空间进程所看到的内存,即进程地址空间。所有进程之间都以虚拟内存的方式共享内存,对于一个进程而言,它房屋可以访问整个系统的所有物理内存,且它拥有的地址空间也可以远远大于系统物理内存。

地址空间

进程地址空间由进程可寻址的虚拟内存组成,在现代操作系统中,通常使用平坦的一个独立的连续区间来作为地址空间范围。

内存地址是一个给定的值,在地址空间范围之内,如 4021f000,这个值指的是进程地址空间中的一个特定的字节。尽管进程可以寻址所有的虚拟内存,但并不代表它有权访问所有的虚拟内存。而哪些能被进程访问的虚拟内存的地址区间,又被称为内存区域,通过内核,进程可以为自己的地址空间动态添加或减少动态区域。

每个内存区域也具有相关的权限,如对相关进程有可读、可写、可执行属性,如果一个进程访问了不在有效范围内的内存区域,或以不正确的方式访问了有效地址,内核会终止该进程,并返回“段错误”信息。

内存区域包含了各种内存对象,如:

  • 可执行文件代码的内存映射,称为代码段(text section)。
  • 可执行文件的已初始化全局变量的内存映射,称为数据段(data section)。
  • 包含未初始化全局变量,即 bss 段的零页(block started by symbol,被赋予默认值–零值,所以不用再申请时显示初始化,减少了空间浪费)。
  • 用于进程用户空间栈的零页的内存映射。
  • 每一个诸如 C 库或动态连接程序等共享库的代码段、数据段和 bss 也会被载入进程的地址空间。
  • 任何内存映射文件。
  • 任何共享内存段。
  • 任何匿名的内存映射。

实际上就是一文了解内存映射中提到的内存映射区,进程中任何的有效地址都必须在唯一的区域中,且这些区域之间不可以相互覆盖。

内存描述符

内核使用内存描述符来标识进程的地址空间,它包含了和进程地址空间有关的全部信息。下面列举了一些重要且典型的字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct mm_struct {
struct vm_area_struct *mmap; // 内存区域链表
struct rb_root mm_rb; // VMA 形成的红黑树
struct vm_area_struct *mmap_cache; // 最近使用的内存区域
unsigned long free_area_cache; // 地址空间第一个空洞
pgd_t *pgd; // 页全局目录
atomic_t mm_users; // 使用地址空间的用户数
atomic_t mm_count; // 主使用计数器
int map_count; // 内存区域的个数
spinlock_t page_table_lock; // 页表锁
struct list_head mmlist; // 所有 mm_struct 形成的链表
unsigned long start_code; // 代码段起始地址
unsigned long end_code; // 代码段结束地址
unsigned long start_data; // 数据段起始地址
unsigned long end_data; // 数据段结束地址
unsigned long start_brk; // 堆起始地址
unsigned long brk; // 堆结束地址
unsigned long start_stack; // 进程栈的首地址
unsigned long rss; // // 所分配的物理页
...
}

mm_users 字段主要用来记录共用此命名空间的线程数量,如果是俩线程共享该地址空间,那么 mm_users 值为 2。在 linux 创建线程时,会有如下操作,代码中仅仅更新了 mm_user 的值,并使子进程和父进程共享 mm_struct 结构体(如果不是创建线程,则子进程会重新申请 mm_struct 结构体)。

1
2
3
4
5
if (clone_flags & CLONE_VM) {
// current 是父进程,tsk 在 fork() 执行期间是子进程
atomic_inc(&current->mm->mm_users);
tsk->mm = current->mm;
}

mm_count 则主要记录对此 mm_struct 结构体的引用情况,比如在内核线程调度进来的时候,它会借用上一个进程的地址空间(虽然它不会对该地址空间操作),此时 mm_count 就会增加 1。从另一个层面来理解的话,可以理解为 mm_count 是以进程为单位的,而 mm_users 则是以线程为单位的。

mmap 和 mm_rb 俩不同的数据将结果描述对象是相同的,在一文了解 os-内存映射中提到,这是用于管理该地址空间中所有内存区域的一种方式,前者以链表形式存放,后者以红黑树形式存放,内核为了内存区域上各种不同操作都能获得高性能,所以同时使用了这两种数据结构,此处不再概述。

所有的 mm_struct 结构体都通过自身的 mm_list 字段连在一个双向链表中,恰巧符合上文数据结构中 Linux 链表实现的描述。链表的头节点是 init_mm 内存描述符,代表 init 进程的地址空间。

在进程描述符 task_struct 中,mm 字段存放这该进程使用的内存描述符,通常在 fork 中利用 copy_mm() 来拷贝父进程的内存描述符,而子进程的 mm_struct 实际上是通过 allocate_mm() 宏从 mm_cachep slab 缓存中获取的。

在描述 mm_users 字段时笔者引入了一个代码段,它描述了 clone() 创建新进程的一个过程。在调用 clone 时设置了 CLONE_VM 标志,目的是父进程希望与子进程共享地址空间,即我们常讲的线程,这也是进程和 Linux 中所谓线程间本质上的唯一区别,线程对内核来说仅仅是一个共享特定资源的进程而已。

引用计数

前文交代了 mm_users 与 mm_count 的意义,在进程退出时,内核调用 exit_mm(),其中通过 mmput() 减少内存描述符中的 mm_users 用户计数,当用户计数降为 0 时,会调用 mmdrop(),减少 mm_count 使用计数。如果使用计数也降为 0,说明内核中已经没有对象引用该文件描述符了,所以会调用 free_mm() 宏通过 kmem_cache_free() 将 mm_struct 解耦提归还给 mm_cachep slab 缓存。

mm_struct 与内核线程

内核线程没有地址空间,自然也没有 mm_struct,所以它对应的 task_struct 中的 mm 字段为 NULL,恰恰说明了内核线程是没有上下文的。

内核线程不会去访问任何用户空间的内存,它们没有页,也不需要页表,但是访问内核时确实也需要一部分数据,所以它们会直接使用前一个进程的内存描述符。

当一个进程被调度时,该进程 mm 字段指向的地址空间被加载到内存中,task_struct 的 active__mm 会更新,指向新的地址空间。当一个内核线程被调度时,内核会发现它的 mm 字段为 NULL,所以会保留前一个进程的地址空间,active_mm 指向前一个进程的地址空间,在内核线程需要的时候就能通过前一个进程的地址空间去使用一些关于内核内存相关的信息。

虚拟内存区域

内存区域由 vm_area_struct 结构体描述,通常简称 VMA。而常说的内存区域其实与虚拟内存区域是一个东西,它如前文所讲,描述了指定地址空间内连续区间上的一个独立内存范围,内核将每个内存区域作为一个单独的内存对象管理,每个区域都有属于自己的属性(如访问控制权限)。实际上,每一个区域都代表不同类型的内存区域(如内存映射文件或进程用户空间栈)。下面给出一些主要的属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct vm_area_struct {
struct mm_struct *vm_mm; // 相关的 mm_struct 结构体
unsigned long vm_start; // 区间首地址
unsigned long vm_end; // 区间尾地址
struct vm_area_struct *vm_next; // VMA 链表
pgprot_t vm_page_prot; // 访问控制权限
unsigned long vm_flags; // 标志
struct rb_node vm_rb; // 树上该 VMA 的节点
struct list_head anon_vma_node; // anon_vma 项
struct anon_vma *anon_vma; // 匿名 VMA 对象
struct vm_operations_struct *vm_ops; // 操作表,关联对应的操作方法
struct file *vm_file; // 被映射的文件(如果存在)
void *vm_private_data; // 私有数据
...
}

每个 VMA 对齐相关的 mm_struct 来说都是唯一的,即使俩独立的进程将同一个文件映射到各自的地址空间,他们分别都有一个 VMA 来标志自己的内存区域。换句话说,当俩线程共享一个地址空间时,它们就共享该地址空间中的所有 VMA。

和物理页的访问权限不同,flags(即 VMA 标志)反映了内核处理页面需要遵循的行为准则。常见的 VM_READ、VM_WRITE 和 VM_EXEC 标志内存区域中可读、可写和可执行权限。

VM_SHARD 指明该内存区域包含的映射是否可以在多进程间共享,如果设置了这个标志,说明其为共享映射,否则称为私有映射

比较有意思的是 VM_SEQ_READ 标志,它暗示了内核应用程序对映射内容执行有序的读操作,这样内核就可以用预读机制(在读数据时有意按顺序多读取一些本次请求外的数据,希望多读的数据能够很快被用到)来优化效率。VM_RAND_READ 则相反,对于这种标志的区域,内核可以减少或取消文件预读。

vm_ops 是函数操作表,由 vm_operations_struct 结构体表示,定义了指定内存区域相关的操作函数表,函数操作表字段的设置兼容了 vm_area_struct 通用对象的作用,操作表中描述的是对特定对象实例的特定方法。

1
2
3
4
5
6
7
struct vm_operations_strut {
void (*open) (strut vm_area_struct *);
void (*close) (strut vm_area_struct *);
int (*fault) (strut vm_area_struct *, struct vm_fault *);
int (*page_mkwrite) (strut vm_area_struct *vma, struct vm_fault *vmf);
int(*access) (struct vm_area_struct *, unsigned long, void *, int, int);
}

创建地址区间——mmap() 与 do_mmap()

内核使用内核函数 do_mmap() 创建一个新的内存区域,如果该内存区域与一个已存在的内存区域相邻,且它们都有相同的访问权限时,两个区间会合并为一个。do_mmap 的定义如下

1
2
unsigned long do_mmap(struct file *file, unsigned long addr, unsigned long len, 
unsigned long prot, unsigned long flag, unsigned long offset)

映射由 file 指定的文件,具体映射是从文件中偏移 offset 处开始,长为 len 的范围内存的数据。如果 file 为 NULL 且 offset 为 0,说明是匿名映射,如果指定了 file 和 offset,那么为文件映射。addr 用于指定搜索空闲区域的起始位置,是可选项。prot 指定内存区域内页面的访问权限。flag 指定 VMA 标志,上文有提到。

当新创建的内存区域不与其余内存区域相邻时,内核会从 vm_area_cachep slab 缓存中分配一个 vma 结构体,并使用 vma_link() 将新分配的内存区域添加到地址空间的内存区域链表和红黑树中,还得更新 mm_struct 的 total_vm 字段,然后才返回新分配内存区域的起始地址。

在用户空间中可以通过 mmap 系统调用(mmap2())来调用 do_mmap,与最原始的 mmap 相比,它的最后一个参数更改为了使用页面偏移而非字节偏移来指定偏移量,这样可以映射更大的文件和更大的偏移位置。

最原始的 mmap 在内核中已经没有对应的实现,实际上在 c 库中最终也是调用的 mmap2,mmap 的作用只是将字节偏移转换为页面偏移,底层还是用的 mmap2。

do_munmap() 从特定进程地址空间中删除指定地址区间,用户空间可以通过系统调用 munmap() 来使用,munmap() 则只是对 do_munmap 的简单封装。

1
int do_munmap(struct mm_struct *mm, unsigned long start, size_t len)

页表

应用程序操作的对象是映射到物理内存上的虚拟内存,但处理器直接操作的却是物理内存。当应用程序需要访问一个虚拟地址时,必须将虚拟地址转换为物理地址,处理器才能解析地址访问。而地址转换需要查询页表,即需要将虚拟地址分段,使得每一个虚拟地址都作为索引指向页表,页表项则指向下一级页表或最终页面。

Linux 中使用三级页表完成地址转换,利用多级页表能够节约地址转换占用的空间。如果用静态页表来实现页表,即使不存放数据,光是页表也会占用大量空间。Linux 对所有体系结构(包括不支持三级页表的体系结构)都用三级页表管理,因为三级页表结构可以利用最大公约数思想——按照需要在编译时简化使用页表的三级结构,如只使用两级。最大公约数思想我理解为追求共同点,即兼容,使用三级结构时,可以兼容使用二级的情况。

顶级页表是页全局目录 PGD,包含一个 pgd_t 类型(等同于 unsigned long)的数组,PGD 表项指向二级页目录中的表项 PMD。二级页表是中间页目录 PMD,是一个 pmd_t 类型的数组,表项指向 PTE 中的表项。PTE 简称页表,包含了 pte_t 类型的页表项,该页表项指向物理页。

查页表这件事比较追求效率,所以一般由硬件完成。此外多数结构还实现了一个翻译后缓冲器(translate lookaside buffer,TLB,好久没听译名都忘了 TLB 原本的含义…),TLB 为虚拟地址到物理地址映射的硬件缓存,当请求访问一个虚拟地址时,处理器会先检查 TLB 中是否包含该映射,如果有则直接命中,立即返回,否则才通过页表搜索物理地址。

每个进程都有自己的页表,线程会共享页表。mm_struct 中的 pgd 字段指向的就是进程的页全局目录,操作和检索页表的时候都得用 page_table_lock 来锁住页表防止竞态条件。所以多线程的情况下,线程切换免去了切换进程时上下文切换得切换页表导致 TLB 失效的开销,这恰恰是多线程场景优于多进程场景的原因之一。书中(当时是 2.6)还描述了将来的改进可能包括在写时拷贝的方式共享页表,来消除 fork() 操作中页表拷贝带来的消耗,可以更快提升 fork() 的效率,避免大页表阻塞 fork() 操作。这一点其实在 22 年的时候就有计划了Introduce Copy-On-Write to Page Table,而且貌似有相关论文已经发表 Effects of copy-on-write memory management on the response time of UNIX fork operations,但是对一些属性的引用关系和计数会比较复杂,所以实现起来也相对复杂。

页高速缓存与页回写

页高速缓存实现磁盘缓存,主要用来减少对磁盘的 I/O 操作,具体的讲是通过将磁盘中的数据缓存在物理内存中,将对磁盘的访问转变为对物理内存的访问。页回写是指将页高速缓存中的变更数据刷新回磁盘的操作。

磁盘高速缓存存在的原因有二:

  • 一方面,访问磁盘的速度(ms 级别)远远低于访问内存的速度(ns 级别),相差了几个数量级。
  • 另一方面是根据临时局部原理,数据一旦被访问就很有可能在短期内再次被访问到。临时局部原理能保证,如果第一次访问数据时缓存它,那么极有可能在短期内再次被高速缓存命中。由于内存访问比磁盘快,加上临时局部原理的保证,使得磁盘的内存缓存为系统存储性能带来很大提升。

缓存手段

页高速缓存由内存中的物理页组成,其包含的内容对应磁盘上的物理块。页高速缓存大小可以动态调整。

写缓存

缓存一般被实现为三种策略之一:

  1. 不缓存。高速缓存不去缓存任何写操作,当对一个缓存中的数据片进行写时,将直接跳过缓存,写到磁盘中,同时使得缓存中的数据失效。如果后续读操作进行时,需要重新从磁盘中读数据。这也是典型的只读缓存处理策略,笔者在 geekcache 中主要也是使用这种方式。对于常见的缓存来说,这种策略却很少使用,因为这种策略不仅不缓存写操作,还需要额外功夫使得缓存数据失效。
  2. 写操作自动更新内存缓存,同时也更新磁盘文件。也被称为写透缓存,因为写操作会立刻穿透缓存到磁盘中。书中的描述比较简略,这种应该是同步直写策略,会降低缓存的访问性能,增加缓存响应延迟。但是能保证缓存数据时刻与磁盘数据同步,而且不需要让缓存失效,同时实现也比较简单。
  3. 回写策略,也是 Linux 所采用的。写操作直接写到缓存中,后端存储不会立刻更新,而是将页高速缓存中被写入的页标记为脏页,并加入到脏页链表中,由回写进程周期性将脏写链表中的页写回到磁盘,可以保证缓存数据与磁盘数据的最终一致性。这个策略会有数据丢失的风险,而且实现复杂度高,但是能方便以后更多时间内合并更多的数据和再一次刷新,减少磁盘 I/O 次数。

缓存淘汰策略

作用是为更重要的缓存项腾出位置,或者是收缩缓存大小,腾出内存给其他地方使用。

  1. 最近最少使用

经典的 LRU 算法,跟中每个页面的访问踪迹(或者说按照访问时间为序),来回收最老时间戳的页面。它假定缓存的数据越久没被访问过,则越不可能近期再被访问,而最近访问的最有可能被再次访问。对于许多文件被访问一次后就不再被访问的场景,LRU 起不到啥明显的效果,当然,内核也并不能预测到哪些文件只会被访问一次,但是它可以知道过去这个文件被访问了多少次。

  1. 双链策略

对我来说是个比较陌生的概念,可能之前在哪里也看到过。Linux 实现的是一个修改过的 LRU,维护了两个链表:活跃链表和非活跃链表。处于活跃链表上的页面不会被淘汰,而在非活跃链表上的页面可以被淘汰的。活跃链表中的页面必须得从非活跃链表中晋升。两个链表都被伪 LRU 规则维护:页面从尾部加入,从头部移除,像队列一样。两个链表中还得维持平衡,当活跃链表中页面超过了非活跃链表,那么活跃链表的头页面会被移回非活跃链表。双链策略解决了传统 LRU 算法对仅一次访问的窘迫,而且也更加简单的实现了伪 LRU 语义,也被称为 LRU/2,更普遍的 n 个链表被称为 LRU/n。

Linux 页高速缓存

address_space

参考

]]>
<h2 id="数据结构">数据结构</h2> <h3 id="链表">链表</h3> <p>相比普遍的链表实现方式,Linux 内核的实现比较独树一帜。普通的实现是数据通过在内部添加一个指向数据的 next 或 prev 节点指针,才能串联在链表中,存储这个结构到链表里的通常方
Raft 论文研读笔记 https://makonike.github.io/2023/03/01/Raft%E8%AE%BA%E6%96%87%E7%A0%94%E8%AF%BB%E7%AC%94%E8%AE%B0/ 2023-03-01T13:06:28.000Z 2023-03-30T16:50:41.238Z 摘要

Raft 是用于管理复制日志的一致性协议,与 Multi-Paxos 作用相同,效率相当,但是架构更简单,更容易实现。Raft 将共识算法的关键因素分为几个部分:

  • Leader election 领导者选举
  • Log replication 日志复制
  • Safety 安全性

且 Raft 用了一种更强的共识性来减少要考虑的状态 state 的数量。

Raft 对比于现有的共识算法有几个新特性:

  • Strong leader(强领导性):相比于其他算法,Raft 使用了更强的领导形式。比如,日志条目只能从 leader 流向 follower(集群中除 leader 外其他的服务器)。这在使 Raft 更易懂的同时简化了日志复制的管理流程。
  • Leader election(领导选举):Raft 使用随机计时器来进行领导选举。任何共识算法都需要心跳机制(heartbeats),Raft 只需要在这个基础上,添加少量机制,就可以简单快速地解决冲突。
  • Membership changes(成员变更):Raft 在更改集群中服务器集的机制中使用了一个 联合共识(joint consensus)的方法。在联合共识(joint consensus)下,在集群配置的转换过程中,新旧两种配置大多数是重叠的,这使得集群在配置更改期间可以继续正常运行。

复制状态机

复制状态机用于解决分布式系统中的各种容错问题,通常使用日志复制来实现。如图,每个服务器保存一份含有一系列命令的日志,然后服务器上的复制状态机按顺序执行日志中的命令。每一份日志按相同顺序包含了相同的命令,因此每个状态机都能处理相同的命令序列。

共识算法主要是保证复制日志的一致性。每个服务器上的共识模块收到来自客户端的指令后,先按顺序存到日志里,然后与其他服务器上的共识模块通信,确保每个服务器上的日志都以相同顺序包含相同命令。当命令复制完成时,每台服务器上的状态机就会按日志顺序处理命令,并将输出结果返回给客户端。

共识算法通常包含以下特征:

  • 确保在非拜占庭错误下的安全性,从不返回一个错误的结果。(即使网络延迟、分区、数据包重复、丢包、乱序)
  • 只要过半服务器是可运行的,且能互相通信和与客户端通信,那么共识算法可用。
  • 保证日志一致性上不依赖于时序:错误的时钟和极端消息延迟在最坏情况下会产生影响可用性的一系列问题。
  • 只要集群中过半服务器响应了 RPC,命令就可以被视为完成。

Paxos 存在的问题

  1. 非常难理解 作者选择了 Single-decree Paxos 来作为基础,而 Single-decree Paxos 的两个阶段没有简单直观的说明,又不能分开理解,所以很难理解为什么这个算法能起作用。而 Multi-Paxos 的合成规则又很复杂。
  2. 没有为实际实现提供一个良好的基础:没有广泛认同的针对 Multi-Paxos 的算法,Lamport 在论文中概述了针对 multi-Paxos 的可能的方法(he sketched possible approaches to multi-Paxos),但是缺失了很多细节。虽然有人实现,但是实现时需要解决很多问题,解决后又成为了一个与 Paxos 不同的架构。

Designing for understandability

主要讲了作者在设计 Raft 算法时以可理解性为优先,在多个备选方案中做抉择时优先考虑可理解性:

  1. 问题分解。将问题划分为几个相对独立解决、解释和理解的子问题。
  2. 通过减少状态的数量来简化状态空间,尽可能使系统变得更连贯,尽可能消除不确定性。比如日志不允许有空档,限制日志之间可能不一样的方式,另一个例子是随机化方法,引入不确定性来处理可能的选择,减少状态空间,如 leader election。

The Raft consensus algorithm

在 Raft 中,首先选举一个 leader,由这个 leader 全权负责复制日志的管理,Leader 从多个客户端接收日志条目,然后将他们复制给其他的服务器,并在保证安全性的前提下通知其他服务器将日志条目 apply 到他们的状态机中。有了这个 leader,大大简化了复制日志的管理流程。当然,一个 leader 可能会崩溃,此时 Raft 会选举出一个新的 leader 来。

通过选主方式,拆分为三个独立子问题:

  • Ledaer election 领导选举 一个 leader 故障时,会选举一个新的 leader
  • Log replication 日志复制 leader 必须接收来自客户端的日志并复制给集群中的其他节点,并强制其他节点的日志和自己的保持一致
  • Safety 安全性 Raft 安全性的关键指状态机中的安全性。如果一个节点将一个有给定 index 的日志条目发给它的状态机,那么集群中不会有节点将相同 index 引用到不同的日志条目。这保证了一个 index 在集群中只会标识唯一一个日志条目。

Raft 基础

一个典型的 Raft 集群中会包含 5 个节点,它可以容忍两个节点失效的情况。在任意一个时刻,集群中的每一个节点都只可能是以下三种身份之一:

  • leader:它会处理所有来自客户端的请求(如果一个客户端与一个 follower 通信,follower 会将请求重定向到 leader 上)
  • follower:不会发送任何请求,只是简单响应来自 leader 和 candidate 的请求
  • candidate:选主时的一个临时状态

一般来说,一个集群只会有一个 leader,其余节点都是 follower。下图是状态的转换关系:

Raft 将时间划分为任意长度的任期(term),每一段任期从选举开始,如果一个 candidate 赢得了选举,则在任期剩余时间内担任 leader。某些情况下一次选举可能选不出 leader,这个任期就会随着无 leader 而结束,同时会有新的一个任期开始,伴随着新的一轮选举。Raft 会保证一个任期内至多只有一个 leader。

由于网络或其他原因,集群中不同节点见到任期转换次数可能是不同的,一个节点甚至可能没观察到整个任期过程。此外,任期还担任了逻辑时钟(logical clock)的角色,使得服务器能发现一些过期的信息,如过期的 leader。

每个节点存储一个当前任期号(current term number),随着时间单调递增,节点通信时会交换任期号,如果发现一个节点的当前任期号比其他节点的任期号小,那么它会将自己的当前任期号更新为较大的那个。如果一个 candidate 或 leader 发现自己的任期号过期了,那么它会立刻回到 follower 状态。如果一个节点收到一个带着过期任期号的请求,它会拒绝这个请求。

Raft 中集群节点以 RPC 形式通信,一般的共识算法主要有两种 RPC:

  • RequestVote RPCs(请求投票):由 candidate 在选举过程中发出
  • AppendEntried RPCs(追加条目):由 leader 发出,用于日志复制和提供心跳机制。
    Raft 为了在节点之间传输快照(snapshot),加了第三种 RPC。当节点没收到 RPC 响应时会重试。

Leader election

Raft 采用心跳机制来触发 Leader 选举。当服务器启动的时候,所有节点都是 follower。一个节点只要从 candidate 或者 leader 那接收到有效的 RPC 就会一直保持 follower 状态,同理,Leader 需要周期性向所有 follower 发起心跳来维持自己的 Leader 低位。心跳,即不包含日志条目的 AppendEntried RPC。

当一个 follower 在一段时间内没收到任何信息时,会导致选举超时(election timeout),那么它会假定目前集群中没有一个有效的 Leader,会开启一个选举来选择一个新的 Leader。

开启选举时,一个 follower 会变成 candidate,然后给自己投票,同时以并行的方式向集群中的每个节点都发送一个 RequestVote RPC,希望得到它们的投票。一个 candidate 会保持这个状态直到以下三个事情之一发生:

  • 它赢得选举,成为新的 Leader
  • 别的节点赢得选举,它会变为 follower
  • 一段时间内没有任何节点赢得选举,开启下一轮选举

当一个 candidate 获取了集群中半数以上节点对同一任期的投票时,它会赢得选举成为新的 Leader,向集群中其他节点发送心跳信息来维持自己的地位,同时阻止新的选举。对于同一个任期,每一个服务器节点按照先来先服务原则只会给一个 candidate 投票,如开启选举的 follower,会给自己投票然后请求别人投票给自己,而自己不能投票给别人。这样保证了集群中最多只有一个 candidate 赢得选举。

candidate 在选举时可能会收到另一个自称 Leader 的节点发来的 AppendEntried RPC。如果这个 leader 的任期号不小于自己的任期号,则这个 candidate 会变为 follower。如果这个 leader 的任期号比自己的任期号还小,candidate 会拒绝请求,保持 candidate 状态。

在第三种情况的结果是重新开启一次选举。举个例子,集群中所有节点都成为 candidate,同时给自己投票,并同时向其他节点请求投票,这个情况会尬住,没有一个 candidate 获取半数以上的投票数,此时每个 candidate 都会进行一次响应超时(timeout),然后自增任期数,开启新一轮选举。

为了避免上述的情况再次发生,Raft 采用了随机选举超时时间(randomized eleection timeouts)来减少无结果投票的发生。为了防止选票一开始被瓜分,选举超时时间从一个固定的区间内随机选择(如 150ms-300ms),这样能将节点分散开,确保大多数情况下只会有一个节点率先结束超时,此时这个节点会在其他节点超时前发送心跳,成为新的 leader,大概率不会产生无结果投票的情况。

选票瓜分的情况亦同,每一个 candidate 在开启一次新选举时重置随机选举超时时间,能够减少一轮无结果投票后再发生一次无结果投票的可能性。

Log replication

当 Leader 被选举出来后,它就要开始为客户端请求提供服务了。每一个客户端请求都包含一条将被复制状态机执行的命令。Leader 会将其作为一个新条目追加到自己的日志中,并同步向其他节点发送含有该日志的 AppendEntries RPC,让它们复制条目。当条目被安全地复制时(创建该日志条目的 Leader 将其复制到过半的节点上),Leader 会将该条目应用到自己的状态机中,状态机执行指令,然后将执行结果返回给客户端。

如果 follower 崩溃或网络丢包导致 Leader 无法收到响应,Leader 会不断重试AppendEntries RPC,直到所有 follower 都成功存储所有日志条目。

当创建该日志条目的 Leader 将其复制到过半的节点上时(如下图),此时该日志被称为已提交的日志,同时,Leader 日志中该条目之前的所有日志都会被提交,包括由其他 Leader 创建的日志条目。Leader 将要提交的日志条目的最高索引包含在未来的 AppendEntries RPC(包括心跳)中,当一个 follower 通过 RPC 知道了一个日志条目被提交时,它会将该条目日志顺序应用到自己的状态机中。

Raft 通过维护两个特性来构成日志匹配特性(Log Matching Property):

  • 如果不同日志的两个条目有着相同的索引和任期值,那么它们存储着相同的命令
  • 如果不同日志的两个条目有着相同的索引和任期值,那么它们之前的所有日志条目都相同

第一条特性源于给定的一个任期值和一个日志索引中,一个 Leader 最多可以创建一个日志条目,且日志条目在日志中的位置(索引)不会被改变。

第二个特性则是 AppendEntries RPC 执行的简单一致性检查保证的。可以看做是一个懒检查,Leader 会将前一个日志索引位置和任期号包含在 AppendEntries RPC 中,也就是说,这个 AppendEntries RPC 不仅会有当前要同步复制的日志条目(可能,心跳是没有的),还会有前一个日志索引位置和任期号。一个 follower 如果在它的日志中找不到包含相同索引和任期号的条目,它就会拒绝新的日志条目。一致性检查保证了日志扩展时的日志匹配特性,当 follower 响应时,Leader 就知道 follower 的日志一定和自己相同。

正常情况下一致性检查肯定是成功的,但是当 Leader 崩溃时,可能会导致日志处于不一致的状态。

在 Raft 中,Leader 通过强制 follower 复制 Leader 日志来解决日志不一致的问题,即 follower 中与 Leader 冲突的日志会被 Leader 的日志条目所覆盖。

Leader 需要删除 follower 日志中从两者达成一致的最大的条目索引后的所有日志条目,并将自己那个索引后的所有日志条目都发给 follower,这些操作都包含在针对前文的 AppendEntries RPC 的一致性检查的响应中。Leader 维护一个针对每一个 follower 的 nextindex,代表 Leader 要发给 follower 的下一个日志条目的索引。当选出一个新 Leader 时,该 Leader 将所有 nextindex 的值都初始化为自己最后一个日志条目的 index+1(选举时已经保证了 Leader 的数据是最新的,持有最高的任期号),如上图的 11。

如果一个 follower 的日志与 leader 冲突,那么下一次的一致性检查就会失败,AppendEntries RPC 被 follower 拒绝后,leader 对该 follower 的 nextindex-1,重试,直到满足一致性检查,那么这个 index 就是两者达成一致的最大的条目索引。此时将 follower 中冲突位置后的所有日志条目删除,然后根据 leader 发送的日志条目进行追加。如此就能保证 follower 的日志和 leader 的一致。

总结来说,需要两次 RPC 广播来同步状态机。

  1. 同步日志 AppendEntries RPC,得到过半节点响应,Leader 状态机推进 commitIndex,追加日志到自己的状态机中,返回 Client 成功
  2. 在下一次 AppendEntries RPC 中附加上一次的 commitIndex,follower 收到后再追加日志到自己的状态机中。

Safety

上述讨论的机制都无法保证每一个状态机都会按相同的顺序执行相同的命令。如一个 follower 可能会进入不可用状态,期间 leader 提交了很多日志条目,当这个 follower 重新加入集群时,它可能会被选举为新的 leader,且用新的日志条目去覆盖旧 leader 已经提交的日志条目。

Raft 通过一个限制保证对于给定的任意任期号,其对应的 leader 都包含了之前各个任期所有被提交的日志条目。

Election restriction 选举限制

在很多基于 leader 的共识算法中,一个节点即使没有包含所有已提交的日志条目,它也有可能被选举为 leader。而在 Raft 中,日志条目的传输只能从 leader 到 follower,因此 leader 从来不会覆盖本地日志中已有的日志,保证了新 leader 当选时就将包含了所有任期中已提交的日志条目。

以投票为例,一个 candidate 想当选为 leader,必须获得集群中半数以上节点的投票,如果能获得半数以上节点的投票,说明其日志至少与半数以上的节点一样新(日志中最后一个日志条目的索引和任期号最新,任期号不同,则任期号大的新,任期号相同,则索引最大的新),因此它一定包含了所有已提交的日志条目。当有的节点的日志条目比 candidate 还新,那么它会拒绝投票请求,而 candidate 就必然不会赢得选举。

Committing entries from previous terms

Raft 的 Figure 8 讲了什么问题?为什么需要 no-op 日志?

Leader 不能提交之前任期的日志,只能通过提交自己任期的日志,从而间接提交之前任期的日志。当 leader 交替宕机时,如果允许提交之前任期的日志,可能会产生这种情况:一个索引的日志条目被提交两次甚至是多次,覆盖了已经提交的日志。

这种情况下,超出了选举界定的限制,一个任期号本该不足以赢得选举的节点却选举成功。因此要添加约束:Leader 只能提交自己任期的日志。

Safety argument

反面论证了 leader 的完整性特征,讨论证明了 leader 一定包含以往任期提交的所有日志条目,证明了状态机的安全特性,这样就能保证所有的节点都会按照相同的顺序应用相同的日志条目到自己的状态机中。

follower 和 candidate 崩溃

如果一个 follower 或 candidate 崩溃,后面发送给他们的 RequestVote 和 AppendEntries RPCs 都会失败。Raft 通过无限重试来处理这种失败,如果崩溃的节点重启了,那么这些 RPC 就能被正常响应。Raft 的 RPCs 都是幂等的,发送相同的 RPC 不会造成任何损害,follower 会忽略新的 AppendEntries RPC 中包含的自己已有的日志。

时序与可用性(Timing and availability)

Raft 的安全性不能依赖于时序(timing,整个系统不能因为某些时间运行得比预期快一点或慢一点就产生错误的结果),但是可用性必须要依赖于时序。当信息交换时间比节点崩溃持续时间还长时,会导致集群缺少一个稳定的 leader,而 Raft 将无法正常工作。

Raft 中最关键的地方在于 Leader Election,需要满足以下时间要求:

广播时间(broadcastTime) << 选举超时时间(electionTimeout) << 平均故障间隔时间(MTBF)

  • 广播时间:指一个节点并行发送 RPCs 给集群其余节点,并得到响应的平均时间
  • 选举超时时间:指 Leader Election 提到的选举超时时间
  • 平均故障间隔时间:指对于一台机器,两次故障间隔时间的平均值

选举超时时间可以自定义,论文中提到,在一个集群中,广播时间约为 0.5ms-20ms 时,选举超时时间可以设置在 10ms-500ms 之间,而大部分机器的平均故障间隔时间都在几个月甚至更长时间。尽管 leader 崩溃后会有一小段选举超时时间不可用,但是这部分时间很短,远小于平均故障间隔时间,因此可以被接受。

Log compaction

正常情况下,Raft 的日志会随着请求的增加而不断增长,占用大量空间,当一个节点需要恢复到当前集群节点状态时,需要重新执行一遍 committed 的日志,如果这个日志很大,恢复耗时会很久。所以得用一定的方式来压缩日志,清除过时的信息。

最简单的方法就是快照技术(snapshotting),在某个时间点下的整个当前系统状态都会以快照的形式持久化,先前的日志就会被废除。

增量压缩方法(Incremental approaches to compaction),比如日志清理(log cleaning)和日志结构合并树(log-structured merge trees,熟知的 LSM-Tree),都是可行的。这些方法每次只对一部分的数据操作,分散了压缩的负载压力。首先选择一个积累了大量已删除数据和已覆写对象的区域,然后重写还存活的对象,释放该区域。比起快照技术,这种方式引入了大量额外机制和复杂性,而快照技术通过操作数据集来简化问题。当需要日志清理时,状态机会像快照技术一样使用相同的接口来实现 LSM 树。

如上图,每个节点都会独立生成快照,其中只包含自己日志中已提交的条目,其中主要工作是状态机将自己的状态写入快照中,快照还包含了少量的 metadata:

  • last included index:表示已添加的最后一个 entry 在日志中的 index
  • last included term:该条目所处的任期号

这些 metadata 支持了快照后的第一个 entry 的一致性检查,因为那个 entry 需要先前一个 entry 的 index 和 term。为了支持集群成员变更,快照包含了到 last included index 为止的最新的配置,一旦一个节点完成了写入快照,可能会删除所有在 last included index 之前的日志条目,以及之前的快照。

尽管通常情况下节点会独立生成快照,但在一个 follower 运行缓慢或刚加入集群时,leader 会发送快照给该 followers,使得它可以赶上 leader,更新到最新的状态。

leader 通过 InstallSnapshot RPC 发送快照给落后的 follower。当一个 follower 接收到这个快照,会决定如何处理当前已存在的日志条目。快照通常包含了已有日志中不存在的新信息,在这种情况下,follower 舍弃了它的所有日志条目,它的日志条目将由快照取代,且可能有与快照冲突的未提交日志条目。相反,当一个 follower 收到了一个描述其日志前缀的快照(可能因为重传或错误),这个被覆盖的日志条目将被删除,但快照后的日志仍然有效,且必须被保留。

在快照生成时,共识已经达成了,因此没有决策会出现冲突。这种情况下数据和以前一样只能从 leader 流向 follower,只是现在允许 follower 可以重新组织它们的数据而已。

作者想过一种可替代的方案,即只有 leader 可以创建快照,然后由 leader 将这份快照发送给其他所有 follower,但是这个方案有两个缺点:

  1. 浪费网络带宽和延缓了快照处理过程。实际上每个节点都已拥有自己创建快照的数据,很显然由节点根据本地状态来创建快照更方便。
  2. 使得 leader 的实现更复杂。不阻塞新的客户端请求,需要 leader 在发送快照给 follower 的同时做到并行将新的日志条目发给它们,实现起来更为复杂。

还有两个问题会影响快照的性能:

  1. 每个节点需要判断何时生成快照。如果频率太高,会浪费大量磁盘带宽和其他资源,如果频率太低,需要承担耗尽存储的风险,也增加了重启时重新执行日志的时间。一个简单的策略是当日志达到一个固定阈值时生成一份快照,如果阈值设置的显著大于期望的日志大小,那么快照的磁盘开销会比较小。
  2. 写快照需要一定的时间,如果不希望它影响到正常操作,通常使用写时复制的技术。比如 Redis 的 AOF 日志重写,fork 一个子进程来写快照。但 fork 也会阻塞,阻塞时长与日志大小有关(数据大小->页数->页目录项->页表大小)。

Client interaction

Raft 的客户端将所有请求发给 Leader。当一个客户端第一次启动,它会连上一个随机选择的节点,如果这个节点不是 Leader,这个节点会拒绝客户端的请求,并提供关于最近 Leader 的信息(在 AppendEntries 请求中会写到 Leader 的网络地址)。如果 Leader crash 了,客户端请求会超时,然后客户端会重试,去请求一个被随机选中的节点。

线性化语义是指每次操作看起来都像是在调用和响应之间的某个节点上即时执行,我们希望 Raft 能实现线性化语义。在以上的通信规则中,Raft 可能对同一条执行多次,解决方法是客户端对于每一个指令都赋予一个唯一的序列号,状态机跟踪每个客户端已处理的最新的序列号以及相关联的响应。如果状态机接收到了一条已执行过的指令,就立即作出响应,而不是重复执行该指令。

读操作则可以直接处理而不需要日志记录,但是也会有过期读的风险,比如在网络分区的情况下,Leader 响应客户端请求的时候,它可能已经被新的 Leader 替代了,但是它自己却不知道。

线性化操作肯定不会返回过期的数据。而 Raft 主要通过两个额外的预防措施来保证:

  1. Leader 必须拥有已提交日志条目的最新信息。在 Leader 任期刚开始时,它可能还不知道哪些是已提交的,因此它需要在它的任期中提交一个日志条目。即前文的Committing entries from previous terms,Raft 通过让 Leader 在任期开始的时候提交一个空的日志条目到日志中来解决这个问题。
  2. Raft 在处理读请求时需要检查自己是否已经被替代了。Raft 通过让 Leader 在响应读请求前,先和集群中过半节点交换一次心跳信息来解决该问题。论文中提到另一种可选的方案是 Leader 依赖心跳机制来实现一种租约形式,但是这种方式的安全性依赖于时序(必须有严格的时间误差界限)。

Implementation and evaluation

论文中提供了大概 2000+行的 C++代码作为 Raft 实现。这一节通过可理解性、正确性以及性能三个方面来评估 Raft 算法。

Understandability

作者在斯坦福大学和加州大学伯克利分校进行了一项实验研究,研究调查结果表明大部分人认为 Raft 算法比 Paxos 更容易去实现或解释。

Correctness

作者用 TLA 证明系统机械地证明了日志完整性(Log Completeness Property),但是这个证明依赖的约束前提还没有被机械证明,比如规范中的类型安全。

Raft 是没有证明的,你是不知道他有没有缺陷的,所以需要补形式语言和自动机,要写 coq 或者 tla 去验证算法,这种多状态算法不验证是会出问题的。

Performance

Raft 的性能与其他共识算法如 Paxos 很类似。最关键的点在于 Leader 被选举出来后,它应该在什么时候复制新的日志条目,Raft 通过少量的消息就解决了这个问题(一轮从 Leader 到过半节点的消息传递)。还可以支持批量操作和管道操作来提高吞吐量和降低延迟,很多在其他共识算法中已提出的优化方案都可以用在 Raft 上。

作者反复使一个拥有 5 个节点的集群的 leader 宕机,并计算它检测崩溃和重新选出一个新 leader 所需的时间,结果是最小的宕机时间大约是最小选举超时时间的一半。表名了只需要在选举超时时间上使用很小的随机化即可大大避免出现选举失效的情况。当然,这个时间需要在一定的范围内。比如选举超时时间为 12-24ms 的情况下,只需要平均 35ms 就可以选举出新的 leader,进一步降低选举超时时间可能就会违反 Raft 的不等式的要求。作者推荐的保守的选举超时时间是 150-300ms,不大可能导致不必要 leader 更换,还能提供不错的可用性。

广播时间(broadcastTime) << 选举超时时间(electionTimeout) << 平均故障间隔时间(MTBF)

作者在本节介绍了许多与共识算法相关的产物,重点讨论了 Raft 与 Paxos 的异同。

Raft 和 Paxos 最大的不同在于 Raft 的强领导性,Raft 将 Leader Election 作为共识协议中非常重要的一环,并且将大多数功能集成在 Leader 上。而在 Paxos 中,Leader Election 和基本的共识算法是正交的,它只是一种性能优化,而不是实现共识所必须的,因此引入了很多额外机制,如两段式的基本共识协议和单独的 Leader Election 机制。

VR 与 ZooKeeper 也是基于 Leader 的,也拥有 Raft 的一些优点,但是机制更多。在 Raft 中,日志条目单向从 Leader 流向 Follower,而在 VR 中,日志条目的流动是双向的,引入了额外的机制和复杂性。Raft 的消息类型更少,但是消息量更大,但总的来说,它更简单。

参考

]]>
<h2 id="摘要">摘要</h2> <p>Raft 是用于管理复制日志的一致性协议,与 Multi-Paxos 作用相同,效率相当,但是架构更简单,更容易实现。Raft 将共识算法的关键因素分为几个部分:</p> <ul> <li>Leader election 领导者选举<
一文了解一致性哈希 https://makonike.github.io/2023/02/01/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3%E4%B8%80%E8%87%B4%E6%80%A7%E5%93%88%E5%B8%8C/ 2023-02-01T09:57:11.000Z 2023-03-30T16:51:15.524Z 对于分布式存储,在不同机器上存储不同对象的数据,我们通过使用哈希算法来建立从数据到服务器之间的映射关系。

为什么需要一致性哈希

使用简单哈希算法的例子就是m = hash(o) mod n,其中 o 为对象,n 为机器数量,得到的 m 为机器编号,hash()为选用的哈希函数。

考虑以下场景:

3 个机器节点,有 10 个数据哈希值为 1,2,3…10。使用的哈希算法为m = hash(o) mod 3,其中机器 0 上保存的数据有 3,6,9,机器 1 上保存的数据有 1,4,7,10,机器 2 上的数据保存的是 2,5,8。

当增加一台机器后,n=4,此时通过哈希算法索引数据所在节点编号时会发生变化,如数据 4 会保存的机器是编号 0 而不再是 1,所以数据也需要根据集群节点的变化而迁移。当集群中数据量较大时,使用这种简单哈希函数所导致的迁移带来的开销将是集群节点难以承担的,在分布式存储系统中,这意味着如果想要增加一台机器时,就要停下服务,等待所有文件重新分布一次才能对外重新提供服务,而一台机器掉线时,尽管只掉了一部分数据,但所有数据访问路由都会出现问题,导致整个服务无法平滑的扩缩容,成为了有状态的服务,这种问题又被称为 rehashing 问题。

除此之外,当节点数量发生变化时,所有的节点都需要获取到对应哈希函数的配置,如上述是强哈希简单取模,那么需要获取结点数量 n。

一致性哈希简述

一致性哈希算法就是为了解决 rehashing 问题而生的,它能够保证当机器增加或减少时,节点之间的数据迁移只限于两个节点之间,而不会造成全局的网络问题。

在一致性哈希中,会维护一个哈希环,根据常用的哈希算法将对应的 key 哈希到一个具有 2^32 次方个桶的空间,将数字头尾相连(0 到 2^32-1),即想象成一个闭合的环形。与常用的哈希算法不同,常用的哈希算法是对节点的数量进行取模运算,而一致性哈希算法则是对 2^32 进行取模运算。

img

哈希环的空间是按顺时针方向组织的,需要对指定 key 对应的值进行读写时,会首先将 key 作为参数通过哈希函数确定 key 在环上的位置,然后从这个位置沿着哈希环顺时针“行走”,遇到的第一个节点就是 key 对应的结点。我们假设有 key1,key2,key3,经过哈希算法计算后,在环上的位置如图所示:

img

在上述例子中,我们假设在 3 个节点的集群中再添加一个节点 node4,可以看到,key1 与 key2 的映射不会受到影响,只有 key3 的映射会由原先的 node3 映射到 node4。

img

假设此时 node1 故障了,key1 也会被重新转移,映射到 node2 上。可以从下图看到,key2 与 key3 的映射并不受到影响,会受到影响的数据仅仅只是会寻址到此节点与前一节点之间的数据。

img

比起普通的哈希算法,使用一致性哈希算法后,在扩容或缩容时,只需要重定位环空间中一小部分的数据,一致性哈希算法具有较好的容错性和可扩展性。但是在哈希寻址中经常有客户端集中访问少数几个节点的情况,这是由于 key 在节点之间分布不均导致的,从而出现某些机器高负载,某些机器低负载的情况。

img

虚拟节点

在节点数量较少的情况下,上述问题尤为显著,我们可以通过虚拟节点来增加节点数,使得节点的分布更为均匀。

对每一个机器节点计算多个哈希值,在每个计算结果的位置上都放置一个虚拟节点,并将虚拟节点映射到实际节点中,如可以在主机名后增加编号,分别计算 node2-1,node2-2,node2-3…node2-x 的哈希值,为 node2 节点形成了 x 个虚拟节点,如此为所有真实节点都计算 x 个虚拟节点,再分布到哈希环上,这样节点的分布就会显得比较均匀。当然,节点越多分布的会越均匀,此外,使用虚拟节点还可以降低节点之间的负载差异。

对于 x,我们称之为权重,具体取值取决于不同的情况(也可能每个节点的权重都不同),来调整 key 最终在每个节点的概率,如果机器节点 node1 能承受的负载更大,那么它可以被分配两倍的权重,因此平均而言,最终会有两倍的 key 映射到 node1 上。

img

当一个真实节点故障时(或被移除集群),必须从环中删除其所有虚拟节点,而以前与被删除节点相邻的节点则将继承被删除节点的 key 的映射。而其余已经被映射到其它节点的 key 不会受到丝毫影响。

当我们向集群中添加节点时,发生的事情也是相似的,大概会有 1/3 的 key(属于其它节点)会被重定向到新加入的节点中,而其余的 key 映射则不变。

这就是一致性哈希解决 rehashing 问题的方法,一般来说,当 a 为 key 的数量,b 为 node 的数量时(确切来说,是初始和最终节点数的最大值),只有 a/b 的 key 需要被重新映射。

算法权衡

一致性哈希的概念在 Karger 1997 年发布的论文《Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web》中引入,之后在许多其他分布式系统中使用,并不断优化和改良。

在论文中,作者在讨论 Consistent Hashing 的定义时,针对算法好坏给出了 4 个评判指标:

  • 平衡性(Balance):不同 key 的哈希结果分布均匀,尽可能均衡地分布到各个节点上。标准哈希函数的设计很重视平衡性,在分布式存储的设计中,它能使得所有的节点空间都得到利用。
  • 单调性(Monotonicity):当有新节点上线后,系统中原有 key 要么还映射到原来的旧节点上,要么映射到新的节点上,而不会出现从一个旧节点重新映射到另一个旧节点的情况。即当可用存储桶的集合发生更改时,只有在必要时才移动项目以保持均匀的分布。
  • 分散性(Spread):在不同客户端视角中,由于它们可能无法看到后端的所有服务,对于相同的 key,它们可能会认为会被分散到不同的服务节点上,从而降低后端存储的效率,我们称之为不同的观点。分散性要求总的观点数量必须有一个上限,优秀的一致性算法应当让分散性尽可能的小。
  • 服务器负载均衡(Load):类似于 Spread,Load 从服务端的角度来看,指各个服务节点的负载尽量均衡。简单来说,它规定单个节点所能承受 key 映射的上限,好的一致性算法应该让这个上限尽可能的小。

在后续论文《Web Caching with Consistent Hashing》中,Karger 等人提出了一致性哈希的实现,即上文所述的环切法,这个算法的特点在于维护哈希环需要占用内存,具体大小根据节点总数(虚拟节点)而定。

ketama 算法

最常见的一致性哈希算法实现是 ketama 算法,它满足单调性,实现简单,因此也被广泛使用。在 github 上有多语言实现版本

img

算法的核心思路是:从配置文件中读取机器节点列表,包括节点地址以及 mem,其中 mem 参数用于衡量一个节点的权重。对于每个节点按权重计算需要生成几个虚拟节点,ketama 算法的基准是每个节点会计算 160 个虚拟节点,每个节点会生成成 10.0.1.1:11121-1、10.0.1.1:11121-2 到 10.0.1.1:11121-40 共 40 个字符串,以此算出 40 个 16 字节的哈希值(使用的哈希算法是 MD5),每个哈希值生成 4 个 4 字节的哈希值,共计 160 个哈希值,对应 160 个虚拟节点。将所有哈希值及其对应地址存放到一个 continuum 存组中,并按哈希值排序,方便后续通过二分查找直接计算映射节点。

这里贴出代码的关键实现:

img

img

算法的总体复杂度是 log(vn),n 为节点数,v 为每个节点的虚拟节点数,默认为 160。

算法的缺点是占用内存较大(n*v),且在虚拟节点数较少的情况下,平衡性较差。

HRW 算法

集合哈希(Rendezvous hashing),也被称为最高随机权重哈希(HRW),是 1996 年的论文《A Name-Base Mapping Scheme for Rendezvous》中发布的算法,它让多个客户端对 key 映射到后端 n 个服务达成共识,典型的应用就是代理:客户需要将对象分配给哪些站点达成一致。

算法思路是:对于每个 Object O,为每个 Server j 去计算一个得分,然后将 O 分配给最高得分的 Server。首先所有的 client 要有一个一致的 hash 算法 h(),对于每个 O,都会调用 w(i, j) = h(Oi, Sj),由于算法是一致的,所有 client 都可以各自计算权重并挑选最高权重的 Server,从而使得任意 client 都可以基于 HRW 计算 Object 最终的分配位置。

此外,HRW 还可以轻易适应不同 Server 的不同负载能力,假如 Server k 的负载能力是其他 Server 的两倍,只要将 Server k push 到 Server list 中两次即可,显然,Server k 就会被分配到两倍的 Object。

与常规 consistent hashing 相比,HRW 不需要提前计算和存储 token(这里指节点在哈希环上的哈希值),避免了正确处理每个 Server token 带来的开销和复杂性,HRW 还能保证全局均匀分摊 Object,在计算哈希值后,还能选择多个 Server。

跳转一致性哈希

跳转一致性哈希(Jump consistent hash)是 Google 于 2014 年发表的论文《A Fast, Minimal Memory, Consistent Hash Algorithm》中提出的一种一致性哈希算法,特点是占用内存小且速度快,实现代码精简,适合用在分 shard 的分布式存储系统中。

算法思路如下:

以下代码是一个一致性哈希函数,将 key 一致性映射到给定的几个节点中的一个上,输入 key 和节点数量 num_buckets,输出映射到的节点的标号。

1
2
3
4
5
6
7
8
9
int32_t JumpConsistentHash(uint64_t key, int32_t num_buckets) {
int64_t b = -1, j = 0;
while (j < num_buckets) {
b = j;
key = key * 2862933555777941757ULL + 1;
j = (b + 1) * (double(1LL << 31) / double((key >> 33) + 1));
}
return b;
}

假设我们要求的一致性哈希函数的 ch(k, n),n 为节点数量,k 为要映射的 key,K 是 key 的总数。那么有以下情况:

  1. 当 n 为 1 时,所有 k 要映射到同一个节点上,函数返回 0,即 ch(k, 1) = 0。
  2. 当 n 为 2 时,为了映射均匀,每个节点需要映射到 K/2 个 key,因此有 K/2 的 key 需要重新映射。
  3. 以此类推,当 n 为 n+1 时,需要 K/(n+1) 个 key 进行重新映射。

那么哪些 key 需要被重新映射呢?跳转一致性哈希的做法就是使用随机数来决定一个 key 每次是否需要重新映射到新的节点上,此处的随机为伪随机,随机序列随种子变化。为了保证节点数量从 j 变到 j+1 时会有 1/(j+1) 占比的数据重新映射到新结点 (j+1) 中,可以通过这个方法来判断:如果random.next() < 1 / (j + 1)则重新映射,否则不变。实现思路与跳表有点相似,得到如下代码:

1
2
3
4
5
6
7
8
int ch(int k, int n) {
random.seed(k);
int b = 0; // This will track ch(k, j+1).
for (int j = 1; j < n; j++) {
if (random.next() < 1.0/(j+1)) b = j;
}
return b;
}

img

这个函数的复杂度显然是 O(N),接下来我们将其优化到 O(logN)。

在上面的代码中,random.next() < 1 / (j + 1)发送的概率相对小一点,因此命中率不高,只有少数的 key 选择重新映射,这里我们可以帮助其加速。

b 是用于记录 key 最后一次重新映射的节点标号,假如我们现在处于 key 刚刚最后被重新映射的时刻,此时一定有 b+1 个节点,接下来要新增一个节点为 b+2 时,可以知道 k 不需要重新映射的概率是 (b+1)/(b+2)。假设我们要找的下一个 b 是 j,即当节点数量新增到 j+1 个时,恰好位于 key 刚刚最后被重新映射的时候。这个期间 k 保持连续不重新映射的概率应该是 (b+1)/j。

img

改下 ch 函数,当符合连续不重新映射的概率时,直接跳过。

1
2
3
4
5
6
7
8
9
int ch(int k, int n) {
random.seed(k);
int b = 0, j = 0;
while (j < n) {
if (random.next() < (b+1.0)/j) b = j;
j += continuous_stays;
}
return b;
}

设 r=random.next(),转换为 j 最多移动 (b+1)/r 的条件,向下取整为 floor(b+1)/r,改写函数如下:

1
2
3
4
5
6
7
8
9
10
int ch(int k, int n) {
random.seed(k);
int b = -1, j = 0;
while (j < n) {
b = j;
r = random.next();
j = floor((b+1) / r);
}
return b;
}

由于 r 分布均匀,当节点数变化为 i 时发生重新映射的概率是 1/i,所以预期的映射次数是 1/2+…+1/i+…1/n,函数收敛,复杂度为 O(logN)。

与一致性哈希相比,跳转一致性哈希在执行速度、内存消耗、映射均匀性上的表现都更优秀,几乎没有额外的内存消耗。它的性能虽然优秀,但是缺点也显著,它不支持设置节点的权重,尽管可以通过尝试添加虚拟节点来做权重;其次,跳转一致性哈希只能在末尾增删节点,如果在非尾部增删节点会导致后面的节点全部重新标号,会影响数据一致性;此外,跳转一致性哈希还不允许自定义节点编号,标号都是从 0 开始递增的。

除了上述几种算法外,一致性哈希衍生算法有很多种针对不同方面进行优化的实现算法,如有界载荷一致性哈希(Consistent Hashing with Bounded Loads)、悬浮一致性哈希(Maglev Hash)等,读者感兴趣自行了解即可。

应用场景

  • 分布式存储分片
  • P2P 系统
  • 服务路由,负载均衡:当服务为一个有状态服务时,需要根据特定 Key 路由到相同服务机器进行处理。

一致性哈希实战

WebSocket 集群实践

WebSocket 常用于记录用户在线状态、计时,服务端主动传输数据等方面。

为什么要用到一致性哈希

WebSocket 集群的实践中有两个重点需要解决的问题:

  1. 连接用到的 WebSocketSession 存储在服务节点,如何找到某个用户的 Session 所在服务节点?
  2. 如何确保客户端均衡连接到各个服务节点,防止单个服务节点负载过高?

这两个问题都可以通过一致性哈希来解决。

第一个问题主要围绕路由,上文也解释过了,一致性哈希解决了 rehashing 代价高昂的问题,第二个问题则围绕服务节点负载均衡的问题。这里笔者通过添加 Gateway 作为代理层实现路由,当有服务节点故障下线时,该节点与客户端的连接会自动断开(这里假设服务节点与服务中心的连接是畅通的,且客户端有心跳机制去探索服务节点是否存活),重新连接时(客户端 WebSocket 的重连机制),会在 Gateway 通过一致性哈希,通过客户连接标识的哈希值路由到应该重新映射到的服务节点上,而其余节点的连接不会受到影响。

当添加节点时,Gateway 会监听到有新节点上线,根据计算映射到新节点与上一个节点之间的 key 的集合,这些 key 将被重新映射到新节点,因此 Gateway 会通知该旧节点将这些 key 对应的连接断开,然后这些 key 会自动重新连接,通过 Gateway 路由重新映射到新节点上。

如下图,如果新节点 node3 映射到了 node1 与 node2 中间,以顺时针为例,这里的 key2 与 key3 都应该重新映射到 node3 上,因此 Gateway 会通知 node1,将 key2 与 key3 的连接主动断开,当他们自动尝试重连时,会重新连接到 node3 上。关于通知 node1,由于 Gateway 维护了哈希环,可以通过 key 找到映射的节点 node,因此通过路由,通知服务节点是可实现的。

img

尽管 WebSocket Server 是有状态的,但有着客户端的自动重连机制,可以尽可能将切换连接的损耗降低,尽管还有 WebSocket 连接建立和断开的开销。

存在的其他问题

  1. 网络连接与服务状态:前文中我们假设服务节点与服务中心的连接是畅通的,在服务节点无法与服务中心沟通时,我们保证客户端也无法与服务节点沟通。但是实际生产环境中可能存在各种各样的问题,比如服务节点无法与服务中心沟通时,已连接的客户端却能与服务节点沟通,这样 Gateway 在路由时就会丢失很多信息。
  2. 哈希环的存储:前文假设哈希环存放在 Gateway 上,当 Gateway 集群中,一个 Gateway 节点宕机后应该如何正确同步数据。这里需要保证哈希环应该是集群共享读写,服务中心必须灵敏感知服务节点状态,及时更新哈希环,才能避免消息丢失,这里如果将哈希环维护在 Gateway 本地,由于 IO 延迟等因素,需要保证哈希环及时更新的话,实现起来较为复杂。
  3. 负载不均衡:服务节点之间由于访问 key 的热度不同,可能存在的问题是相同 key 数量映射到相同服务节点上时,有些服务节点的压力会更大一些,因此建立连接的路由不能仅仅通过哈希环,还应该记录服务节点的负载情况,根据负载情况来选择路由。另一种办法是缓存该 key 与路由到的节点的映射,避免了多次调用哈希函数的开销,但也需要动态变化。

从一致性哈希看分布式缓存设计

总结

本文简要介绍了一致性哈希,从使用简单的哈希函数取模引出 rehashing 带来的高昂代价问题,说明了一致性哈希存在的意义。在后文中,还介绍了一致性哈希用于解决负载不均衡的问题,同时,本文还介绍了几种常见的一致性哈希算法。在末尾,本文还通过 WebSocket 集群的例子来实践一致性哈希。

参考

]]>
<p>对于分布式存储,在不同机器上存储不同对象的数据,我们通过使用哈希算法来建立从数据到服务器之间的映射关系。</p> <h2 id="为什么需要一致性哈希">为什么需要一致性哈希</h2> <p>使用简单哈希算法的例子就是<code>m = hash(o) mod n</cod
一文了解 Go 语言 Sync 标准库 https://makonike.github.io/2023/01/29/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3Go%E8%AF%AD%E8%A8%80Sync%E6%A0%87%E5%87%86%E5%BA%93/ 2023-01-29T09:01:31.000Z 2023-03-30T16:51:02.614Z 写在前面

Go 语言是一门在语言层面支持用户级线程的高级语言,因此并发同步在 Go 程序编写中尤其重要,其中 channel 虽然作为并发控制的高级抽象,但它的底层就是依赖于 sync 标准库中的 mutex 来实现的,因此了解 sync 标准库是每一个 Gopher 的必备技能之一。

笔者使用的 Go 版本是 1.18.1

sync.WaitGroup

sync.WaitGroup 使得多个并发执行的代码块在达到 WaitGroup 显式指定的同步条件后才得以继续执行Wait()调用后的代码,即达到并发 goroutine 执行屏障的效果。

在以下代码中,我们希望达到多个 goroutine 异步执行完输出任务后,main goroutine 才退出的效果,此时程序执行完毕。转换为实例,就是使得程序输出 110,此处我们并不关心main()中创建的两个 goroutine 之间的执行顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
go func() {
fmt.Print(1)
}()
go func() {
fmt.Print(1)
}()
fmt.Print(0)
}

此时输出

1
0

与期望结果不符,程序运行完毕时只输出了 0,这是因为 goroutine 的创建和调度需要时间,在两个 goroutine 创建期间,main goroutine 已经输出了 0,导致 main 函数结束,程序执行完毕。我们可以使用 WaitGroup 来完成上述需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"sync"
)

func main() {
wg := sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
fmt.Print(1)
}()
go func() {
defer wg.Done()
fmt.Print(1)
}()
wg.Wait()
fmt.Print(0)
}

此时输出 110

1
110

sync.WaitGroup中记录了仍在并发执行的代码块的数量,Add()相当于对这个数量执行 +1 操作,不难想到Done()则为执行了Add(-1),事实确实如此。

1
2
3
4
// Done decrements the WaitGroup counter by one.
func (wg *WaitGroup) Done() {
wg.Add(-1)
}

WaitGroup

在此之前,我们先明确设定状态运行计数与等待计数信号信号计数

WaitGroup 的结构体记录了运行计数,等待计数和信号计数三个 uint32 来对并发的 goroutine 进行不同目的的计数,在笔者使用版本中,其实是一个 uint64 与一个 uint32。这里兼容了 32 位系统,因为在32 位机器上无法实现对 64 位字段的原子操作(64 位字段相当于两个指令,无法同时完成)。在 32 位平台上,如果wg.state1元素依然按照 64 位平台的顺序返回(waiter, counter, sema),那么wg.state1[0]的内存地址是 32 位对齐的,不能保证一定是 64 位对齐的,就无法进行 64 位原子操作(后续需要对状态进行整体的原子更改atomic.AddUnit64)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type WaitGroup struct {
// 表示 `WaitGroup` 是不可复制的,只能用指针传递,保证全局唯一
// noCopy是个空接口,占用0字节,因此内存对齐可以忽略此字段
noCopy noCopy

state1 uint64
state2 uint32
}

// state 返回指向存储在 wg.state 中的 state(运行计数和等待计数) 和 sema(信号计数) 字段的指针。
func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
// 判断是否对8对齐,32位也可能满足这种情况,否则要手动padding
if unsafe.Alignof(wg.state1) == 8 || uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
// 这里对8字节对齐
// 前8bytes做uint64指针state,后4bytes做sema
return &wg.state1, &wg.state2
} else {
// 前4bytes做sema,后8bytes做uint64指针state
state := (*[3]uint32)(unsafe.Pointer(&wg.state1))
return (*uint64)(unsafe.Pointer(&state[1])), &state[0]
}
}

为了保证在 32 位系统上也能原子访问 64 位对齐的 64 位字,通过 state() 来消除高层上实现的差异,具体可以参考issue-6404。由于在 64 位机器上,8 字节是单个机器字的长度,内存地址对 8 取模可以判断该数据对象的内存地址是否为 64 位对齐,state()wg.state1的内存地址对 8 进行取模来判断程序是允许在 64 位平台还是 32 位平台上,根据结果来返回信息

  • 等于 0 返回: wg.state1(wg.state1[0]) 和wg.state1[2]的内存地址
    • 此时返回状态、信号
  • 不等于 0(32 位环境)返回:wg.state1[1]wg.state1[0]的内存地址
    • 此时返回信号、状态

state 调整的前提:如果不能保证对 8 字节对齐,需要手动移位对齐,这里用了内存对齐的 padding

在 4 字节对齐的环境中,8 字节可能跨越了两个 cache line,不保证 64 位的原子操作。

img

在 32 位架构中,WaitGroup 在初始化的时候,分配内存地址的时候是随机的,所以 WaitGroup 结构体 state1 起始的位置不一定是 64 位对齐,可能会是:uintptr(unsafe.Pointer(&wg.state1))%8 = 4,如果出现这样的情况,那么就需要用 state1 的第一个元素做 padding + 4,这样操作后两组就能对 8 字节进行对齐了,用 state1 的后两个元素合并成 uint64 来表示 statep,以下是一个小实验:

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
import (
"unsafe"
)

type a struct {
b byte
}

type w struct {
state1 uint64
state2 uint32
}

func main() {
b := a{}
println(unsafe.Sizeof(b), uintptr(unsafe.Pointer(&b)), uintptr(unsafe.Pointer(&b))%8 == 0)
wg := w{}
// 64环境下
// 1 824634031959 false
// 16 824634031968 true
// 16 824634031972 false
// 32环境下 如果是64位win,需要在cmd情况下 set GOARCH=386
// 1 285454255 false
// 12 285454260 false
// 12 285454264 true 经过第一个uint32的padding,后续的两位uint32对8字节对齐
println(unsafe.Sizeof(wg), uintptr(unsafe.Pointer(&wg.state1)), uintptr(unsafe.Pointer(&wg.state1))%8 == 0)
state := (*[3]uint32)(unsafe.Pointer(&wg.state1))
println(unsafe.Sizeof(wg), uintptr(unsafe.Pointer(&state[1])), uintptr(unsafe.Pointer(&state[1]))%8 == 0)
}

Wait()

Wait()主要用于阻塞 g,直到 WaitGroup 的计数为 0。先获取访问计数值的指针 (state,sema),在自旋循环体中,通过检查计数来检查目前还没有达成同步条件的并行代码块的数量,并且在每次完成检查后增加一次等待计数。此处没有使用密集循环来构造自旋锁等待,是处于性能考虑:为了保证其他 goroutine 能够得到充分调度。如果每一次检查计数时没有达成同步条件,下次循环如果当前 goroutine 不主动让出 CPU,会导致 CPU 空转,降低性能。这里用了runtime_Semacquire(semap),如果等待计数被成功记录,则直接增加信号量,挂起当前 g,否则再进行一次循环获取最新的同步状态。

Wait() 可以在不同的 g 上执行,且调用 Wait() 的 g 数量也可能不唯一,因此需要等待计数。

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
// Wait blocks until the WaitGroup counter is zero.
// race检测相关代码已略去
func (wg *WaitGroup) Wait() {
statep, semap := wg.state()
for {
// 原子操作
state := atomic.LoadUint64(statep)
// 运行counter(state的高32位)
v := int32(state >> 32)
// counter为0时直接返回
if v == 0 {
return
}
// 增加等待计数
if atomic.CompareAndSwapUint64(statep, state, state+1) {
// 增加信号量并挂起当前g,使当前g让出cpu
// 信号量为0时唤醒
runtime_Semacquire(semap)
if *statep != 0 {
panic("sync: WaitGroup is reused before previous Wait has returned")
}
return
}
}
}

Add()

Add()不止是简单的将信号量增 delta,还需要考虑很多因素

  • 内部运行计数不能为负
  • Add 必须与 Wait 属于 happens before 关系
    • 毕竟 Wait 是同步屏障,没有 Add,Wait 就没有了意义
  • 通过信号量通知所有正在等待的 goroutine

先假设 statep 的高 32 位=1,代表有一个运行计数。当Add(-1)时,statep 的高 32 位 + 负数的补码 32 个 1,会溢出 1 导致 statep 的高 32 位=0,即运行计数清零,Wait 操作达成同步条件

img

具体过程如下:

  1. 通过 state 获取状态指针 statep 和信号指针 semap,statep 的高 32 位为 counter,低 32 位为 waiter
  2. 调用atomic.AddUint64()将传入的 delta 左移四位加上 statep,即 counter+delta
  3. counter 可能为负,所以用 int32 来存值,waiter 不可能为负,所以用 uint32 存值
  4. 经过一系列校验,counter 为负则 panic,w 不等于 0 且 delta>0 且 v 值为 delta,说明 add 在 wait 后调用,会 panic,因为 waitGroup 不允许 Wait 方法调用后还调用 add 方法
  5. v > 0 或 w != 0 时直接 return,此时不需要释放 waiter
  6. 到了*statep != state,状态只能是 waiter>0 且 counter==0,当 waiter>0 时,肯定不能 add,且 counter==0 时,wait 不会再自增 waiter,结果一定是一致的,否则触发 panic
  7. 将 statep 置为 0,释放所有 waiter
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
37
38
39
40
41
// 已略去race检测相关代码
func (wg *WaitGroup) Add(delta int) {
// 获取状态指针和信号指针
statep, semap := wg.state()
// 在运行计数上记录delta
// 高32bit是计数值v,所以把delta左移32,增加到计数上
state := atomic.AddUint64(statep, uint64(delta)<<32)
// 运行计数
v := int32(state >> 32)
// 等待计数(低32位)
w := uint32(state)
// 任务计数器不能为负数
if v < 0 {
panic("sync: negative WaitGroup counter")
}
// 添加与等待同时调用(应该是happens before的关系)
// 已经执行了Wait,不容许再执行Add
if w != 0 && delta > 0 && v == int32(delta) {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
// 运行计数>0或没有writer在等待,直接返回
if v > 0 || w == 0 {
return
}
// happens before,add和wait并发调用
if *statep != state {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
// 唤醒所有等待的goroutine,并将等待计数清零
// 此时counter一定为0,waiter一定>0
*statep = 0
for ; w != 0; w-- {
// 执行一次释放一个,唤醒一个waiter
runtime_Semrelease(semap, false, 0)
}
}

// Done decrements the WaitGroup counter by one.
func (wg *WaitGroup) Done() {
wg.Add(-1)
}

WaitGroup 的实现原理总结

WaitGroup 内部维护了 3 个 uint32(实际上是一个 uint64,一个 uint32),分别是状态和信号,状态包括了运行计数 counter 和等待计数 waiter,信号指信号计数 sema。运行计数代表了调用Add()添加的 delta 值,等待计数代表了调用Wait()陷入等待的 goroutine 的数量,信号量 sema 是 runtime 内部信号量的实现,用于挂起和唤醒 goroutine。在Add()的时候增加运行计数,Wait()的时候增加等待计数,如果运行计数不为 0,则将 goroutine 挂起,等到调用Done()->Add(-1) 使等待计数为 0 时会唤醒所有挂起的 goroutine。

我觉得比较核心的地方在于3 个 uint32 中兼容 32 位机器和 64 位机器的实现。由于状态是 64 位的,需要进行 64 位原子操作更新,但是由于 32 位的环境只对 4 字节对齐,首字段可能不是对 8 字节对齐的,因此要用 state 进行兼容,如果不对 8 字节对齐,则将状态放在后面两位 uint32 中,前面一个 4 字节的作为 padding,存放信号计数 sema。如果对 8 字节对齐,状态直接放在前两位即可,这样就兼容了 32 位和 64 位对 64 位原子操作的支持。

sync.Pool

sync.Pool 也许应该叫 sync.Cache,简单来说就是为了避免频繁分配、回收内存给 GC 带来负担的 cache,pool 与连接池类似。sync.Pool 可以将暂时不用的对象缓存起来,等到下次需要的时候直接使用,也不用再次经过内存分配,复用对象的内存,减轻了 GC 的压力,提升系统性能。

如果没有 sync.Pool

多个 goroutine 都需同时创建一个对象时,如果 goroutine 数过多,会导致对象的创建数目递增,导致 GC 压力过大。形成’并发大->占用内存大->GC 缓慢->处理并发能力降低->并发更大’的恶性循环。解决此问题的关键思想就在于对象的复用,避免重复创建、销毁。

以下是一个简单的例子:

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
package main

import (
"fmt"
"sync"
)

var pool *sync.Pool

type Person struct {
Name string
}

func init() {
pool = &sync.Pool{
New: func() interface{} {
fmt.Println("creating a new person")
return new(Person)
},
}
}

func main() {
person := pool.Get().(*Person)
fmt.Println("Get Pool Object:", person)
person.Name = "first"
pool.Put(person)

fmt.Println("Get Pool Object:", pool.Get().(*Person))
fmt.Println("Get Pool Object:", pool.Get().(*Person))
}

输出结果如下

1
2
3
4
5
creating a new person
Get Pool Object: &{}
Get Pool Object: &{first}
creating a new person
Get Pool Object: &{}

在以上代码中,init()创建了一个 sync.Pool,实现的New()方法为创建一个 person 对象,并打印一句话,main()中调用了三次 Get() 和一次 Put。根据输出结果看来,如果在调用Get()时,pool 中没有对象,那么就会调用New()创建新的对象,否则会从 pool 中的对象获取。我们还可以看到,put 到 pool 中的对象属性依然是之前设定的,并没有被重置。

sync.Pool 广泛运用于各种场景,典型例子是 fmt 包中的 print:

img

sync.Pool 的底层实现

1
2
3
4
5
6
7
8
9
10
11
12
type Pool struct {
// go1.7引入的一个静态检查机制,代表该对象不希望被复制,可以使用go vet工具检测到是否被复制
// 在使用时需要实现noCopy保证一个对象第一次使用后不会发生复制
noCopy noCopy
local unsafe.Pointer // 每个P的本地队列,实际类型为[P]poolLocal, 一个切片
localSize uintptr // 大小
victim unsafe.Pointer // local from previous cycle
victimSize uintptr // size of victims array
// 自定义创建对象回调函数,当pool中没有可用对象时会调用此函数
// 当没有可用对象时,如果不设置该函数,get()会返回nil
New func() any
}

noCopy 代表这个结构体是禁止拷贝的,在使用 go vet 工具时生效。

local 是一个 poolLocal 数组(其实是切片local := make([]poolLocal, size))的指针,localSize 代表这个数组的大小,victim 也是一个 poolLocal 数组的指针。

New 函数在创建 pool 时设置,当 pool 中没有缓存对象时,会调用 New 方法生成一个新的对象。

在索引 poolLocal 时,P 的 id 对应[P]poolLocal 下标索引,这样在多个 goroutine 使用同一个 pool 时能减少竞争,提升了性能。在一轮 GC 到来时,victim 和 victimSize 会分别接管 local 和 localSize,victim 机制用于减少 GC 后冷启动导致的性能抖动,使得分配对象更加平滑

Victim Cache 是计算机架构里的一个概念,是 CPU 硬件处理缓存的一种技术,sync.Pool 引入它的目的在于降低 GC 压力的同时提高命中率。

poolLocal

img

这里得提到伪共享问题。伪共享问题,就是在多核 CPU 架构下,为了满足数据一致性维护一致性协议 MESI,频繁刷新同一 cache line 导致高速缓存并未起到应有的作用的问题。试想一下,两个独立线程要更新两个独立变量,但俩独立变量都在同一个 cache line 上,当前 cache line 是 share 状态。如果 core0 的 thread0 去更新 cache line,会导致 core1 中的 cache line 状态变为 Invalid,随后 thread1 去更新时必须通知 core0 将 cache line 刷回主存,然后它再从主从中 load 该 cache line 进高速缓存之后再进行修改,但是该修改又会使得 core0 的 cache line 失效,重复上述过程,导致高速缓存相当于没有一样,反而还因为频繁更新 cache 影响了性能。

这里 poolLocal 的字段 pad 就是用于防止伪共享问题,cache line 在 x86_64 体系下一般是 64 字节

1
2
3
4
5
6
7
type poolLocal struct {
poolLocalInternal
// 将poolLocal补齐至缓存行的大小,防止false sharing(伪共享)
// 在多数平台上128 mod (cache line size) = 0可以防止伪共享
// 伪共享,仅占位用,防止在cache line上分配多个poolLocalInternal
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

poolLocal 数组的大小是程序中 P 的数量,Pool 的最大个数是runtime.GOMAXPROCS(0)

1
2
3
4
5
6
7
// Local per-P Pool appendix.
type poolLocalInternal struct {
// P的私有缓存区,使用时不需要加锁
private any // Can be used only by the respective P.
// 公共缓存区,本地P可用pushHead/popHead。其他的P只能popTail
shared poolChain // Local P can pushHead/popHead; any P can popTail.
}

poolLocalInternal 中 private 代表缓存了一个元素,只能由相应的一个 P 存取。因为一个 P 同时只能执行一个 goroutine,因此不会有并发问题,使用时不需要加锁。

shared 则可以由任意的 P 访问,但是只有本地的 P 才能 pushHead 或 popHead,其他 P 可以 popTail。

poolChain

看看 poolChain 的实现,这是一个双端队列的实现

其中 poolDequeue 是 PoolQueue 的一个实现,实现为单生产者多消费者的固定大小的无锁 Ring 式队列,通过 atomic 实现,底层存储用数组,head,tail 标记。

生产者可以从 head 插入,tail 删除,而消费者只能从 tail 删除。headTail 变量通过位运算存储了 head 和 tail 的指针,分别指向队头与队尾。

poolChain 没有使用完整的 poolDequeue,而是封装了一层,这是因为它的大小是固定长度的,而 pool 则是不限制大小的。

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
// file: /sync/poolqueue.go
type poolChain struct {
// 只有生产者会push,因此不需要加锁同步
head *poolChainElt

// 能被消费者使用,所以操作必须要有原子性
tail *poolChainElt
}

type poolChainElt struct {
poolDequeue
// next被producer写,consumer读,所以只会从nil变成non-nil
// prev被consumer写,producerr读,所以只会从non-nil变成nil
next, prev *poolChainElt
}

type poolDequeue struct {
// 包含了一个32位head指针和一个32位tail指针,都与len(vals) - 1取模过
// tail是队列中最老是数据,head指向下一个要填充的slot
// slots范围是[tail, head),由consumers持有
// 高32位为head,低32位为tail
headTail uint64
// vals是一个存储interface{}的环形队列,size必须是2的幂
// 如果slot为空,则vals[i].typ为空
// 一个slot在此宣告无效,那么tail就不指向它了,vals[i].typ为nil
// 由consumers设置为nil,由producer读
vals []eface
}

type eface struct {
typ, val unsafe.Pointer
}

由此图可以看到 pool 的整体结构

img

获取一个对象-Get()

Get()的过程清晰明了:

  1. 首先通过调用p.pin()将当前 goroutine 与 P 绑定,禁止被抢占,返回当前 P 对应的 poolLocal 以及 pid。
  2. 获取 local 的 private 赋给 x,并置 local 的 private 为 nil
  3. 判断 x 是否为空,若为空,则尝试从 local 的 shared 头部获取一个对象,赋值给 x。如果 x 仍然为空,会调用getSlow()从其他 P 的 shared 尾部偷取一个对象
  4. 调用runtime_procUnpin()解除非抢占。
  5. 如果到此时还没有获取到对象,调用设置的New()创建一个新对象。
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
func (p *Pool) Get() any {
...
// 将当前goroutine绑定到当前p上
l, pid := p.pin()
// 优先从local的private中获取
x := l.private
l.private = nil
if x == nil {
// 如果local的private没有,尝试获取local shared的head
x, _ = l.shared.popHead()
// 如果还没有,则进入slow path
// 调用
if x == nil {
x = p.getSlow(pid)
}
}
// 解除抢占
runtime_procUnpin()
...
// 如果没有获取到,尝试使用New()创建一个新对象
if x == nil && p.New != nil {
x = p.New()
}
return x
}

pin()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (p *Pool) pin() (*poolLocal, int) {
pid := runtime_procPin()
s := runtime_LoadAcquintptr(&p.localSize) // load-acquire
l := p.local // load-consume
if uintptr(pid) < s {
return indexLocal(l, pid), pid
}
return p.pinSlow()
}

func indexLocal(l unsafe.Pointer, i int) *poolLocal {
// 传入的i是数组的index
lp := unsafe.Pointer(uintptr(l) + uintptr(i)*unsafe.Sizeof(poolLocal{}))
return (*poolLocal)(lp)
}

作用是将当前 goroutine 与 P 绑定在一起,禁止抢占,且返回对应 poolLocal 和 P 的 id。

如果 goroutine 被抢占,那么 g 的状态会从 running 变为 runnable,会被放回 P 的 localq 或 globalq,等待下一次调度。但当 goroutine 下次再次执行时,就不一定和现在的 P 结合了,因为之后会用到 pid,如果被抢占,可能接下来使用的 pid 与绑定的 pid 不是同一个。

绑定的逻辑主要在procPin()中。它将当前 gorotuine 绑定的 m 上的 locks 字段 +1,即完成了绑定。调度器执行调度时,又是会抢占当前执行 goroutine 所绑定的 P,防止一个 goroutine 占用 CPU 过长时间。而判断一个 goroutine 能被被抢占的条件就是看 m.locks 是否为 0,若为 0,则可以被抢占。而在procPin()中,m.locks+1,表示不能被抢占。

1
2
3
4
5
6
7
8
//go:nosplit
func procPin() int {
_g_ := getg()
mp := _g_.m

mp.locks++
return int(mp.p.ptr().id)
}

p.pin()中,获取到 p.localSize 和 p.local 后,如果当前 pid 小于 p.localSize,则直接获取 poolLocal 数组中 pid 索引处的位置,否则说明 Pool 还没有创建 poolLocal,调用p.pinSlow()完成创建。

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
func (p *Pool) pinSlow() (*poolLocal, int) {
// 解除绑定
// 避免上大锁造成阻塞,浪费资源
runtime_procUnpin()
// 加全局锁
allPoolsMu.Lock()
defer allPoolsMu.Unlock()
// 重新绑定
pid := runtime_procPin()
// 已经加了全局锁,此时不需要再用原子操作
s := p.localSize
l := p.local
// 对pid重新检查,因为pinSlow途中可能已经被其他线程调用了
// 如果已经创建过了,那么直接返回即可
if uintptr(pid) < s {
return indexLocal(l, pid), pid
}
// 初始化时会将pool放到allPools中
if p.local == nil {
allPools = append(allPools, p)
}
// 当前P数量
// If GOMAXPROCS changes between GCs, we re-allocate the array and lose the old one.
size := runtime.GOMAXPROCS(0)
local := make([]poolLocal, size)
// 回收旧的local
atomic.StorePointer(&p.local, unsafe.Pointer(&local[0])) // store-release
runtime_StoreReluintptr(&p.localSize, uintptr(size)) // store-release
return &local[pid], pid
}

pinSlow()在加锁的情况下进行重试,加全局锁创建一个 poolLocal。整体过程如下:

img

popHead()

1
2
3
4
5
6
7
8
9
10
11
12
func (c *poolChain) popHead() (any, bool) {
d := c.head
for d != nil {
if val, ok := d.popHead(); ok {
// 如果成功获得值,则返回
return val, ok
}
// 继续尝试获取缓存的对象
d = loadPoolChainElt(&d.prev)
}
return nil, false
}

popHead()只会被生产者调用。函数执行时先拿到头节点,如果不为空,则调用头节点的popHead()。这俩popHead()的实现不一致。poolDequeue 的popHead()移除并返回 queue 的头节点,如果 queue 为空,会返回 false。此处 queue 中存储的对象就是 Pool 里缓存的对象。

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
func (d *poolDequeue) popHead() (any, bool) {
var slot *eface
for {
ptrs := atomic.LoadUint64(&d.headTail)
head, tail := d.unpack(ptrs)
// queue为空
if tail == head {
return nil, false
}
// 验证尾节点,并自减头节点指针,这个操作在读出slot的value之前执行。
// 此处是为了锁住head指针的位置,下一步CAS保证去除的必然是头节点
head--
ptrs2 := d.pack(head, tail)
// 典型CAS
if atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) {
// 成功取出value
// 实际就是取head低n位的值
slot = &d.vals[head&uint32(len(d.vals)-1)]
break
}
}
val := *(*any)(unsafe.Pointer(slot))
// 获取到nil的话就是nil了
if val == dequeueNil(nil) {
val = nil
}
// 重置slot。和popTail不同,这里不会与pushHead产生竞态条件。
*slot = eface{}
return val, true
}

回到poolChain.popHead(),如果获取成功则直接返回,否则继续尝试。

getSlow()

getSlow()在 shared 没有获取到缓存对象的情况下,会尝试从其他 P 的 poolLocal 偷取

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
func (p *Pool) getSlow(pid int) any {
// See the comment in pin regarding ordering of the loads.
size := runtime_LoadAcquintptr(&p.localSize) // load-acquire
locals := p.local // load-consume
// 尝试从其他P偷取对象
for i := 0; i < int(size); i++ {
// 从索引pid+1处开始投
l := indexLocal(locals, (pid+i+1)%int(size))
if x, _ := l.shared.popTail(); x != nil {
return x
}
}
// 在尝试从其他P偷取对象失败后,会尝试从victim cache中取对象
// 这样可以使得victim中的对象更容易被回收
size = atomic.LoadUintptr(&p.victimSize)
if uintptr(pid) >= size {
return nil
}
locals = p.victim
l := indexLocal(locals, pid)
if x := l.private; x != nil {
l.private = nil
return x
}
for i := 0; i < int(size); i++ {
l := indexLocal(locals, (pid+i)%int(size))
if x, _ := l.shared.popTail(); x != nil {
return x
}
}
// 清空victim,防止后来人再来这里找
atomic.StoreUintptr(&p.victimSize, 0)
return nil
}

回到Get(),如果实在偷不到,后面会通过New()创建一个新的对象。

popTail()

popTail()将 queue 尾部元素弹出,类似popHead()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (c *poolChain) popTail() (any, bool) {
d := loadPoolChainElt(&c.tail)
if d == nil {
return nil, false
}
for {
// TODO: pop前先加载next,此处与一般的双向链表是相反的
d2 := loadPoolChainElt(&d.next)
if val, ok := d.popTail(); ok {
return val, ok
}
if d2 == nil {
// 队列为空,只有一个尾结点
return nil, false
}
// 双向链表尾节点的queue已经为空,看下一个节点
// 因为它为空,需要pop掉
if atomic.CompareAndSwapPointer((*unsafe.Pointer)(unsafe.Pointer(&c.tail)), unsafe.Pointer(d), unsafe.Pointer(d2)) {
storePoolChainElt(&d2.prev, nil)
}
// 防止下次popTail的时候会看到一个空的dequeue
d = d2
}
}

底层用的还是 poolDequeue 的popTail(),与寻常实现大体类似,也是 CAS。因为要移除尾部元素,所以 tail 自增 1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (d *poolDequeue) popTail() (any, bool) {
var slot *eface
for {
ptrs := atomic.LoadUint64(&d.headTail)
head, tail := d.unpack(ptrs)
if tail == head {
return nil, false
}
ptrs2 := d.pack(head, tail+1)
if atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) {
slot = &d.vals[tail&uint32(len(d.vals)-1)]
break
}
}
val := *(*any)(unsafe.Pointer(slot))
if val == dequeueNil(nil) {
val = nil
}
slot.val = nil
atomic.StorePointer(&slot.typ, nil)
return val, true
}

img

存放一个对象-Put()

Put()将对象添加到 Pool 中,主要过程如下:

  1. 绑定当前 goroutine 与 P,然后尝试将 x 赋值给 private
  2. 如果失败,则将其放入 local shared 的头部
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (p *Pool) Put(x any) {
if x == nil {
return
}
...
l, _ := p.pin()
if l.private == nil {
l.private = x
x = nil
}
if x != nil {
l.shared.pushHead(x)
}
runtime_procUnpin()
}

pushHead()

如果头节点为空,则初始化一下,默认大小为 8。然后调用poolDequeue.pushHead()将其 push 到队列中,如果失败,则说明队列已满,会创建一个两倍大小的 dequeue,然后再次调用poolDequeue.pushHead()。由于前面 g 与 p 已经绑定了,所以不会有竞态条件,这里只需要一次重试就行了。

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
const dequeueBits = 32
const dequeueLimit = (1 << dequeueBits) / 4

func (c *poolChain) pushHead(val any) {
d := c.head
if d == nil {
// 初始化头节点,初始大小为8
const initSize = 8
d = new(poolChainElt)
d.vals = make([]eface, initSize)
c.head = d
storePoolChainElt(&c.tail, d)
}
// 将其push到队列中,如果成功则直接返回
if d.pushHead(val) {
return
}
// 当前dequeue满了,分配一个新的两倍大小的dequeue
newSize := len(d.vals) * 2
if newSize >= dequeueLimit {
// dequeue最大限制为2^30
newSize = dequeueLimit
}

d2 := &poolChainElt{prev: d}
d2.vals = make([]eface, newSize)
c.head = d2
storePoolChainElt(&d.next, d2)
d2.pushHead(val)
}

底层用的还是 poolDequeue 的pushHead(),他将 val 添加到队列头部,如果队列满了会返回 false,走刚才创建新一个两倍大小队列的路,这个函数只能被一个生产者调用,因此不会有竞态条件。首先通过位运算判断队列是否已满,将 tail 加上当前 dequeue 内节点的数量,即 d.vals 的长度,再取低 31 位,看看它与 head 是否相等,相等的话就说明队列满了,如果满了直接返回 false。否则通过 head 找到即将填充的 slot 位置,去 head 指针的低 31 位,判断是否有另一个 goroutine 在 popTail 这个 slot,如果有则返回 false。这里是判断 typ 是否为空,因为 popTail 是先设置 val,再将 typ 设置为 nil 的。最后将 val 赋值给 slot,自增 head。

img

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
func (d *poolDequeue) pushHead(val any) bool {
ptrs := atomic.LoadUint64(&d.headTail)
// 解包,高32位为head,低32位为tail
head, tail := d.unpack(ptrs)
// 判断队列是否已满
if (tail+uint32(len(d.vals)))&(1<<dequeueBits-1) == head {
// 队列满了
return false
}
// 找到即将填充的slot位置
slot := &d.vals[head&uint32(len(d.vals)-1)]
// 检查slot是否与popTail有冲突
typ := atomic.LoadPointer(&slot.typ)
if typ != nil {
// 另一个g在popTail这个slot,说明这个队列仍然是满的
return false
}
if val == nil {
val = dequeueNil(nil)
}
// 将val赋值给slot
*(*any)(unsafe.Pointer(slot)) = val
// 自增head
atomic.AddUint64(&d.headTail, 1<<dequeueBits)
return true
}

在*(*any)(unsafe.Pointer(slot)) = val中,由于 slot 是 eface 类型,先将 slot 转换为 interface{}类型,这样 val 就能以 interface{}类型赋值给 slot,使得 slot.typ 和 slot.val 指向其内存块,slot 的 typ, val 均不为空。

pack()

再来看看pack()unpack(),它们的作用是打包和解包 head 和 tail 俩指针。实际上很简单,pack()就是将 head 左移 32 位,或上 tail 与低 31 位全 1,返回整合成的 uint64。

1
2
3
4
5
func (d *poolDequeue) pack(head, tail uint32) uint64 {
const mask = 1<<dequeueBits - 1
return (uint64(head) << dequeueBits) |
uint64(tail&mask)
}

unpack()则将整合的 uint64 右移 32 位与上低 31 位全 1,得到 head,而 tail 则是低 32 位与上低 31 位全 1。

1
2
3
4
5
6
func (d *poolDequeue) unpack(ptrs uint64) (head, tail uint32) {
const mask = 1<<dequeueBits - 1
head = uint32((ptrs >> dequeueBits) & mask)
tail = uint32(ptrs & mask)
return
}

GC

Pool 实际上也不能无限扩展,否则会因为对象占用内存过多导致 OOM。在 pool.go 的init()中,注册了 GC 发生时如何清理 Pool 的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// sync/pool.go
func init() {
runtime_registerPoolCleanup(poolCleanup)
}

// runtime/mgc.go
var poolcleanup func()

//go:linkname sync_runtime_registerPoolCleanup sync.runtime_registerPoolCleanup
// 利用编译器标志将sync包的清理注册到runtime
func sync_runtime_registerPoolCleanup(f func()) {
poolcleanup = f
}

实际上调用的是pool.poolCleanup(),主要是将 local 与 victim 进行交换,不至于让 GC 将所有的 Pool 都清空,有 victim 兜底,需要两个 GC 周期才会被释放。如果 sync.Pool 的获取、释放速度稳定,就不会有新的 Pool 对象进行分配,如果获取的速度下降,那么对象可能会在两个 GC 周期内被释放,而不是以前的一个 GC 周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func poolCleanup() {
for _, p := range oldPools {
p.victim = nil
p.victimSize = 0
}

// Move primary cache to victim cache.
for _, p := range allPools {
p.victim = p.local
p.victimSize = p.localSize
p.local = nil
p.localSize = 0
}

// The pools with non-empty primary caches now have non-empty
// victim caches and no pools have primary caches.
oldPools, allPools = allPools, nil
}

下面模拟一下调用poolCleanup()前后,oldPools,allPools 与 p.victim 的变化:

  1. 初始时 oldPools 与 allPools 都为 nil
  2. 第一次调用Get(),因为 p.local 为 nil,会通过pinSlow()创建 p.local,将 p 放入 allPools,此时 allPools 长度为 1,oldPools 为 nil
  3. 对象使用完毕,调用Put()放回对象
  4. 第一次 GC STW,allPools 中所有 p.local 赋值给 victim,并置为 nil。allPools 赋值给 oldPools,置为 nil。此时 oldPools 长度为 1,allPools 为 nil
  5. 第二次调用Get(),由于 p.local 为 nil,会尝试从 p.victim 中获取对象。
  6. 对象使用完毕,调用Put()放回对象。由于 p.local 为 nil,会重新创建 p.local,并放回对象,此时 allPools 长度为 1,oldPools 长度也为 1
  7. 第二次 GC STW,oldPools 中所有 p.victim 置为 nil,前一次 cache 在本次 GC 时被回收,allPools 中所有 p.local 将值赋值给 victim 并置为 nil。最后 allPools 为 nil,oldPools 长度为 1。

从以上可以看出,p.victim 的定位是次级缓存,在 GC 时将对象放到其中,下次 GC 来临前,如果有Get()调用则从其中获取,直到再一次 GC 到来时回收。从 victim 中取出的对象并不放回 victim 中,一定程度上也减小了下一次 GC 的开销,使得原先一次 GC 的开销被拉长到两次,有一定程度的开销减小。

sync.Mutex

Mutex 是公平互斥锁

每个 g 去获取锁的时候都会尝试自旋几次,如果没有获取到则进入等待队列尾部(先入先出FIFO)。当持有锁的 g 释放锁时,位于等待队列头部的 g 会被唤醒,但是需要与后来 g 竞争,当然竞争不过,因为后来 g 运行在 cpu 上处于自旋状态,且后来 g 会有很多,而刚唤醒的 g 只有一个,只能被迫重新插回头部。当等待的 g 本次加锁等待时间超过 1ms 都没有获得锁时,它会将当前 Mutex 从正常模式切换为饥饿模式,Mutex 所有权会直接从释放锁的 g 上直接传给队头的 g,后来者不自旋也不会尝试获取锁,会直接进入等待队列尾部。

当发生以下两种情况时 Mutex 会从饥饿模式切换为正常模式

  • 获取到锁的 g 刚来,等待时间小于 1ms
  • 该 g 是等待队列中最后一个 g

饥饿模式下不再尝试自旋,所有 g 都要排队,严格先来后到,可以防止尾端延迟

img

互斥锁 state 的最低三位分别标识mutexLockedmutexWokenmutexStarving,剩余位置用于标识当前有多少个 g 在等待互斥锁释放

  • mutexLocked — 表示互斥锁的锁定状态;
  • mutexWoken — 表示从正常模式被唤醒;
  • mutexStarving — 当前的互斥锁进入饥饿状态;
  • waitersCount — 当前互斥锁上等待的 Goroutine 个数;
1
2
3
4
5
6
7
8
9
10
// A Mutex is a mutual exclusion lock.
// The zero value for a Mutex is an unlocked mutex.
//
// A Mutex must not be copied after first use.
type Mutex struct {
// 状态
state int32
// 信号量
sema uint32
}

看看 lock 与 unlock。lock 会锁住 Mutex,如果这个锁在被使用,那么调用的 g 会被阻塞直到这个互斥锁被释放。当锁 state 为 0 时,会将 mutexLocked 位置置为 1,当 state 不是 0 时,会调用sync.Mutex.lockSlow()尝试通过自旋等方式来等待锁的释放。

自旋是一种多线程同步机制,当前进程在进入自旋的过程中会一直保持对 CPU 的占用,持续检查某个条件是否为真,在多核 CPU 上,自旋可以避免 goroutine 的切换,某些情况下能对显著提升性能。g 在进入自旋的需要满足的条件如下

  1. 互斥锁只有在普通模式才能进入自旋
  2. runtime.sync_runtime_canSpin()返回 true
    1. 在多 CPU 机器上
    2. 当前 g 为了获取该锁进入自旋次数少于 4 次
    3. GOMAXPROCS > 1,至少一个其他 P 在 running,且当前 p 本地 runq 为空
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
// 争锁,实现fast path
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
... race检测相关
return
}
// 便于编译器对fast path进行内联优化
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}

// 如果CAS没有获得锁则进入slow path
// 主体是一个很大的for循环,主要由以下过程组成
// 1. 判断当前g能否进入自旋
// 2. 通过自旋等待互斥锁释放
// 3. 计算互斥锁的最新状态
// 4. 更新互斥锁的状态并获取锁
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
for {
// 饥饿模式下无法自旋
// 告知持有锁的g,在唤醒锁的时候不用再唤醒其他g了
// old&(mutexLocked|mutexStarving) == mutexLocked 必须是上锁、不能处于饥饿状态
// runtime_canSpin(iter)看看自旋次数iter是否超过4,是否在多CPU机器上运行,是否有运行中的P且runq为空
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// !awoke是否是唤醒状态
// old&mutexWoken == 0没有其他正在唤醒的节点
// old>>mutexWaiterShift != 0 表示当前有正在等待的goroutine
// atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) CAS将mutexWoken状态位设置为1
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
// 设置唤醒状态位真
awoke = true
}
// 执行30次PAUSE指令占用CPU并消耗CPU时间
runtime_doSpin()
// 自旋次数加一
iter++
// 获取当前锁状态
old = m.state
continue
}
// ---------------------------------------------------
// 处理完自旋逻辑后,会根据上下文计算当前互斥锁的最新状态
// 当前情况有两种1.自旋超过了次数 2.目前锁没有被持有
new := old
if old&mutexStarving == 0 {
// 如果当前不是饥饿模式,那么将mutexLocked状态位设置1,表示加锁
new |= mutexLocked
}
if old&(mutexLocked|mutexStarving) != 0 {
// 如果old被锁定或者处于饥饿模式,则waiter加一,表示等待一个等待计数
new += 1 << mutexWaiterShift
}
// 如果是饥饿状态,并且已经上锁了,那么mutexStarving状态位设置为1,设置为饥饿状态
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}
// awoke为true则表明当前线程在上面自旋的时候,修改mutexWoken状态成功
if awoke {
// g被唤醒了,无论要抢锁还是排队,操作完后都不是被唤醒的g了
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
// 清除唤醒标志位,后续流程可能g被挂起,需要其他释放锁的g来唤醒
new &^= mutexWoken
}
// ---------------------------------------------------
// 使用CAS函数更新状态
// 在正常模式下,这段代码会设置唤醒和饥饿标记、重置迭代次数并重新执行获取锁的循环;
// 在饥饿模式下,当前 Goroutine 会获得互斥锁
// 如果等待队列中只有当前 Goroutine,互斥锁还会从饥饿模式中退出;
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 1.如果原来状态没有上锁,也没有饥饿,那么直接返回,表示获取到锁
if old&(mutexLocked|mutexStarving) == 0 {
break // locked the mutex with CAS
}
// 2.到这里是没有获取到锁,判断一下等待时长是否不为0
// 如果之前已经等过,则放到队列头部
queueLifo := waitStartTime != 0
// 3.如果等待时间为0,那么初始化等待时间
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
// 4.阻塞等待
// 如果没有通过CAS获取到锁,则会调用此函数通过信号量来保证资源不会被两个g同时获取
// 会在方法中不断尝试获取锁并陷入休眠等待信号量释放, 一旦当前g获取到信号量,会立即返回继续执行下文逻辑
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 5.唤醒之后检查锁是否应该处于饥饿状态
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
// 6.判断是否已经处于饥饿状态
if old&mutexStarving != 0 {
if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
throw("sync: inconsistent mutex state")
}
// 7.加锁并且将waiter数减1
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
// 8.如果当前goroutine不是饥饿状态,就从饥饿模式切换会正常模式
delta -= mutexStarving
}
// 9.设置状态
atomic.AddInt32(&m.state, delta)
break
}
awoke = true
iter = 0
} else {
old = m.state
}
}
}

const(
active_spin = 4
)

// Active spinning for sync.Mutex.
//go:linkname sync_runtime_canSpin sync.runtime_canSpin
//go:nosplit
func sync_runtime_canSpin(i int) bool {
// sync.Mutex is cooperative, so we are conservative with spinning.
// Spin only few times and only if running on a multicore machine and
// GOMAXPROCS>1 and there is at least one other running P and local runq is empty.
// As opposed to runtime mutex we don't do passive spinning here,
// because there can be work on global runq or on other Ps.
if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
return false
}
if p := getg().m.p.ptr(); !runqempty(p) {
return false
}
return true
}

unlock 的过程比 lock 的过程简单,fast path 通过去除 mutexLocked 标志位来快速解锁,如果失败,则进入 slow path。slow path 先判断是否已经被解锁。

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
37
38
func (m *Mutex) Unlock() {
...race检测相关
// Fast path: drop lock bit.
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
// 等待队列有g在排队
m.unlockSlow(new)
}
}

func (m *Mutex) unlockSlow(new int32) {
// 先判断是否已经被解锁
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
// 如果是正常模式
if new&mutexStarving == 0 {
old := new
for {
// 如果等待队列为空,或者一个g已经被唤醒或抢到了锁,则不需要唤醒任何g
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
// 获取唤醒某个g的机会
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
} else {
// 饥饿模式:将互斥锁的所有权直接移交给等待队列头的g,并让出时间片,以便于它可以立即开始运行
// mutexLocked没有设置1,在等待队列头的g被唤醒后才设置
// 如果设置了饥饿模式,mutex仍然是被认定为锁定的, 这样才能让新的g不会获取它
runtime_Semrelease(&m.sema, true, 1)
}
}

Mutex 的实现原理总结

Mutex 底层是 CAS 实现的,内部维护了一个 int32 的状态 state,用于标识锁状态,以及一个 uint32 的信号量 sema 用于挂起和阻塞 goroutine。Mutex 分为正常模式和饥饿模式,不同模式下Lock()UnLock()的对加锁、解锁处理方式不同。Mutex 中的 state 第 4 位及其高位用于存放等待计数,低三位的第一位表示锁状态,第二位表示从正常模式被唤醒,第三位表示进入饥饿模式。

Lock() 先通过 CAS 置 state 为 1,如果失败,则进入 slow path 处理:

  1. 先判断是否可以自旋,饥饿模式下无法自旋
    1. 如果是正常模式,且可以自旋(运行在多 CPU 机器上、当前 g 为了争取该锁进入自旋的次数少于 4、当前机器上至少有个正在运行的 P 且 runq 为空),尝试进行自旋准备:通知运行的 goroutine 不要唤醒其他挂起的 gorotuine,解锁时直接让当前 g 获取锁即可。然后调用runtime_doSpin()进入自旋,执行 30 次 PAUSE 指令占用 CPU,递增自旋次数,重新计算状态
  2. 计算锁状态
  3. 使用 CAS 更新状态
    1. 成功获取锁:返回
    2. 判断等待时间是否为 0,如果是 0 则放在队尾,如果非 0 则放在头部,进入阻塞
    3. 唤醒
      1. 锁是否要进入饥饿状态:等待时间超过 1ms
      2. 重新获取锁状态
      3. 判断是否处于饥饿状态
        1. 是则可以直接获取锁:自减等待计数,设置状态获取锁,如果 starving 不为饥饿,或等待时间没有超过 1ms,或者只有一个 g 在等待队列中,满足任一条件则切换为正常状态
        2. 否:再次循环抢占

UnLock()先 CAS 置锁状态最低位为 0,如果返回结果不为 0,进入 slow path:

也是分别对正常模式和饥饿模式两种进行分别处理,饥饿模式下将锁的所有权直接移交给等待队列头的 g,并让出时间片,以便于它可以立即开始运行。

正常模式下,通过 CAS 更新状态值,唤醒等待队列中的 waiter。当然,如果没有 waiter,或低三位标志位中有一个不为 0 说明有其他 g 在处理了,直接返回。

sync.RWMutex

读写互斥锁不限制并行读,但是读写、写读、写写操作无法并行执行

1
2
3
4
5
6
7
type RWMutex struct {
w Mutex // 被正在写的g持有
writerSem uint32 // 写等待读信号量
readerSem uint32 // 读等待写信号量
readerCount int32 // 正在读的数量
readerWait int32 // 写操作被阻塞时,等待读的数量
}

写锁使用sync.RWMutex.LockUnLock,读锁使用RLockRUnlock

Lock 与 UnLock

Lock 中,先获取内置的互斥锁,获取成功后,其余竞争者 g 会陷入自旋或阻塞。atomic.AddInt32用于阻塞后续的读操作,如果仍有活跃的读操作 g,那么当前写操作 g 会调用 runtime.SemacquireMutex 进入休眠状态等待全部读锁持有者结束后释放 writerSem 信号量,将当前 g 唤醒

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Lock locks rw for writing.
// If the lock is already locked for reading or writing,
// Lock blocks until the lock is available.
func (rw *RWMutex) Lock() {
// ...省略race检测
// 首先解决与其他写操作的竞争
rw.w.Lock()
// 通知读操作者,这是一个写操作
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
// 等待活跃的读操作执行完成
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
// func runtime_SemacquireMutex(s *uint32, lifo bool, skipframes int)
// SemacquireMutex 类似于 Semacquire,但用于分析争用的互斥体。如果 lifo 为真,则在等待队列的头部排队等待服务员。 skipframes 是跟踪期间要忽略的帧数,从 runtime_SemacquireMutex 的调用者开始计算。
runtime_SemacquireMutex(&rw.writerSem, false, 0)
}
// ...省略race检测代码
}

写锁的释放过程与加锁过程相反

  1. atomic.AddInt32 将 readerCount 变为正数,释放读锁
  2. 通过 for 循环唤醒所有阻塞的读操作 g
  3. 释放写锁

获取写锁时先阻塞写锁获取,后阻塞读锁获取,释放写锁时,先释放读锁唤醒读操作,后释放写锁。这种策略能够保证读操作不会被连续的写操作饿死

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Unlock unlocks rw for writing. It is a run-time error if rw is
// not locked for writing on entry to Unlock.
//
// As with Mutexes, a locked RWMutex is not associated with a particular
// goroutine. One goroutine may RLock (Lock) a RWMutex and then
// arrange for another goroutine to RUnlock (Unlock) it.
func (rw *RWMutex) Unlock() {
// ...省略race检测相关
// 通知所有读者,没有活跃的写操作了
r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
// 解锁不存在的读锁会throw
if r >= rwmutexMaxReaders {
race.Enable()
throw("sync: Unlock of unlocked RWMutex")
}
// 唤醒所有阻塞的读操作
for i := 0; i < int(r); i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
// 解锁允许其他写操作者抢占
rw.w.Unlock()
// ...省略race检测相关
}

RLock 与 RUnlock

读锁的加锁方法不能用于递归读锁定,为不可重入锁,同时,Lock 调用会阻止新的读者获取锁。其中只是将 readerCount+1,如果返回了负数,说明其他 g 获得了写锁,当前 g 就会调用runtime_SemacquireMutex()陷入休眠等待写锁释放。如果返回了正数,则代表 g 没有获取写锁,当前方法返回成功

1
2
3
4
5
6
7
8
9
// RLock locks rw for reading.
func (rw *RWMutex) RLock() {
// ...省略检测race相关
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// A writer is pending, wamkit for it.
runtime_SemacquireMutex(&rw.readerSem, false, 0)
}
// ...省略检测race相关
}

释放读锁的方法也很简单,atomic.AddInt32 减少 readerCount 正在读资源的数量,如果返回大于等于 0,则说明解锁成功,如果小于 0,说明有一个正在执行的写操作,会调用 rUnlockSlow 进入 slow path 处理。rUnlockSlow 会减少写操作等待的读操作数 readerWait 并在所有读操作释放后触发写操作的信号量 writeSem,当该信号量触发时,调度器会唤醒尝试获取写锁的 g

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
// RUnlock undoes a single RLock call;
// it does not affect other simultaneous readers.
// It is a run-time error if rw is not locked for reading
// on entry to RUnlock.
func (rw *RWMutex) RUnlock() {
// ...省略检测race相关
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
// Outlined slow-path to allow the fast-path to be inlined
rw.rUnlockSlow(r)
}
// ...省略检测race相关
}

func (rw *RWMutex) rUnlockSlow(r int32) {
// 解锁不存在的读锁会throw
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
race.Enable()
throw("sync: RUnlock of unlocked RWMutex")
}
// A writer is pending.
if atomic.AddInt32(&rw.readerWait, -1) == 0 {
// The last reader unblocks the writer.
runtime_Semrelease(&rw.writerSem, false, 1)
}
}

与 Mutex

对于读操作而言主要是使用信号量限制,写操作则是使用互斥锁与信号量限制

  • 获取写锁时
    • 每次解锁读锁都会将 readerCount-1,归零时说明没有读锁获取
    • 将 readerCount 减少 rwmutexMaxReaders 阻塞后续的读操作(将 readerCount 变为负数)
  • 释放写锁时
    • 先通知所有读操作
    • 将 readerCount 置为正数,释放写锁互斥锁

RWMutex 在 Mutex 上提供了额外的细粒度控制,能在读操作远远多于写操作时提升性能。

sync.noCopy

sync.noCopy 是一个特殊的私有结构体,tools/go/analysis/passes/copylock 包中的分析器会在编译期间检查被拷贝的变量中是否包含 sync.noCopy 或者实现了 Lock 和 Unlock 方法,如果包含该结构体或者实现了对应的方法就会报错

1
2
3
$ go vet proc.go./prog.go:10:10: assignment copies lock value to yawg: sync.WaitGroup
./prog.go:11:14: call of fmt.Println copies lock value: sync.WaitGroup
./prog.go:11:18: call of fmt.Println copies lock value: sync.WaitGroupv

semaTable

semaTable 存储了可供 g 使用的信号量,是大小为 251 的数组。每一个元素存储了一个平衡树的根,节点是 sudog 类型,在使用时需要一个记录信号量数值的变量 sema,根据它的地址映射到数组中的某个位置,找到对应的节点就找到对应信号的等待队列。

channel 没有使用信号量,而是自己实现了一套排队逻辑

1
2
3
4
5
type semaRoot struct {
lock mutex
treap *sudog // root of balanced tree of unique waiters.
nwait uint32 // Number of waiters. Read w/o the lock.
}

sync.Once

sync.once 文件内容很少,只有一个结构体与两个方法,其中 sync.Once 用于保证 go 程序运行期间的某段代码只执行一次,暴露出的 Do 方法用于执行给定的方法

在以下代码中,只会输出一次 only once

1
2
3
4
5
6
7
8
func main() {
o := &sync.Once{}
for i := 0; i < 10; i++ {
o.Do(func() {
fmt.Println("only once")
})
}
}

输出结果如下:

1
2
$ go run main.go
only once

Once 的结构体也很简单:

1
2
3
4
type Once struct {
done uint32 // 标识代码块是否执行过done
m Mutex // 互斥锁,用于保证原子性操作
}

看看 Do 方法的实现。在源代码的注释中说明了为什么不直接 CAS 设定值,if 方法中调用函数,而要使用这种方式,是因为Do 保证了当它返回时,f 函数已经完成,而直接 CAS 后成功执行不成功返回是无法这样保证的。对于 panic 而言,defer 保证了 panic 也会将 done 置为 1,因此即使 f 调用中 panic,依然算已经执行过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Do 调用函数 f 当且仅当 Do 为这个 Once 的实例第一次被调用时
func (o *Once) Do(f func()) {
// fast path,快速判断是否已执行。如果未执行则进入slow path
if atomic.LoadUint32(&o.done) == 0 {
// Outlined slow-path to allow inlining of the fast-path.
o.doSlow(f)
}
}

func (o *Once) doSlow(f func()) {
// 加锁保证原子性操作
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
// defer 保证了如果panic也能设置已完成
defer atomic.StoreUint32(&o.done, 1)
f()
}
}

sync.Cond

Cond 可以让一组 goroutine 在满足特定条件时被唤醒。在以下代码中同时运行了 11 个 goroutine,其中 10 个 goroutine 通过sync.Cond.Wait()等待特定条件瞒住,1 个 goroutine 通过sync.Cond.Broadcast()唤醒所有陷入等待的 goroutine。

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
37
38
39
40
41
package main

import (
"fmt"
"os"
"os/signal"
"sync"
"sync/atomic"
"time"
)

var status int64

func main() {
c := sync.NewCond(&sync.Mutex{})
for i := 0; i < 10; i++ {
go listen(c)
}
time.Sleep(1 * time.Second)
go broadcast(c)

ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
<-ch
}

func broadcast(c *sync.Cond) {
c.L.Lock()
atomic.StoreInt64(&status, 1)
c.Broadcast()
c.L.Unlock()
}

func listen(c *sync.Cond) {
c.L.Lock()
for atomic.LoadInt64(&status) != 1 {
c.Wait()
}
fmt.Println("listen")
c.L.Unlock()
}

运行结果如下:

1
2
3
4
5
6
7
8
9
10
listen
listen
listen
listen
listen
listen
listen
listen
listen
listen

结构体 sync.Cond 中包含了四个字段,最主要的还是 notify。

1
2
3
4
5
6
type Cond struct {
noCopy noCopy // 保证结构体不会在编译期间内被拷贝
L Locker // 用于保护内部的notify字段
notify notifyList // 一个goroutine链表,是实现同步机制的核心结构
checker copyChecker // 禁止运行期间发生的拷贝
}

notifyList 维护了一个 goroutine 链表,以及不同状态的 goroutine 索引

1
2
3
4
5
6
7
type notifyList struct {
wait uint32 // 正在等待的goroutine索引
notify uint32 // 已通知到的goroutine索引
lock uintptr // key field of the mutex
head unsafe.Pointer // 指向链表头节点
tail unsafe.Pointer // 指向链表尾节点
}

在创建一个 Cond 时,必须传入一个 mutex 以关联这个 Cond,保证这个 Cond 的同步属性。

1
2
3
func NewCond(l Locker) *Cond {
return &Cond{L: l}
}

sync.Cond.Wait()

Wait()会使得当前 goroutine 陷入休眠,执行过程分为两个步骤:

  1. 调用runtime_notifyListAdd()将等待计数器 +1 并解锁
  2. 调用runtime_notifyListWait()等待其他 goroutine 的唤醒并加锁
1
2
3
4
5
6
7
8
9
10
11
12
func (c *Cond) Wait() {
c.checker.check()
t := runtime_notifyListAdd(&c.notify)
c.L.Unlock()
runtime_notifyListWait(&c.notify, t)
c.L.Lock()
}

func notifyListAdd(l *notifyList) uint32 {
// 即将wait以原子方式+1
return atomic.Xadd(&l.wait, 1) - 1
}

notifyListWait()则将当前 goroutine 封装为 sudog,追加到 goroutine 通知链表的末尾,然后挂起当前 goroutine。

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
func notifyListWait(l *notifyList, t uint32) {
lockWithRank(&l.lock, lockRankNotifyList)
// 如果已经被唤醒,直接返回
if less(t, l.notify) {
unlock(&l.lock)
return
}
// 封装为sudog,并入队
s := acquireSudog()
s.g = getg()
s.ticket = t
s.releasetime = 0
t0 := int64(0)
if blockprofilerate > 0 {
t0 = cputicks()
s.releasetime = -1
}
if l.tail == nil {
l.head = s
} else {
l.tail.next = s
}
l.tail = s
// 挂起当前goroutine
// 让出当前cpu,并等待scheduler唤醒
goparkunlock(&l.lock, waitReasonSyncCondWait, traceEvGoBlockCond, 3)
if t0 != 0 {
blockevent(s.releasetime-t0, 2)
}
releaseSudog(s)
}

Signal()

Signal()会唤醒队列最前面的 goroutine,核心代码如下:

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
37
38
func (c *Cond) Signal() {
c.checker.check()
runtime_notifyListNotifyOne(&c.notify)
}

func notifyListNotifyOne(l *notifyList) {
// fast path:如果在上次signal后,没有新的waiter,不需要锁了,直接返回即可
if atomic.Load(&l.wait) == atomic.Load(&l.notify) {
return
}
lockWithRank(&l.lock, lockRankNotifyList)
t := l.notify
// 在加锁的情况下recheck
if t == atomic.Load(&l.wait) {
unlock(&l.lock)
return
}
atomic.Store(&l.notify, t+1)
// 从头开始找到满足sudog.ticket == l.notify的goroutine,唤醒并返回
for p, s := (*sudog)(nil), l.head; s != nil; p, s = s, s.next {
if s.ticket == t {
n := s.next
if p != nil {
p.next = n
} else {
l.head = n
}
if n == nil {
l.tail = p
}
unlock(&l.lock)
s.next = nil
readyWithTime(s, 4)
return
}
}
unlock(&l.lock)
}

Broadcast()

Broadcast()会唤醒所有满足条件的 goroutine,这个唤醒顺序也是按照加入队列的先后顺序,先加入的会先被唤醒。

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
func (c *Cond) Broadcast() {
c.checker.check()
runtime_notifyListNotifyAll(&c.notify)
}

func notifyListNotifyAll(l *notifyList) {
// fast path
if atomic.Load(&l.wait) == atomic.Load(&l.notify) {
return
}
lockWithRank(&l.lock, lockRankNotifyList)
s := l.head
l.head = nil
l.tail = nil
atomic.Store(&l.notify, atomic.Load(&l.wait))
unlock(&l.lock)
for s != nil {
next := s.next
s.next = nil
readyWithTime(s, 4)
s = next
}
}

func readyWithTime(s *sudog, traceskip int) {
if s.releasetime != 0 {
s.releasetime = cputicks()
}
// Mark g ready to run.
goready(s.g, traceskip)
}

在条件长时间无法满足时,与使用for {}的忙等相比,sync.Cond 能够让出处理器的使用权,提高 CPU 的利用率。

sync.Map

Go 原生 map 不是线程安全的,在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志置位(等于 1),则直接 panic。而 sync.Map 则是并发安全的,读取、插入、删除都保持着常数级的时间复杂度,且 sync.Map 的零值是有效的,是一个空 map。sync.Map 更适用于读多写少的场景,写多的场景中会导致 read map 缓存失效,需要加锁,导致冲突增多,而且因为未命中 read map 次数变多,导致 dirty map 提升为 read map,是一个 O(N) 的操作,会降低性能。

一般解决并发读写 map 的思路是加一把大锁,在读写的时候先进行加锁,或把一个 map 分成若干个小 map,对 key 进行哈希操作,只操作对应的小 map,前者锁粒度大,影响并发性能,而后者实现较为复杂,容易出错。

与原生 map 相比,sync.Map 仅遍历的方式有些不同。

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
package main

import (
"fmt"
"sync"
)

func main() {
var m sync.Map
// 存放
m.Store("test1", 1)
m.Store("test2", 2)
// 取值
age, _ := m.Load("test1")
fmt.Println(age)
// 遍历
m.Range(func(key, value any) bool {
name := key.(string)
age := value.(int)
fmt.Println(name, age)
return true
})
// 删除
m.Delete("test1")
age, ok := m.Load("test1")
fmt.Println(age, ok)
// 读取或写入
m.LoadOrStore("test2", 3)
age, _ = m.Load("test2")
fmt.Println(age)
}

输出结果如下:

1
2
3
4
5
1
test2 2
test1 1
<nil> false
2

sync.Map 的底层实现

由四个字段组成,其中 mu 互斥锁用于保护 read 和 dirty 字段。

1
2
3
4
5
6
type Map struct {
mu Mutex
read atomic.Value // 实际上存储的是readOnly,可以并发读
dirty map[any]*entry // 原生map,包含新写入的key,且包含read中所有被删除的key
misses int // 每次从read中读取失败就会自增misses,达到一定阈值后会将dirt提升为read
}

真正存储key/value的是read与dirty,但是它们存储的方式是不一样的,前者用atomic.Value,后者单纯使用原生map,原因是read用的是无锁操作,需要保证load/store的原子性,而dirty map 的 load+store 操作是由 mu 互斥锁来保护的。

readOnly 是一个支持原子性的存储的只读数据结构,底层也是一个原生 map。其中 entry 包含了一个指针,指向 value。dirty 的 value 也是 entry 类型的,这里可以看出 read 和 dirty 各自维护了一套 key,key 指向的是同一个 value,只要修改了 entry,对 read 和 dirty 都是可见的。

1
2
3
4
5
6
7
8
type readOnly struct {
m map[any]*entry
amended bool // true if the dirty map contains some key not in m.
}

type entry struct {
p unsafe.Pointer // *interface{}
}

img

entry 的指针 p 共有三种状态:

  1. p == nil:说明该 key/value 已被删除,且 m.dirty == nil 或 m.dirty[k]指向该 key
  2. p == expunged,说该 key/value 已被删除,且 m.dirty 不为 nil,且 m.dirty 中没有这个 key
  3. p 指向一个正常值:表示实际 interface{}的地址,且被记录在 m.read.m[key]中,如果此时 m.dirty 也不为 nil,那么它也被记录在 m.dirty[key]中,二者指向同一个地址。

当删除 key 时,sync.Map 并不会真正地删除 key,而是通过 CAS 将 entry 的 p 设置为 nil,标记为被删除。如果之后创建 m.dirty,p 又会 CAS 设置为 expunged,且不会复制到 m.dirty 中。

如果 p 不为 expunged,和 entry 关联的 value 则可以被原子地更新,如果 p 为 expunged,那么只有在它初次被设置到 m.dirty 后才能被更新。

Store()

expunged 实际上是一个指向任意类型的指针,用于标记从 dirty map 中删除的 entry。

1
var expunged = unsafe.Pointer(new(any))

Store 直接看代码即可,如果 key 在 read 中,会先调用 tryStore,使用 for 循环+CAS 尝试更新 entry,如果更新成功则直接返回。接下来要么 read 中没有这个 key,要么 key 被标记为已删除了,需要先加锁再操作。

  1. 先去 read 中 double check 下,如果存在 key,但 p 为 expunged,说明 m.dirty 不为 nil,且 m.dirty 不存在该 key。此时将 p 状态设置为 nil,将 key 插入 dirty map 中,更新对应 value
  2. 如果 read 中没有此 key,而 dirty 中有,直接更新对应 value
  3. 如果 read 和 dirty 都没有该 key,先看看 dirty 是否为空,为空就要创建一个 dirty,并从 read 中复制没被删除的元素。然后更新 amended 标记为 true,标识 dirty 中存在 read 没有的 key,将 key/value 写入 dirty map 中。更新对应的 value。
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
func (m *Map) Store(key, value any) {
// 如果read map中存在该key,则尝试直接修改
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}

m.mu.Lock()
read, _ = m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() {
// 如果read map中存在该key,但p为expunged,说明m.dirty不为nil且m.dirty不存在该key
// 此时将p的状态修改为nil,并在dirty map中插入key
m.dirty[key] = e
}
// 更新p指向value
e.storeLocked(&value)
} else if e, ok := m.dirty[key]; ok {
// 如果read中不存在该key,但dirty map中存在该key,直接写入更新entry
// 此时read map中仍然没有该key
e.storeLocked(&value)
} else {
// read和dirty中都没有该key
// 如果dirty map为nil,需要创建dirty map,并从read map中复制未删除的元素到新创建的dirty map中
// 更新emended字段未true,表示dirty map中存在read map中没有的key
// 将key/value写入dirty map
if !read.amended {
// 添加第一个新key到dirty map中
// 此处先判断dirty map是否为空,若为空,则浅拷贝read map一次
m.dirtyLocked()
m.read.Store(readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()
}

// 如果为空,则创建一个dirty map,并从read map中复制未删除的元素到新创建的dirty map中
func (m *Map) dirtyLocked() {
if m.dirty != nil {
return
}

read, _ := m.read.Load().(readOnly)
m.dirty = make(map[any]*entry, len(read.m))
for k, e := range read.m {
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}

// CAS设置entry,当p为expunged时返回false
func (e *entry) tryStore(i *any) bool {
for {
p := atomic.LoadPointer(&e.p)
if p == expunged {
return false
}
if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
return true
}
}
}

// 确保entry没有被标记为已清除
func (e *entry) unexpungeLocked() (wasExpunged bool) {
return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}

Load()

流程比 Store 更简单。先从 read 中找,找到了直接调用entry.load(),否则看看 amended,如果是 false,说明 dirty 为空,直接返回 nil 和 false,如果 emended 为 true,说明 dirty 中可能存在要找的 key。先上锁,然后 double check 从 read 找,还没找到就去 dirty 中找,不管 dirty 中找没找到,都得在 missed 记一下,在 dirty 被提升为 read 前都会走这条路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (m *Map) Load(key any) (value any, ok bool) {
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key]
m.missLocked()
}
m.mu.Unlock()
}
if !ok {
return nil, false
}
return e.load()
}

missLocked 直接将 misses+1,标识有一次未命中,如果未命中的次数小于 m.dirty 长度,直接返回,否则将 m.dirty 提升为 read,并清空 dirty 和 misses 计数。这样之前一段时间加的 key 就会进到 read 中,提高 read 的命中率。

1
2
3
4
5
6
7
8
9
func (m *Map) missLocked() {
m.misses++
if m.misses < len(m.dirty) {
return
}
m.read.Store(readOnly{m: m.dirty})
m.dirty = nil
m.misses = 0
}

entry.load()对 p 为 nil 和 p 为 expunged 的 entry 直接返回 nil 和 false,否则将其转为 interface{}返回。

1
2
3
4
5
6
7
func (e *entry) load() (value any, ok bool) {
p := atomic.LoadPointer(&e.p)
if p == nil || p == expunged {
return nil, false
}
return *(*any)(p), true
}

Delete()

套路与Load()Store()相似,从 read 中查是否有这个 key,如果有的话调用entry.delete(),将 p 置为 nil。read 中没找到的话,如果 dirty 不为 nil,就去 dirty 中找,先上锁,再 double check,还没在 read 中找到就去 dirty 中找,然后执行删除操作,再调用 missLocked 看看是否要将 dirty 上升到 read,这里不管是否在 dirty 中找到都得标记一下 misses。如果找到并删除了,调用entry.delete(),否则返回 nil 和 false。

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
// Delete deletes the value for a key.
func (m *Map) Delete(key any) {
m.LoadAndDelete(key)
}

func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key]
delete(m.dirty, key)
// 不管是否存在都记一下misses
m.missLocked()
}
m.mu.Unlock()
}
if ok {
return e.delete()
}
return nil, false
}

entry.delete()也是 CAS 操作,将 p 置为 nil,当判断 p 为 nil 或 expunged 时会直接返回 nil 和 false。

1
2
3
4
5
6
7
8
9
10
11
func (e *entry) delete() (value any, ok bool) {
for {
p := atomic.LoadPointer(&e.p)
if p == nil || p == expunged {
return nil, false
}
if atomic.CompareAndSwapPointer(&e.p, p, nil) {
return *(*any)(p), true
}
}
}

如果 read 中找到了 key,仅仅是将 p 置为 nil 做一个标记,这样在仅有 dirty 中有这个 key 的时候才会直接删除这个 key。这样的目的在于在下次查找这个 key 时会命中 read,提升效率,如果只在 dirty 存在,read 就无法起到缓存的作用,会直接删除。key 本身是需要在 missLocked 前将 key 从 dirty 中删除,才能使其被垃圾回收。

LoadOrStore()

结合了 Load 和 Store 的功能,如果 map 存在该 key,就返回对应 value,否则将 value 设置给该 key。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
func (m *Map) LoadOrStore(key, value any) (actual any, loaded bool) {
// fast path
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {
actual, loaded, ok := e.tryLoadOrStore(value)
if ok {
return actual, loaded
}
}

m.mu.Lock()
// 与store类似
read, _ = m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() {
m.dirty[key] = e
}
actual, loaded, _ = e.tryLoadOrStore(value)
} else if e, ok := m.dirty[key]; ok {
actual, loaded, _ = e.tryLoadOrStore(value)
m.missLocked()
} else {
if !read.amended {
// We're adding the first new key to the dirty map.
// Make sure it is allocated and mark the read-only map as incomplete.
m.dirtyLocked()
m.read.Store(readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value)
actual, loaded = value, false
}
m.mu.Unlock()

return actual, loaded
}

func (e *entry) tryLoadOrStore(i any) (actual any, loaded, ok bool) {
p := atomic.LoadPointer(&e.p)
if p == expunged {
return nil, false, false
}
if p != nil {
return *(*any)(p), true, true
}
ic := i
for {
if atomic.CompareAndSwapPointer(&e.p, nil, unsafe.Pointer(&ic)) {
return i, false, true
}
p = atomic.LoadPointer(&e.p)
if p == expunged {
return nil, false, false
}
if p != nil {
return *(*any)(p), true, true
}
}
}

Range()

参数需要传入一个函数,Range()在遍历时会将调用时刻所有key/value传给该函数,如果返回了false会停止遍历。由于会遍历所有key,是一个O(N)的操作,所以将dirty提升为read,将开销分摊开,提升了效率。

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
func (m *Map) Range(f func(key, value any) bool) {
read, _ := m.read.Load().(readOnly)
// dirty存在read没有的key
if read.amended {
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
// double check
if read.amended {
// 将dirty提升为read
read = readOnly{m: m.dirty}
m.read.Store(read)
m.dirty = nil
m.misses = 0
}
m.mu.Unlock()
}
// O(N)遍历,当f返回false时停止
for k, e := range read.m {
v, ok := e.load()
if !ok {
continue
}
if !f(k, v) {
break
}
}
}

参考

]]>
<h2 id="写在前面">写在前面</h2> <p>Go 语言是一门在语言层面支持用户级线程的高级语言,因此并发同步在 Go 程序编写中尤其重要,其中 channel 虽然作为并发控制的高级抽象,但它的底层就是依赖于 sync 标准库中的 mutex 来实现的,因此了解 syn
一文了解 Go 语言 HTTP 标准库 https://makonike.github.io/2023/01/12/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3Go%E8%AF%AD%E8%A8%80HTTP%E6%A0%87%E5%87%86%E5%BA%93/ 2023-01-12T03:42:35.000Z 2023-03-30T16:51:00.256Z 基于 HTTP 构建的服务标准模型包括客户端Client 和服务端Server。HTTP 请求从客户端发出,服务端接收到请求后进行处理,然后响应返回给客户端。因此 HTTP 服务器的工作就在于如何接受来自客户端的请求,并向客户端返回响应。典型的 HTTP 服务如下图:

img

Client

以下是一个简单示例,在例子中,我们通过 http 标准库向给定的 url:http://httpbin.org/get发送了一个 Get 请求,请求参数为 name=makonike。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"fmt"
"io"
"net/http"
)

func main() {
resp, err := http.Get("http://httpbin.org/get?name=makonike")
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}

http.Get()中,它调用的是DefaultClient.Get(),DefaultClient 是 Client 的一个空实例,实际上调用的是Client.Get()

1
2
// DefaultClient is the default Client and is used by Get, Head, and Post.
var DefaultClient = &Client{}

Client 结构体

1
2
3
4
5
6
type Client struct {
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration
}

Client 结构体总共包含四个字段:

  • Transport:表示 HTTP 事务,用于处理客户端请求连接并等待服务端的响应
  • CheckRedirect:用于指定处理重定向策略
  • Jar:用于存储和管理请求中的 cookie
  • Timeout:设置客户端请求的最大超时时间,包括连接、任何重定向以及读取响应的时间

初始化请求

Get()向指定 url 发出 Get 请求,其中先要根据请求类型构造一个完整的请求,包含请求头、请求体和请求参数,然后才根据请求的完整结构来执行请求。NewRequest()用于构造请求,它会调用NewRequestWithContext(),它返回一个 Request 结构体,其中包含了一个 HTTP 请求的所有信息。

1
2
3
4
5
6
7
8
9
10
11
12
func (c *Client) Get(url string) (resp *Response, err error) {
req, err := NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
return c.Do(req)
}

// NewRequest wraps NewRequestWithContext using context.Background.
func NewRequest(method, url string, body io.Reader) (*Request, error) {
return NewRequestWithContext(context.Background(), method, url, body)
}

Request 是请求的抽象结构体,其中有很多字段都是我们已熟知的,这里仅仅列举一部分:

1
2
3
4
5
6
7
type Request struct {
Method string // 请求方法
URL *url.URL // 请求路径
Header Header // 请求头
Body io.ReadCloser // 请求体
...
}

我们直接来看看NewRequestWithContext(),它的作用是将请求封装成一个 Request 结构体并返回。它强制请求方法不为空,并校验了请求方法的有效性,同时解析 url,最终封装请求信息为一个 Request 结构体。

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
37
38
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
// 如果请求方法为空,那么将其设为 GET
if method == "" {
method = "GET"
}
// 校验请求方法是否被支持
if !validMethod(method) {
return nil, fmt.Errorf("net/http: invalid method %q", method)
}
// 上下文信息,前文使用的是 context.Background(),返回一个空的 context
if ctx == nil {
return nil, errors.New("net/http: nil Context")
}
// 解析 url
u, err := urlpkg.Parse(url)
if err != nil {
return nil, err
}
rc, ok := body.(io.ReadCloser)
if !ok && body != nil {
rc = io.NopCloser(body)
}
// The host's colon:port should be normalized. See Issue 14836.
u.Host = removeEmptyPort(u.Host)
req := &Request{
ctx: ctx,
Method: method,
URL: u,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: make(Header),
Body: rc,
Host: u.Host,
}
...
return req, nil
}

获取到一个完整的 Request 结构体后,Get()会调用c.Do(req)发送请求,它会调用c.do(req),然后调用c.send(req, deadline),最终会调用到send()Client.Do 的逻辑主要分为两段,一段是调用 send() 发送请求接收 Response,关闭 Req.Body,另一段是对需要 redirect 的请求执行重定向操作,并关闭 Resp.Body。

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
37
38
39
40
41
42
func (c *Client) send(req *Request, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
...
resp, didTimeout, err = send(req, c.transport(), deadline)
if err != nil {
return nil, didTimeout, err
}
...
return resp, nil, nil
}

func (c *Client) Do(req *Request) (*Response, error) {
return c.do(req)
}

func (c *Client) do(req *Request) (retres *Response, reterr error) {
// 发送请求并接收 Response
if resp, didTimeout, err = c.send(req, deadline); err != nil {
// c.send() always closes req.Body
reqBodyClosed = true
if !deadline.IsZero() && didTimeout() {
err = &httpError{
err: err.Error() + " (Client.Timeout exceeded while awaiting headers)",
timeout: true,
}
}
return nil, uerr(err)
}
// 检查是否应该重定向
var shouldRedirect bool
redirectMethod, shouldRedirect, includeBody = redirectBehavior(req.Method, resp, reqs[0])
if !shouldRedirect {
return resp, nil
}
req.closeBody()
}

func (c *Client) transport() RoundTripper {
if c.Transport != nil {
return c.Transport
}
return DefaultTransport
}

Client.send()调用send()进行下一步处理前,会先调用c.transport()获取 DefaultTransport 实例。

1
2
3
4
5
6
7
8
9
10
11
12
var DefaultTransport RoundTripper = &Transport{
Proxy: ProxyFromEnvironment, // 对特定的请求返回代理
DialContext: defaultTransportDialContext(&net.Dialer{ // 指定底层 TCP 连接的创建函数
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}),
ForceAttemptHTTP2: true,
MaxIdleConns: 100, // 最大空闲连接数
IdleConnTimeout: 90 * time.Second, // 空闲连接超时时间
TLSHandshakeTimeout: 10 * time.Second, // TLS 握手超时时间
ExpectContinueTimeout: 1 * time.Second,
}

RoundTripper 负责 HTTP 请求的建立,发送,接收 HTTP 应答以及关闭,但是不对 HTTP 响应进行额外处理,例如:redirects, authentication, or cookies 等上层协议细节。Transport 实现了 RoundTripper 接口,它实现的RoundTrip()方法会具体地处理请求,处理完毕后返回 Response。

1
2
3
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}

回到Client.send(),它会调用send(),这个函数的主要逻辑交给获取到的 Transport 实现的RoundTrip()来执行,RoundTrip()会调用到roundTrip()

1
2
3
4
5
6
7
8
9
func send(ireq *Request, rt RoundTripper, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
...
resp, err = rt.RoundTrip(req)
...
}

func (t *Transport) RoundTrip(req *Request) (*Response, error) {
return t.roundTrip(req)
}

roundTrip()中会做两件事,一是调用 Transport 的 getConn() 获取连接,二是在获取到连接后,调用 persistConn 的 roundTrip() 发送请求,等待响应结果。persistConn 是对连接的一层封装,是持久化连接的抽象,通常表示 keep-alive 连接,也可以表示 non-keep-alive 连接,它与 Client 和 Transport 的关系大致如下图:

img

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
func (t *Transport) roundTrip(req *Request) (*Response, error) {
t.nextProtoOnce.Do(t.onceSetNextProtoDefaults)
ctx := req.Context()
trace := httptrace.ContextClientTrace(ctx)
...
// 根据 url 的协议选择对应的实现来替代默认的逻辑,主要用了 useRegisteredProtocol()
scheme := req.URL.Scheme
if altRT := t.alternateRoundTripper(req); altRT != nil {
if resp, err := altRT.RoundTrip(req); err != ErrSkipAltProtocol {
return resp, err
}
var err error
req, err = rewindBody(req)
if err != nil {
return nil, err
}
}
...
for {
select {
case <-ctx.Done():
req.closeBody()
return nil, ctx.Err()
default:
}
// 封装请求
treq := &transportRequest{Request: req, trace: trace, cancelKey: cancelKey}
cm, err := t.connectMethodForRequest(treq)
if err != nil {
req.closeBody()
return nil, err
}
// 获取连接
pconn, err := t.getConn(treq, cm)
if err != nil {
t.setReqCanceler(cancelKey, nil)
req.closeBody()
return nil, err
}
// 等待响应结果
var resp *Response
if pconn.alt != nil {
// HTTP/2 path.
t.setReqCanceler(cancelKey, nil) // not cancelable with CancelRequest
resp, err = pconn.alt.RoundTrip(req)
} else {
resp, err = pconn.roundTrip(treq)
}
if err == nil {
resp.Request = origReq
return resp, nil
}
...
}
}

func (t *Transport) alternateRoundTripper(req *Request) RoundTripper {
if !t.useRegisteredProtocol(req) {
return nil
}
altProto, _ := t.altProto.Load().(map[string]RoundTripper)
return altProto[req.URL.Scheme]
}

func (t *Transport) useRegisteredProtocol(req *Request) bool {
if req.URL.Scheme == "https" && req.requiresHTTP1() {
return false
}
return true
}

func (r *Request) requiresHTTP1() bool {
return hasToken(r.Header.Get("Connection"), "upgrade") &&
ascii.EqualFold(r.Header.Get("Upgrade"), "websocket")
}

获取连接

getConn()有两个阶段:

  1. 调用queueForIdleConn()获取空闲连接
  2. 当不存在空闲连接时,调用queueForDial()创建新的连接

img

可以看到关系图如上。getConn()先封装请求为 wantConn 结构体,然后调用queueForIdleConn()获取空闲连接,如果成功获取则返回连接,失败了则调用queueForDial()创建一个新连接,进行后续的处理。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
req := treq.Request
trace := treq.trace
ctx := req.Context()
if trace != nil && trace.GetConn != nil {
trace.GetConn(cm.addr())
}
// 将请求封装成 wantConn 结构体
w := &wantConn{
cm: cm,
key: cm.key(), // 注意这个字段:其实是 connectMethodKey 结构体类型
// 是对 url 关键字段的分解,代表连接的目标方 (代理,协议,目的地址)
// 包含了 proxyURL,targetAddr、Scheme 和 onlyH1
// onlyH1 bool // whether to disable HTTP/2 and force HTTP/1
ctx: ctx,
ready: make(chan struct{}, 1),
beforeDial: testHookPrePendingDial,
afterDial: testHookPostPendingDial,
}
defer func() {
if err != nil {
w.cancel(t, err)
}
}()

// Queue for idle connection.
if delivered := t.queueForIdleConn(w); delivered {
pc := w.pc
// Trace only for HTTP/1.
// HTTP/2 calls trace.GotConn itself.
if pc.alt == nil && trace != nil && trace.GotConn != nil {
trace.GotConn(pc.gotIdleConnTrace(pc.idleAt))
}
// set request canceler to some non-nil function so we
// can detect whether it was cleared between now and when
// we enter roundTrip
t.setReqCanceler(treq.cancelKey, func(error) {})
return pc, nil
}

cancelc := make(chan error, 1)
t.setReqCanceler(treq.cancelKey, func(err error) { cancelc <- err })

// Queue for permission to dial.
t.queueForDial(w)

// Wait for completion or cancellation.
select {
// 成功获取连接后进入该分支
case <-w.ready:
// Trace success but only for HTTP/1.
// HTTP/2 calls trace.GotConn itself.
if w.pc != nil && w.pc.alt == nil && trace != nil && trace.GotConn != nil {
trace.GotConn(httptrace.GotConnInfo{Conn: w.pc.conn, Reused: w.pc.isReused()})
}
if w.err != nil {
select {
case <-req.Cancel:
return nil, errRequestCanceledConn
case <-req.Context().Done():
return nil, req.Context().Err()
case err := <-cancelc:
if err == errRequestCanceled {
err = errRequestCanceledConn
}
return nil, err
default:
// return below
}
}
return w.pc, w.err
case <-req.Cancel:
return nil, errRequestCanceledConn
case <-req.Context().Done():
return nil, req.Context().Err()
case err := <-cancelc:
if err == errRequestCanceled {
err = errRequestCanceledConn
}
return nil, err
}
}

// 对 url 关键字段的分解,代表连接的目标方 (代理,协议,目的地址)
func (cm *connectMethod) key() connectMethodKey {
proxyStr := ""
targetAddr := cm.targetAddr
if cm.proxyURL != nil {
proxyStr = cm.proxyURL.String()
if (cm.proxyURL.Scheme == "http" || cm.proxyURL.Scheme == "https") && cm.targetScheme == "http" {
targetAddr = ""
}
}
return connectMethodKey{
proxy: proxyStr,
scheme: cm.targetScheme,
addr: targetAddr,
onlyH1: cm.onlyH1,
}
}

来看看queueForIdleConn(),它先根据 wantConn.key 去 idleConn map 中看看是否存在空闲的 connection list,如果能获取到,则获取列表中最后一个 connection 返回,否则将当前 wantConn 加入到 idleConnWait map 中。这部分的逻辑比较简单,直接看代码就能理解。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {
// 禁用长连接的话每次都需要新的 connection,直接返回 false 即可
if t.DisableKeepAlives {
return false
}
t.idleMu.Lock()
defer t.idleMu.Unlock()
// 阻止关闭空闲连接,因为现在我们正在找一个空闲连接用
t.closeIdle = false
...
// If IdleConnTimeout is set, calculate the oldest
// persistConn.idleAt time we're willing to use a cached idle
// conn.
var oldTime time.Time
// 设置超时
if t.IdleConnTimeout > 0 {
oldTime = time.Now().Add(-t.IdleConnTimeout)
}

// 看看最近使用的空闲连接,找到 w.key 相同的 connection list
// values 是 persistConn 的列表
// persistConn 是对连接的一层封装,通常表示 keep-alive 连接,也可以表示 non-keep-alive 连接
if list, ok := t.idleConn[w.key]; ok {
stop := false
delivered := false
for len(list) > 0 && !stop {
// 有连接,获取最后一个
pconn := list[len(list)-1]

// 看看这个 connection 是不是等太久了
tooOld := !oldTime.IsZero() && pconn.idleAt.Round(0).Before(oldTime)
if tooOld {
// 异步清除
go pconn.closeConnIfStillIdle()
}
// 如果标记已断开,或者这个 connection 等太久了执行异步清除,就得忽略它找下一个。
if pconn.isBroken() || tooOld {
list = list[:len(list)-1]
continue
}
// 尝试将整个 connection 写到 wantConn 中
delivered = w.tryDeliver(pconn, nil)
// 如果操作成功,需要将 connection 从 idle connection list 中删除
if delivered {
if pconn.alt != nil {
// HTTP/2: multiple clients can share pconn.
// Leave it in the list.
} else {
// HTTP/1: only one client can use pconn.
// Remove it from the list.
t.idleLRU.remove(pconn)
list = list[:len(list)-1]
}
}
stop = true
}
if len(list) > 0 {
t.idleConn[w.key] = list
} else {
// 如果 list 为 0 了,则将对应的 list 从 map 中删除,可以避免下次判断,尽早去创建连接
// 这里也是为了防止 idleConn 的 list 过多。
delete(t.idleConn, w.key)
}
if stop {
return delivered
}
}
// 如果找不到空闲 connection
if t.idleConnWait == nil {
t.idleConnWait = make(map[connectMethodKey]wantConnQueue)
}
// 将 wantConn 加入到 idleConnWait map 中
q := t.idleConnWait[w.key]
q.cleanFront()
q.pushBack(w)
t.idleConnWait[w.key] = q
return false
}

来看看queueForDial(),它在获取不到同一个 url 对应的空闲连接时调用,会尝试去创建一个连接,这里主要是参数校验,创建连接的逻辑在dialConnFor()中。调用过程如下:

  1. 先校验 MaxConnsPerHost 是否未设置或已到达上限。如果校验不通过则将请求放在 connsPerHostWait map 中。
  2. 如果校验通过,会异步调用dialConnFor()创建连接
  3. dialConnFor()首先调用 dialConn() 创建 TCP 连接,然后启用两个异步线程来处理数据,然后调用 tryDeliver 将连接绑定在 wantConn 上。
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
37
38
39
40
41
42
43
44
45
46
47
48
49
func (t *Transport) queueForDial(w *wantConn) {
w.beforeDial()
// 小于 0 说明每个 host 的最大 connection 数量无限制,直接异步调用 dialConnFor(),然后返回
if t.MaxConnsPerHost <= 0 {
go t.dialConnFor(w)
return
}

t.connsPerHostMu.Lock()
defer t.connsPerHostMu.Unlock()
// 连接数没达到上限,异步建立连接
// MaxConnsPerHost 控制某个 host 的所有连接,包括创建中,正在使用的,以及空闲的连接
// 一旦超过限制,dial 会阻塞
if n := t.connsPerHost[w.key]; n < t.MaxConnsPerHost {
if t.connsPerHost == nil {
t.connsPerHost = make(map[connectMethodKey]int)
}
t.connsPerHost[w.key] = n + 1
go t.dialConnFor(w)
return
}
// 建立的连接数已到上限,需要进入等待队列
if t.connsPerHostWait == nil {
t.connsPerHostWait = make(map[connectMethodKey]wantConnQueue)
}
q := t.connsPerHostWait[w.key]
q.cleanFront()
q.pushBack(w)
t.connsPerHostWait[w.key] = q
}

func (t *Transport) dialConnFor(w *wantConn) {
defer w.afterDial()
// 建立连接
pc, err := t.dialConn(w.ctx, w.cm)
// 连接绑定 wantConn
delivered := w.tryDeliver(pc, err)
// 连接建立成功,但是绑定 wantConn 失败:可能是 HTTP/2 或被共享的
// 那么将该连接放到 idleConnection map 中
if err == nil && (!delivered || pc.alt != nil) {
// pconn was not passed to w,
// or it is HTTP/2 and can be shared.
// Add to the idle connection pool.
t.putOrCloseIdleConn(pc)
}
if err != nil {
t.decConnsPerHost(w.key)
}
}

dialConnFor()调用dialConn()创建 TCP 连接,然后调用tryDeliver()将其与 wantConn 绑定。在dialConn()中,会根据 scheme 的不同设置不同的连接配置,如下是 HTTP 连接的创建过程,创建完成后会开俩 goroutine 为该连接异步处理读写数据。创建的连接结构体包含了俩 channel:writech 负责写入请求数据,reqch 负责响应数据。开的两个 goroutine 异步循环就是处理其中的数据。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
// 持久化连接
pconn = &persistConn{
t: t,
cacheKey: cm.key(), // 分解 url,as cache key
reqch: make(chan requestAndChan, 1),
writech: make(chan writeRequest, 1),
closech: make(chan struct{}),
writeErrCh: make(chan error, 1),
writeLoopDone: make(chan struct{}),
}
...
// 根据不同 scheme 进行不同的配置
if cm.scheme() == "https" && t.hasCustomTLSDialer() {
// 执行 TLS 握手过程
...
} else {
// 建立 tcp 连接
conn, err := t.dial(ctx, "tcp", cm.addr())
if err != nil {
return nil, wrapErr(err)
}
pconn.conn = conn
if cm.scheme() == "https" {
var firstTLSHost string
if firstTLSHost, _, err = net.SplitHostPort(cm.addr()); err != nil {
return nil, wrapErr(err)
}
if err = pconn.addTLS(ctx, firstTLSHost, trace); err != nil {
return nil, wrapErr(err)
}
}
}
// proxy ...
if s := pconn.tlsState; s != nil && s.NegotiatedProtocolIsMutual && s.NegotiatedProtocol != "" {
if next, ok := t.TLSNextProto[s.NegotiatedProtocol]; ok {
alt := next(cm.targetAddr, pconn.conn.(*tls.Conn))
if e, ok := alt.(erringRoundTripper); ok {
// pconn.conn was closed by next (http2configureTransports.upgradeFn).
return nil, e.RoundTripErr()
}
return &persistConn{t: t, cacheKey: pconn.cacheKey, alt: alt}, nil
}
}
// 设置读写 buffer
pconn.br = bufio.NewReaderSize(pconn, t.readBufferSize())
// 俩 buffer 都关联了 persistConn
pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize())
// 为每个连接开俩 gorotuine 异步处理读写数据
go pconn.readLoop()
go pconn.writeLoop()
return pconn, nil
}

// 建立连接
func (t *Transport) dial(ctx context.Context, network, addr string) (net.Conn, error) {
if t.DialContext != nil {
return t.DialContext(ctx, network, addr)
}
if t.Dial != nil {
c, err := t.Dial(network, addr)
if c == nil && err == nil {
err = errors.New("net/http: Transport.Dial hook returned (nil, nil)")
}
return c, err
}
return zeroDialer.DialContext(ctx, network, addr)
}

建立连接用到了 DialContext,它用于创建指定网络协议(如 tcp,udp),指定地址的连接,如下:

1
2
3
4
5
6
Dial("tcp", "golang.org:http")
Dial("tcp", "192.0.2.1:http")
Dial("tcp", "198.51.100.1:80")
Dial("udp", "[2001:db8::1]:domain")
Dial("udp", "[fe80::1%lo0]:53")
Dial("tcp", ":80")

通过注释可以看到t.Dial(network, addr)已经弃用了,这里兼容保留,优先使用 DialContext。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Deprecated: Use DialContext instead, which allows the transport
// to cancel dials as soon as they are no longer needed.
// If both are set, DialContext takes priority.
Dial func(network, addr string) (net.Conn, error)

// Examples:
// Dial("ip4:1", "192.0.2.1")
// Dial("ip6:ipv6-icmp", "2001:db8::1")
// Dial("ip6:58", "fe80::1%lo0")

func Dial(network, address string) (Conn, error) {
var d Dialer
return d.Dial(network, address)
}

func (d *Dialer) Dial(network, address string) (Conn, error) {
return d.DialContext(context.Background(), network, address)
}
// 发现最终也是调用的 DialContext()

最初我们获得的 DefaultTransport 中就初始化了 DialContext,其接口实现者 net.Dialer 包含了创建 TCP 连接的各种选项,如超时设置 timeout(TCP 连接建立超时时间,OS 中一般为 3mins),Deadline(限制了确定的时刻,与 timeout 作用类似),TCP 四元组的原始 IP 地址 LocalAddr 等。

DialContext 创建连接分为三个阶段

  1. resolveAddrList:根据本地地址网络类型以及地址族拆分目标地址,返回地址列表。
  2. dialSerial:对 resolveAddrList 返回的地址依次尝试创建连接,返回第一个创建成功的连接,否则返回第一个错误 firstErr。
  3. setKeepAlice:创建完连接后,检查该连接是否为 TCP 连接,如果是,则设置 KeepAlice 时间。
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) {
if ctx == nil {
panic("nil context")
}
deadline := d.deadline(ctx, time.Now())
if !deadline.IsZero() {
if d, ok := ctx.Deadline(); !ok || deadline.Before(d) {
subCtx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
ctx = subCtx
}
}
if oldCancel := d.Cancel; oldCancel != nil {
subCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
select {
case <-oldCancel:
cancel()
case <-subCtx.Done():
}
}()
ctx = subCtx
}

// Shadow the nettrace (if any) during resolve so Connect events don't fire for DNS lookups.
resolveCtx := ctx
if trace, _ := ctx.Value(nettrace.TraceKey{}).(*nettrace.Trace); trace != nil {
shadow := *trace
shadow.ConnectStart = nil
shadow.ConnectDone = nil
resolveCtx = context.WithValue(resolveCtx, nettrace.TraceKey{}, &shadow)
}

addrs, err := d.resolver().resolveAddrList(resolveCtx, "dial", network, address, d.LocalAddr)
if err != nil {
return nil, &OpError{Op: "dial", Net: network, Source: nil, Addr: nil, Err: err}
}

sd := &sysDialer{
Dialer: *d,
network: network,
address: address,
}

var primaries, fallbacks addrList
if d.dualStack() && network == "tcp" {
primaries, fallbacks = addrs.partition(isIPv4)
} else {
primaries = addrs
}

var c Conn
if len(fallbacks) > 0 {
c, err = sd.dialParallel(ctx, primaries, fallbacks)
} else {
c, err = sd.dialSerial(ctx, primaries)
}
if err != nil {
return nil, err
}

if tc, ok := c.(*TCPConn); ok && d.KeepAlive >= 0 {
setKeepAlive(tc.fd, true)
ka := d.KeepAlive
if d.KeepAlive == 0 {
ka = defaultTCPKeepAlive
}
setKeepAlivePeriod(tc.fd, ka)
testHookSetKeepAlive(ka)
}
return c, nil
}

发送请求等待响应

img

分析完 getConn() 后,我们回头来分析 Client.Do 的第二步resp, err = rt.RoundTrip(req)。它先将请求数据写入到 writech channel 中,writeLoop 接收到数据后就会处理请求。然后roundTrip()将 requestAndChan 结构体放入 reqch channel。roundTrip()循环等待,当 readLoop 读取到响应数据后就会通过 requestAndChan 结构体保存的 channel,将数据封装为 responseAndError 结构体回写,这样roundTrip()接到响应数据后就结束循环等待并返回。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
func (t *Transport) roundTrip(req *Request) (*Response, error) {
...
pconn, err := t.getConn(treq, cm)
if err != nil {
t.setReqCanceler(cancelKey, nil)
req.closeBody()
return nil, err
}

var resp *Response
if pconn.alt != nil {
// HTTP/2 path.
t.setReqCanceler(cancelKey, nil) // not cancelable with CancelRequest
resp, err = pconn.alt.RoundTrip(req)
} else {
// http/1,走这
resp, err = pconn.roundTrip(treq)
}
...
}

func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
...
startBytesWritten := pc.nwrite
writeErrCh := make(chan error, 1)
// 将请求数据写到 writech 中
pc.writech <- writeRequest{req, writeErrCh, continueCh}
// 用于接收响应的 channel
resc := make(chan responseAndError)
// 将用于接收响应的 channel 封装为 requestAndChan,写到 reqch channel 中
pc.reqch <- requestAndChan{
req: req.Request,
cancelKey: req.cancelKey,
ch: resc,
addedGzip: requestedGzip,
continueCh: continueCh,
callerGone: gone,
}

for {
testHookWaitResLoop()
select {
...
// 接收响应数据
case re := <-resc:
if (re.res == nil) == (re.err == nil) {
panic(fmt.Sprintf("internal error: exactly one of res or err should be set; nil=%v", re.res == nil))
}
if debugRoundTrip {
req.logf("resc recv: %p, %T/%#v", re.res, re.err, re.err)
}
if re.err != nil {
return nil, pc.mapRoundTripError(req, startBytesWritten, re.err)
}
// 返回响应数据
return re.res, nil
...
}
}
}

writeLoop 会请求数据 writeRequest,将 writech channel 中获取到的数据写入 TCP 连接中,并发送到目标服务器。当调用Request.write()向请求写入数据时,实际上直接将数据写入了 persistConnWriter 封装的 TCP 连接中bw.Flush(),TCP 协议栈负责将 HTTP 请求中的内容发送到目标服务器。

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
func (pc *persistConn) writeLoop() {
defer close(pc.writeLoopDone)
for {
select {
case wr := <-pc.writech:
startBytesWritten := pc.nwrite
// 向 TCP 连接写入数据,并发送到目标服务器。
// 里面是将数据写到 pc.bw 中,然后调用 bw.Flush()
err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))
...
case <-pc.closech:
return
}
}
}

type persistConnWriter struct {
pc *persistConn
}

func (w persistConnWriter) Write(p []byte) (n int, err error) {
n, err = w.pc.conn.Write(p)
w.pc.nwrite += int64(n)
return
}

TCP 连接中的响应数据通过 roundTrip 传入的 channel 再回写,然后 roundTrip 就会接收到数据并返回获取到的响应数据。

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
func (pc *persistConn) readLoop() {
closeErr := errReadLoopExiting // default value, if not changed below
defer func() {
pc.close(closeErr)
pc.t.removeIdleConn(pc)
}()
...
alive := true
for alive {
pc.readLimit = pc.maxHeaderResponseSize()
// 获取 roundTrip 发送的结构体
rc := <-pc.reqch
trace := httptrace.ContextClientTrace(rc.req.Context())

var resp *Response
if err == nil {
// 读取数据
resp, err = pc.readResponse(rc, trace)
} else {
err = transportReadFromServerError{err}
closeErr = err
}
...
// 将响应数据写回到 channel 中
select {
case rc.ch <- responseAndError{res: resp}:
case <-rc.callerGone:
return
}
}
}

至此,HTTP 标准包中 Client 端的大致处理流程已经介绍完了,我们总结一下大致步骤:

  1. 初始化请求:首先通过 NewRequest() 校验请求信息,并将请求封装为 request 结构体,然后调用 Client.Do() 发送请求。
  2. 发送请求准备Client.Do() 中调用了 Client.send() 通过 Client.transport() 获取默认的 Transport 实例,然后调用 send()。
  3. 获取连接:send() 中先通过 getConn() 获取连接,其中先调用 queueForIdleConn() 获取空闲连接,如果获取不到,则调用 queueForDial() 创建新的连接。获取连接前需要校验连接数是否超过限制,然后异步调用 dialConnFor() 创建连接,其中是调用 dialConn() 来创建 TCP 连接,然后启用两个异步 goroutine 来处理数据,调用 tryDeliver() 将连接绑定在 wantConn 上。
  4. 发送请求并等待响应:成功获取连接后,在 send() 的下文调用 RoundTrip(),它将请求数据写入 writech 中,异步执行的 writeLoop 接收到数据后就会处理请求。然后 roundTrip() 将封装的 requestAndChan 结构体放入 reqch,进入循环等待。当 readLoop 读取到响应数据后会通过 requestAndChan 保存的 channel,将数据封装为 responseAndError 结构体写回。循环等待的 roundTrip() 接收到响应数据后就结束循环等待,并返回。

Server

同样以一个简单的例子开头:下面的例子监听 8000 端口,并在客户端请求路径/时在控制台打印Hello World

1
2
3
4
5
6
7
8
func HelloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World")
}

func main() {
http.HandleFunc("/", HelloHandler)
http.ListenAndServe(":8000", nil)
}

借用 luozhiyun 博主的图囊括一下流程:

img

从上图看出大致步骤:

  1. 注册 handler 到 handler map 中。
  2. open listener 开启循环监听,每听到一个连接就会创建一个 goroutine。
  3. 在创建好的 goroutine 中 accept loop 循环等待接收数据,异步处理请求。
  4. 有数据到来时根据请求地址去 handler map 中 match handler,然后将 request 交给 handler 处理。

注册处理器

上述例子中,使用了http.HandleFunc()来注册 handler。其调用的是DefaultServeMux.HandleFunc(pattern, handler),最终调用mux.Handle(pattern, HandlerFunc(handler))来为给定的 pattern 注册 handler。如果对应的 pattern 已经存在一个 handler,则会 panic。其实 mux.m 就是一个 hash 表,用于精确匹配。当 pattern 尾字符为’/'时,放入[]muxEntry 用于部分匹配。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}

type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}

// HandleFunc registers the handler function for the given pattern.
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
if handler == nil {
panic("http: nil handler")
}
mux.Handle(pattern, HandlerFunc(handler))
}

func (mux *ServeMux) Handle(pattern string, handler Handler) {
mux.mu.Lock()
defer mux.mu.Unlock()

if pattern == "" {
panic("http: invalid pattern")
}
if handler == nil {
panic("http: nil handler")
}
// 已存在,panic
if _, exist := mux.m[pattern]; exist {
panic("http: multiple registrations for " + pattern)
}

if mux.m == nil {
mux.m = make(map[string]muxEntry)
}
e := muxEntry{h: handler, pattern: pattern}
// 其实就是一个 map,存储了 muxEntry 对象,封装了 pattern 和 handler。
mux.m[pattern] = e
// 如果尾字符为'/',则将对应的 muxEntry 放到 []muxEntry 中,用于部分匹配
if pattern[len(pattern)-1] == '/' {
// 保证了长到短有序
mux.es = appendSorted(mux.es, e)
}

if pattern[0] != '/' {
mux.hosts = true
}
}

func appendSorted(es []muxEntry, e muxEntry) []muxEntry {
n := len(es)
i := sort.Search(n, func(i int) bool {
return len(es[i].pattern) < len(e.pattern)
})
if i == n {
return append(es, e)
}
// we now know that i points at where we want to insert
es = append(es, muxEntry{}) // try to grow the slice in place, any entry works.
copy(es[i+1:], es[i:]) // Move shorter entries down
es[i] = e
return es
}

循环监听

监听通过调用http.ListenAndServe(":8000", nil)实现,其调用的是server.ListenAndServe()。server 封装了给定的 addr 和 handler,handler 通常情况下为 nil,这个 handler 用于处理全局的请求。核心逻辑是net.Listen()server.Serve()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (srv *Server) ListenAndServe() error {
if srv.shuttingDown() {
return ErrServerClosed
}
addr := srv.Addr
if addr == "" {
addr = ":http"
}
// 监听端口
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
// 循环接收监听到的网络请求
return srv.Serve(ln)
}

func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}

Serve() 接收每个连接,并为每个连接创建一个 goroutine 来读取请求,然后调用 srv.Handler 来回复它们。里面用到了一个循环去接收监听到的网络连接,如果并发很高的话,可能会一次性创建太多协程,导致处理不过来的情况。

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
func (srv *Server) Serve(l net.Listener) error {
...
l = &onceCloseListener{Listener: l}
defer l.Close()
...
baseCtx := context.Background()
...
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
for {
rw, err := l.Accept()
if err != nil {
select {
case <-srv.getDoneChan():
return ErrServerClosed
default:
}
...
return err
}
connCtx := ctx
...
// 为每个连接创建新的net/http.conn,是HTTP连接服务端的抽象
c := srv.newConn(rw)
c.setState(c.rwc, StateNew, runHooks) // before Serve can return
go c.serve(connCtx)
}
}

处理请求

读取请求调用的是c.readRequest(ctx),分发请求需要看具体的实现,调用这个接口serverHandler{c.server}.ServeHTTP(w, w.req)时,最终调用的是ServeMux.ServeHTTP(),然后通过 handler 调用到match()进行路由匹配。最终是调用handler.ServeHTTP()处理请求。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
func (c *conn) serve(ctx context.Context) {
c.remoteAddr = c.rwc.RemoteAddr().String()
ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
...
// HTTP/1.x from here on.
ctx, cancelCtx := context.WithCancel(ctx)
c.cancelCtx = cancelCtx
defer cancelCtx()

c.r = &connReader{conn: c}
c.bufr = newBufioReader(c.r)
c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)

for {
// 读取请求
w, err := c.readRequest(ctx)
...
// 根据匹配到的 handler 处理请求
serverHandler{c.server}.ServeHTTP(w, w.req)
...
w.cancelCtx()
if c.hijacked() {
return
}
w.finishRequest()
...
}
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
// 一般设为 nil,会默认填充 DefaultServeMux
if handler == nil {
handler = DefaultServeMux
}
// 如果请求*或请求方法为 OPTIONS(与同源策略相关,表示一种试探请求)
if req.RequestURI == "*" && req.Method == "OPTIONS" {
handler = globalOptionsHandler{}
}
...
handler.ServeHTTP(rw, req)
}

// 将请求分派给 handler
func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
if r.RequestURI == "*" {
if r.ProtoAtLeast(1, 1) {
w.Header().Set("Connection", "close")
}
w.WriteHeader(StatusBadRequest)
return
}
h, _ := mux.Handler(r)
h.ServeHTTP(w, r)
}


// 处理器
func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string) {
...
return mux.handler(host, r.URL.Path)
}

func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {
mux.mu.RLock()
defer mux.mu.RUnlock()

// Host-specific pattern takes precedence over generic ones
if mux.hosts {
h, pattern = mux.match(host + path)
}
if h == nil {
h, pattern = mux.match(path)
}
if h == nil {
h, pattern = NotFoundHandler(), ""
}
return
}

// Find a handler on a handler map given a path string.
// Most-specific (longest) pattern wins.
func (mux *ServeMux) match(path string) (h Handler, pattern string) {
// 先检查精确匹配
v, ok := mux.m[path]
if ok {
return v.h, v.pattern
}
// 找最长的合法匹配,一直匹配到下一个父节点路由,直到根路由
// mux.es 包含的路径是长到短有序的
for _, e := range mux.es {
if strings.HasPrefix(path, e.pattern) {
return e.h, e.pattern
}
}
return nil, ""
}

最后调用handler.ServeHTTP()来处理请求。

1
2
3
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}

至此,HTTP 标准库为 Server 端提供请求处理的流程大致介绍完了。简单来看就是注册处理器(在 Go Web 中,挺多人称呼 handler 为“中间件”,虽然我不太能理解= =)、监听端口、处理请求,当请求到来时为其创建一个 goroutine 处理请求,主要是根据 url 来分派 handler,调用 handler 方法来处理请求。

接口型函数

在上文中,我们看到了一个比较特殊的实现:HandlerFunc。HTTP 标准库中定义了一个接口Handler,代表处理器,只包含一个方法ServeHTTP(ResponseWriter, *Request),接着定义了一个函数类型HandlerFunc,它的参数与返回值都和 Handler 里的 ServeHTTP 方法是一致的。而且 HandlerFunc 还定义了 ServeHTTP 方法,并在其中调用自己,这样就实现了接口 Handler。所以 HadnlerFunc 是一个实现了接口的函数类型,简称为接口型函数。

1
2
3
4
5
6
7
8
9
10
11

type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}

type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}

优势

我们以上文的mux.Handle(pattern, HandlerFunc(handler))为例,它的作用是使用某个处理器方法来处理匹配路径的请求,而 handler 则代表了处理器方法。

1
2
3
4
5
6
7

func (mux *ServeMux) Handle(pattern string, handler Handler) {
...
e := muxEntry{h: handler, pattern: pattern}
mux.m[pattern] = e
...
}

我们可以使用多种方式来调用这个函数,例如将 HandlerFunc 类型的函数作为参数,这样支持匿名函数,也支持普通的函数,还有一种方式是将实现了 Handler 接口的结构体作为参数,但是这种方式适用于逻辑较为复杂的场景,如果需要的信息比较多,而且还有很多中间状态要保持,那么封装为一个结构体显然更符合情况。

通过接口型函数,该方法的参数既可以是普通的函数类型,也可以是结构体,使用起来更为灵活,可读性更好。

1
2
3
4
5
6
7
8
9
10

func home(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("hello, index page"))
}

func main() {
http.Handle("/home", http.HandlerFunc(home))
_ = http.ListenAndServe("localhost:8000", nil)
}

总结

HTTP 标准库的实现相对来说比较简单,感觉能学到的东西比较少,不过想要学习更多的网络框架、Web 框架,HTTP 标准库还是先要了解的。

参考与推荐阅读

]]>
<p>基于 HTTP 构建的服务标准模型包括<strong>客户端</strong>Client 和<strong>服务端</strong>Server。HTTP 请求从客户端发出,服务端接收到请求后进行处理,然后响应返回给客户端。因此 HTTP 服务器的工作就在于如何接受来自客
一文了解事务 https://makonike.github.io/2023/01/04/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3%E4%BA%8B%E5%8A%A1/ 2023-01-04T15:50:02.000Z 2023-07-27T15:13:44.854Z 简化容错,不惧失败

在实际的生产环境中,分布式数据系统面临着命运的裁决,诸多不幸随时可能发生:

  • 系统侧:数据库软件和硬件系统在任何时间都有可能会发生故障
  • 应用侧:应用程序在任意时刻都可能会崩溃
  • 网络侧:数据库与应用、或与其他数据库节点的连接随时可能断开
  • 并发:多个客户端并发写入时,可能会有竞态条件和相互覆盖
  • 半读:一个客户端可能会读到部分更新的数据库

复杂度是不灭的,只能转移。为了实现可靠性,数据库必须处理这些故障,如果数据库对这些故障不做任何处理,应用层就需要处理上述所有相关问题,会极大的增加应用侧编程的复杂度。事务,就是为简化应用编程模型而生的,事务为应用程序提供了安全保证(safety guarantees),使得应用程序可以自由地忽略某些潜在的错误情况和并发问题。

简单来说,事务(transaction) 是将多个读写操作组合成一个逻辑单元进行执行,并提供一种保证,事务中的所有操作被视作单个操作来执行:整个事务要么成功(提交 commit),要么失败(被动终止 abort,或主动回滚 rollback)。如果事务执行失败,应用程序可以安全地重试,不用担心存在部分失败的情况,即某些操作成功,某些操作由于某种原因失败的一种中间状态。

时间的角度看,事务在一个生命周期中保证了一组操作的整体性,从空间的角度看,事务在多个事务间做好了并发控制。

当然,事务不是天然存在的,事务简化了应用编程模型,但任何便利性都是有代价的,使用事务的时候一定程度上牺牲了性能和可用性。如果有多个客户端的事务并发执行,还会涉及到隔离性的问题。

从另一个角度来说,也不是所有的应用都需要事务,有时候弱化事务保证或者完全放弃事务也是完全可以接受的,因为这样可以获得更高的性能或者更高可用性,而且一些安全属性也可以在没有事务的情况下实现。一般来说,数据库允许用户在隔离级别与性能之间做选择。

棘手的概念

现在几乎所有的关系型数据库和一些非关系型数据库都支持事务,大多数遵循 IBM System R(第一个 SQL 数据库)在 1975 年引入的风格。

但是近些年,NoSQL 的发展对事务的概念造成了一些冲击,在 2000 年后为了支持大规模分布式数据的存储,NoSQL 引入了分区、冗余,部分放弃了对原有事务的完整支持。部分新一代数据库通过重新定义“事务”来号称仍然支持事务,亦或是为了商业的宣传引入近似的名词。

其中不乏两种极端的观点,一种认为事务与可伸缩性不可兼得,大型的分布式系统必须放弃事务以保持高可用和高性能;另一种认为事务是保证高可用的应用不丢失数据的必要条件。这两种观点都有失偏颇,与任何技术一样,事务有其优点和局限性,为了理解这些权衡,我们需要了解事务在正常情况与极端情况下锁提供保证的细节。

ACID 的含义

事务提供的安全保证通常由缩略词 ACID 来描述,ACID 代表原子性(Atomicity)一致性(Consistency)隔离性(Isolation)持久性(Durability)。它由 Theo Härder 和 Andreas Reuter 于 1983 年提出,目的是为数据库中的容错保证提供一种相对精确的描述。但是不同数据库对 ACID 的支持并不相同,尤其是 Isolation-隔离性。如今,ACID 更多的沦为一个营销术语。

与 ACID 一同提到的另一个标准为 BASE,代表了基本可用性(Basically Available)软状态(Soft State)最终一致性(Eventual consistency)。它比 ACID 的定义更模糊,因此 BASE 更多用来描述不符合 ACID 标准的系统。

下面将逐一探究 Atomicity、Consistency、Isolation 和 Durability 的精确含义,以此提炼出事务的思想。

原子性(Atomicity)

原子一般指不可分割的最小单位。在并发编程中,一个线程执行一个原子操作,这意味着另一个线程无法看到该操作执行到一半的中间结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间的状态。

但是 ACID 的原子性并不是关于并发的,它更多描述的是单个客户端/线程内,一组操作可以被原子执行,如果执行到一半,已经执行的操作可以被全部回滚。原子性提供的保证是在发生错误时,会回滚该事务所有已经写入的变更

如果没有原子性,在多处变更进行到一半时发生错误(例如进程崩溃、网络连接中断、磁盘变满或某种完整性约束被违反),很难知道哪些变更已经生效,哪些变更没有生效。此时应用程序可以再试一次,但是这需要承担某些变更两次执行生效的风险,这可能会导致重复数据或者错误的数据。原子性简化了这个问题,使得引用程序可以放心和安全地重试。以此看来,原子性或许叫可中止性(abortability) 会更好。

一致性(Consistency)

一致性是一个被广泛使用的词,在不同的上下文中,有着不同的含义:

  1. 多副本:包括多副本一致性和异步复制带来的最终一致性问题
  2. 一致性哈希:一种分区和调度的方式,在增删机器节点后,可以以较小代价进行副本迁移和负载均衡。
  3. CAP 定理:其中的一致性指的是线性一致性(强一致性),是多副本间一致性的一种特例,基本想法是让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子性的。
  4. ACID:数据库在应用程序的视角处于某种”一致性的状态“。

在使用一致性的术语时,需要明确其所属上下文,才能进而明确其含义。具体到 ACID 中,一致性是指对数据的一组特定约束必须始终成立,即对某些不变性(invariants) 的维持。所谓的不变性即某些约束条件,比如说在银行账户中,在任何时刻账户的余额必须等于收入减去支出,又如会计系统中,所有账户整体上必须借贷相抵。

不同于 ACID 的其他特性,一致性是需要应用侧与数据库侧共同维护的:

  1. 应用侧要保证写入满足应用侧视角约束要求的数据。尽管一些特定的不变式可以由数据库来检查,如唯一约束或外键约束等,但一般来说,应用侧可以定义什么样的数据是有效的,什么样的数据是无效的,而数据库只管存储。
  2. 数据库侧要保证多次写入前后,尤其是遇到问题时,维持该约束。

我们可以这么说,应用侧可能依赖于数据库提供的原子性和隔离性来实现一致性。可见,一致性并不仅取决于数据库,它表现更多的是应用侧的一种属性。

乔・海勒斯坦(Joe Hellerstein)指出,在 Härder 与 Reuter 的论文中,“ACID 中的 C”是被“扔进去凑缩写单词的”,而且那时候大家都不怎么在乎一致性。

隔离性(Isolation)

多个客户端并发访问相同的数据库记录时,会产生并发问题,或者称为竞态条件(race condition)

下图是一个简单例子。假设有两个客户端同时在数据库中递增同一个计数器,每个客户端需要先读取计数器的值,加 1,再写回新值。如图所示,计数器的值本应从 42 增长至 44,由于竞态条件,实际上只增长至 43。

两个客户端并发递增计数器

ACID 的隔离性用于解决这种问题。隔离性的定义是指每个事务的执行都是互相隔离的,每个事务都认为自己是系统中唯一正在运行的事务。在传统教科书上,这种事务隔离形式被称为可串行化(Serializability),即如果事务都串行执行,则任意时刻必然只有一个事务在执行,从而在根本上消除了可能存在的并发问题。用另一句话说,数据库的隔离性确保了多个事务并行执行的结果应当与这些事务串行执行(一个接一个)的结果是一样的

在实际生产中很少使用可串行化这么强的隔离性,因为它会带来性能损失。实际上隔离性强弱类似于一个光谱,数据库系统提供商一般会实现其中几个,用户可以根据业务情况在隔离性和性能间进行选择。后文我们会详细讨论除可串行化外的几种弱隔离级别

持久性(Durability)

数据库系统的目的是,提供一个安全的地方存储数据,而不用担心丢失。持久性保证了一旦事务提交,即使发生硬件故障或数据库崩溃,已经写入的任何数据都不会丢失

在单节点(单机)数据库中,持久性意味着以数据页(Page)或日志形式(WAL)写入了非易失性存储。在多副本(replication)数据库中,持久性意味着数据已经复制到了多数节点中。

然而,完美的持久性是不存在的,它只能做到某种程度的保证:如果所有硬盘和所有副本备份同时被销毁,那显然没有任何数据库能救得了你,数据必然丢失。

在现实世界中,存储涉及到的所有环节都不是完美的:

  • 写入磁盘后宕机,虽然数据没丢失,但是在机器修复或磁盘转移前,数据服务是不可用的。多副本冗余(Replication)系统可以解决这个问题。
  • 一个关联性的故障,如软件 bug 或者机房断电,可以同时摧毁一个机房中的所有副本,任何仅存储在内存中的数据都会丢失。因此内存数据库仍然需要定期持久化到外存。
  • 异步复制系统中,当主副本不可用时,由于数据没来得及同步到多数节点,最近成功写入主副本的数据可能会丢失。
  • 当突然断电时,固态硬盘不能保证数据已经完全刷盘,甚至用户显式调用 fsync 都无济于事。此外,磁盘驱动也可能有 bug。
  • 磁盘上的数据可能随着时间逐渐损坏,甚至副本数据也可能同时损坏,此时只能依赖于历史备份来恢复数据。

在实践中,没有一种技术可以提供绝对保证。因此数据的持久性要通过多种手段来保证,如强制刷盘、校验码、异地多机房复制、定期备份等,但这也只能做到部分的保证,而非绝对保证。与往常一样,最好抱着怀疑的态度接受任何理论上的“保证”。

单对象和多对象操作

在 ACID 中,原子性和隔离性描述了客户端在同一事务中执行多次写入时数据库提供的保证,并且它们通常假设一个事务中会同时修改多个对象(行,文档,记录)。比起单对象事务,这种多对象事务是一种更强的保证,且更为实用,因为通常多个写入不会只针对单个对象。

假设有一个电子邮件应用,通过以下语句来查询显示用户未读邮件的数量:

1
SELECT COUNT(*FROM emails WHERE recipient_id = 2 AND unread_flag = true

如果邮件太多,查询可能会比较慢,你可能会使用单独一个字段来存储未读邮件的数量(反范式化denormalization),每次新增和读过邮件都需要更新该字段值。

在下图中,用户 2 遇到了异常情况:邮件列表中显示有未读消息,但是未读数字段值却显示为 0,因为此时未读数字段值还未递增。你可能觉得未读邮件数错误不是什么很重要的事,换种角度,如果这个是客户账户余额,将邮件收发看成支付交易,这种错误将造成不可估量的影响。

违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)

所幸隔离性可以解决这种问题,使得用户 2 要么看到用户 1 的所有更新,要么看不到任何更新。

在下图中,原子性提供了保证。如果事务执行过程中发生了错误,原子性会保证如果未读数字段值更新失败,新增的邮件也会被回滚。

原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致

多对象事务需要通过某种方式来确定哪些操作是属于同一事务的:

  1. 从物理上来看,可以通过 TCP 连接确定,在同一个连接中,BEGIN TRANSACTIONCOMMIT之间的所有内容,可以认为是属于同一事务的。其中也会有些差错,如客户端在提交请求后,服务器确认提交之前发生网络中断,此时客户端无从得知事务是否已被成功提交。
  2. 从逻辑上来看,可以使用事务管理器,为每个事务分配一个唯一标识符,从而对操作进行分组。MySQL 中 MVCC 的实现就是如此,为每个事务分配了一个独一无二的 trx_id 以标识操作属于哪个事务。

另一方面,许多非关系型数据库并没有将这些操作组合成一个逻辑单元的方法,即使可能存在 BATCH API,但它们不一定具有事务语义,可能有些对象操作成功,有些对象操作失败。

单对象写入

当对单个对象进行变更时,原子性和隔离性依然能提供保障。例如,假设你需要向数据库写入一个 20KB 的 JSON 文档:

  • 如果在发送第一个 10KB 后网络连接中断,数据库是否存储了前 10KB 无法解析的 JSON 片段?
  • 如果该操作是在覆盖一个老版本同 id 数据,覆盖一半时发生了电源故障,数据库是否会存在一半新值一半旧值的情况?
  • 如果有另一个客户端同时在读取该文档,是否会看到半更新状态?

这些问题让人头大,如果数据库不提供任何保证,应用侧需要写很多错误处理逻辑。因此存储引擎一个几乎普遍的目标是:对单节点的单个对象(如键值对)上提供原子性和隔离性保证。原子性使得数据库可以通过日志(WAL)来实现崩溃恢复,且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问该对象)。

另外一些数据库也会提供更复杂的原子操作支持,如原子自增操作,避免了上文讨论隔离性时多个客户端并发自增计数器导致的交错更新。另一种更加泛化的原子性保证是提供单个对象上的 CAS 操作,运行用户原子执行针对单个对象的 read-modify-write 操作。细想一下,原子自增(atomic increment)在 ACID 中实际上是指隔离性(Isolation)的范畴,此处的原子自增则是多线程中的概念。

在许多 NoSQL 服务中,CAS 以及其他单一对象操作被称为“轻量级事务”,甚至处于营销目的重新定义为“ACID”,但是这个术语是具有误导性的。事务通常被理解为将多个对象的多个操作合并为一个执行单元的机制

界定对多对象事务的需求

由于多对象事务很难跨分区实现,且会可能会非常损失性能,许多分布式数据存储都放弃了多对象事务。但是有的场景确实需要多对象事务,因此一些数据库将其是否打开事务作为一个选项供用户选择。

因此,在用户侧,数据库选型时,需要审视一下是否真的需要多对象事务,是否只用键值数据模型和单对象操作就能满足需求。一些情况下是可以的,但更多的场景还是需要协同更新多个对象:

  • 在关系型数据库中,一些表通常存有外键,在更新时需要进行同步更新。
  • 在文档型数据库中,一些相关数据通常会存放在同一个文档中,单个文档被视作单个对象,更新单个文档时确实不需要多对象事务。但是由于大部分的文档数据库不支持连接操作,因此不得不使用前文提到的数据库非规范化 denormalization 对数据进行冗余存储,此时就产生了同步更新的需求。
  • 在支持次级索引的数据库中,数据和对应的多个索引需要进行同步更新。

如果数据库没有实现多对象事务,那么这些保证只能在应用侧实现,徒增了复杂度,且很容易出错。

故障与中止

事务一个关键特征是如果发生错误,它可以中止并安全地重试。ACID 数据库就基于这样的哲学:当出现违反原子性、一致性或持久性的危险,宁愿完全丢弃已经执行的变更,而不是留下部分执行成功的半成品。

然而不是所有的数据库系统都遵循这个哲学。例如多副本中的无主模型,就采用了“尽力而为”的模型,即尽可能保证完成任务,如不能完成,也不会回滚已经发生的修改。因此,从错误中恢复是应用程序的责任。

尽管无脑重试被中止的事务简单而有效,但是这并不是万能的:

  1. 事务被成功提交,但是返回给用户时出错。用户如果简单重试,会使得该事务中的操作被执行两次,造成错误数据。除非用户在应用侧进行去重(如保证多次执行这些语句的结果都是一致的)。
  2. 由于系统负载过高而导致事务执行失败。如果简单重试,会进一步加重系统的负担。此时可以使用指数后退方式重试,并且限制最大重试次数。TCP 协议中的保活机制就是一个例子。
  3. 一些临时错误,如死锁、异常、网络抖动和故障切换时,重试才有效;而对于一些永久性故障,如磁盘损坏,此时重试是没有意义的。
  4. 某事务在数据库之外如果有副作用,重试事务时会导致副作用多次发生。如果某个副作用是发送邮件,则肯定不希望事务每次重试时都发送一次电子邮件。如果想进行多个系统间的协同,可以考虑两阶段提交(2PC,two-phase commit)
  5. 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。

弱隔离级别

如果两个事务需要变更的数据之间没有交集,则可以安全地 并行(parallel) 执行,否则会出现竞态条件。并发 BUG 很难通过测试找到,因为这样的错误只在特殊时序下才会触发,而这样的时序问题可能非常少发生,通常很难复现。在大型应用中,单客户端情况的开发已经很麻烦了,有多个客户端并发访问更加剧了开发难度。

数据库通过 事务隔离(transaction isolation) 来给用户提供一种隔离保证,隐藏应用程序开发者的并发问题,从而简化应用程序的开发。从理论上讲,隔离可以通过假装没有并发来发生:即 可串行化(Serializability) 的隔离等级,意味着数据库保证事务的效果如同串行执行,任何时刻都只有一个事务在执行。

从实现的角度对几种隔离级别进行理解,会简单一些。如 ANSI SQL 定义的四种隔离级别: 读未提交(Read Uncommited)读已提交(Read Commited)可重复读(Repeatable Read)可串行化(Serializability) ,都可以从使用锁实现事务的角度来理解。

最强隔离性的隔离级别——可串行化,可以理解为一把全局的排它锁,每个事务启动时使用,在提交、回滚或终止时释放,这种隔离级别无疑性能最差。这侧面反映了其它几种弱隔离级别的意义:提高性能,缩小加锁的粒度、减小加锁的时间,从而牺牲一部分事务保证来换取性能。从上锁的强度考虑,有互斥锁(Mutex Lock,也称为写锁)和共享锁(Shared Lock,又称为读锁);从上锁的长短来考虑,有长时锁(Long Period Lock,在事务开始时获取锁,尽管中途需要保证事务的动作已经执行完成,也要到事务结束时才释放锁)和短时锁(Short Period Lock,执行动作前申请锁,执行结束后立即释放锁);从上锁的粗细来考虑,有对象锁(Row Lock,在关系型数据库中描述为锁住一行数据)和谓词锁(Predicate Lock,锁住一个范围内的数据)。

以锁来考虑隔离级别并没有覆盖到一个常见的隔离级别——快照隔离(SI,Snapshot Isolation),因为它引出了另一个实现技术——多版本并发控制(MVCC,multi-version concurrency control)。由于属于不同的实现,快照隔离和可重复读在隔离级别的光谱上属于一个偏序关系,不能说谁强于谁。

接下来将讨论几种弱隔离级别,以及隔离级别不够导致的几种现象——丢失更新(Lost Update)、写偏序(Write Skew)和幻读(Phantom Read)。

读已提交

性能最好的隔离级别是完全不上任何锁,但是其中会存在脏读和脏写的问题,为了避免脏写,需要给要更改的对象加长时写锁,读数据时并不加锁,此时隔离级别为读未提交(RU,Read Uncommitted)。但是此时仍然会有脏读,为了避免脏读,可以对要读取的对象加短时读锁,此时的隔离级别就是读已提交(RC,Read Committed)。

读已提交是最基本的事务隔离级别,它提供了两个保证:

  1. 从数据库读时,只能读到已提交的数据(没有脏读,即 dirty reads)。
  2. 在写入数据库时,只会覆盖已提交的数据(没有脏写,即 dirty writes)。

没有脏读

如果一个事务 A 能够读到另一个未提交事务 B 的中间状态,则称为脏读(dirty reads)。在读已提交的隔离级别上运行的事务是不会有脏读的。举个例子,如下图所示,在用户 1 提交之前,用户 2 读到的值一直是 2。

 没有脏读:用户 2 只有在用户 1 的事务已经提交后才能看到 x 的新值。

试想一下允许脏读的情况:

  1. 一个事务可以看到另一个未提交事务的中间状态。如上文的邮件未读数的例子,读取到部分更新状态的数据库会让用户感到迷惑,并可能导致其他事务做出错误的决定。
  2. 如果事务中止,回滚所有操作,允许脏读会让另一个事务读取到被回滚的数据。

没有脏写

两个事务同时尝试更新数据库中的相同对象,写入的顺序我们是无法得知的,但是我们知道后面的写入通常会覆盖前面的写入。如果先写入的是尚未提交事务的一部分,那么后一个写入会覆盖这个尚未提交的值,这被称为脏写(dirty write)。

读已提交的隔离级别上运行的事务不存在脏写问题,通常是延迟第二次写入,直到第一次写入事务提交或中止为止。

通过禁止脏写,可以避免一些并发产生的不一致问题:

  1. 如果多个事务同时更新相交的多个对象,脏写可能会产生错误的结果。如下图的二手车销售,购买汽车需要两个步骤:更新购买列表、将发票发给买家。如果 Alice 和 Bob 的购买事务允许脏写,则可能出现 Bob 购买到了商品(他成功更新了商品列表),而发票却发给了 Alice 的情况(她成功更新了发票表)。
  2. 但是读已提交并不能防止讨论隔离性时提到的更新计数器的竞态条件问题,因为这属于一种更新丢失现象。两个事务都是读的已提交的数据(因此不是脏读),且写入时,另一个事务写入发生在前一个事务之后(因此不是脏写),但仍然不能避免写入丢失的问题(只增加了一次)。

如果存在脏写,来自不同事务的冲突写入可能会混淆在一起

实现

读已提交是一个常见的隔离级别,是 Oracle 11g、PostgreSQL、SQL Server 2012、MemSQL 和其他许多数据库的默认设置。

那么如何实现读已提交的隔离级别呢?

首先是脏写,最简单且最常见的解决办法是使用行锁(起源于关系型数据库),即针对单条数据的长时写锁(Long Period Write Lock)。当事务想要修改某个对象时,先获取该对象的锁,如果已经被获取,则等待;如果成功获取,则可以写入数据,等待事务提交时才释放锁。

其次是脏读,可以使用针对单条数据的短时读锁来解决脏读问题。读锁可以并发,但是与上述的写锁是互斥的,这可以保证有脏数据(未提交的更改)时,其他事务针对该对象的读取都会被阻塞。但使用行锁的性能也不是很好,因为一个长写事务,可能会把其他读取该对象的读事务给“饿死”,损失性能且造成长时间延迟。

处于这个原因,大多数数据库会使用非锁的形式实现读已提交:对于写入的某个对象,数据库会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。当写事务正在进行时,任何其他读取对象都会读到旧值,只有当新值提交时,其余读事务才会读取到新值。将其泛化一下,就是我们常说的 MVCC。

快照隔离和可重复读

读已提交似乎满足了事务所需的一切。它允许中止,满足了原子性的要求;它防止读取不完整的事务结果,并且防止并发写入造成的混乱,满足了隔离性的要求。

但是使用此隔离级别的时候,仍有很多地方可能会产生并发错误。如下图所示,考虑这么一种场景,Alice 分两个账户,各存了 500 块,但如果其两次分别查看两个账户期间,发生了一笔转账交易,则两次查看的余额加起来并不等于 100。对 Alice 来说,现在她的账户似乎总共只有 900 块——看起来有 100 块已经凭空消失了。

读取偏差:Alice 观察数据库处于不一致的状态

这种异常被称为不可重复读(non-repeatable read)或者叫读倾斜(read skew,skew 有点被过度使用)。读已提交隔离级别是允许不可重复读的,如上述例子,每次读取到的都是已提交的内容。

上述例子中的不一致状态只是暂时的,但在某些情况下,这种暂时的不一致也是不可接受的:

  1. 备份:备份可能需要花费很长时间,由于备份过程中会有读写存在,从而导致备份时数据的不一致。如果之后再使用此备份进行恢复,则会造成永久的不一致。
  2. 分析型查询和完整性检查:这个操作与备份一样,耗时都比较长。如果执行过程中有其他事务并发导致出现不一致的现象,就会导致返回的结果有问题。

快照隔离(snapshot isolation)的隔离级别可以解决上述的问题,使用快照隔离级别时,每个事务都可以取得一个某个时间点的一致性快照(consistent snapshot),在整个事务期间,读取到的状态都是该时间点的快照,其他事务的修改并不会影响到该快照的数据。

快照隔离是一个流行的功能,PostgreSQL、使用 InnoDB 引擎的 MySQL、Oracle、SQL Server 等数据库系统都支持快照隔离。

快照隔离的实现

与读已提交一样,快照隔离也使用加锁的方式来防止脏写,但是进行数据读取的时候不使用锁。快照隔离的一个关键原则就是“读不阻塞写,写也不阻塞读”,从而允许用户在长时间查询时不影响新的写入。

为了实现快照隔离,保证读不阻塞写,且避免脏读,数据库需要对同一对象保留多个已提交的版本,我们称之为多版本并发控制(MVCC,multi-version concurrency control)。

如果一个数据只需要实现到读已提交级别,那么保留两个版本就够了(旧版本和新版本)。但是如果要实现快照隔离级别,一般使用 MVCC。相对于锁而言,MVCC 是一种事务实现的流派,而且在近些年来很受欢迎。当然,MVCC 也是一种思想,具体到实现,有 MVTO(Timestamp Ordering)、MVOCC(Optimistic Currenccy Control)、MV2PL(2 Phrase Lock)等,即基于多版本,加上一种避免写写冲突的方式。

具体来说,使用 MVCC 流派,也可以实现读未提交、读已提交、快照隔离、可串行化等隔离级别。

  1. 读已提交在查询语句粒度使用单独的快照,且快照粒度更小,因此性能更好。
  2. 快照隔离在事务粒度使用相同的快照,主要是为了解决不可重复读问题。

MVCC 的基本要点如下:

  1. 每个事务开始时会获取一个自增的、唯一的事务 ID(txid),该 txid=max(existing txid) + 1。
  2. 该事务在修改数据时,不会修改以前的版本,而是会新增一个具有 txid 版本的数据。
  3. 该事务只能访问所有版本<=txid 的数据。
  4. 在写入时,如果发现某个数据存在>txid 的版本,则存在写写冲突。

下图是 PostgreSQL 中基于 MVCC 实现快照隔离的示意图,场景是两个账户,每个账户各有 500 块。例子通过使用两个版本信息字段:created_by 和 deleted_by 来标记一个数据版本的生命周期。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过在 deleted_by 字段打上请求删除事务的 txid 来标记删除,在稍后时间当确定没有事务访问该已删除数据时,数据库中的垃圾回收机制就会将所有带有删除标记的行移除,并释放其空间。

在 PostgreSQL 中,created_by 的实际名称为 xmin,deleted_by 的实际名称为 xmax

使用多版本对象实现快照隔离

在上述过程中,UPDATE操作被翻译为DELETEINSERT,余额为 500 块的行会被标记为被事务 13 删除,而余额为 400 块的行由事务 13 创建。

可见性规则

在多版本并发控制中,对每个对象来说,最重要的是控制其版本对事务的可见性,保证事务能够看到一致性的视图。

在多版本并发控制中,每个对象都有多个版本,上文提到一个事务只能访问到所有版本<=txid 的数据其实是较为粗略的说法,展开来讲:

  1. 事务开始时,所有正在进行的事务(包括已经开始但是未提交或终止的事务),所做的任何写入都会被忽略。
  2. 被中止的事务,所做的任何写入都会被忽略。
  3. 具有较晚事务 ID 的事务所做的任何写入都会被忽略。
  4. 剩余其他的数据都对此事务可见。

如果事务 txid 是自增的,可以理解为:

  1. 对于所有 txid < x 的事务,如果已经中止或正在进行,其所写数据不可见。
  2. 对于所有 txid > x 的事务,所写数据皆不可见。

换句话说,如果以下两个条件都成立,则可见一个对象:

  • 读事务开始时,创建该对象的事务已经提交。
  • 对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。

长时间运行的事务可能会长时间使用快照,并继续读取(从其他事务角度看)早已被覆盖或删除的值。由于从来不原地更新值,而是每次值改变时创建一个新的版本,数据库可以在提供一致性快照的同时只产生很小的额外开销(只需要动态维护部分对象,即改变的值的快照版本)。

索引与快照隔离

索引如何在多版本数据库中工作?一种选择是使索引简单指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集机制删除任何不再可见的旧版本对象时,相应的索引条目也可以被删除。

在实践中,有许多实现细节共同决定了多版本并发控制的性能。例如,如果同一对象的不同版本可以放入同一个数据页中,PostgreSQL 的优化使得对应的索引指向可以不用更新。 Bruce Momjian: “MVCC Unmasked,” momjian.us, July 2014.

CouchDB、Datomic 和 LMDB 使用的是另一种方式,仅 追加 / 写时拷贝(append-only/copy-on-write) 的 B 树变体,是一种多版本技术的变体。boltdb就是参考的 LMDB,也可以归为此类。此类 B 树每次修改,都会引起叶子节点(所有数据都会落到叶子节点)到根节点的一条路径级联更新(叶子节点变了,其父节点内容——指针,也要跟着修改,因此引起级联更新),如果引起节点的分裂或合并,会引发更大范围的更新和修改。

这种修改不会覆盖旧的页面,每个修改页面都会创建一份副本,更新的节点会指向其子页面的新版本。使用仅追加的 B 树,每个写入事务都会创建一颗新的 B 树,当创建时,从该特定树根节点生长的树就是数据库的一个一致性视图。没必要根据事务 ID 来过滤掉事务,因为后续写入的事务都不能修改现有的 B 树,它们只能创建新的树根(副本)来修改。很显然,这种方式也需要一个负责压缩和垃圾收集的后台进程。

命名困惑

在 1975 年 System R 定义 ANSI SQL 标准的隔离级别时,只定义了 RU、RC、RR 和 Serializability。当时,快照隔离还没有被发明,但是上述四种级别汇总有一个和快照隔离类似的级别:可重复读(RR,Repeatable Read)。

许多数据库实现了快照隔离,却使用不同的名字来称呼。在 Oracle 中称为 可串行化(Serializable) 的,在 PostgreSQL 和 MySQL 中称为 可重复读(repeatable read)

这种命名混淆的原因在于 SQL 标准没有快照隔离的概念,因为那时快照隔离的概念还没正式下定义。相反,它定义了可重复读,看起来和快照隔离很像,于是 PostgreSQL 和 MySQL 称其快照隔离级别为可重复读,因为这样可以符合 SQL 标准要求,以号称兼容 SQL 标准。

严格来说,SQL 标准对隔离级别的定义是有缺陷的,模糊且不精确的。虽然一些文献中有对可重复读进行了精确定义,但大部分实现并不严格满足此定义。到最后,没有人知道可重复读的真正含义。

防止丢失更新

读已提交和快照隔离级别主要保证了只读事务在并发写入时可以读到什么,却忽略了两个事务并发写入的问题,一种特定类型的写 - 写冲突可能出现的。

并发写入事务之间的冲突中,最著名的就是丢失更新问题,像前文提到的俩客户端并发递增计数器的例子。

更新丢失问题发生的关键在于,两个事务中都有读后写序列(读取 - 修改 - 写入序列,写偏序也是这个序列,但是是针对多个对象),即写依赖于之前的读。如果读到的内容被其他事务修改,则本事务稍后的依赖于此读的写就会发生问题:

  1. 并发更新计数器和余额。
  2. 将本地修改写入一个复杂值中:例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改再写回修改的文档)。
  3. 两个用户同时修改 wiki 页面,并且都是修改后将页面完整覆写回。

从以上可以看出这是一个普遍的问题,所以已经有了各种解决方案。

原子写

简单来看就是将 read-modify-write 的操作打包成一个原子操作。如下指令,它在大多数关系型数据库中执行是并发安全的:

1
UPDATE counters SET value = value + 1 WHERE key = 'foo';

像 MongoDB 这样的文档数据库也提供了对 JSON 文档的一部分进行本地修改的原子操作,Redis 中也提供了修改数据结构(如优先队列)的原子操作。但是不是所有的操作都能被解释为原子操作,如 wiki 页面的更新,设计到任意文本编辑,其实将其表示为原子的变化流也是可以实现的,但是比较复杂。

原子操作通常在读取对象时获取其上的排它锁实现,以便更新完成时没有其他事务可以读取它。这种技术被称为游标稳定性(cursor stability),另一种选择是简单地强制所有针对同一个对象的操作在单一线程上执行,将任何单个对象的执行序列化。

显式上锁

即应用在有针对单个对象的 read-modify-write 序列时,将是否上锁的决策交给应用层,显式地锁定将要更新的对象。通常的 SQL 语法如下:

1
select xx where xx for update;

考虑一个场景,一个多人游戏中,几个玩家可以同时移动相同的棋子。由于规则限制,一个原子操作可能不够。此时可以使用数据库提供的语法进行显式上锁,来防止两个玩家移动有交集的棋子集合。

1
2
3
4
5
6
7
8
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;

-- 检查玩家的操作是否有效,然后更新先前 SELECT 返回棋子的位置。
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;

具体的使用还是需要根据应用需求来定。

自动检测丢失更新

除了悲观地强制执行原子操作外,还可以使用乐观的方式,允许其并发执行,检测到更新丢失后再重试。

在快照隔离级别的基础上,可以高效地对更新丢失进行检测。事实上,PostgreSQL 的可重复读、Oracle 的可串行化和 SQL Server 的快照隔离级别都能自动机检测丢失更新的冲突,并中止事务的执行。但是 MySQL/InnoDB 的可重复读不会检测丢失更新,一些开发者认为,数据库必须能防止丢失更新,才能称得上是提供了快照隔离,因此,在这个定义下,MySQL 不提供快照隔离的隔离级别。

丢失更新检测不需要应用程序代码使用任何特殊的数据库功能,不太容易出错。

比较并设置(CAS)

比较并设置(CAS,Compare And Set)是不加锁实现原子操作的一种常见方式,其使用的是内存共享的方式。目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新生效。如果当前值与先前读取的值不一致,则更新不生效,必须重新读取,再次尝试。

下面是一个例子,防止两个用户同时更新一个 wiki 页面,可以尝试这种方式:

1
2
3
-- 根据数据库的实现情况,这可能安全也可能不安全
UPDATE wiki_pages SET content = '新内容'
WHERE id = 1234 AND content = '旧内容';

如果更新后的值与旧值一致,此次更新涉及到一些版本问题的话也是不行的,可以加一个更新时间戳字段或唯一的版本号作为标识。

多副本冲突与解决

在多副本数据库中,解决丢失更新的问题要更难一些。

在多住和无主模型中,允许数据进行并发写入和异步复制,无法保证只有一个最新数据的副本。所以 CAS 与基于锁的技术不适用于这种情况。这种多副本数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(称为兄弟值),并使用应用程序或特殊的数据结构在事件发生后解决和合并这些版本。

特殊情况下,当多个操作满足“交换律”(即,可以在不同的副本上以不同的顺序应用它们,且仍然可以得到相同的结果)时,原子操作看一看在多副本数据中正常工作,如计数器场景就满足交换律,在 Riak2.0 之后就支持并发更新,且会自动合并结果,不会有丢失更新问题。

另一方面,后者胜(LWW,last write win)的冲突解决策略是会造成丢失更新问题的,虽然很多多副本数据库都默认使用这种策略来进行冲突解决。

写偏序和幻读

前文中我们提到了脏写和丢失更新,当不同事务并发写入相同对象时,会出现这两种竞态问题。它们可以在数据库层面自动解决,也可以在应用侧通过显式调用原子操作或加锁来解决。

除了上述并发写入问题外,还有一些比较微妙的冲突例子,涉及到多个对象的访问。

考虑如下的一个场景,一个医生在值班,议员通常要求几个医生同时值班,即使有特殊情况,也要保证有不少于一个医生值班。假设在某天,轮到 Alice 和 Bob 两人值班,不巧的是,他们都感觉自己身体不适,且恰好同时申请请假。

写入偏差导致应用程序错误的示例

假定数据库允许在快照隔离级别下,Alice 和 Bob 同时开启事务,同时查询了今天的值班情况,两次查询都返回 2,所以两个事务都进入下一个阶段。Alice 更新自己的记录休班,Bob 也做了同样的事情,两个事务都成功提交了,现在没有医生值班了。这违反了至少有一名医生值班的规定。

写偏序的特点

上述产生的异常被称为写偏序,它既不是脏写,也不是丢失更新,因为这两个事务在更新两个不同的对象。在这里发生的冲突虽然不明显,但显然也是一个竞态条件:如果两个事务串行执行,那么后一个申请的医生就不能歇班了。这种异常行为只有在事务并发进行时才有可能发生。

我们可以把写偏序视为丢失更新问题的一种泛化体现。写偏序的本质也是 read-modify-write,虽然涉及多个对象,但本质仍然是一个事务的写入导致另一个事务读取到的信息失效。写偏序是由 MVCC 实现的快照隔离级别特有的缺陷,它出现的原因是多个事务的读依赖于同一个不变的快照。

解决丢失更新的许多手段都无法用在解决写偏序上:

  • 由于涉及到多个对象,单对象的原子操作不起作用。
  • 在快照隔离级别实现中,想要自动防止写偏序必须实现真正的可串行化隔离。
  • 虽然有些数据允许执行约束,但是往往是单对象的简单约束,如唯一约束、外键约束等,当然,可以使用触发器或物化视图来实现。
  • 如果没办法使用可串行化的隔离级别,还可以使用数据库提供的显式加锁(for update)机制来显式加锁。
1
2
3
4
5
6
7
8
9
10
11
BEGIN TRANSACTION;
SELECT * FROM doctors
WHERE on_call = TRUE
AND shift_id = 1234 FOR UPDATE;

UPDATE doctors
SET on_call = FALSE
WHERE name = 'Alice'
AND shift_id = 1234;

COMMIT;

其他写偏序例子

写偏序的特定在于:

  1. 涉及多个对象。
  2. 一个事务的写入会导致另外事务的读取失效,进而影响其写入决策。

以下是一些例子

  • 会议室预定系统

基本的流程是先检查预定是否有冲突,如果没有,则创建会议。

1
2
3
4
5
6
7
8
9
10
11
12
BEGIN TRANSACTION;

-- 检查所有现存的与 12:00~13:00 重叠的预定
SELECT COUNT(*) FROM bookings
WHERE room_id = 123 AND
end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';

-- 如果之前的查询返回 0
INSERT INTO bookings(room_id, start_time, end_time, user_id)
VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);

COMMIT;

在快照隔离级别下,使用上述语句无法避免多个用户并发预定时,预定到同一个会议室的时段。只能使用可串行化来避免冲突。

  • 多人棋牌游戏

前面提到的多人棋类游戏,对棋子对象加锁虽然可以防止两个玩家同时移动同一个棋子,但是不能避免两个玩家将不同的棋子移动到同一个位置。

  • 抢注用户名

在每个用户具有唯一用户名的网站上,两个用户并发尝试创建相同用户名的账户。如果使用检查名字是否可用->没有则允许注册的流程,在快照隔离级别下,是无法避免两个用户注册到相同用户名的。不过这种情况可以通过给用户名列加唯一性约束来保证该特性。

  • 防止双重开支

允许用户花钱和使用点券的服务,通常会在用户消费时检查其是否透支。可以通过在账户余额中插入一个临时的试探性项目来实现这一点,列出账户中所有项目,并检查总和是否为正值。在写偏序的场景下,可能会发生两个支出项目同时插入,导致余额为负数,但是这两个事务都不会注意到另一个。

幻读导致写偏序

以上的例子都可以归纳为以下流程:

  1. 通过 select 语句 + 条件过滤出符合所有行。
  2. 根据上述结果,应用侧决定是否继续。
  3. 如果应用侧决定继续,就执行更改,并提交事务。

其中步骤 3 可能会导致另一个事务的步骤 1 失效,即如果另一个事务此时重新执行 1 的 select 查询,会得到不一致的结果。进而影响步骤 2 的决策。

上述例子中,医生值班的例子可以通过FOR UPDATE锁定步骤 1 的行来避免写偏序。但是其他的例子不同:它们检查是否不存在某些满足条件的行,而写入会添加一个匹配相同条件的行。如果步骤 1 没有返回任何行,那么SELECT FOR UPDATE是锁不了任何东西的。

这种一个事务的写入会改变另一个事务的查询结果的现象,称为幻读(Phantom Read)。快照隔离能够避免只读事务中的幻读,但是对于读写事务,就可能会出现由幻读引起的写偏序问题。

物化冲突

如果幻读的问题是在步骤一查询不出任何对象以加锁,那么我们自然会想,能否手动添加一些对象来使得加锁称为可能?

在会议室预定的场景中,可以想象一个关于时间槽和房间的表。这个表中的每一行对应特定时间段的特定房间,比如每 15 分钟一个时间段。可以提前插入房间和时间的所有可能组合行。如果现在一个事务想要预定某个会议室某个时间段,就可以在表中将对应的对象锁住,然后执行预定的操作。

值得强调的是,该表只为了防止同时预定同一个会议室的同一时间段,并不用来存储预定信息,可以理解为一个锁表,每一行都是一把锁。

这种方法被称为物化冲突(materializing conflicts),因为它将幻读转化为了数据库中一组具体行上的锁冲突。不过弄清楚如何物化冲突很难,也很容易出错,而且这种方法将解决并发冲突的细节暴露给了应用层(应用层需要感知物化出来的表),是一种万不得已才会采用的方法。如果数据库本来就支持可串行化,那么大多数情况下,直接使用可串行化隔离级别是更可取的。

总结

这篇文章可以说是《DDIA》第七章的阅读笔记,内容基本都参考于此。

本文从事务棘手的概念入手,剖析了 ACID 的各个含义。其中原子性保证了发生错误时会回滚事务期间生效的所有变更;一致性需要根据上下文来明确语义,在 ACID 中是指对某些不变性的维持,需要由应用侧和数据库共同维护,更多指的是应用侧的属性;隔离性保证了每个执行的结果是相互隔离的,每个事务都可以认为自己是系统中唯一正在运行的事务。其实隔离性强弱类似一个光谱,因此引申出了弱隔离性。持久性保证了一旦事务提交,即使发生硬件故障或数据库崩溃,已经写入的任何数据都不会丢失。但是完美的持久性是不存在的,应当采取多种技术去兜底,抱着怀疑的态度接受任何理论上的“保证”。

然后我们从单对象和多对象的角度切入,阐明了两者间事务实现的差别,为后文弱隔离性的介绍奠定基础。

本文还从非正式的角度举例讨论了几种弱隔离级别,针对这几种隔离级别由于事务保证不充分导致的各种问题,以及这些问题的解决方法。读已提交隔离级别通过两个快照来避免脏读,通过添加行锁来防止脏写;快照隔离级别通过实现 MVCC 来避免了不可重复读的问题;可串行化隔离级别则解决了数据库事务并发的所有问题,包括快照隔离无法解决的丢失更新问题、幻读和写偏序问题。

参考与推荐阅读

]]>
<h2 id="简化容错,不惧失败">简化容错,不惧失败</h2> <p>在实际的生产环境中,分布式数据系统面临着命运的裁决,诸多不幸随时可能发生:</p> <ul> <li>系统侧:数据库软件和硬件系统在任何时间都有可能会发生故障</li> <li>应用侧:应用程序在任意时刻都
2022 年总结:勇敢迈进,开拓自我 https://makonike.github.io/2022/12/30/2022%E5%B9%B4%E6%80%BB%E7%BB%93%EF%BC%9A%E5%8B%87%E6%95%A2%E8%BF%88%E8%BF%9B%EF%BC%8C%E5%BC%80%E6%8B%93%E8%87%AA%E6%88%91/ 2022-12-30T15:53:23.000Z 2023-12-28T15:35:34.220Z 技术

随手写写,希望以后内容会更丰富。

输出

纵观我的个人网站,发现基本都是今年发文的。= =这也怪我太懒了,之前出于好奇整了这个 butterfly 主题的博客,静态托管到 github pages 后就直接忘了,没有再管过,尽管学习笔记也有一直记着。今年发文后,我很明显的感觉到我的短板在总结这方面,在开启第一场面试之后感觉就更为明显,这也加快了我养成学习时同时输出的习惯。

笔记这方面,今年正式放弃了使用已久的语雀,转而使用飞书文档来作为笔记工具。语雀呢我觉得这个产品确实挺好的,但是有些时候用起来感觉确实挺不爽的。一方面是大文件的加载方面,我这有一个内容比较多的文档,图片很多,导致全文也显得很长,打开的时候就需要加载好几秒,而且阅读到一半发现个 typo,希望修改一下,点击编辑按钮进入编辑界面,又得加载好几秒,编辑完成后保存,又得好几秒。现在看了一下貌似快了很多,但是编辑和只读状态的切换还是太久了。飞书在这方面就做的很好,在我刚开始使用飞书的时候,还是没有阅读状态的,但是现在开始灰度阅读状态和宽度显示了,弥补了我对笔记工具一部分的需求。另一方面呢是语雀好像开始限制免费用户了,将免费版降级为了体验版,普通用户可以新建的文档数降为了 100 篇每月,而且限制了互联网公开知识文档的功能,不过这些我也不关心,100 篇大概也足够我使用了。主要是团队的文档存储换为了飞书文档,为了统一一下,我也将语雀迁移至了飞书文档(主要是我很馋飞书文档的思维笔记)。

语雀热力图

说到飞书文档,不得不提一下之前用过的【幕布】了,最开始接触幕布还是因为看了 mc 大佬的八股笔记,后面和实验室里的师兄交流了一下才知道他也用的幕布,幕布的功能也太好用了,第一次见到这种形式文档的我震撼了好久好久。深入了解一下才发现幕布已经不再维护了,好像是给字节跳动收购了来着,思维笔记就是继承自这里的。但是飞书的思维笔记也有相对于幕布不足的地方,比如说部分文字高亮啊啥的,反正幕布还是看着舒服点,不过幕布是收费的,飞书文档个人免费试用,所以我果断选择了飞书文档作为笔记工具。

其实掘金也维护了一份谈笑风生间的个人主页,不过主要是为了投稿拿个奖品,现在正在用的杯子就是掘金送的,质量属实好(

掘金杯子

话题转回到总结吧,我一直都觉得输出是我的薄弱项,于是今年开始正式踏出了我的第一步,在个人网站开始输出我的文章(虽然没有人看就是了),这次的年度总结也是第一次写,难免有些生疏,还是得多写多输出才行哈- -。希望明年能写更多有趣的文章,一边弥补着自己的技术漏洞,一边慢慢地探索更多未知的领域,学习更多的新知识。

深入学习了一门新语言

今年五、六月份通过字节青训营为契机正式入门了 Go,学习了它之后给我的感觉挺复杂的。其实早在很久之前我就听说过 Go 的大名了,同届信工的朋友也凭 Go 在多个大厂走了一回,当时我也挺眼红的。听的最多的当然是 Kubernetes 了,但是由于对我而言没啥应用场景(要多个服务器组多机玩,学生党暂时没这个条件跑嘛,本机又带不动,本地跑也没什么意思),所以到现在也没怎么去看= =,这里立个 flag 明年一定会深入学习下,因为真的仰慕很久了。再然后是 MIT 6.824 课程,通过 Go 实现一个支持 raft 协议的分布式的 kv 存储,这个课程我也挺感兴趣的,在今年年末的时候也正式开始学习了,终于感受到读 paper 的感觉了 ( ̄▽ ̄)/,也顺便提升下自己的英语能力。

Go 是我深入学习的第二门语言,让我感受最深的是"less can be more"的哲学,Go 的设计使得程序员的工作量最小化,例如 channel 与 goroutine 实现的 CSP 模型,使得 Go 在语言层面支持了并发,使得编写一个 Go 并发程序成本变得很低,这就是"less can be more"哲学体现的其中之一。在 Go 中,简洁统一的代码风格也变得很重要,即使声明了变量不去使用,也是过不了编译的,godoc 更是可以以统一的风格格式化代码。

Go 跨平台、原生二进制文件比较小也是我看重它的理由之一。跑一些 pipeline 的时候就知道了,Go 程序跑在小体积的 alpine 容器上使得传输非常迅速,反观 Java,一个装有 Java 运行时环境的容器就很大很大了,而且 Java 生成的 jar 包也很大,编译和启动 jar 的速度又慢,导致一个 pipeline 跑下来时间非常不可观,即使是在某些地方加上 cache。

在深入学习 Go 的时候,我主要瞄准了 Go 的部分标准包,几个特性的底层源码学习,感谢【幼麟实验室】带领我入坑源码,当初看到感觉画风很 cute,却发现自己有点听不懂,于是就开始较劲地钻研源码了。幼麟实验室现在出书了,今天我也补票下单了一本,希望能更深入的学习 Go。除了幼麟实验室以外,我还遇到了曹大、码农桃花源、Go 夜读这样的优质博主,他们的博文也帮助我加深了对 Go 的理解。希望明年能输出一些关于 Go 的知识和见解!

关于 Go 的项目,七八月份的时候走马观花看了下drone,不过现在忘得差不多了,深入去看了boltdb和别人推荐的nyadb,跟着写了下极客兔兔的 gin 实现。其实还有很多想看的 Go 项目,比如大名鼎鼎的 kubernetes、dubbo-go、tidb 等,立个 flag 明年看一下。

Go 如今已经正式发布 1.19.4,估计离 Go2 也不远了,作为一门新兴语言,我对 Go 的前景还是很看好的,即使它在计算领域 GC 的开销很大,对 Go 底层逻辑优化的难度也很高。我学习 Go 的路途也不会就此终止,希望 2023 年 Go 的优化能越做越好。

技术之外

阅读

今年看的大头还是网文、技术书籍。技术书籍主要包括《数据密集型应用系统设计》、《Go 语言程序员笔试面试宝典》、《深入理解 Linux 网络》、《深入理解计算机系统》、《TCP/IP 协议详解:卷一》、《Linux 内核设计与实现》等,网络部分的书看的比较多,但是网络部分的知识比较杂,整理起来也比较复杂,所以还得持续巩固。技术书籍中DDIA(《数据密集型应用系统设计》) 是我今年最喜欢的一本,也是觉得讲的最好的一本,浅显的语言描绘了数据密集型应用的设计思路和注意事项,我看的是 Vonng 大佬参译的个人版本DDIA

就这样,希望明年能看多一点文学书籍,技术书籍也不要落下。

音乐

今年很少听电子了,听的比较多的还是粤语经典、华语流行这类。其实啥都听一点ヽ ( ̄▽ ̄) ノ
最喜欢的还是卫兰的《街灯晚餐》和《爱没有假如》,真的是百听不厌,我很喜欢卫兰的声线。

年度歌单

设备

今年买了新键盘,Hyeku X1 Pro,去除了小键盘位的 68 键,敲起 leetcode 来格外舒服。

黑峡谷键盘

年末买了个有麦的耳机,同学推荐的漫步者,可惜没送猫耳 hhhh

耳机

贴下简陋的办公环境 hhh

工位

宿舍

动画片与动画电影

今年看的新番很少,回顾以往的佳作比较多。

英雄联盟:双城之战

如果有人问我有没有什么推荐的动画片,我会首选推荐双城之战。在我心里双城之战是能打上 10 分满分的,它的动画制作十分精良,无论是画风还是改编的剧情、人物性格的塑造都让我耳目一新。让我感触最深的是其中展现的矛盾和冲突异常突出,尤其是在维克托接触海克斯核心、而另一边杰斯在和黑妹戏耍的对比画面,给了我一个小小的震撼,此外,皮城与祖安环境、阶级的对比,另一边皮城中杰斯和议员们的激进和黑默丁格的保守等等这些也都是对比冲突,这部剧里面的对比冲突使得剧情线变得更加紧凑且刺激。

我本身也是玩英雄联盟的,偶尔也关注各大职业比赛。双城之战这个剧我是二刷了,在第一次看的时候还觉得很新奇,一种肉眼可见的艺术感,让我一下子就爱上了这种用 3D 的制作手法仿造 2D 手绘的风格。主线剧情是围绕着蔚和金克斯俩姐妹展开的,虽然没咋磕到她俩的糖,但是另一边闯入的小蛋糕凯特琳却让我一把子狠狠磕到了,如果小蛋糕和蔚可以亲一亲就好了(

小蛋糕和蔚

你的名字

记不清是第几刷了,不过它还是我心中的神作。第一次看还是在初中和小伙伴们看的首映,没想到眨眼间六年过去了,物是人非,到现在只有寥寥几个朋友维持着联系。

你的名字的制作十分精良,配乐制作更是我喜欢的 RADWIMPS,虽然剧情线稍稍冗长,不过还是很戳我的。对于产灵还是啥神的解读我不太感兴趣,但对三叶和泷的剧情线发展还是挺吸引我的。依据冲突发展的主线,笑点和情感点点缀在其中,每条故事线仿佛都编织成一条结绳,使得原来几乎不可能有接触的俩人结下了深深的羁绊。

总结

今年顺利踏出了很多领域的第一步,希望自己在新的一年里也能勇敢迈进,不断开拓自我,学习和研究更多的东西。

封面来自这里

]]>
<h2 id="技术">技术</h2> <p>随手写写,希望以后内容会更丰富。</p> <h3 id="输出">输出</h3> <p>纵观我的个人网站,发现基本都是今年发文的。= =这也怪我太懒了,之前出于好奇整了这个 butterfly 主题的博客,静态托管到 github p
一文了解 OS-内存布局 https://makonike.github.io/2022/12/29/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3OS-%E5%86%85%E5%AD%98%E5%B8%83%E5%B1%80/ 2022-12-29T07:10:26.000Z 2023-07-04T16:46:48.014Z 揭开操作系统内存的面纱

众所周知,每个程序都有属于它自己的源程序,通过翻译、链接阶段可以得到它的 ELF 可执行目标文件。ELF 可执行目标文件则是将这个程序的代码和数据按照一定的格式组织在这个文件中,其中包含了段头部表、ELF 头、节头部表和若干的节(section)。在 ELF 文件中,会为这个文件的每一条指令和数据分配一段虚拟地址,在加载 ELF 文件时,按照虚拟地址的大小来组织就能得到一个虚拟地址空间的布局。

当存在多个程序时,由于它们的ELF 可执行目标文件里的虚拟地址都是一样的,因此它们自己的虚拟地址空间的范围都是从 0 到虚拟内存的最大值,这便是虚拟内存的作用之一,隔离程序内存。不同的是它们的指令和代码都不一样,因此对于一模一样的虚拟地址空间而言,其使用情况不一样。当程序运行时,只需要将 ELF 可执行目标文件加载到物理内存中,此时只需要加载使用到的指令和数据(按需加载),因此尽管物理内存不大,操作系统却可以在内存中同时维护着多个程序。对于物理内存地址和虚拟内存地址的映射维护,则交由页表来管理。

总而言之,虚拟内存其实是不可见的,虚拟内存中的虚拟地址只是一个存在于物理内存上的值,通过页表映射到实际使用的物理内存中,为程序分配虚拟地址时只是赋予其虚拟地址的值而已,并没有真正为程序分配(物理)内存。在页表初始化的时候,虚拟地址对应到的物理页号都是不知道的,此时页表中的有效位都为 0。当要访问一段虚拟内存地址时,那么需要将该虚拟地址压入 eax 寄存器,CPU 去 TLB 中查找,发现没有,再去页表中查找,如果页表中对应项没有物理页号,则会产生缺页异常,然后通过调用缺页异常处理程序获取一个空闲的物理页,分配给需要访问的虚拟页对应的页号。然后,CPU 会再次执行一次指令,这次指令的执行就能够正确访问到对应的物理内存了。

虚拟内存布局

对于 32 位操作系统而言,虚拟地址空间大小共有 2^32 = 4G,而对于 64 位操作系统而言,虚拟地址空间大小共有 2^64 = 16,777,216TB。这里需要明确一下,内存是以字节为单位存储的,无论是虚拟地址还是物理地址都对应的是一个字节。

一个程序运行的时候,可能会处于用户态或者内核态,但不管是运行在用户态还是运行在内核态,都需要使用虚拟地址,这个是由于硬件决定的,计算机访存的时候都会经过地址转换(内存管理单元 MMU)来获得最终的物理地址,操作系统作为软件需要服从硬件的决定。

32 位系统中,一般内核态与用户态的占比为 1:3,这意味着 4G 的总虚拟内存中,内核态占 1GB,用户态占 3GB。每个用户程序都使用相同的虚拟地址空间 0x0 ~ 0xFFFFFFFF,其中内核程序使用虚拟地址空间:0xc0000000 ~ 0xFFFFFFF。内核程序的虚拟地址空间和任意一个程序都无关,所有程序通过系统调用进入到内核之后,看到的虚拟地址是一样的,虽然应用程序看到的虚拟地址也是一样的,但是内容不一样,而内核程序的话看到的就是内容也是一样的,这意味着所有程序的内核态共享一个虚拟地址空间

为什么维护了内核页表,还要将内核页表拷贝到程序页表中?

主要是为了提高性能,当一个程序通过系统调用陷入内核态时,就不需要再切换页表了(切换页表需要消耗性能,比如刷新 TLB 页表项缓存),这是一种空间换时间的设计。

在 64 位系统中,一般只用到 48 位(256T),其中内核态使用 128T,用户态用 128T,中间是一堆操作系统不会去使用的内存空洞。内核态地址(0~46 位任意,47~63 全是 1),用户态地址(0~46 位任意,47~63 全是 0),其中用户态顶部界限为 TASK_SIZE=0x0000 7FFF FFFF FFFF,标志着用户态的最大空间。

用户态虚拟内存布局

由于 32 位与 64 位用户态虚拟内存布局类似,以下则不分 32 位与 64 位讨论。

用户态虚拟地址空间存储的内容从高地址到低地址大致有如下几点:

  1. 栈:存放函数调用时的参数、返回值、局部变量等信息,从高地址向低地址增长,可读写且私有。动态,高地址往低地址增长。rsp 是指向栈顶的指针,存储在 RSP 寄存器中。

栈为什么要由高地址向低地址扩展?

  • 为了避免栈空间和代码段冲突,防止缓冲区溢出
  • 便于确定栈空间的起始地址
  • 可以使堆和栈能够充分利用空闲的地址空间。有些程序用堆多,有些用栈多,很难确定栈和堆的分界线
  • 历史原因:在没有 MMU 的时代,为了最大的利用内存空间,堆和栈被设计为从两端相向生长。那么哪一个向上,哪一个向下呢?人们对数据访问是习惯于向上的,比如你在堆中 new 一个数组,是习惯于把低元素放到低地址,把高位放到高地址,所以堆向上生长比较符合习惯,而栈则对方向不敏感,一般对栈的操作只有 PUSH 和 POP,无所谓向上向下,所以就把堆放在了低端,把栈放在了高端。但现在已经习惯这样了。
  1. mmap 内存映射区:存放文件映射到内存的区域,如 mmap 等函数创建的映射区域或动态链接库等,可读写或只读且共享或私有。从高地址往低地址增长。mmap_base 指针指向了内存映射区的基地址。

为什么从高地址往低地址增长?

  • 32 位用户态只有 3GB,从低地址往高地址增长的话,堆用的内存空间可能就只有很少一部分,但是对栈来说,栈一般占的都很少,一般固定栈大小为 10MB,基本够用了,如果栈有界,可以在栈末尾端安置内存映射区,就能从高地址往低地址增长了。
  • 为了兼容历史上的驱动程序,低地址被分配给物理内存使用,高地址被分配给 Memory map IO(内存映射输入输出)。因此,当物理内存不足时,就只能从高地址开始分配 mmap 内存映射区。
  • mmap 内存映射区通常用于加载大文件或共享内存等场景,如果从低地址开始分配,可能会导致虚拟地址空间的碎片化。而从高地址开始分配,则可以避免这种情况。
  • 对于 64 位来说,往哪边增长都一样,两种内存布局都存在。反正空间大小足够。
  • 关于内存映射区,可以移步博文一文了解 os-内存映射了解。
  1. 运行时堆 (heap):存放动态分配的内存,如 malloc 等函数申请的内存,从低地址向高地址增长,可读写且私有。
  2. 未初始化数据 (.bss):存放未初始化的全局变量和静态变量,初始值为 0 或 NULL,可读写且私有。
  3. 已初始化数据 (.data):存放已初始化的全局变量和静态变量,可读写且私有。
  4. 代码 (.text) 存放可执行文件中的代码,通常位于低地址处,只读且共享。

内核态虚拟内存布局

与用户态不同,在 32 位系统与 64 位系统的局部差别就比较大,主要因为32 位内核态空间太小了,因此后文会区分 32 位与 64 位,分开讨论。

在 32 位系统下,我们知道内核态占了 1GB,用户态占了 3GB,中间通过 PAGE_OFFSET(TASK_SIZE)分隔开。

内核态的前 896M 是直接映射区,用于存放内核代码、数据和程序相关的数据结构,比如页表、用户态的虚拟地址空间结构 mm_struct、vm_area_struct 等。接下来是 vmalloc 区,用于内核映射,申请不连续的物理内存(堆内存),使用 vmalloc() 申请,从 VMALLOC_START(低地址)到 VMALLOC_END(高地址)。在 vmalloc 区域与直接映射区之间有个 8M 的安全区,用于捕获对内存的越界访问。基本每两个相邻的区之间都会有。再接下来是持久映射区,从 KMAP_BASE(低地址)到 FIXADDR_START,用于内核永久内存映射。然后是固定映射区,用于内核临时映射,到 0XFFFF FFFF。

直接映射区的命名由来是该区域与物理内存之间只是简单的直接映射关系,该区域对应的物理内存区域是从 0 地址到 896M 的区域,虚拟地址只用减去特定偏移即可获取到对应的物理地址,比如我获取到直接映射区的一个虚拟地址,只需要简单的计算,如减去 PAGE_OFFSET,即可得到对应的物理地址。那么内核访问直接映射区也需要经过地址转换吗?答案显而易见,内核程序只能服从于硬件要求。

下面我们来了解一下高端内存这个概念,它是 32 位系统有,而 64 位系统没有的。高端内存是直接映射区高地址邻接点以上的区域,即分布在 896M 以上的物理内存区域,是只属于物理内存的概念。用户态的内存可以直接映射到高端内存上,但是没法访问直接映射区(前 896M),而内核态的内存却可以访问所有的物理内存。由于直接映射区已经映射了内核态虚拟地址空间的 896M,内核态的虚拟地址只剩下 120M,那么内核态该如何访问所有的高端内存?

如果是正常映射的话只能映射到高端内存的 120M,我们可以使用持久映射、固定(临时)映射、vmalloc。

  • 固定映射:
    • 拿 120M 的一部分做映射,要哪块的时候就建立映射,要访问其他的时候就删除映射,映射另一块区域(相同虚拟地址段在不同时刻映射不同的物理内存区域)
    • 对于固定映射而言,将这个区域划分为若干固定的映射区域,每个映射区域映射不同的物理内存区域,用完了直接删除映射关系即可。可以用 kmap_atomic()。
    • kmap_atomic() 是用于建立临时内存映射的函数,它映射到固定映射区(Fixing Mapping Region)。它用于紧急的,短时间的映射,没有使用任何锁,完全靠一个数学公式来避免混乱。它空间有限且虚拟地址固定,这意味着它映射的内存不能长期被占用而不被 unmap。kmap_atomic() 在效率上要比 kmap() 提升不少,然而它和 kmap() 却不是用于同一场合的。kamp_atomic() 的使用场景如下:
      • 在内核进入保护模式之前,要先建立一个临时内核页表并开启分页功能,这个临时页表用于映射相应的内存。
      • 在中断处理程序中,使用 kmap_atomic() 和 kunmap_atomic() 函数来创建和销毁临时映射区间,这个区间用于映射高端内存。
  • 持久映射:
    • 与固定映射不同的是,这个区域创建的映射区域大小不固定。可以通过 kmap()
    • kmap() 主要用于文件系统、网络等对高端内存访问有较高性能要求的模块中。
    • 临时映射比持久映射的实现要简单,临时映射运行也快,适用于中断处理程序。但是临时映射的窗口很少(固定分了几块而已)。

64 位的内核态虚拟内存布局就简单许多,因为它的空间大,足够映射所有的物理内存。

前 8T 是空洞(作为保护作用),PAGE_OFFSET,从 PAGE_OFFSET 开始是 64T 的直接映射区,用于内核程序数据结构,如页表、mm_struct、vm_area_struct 等。然后是 1T 空洞,从 VMALLOC_START 到 VMALLOC_END,中间是 32T 的 vmalloc 区,用于内核动态请求不连续的物理内存,再是 1T 空洞到 VMMENMAP_START,到虚拟映射区 1T,只有内核使用稀疏内存模型的时候才会用到这块区域。然后是一大波空洞,到临界点__START_KERNEL_map,后面是 512M 的代码段,没放到直接映射区中,但是通过直接映射的方式映射到物理内存的 0 到 512MB。再然后上面都是空洞。关于布局的信息可以查阅Documentation/x86/x86_64/mm.txt

以下是物理内存的映射情况,可以看到内核态虚拟内存足够大,甚至有能力直接映射整个物理内存,所以说 64 位系统不需要高端内存。

物理内存模型

总共分为平坦、不连续、稀疏内存模型三种。

我们首先要知道如何去衡量 CPU 性能,显而易见的是响应一条指令的时间以及执行指令的吞吐量。如果我们要提高计算机的性能,从纵向看,我们能够提高单个 CPU 的主频,但是 CPU 的主频提高受到硬件制约,发展到如今,CPU 的主频已经非常高了,如果再提高,可能成本也会有大幅上升。从横向看,我们可以增加 CPU 的个数,这样可以并行执行指令,以提高吞吐量。

当一台计算机内有多个 CPU 时,该怎么去协调 CPU 访问内存呢?在一致内存访问 UMA(Uniform Memory Access)的方式下,多个 CPU 会与多个内存共享一条总线,CPU 将多个内存看成一个内存来使用,每个 CPU 访问主存是一样快的,但是由于是共享总线,需要检测总线是否忙碌,确定非忙碌后才让总线传输数据。这种系统完全受到总线带宽的限制

为了改善 UMA,我们可以为每个 CPU 分配一块高速缓存,许多操作通过高速缓存就能完成,而不需要去访问数据总线,减少了总线流量,使得 UMA 能支持更多的 CPU。

如果 CPU 核数超过 100,那最好使用非一致内存访问 NUMA(Non-uniform Memory Access)架构的系统。在 NUMA 中,每个 CPU 都有自己独立的主存,每个 CPU 除了访问自己的主存,还能通过总线访问其它 CPU 的主存,当然,比起访问自己的主存,通过总线去访问其它 CPU 的主存肯定是要慢很多的。NUMA 将每个独立的主存抽象为 node,很显然,NUMA 有多个 node,而 UMA 架构下只有一个 node。

UMA 与 NUMA 都是对称多处理技术 SMP(Symmetrical Multi-Processing)的具体实现。

物理内存抽象

假设一个节点里物理内存有 4G,一个物理页 4K,页被抽象为一个结构体 page,用于管理对应的物理页(物理页和 page 实例不是一个东西,每个物理页 4k,但是每个 page 实例却不一定是 4k,得看结构体属性来决定)

1
2
3
4
5
6
7
8
struct page{
unsigned long flags;
// 用于描述页框的状态
atomic_t _count;
// -1 表示页框空闲 >=0 表示已分配
atomic_t _mapcount;
// ...
}

如果把物理内存看成一个 page 组成的数组,struct page mem_map[1048576],数组占用多大空间取决于每个 page 实例占用多大空间,而 page 实例所占空间则取决于 page 结构体中的属性的大小。

假设创建了一个 page 实例 p,并为它初始化,page 实例 p 存放在哪呢?

p 是一个指针,即一个虚拟地址,p 指向的虚拟地址是内核态虚拟地址空间(page 是内核为了管理物理内存抽象出来的数据结构,存放在内核态直接映射区),实例 p 存放在直接映射区映射到物理内存中的内核动态数据区域。

当用户态产生缺页异常要获取一个页时,应该怎么办?

最简单的一种方法就是在内核动态数据的 page 实例数组中遍历,直到找到满足条件的 page,返回(空闲的、大小合适的、连续的)。

那么怎么知道这个 page 是映射到哪个物理页号呢,是哪个物理页的抽象?

page 实例是直接映射的,计算 page 在 mem_map 的索引即可,物理页号=(page - mem_map) / 32,page 数组的索引就是页帧号,32 是 page 实例的大小。转换的函数是 page_to_pfn()。还提供了另一个方法,根据一个物理页号,找到某一个对应的 page 实例:pfn_to_page(): pfn*32 + mem_map,其中 32 是 page 实例的大小。

平坦内存模型

平坦内存模型 FLATMEM(flat memory model)是 Linux 最初提供的内存模型(自 0.11 版本就存在。它通过简单的线性映射将物理页与一个数组 mem_map 对应起来,简单而高效。但是如果存在内存空洞的话,会很浪费内存。)

什么是内存空洞?

实际上物理内存中有些地址用不了,比如 4G 内有一部分的地址是空洞无法访问的,称为不连续内存。平坦内存模型中的 mem_map 是数组,在内存中是连续的,即使对应物理内存的物理页部分是空洞的,也仍然要在 mem_map 中占一个位置。其实空洞这部分是不需要管理的,不需要 page 实例去映射。
NUMA 架构下就会出现内存空洞,它有多个节点组成,节点与节点之间的内存就有可能是不连续的。如果还是用平坦内存模型就很浪费。(当然,选用什么物理内存模型与硬件是什么架构没什么关系)

不连续内存模型

NUMA 架构下,每个节点之间的内存是有空洞的,但是每个节点内的内存是不存在空洞的。那么我们可以将每个节点的内存抽象为一个 mem_map,然后使用一个 mem_map_array 来存储每个 mem_map 的起始地址。效率没那么高,需要多次转换。但是如果内存之间有空洞,可以降低内存占用。这个模型与 NUMA 耦合太紧,只适用于 NUMA 架构下(如果 UMA 架构下内存出现大量空洞也用不了),NUMA 如果每个节点内的内存页出现大量空洞,也会出现浪费内存的情况。

因此不连续内存模型只是一个过渡用的中间产物,最终被稀疏内存模型替代。

与平坦内存模型不同,不连续内存模型通过 page 实例获取物理页号要麻烦的多。

page_to_pfn():

  1. 从 page 的 flags 中拿到 nid(nid 是 node id,节点号。flags 值在系统初始化时设置)
  2. 根据 nid 拿到 node 以及对应 mem_map
  3. (page - node.mem_map) / 32 + node.start_pfn(先获取到 page 在 mem_map 中的索引,然后加上初始页号即可。node.start_pfn 是每个节点的起始页号,在初始化时设置)

通过pfn_to_page()可以通过物理页号得到 page 实例

pfn_to_page():

  1. 根据 pfn 拿到 nid(哈希表)
  2. 根据 nid 拿到 node 以及对应的 mem_map
  3. node.mem_map + pfn - node.start_pfn

稀疏内存模型

稀疏内存模型将物理内存分为多个段(mem_section,多个 mem_section 存储在 mem_section_map 中),每个段内有多个 page 实例,假设每个段大小为 2^27 = 128MB。其实感觉和多级分页的设计差不多,如果对应上的段是空洞,则段指向 None 即可,节省空间。有可插拔的特性,与 NUMA 架构高度解耦。

一个 mem_section_map 的数组需要存储在一个页帧中,大小为SECTIONS_PER_ROOT=(PAGE_SIZE / sizeof(struct mem_section))

根据 page 实例来计算页号看起来复杂,其实只是计算下标后在二维数组中查找而已,实现起来也另有技巧。

page_to_pfn():

  1. 从 page 的 flags 获取全局段号 nr(每个段都有个段号标识)。
  2. 计算 page 实例所属的 mem_section 的基地址:
    假设 SECTIONS_PER_ROOT=3(mem_section_map 中有三个元素),根据段号 nr 和 SECTION_PER_ROOT 获取到 mem_section_map 在 ROOTS 中的索引。然后使用 nr & SECTIONS_PER_ROOT 得到段在 mem_section_map 的索引。
  3. 获取到 mem_map(即 mem_sections 的基地址)。使用 page - mem_map + start_pfn(每个段都有自己的 start_pfn)。

pfn_to_page():

  1. 计算 pfn 的全局段号 nr,pfn >> PFN_SECTION_SHIFT(定义好的,27-12)
    pfn * 4KB / 128MB = pfn / (2^(27-12))
  2. 计算 page 所属的 mem_map
    mem_map = ROOTS[nr / SECTIONS_PER_ROOT][nr & SECTIONS_PER_ROOT]
  3. mem_map + (pfn - start_pfn)

三种内存模型的关系

如图,UMA 架构一般会选用平坦内存模型以及稀疏内存模型,因为它将主存看做一块。NUMA 架构则三种内存模型都可以使用。

总结

本文从虚拟内存入手,介绍了 32 位、64 位系统下的内核态和用户态虚拟内存布局,其中 32 位内核态由于空间太小,需要通过固定映射、临时映射来使得有限的内核态虚拟地址空间去访问更多的物理内存中的高端内存,而 64 位系统下由于空间充足,就显得很豪气,即使是通过直接映射也能轻易映射所有的物理内存,毕竟 128TB 的物理内存对于当下一台计算机而言还是极其少见的。接着,我们又着眼于物理页,简要介绍了两种 CPU 与内存交互的架构和三种物理内存模型。实际上,三种内存模型的实现远比本文所讲的复杂的多,有兴趣者可以自己深入学习一下(推荐阅读)。

参考与推荐阅读

]]>
<h2 id="揭开操作系统内存的面纱">揭开操作系统内存的面纱</h2> <p>众所周知,每个程序都有属于它自己的源程序,通过翻译、链接阶段可以得到它的 ELF 可执行目标文件。ELF 可执行目标文件则是将这个程序的代码和数据按照一定的格式组织在这个文件中,其中包含了段头部表、
走近 bolt https://makonike.github.io/2022/12/13/%E8%B5%B0%E8%BF%91bolt/ 2022-12-13T10:30:00.000Z 2023-03-30T16:51:21.732Z 飞书文档思维笔记 - 走近 bolt

img2


img

]]>
<p><a href="https://vwn3qg1pgo.feishu.cn/mindnotes/bmncnxNwEiqpEpXPxkCV44kaMHh">飞书文档思维笔记 - 走近 bolt</a></p> <p><img src="https://makonike-blo
一文了解 OS-中断 https://makonike.github.io/2022/11/28/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3OS-%E4%B8%AD%E6%96%AD/ 2022-11-28T05:06:24.000Z 2023-07-27T15:41:12.729Z 一步步认识中断

想要认识中断,我们必须要知道中断是什么,中断在操作系统中起到了什么作用,为什么中断是必不可少的。

我们先来看看操作系统与外部设备交互的过程,其中有两种交互方式,一种是直接通过汇编指令,另一种就是使用中断机制。

由于需要兼容多种底层设备,CPU 不方便直接去操作外部设备,因此需要加一个中间层——设备管理器(每一种外部设备都有一个设备控制器)来控制与外部设备的交互。设备管理器中包括了与 CPU 交互的三个主要的寄存器,状态寄存器、命令寄存器与数据寄存器以及与设备交互的控制电路,还有一个用于接收数据的缓冲区。其中状态寄存器存储了状态指示当前设备是否正在忙碌,或者处于就绪状态,命令寄存器存储了 CPU 需要执行的指令,数据寄存器存储了 CPU 传输给设备,或设备传入到设备控制器的数据。缓冲区用于接收和缓存数据,等待数据达到了缓冲区大小才将数据放入内存,避免了频繁占用总线开销大。

众所周知,CPU 的运算速度远远高于存储设备、外部硬件设备的操作速度(如网卡并不是一瞬间将所有数据都接收到,可能存在一个过程),CPU 需要去查看外部设备是否正在忙碌,此时使用的是轮询、忙等待的方式。

那么 CPU 如何知道设备控制器的三个寄存器在哪呢?

操作系统会给设备控制器的每个寄存器分配一个唯一的端口值,如 0X03A1,这被称为端口映射 IO。此外,还有一种将每个寄存器的地址看做内存地址的方式,这种情况可以直接通过 MOV 来传递数据,也被称为内存映射 IO。实际上,一台计算机中对于设备控制器的寄存器而言,内存映射 IO 和端口映射 IO 都有用到。

CPU 如何确定数据要传到哪个寄存器中呢?

当然是通过指令啦。在端口映射中,内存中有指令OUT 0X03A1 EAX,即代表将寄存器 EAX 的数据写入到 0X03A1 中,而在内存映射 IO 中,可以直接通过 MOV 将 EAX 数据 MOV 到对应目标地址。

CPU 轮询到外部设备为就绪状态时,即可将数据下发到数据寄存器中,并且设置设备的状态为忙碌(busy),再下发指令到命令寄存器中,执行指令,执行完成后再重置状态寄存器中的状态为就绪状态,这样就完成了一次与外部设备的交互。

说了那么多前置的知识,下面开始进入正题。

什么是中断

中断可以归结为一种事件处理机制,通过中断发出一个信号,然后操作系统会打断当前的操作,根据信号找到对应的处理程序处理这个中断,处理完毕之后再根据处理结果来决定是否需要返回到原程序继续执行。中断本质上是一种特殊电信号,且硬件设备生成中断的时候不与处理器时钟同步,因此中断随时可以产生,内核也随时可能因为新的中断的到来而被打断。这里我们主要讨论的是由硬件产生的异步中断,后面会讲述到。

中断解决了什么问题?

在上文中,我们讨论了当 CPU 需要访问外部设备时,它必须不断进行轮询和等待外部设备的状态。这种轮询过程极大地浪费资源,特别是在单核 CPU 中,由于设备访问的阻塞性质,CPU 可能无法响应其他程序的请求。为了解决这个问题,引入了中断的概念。中断机制有效地解决了 CPU 轮询和忙等待以检查外部设备状态所带来的性能损耗问题

通过中断,当外部设备完成了需要 CPU 关注的任务,它会发送一个中断信号给 CPU。这时,CPU 就会立即暂停当前正在执行的任务,保存当前的状态,并转而去处理设备发来的中断。这样,CPU 就不再需要进行忙碌的轮询,而是在真正需要处理设备的时候再去响应它。举个🌰,操作系统现在与一台打印机交互,而这台打印机目前正在忙碌,所以 CPU 需要轮询发指令去检查打印机是否准备就绪。

我们再通过一个涉及键盘的示例来说明中断的整个过程:键盘上有一个键盘编码器,用于监控每个按键的状态。当用户按下一个按键时,键盘会解码数据并将其存储在键盘控制器的数据寄存器中。这将触发一个中断,并向中断控制器发送一个电信号(中断控制器是一个简单的电子芯片,通过复用技术将多个中断线路通过一个连接到 CPU 的管道进行通信)。

如果中断线处于活动状态,中断控制器会将中断转发给 CPU,并提供对应的中断号(IRQ)以表示特定键盘动作(每个中断都有唯一的标识)。CPU 查询中断向量表(其中存储了中断号与相应中断处理程序内存基地址的映射关系),然后跳转到指定的中断处理程序内存基地址以执行中断处理例程。

在这个过程中,CPU 需要保存之前程序的状态,包括寄存器信息、RIP、RSP、CPU 状态寄存器等。然后,通过类似于 IN EAX 0X03FA 的指令,CPU 将从外部设备(键盘)的数据寄存器读取数据到 EAX 寄存器中。然后,通过 OUT 0X06B1 EAX 指令,将 EAX 中的数据写入显示器的数据寄存器中,从而在显示器上显示数据。完成中断处理例程后,CPU 恢复到之前程序的状态。

中断机制解决了 CPU 轮询、忙等待的问题,CPU 与外部硬件交互的利用率可能还是很低

我们针对操作系统与打印机交互而言,操作系统每次执行OUT指令传递一个字符到打印机控制器中,此时如果打印机忙等,CPU 会去执行另一个用户程序,CPU 响应一次中断信号,切换到执行中断处理程序,然后再执行OUT指令传递一个字符到打印机控制器,如此反复。由于每次打印一个字符都要响应一次中断消耗 CPU 时间,主要的原因是CPU 参与了数据移动(将数据移动到打印控制器的数据寄存器)
解决方法是使用 DMA 机制。DMA(Direct Memory Access)机制能显著减少 CPU 开销。DMA 控制器存储了数据源地址、数据目的地址、数据长度,当应用程序需要打印字符串时,CPU 通过系统调用陷入内核,设置 DMA 控制器,其余的工作就可以让 DMA 来完成,CPU 可以去执行其余的应用程序。当一次打印完成后,DMA 控制器通过中断控制器发出中断信号给 CPU,CPU 查表执行中断处理程序,只需要简单的切换回原先执行打印的应用程序即可。一次任务只需要一次中断,提高了 CPU 的利用率。

发起中断

对了,说到了中断时,CPU 需要执行操作系统中的指令(打印机的中断处理程序、设置 DMA),还需要讲讲系统调用、内核态与用户态,此处简单介绍一下。

操作系统由于安全问题,将 CPU 的执行状态分为了内核态与用户态,分别对应操作系统运行以及应用程序运行。在内核态下,CPU 可以使用所有的指令,而在用户态下,CPU 只能使用部分指令,这样确保了操作系统的安全。

在 Linux 中,系统调用是用户空间访问内核的唯一手段(除了异常和陷入以外)。系统调用为用户空间提供了一种硬件的抽象接口,且保证了系统的稳定和安全,系统调用时,内核可以基于权限、用户类型或其他规则对进行的访问进行裁决。

举个栗子,一个 c 语言写的应用程序,调用了printf()输出一些字符串,这个方法来自于用户接口程序库函数(glibc),其底层的实现就是调用了中断指令进行系统调用陷入内核。在 Linux32 位中,用户态通过调用INT $0X80中断指令陷入内核,在 Linux64 位中,调用的是 syscall 汇编指令,二者大体上是一样的。

应用程序如何通知内核执行系统调用呢?

通过软中断,告诉内核自己需要执行一个系统调用,希望系统切换到内核态,这样内核就可以代表应用程序在内核空间执行系统调用了。

CPU 怎么知道要执行哪个系统调用?

针对各个系统调用方法而言,操作系统为每个系统调用方法分配了一个唯一的系统调用号,内存中维护了一张系统调用表,存储了系统调用号以及系统调用的实现函数内存基地址(如 open 系统调用方法的实现函数为 sys_open)。用户态通过系统调用的方法名,找到记录在 glibc 中的系统调用号,然后将其存放在寄存器 EAX 中,查找中断向量表,执行中断处理程序,再查系统调用表,由于系统调用号存在寄存器 EAX 中,直接读取然后查询即可,然后执行实现函数。当然,大部分系统调用方法都会有参数,用户态在执行库函数的时候就会将参数放在特定的寄存器中(ebx,ecx,edx,esi,edi,ebp)

中断处理程序

中断处理程序只是普通的 c 函数,产生中断的每个设备都有一个相应的中断处理程序,包含在该设备的驱动程序中(如果一个设备可以产生多种不同的中断,那么这个设备就可以对应多个中断处理程序,该设备的驱动程序就需要准备多个中断处理函数)。中断处理函数与其余内核函数的区别在于,中断处理程序是用于被内核调用来响应中断的,运行在被称为中断上下文的特殊上下文中(偶尔也被称为原子上下文),该上下文中执行的代码不可阻塞。

由于中断随时都可能发生,需要保证中断处理程序能快速执行,且能快速恢复中断代码的执行,一般将中断处理的过程切成两部分。这样既解决了想要中断处理程序运行快,又想让中断处理程序完成的工作量多的矛盾关系。

中断处理程序是上半部(top half),接收到一个中断,它就立即开始执行,但是只做有严格时限的工作,如对接收的中断进行应答或复位硬件。允许稍后完成的工作都推迟到了下半部(bottom half),在合适的时机才会被执行。

我们以网卡为例,当网卡接收到来自网络的数据包时,需要通知内核数据包到了,网卡需要立即完成这件事,从而优化网络的吞吐量和传输周期,避免超时。因此网卡立即发出中断,内核通过执行网卡注册的中断处理程序来应答。中断开始执行,通知硬件,拷贝最新的网络数据包到内存,然后再去读取网卡更多的数据包。这些重要且急迫,又与硬件相关的工作都由上半部来完成。内核通常需要快速的拷贝网络数据包到系统内存,因为网卡上接收网络数据包的缓存大小固定,而且相比系统内存也要小得多。所以如果拷贝的动作延迟了,必然会导致缓存溢出,进入的网络包占满了网卡的缓存,后续到来的网络包只能被丢弃。当网络数据包被拷贝到系统内存后,中断的任务就完成了,此时它将控制权还给系统被中断前原先运行的程序,处理和操作数据包的工作都在随后的下半部来完成。

中断上下文

前面我们提到,中断上下文是中断处理程序的运行时环境,它与进程没有什么瓜葛,也与 current 当前宏无关(尽管 current 还是指向被中断的进程)。由于没有后备进程,中断上下文不可用休眠和重新调度。因此,也不能再中断上下文中调用某些函数(比如说sleep()函数)。

中断上下文有严格的时间限制,因为它打断了其他代码的执行,中断上下文中的代码应当迅速且简洁,尽量不使用循环处理繁重的工作。因为中断处理程序打断了其余程序的执行,这种异步执行的特性使得中断处理程序应该尽可能迅速、简洁。尽量将繁重的工作从中断处理程序中剥离出来,放到下半部来执行

曾经中断处理程序没有自己的栈,它们共享所中断进程的内核栈,直到Linux2.6,添加了一个选项使得内核栈的大小从两页减少到一页(减轻内存的压力),为了应对栈大小的减少,中断处理程序拥有了自己的中断请求栈 hardirq_stack,每个 CPU 有一个,大小为一页,由于中断处理程序占了一整个页,能使用的空间比之前共享的还要大得多了(之前是共享中断进程内核栈的两个页,但是平均可用的栈空间很小)。其实中断栈的使用还是需要根据操作系统位数来讨论,在 64 位系统中,仍然是使用当前进程的内核栈作为中断栈,在 32 位系统中才使用 CPU 中单独的中断请求栈 hardirq_stack。中断处理程序不用关心内核栈大小为多少、栈如何设置,只要尽量节约内核栈空间即可。

异常与中断,傻傻分不清的系统调用

都说中断分为上下部,网络上很多文章说系统调用是通过软中断实现的,那么系统调用是中断吗?如果不是的话,为什么没有中断上半部呢?如果属于中断,那么执行的中断处理程序又是什么呢?软件中断又和软中断有什么关系呢?

前几天和朋友讨论时发现系统调用时发现了有这几个问题,究其原因是没有理清楚异常、中断的概念,还有过于僵化地认为中断必然会分为上半下半两部。

先说说异常,在《深入理解计算机系统》中提到,异常是异常控制流的一种,一部分由硬件实现,一部分由操作系统实现。异常是控制流中的突变,用于响应 CPU 状态中的某种变化,基本的思想是 CPU 状态的变化触发从应用程序到异常处理程序的突发的控制转移(异常),在异常处理程序处理完成后,将控制返回给被中断的程序或者终止。

CPU 的状态变化的又被称为事件,事件可能与当前指令的执行有关,如发生虚拟内存缺页、算术溢出、除零等,也可能与当前指令的执行无关,如一个系统定时器产生信号或者一个 I/O 请求完成。

在任何情况下,当 CPU 检测到有事件发生时,就会通过一张异常表的跳转表执行一个间接过程调用(也称为异常),然后执行一个专门设计用于处理这类事件的操作系统子程序(异常处理程序)。当异常处理程序完成处理后,会根据引起事件的类型,以及处理事件的结果发生返回执行当前指令返回执行下一条指令或者终止被中断程序这三种情况之一

这个执行流程非常眼熟,简直就是中断的执行流程,那么我们来看看异常与中断的关系。在《深入理解计算机系统》,异常又被分为中断、陷阱(trap)、故障(fault)和终止(abort)四种。其中中断是异步产生的,是来自 CPU 外部的 I/O 设备的信号的结果,由于硬件中断不是由任何一条指令产生的,从这个意义上来说它是异步的(操作系统无法预知它的产生),而硬件中断的异常处理程序常常又被称为中断处理程序。

到这里差不多明了了,其实对于异常和中断来说,不同的书都有不同的定义,不过大致上都差不多。在《intel architectures software developer’s manual》中的定义是:

  • An interrupt is an asynchronous event that is typically triggered by an I/O device.
  • An exception is a synchronous event that is generated when the processor detects one or more predefined conditions while executing an instruction. The IA-32 architecture specifies three classes of exceptions: faults,traps, and aborts.

我们可以大致上把中断理解为是一个被外部I/O设备触发的异步事件,例如用户的键盘输入。它是一种电信号,由硬件设备生成,然后通过中断控制器传递给 CPU,CPU 有两个特殊的引脚 NMI 和 INTR 负责接收中断信号。

异常则是一个同步的事件,通常由程序的错误产生的,或是由内核必须处理的异常条件产生的,如缺页异常或 syscall 等。异常可以分为错误、陷阱、终止。同步的意思是它产生时必须考虑与处理器时钟同步,只有在一条指令执行完毕后 CPU 才会发出中断,而不是在代码指令执行期间发生的,比如说系统调用。

来看看陷阱(trap),陷阱是有意的异常,是执行一条指令的结果。它最重要的用户就是在用户程序和内核之间提供一个像过程一样的接口,叫系统调用。系统调用与普通的函数调用的实现不同,普通函数调用允许在用户模式,被限制了函数可以执行的指令的类型,且它们只能访问与调用函数相同的栈,而系统调用允许在内核模式中,内核模式允许系统调用执行特权指令,并访问定义在内核中的栈。

实际上,系统调用的执行过程是通过中断实现的,使用到的中断属于“软件触发的硬中断” ,属于陷阱(异常),即同步中断,而不是本文提到的中断(硬件中断),因为系统调用过程是要同步处理的,不能使用异步的软中断方式实现。系统调用通过指令触发异常(这里的异常是一个过程,实际上就是同步中断,从应用程序被打断,陷入内核态,执行完后又恢复回来。对于异常的几种子类而言,基本都是这个流程),陷入内核态,触发异常处理程序(在异步中断中也叫中断处理程序),这个异常处理程序的内容就是进行系统调用(通过在 EAX 寄存器获取系统调用号,查系统调用表得到系统调用实现方法的基地址,执行系统调用方法),执行完异常处理程序后恢复到被中断执行的指令

在 linux 中执行 cat /proc/interrupts 会打印所有注册的硬中断,仔细观察之后,你会发现其中包含一个名为‘CAL’的中断,它就是系统调用所对应的中断号。这是通过执行机器指令触发的,所以我才说它是软件触发的硬中断。

1
2
3
4
5
6
cat /proc/interrupts
CPU0
0: 181 IO-APIC-edge timer
...
CAL: 0 Function call interrupts
...

总结一下,我们可以把异常称为同步中断,而本文所说的硬件中断称为异步中断。异步中断的实现是分为上半部与下半部的,但也不是所有的异步中断都需要有下半部,只有需要推迟执行的任务才需要下半部(用另一种情况说,理想情况下,只有下半部),而系统调用属于同步中断,通过异常来触发软中断,执行的中断处理程序就是系统调用。而软件中断,我没找到比较合理的定义,网络上基本都是将中断分为硬件中断与软件中断,软件中断由软件触发,即指令触发,这么说来软件中断也是属于同步中断,可以理解为系统调用也是属于软件中断的一种。需要明确的是,软中断并不是软件中断,软中断只是中断下半部的一种实现机制,是异步中断的一部分,用于执行推迟的工作。

软中断

软中断的意义是使内核可以延期执行任务,因为它的运作方式和上述的中断类似,但完全是从软件实现的,所以称为软中断。内核借助软中断来获知异常情况的发生,而该情况将在稍后有专门的处理程序解决。

软中断是相对稀缺的资源,因为每个软中断都有一个唯一的编号,所以使用其必须谨慎,不能由各种设备驱动程序和内核组件随意使用。默认情况下,系统上只能使用 32 个软中断,但这没什么,因为基于软中断内核还衍生出了许多其他其他延期执行机制,比如 tasklet、工作队列和内核定时器。我们稍后会介绍它们。

只有中枢的内核代码才会使用到软中断,软中断只用于少数场景,如下就是其中相对重要的场景。其中两个用来实现 tasklet(HI_SOFTIRQ,TASKLET_SOFTIRQ),两个用于网络的发送和接收(NET_TX_SOFTIRQ,NET_RX_SOFTIRQ,这两个是构建软中断机制的最主要原因)一个用于块层,实现异步请求完成(BLOCK_SOFTIRQ),一个用于调度器(SCHED_SOFTIRQ),以实现 SMP 系统上周期性的负载均衡。在启用高分辨率定时器时,还需要一个软中断(HRTIMER_SOFTIRQ)。

1
2
3
4
5
6
7
8
9
10
11
12
13
enum
{
HI_SOFTIRQ=0
TIMER_SOFTIRQ,
NET_TX_SOFTIRQ,
NET_RX_SOFTIRQ,
BLOCK_SOFTIRQ,
TASKLET_SOFTIRQ,
SCHED_SOFTIRQ,
#ifdef CONFIG_HIGH_RES_TIMERS
HRTIMER_SOFTIRQ,
#endif
};

软中断的编号形成了优先顺序,这影响了多个软中断同时处理时执行的次序。

我们可以通过raise_softirq(int nr)发起一个软中断(类似普通中断),软中断的编号通过参数指定。每个 CPU 都有一个位图irg_stat,其中每一位都代表了一个中断号,raise_softirq()会设置各个 CPU 变量irg_stat的比特位。该函数会将对应的软中断标记为 1,但是该中断的处理程序不会立即运行。通过使用特定于处理器的位图,内核可以确保几个软中断(甚至是相同的)可以在不同的 CPU 上执行

那么软中断什么时候执行呢?

  1. 当前面的硬件中断处理程序执行结束后,会检查当前 CPU 是否有待处理的软中断,如果有的话会按照次序处理所有的待处理软中断,每处理一个软中断之前,会将其对应的比特位清零,处理完所有软中断的过程,我们称之为一轮循环。
    • 一轮循环处理结束后,内核会通过位图再次检查是否有新的软中断到来,如果有的话会一并处理,这就会出现第二轮循环,第三轮循环。
    • 但是软中断不会无休止的重复下去,当处理轮数超过MAX_SOFTIRQ_RESTART(通常是 10),就会唤醒软中断守护线程(每个 CPU 都有一个),然后退出。
  2. 软中断守护线程负责在软中断过多时,以一个调度实体的形式(即能合其他进程一样被调度),帮助处理软中断请求,在这个守护线程中会重复的检测是否有待处理的软中断请求。
    • 如果没有软中断请求了,则会进入睡眠状态,等待下一次被唤醒。
    • 如果有请求,则会调用对应的软中断处理程序

下半部

下半部执行与中断处理密切相关但中断处理程序本身不执行的工作,在理想的情况下,最好是中断处理程序将所有的工作都交给下半部分执行,因为我们希望中断处理程序执行工作越快越好,但是中断处理程序注定要完成一部分的工作,比如通过操作硬件对中断的到达进行确认,或者从硬件拷贝数据,这些工作对时间非常敏感,只能靠中断处理程序自己完成。

在上半部将数据从硬件拷贝到内存后,那么应当在下半部来处理它们。操作系统没有规定哪些任务在哪些阶段来执行,需要开发者自己把握分寸,将中断处理程序执行的时间尽量压缩到最小。

对于上半部来说,中断处理程序执行的过程中,当前的中断线上的所有处理器都会被屏蔽,下半部的执行则不会,在下半部执行的过程中,允许响应所有的中断,这样提高了系统的响应能力,不止是 Linux,这也是大部分操作系统的设计实现。

与上半部只能通过中断处理程序实现不同,下半部可以通过多种机制实现,在 Linux 的发展中就存在多种的下半部机制。虽然软中断是将操作推迟到未来时刻执行的最有效方式,但是软中断的中断号有限,而且该延期机制处理起来非常复杂。因为多个处理器可以同时且独立地处理软中断,所以一个软中断的处理程序例程可以在几个 CPU 上同时运行,这要求软中断处理程序必须是可重入且线程安全的,临界区必须使用自旋锁来保护。此外,在软中断中还不能进入睡眠,在中断上下文中我们提到过,软中断的其中一部分是硬件中断处理结束后才进行的,这时候软中断执行函数没有调度实体,所以不能进入睡眠。

早期的下半部机制称为 BH,它提供了一个静态创建、由 32 个 bottom halves 组成的链表,上半部通过一个 32 位的整数中的一位来标识出哪个 bottom half 可以执行,每个 BH 在全局范围内进行同步即使分属于不同的处理器,也不允许任意两个 bottom half 同时执行。这种机制虽然方便且简单,但是不够灵活,而且有性能瓶颈。

后续出现了任务队列机制来实现工作的推后执行,试图用它来替代 BH 机制。内核定义了一组队列,每个队列都包含一个由等待调用的函数组成的链表。根据其在所处队列的位置,这些函数会在某个时刻执行,但是它还是不够灵活,不能胜任一些性能要求比较高的子系统,如网络部分。

为了弥补这个缺点,Linux2.3 中引入了软中断和 tasklet,如果不考虑兼容,软中断与 tasklet 完全可以替代 BH 机制。软中断也是一组静态定义的下半部接口,有 32 个,即使是相同类型的两个接口,也可以在处理器上同时执行tasklet是一种基于软中断实现的灵活性较强的、动态创建的下半部实现机制,相同类型的 tasklet 不能同时执行。实际上,tasklet 其实是一种性能与易用性之间寻求平衡的产物,对于大部分的下半部处理只用 tasklet 就够了,只有像网络这种要求性能高的子系统才需要使用软中断。软中断必须再编译期间就进行静态注册(驱动处理程序),而 tasklet 可以通过代码动态注册。

网络上很多文章将所有的下半部都当做软件产生的中断,或者软中断,其实软中断与 BH 和 tasklet 并驾齐名,在 Linux2.5 中,BH 机制和任务队列机制都被完全去除了。新引入的工作队列的接口取代了任务队列的接口,在工作队列中,它们需要先对推后执行的工作进行排队,稍后才在进程上下文中执行它们。

下半部机制状态
BH在 2.5 中去除
任务队列(task queues)在 2.5 中去除
软中断(softirq)在 2.3 开始引入
tasklet在 2.3 开始引入
工作队列(work queues)在 2.5 开始引入

tasklet

tasklet 的实现基于软中断,但是它们更易于使用,因而更适合于设备驱动程序。

在内核中,每个 tasklet 都有与之对应的一个对象表示,内核以链表的形式管理所有的 tasklet,而且每个 tasklet 都有两个状态,这两个状态通过 state 字段的不同位表示,其中一个代表 tasklet 是否注册到内核,成为一个调度实体(TASKLET_STATE_SCHED),另一个代表该 tasklet 是否正在运行(TASKLET_STATE_RUN)。通过 TASKLET_STATE_RUN,我们可以使一个 tasklet 只在一个 CPU 上执行。此外 count 字段大于 0 表示该 tasklet 被忽略。

1
2
3
4
5
6
7
8
struct tasklet_struct
{
struct tasklet_struct *next;
unsigned long state;
atomic_t count;
void (*func)(unsigned long);
unsigned long data;
};

当我们注册 tasklet 时,如果发现 TASKLET_STATE_SCHED 已经被置为 1,则说明该 tasklet 已经注册了,就不会重复注册。那么 tasklet 说明时候执行呢?tasklet 的执行被关联到 TASKELT_SOFTIRQ 软中断。因此,在调用raise_softirq(TASKLET_SOFTIRQ)时,tasklet 就会在合适的时机执行。执行过程如下:

  1. 检查 tasklet 的 TASKLET_STATE_RUN 是否被置为 1,是的话则说明其他 CPU 在执行它,那么当前 CPU 就会跳过它。
  2. 检查其是否被禁用(count 是否大于 0)
  3. 将 TASKLET_STATE_RUN 置为 1
  4. 调用 tasklet 的 func

因为 tasklet 本质上是再软中断的处理程序中执行的,所以它也不能睡眠或阻塞,但是它可以保证同一时刻某个 tasklet 只会在一个 CPU 上执行,有着天生的线程安全保障。

除了普通的 tasklet 之外,内核还提供了另一种 tasklet,它具有更高的优先级。高优先级的 tasklet 通过 HI_SOFTIRQ 软中断触发而不是 TASKLET_SOFTIRQ,这两种 tasklet 在不同的链表中维护。这里的高优先级指的是软中断的处理程序 HI_SOFTIRQ 比其他软中断处理程序更先执行,因为它排在软中断号的第一位。很多声卡驱动以及高速网卡都是依赖高优先级的 tasklet 实现的。

等待队列

我们已经得知 tasklet 无法睡眠和阻塞,那么当设备驱动要等待某一特定事件发生的时候,有什么办法吗?我们可以通过等待队列来完成这个需求。既然要睡眠和阻塞,肯定需要一个调度实体,换句话说,等待队列中的项不再是一个简单的处理函数,而是一个类似于后台进程一样的存在。

1
2
3
4
5
6
struct wait_queue_t {
unsigned int flags; // 当 flags 为 WQ_FLAG_EXCLUSIVE 时,表示该事件可能是独占的,唤醒一个进程后就返回
void *private; // 大部分情况下指向进程对象 task_struct
wait_queue_func_t func; // 调用该函数唤醒等待进程
struct list_head task_list; // 链表实现需要
};

等待队列的使用分为如下部分

  1. 为了使当前进程在一个等待队列中睡眠,那么需要调用wait_event()函数。进程进入睡眠后,会将控制权释放给调度器。内核通常会在向块设备发出传输数据的请求后,调用该函数,因为传输操作不会立即发生,而在此期间有没有其他事情可做,所以进程可以睡眠,将 CPU 时间让给系统中的其他进程。
  2. 就上面的例子而言,块设备的数据到达后,必须调用wake_up()函数来唤醒等待队列中的睡眠进程。在使用wait_event()使进程睡眠之后,必须确保在内核中另一处有一个对应的wake_up()调用。

wait_event()是一个宏,它接收两个参数,第一个是等待队列对象 wait_queue_t,第二个是判断事件是否到来的 bool 表达式。这个宏的实现也很简单,就是先将当前进程加入到等待队列的 task_struct 链表中,然后循环地通过第二个参数确认事件是否到来,如果到来了则跳出循环,否则继续睡眠。

wait_up()函数的实现也很简单,有三个参数,第一个是等待队列链表的第一个对象 wait_queue_head_t,第二个参数 mode 指定进程的状态(TASK_UNINTERRUPTIBLE | TASK_INTERRUPTIBLE),第三个参数 nr_exclusive 控制唤醒该队列上的几个进程,如果为 1 则表明是独占的事件,只唤醒其中一个,如果是 0 则唤醒该队列中的所有进程。

工作队列

工作队列是将任务延迟执行的另一种机制。它和等待队列一样是通过守护进程实现的,在用户上下文执行,所以可以睡眠任意长的时间。它与“线程池”非常类似,在创建的时候我们需要指定线程名,同时也可以指定是单个线程,还是每个 CPU 上创建一个对应的线程。

1
struct workqueue_struct *__create_workqueue(const char *nameint singlethread)

创建好工作队列后,我们可以向其中注册任务,每个任务的结构如下。注册完的的任务会维护在一个链表中,按照顺序依次执行。

1
2
3
4
5
struct work_struct {
atomic_long_t data; // 和本工作项相关的数据,例如工作函数可以将一些中间内容或者结果保存在 data 中
struct list_head entry; // 链表实现需要
work_func_t func; // 函数指针,其中一个函数参数指向了本 work_struct 对象,使函数内可以访问到 data 属性
}

而且在注册工作内容时,我们还可以指定延时任务,它会在一个指定延迟后开始执行。当创建延时任务后,内核会创建一个定时器,它将在 delay jiffies 之后超时,随后相关的处理程序就会将 delayed_word 内部的 work_struct 对象加入到工作队列的链表中,剩下的工作就和普通任务完全一样了。

1
2
3
4
5
6
int fastcall queue_delayed_work(struct workqueue_struct *wq,struct delayed_work *dwork,unsigned long delay)

struct delayed_work {
struct work_struct work;
struct timer_list timer;
}

总结

到此为止,本文介绍了中断的执行流程、中断的意义、中断的划分以及划分后部分的执行内容和机制。除此之外,中断还有很多地方可以深入探索,譬如详细叙述异常以及异常的其余分类、中断处理程序的初始化、加载、释放过程,中断机制下半部机制的选择,对于下半部机制的执行过程中,还涉及到内核同步与并发等。对于本文而言,仅仅是简要介绍中断的一部分。

参考与推荐阅读

]]>
<h2 id="一步步认识中断">一步步认识中断</h2> <p>想要认识中断,我们必须要知道中断是什么,中断在操作系统中起到了什么作用,为什么中断是必不可少的。</p> <p>我们先来看看<strong>操作系统与外部设备交互的过程</strong>,其中有两种交互方式,一种是
一文了解 GoFound https://makonike.github.io/2022/10/29/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3GoFound/ 2022-10-28T17:14:08.000Z 2023-03-30T16:50:56.623Z GoFound 去发现,去探索全文检索的世界,一个小巧精悍的全文检索引擎,支持持久化和单机亿级数据毫秒级查找。

基础概念

全文检索

全文检索一般有两种方法。一是从头到尾扫描作为检索对象的文档,以此来搜索要检索的字符串,如 Unix 中的grep命令,但是文档数量越多,文档越大,检索的时间会越长,甚至超出预期上限。另一种是利用索引进行全文搜索,事先为文档建立索引,尽管建立索引可能需要不少时间,但是优点是即使文档的数量增加,检索的速度也不会大幅下降,GoFound 也是采取这种方式。

倒排索引

倒排索引与词典索引类似,用一本书的倒排索引作为示例,key 存储了单词,value 存储了一个组成页数的数组,当你需要查找I search keywords in Google.这句话时,你可以很直观的看到它的单词在哪一页。

img

倒排索引中,每个单词都有一个引用指向属于它的一个倒排列表,倒排列表中存储了众多的倒排项,倒排项即文档 ID。取出多个单词的倒排列表后,可以根据情况进行交集处理与评分,获取到更符合预想的搜索结果。

正排索引

正排索引存储了文档 id 和索引词组的映射,便于在修改索引时判断索引 text 变更,以及计算相关度。

文档

文档是作为检索对象的数据,在全文搜索中,通常将构建索引的单位称为文档 (Document),将文档的唯一标识信息称为编号 (文档 ID)。可以说,倒排索引就是将单词和单词所在文档的文档编号对应起来的表格。

分词与词典

由于中文不像英文那样有空格隔开,中文分词手段主要依靠了字典和统计学。

GoFound 使用的中文分词组件是结巴分词,支持精确模式、全模式、搜索引擎模式和 paddle 模式四种分词模式。分词时采取的是搜索引擎模式CutForSearch,在精准模式的基础上试图将长词分为若干个短词,提高召回率。CutForSearch()会返回一个 channel,用于防止分词阻塞以及接收分词后的短语。

GoFound 通过 jieba 的接口载入自定义词典,在调用的 jieba-go sdk 中,jieba 通过将词典的每一个词以及该词的词频、词性封装为Token,将其存入 map,设定词频为其所给词频,然后将该词继续分割为前缀词,将前缀词加载入词典中,设定前缀词词频为 0.0

1
2
3
4
5
6
7
8
9
10
11
12
func (d *Dictionary) addToken(token dictionary.Token) {
d.freqMap[token.Text()] = token.Frequency()
d.total += token.Frequency()
runes := []rune(token.Text())
n := len(runes)
for i := 0; i < n; i++ {
frag := string(runes[:i+1])
if _, ok := d.freqMap[frag]; !ok {
d.freqMap[frag] = 0.0
}
}
}

数据结构与定义

索引实体-IndexDoc

1
2
3
4
5
6
// IndexDoc 索引实体
type IndexDoc struct {
Id uint32 // 索引 id, 唯一标识一个文档/索引对象
Text string // 索引文本
Document map[string]interface{} // 文档
}

文档对象-StorageIndexDoc

1
2
3
4
5
// StorageIndexDoc 文档对象
type StorageIndexDoc struct {
*IndexDoc // 索引实体
Keys []string // 索引词组
}

响应文档结果-ResponseDoc

1
2
3
4
5
6
type ResponseDoc struct {
IndexDoc // 索引实体
OriginalText string // 原始索引文本
Score int // 估值评分(关联度计算)
Keys []string // 索引词组
}

删除文档索引模型-RemoveIndexModel

1
2
3
type RemoveIndexModel struct {
Id uint32 // 索引 id
}

搜索请求-SearchRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SearchRequest 搜索请求
type SearchRequest struct {
Query string // 搜索关键词
Order string // 排序类型
ScoreExp string // 分数计算表达式
Page int // 页码
Limit int // 每页大小
Highlight *Highlight // 关键词高亮
Database string // 数据库名字
}

// Highlight 关键词高亮
type Highlight struct {
PreTag string // 高亮前缀
PostTag string // 高亮后缀
}

搜索响应-SearchResult

1
2
3
4
5
6
7
8
9
10
// SearchResult 搜索响应
type SearchResult struct {
Time float64 // 查询用时
Total int // 记录总数
PageCount int // 总页数
Page int // 页码
Limit int // 页大小
Documents []ResponseDoc // 文档集合
Words []string // 索引词组
}

存储结构-LeveldbStorage

1
2
3
4
5
6
7
8
9
type LeveldbStorage struct {
db *leveldb.DB // goleveldb 句柄
path string // 存储路径
mu sync.RWMutex // 加锁
closed bool // 数据库是否关闭
timeout int64 // 自动关闭数据库连接
lastTime int64 // 上一次开启时长
count int64 // 存储的键数量
}

分页限制-Pagination

1
2
3
4
5
6
type Pagination struct {
Limit int // 限制大小

PageCount int // 总页数
Total int // 总数据量
}

容器-Container

真的难以形容这个命名,实际上也只是将引擎封装了一波

1
2
3
4
5
6
7
8
9
type Container struct {
Dir string // 数据文件夹
engines map[string]*Engine // 引擎
Debug bool // 是否开启调试模式
Tokenizer *words.Tokenizer // 分词器
Shard int // 分片数
Timeout int64 // 超时关闭数据库的超时数
BufferNum int // 分片缓冲数
}

引擎-Engine

引擎其实就是一个 GoFound 数据库结构的封装,包含了正排索引、倒排索引以及文档数据的存储结构,还有分词器和添加索引工作 channel,分片数配置等。

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
type Engine struct {
IndexPath string // 索引文件存储目录
Option *Option // 配置

invertedIndexStorages []*storage.LeveldbStorage // 倒排索引 关键字和 Id 映射 key=id,value=[]words
positiveIndexStorages []*storage.LeveldbStorage // 正排索引 ID 和 key 映射,用于计算相关度,一个 id 对应多个 key
docStorages []*storage.LeveldbStorage // 文档仓

// Note: 继承锁在传参时因为值拷贝可能会导致锁失效
sync.Mutex // 锁
sync.WaitGroup // 等待
addDocumentWorkerChan []chan *model.IndexDoc // 添加索引的通道
IsDebug bool // 是否调试模式
Tokenizer *words.Tokenizer // 分词器
DatabaseName string // 数据库名

// Note: 这个地方没有考虑如果更改了分片数,那么之前的有些地方就会映射错误
Shard int // 分片数,用于文件分片
Timeout int64 // 超时时间,单位秒
BufferNum int // 分片缓冲数

documentCount int64 // 文档总数量
}

type Option struct {
InvertedIndexName string // 倒排索引
PositiveIndexName string // 正排索引
DocIndexName string // 文档存储
}

执行流程

初始化

  1. 解析配置文件信息
    1. 包含设置默认值
  2. 初始化分词器和容器
    1. 分词器:调用 jieba 分词的 apiLoadDictionary加载路径下的词典data/dictionary.txt
    2. 容器:包括设置加载配置中的参数,如数据目录,分片数,超时时间和分片缓冲数,以及分词器。并创建数据目录,初始化引擎,加载数据库,如果有多个数据库(数据目录下有多个目录),则会创建多个引擎,数量相等于数据库数量。当该数据库不存在时,会创建数据库。
    3. 初始化引擎:主要用于管理索引、文档存储相关内容,会初始化一些参数如分片数Shard、分片缓冲数BufferNum。还会初始化索引引擎。
    4. 初始化索引引擎:为每个分片都创建一个个 channel 用作添加索引缓冲,缓冲 channel 的缓冲区大小为 BufferNum,并设定一个工作 g 专门用于添加文档索引。为每个分片新建索引文档、正排索引和倒排索引的存储结构。设定一个 g 用于自动 GC(10s 一次)。
  3. 初始化业务逻辑
    1. 初始化基础管理,数据库管理,索引管理和分词管理,注册相应的回调函数。可以由统一的全局变量 srv 来调度
  4. 注册路由
    1. 选择是否开启 gzip 压缩,设置 basic 认证
    2. 分组注册路由以及中间件
  5. 启动服务
    1. 开启一个 goroutine 启动服务
  6. 优雅关机
    1. 接收到 os 的 signal 时会等待五秒,取消所有的请求再关闭

添加、更新索引

在这里要提到文档的 Id,文档 Id 是用户自己设置的,而不是 GoFound 给予的。接口的处理方法是异步添加索引,在接收到索引实体 (IndexDoc) 后,自增文档数量并把文档的索引实体传入文档中,返回 nil。

1
2
3
4
5
func (e *Engine) IndexDocument(doc *model.IndexDoc) error {
e.documentCount++
e.addDocumentWorkerChan[e.getShard(doc.Id)] <- doc
return nil
}

在初始化时已经为每个分片创建了一个 g 用来添加索引工作,处理对应缓冲 channel 的文档。工作 g 执行方法是在 for 中等待 worker channel 传来的 doc,然后调用添加文档方法AddDocument(),此处才真正开始处理添加文档和索引。

首先是调用分词器对索引的原始文本进行分词,然后调用optimizeIndex()检测是否需要更新倒排索引,获取新插入的以及需要更新倒排索引的词组。调用getDifference(id)获取索引词组新旧的差别,这里传入了文档 Id,通过正排索引查找出旧索引的词组,比较俩词组的区别,返回需要新增的,需要移除的词组。如果新词组与旧词组不一致,先移除需要移除词组的倒排索引,将需要插入的词组返回,由AddDocument()进行处理。对需要新增的词组调用addInvertedIndex()添加倒排索引,最后调用addPositiveIndex()更新覆盖正排索引以及文档存储。至此添加与更新索引结束

删除索引/文档

删除文档的处理较为简单,但因为没有加锁处理可能会导致不寻常的意外发生,这些我们在后文细谈。通过接收到的文档 id,查找相应的正排索引,如果没找到则直接返回。删除获取到的索引词组对应倒排索引的文档 id 映射,删除文档存储,并减少 engine 的 documentCount。

查询

查询调用的是 engine 的方法MultiSearch()多线程查询。先对查询文本进行分词,设定排序模式,对每一个词都开启一个 g 调用processKeySearch()进行搜索。获取到该词所在分片的倒排索引映射的一组文档 id,将文档 id 数组加入 fastSort 的 temps 中,等待进一步处理。处理完每个词后,还需要进行交集得分和去重,在fastSort.Process()中进。上一步已经将所有词组相关的文档 id 存入了 fastSort 的 temps 中,先将 temps 进行排序,遍历 temps,根据 id 的数量来增加 id 对应的分数,将分数统计加入到 fastSort.data 中,它是个 SliceItem 数组,记录了文档 id 以及对应的分数。统计完成后,对 fastSort.data 进行降序排序,然后开始处理分页和进行自定义分数统计。

分页统计也很简单,计算取得数据的区间,直接获取fastSort.data[start:end]即可。

对于每个 SliceItem,启动一个 g 去获取该 SliceItem 的文档 id 对应的文档数据,进行文档存储的 gob 解析,使用request.Highlight高亮原始索引文本里的本次的索引词组。调用第三方 govaluate 包进行表达式解析、执行 (Evaluate),并更改评分,如果此时排序模式是倒序的,则需要将顺序对调,再返回即可。

性能实测

笔者测试虚拟机配置为 6 核 6G Linux 发行版为 Ubuntu22.04

1
2
$ uname -a
Linux lcf-virtual-machine 5.15.0-48-generic #54-Ubuntu SMP Fri Aug 26 13:26:29 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux

添加索引

添加索引速度很慢,同时占用 cpu 高,可以看到 index 处是枚举每个文档进行插入的,而且 channel 也有缓冲数量,限制了添加索引的速度,默认 shard 为 10,buffnum 为 1000 时差不多 200 个索引/10s。在 AddDocument() 中,对索引进行操作都有全局锁锁住,特别是 optimizeIndex(),在计算新旧索引词组的时候也一并锁住了,影响了 g 的并发性能。个人认为此处只锁住 id 就行了,对于更新正排索引而言,还需要锁住词组即可,这样可以保证多个 g 并行添加索引,又保证了数据的安全性。

1
2
3
4
5
6
7
8
9
10
// BatchAddIndex 批次添加索引
func (i *Index) BatchAddIndex(dbName string, documents []*model.IndexDoc) error {
db := i.Container.GetDataBase(dbName)
for _, doc := range documents {
if err := db.IndexDocument(doc); err != nil {
return err
}
}
return nil
}

添加索引时会占用大量 cpu(3 核)与部分内存(2G)

img.png

正常启动并添加 5w 数据后内存恒定占 2G

img_4.png

查询性能

虽然代码比较简单,但是查询性能还是挺给力的,笔者 mock 了 5w 数据写入,搜索长度 45 的句子,在 3w 数据中还是达到毫秒级的

img.png

查询过程中会占用少量 cpu

img.png

问题与优化

搜索结果不稳定

笔者同一查询参数去查询时 total 的数量不稳定

img_1.png

img_2.png

img_3.png

修改分片可能会导致数据正常无法访问到

没有做相应的分片数据转移和负载均衡功能

查询过程中没有有意地控制 g 的数量

每一个词一个 g,数据量较大时会 oom
img_5.png

]]>
<p><a href="https://github.com/sea-team/gofound">GoFound 去发现</a>,去探索全文检索的世界,一个小巧精悍的全文检索引擎,支持持久化和单机亿级数据毫秒级查找。</p> <h2 id="基础概念">基础概念</h2> <h3
一文了解 LSM-Tree https://makonike.github.io/2022/09/23/%E4%B8%80%E6%96%87%E4%BA%86%E8%A7%A3LSM-Tree/ 2022-09-23T13:55:41.000Z 2023-03-30T16:51:04.998Z 什么是 LSM-Tree

SSTables 的结构

想要了解 LSM-Tree(Log Structured Merge Tree,日志结构合并树),我们得先了解 SSTables。SSTables 即排序字符串表(Sorted String Table),也是 LSM-Tree 里的核心数据结构。它的概念来自 Google 的 Bigtable 论文,大概的意思就是,SSTable 是一种可持久化,有序且不可变键值存储结构,key 和 value 都可以是任意的字节数组,并且给提供了按照指定 key 查找和指定范围的 key 区间迭代遍历功能。

如下图所示,该图表示一个个日志结构存储数据段文件:为了避免最终用完磁盘空间,才将日志分为特定大小的段,对旧段文件进行压缩和合并,在新的日志文件中只保留每个键的最近更新。这种情况下,由于旧段的内容不会被修改,因此合并的段可以放入一个新的文件,旧段的合并和压缩都可以在后台线程完成,请求时仍然使用旧段处理,而完成合并后,就能直接删除旧段,使用新的合并段。

img

日志中稍后的值优先于日志中较早的相同键的值

怎么保证最近更新?

  • 每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值必须比另一个段中的所有值更新(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值

合并几个 SSTable 段,只保留每个键的最新值。此处每个段的键都是唯一的,是经过了一个压缩的过程

img

我们对这些段文件要求键值对的序列按键来排序,且保证每个键在同一个段文件中只出现一次(在压缩过程中已经保证了,只保留最近更新),此外,SSTable 具有内存索引。在 SSTable 的结构中,包含了若干的键值对,称为block,在其末尾,存储了一组元数据 block记录数据 block 的描述信息,比如索引、BloomFilter、压缩、统计等信息,其中索引记录了这些数据 block 的键的偏移量

img

An SSTable provides a persistent, ordered immutable map from keys to values, where both keys and values are arbitrary byte strings. Operations are provided to look up the value associated with a specified key, and to iterate over all key/value pairs in a specified key range. Internally, each SSTable contains a sequence of blocks (typically each block is 64KB in size, but this is configurable). A block index (stored at the end of the SSTable) is used to locate blocks; the index is loaded into memory when the SSTable is opened. A lookup can be performed with a single disk seek: we first find the appropriate block by performing a binary search in the in-memory index, and then reading the appropriate block from disk. Optionally, an SSTable can be completely mapped into memory, which allows us to perform lookups and scans without touching disk.

在文件中查找一个特定的键不需要保存内存中所有键的索引,如下图所示,如果要找键handiwork,但不知道段文件中该 key 的确切偏移量,但是你知道handbaghandsome的偏移,而且由于 key 有序的特性,你还知道handiwork在其二者之间。因此可以跳到handbag的位置开始扫描,直到扫描至handsome。索引记录了一些键的偏移量,虽然可能会很稀疏,但是由于扫描文件的速度很快,每几千个字节段文件有一个 key 索引就可以了。

由于 Read 请求需要扫描请求范围内的多个键值对,因此可以将这些记录分组到块中,写入磁盘前对其压缩,如下图灰色部分,索引的每个条目都指向压缩块的开始处,即节省了磁盘空间,也减少了 I/O 带宽使用

如果每个 key,value 都是等长的(容易获取记录分界点),甚至可以直接在段文件上二分查找,避免了使用内存索引

img

在下文 LSM-Tree 的结构中可以看到 SSTable 一般是分 level 的,level 级数越小,表示处于该 level 的 SSTable 越老,最大级数由系统设定。当某个 level 下的文件数超过一定值后,就会将这个 level 下的一个 SSTable 文件和更高一级的 SSTable 文件合并,由于 SSTable 是有序的,合并过程相当于一次多路归并排序,速度较快。Leveled-N 模型有着减小写放大作用。

LSM-Tree 的概念和结构

大致了解了 SSTables,下面我们来看看 LSM-Tree

LSM-Tree 是一种分层、有序、针对块存储设备特点设计的数据存储结构。它的核心思想在于将写入推迟 (Defer) 并转换为批量 (Batch) 写,首先将大量写入缓存在内存,当积攒到一定程度后,将他们批量写入文件中,这要一次 I/O 可以进行多条数据的写入,充分利用每一次I/O,从而实现高效地顺序写入数据。顺序写入的速度比随机写入的速度快很多,即追加内容,非就地更新,类似普通的日志写入方式以 append 的模式追加,不覆盖旧条目。

追加日志看起来很浪费,为什么不更新文件,以新值覆盖旧值?

  • 顺序写入速度比随机写入得多:追加和分段合并是顺序写入的操作,通常比随机写入快得多。某种程度上顺序写入在基于闪存的固态硬盘(SSD)上也是优选

    • 当然,较于随机写的 B-Tree,LSM-Tree 的写入速度会更快,而 B-Tree 的读取速度更快,LSM-Tree 的读取速度比较慢。
  • 易于处理并发和崩溃恢复:段文件(SSTable)是附加的或不可变的,不必担心在覆盖值的时候发生崩溃情况导致将包含旧值和一部分新值保留在一起

  • 合并旧段可以避免数据文件随时间的推移而分散的问题

    • 这里指的是随着使用时间变长,不断随机写入导致的磁盘碎片的问题

img

LSM-Tree 一般由两个或两个以上存储数据的结构组成,这些存储数据的结构也被称为组件(一般是多组件,只有 C0 在内存中,其余都在磁盘中),这里举个最简单的只有俩组件的例子,一个称为 C0-Tree,常驻内存中,可以是任何方便键值查找的数据结构,如 AVL 等结构,另一个称为 C1-Tree,常驻硬盘中,结构与 B-Tree 相似。C1 在初始时为空,当内存 C0 的大小到一定程度的时候就要进行rolling merge,C0 会将部分内容 dump 到 C1 中,将数据从小到大(从左到右)依次追加写到 C1 的一个 multi-page block 叶节点 buffer 中,如果 buffer 满了就将其写到硬盘,以此类推,直到 C0 扫描到最右,C1 首次产生。当然,C0 并不将所有的条目都拿来 rolling merge, 由于 C0 存储在内存之中,所以 C0 可以保留最近插入或最常访问的那些数据,以提高访问速率并降低 I/O 操作的次数,C1 中经常被访问的结点也将会被缓存在 C0 中

LSM 在 merge 的时候如何把即将 merge 的数据定位到 C1 已经写入磁盘中的数据?

  • LSM 在 merge 时可以从根结点开始逐级往下选取与 C0 的新数据最接近的数据,更加复杂的办法还可以考虑每次 C0 往 C1 merge 的数据的位置的频率

当存在以下情况时,C1 目录节点会被强制刷盘

  • 包含目录节点的 multi-page block 缓存满了,只有该 multi-page block 会被刷盘

  • 根节点分裂,增加了 C1 的深度,所有 multi-page block 被刷盘

  • checkpoint 被执行,所有 multi-page block 刷盘

  • rolling merge:可以想象为拥有一个概念上的游标,在 C0 和 C1-Tree 的等值 k-v 之间缓慢穿梭移动,将 C0 索引数据取出放在 C1-Tree 上

    • 当增长的 C0-Tree 第一次到阙值
    • 最靠左的一系列条目会以高效批量形式从 C0-Tree 中删除
    • 然后被按 key 递增顺序重组到 C1-Tree,C1-Tree 会被完全填满
    • 连续的 C1-Tree 的叶节点会按从左到右顺序,首先放置到常驻内存的 multi-page block 内的若干初始页上
    • 直到该 multi-page block 被填满
    • 该 multi-page block 被刷盘,称为 C1-Tree 叶节点层的第一部分,直接常驻硬盘
    • 随着连续的叶节点不断添加的过程,C1-Tree 的目录节点会在内存缓存中被创建(为了高效利用内存和硬盘,这些上层目录节点会被存放在单独的页(或 multi-page block)缓存中,还有分隔点索引 M,将访问精确匹配导向某个下一层级的单页节点而不是 multi-page block。因此可以在 rolling merge 中使用 multi-page block,索引精确匹配时访问单页节点)
  • multi-page block:不同于 B-Tree,LSM-Tree 的延时写 (数据可以积攒) 可以有效的利用 multi-page block,在 rolling merge 的过程中,一次从 C1 中读出多个连续 pages,与 C0 进行 merge,然后一次向 C1 写回这些连续 pages,这样有效利用单次I/O完成多个 pages 的读写(B-Tree 在此场景下无法利用 multi-page 的优势)

  • batch:同样因为延迟写,LSM-Tree 可以在 rolling Merge 中,通过一次 I/O 批量向 C1 写入 C0 多条数据,那么这多条数据就均摊了这一次 I/O,减少磁盘的 I/O 开销

multi-page block 及其结点结构

img

rolling merge

img

在上面的 LSM-Tree 结构图中,我们还看到了 WAL 和 MemTable,以及 Immutable MemTable。实际上,上图是 LSM 经过实践后形成的结构,LSM-Tree 的内存结构可以由一个 MemTable 和一个或多个 Immutable MemTable 组成。

  • MemTable 往往是一个跳表组织的有序的数据结构(也可以是有序数组或红黑树等二叉搜索树),即支持高效的动态插入数据对数据进行排序,也支持高效的对数据进行精确查找和范围查找

  • Immutable Memtable 是内存中只读的 MemTable,由于内存是有限的,通常会设置一个阙值,当 MemTable 占有内存到阙值后就会转换为 Immutable MemTable,与 MemTable 的区别在于它是只读的。它的存在是为了避免将 MemTable 中的内容序列化到磁盘中时会阻塞写操作

  • WAL 结构与其余数据库的一致,是一个只能在尾部以 append only 追加记录的日志结构文件,用于系统崩溃重启时重放操作,使得 MemTable 与 Immutable MemTable 未持久化到磁盘中的数据不会丢失。严格来说,WAL 并不是 LSM-Tree 数据结构的一部分,但是实际中,WAL 却是数据库中不可缺少的一部分。每当内存数据写到 SSTable 时,相应的 WAL 日志就可以被丢弃

应用场景与优劣

应用

LSM-Tree 是基于硬盘的数据结构,与 B-Tree 相比,能显著的减少硬盘磁盘臂的开销,并在较长时间内提供对文件的高速插入/删除,但在查询需要快速响应的时候性能不佳。通常 LSM-Tree 适用于索引插入比检索更频繁的应用系统,如日志系统,推荐系统,或者 no sql 数据库等,较为出名的有 Lucene search engine、Google Big Table、LevelDB、ScyllaDB、RocksDB 等等。

在大量应用服务上线的今天,每天都会产生大量的日志,需要存储下来便于服务监控、数据分析、排查定位问题和链路追踪等。在推荐系统中,也要每时每刻记录用户的行为信息,用于训练线上运行的推荐模型,且用户和内容的一些动态特征信息每天也要频繁更新,方便提供个性化服务。

优点

LSM-Tree 的主要优势在于能推迟写回硬盘的时间,进而达到批量地插入数据的目的

  1. 减少写放大

写放大:在数据库声明中写入数据库导致对磁盘的多次写入。在写入繁重的应用程序中,性能瓶颈可能就是数据库写入磁盘的速度:存储引擎写入次数越多,可用磁盘带宽内每次写入次数越少

B-Tree 必须至少两次写入每一段数据,即使一页中只有几个字节发生了变化,也需要一次编写整个页面的开销,而 LSM-Tree 的延时写能够充分利用每一次I/O ,一次 I/O 将多条数据写入,减少了写入磁盘的次数,有研究表明,LSM-Tree 能够在可用 I/O 带宽内提供更多的读取和写入请求

  1. 比 B 树支持更高的写入吞吐量

顺序写入紧凑的 SSTable 文件而不是必须覆盖树中的几个页面,其中顺序写入比随机写入快得多

  1. 可以更好的被压缩

B-Tree 存储引由于分割会留下一些未使用的磁盘空间,而 LSM-Tree 不是面向页面的,并且会定期重写 SSTables 以去除碎片,所以具有较低的存储开销

缺点

LSM-Tree 的缺点主要在于空间放大和读放大,以及压缩过程有时会干扰正在进行的读写操作:如果一项数据更新多次,这项数据可能会存储在多个不同的 SSTable 中,甚至一项数据不同部分的最新数据内容存储在不同 SSTable 中(数据部分更新),导致读操作繁杂。一项数据在磁盘中存储了多份副本,老的副本是过时无用的,导致数据实际占用的存储空间比有效数据需要的大,即空间放大。在查询某个具体数据的时候,需要按新到老的顺序查找 SSTable,直到找到所需的数据。如果目标数据在最底层 Level-N 的 SSTable 中,则要读取和查找所有的 SSTable,即读放大问题

对于空间放大问题,可以通过类似GC(即压缩,只保留最近的 key,删除旧数据或标记为已删除的数据) 的过程来解决。而针对于读放大,也可以通过分层布隆过滤器解决。
分层即 SSTable 的 Level 机制,可以限制查找的范围。

  1. 每个 level 中都是上个 level 归并下来的,在单个 level 不会有重复 key。当数据量到达一定程度后会归并到下一个 level。比如 level1 的第一个文件大到临界值,会和 level2 的文件进行归并,去除了该文件与 level2 文件的重复 key,保证了 level2 不会有重复 key。
  2. level0 是在内存中 dump 下来的,不能保证没有重复 key。内存中维护了一个活跃内存表 MemTable 和一个不变内存表 Immutable MemTable,用于区分何时将 c0 数据结构 dump 到磁盘(超过一定大小触发),不变的内存表又易于 dump 数据。二者相互交替,周期性的将不变内存表 dump 到内存中形成一个分段文件。

布隆过滤器可以快速确定数据在不在 SSTable 中,避免了数据不存在时,遍历 SSTable 读取数据 block 内容带来的开销。当然,有还是会极少部分因为有冲突导致穿透,但这完全可以接受,因为数据不存在的数据在布隆过滤器中一定不存在。

在 WiscKey 中,还将读写分离与 LSM 相结合进行优化,不但减少了读写放大,延长 SSD 使用寿命,还充分利用了 SSD 的并行读写特性(线程池随机异步读写)。它的 SSTable 中不再存储 value,而是存储指向 value 的指针,避免了当 value 很大时,多次归并 SSTable 需要多次移动 value 降低性能。value 与 WAL 结合为 vLog,vLog 中也存储了 key 可以同时用于崩溃恢复。

实现及操作

构建 LSM-Tree 的整个流程中,如何让数据按键来排序呢?答案是在内存中来维护,因为在内存中维护总是比在磁盘中维护有序结构容易得多

  1. 写入数据时,将其添加到内存中的平衡树数据结构,即内存表 MemTable
  2. 当内存表大于某个阈值(通常是几兆字节)的时候,将其作为 SSTable 文件写入磁盘中。新的 SSTable 则成为数据库的最新部分,当 SSTable 被写入磁盘时,会开一个新的 MemTable 继续写入数据
  3. 为了提供读取请求,会先在 MemTable 找到该关键字,然后在最近的磁盘段中,在下一个较旧的段中找到该关键字
  4. 有时会在后台运行合并和压缩来组合段文件并丢弃或覆盖旧值
  5. 同时需要在磁盘中保存一个单独的日志(WAL)来记录每个写入,防止数据库崩溃时,未写入磁盘的 MemTable 的数据丢失。该日志仅有的作用就是崩溃后恢复 MemTable
  • 更新

更新即插入,在读取时总是从 C0-Tree 到 Level0 的 SSTable 到更老的 SSTable,总是能读取到最新值

  • 删除

为了更高效地利用 LSM-tree 的插入优势,删除操作被设计为通过插入操作来执行。当要删除一个条目时,先在 C0 上找对应的索引是否存在,如果不在就建一个索引,在索引键值上设置删除条目,通知所有访问该索引的操作该条目已删除。后续滚动合并中,在较大 CxTree 中碰到与该索引键值相同的条目都将被删除。在 C0 查找该条目时,碰到该删除条目,会直接返回未找到。如果 C0 上找到该索引存在,则直接将删除条目覆盖该索引

参考

]]>
<h2 id="什么是-LSM-Tree">什么是 LSM-Tree</h2> <h3 id="SSTables-的结构">SSTables 的结构</h3> <p>想要了解 LSM-Tree(Log Structured Merge Tree,日志结构合并树),我们得先了解 S