MoreRSS

site iconcyhone | 沉思录修改

记录自己思考和总结的一些东西,包括不限于源码分析、读书总结以及技术方案等方面。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

cyhone | 沉思录的 RSS 预览

【月刊】2025-08-20 期

2025-08-20 21:00:54

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

这篇文章讨论了经典的程序员要不要造“轮子”的问题,文中有一些非常有价值的观点和建议:

  1. 写玩具项目可以帮我们理解一些核心的技术点。费曼说:“What I cannot create, I do not understand”
  2. 写的时候要避免过度工程化,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 教主分享在微博上的文章:“粉丝群有人问有没有关于怎么带团队的书。我觉得把《怎样当好一名师长》看明白就够了。这也是我非常推崇的一篇文章。”

  1. 要勤快:应该自己干的事情一定要亲自过问,亲自动手。
  2. 要摸清上级的意图:对上级的意图要真正理解,真正融会贯通,真正认识自己所领受的任务在战役、战斗全局中的地位和作用。这样,才能充分发挥自己的主观能动性,才能打破框框,有敢于和善于在新情况中找到新办法的创造性。
  3. 要调查研究:平时积累的掌握的情况越多,越系统,在战时,特别是在紧张复杂的情况下,就越沉着,越有办法,急中生智的“智”才有基础。
  4. 要有活地图
  5. 要把各方面的问题想够想透:
    • 要让大家提出各种可能发现的问题,要让大家来找答案
    • 没有得到答案的问题,不能因为想了很久想不出来就把它丢开,留一个疙瘩
    • 整个战役战斗的过程,就是不断的提出问题和不断的回答问题的过程。
    • 总之,对每一个问题都不能含糊了事,问题回答完了,战役、战斗的组织才算完成。
  6. 要及时下达决心
  7. 要有一个很好的很团结的班子
  8. 要有一个好的战斗作风
  9. 要重视政治,亲自做政治工作

智元机器人 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-08-20 20:21:54

我的博客之前多以技术文章为主(虽然现在基本是年更了),很少有自我表达的部分。主要我认为我的想法并不独特到可以分享出来让大家看到。

但最近一段时间看到一些观点,让我决定做出一些改变。

第一: 公众号文章 《人要大量地表达自己》,中间有几个观点非常触动我

真正的表达,不是为了炫耀,也无关于技巧,而是一种自我的袒露。你越愿意将内在推向外部,你就越清楚自己是谁、想要什么、和他人有什么不同。而你越害怕表达、越不敢袒露,你的“自我”就越容易被模糊、被裹挟、被湮没。久而久之,甚至你自己也不认识自己了。

表达像是一面镜子,一面不断校准自我认知的镜子。你表达得越多,越真实,就越靠近那个真实的“自己”

第二: 李继刚在 即刻中提到: 可以建立一个“人生周报”,让发生过的思考真正「存在」。

第三:最近看的《打造第二大脑》这本书中提到尽早输出,可以尽快得到反馈。

会在月刊里面分享什么东西?

目前短期的想法是,月刊会是我收藏夹和笔记的定期整理。我把其中比较有价值的、让人耳目一新的或者受人启发的内容挑选出来。
我并不确定这些内容是否其他人同样感兴趣或者觉得有价值,但希望它是一个关于自我思考的好的开始。

至于发布频率,虽然说是月刊,其实会是不定期的更新。

另外,下决定做月刊的时候,又习惯性的准备把文章发布时间地拖延到月底或者下月初。后来决定尽早做,时间节点并不重要,尽早开始更加重要。

在整理资料的时候,也发现了类似的想法:

  • 纳瓦尔:我真的相信灵感是会过期的。我马上就会去做。所以当你有灵感去做某件事时,就去做。
  • 在李继刚的开篇周刊中说:“以前想启动一件事时, 会想今天是不是一个好时候? 周三这个时间点, 当不当正不正的, 要不就下周一开始正式启动吧。现在想明白了一件事: 时间是人为规定的。 真想做,那就做。”

分享

最后分享一些我自己经常的周刊或者类周刊的读物:

我之后自己的月刊,也会在这个索引页上定期更新。

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

2025-03-04 09:41:54

C++17 中引入了 std::any,可以非常方便地将任意类型的变量放到其中,做到安全的类型擦除。然而万物皆有代价,这种灵活性背后必然伴随着性能取舍。

std::any 的实现本身也并不复杂,本文将基于 libstd++ 标准库源码 深入解析其实现机制与性能开销。

代价

底层存储

std::any 需要解决的核心问题在于:

  1. 异构数据存储:如何统一管理不同尺寸的对象
  2. 类型安全访问:如何在擦除类型信息后仍能提供安全的类型查询。例如可以直接通过 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 的设计特点必然会带来一些额外的开销:

  1. 内存开销:在 64 位机器下固定占用 16 byte 空间(8 字节的_M_storage 和 8 字节的_M_manager 函数指针)。存储 1 字节数据时空间利用率仅 6.25%;
  2. 性能开销:小对象直接栈存储,对于大对象会触发堆分配。

从源码角度解读 enable_shared_from_this

2025-01-03 22:41:54

我们在使用 C++ 的时候,有时会需要在类的内部获取自身的 shared_ptr,这就会用到 std::enable_shared_from_this。在实际使用过程中,std::enable_shared_from_this 有三个陷阱需要注意:

  1. 不能在构造函数中使用 shared_from_this(), 否则会抛出 std::bad_weak_ptr 异常。对应下面情况 1。
  2. 创建的对象必须由 shared_ptr 管理,shared_from_this() 才能生效,否则也会报 std::bad_weak_ptr 异常。对应下面情况 2。
  3. 对应类必须 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_

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

2024-05-06 17:25:54

我们有这么一段业务代码,在 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/[email protected]/utils.go:831
github.com/go-sql-driver/mysql.(*mysqlConn).cancel(0xc004e6fc20, {0x0, 0x0})
/root/go/pkg/mod/github.com/go-sql-driver/[email protected]/connection.go:435 +0x3d
github.com/go-sql-driver/mysql.(*mysqlConn).startWatcher.func1()
/root/go/pkg/mod/github.com/go-sql-driver/[email protected]/connection.go:622 +0x192
created by github.com/go-sql-driver/mysql.(*mysqlConn).startWatcher
/root/go/pkg/mod/github.com/go-sql-driver/[email protected]/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()
}

具体的故障现象大概明确了:

  1. mysql driver 里面监听了context.Done(), 当 channel 返回时,将ctx.Err()设置到原子变量里面。
  2. 问题就在于: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 的原因还没有完全查明,问题的阴影仍然持续笼罩着:

  1. 按照我们的推断,应该只会返回 error,不会出现 panic。
  2. 这个问题对于线上和测试环境应该没有什么区别,为什么错误的表现却不一样?

Gin 对 Context 的缓存

继续深扒下源码,这次找到了 Gin 对请求的处理过程:在每个处理过程中,都有对sync.Pool的使用。
对缓存的复用和清理一般是问题频发的根源,我们对这块着重进行了梳理,还真的找到了原因:

  1. gin.Context本质上是对c.Request.Context()的封装。所有对 Context 的 Done、Err 方法调用,都会转发给c.Request.Context()
  2. gin 会利用sync.Poolgin.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 底层原理》

  1. 禁用 GC: debug.SetGCPercent(0) 。因为每轮 GC 之后,sync.Pool 都会被强制清空。
  2. 设置 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.ContextDone()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 对象。

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

2023-11-29 13:05:01

几乎世界上每个 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/LoopvarExperimentproposal#60078