C++如何计算普通类型的 Hash 值:基于 gcc/clang 源码分析

当 int/long/float/指针/std::string 作为 std::unordered_map 的 key 时,C++底层是如何计算 hash 值的? gcc/clang 作为使用最多的两种编译器和标准库,它们在这个问题的实现上略有差异。本文将基于二者的源码进行对比分析。 std::string 在深入讨论其他类型的 hash 实现之前,我们首先分析 std::string 的 hash 逻辑。这是因为其他类型的哈希操作也依赖于对底层字节序列的通用 hash 操作,我们把它统一叫做hash_bytes。 在 gcc 中,无论是 32 位系统还是 64 位系统使用 murmurhash。代码在:libstdc++/hash_bytes.cc#L138 而 clang 在 32 位系统下采用 murmurhash, 64 位系统下采用 cityhash64。代码在:llvm/libcxx/__functional/hash.h#L86 整型 我们需要注意 std::hash 的结果类型是 size_t,而 size_t 在 64 位系统下是 8 字节,在 32 位系统是 4 字节。 因此,在讨论整数如何进行 hash 的时候,需要分两种情况: sizeof(T) <= sizeof(size_t): 比如 int32_t、64 位系统下的 int64_t。 sizeof(T) > sizeof(size_t): 比如 32 位系统下的 int64_t。 当 sizeof(T) <= sizeof(size_t) 时,这种情况下非常简单,直接将 key 的值本身作为 hash 值,无需进行额外的计算: hash = static_cast<size_t>(key)。这点 gcc 和 clang 的做法都是一致的。 而当 sizeof(T) > sizeof(size_t) 时,比如 32 位系统下的 int64_t。 gcc 和 clang 的做法略有不同。 在 gcc 中:对于这种情况也一样的强转成static_cast<size_t>(key)。但这里就涉及到了精度损失,在小端序下会丢弃高 32 位,保留低 32 位。这种情况下就会有 hash 冲突问题。 而 clang 的做法更加严谨:会将 int64_t 视为长度为 8 字节的 bytes,进行 hash_bytes 计算得到 hash 值。 llvm/libcxx/__functional/hash.h#L281 这个操作涉及底层hash_bytes操作,明显性能略差。但在 64 位系统是主流的今天,sizeof(T) > sizeof(size_t)的场景也非常少,实际上大部分情况下并不会走到这个逻辑分支。 另外 bool、char、short 等这些也统一被视为整型。同样的,值本身会被作为 hash 值。 float/double 在 gcc 中 将 float 和 double 视为底层是 4 字节和 8 字节的 bytes,直接进行 hash_bytes 计算得到 hash 值。代码在 libstdc++/hash_bytes.cc#L258 而在 clang 中进行了更细致的区分处理: 当sizeof(T) > sizeof(size_t)时,比如 32 位机器下的 double。这种情况下会和 gcc 一样,对 double 进行 hash_bytes 操作。 当 sizeof(T) <= sizeof(size_t) 时:比如 64 位机器下的 double 和 float。clang 借助了 union 进行高效的转换处理,但针对 double 和 float 的实现略有不同。 针对 64 位机器下的 float,clang 先将 union 中的 64 位 size_t 初始化为 0,然后将 float 放在低 32 位,高 32 位保持为 0,最后返回这个 size_t 作为哈希结果。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 假设现在是 64 位机器,我们要处理 32 位 float 的场景 template <class _Tp> struct __scalar_hash<_Tp, 0> : public __unary_function<_Tp, size_t> { _LIBCPP_HIDE_FROM_ABI size_t operator()(_Tp __v) const _NOEXCEPT { union { _Tp __t; size_t __a; } __u; // 8 字节全部初始化为 0 __u.__a = 0; // 填充低位 4 字节,高位 4 字节保持为 0 __u.__t = __v; return __u.__a; } }; 而对于 64 位机器下的 double,因为 double 的尺寸和 size_t 一致,因此直接使用 union 进行类型转换就行。 1 2 3 4 5 6 7 8 9 10 11 template <class _Tp> struct __scalar_hash<_Tp, 1> : public __unary_function<_Tp, size_t> { _LIBCPP_HIDE_FROM_ABI size_t operator()(_Tp __v) const _NOEXCEPT { union { _Tp __t; size_t __a; } __u; __u.__t = __v; return __u.__a; } }; 这里之所以不用 static_cast 强制转换为 size_t 类型,是因为 float 转为 size_t 会直接丢掉小数部分。 可以看出当sizeof(T) <= sizeof(size_t)时,clang 的实现更加简洁高效,性能更好,几乎没有什么开销。而这个优化可以命中绝大部分的使用场景。 此外,无论是 clang 还是 gcc,对于 float 值=0 的情况都进行了特殊处理,直接特判并返回 hash 值等于 0。这是因为在浮点数 IEEE 754 规定 +0.0 == -0.0,但它们的二进制表示不同,因此不能直接进行通用的底层二进制的转换。 +0.0 的二进制:0x00000000(float32) -0.0 的二进制:0x80000000(float32) 指针 指针类型的长度和 size_t 一样,gcc 利用了这个特性,直接将指针视为 size_t 作为 hash 值,这样做没有任何精度损失和计算开销。代码如下: 1 2 3 4 5 6 7 template<typename _Tp> struct hash<_Tp*> : public __hash_base<size_t, _Tp*> { size_t operator()(_Tp* __p) const noexcept { return reinterpret_cast<size_t>(__p); } }; 而 clang 的实现则不同,clang 是对指针进行了 hash_bytes 操作,计算得到一个 hash 值。代码在 llvm/libcxx/__functional/hash.h#L340 为什么 clang 不采用性能更好的 reinterpret_cast 操作呢? 这因为考虑到一些内存地址步长固定的场景,比如对于一个 std::vector<int64_t>,其中每个相邻元素内存地址都固定相差 8 字节。这个时候直接用指针地址作为 hash 值,极易发生 hash 冲突。 总结 我们通过分别 gcc 和 clang 的源码,了解到了普通类型 hash 值计算策略 以及二者的不同。在一些高性能场景下,我们针对性的进行优化,比如: 可以根据业务场景,选择更高效的 hash 计算方式,而非默认的 murmurhash/cityhash64 整型作为 hash key 可以绕过 hash 计算。我们在某些性能敏感的场景下提前计算好 hash 值 替代 std::string 作为 key,避免每次 find 和 insert 操作时的 hash 计算开销。

2025/9/6
articleCard.readMore

【月刊】2025-08-20 期: “人要大量地表达自己”

2025-08-20 期 文章 《人要大量地表达自己》 原文链接:https://mp.weixin.qq.com/s/MMgJa-5EVsINXuMvN0SF_Q 一方面,我越来越擅长自学,也养成了独立思考的能力;另一方面,我变得孤僻,不善与人建立连接。面对人群,我总是局促不安,不知如何表达真实的自己;我害怕袒露想法,总觉得无人理解,也无人愿意理解。 人必须与外部世界建立连接,而表达,就是这场连接中的第一道桥梁。 我才明白,表达不是可有可无的能力,而是一门人生的必修课 真正的表达,不是为了炫耀,也无关于技巧,而是一种自我的袒露。你越愿意将内在推向外部,你就越清楚自己是谁、想要什么、和他人有什么不同。而你越害怕表达、越不敢袒露,你的“自我”就越容易被模糊、被裹挟、被湮没。久而久之,甚至你自己也不认识自己了。 表达像是一面镜子,一面不断校准自我认知的镜子。你表达得越多,越真实,就越靠近那个真实的“自己” 这篇文章非常触动我。因为我自己在生活中就是一个不愿意表达自我的人。似乎在传统教育中并不鼓励自我表达,我们习惯标准答案,过度的自我表达会被认定为标新立异,它会带来需要羞耻感。但等到自己想要真正表达自我的时候,就会发现无法准确的表达出自己的想法,甚至陷入自我怀疑的沼泽。 这篇文章让我意识到,表达自我其实是一个思考自我的练习方式。通过不断的练习表达,那个真实的我才会浮现出来。 所以表达自我很重要,哪怕无人倾听。 《Writing Toy Software Is A Joy》 原文链接:https://blog.jsbarretto.com/post/software-is-joy 这篇文章讨论了经典的程序员要不要造“轮子”的问题,文中有一些非常有价值的观点和建议: 写玩具项目可以帮我们理解一些核心的技术点。费曼说:“What I cannot create, I do not understand” 写的时候要避免过度工程化,keep it simple:我们自己在开发过程中应该经常会有类似的体会。大部分时间都花在 corner case 和周边工作上,实际上核心开发工作并不多。所以在开发玩具程序要遵循二八规则:花 20% 时间做出来 80% 的功能。重点不是构建出可以线上使用的版本,而是构建一个包含核心功能点的最小可用版本。 最后文章也给了一些玩具项目的例子: 正则表达式引擎(难度 4/10,时间 5 天) x86 操作系统内核(难度 7/10,时间 2 个月) GameBoy/NES 模拟器(难度 6/10,时间 3 周) GameBoy Advance 游戏(难度 3/10,时间 2 周) 物理引擎(难度 5/10,时间 1 周) 动态解释器(难度 4/10,时间 1-2 周) 类 C 语言编译器(难度 8/10,时间 3 个月) 文本编辑器(难度 5/10,时间 2-4 周) 异步运行时(难度 6/10,时间 1 周) 哈希映射(难度 4/10,时间 3-5 天) 光栅化器/纹理映射器(难度 6/10,时间 2 周) SDF 渲染(难度 5/10,时间 3 天) POSIX shell(难度 = 4/10,时间 = 3-5 天) 《People Die, but Long Live GitHub》 原文链接:https://laike9m.com/blog/people-die-but-long-live-github,122/ 如果你希望存储一段信息,让 100 年后的人也能访问,要怎么做? 三体中有类似的想法:如果人类灭亡,最好的保存文明的方式是什么?答案是:“刻在石头上” 但在一百年的时间尺度上,文章作者认为 GitHub 是存储信息的最好方式: Github 已经是全人类的互联网基础设施 Git 能保存所有的 commit 历史 Git 是分布式存储的,即使 Github 被黑客侵入数据丢失,只要有一份 fork 还在,数据就还在。 越来越多的人会把自己的信息搬到 GitHub 上,依托 GitHub 实现曾经人们可望而不可及的"永生"。 几十几百年后,GitHub 将成为世界上最大的数字公墓,注册用户大部分都已去世,然而个人主页,项目,commit 历史 还述说着他们生前做过的事——就比如 Joe 的博客。 这虽然是个比较 creepy 的推论,但从另一个角度想,却证明了人类的巨大进步:对抗死亡是人类文明的永恒主题,而我们已经实现了阶段性胜利。 这篇文章是 2019 年写的,当时大模型尚未出现。如今 Deepseek 和 chatgpt 几乎成为知识工作者的标配的时候。保存自己的数字内容,自己独特的数字历史上下文更有意义。 看完这篇文章后,我也决定之后把博客中的所有 md 文件都在 Github 上公开。 《怎样当好一名师长》 tk 教主分享在微博上的文章:“粉丝群有人问有没有关于怎么带团队的书。我觉得把《怎样当好一名师长》看明白就够了。这也是我非常推崇的一篇文章。” 要勤快:应该自己干的事情一定要亲自过问,亲自动手。 要摸清上级的意图:对上级的意图要真正理解,真正融会贯通,真正认识自己所领受的任务在战役、战斗全局中的地位和作用。这样,才能充分发挥自己的主观能动性,才能打破框框,有敢于和善于在新情况中找到新办法的创造性。 要调查研究:平时积累的掌握的情况越多,越系统,在战时,特别是在紧张复杂的情况下,就越沉着,越有办法,急中生智的“智”才有基础。 要有活地图 要把各方面的问题想够想透: 要让大家提出各种可能发现的问题,要让大家来找答案 没有得到答案的问题,不能因为想了很久想不出来就把它丢开,留一个疙瘩 整个战役战斗的过程,就是不断的提出问题和不断的回答问题的过程。 总之,对每一个问题都不能含糊了事,问题回答完了,战役、战斗的组织才算完成。 要及时下达决心 要有一个很好的很团结的班子 要有一个好的战斗作风 要重视政治,亲自做政治工作 微博链接:https://weibo.com/1401527553/PB3xN1Pag 原文链接:https://www.marxists.org/chinese/linbiao/mia-chinese-linbiao-193612.htm 智元机器人 CTO 彭志辉(稚晖君) 原文链接:https://mp.weixin.qq.com/s/yUzJY7taYx3-9H0OeMiLrg 很多人应该在 B 站上看过稚晖君的硬核科技视频。这篇文章算是稚晖君的人生小传,值得一看。 其中有一句话引起了我的思考: 彭志辉建议大家想想自己除了各种琐事,真正投入有效学习的时间究竟有多少。 我不禁反思,自己在决心要研究的方向实际究竟花了多久。应该聚焦在少数的方向,并真的投入了有效时间。 观点 来源:B 站纪录片《安藤忠雄:武士建筑师》 https://www.bilibili.com/bangumi/play/ep120908 安藤忠雄:要锻炼出创造性的身体,去看电影、听音乐会、去美术馆、看别人的建筑,然后要有想做得比他们更好的意愿,超越前人的勇气,体力衰退了,竞争意识就会变弱。 没有了创造性的身体,竞争意识就没了。这两个是一回事,要同时锻炼身体和意志 来源:https://yro.slashdot.org/comments.pl?sid=23765914&cid=65583466 8 月 11 号是苹果创始人史蒂夫.沃兹尼亚克的生日,他在这个站点上评论: 我捐出了所有来自苹果的财富,因为财富和权力并非我的追求。我享受生活的乐趣——在我的出生地圣何塞,我资助了许多重要的博物馆和艺术团体,他们以我的名字命名了一条街道以示认可。如今我从事公开演讲并成为行业顶尖,虽然不清楚具体资产数额,但经过 20 年的演讲事业,可能积累了约 1000 万美元和几处房产。我从不寻找任何避税手段,通过劳动获得的收入缴纳约 55%的综合税负。我是世界上最快乐的人。对我而言,生活从来不是关于成就,而是关于快乐,那正是笑容减去愁容的简单公式。这些人生理念在我 18 到 20 岁时就已形成,而我始终坚守至今。 最近看了《成为乔布斯》这本书以及听了播客半拿铁的《苹果简史》。对沃兹的印象就是他和乔布斯最开始的组合就是天使+魔鬼,沃兹是毫无疑问的赤子。 沃兹的这个留言更加印证了这个印象。 贝索斯的遗憾最小化原则:我们做任何决定,最终都有可能会后悔会遗憾,但是面对多个选择时,我们应该选择让自己后悔或者遗憾最少的那个。 原文链接:https://weibo.com/1401527553/PDdCPaKsk tombkeeper: 股市是世界上最可爱的东西。对于股市你可以随便怎么想,怎么想都可以,甚至觉得“川大智胜”和川普有关系都可以。 一切观点、一切想法都可以掏钱验证。如果你是对的,那就不光是对的,还能赚钱。所以如果你这么坚定地相信,就没道理不掏钱。除非,你其实也没那么相信。 社会是一个周期更长的股市。每个人最终都会在这个股市里得到和自己的想法相匹配的回报,一切想法也最终都会变成收益或亏损。而且社会这个股市是由不得你不掏钱的,退不了,缩不了,怂不了。 不光是钱,你的整个人生都会被投入进去。所以收获的也不光是钱,是你整个人生的悲喜荣哀。 所以,你可以随便怎么想,怎么舒服怎么想,或者,认真地思考自己的每一个判断,每一个决定,想清楚自己要的到底是简单、爽,还是有更想要的东西。 在股市人声鼎沸的当下,很有思考意义。

2025/8/20
articleCard.readMore

为什么准备写月刊?

我的博客之前多以技术文章为主(虽然现在基本是年更了),很少有自我表达的部分。主要我认为我的想法并不独特到可以分享出来让大家看到。 但最近一段时间看到一些观点,让我决定做出一些改变。 第一: 公众号文章 《人要大量地表达自己》,中间有几个观点非常触动我 真正的表达,不是为了炫耀,也无关于技巧,而是一种自我的袒露。你越愿意将内在推向外部,你就越清楚自己是谁、想要什么、和他人有什么不同。而你越害怕表达、越不敢袒露,你的“自我”就越容易被模糊、被裹挟、被湮没。久而久之,甚至你自己也不认识自己了。 表达像是一面镜子,一面不断校准自我认知的镜子。你表达得越多,越真实,就越靠近那个真实的“自己” 第二: 李继刚在 即刻中提到: 可以建立一个“人生周报”,让发生过的思考真正「存在」。 第三:最近看的《打造第二大脑》这本书中提到尽早输出,可以尽快得到反馈。 会在月刊里面分享什么东西? 目前短期的想法是,月刊会是我收藏夹和笔记的定期整理。我把其中比较有价值的、让人耳目一新的或者受人启发的内容挑选出来。 我并不确定这些内容是否其他人同样感兴趣或者觉得有价值,但希望它是一个关于自我思考的好的开始。 至于发布频率,虽然说是月刊,其实会是不定期的更新。 另外,下决定做月刊的时候,又习惯性的准备把文章发布时间地拖延到月底或者下月初。后来决定尽早做,时间节点并不重要,尽早开始更加重要。 在整理资料的时候,也发现了类似的想法: 纳瓦尔:我真的相信灵感是会过期的。我马上就会去做。所以当你有灵感去做某件事时,就去做。 在李继刚的开篇周刊中说:“以前想启动一件事时, 会想今天是不是一个好时候? 周三这个时间点, 当不当正不正的, 要不就下周一开始正式启动吧。现在想明白了一件事: 时间是人为规定的。 真想做,那就做。” 分享 最后分享一些我自己经常的周刊或者类周刊的读物: 阮一峰的科技爱好者周刊:https://www.ruanyifeng.com/blog/weekly/ flomo 官方出品的“小信号”:https://read.flomoapp.com/weekly/ 李继刚的人生周刊:https://mp.weixin.qq.com/s/l1mDjmgufhn8U5IFx_9EXQ 沉浸式翻译的作者"Owen 的博客": https://www.owenyoung.com/ 我之后自己的月刊,也会在这个索引页上定期更新。

2025/8/20
articleCard.readMore

std::any 的性能开销:基于 libstd++ 源码分析

C++17 中引入了 std::any,可以非常方便地将任意类型的变量放到其中,做到安全的类型擦除。然而万物皆有代价,这种灵活性背后必然伴随着性能取舍。 std::any 的实现本身也并不复杂,本文将基于 libstd++ 标准库源码 深入解析其实现机制与性能开销。 底层存储 std::any 需要解决的核心问题在于: 异构数据存储:如何统一管理不同尺寸的对象 类型安全访问:如何在擦除类型信息后仍能提供安全的类型查询。例如可以直接通过 std::any 提供的 type() 函数,直接获取到底层数据的类型信息。 从 libstd++ 源码中提取的关键类结构如下 1 2 3 4 5 class any { _Storage _M_storage; void (*_M_manager)(_Op, const any*, _Arg*); } 可以看到有两个核心变量: _M_storage:负责存储数据值本身或者指针。 _M_manager :函数指针,负责指向具体类型 template class 的实现,其中包含了类型信息。 我们先看 _M_storage 的实现: 1 2 3 4 5 union _Storage { void* _M_ptr; unsigned char _M_buffer[sizeof(_M_ptr)]; }; _Storage 类是一个 union 实现。里面包含两个属性:_M_ptr 和长度为 sizeof(_M_ptr) 的 char 数组 _M_buffer。即长度为指针大小,在 64 位机器下,_M_buffer 的长度是 8。 那么,在什么情况下分别使用 _M_ptr 和 _M_buffer 呢?主要通过以下模板变量进行编译期决策。 1 2 template<typename _Tp, typename _Safe = is_nothrow_move_constructible<_Tp>, bool _Fits = (sizeof(_Tp) <= sizeof(_Storage)) && (alignof(_Tp) <= alignof(_Storage))> using _Internal = std::integral_constant<bool, _Safe::value && _Fits>; 简单来说:_Tp 可以无异常移动构造 && _Tp 能完全放入 _Storage 中 。 这是一个非常典型的 SOO(Small Object Optimization 小对象优化)。即:对于小尺寸对象,直接在容器自身的连续内存中 (通常为栈内存) 完成存储,这样可以避免在堆上开辟新的内存。 因此: 对于小尺寸对象(≤指针大小),直接在 _M_buffer 中通过 placement new 创建对象。避免堆内存分配带来的性能开销,提升 CPU 缓存局部性(对高频访问的场景尤为重要)。 对于大尺寸对象,直接在堆上通过 new 申请内存,_M_storage 存储对应的指针。 但这个内存结构的设计,也存在着潜在的内存浪费:union 的内存等于最大字段的内存,因此即使在 std::any 中存储 1 字节的 char 类型变量,_M_storage 也需要 8 字节。 另外,我们发现在 _Storage 并未存储任何类型信息。但我们可以通过 std::any 的 type() 函数获取到对应的类型信息。这是如何做到呢? 接下来,我们看 _M_manager 的实现: std::any 的做法非常巧妙,将所有需要类型信息的操作,都通过一个 template class 的 static 函数来实现。std::any 对象中只存储这个函数的指针,即 void (*_M_manager)(_Op, const any*, _Arg*)。 1 2 3 4 5 6 template<typename _Tp> struct _Manager_internal { static void _S_manage(_Op __which, const any* __anyp, _Arg* __arg); }; 以 std::any 的 type() 函数实现为例, 代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const type_info& type() const noexcept { _Arg __arg; _M_manager(_Op_get_type_info, this, &__arg); return *__arg._M_typeinfo; } template<typename _Tp> void any::_Manager_internal<_Tp>:: _S_manage(_Op __which, const any* __any, _Arg* __arg) { switch (__which) { case _Op_get_type_info: __arg->_M_typeinfo = &typeid(_Tp); break; } } 我们可以看到,通过_M_manager找到对应template class的具体实现,直接调用typeid(_Tp)就可以获取到对应的 typeinfo 信息了。 但值得注意的是,在调用 _M_manager 函数的时候,额外传递了一个 enum 值 _Op_get_type_info。 这是 std::any 的特殊设计,通过枚举值区分不同的逻辑,将所有需要类型信息的操作都整合到一个函数入口。这样做仅用一个函数指针即可,可以节省内存开销。 总结 虽然 std::any 提供了极大的灵活性,且绝大部分场景下性能也够用。但根据我们对源码的深入分析,发现 std::any 的设计特点必然会带来一些额外的开销: 内存开销:在 64 位机器下固定占用 16 byte 空间(8 字节的_M_storage 和 8 字节的_M_manager 函数指针)。存储 1 字节数据时空间利用率仅 6.25%; 性能开销:小对象直接栈存储,对于大对象会触发堆分配。

2025/3/4
articleCard.readMore

从源码角度解读 enable_shared_from_this

我们在使用 C++ 的时候,有时会需要在类的内部获取自身的 shared_ptr,这就会用到 std::enable_shared_from_this。在实际使用过程中,std::enable_shared_from_this 有三个陷阱需要注意: 不能在构造函数中使用 shared_from_this(), 否则会抛出 std::bad_weak_ptr 异常。对应下面情况 1。 创建的对象必须由 shared_ptr 管理,shared_from_this() 才能生效,否则也会报 std::bad_weak_ptr 异常。对应下面情况 2。 对应类必须 public 继承 std::enable_shared_from_this, 不能是 protected 或 private 继承,否则也会报 std::bad_weak_ptr 异常。对应下面情况 3。 以上 case 均可以通过 wandbox 复现。 那么为什么会有这些限制呢?本文将从 std::enable_shared_from_this 的源码角度解读其原因。(本文基于 clang libc++ 的源码实现进行解读, 代码地址:shared_ptr.h#L1433) 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 #include <memory> // 情况 1:在构造函数中使用 shared_from_this class Case1 : public std::enable_shared_from_this<Case1>{ public: Case1(){ // 抛异常:terminating due to uncaught exception of type std::__1::bad_weak_ptr: bad_weak_ptr auto case1 = shared_from_this(); } }; // 情况 2:不使用 shared_ptr 管理对象 class Case2 : public std::enable_shared_from_this<Case2>{ public: std::shared_ptr<Case2> get_shared_ptr() { // 抛异常:terminating due to uncaught exception of type std::__1::bad_weak_ptr: bad_weak_ptr return shared_from_this(); } }; // 情况 3:未 public 继承 std::enable_shared_from_this class Case3 : std::enable_shared_from_this<Case3>{ public: std::shared_ptr<Case3> get_shared_ptr() { // 抛异常:terminating due to uncaught exception of type std::__1::bad_weak_ptr: bad_weak_ptr return shared_from_this(); } }; int main(){ // 情况 1 auto c1 = std::make_shared<Case1>(); // 情况 2 Case2* c2 = new Case2(); c2->get_shared_ptr(); // 情况 3 auto c3 = std::make_shared<Case3>(); c3->get_shared_ptr(); return 0; } 我把 enable_shared_from_this 的源码摘录下来,删掉了一些不太重要的逻辑以方便理解。代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 template <class _Tp> class enable_shared_from_this { mutable weak_ptr<_Tp> __weak_this_; public: shared_ptr<_Tp> shared_from_this() { return shared_ptr<_Tp>(__weak_this_); } template <class _Up> friend class shared_ptr; }; 从代码可以看出 enable_shared_from_this 核心的就是一个 weak_ptr 属性 __weak_this_ 。而 shared_from_this 其实就是把 weak_ptr 转换成 shared_ptr。 那么问题来了,__weak_this_ 是在什么时候设置呢?答案是:在创建 shared_ptr 对象的时候。 以下是 shared_ptr 中创建对象的逻辑,其中在 __enable_weak_this 中设置了 enable_shared_from_this 的 __weak_this_ 属性。 1 2 3 4 5 6 7 8 9 template <class _Yp, class _CntrlBlk> static shared_ptr<_Tp> __create_with_control_block(_Yp* __p, _CntrlBlk* __cntrl) _NOEXCEPT { shared_ptr<_Tp> __r; __r.__ptr_ = __p; __r.__cntrl_ = __cntrl; // 设置__weak_this_ __r.__enable_weak_this(__r.__ptr_, __r.__ptr_); return __r; } 在 __enable_weak_this 的实现中,因为 enable_shared_from_this 类里面将 shared_ptr<T> 设置为了 friend class。因此 shared_ptr 可以直接访问并设置 enable_shared_from_this 的 __weak_this_ 属性。 同时,__enable_weak_this 使用 SFINAE 实现了一个模板匹配,即:只有当满足 __enable_if_t<is_convertible<_OrigPtr*, const enable_shared_from_this<_Yp>*>::value, int> = 0 时(即对应类可以转换成 enable_shared_from_this,也就是类 public 继承了 enable_shared_from_this), 才会设置 __weak_this_。 否则会匹配到一个空实现。 1 2 3 4 5 6 7 8 9 10 11 12 13 // 匹配到 enable_shared_from_this template <class _Yp, class _OrigPtr, __enable_if_t<is_convertible<_OrigPtr*, const enable_shared_from_this<_Yp>*>::value, int> = 0> void __enable_weak_this(const enable_shared_from_this<_Yp>* __e, _OrigPtr* __ptr) _NOEXCEPT { typedef __remove_cv_t<_Yp> _RawYp; if (__e && __e->__weak_this_.expired()) { __e->__weak_this_ = shared_ptr<_RawYp>(*this, const_cast<_RawYp*>(static_cast<const _Yp*>(__ptr))); } } // 空实现 void __enable_weak_this(...) _NOEXCEPT {} 解读完源码之后,一切情况非常明了。我们再回头看下文章刚开始提到的三个陷阱: 情况 1:不能在构造函数中使用 shared_from_this()。这是因为整个过程是:先创建好了原始对象,再去设置 __weak_this_ 属性,最终才能得到一个 shared_ptr 对象。所以在执行原始对象的构造函数时,__weak_this_ 属性尚未设置,当然不能用 shared_from_this。 情况 2:创建的对象必须由 shared_ptr 管理,shared_from_this() 才能生效。这是因为,只有在 shared_ptr 里面才会设置 __weak_this_。 情况 3:对应类必须 public 继承 std::enable_shared_from_this。因为只有 public 继承,才能正确匹配到对应的 __enable_weak_this,从而设置 __weak_this_。

2025/1/3
articleCard.readMore

Context的错误使用引发Panic的问题复盘

我们有这么一段业务代码,在 Gin 的 API Handler 中,开了一个子 goroutine 写 DB,代码大概是这样: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package main import ( "github.com/gin-gonic/gin" "gorm.io/gorm" ) var db *gorm.DB func ServerHandler(c *gin.Context) { // 一些旁路逻辑,为了不影响接口耗时,在子goroutine中执行 go func() { db.WithContext(c).Exec("update xxx") }() // 一些后置逻辑 } 代码在测试阶段一直没啥问题,但是一上线立马出现了大面积的 panic。panic 的栈也非常奇怪,挂在了 mysql driver 里面: 1 2 3 4 5 6 7 8 9 10 11 12 13 panic: sync/atomic: store of nil value into Value goroutine 357413 [running]: sync/atomic.(*Value).Store(0xc004097ef0, {0x0,0x0}) /usr/local/go/src/sync/atomic/value.go:47 +0xeb github.com/go-sql-driver/mysql.(*atomicError).Set(..) /root/go/pkg/mod/github.com/go-sql-driver/mysql@v1.6.0/utils.go:831 github.com/go-sql-driver/mysql.(*mysqlConn).cancel(0xc004e6fc20, {0x0, 0x0}) /root/go/pkg/mod/github.com/go-sql-driver/mysql@v1.6.0/connection.go:435 +0x3d github.com/go-sql-driver/mysql.(*mysqlConn).startWatcher.func1() /root/go/pkg/mod/github.com/go-sql-driver/mysql@v1.6.0/connection.go:622 +0x192 created by github.com/go-sql-driver/mysql.(*mysqlConn).startWatcher /root/go/pkg/mod/github.com/go-sql-driver/mysql@v1.6.0/connection.go:611 +0x105 把 mysql driver 相关栈的源码扒出来,大概是这样: 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 func (mc *mysqlConn) startWatcher() { watcher := make(chan context.Context, 1) mc.watcher = watcher finished := make(chan struct{}) mc.finished = finished go func() { for { var ctx context.Context select { case ctx = <-watcher: case <-mc.closech: return } select { case <-ctx.Done(): // 监听ctx.Done() mc.cancel(ctx.Err()) case <-finished: case <-mc.closech: return } } }() } // finish is called when the query has canceled. func (mc *mysqlConn) cancel(err error) { // 这里设置了原子变量 mc.canceled.Set(err) mc.cleanup() } 具体的故障现象大概明确了: mysql driver 里面监听了context.Done(), 当 channel 返回时,将ctx.Err()设置到原子变量里面。 问题就在于:context.Done()虽然返回了,ctx.Err()却是 nil。这就导致了在 set 原子变量时直接 panic 了。 这个问题非常难以理解,因为根据 context 的源码来看,只要context.Done()返回了,ctx.Err()就不可能是 nil。而且这个问题在测试环境无法复现,问题排查暂时陷入了僵局。 错误的 Context 使用 虽然 panic 的原因暂未查明,但是仔细看下这段业务逻辑,就可以看出来一些问题。 首先,我们需要知道这个 context 在什么时候会触发 Done,也就是什么时候 cancel 的。翻下 Golang HTTP Server 的源码,事情一目了然: 1 2 3 4 5 6 7 8 9 10 func (c *conn) serve(ctx context.Context) { ... ctx, cancelCtx := context.WithCancel(ctx) c.cancelCtx = cancelCtx defer cancelCtx() // handle request .... } 在开始处理请求之前,HTTP Server 会创建一个 context 对象,在请求处理结束之后,会自动 cancel 这个 context。 也就是说:当 API Handler 的处理逻辑完成返回的时候,context 会主动 cancel。此时即使子 goroutine 的处理逻辑还没结束,db 请求也会取消。按照 mysql driver 的逻辑,应该会抛出来一个context canceled的 Err。 翻了下测试环境的日志,的确有偶发的context canceled。 之所以不是必现,是因为子 goroutine 之后还有后置的处理逻辑。如果子 goroutine 的处理逻辑快于接口的后续处理逻辑,那这个 Err 就不会触发。 实际上,这里业务代码对 Context 使用上出现了错误:在这个场景下,子 goroutine 的逻辑处理的生命周期实际上是和父层的逻辑完全没有关系,我们不需要用同一个 context 强行把两个逻辑的生命周期保持一致。 在这种情况下,子 goroutine 中可以用context.Background()创建一个新的 context 对象 ,和外部接口主逻辑的 context 分离开,以免受到影响。 按照这个逻辑更改完成之后,测试环境没有了context canceled错误,线上服务也正常恢复了。 问题虽然得到了解决,但是 panic 的原因还没有完全查明,问题的阴影仍然持续笼罩着: 按照我们的推断,应该只会返回 error,不会出现 panic。 这个问题对于线上和测试环境应该没有什么区别,为什么错误的表现却不一样? Gin 对 Context 的缓存 继续深扒下源码,这次找到了 Gin 对请求的处理过程:在每个处理过程中,都有对sync.Pool的使用。 对缓存的复用和清理一般是问题频发的根源,我们对这块着重进行了梳理,还真的找到了原因: gin.Context本质上是对c.Request.Context()的封装。所有对 Context 的 Done、Err 方法调用,都会转发给c.Request.Context()。 gin 会利用sync.Pool对gin.Context进行对象复用。每次从sync.Pool拿到一个 gin.Context 对象的时候,都会重置其 Request 属性。 1 2 3 4 5 6 7 8 9 10 11 12 13 // ServeHTTP conforms to the http.Handler interface. func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { // engine.pool是sync.Pool c := engine.pool.Get().(*Context) c.writermem.reset(w) // 重置Request属性 c.Request = req c.reset() engine.handleHTTPRequest(c) engine.pool.Put(c) } 1 2 3 4 5 6 7 8 9 // Done returns nil (chan which will wait forever) when c.Request has no Context. func (c *Context) Done() <-chan struct{} { return c.Request.Context().Done() } // Err returns nil when c.Request has no Context. func (c *Context) Err() error { return c.Request.Context().Err() } 梳理下来,所有的情况都可以得到解释。简单来说:请求 1 中开的子 goroutine 持有的 context 对象,会被请求 2 复用,造成并发问题。 存在这样一种 case:请求1的子goroutine,在ctx.Done返回,并且要准备取ctx.Err之前。context刚好被复用,并且新的请求还没有结束。 请求 1 中开启了子 goroutine ,正在监听 ctx.Done。整个外部处理逻辑结束,触发 HTTP Server 内部的 context cancel。此时,子 goroutine 中的ctx.Done channel 返回,准备去取context.Err()。同时请求 2 到来,复用了 context 对象。 因为线上环境请求非常频繁,context 对象会被立即复用。此时 context 对象的 Request 属性被替换成新的了,因为新的请求还在处理中, c.Request.Context().Err()当然会返回 nil 为什么测试环境很难复现: 测试环境请求非常稀疏:子 goroutine 在取ctx.Err()之前,如果没有其他请求到来并复用这个 context,是不会出现问题的。 怎么复现这个问题? 为了方便构造这种 case,我们需要复现两个充分必要条件: 条件 1:两个请求复用同一个 context 对象。 条件 2:请求 1 在处理 ctx.Err()之前的间隙,请求 2 复用其 context 对象,并重置 Request 对象。 对于条件 1,我们需要简单了解下 sync.Pool 的原理,具体可以看我的另外一篇博客 《深度分析 Golang sync.Pool 底层原理》: 禁用 GC: debug.SetGCPercent(0) 。因为每轮 GC 之后,sync.Pool 都会被强制清空。 设置 P 的个数为 1。因为sync.Pool会在每个 P 内部有一个私有对象和 localPool,只有设置为 1,才会保证一定可以复用上次请求的 context。 对于条件 2,其实只要请求 QPS 足够大,基本是可以必现的。我们使用 sleep 协调下两个请求,以模拟这种 case。代码如下: 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 package main import ( "context" "fmt" "net/http" "net/http/httptest" "runtime" "runtime/debug" "time" "github.com/gin-gonic/gin" ) func createTestGinServer() *gin.Engine { router := gin.Default() router.ContextWithFallback = true router.GET("/test1", func(c *gin.Context) { // 打印地址,以确认两次拿到了context是不是同一个 fmt.Printf("context Pointer address: %p\n", c) c.JSON(200, gin.H{ "message": "Hello, World!", }) go func() { select { case <-c.Done(): // 等待2秒,保证新的请求到来,覆盖c.Request time.Sleep(2 * time.Second) if c.Err() == nil { panic("context is done, but c.Err() is nil") } else { fmt.Printf("context done , and err is %s\n", c.Err()) } } }() }) router.GET("/test2", func(c *gin.Context) { time.Sleep(3 * time.Second) c.JSON(200, gin.H{ "message": "Hello, World!", }) }) return router } func callApi(router *gin.Engine, api string) { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", api, nil) // 模拟http server的cancel逻辑 ctx, cancelCtx := context.WithCancel(context.Background()) defer cancelCtx() req = req.WithContext(ctx) router.ServeHTTP(w, req) } func main() { // 禁用GC,防止sync.Pool被清空 debug.SetGCPercent(0) // 设置只有一个P,保证两次请求一定能复用同一个context对象 runtime.GOMAXPROCS(1) router := createTestGinServer() callApi(router, "/test1") // sleep 1s,保证子goroutine一定启动了 time.Sleep(1 * time.Second) // 重新一个耗时请求,模拟请求未结束的情况 callApi(router, "/test2") time.Sleep(5 * time.Second) } 总结 为了方便描述问题,这里还有个额外的情况没有说明:我们在使用 Gin 时开启了 ContextWithFallback,这是在是在Gin的v1.8.1版本引入的。 如果你的Gin版本在 v1.8.1 之前或者 v1.8.1 之后并开启了 ContextWithFallback,才会保证所有对gin.Context的Done()、Err() 函数的访问,全部转发给c.Request.Context() 。如果没有开启 ContextWithFallback, 实际上ctx.Done() channel 会永远阻塞, 并不会出现本文中的问题。 总结来说该问题的根源在于:不应该在子 goroutine 中继续使用gin.Context,即使不会 panic,也会导致高概率的context.Canceled错误。 我们之后应该如何避免: 方法一:其实可以将 gin 的 ContextWithFallback 设置为 false,这样这类问题都不会出现。 方法二:这种子 goroutine 的逻辑生命周期不需要和外部逻辑强行保持一致的 case, 直接利用context.Background创建一个新的 context 对象即可。 方法三:如果确实有场景需要在子 goroutine 中用 gin 的 Context,可以使用gin.Context.Copy函数复制出来一个新的 context 对象。

2024/5/6
articleCard.readMore

Go 1.22 可能将改变 for 循环变量的语义

几乎世界上每个 Golang 程序员都踩过一遍 for 循环变量的坑,而这个坑的解决方案已经作为实验特性加入到了 Go 1.21 中,并且有望在 Go 1.22 中完全开放。 举个例子,有这么段代码: 1 2 3 4 5 6 7 8 var ids []*int for i := 0; i < 10; i++ { ids = append(ids, &i) } for _, item := range ids { println(*item) } 可以试着在 playgound 里面运行下:go.dev/play/p/O8MVGtueGAf 答案是:打印出来的全是 10。 这个结果实在离谱。原因是因为在目前 Go 的设计中,for 中循环变量的定义是 per loop 而非 per iteration。也就是整个 for 循环期间,变量 i 只会有一个。以上代码等价于: 1 2 3 4 5 var ids []*int var i int for i = 0; i < 10; i++ { ids = append(ids, &i) } 同样的问题在闭包使用循环变量时也存在,代码如下: 1 2 3 4 5 6 7 var prints []func() for _, v := range []int{1, 2, 3} { prints = append(prints, func() { fmt.Println(v) }) } for _, print := range prints { print() } 根据上面的经验,闭包 func 中 fmt.Println(v),捕获到的 v 都是同一个变量。因此打印出来的都是 3。 在目前的 go 版本中,正常来说我们会这么解决: 1 2 3 4 5 var ids []*int for i := 0; i < 10; i++ { i := i // 局部变量 ids = append(ids, &i) } 定义一个新的局部变量, 这样无论闭包还是指针,每次迭代时所引用的内存都不一样了。 这个问题其实在 C++ 中也同样存在: wandbox.org/permlink/Se5WaeDb6quA8FCC。 但真的太容易搞错了,几乎每个 Go 程序员都踩过一遍,而且也非常容易忘记。即使这次记住了,下次很容易又会踩一遍。 甚至知名证书颁发机构 Let’s Encrypt 就踩过一样的坑 bug#1619047。代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // authz2ModelMapToPB converts a mapping of domain name to authz2Models into a // protobuf authorizations map func authz2ModelMapToPB(m map[string]authz2Model) (*sapb.Authorizations, error) { resp := &sapb.Authorizations{} for k, v := range m { // Make a copy of k because it will be reassigned with each loop. kCopy := k // 坑在这里 authzPB, err := modelToAuthzPB(&v) if err != nil { return nil, err } resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB}) } return resp, nil } 在这个代码中,开发人员显然是很清楚这个 for 循环变量问题的,为此专门写了一段 kCopy := k。但是没想到紧接着下一行就不小心用了 &v。 因为这个 bug,Let’s Encrypt 为此召回了 300 万份有问题的证书。 对现有程序的影响 Go 团队目前的负责人 Russ Cox 在 2022 年 10 月份的这个讨论 discussions/56010 里面,提到要修改 for 循环变量的语义,几乎是一呼百应。今年五月份,正式发出了这个提案proposal#60078。 在今年 8 月份发布的 Go 1.21 中已经带上了这个修改。只要开启 GOEXPERIMENT=loopvar 这个环境变量,for 循环变量的生命周期将变成每个迭代定义一次。 但毫无疑问,这是个 break change。如果代码中依赖了这个 for 循环变量是 per loop 的特性,那升级之后就会遇到问题。例如以下代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 func sum(list []int) int { m := make(map[*int]int) for _, x := range list { // 每次 & x 都是一样,因此一直追加写同一个元素 m[&x] += x } // 这个 for 循环只会执行一次,因为 m 的长度一定是 1 for _, sum := range m { return sum } return 0 } 另外,对于程序性能也会有轻微影响, 毕竟新的方案里面将重复分配 N 次变量。对于性能极其敏感的场景,用户可以自行把循环变量提到外面。 同样的改变在 C# 也发生过,并没有出现大问题。 这个方案预计最早在 Go 1.22 就会正式开启了。按照 Go 每年发两个版本的惯例,在 2024 年 2 月份,我们就可以正式用上这个特性,彻底抛弃 x := x 的写法 ~ 本文主要内容汇总自 go/wiki/LoopvarExperiment 和 proposal#60078

2023/11/29
articleCard.readMore

剖析Golang Bigcache的极致性能优化

Bigcache是用Golang实现的本地内存缓存的开源库,主打的就是可缓存数据量大,查询速度快。 在其官方的介绍文章《Writing a very fast cache service with millions of entries in Go》一文中,明确提出了bigcache的设计目标: 多: 缓存的元素数量非常大,可以达到百万级或千万级。 快: 对延迟有非常高的要求,平均延迟要求在5毫秒以内。redis、memcached之类的就不考虑在内了,毕竟用Redis还要多走一遍网络IO。 稳: 99.9分位延迟应在10毫秒左右,99.999分位延迟应在400毫秒左右。 目前有许多开源的cache库,大部分都是基于map实现的,例如go-cache,ttl-cache等。bigcache明确指出,当数据量巨大时,直接基于map实现的cache库将出现严重的性能问题,这也是他们设计了一个全新的cache库的原因。 本文将通过分析bigcache v3.1.0的源码,揭秘bigcache如何解决现有map库的性能缺陷,以极致的性能优化,实现超高性能的缓存库。 bigcache的设计思想 如何避免GC对map的影响 当map里面数据量非常大时,会出现性能瓶颈。这是因为在Golang进行GC时,会扫描map中的每个元素。当map足够大时,GC时间过长,会对程序的性能造成巨大影响。 根据bigcache介绍文章的测试,在缓存数据达到数百万条时,接口的99th百分位延迟超过了一秒。监测指标显示堆中超过4,000万个对象,GC的标记和扫描阶段耗时超过了4秒。这样的延迟对于bigcache来说是完全无法接受的。 这个问题在Go 1.5版本中有一项专门的优化(issue-9477):如果map的key和value中使用没有指针,那么GC时将无需遍历map。例如map[int]int、map[int]bool。这是当时的pull request: go-review.googlesource.com/c/go/+/3288。里面提到: Currently scanning of a map[int]int with 2e8 entries (~8GB heap) takes ~8 seconds. With this change scanning takes negligible time. 对2e8个元素的map[int]int上进行了测试,GC扫描时间从8秒减少到0。 为什么当map的key和value不包含指针时,可以省去对元素的遍历扫描呢?这是因为map中的int、bool这种不可能会和外部变量有引用关系: int、bool这种在map中存储的就是值本身。 map的key和value不可被寻址。也就是说,以map[int]int为例,外部没有办法取到这个key和value的指针,那也就无从引用了。 这个优化听起来非常强大好用,但是在Golang中指针无处不见,结构体指针、切片甚至字符串的底层实现都包含指针。一旦在map中使用它们(例如map[int][]byte、map[string]int),同样会触发垃圾回收器的遍历扫描。 bigcache的整体设计 bigcache整体设计的出发点都是基于上文提到的Golang对Map GC优化,整个设计思路包含几个方面: 数据分片存储,以降低锁冲突并提升并发量。 避免在map中存储指针,从而避免在GC时对map进行遍历扫描。 采用FIFO式的Ring Buffer设计,简化整体内存设计逻辑。 数据分shard 这是一个非常常见的数据存储优化手段。表面上bigcache中所有的数据是存在一个大cache里面,但实际上底层数据分成了N个不互重合的部分,每一个部分称为一个shard。 在Set或者Get数据时,先对key计算hash值,根据hash值取余得到目标shard,之后所有的读写操作都是在各自的shard上进行。 以Set方法为例: 1 2 3 4 5 func (c *BigCache) Set(key string, entry []byte) error { hashedKey := c.hash.Sum64(key) shard := c.getShard(hashedKey) return shard.set(key, hashedKey, entry) } 这么做的优势是可以减少锁冲突,提升并发量:当一个shard被加上Lock的时候,其他shard的读写不受影响。 在bigcache的设计中,对于shard有如下要求: 一旦建好,shard将不改变。这带来的两点好处: 不用再考虑shard变化时的数据迁移问题。 因为shard数组是固定不变的,因此从shard数组中根据hash值取目标shard的时候,就无需加锁了。 shard个数必须是2的平方数。这么做的好处是,对2的平方数取余可以改成位运算,会比传统的%快很多(根据不权威的benchmark,计算速度大概会有2倍左右的差距)。 1 2 3 4 func (c *BigCache) getShard(hashedKey uint64) (shard *cacheShard) { // shardMask: uint64(config.Shards - 1) return c.shards[hashedKey&c.shardMask] } bigcache的shard数默认值是1024。 map不存原始数据,避免GC遍历扫描 前文提到,map的key和value一旦涉及指针相关的类型,GC时就会触发遍历扫描。 因此在bigcache的设计中,shard中的map直接定义为了map[uint64]uint32 ,避免了存储任何指针。shard的结构体定义如下: 1 2 3 4 5 6 type cacheShard struct { ... hashmap map[uint64]uint32 entries queue.BytesQueue ... } 其中:hashmap的key是cache key的hash值,而value仅仅是个uint32。这显然不是我们Set的时候value的原始byte数组。 那value的原始值存在了哪里?答案是cacheShard中的另外一个属性entries queue.BytesQueue。 queue.BytesQueue是一个ring buffer的内存结构,本质上就是个超大的[]byte数组,里面存放了所有的原始数据。每个原始数据就存放在这个大[]byte数组中的其中一段。 hashmap中uint32的value值存放的就是value的原始值在BytesQueue中的数组下标。(其实并不只是原始的value值,里面也包含了key、插入时间戳等信息) 之所以用一个大的[]byte数组和ring buffer结构,除了方便管理和复用内存之外,一个更重要的原因是:对于[]byte数组, GC时只用看做一个变量扫描,无需再遍历全部数组。这样又避免了海量数据对GC造成的负担。 FIFO式的内存结构设计 bigcache在内存结构设计上完全遵循FIFO原则: 新增数据以及对旧数据的修改,都是直接Append新数据到BytesQueue中。基本不直接对内存进行修改和删除等。 每个数据项不可以定制单独的缓存时长,必须全部保持一致。这对数据淘汰非常友好,下文会详细讲述。 这样一整套设计约定下来,bigcache的逻辑变成非常简洁明了,但这样同时造成了bigcache的局限性。 Set过程 1 cache.Set("my-unique-key", []byte("value")) 前面讲述了bigcache的设计思想之后,Set的整个逻辑也就很清晰了: 计算key的hash值,得到对应的shard 将key和value等信息序列化成指定格式的[]byte, push到BytesQueue中。 根据BytesQueue返回的内存偏移量(也就是数组下标),将key(hash值)和value(数组下标)设置hashmap中。 这里需要注意的是,在bigcache的设计里面,Set时value一定得是个[]byte类型。 前文讲到,bigcache中所有的原始数据都会被塞到一个大的[]byte数组里。因此对于bigcache来说最理想的肯定是直接给到[]byte最为方便,否则还需要考虑序列化等问题。 BytesQueue是一个ring buffer设计,和其他ring buffer的结构大同小异,本文不再细究其实现了,。 除了正常的set逻辑外,还有一些额外的情况需要考虑在内: 问题1:如果key之前设置过,Set的时候会如何处理? 在其他cache库的实现中,这种情况一般是找到旧值、删除,然后把新值设置到旧值的位置。 但在bigcache中并不是这样,前文提到,bigcache的内存结构设计是FIFO式的,哪怕是有旧值的情况下,新值也不会复用其内存,依旧是push新的value到队列中。 那旧值将如何处理的呢?我们看下代码: 1 2 3 4 5 6 7 if previousIndex := s.hashmap[hashedKey]; previousIndex != 0 { if previousEntry, err := s.entries.Get(int(previousIndex)); err == nil { resetHashFromEntry(previousEntry) //remove hashkey delete(s.hashmap, hashedKey) } } 最核心的一句就是:delete(s.hashmap, hashedKey) 简单来说:之前的旧值并未从内存中移除,仅仅只是将其偏移量从s.hashmap中移除了,使得外部读不到。 那旧值什么时候会被淘汰呢?会有两种情况: 如果设置了CleanWindow ,且旧值刚好过时,会被清理的定时器自动淘汰 如果设置了MaxEntrySize 或者HardMaxCacheSize,当内存满时,也会触发最旧数据的淘汰。 在此之前,旧值的数据一直都会保留在内存中。 另外还有resetHashFromEntry ,这个逻辑主要是把entry中的hash部分的数值置为0。这么做只是打上一个已处理的标记,保证数据在淘汰的时候不再去调用OnRemove的callback而已。 其实这里还有个场景:当s.hashmap[hashedKey]存在value时,并不一定是设置过这个key,也有可能发生了hash碰撞。 按照上述逻辑,bigcache并未对hash碰撞做特殊处理,统一都把之前相同hash的旧key删除。 毕竟这只是缓存的场景,并不保证之前Set进去的数据一直会存在。 问题2:当ring buffer满时,无法继续push数据,bigcache会如何处理? 情况分成两种: 如果entries queue.BytesQueue 未达到设定的HardMaxCacheSize(最大内存上限),或者无HardMaxCacheSize要求,则直接扩容queue.BytesQueue 直到达到上限。不过扩容的时候,是创建了一个新的空[]byte数组,把原有数据copy过去。 如果内存已达上限,无法继续扩容,则会尝试删除最旧数据(无论是否过期),直至可以将数据放到BytesQueue中。如果这个时候新数据非常大,可能会为此淘汰掉许多旧数据。 Get和GetWithInfo 1 2 entry, _ := cache.Get("my-unique-key") fmt.Println(string(entry)) Get基本上是Set的逆过程,整个过程更简单一些,没有太多额外的知识可讲。不过在使用时,需要注意的是: Get时如果数据到达了过期时间,但暂时还没有被清掉,这个时候也能正常查到value,不会报错。 其实这个倒是符合大多数的实际需求场景,实际场景中其实对缓存过期时间并没有那么敏感,短时间读到旧值一般都是可以接受的。 如果对于缓存时间敏感的场景,可以使用GetWithInfo接口,返回值中有是否过期的标识。 删除 跟删除有关的核心逻辑只有这两行,整个逻辑和Set过程中清除旧值的一样: 1 2 3 4 5 ... delete(s.hashmap, hashedKey) ... resetHashFromEntry(wrappedEntry) ... 不过在调用bigcache.Delete接口时需要注意的是,如果key不存在时,会返回一个ErrEntryNotFound 过期淘汰 上面讲到删除逻辑和set时清除旧值时,都只是简单的把key从map中删除,不让外部读取到而已。那原始值什么时候删呢?答案就是过期淘汰。 bigcache有个设计上的优势:bigcache没有开放单个元素的可过期时间,所有元素的cache时长都是一样的,这就意味着所有元素的过期时间在队列中天然有序。 这就使得淘汰逻辑非常简单,代码如下: 1 2 3 4 5 6 7 8 9 10 11 func (s *cacheShard) cleanUp(currentTimestamp uint64) { s.lock.Lock() for { if oldestEntry, err := s.entries.Peek(); err != nil { break } else if evicted := s.onEvict(oldestEntry, currentTimestamp, s.removeOldestEntry); !evicted { break } } s.lock.Unlock() } 其实就是从头到尾遍历数组,直至元素不过期就跳出。 另外,即使淘汰过期数据时,数据也并未被真实的删除,仅仅对应于ring buffer中head和tail下标的移动。 这样整个删除过程非常轻量级,好处不仅在于逻辑更简单,更重要的是,淘汰时需要对整个shard加写锁,这种对有序数组的遍历删除,加锁的时间会非常短(当然也取决于这个时刻过期的数据条数)。 当然,这也意味着bigcache的局限性:数据过期模式非常简单,这种FIFO式的数据淘汰相比于LRU、LFU来说,缓存命中率会低不少。 此外从这里可以得知,哪怕是经过了淘汰,bigcache的内存也不会主动降下去,除非外部调用了Reset方法。因此在实际实践中,我们最好是控制好HardMaxCacheSize,以免OOM。 细节的极致优化 bigcache的主要逻辑已经基本讲完了,作为一个以性能为卖点的cache库,bigcache在细节上也有大量的性能优化: varint的使用: 在最开始讲bigcache中每个entry结构的设计时,图中有一个blocksize,代表数据entry的大小,用于bigcache确定数据边界。这里blocksize用到了varint来表示,可以一定程度上减小数据量。具体varint的介绍可以参考我的另外一篇文章《解读 Golang 标准库里的 varint 实现》。 buffer内存复用:在每次set数据的时候,上面varint和整个entry都需要动态地分配内存,bigcache这里在每个shard中内置了两个全局的buffer: headerBuffer 和entrybuffer ,避免了每次的内存分配。 自己实现fnv Hash: bigcache自己实现了一套fnv hash,并没有用go官方标准库的,这也是基于性能的考虑。在Go官方的实现中 hash/fnv/fnv.go,创建Fnv对象的时候,有这么一段逻辑: 1 2 3 4 func New32a() hash.Hash32 { var s sum32a = offset32 return &s } 根据Golang的逃逸分析,s这个变量在结束的时候会被外部用到,这样Go编译器会将其分配到堆上(逃逸到堆上)。 我们知道,直接在栈上操作内存比堆上更快速,因此bigcache实现了一个基于栈内存的fnv hash版本。 序列化问题 bigcache的介绍文章中也提到,JSON序列化问题成为了一个性能问题: While profiling our application, we found that the program spent a huge amount of time on JSON deserialization. Memory profiler also reported that a huge amount of data was processed by json.Marshal. 他们换成了ffjson来替换go标准库中的json操作,性能得到了不少的提升。 不过这样给我们提了个醒,如果不是海量数据,尚未达到map的gc瓶颈,倒是没有必要直接就上bigcache, 毕竟序列化所带来的开销也不算低。 附录:bigcache配置详解 bigcache.Config中有很多配置参数,这里大概列一下: 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 // Config for BigCache type Config struct { // Number of cache shards, value must be a power of two // shard个数。必须2的平方数。 Shards int // Time after which entry can be evicted // 最小粒度是秒,当CleanWindow设置的时候,一定要设置这个值 LifeWindow time.Duration // Interval between removing expired entries (clean up). // If set to <= 0 then no action is performed. Setting to < 1 second is counterproductive — bigcache has a one second resolution. // 如果没有设置,数据将不会被定时清理。最好大于1秒,因为bigcache的最小时间粒度就是秒 CleanWindow time.Duration // Max number of entries in life window. Used only to calculate initial size for cache shards. // When proper value is set then additional memory allocation does not occur. MaxEntriesInWindow int // Max size of entry in bytes. Used only to calculate initial size for cache shards. // 单条数据最大的size,并不会做强制约束,只是用来初始化cache大小用,这个是仅包含用户自己设置的key和value的大小。 MaxEntrySize int // StatsEnabled if true calculate the number of times a cached resource was requested. // 是否对每条数据都开启hit次数统计的功能 StatsEnabled bool // Verbose mode prints information about new memory allocation Verbose bool // Hasher used to map between string keys and unsigned 64bit integers, by default fnv64 hashing is used. // hash函数,默认是bigcache自己实现的fnv Hasher Hasher // HardMaxCacheSize is a limit for BytesQueue size in MB. // It can protect application from consuming all available memory on machine, therefore from running OOM Killer. // Default value is 0 which means unlimited size. When the limit is higher than 0 and reached then // the oldest entries are overridden for the new ones. The max memory consumption will be bigger than // HardMaxCacheSize due to Shards' s additional memory. Every Shard consumes additional memory for map of keys // and statistics (map[uint64]uint32) the size of this map is equal to number of entries in // cache ~ 2×(64+32)×n bits + overhead or map itself. // 最大内存数限制。 HardMaxCacheSize int // OnRemove is a callback fired when the oldest entry is removed because of its expiration time or no space left // for the new entry, or because delete was called. // Default value is nil which means no callback and it prevents from unwrapping the oldest entry. // ignored if OnRemoveWithMetadata is specified. OnRemove func(key string, entry []byte) // OnRemoveWithMetadata is a callback fired when the oldest entry is removed because of its expiration time or no space left // for the new entry, or because delete was called. A structure representing details about that specific entry. // Default value is nil which means no callback and it prevents from unwrapping the oldest entry. OnRemoveWithMetadata func(key string, entry []byte, keyMetadata Metadata) // OnRemoveWithReason is a callback fired when the oldest entry is removed because of its expiration time or no space left // for the new entry, or because delete was called. A constant representing the reason will be passed through. // Default value is nil which means no callback and it prevents from unwrapping the oldest entry. // Ignored if OnRemove is specified. OnRemoveWithReason func(key string, entry []byte, reason RemoveReason) // Logger is a logging interface and used in combination with `Verbose` // Defaults to `DefaultLogger()` Logger Logger }

2023/11/25
articleCard.readMore

解读 Golang 标准库里的 varint 实现

最近发现 Golang 标准库竟然自带了 varint 的实现,代码位置在 encoding/binary/varint.go,这个跟protobuf里面的varint实现基本是一致的。刚好借助 golang 标准库的 varint 源码,我们来系统地学习和梳理下 varint。 熟悉 protobuf 的人肯定对 varint 不陌生,protobuf 里面除了带 fix (如 fixed32、fixed64) 之外的整数类型, 都是 varint 编码。 varint 的出现主要是为了解决两个问题: 空间效率:以 uint64 类型为例,可以表示的最大值为 18446744073709551615。然而在实际业务场景中,我们通常处理的整数值远小于 uint64 的最大值。假设在我们的业务中,需要处理的整数值仅为 1,但在网络传输过程中,我们却需要使用 8 个字节来表示这个值。这就导致了大量的空间浪费,因为大部分字节并没有实际存储有效的信息。varint 编码通过使用可变长度的字节序列来表示整数,使得小的整数可以用更少的字节表示,提高空间效率。 兼容性:varint 使得我们可以在不改变编码 / 解码逻辑的情况下,处理不同大小的整数。这意味着我们可以在不破坏向后兼容性的情况下,将一个字段从较小的整数类型(如 uint32)升级到较大的整数类型(如 uint64) 本文将通过分析 Golang 标准库自带的 varint 源码实现,介绍 varint 的设计原理以及Golang标准库是如何解决 varint 在编码负数时遇到的问题。 varint 的设计原理 varint 的设计原理非常简单: 7bit 为一组:将整数的二进制表示分为 7 个 bit 位一组。从低位到高位,每 7 个 bit 位作为一个单元。 最高位表示 “继续” 标志。在每个 7 位的单元前面添加一个标志位,形成一个 8 位的字节。如果后面还有更多的字节,这个标志位就设置为 1,否则设置为 0。 例如:对于一个整数 300,它的二进制表示是 100101100。我们可以将它分为两组,即 10 和 0101100。然后在每组前面添加标志位,得到两个字节 00000010 和 10101100,这两个字节就是 300 的 varint 编码。相比于用 uint32 的 4 字节表示,少了 50% 的存储空间。 无符号整数的 varint 在 Golang 标准库中有两套 varint 的函数: 分别用于无符号整数的 PutUvarint 和 Uvarint,以及用于有符号整数的 Varint 和 PutVarint。 我们先看下无符号整数的 varint 实现,代码如下: 1 2 3 4 5 6 7 8 9 10 func PutUvarint(buf []byte, x uint64) int { i := 0 for x >= 0x80 { buf[i] = byte(x) | 0x80 x >>= 7 i++ } buf[i] = byte(x) return i + 1 } 代码里有个非常重要的常量:0x80,对应于二进制编码就是 1000 0000。这个常量对接下来的逻辑非常重要: x >= 0x80。这意味着 x 的二进制表示形式至少有 8 位,我们刚才讲到 7 个 bit 位为一组,那 x 就需要被拆分了。 byte(x) | 0x80。将 x 的最低 8 位与 1000 0000 进行按位或操作,然后将结果存储在 buf[i] 中。这样 既可以将最高位设置为 1,同时也提取出了 x 的最低 7 位。 x >>= 7. 将 x 右移 7 位,处理下一个分组。 buf[i] = byte(x)。当 for 循环结束时,即意味着 x 的二进制表示形式最高位必然是 0。此时就不用做额外的补零操作了。 经过编码之后,原数据的最低位将在byte数组的最开始的位置,最高位在byte数组的尾部。 而Uvarint 是 PutUvarint 的逆过程,实际上就是逐byte取元素还原,直到byte最高位是0,则还原结束。 需要注意的是,varint 将整数划分为 7 位一组。这意味着,对于大整数 varint 将会出现负向优化。例如对于 uint64 的最大值来说,本来只需要 8 个 byte 来表示,但在 varint 中却需要 10 个字节来表示了。(64/7 ≈ 10) 负数的编码:zigzag 编码 看似 varint 编码已经完美无缺了,但以上忽略了一点:负数的存在。 我们知道,在计算机中数字是用补码来表示的,而负数的补码则是将其绝对值按位取反再加 1。这就意味着一个很小的负数,它的二进制形式对应于一个非常大的整数。例如:对于一个 32 位的整数 -5 来说,其绝对值 5 的二进制形式是 101。 但 -5 的二进制形式却是 11111111111111111111111111111011,如果使用 varint 对其编码, 需要 5 个字节才能表示。 Golang标准库引入了 zigzag 编码来解决这个问题,zigzag 的原理非常简单: 对于正数 n,会将其映射为 2n。例如整数 2,经过 zigzag 编码之后变成了 4。 对于负数 -n 来说,会将其映射为 2n-1。例如负数 -3,经过 zigzag 编码之后变成了 5。 这样负数和正数在数值上完全不会冲突,正整数和负整数交错排列,这也是为什么叫做 zigzag 编码 (锯齿形编码)的原因。 同时,负数被转换成正数之后,二进制编码也精简了许多。 例如: 对 -5 进行 zigzag 编码后,变成了 9,对应于二进制为 00000000000000000000000000001001,使用 1 个字节即可表示 varint。 我们看下 Golang 标准库的实现,代码如下: 1 2 3 4 5 6 7 8 func PutVarint(buf []byte, x int64) int { // zigzag 编码 ux := uint64(x) << 1 if x < 0 { ux = ^ux } return PutUvarint(buf, ux) } 从代码可以看出,对于有符号整数的varint实现,golang标准库这里分成了两步: 先对整数进行 zigzag 编码进行转换 对转换之后的数值再进行 varint 编码 我们详细讲下 zigzag 编码的实现部分: 正数:ux := uint64(x) << 1。这个位运算左移一位,相当于 ux*2。对于正数,符合 ZigZag 编码。 负数:ux := uint64(x) << 1; ux = ^ux。负数这里就有些难以理解了,为什么这么转换之后就等于2n - 1了? 我们可以大概推导下整个过程,假设我们有个整数 -n: 对原数值先左移,再进行取反。其实可以看做:对原数值先取反,再左移,然后+1。 即 2*(~(-n))+1 我们知道负数的补码=绝对值按位取反+1,那如何根据补码再推导出绝对值?这里有个公式是:|A| = ~A+1 我们将这个公式带到第一步的式子里面: 2*(n-1) + 1 = 2n - 1。这就完美对应上了负数的 ZigZag 编码。 在 Golang 标准库里面,调用 PutUvarint 时只会使用 varint 编码,调用 PutVarint 会先进行 zigzag 编码,再进行 varint 编码。 而在 protobuf 中,如果类型是 int32、int64、uint32、uint64,只会使用 varint 编码。使用 sint32、sint64 将先进行 zigzag 编码,再进行 varint 编码 varint 不适用的情况 虽然 varint 编码设计非常精妙,但并不适用于所有的场景: 大整数:对于非常大的整数,varint 编码可能会比固定长度的编码更消耗空间。例如当所有的整数值域大于 2^63,那使用 varint 会用到 10 字节。相比于传统的八字节整数,反而多用了 25% 的空间 需要快速随机访问的数据:由于 varint 是变长编码,所以我们无法直接通过索引来访问特定的整数,必须从头开始解码,直到找到我们需要的整数。这使得 varint 编码不适合用于需要快速随机访问的数据。 需要频繁进行数学运算的数据:由于 varint 编码的数据需要先解码才能进行数学运算,所以如果一个应用需要频繁地对数据进行数学运算,那么使用 varint 编码可能会导致性能下降。 安全敏感的应用:varint 编码的数据可能会暴露出一些关于原始整数的信息,例如它的大小。在某些安全敏感的应用中,这可能是不可接受的。

2023/11/23
articleCard.readMore

深度分析 Golang sync.Pool 底层原理

sync.Pool 是 Golang 内置的对象池技术,可用于缓存临时对象,以缓解因频繁建立临时对象带来的性能损耗以及对 GC 带来的压力。 在许多知名的开源库中都可以看到 sync.Pool 的大量使用。例如,HTTP 框架 Gin 用 sync.Pool 来复用每个请求都会创建的 gin.Context 对象。 在 grpc-Go、kubernetes 等也都可以看到对 sync.Pool 的身影。 但需要注意的是,sync.Pool 缓存的对象随时可能被无通知的清除,因此不能将 sync.Pool 用于存储持久对象的场景。 sync.Pool 作为 goroutine 内置的官方库,其设计非常精妙。sync.Pool 不仅是并发安全的,而且实现了 lock free,里面有许多值得学习的知识点。 本文将基于 go-1.16 的源码 对 sync.Pool 的底层实现一探究竟。 基本用法 在正式讲 sync.Pool 底层之前,我们先看下 sync.Pool 的基本用法。其示例代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 type Test struct { A int } func main() { pool := sync.Pool{ New: func() interface{} { return &Test{ A: 1, } }, } testObject := pool.Get().(*Test) println(testObject.A) // print 1 pool.Put(testObject) } sync.Pool 在初始化的时候,需要用户提供一个对象的构造函数 New。用户使用 Get 来从对象池中获取对象,使用 Put 将对象归还给对象池。整个用法还是比较简单的。 接下来,让我们详细看下 sync.Pool 是如何实现的。 sync.Pool 的底层实现 在讲 sync.Pool 之前,我们先聊下 Golang 的 GMP 调度。在 GMP 调度模型中,M 代表了系统线程,而同一时间一个 M 上只能同时运行一个 P。那么也就意味着,从线程维度来看,在 P 上的逻辑都是单线程执行的。 sync.Pool 就是充分利用了 GMP 这一特点。对于同一个 sync.Pool ,它在每个 P 上都分配了一个本地对象池 poolLocal。如下图所示。 sync.Pool 的 代码定义如下 sync/pool.go#L44: 1 2 3 4 5 6 7 8 9 10 11 type Pool struct { noCopy noCopy local unsafe.Pointer // local fixed-size per-P pool, actual type is [P]poolLocal localSize uintptr // size of the local array victim unsafe.Pointer // local from previous cycle victimSize uintptr // size of victims array New func() interface{} } 其中,我们需要着重关注以下三个字段: local 是个数组,长度为 P 的个数。其元素类型是 poolLocal。这里面存储着各个 P 对应的本地对象池。可以近似的看做 [P]poolLocal。 localSize。代表 local 数组的长度。因为 P 可以在运行时通过调用 runtime.GOMAXPROCS 进行修改, 因此我们还是得通过 localSize 来对应 local 数组的长度。 New 就是用户提供的创建对象的函数。这个选项也不是必需。当不填的时候,Get 有可能返回 nil。 其他几个字段我们暂时不用太过关心,这里先简单介绍下: victim 和 victimSize。这一对变量代表了上一轮清理前的对象池,其内容语义 local 和 localSize 一致。victim 的作用还会在下面详细介绍到。 noCopy 是 Golang 源码中禁止拷贝的检测方法。可以通过 go vet 命令检测出 sync.Pool 的拷贝。这个在另外一篇文章 Golang WaitGroup 原理深度剖析 中也有讲到,这里不再展开讨论了。 由于每个 P 都有自己的一个本地对象池 poolLocal,Get 和 Put 操作都会优先存取本地对象池。由于 P 的特性,操作本地对象池的时候整个并发问题就简化了很多,可以尽量避免并发冲突。 我们再看下本地对象池 poolLocal 的定义,如下: 1 2 3 4 5 6 7 8 9 10 11 // 每个 P 都会有一个 poolLocal 的本地 type poolLocal struct { poolLocalInternal pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte } type poolLocalInternal struct { private interface{} shared poolChain } pad 变量的作用在下文会讲到,这里暂时不展开讨论。我们可以直接看 poolLocalInternal 的定义,其中每个本地对象池,都会包含两项: private 私有变量。Get 和 Put 操作都会优先存取 private 变量,如果 private 变量可以满足情况,则不再深入进行其他的复杂操作。 shared。其类型为 poolChain,从名字不难看出这个是链表结构,这个就是 P 的本地对象池了。 poolChain 的实现 poolChain 的整个存储结构如下图所示: 从名字大概就可以猜出,poolChain 是个链表结构,其链表头 HEAD 指向最新分配的元素项。链表中的每一项是一个 poolDequeue 对象。poolDequeue 本质上是一个 ring buffer 结构。其对应的代码定义如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 type poolChain struct { head *poolChainElt tail *poolChainElt } type poolChainElt struct { poolDequeue next, prev *poolChainElt } type poolDequeue struct { headTail uint64 vals []eface } 为什么 poolChain 是这么一个链表 + ring buffer 的复杂结构呢?简单的每个链表项为单一元素不行吗? 使用 ring buffer 是因为它有以下优点: 预先分配好内存,且分配的内存项可不断复用。 由于ring buffer 本质上是个数组,是连续内存结构,非常利于 CPU Cache。在访问poolDequeue 某一项时,其附近的数据项都有可能加载到统一 Cache Line 中,访问速度更快。 ring buffer 的这两个特性,非常适合于 sync.Pool的应用场景。 我们再注意看一个细节,poolDequeue 作为一个 ring buffer,自然需要记录下其 head 和 tail 的值。但在 poolDequeue 的定义中,head 和 tail 并不是独立的两个变量,只有一个 uint64 的 headTail 变量。 这是因为 headTail 变量将 head 和 tail 打包在了一起:其中高 32 位是 head 变量,低 32 位是 tail 变量。如下图所示: 为什么会有这个复杂的打包操作呢?这个其实是个非常常见的 lock free 优化手段。我们在 《Golang WaitGroup 原理深度剖析》 一文中也讨论过这种方法。 对于一个 poolDequeue 来说,可能会被多个 P 同时访问(具体原因见下文 Get 函数中的对象窃取逻辑),这个时候就会带来并发问题。 例如:当 ring buffer 空间仅剩一个的时候,即 head - tail = 1 。 如果多个 P 同时访问 ring buffer,在没有任何并发措施的情况下,两个 P 都可能会拿到对象,这肯定是不符合预期的。 在不引入 Mutex 锁的前提下,sync.Pool 是怎么实现的呢?sync.Pool 利用了 atomic 包中的 CAS 操作。两个 P 都可能会拿到对象,但在最终设置 headTail 的时候,只会有一个 P 调用 CAS 成功,另外一个 CAS 失败。 1 atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) 在更新 head 和 tail 的时候,也是通过原子变量 + 位运算进行操作的。例如,当实现 head++ 的时候,需要通过以下代码实现: 1 2 3 const dequeueBits = 32 atomic.AddUint64(&d.headTail, 1<<dequeueBits) Put 的实现 我们看下 Put 函数的实现。通过 Put 函数我们可以把不用的对象放回或者提前放到 sync.Pool 中。Put 函数的代码逻辑如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func (p *Pool) Put(x interface{}) { 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() } 从以上代码可以看到,在 Put 函数中首先调用了 pin()。pin 函数非常重要,它有三个作用: 初始化或者重新创建local数组。 当 local 数组为空,或者和当前的 runtime.GOMAXPROCS 不一致时,将触发重新创建 local 数组,以和 P 的个数保持一致。 取当前 P 对应的本地缓存池 poolLocal。其实代码逻辑很简单,就是从 local 数组中根据索引取元素。这段的逻辑如下: 1 2 3 4 func indexLocal(l unsafe.Pointer, i int) *poolLocal { lp := unsafe.Pointer(uintptr(l) + uintptr(i)*unsafe.Sizeof(poolLocal{})) return (*poolLocal)(lp) } 防止当前 P 被抢占。 这点非常重要。在 Go 1.14 以后,Golang 实现了抢占式调度:一个 goroutine 占用 P 时间过长,将会被调度器强制挂起。如果一个 goroutine 在执行 Put 或者 Get 期间被挂起,有可能下次恢复时,绑定就不是上次的 P 了。那整个过程就会完全乱掉。因此,这里使用了 runtime 包里面的 procPin,暂时不允许 P 被抢占。 接着,Put 函数会优先设置当前 poolLocal 私有变量 private。如果设置私有变量成功,那么将不会往 shared 缓存池写了。这样操作效率会更高效。 如果私有变量之前已经设置过了,那就只能往当前 P 的本地缓存池 poolChain 里面写了。我们接下来看下,sync.Pool 的每个 P 的内部缓存池 poolChain 是怎么实现的。 在 Put 的时候,会去直接取 poolChain 的链表头元素 HEAD: 如果 HEAD 不存在 ,则新建一个 buffer 长度为 8 的 poolDequeue,并将对象放置在里面。 如果 HEAD 存在,且 buffer 尚未满,则将元素直接放置在 poolDequeue 中。 如果 HEAD 存在,但 buffer 满了,则新建一个新的 poolDequeue,长度为上个 HEAD 的 2 倍。同时,将 poolChain 的 HEAD 指向新的元素。 Put 的过程比较简单,整个过程不需要和其他 P 的 poolLocal 进行交互。 Get 的实现 在了解 Put 是如何实现后,我们接着看 Get 的实现。通过 Get 操作,可以从 sync.Pool 中获取一个对象。 相比于 Put 函数,Get 的实现更为复杂。不仅涉及到对当前 P 本地对象池的操作,还涉及对其他 P 的本地对象池的对象窃取。其代码逻辑如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func (p *Pool) Get() interface{} { l, pid := p.pin() x := l.private l.private = nil if x == nil { x, _ = l.shared.popHead() if x == nil { x = p.getSlow(pid) } } runtime_procUnpin() if x == nil && p.New != nil { x = p.New() } return x } 其中 pin() 的作用和 private 对象的作用,和 PUT 操作中的一致,这里就不再赘述了。我们着重看一下其他方面的逻辑: 首先,Get 函数会尝试从当前 P 的 本地对象池 poolChain 中获取对象。从当前 P 的 poolChain 中取数据时,是从链表头部开始取数据。 具体来说,先取位于链表头的 poolDequeue,然后从 poolDequeue 的头部开始取数据。 如果从当前 P 的 poolChain 取不到数据,意味着当前 P 的缓存池为空,那么将尝试从其他 P 的缓存池中 窃取对象。这也对应 getSlow 函数的内部实现。 在 getSlow 函数,会将当前 P 的索引值不断递增,逐个尝试从其他 P 的 poolChain 中取数据。注意,当尝试从其他 P 的 poolChain 中取数据时,是从链表尾部开始取的。 1 2 3 4 5 6 for i := 0; i <int(size); i++ { l := indexLocal(locals, (pid+i+1)%int(size)) if x, _ := l.shared.popTail(); x != nil { return x } } 在对其他 P 的 poolChain 调用 popTail,会先取位于链表尾部的 poolDequeue,然后从 poolDequeue 的尾部开始取数据。如果从这个 poolDequeue 中取不到数据,则意味着该 poolDequeue 为空,则直接从该 poolDequeue 从 poolChain 中移除,同时尝试下一个 poolDequeue。 如果从其他 P 的本地对象池,也拿不到数据。接下来会尝试从 victim 中取数据。上文讲到 victim 是上一轮被清理的对象池, 从 victim 取对象也是 popTail 的方式。 最后,如果所有的缓存池都都没有数据了,这个时候会调用用户设置的 New 函数,创建一个新的对象。 sync.Pool 在设计的时候,当操作本地的 poolChain 时,无论是 push 还是 pop,都是从头部开始。而当从其他 P 的 poolChain 获取数据,只能从尾部 popTail 取。这样可以尽量减少并发冲突。 对象的清理 sync.Pool 没有对外开放对象清理策略和清理接口。我们上面讲到,当窃取其他 P 的对象时,会逐步淘汰已经为空的 poolDequeue。但除此之外,sync.Pool 一定也还有其他的对象清理机制,否则对象池将可能会无限制的膨胀下去,造成内存泄漏。 Golang 对 sync.Pool 的清理逻辑非常简单粗暴。首先每个被使用的 sync.Pool,都会在初始化阶段被添加到全局变量 allPools []*Pool 对象中。Golang 的 runtime 将会在 每轮 GC 前,触发调用 poolCleanup 函数,清理 allPools。代码逻辑如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 func poolCleanup() { for _, p := range oldPools { p.victim = nil p.victimSize = 0 } for _, p := range allPools { p.victim = p.local p.victimSize = p.localSize p.local = nil p.localSize = 0 } oldPools, allPools = allPools, nil } 这里需要正式介绍下 sync.Pool 的 victim(牺牲者) 机制,我们在 Get 函数的对象窃取逻辑中也有提到 victim。 在每轮 sync.Pool 的清理中,暂时不会完全清理对象池,而是将其放在 victim 中。等到下一轮清理,才完全清理掉 victim。也就是说,每轮 GC 后 sync.Pool 的对象池都会转移到 victim 中,同时将上一轮的 victim 清空掉。 为什么这么做呢? 这是因为 Golang 为了防止 GC 之后 sync.Pool 被突然清空,对程序性能造成影响。因此先利用 victim 作为过渡,如果在本轮的对象池中实在取不到数据,也可以从 victim 中取,这样程序性能会更加平滑。 victim 机制最早用在 CPU Cache 中,详细可以阅读这篇 wiki: Victim_cache。 其他的优化 false sharing 问题的避免 1 2 3 4 5 6 7 type poolLocal struct { poolLocalInternal // Prevents false sharing on widespread platforms with // 128 mod (cache line size) = 0 . pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte } 我们在上面讲到 poolLocal 时,会发现这么一个奇怪的结构:poolLocal 有一个 pad 属性,从这个属性的定义方式来看,明显是为了凑齐了 128 Byte 的整数倍。为什么会这么做呢? 这里是为了避免 CPU Cache 的 false sharing 问题:CPU Cache Line 通常是以 64 byte 或 128 byte 为单位。在我们的场景中,各个 P 的 poolLocal 是以数组形式存在一起。假设 CPU Cache Line 为 128 byte,而 poolLocal 不足 128 byte 时,那 cacheline 将会带上其他 P 的 poolLocal 的内存数据,以凑齐一整个 Cache Line。如果这时,两个相邻的 P 同时在两个不同的 CPU 核上运行,将会同时去覆盖刷新 CacheLine,造成 Cacheline 的反复失效,那 CPU Cache 将失去了作用。 CPU Cache 是距离 CPU 最近的 cache,如果能将其运用好,会极大提升程序性能。Golang 这里为了防止出现 false sharing 问题,主动使用 pad 的方式凑齐 128 个 byte 的整数倍,这样就不会和其他 P 的 poolLocal 共享一套 CacheLine。 sync.Pool 的性能之道 回顾下 sync.Pool 的实现细节,总结来说,sync.Pool 利用以下手段将程序性能做到了极致: 利用 GMP 的特性,为每个 P 创建了一个本地对象池 poolLocal,尽量减少并发冲突。 每个 poolLocal 都有一个 private 对象,优先存取 private 对象,可以避免进入复杂逻辑。 在 Get 和 Put 期间,利用 pin 锁定当前 P,防止 goroutine 被抢占,造成程序混乱。 在获取对象期间,利用对象窃取的机制,从其他 P 的本地对象池以及 victim 中获取对象。 充分利用 CPU Cache 特性,提升程序性能。 参考 en.wikipedia.org/wiki/Victim_cache 伪共享(false sharing),并发编程无声的性能杀手

2021/7/18
articleCard.readMore

os.Chmod 时到底用 777 还是 0777?

问题是这样的:我在代码里面调用了 os.Chmod("test.txt", 777),希望把该文件的读写及执行权限对所有用户开放。 执行完代码,顺手 ls 看了下。如下: 1 2 $ ls -l test.txt -r----x--x 1 cyhone 1085706827 0 Jun 20 13:27 test.txt 结果出乎意料,不仅文件权限没有按预期的变成 rwxrwxrwx。反而执行完后,当前用户就只剩可读权限了,其他用户就只有可执行权限同时无读写权限。 因为这实在是一个简单又愚蠢的错误,所以先直接给出结论: 在 C 语言和 Go 语言中,如果想要将文件权限形式修改为 rwxrwxrwx,需要写成 0777,而非 777。 0777 是八进制格式,777 是十进制格式。在用 Go 语言表示此类权限的时候,如果要对标 chmod 命令的表示形式,用八进制表示更方便和准确点。 如果不是在代码里,而是在命令行直接调 chmod 的话,那 0777 和 777 都可以。 这个问题虽然非常简单,但尴尬的是我还踩了坑,所以把这个问题及原因分享出来。 原因 为什么 rwxrwxrwx 对应的是八进制的 0777,而不是 777 呢?。 原因是,底层在将数字翻译成对应权限时,实际上用的该数字对应的二进制位,并将后 9 位逐位翻译。 例如,对应八进制 0777 来说,其二进制的表示如下: 从上图来看,0777 就代表了 rwxrwxrwx。 而对于十进制的 777,其二进制的表示形式如下: 从其按位翻译来看,恰好 777 的后 9 位,就代表了 r----x--x, 和我们的运行结果一致。 那么话说回来,根据这个理论,如果非要用十进制表示 rwxrwxrwx,那么应该是 511。 我们可以用代码实验下: 1 2 fileMode := os.FileMode(511) fmt.Println(fileMode.String()) // -rwxrwxrwx 从结果看的确是符合预期的。 参考 Is there any difference between mode value 0777 and 777?

2021/6/20
articleCard.readMore

一个 Gin 缓存中间件的设计与实现

我们在开发 HTTP Server 的时候,经常有对接口内容做缓存的需求。例如,对于某些热点内容,我们希望做 1 分钟内的缓存。短期内缓存相同内容不会对业务造成实质影响,同时也会降低系统的整体负载。 有时我们需要把缓存逻辑放在 Server 内部,而非网关侧如 Nginx 等,是因为这样我们可以根据需要便捷地清除缓存,或者可以使用 Redis 等其他存储介质作为缓存后端。 这样的缓存场景无非是有缓存时从缓存取,无缓存时从下游服务取,并将数据放入缓存中。这其实是个非常通用的逻辑,应该可以将其抽象出来。从而缓存逻辑无需侵入进业务代码。 我常用的 HTTP 框架是 golang 的 gin。gin 官方就有一个 cache 组件:github.com/gin-contrib/cache,但这个 cache 组件无论在性能还是接口设计上,都有一些不足之处。 因此,我重新设计了一套 cache 中间件: gin-cache。 从压测结果来看,其性能相比于 gin-contrib/cache 明显提升。 gin-contrib/cache 的问题分析 gin-contrib/cache 是 gin 官方提供的一个 cache 组件,但这个组件在性能还是接口设计上,都并不令人满意。如下: 接口设计 gin-contrib/cache 对外提供的使用方式是 wrap handler 的方式,而非更加优雅和通用的 middleware。 如: 1 2 3 cache.CachePage(store, time.Minute, func(c *gin.Context) { c.String(200, "pong"+fmt.Sprint(time.Now().Unix())) }) 用户无法根据请求自定义地生成 cache key。gin-contrib/cache 只提供了 CachePage、CachePageWithoutQuery 等函数,用户可以根据 url 作为缓存的 key。但该组件并不支持自定义 cache key。对于一些特殊场景,将无法满足需求。 性能方面 该组件写入 cache 的方式是,重载了 ResponseWriter 的 Write 函数。每次在 gin 中调用 Write 函数时,都会触发一次缓存的 get 和 append 操作。这种边写边 cache 的过程,其性能显然是比较糟糕的。 最糟糕的是关于并发安全的实现。由于该组件写缓存之前需要先 get 原始内容进行拼接,这个过程并非是原子的。为了保证在最 HTTP Server 基本的并发安全性,该组件在对外提供的 CachePageAtomic 接口,加了一把互斥锁来保证缓存不会写冲突, 代码如下。这把互斥锁会使得在并发越大的情况下,反而接口性能会越差。 1 2 3 4 5 6 7 8 9 func CachePageAtomic(store persistence.CacheStore, expire time.Duration, handle gin.HandlerFunc) gin.HandlerFunc { var m sync.Mutex p := CachePage(store, expire, handle) return func(c *gin.Context) { m.Lock() defer m.Unlock() p(c) } } 关于性能的这两个方面,让我着实踩了一些坑。针对于性能方面的第一项,我也对 gin-contrib/cache 提了一个 pull request。但是其他方面, 尤其是接口设计方面,让我觉得这个库或许不是最终的答案。 在踩了这个库的坑后,我决定不如实现一个新的库,可以满足我这些需求。 新的方案 在踩了 gin-contrib/cache 的坑后,gin-cache 就随之诞生。其具体实现可以看 github.com/chenyahui/gin-cache。 1 2 3 4 5 6 7 8 9 app.GET("/hello", cache.CacheByPath(cache.Options{ CacheDuration: 5 * time.Second, CacheStore: persist.NewMemoryStore(1 * time.Minute), }), func(c *gin.Context) { c.String(200, "hello world") }, ) 对外提供的形式是 Middleware 的方式 用户可以根据自己需要自定义 cachekey 的生成方式。例如,可以根据 Header 内容或者 body 内容进行 Cache 。​自定义方式如下: 1 2 3 4 5 6 Cache( func(c *gin.Context) (string, bool) { return c.Request.RequestURI, true }, options, ) 当然,我也提供了一些快捷方法:CacheByURI 和 CacheByPath,分别以 url 为 key 以及忽略 url 中的 query 参数为 key 进行 cache,这样可以满足大部分的需求。 在性能方面,相比于 gin-contrib/cache 边写边 cache 的方式,gin-cache 只会在整个 handler 结束后,cache 最终的 response 内容。整个过程只会涉及一次写 cache 的操作。 除此之外,gin-cache 也有其他方面的性能优化: sync.Pool 来优化高频对象的创建和释放。 使用 singleflight 解决缓存击穿问题. 缓存击穿问题 在缓存设计中,会遇到一个常见的问题: 缓存击穿 。缓存击穿指的是:当某个热点 key 在其缓存过期的一瞬间,大量的请求将访问不到这个 key 对应的缓存,这时请求将直接打到下游存储或服务中。一瞬间的大量请求,可能会对下游服务造成极大压力。 关于此问题,golang 官方有一个 singleflight 库: golang.org/x/sync/singleflight,可以有效的解决缓存击穿问题。其原理非常简单,有兴趣的可以直接在 Github 搜源码看就可以了,本文不再展开讨论。 benchmark 使用 Linux CPU 8 核,16G 内存的系统配置下,我使用 wrk 对 gin-contrib/cache 和 gin-cache 做了 benchmark 压力测试。 我们使用如下命令进行压测: 1 wrk -c 500 -d 1m -t 5 http://127.0.0.1:8080/hello 我们分别对MemoryCache和RedisCache两种存储后端进行了压测。从下图来看,最终的压测结果非常惊人。 对于MemoryCache进程内缓存这个场景,gin-cache提升了23%。 对于Redis做缓存后端这个场景,gin-cache相比来说QPS更是提升了30倍左右。当然这也得益于gin-cache使用的redis client库的性能更好。 而且,从二者的设计对比来看,在当 handler 请求耗时越大,gin-cache 的优势将更加明显。

2021/6/14
articleCard.readMore

高性能服务之优雅终止

「优雅终止」指的是当服务需要下线或者重启时,通过一些措施和手段,一方面能够让其他服务尽快的感知到当前服务的下线,另一方面也尽量减小对当前正在处理请求的影响。优雅终止可提升服务的高可用,减少下线造成的服务抖动,提升服务稳定性和用户体验。 下线服务不仅仅是运维层面的工作,需要整个 RPC 实现、服务架构以及运维体系的配合,才能完美的实现服务的优雅下线。本文将基于服务下线的整个流程,分析如何实现微服务的优雅终止。主要包含以下方面: 服务注册中心的主动下线 基于 gRPC-Go 的源码,分析 gRPC 如何实现优雅终止 探讨 k8s 的优雅终止 服务注册中心的主动下线 如果服务使用了服务注册中心(例如 Consul、etcd 等),那第一步就是首先将服务从注册中心下线。这样可以尽快保证新的请求不会打到这台节点上。 虽然绝大部分的服务注册中心都有节点的心跳和超时自动清理的机制,但是心跳也是有固定间隔的,注册中心需要等到预设的心跳超时后才能发现节点的下线。因此,主动下线可以极大缩短这个异常发现的过程。 如果服务是基于 k8s 进行管理和调度,那这件事情就做起来非常方便了。 首先,k8s 本身自带了一个可靠的 服务发现,在 k8s 上进行 pod 的上下线,k8s 自然都会第一时间感知到。 如果使用的是外置的名字服务,则可以使用 k8s 的 preStop 功能。k8s 原生支持了 容器生命周期回调, 我们可以定义 pod 的 preStop 钩子,来实现服务下线前的清理操作。如下: 例如: 1 2 3 4 5 6 7 containers: - name: my-app-container image: my-app-image lifecycle: preStop: exec: command: ["/bin/sh","-c","/app/pre_stop.sh"] pod在下线之前,首先会执行 /app/pre_stop.sh 命令,在这个命令中,我们可以做很多预清理策略。 RPC 的优雅终止 将服务节点从名字服务中摘除,可以阻挡新流量进入到该节点,这是优雅终止的第一步。但是,对于该节点上已建立的客户端连接,如果贸然下线,将会造成正在的业务逻辑的突然中止。因此,我们需要实现RPC级别的,对连接和请求处理进行优雅终止,以保证业务逻辑尽量少的受到影响。 以 gRPC-Go 为例,gRPC 实现了两个停止接口 GracefulStop 和 Stop,分别代表服务的优雅终止和非优雅终止。我们来看下 gRPC 是如何优雅终止的。 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 func (s *Server) GracefulStop() { s.quit.Fire() defer s.done.Fire() ... s.mu.Lock() // 首先关闭监听 socket,保证不会有新的连接到来 for lis := range s.lis { lis.Close() } s.lis = nil if !s.drain { for st := range s.conns { st.Drain() } s.drain = true } // Wait for serving threads to be ready to exit. Only then can we be sure no // new conns will be created. s.mu.Unlock() s.serveWG.Wait() s.mu.Lock() for len(s.conns) != 0 { s.cv.Wait() } ... s.mu.Unlock() } 第一步: 调用 s.quit.Fire()。当该语句执行后,gRPC对于所有新 Accept 到来的连接,都会直接丢弃。 第二步: 逐个调用 lis.Close()。关闭监听 Socket,这样将不会再有新连接到达。 第三步: 对已建立的连接,逐个调用 st.Drain()。由于 gRPC 是基于 HTTP2 实现,因此这里将会应用到 HTTP2 的 goAway帧。 goAway 帧相当于服务器端主动给客户端发送的连接关闭的信号,客户端收到这个信号后,将会关闭该连接上所有的 HTTP2 的流。这样客户端侧可以主动感知到连接关闭,同时不会继续发送新的请求过来。 第四步: 调用 s.serveWG.Wait()。保证 gRPC 的 Serve 函数已正常退出。 第五步: 调用 s.cv.Wait()。这个逻辑用于等待所有已建立连接的业务处理逻辑的正常结束。这样就不会因为服务的突然关闭,造成业务逻辑的异常。 以上就是 gRPC 的优雅终止过程。简单来说,gRPC 需要从外至内的保证了各层逻辑的正常关闭。 但是,这里有个问题可能容易忽视。最后一步调用 s.cv.Wait(),用来等待业务处理逻辑的正常结束。但这里可能有异常情况是,如果业务逻辑由于代码 bug,发生了死锁或者死循环,那么业务逻辑将永远无法结束,s.cv.Wait() 也将会一直卡住。这样,GracefulStop 也将永远无法结束。 针对于这个问题,需要配合外置的部署系统,对服务进行强行的超时终止。接下来,我们看下 k8s 是如何实现这一点的。 k8s 的优雅终止 在 k8s 下线 pod 之前,集群并不会强制的杀死 pod,而是需要执行一系列步骤才会让 pod 体面的下线。 检查 pod 的生命周期,如果配置有 preStop,则先执行 preStop 钩子。我们可以做一些预先清理和服务注册中心下线等工作。 向 pod 发送 SIGTERM 信号。SIGTERM 其实就对应于 linux 命令 kill -15。这就需要 RPC 自行监听 SIGTERM 信号,一旦收到信号,即可执行优雅终止。 等待一段时间,如果 pod 依然没有自行停止,则向 pod 发送 SIGKILL 信号,相当于 linux 命令 kill -9,pod 将被强行终止。而等待的时长取决于 pod 配置的优雅终止时间 terminationGracePeriodSeconds 参数,默认为 30 秒。 这个时候,突然想到了《让子弹飞》里面的一句话:“黄老爷是个体面人,他要是体面,你就让他体面。他要是不体面,你就帮他体面。” k8s 允许 pod 体面的下线,如果 pod 不体面,那么就强行让他体面的下线。 优雅终止的流程总结 以上内容分别讲了如何在各个方面实现服务的优雅终止,总结下整个优雅终止的流程: 首先将服务节点主动从服务注册中心下线,保证服务注册中心。如果服务基于 k8s 进行调度和管理,可使用 preStop 回调进行服务注册中心的下线。 RPC需要实现一整套的优雅终止逻辑。保证现有业务逻辑尽量不受损。 k8s等待pod优雅终止期过后,强制停止pod。 基于以上一整套流程,可以实现服务的优雅终止, 这对于无状态服务来说基本已经够用了。但对于有状态服务,优雅终止的挑战会更难一些。TiDB 这里有一篇文章,讲述了 有状态分布式应用的优雅终止挑战,有兴趣的同学可以扩展看一下。 本文主要讲了服务的优雅终止,那么既然有优雅终止,那同时也会对应服务的优雅启动。服务的优雅终止是从外至內的,首先关闭掉最外层的流量进入,再逐步向内停止逻辑。而优雅启动要从内至外地保证各层逻辑正常打开,才能完成最终的上线。 参考 https://kubernetes.io/zh/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination https://skyao.io/learning-http2/frame/definition/goaway.html

2021/3/18
articleCard.readMore

一致性 Hash 原理及 GroupCache 源码分析

一致性 Hash 常用于缓解分布式缓存系统扩缩容节点时造成的缓存大量失效的问题。一致性 Hash 与其说是一种 Hash 算法,其实更像是一种负载均衡策略。 GroupCache 是 golang 官方提供的一个分布式缓存库,其中包含了一个简单的一致性 Hash 的实现。其代码在 github.com/golang/groupcache/consistenthash。本文将会基于 GroupCache 的一致性 Hash 实现,深入剖析一致性 Hash 的原理。 本文会着重探讨以下几点内容: 传统的 Hash 式负载均衡在集群扩缩容时面临的缓存失效问题。 一致性 Hash 的原理。 Golang 的开源库 GroupCache 如何实现一致性 Hash。 集群扩缩容导致缓存的问题 我们先看下传统的 Hash 式负载均衡,当集群扩缩容时会遇到哪些问题。 假设我们有三台缓存服务器,每台服务器用于缓存一部分用户的信息。最常见的 Hash 式负载均衡做法是:对于指定用户,我们可以对其用户名或者其他唯一信息计算 hash 值,然后将该 hash 值对 3 取余,得到该用户对应的缓存服务器。如下图所示: 而当我们需要对集群进行扩容或者缩容时,增加或者减少部分服务器节点,将会带来大面积的缓存失效。 例如需要扩容一台服务器,即由 3 台缓存服务器增加为 4 台,那么之前 hash(username) % 3 这种策略,将变更为 hash(username) % 4。整个负载均衡的策略发生了彻底的变化,对于任何一个用户都会面临Hash失效的风险。 而一旦缓存集体失效,所有请求无法命中缓存,直接打到后端服务上,系统很有可能发生崩溃。 一致性 Hash 的原理 针对以上问题,如果使用一致性 Hash 作为缓存系统的负载均衡策略,可以有效缓解集群扩缩容带来的缓存失效问题。 相比于直接对 hash 取模得到目标 Server 的做法,一致性 Hash 采用 有序 Hash 环 的方式选择目标缓存 Server。如下图所示: 对于该有序 Hash 环,环中的每个节点对应于一台缓存 Server,同时每个节点也包含一个整数值。各节点按照该整数值从小到大依次排列。 对于指定用户来说,我们依然首先出计算用户名的 hash 值。接着,在 Hash 环中按照值大小顺序,从小到大依次寻找,找到 第一个大于等于该 hash 值的节点,将其作为目标缓存 Server。 例如,我们 hash 环中的三个节点 Node-A、Node-B、Node-C 的值依次为 3、7、13。假设对于某个用户来说,我们计算得到其用户名的 hash 值为 9,环中第一个大于 9 的节点为 Node-C,则选用 Node-C 作为该用户的缓存 Server。 缓存失效的缓解 以上就是正常情况下一致性 Hash 的使用,接下来我们看下,一致性 Hash 是如何应对集群的扩缩容的。 当我们对集群进行扩容,新增一个节点 New-Node, 假设该节点的值为 11。那么新的有序 Hash 环如下图所示: 我们看下此时的缓存失效情况:在这种情况下, 只会造成 hash 值范围在 Node-B 和 NewNode 之间(即(7, 11])的数据缓存失效。这部分数据原本分配到节点 Node-C(值为 13),现在都需要迁移到新节点 NewNode 上。 而原本分配到 Node-A、Node-B 两个节点上的缓存数据,不会受到任何影响。之前值范围在 NewNode 和 Node-B 之间(即(11, 13])的数据,被分配到了 Node-C 上面。新节点出现后,这部分数据依然属于 Node-C,也不会受到任何影响。 一致性 Hash 利用有序 Hash 环,巧妙的缓解了集群扩缩容造成的缓存失效问题。注意,这里说的是 “缓解”,缓存失效问题无法完全避免,但是可以将其影响降到最低。 这里有个小问题是,因为有序 Hash 环需要其中每个节点有持有一个整数值,那这个整数值如何得到呢?一般做法是,我们可以利用该节点的特有信息计算其 Hash 值得到, 例如 hash(ip:port)。 数据倾斜与虚拟节点 以上介绍了一致性 hash 的基本过程,这么看来,一致性 hash 作为缓解缓存失效的手段,的确是行之有效的。 但我们考虑一个极限情况,假设整个集群就两个缓存节点: Node-A 和 Node-B。则 Node-B 中将存放 Hash 值范围在 (Node-A, Node-B] 之间的数据。而 Node-A 将承担两部分的数据: hash < Node-A 和 hash > Node-B。 从这个值范围,我们可以轻易的看出,Node-A 的值空间实际上远大于 Node-B。当数据量较大时,Node-A 承担的数据也将远超于 Node-B。实际上,当节点过少时,很容易出现分配给某个节点的数据远大于其他节点。这种现象我们往往称之为 “数据倾斜”。 对于此类问题,我们可以引入虚拟节点的概念,或者说是副本节点。每个真实的缓存 Server 在 Hash 环上都对应多个虚拟节点。如下图所示: 对于上图来说,我们其实依然只有三个缓存 Server。但是每个 Server 都有一个副本,例如 V-Node-A 和 Node-A 都对应同一个缓存 Server。 GroupCache 的一致性 Hash 实现 GroupCache 提供了一个简单的一致性 hash 的实现。其代码在 github.com/golang/groupcache/consistenthash。 我们先看下它的使用方法: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import ( "fmt" "github.com/golang/groupcache/consistenthash" ) func main() { // 构造一个 consistenthash 对象,每个节点在 Hash 环上都一共有三个虚拟节点。 hash := consistenthash.New(3, nil) // 添加节点 hash.Add( "127.0.0.1:8080", "127.0.0.1:8081", "127.0.0.1:8082", ) // 根据 key 获取其对应的节点 node := hash.Get("cyhone.com") fmt.Println(node) } consistenthash 对外提供了三个函数: New(replicas int, fn Hash):构造一个 consistenthash 对象,replicas 代表每个节点的虚拟节点个数,例如 replicas 等于 3,代表每个节点在 Hash 环上都对应有三个虚拟节点。fn 代表自定义的 hash 函数,传 nil 则将会使用默认的 hash 函数。 Add 函数:向 Hash 环上添加节点。 Get 函数:传入一个 key,得到其被分配到的节点。 Add 函数 我们先看下其 Add 函数的实现。Add 函数用于向 Hash 环上添加节点。其源码如下: 1 2 3 4 5 6 7 8 9 10 11 12 func (m *Map) Add(keys ...string) { for _, key := range keys { for i := 0; i < m.replicas; i++ { hash := int(m.hash([]byte(strconv.Itoa(i) + key))) m.keys = append(m.keys, hash) m.hashMap[hash] = key } } // 排序,这个动作非常重要,因为只有这样,才能构造一个有序的 Hash 环 sort.Ints(m.keys) } 在 Add 函数里面涉及两个重要的属性: keys: 类型为 []int。这个其实就是我们上面说的有序 Hash 环,这里用了一个数组表示。数组中的每一项都代表一个虚拟节点以及它的值。 hashMap:类型为 map[int]string。这个就是虚拟节点到用户传的真实节点的映射。map 的 key 就是 keys 属性的元素。 在这个函数里面有生成虚拟节点的操作。例如用户传了真实节点为 ["Node-A", "Node-B"], 同时 replicas 等于 2。则 Node-A 会对应 Hash 环上两个虚拟节点:0Node-A,1Node-A,这两个节点对应的值也是直接进行对其计算 hash 得到。 需要注意的是,每次 Add 时候,函数最后会对 keys 进行排序。因此最好一次把所有的节点都加进来,以避免多次排序。 Get 函数 接下来我们分析下 Get 函数的使用,Get 函数用于给指定 key 分配对应节点。其源码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func (m *Map) Get(key string) string { if m.IsEmpty() { return "" } hash := int(m.hash([]byte(key))) // Binary search for appropriate replica. // 二分查找 idx := sort.Search(len(m.keys), func(i int) bool { return m.keys[i] >= hash }) // Means we have cycled back to the first replica. // 如果没有找到,则使用首元素 if idx == len(m.keys) { idx = 0 } return m.hashMap[m.keys[idx]] } 首先计算用户传的 key 的 hash 值,然后利用 sort.Search 在 keys 中二分查找,得到数组中满足情况的最小值。因为 keys 是有序数组, 所以使用二分查找可以加快查询速度。 如果没有找到则使用首元素,这个就是环形数组的基本操作了。最后利用 hashMap[keys[idx]], 由虚拟节点,得到其真实的节点。 以上就是 Groupcache 对一致性 Hash 的实现了。这个实现简单有效,可以帮助我们快速理解一致性 Hash 的原理。

2021/2/21
articleCard.readMore

Golang sync.Cond 条件变量源码分析

sync.Cond 条件变量是 Golang 标准库 sync 包中的一个常用类。sync.Cond 往往被用在一个或一组 goroutine 等待某个条件成立后唤醒这样的场景,例如常见的生产者消费者场景。 本文将基于 go-1.13 的源码 分析 sync.Cond 源码,将会涉及以下知识点: sync.Cond 的基本用法 sync.Cond 的底层结构及原理分析 sync.Cond 的惯用法及使用注意事项 sync.Cond 的基本用法 在正式讲 sync.Cond 的原理之前,我们先看下 sync.Cond 是如何使用的。这里我给出了一个非常简单的单生产者多消费者的例子,代码如下: 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 var mutex = sync.Mutex{} var cond = sync.NewCond(&mutex) var queue []int func producer() { i := 0 for { mutex.Lock() queue = append(queue, i) i++ mutex.Unlock() cond.Signal() time.Sleep(1 * time.Second) } } func consumer(consumerName string) { for { mutex.Lock() for len(queue) == 0 { cond.Wait() } fmt.Println(consumerName, queue[0]) queue = queue[1:] mutex.Unlock() } } func main() { // 开启一个 producer go producer() // 开启两个 consumer go consumer("consumer-1") go consumer("consumer-2") for { time.Sleep(1 * time.Minute) } } 在以上代码中,有一个 producer 的 goroutine 将数据写入到 queue 中,有两个 consumer 的 goroutine 负责从队列中消费数据。而 producer 和 consumer 对 queue 的读写操作都由 sync.Mutex 进行并发安全的保护。 其中 consumer 因为需要等待 queue 不为空时才能进行消费,因此 consumer 对于 queue 不为空这一条件的等待和唤醒,就用到了 sync.Cond。 我们看下 sync.Cond 接口的用法: sync.NewCond(l Locker): 新建一个 sync.Cond 变量。注意该函数需要一个 Locker 作为必填参数,这是因为在 cond.Wait() 中底层会涉及到 Locker 的锁操作。 cond.Wait(): 等待被唤醒。唤醒期间会解锁并切走 goroutine。 cond.Signal(): 只唤醒一个最先 Wait 的 goroutine。对应的另外一个唤醒函数是 Broadcast,区别是 Signal 一次只会唤醒一个 goroutine,而 Broadcast 会将全部 Wait 的 goroutine 都唤醒。 接下来,我们将分析下 sync.Cond 底层是如何实现这些操作的。 sync.Cond 底层原理分析 底层数据结构 sync.Cond 的 struct 定义如下: 1 2 3 4 5 6 7 8 9 type Cond struct { noCopy noCopy // L is held while observing or changing the condition L Locker notify notifyList checker copyChecker } 其中最核心的就是 notifyList 这个数据结构, 其源码在 runtime/sema.go#L446: 1 2 3 4 5 6 7 8 9 type notifyList struct { wait uint32 notify uint32 // List of parked waiters. lock mutex head *sudog tail *sudog } 以上代码中,notifyList 包含两类属性: wait 和 notify。这两个都是ticket值,每次调 Wait 时,ticket 都会递增,作为 goroutine 本次 Wait 的唯一标识,便于下次恢复。 wait 表示下次 sync.Cond Wait 的 ticket 值,notify 表示下次要唤醒的 goroutine 的 ticket 的值。这两个值都只增不减的。利用 wait 和 notify 可以实现 goroutine FIFO式的唤醒,具体见下文。 head 和 tail。等待在这个 sync.Cond 上的 goroutine 链表,如下图所示: Wait 操作 我们先分析下当调用 sync.Cond 的 Wait 函数时,底层做了哪些事情。代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 func (c *Cond) Wait() { c.checker.check() // 获取ticket t := runtime_notifyListAdd(&c.notify) // 注意这里,必须先解锁,因为 runtime_notifyListWait 要切走 goroutine // 所以这里要解锁,要不然其他 goroutine 没法获取到锁了 c.L.Unlock() // 将当前 goroutine 加入到 notifyList 里面,然后切走 goroutine runtime_notifyListWait(&c.notify, t) // 这里已经唤醒了,因此需要再度锁上 c.L.Lock() } Wait 函数虽然短短几行代码,但里面蕴含了很多重要的逻辑。整个逻辑可以拆分为 4 步: 第一步:调用 runtime_notifyListAdd 获取 ticket。ticket 是一次 Wait 操作的唯一标识,可以用来防止重复唤醒以及保证 FIFO 式的唤醒。 它的生成也非常简单,其实就是对 notifyList 的 wait 属性进行原子自增。其实现如下: 1 2 3 func notifyListAdd(l *notifyList) uint32 { return atomic.Xadd(&l.wait, 1) - 1 } 第二步:c.L.Unlock() 先把用户传进来的 locker 解锁。因为在 runtime_notifyListWait 中会调用 gopark 切走 goroutine。因此在切走之前,必须先把 Locker 解锁了。要不然其他 goroutine 获取不到这个锁,将会造成死锁问题。 第三步:runtime_notifyListWait 将当前 goroutine 加入到 notifyList 里面,然后切走goroutine。下面是 notifyListWait 精简后的代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 func notifyListWait(l *notifyList, t uint32) { lock(&l.lock) ... s := acquireSudog() s.g = getg() s.ticket = t if l.tail == nil { l.head = s } else { l.tail.next = s } l.tail = s // go park 切走 goroutine goparkunlock(&l.lock, waitReasonSyncCondWait, traceEvGoBlockCond, 3) // 注意:这个时候,goroutine 已经切回来了, 释放 sudog releaseSudog(s) } 从以上代码可以看出,notifyListWait 的逻辑并不复杂,主要将当前 goroutine 追加到 notifyList 链表最后以及调用 gopark 切走 goroutine。 第四步:goroutine 被唤醒。如果其他 goroutine 调用了 Signal 或者 Broadcast 唤醒了该 goroutine。那么将进入到最后一步:c.L.Lock()。此时将会重新把用户传的 Locker 上锁。 以上就是 sync.Cond 的 Wait 过程,可以简单用下图表示: Signal:唤醒最早 Wait 的 goroutine 正如最开始的例子中展示的,在 producer 的 goroutine 里面调用 Signal 函数将会唤醒正在 Wait 的 goroutine。而且这里需要注意的是,Signal 只会唤醒一个 goroutine,且该 goroutine 是最早 Wait 的。 我们接下来看下,Signal 是如何唤醒 goroutine 以及如何实现 FIFO 式的唤醒。 代码如下: 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 *Cond) Signal() { runtime_notifyListNotifyOne(&c.notify) } func notifyListNotifyOne(l *notifyList) { // 如果二者相等,说明没有需要唤醒的 goroutine if atomic.Load(&l.wait) == atomic.Load(&l.notify) { return } lock(&l.lock) t := l.notify if t == atomic.Load(&l.wait) { unlock(&l.lock) return } // Update the next notify ticket number. atomic.Store(&l.notify, t+1) 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 // 唤醒 goroutine readyWithTime(s, 4) return } } unlock(&l.lock) } 我们上面讲 Wait 实现的时候讲到,每次 Wait 的时候,都会同时生成一个 ticket,这个 ticket 作为此次 Wait 的唯一标识。ticket 是由 notifyList.wait 原子递增而来,因此 notifyList.wait 也同时代表当前最大的 ticket。 那么,每次唤醒的时候,也会对应一个 notify 属性。例如当前 notify 属性等于 1,则去逐个检查 notifyList 链表中 元素,找到 ticket 等于 1 的 goroutine 并唤醒,同时将 notify 属性进行原子递增。 那么问题来了,我们知道 sync.Cond 的底层 notifyList 是一个链表结构,我们为何不直接取链表最头部唤醒呢?为什么会有一个 ticket 机制? 这是因为 notifyList 会有乱序的可能。从我们上面 Wait 的过程可以看出,获取 ticket 和加入 notifyList,是两个独立的行为,中间会把锁释放掉。而当多个 goroutine 同时进行时,中间会产生进行并发操作,那么有可能后获取 ticket 的 goroutine,先插入到 notifyList 里面, 这就会造成 notifyList 轻微的乱序。Golang 的官方解释如下: Because g’s queue separately from taking numbers, there may be minor reorderings in the list. 因此,这种 逐个匹配 ticket 的方式 ,即使在 notifyList 乱序的情况下,也能取到最先 Wait 的 goroutine。 这里有个问题是,对于这种方法我们需要逐个遍历 notifyList, 理论上来说,这是个 O(n) 的线性时间复杂度。Golang 也对这里做了解释:其实大部分场景下只用比较一两次之后就会很快停止,因此不用太担心性能问题。 sync.Cond 的惯用法及使用注意事项 sync.Cond 在使用时还是有一些需要注意的地方,否则使用不当将造成代码错误。 sync.Cond不能拷贝,否则将会造成panic("sync.Cond is copied")错误 Wait 的调用一定要放在 Lock 和 UnLock 中间,否则将会造成 panic("sync: unlock of unlocked mutex") 错误。代码如下: 1 2 3 4 5 6 c.L.Lock() for !condition() { c.Wait() } ... make use of condition ... c.L.Unlock() Wait 调用的条件检查一定要放在 for 循环中,代码如上。这是因为当 Boardcast 唤醒时,有可能其他 goroutine 先于当前 goroutine 唤醒并抢到锁,导致轮到当前 goroutine 抢到锁的时候,条件又不再满足了。因此,需要将条件检查放在 for 循环中。 Signal 和 Boardcast 两个唤醒操作不需要加锁。

2021/2/4
articleCard.readMore

Golang WaitGroup 原理深度剖析

sync.WaitGroup 是 Golang 中常用的并发措施,我们可以用它来等待一批 Goroutine 结束。 WaitGroup 的源码也非常简短,抛去注释外也就 100 行左右的代码。但即使是这 100 行代码,里面也有着关乎内存优化、并发安全考虑等各种性能优化手段。 本文将基于 go-1.13 的源码 进行分析,将会涉及以下知识点: WaitGroup 的实现逻辑 WaitGroup 的底层内存结构及性能优化 WaitGroup 的内部如何实现无锁操作 WaitGroup 的使用 在正式分析源码之前,我们先看下 WaitGroup 的基本用法: 1 2 3 4 5 6 7 8 9 10 11 12 func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() println("hello") }() } wg.Wait() } 从上述代码可以看出,WaitGroup 的用法非常简单:使用 Add 添加需要等待的个数,使用 Done 来通知 WaitGroup 任务已完成,使用 Wait 来等待所有 goroutine 结束。 WaitGroup 的实现逻辑 我们首先看下 WaitGroup 的组成结构,代码如下: 1 2 3 4 type WaitGroup struct { noCopy noCopy state1 [3]uint32 } 其中 noCopy 是 golang 源码中检测禁止拷贝的技术。如果程序中有 WaitGroup 的赋值行为,使用 go vet 检查程序时,就会发现有报错。但需要注意的是,noCopy 不会影响程序正常的编译和运行。 state1 [3]uint32 字段中包含了 WaitGroup 的所有状态数据。该字段的整个设计其实非常复杂,为了便于快速理解 WaitGroup 的主流程,我们将在后面部分单独剖析 state1。 为了便于理解 WaitGroup 的整个实现过程,我们暂时先不考虑内存对齐和并发安全等方面因素。那么 WaitGroup 可以近似的看做以下代码: 1 2 3 4 5 type WaitGroup struct { counter int32 waiter uint32 sema uint32 } 其中: counter 代表目前尚未完成的个数。WaitGroup.Add(n) 将会导致 counter += n, 而 WaitGroup.Done() 将导致 counter--。 waiter 代表目前已调用 WaitGroup.Wait 的 goroutine 的个数。 sema 对应于 golang 中 runtime 内部的信号量的实现。WaitGroup 中会用到 sema 的两个相关函数,runtime_Semacquire 和 runtime_Semrelease。runtime_Semacquire 表示增加一个信号量,并挂起 当前 goroutine。runtime_Semrelease 表示减少一个信号量,并唤醒 sema 上其中一个正在等待的 goroutine。 WaitGroup 的整个调用过程可以简单地描述成下面这样: 当调用 WaitGroup.Add(n) 时,counter 将会自增: counter += n 当调用 WaitGroup.Wait() 时,会将 waiter++。同时调用 runtime_Semacquire(semap), 增加信号量,并挂起当前 goroutine。 当调用 WaitGroup.Done() 时,将会 counter--。如果自减后的 counter 等于 0,说明 WaitGroup 的等待过程已经结束,则需要调用 runtime_Semrelease 释放信号量,唤醒正在 WaitGroup.Wait 的 goroutine。 以上就是 WaitGroup 实现过程的简略版。但实际上,WaitGroup 在实现过程中对并发性能以及内存占用优化上,都有一些非常巧妙的设计点,我们接下来要着重讨论下。 WaitGroup 的底层内存结构 我们回来讨论 WaitGroup 中 state1 的内存结构。state1 长度为 3 的 uint32 数组,但正如我们上文讨论,其中 state1 中包含了三个变量的语义和行为,其内存结构如下: 我们在图中提到了 Golang 内存对齐的概念。简单来说,如果变量是 64 位对齐 (8 byte), 则该变量的起始地址是 8 的倍数。如果变量是 32 位对齐 (4 byte),则该变量的起始地址是 4 的倍数。 从图中看出,当 state1 是 32 位对齐和 64 位对齐的情况下,state1 中每个元素的顺序和含义也不一样: 当 state1 是 32 位对齐:state1 数组的第一位是 sema,第二位是 waiter,第三位是 counter。 当 state1 是 64 位对齐:state1 数组的第一位是 waiter,第二位是 counter,第三位是 sema。 为什么会有这种奇怪的设定呢?这里涉及两个前提: 前提 1:在 WaitGroup 的真实逻辑中, counter 和 waiter 被合在了一起,当成一个 64 位的整数对外使用。当需要变化 counter 和 waiter 的值的时候,也是通过 atomic 来原子操作这个 64 位整数。但至于为什么合在一起,我们会在下文WaitGroup-的无锁实现中详细解释原因。 前提 2:在 32 位系统下,如果使用 atomic 对 64 位变量进行原子操作,调用者需要自行保证变量的 64 位对齐,否则将会出现异常。golang 的官方文档 sync/atomic/#pkg-note-BUG 原文是这么说的: On ARM, x86-32, and 32-bit MIPS, it is the caller’s responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically. The first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned. 因此,在前提 1 的情况下,WaitGroup 需要对 64 位进行原子操作。那根据前提 2,WaitGroup 则需要自行保证 count+waiter 的 64 位对齐。这也是 WaitGroup 采用 [3]uint32 存储变量的目的: 当 state1 变量是 64 位对齐时,也就意味着数组前两位作为 64 位整数时,自然也可以保证 64 位对齐了。 当 state1 变量是 32 位对齐时,我们把数组第 1 位作为对齐的 padding,因为 state1 本身是 uint32 的数组,所以数组第一位也有 32 位。这样就保证了把数组后两位看做统一的 64 位整数时是64位对齐的。 这个方法非常的巧妙,只不过是改变 sema 的位置顺序,就既可以保证 counter+waiter 一定会 64 位对齐,也可以保证内存的高效利用。 Golang 官方文档中也给出了 判断当前变量是 32 位对齐还是 64 位对齐的方法:: 1 uintptr(unsafe.Pointer(&x)) % unsafe.Alignof(x) == 0 WaitGroup 中从 state1 中取变量的方法如下: 1 2 3 4 5 6 7 func (wg *WaitGroup) state() (statep *uint64, semap *uint32) { if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 { return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2] } else { return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0] } } 注: 有些文章会讲到,WaitGroup 两种不同的内存布局方式是 32 位系统和 64 位系统的区别,这其实不太严谨。准确的说法是 32 位对齐和 64 位对齐的区别。因为在 32 位系统下,state1 变量也有可能恰好符合 64 位对齐。 WaitGroup 的无锁实现 我们上文讲到,在 WaitGroup 中,其实是把 counter 和 waiter 看成一个 64 位整数进行处理,但为什么要这么做呢?分成两个 32 位变量岂不是更方便?这其实是 WaitGroup 的一个性能优化手段。 counter 和 waiter 在改变时需要保证并发安全。对于这种场景,我们最简单的做法是,搞一个 Mutex 或者 RWMutex 锁, 在需要读写 counter 和 waiter 的时候,加锁就完事。但是我们知道加锁必然会造成额外的性能开销,作为 Golang 系统库,自然需要把性能压榨到极致。 WaitGroup 直接把 counter 和 waiter 看成了一个统一的 64 位变量。其中 counter 是这个变量的高 32 位,waiter 是这个变量的低 32 位。 在需要改变 counter 时, 通过将累加值左移 32 位的方式:atomic.AddUint64(statep, uint64(delta)<<32),即可实现 count += delta 同样的效果。 在 Wait 函数中,通过 CAS 操作 atomic.CompareAndSwapUint64(statep, state, state+1), 来对 waiter 进行自增操作,如果 CAS 操作返回 false,说明 state 变量有修改,有可能是 counter 发生了变化,这个时候需要重试检查逻辑条件。 还有一个小细节值得一提的是,因为 WaitGroup 是可以复用的。因此在 Wait 结束的时候需要将 waiter--,重置状态。但这肯定会涉及到一次原子变量操作。如果调用 Wait 的 goroutine 比较多,那这个原子操作也会随之进行很多次。 WaitGroup 这里直接在 Done 的时候,判断如果 counter 等于 0 ,直接将 counter+waiter 整个 64 位整数全部置 0,既可以达到重置状态的效果,也免于进行多次原子操作。 总结 Waitgroup 虽然只有 100 行左右的代码。作为语言的内置库,我们从中可以看出作者对每个细节的极致打磨,非常精细的针对场景优化性能,这也给我们写程序带来了很多启发。 参考 Go:Size_and_alignment_guarantees Dig101-Go 之聊聊 struct 的内存对齐

2021/1/17
articleCard.readMore

Facebook 在 Golang 依赖注入的实现

依赖注入是一个经典的设计模式,可有效地解决项目中复杂的对象依赖关系。 对于有反射功能的语言来说,实现依赖注入都比较方便一些。在 Golang 中有几个比较知名的依赖注入开源库,例如 google/wire、uber-go/dig 以及 facebookgo/inject 等。 本文将基于 facebookgo/inject 介绍依赖注入, 接下来将会着重讨论以下几点内容: 依赖注入出现的背景以及解决的问题 facebookgo/inject 的使用方法 facebookgo/inject 的缺陷 依赖注入的背景 对于稍微复杂些的项目,我们往往就会遇到对象之间复杂的依赖关系。手动管理和初始化这些管理关系将会极其繁琐,依赖注入可以帮我们自动实现依赖的管理和对象属性的赋值,将我们从这些繁琐的依赖管理中解放出来。 以一个常见的 HTTP 服务为例,我们在开发后台时往往会把代码分为 Controller、Service 等层次。如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 type UserController struct { UserService *UserService Conf *Conf } type PostController struct { UserService *UserService PostService *PostService Conf *Conf } type UserService struct { Db *DB Conf *Conf } type PostService struct { Db *DB } type Server struct { UserApi *UserController PostApi *PostController } 上述的代码例子中,有两个 Controller:UserController 和 PostController,分别用来接收用户和文章的相关请求逻辑。除此之外还会有 Service 相关类、Conf 配置文件、DB 连接等。 这些对象之间存在比较复杂的依赖关系,这就给项目的初始化带来了一些困扰。对于以上代码,对应初始化逻辑大概就会是这样: 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 main() { conf := loadConf() db := connectDB() userService := &UserService{ Db: db, Conf: conf, } postService := &PostService{ Db: db, } userHandler := &UserController{ UserService: userService, Conf: conf, } postHandler := &PostController{ UserService: userService, PostService: postService, Conf: conf, } server := &Server{ UserApi: userHandler, PostApi: postHandler, } server.Run() } 我们会有一大段的逻辑都是用来做对象初始化,而当接口越来越多的时候,整个初始化过程就会异常的冗长和复杂。 针对以上问题,依赖注入可以完美地解决。 facebookgo/inject 的使用 接下来,我们试着使用 facebookgo/inject 的方式,对这段代码进行依赖注入的改造。如下: 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 type UserController struct { UserService *UserService `inject:""` Conf *Conf `inject:""` } type PostController struct { UserService *UserService `inject:""` PostService *PostService `inject:""` Conf *Conf `inject:""` } type UserService struct { Db *DB `inject:""` Conf *Conf `inject:""` } type PostService struct { Db *DB `inject:""` } type Server struct { UserApi *UserController `inject:""` PostApi *PostController `inject:""` } func main() { conf := loadConf() // *Conf db := connectDB() // *DB server := Server{} graph := inject.Graph{} if err := graph.Provide( &inject.Object{ Value: &server, }, &inject.Object{ Value: conf, }, &inject.Object{ Value: db, }, ); err != nil { panic(err) } if err := graph.Populate(); err != nil { panic(err) } server.Run() } 首先每一个需要注入的字段都需要打上 inject:"" 这样的 tag。所谓依赖注入,这里的依赖指的就是对象中包含的字段,而注入则是指有其它程序会帮你对这些字段进行赋值。 其次,我们使用 inject.Graph{} 创建一个 graph 对象。这个 graph 对象将负责管理和注入所有的对象。至于为什么叫 Graph,其实这个名词起的非常形象,因为各个对象之间的依赖关系,也确实像是一张图一样。 接下来,我们使用 graph.Provide() 将需要注入的对象提供给 graph。 1 2 3 4 5 6 7 8 9 10 11 graph.Provide( &inject.Object{ Value: &server, }, &inject.Object{ Value: &conf, }, &inject.Object{ Value: &db, }, ); 最后调用 Populate 函数,开始进行注入。 从代码中可以看到,我们一共就向 Graph 中 Provide 了三个对象。我们提供了 server 对象,是因为它是一个顶层对象。提供了 conf 和 db对象,是因为所有的对象都依赖于它们,可以说它们是基础对象了。 但是其他的对象呢? 例如 UserApi 和 UserService 呢?我们并没有向 graph 调用 Provide 过。那么它们是怎么完成赋值和注入的呢? 其实从下面这张对象依赖图能够很简单的看清楚。 从这个依赖图中可以看出,conf 和 db 对象是属于根节点,所有的对象都依赖和包含着它们。而 server 属于叶子节点,不会有其他对象依赖它了。 我们需要提供给 Graph 的就是根节点和叶子节点,而对于中间节点来说,完全可以通过根节点和叶子节点推导出来。Graph 会通过 inject:"" 标签,自动将中间节点 Provide 到 Graph 中,进行注入。 对以上例子,我们深入剖析下 Graph 内部进行 Populate 时都发生了哪些动作: Graph 首先解析 server 对象,发现其有两个标记为 inject 的字段:UserApi 和 PostApi。其类型 UserController 和 PostController, Graph 中从未出现过这两个类型。因此,Graph 会自动对该字段调用 Provide,提供给 Graph。 解析 UserApi 时,发现其依然有也有两个标记为 inject 的字段:UserService 和 Conf。对于 UserService 这种 Graph 中未登记过的类型,会自动 Provide。而对 Conf, Graph 中之前已经注册过了,因此直接将注册的对象赋值给该字段即可。 接下来就是继续逐步解析,直至没有tag为 inject 的字段。 以上就是整个依赖注入的流程了。 这里需要注意的是,在我们上面的示例中,以这种方式注入,其中所有的对象都相当于单例对象。即一个类型,只会在 Graph 中存在一个实例对象。比如 UserController 和 PosterController 中的 UserService 实际上是同一个对象。 我们的 main 函数使用 inject 进行改造后,将会变得非常简洁。而且即使随着业务越来越复杂,Handler 和 Service 越来越多,这个 main 函数中的注入逻辑也不会任何改变,除非有新的根节点对象出现。 当然,对于 Graph 来说,也不是只能 Provide 根节点和叶子节点,我们也可以自行 Provide 一个 UserService 的实例进去,对于 Graph 的运作是没有任何影响的。只不过只 Provide 根节点和叶子节点,代码会看起来更简洁一些。 inject 的高级用法 我们在声明 tag 时,除了声明为 inject:"" 这种默认用法外,还可以有其他三种高级的用法: inject:"private"。私有注入。 inject:"inline"。内联注入。 inject:"object_name"。命名注入,这里的 object_name 可以取成任意的名字。 private (私有注入) 我们上文讲过,默认情况下,所有的对象都是单例对象。一个类型只会有一个实例对象存在。但也可以不使用单例对象,private 就是提供了这种可能。 例如: 1 2 3 4 type UserController struct { UserService *UserService `inject:"private"` Conf *Conf `inject:""` } 我们将 UserController 中的 UserService 属性声明为 private 注入。这样的话,graph 遇到 private 标签时,会自动的 new 一个全新的 UserService 对象,将其赋值给该字段。 这样 Graph 中就同时存在了两个 UserService 的实例,一个是 UserService 的全局实例,给默认的 inject:"" 使用。一个是专门给 UserController 实例中的 UserService 使用。 但在实际开发中,这种 private 的场景似乎也比较少,大部分情况下,默认的单例对象就足够了。 inline (内联注入) 默认情况下,需要注入的属性必须得是 *Struct。但是也是可以声明为普通对象的。例如: 1 2 3 4 type UserController struct { UserService UserService `inject:"inline"` Conf *Conf `inject:""` } 注意,这里的 UserService 的类型,并非是 *UserService 指针类型了,而是普通的 struct 类型。struct 类型在 Go 里面都是值语义,这里当然也就不存在单例的问题了。 命名注入 如果我们需要对某些字段注入专有的对象实例,那么我们可能会用到命名注入。使用方法就是在 inject 的 tag 里写上专有的名字。如下: 1 2 3 4 type UserController struct { UserService UserService `inject:"named_service"` Conf *Conf `inject:""` } 当然,这个命名肯定不能命名为 private 和 inline,这两个属于inject的保留词。 同时,我们一定要把这个命名实例 Provide 到 graph 里面,这样 graph 才能把两个对象联系起来。 1 2 3 4 5 6 graph.Provide( &inject.Object{ Value: &namedService, Name: "named_service", }, ); 注入 map 我们除了可以注入对象外,还可以注入 map。如下: 1 2 3 4 5 type UserController struct { UserService UserService `inject:"inline"` Conf *Conf `inject:""` UserMap map[string]string `inject:"private"` } 需要注意的是,map 的注入 tag 一定要是 inject:"private"。 facebookgo/inject 的缺陷 facebookgo/inject 固然很好用,只要声明 inject:"" 的 tag,提供几个对象,就可以完全自动的注入所有依赖关系。 但是由于Golang本身的语言设计, facebookgo/inject 也会有一些缺陷和短板: 所有需要注入的字段都需要是 public 的。 这也是 Golang 的限制,不能对私有属性进行赋值。所以只能对public的字段进行注入。但这样就会把代码稍显的不那么优雅,毕竟很多变量我们其实并不想 public。 只能进行属性赋值,不能执行初始化函数。 facebookgo/inject只会帮你注入好对象,把各个属性赋值好。但很多时候,我们往往需要在对象赋值完成后,再进行其他一些动作。但对于这个需求场景,facebookgo/inject并不能很好的支持。 这两个问题的原因总结归纳为:Golang没有构造函数…

2020/8/15
articleCard.readMore

Golang 定时器底层实现深度剖析

本文将基于 Golang 源码对 Timer 的底层实现进行深度剖析。主要包含以下内容: Timer 和 Ticker 在 Golang 中的底层实现细节,包括数据结构等选型。 分析 time.Sleep 的实现细节,Golang 如何实现 Goroutine 的休眠。 注:本文基于 go-1.13 源码进行分析,而在 go 的 1.14 版本中,关于定时器的实现略有一些改变,以后会再专门写一篇文章进行分析。 概述 我们在日常开发中会经常用到 time.NewTicker 或者 time.NewTimer 进行定时或者延时的处理逻辑。 Timer 和 Ticker 在底层的实现基本一致,本文将主要基于 Timer 进行探讨研究。Timer 的使用方法如下: 1 2 3 4 5 6 7 8 9 10 import ( "fmt" "time" ) func main() { timer := time.NewTimer(2 * time.Seconds) <-timer.C fmt.Println("Timer fired") } 在上面的例子中,我们首先利用 time.NewTimer 构造了一个 2 秒的定时器,同时使用 <-timer.C 阻塞等待定时器的触发。 Timer 的底层实现 对于 time.NewTimer 函数,我们可以轻易地在 go 源码中找到它的实现,其代码位置在 time/sleep.go#L82。如下: time/sleep.go 1 2 3 4 5 6 7 8 9 10 11 12 13 func NewTimer(d Duration) *Timer { c := make(chan Time, 1) t := &Timer{ C: c, r: runtimeTimer{ when: when(d), f: sendTime, arg: c, }, } startTimer(&t.r) return t } NewTimer 主要包含两步: 创建一个 Timer 对象,主要包括其中的 C 属性和 r 属性。r 属性是 runtimeTimer 类型。 调用 startTimer 函数,启动 timer。 在 Timer 结构体中的属性 C 不难理解,从最开始的例子就可以看到,它是一个用来接收 Timer 触发消息的 channel。注意,这个 channel 是一个有缓冲 channel,缓冲区大小为 1。 我们主要看的是 runtimeTimer 这个结构体: when: when 代表 timer 触发的绝对时间。计算方式就是当前时间加上延时时间。 f: f 则是 timer 触发时,调用的 callback。而 arg 就是传给 f 的参数。在 Ticker 和 Timer 中,f 都是 sendTime。 timer 对象构造好后,接下来就调用了 startTimer 函数,从名字来看,就是启动 timer。具体里面做了哪些事情呢? startTimer 具体的函数定义在 runtime/time.go 中,里面实际上直接调用了另外一个函数 addTimer。我们可以看下 addTimer 的代码 /runtime/time.go#L131: 1 2 3 4 5 6 7 8 9 10 11 12 13 func addtimer(t *timer) { // 得到要被插入的 bucket tb := t.assignBucket() // 加锁,将 timer 插入到 bucket 中 lock(&tb.lock) ok := tb.addtimerLocked(t) unlock(&tb.lock) if !ok { badTimer() } } 可以看到 addTimer 至少做了两件事: 调用 assignBucket,得到获取可以被插入的 timersBucket 调用 addtimerLocked 将 timer 插入到 timersBucket 中。从函数名可以看出,这同时也是个加锁操作。 那么问题来了,timersBucket 是什么?timer 插入到 timersBucket 中后,会以何种方式触发? timersBucket 在 go 1.13 的 runtime 中,共有 64 个全局的 timersBucket。每个 timersBucket 负责管理一些 timer。 timer 的整个生命周期包括创建、销毁、唤醒和睡眠等都由 timersBucket 管理和调度。 timersBucket 的结构: 最小四叉堆 每个 timersBucket 实际上内部是使用最小四叉堆来管理和存储各个 timer。 最小堆是非常常见的用来管理 timer 的数据结构。在最小堆中,作为排序依据的 key 是 timer 的 when 属性,也就是何时触发。即最近一次触发的 timer 将会处于堆顶。如下图: 关于四叉堆的具体实现,这里没有什么特殊需要介绍的,与二叉树基本一致。有兴趣的同学可以直接参考二叉树相关实现即可。 timerproc 的调度 每个 timersBucket 负责管理一堆这样有序的 timer,同时每个 timersBucket 都有一个对应的名为 timerproc 的 goroutine 来负责不断调度这些 timer。代码在 /runtime/time.go#L247 对于每个 timersBucket 对应的 timeproc,该 goroutine 也不是时时刻刻都在监听。timerproc 的主要流程概括起来如下: 创建。 timeproc 是懒加载的,虽然 64 个 timersBucket 一直是存在的,但是这些 timerproc 对应的 goroutine 并不是一开始就存在。第一个 timer 被加到 timersBucket 中时,才会调用 go timerproc(tb), 创建该 goroutine。 调度。从 timersBucket 不断取堆顶元素,如果堆顶的 timer 已触发,则将其从最小堆中移除,并调用对应的 callback。这里的 callback 也就是 runtimeTimer 结构体中的 f 属性。 如果 timer 是个 ticker(周期性 timer),则生成新的 timer 塞进 timersBucket 中。 挂起。如果 timersBucket 为空,意味着所有的 timer 都被消费完了。则调用 gopark 挂起该 goroutine。 唤醒。当有新的 timer 被添加到该 timersBucket 中时,如果 goroutine 处于挂起状态,会调用 goready 重新唤醒 timerproc。 当 timer 触发时,timerproc 会调用对应的 callback。对于 timer 和 ticker 来说,其 callback 都是 sendTime 函数,如下: 1 2 3 4 5 6 func sendTime(c interface{}, seq uintptr) { select { case c.(chan Time) <- Now(): default: } } 这里的 c interface{},也就是我们上文中提到的,在定义 timer 或 ticker 时,timer 对象中的 C 属性, 在 timer 和 ticker 中,它都被初始化为长度为 1 的有缓冲 channel。 调用 sendTime 时,会向 channel 中传递一个值。由于是缓冲为 1 的 buffer,因此当缓冲为空时,sendTime 可以无阻塞地把数据放到 channel 中。 如果定时时间过短,也不用担心用户调用 <-timer.C 接收不到触发事件,因为事件已经放到了 channel 中。 而对于 ticker 来说,sendTime 会被调用多次,而 channel 的缓冲长度只有 1。如果 ticker 没有来得及消费 channel,会不会导致 timerproc 调用 callback 阻塞呢? 答案是不会的。因为我们可以看到,在这个 select 语句中,有一个 default 选项,如果 channel 不可写,会触发 default。 对于 ticker 来说,如果之前的触发事件没有来得及消费,那新的触发事件到来,就会被立即丢弃。 因此对于 timerproc 来说,调用 sendTime 的时候,永远不会阻塞。这样整个 timerproc 的过程也不会因为用户侧的行为,导致某个 timer 没有来得及消费而造成阻塞。 为什么是 64 个 timerBucket? 64 个 timersBucket 的定义代码如下,在 /runtime/time.go#L39 可以看到。 /runtime/time.go 1 2 3 4 5 6 7 8 9 const timersLen = 64 var timers [timersLen]struct { timersBucket // The padding should eliminate false sharing // between timersBucket values. pad [cpu.CacheLinePadSize - unsafe.Sizeof(timersBucket{})%cpu.CacheLinePadSize]byte } 不过为什么是 64 个 timersBucket,而不是一个,或者为什么不干脆与 GOMAXPROCS 的大小保持一致呢? 首先,在 go 1.10 之前,go runtime 中的确只有一个 timers 对象,负责管理所有 timer。这个时候也就没有分桶了,整个定时器调度模型非常简单。但问题也非常的明显: 创建和停止 timer 都需要对 timersBucket 进行加锁操作。 当 timer 过多时,单个 timersBucket 的调度负担太重,可能会造成 timer 的延迟。 因此,在 go 1.10 中,引入了全局 64 个 timer 分桶的策略。将 timer 打散到分桶内,每个桶负责自己分配到的 timer 即可。好处也非常明显,可以有效降低了锁粒度和 timer 调度的负担。 至于为什么是 64 个 timersBucket,这点在源码注释中也有详细的说明: Ideally, this would be set to GOMAXPROCS, but that would require dynamic reallocation. The current value is a compromise between memory usage and performance that should cover the majority of GOMAXPROCS values used in the wild. 理想情况下,分桶的个数和保持 GOMAXPROCS 一致是最优解。但是这就会涉及到 go 启动时的动态内存分配。作为语言的runtime应该尽量减少程序负担,而 64 个 timersBucket 则是内存占用和性能之间的权衡结果了。 每个 timersBucket 具体负责管理的 timer 和 go 调度模型 GMP 中 P 有关,代码如下: 1 2 3 4 5 func (t *timer) assignBucket() *timersBucket { id := uint8(getg().m.p.ptr().id) % timersLen t.tb = &timers[id].timersBucket return t.tb } 可以看到,timer 获取其对应的 timersBucket 时,是根据 golang 的 GMP 调度模型中的 P 的 id 进行取模。而当 GOMAXPROCS > 64, 一个 timersBucket 将会同时负责管理多个 P 上的 timer。 为什么是四叉堆 timersBucket 里面使用最小堆管理 Timer,但与我们常见的使用二叉树来实现最小堆不同,Golang 这里采用了四叉堆 (4-heap) 来实现。这里 Golang 并没有直接给出解释。 这里直接贴一段 知乎网友对二叉堆和 N 叉堆的分析。 上推节点的操作更快。假如最下层某个节点的值被修改为最小,同样上推到堆顶的操作,N 叉堆需要的比较次数只有二叉堆的 log⁡N2\log_N{2}logN​2倍。 对缓存更友好。二叉堆对数组的访问范围更大,更加随机,而 N 叉堆则更集中于数组的前部,这就对缓存更加友好,有利于提高性能。 C 语言知名开源网络库 libev,其timer定时器实现可以在编译时选择采用四叉堆还是二叉堆。在它的注释里提到四叉堆相比来说缓存更加友好。 根据benchmark,在 50000 + 个 timer 的场景下,四叉堆会有 5% 的性能优势。具体可见 libev/ev.c#L2227 sleep 的实现 我们通常使用 time.Sleep(1 * time.Second) 来将 goroutine 暂时休眠一段时间。sleep 操作在底层实现也是基于 timer 实现的。代码在 runtime/time.go#L84。有一些比较有意思的地方,单独拿出来讲下。 我们固然也可以这么做来实现 goroutine 的休眠: 1 2 timer := time.NewTimer(2 * time.Seconds) <-timer.C 这么做当然可以。但 golang 底层显然不是这么做的,因为这样有两个明显的额外性能损耗。 每次调用 sleep 的时候,都要创建一个 timer 对象。 需要一个 channel 来传递事件。 既然都可以放在 runtime 里面做。golang 里面做的更加干净: 每个 goroutine 底层的 G 对象上,都有一个 timer 属性,这是个 runtimeTimer 对象,专门给 sleep 使用。当第一次调用 sleep 的时候,会创建这个 runtimeTimer,之后 sleep 的时候会一直复用这个 timer 对象。 调用 sleep 时候,触发 timer 后,直接调用 gopark,将当前 goroutine 挂起。 timerproc 调用 callback 的时候,不是像 timer 和 ticker 那样使用 sendTime 函数,而是直接调 goready 唤醒被挂起的 goroutine。 这个做法和libco的poll实现几乎一样:sleep时切走协程,时间到了就唤醒协程。 总结 分析 timer 的实现,可以明显的看到整个设计的演进,从最开始的全局 timers 对象,到分桶 bucket,以及到 go1.14 最新的 timer 调度。整个过程也可以学习到整个决策的走向和取舍。 参考 https://en.wikipedia.org/wiki/D-ary_heap https://www.zhihu.com/question/358807741/answer/922148368 https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-timer/

2020/6/19
articleCard.readMore

Elasticsearch 学习:入门篇

Elasticsearch 是一个分布式搜索引擎,底层基于 Lucene 实现。Elasticsearch 屏蔽了 Lucene 的底层细节,提供了分布式特性,同时对外提供了 Restful API。Elasticsearch 以其易用性迅速赢得了许多用户,被用在网站搜索、日志分析等诸多方面。由于 ES 强大的横向扩展能力,甚至很多人也会直接把 ES 当做 NoSQL 来用。 本文主要记录了 ES 的一些必要的基础知识,也是自己在学习和使用 ES 的一些总结。当然,要系统和深入学习还是要依靠官方文档:Elasticsearch Reference 和不断地实践。 本文会涉及以下内容: ES 的基本概念讲解 如何通过 ES 增删数据以及批量修改 ES 基本的查询和搜索功能、高亮关键词搜索以及多索引查询功能 基本概念 在正式学习,有一些名词和概念需要简单的了解下。 Document (文档) Index (索引) Type [已废弃] Document (文档) 文档指的是用户提交给 ES 的一条数据。需要注意的是,这里的文档并非指的是一个纯字符串文本,在 ES 中文档指的是一条 JSON 数据。如果对 MongoDB 有了解的话,这里文档的含义和 MongoDB 中的基本类似。 JSON 数据中可以包含多个字段,这些字段可以类比为 MySQL 中每个表的字段。 例如: 1 2 3 4 { "message": "this is my blog", "author": "cyhone" } 这样我们后期进行搜索和查询的时候,也可以分别针对 message 字段和 author 字段进行搜索。 Index (索引) Index(索引) 可以理解为是文档的集合,同在一个索引中的文档共同建立倒排索引。 也有很多人会把索引类比于 MySQL 中 schema 的概念。但在 ES 中 Index 更加灵活,用起来也更加方便。 此外,提交给同一个索引中的文档,最好拥有相同的结构。这样对于 ES 来说,不管是存储还是查询,都更容易优化。 Type [已废弃] Type 可以理解为是 Index 的子集,类似于 MySQL 中 schema 和 table 的关系。Type 原来存在的目的是为了在同一个 Index 存储异构数据。但其实 ES 中的索引用起来足够方便和灵活,对于异构数据,完全可以再建另外单独的 Index 存储。 所以在 Elasticsearch 的新版本中,已经逐步淡化和移除了 Type 的概念。在 7.0 版本中,对于每个 Index,ES 直接内置了一个 _doc 的 Type,且一个 Index 只能包含一个 Type。如果用户在添加数据时用到了其他 Type,则会报错。 所以不管是新旧版本,大家在使用 ES 的时候,也忘记 Type 这个存在就好,用 _doc 即可。 我们接下来看下如何在 ES 中存储和查询一个文档,也是常说的 CRUD 操作。 在 ES 中,用户的一切操作和行为都是围绕 REST 风格的 HTTP API 进行的。ES 中所有接口的语义都严格遵守 REST 规范。 新增 / 更新文档 要想搜索内容的前提肯定是先把内容交给 ES 进行存储和索引。 我们有两种方法向对应索引中新增文档: 通过 POST 新增文档 1 2 3 4 5 6 POST /es-test/_doc { "message": "this is my blog", "author": "cyhone" } 对于以上请求来说,我们通过 POST 把对应的数据存储在了索引 es-test 中。 这里需要注意的是,Index 并不需要提前建好。对于用户指定的 Index,如果不存在,ES 会自动建立对应的 Index。 通过 PUT 新增文档 1 2 3 4 5 6 PUT /es-test/_doc/1 { "message": "this is my blog", "author": "cyhone" } 在上面例子里面,我们通过 PUT 在索引 es-test 中,新增了一条数据。与 POST 不一样的是,通过 PUT 新增数据需要手动指定该条数据的唯一 id。也就是上述的 /es-test/_doc/1 中的 1。这个唯一 id 不必要是数字,任何合法字符串均可。 POST 和 PUT 的行为都非常符合 REST 风格: PUT 保证幂等性。因此在提交的时候需要指定一个唯一 id,对于同一个唯一 id 来说,无论 PUT 多少次,ES 只会修改这个 id 对应文档的内容,而不会新增文档。 POST 不保证幂等性。因此每次的 POST 请求都会在系统新增一条文档。对于新增的文档,系统会自动生成一个唯一 ID。 这也意味着,我们可以用 PUT + 指定唯一 id 的方式,来修改和更新文档。 删除文档 我们可以使用 DELETE 来删除一个文档。例如: 1 DELETE /es-test/_doc/1 DELETE 也是幂等性操作,在使用的时候也需要指定唯一 ID。 查询 / 搜索文档 查询和搜索文档相对来说非常复杂,不过这也是很多人使用 ES 的原因。作为一个搜索引擎,自然需要提供足够强大的查询功能。 本文仅介绍几种常用的查询方法,其他复杂的查询方式和聚合、分析等操作,以后会单独写一篇文章总结。 简单查询 我们可以通过以下语法,提供关键词,搜索所有字段进行查询。 1 GET /es-test/_search?q=blog 或者我们也可以指定查询某个字段,如下: 1 2 3 4 5 6 7 8 GET /es-test/_search { "query":{ "match": { "message": "elasticsearch" } } } 以上例子中,我们指定查询 message 字段中包含有 elasticsearch 的文档。 分页查询 对于查询得到的结果,数目过多的情况下,es 默认会进行分页。分页主要有两个参数进行控制: size 显示应该返回的结果数量,默认是 10 from 显示应该跳过的初始结果数量,默认是 0 我们可以通过直接在 url 中指定分页参数,如下: 1 GET /es-test/_search?size=5&from=10 也可以在请求体中指定分页参数,如下: 1 2 3 4 5 6 7 8 9 10 GET /es-test/_search { "query":{ "match": { "message": "elasticsearch" } }, "size": 10, "from": 5 } 关键词高亮显示 我们通常自己开发搜索引擎的时候,往往需要对搜索结果中的关键词高亮这种功能。如下: ES 可以非常简单的实现关键词的高亮。我们可以构建如下请求体: 1 2 3 4 5 6 7 8 9 10 11 12 { "query": { "match": { "message": "blog" } }, "highlight": { "fields": { "message": {} } } } 其实就是增加一个 highlight 属性,里面指明了要高亮的字段。其返回的消息体如下: 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 { "took" : 41, "timed_out" : false, "_shards" : { "total" : 1, "successful" : 1, "skipped" : 0, "failed" : 0 }, "hits" : { "total" : { "value" : 1, "relation" : "eq" }, "max_score" : 0.2876821, "hits" : [ { "_index" : "es-test", "_type" : "_doc", "_id" : "PNyBzHABTSSzPOmql8i9", "_score" : 0.2876821, "_source" : { "message" : "this is my blog", "author" : "cyhone" }, "highlight" : { "message" : [ "this is my <em>blog</em>" ] } } ] } } 在返回体中有一个 highlight 字段,里面对 message 字段进行高亮处理: 关键词使用了 <em></em> 标签包围了。 我们可以利用 css 修改对 <em> 标签的样式,以实现其关键词高亮效果。 多索引查询 在 ES 中可以非常方便地在多个索引中通过搜索文档。 例如你有两个索引: es-test-1 和 es-test-2。 你可以这样直接在 URL 中指明两个索引: 1 GET /es-test-1,es-test-2/_search 或者如下的模糊搜索的方式 1 GET /es-test-*/_search 如果有必要的话,甚至可以这样: 1 GET /a*, b*/_search 以上方式都可以在多个索引中同时搜索文档,把多个索引看做一个使用。 其实这也意味着,我们在存储的时候,没必要把所有的文档都存在一个 Index 中。 很常见的一个操作是,我们可以将文档按天分索引存储。例如: es-test-2020-03-11,es-test-2020-03-12 等, 在查询的时候,指定 es-test-* 查询即可,这样对外看来,文档似乎还是存储在一起,同时也减轻了 Index 的存储压力。(一个 ES 分片最多能存储 Integer.MAX_VALUE - 128 个文档) 批量操作 上文讲到的通过 POST、PUT 来新增或修改数据,都是基于单条数据的。但是我们知道网络 IO 是网络操作中最耗时的部分,对于大数据量写入的场景下,我们通常希望写入方可以提供批量修改的接口,以避免频繁的网络交互,更大限度地提升写入性能。 ES 当然也提供了批量修改的接口。在批量接口中,我们一次可以进行多个新增、更新和删除等修改行为的动作。例如: 1 2 3 4 5 6 7 8 POST _bulk {"index" : { "_index" : "es-test"} } {"message" : "this is my blog"} {"create" : { "_index" : "es-test", "_id" : "3"} } {"message" : "this is my blog"} {"delete" : { "_index" : "es-test", "_id" : "2"} } {"update" : {"_id" : "1", "_index" : "test"} } {"message" : "this is my blog"} 以上这个批量操作有些复杂。里面包含了 4 种操作 index、create、delete 和 update。 其中 index、create 和 update 都包含两行,一行是具体的操作,一行是文档内容。 index 和 create 的区别在于,create 会携带一个唯一 id,如果该 id 存在,则插入失败。 动态映射 有一点值得注意的是,本文中的例子都是用了 message 字段来进行 match 搜索,如果换成字段名换成了其他,例如 content 可能就不行。 这是因为在我这边的 ES 有一个默认的动态映射,将长度低于 2048 的字符串认定为 keyword 类型。但是字段名是 message 的话,则为 text 类型。keyword 类型不进行分词处理,不适合进行关键词搜索处理。 这样就需要我们不得不关注 ES 的动态映射。此部分内容以后会再单独分一篇文章讲解。

2020/3/11
articleCard.readMore

个人博客及公众号常用工具

本文整理和记录下自己在运营 个人博客 以及公众号时常使用到的一些工具。主要包含以下方面: 中英文空格的自动排版 微信公众号如何使用 markdown 发布 绘图工具 图片压缩工具 如何测试网站的打开速度以及针对性优化 中英文排版及相关工具 在中英文排版最重要的就是中英文之间的空格,Github 有一个热门仓库《中文文案排版指北》(sparanoid/chinese-copywriting-guidelines), 详细的说明了不加空格的严重性: 有研究显示,打字的时候不喜欢在中文和英文之间加空格的人,感情路都走得很辛苦,有七成的比例会在 34 岁的时候跟自己不爱的人结婚,而其余三成的人最后只能把遗产留给自己的猫。毕竟爱情跟书写都需要适时地留白。 所以就有了自动排版加空格的工具 pangu.js 的诞生。 pangu.js 有两种使用方法: 直接在 HTML 页面中引入 pangu.js,可以实现自动排版。 使用 vscode-pangu 插件,markdown 编写完成后,手动调用插件排下版。 我个人更喜欢用第二种方式,这样就不用在页面中再单独引入一个 js 文件,尽可能的保持页面的精简。当然前提是用vscode打开和编写markdown。 markdown 发布到公众号 我的博客 cyhone.com 一直是用 hexo 搭建的,平常也更习惯用 markdown 写文章。但是微信公众号并不支持 markdown,所以刚开始一直在找 markdown 转微信公众号的方式。 尝试了好几款推荐度比较高的方法,这里推荐下 mdnice.com。个人觉得用起来非常舒服,不仅支持多款 markdown 主题以及代码主题, 更重要的是功能维护和更新都非常及时。 绘图工具 插图是一个博客非常重要的组成部分,好的绘图可以帮助文章把问题解释的更加清楚。我自己也在探索和实践中,目前更习惯用的是 draw.io 和 processon。 这里也推荐一篇 《技术文章配图指南》一文。 文章中作者对比了各类绘图工具的优劣,更重要的是给出了绘图的一些建议,例如图片内容展示,配色和字号等方面。 图片压缩:Tinypng 上文讲到绘图是博客非常重要的组成部分。但是图片如果过多,则会影响页面的加载速度,给文章的观感和用户的流量都不是很好。 这里推荐下 tinypng 这个在线图片压缩工具。下图中的图片是我截屏的图片,压缩率往往可以达到 70% 左右。 Tinypng 采用的是有损压缩算法,选择性减少图片中的一些肉眼几乎分别不出来的颜色点,起到压缩图片的作用。 网站打开速度优化 在我们自己维护和运营博客的时候,往往遇到打开速度比较慢的情况,而一时间不知道该如何下手优化。 这时候可以使用 Google 家的 Pagespeed Insights。其地址在: developers.google.com/speed/pagespeed/insights/。 把网址输进去,Pagespeed Insights就可以给你的网页打开速度进行打分,并给出有价值的优化建议。 这个工具更适合个人网站进行针对性优化~ 总结 好的工具帮助提升效率,节约更多的时间。本文会不定时更新,分享自己遇到的好工具~

2020/3/8
articleCard.readMore