<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Hedon Garden</title>
    <link>https://hedon.top</link>
    <atom:link href="https://hedon.top/rss.xml" rel="self" type="application/rss+xml" />
    <description>关于产品、工程、AI 学习与独立写作的长期个人博客。</description>
    <language>zh-CN</language>
    <lastBuildDate>Sun, 26 Jul 2026 17:18:03 GMT</lastBuildDate>
    <item>
      <title>AI Chatbot 追问建议组件的设计与实现</title>
      <link>https://hedon.top/blog/ai-chatbot-suggestion-list/</link>
      <guid isPermaLink="true">https://hedon.top/blog/ai-chatbot-suggestion-list/</guid>
      <pubDate>Sat, 18 Jul 2026 16:50:37 GMT</pubDate>
      <description>从生成 3 个追问到选择高价值下一步，系统梳理 AI Chatbot suggestion list 的评价标准、四种实现方案，以及如何通过 ActionSpec、Selector、Writer 和 Verifier 提升可控性与稳定性。</description>
      <category>AI Agent</category>
      <content:encoded><![CDATA[<p>很多的 AI Chatbot 在 AI 回复后，都会在 AI 消息下面挂上 1~3 个追问建议（本文我们将其称之为 <code>suggestion list</code>），以减少用户跟 AI 对话的摩擦，提高交互效率。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/39eb51fe-6de1-48ce-a770-7bac890ed2ed.png" alt=""></p>
<p>最近笔者在公司中也负责了这一块的相关开发工作，发现这个追问建议组件并不像想象中那么简单，如果想要做好的话，还是有很多需要去考虑和抉择的点的。本篇就梳理一下笔者在这块的一些思考，以供参考。</p>
<h2>1. 最初的方案</h2>
<p>先聊一下我们在这过程中踩的一些坑，首先笔者做的是一款面向二手电商的买家侧 AI Agent。我们最初的实现方案也比较简单：</p>
<blockquote>
<p>通过提示词引导，并注入「当前用户输入 + AI 最后回复 + 若干相关的上下文」，使用一个小模型（flash）来生成 3 个追问建议。</p>
</blockquote>
<p>我们考虑的点也很清晰：</p>
<ol>
<li>认为这是一个较为简单的任务，不希望有太长的 RT（响应时间），以及成本的考量，所以倾向于使用小模型。</li>
<li>注入当前最近的对话内容以及用户密切相关的背景信息，企图引导模型输出用户最需要的快捷指令。</li>
</ol>
<p>但是在实践过程中，我们遇到了诸多问题，生成的追问建议普遍存在以下问题：</p>
<ul>
<li>语境不相关</li>
<li>建议无意义</li>
<li>超过当前 Agent 能力范畴</li>
<li>语义表达不完整、语言不通顺</li>
<li>商品指代错误（瞎编造、张冠李戴）</li>
<li>指令遵循能力差，比如很倾向于输出 prompt 中的 &quot;反面例子&quot;</li>
<li>口吻错误（建议文案应该是用户口吻）</li>
<li>语言低级错误（如：找 30 岁以内的二手手机）</li>
<li>...</li>
</ul>
<p>最后经过我们<strong>提示词 + 上下文内容优化梳理</strong>后，少量人工抽样评测的可用性（3 条均可用称为可用）从 25% 提升到了 70+%。然而这还只是可用率，但是优秀率等高级标准的表现更是惨淡。</p>
<p>无论怎么调整提示词，上述问题依旧时有发生，很不稳定，难以达到令人满意的结果。</p>
<p>我们也尝试换过别的小模型，比如从 <code>qwen3.5-flash</code>、<code>qwen3.6-flash</code> 到 <code>deepseek-v4-flash</code>，我们都尝试过，依旧不尽如人意，且在尝试不同的模型过程中，暴露出来了另外一个更为严重的问题：之前的 prompt 不适配不同的模型，导致切换模型时，表现波动非常大。这种对于模型的依赖性和倾向性，也是危险信号之一。因为我们知道，现在模型迭代非常快，我们肯定是会不断更换更好的模型进行尝试的，但是如果每次更换模型，都需要面临巨大的水位波动，真实业务场景是不能接受这种不稳定的。</p>
<h2>2. 问题分析</h2>
<p>笔者开始怀疑<strong>提示词 + 上下文内容优化梳理</strong>这条路是否是正确的，你如何避免模型输出 <code>找 30 岁以内的二手手机</code> 这种情况呢？😅</p>
<p>笔者认为，当前出现的诸多 bad case，可能并不是提示词的问题，而是实现方案的问题，本身这个架构可能就是错的。</p>
<blockquote>
<p>不是&quot;追问建议的 prompt 写得不够好&quot;，而是把一个<u>产品决策问题</u>交给了 LLM 自由发挥，且它还是一个小模型。</p>
</blockquote>
<h3>2.1 suggestion list 究竟是为了什么</h3>
<p>让我们后退几步，思考一下 suggestion list 这个东西的存在，究竟是为了解决什么问题？</p>
<p>从第一性原理看，用户和 Agent 交互时有几个天然问题：</p>
<ol>
<li>用户往往不知道下一步该问什么；</li>
<li>Agent 的能力边界用户也不清楚；</li>
<li>一个任务通常不是一问一答完成的，而是多轮推进；</li>
<li>用户看到答案后，可能需要继续细化、验证、转换、执行。</li>
</ol>
<p>所以 suggestion list 的真实目标可以归纳为至少 5 个点：</p>
<ol>
<li><strong>降低用户的下一步思考成本</strong>：用户看到回答后，经常会卡在 &quot;然后呢&quot;、&quot;我还可以让它做什么&quot;，好的 suggestion list 应该替用户补上这些下一步。</li>
<li><strong>推进任务，而不是延长对话</strong>：很多系统做 suggestion list 时会犯一个产品错误：把目标理解成&quot;让用户多点几个&quot;，这会导致追问建议组件变成&quot;话题续命器&quot;，而不是&quot;任务推进器&quot;。<u>真正需要思考的是：用户点击后，Agent 能不能更快帮用户完成目标？</u></li>
<li><strong>暴露 Agent 能力，但不能夸大能力</strong>：追问建议组件还可以承担一个能力发现功能，因为用户未必清楚 Agent 的能力边界。</li>
<li><strong>弥补回答后的不完整状态</strong>：很多回答天然会留下后续空间，好的 suggestion list 应该从回答中识别出&quot;天然的下一步&quot;。</li>
<li><strong>帮系统收集更明确的用户意图</strong>：suggestion list 也是一种&quot;低成本意图采集&quot;，用户点击哪个 suggestion，说明他更关心什么。所以 suggestion list 不只是 UI，它还可以帮助后续 Agent 路由、上下文压缩、能力选择和个性化。</li>
</ol>
<h3>2.2 suggestion list 是什么</h3>
<p>suggestion list 不应该被定义成 &quot;生成 3 个追问建议&quot;，而应该是<u>根据当前对话状态，从可执行动作集合中选择 0~3 个高价值下一步，并渲染成用户可点击的自然语言。</u></p>
<p>核心是什么：</p>
<blockquote>
<p>先选择动作，再写成问题。</p>
</blockquote>
<p>而不是：</p>
<blockquote>
<p>让 LLM 根据当前回复随便想 3 个相关问题。</p>
</blockquote>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/ChatGPT%20Image%20Jul%2023,%202026,%2009_52_34%20PM%20(3).png" style="zoom:33%;" />

<h3>2.3 suggestion list 的评价标准</h3>
<h4>2.3.1 suggestion list 本身</h4>
<p>笔者认为，可以从以下 11 个角度判断 suggestion list 的优劣：</p>
<ul>
<li><strong>事实性</strong>：相关结论、约束、判断和锚点必须来自可见上下文，不得编造事实、虚构前提或违反基本常识。</li>
<li><strong>语言通顺</strong>：每条 suggestion 都应语义完整、表达自然，不应出现语病、残句或机械拼接。</li>
<li><strong>用户口吻</strong>：每条 suggestion 都必须是用户可能发送给 AI 的话，而不是 AI 反过来向用户提出的问题。</li>
<li><strong>相关性</strong>：suggestion 必须能够自然承接当前对话，而不是仅仅与整个主题存在宽泛关联。</li>
<li><strong>可执行性</strong>：Agent 必须具备处理该推荐的能力，不能推荐系统实际上无法完成的任务。</li>
<li><strong>上下文充分性</strong>：suggestion 中的对象、条件和指代必须足够明确，使 Agent 在用户点击后可以直接开始处理，而不必重新猜测问题所指。</li>
<li><strong>安全性与合规性</strong>：suggestion 不能诱导用户进入违法、危险、欺诈或其他不适当的方向。</li>
<li><strong>不重复</strong>：不要再次推荐已经回答过、已经执行过，或者用户已经明确拒绝过的内容。</li>
<li><strong>具体性</strong>：suggestion 不能过于空泛，应让用户清楚点击之后将讨论什么问题。</li>
<li><strong>多样性</strong>：三条 suggestion 不应只是同一个问题的轻微改写，而应尽量代表不同但合理的后续方向。</li>
<li><strong>推进性</strong>：suggestion 应推动用户的认知、决策或任务向前发展，而不是单纯为了延长对话。</li>
</ul>
<p>不过，并不是所有场景对这 11 个维度的要求都完全一致。不同对话阶段、不同任务类型，以及不同 Agent 能力边界，会形成不同的优先级和侧重点。因此，可以再将它们划分为底线、基线和上限三个层次。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/ChatGPT%20Image%20Jul%2023,%202026,%2009_52_34%20PM%20(4).png" style="zoom: 33%;" />

<p>底线维度决定一条推荐是否允许展示。一旦违反，即使其他维度表现很好，这条推荐也应当被过滤掉。</p>
<p>基线维度决定 suggestion list 是否达到了基本可用的标准。这些维度保证用户能够看懂推荐，理解其中的对象和意图，并且相信点击之后会得到与当前问题相关的新内容。一组推荐即使没有明显错误，但如果含糊、重复、脱离上下文，仍然不能被认为是合格的 suggestion list。</p>
<p>上限维度决定 suggestion list 是否只是&quot;能用&quot;，还是能够显著改善用户体验和任务完成效率。</p>
<p>多样性并不是简单要求三条问题彼此不同，而是要求它们共同构成一个有价值的下一步决策空间。</p>
<p>推进性则要求每个推荐都能减少用户距离目标的剩余路径。例如，在购物 Agent 中，用户已经明确品类和预算后，好的推荐应该帮助其继续缩小候选范围、比较关键差异或完成购买决策，而不是退回去讨论宽泛的品类知识。</p>
<p>因此，suggestion list 的评价过程不应是简单地计算 11 个维度的平均分，而应遵循分层判断：</p>
<ol>
<li>先检查底线，排除不可展示的结果；</li>
<li>再检查基线，判断推荐是否清晰、相关且可用；</li>
<li>最后评价上限，判断整组推荐能否形成高质量的下一步决策空间。</li>
</ol>
<p>可以将其概括为：</p>
<blockquote>
<p>底线决定能不能展示，基线决定好不好使用，上限决定能不能真正推动用户完成任务。</p>
</blockquote>
<h4>2.3.2 架构/产品迭代/稳定性</h4>
<p>除了聚焦 suggestion list 本身的内容质量，笔者认为我们还需要从系统架构、产品迭代和运行稳定性等非功能性需求出发，评价整套方案的工程质量。</p>
<p>这些能力未必直接决定一条推荐是否合格，却会显著影响系统能否长期维护、快速迭代和稳定优化，因此可以将其视为一些锦上添花的工程能力。</p>
<ul>
<li><strong>可插拔性</strong>：Agent 的能力范围是可能会变化的，无论是产品迭代，还是做灰度、A/B 实验，都有可能会对 Agent 能力范围造成变化。那 suggestion list 与 Agent 能力对齐的难度要尽可能低、尽可能简单，甚至是程序化强绑定。所以单纯靠 Prompt，约束性较差，维护起来强关联性也不足。</li>
<li><strong>可理解性</strong>：最好能知道 LLM 为什么选这些 suggestion list，才方便我们进行追溯、分析和优化。现状是只输出了 <code>[&quot;suggestion1&quot;, &quot;suggestion2&quot;, &quot;suggestion3&quot;]</code>，看似简单，其实是把复杂度都藏在了不可控、无法追溯的黑盒里。当然，一旦要引入更多的可理解化能力，必然会对 RT 有影响。不过可以讨论的一个点是：suggestion list 对 RT 的敏感度有多高？理论上，只要能在用户阅读完正文之前（更前一点）生成完毕即可。所以除非是对 RT 非常非常敏感的场景，笔者都不建议纯输出 <code>[&quot;suggestion1&quot;, &quot;suggestion2&quot;, &quot;suggestion3&quot;]</code>。</li>
<li><strong>可控制性</strong>：确定性程序能够控制的部分，应尽量由程序控制；能够提前验证的问题，应尽量在展示之前完成校验；能够根据明确状态直接下发的内容，也没有必要再次调用模型生成。同时，传递给模型的上下文也不应越多越好。无关信息越多，模型越容易受到噪声干扰，出现注意力分散、错误锚定和幻觉。</li>
</ul>
<h3>2.4 suggestion list 有哪些类型</h3>
<blockquote>
<p>不同的业务场景有不同的分类方式，不同的产品阶段也有不同的分类方式，这里仅提供一些方向，仅供参考。</p>
</blockquote>
<p>从通用的角度来讲，可能分为以下几种类型（按需合并或扩展，不是越多越好）：</p>
<ul>
<li><strong>追问型</strong>：引导用户继续深入当前话题。</li>
<li><strong>快捷回复型</strong>：不是提出新问题，而是帮助用户快速回答 AI 当前的问题。</li>
<li><strong>澄清型</strong>：帮助用户补充完成任务所缺失的信息。</li>
<li><strong>深入探索型</strong>：从当前回答中的某个关键概念继续向下钻取。</li>
<li><strong>横向扩展型</strong>：从当前话题扩展到其他相关方向。</li>
<li><strong>对比决策型</strong>：帮助用户比较多个候选项。</li>
<li><strong>任务推进型</strong>：直接推动用户进入任务的下一阶段。</li>
<li><strong>执行动作型</strong>：点击后不是把一句自然语言发给模型，而是直接执行某个产品动作。</li>
<li><strong>修正与恢复型</strong>：当当前结果不理想时，帮助用户快速纠正方向。</li>
<li><strong>个性化偏好型</strong>：帮助系统持续获取用户偏好。</li>
<li><strong>结果消费型</strong>：围绕已经生成的结果进行二次加工。</li>
</ul>
<p>以二手电商买家 Agent 业务场景来说，可以分为以下几个类型：</p>
<ul>
<li><strong>商品理解型</strong>：帮买家看懂商品、术语、品类和描述</li>
<li><strong>搜索/沟通/议价执行型</strong>：帮买家完成下一步具体动作</li>
<li><strong>个性化筛选型</strong>：按预算、用途、地区、偏好调整建议</li>
<li><strong>风险核验型</strong>：检查商品、价格、卖家、交易方式风险</li>
<li><strong>信息整理型</strong>：把商品信息、聊天信息、候选项整理成清单/表格/摘要</li>
<li><strong>比价决策型</strong>：在多个商品或交易方案之间做选择</li>
<li><strong>交易/物流/售后排障型</strong>：处理订单、物流、退款、维权问题</li>
<li><strong>安全合规替代型</strong>：当原路径有风险时，引导用户走平台内、安全、可保障的方式</li>
</ul>
<blockquote>
<p>最后一个<strong>安全合规替代型</strong>在真实线上业务系统中是非常重要的，我们一定要预留一个 fallback action 用来承接所有其他未穷举到的情况，确保 suggestion 对应的 action 落在我们可控的范围内，而不是仍由 LLM 自由发挥，那是极度不安全的。</p>
</blockquote>
<h3>2.5 suggestion list 怎么分配</h3>
<p>suggestion list 的分配，本质上是在决定：</p>
<blockquote>
<p>有限的 3 个位置，应该覆盖哪些用户下一步意图。</p>
</blockquote>
<p>最好是能根据<strong>当前对话阶段</strong>动态分配。</p>
<ul>
<li>需求还不明确：优先补齐信息.</li>
<li>信息已经充分：优先推进任务</li>
<li>用户在学习和理解：覆盖不同认知方向</li>
<li>用户已经得到结果：围绕结果继续操作</li>
</ul>
<p>除了一般的通用原则之外，还可以根据业务需求制定相关的优先级，甚至是根据重复率进行降级等策略。</p>
<h2>3. 方案探讨</h2>
<h3>3.1 纯 LLM 驱动</h3>
<p>最简单的方案如我们在开篇时所说，可以直接将最后一条 AI 回复给到一个（flash）大模型，直接让其根据上下文生成 n 个 suggestion。</p>
<p>一般来说，提示词我们会分成 <code>system prompt</code> 和 <code>user prompt</code>，这里给一个简单的示例：</p>
<p><code>system prompt</code>:</p>
<pre><code class="language-markdown">## 1. 角色与任务

你是 suggestion list 生成器。请结合当前用户输入、AI 最后一条回复和相关上下文，生成 3 条用户下一步可能点击发送的快捷追问，帮助用户继续推进当前任务。

## 2. 生成原则

### 底线：不能出错
...

### 基线：至少可用
...

### 上限：有效推进
...

## 3. Agent 能力范畴

当前 Agent 可以：

* ...

当前 Agent 不可以：

* ...
## 4. 输出要求

* 使用与用户相同的语言。
* 恰好输出 3 条建议。
* 每条建议必须能够被用户直接点击并原样发送。
* 每条建议尽量简洁。
* 只输出合法 JSON 数组，不要输出解释、标题或 Markdown。
</code></pre>
<p><code>user prompt</code>:</p>
<pre><code class="language-markdown">请根据以下信息生成 3 条快捷追问建议。

&lt;current_user_input&gt;
{{CURRENT_USER_INPUT}}
&lt;/current_user_input&gt;

&lt;assistant_last_response&gt;
{{ASSISTANT_LAST_RESPONSE}}
&lt;/assistant_last_response&gt;

&lt;relevant_context&gt;
{{RELEVANT_CONTEXT}}
&lt;/relevant_context&gt;

你生成的 3 条快捷追问建议是：
</code></pre>
<p>这种方案的优点就是实现简单、架构简洁，可以快速看到效果，而且由于模型的输出内容非常简洁明了，一般 RT 也是比较短的。事实上，如果你的业务场景比较简单、边界比较清晰，或是在项目启动初期，笔者是很推荐使用该方案的，基本上经过简单的提示词和上下文调优，也是可以达到也合格的水准的。如果在加上简单的后训练，用专门的模型去承担该生成任务的话，相信可以进一步抬高下限。</p>
<p>但是在复杂场景，如笔者所面临的二手电商买家场景，那就复杂得多了。这个时候如果你的业务体量比较大，那所有的边界 case 都不容忽视。如前文所述，该方案经过多轮调优后，笔者在实践过程中仍然遇到了诸多问题，且概率不低。</p>
<h3>3.2 引入 ActionSpec</h3>
<p>在复杂业务场景中，我们首先要做的事情就是提高控制权，这是为了将一个无限的开放问题限制在一个可控的有限集合中，这可以大大减小系统的不可控性。另外就是要引入可理解性，这是为了让我们分析 bad case 的时候有理可据。</p>
<p>这个时候我们可以把 Agent 支持的能力，枚举成若干个 <code>Action</code>，每一个 <code>Action</code> 定义一个 <code>ActionSpec</code>，让模型在 <code>ActionList</code> 中进行挑选，比如让模型输出：</p>
<pre><code class="language-json">[
  {
    &quot;action&quot;: &quot;选择的 action&quot;,
    &quot;reason&quot;: &quot;为什么选择这个 reason&quot;,
    &quot;metadata&quot;: { // 这个 action text 对应的可用于结构化校验的元数据
      ..
    },
    &quot;text&quot;: &quot;对应的 suggestion 文案&quot;
  },
  {
    ...
  }
]
</code></pre>
<p>这样除了可以获得 2.3.2 节中提到可插拔性、可理解性和可控制性，由于模型在输出的时候，不是纯粹输出 suggestion，还要输出它对应的 action、reason、metadata，这有一种 CoT（Chain of Thought）的感觉，在这种情况下，模型的表现往往会更好（当然也无法保证它不会犯错）。</p>
<p><code>ActionSpec</code> 的简化版定义可以参考如下：</p>
<pre><code class="language-rust">// 动作枚举
enum Action {
  ...
}

// 动作规约
struct ActionSpec {
  action: Action,      					// 动作类型
  description: String, 					// 动作说明，给开发者看的
  metadata: Option&lt;Metadata&gt;, 	// 该动作对应的元数据，可选
  enabled: bool,								// 该动作是否可用
  select_condition: String, 		// 选择条件，告诉模型什么情况下可以选择这个 action，什么时候不选择
  validate: Option&lt;Func&gt;				// 校验 metadata 的处理函数，可选
  text_guide: String,						// 这个 action 对应的 text 要怎么写，有哪些原则需要遵循
}
</code></pre>
<p>这个时候提示词模版也需要修改一下，模型的能力范畴改为动态注入，可参考如下。</p>
<p><code>system prompt</code>: </p>
<pre><code class="language-markdown">## 1. 角色与任务

你是 AI Agent 的快捷追问生成器。

请根据当前用户输入、AI 最后一条回复、相关上下文和当前可用的 ActionSpec，生成 3 条用户可以直接点击发送的快捷追问。

每条结果包含：

* `action`：选择的 Action；
* `reason`：选择该 Action 的简要原因；
* `metadata`：用于校验或执行该 Action 的结构化信息；
* `text`：展示给用户的快捷追问文案。

## 2. 生成原则

### 底线
...

### 基线
...

### 上限
...

## 3. ActionSpec 使用规则

* 只能从 `&lt;available_action_specs&gt;` 中选择 Action，不得创建新的 Action。
* 必须满足对应的 `select_condition`。
* `text` 必须遵守对应的 `text_guide`。
* `metadata` 必须符合对应的 metadata 定义。
* metadata 的值只能从输入上下文中提取，不得猜测。
* 缺少必要 metadata 时，不得选择该 Action。
* `reason` 只说明选择依据和推进价值，不要输出详细推理过程。

## 4. 输出要求

* 恰好输出 3 条结果。
* `action` 必须与 ActionSpec 中的名称完全一致。
* `text` 使用与当前用户相同的语言。
* 不需要 metadata 时输出空对象 `{}`。
* 只输出合法 JSON，不要输出 Markdown 或其他文字。

[
  {
    &quot;action&quot;: &quot;action_name&quot;,
    &quot;reason&quot;: &quot;选择该 Action 的简要原因&quot;,
    &quot;metadata&quot;: {},
    &quot;text&quot;: &quot;用户可直接发送的快捷追问&quot;
  }
]
</code></pre>
<p><code>user_prompt</code>: </p>
<pre><code class="language-markdown">&lt;available_action_specs&gt;
{{AVAILABLE_ACTION_SPECS}}
&lt;/available_action_specs&gt;

&lt;current_user_input&gt;
{{CURRENT_USER_INPUT}}
&lt;/current_user_input&gt;

&lt;assistant_last_response&gt;
{{ASSISTANT_LAST_RESPONSE}}
&lt;/assistant_last_response&gt;

&lt;relevant_context&gt;
{{RELEVANT_CONTEXT}}
&lt;/relevant_context&gt;

请遵循**生成原则**，根据&lt;current_user_input&gt;和&lt;relevant_context&gt;，从&lt;available_action_specs&gt;中选择 3 个能承接&lt;assistant_last_response&gt;的不同方向，并输出合法的 JSON 结果。

你选择的方向是：
</code></pre>
<p>这里建议动态注入的部分，一定要把 <code>AVAILABLE_ACTION_SPECS</code> 放在最靠近 <code>system prompt</code> 的地方，因为它的变动频率是比较低的，这样我们可以按照提示词的稳定程度 **不变 &gt; 低变化 &gt; 高变化 **来组织上下文，从而最大限度利用 KV Cache。</p>
<p>这个时候整条链路可以简化如下：</p>
<pre><code class="language-mermaid">flowchart LR
    A[固定 System Prompt] --&gt; B[注入可用 ActionSpec]
    B --&gt; C[注入当前对话上下文]
    C --&gt; D[Flash 模型生成 Suggestions]
    D --&gt; E[程序化校验]
    E --&gt; F[返回 3 条 Suggestion]
</code></pre>
<p>程序化校验的逻辑展开如下：</p>
<pre><code class="language-mermaid">flowchart LR
    A[模型输出] --&gt; B{Action 合法?}
    B --&gt; C{Metadata 合法?}
    C --&gt; D{业务校验通过?}
    D --&gt; E[展示]
    B --&gt;|否| F[丢弃或降级]
    C --&gt;|否| F
    D --&gt;|否| F
</code></pre>
<p>还有一个可以进一步缩短上下文、降低幻觉、降低模型理解成本的优化思路是进一步精简 <code>AVAILABLE_ACTION_SPECS</code>，因为我们在模型生成回复后是需要进行程序化校验 <code>metadata</code> 的，那如果我们在生成之前就可以判断哪些 <code>metadata</code> 是不可能获取的（或者是可以明确判断当前哪些方向是不可行的），那就没必要把其相关的 <code>ActionSpec</code> 给到模型了，这个时候就可以进一步减小模型可选的空间了，这样模型的注意力会更专注一点。</p>
<p>这个时候整条链路就变成了：</p>
<pre><code class="language-mermaid">flowchart LR 
	A[固定 System Prompt] --&gt; D[注入候选 ActionSpec] 
	B[完整 ActionSpec] --&gt; C[程序化预过滤] 
	X[当前对话上下文] --&gt; C 
	C --&gt; D 
	X --&gt; E[注入当前对话上下文] 
	D --&gt; E 
	E --&gt; F[Flash 模型生成 Suggestions] 
	F --&gt; G[程序化结果校验] 
	G --&gt; H[返回 3 条 Suggestion]
</code></pre>
<p>我们来总结一下这个方案的优缺点。</p>
<p>优点：</p>
<ol>
<li>可控性提高：将 suggestion 的可选方向从 LLM 随机发挥的无限集合收拢到程序控制的有限集合中，同时也引入了 metadata 供程序进行结构化校验。</li>
<li>可插拔化：这个不赘述。</li>
<li>引入可理解性：不再是单独输出 suggestion 文案，还让模型输出了 reason，这在一定程度也是一个 CoT 的过程，可以提高模型输出的稳定性。</li>
</ol>
<p>缺点：</p>
<ol>
<li>依旧是一个模型包揽一切，跟第一个方案本质上还是没有区别。</li>
<li>如果业务上强制要求 3 条 suggestion，为了防止 metadata 过滤后导致条数不足，那就需要进行冗余输出，当然仍然可能会出现条数不足的情况，这里是建议宁缺毋滥。</li>
<li>输出结构复杂度提升了，RT 会增加不少，甚至可能会输出非法 JSON，需要有对应的修复 JSON 的逻辑兜底。</li>
</ol>
<h3>3.3 selector+writer</h3>
<p>方案二只是限制了模型的输出空间；方案三进一步限制了每个模型节点的认知任务。这个方案的出发点是想将模型的职责进行拆分，如我们前面分析，生成 suggestion list 其实并不是一件简单的事情，它不是&quot;生成 3 个追问建议&quot;，而应该是<u>根据当前对话状态，从可执行动作集合中选择 0~3 个高价值下一步，并渲染成用户可点击的自然语言。</u></p>
<p>所以这里笔者的核心思路是将<strong>方向选择</strong>和<strong>文案生成</strong>，拆成 2 个节点，前者专门负责方向选择，后者仅需将选择好的合法方向渲染成合适的文案即可。整个流程就变成了：</p>
<pre><code class="language-mermaid">flowchart LR
    A[上下文 + 预过滤后的 ActionSpec]
    --&gt; B[方向选择&lt;br/&gt;选择 0~3 个 Action]
    --&gt; C[程序化校验&lt;br/&gt;Action / Metadata]
    --&gt; D[文案渲染&lt;br/&gt;生成用户口吻 Text]
    --&gt; E[suggestion list]
</code></pre>
<p>这里方向选择有 2 种大方向可以选择：</p>
<ul>
<li><strong>程序化决策</strong>：如果业务流程的状态流转是有明确可识别信号的，可以在程序内部就确定当前属于什么状态，下一步可以是什么状态，那是最理想的，这个时候，可以直接用代码逻辑选择合适的方向。</li>
<li><strong>大模型识别</strong>：多数复杂的业务场景可能无法提供那么清晰化的状态流转信号，尤其是 Chatbot 类型的应用，比如笔者负责的二手电商买家侧 Agent，买家的动作是不可预估的，购买任务也不是线性一路走到黑的，而是一个错综复杂的流转过程。这个时候，定义状态机是不现实的。更现实的思路是，不在乎过往的一切状态，仅聚焦在当前所在的位置，从当前节点出发，去判断能往哪些方向前行。这个时候往往我们能拿到的信息都是自然语言文本，确定性程序是解决不了的，或者说是不够灵活、难以全面覆盖的。这个时候，反而就是大模型优势之所在了，语言理解，恰恰是大模型的强项。</li>
</ul>
<pre><code class="language-mermaid">flowchart LR
    B[完整 ActionSpec] --&gt; C[ActionSpec 预过滤]
    A[当前对话上下文] --&gt; C

    C --&gt; D{是否存在明确业务信号?}
    D --&gt;|是| E[程序化 Selector]
    D --&gt;|否| F[Selector LLM]

    E --&gt; G[0~3 个 Action + Metadata]
    F --&gt; G

    G --&gt; H[程序化校验]
    H --&gt; I[Writer LLM]
    A -.-&gt; I

    I --&gt; J[suggestion list]
</code></pre>
<p>这个时候，我们需要丰富一下 <code>ActionSpec</code> 的定义：</p>
<pre><code class="language-rust">// 动作规约
struct ActionSpec {
  action: Action,      					// 动作类型
  description: String, 					// 动作说明，给开发者看的
  metadata: Option&lt;Metadata&gt;, 	// 该动作对应的元数据，可选
  enabled: bool,								// 该动作是否可用
  select_condition: String, 		// 选择条件，告诉模型什么情况下可以选择这个 action，什么时候不选择
  validate: Option&lt;Func&gt;				// 校验 metadata 的处理函数，可选
  text_guide: String,						// 这个 action 对应的 text 要怎么写，有哪些原则需要遵循
	
  // 新增 build_text_guide
  build_text_guide: Func&lt;Metadata&gt; // 这个 action 对应的 text 要怎么写，有哪些原则需要遵循，动态注入 Metadata 对应的实体的详细信息
}
</code></pre>
<p>我们可以新增一个 <code>build_text_guide</code> 的函数来替代固定的 <code>text_guide</code> 文本，它的好处是可以根据 Selector LLM 输出的 metadata，动态注入其指定的实体的详细信息，这样 Writer LLM 的关注点会更聚焦。</p>
<p>举个例子，比如我们的 Action 是 <code>compare_product</code>（商品比较），那 <code>metadata</code> 就是 2 个 <code>product_id</code>（商品 ID），这 2 个 ID 是 Selector LLM 在 n 个商品列表中选择的要比较的 2 个商品，我们需要把整个商品列表的信息给到 Selector LLM。这时候在注入上下文给 Writer LLM 的时候，我们可以根据 <code>product_id</code> 查出商品的详细信息，然后<strong>仅仅</strong>注入这 2 个商品的信息给到 Writer LLM，这样 Writer LLM 就会聚焦在这 2 个商品上，而不会被其他商品信息分散了注意力，这可以大大降低幻觉的概率。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/ChatGPT%20Image%20Jul%2023,%202026,%2009_52_35%20PM%20(5).png" alt=""></p>
<p>笔者经过实验，在我们的业务场景上，满分 100 分的情况下，仅生成的 suggestion list 的质量这一个维度，该方案可以比 LLM 直出高 20 分左右。更重要的是，这个方案在架构、产品迭代和稳定性方面带来的增益，也是不可忽视的。</p>
<p>唯一的缺点就是，方向选择这一关节点如果无法进行程序化决策，而只能由大模型进行选择的话，那就会多一次 LLM 调用，RT 会进一步增加。这个情况下，就需要分别针对 Selector 和 Writer 训练专门的小模型来进一步压缩 RT 了。那如果 RT 依旧是业务无法接受的情况下，退一步讲，你也可以用该架构生成优质的 suggestion list 来作为方案一微调时的训练数据。</p>
<h3>3.4 selector+writer+verifier</h3>
<p>在实践过程中，方案三已经可以达到比较高的水位了，通过控制给到 Writer LLM 的上下文范围，可以极大降低模型幻觉的概率。不过依旧无法避免，你依旧无法避免模型在为多个 Action 生成 suggestion 的时候，出现张冠李戴等幻觉错误。</p>
<p>这个时候如果要进一步降低出错概率，那可以在输出之前引入一个后置校验节点 <code>verifier</code>。</p>
<pre><code class="language-mermaid">flowchart LR
    A[Selector LLM&lt;br/&gt;选择 Action + Metadata]
    --&gt; B[程序化校验]
    --&gt; C[Writer LLM&lt;br/&gt;生成 Suggestion Text]
    --&gt; D[Verifier&lt;br/&gt;校验 Action、实体与文案是否一致]
    --&gt; E{通过?}

    E --&gt;|是| F[返回 suggestion list]
    E --&gt;|否| G[丢弃或重试]
</code></pre>
<p>但是这个时候就不太推荐再引入一个大模型来进行校验了，可以针对一些高频、高危的场景，做一些简单的程序化校验，如内部实体 ID 的检测、高危敏感词的正则匹配等。 </p>
<h2>4. 实践建议</h2>
<p>先回顾一下 4 个方案的演进过程：</p>
<table>
<thead>
<tr>
<th>方案</th>
<th>主要解决的问题</th>
<th>仍然存在的问题</th>
</tr>
</thead>
<tbody><tr>
<td><strong>纯 LLM 直出</strong></td>
<td>快速生成，低成本接入</td>
<td>模型自由发挥，能力越界，结果不可解释</td>
</tr>
<tr>
<td><strong>ActionSpec + 单 LLM</strong></td>
<td>将无限生成空间收敛到有限 Action 集合，并支持结构化校验</td>
<td>方向选择和文案生成仍由同一个模型完成，职责过重</td>
</tr>
<tr>
<td><strong>Selector + Writer</strong></td>
<td>拆分方向选择与文案生成，并通过精简上下文降低张冠李戴和幻觉</td>
<td>Writer 仍可能生成与 Action、Metadata 不一致的文案</td>
</tr>
<tr>
<td><strong>Selector + Writer + Verifier</strong></td>
<td>在输出前增加最后一道一致性与安全性校验</td>
<td>RT、成本和系统复杂度进一步上升</td>
</tr>
</tbody></table>
<p>它们的对比总结如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/ChatGPT%20Image%20Jul%2023,%202026,%2009_52_36%20PM%20(6).png" alt=""></p>
<p>四个方案不是互斥的技术路线，而是一条逐步增加控制权的演进路径：从让模型自由生成，到约束动作空间，再到拆分决策与表达，最后增加后置校验。系统每向后演进一步，通常都会获得更高的稳定性和可控性，同时付出更高的 RT、成本与工程复杂度。</p>
<p>在实践的过程中，我们没必要去盲目套用这里面的任何方案，核心是要先明确不同实现方式的强项在哪里，再根据自己的业务背景和项目架构进行灵活选择：</p>
<ul>
<li><strong>程序化</strong>：结构化、强状态、可明确枚举的场景，通常具有更低延迟、更强可控性和更高确定性。</li>
<li><strong>大模型</strong>：适合非结构化、需要语境理解、不可穷举、个性化文本生成。</li>
</ul>
<p>最终实现的效果如何，我们还是需要搭建一套评测链路来进行评测比较，才能下结论。</p>
<p>而现在这种 AI Agent 应用跟传统的互联网应用又不一样，它无法像我们之前的确定性逻辑一样，快速回归一下单元测试或集成测试，或是简单跑一个批量脚本，就可以得到改进的效果数据。整条链路是不确定性和复杂度会大很多。</p>
<p>但是利用 AI，我们可以在实践过程中总结一下 SKILL 来串起整个过程，从而提高我们迭代的自动化程度和效率，把冗长、重复的执行过程交给 AI 去做，从而解放自己。</p>
<p>这里笔者结合自己的实践，给出一些建议供各位读者参考：</p>
<ul>
<li>整个 Agent 所有的执行链路，包括但不限于模型调用、工具调用和中间件调用等，都落好 trace，尤其是出入参和耗时。同时可以总结一个根据 traceID 自动拉取 trace 明细并结合代码分析问题的 SKILL。</li>
<li>评测平台要能直接拉取线上、测试环境的 trace，并将它们沉淀为待评测数据集，并制定相关的评测指标，使用 LLM as a Judge 进行测评活动。同时可以总结一个分析整个数据集评测结果、或者分析单条 bad case 的 SKILL，这样可以直接让 AI 拉取评测结果 case 并结合评测结果、trace、代码综合分析问题所在，并给出改进建议。</li>
<li>评测平台拉下来的 trace，还可以作为系统回放的仿真数据集，这样在系统迭代时，我们可以用真实的流量进行回放，并对比回放结果的评测表现，从而判断系统的水位起伏。</li>
<li>将上面 3 个串起来，我们就可以再总结一个自我迭代的 SKILL，我们只需要指定好评测标准，给到目标分数，让 AI 自己跑整个迭代循环，如：<code>优化代码 &gt; 仿真回放 &gt; 运行评测 &gt; 分析评测结果 &gt; 拉取 bad case trace &gt; 归因分析 &gt; 优化代码/调整评测标准 &gt; 直到达到目标或最大迭代次数</code>。</li>
</ul>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/ChatGPT%20Image%20Jul%2023,%202026,%2009_52_36%20PM%20(7).png" style="zoom: 33%;" />

<h2>5. 过程反思</h2>
<p>在实践过程中，笔者也走了一些弯路，这里简单做下总结，可供参考：</p>
<ul>
<li>AI 在修改提示词或者代码的时候，很喜欢根据 bad case 进行 case by case 针对性修复，如 prompt 里面直接将评测数据集中的 case 指定为正例或反例，或是在代码里面使用正则匹配、字符串过滤等硬编码逻辑。一定要警惕这种行为，要在 <code>CLAUDE.md</code> 和 <code>AGENTS.md</code> 里面明令禁止，要明确要求 AI 在撰写 prompt 和编码的过程中，要抽象出 bad case 背后的通用性原则，而不是进行硬编码。</li>
<li>现在很多人不仅是 vibe coding，prompt 大部分也都是 AI 写的，这在一开始是没有问题的，但是随着 AI 自身的不断迭代，prompt 会变得非常冗长、重复、多余、矛盾、不可理解，这个时候，人工介入进行手工重写，是很有必要的。</li>
<li>仿真回放的时候没有必要走整个业务流程（ Agent + suggestion list），可以固定 Agent 的输出，作为快照，在仿真的时候，直接进入 suggestion list 节点，这样既可以稳定场景进行不断复现比较，也可以省资源，同时效率也会大大提升。</li>
</ul>
<h2>6. 总结</h2>
<p>suggestion list 的本质不是&quot;生成几个追问&quot;，而是帮助 Agent 在当前对话状态下选择一个高价值、可执行的下一步，并将其转换成用户可理解的自然语言。对于简单场景，纯 LLM 生成已经足够；但在复杂业务中，需要逐步引入 ActionSpec、Selector/Writer 拆分以及 Verifier 等机制，将模型的自由发挥约束在可控范围内。同时，AI Agent 的优化也不能依赖人工调 Prompt，而应该结合 Trace、评测、Bad Case 分析和仿真回放，建立持续迭代闭环，最终实现稳定、可控、可演进的 Agent 系统。</p>
<p>另外，除了静态的评测结果，还需结合点击率、点击后继续对话率、任务完成率、退出率，以及相对无 suggestion 对照组的任务成功率提升等业务指标来综合判断我们的快捷追问组件是否真的能起到推动用户完成任务的作用。</p>
]]></content:encoded>
    </item>
    <item>
      <title>ReAct 是更好的故事，Workflow 是更好的系统</title>
      <link>https://hedon.top/blog/ai-agent-react-vs-workflow/</link>
      <guid isPermaLink="true">https://hedon.top/blog/ai-agent-react-vs-workflow/</guid>
      <pubDate>Mon, 15 Jun 2026 21:58:37 GMT</pubDate>
      <description>从一次电商 AI Agent 实践出发，聊聊 ReAct Agent、Workflow Agent，以及为什么业务生产系统最终还是要回到确定性。</description>
      <category>AI Agent</category>
      <content:encoded><![CDATA[<p>最近一直在公司做一款电商方向的 AI Agent，大概是辅助购物类的 Agent，更具体的就不方便说了（虽然我也不知道有没有关系）。项目当然也还没成功，还未全面上线，不过在这个过程中，笔者也算积累了不少的经验（<del>折磨</del>），尤其是在 ReAct 和 Workflow 这两种架构模式上，有了一些个人的理解，所以就准备写篇文章来唠唠。</p>
<blockquote>
<p>自从有了 AI 后，手写文章的次数和频率真是越来越少了。</p>
</blockquote>
<p>本篇不是严格意义上的技术分享，也不是某个框架的最佳实践教程。笔者更想聊的是一个很现实的问题：为什么大家都喜欢做 ReAct Agent？为什么它在 Demo、PPT、社交媒体里面那么有吸引力？以及，为什么真正到了高频、低容错、强业务目标的生产场景里，笔者反而越来越觉得，Workflow 才是更现实的底座。</p>
<p>这里可以先给出笔者的个人观点：</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--info" aria-hidden="true"></span></p>
<p><strong>一句话核心</strong></p>
<p>ReAct 是更好的故事，Workflow 是更好的系统。</p>
</blockquote>
<p>当然，这句话不是说 ReAct 没有价值，也不是说 Workflow 能解决一切问题。更准确一点说，ReAct 适合处理那些路径未知、需要动态探索的问题；Workflow 适合承接那些目标明确、流程稳定、需要强兜底的业务系统。真正靠谱的生产架构，通常也不是二选一，而是让 Workflow 控制主流程，让 ReAct 只进入那些局部不确定、但又可以被验证和兜底的小闭环里。</p>
<h3>什么是 ReAct、什么是 Workflow</h3>
<p>先讲 Workflow。</p>
<p>Workflow 看名字就知道，它代表的是一个需要被前置定义好的工作流程。系统按照一个相对明确的执行图往下跑。它适合那些流程比较固定的场景，优点就是确定性高、稳定性强、好调试、好兜底，缺点也很明显：通用性较差，不太适合那种一开始就不知道要怎么做的开放性任务。</p>
<pre><code class="language-mermaid">flowchart LR
    I[Input]
    subgraph Workflow
        C1[Step or node 1]
        G{Gate or route}
        C2[Step or node 2]
        C3[Step or node 3]
        J[Join or evaluator]
        O[Output]
        I --&gt; C1 --&gt; G
        G --&gt; C2 --&gt; J
        G --&gt; C3 --&gt; J
        J --&gt; O
    end
</code></pre>
<p>ReAct 则不一样。ReAct 来自 <code>Reasoning and Acting</code>。在工程实现中，它通常被展开为 <code>Thought/Reasoning → Action → Observation</code> 的循环：模型先基于上下文生成下一步判断，再调用工具或执行动作，然后把外部观察结果重新放回上下文，继续下一轮决策。</p>
<pre><code class="language-mermaid">flowchart LR
    U[User task]
    subgraph ReAct
        P[Prompt and tool schemas]
        T[Model reasoning]
        A{Tool action?}
        O[Tool result or environment observation]
        F[Final answer]
        P --&gt; T --&gt; A
        A --&gt;|yes| O --&gt; T
        A --&gt;|no| F
    end
    U --&gt; P
</code></pre>
<p>从技术实现细节上看，很多 ReAct Agent 的退出条件都比较简单：模型不再产生 Tool Call，或者返回了最终答案，或者触发了最大轮次、异常、业务停止条件。也就是说，ReAct 的核心不是&quot;固定执行哪几步&quot;，而是&quot;每一步都由模型根据当前上下文决定下一步&quot;。</p>
<p>这就是 ReAct 和 Workflow 最根本的差异。</p>
<p>Workflow 是人提前设计控制流，模型只是某些节点里的能力组件；ReAct 是把相当一部分控制流交给模型，让模型在运行时边想边做。我们能做的就是通过 Prompt Engineering 去做引导、通过 Context Engineering 给予它更充分（有效）的上下文信息、通过 Harness Engineering 去进行约束控制帮助它进行自我纠正，以及通过 Loop Engineering 去定义验收标准从而指导它自我进化。ReAct 的优点就是高度灵活、通用性较强，但是缺点也非常明显：不可控、不稳定、难调试。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/ChatGPT%20Image%20Jun%2016,%202026,%2009_16_41%20AM%20(1).png" alt=""></p>
<p>很多复杂 Agent 架构都可以看成 ReAct Loop 的扩展，只不过有人在里面加了 Planner，有人加了 Memory，有人加了 Reflection，有人加了 Evaluator，有人加了多 Agent 协作，有人用图结构把它包起来。名字越来越多，架构图越来越复杂，但究其根本，都是 ReAct。</p>
<h3>ReAct 和 Workflow 怎么选</h3>
<p>这 2 个咋选呢？答案可能是：</p>
<ul>
<li>Workflow：步骤明确的常规任务。</li>
<li>ReAct：非固定流程，开放性问题。</li>
</ul>
<p>笔者觉得比较抽象的答案是这样的：</p>
<ul>
<li>你知道怎么做 ☞ Workflow</li>
<li>你不知道怎么做 ☞ ReAct</li>
</ul>
<p>虽然比较糙，但是笔者觉得描述得还是比较准确的。</p>
<p>比如，用户要查订单、改地址、退款、投诉、申诉，这类任务虽然也会有各种边界 case，但主流程大体是明确的。你知道要查什么数据，知道要校验什么条件，知道成功和失败分别怎么处理，知道异常要怎么兜底。这个时候强行上一个全自由的 ReAct Agent，让它自己决定下一步要不要查订单、要不要校验状态、要不要调用退款工具，听起来很 AI Native，实际上就是把原本清清楚楚的业务流程交给一个概率模型去摇骰子。</p>
<p>但是，如果任务是&quot;帮我分析一下为什么这个系统最近转化率下降了&quot;，或者&quot;帮我定位 Bug&quot;，那你很难提前定义完整流程。它可能要查日志、看代码、跑测试、搜索历史 Issue、对比版本、猜测原因、验证假设。这个时候 ReAct 的优势就出来了，因为它可以根据每一步观察结果动态调整方向。</p>
<p>为了进一步讲清楚这个观点，我们需要对这 2 个模式进行更深入的比较。不过这里先亮出一个表格，不做过多的赘述，各位读者可以在后文中慢慢感受 😄。</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>ReAct Agent</th>
<th>Workflow Agent</th>
</tr>
</thead>
<tbody><tr>
<td>控制流</td>
<td>动态生成</td>
<td>预先定义</td>
</tr>
<tr>
<td>规划方式</td>
<td>由 LLM 在运行时生成</td>
<td>由开发者提前设计</td>
</tr>
<tr>
<td>下一步动作</td>
<td>运行时由模型决定</td>
<td>执行前已经确定</td>
</tr>
<tr>
<td>可靠性</td>
<td>较低</td>
<td>较高</td>
</tr>
<tr>
<td>灵活性</td>
<td>较高</td>
<td>较低</td>
</tr>
<tr>
<td>成本</td>
<td>通常较高</td>
<td>通常较低</td>
</tr>
<tr>
<td>调试难度</td>
<td>更难调试</td>
<td>更容易调试</td>
</tr>
<tr>
<td>生产适用性</td>
<td>适合探索型、不确定任务</td>
<td>适合明确流程、业务系统</td>
</tr>
</tbody></table>
<h3>为什么大家都在做 ReAct Agent</h3>
<p>看似 ReAct 和 Workflow 各有优劣，应该是各站半壁江山的场面，那为什么大厂和各种社交媒体里面，都在鼓吹它们做的 ReAct Agent<del>（Demo）</del>呢？</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>一句话核心</strong></p>
<p>一句话：好讲故事！</p>
</blockquote>
<p>&quot;死逻辑&quot;的 Workflow 有啥好吹牛呢，那不就是传统业务流程换了个壳吗？那能跟 AI Native 有什么关系呢？随机应变、&quot;全知全能&quot;、能&quot;自主进化&quot;的 ReAct Agent，那才是真正的 AI Native！那才是 AI 时代&quot;我们&quot;应该做的事情！</p>
<p>Workflow 更像是一个 SOP，一个流程引擎，一个状态机。它可能更稳、更便宜、更好维护，但它不性感。你很难拿着一个&quot;先查库存、再校验状态、再生成推荐、再兜底异常&quot;的流程图去讲一个激动人心的 AI 故事。</p>
<p>但 ReAct 可以。</p>
<p>它可以讲&quot;自主规划&quot;，可以讲&quot;动态决策&quot;，可以讲&quot;自我修复&quot;，可以讲&quot;像人一样工作&quot;。这些词放在 PPT 里非常好看，放在 Demo 里也非常震撼。至于真实线上系统里它能不能稳定跑、成本能不能兜住、RT 能不能接受、异常能不能监控，那就是另外一回事了。</p>
<p>当然啦，上述观点肯定略显偏颇。笔者斗胆猜测，还有另外一个很重要的原因就是：Claude Code 和 Codex 这类 Coding Agent（当然它们不止 coding 能力）做得太好了，太突出了，太令人意外了。所以都认为能在他们的业务领域内做出比肩甚至超过 Claude Code、Codex 的 Agent。</p>
<h3>为什么 Coding Agent 是一个特殊样本</h3>
<p>笔者认为除了 Coding Agent，面向大众生活场景的业务，只有 Workflow 这一条路可以走。</p>
<p>更严谨的说法可能是：</p>
<blockquote>
<p>在高频、低容错、强业务目标的消费级业务场景里，主控流应该优先选择 Workflow；ReAct 更适合作为局部不确定节点中的能力模块，而不是整个系统的主架构。</p>
</blockquote>
<p>事实上，绝大部分业务系统的 ReAct Agent 演变着演变着就是  Workflow。</p>
<p>我们需要思考，为什么 Claude Code 和 Codex 能做得那么好？以及，它们真的就足够智能吗？真就那么 Agentic 吗？</p>
<p>笔者觉得不能只看模型本身。以现在 Transformer 为基础的大模型，说到底它仍然是一个概率模型。它可以表现出很强的推理行为，但从工程视角看，我们不能把它当成一个稳定、可验证、可重复的符号推理系统。所谓的 CoT，很多时候也不是严格意义上的&quot;思考过程&quot;，而是一种对思考过程的语言化模拟。</p>
<p>那想让它做得好至少需要以下几个前提：</p>
<ol>
<li><strong>足够多的优质素材</strong>：GitHub、Stack Overflow、开源项目、技术博客、Issue、PR、文档，这些东西给大模型提供了大量高质量代码和工程语料。代码世界虽然复杂，但它的表达形式高度结构化，而且很多问题在互联网上都留下过痕迹。</li>
<li><strong>足够健壮的验证设施</strong>：软件工程发展了这么多年，编译器、类型系统、Lint、单元测试、集成测试、语法分析器、CI/CD、日志、监控，这些东西本来就是为了减少人类犯错而存在的。结果到了 Agent 时代，它们天然就变成了 ReAct 的 Observation 来源。模型写错了，编译器会骂它；测试挂了，测试结果会告诉它哪里不对；类型不匹配，IDE 和语言服务会直接提示。模型每犯一次错，都可能得到一个明确的外部反馈，然后继续修。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/ChatGPT%20Image%20Jun%2016,%202026,%2009_16_43%20AM%20(4).png" alt=""></p>
<p>这就很关键。ReAct 不是因为模型&quot;突然有灵魂了&quot;才强，而是因为它在 Coding 场景里有大量高质量、低成本、可自动化的反馈信号。</p>
<p>事实上，更重要的还有什么？还需要有人愿意主动去使用、去反馈、甚至去为它构建一系列的设施工具来增强它。Coding Agent 有大量专业的使用者，其中不乏大量自愿付费的使用者，他们在各种各样的业务场景下进行了各种各样的验证和反馈，才能让 Coding Agent 有如此快速和惊人的发展。</p>
<h3>反观常规业务系统</h3>
<p>那反观常规的业务系统呢？存在以下几个必须要面对的难题：</p>
<ol>
<li>**好好的用户凭什么来用你的 AI Agent？**Claude Code 是开发者求爷爷告奶奶想方设法找各种路子去使用，业务 Agent 很多时候是业务方求着哄着用户来使用。一个是用户主动找工具解决强痛点，一个是产品希望用户来体验一个&quot;看起来很智能&quot;的新入口。这两者的起点完全不一样。</li>
<li>**盈利点在哪里？**整了那么多花里胡哨的&quot;升级&quot;后，带来的那点转化率的提升所带来的利润收入，真的能对冲其带来的额外维护成本和 token 费用吗？甚至更残酷一点，转化率真的有提升吗？</li>
<li>**真的适合吗？真的做得好吗？**你的业务场景真的适合上 ReAct Agent 吗？编程场景里有编译器、测试、类型系统、日志这些验证设施，可以不断给 Agent 纠偏。那你的业务场景里有吗？用户说&quot;这个能买吗&quot;，你能不能定义什么叫&quot;能买&quot;？是价格合适？成色合适？卖家可信？物流可控？售后风险低？还是用户当前预算和偏好匹配？这些判断很多时候并没有一个像单元测试那样明确的红绿灯。没有高质量 Observe，ReAct 的 Reason 就很容易变成自说自话。</li>
</ol>
<p>除此之外，Claude Code、Codex 真的就那么强大、那么 Agentic 了吗？那为什么还需要开发者去维护那么多的 <code>CLAUDE.md</code>、<code>AGENTS.md</code>？搞什么 <code>skills</code>、<code>rules</code>、<code>commands</code>、<code>plugins</code>？即便有了这些，再搭配（当下）最强大的 Claude 模型，在有些场景下依旧表现很差，需要反复调教，甚至最后花了几百美刀后还是只能靠人工介入才能彻底解决。</p>
<p>除此之外，普通业务系统还有一个很大的矛盾：<strong>错误容忍度非常低。</strong></p>
<p>常规业务系统本身就要容忍代码缺陷、脏数据、依赖异常、网络波动，现在你还要额外容忍一个不确定的 Agent。更麻烦的是，Agent 的错误不一定是直接失败，它可能是效果变差、回答变奇怪、推荐不稳定、语气不合适、状态判断轻微偏移。这种错误很可怕，因为它是温水煮青蛙式地变差。你要是监控系统做不到位，等知道的时候就已经晚了。</p>
<p>这还只是系统本身，还有另外一方面，<strong>用户对错误的容忍度是多少？</strong></p>
<p>Coding Agent 跑几分钟、十几分钟，甚至更久，开发者很多时候是能接受的。因为手工 Coding 本来就慢，Agent 就算绕路，只要最后能把事情做成，用户也觉得值。但是消费级业务系统不一样。用户点一下按钮、发一句话，很多时候期望的是秒级甚至毫秒级响应。你 ReAct Loop 即便具备所谓的自我修复、自我纠正能力，可每多一轮思考，就多一次模型调用；每多一次工具调用，就多一次延迟；每多一次错误纠正，就多一层 token 成本。</p>
<p>这你的用户能接受吗？</p>
<p>还有一个问题是，<strong>普通用户没有能力、也没有意愿帮你提升系统上限</strong>。</p>
<p>常规的业务系统需要把用户当小白，尽可能让系统通用、易用、好用。你不敢让你的用户做太多的操作，因为每多一个步骤，你就害怕用户弃之而去。Coding Agent 不一样啊，它的受众绝大部分都是一群专业的开发者，他们天生就具备构建能力，他们也愿意去根据当下 Coding Agent 的不足去捣鼓各种技巧来提升 Agent 的上限。不仅如此，他们还乐于分享（<del>真是该死</del>）！他们会把自己的各种奇技淫巧分享在各大平台上，让那些本没那么专业的用户也能提升使用 Coding Agent 的能力。</p>
<h3>于是我们踩了什么坑</h3>
<p>为什么笔者会有这样的一些感想呢？回到开头，笔者提到，最近一直在公司做一款电商方向的 AI Agent。</p>
<p>AI 时代，我们要做肯定是要做 AI Native 产品嘛！所以就希望以 ChatBot 的形式来展现这款 AI Agent（笔者也不知道为什么 ChatBot 就是 AI Native 了...）。我们一上来就搞 ReAct Agent，那会刚好赶上 Claude Code &quot;开源&quot;（<del>源码泄露</del>），所以我们的口号必然就是：</p>
<blockquote>
<p>对标 Claude Code。</p>
</blockquote>
<p>demo 阶段当然是说得过去了，做 demo 谁不会呢？AI 时代，是个人都能做出吹上天的 demo。</p>
<p>但是随着需求的不断细化、验证过程中不断发现的一些边界 case 和异常情况，发现越来越兜不住了！那怎么办呢？Context Engineering 啊！调 Prompt，上 skill！还是不行怎么办呢？Harness Engineering！代码逻辑强行纠正，上正则，上规则判断，上各种兜底！</p>
<p>最后系统变成什么样子呢？</p>
<p>用 ReAct Agent 的架构，在 Prompt 里面塞一堆 Workflow 的硬规则指令。希望模型既能像 ReAct 一样灵活，又能像 Workflow 一样稳定；既能自主判断，又能严格遵守业务流程；既能泛化，又能确定性执行。</p>
<p>结果当然很尴尬。</p>
<p>模型根本不一定遵循指令。你让它走流程，它可能自己加戏；你让它自主判断，它又可能乱判断；即得不到 ReAct 的通用智能能力，也失去了 Workflow 的确定性和稳定性，&quot;人财&quot;两空。而且随着系统越来越复杂，这个弊端会指数型放大。</p>
<p>真是痛苦啊~</p>
<h3>更现实的答案：Macro Workflow + Micro ReAct</h3>
<p>所以，综上所述，在笔者有限的认知看来，只要大模型的底层原理没有发生本质改变，传统业务为了 AI 而 AI，强行套上一个全局 ReAct Agent，从一开始就大概率不是一条好路。</p>
<p>更现实的选择是：Workflow 作为主控流，ReAct 作为局部能力。</p>
<p>也就是：</p>
<blockquote>
<p>Macro Workflow + Micro ReAct。</p>
</blockquote>
<p>Workflow 负责主流程、状态机、成本控制、权限控制、异常兜底、观测指标和业务确定性。然后在一些传统工程手段做不到的地方、在一些传统机器学习、深度学习做不好的地方、在那些需要动态探索、但又能被局部验证的节点中，可以尝试利用大模型的泛化能力来提升上限，但是，它依旧需要控制在一个小的闭环范围内。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/ChatGPT%20Image%20Jun%2016,%202026,%2009_16_42%20AM%20(3).png" alt=""></p>
<p>说白了，不要让 ReAct 接管系统，只让它接管局部不确定性。</p>
<p>比如，在电商场景里，订单状态流转、支付校验、售后规则、风控拦截、库存状态，这些东西本来就应该是 Workflow。你不能让模型自由发挥。它可以解释规则，可以总结信息，可以生成话术，可以辅助判断，但它不应该随便决定业务状态怎么流转。</p>
<p>但是在一些模糊节点里，LLM 是有价值的。比如理解用户到底在纠结什么，判断用户是在问价格、成色、真假、风险还是决策建议；再比如从一堆非结构化信息里提取关键信号；再比如把平台已有的规则和商品上下文组织成用户能听懂的话。这些地方传统规则很难做得自然，传统机器学习也不一定够灵活，LLM 的泛化能力就可以派上用场。</p>
<p>关键在于，这个能力必须被关在一个小闭环里。</p>
<p>你要知道它什么时候进来，什么时候出去，输出怎么校验，失败怎么兜底，成本怎么控制，效果怎么监控。否则 ReAct 的灵活性很快就会变成系统的不确定性。</p>
<h3>那应该怎么做</h3>
<p>经过这段时间的折磨后，笔者觉得比较好的实践路径可能是这样的：</p>
<ul>
<li>首先就不该为了 AI 而 AI，还是要从业务出发，去找到那些传统工程解决不好的点，去识别用户真正的痛点，然后去思考当下的 LLM 能否比较好的解决这些痛点。</li>
<li>好的思路不应该是上来就做一个所谓的 AI Native ChatBot，然后通过各种投流、奖励机制来&quot;诱骗&quot;用户过来使用。更好的方式可能是，系统实时陪伴用户，在用户真的遇到痛点、真的不知道怎么办的时候，Agent 恰当地出现。这个时候，利用平台的专业性、权威性和上下文优势，告诉用户：我知道你现在在苦恼什么，我可以帮你解决。这个时候用户主动使用的意愿和概率才会高。这个时候关键就在于你能否真的解决用户的问题，如果你能解决得好，那还愁没有用户来用吗？而且这个时候因为你是实实在在在解决用户的痛点，它的转化率（对应的昂贵的 token 成本）肯定就更优，才更有可能有盈利点（因为你还可以针对场景的收益高低选择不同的策略）。</li>
<li>从一个小的、局部的、高频率的、高优先级的、高确定性的点出发，再逐步扩大，即能快速看到效果，又能控制成本，又可以把异常情况缩小在一个可控的范围内。</li>
</ul>
<p>别一上来就想做一个无所不能的 Agent。无所不能，通常意味着无所能稳定。</p>
<h3>最后</h3>
<p>所以，如果要用一句话总结笔者现在对 ReAct 和 Workflow 的理解，那就是：</p>
<p><strong>ReAct 是更好的故事，Workflow 是更好的系统。</strong></p>
<p>ReAct 的确更像 AI，也更好讲故事。它让人看到 Thought、Action、Observation，看到模型像一个人一样边想边做，这很迷人。但生产系统最终不是比谁更像人，而是比谁能稳定解决问题，谁能控制成本，谁能被观测，谁能被兜底，谁能在出错的时候快速定位和回滚。</p>
<p>所以，笔者现在越来越倾向于认为：在高频、低容错、强业务目标的消费级业务场景里，不应该让 ReAct 成为系统的主架构。主架构应该是 Workflow，ReAct 只应该作为局部不确定节点里的增强能力。</p>
<p>这可能没那么性感，也没那么 AI Native。</p>
<p>但它更像一个系统。</p>
<p>只不过这年头，哪有人愿意慢下来做事呢？</p>
<p>没辙咯~</p>
]]></content:encoded>
    </item>
    <item>
      <title>Claude Code 源码解析丨从 6 个问题窥视 CC 的 Harness Engineering</title>
      <link>https://hedon.top/blog/claude-code-source/</link>
      <guid isPermaLink="true">https://hedon.top/blog/claude-code-source/</guid>
      <pubDate>Sun, 05 Apr 2026 11:17:20 GMT</pubDate>
      <description>仅仅拿着各种 AI 工具读 Claude Code 源码往往收效甚微，容易陷入“AI 学会了，但自己好像没学会”的困境。本文将“带着问题去让 AI 进行主题性分析”，解剖这个 AI Coding CLI 脱颖而出的原因。我们将聚焦 Prompt 结构与撰写原则、ReAct 循环的具体实现、灵活的内置工具体系以及代码修改的具体流程这几个核心点，为您提供独家深度解剖，助您不再只是“让 AI 重新认识一遍自己”。</description>
      <category>Claude Code</category><category>AI</category>
      <content:encoded><![CDATA[<p>Claude Code 源码&quot;开源&quot;之后，相信很多人拿着各种 AI 工具对其源码进行了一顿操作，然而，相信大部分人，都是让 CC &quot;重新认识了一遍自己&quot;，AI 学会了，但自己好像没学会。</p>
<p>笔者认为，带着问题去让 AI 进行主题性分析的话，或许会有更大的收获。</p>
<p>目前绝大部分 Agent 无非就是一个 ReAct 循环，只不过将这个几十行代码的 ReAct 扩展成了若干万行代码，以满足不同的场景需求。Claude Code 当然也不例外，那为什么 Claude Code 能在众多 Agent（尤其是 Coding CLI 中）脱颖而出呢？</p>
<p>笔者对以下几个问题其实是十分好奇的：</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--success" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<ol>
<li>Claude Code 每次 LLM API 调用的 Prompt 是怎样的？<ol>
<li>Prompt 的组成 部分是怎样的？为什么是选择这些而不是别的？</li>
<li>Prompt 有哪些撰写原则？我们能学到什么？</li>
<li>Prompt 过长时，压缩机制是怎样的？</li>
</ol>
</li>
<li>Claude Code 的 ReAct 循环是怎样的？在 Receive-Action-Observe 这个循环中，它做了哪些不一样的东西？</li>
<li>Claude Code 内置了哪些工具，以支撑它如此灵活地完成各种各样的任务？工具体系又是如何搭建的？</li>
<li>Claude Code 在&quot;写代码（修改文件）&quot;的时候，究竟发生了什么？</li>
</ol>
</blockquote>
<p>当然还有很多很多其他的问题，比如权限系统、sub-agent 体系、UI 优化等。不过贪多嚼不烂，笔者目前对上述问题是比较感兴趣的，所以就让 Claude Code 对着它自己的源码，通过对这些问题层层展开，最后由笔者整理成本文。</p>
<blockquote>
<p><font color="red"><u>注：本文尽力避免&quot;万字长文&quot;，怎奈为使行文连贯，有些地方难免啰嗦。好在每章自成主题，可单独阅读，按需食用即可。</u></font></p>
</blockquote>
<hr>
<h2>1. Claude Code 每一个 Prompt 是如何组织的</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403040421786.png" alt=""></p>
<p>如上图所示，发给 Claude API 的最终请求结构其实很简单：</p>
<pre><code class="language-typescript">{
  system: string[],
  messages: Message[],
  tools: Tool[]
}
</code></pre>
<p>但构建这个请求的过程远不简单。整个 Prompt 的构建可以分为三个核心区域：</p>
<ol>
<li><strong>System Prompt</strong>（系统提示）—— 告诉 Claude &quot;你是谁、你该怎么做&quot;</li>
<li><strong>Messages</strong>（消息列表）—— 包含用户输入、历史对话和各种附加上下文</li>
<li><strong>Tools</strong>（工具定义）—— Claude 可以调用的工具列表</li>
</ol>
<h3>1.1 System Prompt：Claude Code 的&quot;灵魂&quot;</h3>
<p>System Prompt 是整个架构中最精心设计的部分。它分为<strong>静态部分</strong>和<strong>动态部分</strong>，两者之间由一个关键的边界标记 <code>SYSTEM_PROMPT_DYNAMIC_BOUNDARY</code> 分隔。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403041957808.png" alt=""></p>
<p>静态部分定义在 <code>constants/prompts.ts</code> 中，包含 7 个 Section，如图所示不再赘述。</p>
<p>这部分无论固定不变的。 ← 所以可以命中 KV Cache，提高响应速度的同时，减少 token 费用。</p>
<p>在静态和动态部分之间，有一段至关重要的文字：</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>&quot;Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior.&quot;</p>
</blockquote>
<p>这段话的意义深远——它告诉 Claude，接下来的动态指令（包括用户的自定义规则）优先级高于默认行为。这就是为什么我们在 <code>CLAUDE.md</code> 中写的规则能够生效。</p>
<p>动态部分由 <code>utils/cloudend.ts</code> 生成，包含多达 8 个组件，如图所示不再赘述。</p>
<p>这里值得特别展开的是 CLAUDE.md 的加载。Claude Code 会从<strong>多个位置</strong>按照优先级依次查找并合并配置。这个设计非常优雅，它允许从企业级的全局规范到个人项目的特定偏好，形成一个完整的配置层级。越靠近当前工作目录的规则，优先级越高。</p>
<h3>1.2 Messages：上下文的大拼图（附件系统）</h3>
<p>Messages 是整个 Prompt 中最复杂的部分。一条看似简单的用户消息，在发送给 API 之前，会被附加大量的上下文信息，具体分为以下 2 个部分：</p>
<ul>
<li><strong>User Message（用户输入）</strong>：你在终端里实际打的字，比如 &quot;帮我修改这个文件&quot;</li>
<li><strong>Attachment Messages（本轮附加注入）</strong>：系统自动生成的上下文信息</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403042035602.png" alt=""></p>
<p>如上图所示，附件系统是 Claude Code 的核心设计之一，实现在 <code>utils/attachments.ts</code> 中（三层结构）。</p>
<p>这里要思考几个问题：</p>
<ol>
<li>为什么需要附件系统？</li>
<li>附件为什么需要分层？</li>
<li>这么多的附件类型是一回事，关键是，为什么是它们？</li>
<li>这么多附件，每次注入的时候如何选择？</li>
</ol>
<h4>1.2.1 为什么需要附件系统？</h4>
<p>先思考第 1 个问题，为什么需要附件系统？</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>模型不知道你刚在 IDE 里手动改了一行代码，不知道 linter 自动格式化了文件，不知道长会话已经跨越了午夜。附件系统的工作，就是在每个 turn 自动注入这些模型&quot;应该知道但没法主动获取&quot;的信息。</p>
</blockquote>
<h4>1.2.2 附件为什么要分层？</h4>
<p>根据不同的触发条件，附件被分为三类：</p>
<table>
<thead>
<tr>
<th>层级</th>
<th>触发条件</th>
<th>包含什么</th>
<th>设计意图</th>
</tr>
</thead>
<tbody><tr>
<td><strong>userInputAttachments</strong></td>
<td>仅用户有输入时</td>
<td>@文件引用、@agent、MCP 资源、技能发现</td>
<td>响应用户的直接意图</td>
</tr>
<tr>
<td><strong>allThreadAttachments</strong></td>
<td>每轮都收集（含子 agent）</td>
<td>文件变更、日期变化、记忆、plan、任务提醒</td>
<td>维护全局一致性</td>
</tr>
<tr>
<td><strong>mainThreadAttachments</strong></td>
<td>仅主线程</td>
<td>IDE 选区、打开文件、诊断信息、token 用量</td>
<td>交互感知（子 agent 隔离）</td>
</tr>
</tbody></table>
<p>现在可以回答第 2 个问题：为什么要分层？</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>子 agent 被分配了&quot;重构 parser.ts&quot;的子任务，它<strong>需要</strong>知道 <code>parser.ts</code> 是否被外部修改了（<code>changed_files</code>，在 allThread 层），但<strong>不需要</strong>知道用户在 IDE 里选中了哪段代码（<code>ide_selection</code>，在 mainThread 层）。信息按需分发，不是所有线程看到所有东西。</p>
</blockquote>
<h4>1.2.3 为什么是这些附件？</h4>
<p>要解答第 3 个问题，我们可以将附件按照它们要解决的问题分类，这样能更好地理解设计意图。</p>
<p>类别 1：保持模型认知与现实世界同步</p>
<blockquote>
<p><strong>核心原则：模型的世界模型必须与真实状态保持同步。</strong> 每当外部状态变化，附件系统负责&quot;通知&quot;模型。注意这里的关键词是&quot;delta&quot;——很多附件名字里就带着&quot;变化&quot;的含义，因为它们只在状态改变时才注入。</p>
</blockquote>
<table>
<thead>
<tr>
<th>附件</th>
<th>注入什么</th>
<th>解决什么问题</th>
</tr>
</thead>
<tbody><tr>
<td><code>changed_files</code></td>
<td>&quot;foo.ts was modified. Here are the changes: ...&quot;</td>
<td>用户在 IDE 手动改了文件 / linter 自动格式化了文件 → 模型不知道</td>
</tr>
<tr>
<td><code>date_change</code></td>
<td>&quot;Today&#39;s date is now 2025-07-02. DO NOT mention this.&quot;</td>
<td>长会话跨越午夜 → 模型的日期认知过期</td>
</tr>
<tr>
<td><code>nested_memory</code></td>
<td>子目录的 CLAUDE.md 内容</td>
<td>模型进入新目录工作 → 不知道该目录的规则/约定</td>
</tr>
<tr>
<td><code>deferred_tools_delta</code></td>
<td>&quot;New tools available via ToolSearch: ...&quot;</td>
<td>MCP server 连接/断开 → 工具集变了但模型不知道</td>
</tr>
<tr>
<td><code>agent_listing_delta</code></td>
<td>&quot;New agent types available: ...&quot;</td>
<td>Agent 列表变化 → 模型可能调用已不存在的 agent</td>
</tr>
<tr>
<td><code>mcp_instructions_delta</code></td>
<td>MCP server 使用说明</td>
<td>MCP server 连接后带来了使用说明 → 需要告知模型</td>
</tr>
</tbody></table>
<p>类别 2：管理模型的&quot;心理状态&quot;</p>
<blockquote>
<p><strong>核心原则：LLM 不是无状态的——它会根据感知到的上下文压力改变行为。</strong></p>
<p><code>compaction_reminder</code> 是一个典型案例。当对话历史很长时，模型会&quot;感觉到&quot;上下文窗口快满了，于是开始焦虑地赶进度——提前总结、跳过细节、催促用户做决定。这不是我们想要的行为。通过注入&quot;你有无限上下文&quot;这个安抚信息，Claude Code 在管理模型的&quot;情绪&quot;。</p>
</blockquote>
<table>
<thead>
<tr>
<th>附件</th>
<th>注入什么</th>
<th>解决什么问题</th>
</tr>
</thead>
<tbody><tr>
<td><code>compaction_reminder</code></td>
<td>&quot;Auto-compact is enabled. There is no need to stop or rush — you have unlimited context.&quot;</td>
<td>长对话中模型&quot;感知&quot;到 context 快满 → 表现出焦虑行为（提前总结、催促用户）</td>
</tr>
<tr>
<td><code>context_efficiency</code></td>
<td>引导模型使用 SnipTool 清理历史</td>
<td>对话膨胀 → 但模型不知道可以主动剪枝</td>
</tr>
<tr>
<td><code>ultrathink_effort</code></td>
<td>&quot;The user has requested reasoning effort level: max&quot;</td>
<td>用户切换了推理力度 → 模型需要调整行为</td>
</tr>
<tr>
<td><code>output_style</code></td>
<td>输出风格配置</td>
<td>用户设置了特定输出风格 → 模型需遵守</td>
</tr>
</tbody></table>
<p>类别 3：IDE 上下文感知</p>
<blockquote>
<p><strong>核心原则：把 IDE 当作模型的&quot;眼睛&quot;。</strong> 没有附件系统，模型是&quot;盲人编程&quot;——不知道用户在看什么、IDE 报了什么错。</p>
</blockquote>
<table>
<thead>
<tr>
<th>附件</th>
<th>注入什么</th>
<th>解决什么问题</th>
</tr>
</thead>
<tbody><tr>
<td><code>ide_selection</code></td>
<td>&quot;The user selected lines 10-20 from foo.ts: [代码]&quot;</td>
<td>用户在 IDE 选了代码然后提问 → 模型不知道用户在看什么</td>
</tr>
<tr>
<td><code>ide_opened_file</code></td>
<td>&quot;The user opened foo.ts in the IDE.&quot;</td>
<td>用户切换了文件 tab → 模型不知道用户的注意力在哪</td>
</tr>
<tr>
<td><code>diagnostics</code></td>
<td>TypeScript/ESLint 错误列表</td>
<td>IDE 报了新的编译错误 → 模型不知道自己的修改引入了问题</td>
</tr>
</tbody></table>
<p>类别 4：行为习惯培养</p>
<blockquote>
<p><strong>核心原则：用周期性提醒替代一次性指令。</strong> System prompt 只在对话开头出现一次，但在长达数十轮的对话中，模型很容易&quot;遗忘&quot;早期的指令。附件系统通过周期性地重复提醒来对抗这种遗忘。</p>
</blockquote>
<table>
<thead>
<tr>
<th>附件</th>
<th>注入什么</th>
<th>解决什么问题</th>
</tr>
</thead>
<tbody><tr>
<td><code>todo_reminder</code> / <code>task_reminder</code></td>
<td>&quot;The task tools haven&#39;t been used recently... This is just a gentle reminder&quot;</td>
<td>模型在长任务中忘记维护任务列表</td>
</tr>
<tr>
<td><code>plan_mode</code></td>
<td>Plan mode 的完整指令</td>
<td>模型在 plan 模式中忘记自己在做计划、开始直接执行代码</td>
</tr>
<tr>
<td><code>plan_mode_exit</code></td>
<td>&quot;You have exited plan mode. You can now make edits.&quot;</td>
<td>模型从 plan 模式退出但不知道自己已有权限执行操作</td>
</tr>
<tr>
<td><code>verify_plan_reminder</code></td>
<td>&quot;Please call VerifyPlanExecution tool&quot;</td>
<td>模型实施完计划后忘记验证</td>
</tr>
</tbody></table>
<p>类别 5：资源感知与约束</p>
<blockquote>
<p><strong>核心原则：让模型具备&quot;自我约束&quot;的能力。</strong> 这里的设计选择非常有趣——Claude Code 不是在系统层面硬截断输出，而是让模型&quot;知道&quot;资源情况后自行调节行为。这是一种软约束，给了模型根据情况灵活应对的空间。</p>
</blockquote>
<table>
<thead>
<tr>
<th>附件</th>
<th>注入什么</th>
<th>解决什么问题</th>
</tr>
</thead>
<tbody><tr>
<td><code>token_usage</code></td>
<td>&quot;Token usage: 85000/200000; 115000 remaining&quot;</td>
<td>模型不知道自己用了多少 token，无法自我调节</td>
</tr>
<tr>
<td><code>budget_usd</code></td>
<td>&quot;USD budget: 2.50/2.50/5.00; $2.50 remaining&quot;</td>
<td>模型不知道花了多少钱，可能无节制调用工具</td>
</tr>
<tr>
<td><code>output_token_usage</code></td>
<td>&quot;Output tokens — turn: 2000/8000 · session: 15000&quot;</td>
<td>配合 token budget 功能，让模型知道输出进度</td>
</tr>
<tr>
<td><code>max_turns_reached</code></td>
<td>&quot;Maximum turns reached.&quot;</td>
<td><code>--max-turns</code> 限制下通知模型该停止了</td>
</tr>
</tbody></table>
<p>类别 6：知识注入</p>
<table>
<thead>
<tr>
<th>附件</th>
<th>注入什么</th>
<th>解决什么问题</th>
</tr>
</thead>
<tbody><tr>
<td><code>relevant_memories</code></td>
<td>从 auto-memory 目录中检索的相关记忆</td>
<td>模型不知道之前会话积累的经验/偏好</td>
</tr>
<tr>
<td><code>skill_listing</code></td>
<td>当前可用的技能列表</td>
<td>模型不知道有哪些自定义技能可以调用</td>
</tr>
<tr>
<td><code>skill_discovery</code></td>
<td>基于语义搜索的相关技能</td>
<td>模型可能不知道某个技能的存在 → 主动推荐</td>
</tr>
<tr>
<td><code>queued_command</code></td>
<td>用户在工具执行中途输入的新消息</td>
<td>Agent 循环中用户打了新消息 → 不能等到循环结束才看到</td>
</tr>
</tbody></table>
<h4>1.2.4 如何选择要注入的附件？</h4>
<p>附件不是每轮都全量注入的——那样会快速耗尽上下文窗口。每种附件都有精心设计的触发条件、去重策略和生命周期管理：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403053613193.png" alt=""></p>
<h3>1.3 Tools：强大的工具体系</h3>
<p>请见 🫱 [4. Claude Code 的工具体系](#4. Claude Code 的工具体系)</p>
<h2>2. Claude Code 的 Prompt 有哪些撰写原则</h2>
<p>在知道了 Claude Code 每一个 Prompt 的组成部分之后，里面具体写了什么呢？有没有一些最佳实践是我们可以参考（抄）的呢？当然是有的，经对源码进行分析后，笔者（借助 AI）提取出了 Claude Code 在 Prompt Engineering 的 8 个最佳实践，可供参考。</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>一句话总结： Claude Code 的 prompt 哲学是：不教模型&quot;怎么做好&quot;，而是堵住模型&quot;会做错&quot;的所有口子；不用抽象规则，而用具体场景；不靠文字约束，而靠系统设计让错误行为无法发生。</p>
</blockquote>
<h3>2.1 行为塑造靠&quot;反面清单&quot;，不靠&quot;正面宣言&quot;</h3>
<p>传统做法是告诉 AI &quot;你是一个优秀的程序员&quot;。Claude Code 几乎不做正面描述，而是用大量具体的禁止行为来约束：</p>
<pre><code class="language-text">&quot;Don&#39;t add features, refactor code, or make &#39;improvements&#39; beyond what was asked.&quot;
&quot;Don&#39;t add docstrings, comments, or type annotations to code you didn&#39;t change.&quot;
&quot;Don&#39;t add error handling, fallbacks, or validation for scenarios that can&#39;t happen.&quot;
&quot;Don&#39;t create helpers, utilities, or abstractions for one-time operations.&quot;
&quot;Three similar lines of code is better than a premature abstraction.&quot;
</code></pre>
<p>LLM 天然倾向&quot;过度工程化&quot;和&quot;讨好用户&quot;。不写反面清单，模型会不断添加不必要的代码。这些禁令是从大量真实用户反馈中提炼出来的,每一条背后都是一个&quot;模型做过头了&quot;的真实场景。</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>写 prompt 时，先想&quot;AI 最容易犯什么错&quot;，然后写对应的禁令。</p>
</blockquote>
<h3>2.2 用具体场景代替抽象规则</h3>
<p>Claude Code 从不写&quot;请谨慎操作&quot;这样的空话，而是给出具体例子和边界条件：</p>
<pre><code class="language-text">&quot;Examples of the kind of risky actions that warrant user confirmation:
 - Destructive operations: deleting files/branches, dropping database tables,
   killing processes, rm -rf, overwriting uncommitted changes
 - Hard-to-reverse operations: force-pushing, git reset --hard, amending
   published commits, removing or downgrading packages/dependencies
 - Actions visible to others: pushing code, creating/closing/commenting on
   PRs or issues, sending messages (Slack, email, GitHub)&quot;
</code></pre>
<p>又比如对 git 的操作：</p>
<pre><code class="language-text">&quot;A user approving an action (like a git push) once does NOT mean that they
 approve it in all contexts&quot;

&quot;If you discover unexpected state like unfamiliar files, branches, or
 configuration, investigate before deleting or overwriting, as it may
 represent the user&#39;s in-progress work.&quot;
</code></pre>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>每条规则都用&quot;例如...&quot;或具体场景补充。让模型能类比推理到新场景。</p>
</blockquote>
<h3>2.3 面向失败模式设计，每条规则对治一个真实 BUG</h3>
<p>读 <code>prompts.ts</code> 的注释能看到很多 <code>@[MODEL LAUNCH]</code> 标记，说明这些指令是针对特定模型版本的行为问题定制的：</p>
<pre><code class="language-text">// @[MODEL LAUNCH]: capy v8 thoroughness counterweight (PR #24302)
&quot;Before reporting a task complete, verify it actually works: run the test,
 execute the script, check the output.&quot;

// @[MODEL LAUNCH]: False-claims mitigation for Capybara v8 (29-30% FC rate)
&quot;Never claim &#39;all tests pass&#39; when output shows failures, never suppress or
 simplify failing checks to manufacture a green result&quot;

// @[MODEL LAUNCH]: Remove or soften once the model stops over-commenting
&quot;Default to writing no comments. Only add one when the WHY is non-obvious&quot;
</code></pre>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>Prompt 不是一次性写好的。应该持续监控模型行为，发现问题后添加对应的矫正指令。这是一个持续迭代的工程过程。</p>
</blockquote>
<h3>2.4 面向人类用户体验的输出控制</h3>
<p>Claude Code 对输出格式的控制极其细致：</p>
<ul>
<li><p>倒金字塔原则：</p>
<pre><code class="language-text">&quot;Lead with the answer or action, not the reasoning.&quot;
&quot;Use inverted pyramid when appropriate (leading with the action), and if
 something about your reasoning is so important that it absolutely must be
 in user-facing text, save it for the end.&quot;

“先给出答案或行动方案，而非先阐述理由。”
“在适当的情况下采用倒金字塔结构（先讲行动方案），如果您的理由中有某些内容非常重要，必须出现在面向用户的文本中，那么就将其放在最后。”
</code></pre>
</li>
<li><p>结构格式的克制：</p>
<pre><code class="language-text">&quot;Match responses to the task: a simple question gets a direct answer in prose,
 not headers and numbered sections.&quot;
&quot;Only use tables when appropriate; for example to hold short enumerable facts.
 Don&#39;t pack explanatory reasoning into table cells.&quot;

“将回答与任务内容相匹配：简单的问题应给出明确的散文式回答，而非使用标题和编号列表形式。”
“仅在适当的情况下使用表格；例如用于展示简短的可列举事实。切勿将解释性推理内容塞进表格单元格中。”
</code></pre>
</li>
<li><p>用户注意力管理：</p>
<pre><code class="language-text">&quot;Focus text output on:
 - Decisions that need the user&#39;s input
 - High-level status updates at natural milestones
 - Errors or blockers that change the plan&quot;

&quot;If you can say it in one sentence, don&#39;t use three.&quot;

“将文本输出的重点放在以下方面：
- 需要用户参与的决策事项
- 在自然的阶段性节点上呈现的高级状态更新
- 会改变计划的错误或阻碍因素”
“如果能用一句话表达清楚就不要用三句话。”
</code></pre>
</li>
<li><p>防止语义回溯：</p>
<pre><code class="language-text">&quot;Avoid semantic backtracking: structure each sentence so a person can read it
 linearly, building up meaning without having to re-parse what came before.&quot;

 “避免语义回溯：将每个句子进行结构设计，以便读者能够按线性顺序阅读，从而逐步构建出意义，而无需反复解析之前的内容。”
</code></pre>
</li>
</ul>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>不只管&quot;模型说什么&quot;，还管&quot;模型怎么说&quot;。输出质量 = 信息量 / 认知负荷。</p>
</blockquote>
<h3>2.5 用隐式约束</h3>
<p>最巧妙的是通过工具系统设计而非 prompt 文字来约束行为：</p>
<p>工具描述中（getEditToolDescription）：</p>
<pre><code class="language-text">&quot;You MUST use your Read tool at least once before editing.&quot;
→ 不是建议，是工具层面的强制（validateInput 会检查 readFileState）

&quot;The edit will FAIL if old_string is not unique in the file.&quot;
→ 不是提醒，是实际的校验逻辑
</code></pre>
<p>工具可用性控制：</p>
<pre><code class="language-text">- Glob/Grep 存在 → prompt 告诉模型 &quot;Use Glob instead of find&quot;
- Glob/Grep 不存在（embedded 模式）→ 这段 prompt 自动消失
→ 工具集和 prompt 始终一致
</code></pre>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>最好的 prompt 是让错误行为根本无法执行，而不是&quot;请不要这样做&quot;。</p>
</blockquote>
<h3>2.6 预设用户的&quot;走开&quot;场景</h3>
<p>Claude Code 假设用户不会一直盯着屏幕：</p>
<pre><code class="language-text">&quot;When making updates, assume the person has stepped away and lost the thread.
 They don&#39;t know codenames, abbreviations, or shorthand you created along the
 way, and didn&#39;t track your process. Write so they can pick back up cold:
 use complete, grammatically correct sentences without unexplained jargon.&quot;

&quot;Before your first tool call, briefly state what you&#39;re about to do.
 While working, give short updates at key moments.&quot;
</code></pre>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>Agent 的输出应该是自包含的进度报告，不是需要&quot;回看上文&quot;才能理解的日志。</p>
</blockquote>
<h3>2.7 精确的权限边界语言</h3>
<p>安全相关的 prompt 使用了极其精确的法律式语言：</p>
<pre><code class="language-text">&quot;Authorization stands for the scope specified, not beyond.&quot;

&quot;Match the scope of your actions to what was actually requested.&quot;

&quot;Follow both the spirit and letter of these instructions — measure twice, cut once.&quot;
</code></pre>
<p>安全领域：</p>
<pre><code class="language-text">&quot;Assist with authorized security testing, defensive security, CTF challenges,
 and educational contexts. Refuse requests for destructive techniques, DoS
 attacks, mass targeting, supply chain compromise, or detection evasion for
 malicious purposes.&quot;
→ 不是简单的&quot;不要做坏事&quot;，而是穷举了允许和禁止的边界
</code></pre>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>安全指令要像法律条文——穷举允许项和禁止项，不留模糊地带。</p>
</blockquote>
<h3>2.8 优秀的工程管理</h3>
<table>
<thead>
<tr>
<th>实践</th>
<th>做法</th>
<th>为什么</th>
</tr>
</thead>
<tbody><tr>
<td>分节管理</td>
<td>每个 section 独立函数，条件加载</td>
<td>可缓存、可 A/B 测试</td>
</tr>
<tr>
<td>静态/动态分离</td>
<td>SYSTEM_PROMPT_DYNAMIC_BOUNDARY</td>
<td>prompt cache 命中率</td>
</tr>
<tr>
<td>变量引用工具名</td>
<td><code>${FILE_EDIT_TOOL_NAME}</code> 而非硬编码</td>
<td>工具重命名时 prompt 自动更新</td>
</tr>
<tr>
<td>条件裁剪</td>
<td><code>enabledTools.has(...)</code> 检查后才加入</td>
<td>不存在的工具不出现在 prompt 中</td>
</tr>
<tr>
<td>模型版本适配</td>
<td><code>@[MODEL LAUNCH]</code> 标记</td>
<td>不同模型有不同行为矫正</td>
</tr>
<tr>
<td>内外有别</td>
<td><code>process.env.USER_TYPE === &#39;ant&#39;</code></td>
<td>内部版本有更详细的指令</td>
</tr>
<tr>
<td>迭代注释</td>
<td>每条规则旁标注 PR 编号</td>
<td>可追溯为什么加这条规则</td>
</tr>
</tbody></table>
<h2>3. Claude Code 的 ReAct 循环剖析</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403043045739.png" alt=""></p>
<p>如上图所示，这是一个经典的 ReAct 循环，事实上绝大部分的 Agent 都是一个 ReAct 循环。关键在于，Claude Code 是如何把一个几十行代码的 ReAct 循环硬生生整出超过 51w 行代码的？或者说，为啥要这样？这可能就是最近比较火的 Harness Engineering 了吧。</p>
<p>接下来让我们从上图这 5 个阶段来一一拆解。</p>
<h3>3.1 消息预处理阶段</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403043433082.png" alt=""></p>
<p>这里 Claude Code 在压缩（compact）Prompt 的处理，是值的我们学习的。谈到压缩，我们一般能想到的就是滑动窗口，丢失掉旧的数据，这个时候我们会怕关键信息丢失，所以自然就想到了调另外一个 LLM 来做 summarize，一般也就到此为止了。</p>
<p>而 Claude Code 的压缩并不是一个简单的&quot;满了就摘要&quot;的操作，而是一套从轻到重的<strong>五级渐进式降级策略</strong>。这五级从轻到重依次执行，每一级解决一个特定的膨胀来源，设计思想是<strong>能不动就不动，能少压就少压</strong>。</p>
<table>
<thead>
<tr>
<th>级别</th>
<th>信息损失</th>
<th>计算成本</th>
<th>可逆性</th>
</tr>
</thead>
<tbody><tr>
<td>1. 工具结果预算</td>
<td>极低（只截断超大输出）</td>
<td>无 LLM 调用</td>
<td>否</td>
</tr>
<tr>
<td>2. 历史剪裁</td>
<td>低（丢弃远古对话）</td>
<td>无 LLM 调用</td>
<td>否</td>
</tr>
<tr>
<td>3. 微压缩</td>
<td>低（标记跳过）</td>
<td>无 LLM 调用</td>
<td>是</td>
</tr>
<tr>
<td>4. 上下文折叠</td>
<td>中（摘要但可展开）</td>
<td>无 LLM 调用</td>
<td>是</td>
</tr>
<tr>
<td>5. Autocompact</td>
<td>高（整段历史压成摘要）</td>
<td>需要 LLM 调用</td>
<td>否</td>
</tr>
</tbody></table>
<p>每一级都只在前一级不够用时才启动。大部分对话只会触发前三级，只有真正的超长会话才会走到 Autocompact。这既节省了 LLM 调用成本，也最大限度地保留了对话上下文的完整性。</p>
<p>当然，其中最具挑战、最容易造成信息损失的，还是 Autocompact。在让 LLM 进行 summarize 的时候，我们需要思考以下几个问题：</p>
<ol>
<li>什么时候触发 Autocompact？</li>
<li>提示词是怎样的？（要留哪些东西？舍弃哪些东西？）</li>
<li>summarize 后仍然超长怎么办？</li>
</ol>
<h4>3.1.1 什么时候触发压缩？</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403131307071.png" alt=""></p>
<h4>3.1.2 压缩的提示词是什么？</h4>
<p>获取压缩 prompt 的代码如下：</p>
<pre><code class="language-typescript">export function getCompactPrompt(customInstructions?: string): string {
  let prompt = NO_TOOLS_PREAMBLE + BASE_COMPACT_PROMPT;
  if (customInstructions &amp;&amp; customInstructions.trim() !== &quot;&quot;) {
    prompt += `\n\nAdditional Instructions:\n${customInstructions}`;
  }
  prompt += NO_TOOLS_TRAILER;
  return prompt;
}
</code></pre>
<p>说白了，就是三个部分：</p>
<ul>
<li>NO_TOOLS_PREAMBLE：告诉模型不要调用工具，直接输出文本</li>
<li>BASE_COMPACT_PROMPT：告诉模型压缩的时候要保留什么东西</li>
<li>NO_TOOLS_TRAILER：再次强调模型不要调用工具，直接输出文本</li>
</ul>
<pre><code class="language-typescript">const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.

- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- You already have all the context you need in the conversation above.
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
- Your entire response must be plain text: an &lt;analysis&gt; block followed by a &lt;summary&gt; block.

`;

const BASE_COMPACT_PROMPT = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user&#39;s explicit requests and your previous actions.
This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context.

${DETAILED_ANALYSIS_INSTRUCTION_BASE}

Your summary should include the following sections:

1. Primary Request and Intent: Capture all of the user&#39;s explicit requests and intents in detail
2. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed.
3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important.
4. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently.
5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts.
6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users&#39; feedback and changing intent.
7. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on.
8. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable.
9. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user&#39;s most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first.
                       If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there&#39;s no drift in task interpretation.

Here&#39;s an example of how your output should be structured:

&lt;example&gt;
&lt;analysis&gt;
[Your thought process, ensuring all points are covered thoroughly and accurately]
&lt;/analysis&gt;

&lt;summary&gt;
1. Primary Request and Intent:
   [Detailed description]

2. Key Technical Concepts:
   - [Concept 1]
   - [Concept 2]
   - [...]

3. Files and Code Sections:
   - [File Name 1]
      - [Summary of why this file is important]
      - [Summary of the changes made to this file, if any]
      - [Important Code Snippet]
   - [File Name 2]
      - [Important Code Snippet]
   - [...]

4. Errors and fixes:
    - [Detailed description of error 1]:
      - [How you fixed the error]
      - [User feedback on the error if any]
    - [...]

5. Problem Solving:
   [Description of solved problems and ongoing troubleshooting]

6. All user messages: 
    - [Detailed non tool use user message]
    - [...]

7. Pending Tasks:
   - [Task 1]
   - [Task 2]
   - [...]

8. Current Work:
   [Precise description of current work]

9. Optional Next Step:
   [Optional Next step to take]

&lt;/summary&gt;
&lt;/example&gt;

Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response. 

There may be additional summarization instructions provided in the included context. If so, remember to follow these instructions when creating the above summary. Examples of instructions include:
&lt;example&gt;
## Compact Instructions
When summarizing the conversation focus on typescript code changes and also remember the mistakes you made and how you fixed them.
&lt;/example&gt;

&lt;example&gt;
# Summary instructions
When you are using compact - please focus on test output and code changes. Include file reads verbatim.
&lt;/example&gt;
`;

const NO_TOOLS_TRAILER =
  &quot;\n\nREMINDER: Do NOT call any tools. Respond with plain text only — &quot; +
  &quot;an &lt;analysis&gt; block followed by a &lt;summary&gt; block. &quot; +
  &quot;Tool calls will be rejected and you will fail the task.&quot;;
</code></pre>
<h4>3.1.3 压缩后还超长怎么办？</h4>
<p>摘要不够短就砍掉更多历史再摘要，直到低于阈值。渐进式截断而非一次性丢弃，尽可能多保留信息。</p>
<pre><code class="language-typescript">export async function compactConversation(...) ... {
  	...
    let ptlAttempts = 0
    for (;;) {
      ...
      ptlAttempts++
      const truncated =
        ptlAttempts &lt;= MAX_PTL_RETRIES
          ? truncateHeadForPTLRetry(messagesToSummarize, summaryResponse)
          : null
      if (!truncated) {
        throw new Error(ERROR_MESSAGE_PROMPT_TOO_LONG)
      }
    ...
    }
		...
}
</code></pre>
<h4>3.1.4 Autocompact 总结</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403134331759.png" alt=""></p>
<h3>3.2 Thinking 阶段：调用 LLM 生成下一轮思考/行动</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403043452191.png" alt=""></p>
<h3>3.3 Act 阶段：执行 LLM 请求的工具调用，获取结果</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403043501172.png" alt=""></p>
<h3>3.4 Observe 阶段：收集工具结果和外部输入，准备下一轮</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403043512673.png" alt=""></p>
<h3>3.5 状态更新 &amp; 继续判断：决定是继续循环还是退出</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403043520072.png" alt=""></p>
<h3>3.6 我们能学到什么？</h3>
<p>了解了上述 5 个阶段后，我们能学习什么？这里笔者像转变一下视角：</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--success" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>不是 Claude Code 设计了什么，而是工业级 Agent 必须具备什么？Claude Code 又是怎么满足的？</p>
</blockquote>
<p>笔者认为可以考虑以下几个硬性需求：</p>
<table>
<thead>
<tr>
<th>硬性需求</th>
<th>主要矛盾</th>
</tr>
</thead>
<tbody><tr>
<td>状态清晰可管理</td>
<td>状态必然复杂 vs 复杂产生 bug</td>
</tr>
<tr>
<td>长时间运行</td>
<td>无限任务 vs 有限上下文窗口</td>
</tr>
<tr>
<td>高响应性</td>
<td>多步操作的串行延迟 vs 用户的耐心</td>
</tr>
<tr>
<td>容错性高</td>
<td>错误必然发生 vs Agent 不能轻易死掉</td>
</tr>
<tr>
<td>可扩展</td>
<td>循环逻辑要稳定 vs 行为要不断变化</td>
</tr>
<tr>
<td>可观测</td>
<td>Agent 思考过程是盲盒 vs 调试和改进的需要</td>
</tr>
</tbody></table>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260403044026513.png" alt=""></p>
<h2>4. Claude Code 的工具体系</h2>
<h3>4.1 架构全景</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260402170147717.png" alt=""></p>
<h3>4.2 工具总览</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260402164548961.png" alt=""></p>
<h3>4.3 工具接口</h3>
<pre><code class="language-typescript">type Tool&lt;Input, Output, P&gt; = {
  // ═══ 身份 ═══
  name: string                    // 唯一名称，如 &quot;Glob&quot;, &quot;Bash&quot;
  aliases?: string[]              // 别名（重命名兼容）
  searchHint?: string             // ToolSearch 的关键词提示

  // ═══ Schema ═══
  inputSchema: ZodObject          // Zod schema，定义输入参数
  inputJSONSchema?: JSONSchema    // MCP 工具用 JSON Schema 替代
  outputSchema?: ZodType          // 输出 schema

  // ═══ 核心执行 ═══
  call(input, context, canUseTool, parentMsg, onProgress?)
    : Promise&lt;ToolResult&lt;Output&gt;&gt;         // 执行工具！

  // ═══ 权限 ═══
  validateInput?(input, context)          // 语义校验（路径存在？参数合法？）
    : Promise&lt;ValidationResult&gt;
  checkPermissions(input, context)        // 工具特有权限检查
    : Promise&lt;PermissionResult&gt;
  preparePermissionMatcher?(input)        // Hook if 条件匹配器
    : Promise&lt;(pattern) =&gt; boolean&gt;

  // ═══ 属性查询 ═══
  isEnabled(): boolean                    // 当前环境是否启用
  isConcurrencySafe(input): boolean       // 可否与其他工具并行执行
  isReadOnly(input): boolean              // 是否只读
  isDestructive?(input): boolean          // 是否破坏性操作
  interruptBehavior?(): &#39;cancel&#39; | &#39;block&#39; // 用户中断时的行为

  // ═══ 结果处理 ═══
  mapToolResultToToolResultBlockParam(output, toolUseID)
    : ToolResultBlockParam                // 转成 API tool_result 格式
  maxResultSizeChars: number              // 超此大小持久化到磁盘

  // ═══ UI 展示 ═══
  description(input, options): Promise&lt;string&gt;  // 发给模型的工具描述
  prompt(options): Promise&lt;string&gt;              // system prompt 中的描述
  userFacingName(input): string                 // 终端显示名
  renderToolUseMessage?(...)                    // 渲染工具调用
  renderToolResultMessage?(...)                 // 渲染工具结果
  getActivityDescription?(input): string        // spinner 显示的活动描述

  // ═══ 高级特性 ═══
  shouldDefer?: boolean           // 延迟加载（ToolSearch 触发后才可用）
  alwaysLoad?: boolean            // 强制始终加载（不延迟）
  isMcp?: boolean                 // 是否 MCP 工具
  backfillObservableInput?(input) // 向观察者填充派生字段
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260402163105335.png" alt=""></p>
<p>提供安全的默认值（fail-closed 原则）：</p>
<table>
<thead>
<tr>
<th>默认</th>
<th>值</th>
<th>理由</th>
</tr>
</thead>
<tbody><tr>
<td>isEnabled</td>
<td>true</td>
<td>默认启用</td>
</tr>
<tr>
<td>isConcurrencySafe</td>
<td>false</td>
<td>假设不安全（保守）</td>
</tr>
<tr>
<td>isReadOnly</td>
<td>false</td>
<td>假设有写操作</td>
</tr>
<tr>
<td>isDestructive</td>
<td>false</td>
<td>默认不破坏性</td>
</tr>
<tr>
<td>checkPermissions</td>
<td>allow</td>
<td>交给通用权限系统</td>
</tr>
</tbody></table>
<h3>4.4 工具工厂</h3>
<pre><code class="language-typescript">buildTool() —— 工具工厂 (Tool.ts:783)

export function buildTool(def) {
  return { ...TOOL_DEFAULTS, userFacingName: () =&gt; def.name, ...def }
}
</code></pre>
<h3>4.5 GlobTool 为例</h3>
<pre><code class="language-shell">src/tools/GlobTool/
  ├── GlobTool.ts   ← 工具定义（核心逻辑）
  ├── prompt.ts     ← 工具描述文本（发给模型）
  └── UI.tsx        ← 终端渲染组件
</code></pre>
<p>完整实现如下：</p>
<pre><code class="language-typescript">export const GlobTool = buildTool({
  // ── 身份 ──
  name: &#39;Glob&#39;,
  searchHint: &#39;find files by name pattern or wildcard&#39;,
  maxResultSizeChars: 100_000,

  // ── Schema ──
  inputSchema: z.strictObject({
    pattern: z.string(),
    path: z.string().optional(),
  }),

  // ── 属性 ──
  isConcurrencySafe: () =&gt; true,     // ← 文件搜索可以并行
  isReadOnly: () =&gt; true,            // ← 只读操作

  // ── 校验 ──
  validateInput({ path }) {           // ← path 存在且为目录？
    if (path) {
      const stats = await fs.stat(expandPath(path))
      if (!stats.isDirectory()) return { result: false, ... }
    }
    return { result: true }
  },

  // ── 权限 ──
  checkPermissions(input, context) {  // ← 读权限检查
    return checkReadPermissionForTool(GlobTool, input, ...)
  },

  // ── 核心执行 ──
  call(input, { abortController, getAppState, globLimits }) {
    const { files, truncated } = await glob(
      input.pattern,
      GlobTool.getPath(input),
      { limit: globLimits?.maxResults ?? 100 },
      abortController.signal,
    )
    return {
      data: { filenames: files.map(toRelativePath), durationMs, numFiles, truncated }
    }
  },

  // ── 结果转 API 格式 ──
  mapToolResultToToolResultBlockParam(output, toolUseID) {
    return {
      type: &#39;tool_result&#39;,
      tool_use_id: toolUseID,
      content: output.filenames.join(&#39;\n&#39;),
    }
  },
})
</code></pre>
<h3>4.6 工具注册</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260402162828253.png" alt=""></p>
<p>💡 关键设计原则：</p>
<ul>
<li><code>feature()</code> 门控在编译时做死代码消除（DCE），不同构建版本有不同工具集。</li>
<li>工具排序保证 prompt cache 命中率——内置工具始终是 <code>contiguous prefix</code>。</li>
<li><code>uniqBy(&#39;name&#39;)</code> 确保内置工具与 MCP 同名工具不冲突。</li>
</ul>
<h3>4.7 MCP 集成</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260402164645224.png" alt=""></p>
<p>MCP 工具的特殊之处：</p>
<ul>
<li>使用 <code>inputJSONSchema</code> 而非 Zod schema（直接传递 JSON Schema 给 API）</li>
<li>命名格式 <code>mcp__&lt;server&gt;__&lt;tool&gt;</code>，支持 anthropic/alwaysLoad 元标记</li>
<li><code>checkPermissions</code> 返回 <code>passthrough</code>，交给通用权限系统处理</li>
<li>支持 <code>URL elicitation</code> 重试（OAuth 等认证流）</li>
</ul>
<h3>4.8 工具执行</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260402163442133.png" alt=""></p>
<h3>4.9 权限控制</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260402164614738.png" alt=""></p>
<h2>5. Claude Code 是如何修改代码的</h2>
<p>在对 Claude Code 的上下文管理、ReAct 循环、Harness Engineering 和工具体系有了一个基本的了解之后，笔者身为程序员，自然最关心的就是&quot;修改代码&quot;这件事情了，所以本节我们就落到这个具体的需求点上，来探讨一下 Claude Code 在做修改代码这件事情的时候，到底做了什么。</p>
<p>当面对这个问题的时候，笔者心中又衍生了几个小问题：</p>
<ol>
<li>修改代码这个行为是怎么完成的？</li>
<li>如何确保修改的精确性？</li>
<li>如何确保修改的全面性？</li>
<li>如何确保修改的安全性？（并发冲突...）</li>
<li>如何确保修改的语法正确性？</li>
<li>如何确保修改的功能正确性？</li>
<li>修改后整个上下文管理发生了什么？</li>
</ol>
<p>好的，让我们一一解答。（废了，我不想要&quot;万字长文&quot;的 🤷🏻‍♀️🤡😷）</p>
<h3>5.1 修改代码这个行为是怎么完成的？</h3>
<p>Claude Code 修改文件有两个工具：</p>
<table>
<thead>
<tr>
<th>工具</th>
<th>用途</th>
<th>输入</th>
<th>何时使用</th>
</tr>
</thead>
<tbody><tr>
<td><strong>FileEditTool</strong></td>
<td>精确替换文件中的一段文本</td>
<td><code>file_path</code>, <code>old_string</code>, <code>new_string</code>, <code>replace_all</code></td>
<td>修改现有文件（首选）</td>
</tr>
<tr>
<td><strong>FileWriteTool</strong></td>
<td>全量写入文件</td>
<td><code>file_path</code>, <code>content</code></td>
<td>创建新文件 / 完全重写</td>
</tr>
</tbody></table>
<p>System prompt 引导模型<strong>优先使用 Edit</strong>——修改 500 行文件中的 1 行，Edit 只需发送 ~10 行 token，Write 则需发送全部 500 行。</p>
<pre><code class="language-markdown">&quot;ALWAYS prefer editing existing files. NEVER write new files unless explicitly required.&quot;
</code></pre>
<p>举个例子：</p>
<pre><code>输入:
  file_path:  &quot;/project/src/auth.ts&quot;
  old_string: &quot;function login(user) {\n  return false\n}&quot;
  new_string: &quot;function login(user) {\n  return authenticate(user)\n}&quot;

执行:
  fileContent.replace(old_string, new_string)
</code></pre>
<p>为什么选这个方案？ —— 因为它匹配 LLM 的能力特征：</p>
<table>
<thead>
<tr>
<th>方案</th>
<th>LLM 需要做什么</th>
<th>容易出错的点</th>
</tr>
</thead>
<tbody><tr>
<td>行号定位</td>
<td>记住精确行号</td>
<td>行号幻觉（第 15 行记成第 13 行）</td>
</tr>
<tr>
<td>Unified diff</td>
<td>生成 <code>@@ -15,3 +15,4 @@</code></td>
<td>格式/行号双重出错</td>
</tr>
<tr>
<td>AST 操作</td>
<td>理解语法树节点</td>
<td>每种语言不同，复杂度高</td>
</tr>
<tr>
<td>全文重写</td>
<td>输出整个文件</td>
<td>不出错，但 500 行改 1 行 = 浪费 499 行 token</td>
</tr>
<tr>
<td><strong>字符串替换</strong></td>
<td><strong>复制原文 + 输出修改后的版本</strong></td>
<td><strong>LLM 最擅长这个</strong></td>
</tr>
</tbody></table>
<h3>5.2 如何确保修改的精确性？</h3>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>核心问题：模型给出的 <code>old_string</code>，能不能准确定位到文件中的正确位置？</p>
</blockquote>
<h4>5.2.1 唯一性校验 - 不允许歧义</h4>
<p>默认 <code>replace_all = false</code>，要求 <code>old_string</code> 在文件中<strong>只出现一次</strong>：</p>
<pre><code>// 匹配 3 次 → 拒绝

&quot;Found 3 matches, but replace_all is false. Provide more context to uniquely identify the instance.&quot;
</code></pre>
<p>这个设计<strong>迫使模型提供足够的上下文行</strong>来唯一定位修改点。模型不能只写 <code>return false</code>（可能出现多次），必须包含上下行直到唯一。</p>
<h4>5.2.2 先读后写 - 禁止盲改</h4>
<pre><code>const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp) {
  → 拒绝: &quot;File has not been read yet. Read it first.&quot;
}
</code></pre>
<p>模型必须先用 <code>FileReadTool</code> 读过文件。这确保 <code>old_string</code> 是基于<strong>当前真实内容</strong>构造的，而不是基于训练数据中的&quot;记忆&quot;。</p>
<h4>5.2.3 编码和换行符保留 - 不引入格式噪音</h4>
<pre><code class="language-text">读取时: 检测编码(UTF-8/UTF-16LE) + 换行符(LF/CRLF) → 记录
替换时: 在标准化的 \n 文本上操作
写入时: 用原始编码 + 原始换行符写回
</code></pre>
<p>不保留的后果：Windows 项目 CRLF 文件被转成 LF → git diff 爆炸，每行都显示被修改。</p>
<h3>5.3 如何确保修改的全面性？</h3>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>核心问题：如果需要改多处，怎么保证不遗漏？</p>
</blockquote>
<h4>5.3.1 replace_all 模式</h4>
<p>一个 Edit 调用 + <code>replace_all: true</code> 可以一次替换文件中所有匹配——适用于变量重命名等场景。</p>
<h4>5.3.2 对话循环驱动多文件修改</h4>
<p>Claude Code 的修改不是一次性的，而是<strong>对话循环驱动的多轮工具调用</strong>：</p>
<pre><code>模型: &quot;我需要修改 3 个文件&quot;
  → tool_use: Edit(file_a, ...)
  → tool_use: Edit(file_b, ...)
  → tool_use: Edit(file_c, ...)
  → &quot;修改完成&quot;
</code></pre>
<p>模型可以在一轮中发出多个 Edit 调用，并行执行（Glob/Grep/Read 是 <code>concurrency_safe</code>，可并行；Edit 是非 safe，串行执行）。</p>
<h4>5.3.3 Agent 并行修改</h4>
<p>主线程可以 spawn 子 Agent 并行处理不同文件/模块：</p>
<pre><code>主 Agent: &quot;重构 auth 模块&quot;
  ├── spawn Explore Agent → 搜索所有相关文件
  ├── 基于搜索结果，spawn 多个 General Agent → 各自修改不同文件
  └── 汇总结果
</code></pre>
<h4>5.3.4 附件系统的&quot;变更感知&quot;</h4>
<p>每轮工具执行完成后，<code>changed_files</code> 附件会检测哪些文件被外部修改（<code>linter</code>、<code>format-on-save</code> 等），注入 <code>diff snippet</code> 告知模型：</p>
<pre><code>&quot;Note: auth.ts was modified, either by the user or by a linter.
 Here are the relevant changes: ...&quot;
</code></pre>
<p>模型看到这些变更后可以决定是否需要追加修改——确保不遗漏 linter 引入的变化。</p>
<h3>5.4 如何确保修改的安全性？</h3>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>核心问题：并发冲突、外部修改、误操作如何防范？</p>
</blockquote>
<table>
<thead>
<tr>
<th>防线</th>
<th>机制</th>
<th>一句话</th>
</tr>
</thead>
<tbody><tr>
<td>Staleness 双重检查</td>
<td>validateInput + call 各检查一次 mtime</td>
<td>被别人改过的文件不准覆盖</td>
</tr>
<tr>
<td>原子性写入</td>
<td>同步读 + 同步写，无 await 间隙</td>
<td>并发竞争不能破坏文件</td>
</tr>
<tr>
<td>权限控制</td>
<td>deny 规则 → hooks → allow 规则 → 交互确认</td>
<td>用户不同意不准改</td>
</tr>
<tr>
<td>UNC 路径防护</td>
<td>跳过 <code>\\server\share</code> 的文件系统操作</td>
<td>防止 NTLM credential 泄露</td>
</tr>
<tr>
<td>文件备份</td>
<td>Edit 前按 content hash 创建 v1 备份</td>
<td>可撤销 AI 的修改</td>
</tr>
</tbody></table>
<h3>5.5 如何确保修改的语法正确性？</h3>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>核心问题：替换完成后，文件的语法是否仍然合法？</p>
</blockquote>
<h4>5.5.1 Claude Code 当前策略：事后检测而非事前校验</h4>
<p>FileEditTool 本身<strong>不做任何语法检查</strong>——它是语言无关的纯文本替换。语法正确性由两个机制保障：</p>
<ul>
<li><strong>LSP 实时诊断（事后检测）</strong>：如果编辑引入了语法错误，LSP 会产生诊断信息（error/warning），通过 <code>diagnostics</code> 附件在下一轮注入到模型上下文，模型看到诊断后会自行修复。</li>
<li><strong>模型自身的代码理解能力</strong>：模型在生成 <code>new_string</code> 时已经考虑了语法——它不是随机替换，而是理解代码结构后生成的。先读后写强制确保模型看过完整文件，有足够的上下文生成语法正确的代码。</li>
</ul>
<h4>5.5.2 工程建议：借鉴强类型语言的编译时保障</h4>
<p>当前的&quot;事后检测&quot;模式存在缺陷：<strong>先写入错误代码 → LSP 报错 → 模型再修 → 又一轮 API 调用</strong>。每次修复循环至少浪费一轮 token。更好的思路是<strong>写入前校验</strong>。</p>
<p>这个时候，静态强类型语言的编译器（如 TypeScript 和 Rust），就可以发挥巨大的作用。</p>
<h3>5.6 如何确保修改的功能正确性？</h3>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>核心问题：语法对了，但逻辑是否正确？功能是否如预期？</p>
</blockquote>
<p>这不是单个工具能解决的问题。我们可以通过<strong>三道防线</strong>构建功能正确性保障：</p>
<ul>
<li>Prompt 引导自验证</li>
<li>TDD 驱动的验证循环</li>
<li>独立于执行者的外部校验</li>
</ul>
<h4>5.6.1 第一道防线：Prompt 引导自验证</h4>
<p>Claude Code 的 System prompt 中存在以下关键指令:</p>
<pre><code>&quot;Before reporting a task complete, verify it actually works:
 run the test, execute the script, check the output.&quot;

&quot;If you can&#39;t verify (no test exists, can&#39;t run the code),
 say so explicitly rather than claiming success.&quot;

&quot;Never claim &#39;all tests pass&#39; when output shows failures.&quot;
</code></pre>
<p>这些不是建议，而是<strong>行为纠偏指令</strong>——专门针对模型&quot;虚报成功&quot;的倾向。</p>
<h4>5.6.2 第二道防线：TDD 驱动的验证循环</h4>
<p>Claude Code 的对话循环天然支持 <strong>红-绿-重构</strong> 的 TDD 工作流：</p>
<pre><code>理想流程（TDD 先行）:

用户: &quot;给 authenticate 函数添加 token 过期检查&quot;

模型: Read(auth.test.ts)                → 了解现有测试
模型: Edit(auth.test.ts, ...)           → 先写测试（红）
模型: Bash(&quot;npm test&quot;)                  → 确认测试失败（红 ✓）
模型: Read(auth.ts)                     → 了解实现
模型: Edit(auth.ts, ...)                → 修改实现（绿）
模型: Bash(&quot;npm test&quot;)                  → 确认测试通过（绿 ✓）
模型: &quot;修改完成，新增了 token 过期测试，所有测试通过。&quot;
</code></pre>
<p>即使模型不主动做 TDD，<strong>现有测试也充当安全网</strong>：</p>
<pre><code>实际常见流程:

模型: Edit(auth.ts, ...)                → 修改代码
模型: Bash(&quot;npm test&quot;)                  → 运行已有测试
      ← 失败: &quot;TypeError: authenticate is not a function&quot;
模型: Read(auth.ts)                     → 重读文件
模型: Edit(auth.ts, ...)                → 修复问题
模型: Bash(&quot;npm test&quot;)                  → 再次运行
      ← 所有测试通过
</code></pre>
<p><strong>TDD 思维对 AI Coding 的特殊价值</strong>：</p>
<table>
<thead>
<tr>
<th>传统 TDD 价值</th>
<th>在 AI Coding 中的放大效果</th>
</tr>
</thead>
<tbody><tr>
<td>测试即需求文档</td>
<td>模型从测试中<strong>理解预期行为</strong>比读注释更精确</td>
</tr>
<tr>
<td>红绿循环提供反馈</td>
<td>模型的自验证有了<strong>客观标准</strong>，不靠自我评估</td>
</tr>
<tr>
<td>防止回归</td>
<td>模型改了 A 功能，测试发现 B 功能被破坏 → 立即修复</td>
</tr>
<tr>
<td>增量确信</td>
<td>每个小修改都有测试兜底，模型可以大胆重构</td>
</tr>
</tbody></table>
<p><strong>当没有测试时</strong>——这是最危险的场景。Claude Code 的 prompt 要求模型明确说 <em>&quot;I can&#39;t verify&quot;</em> 而不是假装成功。但更好的做法是：模型主动为修改的代码补充测试。这可以通过 <code>CLAUDE.md</code> 中的项目级指令强化：</p>
<pre><code class="language-markdown"># CLAUDE.md

修改任何函数时，如果该函数没有测试，先补充测试再修改实现。
</code></pre>
<h4>5.6.3 第三道防线：独立于执行者的外部校验</h4>
<ul>
<li><strong>权限对话框——写入前的代码审查窗口</strong>：每次写操作前弹出确认对话框（未明确授权时）。</li>
<li><strong>极简返回值——迫使模型主动自验证</strong>：Edit 成功只返回 <code>&quot;file updated successfully&quot;</code>，<strong>不附带修改后的文件内容</strong>。这与前面的 LSP 诊断形成配合——LSP 诊断是异步注入的（下一轮才能看到），在此之前模型没有任何&quot;修改是否正确&quot;的直接信号。极简返回值切断了&quot;看到结果就以为改对了&quot;的捷径：模型必须主动 <code>Read</code> 重新检查，或 <code>Bash</code> 运行测试来确认修改生效，而不是假设成功。</li>
<li><strong>独立 Agent 的异步复查</strong>：内置的 Verification Agent 在后台异步运行，专门负责复查代码变更。它被刻意限制为<strong>只读模式</strong>——只能使用 Read、Glob、Grep、Bash，不能调用任何写工具。</li>
</ul>
<h3>5.7 修改后整个上下文管理发生了什么？</h3>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--success" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>核心目标：让模型在每一轮都拥有与真实世界一致的认知。</p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260402201822107.png" alt=""></p>
<p>整体状态（state）的迁移图如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20260402202808071.png" alt=""></p>
<p>一次 Edit 不只是&quot;改了一个文件&quot;——它更新了系统的<strong>文件认知</strong>（readFileState）、触发了<strong>语言服务器重分析</strong>（LSP）、通知了<strong>用户的 IDE</strong>（VSCode）、创建了<strong>可撤销的备份</strong>（fileHistory），并在下一轮对话中通过<strong>附件系统</strong>将所有变化反馈给模型。整个上下文管理的目标是：<strong>让模型在每一轮都拥有与真实世界一致的认知。</strong></p>
<h3>5.8 串一下整个流程</h3>
<p>一个完整的代码修改流程如下所示：</p>
<pre><code class="language-markdown">模型发出 Edit tool_use
│
═══ validateInput() ════════════════════════════
│
Step 1: 基础校验
├── old_string === new_string? → 拒绝（无变化）
├── deny 规则检查 → 路径是否被禁止编辑
├── UNC 路径安全检查（防 NTLM 泄露）
└── 文件大小 &gt; 1GiB? → 拒绝（防 OOM）
│
Step 2: 读取文件
├── 检测编码（UTF-8 / UTF-16LE）
├── CRLF → LF 标准化
├── 文件不存在 + old_string 为空 → 创建新文件
└── .ipynb 文件 → 引导用 NotebookEditTool
│
Step 3: &quot;先读后写&quot;检查 ★★★
├── readFileState 中没有此文件?
│ → 拒绝: &quot;File has not been read yet. Read it first.&quot;
│ (强制模型先用 Read 工具读文件，确保了解文件内容)
│
└── readFileState.timestamp &lt; 文件 mtime?
→ 拒绝: &quot;File has been modified since read.&quot;
(防止覆盖用户或 linter 的修改)
│
Step 4: 查找 old_string
├── 精确匹配 → 使用
├── 不匹配 → 尝试 curly quote 标准化匹配
│ (文件中 &#39;hello&#39; → 模型输出 &#39;hello&#39; → 自动匹配)
└── 仍不匹配 → 拒绝: &quot;String to replace not found&quot;
│
Step 5: 唯一性检查
├── 匹配 1 次 → OK
├── 匹配 &gt; 1 次 且 replace_all=false
│ → 拒绝: &quot;Found N matches, set replace_all=true or provide more context&quot;
└── 匹配 &gt; 1 次 且 replace_all=true → OK（替换全部）
│
═══ checkPermissions() ═════════════════════════
│
Step 6: 写权限检查
└── checkWritePermissionForTool() → allow/ask/deny
│
═══ call() ═════════════════════════════════════
│
Step 7: 执行替换
├── 7a. 发现技能目录（非阻塞）
├── 7b. 诊断追踪器 beforeFileEdited()
├── 7c. 确保父目录存在 mkdir()
├── 7d. 文件历史备份（若启用）
├── 7e. 同步读取文件（原子性临界区开始）
│ readFileSyncWithMetadata() ← 保留编码和换行符
├── 7f. 再次检查文件未被修改（staleness check）
│ mtime &gt; lastRead.timestamp? → throw Error
├── 7g. findActualString() → 处理 curly quote
├── 7h. preserveQuoteStyle() → 保留文件原有引号风格
├── 7i. getPatchForEdit() → 生成 patch + 新文件内容
│ replace_all ? file.replaceAll(old, new) : file.replace(old, new)
├── 7j. writeTextContent() ← 写入磁盘 ★★★
│ 保留原始编码（UTF-8/UTF-16LE）
│ 保留原始换行符（LF/CRLF）
│
Step 8: 后置处理
├── 8a. 通知 LSP 服务器 didChange + didSave
├── 8b. 通知 VSCode 更新 diff 视图
├── 8c. 更新 readFileState（更新 timestamp 和 content）
├── 8d. 记录遥测事件
└── 8e. 返回 { filePath, oldString, newString, originalFile, structuredPatch }
</code></pre>
<h2>6. 写在最后</h2>
<p>Claude Code 的&quot;开源&quot;，给了我们一个难得的机会，去理解一个工业级 AI Agent 的真实复杂度。它不是一个&quot;更大的 demo&quot;，而是一个<strong>充分考虑了边界情况、失败模式、用户体验的工程系统</strong>。</p>
<p>但更重要的是，这些设计原则是<strong>可迁移的</strong>。无论你在构建 Coding Agent、数据分析 Agent，还是任何其他领域的 Agent：</p>
<ul>
<li>附件系统的**&quot;分层注入、差分通知、频率控制&quot;**思想适用于任何需要上下文管理的场景</li>
<li>**&quot;反面清单&quot;**式的 Prompt 写法适用于任何需要约束模型行为的场景</li>
<li>**&quot;先校验再执行、先读再写&quot;**的工具设计适用于任何有副作用的操作</li>
<li>**&quot;事后检测 + 反馈闭环&quot;**的质量保障模式适用于任何无法事前完全验证的场景</li>
</ul>
<p>希望本文的分析能帮助读者不只是&quot;知道 Claude Code 做了什么&quot;，而是<strong>理解为什么这样做</strong>，并将这些思想应用到自己的实践中。</p>
<p>毕竟，AI 学会了没用——<strong>你学会了才有用</strong>。</p>
]]></content:encoded>
    </item>
    <item>
      <title>MySQL 底层原理丨事务的实现（及三种日志）</title>
      <link>https://hedon.top/blog/mysql-transaction/</link>
      <guid isPermaLink="true">https://hedon.top/blog/mysql-transaction/</guid>
      <pubDate>Thu, 18 Dec 2025 23:37:00 GMT</pubDate>
      <description>本文将从第一性原理出发，深入剖析 MySQL 的事务实现原理，并详细介绍三种日志（Redo Log、Undo Log、Binlog）的原理、应用场景及使用方法。</description>
      <category>MySQL</category><category>事务</category><category>数据库</category>
      <content:encoded><![CDATA[<h2>MySQL 的事务是如何实现的？</h2>
<p>MySQL 的事务是一个非常复杂的话题，在思考这个问题的时候，想要即触及重点又不显得啰嗦，就需要采用&quot;总-分-总&quot;的结构，并紧扣 ACID 这一核心模型来展开。</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--success" aria-hidden="true"></span></p>
<p><strong>一句话核心</strong></p>
<p>MySQL（InnoDB 引擎）的事务实现，本质上就是为了保证 ACID 特性。它的底层主要依赖两类日志（redo log &amp; undo log）和一套并发控制机制（锁 &amp; MVCC）来共同完成。</p>
</blockquote>
<ol>
<li><strong>原子性（Atomicity）</strong>：靠 Undo Log。原子性保证事务要么全部成功，要么全部失败。这是通过 Undo Log（回滚日志）实现的。它记录了数据的逻辑反向操作（比如 Insert 对应 Delete）。如果事务失败或回滚，利用 Undo Log 就能把数据恢复到事务开始之前的状态。</li>
<li><strong>持久性（Durability）</strong>：靠 Redo Log（WAL）。持久性保证提交的数据不丢失。这是通过 Redo Log（重放日志）实现的。它遵循 WAL（Write-Ahead Logging）原则，事务提交时先写日志再刷盘。即使宕机，重启后也能通过 Redo Log 重放来恢复数据。另外，Bin Log 和 Redo Log 通过两阶段提交（2PC）来保证逻辑一致性。</li>
<li><strong>隔离性（Isolation）</strong>：靠锁和 MVCC 机制。隔离性是为了解决并发问题，InnoDB 提供了两种手段。<ul>
<li>写操作（当前读）：依赖锁机制（如 Record Lock、Gap Lock、Next-Key Lock）来防止脏读和幻读。</li>
<li>读操作（快照读）：依赖 MVCC（多版本并发控制），通过 ReadView + Undo Log 版本链实现不加锁的非阻塞读，极大提高了并发性能。</li>
</ul>
</li>
<li><strong>一致性（Consistency）</strong>：一致性是事务的最终目标。它是通过上述的原子性、持久性和隔离性共同保障的，同时还需与业务层面的逻辑约束（如外键）来配合。</li>
</ol>
<p>所以简单来说，<strong>Redo Log 保证了数据不丢，Undo Log 保证了可以后悔，MVCC 和锁保证了并发安全，最终实现了事务的一致性。</strong></p>
<h2>redo log 是怎么实现持久性的？</h2>
<p>对 ACID 有了全局视野上的认知后，那么第一个问题就来了，redo log 是如何实现持久性的？</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--success" aria-hidden="true"></span></p>
<p><strong>一句话核心</strong></p>
<p>Redo Log 实现持久性的核心思想是 WAL（Write-Ahead Logging，日志先行）技术，简单来说就是：先顺序写日志，后提交事务，再写磁盘数据。</p>
</blockquote>
<p>具体来说包含 3 个方面：</p>
<ul>
<li><strong>利用顺序写替代随机写（性能基础）</strong>：当事务修改数据时，InnoDB 只是修改了内存（Buffer Pool）中的数据页，此时数据变成了<strong>脏页</strong>。 如果每次修改都直接把数据页刷入磁盘，那是<strong>随机 I/O</strong>，性能非常差。 所以，InnoDB 先把对数据页的<strong>物理修改</strong>记录到 Redo Log 中。Redo Log 是追加写的，属于<strong>顺序 I/O</strong>，速度非常快。只要日志落盘了，事务就算成功了。</li>
<li><strong>刷盘策略</strong>：为了保证日志真的落盘，InnoDB 提供了 <code>innodb_flush_log_at_trx_commit</code> 参数。 实现 ACID 中<strong>严格持久性</strong>的关键在于将该参数设置为 <strong>1</strong>。 这意味着：每次事务提交时，都会强制调用 <code>fsync</code> 将 Redo Log Buffer 中的日志刷入磁盘。只有刷盘成功，事务才算 Commit 成功。</li>
<li><strong>崩溃恢复</strong>：如果 MySQL 宕机，内存里的<strong>脏页</strong>还没来得及刷入磁盘（丢失了）。 但在重启时，InnoDB 会读取磁盘上的 Redo Log，根据日志里的物理修改记录（比如把第 10 页偏移量 50 的值改为 A），重新把这些操作在内存里执行一遍。 这个过程叫 <strong>Crash Recovery</strong>，它保证了即使宕机，提交过的数据也绝对不会丢失。</li>
</ul>
<p>另外，Redo Log 是循环写入的（Circular Buffer），配合 <strong>Checkpoint</strong> 机制。当脏页真正刷入磁盘后，对应的 Redo Log 空间就可以被覆盖重用了。</p>
<h2>redo log 是如何进行崩溃恢复的？(持久性+原子性)</h2>
<p>接下来最复杂的地方就是 redo log 它凭什么能支持崩溃恢复？</p>
<h3>redo log 的存储结构是怎样的？</h3>
<p>首先我们要看一下 redo log 的底层结构是怎样的：</p>
<ul>
<li>redo log 是循环写入的，底层会将 redo log 分成一个个的 redo log block，每个 block 大小为 512MB。</li>
<li>事务中执行修改类 SQL 的时候，在修改完内存数据（buffer pool）每条会生成 LSN（Log Sequence Number），然后 WAL 写到 redo log block 中。注意，这里一个事务会生成多个 LSN，多个事务是并发执行的，所以多个事务之间的 LSN 是可能存在交替的。也就是说，为提交事务的 LSN 也可能会被刷盘哦！（后面会详细展开为什么可以这样）</li>
<li>LSN 是逻辑上日志按照时间顺序从小到大的编码，在 innodb 上，LSN 是一个 64 位的整数，取的是从数据库安装启动开始，到当前所写入的总的日志字节数。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/0*xmXBaxykPRbdEMwV.png" alt=""></p>
<p>需要注意的是，redo log 也不是直接写磁盘的，也是先写 redo log buffer，然后再刷盘的。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251218142704578.png" alt=""></p>
<p>所以要保证真正的持久性，还需要结合 redo log 的刷盘策略配置：<a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit">innodb_flush_log_at_trx_commit</a> ，InnoDB 提供了 3 种策略：</p>
<ul>
<li><code>0</code>：每秒刷一次磁盘</li>
<li><code>1</code>：每提交一个事务，就刷一次磁盘。最安全！这是保证持久性的必要条件！也是 InnoDB 的默认值！</li>
<li><code>2</code>：每次事务提交时，写到 OS Cache，但不立即 Flush。Flush 由后台线程每秒做一次。</li>
</ul>
<h3>redo log 存的是什么？</h3>
<p>下一个问题就是  redo log 存的到底是什么？有这么几种选项：</p>
<table>
<thead>
<tr>
<th>形式</th>
<th>逻辑</th>
<th>缺点</th>
</tr>
</thead>
<tbody><tr>
<td>类似 bin log 的 statement</td>
<td>记原始 SQL 语句 <code>insert/update/delete</code>。</td>
<td>1. 恢复速度慢：如果数据库宕机前执行了 10 万次 <code>UPDATE</code>，重启时要恢复，就必须把这 10 万条 SQL <strong>重新解析、重新优化、重新执行</strong>一遍。这消耗大量的 CPU 和时间，会导致数据库重启时间长到无法接受。<br>2. 不确定性：如果 SQL 语句里包含了 <code>now()</code>、<code>rand()</code> 或者依赖索引顺序的操作，重放时的结果可能和宕机前不一样，导致数据不一致。<br>3. 无法修复物理损坏：    如果磁盘上的 B+ 树数据页结构坏了（比如分裂到一半断电），单纯重放 SQL 是修不好这个页面的物理结构的。</td>
</tr>
<tr>
<td>类似 bin log 的  RAW 格式</td>
<td>记录每张表的每条记录的修改前的值，修改后的值，类似 <code>(表, 行, 修改前的值, 修改后的值)</code>。</td>
<td>假设一个 <code>INSERT</code> 操作引发了 B+ 树的 <strong>页分裂 (Page Split)</strong>。这个操作不仅写入了数据，还修改了父节点指针、兄弟节点指针、Page Header 等。如果在页分裂的中间断电了，数据页处于“半分裂”的损坏状态。此时，如果你只知道插入了 id=5, val=abc，你根本不知道该怎么把那个损坏的 B+ 树指针修好。</td>
</tr>
<tr>
<td>记录修改的每个 Page 的字节数据</td>
<td>由于每个 Page 有 16KB，记录这 16KB 里面哪些部分被修改了。一个 Page 如果被修改了多个地方，就会有多条物理日志。</td>
<td>1. 日志空间爆炸：InnoDB 的页大小是 16KB。如果你只是修改了一个 <code>int</code> 字段（4字节），却要把整个 16KB 的页都记下来，或者记录大量复杂的 Diff，日志量会极其庞大。<br>2. 并发控制复杂：纯物理日志要求恢复时必须严格按照物理状态回放。如果采用这种方式，往往需要在写日志时对页面加更重的锁，影响高并发性能。</td>
</tr>
</tbody></table>
<p>redo log 的日志叫<code>physiological logging</code>，翻译过来就是 <code>physical + logical logging</code>，即<strong>物理逻辑日志</strong>。</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>简单来说</strong></p>
<p>先以 Page 为单位记录日志，每个 Page 里面再采取逻辑记法（记录 Page 里面哪一行被修改了）。即<strong>物理到页，逻辑到行</strong>。</p>
</blockquote>
<p><strong>格式结构</strong>：<code>Type</code> + <code>Space ID</code> + <code>Page Number</code> + <code>Data</code></p>
<ul>
<li><strong>物理层面</strong>：它精准记录了要修改哪个<strong>表空间 (Space ID)</strong> 的哪个<strong>数据页 (Page Number)</strong>。</li>
<li><strong>逻辑层面</strong>：在数据页内部，它记录的是逻辑操作。</li>
</ul>
<pre><code>物理定位 (Physical)        逻辑操作 (Logical)
+---------------------+   +-------------------------+
|  Page Location      |   |   Modification Body     |
+--+-------+----------+   +-------------------------+
|T | Space | Page No  |   | Offset | Len | Value... |
|y |  ID   |          |   |        |     |          |
|p |       |          |   |        |     |          |
|e |       |          |   |        |     |          |
+--+-------+----------+   +--------+-----+----------+
</code></pre>
<p>假设我们要执行 <code>UPDATE users SET age = 20 WHERE id = 1;</code> 假设这条记录在 <strong>Space 5</strong> 的 <strong>Page 100</strong>，记录在页内的偏移量是 <strong>600</strong>。</p>
<p>Redo Log 可能会记成这样一条记录：</p>
<pre><code>类型: MLOG_4BYTES (写入4字节整数)
表空间: 5
页号: 100
页内偏移量: 600
新值: 20
</code></pre>
<p>恢复过程是这样的：</p>
<ol>
<li><strong>物理寻址</strong>：根据 <code>Space 5</code> + <code>Page 100</code>，直接从磁盘读出这个页加载到内存。</li>
<li><strong>逻辑重放</strong>：根据类型 <code>MLOG_4BYTES</code>，找到页内偏移量 <code>600</code> 的位置。</li>
<li><strong>执行修改</strong>：把那里的 4 个字节直接覆盖为 <code>20</code>。</li>
</ol>
<p>所以，Redo Log 是<strong>物理位置 + 逻辑操作</strong>的结合。这种设计既利用了物理定位的<strong>幂等性</strong>（恢复时可以直接定位到页），又节省了大量的存储空间（不用记录整个页面的变化）。</p>
<hr>
<p>既然 Redo Log 是写在固定大小的 Block 里的，那如果我要写入的一组操作（比如一个事务）特别大，跨了多个 Block 怎么办？或者写到一半断电了，只有半条 Log 怎么办？这就涉及到了 MTR (Mini-Transaction) 的原子性设计。</p>
<p><strong>MTR (Mini-Transaction)</strong> 是 InnoDB 修改底层数据页的<strong>最小原子单位</strong>。</p>
<p>你可以把它理解为底层的微事务。一个普通的 SQL 事务可能包含很多条 SQL，而每一条 SQL 在底层执行时，可能会触发多次 MTR。</p>
<p>你在 B+ 树插入一条记录，可能需要：</p>
<ol>
<li>修改叶子节点页。</li>
<li>修改索引页。</li>
<li>如果触发页分裂，还要修改父节点、甚至增加树的高度。</li>
</ol>
<p><strong>MTR 承诺</strong>上述这 1、2、3 步涉及的所有 Redo Log，必须<strong>作为一个整体</strong>写入日志文件。要么全有，要么全无。</p>
<p>MTR 的运行遵循一个&quot;先收集、后批发&quot;的模式：</p>
<ol>
<li><strong>收集阶段 (Private Buffer)</strong>： 当一个线程要修改数据页时，它会先在自己的<strong>私有内存区域</strong>开辟一块空间，把这次操作产生的所有 Physiological Log 记录下来。</li>
<li><strong>原子提交 (Commit to Log Buffer)</strong>： 当这个原子操作完成时（比如页分裂完成了），MTR 会执行 <code>commit</code>。此时，它会将私有缓冲区里的所有日志<strong>一次性</strong>拷贝到全局的 <code>Redo Log Buffer</code> 中。</li>
<li><strong>打标 (The End Marker)</strong>： MTR 会在这一组日志的最后一条记录后面，附带一个特殊的标记（<code>MLOG_MULTI_REC_END</code>）。</li>
</ol>
<p><strong>End Marker</strong> 的作用：</p>
<ul>
<li><strong>恢复时的逻辑</strong>：InnoDB 在崩溃恢复扫描 Redo Log 时，会一组一组地看。</li>
<li><strong>完整性校验</strong>：如果它发现一组日志开头了，但扫描到最后没看到 <code>MLOG_MULTI_REC_END</code> 标记，它就认为这组日志是<strong>损坏且不完整</strong>的。</li>
<li><strong>处理策略</strong>：<strong>直接丢弃这组不完整的日志</strong>，不进行重放。因为没看到 End Marker，说明这个 MTR 还没 Commit。根据 WAL 原则，既然日志没 Commit，那么磁盘上的物理数据页肯定也没改（或者改了也会被 Undo 回滚）。丢弃它保证了数据库永远不会停留在 B+ 树断裂的中间状态。</li>
</ul>
<h3>redo log 恢复过程是怎样的？</h3>
<p>直到了存什么、怎么存之后，最后就是怎么用的问题了。</p>
<p>**ARIES 算法（Algorithms for Recovery and Isolation Exploiting Semantics）**是现代主流数据库（InnoDB、SQL Server、Oracle）崩溃恢复算法的鼻祖。在 MySQL InnoDB 的实现中，虽然有很多工程上的优化，但其核心思想完全继承自 ARIES。</p>
<p>ARIES 主要分为三个阶段：</p>
<ol>
<li>分析阶段：确定哪些数据页是脏页（做 redo），确定哪些事务未提交（做 undo）。</li>
<li>执行 redo。</li>
<li>执行 undo。</li>
</ol>
<blockquote>
<p>为什么先执行 redo 再执行 undo？</p>
<p>因为 undo log 也是&quot;数据&quot;，它也要&quot;写 redo log&quot;！所以 redo 后，undo log 就恢复如初了！这个时候才可以 undo。</p>
</blockquote>
<p>那问题就来了，怎么知道哪些数据页是脏页？怎么知道哪些事务未提交？</p>
<p>直观的想法就是对整个内存做一个快照，但是这数据量太大了，所以为了减少这个数据量，InnoDB 维护了两个表，并定期做 checkpoint，这称为 <code>fuzzy checkpoint</code>。</p>
<ul>
<li><strong>活跃事务表</strong>：当前所有未提交事务的合集，每个事务维护了一个关键变量 <code>lastLSN</code>。<code>lastLSN</code> 是该事务产生的最后一条日志的 LSN。</li>
<li><strong>脏页表</strong>：当前所有未刷盘的 Page 的集合（包括已提交事务和未提交事务），每个 Page 维护了一个关键变量 <code>recoveryLSN</code>。<code>recoveryLSN</code>  是导致该 Page 成为脏页的最早的 LSN。比如一个 Page 本来是 clean，然后事务 1 修改了它，对应的 LSN 是 LSN1，之后事务 2，事务 3 又修改了它，对应 LSN2 和 LSN3，这个时候 <code>recoveryLSN</code> 就是 LSN1。</li>
</ul>
<p>每次 <code>fuzzy checkpoint</code>，就把这 2 张表的数据生成一个快照，形成一条 checkpoint 日志，记入 redo log。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251218150404609.png" alt=""></p>
<p>我们来看一下这 2 个表形成的 checkpoint 日志具体是如何运用的。假设我们有一段如上图所示的日志执行流。</p>
<p>（一）首先是分析阶段，我们检查 Crash 最近的 Checkpoint2：</p>
<ul>
<li>这个时候活跃事务表有 <code>{T2, T3}</code>，此时还没有 T4、T5。从此处开始遍历到 Redo Log 末尾。遍历的过程中，遇到 T2 的结尾 <code>MLOG_MULTI_REC_END</code>，就把 T2 从集合中剔除。当遇到事务 T4 的时候，就把 T4 加入到集合中。当遇到事务 T5 的时候，就把 T5 加入到集合中。最终集合就变成了 <code>{T2, T3, T4}</code>。</li>
<li>假设这个时候的脏页有 <code>{P2, P3}</code>，从后遍历遇到新的页就将其加入脏页集合，最终集合可能是 <code>{P1, P2, P3, P4, P5}</code>。</li>
</ul>
<p>（二）进行 Redo：</p>
<ul>
<li>我们取脏页集合中最小的 recoveryLSN，得到 <code>firstLSN</code>。从 <code>firstLSN</code> 遍历到 Redo Log 到末尾，把每条 Redo Log 对应的 Page 全部重新刷一次盘。</li>
<li>我们不怕重复刷盘，这是因为 Redo Log 是幂等的。磁盘上每一个 Page 都有一个关键字段 <code>pageLSN</code>。这个 LSN 记录这个 Page 刷盘时最后一次修改它的日志对应的 LSN。如果重放日志的 LSN &lt;= pageLSN，则不修改对应日志的 Page，略过这条日志。</li>
<li>Redo 完成后，就保证了所有的脏页都已经刷到磁盘上了，并且未提交的事务 <code>{T2, T3, T4}</code>  对应的页也写入了磁盘，这个时候就要做回滚了。</li>
</ul>
<p>（三）进行 Undo：</p>
<ul>
<li>在分析阶段我们已经找出了未提交事务集合 <code>{T2, T3, T4}</code> 。从最后一条日志逆向遍历，因为每条日志都有一个 <code>prevLSN</code>  字段，所以可以沿着 T3、T4、T5 各自的日志链一直回溯到 T3 的第一条日志。</li>
<li>所以 Undo，是指每一道一条属于 T3、T4、T5 的 Log，就生成一条逆向的 SQL 来执行，其对应执行的 Redo Log 是 Compensation Log Record（CLR），会在 Redo Log 尾部继续追加，由此来实现回滚功能。</li>
</ul>
<p>在进行 Undo 的时候，还有可能会遇到一个问题，回滚到一半，宕机，重启，再回滚，要进行&quot;回滚的回滚&quot;。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251219211920578.png" alt=""></p>
<p>假设事务 T 有三条日志，对应 LSN 分别为 600、900 和 1000。</p>
<ol>
<li>首先对 1000 进行回滚，生成对应的 LSN=1200 的日志，这条日志里面会有一个字段叫做 <code>UndoNxtLSN</code>，记录的是其对应的被回滚的日志的前一条日志，即 UndoNxtLSN=900.</li>
<li>宕机重启时，遇到 LSN=1200 的 CLR，会忽略，然后看到 UndoNxtLSN=900，会定位到 LSN=900 的日志，为其生成对应的 CLR 日志 LSN=1600，然后继续回滚。</li>
<li>然后 LSN=1700 的日志，回滚的是 600 的。</li>
</ol>
<p>这样就能保证回滚日志和之前的日志一一对应，不会出现&quot;回滚嵌套&quot;的情况。</p>
<h3>小节</h3>
<p>到此为止，我们已经对事物的 A（原子性）和 D（持久性）有了一个全面的理解，这里对 Redo Log 做一个简单的总结：</p>
<ul>
<li>一个事务赌赢多条 Redo Log，事务的 Redo Log 不是连续存储的，靠 <code>prevLSN</code> 实现回溯。</li>
<li>Redo Log 不保证事务的原子性，而是保证了持久性。无论提交的、未提交的事务的日志，都会进入 Redo Log。从而使得 Redo Log 回放完毕，数据库就恢复到了宕机之前的状态，称为 Repeating History。</li>
<li>同时，把未提交的事务挑出来进行回滚。回滚通过 Checkpoint 记录的 <code>活跃事务表</code> + <code>每个事务日志中的开始/结束标记</code> + <code>Undo Log</code> 来实现。</li>
<li>Redo Log 具有幂等性，通过每个 Page 里面的 <code>pageLSN</code>  进行实现。</li>
<li>事务不存在物理回滚，所有的回滚操作都被转化成了 Compensation Log Record 进行 Commit。</li>
</ul>
<h2>undo log 是怎么实现隔离性的？</h2>
<p>前面我们将了 redo log 是如何实现持久性的，并且顺带将了崩溃恢复过程中，undo log 是如何实现原子性的。接下来我们来看下，undo log 是如何实现隔离性的。</p>
<h3>真的需要 undo log 吗？</h3>
<p>这是笔者在其他资料都看不到的一个问题（当然，也可能是看得太少了 😭），但是<a href="https://book.douban.com/subject/30443578/">《软件架构设计·大型网站技术架构与业务架构融合之道》</a>这书就着重讲解了这个问题，讲的太好了 👏🏻！</p>
<p>事务回滚有 4 种场景：</p>
<ol>
<li>人为回滚。业务异常，客户端主动请求回滚。</li>
<li>宕机回滚。</li>
<li>人为回滚 + 宕机回滚。</li>
<li>宕机回滚 + 宕机回滚。</li>
</ol>
<p>数据库面临两个核心决策：</p>
<ol>
<li><strong>何时把脏页写盘？</strong> （是否允许在事务提交前写？） -&gt; <strong>STEAL vs NO-STEAL</strong></li>
<li><strong>事务提交时必须写盘吗？</strong> （是否要求提交时同步刷盘？） -&gt; <strong>FORCE vs NO-FORCE</strong></li>
</ol>
<table>
<thead>
<tr>
<th>类型</th>
<th>说明</th>
<th>分析</th>
<th>问题</th>
</tr>
</thead>
<tbody><tr>
<td>Force + No Steal</td>
<td>已提交的事务必须写入磁盘，未提交的事务不允许写入磁盘。</td>
<td>不需要 Log。因为磁盘上都已提交的，内存的宕机直接消失。</td>
<td>每次提交都需要 I/O，性能太差。</td>
</tr>
<tr>
<td>No Force + No Steal</td>
<td>已提交的事务可以不写入磁盘，未提交是事务不允许写入磁盘。</td>
<td>只需要 Redo Log，只需要在恢复的时候重放 Redo Log 即可。</td>
<td>事务必须提交了，才允许开始写入磁盘，I/O 效率相对较低，但还可以接受。</td>
</tr>
<tr>
<td>Force + Steal</td>
<td>已提交的事务必须写入磁盘，未提交的事务也写入磁盘。</td>
<td>只需要 Undo Log，只需要在恢复的时候利用 Undo Log 回滚未提交事务即可。</td>
<td>每次提交都需要 I/O，性能太差。</td>
</tr>
<tr>
<td>👉 No Force + Steal</td>
<td>事务有没有提交，都可以写入磁盘，想啥时候写啥时候写。</td>
<td>Redo Log 用于重放已提交事务，Undo Log 用于回滚未提交事务。</td>
<td>效果最好！</td>
</tr>
</tbody></table>
<h3>undo log 的存储结构是怎样的？</h3>
<p>要理解 InnoDB Undo Log 的底层实现，我们不能把它想象成一个简单的文本日志文件（像 error.log 那样）。</p>
<p>从第一性原理来看，<strong>InnoDB 把 Undo Log 当作&quot;特殊的只读数据&quot;来管理</strong>。 这意味着：Undo Log 也是存在于 <strong>Page（页）</strong> 里的，它也受 <strong>Buffer Pool</strong> 缓存，它修改时也会产生 <strong>Redo Log</strong>，它也会被刷入磁盘（前面的崩溃恢复算法也说明了这一点）。</p>
<p>我们需要从 <strong>微观（单条记录长什么样）</strong> 到 <strong>宏观（如何在磁盘和内存中组织）</strong> 两个维度来拆解。</p>
<h4>微观视角：Undo Log Record（物理二进制结构）</h4>
<p>Undo Log 并不是记录原来的整个页面，而是记录<strong>逻辑上的反向操作</strong>。根据操作类型的不同，它的物理结构分为两类，这种区分是为了极致的存储效率。</p>
<p>当你执行 <code>INSERT</code> 时，产生的 Undo Log。</p>
<ul>
<li>如果我要回滚一个插入，我只需要知道**主键（Primary Key）**是什么，然后把它删掉就行了。</li>
<li><strong>物理结构：</strong> 非常简单，只记录了 <code>&lt;Table ID, Primary Key&gt;</code>。</li>
<li><strong>生命周期：</strong> <strong>极短</strong>。事务一提交（Commit），这个 Undo Log 就没用了（因为新插入的数据对于其他早于它的事务是天然不可见的，不需要通过 Undo Log 来构建历史版本）。所以它会在提交后<strong>直接删除</strong>。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/insert_undo_record.png" alt=""></p>
<blockquote>
<ul>
<li>Undo Number 是Undo的一个递增编号</li>
<li>Table ID 用来表示是哪张表的修改。</li>
<li>下面一组 Key Fields 的长度不定，因为对应表的主键可能由多个 field 组成，这里需要记录 Record 完整的主键信息，回滚的时候可以通过这个信息在索引中定位到对应的 Record。</li>
<li>除此之外，在 Undo Record 的头尾还各留了两个字节用户记录其前序和后继 Undo Record 的位置。</li>
</ul>
</blockquote>
<p>当你执行 <code>UPDATE</code> 或 <code>DELETE</code> 时，产生的 Undo Log。</p>
<ul>
<li>如果我要回滚一个更新，我必须知道<strong>更新前的旧值</strong>。如果我要支持 MVCC，我也需要这个旧值。</li>
<li><strong>物理结构：</strong> 比较复杂。它需要记录 <code>&lt;Table ID, Primary Key, 修改列的旧值 (Old Value)&gt;</code>。</li>
<li><strong>生命周期：</strong> <strong>很长</strong>。事务提交后，<strong>不能马上删除</strong>。因为可能有别的长事务（Read View）还在运行，它们可能需要通过这个 Undo Log 来看快照读。</li>
<li>只有当系统里没有任何一个事务需要看这个历史版本时，它才会被 <strong>Purge 线程</strong> 清理掉。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/update_undo_record.png" alt=""></p>
<blockquote>
<p>除了跟 Insert Undo Record 相同的头尾信息，以及主键 Key Fileds 之外，Update Undo Record 增加了：</p>
<ul>
<li>Transaction Id 记录了产生这个历史版本事务 Id，用作后续 MVCC 中的版本可见性判断。</li>
<li>Rollptr 指向的是该记录的上一个版本的位置，包括 space number，page number 和 page 内的 offset。沿着 Rollptr 可以找到一个 Record 的所有历史版本。</li>
<li>Update Fields 中记录的就是当前这个 Record 版本相对于其之后的一次修改的 Delta 信息，包括所有被修改的 Field 的编号，长度和历史值。</li>
</ul>
</blockquote>
<h4>宏观视角：组织结构（磁盘与内存）</h4>
<p>InnoDB 是如何管理成千上万个并发事务产生的 Undo Log Record 呢？</p>
<p>这涉及到一个层级结构：<strong>Tablespace -&gt; Rollback Segment -&gt; Undo Log Segment -&gt; Undo Page</strong>。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/Gemini_Generated_Image_4l7wrj4l7wrj4l7w.png" alt=""></p>
<p>这张图展示了 InnoDB 是如何在<strong>磁盘</strong>和<strong>内存</strong>中从宏观到微观组织 Undo Log 的。这就像是一个巨大的&quot;档案管理系统&quot;。</p>
<p>为了更直观地理解，我们可以把这个结构比喻成一个<strong>大型档案馆</strong>。我们由外向内，层层拆解：</p>
<p><strong>第一层：Undo Tablespace (档案馆大楼)</strong></p>
<ul>
<li><strong>图中位置：</strong> 最外层的深蓝色大框 <code>InnoDB Undo Tablespace</code>。</li>
<li><strong>物理对应：</strong> 磁盘上的文件，比如 <code>undo_001</code>, <code>undo_002</code>（或者老版本在 <code>ibdata1</code> 中）。</li>
<li><strong>解释：</strong> 这是存放档案的物理大楼。所有回滚记录最终都要落在这里。自 MySQL 8.0 开始，Undo Log 默认使用独立的表空间文件，不再挤在系统表空间里，这样管理更灵活。</li>
</ul>
<p><strong>第二层：Rollback Segment (档案室/管理员)</strong></p>
<ul>
<li><strong>图中位置：</strong> 左上角的 <code>Rollback Segment (rseg 0 ... 127)</code>。</li>
<li><strong>解释：</strong> 为了不让成千上万个事务打架，InnoDB 把大楼划分成了 <strong>128 个档案室（rseg）</strong>。每个事务开始时，会被分配给其中一个 rseg 来管理。这样，不同 rseg 下的事务就不会产生严重的锁竞争。</li>
</ul>
<p><strong>第三层：Undo Slot (档案柜/目录槽)</strong></p>
<ul>
<li><strong>图中位置：</strong> 左边中间放大的 <code>Undo Slot 0 ... 1023</code>。</li>
<li><strong>解释：</strong> 走进第 5 号档案室（<code>rseg 5</code>），你会看到墙上有一排排的格子，这就是 <strong>Slot</strong>。每个 rseg 有 <strong>1024 个 Slot</strong>。当一个事务开启时，它会占用一个 Slot。这个 Slot 并不直接存数据，而是存放指针，指向真正存放档案的地方。</li>
</ul>
<p><strong>第四层：Undo Log Segment (档案卷宗/链表)</strong></p>
<ul>
<li><strong>图中位置：</strong> 右边的大框，由箭头连接的三个 <code>Undo Page</code>。</li>
<li><strong>解释：</strong> 这是最核心的存储结构。<ul>
<li><strong>动态扩展：</strong> 事务刚开始写 Undo Log 时，可能只需要一张纸（一个 Page）。但如果是个大事务（比如更新了 100 万行数据），一张纸不够写，就需要申请第二张、第三张。</li>
<li><strong>链表结构：</strong> 图中的箭头展示了它们是如何连起来的：<code>Start Page</code>（首页） -&gt; <code>Middle Page</code>（中间页） -&gt; <code>End Page</code>（尾页）。这组成了一个逻辑上的 <strong>Segment（段）</strong>。</li>
<li>左边的 <code>Undo Slot</code> 指针，实际上就是指向这个链表的<strong>头部</strong>。</li>
</ul>
</li>
</ul>
<p><strong>第五层：Undo Page (档案纸/物理页)</strong></p>
<ul>
<li><strong>图中位置：</strong> 右边的三个蓝色小方块。</li>
<li><strong>解释：</strong> 这是磁盘读写的最小单位（默认为 16KB）。<ul>
<li><strong>Page Header/Footer：</strong> 每一页都有头和尾，记录了校验和、页号、以及上一页/下一页的指针（双向链表）。</li>
<li><strong>Undo Log Record：</strong> 页中间那一堆 <code>Update, ID=A</code>、<code>Insert, ID=B</code> 就是真正的回滚记录。</li>
</ul>
</li>
</ul>
<p>当一个事务需要修改数据时：</p>
<ol>
<li><strong>Space Alloc:</strong> 在 <strong>Undo Tablespace</strong> 中定位。</li>
<li><strong>Rseg Assign:</strong> 事务被调度到第 $N$ 号 <strong>Rollback Segment</strong>。</li>
<li><strong>Slot Reserving:</strong> 在 Rseg Header 中找到一个空闲的 <strong>Undo Slot</strong>，将其状态置为占用。</li>
<li><strong>Segment Init:</strong> 如果 Slot 指向空，则通过 FSEG 申请一个新的 <strong>Undo Log Segment</strong>（包含至少一个 Page）；如果 Slot 指向已缓存的 Segment，则复用之。</li>
<li><strong>Page Write:</strong> 事务在 <strong>Undo Page</strong> 中顺序写入 Undo Log Record。若当前页写满，则通过 Segment 申请新页，并更新页头的双向链表指针。</li>
</ol>
<p>这么设计的两大好处：</p>
<ul>
<li><strong>为了高并发</strong>：128 个 Segment × 1024 个 Slot = 支持 <strong>13 万</strong> 个并发事务同时写 Undo Log。</li>
<li><strong>为了大事务</strong>：通过 Page 链表（Segment），一个事务可以无限写入 Undo Log，而不会受到单页 16KB 的限制。</li>
</ul>
<h3>undo log 如何实现 MVCC？</h3>
<p>在回答这个问题之前，我们先来思考一下多线程编程中，读写的并发问题有哪些策略？</p>
<ul>
<li>互斥锁</li>
<li>读写锁</li>
<li>CopyOnWrite</li>
</ul>
<p>这 3 种策略，从上到下，并发度越来越高。而 InnoDB 用的就是 CopyOnWrite 的思想，即 Undo Log 的本质思想就是 CopyOnWrite。</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>每个事务修改记录之前，都会把该记录拷贝一份出来，拷贝出来的这个备份在 Undo Log 里，因为事务有唯一的编号（Trx ID），ID 从小到大递增，每一次修改，就是一个版本，因为 Undo Log 维护了数据的从旧到新的每个版本，各个版本之间的记录通过链表串联。</p>
</blockquote>
<p>那内存中是如何把数据行和 Undo Log 连起来的呢？</p>
<p>我们在 Buffer Pool 里的每一行数据，实际上都有两个隐藏列：</p>
<ol>
<li><strong><code>DB_TRX_ID</code></strong>：最近修改这条数据的事务 ID。</li>
<li><strong><code>DB_ROLL_PTR</code></strong>：回滚指针。</li>
</ol>
<p><strong>这个指针指向哪里？</strong> 它包含三个信息：<code>Space ID</code> (表空间号), <code>Page No</code> (页号), <code>Offset</code> (页内偏移量)。 它精准地指向了<strong>Undo Page</strong> 中存放的那条对应的 <strong>Update Undo Log Record</strong>。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/Gemini_Generated_Image_bakffbakffbakffb.png" alt=""></p>
<p>当事务提交后，Insert Undo Log 被释放了，但 Update Undo Log 被留下来了。 这些提交后的 Update Undo Log 会被加入到一个全局的大链表——<strong>History List</strong>。这个链表是给 <strong>Purge 线程</strong> 用的。</p>
<p>Purge 线程像一个扫地僧，它顺着 History List 扫描，一旦发现某个 Undo Log 对应的事务 ID 已经太老了（比当前所有活跃事务的 Read View 都老），说明再也没人需要看这个版本了，就把它物理删除，释放空间。</p>
<h3>undo log 结合 redo log</h3>
<p>我们前面说，undo log 其实也是一种&quot;数据&quot;，它也是要写 Redo Log 的。我们可以用一个例子来将这 2 者进行结合。</p>
<p>假设有如下一个事务：</p>
<pre><code>start transaction
	update 表1某行记录
	delete 表1某行记录
	insert 表2某行记录
commit
</code></pre>
<p>把 Undo Log 和 Redo Log 加进去，此事务类似下面伪代码所示：</p>
<pre><code>start transaction
	写 Undo Log1：备份该行数据（update）
	update 表1某行记录
	写 Redo Log1
	
	写 Undo Log2：备份该行数据（insert）
	delete 表1某行记录
	写 Redo Log2
	
	写 Undo Log3：该行的主键（delete）
	insert 表2某行记录
	写 Redo Log3
commit
</code></pre>
<h2>binlog 是如何保证集群一致性的？</h2>
<p>由于 MySQL 是插件式存储引擎架构，Binlog 作为 Server 层的全局日志，必须通过 <strong>两阶段提交（2PC）</strong> 与存储引擎层的 Redo Log 强绑定，才能在逻辑上构成一个完整的、可恢复的事务。</p>
<p>之所以需要这个机制，是因为 <strong>Redo Log</strong>和 <strong>Binlog</strong> 是独立的。如果不同步，就会出现&quot;主库数据恢复了，但从库没同步&quot;或者&quot;从库同步了，但主库崩溃后数据丢了&quot;的灾难。</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>Redo Log 保证了<strong>物理一致性</strong>（崩溃恢复），而 Binlog 保证了<strong>逻辑一致性</strong>（主从复制、数据回滚/闪回）。</p>
</blockquote>
<h3>binlog 如何跟 redo log 进行配合？</h3>
<p>因为 Binlog 和 Redo log 两个是独立的，所以要保证它们的一致性，需要遵循如下的两阶段提交（2PC）过程：</p>
<p><strong>Prepare 阶段</strong>：</p>
<ul>
<li>InnoDB 将修改记录到 Redo Log。</li>
<li>将该事务的 <strong>XID</strong>（全局事务 ID）写入 Redo Log。</li>
<li>将 Redo Log 状态置为 <code>TRX_PREPARE</code> 并刷盘。</li>
</ul>
<p><strong>Commit 阶段</strong>：</p>
<ul>
<li>Server 层将事务的逻辑操作写入 Binlog 文件并刷盘。</li>
<li>InnoDB 收到 Binlog 成功的反馈，在 Redo Log 中记录 <code>commit</code> 标记，事务正式完成。</li>
</ul>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251220001531508.png" style="zoom: 25%;" />

<p>如果系统在 2PC 过程中宕机，重启后 MySQL 会扫描 Redo Log，根据 <strong>XID</strong> 去 Binlog 中寻找匹配：</p>
<ul>
<li><strong>判定 1</strong>：Redo Log 有 <code>commit</code> 标记 $\to$ <strong>直接提交</strong>。</li>
<li><strong>判定 2</strong>：Redo Log 只有 <code>prepare</code> 标记，但 Binlog 中<strong>存在</strong>该 XID $\to$ <strong>提交</strong>（因为 Binlog 已落盘，从库可能已同步，主库必须保持一致）。</li>
<li><strong>判定 3</strong>：Redo Log 只有 <code>prepare</code> 标记，且 Binlog 中<strong>不存在</strong>该 XID $\to$ <strong>回滚</strong>（说明宕机发生在写 Binlog 之前）。</li>
</ul>
<blockquote>
<p>既然第 1 阶段 Redo 已经刷盘了，为什么非要等到第 2 阶段 Binlog 刷盘后，事务才算成功？</p>
<p>因为 Redo Log 只管主库自己的崩溃恢复。如果不等 Binlog 刷盘就认为成功，万一主库写完 Redo 挂了，没来得及写 Binlog，<strong>从库就不会同步这笔数据</strong>。重启后，主库有这笔数，从库没有，<strong>主从一致性就彻底崩了</strong>。所以 2PC 的核心就是用 Binlog 的落盘来充当全局事务的裁决者。</p>
</blockquote>
<p>同 Redo Log 一样，Binlog 也存在一个刷盘策略问题，由参数 <code>sync_binlog</code> 控制：</p>
<ul>
<li><code>0</code>：事务提交之后不主动刷盘，依靠操作系统自身的刷盘机制，可能会丢失数据。</li>
<li><code>1</code>：每提交一次事务，刷一次盘。</li>
<li><code>n</code>：每提交 n 次事务，刷一次盘。</li>
</ul>
<p>显然，<code>0</code> 和 <code>n</code> 都不安全。为了不丢失数据，一般多建议双 <code>1</code> 保证，即 <code>sync_binlog</code> 和 <code>innodb_flush_log_at_trx_commit</code> 的值都取位 <code>1</code>。</p>
<h3>刷盘压力那么大怎么缓解？</h3>
<p>在双 <code>1</code> 配置下，每个事务都要触发磁盘 IO（fsync）。如果并发量上来，磁盘 IOPS 会直接把数据库卡死。</p>
<p>MySQL 5.6 引入了 BLGC，将提交过程设计成一个<strong>流水线</strong>。</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>核心思想</strong></p>
<p><strong>当有多个事务并发提交时，MySQL 维护了一个队列，第一个抢到锁的事务（第一个进入空队列）成为队长 (Leader)，后面排队的事务成为队员 (Follower)。队长会代表所有队员，一次性完成 IO 操作。</strong></p>
</blockquote>
<p>这个过程分为三个阶段的队列：</p>
<ol>
<li><p><strong>Flush 阶段（写 OS Cache）</strong>：队长带着一群队员，把他们各自的 Binlog 数据写入操作系统的<strong>文件缓存 (OS Cache)</strong>。</p>
</li>
<li><p><strong>Sync 阶段（刷磁盘 fsync）</strong>：队长调用一次 <code>fsync()</code>。因为大家的数据都在 OS Cache 里了，这一次 <code>fsync</code>就把<strong>这一整车人</strong>的 Binlog 全都持久化到磁盘了。原本 10 个事务需要 10 次 fsync，现在 1 次搞定。吞吐量直接翻倍。</p>
</li>
<li><p><strong>Commit 阶段（引擎层提交）</strong>：队长通知 InnoDB 引擎，把这批事务在 Redo Log 里的状态由 <code>Prepare</code> 改为 <code>Commit</code>。</p>
</li>
</ol>
<p>🚀 Redo Log 也就顺便搭便车了</p>
<p>在没有 BLGC 之前，Redo Log 需要在 Prepare 阶段自己去刷盘。 有了 BLGC 后，MySQL 做了一个极度聪明的优化，叫做 <strong>Redo Log Group Commit</strong>。</p>
<p><strong>它的时机是在 <code>Flush 阶段</code> 之后，<code>Sync 阶段</code> 之前。</strong></p>
<ol>
<li><strong>Flush 阶段</strong>：队长把 Binlog 写进 OS Cache。</li>
<li><strong>Redo Log 刷盘</strong>：在队长执行 Binlog 的 <code>fsync</code> <strong>之前</strong>，InnoDB 发现：&quot;哎？既然你们这一车人都要提交了，那你们对应的 Redo Log (Prepare 状态) 肯定也要落盘啊&quot;。于是，InnoDB 会把这一组事务的 Redo Log <strong>一次性</strong> 刷入磁盘。</li>
<li><strong>Sync 阶段</strong>：队长执行 Binlog 的 <code>fsync</code>。</li>
</ol>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251220002636458.png" style="zoom: 25%;" />



<p>通过这个机制，Redo Log 也享受到了组提交的红利。 原本：N 个事务 = N 次 Redo fsync + N 次 Binlog fsync。 现在：N 个事务（一批）= 1 次 Redo fsync + 1 次 Binlog fsync。</p>
<p>这就是为什么现在的 MySQL 即使开启了双 <code>1</code> 配置，在并发高的时候，性能依然非常强劲的原因。</p>
<h2>总结</h2>
<p>综上，我们就从 ACID 各个方向阐述了 InnoDB 是如何实现事务的了。回顾整个 MySQL 事务的实现链路，我们可以将其核心设计哲学概括为<strong>三重保障</strong>，这也是我们在架构设计中可以借鉴的最高准则。</p>
<p><strong>物理层：WAL 与崩溃恢复</strong> —— InnoDB 不直接写磁盘数据页，而是先写 <strong>Redo Log</strong>。</p>
<ul>
<li>通过 <strong>Physiological Logging</strong>（定位到页，逻辑修改内容），在保证日志足够小的同时，拥有了修复物理 B+ 树的能力。</li>
<li><strong>ARIES 算法</strong> 保证了即使在极端宕机情况下，数据库也能通过&quot;重做历史&quot;和&quot;逻辑回滚&quot;恢复到一致状态。</li>
</ul>
<p>**逻辑层：MVCC 与锁平衡 **—— 为了解决读写冲突，MySQL 并没有简单粗暴地使用全表锁或行锁。</p>
<ul>
<li><strong>Undo Log</strong> 构建了数据的 History List，配合 <strong>ReadView</strong> 机制，实现了 RC 和 RR 级别下的<strong>快照读</strong>，做到了读不阻塞写，写不阻塞读。</li>
<li>这本质上就是 CopyOnWrite。</li>
</ul>
<p><strong>架构层：2PC 与组提交协同</strong></p>
<ul>
<li><strong>Binlog</strong> 决定了集群的逻辑一致性（主从）。</li>
<li><strong>Redo Log</strong> 决定了单机的物理一致性。</li>
<li><strong>BLGC (组提交)</strong> 则是在双 <code>1</code> 安全配置下，通过流水线和搭便车机制，将磁盘 I/O 成本降到最低。</li>
</ul>
<p>理解 MySQL 事务，不应止步于 ACID 的定义，而应看透其在<strong>数据安全（持久性）</strong>、**高并发（隔离性）<strong>与</strong>吞吐量（性能）**之间所做的精妙权衡。</p>
<h2>参考</h2>
<ul>
<li><a href="https://catkang.github.io/2021/10/30/mysql-undo.html">庖丁解InnoDB之Undo LOG</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>MySQL 底层原理丨主从复制带来的新问题</title>
      <link>https://hedon.top/blog/mysql-master-slave-new-questions/</link>
      <guid isPermaLink="true">https://hedon.top/blog/mysql-master-slave-new-questions/</guid>
      <pubDate>Wed, 17 Dec 2025 21:57:00 GMT</pubDate>
      <description>本篇将探讨主从复制带来的新问题。</description>
      <category>MySQL</category><category>主从复制</category><category>数据库</category>
      <content:encoded><![CDATA[<p>在单机数据库模式下，我们享受了太多的理所当然：ACID 事务、强一致性读写、瞬间的数据可见性。然而，一旦引入了<strong>异步主从复制</strong>，这个美好的世界就崩塌了。系统从 <strong>CP (一致性)</strong> 模型滑向了 <strong>AP (可用性)</strong> 模型。<strong>主从延迟（Replication Lag）</strong> 像一个幽灵，让许多在单机下奉为圭臬的最佳实践，变成了分布式环境下的致命陷阱。</p>
<p>核心矛盾根源：真相的传播延迟</p>
<ul>
<li><strong>单机模式</strong>：&quot;真相&quot;只有一个（唯一的 DB 实例）。写入即事实，读取即真相。时间差近乎为零。</li>
<li><strong>主从模式</strong>：&quot;真相&quot;在传播。主库是当前的真相，从库是几百毫秒甚至几秒前的&quot;历史影像&quot;。<u>依赖历史影像做决策，必然出错</u>。</li>
</ul>
<p>从单机到主从架构的升级，不是简单的加几台机器，而是一场思维方式的革命：</p>
<ol>
<li><strong>放弃对实时强一致性的幻想</strong>：在分布式系统中，除了核心的金融级数据，大部分场景都要接受<strong>最终一致性</strong>。</li>
<li><strong>识别真理之源</strong>：时刻清楚 Master DB 是唯一的真理。Slave DB 只是用于分担非敏感读压力的快照副本。</li>
<li><strong>防御性编程</strong>：写代码时，永远要假设你读到的数据可能是旧的，并思考&quot;如果它是旧的，我的业务逻辑会不会炸？&quot;如果会炸，就必须升级方案（走主库、加异步校验链等）。</li>
</ol>
<p>本篇接下来就梳理单机模式最佳实践在主从架构下失效的经典场景。</p>
<h2>1. 缓存一致性</h2>
<p>在单机模式下，为了应对 MySQL 和 Redis（缓存）的一致性，业界通用的解决方案是 Cache Aside（旁路缓存）。即：先更新 DB，再删缓存。下次读请求发现 Cache Miss，查 DB 并回填。因 DB 里的数据永远是最新的，回填缓存没问题。</p>
<p>但是在主从模式下就不一样了：</p>
<blockquote>
<p>写请求删了缓存。读请求去<strong>从库</strong>查到了旧数据（因为延迟）。读请求把<strong>旧数据</strong>当作新数据塞回了 Redis。结果就导致了 Redis 里存储了脏数据。</p>
</blockquote>
<h2>总结</h2>
<table>
<thead>
<tr>
<th><strong>核心场景</strong></th>
<th><strong>单机模式下的最佳实践 (Best Practice)</strong></th>
<th><strong>主从模式下的失效原因 (The Trap)</strong></th>
<th><strong>必须升级的分布式方案 (The Upgrade Path)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>缓存一致性</strong> (Cache-Aside)</td>
<td><strong>先写 DB，再删缓存</strong> 依赖读操作回填缓存。简单高效，极少出现不一致。</td>
<td><strong>脏数据回填死结</strong> 写完主库删缓存后，读请求立刻打到延迟的从库，读到旧数据并永久回填入缓存。（即我们刚深入讨论的案例）。</td>
<td><strong>方案 A (标准)：Binlog 异步消息队列删除</strong>（引入异步重试机制兜底）。 <br><strong>方案 B (极强)：回填前强制校验主库版本号</strong>（牺牲主库性能换取强一致）。</td>
</tr>
<tr>
<td><strong>读己之写</strong> (Read-Your-Own-Write)</td>
<td><strong>写完直接读</strong> 用户修改资料后，立刻刷新页面调用查询接口，能马上看到更新后的信息。</td>
<td><strong>用户体验崩塌</strong> 刚写完主库，转头读了从库。用户发现自己刚改的数据没生效，产生恐慌或重复提交。</td>
<td><strong>方案 A (折中)：短期强制路由</strong>（写操作后的 N 秒内，该用户的读请求强制走主库）。 <strong>方案 B (复杂)：客户端版本追踪</strong>（客户端记住自己刚修改的版本号，请求时带上，中间件判断从库是否追上）。</td>
</tr>
<tr>
<td><strong>唯一性检查 / 业务前置校验</strong> (Business Check)</td>
<td><strong>先查后写 (Check-Then-Act)</strong> 例如注册时：<code>SELECT count(*) FROM user WHERE email=?</code>，若为 0 则 INSERT。</td>
<td><strong>并发重复与校验失效</strong> 两个请求同时发到不同从库查询，都以为 email 不存在，然后同时向主库发起 INSERT。虽然主库唯一索引能挡住，但业务层的校验逻辑彻底失效，引发大量报错。</td>
<td><strong>方案 A：强制查主库</strong>（所有涉及状态决策的前置查询，必须走主库）。 <br><strong>方案 B：分布式锁</strong>（在查 DB 前，先在 Redis/ZK 上抢占一个 key，但这引入了新组件依赖）。</td>
</tr>
<tr>
<td><strong>高并发库存扣减</strong> (Inventory Deduction)</td>
<td><strong>悲观锁 <code>SELECT FOR UPDATE</code> 或 乐观锁 <code>UPDATE ... WHERE count &gt; 0</code></strong> 依赖数据库行锁或 MVCC 保证原子性扣减。</td>
<td><strong>从库读取导致超卖</strong> 如果为了性能去从库查询“当前库存”，得到旧值（比如显示有货，实际主库已没货），然后发起扣减请求，导致判断失误。</td>
<td><strong>铁律：涉及钱和库存的操作，读写必须全部在主库完成。</strong> 或者彻底升级架构，使用 Redis + Lua 脚本做库存扣减中心，数据库只做异步落库。</td>
</tr>
<tr>
<td><strong>事务状态查询</strong> (Transaction Status)</td>
<td><strong>直接查询订单表状态</strong> 支付回调后，查询订单表确认订单是否已变为“已支付”。</td>
<td><strong>回调“未找到订单”</strong> 支付成功的回调非常快，可能在主从同步完成前就到达。此时去从库查订单状态，可能查到还是“未支付”，导致业务逻辑错误。</td>
<td><strong>方案：强制查主库</strong>。 对于此类时效性极高的状态确认查询，绝不能走从库。</td>
</tr>
</tbody></table>
]]></content:encoded>
    </item>
    <item>
      <title>MySQL 底层原理丨Online DDL</title>
      <link>https://hedon.top/blog/mysql-online-ddl/</link>
      <guid isPermaLink="true">https://hedon.top/blog/mysql-online-ddl/</guid>
      <pubDate>Wed, 17 Dec 2025 21:57:00 GMT</pubDate>
      <description>本文以第一性原理为切入点，系统梳理 MySQL Online DDL 的核心原理、发展历程与典型实现方式，结合实际场景分析 Online DDL 的类型、适用性与性能优化策略，并剖析其底层机制与使用注意事项。</description>
      <category>MySQL</category><category>Online-DDL</category><category>数据库</category>
      <content:encoded><![CDATA[<p>要理解 MySQL 的 Online DDL，我们不能只看语法，必须从<strong>第一性原理</strong>出发，理解数据库在&quot;修改结构&quot;和&quot;读写数据&quot;这两个核心需求之间的矛盾与平衡。</p>
<p>简单来说，Online DDL 的核心目标是：<strong>在修改表结构（DDL）的同时，不阻塞业务对表的读写操作（DML）。</strong></p>
<h2>1. 宏观概述</h2>
<h3>1.1 背景与痛点</h3>
<p>为什么我们需要 Online DDL？</p>
<p>在 MySQL 5.6 之前，大多数 DDL 操作（如添加索引、添加列）的本质是暴力重构。MySQL 会执行以下步骤：</p>
<ol>
<li>创建一个新的临时表（新结构）。</li>
<li><strong>锁住原表</strong>（禁止写入）。</li>
<li>将原表数据一行行复制到新表。</li>
<li>删除原表，重命名新表。</li>
</ol>
<p>这种方式被称为 <strong>Copy Table</strong> 方式。它的痛点极其明显：在数据量大的表中，DDL 可能运行数小时，期间业务无法写入，这对于高并发互联网应用是灾难性的。</p>
<p>为了解决这个问题，MySQL 逐步引入了 <strong>In-Place</strong> 和 <strong>Instant</strong> 算法，统称为 Online DDL。</p>
<h3>1.2 从 Copy 到 Instant</h3>
<p>理解 Online DDL 的关键在于理解三种算法的演进，这是一个由重到轻的过程：</p>
<h4>1.2.1 COPY（MySQL 5.6 之前）</h4>
<ul>
<li><strong>原理</strong>：在 Server 层处理，新建表 -&gt; 导数据 -&gt; 删旧表。</li>
<li><strong>代价</strong>：极高。I/O 飙升，占用双倍磁盘空间，<strong>全程锁表</strong>（无法写入）。</li>
</ul>
<h4>1.2.2 INPLACE（MySQL 5.6 引入，成熟于 5.7）</h4>
<ul>
<li><strong>原理</strong>：不再通过 Server 层复制数据，而是由 InnoDB 引擎内部在“原地”进行操作。</li>
<li><strong>关键机制</strong>：虽然叫 In-Place（原地），但对于重建表的操作（如添加主键），它实际上还是在引擎内部重建了数据文件，但它引入了一个<strong>Row Log（增量日志）</strong>。</li>
<li><strong>并发性</strong>：允许 DML（增删改）操作。<ul>
<li>在 DDL 执行期间，业务产生的新数据写入 Row Log。</li>
<li>DDL 完成数据重组后，再重放 Row Log 中的变更应用到新表空间。</li>
</ul>
</li>
</ul>
<h4>1.2.3 INSTANT（MySQL 8.0 引入并持续增强）</h4>
<ul>
<li><strong>原理</strong>：只修改数据字典（Metadata）中的元数据，完全不触碰底层数据文件（B+ 树）。</li>
<li><strong>代价</strong>：几乎为零。耗时通常在毫秒级。</li>
<li><strong>场景</strong>：MySQL 8.0 支持瞬间添加列，不需要重建表。</li>
</ul>
<h2>2. 底层原理</h2>
<h3>2.1 INPLACE</h3>
<p>MySQL 5.6 引入，5.7 成熟。它的核心变革在于：<strong>将 DDL 操作下沉到 InnoDB 引擎层，并引入了 Row Log 来暂存并发写入。</strong></p>
<p>INPLACE 分为两类：需要重建表（Rebuild）和不需要重建表（No-Rebuild）。我们重点讲最复杂的 <strong>Rebuild</strong> 场景（例如：添加主键、删除列、修改列字符集）。</p>
<hr>
<p><strong>第一阶段：初始化 (Prepare Phase)</strong></p>
<ol>
<li>对表加 <strong>MDL 写锁</strong>（极短时间）。</li>
<li>获取原表的元数据快照。</li>
<li><strong>降级锁</strong>：将 MDL 写锁降级为 <strong>MDL 读锁</strong>。此时业务可以正常读写（SELECT/INSERT/UPDATE/DELETE）。</li>
</ol>
<hr>
<p><strong>第二阶段：执行 (Execution Phase) —— 最漫长</strong></p>
<p>这是真正的 Online 阶段。InnoDB 引擎内部做两件事：</p>
<p><strong>1. 数据重组 (Rebuild Data)</strong>：</p>
<ul>
<li>InnoDB 建立一个新的临时表空间文件（<code>.ibd</code>）。</li>
<li>扫描原表的 B+ 树叶子节点，按顺序将数据写入新的 B+ 树中。</li>
</ul>
<p><strong>2. 增量捕获 (Row Log)</strong>：</p>
<ul>
<li>在数据重组期间，所有业务产生的 DML（增删改）操作，除了修改原表数据外，还会被记录到一个专门的内存缓冲区：**Row Log **。如果内存存不下，会溢出写入到临时文件中。</li>
</ul>
<hr>
<p><strong>第三阶段：提交 (Commit Phase)</strong></p>
<ol>
<li><strong>升级锁</strong>：再次将 MDL 读锁升级为 <strong>MDL 写锁</strong>（此时业务写入再次短暂阻塞）。</li>
<li><strong>日志回放 (Apply Log)</strong>：将 <strong>Row Log</strong> 中积累的增量变更，应用到新的表空间中。因为 Row Log 通常比全量数据小得多，所以这个过程很快。</li>
<li><strong>文件交换</strong>：用新的 <code>.ibd</code> 文件替换旧文件。</li>
<li><strong>释放锁</strong>。</li>
</ol>
<hr>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251218000752138.png" alt=""></p>
<p>总结：</p>
<ul>
<li><strong>核心机制</strong>：<strong>快照数据（基线） + Row Log（增量）</strong>。</li>
<li><strong>代价</strong>：虽然不阻塞写入，但会消耗大量 CPU 和 IO（同时写原表和 Log），且若 DDL 耗时过长，Row Log 可能撑爆磁盘。</li>
</ul>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>注意</strong></p>
<p><strong>修改列的数据类型</strong>（例如从 <code>CHAR</code> 改为 <code>INT</code>）通常<strong>必须使用 COPY 算法</strong>。因为底层二进制存储格式变了，必须重写每一行数据，无法 In-Place。</p>
</blockquote>
<h3>2.2 INSTANT</h3>
<p>MySQL 8.0 引入，并在 8.0.12 和 8.0.29 版本中大幅增强。它的核心在于：<strong>彻底放弃修改物理数据文件，只修改数据字典（Metadata）。</strong></p>
<ul>
<li>MySQL 8.0.12：仅支持追加列。原理很简单，在表定义的元数据中记录一个&quot;列数&quot;标记。旧数据读出来列数不够，就自动补默认值。</li>
<li>MySQL 8.0.29：支持任意位置加列/删列。原理更加精妙，利用了<strong>行版本控制</strong>。</li>
</ul>
<p>底层原理流程：</p>
<ol>
<li><strong>加锁</strong>：加 MDL 写锁（极短，仅用于修改元数据）。</li>
<li><strong>修改元数据</strong>：<ul>
<li>在 <code>.SDI</code> (System Data Index) 或数据字典中，更新表的定义。</li>
<li><strong>关键点</strong>：给表分配一个新的 <strong><code>INSTANT_COLUMN_ID</code></strong>。</li>
<li>设置新列的 <strong>Default Value</strong>（存储在元数据中，不存磁盘行内）。</li>
</ul>
</li>
<li><strong>释放锁</strong>。</li>
</ol>
<p>当业务执行 <code>SELECT *</code> 时，InnoDB 引擎读取磁盘上的行记录（Row）：</p>
<ul>
<li><strong>旧行（DDL 之前写入的）</strong>：行头部的版本信息或列数标记表明它是&quot;老版本&quot;。InnoDB 发现这一行缺少新列，于是从<strong>元数据</strong>中读取该列的默认值，动态拼接并返回给应用。</li>
<li><strong>新行（DDL 之后写入的）</strong>：按照新的结构物理存储，包含新列的数据。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251218000807408.png" alt=""></p>
<h2>3. 风险隐患</h2>
<h3>3.1 INPLACE</h3>
<p>虽然 INPLACE 是 &quot;Online&quot;，但在生产环境（特别是大表）直接执行 <code>ALTER TABLE</code> 依然极其危险。</p>
<h4>3.1.1 MDL 锁风暴</h4>
<p>这是最容易被忽视的风险。</p>
<ul>
<li><strong>现象</strong>：你发起一个 Online DDL，理论上不锁表。但是，如果有另一个长事务（比如一个正在运行的慢查询）正在引用这张表，DDL 就会挂起，等待获取 MDL 锁。</li>
<li><strong>连锁反应</strong>：MySQL 的锁等待队列通常是 <strong>FCFS (First Come First Served)</strong> 或<strong>写锁优先级高于读锁</strong>。DDL 一旦开始等待 MDL 锁，它后面进来的所有正常业务请求（SELECT/UPDATE）全都会被阻塞！瞬间导致连接池爆满，业务挂掉。</li>
<li><strong>对策</strong>：在执行 DDL 前，务必检查 <code>information_schema.processlist</code>，确保没有长事务或慢查询在操作该表。</li>
</ul>
<blockquote>
<p>INSTANT 也有这个问题。</p>
</blockquote>
<h4>3.1.2 主从延迟</h4>
<p>这是 MySQL 主从架构的经典痛点。</p>
<ul>
<li><strong>Master</strong>：Online DDL 可以开启多线程（MySQL 8.0+ 支持 parallel DDL），或者只是 Master 机器性能强，跑了 10 分钟做完了。</li>
<li><strong>Binlog</strong>：DDL 完成后，Master 会往 Binlog 写一句 <code>ALTER TABLE ...</code>。</li>
<li><strong>Slave</strong>：<ul>
<li>Slave 的 SQL Thread 是单线程回放 DDL 的（在很多版本配置下）。</li>
<li>Slave 收到这个 DDL，也开始跑。Master 跑了 10 分钟，Slave 可能也要跑 10 分钟（甚至更久，因为 Slave 配置通常低）。</li>
<li><strong>后果</strong>：在这 10 分钟内，Slave 忙着做 DDL，无法处理 Master 传过来的其他 Update/Insert Binlog。</li>
<li><strong>Seconds_Behind_Master 飙升</strong>。读写分离的业务读不到最新数据，产生逻辑错误。</li>
</ul>
</li>
</ul>
<h4>3.1.3 资源争抢与 Buffer Pool 污染</h4>
<p>Online DDL（In-Place Rebuild）本质上是一次<strong>全量的读 + 全量的写</strong>。</p>
<ul>
<li><strong>Buffer Pool 污染</strong>：<ul>
<li>DDL 扫描原表所有数据页。如果表很大，会把 Buffer Pool 里的热数据（业务正在用的数据）挤出去。</li>
<li>DDL 结束后，业务查询必须重新从磁盘加载数据，导致 <strong>RT (响应时间) 抖动</strong>。</li>
</ul>
</li>
<li><strong>IO 争抢</strong>：虽然 DDL 设置了并发度，但它依然会占用大量的磁盘 IOPS 和 CPU。如果是云盘或机械盘，业务正常的 CRUD 可能会因为 IO 等待而变慢。</li>
</ul>
<h4>3.1.4 Apply Log 阻塞</h4>
<p>MySQL 为了保证新表和旧表数据<strong>严格一致</strong>，在 Switch（切换）的那一刻，必须保证 Row Log 里的数据全部回放完毕。</p>
<ul>
<li><strong>理想情况</strong>：DDL 执行期间，业务写入很少。Row Log 只有几 KB。Step B 在毫秒级完成，用户无感知。</li>
<li><strong>糟糕情况</strong>：DDL 跑了 1 个小时（全表重组）。这 1 小时内，业务疯狂写入（TPS 很高）。Row Log 积压了 500MB 甚至更多。<ul>
<li>到了 Commit 阶段，MySQL 获取写锁。</li>
<li>开始回放这 500MB 的日志。</li>
<li><strong>整个回放过程，业务全部被堵在外面！</strong></li>
<li>如果回放需要 1 分钟，你的系统就挂了 1 分钟。</li>
</ul>
</li>
</ul>
<blockquote>
<p><strong>第一性原理视角</strong>：这就是 <strong>追赶模型 (Catch-up Model)</strong> 的固有缺陷。如果&quot;增量产生的速度&quot;接近&quot;回放的速度&quot;，或者存量太大，最后的同步阶段就会拉长阻塞窗口。</p>
<p>MySQL 其实做了一些优化（Iterative Apply），在 Execute 阶段末尾会尝试预先回放一部分日志。但为了保证最终一致性，<strong>最后的一小截尾巴，必须在写锁保护下回放</strong>。如果这截尾巴处理不掉，阻塞就不可避免。</p>
</blockquote>
<h4>3.1.5 磁盘空间爆满</h4>
<p>DDL 执行期间的并发写入（Insert/Update/Delete）不会直接修改正在重建的表，而是暂存在 <code>innodb_online_alter_log</code> 中（内存+磁盘临时文件）。该日志的大小受到 <code>innodb_online_alter_log_max_size</code> 参数的硬性限制（默认仅 128MB）。</p>
<p>如果 DDL 耗时很久，或者业务写入量很大，填满了这个 Log。<strong>MySQL 会直接报错并回滚整个 DDL 操作</strong>，之前几个小时的 CPU/IO 资源白费。更严重的是，<strong>触发溢出的那个用户事务会被强制回滚</strong>，导致业务报错。</p>
<h3>3.2 INSTANT</h3>
<p>天下没有免费的午餐。INSTANT 算法虽然写得快，但它把成本转移到了<strong>读</strong>的时候。</p>
<h4>3.2.1 读放大</h4>
<ul>
<li><strong>Copy/Inplace</strong>：数据在物理上已经重写好了。读取时，拿出来的就是完整的行。</li>
<li><strong>Instant</strong>：物理上的行数据可能只有 3 列，但表定义里有 4 列。每次 <code>SELECT</code> 读取该行时，InnoDB 引擎必须在内存中判断并加上缺失行的默认值。这增加了一点点 CPU 的开销（微乎其微，但存在）。</li>
</ul>
<h4>3.2.2 数据腐烂风险</h4>
<p>如果你对一张表连续做了 50 次 <code>Instant Add Column</code>，再删几列。表里的数据行就会变得五花八门：有的行是 2023 年的版本，有的行是 2024 年的版本。</p>
<p>表结构的元数据会变得非常复杂，解析成本变高。</p>
<p><strong>建议</strong>：如果一张表经过了极其频繁的 Instant 变更，在低峰期做一次 <code>OPTIMIZE TABLE</code>（这会强制触发 In-Place Rebuild）来把物理数据规整化，是有好处的。</p>
<h2>4. 什么时候用哪个</h2>
<p>什么时候 INSTANT，什么时候 INPLACE，什么时候 COPY？怎么判断呢？</p>
<p>判断这三者的核心逻辑，依然遵循<strong>第一性原理：看数据的物理存储是否需要改变</strong>。</p>
<p>MySQL 在执行 DDL 时，内部遵循一个<strong>懒惰原则</strong>：能偷懒就偷懒。优先级是： <strong>Instant (最懒/只改元数据) &gt; In-Place (勤快/原地修整) &gt; Copy (笨重/推倒重来)</strong>。</p>
<h3>4.1 INSTANT (首选，快乐路径)</h3>
<p><strong>原理</strong>：只修改 <code>.SDI</code> (元数据) 或 数据字典。不触碰 B+ 树叶子节点的数据。</p>
<p><strong>适用场景</strong>：</p>
<ul>
<li><strong>新增列 (Add Column)</strong>：<ul>
<li>MySQL 8.0.12+ 支持（只能加在最后）。</li>
<li>MySQL 8.0.29+ 支持（任意位置 <code>AFTER column</code>）。</li>
</ul>
</li>
<li><strong>删除列 (Drop Column)</strong>：MySQL 8.0.29+ 支持。</li>
<li><strong>重命名 (Rename)</strong>：表名、列名。</li>
<li><strong>修改默认值 (Set/Drop Default)</strong>。</li>
<li><strong>虚拟列 (Virtual Column)</strong>：添加或删除。</li>
<li><strong>扩展 VARCHAR 长度</strong>：且扩展后字节数 <strong>&lt; 256</strong> (长度前缀保持 1 字节)。</li>
</ul>
<h3>4.2 INPLACE (主流，需谨慎)</h3>
<p><strong>原理</strong>：引擎层&quot;原地&quot;重建 B+ 树或索引树。利用 <strong>Row Log</strong> 记录执行期间的并发写入，最后回放。</p>
<p><strong>适用场景</strong>：</p>
<ul>
<li><strong>添加索引 (Add Index)</strong>：最常见的场景。</li>
<li><strong>删除索引 (Drop Index)</strong>：实际上改元数据即可，极快（但在官方分类中常归为 Inplace/Metadata）。</li>
<li><strong>整理表碎片 (Optimize Table)</strong>：强制触发生效。</li>
<li><strong>修改 Row Format</strong>：如 <code>Compact</code> -&gt; <code>Dynamic</code>。</li>
</ul>
<blockquote>
<p><strong>风险提示</strong>：虽不锁表，但消耗大量 IO 和 CPU，且最后 Apply Log 阶段如果追不上，可能短暂阻塞。<strong>务必避开业务高峰。</strong></p>
</blockquote>
<h3>4.3 COPY (禁区，保底方案)</h3>
<p><strong>原理</strong>：数据的<strong>二进制存储格式</strong>发生了根本变化，引擎无法处理，必须由 Server 层建立新表，一行行读出来清洗、转换、写入。</p>
<p><strong>适用场景</strong>：</p>
<ul>
<li><p><strong>修改字段数据类型</strong>：如 <code>INT</code> -&gt; <code>BIGINT</code>, <code>VARCHAR</code> -&gt; <code>INT</code>。</p>
</li>
<li><p><strong>修改字符集</strong>：如 <code>utf8</code> -&gt; <code>utf8mb4</code>。</p>
</li>
<li><p><strong>删除主键 (Drop Primary Key)</strong>。</p>
</li>
<li><p><strong>扩展 VARCHAR 长度</strong>：当从 &lt; 256 变为 ≥ 256 字节时（字节记录长度发生变化）。</p>
<blockquote>
<p>MySQL 8.0.29+ 的 INSTANT 甚至支持跨越 256 字节的 VARCHAR 扩展（只要不修改数据本身）。</p>
</blockquote>
</li>
<li><p><strong>MySQL 5.6 之前的大部分操作</strong>。</p>
</li>
</ul>
<blockquote>
<p><strong>警告</strong>：<strong>全程锁写入</strong>。线上大表严禁直接执行，必须使用 <code>gh-ost</code> 或 <code>pt-osc</code>。</p>
</blockquote>
<h3>4.4 最佳实践</h3>
<p>不要让 MySQL 帮你选，<strong>显式指定算法</strong>，让潜在的 COPY 暴露出来：</p>
<pre><code class="language-SQL">-- 你的防御性写法
ALTER TABLE your_table
ADD INDEX idx_xxx (xxx),
ALGORITHM=INPLACE,  -- 强制要求原地执行
LOCK=NONE;          -- 强制要求不锁表
</code></pre>
<ul>
<li>如果 MySQL 能够满足（是 Instant 或 Inplace），它就执行。</li>
<li>如果 MySQL 发现这事儿必须 COPY（要锁表），它会<strong>直接报错</strong>。</li>
</ul>
<p>如下所示：</p>
<pre><code class="language-bash">mysql&gt; ALTER TABLE t_online_ddl MODIFY COLUMN val BIGINT, ALGORITHM=INPLACE, LOCK=NONE;

ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported. Reason: Cannot change column type INPLACE. Try ALGORITHM=COPY.
</code></pre>
<h2>5. 其他工具</h2>
<p>原生 Online DDL 还是存在一切痛点，尤其是 INPLACE 和 COPY。所以出现了 <code>pt-online-schema-change (pt-osc)</code> 和 <code>github-online-schema-migration-tool (gh-ost)</code> 这两个工具。</p>
<p>它们的核心思想都是 <strong>影子表（Shadow Table）策略</strong>：建一张新表，同步数据，最后切换。</p>
<p>区别在于：<strong>怎么同步增量数据？</strong></p>
<h3>5.1 pt-osc</h3>
<blockquote>
<p>pt-osc 是 Percona Toolkit 的一部分，是老牌的方案。</p>
</blockquote>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>核心原理</strong></p>
<p>数据库触发器 (Database Triggers)。</p>
</blockquote>
<p>工作流程：</p>
<ol>
<li><strong>创建影子表</strong>：创建一个和原表结构一样的新表 <code>_table_new</code>。</li>
<li><strong>修改结构</strong>：在新表上执行 <code>ALTER TABLE</code>（比如加字段）。</li>
<li><strong>创建触发器 (关键)</strong>：在原表上创建 3 个触发器（<strong>AFTER INSERT, AFTER UPDATE, AFTER DELETE</strong>）。这意味着：每当业务向原表写入一行数据，MySQL 会自动触发一个动作，把这行数据的变更同步写入到新表中。</li>
<li><strong>拷贝存量数据</strong>：工具开始分批次（Chunk）把原表的历史数据 <code>INSERT IGNORE</code> 到新表中。</li>
<li><strong>原子切换</strong>：数据拷贝完后，利用 <code>RENAME TABLE</code> 原子操作，把原表改名，新表上位。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251218000826173.png" alt=""></p>
<p>优点：</p>
<ul>
<li><strong>可靠</strong>：基于数据库内部机制，数据一致性强。</li>
<li><strong>兼容性</strong>：支持所有 Binlog 格式（Statement/Row/Mixed）。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>同步阻塞</strong>：触发器是和业务 SQL <strong>在同一个事务</strong> 里执行的。<ul>
<li>如果你的业务 SQL 执行需 1ms，加上触发器写新表可能变成 2ms。这直接导致<strong>写性能下降</strong>。</li>
<li>如果触发器写新表失败（比如锁等待），你的业务 SQL 也会回滚失败！</li>
</ul>
</li>
<li><strong>元数据锁 (MDL)</strong>：创建和删除触发器的瞬间，需要锁表（MDL 写锁），在高并发下可能导致拥堵。</li>
</ul>
<h3>5.2 gh-ost</h3>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>核心原理</strong></p>
<p>模拟从库 (Binlog Simulation)。</p>
</blockquote>
<p>GitHub 在被 <code>pt-osc</code> 的触发器搞了几次故障后，开发了 <code>gh-ost</code>。它彻底抛弃了触发器。</p>
<p>工作流程：</p>
<ol>
<li><strong>创建影子表</strong>：同上，建新表、改结构。</li>
<li><strong>伪装成从库</strong>：<code>gh-ost</code> 进程把自己伪装成一个 MySQL Slave，连接到 Master（或者真实的 Slave）。它请求 Dump <strong>Binlog</strong> 流。</li>
<li><strong>监听 Binlog</strong>：业务在原表写入数据，产生 Binlog。<code>gh-ost</code> 读取 Binlog，将解析出来的 Binlog 事件（Insert/Update/Delete），转换成 SQL 语句，并在影子表上回放。</li>
<li><strong>拷贝存量数据</strong>：同上，分批拷贝历史数据。</li>
<li><strong>原子切换</strong>：因为 <code>gh-ost</code> 是异步的，所以切换的时候必须有&quot;同步&quot;辅助方案。所以它使用一种特殊的机制（Cut-over）进行表名切换。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251218000842840.png" alt=""></p>
<p>Cut-over 过程：</p>
<ol>
<li><strong>制造阻塞</strong>：<code>gh-ost</code> 会建立一个连接 C1，执行 <code>LOCK TABLES tbl WRITE;</code>。这时候，<strong>所有</strong>业务线程想写这张表，都会被堵塞住（Blocked）。原表被冻结了，不再会有新数据写入（这就消除了追不上的问题）。</li>
<li><strong>发起改名</strong>：<code>gh-ost</code> 建立另一个连接 C2，执行 <code>RENAME TABLE tbl TO tbl_old, ghost_tbl TO tbl;</code>。这条 SQL 也会被 C1 的锁堵塞住，<strong>卡在 MySQL 的执行队列里</strong>。</li>
<li><strong>插队机制</strong>：此时，MySQL 的锁等待队列里可能排着一堆请求。但是！<strong><code>RENAME</code> 等 DDL 操作的优先级高于 <code>INSERT/UPDATE</code> 等 DML 操作。</strong> 所以，虽然大家都在排队，但 MySQL 会把 C2 的 Rename 请求<strong>提到最前面</strong>（仅次于持有锁的 C1）。</li>
<li><strong>瞬间释放</strong>：<code>gh-ost</code> 确认 C2 已经乖乖排在队首后，C1 执行 <code>UNLOCK TABLES;</code>。锁一释放，排在队首的 C2（Rename）瞬间执行。因为 C2 优先级最高，没有任何业务写入能插到 C1 释放和 C2 执行这两个动作中间。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251218000920326.png" alt=""></p>
<p>优点：</p>
<ul>
<li><strong>解耦</strong>：<code>gh-ost</code> 是在<strong>应用层</strong>（或者说外部工具层）回放数据。它和业务的写操作完全<strong>异步</strong>。</li>
<li><strong>轻量</strong>：业务 SQL 写入原表后就结束了，不需要等新表写入。<strong>不影响业务响应时间</strong>。</li>
<li><strong>可暂停/限速</strong>：这是 <code>gh-ost</code> 的杀手锏。如果发现数据库负载高了，<code>gh-ost</code> 可以瞬间<strong>暂停</strong>读取 Binlog，甚至在拷贝数据的过程中随时停下来睡觉。这是触发器做不到的。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>依赖 Row 格式</strong>：必须开启 Binlog <code>row</code> 模式（现代 MySQL 标配）。</li>
<li><strong>复杂性</strong>：架构比 <code>pt-osc</code> 复杂，需要处理 Binlog 解析。</li>
</ul>
<h2>6. 总结</h2>
<p>回到<strong>第一性原理</strong>：Online DDL 的本质，是在&quot;修改表结构&quot;与&quot;保持业务可用&quot;之间寻找平衡点。</p>
<table>
<thead>
<tr>
<th>算法</th>
<th>原理</th>
<th>代价</th>
<th>场景</th>
</tr>
</thead>
<tbody><tr>
<td><strong>COPY</strong></td>
<td>Server 层新建表 → 复制数据 → 删旧表</td>
<td>极高，全程锁表</td>
<td>数据类型变更、字符集修改</td>
</tr>
<tr>
<td><strong>INPLACE</strong></td>
<td>引擎层原地重建 + Row Log 增量</td>
<td>中等，不锁表但耗 IO</td>
<td>加索引、优化表碎片</td>
</tr>
<tr>
<td><strong>INSTANT</strong></td>
<td>只改元数据，不碰数据</td>
<td>几乎为零，毫秒级</td>
<td>加/删列、改默认值</td>
</tr>
</tbody></table>
<p>无论哪种算法，<strong>MDL 锁</strong>始终是最隐蔽的杀手。一个长事务就能让整个 DDL 链路瘫痪。</p>
<ul>
<li><strong>INPLACE</strong>：主从延迟、Buffer Pool 污染、Apply Log 阻塞、磁盘撑爆。</li>
<li><strong>INSTANT</strong>：读放大、数据腐烂（频繁变更后需定期 OPTIMIZE）。</li>
</ul>
<p>工具选型：</p>
<table>
<thead>
<tr>
<th>场景</th>
<th>推荐方案</th>
</tr>
</thead>
<tbody><tr>
<td>小表 + 低峰期</td>
<td>原生 <code>ALTER TABLE ... ALGORITHM=INPLACE, LOCK=NONE</code></td>
</tr>
<tr>
<td>大表 + 主从架构</td>
<td><code>gh-ost</code>（无触发器、可暂停、可限速）</td>
</tr>
<tr>
<td>兼容性要求高</td>
<td><code>pt-osc</code>（支持所有 Binlog 格式）</td>
</tr>
</tbody></table>
<p>生产环境黄金法则：</p>
<ol>
<li><strong>永远显式指定算法</strong>：<code>ALGORITHM=INPLACE, LOCK=NONE</code>，让潜在的 COPY 暴露出来。</li>
<li><strong>执行前检查长事务</strong>：<code>SELECT * FROM information_schema.processlist WHERE TIME &gt; 10;</code></li>
<li><strong>避开高峰期</strong>：再 Online 的 DDL，也会消耗资源。</li>
<li><strong>大表用工具</strong>：超过 100 万行，优先考虑 <code>gh-ost</code>。</li>
<li><strong>监控主从延迟</strong>：<code>Seconds_Behind_Master</code> 是你的生命线。</li>
</ol>
<hr>
<p><strong>一句话总结</strong>：</p>
<blockquote>
<p>Online DDL 不是银弹。它只是把&quot;长时间锁表&quot;变成了&quot;短时间锁表 + 长时间后台作业&quot;。理解它的边界，才能用好它。</p>
</blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>MySQL 底层原理丨锁</title>
      <link>https://hedon.top/blog/mysql-lock/</link>
      <guid isPermaLink="true">https://hedon.top/blog/mysql-lock/</guid>
      <pubDate>Wed, 17 Dec 2025 20:21:00 GMT</pubDate>
      <description>本文将从底层原理出发，全面解析 MySQL 的锁机制，涵盖锁的结构、对象、类型、粒度及其背后的实现逻辑与优化原则，帮助读者构建系统性认知。</description>
      <category>MySQL</category><category>锁</category><category>数据库</category>
      <content:encoded><![CDATA[<h2>1. 基础认知</h2>
<p>要从根本上理解 MySQL 的锁，必须理解它在 <strong>内存（RAM）</strong> 和 <strong>数据结构（B+Tree）</strong> 层面是如何运作的。</p>
<p>可以将 MySQL（主要是 InnoDB 引擎）的锁机制拆解为五个层次：</p>
<ol>
<li>锁的结构：锁在内存里面到底长什么样？（打破&quot;锁是数据行上的标记&quot;这一误区）</li>
<li>锁的对象：锁是加在什么对象上？（打破&quot;锁行&quot;的字面理解）</li>
<li>锁的类型：读写冲突怎么解决？</li>
<li>锁的粒度：如何提高并发效率？（意向锁的由来）</li>
<li>锁的算法：如何解决幻读？（Gap Lock 的由来）</li>
</ol>
<h3>1.1 锁的结构：锁在内存里面到底长什么样？</h3>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>核心认知：锁不是数据行（Row）上的一个字段，而是内存中独立的数据结构。</p>
</blockquote>
<p>很多初学者认为锁是磁盘上每一行数据里的一个 <code>is_locked</code> 标记。这是错的。如果是这样，锁事务回滚时还需要去修改磁盘数据，IO 开销太大。</p>
<p>在 InnoDB 内部，锁通过 <strong><a href="https://dev.mysql.com/doc/dev/mysql-server/8.4.7/PAGE_INNODB_LOCK_SYS.html">Lock System (lock_sys)</a></strong> 进行管理，是一个巨大的 <strong>Hash Table</strong>。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251217203647272.png" alt=""></p>
<p>InnoDB 不会为每一行数据创建一个锁对象（Lock Struct），那是巨大的内存浪费。它是按**页（Page）**来管理的。</p>
<pre><code class="language-c++">struct lock_t {
    trx_t* trx;       // 属于哪个事务
    ulint  space_id;  // 表空间 ID
    ulint  page_no;   // 页号
    uint32 type_mode; // 锁类型 (Shared/Exclusive) | 锁模式 (Rec/Gap/Next-Key)
    // ---------------------------------------------
    // 重点来了：这里没有 Row ID，而是一个 Bitmap
    // ---------------------------------------------
    uint8  bitmap[];  // 位图！映射这一页上的物理记录
};
</code></pre>
<ul>
<li>一个 <code>lock_t</code> 结构体对应&quot;一个事务&quot;在&quot;一个数据页&quot;上的锁信息。</li>
<li>在这个结构体后面，跟着一个 <strong>Bitmap（位图）</strong>。</li>
<li>如果一个页面有 100 条记录，位图里就有对应的一堆 Bit。如果你执行 <code>UPDATE... WHERE id &lt; 1000</code>，锁住了这页的 50 行记录，InnoDB 不需要创建 50 个锁对象，只需要在一个锁对象的位图中把这 50 个 bit 置为 1。这就是为什么 MySQL 宣称它支持行级锁且开销极小，即便你锁住了 100 万行，只要它们集中在几千个 Page 里，内存消耗依然很小。</li>
</ul>
<h3>1.2 锁的对象：锁是加在什么对象上？</h3>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--danger" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p>核心认知：InnoDB 的行锁，永远是加在索引（Index）上的。</p>
</blockquote>
<p>这是理解所有复杂锁问题的总钥匙。</p>
<ul>
<li><strong>如果你通过主键更新</strong>：锁加在<strong>聚簇索引</strong>（Clustered Index）的记录上。</li>
<li><strong>如果你通过二级索引更新</strong>：锁<strong>先</strong>加在二级索引（Secondary Index）的记录上，<strong>然后</strong>回表，去锁聚簇索引上的记录。</li>
<li><strong>如果你不走索引</strong>：因为 MySQL 必须扫描全表才能找到你要更新的行。它会扫描一条、锁一条（在聚簇索引上）。虽然在 RC 隔离级别下 MySQL 有优化（不匹配的行会释放锁），但在 RR 级别下，它会把<strong>所有扫描过的记录</strong>以及<strong>记录之间的间隙</strong>全锁上。这在效果上等同于<strong>锁表</strong>。</li>
</ul>
<h3>1.3 锁的类型：读写冲突怎么解决？</h3>
<p>MySQL InnoDB 有 7 种锁：</p>
<ol>
<li>共享锁（S 锁）和排他锁（X 锁）</li>
<li>意向锁（Intension Locks）</li>
<li>记录锁（Record Locks）</li>
<li>间隙锁（Gap Locks）</li>
<li>临键锁（Next-Key Locks）</li>
<li>插入意向锁（Insert Intension Locks）</li>
<li>自增锁（Auto-Inc Locks）</li>
</ol>
<blockquote>
<p>MDL 是 MySQL Server 层面的锁。</p>
</blockquote>
<p>可以从 2 个维度进行理解：</p>
<ul>
<li>锁的粒度：锁表、锁行、锁区间</li>
<li>锁的模式：共享、排他、意向</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251217211914464.png" alt=""></p>
<h3>1.4 锁的粒度：如何提高并发效率？</h3>
<p>如果表里有 100 万行数据，事务 A 锁住了其中第 999 行（行锁）。此时事务 B 想申请整张表的写锁（表锁），B 怎么知道能不能加锁？</p>
<ul>
<li><strong>笨办法</strong>：B 遍历这 100 万行，看有没有人加了行锁。-&gt; <strong>效率极低，不可接受</strong>。</li>
<li><strong>第一性原理优化</strong>：在层级结构中，下层有锁，上层必须有标记。</li>
</ul>
<p><strong>意向锁（Intention Lock, IS/IX）</strong> 这实际上是表级锁，它的作用只是**&quot;信号灯&quot;**：</p>
<ul>
<li><strong>规则</strong>：事务 A 在给第 999 行加 <strong>行级排他锁 (X)</strong> 之前，必须先给这张表挂一个 <strong>意向排他锁 (IX)</strong>。</li>
<li><strong>效果</strong>：事务 B 来看一眼表门头，发现有 <strong>IX</strong> 标记，就知道表里有人在干活，于是 B 阻塞等待。B 不需要遍历全表，<strong>效率提升为 O(1)</strong>。</li>
</ul>
<h3>1.5 锁的算法：如何解决幻读？</h3>
<p>为了防止幻读（Phantom Read），InnoDB 发明了复杂的锁算法，我们需要把数据想象成 B+ 树叶子节点上的一条有序链表。</p>
<p>假设表中现在的 ID 有：<code>10, 20, 30</code>。</p>
<p><strong>Record Lock（记录锁）：</strong></p>
<ul>
<li>定义：仅仅锁住索引记录本身。</li>
<li>场景：精准命中。例如 <code>SELECT * FROM t WHERE id = 10 FOR UPDATE;</code>。</li>
<li>内存表现：Bitmap 中对应 <code>id=10</code> 的那个 bit 被置位。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251217212218168.png" alt=""></p>
<p><strong>Gap Lock（间隙锁）：</strong></p>
<ul>
<li>定义：锁住两个索引记录之间的空隙，<strong>不包含记录本身</strong>。</li>
<li>目的：纯粹是为了通过禁止插入（insert）来防止幻读。</li>
<li>场景：<code>SELECT * FROM t WHERE id = 15 FOR UPDATE;</code>。</li>
<li>范围：例如 <code>(10, 20)</code>。这意味着你不能插入 <code>11,12... 19</code>。</li>
<li>重要特性：Gap Lock 之间是兼容的！事务 A 可以对 <code>(10, 20)</code> 加 Gap Lock，事务 B 也可以对 <code>(10, 20)</code> 加 Gap Lock。为什么？因为它们的目的都是阻止别人插入，不冲突。真正冲突的是 <strong>Insert Intention Lock（插入意向锁）</strong>。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251217212309192.png" alt=""></p>
<p><strong>Next Key Lock（临键锁）：</strong></p>
<ul>
<li>定义：<strong>Record Lock + Gap Lock</strong>。即锁住记录本身，也锁住它前面的空隙。</li>
<li>默认行为：在 RR（Repeatable Read）隔离级别下，InnoDB 对于范围查询或非唯一索引的等值查询，默认加 Next-Key Lock。</li>
<li>场景：<code>SELECT * FROM t WHERE id &gt; 10 FOR UPDATE;</code></li>
<li>左开又闭：在 <code>10, 20, 30</code> 的例子中，扫描到 20 时，加 Next-Key Lock <code>(10, 20]</code>；扫描到 30 时，加 Next-Key Lock <code>(20, 30]</code>。扫描完 30 后，指针继续向后，发现没有真实记录了，碰到了 B+ 树页面的 <strong>Supremum 伪记录</strong>（代表无穷大）。加 Next-Key Lock <code>(30, ∞)</code>。综合起来是锁住了 <code>(10, +∞)</code>。它不仅仅是锁间隙，也会锁住记录。这意味着：你不能修改 20，也不能在 10 到 20 之间插入数据 。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251217212317333.png" alt=""></p>
<p><strong>Insert Intension Lock（插入意向锁）：</strong></p>
<ul>
<li>定义：这其实是一种特殊的 Gap Lock，但在代码里它叫&quot;插入意向&quot;。</li>
<li>触发：当执行 <code>INSERT</code> 时，如果目标位置已经被别的事务加了 Gap Lock，插入操作就会进入等待，并在这个间隙上生成一个&quot;插入意向锁&quot;。</li>
<li>死锁之源：很多死锁都是因为两个事务持有 Gap Lock，然后又都想在这个 Gap 里插入数据（申请插入意向锁），结果互相等待。</li>
</ul>
<p><strong>Metadata Lock（元数据锁）：</strong></p>
<ul>
<li>作用：保护表结构。当你执行 <code>SELECT</code> 或 <code>UPDATE</code> 时，自动加 MDL 读锁。当你执行 <code>ALTER TABLE</code> 时，需要 MDL 写锁。</li>
<li>阻塞逻辑：读写互斥。这意味着，只要有一个长事务（哪怕只是 <code>SELECT</code>）没提交，持有 MDL 读锁，后续的 <code>ALTER TABLE</code> 就会被卡住，而 <code>ALTER TABLE</code> 卡住后，后面所有的 <code>SELECT/UPDATE</code> 也会被卡住（形成锁队列堆积）。这就是著名的&quot;MDL 暴击&quot;。</li>
</ul>
<h2>2. 分析模板</h2>
<p>当一个事务执行 SQL 时，InnoDB 锁引擎会遵循以下判定树：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/%E9%94%81%E5%88%86%E6%9E%90%E5%88%A4%E5%AE%9A%E6%A0%91.png" alt=""></p>
<p>针对 RR 隔离级别，我们要秉持三大分析原则：</p>
<ol>
<li><p><strong>原则一：</strong> 加锁的基本单位是 <strong>Next-Key Lock</strong>（左开右闭）。</p>
</li>
<li><p><strong>原则二：</strong> 查找过程中，访问到的对象才会加锁。</p>
<ul>
<li><p>如果 SQL 走了二级索引，二级索引会被强力锁定（Next-Key + Gap）。</p>
</li>
<li><p>回表时，主键索引只加 <strong>Record Lock</strong>。</p>
</li>
</ul>
</li>
<li><p><strong>原则三（优化）：</strong> 索引上的等值查询：</p>
<ul>
<li><p><strong>唯一索引</strong>：Next-Key Lock 退化为 Record Lock（因为不用担心那个值后面插进一样的）。</p>
</li>
<li><p><strong>非唯一索引</strong>：向右扫描到第一个不符合条件的记录，该记录加 <strong>Gap Lock</strong>（不锁记录本身，只锁它前面的空隙）。</p>
</li>
</ul>
</li>
</ol>
<h2>3. 例题分析</h2>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--info" aria-hidden="true"></span></p>
<p><strong>提示</strong></p>
<p><strong>表结构：</strong></p>
<pre><code class="language-sql">CREATE TABLE students (
  id INT PRIMARY KEY,       -- 主键
  score INT,                -- 非唯一索引
  name VARCHAR(20),         -- 无索引
  INDEX idx_score (score)
) ENGINE=InnoDB;
</code></pre>
<p><strong>现有数据：</strong></p>
<ul>
<li><code>id=1, score=10, name=&#39;A&#39;</code></li>
<li><code>id=5, score=20, name=&#39;B&#39;</code></li>
<li><code>id=10, score=30, name=&#39;C&#39;</code></li>
</ul>
<p><strong>当前事务执行：</strong></p>
<pre><code class="language-sql">-- 隔离级别：RR
DELETE FROM students WHERE score = 20;
</code></pre>
<p><strong>请分析以下三个核心问题：</strong></p>
<ol>
<li><strong>二级索引锁：</strong> 在 <code>idx_score</code> 这棵 B+ 树上，锁住了哪些范围？（请用准确的区间表示，如 <code>(10, 20]</code>）</li>
<li><strong>聚簇索引锁（回表）：</strong> 在 <code>PRIMARY KEY</code> (id) 这棵 B+ 树上，锁住了哪些 ID？有没有加 Gap Lock？</li>
<li><strong>并发测试：</strong> 此时，另一个事务想执行 <code>INSERT INTO students (id, score, name) VALUES (2, 15, &#39;D&#39;);</code>，能否成功？为什么？</li>
</ol>
</blockquote>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251217214204837.png" style="zoom:33%;" />

<p><strong>问题一：<code>idx_score</code> 到底锁了什么？</strong></p>
<blockquote>
<p><strong>数据 (id, score):</strong> <code>(1, 10)</code>, <code>(5, 20)</code>, <code>(10, 30)</code></p>
<p><strong>SQL:</strong> <code>DELETE FROM students WHERE score = 20;</code> (隔离级别 RR)</p>
</blockquote>
<p>InnoDB 的逻辑是：我要锁住 <code>score=20</code>，并且防止别人在 <code>score=20</code> 的前后插入数据。</p>
<ul>
<li><strong>命中记录：</strong> 首先找到 <code>(5, 20)</code> 这条记录。加上 <strong>Next-Key Lock</strong>。<ul>
<li><strong>范围一：</strong> <code>(10, 20]</code>。</li>
<li><strong>注意：</strong> 这里锁的是 <code>score</code> 的范围，配合主键 <code>id</code>。确切地说，它锁的是 <code>(score=10, id=1)</code> 到 <code>(score=20, id=5)</code> 之间的间隙，加上 <code>(20, 5)</code> 这条记录本身。</li>
</ul>
</li>
<li><strong>向右探测：</strong><ul>
<li>为了防止幻读（比如别人插入一个 <code>score=20</code> 的新行），InnoDB <strong>必须</strong>继续向右扫描，直到遇到<strong>第一条不满足条件</strong>的记录为止。</li>
<li>它向右看到了 <code>(10, 30)</code>。</li>
<li>因为 <code>30!= 20</code>，扫描结束。但是，为了封锁 <code>20</code> 之后到 <code>30</code> 之前的空隙，它必须在 <code>(10, 30)</code> 这条记录上加一个 <strong>Gap Lock</strong>。</li>
<li><strong>范围二：</strong> <code>(20, 30)</code>。</li>
</ul>
</li>
</ul>
<p><strong>结论：</strong> 在 <code>idx_score</code> 索引上，实际锁住的范围是 <strong>(10, 30)</strong>。</p>
<ul>
<li><code>score</code> 在 <code>(10, 20]</code> 的不能插。</li>
<li><code>score</code> 在 <code>(20, 30)</code> 的也不能插。</li>
</ul>
<hr>
<p><strong>问题二：聚簇索引 (<code>PRIMARY KEY</code>) 锁了什么？</strong></p>
<p>二级索引回表锁主键时，<strong>只加 Record Lock，不加 Gap Lock</strong>。</p>
<p><strong>原因：</strong> Gap Lock 是为了防止&quot;在范围内插入&quot;。二级索引上的 Gap Lock 已经足够阻止 <code>score=20</code> 的插入了（因为插入必须维护所有索引）。主键上再加 Gap Lock 是多余的，且会极大地降低并发（会误伤 <code>id</code> 邻近但 <code>score</code> 无关的行）。</p>
<p>所以主键 <code>id=5</code> 上只有 Record Lock (X 锁)。</p>
<hr>
<p><strong>问题三：并发测试结果分析</strong></p>
<blockquote>
<p>SQL: <code>INSERT INTO students (id, score, name) VALUES (2, 15, &#39;D&#39;);</code></p>
</blockquote>
<ul>
<li>我们要插入 <code>score=15</code>。</li>
<li>检查 <code>idx_score</code> 锁范围：<code>15</code> 落在 <code>(10, 20]</code> 这个区间内。</li>
<li><strong>结果：</strong> 被 <code>idx_score</code> 的 <strong>Next-Key Lock</strong> 阻塞。</li>
</ul>
<h2>参考</h2>
<ul>
<li><a href="https://hevodata.com/learn/mysql-locks/">MySQL Locks: A Comprehensive Guide 101</a></li>
<li><a href="https://kernelmaker.github.io/MySQL-implicit-locks">Deep Dive into MySQL - Implicit Locks</a></li>
<li><a href="https://cloud.tencent.com/developer/article/1799236">深入浅出 MySQL 8.0 lock_sys 锁相关优化</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>盘点 Redis 各种数据类型</title>
      <link>https://hedon.top/blog/redis-datatype/</link>
      <guid isPermaLink="true">https://hedon.top/blog/redis-datatype/</guid>
      <pubDate>Tue, 09 Dec 2025 15:06:00 GMT</pubDate>
      <description>本文系统梳理 Redis 十一种数据类型，并结合底层实现原理、关键优化细节，深入解析其高性能背后的设计智慧与技术取舍。</description>
      <category>Redis</category>
      <content:encoded><![CDATA[<p>理解 Redis 各类数据结构的精妙之处，离不开两个底层设计原则：</p>
<ol>
<li>极致节省内存：能省就省，既压缩结构本身，也采用动态或共享等机制确保每一位（bit）都物尽其用。</li>
<li>最大化 CPU 缓存友好：优先用一块连续小内存管理数据，降低指针跳转和碎片化，以提升访问速度和 CPU 缓存命中率。</li>
</ol>
<p>这两点支撑着 Redis 各类数据类型的演进路线与编码选择。</p>
<blockquote>
<p>版本声明：<a href="https://github.com/redis/redis/tree/8.4.0">Redis8.4.0</a></p>
</blockquote>
<h2>0. redisObject</h2>
<p>在深入具体类型之前，我们先对 Redis 对象头 <code>redisObject</code> 做一个简单的了解，因为 Redis 中的所有 Key 和 Value，在底层都是一个 <code>redisObject</code> 结构体。这是 Redis 多态的基石。<code>redisObject</code> 的定义位于 <a href="https://github.com/redis/redis/blob/8.4.0/src/server.h#L1057">src/server.h</a></p>
<pre><code class="language-c">#define LRU_BITS 24
#define OBJ_REFCOUNT_BITS 30

struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    unsigned iskvobj : 1;   /* 1 if this struct serves as a kvobj base */
    unsigned expirable : 1; /* 1 if this key has expiration time attached.
                             * If set, then this object is of type kvobj */
    unsigned refcount : OBJ_REFCOUNT_BITS;
    void *ptr;
};
</code></pre>
<ul>
<li><code>type</code>: 逻辑数据类型（String、List、Hash、Set、ZSet）。</li>
<li><code>encoding</code>: 物理编码格式（int、row、listpack、skiplist）。</li>
<li><code>lru</code>: 淘汰策略数据，如果是 LRU 模式，则存储最后一次访问的时间戳（秒级）。如果是 LFU 模式，则存储方法时间和访问频率。当内存达到 <code>maxmemory</code> 时，Redis 根据此字段决定淘汰哪个 Key。</li>
<li><code>iskvobj</code>: Redis 8.0 的新特性。<code>1</code> 表示该对象是一个 <code>kvobj</code> （键值对象）的一部分，意味着 Key、Value 和元数据在内存中是紧凑/内嵌存储的。这样可以消除&quot;键值分离&quot;带来的指针跳转，提升 CPU 缓存命中率。</li>
<li><code>expirable</code>: Redis8.0 的新特性。<code>1</code> 表示该 Key 设置了过期时间。这提供了一条快速通道，访问时若为 0，直接跳过查询 <code>expires</code> 哈希表的步骤，节省昂贵的哈希查找开销。</li>
<li><code>refcount</code>: 记录有多少个指针引用了该对象。用于内存管理和对象共享（主要用于小整数共享）。当计数降为 0 时，回收内存。这里使用了位域压缩以节省空间。</li>
<li><code>ptr</code>: 指向实际存储数据的内存地址（如 SDS、Dict、Quicklist 的地址）。特别地，如果 encoding 是 <code>INT</code>，这里直接存储整数值本身，不再是指针。</li>
</ul>
<p>上述多个字段，最重要的就是 <code>type</code> 和 <code>encoding</code>。总的来说，<code>type</code> 决定了它对外声称是什么（比如 Hash），而 <code>encoding</code> 决定了它在内存里到底怎么存（比如是压缩列表还是哈希表）。<strong>优化 Redis 内存的核心，就是想办法让它保持在轻量级的 <code>encoding</code> 上。</strong></p>
<h2>1. String</h2>
<p>String 是 Redis 最基本的数据类型，也是所有 Key 的类型。</p>
<h3>1.1 核心操作</h3>
<pre><code class="language-bash"># 基础操作
SET key value                 # 设置键值对
GET key                       # 获取值
DEL key                       # 删除键
EXISTS key                    # 检查键是否存在

# 批量操作
MSET key1 value1 key2 value2  # 批量设置
MGET key1 key2 key3           # 批量获取

# 数值操作
INCR key                      # 原子递增 +1
DECR key                      # 原子递减 -1
INCRBY key increment          # 按指定值递增
DECRBY key decrement          # 按指定值递减

# 字符串操作
APPEND key value              # 追加字符串
STRLEN key                    # 获取字符串长度
GETRANGE key start end        # 获取子字符串（闭区间）
SETRANGE key offset value     # 从指定偏移量设置字符串

# 过期时间
SETEX key seconds value       # 设置带过期时间的键
TTL key                       # 查看剩余生存时间
EXPIRE key seconds            # 为已有键设置过期时间
</code></pre>
<p><strong>常见使用场景：</strong></p>
<ul>
<li><strong>缓存层</strong>：存储 JSON 序列化后的对象，如用户信息、商品详情</li>
<li><strong>计数器</strong>：文章阅读数、点赞数、库存数量等原子计数场景</li>
<li><strong>分布式锁</strong>：<code>SET key value NX EX 30</code> 实现简单的分布式锁</li>
<li><strong>限流器</strong>：使用 <code>INCR</code> 和 <code>EXPIRE</code> 实现滑动窗口限流</li>
<li><strong>会话管理</strong>：存储用户 session 信息，配合过期时间自动清理</li>
</ul>
<h3>1.2 底层原理</h3>
<p>Redis 没有直接使用 C 语言的字符串 <code>char*</code>，而是自己封装了 SDS（Simple Dynamic String），而且还进一步分成了 <code>sdshdr5</code>、<code>sdshdr8</code>、<code>sdshdr16</code>、<code>sdshdr32</code> 和 <code>sdshdr64</code>。其核心目的是根据<strong>字符串的长度</strong>，使用不同大小的头部，从而进一步压缩内存的使用。</p>
<p>SDS 的定义位于 <a href="https://github.com/redis/redis/blob/8.4.0/src/sds.h">src/sds.h</a>。</p>
<pre><code class="language-c">/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
</code></pre>
<p>我们重点关注 3 个字段：</p>
<ul>
<li><code>len</code>: 已用长度。</li>
<li><code>alloc</code>: 分片的总内存大小（不包括头部和末尾的 <code>\0</code>）。</li>
<li><code>buf[]</code>: 实际存储字符数据的柔性数组。</li>
</ul>
<p>即便都是 String，Redis 为了省内存，也使用了 3 种不同的编码格式：</p>
<table>
<thead>
<tr>
<th><strong>编码类型</strong></th>
<th><strong>场景</strong></th>
<th><strong>特点</strong></th>
<th><strong>优缺点</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>int</strong></td>
<td>纯数字且在 long 范围内</td>
<td><code>ptr</code> 指针直接存数值，不需要 SDS。</td>
<td>最省内存，无额外指针开销。</td>
</tr>
<tr>
<td><strong>embstr</strong></td>
<td>字符串 ≤ 44 字节</td>
<td><code>redisObject</code> 和 <code>SDS</code> 连续分配，只调用 1 次 malloc。</td>
<td><strong>内存连续</strong>，缓存亲和性好；但只读，修改会转 raw。</td>
</tr>
<tr>
<td><strong>raw</strong></td>
<td>字符串 &gt; 44 字节</td>
<td><code>redisObject</code> 和 <code>SDS</code> 分开分配，调用 2 次 malloc。</td>
<td>适合长字符串，修改灵活。</td>
</tr>
</tbody></table>
<blockquote>
<p>[!note]</p>
<p><strong>💡 44 字节怎么来的？</strong> <code>redisObject</code> (16B) + <code>SDS</code> 头 (3B) + <code>\0</code> (1B) = 20B。 内存分配器（如 jemalloc）通常分配 64B 的块。 64B - 20B = 44B。刚好填满一个内存块，不浪费。</p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251209161405085.png" alt=""></p>
<h2>2. List</h2>
<p>List 在 Redis 中逻辑上是一个 <strong>双向链表</strong>。这意味着：</p>
<ul>
<li><strong>头尾操作极快</strong>：<code>LPUSH</code>/<code>RPOP</code> 是 <strong>O(1)</strong>。</li>
<li><strong>随机访问极慢</strong>：<code>LINDEX</code> 是 <strong>O(N)</strong>。（千万别把它当数组用！）</li>
</ul>
<h3>2.1 核心操作</h3>
<pre><code class="language-bash"># 头部操作
LPUSH key value1 value2       # 从左侧头部推入元素（栈：先进后出）
RPUSH key value1 value2       # 从右侧尾部推入元素（队列：先进先出）
LPOP key                      # 从左侧头部弹出元素
RPOP key                      # 从右侧尾部弹出元素
LPOP key count                # 批量从左侧弹出多个元素

# 阻塞操作（重要）
BLPOP key1 key2 timeout       # 阻塞式左弹出，用于消息队列
BRPOP key1 key2 timeout       # 阻塞式右弹出，用于消息队列
BLMOVE source destination timeout LEFT|RIGHT LEFT|RIGHT  # 阻塞式移动元素

# 查询操作
LLEN key                      # 获取列表长度
LRANGE key start end          # 获取指定范围内的元素（支持负数索引）
LINDEX key index              # 获取指定索引的元素
LPOS key value [COUNT count]  # 查找元素位置

# 修改操作
LSET key index value          # 设置指定索引的值
LINSERT key BEFORE|AFTER pivot value  # 在指定元素前/后插入新元素
LTRIM key start end           # 修剪列表，只保留指定范围内的元素

# 移动操作
RPOPLPUSH source destination  # 从 source 右弹出，destination 左推入
LMOVE source destination LEFT|RIGHT LEFT|RIGHT  # 灵活的元素移动
</code></pre>
<p><strong>常见使用场景：</strong></p>
<ul>
<li><strong>消息队列</strong>：<code>LPUSH/BRPOP</code> 实现简单的生产者-消费者模式，<code>BLPOP</code> 提供阻塞式消费</li>
<li><strong>最新动态</strong>：用户动态、新闻列表使用 <code>LPUSH/LRANGE 0 10</code> 获取最新 10 条</li>
<li><strong>任务队列</strong>：异步任务处理，配合 <code>RPOPLPUSH</code> 实现可靠队列（失败重试）</li>
<li><strong>数据分页</strong>：存储分页数据，使用 <code>LRANGE</code> 实现高效分页查询</li>
<li><strong>排行榜</strong>：结合 <code>LPUSH/LTRIM</code> 维护固定大小的排行榜或热榜</li>
<li><strong>限流队列</strong>：实现滑动窗口限流，使用 <code>LLEN</code> 监控队列长度</li>
</ul>
<h3>2.2 底层原理</h3>
<pre><code class="language-c">typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

typedef struct listIter {
    listNode *next;
    int direction;
} listIter;

typedef struct list {
    listNode *head;
    listNode *tail;
    void *(*dup)(void *ptr);
    void (*free)(void *ptr);
    int (*match)(void *ptr, void *key);
    unsigned long len;
} list;
</code></pre>
<p>Redis List 的底层实现经历了三次大的变革：</p>
<table>
<thead>
<tr>
<th><strong>Redis 版本</strong></th>
<th><strong>底层实现</strong></th>
<th><strong>结构描述</strong></th>
<th><strong>优缺点</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>v3.2 之前</strong></td>
<td><strong>LinkedList</strong> (双向链表)<br><strong>Ziplist</strong> (压缩列表)</td>
<td>元素少用 Ziplist，多用 LinkedList。</td>
<td><strong>LinkedList</strong>：指针占用内存巨大，内存碎片多，Cache 亲和性差。 <br><strong>Ziplist</strong>：省内存，但更新效率低。</td>
</tr>
<tr>
<td><strong>v3.2 - v6.2</strong></td>
<td><strong>Quicklist</strong> (快速列表)</td>
<td>链表的节点是压缩列表。</td>
<td>集大成者。既保留了链表的伸缩性，又利用了压缩列表的省内存特性。</td>
</tr>
<tr>
<td><strong>v7.0+</strong></td>
<td><strong>Quicklist</strong> + <strong>Listpack</strong></td>
<td>将 Quicklist 内部的 Ziplist 替换为 Listpack。</td>
<td>解决了 Ziplist 的级联更新问题。</td>
</tr>
</tbody></table>
<h4>2.2.1 LinkedList</h4>
<p>我们先来看 <code>LinkedList</code>，这是最直觉的实现：</p>
<pre><code>          +------+     +------+     +------+
... &lt;---- | prev | &lt;-&gt; | prev | &lt;-&gt; | prev | ----&gt; ...
          | data |     | data |     | data |
... &lt;---- | next | &lt;-&gt; | next | &lt;-&gt; | next | ----&gt; ...
          +------+     +------+     +------+
</code></pre>
<p>优点：完美的 O(1) 头尾操作</p>
<p>缺点：</p>
<ul>
<li><strong>高昂的内存开销</strong>：<code>prev</code> 和 <code>next</code> 指针占据了大量内存，如果 <code>data</code> 不够大，那性价比就很低了。</li>
<li><strong>糟糕的 CPU 缓存局部性</strong>：链表的节点在内存中是<strong>离散</strong>分布的。当 CPU 遍历链表时，指针跳转的行为极易导致 <strong>CPU Cache Miss</strong>。</li>
</ul>
<h4>2.2.2 Ziplist</h4>
<p>为了克服 <code>linkedlist</code> 的双重代价，Redis 的设计者们创造了一种极其紧凑的数据结构：<strong>压缩列表 (ziplist)</strong>。</p>
<p><code>ziplist</code> 的核心思想是，用一块<strong>连续的、完整的内存块</strong>来存储所有元素，从而彻底消除指针开销，并最大化利用 CPU 缓存。</p>
<pre><code>&lt;zlbytes&gt; &lt;zltail&gt; &lt;zllen&gt; &lt;entry_1&gt; &lt;entry_2&gt; ... &lt;entry_N&gt; &lt;zlend&gt;
</code></pre>
<ul>
<li><code>&lt;zlbytes&gt;</code>: 整个 <code>ziplist</code> 占用的总字节数。</li>
<li><code>&lt;zltail&gt;</code>: 到最后一个 entry 的偏移量，用于快速定位到表尾。</li>
<li><code>&lt;zllen&gt;</code>: entry 的数量。</li>
<li><code>&lt;entry&gt;</code>: 真正的列表元素，每个 entry 也是变长的。</li>
<li><code>&lt;zlend&gt;</code>: 特殊的结束标记 <code>0xFF</code>。</li>
</ul>
<p><code>ziplist</code> 的精髓在于 <code>entry</code> 的设计。每个 entry 的头部会记录<strong>前一个 entry</strong> 的长度 (<code>prev_len</code>)，这使得 <code>ziplist</code> 可以从后向前遍历。</p>
<p>优点：内存占用小且对 CPU 缓存友好。</p>
<p>缺点：级联更新。因为 <code>entry</code> 包含了前面节点的信息（<code>prev_len</code>），所以前面的节点发生变化时，很容易造成后面节点的级联更新。</p>
<h4>2.2.3 Quicklist</h4>
<p>既然 <code>linkedlist</code> 和 <code>ziplist</code> 各有优劣，能否将它们结合起来，取其精华，去其糟粕？<strong>快速列表 (quicklist)</strong> 应运而生，并从 Redis 3.2 开始成为 List 的默认实现。</p>
<p><code>quicklist</code> 的本质，就是一个由 <code>ziplist</code>（或后来的 <code>listpack</code>）节点组成的<strong>双向链表</strong>。</p>
<pre><code>+----------------+     +----------------+     +----------------+
| quicklistNode  | &lt;-&gt; | quicklistNode  | &lt;-&gt; | quicklistNode  |
| (ziplist/pack) |     | (ziplist/pack) |     | (ziplist/pack) |
+----------------+     +----------------+     +----------------+
         ^                    ^                      ^
         |                    |                      |
   [ e1, e2, e3 ]       [ e4, e5 ]           [ e6, e7, e8, e9 ]
</code></pre>
<p>它在宏观上是一个 <code>linkedlist</code>，保持了 O(1) 的头尾插入性能和灵活性。而在微观上，每个节点内部是一个 <code>ziplist</code> 或 <code>listpack</code>，存储了多个元素，极大地节省了内存，并提升了缓存局部性。<code>quicklist</code> 通过将连锁更新的风险<strong>限制</strong>在一个个独立的小节点内部，完美地规避了 <code>ziplist</code> 最大的风险。</p>
<h4>2.2.4 Listpack</h4>
<p><code>quicklist</code> 虽然将 <code>ziplist</code> 的缺点限制在了一个节点之间，但是其缺点依旧存在，为此，<strong>紧凑列表(listpack)</strong> 诞生了。它去掉了 <code>prev_len</code>，改为在当前节点内部记录自己的长度 <code>back-len</code>（并且经过特殊编码支持反向解码）。修改当前节点，再也不会影响下一个节点的头部的长度了。</p>
<p>一个 <code>listpack</code> 结构如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251209213942454.png" alt=""></p>
<p>重点在于这个 <code>back-len</code>，它等于该条目自身的 <code>encoding-type</code> 和 <code>element-data</code> <strong>两部分加起来的长度</strong>（我们称之为&quot;部分长度&quot;）。当需要从后向前遍历时，解析器会从前一个条目的<strong>尾部</strong>，反向解析出这个&quot;部分长度&quot;，然后再动态计算出 <code>back-len</code> 字段自身的长度，两者相加得到前一个条目的<strong>总长度</strong>，从而实现精确的回溯跳转。</p>
<h2>3. Hash</h2>
<p>Hash 是一个 String 类型的 Field 和 Value 的映射表。</p>
<h3>3.1 核心操作</h3>
<pre><code class="language-bash"># 基础操作
HSET key field value          # 设置字段值（可以同时设置多个字段）
HGET key field                # 获取字段值
HMSET key field1 value1 field2 value2  # 批量设置字段
HMGET key field1 field2 field3  # 批量获取字段
HGETALL key                   # 获取所有字段和值
HDEL key field1 field2        # 删除指定字段
HEXISTS key field             # 检查字段是否存在

# 字段操作
HLEN key                      # 获取字段数量
HKEYS key                     # 获取所有字段名
HVALS key                     # 获取所有字段值
HSTRLEN key field             # 获取字段值的字符串长度

# 数值操作
HINCRBY key field increment   # 字段值按指定整数递增
HINCRBYFLOAT key field increment  # 字段值按指定浮点数递增

# 高级操作（Redis 6.2+）
HSETNX key field value        # 仅当字段不存在时设置
HRANDFIELD key [count [WITHVALUES]]  # 随机获取字段
</code></pre>
<p><strong>常见使用场景：</strong></p>
<ul>
<li><strong>对象缓存</strong>：存储用户信息、商品属性等结构化数据，如 <code>user:1001 {name: &quot;张三&quot;, age: 25, city: &quot;北京&quot;}</code></li>
<li><strong>计数器集合</strong>：文章的多维统计，如 <code>article:1001 {views: 1000, likes: 50, comments: 20}</code></li>
<li><strong>配置管理</strong>：系统配置、用户偏好设置等键值对集合</li>
<li><strong>购物车</strong>：简化版购物车实现，<code>cart:1001 {item_001: 2, item_005: 1}</code></li>
<li><strong>会话管理</strong>：比 String 更灵活的 session 存储，支持细粒度更新</li>
<li><strong>实时统计</strong>：网站在线用户统计、API 调用次数等分组统计场景</li>
<li><strong>标签系统</strong>：文章标签管理，<code>tags:article_001 {tech: 1, redis: 1, database: 1}</code></li>
</ul>
<h3>3.2 底层原理</h3>
<p>Hash 的底层实现有两种：<strong>Listpack (原 Ziplist)</strong> 和 <strong>Hashtable</strong>。</p>
<table>
<thead>
<tr>
<th><strong>编码类型</strong></th>
<th><strong>Redis 7.0 前</strong></th>
<th><strong>Redis 7.0 后</strong></th>
<th><strong>触发条件 (默认配置)</strong></th>
<th><strong>内存特征</strong></th>
<th><strong>查询复杂度</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>紧凑型</strong></td>
<td><strong>Ziplist</strong></td>
<td><strong>Listpack</strong></td>
<td>元素数 &lt; 512 且 值长度 &lt; 64 字节</td>
<td><strong>连续内存</strong>，无指针</td>
<td><strong>O(N)</strong></td>
</tr>
<tr>
<td><strong>分散型</strong></td>
<td><strong>Hashtable</strong></td>
<td><strong>Hashtable</strong></td>
<td>超过上述任一阈值</td>
<td><strong>指针+链表</strong>，有碎片</td>
<td><strong>O(1)</strong></td>
</tr>
</tbody></table>
<blockquote>
<p>[!NOTE]</p>
<p><strong>💡 关键点：Hash 的 Listpack 布局</strong> 和 List 不同，Hash 在 Listpack 中存储时，是<strong>成对</strong>出现的。 <code>[ Key1 | Val1 | Key2 | Val2 | ... ]</code> 查找时，Redis 从头遍历，找到 Key 后，紧接着取下一个 Entry 就是 Value。</p>
</blockquote>
<p><code>listpack</code> 的存储结构在 List 我们已经介绍过了，这里就介绍 Hash 的默认编码格式 <code>hashtable</code>。构成 Redis 哈希表的核心是 <code>dict</code> 及其节点 <code>dictEntry</code> 结构，源码可看：<a href="https://github.com/redis/redis/blob/8.4.0/src/dict.c">src/dict.c</a>。它不仅支撑了 Redis 的 Hash 数据类型，更重要的是，<strong>Redis 整个数据库（KV 空间）本身就是一个巨大的 Dict</strong>。</p>
<pre><code class="language-c">struct dictEntry {
    struct dictEntry *next;  /* 指向下一个节点，解决哈希冲突 */
    void *key;               /* 键 */
    union {                  /* 值（使用联合体优化内存） */
        void *val;			/* 通常指向一个 redisObject */
        uint64_t u64;		/* 无符号整数 */
        int64_t s64;		/* 有符号整数 */
        double d;				/* 浮点数 */
    } v;
};

struct dict {
    dictType *type;          /* 类型特定函数 */

    dictEntry **ht_table[2]; /* 两个哈希表，平时只用[0]，迁移时共用[0]和[1] */
    unsigned long ht_used[2];/* 两个表已有的节点数量 */

    long rehashidx;          /* Rehash 进度索引 */

    unsigned pauserehash;    /* Rehash 暂停标志 */

    /* 紧凑排列的小变量，优化内存填充 */
    signed char ht_size_exp[2]; /* 大小的指数 (size = 1 &lt;&lt; exp) */
    signed pauseAutoResize: 15; /* 禁止自动扩容标志 */
    unsigned useStoredKeyApi: 1;
    void *metadata[];           /* 变长数组，用于存储额外元数据 */
};
</code></pre>
<ul>
<li><strong>拉链法 (Chaining)</strong>：<code>dictEntry</code> 中的 <code>next</code> 指针，是 Redis 解决哈希冲突的基础手段，构建了 bucket 内的单向链表。</li>
<li><strong>双表架构 (Double Tables)</strong>：<code>ht_table[2]</code> 是 Redis 实现<strong>无阻塞扩容</strong>的物理基础。通常情况下，只有 <code>ht_table[0]</code> 在工作，<code>ht_table[1]</code> 是空的。当 <code>ht[0]</code> 满了需要扩容（或缩容）时，Redis 并不是在原表上操作，而是先给 <code>ht[1]</code> 分配更大的空间（两倍），然后将 <code>ht[0]</code> 的数据慢慢搬到 <code>ht[1]</code>。等搬完后，删除 <code>ht[0]</code>，把 <code>ht[1]</code> 变成新的 <code>ht[0]</code>。</li>
<li><strong>渐进式 Rehash (Incremental)</strong>：<code>rehashidx</code> 记录当前搬迁到了 <code>ht[0]</code> 的第几个 bucket（数组下标）。每次对字典进行增删改查（CRUD）时，顺便把 <code>rehashidx</code> 位置的一个 bucket 搬到 <code>ht[1]</code>，然后 <code>rehashidx++</code>。不用担心没有请求时就一直不迁移，后台有定时任务 <code>serverCron</code> 会包含 rehash 操作用于辅助迁移，以避免这个问题。</li>
</ul>
<h2>4. Set</h2>
<p>Set 是一个无序、唯一的字符串元素集合。其核心价值在于高效的成员关系判断（<code>SISMEMBER</code>）和服务器端的集合运算（<code>SINTER</code>, <code>SUNION</code>, <code>SDIFF</code>）。</p>
<h3>4.1 核心操作</h3>
<pre><code class="language-bash"># 基础操作
SADD key member1 member2        # 添加成员到集合
SREM key member1 member2        # 从集合中删除成员
SPOP key [count]                # 随机弹出并删除成员
SRANDMEMBER key [count]         # 随机获取成员（不删除）
SCARD key                       # 获取集合成员数量
SMEMBERS key                    # 获取所有成员

# 成员判断
SISMEMBER key member            # 检查成员是否存在
SMISMEMBER key member1 member2  # 批量检查成员是否存在

# 集合运算（重要）
SINTER key1 key2 key3           # 计算多个集合的交集
SINTERCARD numkeys key1 key2 [key3...] [LIMIT limit]  # 计算交集的基数
SINTERSTORE destination key1 key2  # 计算交集并存储到新集合
SUNION key1 key2 key3           # 计算多个集合的并集
SUNIONSTORE destination key1 key2  # 计算并集并存储到新集合
SDIFF key1 key2 key3            # 计算差集（key1 - key2 - key3）
SDIFFSTORE destination key1 key2  # 计算差集并存储到新集合

# 集合操作（Redis 6.2+）
SADD key member1 member2 [NX|XX]  # NX:仅添加不存在的，XX:仅添加已存在的
SMOVE source destination member   # 将成员从源集合移动到目标集合
</code></pre>
<p><strong>常见使用场景：</strong></p>
<ul>
<li><strong>标签系统</strong>：文章标签、用户兴趣标签，<code>SINTER</code> 找共同标签，<code>SUNION</code> 找所有标签</li>
<li><strong>共同好友</strong>：社交网络中找出两个用户的共同好友，<code>SINTER user:1001:friends user:1002:friends</code></li>
<li><strong>去重统计</strong>：访问用户去重、搜索关键词去重，利用 Set 的天然去重特性</li>
<li><strong>权限控制</strong>：角色权限管理，<code>SADD role:admin user,delete,create</code></li>
<li><strong>推荐系统</strong>：基于共同兴趣的用户推荐，<code>SINTER user:1001:likes user:1002:likes</code></li>
<li><strong>黑名单/白名单</strong>：IP 黑白名单、用户黑名单等，<code>SISMEMBER</code> 快速判断</li>
<li><strong>抽奖活动</strong>：<code>SRANDMEMBER</code> 随机抽取中奖用户，<code>SPOP</code> 抽取并移除避免重复中奖</li>
<li><strong>在线用户</strong>：实时在线用户集合，<code>SADD</code> 用户上线，<code>SREM</code> 用户下线</li>
</ul>
<h3>4.2 底层原理</h3>
<p>Set 同样采用了双重编码策略，分别是 <code>hashtable</code> 和 <code>intset</code>。</p>
<table>
<thead>
<tr>
<th><strong>编码类型</strong></th>
<th><strong>场景</strong></th>
<th><strong>结构描述</strong></th>
<th><strong>优缺点</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>IntSet</strong> (整数集合)</td>
<td>元素<strong>全部是整数</strong> 且 数量 &lt; 512</td>
<td><strong>有序</strong>的整数数组</td>
<td><strong>二分查找 O(logN)</strong>；极致省内存。</td>
</tr>
<tr>
<td><strong>Hashtable</strong> (哈希表)</td>
<td>元素包含非整数 或 数量 &gt; 512</td>
<td>value 为 <code>NULL</code> 的字典</td>
<td>查询 <strong>O(1)</strong>；内存占用大。</td>
</tr>
</tbody></table>
<p>其中 <code>hashtable</code> 完全复用上文所述的 dict 结构。集合中的每个元素被存储为 <code>dictEntry</code> 中的 <code>key</code>，而 <code>union v</code> (值) 部分则被忽略（统一设为 NULL）。这种设计直接利用了 dict 键的唯一性来保证 Set 元素的唯一性，并继承了其 O(1) 的查找性能。</p>
<p>当一个 Set 满足以下两个条件时，会采用 <code>intset</code> 编码：</p>
<ol>
<li>集合中所有元素均为整数。</li>
<li>元素数量未超过 <code>set-max-intset-entries</code> 配置项的阈值（默认为 512）。</li>
</ol>
<p><code>intset</code> 是一种自适应整数编码的有序数组。它可以根据存入整数的范围，将内部存储格式动态升级为 <code>int16_t</code>, <code>int32_t</code> 或 <code>int64_t</code>，以最小的内存空间存储数据。成员查找通过二分搜索算法实现。它的定义位于 <a href="https://github.com/redis/redis/blob/8.4.0/src/intset.h#L35">src/intset.h</a>。</p>
<pre><code class="language-c">#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

typedef struct intset {
    uint32_t encoding;  // 当前元素编码宽度：16/32/64 位
    uint32_t length;    // 元素个数
    int8_t contents[];  // 紧凑存放有序整数
} intset;
</code></pre>
<p><strong>升级机制</strong>：</p>
<ul>
<li><p>如果当前存的都是小整数（如 1, 2, 100），数组用 <code>int16</code> 存储。</p>
</li>
<li><p>当你插入一个大整数（如 65536，超出了 int16 范围），Redis 会<strong>将整个数组的所有元素升级</strong>为 <code>int32</code>，并重新分配内存。<strong>注意</strong>：IntSet <strong>不支持降级</strong>。一旦升级，这就回不去了。</p>
</li>
<li><p>如果你向一个 IntSet 插入了一个字符串（比如 &quot;a&quot;），即使之前存的全是整数，Redis 也会立刻将其转换为 <strong>Hashtable</strong>。且不可逆：就算你后来把 &quot;a&quot; 删了，它也不会变回 IntSet。</p>
</li>
</ul>
<hr>
<p>这里简单补充一下 <code>int8_t contents[]</code> 的含义，因为笔者对 C/C++ 并不熟悉，所以一开始阅读这个代码定义的时候还觉得很奇怪？</p>
<blockquote>
<p>—— 为什么 <code>int8_t[]</code> 类型的数组可以存放 <code>int16/int32/int64</code> 的元素？</p>
</blockquote>
<p>在 C99 标准中，结构体最后一个元素如果是未知大小的数组（如 <code>contents[]</code>），被称为<strong>柔性数组成员</strong>。<code>contents</code> 本身不占用 <code>intset</code> 结构体的大小（或者只被视为一个偏移量标记）。当我们为 <code>intset</code> 分配内存时，会多分配一块连续内存跟在结构体后面。</p>
<pre><code class="language-c">// 伪代码：分配一个包含 10 个 int64 元素的 intset
size_t size = sizeof(intset) + (10 * sizeof(int64_t));
intset *is = malloc(size);
</code></pre>
<p>声明为 <code>int8_t</code>（即 1 字节），是因为在 C 语言中，<code>int8_t</code>（或 <code>char</code>）是最小的内存寻址单位。这意味着 <code>contents</code> 指向的是一块<strong>原始的、未被定义的字节流</strong>。</p>
<p>Redis 在读取数据时，并不是直接通过下标 <code>contents[i]</code> 来读取（那样只能读到 1 个字节），而是通过以下逻辑进行<strong>指针强转和偏移计算</strong>：</p>
<pre><code class="language-c">/* * 根据给定的编码方式 (encoding)，返回 intset 中指定位置 (pos) 的元素值。
 * 注意：返回值统一提升为 int64_t，确保能容纳 int16, int32 和 int64。
 */
static int64_t _intsetGetEncoded(intset *is, int pos, uint8_t enc) {
    int64_t v64;
    int32_t v32;
    int16_t v16;

    /* 情况 1: 集合存储的是 64 位整数 */
    if (enc == INTSET_ENC_INT64) {
        /* * 核心指针运算：
         * 1. (int64_t*)is-&gt;contents : 将 int8_t* 强转为 int64_t*。
         * 这告诉编译器：这里的内存步长是 8 字节。
         * 2. + pos : 指针向后移动 pos * 8 个字节，精准定位到第 pos 个元素。
         * 3. memcpy : 从计算出的地址拷贝 8 个字节到栈变量 v64 中。
         * 使用 memcpy 而不是直接赋值 (v64 = ...)，是为了防止内存未对齐导致的 CPU 异常。
         */
        memcpy(&amp;v64,((int64_t*)is-&gt;contents)+pos,sizeof(v64));

        /* * 字节序处理：
         * 如果当前 CPU 是大端序 (Big Endian)，则翻转字节顺序为小端序。
         * 如果是小端序 (Little Endian)，此宏不做任何操作。
         * 保证数据在内存中统一以小端序存储，实现跨平台兼容。
         */
        memrev64ifbe(&amp;v64);
        return v64;
    } else if (enc == INTSET_ENC_INT32) {
        memcpy(&amp;v32,((int32_t*)is-&gt;contents)+pos,sizeof(v32));
        memrev32ifbe(&amp;v32);
        return v32;
    } else {
        memcpy(&amp;v16,((int16_t*)is-&gt;contents)+pos,sizeof(v16));
        memrev16ifbe(&amp;v16);
        return v16;
    }
}
</code></pre>
<h2>5. Sorted Set</h2>
<p>Sorted Set，即 ZSet，它是一个 <code>Member</code> (成员) 到 <code>Score</code> (分数) 的有序映射。</p>
<ul>
<li><strong>特性</strong>：Member 唯一，Score 可重复。</li>
<li><strong>排序</strong>：按 Score 从小到大排；Score 相同，按 Member 字典序排。</li>
</ul>
<h3>5.1 核心操作</h3>
<pre><code class="language-bash"># 基础操作
ZADD key score1 member1 score2 member2  # 添加成员及其分数
ZREM key member1 member2                # 删除指定成员
ZCARD key                              # 获取集合成员数量
ZSCORE key member                      # 获取成员分数
ZCOUNT key min max                     # 统计指定分数范围内的成员数量

# 排名查询（重要）
ZRANK key member                       # 获取成员排名（从0开始，分数从小到大）
ZREVRANK key member                    # 获取反向排名（分数从大到小）
ZRANGE key start stop [BYSCORE|BYLEX] [REV] [LIMIT offset count] [WITHSCORES]  # 获取排名范围内成员
ZREVRANGE key start stop [WITHSCORES]  # 获取反向排名范围内成员

# 分数范围查询
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]     # 按分数范围查询
ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]  # 按分数范围反向查询
ZREMRANGEBYRANK key start stop         # 按排名范围删除成员
ZREMRANGEBYSCORE key min max           # 按分数范围删除成员

# 分数操作
ZINCRBY key increment member           # 增加指定成员的分数

# 集合运算
ZINTERSTORE destination numkeys key1 key2 [WEIGHTS w1 w2] [AGGREGATE SUM|MIN|MAX]  # 计算交集
ZUNIONSTORE destination numkeys key1 key2 [WEIGHTS w1 w2] [AGGREGATE SUM|MIN|MAX]  # 计算并集
ZDIFFSTORE destination numkeys key1 key2  # 计算差集

# 字典序操作（Redis 6.2+）
ZLEXCOUNT key min max                  # 统计字典序范围内的成员数量
ZRANGEBYLEX key min max [LIMIT offset count]  # 按字典序范围查询
ZREMRANGEBYLEX key min max             # 按字典序范围删除
ZMSCORE key member1 member2 member3    # 批量获取成员分数
</code></pre>
<p><strong>常见使用场景：</strong></p>
<ul>
<li><strong>排行榜系统</strong>：游戏积分榜、文章热度榜、用户财富榜，<code>ZRANGE/ZREVRANGE</code> 获取 Top N</li>
<li><strong>延迟队列</strong>：以时间戳为分数，实现定时任务调度，<code>ZRANGEBYSCORE</code> 获取到期任务</li>
<li><strong>优先级队列</strong>：任务优先级管理，高优先级任务分数更高，<code>ZPOPMAX</code> 获取最高优先级任务</li>
<li><strong>范围搜索</strong>：价格区间商品查询、年龄范围用户筛选，<code>ZRANGEBYSCORE</code> 高效范围查询</li>
<li><strong>加权投票</strong>：用户投票系统，投票时间越新权重越高（时间戳作为分数）</li>
<li><strong>实时统计</strong>：热门搜索词排行，搜索次数作为分数，<code>ZINCRBY</code> 实时更新</li>
<li><strong>教育资源</strong>：学生成绩管理，分数直接作为 zset 分数，<code>ZRANK</code> 获取排名</li>
<li><strong>地理位置排序</strong>：虽然有 Geo 类型，但 zset 可以实现更复杂的排序逻辑</li>
</ul>
<h3>5.2 底层原理</h3>
<p>Set 同样采用了双重编码策略，分别是 <code>ziplist/listpack</code> 和 <code>skiplist+dict</code>。</p>
<table>
<thead>
<tr>
<th><strong>编码类型</strong></th>
<th><strong>场景</strong></th>
<th><strong>结构描述</strong></th>
<th><strong>优缺点</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>紧凑型</strong> (Ziplist / Listpack)</td>
<td>元素少（&lt; 128）且 元素小（&lt; 64 字节）</td>
<td><strong>紧凑数组</strong>。Member 和 Score 紧挨着存，按 Score 排序。</td>
<td>省内存；插入/查询 <strong>O(N)</strong>。</td>
</tr>
<tr>
<td><strong>分散型</strong> (Skiplist + Dict)</td>
<td>超过阈值</td>
<td><strong>跳表 + 哈希表</strong> (双重索引)。</td>
<td>查询/插入 <strong>O(logN)</strong>；内存占用大。</td>
</tr>
</tbody></table>
<p>相信对阅读到了这里的读者来说，已经不会对 ZSet 使用 <code>listpack</code> 感到意外了，这是 Redis 里典型的用时间换空间的策略了。</p>
<p>当 Sorted Set 中存储的元素数量很少，并且每个元素的值都不大时，Redis 会选择 <code>listpack</code> 编码。</p>
<p>条件是：</p>
<ul>
<li>元素数量小于 <code>zset-max-listpack-entries 128</code></li>
<li>所有元素值的字节长度小于 <code>zset-max-listpack-value 64</code></li>
</ul>
<p>它将每个 (member, score) 对紧凑地序列化存储。为了保持有序，每次插入都需要找到正确的位置，这可能导致其后的数据发生移动。当 <code>N</code> 非常小（例如小于 128）时，𝑂(𝑁) 的操作成本极低，几乎是瞬时的，而节省下来的内存却非常可观。</p>
<p>一旦 <code>listpack</code> 的任一触发条件被打破，Redis 会自动将其转换为 <code>skiplist</code> + <code>dict</code> 编码。其实 ZSet 要采用&quot;双引擎&quot;模式，也不难理解，它要解决的核心矛盾是：<strong>如何创建一个既能通过成员（member）快速查找、又能根据分数（score）高效排序和范围查找的数据集合？</strong></p>
<ul>
<li><strong><code>dict</code> (字典/哈希表)</strong>：负责&quot;集合&quot;的部分。它建立了一个从 <code>member</code> 到 <code>score</code> 的映射。这使得 <code>ZSCORE</code> 这种通过成员获取分数的操作，时间复杂度 𝑂(1)。</li>
<li><strong><code>skiplist</code> (跳表)</strong>：负责&quot;排序&quot;的部分。它将所有的 <code>(score, member)</code> 对按照 <code>score</code>（分数相同则按 <code>member</code> 字典序）进行排序。跳表的特性使得插入、删除和按排名/分数范围查找的平均时间复杂度都是 𝑂(𝑙𝑜𝑔𝑁)。</li>
</ul>
<blockquote>
<p>为了节约内存，<code>dict</code> 和 <code>skiplist</code> 中的 <code>member</code> 字符串是共享的，即它们都指向同一个 <code>SDS (Simple Dynamic String)</code> 对象。</p>
</blockquote>
<p>跳表的核心思想是<strong>空间换时间</strong>和<strong>随机化</strong>。</p>
<p>想象一下，一个普通的单链表，查找效率是 𝑂(𝑁)。现在，我们从这个链表中，随机抽取一些节点，给它们增加一个&quot;上层指针&quot;，指向下一个被抽取的节点。这样我们就构建了第 2 层&quot;快速通道&quot;。我们可以不断重复这个过程，构建出多层快速通道。</p>
<p>当我们要查找一个元素时：</p>
<ol>
<li>从最高层的&quot;快速通道&quot;开始。</li>
<li>在当前层向右查找，直到找到的下一个节点比目标大，或者到了链表末尾。</li>
<li>然后从当前节点下降一层，重复步骤 2。</li>
<li>最终在最底层（原始链表）找到目标位置。</li>
</ol>
<p>因为高层索引可以让你&quot;跳过&quot;大量节点，所以平均查找效率被提升到了 𝑂(𝑙𝑜𝑔𝑁)。而这种层级的建立是完全随机的，它通过概率来维持整体的平衡，避免了红黑树那样复杂的平衡操作。</p>
<p>这就是 Redis 选择跳表的原因：<strong>用更简单的实现，达到了与平衡树相媲美的性能。</strong></p>
<p>在 Redis 8.4.0 的源码中，关于 <code>zset</code> 的类型定义位于 <a href="https://github.com/redis/redis/blob/8.4.0/src/server.h#L1599">src/server.h</a> 文件中。如下所示，它主要包含 3 个核心数据结构：<code>zset</code>、<code>zskiplist</code> 和 <code>zskiplistNode</code>。</p>
<pre><code class="language-c">// 跳表节点
typedef struct zskiplistNode {
    sds ele;          /* 成员字符串 */
    double score;     /* 分值（排序主键） */
    struct zskiplistNode *backward; /* 反向遍历指针（指向前一个节点） */
    struct zskiplistLevel {
        struct zskiplistNode *forward; /* 本层前进指针 */
        unsigned long span;            /* 到 forward 的节点数，用于排名计算 */
    } level[];        /* 可变层高的层数组 */
} zskiplistNode;

// 跳表结构
typedef struct zskiplist {
    struct zskiplistNode *header, *tail; /* 哨兵头/尾 */
    unsigned long length;                /* 节点总数 */
    int level;                           /* 当前最高层数 */
    size_t alloc_size;                   /* 内存统计 */
} zskiplist;

// zset 结构
typedef struct zset {
    dict *dict;        /* 成员 -&gt; score 映射，O(1) 查找/判重 */
    zskiplist *zsl;    /* 按 score+字典序排序的跳表，支持范围/排名 */
} zset;
</code></pre>
<p>从比较直观的角度来讲的话，跳表的结构可以用下图来演示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250918190254554.png" alt=""></p>
<p>如果要完全复刻上述所定义出来的数据结构，那表示起来可能会有点复杂，这里我画了张图，供你参考：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250918190417220.png" alt=""></p>
<p>本篇限于篇幅原因和阅读性价比的关系，笔者对绝大多数数据结构都不太愿意展开源码，不过介于 skiplist 是一个非常常考的点，也是一个比较艺术性的实现，所以在本章节我们将从一个最核心的<strong>插入</strong>逻辑来更进一步了解跳表的实现细节。 <code>zset</code> 的核心实现逻辑位于 <a href="https://github.com/redis/redis/blob/8.4.0/src/t_zset.c#L140">src/t_zset.c</a> 文件中，其中插入操作由 <code>zslInsert</code> 函数来实现。这里我先把完整的代码和注释给你，接下来我们再一一拆解。</p>
<pre><code class="language-c">/* 在跳表中插入一个新节点。
 * 假设：调用者已经检查过该元素不存在（Redis 在调用此函数前会在 dict 中检查）。
 * 注意：跳表会接管传入的 SDS 字符串 &#39;ele&#39; 的所有权。*/
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    // update[]: 记录每一层中，新节点应该插入在哪个节点之后（即前驱节点）
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    // rank[]: 记录 update[i] 节点在整个链表中的排名（从头节点到该节点的跨度之和）
    unsigned long rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    serverAssert(!isnan(score));
    x = zsl-&gt;header;

    /* ----------------------------------------------------------------------
     * 步骤 1: 自顶向下查找插入位置
     * ---------------------------------------------------------------------- */
    for (i = zsl-&gt;level-1; i &gt;= 0; i--) {
        /* 计算 rank:
         * 如果是最高层，rank 初始为 0。
         * 否则，rank 继承上一层已经走过的步数 (rank[i+1])。
         * 这实现了路径累加：比如 L3 走了 10 步，降到 L2 时，起点就是 10。
         */
        rank[i] = i == (zsl-&gt;level-1) ? 0 : rank[i+1];

        /* 向后遍历：直到下一个节点的 score &gt;= 新 score (或 score 相等但 ele 字典序更大) */
        while (x-&gt;level[i].forward &amp;&amp;
                (x-&gt;level[i].forward-&gt;score &lt; score ||
                    (x-&gt;level[i].forward-&gt;score == score &amp;&amp;
                    sdscmp(x-&gt;level[i].forward-&gt;ele,ele) &lt; 0)))
        {
            // 累加经过的跨度 span 到 rank[i]
            rank[i] += x-&gt;level[i].span;
            x = x-&gt;level[i].forward;
        }
        // 记录第 i 层的前驱节点
        update[i] = x;
    }

    /* 此时，rank[0] 保存的是插入位置前一个节点的总排名 */

    /* ----------------------------------------------------------------------
     * 步骤 2: 生成随机层数
     * ---------------------------------------------------------------------- */
    // 幂次定律随机生成层数 (1/4 概率)
    level = zslRandomLevel();

    // 如果新生成的层数比当前跳表最高层还高
    if (level &gt; zsl-&gt;level) {
        // 初始化多出来的层级
        for (i = zsl-&gt;level; i &lt; level; i++) {
            rank[i] = 0;
            // 新层级的前驱自然是 header
            update[i] = zsl-&gt;header;
            // header 在这些新层级的 span 暂时覆盖整个链表长度
            // (因为 header 后直接就是 NULL，或者即将插入的新节点)
            update[i]-&gt;level[i].span = zsl-&gt;length;
        }
        zsl-&gt;level = level; // 更新跳表最大层数
    }

    /* ----------------------------------------------------------------------
     * 步骤 3: 创建节点并调整指针与 Span (最难点)
     * ---------------------------------------------------------------------- */
    x = zslCreateNode(zsl,level,score,ele);

    // 逐层处理指针链接和 span 计算
    for (i = 0; i &lt; level; i++) {
        // 标准的链表插入操作:
        // NewNode-&gt;Next = PrevNode-&gt;Next
        // PrevNode-&gt;Next = NewNode
        x-&gt;level[i].forward = update[i]-&gt;level[i].forward;
        update[i]-&gt;level[i].forward = x;

        /* 计算 span 的逻辑：
         * update[i]-&gt;level[i].span 是原先 update[i] 到其下一个节点的距离。
         * 现在中间插了个 x。
         *
         * rank[0]: 插入点之前的总排名。
         * rank[i]: update[i] 节点的总排名。
         * (rank[0] - rank[i]): update[i] 和 实际插入点(L0层) 之间的距离。
         */

        // 1. 设置新节点 x 的 span
        // x 的 span = 原前驱的 span - (前驱到 x 的距离)
        x-&gt;level[i].span = update[i]-&gt;level[i].span - (rank[0] - rank[i]);

        // 2. 更新前驱节点 update[i] 的 span
        // 前驱的 span = (前驱到 x 的距离) + 1 (这个 1 是 x 节点本身)
        update[i]-&gt;level[i].span = (rank[0] - rank[i]) + 1;
    }

    // 对于比新节点层数更高的层级，结构没变，只是底层多了一个节点
    // 所以这些层级的 span 只需要简单的 +1
    for (i = level; i &lt; zsl-&gt;level; i++) {
        update[i]-&gt;level[i].span++;
    }

    /* ----------------------------------------------------------------------
     * 步骤 4: 设置后退指针 (用于 L0 层双向遍历)
     * ---------------------------------------------------------------------- */
    // 如果前驱是 header，backward 设为 NULL (header 不作为数据节点)
    x-&gt;backward = (update[0] == zsl-&gt;header) ? NULL : update[0];

    // 设置下一个节点的 backward 指向新节点 x
    if (x-&gt;level[0].forward)
        x-&gt;level[0].forward-&gt;backward = x;
    else
        // 如果 x 是最后一个节点，更新 tail 指针
        zsl-&gt;tail = x;

    zsl-&gt;length++;
    return x;
}
</code></pre>
<p>插入操作主要分为四个步骤：</p>
<ol>
<li><strong>查找插入位置（Search）</strong>：从最高层往下找，记录每层的前驱节点（<code>update[]</code>）和到达前驱节点的排名（<code>rank[]</code>）。</li>
<li><strong>生成随机层数（Random Level）</strong>：决定新节点的高度。如果比当前跳表高，需要初始化高层的前驱。</li>
<li><strong>创建节点与链接（Link）</strong>：调整指针，在 <code>0</code> 到 <code>level-1</code> 的每一层中将新节点插入链表，并<strong>极其精细地计算新旧节点的 span 值</strong>。</li>
<li><strong>收尾工作</strong>：更新高层的 span（只加 1），设置后退指针，更新长度。</li>
</ol>
<p>接下来我们来一一拆解这段代码的每一个细节。</p>
<p><strong>第 0 步：初始化与准备</strong></p>
<pre><code class="language-c">zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    // update[]: 记录每一层中，新节点应该插入在哪个节点之后（即前驱节点）
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    // rank[]: 记录 update[i] 节点在整个链表中的排名（从头节点到该节点的跨度之和）
    unsigned long rank[ZSKIPLIST_MAXLEVEL];
    int i, level;
</code></pre>
<ul>
<li><code>zskiplistNode *update[ZSKIPLIST_MAXLEVEL]</code>: <strong>路径记录仪</strong>。它是一个指针数组，<code>update[i]</code> 将用于存储新节点在第 <code>i</code> 层的前驱节点。为什么需要它？因为插入操作的本质就是在 <code>update[i]</code> 和它原来的下一个节点之间，插入我们的新节点。这个数组为我们保存了所有需要修改的连接点。</li>
<li><code>unsigned long rank[ZSKIPLIST_MAXLEVEL]</code>: <strong>距离计数器</strong>。<code>rank[i]</code> 用于记录从跳表 <code>header</code> 头节点出发，到达 <code>update[i]</code> 这个节点时，在最底层（第 0 层）总共跨越了多少个节点。它的核心作用是为后续精确计算和更新节点的 <code>span</code>（跨度）提供数据支持，这是 <code>ZRANK</code> 命令能够实现 𝑂(𝑙𝑜𝑔𝑁) 的基石。</li>
</ul>
<p><strong>第 1 步：查找插入位置并记录路径</strong></p>
<pre><code class="language-c">x = zsl-&gt;header;

/* ----------------------------------------------------------------------
 * 步骤 1: 自顶向下查找插入位置
 * ---------------------------------------------------------------------- */
for (i = zsl-&gt;level-1; i &gt;= 0; i--) {
    /* 计算 rank:
     * 如果是最高层，rank 初始为 0。
     * 否则，rank 继承上一层已经走过的步数 (rank[i+1])。
     * 这实现了路径累加：比如 L3 走了 10 步，降到 L2 时，起点就是 10。
     */
    rank[i] = i == (zsl-&gt;level-1) ? 0 : rank[i+1];

    /* 向后遍历：直到下一个节点的 score &gt;= 新 score (或 score 相等但 ele 字典序更大) */
    while (x-&gt;level[i].forward &amp;&amp;
            (x-&gt;level[i].forward-&gt;score &lt; score ||
                (x-&gt;level[i].forward-&gt;score == score &amp;&amp;
                sdscmp(x-&gt;level[i].forward-&gt;ele,ele) &lt; 0)))
    {
        // 累加经过的跨度 span 到 rank[i]
        rank[i] += x-&gt;level[i].span;
        x = x-&gt;level[i].forward;
    }
    // 记录第 i 层的前驱节点
    update[i] = x;
}
</code></pre>
<p>这是跳表算法的精髓所在——<strong>分层查找</strong>。</p>
<ul>
<li><code>for (i = zsl-&gt;level-1; i &gt;= 0; i--)</code>: 循环从最高层（<code>zsl-&gt;level-1</code>）开始，逐层下降到最底层（<code>0</code>）。这模仿了我们在地图上查找位置的过程：先看洲际地图（高层），再看国家地图（中层），最后看城市街道图（底层）。高层级的&quot;快速通道&quot;可以让我们一次性跳过大量节点。</li>
<li><code>while (...)</code>: 在当前层级，不断向右移动。判断条件 <code>(score &lt; ... || (score == ... &amp;&amp; ele &lt; ...))</code> 保证了跳表的排序规则：优先按 <code>score</code> 升序，如果 <code>score</code> 相同，则按 <code>member</code> 的字典序升序。</li>
<li><code>rank[i] += x-&gt;level[i].span;</code>: 这是 <code>rank</code> 数组工作的核心。每当我们从 <code>x</code> 节点跳到它的下一个节点时，并不是只前进了一步，而是前进了 <code>x-&gt;level[i].span</code> 步。我们将这个跨度累加到 <code>rank[i]</code> 中，<code>rank[i]</code> 就实时记录了我们距离起点的总步数。</li>
<li><code>update[i] = x;</code>: 当 <code>while</code> 循环结束时，<code>x</code> 节点就是新节点在第 <code>i</code> 层的前驱节点。我们将其记录在 <code>update[i]</code> 中。当整个 <code>for</code> 循环结束后，<code>update</code> 数组就完整记录了从最高层到最底层的一条插入路径。</li>
</ul>
<p><strong>第 2 步：确定新节点层高并处理新层级</strong></p>
<pre><code class="language-c">/* ----------------------------------------------------------------------
 * 步骤 2: 生成随机层数
 * ---------------------------------------------------------------------- */
// 幂次定律随机生成层数 (1/4 概率)
level = zslRandomLevel();

// 如果新生成的层数比当前跳表最高层还高
if (level &gt; zsl-&gt;level) {
    // 初始化多出来的层级
    for (i = zsl-&gt;level; i &lt; level; i++) {
        rank[i] = 0;
        // 新层级的前驱自然是 header
        update[i] = zsl-&gt;header;
        // header 在这些新层级的 span 暂时覆盖整个链表长度
        // (因为 header 后直接就是 NULL，或者即将插入的新节点)
        update[i]-&gt;level[i].span = zsl-&gt;length;
    }
    zsl-&gt;level = level; // 更新跳表最大层数
}
</code></pre>
<ul>
<li><code>level = zslRandomLevel()</code>: 新节点将拥有几层&quot;快速通道&quot;？这里不是由复杂的平衡算法决定，而是通过一个简单的概率函数随机生成。大部分节点只有 1 层，极少数节点会有很高的层数。正是这种随机性，使得跳表在整体上能够维持一个高效的对数级结构。</li>
<li><code>if (level &gt; zsl-&gt;level)</code>: 处理一个特殊情况。如果随机出的 <code>level</code> 比当前跳表的最大层数还大，意味着我们需要为整个跳表加盖新的楼层。在这些新楼层，路径上的前驱节点自然就是 <code>header</code> 节点，并且它到 <code>NULL</code> 的跨度就是整个跳表的长度 <code>zsl-&gt;length</code>。</li>
</ul>
<p><strong>第 3 步：节点创建、链接与跨度更新</strong></p>
<pre><code class="language-c">/* ----------------------------------------------------------------------
 * 步骤 3: 创建节点并调整指针与 Span (最难点)
 * ---------------------------------------------------------------------- */
x = zslCreateNode(zsl,level,score,ele);

// 逐层处理指针链接和 span 计算
for (i = 0; i &lt; level; i++) {
    // 标准的链表插入操作:
    // NewNode-&gt;Next = PrevNode-&gt;Next
    // PrevNode-&gt;Next = NewNode
    x-&gt;level[i].forward = update[i]-&gt;level[i].forward;
    update[i]-&gt;level[i].forward = x;

    /* 计算 span 的逻辑：
     * update[i]-&gt;level[i].span 是原先 update[i] 到其下一个节点的距离。
     * 现在中间插了个 x。
     *
     * rank[0]: 插入点之前的总排名。
     * rank[i]: update[i] 节点的总排名。
     * (rank[0] - rank[i]): update[i] 和 实际插入点(L0层) 之间的距离。
     */

    // 1. 设置新节点 x 的 span
    // x 的 span = 原前驱的 span - (前驱到 x 的距离)
    x-&gt;level[i].span = update[i]-&gt;level[i].span - (rank[0] - rank[i]);

    // 2. 更新前驱节点 update[i] 的 span
    // 前驱的 span = (前驱到 x 的距离) + 1 (这个 1 是 x 节点本身)
    update[i]-&gt;level[i].span = (rank[0] - rank[i]) + 1;
}
</code></pre>
<p>这是整个插入过程中最核心的逻辑：</p>
<p><strong>链接 <code>forward</code> 指针</strong>: 这两行是经典的链表插入操作。假设原来是 <code>A -&gt; C</code>，<code>A</code> 就是 <code>update[i]</code>，<code>C</code> 就是 <code>update[i]-&gt;level[i].forward</code>。现在，我们将新节点 <code>x</code> 插入其中，变为 <code>A -&gt; x -&gt; C</code>。</p>
<p><strong>更新 <code>span</code> (跨度)</strong>: 这是最精妙的部分，我们用一个例子来说明。</p>
<ul>
<li>假设在第 <code>i</code> 层，前驱节点 <code>A</code> (即 <code>update[i]</code>) 原来的 <code>span</code> 是 <code>10</code>，它直接指向 <code>C</code>。</li>
<li>我们在第 0 层（最底层）的查找过程中，从 <code>A</code> 之后，又前进了 3 步才找到插入点。这意味着 <code>rank[0] - rank[i]</code> 的值是 3（可以理解为 A 在高层和底层之间的投影偏差）。</li>
<li><code>update[i]-&gt;level[i].span = (rank[0] - rank[i]) + 1;</code>: <code>A</code> 的新 <code>span</code> 变为 <code>3 + 1 = 4</code>。因为它现在指向新节点 <code>x</code>，它俩之间跨越了 <code>3</code> 个旧节点，加上 <code>x</code> 本身，总共是 <code>4</code> 步。</li>
<li><code>x-&gt;level[i].span = update[i]-&gt;level[i].span - (rank[0] - rank[i]);</code>: 新节点 <code>x</code> 的 <code>span</code> 等于 <code>A</code> 的<strong>旧</strong> <code>span</code> (<code>10</code>) 减去它俩之间的距离 (<code>3</code>)，等于 <code>7</code>。</li>
</ul>
<blockquote>
<p><strong>验证</strong>: 插入后，<code>A</code> 的新 <code>span</code> (4) + <code>x</code> 的新 <code>span</code> (7) = <code>11</code>。而 <code>A</code> 的旧 <code>span</code> 是 <code>10</code>。总跨度增加了 <code>1</code>，正好等于新加入的节点数量。完美！</p>
</blockquote>
<p>另外，对于那些高于新节点 <code>level</code> 的层级，新节点并不会被插入。但是，由于整个跳表的总长度增加了 1，这些更高层级上、位于插入路径上的前驱节点（<code>update[i]</code>），它们指向的下一个节点的相对距离也增加 1。因此，它们的 <code>span</code> 值需要递增。</p>
<p><strong>第 4 步：更新后退指针和表尾</strong></p>
<pre><code class="language-c">/* ----------------------------------------------------------------------
 * 步骤 4: 设置后退指针 (用于 L0 层双向遍历)
 * ---------------------------------------------------------------------- */
// 如果前驱是 header，backward 设为 NULL (header 不作为数据节点)
x-&gt;backward = (update[0] == zsl-&gt;header) ? NULL : update[0];

// 设置下一个节点的 backward 指向新节点 x
if (x-&gt;level[0].forward)
    x-&gt;level[0].forward-&gt;backward = x;
else
    // 如果 x 是最后一个节点，更新 tail 指针
    zsl-&gt;tail = x;

zsl-&gt;length++;
return x;
</code></pre>
<ul>
<li><code>x-&gt;backward = ...</code>: 跳表的最底层（<code>level[0]</code>）是一个<strong>双向链表</strong>，<code>backward</code> 指针用于支持 <code>ZREVRANGE</code> 等反向遍历命令。这里将新节点的后退指针正确地指向它的前驱节点 <code>update[0]</code>。</li>
<li><code>if...else...</code>: 更新新节点的后继节点的 <code>backward</code> 指针，让它指向新节点 <code>x</code>。如果新节点是最后一个节点，则更新整个跳表的 <code>tail</code> 指针。</li>
<li><code>zsl-&gt;length++</code>: 最后，将跳表的总长度加 1。</li>
</ul>
<p>至此，一个新节点被天衣无缝地织入了跳表这张大网中，所有相关的指针和跨度信息都得到了原子性的更新。</p>
<h2>6. Bitmap</h2>
<p><code>Bitmap</code>（位图） 利用 String 的二进制位（Bit）来存数据，适用于处理 bit 级别的数据。因为 SDS 最大 512MB，所以 Bitmap 最多能存 $2^{32}$ (约 40 亿) 个 bit。</p>
<pre><code class="language-bash"># 基础位操作
SETBIT key offset value          # 设置指定偏移量的位值（0或1）
GETBIT key offset                # 获取指定偏移量的位值

# 位统计
BITCOUNT key [start end]         # 统计位图中值为1的位数
BITPOS key bit [start end]       # 查找第一个指定值的位置

# 位运算（重要）
BITOP operation destkey key1 key2 ...  # 位运算操作
  # operation: AND(与), OR(或), XOR(异或), NOT(非)

# 字符串和位图转换
BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment]  # 位域操作

# 扩展命令（Redis 6.2+）
BITFIELD_RO key [GET type offset] ...  # 只读的位域操作
</code></pre>
<p><strong>常见使用场景：</strong></p>
<ul>
<li><strong>用户签到</strong>：用位表示用户每日签到状态，1 表示已签到，<code>BITCOUNT</code> 统计月签到天数</li>
<li><strong>在线状态</strong>：用户在线/离线状态跟踪，<code>SETBIT user:online 1001 1</code> 表示用户 1001 在线</li>
<li><strong>权限管理</strong>：RBAC 权限系统，每位代表一种权限，<code>BITOP AND</code> 实现权限交集</li>
<li><strong>布隆过滤器底层</strong>：实现自定义布隆过滤器的基础数据结构</li>
<li><strong>用户特征</strong>：用户画像标签系统，1 表示有该特征，<code>BITOP OR</code> 找相似用户</li>
<li><strong>A/B 测试</strong>：用户分组标识，0 为对照组，1 为实验组</li>
<li><strong>数据统计</strong>：日活用户统计，<code>BITOP OR</code> 合并多日活跃用户计算总活跃数</li>
<li><strong>去重统计</strong>：大量 ID 去重，使用位图存储已见过的 ID，节省内存</li>
<li><strong>游戏系统</strong>：成就系统解锁状态，每个成就对应一个位，<code>BITCOUNT</code> 统计解锁数量</li>
</ul>
<h2>7. HyperLogLog</h2>
<p><code>HyperLogLog</code> 是一个概率数据结构，用于估计集合的基数。它的底层也是 String 类型。每个 <code>HyperLogLog</code> 最多消耗 12KB 的内存，在标准误差 0.81% 的前提下，可以计算 $2^{64}$ 个元素的基数。</p>
<h3>7.1 核心操作</h3>
<pre><code class="language-bash"># 基础操作
PFADD key element1 element2 ...  # 添加元素到 HyperLogLog
PFCOUNT key1 key2 key3 ...       # 获取一个或多个 HyperLogLog 的基数估计
PFMERGE destkey source1 source2 ...  # 合并多个 HyperLogLog 到目标键

# 实用命令组合
# 创建并初始化
PFADD daily_users_20231201 &quot;user_1001&quot; &quot;user_1002&quot; &quot;user_1003&quot;

# 查看基数估计
PFCOUNT daily_users_20231201

# 合并多日数据
PFMERGE month_users daily_users_20231201 daily_users_20231202 daily_users_20231203

# 多键统计
PFCOUNT week_users month_users  # 可以同时查询多个键的合并基数
</code></pre>
<p><strong>常见使用场景：</strong></p>
<ul>
<li><strong>UV 统计</strong>：网站/APP 日活、月活用户统计，<code>PFADD</code> 记录用户访问，<code>PFCOUNT</code> 获取 UV 数量</li>
<li><strong>大数据去重</strong>：海量数据去重统计，如日志分析中的独立 IP 统计</li>
<li><strong>实时统计</strong>：社交平台的活跃用户数、电商平台的访客数等实时去重统计</li>
<li><strong>用户行为分析</strong>：页面浏览量去重、搜索关键词去重统计</li>
<li><strong>广告曝光</strong>：广告独立曝光人数统计，避免重复计算同一用户</li>
<li><strong>数据分析</strong>：A/B 测试中的独立用户数统计、转化漏斗的去重计数</li>
<li><strong>监控告警</strong>：系统独立错误触发次数、独立异常 IP 统计</li>
<li><strong>内容分发</strong>：视频独立观看人数、文章独立阅读数统计</li>
</ul>
<h3>7.2 底层原理</h3>
<p><code>HyperLogLog</code> 使用概率算法来统计集合的近似基数，而概率算法的本质是伯努利试验（Bernoulli Trial），伯努利试验可以看作一个抛硬币过程。</p>
<p>假设我们玩抛硬币游戏，我让你一直抛，直到抛出&quot;正面&quot;为止，这算一轮。</p>
<p>我们记录每一轮中，&quot;反面&quot;连续出现的次数（也就是前导 0 的个数）。</p>
<ul>
<li><strong>情况 1</strong>：你抛了 1 次就出了正面。前导 0 个数 = 0。</li>
<li><strong>情况 2</strong>：你抛了 2 次（反、正）。前导 0 个数 = 1。</li>
<li>...</li>
<li><strong>情况 N</strong>：如果你告诉我，你刚刚那一轮，<strong>连续抛了 10 次反面</strong>才出正面（前导 0 = 10）。</li>
</ul>
<p>推断：连续抛 10 次反面的概率是 $(1/2)^{10} = 1/1024$。</p>
<p>如果你能做到这件事，我不仅认为你运气好，我更有理由相信：你肯定已经在背后偷偷抛了很多很多轮（大概 1024 轮左右），才碰巧出现了一次这么罕见的情况。所以我们可以通过观察<strong>一组随机数</strong>（Hash 值）中**&quot;最大前导 0 的个数&quot;**，来反推这组随机数大概有多少个（基数）。</p>
<table>
<thead>
<tr>
<th><strong>二进制形态</strong></th>
<th><strong>描述</strong></th>
<th><strong>概率</strong></th>
<th><strong>倒数 (预估数量)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>1...</code></td>
<td>开头是 1 (0 个零)</td>
<td>$1/2$ (50%)</td>
<td>$2^1 = 2$</td>
</tr>
<tr>
<td><code>01...</code></td>
<td>开头是 01 (1 个零)</td>
<td>$1/4$ (25%)</td>
<td>$2^2 = 4$</td>
</tr>
<tr>
<td><code>001...</code></td>
<td>开头是 001 (2 个零)</td>
<td>$1/8$ (12.5%)</td>
<td>$2^3 = 8$</td>
</tr>
<tr>
<td>...</td>
<td>...</td>
<td>...</td>
<td>...</td>
</tr>
<tr>
<td><code>00...01</code> ($k$ 个零)</td>
<td>开头 $k$ 个 0</td>
<td>$1 / 2^{k+1}$</td>
<td><strong>$2^{k+1}$</strong></td>
</tr>
</tbody></table>
<p>如果只依据一次运气最好的记录（最大前导 0）来估算，偶然性太大（比如我第一次就走了狗屎运抛了 10 个反面，你不能说我抛了 1000 次）。</p>
<p><code>HyperLogLog</code> 引入了 分桶（Bucketing）的概念：</p>
<ol>
<li>把数据分成 $m$ 个桶。</li>
<li>分别统计每个桶里的最大前导 0。</li>
<li>对这些桶的结果求<strong>调和平均数</strong>（而不是算术平均数），以消除极端值（离群点）的影响。</li>
</ol>
<blockquote>
<p>算术平均数（Arithmetic Mean）我们都懂：加起来除以 N。</p>
<p>$$
A = \frac{x_1 + x_2}{2}
$$</p>
<p>调和平均数是：倒数的平均数，再取倒数。</p>
<p>$$
H = \frac{2}{\frac{1}{x_1} + \frac{1}{x_2}}
$$</p>
<p>调和平均数对小数值更敏感，而对大数值（离群值）不敏感。Redis 就是通过这 16384 个桶的调和平均数，把&quot;运气&quot;成分对冲掉，最终让误差稳定在 <strong>0.81%</strong> 左右。</p>
</blockquote>
<p>Redos 内部使用字符串 Bitmap 来存储 <code>HyperLogLog</code> 所有桶的计数值，一共包含 $2^{14}=16384$ 个桶，每个桶都是一个 6bit 的数组。<code>HyperLogLog</code> 的头部定义位于：<a href="https://github.com/redis/redis/blob/8.4.0/src/hyperloglog.c#L181">src/hyperloglog.c</a></p>
<pre><code class="language-c">#define HLL_P 14 /* The greater is P, the smaller the error. */
#define HLL_Q (64-HLL_P) /* The number of bits of the hash value used for
                            determining the number of leading zeros. */
#define HLL_REGISTERS (1&lt;&lt;HLL_P) /* With P=14, 16384 registers. */
#define HLL_P_MASK (HLL_REGISTERS-1) /* Mask to index register. */
#define HLL_BITS 6 /* Enough to count up to 63 leading zeroes. */

/* HyperLogLog 头部：
 * magic：固定标识 &quot;HYLL&quot;，用于持久化格式识别。
 * encoding：两种存储模式
 *   - HLL_DENSE：每个寄存器 6 bit，16K 寄存器共约 12288 字节，元素多时内存固定。
 *   - HLL_SPARSE：稀疏 RLE/指令流压缩，元素少时占用小，超过阈值自动转为 DENSE。
 * notused[3]：保留位，必须为 0。
 * card[8]：缓存的基数估计值（小端），为 0 表示缓存失效，下次查询重算并写回。
 * registers[]：寄存器数据区；
 *	 - DENSE 为紧凑 6-bit 数组。
 *	 - SPARSE 为指令序列（ZERO/VAL/XZERO 等表示连续寄存器段）。
 */
struct hllhdr {
    char magic[4];      /* &quot;HYLL&quot; */
    uint8_t encoding;   /* HLL_DENSE or HLL_SPARSE. */
    uint8_t notused[3]; /* Reserved for future use, must be zero. */
    uint8_t card[8];    /* Cached cardinality, little endian. */
    uint8_t registers[]; /* Data bytes. */
};
</code></pre>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251209231456770.png" style="zoom: 33%;" />

<p>当执行 <code>PFADD key element</code> 时：</p>
<ol>
<li><strong>Hash</strong>：Redis 使用 MurmurHash64A 算法将 <code>element</code> 转换为一个 <strong>64 位的整数</strong>（比特串）。</li>
<li><strong>分桶</strong>：Redis 使用这个 64 位整数的 <strong>前 14 位</strong> 作为桶的索引（Register Index）。$2^{14} = 16384$，所以才说 Redis 内部维护了 16384 个桶（<code>registers[]</code>）。</li>
<li><strong>计数</strong>：使用剩下的 <strong>50 位</strong> (64 - 14) 来计算前导 0 的个数，因为只有 50 位，所以 6 个 bit（$2^6 = 64 &gt; 50$）就足够了。如果算出来前导 0 是 $k$，就把第 $index$ 个桶的值更新为 $\max(\text{当前值}, k+1)$。</li>
</ol>
<p>Redis 为了省内存，也不会一上来就申请 12KB，所以 <code>registers[]</code> 存在两种编码格式：</p>
<ul>
<li><strong>稀疏编码 (Sparse)</strong>：当 HLL 刚创建，或者元素很少时，Redis 使用一种类似 RLE (Run-Length Encoding) 的压缩格式。比如&quot;连续 100 个桶都是 0&quot;，它就存&quot;100 个 0&quot;，而不是存 100 个 0 的实际数据。</li>
<li><strong>密集编码 (Dense)</strong>：当数据多了，稀疏编码存不下了，就会转换成标准的 12KB 密集数组。</li>
</ul>
<h2>8. Bloom Filter</h2>
<p>Bloom Filter（布隆过滤器）是由 Burton Howard Bloom 于 1970 年提出的，它是一种 space efficient 的概率型数据结构，用于判断一个元素是否在集合中。通常用于快速判断某个元素是否可能存在于一个大型数据集中，而无需实际存储整个数据集。</p>
<p>在 Redis 8 之后，Bloom Filter 已经内置在 Redis 中了。在这之前，它不是 Redis 的标准功能，而是通过 Redis 4.0+ 引入 Module 插件机制安装的，详细可参考 <a href="https://github.com/RedisBloom/RedisBloom">GitHub 丨 RedisBloom</a>。</p>
<h3>8.1 核心操作</h3>
<pre><code class="language-bash"># 创建布隆过滤器
BF.ADD key item1 item2 item3       # 添加元素（自动创建过滤器）
BF.EXISTS key item                 # 检查元素是否存在
BF.MADD key item1 item2 item3      # 批量添加元素
BF.MEXISTS key item1 item2 item3   # 批量检查元素

# 手动创建并配置
BF.RESERVE key error_rate capacity [EXPANSION expansion] [NONSCALING]
  # error_rate: 期望的错误率（如0.01表示1%）
  # capacity: 期望的元素数量
  # EXPANSION: 扩容因子（默认2）
  # NONSCALING: 禁用自动扩容

# 高级操作
BF.INSERT key [CAPACITY cap] [ERROR error] [NOCREATE] ITEMS item1 item2 ...  # 插入元素
BF.INFO key                        # 查看过滤器信息
BF.CARD key                        # 获取过滤器中元素数量（近似值）
BF.SCANDUMP key iterator           # 用于数据备份和恢复

# 计数布隆过滤器（Cuckoo Filter替代方案）
CF.ADD key item                    # 添加元素到计数过滤器
CF.ADDNX key item                  # 仅当元素不存在时添加
CF.EXISTS key item                 # 检查元素是否存在
CF.DEL key item                    # 删除元素（支持删除）
CF.COUNT key item                  # 获取元素计数
</code></pre>
<p><strong>常见使用场景：</strong></p>
<ul>
<li><strong>缓存穿透防护</strong>：查询数据库前先用布隆过滤器检查 key 是否存在，避免无效查询穿透到数据库</li>
<li><strong>URL 去重</strong>：爬虫系统中判断 URL 是否已经爬取过，避免重复爬取</li>
<li><strong>用户注册</strong>：检查用户名/邮箱是否已被注册，快速响应</li>
<li><strong>垃圾邮件过滤</strong>：维护已知垃圾邮件发送者黑名单，快速过滤</li>
<li><strong>数据库分片</strong>：快速判断某个 key 应该存储在哪个分片上</li>
<li><strong>推荐系统</strong>：过滤用户已经看过的内容，避免重复推荐</li>
<li><strong>安全防护</strong>：IP 黑名单、恶意请求过滤等安全场景</li>
<li><strong>数据同步</strong>：分布式系统中判断数据是否需要同步，减少网络传输</li>
</ul>
<h3>8.2 底层原理</h3>
<p>布隆过滤器的数据结构底层其实就是一个巨大的 <code>Bitmap</code>。通过一组哈希函数，将同一个元素计算出来的多个哈希值对应的 bit 位置为 <code>1</code>，从而判断一个元素是否存在：</p>
<ul>
<li>如果存在 bit 位为 <code>0</code> 的：则该元素<strong>一定不存在</strong>。</li>
<li>如果不存在 bit 位为 <code>0</code> 的：则该元素<strong>大概率存在</strong>。</li>
</ul>
<p>所以很显然，Bloom Filter 是不支持删除元素的。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/add-item-bloom-filter.webp" alt=""></p>
<h2>9. Geospatial</h2>
<p>Redis Geospatial 是 Redis 3.2 引入的一组用于处理地理位置信息的命令。它允许我们存储地理坐标（经度和纬度），并执行诸如&quot;计算两点距离&quot;、&quot;查找附近的人&quot;等 LBS（Location-Based Service）操作。</p>
<h3>9.1 核心操作</h3>
<pre><code class="language-bash"># 基础操作
GEOADD key longitude latitude member [longitude latitude member ...]  # 添加地理位置
GEOPOS key member1 member2 ...     # 获取指定位置的坐标
GEODIST key member1 member2 [unit]  # 计算两个位置之间的距离

# 单位参数：m(米)、km(千米)、mi(英里)、ft(英尺)
GEODIST cities beijing shanghai km  # 计算北京到上海的公里距离

# 范围查询（重要）
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
GEORADIUSBYMEMBER key member radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]

# 查询选项说明：
# WITHCOORD: 返回坐标
# WITHDIST: 返回距离
# WITHHASH: 返回geohash值
# COUNT: 限制返回数量
# ASC|DESC: 按距离排序
# STORE: 存储结果到有序集合
# STOREDIST: 存储距离到有序集合

# 搜索示例
GEORADIUS restaurants 116.397128 39.916527 5 km WITHDIST WITHCOORD COUNT 10
GEORADIUSBYMEMBER restaurants starbucks 2 km WITHDIST

# 哈希操作
GEOHASH key member1 member2 ...  # 获取位置的 geohash 字符串

# 有序集合操作（底层是zset）
ZSCORE key member               # 获取位置的geohash分数
ZRANGE key start stop WITHSCORES  # 获取范围位置和分数
ZREM key member1 member2         # 删除位置
ZCARD key                       # 获取位置数量

# 批量操作（Redis 6.2+）
GEOSEARCH key [FROMMEMBER member | FROMLONLAT longitude latitude] [BYRADIUS radius m|km|ft|mi | BYBOX width height m|km|ft|mi] [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC]
</code></pre>
<p><strong>常见使用场景：</strong></p>
<ul>
<li><strong>附近的人</strong>：社交应用查找附近用户，<code>GEORADIUS</code> 查找指定范围内的用户</li>
<li><strong>附近商家</strong>：生活服务平台查找附近餐厅、酒店、银行等</li>
<li><strong>打车服务</strong>：查找附近司机、计算预估距离和费用</li>
<li><strong>外卖配送</strong>：查找附近商家、计算配送距离和时间</li>
<li><strong>位置签到</strong>：记录用户位置，实现签到功能</li>
<li><strong>地理围栏</strong>：设定虚拟地理边界，进入或离开时触发事件</li>
<li><strong>位置营销</strong>：基于地理位置的精准营销和广告推送</li>
<li><strong>物流追踪</strong>：实时追踪包裹位置，查找附近配送点</li>
<li><strong>旅游导航</strong>：查找附近景点、计算景点间距离</li>
<li><strong>紧急服务</strong>：查找附近的医院、警察局等紧急服务点</li>
</ul>
<p><strong>实用示例：</strong></p>
<pre><code class="language-bash"># 添加城市位置
GEOADD cities 116.397128 39.916527 beijing
GEOADD cities 121.473701 31.230416 shanghai
GEOADD cities 113.264385 23.129112 guangzhou

# 计算距离
GEODIST cities beijing shanghai km  # 输出：1067.5

# 查找北京500km内的城市
GEORADIUS cities 116.397128 39.916527 500 km WITHDIST
</code></pre>
<h3>9.2 底层原理</h3>
<p>Redis Geo 并没有引入新的底层数据结构，其本质上是一个 <code>Sorted Set</code>。</p>
<p>地理位置是一个二维坐标 <code>(经度, 纬度)</code>，而 Redis 的 ZSet 只能按一个一维的 <code>score</code> 进行排序。为了复用 ZSet 的排序和范围查找能力，必须将二维坐标映射为一维整数。</p>
<p><strong>GeoHash</strong> 就是这种映射算法。它将地球表面划分为无数个矩形网格，并利用 Z-Order Curve（Z 阶曲线）将二维网格编码为一维的二进制串。GeoHash 的编码过程就是<u>将经度和纬度分别二分逼近，大于中间值记为 1，小于记为 0。然后将经纬度的二进制位交错组合（偶数位放精度，奇数位放纬度），生成最终的 52 位整数。</u></p>
<p>当我们要查询&quot;附近的人&quot;时，实际上是在 ZSet 中查找 <code>score</code>（GeoHash 值）相近的元素。</p>
<p>但是，GeoHash 存在一个著名的<strong>边界问题</strong>：两个点可能直线距离很近，但刚好位于两个格子的边缘，导致它们的 GeoHash 值差异巨大（例如一个在 Z 曲线的末端，一个在起端）。</p>
<p>为了解决这个问题，Redis 在执行 <code>GEORADIUS</code> 时，不仅会搜索中心点所在的那个格子（GeoHash 范围），还会自动计算并搜索<strong>周围的 8 个邻居格子</strong>。</p>
<p>我让 Gemini 绘制了一个网页，用于辅助理解，感兴趣的读者可自行下载参考。</p>
<blockquote>
<p><a href="https://github.com/hedon954/geohash-display">https://github.com/hedon954/geohash-display</a></p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251210100627954.png" alt=""></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251210101309164.png" alt=""></p>
<h2>10. PubSub</h2>
<p>PubSub（Publish/Subscribe，发布/订阅）是一种消息通信模式。发送者（Pub）将消息发送到频道（Channel），而不用知道谁订阅了它；订阅者（Sub）接收感兴趣频道的消息，而不用知道谁发送了它。</p>
<p>Pub/Sub 的核心逻辑是 <strong>广播 (Broadcasting)</strong>。 它和 List/Stream 最大的区别在于：<strong>它不存储消息</strong>。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251210095612077.png" alt=""></p>
<h3>10.1 核心操作</h3>
<pre><code class="language-bash"># 发布订阅操作
PUBLISH channel message         # 向指定频道发布消息
SUBSCRIBE channel1 channel2 ... # 订阅一个或多个频道
UNSUBSCRIBE channel1 channel2 ...  # 取消订阅指定频道

# 模式订阅
PSUBSCRIBE pattern1 pattern2 ...   # 订阅匹配模式的频道（支持通配符*和?）
PUNSUBSCRIBE pattern1 pattern2 ... # 取消模式订阅

# 查询操作
PUBSUB CHANNELS [pattern]         # 列出活跃的频道（支持模式匹配）
PUBSUB NUMSUB [channel1 channel2 ...]  # 查看指定频道的订阅者数量
PUBSUB NUMPAT                     # 查看模式订阅的数量

# 实用示例
# 发布者
PUBLISH news:sports &quot;中国队获得冠军！&quot;
PUBLISH notifications:user:1001 &quot;您有新的消息&quot;

# 订阅者
SUBSCRIBE news:sports
SUBSCRIBE notifications:user:1001

# 模式订阅（订阅所有新闻频道）
PSUBSCRIBE news:*
PSUBSCRIBE notifications:user:*

# 查看频道状态
PUBSUB NUMSUB news:sports notifications:user:1001
PUBSUB CHANNELS news:*
</code></pre>
<p><strong>常见使用场景：</strong></p>
<ul>
<li><strong>实时通知</strong>：系统通知、消息推送、事件提醒等实时通知场景</li>
<li><strong>聊天室</strong>：多用户实时聊天系统，群聊广播消息</li>
<li><strong>实时数据推送</strong>：股票价格、比赛比分、实时监控数据推送</li>
<li><strong>事件驱动架构</strong>：微服务间的事件通信，解耦生产者和消费者</li>
<li><strong>缓存失效</strong>：当数据更新时，通过 PubSub 通知缓存失效，实现缓存一致性</li>
<li><strong>日志收集</strong>：应用日志实时收集和分发到日志处理系统</li>
<li><strong>监控系统</strong>：系统告警、性能指标实时推送</li>
<li><strong>配置更新</strong>：分布式系统中的配置变更通知</li>
<li><strong>游戏实时通信</strong>：多人游戏中的状态同步、位置更新等</li>
</ul>
<h3>10.2 底层原理</h3>
<p>Redis 服务器结构体 <code>redisServer</code> 中维护了关于 Pub/Sub 的重要字段：</p>
<pre><code class="language-c">/* src/server.h */
struct redisServer {
  kvstore *pubsub_channels;  /* Map channels to list of subscribed clients */
  dict *pubsub_patterns;  /* A dict of pubsub_patterns */
  int notify_keyspace_events; /* Events to propagate via Pub/Sub. This is an
                                 xor of NOTIFY_... flags. */
  kvstore *pubsubshard_channels;  /* Map shard channels in every slot to list of subscribed clients */
  unsigned int pubsub_clients; /* # of clients in Pub/Sub mode */
}
</code></pre>
<p><code>kvstore *pubsub_channels</code>:</p>
<ul>
<li><strong>普通频道订阅关系表</strong>。Key 是频道名 (Channel)，Value 是一个链表，存储了所有订阅该频道的客户端 (Clients)。对应 <code>SUBSCRIBE</code> 和 <code>PUBLISH</code> 命令。</li>
<li>在 Redis 7.0 之前，这里是一个 <code>dict *</code>（字典）。但在新版本中，它升级为了 <strong><code>kvstore *</code></strong>。在 Redis Cluster 模式下，普通的 Pub/Sub 是<strong>广播</strong>的（一个节点收到消息，会转发给集群内所有节点）。<code>kvstore</code> 是 Redis 内部对&quot;支持分槽（Slot）的字典&quot;的抽象。虽然普通的 Pub/Sub 是广播的，但使用 <code>kvstore</code> 统一了内部的数据结构接口，方便管理和扩容。</li>
</ul>
<p><code>dict *pubsub_patterns</code>:</p>
<ul>
<li><strong>模式订阅关系表</strong>。Key 是匹配模式 (Pattern，如 <code>news.*</code>)，Value 是订阅了该模式的客户端列表。对应 <code>PSUBSCRIBE</code> 和 <code>PPUBLISH</code> 命令。</li>
<li>当有消息发布到 <code>news.sports</code> 频道时，Redis 不仅要查 <code>pubsub_channels</code>，还要遍历这个 <code>pubsub_patterns</code> 字典，看 <code>news.sports</code> 是否匹配其中的模式。</li>
</ul>
<p><code>int notify_keyspace_events</code>:</p>
<ul>
<li><strong>键空间通知配置</strong>。这是一个 <strong>Bitmask (位掩码)</strong> 整数。对应 <code>redis.conf</code> 配置中的 <code>notify-keyspace-events</code>（如 &quot;Ex&quot; 代表过期事件）。</li>
<li>Redis 的键空间通知（Keyspace Notifications）本质上就是利用 Pub/Sub 机制实现的。当一个 Key 被修改或过期时，如果这个掩码中对应的位被置为 1，Redis 就会自动向特定的频道（如 <code>__keyevent@0__:expired</code>）发送一条 Pub/Sub 消息。</li>
</ul>
<p><code>kvstore *pubsubshard_channels</code>:</p>
<ul>
<li><strong>分片频道订阅关系表 (Sharded Pub/Sub)</strong>。对应 <code>SSUBSCRIBE</code> 和 <code>SPUBLISH</code> 命令 (Redis 7.0 新增)。</li>
<li>普通的 Pub/Sub 在集群中是<strong>全量广播</strong>的。你在节点 A 发布消息，节点 A 会把它转发给 B、C、D... 即使 B、C、D 上根本没有客户端订阅这个频道。这造成了巨大的网络带宽浪费（写放大）。</li>
<li><strong>Sharded Pub/Sub</strong> 将频道名通过 Hash 算法映射到具体的 <strong>Slot (槽)</strong>，就像普通的 Key 一样。消息只会被发送到<strong>持有该 Slot 的节点</strong>，不再全网广播。这里的 <strong><code>kvstore</code></strong> 发挥了关键作用：它能根据 Slot 快速索引和管理频道。这是 Redis 7.0 针对集群 Pub/Sub 性能优化的一大体现。</li>
</ul>
<p><code>unsigned int pubsub_clients</code>:</p>
<ul>
<li><strong>当前的 Pub/Sub 客户端总数</strong>。用于 <code>INFO</code> 命令统计和监控。</li>
<li>Redis 需要知道当前有多少个连接处于&quot;订阅状态&quot;，因为处于订阅状态的客户端，其 socket 读取逻辑和普通客户端略有不同（只能发送订阅相关命令）。</li>
</ul>
<blockquote>
<p>[!WARNING]</p>
<p>注意，虽然 Pub/Sub 不保存消息，但是为了保证把消息推给（在线的）订阅者，会把积压的消息暂时存在该订阅者 Client 的 <strong>输出缓冲区 (Output Buffer)</strong> 中。如果消费者处理得很慢，导致积压越来越多，这个缓冲区会无限膨胀，最终 OOM。</p>
<p>Redis 默认配置了保护策略 <code>client-output-buffer-limit</code>：如果缓冲区超过 32MB，或者持续 60 秒超过 8MB，Redis 会<strong>强制断开</strong>这个订阅者的连接。订阅者断连，消息丢失，但 Redis 活下来了。</p>
</blockquote>
<h2>11. Stream</h2>
<p>Redis Stream 是 Redis 5.0 引入的最重量级特性，它的出现是为了填补 Redis 在消息队列领域的最后一块短板——<strong>持久化与可靠性</strong>。</p>
<p>在此之前，使用 Redis 做消息队列主要有两种方式，但都有致命缺陷：</p>
<ol>
<li><strong>List (<code>BLPOP</code>)</strong>：无法支持多播；更致命的是，它没有 <strong>ACK 机制</strong>。消息一旦从队列中 <code>POP</code> 出来，如果消费者处理时宕机，这条消息就永久丢失了。</li>
<li><strong>Pub/Sub</strong>：支持多播，但它是 <strong>Fire and Forget</strong>（即发即弃）模式。如果消费者下线，期间发送的所有消息都会丢失，且无法回溯。</li>
</ol>
<p>Stream 的设计灵感深受 Kafka 影响，它不仅支持多播（消费组），还引入了 <strong>PEL (Pending Entries List)</strong> 机制来保证消息的绝对不丢失，同时在底层结构上针对&quot;时间序列 ID&quot;做了极致的内存压缩。</p>
<h3>11.1 核心操作</h3>
<pre><code class="language-bash"># 基础操作
XADD key [MAXLEN len [~]] [NOMKSTREAM] *|ID field value [field value ...]  # 添加消息
XLEN key                              # 获取流中消息数量
XRANGE key start end [COUNT count]    # 读取范围内的消息（正向）
XREVRANGE key end start [COUNT count] # 读取范围内的消息（反向）

# 消息读取
XREAD [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key1 key2 ... ID1 ID2 ...
# ID选项：$（最新消息）、&gt;（消费者组新消息）、具体时间戳ID

# 消费组操作
XGROUP [CREATE key groupname id|$] [MKSTREAM]           # 创建消费组
XGROUP [DESTROY key groupname]                         # 销毁消费组
XGROUP [SETID key groupname id|$]                      # 设置消费组最后消息ID
XGROUP [DELCONSUMER key groupname consumername]        # 删除消费者
XINFO [GROUPS key]                                     # 查看消费组信息
XINFO [CONSUMERS key groupname]                        # 查看消费者信息
XINFO [STREAM key] [FULL]                              # 查看流详细信息

# 消费者读取
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key1 key2 ... ID1 ID2 ...
# ID选项：&gt;（新消息）、0（所有未处理消息）、具体ID

# 消息确认
XACK key group ID [ID ...]                            # 确认消息已处理
XPENDING key group [start end count consumer]         # 查看待处理消息
XCLAIM key group consumer min_idle_time ID [ID ...] [IDLE ms] [TIME ms] [RETRYCOUNT count] [FORCE] [JUSTID] [LASTID id]
XDEL key ID [ID ...]                                  # 删除消息

# 修剪操作
XTRIM key MAXLEN len [~]                              # 修剪流到指定长度
XTRIM key MINID threshold [~]                         # 删除小于指定ID的消息

# 实用示例
# 创建流并添加消息
XADD mystream * name &quot;张三&quot; age 25
XADD mystream * name &quot;李四&quot; age 30

# 读取消息
XREAD COUNT 2 STREAMS mystream 0-0

# 创建消费组
XGROUP CREATE mystream mygroup $ MKSTREAM

# 消费者读取
XREADGROUP GROUP mygroup consumer1 COUNT 1 STREAMS mystream &gt;

# 确认消息
XACK mystream mygroup 1672502400000-0
</code></pre>
<p><strong>常见使用场景：</strong></p>
<ul>
<li><strong>消息队列</strong>：替代 RabbitMQ/Kafka，处理异步任务、事件驱动架构</li>
<li><strong>事件溯源</strong>：记录系统状态变更历史，支持事件回放和时间旅行查询</li>
<li><strong>日志收集</strong>：应用日志的可靠收集和转发，支持消费者组并行处理</li>
<li><strong>监控告警</strong>：系统监控指标的时序数据存储和分析</li>
<li><strong>数据管道</strong>：ETL 过程中的数据缓冲和批处理</li>
<li><strong>实时分析</strong>：用户行为分析、点击流处理等实时数据流处理</li>
<li><strong>IoT 数据</strong>：物联网设备数据的时序存储和处理</li>
<li><strong>金融交易</strong>：交易日志记录、审计追踪，支持精确的时间序列查询</li>
<li><strong>游戏系统</strong>：游戏事件记录、玩家行为分析</li>
<li><strong>聊天系统</strong>：可靠的消息投递，支持离线消息和消息重试</li>
</ul>
<h3>11.2 底层原理</h3>
<p>Stream 本质上是一个 <strong>仅追加 (Append-only)</strong> 的日志结构。为了满足高效的范围查找（按时间范围读消息）和极致的内存节省，Redis 并没有使用简单的链表或数组，而是设计了一种混合结构：<strong>Radix Tree (基数树) + Listpack</strong>。</p>
<h4>11.2.1 stream</h4>
<p>Stream 定义在 <a href="https://github.com/redis/redis/blob/8.4.0/src/stream.h#L16">src/stream.h</a>，负责维护消息的元数据、存储引擎（Rax）入口以及消费组状态。</p>
<pre><code class="language-c">/* Stream 核心结构体
 * 位于 src/stream.h */
typedef struct stream {
    /* ---- 数据存储区 ---- */
    rax *rax;               /* 核心存储引擎：Radix Tree。
                             * Key: 消息 ID (大端序二进制)
                             * Value: 指向一个 Listpack 的指针 (每个 Listpack 存多条消息) */

    uint64_t length;        /* 当前 Stream 中的有效消息数量 (不包含已删除的消息) */

    streamID last_id;       /* 当前 Stream 中最后一条消息的 ID (若为空则为 0-0) */
    streamID first_id;      /* 当前 Stream 中第一条消息的 ID (若为空则为 0-0) */

    /* ---- 统计与清理 ---- */
    streamID max_deleted_entry_id;  /* 历史上被删除的最大消息 ID (用于协议兼容) */
    uint64_t entries_added;         /* 历史上累计写入过的消息总数 (即 ID 序列号计数器) */
    size_t alloc_size;              /* Stream 自身占用的堆内存大小 (不含 key 本身) */

    /* ---- 消费组管理 ---- */
    rax *cgroups;           /* 消费组字典。
                             * Key: 消费组名称 (String)
                             * Value: streamCG 结构体指针 */

    rax *cgroups_ref;       /* 辅助索引：用于加速 NACK 处理等场景 */

    streamID min_cgroup_last_id;    /* 缓存：所有消费组中，进度最慢的那个 last_id。
                                     * 用于垃圾回收，判断哪些数据可以安全清理 */
    unsigned int min_cgroup_last_id_valid: 1; /* 缓存是否有效标记 */
} stream;
</code></pre>
<h4>11.2.2 streamID</h4>
<p>Stream 的消息 ID 默认是自动生成的，格式为 <code>&lt;时间戳&gt;-&lt;序列号&gt;</code>（例如 <code>1702108800000-0</code>）。</p>
<pre><code class="language-c">typedef struct streamID {
    uint64_t ms;        /* Unix time in milliseconds. */
    uint64_t seq;       /* Sequence number. */
} streamID;
</code></pre>
<p>观察这组 ID，你会发现一个显著特征：<strong>前缀高度重复</strong>。在同一毫秒甚至同一秒内产生的消息，其 ID 的高位部分是完全相同的。如果使用普通的 Hash 表或跳表存储，这些重复的前缀会浪费大量内存。</p>
<h4>11.2.3 Radix Tree</h4>
<p><strong>Radix Tree (Rax)</strong> 是一种前缀树。它将公共前缀提取出来作为父节点，差异部分作为子节点。这种结构天然适合存储时间序列数据，极大地压缩了索引空间。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/1280px-Radix_tree.svg.png" alt="undefined" style="zoom: 25%;" />

<blockquote>
<p>这并非标准的 Radix Tree 的实现，标准的 Radix Tree 一个节点只有一个字符，当然这对于 Stream 这个场景依旧是极大的浪费，所以一个改进方案就是将多个相同前缀的字符合并在一个作为一个共享节点。</p>
<p>事实上 Go 的 Gin 框架的路由树也是采取的这种策略，具体可参考：<a href="https://hedon.top/2025/06/30/go/go-gin/">Go 底层原理丨深度剖析 Gin 框架核心机制：从 HTTP 请求生命周期到高性能设计哲学</a>。</p>
</blockquote>
<p>如果 Rax 的每个叶子节点只挂一条消息，那指针开销依然很大。Redis 再次运用了&quot;打包&quot;的思想。</p>
<p>Stream 在 Rax 的节点中，并不直接存储单个消息，而是存储一个 <code>listpack</code>（又来了，神奇的 listpack）。一个 Rax 节点（称为宏节点）可能包含几十条甚至上百条消息。</p>
<ul>
<li><strong>宏观上</strong>：利用 Radix Tree 对 ID 前缀进行压缩，支持 $O(\log N)$ 的时间范围查找。</li>
<li><strong>微观上</strong>：利用 Listpack 对具体的消息内容（Field-Value）进行紧凑存储，利用 CPU 缓存行并减少内存碎片。</li>
</ul>
<blockquote>
<p>[!IMPORTANT]</p>
<p>这再次印证了 Redis 的设计哲学：在大规模索引上使用树/跳表（空间换时间），在局部小数据块上使用紧凑数组（时间换空间）。</p>
</blockquote>
<p>Radix Tree 定义在 <a href="https://github.com/redis/redis/blob/8.4.0/src/rax.h#L78">src/rax.h</a>。</p>
<pre><code class="language-c">/* Rax (Radix Tree) 头部 */
typedef struct rax {
    raxNode *head;          /* 指向根节点的指针 */
    uint64_t numele;        /* 树中存储的元素总数 (即 Key 的数量) */
    uint64_t numnodes;      /* 树中节点的总数 */
    size_t *alloc_size;     /* 指向外部变量的指针，用于统计该树占用的总内存 */
    void *metadata[];       /* 可选的元数据区域 (通常用于填充对齐) */
} rax;

/* Rax 节点 */
typedef struct raxNode {
    /* ---- 位域标志头 (4字节) ---- */
    uint32_t iskey:1;     /* 1 表示这就到达了一个 Key 的终点 (包含 Value) */
    uint32_t isnull:1;    /* 1 表示 Value 为 NULL (即使 iskey=1) */
    uint32_t iscompr:1;   /* 1 表示这是压缩节点 (Compressed)，0 表示非压缩 (Normal) */
    uint32_t size:29;     /* 节点负载大小：
                           * - 若 iscompr=1: 表示压缩后缀的长度 (字节数)
                           * - 若 iscompr=0: 表示子节点的数量 (边数) */

    /* ---- 柔性数组：数据负载区 ----
     * 这里的布局根据 iscompr 的值完全不同
     */
    unsigned char data[];
} raxNode;
</code></pre>
<p>由于 <code>data[]</code> 是变长的，C 语言无法直接描述其结构，其内存布局如下：</p>
<p><strong>1. 压缩节点（iscompr = 1）</strong></p>
<p>这意味着这是一条<strong>单行道</strong>。 当前节点只有一个子节点，且中间经过了一串字符。 比如：从节点 A 到节点 B，中间的路径字符串是 <code>&quot;QD&quot;</code>。</p>
<p><code>data[]</code> 的内存布局：它像三明治一样，把<strong>路径字符串”<strong>和</strong>唯一的子节点指针</strong>紧挨着放。</p>
<pre><code>+-----------------------+ &lt;-- 节点起始地址
| Header (4 Bytes)      | iskey=1, iscompr=1, size=2
+-----------------------+
| &#39;Q&#39; (1 Byte)          | \
+-----------------------+  &gt; 压缩字符串 &quot;QD&quot;
| &#39;D&#39; (1 Byte)          | /
+-----------------------+
| Child Ptr (8 Bytes)   | --&gt; 指向下一个 raxNode (代表 ID 后续部分的节点)
+-----------------------+
| Value Ptr (8 Bytes)   | --&gt; 指向 Listpack (存储消息内容) [仅当 iskey=1 时存在]
+-----------------------+
</code></pre>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251210002143921.png" style="zoom:33%;" />

<p><strong>2. 非压缩节点（iscompr = 0）</strong></p>
<p>这意味着这是一个<strong>十字路口</strong>（分叉点）。 当前节点有多个子节点。 比如：节点 A 下面分出了 <code>&#39;A&#39;</code>, <code>&#39;B&#39;</code>, <code>&#39;C&#39;</code> 三条路。</p>
<p><strong><code>data[]</code> 的内存布局</strong>： 它把**所有的路牌（字符）<strong>放一起，把</strong>所有的路（指针）**放一起。</p>
<pre><code>+-----------------------+ &lt;-- 节点起始地址
| Header (4 Bytes)      | iskey=1, iscompr=0, size=3
+-----------------------+
| &#39;A&#39; (1 Byte)          | \
+-----------------------+  |
| &#39;B&#39; (1 Byte)          |  &gt; 子节点索引字符 (共3个)
+-----------------------+  |
| &#39;C&#39; (1 Byte)          | /
+-----------------------+
| Padding (X Bytes)     | &lt;-- 内存对齐填充 (视当前偏移量而定，确保后续指针地址对齐)
+-----------------------+
| Child Ptr 1 (8 Bytes) | --&gt; 指向 &#39;A&#39; 分支的下一个 raxNode
+-----------------------+
| Child Ptr 2 (8 Bytes) | --&gt; 指向 &#39;B&#39; 分支的下一个 raxNode
+-----------------------+
| Child Ptr 3 (8 Bytes) | --&gt; 指向 &#39;C&#39; 分支的下一个 raxNode
+-----------------------+
| Value Ptr (8 Bytes)   | --&gt; 指向 Listpack (存储消息内容) [仅当 iskey=1 时存在]
+-----------------------+
</code></pre>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251210002239926.png" style="zoom:33%;" />

<h4>11.2.4 streamCG</h4>
<p>Stream 能替代 List 成为企业级消息队列的核心，在于它引入了<strong>消费组 (Consumer Group)</strong> 和 <strong>PEL (Pending Entries List)</strong>。</p>
<p>当消费者调用 <code>XREADGROUP</code> 读取消息时：</p>
<ol>
<li>消息<strong>不会</strong>从 Stream 中删除（这点与 List 不同）。</li>
<li>消息 ID 会被加入到该消费者的 <strong>PEL</strong> 中。</li>
<li>只有当消费者显式调用 <code>XACK</code> 后，Redis 才会把该 ID 从 PEL 中移除。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/8887.1571235190.png" alt=""></p>
<p>这背后的核心数据结构是 <code>streamCG</code>。</p>
<pre><code class="language-c">/* Consumer group. */
typedef struct streamCG {
    streamID last_id;       /* 该消费组最后一次交付（但未确认）的消息 ID。
                               当消费者请求“新消息”时，Redis 会提供大于此 ID 的消息。 */

    long long entries_read; /* 统计信息：该组读取的消息总数 */

    rax *pel;               /* Pending Entries List (待处理列表)。
                               这是一个 Radix Tree。
                               Key: 消息 ID (64位大端序)
                               Value: streamNACK 结构 (记录了投递次数、最后投递时间等)
                               作用: 记录所有已发给消费者但尚未 ACK 的消息。 */

    rax *pel_by_time;       /* 辅助索引：按“投递时间”排序的 PEL。
                               Key: pelTimeKey (包含 delivery_time + stream ID)
                               Value: NULL (所有信息都在 Key 里)
                               作用: 加速超时消息的查询 (如 XPENDING ... IDLE &lt;time&gt;)。 */

    rax *consumers;         /* 消费者字典。
                               Key: 消费者名称
                               Value: streamConsumer 结构 */
} streamCG;
</code></pre>
<p>每个消费组都有一个全局游标 <code>last_id</code>。</p>
<ul>
<li>当你使用 <code>XREADGROUP GROUP mygroup alice &gt;</code> 时，那个 <code>&gt;</code> 符号实际上就是告诉 Redis：&quot;请把 <code>last_id</code> 之后的消息发给我&quot;。</li>
<li>Redis 发送消息后，会更新 <code>last_id</code>。这保证了在同一个组内，消息不会被重复消费（除非显式回溯）。</li>
</ul>
<p>如下图所示，如果我们把 Radix Tree 摊平来看，每个消费组有自己的游标位置，互不干扰，而 <code>stream</code> 结构中的 <code>min_cgroup_last_id</code> 字段会存储最满的 <code>last_id</code>，那前面的消息就可以删除了。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/5646.1571235119.png" alt=""></p>
<p><strong>PEL (Pending Entries List)</strong> 是 Stream 实现 <strong>At Least Once</strong> 的关键。<code>streamCG</code> 维护了一个名为 <code>pel</code> 的 Radix Tree。</p>
<ul>
<li><strong>写入时机</strong>：当消息被 <code>XREADGROUP</code> 读取但未 <code>XACK</code> 时，它会立即进入 <code>pel</code>。</li>
<li><strong>移除时机</strong>：只有收到 <code>XACK</code>，Redis 才会将该 ID 从 <code>pel</code> 中移除。</li>
</ul>
<p>如果消费者宕机，这条消息会永久停留在 <code>pel</code> 中。运维人员可以通过 <code>XPENDING</code> 命令查看到这些消息，并安排其他消费者进行 <strong>Claim (接管)</strong>。</p>
<p>仔细观察结构体，你会发现 <code>streamGC</code> 维护了<strong>两个</strong>与 PEL 相关的 Radix Tree：</p>
<pre><code class="language-c">/* Consumer group. */
typedef struct streamCG {
    rax *pel;
    rax *pel_by_time;
} streamCG;
</code></pre>
<p>这是一种典型的<strong>空间换时间</strong>设计：</p>
<ul>
<li><code>pel</code>: 按 ID 索引，Key 是 <code>消息 ID</code>。当消费者发送 <code>XACK &lt;id&gt;</code> 时，Redis 需要快速找到这条消息并标记完成。使用 ID 索引可以达到 $O(\log N)$ 的查找速度。</li>
<li><code>pel_by_time</code>: 按时间索引，Key 是 <code>投递时间 + 消息 ID</code>。用于故障恢复。当我们需要找出&quot;哪些消息已经超时 10 分钟没处理了？&quot;（即 <code>XPENDING ... IDLE &lt;time&gt;</code> 命令），Redis 不需要遍历整个 PEL，而是直接在 <code>pel_by_time</code> 这个时间树上进行范围查找。</li>
</ul>
<p>这种<strong>主键索引 + 辅助索引</strong>的设计，确保了 Stream 无论是<strong>正常确认 (ACK)</strong> 还是<strong>故障排查 (Pending Query)</strong>，都能保持极高的性能，不会因为积压消息过多而拖慢 Redis。</p>
<blockquote>
<p>[!WARNING]</p>
<p>但这不意味着 Redis Stream 就可以替代传统的 MQ 了，其本质上是受限于昂贵的内存容量和异步持久化机制，无法像基于磁盘的 Kafka 那样以低成本实现海量数据的长期堆积与金融级的零丢失保障。</p>
</blockquote>
<h2>12. 原理和应用场景总结</h2>
<p>Redis 的数据类型虽然丰富多样，但万变不离其宗。回顾全文，我们可以看到 Redis 在设计上始终在做<strong>两个维度的权衡（Trade-off）</strong>：</p>
<ol>
<li><strong>内存 vs CPU</strong>：在数据量少时，倾向于使用时间换空间的紧凑结构（如 Listpack、Intset），通过 CPU 的轮询计算来节省昂贵的内存；在数据量大时，倾向于使用空间换时间的索引结构（如 Hashtable、Skiplist），通过增加指针开销来保证 O(1) 或 O(logN) 的访问速度。</li>
<li><strong>精确 vs 概率</strong>：在处理海量数据统计时，为了突破物理内存的限制，引入了概率型数据结构（HLL、Bloom Filter），用极小的误差换取了巨大的空间收益。</li>
</ol>
<p>以下是全系数据类型的核心原理与选型指南：</p>
<table>
<thead>
<tr>
<th align="left">数据类型</th>
<th align="left">底层结构 (Encoding)</th>
<th align="left">设计权衡 (Trade-off)</th>
<th align="left">核心适用场景</th>
<th align="left">关键限制/注意</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>String</strong></td>
<td align="left"><strong>int</strong> / <strong>embstr</strong> / <strong>raw</strong> (SDS)</td>
<td align="left">根据长度动态分配头部，减少内存碎片。</td>
<td align="left">缓存、计数器、分布式锁、会话</td>
<td align="left">最大 512MB。</td>
</tr>
<tr>
<td align="left"><strong>List</strong></td>
<td align="left"><strong>Quicklist</strong> (Listpack 链表)</td>
<td align="left"><strong>宏观链表 + 微观数组</strong>。平衡了内存紧凑性与两端操作的灵活性。</td>
<td align="left">消息队列、最新 N 条动态、栈</td>
<td align="left">随机访问 (<code>LINDEX</code>) 慢，适合头尾操作。</td>
</tr>
<tr>
<td align="left"><strong>Hash</strong></td>
<td align="left"><strong>Listpack</strong> $\to$ <strong>Hashtable</strong></td>
<td align="left">小数据用紧凑数组（省内存但 O(N)），大数据转哈希表（快但费内存）。</td>
<td align="left">对象存储、购物车、配置项</td>
<td align="left">避免大 Key，大 Hash 迁移会阻塞（虽有渐进式 Rehash）。</td>
</tr>
<tr>
<td align="left"><strong>Set</strong></td>
<td align="left"><strong>Intset</strong> $\to$ <strong>Hashtable</strong></td>
<td align="left">纯整数且有序时极致压缩，一旦插入非整数<strong>不可逆</strong>转为哈希表。</td>
<td align="left">标签系统、共同好友、抽奖</td>
<td align="left">尽量存整数 ID 以利用 Intset 优化。</td>
</tr>
<tr>
<td align="left"><strong>ZSet</strong></td>
<td align="left"><strong>Listpack</strong> $\to$ <strong>Skiplist</strong>+<strong>Dict</strong></td>
<td align="left">双重结构：Dict 保证查询 O(1)，跳表保证范围 O(logN)。</td>
<td align="left">排行榜、延迟队列、范围查找</td>
<td align="left">内存占用较高（指针多），元素多时注意性能。</td>
</tr>
<tr>
<td align="left"><strong>Bitmap</strong></td>
<td align="left"><strong>String</strong> (位数组)</td>
<td align="left">用 Bit 表示状态，将 String 视为结构体数组 (<code>BITFIELD</code>)。</td>
<td align="left">日活统计、签到、用户状态</td>
<td align="left">操作稀疏数据时会浪费大量内存。</td>
</tr>
<tr>
<td align="left"><strong>HLL</strong></td>
<td align="left"><strong>String</strong> (Sparse/Dense)</td>
<td align="left"><strong>概率统计</strong>。用调和平均数消除离群值，12KB 统计亿级基数。</td>
<td align="left">UV 统计、独立 IP 数</td>
<td align="left">有 0.81% 误差，无法取出具体元素。</td>
</tr>
<tr>
<td align="left"><strong>Bloom</strong></td>
<td align="left"><strong>Bitmap</strong> + Hash 函数</td>
<td align="left"><strong>概率判存</strong>。绝无假阴性，但有假阳性。</td>
<td align="left">缓存穿透防护、黑名单校验</td>
<td align="left">不支持删除（除非用 Counting BF），需容忍误判。</td>
</tr>
<tr>
<td align="left"><strong>Geo</strong></td>
<td align="left"><strong>ZSet</strong> (GeoHash)</td>
<td align="left"><strong>降维打击</strong>。将二维坐标映射为一维整数，复用 ZSet 排序能力。</td>
<td align="left">附近的人、距离计算</td>
<td align="left">存在边界误差（需查 9 宫格），高纬度畸变。</td>
</tr>
<tr>
<td align="left"><strong>PubSub</strong></td>
<td align="left">Dict + LinkedList</td>
<td align="left"><strong>无状态广播</strong>。即发即弃，不存储数据。</td>
<td align="left">实时通知、配置刷新</td>
<td align="left">消费者断线即丢消息，无堆积能力。</td>
</tr>
<tr>
<td align="left"><strong>Stream</strong></td>
<td align="left"><strong>Radix Tree</strong> + <strong>Listpack</strong></td>
<td align="left"><strong>前缀压缩</strong>。针对时间序列 ID 优化，引入消费组和 PEL 保证可靠性。</td>
<td align="left">轻量级 MQ、事件溯源、日志</td>
<td align="left">内存型存储，不适合海量历史数据回溯。</td>
</tr>
</tbody></table>
<h2>参考</h2>
<ul>
<li><a href="https://github.com/redis/redis/blob/8.4.0">Redis 8.4.0</a></li>
<li><a href="https://redis.io/docs/latest/commands/">Redis Commands</a></li>
<li><a href="https://systemdesign.one/bloom-filters-explained/">Bloom Filters Explained</a></li>
<li><a href="https://en.wikipedia.org/wiki/Radix_tree">Wikipedia-Radix tree</a></li>
<li><a href="https://devopedia.org/redis-streams">redis streams</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>MySQL Binlog 实践 CDC</title>
      <link>https://hedon.top/blog/mysql-binlog-practice/</link>
      <guid isPermaLink="true">https://hedon.top/blog/mysql-binlog-practice/</guid>
      <pubDate>Mon, 08 Dec 2025 11:20:00 GMT</pubDate>
      <description>本文介绍了 MySQL Binlog 的实践，包括如何使用 MySQL Binlog 进行 CDC 操作。</description>
      <category>MySQL</category><category>Binlog</category><category>CDC</category><category>数据库</category>
      <content:encoded><![CDATA[<p>好的！这是一次非常棒的实战演练。</p>
<p>我们要构建一个**“微型分布式事务同步系统”**。</p>
<p>目标：当 MySQL 中的用户余额发生变更（充值）时，Go 程序自动捕获 Binlog，并将最新的余额实时同步到 Redis 中，实现最终一致性。</p>
<p>我们将全流程分为 <strong>四个步骤</strong>：</p>
<ol>
<li><strong>环境准备 (MySQL &amp; Redis)</strong></li>
<li><strong>Go 项目初始化</strong></li>
<li><strong>编写核心代码 (main.go)</strong></li>
<li><strong>运行与验证</strong></li>
</ol>
<hr>
<h3>🟢 第一步：环境准备 (MySQL &amp; Redis)</h3>
<p>请确保你的本地或开发机已经启动了 MySQL 和 Redis。</p>
<h4>1. MySQL 建表与数据准备</h4>
<p>请在你的 MySQL 中执行以下 SQL。我们创建一个简单的 <code>user_balance</code> 表。</p>
<p>SQL</p>
<pre><code>-- 1. 创建测试数据库
CREATE DATABASE IF NOT EXISTS order_db;
USE order_db;

-- 2. 创建用户余额表
CREATE TABLE IF NOT EXISTS user_balance (
    uid INT PRIMARY KEY,
    username VARCHAR(50),
    balance INT DEFAULT 0 COMMENT &#39;余额(分)&#39;,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

-- 3. 初始化一个用户 (uid=10086, 余额=0)
INSERT INTO user_balance (uid, username, balance) VALUES (10086, &#39;Hedon&#39;, 0)
ON DUPLICATE KEY UPDATE balance=0;

-- 4. 确认 Binlog 格式为 ROW (必须!)
SET SESSION binlog_format = &#39;ROW&#39;;
</code></pre>
<h4>2. 创建拥有复制权限的用户 (可选，推荐)</h4>
<p>为了安全，通常创建一个专门用于同步的账号，而不是用 root。</p>
<p>SQL</p>
<pre><code>CREATE USER &#39;canal_user&#39;@&#39;%&#39; IDENTIFIED BY &#39;canal_pass&#39;;
GRANT REPLICATION SLAVE, REPLICATION CLIENT, SELECT ON *.* TO &#39;canal_user&#39;@&#39;%&#39;;
FLUSH PRIVILEGES;
</code></pre>
<hr>
<h3>🔵 第二步：Go 项目初始化</h3>
<p>在你的工作目录下创建一个新项目：</p>
<p>Bash</p>
<pre><code># 1. 创建目录
mkdir binlog-sync-demo
cd binlog-sync-demo

# 2. 初始化 go mod
go mod init binlog-sync-demo

# 3. 下载依赖库
# go-mysql: 用于解析 Binlog
# go-redis: 用于操作 Redis
go get github.com/go-mysql-org/go-mysql/replication
go get github.com/redis/go-redis/v9
</code></pre>
<hr>
<h3>🟠 第三步：编写核心代码 (main.go)</h3>
<p>这是整个系统的灵魂。我会加上详细的注释，帮助你理解每一行。</p>
<p>创建一个 <code>main.go</code> 文件，将以下代码完整复制进去。</p>
<blockquote>
<p><strong>注意</strong>：请修改代码顶部的 <code>MySQL 配置</code> 和 <code>Redis 配置</code>，适配你本地的环境。</p>
</blockquote>
<p>Go</p>
<pre><code>package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;os&quot;
	&quot;time&quot;

	&quot;github.com/go-mysql-org/go-mysql/replication&quot;
	&quot;github.com/redis/go-redis/v9&quot;
)

// --- 配置区域 (请修改为你自己的配置) ---
var (
	// MySQL 配置
	mysqlHost     = &quot;127.0.0.1&quot;
	mysqlPort     = uint16(3306)
	mysqlUser     = &quot;canal_user&quot; // 或者 root
	mysqlPass     = &quot;canal_pass&quot; // 或者你的密码
	targetDB      = &quot;order_db&quot;
	targetTable   = &quot;user_balance&quot;

	// Redis 配置
	redisAddr     = &quot;127.0.0.1:6379&quot;
	redisPass     = &quot;&quot;
)

var ctx = context.Background()

func main() {
	// 1. 初始化 Redis 客户端
	rdb := redis.NewClient(&amp;redis.Options{
		Addr:     redisAddr,
		Password: redisPass,
	})
	if _, err := rdb.Ping(ctx).Result(); err != nil {
		fmt.Printf(&quot;❌ Redis 连接失败: %v\n&quot;, err)
		os.Exit(1)
	}
	fmt.Println(&quot;✅ Redis 连接成功！等待数据同步...&quot;)

	// 2. 初始化 Binlog Syncer
	cfg := replication.BinlogSyncerConfig{
		ServerID: 100, // 假装自己是一个从库，ID 必须唯一
		Flavor:   &quot;mysql&quot;,
		Host:     mysqlHost,
		Port:     mysqlPort,
		User:     mysqlUser,
		Password: mysqlPass,
	}
	syncer := replication.NewBinlogSyncer(cfg)

	// 3. 开始同步
	// 这里的 Position 设置为 File=&quot;&quot;, Pos=4 表示从当前最新的位置开始监听
	// 生产环境需要从存储的 Checkpoint 读取
	streamer, err := syncer.StartSync(replication.Position{Name: &quot;&quot;, Pos: 4})
	if err != nil {
		fmt.Printf(&quot;❌ 启动 Binlog 监听失败: %v\n&quot;, err)
		os.Exit(1)
	}
	fmt.Println(&quot;🚀 Binlog 监听器启动成功！正在监听 MySQL 变更...&quot;)

	// 4. 进入事件循环
	for {
		ev, err := streamer.GetEvent(context.Background())
		if err != nil {
			fmt.Printf(&quot;❌ 获取事件错误: %v\n&quot;, err)
			break
		}

		// 我们只关心 UPDATE 事件 (充值通常是 Update)
		// 如果是新用户注册，还需要监听 WRITE_ROWS_EVENTv2
		if ev.Header.EventType == replication.UPDATE_ROWS_EVENTv1 || ev.Header.EventType == replication.UPDATE_ROWS_EVENTv2 {
			handleUpdateEvent(ev, rdb)
		}
	}
}

// 处理更新事件
func handleUpdateEvent(ev *replication.BinlogEvent, rdb *redis.Client) {
	rowsEvent := ev.Event.(*replication.RowsEvent)

	// 1. 过滤库名和表名 (我们只关心 user_balance 表)
	// 注意：go-mysql 解析出来的 SchemaName 和 TableName 是 byte 数组
	dbName := string(rowsEvent.Table.Schema)
	tblName := string(rowsEvent.Table.Table)

	if dbName != targetDB || tblName != targetTable {
		return // 不是我们要的表，跳过
	}

	fmt.Printf(&quot;\n⚡ 捕获到 %s.%s 的更新事件！\n&quot;, dbName, tblName)

	// 2. 解析行数据
	// Update 事件的 Rows 数组结构：[旧行1, 新行1, 旧行2, 新行2, ...]
	for i := 0; i &lt; len(rowsEvent.Rows); i += 2 {
		oldRow := rowsEvent.Rows[i]
		newRow := rowsEvent.Rows[i+1]

		// 根据你的表结构 user_balance (uid, username, balance, update_time)
		// 对应的索引是: 0, 1, 2, 3

		// 提取 UID (主键)
		uid := getInt(newRow[0])

		// 提取余额 Balance
		oldBalance := getInt(oldRow[2])
		newBalance := getInt(newRow[2])

		fmt.Printf(&quot;   用户 UID: %d\n&quot;, uid)
		fmt.Printf(&quot;   余额变更: %d -&gt; %d\n&quot;, oldBalance, newBalance)

		// 3. 业务逻辑：同步到 Redis
		// 只有余额真的变了才同步
		if newBalance != oldBalance {
			redisKey := fmt.Sprintf(&quot;user:balance:%d&quot;, uid)

			// 模拟耗时，体现&quot;异步&quot;特性
			// time.Sleep(10 * time.Millisecond)

			err := rdb.Set(ctx, redisKey, newBalance, 0).Err()
			if err != nil {
				fmt.Printf(&quot;   ❌ Redis 同步失败: %v\n&quot;, err)
				// 生产环境这里需要重试或报警
			} else {
				fmt.Printf(&quot;   ✅ Redis 同步成功！Key: %s, Value: %d\n&quot;, redisKey, newBalance)
			}
		} else {
			fmt.Println(&quot;   ⚠️ 余额未发生实质变化，忽略同步。&quot;)
		}
	}
}

// 辅助函数：处理 go-mysql 解析出来的 int 类型差异
// go-mysql 可能会根据数值大小返回 int, int32, int64 等不同类型
func getInt(v interface{}) int64 {
	switch val := v.(type) {
	case int:
		return int64(val)
	case int8:
		return int64(val)
	case int16:
		return int64(val)
	case int32:
		return int64(val)
	case int64:
		return val
	case uint:
		return int64(val)
	case uint32:
		return int64(val)
	case uint64:
		return int64(val)
	default:
		return 0
	}
}
</code></pre>
<hr>
<h3>🟣 第四步：运行与验证 (见证奇迹的时刻)</h3>
<p>现在我们打开两个终端窗口。</p>
<h4>窗口 1：运行 Go 程序 (同步器)</h4>
<p>Bash</p>
<pre><code>go run main.go
</code></pre>
<p>你会看到输出：</p>
<blockquote>
<p>✅ Redis 连接成功！等待数据同步...</p>
<p>🚀 Binlog 监听器启动成功！正在监听 MySQL 变更...</p>
</blockquote>
<p>程序现在处于<strong>阻塞监听</strong>状态。</p>
<h4>窗口 2：操作 MySQL (模拟充值)</h4>
<p>连接到你的 MySQL，执行充值操作。</p>
<p>SQL</p>
<pre><code>USE order_db;

-- 模拟：给用户 10086 充值 100 块 (10000分)
UPDATE user_balance SET balance = balance + 10000 WHERE uid = 10086;
</code></pre>
<h4>👀 观察结果</h4>
<p><strong>回到窗口 1 (Go 程序)</strong>，你应该会立刻看到如下输出：</p>
<p>Plaintext</p>
<pre><code>⚡ 捕获到 order_db.user_balance 的更新事件！
   用户 UID: 10086
   余额变更: 0 -&gt; 10000
   ✅ Redis 同步成功！Key: user:balance:10086, Value: 10000
</code></pre>
<p>检查 Redis (可选)：</p>
<p>如果你有 Redis 客户端，可以去验证一下：</p>
<p>Bash</p>
<pre><code>redis-cli get user:balance:10086
# 输出应该是 &quot;10000&quot;
</code></pre>
<hr>
<h3>🧠 深度复盘：这个 Demo 里的架构点</h3>
<p>通过这个实操，你实际上已经跑通了**“异构数据同步”**的最核心路径。</p>
<ol>
<li><p>ROW 格式的威力：</p>
<p>代码里的 oldRow 和 newRow 之所以能拿到，全靠 MySQL Binlog 的 ROW 格式记录了镜像数据。</p>
</li>
<li><p>准实时性：</p>
<p>你会发现，当你敲下 SQL 回车的一瞬间，Go 程序几乎同时输出了日志。这就是 Binlog 推送 的效率（通常在 10ms 以内），足以满足绝大多数“最终一致性”的业务需求。</p>
</li>
<li><p>解耦：</p>
<p>你在操作 MySQL 时，完全不需要知道 Redis 的存在。是 Go 程序作为“旁路”把数据搬运过去的。这就实现了业务代码与缓存逻辑的解耦。</p>
</li>
</ol>
<p>去做一下这个实验吧！跑通的那一刻，你会对“分布式事务”有全新的实感。</p>
<p>这是一个非常敏锐的架构问题！你能问出这个问题，说明你已经具备了**“生产级架构”**的思维，不再局限于写一个小 Demo。</p>
<p>结论先行：</p>
<p>在刚才的 Demo 中，我跳过 Kafka 是为了降低学习门槛和演示环境复杂度（避免让你在本地搭建 Zookeeper + Kafka 集群）。</p>
<p>但在<strong>真实的生产环境（高并发/大规模）中，引入 Kafka（或 RocketMQ）几乎是必须的</strong>。</p>
<p>我们来深度剖析一下：<strong>为什么在这个链路中，我们需要 Kafka 做中间件？</strong> 它到底解决了什么“直连模式”解决不了的问题？</p>
<hr>
<h3>1. 架构演进：从“直连”到“消息队列”</h3>
<h4>🔵 阶段一：直连模式 (刚才的 Demo)</h4>
<p><strong>架构</strong>：<code>MySQL -&gt; Binlog 监听程序 (Canal/Go) -&gt; Redis</code></p>
<ul>
<li><strong>优点</strong>：<ul>
<li>简单，无中间件依赖。</li>
<li>延迟极低（少了一次网络传输）。</li>
</ul>
</li>
<li><strong>致命缺陷</strong>：<ol>
<li><strong>强耦合</strong>：Go 程序既要负责解析 Binlog（复杂的协议），又要负责写 Redis（业务逻辑）。如果 Redis 挂了，或者 Redis 写得太慢，会阻塞 Binlog 的解析，导致<strong>主从延迟堆积</strong>。</li>
<li><strong>无法复用</strong>：如果你的搜索团队说：“嘿，我也想要一份用户余额变更的数据写到 Elasticsearch 里”。你就得改代码，或者再起一个 Binlog 监听（对 MySQL 造成双倍压力）。</li>
<li><strong>无削峰能力</strong>：如果 MySQL 瞬间爆发 10 万 TPS 的写入，Go 程序会尝试向 Redis 发起 10 万次写入，Redis 可能会直接崩掉（缓存雪崩）。</li>
</ol>
</li>
</ul>
<h4>🟠 阶段二：引入 Kafka (生产标准)</h4>
<p><strong>架构</strong>：<code>MySQL -&gt; Binlog 解析器 (Canal/Debezium) -&gt; Kafka -&gt; 消费者 (Go App) -&gt; Redis</code></p>
<p>在这个架构中，<strong>Kafka</strong> 扮演了最重要的“缓冲”和“解耦”角色。</p>
<hr>
<h3>2. Kafka 在 CDC 链路中的四大核心价值</h3>
<p>如果你在面试中被问到“为什么要加 Kafka”，请抛出这四个关键词：</p>
<h4>① 解耦 (Decoupling) —— 各司其职</h4>
<ul>
<li><strong>Binlog 解析器 (Producer)</strong>：只负责把 Binlog 变成 JSON 扔进 Kafka。它根本不关心下游是 Redis 还是 ES，也不关心下游是不是挂了。它的任务就是<strong>快</strong>，紧跟 MySQL 主库。</li>
<li><strong>业务消费者 (Consumer)</strong>：只负责从 Kafka 拿消息写 Redis。如果 Redis 挂了，消费者可以暂停，Kafka 会帮我们保存进度（Offset）。等 Redis 修好了，消费者重启，继续从断点消费。<strong>整个过程不影响 MySQL 主库。</strong></li>
</ul>
<h4>② 削峰填谷 (Traffic Shaping) —— 保护下游</h4>
<ul>
<li><strong>场景</strong>：大促期间，MySQL 瞬间涌入 5 万 QPS 的充值请求。</li>
<li><strong>作用</strong>：Kafka 极其能抗写（百万级 TPS）。它能瞬间吞下这 5 万条消息，像一个巨大的<strong>蓄水池</strong>。</li>
<li><strong>下游</strong>：后端的 Go 程序可以按照自己的节奏（比如每秒处理 2000 个），慢慢地从 Kafka 里取数据更新 Redis。Redis 此时是非常安全的，不会被流量洪峰打死。</li>
</ul>
<h4>③ 广播/多路分发 (Fan-out) —— 数据资产化</h4>
<ul>
<li><strong>场景</strong>：一份数据，多处使用。</li>
<li><strong>作用</strong>：Binlog 数据进入 Kafka 的 <code>Topic: user_balance_update</code> 后：<ul>
<li><strong>Consumer Group A (缓存组)</strong>：读取数据 -&gt; 更新 Redis。</li>
<li><strong>Consumer Group B (搜索组)</strong>：读取同一份数据 -&gt; 更新 Elasticsearch。</li>
<li><strong>Consumer Group C (数仓组)</strong>：读取同一份数据 -&gt; 存入 Hive/ClickHouse。</li>
</ul>
</li>
<li><strong>收益</strong>：MySQL 只需要承受一次 Binlog Dump 的压力，实现了**“一次生产，多次消费”**。</li>
</ul>
<h4>④ 数据回放 (Data Replay) —— 容错后悔药</h4>
<ul>
<li><strong>场景</strong>：你写的更新 Redis 的代码有个 Bug，把余额加错了。代码上线跑了 1 个小时你才发现。</li>
<li><strong>作用</strong>：如果是直连模式，Binlog 早就流过去了，难以重来。但在 Kafka 模式下，你可以把消费者的 <strong>Offset (偏移量)</strong> 重置到 1 小时前。</li>
<li><strong>结果</strong>：消费者会重新拉取过去 1 小时的历史消息，重新计算并覆盖 Redis 中的错误数据。</li>
</ul>
<hr>
<h3>3. 代码层面的变化</h3>
<p>如果要上 Kafka，你的 Go 代码会拆分成两个独立的程序。</p>
<h4>程序 A：Producer (Binlog -&gt; Kafka)</h4>
<p>你可以继续用 <code>go-mysql</code>，或者直接部署开源的 <strong>Canal Deployer</strong> 或 <strong>Debezium</strong>（推荐，基于 Java/Kafka Connect）。它们配置一下就能用，不需要写代码。</p>
<h4>程序 B：Consumer (Kafka -&gt; Redis)</h4>
<p>这才是你需要写的业务代码：</p>
<p>Go</p>
<pre><code>// 伪代码：从 Kafka 消费
func main() {
    reader := kafka.NewReader(kafka.ReaderConfig{
        Brokers: []string{&quot;localhost:9092&quot;},
        Topic:   &quot;mysql_binlog_user_balance&quot;,
        GroupID: &quot;redis_updater_group&quot;, // 消费者组
    })

    for {
        m, _ := reader.ReadMessage(context.Background())

        // 1. 解析 Kafka 消息 (JSON)
        var event BalanceUpdateEvent
        json.Unmarshal(m.Value, &amp;event)

        // 2. 写入 Redis
        redis.Set(&quot;user:&quot;+event.Uid, event.NewBalance)

        // 3. 提交 Offset (表示这条我处理完了)
        reader.CommitMessages(context.Background(), m)
    }
}
</code></pre>
<h3>总结</h3>
<p>你问得非常好。</p>
<ul>
<li><strong>学习/开发/小规模</strong>：直连模式（MySQL -&gt; App -&gt; Redis）完全 OK，简单粗暴，延迟最低。</li>
<li><strong>生产/大规模/核心链路</strong>：<strong>必须上 Kafka</strong>。不仅仅是为了解耦，更是为了数据的安全和系统的健壮性。</li>
</ul>
<p>建议：</p>
<p>既然你已经跑通了直连。如果想挑战高阶，可以尝试在本地起一个 Kafka，把刚才 Demo 里的 handleUpdateEvent 函数改成“发送 Kafka 消息”，然后再写一个小程序去消费它。这就构成了一个完整的工业级 CDC 链路。</p>
]]></content:encoded>
    </item>
    <item>
      <title>分库分表 ID 生成方案</title>
      <link>https://hedon.top/blog/sharding-id-generate/</link>
      <guid isPermaLink="true">https://hedon.top/blog/sharding-id-generate/</guid>
      <pubDate>Sat, 06 Dec 2025 14:00:00 GMT</pubDate>
      <description>本文深入分析 MySQL 分库分表后的主键生成难点，从唯一性、有序性、高可用等维度对比 UUID、自增 ID、雪花算法等方案，帮助读者系统理解各自优劣与实际落地考量。</description>
      <category>分库分表</category><category>ID 生成</category><category>数据库</category>
      <content:encoded><![CDATA[<p>要彻底掌握分库分表环境下的 ID 生成方案，不能只背诵&quot;雪花算法&quot;的配置，而必须从数据库底层原理（B+ 树）和分布式系统的 CAP 定理出发，建立一套完整的评估体系。</p>
<p>我们可以从 <strong>&quot;不可能三角&quot;</strong> 开始，层层拆解，最后落实到工业级的设计。</p>
<h2>1. 不可能三角</h2>
<p>在分库分表场景下，一个优秀的 ID 必须同时满足以下三个维度的苛刻要求，但这往往存在权衡：</p>
<ol>
<li><strong>全局唯一性 (Uniqueness)</strong>：这是基本底线。不能出现两个分片生成了同一个 ID。</li>
<li><strong>单调递增性 (Trend Increasing)</strong>：这是 MySQL 场景下的核心痛点。MySQL 的 InnoDB 引擎使用 <strong>聚簇索引 (Clustered Index)</strong>。数据是直接挂在主键 B+ 树叶子节点上的。如果 ID 是随机的（如 UUID），插入新数据时，会频繁导致 B+ 树中间节点的 <strong>页分裂 (Page Split)</strong>，造成大量的随机磁盘 I/O 和碎片，极大地降低写入性能。所以：<strong><u>ID 必须尽量有序，最好是严格递增</u></strong>。</li>
<li><strong>高可用与高性能 (Availability &amp; Performance)</strong>：发号器不能成为系统的瓶颈，也不能因为单点故障导致整个业务停摆。</li>
</ol>
<h2>2. 方案演进</h2>
<h3>2.1 反面教材：UUID</h3>
<p>UUID 是原理是利用网卡 MAC 地址、时间戳、随机数生成 128 位字符串。它适用于生成 Token、文件名，但<strong>绝不用于数据库主键</strong>。</p>
<p>主要缺点有：</p>
<ul>
<li><strong>性能杀手</strong>：无序，导致 MySQL 频繁页分裂（写入性能比有序 ID 差 N 倍）。</li>
<li><strong>存储浪费</strong>：128 位字符串太长，且作为二级索引的叶子节点值，会膨胀整个数据库索引空间。</li>
<li><strong>不可读</strong>：无法在日志中快速定位时间或业务含义。</li>
</ul>
<h3>2.2 远古方案：数据库步长法</h3>
<p>利用 MySQL 的 <code>auto_increment</code>，但不同分片设置不同的起始值和步长。</p>
<ul>
<li>DB1: start=1, step=2 -&gt; 1, 3, 5...</li>
<li>DB2: start=2, step=2 -&gt; 2, 4, 6...</li>
</ul>
<p>这种方案有一个最大的缺陷：一旦设定了 step=2，后续想扩容成 3 个分片，所有旧数据的 ID 生成逻辑都要改，几乎无法平滑扩容。</p>
<p>美团的 <a href="https://tech.meituan.com/2017/04/21/mt-leaf.html">Leaf-segment</a> 对这个方案进行了改进，它的核心思路是单独搞一个发号服务，每次从数据库领 1000 个 ID（号段）放在内存里慢慢发。</p>
<ul>
<li>优点：减轻数据库压力，容忍数据库短时间宕机。</li>
<li>缺点：ID 不严格连续，依赖中心化服务。</li>
</ul>
<h3>2.3 黄金标准：雪花算法</h3>
<p>这是目前最主流的分布式 ID 方案，由 Twitter 提出。它本质上是一个<strong>位运算 (Bit Manipulation)</strong> 的艺术。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/0*zgoVKDg2-q9gmU1E.png" alt=""></p>
<ul>
<li><strong>1 bit</strong>：不使用（符号位）。</li>
<li><strong>41 bits</strong>：毫秒级时间戳（可以使用 69 年）。</li>
<li><strong>10 bits</strong>：机器 ID（5 位数据中心 ID + 5 位工作机器 ID，支持 1024 个节点）。</li>
<li><strong>12 bits</strong>：序列号（每毫秒内支持生成 4096 个 ID）。</li>
</ul>
<blockquote>
<p>笔者认为，雪花算法最大的价值在于提出了<strong>ID 分段</strong>的思想，我们大可以根据需求、借助时间戳和分段，自由切割 ID 的不同比特位，赋予其不同的含义，灵活设计自己的 ID 算法。</p>
</blockquote>
<p>雪花算法有两大优势：</p>
<ul>
<li><strong>本地生成</strong>：不依赖网络请求，性能极高。</li>
<li><strong>趋势递增</strong>：高位是时间，整体随时间递增，对 B+ 树友好。</li>
</ul>
<p>但是雪花算法强依赖服务器系统时间。如果服务器时间校准（NTP）导致时间回退，可能会生成 <strong>重复 ID</strong>。</p>
<h4>2.3.1 直接拒绝</h4>
<p>如果发现当前时间 &lt; 上次生成时间，抛出异常，拒绝服务（最简单，但影响可用性）。</p>
<pre><code class="language-rust">// Rust 伪代码示例
if current_timestamp &lt; self.last_timestamp {
    // 警报！当前时间竟然比上一次发号的时间还早！
    // 说明发生了时钟回拨
    return Error(&quot;Clock moved backwards!&quot;);
}
</code></pre>
<h4>2.3.2 等待追赶</h4>
<p>NTP 的校准通常非常微小。如果发现回拨了 2ms，程序可以选择 <strong>不报错，死循环等待</strong>。</p>
<pre><code class="language-rust">// Rust 伪代码示例
if current_timestamp &lt; self.last_timestamp {
    let offset = self.last_timestamp - current_timestamp;
    if offset &lt;= 5 { // 如果只回拨了 5ms 以内
        // 睡一会儿，或者空转，直到时间追上来
        thread::sleep(Duration::from_millis(offset));
        // 重新获取时间
        current_timestamp = Now(); 
    } else {
        panic!(&quot;回拨太多了，救不了！&quot;);
    }
}
</code></pre>
<ul>
<li><strong>代价</strong>：这次 ID 生成请求会增加几毫秒的延迟（用户无感知）。</li>
<li><strong>收益</strong>：服务不会挂，数据不会错。</li>
</ul>
<h4>2.3.3 扩展位</h4>
<p>如果回拨时间较长（比如几秒），等待策略会导致请求超时。 百度开源的 UidGenerator 使用了一种**&quot;未来时间&quot;**的思路，或者利用保留位。</p>
<ul>
<li><strong>思路</strong>：Snowflake 的 64 位中，通常有 <code>1-2</code> 位是保留位（Reserved）。</li>
<li><strong>做法</strong>：当发生回拨时，将 <code>last_timestamp</code> 继续递增（使用虚拟时间），同时修改 <code>sequence</code> 或者启用 <code>回拨位</code>。</li>
<li><strong>本质</strong>：此时生成的 ID 里的&quot;时间戳部分&quot;已经不是真实的物理时间了，而是逻辑时间。只要保证 ID 的单调递增性，物理时间不准确并不影响数据库的主键性能。</li>
</ul>
<p>这里还有个问题！<code>self.last_timestamp</code> 是存在内存中的，服务重启怎么办？</p>
<p>解决方案是：</p>
<blockquote>
<p>每次服务启动时，先去 ZK/Redis 拿一下这台机器&quot;上次汇报的时间&quot;。如果 <code>当前系统时间 &lt; 上次汇报时间</code>，说明机器时间有问题，<strong>拒绝启动</strong>报警。当然，这里肯定是异步汇报的，而且，可以汇报未来时间，比如 <code>当前系统时间+3s</code>，这样，<strong>哪怕我崩溃了，Redis 里记录的时间戳一定比我发出的最后一个 ID 的时间戳要大</strong>。重启时只要检查 Redis，就能 100% 保证时间轴没有重叠。</p>
</blockquote>
<h2>3. 多维查询</h2>
<p>分库分表的 ID 问题，除了 ID 的生成问题，还有 ID 的选择问题。</p>
<p>思考一下：订单ID、用户ID、商户ID。 当拆分的时候，根据哪个维度进行拆分呢?</p>
<blockquote>
<p>假设按用户 ID 维度拆分，同一个用户 ID 的所有订单会落到同一个库的同一张表里。 当查询的时候，按用户 ID 查，可以很容易地定位到某个库的某个表。但如果按订单 ID 或 商户 ID 维度查询，就很难做。</p>
</blockquote>
<p>解决思路有：</p>
<ol>
<li>建立一个映射表：商户 ID 和用户 ID 之间的映射关系，订单 ID 和用户 ID 之间的映射关系，存在分布式事务问题。</li>
<li>业务双写：同一份数据，两套分库分表。一套按用户 ID 切分，一套按商户 ID 切分。同样，存在写入多个库的分布式事务问题。</li>
<li>异步双写：还是两套表，只是业务单写。然后通过监听 Binlog，同步到另外一套表。</li>
<li><strong>基因法</strong>：两个维度统一到一个维度，把订单 ID 和用户 ID 统一成一个维度，比如订单 ID 固定前几位是用户 ID。</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>分库分表后的分页查询思路总结</title>
      <link>https://hedon.top/blog/sharding-page-search/</link>
      <guid isPermaLink="true">https://hedon.top/blog/sharding-page-search/</guid>
      <pubDate>Sat, 06 Dec 2025 13:41:20 GMT</pubDate>
      <description>本文分析了分库分表环境下分页查询的本质难点，详细讲解了全局排序（Broadcast &amp; Merge）、分片本地分页的误区、深分页的性能瓶颈，并对游标分页与业务折衷等优化方案进行了总结。</description>
      <category>分页查询</category><category>分库分表</category><category>数据库</category>
      <content:encoded><![CDATA[<p>分库分表（Sharding）后的分页查询问题，本质上是 <strong>「局部有序」与「全局有序」之间的矛盾</strong>。</p>
<p>在单表场景下，数据库利用 B+ 树索引可以快速定位 offset；但在分片环境下，数据分散在不同的物理节点，没有任何一个节点拥有全局视图。</p>
<blockquote>
<p>当然，在单表场景下，如果 offset 太大，依旧需要白白扫前面的 offset 条数据，然后才取 limit 要的数据，很浪费。</p>
</blockquote>
<p>我们可以从 <strong>问题本源（第一性原理）</strong>、<strong>通用方案</strong>、<strong>深度分页优化</strong> 以及 <strong>业务折衷</strong> 四个层面来思考解决方案。</p>
<h3>1. 为何分表后分页变难了？</h3>
<p>假设我们有 3 个分片（Node A, Node B, Node C），按照 ID 取模分片。现在我们要按时间排序，查询第 10 页的数据（每页 10 条，即 <code>LIMIT 10 OFFSET 90</code>）。</p>
<h4>1.1 错误的直觉</h4>
<p>直觉告诉我们：在每个分片上执行 <code>LIMIT 10 OFFSET 90</code>，然后把结果拿回来合并。</p>
<p><strong>这是错误的</strong>。因为 Node A/B/C 的第 91 条数据，可能是全局第 91 条，也可能是全局第 1 条（如果 Node A 的数据时间都很新），也可能是全局第 270 条。你无法确定每个分片内部数据的<strong>全局相对位置</strong>。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251206140030292.png" alt=""></p>
<h4>1.2 正确但低效的做法（Broadcast &amp; Merge）</h4>
<p>为了保证数据的准确性，你必须确保不错过任何可能的记录。</p>
<ul>
<li>你需要向 <strong>所有分片</strong> 发送请求：<code>SELECT * FROM table ORDER BY time LIMIT 100</code> (OFFSET + LIMIT)。</li>
<li>汇总所有分片返回的数据：$3 \times 100 = 300$ 条。</li>
<li>在内存中对这 300 条数据进行全局排序。</li>
<li>取第 91-100 条，抛弃其余 270 条。</li>
</ul>
<p>这种做法被称为 全局视野法。它的代价是随着页码（OFFSET）的增加，查询代价呈指数级（或线性倍数）增长。</p>
<p>如果用户查询第 10,000 页（Offset 100,000, Limit 10），有 N 个分片：</p>
<ul>
<li><strong>网络 I/O：</strong> 需要传输 $N \times (100,000 + 10)$ 条数据。</li>
<li><strong>内存 CPU：</strong> 应用层或中间件需要对 $N \times 100,010$ 条数据进行排序。</li>
</ul>
<p>这就是著名的 <strong>分库分表深分页（Deep Paging）问题</strong>。</p>
<h3>2. 通用解决方案</h3>
<p>针对上述问题，工界通常有以下几种演进方案：</p>
<ul>
<li>全局视野法：中间件代理，即上述的 broadcast &amp; merge。</li>
<li>二次查询法：新增一个全局 ID 映射表，先查 ID 后查数据。</li>
<li>同步至异构数据源：利用专门的 OLAP 数据库进行处理。</li>
</ul>
<h4>2.1 全局视野法（中间件代理）</h4>
<p>即上述的 &quot;Broadcast &amp; Merge&quot;。</p>
<ul>
<li><strong>实现：</strong> 依赖 ShardingSphere、MyCat 等中间件，或者在代码层并发调用。</li>
<li><strong>适用场景：</strong> 分页深度较浅（前 10-20 页），对性能要求不极致的后台管理系统。</li>
<li><strong>缺点：</strong> 越往后翻，数据库压力越大，最终会导致 OOM（内存溢出）或超时。</li>
</ul>
<h4>2.2 二次查询法（全局 ID 映射）</h4>
<p>如果必须精确分页且页码较深，可以建立一张 <strong>「索引表」</strong>。</p>
<ul>
<li><strong>原理：</strong> 建立一张只有 <code>(排序字段, 主键 ID)</code> 的表，这张表<strong>不分片</strong>（或者按照排序字段分片）。</li>
<li><strong>步骤：</strong><ol>
<li>先在索引表中执行 <code>LIMIT 10 OFFSET N</code>，拿到 10 个 ID。</li>
<li>拿着这 10 个 ID，去各个分片中查询具体数据（利用 <code>IN</code> 查询，命中分片键）。</li>
</ol>
</li>
<li><strong>适用场景：</strong> 排序字段单一，且索引表数据量在单机可承受范围内（例如数据量虽大但每行很小）。</li>
<li><strong>缺点：</strong> 多了一次查询；索引表本身可能成为瓶颈。</li>
</ul>
<h4>2.3 同步至异构数据源（大数据方案）</h4>
<p>这是最通用的重型解决方案。MySQL 只做 OLTP（事务处理），复杂的查询交给专门的检索引擎。</p>
<ul>
<li><strong>原理：</strong> 通过 Binlog（Canal/Debezium）将数据实时同步到 <strong>Elasticsearch (ES)</strong> 或 <strong>ClickHouse</strong>。</li>
<li><strong>步骤：</strong> 分页查询直接走 ES，ES 天然支持分布式搜索和排序。拿到 ID 后，如果需要最新鲜的详情，再回查 MySQL（可选）。</li>
<li><strong>适用场景：</strong> C 端搜索、复杂条件筛选、海量数据分页。</li>
<li><strong>缺点：</strong> 架构复杂，存在数据同步延迟（秒级或毫秒级）。</li>
</ul>
<h3>3. 深度分页的优化技巧</h3>
<p>如果不想引入 ES，必须在 MySQL 体系内解决深分页，可以使用以下方法：</p>
<ul>
<li>游标法：不用 offset，改用 where + limit。</li>
<li>只有 ID 的全局排序：分片只查询 ID，然后再逐个去查询其他字段。</li>
</ul>
<h4>3.1 游标法</h4>
<p>这是最高效的方案，将 $O(N)$ 的复杂度降为 $O(1)$。</p>
<ul>
<li><strong>核心思想：</strong> 抛弃 <code>OFFSET</code>，使用 <strong>&quot;上一页的最后一条记录&quot;</strong> 作为锚点。</li>
<li><strong>前提：</strong> 排序字段必须有唯一性（通常用 <code>order by time, id</code>）。</li>
<li><strong>SQL 变化：</strong><ul>
<li>第 1 页：<code>LIMIT 10</code> -&gt; 记录最后一条的 <code>time=T1, id=ID1</code>。</li>
<li>第 2 页：<code>WHERE time &lt; T1 OR (time = T1 AND id &lt; ID1) ORDER BY time DESC, id DESC LIMIT 10</code>。</li>
</ul>
</li>
<li><strong>优点：</strong> 无论翻到第几万页，每个分片只需要扫描 10 条数据，性能恒定。</li>
<li><strong>缺点：</strong> <strong>不支持跳页</strong>（只能点击&quot;下一页&quot;或&quot;加载更多&quot;），不适合需要跳转到&quot;第 X 页&quot;的场景。适用于 App 的无限流（Feed 流）。</li>
</ul>
<blockquote>
<p>单表场景其实也建议使用这个方案，不然 offset 会白白扫描很多的数据。</p>
</blockquote>
<h4>3.2 只有 ID 的全局排序</h4>
<p>如果必须支持跳页，可以结合方案一进行优化。</p>
<p><strong>步骤：</strong></p>
<ol>
<li>每个分片只查询 <code>id</code> 和 <code>排序字段</code>（不查询 <code>select *</code>），减少网络传输和内存消耗。</li>
<li>在内存中对 ID 列表排序，截取需要的 ID。</li>
<li>用 ID 回表查询完整数据。</li>
</ol>
<p><strong>效果：</strong> 缓解了网络带宽压力，但没有解决数据库扫描的 I/O 压力。</p>
<h3>4. 业务折衷与产品设计</h3>
<p>很多时候，技术上的难题可以通过修改产品逻辑来规避。</p>
<h4>4.1 限制最大页码</h4>
<p>真的有用户会看电商商品的第 5000 页吗？通常 Google 搜索也只给你看前几十页。</p>
<blockquote>
<p>可以限制只能查看前 100 页。超过 100 页提示“请输入更精确的搜索条件”。</p>
</blockquote>
<h4>4.2 牺牲精度（模糊分页）</h4>
<p>当数据量达到亿级，用户并不在乎显示的 &quot;共 10000+ 条&quot; 是否精确，也不在乎第 100 页的第一条数据和第 99 页的最后一条是否严格连续。</p>
<blockquote>
<p>可以每个分片各取一部分数据，按照某种权重拼凑一页给用户。或者，每隔一定时间预计算一次全局 Count。</p>
</blockquote>
<h3>5. 总结</h3>
<table>
<thead>
<tr>
<th><strong>方案</strong></th>
<th><strong>关键技术点</strong></th>
<th><strong>复杂度</strong></th>
<th><strong>适用场景</strong></th>
<th><strong>备注</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>全局视野 (Standard)</strong></td>
<td>中间件广播合并</td>
<td>$O(Offset \times Shards)$</td>
<td>后台管理，前几页</td>
<td>开发成本最低，深分页必死</td>
</tr>
<tr>
<td><strong>异构索引 (ES)</strong></td>
<td>MySQL -&gt; ES</td>
<td>$O(1)$ ~ $O(logN)$</td>
<td>C 端搜索，复杂查询</td>
<td>架构重，有延迟</td>
</tr>
<tr>
<td><strong>游标法 (Seek)</strong></td>
<td><code>WHERE id &gt; last_id</code></td>
<td>$O(1)$</td>
<td>移动端 Feed 流</td>
<td><strong>性能最好</strong>，但不能跳页</td>
</tr>
<tr>
<td><strong>二次查询</strong></td>
<td>全局索引表</td>
<td>$O(logN)$</td>
<td>排序维度单一</td>
<td>维护额外的表</td>
</tr>
</tbody></table>
<p>推荐方案：</p>
<ul>
<li><strong>首选游标法（Cursor-based pagination）</strong>：设计 API 时，参数不要用 <code>page_number</code>，而是用 <code>next_cursor</code>（加密的 token，包含上一页的 ID）。这是现代 API（如 Twitter, Stripe）的标准做法。</li>
<li><strong>兜底方案</strong>：如果必须用传统分页，限制 <code>max_offset</code>。</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>gRPC 原理与应用场景深度解析</title>
      <link>https://hedon.top/blog/net-grpc/</link>
      <guid isPermaLink="true">https://hedon.top/blog/net-grpc/</guid>
      <pubDate>Mon, 01 Dec 2025 11:08:00 GMT</pubDate>
      <description>本文深入剖析 gRPC 的核心设计原理、性能优势、应用场景及其与传统 REST 架构的差异，助你真正理解它在现代分布式系统中的不可或缺地位。</description>
      <category>gRPC</category><category>计算机网络</category><category>计算机基础</category>
      <content:encoded><![CDATA[<p><a href="https://www.youtube.com/watch?v=njC24ts24Pg">观看 YouTube 视频</a></p>
<h2>1. gRPC</h2>
<p>要彻底掌握 gRPC，我们不能仅停留在会写 <code>.proto</code> 文件和生成代码的层面。我们需要从 <strong>第一性原理</strong> 出发，理解它到底解决了什么问题，它是如何构建在网络协议之上的，以及在生产环境中会遇到哪些真实挑战。</p>
<p>特此声明，本篇是笔者与 Google Gemini 3Pro 共创所作，非常庆幸在当今 AI 时代下获取知识已是如此便利，且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑，均由笔者把控和审阅，欢迎放心阅读。</p>
<h3>1.1 为什么需要 gRPC</h3>
<p>在深入技术细节前，必须理解 gRPC 诞生的背景。它本质上是 <strong>RPC (Remote Procedure Call)</strong> 技术的一种现代演进</p>
<p>RPC 的核心愿景是：<strong>让调用远程服务就像调用本地函数一样简单。</strong></p>
<ul>
<li><strong>本地函数：</strong> <code>result = calculator.add(a, b)</code>，在内存中跳转，极快。</li>
<li><strong>远程调用：</strong> <code>result = request(&quot;http://api/add&quot;, {a, b})</code>，需要跨越网络，面临延迟、丢包、序列化开销。</li>
</ul>
<p>要掌握 gRPC，首先要明白它为什么要革 REST 的命：</p>
<table>
<thead>
<tr>
<th><strong>特性</strong></th>
<th><strong>REST (JSON + HTTP/1.1)</strong></th>
<th><strong>gRPC (Protobuf + HTTP/2)</strong></th>
<th><strong>原理差异</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>协议</strong></td>
<td>文本协议 (Text)</td>
<td>二进制协议 (Binary)</td>
<td>计算机处理二进制比处理文本快得多（无需频繁的字符串解析）。</td>
</tr>
<tr>
<td><strong>传输</strong></td>
<td>请求/响应模型，连接复用差</td>
<td>多路复用 (Multiplexing)</td>
<td>HTTP/2 允许在一个 TCP 连接上并行处理多个请求，解决了队头阻塞 (Head-of-Line Blocking)。</td>
</tr>
<tr>
<td><strong>约束</strong></td>
<td>弱类型，依赖文档 (OpenAPI)</td>
<td>强类型，依赖 IDL (.proto)</td>
<td><strong>IDL (Interface Definition Language)</strong> 是 gRPC 的核心，它是强契约，保证了客户端和服务端的数据结构绝对一致。</td>
</tr>
<tr>
<td><strong>方向</strong></td>
<td>主要是单向 (Request-Response)</td>
<td>双向流 (Bi-directional Streaming)</td>
<td>HTTP/2 的流特性允许服务端主动推送数据。</td>
</tr>
</tbody></table>
<blockquote>
<p>gRPC 的高性能并非魔法，而是通过 <strong>空间效率</strong>（Protobuf 压缩率高）和 <strong>时间效率</strong>（HTTP/2 并发高、序列化快）的物理层优化换来的。</p>
</blockquote>
<h3>1.2 两大基石</h3>
<h4>1.2.1 Protocol Buffers (Protobuf)</h4>
<p>不要只把它当作 XML/JSON 的替代品，要理解其 <strong>编码原理</strong>。</p>
<ul>
<li><strong>TLV 格式：</strong> Protobuf 采用 <code>Tag - Length - Value</code> 的紧凑存储方式，没有字段名（字段名在编译后的代码中），只有字段编号 (Field ID)。</li>
<li><strong>Varint 编码：</strong> 对于整数，使用变长编码（Base 128 Varints）。例如数字 <code>1</code> 只需要 1 个字节存储，而不是标准的 4 个字节 (int32)。</li>
<li><strong>向后兼容性：</strong> 掌握如何安全地增加、删除字段而不破坏现有的客户端（永远不要修改已存在的 Field ID）。</li>
</ul>
<pre><code class="language-go">// The greeter service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user&#39;s name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}
</code></pre>
<h4>1.2.2 HTTP/2 传输机制</h4>
<p>gRPC 强依赖 HTTP/2。你需要理解以下概念在 gRPC 中如何映射：</p>
<ul>
<li><strong>Frame (帧)：</strong> HTTP/2 通信的最小单位。gRPC 的数据被封装在 DATA 帧中。</li>
<li><strong>Stream (流)：</strong> 一个 RPC 调用对应一个 Stream。</li>
<li><strong>HPACK：</strong> HTTP 头压缩。RPC 调用往往 Header 重复度高，HPACK 能极大减少带宽消耗。</li>
</ul>
<h3>1.3 四种模式与工程化</h3>
<h4>1.3.1 四种通信模式</h4>
<ul>
<li><strong>Unary RPC：</strong> 一问一答。适用于常规 API。</li>
<li><strong>Server Streaming：</strong> 客户端发一个，服务端回一堆。适用于：<strong>大列表数据</strong>、<strong>实时行情推送</strong>。</li>
<li><strong>Client Streaming：</strong> 客户端发一堆，服务端回一个。适用于：<strong>物联网传感器上报</strong>、<strong>大文件上传</strong>。</li>
<li><strong>Bidirectional Streaming：</strong> 双向实时对话。适用于：<strong>聊天室</strong>、<strong>实时游戏同步</strong>。</li>
</ul>
<h4>1.3.2 Interceptor (拦截器)</h4>
<p>这是 gRPC 的中间件机制。彻底掌握它是做架构设计的关键。</p>
<ul>
<li><strong>用途：</strong> 鉴权 (Auth)、日志 (Logging)、监控 (Metrics)、分布式追踪 (Tracing)。</li>
<li><strong>实践：</strong> 学会编写一个 <code>UnaryServerInterceptor</code>，在其中计算每个请求的耗时并打印日志。</li>
</ul>
<blockquote>
<p>笔者的开源项目 <a href="https://github.com/hedon954/goapm">goapm</a> 中提供了 gRPC Server 和 Client 的链路追踪封装，有需要的读者可参考。</p>
</blockquote>
<h4>1.3.3 Error Handling (错误处理)</h4>
<p>gRPC 的错误不是 HTTP Status Code（虽然底层映射了）。</p>
<ul>
<li><strong>gRPC Status Code：</strong> 掌握标准码的含义，如 <code>OK(0)</code>, <code>CANCELLED(1)</code>, <code>DEADLINE_EXCEEDED(4)</code>, <code>UNAVAILABLE(14)</code>。</li>
<li><strong>Rich Error Model：</strong> 学会使用 <code>google.rpc.Status</code> 传递更详细的错误信息（如具体的字段校验错误），而不仅是一个简单的错误码。</li>
</ul>
<h3>1.4 注意事项</h3>
<h4>1.4.1 负载均衡的陷阱</h4>
<ul>
<li><strong>问题：</strong> gRPC 基于 HTTP/2，连接是 <strong>长连接 (Persistent Connection)</strong>。一旦连接建立，后续请求都在同一个 TCP 连接中复用。</li>
<li><strong>后果：</strong> 传统的 L4 负载均衡器（如 AWS NLB、LVS）只在连接建立时起作用。结果就是：<strong>一个后端实例累死，其他实例闲死。</strong></li>
<li><strong>解决方案：</strong><ul>
<li><strong>客户端负载均衡 (Client-side LB)：</strong> 客户端感知所有后端 IP（需配合 Service Discovery，如 Consul/Etcd），自己做轮询。</li>
<li><strong>代理负载均衡 (Proxy LB / L7 LB)：</strong> 使用支持 HTTP/2 的网关（如 Envoy, Nginx）来拆解请求并分发。</li>
</ul>
</li>
</ul>
<h4>14.2 Deadlines (超时控制)</h4>
<ul>
<li><strong>原则：</strong> 永远不要发起没有 Deadline 的 RPC 调用。</li>
<li><strong>级联故障：</strong> 如果服务 A 调 B，B 调 C，A 必须设置超时，且该超时上下文 (Context) 应该传递给 B 和 C。如果 A 超时了，C 的运算也应该立即取消 (Context Cancel)，避免浪费资源。</li>
</ul>
<h2>2. 数据编码</h2>
<p>为了更深入理解 gRPC 的高性能，从根本上掌握为什么 gRPC 要使用 Protobuf 编码格式。本篇将参考 <a href="https://book.douban.com/subject/26197294/">Designing Data-Intensive Applications(DDIA)</a> 一书，对业内常用的数据编码格式进行统一梳理。</p>
<table>
<thead>
<tr>
<th><strong>协议</strong></th>
<th><strong>类型</strong></th>
<th><strong>Schema 依赖</strong></th>
<th><strong>核心设计哲学</strong></th>
<th><strong>典型场景</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>JSON</strong></td>
<td>文本</td>
<td>无 (Self-describing)</td>
<td><strong>可读性至上</strong>。万物皆文本，浏览器原生支持。</td>
<td>前后端交互、配置文件、调试接口。</td>
</tr>
<tr>
<td><strong>MessagePack</strong></td>
<td>二进制</td>
<td>无 (Schema-less)</td>
<td><strong>二进制版 JSON</strong>。旨在无缝替换 JSON 以换取更小的体积，无需预定义 IDL。</td>
<td>Redis 缓存存储、内部简单服务交互。</td>
</tr>
<tr>
<td><strong>Protobuf</strong></td>
<td>二进制</td>
<td>强 (Static IDL)</td>
<td><strong>微服务契约</strong>。强调字段编号 (Tag) 管理，极致的向后兼容性。</td>
<td>gRPC、微服务内部通信。</td>
</tr>
<tr>
<td><strong>Thrift</strong></td>
<td>二进制</td>
<td>强 (Static IDL)</td>
<td><strong>全栈 RPC</strong>。不仅是序列化，还包含完整的 RPC 传输层和框架实现。</td>
<td>早期大规模跨语言服务 (Facebook 系)。</td>
</tr>
<tr>
<td><strong>Avro</strong></td>
<td>二进制</td>
<td>动态 (Schema w/ Data)</td>
<td><strong>大数据吞吐</strong>。Schema 与数据分离或随数据头传输，去掉 Tag 冗余。</td>
<td>Hadoop、Kafka、数据湖 (Data Lake)。</td>
</tr>
</tbody></table>
<h3>2.1 JSON</h3>
<blockquote>
<p>基于文本的、自描述 (Self-describing) 的键值对格式。</p>
</blockquote>
<p><a href="https://www.json.org/json-en.html">https://www.json.org/json-en.html</a></p>
<p>JSON 实际上是一长串 <strong>Unicode 字符</strong>。</p>
<ul>
<li><strong>自描述性：</strong> 数据中包含了结构信息（<code>{</code>, <code>}</code>, <code>[</code>, <code>]</code>）和字段名称。这意味着接收端不需要任何预先的沟通，只要有一个标准的 JSON 解析器就能读懂。</li>
<li><strong>编码方式：</strong> 数字存储为字符串（ASCII/UTF-8）。例如整数 <code>12345</code> 在内存中通常是 4 字节整数，但在 JSON 中变成了 5 个字符 <code>&quot;1&quot;, &quot;2&quot;, &quot;3&quot;, &quot;4&quot;, &quot;5&quot;</code>，占用 5 个字节。</li>
</ul>
<pre><code class="language-json">{
	&quot;userName&quot;: &quot;Martin&quot;,
	&quot;favoriteNumber&quot;: 1337,
	&quot;interests&quot;: [&quot;daydreaming&quot;, &quot;hacking&quot;]
}
</code></pre>
<p>对于上面的例子，去掉空格后，JSON 格式需要占用 <font color="red">81 bytes</font>。</p>
<h3>2.2 Message Pack</h3>
<blockquote>
<p>二进制的 JSON (Binary JSON)。</p>
</blockquote>
<p><a href="https://msgpack.org/">https://msgpack.org/</a></p>
<p>MessagePack 的目标是：<strong>在保留 JSON 的灵活性的前提下，极致压缩体积和提升解析速度。</strong> 它不需要 Schema，依然存储 Key，但它引入了 <strong>类型前缀 (Type Prefix)</strong> 系统。</p>
<p>对于 JSON <code>{&quot;a&quot;: 1}</code>，MessagePack 的二进制流可能如下：</p>
<ol>
<li><strong>Map 标记 (1 byte):</strong> <code>0x81</code><ul>
<li><code>0x8</code> 表示这是一个 Map。</li>
<li><code>0x1</code> 表示这个 Map 有 1 个元素。</li>
</ul>
</li>
<li><strong>Key 标记 (1 byte):</strong> <code>0xa1</code><ul>
<li><code>0xa</code> 表示这是一个 String。</li>
<li><code>0x1</code> 表示字符串长度为 1。</li>
</ul>
</li>
<li><strong>Key 内容 (1 byte):</strong> <code>0x61</code> (ASCII &#39;a&#39;)</li>
<li><strong>Value (1 byte):</strong> <code>0x01</code>，MessagePack 使用 <code>FixInt</code>，对于小整数，直接用一个字节存值，不需要额外的类型标记。</li>
</ol>
<p>与 JSON 的核心差异：</p>
<ul>
<li><strong>无分隔符：</strong> 它不需要 <code>{</code> 或 <code>:</code>。解析器读到 <code>0xa1</code> 就知道接下来读 1 个字节作为字符串，<strong>无需扫描</strong>，直接进行内存拷贝，速度极快。</li>
<li><strong>Key 依然存在：</strong> 它虽然压缩了结构，但 <code>&quot;userName&quot;</code> 这种字段名依然被完整地编码进去了。</li>
</ul>
<p>我们来看相同的例子：</p>
<pre><code class="language-json">{
	&quot;userName&quot;: &quot;Martin&quot;,
	&quot;favoriteNumber&quot;: 1337,
	&quot;interests&quot;: [&quot;daydreaming&quot;, &quot;hacking&quot;]
}
</code></pre>
<p>对于上面列举的数据，MessagePack 会将其进行如下图所示编码：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251201121921362.png" alt=""></p>
<ol>
<li>第 1 个字节 <code>0x83</code>表示接下来是一个对象（顶部四位 = <code>0x80</code>），有三个字段（底部四位 = <code>0x03</code>）。（如果你想知道，如果一个对象有超过15个字段，字段数不适合四位，它会得到不同的类型指示器，字段数编码为两字节或四字节。）</li>
<li>第 2 个字节 <code>0xa8</code> 表示接下来是一个字符串（顶部四位 = <code>0xa0</code>），长度为八字节（底部四位 = <code>0x08</code>）。</li>
<li>接下来的 8 个字节是 ASCII 中的字段名 userName。既然之前已经标明了长度，就不需要任何标记来告诉我们弦的终点（或任何逸出点）。</li>
<li>接下来的 7 个字节编码带有前缀 <code>0xa6</code> 的六字母字符串值 Martin，依此类推。</li>
</ol>
<p>同样的数据，MessagePack 将数据大小压缩到了 <font color="red">66 bytes</font>。</p>
<h3>2.3 Protocol Buffer</h3>
<blockquote>
<p>基于 IDL (接口定义语言) 的 Tag-Length-Value (TLV) 协议。</p>
</blockquote>
<p><a href="https://protobuf.dev/">https://protobuf.dev/</a></p>
<p>Protobuf 的核心哲学是 <strong>&quot;约定优于配置&quot;</strong>。通信双方必须预先持有 <code>.proto</code> 文件（契约）。 因为有了契约，数据包里 <strong>完全抛弃了字段名</strong>，只保留了字段编号 (Field ID)。</p>
<p>其核心由三个机制组成：</p>
<ol>
<li><strong>Varint (Base 128):</strong> 用变长字节存储整数。数字 <code>1</code> 占 1 字节，数字 <code>300</code> 占 2 字节。</li>
<li><strong>ZigZag:</strong> 将有符号整数映射为无符号整数，解决了负数 varint 编码效率低的问题。</li>
<li><strong>TLV 结构:</strong> 每一个字段都是 $Tag + [Length] + Value$。$Tag$ 包含了 Field ID 和 Wire Type。</li>
</ol>
<p>Protobuf 的关键是其兼容性：</p>
<ul>
<li><strong>向后兼容性</strong>：如果接收端的 <code>.proto</code> 是旧的，它读到了一个新的 Tag（例如 ID=5），它通过 Wire Type 知道这个字段的数据类型，因此它可以安全地 <strong>跳过</strong> 这段数据，继续解析下一个字段，而不会报错。</li>
<li><strong>向前兼容性</strong>：如果接收端的 <code>.proto</code> 是新的，客户端没有传递新的字段，如果该字段被定义为 <code>optional</code> 可选的，则接收端依旧可以跳过该缺失的字段，继续解析下一个字段，而不会报错。</li>
</ul>
<p>对于上面给出的例子，<code>proto</code> 文件定义如下：</p>
<pre><code class="language-protobuf">message Person {
	required string user_name = 1;
	optional int64 favorite_number = 2;
	repeated string interests = 3;
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251201124042969.png" alt=""></p>
<ol>
<li>每一个字段都是 $Tag + [Length] + Value$。$Tag$ 包含了 Field ID 和 Wire Type。</li>
<li>第 1 个字节 <code>0x0a</code> 的低 3 位 <code>010</code> 代表 <strong>Wire Type 2</strong>（Length-delimited，即后面跟着长度）。这告诉解析器：准备好读取一段指定长度的数据（通常是字符串或嵌套对象）。高 5 位 <code>00001</code> 代表 Field ID=1。</li>
<li>第 2 个字节 <code>0x06</code> 表示接下来的数据长度为 6 字节。既然 Tag 里的 Wire Type 是 2，解析器就知道这里必须读一个 Varint 来确定长度。<code>06</code> 就是长度。</li>
<li>接下来的 6 个字节 <code>4d 61 72 74 69 6e</code>  是 ASCII 编码的字符串值 <strong>&quot;Martin&quot;</strong>。解析器读完这 6 个字节后，知道当前字段结束，准备读取下一个 Tag。</li>
<li>重点的对于数组，它们的 tag 是一样的，如上图都是 <code>0x1a</code>，Protobuf 会把一样的 <code>tag</code> 组成数组。</li>
</ol>
<p>同样的数据，Protobuf 将数据大小压缩到了 <font color="red">33 bytes</font>：</p>
<ol>
<li><strong>没有 Key：</strong> 整个流里你找不到 &quot;userName&quot; 这个单词，只有 <code>0x0a</code> (ID=1) 和 <code>0x10</code> (ID=2) 这样的编号。</li>
<li><strong>紧凑的数字：</strong> 1337 这种数字被压缩成了变长格式，且低位在前（Little Endian 风格）。</li>
<li><strong>无分隔符：</strong> 字符串没有结束符（如 <code>\0</code>），完全依靠前面的 Length (<code>06</code>, <code>0b</code>, <code>07</code>) 来精确定位边界。这使得解析过程可以利用内存拷贝（Memcpy），非常高效。</li>
</ol>
<h3>2.4 Thrift</h3>
<blockquote>
<p>全栈式的 RPC 框架与序列化协议。</p>
</blockquote>
<p><a href="https://thrift.apache.org/docs/">https://thrift.apache.org/docs/</a></p>
<p>Thrift 是由 Facebook 开发的跨语言 RPC 框架。与 gRPC (Protobuf) 相比，Thrift 最显著的特点是它把&quot;传输格式&quot;抽象出来了：</p>
<ul>
<li><strong>BinaryProtocol:</strong> 简单粗暴，不做压缩，解析速度极快，但占用带宽。</li>
<li><strong>CompactProtocol:</strong> 极致压缩，逻辑复杂，节省带宽（类似 Protobuf）。</li>
</ul>
<blockquote>
<p> 其实还有 DenseProtocol，不过只支持 C++，不具备跨语言，所以暂不讨论。</p>
</blockquote>
<p>对于上面给出的例子，<code>thrift</code> 文件定义如下：</p>
<pre><code class="language-protobuf">struct Person {
	1: required string userName,
	2: optional i64 favoriteNumber,
	3: optional list&lt;string&gt; interests
}
</code></pre>
<h4>2.4.1 BinaryProtocol</h4>
<p><strong>核心特征：</strong> <strong>定长、豪横、浪费</strong>。它不喜欢做位运算，喜欢用标准的 4 字节（32位）或 8 字节（64位）来存储数字，哪怕数字很小。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251201125109843.png" alt=""></p>
<p>我们看第一个字段 <code>userName=&quot;Martin&quot;</code>：</p>
<ol>
<li>第 1 个字节 <code>0b</code> （Type）用于表示数据类型（String）。</li>
<li>第 2-3 个字节 <code>00 01</code> 表示 Field ID = 1。用了 2 个字节表示 ID，很奢侈啊！</li>
<li>第 4-7 个字节 <code>00 00 00 06</code> 表示字符串长度为 6。用了 4 个字节表示长度，真奢侈啊！</li>
<li>第 8-13 个字节即为 <code>Martin</code> 的 ASCII 编码。</li>
</ol>
<p>再来看第二个字段 <code>favoriteNumber=1337</code>：</p>
<ol>
<li>第 1 个字节 <code>0a</code> （Type）表示数据类型 <code>I64</code>。</li>
<li>第 2-3 个字节 <code>00 02</code> 表示 Field ID = 2。</li>
<li>第 4-11 个字节，用 8 字节的定长证书来表示 1337，真是奢靡！</li>
</ol>
<p>接下来比较复杂的第三个字段 <code>interest(List)</code>：</p>
<ol>
<li>第 1 个字节 <code>0f</code> （Type）表示接下来是一个 List。</li>
<li>第 2-3 个字节 <code>00 03</code> 表示 Field ID = 3。</li>
<li>第 4 个字节 <code>0b</code> 表示数组元素的数据类型的 String。</li>
<li>第 5-8 个字节 <code>00 00 00 02</code> 表示数组列表长度是 2，又是豪横的 4 字节整数。</li>
<li>剩下的就是数组的两个元素的 Length + Value。</li>
</ol>
<p>最后还有一个结尾字符 <code>00</code>，类似于 C 语言字符串的 <code>\0</code>，表示整个 Struct 结束。</p>
<p>同样的数据，Thrift Binary Protocol 用了 <font color="red">59 bytes</font>：</p>
<h4>2.4.2 CompactProtocol</h4>
<p><strong>核心特征：</strong> <strong>变长、紧凑、巧妙</strong>。这一张图的逻辑和 Protobuf 非常像，但有一个<strong>关键的区别</strong>（Delta Encoding）。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251201125118197.png" alt=""></p>
<p>我们看第一个字段 <code>userName=&quot;Martin&quot;</code>：</p>
<ol>
<li>第 1 个字节 <code>0x18</code> （Tag）跟 Protobuf 一样，是一个组合字节，低 4 位 <code>1000</code> （Type）表示数据类型是 String，高 4 位 <code>0001</code> （Delta）表示 <strong>FieldID = 上一个 ID + 1</strong>。因为这是第一个字段，所以 ID=1。 </li>
<li>第 2 个字节 <code>06</code>（Length） 表示字符串长度为 6。Compact Protocol 使用 Varint 存储长度 6。只占 1 字节。</li>
<li>第 3-8 个字节即为 <code>Martin</code> 的 ASCII 编码。</li>
</ol>
<p>再来看第二个字段 <code>favoriteNumber=1337</code>：</p>
<ol>
<li>第 1 个字节 <code>0x16</code> （Tag）低 4 位 <code>0110</code> （Type）表示数据类型是 i64，高 4 位 <code>0001</code> （Delta）表示 <strong>FieldID = 上一个 ID + 1</strong>。因为这是第二个字段，所以 ID=1+1=2。 </li>
<li>第 2-3 个字节 <code>f2 14</code> 是 1337 的 <strong>ZigZag Varint</strong> 编码。和 Protobuf 一样，它把 1337 编码成了变长格式，只用了 2 个字节，而不是 BinaryProtocol 的 8 个字节。</li>
</ol>
<p>接下来比较复杂的第三个字段 <code>interest(List)</code>：</p>
<ol>
<li>第 1 个字节 <code>0x19</code> （Tag）低 4 位 <code>1001</code>（Type）代表数据类型 List，高 4 位 <code>0001</code> （Delta）表示 FieldID=1+2=3。</li>
<li>第 2 个字节 <code>28</code> 也是一个组合字节，低 4 位（ElemType）表示数组元素类型是 String，高 4 位（Size）代表有 2 个元组。</li>
<li>剩下的就是数组的两个元素的 Length + Value。</li>
</ol>
<p>最后一样有一个结尾字符 <code>00</code> 表示整个 Struct 结束。</p>
<p>同样的数据，Thrift Compact Protocol 用了 <font color="red">34 bytes</font>：</p>
<h3>2.5 Avro</h3>
<blockquote>
<p>Schema 与数据分离的、面向大数据的序列化协议。</p>
</blockquote>
<p><a href="https://avro.apache.org/">https://avro.apache.org/</a></p>
<p>Avro 是为 Hadoop 生态系统设计的。它的第一性原理假设是：<strong>一次定义 Schema，处理百万条数据。</strong> 因此，Avro 采取了最激进的策略：<strong>数据包里连 Field ID (Tag) 都不存。</strong></p>
<p>假设 Schema 定义如下：</p>
<pre><code class="language-json">{ &quot;type&quot;: &quot;record&quot;, &quot;fields&quot;: [
    {&quot;name&quot;: &quot;id&quot;, &quot;type&quot;: &quot;int&quot;},
    {&quot;name&quot;: &quot;name&quot;, &quot;type&quot;: &quot;string&quot;}
]}
</code></pre>
<p>对于数据 <code>id=10, name=&quot;foo&quot;</code>，Avro 的二进制流里只有： <code>[Varint 10]</code> + <code>[Length 3]</code> + <code>[Bytes &#39;foo&#39;]</code></p>
<ul>
<li><strong>没有 Key，没有 Tag：</strong> 没有任何标记告诉解析器 <code>10</code> 是 <code>id</code>。</li>
<li><strong>依序解析：</strong> 解析器必须手里拿着 Schema，严格按照顺序读：&quot;Schema 说第一个字段是 int，那我读一个 Varint；Schema 说第二个是 string，那我读一个 string...&quot;。</li>
</ul>
<p>既然没有 ID，怎么处理 Schema 变更（比如加字段）？ Avro 引入了 <strong>Writer Schema</strong>（写数据时的格式）和 <strong>Reader Schema</strong>（读数据时的格式）。 在反序列化时，Avro 库会对比这两份 Schema：</p>
<ul>
<li>如果 Reader 想要字段 A，但 Writer 里没有，且 Reader 定义了默认值，则自动填入默认值。</li>
<li>如果 Writer 有字段 B，但 Reader 不需要，则自动跳过。 这种 <strong>动态解析</strong> 能力使得它非常适合存储历史数据。</li>
</ul>
<p>Avro 的优缺点也很明显：</p>
<ul>
<li><strong>优点：</strong> 对于大批量数据（数组、文件），体积最小（因为完全去除了每条记录的元数据）。支持动态 Schema。</li>
<li><strong>缺点：</strong> 如果没有 Schema，数据完全是一堆乱码，无法解析。单条小数据传输时，如果还要带上 Schema，开销反而巨大。</li>
</ul>
<p>我们还是回到前面介绍的例子，它的 <code>avro</code> 定义如下：</p>
<pre><code class="language-protobuf">record Person {
	string userName;
	union { null, long } favoriteNumber = null;
	array&lt;string&gt; interests;
}
</code></pre>
<p>或者同等含义的 JSON 结构：</p>
<pre><code class="language-json">{
	&quot;type&quot;: &quot;record&quot;,
	&quot;name&quot;: &quot;Person&quot;,
	&quot;fields&quot;: [
		{&quot;name&quot;: &quot;userName&quot;, &quot;type&quot;: &quot;string&quot;},
		{&quot;name&quot;: &quot;favoriteNumber&quot;, &quot;type&quot;: [&quot;null&quot;, &quot;long&quot;], &quot;default&quot;: null},
		{&quot;name&quot;: &quot;interests&quot;, &quot;type&quot;: {&quot;type&quot;: &quot;array&quot;, &quot;items&quot;: &quot;string&quot;}}
	]
}
</code></pre>
<p>对于上面的例子，Avro 会编码成如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251201132124198.png" alt=""></p>
<p>如前面所说的，在解析这张图之前，解析器必须先加载对应的 <code>schema</code>。</p>
<p>我们看第一个字段 <code>userName=&quot;Martin&quot;</code>：</p>
<ol>
<li>第 1 个字节 <code>0c</code> （Length）表示字符串长度。那问题就来了，字符串 &quot;Martin&quot; 长度是 6，为什么这里是 12 (<code>0x0c</code>)？这是因为 Avro 对长度也使用了 ZigZag 编码。<ul>
<li><strong>Thrift/Protobuf</strong> 认为：长度永远是正数，所以用 <strong>无符号数 (Unsigned Varint)</strong>。</li>
<li><strong>Avro</strong> 认为：为了统一简单的底层实现，所有整数都当 <strong>有符号数 (ZigZag Varint)</strong> 处理；而且在数组场景下，长度甚至 <strong>真的可以是负数</strong>（作为一个特殊标记）。所以 Avro **<u>最低位</u>**留给符号位，所以 <code>1100</code> 中，<code>110</code> 表示大小 6，而最后的 <code>0</code> 表示正数。</li>
</ul>
</li>
<li>第 2-7 个字节即为 <code>Martin</code> 的 ASCII 编码。前面没有任何 Tag 告诉我们这是 <code>userName</code>，解析器只是因为这是第一个字段所以把它当字符串读。</li>
</ol>
<p>再来看第二个字段 <code>favoriteNumber=1337</code>：</p>
<ol>
<li>第 1 个字节 <code>02</code>  表示 Union Index，这是 Avro 的关键特性。 Schema 定义这个字段可能是 <code>null</code>，也可能是 <code>long</code>。数据流必须明确这次传的是哪个。由于 Schema 中定义的顺序是 <code>[null, long]</code>，所以 <code>index0=null</code>、<code>index1=long</code>。我们要选择 index1，又根据 ZigZag 编码，所以 $1×2=2=0x02$。</li>
<li>第 2-3 个字节 <code>f2 14</code> 是 1337 的 ZigZag Varint 编码，跟 Protobuf/Thrift Compact 完全一致。</li>
</ol>
<p>接下来比较复杂的第三个字段 <code>interest(List)</code>：</p>
<ol>
<li>第 1 个字节 <code>0x04</code> 表示接下来有 2 个元素（$2×2=4=0x04$)。</li>
<li>剩下的就是数组的两个元素的 Length + Value。</li>
<li>最后有一个 <code>00</code> 代表数组的结束标志。</li>
</ol>
<p>同样的数据，Avro 只用了 <font color="red">33 bytes</font>，这目前的最好成绩！</p>
<p>通过上图的分析，我们可以清晰地看到 Avro 与 Protobuf/Thrift 的根本区别：</p>
<ol>
<li><strong>消失的 Tag：</strong><ul>
<li>Protobuf: <code>08</code> (Field ID=1) -&gt; Value</li>
<li>Avro: 直接 Value</li>
</ul>
</li>
<li><strong>Length 的 ZigZag 化：</strong><ul>
<li>Protobuf 的长度就是单纯的 Varint。</li>
<li>Avro 连长度都要乘 2 (ZigZag)，这是为了保持整个协议整数编码的一致性。</li>
</ul>
</li>
<li>**Union 的代价：**虽然省去了 Tag，但在处理 <code>Nullable</code> 字段时，Avro 需要一个额外的字节来标记非空。</li>
</ol>
<h3>2.6 总结</h3>
<p>通过对同一个 <code>Person</code> 对象（包含 String, Int64, Array）的编码过程进行显微镜式的观察，我们可以从 <strong>空间效率</strong> 和 <strong>设计哲学</strong> 两个维度对这些数据编码协议进行最终的复盘。</p>
<p>在去除了所有不必要的空格和换行后，各协议的编码结果如下表所示：</p>
<table>
<thead>
<tr>
<th><strong>协议</strong></th>
<th><strong>最终大小</strong></th>
<th><strong>核心开销来源</strong></th>
<th><strong>技术评价</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>JSON</strong></td>
<td>81 bytes</td>
<td><strong>文本冗余</strong>：包含完整的字段键名 (<code>&quot;userName&quot;</code>)、结构符号 (<code>{</code>,<code>:</code>) 及数字的文本表示。</td>
<td><strong>极低效率</strong>：保留了完全的可读性与自描述性，但空间代价最高。</td>
</tr>
<tr>
<td><strong>MessagePack</strong></td>
<td>66 bytes</td>
<td><strong>键名冗余</strong>：虽然移除了结构符号并对数字进行了二进制处理，但依然保留了完整的字段键名。</td>
<td><strong>低效率</strong>：仅解决了 JSON 的解析速度与部分体积问题，未解决结构冗余。</td>
</tr>
<tr>
<td><strong>Thrift Binary</strong></td>
<td>59 bytes</td>
<td><strong>定长编码</strong>：使用固定的 4 字节或 8 字节存储整数与长度，不进行 Varint 压缩。</td>
<td><strong>中等效率</strong>：以空间换时间，追求内存映射级别的解析速度。</td>
</tr>
<tr>
<td><strong>Thrift Compact</strong></td>
<td>34 bytes</td>
<td><strong>Delta Encoding</strong>：字段 ID 采用差值存储；<strong>ZigZag</strong>：整数采用变长编码。</td>
<td><strong>高效率</strong>：通过位运算极大降低了元数据占比。</td>
</tr>
<tr>
<td><strong>Protobuf</strong></td>
<td>33 bytes</td>
<td><strong>Tag 机制</strong>：使用数字 ID 替代文本键名；<strong>Varint</strong>：整数变长压缩。</td>
<td><strong>高效率</strong>：利用静态 IDL 契约，实现了极高的信噪比。</td>
</tr>
<tr>
<td><strong>Avro</strong></td>
<td>32 bytes</td>
<td><strong>Schema 分离</strong>：移除 Field ID (Tag)，仅保留数据值与必要的长度/索引信息。</td>
<td><strong>极致效率</strong>：完全依赖 Schema 顺序解析，适合大批量数据存储。</td>
</tr>
</tbody></table>
<p>从底层设计原理来看，这几种协议代表了三种不同的数据治理哲学：</p>
<ol>
<li><strong>自描述模式 (Self-describing) —— JSON, MessagePack</strong><ul>
<li><strong>特征：</strong> 数据包内部自带 Schema 信息（键名、类型）。</li>
<li><strong>优势：</strong> 灵活性极高，无需预定义 IDL，完全解耦。</li>
<li><strong>劣势：</strong> 存在大量冗余信息，不适合高频或高吞吐场景。</li>
</ul>
</li>
<li><strong>静态契约模式 (Static IDL) —— Protobuf, Thrift</strong><ul>
<li><strong>特征：</strong> 依赖预定义的 IDL 文件（<code>.proto</code> / <code>.thrift</code>）。数据包通过 Field ID（Tag）与 IDL 映射。</li>
<li><strong>优势：</strong> 实现了强类型约束与向后兼容性（Tag 机制），解析速度快。</li>
<li><strong>劣势：</strong> 需要维护 IDL 文件，客户端与服务端需同步更新代码。</li>
</ul>
</li>
<li><strong>动态分离模式 (Schema-on-Read) —— Avro</strong><ul>
<li><strong>特征：</strong> 数据与 Schema 分离（或在文件头仅定义一次）。数据体中不包含任何字段标识，仅包含值。</li>
<li><strong>优势：</strong> 在处理大规模数据集（如数仓文件）时，消除了每条记录的元数据开销。支持读写 Schema 动态演进。</li>
<li><strong>劣势：</strong> 必须严格依赖 Schema 解析，单条数据传输时若需附带 Schema 则开销巨大。</li>
</ul>
</li>
</ol>
<p>在实际架构设计中，应根据业务场景的 <strong>I/O 特性</strong> 与 <strong>协作模式</strong> 进行选择：</p>
<ul>
<li><strong>对外 API / 前端交互 / 调试接口</strong> $\rightarrow$ <strong>JSON</strong><ul>
<li>优先考虑可读性与通用性，浏览器原生支持是其不可替代的优势。</li>
</ul>
</li>
<li><strong>微服务内部通信 (RPC)</strong> $\rightarrow$ <strong>Protobuf (gRPC)</strong><ul>
<li>强契约（IDL）能有效降低多人协作中的接口不一致风险，且 Google 生态支持完善。</li>
</ul>
</li>
<li><strong>大数据存储与离线分析 (Data Lake)</strong> $\rightarrow$ <strong>Avro</strong><ul>
<li>在 HDFS/S3 存储 TB 级数据时，移除 Tag 带来的存储成本节省十分显著，且适合 Schema 频繁变更的 ETL 场景。</li>
</ul>
</li>
<li><strong>遗留系统或特定语言栈</strong> $\rightarrow$ <strong>Thrift</strong><ul>
<li>如果需要完整的 RPC 框架且不仅限于序列化（如需要特定的 Server 模型），或者在 Protobuf 支持较弱的语言环境中使用。</li>
</ul>
</li>
</ul>
<h2>3. 总结</h2>
<p>回顾 gRPC 的设计架构，我们可以清晰地看到，其高性能并非源于单一技术的突破，而是源于 <strong>传输层 (HTTP/2)</strong> 与 <strong>表示层 (Protobuf)</strong> 两个维度的深度优化叠加。gRPC 从第一性原理出发，分别解决了网络通信中的&quot;拥塞&quot;与&quot;冗余&quot;问题。</p>
<p>在表示层，gRPC 坚定地选择了 <strong>Protocol Buffers</strong>，这不仅仅是为了更小的体积，更是为了更严谨的契约。</p>
<ul>
<li><strong>极高的信噪比</strong>：通过前文的字节级解剖，我们看到 Protobuf 将一个包含丰富信息的 <code>Person</code> 对象压缩至 <strong>33 bytes</strong>，仅为 JSON (81 bytes) 的 <strong>40%</strong>。它通过移除字段名（Keys）并使用 Varint/ZigZag 压缩数字，极大地减少了网络带宽的占用。</li>
<li><strong>解析效率</strong>：二进制协议允许计算机通过位运算直接解析数据，避免了文本协议中昂贵的字符串匹配与浮点数转换开销。</li>
<li><strong>强契约保证</strong>：IDL (<code>.proto</code>) 的存在使得通信双方必须遵守严格的类型约束，这种“静态”特性消除了运行时猜测数据类型的成本，同时也为大规模微服务治理提供了坚实的基础。</li>
</ul>
<p>在传输层，gRPC 摒弃了文本格式的 HTTP/1.1，全面拥抱二进制的 <strong>HTTP/2</strong>，这从物理上改变了连接的使用方式。</p>
<ul>
<li><strong>多路复用 (Multiplexing)</strong>：这是 HTTP/2 最核心的优势。gRPC 允许在同一个 TCP 连接上并发处理多个请求（Stream）。每个 Request/Response 被拆分成多个二进制帧 (Frame) 并打乱发送，接收端根据 Stream ID 重新组装。这彻底解决了 HTTP/1.1 的 <strong>队头阻塞 (Head-of-Line Blocking)</strong> 问题，使得单一连接的吞吐量成倍提升。</li>
<li><strong>头部压缩 (HPACK)</strong>：在微服务架构中，RPC 调用往往伴随着大量重复的 Header (如 Auth Token, Tracing ID)。HTTP/2 使用 HPACK 算法在客户端和服务端维护动态字典，对 Header 进行增量压缩，进一步减少了带宽消耗。</li>
<li><strong>双向流 (Bi-directional Streaming)</strong>：得益于 HTTP/2 的流特性，gRPC 原生支持四种通信模式，使得实时推送、长连接对话等复杂业务场景的实现变得像普通函数调用一样简单。</li>
</ul>
<p>最终，彻底掌握 gRPC 意味着理解它在 <strong>互操作性</strong> 与 <strong>性能</strong> 之间所做的权衡：</p>
<ol>
<li><strong>它不是万能的</strong>：在浏览器前端、简单的 CRUD 接口或对调试可读性要求极高的场景下，REST/JSON 依然是更优的选择。</li>
<li><strong>它是云原生的通用语</strong>：在微服务内部通信、移动端与后端的长连接交互、以及低延迟高吞吐的系统中，gRPC 凭借其 <strong>Protobuf 的极致编码</strong> 与 <strong>HTTP/2 的高效传输</strong>，成为了现代分布式系统事实上的标准。</li>
</ol>
<p>理解了这些底层原理，我们才能在架构选型时，不盲目跟风，而是根据业务的真实需求（是追求极致的 Bytes 节省，还是追求开发的灵活性），做出最准确的技术决策。</p>
]]></content:encoded>
    </item>
    <item>
      <title>HTTP、SSE、WebSocket、gRPC、Streamable HTTP：原理与场景全方位对比分析</title>
      <link>https://hedon.top/blog/net-http-sse-ws-grpc/</link>
      <guid isPermaLink="true">https://hedon.top/blog/net-http-sse-ws-grpc/</guid>
      <pubDate>Mon, 01 Dec 2025 10:41:00 GMT</pubDate>
      <description>本文以第一性原理为出发点，剖析 HTTP、SSE、WebSocket、gRPC 以及 Streamable HTTP 等主流应用层协议的核心设计思想、演化动因与各自适用场景，助你真正理解它们的本质区别与选择依据。</description>
      <category>HTTP</category><category>Streamable HTTP</category><category>SSE</category><category>WebSocket</category><category>gRPC</category><category>计算机网络</category><category>计算机基础</category>
      <content:encoded><![CDATA[<p>笔者近期在学习 AI 相关技术的时候，发现 MCP 相关的调用方式很多都已经废弃 SSE（Server-Sent Event）而推荐使用 Streamable HTTP 了。于是在对二者进行学习和对比了之后，决定趁机将网络交互相关的应用层协议都总结一下。</p>
<p>本文不会特别深入和细节地探讨各个协议的底层实现，会尽量从第一性原理去介绍为什么要出现这些协议，以及这些协议的适应场景是什么。</p>
<p>要真正理解 HTTP、Streamable HTTP、SSE、WebSocket 和 gRPC这几者的区别，我们不能仅停留在 API 调用的表面，而需要<strong>回到网络通信的第一性原理：TCP 连接的使用方式、数据编码格式以及协议层面的抽象。<strong>绝大多数现代网络通信（除了 HTTP/3 基于 UDP）都建立在 TCP 之上。这五种技术的本质区别，实际上是</strong>如何利用 TCP 连接</strong>以及<strong>如何定义数据交换的格式</strong>。</p>
<h2>1. 协议速览</h2>
<h3>HTTP/1.1</h3>
<p>这是互联网的基石。在最原始的模型中，它遵循一次请求，一次连接的模式（虽然 Keep-Alive 改善了复用，但逻辑上仍是独立的）。</p>
<ul>
<li><strong>底层原理：</strong> 客户端发起 TCP 握手 -&gt; 发送 HTTP Header + Body -&gt; 服务器处理 -&gt; 返回 HTTP Header + Body -&gt; (可能) 关闭连接。</li>
<li><strong>通信模式：</strong> 严格的半双工（Half-duplex）逻辑。客户端不问，服务器不说。</li>
<li><strong>数据格式：</strong> 通常是 JSON (文本) 或 XML。可读性好，但序列化/反序列化有性能损耗。</li>
<li><strong>缺点：</strong> 头部冗余（Header overhead）大。如果要实时获取数据，必须使用 <strong>轮询 (Polling)</strong>，这会制造大量的无效请求，浪费服务器资源和带宽。</li>
</ul>
<blockquote>
<p>关于 HTTP 更多细节可参阅：<a href="https://hedon.top/2025/11/29/computer-net/net-http/">从 HTTP1.0 到 HTTP3 的演化</a></p>
</blockquote>
<h3>Streamable HTTP</h3>
<p>Streamable HTTP 本质上仍然是 HTTP，但利用了 HTTP/1.1 的一个特性：<code>Transfer-Encoding: chunked</code>。</p>
<ul>
<li><strong>底层原理：</strong><ol>
<li>客户端发送标准 HTTP 请求。</li>
<li>服务器在响应头中声明 <code>Transfer-Encoding: chunked</code>，并不返回 <code>Content-Length</code>（因为长度未知）。</li>
<li>服务器通过同一个 TCP 连接，分批次（Chunk）发送数据块。</li>
<li>发送一个长度为 0 的块表示传输结束。</li>
</ol>
</li>
<li><strong>通信模式：</strong> 单向流（Server -&gt; Client）。连接保持打开，直到传输完成。</li>
<li><strong>适用场景：</strong> 生成式 AI（LLM）的打字机效果、大文件下载、动态生成的报表。</li>
</ul>
<h3>SSE</h3>
<p>SSE 是 Streamable HTTP 的一种标准化封装，专门用于浏览器端的服务器推送。它规定了特定的 <code>Content-Type: text/event-stream</code> 和数据格式（<code>data: ...</code>）。</p>
<ul>
<li><strong>底层原理：</strong><ol>
<li>客户端发起 HTTP 请求。</li>
<li>服务器挂起连接，不关闭。</li>
<li>服务器有数据时，通过该连接直接写入遵循特定格式的文本流。</li>
<li>浏览器原生支持 <code>EventSource</code> API，能自动处理断线重连。</li>
</ol>
</li>
<li><strong>与 WebSocket 的本质区别：</strong> SSE 依然是 HTTP 协议。它不需要协议升级，防火墙友好，但只能 <strong>服务器 -&gt; 客户端</strong> 单向传输。</li>
<li><strong>适用场景：</strong> 股票行情更新、新闻推送、CI/CD 日志流、系统状态监控。</li>
</ul>
<blockquote>
<p>关于 SSE 更多细节可参阅：<a href="/blog/rust-action-sse/">Rust 实战丨SSE(Server-Sent Events)</a></p>
</blockquote>
<h3>WebSocket</h3>
<p>WebSocket 旨在解决 HTTP &quot;请求-响应&quot;模式在实时双向通信上的无能。它从 HTTP 开始，但随后&quot;背叛&quot;了 HTTP。</p>
<ul>
<li><strong>底层原理：</strong><ol>
<li><strong>握手：</strong> 客户端发送一个 HTTP 请求，带上 <code>Upgrade: websocket</code> 头。</li>
<li><strong>升级：</strong> 服务器如果同意，返回 101 Switching Protocols。</li>
<li><strong>裸奔：</strong> 此刻起，HTTP 协议层消失，连接变成了原始的 TCP 通道（Over TCP）。</li>
<li><strong>帧（Frame）：</strong> 双方可以在这个通道上自由地发送自定义的二进制帧或文本帧，不再受 HTTP Header 的束缚。</li>
</ol>
</li>
<li><strong>通信模式：</strong> 真正的全双工（Full-duplex）。客户端和服务器地位对等，谁都可以随时发消息。</li>
<li><strong>缺点：</strong> 状态管理复杂（需要处理心跳、重连、鉴权），且不支持 HTTP 的语义（如 404、500 状态码，需要自己定义业务层协议）。</li>
<li><strong>适用场景：</strong> 多人在线游戏、实时聊天室、协同编辑文档。</li>
</ul>
<h3>gRPC</h3>
<p>gRPC 是 Google 开发的高性能框架。它和前面几个不在一个维度：WebSocket 是一种协议，而 gRPC 是一个框架，它默认基于 <strong>HTTP/2</strong> 协议。</p>
<ul>
<li><strong>底层原理：</strong><ol>
<li><strong>HTTP/2 多路复用：</strong> 也就是在一个 TCP 连接上并发处理多个请求，解决了 HTTP/1.1 的队头阻塞问题。</li>
<li><strong>Protobuf (Protocol Buffers)：</strong> 放弃 JSON，使用二进制序列化。数据极其紧凑，且需要预先定义 <code>.proto</code> 文件（Schema）。</li>
<li><strong>RPC (远程过程调用)：</strong> 对开发者屏蔽了网络细节。你调用远程服务器的 <code>GetUser()</code> 方法，就像调用本地函数一样。</li>
</ol>
</li>
<li><strong>gRPC 支持四种模式：</strong><ol>
<li>简单 RPC（类似标准 HTTP）。</li>
<li>服务端流式（类似 SSE）。</li>
<li>客户端流式。</li>
<li>双向流式（类似 WebSocket）。</li>
</ol>
</li>
<li><strong>缺点：</strong> 浏览器支持较差（需要 gRPC-Web 代理），调试不如 JSON 直观（是乱码的二进制）。</li>
<li><strong>适用场景：</strong> 微服务内部通信（高频、低延迟）、IoT 设备通信、多语言混合开发环境。</li>
</ul>
<h3>对比</h3>
<table>
<thead>
<tr>
<th><strong>技术方案</strong></th>
<th><strong>本质 (First Principles)</strong></th>
<th><strong>通信模型</strong></th>
<th><strong>数据形态</strong></th>
<th><strong>核心适用场景</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>HTTP/1.1 (REST)</strong></td>
<td><strong>短连接</strong></td>
<td>问答式 (Request-Response)</td>
<td>JSON (文本)</td>
<td>传统的 CRUD 业务，无实时性要求。</td>
</tr>
<tr>
<td><strong>WebSocket</strong></td>
<td><strong>全双工隧道</strong> (TCP 裸连)</td>
<td>自由对话 (Bi-directional)</td>
<td>二进制 / 文本</td>
<td><strong>高频低延迟</strong>场景（游戏、协同编辑、IM）。</td>
</tr>
<tr>
<td><strong>SSE (Standard)</strong></td>
<td><strong>单向订阅</strong> (GET 请求)</td>
<td>广播式 (Pub / Sub)</td>
<td>文本 (<code>data: ...</code>)</td>
<td><strong>轻量级推送</strong>（股票、新闻、简单的系统通知）。</td>
</tr>
<tr>
<td><strong>Streamable HTTP</strong></td>
<td><strong>分块传输</strong> (POST 请求)</td>
<td>管道式 (Pipeline)</td>
<td>NDJSON / Bytes</td>
<td><strong>AI 生成</strong>（ChatGPT）、大文件下载、复杂 RPC。</td>
</tr>
<tr>
<td><strong>gRPC</strong></td>
<td><strong>RPC 框架</strong> (HTTP/2)</td>
<td>远程调用 (Function Call)</td>
<td>Protobuf (二进制)</td>
<td><strong>微服务内部通信</strong>（高效、强类型、多语言）。</td>
</tr>
</tbody></table>
<h2>2. 为什么 MCP 弃用 SSE</h2>
<p><a href="https://blog.fka.dev/blog/2025-06-06-why-mcp-deprecated-sse-and-go-with-streamable-http/">https://blog.fka.dev/blog/2025-06-06-why-mcp-deprecated-sse-and-go-with-streamable-http/</a></p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--info" aria-hidden="true"></span></p>
<p><strong>color=&quot;orange&quot; 总结</strong></p>
<p><strong>废弃 SSE 不是因为它传输文本格式不好，而是因为&quot;长连接订阅模式&quot;在复杂的 RPC（远程过程调用）场景下是一种架构错误。</strong></p>
<p>MCP 从 SSE 转向 Streamable HTTP，本质上是从 <strong>异步的消息总线模式</strong> 回归到了 <strong>同步流式的 RPC 模式</strong>。后者更简单、更健壮，也更符合 AI Agent 这种&quot;一来一回&quot;的思考特性。</p>
</blockquote>
<h3>2.1 核心痛点：双通道架构</h3>
<p>这是博客中提到的最直观的原因。</p>
<ul>
<li><p><strong>旧的 SSE 模式（Legacy MCP）：</strong> 你必须维护<strong>两条</strong>独立的连接才能完成一次对话：</p>
<ol>
<li><strong>听筒（GET <code>/sse</code>）：</strong> 建立一个长连接，专门用来<strong>听</strong>服务器说话（接收 Event）。</li>
<li><strong>话筒（POST <code>/messages</code>）：</strong> 每次要说话时，发起一个新的短连接，专门用来<strong>发</strong>指令。</li>
</ol>
<blockquote>
<p><strong>比喻：</strong> 就像你给朋友打电话，手里拿着两个手机。左手拿一个手机只听不因，右手拿另一个手机只发短信。朋友回话还得通过左手的手机传过来。</p>
</blockquote>
</li>
<li><p><strong>新的 Streamable HTTP 模式：</strong> <strong>回归单通道。</strong> 你发送一个 <code>POST</code> 请求（说话），服务器直接在这个请求的 Response 里通过流式传输回话（听）。代码复杂度指数级下降。不再需要维护一个&quot;永远在线&quot;的幽灵连接，也不需要处理&quot;指令发出去了，但接收通道断了&quot;这种分布式系统里的脑裂状态。</p>
</li>
</ul>
<h3>2.2 基础设施的敌意</h3>
<p>博客中重点提到了这一点，这是运维层面的第一性原理。</p>
<ul>
<li><strong>SSE 的长连接诅咒：</strong> 旧版 MCP 要求 <code>/sse</code> 连接必须<strong>一直活着</strong>。<ul>
<li><strong>现实世界：</strong> 企业的防火墙、Nginx 负载均衡器、云服务网关（AWS ALB, Cloudflare）非常讨厌占着茅坑不拉屎的空闲长连接。它们会强行切断这些连接（Timeout）。</li>
<li><strong>后果：</strong> 客户端必须不断写复杂的保活（Keep-alive）和重连逻辑。</li>
</ul>
</li>
<li><strong>Streamable HTTP 的优势：</strong> 它是**按需（On-Demand）**的。<ul>
<li>有任务？发个 POST，保持连接直到任务结束。</li>
<li>没任务？连接自然关闭。</li>
<li>这完全符合 HTTP 的设计初衷，对所有的中间件（Middleware）都非常友好。</li>
</ul>
</li>
</ul>
<h3>2.3 状态管理的灾难</h3>
<p>这也是博客中提到的关键技术细节。</p>
<ul>
<li><strong>SSE 的状态同步难题：</strong> 在旧模式下，因为&quot;听&quot;和&quot;说&quot;是分离的，很容易出现竞态条件（Race Condition）。<ul>
<li>比如：客户端刚发了一个 POST 指令，但在服务器回包之前，SSE 连接断了。此时服务器把结果推给了谁？数据丢了吗？客户端重连后还能收到刚才的结果吗？</li>
<li>为了解决这个问题，需要引入复杂的 Session ID 和消息队列机制。</li>
</ul>
</li>
<li><strong>Streamable HTTP 的原子性：</strong> 请求和响应绑定在同一个 TCP 上下文里。<ul>
<li>连接断了 = 请求失败。逻辑非常清晰（要么成功，要么重试），不需要在应用层去猜测数据去哪了。</li>
</ul>
</li>
</ul>
<h3>2.4 为什么博客中提到它依然支持 SSE 格式？</h3>
<p>这是一个容易混淆的点。</p>
<p>博客中澄清了：<strong>Streamable HTTP 依然可以使用 <code>text/event-stream</code> 作为数据传输的格式（Framing），但它改变的是传输的载体（Transport）。</strong></p>
<ul>
<li><strong>旧 MCP：</strong> SSE 是一个<strong>订阅频道</strong>（Pub/Sub）。</li>
<li><strong>新 MCP：</strong> SSE 只是 POST 响应体里的一种<strong>编码方式</strong>。</li>
</ul>
<p>OpenAI 和 Anthropic 现在的做法也是如此：他们不再使用浏览器原生的 <code>EventSource</code>（那个只能 GET 的订阅 API），而是使用 <code>fetch</code> POST 请求，然后把响应体当成流来处理。</p>
]]></content:encoded>
    </item>
    <item>
      <title>从 HTTP1.0 到 HTTP3 的演化</title>
      <link>https://hedon.top/blog/net-http/</link>
      <guid isPermaLink="true">https://hedon.top/blog/net-http/</guid>
      <pubDate>Sat, 29 Nov 2025 14:17:00 GMT</pubDate>
      <description>本文梳理了从 HTTP/1.0 到 HTTP/3 的发展历程，介绍了各版本在连接管理、性能优化及协议升级方面的核心变化，帮助读者理解现代 Web 通信协议的演化脉络。</description>
      <category>HTTP</category><category>HTTP1.0</category><category>HTTP2</category><category>HTTP3</category><category>计算机网络</category><category>QUIC</category><category>计算机基础</category>
      <content:encoded><![CDATA[<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/https%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F82f1e76a-8fbf-4e8f-b030-e20037c66f70_3000x3900.png" alt=""></p>
<h2>HTTP1.0</h2>
<p>HTTP/1.0 的设计初衷只是为了在网络上传输 HTML 文档。</p>
<ul>
<li><p><strong>模型</strong>：Request-Response（一问一答）。</p>
</li>
<li><p><strong>底层</strong>：基于 TCP。</p>
</li>
<li><p><strong>瓶颈</strong>：<strong>TCP 连接成本过高</strong>。</p>
<ul>
<li><p>在 1.0 默认行为中，每一次 HTTP 请求（比如下载一张图片）都要新建一个 TCP 连接。</p>
</li>
<li><p><strong>TCP 三次握手</strong>：这意味着每个请求都要先消耗 1.5 个 RTT（往返时延）才能开始发数据。</p>
</li>
<li><p><strong>TCP 慢启动（Slow Start）</strong>：新连接的速度是从低到高爬坡的，短连接导致 TCP 永远在低速爬坡阶段就断开了，带宽利用率极低。</p>
</li>
</ul>
</li>
</ul>
<h2>HTTP1.1</h2>
<p>为了解决连接成本问题，HTTP/1.1 在 1999 年成为了标准，并统治互联网长达 15 年。</p>
<h3>1. Keep-Alive（长连接）：解决连接复用</h3>
<ul>
<li><strong>机制</strong>：引入持久连接（Persistent Connection）。默认 <code>Connection: keep-alive</code>。</li>
<li><strong>效果</strong>：只要连接不断开，后续请求可以直接发送，省去了三次握手和 TCP 慢启动的开销。</li>
<li><strong>细节</strong>：设置 <code>Keep-Alive: timeout=5, max=100</code> 来控制连接的生命周期。</li>
</ul>
<h3>2. Pipeline（管道化）：解决串行等待（失败的尝试）</h3>
<ul>
<li><strong>尝试</strong>：允许客户端一口气发出去 10 个请求，不用等第一个回来再发第二个。</li>
<li><strong>致命缺陷</strong>：<strong>HTTP 层面的队头阻塞（HOL Blocking）</strong>。<ul>
<li>服务器必须按照接收请求的顺序返回响应（FIFO）。如果第 1 个请求处理很慢（比如数据库查询），第 2-10 个请求即使处理完了，也必须排队等待第 1 个发送完毕。</li>
<li>由于这个风险，浏览器厂商默认都关闭了 Pipeline。</li>
</ul>
</li>
</ul>
<h3>3. Transfer-Encoding: chunked（分块传输）</h3>
<ul>
<li><strong>痛点</strong>：HTTP/1.0 需要在 Header 里通过 <code>Content-Length</code> 告诉对方包体多大。如果是动态生成的流（比如视频、动态页面），服务器必须先把所有数据生成完算出长度才能发，延迟太高。</li>
<li><strong>解决</strong>：<code>chunked</code> 模式允许服务器产生一部分数据就发一部分，最后发一个长度为 0 的块表示结束。这使得<strong>流式传输</strong>成为可能。</li>
</ul>
<h3>4. 缓存控制增强</h3>
<ul>
<li>引入 <code>Cache-Control</code>、<code>Etag</code>、<code>If-None-Match</code>，比 1.0 的 <code>Expires</code> 和 <code>Last-Modified</code> 提供了更精细的缓存策略，节省带宽。</li>
</ul>
<h2>HTTP2</h2>
<p>HTTP/1.1 虽然有了长连接，但本质上请求还是<strong>串行</strong>的。为了并发下载资源，浏览器被迫对同一个域名开启 6 个 TCP 连接。HTTP/2 旨在解决这个问题。</p>
<h3>1. 核心改变：二进制分帧层 (Binary Framing Layer)</h3>
<p>这是 HTTP/2 的基石。</p>
<ul>
<li><strong>HTTP/1.1</strong>：纯文本，换行符分隔。解析慢，且无法交错。</li>
<li><strong>HTTP/2</strong>：将报文切分为更小的<strong>Frame（帧）</strong>。<ul>
<li><strong>Headers Frame</strong>：存放头部。</li>
<li><strong>Data Frame</strong>：存放包体。</li>
</ul>
</li>
<li><strong>意义</strong>：机器解析二进制比解析文本快得多，更重要的是，这为多路复用提供了基础。</li>
</ul>
<h3>2. 多路复用 (Multiplexing)：解决 HTTP 队头阻塞</h3>
<ul>
<li><strong>机制</strong>：在一个 TCP 连接中，通过 <strong>Stream（流）</strong> 的概念，同时传输多个请求的数据帧。<ul>
<li>每个帧都有一个 <code>Stream ID</code>。</li>
<li>比如：Stream 1 的帧和 Stream 2 的帧可以<strong>乱序</strong>发送，接收端根据 ID 重新组装。</li>
</ul>
</li>
<li><strong>彻底解决</strong>：HTTP/1.1 的队头阻塞。请求 A 的处理慢，完全不影响请求 B 的数据传输。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/HTTP1.1VSHTTP2.png" alt=""></p>
<h3>3. HPACK：头部压缩</h3>
<ul>
<li><strong>痛点</strong>：HTTP/1.1 的 Header 很多是冗余的（如 Cookie, User-Agent），每次都要重复发几百字节，浪费带宽。</li>
<li><strong>原理</strong>：<ul>
<li><strong>静态表</strong>：预定义 61 个常用头部（如 <code>method: GET</code> 对应索引 2）。传输时只传索引号（1 个字节）。</li>
<li><strong>动态表</strong>：连接建立后，双方动态维护一个表。第一次发过 <code>User-Agent</code> 后，把它存入动态表，下次只发索引即可。</li>
<li><strong>Huffman 编码</strong>：对字符串进行压缩。</li>
</ul>
</li>
</ul>
<h2>TLS1.2</h2>
<p>在进入 HTTP3 之前，先来阐述一下 TLS1.2 和 TLS1.3，它们都是 HTTPS 的基石。</p>
<p>HTTPS 常用的密钥交换算法有两种，分别是 RSA 和 ECDHE 算法。</p>
<p>其中，RSA 是比较传统的密钥交换算法，它不具备前向安全的性质，因此现在很少服务器使用的。而 ECDHE 算法具有前向安全，所以被广泛使用。</p>
<h3>1. RSA</h3>
<p>我们先回顾一下 RSA 方式的密钥交换，笔者发现大多数的书籍和文章介绍的都是这种方式。但事实上因为它不具备前向安全的性质，现在很少被服务器所使用。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251130232202995.png" alt=""></p>
<ol>
<li><strong>客户端发送 ClientHello</strong>：客户端向服务器发送 <code>ClientHello</code> 消息，包含支持的 TLS 版本、加密算法列表、压缩方法以及一个随机数（Client Random），用于后续生成会话密钥。</li>
<li><strong>服务器发送 ServerHello</strong>：服务器收到 <code>ClientHello</code> 后，选择一个 TLS 版本和加密算法，发送 <code>ServerHello</code> 消息，包含选择的版本、加密算法、另一个随机数（Server Random）以及服务器的数字证书（包含公钥和 CA 信息）。</li>
<li><strong>客户端验证证书并生成预主密钥</strong>：<ul>
<li>客户端验证服务器的数字证书，检查是否由受信任的 CA 颁发（一直验证到根 CA）、是否在有效期内、域名是否匹配。如果验证通过，客户端生成一个预主密钥（Pre-Master Secret），并用服务器的公钥加密后发送给服务器。</li>
<li>结合预主密钥，结合 Client Random 和 Server Random，生成对称加密的会话密钥（Session Key）。</li>
<li>并把迄今为止的通信数据内容生成一个摘要，形成 <code>Finished</code> 报文，发送给服务端。</li>
</ul>
</li>
<li><strong>服务端生成会话密钥</strong>：<ul>
<li>服务器使用私钥解密客户端发送的预主密钥，结合 Client Random 和 Server Random，用跟客户端相同的算法生成对称加密的会话密钥（Session Key）。</li>
<li>然后发送 <code>Finished</code> 报文。</li>
</ul>
</li>
<li><strong>完成握手</strong>：如果双方都能正确解密对方的 <code>Finished</code> 消息，握手成功。</li>
<li><strong>加密传输数据</strong>：握手完成后，客户端和服务器使用会话密钥对所有通信数据进行加密和解密，确保数据的机密性和完整性。</li>
</ol>
<h3>2. ECDHE</h3>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>向前安全的定义</strong></p>
<p>即便<strong>现在</strong>私钥泄漏了，也无法破解<strong>过去</strong>截获的流量。</p>
</blockquote>
<p>为什么说 TLS 1.2 使用 RSA 握手会有前向安全（Forward Secrecy）的问题呢？</p>
<p>在 TLS 1.2 (RSA) 中，最终用来加密数据的 <strong>Session Key</strong> 是这样算出来的：</p>
<p>$$
Session\ Key = Function(ClientRandom, ServerRandom, Pre\text{-}Master\ Secret)
$$</p>
<p>因为 ClientRandom 和 ServerRandom 都是明文的，所以<strong>整个会话的安全性，完全变成了&quot;如何保护 Pre-Master Secret&quot;这一个问题</strong>。</p>
<p>只要私钥一旦泄露，那黑客就可以根据过完的 ClientRandom 和 ServerRandom 以及解密后的 pre-master-secret 还原所有的历史消息。</p>
<p><strong>ECDHE</strong> 旨在解决这个问题。这个名字由四部分组成：</p>
<ul>
<li><strong>EC (Elliptic Curve)</strong>：椭圆曲线。相比 RSA，它用更短的密钥就能实现同等强度，计算更快，带宽更省。</li>
<li><strong>DH (Diffie-Hellman)</strong>：密钥交换算法。它的魔力在于：<strong>双方只交换公钥，就能算出同一个密钥，而无需传递密钥本身。</strong></li>
<li><strong>E (Ephemeral)</strong>：<strong>临时（关键点！）</strong>。指每次握手生成的&quot;私钥&quot;都是临时的，用完即焚。</li>
</ul>
<p>它的核心原理是：<strong>核心原理（离散对数难题）</strong>。</p>
<p>为了不陷入复杂的数学证明，我们用最简化的数学逻辑描述：</p>
<p>假设椭圆曲线上有一个基点 $G$。</p>
<ol>
<li><strong>Client</strong> 生成一个随机临时私钥 $a$，计算公钥 $A = a \cdot G$，发给 Server。</li>
<li><strong>Server</strong> 生成一个随机临时私钥 $b$，计算公钥 $B = b \cdot G$，发给 Client。</li>
<li><strong>计算共享密钥</strong>：<ul>
<li>Client 计算：$S = a \cdot B = a \cdot b \cdot G$</li>
<li>Server 计算：$S = b \cdot A = b \cdot a \cdot G$</li>
<li>结果一样！双方都得到了 $S$。</li>
</ul>
</li>
</ol>
<blockquote>
<p><strong>注意</strong>：黑客截获了 $A$ 和 $B$，但根据椭圆曲线离散对数难题，他无法反推出 $a$ 或 $b$，因此算不出 $S$。</p>
</blockquote>
<p>为什么 ECDHE 有前向安全？在 ECDHE 中，<code>Session Key</code> 依然需要三个参数，但第三个参数的来源变了：</p>
<ol>
<li>ClientRandom（明文，可见）。</li>
<li>ServerRandom（明文，可见）。</li>
<li><strong>ECDH 算出的共享密钥</strong>（替代了原来的 Pre-Master Secret）。</li>
</ol>
<p><strong>关键区别在于</strong>：</p>
<ul>
<li>这个共享密钥是 Client 和 Server 通过交换**临时的（Ephemeral）**公钥 $A$ 和 $B$ 算出来的。</li>
<li>在这个过程中，<strong>服务器的长期私钥仅仅用来&quot;签名&quot;</strong>（证明 $B$ 是我发的），而<strong>不参与密钥的加密或计算</strong>。</li>
<li>握手结束后，Client 和 Server 会把计算用的临时私钥 $a$ 和 $b$ 删掉。</li>
</ul>
<p>我们来看使用 ECDHE 的握手流程：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251130231349481.png" alt=""></p>
<ol>
<li><p><strong>客户端发送 ClientHello：</strong> 客户端向服务器发送 ClientHello 消息，包含支持的 TLS 版本、加密算法列表（必须包含 <strong>ECDHE</strong> 算法）、压缩方法以及一个随机数（<strong>Client Random</strong>）。</p>
</li>
<li><p><strong>服务器发送 ServerHello 和密钥协商参数：</strong> 服务器收到 ClientHello 后，选择 ECDHE 算法，发送 ServerHello（包含选择的版本、算法、<strong>Server Random</strong>）以及服务器数字证书。</p>
<blockquote>
<p><strong>关键区别</strong>：服务器会在本地生成一对<strong>临时的</strong>椭圆曲线公私钥。它将<strong>服务器临时公钥</strong>发送给客户端，并用证书中的<strong>长期私钥</strong>对这个临时公钥进行<strong>数字签名</strong>，以防止篡改。</p>
</blockquote>
</li>
<li><p><strong>客户端验证并协商预主密钥：</strong></p>
<ul>
<li><p>客户端验证服务器证书的合法性。并用证书中的长期公钥验证收到的服务器临时公钥的<strong>签名</strong>是否有效。</p>
</li>
<li><p>验证通过后，客户端也在本地生成一对<strong>临时的</strong>椭圆曲线公私钥。</p>
</li>
<li><p><strong>计算预主密钥</strong>：客户端利用<strong>自己的临时私钥</strong>和收到的<strong>服务器临时公钥</strong>，通过 ECDH 算法在本地计算出预主密钥（Pre-Master Secret）。</p>
</li>
<li><p>客户端将<strong>自己的临时公钥</strong>明文发送给服务器。</p>
</li>
<li><p>结合算出的预主密钥、Client Random 和 Server Random，生成会话密钥（Session Key），并发送 Finished 报文。</p>
</li>
</ul>
</li>
<li><p><strong>服务端计算预主密钥：</strong></p>
<ul>
<li><strong>计算预主密钥</strong>：服务器收到客户端的临时公钥后，利用<strong>自己的临时私钥</strong>和收到的<strong>客户端临时公钥</strong>，通过同样的 ECDH 算法在本地计算出<strong>一模一样</strong>的预主密钥。</li>
<li>结合预主密钥、Client Random 和 Server Random，生成相同的会话密钥（Session Key），并发送 Finished 报文。</li>
</ul>
</li>
<li><p><strong>完成握手：</strong> 如果双方都能正确解密对方的 Finished 消息，握手成功。双方立即<strong>删除</strong>各自生成的临时公私钥（实现前向安全）。</p>
</li>
<li><p><strong>加密传输数据：</strong> 握手完成后，客户端和服务器使用会话密钥对通信数据进行加密传输。</p>
</li>
</ol>
<h2>TLS1.3</h2>
<p>TLS 1.2 虽然支持 ECDHE，但握手还是很慢（需要 2-RTT），而且保留了很多不安全的加密算法。TLS 1.3 做了大刀阔斧的改革。</p>
<p>TLS 1.2 的逻辑是：<strong><u>&quot;先打招呼，商量用什么算法，再交换密钥&quot;</u></strong>。 TLS 1.3 的逻辑是：<strong><u>&quot;我猜你会用这个算法，先把公钥给你！&quot;</u></strong></p>
<h3>1-RTT</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251130231937223.png" alt=""></p>
<p>通过上述流程图可以看到，TLS1.3 直接从 2-RTT 缩短到了 1-RTT：</p>
<ul>
<li>Client 在发 <code>ClientHello</code> 时，不再干等服务器选算法，而是<strong>直接猜测</strong>服务器支持 ECDHE，并直接把自己的<strong>Key Share（临时公钥）</strong> 一起发过去。</li>
<li>Server 收到后直接生成密钥，回复 <code>ServerHello</code> + <code>Finished</code>。握手直接结束。</li>
</ul>
<h3>0-RTT</h3>
<p>更进一步，如果是连接恢复的情况，TLS1.3 还可以做到 0-RTT。</p>
<p>在初始连接 1-RTT 之后，在连接结束前，服务器会生成一个极其重要的数据结构，叫做 <strong>Session Ticket（会话票据）</strong>，并通过 <code>NewSessionTicket</code> 指令发给客户端。<strong>Session Ticket</strong> 包含了 Pre-Shared Key（预共享密钥）</p>
<ul>
<li>一个 <strong>PSK (Pre-Shared Key，预共享密钥)</strong>：这是从当前的会话主密钥衍生出来的一个秘密数据。</li>
<li>有效期、加密算法等信息。</li>
<li>这个 Ticket 通常只有服务器能解密（用服务器只有自己知道的密钥加密）。</li>
</ul>
<p>客户端收到 Ticket 后，把它安全地存在本地（比如浏览器的缓存里），然后断开连接。</p>
<p>假设过了几个小时，用户又打开了同一个网站。客户端发现本地有上次存的 Ticket。</p>
<p><strong>客户端动作：</strong></p>
<ol>
<li><strong>取出 PSK</strong>：客户端从 Ticket 里提取出 PSK。</li>
<li><strong>生成早期密钥</strong>：客户端利用这个 PSK，结合当前的 Client Random，提前算出用于加密早期数据（Early Data）的密钥。</li>
<li><strong>客户端构造一个超级数据包，一次性发给服务器：</strong><ul>
<li><strong>ClientHello</strong>：标准的问候，包含支持的算法、随机数。</li>
<li><strong>Session Ticket</strong></li>
<li><strong>Key Share</strong>：为了保险起见，依然带上新的 ECDHE 临时公钥（万一 0-RTT 失败了，可以无缝降级回 1-RTT）。</li>
<li><strong>Early Data (加密的)</strong>：这是重点！用刚才算的早期密钥加密的 HTTP 请求（如 <code>GET /index.html</code>）。</li>
</ul>
</li>
</ol>
<p><strong>服务器动作：</strong></p>
<ol>
<li><strong>收到大礼包</strong>：服务器收到这一大堆数据。</li>
<li><strong>验证凭证</strong>：服务器解密 Session Ticket，确认它有效，并从中提取出 <strong>PSK</strong>。</li>
<li><strong>尝试解密</strong>：服务器用提取出的 PSK，算出同样的早期密钥，尝试解密后面的 <strong>Early Data</strong>。</li>
<li><strong>并行处理（极速的核心）</strong>：<ul>
<li><strong>路径 A（应用层）</strong>：如果解密成功，服务器<strong>立即</strong>把解出来的 HTTP 请求（GET /）交给后端应用（比如 Nginx/Tomcat）去处理。<strong>此时此刻，握手甚至还没完成，但服务器已经开始干活了！</strong></li>
<li><strong>路径 B（握手层）</strong>：与此同时，服务器利用客户端发来的 Key Share，完成标准的 ECDHE 密钥协商，生成<strong>新的</strong>、具有前向安全的正式会话密钥。</li>
</ul>
</li>
<li><strong>回复</strong>：服务器发送 <code>ServerHello</code> + <code>Finished</code>（完成握手），紧接着发送用新密钥加密的 HTTP 响应（网页内容）。</li>
</ol>
<p><strong>效果对比：</strong></p>
<ul>
<li><strong>1-RTT</strong>：客户端发送 -&gt; 服务器处理握手 -&gt; 服务器回复握手完成 -&gt; 客户端发送 HTTP 请求 -&gt; 服务器处理并回复网页。</li>
<li><strong>0-RTT</strong>：客户端发送(含 HTTP 请求) -&gt; 服务器处理握手并<strong>同时</strong>处理 HTTP 请求 -&gt; 服务器回复握手完成和网页内容。</li>
<li>用户感受到网页加载的时间，整整少了一个 RTT。在跨国网络或移动网络下，这可能是几百毫秒的提升。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251130233954053.png" alt=""></p>
<p>然而！天下没有免费的午餐。0-RTT 带来了极致的速度，也引入了一个巨大的安全风险。</p>
<p>在标准的 ECDHE 握手中，每次的密钥都是基于<strong>全新的</strong> Client Random 和 Server Random 实时计算出来的。黑客录制了昨天的握手数据包，今天重新发给服务器，服务器一看随机数是旧的，或者因为没有对应的临时私钥无法算出正确的密钥，直接就拒绝了。</p>
<p>然而 0-RTT 用于加密 Early Data 的密钥，是基于<strong>以前</strong>的 PSK 衍生出来的，所以是存在重放危险的！</p>
<p>由于这个致命缺陷，TLS 1.3 标准明确建议：<strong>千万不要用 0-RTT 发送非幂等（Non-Idempotent）请求！</strong></p>
<ul>
<li><strong>安全场景</strong>：<code>GET</code> 请求（如获取图片、网页）。重放多少次结果都一样，没副作用。</li>
<li><strong>危险场景</strong>：<code>POST</code>、<code>PUT</code>、<code>DELETE</code> 等改变服务器状态的请求。</li>
</ul>
<h2>HTTP3</h2>
<p>HTTP/3 不再使用 TCP，而是基于 Google 开发的 <strong>QUIC</strong>（Quick UDP Internet Connections），底层使用 <strong>UDP</strong>。</p>
<h3>1. 核心机制</h3>
<ul>
<li><strong>机制 1：独立的流控制（解决 TCP 队头阻塞）</strong><ul>
<li>QUIC 在用户态（User Space）实现了类似 TCP 的可靠传输。</li>
<li>但它知道&quot;流&quot;的概念。如果 Stream 1 丢包，QUIC 只会阻塞 Stream 1，Stream 2 的数据包可以直接交给浏览器渲染。</li>
</ul>
</li>
<li><strong>机制 2：连接迁移 (Connection Migration)</strong><ul>
<li><strong>TCP</strong>：使用四元组（源 IP, 源端口, 目的 IP, 目的端口）标识连接。手机从 Wi-Fi 切到 4G，源 IP 变了，连接断开，必须重连。</li>
<li><strong>QUIC</strong>：使用 <strong>Connection ID</strong> (CID) 标识连接。只要 CID 不变，IP 变了也能继续传输，实现无缝网络切换。</li>
</ul>
</li>
<li><strong>机制 3：内置 TLS 1.3</strong><ul>
<li>HTTP/3 没有把 TLS 当作上层协议，而是直接将 TLS 1.3 的握手过程嵌入到 QUIC 的建立过程中。</li>
<li>传输层握手 + 加密层握手合并，实现真正的 1-RTT 建连（TCP+TLS 需要分别握手）。</li>
</ul>
</li>
<li><strong>机制 4：用户态拥塞控制</strong><ul>
<li>TCP 的拥塞控制（CUBIC, BBR）在操作系统内核里，升级困难（需要更新 OS）。</li>
<li>QUIC 在应用层实现，浏览器更新一下就能换用最新的拥塞控制算法，迭代极快。</li>
</ul>
</li>
<li><strong>QPACK</strong>：<ul>
<li>HTTP/2 的 HPACK 依赖于数据的顺序到达（更新动态表）。</li>
<li>由于 QUIC 允许乱序，HPACK 失效。HTTP/3 引入了 QPACK，专门适配乱序环境下的头部压缩。</li>
</ul>
</li>
</ul>
<h3>2. 握手机制</h3>
<p>接下来我们重点讨论 QUIC（HTTP3）的握手机制。QUIC 的握手是其最核心的创新之一，它解决了 TCP + TLS 分层架构带来的根本性低效问题。</p>
<p>在传统的 HTTPS (HTTP/2) 中，网络栈是分层的：</p>
<ol>
<li><strong>TCP 层</strong>：先花 1-RTT（三次握手）建立可靠连接。此时数据是明文的。</li>
<li><strong>TLS 层</strong>：再花 1-RTT（TLS 1.3）在 TCP 之上协商密钥。</li>
</ol>
<p>总共需要 <strong>2-RTT</strong> 才能开始发 HTTP 数据。</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--success" aria-hidden="true"></span></p>
<p><strong>QUIC 的革命性设计：融合层级</strong></p>
<p>QUIC 基于 UDP，UDP 没有连接的概念。QUIC 协议栈<strong>直接包含</strong>了 TLS 1.3 模块。 QUIC 的握手目标是：**在建立逻辑连接的同时，完成加密密钥的协商。**它把这两个步骤合并成了真正的 <strong>1-RTT</strong>。</p>
</blockquote>
<p>在深入 QUIC 的握手机制之前，我们需要先了解几个 QUIC 特有的概念：</p>
<ol>
<li><p><strong>Connection ID (CID, 连接 ID)</strong>：</p>
<ul>
<li><p>因为 UDP 没有连接，IP 地址和端口又可能会变（比如手机切网络），QUIC 使用 CID 来标识一个连接。</p>
</li>
<li><p>每个数据包头都会带上目标 CID (DCID) 和源 CID (SCID)。这使得连接可以迁移。</p>
</li>
</ul>
</li>
<li><p><strong>报文类型 (Packet Types)</strong>：</p>
<ul>
<li><p>QUIC 在握手阶段使用特殊的长首部（Long Header）报文。</p>
</li>
<li><p><strong>Initial 包</strong>：用于承载 TLS 的 <code>ClientHello</code> 和 <code>ServerHello</code>。</p>
</li>
<li><p><strong>Handshake 包</strong>：用于承载加密后的 TLS 握手消息（如证书、Finished）。</p>
</li>
<li><p><strong>0-RTT 包</strong>：用于承载 0-RTT 的早期应用数据。</p>
</li>
<li><p><strong>Short Header 包</strong>：握手完成后，传输普通应用数据使用。</p>
</li>
</ul>
</li>
<li><p><strong>反放大攻击 (Anti-Amplification)</strong>：</p>
<ul>
<li>由于 UDP 容易被伪造 IP 进行 DDoS 攻击（发送小包骗取大包回复），QUIC 规定在验证客户端 IP 地址之前，服务器回复的数据量不能超过客户端发送数据量的 3 倍。</li>
</ul>
</li>
</ol>
<h4>2.1 1-RTT</h4>
<p>这是最基础的场景，目标是达到 TLS 1.3 的 1-RTT 效果，但不需要 TCP 握手。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251130235652829.png" alt=""></p>
<p><strong>步骤 1：客户端发送 Initial 包</strong></p>
<ul>
<li><strong>外层 (QUIC)</strong>：客户端生成一个随机的各种 CID。封装一个 <code>Initial</code> 类型的 QUIC 包。</li>
<li><strong>内层 (TLS 1.3)</strong>：包含标准的 TLS <code>ClientHello</code> 消息。<ul>
<li>带着 Key Share（猜测的临时公钥，如 X25519 公钥 A）。</li>
<li>带着 TLS 版本、加密套件列表等。</li>
</ul>
</li>
<li><strong>注意</strong>：这个包本身也需要加密，但因为还没协商密钥，所以使用一个协议标准里写死的&quot;初始密钥&quot;进行模糊化（Obfuscation），主要为了防干扰，不防黑客。</li>
</ul>
<p><strong>步骤 2：服务器回复 Initial + Handshake 包</strong></p>
<ul>
<li><p>服务器收到包，提取出 <code>ClientHello</code>，交给内置的 TLS 模块处理。</p>
</li>
<li><p>TLS 模块生成自己的临时公钥 B，算出握手密钥（Handshake Keys）。</p>
</li>
<li><p><strong>服务器需要连续发送两个逻辑上的包（可能会合并在一个 UDP 数据报里）</strong>：</p>
<ol>
<li><strong>QUIC Initial 包</strong>：包裹着 TLS <code>ServerHello</code>（包含服务器的临时公钥 B）。这个包用初始密钥模糊化。</li>
<li><strong>QUIC Handshake 包</strong>：包裹着 TLS 的后续消息——<strong>证书 (Certificate)、签名 (CertificateVerify)、结束消息 (Finished)</strong>。</li>
</ol>
<ul>
<li><strong>关键点</strong>：这个 Handshake 包是使用刚才算出来的<strong>握手密钥加密</strong>的。这是第一批真正加密的数据。</li>
</ul>
</li>
</ul>
<p><strong>步骤 3：客户端完成握手并发送数据</strong></p>
<ul>
<li>客户端收到 <code>ServerHello</code>，算出握手密钥。</li>
<li>用握手密钥解密后续的 <code>Handshake</code> 包，验证证书和签名。</li>
<li>验证成功后，客户端算出最终的传输层会话密钥（1-RTT Keys）。</li>
<li><strong>发送两个逻辑包</strong>：<ol>
<li><strong>QUIC Handshake 包</strong>：包裹着客户端的 TLS <code>Finished</code> 消息（用握手密钥加密）。</li>
<li><strong>QUIC Short Header 包</strong>：包裹着真正的 HTTP/3 请求数据（如 <code>GET /</code>），<strong>用最终会话密钥加密</strong>。</li>
</ol>
</li>
<li><strong>握手结束</strong>。</li>
</ul>
<h4>2.2 0-RTT</h4>
<p>如果你理解了 TLS 1.3 的 0-RTT 原理（利用之前的 PSK/Ticket），QUIC 只是把它封装到了特定的包类型中。</p>
<blockquote>
<p>所以它跟 TCP+TLS1.3 的 0-RTT 一样会存在重放危险。</p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251201000218593.png" alt=""></p>
<p><strong>步骤 1：客户端发送 Initial + 0-RTT 包（大礼包）</strong></p>
<ul>
<li>客户端找到了上次连接存下来的 Session Ticket (PSK)。</li>
<li>客户端使用 PSK 生成&quot;早期数据密钥&quot;。</li>
<li><strong>客户端一股脑发送出去：</strong><ol>
<li><strong>QUIC Initial 包</strong>：包含 TLS <code>ClientHello</code>，里面带着 PSK Ticket，同时也带着新的 Key Share (以防 0-RTT 失败回退到 1-RTT)。</li>
<li><strong>QUIC 0-RTT 包</strong>：使用早期密钥加密的 HTTP 请求数据。</li>
</ol>
</li>
</ul>
<p><strong>步骤 2：服务器并行处理</strong></p>
<ul>
<li>服务器收到这些包。TLS 模块验证 PSK 成功。</li>
<li><strong>关键动作（并行）</strong>：<ul>
<li>服务器使用 PSK 算出早期密钥，<strong>立即解密 0-RTT 包里的 HTTP 请求，交给后端应用处理</strong>。</li>
<li>同时，服务器利用 <code>ClientHello</code> 里的新 Key Share，完成标准的 1-RTT 握手流程，生成新的、前向安全的会话密钥。</li>
</ul>
</li>
</ul>
<p><strong>步骤 3：服务器回复</strong></p>
<ul>
<li>服务器发送 <code>ServerHello</code> 和加密的握手消息（Finished），完成正式握手。</li>
<li>紧接着发送用新密钥加密的 HTTP 响应数据。</li>
</ul>
<hr>
<p>特殊情况：<strong>地址验证（Retry 机制）</strong></p>
<p>为了防止 UDP 反放大攻击，当服务器是一个热门服务，它可能会要求客户端先证明&quot;<u><strong>你确实拥有这个 IP 地址，不是伪造的</strong></u>&quot;。</p>
<p>这会引入一个额外的 RTT：</p>
<ol>
<li><strong>Client -&gt; Server</strong>: 发送 <code>Initial</code> 包 (ClientHello)。</li>
<li><strong>Server -&gt; Client</strong>: 服务器感觉有风险，不进行复杂的 TLS 运算，而是回复一个很小的 <strong>QUIC Retry 包</strong>，里面包含一个加密的 <strong>Token</strong>。</li>
<li><strong>Client -&gt; Server</strong>: 客户端收到 Retry，必须重新发送 <code>Initial</code> 包，但这次要带上刚才收到的 <strong>Token</strong>。</li>
<li><strong>Server -&gt; Client</strong>: 服务器验证 Token 有效，确认客户端 IP 没问题，才开始正常的 1-RTT 握手流程（回复 Certificate 等）。</li>
</ol>
<p>虽然多了一步，但这是一个轻量级的 UDP 交互，比完整的 TCP 握手还是要快。</p>
<h2>总结</h2>
<p>回顾从 HTTP/1.0 到 HTTP/3 的二十多年演进历程，我们可以清晰地看到，推动 Web 协议不断升级的核心动力，始终是为了提供<strong>更快、更稳、更安全</strong>的网络体验。这是一部与网络延迟、带宽限制和计算成本进行持续抗争的历史。</p>
<ul>
<li><strong>HTTP/1.0 -&gt; HTTP/1.1</strong>：为了解决 TCP 短连接带来的巨大开销，HTTP/1.1 引入了<strong>持久连接（Keep-Alive）</strong>，让多个请求可以复用同一个 TCP 连接，显著减少了握手次数，是 Web 性能优化的第一步。</li>
<li><strong>HTTP/1.1 -&gt; HTTP/2</strong>：为了解决 HTTP/1.1 在持久连接上仍然存在的<strong>应用层队头阻塞</strong>问题，HTTP/2 引入了<strong>二进制分帧</strong>和<strong>多路复用（Multiplexing）</strong>。它允许在同一个 TCP 连接上并发处理多个请求和响应，极大地提高了带宽利用率和并发能力。</li>
<li><strong>TLS 的演进 (1.2 -&gt; 1.3)</strong>：在安全层面，TLS 协议也在不断进化。TLS 1.3 通过废除不安全的加密算法、强制使用具备<strong>前向安全</strong>的 ECDHE 密钥交换，并将握手流程极致优化至 <strong>1-RTT</strong> 甚至 <strong>0-RTT</strong>，实现了速度与安全的双重飞跃。</li>
<li><strong>HTTP/2 -&gt; HTTP/3</strong>：为了彻底解决受制于 TCP 协议特性的<strong>传输层队头阻塞</strong>以及网络切换时的连接中断问题，HTTP/3 做出了颠覆性的改变。它基于 UDP 构建了全新的 <strong>QUIC</strong> 协议，将 TLS 1.3 的安全握手深度融合，实现了真正的 <strong>1-RTT</strong> 建连、基于 <strong>Connection ID</strong> 的无缝连接迁移，以及更高效的独立流控制。</li>
</ul>
<p>从最初的简单文本传输，到如今基于 UDP 的高性能、高安全复合协议，HTTP 的演进之路从未停歇。未来的网络协议将如何发展？也许会更加智能化、更加适应边缘计算和物联网的需求，但其核心目标——<strong>连接你我，更快更安全</strong>——将永远不变。</p>
]]></content:encoded>
    </item>
    <item>
      <title>从第一性原理掌握 UDP/TCP/KCP/QUIC</title>
      <link>https://hedon.top/blog/net-udp-tcp-kcp-quic/</link>
      <guid isPermaLink="true">https://hedon.top/blog/net-udp-tcp-kcp-quic/</guid>
      <pubDate>Wed, 26 Nov 2025 23:42:00 GMT</pubDate>
      <description>本文将结合第一性原理，深入解析 UDP、TCP、KCP 和 QUIC 协议的设计动机、核心机制及其区别，助你真正理解它们为何而生、如何演化、彼此之间有何联系与差异。</description>
      <category>UDP</category><category>TCP</category><category>KCP</category><category>QUIC</category><category>网络编程</category><category>计算机基础</category><category>计算机网络</category>
      <content:encoded><![CDATA[<p>相信不少读者跟笔者一样，对计算机网络中传输层的各种协议背了又忘，忘了又背，背了还完。所以本篇想尝试从第一性原理出发，看看能否从根上去掌握传输层中的 UDP、TCP、KCP 和 QUIC 协议（尽管 KCP 和 QUIC 是在应用层进行实现的，不过因为其实现的功能属于传输层的范畴，所以笔者在本篇将将这二者归为传输层）。</p>
<p>特此声明，本篇是笔者与 Google Gemini 3Pro 共创所作，非常庆幸在当今 AI 时代下获取知识已是如此便利，且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑，均由笔者把控和审阅，欢迎放心阅读。</p>
<h2>1. 宏观理解</h2>
<p>之所以背了又忘，通常是因为我们把传输层看作是一堆枯燥的字段（Flags、Window size）和死记硬背的流程（三次握手、四次挥手），而忽略了它存在的<strong>核心目的</strong>和<strong>演化逻辑</strong>。</p>
<p>要从根本上理解传输层（Transport Layer），我们需要剥离掉具体的协议细节，回归到第一性原理：<strong>传输层到底解决了什么网络层（IP）解决不了的问题？</strong></p>
<h3>1.2 三个层级</h3>
<p>我们可以将其分解为三个层级来理解：<strong>复用与分用</strong>、<strong>不可靠基础上的可靠性</strong>、<strong>传输效率的权衡</strong>。</p>
<h4>1.2.1 第一层级：复用与复用</h4>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>👉🏻 核心逻辑</strong></p>
<p>从<strong>主机到主机</strong>进化为<strong>进程到进程</strong>。</p>
</blockquote>
<p>网络层（IP 层）的任务非常纯粹：通过 IP 地址，把数据包从地球的一端（主机 A）送到另一端（主机 B）。</p>
<p>但是，当数据包到达主机 B 时，IP 的任务就结束了。主机 B 此时正运行着微信、浏览器、Steam 和网易云音乐。这个数据包到底是给谁的？IP 协议不知道，也不管。</p>
<p>这就是传输层存在的<strong>第一个根本原因</strong>：</p>
<ul>
<li><strong>网络层</strong>只负责把快递送到<strong>大楼传达室</strong>（主机 IP）。</li>
<li><strong>传输层</strong>负责把快递分发给大楼里的<strong>具体某个人</strong>（进程 Port）。</li>
</ul>
<p>这个过程叫做<strong>复用（Multiplexing）<strong>和</strong>分用（Demultiplexing）</strong>。</p>
<blockquote>
<p>不要把端口想象成物理插口，它只是一个<strong>逻辑地址</strong>。如果没有传输层，你的电脑同一时间只能运行一个网络程序，这显然是不可接受的。</p>
</blockquote>
<h4>1.2.2 第二层级：不可靠基础上的可靠性</h4>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>👉🏻 核心逻辑</strong></p>
<p>在不可靠的物理世界，构建一个完美的虚拟管道。</p>
</blockquote>
<p>这是传输层最难理解、也最容易忘的部分。我们来看 TCP 为什么要搞得这么复杂。</p>
<p><strong>第一性原理推导：</strong> 底层的网络环境（IP 层及更底层）本质上是<strong>不可靠</strong>的。</p>
<ol>
<li><strong>丢包：</strong> 路由器太忙，直接把包扔了。</li>
<li><strong>乱序：</strong> 或者是前面的包走了远路，后面的包走了近路。</li>
<li><strong>篡改/错误：</strong> 电信号干扰导致比特翻转。</li>
</ol>
<p>如果你的应用是&quot;银行转账&quot;或&quot;网页浏览&quot;，你无法容忍上述任何一种情况。你希望应用层感受到的是一条<strong>连续的、无差错的字节流</strong>。</p>
<p>TCP 的所有机制，都是为了填补&quot;不可靠的现实&quot;与&quot;可靠的需求&quot;之间的鸿沟。我们不需要死记硬背，而是通过问题来推导方案：</p>
<ol>
<li><strong>问题：我怎么知道数据包丢没丢？</strong><ul>
<li><strong>方案（确认应答 ACK）：</strong> 接收方收到数据必须回一个&quot;收到了&quot;。</li>
<li><strong>衍生问题：</strong> 如果发送方一直收不到 ACK 怎么办？</li>
<li><strong>方案（超时重传）：</strong> 设个闹钟，时间到了没消息就重发。</li>
</ul>
</li>
<li><strong>问题：数据包乱了怎么办？重复了怎么办？</strong><ul>
<li><strong>方案（序列号 Sequence Number）：</strong> 给每个字节的数据编号。1, 2, 3... 这样接收方就可以重新排序，或者丢弃重复的号。</li>
</ul>
</li>
<li><strong>问题：接收方处理不过来怎么办？（比如服务器太慢，或者你发得太快）</strong><ul>
<li><strong>方案（流量控制 Flow Control）：</strong> 接收方在回信里告诉发送方：&quot;我的缓冲区还剩 X 大小（Window Size）&quot;。发送方根据这个 X 调整发送速度。这就是<strong>滑动窗口</strong>的本质——保护接收方。</li>
</ul>
</li>
<li><strong>问题：网线堵塞了怎么办？（中间的路由器处理不过来）</strong><ul>
<li><strong>方案（拥塞控制 Congestion Control）：</strong> 这是 TCP 最伟大的设计。发送方通过试探（慢启动、拥塞避免），感知网络的拥堵程度。一旦发现丢包（暗示堵车），立马减速。这是一个<strong>保护互联网基础设施</strong>的利他机制。</li>
</ul>
</li>
</ol>
<blockquote>
<p>不要孤立地背诵&quot;慢启动&quot;、&quot;快重传&quot;。把 TCP 想象成一个<strong>强迫症晚期的快递员</strong>：他必须给每一个包裹编号（序列号），必须拿到客户的签字（ACK），发现客户家堆满了（流量控制）就暂停发货，发现路上堵车（拥塞控制）就换个时间发。</p>
</blockquote>
<h4>1.2.3 第三层级：速度与质量的权衡</h4>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>👉🏻 核心逻辑</strong></p>
<p>有时候，比起<strong>准确</strong>，我更需要<strong>实时</strong>。</p>
</blockquote>
<p>并不是所有应用都需要 TCP 那样的强迫症。</p>
<ul>
<li><strong>场景：</strong> 视频通话、在线 FPS 游戏（如 CS:GO）。</li>
<li><strong>思考：</strong> 在视频通话中，如果第 5 秒的画面丢了一帧，TCP 会怎么做？它会暂停后续播放，疯狂重传第 5 秒的那一帧，直到成功。等你看到那一帧时，已经是第 8 秒了，画面卡顿延迟。</li>
<li><strong>实际需求：</strong> 丢了就丢了，赶紧把第 6 秒、第 7 秒的画面给我，我要的是<strong>实时性</strong>。</li>
</ul>
<p>这就是 <strong>UDP（用户数据报协议）</strong> 的生存空间。</p>
<ul>
<li>它几乎不加修饰，只做最基础的“复用/分用”（加个端口号）。</li>
<li>它不保证到达，不保证顺序，没有拥塞控制。</li>
<li><strong>特点：</strong> 快、自由、无连接。</li>
</ul>
<p><strong>理论与实践结合：</strong></p>
<ul>
<li><strong>HTTP/3 (QUIC)：</strong> 现在的互联网巨头发现 TCP 太重了（握手慢、队头阻塞），于是开始在 UDP 之上重新实现一套可靠传输协议（QUIC）。这说明<strong>传输层的本质功能（可靠性）可以上移到应用层实现</strong>，但底层的物理限制永远存在。</li>
</ul>
<h4>1.2.4 总结</h4>
<table>
<thead>
<tr>
<th><strong>特性</strong></th>
<th><strong>TCP (Transmission Control Protocol)</strong></th>
<th><strong>UDP (User Datagram Protocol)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>角色人格</strong></td>
<td><strong>严谨的会计师</strong></td>
<td><strong>急躁的广播员</strong></td>
</tr>
<tr>
<td><strong>核心价值观</strong></td>
<td>可靠性 &gt; 实时性</td>
<td>实时性 &gt; 可靠性</td>
</tr>
<tr>
<td><strong>第一性原理</strong></td>
<td>通过<strong>确认、重传、排序、流控</strong>，将不可靠的 IP 网络模拟成可靠的管道。</td>
<td>尽最大努力交付，保留 IP 网络的原始特性，只增加端口区分进程。</td>
</tr>
<tr>
<td><strong>连接方式</strong></td>
<td><strong>面向连接</strong> (三次握手确认双方都在线)</td>
<td><strong>无连接</strong> (想发就发，不管你在不在)</td>
</tr>
<tr>
<td><strong>典型应用</strong></td>
<td>网页 (HTTP)、邮件 (SMTP)、文件传输 (FTP)</td>
<td>直播、视频会议、DNS、早期的 QUIC</td>
</tr>
</tbody></table>
<h3>1.3 三个时代</h3>
<p>要彻底理解这四个协议，我们不能平铺直叙地去背它们的定义。我们需要站在<strong>进化论</strong>的视角，看它们是如何为了解决特定时代的特定痛点而诞生的。</p>
<p>这不仅仅是协议的区别，更是<strong>思维模式（Mindset）<strong>的转变：从</strong>内核态的僵化</strong>走向<strong>用户态的灵活</strong>。</p>
<p>我们将这四个协议分为三个代际来理解：<strong>二元对立的世界 (TCP vs UDP)</strong>、<strong>暴力美学的补丁 (KCP)</strong> 和 <strong>颠覆架构的革命 (QUIC / HTTP3)</strong>。</p>
<h4>1.3.1 第一代：二元对立的世界 (TCP vs UDP)</h4>
<p>这是互联网早期的基础设定。设计者面临一个根本的取舍（Trade-off）：<strong>你是要绝对准确，还是要绝对速度？</strong></p>
<p><strong>1. TCP (Transmission Control Protocol) —— 老好人</strong></p>
<ul>
<li><strong>第一性原理：</strong> <strong>公平与可靠</strong>。</li>
<li><strong>痛点解决：</strong> 互联网早期网络极不稳定，必须保证数据不丢、不乱。</li>
<li><strong>性格缺陷：</strong><ul>
<li><strong>太守规矩（慢）：</strong> 建立连接要三次握手，断开要四次挥手。</li>
<li><strong>太顾大局（拥塞控制）：</strong> 一旦发现丢包，TCP 默认认为是网络堵了，为了不给互联网添乱，它会立刻减速（慢启动、拥塞避免）。哪怕其实只是因为你家 WiFi 信号抖了一下，它也会大幅降低速度。</li>
<li><strong>队头阻塞（Head-of-Line Blocking）：</strong> 前面一个包没到，后面的包到了也不能用，必须排队等。</li>
</ul>
</li>
</ul>
<p><strong>2. UDP (User Datagram Protocol) —— 甩手掌柜</strong></p>
<ul>
<li><strong>第一性原理：</strong> <strong>简单与直接</strong>。</li>
<li><strong>痛点解决：</strong> 解决 TCP 头部太大、握手太慢的问题。</li>
<li><strong>性格特征：</strong><ul>
<li>只管发，不管你收没收到。</li>
<li>没有任何&quot;流控&quot;或&quot;拥塞控制&quot;，有多少发多少。</li>
</ul>
</li>
<li><strong>问题：</strong> 在复杂的互联网环境下，纯 UDP 几乎无法直接传输关键数据，因为丢包率不可控。</li>
</ul>
<h4>1.3.2 第二代：暴力美学的补丁 (KCP)</h4>
<p>随着**实时对战游戏（如王者荣耀、MMORPG）**的兴起，开发者遇到了两难：</p>
<ul>
<li>用 TCP？延迟太高，角色瞬移，因为 TCP 一丢包就降速等待。</li>
<li>用 UDP？包丢了技能放不出来。</li>
</ul>
<p>于是，<strong>KCP</strong> 诞生了。它不是一个标准的网络协议，而是一个运行在 UDP 之上的<strong>算法库</strong>。</p>
<ul>
<li><strong>核心逻辑：</strong> <strong>用带宽换延迟（流量换速度）</strong>。</li>
<li><strong>第一性原理：</strong> TCP 的可靠性逻辑是对的（要确认、要重传），但 TCP 的<strong>策略太保守了</strong>。KCP 在 UDP 上重新实现了 TCP 的可靠机制，但把参数调得非常激进。</li>
<li><strong>KCP 怎么魔改 TCP 的？</strong><ol>
<li><strong>死不退让（RTO 不翻倍）：</strong> TCP 发现超时，下一次等待时间会翻倍（1s -&gt; 2s -&gt; 4s）；KCP 说不，超时了我立马重传，等待时间只增加一点点（1s -&gt; 1.5s），绝不让用户等。</li>
<li><strong>选择性重传：</strong> 丢了哪个包，只重传那个包，不像早期 TCP 可能把后续的一起重传。</li>
<li><strong>快速重传：</strong> 只要发现跳号（比如收到了 1, 3, 4，没收到 2），KCP 不等超时，立马重传 2。</li>
</ol>
</li>
<li><strong>代价：</strong> 会比 TCP 多消耗 10%-20% 的带宽。这是一种自私的协议，为了我的应用快，我不惜挤占网络资源。</li>
<li><strong>总结：</strong> <strong>KCP = 激进版 TCP + UDP 的外壳</strong>。</li>
</ul>
<h4>1.3.3 颠覆架构的革命 (QUIC / HTTP3)</h4>
<p>到了移动互联网时代，网页请求变得极其复杂（一个页面几百个资源），且用户经常切换网络（从 WiFi 切到 4G）。TCP 的<strong>底层架构</strong>（基于 IP 和端口）和<strong>队头阻塞</strong>成了瓶颈。KCP 这种小修小补已经不够了。</p>
<p>Google 站出来说：我们要重新发明轮子。这就是 <strong>QUIC (Quick UDP Internet Connections)</strong>。</p>
<ul>
<li><strong>核心逻辑：</strong> <strong>移花接木，在用户态重构一切</strong>。</li>
<li><strong>第一性原理：</strong> 既然操作系统的 TCP 协议栈（Kernel）更新太慢（你没法强迫全世界升级 Windows/Linux 内核），那我们就绕过内核，<strong>在应用层（用户态）基于 UDP 自己写一套完美的传输协议</strong>。</li>
<li><strong>QUIC 解决了什么 TCP 解决不了的问题？</strong><ol>
<li><strong>彻底解决队头阻塞（多路复用）：</strong><ul>
<li><strong>TCP：</strong> 一条高速公路，前面车坏了，后面全堵死。</li>
<li><strong>QUIC：</strong> 并不是单纯的一条路，而是并行的多车道（Stream）。图片 A 的包丢了，只影响图片 A，不影响旁边的 CSS 文件和文字加载。这是 QUIC 最核心的优势。</li>
</ul>
</li>
<li><strong>网络切换不断线（Connection Migration）：</strong><ul>
<li><strong>TCP：</strong> 依靠 IP:Port 识别连接。你从 WiFi (IP: A) 切换到 4G (IP: B)，IP 变了，TCP 连接必断，必须重新握手。</li>
<li><strong>QUIC：</strong> 发明了一个 <strong>Connection ID (UUID)</strong>。不管你的 IP 怎么变，只要 ID 没变，服务端就知道还是你，连接保持，无需重连。</li>
</ul>
</li>
<li><strong>0-RTT 建连：</strong><ul>
<li>TCP + TLS 需要多次往返才能建立加密连接。QUIC 把传输层握手和加密层（TLS 1.3）握手合并了，最快可以做到 0 延迟发送数据。</li>
</ul>
</li>
</ol>
</li>
</ul>
<h4>1.3.4 总结</h4>
<table>
<thead>
<tr>
<th><strong>协议</strong></th>
<th><strong>TCP</strong></th>
<th><strong>UDP</strong></th>
<th><strong>KCP</strong></th>
<th><strong>QUIC (HTTP/3)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>本质身份</strong></td>
<td><strong>正规军</strong> (OS内核实现)</td>
<td><strong>传令兵</strong> (OS内核实现)</td>
<td><strong>雇佣兵</strong> (应用层算法库)</td>
<td><strong>特种部队</strong> (应用层协议栈)</td>
</tr>
<tr>
<td><strong>底层载体</strong></td>
<td>IP</td>
<td>IP</td>
<td><strong>UDP</strong></td>
<td><strong>UDP</strong></td>
</tr>
<tr>
<td><strong>核心哲学</strong></td>
<td>可靠性、公平性、顺序</td>
<td>简单、快、无状态</td>
<td><strong>速度优先</strong> (牺牲带宽换低延迟)</td>
<td><strong>效率优先</strong> (解决队头阻塞、快速握手)</td>
</tr>
<tr>
<td><strong>丢包处理</strong></td>
<td>减速、退让、重传</td>
<td>不管</td>
<td><strong>激进重传</strong>、死不退让</td>
<td>独立流重传 (只重传丢的那一路)</td>
</tr>
<tr>
<td><strong>连接识别</strong></td>
<td>五元组 (IP:Port...)</td>
<td>无连接</td>
<td>会话 ID (Conv ID)</td>
<td><strong>Connection ID</strong> (不怕切网络)</td>
</tr>
<tr>
<td><strong>典型场景</strong></td>
<td>网页、文件下载、金融</td>
<td>DNS、广播、简单的 IoT</td>
<td><strong>MOBA 游戏、实时音视频</strong></td>
<td><strong>下一代 Web、YouTube、Gmail</strong></td>
</tr>
</tbody></table>
<ol>
<li><strong>TCP 和 UDP</strong> 是操作系统提供的<strong>原材料</strong>。TCP 也就是加了确认和流控的 UDP。</li>
<li><strong>KCP</strong> 是为了<strong>游戏</strong>和<strong>弱网环境</strong>，在 UDP 上<strong>模仿并魔改</strong>了 TCP，甚至不惜浪费流量也要快。</li>
<li><strong>QUIC</strong> 是为了<strong>现代 Web</strong>，在 UDP 上<strong>彻底重写</strong>了 TCP + TLS，解决了 TCP 几十年改不掉的&quot;队头阻塞&quot;和&quot;僵化&quot;毛病。</li>
</ol>
<p><strong>一句话总结：</strong> TCP 是老古董，UDP 是地基，KCP 是为了快而拼命的魔改插件，QUIC 是试图取代 TCP 的下一代互联网标准。</p>
<h2>2. 底层剖析</h2>
<p>要详细剖析这四个协议的底层原理，我们必须深入到<strong>数据包（Packet）的结构</strong>和**状态机（State Machine）**的控制逻辑中去。</p>
<p>我们要抓住一个核心矛盾：<strong>如何在不可靠的物理线路（丢包、乱序、延迟）上，构建出符合应用层需求的逻辑管道。</strong></p>
<h3>2.1 UDP：裸奔的传输层</h3>
<p>UDP 是理解其他所有协议的基准。它的底层原理非常简单，几乎就是 IP 协议的“影分身”。</p>
<h4>2.1.1 头部结构</h4>
<p>UDP 的头部只有 8 个字节。</p>
<ul>
<li><strong>Source Port &amp; Destination Port：</strong> 也就是前面提到的&quot;复用/分用&quot;。</li>
<li><strong>Length &amp; Checksum：</strong> 告诉你包有多长，校验一下数据有没有坏（比特翻转）。</li>
</ul>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251127132809962.png" style="zoom:33%;" />

<h4>2.1.2 核心机制</h4>
<ul>
<li><strong>无状态（Stateless）：</strong> 操作系统内核不会为 UDP 维护任何表格。来一个包，查一下端口，扔给应用程序。发一个包，直接扔给网卡。</li>
<li><strong>MTU 限制：</strong> UDP 不负责切分数据。如果你应用层给 UDP 一个 2000 字节的数据包，而底层以太网 MTU 只有 1500，IP 层会进行分片（Fragmentation）。一旦其中一个分片丢了，整个 UDP 包就废了。</li>
</ul>
<blockquote>
<p>既然 UDP 什么都不管，那么所有的高级功能（KCP 的重传、QUIC 的流控）都必须由<strong>应用层代码</strong>自己来实现。</p>
</blockquote>
<h3>2.2 TCP：流量与拥塞的博弈</h3>
<p>传输控制协议（TCP）是互联网的基石，其设计目标是在不可靠的 IP 网络上提供<strong>可靠（Reliable）</strong>、**有序（Ordered）<strong>且</strong>错误检查（Error-Checked）**的字节流服务。从第一性原理来看，TCP 是一个复杂的控制论系统，通过负反馈机制（ACK）来调节系统的输入（发送速率），以维持系统的稳定性（避免拥塞崩溃）。</p>
<h4>2.2.1 字节流抽象与序列号管理</h4>
<p>TCP 的核心抽象是<strong>字节流</strong>。不同于 UDP 的报文，TCP 将应用层数据视为无边界的水流。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251127132830720.png" style="zoom:33%;" />

<p>为了实现有序重组，TCP 为发送的每一个<strong>字节</strong>（Octet）分配一个 32 位的序列号。</p>
<ul>
<li><strong>语义</strong>：TCP 段头部的 <code>Sequence Number</code> 字段表示该段数据载荷中<strong>第一个字节</strong>在整个数据流中的偏移量。</li>
<li><strong>ACK 机制</strong>：TCP 使用累积确认（Cumulative ACK）。<code>Acknowledgment Number</code> 表示接收端<strong>期望收到</strong>的下一个字节的序列号。例如，ACK=1001 意味着 1000 及以前的所有字节均已正确接收。</li>
</ul>
<p>内核为每个 TCP 连接维护一个接收缓冲区（Receive Buffer）。</p>
<ul>
<li>当数据包乱序到达（例如，先收到 SEQ 2000，后收到 SEQ 1000），内核会将 SEQ 2000 的数据暂存在缓冲区中的对应位置（SACK 机制辅助记录这一空洞）。</li>
<li>只有当 SEQ 1000 到达填补空洞后，内核才会更新 ACK 指针，并唤醒用户进程读取数据。</li>
<li><strong>队头阻塞（Head-of-Line Blocking, HOL）</strong>：这是 TCP 在低延迟场景下的致命弱点。如果 SEQ 1000 的包丢失，即使 SEQ 2000-5000 的数据已经完整到达，应用层也无法读取这 3000 字节。内核必须等待 SEQ 1000 重传成功，才能交付后续数据。在丢包率为 1%-2% 的网络中，HOL 会导致巨大的延迟抖动。</li>
</ul>
<h4>2.2.2 可靠传输：RTO 与 ARQ</h4>
<p>TCP 的可靠性依赖于自动重传请求（ARQ）。核心问题在于：发送端发出数据后，应该等待多久才认为数据丢失？这个时间被称为<strong>重传超时（RTO）</strong>。</p>
<p>RTO 的计算必须基于对往返时间（RTT）的动态估算。</p>
<ul>
<li><p>SRTT（Smoothed RTT）：采用指数加权移动平均（EWMA）算法平滑采样值。
$$
SRTT_{new} = (1 - \alpha) \cdot SRTT_{old} + \alpha \cdot RTT_{sample}
$$
其中 $\alpha$ 通常取 0.125。</p>
</li>
<li><p>RTTVAR（RTT Variation）：Van Jacobson 引入了对 RTT 抖动（方差）的估算，以适应网络波动 17。</p>
<p>$$
RTTVAR_{new} = (1 - \beta) \cdot RTTVAR_{old} + \beta \cdot |SRTT_{old} - RTT_{sample}|
$$
最终 RTO 计算公式为：
$$
RTO = SRTT + 4 \cdot RTTVAR
$$
系数 4 的选择基于切比雪夫不等式，旨在覆盖 99% 以上的 RTT 分布。</p>
</li>
<li><p><strong>Karn 算法</strong>：解决重传二义性问题。如果一个包发生了重传，收到 ACK 时无法确定是回应原包还是重传包，因此 Karn 算法规定：<strong>发生重传时，不更新 RTT 估算值，并将 RTO 指数退避（Exponential Backoff）</strong> 。</p>
</li>
</ul>
<p>等待 RTO 超时过于缓慢。TCP 引入了快速重传机制：当发送端收到 <strong>3 个重复的 ACK（Duplicate ACK）</strong> 时，推断该 ACK 指示的下一个报文段已丢失，立即重传，而不必等待定时器溢出。这一机制利用了&quot;重复 ACK&quot;作为网络丢包的隐式信号，显著降低了恢复延迟。</p>
<h4>2.2.3 拥塞控制：从 AIMD 到 BBR</h4>
<p>TCP 认为丢包是网络拥塞的信号（这一假设在无线网络中往往不成立，却是 TCP 设计的基石）。</p>
<ol>
<li><strong>慢启动（Slow Start）</strong>：连接建立初期，拥塞窗口（cwnd）呈指数增长，每收到一个 ACK，cwnd 加 1 MSS（最大报文段长度）。这实际上是倍增过程，用于快速探测可用带宽。</li>
<li><strong>拥塞避免（Congestion Avoidance）</strong>：当 cwnd 达到慢启动阈值（ssthresh）后，进入线性增长阶段（AIMD：加法增，乘法减）。每经过一个 RTT，cwnd 增加 1 MSS。</li>
<li><strong>拥塞发生</strong>：一旦检测到丢包（超时或 3 个重复 ACK），TCP 立即大幅削减 cwnd（通常减半或降为 1），以释放网络压力。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/bbr-fig3.png" alt=""></p>
<h5>2.2.3.1 CUBIC 算法</h5>
<p>Linux 默认使用的 CUBIC 算法，将窗口增长函数设计为一个三次函数。
$$
W(t) = C(t - K)^3 + W_{max}
$$
其曲线在接近上次丢包窗口 $W_{max}$ 时变得平缓（稳定探测），而在远离饱和点时快速增长。这种凹凸性使得 CUBIC 在高带宽延迟积（BDP）网络中比线性增长的 Reno 算法更高效。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/bbr-fig4.png" alt=""></p>
<h5>2.2.3.2 BBR(Bottleneck Bandwidth and RTT)</h5>
<p>Google 提出的 BBR 算法颠覆了“基于丢包”的传统逻辑。BBR 基于<strong>模型（Model-based）</strong>，试图实时测量网络的两个物理边界：</p>
<ul>
<li><p><strong>RTprop</strong>（物理链路的最小往返传播时延）。</p>
</li>
<li><p>BtlBw（瓶颈链路带宽）。</p>
<p>BBR 试图将发送速率控制在 BtlBw，同时保持 inflight 数据量等于 BDP（带宽×延迟），从而在不填满路由器缓冲区（Bufferbloat）的情况下跑满带宽。这种机制使得 BBR 在高丢包率环境下依然能保持高吞吐，因为它不会因为非拥塞性丢包而错误地降低速度。</p>
</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/bbr-fig7.png" alt=""></p>
<blockquote>
<p> TCP 的丢包判断机制（通常是 3 次重复 ACK 或超时）在现代高丢包率或高延迟网络（如跨海传输）下显得反应太慢，且一旦退让就退让太多。</p>
</blockquote>
<h3>2.3 KCP：用带宽换时延</h3>
<p>KCP 是一个纯算法层面的 ARQ 协议，其设计者 skywind3000 明确指出，KCP 的第一性原理是<strong>用带宽换延迟</strong>。如果说 TCP 是为了最大化全网的带宽利用率和公平性，那么 KCP 就是为了在单点连接上压榨出物理极限的响应速度，不惜牺牲带宽资源。</p>
<h4>2.3.1 头部结构</h4>
<p>KCP 的核心在于其精巧的数据段结构 <code>IKCPSEG</code>，其头部占据 24 字节（TCP 通常为 20 字节），字段定义直接服务于激进的重传逻辑。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240612172557464.png" style="zoom:33%;" />

<table>
<thead>
<tr>
<th><strong>字段</strong></th>
<th><strong>类型</strong></th>
<th><strong>描述</strong></th>
<th><strong>设计意图</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>conv</code></td>
<td>32-bit</td>
<td>会话 ID</td>
<td>区分不同的逻辑连接，类似于 TCP 的四元组但仅由 ID 标识。</td>
</tr>
<tr>
<td><code>cmd</code></td>
<td>8-bit</td>
<td>指令类型</td>
<td>IKCP_CMD_PUSH (数据), ACK (确认), WASK (窗口探测), WINS (窗口通告)。</td>
</tr>
<tr>
<td><code>frg</code></td>
<td>8-bit</td>
<td>分片序号</td>
<td>支持应用层大数据包的自动分片与重组（倒序编号，0 为最后一片）。</td>
</tr>
<tr>
<td><code>wnd</code></td>
<td>16-bit</td>
<td>接收窗口</td>
<td>类似于 TCP 的 rwnd，用于流量控制。</td>
</tr>
<tr>
<td><code>ts</code></td>
<td>32-bit</td>
<td>时间戳</td>
<td>发送时刻的本地时间，用于接收端回显以计算 RTT。</td>
</tr>
<tr>
<td><code>sn</code></td>
<td>32-bit</td>
<td>序列号</td>
<td>数据包的编号。</td>
</tr>
<tr>
<td><code>una</code></td>
<td>32-bit</td>
<td>未确认序号</td>
<td>告知对方：此编号之前的所有包已收到（累计确认）。</td>
</tr>
<tr>
<td><code>len</code></td>
<td>32-bit</td>
<td>数据长度</td>
<td>载荷长度。</td>
</tr>
<tr>
<td><strong><code>resendts</code></strong></td>
<td>(内部)</td>
<td>重传时间</td>
<td>下一次需要重传的时刻，由 <code>ikcp_update</code> 检查。</td>
</tr>
<tr>
<td><strong><code>rto</code></strong></td>
<td>(内部)</td>
<td>超时时间</td>
<td>该包当前的重传超时设定。</td>
</tr>
<tr>
<td><strong><code>fastack</code></strong></td>
<td>(内部)</td>
<td>跳过次数</td>
<td>记录该包被多少个后续包的 ACK 跳过（SACK 机制），用于触发快速重传。</td>
</tr>
</tbody></table>
<h4>2.3.2 激进 ARQ</h4>
<p>KCP 的激进性体现在它对传统 TCP 策略的全面修正。</p>
<h5>2.3.2.1 混合确认机制：UNA + ACK List</h5>
<p>TCP 主要依赖累计确认（UNA）。KCP 则采用了 <strong>UNA + ACK List</strong> 的混合模式。</p>
<ul>
<li>头部中的 <code>una</code> 字段提供累计确认，保证基础的滑动窗口推进。</li>
<li>同时，KCP 会单独发送 ACK 包（或者在数据包后追加 ACK 信息），显式告知收到了哪些特定的 <code>sn</code>。这种机制类似于 TCP 的 SACK，但在 KCP 中是原生且强制的。它允许发送端精确知道哪些包丢失，从而只重传丢失的包（选择性重传，Selective Repeat），避免了 Go-Back-N 的带宽浪费。</li>
</ul>
<h5>2.3.2.2 快速重传</h5>
<p>TCP 需要 3 个重复 ACK 触发快重传。KCP 引入了 <code>fastack</code> 计数器：</p>
<ul>
<li>当发送端收到一个 ACK，确认了 <code>sn=100</code> 和 <code>sn=102</code>，但没有确认 <code>sn=101</code> 时，<code>sn=101</code> 的 <code>fastack</code> 计数器加 1。</li>
<li>一旦 <code>fastack</code> 达到设定阈值（<code>resend</code> 参数，通常设为 2 甚至 1），KCP 不等待 <code>rto</code> 超时，立即重传 <code>sn=101</code>。</li>
<li>这使得 KCP 在跨越长肥管道（Long Fat Network）时，能比 TCP 快数倍地感知并恢复丢包。</li>
</ul>
<h5>2.3.2.3 非退让的流控</h5>
<p>TCP 检测到丢包会减半窗口（拥塞避免）。KCP 提供了 <code>nc</code>（No Congestion Control）配置开关。</p>
<ul>
<li>当 <code>nc=1</code> 时，KCP 完全关闭拥塞窗口（cwnd）逻辑，只受限于接收端的接收窗口（rwnd）和发送端的发送缓冲区大小。</li>
<li>这意味着即使网络极度拥塞，丢包率极高，KCP 依然会按照最大速度发送数据和重传包。这种“自私”的行为在公共互联网上可能加剧拥塞，但对于实时游戏等对延迟极度敏感的应用，它是保证流畅体验的关键手段。</li>
</ul>
<h5>2.3.2.4 RTO 策略优化</h5>
<ul>
<li><strong>不翻倍</strong>：TCP 超时后 RTO 翻倍（x2, x4, x8）。KCP 默认仅 x1.5，这意味着它会更频繁地重试。</li>
<li><strong>RTO 最小值</strong>：TCP 的 RTO 最小值通常受限于内核 tick（例如 200ms），虽然现代 Linux 已优化，但 KCP 允许在用户态设置极低的 RTO 最小值（如 10ms-30ms），这对于局域网或高质量光纤网的微小抖动反应极快。</li>
</ul>
<h4>2.3.3 算力代价：用户态时钟与轮询</h4>
<p>KCP 的高性能是有代价的——CPU 利用率。</p>
<ul>
<li><strong>User-space Scheduling</strong>：KCP 没有内核的中断驱动机制。用户程序必须在一个循环中不断调用 <code>ikcp_update(current_time)</code>。</li>
<li><strong>Tick 频率</strong>：为了获得低延迟，<code>ikcp_update</code> 通常每 10ms 甚至 1ms 调用一次。这导致 CPU 即使在空闲时也难以进入深度睡眠状态，产生了大量的空轮询开销。</li>
</ul>
<blockquote>
<p>KCP 的本质是在应用层实现了一个<strong>高频轮询的调度器</strong>（通常 <code>update</code> 间隔为 10ms）。它比 TCP 多耗费 20%-30% 的流量，换取了低延迟。</p>
<p>关于 KCP 更多的底层细节可参阅 <a href="/blog/kcp/">KCP 源码分析与原理总结</a>。</p>
</blockquote>
<h3>2.4 QUIC：用户态的 TCP+TLS</h3>
<p>QUIC（Quick UDP Internet Connections），现已标准化为 RFC 9000，代表了网络传输协议的最新演进方向。它不仅仅是一个传输协议，更是一个将传输层（Transport）、安全层（TLS）和部分应用层（HTTP/2）功能融合的<strong>垂直整合架构</strong>。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251127135757381.png" style="zoom: 25%;" />

<p>关键点：</p>
<ol>
<li>低连接延迟</li>
<li>无队头阻塞</li>
<li>灵活拥塞控制</li>
<li>连接迁移</li>
</ol>
<h4>2.4.1 报文结构</h4>
<p>QUIC 设计了两种头部格式，以适应握手和数据传输的不同需求。</p>
<ul>
<li><p><strong>长首部（Long Header）</strong>：用于连接建立阶段（Initial, Handshake, Retry, 0-RTT）。第一字节最高位为 1。包含完整的 Source CID 和 Destination CID，以及版本号。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251127140231826.png" style="zoom:60%;" />
</li>
<li><p><strong>短首部（Short Header）</strong>：用于连接建立后的数据传输（1-RTT）。第一字节最高位为 0。仅包含 Destination CID（可选）和 Packet Number。这极大地减少了头部开销。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251127140249939.png" style="zoom: 85%;" /></li>
</ul>
<p>QUIC 的一个关键安全特性是对 Packet Number 进行加密。</p>
<ul>
<li><strong>机制</strong>：利用 Header Protection Key（从 TLS 协商导出），对 Packet Number 字段进行异或掩码操作。</li>
<li><strong>目的</strong>：防止中间设备（Middleboxes）窥探连接的 RTT 或丢包率，也防止中间设备基于明文头部做深度包检测（DPI）从而干扰连接。这强化了协议的抗僵化能力（Ossification Resistance）。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/http3_packet.png" alt=""></p>
<h4>2.4.2 核心结构：Frame 与 Stream（解决队头阻塞）</h4>
<p>TCP 的队头阻塞源于其单一的字节流抽象。QUIC 引入了<strong>Stream</strong>作为一等公民。</p>
<ul>
<li><strong>独立性</strong>：一个 QUIC 连接可以包含多个 Stream。每个 Stream 有独立的 ID 和 Offset。</li>
<li><strong>底层实现</strong>：QUIC 数据包（Packet）是传输单元，Frame 是逻辑单元。一个 Packet 可以承载属于 Stream A 的 Frame 和属于 Stream B 的 Frame。</li>
<li><strong>抗阻塞</strong>：如果承载 Stream A 数据的 Packet 丢失，接收端只需等待该 Packet 重传即可恢复 Stream A；而 Stream B 的数据如果在后续 Packet 中到达，接收端可以立即提交给应用层，无需等待 Stream A 的恢复。这彻底消除了传输层的队头阻塞。</li>
</ul>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/QUIC-PICTURE-05-1024x560.jpg" style="zoom:50%;" />



<h4>2.4.3 连接迁移与 CID</h4>
<p>移动互联网时代，设备的 IP 地址经常变动（Wi-Fi 切 5G）。TCP 依赖四元组（SrcIP, SrcPort, DstIP, DstPort）标识连接，IP 变动会导致连接中断。</p>
<ul>
<li><strong>Connection ID (CID)</strong>：QUIC 使用 CID 唯一标识连接。</li>
<li><strong>迁移机制</strong>：当客户端 IP 变化时，它在新的 IP 上发送包含原有 Destination CID 的数据包。服务器收到后，通过哈希表查找 CID 对应的连接上下文，验证数据包的真实性（防欺均），然后更新路径信息。连接保持不断，应用层无感知 22。</li>
<li><strong>隐私保护</strong>：为了防止路径关联攻击（通过追踪 CID 关联用户的物理位置），QUIC 允许在连接期间协商一组新的 CID。客户端在切换网络时主动更换使用新的 CID，使得监听者无法关联前后两条路径。</li>
</ul>
<h4>2.4.4 低延迟连接</h4>
<p>QUIC 深度集成了 TLS 1.3，将传输层握手与加密握手合并。</p>
<ul>
<li><strong>1-RTT</strong>：首次连接，客户端发送 Initial 包包含 TLS ClientHello，服务器回复 ServerHello 和 EncryptedExtensions。1 个 RTT 后即可发送应用数据。</li>
<li><strong>0-RTT</strong>：对于曾经连接过的服务器，客户端缓存了 ServerConfig 或 Session Ticket。在重连时，客户端利用预共享密钥（PSK）加密应用数据，随第一个 Initial 包（ClientHello）一起发送。服务器收到后立即解密处理。</li>
<li><strong>反重放（Anti-Replay）</strong>：0-RTT 数据不具备前向安全性，且容易被重放。RFC 9000 要求服务器对 0-RTT 数据的使用极其谨慎，通常只允许幂等请求（如 GET），并通过时间窗和 Ticket 唯一性检查来限制重放窗口。</li>
</ul>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/quic-handshake-comparison.gif" style="zoom:67%;" />

<h4>2.4.5 更精确的恢复</h4>
<p>QUIC 改进了 TCP 的 ACK 机制：</p>
<ul>
<li><strong>ACK Ranges</strong>：TCP SACK 只有 3-4 个块。QUIC 的 ACK Frame 可以携带大量的 ACK Ranges（交替的 Ack 和 Gap 块），能精确描述极度碎片化的接收状态。</li>
<li><strong>Packet Number 单调递增</strong>：TCP 重传时使用相同的 SEQ。QUIC 重传一个 Frame 时，会将其封装在一个新的 Packet 中，使用<strong>新的 Packet Number</strong>。<ul>
<li><strong>消除二义性</strong>：接收端收到 ACK 时，根据 ACK 中的 Packet Number 就能明确知道是确认了原始包还是重传包。这彻底解决了 TCP 的重传二义性问题，使得 RTT 计算极其精准，不再需要 Karn 算法的退避策略。</li>
</ul>
</li>
</ul>
<h3>2.5 总结</h3>
<p>为了更直观地理解，我们对比一下它们处理&quot;数据发送&quot;这个动作的底层逻辑：</p>
<table>
<thead>
<tr>
<th><strong>动作</strong></th>
<th><strong>TCP (Kernel)</strong></th>
<th><strong>UDP (Kernel)</strong></th>
<th><strong>KCP (User Space)</strong></th>
<th><strong>QUIC (User Space)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>封装</strong></td>
<td>这里是数据 -&gt; 加 TCP 头 -&gt; 存入发送缓冲区 -&gt; 睡觉等 ACK</td>
<td>这里是数据 -&gt; 加 UDP 头 -&gt; 扔给网卡 -&gt; 结束</td>
<td>这里是数据 -&gt; <strong>加 KCP 头 -&gt; 放入 UDP Payload</strong> -&gt; 扔给网卡</td>
<td>这里是数据 -&gt; <strong>拆分 Frame -&gt; 加密 -&gt; 放入 UDP Payload</strong> -&gt; 扔给网卡</td>
</tr>
<tr>
<td><strong>重传触发</strong></td>
<td>1. 超时 (RTO 很长) <br>2. 收到 3 个重复 ACK</td>
<td>无</td>
<td>1. 超时 (RTO 很短) <br>2. 收到 n 个跨越包 (n可配)</td>
<td>1. 超时 (基于精确 RTT) <br>2. 独立 Stream 触发</td>
</tr>
<tr>
<td><strong>拥塞响应</strong></td>
<td>丢包 = 网络堵塞 -&gt; <strong>降速</strong></td>
<td>无</td>
<td>丢包 = 信号不好 -&gt; <strong>加速重传</strong> (可选关闭流控)</td>
<td>丢包 = 根据算法 (如 BBR) 智能判断 -&gt; <strong>动态调整</strong></td>
</tr>
<tr>
<td><strong>内存拷贝</strong></td>
<td>用户态 -&gt; 内核态 (Context Switch)</td>
<td>用户态 -&gt; 内核态</td>
<td>用户态处理 -&gt; 此时还在用户态 -&gt; 只有最后发 UDP 时进内核</td>
<td>完全在用户态处理 -&gt; 只有最后发 UDP 时进内核</td>
</tr>
</tbody></table>
<p>从原理出发，我们可以得出工程实践的指导原则：</p>
<ol>
<li><strong>内网微服务 (RPC)：</strong> 依然首选 <strong>TCP</strong>。因为内网环境极其稳定，带宽大，丢包率几乎为 0。TCP 的内核态实现效率极高，CPU 消耗比 QUIC 低得多（QUIC 需要在用户态频繁解密和计算，非常吃 CPU）。</li>
<li><strong>公网实时游戏/音视频：</strong> 首选 <strong>KCP</strong>（或类 KCP 的私有协议）。因为你要的是低延迟，且你可以容忍多跑一点流量。</li>
<li><strong>弱网环境下的 App/Web：</strong> 首选 <strong>QUIC</strong>。比如跨国访问、移动端环境。它解决了 TCP 的队头阻塞和连接迁移问题，能显著提高用户的加载体验。</li>
</ol>
<h2>3. 实践检验</h2>
<blockquote>
<p>抓包看一下 TCP 的三次握手、四次挥手和数据传输。</p>
</blockquote>
<p>server:</p>
<pre><code class="language-rust">use axum::{routing::get, Router};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -&gt; anyhow::Result&lt;()&gt; {
    let router = Router::new().route(&quot;/hello&quot;, get(hello_handler));
    let listener = TcpListener::bind(&quot;0.0.0.0:12345&quot;).await?;
    println!(&quot;listening on {}&quot;, listener.local_addr()?);
    axum::serve(listener, router.into_make_service()).await?;
    Ok(())
}

async fn hello_handler() -&gt; &amp;&#39;static str {
    &quot;hello world&quot;
}
</code></pre>
<p>client:</p>
<pre><code class="language-rust">#[tokio::main]
async fn main() -&gt; anyhow::Result&lt;()&gt; {
    let res = reqwest::get(&quot;http://localhost:12345/hello&quot;).await?;
    println!(&quot;{}&quot;, res.text().await?);
    Ok(())
}
</code></pre>
<p>tcpdump:</p>
<pre><code class="language-shell">sudo tcpdump -Xvvvnnttt -i any  -s 0 tcp port 12345 -w ./packet.pcap &gt; tcpdump.log
</code></pre>
<h3>3.1 三次握手</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251127163957890.png" alt=""></p>
<p>第一次握手：client(57831) 向 server(12345) 发送 <code>SYNC</code> 包</p>
<pre><code class="language-markdown">- 初始化 `Sequence Number` 为 408182767
- 初始化 `Acknowled Number (raw)` 为 0
- 设置 `SYNC` 标记位
- 初始化窗口 `65535`
- Options 中允许 `SACK` 选择性确认
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251127164015711.png" alt=""></p>
<p>第二次握手：server(12345) 向 client(57831) 发送 <code>ACK+SYNC</code> 包</p>
<pre><code class="language-markdown">- 初始化 `Sequence Number` 为 785921704
- 初始化 `Acknowled Number (raw)` 为 408182768（为上一步的 `Sequence Number 408182767` + 1）
- 设置 `SYNC` 和 `ACK` 标记位
- 初始化窗口 `65535`
- Options 中允许 `SACK` 选择性确认
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241121123516535.png" alt=""></p>
<p>第三次握手：client(57831) 向 server(12345) 发送 <code>ACK</code> 包</p>
<pre><code class="language-markdown">- `Sequence Number` 加 1 变为 `408182768`，即上一步的 `Acknowled Number (raw)`
- 初始化 `Acknowled Number (raw)` 为 785921705（为上一步的 `Sequence Number 785921704` + 1）
- 设置 `ACK` 标记位
- 窗口修改为 `5379`
</code></pre>
<h3>3.2 数据传输</h3>
<p>初始状态：</p>
<pre><code class="language-markdown">client:
- SeqNum: 408192768
- AckNum: 78591705
server:
- SeqNum: 78591705（SYNC 包占了一个序列号，所以是 785921704+1）
- AckNum: 408192768
</code></pre>
<p>client → server:</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241121133716537.png" alt=""></p>
<p>server → client:</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241121133759217.png" alt=""></p>
<h3>3.3 四次挥手</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241121133849880.png" alt="img"></p>
<p>第一次挥手：client(57831) 向 server(12345) 发送 <code>FIN</code> 包</p>
<p>第二次挥手：server(12345) 向 client(57831) 发送 <code>ACK</code> 包</p>
<p>第三次挥手：server(12345) 向 client(57831) 发送 <code>FIN</code> 包</p>
<p>第四次挥手：client(57831) 向 server(12345) 发送 <code>ACK</code> 包</p>
<h3>3.4 两个工具</h3>
<h4>3.4.1 netstat</h4>
<p><strong>基础参数</strong></p>
<pre><code class="language-bash">-a    # 显示所有连接和监听端口
-n    # 以数字形式显示地址和端口号
-p    # 显示进程名称/进程号
-t    # 显示 TCP 协议的连接
-u    # 显示 UDP 协议的连接
-l    # 仅显示监听中的连接
</code></pre>
<p><strong>常见组合使用</strong></p>
<pre><code class="language-bash"># 显示所有 TCP 连接
netstat -at

# 显示所有监听端口
netstat -l

# 显示所有 TCP 监听端口
netstat -lt

# 显示所有进程和监听端口（需要 root 权限）
netstat -nltp

# 显示路由表信息
netstat -r
</code></pre>
<p><strong>查看特定端口</strong></p>
<pre><code class="language-bash"># 查看 80 端口的使用情况
netstat -an | grep &#39;:80&#39;
</code></pre>
<p><strong>查看程序连接</strong></p>
<pre><code class="language-bash"># 查看所有 HTTP 相关连接
netstat -anp | grep &#39;http&#39;
</code></pre>
<p><strong>统计连接数</strong></p>
<pre><code class="language-bash"># 统计各种状态的连接数
netstat -n | awk &#39;/^tcp/ {++state[$NF]} END {for(key in state) print key,&quot;\\\\t&quot;,state[key]}&#39;
</code></pre>
<p>netstat 输出的典型字段包括：</p>
<ul>
<li><code>Proto</code>: 协议（TCP/UDP）</li>
<li><code>Recv-Q</code>: 接收队列</li>
<li><code>Send-Q</code>: 发送队列</li>
<li><code>Local Address</code>: 本地地址:端口</li>
<li><code>Foreign Address</code>: 远程地址:端口</li>
<li><code>State</code>: 连接状态</li>
<li><code>PID/Program name</code>: 进程ID和程序名称</li>
</ul>
<p>常见连接状态：</p>
<ul>
<li><code>LISTEN</code>: 监听中</li>
<li><code>ESTABLISHED</code>: 已建立连接</li>
<li><code>TIME_WAIT</code>: 等待关闭</li>
<li><code>CLOSE_WAIT</code>: 等待关闭</li>
<li><code>SYN_SENT</code>: 发送同步</li>
<li><code>SYN_RECV</code>: 接收同步</li>
</ul>
<h4>3.4.2 ss</h4>
<pre><code class="language-shell">ss [选项] [过滤条件]
</code></pre>
<p><strong>基础参数</strong></p>
<pre><code class="language-bash">-n    # 不解析服务名称，以数字显示
-a    # 显示所有套接字
-l    # 显示监听状态的套接字
-p    # 显示进程信息
-t    # 显示 TCP 套接字
-u    # 显示 UDP 套接字
-x    # 显示 Unix domain 套接字
-s    # 显示套接字使用概况
-4    # 仅显示 IPv4
-6    # 仅显示 IPv6
</code></pre>
<p><strong>常见用法</strong></p>
<pre><code class="language-shell"># 显示所有 TCP 连接
ss -t -a

# 显示所有监听端口和进程信息（常用）
ss -tlnp

# 显示统计信息
ss -s

# 显示所有 established 状态的 TCP 连接
ss -t state established
</code></pre>
<p><strong>状态过滤</strong></p>
<pre><code class="language-bash"># 显示指定状态的连接
ss state established
ss state time-wait
ss state listening
</code></pre>
<p><strong>端口过滤</strong></p>
<pre><code class="language-bash"># 显示指定端口的连接
ss sport = :80    # 源端口
ss dport = :80    # 目标端口

# 显示特定端口范围
ss sport gt :1024  # 大于1024的源端口
</code></pre>
<p><strong>地址过滤</strong></p>
<pre><code class="language-bash"># 显示与特定 IP 相关的连接
ss dst 192.168.1.1
ss src 192.168.1.1
</code></pre>
<p><strong>查看具体服务</strong></p>
<pre><code class="language-bash"># 查看 HTTP 相关连接
ss -np state established &#39;( dport = :80 or sport = :80 )&#39;

# 查看 SSH 连接
ss -o state established &#39;( dport = :22 or sport = :22 )&#39;
</code></pre>
<p><strong>查看连接统计</strong></p>
<pre><code class="language-bash"># 查看连接速率
ss -i

# 查看内存使用
ss -m
</code></pre>
<p><strong>高级过滤</strong></p>
<pre><code class="language-bash"># 查看特定进程的连接
ss -p | grep nginx

# 查看非监听 TCP 连接
ss -t -a &#39;( dport != :22 )&#39;
</code></pre>
<p><strong>ss 输出的典型字段包括：</strong></p>
<ul>
<li><code>Netid</code>: 协议类型</li>
<li><code>State</code>: 连接状态</li>
<li><code>Recv-Q</code>: 接收队列</li>
<li><code>Send-Q</code>: 发送队列</li>
<li><code>Local Address:Port</code>: 本地地址和端口</li>
<li><code>Peer Address:Port</code>: 对端地址和端口</li>
<li><code>Process</code>: 进程信息（使用 -p 参数时显示）</li>
</ul>
<p><strong>性能优势：</strong></p>
<ol>
<li><strong>ss 直接从内核空间读取信息，而不是像 netstat 那样读取 /proc</strong></li>
<li>ss 的运行速度更快</li>
<li>ss 能够显示更多的 TCP 状态信息</li>
</ol>
<p><strong>注意事项：</strong></p>
<ol>
<li>某些操作需要 root 权限</li>
<li>不同 Linux 发行版的 ss 版本可能有细微差异</li>
<li>使用 -p 参数时，非 root 用户可能看不到所有进程信息</li>
</ol>
<p><strong>与 netstat 的对比：</strong></p>
<pre><code class="language-bash"># netstat 命令              # ss 等效命令
netstat -tulpn            ss -tulpn
netstat -antop            ss -antop
netstat -t                ss -t
netstat -l                ss -l
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>traceroute 故障排查：Clash Fake IP 及其他 4 种常见原因</title>
      <link>https://hedon.top/blog/clash-fake-ip/</link>
      <guid isPermaLink="true">https://hedon.top/blog/clash-fake-ip/</guid>
      <pubDate>Tue, 25 Nov 2025 21:30:00 GMT</pubDate>
      <description>通过分析 Clash Mi 的 Fake IP 模式导致 traceroute 返回全是星号的问题，深入理解 VPN 代理的工作原理和常见陷阱。</description>
      <category>计算机网络</category><category>计算机基础</category>
      <content:encoded><![CDATA[<p>本篇源于笔者一次使用 <code>traceroute</code> 遇到的疑难杂症的排查，在这个过程中，通过跟 Google Gemini 3Pro 的沟通，对计算机网络和平时使用的 VPN 工具又有了进一步的了解，特此梳理本文。</p>
<h3>traceroute 回顾</h3>
<p>先回顾一下 <code>traceroute</code> 这个工具：</p>
<ul>
<li><strong>作用</strong>：用来跟踪一个 IP 数据包从源点到终点的路径。</li>
<li><strong>原理</strong>：它利用 <strong>IP</strong> 数据报中的 <strong>TTL</strong> 字段和 <strong>ICMP</strong> 时间超时差错报告报文实现对从源点到终点的路径的跟踪。</li>
<li><strong>过程</strong>：<ul>
<li>客户端发送一个 TTL 为 1 的探测数据包（Linux/macOS 默认使用 UDP，Windows 使用 ICMP），在第一跳的时候超时并返回一个 ICMP 超时数据包，得到第一跳的地址。<ul>
<li>客户端发送一个 TTL 为 2 的探测数据包，得到第二跳的地址。</li>
<li>依次递增 TTL，直到到达 <strong>目标主机</strong>，目标主机返回响应（UDP 端口不可达或 ICMP 回显应答），traceroute 结束。</li>
</ul>
</li>
</ul>
</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gt3mv34h3ej30hj0cw0te.jpg" alt=""></p>
<h3>问题再现</h3>
<p>我在使用 <code>traceroute</code> 跟踪我本机到我的博客域名 <code>hedon.top</code> 的跳转路径时，发现很奇怪，返回的全是 <code>* * *</code>！</p>
<pre><code class="language-sh">➜  ~ traceroute hedon.top
traceroute to hedon.top (172.19.0.13), 64 hops max, 40 byte packets
 1  * * *
 2  * * *
 3  * * *
 4  *
</code></pre>
<p>我就怀疑是不是因为我开启了 VPN，所以我就询问了一下 Google Gemini 3Pro，还真是！它说是因为 VPN 里面的 <code>fake ip</code> 导致了，我立马检查了我的 Clash Mi，发现果真如此，同时我关闭 Clash Mi 后，<code>traceroute</code> 就一切正常了。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251125213716125.png" style="zoom:33%;" />

<hr>
<h3>原理分析</h3>
<p>为什么会这样呢？</p>
<blockquote>
<p>[!IMPORTANT]</p>
<p>很多代理软件开启 <strong>增强模式 (Fake IP)</strong> 时，会拦截所有 DNS 请求。为了加快速度，它不进行真正的 DNS 查询，而是直接扔给你一个“假的内部 IP”（通常是 <code>198.18.x.x</code>，但也可以配置成 <code>172.x.x.x</code>），然后由代理软件接管流量。</p>
<p>又因为大多数 VPN 软件的 Fake IP 逻辑只处理 <strong>TCP/UDP 数据流</strong>（用来浏览网页），它并不支持通过 Fake IP 来做 ICMP 路由探测。所以就导致了探测包发出去如泥牛入海，VPN 不回信，真实服务器更收不到（因为根本没发给真实 IP），所以看到的全是 <code>* * *</code>。</p>
</blockquote>
<h4>为什么需要 Fake IP 呢？ —— 为了快！</h4>
<p>在正常的 VPN/代理模式下，当你访问 <code>hedon.top</code> 时，采用的是 <code>Redir-Host</code> 模式：</p>
<ol>
<li><strong>本地 DNS 解析</strong>：电脑问 DNS 服务器 &quot;hedon.top 是多少？&quot;</li>
<li><strong>等待</strong>：等待 DNS 返回 IP（比如 30ms）。</li>
<li><strong>建立连接</strong>：电脑拿着 IP 去发起 TCP 连接。</li>
<li><strong>代理软件</strong>：拦截连接，发现这个 IP 是国外的，于是走代理通道。</li>
</ol>
<p>代理软件的设计者觉得步骤 2 是纯浪费时间。既然反正要走代理，我为什么要让本地 DNS 去查一个国外的 IP？而且万一 DNS 被污染了，给了一个错误的 IP，我还得想办法纠错。</p>
<p><strong>开启 VPN (Fake IP 模式) 后的流程：</strong></p>
<ol>
<li><strong>拦截</strong>：你发出的 DNS 请求，还没出电脑网卡，就被 VPN 软件截获了。</li>
<li><strong>秒回</strong>：VPN <strong>立刻、马上、随便</strong>编一个内网 IP（比如我看到的 <code>172.19.0.13</code>）扔给你的系统。<ul>
<li>VPN 在心里记了个账：<code>172.19.0.13</code> &lt;==&gt; <code>hedon.top</code>。</li>
</ul>
</li>
<li><strong>欺骗成功</strong>：浏览器（或 traceroute）拿到了这个 IP，以为是真的，于是向这个 IP 发起连接。</li>
<li><strong>偷梁换柱</strong>：数据包发出来，又被 VPN 截获。VPN 查账本，发现目标是 <code>172.19.0.13</code>，于是它知道：&quot;哦，这其实是要去访问 <code>hedon.top</code>&quot;。</li>
<li><strong>远程解析</strong>：VPN 把&quot;访问 <code>hedon.top</code>&quot;这个指令发给远端的代理服务器，由远端服务器去解析真正的 IP 并传输数据。</li>
</ol>
<pre><code class="language-mermaid">sequenceDiagram
    autonumber
    participant App as 浏览器/App
    participant OS as 操作系统/DNS栈
    participant Clash as Clash (Fake IP)
    participant Remote as 远端代理服务器

    Note over App, Clash: 阶段一：DNS 欺骗 (极速响应)

    App-&gt;&gt;OS: 域名解析请求: hedon.top
    OS-&gt;&gt;Clash: 发送 UDP 53 包

    Note right of Clash: Clash 拦截请求&lt;br&gt;根本不去查互联网！
    Clash-&gt;&gt;Clash: 1. 从 Fake IP 池选一个空闲 IP&lt;br&gt;比如 172.19.0.13
    Clash-&gt;&gt;Clash: 2. 记账 (Mapping)&lt;br&gt;&quot;172.19.0.13&quot; = &quot;hedon.top&quot;

    Clash--&gt;&gt;App: 秒回: IP 是 172.19.0.13

    Note over App, Clash: 阶段二：建立连接 (偷梁换柱)

    App-&gt;&gt;App: 以为拿到了真 IP&lt;br&gt;向 172.19.0.13 发起 TCP 连接
    App-&gt;&gt;Clash: TCP SYN (Dst: 172.19.0.13)

    Note right of Clash: Clash 拦截 TCP 包&lt;br&gt;查账本：172.19.0.13 是谁？
    Clash-&gt;&gt;Clash: 哦，原来是 hedon.top

    Clash-&gt;&gt;Remote: 把&quot;域名 hedon.top&quot;发给远端&lt;br&gt;由远端服务器去解析真实 IP
    Remote-&gt;&gt;Remote: 在海外解析并连接真实服务器
</code></pre>
<p><strong>关键点：</strong></p>
<ol>
<li><strong>省时</strong>：DNS 响应是<strong>毫秒级</strong>的，因为根本不需要网络请求，Clash 直接从内存里扔一个 IP 给你。</li>
<li><strong>防污染</strong>：因为本地根本不进行真实的 DNS 解析，GFW 的 DNS 污染攻击直接无效。</li>
<li><strong>远端解析</strong>：真实的 IP 解析发生在远端代理服务器（比如在日本或美国的机房），那里解析出来的 IP 一定是离目标最近、最准确的（比如 Google 的 CDN 节点）。</li>
</ol>
<h4>为什么返回是全是 * * * 呢？</h4>
<p>再看那个全是 <code>* * *</code> 的现象，就很容易理解了：</p>
<ol>
<li><strong>执行</strong>：<code>traceroute hedon.top</code>。</li>
<li><strong>Clash Mi 欺骗</strong>：给了 <code>172.19.0.13</code>。</li>
<li><strong>发包</strong>：<code>traceroute</code> 向 <code>172.19.0.13</code> 发送 UDP/ICMP 探测包。</li>
<li><strong>死胡同</strong>：<ul>
<li>这个 IP 在公网上是不存在的。</li>
<li>Clash 通常只代理浏览器的 TCP/UDP <strong>数据流</strong>，它并没有义务去模拟路由器的 ICMP TTL 回显功能。</li>
<li>所以探测包发给 Clash Mi 的虚拟网卡后，就像掉进了黑洞，没有任何设备回信&quot;超时&quot;。</li>
</ul>
</li>
</ol>
<hr>
<h3>注意事项</h3>
<p>Fake IP 虽然爽，但对于写代码的人来说，有两个巨大的坑：</p>
<h4>坑一：Docker/局域网冲突</h4>
<p>如果 Clash Mi 用的 Fake IP 网段（如 <code>172.19.0.0/16</code>）恰好和你的 Docker 容器网段重叠。</p>
<ul>
<li><strong>现象</strong>：你要连本地的 Docker 数据库，结果流量被 Clash 吸走了，报&quot;连接被拒绝&quot;。</li>
<li><strong>解法</strong>：始终确保 Fake IP 网段设置为 <strong><code>198.18.0.1/16</code></strong>。这是一个专门用于性能测试的保留网段，世界上没有公网机器用它，Docker 默认也不用它。</li>
</ul>
<h4>坑二：IP 缓存中毒 (DNS Cache Poisoning)</h4>
<p>有些笨拙的软件（比如旧版的 Java 客户端、某些物联网设备 SDK）会<strong>缓存 DNS 结果</strong>。</p>
<ol>
<li>你开了 VPN，程序解析 <code>hedon.top</code> 拿到 <code>172.19.0.13</code>。</li>
<li>程序把这个 IP 存到自己的内存缓存里，有效期 1 小时。</li>
<li><strong>你关了 VPN</strong>。</li>
<li>程序再次发起请求，它不去解析 DNS 了，直接连 <code>172.19.0.13</code>。</li>
<li><strong>报错</strong>：因为 VPN 关了，操作系统不知道这个 IP 是谁，网络直接不可达。</li>
</ol>
<p><strong>解法</strong>：关 VPN 后，往往需要重启应用，甚至执行 <code>ipconfig /flushdns</code> (Windows) 或 <code>sudo killall -HUP mDNSResponder</code> (macOS)。</p>
<h3>其他原因</h3>
<p>除了 Fake IP 这种本地欺骗导致的全是星星外，在真实的互联网环境中，<code>traceroute</code> 出现 <code>* * *</code> 是非常普遍的现象。</p>
<p>从第一性原理来看，<code>* * *</code> 的本质含义只有一个：<strong>我发出了探测包，但在规定时间内（通常是 5 秒），我没有收到任何回信。</strong></p>
<p>造成没有回信通常有以下四大类原因，我们按照<strong>出现的概率</strong>从高到低排列：</p>
<h4>1. 中间路由器的高冷 (ICMP 限速或禁发)</h4>
<p>表现：中间几行是星星，但最后能到达终点。</p>
<ul>
<li>原理：路由器的核心 KPI 是转发数据包，而不是陪聊。当你发送 TTL 超时的探测包时，路由器需要暂停手头的工作，调用 CPU 生成一个 ICMP Time Exceeded 消息发回给你。这会消耗路由器的 CPU 资源。</li>
<li>策略：为了防止被 DDoS 攻击或节省性能，运营商（ISP）和骨干网路由器通常配置了 ICMP Rate Limiting (限速) 甚至 ICMP Silently Drop (静默丢弃)。</li>
<li><strong>结论</strong>：如果中间全是星，但最后一行通了，<strong>完全不用担心</strong>，这是正常的网络现象。</li>
</ul>
<h4>2. 防火墙的黑洞策略 (DROP vs REJECT)</h4>
<p>表现：从某一行开始全是星星，直到结束都连不上。</p>
<ul>
<li>原理：当探测包撞上防火墙（可能是企业边缘防火墙、GFW、或者目标机器的 iptables）时，防火墙有两种处理方式：<ol>
<li><strong>REJECT</strong>：明确告诉你&quot;滚&quot;。你会收到 <code>Destination Unreachable</code>。</li>
<li><strong>DROP (丢弃)</strong>：直接把包扔垃圾桶，<strong>不给任何回信</strong>。</li>
</ol>
</li>
<li>为什么：出于安全考虑，管理员通常配置 DROP。因为回复错误信息会暴露防火墙的存在和 IP 地址，给黑客留下线索。</li>
<li>Linux 的痛点：Linux traceroute 默认用 UDP 高端口探测。很多企业的防火墙策略是：只允许 Web (80/443) 流量进入，封禁所有未知 UDP 端口。这会导致你还没到终点就被拦截了。</li>
<li>解决方法：使用 <code>traceroute -I</code> (改用 ICMP) 或 <code>traceroute -T</code> (改用 TCP 80 端口) 通常能穿透更多层。</li>
</ul>
<h4>3. 进出路径不一致 (非对称路由 Asymmetric Routing)</h4>
<p>表现：忽通忽断，或者全是星星。</p>
<ul>
<li>原理：互联网非常复杂，&quot;去程&quot;和&quot;回程&quot;走的路往往是不一样的。<ul>
<li><strong>去程</strong>：你 -&gt; 路由器 A -&gt; 路由器 B -&gt; 目标。</li>
<li><strong>回程</strong>：目标 -&gt; 路由器 C -&gt; 路由器 D -&gt; 你。</li>
</ul>
</li>
<li>问题：<ul>
<li>如果你发出的探测包经过了路由器 B（它是有状态防火墙），它记录了&quot;我发出了一个包&quot;。</li>
<li>但回信是路由器 C 试图发回来的。路由器 B（或者你这边的防火墙）一看：&quot;我没见过你 C 发过来的连接请求啊？你是谁？&quot;</li>
<li>于是回信被状态防火墙 (Stateful Firewall) 拦截了。</li>
</ul>
</li>
<li><strong>结论</strong>：虽然数据包可能真的到达了，但回音被杀死了。</li>
</ul>
<h4>4. 真的断网了 (路由黑洞 / 环路)</h4>
<p>表现：到某一跳后中断，或者在两个 IP 之间死循环。</p>
<ul>
<li><p>路由黑洞 (Blackhole)：路由器 A 的路由表说&quot;去往目标找 B&quot;，但路由器 B 说&quot;我不认识目标，也没有默认网关&quot;。数据包到了 B 就被丢弃了（且 B 如果配置了不回显 ICMP，就是星星）。</p>
</li>
<li><p>路由环路 (Loop)：A 说&quot;找 B&quot;，B 说&quot;找 A&quot;。</p>
<p>traceroute 会显示：</p>
<pre><code>5  10.0.0.1
6  10.0.0.2
7  10.0.0.1
8  10.0.0.2
</code></pre>
<p>直到 TTL 耗尽。</p>
</li>
</ul>
<h3>总结</h3>
<p>看到 <code>* * *</code> 时，需要通过<strong>上下文</strong>来判断：</p>
<table>
<thead>
<tr>
<th><strong>现象</strong></th>
<th><strong>含义</strong></th>
<th><strong>后端应对</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>全星 (第 1 跳就开始)</strong></td>
<td>连门都没出去</td>
<td>查 VPN Fake IP、本地防火墙、网关配置</td>
</tr>
<tr>
<td><strong>中间有星，最后通了</strong></td>
<td>中间路由器高冷/忙碌</td>
<td><strong>忽略</strong>，网络是通的</td>
</tr>
<tr>
<td><strong>最后几行全是星</strong></td>
<td>目标主机开了防火墙/禁 Ping</td>
<td>尝试 <code>telnet</code> 端口验证业务层连通性</td>
</tr>
<tr>
<td><strong>从第 X 跳开始全星</strong></td>
<td>链路中断 或 强力防火墙(GFW)</td>
<td>检查路由表，联系网管</td>
</tr>
<tr>
<td><strong>星星夹杂 IP (如 <code>\* 1.1.1.1 \*</code>)</strong></td>
<td>丢包率高 / 负载均衡</td>
<td>网络质量差，存在抖动</td>
</tr>
</tbody></table>
<h3>新工具推荐</h3>
<p>一个实用的命令：</p>
<p>如果你在排查服务器连通性，建议使用 mtr (My Traceroute)。它结合了 ping 和 traceroute，会实时刷新每一跳的丢包率。</p>
<pre><code class="language-sh"># 能够清晰看到是哪一跳开始丢包的
mtr -n hedon.top
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>从第一性原理理解 epoll</title>
      <link>https://hedon.top/blog/linux-io-epoll/</link>
      <guid isPermaLink="true">https://hedon.top/blog/linux-io-epoll/</guid>
      <pubDate>Sun, 23 Nov 2025 20:00:00 GMT</pubDate>
      <description>本文跳出传统 API 层面，从第一性原理剖析 epoll 的高并发优势，阐释其通过内核态持久化管理、异步事件驱动和红黑树/链表机制，彻底解决 select/poll 线性瓶颈的内核机制与原理。</description>
      <category>epoll</category><category>网络编程</category><category>非阻塞 i/o</category><category>计算机基础</category><category>计算机网络</category>
      <content:encoded><![CDATA[<p>要从根本上理解 <code>epoll</code>，我们必须跳出 API 的表象，深入到操作系统内核的数据结构和中断处理机制中。</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--warning" aria-hidden="true"></span></p>
<p><strong>一句话总结 epoll</strong></p>
<p>它将 I/O 处理模式从 <strong>&quot;同步轮询 (Synchronous Polling)&quot;</strong> 彻底转变为 <strong>&quot;异步事件驱动 (Asynchronous Event-Driven)&quot;</strong>，并将文件描述符（FD）集合的管理权从 &quot;用户态&quot; 移交给了 &quot;内核态&quot; 以实现状态持久化。</p>
</blockquote>
<p>为了讲清楚，我们把它拆解为三个维度：<strong>核心痛点</strong>（为什么要有它）、<strong>内核架构</strong>（它长什么样）、<strong>工作流程</strong>（它是怎么跑的）。</p>
<h2>1. 核心痛点：O(N) 的线性复杂度瓶颈</h2>
<p>在 <code>epoll</code> 出现之前（Linux 2.6 之前），网络编程主要依赖 <code>select</code> 或 <code>poll</code>。从计算机体系结构角度看，它们存在一个致命的**无状态（Stateless）**设计缺陷。</p>
<p><code>select</code>/<code>poll</code> 模型要求用户每次发起系统调用时，必须将所有需要监控的 FD 集合传递给内核。内核的处理逻辑如下：</p>
<ol>
<li><strong>全量拷贝</strong>：将用户态的 FD 数组完整拷贝到内核态。</li>
<li><strong>全量遍历</strong>：内核必须线性遍历这个 FD 数组，逐个检查对应的硬件设备状态。</li>
<li><strong>全量返回</strong>：如果发现有就绪事件，或者超时，内核再将修改后的 FD 状态位图拷贝回用户态。</li>
</ol>
<p>这种方式有以下弊端：</p>
<ul>
<li><strong>上下文切换开销</strong>：在高并发场景下（例如 10 万连接），每次调用都要在用户态和内核态之间传递巨大的数据块。</li>
<li><strong>CPU 算力浪费</strong>：时间复杂度为 $O(N)$。即使 10 万个连接中只有 1 个活跃，内核也必须检查完所有 10 万个状态。随着 $N$ 的增加，系统性能呈线性下降趋势。</li>
</ul>
<p>于是就出现了 <code>epoll</code>，<code>epoll</code> 具有以下特点：</p>
<ul>
<li><strong>效率高</strong>: 相较于 <code>select</code> 和 <code>poll</code>，<code>epoll</code> 可以更高效地处理大量的并发连接。<code>select</code> 和 <code>poll</code> 的效率随着监视的文件描述符数量增加而线性下降，而 <code>epoll</code> 则不会因为监视的文件描述符数量增加而显著降低效率。</li>
<li><strong>扩展性好</strong>: <code>epoll</code> 使用一种称为事件通知的机制，只会处理那些真正发生了事件的文件描述符。这意味着系统不必重新检查所有文件描述符，从而大大减少了不必要的 CPU 开销。</li>
<li><strong>支持边缘触发和水平触发</strong>: <code>epoll</code> 支持 <code>Edge Triggered</code> 和水平触发 <code>Level Triggered</code> 两种模式。边缘触发模式只在文件描述符状态改变时才通知应用程序，适用于非阻塞 I/O；而水平触发模式则在有事件可读或可写时都会通知应用程序，更容易使用但效率略低。</li>
</ul>
<h2>2. 内核架构：红黑树与就绪链表</h2>
<p><code>epoll</code> 的核心改进在于它在内核中维护了一个<strong>持久化的上下文（Context）</strong>。当你调用 <code>epoll_create</code> 时，内核会在内存中分配一个 <code>eventpoll</code> 结构体，它包含两个核心数据结构：</p>
<h3>2.1 红黑树 (Red-Black Tree) —— 监控集合的静态存储</h3>
<ul>
<li><strong>作用</strong>：存储所有通过 <code>epoll_ctl</code> 注册的 FD 及其对应的 <code>epitem</code>（封装了事件类型等信息）。</li>
<li><strong>设计理由</strong>：<ul>
<li>红黑树提供了稳定的查找、插入和删除性能，时间复杂度为 $O(\log N)$。</li>
<li>它实现了 <strong>IO 多路复用的状态保持</strong>。用户态不需要每次都重新传递 FD 列表，内核直接在树中维护。</li>
</ul>
</li>
</ul>
<h3>2.2 双向链表 (Double Linked List) —— 活跃集合的动态缓冲</h3>
<ul>
<li><strong>作用</strong>：仅存储<strong>当前处于就绪状态</strong>的 <code>epitem</code> 引用。这是一个“活跃事件队列”。</li>
<li><strong>设计理由</strong>：<ul>
<li><code>epoll_wait</code> 的核心逻辑简化为：检查该链表是否为空。</li>
<li>如果不为空，将链表节点弹出并复制到用户态。</li>
<li><strong>复杂度质变</strong>：获取就绪事件的时间复杂度从 $O(N)$ 降低为 $O(K)$，其中 $K$ 为当前活跃连接数。在海量并发空闲连接的场景下，效率与总连接数无关。</li>
</ul>
</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/178bad747306420493f9c4271df7be7c.webp" alt=""></p>
<h2>3. 工作流程：中断驱动与回调机制</h2>
<p>红黑树中的静态节点如何流转到就绪链表中？这依赖于底层的<strong>硬件中断</strong>与<strong>等待队列回调</strong>机制。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240429121302958.png" alt=""></p>
<ol>
<li><strong>实例初始化 (epoll_create):</strong><ul>
<li>内核分配 eventpoll 结构，初始化红黑树根节点和就绪链表头指针。</li>
</ul>
</li>
<li><strong>事件注册 (<code>epoll_ctl</code> + <code>EPOLL_CTL_ADD</code>)</strong>:<ul>
<li>内核在红黑树中插入新的节点。</li>
<li><strong>关键操作</strong>：内核查找到该 FD 对应的底层文件对象（Socket），并在该对象的<strong>等待队列（Wait Queue）中注册一个特定的回调函数：<code>ep_poll_callback</code></strong>。这一步建立了硬件事件与 <code>epoll</code> 实例的联系。</li>
</ul>
</li>
<li><strong>阻塞等待 (<code>epoll_wait</code>)</strong>:<ul>
<li>检查 <code>eventpoll</code> 的就绪链表是否为空。</li>
<li>若为空，将当前进程（或线程）挂起，进入睡眠状态（调度出 CPU），直到超时或被唤醒。</li>
</ul>
</li>
<li><strong>中断触发 (数据到达)</strong>:<ul>
<li>网卡接收数据 -&gt; CPU 响应硬件中断 -&gt; DMA 拷贝数据到内核缓冲区 -&gt; TCP 协议栈处理。</li>
<li>当数据写入 Socket 接收缓冲区后，协议栈检测到该 Socket 的等待队列非空，随即调用注册的 <strong><code>ep_poll_callback</code></strong>。</li>
</ul>
</li>
<li><strong>回调执行</strong>:<ul>
<li><code>ep_poll_callback</code> 将该 FD 对应的 <code>epitem</code> 引用添加到 <code>eventpoll</code> 的 <strong>就绪链表</strong> 尾部。</li>
<li>同时，唤醒正在 <code>epoll_wait</code> 中阻塞的进程。</li>
</ul>
</li>
<li><strong>返回用户态</strong>:<ul>
<li>进程被唤醒，<code>epoll_wait</code> 将就绪链表中的事件复制到用户态内存，函数返回。</li>
</ul>
</li>
</ol>
<h2>4. LT vs ET</h2>
<p>理解了回调机制后，LT 和 ET 的区别就在于<strong>就绪链表的维护策略</strong>不同。</p>
<h3>4.1 LT 水平触发 (Level Triggered) - 默认模式</h3>
<ul>
<li><strong>机制</strong>：当 <code>epoll_wait</code> 检测到就绪链表中有节点时，会将其报告给用户。如果用户没有读完缓冲区的所有数据，内核在下一次检查时，<strong>会重新将该节点加入就绪链表</strong>（或者不将其从链表中移除）。</li>
<li><strong>特征</strong>：状态驱动。只要缓冲区不为空，事件就一直存在。</li>
</ul>
<h3>4.2 ET 边缘触发 (Edge Triggered) - 高性能模式</h3>
<ul>
<li><strong>机制</strong>：<code>ep_poll_callback</code> 仅在 Socket 状态发生变化（如从&quot;不可读&quot;变为&quot;可读&quot;）时触发一次，将节点加入就绪链表。一旦用户通过 <code>epoll_wait</code> 取走了该事件，除非有新的硬件中断（新数据到达），否则该节点不会再次进入就绪链表。</li>
<li><strong>特征</strong>：事件驱动。</li>
</ul>
<h2>5. 中断</h2>
<p>中断机制是计算机硬件和操作系统核心功能之一，它允许外设或硬件异步地通知 CPU 需要处理某些事件。中断机制的实现并不依赖于类似于 <code>for</code> 循环的轮询检查，而是建立在更为直接和高效的硬件和处理器架构支持之上。</p>
<p>当 CPU 接收到中断信号时，它是通过一套内建于硬件的协调机制来识别和响应中断的。这个过程涉及硬件电路设计、处理器架构和操作系统的中断管理功能。</p>
<h3>5.1 中断信号的检测和响应</h3>
<ol>
<li><strong>中断请求线（IRQ）</strong>：外部设备通过连接到处理器的一个特定的硬件线路（IRQ）发送中断信号。这个线路直接与处理器内的中断控制单元（Interrupt Controller）相连。</li>
<li><strong>中断控制器</strong>：大多数现代计算机系统使用一个或多个中断控制器来管理中断信号。中断控制器的任务是接收来自各种外部设备的中断请求，并将这些请求优先级排序后发送给 CPU。</li>
<li><strong>中断向量</strong>：当中断控制器接收到一个中断信号后，它会根据中断源确定一个中断向量。这个向量是一个数字，指向中断向量表中对应的入口，该入口包含了处理该中断的中断服务例程（ISR）的地址。</li>
</ol>
<h3>5.2 CPU 如何处理中断</h3>
<ol>
<li><strong>当前指令的完成</strong>：当 CPU 接收到中断控制器发出的中断信号时，它首先会完成当前执行的指令。这是为了保证程序的状态能够正确保存，从而在中断处理完毕后可以无缝地恢复执行。</li>
<li><strong>保存上下文</strong>：一旦当前指令执行完毕，CPU 会自动保存当前的程序状态，包括程序计数器（PC）、寄存器和其他必要的状态信息。这些信息通常被推送到当前的栈上。</li>
<li><strong>跳转到 ISR</strong>：CPU 使用中断向量来访问中断向量表，找到与中断号对应的中断服务例程（ISR）的地址，并跳转到该地址开始执行 ISR。这个过程是自动的，由处理器的内部机制控制。</li>
<li><strong>执行 ISR</strong>：中断服务例程会执行必要的操作来处理中断，比如读取数据缓冲区、清除设备状态或发送信号等。</li>
<li><strong>恢复上下文并返回</strong>：一旦 ISR 执行完成，处理器会从栈上恢复之前保存的程序状态，并将控制权返回到被中断的程序，继续执行。</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>Go 底层原理丨网络编程</title>
      <link>https://hedon.top/blog/go-net/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-net/</guid>
      <pubDate>Sun, 23 Nov 2025 12:00:00 GMT</pubDate>
      <description>本文立足于 Go 1.25 版本源码，系统拆解 Go 网络编程模型的底层机制，解析 Goroutine 如何实现同步代码，异步执行、net 包的核心实现，以及 I/O 多路复用背后的第一性原理，带你从源码视角理解 Go 网络高并发的秘密。</description>
      <category>Go</category><category>网络编程</category><category>epoll</category>
      <content:encoded><![CDATA[<p>本篇我们将讨论 Go 语言底层的网络编程原理，本篇将揭示 Go 语言是如何做到<strong>同步的代码，异步的执行</strong>。</p>
<p>特此声明，本篇是笔者基于 Go 1.25.3 版本源码、并与 Google Gemini 3Pro 共创所作，非常庆幸在当今 AI 时代下获取知识已是如此便利，且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑，均由笔者把控和审阅，欢迎放心阅读。</p>
<p>在开启本章之前，你最好对下列知识有一点的了解：</p>
<ul>
<li><a href="https://hedon954.github.io/noteSite/cs/cn/cn-transfer-layer.html">计算机网络 - 传输层</a></li>
<li><a href="https://hedon954.github.io/noteSite/cs/cn/cn-apply-layer.html#_5-socket">计算机网络 - 应用层 - Socket</a></li>
<li><a href="https://hedon954.github.io/noteSite/linux/linux-io/0-concept.html">Linux IO 模型</a></li>
<li><a href="https://hedon954.github.io/noteSite/backend/golang/high/net.html">Go 网络编程</a></li>
<li><a href="/blog/linux-io-epoll/">从第一性原理理解 epoll</a></li>
</ul>
<h2>1. 宏观概述</h2>
<p>要从根本上理解 Go 的网络编程模型，我们需要剥离掉语法糖，回到计算机体系结构和操作系统原理的 <strong>第一性原理</strong>：<strong>如何高效地处理 CPU 计算与 I/O 等待之间的速度差异？</strong></p>
<p>Go 的网络模型之所以强大，是因为它在一个极其优雅的抽象层（Goroutine）下，完美隐藏了复杂的异步 I/O 细节。</p>
<p>接下来让我们尝试由表及里，从编程模型到内核实现，分三个层级来剖析。</p>
<h3>2.1 第一层：编程模型 —— 同步的代码，异步的执行</h3>
<p>在 Go 1.25 中，网络编程的依然遵循着 Go 诞生之初的哲学：<strong>Goroutine-per-connection</strong>。开发者编写的是标准的 <strong>同步阻塞式（Synchronous Blocking）</strong> 代码。</p>
<pre><code class="language-Go">// 开发者视角：逻辑是线性的
listener, _ := net.Listen(&quot;tcp&quot;, &quot;:8080&quot;)
for {
    conn, _ := listener.Accept() // 看起来这里阻塞了，直到有新连接
    go func(c net.Conn) {
        buf := make([]byte, 1024)
        n, _ := c.Read(buf) // 看起来这里阻塞了，直到有数据
        // 处理数据...
    }(conn)
}
</code></pre>
<p>如果按照 C 语言或早期 Java 的传统线程模型，上述代码意味着每个连接需要一个 OS 线程。但线程太重了（栈内存约 1MB - 8MB，上下文切换成本高）。但是在 Go 语言中，当你调用 <code>c.Read</code> 时，当前的 Goroutine 确实&quot;暂停&quot;了，但底层的操作系统线程（M）并没有阻塞，而是去干别的活了。这样开发者拥有了编写简单线性逻辑的权利，同时享受了非阻塞 I/O 的高性能。</p>
<h3>2.2 第二层：系统调用层 —— 非阻塞 I/O 的伪装</h3>
<p>为了实现上述同步阻塞的假象，Go 在底层实际上使用的是 <strong>非阻塞 I/O（Non-blocking I/O）</strong>。</p>
<p>在 Go 1.25 的 <code>net</code> 包内部，当你创建一个 socket 时，Go Runtime 会通过系统调用（如 Linux 下的 <code>socket</code> + <code>fcntl</code>）显式地将该文件描述符（File Descriptor, FD）设置为 <strong>Non-blocking</strong> 模式。</p>
<p>当你调用 <code>conn.Read()</code> 时，Go 底层实际执行了以下逻辑：</p>
<ol>
<li><strong>直接尝试读取：</strong> 直接对 FD 发起 <code>read</code> 系统调用。</li>
<li><strong>EAGAIN 错误：</strong> 绝大多数时候，内核缓冲区是空的。因为 FD 是非阻塞的，操作系统不会让线程睡眠，而是立刻返回一个 <code>EAGAIN</code>（或 <code>EWOULDBLOCK</code>）错误，表示现在没数据，别堵在这。</li>
<li><strong>捕获错误并挂起：</strong> Go 的网络库捕获到这个错误，意识到&quot;现在读不到数据&quot;。于是，它不会让代码报错，而是通过 Runtime 调度器将当前的 <strong>Goroutine</strong> 状态置为 <code>Gwaiting</code>（等待中），并将该 Goroutine 移出 CPU 执行队列。</li>
</ol>
<h3>2.3 第三层：Runtime 核心 —— Netpoller 与 GMP 的联动</h3>
<p>这是 Go 网络模型的心脏。Go 引入了一个名为 <strong>Netpoller</strong> 的组件，它是 Go Runtime 与操作系统 I/O 多路复用机制（I/O Multiplexing）之间的桥梁。</p>
<p>Netpoller 并不是一个一直运行的独立线程，而是 Runtime 中的一组函数逻辑。它封装了不同操作系统的多路复用技术：</p>
<ul>
<li><strong>Linux:</strong> <code>epoll</code></li>
<li><strong>macOS/FreeBSD:</strong> <code>kqueue</code></li>
<li><strong>Windows:</strong> <code>IOCP</code></li>
</ul>
<p>在 Linux 的 <code>epoll</code> 中，包含 3 个核心函数：</p>
<ul>
<li>新建多路复用器：<code>epoll_create()</code></li>
<li>插入监听事件：<code>epoll_ctl()</code></li>
<li>查询发生了什么事件：<code>epoll_wait()</code></li>
</ul>
<p>Go 的 Netpoller 提供了对各个平台多路复用器的抽象和适配：</p>
<ul>
<li><code>netpollinit</code> -&gt; <code>epoll_create</code></li>
<li><code>netpollopen</code> -&gt; <code>epoll_ctl</code></li>
<li><code>netpoll</code> -&gt; <code>epoll_wait</code></li>
</ul>
<p>让我们回到刚才 <code>conn.Read()</code> 返回 <code>EAGAIN</code> 的时刻：</p>
<ol>
<li><strong>注册（Register）：</strong> 当前运行的 Goroutine (G) 在被挂起前，会将自己的 FD 和期望的事件（如可读）注册到 Netpoller 中。本质上是调用了 <code>epoll_ctl</code> 将 FD 加入监听列表。</li>
<li><strong>让出（Park）：</strong> G 停止运行，M（系统线程）现在空闲了。M 会根据 GMP 调度模型，从 P（处理器）的本地队列中抓取下一个可运行的 G 去执行。</li>
<li><strong>监控（Poll）：</strong> 什么时候唤醒原来的 G？<ul>
<li><strong>被动触发：</strong> 当系统监控线程 <code>sysmon</code> 运行，或者调度器发现没有 G 可运行时，会调用 <code>runtime.netpoll</code>。</li>
<li><strong>底层机制：</strong> <code>runtime.netpoll</code> 内部调用 <code>epoll_wait</code>，询问操作系统我关注的那些 FD 有哪些数据到了。</li>
</ul>
</li>
<li><strong>唤醒（Ready）：</strong> 操作系统返回就绪的 FD 列表。Netpoller 根据 FD 找到当初阻塞在上面的 Goroutine，将其状态改为 <code>Grunnable</code>（可运行），并将其注入到当前 P 的本地队列或全局队列中。</li>
<li><strong>执行：</strong> 在下一轮调度中，原来的 G 被 M 拿到，继续执行 <code>conn.Read()</code> 后面的代码。</li>
</ol>
<h3>2.4 小节</h3>
<p>如果用文字总结这套机制的精髓，可以概括为：<strong>用户态的阻塞，内核态的非阻塞；线性的逻辑，事件驱动的内核。</strong></p>
<p><strong>完整的数据流向图解：</strong></p>
<ol>
<li><strong>User:</strong> <code>conn.Read(buf)</code></li>
<li><strong>Go Runtime (Poll):</strong> <code>syscall.Read(fd)</code> -&gt; 返回 <code>EAGAIN</code></li>
<li><strong>Go Scheduler:</strong><ul>
<li>调用 <code>netpollOpen</code> (注册 epoll)</li>
<li>调用 <code>gopark</code> (挂起当前 G，状态 -&gt; Gwaiting)</li>
<li>线程 M 切换去执行其他 G</li>
</ul>
</li>
<li><strong>--- 时间流逝，网络包到达网卡 ---</strong></li>
<li><strong>OS Kernel:</strong> 中断处理，数据拷贝到内核缓冲区，FD 变为 Readable。</li>
<li><strong>Go Runtime (Monitor/Schedule):</strong><ul>
<li><code>sysmon</code> 或 调度器执行 <code>netpoll</code> (<code>epoll_wait</code>)</li>
<li>发现 FD 就绪</li>
<li>调用 <code>goready</code> (找到对应的 G，状态 -&gt; Grunnable)</li>
</ul>
</li>
<li><strong>Go Scheduler:</strong> G 被放入队列，最终被 M 执行。</li>
<li><strong>User:</strong> <code>conn.Read</code> 从挂起处恢复，再次执行 <code>syscall.Read</code>，成功读取数据。</li>
</ol>
<pre><code class="language-mermaid">sequenceDiagram
    autonumber
    participant G as User Goroutine (G)
    participant NP as Netpoller (Internal)
    participant Sched as Go Scheduler (M/P)
    participant OS as OS Kernel (epoll/IO)

    Note over G, Sched: 阶段一：发起读请求 (User Space)

    G-&gt;&gt;NP: 1. conn.Read(buf)
    activate G
    activate NP

    NP-&gt;&gt;OS: 2. syscall.Read(fd) (非阻塞)
    OS--&gt;&gt;NP: 3. 返回 EAGAIN (无数据)

    Note right of NP: 判定需要挂起

    NP-&gt;&gt;OS: 4. netpollOpen / epoll_ctl&lt;br/&gt;(注册 FD 到 epoll 实例)
    NP-&gt;&gt;Sched: 5. gopark (请求挂起 G)
    deactivate NP
    deactivate G

    activate Sched
    Note over G: 状态: Grunning -&gt; Gwaiting
    Note over Sched: 6. 线程 M 解绑当前 G&lt;br/&gt;M 切换去执行其他 G
    deactivate Sched

    Note over G, OS: 阶段二：异步等待 (Kernel Space)

    G-x G: (Goroutine 暂停，不消耗 CPU)
    Note over OS: ... 时间流逝 ...
    Note over OS: 7. 网络包到达 -&gt; 中断处理&lt;br/&gt;数据拷入内核缓冲区 -&gt; FD Readable

    Note over G, OS: 阶段三：唤醒与执行 (Runtime Monitor)

    loop Sysmon 或 调度器检查
        Sched-&gt;&gt;OS: 8. netpoll (epoll_wait)
        OS--&gt;&gt;Sched: 9. 返回就绪 FD 列表
    end

    activate Sched
    Sched-&gt;&gt;Sched: 根据 FD 找到对应的 G
    Sched-&gt;&gt;Sched: 10. goready(G)
    Note over G: 状态: Gwaiting -&gt; Grunnable

    Sched--&gt;&gt;G: 11. G 被放入本地/全局队列&lt;br/&gt;最终被 M 捕获并执行
    deactivate Sched

    activate G
    Note over G: 从 gopark 处恢复代码执行

    G-&gt;&gt;NP: 12. 再次调用 internal read
    activate NP
    NP-&gt;&gt;OS: 13. syscall.Read(fd)
    OS--&gt;&gt;NP: 14. 返回实际数据 (Data)
    NP--&gt;&gt;G: 15. 返回 n, err
    deactivate NP
    deactivate G
</code></pre>
<h2>2. 源码剖析</h2>
<p>在对 Go 的网络编程模型有了一定的宏观了解后，本篇我们将深入底层源码来剖析 Go Runtime 是如何实现上面这些能力的。</p>
<h3>2.1 Go 的系统调用的封装</h3>
<p>在 Go1.16 左右的版本（笔者之前研究的是 Go.16 版本，对其他版本可能不太熟悉），Go 对 <code>epoll_create</code>、<code>epoll_ctl</code> 等系统调用，每个都有单独的汇编实现，如下：</p>
<pre><code class="language-go">// int32 runtime·epollcreate(int32 size);
TEXT runtime·epollcreate(SB),NOSPLIT,$0
	MOVL    size+0(FP), DI
	MOVL    $SYS_epoll_create, AX
	SYSCALL
	MOVL	AX, ret+8(FP)
	RET

// func epollctl(epfd, op, fd int32, ev *epollEvent) int
TEXT runtime·epollctl(SB),NOSPLIT,$0
	MOVL	epfd+0(FP), DI
	MOVL	op+4(FP), SI
	MOVL	fd+8(FP), DX
	MOVQ	ev+16(FP), R10
	MOVL	$SYS_epoll_ctl, AX
	SYSCALL
	MOVL	AX, ret+24(FP)
	RET
</code></pre>
<p>但是当最近笔者在阅读 Go 1.25 版本的源码时，发现 Go 已经统一了系统调用的入口了，如 linux amd64 平台上，Go 将系统调用统一封装在 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/syscall/asm_linux_amd64.s">internal/runtime/syscall/asm_linux_amd64.s</a>，代码如下：</p>
<pre><code class="language-assembly">// func Syscall6(num, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, errno uintptr)
TEXT ·Syscall6&lt;ABIInternal&gt;(SB),NOSPLIT,$0
	// a6 already in R9.
	// a5 already in R8.
	MOVQ	SI, R10 // a4
	MOVQ	DI, DX  // a3
	MOVQ	CX, SI  // a2
	MOVQ	BX, DI  // a1
	// num already in AX.
	SYSCALL
	CMPQ	AX, $0xfffffffffffff001
	JLS	ok
	NEGQ	AX
	MOVQ	AX, CX  // errno
	MOVQ	$-1, AX // r1
	MOVQ	$0, BX  // r2
	RET
ok:
	// r1 already in AX.
	MOVQ	DX, BX // r2
	MOVQ	$0, CX // errno
	RET
</code></pre>
<p>我们不用太纠结它的具体实现，通过注释，我们可以知道这段汇编对应的就是 Go 里面的 <code>Syscall6</code>，具体位于 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/syscall/syscall_linux.go#L17">runtime/syscall/syscall_linux.go#L17</a>:</p>
<pre><code class="language-go">// Syscall6 calls system call number &#39;num&#39; with arguments a1-6.
func Syscall6(num, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, errno uintptr)
</code></pre>
<p>它的具体运用在 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/syscall/syscall_linux.go#L95">syscall/syscall_linux.go#L95</a>：</p>
<pre><code class="language-go">//go:uintptrkeepalive
//go:nosplit
//go:linkname Syscall6
func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno) {
	runtime_entersyscall()
	r1, r2, err = RawSyscall6(trap, a1, a2, a3, a4, a5, a6)
	runtime_exitsyscall()
	return
}

//go:uintptrkeepalive
//go:nosplit
//go:norace
//go:linkname RawSyscall6
func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno) {
	var errno uintptr
	r1, r2, errno = runtimesyscall.Syscall6(trap, a1, a2, a3, a4, a5, a6)
	err = Errno(errno)
	return
}
</code></pre>
<p>这个时候 <code>epollo_create</code>、<code>epollo_wait</code> 和 <code>epollo_ctl</code> 就很好实现了：</p>
<pre><code class="language-go">func EpollCreate1(flags int32) (fd int32, errno uintptr) {
	r1, _, e := Syscall6(SYS_EPOLL_CREATE1, uintptr(flags), 0, 0, 0, 0, 0)
	return int32(r1), e
}

var _zero uintptr

func EpollWait(epfd int32, events []EpollEvent, maxev, waitms int32) (n int32, errno uintptr) {
	var ev unsafe.Pointer
	if len(events) &gt; 0 {
		ev = unsafe.Pointer(&amp;events[0])
	} else {
		ev = unsafe.Pointer(&amp;_zero)
	}
	r1, _, e := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(ev), uintptr(maxev), uintptr(waitms), 0, 0)
	return int32(r1), e
}

func EpollCtl(epfd, op, fd int32, event *EpollEvent) (errno uintptr) {
	_, _, e := Syscall6(SYS_EPOLL_CTL, uintptr(epfd), uintptr(op), uintptr(fd), uintptr(unsafe.Pointer(event)), 0, 0)
	return e
}
</code></pre>
<h3>2.2 Go 对 Epoll 的抽象 - network poller</h3>
<blockquote>
<p>本文仅介绍针对 Linux AMD64 的实现。</p>
</blockquote>
<p>Go NetWork Poll 是对各个平台多路复用器的抽象和适配：</p>
<ul>
<li><code>netpollinit</code> -&gt; <code>epoll_create</code></li>
<li><code>netpollopen</code> -&gt; <code>epoll_ctl</code></li>
<li><code>netpoll</code> -&gt; <code>epoll_wait</code></li>
</ul>
<h4>2.1.1 netpollinit -&gt; epoll_create</h4>
<p>系统指令：<strong><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/syscall/defs_linux_amd64.go#L14">internal/runtime/syscall/defs_linux_amd64.go</a></strong></p>
<pre><code class="language-go">SYS_EPOLL_CREATE1 = 291
</code></pre>
<p>Go 中的声明：<strong><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/syscall/syscall_linux.go#L19">EpolloCreate1</a></strong></p>
<pre><code class="language-go">func EpollCreate1(flags int32) (fd int32, errno uintptr) {
	r1, _, e := Syscall6(SYS_EPOLL_CREATE1, uintptr(flags), 0, 0, 0, 0, 0)
	return int32(r1), e
}

func netpollinit() {
	var errno uintptr
	epfd, errno = syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
  // ...
}
</code></pre>
<ul>
<li><code>_EPOLL_CLOEXEC</code>：创建的 epfd 会设置 <code>FD_CLOEXEC</code>，它是一个 fd 的标识说明，用来设置文件的 close-on-exec 状态的。当 close-on-exec 状态为 0 时，调用 exec 时，fd 不会被关闭；非零状态时则会被关闭，这样做可以防止 fd 泄露给执行 exec 后的进程。</li>
</ul>
<p>针对 Linux 的实现：<strong><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/netpoll_epoll.go#L21">runtime/netpoll_epoll.go</a></strong></p>
<ol>
<li><strong>创建 epoll 实例</strong>：创建 Linux 的 I/O 多路复用器，用于同时监控成千上万个网络连接。</li>
<li><strong>创建 eventfd</strong>：创建一个特殊的文件描述符，用于唤醒阻塞线程。</li>
<li><strong>将 eventfd 注册到 epoll</strong>：这样既能等网络事件，也能被主动唤醒。</li>
</ol>
<pre><code class="language-go">// 新建多路复用器，这个函数在 Go 程序启动时被调用一次，用于初始化 Linux 平台的网络轮询器（netpoller）
func netpollinit() {
	var errno uintptr
  // 1. 创建一个 epoll 实例，返回的文件描述符存储在全局变量 `epfd` 中
  //		`EPOLL_CLOEXEC` 标志确保在 `exec` 时自动关闭这个 fd
	epfd, errno = syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
	if errno != 0 {
		println(&quot;runtime: epollcreate failed with&quot;, errno)
		throw(&quot;runtime: netpollinit failed&quot;)
	}
  // 2. 创建一个 eventfd，这是 Linux 的一种特殊文件描述符
  // 		设置为非阻塞模式（EFD_NONBLOCK）和 exec 时关闭（EFD_CLOEXEC）
  // 		eventfd 用于唤醒阻塞在 `epoll_wait` 上的线程。这是 Go netpoller 的关键机制！
	efd, errno := syscall.Eventfd(0, syscall.EFD_CLOEXEC|syscall.EFD_NONBLOCK)
	if errno != 0 {
		println(&quot;runtime: eventfd failed with&quot;, -errno)
		throw(&quot;runtime: eventfd failed&quot;)
	}
  // 3. 构造 epollo 事件结构，syscall.EPOLLIN 表示监听可读事件
	ev := syscall.EpollEvent{
		Events: syscall.EPOLLIN,
	}
  // 4. 将 netpollEventFd 的地址存储到 ev.Data 中
  //		当 epoll 返回事件时，我们需要知道是哪个 fd 触发的事件。
  //		通过 Data 字段，我们可以区分：
  //			- 是 eventfd 触发的（唤醒信号）
  //			- 还是某个网络连接 fd 触发的（真正的网络 I/O）
	*(**uintptr)(unsafe.Pointer(&amp;ev.Data)) = &amp;netpollEventFd
  // 5. 使用 `EPOLL_CTL_ADD` 操作将 eventfd 添加到 epoll 实例中
  //		当 eventfd 变为可读时，epoll_wait 会返回
	errno = syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, efd, &amp;ev)
	if errno != 0 {
		println(&quot;runtime: epollctl failed with&quot;, errno)
		throw(&quot;runtime: epollctl failed&quot;)
	}
  // 6. 将 eventfd 保存到全局变量中
  //		后续 `netpollBreak()` 函数会使用这个 fd 来唤醒阻塞的 epoll_wait
	netpollEventFd = uintptr(efd)
}
</code></pre>
<h4>2.1.2 netpollopen -&gt; epoll_ctl</h4>
<p>系统指令：<strong><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/syscall/defs_linux_amd64.go#L12">internal/runtime/syscall/defs_linux_amd64.go</a></strong></p>
<pre><code class="language-go">SYS_EPOLL_CTL     = 233
</code></pre>
<p>Go 中的声明：<strong><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/syscall/syscall_linux.go#L37">EpolloCtl</a></strong></p>
<pre><code class="language-go">func EpollCtl(epfd, op, fd int32, event *EpollEvent) (errno uintptr) {
	_, _, e := Syscall6(SYS_EPOLL_CTL, uintptr(epfd), uintptr(op), uintptr(fd), uintptr(unsafe.Pointer(event)), 0, 0)
	return e
}
</code></pre>
<ul>
<li><code>epfd</code>：epoll_create 函数返回的文件描述符，用于标识内核中的 epoll 实例</li>
<li><code>op</code>：对 fd 文件描述符的操作类型：<ul>
<li><code>EPOLL_CTL_ADD</code>：向 interest list 添加一个需要监视的描述符</li>
<li><code>EPOLL_CTL_DEL</code>：向 interest list 删除一个描述符</li>
<li><code>EPOLL_CTL_MOD</code>：修改 interst list 中的一个描述符</li>
</ul>
</li>
<li><code>fd</code>：需要被操作的文件描述符</li>
<li><code>event</code>：一个指向名为 epoll_event 的结构的指针，它存储了我们实际要监视的 fd 的事件<ul>
<li><code>EPOLLIN</code>：表示对应的文件描述符可以读。</li>
<li><code>EPOLLOUT</code>：表示对应的文件描述符可以写。</li>
<li><code>EPOLLERR</code>：表示对应的文件描述符发生错误。</li>
<li><code>EPOLLHUP</code>：表示对应的文件描述符被挂断。</li>
<li><code>EPOLLRDHUP</code>：表示对端关闭连接或半关闭写端。</li>
<li><code>EPOLLET</code>： 将 epoll 设为边缘触发（Edge Triggered）模式，相对于水平触发（Level Triggered）来说的。</li>
</ul>
</li>
</ul>
<p>针对 Linux 的实现：<strong><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/netpoll_epoll.go#L49">runtime/netpoll_epoll.go</a></strong></p>
<ol>
<li>传入一个 socket 的 fd，和 pollDesc 指针，pollDesc 是 Go 中对 socket 的抽象。pollDesc 中记录了 socket 的详细信息，以及哪个协程休眠在等待此 socket；</li>
<li>将 socket 的可读、可写、断开事件注册到 epoll 中；</li>
<li>将 epoll 设置为 ET 模式。</li>
</ol>
<pre><code class="language-go">// 将 fd 的四个事件 syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLRDHUP | syscall.EPOLLET 注册到 epfd 上
// 开始监控其 I/O 事件
func netpollopen(fd uintptr, pd *pollDesc) uintptr {
	var ev syscall.EpollEvent
	ev.Events = syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLRDHUP | syscall.EPOLLET
	tp := taggedPointerPack(unsafe.Pointer(pd), pd.fdseq.Load())
	*(*taggedPointer)(unsafe.Pointer(&amp;ev.Data)) = tp
	return syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, int32(fd), &amp;ev)
}
</code></pre>
<h4>2.1.3 netpoll -&gt; epoll_wait</h4>
<p>系统指令：<strong><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/syscall/defs_linux_amd64.go#L13">internal/runtime/syscall/defs_linux_amd64.go</a></strong></p>
<pre><code class="language-go">SYS_EPOLL_PWAIT   = 281
</code></pre>
<p>Go 中的声明：<strong><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/syscall/syscall_linux.go#L26">EpolloWait</a></strong></p>
<pre><code class="language-go">func EpollWait(epfd int32, events []EpollEvent, maxev, waitms int32) (n int32, errno uintptr) {
	var ev unsafe.Pointer
	if len(events) &gt; 0 {
		ev = unsafe.Pointer(&amp;events[0])
	} else {
		ev = unsafe.Pointer(&amp;_zero)
	}
	r1, _, e := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(ev), uintptr(maxev), uintptr(waitms), 0, 0)
	return int32(r1), e
}
</code></pre>
<ul>
<li><code>epfd</code>：epoll_create 函数返回的文件描述符，用于标识内核中的 epoll 实例。</li>
<li><code>ev</code> 已经分配好的 epoll_event 结构体数组，epoll 会把发生的事件存入 events 中。</li>
<li><code>maxev</code>：告诉内核最多返回的事件数量有多大，必须大于 0。</li>
<li><code>waitms</code>：超时时间，<strong>-1</strong> 表示 epoll 将无限制等待下去。</li>
</ul>
<p>针对 Linux 的实现：<strong><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/netpoll_epoll.go#L99">runtime/netpoll_epoll.go</a></strong></p>
<ol>
<li>根据 delay 确定要轮询多久；</li>
<li>创建一个长度为 128 的事件列表；</li>
<li>调用系统底层的 epollwait，查询有多少事件发生了；</li>
<li>新建一个协程列表；</li>
<li>遍历事件列表；</li>
<li>获取 go 中对 fd 的抽象结构体的值 pd；</li>
<li>将 pd 中的 g 取出来加入到 toRun 列表中；</li>
<li>返回可执行的 <font color="red">goroutine</font> 列表。</li>
</ol>
<pre><code class="language-go">// 注意返回的是一个可执行的 Goroutine 列表
func netpoll(delay int64) (gList, int32) {
	// 1. 计算超时时间
	var waitms int32
	if delay &lt; 0 {  // 无限等待，阻塞直到有事件
		waitms = -1
	} else if delay == 0 {  // 非阻塞，立即返回
		waitms = 0
	} else if delay &lt; 1e6 {
		waitms = 1 // 小于 1 微秒的延迟，至少等待 1 毫秒，毫秒是最小粒度，0 会变成非阻塞
	} else if delay &lt; 1e15 {
		waitms = int32(delay / 1e6)  // 正常范围：转换纳秒到毫秒 (1ms = 1e6 ns)
	} else {
		waitms = 1e9 // 超大延迟，限制为约 11.5 天
	}

	// 2. 准备接收最多 128 个就绪事件
	var events [128]syscall.EpollEvent

retry:
	// 3. 调用 epoll_wait 等待事件
	n, errno := syscall.EpollWait(epfd, events[:], int32(len(events)), waitms)
	if errno != 0 {
		if errno != _EINTR {
			println(&quot;runtime: epollwait on fd&quot;, epfd, &quot;failed with&quot;, errno)
			throw(&quot;runtime: netpoll failed&quot;)
		}
		if waitms &gt; 0 {
			return gList{}, 0
		}
		goto retry
	}

	// 4. 初始化返回值
	var toRun gList      // 就绪的 goroutine 列表
	delta := int32(0)    // netpollWaiters 的调整值

	// 5.处理所有返回的事件
	for i := int32(0); i &lt; n; i++ {
		ev := events[i]
		if ev.Events == 0 {
			continue
		}

		// 6. 判断是否是 eventfd 的唤醒信号，eventfd 用于从外部唤醒 epoll_wait
		if *(**uintptr)(unsafe.Pointer(&amp;ev.Data)) == &amp;netpollEventFd {
			// eventfd 应该只产生 EPOLLIN 事件
			if ev.Events != syscall.EPOLLIN {
				println(&quot;runtime: netpoll: eventfd ready for&quot;, ev.Events)
				throw(&quot;runtime: netpoll: eventfd ready for something unexpected&quot;)
			}

			// 消费唤醒信号
			if delay != 0 {
				var one uint64
				read(int32(netpollEventFd), noescape(unsafe.Pointer(&amp;one)), int32(unsafe.Sizeof(one)))
				// 清除唤醒标志，允许下次 netpollBreak
				netpollWakeSig.Store(0)
			}
			// 跳过 eventfd，继续处理其他事件，eventfd 只是唤醒机制，不对应真实的网络 I/O
			continue
		}

		// 7.根据触发的事件设置读写模式
		var mode int32

		// [可读事件] 检查各种可读条件
		if ev.Events&amp;(syscall.EPOLLIN|syscall.EPOLLRDHUP|syscall.EPOLLHUP|syscall.EPOLLERR) != 0 {
			// EPOLLIN: 有数据可读
			// EPOLLRDHUP: 对端关闭写端（半关闭）
			// EPOLLHUP: 连接挂断
			// EPOLLERR: 发生错误
			mode += &#39;r&#39;
		}

		// [可写事件] 检查各种可写条件
		if ev.Events&amp;(syscall.EPOLLOUT|syscall.EPOLLHUP|syscall.EPOLLERR) != 0 {
			// EPOLLOUT: 可以写入数据
			// EPOLLHUP: 连接挂断
			// EPOLLERR: 发生错误
			mode += &#39;w&#39;
		}
		// 注意：mode 可能是 &#39;r&#39;(114), &#39;w&#39;(119), 或 &#39;r&#39;+&#39;w&#39;(233)

		if mode != 0 {
			// 8. 获取 netpoller 对 socket 的抽象实例 pollDesc
			tp := *(*taggedPointer)(unsafe.Pointer(&amp;ev.Data))
			pd := (*pollDesc)(tp.pointer())
			tag := tp.tag() // 提取序列号标签

			// 9. 检查是否是过期事件
			if pd.fdseq.Load() == tag {
				// 序列号匹配，这是有效的事件
				// 原因：防止 ABA 问题（fd 被关闭后重新打开复用）
				pd.setEventErr(ev.Events == syscall.EPOLLERR, tag)

				// 10. 将就绪的 goroutine 加入运行队列
				delta += netpollready(&amp;toRun, pd, mode)
			}
			// else: 序列号不匹配，忽略过期事件，说明这个 pollDesc 已经被新的连接复用了
		}
	}

	// 11. 返回就绪的 goroutine 列表和等待计数调整值
	return toRun, delta
}
</code></pre>
<p><code>netpollready()</code> 表示 pd 底层的 fd 已经可以进行 I/O 操作了：</p>
<pre><code class="language-go">func netpollready(toRun *gList, pd *pollDesc, mode int32) int32 {
	delta := int32(0)
	var rg, wg *g
	if mode == &#39;r&#39; || mode == &#39;r&#39;+&#39;w&#39; {
		rg = netpollunblock(pd, &#39;r&#39;, true, &amp;delta)
	}
	if mode == &#39;w&#39; || mode == &#39;r&#39;+&#39;w&#39; {
		wg = netpollunblock(pd, &#39;w&#39;, true, &amp;delta)
	}
	if rg != nil {
		toRun.push(rg)
	}
	if wg != nil {
		toRun.push(wg)
	}
	return delta
}
</code></pre>
<h4>2.1.4 谁在调用 netpoll()？</h4>
<h5>2.1.4.1 垃圾回收循环</h5>
<p><code>runtime/proc.go</code> 中的 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/proc.go#L1768">startTheWorldWithSema()</a> 会调用 <code>netpoll()</code></p>
<pre><code class="language-go">func startTheWorldWithSema(emitTraceEvent bool) int64 {
	...
	if netpollinited() {
		list := netpoll(0)   //调用 netpoll
		injectglist(&amp;list)
  }
  ...
}
</code></pre>
<p><code>runtime/mgc.go</code> 中的 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/mgc.go#L744">gcStart()</a> 会调用 <code>startTheWorldWithSema()</code>，而 <code>gcStart()</code> 又会被我们的 g0 协程一直循环执行。</p>
<pre><code class="language-go">// gcStart starts the GC.
func gcStart(trigger gcTrigger) {
  ...
  // Concurrent mark.
  systemstack(func() {
    now = startTheWorldWithSema(trace.enabled)	// 调用 startTheWorldWithSema
    ...
  })
  ...
}
</code></pre>
<p>而且 <code>g0</code> 协程在循环 gc 的时候，顺带执行了 <code>netpoll()</code> 来检查是否有事件发生。</p>
<h5>2.1.4.2 协程调度</h5>
<p>在 <a href="https://hedon.top/2024/01/20/go/go-gpm/">深入浅出 Go 语言的 GPM 模型（Go1.21）</a> 中，我们提到了 Go 协程调度最核心的函数 <code>schedule()</code>：</p>
<pre><code class="language-go">func schedule() {
	// ..
	gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
	// ...
}
</code></pre>
<p>协程调度的时候会去执行 <code>findRunnable()</code> 寻找可以运行的 Goroutine，这里面也会调用 <code>netpoll()</code> 检查是否有网络事件发生。</p>
<pre><code class="language-go">func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
	// ..
	// Poll network until next timer.
	if netpollinited() &amp;&amp; (netpollAnyWaiters() || pollUntil != 0) &amp;&amp; sched.lastpoll.Swap(0) != 0 {
		//..
		list, delta := netpoll(delay) // block until new work is available
    // ...
	}
	// ..
}
</code></pre>
<h3>2.2 network poll 对 socket 的抽象 —— pollDesc</h3>
<p>Go 的 netpoller 需要进一步对 socket 进行抽象，是为了解决 2 个核心问题：</p>
<ol>
<li><strong>状态同步问题</strong>：如何让 Go 调度器（用户态）和操作系统内核（内核态）共享同一个 socket 的状态（是读还是写？是谁在等？）。</li>
<li><strong>生命周期错位问题</strong>：操作系统内核的通知是异步的，可能在 Go 已经关闭或复用了文件描述符（FD）之后，内核才发来一个旧的就绪通知。这会导致严重的内存腐坏或逻辑错误。</li>
</ol>
<p>为此，Go 定义了两个数据结构：<code>pollDesc</code> 和 <code>pollCache</code>。我们将 <code>pollDesc</code> 看作**&quot;桥梁&quot;<strong>，将 <code>pollCache</code> 看作</strong>&quot;安全区&quot;**。</p>
<h4>2.2.1 pollDesc</h4>
<p><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/netpoll.go#L75">pollDesc</a> 是 Go 运行时为每个网络文件描述符（socket）创建的轮询描述符对象，用于管理该 fd 的异步 I/O 状态。</p>
<pre><code class="language-go">// Network poller descriptor.
//
// No heap pointers.
type pollDesc struct {
	_     sys.NotInHeap
	link  *pollDesc      // in pollcache, protected by pollcache.lock
	fd    uintptr        // constant for pollDesc usage lifetime
	fdseq atomic.Uintptr // protects against stale pollDesc

	// atomicInfo holds bits from closing, rd, and wd,
	// which are only ever written while holding the lock,
	// summarized for use by netpollcheckerr,
	// which cannot acquire the lock.
	// After writing these fields under lock in a way that
	// might change the summary, code must call publishInfo
	// before releasing the lock.
	// Code that changes fields and then calls netpollunblock
	// (while still holding the lock) must call publishInfo
	// before calling netpollunblock, because publishInfo is what
	// stops netpollblock from blocking anew
	// (by changing the result of netpollcheckerr).
	// atomicInfo also holds the eventErr bit,
	// recording whether a poll event on the fd got an error;
	// atomicInfo is the only source of truth for that bit.
	atomicInfo atomic.Uint32 // atomic pollInfo

	// rg, wg are accessed atomically and hold g pointers.
	// (Using atomic.Uintptr here is similar to using guintptr elsewhere.)
	rg atomic.Uintptr // pdReady, pdWait, G waiting for read or pdNil
	wg atomic.Uintptr // pdReady, pdWait, G waiting for write or pdNil

	lock    mutex // protects the following fields
	closing bool
	rrun    bool      // whether rt is running
	wrun    bool      // whether wt is running
	user    uint32    // user settable cookie
	rseq    uintptr   // protects from stale read timers
	rt      timer     // read deadline timer
	rd      int64     // read deadline (a nanotime in the future, -1 when expired)
	wseq    uintptr   // protects from stale write timers
	wt      timer     // write deadline timer
	wd      int64     // write deadline (a nanotime in the future, -1 when expired)
	self    *pollDesc // storage for indirect interface. See (*pollDesc).makeArg.
}
</code></pre>
<p>核心字段解析：</p>
<ol>
<li><p><strong><code>fd</code></strong>：这是最原始的操作系统文件描述符（例如 Linux 上的 <code>int</code> 类型的 5, 6 等）。它是连接到 <code>epoll</code> / <code>kqueue</code> 的物理句柄。</p>
</li>
<li><p><strong><code>rg</code> (Read Group) / <code>wg</code> (Write Group)</strong>：<strong>这是最重要的字段。</strong> 它们实现了无锁（Lock-free）的状态流转。它们不仅仅存储 Goroutine 的指针（<code>*g</code>），还是一个多状态的原子变量：</p>
<ul>
<li><code>0 (pdNil)</code>: 没有任何 Goroutine 在等待。</li>
<li><code>1 (pdReady)</code>: I/O 已经就绪（网卡有数据了），不需要等待，直接读。</li>
<li><code>2 (pdWait)</code>: 正在准备挂起，作为中间状态。</li>
<li><code>&gt; 2 (G Pointer)</code>: <strong>存储了正在阻塞等待的 Goroutine 的内存地址。</strong></li>
</ul>
<p>当 <code>epoll_wait</code> 返回就绪事件时，Netpoller 会通过 <code>rg</code> 或 <code>wg</code> 里的地址找到那个 G，然后调用 <code>goready(G)</code> 唤醒它。</p>
</li>
<li><p>超时管理（<strong><code>rt</code></strong>、<strong><code>wt</code></strong>、<strong><code>rd</code></strong>、<strong><code>wd</code></strong>）：管理读写操作的 deadline。Go 的 <code>SetReadDeadline</code> 和 <code>SetWriteDeadline</code> 就是在这里实现的。每个网络连接自带两个定时器。如果超时触发，定时器回调会强制将 <code>rg</code> 或 <code>wg</code> 状态置为错误，并唤醒 G。G 醒来后发现是超时导致的唤醒，于是返回 <code>timeout error</code>。</p>
</li>
<li><p>防止过时通知（<strong><code>fdseq</code></strong>、<strong><code>rseq</code></strong>、<strong><code>wseq</code></strong>）：通过序列号防止在 <code>fd</code> 复用后收到旧的就绪通知。</p>
</li>
<li><p><strong><code>link</code></strong>：指向下一个空闲的 <code>pollDesc</code>，后面会详细分析。</p>
</li>
</ol>
<h4>2.2.1 pollCache</h4>
<p>网络程序中会频繁地打开和关闭连接，每个连接都需要一个 <code>pollDesc</code>。如果每次都分配新对象并最终让 GC 回收，会带来巨大的性能开销。<a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/netpoll.go#L192">pollCache</a> 通过对象池模式复用 <code>pollDesc</code>，大幅提升性能。用一句话概述就是：<code>pollCache</code> 是一个专门用于分配 <code>pollDesc</code> 的链表式缓存池。</p>
<pre><code class="language-go">type pollCache struct {
	lock  mutex					// 锁
	first *pollDesc			// 指向 pollDesc 链表的第一个节点，即下一个可用的空闲节点（头插法）
	// PollDesc objects must be type-stable,
	// because we can get ready notification from epoll/kqueue
	// after the descriptor is closed/reused.
	// Stale notifications are detected using seq variable,
	// seq is incremented when deadlines are changed or descriptor is reused.
}
</code></pre>
<p>相信不少读者都会注意到注释中的这句话：</p>
<pre><code class="language-go">// PollDesc objects must be type-stable,
</code></pre>
<p>为什么呢？想象下面这样一个流程：</p>
<ol>
<li>你打开了一个 Socket，FD 为 10。</li>
<li>Go 将 FD 10 注册给 <code>epoll</code>，由于内核并没有给我们回调函数，<code>epoll</code> 内部通常存储的是 <code>pollDesc</code> 的<strong>内存地址</strong>作为 <code>user_data</code>。</li>
<li>你关闭了连接。Go 回收了 FD 10，也释放了 <code>pollDesc</code> 的内存。</li>
<li><strong>危险时刻</strong>：假设这块内存立刻被 Go 的 GC 分配给了一个 <code>string</code> 或者是其他对象。</li>
<li><strong>延迟通知</strong>：此时，内核里积压的一个关于 FD 10 的&quot;可读&quot;事件突然触发了（或者是一个极端的竞态条件）。<code>epoll</code> 返回了那个旧的 <code>pollDesc</code> 内存地址，告诉 Runtime 这里&quot;可读&quot;。</li>
<li><strong>崩溃</strong>：Runtime 以为这还是个 <code>pollDesc</code>，试图去修改它的 <code>rg</code> 字段。但这块内存现在存的是一个字符串！<strong>结果：内存腐坏（Memory Corruption），程序直接崩溃且极难调试。</strong></li>
</ol>
<p><strong>解决方案：Type-Stable Memory（类型稳定内存）</strong></p>
<p><code>pollCache</code> 保证了通过它分配出去的内存块，<strong>即使被释放回收了，也永远只能作为 <code>pollDesc</code> 存在，绝不会被 GC 挪作他用。</strong></p>
<ul>
<li><code>sys.NotInHeap</code>: 标记这个结构体不在普通的 GC 堆上管理，而是手动管理的（<code>pollDesc</code> 的第一个字段）。</li>
<li><strong>链表管理</strong>:<ul>
<li><code>lock</code>: 保护链表。</li>
<li><code>first</code>: 指向链表头部的空闲 <code>pollDesc</code>。</li>
<li><strong>分配</strong>: 从 <code>first</code> 取一个。如果链表空，向 OS 申请一大块内存（4KB），切分成多个 <code>pollDesc</code> 串到链表上。</li>
<li><strong>释放</strong>: 并不是真的 <code>free</code> 掉内存，而是把它放回 <code>first</code> 链表头，留给下一个连接复用。</li>
</ul>
</li>
</ul>
<p>这就保证了：即使内核发来一个过期的通知，Runtime 访问的那个内存地址依然是一个合法的 <code>pollDesc</code> 结构体（虽然它可能不再关联任何活跃连接），最多就是读到一个无效状态，而不会导致内存越界或类型错误。</p>
<h5>2.2.1.1 分配 alloc</h5>
<pre><code class="language-go">func (c *pollCache) alloc() *pollDesc {
	lock(&amp;c.lock)
  // 1. 未初始化，则先进行初始化，一次性分配 n 个 pollDesc
	if c.first == nil {
		type pollDescPadded struct {
			pollDesc
			pad [tagAlign - unsafe.Sizeof(pollDesc{})]byte
		}
		const pdSize = unsafe.Sizeof(pollDescPadded{})
		n := pollBlockSize / pdSize
		if n == 0 {
			n = 1
		}
		// Must be in non-GC memory because can be referenced
		// only from epoll/kqueue internals.
		mem := persistentalloc(n*pdSize, tagAlign, &amp;memstats.other_sys)
		for i := uintptr(0); i &lt; n; i++ {
			pd := (*pollDesc)(add(mem, i*pdSize))
			lockInit(&amp;pd.lock, lockRankPollDesc)
			pd.rt.init(nil, nil)
			pd.wt.init(nil, nil)
			pd.link = c.first
			c.first = pd
		}
	}
  // 2. 取出链表头部的空闲节点
	pd := c.first
  // 3. 移动到下一个空闲节点
	c.first = pd.link
	unlock(&amp;c.lock)
	return pd
}
</code></pre>
<h5>2.2.1.2 回收 free</h5>
<pre><code class="language-go">func (c *pollCache) free(pd *pollDesc) {
	// pd can&#39;t be shared here, but lock anyhow because
	// that&#39;s what publishInfo documents.
	lock(&amp;pd.lock)

  // 1. 自增 fdseq，避免处理过期事件造成生命周期错位问题
	fdseq := pd.fdseq.Load()
	fdseq = (fdseq + 1) &amp; (1&lt;&lt;tagBits - 1)
	pd.fdseq.Store(fdseq)

  // 2. 重置 pollDesc
	pd.publishInfo()

	unlock(&amp;pd.lock)

	lock(&amp;c.lock)

  // 3. 放回链表头部
	pd.link = c.first
	c.first = pd
	unlock(&amp;c.lock)
}
</code></pre>
<p>即便内存类型安全了，我们还面临逻辑上的 <strong>ABA 问题</strong>：</p>
<ol>
<li>Goroutine A 使用 FD 10 (<code>pollDesc</code> 地址 0x123)。</li>
<li>A 关闭连接，释放 FD 10，释放 <code>pollDesc</code> (0x123 返回缓存池)。</li>
<li>Goroutine B 建立新连接，刚好系统又分配了 FD 10，且 <code>pollCache</code> 又把 0x123 分配给了 B。</li>
<li><strong>此时，内核发来了 A 时代的 FD 10 的就绪事件。</strong></li>
<li>Runtime 拿着 0x123，以为是 B 的数据来了，错误地唤醒了 B（或者处理了错误的数据）。</li>
</ol>
<p><strong><code>fdseq</code> 的作用：</strong> 每次 <code>pollDesc</code> 被复用（从缓存池拿出来）时，<code>fdseq</code> 都会自增。</p>
<ul>
<li>当注册 <code>epoll</code> 时，Go 会把当前的 <code>fdseq</code> 记录在某个地方（或者在检查时比对）。</li>
<li>当事件回来时，Runtime 会检查：<code>Event.seq == pollDesc.seq?</code></li>
<li>如果不相等，说明是个过期事件，直接忽略，不进行唤醒操作。</li>
</ul>
<h4>2.2.3 总结</h4>
<ol>
<li><strong><code>pollDesc</code> (State)</strong>: 使用 <code>atomic.Uintptr</code> 存储 Goroutine 指针，实现了<strong>用户态 G 与内核态 I/O 事件的高效无锁传递</strong>。</li>
<li><strong><code>pollCache</code> (Memory)</strong>: 使用<strong>类型稳定内存（Type-Stable Memory）</strong>，从物理内存布局的层面消灭了异步 I/O 可能导致的内存腐坏风险。</li>
<li><strong><code>fdseq</code> (Logic)</strong>: 使用<strong>版本号机制</strong>，解决了资源复用带来的逻辑混淆（ABA 问题）。</li>
</ol>
<p>这就是为什么 Go 的网络库在高并发、高动态（大量连接建立和断开）场景下，依然稳如磐石的底层原因。</p>
<h3>2.3 network poller 工作细节</h3>
<h4>2.3.1 初始化 poll_runtime_pollServerInit</h4>
<p>通过原子操作 &amp; 双重检查来执行一次 <code>netpollinit()</code>，创建一个 epoll。</p>
<pre><code class="language-go">//go:linkname poll_runtime_pollServerInit internal/poll.runtime_pollServerInit
func poll_runtime_pollServerInit() {
	netpollGenericInit()
}

func netpollGenericInit() {
  // 类似于 双重检查 的单例模式
  // 保证只执行一次 netpollinit()
	if netpollInited.Load() == 0 {
		lockInit(&amp;netpollInitLock, lockRankNetpollInit)
		lockInit(&amp;pollcache.lock, lockRankPollCache)
		lock(&amp;netpollInitLock)
		if netpollInited.Load() == 0 {
			netpollinit() // epoll_create() 创建一个多路复用器
			netpollInited.Store(1)
		}
		unlock(&amp;netpollInitLock)
	}
}
</code></pre>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--success" aria-hidden="true"></span></p>
<p><strong>&quot;补充：go:linkname</strong></p>
<p>补充：go:linkname</p>
<blockquote>
<p>The //go:linkname directive instructs the compiler to use “importpath.name” as the object file symbol name for the variable or function declared as “localname” in the source code. Because this directive can subvert the type system and package modularity, it is only enabled in files that have imported “unsafe”.</p>
</blockquote>
<p><code>//go:linkname</code>的目的是告诉编译器使用<code>importpath.name</code>来对本来不可导出的（localname）函数或者变量实现导出功能。由于这种方法是破坏了 Go 语言的模块化规则的，所以必须在导入了<code>&quot;unsafe&quot;</code>包的情况下使用。</p>
<p>即：</p>
<blockquote>
<p>由于 Go 语法规则限制，小写字母开头的函数或者变量是本模块私有的，不可被包外的代码访问；但是如果必须要能被外部模块访问到，又要限制为私有方法呢？只能在编译器上做手脚，通过一个特殊的 <strong>标记</strong> 来实现这种功能。</p>
</blockquote>
<p>具体到上面的例子：</p>
<pre><code class="language-go">//go:linkname poll_runtime_pollServerInit internal/poll.runtime_pollServerInit
</code></pre>
<ul>
<li>表示调用 <code>internal/poll.runtime_pollServerInit</code> 相当于调用当前的 <code>poll_runtime_pollServerInit</code>。</li>
</ul>
</blockquote>
<h4>2.3.2 新增监听 poll_runtime_pollOpen</h4>
<ol>
<li>在 pollcache 链表中分配一个 pollDesc，用来描述要新增将它的 socket；</li>
<li>初始化 pollDesc，主要是将 rg、wg 置为 0；</li>
<li>调用 netpollopen，将底层 socket 及其读、写和断开事件注册到 epoll 上；</li>
</ol>
<pre><code class="language-go">//go:linkname poll_runtime_pollOpen internal/poll.runtime_pollOpen
func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) {
  // 1. 分配一个 pollDesc，用来描述要新增监听的 socket
	pd := pollcache.alloc()
  // 2. 上锁
	lock(&amp;pd.lock)
	wg := pd.wg.Load()
	if wg != pdNil &amp;&amp; wg != pdReady {
		throw(&quot;runtime: blocked write on free polldesc&quot;)
	}
	rg := pd.rg.Load()
	if rg != pdNil &amp;&amp; rg != pdReady {
		throw(&quot;runtime: blocked read on free polldesc&quot;)
	}
  // 3. 赋值
	pd.fd = fd
	if pd.fdseq.Load() == 0 {
		// The value 0 is special in setEventErr, so don&#39;t use it.
		pd.fdseq.Store(1)
	}
	pd.closing = false
	pd.setEventErr(false, 0)
	pd.rseq++
	pd.rg.Store(pdNil)	// 初始值，还没感兴趣的 Goroutine
	pd.rd = 0
	pd.wseq++
	pd.wg.Store(pdNil)	// 初始化，还没感兴趣的 Goroutine
	pd.wd = 0
	pd.self = pd
	pd.publishInfo()
  // 4. 解锁
	unlock(&amp;pd.lock)

  // 5. 调用 netpollopen  -&gt; epoll_ctl
  // 将 pd 关联的 fd 的相关事件注册到 epoll 上
	errno := netpollopen(fd, pd)
	if errno != 0 {
		pollcache.free(pd)
		return nil, int(errno)
	}
	return pd, 0
}
</code></pre>
<h4>2.3.3 判断是否就绪 poll_runtime_pollWait</h4>
<ol>
<li>协程要对 socket 进行 read 或者 write 的时候，底层就会调用 poll_runtime_pollWait；</li>
<li>该方法循环调用 netpollblock()，直到 netpollblock() 返回 true，表明 rg 或 wg 已经置为 pdReady 了，可以进行读或者写了。</li>
<li>netpollblock()：<ol>
<li>根据 mode，取出 rg 或者 wg，命名为 gpp；</li>
<li>如果 gpp 是 pdReady，直接返回 true，否则，置为 pdWait，返回 false。</li>
</ol>
</li>
</ol>
<pre><code class="language-go">func (pd *pollDesc) wait(mode int, isFile bool) error {
	if pd.runtimeCtx == 0 {
		return errors.New(&quot;waiting for unsupported file type&quot;)
	}
	res := runtime_pollWait(pd.runtimeCtx, mode)
	return convertErr(res, isFile)
}
</code></pre>
<pre><code class="language-go">func runtime_pollWait(ctx uintptr, mode int) int
</code></pre>
<pre><code class="language-go">//go:linkname poll_runtime_pollWait internal/poll.runtime_pollWait
func poll_runtime_pollWait(pd *pollDesc, mode int) int {
   ...
   // 循环调用 netpollblock，直到 netpollblock 返回 true
   // 也就是 rg 或 wg 已经置为 pdReady 了，可以读 / 写了
   for !netpollblock(pd, int32(mode), false) {
      ...
   }
   return pollNoError
}
</code></pre>
<pre><code class="language-go">// returns true if IO is ready, or false if timed out or closed
// waitio - wait only for completed IO, ignore errors
// Concurrent calls to netpollblock in the same mode are forbidden, as pollDesc
// can hold only a single waiting goroutine for each mode.
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
  // 1. 根据 mode，看看是要读还是要写
	gpp := &amp;pd.rg
	if mode == &#39;w&#39; {
		gpp = &amp;pd.wg
	}

	for {
    // 2. 已经 pdReady 了，返回 true，完成
		if gpp.CompareAndSwap(pdReady, pdNil) {
			return true
		}

    // 3. 没有 pdReady，则先置为 pdWait，再往下走
		if gpp.CompareAndSwap(pdNil, pdWait) {
			break
		}
	}

	// 4. 调用 gopark 阻塞当前 goroutine
	if waitio || netpollcheckerr(pd, mode) == pollNoError {
		gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceBlockNet, 5)
	}
	// 5. 当 gopark 返回时，表示被唤醒，重置为 pdNil
	old := gpp.Swap(pdNil)

  // 6. 如果是 pdReady，则返回 true，否则可能是超时等原因，返回 false
	return old == pdReady
}
</code></pre>
<pre><code class="language-go">func netpollblockcommit(gp *g, gpp unsafe.Pointer) bool {
	r := atomic.Casuintptr((*uintptr)(gpp), pdWait, uintptr(unsafe.Pointer(gp)))
	if r {
		// Bump the count of goroutines waiting for the poller.
		// The scheduler uses this to decide whether to block
		// waiting for the poller if there is nothing else to do.
		netpollAdjustWaiters(1)
	}
	return r
}
</code></pre>
<h4>2.3.4 调度协程去读写 socket</h4>
<ul>
<li><p>socket 已经可以读写：</p>
<ol>
<li><p><font color="red">runtime</font> 循环调用 netpoll() 方法；</p>
<blockquote>
<p>前面分析过了，是 g0 协程在 gc 的时候顺便调用了 netpoll。</p>
</blockquote>
</li>
<li><p>发现 socket 可读写时，给对应的 rg 或 wg 置为 pdReady(1)；</p>
</li>
<li><p><font color="red">协程</font>调用 poll_runtime_pollWait() 判断 socket 是否就绪；判断 rg 或者 wg 已经置为 <strong>pdReady(1)</strong>，那就返回 0；</p>
</li>
<li><p>runtime 就知道 socket 可以操作了。</p>
</li>
</ol>
</li>
<li><p>socket 暂时不可读写：</p>
<ol>
<li><font color="red">runtime</font> 循环调用 netpoll() 方法；</li>
<li>netpoll 中没有监听到任何事件，执行不到 netpollready，没有对 pd 做任何改变；</li>
<li><font color="red">协程</font>调用 poll_runtime_pollWait() 判断 socket 是否就绪：<ol>
<li>判断 rg 或 wg 还是 <strong>pdNil(0)</strong>，就将 rg 或者 wg 置为 <strong>pdWait(2)</strong>；</li>
<li>调用 gopark 将协程进行休眠等待；</li>
<li>然后再进入 netpollblockcommit 将 rg 或者 wg 置为 <strong>G pointer</strong>；</li>
</ol>
</li>
<li>假如 runtime 后面再循环调用 netpoll() 方法；</li>
<li>发现 socket 可读写时，进入 netpollready 再检查对应的 rg 或者 wg；</li>
<li>netpollready 再进入 netpollunblock，它会检查 rg 或者 wg；</li>
<li>若为 <strong>G pointer</strong>，那么就将 rg 或者 wg 置为 <strong>pdReady</strong>，然后返回协程地址给 runtime；</li>
<li>runtime 就会去调度对应协程进行 socket 的读写操作。</li>
</ol>
</li>
<li><p>读写后都会再将 rg 或者 wg 置为 <strong>nil</strong></p>
</li>
</ul>
<h4>2.3.5 总结</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5laualr25j21im0u079y.jpg" alt=""></p>
<p>Go 的网络操作底层为 <strong>阻塞模型（协程调度） + 多路复用（系统底层）</strong>，具体情况为：</p>
<ul>
<li>BIO：go 协程从网络读取数据，读取失败并且返回<code>syscall.EAGAIN</code> 时，依次调用 <code>waitRead-&gt;runtime_pollWait-&gt;poll_runtime_pollWait-&gt;netpollblock-&gt;gopark</code> 将当前协程挂起。</li>
<li>NIO：runtime 的 g0 协程在 gc 的时候会顺便调用 <code>netpoll()</code> 检查 socket 事件是否发生，当 socket 可操作的时候，重新唤醒对应协程，进行调度。</li>
</ul>
<p>具体细节为：</p>
<ul>
<li>runtime<ol>
<li>runtime 会一直循环去检查 socket 的可读写状态 —— <code>netpoll()</code></li>
<li>然后再看是否有协程在等待对应的 socket：—— <code>netpollready()</code><ol>
<li>没有，那就单纯记录 pollDesc；</li>
<li>有那就唤醒协程，将 g 加入 toRun 列表，进行调度 —— <code>netpollunblock()</code></li>
</ol>
</li>
</ol>
</li>
<li>goroutine<ol>
<li>表明想要操作 socket —— <code>poll_runtime_pollWait(pd,mode)</code></li>
<li>循环检查自己关心的 socket 是否可操作 —— <code>netpollblock()</code><ol>
<li>可以操作，goroutine 就会对 socket 进行读或写操作了；</li>
<li>不可操作：<ol>
<li>就将自己休眠 —— <code>gopark()</code>；</li>
<li>将 rg 或 wg 置为自己的地址 —— <code>netpollblockcommit()</code></li>
</ol>
</li>
</ol>
</li>
</ol>
</li>
</ul>
<h3>2.4 net 包</h3>
<ul>
<li><p>net 包是 go 原生的网络包；</p>
</li>
<li><p>net 包实现了 TCP、UDP、HTTP 等网络操作；</p>
</li>
<li><p>使用 <code>net.Listen()</code> 可以得到 <code>LISTEN</code> 状态的 socket —— listener；</p>
</li>
<li><p>使用 <code>listener.Accept()</code> 可以得到 <code>ESTABLISHED</code> 状态的 socket —— conn；</p>
</li>
<li><p><code>conn.Read() / Writer()</code> 可以进行读写 socket 的操作；</p>
</li>
<li><p>network poll 作为上述功能的底层支撑；</p>
<blockquote>
<p>本文仅介绍 TCP 相关的部分。</p>
</blockquote>
</li>
</ul>
<h4>2.4.1 net.netFD</h4>
<p><code>netFD</code> 是 Go 中 net 包对 socket 之类的网络文件描述符的抽象。</p>
<pre><code class="language-go">// Network file descriptor.
type netFD struct {
	pfd poll.FD

	// immutable until Close
	family      int
	sotype      int
	isConnected bool // handshake completed or use of association with peer
	net         string
	laddr       Addr
	raddr       Addr
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5ldvdxiy6j21h80i6759.jpg" alt=""></p>
<h4>2.4.2 net.Listen() Listenter</h4>
<pre><code class="language-go">func Listen(network, address string) (Listener, error) {
	var lc ListenConfig
	return lc.Listen(context.Background(), network, address)
}
</code></pre>
<ol>
<li>新建 socket，并执行 bind 操作；</li>
<li>新建一个 netFD，它是 net 包对 socket 的详情描述；</li>
<li>返回一个 TCPListener 对象，底层是调用了 runtime_pollOpen 方法，将 TCPListener 的 FD 信息加入监听。TCPListener 对象本质是一个 <strong>LISTEN</strong> 状态的 socket。</li>
</ol>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5leaayjlcj20u010zn0e.jpg" style="zoom:50%;" />

<h4>2.4.3 listener.Accept()</h4>
<pre><code class="language-go">// Accept implements the Accept method in the [Listener] interface; it
// waits for the next call and returns a generic [Conn].
func (l *TCPListener) Accept() (Conn, error) {
	if !l.ok() {
		return nil, syscall.EINVAL
	}
	c, err := l.accept()
	if err != nil {
		return nil, &amp;OpError{Op: &quot;accept&quot;, Net: l.fd.net, Source: nil, Addr: l.fd.laddr, Err: err}
	}
	return c, nil
}
</code></pre>
<ol>
<li>调用 tcpListener 的 accept，本质上就是调用处于 LISTEN 状态的 socket 的 accept 方法，看看有无新的连接；</li>
<li>如果失败，休眠等待新的连接，底层调用了 runtime_pollWait；</li>
<li>如果有新的连接，那就包装成一个新的 socket，最后返回为一个 TCPConn 变量，底层是调用了 runtime_pollOpen 方法，将 TCPConn 的 FD 信息加入监听。TCPConn 对象本质是一个 <strong>ESTABLISHED</strong> 状态的 socket。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5lf3th4jjj21cz0u0gpf.jpg" alt=""></p>
<h4>2.4.4 conn.Read() / conn.Write()</h4>
<p>这两个方法原理差不多，下面以 Read() 为例。</p>
<pre><code class="language-go">// Read implements the Conn Read method.
func (c *conn) Read(b []byte) (int, error) {
   ...
   n, err := c.fd.Read(b)
	 ...
}
</code></pre>
<pre><code class="language-go">// Read implements io.Reader.
func (fd *FD) Read(p []byte) (int, error) {
	// ...
  // 循环读数据
	for {
   	// 1. 调用系统命令 syscall.Read，读取 sysfd 上的数据，然后往 p 写数据
		n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
		if err != nil {
			n = 0
      // 2. syscall.EAGAIN 说明还没数据，得先等等
			if err == syscall.EAGAIN &amp;&amp; fd.pd.pollable() {
        // 3. 挂起，休眠等待
				if err = fd.pd.waitRead(fd.isFile); err == nil {
          // 4. 当有数据来的时候，会被唤醒走到这里，然后在回到 for 循环读取数据
					continue
				}
			}
		}
		err = fd.eofError(n, err)
		return n, err
	}
}
</code></pre>
<ol>
<li>底层直接调用 socket 原生读写方法（syscall.Read、syscall.Write）；</li>
<li>成功则直接返回；</li>
<li>如果失败，休眠等待可读 / 可写事件的发生；</li>
<li>被唤醒后重新调用系统 socket 进行读写；</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5lfqryruej21ir0u0n3b.jpg" alt=""></p>
<h4>2.4.5 net.DialTCP()</h4>
<p><code>Dial()</code> 方法支持 TCP、UDP、IP、unix、unixgram 和 unixpacket 网络通讯方式，它是一个统共的方法，通过传入 <code>network</code> 字段来区分不同的网络类型，所以它前面很多的操作，都是在判断当前是什么网络类型。本文主要讲 TCP 的实现底层，故直接进入 <code>DialTCP()</code> 即可，其他的网络类型，也是大同小异的。</p>
<pre><code class="language-go">func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error) {
  // 1. 看看具体是哪种 tcp 连接
	switch network {
	case &quot;tcp&quot;, &quot;tcp4&quot;, &quot;tcp6&quot;:
	default:
		return nil, &amp;OpError{Op: &quot;dial&quot;, Net: network, Source: laddr.opAddr(), Addr: raddr.opAddr(), Err: UnknownNetworkError(network)}
	}
	if raddr == nil {
		return nil, &amp;OpError{Op: &quot;dial&quot;, Net: network, Source: laddr.opAddr(), Addr: nil, Err: errMissingAddress}
	}

  // 2. 创建一个系统的网络连接工具
	sd := &amp;sysDialer{network: network, address: raddr.String()}
	var (
		c   *TCPConn
		err error
	)

  // 3. 进行 TCP 连接
	if sd.MultipathTCP() {
		c, err = sd.dialMPTCP(context.Background(), laddr, raddr)
	} else {
		c, err = sd.dialTCP(context.Background(), laddr, raddr)
	}
	if err != nil {
		return nil, &amp;OpError{Op: &quot;dial&quot;, Net: network, Source: laddr.opAddr(), Addr: raddr.opAddr(), Err: err}
	}
	return c, nil
}
</code></pre>
<pre><code class="language-go">func (sd *sysDialer) dialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) {
	if h := sd.testHookDialTCP; h != nil {
		return h(ctx, sd.network, laddr, raddr)
	}
	if h := testHookDialTCP; h != nil {
		return h(ctx, sd.network, laddr, raddr)
	}
  // 4. 进入 doDialTCP
	return sd.doDialTCP(ctx, laddr, raddr)
}
</code></pre>
<pre><code class="language-go">func (sd *sysDialer) doDialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) {
	return sd.doDialTCPProto(ctx, laddr, raddr, 0)
}

func (sd *sysDialer) doDialTCPProto(ctx context.Context, laddr, raddr *TCPAddr, proto int) (*TCPConn, error) {
	ctrlCtxFn := sd.Dialer.ControlContext
	if ctrlCtxFn == nil &amp;&amp; sd.Dialer.Control != nil {
		ctrlCtxFn = func(ctx context.Context, network, address string, c syscall.RawConn) error {
			return sd.Dialer.Control(network, address, c)
		}
	}
 	// 5. 有了前面的基础，到这就明白了
 	// internetSocket 创建一个 fd，生成一个新的 socket，并注册到 epoll 中监听
	fd, err := internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, proto, &quot;dial&quot;, ctrlCtxFn)
	for i := 0; i &lt; 2 &amp;&amp; (laddr == nil || laddr.Port == 0) &amp;&amp; (selfConnect(fd, err) || spuriousENOTAVAIL(err)); i++ {
		if err == nil {
			fd.Close()
		}
		fd, err = internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, proto, &quot;dial&quot;, ctrlCtxFn)
	}

	if err != nil {
		return nil, err
	}
  // 6. 返回一个 TCPConn
	return newTCPConn(fd, sd.Dialer.KeepAlive, sd.Dialer.KeepAliveConfig, testPreHookSetKeepAlive, testHookSetKeepAlive), nil
}
</code></pre>
<ol>
<li>创建一个系统的网络连接工具 sysDialer；</li>
<li>dial 进行 TCP 连接，连接不上那就是 connect refused；</li>
<li>连接上的话，创建一个新的 socket，并最后返回为一个 TCPConn 变量，底层是调用了 runtime_pollOpen 方法，将 TCPConn 的 FD 信息加入监听。TCPConn 对象本质是一个 <strong>ESTABLISHED</strong> 状态的 socket。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5lgqvxxivj21nd0u0q67.jpg" alt=""></p>
<h2>3. 总结</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5lh6uxgk3j21770u0773.jpg" alt=""></p>
]]></content:encoded>
    </item>
    <item>
      <title>Go 底层原理丨channel</title>
      <link>https://hedon.top/blog/go-channel/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-channel/</guid>
      <pubDate>Sat, 22 Nov 2025 10:30:00 GMT</pubDate>
      <description>本文将以 Go 1.25 版本源码为基础，系统剖析 Go channel 的底层实现，包括 hchan 数据结构、缓存队列、发送接收机制与阻塞唤醒原理，帮助读者以第一性原理深入理解 Go 并发通信的内核。</description>
      <category>Go</category><category>channel</category><category>并发编程</category>
      <content:encoded><![CDATA[<p>继上篇 <a href="https://hedon.top/2025/11/21/go/go-lock/">Go 底层原理丨锁</a>，本篇将进入 Go 语言中关于通道（channel）底层原理的探讨。在 Rust 中，笔者参考 Mara Bos 的 <a href="https://marabos.nl/atomics/">《Rust Atomics and Locks》</a> 实现了一个 oneshot channel，感兴趣的读者也可以参阅 <a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a>。</p>
<p>特此声明，本篇是笔者基于 Go 1.25.3 版本源码、并与 Google Gemini 3Pro 共创所作，非常庆幸在当今 AI 时代下获取知识已是如此便利，且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑，均由笔者把控和审阅，欢迎放心阅读。</p>
<h2>结论先行</h2>
<p>channel 底层是 <code>hchan</code> 结构体，包含了：</p>
<ul>
<li>一个环形缓存队列；</li>
<li>接受者队列、发送者队列；</li>
<li>锁；</li>
<li>关闭标志；</li>
</ul>
<p>发送：<code>chansend()</code></p>
<ul>
<li>直接发送给阻塞中的接受者</li>
<li>塞入缓存</li>
<li>休眠等待</li>
</ul>
<p>接收：<code>chanrecv()</code></p>
<ul>
<li>直接接收阻塞中的发送者的数据</li>
<li>从缓存拿</li>
<li>休眠等待</li>
</ul>
<h2>1. 数据结构 hchan</h2>
<p>channel 的底层数据结构 <code>hchan</code> 源码位于 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/chan.go#L34">runtime/chan.go#L34</a>，如下所示：</p>
<pre><code class="language-go">type hchan struct {
	qcount   uint           // total data in the queue
	dataqsiz uint           // size of the circular queue
	buf      unsafe.Pointer // points to an array of dataqsiz elements
	elemsize uint16
	closed   uint32
	timer    *timer // timer feeding this chan
	elemtype *_type // element type
	sendx    uint   // send index
	recvx    uint   // receive index
	recvq    waitq  // list of recv waiters
	sendq    waitq  // list of send waiters
	bubble   *synctestBubble

	// lock protects all fields in hchan, as well as several
	// fields in sudogs blocked on this channel.
	//
	// Do not change another G&#39;s status while holding this lock
	// (in particular, do not ready a G), as this can deadlock
	// with stack shrinking.
	lock mutex
}
</code></pre>
<p>其中以下五个字段组成了一个 <strong>环形缓冲队列</strong>：</p>
<pre><code class="language-go">qcount   uint           // 当前在队列中的数据个数
dataqsiz uint           // 环形队列大小
buf      unsafe.Pointer	// 指向环形队列的指针
elemsize uint16					// 每个数据大小
elemtype *_type 				// 数据类型
</code></pre>
<blockquote>
<p>环形缓存可以大幅降低 GC 的开销。</p>
</blockquote>
<p>其中还有四个字段组成了两个 <strong>链表</strong>：</p>
<pre><code class="language-go">sendx    uint   // 下次要发送的数据的 index
recvx    uint   // 下个要接收的数据的 index
recvq    waitq  // 接受者等待队列
sendq    waitq  // 发送者等待队列
</code></pre>
<p>还有一个 <strong>锁</strong>：</p>
<pre><code class="language-go">lock mutex		// 保存 hchan 中的所有字段
</code></pre>
<blockquote>
<p>互斥锁并不是排队发送 / 接收数据，它保护的是 hchan 结构体本身。</p>
</blockquote>
<p>还有一个标记：</p>
<pre><code class="language-go">closed   uint32   // 标记 channel 是否已经关闭
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5fztbxrzej21ka0u0q62.jpg" alt=""></p>
<h2>2. 创建 makechan</h2>
<p>创建 channel 的逻辑位于 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/chan.go#L75">runtime/chan.go#L75</a>：</p>
<pre><code class="language-go">// makechan 创建一个新的 channel
// t: channel 的类型信息（包含元素类型）
// size: channel 的缓冲区大小（0 表示无缓冲）
func makechan(t *chantype, size int) *hchan {
	elem := t.Elem

	// === GC 优化说明 ===
	// 当 buf 中的元素不包含指针时，hchan 对 GC 来说不包含需要追踪的指针
	// - buf 指针指向同一次分配的内存（或单独分配的不含指针的内存）
	// - elemtype 是持久化的类型元数据
	// - sudog 由其所属线程引用，不会被回收
	// TODO: 当 GC 支持移动对象时需要重新考虑这个设计

	// === 三种分配策略 ===
	var c *hchan
	switch {
	case mem == 0:
		// 策略1: 无缓冲 channel 或元素大小为 0
		// 只需分配 hchan 结构体本身，不需要缓冲区
		// 示例：make(chan int, 0) 或 make(chan struct{}, 10)
		c = (*hchan)(mallocgc(hchanSize, nil, true))
		// Race detector 使用这个地址作为同步点
		// 即使没有实际的 buf，也需要一个地址用于竞态检测
		c.buf = c.raceaddr()

	case !elem.Pointers():
		// 策略2: 元素不包含指针（如 int, float, struct{int,int} 等）
		// GC 优化：hchan 和 buf 在一次分配中完成，减少 GC 扫描开销
		// 内存布局：[hchan 结构][buf 数组]
		c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
		// buf 指向紧跟在 hchan 后面的内存
		c.buf = add(unsafe.Pointer(c), hchanSize)

	default:
		// 策略3: 元素包含指针（如 *int, string, slice, map 等）
		// 必须分开分配，让 GC 能够正确追踪 buf 中的指针
		// new(hchan) 会将 hchan 分配在 GC 扫描的内存区域
		c = new(hchan)
		// mallocgc 的第二个参数传入 elem 类型，让 GC 知道如何扫描这块内存
		c.buf = mallocgc(mem, elem, true)
	}

	// === 初始化 hchan 字段 ===

	c.elemsize = uint16(elem.Size_)    // 元素大小（已检查不超过 uint16）
	c.elemtype = elem                   // 元素类型信息（用于类型安全的内存操作）
	c.dataqsiz = uint(size)            // 循环队列容量

	// 如果当前 goroutine 在 synctest bubble 中，关联到 channel
	// synctest 是用于确定性测试的机制
	if b := getg().bubble; b != nil {
		c.bubble = b
	}

	// 初始化互斥锁，指定锁的等级（用于死锁检测）
	lockInit(&amp;c.lock, lockRankHchan)
	return c
}
</code></pre>
<p><strong>策略 1：无缓冲/零大小元素</strong></p>
<pre><code class="language-go">case mem == 0:
    c = (*hchan)(mallocgc(hchanSize, nil, true))
</code></pre>
<ul>
<li><p>无缓冲 channel：size = 0，数据直接在 goroutine 间传递</p>
</li>
<li><p>零大小元素：struct{}，不需要实际存储空间</p>
</li>
</ul>
<p><strong>策略 2：元素不含指针</strong></p>
<pre><code class="language-go">case !elem.Pointers():
    c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
    c.buf = add(unsafe.Pointer(c), hchanSize)
</code></pre>
<ul>
<li><p>一次分配：减少内存碎片，提高缓存局部性</p>
</li>
<li><p>GC 优化：整块内存标记为&quot;无指针&quot;，GC 扫描时可以跳过</p>
</li>
<li><p>内存布局：</p>
<pre><code>  低地址 → 高地址
  [hchan 结构体][buf[0]][buf[1]]...[buf[n-1]]
</code></pre>
</li>
</ul>
<p><strong>策略 3：元素含指针</strong></p>
<pre><code class="language-go">default:
    c = new(hchan)
    c.buf = mallocgc(mem, elem, true)
</code></pre>
<ul>
<li><p>mallocgc 的第二个参数 elem 告诉 GC 这块内存的类型</p>
</li>
<li><p>GC 需要递归扫描 buf 中的每个元素，查找其中的指针</p>
</li>
<li><p>如果用策略 2，GC 无法正确追踪 buf 中的指针，导致对象被错误回收</p>
</li>
</ul>
<p>示例：</p>
<pre><code class="language-go">type Node struct {
    Value int
    Next  *Node  // 指针！
}
ch := make(chan *Node, 10)  // 使用策略 3
</code></pre>
<h2>3. 发送 chansend</h2>
<ol>
<li>对整个 channel 上锁；</li>
<li>检查 channel 是否已经关闭，若关闭，这 panic；</li>
<li>检查是否有正在等待中的协程：<ol>
<li>有的话，直接将数据拷贝给它，然后唤醒它；</li>
<li>没有，则检查缓存队列是否已满：<ol>
<li>没有满，则将数据塞入缓存队列中；</li>
<li>已满，则把自己包装成 sudog 放入 sendq 队列，休眠并解锁，等待唤醒。被唤醒后数据已经被取走了，当下 sudog 负责维护其他的数据；</li>
</ol>
</li>
</ol>
</li>
<li>解锁。</li>
</ol>
<pre><code class="language-go">func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
  // 快速路径：在非阻塞情况（select）下，如果 channel 已经满了，快速返回
	if !block &amp;&amp; c.closed == 0 &amp;&amp; full(c) {
		return false
	}

	var t0 int64
	if blockprofilerate &gt; 0 {
		t0 = cputicks()
	}

  // 1. 上锁修改队列
	lock(&amp;c.lock)

  // 2. 不允许往已关闭的 channel 发送数据
	if c.closed != 0 {
		unlock(&amp;c.lock)
		panic(plainError(&quot;send on closed channel&quot;))
	}

  // 3. 检查是否有阻塞中的接收者，如果有，取出一个，直接将数据交付给它
	if sg := c.recvq.dequeue(); sg != nil {
		send(c, sg, ep, func() { unlock(&amp;c.lock) }, 3)
		return true
	}

  // 4. 没有接受者，且缓冲区还有位置，则数据进入缓冲区，直接返回
	if c.qcount &lt; c.dataqsiz {
		qp := chanbuf(c, c.sendx)
		typedmemmove(c.elemtype, qp, ep)
		c.sendx++
		if c.sendx == c.dataqsiz {
			c.sendx = 0
		}
		c.qcount++
		unlock(&amp;c.lock)
		return true
	}

  // 5. 非阻塞，返回 false
	if !block {
		unlock(&amp;c.lock)
		return false
	}

  // 6. 没有接受者，缓冲区也没有位置，且是阻塞队列，则当前协程入队休眠，等待接受者唤醒
	gp := getg()
	mysg := acquireSudog()
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}
	mysg.elem = ep
	mysg.waitlink = nil
	mysg.g = gp
	mysg.isSelect = false
	mysg.c = c
	gp.waiting = mysg
	gp.param = nil
	c.sendq.enqueue(mysg) // 入队
	gp.parkingOnChan.Store(true)
	reason := waitReasonChanSend
	if c.bubble != nil {
		reason = waitReasonSynctestChanSend
	}

  // 7. 陷入阻塞，等待唤醒
	gopark(chanparkcommit, unsafe.Pointer(&amp;c.lock), reason, traceBlockChanSend, 2)
	KeepAlive(ep)

	// 8. 被唤醒了
	if mysg != gp.waiting {
		throw(&quot;G waiting list is corrupted&quot;)
	}
	gp.waiting = nil
	gp.activeStackChans = false
	closed := !mysg.success
	gp.param = nil
	if mysg.releasetime &gt; 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	mysg.c = nil

  // 9. 被唤醒的时候，数据其实已经被取走了，mysg 负责维护其他数据
	releaseSudog(mysg)
	if closed {
		if c.closed == 0 {
			throw(&quot;chansend: spurious wakeup&quot;)
		}
		panic(plainError(&quot;send on closed channel&quot;))
	}
	return true
}
</code></pre>
<h2>4. 接收 chanrecv</h2>
<ol>
<li>对整个 channel 上锁；</li>
<li>如果 channel 已经关闭，且缓存中没有数据，如果这个时候 eq 指向的地址有数据，则清空数据；</li>
<li>检查是否有等待中的 sender：<ol>
<li>有，则看 channel 有无缓存：<ol>
<li>没有，则直接从 sender 中取走数据，唤醒 sender；</li>
<li>有，则说明缓存已满，从缓存队列队头取走数据，然后将 sender 数据塞到队尾，唤醒 sender；</li>
</ol>
</li>
<li>无，则看 channel 有无缓存：<ol>
<li>有，则直接从缓存中取走数据，维护队列索引，解锁返回；</li>
<li>无，则将自己包装成 sudog，放入 recvq 休眠等待唤醒，被唤醒的时候，sender 已经将数据拷贝到位了；</li>
</ol>
</li>
</ol>
</li>
<li>解锁。</li>
</ol>
<pre><code class="language-go">func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
	// 快速路径：非阻塞（select）且队列为空，则直接返回
	if !block &amp;&amp; empty(c) {
		if atomic.Load(&amp;c.closed) == 0 {
			return
		}
		if empty(c) {
			return true, false
		}
	}

	var t0 int64
	if blockprofilerate &gt; 0 {
		t0 = cputicks()
	}

  // 1. 对整个 channel 上锁
	lock(&amp;c.lock)

  // 2. 检查 channel 是否已经被关闭，且缓存中没有数据
	if c.closed != 0 {
    // 缓冲区没有数据，返回 false
		if c.qcount == 0 {
			unlock(&amp;c.lock)
			if ep != nil {
        // 清除 ep 指向的数据
				typedmemclr(c.elemtype, ep)
			}
			return true, false
		}
	} else {
    // 3. 如果没有关闭，且有阻塞中的发送者，则直接接收发送者的数据，然后唤醒它
		if sg := c.sendq.dequeue(); sg != nil {
			recv(c, sg, ep, func() { unlock(&amp;c.lock) }, 3)
			return true, true
		}
	}

  // 4. 没有等待中的 sender，且缓存中有数据，则时间从缓存队列中取出数据，并解锁返回
	if c.qcount &gt; 0 {
		qp := chanbuf(c, c.recvx)
		if raceenabled {
			racenotify(c, c.recvx, nil)
		}
		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)
		}
		typedmemclr(c.elemtype, qp)
		c.recvx++
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.qcount--
		unlock(&amp;c.lock)
		return true, true
	}

  // 5. 非阻塞情况下，没有获取到数据，则返回 false
	if !block {
		unlock(&amp;c.lock)
		return false, false
	}

	// 6. 缓存中没有数据，则将自己包装成 sudog，放入 recvq 队列中，休眠等待唤醒
	gp := getg()
	mysg := acquireSudog()
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}
	mysg.elem = ep
	mysg.waitlink = nil
	gp.waiting = mysg

	mysg.g = gp
	mysg.isSelect = false
	mysg.c = c
	gp.param = nil
	c.recvq.enqueue(mysg)
	if c.timer != nil {
		blockTimerChan(c)
	}

	gp.parkingOnChan.Store(true)
	reason := waitReasonChanReceive
	if c.bubble != nil {
		reason = waitReasonSynctestChanReceive
	}
  // 6. 被唤醒，唤醒的时候，sender 已经将数据拷贝到 receiver 的 ep 所指向的位置了（也就是 chansend 的第 3 步）
	gopark(chanparkcommit, unsafe.Pointer(&amp;c.lock), reason, traceBlockChanRecv, 2)

	// 被唤醒，这个时候，数据已经接收到了
	if mysg != gp.waiting {
		throw(&quot;G waiting list is corrupted&quot;)
	}
	if c.timer != nil {
		unblockTimerChan(c)
	}
	gp.waiting = nil
	gp.activeStackChans = false
	if mysg.releasetime &gt; 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	success := mysg.success
	gp.param = nil
	mysg.c = nil
	releaseSudog(mysg)
	return true, success
}
</code></pre>
<h2>5. 关闭 closechan</h2>
<ol>
<li>设置 c.closed = 1</li>
<li>唤醒所有接收者（返回零值，success = false）</li>
<li>唤醒所有发送者（会 panic）</li>
<li>释放锁后再调用 goready，避免持锁调度</li>
</ol>
<pre><code class="language-go">func closechan(c *hchan) {
  // 1. 不能关闭 nil channel
	if c == nil {
		panic(plainError(&quot;close of nil channel&quot;))
	}
	if c.bubble != nil &amp;&amp; getg().bubble != c.bubble {
		fatal(&quot;close of synctest channel from outside bubble&quot;)
	}

  // 2. 上锁
	lock(&amp;c.lock)

  // 3. 不能重复关闭 channel
	if c.closed != 0 {
		unlock(&amp;c.lock)
		panic(plainError(&quot;close of closed channel&quot;))
	}

	// 4. 设置关闭标识
	c.closed = 1

	var glist gList

	// 5. 唤醒所有接收者（返回零值，success = false）
	for {
		sg := c.recvq.dequeue()
		if sg == nil {
			break
		}
		if sg.elem != nil {
			typedmemclr(c.elemtype, sg.elem)
			sg.elem = nil
		}
		if sg.releasetime != 0 {
			sg.releasetime = cputicks()
		}
		gp := sg.g
		gp.param = unsafe.Pointer(sg)
		sg.success = false
		glist.push(gp)
	}

	// 6. 唤醒所有发送者（会 panic）
	for {
		sg := c.sendq.dequeue()
		if sg == nil {
			break
		}
		sg.elem = nil
		if sg.releasetime != 0 {
			sg.releasetime = cputicks()
		}
		gp := sg.g
		gp.param = unsafe.Pointer(sg)
		sg.success = false
		if raceenabled {
			raceacquireg(gp, c.raceaddr())
		}
		glist.push(gp)
	}
	unlock(&amp;c.lock)

	// 7. 释放锁后再调用 goready，避免持锁调度
	for !glist.empty() {
		gp := glist.pop()
		gp.schedlink = 0
		goready(gp, 3)
	}
}
</code></pre>
<h2>6. 实践建议</h2>
<p>通过上述对 channel 各种操作的源码分析，我们可以发现存在一些容易 panic 的点：</p>
<ul>
<li>往未初始化的 channel 发送数据，会 panic</li>
<li>重复关闭 channel，会 panic</li>
<li>往关闭的 channel 发送数据，会 panic</li>
</ul>
<p>这里笔者总结了一些实践建议，供参考。</p>
<p>基于 <code>go-channel.md</code> 中解析的底层原理，Go channel 的 panic 风险主要源于对 <code>hchan</code> 状态的错误操作（特别是 <code>closed</code> 状态）。</p>
<p>以下是基于第一性原理（源码逻辑）总结的 channel 使用最佳实践：</p>
<h3>6.1 核心原则：谁发送，谁关闭</h3>
<p>这是避免 panic 的第一铁律。</p>
<ul>
<li><strong>原理</strong>：源码中 <code>chansend</code> 会在检测到 <code>c.closed != 0</code> 时直接 panic。同时，<code>closechan</code> 唤醒被阻塞的发送者时，发送者被唤醒后检测到 channel 已关闭也会 panic。</li>
<li><strong>建议</strong>：只有<strong>发送端</strong>（Sender）才有资格关闭 channel。<ul>
<li>如果 channel 是由接收端（Receiver）关闭的，发送端无法感知，一旦再次发送就会 panic。</li>
<li><strong>如果有多个发送端</strong>：不要在发送端关闭 channel。应该使用一个额外的信号 channel（stop channel）或者是 <code>sync.WaitGroup</code> 来协调，或者让 channel 由 GC 自动回收（如果没有 goroutine 引用它）。</li>
</ul>
</li>
</ul>
<h3>6.2 严禁重复关闭</h3>
<ul>
<li><strong>原理</strong>：<code>closechan</code> 函数开头就会检查 <code>c.closed</code>，如果不为 0，会直接 panic &quot;close of closed channel&quot;。</li>
<li><strong>建议</strong>：<ul>
<li>确保代码逻辑中 <code>close()</code> 只被执行一次。</li>
<li>在复杂的多并发场景下，如果无法确定谁是最后一个关闭者，可以使用 <code>sync.Once</code> 来封装关闭操作，确保幂等性。</li>
</ul>
</li>
</ul>
<h3>6.3 接收端使用 &quot;comma, ok&quot; 句式</h3>
<ul>
<li><strong>原理</strong>：<code>chanrecv</code> 在 channel 已关闭且缓存无数据时，会返回对应类型的零值，并且返回的 <code>success</code> (即 ok) 为 <code>false</code>。</li>
<li><strong>建议</strong>：<ul>
<li>总是检查接收操作的第二个返回值：<code>val, ok := &lt;-ch</code>。</li>
<li>如果 <code>!ok</code>，说明 channel 已关闭且已读完，应当退出接收循环，而不是继续处理零值。</li>
</ul>
</li>
</ul>
<h3>6.4 避免关闭 nil channel</h3>
<ul>
<li><strong>原理</strong>：<code>closechan</code> 第一步检查 <code>if c == nil</code>，如果是则 panic &quot;close of nil channel&quot;。</li>
<li><strong>建议</strong>：<ul>
<li>在使用 channel 前确保它已被 <code>make</code> 初始化。</li>
<li>小心处理结构体中的 channel 字段，确保它们不是默认的 nil 值。</li>
</ul>
</li>
</ul>
<h3>6.5 优雅退出模式（Signal Channel）</h3>
<p>当有多个发送者（N Senders）或 1 个接收者想停止多个发送者时，不要直接关闭数据 channel。</p>
<ul>
<li><strong>原理</strong>：直接关闭会导致正在运行的发送者 panic。</li>
<li><strong>建议</strong>：<ul>
<li>创建一个专门的 <code>done</code> 或 <code>stop</code> channel（通常是 <code>chan struct{}</code>）。</li>
<li>接收者通过 <code>close(done)</code> 进行广播（利用了“从已关闭 channel 接收会立即返回零值”的特性）。</li>
<li>发送者在 <code>select</code> 中同时监听 <code>dataCh</code> 和 <code>done</code>，一旦 <code>done</code> 关闭，立即停止发送。</li>
</ul>
</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Go 底层原理丨锁</title>
      <link>https://hedon.top/blog/go-lock/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-lock/</guid>
      <pubDate>Fri, 21 Nov 2025 15:30:00 GMT</pubDate>
      <description>本文深入剖析 Go 语言中多种锁（Mutex、RWMutex、WaitGroup、Once、Cond）的底层实现原理，结合 atomic 原子操作与 sema 信号量机制，揭示锁的本质和并发安全保障机制，帮助读者以第一性原理理解 Go 并发锁的内部运作。</description>
      <category>Go</category><category>锁</category>
      <content:encoded><![CDATA[<p>本篇将进入 Go 语言中关于锁的底层原理的探讨，笔者有幸阅读过 Mara Bos 的 <a href="https://marabos.nl/atomics/">《Rust Atomics and Locks》</a>，该书对锁这一概念和底层原理进行了非常详尽的探讨，并且给出了 Rust 中 SpinLock、Mutex、RWMutex、Channel 和 Arc 等基础并发工具的手写实战案例，对于想更加深入理解并发编程尤其那些想手写并发工具的读者，非常推荐阅读该书。</p>
<p>特此声明，本篇是笔者基于 Go 1.25.3 版本源码、并与 Google Gemini 3Pro 共创所作，非常庆幸在当今 AI 时代下获取知识已是如此便利，且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑，均由笔者把控和审阅，欢迎放心阅读。</p>
<h2>结论先行</h2>
<p>本篇我们将探讨 Go 语言中的各种&quot;锁&quot;的底层实现原理，包括 <code>Mutex</code>、<code>RWMutex</code>、<code>WaitGroup</code> 和 <code>Once</code> 。它们都离不开两个核心基础：<code>atomic</code> 和 <code>sema</code>：</p>
<ul>
<li><code>atomic</code> 即原子变量，是一种硬件层面加锁的机制，可以保证基本类型在高并发下的并发安全性，实现原子操作。</li>
<li><code>sema</code> 全称 semaphore，也叫信号锁 / 信号量锁，它的核心是一个 <code>uint32</code> 类型的值，含义是同时可并发的协程数量。在 Go 语言里面，每个 <code>seam</code> 背后都对应一个 <code>semaRoot </code>结构体。</li>
</ul>
<p>我们先给出上述几种并发工具的简要概述，后文再进行详细阐述：</p>
<ul>
<li><code>Mutex</code>：互斥锁，只能有一个持有者。<ul>
<li>正常模式：得到锁返回，得不到锁自旋，自旋多了就饥饿。</li>
<li>饥饿模式：不自选，直接入队等待。依次从队里唤醒协程并授予锁。</li>
</ul>
</li>
<li><code>RWMutex</code>：读写锁，只能一个写，可以同时多个读。</li>
<li><code>WaitGroup</code>：一组协程等待另外一组协程全部执行完毕再执行。</li>
<li><code>Once</code>：控制一段代码在并发中只执行一次。</li>
<li><code>Cond</code>：条件变量，允许多个 Goroutine 等待某个条件成立后被唤醒，常用于处理生产者-消费者模型。</li>
</ul>
<h2>1. Go 锁的两大基础</h2>
<h3>1.1 原子操作</h3>
<p>Go 在 <code>sync/atomic</code> 包提供了一系列基本类型的原子操作，使用这些操作，可以保证基本类型在高并发下的并发安全性，实现原子操作。</p>
<ul>
<li>SwapInt32</li>
<li>CompareAndSwapInt32</li>
<li>AddInt32</li>
<li>LoadInt32</li>
<li>StoreInt32</li>
</ul>
<pre><code class="language-go">// AddInt32 atomically adds delta to *addr and returns the new value.
func AddInt32(addr *int32, delta int32) (new int32)
</code></pre>
<p>查看 AMD64 的汇编时，我们会发现其中有一个 <code>LOCK</code> 指令：</p>
<pre><code class="language-assembly">// uint32 Xadd(uint32 volatile *val, int32 delta)
// Atomically:
//	*val += delta;
//	return *val;
TEXT ·Xadd(SB), NOSPLIT, $0-20
	MOVQ	ptr+0(FP), BX
	MOVL	delta+8(FP), AX
	MOVL	AX, CX
	LOCK
	XADDL	AX, 0(BX)
	ADDL	CX, AX
	MOVL	AX, ret+16(FP)
	RET
</code></pre>
<p>可以再看一下 ARM64 的汇编代码，我们会发现其中有：<code>LDADDALW</code>、<code>LDAXRW</code> 和 <code>STLXRW</code> 指令：</p>
<pre><code class="language-assembly">TEXT ·Xadd(SB), NOSPLIT, $0-20
	MOVD	ptr+0(FP), R0
	MOVW	delta+8(FP), R1
#ifndef GOARM64_LSE
	MOVBU	internal∕cpu·ARM64+const_offsetARM64HasATOMICS(SB), R4
	CBZ 	R4, load_store_loop
#endif
	LDADDALW	R1, (R0), R2
	ADD 	R1, R2
	MOVW	R2, ret+16(FP)
	RET
#ifndef GOARM64_LSE
load_store_loop:
	LDAXRW	(R0), R2
	ADDW	R2, R1, R2
	STLXRW	R2, (R0), R3
	CBNZ	R3, load_store_loop
	MOVW	R2, ret+16(FP)
	RET
#endif
</code></pre>
<p>概括来说：</p>
<blockquote>
<p>[!IMPORTANT]</p>
<p>原子操作的底层实现依赖于 86 的 <code>lock</code> 前缀或 ARM 的 <code>LL/SC</code>，而这二者又依赖于硬件级别的协同机制，其核心是通过 <strong>缓存一致性协议</strong>、<strong>总线仲裁</strong> 和 <strong>指令集层面的特殊支持</strong> 来保证多核环境下的原子性和内存顺序。</p>
</blockquote>
<p>对于原子操作的底层原理和硬件层面的细节，感兴趣的读者可以阅读我这两篇笔记：</p>
<ul>
<li><a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a></li>
<li><a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a></li>
</ul>
<h3>1.2 sema 锁</h3>
<h4>1.2.1 概述</h4>
<ul>
<li>sema 锁全称 semaphore，也叫信号锁 / 信号量锁。</li>
<li>sema 的核心是一个 <code>uint32</code> 类型的值，含义是同时可并发的协程数量。</li>
<li>每一个 sema 锁都对应一个 <code>semaRoot</code>结构体。</li>
<li><code>semaRoot</code> 中有一个平衡二叉树用于协程排队。</li>
</ul>
<h4>1.2.2 数据结构</h4>
<p>在 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/sync/mutex.go#L20">internal/sync/mutex.go#L20</a> 定义了 <code>Mutex</code> 的数据结构，如下：</p>
<pre><code class="language-go">// A Mutex is a mutual exclusion lock.
//
// See package [sync.Mutex] documentation.
type Mutex struct {
	state int32
	sema  uint32
}
</code></pre>
<p>其中第二个元素 <code>sema</code>，便是一个 sema 锁，它本质上是一个 <code>semaRoot</code> 结构体的值。</p>
<p><code>semaRoot</code> 定义在 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/sema.go#L40">runtime/sema.go#L40</a>：</p>
<pre><code class="language-go">// Asynchronous semaphore for sync.Mutex.

// A semaRoot holds a balanced tree of sudog with distinct addresses (s.elem).
// Each of those sudog may in turn point (through s.waitlink) to a list
// of other sudogs waiting on the same address.
// The operations on the inner lists of sudogs with the same address
// are all O(1). The scanning of the top-level semaRoot list is O(log n),
// where n is the number of distinct addresses with goroutines blocked
// on them that hash to the given semaRoot.
// See golang.org/issue/17953 for a program that worked badly
// before we introduced the second level of list, and
// BenchmarkSemTable/OneAddrCollision/* for a benchmark that exercises this.
type semaRoot struct {
    lock  mutex          // 保护整个数据结构的锁
    treap *sudog         // Treap 的根节点
    nwait atomic.Uint32  // 等待者数量（可无锁读取，用于快速判断）
}

// 简单理解：Runtime 内部专用锁
// 通过一个 uintptr 字段同时存储状态标志位和等待 M 线程的指针栈（低 10 位是状态，高位是 M 链表头），
// 直接用 OS 信号量阻塞 M 线程，禁用抢占，零分配，不触发 GC。
type mutex struct {
	lockRankStruct
	key uintptr
}

// sudog (pseudo-g) 代表的是一个在等待队列中的 goroutine
type sudog struct {
    g *g                // 指向等待的 goroutine

    // === Treap 二叉树指针 ===
    parent *sudog       // 父节点
    prev   *sudog       // 左子节点
    next   *sudog       // 右子节点

    // === 同地址等待链表 ===
    waitlink *sudog     // 链表中的下一个等待者
    waittail *sudog     // 链表尾部（只在头节点有效）

    // === 地址和优先级 ===
    elem   unsafe.Pointer  // 等待的地址（如 &amp;mutex.sema）
    ticket uint32          // Treap 的堆优先级（随机数）

    // === 统计和性能分析 ===
    waiters     uint16  // 链表中其他等待者的数量（头节点）
    acquiretime int64   // 开始等待的时间
    releasetime int64   // 被唤醒的时间
}
</code></pre>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5d2yqk87zj21gc0mq3zt.jpg" style="zoom: 33%;" />

<h4>1.2.3 操作</h4>
<p><strong>当 unit32 &gt; 0 时，表示可以并发的协程个数</strong></p>
<ul>
<li>获取锁：sema - 1， 获得锁成功</li>
<li>释放锁：sema + 1，释放锁成功</li>
</ul>
<p><strong>当 unit32 = 0 时，表示没锁了，sema 锁退化成一个专用的休眠队列</strong></p>
<ul>
<li>获取锁：进入堆树等待，协程休眠；</li>
<li>释放锁：从堆树中取出一个协程并唤醒</li>
</ul>
<h4>1.2.4 semeacquire()</h4>
<p><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/sema.go#L146">semaacuqire()</a> 尝试递减计数器，失败则创建 <code>sudog</code> 加入等待队列并休眠，等待被唤醒。</p>
<ul>
<li>sema &gt; 0：sema --</li>
<li>sema = 0：将协程放入堆树中等待，并休眠</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5dalgurqyj223d0u0whl.jpg" alt=""></p>
<pre><code class="language-go">func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags, skipframes int, reason waitReason) {
	gp := getg()
	if gp != gp.m.curg {
		throw(&quot;semacquire not on the G stack&quot;)
	}

	// 快捷路径，先进行一次简单的原子操作尝试获取锁，成功则直接返回
	if cansemacquire(addr) {
		return
	}

  // 慢速路径：
	// Harder case:
	//	increment waiter count
	//	try cansemacquire one more time, return if succeeded
	//	enqueue itself as a waiter
	//	sleep
	//	(waiter descriptor is dequeued by signaler)
  // 获取 sema 底层的 semaRoot，并为它赋初始值
	s := acquireSudog()
	root := semtable.rootFor(addr)
	for {
		lockWithRank(&amp;root.lock, lockRankRoot)
		// 1. 自增等待者数量
		root.nwait.Add(1)
		// 2. 再次尝试获取锁，成功则可以返回了
		if cansemacquire(addr) {
			root.nwait.Add(-1)
			unlock(&amp;root.lock)
			break
		}
    // 3. 还是失败，则进入休眠队列
		root.queue(addr, s, lifo)
    // 4. 调用 gopark() 休眠协程（不了解 gopark 可以先去了解一下 GMP 底层原理）
		goparkunlock(&amp;root.lock, reason, traceBlockSync, 4+skipframes)
		if s.ticket != 0 || cansemacquire(addr) {
			break
		}
	}
	if s.releasetime &gt; 0 {
		blockevent(s.releasetime-t0, 3+skipframes)
	}
	releaseSudog(s)
}

// 判断是否可以获取 sema 锁
func cansemacquire(addr *uint32) bool {
	for {
		v := atomic.Load(addr)
    // sema 为 0，表示没锁了，这个时候是一个等待队列，无法直接获取锁
		if v == 0 {
			return false
		}
    // sema 大于 0，说明这个时候有可以并发的协程个数，尝试进行 cas 获取锁，成功则返回 true
		if atomic.Cas(addr, v, v-1) {
			return true
		}
	}
}
</code></pre>
<h4>1.2.5 semarelease()</h4>
<p><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/sema.go#L207">semarelease()</a> 递增计数器，如果有等待者则从队列中取出一个 <code>sudog</code> 并唤醒对应的 goroutine，<strong>handoff</strong> 模式下直接移交锁并让出 CPU。</p>
<ul>
<li>无等待中的协程：直接返回</li>
<li>有等待中的协程：从堆树中出队一个协程，唤醒，并调度到当前 P 的 runq 中</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5dapxzf8jj20u00uidh6.jpg" alt=""></p>
<pre><code class="language-go">// semrelease1 释放一个信号量，如果有等待者则唤醒一个 goroutine
// addr: 信号量地址（如 &amp;mutex.sema）
// handoff: 是否直接移交（饥饿模式下为 true，直接把锁交给被唤醒者）
// skipframes: 用于性能分析时跳过的栈帧数
func semrelease1(addr *uint32, handoff bool, skipframes int) {
	// 通过 addr 的哈希值找到对应的 semaRoot
	root := semtable.rootFor(addr)

	// ===== 关键操作：先递增信号量计数器 =====
	// 无论有没有等待者，都原子地将 *addr 加 1
	// 这相当于传统信号量的 V 操作，释放一个&quot;资源&quot;
	atomic.Xadd(addr, 1)

	// ===== Fast Path：快速检查是否有等待者 =====
	//
	// 注意顺序很重要！必须先执行 Xadd，再检查 nwait
	// 原因：防止 &quot;错过唤醒&quot; 的竞态条件
	// - semacquire 的顺序是：先 nwait++，再 cansemacquire，最后 gopark
	// - 如果我们先检查 nwait 再 Xadd，可能在两步之间 semacquire 刚好 nwait++
	//   导致我们误以为没有等待者而不唤醒，但 goroutine 已经睡眠了
	if root.nwait.Load() == 0 {
		return  // 没有等待者，直接返回（信号量值已经被递增了）
	}

	// ===== Slow Path：有等待者，需要唤醒一个 =====
  //
	// 获取 semaRoot 的锁，保护等待队列的并发访问
	lockWithRank(&amp;root.lock, lockRankRoot)

	// 持锁后再次检查 nwait，因为可能在获取锁之前已经被其他人消费了
	if root.nwait.Load() == 0 {
		// 场景：我们在获取锁期间，另一个 goroutine 可能：
		// 1. 调用了 semacquire 并通过 cansemacquire 成功获取（消费了我们递增的值）
		// 2. 减少了 nwait 计数
		// 所以不需要再唤醒任何人了
		unlock(&amp;root.lock)
		return
	}

	// 从等待队列中取出一个等待者（sudog）
	// s: 要唤醒的 sudog
	// t0: 当前时间（用于性能统计）
	// tailtime: 队尾等待者的开始等待时间（用于计算平均等待时间）
	s, t0, tailtime := root.dequeue(addr)

	if s != nil {
		// 成功取出一个等待者，减少等待者计数
		root.nwait.Add(-1)
	}

	// 尽快释放锁，因为后续操作可能很慢（甚至可能让出 CPU）
	unlock(&amp;root.lock)
	if s != nil {
		// ===== Handoff 模式：直接移交锁 =====
		if handoff &amp;&amp; cansemacquire(addr) {
			// handoff=true 表示饥饿模式
			// 尝试提前消费信号量（将 *addr 减 1）
			// 如果成功，设置 ticket=1，告诉被唤醒的 goroutine：
			// &quot;你不需要竞争了，锁已经是你的了&quot;
			s.ticket = 1
		}

		// ===== 唤醒 goroutine =====
		// 将 sudog 对应的 goroutine 标记为可运行
		// 并将其加入到当前 P 的 runnext 位置（优先运行）
		readyWithTime(s, 5+skipframes)

		// ===== Direct G Handoff：直接切换到被唤醒者 =====
		if s.ticket == 1 &amp;&amp; getg().m.locks == 0 &amp;&amp; getg() != getg().m.g0 {
			// 条件满足时，主动让出 CPU 给被唤醒者立即运行：
			// - s.ticket == 1：已经直接移交了锁
			// - getg().m.locks == 0：当前没有持有其他 runtime 锁
			// - getg() != getg().m.g0：不在系统栈上
			//
			// readyWithTime 已经把被唤醒者放到了 P 的 runnext 位置
			// 现在调用 goyield() 主动让出时间片：
			// - 被唤醒者继承我们的时间片，立即开始运行
			// - 避免高竞争场景下某个 goroutine 霸占 P
			// - goyield 会把当前 G 放到本地队列（不是全局队列）
			//
			// 只在饥饿模式（handoff=true）下这样做，因为：
			// - 正常模式：被唤醒者可能竞争失败，让出 CPU 会浪费
			// - 饥饿模式：已经直接移交，保证能获取锁，不会浪费
			//
			// 相关讨论见 issue 33747
			goyield()
		}
	}
}
</code></pre>
<h4>1.2.6 深度理解</h4>
<p><code>sema</code> 是 Go <code>sync.Mutex</code> 连接 <strong>Go 运行时 (Runtime)</strong> 和 <strong>操作系统 (OS)</strong> 的关键枢纽。 <code>sema</code> 就是用来解决&quot;<u><strong>拿不到锁的 Goroutine 到底去了哪里、怎么睡、怎么醒</strong></u>&quot;的关键问题。</p>
<p>我们要从以下三个层次由浅入深地理解 <code>sema</code>：</p>
<ol>
<li><strong>数据结构层</strong>：它是怎么存储等待者的？</li>
<li><strong>运行时层 (Runtime)</strong>：Go 如何高效管理成千上万个锁？</li>
<li><strong>操作系统层 (OS)</strong>：底层的 <code>futex</code> 到底在做什么？</li>
</ol>
<h5>1.2.6.1 第一层：它在内存中是什么？</h5>
<p>在 <code>sync.Mutex</code> 的定义中：</p>
<pre><code class="language-go">type Mutex struct {
    state int32
    sema  uint32 // &lt;--- 就是它
}
</code></pre>
<p>前面我们讨论过，<strong><code>sema</code> 本质上只是一个内存地址（Address）</strong>。</p>
<ul>
<li><strong>作为 Key</strong>：Go 运行时并不关心 <code>sema</code> 变量里存的具体数值是多少（虽然它确实会变），运行时真正关心的是 <code>&amp;sema</code>（这个变量在内存中的地址）。</li>
<li><strong>全局哈希表</strong>：Go 运行时维护了一个全局的哈希表（<code>semTable</code>），在这个表中：<ul>
<li><strong>Key</strong> <code>&amp;sema</code> (Mutex 中 sema 字段的内存地址)。</li>
<li><strong>Value</strong> 一个等待队列（平衡二叉树），里面躺着一个个正在睡觉的 Goroutine。</li>
</ul>
</li>
</ul>
<pre><code class="language-go">var semtable semTable

// Prime to not correlate with any user patterns.
const semTabSize = 251

type semTable [semTabSize]struct {
	root semaRoot
	pad  [cpu.CacheLinePadSize - unsafe.Sizeof(semaRoot{})]byte
}

func (t *semTable) rootFor(addr *uint32) *semaRoot {
	return &amp;t[(uintptr(unsafe.Pointer(addr))&gt;&gt;3)%semTabSize].root
}
</code></pre>
<p><strong>为什么这么设计？</strong> 如果每个 Mutex 都向操作系统申请一个专门的内核信号量对象，开销太大了。Go 程序中可能有数百万个 Mutex，通过把它们映射到一个固定大小的全局哈希表中，Go 实现了极高的扩展性。</p>
<h5>1.2.6.2 第二层：运行时调度 (GMP)</h5>
<p>当 <code>state</code> 字段判断需要阻塞时，Go 会调用 <code>runtime_SemacquireMutex(&amp;m.sema, ...)</code>（其实背后就是上面提到的 <code>semacuqire()</code>）。这背后发生了什么？这是与 <strong>GMP 模型</strong> 交互的核心。</p>
<p>当 <code>state</code> 字段判断需要阻塞时，Go 会调用 <code>runtime_SemacquireMutex(&amp;m.sema, ...)</code>。这背后发生了什么？这是与 <strong>GMP 模型</strong> 交互的核心。</p>
<p><strong>1. 包装：从 G 到 Sudog</strong></p>
<p>Goroutine (<code>G</code>) 是不能直接挂在链表上的。Go 使用了一个中间结构体叫 <code>sudog</code>。</p>
<ul>
<li>当一个 G 需要阻塞时，运行时会创建一个 <code>sudog</code>，把这个 G 包装进去。</li>
<li>这个 <code>sudog</code> 代表了&quot;一个在特定信号量上等待的 G&quot;。</li>
</ul>
<p><strong>2. 入队与休眠</strong></p>
<ol>
<li><strong>计算哈希</strong>：根据 <code>&amp;sema</code> 的地址，算出它在全局 <code>semTable</code> 中的位置。</li>
<li><strong>挂载</strong>：把包装好的 <code>sudog</code> 挂到该位置的 Treap 尾部。</li>
<li><strong>切出 (Park)</strong>：<ul>
<li>调用 <code>goparkunlock</code>。</li>
<li><strong>关键点</strong>：当前的 <strong>M (系统线程)</strong> 会断开与当前 <strong>G</strong> 的关系。</li>
<li>G 的状态从 <code>Running</code> 变为 <code>Waiting</code>。</li>
<li><strong>M</strong> 并没有睡觉，它会去 <strong>P (处理器)</strong> 的本地队列里找<strong>下一个</strong>可运行的 G 来执行。</li>
<li><strong>这就是 Go 高并发的精髓</strong>：用户层面的阻塞锁，并没有阻塞底层的系统线程（除非没有其他工作可做）。</li>
</ul>
</li>
</ol>
<p><strong>3. 唤醒 (Handoff)</strong></p>
<p>当 <code>Unlock</code> 调用 <code>runtime_Semrelease(&amp;m.sema)</code> （即 <code>semarelease()</code>）时：</p>
<ol>
<li><strong>查找</strong>：再次根据 <code>&amp;sema</code> 地址去全局哈希表里找。</li>
<li><strong>出队</strong>：取出链表头部的 <code>sudog</code>。</li>
<li><strong>调度</strong>：<ul>
<li>把 <code>sudog</code> 里的 G 取出来。</li>
<li>将 G 的状态从 <code>Waiting</code> 改为 <code>Runnable</code>。</li>
<li>把它扔到当前 P 的运行队列或者全局运行队列中，等待被 M 执行。</li>
</ul>
</li>
</ol>
<h5>1.2.6.3 第三层：操作系统原语</h5>
<p>这就到了物理实现的底座了。如果 M 发现没有别的 G 可以执行了，或者 Go 运行时本身的某些同步需要，它最终必须依赖操作系统的能力来让 CPU 停下来。</p>
<p>在 Linux 平台上，<code>sema</code> 的底层实现依赖于 <strong>Futex (Fast Userspace Mutex)</strong>。</p>
<p><code>Futex</code> 是 Linux 内核提供的一种机制，它的核心理念是：<strong>即使需要内核介入，也要尽量减少陷入内核的次数。</strong></p>
<p>它包含两个操作：</p>
<ol>
<li><strong>User Space Check (用户态检查)</strong>：先检查内存中的一个整数（就是 <code>sema</code> 的值）。如果条件满足（比如有信号），直接走人，完全不涉及内核。</li>
<li><strong>Kernel Wait (内核态等待)</strong>：只有当条件不满足时，才发起系统调用（System Call），让内核把线程挂起。</li>
</ol>
<p>在 <code>runtime/os_linux.go</code> 中，你会看到类似这样的汇编或封装调用：</p>
<ul>
<li><strong>休眠 (<code>futexsleep</code>)</strong>： 调用 <code>futex(addr, FUTEX_WAIT, val, ...)</code>。 意思就是：<em>“内核老兄，请你看看 <code>addr</code> 这个内存地址的值是不是 <code>val</code>？如果是，就把我（当前线程 M）挂起；如果不是，说明中间有人改过（可能有信号了），那我就不睡了，直接返回。”</em></li>
<li><strong>唤醒 (<code>futexwakeup</code>)</strong>： 调用 <code>futex(addr, FUTEX_WAKE, count, ...)</code>。 意思就是：<em>“内核老兄，在这个地址上睡觉的线程，请帮我叫醒 <code>count</code> 个。”</em></li>
</ul>
<p>关于 Futex 的更多细节，推荐阅读笔者整理的：<a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a>。</p>
<h3>1.3 总结</h3>
<p>atomic 和 sema 是 Go 并发的&quot;阴阳二元&quot;：</p>
<table>
<thead>
<tr>
<th align="left"></th>
<th align="left">atomic</th>
<th align="left">sema</th>
</tr>
</thead>
<tbody><tr>
<td align="left">哲学</td>
<td align="left">乐观（假设无竞争）</td>
<td align="left">悲观（接受竞争）</td>
</tr>
<tr>
<td align="left">机制</td>
<td align="left">硬件指令</td>
<td align="left">OS/Runtime 调度</td>
</tr>
<tr>
<td align="left">速度</td>
<td align="left">极快（纳秒）</td>
<td align="left">较慢（微秒）</td>
</tr>
<tr>
<td align="left">能力</td>
<td align="left">状态变更</td>
<td align="left">休眠/唤醒</td>
</tr>
<tr>
<td align="left">使用</td>
<td align="left">所有路径</td>
<td align="left">慢速路径</td>
</tr>
<tr>
<td align="left">目标</td>
<td align="left">性能</td>
<td align="left">正确性 + 公平性</td>
</tr>
</tbody></table>
<p>所有 Go 的同步原语都是这两者的不同组合方式，遵循 &quot;Fast Path with Atomic, Slow Path with Semaphore&quot; 的设计模式！🎯</p>
<p>用一句话总结就是：</p>
<blockquote>
<p>[!IMPORTANT]</p>
<p>Atomic 提供无锁的快速状态管理（CAS、加减），sema 提供有竞争时的 goroutine 休眠/唤醒机制，两者组合实现&quot;乐观尝试 + 悲观等待&quot;的高效并发模型。</p>
</blockquote>
<pre><code class="language-mermaid">graph LR
    subgraph &quot;性能层级&quot;
        A[atomic&lt;br/&gt;纳秒级&lt;br/&gt;99% 场景]
        B[sema&lt;br/&gt;微秒级&lt;br/&gt;1% 竞争]
    end

    A --&gt;|无竞争| Fast[Fast Path]
    A --&gt;|低竞争&lt;br/&gt;自旋| Spin[Spin]
    B --&gt;|高竞争| Slow[Slow Path&lt;br/&gt;休眠/唤醒]

    style A fill:#ccffcc
    style B fill:#e1f5ff
    style Fast fill:#90EE90
    style Slow fill:#FFB6C1
</code></pre>
<h2>2. sync.Mutex</h2>
<h3>2.1 概述</h3>
<p>Go 语言的 <code>sync.Mutex</code> 是一种并发原语，旨在保证同一时间只有一个 Goroutine 可以访问共享资源，从而实现互斥（Mutual Exclusion）。它的底层实现是基于两个核心字段和一套复杂的自旋、排队和唤醒逻辑，以在性能和公平性之间取得平衡。</p>
<p><code>sync.Mutex</code> 类型只有两个公开的指针方法：<code>Lock()</code> 和 <code>Unlock()</code>。</p>
<ul>
<li><code>m.Lock()</code>：锁定当前的共享资源</li>
<li><code>m.Unlock()</code>：进行解锁</li>
</ul>
<h3>2.2 数据结构</h3>
<p>前面我们已经展示过 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/sync/mutex.go#L20">sync.Mutex</a> 的数据结构了：</p>
<pre><code class="language-go">type Mutex struct {
	state int32
	sema  uint32
}
</code></pre>
<p>Go 语言的 sync.Mutex 结构体非常精简，仅包含两个字段：</p>
<ul>
<li><code>state (int32)</code>：这是一个 32 位整数，用于原子地表示互斥锁的当前状态。通过不同的位（Bit）来编码多种信息，实现了极高的效率。</li>
<li><code>sema (uint32)</code>：这是我们前面提到的 sema 锁，用于实现 Goroutine 的阻塞和唤醒机制。当 Goroutine 无法立即获取锁时，它会在该信号量上阻塞休眠，等待锁的持有者释放信号量将其唤醒。</li>
</ul>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5d5mp6h8kj21520l23zu.jpg" style="zoom:50%;" />

<p>如何理解这 2 个字段呢？在我看来：</p>
<ul>
<li><code>state</code> 字段是在用户态（User Space）解决&quot;谁拿到锁&quot;的逻辑。</li>
<li><code>sema</code> 字段是用来解决&quot;拿不到锁的 Goroutine 到底去了哪里、怎么睡、怎么醒&quot;的物理问题。</li>
</ul>
<h3>2.3 state 字段</h3>
<p><code>sema</code> 前面已经介绍得非常清楚了，下面我们重点来分析一下 <code>state</code> 字段。</p>
<p>为了最大化性能，<code>state</code> 字段通过位运算存储了四个关键信息，这些信息共同决定了锁的运行模式和竞争程度：</p>
<table>
<thead>
<tr>
<th><strong>位 (Bit)</strong></th>
<th><strong>含义</strong></th>
<th><strong>解释</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>0</strong></td>
<td><code>Locked</code></td>
<td><strong>1</strong> 表示已加锁，<strong>0</strong> 表示未加锁。</td>
</tr>
<tr>
<td><strong>1</strong></td>
<td><code>Woken</code></td>
<td><strong>1</strong> 表示已有 Goroutine 被唤醒（正在尝试获取锁），此时不需要再唤醒其他人。</td>
</tr>
<tr>
<td><strong>2</strong></td>
<td><code>Starvation</code></td>
<td><strong>1</strong> 表示进入<strong>饥饿模式</strong>（Go 1.9+ 引入的关键优化）。</td>
</tr>
<tr>
<td><strong>3-31</strong></td>
<td><code>WaiterCount</code></td>
<td>记录当前有多少个 Goroutine 在排队等待。</td>
</tr>
</tbody></table>
<p>如下图所示：</p>
<pre><code> 31                           3  2  1  0
┌─────────────────────────────┬──┬──┬──┐
│    等待者数量 29 bits         │S │W │L │
└─────────────────────────────┴──┴──┴──┘
                               │  │  └─ mutexLocked (锁定状态)
                               │  └──── mutexWoken (唤醒标志)
                               └─────── mutexStarving (饥饿模式)
</code></pre>
<p>使用一个 int32 来存储这么多信息有三大好处：</p>
<ol>
<li><p><strong>满足多个状态修改的原子性</strong>：所有状态必须在一个原子操作中一起更新，避免状态不一致。</p>
<pre><code class="language-go">// 错误的设计（如果分开存储）
mutex.locked = true      // ← 这里可能被中断
mutex.waiterCount++      // ← 状态不一致的窗口期

// 正确的设计（单个原子操作）
atomic.CompareAndSwapInt32(&amp;m.state, old, new)  // 一次性更新所有状态
</code></pre>
</li>
<li><p><strong>CPU Cache Line 效率</strong>：一个 int32 只占 4 字节，极度缓存友好，所有状态信息在同一个 cache line 中，读取/修改只需要一次内存访问，避免 false sharing。</p>
</li>
<li><p><strong>Fast Path 快速路径优化</strong>：在无竞争情况下，即 state == 0 表示完全空闲（无锁、无等待、无标志），一次 CAS 就能完成加锁，编译器可以内联这段代码，这是 99% 无竞争场景的关键优化。</p>
<pre><code class="language-go">func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
    if atomic.CompareAndSwapInt32(&amp;m.state, 0, mutexLocked) {
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }
    // Slow path (outlined so that the fast path can be inlined)
    m.lockSlow()
</code></pre>
</li>
</ol>
<p><code>state</code> 的状态转换示例：</p>
<pre><code class="language-go">// 初始状态
state = 0b00000000_00000000_00000000_00000000

// goroutine A 获取锁
state = 0b00000000_00000000_00000000_00000001  // L=1

// goroutine B 尝试获取，进入等待队列
state = 0b00000000_00000000_00000000_00001001  // L=1, waiter=1

// goroutine B 设置了 woken 标志（自旋中）
state = 0b00000000_00000000_00000000_00001011  // L=1, W=1, waiter=1

// 等待超过 1ms，进入饥饿模式
state = 0b00000000_00000000_00000000_00001101  // L=1, S=1, waiter=1
</code></pre>
<h3>2.4 上锁</h3>
<ul>
<li>正常模式：获得锁直接返回，得不到锁就自旋，自旋多次后进入 sema 队列中休眠，超过 1ms 就转为饥饿模式；</li>
<li>饥饿模式：<ul>
<li>新来的协程不自旋，直接今年入 sema 队列中；</li>
<li>依次从 sema 队列中唤醒协程，并直接获得锁，当 sema 队列为空时，跳回正常模式</li>
</ul>
</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5daz7hk4nj22a80u077s.jpg" alt=""></p>
<p>上锁的源码位于 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/sync/mutex.go#L61">sync/mutex.go#L61</a>，代码如下所示：</p>
<pre><code class="language-go">func (m *Mutex) lockSlow() {
	// ===== 初始化局部变量 =====
	var waitStartTime int64  // 开始等待的时间戳（用于判断是否饥饿）
	starving := false        // 当前 goroutine 是否处于饥饿状态
	awoke := false           // 当前 goroutine 是否从休眠中被唤醒
	iter := 0                // 自旋迭代次数
	old := m.state           // 保存当前 mutex 的状态

	// ===== 主循环：不断尝试获取锁 =====
	for {
		// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
		// 第一阶段：自旋尝试（Active Spinning）
		// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
		// 自旋条件：
		// 1. old&amp;mutexLocked != 0：锁已被持有
		// 2. old&amp;mutexStarving == 0：不在饥饿模式（饥饿模式下新来的不能竞争）
		// 3. runtime_canSpin(iter)：满足自旋条件（多核、迭代次数 &lt; 4、有其他 P 等）
		if old&amp;(mutexLocked|mutexStarving) == mutexLocked &amp;&amp; runtime_canSpin(iter) {
			// 尝试设置 mutexWoken 标志，条件：
			// 1. !awoke：我们还没设置过
			// 2. old&amp;mutexWoken == 0：当前没有其他 goroutine 设置
			// 3. old&gt;&gt;mutexWaiterShift != 0：有等待者（不然设置 woken 没意义）
			//
			// 目的：告诉 Unlock &quot;有人在自旋，不要唤醒休眠的 goroutine&quot;，减少不必要的唤醒开销
			if !awoke &amp;&amp; old&amp;mutexWoken == 0 &amp;&amp; old&gt;&gt;mutexWaiterShift != 0 &amp;&amp;
				atomic.CompareAndSwapInt32(&amp;m.state, old, old|mutexWoken) {
				awoke = true
			}

			// 执行实际的自旋（CPU 级别的忙等待）
			runtime_doSpin()
			iter++
			old = m.state  // 重新读取状态
			continue       // 继续下一轮尝试
		}

		// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
		// 第二阶段：准备新的状态值（CAS 更新）
		// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

		new := old  // 基于旧状态构造新状态

		// 如果不在饥饿模式，尝试设置 mutexLocked 位
		// （在饥饿模式下，新来的 goroutine 不能直接抢锁，必须排队）
		if old&amp;mutexStarving == 0 {
			new |= mutexLocked
		}

		// 如果锁已被持有或处于饥饿模式，增加等待者计数（即将进入等待队列）
		if old&amp;(mutexLocked|mutexStarving) != 0 {
			new += 1 &lt;&lt; mutexWaiterShift  // 等待者数量 +1
		}

		// 如果当前 goroutine 已经饥饿（等待超过 1ms），并且锁还被持有
		// 则尝试将 mutex 切换到饥饿模式
		// 注意：只在锁被持有时切换，因为 Unlock 期望饥饿模式必有等待者
		if starving &amp;&amp; old&amp;mutexLocked != 0 {
			new |= mutexStarving
		}

		// 如果当前 goroutine 是被唤醒的，需要清除 mutexWoken 标志
		if awoke {
			// 清除 mutexWoken 标志（用 &amp;^ 位清除操作）
			new &amp;^= mutexWoken
		}

		// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
		// 第三阶段：CAS 更新状态
		// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

		// 尝试用 CAS 将状态从 old 更新为 new
		if atomic.CompareAndSwapInt32(&amp;m.state, old, new) {
			// CAS 成功！

			// 检查是否成功获取了锁
			// 条件：旧状态既没锁定也不在饥饿模式
			if old&amp;(mutexLocked|mutexStarving) == 0 {
				break // 成功获取锁，退出循环！
			}

			// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
			// 第四阶段：进入等待队列并休眠
			// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

			// 如果之前已经等待过（被唤醒后重新竞争失败），插入队首（LIFO）
			// 否则插入队尾（FIFO）
			queueLifo := waitStartTime != 0

			// 记录开始等待的时间（只记录一次）
			if waitStartTime == 0 {
				waitStartTime = runtime_nanotime()
			}

      // 调用 semacuqire 进入休眠 &lt;--- 阻塞在这里，直到被唤醒
			runtime_SemacquireMutex(&amp;m.sema, queueLifo, 2)

			// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
			// 被唤醒了！从这里继续执行
			// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

			// 检查是否应该进入饥饿模式
			// 条件：之前已经饥饿 || 等待时间超过 1ms
			starving = starving || runtime_nanotime()-waitStartTime &gt; starvationThresholdNs

			// 重新读取当前状态
			old = m.state

			// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
			// 饥饿模式的特殊处理
			// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

			if old&amp;mutexStarving != 0 {
				// 饥饿模式下被唤醒，说明锁被直接移交给我们了
				// 但此时状态还不一致：
				// - mutexLocked 还没设置（需要我们设置）
				// - 我们还被计入等待者（需要减 1）


				// 计算状态变化：
				// +mutexLocked：设置锁定标志
				// -1&lt;&lt;mutexWaiterShift：等待者数量减 1
				delta := int32(mutexLocked - 1&lt;&lt;mutexWaiterShift)

				// 决定是否退出饥饿模式
				// 条件：
				// 1. 当前 goroutine 不再饥饿（等待时间 &lt; 1ms）
				// 2. 或者我们是最后一个等待者
				if !starving || old&gt;&gt;mutexWaiterShift == 1 {
					// 退出饥饿模式（清除 mutexStarving 标志）
					// 注意：必须在这里退出，考虑实际等待时间
					// 饥饿模式效率低，如果不及时退出，两个 goroutine
					// 可能会无限期地在饥饿模式下来回切换
					delta -= mutexStarving
				}

				// 原子更新状态
				atomic.AddInt32(&amp;m.state, delta)
				break  // 成功获取锁，退出循环！
			}

			// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
			// 正常模式被唤醒：重新开始竞争
			// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

			awoke = true   // 标记为已唤醒
			iter = 0       // 重置自旋计数器（可以重新自旋）

		} else {
			// CAS 失败：状态被其他 goroutine 改变了
			// 重新读取状态，继续下一轮循环
			old = m.state
		}
	}
	// ===== 成功获取锁，退出循环 =====
}
</code></pre>
<p>关键步骤：</p>
<ol>
<li>自旋（Spinning）：在正常模式且满足条件时自旋等待</li>
<li>设置 mutexWoken：告诉 Unlock 不要唤醒其他 goroutine</li>
<li>更新等待者计数：增加 waiter 数量</li>
<li>进入信号量等待：调用 runtime_SemacquireMutex</li>
<li>饥饿模式切换：等待时间超过 1ms 切换到饥饿模式</li>
</ol>
<p>自旋条件：</p>
<pre><code class="language-go">const (
	active_spin     = 4  // referenced in proc.go for sync.Mutex implementation
	active_spin_cnt = 30 // referenced in proc.go for sync.Mutex implementation
)

func internal_sync_runtime_canSpin(i int) bool {
  // 必须同时满足：
	// 	- 自旋次数 &lt; 4
	// 	- 多核 CPU（numCPU &gt; 1）
	//  - 有其他运行的 P
  //  - 本地运行队列为空
	if i &gt;= active_spin || numCPUStartup &lt;= 1 || gomaxprocs &lt;= sched.npidle.Load()+sched.nmspinning.Load()+1 {
		return false
	}
	if p := getg().m.p.ptr(); !runqempty(p) {
		return false
	}
	return true
}
</code></pre>
<h3>2.5 解锁</h3>
<ul>
<li>正常模式：解锁后新来的协程和 sema 队列中的协程一起竞争；</li>
<li>饥饿模式：新来的协程直接入 sema 队列，依次从 sema 队列中唤醒协程并直接交付锁；</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5db20m37pj21pc0m475r.jpg" alt=""></p>
<p>上锁的源码位于 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/sync/mutex.go#L202">sync/mutex.go#L202</a>，代码如下所示：</p>
<pre><code class="language-go">func (m *Mutex) Unlock() {
	// ===== Fast Path：快速路径（无竞争情况）=====
	// 原子地将 state 减去 mutexLocked（即清除锁定标志位）
	// 如果 mutex 完全空闲（无等待者、无其他标志），new 将等于 0
  // 如果 new == 0，说明没有等待者，直接返回（最快路径）
	new := atomic.AddInt32(&amp;m.state, -mutexLocked)
	if new != 0 {
		// new != 0 说明还有其他信息（等待者、标志位等）
		// 需要进入慢速路径处理
		m.unlockSlow(new)
	}
}

// unlockSlow 是 Unlock 的慢速路径，处理有等待者或特殊标志的情况
// 参数 new：已经减去 mutexLocked 后的新状态值
func (m *Mutex) unlockSlow(new int32) {
	// 不允许对未加锁的 mutex 进行 unlock!
	if (new+mutexLocked)&amp;mutexLocked == 0 {
		fatal(&quot;sync: unlock of unlocked mutex&quot;)
	}

	// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
	// 分支 1：正常模式（Normal Mode）
	// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
	if new&amp;mutexStarving == 0 {
		// 正常模式下的唤醒逻辑：不保证被唤醒者一定能获取锁
		old := new

		for {
			// ===== 检查是否需要唤醒等待者 =====
			// 以下任一条件满足，都无需唤醒：
			// 1. old&gt;&gt;mutexWaiterShift == 0
			//    → 没有等待者
			//
			// 2. old&amp;mutexLocked != 0
			//    → 锁已经被其他 goroutine 抢走了
			//       （在我们 Unlock 之后，有新来的 goroutine 直接 CAS 获取了锁）
			//
			// 3. old&amp;mutexWoken != 0
			//    → 已经有一个 goroutine 被标记为唤醒状态
			//       （可能在自旋，或者已经被其他 Unlock 唤醒）
			//
			// 4. old&amp;mutexStarving != 0
			//    → 进入饥饿模式了
			//       （虽然我们检查的是 new&amp;mutexStarving == 0 才进这个分支，
			//        但在循环中 old 可能被其他 goroutine 更新了）
			//       饥饿模式有专门的处理逻辑，我们不应该干预
			if old&gt;&gt;mutexWaiterShift == 0 || old&amp;(mutexLocked|mutexWoken|mutexStarving) != 0 {
				return  // 不需要唤醒，直接返回
			}

			// ===== 准备唤醒一个等待者 =====

			// 构造新状态：
			// 1. old - 1&lt;&lt;mutexWaiterShift：等待者数量减 1
			// 2. | mutexWoken：设置 mutexWoken 标志
			//
			// mutexWoken 的作用：
			// - 告诉正在 Lock 的 goroutine：&quot;已经有人被唤醒了&quot;
			// - 避免多个 Unlock 重复唤醒
			// - 被唤醒的 goroutine 会清除这个标志
			new = (old - 1&lt;&lt;mutexWaiterShift) | mutexWoken

			// 用 CAS 更新状态
			if atomic.CompareAndSwapInt32(&amp;m.state, old, new) {
				// CAS 成功！我们获得了唤醒的权利

				// 调用 runtime 的信号量释放操作，唤醒一个等待者
				// 参数：
				// - &amp;m.sema：信号量地址
				// - false：handoff=false，正常模式，不直接移交
				//          被唤醒的 goroutine 需要重新竞争锁
				// - 2：skipframes（用于性能分析）
				runtime_Semrelease(&amp;m.sema, false, 2)
				return
			}

			// CAS 失败：状态被其他 goroutine 改变了
			// 重新读取状态，继续下一轮循环
			old = m.state
		}

	// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
	// 分支 2：饥饿模式（Starvation Mode）
	// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
	} else {
		// 饥饿模式的特殊处理：
		//
		// 1. 直接移交所有权（handoff=true）
		//    - 被唤醒的 goroutine 保证能获取锁
		//    - 不需要重新竞争
		//
		// 2. mutexLocked 位不设置
		//    - 被唤醒的 goroutine 会自己设置
		//    - 见 lockSlow 中 old&amp;mutexStarving != 0 分支
		//
		// 3. mutexStarving 标志保持
		//    - 新来的 goroutine 看到这个标志，知道不能竞争
		//    - 必须排队等待
		//
		// 4. 当前 goroutine 会主动让出 CPU（在 semrelease1 中 goyield）
		//    - 让被唤醒者立即运行
		//    - 避免延迟

    // 调用 semarelease() 释放操作
		// 参数：
		// - &amp;m.sema：信号量地址
		// - true：handoff=true，饥饿模式，直接移交
		//         semrelease1 会设置 ticket=1，并调用 goyield()
		// - 2：skipframes（用于性能分析）
		runtime_Semrelease(&amp;m.sema, true, 2)
	}
}
</code></pre>
<p>关键步骤：</p>
<ol>
<li>原子清除锁定位：atomic.AddInt32(&amp;state, -mutexLocked)，结果为 0 则直接返回</li>
<li>检查是否需要唤醒：无等待者/已有锁持有者/已有被唤醒者则跳过</li>
<li>正常模式：设置 mutexWoken 标志 + 减少等待者计数 + semrelease(handoff=false) 唤醒但需重新竞争</li>
<li>饥饿模式：semrelease(handoff=true) 直接移交所有权 + goyield() 让出 CPU</li>
</ol>
<h3>2.6 总结</h3>
<p>到这里，我们已经深入探析了 <code>sync.Mutex</code> 的核心机制和底层数据结构。归纳下来，Go 的 <code>Mutex</code> 实现，<strong>本质上是通过 atomic 乐观抢占为主、sema 信号量排队休眠为辅，辅以饥饿/公平模式动态切换</strong>，来最大化锁的性能与公平性。</p>
<p><strong>精髓：抢得快靠 &quot;atomic&quot;，等得稳靠 &quot;sema&quot;。</strong></p>
<ul>
<li><strong>快速路径（Fast Path）：</strong> 绝大多数情况下，goroutine 利用 <code>atomic</code> 硬件指令快速抢占锁，纳秒级切换，无需操作内核。</li>
<li><strong>慢速路径（Slow Path）：</strong> 发生竞争时，goroutine 通过 <code>sema</code> 跳入排队睡眠，只有唤醒才参与下一轮抢占。这部分涉及用户/内核态切换，耗时微秒级，但能极大减少资源消耗与 CPU 干扰。</li>
<li><strong>三层状态编码：</strong> 利用一个 <code>int32</code> 整数位操作，节省空间同时高效追踪锁的&quot;持有&quot;&quot;等待&quot;&quot;唤醒&quot;&quot;饥饿&quot;等复杂状态。</li>
<li><strong>饥饿模式保障公平性：</strong> 当长时间得不到锁时，自动切换到饥饿模式，保证队列排头的人下一次必定抢到锁，杜绝饥饿和“惊群”。</li>
</ul>
<h2>3. sync.RWMutex</h2>
<h3>3.1 概述</h3>
<ul>
<li>同时只能有一个 Goroutine 能够获得写锁</li>
<li>同时可以有任意多个 Gorouinte 获得读锁</li>
<li>同时只能存在写锁或读锁（读和写互斥）</li>
</ul>
<p><code>sync.RWMutex</code> 提供了 4 个方法：</p>
<ul>
<li><code>rwm.RLock()</code>：上读锁</li>
<li><code>rwm.RUnlock()</code>：解读锁</li>
<li><code>rwm.Lock()</code>：上写锁</li>
<li><code>rwm.Unlock()</code>：解读锁</li>
</ul>
<h3>3.2 数据结构</h3>
<p><code>sync.RWMutex</code> 定义在 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/sync/rwmutex.go#L39">sync/rwmutex.go#L39</a>，如下所示：</p>
<pre><code class="language-go">type RWMutex struct {
	w           Mutex        // held if there are pending writers
	writerSem   uint32       // semaphore for writers to wait for completing readers
	readerSem   uint32       // semaphore for readers to wait for completing writers
	readerCount atomic.Int32 // number of pending readers
	readerWait  atomic.Int32 // number of departing readers
}
</code></pre>
<ul>
<li><code>w</code>：写锁，拿到它直接有了上写锁的资格，有可能还需要等待读锁全部释放</li>
<li><code>writerSem</code>：写协程等待队列</li>
<li><code>readerSem</code>：读协程等待队列</li>
<li><code>readerCount</code>：正值表示正值读的协程个数，负值表示加了写锁；</li>
<li><code>readerWait</code>：上写锁应该等待读协程的个数</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5dc3vpgidj21fy0p6wft.jpg" alt=""></p>
<h3>3.3 上写锁</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5edigi0eqj22c409cab9.jpg" alt=""></p>
<pre><code class="language-go">const rwmutexMaxReaders = 1 &lt;&lt; 30 // 最多的读者个数，是一个非常大的值

func (rw *RWMutex) Lock() {
	// 1. 抢占获取写锁的资格
	rw.w.Lock()
	// Announce to readers there is a pending writer.
  // 2. 原子变量 readerCount 减去 rwmutexMaxReaders 表明当前有写的需求，
  // 	阻止后续读锁的抢占，写者优先！
  // 	再加回去是要恢复原来的值，以得到抢锁之前正常读的协程的个数 r
	r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders
  // 3. 陷入 writerSem，等待 readerWait 个正在读的协程释放读锁
	if r != 0 &amp;&amp; rw.readerWait.Add(r) != 0 {
		runtime_SemacquireRWMutex(&amp;rw.writerSem, false, 0)
	}
  // 4. 抢锁成功
}
</code></pre>
<h3>3.4 解写锁</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5edvrcnjzj21ns0d4751.jpg" alt=""></p>
<pre><code class="language-go">func (rw *RWMutex) Unlock() {
  // 1. 把 rwmutexMaxReaders 加回去，表示已经没有写协程了
	r := rw.readerCount.Add(rwmutexMaxReaders)
	if r &gt;= rwmutexMaxReaders {
    // 不允许对未上锁的锁进行 Unlock！
		fatal(&quot;sync: Unlock of unlocked RWMutex&quot;)
	}
  // 2. 唤醒所有阻塞在 readerSem 中的读协程
	for i := 0; i &lt; int(r); i++ {
		runtime_Semrelease(&amp;rw.readerSem, false, 0)
	}
	// 3. 允许其他协程抢占写锁
	rw.w.Unlock()
}
</code></pre>
<h3>3.5 上读锁</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5ednp240kj21py07idgq.jpg" alt=""></p>
<pre><code class="language-go">func (rw *RWMutex) RLock() {
  // 1. readerCount++，检查是否有写锁
	if rw.readerCount.Add(1) &lt; 0 {
		// 2. 有写锁，则陷入 readerSem，等待写锁释放
		runtime_SemacquireRWMutexR(&amp;rw.readerSem, false, 0)
	}
  // 3. 没有写锁或者写锁释放后唤醒 readerSem，则获得读锁成功
}
</code></pre>
<h3>3.6 解读锁</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5edvrcnjzj21ns0d4751.jpg" alt=""></p>
<pre><code class="language-go">func (rw *RWMutex) RUnlock() {
  // 1. 释放当前读锁，将 readerCount --
  // 2. 检查是否有写协程正在等待
	if r := rw.readerCount.Add(-1); r &lt; 0 {
		// 3. 如果有写协程等待，则往下走
		rw.rUnlockSlow(r)
	}
}

func (rw *RWMutex) rUnlockSlow(r int32) {
	if r+1 == 0 || r+1 == -rwmutexMaxReaders {
		fatal(&quot;sync: RUnlock of unlocked RWMutex&quot;)
	}
  // 4. readerWait--
  // 5. 判断是否是最后一个释放读锁的协程
	if rw.readerWait.Add(-1) == 0 {
		// 6. 是的话，就从 writerSem 中唤醒写协程
		runtime_Semrelease(&amp;rw.writerSem, false, 1)
	}
}
</code></pre>
<h3>3.7 总结</h3>
<p>总的来说，Go 的 <code>RWMutex</code> 遵循的是<strong>写者优先（Writer Priority）</strong> 原则，防止写者饥饿。四个核心方法的要点总结如下：</p>
<ul>
<li><p><strong>上写锁</strong>：竞争写锁，看看有无读协程：</p>
<ul>
<li><p>没有读协程的话直接获得写锁；</p>
</li>
<li><p>有读协程的话，阻塞后来的读协程，等待当前读协程释放；</p>
</li>
</ul>
</li>
<li><p><strong>解写锁</strong>：解写锁，唤醒 readerSem；</p>
</li>
<li><p><strong>上读锁</strong>：readerCount++，并检查是否有写锁：</p>
<ul>
<li><p>没有写锁，则上锁完毕；</p>
</li>
<li><p>有写锁，则陷入 readerSem，等待写锁释放；</p>
</li>
</ul>
</li>
<li><p><strong>解读锁</strong>：readerCount --，并检测是否有写协程被阻塞：</p>
<ul>
<li><p>无，则返回；</p>
</li>
<li><p>有，则 readerWait --；判断是否是最后一个释放读锁的协程：</p>
<ul>
<li>不是，则返回；</li>
<li>是，则唤醒 writerSem，解锁完毕；</li>
</ul>
</li>
</ul>
</li>
</ul>
<h2>4. sync.WaitGroup</h2>
<h3>4.1 概述</h3>
<p>WaitGroup 等待一组 Goroutine 完成。主 Goroutine 调用 Add 来设置要等待的 Goroutine 的数量。然后每个 Goroutine 运行并在完成时调用 Done。同时，主 Goroutine 可以使用 Wait 来阻塞，直到所有 Goroutine 完成。</p>
<ul>
<li><code>wg.Add(delta int)</code>：Add 将 delta（可能为负）添加到 WaitGroup 计数器。如果计数器变为 0，所有在 Wait 时阻塞的 Goroutine 将被释放。如果计数器变成负值，Add 会 panic。</li>
<li><code>wg.Done()</code>：当 WaitGroup 同步等待组中的某个 Goroutine 执行完毕后，设置这个 WaitGroup 的 counter 数值减 1。</li>
<li><code>wg.Wait()</code>：表示让当前的 Goroutine 等待，进入阻塞状态。一直到 WaitGroup 的计数器为 0，才能解除阻塞，这个 Goroutine 才能继续执行。</li>
</ul>
<h3>4.2 数据结构</h3>
<p><code>sync.WaitGroup</code> 源码位于 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/sync/waitgroup.go#L48">sync/waitgroup.go#L48</a>：</p>
<pre><code class="language-go">type WaitGroup struct {
	noCopy noCopy   // 防止拷贝

	// Bits (high to low):
	//   bits[0:32]  counter
	//   bits[32]    flag: synctest bubble membership
	//   bits[33:64] wait count
	state atomic.Uint64  // 核心状态字段（64位）
	sema  uint32				 // sema 锁地址
}
</code></pre>
<p>重点是看 <code>state</code> 字段：</p>
<pre><code> 63                    33  32  31                     0
┌─────────────────────┬───┬───┬─────────────────────┐
│   waiter count      │ B │ 0 │      counter        │
│    (31 bits)        │ u │   │    (32 bits)        │
│                     │ b │   │                     │
│                     │ b │   │                     │
│                     │ l │   │                     │
│                     │ e │   │                     │
└─────────────────────┴───┴───┴─────────────────────┘
  bits[33:64]         bit32   bits[0:32]

counter:      当前待完成的任务数（Add 增加，Done 减少）
bubble flag:  synctest 相关（测试用）
waiter count: 有多少个 goroutine 在 Wait 中阻塞
</code></pre>
<p>为什么要用一个字段？</p>
<ol>
<li>原子操作：可以用一次原子操作同时读写两个值</li>
<li>避免竞态：counter 和 waiter 总是一致的快照</li>
<li>零分配：整个 WaitGroup 只需 16 字节（8+4+padding）</li>
</ol>
<pre><code class="language-go">state := wg.state.Load()
counter := int32(state &gt;&gt; 32)     // 取高32位
waiter := uint32(state &amp; 0x7fffffff) // 取低31位（忽略bubble flag）
</code></pre>
<h3>4.3 wg.Wait()</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5eepe7yxij21f806y0td.jpg" alt=""></p>
<pre><code class="language-go">func (wg *WaitGroup) Wait() {
    for {
        state := wg.state.Load()
        v := int32(state &gt;&gt; 32)    // counter

        // 1. Fast Path: counter == 0，直接返回
        if v == 0 {
            return
        }

        // 2. Slow Path: counter &gt; 0，需要等待
        // 用 CAS 增加 waiter 计数
        if wg.state.CompareAndSwap(state, state+1) {
            // CAS 成功，进入等待
            runtime_SemacquireWaitGroup(&amp;wg.sema, false)
            // 被唤醒后返回
            return
        }
        // CAS 失败，重试
    }
}
</code></pre>
<h3>4.4 wg.Add()</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5eexehxpaj220g0eg75w.jpg" alt=""></p>
<pre><code class="language-go">func (wg *WaitGroup) Add(delta int) {
    // 1. 原子地将 delta 加到 counter（高32位）
    state := wg.state.Add(uint64(delta) &lt;&lt; 32)

    // 2. 解析 state
    v := int32(state &gt;&gt; 32)  // counter
    w := uint32(state &amp; 0x7fffffff) // waiter count

    // 3. 错误检查
    if v &lt; 0 {
        panic(&quot;negative counter&quot;)
    }
    if w != 0 &amp;&amp; delta &gt; 0 &amp;&amp; v == int32(delta) {
        panic(&quot;Add called concurrently with Wait&quot;)
    }

    // 4. 快速返回：counter &gt; 0 或没有 waiter
    if v &gt; 0 || w == 0 {
        return
    }

    // 5. 关键时刻：counter 降到 0，且有 waiter 在等待
    //    → 唤醒所有 waiter！
    wg.state.Store(0)  // 重置状态
    for ; w != 0; w-- {
        runtime_Semrelease(&amp;wg.sema, false, 0)  // 唤醒一个
    }
}
</code></pre>
<h3>4.5 wg.Done()</h3>
<pre><code class="language-go">func (wg *WaitGroup) Done() {
  // 就是执行 counnter--
	wg.Add(-1)
}
</code></pre>
<h2>5. sync.Once</h2>
<h3>5.1 概述</h3>
<p><code>sync.Once</code> 可以让并发中的一段代码只执行一次；</p>
<ul>
<li><strong>once.Do(func)</strong>：执行某一函数，该函数在多个协程中，只会被执行一次。</li>
</ul>
<h3>5.2 数据结构</h3>
<p><code>sync.Once</code> 的源码位于 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/sync/once.go#L20">sync/once.go#L20</a>：</p>
<pre><code class="language-go">type Once struct {
	_ noCopy

	done atomic.Bool
	m    Mutex
}
</code></pre>
<ul>
<li><code>done</code>：表示当前 once 是否已经执行过了；</li>
<li><code>m</code>：锁</li>
</ul>
<h3>5.3 once.Do()</h3>
<p>其实就一个简单的双重检测逻辑。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5efehy8jqj21tw0bo0tw.jpg" alt=""></p>
<pre><code class="language-go">func (o *Once) Do(f func()) {
  // 如果已经执行过的了，直接返回
	if !o.done.Load() {
		o.doSlow(f)
	}
}

func (o *Once) doSlow(f func()) {
  // 上锁
	o.m.Lock()
	defer o.m.Unlock()
  // 上锁后二次检查
	if !o.done.Load() {
		defer o.done.Store(true)
		f()
	}
}
</code></pre>
<h2>6. sync.Cond</h2>
<h3>6.1 概述</h3>
<p>从第一性原理来看，<code>sync.Cond</code> 解决的是<strong>轮询（Polling） vs 事件通知（Event Notification）<strong>的问题。 当你需要等待某个</strong>特定条件</strong>（比如&quot;队列不为空&quot;或&quot;缓冲区有空位&quot;）满足时，你只有两种选择：</p>
<ol>
<li><strong>轮询 (Spinning)</strong>：在一个死循环里不断加锁检查。</li>
<li><strong>通知 (Cond)</strong>：我去睡觉，等条件满足了，你把我叫醒。</li>
</ol>
<p>Go 的 <code>sync.Cond</code> 实现非常独特，它没有直接使用操作系统层面的 Condition Variable（如 Pthread Cond），而是自己在 Runtime 层面实现了一套<strong>基于票号（Ticket）的通知队列</strong>。</p>
<p><code>sync.Cond</code> 提供了 3 个核心方法：</p>
<ul>
<li><code>c.Wait()</code>：阻塞，等待条件发生</li>
<li><code>c.Signal()</code>：唤醒一个等待的协程</li>
<li><code>c.Broadcast()</code>：唤醒所有等待的协程</li>
</ul>
<p>使用方式：</p>
<pre><code class="language-go">c.L.Lock()          // 1. 先加锁（保护条件 condition）
for !condition() {  // 2. 必须用 for 循环检查（防止虚假唤醒）
    c.Wait()        // 3. 挂起（内部会：解锁 -&gt; 睡 -&gt; 加锁）
}
// 执行业务逻辑...
c.L.Unlock()        // 4. 最终解锁
</code></pre>
<h3>6.2 数据结构</h3>
<p><code>sync.Cond</code> 源码位于 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/sync/cond.go#L37">sync/cond.go#L37</a>：</p>
<pre><code class="language-go">type Cond struct {
    noCopy  noCopy       // 防止拷贝
    L       Locker       // 关联的锁（通常是 *Mutex 或 *RWMutex）
    notify  notifyList   // 等待队列（ticket-based）
    checker copyChecker  // 运行时拷贝检测
}

type notifyList struct {
	wait atomic.Uint32 // 下一个等待者的票号（原子递增）
	notify uint32 // 下一个要通知的票号

	// 等待者列表
	lock mutex
	head *sudog
	tail *sudog
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251121205142864.png" alt=""></p>
<p>理解 <code>sync.Cond</code> 的关键，在于理解它如何解决<strong>虚假唤醒</strong>和<strong>消息丢失</strong>的问题。Go 使用了一种类似银行排号系统的逻辑。</p>
<pre><code>wait = 5, notify = 2
         ↓
当前排队: ticket 2, 3, 4 (还未通知)
即将排队: ticket 5, 6, 7... (新来的)
</code></pre>
<h3>6.3 c.Wait()</h3>
<p>当一个 Goroutine 调用 <code>Wait()</code> 时，发生了以下严密的步骤：</p>
<ol>
<li><strong>拿号 (Ticket Allocation)</strong>： 调用 <code>runtime_notifyListAdd</code>。这本质上是一个原子操作，将 <code>notifyList</code> 中的 <code>wait</code> 计数器加 1，并返回当前的序列号（Ticket）。</li>
<li><strong>解锁 (Unlock)</strong>： 调用 <code>c.L.Unlock()</code>。必须先拿号，再解锁。这保证了即使你在解锁后、睡觉前，有人发送了信号，你的号也已经排进去了，不会错过通知。</li>
<li><strong>睡觉 (Block)</strong>： 调用 <code>runtime_notifyListWait(Ticket)</code>，把自己挂起，等待有人喊&quot;第 100 号&quot;或者&quot;所有人&quot;醒来。</li>
<li><strong>重新加锁 (Lock)</strong>： 当被唤醒后，<code>Wait</code> 函数返回前，会<strong>自动</strong>调用 <code>c.L.Lock()</code>。</li>
</ol>
<pre><code class="language-go">func (c *Cond) Wait() {
    // 步骤 1: 拿号 (Ticket Allocation)
    // 关键点：在解锁之前先拿号！
    // 这保证了即使我还没睡着，Signal 发送者也能知道&quot;有一个持有 t 号的人正在赶来的路上&quot;。
    t := runtime_notifyListAdd(&amp;c.notify)

    // 步骤 2: 解锁 (Unlock User Lock)
    // 必须解锁，否则 Signal 的发送者无法获得锁来修改条件，死锁。
    c.L.Unlock()

    // 步骤 3: 入队并休眠 (Enqueue &amp; Park)
    runtime_notifyListWait(&amp;c.notify, t)

    // 步骤 4: 重新加锁 (Relock)
    // 醒来后，必须恢复到调用 Wait 前的状态，以便重新检查 for !condition()。
    c.L.Lock()
}

func notifyListAdd(l *notifyList) uint32 {
	// This may be called concurrently, for example, when called from
	// sync.Cond.Wait while holding a RWMutex in read mode.
	return l.wait.Add(1) - 1
}

func notifyListWait(l *notifyList, t uint32) {
	lockWithRank(&amp;l.lock, lockRankNotifyList)

  // 进入 notifyListWait 后，再次检查一下 l.notify），
  // 如果 l.notify &gt; t，说明已经被叫过了，
  // 那我就不睡了，直接返回。这完美解决了信号丢失问题。
	if less(t, l.notify) {
		unlock(&amp;l.lock)
		return
	}

	// 入队休眠
	s := acquireSudog()
	s.g = getg()
	s.ticket = t
	s.releasetime = 0
	t0 := int64(0)
	if blockprofilerate &gt; 0 {
		t0 = cputicks()
		s.releasetime = -1
	}
	if l.tail == nil {
		l.head = s
	} else {
		l.tail.next = s
	}
	l.tail = s
	goparkunlock(&amp;l.lock, waitReasonSyncCondWait, traceBlockCondWait, 3)
	if t0 != 0 {
		blockevent(s.releasetime-t0, 2)
	}
	releaseSudog(s)
}
</code></pre>
<h3>6.4 c.Signal()</h3>
<p>当调用 <code>Signal()</code> 时：</p>
<ol>
<li>调用 <code>runtime_notifyListNotifyOne</code>。</li>
<li>它会查找 <code>notifyList</code> 中<strong>最早</strong>那个还没被唤醒的 Ticket（比如第 99 号已醒，现在叫第 100 号）。</li>
<li>通过 <code>sema</code>（信号量）精确唤醒持有该 Ticket 的那个 Goroutine。</li>
</ol>
<pre><code class="language-go">func (c *Cond) Signal() {
	runtime_notifyListNotifyOne(&amp;c.notify)
}

func notifyListNotifyOne(l *notifyList) {
  // 如果 wait == notify，说明没有新的等待者，直接返回
	if l.wait.Load() == atomic.Load(&amp;l.notify) {
		return
	}

  // 获取锁，因为需要修改 notifylist
	lockWithRank(&amp;l.lock, lockRankNotifyList)

	// 双重检查，如果没有新的等待者，则直接返回
	t := l.notify
	if t == l.wait.Load() {
		unlock(&amp;l.lock)
		return
	}

	// 更新下一个 notify 的票号
	atomic.Store(&amp;l.notify, t+1)

	// 从 notifyList 尝试唤醒一个休眠中的 G
	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(&amp;l.lock)
			s.next = nil
			if s.g.bubble != nil &amp;&amp; getg().bubble != s.g.bubble {
				println(&quot;semaphore wake of synctest goroutine&quot;, s.g.goid, &quot;from outside bubble&quot;)
				fatal(&quot;semaphore wake of synctest goroutine from outside bubble&quot;)
			}
      // 唤醒
			readyWithTime(s, 4)
			return
		}
	}
	unlock(&amp;l.lock)
}

func readyWithTime(s *sudog, traceskip int) {
	if s.releasetime != 0 {
		s.releasetime = cputicks()
	}
	goready(s.g, traceskip)
}
</code></pre>
<h3>6.5 c.Broadcast()</h3>
<p>当调用 <code>Broadcast()</code> 时：</p>
<ol>
<li>调用 <code>runtime_notifyListNotifyAll</code>。</li>
<li>它不需一个一个叫，而是直接记下当前的 <code>wait</code> 计数器值（比如当前排到了 150 号）。</li>
<li>它会唤醒从&quot;当前已唤醒号&quot;到&quot;150 号&quot;之间的<strong>所有</strong> Goroutine。</li>
</ol>
<pre><code class="language-go">func (c *Cond) Broadcast() {
	runtime_notifyListNotifyAll(&amp;c.notify)
}

func notifyListNotifyAll(l *notifyList) {
	// 没有新的等待者，直接返回
	if l.wait.Load() == atomic.Load(&amp;l.notify) {
		return
	}

	// 上锁
	lockWithRank(&amp;l.lock, lockRankNotifyList)
  // 清空 notifyList，因为全部都会被唤醒
	s := l.head
	l.head = nil
	l.tail = nil

	// 更新 notify 为当前的 wait
	atomic.Store(&amp;l.notify, l.wait.Load())
	unlock(&amp;l.lock)

	// 唤醒旧的 notifyList 的所有 sudog
	for s != nil {
		next := s.next
		s.next = nil
		if s.g.bubble != nil &amp;&amp; getg().bubble != s.g.bubble {
			println(&quot;semaphore wake of synctest goroutine&quot;, s.g.goid, &quot;from outside bubble&quot;)
			fatal(&quot;semaphore wake of synctest goroutine from outside bubble&quot;)
		}
    // 唤醒
		readyWithTime(s, 4)
		s = next
	}
}
</code></pre>
<h3>6.6 总结</h3>
<pre><code class="language-mermaid">graph TB
    A[sync.Cond 核心机制]

    A --&gt; B[Ticket 系统&lt;br/&gt;wait &amp; notify]
    A --&gt; C[三步原子操作&lt;br/&gt;Add→Unlock→Wait]
    A --&gt; D[按序唤醒&lt;br/&gt;FIFO]

    B --&gt; E[防止丢失唤醒]
    C --&gt; F[保证 happens-before]
    D --&gt; G[公平性]

    style A fill:#ffcccc
    style B fill:#e1f5ff
    style C fill:#fff4e1
    style D fill:#ccffcc
</code></pre>
<p><code>sync.Cond</code> 的核心设计：</p>
<ul>
<li><strong>Ticket 系统</strong>：基于票号的通知机制，防止丢失唤醒</li>
<li><strong>三步原子操作</strong>：Add→Unlock→Wait，顺序不能错</li>
<li><strong>必须循环 Wait</strong>：防止虚假唤醒和竞态条件</li>
<li><strong>关联 Locker</strong>：Wait 自动释放和重新获取锁</li>
</ul>
<h2>7. 排查锁异常问题</h2>
<h3>7.1 锁拷贝 go vet</h3>
<pre><code class="language-go">m := sync.Mutex{}
m.Lock()
n := m // n 拷贝 m
m.Unlock()
n.Lock()  // 这里会报错，因为 n 在拷贝 m 的时候，把它已经 lock 的状态也拷贝了
</code></pre>
<p>这个时候，可以用 Go 提供的 <code>go vet</code> 工具来检查是否存在锁拷贝问题：</p>
<pre><code class="language-shell">➜ go vet main.go
# command-line-arguments
./main.go:16:7: assignment copies lock value to n: sync.Mutex
</code></pre>
<blockquote>
<p><code>go vet</code> 还能检测可能的 bug 和可疑的构造。</p>
</blockquote>
<h3>7.2 数据竞争问题 - go build -race</h3>
<pre><code class="language-go">// 此处 i 有并发问题
func add(i *int32) {
	*i++
}

func main() {
	c := int32(0)
	for i := 0; i &lt; 100; i++ {
		go add(&amp;c)
	}
	time.Sleep(time.Second)
}
</code></pre>
<p>这个时候，可以用 Go 提供的 <code>go build -race</code> 工具来检查是否存在数据竞争问题：</p>
<pre><code class="language-shell">➜  go build -race main.go
➜  ./main
==================
WARNING: DATA RACE
Read at 0x00c000124000 by goroutine 7:
  main.add()
      /Users/hedon-/goProjects/leetcode/go_advance/13-mutex/atomic/main.go:6 +0x3a

Previous write at 0x00c000124000 by goroutine 6:
  main.add()
      /Users/hedon-/goProjects/leetcode/go_advance/13-mutex/atomic/main.go:6 +0x4e

Goroutine 7 (running) created at:
  main.main()
      /Users/hedon-/goProjects/leetcode/go_advance/13-mutex/atomic/main.go:12 +0x84

Goroutine 6 (finished) created at:
  main.main()
      /Users/hedon-/goProjects/leetcode/go_advance/13-mutex/atomic/main.go:12 +0x84
==================
Found 1 data race(s)
</code></pre>
<h3>7.3 死锁 go-deadlock</h3>
<ul>
<li><a href="https://github.com/sasha-s/go-deadlock">https://github.com/sasha-s/go-deadlock</a></li>
</ul>
<h2>8. 再次看 Go 锁的两大基础</h2>
<p>在分析完 Go 的各种并发工具之后，相信不少读者都能理解为什么 atomic 和 sema 是 Go 锁的两大基础了。</p>
<pre><code class="language-mermaid">graph TB
    subgraph &quot;用户层并发工具&quot;
        Mutex[sync.Mutex]
        RWMutex[sync.RWMutex]
        WaitGroup[sync.WaitGroup]
        Cond[sync.Cond]
        Once[sync.Once]
        Pool[sync.Pool]
        Chan[Channel]
    end

    subgraph &quot;Runtime 基础原语&quot;
        Atomic[Atomic 原子操作]
        Sema[Semaphore&lt;br/&gt;sleep/wakeup]
    end

    Mutex --&gt; Atomic
    Mutex --&gt; Sema
    RWMutex --&gt; Atomic
    RWMutex --&gt; Sema
    WaitGroup --&gt; Atomic
    WaitGroup --&gt; Sema
    Cond --&gt; Sema
    Once --&gt; Atomic
    Pool --&gt; Atomic
    Chan --&gt; Atomic
    Chan --&gt; Sema

    style Atomic fill:#ffcccc
    style Sema fill:#e1f5ff
</code></pre>
<p>还是前面那句话：</p>
<blockquote>
<p>[!IMPORTANT]</p>
<p>atomic 提供无锁的快速状态管理（CAS、加减），sema 提供有竞争时的 goroutine 休眠/唤醒机制，两者组合实现&quot;乐观尝试 + 悲观等待&quot;的高效并发模型。</p>
</blockquote>
<pre><code class="language-mermaid">graph LR
    subgraph &quot;性能层级&quot;
        A[atomic&lt;br/&gt;纳秒级&lt;br/&gt;99% 场景]
        B[sema&lt;br/&gt;微秒级&lt;br/&gt;1% 竞争]
    end

    A --&gt;|无竞争| Fast[Fast Path]
    A --&gt;|低竞争&lt;br/&gt;自旋| Spin[Spin]
    B --&gt;|高竞争| Slow[Slow Path&lt;br/&gt;休眠/唤醒]

    style A fill:#ccffcc
    style B fill:#e1f5ff
    style Fast fill:#90EE90
    style Slow fill:#FFB6C1
</code></pre>
<p>这里笔者再次梳理下各个并发工具的如何运用 atomic 和 sema 的：</p>
<ul>
<li><p><code>sync.Mutex</code></p>
<pre><code class="language-go">type Mutex struct {
    state int32   // ← Atomic 操作的目标
    sema  uint32  // ← Semaphore 使用的地址
}

// Lock 流程：
// 1. atomic.CAS(state, 0, 1)           ← Atomic 快速路径
// 2. 失败 → 自旋 + atomic 操作          ← Atomic 重试
// 3. 还失败 → semacquire(&amp;sema)        ← Semaphore 休眠

// Unlock 流程：
// 1. atomic.Add(state, -1)             ← Atomic 快速路径
// 2. 有等待者 → semrelease(&amp;sema)      ← Semaphore 唤醒
</code></pre>
</li>
<li><p><code>sync.RWMutex</code></p>
<pre><code>Atomic: 管理 state（锁定/唤醒/饥饿/等待者）
Sema:   竞争时休眠/唤醒
</code></pre>
</li>
<li><p><code>sync.WaitGroup</code></p>
<pre><code>Atomic: 管理 reader 计数和 writer 等待标志
Sema:   writer 等待、reader 等待（两个独立的 sema）
</code></pre>
</li>
<li><p><code>sync.Once</code></p>
<pre><code>Atomic: 管理缓冲区索引、状态标志
Sema:   发送/接收阻塞时休眠/唤醒
</code></pre>
</li>
<li><p><code>sync.Cond</code></p>
<pre><code class="language-go">Atomic: 管理计数器（Add/Done）
Sema:   Wait() 时如果计数 &gt; 0 则休眠
</code></pre>
</li>
<li><p><code>Channel</code></p>
<pre><code class="language-go">Atomic: (底层 Mutex 用)
Sema:   Wait() 休眠，Signal/Broadcast 唤醒
</code></pre>
</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Go 底层原理丨垃圾回收（green tea gc）</title>
      <link>https://hedon.top/blog/go-gc-green-tea-gc/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-gc-green-tea-gc/</guid>
      <pubDate>Wed, 19 Nov 2025 21:30:00 GMT</pubDate>
      <description>本文基于 Go 官方资料和社区解读，全面分析 Go 1.26 起默认启用的 Green Tea GC 垃圾回收机制，包括其解决 CPU Cache Miss 的核心原理、算法的微架构优化设计、对开发实务和未来硬件的深远影响，帮助读者理解新 GC 的革命性变革。</description>
      <category>Go</category><category>垃圾回收</category><category>GreenTea</category>
      <content:encoded><![CDATA[<p>前篇 <a href="https://hedon.top/2025/11/17/go/go-gc/">Go 底层原理丨垃圾回收（三色标记法）</a>我们详细介绍了截止 Go1.25 版本中 Go 一直使用的基于三色标记法的垃圾回收算法。在 2025 年 10 月 29 日 Go 官方博客发布了一篇 <a href="https://go.dev/blog/greenteagc">The Green Tea Garbage Collector</a> 介绍了其将在 Go1.26 版本默认开启的最新垃圾回收算法，当然，在 Go1.25 也可以通过 <code>GOEXPERIMENT=greenteagc</code> 实验标识提前开启进行体验。</p>
<p>推荐可以提前看一遍 Go 官方成员在 Youtube 发布的对 Green Tea GC 的基本介绍视频：</p>
<p><a href="https://www.youtube.com/watch?v=gPJkM95KpKo">观看 YouTube 视频</a></p>
<p>本篇将基于上面的视频和我跟 Gemini 2.5Pro 的探讨，尝试从第一性原理来理解为什么要设计 Green Tea GC 算法、以及其底层是如何实现的。</p>
<p>这里先给出结论：</p>
<blockquote>
<p>[!IMPORTANT]</p>
<p>针对传统 GC 随机指针追逐导致频繁 CPU Cache Miss 的问题，Green Tea GC 采取 <strong>利用 FIFO 队列延迟累积、将操作粒度从单对象提升至物理连续 Span</strong> 的方法，达到将随机访问转化为缓存友好的批量顺序扫描、通过最大化空间局部性大幅提升吞吐量的效果。</p>
</blockquote>
<h2>1. 冯·诺依曼瓶颈与现代计算的微架构危机</h2>
<p>在过去二十年的高性能计算演进中，硬件架构的发展呈现出一种极不均衡的态势。虽然摩尔定律在晶体管密度和核心数量上的预测在很大程度上得以维持，但动态随机存取存储器（DRAM）的访问延迟并未随之线性缩减。这种处理器时钟速度与内存访问速度之间日益扩大的差距，被称为<strong>内存墙（Memory Wall）</strong>。对于像 Go 语言这样依赖自动内存管理的现代编程语言而言，内存墙已不再是一个理论上的瓶颈，而是阻碍吞吐量提升的物理现实。</p>
<p>传统的垃圾回收算法，特别是 Go 长期采用的三色并发标记清除算法，在本质上是图论中的遍历问题。算法将堆内存视为一个抽象的图，节点是对象，边是某种形式的指针引用。虽然这种抽象在数学上是优雅的，但在物理实现上，它与现代 CPU 的缓存层次结构（L1、L2、L3 Cache）和转换后备缓冲器（TLB）不仅不兼容，甚至常常处于对立状态。根据 Go 核心团队的分析，<u>传统的 GC 扫描循环中，超过 35% 的 CPU 周期并不是在执行有效的标记指令，而是完全停滞，处于等待内存数据从主存取回的&quot;空转&quot;状态</u>。</p>
<p>随着 Go 1.25 实验性功能的发布，一种代号为 Green Tea 的全新垃圾回收架构应运而生。该算法标志着 Go 运行时设计哲学的一个根本性转变：从关注抽象的对象图遍历效率，转向对底层物理内存布局的极致利用。本篇将对 Green Tea 算法进行详尽的技术拆解，分析其如何通过以&quot;页&quot;（Page）或&quot;跨度&quot;（Span）为中心的扫描机制、先进的位图差分算法以及对 FIFO（先进先出）工作队列的创新利用，来系统性地瓦解内存墙带来的性能桎梏。</p>
<h2>2. 传统标记-清除算法的微架构缺陷分析</h2>
<p>要理解 Green Tea 算法的革命性，必须首先深入剖析传统算法在现代硬件上的病理表现。Go 现有的 GC 采用的是基于对象的图洪泛（Graph Flood）算法。在这个过程中，垃圾回收器从根对象（栈、全局变量）出发，递归地追踪所有可达的指针。</p>
<h3>2.1 &quot;城市街道&quot;困境与随机访问代价</h3>
<p>Go 团队将传统 GC 的内存访问模式形象地比喻为&quot;城市街道&quot;（City Streets）上的导航。在这种模式下，内存访问具有高度的随机性和不可预测性：</p>
<ol>
<li><strong>空间局部性的缺失</strong>：在堆内存中，逻辑上相互引用的对象（例如链表中的节点或树结构）在物理地址空间中往往是不连续的。当 GC 追踪一个指针时，它往往需要跳转到一个完全不同的内存页。<u>这种跳转导致了 CPU 缓存行的频繁失效（Cache Miss），因为加载包含当前对象的缓存行对于处理下一个对象毫无帮助</u>。</li>
<li><strong>延迟链（Latency Chains）效应</strong>：在图遍历过程中，只有当当前对象被加载并解析后，GC 才能知道下一个需要扫描的对象的地址。这种严格的数据依赖性使得现代 CPU 强大的乱序执行（Out-of-Order Execution）和硬件预取（Hardware Prefetching）机制失效。CPU 无法推测下一个地址在哪里，因此无法提前将数据拉入缓存。</li>
<li><strong>TLB 抖动</strong>：频繁的跨页访问不仅影响数据缓存，还会对 TLB 造成巨大压力，导致虚拟地址到物理地址的转换延迟显著增加。</li>
</ol>
<h3>2.2 停顿周期的量化分析</h3>
<p>根据 GitHub 上关于 Go 运行时问题的详细追踪（Issue 73581），这种随机访问模式导致 GC 扫描循环的效率极低。在总体的垃圾回收时间中，约 85% 被消耗在扫描循环（Scan Loop）中，而在这些宝贵的计算时间内，<u>CPU 实际上有超过三分之一的时间是在空等数据</u>。这种微架构层面的低效，意味着单纯增加 CPU 核心数或提高主频，已无法线性地提升 GC 的性能，因为瓶颈已经转移到了内存子系统。</p>
<h2>3. Green Tea 的核心架构：从对象中心到跨度中心</h2>
<p>Green 算法的核心假设是：通过牺牲图遍历的即时性（即不立即处理发现的指针），转而对内存操作进行批量化管理，可以重建内存访问的空间局部性。这一策略将 GC 的基本操作单元从单个&quot;对象&quot;提升到了物理上连续的内存块——&quot;跨度&quot;（Span），即我们上篇提到的 <code>scanspan()</code>。</p>
<h3>3.1 跨度（Span）与小对象特化策略</h3>
<p>在 Go 的内存分配器中，跨度是管理内存的基本单位，通常是 8 KiB 的倍数。Green Tea 算法并非全盘替代现有的 GC，而是一个针对特定问题的特化增强。它专门针对&quot;小对象&quot;（Small Objects，定义为大小不超过 512 字节）进行优化。</p>
<p>为何专注于小对象？</p>
<p>大对象通常占据较大的连续内存空间，其扫描过程天然具有一定的顺序性。然而，小对象是造成内存碎片化和指针跳跃的主要元凶。在一个 8 KiB 的页面中，可能挤满了数百个 32 字节的小对象。如果按照传统的图遍历方式，GC 可能会在这个页面访问一个对象，然后跳到几 GB 外的另一个页面，稍后又跳回来访问该页面的另一个对象。这种反复横跳是缓存杀手。</p>
<p>Green Tea 算法强制将处理粒度对齐到 8 KiB 的跨度上。对于小对象，算法不再维护对象的全局工作列表，而是维护 <code>包含待扫描对象 span</code> 列表。由于这些 <code>span</code> 是严格 8 KiB 对齐的，GC 可以通过简单的指针算术（掩码操作）快速定位元数据，而无需昂贵的查表操作或依赖性内存加载。</p>
<h3>3.2 位图差分与惰性累积</h3>
<p>在技术演示视频的 15:39 处，展示了 Green Tea 算法最核心的机制：基于页面的元数据管理和位图差分逻辑。这是一个精妙的设计，旨在最大化单次内存加载的有效工作量。</p>
<p>传统的 GC 使用标记位来记录对象是否存活。而 Green Tea 引入了更复杂的双位图系统，对于 <code>span</code> 中的每个对象槽位，维护两个状态位：</p>
<ol>
<li><strong>Seen Bit（已见位）</strong>：表示该对象已被其他存活对象引用，即它是可达的。在三色标记法中，这相当于对象被染成了“灰色”。</li>
<li><strong>Scanned Bit（已扫位）</strong>：表示该对象不仅可达，而且 GC 已经扫描了该对象内部包含的所有指针。在三色标记法中，这相当于对象被染成了黑色。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/greentea-060.png" alt=""></p>
<p>工作流程与逻辑推导：</p>
<p>当 GC 发现一个指向小对象的指针时，它不会立即递归扫描该对象，而是执行以下操作：</p>
<ol>
<li><strong>设置 Seen Bit</strong>：在目标对象所属跨度的元数据中，将对应的 Seen Bit 置为 <code>1</code>。</li>
<li><strong>入队检查</strong>：检查该跨度是否已经在工作队列中。如果不在，则将整个 <code>span</code> 推入工作队列。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/greentea-066.png" alt=""></p>
<p>当工作线程最终从队列中取出该跨度时，算法执行一种差分操作来确定需要做哪些工作。在 15:39 的示例中，演示者通过页面 A 的状态展示了这一逻辑：</p>
<ul>
<li><strong>计算 Delta</strong>：待处理对象集合 $O$ 等于 $Seen$ 位图与 $Scanned$ 位图的差集。用布尔代数表示为：$O = Seen \land (\neg Scanned)$。</li>
<li><strong>批量处理</strong>：算法仅扫描那些 $Seen$ 为 1 且 $Scanned$ 为 0 的对象。</li>
<li><strong>状态更新</strong>：扫描完成后，将 $Seen$ 位图的状态复制到 $Scanned$ 位图中，即 $Scanned \leftarrow Seen$。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251119222527760.png" alt=""></p>
<p>惰性累积（Lazy Accumulation）的价值：</p>
<p>这种机制的关键在于“延迟”。由于跨度在队列中停留了一段时间，当它被取出处理时，可能已经有多个对象被标记为 <code>Seen</code>。例如，演示中提到在处理页面 A 时，一次性处理了三个对象。这意味着加载该页面元数据的昂贵开销被这三个对象分摊了。更重要的是，如果在这期间有重复的引用指向同一个对象，差分逻辑会自动忽略已处理的对象，天然避免了重复扫描。</p>
<h3>3.3 先进先出（FIFO）队列与&quot;高速公路&quot;效应</h3>
<p>为了最大化上述的惰性累积效应，Green Tea 算法颠覆了传统深度优先搜索（DFS）常用的后进先出（LIFO/Stack）模式，转而采用先进先出（FIFO/Queue）模式。</p>
<ul>
<li><strong>LIFO 的局限</strong>：虽然 LIFO 有助于保持 CPU 缓存的热度（刚发现的对象立即被处理），但在图结构松散的堆内存中，这种局部性往往是虚幻的。</li>
<li><strong>FIFO 的优势</strong>：FIFO 强制让跨度在队列中&quot;陈酿&quot;。在跨度等待被调度的过程中，应用程序和其他 GC 线程可能会发现更多指向该跨度内对象的指针。当该跨度最终被处理时，其工作密度达到了最大化。</li>
</ul>
<p>这种策略将零散的内存访问转化为连续的、高密度的内存操作流。Go 团队将其比喻为从&quot;城市街道&quot;驶上了&quot;高速公路&quot;（Highway）。在高速公路上，车辆（内存操作）首尾相接，全速前行。通过处理整个页面，GC 能够利用 CPU 的预取器，连续加载相邻的缓存行，极大地提升了内存带宽的利用率。</p>
<p>下表详细对比了传统图洪泛策略与 Green Tea 内存感知策略的关键技术指标：</p>
<table>
<thead>
<tr>
<th><strong>特性维度</strong></th>
<th><strong>传统图洪泛 GC (Go 1.24 及以前)</strong></th>
<th><strong>绿茶 GC (Go 1.25 实验性)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>基本调度单元</strong></td>
<td>单个对象 (Object)</td>
<td>内存跨度 (Span / 8 KiB Page)</td>
</tr>
<tr>
<td><strong>遍历顺序</strong></td>
<td>LIFO (栈) / 近似深度优先</td>
<td>FIFO (队列) / 广度优先延迟处理</td>
</tr>
<tr>
<td><strong>内存访问模式</strong></td>
<td>随机跳跃 (城市街道)</td>
<td>批量连续 (高速公路)</td>
</tr>
<tr>
<td><strong>元数据位置</strong></td>
<td>全局或分散在对象头</td>
<td>集中在跨度元数据区</td>
</tr>
<tr>
<td><strong>缓存利用策略</strong></td>
<td>依赖时间局部性 (Temporal Locality)</td>
<td>强制构建空间局部性 (Spatial Locality)</td>
</tr>
<tr>
<td><strong>主要性能瓶颈</strong></td>
<td>内存延迟 (Latency)</td>
<td>内存带宽 (Bandwidth)</td>
</tr>
<tr>
<td><strong>适用场景</strong></td>
<td>通用，对大对象友好</td>
<td>特化针对小对象密集型场景</td>
</tr>
</tbody></table>
<h3>3.4 针对单对象的微优化：代表对象与命中标志</h3>
<p>尽管页面级扫描在大规模数据下效率极高，但在某些边缘情况（例如一个跨度中仅有一个活跃对象）下，加载整个页面元数据的开销可能超过直接扫描对象的收益。为了解决这个问题，研发团队引入了&quot;代表对象&quot;（Representative）和&quot;命中标志&quot;（Hit Flag）机制。</p>
<ul>
<li><strong>代表对象</strong>：当一个跨度第一次被加入队列时，触发该操作的那个特定对象被记录为&quot;代表&quot;。</li>
<li><strong>命中标志</strong>：如果在该跨度等待期间，有第二个不同的对象被标记为 Seen，则设置&quot;命中标志&quot;。</li>
<li><strong>快速路径</strong>：当工作线程取出跨度时，首先检查命中标志。如果标志未设置，说明该跨度仅有一个待处理对象。此时，GC 会跳过复杂的位图差分计算，直接扫描&quot;代表对象&quot;。这种回退机制确保了 Green Tea 算法在最坏情况下的性能也能逼近传统算法。</li>
</ul>
<h2>4. 生产环境的现实：性能收益与延迟倒挂</h2>
<p>Green Tea 算法并非银弹，其在真实生产环境中的表现呈现出复杂的权衡关系。根据 Google 内部及早期采用者的反馈，该算法在 CPU 吞吐量和请求延迟之间引入了新的变量。</p>
<h3>4.1 吞吐量的显著提升</h3>
<p>在基准测试和大规模内存密集型应用中，Green Tea 算法展现了强大的吞吐量优势。报告显示，GC 阶段的 CPU 消耗总体减少了 10% 到 40%。对于拥有数万台服务器的超大规模数据中心而言，这种 CPU 效率的提升直接转化为巨大的硬件成本节省和能源效率优化。这验证了解决&quot;内存墙&quot;问题对于提升现代软件性能的决定性作用。</p>
<h3>4.2 延迟倒挂现象</h3>
<p>然而，InfoQ 和 Github Issue 73581 中的讨论揭示了一个反直觉的现象：部分应用在启用 <code>GOEXPERIMENT=greenteagc</code> 后，虽然 GC 运行的频率降低了，但单次 GC 循环的 CPU 占用率却上升了，导致应用程序的长尾延迟（Tail Latency）恶化。</p>
<p><strong>原因分析：</strong></p>
<ol>
<li><strong>工作的突发性</strong>：由于 FIFO 队列和惰性累积机制，当一个跨度最终被处理时，它可能包含了大量积累的工作。处理一个包含数百个对象的跨度，远比处理单个对象要耗时。这种批处理特性导致了 GC 工作的突发性增强。</li>
<li><strong>应用层的感知</strong>：虽然总的 GC 时间变短了，但这种高密度的 CPU 占用可能会在短时间内挤占应用逻辑（Mutator）的计算资源，特别是在 GOMAXPROCS 限制较紧的容器环境中。</li>
<li><strong>调度器争用</strong>：Green Tea 采用了分布式的工作窃取（Work-Stealing）队列来替代全局锁，虽然减少了锁竞争，但在某些负载下，跨核的工作窃取可能会导致缓存一致性流量增加。</li>
</ol>
<p>针对这一问题，Go 团队已经在着手优化，预计在 Go 1.26 版本中通过调整批处理的粒度和队列调度的启发式算法来平滑这种延迟尖峰。</p>
<h3>4.3 容器环境下的微架构干扰</h3>
<p>社区反馈还指出，在 Docker 等容器化环境中，CPU 配额（CPU Quota）的设置可能会干扰 GC 的行为。在 Go 1.25 之前，<code>GOMAXPROCS</code> 默认是基于宿主机的逻辑核心数，而非容器的配额。这导致在受限容器中，GC 线程可能会因为争抢时间片而加剧延迟。Green Tea 算法的高密度计算特性可能会放大这种资源争夺，特别是在 CPU 节流（Throttling）发生时。因此，配合 <code>uber-go/automaxprocs</code> 等库正确设置线程数，对于发挥 Green Tea 算法的优势至关重要。</p>
<h2>5. 代码生成与编译器级的微优化</h2>
<p>除了运行时的架构调整，Green Tea 算法的引入还伴随着编译器层面的深度优化。GitHub Issue 76212 揭示了一个关于 <code>heapBitsSmallForAddrInline</code> 函数的优化细节。</p>
<p>在扫描小对象的热路径（Hot Path）中，<code>scanObjectsSmall</code> 函数会频繁调用 <code>heapBitsSmallForAddrInline</code> 来获取对象的元数据位。在早期的实现中，这个内联函数包含了一些重复计算。由于在处理同一个 <code>span</code> 时，基地址和对象大小是固定不变的，编译器团队通过手动将这些循环不变量（Loop Invariants）提取到循环外部，消除了冗余的指令执行。</p>
<p>这种微优化虽然在代码层面看似微不足道，但在每秒执行数十亿次的 GC 循环中，它对指令流水线的通畅起到了关键作用。基准测试显示，这种手动提升（Hoisting）在多种架构上都带来了统计学上显著的性能提升，且没有引起回归。这体现了系统编程中毫秒必争的优化哲学。</p>
<h2>6. 为什么叫 Green Tea</h2>
<p>在计算机科学的历史中，重大的架构变革往往伴随着富有轶事色彩的命名。Green Tea 也不例外。该项目的命名并非源自任何技术缩写，而是源自其主要设计者 Austin Clements 的一段生活经历。</p>
<p>2024 年，Go 团队的技术负责人 Austin Clements 在日本期间，构思并开发了该算法的早期原型。为了验证基于跨度的扫描是否可行，他需要在不同的咖啡馆之间穿梭工作（Cafe Crawling）。据 Austin 本人回忆，在攻克算法核心难题的那段时间里，他摄入了大量的抹茶（Matcha）。这种富含咖啡因的绿茶（Green Tea）成为了项目诞生的燃料。</p>
<p>当原型证明了核心想法的可行性后，Green Tea 这个代号便自然而然地保留了下来，成为了 Go 语言对抗内存墙这一技术挑战的文化符号。这与 Java 的&quot;Oak&quot;（橡树）或 Android 的甜点命名传统一脉相承，赋予了冷冰冰的代码以人文温度。</p>
<h2>7. 硬件协同的未来：SIMD 与向量化</h2>
<p>Green Tea 算法的跨度中心设计，不仅解决了当前的缓存问题，更为未来的硬件加速铺平了道路。其中最令人兴奋的前景是利用单指令多数据（SIMD）指令集（如 x86 的 AVX-512 或 ARM64 的 NEON）来加速垃圾回收。</p>
<h3>7.1 向量化的可能性</h3>
<p>在传统的对象图遍历中，由于内存地址的随机性，根本无法利用 SIMD 指令。你无法向量化一个随机游走（Random Walk）的过程。然而，Green Tea 算法改变了这一局面：</p>
<ul>
<li><strong>连续的元数据</strong>：Seen 和 Scanned 位图在内存中是连续存储的。这意味着 15:39 演示中的位图差分操作（AND, OR, XOR）可以被简单地映射为向量指令。一条 512 位的 AVX-512 指令可以在单个时钟周期内处理数百个对象的状态更新。</li>
<li><strong>扫描内核的加速</strong>：除了元数据处理，核心团队还在探索使用 SIMD 来加速扫描内核本身。通过将多个指针的检查并行化，可以进一步压缩扫描时间。</li>
</ul>
<h3>7.2 集中器网络（Concentrator Network）</h3>
<p>GitHub Issue 73581 中还提到了一个更具野心的构想——使用&quot;集中器网络&quot;。这是一个排序网络，旨在提高指针的密度。通过在内存中重新排列指针或元数据，使其更加紧凑，可以为 SIMD 指令提供更高效的数据输入，从而在元数据操作之外，也能利用向量化加速。尽管由于复杂性原因，这一特性尚未包含在当前的实验版本中，但它指明了 Go 运行时未来的演进方向：极致的硬件亲和性。</p>
<h2>8. 结论与展望</h2>
<p>Go 1.25 引入的 Green Tea 垃圾回收算法，不仅是一次运行时的升级，更是对高性能计算未来趋势的一次深刻回应。它承认了在内存墙面前，算法的理论纯洁性必须向硬件的物理现实低头。</p>
<p>通过将分析单元从对象转移到跨度，并采用 FIFO 驱动的惰性累积策略，Green Tea 成功地将垃圾回收过程中混乱的随机访问（城市街道）转化为高效的顺序流（高速公路）。15:39 演示中的位图差分机制，以其简洁的逻辑展示了这种架构的优雅——不是通过复杂的图论技巧，而是通过对 CPU 缓存层次结构的极致尊重来消除冗余工作。</p>
<p>尽管目前仍存在延迟倒挂等需要微调的工程挑战，但 Green Tea 架构所展现出的 10-40% 的 CPU 节约潜力，以及其对 NUMA 架构和 SIMD 指令集的天然亲和力，使其成为 Go 语言在后摩尔定律时代保持竞争力的关键基石。随着 Go 1.26 及后续版本的迭代，我们有理由相信，这种内存感知的 GC 设计将成为管理语言运行时的新标准。</p>
<p>对于开发者而言，理解 Green Tea 不仅仅是为了调整 <code>GOGC</code> 或 <code>GOMAXPROCS</code> 参数，更是为了理解现代软件工程的一个核心真理：软件的性能极限，最终取决于它对底层硬件的理解与尊重。</p>
]]></content:encoded>
    </item>
    <item>
      <title>Go 底层原理丨垃圾回收（三色标记法）</title>
      <link>https://hedon.top/blog/go-gc-tri-color-marking/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-gc-tri-color-marking/</guid>
      <pubDate>Mon, 17 Nov 2025 15:30:00 GMT</pubDate>
      <description>本文深入剖析 Go 语言中的三色标记法垃圾回收机制，从基本原理、核心算法到运行时实现细节，系统讲解可达性分析、对象标记过程、并发与屏障机制等关键技术，帮助读者全面理解 Go GC 的设计理念与实际应用场景。</description>
      <category>Go</category><category>垃圾回收</category><category>三色标记法</category>
      <content:encoded><![CDATA[<blockquote>
<p>特此声明，本篇是笔者基于 Go 1.25.3 版本源码、并与 Google Gemini 3Pro 共创所作，非常庆幸在当今 AI 时代下获取知识已是如此便利，且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑，均由笔者把控和审阅，欢迎放心阅读。</p>
</blockquote>
<h2>1. 垃圾回收</h2>
<p>抛开具体的语言，垃圾回收（GC）在计算机科学中解决的核心问题只有一个：<strong>对象生命周期的自动化管理</strong>。</p>
<p>如果手动管理内存（如 C/C++ 的 <code>malloc/free</code>），我们面临的是由于&quot;人为疏忽&quot;导致的两个极端错误：</p>
<ul>
<li><strong>悬挂指针（Dangling Pointer）</strong>：过早释放，导致后续访问出错。</li>
<li><strong>内存泄漏（Memory Leak）</strong>：忘记释放，导致资源耗尽。</li>
</ul>
<p>GC 的出现，是为了将&quot;判断内存是否不再使用&quot;这个逻辑，从<strong>业务代码</strong>剥离，下沉到<strong>运行时（Runtime）</strong>。</p>
<p>从大的方面来讲，实现垃圾回收主要是要解决 2 个问题：</p>
<ol>
<li>怎么判断哪些对象是垃圾？</li>
<li>如何清理垃圾？</li>
</ol>
<h3>1.1 垃圾搜索算法</h3>
<p>从原理上讲，一个对象被判定为垃圾，意味着<strong>当前程序的后续执行中，再也无法访问到它了</strong>。这在计算机科学中被称为对象存活性（Object Liveness）问题。</p>
<p>主要有 2 个思路：引用计数法和可达性分析。</p>
<h4>1.1.1 引用计数法</h4>
<ul>
<li>给每个对象贴一个计数器。只要有一个地方引用它，计数器就 +1；引用失效（比如指针置空或离开作用域），计数器就 -1。当计数器归零时，该对象即为垃圾。一旦变成垃圾，立刻就能被回收，不需要等待特定的 GC 时间点。</li>
<li>但是存在<strong>循环引用</strong>的缺陷：假如对象 A 引用 B，B 也引用 A，除此之外没有其他人引用它们。虽然它们在外部已经无法访问（本质是垃圾），但它们互相揪着对方，计数器永远是 1，导致内存泄漏。</li>
<li>CPython（Python 的解释器）的主力 GC 机制就是引用计数，但它配合了&quot;标记-清除&quot;来专门处理循环引用问题。PHP 和 C++ 的 <code>std::shared_ptr</code> 也是基于此思路。</li>
</ul>
<h4>1.1.2 可达性分析</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5upnu7sa5j21fg0laq4k.jpg" alt=""></p>
<ul>
<li>从根（GC Roots）节点向下搜索对象节点，搜索走过的路经称为引用链，当一个对象到根之间没有连通的话，则对象不可用。</li>
<li>可以作为 GC Roots 的对象通常是指那些<strong>肯定在使用中</strong>的对象：<ul>
<li>被栈上的指针引用；</li>
<li>被全局变量的指针引用；</li>
<li>被寄存器中的指针引用；</li>
</ul>
</li>
<li>可达性分析的核心挑战是在遍历过程中，如果程序还在运行（对象引用关系在变），图就在变，怎么保证准确性？传统的做法是 <strong>STW (Stop The World)</strong>，暂停所有用户线程专门来做 GC。现代的做法是 <strong>三色标记法 (Tri-color Marking)</strong>（如 Go 语言），允许 GC 线程和用户线程并发运行，用读写屏障（Barrier）技术来修正并发带来的标记误差，从而尽可能减少 STW 的时长。</li>
</ul>
<h3>1.2 垃圾回收算法</h3>
<p>找出了垃圾，下一步就是回收内存。这里的核心矛盾是：<strong>效率</strong> vs <strong>空间碎片</strong>。</p>
<h4>1.2.1 标记清理法</h4>
<p>算法分成 <strong>标记</strong> 和 <strong>清除</strong> 两个阶段，先标记出要回收的对象，然后统一回收这些对象。</p>
<ul>
<li>简单。</li>
<li>效率不高，标记和清除的效率都不高。</li>
<li>标记清除后会产生大量不连续的内存碎片，从而导致在分配大对象时触发 GC。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5upp3jgc0j21b00mmt8y.jpg" alt=""></p>
<blockquote>
<p>Go 使用的就是标记清除法</p>
<p>虽然普通的标记清除法会造成内存碎片的问题，但是由于 Go 的内存模型中，将内存天然划分成多个 span，所以不存在内存碎片问题。故 Go 用了这种实现简单的标记清除法。对于 Go 内存模型不熟悉的读者，可参阅：<a href="https://hedon.top/2025/11/17/go/go-memory-model/">Go 底层原理丨内存模型</a>。</p>
</blockquote>
<h4>1.2.2 标记复制法</h4>
<p>把内存分成<strong>两块完全相同的区域</strong>，每次使用其中一块，当一块使用完了，就把这块上还存活的对象拷贝到另外一块，然后把这块清除掉。</p>
<ul>
<li>实现简单、运行高效，不用考虑内存碎片的问题。</li>
<li>内存有些浪费。</li>
</ul>
<blockquote>
<p>JVM 实际实现中，是将内存分为一块较大的 Eden 区和两块较小的 Survivor 空间，每次使用 Eden 和一块 Survivor，回收时，把存活的对象复制到另外一块 Survivor。</p>
<p>HotSpot 默认的 Eden 和 Survivor 比是 8:1，也就是每次能用 90% 的新生代空间。</p>
<p>如果 Survivor 空间不够，就要依赖老年代进行分配担保，把放不下的对象直接进入老年代。</p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5upp2o16yj21au0m60sy.jpg" alt=""></p>
<h4>1.2.3 标记整理法</h4>
<p>标记过程跟标记清除一样，但后续不是直接清除可回收对象，而是让所有存活对象都向一端移动，然后直接清除边界以外的内存。</p>
<blockquote>
<p>标记整理法的开销较大，Java 的老年代就采用标记整理法，因为老年代的 GC 频率较低。</p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5upp1xvtvj21580lqgnd.jpg" alt=""></p>
<h2>2. 宏观概述</h2>
<p>在对 GC 有了一个简单的了解之后，我们先来详细了解 Go 语言的垃圾回收机制的宏观详细设计，在下一章节我们将在 AI 的帮助下，深入源码（Go1.25.3）去了解去背后的底层实现细节和那些令人叹为观止的优化思路。</p>
<p>截止 Go1.25，Go 还是使用的<strong>三色标记法 + 并发标记清理法 + 混合写屏障</strong>进行垃圾回收，Go 官方透露在 Go1.26 将默认开启 Green Tea GC，关于 Green Tea GC，将会在下篇进行详细展开。</p>
<h3>2.1 核心架构特征</h3>
<ul>
<li><strong>并发标记-清扫</strong>（Concurrent Mark-Sweep）</li>
<li><strong>类型精确</strong>（Type Accurate）：知道内存中哪些是指针</li>
<li><strong>写屏障</strong>（Write Barrier）：保证并发标记的正确性</li>
<li><strong>非分代</strong>（Non-generational）</li>
<li><strong>非压缩</strong>（Non-compacting）</li>
<li><strong>Per-P 分配</strong>：减少锁竞争</li>
</ul>
<h3>2.2 三色标记法</h3>
<h4>2.2.1 基本原理</h4>
<p>Go 将对象用三种颜色来进行标记：</p>
<ul>
<li><strong>黑色</strong>：本对象已经被 GC 访问过，且本对象的子引用对象也已经被访问过了</li>
<li><strong>灰色</strong>：本对象已访问过，但是本对象的子引用对象还没有被访问过，全部访问完会变成黑色，属于中间态</li>
<li><strong>白色</strong>：尚未被 GC 访问过的对象，如果全部标记已完成依旧为白色的，称为不可达对象，既垃圾对象</li>
</ul>
<h4>2.2.2 基本步骤</h4>
<ol>
<li>起初所有堆上的对象都是【白色】的；</li>
<li>将 GC Roots 直接引用到的对象挪到【灰色】中；</li>
<li>对【灰色】的对象进行根搜索算法：<ol>
<li>将该对象引用到的其他对象加入【灰色】中；</li>
<li>将自己挪到【黑色】中；</li>
</ol>
</li>
<li>重复 3 直到【灰色】为空；</li>
<li>回收【白色】中的对象。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5uq7o58zlg20jm0cjjuv.gif" alt=""></p>
<h4>2.2.3 删除屏障</h4>
<blockquote>
<p>并发标记时，对指针释放的白色对象置灰。</p>
</blockquote>
<p>这样可以避免在并发 GC 的过程中，由于指针的转移造成对象被误清。</p>
<p>比如一开始 B → C，当 B 在灰色集合的时候，释放了对 C 的指针，但是这个时候有一个在黑色集合的 E 指向了 C，也就是 E → C。由于 E 已经分析过了，所以在对 B 进行分析的时候，就会漏掉 C，导致后面 C 还是在白色集合中，就被误清了。</p>
<p>加入删除屏障后，C 会被强制置灰，就不会误清了。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5uqiowdqbj21e80awgm4.jpg" alt=""></p>
<h4>2.2.4 插入屏障</h4>
<blockquote>
<p>并发标记时，对指针新指向的白色对象置灰。</p>
</blockquote>
<p>这样可以避免在并发 GC 的过程中，误清掉指针新指向的对象。</p>
<p>比如一开始并没有指向 C 的对象，但是在 GC 过程中，E → C，但是由于 E 已经分析过了，已经进入黑色集合了，所以最后会漏掉 C，导致 C 被误清。</p>
<p>加入插入屏障后，C 会被强制置灰，就不会误清了。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5uqkss0rnj21je0buaam.jpg" alt=""></p>
<h3>2.3 GC 四阶段循环</h3>
<pre><code class="language-mermaid">graph TB
    %% 定义样式
    classDef stw fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#b71c1c;
    classDef concurrent fill:#e1f5fe,stroke:#0277bd,stroke-width:2px,color:#01579b;
    classDef trigger fill:#fff9c4,stroke:#fbc02d,stroke-dasharray: 5 5,color:#f57f17;

    %% 节点定义
    subgraph Cycle [GC 循环周期]
        direction TB
        P1(Phase 1: Sweep Termination&lt;br/&gt;清扫终止):::stw
        P2(Phase 2: Concurrent Mark&lt;br/&gt;并发标记):::concurrent
        P3(Phase 3: Mark Termination&lt;br/&gt;标记终止):::stw
        P4(Phase 4: Concurrent Sweep&lt;br/&gt;并发清扫):::concurrent
    end

    %% 触发条件
    Trigger(GC Trigger&lt;br/&gt;堆阈值/定时/手动):::trigger

    %% 连线关系
    Trigger --&gt; P1
    P1 --&gt;|开启写屏障&lt;br/&gt;SetGCPhase: _GCmark| P2
    P2 --&gt;|所有对象标记完成&lt;br/&gt;gcMarkDone| P3
    P3 --&gt;|关闭写屏障&lt;br/&gt;SetGCPhase: _GCoff| P4
    P4 --&gt;|清理结束 &amp; 等待下一轮| Trigger

    %% 补充说明
    note1[STW: 准备根对象, 清理上一轮残余] -.-&gt; P1
    note2[STW: 保证全局标记完成, 必须全局一致] -.-&gt; P3
</code></pre>
<h3>2.4 GC 触发机制</h3>
<ul>
<li><strong>堆大小触发</strong>：GOGC=100 时，堆增长 100% 触发（4M→8M）</li>
<li><strong>定时触发</strong>：sysmon 会定时检查，如果 2min 内没有进行 gc，那 runtime 就会进行一次 gc。</li>
<li><strong>手动触发</strong>：<code>runtime.GC()</code></li>
</ul>
<h2>3. 源码解析</h2>
<p>结论先行，整个 GC 的全景图如下所示：</p>
<pre><code>┌─────────────────────────────────────────────────────────────┐
│                    GC 周期完整流程                            │
└─────────────────────────────────────────────────────────────┘

触发 GC (gcStart)
    ├─ 检查触发条件 (gcTrigger.test)
    │   ├─ gcTriggerHeap: heapLive &gt;= trigger
    │   ├─ gcTriggerTime: 距上次GC &gt; 2分钟
    │   └─ gcTriggerCycle: 手动触发
    │
    ├─ 完成上一轮扫描 (sweepone)
    │
    └─ === 阶段 1: 扫描终止 (STW) ===
        ├─ stopTheWorld(stwGCSweepTerm)
        ├─ finishsweep_m()      // 完成剩余扫描
        ├─ clearpools()         // 清理 sync.Pool
        └─ gcResetMarkState()   // 重置标记状态

┌─────────────────────────────────────────────────────────────┐
│                    阶段 2: 并发标记                           │
└─────────────────────────────────────────────────────────────┘

    ├─ setGCPhase(_GCmark)      // 启用写屏障
    ├─ gcBgMarkPrepare()         // 准备后台工作者
    ├─ gcPrepareMarkRoots()      // 准备根对象扫描
    ├─ atomic.Store(&amp;gcBlackenEnabled, 1)  // 启用标记
    └─ startTheWorld()           // 恢复世界

    并发执行：
    ├─ 标记工作者 (gcBgMarkWorker)
    │   ├─ Dedicated Worker: 专用标记
    │   ├─ Fractional Worker: 分数标记
    │   └─ Idle Worker: 空闲标记
    │
    ├─ Mutator Assist (gcAssistAlloc)
    │   └─ 分配者协助标记以保持节奏
    │
    └─ 根对象扫描
        ├─ 扫描所有 goroutine 栈
        ├─ 扫描全局变量
        └─ 扫描 finalizer 队列

    工作循环：
    └─ while (有灰色对象) {
        obj = gcw.tryGetObj()  // 从队列获取灰色对象
        scanobject(obj, gcw)   // 扫描对象，标记引用
        // 将新发现的灰色对象加入队列
    }

┌─────────────────────────────────────────────────────────────┐
│              阶段 3: 标记终止检测 (gcMarkDone)                 │
└─────────────────────────────────────────────────────────────┘

检测循环：
    ├─ 条件: work.nwait == work.nproc &amp;&amp; !gcMarkWorkAvailable
    │
    ├─ === Ragged Barrier ===
    │   └─ forEachP: 刷新所有 P 的本地缓冲
    │       ├─ wbBufFlush1(pp)   // 写屏障缓冲
    │       └─ pp.gcw.dispose()  // 工作缓冲
    │
    ├─ 发现新工作？goto 检测循环
    │
    └─ === 标记终止 (STW) ===
        ├─ stopTheWorld(stwGCMarkTerm)
        ├─ 最后检查: 处理 ragged barrier 后的写屏障
        ├─ 发现新工作？startTheWorld, goto 检测循环
        └─ 确认完成

┌─────────────────────────────────────────────────────────────┐
│                 阶段 4: 并发扫描 (gcSweep)                    │
└─────────────────────────────────────────────────────────────┘

    ├─ atomic.Store(&amp;gcBlackenEnabled, 0)  // 禁用标记
    ├─ setGCPhase(_GCoff)                  // 禁用写屏障
    ├─ mheap_.sweepgen += 2                // 更新扫描代数
    └─ startTheWorld()                     // 恢复世界

    并发执行：
    ├─ 后台扫描 (bgsweep)
    │   └─ 循环调用 sweepone()
    │
    └─ 惰性扫描 (lazy sweep)
        └─ 分配时按需扫描 span

┌─────────────────────────────────────────────────────────────┐
│                   阶段 5: 等待下次触发                         │
└─────────────────────────────────────────────────────────────┘

    ├─ 计算下次触发点
    │   ├─ heapGoal = heapMarked * (1 + GOGC/100)
    │   └─ trigger = heapGoal - runway
    │
    └─ 在分配路径检查: heapLive &gt;= trigger
        └─ 是 → gcStart (回到顶部)
</code></pre>
<h3>3.1 GC 触发 gcStart()</h3>
<p>GC 的触发通过 <code>gcTrigger</code> 机制来检测三种条件：</p>
<pre><code class="language-go">type gcTrigger struct {
	kind gcTriggerKind
	now  int64  // gcTriggerTime: 当前时间
	n    uint32 // gcTriggerCycle: 要启动的周期编号
}

const (
	// gcTriggerHeap: 当堆大小达到控制器计算的触发堆大小时启动
	gcTriggerHeap gcTriggerKind = iota

	// gcTriggerTime: 距离上次GC超过 forcegcperiod (2分钟) 时启动
	gcTriggerTime

	// gcTriggerCycle: 手动触发
	gcTriggerCycle
)
</code></pre>
<p>当 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/mgc.go#L616">gcTrigger.test()</a> 返回 <code>true</code> 时，就会执行 <code>gcStart()</code> 函数：</p>
<pre><code class="language-go">func (t gcTrigger) test() bool {
	// 必须满足：GC已启用、非panic状态、不在GC中
	if !memstats.enablegc || panicking.Load() != 0 || gcphase != _GCoff {
		return false
	}
	switch t.kind {
	case gcTriggerHeap:
		// 堆触发：heapLive &gt;= trigger
		trigger, _ := gcController.trigger()
		return gcController.heapLive.Load() &gt;= trigger
	case gcTriggerTime:
		// 时间触发：距上次GC &gt; forcegcperiod (2分钟)
		if gcController.gcPercent.Load() &lt; 0 {
			return false
		}
		lastgc := int64(atomic.Load64(&amp;memstats.last_gc_nanotime))
		return lastgc != 0 &amp;&amp; t.now-lastgc &gt; forcegcperiod
	case gcTriggerCycle:
		// 手动触发
		return int32(t.n-work.cycles.Load()) &gt; 0
	}
	return true
}
</code></pre>
<p><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/mgc.go#L643">gcStart()</a> 函数的核心流程：</p>
<pre><code class="language-go">func gcStart(trigger gcTrigger) {
    // 1. 安全性检查 (Preamble)
    // 如果当前 Goroutine 正持有锁（如在 malloc 内部），或者不可抢占，
    // 强行启动 GC 可能会导致死锁或状态损坏。此时放弃，等待下一次机会。
    mp := acquirem()
    if gp := getg(); gp == mp.g0 || mp.locks &gt; 1 || mp.preemptoff != &quot;&quot; {
        releasem(mp)
        return
    }
    releasem(mp)

    // 2. 清理上一轮的残余 (Finish Previous Sweep)
    // 在开启新一轮 GC 前，必须确保上一轮的垃圾清理（Sweep）完全结束。
    // 如果是后台触发，通常已经清完了；如果是手动强制触发，这里会循环清理直到干净。
    for trigger.test() &amp;&amp; sweepone() != ^uintptr(0) {
    }

    // 抢占启动锁，防止多个 P 同时启动 GC
    semacquire(&amp;work.startSema)

    // 再次检查触发条件（Double Check），防止在抢锁过程中条件已变化
    if !trigger.test() {
        semrelease(&amp;work.startSema)
        return
    }

    // ============================================================
    // 3. 阶段一：扫描终止 (Sweep Termination) - STW 开始
    // ============================================================

    // 唤醒后台标记工作协程（gcBgMarkWorker），让它们准备好干活
    gcBgMarkStartWorkers()

    // 重置标记相关的全局状态（如重置工作队列等）
    systemstack(gcResetMarkState)

    // Stop The World!
    // 这是 GC 周期的第一个 STW。目的是为了在一个静止的世界里，
    // 安全地切换 GC 阶段标志位，并开启写屏障。
    // 此时，所有用户代码暂停。
    var stw worldStop
    systemstack(func() {
        stw = stopTheWorldWithSema(stwGCSweepTerm)
    })

    // 在 STW 期间，确保所有 Span 的清理工作彻底完成（兜底）
    systemstack(func() {
        finishsweep_m()
    })

    // 清理 sync.Pool。
    // 这是一个权衡：必须在 STW 期间清空，否则老对象会活到下一轮。
    clearpools()

    // 增加 GC 计数器
    work.cycles.Add(1)

    // 初始化 GC 控制器，设定本轮的目标（基于 P 的数量等）
    gcController.startCycle(now, int(gomaxprocs), trigger)

    // ============================================================
    // 4. 阶段二：准备并发标记 (Prepare Concurrent Mark)
    // ============================================================

    // 【关键点】开启混合写屏障 (Hybrid Write Barrier)
    // setGCPhase 将全局状态改为 _GCmark。
    // 由于此时还在 STW，所有 P 在被唤醒后，都会看到这个新状态，
    // 从而在执行 pointer write 时自动触发屏障逻辑。
    setGCPhase(_GCmark)

    // 准备根对象（Globals, Stack, Registers 等）
    // 这一步必须在 assist 开启前完成。
    gcBgMarkPrepare()
    gcPrepareMarkRoots()

    // 标记所有 tiny alloc 块为黑色。
    // 这是一个优化：小对象分配非常频繁，如果不预先染黑，
    // 每次分配都要触发屏障，性能会崩。
    gcMarkTinyAllocs()

    // 【关键点】启用 Mutator Assist (辅助标记)
    // 允许用户协程在分配内存太快时，“被迫”帮忙进行标记。
    // 必须在写屏障开启后才能启用。
    atomic.Store(&amp;gcBlackenEnabled, 1)

    // ============================================================
    // 5. 恢复世界 (Start The World)
    // ============================================================

    // 此时状态已经切换为 _GCmark，写屏障已启用，后台 Worker 已就绪。
    // 恢复用户代码运行。
    systemstack(func() {
        now = startTheWorldWithSema(0, stw)
    })

    // 释放启动锁
    semrelease(&amp;work.startSema)
}
</code></pre>
<h3>3.2 并发标记 gcBgMarkWorker</h3>
<p>在上面 <code>gcStart()</code> 中，会调用 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/mgc.go#L1350">gcBgMarkStartWorkers()</a> 准备后台标记工作者：</p>
<pre><code class="language-go">func gcBgMarkStartWorkers() {
	ready := make(chan struct{}, 1)
	for gcBgMarkWorkerCount &lt; gomaxprocs {
		go gcBgMarkWorker(ready)
		&lt;-ready
	}
}
</code></pre>
<p>它的逻辑很简单，就是为每一个 P 调用一个 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/mgc.go#L1428">gcBgMarkWorker(ready)</a>：</p>
<pre><code class="language-go">func gcBgMarkWorker(ready chan struct{}) {
	ready &lt;- struct{}{}
	for {
    // 根据不同标记的工作者类型调用不同的标记函数
		systemstack(func() {
			switch pp.gcMarkWorkerMode {
			case gcMarkWorkerDedicatedMode:
				gcDrainMarkWorkerDedicated(&amp;pp.gcw, true)
				if gp.preempt {
					if drainQ := runqdrain(pp); !drainQ.empty() {
						lock(&amp;sched.lock)
						globrunqputbatch(&amp;drainQ)
						unlock(&amp;sched.lock)
					}
				}
				gcDrainMarkWorkerDedicated(&amp;pp.gcw, false)
			case gcMarkWorkerFractionalMode:
				gcDrainMarkWorkerFractional(&amp;pp.gcw)
			case gcMarkWorkerIdleMode:
				gcDrainMarkWorkerIdle(&amp;pp.gcw)
			}
			casgstatus(gp, _Gwaiting, _Grunning)
		})

    // 检测标记终止
		if incnwait == work.nproc &amp;&amp; !gcMarkWorkAvailable(nil) {
			gcMarkDone()
		}
	}
}
</code></pre>
<p><code>gcBgMarkWorker()</code> 主要包含 2 个核心逻辑：</p>
<ol>
<li><p>根据不同标记的工作者类型调用不同的标记函数，如 <code>gcDrainMarkWorkerDedicated()</code>、<code>gcDrainMarkWorkerFractional()</code> 和 <code>gcDrainMarkWorkerIdle()</code>。而事实上，这 3 个函数，都是调用了 <code>gcDrain()</code>。<code>gcDrain()</code> 函数是 GC 标记阶段的核心工作循环，负责&quot;排空&quot;（drain）标记工作队列，将灰色对象扫描并标记为黑色。这是标记工作者执行实际标记工作的主要函数。</p>
<p>调用层级如下所示：</p>
<pre><code>gcBgMarkWorker (后台工作者)
    └─&gt; gcDrainMarkWorkerDedicated/Fractional/Idle
            └─&gt; gcDrain
                    ├─&gt; markroot (扫描根对象)
                    ├─&gt; scanobject (扫描堆对象)
                    └─&gt; scanSpan (扫描 span)
</code></pre>
</li>
<li><p>检测标记终止：<code>gcMarkDone()</code>，我们将在 3.3 章节进行详细展开。</p>
</li>
</ol>
<h4>3.2.1 标记工作者类型 gcMarkWorkerMode</h4>
<p>Go GC 使用三种类型的标记工作者：</p>
<ul>
<li><code>gcMarkWorkerDedicatedMode</code>：专用标记工作者，持续标记直到没有更多工作或被抢占。</li>
<li><code>gcMarkWorkerFractionalMode</code>：分数标记工作者，按照目标使用率工作。</li>
<li><code>gcMarkWorkerIdleMode</code>：空闲标记工作者，仅在 P 空闲时工作。</li>
</ul>
<pre><code class="language-go">switch pp.gcMarkWorkerMode {
case gcMarkWorkerDedicatedMode:
	// Dedicated Worker: 专用标记工作者，持续标记直到没有更多工作或被抢占
	gcDrainMarkWorkerDedicated(&amp;pp.gcw, true)
	if gp.preempt {
		// 被抢占时，清空运行队列
		if drainQ := runqdrain(pp); !drainQ.empty() {
			lock(&amp;sched.lock)
			globrunqputbatch(&amp;drainQ)
			unlock(&amp;sched.lock)
		}
	}
	gcDrainMarkWorkerDedicated(&amp;pp.gcw, false)

case gcMarkWorkerFractionalMode:
	// Fractional Worker: 分数标记工作者，按照目标使用率工作
	gcDrainMarkWorkerFractional(&amp;pp.gcw)

case gcMarkWorkerIdleMode:
	// Idle Worker: 空闲标记工作者，仅在P空闲时工作
	gcDrainMarkWorkerIdle(&amp;pp.gcw)
}
</code></pre>
<h4>3.2.2 标记工作队列 gcWork</h4>
<p><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/mgcwork.go#L82">gcWork</a> 是 GC 标记工作的生产者-消费者接口，每个 P 都有自己的 <code>gcWork</code>，通过双缓冲减少全局队列竞争。</p>
<pre><code class="language-go">type gcWork struct {
    wbuf1, wbuf2 *workbuf  // 双缓冲：wbuf1 当前使用，wbuf2 备用
    bytesMarked uint64     // 本地标记的字节数
    flushedWork bool       // 是否将工作刷新到全局队列
}
</code></pre>
<p>它有两个核心方法：</p>
<ul>
<li><code>putObj()</code>：将一个灰色对象加入工作队列（生产）</li>
<li><code>tryGetObj()</code>：从工作队列取出一个灰色对象（消费）</li>
</ul>
<pre><code class="language-go">// putObj 将一个灰色对象加入工作队列（生产）
func (w *gcWork) putObj(obj uintptr) {
    wbuf := w.wbuf1

    // 初始化或检查缓冲区
    if wbuf == nil {
        w.init()  // 初始化双缓冲
        wbuf = w.wbuf1
    } else if wbuf.nobj == len(wbuf.obj) {  // wbuf1 满了
        // 双缓冲切换：wbuf1 &lt;-&gt; wbuf2
        w.wbuf1, w.wbuf2 = w.wbuf2, w.wbuf1
        wbuf = w.wbuf1

        if wbuf.nobj == len(wbuf.obj) {  // 两个缓冲区都满了
            putfull(wbuf)  // 将满的缓冲区放入全局 full 队列
            w.flushedWork = true
            wbuf = getempty()  // 获取新的空缓冲区
            w.wbuf1 = wbuf
        }
    }

    // 将对象加入缓冲区
    wbuf.obj[wbuf.nobj] = obj
    wbuf.nobj++
}

// tryGetObj 从工作队列取出一个灰色对象（消费）
func (w *gcWork) tryGetObj() uintptr {
    wbuf := w.wbuf1

    if wbuf == nil {
        w.init()
        wbuf = w.wbuf1
    }

    if wbuf.nobj == 0 {  // wbuf1 空了
        // 双缓冲切换
        w.wbuf1, w.wbuf2 = w.wbuf2, w.wbuf1
        wbuf = w.wbuf1

        if wbuf.nobj == 0 {  // 两个缓冲区都空了
            owbuf := wbuf
            wbuf = trygetfull()  // 从全局 full 队列获取
            if wbuf == nil {
                return 0  // 没有工作了
            }
            putempty(owbuf)  // 将空缓冲区归还全局 empty 队列
            w.wbuf1 = wbuf
        }
    }

    // 从缓冲区取出对象
    wbuf.nobj--
    return wbuf.obj[wbuf.nobj]
}
</code></pre>
<p>设计要点：</p>
<ul>
<li>双缓冲机制：减少对全局队列的访问频率，降低锁竞争</li>
<li>本地优先：优先使用 P 本地缓冲区，只在必要时访问全局队列</li>
<li>滞后效应：一个缓冲区的容量作为滞后，摊销获取/放回缓冲区的成本</li>
</ul>
<h4>3.2.3 根对象扫描准备 gcPrepareMarkRoots()</h4>
<p>在 <code>gcStart()</code> 的时候，会先执行 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/mgcmark.go#L60">gcPrepareMarkRoot()</a> 扫描根对象，即所谓的 GC Roots，如我们前面的可达性分析章节所述， GC Roots 的对象通常是指那些<strong>肯定在使用中</strong>的对象：</p>
<ul>
<li>被栈上的指针引用</li>
<li>被全局变量的指针引用</li>
<li>被寄存器中的指针引用</li>
</ul>
<pre><code class="language-go">func gcPrepareMarkRoots() {
	assertWorldStopped()

	// 1. 计算data段和bss段的根对象数量
	work.nDataRoots = 0
	work.nBSSRoots = 0
	for _, datap := range activeModules() {
		nDataRoots := nBlocks(datap.edata - datap.data)
		if nDataRoots &gt; work.nDataRoots {
			work.nDataRoots = nDataRoots
		}
		nBSSRoots := nBlocks(datap.ebss - datap.bss)
		if nBSSRoots &gt; work.nBSSRoots {
			work.nBSSRoots = nBSSRoots
		}
	}

	// 2. 准备扫描span中的finalizer specials
	mheap_.markArenas = mheap_.heapArenas[:len(mheap_.heapArenas):len(mheap_.heapArenas)]
	work.nSpanRoots = len(mheap_.markArenas) * (pagesPerArena / pagesPerSpanRoot)

	// 3. 准备扫描所有goroutine的栈
	// 在此点之后创建的G会从重置状态开始，所以不需要扫描
	work.stackRoots = allGsSnapshot()
	work.nStackRoots = len(work.stackRoots)

	// 计算总的根对象扫描任务数
	work.markrootNext = 0
	work.markrootJobs = uint32(fixedRootCount + work.nDataRoots +
	                           work.nBSSRoots + work.nSpanRoots + work.nStackRoots)

	// 计算各类根对象的基础索引
	work.baseData = uint32(fixedRootCount)
	work.baseBSS = work.baseData + uint32(work.nDataRoots)
	work.baseSpans = work.baseBSS + uint32(work.nBSSRoots)
	work.baseStacks = work.baseSpans + uint32(work.nSpanRoots)
	work.baseEnd = work.baseStacks + uint32(work.nStackRoots)
}
</code></pre>
<h4>3.2.4 标记循环 gcDrain()</h4>
<p>前面我们提到 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/mgcmark.go#L1169">gcDrain()</a> 函数是 GC 标记阶段的核心工作循环，负责&quot;排空&quot;（drain）标记工作队列，将灰色对象扫描并标记为黑色。它的核心流程很简单，就是<strong>从工作队列中持续取出灰色对象进行扫描，直到满足退出条件</strong>：</p>
<ol>
<li>工作队列为空</li>
<li>被抢占（如果允许抢占）</li>
<li>满足退出条件（空闲/分数模式）</li>
</ol>
<pre><code class="language-go">func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    // === 1. 初始化和模式设置 ===
  	// ...

    // 设置检查点：定期检查是否应该退出
    checkWork := int64(1&lt;&lt;63 - 1)  // 默认几乎不检查
    var check func() bool
    if flags&amp;(gcDrainIdle|gcDrainFractional) != 0 {
        checkWork = initScanWork + drainCheckThreshold  // 每完成一定量工作就检查
        if idle {
            check = pollWork  // 空闲模式：检查是否有其他工作
        } else if flags&amp;gcDrainFractional != 0 {
            check = pollFractionalWorkerExit  // 分数模式：检查是否达到目标时间
        }
    }

    // === 2. 阶段一：排空根标记任务 ===
    // 根对象包括：全局变量、goroutine 栈、finalizer 等
  	// 即前面 gcPrepareMarkRoots() 准备的内容
    if work.markrootNext &lt; work.markrootJobs {
        for !(gp.preempt &amp;&amp; (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) {
            // 原子获取下一个根标记任务
            job := atomic.Xadd(&amp;work.markrootNext, +1) - 1
            if job &gt;= work.markrootJobs {
                break  // 所有根任务已完成
            }

            markroot(gcw, job, flushBgCredit)  // 标记根对象

            // 定期检查退出条件
            if check != nil &amp;&amp; check() {
                goto done  // 空闲模式有其他工作 or 分数模式达到时间
            }

            // GreenTeaGC: 如果需要，启动新工作者
            if goexperiment.GreenTeaGC &amp;&amp; gcw.mayNeedWorker {
                gcw.mayNeedWorker = false
                if gcphase == _GCmark {
                    gcController.enlistWorker()
                }
            }
        }
    }

    // === 3. 阶段二：排空堆标记任务（主循环）===
    for !(gp.preempt &amp;&amp; (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) {
        // 3.1 工作平衡：保持全局队列有工作，避免其他工作者等待
        if work.full == 0 {
            gcw.balance()  // 将本地缓冲的部分工作放回全局队列
        }

        // 3.2 按优先级顺序获取工作（见 mgcwork.go 注释）
        var b uintptr  // 对象指针
        var s objptr   // span 指针

        // 优先级 1: P-local workbuf
        if b = gcw.tryGetObjFast(); b == 0 {
            // 优先级 2: P-local span queue (GreenTeaGC)
            if s = gcw.tryGetSpan(false); s == 0 {
                // 优先级 3: 全局 workbuf
                if b = gcw.tryGetObj(); b == 0 {
                    // 刷新写屏障缓冲区，可能产生新工作
                    wbBufFlush()
                    if b = gcw.tryGetObj(); b == 0 {
                        // 优先级 4: 全局 span queue
                        s = gcw.tryGetSpan(true)
                    }
                }
            }
        }

        // 3.3 处理获取到的工作
        if b != 0 {
            scanobject(b, gcw)  // 扫描对象：遍历其指针字段，标记引用
        } else if s != 0 {
            scanSpan(s, gcw)    // 扫描 span：批量处理 span 中的对象
        } else {
            break  // 没有工作了，退出
        }

        // 3.4 可能启动新工作者
        if goexperiment.GreenTeaGC &amp;&amp; gcw.mayNeedWorker {
            gcw.mayNeedWorker = false
            if gcphase == _GCmark {
                gcController.enlistWorker()
            }
        }

        // 3.5 刷新扫描工作信用（用于 mutator assist 的记账）
        if gcw.heapScanWork &gt;= gcCreditSlack {  // 累积了 2000 字节扫描工作
            gcController.heapScanWork.Add(gcw.heapScanWork)  // 刷新到全局

            if flushBgCredit {
                // 后台标记：产生信用，让 mutator 可以借用
                gcFlushBgCredit(gcw.heapScanWork - initScanWork)
                initScanWork = 0
            }

            checkWork -= gcw.heapScanWork
            gcw.heapScanWork = 0

            // 定期检查退出条件
            if checkWork &lt;= 0 {
                checkWork += drainCheckThreshold
                if check != nil &amp;&amp; check() {
                    break
                }
            }
        }
    }

done:
    // === 4. 清理：刷新剩余的扫描工作 ===
    if gcw.heapScanWork &gt; 0 {
        gcController.heapScanWork.Add(gcw.heapScanWork)
        if flushBgCredit {
            gcFlushBgCredit(gcw.heapScanWork - initScanWork)
        }
        gcw.heapScanWork = 0
    }
}
</code></pre>
<p>关键设计点：</p>
<ol>
<li><strong>工作优先级</strong>：<code>P-local workbuf</code> → <code>P-local span</code> → <code>全局 workbuf</code> → <code>全局 span</code>，优先使用本地缓存，减少全局竞争。</li>
<li><strong>工作平衡</strong>：防止工作集中在某个 P，其他 P 空闲。</li>
<li><strong>抢占检查</strong>：响应抢占请求、STW 请求、forEachP 调用。</li>
<li><strong>信用系统</strong>：后台标记工作产生&quot;信用&quot;，Mutator assist 消耗&quot;信用&quot;，平衡 GC 工作和应用程序分配。</li>
</ol>
<p><code>gcDrain()</code> 包含了 3 个最重要的子逻辑：</p>
<ul>
<li><code>markroot()</code>: 标记 GC 的根集（root set），这些是追踪的起点。</li>
<li><code>scanobject()</code>：扫描一个堆对象，标记它引用的所有对象。</li>
<li><code>scanSpan(</code>)：扫描 span，批量处理 span 中的对象，这是 Green Tea GC 的优化，这个我们下一篇再进行展开。</li>
</ul>
<h4>3.2.5 标记根对象 markroot()</h4>
<p>关键点：</p>
<ul>
<li><p>根对象种类：全局变量（data/BSS）、栈、finalizer、cleanup、span specials</p>
</li>
<li><p>分片处理：大的根对象（如全局变量）被分成多个任务，并行处理</p>
</li>
<li><p>栈扫描：需要暂停 goroutine，扫描后恢复</p>
</li>
</ul>
<pre><code class="language-go">// markroot 标记第 i 个根对象任务
// 根对象是 GC 追踪的起点，包括全局变量、栈、finalizer 等
func markroot(gcw *gcWork, i uint32, flushBgCredit bool) int64 {
    var workDone int64
    var workCounter *atomic.Int64

    switch {
    // === 1. 全局变量（data 段）===
    case work.baseData &lt;= i &amp;&amp; i &lt; work.baseBSS:
        workCounter = &amp;gcController.globalsScanWork
        for _, datap := range activeModules() {
            // 扫描 data 段：已初始化的全局变量
            workDone += markrootBlock(
                datap.data,              // 起始地址
                datap.edata-datap.data,  // 大小
                datap.gcdatamask.bytedata, // 指针位图
                gcw,
                int(i-work.baseData),    // 分片索引
            )
        }

    // === 2. 全局变量（BSS 段）===
    case work.baseBSS &lt;= i &amp;&amp; i &lt; work.baseSpans:
        workCounter = &amp;gcController.globalsScanWork
        for _, datap := range activeModules() {
            // 扫描 BSS 段：未初始化的全局变量
            workDone += markrootBlock(
                datap.bss,
                datap.ebss-datap.bss,
                datap.gcbssmask.bytedata,
                gcw,
                int(i-work.baseBSS),
            )
        }

    // === 3. Finalizer 队列 ===
    case i == fixedRootFinalizers:
        for fb := allfin; fb != nil; fb = fb.alllink {
            cnt := uintptr(atomic.Load(&amp;fb.cnt))
            // 扫描 finalizer 结构体中的指针
            scanblock(uintptr(unsafe.Pointer(&amp;fb.fin[0])),
                      cnt*unsafe.Sizeof(fb.fin[0]),
                      &amp;finptrmask[0], gcw, nil)
        }

    // === 4. 释放死亡 G 的栈 ===
    case i == fixedRootFreeGStacks:
        systemstack(markrootFreeGStacks)

    // === 5. Cleanup 队列 ===
    case i == fixedRootCleanups:
        for cb := (*cleanupBlock)(gcCleanups.all.Load()); cb != nil; cb = cb.alllink {
            n := uintptr(atomic.Load(&amp;cb.n))
            scanblock(uintptr(unsafe.Pointer(&amp;cb.cleanups[0])),
                      n*goarch.PtrSize,
                      &amp;cleanupBlockPtrMask[0], gcw, nil)
        }

    // === 6. Span 特殊对象（如 finalizer specials）===
    case work.baseSpans &lt;= i &amp;&amp; i &lt; work.baseStacks:
        markrootSpans(gcw, int(i-work.baseSpans))

    // === 7. Goroutine 栈（最重要！）===
    default:
        workCounter = &amp;gcController.stackScanWork
        if i &lt; work.baseStacks || work.baseEnd &lt;= i {
            throw(&quot;markroot: bad index&quot;)
        }

        gp := work.stackRoots[i-work.baseStacks]  // 获取 goroutine

        systemstack(func() {
            // 处理自扫描情况
            userG := getg().m.curg
            selfScan := gp == userG &amp;&amp; readgstatus(userG) == _Grunning
            if selfScan {
                casGToWaitingForSuspendG(userG, _Grunning, waitReasonGarbageCollectionScan)
            }

            // 暂停 goroutine 并扫描其栈
            stopped := suspendG(gp)
            if stopped.dead {
                gp.gcscandone = true
                return
            }
            if gp.gcscandone {
                throw(&quot;g already scanned&quot;)
            }

            workDone += scanstack(gp, gcw)  // 扫描栈！
            gp.gcscandone = true
            resumeG(stopped)  // 恢复 goroutine

            if selfScan {
                casgstatus(userG, _Gwaiting, _Grunning)
            }
        })
    }

    // 更新工作统计和信用
    if workCounter != nil &amp;&amp; workDone != 0 {
        workCounter.Add(workDone)
        if flushBgCredit {
            gcFlushBgCredit(workDone)  // 产生 assist 信用
        }
    }
    return workDone
}
</code></pre>
<h4>3.2.6 对象扫描 scanobject()</h4>
<p>关键点：</p>
<ul>
<li><p><strong>Oblet 机制</strong>：大对象（&gt;128KB）被拆分成多个 oblet，每个 ≤128KB</p>
<ul>
<li><p>优势：提高并行性，降低扫描延迟（~100µs）</p>
</li>
<li><p>其他 oblet 被放入工作队列，可能被其他工作者处理</p>
</li>
</ul>
</li>
<li><p><strong>类型指针迭代器</strong>：高效遍历对象中的指针字段，跳过标量字段</p>
</li>
<li><p><strong>快速过滤</strong>：过滤 nil 和自引用，减少不必要的 <code>findObject</code> 调用</p>
</li>
</ul>
<pre><code class="language-go">// scanobject 扫描地址 b 处的对象，将其变黑，并将引用的对象变灰
func scanobject(b uintptr, gcw *gcWork) {
    // 预取对象，提高缓存命中率
    sys.Prefetch(b)

    // === 1. 获取对象信息 ===
    s := spanOfUnchecked(b)  // 获取对象所在的 span
    n := s.elemsize           // 对象大小

    if n == 0 {
        throw(&quot;scanobject n == 0&quot;)
    }
    if s.spanclass.noscan() {
        throw(&quot;scanobject of a noscan object&quot;)  // noscan 对象不应该到这
    }

    // === 2. 处理大对象：拆分成 oblets ===
    var tp typePointers  // 类型指针迭代器
    if n &gt; maxObletBytes {  // 对象 &gt; 128KB
        // 大对象拆分成多个 128KB 的 oblet，提高并行性和降低延迟
        if b == s.base() {
            // 只在第一次遇到对象时，将其他 oblet 入队
            for oblet := b + maxObletBytes; oblet &lt; s.base()+s.elemsize; oblet += maxObletBytes {
                if !gcw.putObjFast(oblet) {
                    gcw.putObj(oblet)  // 将 oblet 加入工作队列
                }
            }
        }

        // 计算当前 oblet 的大小
        n = s.base() + s.elemsize - b
        n = min(n, maxObletBytes)
        tp = s.typePointersOfUnchecked(s.base())
        tp = tp.fastForward(b-tp.addr, b+n)  // 跳到当前 oblet
    } else {
        // 小对象，直接获取类型指针
        tp = s.typePointersOfUnchecked(b)
    }

    // === 3. 遍历对象中的所有指针 ===
    var scanSize uintptr
    for {
        var addr uintptr
        // 快速路径：尝试快速获取下一个指针
        if tp, addr = tp.nextFast(); addr == 0 {
            // 慢速路径：需要更多处理
            if tp, addr = tp.next(b + n); addr == 0 {
                break  // 没有更多指针了
            }
        }

        // 跟踪扫描进度（用于统计）
        scanSize = addr - b + goarch.PtrSize

        // === 4. 读取指针值 ===
        obj := *(*uintptr)(unsafe.Pointer(addr))

        // === 5. 快速过滤 ===
        // 过滤 nil 和指向当前对象内部的指针
        if obj != 0 &amp;&amp; obj-b &gt;= n {
            // === 6. 标记被引用的对象 ===
            if !tryDeferToSpanScan(obj, gcw) {
                // 查找对象
                if obj, span, objIndex := findObject(obj, b, addr-b); obj != 0 {
                    // 将对象标记为灰色（核心！）
                    greyobject(obj, b, addr-b, span, gcw, objIndex)
                }
            }
        }
    }

    // === 7. 统计 ===
    gcw.bytesMarked += uint64(n)     // 标记的字节数
    gcw.heapScanWork += int64(scanSize)  // 扫描的字节数
    if debug.gctrace &gt; 1 {
        gcw.stats[s.spanclass.sizeclass()].sparseObjsScanned++
    }
}
</code></pre>
<h4>3.2.7 对象标记 greyobject()</h4>
<p><code>scanobject()</code> 会将正在扫描的堆对象引用的对象调用 <code>greyobject()</code> 将其从白色标记为灰色。</p>
<p>关键点：</p>
<ul>
<li><p><strong>幂等性</strong>：重复标记同一对象是安全的（已标记则直接返回）</p>
</li>
<li><p><strong>原子操作</strong>：标记位和页位图的设置都是原子的，支持并发标记</p>
</li>
<li><p><strong>noscan 优化</strong>：没有指针的对象直接变黑，不入队</p>
</li>
<li><p><strong>预取优化</strong>：将对象预取到缓存，提高后续扫描性能</p>
</li>
</ul>
<pre><code class="language-go">// greyobject 将对象 obj 标记为灰色
// obj: 对象地址
// base, off: 用于调试，指示从哪里发现的这个引用
// span: 对象所在的 span
// gcw: 工作队列
// objIndex: 对象在 span 中的索引
func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) {
    // === 1. 对齐检查 ===
    if obj&amp;(goarch.PtrSize-1) != 0 {
        throw(&quot;greyobject: obj not pointer-aligned&quot;)
    }

    // === 2. 获取标记位 ===
    mbits := span.markBitsForIndex(objIndex)

    if useCheckmark {
        // 调试模式：checkmark
        if setCheckmark(obj, base, off, mbits) {
            return  // 已标记
        }
        if debug.checkfinalizers &gt; 1 {
            print(&quot;  mark &quot;, hex(obj), &quot; found at *(&quot;, hex(base), &quot;+&quot;, hex(off), &quot;)\n&quot;)
        }
    } else {
        // === 3. 检查是否已标记 ===
        if mbits.isMarked() {
            return  // 已经是灰色或黑色，跳过
        }

        // === 4. 设置标记位（白→灰）===
        mbits.setMarked()

        // === 5. 标记 span 的页位图 ===
        // 用于快速判断某页是否有存活对象
        arena, pageIdx, pageMask := pageIndexOf(span.base())
        if arena.pageMarks[pageIdx]&amp;pageMask == 0 {
            atomic.Or8(&amp;arena.pageMarks[pageIdx], pageMask)
        }
    }

    // === 6. noscan 对象快速路径 ===
    // noscan 对象（如 []byte）没有指针，直接变黑，不需要扫描
    if span.spanclass.noscan() {
        gcw.bytesMarked += uint64(span.elemsize)
        return  // 不入队，直接完成
    }

    // === 7. 预取对象 ===
    // 对象即将被扫描，预取到 CPU 缓存
    sys.Prefetch(obj)

    // === 8. 将对象加入工作队列（灰色队列）===
    // 对象现在是灰色的，等待被扫描（变黑）
    if !gcw.putObjFast(obj) {
        gcw.putObj(obj)  // 快速路径失败，使用慢速路径
    }
}
</code></pre>
<h4>3.2.8 并发标记小节</h4>
<p><code>gcDrain</code> 的核心逻辑是一个消费循环。它从本地或全局的工作缓冲区（<code>gcWork</code>）中提取指针（灰色对象），并调用 <code>scanobject</code> 对其进行处理。其工作流可以形式化为以下几个步骤：</p>
<ol>
<li><strong>本地获取（Local Fetch）</strong>：首先尝试从当前 P 的本地 <code>gcWork</code> 缓存中获取工作。这是一个无锁操作（Lock-free），效率极高。</li>
<li><strong>全局获取与窃取（Global Fetch &amp; Steal）</strong>：如果本地缓存为空，<code>gcDrain</code> 必须尝试从全局队列获取工作，或者从其他 P 的本地队列中窃取工作。这一步涉及到跨 P 的协调，是锁竞争的高发区。</li>
<li><strong>扫描与着色（Scan &amp; Shade）</strong>：对获取到的每一个对象调用 <code>scanobject</code>，识别其引用的子对象，并通过 <code>greyobject</code> 将子对象加入工作队列（即着色为灰色）。</li>
<li><strong>抢占检查（Preemption Check）</strong>：为了保证调度的公平性，<code>gcDrain</code> 会周期性地检查是否需要让出 P。</li>
</ol>
<p>整个 <code>gcDrain()</code> 的标记循环流程可以总结为如下图所示：</p>
<pre><code class="language-mermaid">graph LR
    A[灰色对象队列] --&gt;|取出| B[gcDrain]

    B --&gt; C[扫描函数]
    C --&gt;|markroot| D[扫描根]
    C --&gt;|scanobject| E[扫描对象]
    C --&gt;|scanSpan| F[扫描Span]

    D --&gt; G[greyobject]
    E --&gt; G
    F --&gt; G

    G --&gt;|白→灰| H[设置标记位]
    H --&gt;|入队| A

    style B fill:#e1f5ff,stroke:#0277bd,stroke-width:3px
    style G fill:#ffebee,stroke:#c62828,stroke-width:3px
    style A fill:#fff9c4,stroke:#f57f17,stroke-width:2px
</code></pre>
<h3>3.3 标记终止检测 gcMarkDone()</h3>
<p>为了进入并发清理阶段，需要先确保所有标记已经终止，即 Mark Termination。这是最复杂的阶段，Go 使用<strong>分布式终止算法</strong>和 <strong>Ragged Barrier</strong> 来确保所有标记工作完成。</p>
<p>所谓检测并发标记阶段是否完成，即<u><strong>确认所有可达对象都已标记，没有遗漏的灰色对象</strong></u>。</p>
<p>在并发环境中，标记工作分散在多个位置：</p>
<ul>
<li><p>P-local buffers：每个 P 的 gcWork 缓冲区</p>
</li>
<li><p>Global work queues：全局工作队列 work.full</p>
</li>
<li><p>Write barrier buffers：写屏障缓冲区 wbBuf</p>
</li>
<li><p>Root scan jobs：根对象扫描任务</p>
</li>
</ul>
<p>那么问题就来了：<font color="red"><u>如何在不停止世界的情况下，确保检查所有缓冲区时，不会有新的工作产生？</u></font></p>
<blockquote>
<p>[!IMPORTANT]</p>
<p><code>gcMarkDone()</code> 通过&quot;<strong>检查所有工作者空闲(nwait==nproc)且全局队列为空 → Ragged Barrier 同步刷新所有 P 的写屏障缓冲和工作队列到全局 → STW 后验证写屏障无残留工作</strong>&quot;的三步循环检测，任一步骤发现新的灰色对象就回到起点重新检测，直到确认不存在任何隐藏的本地工作和灰色对象后才进入标记终止阶段。</p>
</blockquote>
<p>下面是 <code>gcMarkDone()</code> 的源码解析：</p>
<pre><code class="language-go">func gcMarkDone() {
	semacquire(&amp;work.markDoneSema)

top:
	// 检查终止条件：
	// 1. 当前处于标记阶段
	// 2. 所有worker都在等待 (nwait == nproc)
	// 3. 没有可用的标记工作
	if !(gcphase == _GCmark &amp;&amp; work.nwait == work.nproc &amp;&amp; !gcMarkWorkAvailable(nil)) {
		semrelease(&amp;work.markDoneSema)
		return
	}

	semacquire(&amp;worldsema)

	// 阻止weak-&gt;strong转换产生额外的GC工作
	work.strongFromWeak.block = true

	// === Ragged Barrier ===
	// 刷新所有P的本地缓冲区
	gcMarkDoneFlushed = 0
	forEachP(waitReasonGCMarkTermination, func(pp *p) {
		// 刷新写屏障缓冲
		wbBufFlush1(pp)

		// 刷新gcWork缓冲
		pp.gcw.dispose()

		// 收集flushedWork标志
		if pp.gcw.flushedWork {
			atomic.Xadd(&amp;gcMarkDoneFlushed, 1)
			pp.gcw.flushedWork = false
		}
	})

	// 如果发现新的灰色对象，重新开始检测
	if gcMarkDoneFlushed != 0 {
		semrelease(&amp;worldsema)
		goto top
	}

	// === 标记终止 (STW) ===
	now := nanotime()
	work.tMarkTerm = now
	getg().m.preemptoff = &quot;gcing&quot;
	systemstack(func() {
		stw = stopTheWorldWithSema(stwGCMarkTerm)
	})

	// 处理ragged barrier后的写屏障产生的工作
	restart := false
	systemstack(func() {
		for _, p := range allp {
			wbBufFlush1(p)
			if !p.gcw.empty() {
				restart = true
				break
			}
		}
	})

	// 如果又发现新工作，重启并发标记
	if restart {
		getg().m.preemptoff = &quot;&quot;
		systemstack(func() {
			now := startTheWorldWithSema(0, stw)
			work.pauseNS += now - stw.startedStopping
		})
		semrelease(&amp;worldsema)
		goto top
	}

	// 禁用标记和assists
	atomic.Store(&amp;gcBlackenEnabled, 0)
	gcWakeAllAssists()

	// 结束周期，计算下次GC触发点
	gcController.endCycle(now, int(gomaxprocs), work.userForced)

	// 执行标记终止
	gcMarkTermination(stw)
}
</code></pre>
<p>这里再简单解释一下 <strong>Ragged Barrier</strong>：</p>
<blockquote>
<p>Ragged Barrier 是分布式系统中的一个同步原语，名字来源于它的行为特征：不同处理器/线程到达屏障的时间是&quot;参差不齐&quot;（ragged）的。</p>
</blockquote>
<p>用一句话来解释就是 Ragged Barrier 是一种异步同步原语，让多个处理单元独立完成各自的本地状态刷新操作，无需等待其他单元，最终达到全局状态一致的目的。</p>
<p>在并发标记完成检测时，通过 Ragged Barrier 将所有 P 的本地缓冲区（写屏障缓冲和工作队列）刷新到全局，使隐藏的工作可见，从而能够正确判断是否真的没有剩余标记工作。</p>
<h3>3.4 并发清理 gcSweep()</h3>
<p>标记完成后，进入扫描阶段，<code>gcSweep()</code> 负责初始化和启动垃圾回收的扫描（清理）阶段，将未标记的对象回收，准备下一个 GC 周期。</p>
<p><code>gcSweep()</code> 可以概括为：</p>
<ol>
<li><strong>递增 sweepgen（+2）</strong>：建立新旧 GC 周期的边界</li>
<li><strong>选择执行模式</strong>：同步立即完成 vs 并发后台进行</li>
<li><strong>启动扫描机制</strong>：直接调用 sweepone() 或唤醒 bgsweep</li>
</ol>
<pre><code class="language-go">// 返回 bool：true = 同步扫描完成，false = 后台并发扫描
func gcSweep(mode gcMode) bool {
  // 必须在世界停止（STW）时调用
	assertWorldStopped()

  // GC 阶段必须已经切换到 _GCoff（标记已完成）
	if gcphase != _GCoff {
		throw(&quot;gcSweep being done but phase is not GCoff&quot;)
	}

	// 准备扫描状态
	lock(&amp;mheap_.lock)
	mheap_.sweepgen += 2  // 代数递增 2，后面解释
	sweep.active.reset()
	mheap_.pagesSwept.Store(0)
	mheap_.sweepArenas = mheap_.heapArenas // 记录要扫描的 arenas
	mheap_.reclaimIndex.Store(0)
	mheap_.reclaimCredit.Store(0)
	unlock(&amp;mheap_.lock)

	sweep.centralIndex.clear()  // 清空中心索引

	// 特殊情况：同步扫描
	if !concurrentSweep || mode == gcForceBlockMode {
		lock(&amp;mheap_.lock)
		mheap_.sweepPagesPerByte = 0
		unlock(&amp;mheap_.lock)

		// 刷新所有mcache
		for _, pp := range allp {
			pp.mcache.prepareForSweep()
		}

		// 立即扫描所有span
		for sweepone() != ^uintptr(0) {
		}

		// 释放工作缓冲区
		prepareFreeWorkbufs()
		for freeSomeWbufs(false) {
		}

		mProf_NextCycle()
		mProf_Flush()
		return true
	}

	// 后台并发扫描
	lock(&amp;sweep.lock)
	if sweep.parked {
		sweep.parked = false
		ready(sweep.g, 0, true)  // 唤醒后台扫描 goroutine
	}
	unlock(&amp;sweep.lock)
	return false
}
</code></pre>
<p>其中 <code>sweepgen</code> 是一个单调递增的计数器，用于追踪 <code>span</code> 的扫描状态，通过设置全局的 <code>mheap_.sweepgen</code>，可以巧妙区分不同状态的 <code>span</code>，从而避免重复扫描。</p>
<pre><code>sweepgen 的三种状态（对于当前 sweepgen = N）：

span.sweepgen = N-2  →  未扫描（unswept）
span.sweepgen = N-1  →  正在扫描中
span.sweepgen = N    →  已扫描（swept）

通过 +2 递增，巧妙地区分了三个状态：
- 当前周期的未扫描：sweepgen - 2
- 当前周期的已扫描：sweepgen
- 正在扫描：sweepgen - 1（CAS 操作时的中间状态）
</code></pre>
<p>有两种扫描方式，分别是同步扫描和并发扫描，并发扫描实际上执行的是 <code>bgsweep()</code>，它们俩的核心逻辑都在 <code>sweepone()</code>，<code>sweepone()</code> 用于扫描单个 <code>span</code>：</p>
<pre><code class="language-go">func sweepone() uintptr {
	gp := getg()
	gp.m.locks++  // 防止抢占

	// 1. 获取扫描锁
	sl := sweep.active.begin()
	if !sl.valid {
		gp.m.locks--
		return ^uintptr(0)  // 没有工作
	}

	// 2. 查找要扫描的 span
	npages := ^uintptr(0)
	var noMoreWork bool
	for {
		s := mheap_.nextSpanForSweep()
		if s == nil {
			noMoreWork = sweep.active.markDrained()
			break
		}

		// 检查 span 状态
		if state := s.state.get(); state != mSpanInUse {
			continue  // 跳过非使用中的 span
		}

		// 3. 尝试获取 span 的扫描所有权，tryAcquire 里面就用到了 sweepgen
		if s, ok := sl.tryAcquire(s); ok {
			npages = s.npages

			// 4. 执行扫描
			if s.sweep(false) {
				// 整个 span 被释放，计入回收积分
				mheap_.reclaimCredit.Add(npages)
			} else {
				// span 仍在使用，返回 0 页
				npages = 0
			}
			break
		}
	}

	sweep.active.end(sl)

	// 5. 如果没有更多工作，唤醒清道夫
	if noMoreWork {
		scavenger.ready()
	}

	gp.m.locks--
	return npages
}
</code></pre>
<p>核心逻辑都在 <code>s.sweep(false)</code> 中，它的核心职责是<strong>回收未标记的对象，准备 span 给下次分配使用</strong>。</p>
<pre><code class="language-go">// sweep 回收未标记的对象，准备 span 给下次分配使用
// 返回 true 表示 span 已归还堆
func (sl *sweepLocked) sweep(preserve bool) bool {
    s := sl.mspan
    sweepgen := mheap_.sweepgen

    // ==================== 1. 验证状态 ====================
    // 确保 span 正在使用且处于扫描中状态 (sweepgen-1)
    if state := s.state.get(); state != mSpanInUse || s.sweepgen != sweepgen-1 {
        throw(&quot;mspan.sweep: bad span state&quot;)
    }

    // ==================== 2. 处理 Specials ====================
    // 处理 finalizers、弱引用等特殊记录
    hadSpecials := s.specials != nil
    siter := newSpecialsIter(s)
    for siter.valid() {
        objIndex := uintptr(siter.s.offset) / size
        p := s.base() + objIndex*size
        mbits := s.markBitsForIndex(objIndex)

        if !mbits.isMarked() {
            // 对象未标记（将被回收）
            hasFinAndRevived := false

            // Pass 1: 检查是否有 finalizer
            for tmp := siter.s; tmp != nil &amp;&amp; uintptr(tmp.offset) &lt; endOffset; tmp = tmp.next {
                if tmp.kind == _KindSpecialFinalizer {
                    // 有 finalizer：复活对象！
                    mbits.setMarkedNonAtomic()  // 重新标记为存活
                    hasFinAndRevived = true
                    break
                }
            }

            if hasFinAndRevived {
                // Pass 2: 将 finalizer 加入执行队列，清除弱引用
                for siter.valid() &amp;&amp; uintptr(siter.s.offset) &lt; endOffset {
                    special := siter.s
                    p := s.base() + uintptr(special.offset)
                    if special.kind == _KindSpecialFinalizer || special.kind == _KindSpecialWeakHandle {
                        siter.unlinkAndNext()
                        freeSpecial(special, unsafe.Pointer(p), size)
                    } else {
                        siter.next()
                    }
                }
            } else {
                // Pass 2: 对象真的死了，释放所有 specials
                for siter.valid() &amp;&amp; uintptr(siter.s.offset) &lt; endOffset {
                    special := siter.s
                    p := s.base() + uintptr(special.offset)
                    siter.unlinkAndNext()
                    freeSpecial(special, unsafe.Pointer(p), size)
                }
            }
        } else {
            // 对象存活，保留 specials
            if siter.s.kind == _KindSpecialReachable {
                special := siter.unlinkAndNext()
                (*specialReachable)(unsafe.Pointer(special)).reachable = true
                freeSpecial(special, unsafe.Pointer(p), size)
            } else {
                siter.next()
            }
        }
    }

    // ==================== 3. 检查僵尸对象 ====================
    // 僵尸对象 = 被标记但未分配（理论上不应存在）
    if s.freeindex &lt; s.nelems {
        obj := uintptr(s.freeindex)
        // 检查：gcmarkBits 为 1 且 allocBits 为 0
        if (*s.gcmarkBits.bytep(obj/8) &amp;^ *s.allocBits.bytep(obj/8))&gt;&gt;(obj%8) != 0 {
            s.reportZombies()  // 报告错误
        }
        for i := obj/8 + 1; i &lt; divRoundUp(uintptr(s.nelems), 8); i++ {
            if *s.gcmarkBits.bytep(i) &amp;^ *s.allocBits.bytep(i) != 0 {
                s.reportZombies()
            }
        }
    }

    // ==================== 4. 【核心】位图交换 ====================
    // gcmarkBits 变成 allocBits（标记结果变成分配状态）
    s.allocBits = s.gcmarkBits
    // 获取新的空白 gcmarkBits，为下次 GC 准备
    s.gcmarkBits = newMarkBits(uintptr(s.nelems))

    // 刷新 pinnerBits（如果存在）
    if s.pinnerBits != nil {
        s.refreshPinnerBits()
    }

    // 初始化分配位缓存
    s.refillAllocCache(0)

    // ==================== 5. 更新 sweepgen ====================
    // 原子更新：sweepgen-1 → sweepgen（标记为已扫描）
    atomic.Store(&amp;s.sweepgen, sweepgen)

    // ==================== 6. 归类 span ====================
    if spc.sizeclass() != 0 {
        // 小对象 span
        if nfreed &gt; 0 {
            s.needzero = 1  // 标记需要清零
            // 更新统计信息
            gcController.totalFree.Add(int64(nfreed) * int64(s.elemsize))
        }

        if !preserve {
            if nalloc == 0 {
                // 完全空闲：直接归还给堆
                mheap_.freeSpan(s)
                return true
            }
            if nalloc == s.nelems {
                // 完全占满：放入 fullSwept 列表
                mheap_.central[spc].mcentral.fullSwept(sweepgen).push(s)
            } else {
                // 部分占用：放入 partialSwept 列表
                mheap_.central[spc].mcentral.partialSwept(sweepgen).push(s)
            }
        }
    } else if !preserve {
        // 大对象 span
        if nfreed != 0 {
            // 释放大对象到堆
            gcController.totalFree.Add(int64(size))
            mheap_.freeSpan(s)
            return true
        }
        // 添加到 fullSwept 列表
        mheap_.central[spc].mcentral.fullSwept(sweepgen).push(s)
    }

    return false
}
</code></pre>
<p>处理流程可参考下图进行理解：</p>
<pre><code class="language-mermaid">flowchart TD
    Start([sweep 入口]) --&gt; Verify[验证状态&lt;br/&gt;sweepgen == global-1]

    Verify --&gt; Specials[处理 Specials]
    Specials --&gt; FinCheck{有 finalizer?}

    FinCheck --&gt;|是| Revive[复活对象&lt;br/&gt;加入执行队列]
    FinCheck --&gt;|否| FreeSp[释放 specials]

    Revive --&gt; Zombie
    FreeSp --&gt; Zombie

    Zombie[检查僵尸对象] --&gt; ZombieCheck{存在?}
    ZombieCheck --&gt;|是| Error[throw]
    ZombieCheck --&gt;|否| Core

    Core[核心: 位图交换]:::highlight
    Core --&gt; Swap[&quot;allocBits = gcmarkBits&lt;br/&gt;gcmarkBits = new()&quot;]:::highlight

    Swap --&gt; Update[更新 sweepgen&lt;br/&gt;global-1 → global]

    Update --&gt; Classify[归类 span]
    Classify --&gt; CheckN{nalloc?}

    CheckN --&gt;|0| ToHeap[freeSpan&lt;br/&gt;归还堆]
    CheckN --&gt;|nelems| ToFull[fullSwept&lt;br/&gt;完全占满]
    CheckN --&gt;|其他| ToPartial[partialSwept&lt;br/&gt;部分占用]

    ToHeap --&gt; RetTrue[return true]
    ToFull --&gt; RetFalse[return false]
    ToPartial --&gt; RetFalse

    RetTrue --&gt; End([结束])
    RetFalse --&gt; End
    Error --&gt; End

    classDef highlight fill:#ffeb3b,stroke:#f57c00,stroke-width:3px
    style Start fill:#4caf50,color:#fff
    style End fill:#4caf50,color:#fff
</code></pre>
<h3>3.5 计算下次触发点</h3>
<p>GC 结束时，通过 pacer 计算下次触发点：</p>
<pre><code class="language-go">// 在 gcController.endCycle 中计算
// 基本公式：
// heapGoal = heapMarked * (1 + GOGC/100)
// trigger = heapGoal - runway

// 其中：
// - heapMarked: 标记阶段存活的堆大小
// - GOGC: 环境变量，默认100
// - runway: 给 GC 留出的缓冲空间，让它能在 heapGoal 前完成标记
</code></pre>
<p>简单来说，Pacer 通过测量上次 GC 的分配速率和扫描速率，计算出一个合适的触发点（Trigger），让 GC 既不会太频繁（浪费 CPU），也不会太晚（OOM），实现自适应的垃圾回收调度。</p>
<h3>3.6 STW 分析</h3>
<pre><code class="language-mermaid">graph TB
    %% 定义样式
    classDef stw fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#b71c1c;
    classDef concurrent fill:#e1f5fe,stroke:#0277bd,stroke-width:2px,color:#01579b;
    classDef trigger fill:#fff9c4,stroke:#fbc02d,stroke-dasharray: 5 5,color:#f57f17;

    %% 节点定义
    subgraph Cycle [GC 循环周期]
        direction TB
        P1(Phase 1: Sweep Termination&lt;br/&gt;清扫终止):::stw
        P2(Phase 2: Concurrent Mark&lt;br/&gt;并发标记):::concurrent
        P3(Phase 3: Mark Termination&lt;br/&gt;标记终止):::stw
        P4(Phase 4: Concurrent Sweep&lt;br/&gt;并发清扫):::concurrent
    end

    %% 触发条件
    Trigger(GC Trigger&lt;br/&gt;堆阈值/定时/手动):::trigger

    %% 连线关系
    Trigger --&gt; P1
    P1 --&gt;|开启写屏障&lt;br/&gt;SetGCPhase: _GCmark| P2
    P2 --&gt;|所有对象标记完成&lt;br/&gt;gcMarkDone| P3
    P3 --&gt;|关闭写屏障&lt;br/&gt;SetGCPhase: _GCoff| P4
    P4 --&gt;|清理结束 &amp; 等待下一轮| Trigger

    %% 补充说明
    note1[STW: 准备根对象, 清理上一轮残余] -.-&gt; P1
    note2[STW: 保证全局标记完成, 必须全局一致] -.-&gt; P3
</code></pre>
<p>我们再来看一下这张图，分析一下为什么 ① ③ 阶段需要 STW，而 ② ④ 却不需要呢？</p>
<h4>Sweep Termination - STW</h4>
<pre><code>需要做的事：
├─ 完成上一轮剩余的扫描
├─ 清理 sync.Pool
├─ 重置标记状态 (gcResetMarkState)
├─ 启用写屏障 (setGCPhase(_GCmark))
└─ 准备根对象扫描
</code></pre>
<p><strong>必须 STW 的核心原因</strong>：</p>
<ul>
<li><strong>写屏障必须同时在所有 P 上生效</strong></li>
<li>如果不 STW，某些 P 开启了写屏障，某些还没开</li>
<li>会导致指针写入不一致，漏标记对象 ❌</li>
</ul>
<h4>Mark Termination - STW</h4>
<pre><code>需要做的事：
├─ 禁用 workers 和 assists
├─ 刷新缓存 (mcache flush)
├─ 禁用写屏障
├─ 切换阶段 (setGCPhase(_GCoff))
└─ 启动清扫 (gcSweep)
</code></pre>
<p><strong>必须 STW 的核心原因</strong>：</p>
<ul>
<li><strong>需要全局一致性视图</strong>：确认所有标记工作真的完成了</li>
<li><strong>禁用写屏障必须原子</strong>：不能有些 P 关了，有些还开着</li>
<li><strong>位图状态切换</strong>：sweepgen += 2 需要在稳定状态下进行</li>
</ul>
<h4>Mark Phase - 并发</h4>
<p><strong>为什么可以并发？</strong></p>
<pre><code>有写屏障保护：
mutator 写指针 → 写屏障记录 → 标记为灰色
workers 并发标记 → 不会漏标记对象 ✓

三色不变式保证正确性：
- 强三色：黑色对象不能直接指向白色对象
- 弱三色：黑色→白色之间必有灰色对象
</code></pre>
<p><strong>关键技术</strong>：</p>
<ul>
<li><strong>写屏障</strong>：Dijkstra 插入屏障，拦截所有指针写入</li>
<li><strong>并发安全</strong>：标记位操作是原子的</li>
<li><strong>增量处理</strong>：每个 worker 独立工作，不需要全局同步</li>
</ul>
<h4>Sweep Phase - 并发</h4>
<p><strong>为什么可以并发？</strong></p>
<pre><code>扫描和分配互不干扰：
├─ 扫描：检查 span.sweepgen，CAS 获取所有权
├─ 分配：检查 span.sweepgen，只用已扫描的 span
└─ sweepgen 机制保证不会重复扫描 ✓

惰性扫描：
分配时按需扫描，保证使用的 span 都是干净的
</code></pre>
<p><strong>关键技术</strong>：</p>
<ul>
<li><strong>sweepgen 版本控制</strong>：每个 span 有独立状态</li>
<li><strong>CAS 操作</strong>：原子获取扫描所有权</li>
<li><strong>按需扫描</strong>：分配路径自动扫描，不阻塞其他操作</li>
</ul>
<h4>对比总结</h4>
<blockquote>
<p>[!IMPORTANT]</p>
<p>Sweep Termination 和 Mark Termination 需要 STW 是因为必须原子地切换写屏障状态和确认全局一致性，而 Mark Phase 和 Sweep Phase 可以并发是因为有写屏障和 sweepgen 机制保护，不需要全局同步。</p>
<p><strong>本质</strong>：STW 用于<strong>状态切换</strong>，并发用于<strong>实际工作</strong>。🎯</p>
</blockquote>
<table>
<thead>
<tr>
<th>阶段</th>
<th>STW</th>
<th>原因</th>
<th>时长</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Sweep Termination</strong></td>
<td>✋ 是</td>
<td>同步启用写屏障</td>
<td>~100μs</td>
</tr>
<tr>
<td><strong>Mark Phase</strong></td>
<td>✅ 否</td>
<td>写屏障保护</td>
<td>~数十 ms</td>
</tr>
<tr>
<td><strong>Mark Termination</strong></td>
<td>✋ 是</td>
<td>全局一致性确认</td>
<td>~100μs</td>
</tr>
<tr>
<td><strong>Sweep Phase</strong></td>
<td>✅ 否</td>
<td>sweepgen + CAS</td>
<td>~数十 ms</td>
</tr>
</tbody></table>
<h3>3.7 核心机制总结</h3>
<ol>
<li><strong>三色标记法</strong>：白色（未扫描）→ 灰色（已发现）→ 黑色（已扫描）</li>
<li><strong>混合写屏障</strong>：Dijkstra + Yuasa 保证并发标记的正确性</li>
<li><strong>分布式终止检测</strong>：Ragged Barrier 确保所有本地缓冲区都被刷新</li>
<li><strong>Mutator Assist</strong>：分配速度过快时，分配者协助标记以保持 GC 进 度</li>
<li><strong>代数机制</strong>：<code>sweepgen</code> 通过 +2 的方式区分不同 GC 周期的 span 状态</li>
</ol>
<p>整个 GC 周期是一个精密设计的并发系统，在保证程序正确性的同时，最大化地减少 STW 时间，实现了低延迟的垃圾回收。</p>
<h2>4. 工程建议</h2>
<h3>4.1 参数调优</h3>
<h4>4.1.1 GOGC 参数</h4>
<p>GOGC 控制 GC 的激进程度：</p>
<table>
<thead>
<tr>
<th align="left">GOGC 值</th>
<th align="left">含义</th>
<th align="left">效果</th>
</tr>
</thead>
<tbody><tr>
<td align="left">GOGC=off</td>
<td align="left">禁用 GC</td>
<td align="left">内存会无限增长</td>
</tr>
<tr>
<td align="left">GOGC=50</td>
<td align="left">堆增长 50% 触发</td>
<td align="left">频繁 GC，低内存使用</td>
</tr>
<tr>
<td align="left">GOGC=100</td>
<td align="left">堆增长 100% 触发（默认）</td>
<td align="left">平衡</td>
</tr>
<tr>
<td align="left">GOGC=200</td>
<td align="left">堆增长 200% 触发</td>
<td align="left">低频 GC，高内存使用</td>
</tr>
<tr>
<td align="left">GOGC=400</td>
<td align="left">堆增长 400% 触发</td>
<td align="left">极低频 GC，极高内存</td>
</tr>
</tbody></table>
<h4>4.1.2 GOMEMLIMIT</h4>
<p>Go1.19 新增的软内存限制，优先级高于 GOGC，<code>GOMEMLIMIT</code> 让 Go 程序知道&quot;不能超过多少内存&quot;，接近时自动加大 GC 力度，既防止 OOM 又提高内存利用率，是容器化部署的必备配置。</p>
<p>建议配置为：<code>GOMEMLIMIT = 容器限制 × 0.9</code>。</p>
<h3>4.2 性能优化</h3>
<ol>
<li>减少分配</li>
</ol>
<pre><code class="language-go">// ❌ 避免：频繁小对象分配
for i := 0; i &lt; n; i++ {
    s := fmt.Sprintf(&quot;%d&quot;, i)  // 每次分配
}

// ✅ 优化：复用 buffer
var buf bytes.Buffer
for i := 0; i &lt; n; i++ {
    buf.Reset()
    fmt.Fprintf(&amp;buf, &quot;%d&quot;, i)
}
</code></pre>
<ol start="2">
<li>对象池复用</li>
</ol>
<pre><code class="language-go">var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

// 使用
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
buf.Reset()
</code></pre>
<ol start="3">
<li>预分配切片</li>
</ol>
<pre><code class="language-go">// ❌ 避免：动态扩容
var s []int
for i := 0; i &lt; 10000; i++ {
    s = append(s, i)  // 多次扩容
}

// ✅ 优化：预分配
s := make([]int, 0, 10000)
for i := 0; i &lt; 10000; i++ {
    s = append(s, i)
}
</code></pre>
<ol start="4">
<li>避免指针密集结构</li>
</ol>
<pre><code class="language-go">// ❌ 避免：大量指针
type Node struct {
    Value *int
    Next  *Node
}

// ✅ 优化：值类型
type Node struct {
    Value int
    Next  *Node  // 只保留必要指针
}
</code></pre>
<ol start="5">
<li>栈分配优先</li>
</ol>
<pre><code class="language-go">// ❌ 逃逸到堆
func bad() *int {
    x := 42
    return &amp;x  // 逃逸
}

// ✅ 栈分配
func good() int {
    x := 42
    return x  // 栈上
}
</code></pre>
<h3>4.3 分析工具</h3>
<ul>
<li>go tool pprof</li>
<li>go tool trace</li>
<li>go build -gcflags -m</li>
<li>GODEBUG=&quot;gctrace=1&quot;</li>
</ul>
<p>以下面程序为例：</p>
<pre><code class="language-go">package main

import (
	&quot;net/http&quot;
	&quot;sync&quot;

  _ &quot;net/http/pprof&quot;		// pprof 需要
)

func main() {

	go func() {
		wg := sync.WaitGroup{}
		wg.Add(10)
		for i := 0; i &lt; 10; i++ {
			go func(wg *sync.WaitGroup) {
				var counter int
				for i := 0; i &lt; 1e10; i++ {
					counter++
				}
				wg.Done()
			}(&amp;wg)
		}
		wg.Wait()
	}()

	_ = http.ListenAndServe(&quot;:8080&quot;, nil)
}
</code></pre>
<ul>
<li><p>go tool pprof</p>
<p>启动程序后，访问：<a href="http://127.0.0.1:8080/debug/pprof/heap?debug=1">http://127.0.0.1:8080/debug/pprof/heap?debug=1</a></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5urh515n9j225g0tk7aw.jpg" alt=""></p>
</li>
<li><p>go build -gcflags -m</p>
<pre><code class="language-sh">➜  go build -gcflags -m main.go
# command-line-arguments
./main.go:15:7: can inline main.func1.1
./main.go:15:4: can inline main.func1.gowrap1
./main.go:20:12: inlining call to sync.(*WaitGroup).Done
./main.go:21:5: inlining call to main.func1.1
./main.go:21:5: inlining call to sync.(*WaitGroup).Done
./main.go:26:25: inlining call to http.ListenAndServe
./main.go:15:12: leaking param: wg
./main.go:12:3: moved to heap: wg
./main.go:15:7: func literal escapes to heap
./main.go:11:5: func literal escapes to heap
./main.go:26:25: &amp;http.Server{...} escapes to heap
</code></pre>
</li>
<li><p>GODEBUG=&quot;gctrace=1&quot;</p>
<pre><code class="language-shell">➜  GODEBUG=&quot;gctrace=1&quot; go run main.go
gc 1 @0.003s 3%: 0.056+0.93+0.074 ms clock, 0.68+0.18/0.53/0+0.89 ms cpu, 3-&gt;4-&gt;1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 2 @0.005s 5%: 0.060+1.2+0.061 ms clock, 0.72+0.30/0.75/0+0.73 ms cpu, 3-&gt;4-&gt;1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 3 @0.007s 6%: 0.037+0.92+0.079 ms clock, 0.45+0.32/0.80/0+0.95 ms cpu, 3-&gt;3-&gt;1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 4 @0.008s 6%: 0.068+1.4+0.058 ms clock, 0.82+0.20/0.78/0+0.69 ms cpu, 3-&gt;4-&gt;1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 5 @0.011s 6%: 0.015+0.44+0.013 ms clock, 0.18+0.020/0.85/1.0+0.15 ms cpu, 3-&gt;4-&gt;1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 6 @0.014s 5%: 0.036+0.42+0.019 ms clock, 0.43+0.051/0.97/1.4+0.22 ms cpu, 3-&gt;3-&gt;2 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 7 @0.017s 6%: 0.067+1.0+0.029 ms clock, 0.81+0.23/2.4/4.8+0.35 ms cpu, 4-&gt;4-&gt;3 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 8 @0.027s 5%: 0.42+1.0+0.035 ms clock, 5.1+0.11/2.0/1.7+0.42 ms cpu, 5-&gt;6-&gt;4 MB, 6 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 9 @0.034s 5%: 0.078+0.83+0.023 ms clock, 0.94+0.28/1.9/1.6+0.28 ms cpu, 7-&gt;8-&gt;4 MB, 8 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 10 @0.037s 5%: 0.029+0.66+0.012 ms clock, 0.35+0.069/1.5/2.6+0.15 ms cpu, 8-&gt;8-&gt;3 MB, 9 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 11 @0.039s 5%: 0.041+0.58+0.003 ms clock, 0.49+0.059/1.4/2.5+0.045 ms cpu, 6-&gt;6-&gt;3 MB, 7 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 12 @0.041s 6%: 0.086+0.94+0.010 ms clock, 1.0+0.83/2.1/0.19+0.12 ms cpu, 6-&gt;8-&gt;4 MB, 7 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 13 @0.043s 6%: 0.064+0.73+0.008 ms clock, 0.77+0.30/1.6/1.2+0.096 ms cpu, 8-&gt;9-&gt;4 MB, 9 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 14 @0.044s 6%: 0.043+0.73+0.028 ms clock, 0.51+0.12/1.6/1.9+0.34 ms cpu, 7-&gt;9-&gt;4 MB, 9 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 15 @0.046s 7%: 0.077+1.0+0.022 ms clock, 0.92+2.5/2.3/0.047+0.27 ms cpu, 7-&gt;10-&gt;5 MB, 9 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 16 @0.048s 7%: 0.057+0.59+0.011 ms clock, 0.69+0.83/1.4/0.48+0.13 ms cpu, 8-&gt;10-&gt;4 MB, 10 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 17 @0.050s 7%: 0.025+0.52+0.003 ms clock, 0.30+0.098/1.4/2.1+0.039 ms cpu, 8-&gt;9-&gt;3 MB, 10 MB goal, 0 MB stacks, 0 MB globals, 12 P
</code></pre>
</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Go 底层原理丨内存模型</title>
      <link>https://hedon.top/blog/go-memory-model/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-memory-model/</guid>
      <pubDate>Mon, 17 Nov 2025 14:30:00 GMT</pubDate>
      <description>本文基于 Go 1.25.3 源码，从第一性原理出发，深入探讨 Go 的内存模型，包括内存分配机制、实现原理、使用场景以及最新特性。</description>
      <category>Go</category><category>内存管理</category><category>malloc</category>
      <content:encoded><![CDATA[<blockquote>
<p>[!NOTE]</p>
<p>💡 本文基于 Go 1.25.3 源码编写，相比 Go 1.16 版本，增加了 User Arena、Weak Pointer、Cleanup 机制等重要特性。后续版本可能会有变化。建议结合实际使用的 Go 版本阅读相关源码。</p>
<p>特此声明，本篇是笔者与 Google Gemini 3Pro 共创所作，非常庆幸在当今 AI 时代下获取知识已是如此便利，且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑，均由笔者把控和审阅，欢迎放心阅读。</p>
</blockquote>
<h2>结论先行</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/68747470733a2f2f747661312e73696e61696d672e636e2f6c617267652f6536633964323465677931683575666b306f3079756a3231657a3075303736662e6a7067.jpeg" alt="Go内存模型架构"></p>
<p>Go 的内存分配器设计源于 <strong>TCMalloc</strong>，采用了多层级缓存架构来减少锁竞争并提高性能。核心设计如下：</p>
<h3>核心架构</h3>
<ul>
<li>Go 将堆内存抽象为 <strong>mheap</strong> 结构体；</li>
<li>Go 进程会从虚拟内存中申请 n 个 <strong>heapArena</strong>（64 位系统每个 64MB）；</li>
<li>每个 heapArena 被按需划分成不同 class 的 <strong>mspan</strong>，共有 <strong>68</strong> 个 size class；</li>
<li>每个 mspan 由 n 个相同大小的 span 组成；</li>
<li>为了快速定位合适的 span，为 mheap 建立了 <strong>136</strong> 个中央索引 <strong>mcentral</strong>；</li>
<li>每个 mcentral 存储对应 class 的 mspan，每种 mspan 又划分为 gc scan 和 no scan 两种，故共有 68 × 2 = 136 个 mcentral；</li>
<li>为了解决中央索引的并发锁竞争问题，为每一个 P（线程）建立一个本地缓存 <strong>mcache</strong>；</li>
<li>每个 mcache 存储 <strong>136</strong> 个 span，分别是每种 class 的 mspan 的一个 scan 和 noscan 的 span。</li>
</ul>
<h3>内存分配策略</h3>
<ul>
<li>Go 中根据对象大小分为 <strong>tiny</strong>、<strong>small</strong> 和 <strong>large</strong> 三种对象；</li>
<li>tiny (0~16B 无指针) 对象主要分配到 class 2 的 span 中（通过 tiny allocator）；</li>
<li>small (16B~32KB) 对象会被分配到 class 2 ~ class 67 的 span 中；</li>
<li>class 1 (8B) 仅用于 64 位平台上的单指针对象，使用极少；</li>
<li>large (&gt;32KB) 对象会量身定做分配到 class0 的 span 中，直接从 mheap 上申请；</li>
<li>为对象分配内存时，会先从 mcache 上找 span，找不到就去 mcentral 上交换，还找不到就去 mheap 上申请，最后找不到就 OOM。</li>
</ul>
<h2>1. 协程栈</h2>
<h3>1.1 作用</h3>
<p>协程栈是 Go 协程执行的核心数据结构，主要用于：</p>
<ul>
<li><strong>记录执行路径</strong>：追踪函数调用链</li>
<li><strong>存储局部变量</strong>：每个栈帧保存函数的局部变量</li>
<li><strong>函数传参</strong>：通过栈传递函数参数</li>
<li><strong>保存返回值</strong>：存储函数的返回值</li>
</ul>
<h3>1.2 位置</h3>
<ul>
<li>Go 协程栈位于 <strong>Go 堆内存</strong>上（而非操作系统栈）</li>
<li>Go 堆内存位于<strong>操作系统虚拟内存</strong>上</li>
<li>这种设计使得 Go 可以灵活管理协程栈的大小</li>
</ul>
<h3>1.3 图解</h3>
<p>以下面的代码为例：</p>
<pre><code class="language-go">package main

func sum(a, b int) int {
  sum := 0
  sum = a + b
  return sum
}

func main() {
  a := 3
  b := 5
  print(sum(a, b))
}
</code></pre>
<p>栈帧结构如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/68747470733a2f2f747661312e73696e61696d672e636e2f6c617267652f65366339643234656779316835746934766b3936356a32316937307530676f772e6a7067.jpeg" alt="协程栈结构"></p>
<h3>1.4 参数传递</h3>
<p><strong>Go 采用值传递</strong></p>
<ul>
<li>传递结构体时：<strong>拷贝结构体中的全部内容</strong></li>
<li>传递结构体指针时：<strong>拷贝结构体指针</strong>（8 字节）</li>
</ul>
<h3>1.5 栈大小</h3>
<p>Go 1.25.3 中，协程栈的初始大小为 <strong>2KB</strong>，相比早期版本（如 Go 1.2 的 8KB）更加轻量。</p>
<h3>1.6 逃逸分析</h3>
<p>不是所有的变量都能放在协程栈上。以下三种情况会导致变量<strong>逃逸到堆</strong>上：</p>
<h4>1. 指针逃逸</h4>
<p>函数返回局部变量的指针：</p>
<pre><code class="language-go">func newInt() *int {
    x := 42
    return &amp;x  // x 逃逸到堆
}
</code></pre>
<h4>2. 空接口逃逸</h4>
<p>函数参数为 <code>interface{}</code>，编译器无法确定具体类型：</p>
<pre><code class="language-go">func println(v interface{}) {  // v 可能逃逸
    // ...
}
</code></pre>
<h4>3. 大变量逃逸</h4>
<p>变量太大，栈帧放不下。在 64 位机器中，一般超过 <strong>64KB</strong> 的变量就会逃逸。</p>
<h3>1.7 栈扩容</h3>
<p>Go 栈的初始空间为 2KB。在函数调用前会执行 <code>morestack</code> 判断栈空间是否足够。</p>
<h4>栈扩容策略演进</h4>
<ul>
<li><p><strong>分段栈</strong>（Go 1.3 之前）</p>
<ul>
<li>优点：没有空间浪费</li>
<li>缺点：栈帧在不连续的空间之间横跳，性能较差（&quot;热分裂&quot;问题）</li>
</ul>
</li>
<li><p><strong>连续栈</strong>（Go 1.3 及之后）</p>
<ul>
<li>优点：空间连续，性能更好</li>
<li>缺点：扩容时需要拷贝，开销较大</li>
<li>策略：小于 1KB 时翻倍，否则增长 25%</li>
</ul>
</li>
</ul>
<hr>
<h2>2. 虚拟内存单元 heapArena</h2>
<h3>2.1 概述</h3>
<ul>
<li>在物理内存为 64GB 的机器中，每个 Go 进程最多可被分配到 <strong>256TB</strong> 的虚拟内存</li>
<li>Go 的虚拟内存单元为 <code>heapArena</code>，每次申请 <strong>64MB</strong>（64 位非 Windows 系统）</li>
<li>最多可以申请 <strong>2²⁰</strong> (约 100 万) 个 heapArena</li>
<li>所有的 <code>heapArena</code> 组成了 <code>mheap</code>（Go 堆内存）</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/68747470733a2f2f747661312e73696e61696d672e636e2f6c617267652f6536633964323465677931683575647a7a6c3436316a3231696130656b6468772e6a7067.jpeg" alt="heapArena 结构"></p>
<blockquote>
<p>💡 <strong>相关阅读</strong>：<a href="https://hedon954.github.io/noteSite/cs/os/04-os-store.html#_6-%E8%99%9A%E6%8B%9F%E5%86%85%E5%AD%98">操作系统 - 虚拟内存</a></p>
</blockquote>
<h3>2.2 底层结构</h3>
<p><code>heapArena</code> 定义在 <a href="https://github.com/golang/go/blob/go1.25.3/src/runtime/mheap.go#L266">runtime/mheap.go#L266</a>：</p>
<pre><code class="language-go">type heapArena struct {
    _ sys.NotInHeap

    // spans 将 arena 中的虚拟页 ID 映射到 mspan
    spans [pagesPerArena]*mspan

    // pageInUse 是位图，标记哪些页正在被使用
    pageInUse [pagesPerArena / 8]uint8

    // pageMarks 用于 GC，标记哪些 span 有被标记的对象
    pageMarks [pagesPerArena / 8]uint8

    // pageSpecials 标记哪些 span 有 special 记录（finalizer 等）
    pageSpecials [pagesPerArena / 8]uint8

    // pageUseSpanInlineMarkBits 标记使用内联 mark bits 的 span
    pageUseSpanInlineMarkBits [pagesPerArena / 8]uint8

    // checkmarks 用于 GC 调试
    checkmarks *checkmarksMap

    // zeroedBase 标记第一个未使用且已归零的页
    zeroedBase uintptr
}
</code></pre>
<h3>2.3 分配策略对比</h3>
<table>
<thead>
<tr>
<th>线性分配</th>
<th>链表分配</th>
<th>分级分配</th>
</tr>
</thead>
<tbody><tr>
<td><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h5uebsvbasj21ei0ne76j.jpg" alt="线性分配"></td>
<td><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h5ueba2f53j21fi0n641m.jpg" alt="链表分配"></td>
<td><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h5uedvcmcdj21dw0mm79w.jpg" alt="分级分配"></td>
</tr>
<tr>
<td>实现简单，但内存碎片较多</td>
<td>将空闲块连接起来，牺牲部分性能来缓解内存碎片</td>
<td>将内存按级别分成很多块，根据对象大小存放在能容纳它的最小块中</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>Go 采用分级分配策略</strong>，参考了 <a href="http://goog-perftools.sourceforge.net/doc/tcmalloc.html">TCMalloc</a>，将每一个级定义为 <code>mspan</code>。</p>
</blockquote>
<h2>3. 内存管理单元 mspan</h2>
<h3>3.1 概述</h3>
<ul>
<li>Go 使用内存时的基本单位是 <code>mspan</code></li>
<li>每个 <code>mspan</code> 由 N 个相同大小的 <code>span</code> 组成</li>
<li>Go 1.25.3 中有 <strong>68</strong> 种 size class（class 0 ~ class 67）</li>
</ul>
<h4>Size Class 表（部分）</h4>
<pre><code class="language-go">// runtime/sizeclasses.go

// class  bytes/obj  bytes/span  objects  tail waste  max waste
//     0          0           0        0           0      0.00%
//     1          8        8192     1024           0     87.50%
//     2         16        8192      512           0     43.75%
//     3         24        8192      341           8     29.24%
//     4         32        8192      256           0     11.72%
//   ...
//    64      24576       24576        1           0     11.45%
//    65      27264       81920        3         128     10.00%
//    66      28672       57344        2           0      4.91%
//    67      32768       32768        1           0     12.50%
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h5uemrzou7j217a0kwadr.jpg" alt="mspan 结构"></p>
<h3>3.2 底层结构</h3>
<p><code>mspan</code> 定义在 <a href="https://github.com/golang/go/blob/go1.25.3/src/runtime/mheap.go#L420">runtime/mheap.go#L420</a>：</p>
<pre><code class="language-go">type mspan struct {
    _ sys.NotInHeap

    // 双向链表
    next *mspan
    prev *mspan
    list *mSpanList

    // 内存地址和大小
    startAddr uintptr    // 起始地址
    npages    uintptr    // 页数（每页 8KB）

    // 分配信息
    freeindex        uint16      // 下一个空闲对象的索引
    freeIndexForScan uint16      // GC 扫描器使用的索引（Go 1.19+）
    nelems           uint16      // 对象总数
    allocCount       uint16      // 已分配对象数

    // 位图
    allocBits  *gcBits   // 分配位图
    gcmarkBits *gcBits   // GC 标记位图
    pinnerBits *gcBits   // 固定对象位图（Go 1.21+）

    // 元数据
    spanclass  spanClass      // size class 和 noscan 标志
    elemsize   uintptr        // 对象大小
    state      mSpanStateBox  // mspan 状态
    sweepgen   uint32         // 清扫代数

    // 新增字段（Go 1.20+）
    isUserArenaChunk bool       // 是否为 user arena chunk
    userArenaChunkFree addrRange // user arena 管理

    // GreenTeaGC 实验字段（Go 1.24+）
    scanIdx uint16  // 扫描索引

    // 大对象类型信息（Go 1.22+）
    largeType *_type

    // Special 记录
    speciallock mutex
    specials    *special
}
</code></pre>
<h3>3.3 关键字段说明</h3>
<h4>3.3.1 分配位图（allocBits）</h4>
<p>使用位图标记对象是否已分配：</p>
<ul>
<li><code>0</code> 表示空闲</li>
<li><code>1</code> 表示已分配</li>
</ul>
<h4>3.3.2 双索引设计（Go 1.19+）</h4>
<ul>
<li><code>freeindex</code>：分配器使用</li>
<li><code>freeIndexForScan</code>：GC 扫描器使用</li>
</ul>
<p>这样设计避免了竞争条件，确保 GC 只在对象完全初始化后才能看到它。</p>
<h4>3.3.2 状态机</h4>
<pre><code class="language-go">const (
    mSpanDead   mSpanState = iota  // 未使用
    mSpanInUse                     // 正在使用
    mSpanManual                    // 手动管理（如栈）
)
</code></pre>
<hr>
<h2>4. 中心索引 mcentral</h2>
<h3>4.1 概述</h3>
<p>heapArena 中的 <code>mspan</code> 不是一开始就全部划分好的，而是<strong>按需划分</strong>。</p>
<p>由于每个 heapArena 中的 mspan 分布是动态的，为了给要分配空间的对象快速定位到合适的 mspan，Go 定义了中心索引 <code>mcentral</code>。</p>
<ul>
<li>总共有 <strong>136</strong> 个 <code>mcentral</code> 结构体</li>
<li>其中 <strong>68</strong> 个用于需要 GC 扫描的对象（scan）</li>
<li>另外 <strong>68</strong> 个用于无需 GC 扫描的对象（noscan）</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5uf0kf27jj21iw0rg0xy.jpg" alt="mcentral 结构"></p>
<h3>4.2 底层结构</h3>
<p><code>mcentral</code> 定义在 <a href="https://github.com/golang/go/blob/go1.25.3/src/runtime/mcentral.go#L22">runtime/mcentral.go#L22</a>：</p>
<pre><code class="language-go">type mcentral struct {
    _ sys.NotInHeap

    spanclass spanClass  // size class 级别

    // 双缓冲设计：配合 GC 的 sweepgen
    partial [2]spanSet  // 有空闲对象的 span 列表
    full    [2]spanSet  // 无空闲对象的 span 列表
}
</code></pre>
<h3>4.3 双缓冲机制</h3>
<p><code>mcentral</code> 使用双缓冲配合 GC 的清扫机制：</p>
<ul>
<li><code>sweepgen</code> 每次 GC 增加 2</li>
<li><code>partial[sweepgen/2%2]</code> 是已清扫的 span</li>
<li><code>partial[1-sweepgen/2%2]</code> 是未清扫的 span</li>
</ul>
<p>这种设计使得 GC 和分配可以并发进行，无需等待所有 span 都清扫完毕。</p>
<h2>5. 线程缓存 mcache</h2>
<h3>5.1 概述</h3>
<p><code>mcentral</code> 是一个中心索引，修改它需要使用互斥锁进行保护，锁竞争会造成性能问题。</p>
<p>Go 参考 <strong>GMP 模型</strong>，为每个 P（逻辑处理器）建立了<strong>线程本地缓存</strong> <code>mcache</code>，极大缓解了并发锁争夺的性能消耗。</p>
<p>设计要点：</p>
<ul>
<li>每个 <strong>P</strong> 有一个 <code>mcache</code></li>
<li>对于每一种 size class，取一个 scan 和一个 noscan span</li>
<li>一个 <code>mcache</code> 拥有 <strong>136</strong> 个 <code>mspan</code>（68 个 scan + 68 个 noscan）</li>
<li>当本地缓存用完后，才需要上锁去 mcentral 交换</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5ufd5lnvxj21ez0u0n3h.jpg" alt="mcache 结构"></p>
<h3>5.2 底层结构</h3>
<p><code>mcache</code> 定义在 <a href="https://github.com/golang/go/blob/go1.25.3/src/runtime/mcache.go#L20">runtime/mcache.go#L20</a>：</p>
<pre><code class="language-go">type mcache struct {
    _ sys.NotInHeap

    // 内存分析相关
    nextSample  int64   // 触发堆采样的字节数
    memProfRate int     // 缓存的内存分析速率
    scanAlloc   uintptr // 可扫描对象的已分配字节数

    // 微对象分配器（&lt;16B 的对象）
    tiny       uintptr  // 当前 tiny block 的起始地址
    tinyoffset uintptr  // tiny block 中的偏移
    tinyAllocs uintptr  // tiny 分配次数

    // 核心：136 个 mspan
    alloc [numSpanClasses]*mspan  // numSpanClasses = 136

    // 栈缓存
    stackcache [_NumStackOrders]stackfreelist

    // GC 相关
    flushGen atomic.Uint32  // 上次 flush 时的 sweepgen
}
</code></pre>
<h3>5.3 与 P 的关系</h3>
<pre><code class="language-go">type p struct {
    // ...
    mcache *mcache
    // ...
}
</code></pre>
<p>每个 P 持有一个 mcache 指针，实现<strong>无锁快速路径</strong>。</p>
<h2>6. 堆 mheap</h2>
<p><code>mheap</code> 是 Go 堆内存的全局管理者，统筹所有内存分配。</p>
<h3>6.1 底层结构</h3>
<p><code>mheap</code> 定义在 <a href="https://github.com/golang/go/blob/go1.25.3/src/runtime/mheap.go#L64">runtime/mheap.go#L64</a>：</p>
<pre><code class="language-go">type mheap struct {
    _ sys.NotInHeap

    // 全局锁（必须在系统栈上获取）
    lock mutex

    // 页分配器
    pages pageAlloc

    // GC 相关
    sweepgen       uint32           // 清扫代数
    pagesInUse     atomic.Uintptr   // 使用中的页数
    pagesSwept     atomic.Uint64    // 已清扫的页数
    sweepPagesPerByte float64       // 比例清扫速率

    // Arena 管理（二级映射）
    arenas [1 &lt;&lt; arenaL1Bits]*[1 &lt;&lt; arenaL2Bits]*heapArena
    heapArenas []arenaIdx  // 所有已分配的 arena
    curArena struct {      // 当前正在增长的 arena
        base, end uintptr
    }

    // 中心索引（136 个）
    central [numSpanClasses]struct {
        mcentral mcentral
        pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
    }

    // mspan 是按需分级的，这里保存所有已划分的 mspan
    allspans []*mspan

    // 各种 fixalloc 分配器
    spanalloc             fixalloc  // 分配 mspan
    cachealloc            fixalloc  // 分配 mcache
    specialfinalizeralloc fixalloc  // 分配 finalizer
    specialWeakHandleAlloc fixalloc // 分配弱指针（Go 1.23+）
    specialCleanupAlloc   fixalloc  // 分配 cleanup（Go 1.24+）
    specialPinCounterAlloc fixalloc // 分配 pin counter（Go 1.21+）
    // ... 更多 special 分配器

    speciallock mutex  // 保护 special 分配器
    arenaHintAlloc fixalloc

    // 【新特性】User Arena 状态（Go 1.20+）
    userArena struct {
        arenaHints     *arenaHint
        quarantineList mSpanList  // 等待释放的 span
        readyList      mSpanList  // 可复用的 span
    }
    userArenaArenas []arenaIdx

    // 【新特性】Cleanup ID 计数器（Go 1.24+）
    cleanupID uint64

    // 【新特性】弱指针映射（Go 1.23+）
    immortalWeakHandles immortalWeakHandleMap
}
</code></pre>
<h3>6.2 关键设计</h3>
<h4>1. 二级映射（arenas）</h4>
<p>为了支持稀疏的虚拟地址空间，使用二级数组：</p>
<ul>
<li>L1 map：索引 arena 组</li>
<li>L2 map：索引具体的 heapArena</li>
</ul>
<p>在大多数 64 位平台上，<code>arenaL1Bits = 0</code>，退化为单级映射。</p>
<h4>2. Cache Line 对齐</h4>
<pre><code class="language-go">pad [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
</code></pre>
<p>填充字节避免伪共享（false sharing），提升多核性能。</p>
<h4>3. 页回收器</h4>
<pre><code class="language-go">reclaimIndex  atomic.Uint64  // 下一个要回收的页索引
reclaimCredit atomic.Uintptr // 额外回收的页的信用
</code></pre>
<p>后台异步回收未使用的页，减少内存占用。</p>
<h2>7. 内存分配</h2>
<h3>7.1 对象分级</h3>
<p>Go 根据对象大小将分配分为三类：</p>
<table>
<thead>
<tr>
<th>类型</th>
<th>大小范围</th>
<th>分配方式</th>
<th>Size Class</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Tiny</strong></td>
<td>0 ~ 16B（无指针）</td>
<td>多个对象合并到 16B</td>
<td>class 2</td>
</tr>
<tr>
<td><strong>Tiny</strong></td>
<td>8B（单指针）</td>
<td>64 位上使用 class 1</td>
<td>class1</td>
</tr>
<tr>
<td><strong>Small</strong></td>
<td>16B ~ 32KB</td>
<td>从 mcache 分配</td>
<td>class 2 ~ 67</td>
</tr>
<tr>
<td><strong>Large</strong></td>
<td>&gt; 32KB</td>
<td>直接从 mheap 分配</td>
<td>class 0</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>注意</strong>：Class 1 (8B) 在实践中使用极少，仅在 64 位平台上分配恰好 8 字节且包含指针的对象时使用。绝大多数 8 字节对象要么无指针（走 tiny allocator），要么是结构体的一部分。</p>
</blockquote>
<h4>7.1.1 Tiny 对象分配</h4>
<p>对于 <strong>&lt; 16B</strong> 且<strong>无指针</strong>的对象，Go 使用特殊的 <strong>tiny allocator</strong>：</p>
<pre><code class="language-go">// mcache 中的 tiny allocator
tiny       uintptr  // 当前 tiny block 起始地址
tinyoffset uintptr  // 已使用的偏移量
tinyAllocs uintptr  // tiny 分配计数
</code></pre>
<ol>
<li>尝试在当前 tiny block 中分配（根据对齐要求）</li>
<li>如果空间不足，从 class 2 (16B) 的 span 中获取新的 tiny block</li>
<li>多个 tiny 对象共享同一个 16B 块，减少内存浪费</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5ulg364i2j21nk09uq4s.jpg" alt="Tiny 对象分配"></p>
<blockquote>
<p><strong>Class 1 的特殊性</strong>：Class 1 (8B) 在实践中使用极少，仅在 64 位平台上分配恰好 8 字节且包含指针的对象时使用。典型例子如单个逃逸的指针变量。由于这种场景非常罕见，class 1 基本处于&quot;保留但不常用&quot;的状态。大多数 8 字节对象要么：</p>
<ul>
<li>无指针 → 走 tiny allocator（class 2）</li>
<li>是结构体字段的一部分 → 随结构体一起分配</li>
<li>是栈上变量 → 不进行堆分配</li>
</ul>
</blockquote>
<h4>7.1.2 Small 对象分配</h4>
<p>对于 <strong>16B ~ 32KB</strong> 的对象：</p>
<ol>
<li>根据对象大小查表确定 size class</li>
<li>在 mcache 中寻找对应 class 的 span</li>
<li>从 span 的 allocBits 中找到空闲 slot</li>
<li>如果 mcache 中 span 已满，去 mcentral 交换</li>
<li>如果 mcentral 也没有，去 mheap 申请</li>
</ol>
<h4>7.1.3 Large 对象分配</h4>
<p>对于 <strong>&gt; 32KB</strong> 的大对象：量身定做 class0，直接从 mheap 上申请内存。</p>
<h3>7.2 mcache 替换</h3>
<p>在 mcache 中，每个 class 的 mspan 只有一个，当 mspan 满了之后，会从 mcentral 中兑换一个新的。</p>
<pre><code class="language-go">func (c *mcache) refill(spc spanClass) {
    // 1. 释放当前 span 回 mcentral
    s := c.alloc[spc]
    if s != &amp;emptymspan {
        if s.sweepgen != mheap_.sweepgen+3 {
            throw(&quot;bad sweepgen in refill&quot;)
        }
        mheap_.central[spc].mcentral.uncacheSpan(s)
    }

    // 2. 从 mcentral 获取新的 span
    s = mheap_.central[spc].mcentral.cacheSpan()
    if s == nil {
        throw(&quot;out of memory&quot;)
    }

    // 3. 更新 mcache
    c.alloc[spc] = s
}
</code></pre>
<h3>7.3 mcentral 扩容</h3>
<p>mcentral 中，只有有限数量的 mspan，当 mspan 缺少时，会像 mheap 中开辟新的 heapArena，并申请对应 class 的 span。</p>
<pre><code class="language-go">func (c *mcentral) grow() *mspan {
    // 1. 计算需要的页数
    npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
    size := uintptr(class_to_size[c.spanclass.sizeclass()])

    // 2. 从 mheap 分配新的 span
    s := mheap_.alloc(npages, c.spanclass)
    if s == nil {
        return nil
    }

    // 3. 初始化 span
    n := (npages &lt;&lt; pageShift) / size
    s.limit = s.base() + size*n

    return s
}
</code></pre>
<h3>7.4 mallocgc 源码分析</h3>
<p><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/malloc.go#L1014">mallocgc</a> 是 Go 内存分配的核心函数，核心结构如下：</p>
<pre><code class="language-markdown">mallocgc
├── mallocgcTiny // Tiny 对象 (0~16B, 无指针)
├── mallocgcSmallNoscan // Small 对象 (16B~32KB, 无指针)
├── mallocgcSmallScanNoHeader // Small 对象 (带指针, 无 header)
├── mallocgcSmallScanHeader // Small 对象 (带指针, 有 header)
└── mallocgcLarge // Large 对象 (&gt;32KB)
</code></pre>
<p>源码注释如下（省略了与内存分配无关的次要代码）：</p>
<pre><code class="language-go">func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // ...

    // 零大小分配的快速路径
    if size == 0 {
        return unsafe.Pointer(&amp;zerobase)
    }

    // ========== 核心分配逻辑 ==========
    var x unsafe.Pointer
    var elemsize uintptr

    if size &lt;= maxSmallSize-gc.MallocHeaderSize {
        // 小对象分配
        if typ == nil || !typ.Pointers() {
            // 无指针对象
            if size &lt; maxTinySize {
                x, elemsize = mallocgcTiny(size, typ)        // Tiny 路径
            } else {
                x, elemsize = mallocgcSmallNoscan(size, typ, needzero)  // Small Noscan
            }
        } else {
            // 有指针对象（必须归零）
            if !needzero {
                throw(&quot;objects with pointers must be zeroed&quot;)
            }
            if heapBitsInSpan(size) {
                x, elemsize = mallocgcSmallScanNoHeader(size, typ)  // 位图在 span 中
            } else {
                x, elemsize = mallocgcSmallScanHeader(size, typ)    // 需要 malloc header
            }
        }
    } else {
        // 大对象分配
        x, elemsize = mallocgcLarge(size, typ, needzero)
    }

  	// ...
    return x
}
</code></pre>
<h4>7.4.1 Tiny 对象分配：mallocgcTiny</h4>
<blockquote>
<p>用于 &lt; 16B 且无指针的对象，多个对象合并到 16B 块中。<code>mallocgcTiny</code> 进行了以下优化：</p>
</blockquote>
<ul>
<li><p>对齐优化：根据大小选择合适的对齐</p>
</li>
<li><p>空间复用：多个对象共享 16B 块</p>
</li>
<li><p>无锁快速路径：nextFreeFast 尝试无锁获取</p>
</li>
<li><p>平均浪费率：约 12.5%（远低于独立分配的 87.5%）</p>
</li>
</ul>
<pre><code class="language-go">func mallocgcTiny(size uintptr, typ *_type) (unsafe.Pointer, uintptr) {
    // ========== 1. 获取 M 和 mcache ==========
    mp := acquirem()
    mp.mallocing = 1

    c := getMCache(mp)
    off := c.tinyoffset

    // ========== 2. 对齐计算 ==========
    if size&amp;7 == 0 {
        off = alignUp(off, 8)   // 8 字节对齐
    } else if goarch.PtrSize == 4 &amp;&amp; size == 12 {
        off = alignUp(off, 8)   // 32 位特殊情况
    } else if size&amp;3 == 0 {
        off = alignUp(off, 4)   // 4 字节对齐
    } else if size&amp;1 == 0 {
        off = alignUp(off, 2)   // 2 字节对齐
    }

    // ========== 3. 尝试在现有 tiny block 中分配 ==========
    if off+size &lt;= maxTinySize &amp;&amp; c.tiny != 0 {
        x := unsafe.Pointer(c.tiny + off)
        c.tinyoffset = off + size
        c.tinyAllocs++
        mp.mallocing = 0
        releasem(mp)
        return x, maxTinySize
    }

    // ========== 4. 需要新的 tiny block ==========
    span := c.alloc[tinySpanClass]
    v := nextFreeFast(span)
    if v == 0 {
        v, span, _ = c.nextFree(tinySpanClass)
    }

    x := unsafe.Pointer(v)
    (*[2]uint64)(x)[0] = 0  // 清零前 16 字节
    (*[2]uint64)(x)[1] = 0

    // ========== 5. 更新 tiny allocator 状态 ==========
    if !raceenabled &amp;&amp; (size &lt; c.tinyoffset || c.tiny == 0) {
        c.tiny = uintptr(x)
        c.tinyoffset = size
    }
    size = maxTinySize

    // ... 发布屏障和返回
    publicationBarrier()
    return x, size
}
</code></pre>
<h4>7.4.2 小对象无扫描分配：mallocgcSmallNoscan</h4>
<blockquote>
<p>用于 16B~32KB 且无指针的对象。</p>
</blockquote>
<p>关键点：</p>
<ul>
<li><p>查表优化：两个查找表覆盖不同大小范围</p>
</li>
<li><p>延迟归零：只在需要时归零</p>
</li>
<li><p>GC 协作：黑色分配或设置 freeIndexForScan</p>
</li>
<li><p>发布屏障：确保内存可见性</p>
</li>
</ul>
<pre><code class="language-go">func mallocgcSmallNoscan(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {
    mp := acquirem()
    mp.mallocing = 1

    c := getMCache(mp)

    // ========== 1. 确定 size class ==========
    var sizeclass uint8
    if size &lt;= gc.SmallSizeMax-8 {
        sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
    } else {
        sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)]
    }
    size = uintptr(gc.SizeClassToSize[sizeclass])

    // ========== 2. 从 mcache 分配 ==========
    spc := makeSpanClass(sizeclass, true)  // true = noscan
    span := c.alloc[spc]
    v := nextFreeFast(span)
    if v == 0 {
        v, span, checkGCTrigger = c.nextFree(spc)
    }

    // ========== 3. 按需归零 ==========
    x := unsafe.Pointer(v)
    if needzero &amp;&amp; span.needzero != 0 {
        memclrNoHeapPointers(x, size)
    }

    // ========== 4. 发布屏障 ==========
    publicationBarrier()

    // ========== 5. GC 期间分配黑色 ==========
    if writeBarrier.enabled {
        gcmarknewobject(span, uintptr(x))
    } else {
        span.freeIndexForScan = span.freeindex
    }

    // ... 返回
    return x, size
}
</code></pre>
<h4>7.4.3 小对象有扫描分配（无 Header）：mallocgcSmallScanNoHeader</h4>
<blockquote>
<p>用于带指针的小对象，且堆位图在 span 中。</p>
</blockquote>
<p>关键点：</p>
<ul>
<li><p>堆位图设置：heapSetTypeNoHeader 根据类型设置指针位图</p>
</li>
<li><p>扫描统计：累加 scanAlloc 用于 GC 调度</p>
</li>
<li><p>8 字节优化：64 位平台的特殊处理</p>
</li>
</ul>
<pre><code class="language-go">func mallocgcSmallScanNoHeader(size uintptr, typ *_type) (unsafe.Pointer, uintptr) {
    mp := acquirem()
    mp.mallocing = 1

    c := getMCache(mp)

    // ========== 1. 确定 size class ==========
    sizeclass := gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
    spc := makeSpanClass(sizeclass, false)  // false = scan

    // ========== 2. 分配和归零 ==========
    span := c.alloc[spc]
    v := nextFreeFast(span)
    if v == 0 {
        v, span, checkGCTrigger = c.nextFree(spc)
    }
    x := unsafe.Pointer(v)
    if span.needzero != 0 {
        memclrNoHeapPointers(x, size)
    }

    // ========== 3. 设置堆位图（类型信息）==========
    if goarch.PtrSize == 8 &amp;&amp; sizeclass == 1 {
        // 8 字节 class 在 64 位平台已预设指针位
        c.scanAlloc += 8
    } else {
        c.scanAlloc += heapSetTypeNoHeader(uintptr(x), size, typ, span)
    }
    size = uintptr(gc.SizeClassToSize[sizeclass])

    // ========== 4. 发布屏障 + GC 协作 ==========
    publicationBarrier()
    if writeBarrier.enabled {
        gcmarknewobject(span, uintptr(x))
    } else {
        span.freeIndexForScan = span.freeindex
    }

    // ... 返回
    return x, size
}
</code></pre>
<h4>7.4.4 小对象有扫描分配（有 Header）：mallocgcSmallScanHeader</h4>
<blockquote>
<p>用于带指针的小对象，需要 malloc header 存储类型信息。</p>
</blockquote>
<p>Malloc Header 设计：</p>
<pre><code>+------------------+
| *_type (header)  丨  &lt;- 指向类型元数据的指针
+------------------+
| 实际对象数据       |  &lt;- x 指向这里
+------------------+
</code></pre>
<p>为什么需要 Header：</p>
<ul>
<li><p>当对象较大且堆位图不在 span 中时，需要额外存储类型信息</p>
</li>
<li><p>Header 存储类型指针，GC 可以快速找到对象的类型信息</p>
</li>
<li><p>权衡：增加少量空间换取更快的 GC 扫描</p>
</li>
</ul>
<pre><code class="language-go">func mallocgcSmallScanHeader(size uintptr, typ *_type) (unsafe.Pointer, uintptr) {
    mp := acquirem()
    mp.mallocing = 1

    c := getMCache(mp)

    // ========== 1. 增加 header 空间 ==========
    size += gc.MallocHeaderSize

    // ========== 2. 确定 size class ==========
    var sizeclass uint8
    if size &lt;= gc.SmallSizeMax-8 {
        sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
    } else {
        sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)]
    }
    size = uintptr(gc.SizeClassToSize[sizeclass])

    // ========== 3. 分配和归零 ==========
    spc := makeSpanClass(sizeclass, false)
    span := c.alloc[spc]
    v := nextFreeFast(span)
    if v == 0 {
        v, span, checkGCTrigger = c.nextFree(spc)
    }
    x := unsafe.Pointer(v)
    if span.needzero != 0 {
        memclrNoHeapPointers(x, size)
    }

    // ========== 4. 设置 malloc header ==========
    header := (**_type)(x)
    x = add(x, gc.MallocHeaderSize)  // 跳过 header
    c.scanAlloc += heapSetTypeSmallHeader(uintptr(x), size-gc.MallocHeaderSize, typ, header, span)

    // ========== 5. 发布屏障 + GC 协作 ==========
    publicationBarrier()
    if writeBarrier.enabled {
        gcmarknewobject(span, uintptr(x)-gc.MallocHeaderSize)
    } else {
        span.freeIndexForScan = span.freeindex
    }

    // ... 返回
    return x, size
}
</code></pre>
<h4>7.4.5 大对象分配：mallocgcLarge</h4>
<blockquote>
<p>用于 &gt; 32KB 的对象。</p>
</blockquote>
<p>大对象优化：</p>
<ul>
<li><p>直接分配：绕过 mcache/mcentral，直接从 mheap</p>
</li>
<li><p>largeType 字段：避免为大对象创建复杂的位图</p>
</li>
<li><p>分块归零：允许抢占，减少延迟</p>
</li>
<li><p>量身定做：每个大对象有专属的 span</p>
</li>
</ul>
<pre><code class="language-go">func mallocgcLarge(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {
    mp := acquirem()
    mp.mallocing = 1

    c := getMCache(mp)

    // ========== 1. 直接从 mheap 分配 ==========
    span := c.allocLarge(size, typ == nil || !typ.Pointers())
    span.freeindex = 1
    span.allocCount = 1
    span.largeType = nil  // 暂时设为 nil，防止 GC 过早扫描

    size = span.elemsize
    x := unsafe.Pointer(span.base())

    // ========== 2. 发布屏障 ==========
    publicationBarrier()

    // ========== 3. GC 协作 ==========
    if writeBarrier.enabled {
        gcmarknewobject(span, uintptr(x))
    } else {
        span.freeIndexForScan = span.freeindex
    }

    // ========== 4. 设置类型信息 ==========
    if typ != nil &amp;&amp; typ.Pointers() {
        if !heapBitsInSpan(span.elemsize) {
            // 大对象使用 largeType 字段
            span.largeType = typ
            // 发布屏障确保 largeType 可见
            publicationBarrier()
        } else {
            c.scanAlloc += heapSetTypeLarge(uintptr(x), span.elemsize, typ, span)
        }
    }

    // ========== 5. 按需归零（可抢占） ==========
    if needzero &amp;&amp; span.needzero != 0 {
        if goexperiment.AllocHeaders {
            memclrNoHeapPointersChunked(size, x)  // 分块归零
        } else {
            memclrNoHeapPointers(x, size)
        }
    }

    // ... 返回
    return x, size
}
</code></pre>
<h2>8. Go 1.20+ 新增特性</h2>
<p>Go 在 1.16 之后的版本中引入了多项重要的内存管理特性，极大地增强了 Go 的能力和灵活性。</p>
<h3>8.1 User Arena（Go 1.20+）</h3>
<h4>概述</h4>
<p>User Arena 允许应用程序<strong>手动管理</strong>一组对象的生命周期，所有对象在同一个 arena 中分配，可以一次性释放整个 arena。</p>
<h4>使用场景</h4>
<ul>
<li><strong>临时数据处理</strong>：请求处理完后批量释放</li>
<li><strong>请求级别内存池</strong>：每个请求一个 arena</li>
<li><strong>减少 GC 压力</strong>：大量临时对象不进入 GC 扫描</li>
</ul>
<h4>数据结构</h4>
<pre><code class="language-go">type mheap struct {
	// mheap 中的 user arena 状态
	userArena struct {
    arenaHints     *arenaHint   // 分配提示
    quarantineList mSpanList    // 隔离列表（等待无指针引用）
    readyList      mSpanList    // 就绪列表（可复用）
	}
}

type mspan struct {
  // mspan 中的标记
  isUserArenaChunk   bool      // 是否为 user arena chunk
  userArenaChunkFree addrRange // chunk 分配管理
}
</code></pre>
<h4>使用示例</h4>
<pre><code class="language-go">import &quot;arena&quot;

func processRequest(data []byte) {
    // 创建 arena
    a := arena.NewArena()
    defer a.Free()  // 批量释放

    // 在 arena 中分配对象
    obj := arena.New[MyStruct](a)

    // 使用对象...
}
</code></pre>
<h3>8.2 Weak Pointer（Go 1.23+）</h3>
<h4>概述</h4>
<p>弱引用机制允许持有对象的引用，但<strong>不阻止 GC 回收</strong>该对象。</p>
<h4>使用场景</h4>
<ul>
<li><strong>缓存</strong>：缓存条目可以被 GC 回收</li>
<li><strong>Observer 模式</strong>：观察者不阻止被观察对象回收</li>
<li><strong>循环引用打破</strong>：避免内存泄漏</li>
</ul>
<h4>数据结构</h4>
<pre><code class="language-go">// mheap 中的弱指针映射
immortalWeakHandles immortalWeakHandleMap  // 不朽对象的弱指针

// Special 记录
specialWeakHandle struct {
    special special
    handle *atomic.Uintptr  // 弱指针句柄
}
</code></pre>
<h4>使用示例</h4>
<pre><code class="language-go">import &quot;weak&quot;

type Cache struct {
    items map[string]weak.Pointer[*Item]
}

func (c *Cache) Get(key string) *Item {
    wp := c.items[key]
    return wp.Value()  // 可能返回 nil（已被 GC）
}

func (c *Cache) Set(key string, item *Item) {
    c.items[key] = weak.Make(item)
}
</code></pre>
<h4>实现细节</h4>
<ul>
<li>弱指针本身不占用 GC 扫描时间</li>
<li>对象被回收后，弱指针自动变为 nil</li>
<li>弱指针转强指针需要确保 span 已清扫</li>
</ul>
<h3>8.3 Cleanup 机制（Go 1.24+）</h3>
<h4>概述</h4>
<p>类似 finalizer 但更安全的资源清理机制，<strong>不会使对象复活</strong>。</p>
<h4>Cleanup vs Finalizer</h4>
<table>
<thead>
<tr>
<th>特性</th>
<th>Finalizer</th>
<th>Cleanup</th>
</tr>
</thead>
<tbody><tr>
<td>对象复活</td>
<td>会</td>
<td>不会</td>
</tr>
<tr>
<td>执行时机</td>
<td>第一次变成不可达</td>
<td>对象真正释放前</td>
</tr>
<tr>
<td>多个回调</td>
<td>不支持</td>
<td>支持</td>
</tr>
<tr>
<td>GC 延迟</td>
<td>较大</td>
<td>较小</td>
</tr>
</tbody></table>
<h4>数据结构</h4>
<pre><code class="language-go">// mheap 中的 cleanup ID
cleanupID uint64  // 全局唯一 ID 计数器

// Special 记录
specialCleanup struct {
    special special
    fn *funcval  // 清理函数
    id uint64    // 全局唯一 ID
}
</code></pre>
<h4>使用示例</h4>
<pre><code class="language-go">import &quot;runtime&quot;

type Resource struct {
    handle uintptr
}

func NewResource() *Resource {
    r := &amp;Resource{handle: openResource()}

    // 注册 cleanup（不会使 r 复活）
    runtime.AddCleanup(r, func() {
        closeResource(r.handle)
    })

    return r
}
</code></pre>
<h3>8.4 Pinner 机制（Go 1.21+）</h3>
<h4>概述</h4>
<p>固定对象在内存中的位置，防止 GC 移动（为未来的移动式 GC 做准备）。</p>
<h4>使用场景</h4>
<ul>
<li><strong>CGO 交互</strong>：C 代码持有 Go 对象指针</li>
<li><strong>DMA 操作</strong>：硬件直接访问内存</li>
<li><strong>性能优化</strong>：避免某些热点对象移动</li>
</ul>
<h4>数据结构</h4>
<pre><code class="language-go">// mspan 中的 pin 位图
pinnerBits *gcBits  // 标记哪些对象被固定

// Special 记录（支持多次 pin）
specialPinCounter struct {
    special special
    counter uintptr  // pin 计数
}
</code></pre>
<h4>使用示例</h4>
<pre><code class="language-go">import &quot;runtime&quot;

func passToC(data []byte) {
    var pinner runtime.Pinner
    pinner.Pin(&amp;data[0])  // 固定切片底层数组

    // 调用 C 函数
    C.processData(unsafe.Pointer(&amp;data[0]), C.int(len(data)))

    pinner.Unpin()  // 解除固定
}
</code></pre>
<h3>8.5 GreenTeaGC（实验性，Go 1.24+）</h3>
<h4>概述</h4>
<p>实验性的新 GC 算法，旨在进一步降低延迟。</p>
<h4>关键改进</h4>
<ul>
<li><strong>Span Inline Mark Bits</strong>：将 mark bits 内联到 span 中</li>
<li><strong>增量标记</strong>：更细粒度的标记控制</li>
<li><strong>减少停顿</strong>：优化 STW 阶段</li>
</ul>
<h4>数据结构</h4>
<pre><code class="language-go">// mspan 中的 GreenTeaGC 字段
scanIdx uint16  // 扫描索引

// heapArena 中的位图
pageUseSpanInlineMarkBits [pagesPerArena / 8]uint8
</code></pre>
<h4>启用方式</h4>
<pre><code class="language-bash">GOEXPERIMENT=greentea go build myapp.go
</code></pre>
<h2>9. 性能优化技巧</h2>
<h3>9.1 减少内存分配</h3>
<h4>1. 复用对象（sync.Pool）</h4>
<pre><code class="language-go">var bufferPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func process() {
    buf := bufferPool.Get().(*bytes.Buffer)
    defer bufferPool.Put(buf)
    buf.Reset()

    // 使用 buf...
}
</code></pre>
<h4>2. 预分配切片</h4>
<pre><code class="language-go">// 不好
var items []Item
for i := 0; i &lt; 1000; i++ {
    items = append(items, Item{})
}

// 好
items := make([]Item, 0, 1000)
for i := 0; i &lt; 1000; i++ {
    items = append(items, Item{})
}
</code></pre>
<h4>3. 字符串拼接优化</h4>
<pre><code class="language-go">// 不好
s := &quot;&quot;
for i := 0; i &lt; 100; i++ {
    s += &quot;a&quot;
}

// 好
var b strings.Builder
b.Grow(100)
for i := 0; i &lt; 100; i++ {
    b.WriteString(&quot;a&quot;)
}
s := b.String()
</code></pre>
<h3>9.2 避免逃逸</h3>
<h4>1. 返回值而非指针</h4>
<pre><code class="language-go">// 逃逸
func newPoint() *Point {
    p := Point{x: 1, y: 2}
    return &amp;p  // 逃逸
}

// 不逃逸
func newPoint() Point {
    return Point{x: 1, y: 2}
}
</code></pre>
<h4>2. 使用确定大小的数组</h4>
<pre><code class="language-go">// 逃逸
func process() {
    data := make([]byte, n)  // 如果 n 不是常量，可能逃逸
}

// 不逃逸
func process() {
    var data [1024]byte  // 数组在栈上
}
</code></pre>
<h3>9.3 检测工具</h3>
<h4>逃逸分析</h4>
<pre><code class="language-bash">go build -gcflags=&quot;-m&quot; main.go
</code></pre>
<h4>内存分析</h4>
<pre><code class="language-go">import _ &quot;net/http/pprof&quot;

func main() {
    go func() {
        http.ListenAndServe(&quot;localhost:6060&quot;, nil)
    }()

    // 访问 http://localhost:6060/debug/pprof/heap
}
</code></pre>
<h4>Trace 分析</h4>
<pre><code class="language-bash">go test -trace=trace.out
go tool trace trace.out
</code></pre>
<hr>
<h2>10. 总结</h2>
<h3>10.1 内存模型演进</h3>
<p>从 Go 1.16 到 Go 1.25.3，内存模型的主要演进方向：</p>
<ol>
<li><p><strong>更灵活的内存管理</strong></p>
<ul>
<li>User Arena：用户可控的批量分配/释放</li>
<li>适应更多场景需求</li>
</ul>
</li>
<li><p><strong>更丰富的引用语义</strong></p>
<ul>
<li>Weak Pointer：支持弱引用</li>
<li>打破循环引用，优化缓存</li>
</ul>
</li>
<li><p><strong>更安全的资源管理</strong></p>
<ul>
<li>Cleanup 机制：不会使对象复活</li>
<li>减少 finalizer 带来的问题</li>
</ul>
</li>
<li><p><strong>更好的 CGO 支持</strong></p>
<ul>
<li>Pinner 机制：固定对象位置</li>
<li>安全地与 C 代码交互</li>
</ul>
</li>
<li><p><strong>持续的 GC 优化</strong></p>
<ul>
<li>GreenTeaGC：实验性的低延迟 GC</li>
<li>Inline mark bits：减少内存开销</li>
</ul>
</li>
</ol>
<h3>10.2 核心设计原则</h3>
<p>Go 内存分配器的核心设计原则始终如一：</p>
<ol>
<li><strong>多层级缓存</strong>：<ul>
<li><strong>本地缓存</strong>：mcache（Per-P，无锁）</li>
<li><strong>中央索引</strong>：mcentral（按 size class，需要锁）</li>
<li><strong>全局堆</strong>：mheap（全局，需要全局锁）</li>
<li><strong>虚拟内存</strong>：heapArena（64MB 单元）</li>
</ul>
</li>
<li><strong>减少锁竞争</strong>：Per-P 缓存 + 细粒度锁</li>
<li><strong>分级管理</strong>：68 个 size class 减少碎片</li>
<li><strong>延迟归零</strong>：按需清零提高性能</li>
<li><strong>与 GC 协作</strong>：双缓冲、sweepgen 等机制</li>
</ol>
<h3>10.3 最佳实践</h3>
<ol>
<li><strong>理解内存分配路径</strong>：优先使用 mcache 的无锁快速路径</li>
<li><strong>减少逃逸</strong>：让对象尽量在栈上分配</li>
<li><strong>复用对象</strong>：使用 sync.Pool 减少分配</li>
<li><strong>预分配容量</strong>：避免 slice/map 反复扩容</li>
<li><strong>选择合适的特性</strong>：根据场景使用 User Arena、Weak Pointer 等</li>
</ol>
<h3>10.4 参考资料</h3>
<ul>
<li><a href="https://go.dev/doc/">Go 官方文档</a></li>
<li><a href="https://github.com/golang/go/tree/master/src/runtime">Go 运行时源码</a></li>
<li><a href="http://goog-perftools.sourceforge.net/doc/tcmalloc.html">TCMalloc 论文</a></li>
<li><a href="https://github.com/golang/proposal/blob/master/design/44167-gc-pacer-redesign.md">Go GC 设计文档</a></li>
</ul>
<h2>附录：常用命令</h2>
<h3>内存相关环境变量</h3>
<pre><code class="language-bash">GOGC=100          # GC 触发时的堆增长百分比
GOMEMLIMIT=4GiB   # 内存限制（Go 1.19+）
GODEBUG=gctrace=1 # 打印 GC 跟踪信息
</code></pre>
<h3>性能分析</h3>
<pre><code class="language-bash"># CPU profile
go test -cpuprofile=cpu.prof
go tool pprof cpu.prof

# Memory profile
go test -memprofile=mem.prof
go tool pprof mem.prof

# Trace
go test -trace=trace.out
go tool trace trace.out
</code></pre>
<h3>逃逸分析</h3>
<pre><code class="language-bash"># 编译时查看逃逸分析
go build -gcflags=&quot;-m -m&quot; main.go

# 查看汇编代码
go tool compile -S main.go
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>Go 底层原理丨interface</title>
      <link>https://hedon.top/blog/go-interface/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-interface/</guid>
      <pubDate>Mon, 17 Nov 2025 13:00:00 GMT</pubDate>
      <description>本文系统解析 Go interface 的底层实现原理，剖析空接口（eface）与含方法接口（iface）的内存结构、类型方法表（itab）、动态类型匹配及多态分发机制，并探讨其设计哲学与应用场景。</description>
      <category>Go</category>
      <content:encoded><![CDATA[<h2>1. 从第一性原理理解 Go 的类型系统设计</h2>
<p>想要从根本上理解 Go 的 <code>interface</code>，我们不仅要知道它是怎么实现的，更要知道为什么需要它，它的出现是为了解决什么问题。</p>
<blockquote>
<p>特此声明，本篇是笔者与 Google Gemini 3Pro 共创所作，非常庆幸在当今 AI 时代下获取知识已是如此便利，且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑，均由笔者把控和审阅，欢迎放心阅读。</p>
</blockquote>
<p>在静态类型语言中，我们面临一个根本性的矛盾：<strong><u>静态类型语言如何实现动态多态？</u></strong></p>
<ul>
<li><p>编译期：需要类型检查，确保类型安全</p>
</li>
<li><p>运行期：需要动态分发，实现多态</p>
</li>
</ul>
<p>Go 通过接口 <code>interface</code> 优雅地解决了这个问题。</p>
<p>Go 在运行时将接口分为两种表示：</p>
<pre><code class="language-go">type iface struct {
	tab  *itab
	data unsafe.Pointer
}

type eface struct {
	_type *_type
	data  unsafe.Pointer
}
</code></pre>
<ul>
<li><p>eface（Empty Interface）：表示空接口 interface{} 或 any</p>
</li>
<li><p>iface（Non-empty Interface）：表示包含方法的接口</p>
</li>
</ul>
<h2>2. 深入理解 iface 结构</h2>
<h3>2.1 iface 内存布局</h3>
<pre><code class="language-go">type iface struct {
    tab  *itab          // 接口表指针（8字节）
    data unsafe.Pointer // 实际数据指针（8字节）
}
</code></pre>
<p>这是一个 16 字节（64 位系统）的结构，包含两个指针：</p>
<ul>
<li>tab：指向接口表（interface table），存储类型信息和方法集</li>
<li>data：指向实际的数据</li>
</ul>
<h3>2.2 itab 的核心结构</h3>
<pre><code class="language-go">type itab = abi.ITab

type ITab struct {
	Inter *InterfaceType
	Type  *Type
	Hash  uint32     // copy of Type.Hash. Used for type switches.
	Fun   [1]uintptr // variable sized. fun[0]==0 means Type does not implement Inter.
}
</code></pre>
<p><code>itab</code> 是整个接口系统的核心，它包含：</p>
<ul>
<li><code>Inter</code>：指向接口类型的元数据（接口定义了哪些方法）</li>
<li><code>Type</code>：指向具体类型的元数据（实际存储的是什么类型）</li>
<li><code>Hash</code>：类型的哈希值，用于 type switch 快速匹配</li>
<li><code>Fun</code>：方法表，这是一个可变长度数组，存储该具体类型实现接口方法的函数指针</li>
</ul>
<h3>2.3 为什么需要 itab</h3>
<p>为什么不直接在 <code>iface</code> 中存储类型信息和方法？是为了<strong>性能优化 + 内存共享</strong>。</p>
<pre><code class="language-go">type Reader interface {
    Read(p []byte) (n int, err error)
}

var r1 Reader = &amp;File{...}
var r2 Reader = &amp;File{...}
</code></pre>
<p>如果 <code>r1</code> 和 <code>r2</code> 都是 <code>*File</code> 类型实现 <code>Reader</code> 接口：</p>
<ul>
<li><p>它们的 <code>data</code> 指针不同（指向不同的 <code>File</code> 实例）</p>
</li>
<li><p>但它们的 <code>tab</code> 指针相同（指向同一个 <code>itab</code>）</p>
</li>
</ul>
<p><code>itab</code> 是全局唯一的，对于相同的 (接口类型, 具体类型) 对，运行时只会创建一个 <code>itab</code>，并被所有相同类型组合的接口值共享。</p>
<h2>3. 接口赋值的运行时过程</h2>
<h3>3.1 从具体类型到接口类型</h3>
<p>下面这个过程，在运行时发生了什么？</p>
<pre><code class="language-go">type MyInt int

func (m MyInt) String() string {
    return strconv.Itoa(int(m))
}

var i fmt.Stringer = MyInt(42)
</code></pre>
<p><strong>1. 查找并创建 itab</strong></p>
<pre><code class="language-go">func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
	if len(inter.Methods) == 0 {
		throw(&quot;internal error - misuse of itab&quot;)
	}

	// easy case
	if typ.TFlag&amp;abi.TFlagUncommon == 0 {
		if canfail {
			return nil
		}
		name := toRType(&amp;inter.Type).nameOff(inter.Methods[0].Name)
		panic(&amp;TypeAssertionError{nil, typ, &amp;inter.Type, name.Name()})
	}

	var m *itab

	// First, look in the existing table to see if we can find the itab we need.
	// This is by far the most common case, so do it without locks.
	// Use atomic to ensure we see any previous writes done by the thread
	// that updates the itabTable field (with atomic.Storep in itabAdd).
	t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&amp;itabTable)))
	if m = t.find(inter, typ); m != nil {
		goto finish
	}

	// Not found.  Grab the lock and try again.
	lock(&amp;itabLock)
	if m = itabTable.find(inter, typ); m != nil {
		unlock(&amp;itabLock)
		goto finish
	}

	// Entry doesn&#39;t exist yet. Make a new entry &amp; add it.
	m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.Methods)-1)*goarch.PtrSize, 0, &amp;memstats.other_sys))
	m.Inter = inter
	m.Type = typ
	// The hash is used in type switches. However, compiler statically generates itab&#39;s
	// for all interface/type pairs used in switches (which are added to itabTable
	// in itabsinit). The dynamically-generated itab&#39;s never participate in type switches,
	// and thus the hash is irrelevant.
	// Note: m.Hash is _not_ the hash used for the runtime itabTable hash table.
	m.Hash = 0
	itabInit(m, true)
	itabAdd(m)
	unlock(&amp;itabLock)
finish:
	if m.Fun[0] != 0 {
		return m
	}
	if canfail {
		return nil
	}
	// this can only happen if the conversion
	// was already done once using the , ok form
	// and we have a cached negative result.
	// The cached result doesn&#39;t record which
	// interface function was missing, so initialize
	// the itab again to get the missing function name.
	panic(&amp;TypeAssertionError{concrete: typ, asserted: &amp;inter.Type, missingMethod: itabInit(m, false)})
}
</code></pre>
<ul>
<li><p>运行时调用 getitab(interfaceType, concreteType)</p>
</li>
<li><p>先在全局 itabTable 中查找是否已存在</p>
</li>
<li><p>如果不存在，创建新的 itab 并初始化方法表</p>
</li>
</ul>
<p><strong>2. 初始化方法表 itabInit</strong></p>
<pre><code class="language-go">// itabInit fills in the m.Fun array with all the code pointers for
// the m.Inter/m.Type pair. If the type does not implement the interface,
// it sets m.Fun[0] to 0 and returns the name of an interface function that is missing.
// If !firstTime, itabInit will not write anything to m.Fun (see issue 65962).
// It is ok to call this multiple times on the same m, even concurrently
// (although it will only be called once with firstTime==true).
func itabInit(m *itab, firstTime bool) string {
	inter := m.Inter
	typ := m.Type
	x := typ.Uncommon()

	// both inter and typ have method sorted by name,
	// and interface names are unique,
	// so can iterate over both in lock step;
	// the loop is O(ni+nt) not O(ni*nt).
	ni := len(inter.Methods)
	nt := int(x.Mcount)
	xmhdr := (*[1 &lt;&lt; 16]abi.Method)(add(unsafe.Pointer(x), uintptr(x.Moff)))[:nt:nt]
	j := 0
	methods := (*[1 &lt;&lt; 16]unsafe.Pointer)(unsafe.Pointer(&amp;m.Fun[0]))[:ni:ni]
	var fun0 unsafe.Pointer
imethods:
	for k := 0; k &lt; ni; k++ {
		i := &amp;inter.Methods[k]
		itype := toRType(&amp;inter.Type).typeOff(i.Typ)
		name := toRType(&amp;inter.Type).nameOff(i.Name)
		iname := name.Name()
		ipkg := pkgPath(name)
		if ipkg == &quot;&quot; {
			ipkg = inter.PkgPath.Name()
		}
		for ; j &lt; nt; j++ {
			t := &amp;xmhdr[j]
			rtyp := toRType(typ)
			tname := rtyp.nameOff(t.Name)
			if rtyp.typeOff(t.Mtyp) == itype &amp;&amp; tname.Name() == iname {
				pkgPath := pkgPath(tname)
				if pkgPath == &quot;&quot; {
					pkgPath = rtyp.nameOff(x.PkgPath).Name()
				}
				if tname.IsExported() || pkgPath == ipkg {
					ifn := rtyp.textOff(t.Ifn)
					if k == 0 {
						fun0 = ifn // we&#39;ll set m.Fun[0] at the end
					} else if firstTime {
						methods[k] = ifn
					}
					continue imethods
				}
			}
		}
		// didn&#39;t find method
		// Leaves m.Fun[0] set to 0.
		return iname
	}
	if firstTime {
		m.Fun[0] = uintptr(fun0)
	}
	return &quot;&quot;
}
</code></pre>
<ul>
<li><p>遍历接口定义的方法</p>
</li>
<li><p>在具体类型的方法集中查找对应的实现</p>
</li>
<li><p>将函数指针填入 Fun 数组</p>
</li>
<li><p>如果找不到实现，设置 Fun[0] = 0 表示类型不匹配</p>
</li>
</ul>
<p><strong>3. 构造 iface</strong></p>
<pre><code class="language-go"> iface {
     tab:  指向 (fmt.Stringer, MyInt) 的 itab,
     data: 指向 MyInt(42) 的内存地址
 }
</code></pre>
<h3>3.2 接口方法调用</h3>
<pre><code class="language-go">i.String()  // 调用接口方法
</code></pre>
<p>编译器生成的伪代码：</p>
<pre><code class="language-go">// 1. 从 iface 中取出 tab
tab := i.tab

// 2. 从 tab.Fun 中取出第一个方法（String 是第0个方法）
fn := tab.Fun[0]

// 3. 调用该方法，传入 data 作为接收者
return fn(i.data)
</code></pre>
<h2>4. eface vs iface 的设计哲学</h2>
<h3>4.1 为什么区分空接口和非空接口？</h3>
<pre><code class="language-go">type eface struct {
	_type *_type
	data  unsafe.Pointer
}
</code></pre>
<p>空接口 any 不需要方法表，因此直接存储类型指针，省去了 itab 的开销：</p>
<table>
<thead>
<tr>
<th align="left">接口类型</th>
<th align="left">结构</th>
<th align="left">用途</th>
</tr>
</thead>
<tbody><tr>
<td align="left">eface</td>
<td align="left">_type + data</td>
<td align="left">不需要方法调用，只需要类型信息</td>
</tr>
<tr>
<td align="left">iface</td>
<td align="left">itab + data</td>
<td align="left">需要动态方法分发</td>
</tr>
</tbody></table>
<p>这是一种针对性优化：</p>
<ul>
<li><p>空接口场景（如 fmt.Println(any)）非常常见</p>
</li>
<li><p>通过简化结构减少内存占用和间接访问</p>
</li>
</ul>
<h3>4.2 类型断言的实现</h3>
<pre><code class="language-go">if v, ok := i.(MyInt); ok {
    // 使用 v
}
</code></pre>
<p>运行时逻辑：</p>
<pre><code class="language-go">// 对于 iface
if i.tab.Type == TypeOf(MyInt) {
    return *(*MyInt)(i.data), true
}
return zero, false

// 对于 eface
if i._type == TypeOf(MyInt) {
    return *(*MyInt)(i.data), true
}
return zero, false
</code></pre>
<h2>5. 类型系统的完整图景</h2>
<h3>5.1 类型元数据 _type</h3>
<pre><code class="language-go">type Type struct {
	Size_       uintptr
	PtrBytes    uintptr // number of (prefix) bytes in the type that can contain pointers
	Hash        uint32  // hash of type; avoids computation in hash tables
	TFlag       TFlag   // extra type information flags
	Align_      uint8   // alignment of variable with this type
	FieldAlign_ uint8   // alignment of struct field with this type
	Kind_       Kind    // enumeration for C
	// function for comparing objects of this type
	// (ptr to object A, ptr to object B) -&gt; ==?
	Equal func(unsafe.Pointer, unsafe.Pointer) bool
	// GCData stores the GC type data for the garbage collector.
	// Normally, GCData points to a bitmask that describes the
	// ptr/nonptr fields of the type. The bitmask will have at
	// least PtrBytes/ptrSize bits.
	// If the TFlagGCMaskOnDemand bit is set, GCData is instead a
	// **byte and the pointer to the bitmask is one dereference away.
	// The runtime will build the bitmask if needed.
	// (See runtime/type.go:getGCMask.)
	// Note: multiple types may have the same value of GCData,
	// including when TFlagGCMaskOnDemand is set. The types will, of course,
	// have the same pointer layout (but not necessarily the same size).
	GCData    *byte
	Str       NameOff // string form
	PtrToThis TypeOff // type for pointer to this type, may be zero
}
</code></pre>
<p>每个 Go 类型在编译时都会生成一个 <code>_type</code> 结构，包含：</p>
<ul>
<li><p>类型大小、对齐</p>
</li>
<li><p>GC 信息（哪些字段是指针）</p>
</li>
<li><p>类型的唯一标识（Hash）</p>
</li>
<li><p>类型的 Kind（int, string, struct, ...）</p>
</li>
</ul>
<h3>5.2 接口类型 InterfaceType</h3>
<pre><code class="language-go">type InterfaceType struct {
	Type
	PkgPath Name      // import path
	Methods []Imethod // sorted by hash
}
</code></pre>
<p>接口类型的元数据，包含：</p>
<ul>
<li><p>基础的 Type 信息</p>
</li>
<li><p>方法列表（按哈希排序，用于快速查找）</p>
</li>
</ul>
<h3>5.3 全局 itabTable</h3>
<pre><code class="language-go">const itabInitSize = 512

var (
	itabLock      mutex                               // lock for accessing itab table
	itabTable     = &amp;itabTableInit                    // pointer to current table
	itabTableInit = itabTableType{size: itabInitSize} // starter table
)

type itabTableType struct {
	size    uintptr             // length of entries array. Always a power of 2.
	count   uintptr             // current number of filled entries.
	entries [itabInitSize]*itab // really [size] large
}
</code></pre>
<p>这是一个全局哈希表，键是 (接口类型, 具体类型) 对，值是 itab：</p>
<ul>
<li><p>避免重复创建相同的 itab</p>
</li>
<li><p>使用无锁读取（lock-free read）优化热路径</p>
</li>
<li><p>动态扩容（75% 负载因子）</p>
</li>
</ul>
<h2>6. 设计权衡与优势</h2>
<h3>6.1 性能优势</h3>
<ol>
<li>方法调用只需两次间接寻址：<code>iface.tab.Fun[i]</code></li>
<li><code>itab</code> 全局共享：内存效率高</li>
<li>无锁快速路径：大多数情况下不需要加锁</li>
</ol>
<h3>6.2 类型安全</h3>
<ol>
<li>编译期检查：编译器确保接口实现完整</li>
<li>运行期验证：<code>itabInit</code> 时再次验证方法匹配</li>
<li>类型断言安全：通过比较 <code>_type</code> 指针实现</li>
</ol>
<h3>6.3 灵活性</h3>
<ol>
<li>鸭子类型：不需要显式声明实现接口</li>
<li>动态组合：运行时可以将任何匹配的类型赋值给接口</li>
<li>反射基础：<code>reflect</code> 包通过 <code>_type</code> 和 <code>itab</code> 实现</li>
</ol>
<h2>7. 结构体和其指针实现接口的问题</h2>
<p>如果是用结构体类型去实现接口，Go 在编译的时候，会自动再为其对应的指针类型实现接口；</p>
<pre><code class="language-go">// 手动写
func (t Truck) Drive() {

}

// Go 底层会帮我们自动写这个
func (t *Truck) Drive() {

}
</code></pre>
<p>如果只是用结构体指针类型去实现接口，Go 在编译的时候，就不会为结构体类型去实现接口；</p>
<pre><code class="language-go">// 手动写
func (t *Truck) Drive() {

}

/*
   Go 底层不会帮我们写这个
func (t Truck) Drive() {

}
*/
</code></pre>
<h2>8. nil &amp; 空结构体 &amp; 空指针</h2>
<ul>
<li>nil 是六种类型的零值，不包括基本类型和 struct；</li>
<li>空接口可以承载任意类型，只有当 <code>_type</code> 和 <code>data</code> 都为空的时候，它才是 nil；</li>
<li>空结构体的指针和值都不是 nil；</li>
</ul>
<pre><code class="language-go">// nil is a predeclared identifier representing the zero value for a
// pointer, channel, func, interface, map, or slice type.
var nil Type // Type must be a pointer, channel, func, interface, map, or slice type

// Type is here for the purposes of documentation only. It is a stand-in
// for any Go type, but represents the same type for any given function
// invocation.
type Type int
</code></pre>
<h2>9. 总结</h2>
<p>Go 的接口系统是一个精妙的工程设计：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251205133500639.png" alt=""></p>
<ol>
<li><strong>分离类型信息和数据</strong>：使得接口可以容纳任何类型</li>
<li><strong>分离接口定义和实现</strong>：通过 itab 连接两者</li>
<li><strong>缓存和共享</strong>：全局 itabTable 提升性能</li>
<li><strong>针对性优化</strong>：空接口和非空接口使用不同表示</li>
</ol>
<p>这种设计让 Go 在保持静态类型安全的同时，实现了接近动态语言的灵活性，并且性能开销极小。这就是 Go 类型系统的第一性原理！</p>
]]></content:encoded>
    </item>
    <item>
      <title>Go 底层原理丨map（Swiss Table 版本）</title>
      <link>https://hedon.top/blog/go-map-swiss/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-map-swiss/</guid>
      <pubDate>Sun, 16 Nov 2025 16:00:00 GMT</pubDate>
      <description>本文系统解析 Go map（Swiss Table 版本）的底层实现，涵盖控制字并行匹配、开放寻址探测、可扩展哈希、细粒度并发控制，并对比 sync.Map 的 HashTrieMap 实现。</description>
      <category>Go</category>
      <content:encoded><![CDATA[<p>特此声明，本篇是笔者与 Google Gemini 3Pro 共创所作，非常庆幸在当今 AI 时代下获取知识已是如此便利，且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑，均由笔者把控和审阅，欢迎放心阅读。</p>
<h2>1. 整体设计思想</h2>
<p>Go map（Swiss Table 版本）是基于 <strong>Google Abseil 的 &quot;Swiss Table&quot;</strong> 设计的现代化哈希表实现，采用以下核心设计：</p>
<ol>
<li><strong>控制字并行匹配</strong>：使用 8 字节控制字一次性检查 8 个槽位</li>
<li><strong>开放寻址 + 二次探测</strong>：无需链表，所有数据存储在连续数组中</li>
<li><strong>可扩展哈希</strong>：使用目录机制实现增量扩容，单表大小受限</li>
<li><strong>SIMD 加速</strong>：AMD64 架构使用 SIMD 指令并行比较控制字</li>
</ol>
<blockquote>
<p>设计灵感来源：<a href="https://abseil.io/about/design/swisstables">Abseil Swiss Tables</a></p>
</blockquote>
<h2>2. 核心数据结构</h2>
<p>本篇以 Go1.25 版本的源码为基准，完整源码参考：</p>
<ul>
<li>Runtime 封装：<a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/map_swiss.go">map_swiss.go</a></li>
<li>核心实现：<a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/maps/">internal/runtime/maps/</a></li>
</ul>
<h3>2.1 Map（顶层结构）</h3>
<pre><code class="language-go">type Map struct {
    // 元素总数（必须在第一位，供 len() 使用）
    used uint64

    // 哈希种子（每个 map 独立随机）
    seed uintptr

    // 目录指针：指向 table 数组或单个 group
    // 小 map 优化：≤8 个元素时，dirPtr 直接指向单个 group
    // 大 map：dirPtr 指向 *[dirLen]*table
    dirPtr unsafe.Pointer
    dirLen int  // 目录长度 = 1 &lt;&lt; globalDepth

    // 可扩展哈希的全局深度（使用哈希高位的 bit 数）
    globalDepth uint8
    globalShift uint8  // 64 - globalDepth（用于快速计算索引）

    // 并发写检测标志
    writing uint8

    // 墓碑存在标记
    tombstonePossible bool

    // 清空序列号（用于检测迭代中的 clear）
    clearSeq uint64
}
</code></pre>
<p>关键字段说明：</p>
<ul>
<li><code>dirPtr</code> + <code>dirLen</code>：实现可扩展哈希的目录结构</li>
<li><code>globalDepth</code>：当前使用哈希高位的 bit 数</li>
<li>小 map 优化：≤8 个元素时无需分配 table</li>
</ul>
<h3>2.2 Table（哈希表）</h3>
<pre><code class="language-go">type table struct {
    used     uint16  // 已使用槽位数
    capacity uint16  // 总槽位数（= groups数 × 8）
    growthLeft uint16  // 剩余可填充槽位

    localDepth uint8  // 表的局部深度
    index int  // 在目录中的索引

    groups groupsReference  // group 数组
}
</code></pre>
<p><strong>单表最大容量</strong>：1024 个槽位（限制单次扩容开销）</p>
<h3>2.3 Group（组结构）</h3>
<p>每个 group 包含：</p>
<ul>
<li><strong>1 个控制字</strong>（8 字节）：每字节对应一个槽位的状态</li>
<li><strong>8 个槽位</strong>：每个槽位存储一个 key-value 对</li>
</ul>
<pre><code class="language-go">type groupReference struct {
    data unsafe.Pointer  // 指向实际的 group 内存
}

// 内存布局
type group struct {
    ctrls ctrlGroup  // 8 字节控制字
    slots [8]struct {
        key  K
        elem V
    }
}
</code></pre>
<p><strong>控制字编码</strong>：</p>
<pre><code>  empty: 1000 0000  (0x80)
deleted: 1111 1110  (0xFE) - 墓碑标记
   full: 0xxx xxxx  (0x00-0x7F) - 低 7 位存储 H2 哈希值
</code></pre>
<h3>2.4 总结图</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251116170218340.png" alt=""></p>
<h2>3. 核心算法</h2>
<h3>3.1 哈希值分割</h3>
<pre><code>64 位哈希值分为两部分：
┌─────────────────────────────────────────────┬───────────┐
│           H1 (高 57 位)                      │ H2 (低7位) │
└─────────────────────────────────────────────┴───────────┘
      用于定位 group                           存储在控制字中
</code></pre>
<p><strong>H1</strong>：用于定位 group（二次探测）
<strong>H2</strong>：存储在控制字中，用于快速过滤</p>
<h3>3.2 并行匹配算法</h3>
<p>传统方式（non-Swiss）：</p>
<pre><code class="language-go">for i := 0; i &lt; 8; i++ {
    if tophash[i] == target {
        // 逐个串行比较
    }
}
</code></pre>
<p>Swiss 方式：</p>
<pre><code class="language-go">// 一次操作检查所有 8 个槽位！
matches := ctrlGroup.matchH2(target)
// matches 是 bitset，每位表示一个槽位是否匹配
</code></pre>
<p>实现原理：</p>
<pre><code class="language-go">func ctrlGroupMatchH2(g ctrlGroup, h uintptr) bitset {
    // 1. XOR：将匹配的字节变为 0x00
    v := uint64(g) ^ (0x0101010101010101 * uint64(h))

    // 2. 减法技巧：0x00 - 0x01 会借位变成 0xFF
    //    其他值减 0x01 不会设置最高位
    result := (v - 0x0101010101010101) &amp;^ v

    // 3. 提取每字节的最高位
    return bitset(result &amp; 0x8080808080808080)
}
</code></pre>
<p><strong>AMD64 优化</strong>：使用 SIMD 指令（SSE2 PCMPEQB）进一步加速</p>
<h3>3.3 二次探测序列</h3>
<pre><code class="language-go">type probeSeq struct {
    mask   uint64  // 组数 - 1
    offset uint64  // 当前偏移
    index  uint64  // 探测索引
}

func (s probeSeq) next() probeSeq {
    s.index++
    // 三角数序列：offset[i+1] = offset[i] + i + 1
    s.offset = (s.offset + s.index) &amp; s.mask
    return s
}
</code></pre>
<p><strong>探测公式</strong>：<code>p(i) = (i² + i)/2 + hash (mod 组数)</code></p>
<p><strong>优势</strong>：</p>
<ul>
<li>当组数为 2 的幂时，保证遍历所有组</li>
<li>跳跃式分布，减少聚集（clustering）</li>
<li>缓存友好性优于线性探测</li>
</ul>
<h3>3.4 查找流程</h3>
<p>底层调用了 <code>runtime/maps/runtime_swiss.go</code> 中的 <code>mapaccess1()</code> 或者 <code>mapaccess2</code> 函数：</p>
<ul>
<li>v := m[k] 调用 <code>runtime_mapaccess1()</code></li>
<li>v,k := m[k] 调用 <code>runtime_mapaccess2()</code></li>
</ul>
<p>我们重点来看 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/maps/runtime_swiss.go#L40">runtime_mapaccess1()</a>：</p>
<pre><code class="language-go">func runtime_mapaccess1(typ *abi.SwissMapType, m *Map, key unsafe.Pointer) unsafe.Pointer {
	// 空map检查
	if m == nil || m.Used() == 0 {
		if err := mapKeyError(typ, key); err != nil {
			panic(err)
		}
		return unsafe.Pointer(&amp;zeroVal[0])
	}

	// 并发写检测
	if m.writing != 0 {
		fatal(&quot;concurrent map read and map write&quot;)
	}

	// ============================================
	// 步骤 1：计算哈希值
  // hash 将被分为 H1（高57位）和 H2（低7位）
	// ============================================
	hash := typ.Hasher(key, m.seed)

	// ============================================
	// 步骤 2：小 map 快速路径（≤8个元素）
	// ============================================
	if m.dirLen &lt;= 0 {
		// 小map：数据直接存储在单个group中，无需探测
		_, elem, ok := m.getWithKeySmall(typ, hash, key)
		if !ok {
			return unsafe.Pointer(&amp;zeroVal[0])
		}
		return elem
	}

	// ============================================
	// 步骤 3：大 map 路径 - 选择 table
  // 使用哈希值的高位索引目录，选择对应的 table
	// ============================================
	idx := m.directoryIndex(hash)  // idx = hash &gt;&gt; globalShift
	t := m.directoryAt(idx)         // 获取 table 指针

	// ============================================
	// 步骤 4：二次探测循环
  // 初始化探测序列：使用 H1（高57位）定位初始 group
	// ============================================
	seq := makeProbeSeq(h1(hash), t.groups.lengthMask)

	for ; ; seq = seq.next() {  // 无限循环，直到找到或遇到空槽
		// ----------------------------------------
		// 步骤 4.1：获取当前探测的 group
		// ----------------------------------------
		g := t.groups.group(typ, seq.offset)

		// ----------------------------------------
		// 步骤 4.2：并行匹配控制字（核心优化！）
    // 使用 H2（低7位）与控制字进行并行匹配
    // 一次操作检查 8 个槽位的控制字
		// ----------------------------------------
		match := g.ctrls().matchH2(h2(hash)) // match 是 bitset，每位代表一个槽位是否匹配


		// ----------------------------------------
		// 步骤 4.3：遍历所有匹配的槽位
		// ----------------------------------------
		for match != 0 {
			// 获取第一个匹配槽位的索引
			i := match.first()

			// 获取槽位中的 key 指针
			slotKey := g.key(typ, i)
			slotKeyOrig := slotKey  // 保存原始指针（计算 elem 偏移用）

			// 如果是间接key（key太大，存的是指针）
			if typ.IndirectKey() {
				slotKey = *((*unsafe.Pointer)(slotKey))  // 解引用
			}

			// ----------------------------------------
			// 步骤 4.4：完整 key 比较
			// ----------------------------------------
			if typ.Key.Equal(key, slotKey) {
				// 找到了！计算 elem 的地址
				// elem 紧跟在 key 后面，偏移量为 typ.ElemOff
				slotElem := unsafe.Pointer(uintptr(slotKeyOrig) + typ.ElemOff)

				// 如果是间接elem（elem太大，存的是指针）
				if typ.IndirectElem() {
					slotElem = *((*unsafe.Pointer)(slotElem))  // 解引用
				}

				return slotElem  // 返回元素指针
			}

			// key不匹配（控制字碰撞），移除当前匹配位，检查下一个
			match = match.removeFirst()
		}

		// ----------------------------------------
		// 步骤 4.5：检查空槽（探测终止条件）
		// ----------------------------------------
		match = g.ctrls().matchEmpty()
		if match != 0 {
			// 找到空槽 → 探测序列结束 → key不存在 → 返回对应类型的零值
			return unsafe.Pointer(&amp;zeroVal[0])
		}

		// 当前group没有匹配且无空槽，继续探测下一个group
		// seq.next() 会应用二次探测公式：offset += index+1
	}
}
</code></pre>
<p><strong>查找步骤</strong>：</p>
<ol>
<li>计算哈希并分割为 H1/H2</li>
<li>使用 H1 定位初始 group</li>
<li>并行匹配 H2（一次检查 8 个槽位）</li>
<li>对匹配的槽位进行完整 key 比较</li>
<li>未找到则二次探测下一个 group</li>
</ol>
<pre><code class="language-mermaid">flowchart TD
    Start([mapaccess1]) --&gt; Hash[计算哈希&lt;br/&gt;hash = Hasher key, seed]

    Hash --&gt; Size{小map?&lt;br/&gt;dirLen &lt;= 0}

    Size --&gt;|是| SmallMap[单group查找&lt;br/&gt;getWithKeySmall]
    SmallMap --&gt; Result1{找到?}
    Result1 --&gt;|是| Return1[返回 elem]
    Result1 --&gt;|否| Return2[返回 zeroVal]

    Size --&gt;|否| SelectTable[选择table&lt;br/&gt;idx = directoryIndex hash]

    SelectTable --&gt; ProbeLoop[二次探测循环]

    ProbeLoop --&gt; GetGroup[获取group&lt;br/&gt;g = groups seq.offset]

    GetGroup --&gt; ParallelMatch[⭐ 并行匹配控制字&lt;br/&gt;matches = g.ctrls.matchH2 H2]

    ParallelMatch --&gt; HasMatch{有匹配?}

    HasMatch --&gt;|是| KeyCompare[完整key比较&lt;br/&gt;key == slotKey?]

    KeyCompare --&gt;|是| Return3[返回 slotElem]

    KeyCompare --&gt;|否| NextMatch{下一个匹配?}
    NextMatch --&gt;|有| KeyCompare
    NextMatch --&gt;|无| CheckEmpty

    HasMatch --&gt;|否| CheckEmpty[检查空槽&lt;br/&gt;matchEmpty]

    CheckEmpty --&gt; IsEmpty{有空槽?}

    IsEmpty --&gt;|是| Return4[返回 zeroVal&lt;br/&gt;key不存在]

    IsEmpty --&gt;|否| NextGroup[下一个group&lt;br/&gt;seq = seq.next]

    NextGroup --&gt; GetGroup

    Return1 --&gt; End([结束])
    Return2 --&gt; End
    Return3 --&gt; End
    Return4 --&gt; End

    style ParallelMatch fill:#ffeb3b,stroke:#f57f17,stroke-width:3px
    style KeyCompare fill:#4caf50,stroke:#2e7d32,stroke-width:2px
    style Return3 fill:#2196f3,stroke:#1565c0,stroke-width:2px
    style Return4 fill:#f44336,stroke:#c62828,stroke-width:2px
</code></pre>
<h3>3.5 插入流程</h3>
<p>插入底层调用了 <code>runtime/maps/runtime_swiss.go</code> 中的 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/maps/runtime_swiss.go#L188">runtime_mapassign()</a> 函数：</p>
<pre><code class="language-go">func runtime_mapassign(typ *abi.SwissMapType, m *Map, key unsafe.Pointer) unsafe.Pointer {
	// 并发写检测
	if m.writing != 0 {
		fatal(&quot;concurrent map writes&quot;)
	}

	// ============================================
	// 步骤 1：计算哈希并设置写标志
	// ============================================
	hash := typ.Hasher(key, m.seed)

	// 在调用Hasher之后设置writing标志（Hasher可能panic）
	m.writing ^= 1  // toggle写标志

	// ============================================
	// 步骤 2：确保map已初始化
	// ============================================
	if m.dirPtr == nil {
		m.growToSmall(typ)  // 初始化为小map
	}

	// ============================================
	// 步骤 3：小 map 快速路径（≤8个元素）
	// ============================================
	if m.dirLen == 0 {
		if m.used &lt; abi.SwissMapGroupSlots {
			// 小map还有空间，直接插入
			elem := m.putSlotSmall(typ, hash, key)

			if m.writing == 0 {
				fatal(&quot;concurrent map writes&quot;)
			}
			m.writing ^= 1  // 清除写标志

			return elem
		}

		// 小map满了，扩展为大map
		m.growToTable(typ)
	}

	// ============================================
	// 步骤 4：大 map 路径 - 外层循环（处理rehash）
	// ============================================
	var slotElem unsafe.Pointer
outer:
	for {
		// ----------------------------------------
		// 步骤 4.1：选择 table
		// ----------------------------------------
		idx := m.directoryIndex(hash)
		t := m.directoryAt(idx)

		// ----------------------------------------
		// 步骤 4.2：初始化探测序列
		// ----------------------------------------
		seq := makeProbeSeq(h1(hash), t.groups.lengthMask)

		// 记录第一个删除槽位（墓碑复用优化）
		var firstDeletedGroup groupReference
		var firstDeletedSlot uintptr

		// ----------------------------------------
		// 步骤 4.3：二次探测内层循环
		// ----------------------------------------
		for ; ; seq = seq.next() {
			g := t.groups.group(typ, seq.offset)

			// 并行匹配控制字
			match := g.ctrls().matchH2(h2(hash))

			// ====================================
			// 场景 1：查找已存在的 key（更新操作）
			// ====================================
			for match != 0 {
				i := match.first()

				slotKey := g.key(typ, i)
				slotKeyOrig := slotKey
				if typ.IndirectKey() {
					slotKey = *((*unsafe.Pointer)(slotKey))
				}

				// 找到相同的key → 更新操作
				if typ.Key.Equal(key, slotKey) {
					// 某些类型需要更新key（如float NaN）
					if typ.NeedKeyUpdate() {
						typedmemmove(typ.Key, slotKey, key)
					}

					// 计算elem地址并返回
					slotElem = unsafe.Pointer(uintptr(slotKeyOrig) + typ.ElemOff)
					if typ.IndirectElem() {
						slotElem = *((*unsafe.Pointer)(slotElem))
					}

					t.checkInvariants(typ, m)
					break outer  // 完成更新，退出所有循环
				}
				match = match.removeFirst()
			}

			// ====================================
			// 场景 2：遇到空槽 → 插入新 key
			// ====================================
			match = g.ctrls().matchEmpty()
			if match != 0 {
				// 找到空槽，探测序列结束

				var i uintptr

				// 优先复用删除槽位（避免消耗growthLeft）
				if firstDeletedGroup.data != nil {
					g = firstDeletedGroup
					i = firstDeletedSlot
					t.growthLeft++  // 补偿后面的--操作
				} else {
					// 使用空槽
					i = match.first()
				}

				// --------------------------------
				// 检查是否有增长空间
				// --------------------------------
				if t.growthLeft &gt; 0 {
					// 有空间，直接插入新entry

					// 准备key存储
					slotKey := g.key(typ, i)
					slotKeyOrig := slotKey
					if typ.IndirectKey() {
						// 大key：分配堆内存，存指针
						kmem := newobject(typ.Key)
						*(*unsafe.Pointer)(slotKey) = kmem
						slotKey = kmem
					}
					typedmemmove(typ.Key, slotKey, key)

					// 准备elem存储
					slotElem = unsafe.Pointer(uintptr(slotKeyOrig) + typ.ElemOff)
					if typ.IndirectElem() {
						// 大elem：分配堆内存，存指针
						emem := newobject(typ.Elem)
						*(*unsafe.Pointer)(slotElem) = emem
						slotElem = emem
					}

					// 设置控制字为H2
					g.ctrls().set(i, ctrl(h2(hash)))

					// 更新计数器
					t.growthLeft--
					t.used++
					m.used++

					t.checkInvariants(typ, m)
					break outer  // 完成插入，退出所有循环
				}

				// --------------------------------
				// 没有增长空间，触发rehash
				// --------------------------------
				t.rehash(typ, m)
				continue outer  // 重新开始（table结构已变化）
			}

			// ====================================
			// 场景 3：记录第一个删除槽位（延迟选择）
			// ====================================
			// 当前group没有空槽，继续探测，但记录删除槽位
			if firstDeletedGroup.data == nil {
				match = g.ctrls().matchEmptyOrDeleted()
				if match != 0 {
					// 找到删除槽位，记录下来
					// （可能后续找到existing key，也可能用于插入）
					firstDeletedGroup = g
					firstDeletedSlot = match.first()
				}
			}

			// 继续探测下一个group
		}
	}

	// ============================================
	// 步骤 5：清除写标志并返回
	// ============================================
	if m.writing == 0 {
		fatal(&quot;concurrent map writes&quot;)
	}
	m.writing ^= 1  // 清除写标志

	return slotElem  // 返回elem指针供调用者写入值
}
</code></pre>
<p><strong>关键策略</strong>：</p>
<ul>
<li>优先重用墓碑槽位（避免浪费空间）</li>
<li>探测到空槽表示 key 不存在</li>
<li>自动触发表分裂（如果负载过高）</li>
</ul>
<pre><code class="language-mermaid">flowchart TD
    Start([mapassign]) --&gt; Hash[计算哈希&lt;br/&gt;设置写标志]

    Hash --&gt; Init{已初始化?}
    Init --&gt;|否| GrowSmall[growToSmall]
    Init --&gt;|是| CheckSize
    GrowSmall --&gt; CheckSize

    CheckSize{小map?&lt;br/&gt;dirLen == 0}

    CheckSize --&gt;|是| HasSpace{used &lt; 8?}
    HasSpace --&gt;|是| PutSmall[直接插入&lt;br/&gt;putSlotSmall]
    HasSpace --&gt;|否| GrowTable[扩展为大map&lt;br/&gt;growToTable]

    PutSmall --&gt; Return1[返回 elem]
    GrowTable --&gt; BigMap

    CheckSize --&gt;|否| BigMap[大map路径]

    BigMap --&gt; SelectTable[选择table&lt;br/&gt;idx = directoryIndex hash]

    SelectTable --&gt; ProbeLoop[二次探测循环]

    ProbeLoop --&gt; GetGroup[获取group]

    GetGroup --&gt; Match[⭐ 并行匹配H2&lt;br/&gt;matchH2]

    Match --&gt; CheckExist{找到existing&lt;br/&gt;key?}

    CheckExist --&gt;|是| UpdateKey[更新操作&lt;br/&gt;NeedKeyUpdate?]
    UpdateKey --&gt; Return2[返回 elem]

    CheckExist --&gt;|否| CheckEmpty{遇到空槽?}

    CheckEmpty --&gt;|是| HasDeleted{有记录的&lt;br/&gt;删除槽位?}

    HasDeleted --&gt;|是| UseDeleted[复用删除槽位]
    HasDeleted --&gt;|否| UseEmpty[使用空槽]

    UseDeleted --&gt; CheckGrowth
    UseEmpty --&gt; CheckGrowth{growthLeft &gt; 0?}

    CheckGrowth --&gt;|是| InsertNew[插入新entry&lt;br/&gt;设置控制字&lt;br/&gt;更新计数]
    InsertNew --&gt; Return3[返回 elem]

    CheckGrowth --&gt;|否| Rehash[触发rehash&lt;br/&gt;t.rehash]
    Rehash --&gt; SelectTable

    CheckEmpty --&gt;|否| RecordDeleted[记录删除槽位&lt;br/&gt;matchEmptyOrDeleted]

    RecordDeleted --&gt; NextGroup[下一个group&lt;br/&gt;seq.next]
    NextGroup --&gt; GetGroup

    Return1 --&gt; ClearFlag[清除写标志]
    Return2 --&gt; ClearFlag
    Return3 --&gt; ClearFlag
    ClearFlag --&gt; End([结束])

    style Match fill:#ffeb3b,stroke:#f57f17,stroke-width:3px
    style InsertNew fill:#4caf50,stroke:#2e7d32,stroke-width:2px
    style UpdateKey fill:#2196f3,stroke:#1565c0,stroke-width:2px
    style Rehash fill:#ff9800,stroke:#e65100,stroke-width:2px
</code></pre>
<h3>3.6 删除操作（墓碑机制）</h3>
<p>删除操作底层调用了 <code>runtime/map_swiss.go</code> 中的 <a href="http://github.com/golang/go/blob/release-branch.go1.25/src/runtime/map_swiss.go#L139">mapdelete()</a> 函数：</p>
<pre><code class="language-go">func mapdelete(t *abi.SwissMapType, m *maps.Map, key unsafe.Pointer) {
	// ...

	m.Delete(t, key)
}

func (m *Map) Delete(typ *abi.SwissMapType, key unsafe.Pointer) {
	// ...

	if m.dirLen == 0 {
    // 小 map 删除
		m.deleteSmall(typ, hash, key)
	} else {
    // 大 map 删除
		idx := m.directoryIndex(hash)
		if m.directoryAt(idx).Delete(typ, m, hash, key) {
			m.tombstonePossible = true // 如果返回 true，则表明可能设置了墓碑
		}
	}

  // ...
}
</code></pre>
<p>根据 map 的大小具体分为 <code>m.deleteSmall(typ, hash, key)</code> 和 <code>m.directoryAt(idx).Delete(typ, m, hash, key)</code> 两种删除逻辑。我们重点来看一下 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/internal/runtime/maps/table.go#L421">m.directoryAt(idx).Delete(typ, m, hash, key)</a>：</p>
<pre><code class="language-go">// Delete 删除 key，返回是否放置了墓碑
// 返回 true：放置了墓碑（group已满，需要保持探测链完整）
// 返回 false：未找到key 或 直接清空（group有空槽）
func (t *table) Delete(typ *abi.SwissMapType, m *Map, hash uintptr, key unsafe.Pointer) bool {
  // 1：初始化二次探测序列
	seq := makeProbeSeq(h1(hash), t.groups.lengthMask)

  // 2：探测循环 - 查找 key
	for ; ; seq = seq.next() {
		// 获取当前 group
		g := t.groups.group(typ, seq.offset)

		// 并行匹配控制字
		match := g.ctrls().matchH2(h2(hash))

		// 遍历所有匹配的槽位
		for match != 0 {
			i := match.first()

			// 获取槽位中的 key
			slotKey := g.key(typ, i)
			origSlotKey := slotKey  // 保存原始指针
			if typ.IndirectKey() {
				slotKey = *((*unsafe.Pointer)(slotKey))
			}

			// 3：找到匹配的 key，执行删除
			if typ.Key.Equal(key, slotKey) {
				t.used--
				m.used--

				// 清除 key 的内存
				if typ.IndirectKey() {
					*(*unsafe.Pointer)(origSlotKey) = nil
				} else if typ.Key.Pointers() {
					typedmemclr(typ.Key, slotKey)
				}

				// 清除 elem 的内存
				slotElem := g.elem(typ, i)
				if typ.IndirectElem() {
					*(*unsafe.Pointer)(slotElem) = nil
				} else {
					typedmemclr(typ.Elem, slotElem)
				}

				// 4：决定控制字策略（关键设计！）
				/*
				 * 核心逻辑：
				 * - 探测链遇到空槽会终止
				 * - 只有&quot;满group&quot;才会出现在探测链中间
				 * - group一旦满了，在rehash前会一直保持满
				 *
				 * 因此：
				 * - group有空槽 → 删除不会破坏探测链 → 直接清空
				 * - group无空槽 → 删除可能破坏探测链 → 放置墓碑
				 */
				var tombstone bool
				if g.ctrls().matchEmpty() != 0 {
					// --------------------------------
					// 场景 A：group 有空槽 → 直接清空
					// --------------------------------
					g.ctrls().set(i, ctrlEmpty)
					t.growthLeft++  // 恢复增长空间
					tombstone = false
				} else {
					// --------------------------------
					// 场景 B：group 已满 → 放置墓碑
					// --------------------------------
					g.ctrls().set(i, ctrlDeleted)
					tombstone = true
					// 注意：不增加 growthLeft
					// 墓碑仍占用空间，但可被后续插入复用
				}

				t.checkInvariants(typ, m)
				return tombstone  // 返回是否放置了墓碑
			}

			// key不匹配，检查下一个匹配位
			match = match.removeFirst()
		}

		// 4：检查空槽（探测终止条件）
		match = g.ctrls().matchEmpty()
		if match != 0 {
			// 遇到空槽 → 探测链结束 → key不存在
			return false
		}

		// 继续探测下一个 group
	}
}
</code></pre>
<pre><code class="language-mermaid">flowchart TD
    Start([table.Delete]) --&gt; InitProbe[初始化探测&lt;br/&gt;seq = makeProbeSeq H1]

    InitProbe --&gt; ProbeLoop[探测循环]

    ProbeLoop --&gt; GetGroup[获取group]

    GetGroup --&gt; Match[⭐ 并行匹配H2&lt;br/&gt;matchH2]

    Match --&gt; HasMatch{有匹配?}

    HasMatch --&gt;|是| KeyCompare[完整key比较&lt;br/&gt;key == slotKey?]

    KeyCompare --&gt;|是| UpdateCount[更新计数&lt;br/&gt;t.used--&lt;br/&gt;m.used--]

    UpdateCount --&gt; ClearKey[清除key内存&lt;br/&gt;IndirectKey?&lt;br/&gt;Pointers?]

    ClearKey --&gt; ClearElem[清除elem内存&lt;br/&gt;总是清除]

    ClearElem --&gt; CheckFull{⭐ group有&lt;br/&gt;空槽?}

    CheckFull --&gt;|是| SetEmpty[设置 ctrlEmpty&lt;br/&gt;growthLeft++]
    SetEmpty --&gt; ReturnFalse[返回 false&lt;br/&gt;未放置墓碑]

    CheckFull --&gt;|否| SetDeleted[设置 ctrlDeleted&lt;br/&gt;保持 growthLeft]
    SetDeleted --&gt; ReturnTrue[返回 true&lt;br/&gt;已放置墓碑]

    KeyCompare --&gt;|否| NextMatch{下一个匹配?}
    NextMatch --&gt;|有| KeyCompare
    NextMatch --&gt;|无| CheckEmpty

    HasMatch --&gt;|否| CheckEmpty[检查空槽&lt;br/&gt;matchEmpty]

    CheckEmpty --&gt; IsEmpty{有空槽?}

    IsEmpty --&gt;|是| ReturnFalse2[返回 false&lt;br/&gt;key不存在]

    IsEmpty --&gt;|否| NextGroup[下一个group&lt;br/&gt;seq.next]
    NextGroup --&gt; GetGroup

    ReturnTrue --&gt; End([结束])
    ReturnFalse --&gt; End
    ReturnFalse2 --&gt; End

    style Match fill:#ffeb3b,stroke:#f57f17,stroke-width:3px
    style CheckFull fill:#ff9800,stroke:#e65100,stroke-width:3px
    style SetEmpty fill:#4caf50,stroke:#2e7d32,stroke-width:2px
    style SetDeleted fill:#f44336,stroke:#c62828,stroke-width:2px
</code></pre>
<p>墓碑的核心作用是：<strong>保持探测链的完整性，防止后续元素&quot;失联&quot;</strong>。</p>
<p>不用墓碑会发生什么？</p>
<pre><code>假设有3个连续的满group（hash碰撞导致）：

初始状态（插入顺序：A → B → C）：
┌─────────────────────────┐
│ Group 5: [A][X][X][X][X][X][X][X] │ ← A的首选位置，但已满
└─────────────────────────┘
           ↓ 继续探测
┌─────────────────────────┐
│ Group 6: [B][X][X][X][X][X][X][X] │ ← B的首选也是Group 5，被挤到这里
└─────────────────────────┘
           ↓ 继续探测
┌─────────────────────────┐
│ Group 7: [C][X][X][X][X][X][X][X] │ ← C也是，被挤到这里
└─────────────────────────┘
           ↓
┌─────────────────────────┐
│ Group 8: [ ][...未使用...] │ ← 空槽，探测终止
└─────────────────────────┘


现在删除 B（如果直接设置为 Empty）：
┌─────────────────────────┐
│ Group 5: [A][X][X][X][X][X][X][X] │
└─────────────────────────┘
           ↓
┌─────────────────────────┐
│ Group 6: [Empty][X][X][X][X][X][X][X] │ ← 变成空槽！
└─────────────────────────┘
           ↓ ⚠️ 探测在这里终止！
┌─────────────────────────┐
│ Group 7: [C][X][X][X][X][X][X][X] │ ← C&quot;失联&quot;了！
└─────────────────────────┘


查找 C 时：
1. 计算 hash → 首选 Group 5
2. Group 5 没有 → 继续探测到 Group 6
3. Group 6 发现 Empty → 终止探测
4. 返回&quot;不存在&quot; ❌ （但 C 实际在 Group 7！）
</code></pre>
<p>解决方案：使用墓碑</p>
<pre><code>删除 B（使用墓碑）：
┌─────────────────────────┐
│ Group 5: [A][X][X][X][X][X][X][X] │
└─────────────────────────┘
           ↓
┌─────────────────────────┐
│ Group 6: [Deleted][X][X][X][X][X][X][X] │ ← 墓碑，探测继续！
└─────────────────────────┘
           ↓ ✓ 探测继续
┌─────────────────────────┐
│ Group 7: [C][X][X][X][X][X][X][X] │ ← 能找到 C！
└─────────────────────────┘
           ↓
┌─────────────────────────┐
│ Group 8: [ ][...未使用...] │ ← Empty 才终止
└─────────────────────────┘


查找 C 时：
1. 计算 hash → 首选 Group 5
2. Group 5 没有 → 继续
3. Group 6 发现 Deleted → 跳过，继续探测！
4. Group 7 找到 C ✓
</code></pre>
<p>三种控制字代表不同的探测行为：</p>
<table>
<thead>
<tr>
<th>ctrlEmpty</th>
<th>ctrlDeleted</th>
<th>H2 (0-127)</th>
</tr>
</thead>
<tbody><tr>
<td>空槽</td>
<td>墓碑</td>
<td>有效 entry</td>
</tr>
<tr>
<td>停止探测，key 不存在</td>
<td>跳过继续找</td>
<td>检查 key，可能找到</td>
</tr>
</tbody></table>
<p>实际代码中的体现：</p>
<pre><code class="language-go">// 探测循环中
match = g.ctrls().matchEmpty()
if match != 0 {
    // 遇到 Empty → 终止
    return false  // key不存在
}
// 遇到 Deleted → 循环继续
// 遇到 H2 → 检查key

// 这就是为什么墓碑必须 != ctrlEmpty
</code></pre>
<p>为什么有些情况不需要墓碑？</p>
<pre><code>如果删除后 group 有空槽：

删除前：
Group 6: [B][X][X][ ][ ][ ][ ][ ]  ← 有空槽

删除 B：
Group 6: [ ][X][X][ ][ ][ ][ ][ ]  ← 直接清空

为什么安全？
因为探测链本来就在这个 group 终止！
（有空槽意味着后续没有碰撞元素）
</code></pre>
<p>总结来说，墓碑 = <strong>探测链的&quot;虚拟占位符&quot;</strong>，如果没有墓碑，删除操作会破坏开放寻址哈希表的探测链，导致某些元素永远找不到！</p>
<table>
<thead>
<tr>
<th>作用</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>🔗 <strong>保持链接</strong></td>
<td>防止探测链被&quot;截断&quot;，后续元素失联</td>
</tr>
<tr>
<td>♻️ <strong>可复用</strong></td>
<td>插入时优先使用墓碑位置（不消耗 growthLeft）</td>
</tr>
<tr>
<td>🧹 <strong>延迟清理</strong></td>
<td>rehash 时一次性清除所有墓碑</td>
</tr>
<tr>
<td>⚖️ <strong>权衡</strong></td>
<td>占用空间 vs 探测链完整性</td>
</tr>
</tbody></table>
<h2>4. 可扩展哈希与增量扩容</h2>
<h3>4.1 可扩展哈希原理</h3>
<p>传统单表扩容的问题：</p>
<pre><code>Table (100万元素) → Table (200万元素)
                    ↑ 延迟峰值！
</code></pre>
<p>Swiss map 的解决方案：</p>
<pre><code>将大 map 拆分成多个小 table
每个 table 独立扩容
</code></pre>
<p><strong>目录结构</strong>：</p>
<pre><code>Hash = 0x1A2B3C4D

globalDepth = 2 (使用高 2 位)
┌────────────────────┐
│ Directory (size=4) │
├────────────────────┤
│ [00] → Table0      │ ← localDepth=1
│ [01] → Table0      │ ← 同一个 table
│ [10] → Table1      │ ← localDepth=2
│ [11] → Table2      │ ← localDepth=2
└────────────────────┘

hash &gt;&gt; (64-2) = 00  → 索引 Table0
</code></pre>
<h3>4.2 表分裂过程</h3>
<pre><code class="language-go">const maxTableCapacity = 1024

func (t *table) rehash(typ *abi.SwissMapType, m *Map) {
	newCapacity := 2 * t.capacity
	if newCapacity &lt;= maxTableCapacity {
		t.grow(typ, m, newCapacity) // 表内扩容
		return
	}

	t.split(typ, m) // 分裂
}
</code></pre>
<p><strong>触发条件</strong>：</p>
<ul>
<li>负载因子 &gt; 7/8（容量 × 0.875）</li>
<li>单表容量 ≤ 1024：表内扩容（容量翻倍）</li>
<li>单表容量 &gt; 1024：分裂成两个表</li>
</ul>
<p><strong>分裂示例</strong>：</p>
<pre><code>初始状态 (globalDepth=1):
Directory: [0] → Table0 (localDepth=1, 容量=1024)
           [1] → Table1 (localDepth=1, 容量=1024)

Table1 满了，需要分裂：
1. Table1 分裂为 Table1a 和 Table1b
2. globalDepth 增加到 2
3. 目录扩展：
   Directory: [00] → Table0  (localDepth=1)
              [01] → Table0  (同一个)
              [10] → Table1a (localDepth=2)
              [11] → Table1b (localDepth=2)
</code></pre>
<p><strong>分裂规则</strong>：</p>
<pre><code>旧 table 的元素根据哈希的新 bit 分流：
hash &amp; newbit == 0 → Table1a (低位)
hash &amp; newbit != 0 → Table1b (高位)
</code></pre>
<h3>4.3 负载因子</h3>
<pre><code class="language-go">const maxAvgGroupLoad = 7  // 7/8 = 87.5%
</code></pre>
<ul>
<li>Non-Swiss：6.5（约 81%）</li>
<li>Swiss：7/8（87.5%）</li>
</ul>
<p><strong>更高的原因</strong>：开放寻址 + 墓碑重用提高空间利用率</p>
<h2>5. 与 Non-Swiss 对比</h2>
<table>
<thead>
<tr>
<th>维度</th>
<th>Non-Swiss</th>
<th>Swiss</th>
</tr>
</thead>
<tbody><tr>
<td><strong>核心结构</strong></td>
<td>桶 + 链式溢出</td>
<td>组 + 开放寻址</td>
</tr>
<tr>
<td><strong>每桶/组大小</strong></td>
<td>8 个键值对</td>
<td>8 个槽位</td>
</tr>
<tr>
<td><strong>查找方式</strong></td>
<td>顺序比较 tophash</td>
<td><strong>并行匹配 H2</strong> ⭐</td>
</tr>
<tr>
<td><strong>冲突处理</strong></td>
<td>溢出桶链表</td>
<td>二次探测</td>
</tr>
<tr>
<td><strong>扩容方式</strong></td>
<td>渐进式迁移（全局锁）</td>
<td><strong>表分裂（细粒度）</strong> ⭐</td>
</tr>
<tr>
<td><strong>负载因子</strong></td>
<td>6.5 (81%)</td>
<td>7/8 (87.5%)</td>
</tr>
<tr>
<td><strong>删除标记</strong></td>
<td>直接删除</td>
<td>墓碑机制</td>
</tr>
<tr>
<td><strong>SIMD 支持</strong></td>
<td>无</td>
<td><strong>AMD64 优化</strong> ⭐</td>
</tr>
<tr>
<td><strong>内存布局</strong></td>
<td>分离 keys/values</td>
<td>交错 key-value</td>
</tr>
</tbody></table>
<p><strong>Swiss 的优势</strong>：</p>
<ol>
<li>✅ 并行匹配：一次检查 8 个槽位</li>
<li>✅ SIMD 加速：硬件级优化</li>
<li>✅ 细粒度扩容：单表限制控制延迟</li>
<li>✅ 更高负载因子：空间利用率提升</li>
</ol>
<p><strong>Swiss 的劣势</strong>：</p>
<ol>
<li>❌ 墓碑管理：需要定期清理</li>
<li>❌ 实现复杂：位运算 + SIMD 内联</li>
<li>❌ 目录开销：大 map 需要额外目录</li>
</ol>
<h2>6. 从源头理解 Swiss Table 版本</h2>
<p>swiss table 版本的 map 实现对笔者来说还是比较新奇的，我一开始一直无法彻底这种这种实现思路。因为它的核心思想确实与我们教科书上常见的拉链法（Chaining）或线性探测法（Linear Probing）有很大不同。好在现在有 LLM，所以笔者跟 Gemini 进行了一番探讨，才对 swiss table 的底层原理有了更深的理解。</p>
<h3>6.1 传统 HashMap 的问题</h3>
<p>要从根本上理解 swiss table 版本的 HashMap，我们必须回归到<strong>第一性原理</strong>：HashMap 的目标是在 O(1) 的时间复杂度内完成插入、查找和删除。而这个 <code>1</code> 在现代 CPU 面前，其含金量是完全不同的。</p>
<blockquote>
<p>[!IMPORTANT]</p>
<p>现代 CPU 的第一性原理：缓存为王 (Cache is King)</p>
</blockquote>
<p>我们常说的 O(1) 理论上假设内存访问是等价的。但在现实中：</p>
<ul>
<li><strong>CPU 访问 L1 缓存</strong>：~1-2 纳秒</li>
<li><strong>CPU 访问 L2 缓存</strong>：~5-10 纳秒</li>
<li><strong>CPU 访问 L3 缓存</strong>：~30-50 纳秒</li>
<li><strong>CPU 访问主内存 (RAM)</strong>：~100-200 纳秒</li>
</ul>
<p>一个 Cache Miss 并从主内存读取数据的代价，可能是访问 L1 缓存的 100 倍以上。</p>
<p>传统的 HashMap 是一个&quot;数组 + 链表&quot;的结构。<code>hash(key)</code> 算出一个数组下标（桶 B[i]）。如果冲突了，就把这个 (key, value) 挂在 <code>B[i]</code> 后面的链表上。这存在 2 个问题：</p>
<ul>
<li><strong>缓存极不友好：</strong> 链表的节点在内存中是<strong>离散分布</strong>的。当你遍历一个长链表来查找 key 时，每访问一个节点，都极有可能导致一次 <strong>Cache Miss</strong>（这被称为指针追逐 pointer chasing）。</li>
<li><strong>元数据开销：</strong> 每个节点都需要一个额外的指针来指向下一个节点，这在 64 位系统上就是 8 个字节。如果你的 (key, value) 本身很小（比如 <code>map[int]int</code>），这个指针的开销就非常巨大。</li>
</ul>
<p>所以：传统拉链法的主要瓶颈不在于 CPU 计算，而在于<strong>内存访问延迟</strong>。</p>
<h3>6.2 Swiss Table 的创新</h3>
<p>Swiss Table 的核心思想是<strong>用密集的计算换取稀疏的内存访问</strong>。它属于开放寻址法（Open Addressing）的一种，但又做出了革命性的优化。它同时解决了两个层面的核心问题：</p>
<ol>
<li><strong>微观问题 (Group 内)：</strong> 如何在 8 个槽位 (slot) 中<strong>一次性</strong>找到目标？（解决 CPU 运算和 L1 缓存效率）</li>
<li><strong>宏观问题 (Map 级)：</strong> 如何在 map 变得巨大时，实现<strong>低延迟</strong>的扩容？（解决内存访问和延迟抖动）</li>
</ol>
<p>在我看来，Swiss Table 有 3 点最重要的核心创新：</p>
<h4>6.2.1 核心创新一：控制字 (ctrls) 与并行匹配</h4>
<p>这是 Swiss Map 对缓存为王的极致应用。</p>
<ul>
<li><p><strong>第一性原理：</strong> CPU 访问 8 个完整的 <code>key</code> 来比较，即使它们都在 L1 缓存中，也是昂贵的（因为 <code>key</code> 可能很大）。最快的方式是，先用元数据过滤掉 99% 的无效比较。</p>
</li>
<li><p><strong>理论：</strong></p>
<ol>
<li><strong>数据结构：</strong> <code>group</code> 结构包含一个 8 字节的 <code>ctrls</code> 控制字 和 8 个 <code>slots</code> (key/elem 对)。</li>
<li><strong>哈希分割：</strong> 64 位哈希被分为 H1（高 57 位，用于定位）和 <strong>H2（低 7 位，用于匹配）</strong>。</li>
<li><strong>控制字编码：</strong> 这 8 字节的 <code>ctrls</code> 中，每个字节代表一个槽位 (slot) 的状态。<ul>
<li><code>0x80</code> (1000 0000) = <code>empty</code>（空）</li>
<li><code>0xFE</code> (1111 1110) = <code>deleted</code>（墓碑）</li>
<li><code>0x00-0x7F</code> (0xxx xxxx) = <code>full</code>（已占用）</li>
</ul>
</li>
<li><strong>关键设计：</strong> 当槽位为 <code>full</code> 时，它的 7 个 <code>x</code> 位存储的<strong>正是 H2 的 7 位哈希值</strong>。</li>
</ol>
</li>
<li><p>**实践（并行匹配的魔力）：**当我们要查找一个 key（其 H2 值为 target_h2）时，我们不再需要 for 循环 8 次。Go (Abseil) 使用了一种位运算的魔法：<code>ctrlGroup.matchH2(target_h2)</code>。</p>
<pre><code class="language-Go">v := uint64(g) ^ (0x0101010101010101 * uint64(h))
result := (v - 0x0101010101010101) &amp;^ v
matches := bitset(result &amp; 0x8080808080808080)
</code></pre>
<p>这一系列操作（在 AMD64 上甚至可以用 <strong>SIMD 指令</strong> <code>PCMPEQB</code> 加速）的<strong>唯一目的</strong>是：</p>
<blockquote>
<p><strong>用 1-2 个 CPU 指令，同时将 8 个槽位的 H2 值与 <code>target_h2</code> 进行比较</strong>，并返回一个 <code>bitset</code>。</p>
</blockquote>
<p>这个 <code>bitset</code> (例如 <code>0b00100100</code>) 会瞬间告诉我们：&quot;第 2 号和第 5 号槽位的 H2 匹配了，请只检查它们俩的完整 <code>key</code>&quot;。这使得查找 8 个槽位的开销，从 <strong>O(8) 次比较</strong> 降低到了 <strong>O(1) 次并行比较</strong>。</p>
</li>
</ul>
<h4>6.2.2 核心创新二：开放寻址与二次探测</h4>
<p>这是 Swiss Map 解决哈希冲突的方式，也是它优于传统拉链法的第二个关键点。</p>
<ul>
<li><strong>第一性原理：</strong> 拉链法的 <code>overflow</code> 链表节点在内存中是<strong>离散</strong>的，遍历链表会导致大量的指针追逐 (pointer chasing)，引发<strong>灾难性的 Cache Miss</strong>。</li>
<li>**理论（开放寻址）：**Swiss Map 规定，所有数据都必须存储在连续的 group 数组中。如果 <code>hash(key)</code> 算出的主 group 满了，它不会挂一个链表。</li>
<li><strong>实践（二次探测）：</strong><ol>
<li>如果主 <code>group</code> 满了（或者 H2 没匹配上，且没有空槽），我们怎么办？</li>
<li>我们通过一个二次探测 (Quadratic Probing) 公式： <code>p(i) = (i² + i)/2 + hash (mod 组数)</code>，计算<strong>下一个</strong>要探测的 <code>group</code> 的索引。</li>
<li>这个公式的重点在于它<strong>可预测、无指针、计算快</strong>，并且能保证（在 2 的幂容量下）遍历所有 <code>group</code>。</li>
<li><strong>根本收益：</strong> 我们用 <strong>CPU 计算（下一个索引）</strong> 代替了 <strong>内存访问（指针跳转）</strong>。由于 <code>group</code> 数组是连续内存，下一个 <code>group</code> 极有可能也已在 CPU 缓存中，访问极快。</li>
</ol>
</li>
<li>**带来的问题（墓碑机制）：**这也解释了为什么需要 <code>deleted (0xFE)</code> 状态。如文档所说，如果你删除了探测链中间的一个元素并将其标记为 <code>empty (0x80)</code>，那么查找它后面的元素时，探测链会在这里错误地终止。因此，删除时必须留下墓碑 (tombstone)，告诉查找操作：&quot;这里曾经有过人，请继续往后找&quot;。</li>
</ul>
<h4>6.2.3 核心创新三：可扩展哈希与表分裂</h4>
<p>这是 Swiss Map 解决宏观扩容问题的精妙之举。</p>
<ul>
<li><strong>第一性原理：</strong> 传统 map 扩容时，需要分配一个 2 倍大的新数组，并把<strong>所有</strong>元素 rehash 过去。如果 map 有 1 亿个元素，这个延迟是不可接受的。</li>
<li>**理论（可扩展哈希）：**Go 的 Swiss Map 并没有一个无限大的 group 数组。它引入了目录 (Directory) 结构。<ol>
<li>顶层 <code>Map</code> 结构有一个 <code>dirPtr</code> 和 <code>dirLen</code>。</li>
<li><code>dirPtr</code> 指向一个 <code>[dirLen]*table</code> 指针数组。</li>
<li>每个 <code>table</code> 才是真正存储 <code>group</code> 的地方，但<strong>每个 <code>table</code> 的容量是受限的</strong>（例如 1024 个槽位）。</li>
</ol>
</li>
<li><strong>实践（增量扩容与表分裂）：</strong><ol>
<li><strong>查找：</strong> <code>hash(key)</code> 的高位（由 <code>globalDepth</code> 决定）用于在 <code>directory</code> 中<strong>选择使用哪个 <code>table</code></strong>。<code>hash(key)</code> 的低位（H1）用于在该 <code>table</code> 内进行“二次探测”。</li>
<li><strong>扩容（关键）：</strong> 当一个 <code>table</code> 负载过高（例如 &gt; 7/8） 且已达到 1024 槽位的上限时，我们<strong>不再扩容整个 map</strong>。</li>
<li>我们只<strong>分裂这一个 <code>table</code></strong>。如 <code>Table1</code> (localDepth=1) 分裂为 <code>Table1a</code> 和 <code>Table1b</code> (localDepth=2)。</li>
<li>然后，我们去更新 <code>directory</code> 中的指针。如果 <code>directory</code> 不够大（<code>globalDepth &lt; new_localDepth</code>），我们就<strong>只扩容 <code>directory</code></strong>（这很快，因为它只存指针）。</li>
<li><strong>根本收益：</strong> 扩容的开销被<strong>均摊</strong>了。延迟是可控的，因为我们<strong>一次最多只迁移 1024 个元素</strong>，而不是 1 亿个。</li>
</ol>
</li>
</ul>
<h4>6.2.4 总结</h4>
<p>总的来说，Go Swiss Table 是三种先进技术的完美结合：</p>
<ol>
<li><strong>微观 (Group 内)：</strong> <strong>并行匹配</strong>（基于 SIMD/位运算），实现 O(1) 的 8 槽位匹配。</li>
<li><strong>中观 (Table 内)：</strong> <strong>二次探测</strong>（开放寻址），实现无指针、缓存友好的冲突解决。</li>
<li><strong>宏观 (Map 级)：</strong> <strong>可扩展哈希</strong>（目录+表分裂），实现低延迟、增量式的扩容。</li>
</ol>
<p>这套组合拳，从 L1 缓存、CPU 指令集，一直优化到全局内存布局和延迟控制，是现代高性能 Hash Map 的典范之作。</p>
<h3>6.3 Swiss Table 的演进猜想</h3>
<p>理解了是什么（What）之后，追问为什么（Why）和如何演进（How）是掌握一个复杂系统最根本的方法。本小节我们尝试像一个系统设计师一样，从零开始推演 Go Swiss Table 的实现过程。</p>
<h4>6.3.1 第 0 步：明确目标与核心矛盾</h4>
<ul>
<li><strong>初始状态：</strong> 拉链法。</li>
<li><strong>要解决的核心矛盾（第一性原理）：</strong><ol>
<li><strong>性能瓶颈：</strong> 传统 map 的性能瓶颈<strong>不在 CPU 计算，而在内存访问</strong>。</li>
<li><strong>缓存失效 (Cache Miss)：</strong> 拉链法的溢出桶链表在内存中是<strong>离散</strong>的，遍历它会导致大量的指针追逐，每一次跳转都可能是一次昂贵的 Cache Miss（访问主内存）。</li>
<li><strong>延迟抖动：</strong> 传统 map 扩容时，需要一次性迁移所有数据，导致服务（STW）的延迟毛刺。</li>
</ol>
</li>
<li><strong>新 map 的目标：</strong><ol>
<li><strong>缓存友好：</strong> 必须用连续内存布局，消除指针追逐。</li>
<li><strong>低延迟：</strong> 必须实现增量式扩容，平滑延迟。</li>
<li><strong>高吞吐：</strong> 查找、插入、删除操作要尽可能快。</li>
</ol>
</li>
</ul>
<h4>6.3.2 第 1 步：从拉链到开放寻址</h4>
<p><strong>为了实现目标 1（缓存友好）：</strong></p>
<ul>
<li><strong>设计师的决策：</strong> 抛弃拉链法，全面转向<strong>开放寻址法</strong> (Open Addressing)。</li>
<li><strong>为什么？</strong> 开放寻址法将所有 (key, value) 存储在一个<strong>连续的数组</strong>中。</li>
<li><strong>带来的新问题：</strong><ol>
<li><strong>冲突解决：</strong> 如何处理哈希冲突？（拉链法用链表，现在没链表了）</li>
<li><strong>查找效率：</strong> 如何在连续数组中快速找到目标？</li>
<li><strong>删除：</strong> 拉链法删除一个节点很简单，开放寻址法删除了一个元素，会不会中断探测链？</li>
</ol>
</li>
</ul>
<h4>6.3.3 第 2 步：解决冲突与查找效率</h4>
<p><strong>为了解决第 1 步的新问题（冲突与查找）：</strong></p>
<ul>
<li><strong>决策 1 - 冲突解决：</strong> 采用<strong>二次探测</strong> (Quadratic Probing)。<ul>
<li><em>为什么不用线性探测？</em> 线性探测（<code>hash + i</code>）会导致严重的聚集。</li>
<li><em>为什么用二次探测？</em> <code>p(i) = (i² + i)/2 + hash</code> 公式能跳跃式分布，减少聚集，且计算开销小。</li>
</ul>
</li>
<li><strong>决策 2 - 查找效率（Swiss Table 的核心！）：</strong><ul>
<li><em>开放寻址的痛点：</em> 探测 <code>i</code> 位置时，必须比较 <code>key == target_key</code>。如果 <code>key</code> 是个很长的字符串，这个比较本身就非常昂贵。</li>
<li><strong>思路：</strong> 我们能不能<strong>先不过早地比较完整的 Key</strong>？</li>
<li><strong>解决方案：</strong> 引入元数据！将哈希值一分为二：<strong>H1</strong>（用于探测）和 <strong>H2</strong>（用于过滤）。</li>
<li><strong>演进：</strong> 我们设计一个<strong>控制字</strong> (Control Word) 数组，它与 <code>slots</code> 数组平行。这个 <code>ctrls</code> 数组只存放 H2（低 7 位）。</li>
<li><strong>查找流程优化：</strong><ol>
<li>（昂贵）<code>for i... { if array[i].key == my_key }</code></li>
<li>（优化后）<code>for i... { if ctrls[i] == H2 { if array[i].key == my_key } }</code></li>
</ol>
</li>
<li><strong>收益：</strong> 99% 的比较，都从昂贵的 Key 比较变成了廉价的 1 字节 H2 比较。</li>
</ul>
</li>
</ul>
<h4>6.3.4 第 3 步：将查找效率推向极致</h4>
<p><strong>为了解决第 2 步的遗留问题：</strong></p>
<ul>
<li><strong>新痛点：</strong> 查找 H2（<code>ctrls[i] == H2</code>）虽然廉价，但我们<strong>仍然在 <code>for</code> 循环</strong>！如果一个 <code>group</code> 有 8 个槽位，最坏情况还是要循环 8 次。</li>
<li><strong>设计师的决策：</strong> <strong>一次性比较 8 个槽位！</strong></li>
<li><strong>为什么？</strong><ol>
<li>现代 CPU 拥有 <strong>SIMD</strong>（单指令多数据流）指令集（如 x86 上的 SSE2，ARM 上的 NEON）。</li>
<li>CPU 的 L1 缓存一次加载 64 字节（一个 Cache Line）。我们一个 <code>group</code> 里的 8 字节 <code>ctrls</code> 必定在同一个 Cache Line 里。</li>
</ol>
</li>
<li><strong>解决方案：</strong><ul>
<li>将 8 个 <code>ctrl</code> 字节视为一个 <code>uint64</code>。</li>
<li>使用 SIMD 指令（如 <code>PCMPEQB</code>）或等效的位运算，<strong>并行地</strong>将这个 <code>uint64</code> 中的 8 个字节与我们目标的 H2 进行比较。</li>
<li><strong>收益：</strong> 查找 <code>group</code> 内 8 个槽位的复杂度，从 <strong>O(8)</strong>（串行） 降低到 <strong>O(1)</strong>（并行）。</li>
</ul>
</li>
</ul>
<h4>6.3.5 第 4 步：解决删除的遗留问题</h4>
<p><strong>为了解决第 1 步留下的删除问题：</strong></p>
<ul>
<li><strong>痛点：</strong> 在一个探测链 <code>A -&gt; B -&gt; C</code> 中，如果删除了 B 并标记为 <code>empty</code>。</li>
<li><strong>后果：</strong> 查找 C 时，探测到 A，下一个是 <code>empty</code>，<strong>查找会提前终止</strong>，导致 C 永远找不到。</li>
<li><strong>解决方案：</strong> 引入<strong>墓碑</strong> (Tombstone) 状态。</li>
<li><strong>演进：</strong> <code>ctrls</code> 字节现在必须有 3 种状态：<ol>
<li><code>empty</code> (0x80)：空的，探测终止。</li>
<li><code>deleted</code> (0xFE)：墓碑，<strong>插入时可复用</strong>，<strong>查找时请继续</strong>。</li>
<li><code>full</code> (0x00-0x7F)：有数据（H2）。</li>
</ol>
</li>
</ul>
<h4>6.3.6 第 5 步：解决扩容的延迟目标</h4>
<p><strong>为了实现目标 2（低延迟）：</strong></p>
<ul>
<li><strong>痛点：</strong> 我们的开放寻址表（<code>group</code> 数组）如果满了（例如负载 &gt; 87.5%），传统的做法是分配一个 2 倍大的新表，然后 rehash <strong>所有</strong>元素。</li>
<li><strong>后果：</strong> 导致巨大的延迟毛刺，违背了目标 2。</li>
<li><strong>解决方案：</strong> <strong>可扩展哈希</strong> (Extensible Hashing)。</li>
<li><strong>演进：</strong><ol>
<li>我们不使用一个无限大的表。我们将数据拆分到<strong>多个</strong>、<strong>固定大小</strong>（如 1024 槽位）的 <code>table</code> 中。</li>
<li>我们引入一个<strong>目录</strong> (Directory) 结构，它是一个指针数组，指向这些 <code>table</code>。</li>
<li>使用哈希的<strong>高位</strong> (<code>globalDepth</code>) 来决定去哪个 <code>directory</code> 槽位，找到对应的 <code>table</code>。</li>
<li>使用哈希的<strong>低位</strong> (H1) 在 <code>table</code> 内部进行二次探测。</li>
</ol>
</li>
<li><strong>收益：</strong><ul>
<li>当一个 <code>table</code> 满了，我们<strong>只需要分裂这一个 <code>table</code></strong>！</li>
<li>扩容的开销被<strong>均摊</strong>了。一次迁移的元素上限是 1024，而不是 N（N 可能是 1 亿）。这完美解决了延迟抖动问题。</li>
</ul>
</li>
</ul>
<h4>6.3.7 第 6 步：锦上添花的优化</h4>
<ul>
<li><strong>痛点：</strong> 如果用户只 <code>make(map[int]int)</code> 并存了 3 个元素，我们也需要分配 <code>directory</code> 和 <code>table</code> 吗？太浪费了。</li>
<li><strong>解决方案：</strong> <strong>小 Map 优化</strong>。</li>
<li><strong>演进：</strong><ul>
<li>当 <code>dirLen == 0</code> 时，<code>dirPtr</code> 不指向 <code>directory</code>，而是<strong>直接指向单个 <code>group</code></strong>（8 个槽位）。</li>
<li><strong>收益：</strong> 对于绝大多数（&lt; 8 个元素）的 map，内存开销极小，且无需任何 <code>table</code> 间接寻址。</li>
</ul>
</li>
</ul>
<h4>6.3.8 总结：从 0 到 1 的演进路径</h4>
<p>这个 0 到 1 的实现路径，清晰地展现了从第一性原理出发的思考：</p>
<p><strong>缓存失效（问题）</strong> → <strong>1. 开放寻址（方案）</strong> → <strong>2. 元数据（H2）过滤（解决比较效率）</strong> → <strong>3. 并行匹配（极致优化比较）</strong> → <strong>4. 墓碑（解决删除遗留问题）</strong> → <strong>5. 可扩展哈希（解决扩容延迟）</strong> → <strong>6. 小 Map 优化（解决小内存开销）</strong></p>
<h2>7. 并发：sync.Map 的 HashTrieMap 实现</h2>
<h3>7.1 为什么需要新实现？</h3>
<p><strong>传统 sync.Map 的问题</strong>：</p>
<ul>
<li>全局锁：写操作竞争激烈</li>
<li>双 map 开销：read + dirty 内存翻倍</li>
<li>提升机制：周期性全量拷贝</li>
</ul>
<p><strong>HashTrieMap 的解决方案</strong>：</p>
<ul>
<li><strong>Hash-Trie 结构</strong>：树形结构，天然支持分区</li>
<li><strong>细粒度锁</strong>：每个节点独立锁，并发度高</li>
<li><strong>无需提升</strong>：没有 read/dirty 切换</li>
</ul>
<h3>7.2 HashTrieMap 数据结构</h3>
<pre><code class="language-go">type HashTrieMap[K comparable, V any] struct {
    root     atomic.Pointer[indirect[K, V]]
    keyHash  hashFunc
    valEqual equalFunc
    seed     uintptr
}

// 中间节点（indirect node）
type indirect[K, V] struct {
    mu       Mutex                      // 节点锁
    dead     atomic.Bool                // 节点是否已死
    children [64]atomic.Pointer[node]   // 64 个子节点
}

// 叶子节点（entry node）
type entry[K, V] struct {
    overflow *entry[K, V]  // 哈希冲突链表
    key      K
    value    V
}
</code></pre>
<p><strong>Trie 结构</strong>（每层使用 6 位哈希）：</p>
<pre><code>               root
              /    \
      [0-63]        [64-127]
      /    \            |
 [0-15]  [16-31]      [...]
   |       |
entries entries
</code></pre>
<h3>7.3 查找流程</h3>
<pre><code class="language-go">func (ht *HashTrieMap[K, V]) Load(key K) (V, bool) {
    hash := Hash(key)
    i := ht.root.Load()

    // 从根向下遍历，每次取 6 位哈希
    for shift := 58; shift &gt;= 0; shift -= 6 {
        idx := (hash &gt;&gt; shift) &amp; 0x3F  // 取 6 位
        n := i.children[idx].Load()

        if n == nil {
            return zero, false
        }
        if n.isEntry {
            return n.entry().lookup(key)
        }
        i = n.indirect()
    }
}
</code></pre>
<p><strong>无锁读取</strong>：读操作不需要加锁！</p>
<h3>7.4 插入流程</h3>
<pre><code class="language-go">func (ht *HashTrieMap[K, V]) LoadOrStore(key K, value V) (V, bool) {
    hash := Hash(key)

retry:
    // 无锁查找插入点
    i, slot := ht.findInsertPoint(hash)

    // 加锁（只锁一个节点）
    i.mu.Lock()
    defer i.mu.Unlock()

    // 双重检查
    n := slot.Load()
    if n != nil &amp;&amp; n changed {
        goto retry  // 节点变化，重试
    }

    // 插入新 entry
    newEntry := &amp;entry{key: key, value: value}
    slot.Store(newEntry)
    return value, false
}
</code></pre>
<p><strong>细粒度锁</strong>：</p>
<pre><code>        root (lock1)
       /           \
   indirect1    indirect2
   (lock2)       (lock3)
      ↓             ↓
  goroutine1  goroutine2
  可以同时修改不同子树！
</code></pre>
<h3>7.5 与传统 sync.Map 对比</h3>
<table>
<thead>
<tr>
<th>维度</th>
<th>Read-Write sync.Map</th>
<th>HashTrieMap</th>
</tr>
</thead>
<tbody><tr>
<td><strong>数据结构</strong></td>
<td>双 map（read + dirty）</td>
<td>Hash-Trie 树</td>
</tr>
<tr>
<td><strong>并发策略</strong></td>
<td>全局锁 + 读写分离</td>
<td><strong>细粒度锁（per-node）</strong></td>
</tr>
<tr>
<td><strong>读性能</strong></td>
<td>命中 read：O(1) 无锁<br>未命中：加锁</td>
<td><strong>始终无锁，O(log₆₄ n)</strong></td>
</tr>
<tr>
<td><strong>写性能</strong></td>
<td>快速路径：原子操作<br>慢速路径：全局锁</td>
<td><strong>锁粒度细，O(log₆₄ n)</strong></td>
</tr>
<tr>
<td><strong>内存开销</strong></td>
<td>两份 map</td>
<td>Trie 节点开销</td>
</tr>
<tr>
<td><strong>提升机制</strong></td>
<td>需要周期性提升</td>
<td><strong>无需提升</strong></td>
</tr>
<tr>
<td><strong>类型安全</strong></td>
<td><code>map[any]any</code></td>
<td><strong>泛型 <code>HashTrieMap[K,V]</code></strong></td>
</tr>
</tbody></table>
<p><strong>HashTrieMap 的优势</strong>：</p>
<ol>
<li>✅ 细粒度并发：锁竞争大幅降低</li>
<li>✅ 无需提升：没有全量拷贝开销</li>
<li>✅ 泛型支持：类型安全</li>
<li>✅ 读无锁：始终无需加锁</li>
</ol>
<p><strong>适用场景</strong>：</p>
<ul>
<li>高并发写入</li>
<li>键空间大（Trie 的空间优势）</li>
<li>需要泛型支持</li>
</ul>
<h2>8. 总结</h2>
<h3>8.1 Swiss Map 核心创新</h3>
<ol>
<li><p><strong>并行匹配算法</strong>：</p>
<pre><code>传统：逐个比较 8 次
Swiss：并行比较 1 次（8 倍提升）
</code></pre>
</li>
<li><p><strong>可扩展哈希</strong>：</p>
<pre><code>传统：单表扩容，延迟峰值
Swiss：多表分裂，延迟可控
</code></pre>
</li>
<li><p><strong>SIMD 加速</strong>：</p>
<pre><code>AMD64：使用 SSE2 指令
性能提升：2-3 倍
</code></pre>
</li>
</ol>
<h3>8.2 设计权衡</h3>
<p><strong>优势</strong>：</p>
<ul>
<li>✅ 查找性能：并行匹配 + SIMD</li>
<li>✅ 空间效率：87.5% 负载因子</li>
<li>✅ 增量扩容：表分裂控制延迟</li>
</ul>
<p><strong>劣势</strong>：</p>
<ul>
<li>❌ 墓碑管理：需要定期清理</li>
<li>❌ 实现复杂：位运算 + SIMD 内联</li>
<li>❌ 目录开销：大 map 需要额外结构</li>
</ul>
<h3>8.3 选择建议</h3>
<pre><code>┌─────────────────────────────────────┐
│ 需要并发？                           │
│  ├─ 否 → 使用 map (Swiss 或 non-Swiss)|
│  └─ 是 ↓                            │
├─────────────────────────────────────┤
│ 写多还是读多？                       │
│  ├─ 读多 → sync.Map (Read-Write)    │
│  └─ 写多 → sync.Map (HashTrieMap)   │
└─────────────────────────────────────┘
</code></pre>
<p><strong>最佳实践</strong>：</p>
<ol>
<li>默认使用 Swiss map（已启用实验特性）</li>
<li>高并发场景使用 sync.Map</li>
<li>简单场景用 <code>RWMutex + map</code></li>
</ol>
<hr>
<p><strong>参考资料</strong>：</p>
<ul>
<li><a href="https://abseil.io/about/design/swisstables">Abseil Swiss Tables</a></li>
<li><a href="https://github.com/golang/go/issues/54766">Go Swiss Map PR</a></li>
<li><a href="https://github.com/golang/go/issues/70155">Go HashTrieMap PR</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Go 底层原理丨map（非 swiss 版本）</title>
      <link>https://hedon.top/blog/go-map-no-swiss/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-map-no-swiss/</guid>
      <pubDate>Sun, 16 Nov 2025 14:00:00 GMT</pubDate>
      <description>本文系统解析 Go map（非 swiss 版本）的底层实现，涵盖数据结构、寻址与扩容、哈希冲突处理、迭代语义与适用场景，并对 sync.Map 的设计取舍与实现要点进行对照说明。</description>
      <category>Go</category>
      <content:encoded><![CDATA[<p>特此声明，本篇是笔者与 Google Gemini 3Pro 共创所作，非常庆幸在当今 AI 时代下获取知识已是如此便利，且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑，均由笔者把控和审阅，欢迎放心阅读。</p>
<h2>1. 整体设计思想</h2>
<p>Go map（no swiss 版本） 本质上是一个<strong>哈希表</strong>，采用以下核心设计：</p>
<ol>
<li><strong>桶式哈希</strong>：数据被组织成桶（bucket）数组</li>
<li><strong>链式溢出</strong>：每个桶最多存储 8 个键值对，超过则链接溢出桶</li>
<li><strong>渐进式扩容</strong>：扩容时采用增量迁移，避免一次性拷贝大量数据</li>
<li><strong>分离存储</strong>：键和值分别连续存储（而非交替存储），减少内存对齐的填充开销</li>
</ol>
<h2>2. 核心数据结构</h2>
<p>本篇以 Go1.25 版本的源码为基准进行展开，完整源码可参考官方代码：<a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/map_noswiss.go#L115">map_noswiss.go</a>。</p>
<h3>2.1 hmap（map 头部结构）</h3>
<pre><code class="language-go">type hmap struct {
	// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
	// Make sure this stays in sync with the compiler&#39;s definition.
	count     int // # live cells == size of map.  Must be first (used by len() builtin)
	flags     uint8
	B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
	noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
	hash0     uint32 // hash seed

	buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
	oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
	nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)
	clearSeq   uint64

	extra *mapextra // optional fields
}
</code></pre>
<ul>
<li><code>count</code>：map 中元素个数</li>
<li><code>B</code>：桶数量的对数（桶数 = 2^B）</li>
<li><code>buckets</code>：当前桶数组 <code>[]bmap</code> 指针</li>
<li><code>oldbuckets</code>：扩容时的旧桶数组 <code>[]bmap</code></li>
<li><code>nevacuate</code>：扩容迁移进度计数器</li>
<li><code>hash0</code>：哈希种子（防止哈希碰撞攻击）</li>
</ul>
<h3>2.2 bmap（桶结构）</h3>
<pre><code class="language-go">// A bucket for a Go map.
type bmap struct {
	// tophash generally contains the top byte of the hash value
	// for each key in this bucket. If tophash[0] &lt; minTopHash,
	// tophash[0] is a bucket evacuation state instead.
	tophash [abi.OldMapBucketCount]uint8
	// Followed by bucketCnt keys and then bucketCnt elems.
	// NOTE: packing all the keys together and then all the elems together makes the
	// code a bit more complicated than alternating key/elem/key/elem/... but it allows
	// us to eliminate padding which would be needed for, e.g., map[int64]int8.
	// Followed by an overflow pointer.
}
</code></pre>
<p><strong>内存布局</strong>（运行时动态生成）：</p>
<pre><code>[tophash0][tophash1]...[tophash7]
[key0][key1]...[key7]
[value0][value1]...[value7]
[overflow pointer]
</code></pre>
<p><strong>tophash 的作用</strong>：</p>
<ul>
<li>存储哈希值的高 8 位，用于快速比较</li>
<li>特殊值标记桶的状态（空槽、已迁移等）</li>
</ul>
<h3>2.3 特殊状态值</h3>
<pre><code class="language-go">	// Possible tophash values. We reserve a few possibilities for special marks.
	// Each bucket (including its overflow buckets, if any) will have either all or none of its
	// entries in the evacuated* states (except during the evacuate() method, which only happens
	// during map writes and thus no one else can observe the map during that time).
	emptyRest      = 0 // this cell is empty, and there are no more non-empty cells at higher indexes or overflows.
	emptyOne       = 1 // this cell is empty
	evacuatedX     = 2 // key/elem is valid.  Entry has been evacuated to first half of larger table.
	evacuatedY     = 3 // same as above, but evacuated to second half of larger table.
	evacuatedEmpty = 4 // cell is empty, bucket is evacuated.
	minTopHash     = 5 // minimum tophash for a normal filled cell.

	// flags
	iterator     = 1 // there may be an iterator using buckets
	oldIterator  = 2 // there may be an iterator using oldbuckets
	hashWriting  = 4 // a goroutine is writing to the map
	sameSizeGrow = 8 // the current map growth is to a new map of the same size
</code></pre>
<h3>2.4 总结图</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h54yxtuoa2j21w00qaadi.jpg" alt=""></p>
<h2>3. 核心操作</h2>
<h3>3.1 初始化（makemap）</h3>
<ul>
<li><p>make</p>
<pre><code class="language-go">m := make(map[int]string, 10)
</code></pre>
<p>底层：调用 runtime/map.go 中的 <code>makemap()</code></p>
<pre><code class="language-go">func makemap(t *maptype, hint int, h *hmap) *hmap {

  // 1. 计算预期的 map 大小
    mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
    if overflow || mem &gt; maxAlloc {
        hint = 0
    }

    // 2. 创建一个新的 hmap
    if h == nil {
        h = new(hmap)
    }
    h.hash0 = fastrand()

    // 3. 计算 B
    B := uint8(0)
    for overLoadFactor(hint, B) {
        B++
    }
    h.B = B

    if h.B != 0 {
    // 4. 根据 B 创建桶和溢出桶
        var nextOverflow *bmap
        h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
        if nextOverflow != nil {
      // 5. 将溢出桶的数据存在 mapextra 中
            h.extra = new(mapextra)
            h.extra.nextOverflow = nextOverflow
        }
    }

    return h
}
</code></pre>
</li>
<li><p>字面量（底层还是先调用 makemap，然后再做赋值）</p>
<ul>
<li>元素少于 25 个时，一个一个简单赋值</li>
<li>元素多个 25 个时，转为循环赋值</li>
</ul>
</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h54yxo97l4j21y20o0tbz.jpg" alt=""></p>
<h3>3.2 查找操作（mapaccess1/mapaccess2）</h3>
<p>底层调用了 <code>runtime/map.go</code> 中的 <code>mapaccess1()</code> 或者 <code>mapaccess2</code> 方法：</p>
<ul>
<li>v := m[k] 调用 <code>mapaccess1()</code></li>
<li>v,k := m[k] 调用 <code>mapaccess2()</code></li>
</ul>
<p><strong>查找流程</strong>：</p>
<pre><code class="language-go">// mapaccess1 实现 v := m[k] 语义，返回指向值的指针
// 核心思路：hash定位桶 → tophash快速过滤 → 完整key比较
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
	// ============================================
	// 第一部分：安全性检查（Race/Memory Sanitizer）
	// ============================================
	if raceenabled &amp;&amp; h != nil {
		// Race Detector：记录读操作，检测并发竞态条件
		callerpc := sys.GetCallerPC()
		pc := abi.FuncPCABIInternal(mapaccess1)
		racereadpc(unsafe.Pointer(h), callerpc, pc)
		raceReadObjectPC(t.Key, key, callerpc, pc)
	}
	if msanenabled &amp;&amp; h != nil {
		// MSan（Memory Sanitizer）：检查 key 是否已初始化
		msanread(key, t.Key.Size_)
	}
	if asanenabled &amp;&amp; h != nil {
		// ASan（Address Sanitizer）：检查 key 的内存访问是否合法
		asanread(key, t.Key.Size_)
	}

	// ============================================
	// 第二部分：边界情况处理
	// ============================================
	if h == nil || h.count == 0 {
		// nil map 或空 map：检查 key 类型是否支持作为 map 的键
		// 例如：不可比较类型（slice、map、func）会在这里 panic
		if err := maps.OldMapKeyError(t, key); err != nil {
			panic(err) // see issue 23734
		}
		// 返回零值的引用（所有零值共享同一个全局 zeroVal）
		return unsafe.Pointer(&amp;zeroVal[0])
	}

	// ============================================
	// 第三部分：并发安全检查
	// ============================================
	if h.flags&amp;hashWriting != 0 {
		// 检测到并发读写：map 不支持并发，立即 fatal
		// 注意：这只能检测到部分并发情况，不是完整的并发保护
		fatal(&quot;concurrent map read and map write&quot;)
	}

	// ============================================
	// 第四部分：计算哈希值并定位桶
	// ============================================
	// 使用类型特定的哈希函数计算 hash 值（包含随机 seed）
	hash := t.Hasher(key, uintptr(h.hash0))

	// 计算桶掩码：bucketMask(B) = 2^B - 1
	// 用于快速取模：hash &amp; mask 等价于 hash % (2^B)
	m := bucketMask(h.B)

	// 定位到目标桶：使用哈希值的低 B 位索引桶数组
	// 公式：bucket_index = hash &amp; (2^B - 1)
	b := (*bmap)(add(h.buckets, (hash&amp;m)*uintptr(t.BucketSize)))

	// ============================================
	// 第五部分：处理扩容中的情况
	// ============================================
	if c := h.oldbuckets; c != nil {
		// map 正在扩容中，需要检查旧桶
		if !h.sameSizeGrow() {
			// 情况1：翻倍扩容（容量变为 2 倍）
			// 旧桶数量是新桶的一半，需要将掩码右移一位
			// 例如：B=4 时有 16 个桶，扩容后 B=5 有 32 个桶
			m &gt;&gt;= 1
		}
		// 情况2：等量扩容（sameSizeGrow）
		// 桶数量不变，只是整理碎片，掩码不变

		// 在旧桶数组中定位对应的桶
		oldb := (*bmap)(add(c, (hash&amp;m)*uintptr(t.BucketSize)))

		// 检查旧桶是否已迁移（evacuated）
		if !evacuated(oldb) {
			// 旧桶还未迁移，从旧桶中查找
			// 这是渐进式扩容的关键：读操作会读取未迁移的旧桶
			b = oldb
		}
		// 如果旧桶已迁移，继续使用新桶 b（前面已计算）
	}

	// ============================================
	// 第六部分：tophash 快速过滤
	// ============================================
	// 提取哈希值的高 8 位作为 tophash
	// tophash 用于快速过滤：只有 tophash 匹配才进行完整 key 比较
	top := tophash(hash)

	// ============================================
	// 第七部分：遍历桶链表查找 key
	// ============================================
bucketloop:
	// 外层循环：遍历溢出桶链表（当前桶 → overflow → overflow → ...）
	for ; b != nil; b = b.overflow(t) {
		// 内层循环：遍历当前桶的 8 个槽位
		for i := uintptr(0); i &lt; abi.OldMapBucketCount; i++ {
			// ----------------------------------------
			// 步骤1：tophash 预检（快速路径）
			// ----------------------------------------
			if b.tophash[i] != top {
				// tophash 不匹配，快速跳过
				// 这避免了昂贵的完整 key 比较

				if b.tophash[i] == emptyRest {
					// 优化：遇到 emptyRest 标记
					// 表示当前位置及后续所有槽位（包括溢出桶）都是空的
					// 可以直接结束查找，无需继续遍历
					break bucketloop
				}
				// tophash 为 emptyOne 或其他已占用槽位：继续检查下一个
				continue
			}

			// ----------------------------------------
			// 步骤2：tophash 匹配，获取 key 指针
			// ----------------------------------------
			// 计算 key 的位置：dataOffset + i * keySize
			// 内存布局：[tophash数组][key0][key1]...[key7][value0]...[value7][overflow指针]
			k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))

			if t.IndirectKey() {
				// 间接 key：key 太大（&gt;128字节），实际存储的是指针
				// 需要解引用获取真实的 key 地址
				k = *((*unsafe.Pointer)(k))
			}

			// ----------------------------------------
			// 步骤3：完整 key 比较（慢速路径）
			// ----------------------------------------
			if t.Key.Equal(key, k) {
				// key 匹配！计算对应的 value 地址
				// value 紧跟在所有 key 之后
				// 位置：dataOffset + 8*keySize + i*valueSize
				e := add(unsafe.Pointer(b), dataOffset+abi.OldMapBucketCount*uintptr(t.KeySize)+i*uintptr(t.ValueSize))

				if t.IndirectElem() {
					// 间接 elem：value 太大，存储的是指针
					// 解引用获取真实的 value 地址
					e = *((*unsafe.Pointer)(e))
				}

				// 返回指向 value 的指针
				return e
			}
			// key 不匹配（tophash 碰撞，false positive）
			// 继续检查当前桶的下一个槽位
		}
		// 当前桶的 8 个槽位都检查完毕，继续检查溢出桶
	}

	// ============================================
	// 第八部分：未找到 key
	// ============================================
	// 遍历完所有桶和溢出桶都未找到 key
	// 返回零值引用（而不是 nil）
	return unsafe.Pointer(&amp;zeroVal[0])
}
</code></pre>
<p><strong>关键步骤</strong>：</p>
<ol>
<li>计算 key 的哈希值</li>
<li>用哈希值的低位定位桶（<code>hash &amp; bucketMask</code>）</li>
<li>如果正在扩容，检查旧桶是否已迁移</li>
<li>用哈希值的高 8 位（tophash）快速比较</li>
<li>tophash 匹配后，再进行完整的 key 比较</li>
<li>遍历溢出桶链表</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h54yxrocm7j21op0u0gqu.jpg" alt=""></p>
<h3>3.3 插入/更新操作（mapassign）</h3>
<p>底层调用 <code>runtime/map.go</code> 的 <code>mapassign()</code> 方法，跟 <code>mapaccess()</code> 非常像，只不过：</p>
<ol>
<li>先找找看 key 在不在，在的话，则覆盖新的 value；</li>
<li>如果 key 不在，则插入新的 key 和 value，这里则需要考虑是否需要扩容了；</li>
</ol>
<h3>3.4 删除操作（mapdelete）</h3>
<pre><code class="language-go">func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
	// 计算哈希值
	hash := t.Hasher(key, uintptr(h.hash0))

	// 设置写标志（在Hasher之后，避免panic时状态不一致）
	h.flags ^= hashWriting

	// 定位桶并触发渐进式迁移（如果正在扩容）
	bucket := hash &amp; bucketMask(h.B)
	if h.growing() {
		growWork(t, h, bucket)
	}

	b := (*bmap)(add(h.buckets, bucket*uintptr(t.BucketSize)))
	bOrig := b  // 保存原始桶，用于emptyRest优化时回溯
	top := tophash(hash)

search:
	// 遍历桶链表
	for ; b != nil; b = b.overflow(t) {
		// 遍历桶内8个槽位
		for i := uintptr(0); i &lt; abi.OldMapBucketCount; i++ {
			// tophash不匹配，快速跳过
			if b.tophash[i] != top {
				if b.tophash[i] == emptyRest {
					break search
				}
				continue
			}

			// 获取key并比较
			k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
			k2 := k
			if t.IndirectKey() {
				k2 = *((*unsafe.Pointer)(k2))
			}
			if !t.Key.Equal(key, k2) {
				continue
			}

			// 找到了，清理key（帮助GC）
			if t.IndirectKey() {
				*(*unsafe.Pointer)(k) = nil
			} else if t.Key.Pointers() {
				memclrHasPointers(k, t.Key.Size_)
			}

			// 清理value（帮助GC）
			e := add(unsafe.Pointer(b), dataOffset+abi.OldMapBucketCount*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
			if t.IndirectElem() {
				*(*unsafe.Pointer)(e) = nil
			} else if t.Elem.Pointers() {
				memclrHasPointers(e, t.Elem.Size_)
			} else {
				memclrNoHeapPointers(e, t.Elem.Size_)
			}

			// 标记为emptyOne
			b.tophash[i] = emptyOne

			// 优化：如果后面都是空的，将连续的emptyOne转为emptyRest
			// 这样后续查找遇到emptyRest可以立即终止
			if i == abi.OldMapBucketCount-1 {
				if b.overflow(t) != nil &amp;&amp; b.overflow(t).tophash[0] != emptyRest {
					goto notLast
				}
			} else {
				if b.tophash[i+1] != emptyRest {
					goto notLast
				}
			}

			// 向前回溯，将emptyOne改为emptyRest
			for {
				b.tophash[i] = emptyRest
				if i == 0 {
					if b == bOrig {
						break
					}
					// 跳到前一个桶的最后一个槽位
					c := b
					for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
					}
					i = abi.OldMapBucketCount - 1
				} else {
					i--
				}
				if b.tophash[i] != emptyOne {
					break
				}
			}

		notLast:
			h.count--
			// map变空时重置哈希种子，防止哈希碰撞攻击
			if h.count == 0 {
				h.hash0 = uint32(rand())
			}
			break search
		}
	}

	// 清除写标志
	if h.flags&amp;hashWriting == 0 {
		fatal(&quot;concurrent map writes&quot;)
	}
	h.flags &amp;^= hashWriting
}
</code></pre>
<p><strong>关键优化</strong>：</p>
<ul>
<li>删除后将 tophash 标记为 <code>emptyOne</code></li>
<li>如果后续都是空槽，优化为 <code>emptyRest</code>（加速查找）</li>
<li>map 为空时重置哈希种子（防止攻击）</li>
</ul>
<h2>4. 扩容机制</h2>
<h3>4.1 触发条件</h3>
<pre><code class="language-go">// overLoadFactor reports whether count items placed in 1&lt;&lt;B buckets is over loadFactor.
func overLoadFactor(count int, B uint8) bool {
	return count &gt; abi.OldMapBucketCount &amp;&amp; uintptr(count) &gt; loadFactorNum*(bucketShift(B)/loadFactorDen)
}

// tooManyOverflowBuckets reports whether noverflow buckets is too many for a map with 1&lt;&lt;B buckets.
// Note that most of these overflow buckets must be in sparse use;
// if use was dense, then we&#39;d have already triggered regular map growth.
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
	// If the threshold is too low, we do extraneous work.
	// If the threshold is too high, maps that grow and shrink can hold on to lots of unused memory.
	// &quot;too many&quot; means (approximately) as many overflow buckets as regular buckets.
	// See incrnoverflow for more details.
	if B &gt; 15 {
		B = 15
	}
	// The compiler doesn&#39;t see here that B &lt; 16; mask B to generate shorter shift code.
	return noverflow &gt;= uint16(1)&lt;&lt;(B&amp;15)
}
</code></pre>
<p>两种扩容情况：</p>
<ol>
<li><strong>负载因子过高</strong>：元素数量 &gt; 6.5 * 桶数量 → <strong>翻倍扩容</strong></li>
<li><strong>溢出桶过多</strong>：溢出桶数量 ≈ 主桶数量 → <strong>等量扩容</strong>（整理碎片）</li>
</ol>
<h3>4.2 扩容实现（hashGrow）</h3>
<pre><code class="language-Go">// hashGrow 初始化map扩容
// 实际的数据迁移由 growWork() 和 evacuate() 增量完成
func hashGrow(t *maptype, h *hmap) {
	// 决定扩容策略：
	// 1. 负载因子过高 → 翻倍扩容 (bigger=1, 容量x2)
	// 2. 溢出桶过多   → 等量扩容 (bigger=0, 容量不变，只整理碎片)
	bigger := uint8(1)
	if !overLoadFactor(h.count+1, h.B) {
		// 元素不多但溢出桶多 → 等量扩容
		bigger = 0
		h.flags |= sameSizeGrow
	}

	// 保存旧桶，分配新桶数组
	oldbuckets := h.buckets
	newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)

	// 更新迭代器标志：将当前迭代器标记转移到旧迭代器标记
	// 这样正在进行的迭代器知道需要检查oldbuckets
	flags := h.flags &amp;^ (iterator | oldIterator)
	if h.flags&amp;iterator != 0 {
		flags |= oldIterator
	}

	// 提交扩容（原子性操作，对GC可见）
	h.B += bigger           // 更新桶数量指数
	h.flags = flags         // 更新标志
	h.oldbuckets = oldbuckets  // 设置旧桶（触发渐进式迁移）
	h.buckets = newbuckets  // 设置新桶
	h.nevacuate = 0         // 重置迁移进度
	h.noverflow = 0         // 重置溢出桶计数

	// 处理溢出桶：将当前的溢出桶转移到旧溢出桶
	if h.extra != nil &amp;&amp; h.extra.overflow != nil {
		if h.extra.oldoverflow != nil {
			throw(&quot;oldoverflow is not nil&quot;)
		}
		h.extra.oldoverflow = h.extra.overflow
		h.extra.overflow = nil
	}

	// 设置预分配的溢出桶
	if nextOverflow != nil {
		if h.extra == nil {
			h.extra = new(mapextra)
		}
		h.extra.nextOverflow = nextOverflow
	}

	// 注意：这里只是初始化扩容，实际数据迁移是增量进行的
	// 每次写操作（insert/delete）时会调用 growWork() 迁移2个桶
}

func growWork(t *maptype, h *hmap, bucket uintptr) {
	evacuate(t, h, bucket&amp;h.oldbucketmask())
	if h.growing() {
		evacuate(t, h, h.nevacuate)
	}
}
</code></pre>
<ol>
<li>分配新桶数组（2 倍或相同大小）</li>
<li>保存旧桶到 <code>oldbuckets</code></li>
<li>不立即迁移数据，标记为&quot;正在扩容&quot;</li>
<li>后续每次写操作时增量迁移</li>
</ol>
<h3>4.3 渐进式迁移（evacuate）</h3>
<pre><code class="language-go">// evacuate 将指定的旧桶迁移到新桶
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
	// 定位要迁移的旧桶
	b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.BucketSize)))
	newbit := h.noldbuckets()  // 旧桶数量（用于计算哈希bit）

	if !evacuated(b) {
		// 准备迁移目标：x（低位）和 y（高位）
		var xy [2]evacDst
		x := &amp;xy[0]
		// x 目标：与旧桶索引相同的新桶位置
		x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.BucketSize)))
		x.k = add(unsafe.Pointer(x.b), dataOffset)
		x.e = add(x.k, abi.OldMapBucketCount*uintptr(t.KeySize))

		if !h.sameSizeGrow() {
			// 翻倍扩容：还需要准备 y 目标（新增的高位桶）
			// y 的位置 = oldbucket + 旧桶总数
			y := &amp;xy[1]
			y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.BucketSize)))
			y.k = add(unsafe.Pointer(y.b), dataOffset)
			y.e = add(y.k, abi.OldMapBucketCount*uintptr(t.KeySize))
		}
		// 等量扩容：只使用 x，所有元素留在原位

		// 遍历旧桶及其溢出链
		for ; b != nil; b = b.overflow(t) {
			k := add(unsafe.Pointer(b), dataOffset)
			e := add(k, abi.OldMapBucketCount*uintptr(t.KeySize))

			// 遍历桶内8个槽位
			for i := 0; i &lt; abi.OldMapBucketCount; i, k, e = i+1, add(k, uintptr(t.KeySize)), add(e, uintptr(t.ValueSize)) {
				top := b.tophash[i]

				// 空槽位：标记为已迁移的空槽
				if isEmpty(top) {
					b.tophash[i] = evacuatedEmpty
					continue
				}
				if top &lt; minTopHash {
					throw(&quot;bad map state&quot;)
				}

				// 获取key
				k2 := k
				if t.IndirectKey() {
					k2 = *((*unsafe.Pointer)(k2))
				}

				// 决定迁移到x还是y（仅翻倍扩容需要）
				var useY uint8
				if !h.sameSizeGrow() {
					// 重新计算哈希，根据新增的bit决定去向
					hash := t.Hasher(k2, uintptr(h.hash0))

					if h.flags&amp;iterator != 0 &amp;&amp; !t.ReflexiveKey() &amp;&amp; !t.Key.Equal(k2, k2) {
						// 特殊情况：NaN key（key != key）
						// 哈希值不可重现，使用tophash的最低bit决定
						// 这样可以保证迭代器看到的结果一致
						useY = top &amp; 1
						top = tophash(hash)
					} else {
						// 正常情况：检查新增的bit位
						// hash &amp; newbit == 0 → 去x（低位）
						// hash &amp; newbit != 0 → 去y（高位）
						if hash&amp;newbit != 0 {
							useY = 1
						}
					}
				}

				// 标记旧槽位已迁移（evacuatedX或evacuatedY）
				if evacuatedX+1 != evacuatedY || evacuatedX^1 != evacuatedY {
					throw(&quot;bad evacuatedN&quot;)
				}
				b.tophash[i] = evacuatedX + useY  // evacuatedX=2, evacuatedY=3

				// 选择目标桶
				dst := &amp;xy[useY]

				// 目标桶满了，分配新的溢出桶
				if dst.i == abi.OldMapBucketCount {
					dst.b = h.newoverflow(t, dst.b)
					dst.i = 0
					dst.k = add(unsafe.Pointer(dst.b), dataOffset)
					dst.e = add(dst.k, abi.OldMapBucketCount*uintptr(t.KeySize))
				}

				// 拷贝tophash
				dst.b.tophash[dst.i&amp;(abi.OldMapBucketCount-1)] = top

				// 拷贝key
				if t.IndirectKey() {
					*(*unsafe.Pointer)(dst.k) = k2  // 拷贝指针
				} else {
					typedmemmove(t.Key, dst.k, k)   // 拷贝值
				}

				// 拷贝value
				if t.IndirectElem() {
					*(*unsafe.Pointer)(dst.e) = *(*unsafe.Pointer)(e)  // 拷贝指针
				} else {
					typedmemmove(t.Elem, dst.e, e)  // 拷贝值
				}

				// 移动目标指针到下一个槽位
				dst.i++
				dst.k = add(dst.k, uintptr(t.KeySize))
				dst.e = add(dst.e, uintptr(t.ValueSize))
			}
		}

		// 清理旧桶的key/value，帮助GC（如果没有迭代器在使用）
		if h.flags&amp;oldIterator == 0 &amp;&amp; t.Bucket.Pointers() {
			b := add(h.oldbuckets, oldbucket*uintptr(t.BucketSize))
			// 保留tophash（用于标记迁移状态）
			ptr := add(b, dataOffset)
			n := uintptr(t.BucketSize) - dataOffset
			memclrHasPointers(ptr, n)
		}
	}

	// 如果刚好迁移的是nevacuate指向的桶，推进迁移进度
	if oldbucket == h.nevacuate {
		advanceEvacuationMark(h, t, newbit)
	}
}
</code></pre>
<p>迁移策略：</p>
<ul>
<li><strong>翻倍扩容</strong>：一个旧桶分裂到两个新桶（X 和 Y）<ul>
<li>根据哈希值的新比特位决定去 X 还是 Y</li>
</ul>
</li>
<li><strong>等量扩容</strong>：紧凑化，消除碎片</li>
<li>每次写操作迁移 2 个桶（当前桶 + nevacuate 桶）</li>
</ul>
<h3>4.4 总结图</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h54yxszqbej21ra0u0n3v.jpg" alt=""></p>
<h2>5. 迭代器实现</h2>
<pre><code class="language-go">// A hash iteration structure.
// If you modify hiter, also change cmd/compile/internal/reflectdata/reflect.go
// and reflect/value.go to match the layout of this structure.
type hiter struct {
	key         unsafe.Pointer // Must be in first position.  Write nil to indicate iteration end (see cmd/compile/internal/walk/range.go).
	elem        unsafe.Pointer // Must be in second position (see cmd/compile/internal/walk/range.go).
	t           *maptype
	h           *hmap
	buckets     unsafe.Pointer // bucket ptr at hash_iter initialization time
	bptr        *bmap          // current bucket
	overflow    *[]*bmap       // keeps overflow buckets of hmap.buckets alive
	oldoverflow *[]*bmap       // keeps overflow buckets of hmap.oldbuckets alive
	startBucket uintptr        // bucket iteration started at
	offset      uint8          // intra-bucket offset to start from during iteration (should be big enough to hold bucketCnt-1)
	wrapped     bool           // already wrapped around from end of bucket array to beginning
	B           uint8
	i           uint8
	bucket      uintptr
	checkBucket uintptr
	clearSeq    uint64
}
</code></pre>
<p><strong>关键特性</strong>：</p>
<ol>
<li><strong>随机起始位置</strong>：防止依赖迭代顺序</li>
<li><strong>快照机制</strong>：记录迭代开始时的 buckets 指针</li>
<li><strong>扩容兼容</strong>：同时检查新旧桶，确保不重复/遗漏</li>
</ol>
<h2>6. 负载因子选择</h2>
<pre><code class="language-go">// Picking loadFactor: too large and we have lots of overflow
// buckets, too small and we waste a lot of space. I wrote
// a simple program to check some stats for different loads:
// (64-bit, 8 byte keys and elems)
//  loadFactor    %overflow  bytes/entry     hitprobe    missprobe
//        4.00         2.13        20.77         3.00         4.00
//        4.50         4.05        17.30         3.25         4.50
//        5.00         6.85        14.77         3.50         5.00
//        5.50        10.55        12.94         3.75         5.50
//        6.00        15.27        11.67         4.00         6.00
//        6.50        20.90        10.79         4.25         6.50
//        7.00        27.14        10.15         4.50         7.00
//        7.50        34.03         9.73         4.75         7.50
//        8.00        41.10         9.40         5.00         8.00
//
// %overflow   = percentage of buckets which have an overflow bucket
// bytes/entry = overhead bytes used per key/elem pair
// hitprobe    = # of entries to check when looking up a present key
// missprobe   = # of entries to check when looking up an absent key
</code></pre>
<p><strong>Go 选择了 6.5 的负载因子</strong>（13/16 ≈ 0.8125）：</p>
<ul>
<li>在空间利用率和性能之间取得平衡</li>
<li>约 20% 的桶会有溢出桶</li>
</ul>
<h2>7. 设计亮点</h2>
<h3>7.1 <strong>分离存储优化</strong></h3>
<p>将 keys 和 values 分别连续存储，而不是交替存储 <code>key1, val1, key2, val2...</code>，这样可以：</p>
<ul>
<li>减少内存对齐造成的填充浪费</li>
<li>例如 <code>map[int64]int8</code>，交替存储需要大量填充</li>
</ul>
<h3>7.2 <strong>tophash 快速过滤</strong></h3>
<ul>
<li>先比较 8 位 tophash，不匹配直接跳过</li>
<li>只有 tophash 匹配才进行完整 key 比较</li>
<li>大幅减少昂贵的 key 比较次数</li>
</ul>
<h3>7.3 <strong>渐进式扩容</strong></h3>
<ul>
<li>避免一次性迁移造成的延迟峰值</li>
<li>分摊到后续的每次写操作</li>
<li>适合实时系统</li>
</ul>
<h3>7.4 <strong>等量扩容（整理碎片）</strong></h3>
<ul>
<li>频繁增删导致溢出桶过多时触发</li>
<li>保持桶数量不变，重新排列元素</li>
<li>消除内存碎片，提升性能</li>
</ul>
<h3>7.5 <strong>并发安全检测</strong></h3>
<ul>
<li>使用 <code>hashWriting</code> 标志检测并发读写</li>
<li>虽然不提供内置锁，但能快速检测到竞态条件</li>
<li>帮助开发者发现 bug</li>
</ul>
<h2>8. 并发</h2>
<h3>8.1 问题</h3>
<p>前面分析 map 的访问的时候，我们已经知道 map 明确严禁并发读写。比如：</p>
<ul>
<li>一个协程在读 map，另一个协程在驱逐，就可能出现问题。</li>
</ul>
<p>所以如果我们非要在并发情况下使用 map 的话，就需要用 mutex 加锁了，但是这样 map 的性能非常差。</p>
<h3>8.2 解决 —— sync.Map</h3>
<h4>8.2.1 底层</h4>
<ul>
<li><p>Map</p>
<pre><code class="language-go">type Map struct {
    mu Mutex                            // 锁
    read atomic.Value         // 指向一个 readOnly 结构体的值
    dirty map[any]*entry    // 指向一个 map
    misses int                        // 没有命中的个数，即在 read 中读不到的次数
}
</code></pre>
</li>
<li><p>readOnly</p>
<pre><code class="language-go">// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
    m       map[any]*entry    // 存储 map 数据
    amended bool                        // 当 dirtyMap 中有 m 没有的元素的时候，amended 值为 true
}
</code></pre>
</li>
<li><p>entry</p>
<pre><code class="language-go">type entry struct {
    p unsafe.Pointer // 万能指针，指向 value
}
</code></pre>
</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h54yxq23ulj21r80ledi6.jpg" alt=""></p>
<h4>8.2.2 正常读写</h4>
<ul>
<li>走 read，读出 value 或者覆盖 value</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h54yxngurcj21pk0o2ju9.jpg" alt=""></p>
<h4>8.2.3 追加</h4>
<pre><code class="language-go">func (m *Map) Store(key, value interface{}) {
	// 1. 先尝试在 read map 中进行写
  read, _ := m.read.Load().(readOnly)
	if e, ok := read.m[key]; ok &amp;&amp; e.tryStore(&amp;value) {
		return
	}

  // 2. read 中没有对应的 key，那就只能追加了，上锁操作 dirty map
	m.mu.Lock()
  // 3. 再读一遍 read map，因为有可能在我们上锁之前的一瞬间，别的协程将 dirty 提升了
	read, _ = m.read.Load().(readOnly)
  // 4. read map 中有了，说明已经被其他协程 dirty 提升了，
	if e, ok := read.m[key]; ok {
    // 4-1. 判断读出来的 entry 是否已经被标记为 unexpunged(已删除)
		if e.unexpungeLocked() {
			// 4-2. 该 enrty 已被标记为删除，那么就需要将其放到 dirty 中
			m.dirty[key] = e
		}
    // 4-3 读出来
		e.storeLocked(&amp;value)
	} else if e, ok := m.dirty[key]; ok {
    // 5. read map 中还是没有，那就读 dirty map
		e.storeLocked(&amp;value)
	} else {
    // 6. dirty map 中还是没有，那就只能往 dirty map 中追加了
		if !read.amended {
			m.dirtyLocked()
			m.read.Store(readOnly{m: read.m, amended: true})
		}
		m.dirty[key] = newEntry(value)
	}
  // 7. 追加完，解锁
	m.mu.Unlock()
}

// unexpungeLocked 可确保 entry 未标记为已清除。
//
// 如果该 entry 已经被标记为删除了，则必须在解锁 m.mu 之前将其添加到 dirty map 中。
func (e *entry) unexpungeLocked() (wasExpunged bool) {
	return atomic.CompareAndSwapPointer(&amp;e.p, expunged, nil)
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h54yxmh3pyj21xy0rwgr2.jpg" alt=""></p>
<h4>8.2.4 追加后的读</h4>
<pre><code class="language-go">func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
  // 1. 先在 read 中找
	read, _ := m.read.Load().(readOnly)
	e, ok := read.m[key]
  // 2. read 中找不到，就在 dirty 中找
	if !ok &amp;&amp; read.amended {
    // 4. 上锁
		m.mu.Lock()
    // 5. 再读一次 read map，因为有可能在我们上锁之前的一瞬间，别的协程将 dirty 提升了
		read, _ = m.read.Load().(readOnly)
		e, ok = read.m[key]
    // 6. 还是没在 read 中找到，就只能在 dirty 中找了
		if !ok &amp;&amp; read.amended {
			e, ok = m.dirty[key]
      // 7. misses ++ 并判断是否需要 dirty 提升
			m.missLocked()
		}
    // 8. 解锁
		m.mu.Unlock()
	}
	if !ok {
		return nil, false
	}
	return e.load()
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h550i4oryqj21ts0n043n.jpg" alt=""></p>
<h4>8.2.5 dirty 提升</h4>
<p>当 <code>meisses = len(dirty)</code> 的时候，就砍掉 read，将 dirty 提升到 read 的位置。</p>
<pre><code class="language-go">func (m *Map) missLocked() {
  // 1. 每在 dirty 中查一次，就 misses++
	m.misses++
	if m.misses &lt; len(m.dirty) {
		return
	}
  // 2. 当 misses = len(m.dirty) 的时候，就 dirty 提升
  // 3. 将 dirtymap 赋值给 read map
  //     这里没有指出 amended，但是因为默认零值是 false，所以这里也将 amended 置为 false 了
	m.read.Store(readOnly{m: m.dirty})
  // 4. dirty 先为 nil，当要追加的时候，再来复制 read map
	m.dirty = nil
  // 5. 重置 misses
	m.misses = 0
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h550aoso7oj21u40kswhd.jpg" alt=""></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5511ernl4j21ji0f40uv.jpg" alt=""></p>
<pre><code class="language-go">func (m *Map) Store(key, value interface{}) {
	...
	if e, ok := read.m[key]; ok {
		...
	} else if e, ok := m.dirty[key]; ok {
		...
	} else {
    // 第一次追加到 dirty map 的时候，需要判断看 dirty map 之前是否已经被提升了，可能为 nil
    // 如果是 nil 的话，就需要复制 read map
		if !read.amended {
			m.dirtyLocked()
			m.read.Store(readOnly{m: read.m, amended: true})
		}
    // 追加 key
		m.dirty[key] = newEntry(value)
	}
	m.mu.Unlock()
}

// 当 dirty map 为 nil 的时候
// 负责将 read map 复制到 dirty map
func (m *Map) dirtyLocked() {
	if m.dirty != nil {
		return
	}

	read, _ := m.read.Load().(readOnly)
	m.dirty = make(map[interface{}]*entry, len(read.m))
	for k, e := range read.m {
		if !e.tryExpungeLocked() {
			m.dirty[k] = e
		}
	}
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h559h4hywmj21iq0kmn01.jpg" alt=""></p>
<h4>8.2.6 删除</h4>
<ul>
<li><p>正常删除</p>
<pre><code class="language-go">// Delete deletes the value for a key.
func (m *Map) Delete(key interface{}) {
    m.LoadAndDelete(key)
}

// LoadAndDelete deletes the value for a key, returning the previous value if any.
// The loaded result reports whether the key was present.
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) {
    read, _ := m.read.Load().(readOnly)
  // 1. 先从 read map 中找
    e, ok := read.m[key]
    ...
  // 2. 找到了，就直接在 read map 中删除
    if ok {
        return e.delete()
    }
    return nil, false
}

func (e *entry) delete() (value interface{}, ok bool) {
    for {
        p := atomic.LoadPointer(&amp;e.p)
        if p == nil || p == expunged {
            return nil, false
        }
    // 3. 直接将 *entry 的 Pointer 置为空
        if atomic.CompareAndSwapPointer(&amp;e.p, p, nil) {
            return *(*interface{})(p), true
        }
    }
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h559grevtmj21jy0m0q6e.jpg" alt=""></p>
</li>
<li><p>追加后删除</p>
<pre><code class="language-go">func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) {
  // 1. 先在 read map 中找
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    if !ok &amp;&amp; read.amended {
    // 2. 找不到，就上锁，然后在 dirty map 中找
        m.mu.Lock()
    // 3. 找的时候一样，还是再次在 read map 中查一遍，防止有 dirty 提升
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok &amp;&amp; read.amended {
      // 4. read map 中还是没找到，那就在 dirty map 中找，删的时候，还是 *entry.Pointer = nil
            e, ok = m.dirty[key]
            delete(m.dirty, key)
      // 5. misses ++
            m.missLocked()
        }
        m.mu.Unlock()
    }
    if ok {
        return e.delete()
    }
    return nil, false
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h559c3ywbuj21kk0n2430.jpg" alt=""></p>
</li>
<li><p>删除后提升 dirty</p>
<pre><code class="language-go">func (m *Map) dirtyLocked() {
    if m.dirty != nil {
        return
    }

    read, _ := m.read.Load().(readOnly)
    m.dirty = make(map[interface{}]*entry, len(read.m))
    for k, e := range read.m {
    // 不复制标记为 expunged 的
        if !e.tryExpungeLocked() {
            m.dirty[k] = e
        }
    }
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h559gbgjkqj21ig0fw40y.jpg" alt=""></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h559irrt5kj21jy0iwgoj.jpg" alt=""></p>
</li>
</ul>
<h3>8.3 总结</h3>
<ul>
<li>sync.Map 使用了两个 map，将“普通读写”和“追加”进行分离；</li>
<li>不会引发扩容的操作（查、改）使用 read map；</li>
<li>可能引起扩容的操作（增）使用 dirty map；</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Go 底层原理丨slice 从第一性原理到实现细节</title>
      <link>https://hedon.top/blog/go-slice/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-slice/</guid>
      <pubDate>Sun, 16 Nov 2025 13:00:00 GMT</pubDate>
      <description>本文从第一性原理出发，深入探讨 slice 的实现细节，包括 slice 的底层结构、实现原理、使用场景等。</description>
      <category>Go</category>
      <content:encoded><![CDATA[<p>本篇将从第一性原理出发，全面解析 Go 的 slice 设计哲学和底层实现。</p>
<p>特此声明，本篇是笔者与 Google Gemini 3Pro 共创所作，非常庆幸在当今 AI 时代下获取知识已是如此便利，且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑，均由笔者把控和审阅，欢迎放心阅读。</p>
<h2>1. 本质</h2>
<p>在计算机内存中，最基础的数据结构是<strong>连续内存块</strong>（数组）。但数组有一个致命缺陷：</p>
<ul>
<li><strong>固定大小</strong>：编译时确定，无法动态增长</li>
<li><strong>值语义</strong>：传递时整体拷贝，效率低下</li>
<li><strong>缺乏元信息</strong>：只有指针，不知道长度和容量</li>
</ul>
<p>Slice 本质上是对数组的<strong>引用封装</strong>，它解决了上述三个问题：</p>
<pre><code class="language-Go">// runtime/slice.go

type slice struct {
	array unsafe.Pointer  // 指向底层数组的指针
	len   int             // 当前长度（已使用元素数量）
	cap   int             // 容量（底层数组的总大小）
}
</code></pre>
<ul>
<li><code>array</code>：指向真正的数据存储位置（底层数组）</li>
<li><code>len</code>：定义了 slice 的<strong>可见范围</strong>（0 到 len-1 可访问）</li>
<li><code>cap</code>：定义了 slice 的<strong>扩展能力</strong>（len 到 cap-1 可通过 reslice 访问）</li>
</ul>
<p>所以说 Slice 不是容器，而是<strong>视图</strong>（View）+ <strong>元数据</strong>（Metadata）的组合。</p>
<h2>2. 创建</h2>
<h3>2.1 makeslice</h3>
<pre><code class="language-go">func main() {
	s := make([]int, 3)
	fmt.Println(s)
}
</code></pre>
<p>通过下述命令，可以查看 Plan9 汇编代码，你会发现 <code>make</code> 一个 slice 底层调用的就是 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/slice.go#L101">makeslice</a> 函数：</p>
<pre><code class="language-shell">go build -gcflags -S main.go
</code></pre>
<p>输出如下：</p>
<pre><code class="language-assembly">LEAQ    type.int(SB), AX
MOVL    $3, BX
MOVQ    BX, CX
PCDATA  $1, $0
CALL    runtime.makeslice(SB) 		#直接调用 makeslice 方法
</code></pre>
<p><code>makeslice</code> 的实现如下：</p>
<pre><code class="language-go">func makeslice(et *_type, len, cap int) unsafe.Pointer {
	mem, overflow := math.MulUintptr(et.Size_, uintptr(cap))
	if overflow || mem &gt; maxAlloc || len &lt; 0 || len &gt; cap {
		mem, overflow := math.MulUintptr(et.Size_, uintptr(len))
		if overflow || mem &gt; maxAlloc || len &lt; 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

	return mallocgc(mem, et, true)
}
</code></pre>
<ol>
<li><p><strong>安全检查优先</strong>：</p>
<ul>
<li>检查整数溢出（<code>overflow</code>）</li>
<li>检查内存限制（<code>mem &gt; maxAlloc</code>）</li>
<li>检查参数合法性（<code>len &lt; 0 || len &gt; cap</code>）</li>
</ul>
</li>
<li><p><strong>按容量分配内存</strong>：<code>mem = et.Size_ × cap</code></p>
<ul>
<li>分配的是 <code>cap</code> 而非 <code>len</code> 的内存</li>
<li>这为后续增长预留了空间，避免频繁重新分配</li>
</ul>
</li>
<li><p><strong>调用 mallocgc</strong>：Go 的核心内存分配器</p>
<ul>
<li>与 GC 集成，支持垃圾回收</li>
<li>第三个参数 <code>true</code> 表示需要初始化为零值</li>
</ul>
</li>
</ol>
<h2>3. 扩容</h2>
<h3>3.1 扩容触发时机</h3>
<p>当 <code>append</code> 导致 <code>len &gt; cap</code> 时，触发 <a href="https://github.com/golang/go/blob/release-branch.go1.25/src/runtime/slice.go#L177">growslice</a> 函数。<code>groupslice</code> 可以概括为 6 步：</p>
<ol>
<li><p>参数验证：检查新长度是否合法，零大小类型直接返回特殊值。</p>
</li>
<li><p>计算新容量：调用 <code>nextslicecap</code>：容量小于 256 时翻倍，大于等于 256 时约 1.25 倍增长。</p>
</li>
<li><p>计算内存大小：根据元素大小选择优化路径：1 字节无需乘法，指针大小用位移，2 的幂用位移，其他用乘法。</p>
</li>
<li><p>内存对齐：用 <code>roundupsize</code> 向上取整到分配器的标准大小，充分利用空间，同时检查溢出和内存上限。</p>
</li>
<li><p>分配新内存：非指针类型告诉 GC 不扫描；指针类型需要 GC 扫描和写屏障。只清零 <code>[newLen:newCap)</code> 区间。</p>
</li>
<li><p>复制数据：用 <code>memmove</code> 复制旧元素到新内存，返回新 slice（新指针、新长度、新容量）。</p>
</li>
</ol>
<h3>3.2 容量增长算法</h3>
<pre><code class="language-go">func nextslicecap(newLen, oldCap int) int {
	newcap := oldCap

  // 如果新长度超过两倍旧容量，直接使用新长度
	doublecap := newcap + newcap
	if newLen &gt; doublecap {
		return newLen
	}

  // 小于 256，双倍增长
	const threshold = 256
	if oldCap &lt; threshold {
		return doublecap
	}

  // 大于 256
	for {
    // 逐步从 2 倍扩容缩小到 1.25 倍扩容
		newcap += (newcap + 3*threshold) &gt;&gt; 2
		if uint(newcap) &gt;= uint(newLen) {
			break
		}
	}

	if newcap &lt;= 0 {
		return newLen
	}
	return newcap
}
</code></pre>
<ol>
<li><p><strong>特殊情况</strong>：如果新长度超过两倍旧容量，直接使用新长度</p>
<ul>
<li>避免多次扩容</li>
</ul>
</li>
<li><p><strong>小切片（cap &lt; 256）</strong>：双倍增长</p>
<ul>
<li>快速增长，减少小数据量的多次分配</li>
<li>时间复杂度：单次 append 的摊销成本为 O(1)</li>
</ul>
</li>
<li><p><strong>大切片（cap ≥ 256）</strong>：从 2 倍逐步降到 1.25 倍增长</p>
<ul>
<li><code>newcap += (newcap + 3×256) / 4</code></li>
<li>平衡了增长速度和内存浪费</li>
<li>避免大切片浪费过多内存</li>
</ul>
</li>
</ol>
<h3>3.3 内存对齐优化</h3>
<pre><code class="language-go">	switch {
	case et.Size_ == 1:
		lenmem = uintptr(oldLen)
		newlenmem = uintptr(newLen)
		capmem = roundupsize(uintptr(newcap), noscan)
		overflow = uintptr(newcap) &gt; maxAlloc
		newcap = int(capmem)
	case et.Size_ == goarch.PtrSize:
		lenmem = uintptr(oldLen) * goarch.PtrSize
		newlenmem = uintptr(newLen) * goarch.PtrSize
		capmem = roundupsize(uintptr(newcap)*goarch.PtrSize, noscan)
		overflow = uintptr(newcap) &gt; maxAlloc/goarch.PtrSize
		newcap = int(capmem / goarch.PtrSize)
	case isPowerOfTwo(et.Size_):
		var shift uintptr
		if goarch.PtrSize == 8 {
			// Mask shift for better code generation.
			shift = uintptr(sys.TrailingZeros64(uint64(et.Size_))) &amp; 63
		} else {
			shift = uintptr(sys.TrailingZeros32(uint32(et.Size_))) &amp; 31
		}
		lenmem = uintptr(oldLen) &lt;&lt; shift
		newlenmem = uintptr(newLen) &lt;&lt; shift
		capmem = roundupsize(uintptr(newcap)&lt;&lt;shift, noscan)
		overflow = uintptr(newcap) &gt; (maxAlloc &gt;&gt; shift)
		newcap = int(capmem &gt;&gt; shift)
		capmem = uintptr(newcap) &lt;&lt; shift
	default:
		lenmem = uintptr(oldLen) * et.Size_
		newlenmem = uintptr(newLen) * et.Size_
		capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
		capmem = roundupsize(capmem, noscan)
		newcap = int(capmem / et.Size_)
		capmem = uintptr(newcap) * et.Size_
	}
</code></pre>
<ol>
<li><p><strong>特化常见情况</strong>：</p>
<ul>
<li><code>Size == 1</code>（byte/uint8）：直接计算，无需乘法</li>
<li><code>Size == PtrSize</code>（指针大小）：编译器可优化为位移</li>
<li><code>Size</code> 为 2 的幂：用位移替代乘除法</li>
</ul>
</li>
<li><p><strong>roundupsize 函数</strong>：</p>
<ul>
<li>将内存大小向上舍入到分配器的 size class</li>
<li>利用内存分配器的固定大小类别，避免内存碎片</li>
<li><strong>结果</strong>：实际分配的容量可能大于计算的容量</li>
</ul>
</li>
</ol>
<h3>3.4 数据复制与 GC 协作</h3>
<pre><code class="language-go">	var p unsafe.Pointer
	if !et.Pointers() {
		p = mallocgc(capmem, nil, false)
		// The append() that calls growslice is going to overwrite from oldLen to newLen.
		// Only clear the part that will not be overwritten.
		// The reflect_growslice() that calls growslice will manually clear
		// the region not cleared here.
		memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
	} else {
		// Note: can&#39;t use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory.
		p = mallocgc(capmem, et, true)
		if lenmem &gt; 0 &amp;&amp; writeBarrier.enabled {
			// Only shade the pointers in oldPtr since we know the destination slice p
			// only contains nil pointers because it has been cleared during alloc.
			//
			// It&#39;s safe to pass a type to this function as an optimization because
			// from and to only ever refer to memory representing whole values of
			// type et. See the comment on bulkBarrierPreWrite.
			bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(oldPtr), lenmem-et.Size_+et.PtrBytes, et)
		}
	}
	memmove(p, oldPtr, lenmem)

	return slice{p, newLen, newcap}
</code></pre>
<p><strong>GC 协作的设计</strong>：</p>
<ol>
<li><p><strong>区分指针和非指针类型</strong>：</p>
<ul>
<li>非指针类型：<code>mallocgc(..., nil, false)</code> - 不需要 GC 扫描</li>
<li>指针类型：<code>mallocgc(..., et, true)</code> - 需要 GC 扫描和 write barrier</li>
</ul>
</li>
<li><p><strong>Write Barrier</strong>：</p>
<ul>
<li>在并发 GC 时，保证指针写入的可见性</li>
<li>使用 <code>bulkBarrierPreWriteSrcOnly</code> 批量处理，提高效率</li>
</ul>
</li>
<li><p><strong>内存清零策略</strong>：</p>
<ul>
<li>只清除 <code>[newLen, newCap)</code> 区间，节省时间</li>
<li><code>[oldLen, newLen)</code> 由 append 覆盖，无需清零</li>
</ul>
</li>
</ol>
<h2>4. 共享机制</h2>
<h3>4.1 Reslicing 原理</h3>
<pre><code class="language-go">s := []int{0, 1, 2, 3, 4, 5}
s1 := s[1:4]  // len=3, cap=5, 共享同一底层数组
s2 := s[2:5]  // len=3, cap=4, 也共享
</code></pre>
<p><strong>底层实现</strong>：</p>
<ul>
<li>三个 slice 的 <code>array</code> 指针指向同一块内存的不同偏移</li>
<li><code>s1.array = s.array + 1×sizeof(int)</code></li>
<li><code>s2.array = s.array + 2×sizeof(int)</code></li>
</ul>
<p><strong>设计权衡</strong>：</p>
<ul>
<li><strong>优势</strong>：零拷贝，高效的子序列操作</li>
<li><strong>风险</strong>：修改一个 slice 会影响其他共享底层数组的 slice</li>
<li><strong>哲学</strong>：Go 选择效率优先，由程序员管理共享语义</li>
</ul>
<h3>4.2 特殊情况：元素大小为 0</h3>
<pre><code class="language-go">	if et.Size_ == 0 {
		// append should not create a slice with nil pointer but non-zero len.
		// We assume that append doesn&#39;t need to preserve oldPtr in this case.
		return slice{unsafe.Pointer(&amp;zerobase), newLen, newLen}
	}
</code></pre>
<p>对于 <code>struct{}</code>、<code>[0]int</code> 等零大小类型：</p>
<ul>
<li>所有实例共享同一个地址 <code>&amp;zerobase</code></li>
<li>不分配任何实际内存</li>
<li>len 和 cap 的语义依然保持</li>
</ul>
<h2>5. 实践</h2>
<h3>5.1 预分配的重要性</h3>
<pre><code class="language-go">// 差：多次扩容
var s []int
for i := 0; i &lt; 10000; i++ {
    s = append(s, i)
}

// 好：一次分配
s := make([]int, 0, 10000)
for i := 0; i &lt; 10000; i++ {
    s = append(s, i)
}
</code></pre>
<h3>5.2 注意共享陷阱</h3>
<pre><code class="language-go">func process(s []int) {
    s = append(s, 999)  // 可能扩容，不影响原 slice
    s[0] = 100          // 可能影响原 slice（如果未扩容）
}
</code></pre>
<h3>5.3 大 Slice 的子切片内存泄漏</h3>
<pre><code class="language-go">// 问题：持有整个底层数组
func leak() []byte {
    data := make([]byte, 1&lt;&lt;20)  // 1MB
    return data[:100]             // 只用 100 字节，但持有 1MB
}

// 解决：拷贝所需数据
func noLeak() []byte {
    data := make([]byte, 1&lt;&lt;20)
    result := make([]byte, 100)
    copy(result, data[:100])
    return result
}
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>三年工作复盘丨技术篇：软件工程是什么丨（一）管理复杂度</title>
      <link>https://hedon.top/blog/first-job-review-01-tech-01-manager-complexity/</link>
      <guid isPermaLink="true">https://hedon.top/blog/first-job-review-01-tech-01-manager-complexity/</guid>
      <pubDate>Fri, 14 Nov 2025 21:00:00 GMT</pubDate>
      <description>三年工作复盘丨技术篇：软件工程是什么丨（一）管理复杂度</description>
      <category>三年工作复盘</category><category>技术篇</category><category>软件工程</category><category>管理复杂度</category>
      <content:encoded><![CDATA[<p>我觉得可以用<strong>道法术器</strong>来对复杂度管理进行一个重点概述：</p>
<ul>
<li><strong>道（目标）</strong>：管理复杂度</li>
<li><strong>法（基石）</strong>：抽象、分治、分层、模块化</li>
<li><strong>术（方法）</strong>：SOLID 原则、设计模式、架构模式、领域驱动设计（DDD）</li>
<li><strong>器（工具）</strong>：单元测试、可观测性</li>
</ul>
<h2>道：管理复杂度是我们的终极目标</h2>
<p>&quot;道&quot;是我们的终极目标，是我们实施软件工程一切的 WHY，</p>
<p>在三年的工作经历中，我对&quot;屎山&quot;的理解太深刻了。我亲手维护了大量前人留下的屎山代码，不做分层设计、模块划分不恰当、全局变量到处飞、命名随便起、概念不明晰。我也亲眼见证我由我经手的代码是如何一步步变成屎山的，需求的随意修改、为了应付 deadline 而习惯成自然的&quot;龙卷风战术&quot;、迭代时对现有字段的概念胡乱扩充、解决问题不处理根源而为了炫技在外面包装一层，金玉其外败絮其中。</p>
<p>这些技术债，使得代码阅读难度飙升，功能迭代负担巨大，重构风险难以估量，对新人很不友好。随着破窗效应的不断扩大，为了快速应付哪些莫须有的 deadline 和&quot;紧急&quot;需求，领导们和底层员工都习惯于采取&quot;龙卷风战术&quot;来快速完全需求，加剧了恶性循环。截止到我离职之前，这些技术债已经对业务发展的技术支持度、研发效率和产品质量造成了严重影响了。</p>
<p>我也试图做过一些努力，亲自全力推进了<strong>代码质量建设</strong>和<strong>服务监控建设</strong>两大专项，对于我个人来说改变是巨大的，我从工程认知、编码思维、业务理解等多方面都有巨大的突破。坦白说，从另一个层面来说，我庆幸过这些&quot;屎山&quot;的存在，我也很庆幸自己在职业初期就打下了坚实的基础，也认定了要成为一位优秀的软件工程师的目标。只不过，在历史长河中，我这两大专项对于团队的影响，却是杯水车薪，聊胜于无罢了。</p>
<p>我一直在思考，为什么？为什么复杂度就像&quot;熵增&quot;一样不可避免？我们程序员的宿命，难道就是不断地在屎山上雕花吗？若将来我有机会成为一位领导者，我如何避免上述问题的发生？</p>
<p>Fred Brooks 在《人月神话》中早已断言：软件的困难，在于其<strong>固有的复杂度 (Essential Complexity)</strong>。</p>
<ul>
<li><strong>复杂度不是难</strong>：不是指&quot;这个算法很难&quot;，而是指&quot;<strong>系统中组件间依赖关系的数量</strong>&quot;。</li>
<li><strong>复杂度是非线性增长的</strong>：一个 100 个模块的系统，其潜在的&quot;依赖&quot;和&quot;状态组合&quot;是天文数字。当认知负荷超过人脑（或团队）的上限时，系统就失控了。</li>
<li><strong>复杂度是万恶之源</strong>：<ul>
<li>你修复一个 Bug，却引发了三个新 Bug？—— <strong>复杂度失控</strong>。</li>
<li>你无法安全地添加一个新功能？—— <strong>复杂度失控</strong>。</li>
<li>你不敢重构？—— <strong>复杂度失控</strong>。</li>
</ul>
</li>
</ul>
<p>所以我觉得不管是什么样的技术栈、设计原则、编程思维、架构模式，或是那么多的软件工程管理方法论，比如敏捷开发、极限编程，或是现在的终极大杀器领域驱动设计，都是为了管理复杂度。因此，本篇后续的所有内容都是为了服务于&quot;<strong>管理复杂度</strong>&quot;这唯一且根本的道。</p>
<h2>法：管理复杂度的四大核心原则</h2>
<p>既然我们无法消灭复杂度，我们就只能<strong>管理</strong>它。在众多编程思想、设计模式、架构模式中，我觉得其中最最最根本、生命力最最持久、最有可能以不变应万变的是以下 4 点：</p>
<ul>
<li><strong>抽象</strong>：隐藏实现细节，只暴露意图契约。</li>
<li><strong>分治</strong>：将一个大问题，拆解为一堆可独立解决的小问题。</li>
<li><strong>分层</strong>：规定模块间的依赖关系，且依赖必须是单向的。</li>
<li><strong>模块化</strong>：高内聚 (High Cohesion)，低耦合 (Low Coupling)。</li>
</ul>
<h3>抽象</h3>
<h4>抽象的作用</h4>
<p>我发现！抽象这个词是真的抽象！我们经常在聊抽象，当发现原有代码不好迭代的时候，我们会说&quot;这个抽象得不够好&quot;，当看到代码比较混乱、重复较多时，我们会说&quot;这个有空可以抽象一下&quot;，当然有时候也会吐槽&quot;这个代码写得真抽象&quot;，或者&quot;这有点过度抽象了&quot;。</p>
<p>我时常想不明白当我们在谈抽象的时候，我们到底在说些什么？什么是抽象？怎么判断要不要抽象？怎么做抽象？要抽象的东西到底是什么？抽象到什么程度是恰当的？怎么评判一个抽象行为的好坏？如何避免过度抽象？如何在不断变化的业务需求中做一个稳定的抽象？</p>
<p>用一句话形容就是：<font color="orange"><u>我们经常在谈抽象，它在软件工程中无处不在，但又极其&quot;主观&quot;和&quot;暧昧&quot;。</u></font></p>
<p>为了更靠近上述问题的答案，或许我们应该退一步，回归它的第一性原理：<strong>它不是一种代码技巧，而是一种管理复杂度的核心战略。</strong></p>
<p>本篇我们在谈管理复杂度的问题，但是人脑的认知负荷是有限的（米勒定律说我们只能同时处理 7±2 个信息块）。一个拥有 100 个模块的系统，其潜在的依赖关系和状态组合是天文数字，远超人脑上限。</p>
<p>而抽象是我们对抗认知负荷的第一武器。既然我们没法同时处理那么多的信息块，那就想办法让自己只需要同时处理少数信息块。所以抽象的本质是就是<strong>信息隐藏</strong>。它将一个复杂系统，拆分为两部分：</p>
<ul>
<li>**契约或 API：**这是 <strong>What</strong>，即它能做什么。它是简单的、稳定的、易于理解的。</li>
<li><strong>实现：</strong> 这是 <strong>How</strong>，即它如何做的。它是复杂的、易变的、被隐藏的。</li>
</ul>
<p>因此，一个好的抽象，就是一套<strong>简单易懂的契约</strong>；而一个坏的抽象，就是一套<strong>让人猜不透的契约</strong>。</p>
<h4>抽象的难点</h4>
<p>在实际编码过程中，最常见的抽象行为就是定义接口。但是我们经常会发现很多接口的定义是毫无意义甚至是负作用的。我总结了过去 3 年工作中存在的关于接口定义问题最大的 3 个点：</p>
<ol>
<li><strong>毫无接口定义</strong>：起初在我们的 Web 服务中，没有任何的接口定义，甚至都只有两层架构，只能面向实现编程，各个模块耦合严重，写代码牵一发而动全身，在代码理解、模块划分、职责明晰、组件升级、代码复用、架构重构、单元测试、问题排查和业务迭代等各个方面都带来了层层阻力。</li>
<li><strong>单一实现大接口</strong>：在我们的老匹配服中，倒是定义了一些接口，但是这些接口都非常大，动辄三四十个方法，而且都只有一种实现。这种接口定义，除了给阅读代码带来多一层跳转的心智负担之外，毫无意义。</li>
<li><strong>接口繁多且职责不匹配</strong>：在我们的新匹配服中，倒是吸取了过往不少的教训，但是过犹不及。我们定义了一大堆接口，引入了一堆的设计模式和编码技巧，使得代码极其抽象，阅读难度很高，经常为了理清一个逻辑要跳转十几次，看了后面忘了前面。而且很多接口定义的方法和接口本身该有的职责是不匹配的，这带来了非常大的困扰。这种我统一称为炫技。比如所以外表虽然看起来牛逼，但实际上代码可读性极差。</li>
</ol>
<p>至今我依然觉得做好接口定义真是一件不容易的事情，而且想一次定义一个好的接口，也几乎是不现实的。不过至少现在我们可以得出一个结论：</p>
<blockquote>
<p>[!IMPORTANT]</p>
<p>抽象是有<strong>成本</strong>的：它增加了<strong>间接性</strong>，代码不再是平铺直叙的，需要多一次跳转，这本身也会增加认知负荷。</p>
<p>**如果收益 &lt; 成本，这就是过度抽象。**过度抽象的本质是：<strong>你为你&quot;猜想&quot;的、但&quot;永远不会发生&quot;的&quot;变化&quot;，提前支付了&quot;抽象的成本&quot;。</strong></p>
</blockquote>
<h4>抽象的本质</h4>
<p>现在需要回到一个最关键的问题，当我们在谈抽象的时候，我们究竟在&quot;抽&quot;什么？如果不知道&quot;抽&quot;什么，我们就会&quot;瞎抽&quot;。</p>
<blockquote>
<p>[!IMPORTANT]</p>
<p>答案是：<font color="red"><u>我们抽象的不是&quot;代码&quot;，我们抽象的是&quot;变化&quot;。</u></font>软件的宿命就是不断变化。而抽象的<strong>目的</strong>，就是<strong>隔离变化</strong>——把系统中<strong>易变的部分</strong>和<strong>不变的部分</strong>隔离开，在它们之间建立一道防火墙。</p>
</blockquote>
<p>关于变化，我觉得可以从 2 个方面进行思考：</p>
<ul>
<li><strong>技术抽象</strong>：是<strong>不变的业务</strong> vs <strong>易变的技术</strong>。</li>
<li><strong>业务抽象</strong>：是<strong>不变的业务本质</strong> vs <strong>易变的业务流程</strong>。</li>
</ul>
<h4>技术层面的抽象</h4>
<p>这里我想以业务逻辑层（Service）和持久化层（Repository）之间的交互来展开谈一谈。</p>
<p>比如说我们有一个订单服务 OrderService，这个时候很多的教学视频都会说，那我们要给持久化层定义一个 OrderRepository，这样后面我们不管是使用 MySQL、还是换成 Mongo、Oracle 都不会影响到 Service 层的逻辑。我个人觉得如果是以这样的目的去做的接口定义，离真正的抽象还是有不少距离的。事实上，在一个系统中，你几乎不会更换数据库的类型，因为它的影响面和风险实在太大了，即便有，频率也是极低的，为了一个极大概率不发生的&quot;变化&quot;提前支付了长时间的&quot;抽象成本&quot;，是不划算的。</p>
<p>那还有没有必要定义 Repository 接口呢？当然是有必要的，不过它的出发点应该是为了应付那些日常研发过程中经常会碰到的&quot;变化&quot;，比如：</p>
<ul>
<li><strong>为了可测试性</strong>：如果你不为 Repository 层定义接口，那你测试 Service 层的时候，就不得不连接到数据库，可测试性极差。</li>
<li><strong>为了不污染核心业务</strong>：数据库不常变，但是访问数据库的方式却是有可能变化的，Repository 可以为 Service 提供一个干净稳定的数据访问契约，屏蔽掉易变化的细节。</li>
<li><strong>为了可控的外部依赖</strong>：如果我们依赖的不是数据库，而是第三方服务，比如说短信 API 服务，那修改第三方服务的可能性也就大大提升了，不同厂商或是同一厂商的不同版本 API 所需要的参数、返回值都可能是不一样的。</li>
</ul>
<p>接下来我们举 3 个例子来分别阐述一下。</p>
<p>首先是为了可测试性而抽象，这是抽象在工程实践中<strong>最刚需、最不可辩驳</strong>的理由。假如说我们接口了一个 OrderService，它没有任何的抽象：</p>
<pre><code class="language-go">// 反例：没有抽象，&quot;业务逻辑&quot; 和 &quot;技术实现&quot; 焊死
type OrderService struct {
    // 没有接口，直接依赖 &quot;具体的&quot; 数据库连接
    db *gorm.DB
}

func (s *OrderService) CreateOrder(order *Order) error {
    // 1. 核心业务逻辑 (比如检查库存、计算价格)
    if order.Price &lt; 0 {
        return errors.New(&quot;价格错误&quot;)
    }

    // 2. 技术实现逻辑 (硬编码)
    // 业务逻辑和 GORM 的 API &quot;焊死&quot; 在一起
    if err := s.db.Create(order).Error; err != nil {
        return err
    }

    return nil
}
</code></pre>
<p>我们根本无法为 <code>CreateOrder</code> 方法写单元测试。你写的任何测试，都会<strong>真的</strong>去 <code>s.db.Create</code>，它会<strong>真的</strong>尝试连接 MySQL。这是一个集成测试，它慢、依赖环境、而且极其脆弱。你也无法单独测试 <code>if order.Price &lt; 0</code> 这行核心业务逻辑。</p>
<p>针对这种情况，我们做的抽象，就是要把那个易变的 <code>s.db</code> 从具体实现<strong>抽象</strong>为契约。</p>
<pre><code class="language-go">// 正例：抽象出 &quot;Repository&quot; 契约

// 1. 定义 &quot;契约&quot; (What)
type OrderRepository interface {
    Save(order *Order) error
}

// 2. &quot;不变&quot; 的业务逻辑
type OrderService struct {
    repo OrderRepository // &lt;-- 依赖 &quot;契约&quot;
}

func (s *OrderService) CreateOrder(order *Order) error {
    // 1. 核心业务逻辑 (100% 纯粹)
    if order.Price &lt; 0 {
        return errors.New(&quot;价格错误&quot;)
    }

    // 2. 调用 &quot;契约&quot;，不关心 &quot;实现&quot;
    return s.repo.Save(order)
}
</code></pre>
<p>这个时候我们的收益是 100% 可以兑现的，即 <code>OrderService</code> 现在<strong>100% 可被单元测试</strong>。</p>
<pre><code class="language-go">// order_service_test.go
func TestCreateOrder_PriceError(t *testing.T) {
    // 1. 准备一个 &quot;假的实现&quot; (Mock)
    mockRepo := new(MockOrderRepo)

    // 2. 注入 &quot;假的实现&quot;
    service := &amp;OrderService{repo: mockRepo}

    // 3. 100% 独立地测试 &quot;业务逻辑&quot;
    err := service.CreateOrder(&amp;Order{Price: -100})

    // 4. 断言
    assert.Error(t, err, &quot;价格错误&quot;)
    // (mockRepo 的 Save 方法根本不会被调用)
}
</code></pre>
<p>接下来是为了不污染核心业务而抽象，假如我们的 <code>OrderService</code> V1 运行良好。老板说：V1 太慢了，给创建订单加一层 Redis 缓存！</p>
<p>如果没有抽象，那你会被迫入侵 <code>OrderService</code> 的实现细节：</p>
<pre><code class="language-go">// 反例：业务逻辑被 &quot;基础设施&quot; 污染
type OrderService struct {
    db    *gorm.DB
    redis *redis.Client // &lt;-- 引入新的 &quot;实现&quot; 依赖
}

func (s *OrderService) CreateOrder(order *Order) error {
    if order.Price &lt; 0 {
        return errors.New(&quot;价格错误&quot;)
    }

    // &quot;业务逻辑&quot; 和 &quot;基础设施逻辑&quot; 像意大利面一样 &quot;耦合&quot;
    if err := s.db.Create(order).Error; err != nil {
        return err
    }

    // &quot;脏活累活&quot; 混了进来
    s.redis.Set(&quot;cache_key_for_orders&quot;, order)

    return nil
}
</code></pre>
<p>噩梦是什么：</p>
<ol>
<li><strong>职责混乱：</strong> <code>OrderService</code> 不再纯粹，它现在<strong>同时</strong>关心&quot;业务规则&quot;、&quot;MySQL 写入&quot;和&quot;Redis 缓存&quot;。</li>
<li><strong>测试灾难：</strong> 你的单元测试（如果有的话）现在<strong>又</strong>需要 Mock <code>redis.Client</code> 了。</li>
<li><strong>下一个噩梦：</strong> 下周老板说再加一个 Kafka 消息，通知履约’中台，你是不是要在这个函数里再加 <code>kafka.Producer</code>？</li>
</ol>
<p>我们的解决方案是 <code>OrderService</code> <strong>一行代码都不用改</strong>。它只认识 <code>OrderRepository</code> 这个契约。</p>
<pre><code class="language-go">// 正例：我们 &quot;实现&quot; 一个新的 &quot;How&quot;

// 1. 新的 &quot;实现&quot;，它 &quot;组合&quot; 了老的 &quot;实现&quot;
type CachedOrderRepo struct {
    nextRepo OrderRepository // &quot;下一层&quot; (e.g., MySQLRepo)
    redis    *redis.Client
}

// 2. CachedOrderRepo 同样实现了 &quot;契约&quot;
func (c *CachedOrderRepo) Save(order *Order) error {
    // &quot;脏活累活&quot; (基础设施逻辑) 被 &quot;封装&quot; 在这里

    // 1. 先调用 &quot;下一层&quot;
    if err := c.nextRepo.Save(order); err != nil {
        return err
    }
    // 2. 再处理缓存
    c.redis.Set(&quot;cache_key_for_orders&quot;, order)

    return nil
}
</code></pre>
<p>这里我们只需要初始化的时候，做出以下修改，OrderService 完全不用动，我们就可以享受到扩展时不污染核心业务的收益，这种收益，是时常会发生的。</p>
<pre><code class="language-go">// v1
repo := &amp;MySQLOrderRepo{db: db}

// v2
mysqlRepo := &amp;MySQLOrderRepo{db: db}
repo := &amp;CachedOrderRepo{nextRepo: mysqlRepo, redis: redisClient}
</code></pre>
<p>最后一个例子是为了可控的外部依赖而抽象。假如说我们有个用户注册服务，需要调用腾讯云短信 API 发送验证码。如果没有抽象，那就会在 <code>UserService</code> 中硬编码了腾讯云的 SDK。</p>
<pre><code class="language-go">// 反例：焊死 &quot;外部依赖&quot;
type UserService struct {
    db    *gorm.DB
    // 直接依赖 &quot;具体的&quot; SDK
    txSmsClient *tx_sms.Client
}

func (s *UserService) Register(phone string) error {
    // ...
    // &quot;业务&quot; 和 &quot;外部 SDK&quot; 焊死
    code := &quot;123456&quot;
    err := s.txSmsClient.Send(phone, code)
    // ...
}
</code></pre>
<p>噩梦是什么：</p>
<ul>
<li><strong>测试地狱：</strong> 你每跑一次 <code>Register</code> 的测试，就<strong>真的</strong>给手机发了一条短信！测试成本高昂，且依赖网络。</li>
<li><strong>SLA 绑架：</strong> 腾讯云短信 API 挂了（这<strong>经常</strong>发生），你的注册服务<strong>跟着一起挂</strong>。</li>
<li><strong>迁移灾难：</strong> 老板说腾讯云太贵，换成阿里云。你<strong>必须</strong>入侵 <code>UserService</code> 内部，把 <code>tx_sms.Client</code> 的所有 API 调用，<strong>逐行</strong>改成 <code>ali_sms.Client</code> 的 API。</li>
</ul>
<p>我们的解决方案是：定义一个你自己的<strong>防腐层</strong>。</p>
<pre><code class="language-go">// 正例：抽象 &quot;短信服务&quot; 契约

// 1. 定义 &quot;契约&quot; (What)
type SMSService interface {
    Send(phone, code string) error
}

// 2. &quot;业务&quot; 只依赖 &quot;契约&quot;
type UserService struct {
    db    *gorm.DB
    sms   SMSService // &lt;-- 依赖 &quot;契约&quot;
}

func (s *UserService) Register(phone string) error {
    // ...
    code := &quot;123456&quot;
    err := s.sms.Send(phone, code) // &lt;-- 调用 &quot;契约&quot;
    // ...
}

// 3. &quot;实现&quot; (How)
type TencentSMSService struct { ... }
func (t *TencentSMSService) Send(...) error { /* ... 腾讯 SDK ... */ }

type AliyunSMSService struct { ... }
func (a *AliyunSMSService) Send(...) error { /* ... 阿里 SDK ... */ }

// 重点：用于 &quot;测试&quot; 和 &quot;开发&quot; 的 &quot;实现&quot;
type LogSMSService struct { ... }
func (l *LogSMSService) Send(phone, code string) error {
    log.Printf(&quot;[Mock SMS] Send to %s, code: %s&quot;, phone, code)
    return nil
}
</code></pre>
<p>收益是什么：</p>
<ul>
<li><strong>可测试性：</strong> 单元测试时，你注入 <code>MockSMSService</code>。</li>
<li><strong>环境隔离：</strong> 开发/测试环境时，你注入 <code>LogSMSService</code>（它只打印日志不发短信）。</li>
<li><strong>可迁移性：</strong> 从腾讯换阿里，<code>UserService</code> <strong>一行不用改</strong>，你只需要在 <code>main.go</code> 替换实现类。</li>
<li><strong>健壮性：</strong> 你甚至可以实现一个 <code>FailoverSMSService</code> 高可用实现，它内部尝试先用腾讯、失败后自动降级到阿里。而 <code>UserService</code> <strong>毫不知情</strong>。</li>
</ul>
<p>这种收益在实际业务开发过程中，也是时常会发生的。</p>
<h4>业务层面的抽象</h4>
<p>前面提到的 3 个技术层面的例子，难度小、代价低、收益高、可复制性强，所以我觉得任何时候我们工程师都要尽力把这些方面做好。</p>
<p>但是业务层面的抽象就不一样了，我们工程师的噩梦，就是业务方（PM）每天都在改需求，我们被<strong>易变的流程</strong>牵着鼻子走，导致核心代码日益腐化。所以业务抽象的<strong>唯一目的</strong>，就是<strong>在易变的业务规则（流程）中，保护不变的业务本质</strong>。</p>
<p>这里我想引用《服务端开发·技术、方法与实用解决方案》一书中的一个例子，这也是我在 2024 年年中绩效总结时对前司部门提出的一个建议（虽然事实上并没起到什么作用）。书中提出了一个疑问：</p>
<blockquote>
<p>[!WARNING]</p>
<p>产品需求退化的根本原因是什么？</p>
<p> —— 是缺乏抽象</p>
</blockquote>
<p>通过抽象可以理清业务的核心问题并设计体系化的方案予以解决，而缺乏抽象则只能通过具体的、复杂的描述来反映事务的表面特征。</p>
<p>比如有以下需求：</p>
<blockquote>
<p>&quot;优惠立减&quot;活动上线后，在 App 主页，如果用户是在活动开始后首次进入，则弹出一个提示窗口，展示&quot;优惠立减&quot;活动信息，吸引用户参与；如果用户点击弹窗信息，则跳转进入到对应的活动页面，之后在 App 主页不再弹窗提示，避免打扰用户；如果用户不点击弹窗信息，则弹窗 5s 后自动关闭，之后用户若再进入 App 主页，则以每周弹窗 3 次的频率提醒用户，直到用户点击弹窗信息为止。</p>
</blockquote>
<p>如果我们完全按照这个需求方案来进行编码，那估计又是一个函数里面硬编码了很多的逻辑，那势必会在需求的每日变化中不断腐化。那这个业务的本质是什么呢：</p>
<blockquote>
<p>这是一个&quot;控制疲劳度&quot;（疲劳频次）的问题，即&quot;业务场景 S 对应 F 次/周期 Q&quot;。</p>
<ul>
<li>S：任意场景</li>
<li>F：整数</li>
<li>Q：时间单位， 天、周、月、年、终身等</li>
</ul>
</blockquote>
<p>不过我觉得，策划和运营团队，对于&quot;运营活动&quot;的模型理解跟技术团队是有区别的，技术团队面对的是具体到一个个细节、完整的需求，而在策划和运营团队那，可能有一套不一样的底层逻辑。技术团队要做到抽象，只能是在接触了多个明显相似的需求后，才有可能进行抽象提取，哪怕是这个时候，跟业务方的理解也可能有偏差。所以如果可以从业务方源头就做好抽象，那真是可以起到四两拨千斤的作用。</p>
<hr>
<p>接下来我们来看两个研发过程中最常见的业务痛点（变化点）：规则和流程。</p>
<p><strong>痛点一：If-Else 怪物 —— 业务规则的腐化</strong></p>
<p>现在有一个计算订单价格的服务 <code>OrderService.CalculatePrice()</code>，它经历了以下几个版本：</p>
<ul>
<li><strong>V1（上线）：</strong> 逻辑很简单：<code>price = product.Price * quantity</code></li>
<li><strong>V2（双十一）：</strong> PM 跑来说：加个双十一规则，所有商品打 8 折！<ul>
<li>你入侵了 <code>CalculatePrice</code>：<code>if (isDoubleEleven) { price = price * 0.8 }</code></li>
</ul>
</li>
<li><strong>V3（拉新）：</strong> PM 又来说：新用户第一单，再打 9 折！<ul>
<li>你再次入侵：<code>if (isNewUser) { price = price * 0.9 } else if (isDoubleEleven) { ... }</code></li>
</ul>
</li>
<li><strong>V4（VIP 会员）：</strong> PM：VIP 用户，折上再打 95 折！<ul>
<li>你：<code>if (isVIP) { ... } else if (isNewUser) { ... } else if ...</code></li>
</ul>
</li>
</ul>
<p><code>CalculatePrice</code> 方法变成了 500 行的 if-else 怪物。它腐化了。</p>
<ul>
<li><strong>认知负荷</strong>：没人（包括你自己）能说清一个价格到底是怎么算出来的。</li>
<li><strong>测试灾难</strong>：你需要 <code>2*2*2=8</code> 种，甚至更多的组合来测试所有规则。</li>
<li><strong>维护地狱</strong>：PM 让你去掉双十一，保留 VIP，你得小心翼翼地去<strong>修改</strong> <code>CalculatePrice</code> 这个函数，删多删少咱也就不好说了。</li>
</ul>
<p>我们的解决方案是：<strong>策略模式 (Strategy Pattern)</strong></p>
<ul>
<li><strong>不变的本质是什么？</strong> 订单价格需要被<strong>一系列规则</strong>所计算。</li>
<li><strong>易变的是什么？</strong> 规则本身（今天双十一，明天 618）。</li>
</ul>
<p>我们要抽象的，就是<strong>规则</strong>这个<strong>易变</strong>的东西。</p>
<pre><code class="language-Go">// 1. &quot;抽象&quot; 出 &quot;契约&quot;：一个 &quot;促销规则&quot; (What)
type PromotionPolicy interface {
    Apply(order *Order) *AppliedDiscount
}

// 2. &quot;不变的本质&quot; (OrderService)
// 它 &quot;不知道&quot; 任何具体规则，它只 &quot;认识&quot; 契约
type OrderService struct {
    // 它只 &quot;聚合&quot; 了一个 &quot;规则列表&quot;
    policies []PromotionPolicy
}

func (s *OrderService) CalculatePrice(order *Order) {
    // 业务核心：&quot;循环&quot; 应用所有规则
    for _, policy := range s.policies {
        discount := policy.Apply(order)
        order.ApplyDiscount(discount)
    }
    // ...
}

// 3. &quot;易变的实现&quot; (How)
// 每一个 &quot;规则&quot; 都是一个 &quot;独立的实现&quot;
type DoubleElevenPolicy struct {}
func (p *DoubleElevenPolicy) Apply(order *Order) *AppliedDiscount {
    if (isDoubleEleven) { /* ... 8折逻辑 ... */ }
    return ...
}

type NewUserPolicy struct {}
func (p *NewUserPolicy) Apply(order *Order) *AppliedDiscount {
    if (isNewUser) { /* ... 9折逻辑 ... */ }
    return ...
}

// ... VIPPolicy, SixEighteenPolicy ...
</code></pre>
<p>收益是什么：</p>
<ul>
<li><strong>腐化被阻止了：</strong> 你的 <code>OrderService</code> 不会再变了。它变得<strong>极其稳定、干净、且纯粹</strong>。</li>
<li><strong>开闭原则的实现：</strong><ul>
<li>PM 让你去掉双十一？你只需要在 <code>policies</code> 列表里，<strong>删除</strong> <code>DoubleElevenPolicy</code> 即可。<strong>核心业务代码 0 修改</strong>。</li>
<li>PM 让你新增 618？你只需要<strong>新建</strong>一个 <code>SixEighteenPolicy.go</code> 文件，然后加到列表里。<strong>核心业务代码 0 修改</strong>。</li>
</ul>
</li>
</ul>
<p>这就是业务抽象的第一个巨大价值：<strong>用组合 (Composition) 代替修改 (Modification)，隔离核心与规则。</strong></p>
<hr>
<p><strong>痛点二：上帝服务 —— 业务流程的膨胀</strong></p>
<p>还是 <code>OrderService</code>。</p>
<ul>
<li><strong>V1（上线）：</strong> <code>CreateOrder</code> 逻辑很简单：<code>repo.Save(order)</code>。</li>
<li><strong>V2（“通知”）：</strong> PM 跑来说：订单创建后，要给用户发个短信！<ul>
<li>你入侵了 <code>CreateOrder</code>：<code>repo.Save(order); sms.Send(...)</code></li>
</ul>
</li>
<li><strong>V3（加积分）：</strong> PM 又来说：发短信后，顺便给用户加个积分！<ul>
<li>你再次入侵：<code>...; sms.Send(...); loyalty.AddPoints(...)</code></li>
</ul>
</li>
<li><strong>V4（通知履约）：</strong> PM：加完积分，还要通知一下履约中台（WMS）！”<ul>
<li>你：<code>...; loyalty.AddPoints(...); wms.Notify(...)</code></li>
</ul>
</li>
</ul>
<p><code>CreateOrder</code> 方法变成了上帝方法。它什么都干。</p>
<ul>
<li><strong>职责膨胀：</strong> <code>OrderService</code> 不仅要管&quot;订单&quot;，它现在还被迫认识了&quot;短信&quot;、&quot;积分&quot;和&quot;履约&quot;。<strong>它高耦合了</strong>。</li>
<li><strong>事务地狱：</strong> &quot;积分&quot;挂了，<code>CreateOrder</code> 事务要不要回滚？&quot;短信&quot;超时了，要不要让用户多等 30 秒？</li>
<li><strong>测试灾难：</strong> 为了测试&quot;创建订单&quot;，你<strong>被迫</strong>要 Mock <code>sms</code>, <code>loyalty</code>, <code>wms</code> 三个外部依赖。</li>
</ul>
<p>我们的解决方案是：<strong>领域事件 (Domain Events)</strong></p>
<ul>
<li><strong>不变的本质是什么？</strong> <code>OrderService</code> 的<strong>核心职责</strong> 只有一个：<strong>创建订单</strong>（即，保证&quot;订单&quot;这个聚合根的状态一致性）。</li>
<li><strong>易变的是什么？</strong> 订单创建后引发的下游副作用（短信、积分、履约...）。</li>
</ul>
<p>我们要抽象的，就是<strong>副作用</strong>这个<strong>易变</strong>的东西。</p>
<pre><code class="language-Go">// 1. &quot;抽象&quot; 出 &quot;契约&quot;：一个 &quot;事件&quot; (What)
type OrderCreatedEvent struct {
    OrderID string
    UserID  string
    Time    time.Time
}

// 2. &quot;不变的本质&quot; (OrderService)
// 它 &quot;不认识&quot; 任何下游，它只 &quot;认识&quot; 事件
type OrderService struct {
    repo      OrderRepository
    publisher EventPublisher // &lt;-- 抽象的 &quot;事件发布器&quot;
}

func (s *OrderService) CreateOrder(order *Order) error {
    // 1. 核心职责：保证状态一致性
    if err := s.repo.Save(order); err != nil {
        return err
    }

    // 2. 核心职责：发布 &quot;已发生&quot; 的 &quot;事实&quot;
    event := &amp;OrderCreatedEvent{OrderID: order.ID, ...}

    // 3. &quot;异步&quot; 发布，与 &quot;下游&quot; 解耦
    // (它可以是 Kafka, 也可以是 RabbitMQ, 甚至是内存 channel)
    s.publisher.Publish(event)

    // CreateOrder 的 &quot;职责&quot; 到此 &quot;结束&quot;！
    return nil
}

// 3. &quot;易变的实现&quot; (How)
// 每一个 &quot;副作用&quot; 都是一个 &quot;独立的订阅者&quot;

// &quot;短信&quot; 服务 (一个独立的微服务，或独立的 goroutine)
type SMSSubscriber struct { ... }
func (s *SMSSubscriber) OnOrderCreated(event *OrderCreatedEvent) {
    // ... sms.Send(...)
}

// &quot;积分&quot; 服务
type LoyaltySubscriber struct { ... }
func (s *LoyaltySubscriber) OnOrderCreated(event *OrderCreatedEvent) {
    // ... loyalty.AddPoints(...)
}
</code></pre>
<p>收益是什么：</p>
<ul>
<li><strong>上帝服务被拆解了：</strong> <code>OrderService</code> 的职责被<strong>净化</strong>了。它回到了它不变的本质——只管&quot;订单&quot;。</li>
<li><strong>高内聚、低耦合的实现：</strong><ul>
<li>PM 让你<strong>去掉</strong>短信通知？你只需要<strong>下线</strong> <code>SMSSubscriber</code> 即可。<code>OrderService</code> <strong>毫不知情</strong>。</li>
<li>PM 让你<strong>新增</strong>财务对账通知？你只需要<strong>新建</strong>一个 <code>FinanceSubscriber</code> 即可。<code>OrderService</code> <strong>毫不知情</strong>。</li>
</ul>
</li>
</ul>
<hr>
<p>我们总结一下，技术抽象是在实现（How）层面做<strong>替换</strong>（<code>MockRepo</code> 替换 <code>MySQLRepo</code>）。而业务抽象是在逻辑（What）层面做<strong>组合</strong>和<strong>解耦</strong>，这里我给出了 2 个思路：</p>
<ul>
<li><strong>策略模式（应对规则）：</strong> 当 <code>if-else</code> 开始腐化你的<strong>核心算法</strong>时，把<strong>规则 (Rules)<strong>抽象成</strong>策略</strong>，用<strong>组合</strong>代替<strong>修改</strong>。</li>
<li><strong>领域事件（应对流程）：</strong> 当下游开始污染你的<strong>核心职责</strong>时，把<strong>副作用 (Side Effects)<strong>抽象成</strong>事件</strong>，用<strong>发布/订阅</strong>代替<strong>直接调用</strong>。</li>
</ul>
<h4>接口定义在哪</h4>
<p>这里我想再多谈一下接口定义在哪里的问题，这会涉及到一组概念：<strong>需求方接口</strong>和<strong>提供方接口</strong>。这也是我在阅读了《软件设计·从专业到卓越》一书后，觉得收获非常大的地方，从那之后，这组概念一直是指导我进行业务抽象和接口定义的核心思想武器。</p>
<p>前面我们提到了要为 <code>OrderService</code> 配一个 <code>OrderRepository</code> 接口，以实现可测试性、扩展时不污染业务和可控的外部依赖。这里我想提出一个问题：<font color="red"><code>OrderRepository</code> 是定义在 service 层还是定义在 repository 层？</font></p>
<p>答案是应该定义在 service 层！这可能会有一点反直觉！</p>
<p>如果定义在了 repository，那就说明 service 依赖了 repository，不管你依赖的是接口，还是具体的实现，都是依赖，在 Go 语言里面的体现就是你需要在 service package 中 import 关于 repository 的东西。</p>
<p>但是如果定义在 service 层，那 service package 中将不会存在任何关于 repository 的引用的，你只需要在依赖注入的时候去 repository 层找到能实现 service 要求的接口实现即可，这个时候反而是 repository 依赖了 service（的要求），也就是所谓的<strong>依赖倒置原则 (DIP)</strong>！</p>
<p>那为什么要这样呢？我们前面提到了接口抽象就是定义契约，那这个契约由谁来定呢？应该由需求方来定义，因为只有需求方，才知道自己需要什么东西。<code>OrderService</code> 需要一个 <code>Save(order)</code> 的方法。它不需要（也不应该）关心 <code>MySQLOrderRepo</code> 还提供了（或被迫实现了）其他 10 个它用不到的方法（比如 <code>GetConnectionPoolStats</code>）。</p>
<p>关于需求方接口和提供方接口的更进一步阐述，感兴趣的读者可以阅读我之前整理的笔记：<a href="https://www.notion.so/vs-f7b7f03169f14ce39c1b1e3aaf64cf6f?pvs=74">需求方接口 vs. 提供方接口</a>，这里就不赘述了。</p>
<h4>抽象的时机</h4>
<p>前面我们总结了抽象的作用、难点和核心，也在技术和业务两个层面进行了展开并给出了一些切实有效的实施建议。我们已经知道&quot;抽&quot;什么（变化），但什么时候&quot;抽&quot;呢？那最后我们就来谈一谈抽象的时机，即如何尽可能减少过早或过度抽象？</p>
<p>我觉得可以遵循一个原则（<strong>收益 &gt; 付出</strong>）两个策略：</p>
<ul>
<li>**策略一：为测试而抽象。**这是刚需，当你的判断出一个业务逻辑值得撰写单元测试的时候，你为了让它（<code>OrderService</code>）可被单元测试，你必须能够替换它的依赖（<code>OrderRepository</code>）。</li>
<li>**策略二：事不过三原则。**这是对抗过度抽象的最佳启发式规则。<ul>
<li><strong>第一次</strong>：你写了一个功能，<strong>不要抽象</strong>。就写具体实现。坚守 <strong>YAGNI</strong> (You Ain&#39;t Gonna Need It) 原则。</li>
<li><strong>第二次</strong>：你写一个类似功能，你可能会复制-粘贴-修改。<strong>忍住，还是不要抽象</strong>。但你要开始警惕了。</li>
<li><strong>第三次</strong>：当你复制-粘贴第三次时，说明<strong>变化的模式</strong>已经稳定出现。此时，你不再是猜测变化，你是在响应已经发生的变化。<strong>这是抽象的最佳时机。</strong> 从具体的代码中提炼出抽象的接口，远比凭空设计一个抽象要靠谱得多。</li>
</ul>
</li>
</ul>
<h3>分治</h3>
<p>分治 (Decomposition) 的第一性原理是将一个大规模的、难以直接处理的大问题，拆解为一系列可独立解决的小问题，然后通过组合这些小问题的解，来得到大问题的解。</p>
<p>这个道理我们都懂，因为人脑的认知负荷有限。一个庞大且 All-in-One 的系统，其内部状态和依赖关系的组合呈指数级增长，很快会超过任何工程师（或团队）的处理上限。</p>
<p>在我的三年经验里，我最恐惧的，莫过于在 💩 代码中，一头扎进一个几千行的函数中：</p>
<ul>
<li>你根本不知道它的<strong>主线</strong>是什么，因为 <code>if-else</code> 的<strong>支线</strong>已经把它变成了意大利面条。</li>
<li>你不敢<strong>重构</strong>，因为你根本不知道你手里这个小问题，是多少个大问题共享的<strong>内脏</strong>，负负得正你受得了吗？</li>
<li>你无法<strong>测试</strong>，因为你连<strong>单元</strong>的边界都找不到。</li>
</ul>
<h4>分治的本质</h4>
<p>在我看来，分治的本质在于治，而不在于分。<strong>分（divide）只是手段，而治（Conquer）才是目的</strong>。</p>
<p>&quot;分&quot;（Divide）是为了什么？</p>
<ul>
<li><p>降低认知负荷</p>
</li>
<li><p>隔离变化</p>
</li>
<li><p>提高可测试性</p>
</li>
<li><p>实现复用</p>
</li>
</ul>
<p>但这些都是为了&quot;治&quot;（Conquer）服务的：</p>
<ul>
<li><p>能够独立理解每个部分</p>
</li>
<li><p>能够独立开发每个部分</p>
</li>
<li><p>能够独立测试每个部分</p>
</li>
<li><p>能够独立修改每个部分</p>
</li>
<li><p>最终能够有效地控制复杂度</p>
</li>
</ul>
<p>如果只&quot;分&quot;不&quot;治&quot;，就会出现：</p>
<ul>
<li><p>过度拆分，反而增加复杂度</p>
</li>
<li><p>形式上分离，但依赖关系混乱</p>
</li>
<li><p>看起来模块化，但实际上改一处牵一发而动全身</p>
</li>
</ul>
<h4>分治的边界</h4>
<p>**分治最大的风险，是错误的边界划分。**一个错误的分治，即将一个本应内聚的整体强行拆开，这非但不能降低复杂度，反而会因为引入高耦合和通信开销（如不必要的网络调用），而增加了系统的意外复杂度。</p>
<p>关于分的边界，我个人觉得可以从两个层级进行考虑：</p>
<ul>
<li>代码层级：单一职责（SRP）</li>
<li>系统层级：限界上下文（Bounded Context）</li>
</ul>
<hr>
<p>在代码级别，我们面对的问题是什么？是<strong>变更</strong>。一个软件的生命周期中，最大的成本是维护，而维护的核心就是应对变更需求。</p>
<blockquote>
<p>A class should have only one reason to change. —— Robert C. Martin (Uncle Bob)</p>
<p>一个类应该只有一个变更的理由。</p>
</blockquote>
<ul>
<li><strong>分 (Divide)：</strong> 如何分？SRP 告诉我们，<strong>变更的理由 (Reason to Change) 就是你分的边界。</strong><ul>
<li><strong>问题：</strong> 假设一个 <code>Employee</code> 类，它既负责计算薪酬 (A 理由：财务规则变更)，又负责保存数据到数据库 (B 理由：DBA 变更表结构)，还负责生成报表 (C 理由：HR 变更报表格式)。</li>
<li><strong>复杂度：</strong> 这 3 个理由（A, B, C）被耦合在同一个类里。A 的变更可能会破坏 B 的功能；B 的变更又可能影响 C。这就是 $N^2$ 复杂度的雏形。</li>
</ul>
</li>
<li><strong>治 (Conquer)：</strong> 我们将这个大问题分解。<ul>
<li><code>PayrollCalculator</code> （只因 A 而变）</li>
<li><code>EmployeeRepository</code> （只因 B 而变）</li>
<li><code>EmployeeReporter</code> （只因 C 而变）</li>
</ul>
</li>
<li><strong>合 (Combine)：</strong> 通过清晰的接口将它们组合起来，完成完整的业务。</li>
</ul>
<p>所以在代码级别，<strong>SRP 就是分治思想在管理变更复杂度这个特定场景下的应用。</strong> 它的分是<strong>以&quot;变更的理由&quot;为边界</strong>，把不同变更轴心上的逻辑（职责）隔离开，从而实现高内聚、低耦合，降低代码的认知和耦合复杂度。</p>
<hr>
<p>在系统级别（特别是大型企业应用），我们面对的问题是什么？是<strong>业务的规模和语义的模糊性</strong>。当一个系统大到需要几十上百人协作时，最大的问题不再是&quot;变更理由&quot;，而是&quot;我们说的&#39;客户&#39;是同一个东西吗？&quot;</p>
<ul>
<li>销售团队的客户 (Customer)：有购买意向的潜在个体。</li>
<li>客服团队的客户 (Customer)：有服务工单的已注册用户。</li>
<li>财务团队的客户 (Customer)：有付款记录的法律实体。</li>
</ul>
<p>如果试图建立一个统一的 God Model 来满足所有人，这个模型将变得无比复杂、充满 <code>if-else</code>，并且对所有人来说都是错的。</p>
<blockquote>
<p>领域驱动设计（DDD）提出的限界上下文（Bounded Context）就是来解决这个问题的。</p>
</blockquote>
<p><strong>分 (Divide)：</strong> 如何分？BC 告诉我们，<strong>业务的领域边界和团队的组织边界就是你分的边界。</strong></p>
<ul>
<li><strong>问题：</strong> 试图用一个统一模型描述整个企业的业务。</li>
<li><strong>复杂度：</strong> 语义冲突（Semantic Conflict）和组织沟通的开销（$N^2$ 沟通路径）。</li>
</ul>
<p><strong>治 (Conquer)：</strong> 我们将这个大领域分解为多个子领域。</p>
<ul>
<li><strong>销售上下文 (Sales Context)：</strong> 在这个边界内，客户模型只包含销售所需的属性。</li>
<li><strong>客服上下文 (Support Context)：</strong> 在这个边界内，客户模型只包含服务所需的属性。</li>
<li><strong>财务上下文 (Billing Context)：</strong> 在这个边界内，客户模型只包含账务所需的属性。</li>
</ul>
<p><strong>合 (Combine)：</strong> 通过明确的上下文映射图（Context Map），比如防腐层（ACL）或开放主机服务（OHS），来定义这些上下文之间的关系。</p>
<p>在系统级别，<strong>BC 就是分治思想在管理业务和语义复杂度这个特定场景下的应用。</strong> 它的分是<strong>以&quot;语义一致性&quot;为边界</strong>，把庞大的、模糊的业务领域分解为多个边界清晰、语义明确的子域，从而让每个子域（微服务）内部实现高内聚、低耦合。</p>
<h4>真正的分治</h4>
<p>坦白说，目前我在系统级别层面的分治能力还较为欠缺，这方面还需要更多的沉淀和学习，所以现在我还无法做更进一步的阐述。但是这里我想通过我过去工作中的一个例子，来尝试阐述一下我所认为的真正的分治。</p>
<p>在我所负责的游戏业务中，我们有一个接口负责游戏结算的，它所包含的需求（部分）功能大概如下：</p>
<blockquote>
<p>它要负责多款联机游戏模式的结算逻辑，即要计算成绩、保存成绩、更新历史荣誉，还要涉及师徒系统、任务系统的各个加成、奖励和活跃度更新，有时候还要涉及各种运营活动的发奖逻辑（而且它们发的奖励要在结算接口返回给客户端，不能纯异步）。</p>
</blockquote>
<p>HTTP 层简化的代码如下：</p>
<pre><code class="language-go">func (api *Api) Settle(c *gin.Context) {
	// 参数解析
  var ps struct {
		...
	}
	if err := c.ShouldBind(&amp;ps); err != nil {
		return
	}

  // 格式化成绩
	roomScorePtr, err := api.parseUploadScoreParam(&amp;ps)
	if err != nil {
		return
	}

  // 处理不同的游戏模式
	if mode == GameMode1 {
		// 游戏模式1处理逻辑
		result = api.processGameMode1(ctx, roomScorePtr)
	} else {
		// 游戏模式2处理逻辑
		result = api.processGameMode2(ctx, roomScorePtr)

    // 发布事件
		eventbus.AsyncPublish(&quot;settle_game_mode_1&quot;, roomScorePtr)
	}

  // 任务通知

  // 响应结果
}
</code></pre>
<p>光这一层就存在了非常多的问题，具体来说：</p>
<ol>
<li><p>混杂了三个抽象层次</p>
<ul>
<li><p>HTTP 层：参数绑定、响应构建</p>
</li>
<li><p>业务编排层：模式判断、流程控制</p>
</li>
<li><p>业务执行层：计分逻辑、事件发布、任务检查</p>
</li>
</ul>
</li>
<li><p>职责过载（至少 5 个职责）</p>
<ul>
<li><p>HTTP 请求处理</p>
</li>
<li><p>参数验证和解析</p>
</li>
<li><p>业务模式路由</p>
</li>
<li><p>副作用管理（事件发布、任务通知）</p>
</li>
<li><p>响应构建</p>
</li>
</ul>
</li>
</ol>
<p>业务逻辑层就更夸张了，几百行的意大利面条代码，这里我就不贴了，你可以想想得到，里面就是平铺直叙写把业务要的逻辑一行行实现起来。你可以会说，我把他们都抽成一个个函数，这样不就可以了吗？比如：</p>
<pre><code class="language-go">func (api *Api) processGameMode1(ctx context.Context, score *Score) map[string]interface{} {
	// 原来 500 行代码，现在拆成这样：
  result1 := step1(ctx, score)
  result2 := step2(ctx, score, result1)
  result3 := step3(ctx, score, result2)
  result4 := step4(ctx, score, result3)
  result  := step5(ctx, score, result4)
  return result
}
</code></pre>
<p>看起来&quot;分&quot;了，但这种拆分没有实现&quot;治理&quot;，只是把混乱从一个地方搬到了五个地方。</p>
<p>❌ 无法独立理解：必须看完整流程才能理解每个函数</p>
<p>❌ 无法独立测试：每个函数都依赖上下文</p>
<p>❌ 无法独立修改：改一个函数会影响其他函数</p>
<p>❌ 无法独立复用：函数与特定流程强耦合</p>
<p>那怎样才算是真正的治理呢？我们可以从 4 个维度进行思考：</p>
<ul>
<li><p>可独立理解（认知治理）</p>
</li>
<li><p>可独立修改（演化治理）</p>
</li>
<li><p>可独立验证（测试治理）</p>
</li>
<li><p>可灵活组合（组合治理）</p>
</li>
</ul>
<blockquote>
<p>好的架构让复杂度可控 —— 不是消除复杂度（业务本来就复杂），而是让复杂度在每个局部都是可管理的。</p>
</blockquote>
<p>首先我们看认知治理：每个单元可以独立理解。</p>
<pre><code class="language-go">// ❌ 无法独立理解（当前代码的问题）
func processGameMode1(...) {
    // 500+ 行代码
    // 既算分，又处理战队，又构建响应，又存储
    // 必须从头到尾读完才能理解任何一部分
}

// ✅ 可以独立理解
type ScoreCalculationProcessor struct {
    calculator ScoreCalculator
}

func (p *ScoreCalculationProcessor) Process(ctx context.Context, input *SettlementContext, result *SettlementResult) error {
    // 10行代码，一眼就能看懂：
    // 1. 遍历玩家
    // 2. 调用 calculator 计算分数
    // 3. 转换为奖励
    // 4. 存入 result

    for _, player := range input.Players {
        score := p.calculator.Calculate(player, input)
        reward := p.calculator.ConvertToReward(score)
        result.PlayerRewards[player.ID] = &amp;PlayerReward{
            PlayerID:    player.ID,
            BaseReward:  reward,
            TotalReward: reward.Clone(),
        }
    }
    return nil
}

// &quot;治理&quot;的体现：
// - 不需要理解 HTTP 层怎么工作
// - 不需要理解其他 Processor 做什么
// - 不需要理解数据如何存储
// - 只需要理解：输入玩家分数 → 输出奖励
</code></pre>
<p>再来看演化治理：每个单元可以独立修改。</p>
<pre><code class="language-go">// ❌ 无法独立修改
// 当前代码：要修改师徒加成逻辑
func processGameMode1(...) {
    // ... 150行其他逻辑 ...

    // 师徒逻辑埋在这里
    if masterRelation != nil {
        bonus = baseReward * 0.2
    }

    // ... 又是100行其他逻辑 ...
}
// 问题：
// 1. 要修改师徒加成率，必须找到这段代码（在200+行中定位）
// 2. 修改后要测试整个 processGameMode1（影响面不清晰）
// 3. 无法确定是否影响了其他逻辑

// ✅ 可以独立修改
type MasterApprenticeProcessor struct {
    masterSvc MasterApprenticeService
}

func (p *MasterApprenticeProcessor) Process(...) error {
    // 所有师徒逻辑都在这里
    // 修改时：
    // 1. 直接定位到这个文件
    // 2. 只需要测试这个 Processor
    // 3. 明确不会影响其他逻辑
}

// &quot;治理&quot;的体现：
// - 需求变更有明确的修改边界
// - 影响范围可控
// - 回归测试范围可控
</code></pre>
<p>再来看测试治理：每个单元可以独立验证。</p>
<pre><code class="language-go">// ❌ 无法独立验证
func TestProcessGameMode1(t *testing.T) {
    // 要测试师徒加成，需要：
    // - 准备完整的房间数据
    // - Mock 所有数据库调用
    // - Mock 排名系统
    // - Mock 任务系统
    // - Mock 活动系统
    // ... 可能需要 500+ 行测试代码

    // 而且无法精确测试师徒加成逻辑
    // 只能测试整体是否工作
}

// ✅ 可以独立验证
func TestMasterApprenticeProcessor(t *testing.T) {
    // 只需要 mock 一个接口
    mockSvc := &amp;MockMasterApprenticeService{}
    processor := &amp;MasterApprenticeProcessor{masterSvc: mockSvc}

    // 准备最小化的输入
    input := &amp;SettlementContext{}
    result := &amp;SettlementResult{}

    // 执行
    err := processor.Process(ctx, input, result)

    // 精确验证师徒加成逻辑
    assert...
}

// &quot;治理&quot;的体现：
// - 20行代码就能测试核心逻辑
// - 测试用例清晰（输入100金币，加成20%，得到20金币）
// - 测试快速（无需数据库，无需 HTTP）
// - 测试稳定（不依赖外部状态）
</code></pre>
<p>最后看组合治理：整体可以灵活组装。</p>
<pre><code class="language-go">// ❌ 无法灵活组合
// 当前代码：要支持新的游戏模式
func (api *Api) Settle(c *gin.Context) {
    if mode == GameMode1 {
        result = api.processGameMode1(...)
    } else if mode == GameMode2 {
        result = api.processGameMode2(...)
    } else if mode == GameModeNew {
        // 要添加新模式，必须：
        // 1. 修改这个主流程
        // 2. 实现一个新的 processGameModeXXX 函数
        // 3. 每个函数内部重复大量相同逻辑
        result = api.processGameModeXXX(...)
    }
}

// ✅ 可以灵活组合
// 新增游戏模式时：

// 1. 创建新的 Pipeline（不需要修改现有代码）
func (f *SettlementPipelineFactory) CreateNewModePipeline() *SettlementPipeline {
    return NewSettlementPipeline(
        f.scoreCalc,       // 复用
        f.ranking,         // 复用
        f.activity,        // 复用
        // 不需要师徒系统
        // 不需要任务系统
        NewSpecialProcessor(), // 新模式特有的处理器
        f.persistence,     // 复用
    )
}

// 2. 注册新接口
router.POST(&quot;/game/newmode/settle&quot;, &amp;NewModeHandler{
    pipeline: factory.CreateNewModePipeline(),
})

// &quot;治理&quot;的体现：
// - 90%的代码复用（Processor 都是通用的）
// - 只需要实现 10%的特殊逻辑
// - 不影响现有模式
// - 清晰的扩展点
</code></pre>
<p>这里我给一个分治后的架构供各位读者参考：</p>
<pre><code>第1层：接口隔离
  ├── /game/mode1/settle
  ├── /game/mode2/settle
  └── /game/mode3/settle

第2层：HTTP处理层
  ├── 请求解析
  ├── 调用业务逻辑层
  └── 响应构建

第3层：应用逻辑层
  ├── 调用Pipeline
  ├── 异步副作用处理
  └── 返回结果

第4层：Pipeline编排层
  └── 按游戏模式组装不同的Processor链

第5层：业务处理层（独立的Processor）
  ├── ScoreCalculationProcessor 分数计算
  ├── MasterApprenticeProcessor 师徒系统
  ├── TaskSystemProcessor       任务系统
  ├── ActivityProcessor         活动系统
  ├── RankingUpdateProcessor    排名系统
  ├── AchievementProcessor      成就系统
  ├── HonorUpdateProcessor      荣誉系统
  └── PersistenceProcessor      成绩保存

第6层：领域服务层
  ├── ScoreCalculator           分数计算
  ├── MasterApprenticeService   师徒系统
  ├── TaskService               任务服务
  ├── ActivityService           活动服务
  └── RankingService            排行榜服务
</code></pre>
<p>好处显而易见：</p>
<ol>
<li>职责彻底分离：每个 Processor 只做一件事，15-30 行代码就能看清楚逻辑。</li>
<li>可组合性：底层的业务逻辑层和领域服务层可以复用给各个不同的游戏模式。</li>
<li>可测试性：每个单元都非常小，依赖尽可能少，测试难度大大降低。</li>
<li>性能可观测性：管道可以实现自动记录每个 Processor 的耗时，可以精准定位性能瓶颈。</li>
<li>错误隔离：任何一个 Processor 失败，都能清晰知道是哪个环节出问题</li>
<li>并行优化：如果某些 Processor 之间没有依赖，可以并行执行。</li>
</ol>
<blockquote>
<p>[!IMPORTANT]</p>
<p>总结一下，&quot;分&quot;只是手段，&quot;治&quot;才是目的的深刻含义：</p>
<ul>
<li><p>不要为了拆分而拆分 —— 如果拆分后没有提升治理能力，那就是过度设计。</p>
</li>
<li><p>拆分的目标是治理 —— 每次拆分都要问：<strong>这样拆是否让问题更容易控制？</strong></p>
</li>
<li><p>治理的四个标准：</p>
<ul>
<li><p>可独立理解（认知治理）</p>
</li>
<li><p>可独立修改（演化治理）</p>
</li>
<li><p>可独立验证（测试治理）</p>
</li>
<li><p>可灵活组合（组合治理）</p>
</li>
</ul>
</li>
<li><p>好的架构让复杂度可控 —— 不是消除复杂度（业务本来就复杂），而是让复杂度在每个局部都是可管理的</p>
</li>
</ul>
<p>游戏结算接口的例子完美诠释了这一点：不是要把 800 行代码拆成 80 个函数，而是要把不可控的复杂度转化为可控的、独立的、可组合的单元。这才是真正的&quot;治理&quot;。</p>
</blockquote>
<h3>分层</h3>
<p>分层和分治看起来很像，不过在我看来它们的侧重点还是有所不同的。在我看来，分治面临的是一个问题<strong>规模过大</strong>，导致单个处理单元（人、CPU、服务）无法在有效时间内解决，或者逻辑过于复杂以至于无法一次性正确实现。它的思路是分解、解决和合并。而分层面临的问题是系统的各个部分<strong>过度耦合</strong>。当一个模块的实现细节（比如换个数据库）会影响到另一个模块（比如 UI 界面）时，系统就变得僵化和脆弱。概括来说：</p>
<ul>
<li><strong>分治</strong>侧重于<strong>高内聚</strong>，其原则是按单一变更理由（SRP）将逻辑<strong>聚合</strong>到同一单元。</li>
<li><strong>分层</strong>侧重于<strong>低耦合</strong>，其原则是按技术关注点（SoC）<strong>管理依赖方向</strong>，隔离实现细节。</li>
</ul>
<p>OK，我们还是尝试回归到第一性原理上：</p>
<blockquote>
<p>[!important]</p>
<p>分层到底&quot;分&quot;的是什么呢？ —— 变化速率。</p>
</blockquote>
<p>分层的最终目的其实是<strong>隔离变化</strong>。一个好的分层设计，其核心标准是：**当系统的一部分发生变化时，其他部分应该尽可能少地受到影响。**要做到这一点，不仅仅是画出几个框框然后把代码扔进去那么简单。这需要一套严格的原则和实践。</p>
<p>做好分层，关键在于回答三个问题：<strong>① 按什么标准分？ ② 层与层如何对话？ ③ 谁能依赖谁？</strong></p>
<h4>分层的原则</h4>
<h5>原则一：按什么分？以变化的速率作为切分标准</h5>
<p>这是最根本的原则。为什么表现层和数据访问层要分开？因为 UI 界面（颜色、布局）<strong>变化的频率和原因</strong>，与数据存储方式（用 MySQL 还是 PostgreSQL）<strong>变化的频率和原因</strong>是完全不同的。</p>
<ul>
<li><strong>高内聚：</strong> 把变化原因和速率相近的代码放在同一层。例如所有处理 HTTP 请求、解析 JSON、参数校验的代码，都属于表现层的职责，它们一起变化。</li>
<li><strong>低耦合：</strong> 变化速率不同的代码，应该被坚决地<strong>隔离</strong>在不同的层。</li>
</ul>
<p>很多失败的分层，是因为分错了。例如，在业务逻辑层（Service）里，既有核心业务规则（订单总价 &gt; 100 才能免运费），又混杂着数据格式的转换（把 <code>Entity</code> 转成 <code>DTO</code>）。核心业务规则（免运费策略）可能几个月不变，而 <code>DTO</code>（返回给 App 的 JSON 格式）可能每周都在变。把它们混在一起，就违反了按变化速率切分的原则。</p>
<h5>原则二：谁依赖谁？依赖倒置原则</h5>
<p>这是<strong>做好分层</strong>的关键。相信不少读者跟我一样，在一开始学习 MVC 架构的时候，都是遵循传统的朴素分层：表现层 → 业务层 → 数据层。这种依赖是<strong>具体</strong>的，即表现层<strong>直接依赖</strong>业务层的<strong>具体实现</strong>；业务层<strong>直接依赖</strong>数据层的<strong>具体实现</strong>。例如，<code>UserService</code> 直接 <code>new UserRepositoryImpl()</code>）。它的问题在于业务逻辑层（高层策略）<strong>依赖</strong>了数据访问层（底层细节）。当底层细节（如数据库实现）更换时，业务逻辑层也可能需要修改。</p>
<p>而依赖倒置就不一样了，它的操作过程大致如下：</p>
<ol>
<li><strong>高层（业务逻辑层）定义需求方接口（Interface）</strong>。例如： <code>UserService</code> 定义一个 <code>IUserRepository</code> 接口，接口中声明它需要的方法，如 <code>User GetUser(string id)</code>。</li>
<li><strong>高层（业务逻辑层）只依赖这个需求方接口。</strong><code>UserService</code> 的代码只认识 <code>IUserRepository</code>，完全不知道数据库、Redis 或什么 <code>Impl</code> 的存在。</li>
<li><strong>低层（数据访问层）去实现这个需求方接口。</strong><code>UserRepositoryImpl</code> 实现 <code>IUserRepository</code> 接口。</li>
<li>通过依赖注入将<strong>具体实现</strong>注入给高层。</li>
</ol>
<p>系统的核心价值在于其<strong>业务规则</strong>（高层策略），而不是它用什么数据库（底层细节）。因此，<strong>策略不应该依赖细节，而应该是细节依赖于策略</strong>。这才是分层的精髓：保护高价值的<strong>业务逻辑</strong>不受低价值的<strong>实现细节</strong>的污染。</p>
<h5>原则三：层与层如何对话？严格的接口与封装</h5>
<p>层与层之间绝对不能越级访问或泄露实现细节。上层只应该知道它所需要的<strong>最小接口</strong>。当数据需要跨越层的边界时，使用数据传输对象（DTO / VO / PO）来传递，而不是直接传递内部实现。</p>
<blockquote>
<p>在简单业务中，这可能看起来很繁琐，但这是保持分层纯洁性的代价，需要权衡，没有绝对的答案。</p>
</blockquote>
<h4>分层坏味道</h4>
<p>在我的工作过程中，曾经见到过不少的坏分层，导致各种循环依赖、层次混乱，被它们折磨够呛，我将它们进行简单总结，如果在你的代码中也发现了这些情况，那可能就需要引起重视了。</p>
<ol>
<li><strong>泄露的抽象</strong>：业务逻辑层（Service）向上（Controller）返回了一个<strong>数据库 ORM 的实体对象</strong>。这逼得 Controller 被迫知道了&quot;数据库长什么样&quot;，表现层和数据层被耦合了。</li>
<li><strong>层跳跃</strong>：Controller 为了图方便，绕过了 Service，<strong>直接调用</strong>了 Repository 来获取数据。短期内看似更简洁，实际上导致了业务逻辑被架空。未来如果这个获取数据需要增加权限校验或缓存逻辑（本应在 Service 层做），Controller 里的这处调用就会被遗漏。</li>
<li><strong>胖瘦不均</strong>：要么是 Service 层非常&quot;瘦&quot;，里面没有任何业务逻辑（或干脆没有 Service 层），只是简单地调用 Repository 的 <code>save()</code>、<code>get()</code>。所有的业务逻辑（如校验、计算）都堆积在 Controller 层。要么一个 GodService 类包含了上万行代码，处理了几十种不相关的业务。这违反了高内聚原则，分层失去了意义。</li>
<li><strong>依赖反向</strong>：Repository <strong>反过来 <code>import</code></strong> 了 Service/Controller 的代码。这是最痛苦的，这会造成循环依赖，这在逻辑上是致命的，说明职责划分彻底混乱。</li>
</ol>
<h4>分层的典范</h4>
<p>在现代软件工程中，洋葱架构或整洁架构（Clean Architecture）是依赖倒置原则的最佳实践。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/308528-20170714161640900-366868890.jpg" alt="洋葱架构"></p>
<p>如上图所示，它将分层想象成一个洋葱：</p>
<ol>
<li><p><strong>最中心：领域实体 (Entities)</strong></p>
<p>企业级的核心业务规则，最稳定，变化最少。</p>
</li>
<li><p><strong>第二层：用例/应用服务 (Use Cases / Application Services)</strong></p>
<p>具体的业务流程，编排“实体”来完成一个操作（例如“用户注册”用例）。</p>
</li>
<li><p><strong>第三层：接口适配器 (Interface Adapters)</strong></p>
<p><code>Controller</code>、<code>Presenter</code>、<code>Repository</code> 的实现。它们是“翻译官”。</p>
</li>
<li><p><strong>最外层：框架与驱动 (Frameworks &amp; Drivers)</strong></p>
<p>Web 框架 (Gin, Spring)、数据库 (MySQL)、UI (Web) 等。</p>
</li>
</ol>
<p><strong>这个架构的唯一规则：依赖箭头永远指向内部。</strong></p>
<ul>
<li><code>Controller</code> (外) 依赖 <code>Use Case</code> (内)。</li>
<li><code>Repository</code> (外) <strong>实现</strong> <code>Use Case</code> (内) <strong>定义的接口</strong>。</li>
</ul>
<p>通过这种方式，最核心的业务逻辑（Entities 和 Use Cases）<strong>完全不知道</strong>外部世界有 Web、有 MySQL 的存在。你可以把 Web 替换成命令行，把 MySQL 替换成内存数据库，而<strong>中心的业务代码一行都不用改</strong>。</p>
<p>这，就是做好分层的终极目标：<strong>保护核心业务逻辑，让其独立于外部实现细节而存在。</strong></p>
<h4>分层的实践</h4>
<p>在软件工程出现之前，分层早已是系统工程的基石。所以这一小节，我想借这个机会，梳理一下我们司空见惯的那些计算机核心技术和编程语言（Go/Rust），它们在哪些地方都用到了分层的思想。</p>
<h5>网络协议</h5>
<p>最经典的分层实践就是 OSI 七层协议了，如下图所示。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gt3mt2poj8j30u016idmo.jpg" alt="OSI 七层网络协议"></p>
<p>在实践中，TCP/IP 四层协议对其进行了简化：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gt3mt1ojy7j31gq0katcz.jpg" alt="TCP/IP 四层协议 vs. OSI 七层协议"></p>
<p>TCP/IP 四层协议完美践行了关注点分离（SoC）：</p>
<ul>
<li><strong>应用层</strong> (HTTP)：<strong>只</strong>关注应用数据的语义（比如 <code>GET /user</code> 这个请求）。</li>
<li><strong>传输层</strong> (TCP)：<strong>只</strong>关注进程到进程的可靠性（如三次握手、丢包重传）。</li>
<li><strong>网络层</strong> (IP)：<strong>只</strong>关注主机到主机的路由寻址。</li>
<li><strong>数据链路层</strong> (Ethernet)：<strong>只</strong>关注物理帧的相邻传输。</li>
</ul>
<p>并且它也严格执行了单向依赖的原则，上层（如 HTTP）<strong>依赖</strong>下层（TCP）提供的可靠字节流服务。但 TCP（下层）<strong>完全不</strong>认识（也<strong>不</strong>依赖）HTTP。这种分层带来的低耦合是革命性的：</p>
<ul>
<li>我们可以在不修改 TCP 和 IP 的情况下，发明新的应用层协议（如 WebSocket，gRPC）。</li>
<li>我们也可以在不修改 HTTP 和 TCP 的情况下，将网络层从 IPv4 <strong>无缝</strong>升级到 IPv6。</li>
</ul>
<h5>操作系统</h5>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/System-Call-in-Operating-System.jpg" alt="操作系统用户态与内核态"></p>
<p>操作系统的用户态和内核态也是分层的杰作。</p>
<ul>
<li><strong>用户态 (User Mode)：</strong> 关注业务逻辑（例如，我们用 Go 编写 Web 程序）。</li>
<li><strong>内核态 (Kernel Mode)：</strong> 关注硬件资源管理（如进程调度、内存分配、I/O 驱动）。</li>
</ul>
<p>它们之间的层就是<strong>系统调用接口 (System Call Interface)</strong>。我们的 Go 程序（上层）通过系统调用请求 I/O，它<strong>不需要</strong>（也<strong>不</strong>知道）内核（下层）是如何与 Intel SATA 驱动还是三星 NVMe 驱动的实现细节打交道的。这导致了我们的 Go 程序<strong>只</strong>依赖 Linux 内核这一层，因此它可以移植到运行在任何实现了 Linux 内核 API 的物理机器上，<strong>隔离</strong>了硬件这个<strong>易变</strong>的实现。</p>
<h5>数据库系统</h5>
<p>即使是一个单一的程序，比如我们常用的数据库系统 MySQL，其内部也是<strong>严格分层</strong>的。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/6389433535043459524065439.png" alt=""></p>
<ul>
<li><strong>查询解析/优化器 (Query Optimizer)：</strong> <strong>只</strong>关注 SQL 语句的语义和执行计划（如决定使用哪个索引）。</li>
<li><strong>存储引擎 (Storage Engine) (如 InnoDB)：</strong> <strong>只</strong>关注数据的物理存取（如如何在 B+ 树上读/写、如何管理事务日志）。</li>
</ul>
<p>它们之间通过<strong>存储引擎 API</strong> 这一层来通信。这种分层，使得 MySQL 可以<strong>可插拔地</strong>替换存储引擎。<code>Query Optimizer</code>（上层）<strong>不</strong>依赖 <code>InnoDB</code>（下层）的实现，它只依赖契约。这就是为什么 MySQL 既可以支持 <code>InnoDB</code>（事务型）也可以支持 <code>MyISAM</code>（非事务型）。</p>
<h5>Go 网络编程</h5>
<p>Go 的网络编程模型同样完美践行了关注点分离（SoC），下图自顶向下清晰地展示了这种分层：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h5lh6uxgk3j21770u0773.jpg" alt="Go 网络编程"></p>
<ul>
<li><strong>L6: 业务层 (Goroutine &amp; 编码风格)：</strong> 只关注业务逻辑。开发者只需用同步阻塞的风格（如 <code>conn.Read()</code>）编写业务。</li>
<li><strong>L5: Go 并发调度层 (GMP 调度器)：</strong> 只关注 Goroutine 的并发调度。它隐藏了 L3（<code>netpoller</code>）的 I/O 事件机制，当 L3 报告 I/O 就绪时，它（GMP）负责唤醒对应的 L6（Goroutine）。</li>
<li><strong>L4: Go 协议层 (net 包)：</strong> 只关注 TCP/UDP/HTTP 等网络协议的实现，并提供了平台无关的 API（如 <code>net.Conn</code>）。</li>
<li><strong>L3: Go Runtime 适配层 (network poller)：</strong> 只关注跨平台 I/O 多路复用。它封装（Wrapping）并屏蔽了 L2（<code>epoll/kqueue/iocp</code>）的平台差异。</li>
<li><strong>L2: OS I/O 机制层 (epoll/kqueue/iocp)：</strong> 只关注高性能 I/O 事件的通知机制。</li>
<li><strong>L1: OS 协议/资源层 (socket)：</strong> 只关注传输层协议（TCP/UDP）的内核实现和资源管理（文件描述符）。</li>
</ul>
<p>并且它也严格执行了多重的单向依赖原则：</p>
<ul>
<li>L6（业务）依赖 L5（GMP）提供的并发能力。</li>
<li>L5（GMP）依赖 L3（netpoller）提供的 I/O 就绪通知。</li>
<li>L4（net 包）依赖 L3（netpoller）提供的跨平台 I/O 能力。</li>
<li>L3（netpoller）依赖 L2（epoll 等）提供的 OS 事件能力。</li>
<li>L2（epoll 等）依赖 L1（socket）提供的资源。</li>
</ul>
<p>这种分层带来的低耦合是革命性的：</p>
<ul>
<li>开发者可以在<strong>不修改</strong>业务代码的情况下，享受 Go Runtime 团队对协程调度或 network poller 的性能优化。</li>
<li>Go 团队也可以在<strong>不修改</strong> <code>net</code> 包的情况下，将 <code>network poller</code>适配到 Linux 最新的 <code>io_uring</code>，开发者<strong>无需</strong>改动任何代码即可获得性能提升。</li>
<li>最重要的是，开发者可以<strong>只</strong>关注 <code>goroutine-per-connection</code> 这种同步的业务逻辑，而<strong>不</strong>必关心 Epoll/Kqueue 这些异步非阻塞的底层实现细节，极大地降低了高性能网络编程的认知负荷。</li>
</ul>
<h3>模块化</h3>
<p>分治是在思维层面上将大问题拆分为多个小问题，而分层更多专注在技术层面上的关注点分离。那模块化呢？在我看来，模块化是将一个更加广泛的概念，它跟分治和分层一样，都是为了解决一个高复杂度问题所采取的抽象行为，只不过模块化它的产物更加具体化，比如拆分成一个个的微服务、同一个系统内部的多个 module/package，或是具体到一个个负责不同职责的类。</p>
<p>可以理解为分层是模块化的一个特定应用，它按照技术职责进行模块化区分，如果 UI 层、接口层、业务逻辑层、数据访问层等。而分治的某些场景下的落地实现就是模块化，比如微服务的拆分、业务系统不同组件的拆分等。</p>
<p>在我看来，模块化的终极目标就是老生常谈的：高内聚、低耦合。</p>
<ul>
<li>高内聚：一个模块只做一件事，并把它做好。</li>
<li>低耦合：模块之间的互不依赖，只通过接口进行交互。</li>
</ul>
<p>而要做好模块化，主要是要做好两步：</p>
<ol>
<li>封装：隐藏秘密。把自己的内部实现（私有函数、辅助函数）藏好。</li>
<li>接口：做出承诺。只对外暴露一个清晰、稳定、最小化的接口（契约），告诉别人我能做什么。</li>
</ol>
<h4>模块化的实践</h4>
<h5>硬件与计算机体系结构：总线与 PCle</h5>
<p>在电脑的主板上，CPU、内存、显卡（GPU）、硬盘（SSD）都是独立的模块。</p>
<ul>
<li>接口：它们通过统一的总线（如 PCle）进行通信。PCle 就是一个标准化的接口。</li>
<li>封装：NVIDIA 只需要按照 PCle 接口规范设计显卡，它不知道知道 Intel 的 CPU 如何工作，Intel 的 CPU 也不需要知道显卡内部是如何渲染图形的。</li>
<li>高内聚：显卡高度内聚，只负责图形处理。</li>
<li>低耦合：这使得我们可以随意插拔、更换不同厂商的显卡或 SSD（只要接口兼容），而系统其他部分完全不受影响。</li>
</ul>
<h5>操作系统：从驱动程序到微内核</h5>
<p>操作系统是模块化设计的殿堂。它面临的第一个史诗级挑战就是：世界上有成千上万种硬件（网卡、显卡、磁盘），操作系统如何支持它们，而不让自己崩溃？if else 肯定是行不通的道路，那 Linux 给出的答案就是驱动程序架构。</p>
<ul>
<li>模块：硬件驱动程序（如 NVIDIA 显卡驱动、Intel 网卡驱动）</li>
<li>接口：由操作系统内核（如 Linux Kernel）定义的一组标准化的函数调用。例如，块驱动设备必须实现 <code>read</code>、<code>write</code>、<code>ioctl</code> 等接口；网络驱动必须实现 <code>open</code>、<code>stop</code>、<code>xmit</code> 等接口。</li>
<li>封装：<ul>
<li>OS 内核的封装：NVIDIA 不需要知道 Linux 进程调度器和 VFS（虚拟文件系统）的内部实现。它只需要知道内核提供的网络设备接口长什么样。</li>
<li>驱动的封装：Linux 内核不需要知道显卡芯片是如何通过 CUDA 核心进行计算的。内核只关心一件事：我已经把数据包给你了（调用 <code>xmit</code> 接口），请你把它发出去。</li>
</ul>
</li>
<li>高内聚/低耦合：内核与驱动是极端的低耦合。我们可以随意更新显卡驱动，而无需重新编译整个内核系统。反而，内核升级时，只要不改变驱动接口（保持 ABI 稳定），老的驱动模块就可以继续工作。</li>
</ul>
<h5>计算机网络：TCP/IP 协议栈</h5>
<p>我们之前提到的 OSI 七层协议和 TCP/IP 四层协议，即是分层的完美实践体现，也是模块化的典范。协议栈中的每一层就是一个模块，它们之前都定义了数据传递接口，使得每一层的关注点分离，从而实现了高内聚低耦合。</p>
<h5>数据库系统：SQL 与存储</h5>
<p>数据库系统也是一样的，以 MySQL 为例，可插拔存储引擎架构就是模块化的完美体现。</p>
<ul>
<li>模块：存储引擎（如 InnoDB、MyISAM）和 SQL 解析/优化器（Server 层）。</li>
<li>接口：MySQL 定义了一套存储引擎 API。任何存储引擎，只要实现了这套标准接口，就可以被集成为 MySQL 中。</li>
<li>封装：<ul>
<li>SQL 层的封装：优化器（Server 层模块）只负责生成最优的执行计划。它不需要知道 InnoDB 是如何实现 MVCC 的，也不需要知道 MyISAM 是如何存储索引的。它只需要通过接口手：请你从 <code>idx_user_name</code> 索引中取出数据。</li>
<li>引擎的封装：InnoDB 模块（存储引擎）只负责管理数据页、事务日志、锁。它不需要知道 SQL 是如何被解析和优化的。</li>
</ul>
</li>
<li>高内聚/低耦合：Server 层和 Storage Engine 层是两个高度解耦的模块。Server 层高度内聚，只负责 SQL 解析、优化、网络连接；InnoDB 高度内聚，只负责事务和存储。这完美将&quot;如何解析和优化 SQL&quot;和&quot;如何存储和管理数据&quot;这两个核心且复杂的关注点进行彻底分离，使得它们可以独立演进而互不干扰。</li>
</ul>
<h5>Redis：插件系统</h5>
<p>Redis Modules 同样的模块化运用的典范。</p>
<ul>
<li>模块：可加载的 .so 动态库（如 RediSearch、RedisJSON、RedisGraph）。</li>
<li>接口：<code>redismodule.h</code> 头文件。Redis 核心暴露了一整套 C API，允许模块向 Redis 注册新明了、操作内部数据结构、甚至实现新的数据类型。</li>
<li>封装：<ul>
<li>Redis 核心的封装：RediSearch 模块（全文搜索引擎）不需要知道 Redis 是如何处理网络事件循环或 RDB 快照的。它只需要通过 API 说：请帮我注册一个 FT.SEARCH 命令。</li>
<li>模块的封装：Redis 核心完全不知道 RediSearch 内部是如何构建倒排索引的。它只知道 RediSearch 是一个可加载的黑盒模块。</li>
</ul>
</li>
<li>高内聚/低耦合：Redis 通过 Modules API 将核心 K-V 功能与扩展功能完美解耦。这既保证了核心的轻量与稳定，又提供了无限扩展性。</li>
</ul>
<h5>Kafka：管道与插头的分离</h5>
<p>Kafka 的核心（Borker）是一个高内聚的模块，它只做一件事：高吞吐、可持久化的日志系统。但 Kafka 面临的挑战是：<font color="red"><u>数据如何流入（例如从 MySQL Binlog），又如何流出（例如到 S3）？</u></font>如果让 Kafka 核心团队去写所有这些连接器，他们包顶不住的，核心系统也会变得异常臃肿。那 Kafka 给出的答案就是 Kafka Connect 框架。</p>
<ul>
<li>模块：Connect 框架作为主模块，负责所有脏活累活，如容错、偏移量提交、并行化、REST API。Connecor 作为子模块，如<code>debezium-connector-mysql</code> 是一个 Source 模块，<code>kafka-connect-s3</code> 是一个 Sink 模块。</li>
<li>接口：Kafka Connect 定义了一组 API，如 SourceConnector、SinkConnector、Converter 等 Java 接口。</li>
<li>封装：<ul>
<li>Kafka 核心的封装：<code>debezium-connector-mysql</code> 模块不需要知道 Kafka Broker 是如何实现 Raft 协议或管理磁盘 Log 文件的。它只需要通过接口说：请把这个 Change Event（数据）发送到 mysql-binlog topic。</li>
<li>Connector 的封装：Kafka Broker 核心完全不知道 Debezium 是如何通过伪装成 MySQL 从库来读取 binlog 的。Broker 只认识标准的接口数据。</li>
</ul>
</li>
<li>高内聚低耦合：这种模块化使得 Kafka Broker 高度内聚，只负责高吞吐、可持久化的日志系统。所有与外部系统的集成全部被解耦到了 Connect 模块中，这使得 Kafka 成为了一个万能插座，任何系统都可以通过编写一个 Connector 模块来接入。</li>
</ul>
<h5>RAG 与 AI Agent：天生的模块化</h5>
<p>LLM 作为一个封闭的大脑，它不会使用工作，也没有长期记忆，更不知道我们私有的一些内部文档和资料。为了 AI 能更好的服务我们的实际需求，RAG 和 AI Agent 应运而生。在我看来，RAG 和 AI Agent 的架构天生就是模块的。</p>
<p>RAG 最主要就是两个模块：</p>
<ul>
<li>Retriever（检索器模块）：只负责根据查询从知识库检索相关文档</li>
<li>Generator（生成器模块，即 LLM）：只负责根据给定的上下文和查询生成答案。</li>
</ul>
<p>这使得我们可以随意替换不同的向量数据库、检索策略和 LLM。</p>
<p>AI Agent 最主要的是三个模块：</p>
<ul>
<li>Orchestrator（协调器模块 ）只负责解析 LLM 意图、循环执行。</li>
<li>LLM（大脑模块）：只负责思考和选择工具。</li>
<li>Tools（工具模块）：只负责执行一个具体的任务并给出结果。</li>
</ul>
<p>LLM 不需要知道工具是如何实现 API 调用的，它只知道这个工具的接口描述。这使得你可以无限地插拔新工具，赋予 AI Agent 无限想象的新能力。</p>
<h2>术：构建与设计的指导框架</h2>
<h3>SOLID 原则</h3>
<p>SOLID 原则由 Robert C. Martin (Uncle Bob) 提炼并推广，如果想要理解并运用好这五大原则，核心不在于我们把它们背诵得多么熟练，也不在于我们能多快速地识别现有代码符不符合哪些原则，关键是要将它们看成一个整体，去思考它们背后到底是在解决什么问题。</p>
<p>当我们聊 SOLID 原则时，我们不是在谈论五条独立的规则，而是在谈论一个统一的核心思想：在面向对象 (OOP) 范式下，如何科学地管理依赖关系，以应对软件的复杂性和持续不断的变化。</p>
<blockquote>
<p>当然，在非严格 OOP 编程语言上，也是可以借鉴类似思想的，如 Go 的 struct/interface 和 Rust 的 struct/trait。</p>
</blockquote>
<p>一个软件系统的生命周期中，最大的成本不是来自“首次开发”，而是来自“持续维护”——即修复 Bug、修改功能和添加新功能。</p>
<p>一个腐化的软件系统（高耦合、低内聚）在面对变化时，会表现出两个致命特征：</p>
<ol>
<li><strong>僵化性 (Rigidity)</strong>：改动一个地方很困难，因为它牵连着许多其他模块。</li>
<li><strong>脆弱性 (Fragility)</strong>：改动一个地方，导致系统中许多不相关的地方出现了意料之外的 Bug。</li>
</ol>
<p>SOLID 原则就是一套组合拳，它们共同的目标是创建<strong>高内聚、低耦合</strong>的模块化结构，从而战胜这两种特征。最终产出的系统应该是：</p>
<ul>
<li><strong>易于修改的 (Flexible)</strong>：添加新功能时，对现有代码的影响最小。</li>
<li><strong>易于理解的 (Understandable)</strong>：模块边界清晰，职责单一。</li>
<li><strong>易于测试的 (Testable)</strong>：模块可以被独立地隔离和测试。</li>
</ul>
<h4>单一职责原则 (SRP - Single Responsibility Principle)</h4>
<ul>
<li><strong>它解决了什么：</strong> 模块的&quot;边界&quot;问题。</li>
<li><strong>它的角色：</strong> <strong>解耦的起点</strong>。</li>
<li><strong>逻辑：</strong> 它强制我们进行<strong>拆分</strong>。它定义了一个模块（在 OOP 中通常是 Class，Go/Rust 里面是 struct）应该具有高内聚性。高内聚意味着只为一个变化的原因而存在。如果一个类混合了业务逻辑、数据持久化和日志记录，那么这三个变化的原因中任何一个发生，都可能破坏这个类。SRP 通过拆分，<strong>首先在微观上隔离了变化</strong>。</li>
</ul>
<h4>开放封闭原则 (OCP - Open/Closed Principle)</h4>
<ul>
<li><strong>它解决了什么：</strong> 系统的&quot;扩展&quot;问题。</li>
<li><strong>它的角色：</strong> <strong>解耦的目标</strong>。</li>
<li><strong>逻辑：</strong> 这是 SOLID 的核心目标。它指出系统应该对扩展开放，对修改封闭。这意味着当新需求（变化）到来时，我们应该通过<strong>添加新代码</strong>（例如实现一个新类）来完成，而不是通过<strong>修改旧的、已验证的代码</strong>。</li>
<li><strong>关键问题：</strong> OCP 只是一个目标，它没有说 <em>如何</em> 做到。SRP 拆分了模块，但 OCP 告诉我们这些模块之间必须依赖<strong>抽象</strong>，而不是具体实现。</li>
</ul>
<h4>里氏替换原则 (LSP - Liskov Substitution Principle)</h4>
<ul>
<li><strong>它解决了什么：</strong> 抽象的&quot;可靠性&quot;问题。</li>
<li><strong>它的角色：</strong> <strong>实现 OCP 的基石</strong>。</li>
<li><strong>逻辑：</strong> OCP 依赖于抽象（如接口或基类）和多态。LSP 提供了<strong>实现多态的正确性规范</strong>。它确保任何子类（具体实现）都必须能够替换其父类（抽象）而程序的行为不发生任何改变。如果一个子类的实现违反了父类的约定（例如，一个 <code>Square</code> 类继承 <code>Rectangle</code>，并重写了 <code>setHeight</code> 方法导致其 <code>Width</code> 也发生变化），那么这个抽象就是不可靠的。</li>
<li><strong>作用：</strong> LSP 是<strong>保证 OCP 得以实现的行为契约</strong>。没有 LSP，抽象就毫无意义，对修改封闭也就无从谈起。</li>
</ul>
<h4>接口隔离原则 (ISP - Interface Segregation Principle)</h4>
<ul>
<li><strong>它解决了什么：</strong> 抽象的&quot;粒度&quot;问题。</li>
<li><strong>它的角色：</strong> <strong>降低依赖的成本</strong>。</li>
<li><strong>逻辑：</strong> 即使我们有了 OCP（依赖抽象）和 LSP（抽象可靠），但如果这个抽象（接口）本身非常臃肿，它会强迫客户端（使用者）依赖它们根本不需要的方法。这种不必要的依赖会造成耦合。</li>
<li><strong>作用：</strong> ISP 告诉我们，抽象应该<strong>精细化、客户化</strong>。它本质上是 SRP 在接口设计上的应用。它通过拆分大接口，确保了依赖关系的<strong>最小化</strong>和<strong>精准化</strong>。</li>
</ul>
<h4>依赖倒置原则 (DIP - Dependency Inversion Principle)</h4>
<ul>
<li><strong>它解决了什么：</strong> 依赖的&quot;方向&quot;问题。</li>
<li><strong>它的角色：</strong> <strong>解耦的架构蓝图</strong>。</li>
<li><strong>逻辑：</strong> 这是 SOLID 的最高层指导。它规定了系统中所有依赖关系的方向。<ul>
<li>A. 高层模块（如业务策略）不应依赖低层模块（如数据库实现）。</li>
<li>B. 两者都应依赖于<strong>抽象</strong>（如接口）。</li>
</ul>
</li>
<li><strong>作用：</strong> DIP 将传统的&quot;高层 -&gt; 低层&quot;的依赖关系，<strong>倒置</strong> 为&quot;高层 -&gt; 抽象&quot;和&quot;低层 -&gt; 抽象&quot;。这使得系统的核心业务逻辑（高层）完全独立于任何具体的实现细节（低层）。<strong>这是实现对修改封闭的最强有力的架构手段</strong>。这也是 DDD 和洋葱架构的典型实现。</li>
</ul>
<h4>总结</h4>
<p>SOLID 原则提供了一套完整的、从微观到宏观的解耦策略：</p>
<ul>
<li><strong>SRP</strong> 负责创建高内聚的模块。</li>
<li><strong>OCP</strong> 设定了依赖抽象的最终目标。</li>
<li><strong>LSP</strong> 保证了这些抽象的实现是可靠和可替换的。</li>
<li><strong>ISP</strong> 保证了这些抽象本身是精简和低耦合的。</li>
<li><strong>DIP</strong> 最终定义了整个系统的架构，确保了依赖关系朝向正确的（即稳定的）方向。</li>
</ul>
<h3>设计模式</h3>
<h4>设计模式清单</h4>
<ol>
<li><p>创建型模式 (Creational Patterns)：这些模式提供了不同种类的对象创建机制，使得一个系统在运行时可以选择其中的一个适当的创建方法来创建对象。</p>
<ul>
<li>单例模式 (Singleton Pattern)：确保一个类只有一个实例，并提供全局访问点来访问该实例。</li>
<li>工厂模式 (Factory Pattern)：定义一个用于创建对象的接口，让子类决定实例化哪个类来创建对象。</li>
<li>抽象工厂模式 (Abstract Factory Pattern)：提供一个创建一系列相关或相互依赖对象的接口，而无需指定它们具体的类。</li>
<li>建造者模式 (Builder Pattern)：将一个复杂对象的构造与其表示分离，使得同样的构造过程可以创建不同的表示。</li>
<li>原型模式 (Prototype Pattern)：通过复制现有的实例来创建新实例。</li>
</ul>
</li>
<li><p>结构型模式 (Structural Patterns)：这些模式描述如何将类或对象组合成更大的结构，以满足特定的需求。</p>
<ul>
<li>适配器模式 (Adapter Pattern)：将一个类的接口转换成客户希望的另外一个接口。适配器模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。</li>
<li>桥接模式 (Bridge Pattern)：将抽象部分与它的实现部分分离，使得它们都可以独立地变化。</li>
<li>装饰器模式 (Decorator Pattern)：动态地给一个对象添加一些额外的职责。就增加功能而言，装饰器模式比生成子类更为灵活。</li>
<li>组合模式 (Composite Pattern)：将对象组合成树形结构以表示“部分-整体”的层次结构。</li>
<li>外观模式 (Facade Pattern)：为子系统中的一组接口提供一个一致的界面，该模式定义了一个高层接口，这个接口使得这一子系统更加容易使用。</li>
<li>享元模式 (Flyweight Pattern)：运用共享技术有效地支持大量细粒度的对象。</li>
<li>代理模式 (Proxy Pattern)：为其他对象提供一种代理以控制对这个对象的访问。</li>
</ul>
</li>
<li><p>行为型模式 (Behavioral Patterns)：这些模式涉及到算法和对象间职责的分配，并描述了在对象之间的通信模式。</p>
<ul>
<li>责任链模式 (Chain of Responsibility Pattern)：使多个对象都有机会处理请求，从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链，并沿着这条链传递该请求，直到有一个对象处理它为止。</li>
<li>命令模式 (Command Pattern)：将请求封装成对象，从而让你使用不同的请求、队列或者日志来参数化其它对象。命令模式也可以支持撤销操作。</li>
<li>解释器模式 (Interpreter Pattern)：给定一个语言，定义它的文法的一种表示，并定义一个解释器，该解释器使用该表示来解释语言中的句子。</li>
<li>迭代器模式 (Iterator Pattern)：提供一种方法顺序访问一个聚合对象中的各个元素，而又不暴露该对象的内部表示。</li>
<li>中介者模式 (Mediator Pattern)：用一个中介对象封装一系列的对象交互。中介者使得各个对象之间不需要显式地相互引用，从而使其耦合松散，而且可以独立地改变它们之间的交互。</li>
<li>备忘录模式 (Memento Pattern)：在不破坏封装性的前提下，捕获一个对象的内部状态，并在该对象之外保存这个状态。这样以后就可以将该对象恢复到原先保存的状态。</li>
<li>观察者模式 (Observer Pattern)：定义了对象之间的一对多依赖关系，这样一来，当一个对象改变状态时，它的所有依赖者都会收到通知并自动更新。</li>
<li>状态模式 (State Pattern)：允许对象在内部状态改变时改变它的行为，对象看起来似乎修改了它所属的类。</li>
<li>策略模式 (Strategy Pattern)：定义一系列算法，把它们一个个封装起来，并使它们可以相互替换。本模式使得算法的变化可独立于使用它的客户端。</li>
<li>模板方法模式 (Template Method Pattern)：定义一个操作中的算法的骨架，而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法结构即可重定义该算法的某些特定步骤。</li>
<li>访问者模式 (Visitor Pattern)：表示一个作用于某对象结构中的各元素的操作，它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。</li>
</ul>
</li>
</ol>
<blockquote>
<p>设计模式的代码实战可参考：<a href="https://github.com/hedon954/go-designmode">https://github.com/hedon954/go-designmode</a></p>
</blockquote>
<h4>理解设计模式</h4>
<p>看完前面梳理的 23 种设计模式，相信大多数人跟我一样头都大了，即便我已经做了简单的分类。我一直在思考如何更好地理解和运用设计模式，从而写出更加优雅的代码。AI 的出现，真的让我感觉非常幸运，AI 可以很好地从第一性原理和根本源头上对设计模式进行展示和阐述，所以在我跟 AI 进行深入探讨之后，我对设计模式的理解又更进一步了，这里做一下简单总结。</p>
<p>从第一性原理出发，当我们谈论设计模式时，我们主要在谈论两件事：</p>
<ol>
<li><strong>一个共享的词汇库</strong>：设计模式提供了一套高带宽、无歧义的专业词汇，它让我们谈论复杂抽象的方案时，就像谈论变量或函数一样简单。</li>
<li><strong>一套经验的结晶</strong>：设计模式就是把这些被反复验证、证明是健壮的、优雅的解决方案提取出来，并给它们命了名。</li>
</ol>
<p>所以，设计模式<strong>不是最佳实践的清单</strong>，而是<strong>在特定上下文 (Context)中，针对特定问题 (Problem)的一种解决方案 (Solution)</strong>。它本质上是前人经验的固化。</p>
<p>理解 23 种设计模式的最好方法，不是去背诵它们，而是去<strong>分类</strong>和<strong>抓意图</strong>。你不需要记住 23 种模式的实现细节，你只需要理解 23 种<strong>问题</strong>，以及它们分别属于哪一类<strong>意图</strong>。</p>
<h5>创建型模式</h5>
<blockquote>
<p>如何才能在<strong>不暴露创建细节</strong>的情况下，<strong>灵活且可控地创建对象</strong>？ —— <strong>解耦对象的创建过程</strong></p>
</blockquote>
<ol>
<li><strong>Singleton (单例模式)</strong>：我需要保证这个类在整个应用程序中，<strong>有且仅有一个实例</strong>（比如，配置管理器、日志记录器）。</li>
<li><strong>Factory Method (工厂方法)</strong>：我有一个基类（或接口），但我<strong>不想让客户端</strong>（调用方）<strong>直接</strong> <code>new</code> 它的某个具体子类。我想把这个 <code>new</code> 的决定权<strong>推迟到子类</strong>去做。</li>
<li><strong>Abstract Factory (抽象工厂)</strong>：我需要创建<strong>一系列相互关联的对象</strong>（一个产品族，比如 <code>UI</code> 的深色主题需要 <code>DarkButton</code>、<code>DarkCheckbox</code>），并且我想<strong>一键切换</strong>整个产品族（比如一键切换到浅色主题）。</li>
<li><strong>Builder (建造者模式)</strong>：我要创建的这个对象<strong>太复杂了</strong>，它的构造函数有<strong>一大堆参数</strong>，其中很多还是可选的。我不想写一堆重载的构造函数，也不想让对象在创建过程中处于不完整状态。（在 Go 或 Rust 中可能更熟悉的是 <code>Option</code> 模式，这是 Builder 的一种变体）</li>
<li><strong>Prototype (原型模式)</strong>：创建一个新对象的<strong>成本非常高</strong>（比如涉及 I/O 或复杂的计算）。如果我有一个已有的对象，通过**复制（clone）**它来创建新对象会快得多。</li>
</ol>
<h5>结构型模式</h5>
<blockquote>
<p>如何才能<strong>灵活地组合</strong>类与对象，形成<strong>更大的、功能更强的结构</strong>？—— <strong>解耦对象的组合方式</strong></p>
</blockquote>
<ol>
<li><strong>Adapter (适配器模式)</strong>：我有一个现成的类（A），它的功能很棒，但我<strong>无法直接使用</strong>，因为客户代码要求的是另一个<strong>不兼容的接口</strong>（B）。我需要一个&quot;转换插头&quot;。</li>
<li><strong>Decorator (装饰器模式)</strong>：我想在<strong>不修改</strong>一个类（或对象）的代码的前提下，<strong>动态地</strong>给它<strong>添加</strong>新的功能（职责）。而且我想可以<strong>层层嵌套</strong>地添加（比如 <code>Buffered</code> -&gt; <code>Gzipped</code> -&gt; <code>FileInputStream</code>）。</li>
<li><strong>Proxy (代理模式)</strong>：我不想让客户端<strong>直接</strong>访问某个对象。我想在中间加一层代理，来<strong>控制</strong>对这个对象的访问（比如，权限检查、懒加载、日志记录、RPC）。</li>
<li><strong>Facade (外观模式)</strong>：我这里有一个<strong>非常复杂的子系统</strong>，内部有一堆类和复杂的调用关系。我只想给客户端提供一个<strong>极其简单的、统一的访问入口</strong>。</li>
<li><strong>Bridge (桥接模式)</strong>：我有<strong>两个独立变化的维度</strong>（比如形状和颜色）。我不想用继承（比如 <code>RedCircle</code>, <code>BlueCircle</code>, <code>RedSquare</code>… 导致类的爆炸），我想把这两个维度<strong>分开</strong>，让它们<strong>各自独立演化</strong>。</li>
<li><strong>Composite (组合模式)</strong>：我需要处理一个<strong>树形结构</strong>（比如文件系统的文件和文件夹）。我希望能够用<strong>完全相同的方式</strong>（同一个接口）来对待单个对象（叶节点）和对象组合（分支节点）。</li>
<li><strong>Flyweight (享元模式)</strong>：我需要创建<strong>海量</strong>的小对象，它们绝大多数的<strong>内部状态</strong>都是相同的。为了<strong>节省内存</strong>，我想把这些相同的状态<strong>共享</strong>（复用）起来。</li>
</ol>
<h5>行为型模式</h5>
<blockquote>
<p>如何才能高效地<strong>分配职责</strong>，并管理对象之间<strong>复杂的通信</strong>？ —— <strong>解耦对象间的通信与职责</strong></p>
</blockquote>
<ol>
<li><strong>Strategy (策略模式)</strong>：我有一堆 <code>if...else if...else</code> 或者一个巨大的 <code>switch</code>，它们在根据不同条件<strong>选择不同的算法</strong>或行为。我想把这些<strong>算法</strong>（策略）<strong>独立</strong>出来，让它们可以<strong>互相替换</strong>。</li>
<li><strong>Observer (观察者模式)</strong>：我有一个&quot;主题&quot;对象，当它的状态发生变化时，需要<strong>自动通知</strong>其他<strong>所有</strong>依赖它的&quot;观察者&quot;对象，但我又不想让&quot;主题&quot;<strong>直接</strong>知道&quot;观察者&quot;的具体实现（实现广播式解耦）。</li>
<li><strong>Command (命令模式)</strong>：我想把一个<strong>操作（请求）封装成一个对象</strong>。这样我就可以把这个&quot;命令&quot;<strong>传递</strong>、<strong>排队</strong>、<strong>记录日志</strong>，甚至实现<strong>撤销（Undo）</strong>。</li>
<li><strong>Template Method (模板方法)</strong>：我有一个算法，它的<strong>骨架（步骤）是固定不变的</strong>，但其中<strong>一两个步骤</strong>的具体实现是<strong>易变</strong>的。我想在基类中定义好&quot;骨架&quot;，让子类去实现那些&quot;易变&quot;的步骤。</li>
<li><strong>Iterator (迭代器模式)</strong>：我有一个<strong>聚合对象</strong>（比如 List, Map, Set），我想让客户端能够<strong>遍历</strong>它，但又<strong>不想暴露</strong>它的<strong>内部实现细节</strong>。</li>
<li><strong>Mediator (中介者模式)</strong>：我有一堆对象，它们之间<strong>互相通信</strong>，形成了一个<strong>复杂的网状结构</strong>（M-N 关系），导致高耦合。我想引入一个&quot;中介&quot;，让所有对象只和&quot;中介&quot;通信（M-1-N），<strong>简化</strong>这个通信网。</li>
<li><strong>State (状态模式)</strong>：一个对象的<strong>行为</strong>完全取决于它的<strong>内部状态</strong>。我现在的代码里有<strong>一堆 <code>switch</code></strong> 在检查&quot;当前状态&quot;来决定下一步做什么。我想把每种&quot;状态&quot;下的行为封装成<strong>独立</strong>的类。</li>
<li><strong>Chain of Responsibility (责任链模式)</strong>：一个请求需要被<strong>多个对象</strong>中的<strong>某一个</strong>处理。但我不确定是哪一个，或者我想让它们<strong>依次尝试</strong>处理（比如 <code>HTTP</code> 中间件）。我想把这些对象<strong>串成一条链</strong>，让请求沿着链传递下去。</li>
<li><strong>Visitor (访问者模式)</strong>：我有一组<strong>稳定的</strong>对象结构（比如一个语法树），但我想为它们添加<strong>各种各样的新操作</strong>（比如类型检查、代码生成）。我不想每加一个操作就去<strong>修改</strong>那些稳定的对象类。</li>
<li><strong>Memento (备忘录模式)</strong>：我需要<strong>保存</strong>一个对象的<strong>内部状态</strong>（创建快照），以便在未来某个时刻能<strong>恢复</strong>到这个状态（比如实现撤销或存档），同时我又不希望<strong>暴露</strong>这个对象内部的实现细节。</li>
<li><strong>Interpreter (解释器模式)</strong>：我需要为一个<strong>简单的语言</strong>（比如正则表达式、SQL 查询）构建一个<strong>解释器</strong>。（这是最不常用的模式之一，通常有现成的工具）</li>
</ol>
<h4>用好设计模式</h4>
<p>我觉得想要用好设计模式，只有一个途径，就是多用，甚至是刻意多用，也就是&quot;手里拿着锤子，看什么都是钉子&quot;那样的多用。用对了，你才能真实体验到设计模式给你带来的收益，你才会更理解它们的由来，你也才会更愿意在这方面花更多的思考和实践。用错了，发现过度设计了，发现代码变得更难理解和维护了，你才能真正感受到理论与实践的差距，你才能从另外一个角度去更全面理解你所运用的设计模式。当然，这种刻意多用，最好更多是在自己的个人项目中，而不是在工作项目上，因为后者的犯错成本要更高，风险也相应更大。当然，工作上的使用，总有第一次，所以不妨大胆一点，只要你是在思考，只要你是在努力做好事情，我觉得，一切都是不亏的。</p>
<p>我很庆幸在我刚入职两三个月的时候，就接手了重构一坨屎山代码的重任，并且在我使用模板方法设计模式对其进行彻底重构后，代码变得极其优雅并在后面的两年多中持续为我带来收益。这些体验和正反馈，让我对设计模式一直有一层滤镜，使得我这三年来一直愿意主动去思考如何将代码写得更加优雅。</p>
<p>这个项目是这样的，我们对接了 20 多个广告商，每个广告商下面有多个不同公司主体下的多个不同 APP，即存在 3 个维度，我们要去请求广告商的 API 去统一汇总所有 APP 的广告收入数据。之前的人开发的时候就是纯复制粘贴，重复代码直接爆了，而且相同步骤还存在非常多不一致的逻辑，这给代码阅读、问题排查、新增广告商/公司主体/APP、业务数据诉求等方面都带来了究极折磨。我发现其实所有广告数据获取都遵循这样一个步骤：<u>请求数据、格式统一、合并数据、异常处理、转存数据</u>。我发现只有请求数据和格式统一这两步是跟广告商 API 强相关且必须单独定制开发的，其他都是一样的逻辑。所以我就采用了<strong>模板方法设计模式</strong>，对这个流程进行了抽象和重构，并且由于骨架非常固定，我还顺带开发了代码生成 CLI 工具，进一步提高开发效率。就这样简单套用了一个设计模式，整个代码的风格和简洁度，焕然一新，又由于架构的简洁统一，使得后续的数据修复、问题排查、新增需求等操作都非常简单和高效高质量。</p>
<p>反面例子也有，我们组内其他同学在重构匹配服的时候，由于对接口定义理解的不足，同时对状态模式、策略模式的理解不足，但又强行套用，同时又有很多其他不必要的抽象操作，我称之为炫技。这一顿操作导致了我们的新匹配服过度抽象、接口定义不合理、架构混乱，进而导致了代码可读性较差、新人接手难度高等一系列问题。但是坦白说，这个失败的例子给我带来的收获和思考，并不比上面提到的成功的例子少。</p>
<p>&quot;手里拿着锤子，看什么都是钉子&quot;是我们刚接触设计模式时的通病，这往往会导致过度设计。我个人觉得想要减少&quot;硬套&quot;设计模式的核心原则是 ：<strong><u>永远让问题驱动模式，而不是反过来</u></strong>。</p>
<ol>
<li><strong>KISS (Keep It Simple, Stupid) 优先：</strong> 永远先写出最简单、最直白的代码。不要一开始就思考我该用哪个模式。</li>
<li><strong>YAGNI (You Ain&#39;t Gonna Need It) 原则：</strong> 不要为了未来可能的扩展性而去应用一个复杂的模式。如果现在简单的 <code>if...else</code> 就能解决问题，并且没有明确的迹象表明它马上会变得复杂，那就用 <code>if...else</code>。</li>
<li><strong>把模式当作重构的手段：</strong> 这是应用模式的最佳时机。<ul>
<li>你的简单代码跑起来了。</li>
<li>随着需求（变化）的到来，你的简单代码开始变得腐化。</li>
<li><strong>此时</strong>，代码的坏味道已经清晰地暴露了问题。</li>
<li><strong>现在</strong>，你才应该引入设计模式，作为一种<strong>重构</strong>手段，去解决这个已经<strong>实际发生</strong>的、而不是臆想出来的设计问题。</li>
</ul>
</li>
<li><strong>评估引入的成本：</strong> 没有任何模式是银弹。<ul>
<li><strong>Factory</strong> 带来了灵活性，但也增加了类的数量。</li>
<li><strong>Observer</strong> 实现了完美的解耦，但也让程序的控制流变得难以追踪（回调地狱）。</li>
<li><strong>Singleton</strong> 简化了访问，但也引入了全局状态，使测试变得极其困难。</li>
</ul>
</li>
</ol>
<p>当你决定要套用一个模式时，必须明确地问自己：<strong>为了解决我眼前的这个问题，我是否愿意支付这个模式带来的额外复杂性的代价？</strong></p>
<h3>架构模式</h3>
<h4>什么是架构</h4>
<p>架构这个词，很多人都在谈，那到底什么是架构呢？架构师又是做啥的呢？<a href="https://book.douban.com/subject/37055698/">《P9 工作法：夯实技术硬实力、架构力和领导力》</a>一书总结得非常好。</p>
<p>书中说，架构师就是<u>运用技术架构的思维框架深入分析业务需求，识别关键问题，并通过持续的演进和迭代来提升系统能力，以支持业务实现商业成功</u>。可以用两组词来表述架构的概念：模块与关系、过程与结果。</p>
<ul>
<li><strong>模块与关系</strong>：软件架构是由哪些模块组成，这些模块由哪些领域模型组成，每个模块的权责边界是什么，以及模块间如何协作。</li>
<li><strong>过程与结果</strong>：软件架构是一个动词，代表一系列决策过程。这些决策主要从全局和未来视角出发，寻找解决实际问题的最佳架构。这就是“架构即过程”的含义。同时，软件架构也是一个名词，是技术解决实际问题、支撑业务发展的结果，也是不同角色进行协作的界面。</li>
</ul>
<p>当我们聊架构设计的时候，我们其实是在谈论一个完整的生命周期，我将其概括为以下 6 个步骤：</p>
<ol>
<li><strong>理解商业与组织上下文：</strong> 我们在谈论深入挖掘利益相关方的真实诉求、明确用户核心痛点、对齐关键商业指标，并诚实评估我们团队现有的技术栈与组织能力。</li>
<li><strong>定义架构特性与约束：</strong> 我们在谈论从性能、可用性、成本等众多特性中，识别出对本次设计最关键的 3-5 个，并清晰定义那些不可逾越的约束红线，以此作为后续所有技术权衡 (trade-off) 的核心基准。</li>
<li><strong>探索方法与决策：</strong> 我们在谈论通过系统地探索多种可选方案、进行客观的利弊权衡与风险评估，最终做出理性的技术决策并将其（例如使用 ADR）沉淀为文档。</li>
<li><strong>设计实施路径与验证机制：</strong> 我们在谈论如何将架构蓝图转化为可执行的实施计划，包括通过 PoC 验证关键难点、拆解任务与里程碑，并通过构建适应度函数来持续验证架构特性的落地。</li>
<li><strong>部署、观测与效果衡量：</strong> 我们在谈论通过 CI/CD 将设计交付上线，并借助 APM 和业务指标监控来实时观测系统的运行状态与商业效果，以此获取最真实的反馈。</li>
<li><strong>复盘、沉淀与演进：</strong> 我们在谈论对线上问题进行彻底的根因分析、将经验教训沉淀为改进后的流程与原则，最终推动人员与组织的共同成长，并为下一轮架构演进做好准备。</li>
</ol>
<h4>架构选择的两大原理</h4>
<ul>
<li>第一原理：一切都是权衡。</li>
<li>第二原理：为什么比如何更重要。</li>
</ul>
<h4>架构原则</h4>
<ul>
<li><strong>KISS (Keep It Simple, Stupid) 原则：</strong> 在所有解决方案中，优先选择最简单、最清晰的那一个。</li>
<li><strong>YAGNI (You Ain&#39;t Gonna Need It) 原则：</strong> 只实现你当前明确需要的功能，不要为&quot;未来可能的需求&quot;编写代码。</li>
<li><strong>DRY (Don&#39;t Repeat Yourself) 原则：</strong> 确保系统中的每一处知识（逻辑、数据）都只有一个权威的、明确的表示。</li>
<li><strong>TDA (Tell, Don&#39;t Ask) 原则：</strong> 你应该&quot;告诉&quot;对象去做事，而不是&quot;询问&quot;它的内部状态来替它做决策。</li>
<li><strong>SoC (Separation of Concerns) 原则：</strong> 将一个复杂的系统划分为多个独立的、只关注一个方面的模块。</li>
<li><strong>LoD (Law of Demeter) 原则：</strong> 一个对象应该尽可能少地了解其他对象的内部结构，只与其必要部分通信。</li>
</ul>
<p>这些原则共同服务于一个目标：<strong>创建一个易于理解、易于修改、易于维护的系统</strong>，从而在软件的整个生命周期内，<strong>最大化地控制住&quot;复杂度&quot;这个敌人</strong>。</p>
<p>你可以按照下面的思路在运用这六大原则：</p>
<ol>
<li>当一个新需求来了，你首先用 <strong>YAGNI</strong> 和 <strong>KISS</strong> 来过滤它：我们真的需要它吗？我们能用最简单的方法实现它吗？</li>
<li>一旦决定要做，你用 <strong>SoC</strong> 来划分它的边界：这个功能应该属于哪个关注点？它是一个新模块吗？</li>
<li>在实现这个模块时，你用 <strong>DRY</strong> 来避免内部的重复代码，通过抽象来保证知识的唯一性。</li>
<li>当这个模块需要与外部模块通信时，你用 <strong>LoD</strong> 和 <strong>TDA</strong> 来指导你的交互设计：只和邻居说话（LoD），并且是告诉它们做事（TDA），而不是打听它们的内部状态。</li>
</ol>
<h4>常用架构模式</h4>
<p>这里我梳理了<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>一书提到的最常用、最经典的架构模式，具体的描述和权衡之道可以参考我梳理的笔记：<a href="https://hedon.top/2025/07/24/note/note-fosa/">读书笔记丨《Fundamentals of Software Architecture》</a>。</p>
<ol>
<li><strong>分层架构</strong>：分层架构的核心驱动力是关注点分离（Separation of Concerns）。它将一个复杂的系统按照不同的职责或技术关注点，垂直地划分成若干个水平的“层（Layer）”。</li>
<li><strong>管道架构</strong>：又称为管道与过滤器架构（Pipes and Filters Architecture），是一种用于处理数据流的强大模式。它的核心思想非常直观，就像一条工厂的流水线：原材料从一端进入，经过一系列独立工站的加工、处理、检验，最终在另一端形成成品。</li>
<li><strong>微核架构</strong>：也被称为插件化架构（Plug-in Architecture），是一种能够提供极高扩展性、灵活性和演化能力的系统设计模式。它的核心思想是将系统功能划分为两部分：一个最小化的、稳定的核心系统（Core System）和一个由独立插件组件（Plug-in Components）构成的可扩展生态。</li>
<li><strong>基于服务的架构</strong>：本质是一种将一个大型的单体应用，分解为少数几个、逻辑独立的、可独立部署的&quot;服务&quot; 的架构风格。SBA 的服务数量通常不多，一般在 4 到 12 个之间。它不像微服务那样追求极致的拆分（可能会有几十上百个服务），而是将应用按照核心的业务领域进行划分。</li>
<li><strong>事件驱动架构</strong>：对特定情况做出反应，并根据该事件采取行动。分为代理模式（broker）和中介者模式（mediator）两种模式，二者最大的区别在于后者具有一个统一的协调者，这会对异常处理、全局统筹有很好的管控手段，当同时也牺牲了系统的解耦程度、灵活度和性能。</li>
<li><strong>空间架构</strong>：名称来源于元组空间（Tuple Space）多个并行处理器通过共享内存进行通信。SBA 的核心理念便是将应用数据保存在内存中（in-memory），并在所有活跃的处理单元（Processing Units）复制，从而移除中心数据库作为同步约束，实现近乎无限的伸缩性。</li>
<li><strong>微服务架构</strong>：核心在于高度解耦。它倾向于复制而非耦合。这意味着，如果架构师的目标是高度解耦，那么他们会选择复制而不是重用。微服务通过物理上建模限界上下文（Bounded Context）的逻辑概念来实现高度解耦。</li>
</ol>
<h3>领域驱动设计</h3>
<p>在复杂度管理的术篇最后，我想用 DDD（领域驱动设计）来收个尾。很遗憾我并没有在上份工作中积累 DDD 的相关经验，我们的业务复杂度其实已经到了难以管理甚至失控的程度了，领导也提出了要尝试使用 DDD 来进行治理，不过后面也不知为何就搁置了。团队这种习惯性有头无尾的风格，也是我下定决定离开的原因之一。</p>
<h4>DDD 存在的意义</h4>
<p>话回正题，我们一直在谈论复杂度管理。软件的复杂度有两个来源：</p>
<ol>
<li><strong>技术复杂度</strong>：由技术选型、框架、性能、并发等引入的复杂度。</li>
<li>**领域复杂度 **：业务本身固有的复杂度。比如一个电商系统的&quot;优惠券计算规则&quot;，一个金融系统的&quot;估值模型&quot;，一场联机游戏的&quot;结算过程&quot;。</li>
</ol>
<p>为什么传统开发的&quot;术&quot;在业务发展到一定规模的时候，在管理复杂度时往往会失效呢？</p>
<p>在很多项目中，我们花了大量时间在技术复杂度上，而对领域复杂度的处理，往往是数据驱动的：先设计数据库表 (DAO/Models)，然后写服务 (Service)，最后写接口 (Controller)。这种方式在业务初期很简单。</p>
<p>但随着业务发展，业务规则会变得极其复杂（比如，一场联机游戏的结算，可能要调用 10 个微服务，涉及 20 张表，处理 30 种运营活动策略）。</p>
<p>此时，业务逻辑被<strong>切割</strong>并<strong>分散</strong>在各个 Service、Helper、Utils 甚至 Controller 层中。代码（技术实现）与真实的业务（领域）之间的<strong>认知鸿沟</strong>越来越大。最终，系统变得无法维护，因为<strong>没有人能说清楚一个完整的业务流程到底是怎么运作的</strong>。</p>
<p><strong>软件的核心是其为用户解决的领域问题</strong>。因此，管理复杂度的根本，在于<strong>精准地捕获、表达和隔离领域复杂度</strong>。它要求我们从技术实现驱动转向领域模型驱动。这便是 DDD 存在的意义。</p>
<h4>DDD 两大核心</h4>
<p>要想理解 DDD 的核心思想，重点在于弄清楚它的战略设计和战术设计，以及其背后的第一性原理。</p>
<h5>战略设计：在宏观上划分战场</h5>
<p>这是 DDD 最重要的部分，它决定了系统的宏观架构。</p>
<ul>
<li><strong>统一语言 (Ubiquitous Language)</strong>：统一语言是业务专家、产品经理和开发团队在同一个限界上下文中共同锤炼、严格遵守的、无歧义的词汇表，它贯穿于所有沟通、文档和代码实现之中，是构建领域模型的基石。</li>
<li><strong>限界上下文 (Bounded Context)</strong>：限界上下文是一个明确的业务边界（比如一个子系统或微服务），它封装并保护一个独立的领域模型，确保&quot;统一语言&quot;在该边界内的含义是唯一且自洽的，从而允许不同上下文对同一业务概念（如商品）拥有不同的模型。</li>
<li><strong>上下文映射 (Context Map)</strong>：上下文映射是一种宏观架构图，它通过定义不同限界上下文之间的集成模式（如防腐层、共享内核或遵从者）来清晰地描绘它们之间的技术依赖和团队组织关系，从而在战略层面管理跨模型的集成复杂度。</li>
</ul>
<h5>战术设计：在微观上保护模型</h5>
<p>当我们通过战略设计划分好了边界之后，战术设计提供了具体的编码方式，来<strong>确保</strong>我们在代码中实现的模型不被破坏。其中最核心的有三点：</p>
<ul>
<li><strong>聚合 (Aggregate)</strong>：聚合是将一个或多个实体与值对象（如订单和订单项）组合成一个业务上的一致性单元，外界只能通过其聚合根这唯一入口来访问，从而强制封装所有业务规则（不变量）并确保其作为一个整体被事务性地持久化。</li>
<li><strong>值对象 (Value Object)</strong>：值对象是一种通过其属性（而非唯一 ID）来定义的对象（如金额或地址），它被设计为不可变的以消除副作用，并在领域模型中承载那些用于度量、描述或限定业务概念的值。</li>
<li><strong>资源库 (Repository)</strong>：资源库是定义在领域层的一个接口，它通过模拟一个内存中的集合来封装数据持久化的所有技术细节，其具体实现（如 SQL 查询）则被隔离在基础设施层，从而使领域模型（尤其是聚合根）保持纯洁，无需关心数据是如何存取的。</li>
</ul>
<p>目前我对于 DDD 的理解和实践仅在于阅读了<a href="https://book.douban.com/subject/37102014/">《悟道领域驱动设计》</a>一书，感兴趣的读者可以参考我梳理的<a href="https://hedon.top/2025/03/11/note/note-ddd-awareness/">读书笔记丨《悟道领域驱动设计》</a>。</p>
<h2>器：验证与洞察的质量手段</h2>
<p>如果说心法是道，术是招式，那么器就是&quot;眼睛&quot;和&quot;标尺&quot;。没有器，我们永远不知道招式打得对不对，也无从得知我们的道是不是走偏了。这里我想重点总结我认为 2 个最重要的工具：<strong>单元测试</strong>和<strong>可观测性</strong>。这也是我在第一份工作中做的最有成就感、也是我进步最大的两个专项：代码质量建设专项和服务监控建设专项。</p>
<p>我之所以认为单元测试和可观测性是管理软件复杂度的两大利器，是因为它们分别为软件生命周期中两个截然不同的复杂度阶段——<strong>静态复杂度</strong>和<strong>动态复杂度</strong>——提供了必不可少的<strong>反馈与控制机制</strong>。</p>
<ul>
<li><strong>静态复杂度</strong>：代码在&quot;写下时&quot;的复杂度。它关乎代码的结构、依赖、正确性和可读性。</li>
<li><strong>动态复杂度</strong>：系统在&quot;运行时&quot;的复杂度。它关乎成千上万个模块交互时所<strong>涌现</strong>出的、难以预测和跟踪的行为。</li>
</ul>
<h3>单元测试</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/81R7v9lnljL._CR2,0,1276,720_SR684,386_.jpeg" alt="Unit Testing Principles, Practices, and Patterns"></p>
<p>这里我强烈建议所有软件工程师都去阅读<a href="https://book.douban.com/subject/34429421/">《Unit Testing Principles, Practices, and Patterns》</a>这本书！绝世好书！而且最好的阅读英文原版！我使用了 2 个月的时间（每天 1 个小时）完完整整阅读了这本书 2 次，它对我在单元测试和代码质量上的理解和实践能力都起到了非常大的帮助。</p>
<p>这里我就不再重复此书的内容，但是如果你曾经或是现在依旧被以下问题所困扰的话，建议你去仔细阅读一下这本书，也可以参考我整理的<a href="https://hedon.top/2025/04/09/note/note-unit-testing/">读书笔记丨《Unit Testing Principles, Practices, and Patterns》</a>。</p>
<ol>
<li>为什么要写单元测试？单元测试的目标是什么？</li>
<li>单元测试的粒度是怎样的？什么叫单元？a class, a function, or a behavior, or an observable behavior?</li>
<li>单测覆盖率真的有用吗？有什么用？又有哪些限制？</li>
<li>怎样才能写好单元测试？怎样才能写出性价比最高的单元测试？</li>
<li>如何判断一个单元测试的好坏？有没有具体可供参阅的维度？</li>
<li>哪些代码需要写单元测试，哪些代码没必要写单元测试？</li>
<li>单元测试和集成测试的边界是什么？</li>
<li>（单元丨集成）测试到底是要测什么东西？</li>
<li>单元测试的侧重点是什么？集成测试的侧重点是什么？二者的比例该是怎样的？</li>
<li>如何使用 Mock？哪些东西是需要 Mock 的？哪些东西是不应该 Mock 的？需要 Mock 的东西，应该在哪个层次进行 Mock？（你的 repository 层需要 Mock 吗？）</li>
<li>为什么你的测试代码很脆弱，总是需要频繁修改，维护起来难度很大？</li>
<li>如何减少测试结果的假阳性和假阴性？</li>
</ol>
<p>本篇我想强调的是，单元测试的价值<strong>远远大于</strong>找 Bug。它首先是一种<strong>设计工具</strong>，其次才是一种<strong>测试工具</strong>。它在三个层面上管理了静态复杂度。</p>
<p><strong>1. 它是高内聚低耦合的设计反馈机制</strong></p>
<p>在软件设计中，高内聚、低耦合（<code>模块化</code>心法）是最重要的目标之一。单元测试是检验这一目标是否达成的<strong>第一个，也是最快的反馈工具</strong>。</p>
<p>当你试图为一个模块（一个函数或一个类）编写单元测试时，如果发现测试很难写，这就是一个明确的设计缺陷信号。难写通常意味着该模块<strong>依赖了过多具体实现</strong>（高耦合），而不是依赖抽象（接口）。例如，你为了测试 <code>A</code>，不得不去实例化 <code>B</code>、<code>C</code>、<code>D</code> 等多个真实对象。</p>
<p>为了使 <code>A</code> 变得可测试，工程师<strong>被迫</strong>使用<code>抽象</code>心法和<code>依赖倒置</code>（术）。不再让 <code>A</code> 直接依赖 <code>B</code>，而是依赖一个 <code>IB</code> 接口。这样，在测试中就可以传入一个模拟（Mock）的 <code>B</code>。</p>
<p>这个时候，单元测试反向强迫工程师在设计时就必须遵守&quot;低耦合&quot;和&quot;强抽象&quot;的心法和术。</p>
<p><strong>2. 它是封装和重构的安全保障</strong></p>
<p>软件的复杂度会随时间腐化。封装（<code>抽象</code>心法）的目的是隐藏内部实现，以便未来可以安全地修改它。单元测试是实现这一目标的<strong>安全保障</strong>。</p>
<p>当一个模块拥有完备的单元测试覆盖时，工程师（尤其是新接手的工程师）获得了<strong>重构的信心</strong>。他们可以<strong>大胆地</strong>修改模块的内部实现（例如优化算法、更换数据结构），而<strong>无需</strong>在认知上承载该模块的全部历史逻辑。</p>
<p>只要在重构后，所有的单元测试依然通过，工程师就能获得极大的信心——<strong>内部实现被优化了，但外部承诺未被破坏</strong>。这从根本上抑制了代码的腐化，管理了维护的复杂度。</p>
<p><strong>3. 它是模块边界的精确定义</strong></p>
<p>文档会过时，但代码不会。单元测试是一种<strong>可执行的、活的文档</strong>。</p>
<p>一个写得好的测试用例（例如 <code>Test_Login_Fails_When_Password_Incorrect</code>），它以代码的形式，<strong>精确地、无歧义地</strong>定义了登录模块这个抽象在特定输入下的行为边界。</p>
<p>单元测试是理解一个模块功能和接口承诺的最快、最准确的途径，它极大地降低了新成员理解系统的认知复杂度。</p>
<h3>可观测性</h3>
<p>单元测试在本地是完美的，但它对运行时的动态复杂度则无能为力。当 1000 个通过了单元测试的微服务（模块）被部署到网络上时，它们交互所产生的涌现行为，是单元测试无法覆盖的。</p>
<p>在我看来，可观测性一般包含 <code>metrics</code>、<code>trace</code> 和 <code>logs</code> 三大部分。</p>
<table>
<thead>
<tr>
<th>组件</th>
<th>核心</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>metrics</td>
<td>帮助你判断是否有问题</td>
<td>统计埋点，包括系统监控、服务监控、业务监控。</td>
</tr>
<tr>
<td>trace</td>
<td>告诉你问题在哪里</td>
<td>实现链路追踪，展示系统拓扑图，梳理服务调用链路，洞察性能瓶颈点。</td>
</tr>
<tr>
<td>logs</td>
<td>帮助你定位到问题根源</td>
<td>制定日志规范，将规范灌输到日常开发的认知习惯中，尝试将部分规范集成到日志组件中，打更有意义的日志， 提高问题排查效率。</td>
</tr>
</tbody></table>
<p>利用好这 3 个组件，可以帮助我们：</p>
<ol>
<li><p>出现问题时，提高问题排查效率。</p>
</li>
<li><p>问题快来时，提供全局视野，提供预知问题的能力。</p>
</li>
<li><p>问题没出现时，提高开发质量，减少问题。</p>
</li>
</ol>
<h4>可观测性的作用</h4>
<p>具体来说，可观测性在三个层面上管理了动态复杂度。</p>
<p><strong>1. 它是分布式系统的交互可视化工具</strong></p>
<p>在模块化的架构中，系统是一个分布式黑盒。任何一个请求都可能跨越几十个模块（服务）。单个模块（已通过单元测试）是正确的，但它们组合运行时的交互可能导致<strong>性能瓶颈</strong>、<strong>级联失败</strong>或<strong>数据不一致</strong>。</p>
<p><strong>分布式追踪 (Tracing)</strong> 提供了请求级的可视化。它能精确地描绘出一个请求从 <code>Service-A</code> 到 <code>Service-B</code> 再到 <code>Service-C</code> 的实际路径和耗时分布。它将黑盒的动态交互复杂度，降维为一张清晰的瀑布图或依赖拓扑图，使工程师能<strong>定位</strong>涌现出的性能瓶颈或错误路径。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/d20fefdb-e245-4b5a-ad23-ebef7ed07633.original.png" alt=""></p>
<p><strong>2. 它是设计权衡的运行时数据</strong></p>
<p>我们所有的心法（<code>抽象</code>、<code>分治</code>、<code>分层</code>、<code>模块化</code>）都是有<strong>性能代价</strong>的。分层带来了数据复制的代价；模块化带来了网络调用的代价；消息队列（抽象）带来了延迟的代价。</p>
<p><strong>指标 (Metrics)</strong> 提供了<strong>量化</strong>这些代价的数据。例如 P99 延迟、GC 压力、队列深度会精确地告诉你：&quot;你为这个分层付出了 30% 的 GC 额外开销&quot;，&quot;你为这个模块化（微服务调用）付出了 40ms 的 P99 延迟&quot;。</p>
<p><strong>可观测性提供了运行时的真实数据，使设计权衡（Trade-off）从拍脑袋变成了数据驱动</strong>。工程师可以基于数据，决定何时打破分层（例如合并 DTO 和 Model）或合并模块（例如合并微服务）以换取性能。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/apm_vs_dt_metrics.webp" alt=""></p>
<p><strong>3. 它是未知问题的上下文</strong></p>
<p>单元测试只能验证已知（Known）的场景。而系统在真实运行时，会遇到大量未知（Unknown）的、涌现的复杂问题。</p>
<p><strong>日志 (Logs)</strong>，尤其是结构化和高基数的日志，提供了高维度的<strong>上下文</strong>。</p>
<p>当黑天鹅事件（例如高并发+特定网络分区）发生时，只有 <code>Traces</code>、<code>Metrics</code> 和 <code>Logs</code> 结合，才能提供足够的<strong>现场信息</strong>，让工程师能事后回溯、定位和理解那些单元测试永远无法复现的动态复杂度。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/p699774.png" alt=""></p>
<h4>可观测性的本质</h4>
<p>可观测性到底在观测什么？<u>我们观测的是一个系统（尤其是模块化和分层后的分布式系统）在运行时所涌现出的、不可预测的动态复杂度。</u></p>
<p>我们观测的不是工具（metrics、trace、logs），而是系统在真实压力下的：</p>
<ol>
<li>外在行为 (External Behavior)</li>
<li>内在状态 (Internal State)</li>
<li>交互关系 (Interactions)</li>
</ol>
<p><strong>1. 观测外在行为：系统在做什么？</strong></p>
<p>这是从外部看，我们的模块（服务）所承诺的接口（功能）是否正常。这通常对应 Google SRE 的黄金四信号中的前三个。</p>
<ul>
<li><strong>延迟 (Latency)</strong>：一个抽象的接口（如 API）完成它的承诺需要多长时间？这是<strong>性能</strong>的直接体现。</li>
<li><strong>流量 (Traffic)</strong>：有多少请求或任务正在压向服务？这是<strong>负载</strong>的直接体现。</li>
<li><strong>错误 (Errors)</strong>：有多少承诺没有被兑现（例如 HTTP 500）？这是<strong>正确性</strong>的直接体现。</li>
</ul>
<p><strong>2. 观测内在状态：系统花多大代价在做？</strong></p>
<p>这是从内部看，我们的模块（服务）为了完成上述外在行为，<strong>内部</strong>的资源和状态是什么样的。</p>
<ul>
<li><strong>饱和度 (Saturation)</strong>：例如，CPU 使用率、内存占用、磁盘 I/O、连接池大小、队列（Kafka）的积压长度。这是<strong>容量</strong>和<strong>健康度</strong>的直接体现。一个外在行为看起来正常（例如延迟低），但其内部状态可能已经处于崩溃边缘（例如队列积压 99%）。</li>
<li><strong>关键业务指标 (Business Metrics)</strong>：例如，订单创建数、支付成功率、用户注册数。这连接了技术复杂度与<strong>业务价值</strong>。</li>
</ul>
<p><strong>3. 观测交互关系：行为和状态是如何关联的？</strong></p>
<p>动态复杂度的根源在于**“交互”<strong>——<code>模块A</code> 调用 <code>模块B</code>，<code>B</code> 再调用 <code>C</code>。当下单这个行为变慢时，我们</strong>必须**观测这个交互链条。</p>
<ul>
<li><strong>上下文的传播</strong>：观测一个请求<strong>如何穿透</strong>抽象边界、模块边界和分层边界。这就是 <code>TraceID</code> 所做的工作。</li>
<li><strong>高基数的上下文</strong>：我们不仅观测 <code>Latency = 500ms</code>，我们观测的是：<code>Latency{service=&quot;payment&quot;, user_id=&quot;12345&quot;, region=&quot;eu-west&quot;, error=&quot;true&quot;}</code>。这允许我们事后去探索那些&quot;<strong>未知的未知</strong>&quot;。例如：为什么只有 <code>eu-west</code> 地区的 <code>VIP</code> 用户的支付行为会失败？</li>
</ul>
<h4>可观测性的方案</h4>
<p>对于落地可观测性，我的建议是尽可能拥抱 OpenTelemetry，它可以说是目前业界的唯一标准。不要自己去造轮子，不要在自己的业务项目中去&quot;创造&quot;一个自己的 traceID，去拥抱开源标准，你会享受到它的强大和遍历。</p>
<p>我在工作过程中，开源了一套 Go 语言的可观测性方案，感兴趣的读者可参考：<a href="https://github.com/hedon954/goapm">goapm</a>。</p>
<h2>总结</h2>
<p>行文至此，我们完整地构建了&quot;管理复杂度&quot;的&quot;道、法、术、器&quot;四层体系。</p>
<p>我们从&quot;道&quot;出发，明确了软件工程的终极目标——对抗&quot;复杂度&quot;这唯一且根本的敌人。我们亲历的&quot;屎山&quot;、那些&quot;龙卷风战术&quot;，本质上都是复杂度失控后的&quot;熵增&quot;表象。</p>
<p>为了对抗&quot;熵增&quot;，我们找到了&quot;法&quot;——抽象、分治、分层、模块化。这不是空洞的理论，而是无数前辈总结出的、应对&quot;认知局限&quot;这一不变约束的四大“不变法则”。它们是我们的第一性原理，是我们构建一切“术”的基石。</p>
<p>&quot;术&quot;是我们手中的&quot;招式&quot;与&quot;套路&quot;。无论是 SOLID、设计模式，还是宏观的架构模式与 DDD，它们都是&quot;法&quot;在特定场景下的具象化应用。它们是&quot;法&quot;的实践工具箱，是确保我们的“招式”不走形、有据可依的“战法”。</p>
<p>最后，我们必须拥有&quot;器&quot;——单元测试与可观测性。它们是我们构建复杂系统的&quot;双眼&quot;。单元测试是我们管理&quot;静态复杂度&quot;的标尺，它在&quot;设计时&quot;强迫我们遵守&quot;法&quot;与&quot;术&quot;；可观测性是我们管理&quot;动态复杂度&quot;的明镜，它在&quot;运行时&quot;为我们揭示&quot;涌现&quot;出的未知。没有&quot;器&quot;，我们所有的&quot;法&quot;与&quot;术&quot;都只是盲人摸象。</p>
<p>回顾这三年的工作，我曾深陷&quot;屎山&quot;，也曾亲手造&quot;山&quot;。我所经历的痛苦、迷茫与挣扎，其根源就在于，我试图用&quot;术&quot;（例如某个设计模式）去解决&quot;道&quot;（复杂度失控）的问题，却又缺乏&quot;器&quot;（可观测性）来度量结果。</p>
<p>这篇复盘，便是我为那段经历寻找的答案。</p>
<p>&quot;道、法、术、器&quot;不是一个需要背诵的清单，它是一个<strong>完整的、自洽的、循环反馈的作战体系</strong>。它定义了一个软件工程师从“编码”走向“工程”的必经之路。</p>
<p>理解这套体系，不是为了在“屎山”上“雕花”，而是为了让我们在面对下一个&quot;紧急&quot;需求、下一次&quot;龙卷风战术&quot;时，拥有<strong>拒绝“熵增”的武器和底气</strong>。</p>
<h2>AI 时代下道法术器的进化</h2>
<p>对于管理复杂度这一话题，我不想止步于此，我想多思考一下：</p>
<blockquote>
<p>[!CAUTION]</p>
<p>在 AI 时代下的 AI 应用开发中，软件工程还有存在的意义吗？它的道法术器有什么变化吗？</p>
</blockquote>
<p>我的结论是：</p>
<blockquote>
<p>[!IMPORTANT]</p>
<p>AI 应用开发，它首先是一个软件工程问题，然后才是一个 AI 问题。软件工程的地位依旧无可撼动，并且它管理复杂度的&quot;道&quot;并没有发生变化，但是&quot;法&quot;、&quot;术&quot;和&quot;器&quot;必须进化，以应对新的变化和挑战。</p>
</blockquote>
<p>在 AI 时代，尤其是大模型 (LLM) 时代，<strong>抽象、分治、分层、模块化</strong>这四大法则不仅没有过时，反而变得<strong>前所未有地重要</strong>。因为 AI 引入了一种全新的、更棘手的复杂度：<strong>非确定性 (Non-Determinism) 复杂度</strong>。</p>
<p>传统的软件工程对抗的是<strong>逻辑复杂度</strong>（&quot;If-Then-Else&quot; 的复杂度）。 AI 时代的软件工程对抗的是<strong>逻辑复杂度 + 非确定性复杂度</strong>（黑盒模型、概率性输出、数据依赖）。</p>
<h3>道的进化：从管理到驾驭</h3>
<p>AI 时代的软件工程，道依然是<strong>管理复杂度</strong>。但 AI 时代，复杂度本身发生了根本性的变化。</p>
<ul>
<li><strong>旧的复杂度</strong>：是<strong>确定性</strong>的。源于我们自己代码中组件间依赖关系的数量。它是可被推导的，只是过于庞大。</li>
<li><strong>新的复杂度</strong>：是<strong>非确定性</strong>和<strong>涌现性</strong>的。源于 LLM 这个黑盒的概率性本质。我们从管理&quot;代码逻辑&quot;转向管理&quot;模型行为&quot;；我们从&quot;调试 Bug&quot;转向&quot;对抗幻觉&quot;。</li>
</ul>
<p>因此，道的目标，在&quot;管理复杂度&quot;之外，增加了两个新的维度：</p>
<ol>
<li><strong>管理非确定性</strong>：我们如何为&quot;屎山&quot;找到根源？我们如何为&quot;幻觉&quot;构建护栏？我们如何为&quot;概率&quot;设计&quot;重试&quot;与&quot;校验&quot;？</li>
<li><strong>驾驭涌现性</strong>：AI Agent 所展现的自主规划能力是一种涌现。我们的道不再是&quot;自顶向下&quot;地控制一切，而是自底向上地<strong>引导</strong>和<strong>驾驭</strong>这种涌现能力，让它在可控的边界内解决问题。</li>
</ol>
<h3>法的进化：从逻辑抽象到能力抽象</h3>
<h4>抽象</h4>
<blockquote>
<p>[!IMPORTANT]</p>
<p>不变的第一性原理 —— 隐藏实现细节，提供一个简洁、稳定的&quot;接口&quot;。</p>
</blockquote>
<p>传统的抽象隐藏的是<strong>清晰的逻辑</strong>（例如，<code>sort(list)</code> 隐藏了快排的实现）。而 AI 时代的抽象需要隐藏的是一个<strong>模糊的、概率性的黑盒</strong>（例如，<code>summarize(text)</code> 隐藏了 LLM 内部上千亿个参数的复杂推理）。</p>
<p>它的进化：</p>
<ol>
<li><strong>从功能抽象到能力抽象：</strong><ul>
<li><em>传统：</em> 我们抽象一个函数 (Function)，它接受确定的输入，产生确定的输出（例如 <code>getUser(id)</code>）。</li>
<li><em>进化：</em> 我们抽象一种能力 (Capability)。例如，<code>OpenAI API</code> 本身就是一种强大的抽象。我们不关心它内部是 Transformer 还是 MoE，我们只关心它暴露了文本生成、图像理解的能力。</li>
</ul>
</li>
<li><strong>Prompt 成为新的 API：</strong><ul>
<li><em>传统：</em> API 是通过严格的函数签名 (Signature) 定义的。</li>
<li><em>进化：</em> <strong>Prompt Engineering 本身就是一种新的抽象实践</strong>。一个精心设计的 Prompt（例如，&quot;你是一个专业的法律助手，请...&quot;）就是创建了一个新的、更可控的抽象层，它将一个通用的 LLM（原始能力）抽象成了一个特定领域的专家（封装后的能力）。</li>
</ul>
</li>
<li><strong>特征存储成为数据抽象：</strong><ul>
<li><em>传统：</em> 我们抽象数据访问层 (DAO / Repository)。</li>
<li><em>进化：</em> 在 MLOps 中，<strong>Feature Store (特征存储)</strong> 成为了关键的数据抽象。它向模型训练和推理隐藏了数据清洗、转换、聚合的复杂 ETL 过程。模型开发者（高层）不再关心数据（低层）是来自 Kafka 还是 MySQL，他们只关心获取 <code>user_7day_purchase_amount</code>这个被抽象出来的特征。</li>
</ul>
</li>
</ol>
<h4>分治</h4>
<blockquote>
<p>[!IMPORTANT]</p>
<p>不变的第一性原理 —— 将一个无法一次性解决的大问题，分解为多个同类型、可独立解决的小问题，最后再合并。</p>
</blockquote>
<p>在 AI 时代的新挑战 一个单一的、巨大的全能模型难以训练、难以调试、成本高昂。同时，一个复杂的现实问题（例如帮我规划一次东京旅行）也超出了单个 LLM 的能力范围。</p>
<p>它的进化：</p>
<ol>
<li><strong>模型训练中的分治 (MoE)：</strong><ul>
<li><em>传统：</em> 归并排序、MapReduce。</li>
<li><em>进化：</em> <strong>混合专家模型 (Mixture of Experts, MoE)</strong> 是分治思想在模型架构上的极致体现。<ul>
<li><em>分解 (Divide)：</em> 不训练一个 1.7 万亿参数的巨无霸模型，而是训练（比如） 8 个 2000 亿参数的专家模型。</li>
<li><em>解决 (Conquer)：</em> 当一个 Token 进来时，一个路由器 (Gating Network) 负责判断这个问题该由哪两个专家来解决？</li>
<li><em>合并 (Combine)：</em> 将这两个专家的输出加权合并。</li>
</ul>
</li>
</ul>
</li>
<li><strong>应用架构上的分治 (RAG)：</strong><ul>
<li><em>传统：</em> 微服务架构。</li>
<li><em>进化：</em> <strong>RAG (Retrieval-Augmented Generation，检索增强生成)</strong> 是分治在 AI 应用架构上的最佳实践。<ul>
<li><em>大问题：</em> 如何让 LLM 回答关于我私有知识库的最新问题？</li>
<li><em>分解 (Divide)：</em> 强迫 LLM 知道一切是不可行的。我们将问题分解为：① 检索 和 ② 生成。</li>
<li><em>解决 (Conquer)：</em><ul>
<li>用一个专门的检索模块（例如向量数据库）解决独立的小问题：找到最相关的知识片段。</li>
<li>用 LLM 解决另一个独立的小问题：基于这些片段，生成通顺的回答。</li>
</ul>
</li>
<li><em>合并 (Combine)：</em> 将检索到的片段（Context）和原始问题（Query）一起合并后，发给 LLM。</li>
</ul>
</li>
</ul>
</li>
<li><strong>AI 智能体 (Agents) 和工具使用 (Tool Use)：</strong><ul>
<li><em>进化：</em> 当 LLM 遇到一个复杂任务（例如明天天气怎么样？）时，它使用分治：<ul>
<li><em>分解：</em> ① 我需要知道&quot;明天&quot;和&quot;地点&quot;。② 我需要一个工具来查天气。③ 我需要组织语言。</li>
<li><em>解决：</em> 它调用 <code>call_weather_api(&quot;beijing&quot;, &quot;tomorrow&quot;)</code>，获得 JSON 结果。</li>
<li><em>合并：</em> 它将 JSON 结果合并到它的上下文中，生成最终答案。</li>
</ul>
</li>
</ul>
</li>
</ol>
<h4>分层</h4>
<blockquote>
<p>[!IMPORTANT]</p>
<p>不变的第一性原理 —— 按&quot;变化的速率&quot;或&quot;职责&quot;划分，管理纵向依赖，上层依赖下层，隔离变化。</p>
</blockquote>
<p>AI 系统的依赖变得极其复杂。它不再只是代码依赖，还包括<strong>数据依赖</strong>、<strong>模型依赖</strong>、<strong>环境依赖</strong>。</p>
<p>它的进化：</p>
<ol>
<li><strong>MLOps 成为新的分层标准：</strong><ul>
<li><em>传统：</em> 表现层 → 业务层 → 数据层。</li>
<li><em>进化：</em> <strong>AI 系统的技术栈被重新分层</strong>，每一层都隔离了不同速率的变化：<ul>
<li><strong>应用层 (Application Layer)：</strong> 传统的 Web 后端。它变化最快（例如 UI 调整）。</li>
<li><strong>AI 编排层 (Orchestration Layer)：</strong> Prompt 模板、RAG 流程、Agent 逻辑。变化较快（例如调整 Prompt）。</li>
<li><strong>模型服务层 (Model Serving Layer)：</strong> API Gateway、模型推理服务 (Triton, vLLM)。变化中等（例如模型版本切换）。</li>
<li><strong>模型训练层 (Model Training Layer)：</strong> 训练流水线、实验跟踪 (MLflow)。变化较慢（例如重训模型）。</li>
<li><strong>数据/特征层 (Data/Feature Layer)：</strong> 特征存储、数据湖。变化最慢（例如增加新数据源）。</li>
</ul>
</li>
<li>这种分层确保了：我可以更新一个 Prompt（编排层），而<strong>无需</strong>重新训练模型（训练层）或重启服务（服务层）。</li>
</ul>
</li>
<li><strong>&quot;数据-模型-代码&quot; 的依赖分层：</strong><ul>
<li><em>进化：</em> 我们必须严格区分三种依赖。在 AI 工程中，<strong>数据是新的代码</strong>。</li>
<li>我们必须建立新的分层依赖规则：<strong>代码 (Code) → 模型 (Model) → 数据 (Data)</strong>。</li>
<li>这意味着，数据的变更会触发模型的重训；模型的变更会触发代码的适配。管理这些&quot;依赖链&quot;和&quot;缓存失效&quot;（例如，数据变了，哪些特征和模型需要重算？）是 AI 时代分层的核心任务。</li>
</ul>
</li>
</ol>
<h4>模块化</h4>
<blockquote>
<p>[!IMPORTANT]</p>
<p>不变的第一性原理 —— 高内聚、低耦合。将系统划分为&quot;横向&quot;的功能单元，通过清晰的接口协作。</p>
</blockquote>
<p>在 AI 时代的新挑战是如何封装 AI 的非确定性？如何让一个概率性的模块与一个确定性的系统（例如支付模块）安全地协作？</p>
<p>它的进化：</p>
<ol>
<li><strong>模型即模块 (Model as a Module)：</strong><ul>
<li><em>传统：</em> 一个 <code>.jar</code> 包或一个 Go <code>package</code> 是一个模块。</li>
<li><em>进化：</em> <strong>一个经过训练并打包的模型（例如一个 Hugging Face 仓库）就是 AI 时代的新模块</strong>。它具有极高的内聚性（封装了解决特定任务的所有知识）和极低的耦合性（通过标准的 API 暴露服务）。</li>
</ul>
</li>
<li><strong>可观测性成为接口的一部分：</strong><ul>
<li><em>传统：</em> 模块的接口是 API 签名。</li>
<li><em>进化：</em> AI 模块的接口不仅要包括输入/输出，还必须包括<strong>可观测性</strong>。因为我们无法 100% 信任它的输出，所以模块必须暴露它的内部状态：例如，它输出 &quot;A&quot; 的置信度是多少？它在推理时参考了哪些知识来源？</li>
</ul>
</li>
<li><strong>确定性外壳模块：</strong><ul>
<li><em>进化：</em> 这是模块化思想最重要的进化。我们<strong>不能</strong>让非确定性泄露到系统的其他部分。</li>
<li>我们必须创建一个确定性外壳模块（一个高内聚的封装）：<ul>
<li><strong>内部 (非确定性)：</strong> 它调用 LLM、处理概率性输出。</li>
<li><strong>外壳 (确定性)：</strong> 它包含防护栏。例如：<ol>
<li><strong>解析与校验：</strong> 强迫 LLM 输出 JSON，如果解析失败则重试或返回错误。</li>
<li><strong>过滤：</strong> 检查输出是否包含敏感词或幻觉。</li>
<li><strong>回退：</strong> 如果 AI 失败或置信度低，则回退到传统的确定性逻辑（例如 <code>if-else</code>）。</li>
</ol>
</li>
</ul>
</li>
<li>这个外壳模块对外提供了一个<strong>看似确定</strong>、<strong>安全</strong>的接口，使得系统的其他部分（如订单处理、支付逻辑）可以安全地调用它。</li>
</ul>
</li>
</ol>
<h3>术的进化：从管理逻辑到驾驭概率</h3>
<p>AI 时代催生了一系列全新的&quot;术&quot;，它们的核心不再是管理&quot;逻辑的确定性&quot;，而是转向<strong>管理&quot;语义的非确定性&quot;和&quot;编排认知（Cognition）&quot;</strong>。</p>
<p>以下是我认为最重要的四大术之进化：</p>
<h4>核心：提示词工程与 AI 编排</h4>
<p>这是 AI 时代<strong>最根本的新&quot;术&quot;</strong>，它几乎重塑了&quot;法&quot;中的抽象和分治。</p>
<ul>
<li><strong>Prompt 即接口</strong>：传统的术是写代码来定义逻辑。全新的术是写 <code>Prompt</code>（自然语言）来<strong>定义能力和契约</strong>。<code>Prompt</code> 成为了我们与 AI 这个非确定性黑盒交互的<strong>新 API</strong>。</li>
<li><strong>编排即分治</strong>：单一 <code>Prompt</code> 无法解决复杂问题。因此，术进化为<strong>AI 编排</strong>（Orchestration），如 LangChain 或 LlamaIndex 所做的那样。<ul>
<li><strong>RAG (检索增强生成)</strong>：就是一种编排&quot;术&quot;。它将检索和生成这两个步骤分治开来，并通过编排合并结果。</li>
<li><strong>链式思考 (Chain-of-Thought)</strong>：这是一种引导 AI 分治其内部思维的&quot;术&quot;。</li>
<li><strong>AI 编排层</strong>：在 MLOps 分层中，这一层成为了新的核心。</li>
</ul>
</li>
</ul>
<h4>涌现：智能体架构与工具调用</h4>
<p>如果说 RAG 是&quot;分治&quot;的初级形态，那么 Agent 架构就是&quot;术&quot;在&quot;分治&quot;思想上的高级进化，它服务于&quot;道&quot;中&quot;驾驭涌现性&quot;的目标。</p>
<ul>
<li><strong>LLM 即认知引擎</strong>：传统的&quot;术&quot;是工程师自顶向下设计一切。Agent &quot;术&quot;则将 LLM 视为一个可以<strong>自主规划</strong>的认知引擎或中央处理器。</li>
<li><strong>工具即能力模块</strong>：这对应了&quot;法&quot;中的&quot;模块化&quot;。<code>Agent</code> 通过工具调用来扩展其能力。</li>
<li><strong>ReAct 循环</strong>：<code>Reason -&gt; Act -&gt; Observe</code> 的循环，是 <code>Agent</code> 架构中最核心的&quot;术&quot;，它为 AI 的涌现行为提供了一个可控的执行框架。</li>
</ul>
<h4>防护：确定性外壳</h4>
<p>这是 AI 时代<strong>保障系统安全和可靠性</strong>的第一防卫术，它源于&quot;法&quot;中&quot;模块化&quot;的思想。</p>
<p>AI 的非确定性是剧毒的，它绝不能泄露到你的核心业务逻辑中（比如支付、订单）。这个&quot;术&quot;的核心就是<strong>封装黑盒、管理边界</strong>。</p>
<p>这个外壳模块 负责所有脏活累活：</p>
<ol>
<li><strong>输入防护</strong>：检查 <code>Prompt</code> 是否合规（防注入）。</li>
<li><strong>输出解析</strong>：强迫 AI 输出 JSON，并进行严格的<strong>校验</strong>、<code>pydantic</code> 风格的类型转换。</li>
<li><strong>安全过滤</strong>：检查 AI 输出是否有害、有偏见、或包含敏感信息。</li>
<li><strong>回退机制</strong>：当 AI 失败、超时或输出&quot;我不知道&quot;时，<strong>回退</strong> 到一个确定的、经典的 <code>if-else</code> 逻辑。</li>
</ol>
<h4>工业：MLOps 与 AI 资产管理</h4>
<p>传统的&quot;术&quot;管理&quot;代码&quot;。AI 时代的&quot;术&quot;必须管理**&quot;代码、模型、数据&quot;三位一体的复杂依赖链**。这就是 MLOps。</p>
<ul>
<li><strong>模型即模块</strong>：AI 时代，一个（例如 <code>Hugging Face</code> 上的）模型，就是一个<strong>可版本化、可部署</strong>的新模块。</li>
<li><strong>数据即代码</strong>：数据是新的代码。因此，&quot;术&quot;必须进化到包含<strong>数据版本管理 (DVC)</strong>、<strong>特征工程 (Feature Engineering)</strong> 和<strong>特征存储 (Feature Store)</strong>。</li>
</ul>
<h3>器的进化：从确定性标尺到非确定性明镜</h3>
<h4>测试</h4>
<p>在 AI 时代，尤其是 LLM 时代，传统测试的第一性原理受到了根本性的挑战。</p>
<ul>
<li><strong>传统测试：</strong> <strong>验证 (Verification)</strong>。其核心是 <strong>确定性 (Determinism)</strong>。我们要求 1+1 必须等于 2。</li>
<li><strong>AI 时代的测试：</strong> <strong>评估 (Evaluation)</strong>。其核心是 <strong>概率性 (Probabilism)</strong> 和 <strong>模糊性 (Fuzziness)</strong>。我们没有所谓的唯一确定的正确答案，但我们知道它应该是&quot;简洁的&quot;、&quot;忠于原文的&quot;、&quot;通顺的&quot;。</li>
</ul>
<p>因此，传统测试在 AI 时代<strong>仍然极端重要，但已远远不够</strong>。它必须进化。</p>
<h5>单元测试</h5>
<p>在 AI 系统中，我们之前讨论过，模块化的进化是使用确定性外壳来包裹非确定性的 AI 内核。<strong>传统单元测试的职责，就是捍卫这个确定性外壳。<strong>它们不测试 AI <em>本身</em>，而是测试所有与 AI 交互的、确定性的</strong>管道和护栏</strong>。</p>
<ul>
<li><strong>测试 Prompt 模板</strong></li>
<li><strong>测试输出解析器 (Parsers)</strong></li>
<li><strong>测试回退逻辑 (Fallbacks)</strong></li>
<li><strong>测试工具调用 (Tool Use)</strong></li>
</ul>
<p>单元测试从&quot;测试业务逻辑&quot;后退到&quot;测试 AI 的输入输出管道&quot;。它保证了无论 AI 表现得多糟糕（例如胡言乱语），我们的系统都不会崩溃，而是会优雅地处理失败。</p>
<h5>集成测试</h5>
<p>**集成测试的职责，是捍卫 AI 工作流的连通性。**它测试的是我们之前讨论的分层与分治架构中，各个模块（服务、数据库、模型 API）之间的胶水层。</p>
<ul>
<li>测试 RAG 流程的集成</li>
<li>测试外部 API 的 Mocking</li>
</ul>
<p>集成测试保证了 AI 应用的骨架是通的。它保证了数据流（Data Flow）在 RAG 管道、微服务和外部 API 之间能正确流转。</p>
<h5>新型测试</h5>
<p>这是全新的、最重要的一层。传统测试验证 <code>func(in) == out</code>，AI 测试评估 <code>eval(func(in), criteria)</code> 是否为 <code>True</code>。我们不再断言相等，而是评估品质。</p>
<ul>
<li>基于&quot;黄金数据集&quot;的回归测试：检测&quot;新回答&quot;与&quot;理想的回答范例&quot;之间的<strong>语义相似度</strong>。防止有益的修改导致意外的衰退。</li>
<li>基于&quot;启发式&quot;的评估：定义一系列可计算的规则，如上下文相关性、上下文精确度、答案相关性、答案有用度。</li>
<li>基于&quot;对抗性&quot;的测试：传统安全测试中的渗透测试，专门测试 AI 的独特漏洞。如 Prompt 注入、偏见与安全和鲁棒性。</li>
<li>LLM 作为评估者：使用一个更强大的 LLM 作为自动化评估的法官。</li>
</ul>
<h5>测试金字塔</h5>
<p>AI 时代的测试不再是一个简单的金字塔，它演变成了一个双重结构：</p>
<ol>
<li><strong>确定性金字塔 (传统软件 1.0)：</strong><ul>
<li><strong>单元测试</strong> (测试管道、解析器、护栏)</li>
<li><strong>集成测试</strong> (测试 RAG 流程、API 连通性)</li>
<li><strong>E2E 测试</strong> (测试 UI 交互)</li>
</ul>
</li>
<li><strong>概率性评估层 (AI 软件 2.0)：</strong><ul>
<li><strong>质量评估</strong> (基于黄金集、启发式、LLM-as-Judge)</li>
<li><strong>安全评估</strong> (对抗性测试、偏见测试)</li>
<li><strong>生产监控 (CI/CT)</strong> (A/B 测试、用户反馈、数据漂移检测)</li>
</ul>
</li>
</ol>
<p>最后，测试<strong>从部署前延伸到了部署后</strong>。A/B 测试和生产环境的用户反馈成为了持续测试 (Continuous Testing) 的最终闭环。</p>
<h4>可观测性</h4>
<p>传统的动态复杂度是&quot;服务 A 调用 B 变慢了&quot;、&quot;为什么服务 A 突然调不通服务 B 了？&quot;。AI 时代的动态复杂度是&quot;<strong><u>为什么 AI 突然开始胡言乱语了？</u></strong>&quot;。我们必须观测那个<strong>非确定性黑盒的心智过程</strong>。</p>
<p>AI 时代的可观测性，<strong>其进化本质是从&quot;监控系统健康&quot;扩展到&quot;评估 AI 行为与质量&quot;</strong>。传统的三大支柱（metrics、trace、log）仍然是地基，但我们必须在上面加盖全新的楼层。</p>
<h5>从三大支柱到四大支柱</h5>
<p>为了解决上述问题，可观测性正在演化，增加了一个全新的、专为 AI 服务的支柱，我称之为 <strong>AI 交互 (AI Interactions)</strong>。这有时也被称为 <strong>LLM O11y</strong> 或 <strong>Trace-centric Observability</strong>。</p>
<p>这个新支柱专门捕获 AI 黑盒的&quot;输入-处理-输出&quot;全貌。</p>
<ul>
<li><strong>传统 Logs：</strong> <code>{&quot;level&quot;: &quot;info&quot;, &quot;service&quot;: &quot;payment&quot;, &quot;msg&quot;: &quot;payment processed&quot;}</code></li>
<li><strong>AI Logs/Traces ：</strong><ul>
<li><strong>Inputs：</strong> 捕获完整的 <strong>Prompt</strong>（包括我们注入的 RAG 上下文、Few-shot 示例）。</li>
<li><strong>Outputs：</strong> 捕获完整的 <strong>Response</strong>（LLM 的原始回答）。</li>
<li><strong>Metadata：</strong><ul>
<li><strong>模型参数：</strong> <code>model_name</code> (gpt-4o, claude-3-sonnet), <code>temperature</code>, <code>max_tokens</code>。</li>
<li><strong>使用情况 (Usage)：</strong> <code>prompt_tokens</code>, <code>completion_tokens</code>, <code>total_tokens</code>。</li>
<li><strong>成本 (Cost)：</strong> <code>cost_in_usd</code> (例如 $0.0015)。</li>
<li><strong>延迟 (Latency)：</strong> <code>time_to_first_token</code>, <code>total_time</code>。</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>这个新支柱是后续所有进化的数据基础。</p>
<h5>metrics：从系统健康到 AI 质量</h5>
<p>传统 Metrics 关注 <strong>RED</strong>（速率, 错误率, 耗时）。在 AI 时代，我们增加了全新的 AI 质量指标。</p>
<table>
<thead>
<tr>
<th><strong>指标维度</strong></th>
<th><strong>传统可观测性</strong></th>
<th><strong>AI 时代可观测性</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>系统健康</strong></td>
<td><code>http_requests_total</code> <br><code>http_errors_rate</code><br> <code>cpu_usage</code></td>
<td>(全部保留)<br> <code>llm_api_error_rate</code> (如 429 限流)</td>
</tr>
<tr>
<td><strong>性能</strong></td>
<td><code>http_request_duration_p99</code></td>
<td><code>llm_time_to_first_token_p95</code> <br><code>llm_token_generation_speed</code> (tokens/sec)</td>
</tr>
<tr>
<td><strong>AI 质量</strong></td>
<td>(无)</td>
<td><code>hallucination_rate</code> (幻觉率)<br> <code>toxicity_score</code> (有毒内容评分) <br> <code>pii_leakage_count</code> (个人隐私泄露计数) <br> <code>user_feedback_score</code> (用户点赞/点踩率)</td>
</tr>
<tr>
<td><strong>成本</strong></td>
<td>(无，或模糊的服务器成本)</td>
<td><code>total_cost_per_day</code> (按模型/按用户) <br><code>cost_per_request</code> (单次请求成本) <br><code>total_tokens_per_service</code></td>
</tr>
</tbody></table>
<p>这意味着可观测性平台 (如 Grafana) 上，除了 CPU 和延迟的图表，<strong>还必须有&quot;每日成本&quot;、&quot;幻觉率&quot;和&quot;用户满意度&quot;的图表</strong>。</p>
<h5>tracing：从调用链到思维链</h5>
<ul>
<li><strong>传统 Trace (OpenTelemetry)：<strong>关注的是</strong>操作 (Operations)</strong>。一个 Span (跨度) 代表一个函数调用或一次 RPC。如 <code>Service A</code> -&gt; <code>Service B (Redis GET)</code> -&gt; <code>Service C (DB Query)</code>。它回答的是请求的瓶颈和问题点出现在哪里？</li>
<li><strong>AI Trace (如 LangSmith, OpenInference)：<strong>关注的是</strong>上下文 (Context)</strong> 和 <strong>AI 的思考步骤</strong>。<ul>
<li>一个 Span 不仅代表操作，更代表 AI 链条中的一步，并<strong>富含语义信息</strong>。</li>
<li>以一个 RAG (检索增强生成) 应用为例，一个 AI Trace 必须清晰地展示：<ol>
<li><strong>[Span 1: Parse Query]</strong> 用户的原始问题。</li>
<li><strong>[Span 2: Embed Query]</strong> 用户的查询被转换成了哪个向量。</li>
<li><strong>[Span 3: Vector Search]</strong> 从向量数据库中<strong>检索到了哪几块(Chunks)文本</strong>？</li>
<li><strong>[Span 4: Build Prompt]</strong> 系统将这些 Chunks 和原始问题<strong>组装成了什么样的最终 Prompt</strong>？</li>
<li><strong>[Span 5: LLM Call]</strong> 调用 LLM (附带 Tokens, Cost 等元数据)。</li>
<li><strong>[Span 6: Parse Output]</strong> 得到 LLM 的原始回答，并解析。</li>
</ol>
</li>
</ul>
</li>
</ul>
<p>AI Trace 是<strong>富上下文</strong>的。当一个 RAG 回答错误时，SRE 或工程师需要打开这个 Trace，<strong>一目了然地看到是 Vector Search 没查到相关文档，还是 Prompt 组装错了，还是 LLM 产生了幻觉</strong>。</p>
<h5>log：从事件记录到评估数据集</h5>
<ul>
<li><strong>传统 Logs：</strong> 主要用于事后排障。</li>
<li><strong>AI Logs：</strong><ol>
<li><strong>主动排障 (Proactive)：</strong> AI Logs (尤其是捕获的 Prompt/Response) 会被<strong>实时</strong>送入一个评估模型 (Evaluator)。例如，用一个 LLM (如 GPT-4) 去评估另一个 LLM (如 Llama 3) 的回答是否有害。如果评估不通过，<strong>立即触发告警</strong>。</li>
<li><strong>黄金数据集 (Golden Dataset)：</strong> 生产环境中的高质量问答对 (来自 AI Logs) 会被筛选出来，用于微调 (Fine-tuning) 未来的模型，形成一个持续改进的闭环。</li>
</ol>
</li>
</ul>
<p>可观测性系统不再只是一个&quot;看&quot;的系统，它成了一个&quot;评估&quot;和&quot;再训练&quot;的数据源头。</p>
<h5>总结</h5>
<table>
<thead>
<tr>
<th><strong>方面</strong></th>
<th><strong>传统可观测性 (O11y 1.0)</strong></th>
<th><strong>AI 时代可观测性 (O11y 2.0)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>核心目标</strong></td>
<td>监控系统<strong>健康</strong> (Health)</td>
<td>监控系统健康 + 评估 AI <strong>质量</strong> (Quality)</td>
</tr>
<tr>
<td><strong>主要挑战</strong></td>
<td>分布式系统的复杂性</td>
<td>LLM 的非确定性、黑盒性、幻觉</td>
</tr>
<tr>
<td><strong>Metrics</strong></td>
<td>RED 指标 (速率、错误、耗时)</td>
<td>RED + <strong>质量指标</strong> (幻觉率、满意度) + <strong>成本指标</strong> (Tokens, Cost)</td>
</tr>
<tr>
<td><strong>Tracing</strong></td>
<td><strong>操作链</strong> (Operation Chain) (如 OpenTelemetry)</td>
<td><strong>思维链 / 上下文链</strong> (Context Chain) (如 LangSmith, OpenInference)</td>
</tr>
<tr>
<td><strong>Logs</strong></td>
<td>事后排障的<strong>事件记录</strong></td>
<td><strong>评估数据集</strong>，用于实时告警和模型微调</td>
</tr>
<tr>
<td><strong>核心工具</strong></td>
<td>Prometheus, Grafana, Jaeger, ELK</td>
<td>(保留上述工具) + <strong>LLM O11y 平台</strong> (如 LangSmith, Arize AI, W&amp;B)</td>
</tr>
</tbody></table>
<p>总而言之，AI 时代的可观测性，是传统 SRE/DevOps 和 MLOps/Data Science 两个领域的<strong>强制融合</strong>。我们不仅需要工程师，还需要懂 AI 质量评估的专家，共同盯着仪表盘。</p>
]]></content:encoded>
    </item>
    <item>
      <title>读书笔记丨《上头Obsidian：手把手教你用AI做好知识管理》</title>
      <link>https://hedon.top/blog/note-obsidian/</link>
      <guid isPermaLink="true">https://hedon.top/blog/note-obsidian/</guid>
      <pubDate>Tue, 14 Oct 2025 17:36:00 GMT</pubDate>
      <description>《上头Obsidian：手把手教你用AI做好知识管理》提出了一套以“表达”为核心的知识管理方法——GAP模型：采集（Grasp）、归类（Arrange）、表达（Present）。通过明确目标、高效采集、合理归类，最终借助Obsidian与AI工具实现高质量输出，将碎片信息转化为结构化知识系统，帮助用户从“信息焦虑”走向“知识掌控”。</description>
      <category>AI</category><category>Obsidian</category><category>知识管理</category><category>读书笔记</category>
      <content:encoded><![CDATA[<h2>道</h2>
<p>知识管理的最终目的是什么？—— 表达（输出）。
所以知识管理的底层逻辑基本是不变的：”输入 → 关联 → 表达“。</p>
<p>《上头 Obsidian》的作者提出了一种构建知识库的思路：GAP，即：</p>
<ul>
<li>G（Grasp）：采集</li>
<li>A（Arrange）：归类</li>
<li>P（Present）：表达</li>
</ul>
<p>所以我们要如何做采集和归类，才能帮助我们做更好的表达呢？我们一定要围绕我们的最终目标”表达“来思考，即在采集环节，只采集那些我们需要的材料，而不是看到什么觉得好像不错、可能有用就一股脑收集起来，最后形成了一个堆满垃圾的仓库。</p>
<p>知识采集可以分为三步走：</p>
<ol>
<li>明确目标：收集要解决的问题、想要表达的内容、需要完成的任务、对自己启发的信息。</li>
<li>喂养大脑：选择”不舒服、长、经典“的高质量资料源，避免无意义的信息堆积。</li>
<li>快速收集：配置顺手的剪藏工作与清晰的采集模板，保证灵感第一时间被捕获。</li>
</ol>
<p>如果说采集阶段是积累原料，那么归类阶段就是开始搭建结构，把零散的信息组织成可用的模块。只有经过合理归类的内容，才能成为后续输出的基石。</p>
<p>知识归类主要做三件事情：</p>
<ol>
<li>定期整理未处理的笔记；</li>
<li>进行笔记关联：<ol>
<li>第一次收集该主题的相关内容，则需要新建一个归类笔记，并将当前这条采集笔记作为第一条材料添加进去。</li>
<li>在归类文件夹中，已经形成了对应的主题笔记，只需要将新的采集笔记链接到已有的归类笔记中即可。</li>
</ol>
</li>
<li>整理归类笔记<ol>
<li>拆分归类笔记，如果一类笔记中积累了很多内容，则需要进一步进行方向拆分。</li>
<li>准备产出，如果某个归类笔记下的内容已经足够成熟、结构也很清晰，那就可以开始表达输出了。</li>
</ol>
</li>
</ol>
<p>表达是最好的学习方式，可分四步走：</p>
<ol>
<li>有表达需求时，立即新建表达笔记；</li>
<li>使用分屏、AI 等工具高效完成内容撰写；</li>
<li>修改状态、建立链接、融入知识网络；</li>
<li>将表达笔记发布到合适的平台，获取外部反馈。</li>
</ol>
<h2>术</h2>
<p>那为什么选择 Obsidian 呢？为什么 Obsidian 可以在 GAP 的各个阶段对我们形成助力呢？为什么在 AI 时代，Obsidian 的能力可以得到进一步加强呢？</p>
<p>核心分为两大部分：</p>
<ul>
<li>知识库：有一个地方可以存储我们的材料，是能保证信息放得合理、有序、能随时被调取。</li>
<li>工作流：明确的处理步骤、从信息的采集、归类、加工到输出到有既定的路径依赖，降低心智负担。能提供可重复使用的内容模版，减少重复劳动，减少不一致性，提高操作效率。</li>
</ul>
<p>在这 2 个方面上，Obsidian 一些特性可以提供很好的支持：</p>
<ol>
<li>Obsidian 本身就是一个笔记软件，且笔记都是以 markdown 文件存储在本地的，支持 iCloud 等多种方式实现多端同步，材料的安全性、完整性和可迁移性都非常强。</li>
<li>Obsidian 的双链、标签、文件夹功能，可以帮助我们建立合理、有序的知识网络，更符合人脑对知识的利用逻辑。</li>
<li>Obsidian 支持多种插件、模板功能，方便我们建立强大的工作流，在大大减少重复劳动、提高操作效率的同时，还能帮助我们产出更具一致性、高质量的知识库。尤其是在 AI 能力的加持下，这些能力会被进一步增强。</li>
</ol>
<p>具体到 GAP 每一个步骤上，Obsidian 可以提供以下的帮助。</p>
<h3>G（Grasp）采集：重点是快、便捷、无心智负担</h3>
<ol>
<li>定义”采集笔记模版“，设立”tags、created、link“通用属性，方便利用 dataview、标签过滤和双链功能建立知识网络和过滤能力。</li>
<li>利用插件和 AI 快速提取材料：<ol>
<li>可以使用官方插件 Obsidian Web Clipper 采集网页内容，它支持 AI Hook，可以在存储为采集材料之前先让 AI 进行一轮总结和关键提取，进一步提高效率。</li>
<li>针对本地的录音、视频，可以用”通义听悟“等 AI 软件把内容转录为文字并提取重点。</li>
<li>针对在线视频（Youtube，Bilibili），可以使用”BilliGPT“等软件进行内容提取和总结。</li>
<li>可以使用 Douban 插件，快速抓取书籍、电影等材料元信息，更方便做智能归纳和整理。</li>
<li>可以使用 weread 插件，快速拉取微信读书的摘录、笔记和评论，再利用 AI 快速生成个性化的读书笔记。</li>
</ol>
</li>
<li>可以使用 iCloud 进行多端同步，同时在 iPhone 上使用便捷指令快速采集网络上需要的材料。</li>
<li>支持多种插件，除了 markdown 文字之外，还可以在笔记中嵌入多中类型的材料，如图片（Image Toolkit）、视频（Convert url to preview（iframe）、PDF（PDF++）。</li>
</ol>
<h3>A（Arrange）归类：这一步大部分需要人工操作</h3>
<ol>
<li>定义”归类笔记模版“，设立”tags、created、link“通用属性，方便利用 dataview、标签过滤和双链功能建立知识网络和过滤能力。</li>
<li>可以在日记中，利用”数据库 base“能力，快速筛选出”当日新建笔记“，方面每日定时进行笔记梳理和清理，保持知识库的整洁和有序。</li>
<li>通过双链功能，解决了信息孤岛的问题，让不同的笔记之间产生关联关系。</li>
</ol>
<h3>P（Present）表达：最根本的目标</h3>
<ol>
<li>定义”表达笔记模版“，设立”tags、created、link“通用属性，方便利用 dataview、标签过滤和双链功能建立知识网络和过滤能力。</li>
<li>当前面 2 步是围绕”表达“这一目标来执行又做得足够好的时候，表达就是自然而然是事情了，因为材料已经有序整理好了，剩下的就是组织和吸收内化的部分了。</li>
<li>可以利用 Copilot 插件，将相链接的材料交给 AI，生成大纲或输出文章的初稿。这里一定要自己去做表达和输出，不要过度依赖 AI 的思考，只有自己的大脑不断思考和变强，知识管理才是有意义的。</li>
<li>当表达笔记产出完毕后，再次将其与知识库已有的归类笔记链接起来，这样它又成为了一个可复用的高质量素材，以此逐步形成复利效应。</li>
</ol>
<h2>三大场景</h2>
<h3>日记复盘</h3>
<blockquote>
<p>Obsidian + Journals</p>
</blockquote>
<ol>
<li>使用 Journals 插件，实现按日、周、月、季、年自动生成日记目标和页面。</li>
<li>使用 Dataview 代码、数据库 Base，打造百年日记视角，回顾每年同一天、周、月发生的事情。</li>
<li>借鉴”机会日记“方法，每天记录 3 个机会、3 个反思、1 个感恩，养成敏锐感知和调整的习惯。</li>
<li>在 Copilot 插件中设置提示词模板，让 AI 工具自动分析一周的日记，输出总结和改进建议。</li>
<li>用 AI 工具生成的周复盘，继续作为月复盘、年复盘的输入材料，形成连贯的复盘链条。</li>
</ol>
<h3>读书笔记</h3>
<blockquote>
<p>Obsidian + 摘录工具 + AI</p>
</blockquote>
<ol>
<li>使用 Weread（微信读书）、iBook（苹果读书）、Kindle Highlights（Kindle）、Readwise Official 等插件可以快速将各个读书平台的划线、笔记和评论同步到 Obsidian 中。</li>
<li>使用 Douban 插件自动抓取豆瓣中的图书、电影、电视剧等内容元信息，高效率完善笔记内容。</li>
<li>使用 Dateview 插件，可自动汇总已完成、进行中、未开始的读书笔记。</li>
<li>根据需要准备读书总结提示词，使用 Copilot 插件利用 AI 进行输出（读书笔记初稿、公众号文章、小红书图文、播客录制等）。</li>
</ol>
<h3>知识管理</h3>
<p>本篇最开始提到的 [[上头Obsidian：手把手教你用AI做好知识管理#道]] 和 [[上头Obsidian：手把手教你用AI做好知识管理#术]] 将的就是基于 GAP 逻辑进行知识管理。</p>
<p>这里再次总结一下核心流程：</p>
<ol>
<li>材料采集<ol>
<li>网页、公众号文档、小红书文章：Obsidian Web Clipper + AI Interpreter</li>
<li>书籍、电影、电视剧元信息：Douban</li>
<li>本地视频、音频：通义听悟</li>
<li>在线视频：BilliGPT</li>
<li>书籍阅读：Weread、iBook、Kindle Highlights、Readwise Official</li>
</ol>
</li>
<li>材料归类：<ol>
<li>分类</li>
<li>链接</li>
</ol>
</li>
<li>表达输出：<ol>
<li>确定目标</li>
<li>AI 辅助输出</li>
<li>链接复用</li>
</ol>
</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>一次由 MySQL Gap 锁导致的阻塞排查实录</title>
      <link>https://hedon.top/blog/record-of-mysql-gap-lock/</link>
      <guid isPermaLink="true">https://hedon.top/blog/record-of-mysql-gap-lock/</guid>
      <pubDate>Tue, 23 Sep 2025 18:30:20 GMT</pubDate>
      <description>记录一次线上 INSERT 被长时间阻塞的定位过程，最终确认由 MySQL Gap/Next-Key 锁引起；文中说明锁的原理与触发场景，提供 MySQL 8.0 的 datalockwaits/INNODBTRX 诊断方法、KILL 应急，以及隔离级别、索引与语句优化等规避建议。</description>
      <category>服务器</category><category>MySQL</category><category>Gap锁</category><category>故障排查</category>
      <content:encoded><![CDATA[<h2>背景与症状</h2>
<p>在一次常规操作中，一条 <code>INSERT</code> 语句（目标 <code>id=664</code>）被长时间阻塞，最后在 Go 应用层报错 <code>invalid connection</code>。</p>
<pre><code class="language-sql">INSERT INTO `chip_info`(`info`,`display_order`,`id`)
VALUES (&#39;{...}&#39;, 519, 664)
ON DUPLICATE KEY UPDATE `info`=VALUES(`info`), `display_order`=VALUES(`display_order`);
</code></pre>
<p>最终排查定位，阻塞的根源是 MySQL 的 Gap 锁（间隙锁）。通过终止持有该锁的悬挂事务，操作立即恢复正常。</p>
<h2>Gap 锁与 Next-Key 锁的定义</h2>
<ul>
<li><strong>Gap 锁 (Gap Lock)</strong>：这是一种锁机制，它锁定的不是具体的某一行记录，而是索引记录之间的&quot;间隙&quot;。其唯一目的是防止其他事务在这个间隙中执行 <code>INSERT</code> 操作。</li>
<li><strong>Next-Key 锁 (Next-Key Lock)</strong>：这是 InnoDB 在 <code>REPEATABLE READ</code> 隔离级别下的默认锁策略。它本质上是<strong>行锁 (Record Lock)</strong> 与该行记录之前<strong>间隙的 Gap 锁</strong>的组合。Next-Key 锁是解决幻读问题的核心机制。</li>
</ul>
<h2>第一性原理：为什么需要 Gap 锁？</h2>
<p><strong>核心目标：实现可重复读 (Repeatable Read)</strong></p>
<p>在 <code>REPEATABLE READ</code> 隔离级别下，数据库承诺在一个事务内，对同一条件的多次查询将返回完全相同的结果集。如果不存在 Gap 锁，并发的 INSERT 操作会破坏这一承诺，导致幻读 (Phantom Read)。</p>
<p><strong>幻读场景示例 (无 Gap 锁的情况下)</strong></p>
<ul>
<li>T1: <code>SELECT * FROM t WHERE id &lt; 25;</code> 返回 5 条记录。</li>
<li>T2: <code>INSERT INTO t(id) VALUES (15);</code> 并提交。</li>
<li>T1: 再次执行 <code>SELECT * FROM t WHERE id &lt;25;</code>，此时将返回 6 条记录。事务 T1 内的查询结果集发生了变化，违反了可重复读的原则。</li>
</ul>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250923193114676.png" style="zoom:50%;" />

<p><strong>Gap 锁的解决方案</strong></p>
<p>为了防止幻读，InnoDB 引入了 Gap/Next-Key 锁。当事务 T1 执行范围查询时，InnoDB 不仅会锁住满足条件的已有行，还会锁住查询范围内的所有&quot;间隙&quot;。这样，事务 T2 的 INSERT 操作因无法在锁定的间隙中插入数据而被阻塞，直到 T1 提交或回滚，从而保证了 T1 的查询结果一致性。</p>
<p><strong>理论与实践的平衡</strong></p>
<p>Gap 锁可以看作是理论上谓词锁 (Predicate Lock) 的一种工程化、高性能的近似实现。它通过锁定索引区间来间接实现对查询谓词的保护。同时，这种机制也保证了基于语句的复制（SBR）在主从环境下执行结果的确定性。</p>
<h2>Gap 锁的触发场景</h2>
<p>Gap 锁的产生与<strong>隔离级别</strong>、<strong>索引使用</strong>和<strong>查询类型</strong>密切相关。</p>
<p><strong>在 <code>REPEATABLE READ</code> 隔离级别下</strong>：</p>
<ul>
<li><strong>范围查询</strong>：执行 <code>SELECT ... FOR UPDATE</code>、<code>SELECT ... LOCK IN SHARE MODE</code>、<code>UPDATE</code>、<code>DELETE</code> 时，若 <code>WHERE</code> 条件是范围扫描（如 <code>&gt;</code>、<code>&lt;</code>、<code>BETWEEN</code>），会锁定扫描过的索引区间。</li>
<li><strong>唯一索引等值查询未命中</strong>：当使用唯一索引（包括主键）进行等值查询，但该记录<strong>不存在</strong>时，为防止并发插入该值，InnoDB 会在对应位置加上 Gap 锁。</li>
</ul>
<p><strong>在 <code>READ COMMITTED</code> 隔离级别下</strong>：</p>
<ul>
<li>该级别下默认<strong>禁用</strong> Gap 锁，因此大大减少了阻塞概率。</li>
<li>但在外键约束检查和唯一性检查这两种特殊场景下，为了保证数据一致性，仍然可能会产生 Gap 锁。</li>
</ul>
<h2>诊断与定位方法 (MySQL 8.0+)</h2>
<p>当怀疑发生 Gap 锁阻塞时，可以通过以下视图进行诊断：</p>
<ol>
<li><p><strong>查询锁等待关系</strong>：</p>
<pre><code class="language-SQL">SELECT * FROM performance_schema.data_lock_waits;
</code></pre>
<p>该表直接展示了哪个事务正在等待哪个事务所持有的锁。</p>
</li>
<li><p><strong>查询活跃事务</strong>：</p>
<pre><code class="language-SQL">SELECT * FROM information_schema.INNODB_TRX;
</code></pre>
<p>该表列出了所有当前正在运行的事务及其状态、执行的 SQL 等信息。</p>
</li>
<li><p><strong>关联查询（推荐）：</strong></p>
<p>通过以下查询可以将事务信息与锁信息关联，快速定位持有锁的事务 ID (trx_id) 和其对应的数据库连接 ID (trx_mysql_thread_id)。</p>
<pre><code class="language-SQL">SELECT
  t.trx_id, t.trx_state, t.trx_started, t.trx_mysql_thread_id, t.trx_query
FROM information_schema.INNODB_TRX t
JOIN performance_schema.data_locks dl
  ON t.trx_id = dl.ENGINE_TRANSACTION_ID
WHERE dl.LOCK_STATUS = &#39;GRANTED&#39; -- 找到持有锁的事务
ORDER BY t.trx_started;
</code></pre>
</li>
</ol>
<h2>应急解决方案</h2>
<p>定位到持有锁的事务后，最直接的解决方法是终止其数据库连接。</p>
<ol>
<li><p><strong>获取连接 ID (thread_id)：</strong></p>
<p>通过上述诊断查询，找到 <code>trx_mysql_thread_id</code>。</p>
</li>
<li><p><strong>终止连接</strong>：</p>
<pre><code class="language-SQL">KILL [trx_mysql_thread_id]; -- 将 ID 替换为实际值
</code></pre>
<p>执行 <code>KILL</code> 命令后，该事务会立即回滚，释放其持有的所有锁，从而解决阻塞问题。</p>
</li>
</ol>
<h2>如何规避 Gap 锁问题</h2>
<ul>
<li><strong>缩短事务生命周期</strong>：保持事务简短，尽快 <code>COMMIT</code> 或 <code>ROLLBACK</code>，减少锁的持有时间。</li>
<li><strong>选择合适的隔离级别</strong>：如果业务逻辑允许，将隔离级别设置为 <code>READ COMMITTED</code> 是最有效的规避方法。</li>
<li><strong>优化查询，精准锁定</strong>：<ul>
<li>尽量使用唯一索引进行等值查询和更新，避免范围扫描。</li>
<li>确保查询条件能够命中高效的索引，避免因索引不当导致锁范围扩大。</li>
</ul>
</li>
<li><strong>谨慎使用锁定读</strong>：仅在必要时使用 <code>SELECT ... FOR UPDATE</code>，并确保 <code>WHERE</code> 条件尽可能精确。</li>
<li><strong>设置锁等待超时</strong>：合理配置 <code>innodb_lock_wait_timeout</code>，避免应用因长时间等待锁而无响应。</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Q&amp;A丨AI 视角下的后端技术重塑</title>
      <link>https://hedon.top/blog/qa-traditional-backend-to-ai-engineer/</link>
      <guid isPermaLink="true">https://hedon.top/blog/qa-traditional-backend-to-ai-engineer/</guid>
      <pubDate>Fri, 05 Sep 2025 17:13:00 GMT</pubDate>
      <description>本文跟 Gemini 探讨在 AI 时代下，传统后端工程师如何结合自身优势，转型为 AI 应用开发工程师。</description>
      <category>思考</category><category>ai问答</category>
      <content:encoded><![CDATA[<p>现在（2025.9），AI 技术突飞猛进，既是机会，也是压力。作为一名传统后端工程师（Go/Rust），笔者一直在想：<u>如何顺势完成从后端开发到 AI 应用开发的转型？</u></p>
<p>在摸索这条路的过程中，焦虑常常多于笃定。AI 框架、AI Agent 应用、新技术、新工具层出不穷，标题一个比一个猛——<code>&quot;它终于来了 xxx&quot;</code>、<code>&quot;👋🏻 再见 xxx&quot;</code>、<code>&quot;史上最强 xxx&quot;</code>……</p>
<p>为了把焦虑变成路径，笔者决定和 Gemini 来一场更系统的探讨：<u>不空谈宏论，而是请它在后端工程师日常用到的技术上补上一层&quot;AI 视角&quot;，用一条从已知走向未知的学习路线把问题说清楚。</u></p>
<p>具体来说，笔者希望 Gemini 围绕 Go/Rust、MySQL/Redis/Kafka，以及《DDIA（设计数据密集型应用）》和《亿级流量系统架构设计与实战》这两本书展开，为这些熟悉的主题都加上一个&quot;AI 视角&quot;。</p>
<p>废话不多说，接下来我们来看看 Gemini 给出的答卷吧~</p>
<hr>
<h2>术</h2>
<h3>一、AI 时代的基础架构转变</h3>
<p>人工智能（AI）的发展并非要取代经典的后端工程原则，而是要求我们对其进行演进。我们将深入剖析从确定性的、低延迟的 Web 服务到概率性的、高延迟的、有状态系统的根本性转变。这一转变是理解和构建下一代 AI 原生应用的基础。</p>
<h4>1.1 从同步请求/响应到异步任务编排</h4>
<p>传统的后端架构建立在同步请求/响应模型之上，客户端发起一个 HTTP 请求，并阻塞等待服务器在几百毫秒内返回结果。然而，大型语言模型（LLM）的引入彻底颠覆了这一范式。一次 LLM 调用可能需要数秒甚至数分钟才能完成，这在传统同步模型中是不可接受的。因此，架构的核心必须转向异步任务编排。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250905183444729.png" alt=""></p>
<h5>任务队列架构</h5>
<p>任务队列架构（Task Queue Architecture）是应对高延迟挑战的首要模式。它将耗时的操作与即时响应解耦，从而保证了用户体验的流畅性。其标准流程如下：</p>
<ol>
<li><strong>任务提交</strong>：客户端通过一个初始 API 调用（例如 <code>POST /api/v1/generate_report</code>）提交一个长耗时任务。请求体中包含所有必要参数，如报告主题、数据源等。</li>
<li><strong>任务入队与即时响应</strong>：API 服务器接收到请求后，并不直接执行任务。相反，它将任务封装成一个消息，推送到一个消息队列（如 Kafka 或 RabbitMQ）中。随后，服务器立即向客户端返回一个唯一的 <code>task_id</code>，这个过程通常在几十毫秒内完成，从而释放客户端连接，避免了超时。</li>
<li><strong>异步处理</strong>：一个独立的、可水平扩展的工作者（Worker）集群消费消息队列中的任务。这些工作者负责执行实际的、耗时的 LLM 调用、数据处理和结果生成。</li>
<li><strong>结果持久化</strong>：任务完成后，工作者将结果（或指向结果的引用）存储在一个持久化存储系统中，如关系型数据库或 Redis，并与 <code>task_id</code> 关联。</li>
</ol>
<h5>结果交付机制</h5>
<p>一旦任务被异步处理，客户端需要一种机制来获取最终结果。主要有两种模式：</p>
<ul>
<li><strong>轮询（Polling）</strong>：客户端使用获取到的 <code>task_id</code>，周期性地调用一个状态查询 API（例如 <code>GET /api/v1/tasks/{task_id}/status</code>）。服务器返回任务的当前状态（如 <code>PENDING</code>, <code>IN_PROGRESS</code>, <code>SUCCESS</code>, <code>FAILED</code>）。当状态为 <code>SUCCESS</code> 时，响应中会包含最终结果或结果的访问链接。这种方法实现简单，但会产生大量无效请求，效率较低。</li>
<li><strong>WebSocket 或服务器推送事件（Server-Sent Events, SSE）</strong>：这是一种更高效、用户体验更佳的模式。客户端在提交任务后，与服务器建立一个持久连接。当任务完成时，服务器通过此连接主动将结果推送给客户端。对于 LLM 的流式生成（token-by-token streaming），SSE 尤其适用，它能让用户实时看到文本的生成过程，极大地改善了感知延迟。</li>
</ul>
<h5>异步系统中的韧性设计</h5>
<p>异步架构的引入也带来了新的韧性挑战。借鉴《设计数据密集型应用》（DDIA）和现代系统设计的原则，必须构建一个能够&quot;为失败而设计&quot;的系统 。</p>
<ul>
<li><strong>死信队列（Dead-Letter Queues, DLQ）</strong>：当一个任务因为 LLM API 错误、数据格式问题或其他原因处理失败，并且重试次数达到上限后，该任务消息不应被丢弃，而应被发送到一个专门的 DLQ。这使得开发人员可以后续分析失败原因，进行手动干预或修复，而不会丢失用户请求 。</li>
<li><strong>指数退避重试（Exponential Backoff Retries）</strong>：对外部服务（如 LLM API）的调用可能会遇到瞬时故障或速率限制。工作者在处理失败时，应采用带抖动的指数退避策略进行重试，避免在短时间内用大量重试请求冲击下游服务 。</li>
<li><strong>幂等性（Idempotency）</strong>：在分布式系统中，消息可能会被重复投递。工作者必须设计成幂等的，即多次处理同一个任务消息应产生与一次处理相同的结果。这通常通过在任务消息中包含一个唯一的幂等性密钥来实现，工作者在处理前检查该密钥是否已被处理过 。</li>
</ul>
<h4>1.2 在分布式、对话式世界中管理状态</h4>
<p>传统的高并发后端服务通常被设计为无状态的，以便于水平扩展和负载均衡。然而，AI 对话天生就是有状态的。用户发出的第二句&quot;那第二个呢？&quot;完全依赖于第一句的上下文。如果这两次请求被负载均衡到不同的服务实例上，系统将无法理解对话的延续性。</p>
<h5>外部化状态存储</h5>
<p>解决这个矛盾的规范方案是将状态从服务内存中剥离，存储到外部共享的存储系统中。这种&quot;外部化状态存储&quot;模式确保了任何服务实例都可以通过访问共享存储来获取完整的对话上下文。</p>
<ul>
<li><strong>MySQL/PostgreSQL</strong>：作为对话历史的长期、持久化存储系统。一个设计良好的 schema 应至少包含 <code>users</code>、<code>sessions</code> 和 <code>messages</code> 三张表。<code>messages</code> 表记录每一条消息的内容、发送者（用户或 AI）、时间戳，并通过外键关联到特定的 <code>sessions</code> 表，<code>sessions</code> 表再关联到 <code>users</code> 表。这种结构化的存储不仅保证了数据的持久性，还便于进行后续的分析和审计 。</li>
<li><strong>Redis</strong>：作为 AI 的高性能短期工作记忆。对于正在进行的活跃对话，将其最近的几轮交互历史缓存在 Redis 中，可以极大地降低对主数据库的读取压力。使用 Redis 的 <code>LIST</code> 或 <code>HASH</code> 数据结构，并为每个会话设置一个合理的过期时间（TTL），例如 30 分钟，是一种常见的实践。这确保了在对话期间可以快速加载上下文，同时自动清理不活跃的会话数据 。</li>
</ul>
<h5>权衡分析：粘性会话</h5>
<p>粘性会话是一种在负载均衡层实现的简单方案，它将来自同一用户的所有请求都路由到同一个服务器实例。虽然这可以解决短期内的状态管理问题，但对于严肃、可扩展的 AI 应用而言，它是一种反模式。其主要缺陷包括：</p>
<ul>
<li><strong>单点故障</strong>：如果该服务器实例宕机，用户的所有会话状态将丢失，对话无法继续。</li>
<li><strong>负载不均</strong>：无法实现真正的负载均衡，可能导致某些服务器实例成为热点，资源利用率低下。</li>
<li><strong>扩展性差</strong>：在服务扩缩容时，会话状态的管理变得复杂，可能导致会话中断。</li>
</ul>
<p>这些缺陷违背了现代分布式系统设计的核心原则——韧性和可扩展性 。因此，外部化状态存储是更加推荐的生产级方案。</p>
<h5>对话历史摘要</h5>
<p>当对话变得非常长时，将完整的历史记录附加到每个 LLM 请求中会消耗大量的 Token，从而增加成本和延迟。一种高级的优化策略是引入对话摘要机制。系统可以设计一个后台任务，当检测到某个会话的历史记录超过特定长度（例如 5000 个 Token）时，自动调用一个 LLM 来将之前的对话内容浓缩成一段摘要。在后续的请求中，系统只需传递这段摘要和最近几轮的对话，即可在保留关键上下文的同时，显著节省 Token 消耗 。</p>
<h4>1.3 为概率性系统设计韧性和可观测性</h4>
<p>传统系统的韧性设计主要关注硬件故障、网络分区和软件缺陷等确定性问题 。AI 系统引入了一种全新的、更隐蔽的失败模式：概率性方差。系统可能在基础设施层面完全“健康”，但其输出的内容却是错误的、带有偏见的或有害的。</p>
<h5>面向 LLM 的可观测性技术栈</h5>
<p>传统的监控（Metrics, Logging, Tracing）需要扩展以适应 LLM 的特性。一个完整的 LLM 可观测性技术栈应包括：</p>
<ul>
<li><strong>性能指标</strong>：<ul>
<li>延迟：首 Token 生成时间（Time-to-First-Token, TTFT）、总生成时间。</li>
<li>吞吐量：每秒请求数（RPS）、每分钟处理的 Token 数（TPM）。</li>
</ul>
</li>
<li><strong>成本指标</strong>：<ul>
<li>Token 消耗：精确追踪每次请求、每个用户、每个功能模块的输入与输出 Token 数量。</li>
<li>API 成本：将 Token 消耗与模型定价关联，实现实时的成本监控。</li>
</ul>
</li>
<li><strong>质量指标</strong>：<ul>
<li>幻觉率：追踪模型生成事实性错误的频率。</li>
<li>回答相关性：评估回答是否切中用户问题。</li>
<li>安全性：检测提示注入（Prompt Injection）攻击、有害内容生成等。</li>
<li>模型漂移：监控模型输出的统计分布随时间的变化，以发现性能衰退。</li>
<li>这些质量指标通常需要人工反馈（例如，用户点击“赞”或“踩”）或自动化的评估流水线来衡量。</li>
</ul>
</li>
<li><strong>追踪（Tracing）</strong>：对于由多个 LLM 调用和工具使用组成的复杂 Agent 链，端到端的分布式追踪至关重要。它能可视化整个执行流程，帮助定位延迟瓶颈或逻辑错误。诸如 Langfuse 这样的专用工具在此领域提供了强大的支持 。</li>
</ul>
<h5>优雅降级模式</h5>
<p>当 AI 系统面临压力或故障时，应能优雅地降级，而不是完全崩溃。</p>
<ul>
<li><strong>模型回退（Model Fallbacks）</strong>：设计一个模型优先级策略。当主力的、昂贵的高性能模型（如 GPT-4）调用失败、超时或返回错误时，系统可以自动捕获异常，并使用一个更便宜、更快的次级模型（如 Claude 3.5 Sonnet 或本地部署的 Llama 模型）来处理请求。这保证了服务的可用性，尽管输出质量可能略有下降 。</li>
<li><strong>断路器（Circuit Breakers）</strong>：在调用外部 LLM API 的客户端中实现断路器模式。当 API 的错误率超过预设阈值时，断路器会跳闸，在一段时间内直接拒绝新的请求，而不是让它们超时。这可以防止单个下游服务的故障引发整个系统的级联崩溃。</li>
<li><strong>确定性回退（Deterministic Fallbacks）</strong>：对于那些必须返回结构化数据（如 JSON）的关键任务，如果 LLM 在多次重试后仍然无法生成格式正确的输出，系统应放弃 LLM 调用，转而执行一个简单的、基于规则的确定性逻辑，以确保核心功能的健壮性 。</li>
</ul>
<h5>&quot;失败&quot;定义的演进</h5>
<p>在 AI 驱动的系统中，&quot;失败&quot;不再是一个简单的二元状态（正常/宕机），而是一个质量降级的连续谱。传统服务的失败是明确的，例如返回一个 HTTP 500 错误或请求超时。而一个 LLM 服务的&quot;失败&quot;则可能是返回一个语法完美、结构正确的 JSON 对象，但其中却包含着微妙的幻觉、逻辑矛盾或偏见言论 。</p>
<p>这种转变意味着传统的健康检查（health check）机制，即简单地检查服务是否返回 HTTP 200，已经变得毫无意义。一个真正具有韧性的 AI 系统，其健康的概念必须从&quot;可用性&quot;扩展到&quot;质量&quot;。这要求建立一个&quot;<strong>语义健康检查</strong>&quot;层。该层会持续地运行一组预定义的黄金测试用例（golden test cases），或根据业务规则对模型的实时输出进行评估，从而量化模型的质量 。</p>
<p>因此，AI 系统的韧性工程与 MLOps（机器学习运维）变得密不可分。后端团队现在必须将自动化评估流水线（Evals pipeline）作为生产准备和监控的核心组成部分。模型的质量严重下降应被视为与服务宕机同等级别的 P1 级事故，并触发相应的告警和应急响应流程</p>
<h3>二、数据层的重构：为 AI 设计存储于检索</h3>
<p>在 AI 时代，数据层的功能被极大地扩展了。它不再仅仅是存储业务数据的仓库，而是扮演着 AI 系统的长期记忆、短期记忆和语义记忆的角色。本部分将重新审视数据库技术，并探讨如何为 AI 应用构建一个高效、可扩展的数据基石。</p>
<h4>2.1 关系型数据库（MySQL/Postgres）：AI 的记忆系统</h4>
<p>尽管向量数据库在 AI 领域备受关注，但关系型数据库（RDBMS）仍然是任何生产级检索增强生成（Retrieval-Augmented Generation, RAG）系统的核心支柱。它为 LLM 处理的非结构化数据提供了结构化的元数据和&quot;地面实况&quot;（ground truth），是系统可信度和可追溯性的保障。</p>
<p><strong>高级 RAG 数据库 Schema 设计</strong></p>
<p>一个生产就绪的 RAG 系统需要一个精心设计的数据库 schema，以管理从原始文档到最终对话的全生命周期。以下是一个经过验证的 schema 范例 ：</p>
<ul>
<li><strong><code>documents</code> 表</strong>：存储源文档的元数据。<ul>
<li><code>id</code> (PK), <code>file_name</code>, <code>source_url</code>, <code>document_type</code> (e.g., PDF, Markdown), <code>uploader_id</code> (FK), <code>processing_status</code> (e.g., PENDING, PROCESSED, FAILED), <code>created_at</code>, <code>updated_at</code>。</li>
</ul>
</li>
<li><strong><code>document_chunks</code> 表</strong>：这是 RAG 的核心表，存储切分后的数据块。<ul>
<li><code>id</code> (PK), <code>document_id</code> (FK to <code>documents</code>), <code>chunk_text</code> (TEXT), <code>chunk_metadata</code> (JSONB, e.g., page number, section headers), <code>vector_id</code> (VARCHAR, indexed)。</li>
<li><code>vector_id</code> 字段至关重要，它建立了关系型数据与向量数据库中嵌入向量之间的一一对应关系。这使得当从向量数据库检索到一个相似的向量时，系统可以快速回溯到其原始文档、上下文和元数据，实现了完整的可追溯性。</li>
</ul>
</li>
<li><strong><code>prompts</code> 表</strong>：用于版本化管理 Prompt 模板。<ul>
<li><code>id</code> (PK), <code>prompt_name</code> (VARCHAR, unique), <code>version</code> (INT), <code>template_text</code> (TEXT), <code>variables</code> (JSONB), <code>is_active</code> (BOOLEAN)。</li>
<li>将 Prompt 与应用代码解耦，允许运营或算法人员在不重新部署服务的情况下，通过修改数据库中的模板来优化、测试和回滚 Prompt，这是关键的 MLOps 实践。</li>
</ul>
</li>
<li><strong><code>conversation_history</code> 表</strong>：如 1.2 节所述，用于持久化存储对话历史。<ul>
<li><code>id</code> (PK), <code>session_id</code> (FK), <code>user_id</code> (FK), <code>message_content</code> (TEXT), <code>sender_role</code> (e.g., &#39;user&#39;, &#39;ai&#39;), <code>timestamp</code>。</li>
</ul>
</li>
</ul>
<h4>2.2 内存数据库（Redis）：AI 的工作记忆</h4>
<p>Redis 的亚毫秒级延迟使其成为 AI 系统理想的 L1 缓存或工作记忆，能够显著降低重复性或状态依赖操作的延迟和成本。</p>
<p><strong>语义缓存（Semantic Caching）</strong></p>
<p>这是 Redis 在 AI 领域最具变革性的应用。传统的缓存基于键的精确匹配，而语义缓存则基于含义的相似性。其工作流程如下：</p>
<ol>
<li>当系统收到一个新的用户查询时，首先调用嵌入模型将其转换为一个向量。</li>
<li>然后，在一个专门用于存储&quot;历史查询及其答案&quot;的 Redis 向量索引中，执行一次向量相似性搜索。</li>
<li>如果找到了一个或多个在语义上高度相似（例如，余弦相似度 &gt; 0.95）且已被回答过的查询，系统可以直接返回其缓存的答案，从而完全跳过对昂贵的 LLM API 的调用。</li>
<li>对于问答机器人、客服等场景，大量用户的提问在语义上是重复的。语义缓存这一模式能够拦截大部分此类请求，极大地降低 API 调用成本和响应延迟。</li>
</ol>
<p><strong>对话历史缓存</strong></p>
<p>如 1.2 节所述，将活跃对话的最近几轮交互缓存在 Redis 中，并设置 TTL。这避免了在对话的每一次轮转中都去查询主数据库，显著提升了交互的流畅性。</p>
<p><strong>Agent 中间步骤缓存</strong></p>
<p>对于复杂的、多步骤的 Agent 工作流（例如：规划一次为期五天的东京旅行），Agent 可能需要依次调用多个工具（查询航班、搜索酒店、规划行程等）。可以将每一步工具执行成功后的结果缓存在 Redis 中，键为 <code>task_id</code> 和 <code>step_number</code>。如果 Agent 在第四步失败需要重试，它可以直接从 Redis 中加载前三步的缓存结果，而无需从头开始执行，这节省了大量的时间和 API 调用成本。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250905215321383.png" alt=""></p>
<h4>2.3 RAG 摄入流水线：数据密集型设计的案例研究</h4>
<p>生产级的 RAG 首先是一个数据工程问题，其次才是一个 LLM 问题。最终输出的质量遵循&quot;垃圾进，垃圾出&quot;的原则，而许多&quot;垃圾&quot;正是在数据摄入和切块（Chunking）阶段产生的 。</p>
<p><strong>流水线阶段</strong></p>
<p>一个健壮的 RAG 摄入流水线应包含以下阶段：</p>
<ol>
<li><strong>加载 (Load)</strong>：使用文档加载器从各种数据源（如 S3、网页、Notion、Confluence）摄入原始文档 。</li>
<li><strong>提取与清洗 (Extract &amp; Clean)</strong>：从 PDF、HTML 等格式中解析出纯文本。然后进行数据清洗，包括移除模板化的页眉页脚、标准化文本格式（如统一编码）、纠正常见拼写错误等 。</li>
<li><strong>切块 (Chunk)</strong>：这是整个流水线中最关键、也最需要技巧的一步。切块的质量直接决定了检索结果的质量。</li>
</ol>
<p><strong>切块策略对比分析</strong></p>
<p>切块是一个看似简单但实则复杂的问题，错误的选择会导致大海捞针或上下文中丢失等问题，即相关信息被切分到不同块中或被淹没在大量不相关信息中，从而影响 LLM 的最终表现 。将切块从一门艺术转变为一项工程决策，需要对不同策略进行系统性评估。</p>
<p>即相关信息被切分到不同块中或被淹没在大量不相关信息中，从而影响 LLM 的最终表现 。将切块从一门“艺术”转变为一项工程决策，需要对不同策略进行系统性评估。</p>
<table>
<thead>
<tr>
<th>策略名称</th>
<th>描述</th>
<th>优点</th>
<th>缺点</th>
<th>最佳适用场景</th>
</tr>
</thead>
<tbody><tr>
<td><strong>固定大小切块 (Fixed-Size)</strong></td>
<td>按固定数量的字符或 Token 进行切分，可设置重叠部分</td>
<td>实现简单，块大小可预测，便于批处理。</td>
<td>常常会粗暴地切断句子和完整的语义单元，破坏上下文。</td>
<td>格式统一、无明显结构特征的简单文本，如日志文件。</td>
</tr>
<tr>
<td><strong>递归字符切块 (Recursive Character)</strong></td>
<td>使用一个优先级列表（如 <code>\n\n</code>, <code>\n</code>, <code> </code>）进行递归切分，直到块大小达标</td>
<td>努力尊重原文的段落、句子等语义边界，是很好的通用选择。</td>
<td>对于格式混乱的文本，仍可能产生不理想的切分。</td>
<td>大多数通用文本文档，如新闻文章、博客、网页内容。</td>
</tr>
<tr>
<td><strong>文档感知切块 (Document-Aware)</strong></td>
<td>根据文档自身的结构进行切分，如按 Markdown 的标题、HTML 的标签、代码的函数或类</td>
<td>生成的块具有极高的语义内聚性和上下文完整性。</td>
<td>需要为每种文档类型编写或使用特定的解析器。</td>
<td>结构化或半结构化文档，如 Markdown、HTML、源代码文件。</td>
</tr>
<tr>
<td><strong>语义切块 (Semantic)</strong></td>
<td>使用嵌入向量将语义上相似的句子聚合在一起，形成一个块</td>
<td>语义内聚性最高；块的划分基于“意义”而非语法或格式。</td>
<td>计算成本高，需要在切块阶段额外进行一次嵌入和聚类。</td>
<td>内容密集、叙事性强的文本，其中概念和主题比结构更重要。</td>
</tr>
<tr>
<td><strong>Agentic 切块 (Agentic)</strong></td>
<td>利用 LLM 自身来判断文档中最合理的切分边界</td>
<td>潜力巨大，能模拟人类编辑的逻辑来切分复杂文档。</td>
<td>速度极慢、成本高昂且结果不确定，目前仍处于实验阶段。</td>
<td>其他方法均告失败的、高度复杂和异构的文档。</td>
</tr>
</tbody></table>
<h3>三、神经系统：使用 Kafka 进行异步处理</h3>
<p>在本部分中，Kafka 的角色被重新定义：它不再仅仅是一个用于服务解耦的消息队列，而是整个 AI 系统的中央数据总线和&quot;神经系统&quot;，负责编排从数据摄入到智能体协作的各种复杂工作流。</p>
<h4>3.1 Kafka 作为 RAG 和微调流水线的支柱</h4>
<p><strong>RAG 摄入流水线</strong></p>
<p>如前所述，Kafka 是构建可扩展、可观测的 RAG 摄入流水线的理想选择。一个标准的 Kafka 拓扑结构如下 ：</p>
<ol>
<li><strong><code>documents-to-process</code> 主题 (Topic)</strong>：当新文档被上传或发现时，一个生产者服务将文档的 URI 或引用发布到此主题。</li>
<li><strong><code>chunks-to-embed</code> 主题</strong>：一个切块器（Chunker）服务消费 <code>documents-to-process</code> 主题。它下载文档、根据预定策略进行切块，然后将每个数据块（包含文本和元数据）作为独立消息发布到此主题。</li>
<li><strong><code>chunks-to-index</code> 主题</strong>：一个嵌入器（Embedder）服务消费 <code>chunks-to-embed</code> 主题。它调用嵌入模型为每个数据块生成向量，然后将包含数据块、元数据和向量的消息发布到此主题。</li>
<li><strong>索引</strong>：最后，一个索引器（Indexer）服务消费 <code>chunks-to-index</code> 主题，并将数据块的元数据写入关系型数据库，同时将向量写入向量数据库。</li>
</ol>
<p>这种基于 Kafka 的流水线架构具有高度的解耦性。每个服务（切块器、嵌入器、索引器）都可以独立开发、部署、扩展和监控，从而构建一个极具弹性和性能的系统。</p>
<p><strong>微调反馈闭环</strong></p>
<p>除了数据摄入，Kafka 还能构建一个实时的模型微调（Fine-Tuning）反馈闭环，实现模型的持续学习和优化 。</p>
<ol>
<li><strong>收集反馈</strong>：应用前端或后端服务将用户的隐式或显式反馈（例如，对回答点&quot;赞&quot;或&quot;踩&quot;、用户手动修正 AI 的回答、对话的最终满意度评分等）作为事件发布到 Kafka 的 <code>feedback</code> 主题。</li>
<li><strong>实时处理</strong>：一个流处理应用（如使用 Apache Flink 或 Kafka Streams）消费 <code>feedback</code> 主题。它对反馈数据进行实时聚合、过滤和清洗，筛选出高质量的训练样本（例如，被用户明确标记为&quot;好&quot;的问答对）。</li>
<li><strong>数据沉淀</strong>：处理后的高质量数据被写入一个专门用于模型训练的数据湖或数据仓库。</li>
<li><strong>触发微调</strong>：当积累的新训练样本达到一定数量（例如 1000 条）时，流处理应用会发布一个事件到 <code>trigger-finetuning-job</code> 主题。</li>
<li><strong>自动化训练</strong>：一个 MLOps 服务监听此主题，并自动启动一个新的模型微调作业，使用最新的数据对基础模型进行优化。</li>
</ol>
<p>这个闭环将用户交互与模型迭代无缝连接起来，使 LLM 能够动态地适应特定领域的需求和用户偏好。</p>
<h4>3.2 为 LLM 工作负载设计 Kafka</h4>
<p>将 Kafka 应用于 LLM 工作负载时，需要考虑其独特的数据特性。</p>
<p><strong>主题与分区策略</strong></p>
<p>为了保证对话的上下文顺序，使用一个与对话或用户相关的标识符（如 <code>user_id</code> 或 <code>session_id</code>）作为消息的分区键（Partition Key）至关重要。这确保了来自同一个对话的所有事件都会被发送到同一个分区，并由同一个消费者实例按顺序处理，从而避免了上下文错乱的问题 。</p>
<p><strong>消息负载管理</strong></p>
<p>LLM 的请求和响应，尤其是包含长对话历史的，可能会非常大。处理大消息负载有以下策略：</p>
<ul>
<li><strong>序列化与压缩</strong>：使用如 Avro 这样的二进制序列化框架，它提供了 schema 演进的支持，比 JSON 更紧凑。同时，启用高效的压缩算法（如 lz4 或 zstd）可以显著减少消息的体积，降低网络传输和存储开销 。</li>
<li><strong>声明检查模式</strong>：对于超过 Kafka 消息大小限制（通常为 1MB）的超大负载，可以采用声明检查模式。即将实际的大负载内容（如整个文档）存储在外部对象存储（如 S3）中，而在 Kafka 消息中只传递该对象的引用（URI 或 key）。消费者接收到消息后，再根据引用去 S3 下载完整内容。</li>
</ul>
<p><strong>消费者组扩展</strong></p>
<ul>
<li><strong>无状态任务</strong>：对于像&quot;嵌入器&quot;这样无状态的任务，可以通过增加消费者组中的消费者实例数量来轻松实现水平扩展，从而提高处理吞吐量。</li>
<li><strong>有状态任务</strong>：对于需要维护顺序的有状态任务（如处理特定用户的对话流），扩展性取决于分区的数量。增加分区数可以提高并行度，但需要预先规划。</li>
</ul>
<h4>3.3 从服务解耦到智能体编排</h4>
<p>Kafka 在 AI 系统中的应用，代表了一次深刻的架构范式演进。在传统的微服务架构中，Kafka 主要用于服务解耦，以提升系统的韧性和可扩展性。而在多智能体（Multi-Agent）AI 系统中，Kafka 的角色升华为智能体之间的发现、协作与通信总线，从而催生出预先未明确设计的复杂、涌现式工作流。</p>
<p><strong>基于 Kafka 的智能体架构</strong></p>
<p>一个复杂的任务，如“分析某公司的最新季度财报”，可以被分解并由多个专职智能体通过 Kafka 协作完成 ：</p>
<ol>
<li>一个<strong>编排者智能体 (Orchestrator Agent)</strong> 接收到高层目标后，将其分解，并向 <code>tasks</code> 主题发布一个初始任务，例如 <code>{&quot;task_type&quot;: &quot;fetch_financial_report&quot;, &quot;company&quot;: &quot;XYZ&quot;}</code>。</li>
<li>一个专门的<strong>搜索智能体 (Search Agent)</strong> 订阅了 <code>fetch_financial_report</code> 类型的任务。它监听到该任务后，执行网络搜索或 API 调用，找到财报，然后将财报的原始内容发布到 <code>data-to-analyze</code> 主题。</li>
<li>一个<strong>分析智能体 (Analysis Agent)</strong> 订阅了 <code>data-to-analyze</code> 主题。它读取财报内容，进行关键指标提取和分析，然后将结构化的分析结果发布到 <code>analysis-results</code> 主题。</li>
<li>一个<strong>总结智能体 (Summarization Agent)</strong> 订阅了 <code>analysis-results</code> 主题，读取分析结果并生成一份人类可读的摘要报告，发布到 <code>final-reports</code> 主题。</li>
<li>最后，编排者智能体从 <code>final-reports</code> 主题获取最终报告，并将其呈现给用户。</li>
</ol>
<p>这种架构是去中心化且高度可扩展的。未来如果需要增加新的能力，例如&quot;情感分析&quot;，只需开发一个新的<strong>情感分析智能体</strong>，让它订阅 <code>data-to-analyze</code> 主题，并将结果发布到一个新的 <code>sentiment-results</code> 主题即可，而无需修改任何现有智能体的代码 。</p>
<p><strong>Kafka 作为涌现式智能的基础</strong></p>
<p>传统事件驱动架构（EDA）中的事件通常是事实的宣告，例如 <code>OrderCreated</code> 。工作流是相对固定的，服务的响应是被动和预定义的。</p>
<p>相比之下，多智能体系统中的通信更具动态性和目的性 。一个智能体发布的消息可能不是一个事实，而是一个子目标、一个证据片段或一个求助请求。Kafka 在此扮演了经典 AI 中的黑板系统（Blackboard System）角色：一个共享的协作空间。一个智能体的输出可以成为另一个智能体自发行动的输入，而无需一个中心化的编排器对它们进行显式的硬编码连接。</p>
<p>这意味着系统的整体智能超越了其各个组成部分智能的总和。后端架构师的角色也随之演变：<u>不再仅仅是设计数据管道的管道工，而是设计一个能让智能体高效互动的数字生态系统的设计师</u>。这种架构范式的转变，将是未来构建更高级、更自主 AI 系统的关键。</p>
<h3>四、高性能实现：Go 与 Rust</h3>
<p>将架构理念转化为现实，需要选择合适的编程语言。本部分将探讨为什么 Go 和 Rust 这两种现代语言在 AI 后端堆栈的不同层次上表现出色，并如何协同工作以构建高性能系统。</p>
<h4>4.1 Go：并发 AI 编排语言</h4>
<p>Go 语言的并发模型，特别是其轻量级线程 Goroutine 和用于通信的 Channel，与 AI 后端的核心工作负载——编排大量并发的、I/O 密集的对 LLM API 和其他外部服务的调用——几乎完美契合 。</p>
<p>Go 中的生产级并发模式：</p>
<ul>
<li><p><strong>扇出/扇入模式 (Fan-out/Fan-in) 用于并行 RAG 检索</strong>：在 RAG 的检索阶段，为了获取最全面的上下文，通常需要同时从多个数据源（如向量数据库、关系型数据库、全文搜索引擎、Web API）进行查询。使用 Go 的 <code>sync.WaitGroup</code> 和 Channel，可以轻松实现并行检索，从而将总延迟降低到最慢的那个数据源的延迟水平。</p>
<p>一个具体的实现模式是：为每个数据源启动一个 Goroutine 进行查询。所有 Goroutine 将其结果发送到同一个 Channel。主 Goroutine 等待所有查询完成后（通过 <code>WaitGroup</code>），再从 Channel 中收集并合并所有结果。</p>
</li>
<li><p><strong>工作者池 (Worker Pools) 用于 API 速率限制</strong>：LLM API 通常有每分钟请求数（RPM）和每分钟 Token 数（TPM）的限制。为了避免因超出限制而被拒绝服务（HTTP 429 错误），可以使用 Go 实现一个工作者池。该池维护固定数量的 Goroutine，从一个任务 Channel 中获取请求并执行。这可以有效地控制对外部 API 的并发调用数量，平滑请求峰值，并实现优雅的背压（backpressure） 。</p>
</li>
<li><p><strong>使用 Channel 实现流式响应</strong>：为了提供更好的用户体验，LLM 的响应通常以流式（token-by-token）方式返回。Go 的 Channel 是实现这一功能的理想工具。后端服务在接收到 LLM API 返回的 token 流时，可以立即将其写入一个 Channel。另一个 Goroutine 则从该 Channel 读取 token，并通过服务器推送事件（SSE）或 WebSocket 将其转发给前端客户端。</p>
</li>
</ul>
<h4>4.2 Rust：高性能推理语言</h4>
<p>如果说 Go 是 AI 编排层的王者，那么 Rust 则在 AI 技术栈性能最关键的核心——推理引擎——中大放异彩。Rust 对内存安全的极致追求、零成本抽象以及对底层硬件的精细控制，使其成为从 GPU 硬件中压榨出每一分性能的理想选择 。</p>
<p>Rust 在 AI 技术栈中的角色：</p>
<ul>
<li><strong>推理引擎</strong>：尽管许多流行的推理框架（如 vLLM）使用 Python 构建，但在追求极致效率和低资源消耗的生产环境中，越来越多的公司开始转向 Rust。例如，Cloudflare 使用 Rust 开发其下一代 LLM 推理引擎 Infire，以期在低级别实现细节上获得完全控制，从而最大化内存、网络 I/O 和 GPU 的利用率，超越 Python 方案的性能极限 。</li>
<li><strong>性能关键的数据管道组件</strong>：在数据处理流水线中，某些环节（如自定义的分词器、高效的数据序列化/反序列化层）对性能要求极高。Rust 是构建这些组件的绝佳选择，其性能可以媲美 C/C++，同时提供了现代语言的内存安全保障。</li>
</ul>
<h4>4.3 Go/Rust 协同工作的多语言架构</h4>
<p>对于复杂的 AI 系统，推荐采用多语言（Polyglot）架构，以发挥不同语言的优势：</p>
<ul>
<li><strong>Go 作为编排层</strong>：负责处理 API 网关、业务逻辑、服务间通信、工作流编排等上层任务。其强大的并发能力和简洁的语法非常适合快速开发和维护复杂的分布式服务。</li>
<li><strong>Rust 作为性能核心</strong>：负责实现性能最敏感的&quot;热路径&quot;组件，如推理服务、嵌入模型服务或高性能数据预处理库。这些 Rust 组件可以作为独立的服务部署，或通过外部函数接口（FFI）被 Go 服务调用。</li>
</ul>
<p>这种架构组合了 Go 的开发效率和 Rust 的运行效率，是构建生产级、高性能 AI 系统的理想范式</p>
<h3>五、高级架构范式与综合</h3>
<p>本部分将综合前述内容，探讨如何应对 AI 系统中最棘手的挑战，并展望未来的架构发展趋势。</p>
<h4>5.1 驯服不确定性：生产就绪工作流的工具箱</h4>
<p>LLM 的内在不确定性（Non-determinism）是其在金融、医疗等任务关键型企业系统中应用的最大障碍 。即使设置 <code>temperature=0</code>，相同的输入在不同时间也可能产生不完全相同的输出，这给系统的可靠性和可测试性带来了巨大挑战 。以下是一个旨在为概率性模型构建确定性“护栏”的综合工具箱。</p>
<p>提升可靠性的策略：</p>
<ol>
<li><strong>强制结构化输出 (Structured Outputs)</strong>：利用 LLM 提供商的特定功能（如 OpenAI 的 JSON 模式、Anthropic 的工具调用功能），强制模型返回符合预定义 JSON schema 的输出。这从根本上消除了因格式错误导致的解析失败，是保证系统健壮性的第一步 。</li>
<li><strong>严格的验证层 (Validation Layer)</strong>：在接收到 LLM 的结构化输出后，绝不能直接信任其内容。必须将其传递给一个严格的验证层（例如，在 Python 中使用 Pydantic，在 Go 中使用结构体验证库）进行数据类型、范围和业务逻辑的校验，然后再传递给下游系统。</li>
<li><strong>带反馈的智能重试 (Intelligent Retries with Feedback)</strong>：如果验证失败，简单的重试（即重复发送相同的 Prompt）效果不佳。更智能的方法是构建一个新的 Prompt，其中包含原始 Prompt、模型返回的错误输出以及具体的验证错误信息，然后请求 LLM &quot;请根据以下错误修正你的输出&quot;。这种&quot;自我修正&quot;的循环能显著提高最终成功的概率 。</li>
<li><strong>确定性解码 (Deterministic Decoding)</strong>：在要求高度一致性的场景下，可以通过解码策略来降低随机性。<ul>
<li><strong>贪心解码 (Greedy Decoding)</strong>：将模型的 <code>temperature</code> 参数设置为 0，使模型在每一步都选择概率最高的 Token。</li>
<li><strong>固定随机种子 (Fixing the Seed)</strong>：在支持设置随机种子的模型或框架中，固定该值。</li>
<li>虽然这些方法不能 100% 保证确定性（因为底层硬件和软件优化也可能引入随机性），但它们能极大地减少输出的可变性 。</li>
</ul>
</li>
<li><strong>集成方法 (Ensemble Methods)</strong>：向多个不同的模型（或同一个模型多次）提交相同的请求，然后对返回的多个结果进行投票或合并处理。例如，对于分类任务，采用少数服从多数的原则；对于内容生成任务，可以选择最一致或最全面的答案。这种方法以增加成本和延迟为代价，换取了更高的稳定性和可靠性 。</li>
</ol>
<h4>5.2 智能体架构的兴起</h4>
<p>许多 AI 应用的未来正从简单的提示-响应或 RAG 模式，转向更自主的智能体（Agent）模式。智能体能够进行推理、规划，并使用工具来完成复杂的多步骤目标 。</p>
<p>关键的智能体设计模式：</p>
<ul>
<li><strong>工具使用 (Tool Use)</strong>：这是智能体架构的核心。系统需要设计成允许 LLM 根据用户意图，自主决定调用哪些外部工具（如 API、数据库查询、代码执行器）来获取信息或执行操作。架构师的核心任务是为这些工具的调用设计一个安全、可靠且可观测的执行环境 。</li>
<li><strong>反思/自我批判 (Reflection / Self-Critique)</strong>：这是一种强大的模式，智能体能够评估并迭代改进自己的输出。例如，一个智能体首先生成报告的初稿；然后，系统启动一个批评家智能体（或使用不同 Prompt 的同一个智能体）来审查初稿，指出其中的逻辑谬误、事实错误或风格问题；最后，初代智能体根据批评意见生成一份经过修订的终稿。这个过程模拟了人类的写作和审查流程，能够显著提升输出质量 。</li>
<li><strong>规划 (Planning)</strong>：对于复杂任务（例如，为我的团队组织一次异地团建），智能体需要具备将其分解为一系列可执行子任务的能力（例如：1. 收集团队成员的偏好；2. 搜索符合条件的场地；3. 检查场地可用性；4. 预订场地和交通等）。像 LangGraph 这样的新兴框架，正致力于为这种有状态的、循环的智能体工作流提供结构化的编程模型 。</li>
</ul>
<h3>总结：现代 AI 工程师的融合技能栈</h3>
<p>&quot;术&quot;篇系统性地剖析了在 AI 时代，后端架构师所面临的核心挑战与范式转变。从根本上说，构建 AI 原生应用不是对传统分布式系统知识的颠覆，而是一次深刻的演进和融合。</p>
<p>现代高级后端工程师必须成为一个多面手，其技能栈需要融合三大领域的深度专业知识：</p>
<ol>
<li><strong>分布式系统设计</strong>：DDIA 中关于可靠性、可扩展性和可维护性的原则依然是基石。但现在必须用新的视角去应用它们，以应对高延迟、有状态和概率性失败等新挑战。</li>
<li><strong>数据工程</strong>：精通 Kafka 这样的数据流平台，以及掌握从数据摄入、清洗、切块到索引的全套 RAG 流水线构建能力，已成为核心竞争力。数据质量直接决定了 AI 系统的智能上限。</li>
<li><strong>机器学习运维 (MLOps)</strong>：后端工程师不再能将模型视为黑盒。必须构建和维护面向 LLM 的可观测性系统，建立自动化的模型评估和反馈闭环，并将模型的质量问题视为与系统宕机同等重要的生产事故。</li>
</ol>
<p>最终，衡量卓越工程能力的新标准，在于是否能架构出不仅可扩展、可靠，而且在面对概率性不确定性时依然具有适应性和韧性的系统。掌握这种在确定性工程与概率性智能之间游刃有余的能力，将是定义下一代顶尖技术专家的关键。</p>
<h2>道</h2>
<p>前面我们深入探讨了在 AI 时代如何应用各项后端技术的&quot;术&quot;。然而，任何精妙的&quot;术&quot;都源于其背后的&quot;道&quot;——那些不随具体技术更迭而改变的根本性原则。若想在 AI 浪潮中立于不败之地，我们必须掌握这些第一性原理。</p>
<p>所以在了解了上篇 Gemini 给出的种种 AI 应用开发解决方案之后，我们尝试让 Gemini 为我们梳理在 AI 后端架构下的四大不变法则，它们是构建未来智能系统的思想基石，帮助我们理解万变中的根本。</p>
<h3>法则一：世界观的革命 —— 从确定性到概率性</h3>
<p>软件工程，在其诞生以来的大部分时间里，都是一个建立在确定性（Determinism）基石上的理想国 。在这里 <code>2+2</code> 永远等于 <code>4</code>，一个函数对相同的输入，永远返回相同的输出。我们的核心挑战是管理复杂的、但可预测的逻辑交互 。这使得软件工程长期以来成为工程领域的异类。一位土木工程师设计的桥梁必须考虑材料强度的公差、风载荷的随机性，其设计目标是&quot;大概率不会坍塌&quot;；而软件工程师则追求&quot;绝对正确&quot;。</p>
<p>大型语言模型（LLM）的出现，彻底颠覆了这一确定性的世界观。当你向 LLM 发出一个提示（Prompt）时，你并非在调用一个传统的函数，而是在<strong>从一个庞大的概率分布中进行采样</strong> 。这意味着，即使所有参数（如 <code>temperature=0</code>）都设为最确定的模式，两次完全相同的输入也可能产生不完全相同的输出，这种现象被称为非确定性（Non-determinism）。</p>
<p>这一根本性的转变，要求我们从&quot;道&quot;的层面重塑对系统设计的认知：</p>
<ol>
<li><strong>&quot;正确&quot;的重新定义</strong>：在确定性世界里，正确是二元的（对或错）。在概率性世界里，正确变成了一个<strong>统计学概念</strong>。一个 AI 系统的输出不再是绝对正确，而是&quot;在多大置信度上是可接受的&quot;。系统的目标从&quot;杜绝错误&quot;转变为&quot;管理和量化错误率&quot;。</li>
<li><strong>&quot;测试&quot;的范式迁移</strong>：传统的单元测试依赖于断言 <code>assert function(input) == expected_output</code>。当 <code>function</code> 的输出是概率性的，这种测试方法便失效了。新的测试范式必须转向<strong>统计验证</strong>：运行大量测试用例，评估输出的整体分布是否符合预期，衡量幻觉率、相关性等宏观质量指标 。</li>
<li><strong>&quot;失败&quot;的认知升级</strong>：传统系统的失败是显性的，如服务宕机、API 返回 500 错误。AI 系统的失败则更加隐蔽和复杂，它可能在基础设施层面完全健康，但其输出的内容却是错误的、有害的或带有偏见的 。因此，架构师必须将**&quot;质量降级&quot;视为与&quot;服务宕机&quot;同等级别的生产事故**，并为此设计相应的监控、告警和优雅降级机制。</li>
</ol>
<blockquote>
<p>[!IMPORTANT]</p>
<p><strong>核心心法</strong>：放弃对绝对控制的执念，拥抱并管理不确定性。将系统设计从追求&quot;不出错的逻辑&quot;转变为构建一个<strong>能够包容、评估并从概率性组件的错误中恢复的、具有韧性的确定性框架</strong> 。</p>
</blockquote>
<h3>法则二：思维的跃迁 —— 从无状态服务到有状态推理</h3>
<p>在过去的十年里，为了应对海量并发，微服务架构的核心信条之一就是<strong>无状态（Stateless）</strong> 。任何一个服务实例都可以处理任何一个请求，因为状态被外部化到了共享的数据库或缓存中。这极大地简化了水平扩展和故障恢复。</p>
<p>然而，AI 的核心能力——无论是多轮对话还是复杂的智能体（Agent）规划——都<strong>天生强依赖于上下文，即本质上是有状态的（Stateful）</strong>。一个没有记忆的 AI 无法进行有意义的推理。这种对状态的内在需求，迫使我们进行一次深刻的思维跃迁。</p>
<ol>
<li><strong>&quot;状态&quot;的内涵扩展</strong>：在传统后端语境中，状态通常指用户的会话数据（Session）或购物车信息。在 AI 语境中，状态的内涵被极大地丰富了，它演变成了 AI 的<strong>记忆系统</strong>，一个多层次、多维度的认知核心 。<ul>
<li><strong>短期记忆（工作记忆）</strong>：当前对话的上下文，用于即时交互，通常存储在 Redis 等高速缓存中 。</li>
<li><strong>长期记忆</strong>：跨越多次会话的知识和用户偏好，是实现个性化的基础 。</li>
<li><strong>情景记忆（Episodic Memory）</strong>：对特定事件和过去交互的记忆，用于从具体经验中学习 。</li>
<li><strong>语义记忆（Semantic Memory）</strong>：关于世界的事实、规则和知识，通常通过 RAG 从知识库中检索 。</li>
</ul>
</li>
<li><strong>架构角色的转变</strong>：过去，状态管理是被剥离和外部化的对象，以保证核心服务的纯粹无状态。现在，<strong>记忆管理（Memory Management）本身成为了 AI 系统的核心架构组件</strong>。架构师的工作不再仅仅是选择一个数据库来存储状态，而是要设计一个高效、分层的记忆系统，确保 AI 能够在正确的时间、以正确的成本，访问到正确的上下文信息 。</li>
</ol>
<blockquote>
<p>[!IMPORTANT]</p>
<p><strong>核心心法</strong>：将 AI 的&quot;状态&quot;从需要管理的负担，升维为需要精心设计的核心资产。架构的重心从&quot;如何消除状态&quot;转变为**&quot;如何构建一个高效、持久且可扩展的认知记忆核心&quot;**。</p>
</blockquote>
<h3>法则三：优化的新方程 —— 从效率到价值</h3>
<p>传统后端系统的优化目标非常纯粹：在有限的资源下，追求<strong>更低延迟（Latency）<strong>和</strong>更高吞吐量（Throughput）</strong>。这是一个二维的优化问题，工程师们通过算法优化、缓存、异步处理等手段，在这个二维空间里寻找最优解。</p>
<p>LLM 的引入，为这个经典的优化问题增加了两个全新的、至关重要的维度：<strong>单次调用的货币成本（Cost）<strong>和</strong>概率性的输出质量（Quality）</strong>。每一次对 LLM API 的调用都在直接消耗预算，并且其返回结果的质量是波动的。</p>
<p>这使得后端架构的优化目标从一个纯粹的技术效率问题，演变成一个复杂的<strong>四维价值方程：<code>价值 = f(延迟, 吞吐量, 成本, 质量)</code></strong>。</p>
<ol>
<li><strong>决策的业务化</strong>：选择哪个模型、是否使用缓存、是否启用更复杂的 Agent 链，这些不再仅仅是技术决策，而是深刻的<strong>业务和产品决策</strong>。例如，一个低延迟、低成本但质量稍逊的模型可能适用于草稿生成，而一个高成本、高延迟但质量顶尖的模型则适用于最终报告的定稿。架构师必须与产品经理紧密合作，理解不同场景下用户对这四个维度的不同容忍度。</li>
<li><strong>架构的动态化</strong>：最优解不再是静态的。一个优秀的 AI 后端架构应该是<strong>价值驱动和动态自适应的</strong>。它应该能够根据请求的类型、用户的重要性、当前的系统负载，甚至预算的消耗情况，动态地在不同的模型、缓存策略和执行路径之间进行路由和切换 。例如，系统可以设计成：<ul>
<li><strong>语义缓存优先</strong>：在调用 LLM 之前，先检查是否有语义相似的问题可以直接返回缓存答案，从而将成本降为零。</li>
<li><strong>模型分级与回退</strong>：优先尝试廉价快速的模型，如果其输出质量不达标（通过评估函数判断），再升级到更昂贵的模型。或者在主力模型不可用时，自动降级到备用模型 。</li>
<li><strong>成本预算控制</strong>：实时追踪 Token 消耗，当接近预算阈值时，可以切换到成本更低的模式或对非核心功能进行熔断 。</li>
</ul>
</li>
</ol>
<blockquote>
<p>[!IMPORTANT]</p>
<p><strong>核心心法</strong>：将系统优化的目标从追求单一维度的&quot;快&quot;，转变为在多维空间中寻找&quot;价值最优&quot;。架构师的角色从性能工程师扩展为<strong>系统经济学家</strong>，其设计的系统应具备在延迟、吞吐、成本和质量之间进行智能权衡与动态调整的能力。</p>
</blockquote>
<h3>法则四：系统的进化 —— 从指令式编排到涌现式生态</h3>
<p>微服务架构通过将庞大的单体应用分解为独立的、功能单一的服务，极大地提升了开发效率和系统的可扩展性 。这些服务之间的协作通常是<strong>指令式和预定义</strong>的，通过 API 调用或消息队列，遵循着由工程师精心设计的、相对固定的工作流（Workflow）。</p>
<p>AI 智能体（Agent）的出现，预示着一种全新的系统范式：从工程师主导的&quot;编排&quot;（Orchestration）到<strong>智能体自主协作的&quot;涌现&quot;（Emergence）</strong>。</p>
<ol>
<li><strong>从&quot;管道工&quot;到&quot;生态设计师&quot;</strong>：在传统微服务架构中，架构师的角色在某种程度上像一个管道工，负责设计和连接服务之间的数据管道。在多智能体系统中，架构师的角色更像一个<strong>生态设计师</strong>。其核心任务不再是硬编码每一个交互步骤，而是创造一个环境和一套规则，让多个专职的、自主的智能体能够在这个环境中<strong>发现彼此、进行通信、协同合作</strong>，以完成一个更高层次的、甚至在设计之初未被完全预料到的复杂目标 。</li>
<li><strong>拥抱&quot;涌现行为&quot;与&quot;可观测性&quot;</strong>：当多个自主智能体开始交互时，系统的整体行为可能超越其任何单个组成部分的能力总和，产生所谓的&quot;涌现行为&quot;。这种行为既是智能体系统威力的来源，也是其复杂性和风险的根源。它可能带来创新的解决方案，也可能导致意想不到的、有害的连锁反应。因此，**可观测性（Observability）**在智能体架构中的重要性被提升到了前所未有的高度。我们需要全新的工具和理念来追踪和理解智能体的决策路径、工具调用、以及智能体之间的交互模式，从而在它们涌现出问题时能够及时发现、理解并干预 。</li>
</ol>
<blockquote>
<p>[!IMPORTANT]</p>
<p><strong>核心心法</strong>：将系统视为一个活的、演化的生态，而非一台静态的、精密的机器。架构师的目标是设计一个<strong>促进有益协作、同时又能约束和观测潜在风险的智能体环境</strong>，从构建可预测的系统，转向引导和管理一个不断演化的、具有涌现智能的系统。</p>
</blockquote>
<h2>结论</h2>
<p>技术的&quot;术&quot;日新月异，今天我们讨论的 Kafka、Redis、Go 或 Rust，明天可能会有新的替代者。然而，上述四大&quot;道&quot;——<strong>拥抱概率性、构建记忆核心、优化价值方程、设计涌现生态</strong>——构成了 AI 时代后端架构的根本。</p>
<p>掌握了这些核心思想，无论未来的技术细节如何演变，我们都能抓住其本质，设计出真正健壮、高效且智能的系统，从而在这场深刻的技术变革中，始终保持前瞻性和领导力。</p>
]]></content:encoded>
    </item>
    <item>
      <title>读书笔记丨《从零构建大语言模型》</title>
      <link>https://hedon.top/blog/note-llm-from-scratch/</link>
      <guid isPermaLink="true">https://hedon.top/blog/note-llm-from-scratch/</guid>
      <pubDate>Sat, 30 Aug 2025 11:22:00 GMT</pubDate>
      <description>通过 500 行代码实现，深入解析从零构建 GPT 风格大语言模型的完整流程：从数据处理、Transformer 架构核心、训练循环到文本生成策略，带你理解大模型背后的工程实现原理。</description>
      <category>读书笔记</category><category>大模型</category>
      <content:encoded><![CDATA[<p>最近在看《从零构建大语言模型》这本书，跟着思路自己也动手码了一个基础款的 LLM，代码不多，拢共 500 来行，完整代码 <a href="https://github.com/hedon-ai-road/llm-from-scratch/blob/main/all_in_one.py">llm-from-scrtach</a>。</p>
<p>所以，这篇读书笔记不打算空谈理论，就想换个方式：试着把这 500 行代码的来龙去脉讲清楚。通过解说代码，把从零构建一个大模型需要经历哪些环节、每个环节的目标和大概原理给串起来。</p>
<p>笔者并非该方向的专业人士，很多东西不会讲得太深（我自己也不懂），所以很多时候都是点到即止。这篇文章更适合那些和我一样的开发者，希望能从一个接地气的工程角度，对大模型的构建原理有个宏观的了解。</p>
<p>我们将采用一种贴近软件开发者思维的<strong>代码执行流</strong>视角，从程序的入口 <code>main</code> 函数开始，顺着调用栈逐层深入，探究数据处理、模型构建、训练循环的每一个细节。当一个模块被调用时，我们便深入其中，直至最底层的实现。</p>
<p>这趟旅程将遵循以下路线图：</p>
<ul>
<li><strong>从 <code>main</code> 函数出发</strong>：探寻程序的入口与总调度中心。</li>
<li><strong>深入数据流水线</strong>：看原始文本如何被加工成模型能够消化的食粮。</li>
<li><strong>解构核心引擎</strong>：层层剖析 <code>GPTModel</code>、<code>TransformerBlock</code> 直至最深处的 <code>MultiHeadAttention</code> 机制。</li>
<li><strong>启动训练循环</strong>：见证模型如何通过损失计算和权重更新，从随机变得智能。</li>
<li><strong>见证文本生成</strong>：观察训练好的模型如何像我们一样，逐字逐句地&quot;思考&quot;和&quot;创作&quot;。</li>
</ul>
<p>让我们即刻出发，揭开大语言模型背后的代码之谜。</p>
<h2>1. 数据准备与采样</h2>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831134803291.png" style="zoom:33%;" />

<p><code>prepare_train_and_val_data</code> 的具体实现如下：</p>
<pre><code class="language-python">def prepare_train_and_val_data(file_path, tokenizer) -&gt; tuple[DataLoader, DataLoader]:
    &quot;&quot;&quot;准备训练和验证数据加载器&quot;&quot;&quot;

    # 步骤 1: 读取原始文本
    with open(file_path, &quot;r&quot;, encoding=&quot;utf-8&quot;) as file:
        text_data = file.read()

    # 步骤 2: 按比例分割成训练和验证两部分：90%训练，10%验证
    train_ratio = 0.9
    split_idx = int(train_ratio * len(text_data))
    train_data = text_data[:split_idx]
    val_data = text_data[split_idx:]

    # 步骤 3: 分别为两部分文本创建 DataLoader
    train_loader = create_dataloader(
        tokenizer,
        train_data,
        batch_size=2,
        max_length=GPT_CONFIG_124M[&quot;context_length&quot;],
        stride=GPT_CONFIG_124M[&quot;context_length&quot;],
        drop_last=True,
        shuffle=True,
        num_workers=0,
    )

    val_loader = create_dataloader(
        tokenizer,
        val_data,
        batch_size=2,
        max_length=GPT_CONFIG_124M[&quot;context_length&quot;],
        stride=GPT_CONFIG_124M[&quot;context_length&quot;],
        drop_last=False,
        shuffle=False,
        num_workers=0,
    )
    return train_loader, val_loader
</code></pre>
<h3>1.1 划分数据集 prepare_train_and_val_data</h3>
<p><code>prepare_train_and_val_data</code> 的代码并不复杂，就是将数据集划分为训练集和验证集，那么第一个问题就来了：<strong><u>模型为何需要训练集和验证集？</u></strong></p>
<p>和人类一样，模型通过<strong>看例子</strong>来学习。我们给它一本&quot;教科书&quot;和配套的&quot;练习册&quot;（<strong>训练集</strong>），让它反复练习，寻找规律。但我们如何知道它是真的学会了，还是仅仅背下了答案（过拟合）呢？</p>
<p>答案是，我们需要一场它从未见过的<strong>模拟考试</strong>（<strong>验证集</strong>）。</p>
<ul>
<li><strong>训练集 (Training Set)</strong>：这是模型学习的<strong>唯一资料</strong>。模型会尽全力去拟合训练集中的数据，目标是在这个数据集上获得尽可能低的出错率（损失）。这对应了代码中 90% 的文本数据 。</li>
<li><strong>验证集 (Validation Set)</strong>：这是一份<strong>被隔离的数据</strong>，模型在训练过程中<strong>绝对不能</strong>用它来更新自己的权重。我们只在训练的特定阶段用它来“考”一下模型，看看模型在“新题型”上的表现如何。这对应了代码中 10% 的文本数据 。</li>
</ul>
<p>如果模型在训练集上表现优异（比如损失很低），但在验证集上表现糟糕，这就亮起了<strong>过拟合</strong>的红灯。这说明模型只是死记硬背了训练题，而没有学到普适的规律。<code>prepare_train_and_val_data</code> 函数的核心使命，就是为模型准备好这两份至关重要的数据集。</p>
<p>它调用了 <code>create_dataloader</code> 函数。我们必须注意 <code>train_loader</code> 和 <code>val_loader</code> 在配置上的一个<strong>关键区别</strong>：</p>
<ul>
<li><p><strong><code>train_loader</code> 的 <code>shuffle</code> 设置为 <code>True</code></strong> 。</p>
<p>这是为了<strong>保证训练的有效性</strong>。在每一轮 (epoch) 训练开始时，<code>DataLoader</code> 都会将训练样本的顺序完全打乱。这就像我们学习时会打乱单词卡片的顺序一样，可以防止模型学到样本的出场顺序这种无关信息，从而迫使它学习更具泛化性的语言规律。</p>
</li>
<li><p><strong><code>val_loader</code> 的 <code>shuffle</code> 设置为 <code>False</code></strong> 。</p>
<p>这是为了<strong>保证评估的客观性和一致性</strong>。验证集是我们的模拟考试，我们希望每次考试的卷子（题目顺序）都是一样的，这样才能客观地比较模型在不同训练阶段的得分，判断它是否真的在进步。</p>
</li>
</ul>
<h3>1.2 构建数据集 create_dataloader</h3>
<p><code>prepare_train_and_val_data</code> 只是一个调度者，真正的数据加工发生在它调用的 <code>create_dataloader</code> 和 <code>GPTDataset</code> 中。</p>
<pre><code class="language-python">def create_dataloader(tokenizer, txt, batch_size=4, max_length=256, stride=128, shuffle=True, drop_last=True, num_workers=0):
    &quot;&quot;&quot;创建数据加载器&quot;&quot;&quot;
    dataset = GPTDataset(txt, tokenizer, max_length, stride)
    dataloader = DataLoader(
        dataset,
        batch_size=batch_size,
        shuffle=shuffle,
        drop_last=drop_last,
        num_workers=num_workers,
    )
    return dataloader
</code></pre>
<p><code>create_dataloader</code> 是一个简单的封装，它的核心是做了两件事：</p>
<ol>
<li><code>dataset = GPTDataset(txt, tokenizer, max_length, stride)</code>：实例化一个 <code>GPTDataset</code> 对象。</li>
<li><code>dataloader = DataLoader(dataset, ...)</code>：将这个 <code>dataset</code> 对象包装成一个 PyTorch 的 <code>DataLoader</code>。</li>
</ol>
<h4>1.2.1 Dataset &amp; DataLoader</h4>
<p>在深入 <code>GPTDataset</code> 结构之前，这里我们先对 PyTorch 中的 2 个关键数据类型 <code>Dataset</code> 和 <code>DataLoader</code> 进行简要介绍，对于 Pytorch 更详细的介绍可参考笔者这篇 <a href="https://hedon.top/2025/08/18/llm/pytorch/">告别死记硬背：一份真正理解 PyTorch 核心设计的指南</a>。</p>
<p>简而言之，<code>Dataset</code> 和 <code>DataLoader</code> 是为了解决&quot;数据集是什么&quot;和&quot;如何使用数据集&quot;这 2 个核心问题，更具体的来说，在数据准备阶段，我们可能会面临以下几个问题：</p>
<ol>
<li>原始数据格式各异，如何统一读取？</li>
<li>数据集可能非常大，无法一次性载入内存，怎么办？</li>
<li>训练时需要对数据进行批量 (batching)、打乱 (shuffling) 和预处理 (preprocessing)，如何高效实现？</li>
<li>如何利用多核 CPU 来加速数据加载，避免 GPU 等待？</li>
</ol>
<p>PyTorch 的解决方案就是 <code>Dataset</code> 和 <code>DataLoader</code> ：</p>
<ul>
<li><code>Dataset</code>：<strong>它定义了&quot;数据集&quot;是什么</strong>。这是一个抽象类，你只需要继承它并实现两个方法：<code>__len__</code>(返回数据集大小) 和 <code>__getitem__</code> (根据索引 <code>idx</code> 返回一条数据)。它解决了如何获取单条数据的问题，将数据访问的逻辑封装起来。</li>
<li><code>DataLoader</code>：<strong>它定义了&quot;如何使用数据集&quot;</strong>。它接收一个 <code>Dataset</code>对象，并在此基础上，优雅地解决了所有工程问题：<ul>
<li><code>batch_size</code>：自动将单条数据打包成一个 batch。</li>
<li><code>shuffle=True</code>：在每个 epoch 开始时自动打乱数据顺序。</li>
<li><code>num_workers</code>：启动多个子进程并行加载数据，极大地提高了数据供给效率。</li>
<li><code>collate_fn</code>：自定义如何将多条样本合并成一个 batch，对于处理非标准数据（如不同长度的句子）非常有用。</li>
</ul>
</li>
</ul>
<p>它们之间的关系如图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830165323656.png" alt="Dataset 与 DataLoader"></p>
<h4>1.2.2 GPTDataset</h4>
<p>现在我们可以来看 <code>GPTDataset</code> 的具体逻辑了：</p>
<pre><code class="language-python">class GPTDataset(Dataset):
    &quot;&quot;&quot;GPT训练数据集&quot;&quot;&quot;
    def __init__(self, txt, tokenizer, max_length, stride) -&gt; None:
        self.input_ids = []
        self.target_ids = []

        token_ids = tokenizer.encode(txt)

        # 使用滑动窗口创建训练样本
        for i in range(0, len(token_ids) - max_length, stride):
            input_chunk = token_ids[i:i+max_length]
            target_chunk = token_ids[i+1:i+max_length+1]
            self.input_ids.append(torch.tensor(input_chunk))
            self.target_ids.append(torch.tensor(target_chunk))

    def __len__(self):
        return len(self.input_ids)

    def __getitem__(self, idx):
        return self.input_ids[idx], self.target_ids[idx]
</code></pre>
<p><code>GPTDataset</code> 继承了 <code>PyTorch</code> 的 <code>Dataset</code> 类型，我们重点来看它的构造函数 <code>__init__()</code>，它分为 2 个步骤：</p>
<ol>
<li>将文本数据转为词元 ID 列表；</li>
<li>使用滑动窗口逐个构建<strong>输入-目标对</strong>，构建整个数据集，分别置于 <code>input_ids</code> 和 <code>target_ids</code> 这 2 个字段中。</li>
</ol>
<h5>1.2.2.1 文本词元化</h5>
<p>现在到了本篇的第一个真正意义上的理论环节，我们需要先搞清楚词元化（即下面这一行代码）到底是在做什么？为什么要这样？有哪些具体的方式方法？</p>
<pre><code class="language-python">token_ids = tokenizer.encode(txt)
</code></pre>
<p>包括大语言模型在内的深度神经网络模型是无法直接处理原始文本的。由于文本数据是离散的，因此我们无法直接用它来执行神经网络训练所需的数学运算。我们需要一种将单词表示为连续值的向量格式的方法（通常是张量 Tensor）。</p>
<p>将数据转换为向量格式的过程通常称为嵌入（embedding），如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830170327803.png" alt=""></p>
<p>要理解 <code>Tensor</code>，我们需要先建立一个最重要的心智模型：<strong>Tensor 的每一个维度 (dimension) 都有其特定的语义含义</strong>。</p>
<p>一个典型的 4D Tensor <code>(B, C, H, W)</code> 在计算机视觉中，其形状 <code>(16, 3, 224, 224)</code> 并不是一串孤立的数字，它的意思是：</p>
<ul>
<li><strong>B (Batch size) = 16</strong>: 这个 Tensor 里有 16 张独立的图像。</li>
<li><strong>C (Channels) = 3</strong>: 每张图像有 3 个通道（R, G, B）。</li>
<li><strong>H (Height) = 224</strong>: 每张图像的高度是 224 像素。</li>
<li><strong>W (Width) = 224</strong>: 每张图像的宽度是 224 像素。</li>
</ul>
<p>在多个维度综合起来语义含义越接近的词，它们的词嵌入向量在空间表示中就越相近，也就越&quot;相似&quot;，如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830170432509.png" alt=""></p>
<p>当把文本转为词嵌入向量之后，我们的训练模型就可以识别这些数据并利用它们进行学习了。</p>
<p>一个完整的文本处理步骤，大概如下图所示：
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830170953531.png" alt=""></p>
<ol>
<li><p>输入文件 <code>This is an example.</code>：这是所有处理的起点，是我们希望模型去理解和回应的原始、非结构化的人类语言。</p>
</li>
<li><p>词元化：原始文本被切分成独立的单元：<code>This</code>, <code>is</code>, <code>an</code>, <code>example</code>, <code>.</code>。</p>
<p>计算机模型无法一次性理解一整个句子。它需要将句子分解成更小的、标准化的单元，这些单元被称为<strong>词元 (Token)</strong>。如图中的文字描述，词元既可以是单词，也可以是标点符号之类的特殊字符。</p>
</li>
<li><p>转换为词元 ID：每个词元被映射到一个唯一的整数：<code>This</code> -&gt; <code>40134</code>, <code>is</code> -&gt; <code>2052</code>, <code>an</code> -&gt; <code>133</code>, <code>example</code> -&gt; <code>389</code>, <code>.</code> -&gt; <code>12</code>。</p>
<p>计算机不认识字符串 <code>This</code>，但它能高效地处理数字 <code>40134</code>。这一步是<strong>将语言世界映射到数字世界</strong>的关键。每一个 ID 都对应着模型词汇表（一个巨大的“字典”）中的一个条目。</p>
</li>
<li><p>生成词元嵌入：一串数字 ID 变成了多个向量（关于词嵌入的具体细节，我们会在后续进行展开）。</p>
<p>即我们前面提到的，单个数字 ID（如 <code>40134</code>）本身是孤立的，不包含任何语义信息，所以我们需要将其转为词嵌入向量，在训练过程中，模型会不断调整这些向量，使得<strong>意思相近的词元，其向量在空间中的位置也相互靠近</strong>。</p>
</li>
<li><p>模型处理与输出：这些嵌入向量组成的序列，最终被送入<strong>类 GPT 的纯解码器 Transformer</strong> 。这是模型的核心大脑。Transformer 模型会分析这些向量之间的关系，理解整个句子的上下文，然后进行计算。经过<strong>后续处理步骤</strong>（如选择概率最高的词元）后，模型会生成一个<strong>输出文本</strong>。</p>
</li>
</ol>
<blockquote>
<p>[!IMPORTANT]</p>
<p>小结一下，从<strong>人类语言 (字符串) -&gt; 语言单元 (词元) -&gt; 机器语言 (数字 ID) -&gt; 数学对象 (嵌入向量) -&gt; 模型输入</strong>，每一步都是为了让原始的、非结构化的文本，变得结构化、数值化，并富含语义信息，最终成为能够被神经网络高效处理的原料。</p>
</blockquote>
<p>一个简单的分词器实现如下所示：</p>
<pre><code class="language-python">class SimpleTokenizer:
    def __init__(self, vocab) -&gt; None:
        self.str_to_int = vocab
        self.int_to_str = {i:s for s, i in vocab.items()}

    def encode(self, text):
        preprocessed = re.split(r&#39;([,.:;?_!&quot;()\&#39;]|--|\s)&#39;, text)
        preprocessed = [item.strip() for item in preprocessed if item.strip()]
        preprocessed = [item if item in self.str_to_int else &quot;&lt;|unk|&gt;&quot; for item in preprocessed]
        ids = [self.str_to_int[s] for s in preprocessed]
        return ids

    def decode(self, ids):
        text = &quot; &quot;.join([self.int_to_str[i] for i in ids])
        # Remove the spaces before specific punctuation marks.
        text = re.sub(r&#39;\s+([,.?!&quot;()\&#39;])&#39;, r&#39;\1&#39;, text)
        return text
</code></pre>
<ol>
<li><code>__init__</code> 初始化词典，里面每一个词元都唯一对应一个 ID；</li>
<li><code>encode</code> 原始文本转为一系列词元 ID，对于不识别的词元，会使用 <code>&lt;|unk|&gt;</code> 特殊标识进行占位，一般来说，还会使用诸如 <code>&lt;|endoftext|&gt;</code> 等特殊标识符来表示文本结束等特殊语义。</li>
<li><code>decode</code> 将词元 ID 列表转回原始文本。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830172025895.png" alt=""></p>
<p>回到本篇的代码实现：</p>
<pre><code class="language-python">token_ids = tokenizer.encode(txt)
</code></pre>
<p>这里我们使用的是现有的 Python 开源库 <code>tiktoken</code>，它基于 Rust 的源代码非常高效地实现了 BPE（Byte Pair Encoder） 算法。</p>
<pre><code class="language-python">import tiktoken

tokenizer = tiktoken.get_encoding(&quot;gpt2&quot;)
text1 = &quot;Hello, do you like tea?&quot;
text2 = &quot;In the sunlit terraces of the palace.&quot;
text = &quot; &lt;|endoftext|&gt; &quot;.join((text1, text2))
ids = tokenizer.encode(text, allowed_special={&quot;&lt;|endoftext|&gt;&quot;})
print(ids)
print(tokenizer.decode(ids))
</code></pre>
<p>输出：</p>
<pre><code class="language-shell">[15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 286, 262, 20562, 13]
Hello, do you like tea? &lt;|endoftext|&gt; In the sunlit terraces of the palace.
</code></pre>
<p>通过输出，我们可以看到 <code>&lt;|endoftext|&gt;</code> 词元被分配了一个较大的词元 ID，即 <code>50256</code>。事实上，用于训练 GPT-2、GPT-3 和 ChatGPT 中使用的原始模型的 BPE 分词器的词汇总量为 <code>50257</code>，这意味着 <code>&lt;|endoftext|&gt;</code> 被分配了最大的词元 ID。</p>
<p>另外，BPE 分词器可以正确地编码和解码未知单词，比如 <code>someunknownPlace</code>。BPE 分词器是如何做到在不使用&lt;|unk|&gt;词元的前提下处理任何未知词汇的呢？</p>
<p>BPE 算法的原理是将不在预定义词汇表中的单词分解为更小的子词单元甚至单个字符， 从而能够处理词汇表之外的单词。因此，得益于 BPE 算法，如果分词器在分词过程中遇到不熟悉的单词，它可以将其表示为子词词元或字符序列，如下图所示。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830172322896.png" alt=""></p>
<p>将未知单词分解为单个字符的能力确保了分词器以及用其训练的大语言模型能够处理任何文本，即使文本中包含训练数据中不存在的单词。</p>
<h5>1.2.2.2 滑动窗口进行数据采样</h5>
<p>分析完了词元化的背后底层逻辑后，我们来看这一部分的代码：</p>
<pre><code class="language-python">class GPTDataset(Dataset):
    def __init__(self, txt, tokenizer, max_length, stride) -&gt; None:
				# ...

        # 使用滑动窗口创建训练样本
        for i in range(0, len(token_ids) - max_length, stride):
            input_chunk = token_ids[i:i+max_length]
            target_chunk = token_ids[i+1:i+max_length+1]
            self.input_ids.append(torch.tensor(input_chunk))
            self.target_ids.append(torch.tensor(target_chunk))
</code></pre>
<p>要理解这段代码，我们需要回归到大语言模型（文本模型）是唯一任务：<strong><u>根据你给出的上文，猜出下一个词应该是什么</u></strong>。</p>
<p>例如，对于句子 <code>Time is an illusion</code>，我们可以为模型制作如下一系列的练习题：</p>
<ul>
<li><strong>问题</strong>：<code>Time</code> -&gt; <strong>答案</strong>：<code>is</code></li>
<li><strong>问题</strong>：<code>Time is</code> -&gt; <strong>答案</strong>：<code>an</code></li>
<li><strong>问题</strong>：<code>Time is an</code> -&gt; <strong>答案</strong>：<code>illusion</code></li>
</ul>
<p>模型需要通过海量的这类&quot;问答对&quot;进行练习，才能逐渐掌握语言的规律。如果手动去制作上亿个这样的问答对，显然是不现实的。代码中的&quot;滑动窗口&quot;机制，就是为了解决这个问题。我们用一个具体的例子来解释这个 <code>for</code> 循环：</p>
<ul>
<li>假设 <code>max_length = 5</code></li>
<li>假设一段文本分词后的 <code>token_ids</code> 是 <code>[10, 20, 30, 40, 50, 60]</code></li>
</ul>
<p>当 <code>for</code> 循环第一次执行时 (<code>i=0</code>)：</p>
<ul>
<li><strong><code>input_chunk = token_ids[0:5]</code></strong> 会切出 <code>[10, 20, 30, 40, 50]</code><ul>
<li>这就是提供给模型的<strong>上下文</strong>，也就是<strong>问题</strong>。</li>
</ul>
</li>
<li><strong><code>target_chunk = token_ids[1:6]</code></strong> 会切出 <code>[20, 30, 40, 50, 60]</code><ul>
<li>这就是模型需要预测的<strong>正确答案</strong>。</li>
</ul>
</li>
</ul>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830172936403.png" alt="滑动窗口示意图" style="zoom:33%;" />

<p>所以我们通过这样一个 for 循环，就可以根据传入的文本 <code>txt</code> 快速生成大量的<strong>输入-目标对</strong>供给模型进行训练和检验。</p>
<hr>
<blockquote>
<p>[!IMPORTANT]</p>
<p>回到 <code>prepare_train_and_val_data</code> 函数，现在我们可以用一句话概括它的全部工作：<strong>它是一个数据准备总管，负责将一本原始小说，严格划分为用于学习的训练集和用于考试的验证集，并最终将它们都加工成模型可以直接使用的、一批一批的、包含(输入-目标)对的标准化数据传送带。</strong></p>
</blockquote>
<h2>2. 初始化模型与优化器</h2>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831134830004.png" style="zoom:33%;" />

<ol>
<li><code>GPTModel()</code> 初始化一个 GPT 模型实例；</li>
<li><code>model.to(device)</code> 是 PyTorch 中用于将模型移动到指定设备（CPU 或 GPU）的方法，深度学习模型在 GPU 上训练速度比 CPU 快很多，通过这种方式可以确保模型和输入数据在同一个设备上。</li>
<li><code>torch.optim.AdamW()</code> 创建一个优化器（optimizer），用于训练神经网络模型。<code>AdamW</code> 是一种优化算法，在训练过程中，优化器会接收损失函数计算出的梯度、使用 AdamW 算法更新模型参数和帮助模型逐步收敛到最优解。在本篇中，我们不对这个进行过多的解释，因为这并不在我们的核心学习目标上。</li>
</ol>
<h3>2.1 模型配置</h3>
<p>在深入代码细节之前，我们先看 <code>GPT_CONFIG_124M</code> 这个<strong>配置字典</strong>。它就像是建造 GPT 模型大厦的<strong>设计蓝图</strong>，定义了模型的规模和所有关键参数：</p>
<pre><code class="language-python">GPT_CONFIG_124M = {
    &quot;vocab_size&quot;: 50257,    # 词汇表大小
    &quot;context_length&quot;: 256,  # 上下文长度
    &quot;emb_dim&quot;: 768,         # 嵌入维度
    &quot;n_heads&quot;: 12,          # 注意力头数量
    &quot;n_layers&quot;: 12,         # Transformer层数
    &quot;drop_rate&quot;: 0.1,       # dropout率
    &quot;qkv_bias&quot;: False       # QKV线性层是否使用偏置
}
</code></pre>
<ul>
<li><p><code>vocab_size</code>: 词汇表里有多少个不同的词元 (Token)。<code>50257</code> 是 GPT-2 使用的标准词汇表大小，即我们前面讨论的 BPE 分词器的词汇表大小。</p>
</li>
<li><p><code>context_length</code>: 模型一次能处理的<strong>最长文本长度</strong>（以词元计）。这里是 256，意味着模型一次最多能看 256 个词元。</p>
</li>
<li><p><code>emb_dim</code>: <strong>嵌入维度</strong>。这是模型内部表示每个词元的向量长度。768 维意味着每个词都会被转换成一个包含 768 个数字的向量，这是模型理解语言的基础。</p>
</li>
<li><p><code>n_heads</code> 和 <code>n_layers</code>: 这两个参数共同决定了模型的<strong>深度和宽度</strong>。<code>n_layers=12</code> 表示我们的模型会堆叠 12 个 <code>TransformerBlock</code>，而 <code>n_heads=12</code> 表示在每个 Block 内部的注意力机制都有 12 个&quot;头&quot;，让模型能从多个角度分析文本。</p>
</li>
<li><p><code>drop_rate</code>: Dropout 比率。这是防止模型过拟合的重要技术。0.1 表示在训练时，每个神经元有 10% 的概率被临时&quot;关闭&quot;，迫使模型学习更鲁棒的特征表示。</p>
</li>
<li><p><code>qkv_bias</code>: 查询-键-值偏置。这个参数控制是否在注意力计算中添加偏置项。False 表示不使用偏置，这是 GPT-2 的设计选择，可能有助于模型的稳定性。</p>
</li>
</ul>
<blockquote>
<p>有些概念你可能还不认识，没关系，我们继续往下看，待会就懂了！· ·</p>
</blockquote>
<h3>2.2 模型结构总览</h3>
<pre><code class="language-python">class GPTModel(nn.Module):
    &quot;&quot;&quot;完整的GPT模型实现&quot;&quot;&quot;
    def __init__(self, cfg) -&gt; None:
        super().__init__()
        self.tok_emb = nn.Embedding(cfg[&quot;vocab_size&quot;], cfg[&quot;emb_dim&quot;])
        self.pos_emb = nn.Embedding(cfg[&quot;context_length&quot;], cfg[&quot;emb_dim&quot;])
        self.drop_emb = nn.Dropout(cfg[&quot;drop_rate&quot;])

        # 堆叠多个Transformer块
        self.trf_blocks = nn.Sequential(
            *[TransformerBlock(cfg) for _ in range(cfg[&quot;n_layers&quot;])],
        )

        self.final_norm = LayerNorm(cfg[&quot;emb_dim&quot;])
        self.out_head = nn.Linear(cfg[&quot;emb_dim&quot;], cfg[&quot;vocab_size&quot;], bias=False)

    def forward(self, in_idx):
        batch_size, seq_len = in_idx.shape
        # 词嵌入 + 位置嵌入
        tok_embeds = self.tok_emb(in_idx)
        pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device))
        x = tok_embeds + pos_embeds
        x = self.drop_emb(x)
        x = self.trf_blocks(x)
        x = self.final_norm(x)
        logits = self.out_head(x)
        return logits
</code></pre>
<p>整个 GPT 模型的架构如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830175651213.png" alt=""></p>
<p><code>GPTModel</code> 类是整个语言模型的顶层封装，其设计目标是构建一个<strong>端到端的、具备自回归（auto-regressive）生成能力</strong>的序列处理架构。从根本上说，任何一个此类模型都必须解决三个核心问题：</p>
<ol>
<li><strong>输入表示 (Input Representation)</strong>：如何将离散的、一维的词元 ID 序列，转化为模型能够处理的、富含信息的连续多维向量？</li>
<li><strong>上下文编码 (Contextual Encoding)</strong>：如何对输入序列中的每个元素进行深度处理，使其向量表示能够充分融合整个序列（尤其是其上文）的上下文信息？</li>
<li><strong>输出投影 (Output Projection)</strong>：如何将模型内部经过深度处理的上下文向量，重新映射回词汇表空间，以生成对下一个词元的概率预测？</li>
</ol>
<p><code>GPTModel</code> 的结构正是围绕这三个核心问题，划分成了三个逻辑清晰的功能区块。</p>
<pre><code class="language-python">class GPTModel(nn.Module):
    def __init__(self, cfg):
        # 区块一：输入表示层
        self.tok_emb = nn.Embedding(...)
        self.pos_emb = nn.Embedding(...)
        self.drop_emb = nn.Dropout(...)

        # 区块二：上下文编码器堆栈
        self.trf_blocks = nn.Sequential(...)

        # 区块三：输出投影层
        self.final_norm = LayerNorm(...)
        self.out_head = nn.Linear(...)

    def forward(self, in_idx):
        # 执行区块一的功能
        tok_embeds = self.tok_emb(in_idx)
        pos_embeds = self.pos_emb(...)
        x = tok_embeds + pos_embeds
        x = self.drop_emb(x)

        # 执行区块二的功能
        x = self.trf_blocks(x)

        # 执行区块三的功能
        x = self.final_norm(x)
        logits = self.out_head(x)
        return logits
</code></pre>
<h3>2.3 输入表示层：从离散符号到情境化向量</h3>
<p>数据流的第一步是将输入的词元索引 <code>in_idx</code> 转换为包含位置信息的向量表示。</p>
<pre><code class="language-python"># GPTModel forward 方法的起始部分
tok_embeds = self.tok_emb(in_idx)
pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device))
x = tok_embeds + pos_embeds
x = self.drop_emb(x)
</code></pre>
<h4>2.3.1 词元嵌入</h4>
<pre><code class="language-python">self.tok_emb = nn.Embedding(cfg[&quot;vocab_size&quot;], cfg[&quot;emb_dim&quot;])
</code></pre>
<p>正如前文所说的，计算机无法直接处理&quot;单词&quot;这样的符号。为了进行数学运算，必须将每个离散的词元映射到一个高维的连续向量空间中。这个过程被称为嵌入(Embedding)。<code>nn.Embedding</code> 是一个简单的查找表。它本质上是一个权重矩阵，维度为 <code>(vocab_size, emb_dim)</code>。输入一个词元的索引，它会返回该索引对应的行向量。这个向量是可训练的，模型在训练过程中会不断调整这些向量，使得在向量空间中语义相近的词元彼此靠近。</p>
<h4>2.3.2 位置嵌入</h4>
<p>理论上，词元嵌入非常适合作为大语言模型的输入。然而，大语言模型存在一个小缺陷——它们的自注意力机制（见后文）无法感知词元在序列中的位置或顺序。嵌入层的工作机制是，无论词元 ID 在输入序列中的位置如何，相同的词元 ID 始终被映射到相同的向量表示，如下图所示。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830193802429.png" alt=""></p>
<p>举个最简单的例子：&quot;人咬狗&quot;和&quot;狗咬人&quot;在上述机制看来，包含的词元集合是相同的。然而，顺序在自然语言中至关重要。</p>
<p>为了实现这一点，可以将位置信息进行嵌入，一般有以下 3 种位置嵌入方式：</p>
<ul>
<li><strong>绝对位置嵌入 (absolute positional embedding)</strong>：直接与序列中的特定位置相关联。对于输入序列的每个位置，该方法都会向对应词元的嵌入向量中添加一个独特的位置嵌入，以明确指示其在序列中的确切位置。例如，序列中的第一个词元会有一个特定的位置嵌入，第二个词元则会有另一个不同的位置嵌入，以此类推。这种方式可以是可学习的，也可以是通过固定的数学函数（如正弦/余弦函数）生成的。</li>
<li><strong>相对位置嵌入 (relative positional embedding)</strong>：关注的是词元之间的相对位置或距离，而非它们的绝对位置。该方法通常在计算注意力分数时，引入一个与词元间距离相关的偏置项，从而让模型学习的是词元之间的&quot;间隔&quot;关系，而不是它们在序列中的&quot;具体坐标&quot;。这种方法使得模型能够更好地适应不同长度（包括在训练过程中从未见过的长度）的序列。</li>
<li><strong>旋转位置嵌入 (Rotary Positional Embedding, RoPE)</strong>：通过一种创新的方式将位置信息融入自注意力机制中。它并非将位置向量直接添加到词元嵌入上，而是根据词元的绝对位置，对其在注意力计算中使用的查询（Query）和键（Key）向量进行旋转。这种精妙的旋转操作使得任意两个词元之间的注意力分数，能够自然地表示出它们的相对位置关系，从而让模型在处理位置信息时既高效又具备强大的长度泛化能力。</li>
</ul>
<p>GPT-2 采用的是可学习的绝对位置嵌入，这种方式简单直接，模型可以在训练中自行学会每个位置的&#39;坐标&#39;信息，对于其设计的固定上下文长度（如 1024）来说已经足够有效。而像 RoPE 这样的相对位置嵌入，则在处理超长文本和提升长度泛化能力方面表现更优，因此被 Llama 等更新的模型所采用。</p>
<p>本书使用的是<strong>绝对位置嵌入</strong>：</p>
<pre><code class="language-python">class GPTModel(nn.Module):
    &quot;&quot;&quot;完整的GPT模型实现&quot;&quot;&quot;
    def __init__(self, cfg) -&gt; None:
				# ...
        self.pos_emb = nn.Embedding(cfg[&quot;context_length&quot;], cfg[&quot;emb_dim&quot;])
        # ...

    def forward(self, in_idx):
        # ...
        pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device))
        x = tok_embeds + pos_embeds
        # ...
</code></pre>
<ol>
<li><strong>参数初始化</strong>：<code>self.pos_emb = nn.Embedding(cfg[&quot;context_length&quot;], cfg[&quot;emb_dim&quot;])</code> 这行代码会初始化一个权重矩阵，也称为查找表。该矩阵的维度是 <code>(context_length, emb_dim)</code>。矩阵的每一行都是一个向量，且每一行都唯一对应一个从 <code>0</code> 到 <code>context_length - 1</code> 的绝对位置索引。这些行向量是模型的可训练参数，其初始值通常是随机设定的。</li>
<li><strong>向量查找</strong>：当模型处理一个具体输入时，<code>torch.arange(seq_len)</code> 会首先生成一个包含该输入序列所有位置索引的张量 (tensor)，例如 <code>[0, 1, 2, ..., seq_len-1]</code>。随后，这个位置索引张量被传递给 <code>self.pos_emb</code> 层。该层会根据索引值，从第一步初始化的权重矩阵中，精确地查找并提取出每一行对应的位置向量，最终构成一个维度为 <code>(seq_len, emb_dim)</code> 的位置嵌入张量 <code>pos_embeds</code>。</li>
<li><strong>信息融合</strong>：<code>x = tok_embeds + pos_embeds</code> 执行向量的逐元素加法操作。此操作将代表词元语义信息的 <code>tok_embeds</code> 张量与上一步生成的位置信息 <code>pos_embeds</code> 张量合并。</li>
</ol>
<p>如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830195222938.png" alt=""></p>
<h4>2.3.3 dropout 掩码</h4>
<p>至此，我们已经通过词元嵌入和位置嵌入的结合，得到了一个信息完备的输入向量。这个向量既包含了词元的语义信息，也明确了其在序列中的顺序，可以说是为模型准备了一份完美的&quot;学习材料&quot;。</p>
<p>然而，在将这份完美的材料送入 Transformer 的核心进行深度加工之前，我们还需要进行一个看似矛盾，却至关重要的操作——故意引入一些不确定性。为什么要这么做呢？**<u>这是为了防止模型在训练中变得过于依赖输入的每一个细节，从而陷入&quot;死记硬背&quot;的陷阱，也就是我们常说的&quot;过拟合&quot;。</u>**为了让模型学会从不完美的信息中也能提取核心规律，我们需要引入一种正则化技术。这正是我们接下来要讨论的 <strong>Dropout</strong>。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/1*iWQzxhVlvadk6VAJjsgXgg.png" alt="Dropout 技术示意图" style="zoom:50%;" />

<p>它的主要目的是<strong>防止模型过拟合</strong>，提升模型的<strong>泛化能力</strong>。在<strong>训练期间</strong>，它会随机地将一部分输入数据置为零，迫使模型不能过度依赖于任何少数的特征，从而学习到更加鲁棒的模式。在<strong>评估和预测时</strong>，Dropout 会自动失效，不会对数据做任何改动。</p>
<pre><code class="language-python">class GPTModel(nn.Module):
    &quot;&quot;&quot;完整的GPT模型实现&quot;&quot;&quot;
    def __init__(self, cfg) -&gt; None:
        # ...
        self.drop_emb = nn.Dropout(cfg[&quot;drop_rate&quot;])
				# ...

    def forward(self, in_idx):
        # ...
        x = self.drop_emb(x)
        # ...
</code></pre>
<p>我们本次的实现总共会有 2 个地方应用到 dropout 技术：</p>
<ol>
<li>在词元嵌入和位置嵌入相加之后，进入第一个 Transformer Block 之前，即上面的代码所做的事情。这是模型遇到的<strong>第一层正则化</strong>。它直接作用于融合了语义和位置信息的初始输入向量 <code>x</code>。通过随机将输入向量中的某些特征置为零，它迫使<strong>后续所有</strong>的 Transformer Block 都不能过度依赖输入向量中的任何单一维度。这相当于从源头上增加了训练难度，要求整个模型学习到对输入特征扰动不敏感的、更本质的规律。</li>
<li>在多头注意力模块内部，计算出注意力权重 <code>attn_weights</code> 并经过 softmax 归一化之后，在用它去加权 <code>Value</code> 向量之前。这种 dropout <strong>不作用于输入向量本身，而是作用于注意力权重</strong>。注意力权重决定了在生成一个词的表示时，应该关注上下文中其他词的程度。在这里应用 dropout，会随机地将某些词与词之间的注意力连接切断（权重置为 0）。这可以防止模型在学习时走捷径，比如过度依赖于某个特定的前文词汇。它鼓励模型去考虑更广泛的上下文信息，而不是仅仅依赖几个最强的信号。（关于注意力模块，我们后面会详细讨论）</li>
</ol>
<hr>
<blockquote>
<p>[!IMPORTANT]
到目前为止，我们已经完整地剖析了 <code>GPTModel</code> 的<strong>输入表示层</strong>。我们从第一性原理出发，理解了为什么需要将离散的词元 ID，通过<strong>词元嵌入（Token Embedding）<strong>和</strong>位置嵌入（Positional Embedding）</strong>，转化为一个融合了<strong>语义</strong>与<strong>顺序</strong>信息的、信息完备的高维向量 <code>x</code>。</p>
<p>这个过程的本质，是将人类的符号语言，翻译成了神经网络能够进行数学运算的、结构化的内部语言。</p>
<p>我们还探讨了 <code>Dropout</code> 技术。它像一个严格的教练，通过在训练中随机遮盖部分信息，强迫模型不能死记硬背，必须学会从不完整的信息中提炼出更本质、更鲁棒的规律，从而提升其泛化能力。</p>
<p>现在，我们有了一批准备就绪、信息丰富且经过初步正则化处理的训练材料。然而，此时此刻，序列中的每一个向量虽然知道了自己是谁以及在哪，但它仍然是一个<strong>独立的、上下文无关的个体</strong>。它并不知道自己与其他词元之间存在着怎样复杂的句法和语义关联。</p>
<p>那么，模型是如何让这些孤立的向量开始&quot;交流&quot;，理解彼此之间的关系，并最终形成对整个序列的深度理解呢？</p>
<p>答案，就藏在 Transformer 架构的革命性核心——<strong>自注意力机制 (Self-Attention Mechanism)</strong> 之中。下面我们就来深入 LLM 中最关键的部分，探究 Transformer 架构的层层细节！</p>
</blockquote>
<h3>2.4 核心处理层：Transformer 块的堆叠</h3>
<p>在 <code>GPTModel</code> 中，我们共使用了 <code>n_layers</code> 个 <code>TransformerBlock</code>：</p>
<pre><code class="language-python">class GPTModel(nn.Module):
    def __init__(self, cfg) -&gt; None:
       	# ...
        # 堆叠多个Transformer块
        self.trf_blocks = nn.Sequential(
            *[TransformerBlock(cfg) for _ in range(cfg[&quot;n_layers&quot;])],
        )
</code></pre>
<p>我们先来看 <code>TransformerBlock</code> 的结构：</p>
<pre><code class="language-python">class TransformerBlock(nn.Module):
    &quot;&quot;&quot;Transformer块：多头注意力 + 前馈网络 + 残差连接&quot;&quot;&quot;
    def __init__(self, cfg) -&gt; None:
        super().__init__()
        self.att = MultiHeadAttention(
            d_in=cfg[&quot;emb_dim&quot;],
            d_out=cfg[&quot;emb_dim&quot;],
            context_length=cfg[&quot;context_length&quot;],
            num_heads=cfg[&quot;n_heads&quot;],
            dropout=cfg[&quot;drop_rate&quot;],
            qkv_bias=cfg[&quot;qkv_bias&quot;],
        )
        self.ff = FeedForward(cfg)
        self.norm1 = LayerNorm(cfg[&quot;emb_dim&quot;])
        self.norm2 = LayerNorm(cfg[&quot;emb_dim&quot;])
        self.drop_shortcut = nn.Dropout(cfg[&quot;drop_rate&quot;])

    def forward(self, x):
        # 第一个子层：多头注意力 + 残差连接
        shortcut = x
        x = self.norm1(x)
        x = self.att(x)
        x = self.drop_shortcut(x)
        x = x + shortcut

        # 第二个子层：前馈网络 + 残差连接
        shortcut = x
        x = self.norm2(x)
        x = self.ff(x)
        x = self.drop_shortcut(x)
        x = x + shortcut
        return x
</code></pre>
<p>它的结构示意图如下所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830202334178.png" alt=""></p>
<h4>2.4.1 注意力机制</h4>
<p>深入探讨大语言模型核心的自注意力机制之前，让我们考虑一下在大语言模型出现之前的没有注意力机制的架构中所存在的问题。假设我们想要开发一个将文本从一种语言翻译成另一种语言的语言翻译模型。如下图所示，由于源语言和目标语言的语法结构不同，我们无法简单地逐个单词进行翻译。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830202911825.png" alt=""></p>
<p>这正是传统的序列处理模型（如 RNN）一个根本缺陷的体现：<strong>信息瓶颈</strong>。它们通过一个循环结构顺序处理文本，导致序列末端的信息很难直接关联到序列开头的遥远信息。</p>
<blockquote>
<p>[!IMPORTANT]</p>
<p><strong>自注意力机制 (Self-Attention)</strong> 的提出，正是为了打破这种信息瓶颈。其根本思想是：<strong>为序列中的每个元素，建立与其他所有元素的直接连接，并动态计算这些连接的强度（即注意力权重）</strong>。这样，模型在处理任何一个词元时，都能拥有一个全局视野，直接审视并借鉴整个上下文。</p>
</blockquote>
<p>在 <code>TransformerBlock</code> 的内部，<code>MultiHeadAttention</code> 模块是其第一个、也是最为关键的子层。它是整个 GPT 模型&quot;智能&quot;的根本来源。要理解它，我们不能一蹴而就。很幸运的是，《从零构建大语言模型》的作者 Sebastian Raschka，为我们提供了一条从简单到复杂的演进路径，如下图所示，这部分的内容非常精华且重要，所以笔者将尽可能将这部分的内容进行完整记录，以帮助读者们更好的理解自注意力机制。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830203719560.png" alt=""></p>
<h5>2.4.1.1 没有可训练权重的简单自注意力机制</h5>
<p>在深入研究包含可训练权重的复杂版本之前，书中首先实现了一个不含任何可训练权重的简化自注意力机制，以便阐明其核心概念 。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830204044917.png" alt=""></p>
<p>如上图所示，这个机制的目标是为输入序列中的每一个词元（Token），计算出一个<strong>上下文向量（Context Vector）</strong> 。这个上下文向量是一种增强版的嵌入，它不仅包含了当前词元自身的信息，还融合了序列中所有其他词元的信息 。这对于理解句子中单词间的关系至关重要 。</p>
<p>计算这个上下文向量的过程分为三步：</p>
<ol>
<li><strong>计算注意力分数</strong>：衡量每个词对其他词的&quot;相关性&quot;或&quot;相似度&quot;。计算每对词之间的点积，得到相似度分数。点积在这里可以被看作是一种衡量相似度的方式：两个向量的点积越大，代表它们之间的对齐程度或相似度越高，注意力分数也越高 。</li>
<li><strong>归一化获取注意力权重</strong>：得到的注意力分数是一些原始的数值，它们的尺度不一。为了使其规范化并易于解释，我们使用 <strong>Softmax 函数</strong>对这些分数进行处理 。Softmax 函数能将一组任意实数转换为一个概率分布，确保所有输出值的和为 1，并且每个值都是正数。这样得到的数值就是&quot;注意力权重&quot;，代表了在当前查询下，序列中每个词元的重要性 。</li>
<li><strong>计算上下文向量</strong>：最后一步，将序列中的每一个词元嵌入向量与其对应的注意力权重相乘，然后将所有结果向量相加 。最终得到的向量就是我们想要的上下文向量，它是整个输入序列的加权和，权重由刚刚计算出的注意力权重决定 。</li>
</ol>
<p>这 3 个步骤实现了自注意力机制的核心思想：</p>
<ul>
<li>看：计算每个词对其他词的关注度</li>
<li>权衡：将关注度转换为权重</li>
<li>融合：根据权重融合所有词的信息</li>
</ul>
<p>最终效果： 每个词的向量表示都包含了整个序列的上下文信息，而不仅仅是自己的信息。这样模型就能理解词与词之间的关系，比如 <code>&quot;journey starts&quot;</code> 中的 <code>&quot;starts&quot;</code> 会更多地关注 <code>&quot;journey&quot;</code> 的信息。</p>
<p>现在我们用代码来演示一下，假设我们有以下输入：</p>
<pre><code class="language-python">inputs = torch.tensor(
    [
        [0.43, 0.15, 0.89],   # Your
        [0.55, 0.87, 0.66],   # journey
        [0.57, 0.85, 0.64],   # starts
        [0.22, 0.58, 0.33],   # with
        [0.77, 0.25, 0.10],   # one
        [0.05, 0.80, 0.55],   # step
    ]
)
</code></pre>
<p>笔者将尝试从第一性原理出发，对代码进行一步步拆解，说明白这个过程为什么能够实现我们期望的目标：<strong>为每个词元（Token）生成一个包含了上下文信息的向量</strong>。</p>
<p>这个问题的核心在于，我们要证明<strong>最终的上下文向量 (Context Vector) 的确融合了其他词元的信息，并且是根据&quot;相关性&quot;来融合的</strong>。</p>
<p>我们将以你例子中的词 <code>starts</code> (第三个词元) 为例，来全程追踪它的变化。</p>
<p><code>starts</code> 的原始输入向量是: <code>[0.57, 0.85, 0.64]</code>。这个向量只代表 <code>starts</code> 本身，它对句子中的其他词一无所知，是孤立的。<u>我们的目标是生成一个新的向量，让这个新的向量知道它前面有 <code>Your journey</code>，后面有 <code>with one step</code>。</u></p>
<p>第一步我们计算注意力分数，是为了发现&quot;谁与我最相关&quot;：</p>
<pre><code class="language-python">attn_scores = torch.empty(6, 6)
for i, x_i in enumerate(inputs):
    for j, x_j in enumerate(inputs):
        attn_scores[i, j] = torch.dot(x_i, x_j)

# 结果如下：
# tensor([[0.9995, 0.9544, 0.9422, 0.4753, 0.4576, 0.6310],
#         [0.9544, 1.4950, 1.4754, 0.8434, 0.7070, 1.0865],
#         [0.9422, 1.4754, 1.4570, 0.8296, 0.7154, 1.0605],
#         [0.4753, 0.8434, 0.8296, 0.4937, 0.3474, 0.6565],
#         [0.4576, 0.7070, 0.7154, 0.3474, 0.6654, 0.2935],
#         [0.6310, 1.0865, 1.0605, 0.6565, 0.2935, 0.9450]])
</code></pre>
<p><code>attn_scores</code> 的第 3 行是：<code>[0.9422, 1.4754, 1.4570, 0.8296, 0.7154, 1.0605]</code>。这行数字告诉我们，<code>starts</code> 这个词与句子中每个词的原始相关性得分分别是：</p>
<ul>
<li>与 <code>Your</code> 的相关性: <code>0.9422</code></li>
<li>与 <code>journey</code> 的相关性: <code>1.4754</code> &lt;-- <strong>非常高</strong></li>
<li>与 <code>starts</code> (自身) 的相关性: <code>1.4570</code> &lt;-- <strong>非常高</strong></li>
<li>与 <code>with</code> 的相关性: <code>0.8296</code></li>
<li>与 <code>one</code> 的相关性: <code>0.7154</code></li>
<li>与 <code>step</code> 的相关性: <code>1.0605</code></li>
</ul>
<p>仅从这一步看，这个机制已经成功地从数学上<strong>发现</strong>了 <code>starts</code> 与 <code>journey</code> 之间的紧密关系，因为它们的点积分数是最高的之一。这完全符合我们对语言的直觉（&quot;旅程&quot;和&quot;开始&quot;在语义上强相关）。</p>
<p>第一步得到的分数是原始的、未经缩放的数值，不易于作为权重使用。比如 <code>1.4754</code> 究竟代表多大的重要性？我们无法直接判断。所以第二步就是进行归一化获取注意力权重：</p>
<pre><code class="language-python"># 2. 归一化获取注意力权重
attn_weights = torch.softmax(attn_scores, dim=-1)
print(attn_weights)

# -1 表示在最后一个维度进行归一化，因为 attn_scores 是一个 [行, 列]，所以这里是在列上进行归一化，使得每行的值（在列维度的总和）为 1
# &quot;沿着列的方向&quot; = 在每一行内部，从左到右（列 0 到列 5）进行归一化
# 每一行都独立进行这个过程，结果每一行的 6 个数字加起来都等于 1
row_2_sum = sum([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581])
print(&quot;Row 2 sum:&quot;, row_2_sum)
print(&quot;All row sums:&quot;, attn_weights.sum(dim=-1))


# 结果如下：
# tensor([[0.2098, 0.2006, 0.1981, 0.1242, 0.1220, 0.1452],
#         [0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581],
#         [0.1390, 0.2369, 0.2326, 0.1242, 0.1108, 0.1565],
#         [0.1435, 0.2074, 0.2046, 0.1462, 0.1263, 0.1720],
#         [0.1526, 0.1958, 0.1975, 0.1367, 0.1879, 0.1295],
#         [0.1385, 0.2184, 0.2128, 0.1420, 0.0988, 0.1896]])
# Row 2 sum: 1.0
# All row sums: tensor([1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000])
</code></pre>
<p>现在，这些数字的意义变得非常清晰了： 当模型在处理 <code>starts</code> 这个词时，它应该将它的注意力这样分配：</p>
<ul>
<li><code>13.90%</code> 的注意力给 <code>Your</code></li>
<li><code>23.69%</code> 的注意力给 <code>journey</code> &lt;-- <strong>权重最高</strong></li>
<li><code>23.26%</code> 的注意力给 <code>starts</code> (自身)</li>
<li><code>12.42%</code> 的注意力给 <code>with</code></li>
<li><code>11.08%</code> 的注意力给 <code>one</code></li>
<li><code>15.65%</code> 的注意力给 <code>step</code></li>
</ul>
<p>这一步将第一步发现的相关性，转化为了具体的、可操作的重要性权重。它明确地告诉我们，为了理解 <code>starts</code>，我们需要重点参考 <code>journey</code> 和 <code>starts</code> 自身的信息。</p>
<p>接下来，这是最关键的一步，我们终于要创造那个&quot;增强版&quot;的向量（上下文向量）了。我们的目标是为词 <code>i</code> (例如 <code>starts</code>) 创建一个<strong>新的表示</strong> $C_i$。这个新的表示 $C_i$ 必须满足两个条件：</p>
<ol>
<li>它必须包含<strong>所有</strong>其他词 <code>j</code> (从 <code>Your</code> 到 <code>step</code>) 的信息。</li>
<li>每个词 <code>j</code> 贡献的信息量，应该由我们刚刚算出的<strong>注意力权重</strong> ${w_{ij}}$ 来决定。权重越高的词，影响越大。</li>
</ol>
<p>现在，让我们思考一下，在数学上，特别是向量空间中，有什么运算可以同时满足这两个条件？<strong>答案就是加权平均 (Weighted Average) 或 加权和 (Weighted Sum)。</strong></p>
<pre><code class="language-python"># 3. 计算上下文向量
all_contexts_vec = attn_weights @ inputs

# 结果如下：
# tensor([[0.4421, 0.5931, 0.5790],
#         [0.4419, 0.6515, 0.5683],
#         [0.4431, 0.6496, 0.5671],
#         [0.4304, 0.6298, 0.5510],
#         [0.4671, 0.5910, 0.5266],
#         [0.4177, 0.6503, 0.5645]])
</code></pre>
<p>矩阵乘法 <code>attn_weights @ inputs</code> 是一种非常高效的、一次性为所有词计算加权求和的方式。本质上是通过以下方式计算的：</p>
<p>$$
C_i = \sum_{j=1}^N w_{ij} \cdot V_j
$$</p>
<p>其中，$w_{ij}$ 是词 i 对词 j 的注意力权重，$V_j$ 是词 j 的原始向量。</p>
<p>现在我们来看这个公式的两个部分：</p>
<p><strong>第一部分：$w_{ij}\cdot V{j}$ （缩放）</strong>：通过这一步，我们为句子中的<strong>每一个词</strong>，都生成了一个<strong>待贡献</strong>的向量。这个向量的意义和原始词一样，但它的影响力已经被其对应的注意力权重精确地调整好了。</p>
<p><strong>第二部分：∑j=1N （求和）</strong>：这一步是把所有这些待贡献的向量<strong>全部加起来</strong>：$C_{starts}=(w_{s,y}⋅V_y)+(w_{s,j}⋅V_j)+(w_{s,s}⋅V_s)+…$。这一步的几何意义是：在那个高维的意义空间里，我们从原点出发，先沿着加强版 <code>journey</code> 向量走一段，再接着走削弱版 <code>one</code>向量的方向，再走加强版 <code>starts</code> 自身的方向...... 把所有词的贡献都走完，最终到达的那个<strong>新的位置</strong>，就是我们的上下文向量 $C_{starts}$。</p>
<p>这个三步过程之所以能起到我们想要的作用，是因为它完美地模拟了人类理解语言的一个核心逻辑：</p>
<ol>
<li><strong>关注焦点</strong>：当我们读到一个词时，我们会本能地寻找与它最相关的词。这个机制通过计算点积，<strong>找到了</strong>这些相关词。</li>
<li><strong>分配精力</strong>：我们不会对所有相关的词都投入相同的精力。这个机制通过 Softmax，将&quot;相关性&quot;转化为&quot;重要性&quot;权重，<strong>量化了</strong>应该投入多少精力。</li>
<li><strong>综合理解</strong>：我们基于这些焦点和精力分配，在大脑中形成对当前词的综合理解。这个机制通过加权求和，将所有词的信息根据重要性权重<strong>融合</strong>在一起，生成了最终的上下文向量。</li>
</ol>
<p>通过这个过程，输出的每一个向量都从&quot;<u>我是谁</u>&quot;的孤立状态，变成了&quot;<u>在这样一个句子里，我是谁</u>&quot;的上下文感知状态，从而实现了我们的最终目标。</p>
<h5>2.4.1.2 实现带可训练权重的自注意力机制</h5>
<p>在上个阶段，<strong>注意力机制本身没有自己独立的、可以在训练中被优化的参数</strong>。模型在训练时，虽然可以学习和调整输入的 <code>x</code> 向量（即词嵌入本身），但它<strong>无法学习如何更好地计算注意力</strong>。无论输入的向量如何变化，计算注意力的公式始终不变。而且这种方式过于僵化。一个词元的向量表示 <code>x</code> 需要同时承载多种信息，它既要代表自身的语义，又要能很好地跟其他词元的向量进行点积来判断相关性。这就像要求一个人同时扮演运动员和裁判员，角色发生了混淆，难以做到最优。</p>
<p>所以第二个版本的根本问题就是：<u>如何让模型<strong>学会</strong>去关注什么？如何让相似度的计算方式本身变得<strong>灵活和强大</strong>？</u></p>
<p>解决方案是引入角色分工，让专业的角色做专业的事。第二个版本中，我们不再直接使用原始的输入向量 <code>x</code>，而是引入三个独立的可训练线性变换层 (<code>nn.Linear</code>)：<strong>Query (Q)</strong>、<strong>Key (K)</strong> 和 <strong>Value (V)</strong>。</p>
<ul>
<li><strong>查询向量 (Query)</strong>：代表当前这个词，主动去查询句子中其他词与自己的关系。可以理解为：我 (starts) 是谁？</li>
<li><strong>键向量 (Key)</strong>：代表句子中的每个词，用来被其他词查询的。可以理解为：我是 (journey)，你可以通过这个‘键’来了解我。</li>
<li><strong>值向量 (Value)</strong>：代表句子中每个词所携带的真正信息。一旦查询完毕，确定了关系密切度，我们就从这个值中提取信息。</li>
</ul>
<p>至关重要的是，这三个权重矩阵 $W_q$、$W_k$、$W_v$ 是<strong>可训练的</strong>。这意味着在训练过程中，模型会不断优化它们，学会如何将原始输入 <code>x</code> 转换成最有效的 Q、K 和 V，从而学会<strong>如何更好地去关注</strong>，让注意力的计算本身变得灵活而强大 。（所以才称这个版本是带可训练权重的自注意力机制）</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/1*moKYjUdtx-uEyYMbhPWbIw.png" alt="Linear transformation of the word embedding to obtain Query, Key, and Value vectors — From(https://epichka.com/blog/2023/qkv-transformer/)"></p>
<p>代码实现如下：</p>
<pre><code class="language-python">import torch
import torch.nn as nn

class SelfAttention_v2(nn.Module):
    def __init__(self, d_in, d_out, qkv_bias=False):
        &quot;&quot;&quot;
        d_in (int): 输入向量的维度。
        d_out (int): 查询(Query)、键(Key)和值(Value)向量的输出维度。
        qkv_bias (bool): 是否为Q, K, V线性层添加偏置项。
        &quot;&quot;&quot;
        super().__init__()
        # 使用 nn.Linear 层来定义 Q, K, V 的线性变换。
        # 相比手动创建 nn.Parameter，nn.Linear 提供了更优的权重初始化，并可选择性地包含偏置项，
        # 是 PyTorch 中实现线性变换的标准做法。
        self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_key = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)

    def forward(self, x):
        &quot;&quot;&quot;
        x (torch.Tensor): 输入张量，形状为 [批量大小, 序列长度, d_in]。
        &quot;&quot;&quot;

        # 1. 线性投影：将输入 x 转换为 Q, K, V 表示
        # 每个输入词元的向量都会通过独立的线性层，生成其在查询、键、值三个空间中的新表示。
        keys = self.W_key(x)      # 形状: [批量大小, 序列长度, d_out]
        queries = self.W_query(x)  # 形状: [批量大小, 序列长度, d_out]
        values = self.W_value(x)    # 形状: [批量大小, 序列长度, d_out]

        # 2. 计算注意力分数：通过点积衡量 Query 和 Key 的相似度
        # queries 与 keys 的转置 (.T) 进行矩阵相乘，得到一个注意力分数矩阵。
        # 矩阵中的每个元素 attn_scores[i, j] 代表第 i 个查询与第 j 个键之间的原始相关性。
        attn_scores = queries @ keys.T

        # 3. 缩放与归一化：将分数转换为最终的注意力权重
        # a. 缩放(Scaling): 将分数除以 key 向量维度的平方根。这一步对于稳定训练至关重要，
        #    可以防止在维度过高时，点积结果过大导致 softmax 梯度消失。
        # b. 归一化(Normalization): 使用 softmax 函数将缩放后的分数转换为概率分布，
        #    确保每一行（代表每个查询）的注意力权重总和为1。
        attn_weights = torch.softmax(
            attn_scores / keys.shape[-1] ** 0.5, dim=-1,
        )

        # 4. 计算上下文向量：对 Value 向量进行加权求和
        # 将上一步得到的注意力权重矩阵与 values 矩阵相乘。
        # 这一步是根据注意力权重，对所有词元的 Value 信息进行加权聚合，
        # 最终为每个词元生成一个融合了全局上下文信息的新向量。
        context_vec = attn_weights @ values
        return context_vec
</code></pre>
<p>大体流程可参考下图理解：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/1*6BEwO4jKy9UC7AzU6nIPPg.png" alt="Dot-product attention procedure —From(https://epichka.com/blog/2023/qkv-transformer/)"></p>
<blockquote>
<p>[!NOTE]</p>
<p><strong>缩放点积注意力的原理</strong>：对嵌入维度进行归一化是为了避免梯度过小，从而提升训练性能。例如，在类 GPT 大语言模型中，嵌入维度通常大于 1000，这可能导致点积非常大，从而在反向传播时由于 softmax 函数的作用导致梯度非常小。当点积增大时，softmax 函数会表现得更像阶跃函数，导致梯度接近零。这些小梯度可能会显著减慢学习速度或使训练停滞。</p>
<p>因此，通过嵌入维度的平方根进行缩放解释了为什么这种自注意力机制也被称为缩放点积注意力机制。</p>
</blockquote>
<h5>2.4.1.3 利用因果注意力隐藏未来词汇</h5>
<p>对于像 GPT 这样用于文本生成的模型，有一个核心要求：<u>在预测序列中的下一个词元时，模型只能看到当前位置及之前的信息，绝不能偷看未来的词元。</u>标准的自注意力机制会一次性访问整个输入序列，这显然不符合要求。为了解决这个问题，我们引入了<strong>因果注意力（Causal Attention）</strong>，也称为掩码注意力（Masked Attention）。</p>
<p>实现因果注意力的关键在于**掩码（Masking）**操作。具体做法是在计算出注意力分数之后、应用 Softmax 函数之前，对注意力分数矩阵进行修改 。我们会创建一个&quot;上三角&quot;掩码矩阵，其中主对角线及以下的元素为 0，而主对角线以上的元素为负无穷大 (<code>-inf</code>) 。</p>
<p>当这个掩码矩阵被加到注意力分数矩阵上时，所有代表&quot;未来&quot;位置的分数都会变成负无穷大 。经过 Softmax 函数处理后，这些负无穷大的值对应的概率会变为 0 。这样一来，任何词元在计算其上下文向量时，其注意力权重都只会分布在它自身及之前的位置上，从而有效地隐藏了未来的词汇 。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830233836610.png" alt=""></p>
<p>代码实现逻辑如下：</p>
<pre><code class="language-python"># diagonal=1 表示不包含主对角线，只包含上三角部分
mask = torch.triu(torch.ones(context_length, context_length), diagonal=1)
print(mask)
# masked_fill() 用指定值填充掩码为True的位置
masked = attn_scores.masked_fill(mask.bool(), -torch.inf)
print(masked)
# masked / keys.shape[-1]**0.5 进行缩放，torch.softmax 进行归一化
attn_weights = torch.softmax(masked / keys.shape[-1]**0.5, dim=1)
print(attn_weights)
</code></pre>
<p>输出：</p>
<pre><code class="language-python">tensor([[0., 1., 1., 1., 1., 1.],
        [0., 0., 1., 1., 1., 1.],
        [0., 0., 0., 1., 1., 1.],
        [0., 0., 0., 0., 1., 1.],
        [0., 0., 0., 0., 0., 1.],
        [0., 0., 0., 0., 0., 0.]])
tensor([[-0.0763,    -inf,    -inf,    -inf,    -inf,    -inf],
        [-0.0408,  0.0038,    -inf,    -inf,    -inf,    -inf],
        [-0.0423,  0.0025,  0.0074,    -inf,    -inf,    -inf],
        [-0.0090,  0.0404,  0.0416,  0.0233,    -inf,    -inf],
        [-0.0586, -0.0207, -0.0141, -0.0227,  0.1097,    -inf],
        [ 0.0040,  0.0504,  0.0502,  0.0317,  0.0320,  0.0354]],
       grad_fn=&lt;MaskedFillBackward0&gt;)
tensor([[1.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.4921, 0.5079, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.3259, 0.3364, 0.3376, 0.0000, 0.0000, 0.0000],
        [0.2442, 0.2529, 0.2531, 0.2498, 0.0000, 0.0000],
        [0.1919, 0.1971, 0.1980, 0.1968, 0.2161, 0.0000],
        [0.1632, 0.1686, 0.1686, 0.1664, 0.1664, 0.1668]],
       grad_fn=&lt;SoftmaxBackward0&gt;)
</code></pre>
<p>此外，为了防止模型在训练中过拟合，还可以在注意力权重矩阵上应用我们前面提到的 <strong>Dropout</strong> 技术。即在训练过程中，随机地将一部分注意力权重置为零，这有助于增强模型的泛化能力 。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830234226162.png" alt=""></p>
<p>代码实现逻辑如下：</p>
<pre><code class="language-python">dropout = torch.nn.Dropout(0.5) # 使用 50% 的 dropout 率
example = torch.ones(6, 6)
print(example)
print(dropout(example))

# 对权重矩阵进行 dropout
print(dropout(attn_weights))
</code></pre>
<p>输出：</p>
<pre><code class="language-python">tensor([[1., 1., 1., 1., 1., 1.],
        [1., 1., 1., 1., 1., 1.],
        [1., 1., 1., 1., 1., 1.],
        [1., 1., 1., 1., 1., 1.],
        [1., 1., 1., 1., 1., 1.],
        [1., 1., 1., 1., 1., 1.]])
tensor([[2., 2., 0., 2., 2., 0.],
        [0., 0., 0., 2., 0., 2.],
        [2., 2., 2., 2., 0., 2.],
        [0., 2., 2., 0., 0., 2.],
        [0., 2., 0., 2., 0., 2.],
        [0., 2., 2., 2., 2., 0.]])
tensor([[2.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.0000, 0.6790, 0.6818, 0.0000, 0.0000, 0.0000],
        [0.0000, 0.0000, 0.5059, 0.5040, 0.0000, 0.0000],
        [0.0000, 0.0000, 0.0000, 0.0000, 0.4336, 0.0000],
        [0.3242, 0.3345, 0.0000, 0.3339, 0.3433, 0.3292]],
       grad_fn=&lt;MulBackward0&gt;)
</code></pre>
<p>可以看到大约一半的值被置为 0，且原来的值被放大了，用于位置权重的整体平衡。</p>
<p>一个完整的简单因果注意力类的参考实现如下：</p>
<pre><code class="language-python">import torch
import torch.nn as nn

class CausalAttention(nn.Module):
    &quot;&quot;&quot;
    一个实现了因果自注意力（Causal Self-Attention）的模块。

    因果特性确保在处理序列中的任何一个词元（token）时，
    注意力机制只能关注到当前位置及之前位置的词元，而不能看到未来的词元。
    这对于自回归（auto-regressive）的文本生成任务至关重要。
    &quot;&quot;&quot;
    def __init__(self, d_in, d_out, context_length, dropout, qkv_bias=False):
        &quot;&quot;&quot;
        初始化方法。

        参数:
        d_in (int): 输入嵌入向量的维度。
        d_out (int): 查询(Query)、键(Key)和值(Value)向量的输出维度。
        context_length (int): 模型的最大序列长度，用于创建因果掩码。
        dropout (float): 应用于注意力权重矩阵的 dropout 比率。
        qkv_bias (bool): 是否为 Q, K, V 的线性层添加偏置项。
        &quot;&quot;&quot;
        super().__init__()
        self.d_out = d_out
        # 定义用于生成 Query, Key, Value 的线性变换层
        self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_key   = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)
        # 定义 Dropout 层，用于正则化，防止过拟合
        self.dropout = nn.Dropout(dropout)
        # 创建并注册因果掩码（causal mask）
        # register_buffer 将一个张量注册为模块的缓冲区，它不会被视为模型参数（即不会在训练中被更新），
        # 但会随着模型移动（例如，.to(device)）。
        self.register_buffer(
            &#39;mask&#39;,
            # torch.triu 创建一个上三角矩阵。diagonal=1 表示主对角线（及以下）的元素都为0，
            # 只有主对角线上方的元素为1。这个矩阵用于屏蔽未来的位置。
            torch.triu(torch.ones(context_length, context_length), diagonal=1)
        )

    def forward(self, x):
        &quot;&quot;&quot;
        执行前向传播。

        参数:
        x (torch.Tensor): 输入张量，形状为 [批量大小, 序列长度, 输入嵌入维度 d_in]。
        &quot;&quot;&quot;
        # 获取输入的维度信息
        b, num_tokens, d_in = x.shape

        # 1. 将输入 x 投影到 Query, Key, Value 空间
        keys = self.W_key(x)      # 输出形状: [b, num_tokens, d_out]
        queries = self.W_query(x) # 输出形状: [b, num_tokens, d_out]
        values = self.W_value(x)  # 输出形状: [b, num_tokens, d_out]

        # 2. 计算注意力分数
        # 将 keys 的最后两个维度转置，以便进行矩阵乘法
        # 形状从 [b, num_tokens, d_out] 变为 [b, d_out, num_tokens]
        # queries @ keys.transpose(...) 计算每个查询与所有键的点积
        attn_scores = queries @ keys.transpose(1, 2)  # 输出形状: [b, num_tokens, num_tokens]

        # 3. 应用因果掩码
        # masked_fill_ 是一个原地操作（in-place），它会直接修改 attn_scores 张量
        # self.mask.bool()[:num_tokens, :num_tokens] 会选取与当前输入序列长度匹配的掩码部分
        # 并将所有需要屏蔽的“未来”位置的分数填充为负无穷大
        attn_scores.masked_fill_(
            self.mask.bool()[:num_tokens, :num_tokens], -torch.inf
        )

        # 4. 缩放分数并应用 softmax 得到注意力权重
        # a. 缩放 (Scaling): 除以 key 维度的平方根，稳定梯度
        # b. Softmax: 将分数转换为概率分布。由于未来位置的分数是负无穷，
        #    经过 softmax 后它们的权重将变为0。
        attn_weights = torch.softmax(
            attn_scores / keys.shape[-1]**0.5, dim=-1
        )

        # 5. 应用 Dropout
        # 在训练阶段，随机将一些注意力权重置为0，以防止过拟合
        attn_weights = self.dropout(attn_weights)

        # 6. 计算上下文向量
        # 将注意力权重与 value 向量相乘，得到加权和
        context_vec = attn_weights @ values # 输出形状: [b, num_tokens, d_out]
        return context_vec
</code></pre>
<h5>2.4.1.4 将单头注意力扩展到多头注意力</h5>
<p>虽然带有可训练权重的因果自注意力机制已经非常强大，但它仍然有局限性：<u>模型在某个位置只能学习到一种注意力模式</u>。为了让模型能够从不同角度、不同表示子空间共同关注信息，原始 Transformer 论文引入了**多头注意力（Multi-Head Attention）**机制 。</p>
<p>&quot;多头&quot;的核心思想是并行地运行多次注意力计算，而不是只进行一次 。具体实现如下：</p>
<ol>
<li><strong>分割成多个头</strong>：我们不再只有一组 $W_q$、$W_k$、$W_v$ 权重矩阵，而是为每个头都创建一组独立的权重矩阵 。例如，如果我们有 12 个头，那我们就有 12 组这样的矩阵。</li>
<li><strong>并行计算注意力</strong>：每个头都独立地对输入执行缩放点积注意力计算（包含因果掩码）。由于每个头拥有不同的权重矩阵，它们会将输入投影到不同的表示子空间，从而学习到输入序列的不同方面特征 。例如，一个头可能关注语法结构，另一个头可能关注语义关联。</li>
<li><strong>拼接与投影</strong>：在所有头都完成计算后，我们会得到多个输出上下文向量。我们将这些向量**拼接（concatenate）**在一起，形成一个更长的向量 。</li>
<li><strong>最终线性投影</strong>：最后，这个拼接后的长向量会通过一个额外的线性层（<code>out_proj</code>）进行投影，将其维度恢复到模型期望的维度，并融合所有头学习到的信息 。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830235007200.png" alt=""></p>
<p>在代码中，可以通过实现一个简单的 <code>MultiHeadAttentionWrapper</code> 类来达到这一目标，<code>MultiHeadAttentionWrapper</code> 类堆叠了多个之前实现的 <code>CausalAttention</code> 模块实例。</p>
<pre><code class="language-python">class MultiHeadAttentionWrapper(nn.Module):
    &quot;&quot;&quot;一个实现多头注意力的封装类&quot;&quot;&quot;
    def __init__(self, d_in, d_out, context_length,
                dropout, num_heads, qkv_bias=False) -&gt; None:
        super().__init__()
        self.heads = nn.ModuleList(  # 堆叠多个 CausalAttention
            [CausalAttention(
                d_in, d_out, context_length, dropout, qkv_bias,
            ) for _ in range (num_heads)]
        )

    def forward(self, x):
        return torch.cat([head(x) for head in self.heads], dim=-1)
</code></pre>
<p>书中还提到了一种更高效的实现方式：与其创建多组独立的权重矩阵，不如创建一个更大的权重矩阵，一次性完成对所有头的查询、键、值向量的计算，然后通过重塑（reshape）和转置（transpose）操作将结果分割成多个头 。这种方法在数学上是等价的，但利用了现代硬件进行大规模矩阵运算的优势，计算效率更高。</p>
<pre><code class="language-python">&quot;&quot;&quot;
输入: [2, 6, 3]
    ↓
Q,K,V: [2, 6, 2]
    ↓
重塑: [2, 6, 2, 1] (2个头，每个头1维)
    ↓
转置: [2, 2, 6, 1] (2个头并行计算)
    ↓
注意力: [2, 2, 6, 6] (每个头有自己的注意力矩阵)
    ↓
上下文: [2, 2, 6, 1] (每个头的结果)
    ↓
合并: [2, 6, 2] (所有头的结果合并)
    ↓
输出投影: [2, 6, 2]
&quot;&quot;&quot;
class MultiHeadAttention(nn.Module):
    &quot;&quot;&quot;一个高效的多头注意力类&quot;&quot;&quot;
    def __init__(self, d_in: int, d_out: int,
                context_length: int, dropout: float, num_heads: int, qkv_bias=False):
        super().__init__()
        assert (d_out % num_heads == 0), &quot;d_out must be divisible by num_heads&quot;

        self.d_out = d_out  # 输出维度
        self.num_heads = num_heads # 头数量
        self.head_dim = d_out // num_heads # 每个头的维度

        # 初始化可训练的权重矩阵，分别代表查询向量、键向量、值向量
        self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_key = nn.Linear(d_in, d_out, bias=qkv_bias)
        self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)

        # 使用一个线性层来组合头的输出
        self.out_proj = nn.Linear(d_out, d_out)

        # 掩码 + dropout
        self.dropout = nn.Dropout(dropout)
        self.register_buffer(
            &quot;mask&quot;,
            torch.triu(torch.ones(context_length, context_length), diagonal=1)
        )

    def forward(self, x):
        # [batch_size, sequence_length, embedding_dim]
        b, num_tokens, d_in = x.shape

        # 计算权重矩阵 Q/K/V
        keys: Tensor = self.W_key(x)
        queries: Tensor = self.W_query(x)
        values: Tensor = self.W_value(x)

        # 重塑为多头格式
        # 将 [batch, seq_len, d_out] 重塑为 [batch, seq_len, num_heads, head_dim]
        # [2, 6, 4] -&gt; [2, 6, 2, 2]
        keys = keys.view(b, num_tokens, self.num_heads, self.head_dim)
        values = values.view(b, num_tokens, self.num_heads, self.head_dim)
        queries = queries.view(b, num_tokens, self.num_heads, self.head_dim)

        # 调整维度顺序，让每个头独立计算注意力，便于批量处理所有头
        # 从形状 (b, num_tokens, num_heads, head_dim)
        # 转换到 (b, num_heads, num_tokens, head_dim)
        keys = keys.transpose(1, 2)
        values = values.transpose(1, 2)
        queries = queries.transpose(1, 2)

        # 计算注意力分数，这样每个批次(2)的每个头(2)都有了一个 6×6 的注意力分数矩阵
        attn_scores = queries @ keys.transpose(2, 3) # [2, 2, 6, 2] @ [2, 2, 2, 6] = [2, 2, 6, 6]
        mask_bool: Tensor = self.mask.bool()[:num_tokens, :num_tokens]

        # 应用因果掩码
        attn_scores.masked_fill_(mask_bool, -torch.inf)

        # 归一化权重
        attn_weights = torch.softmax(attn_scores / keys.shape[-1]**0.5, dim=-1) # [2, 2, 6, 6]

        # 使用 dropout 掩码减少过拟合
        attn_weights = self.dropout(attn_weights) # [2, 2, 6, 6]

        # 每个头计算自己的上下文向量
        context_vec: Tensor = (attn_weights @ values).transpose(1, 2) # [2, 2, 6, 6] @ [2, 2, 6, 2] = [2, 2, 6, 2] -&gt; [2, 6, 2, 2]
        # 重塑回原始格式 [2, 6, 2, 2] -&gt; [2, 6, 4]
        context_vec = context_vec.contiguous().view(b, num_tokens, self.d_out)
        # 通过输出投影层 [2, 6, 4]
        context_vec = self.out_proj(context_vec)
        return context_vec
</code></pre>
<p>第二个版本 <code>MultiHeadAttention</code> 之所以更好，根本原因在于它<strong>将多次小规模的独立计算，整合为一次大规模的并行计算</strong>，从而最大化地利用了现代硬件（尤其是 GPU）的并行处理能力。</p>
<p>第一个版本 <code>MultiHeadAttentionWrapper</code> 的根本问题是<strong>计算被拆散了</strong>。对于一个有 12 个头的模型，这意味着要执行 <strong>12 组</strong>独立的 Q, K, V 矩阵乘法。在 GPU 上，每次独立的矩阵乘法都需要一次内核启动（kernel launch），这个启动本身是有开销的。执行 12 次小规模的计算，其总开销远大于执行 1 次等效的大规模计算。这就像让一个工人去搬 12 次箱子，每次只搬一个，远不如让他用推车一次性搬完 12 个箱子来得快。</p>
<p>这里面的向量变化可能有一些复杂，感兴趣的读者可以参考下图进行辅助理解。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250830235453791.png" alt="多头注意力完整流程可视化"></p>
<blockquote>
<p>[!IMPORTANT]</p>
<p>到此为止，我们已经走完了一条从最简陋到最完备的注意力机制演进之路。我们从一个不带任何可训练参数的简单点积模型出发，理解了&quot;看-权衡-融合&quot;的核心思想；接着，通过引入可学习的 QKV 矩阵，赋予了模型<strong>学会如何去关注</strong>的能力；随后，我们用因果掩码为模型戴上了眼罩，强制它遵守时间顺序，只能回顾过去；最后，通过多头机制和高效的并行化实现，我们构建出了 GPT 模型真正的<strong>认知核心</strong>——<code>MultiHeadAttention</code> 模块。</p>
<p>这个模块是 Transformer 架构的灵魂。它为模型提供了一个动态的、可学习的机制，使其能够在处理每一个词元时，都能审视全局（或全局的过去），并精确地计算出上下文中每一个其他词元对当前词元的重要性，最终生成一个富含深度上下文信息的新表示。</p>
</blockquote>
<p>然而，一个强大的引擎（<code>MultiHeadAttention</code>）本身还不足以构成一辆性能优越的赛车（<code>TransformerBlock</code>）。我们还需要稳定系统、传动装置和进一步的加工环节。这就引出了我们接下来的问题：</p>
<ul>
<li>模型在通过注意力机制<strong>融合</strong>了上下文信息之后，如何对这些新信息进行进一步的<strong>加工和思考</strong>？</li>
<li>当我们把 12 个这样强大的计算层堆叠在一起时，如何保证训练过程的稳定，防止梯度消失或爆炸？</li>
<li>在经过如此复杂的变换后，如何确保原始的、未经处理的信息不会在层层传递中丢失？</li>
</ul>
<p>让我们先来回顾一下 <code>TransformerBlock</code> 的结构：</p>
<pre><code class="language-python">class TransformerBlock(nn.Module):
    &quot;&quot;&quot;Transformer块：多头注意力 + 前馈网络 + 残差连接&quot;&quot;&quot;
    def __init__(self, cfg) -&gt; None:
        super().__init__()
        self.att = MultiHeadAttention(  # &lt;----- 我们已经搞定了！
            d_in=cfg[&quot;emb_dim&quot;],
            d_out=cfg[&quot;emb_dim&quot;],
            context_length=cfg[&quot;context_length&quot;],
            num_heads=cfg[&quot;n_heads&quot;],
            dropout=cfg[&quot;drop_rate&quot;],
            qkv_bias=cfg[&quot;qkv_bias&quot;],
        )

        # &lt;----- 接下来我们继续来解决后面的内容
        self.ff = FeedForward(cfg)
        self.norm1 = LayerNorm(cfg[&quot;emb_dim&quot;])
        self.norm2 = LayerNorm(cfg[&quot;emb_dim&quot;])
        self.drop_shortcut = nn.Dropout(cfg[&quot;drop_rate&quot;])
</code></pre>
<p>答案就藏在构成 <code>TransformerBlock</code> 的另外几个关键组件中。接下来，我们将把目光从注意力机制本身移开，去探索环绕在它周围的左膀右臂——<strong>前馈网络 (FeedForward Network)</strong>、<strong>层归一化 (Layer Normalization)</strong> 和 <strong>残差连接 (Shortcut/Residual Connections)</strong>，看看它们是如何协同工作，共同构成 Transformer 架构坚实可靠的核心处理单元的。</p>
<ul>
<li><strong>层归一化 (LayerNorm)</strong>: 它的根本作用是<strong>稳定训练过程</strong>。在数据经过复杂的注意力计算或前馈网络变换后，其数值分布可能会变得非常不稳定。层归一化就像一个调节器，在每个子层处理之前，都将数据拉回到一个标准的、易于处理的分布上，确保信息流的稳定。</li>
<li><strong>前馈神经网络 (FeedForward Network)</strong>: 如果说注意力机制负责<strong>融合</strong>来自上下文的信息，那么前馈网络则负责对这些融合后的信息进行<strong>加工和思考</strong>。它是一个小型的、独立处理每个词元位置的神经网络，用于提取更高级、更抽象的特征，增加模型的非线性表达能力。</li>
<li><strong>快捷连接 (Shortcut/Residual Connection)</strong>: 这是训练深度网络的关键技巧。它允许信息绕过某个处理层（如注意力或前馈网络），直接传递到下一层。这确保了即使在经过多达 12 层甚至更多的深度变换后，最原始的输入信息也不会完全丢失，同时极大地缓解了深度学习中的梯度消失问题，让深度堆叠成为可能。</li>
</ul>
<h4>2.4.2 使用层归一化进行归一化激活</h4>
<p>一个深度神经网络就像一个多级信息加工流水线。数据（信号）在每一层都会被权重矩阵进行复杂的数学变换。当层数很深时，每一层微小的变化都可能被逐层放大。这会导致两个极端问题 ：</p>
<ol>
<li><strong>信号爆炸</strong>：某些层的输出值变得非常大，导致后续计算溢出，训练过程崩溃。</li>
<li><strong>信号消失</strong>：某些层的输出值变得非常小，接近于零，导致信息无法有效传递到更深层，模型学不到东西。 这两种情况统称为<strong>内部协变量偏移 (Internal Covariate Shift)</strong>，它使得训练过程极其不稳定，就像在一条崎岖不平的山路上开车，油门（学习率）稍有不慎就会冲出赛道。</li>
</ol>
<p><strong>第一性原理解决方案：强制信号标准化</strong></p>
<p>最直接的解决方案，就是在信息进入每个核心处理单元（如注意力和前馈网络）之前，强制进行一次校准或标准化。<strong>层归一化</strong>正是扮演了这个角色。 它的核心思想是，不管上一层传来的数据分布如何，它都强行将这批数据的均值调整为 0，方差调整为 1 。这相当于在流水线的每个关键工序前都安装了一个<strong>稳压器</strong>，确保无论输入信号如何波动，进入工序的信号始终是稳定、标准化的。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831000639468.png" alt=""></p>
<p>在 <code>TransformerBlock</code> 中，层归一化被放置在<strong>多头注意力和前馈网络之前</strong> (<code>self.norm1</code> 和 <code>self.norm2</code>) 。这确保了这两个进行核心计算的模块接收到的输入始终处于一个稳定且易于处理的范围内，从而极大地稳定了整个深度模型的训练过程。</p>
<p><code>LayerNorm</code> 的代码实现如下：</p>
<pre><code class="language-python">class LayerNorm(nn.Module):
    &quot;&quot;&quot;层归一化实现&quot;&quot;&quot;
    def __init__(self, emb_dim):
        super().__init__()
        self.eps = 1e-5
        self.scale = nn.Parameter(torch.ones(emb_dim))
        self.shift = nn.Parameter(torch.zeros(emb_dim))

    def forward(self, x):
        mean = x.mean(dim=-1, keepdim=True)
        var = x.var(dim=-1, keepdim=True, unbiased=False)
        norm_x = (x-mean) / torch.sqrt(var + self.eps)
        return self.scale * norm_x + self.shift
</code></pre>
<p>让我们从第一性原理出发，来根本性地解释 <code>LayerNorm</code> 的这份代码实现。它的每一行都服务于一个核心目的：<strong>在保持模型表达能力的同时，稳定深度网络的训练过程</strong>。我们可以将这个实现拆解为两个核心部分来理解：<strong>强制标准化</strong> 和 <strong>可学习的自适应调整</strong>。</p>
<h5>2.4.2.1 第一部分：强制标准化 - 解决信号失控问题</h5>
<p>这是代码的核心计算部分：</p>
<pre><code class="language-python"># forward 方法中的核心计算
mean = x.mean(dim=-1, keepdim=True)
var = x.var(dim=-1, keepdim=True, unbiased=False)
norm_x = (x-mean) / torch.sqrt(var + self.eps)
</code></pre>
<ul>
<li><code>mean = x.mean(dim=-1, keepdim=True)</code> 和 <code>var = x.var(dim=-1, ...)</code>：这两行代码计算了<strong>每一个</strong>输入样本<strong>在其特征维度（<code>emb_dim</code>）上</strong>的均值和方差。<code>dim=-1</code> 是关键，它指定了归一化是沿着特征维度进行的，而不是像批归一化（Batch Norm）那样跨批次进行。这使得 <code>LayerNorm</code> 的效果与批次大小无关，在处理可变长度序列时尤其稳定。</li>
<li><code>norm_x = (x-mean) / torch.sqrt(var + self.eps)</code>：这是标准的<strong>标准化公式</strong>（减去均值，再除以标准差）。它将原始输入 <code>x</code> 转换为了一个均值为 0、方差为 1 的新向量 <code>norm_x</code>。<ul>
<li><code>self.eps = 1e-5</code>：<code>eps</code> (epsilon) 是一个极小的常数，它的唯一作用是<strong>防止分母为零</strong>。如果某个样本的方差恰好为 0，没有 <code>eps</code> 就会导致除零错误，<code>eps</code> 保证了计算的数值稳定性 1。</li>
<li><code>unbiased=False</code>：这是一个实现细节，表示在计算方差时分母是 <code>N</code> 而不是 <code>N-1</code>。选择 <code>False</code> 是为了<strong>与原始 GPT-2 模型的实现保持兼容</strong>，因为其最初是使用 TensorFlow 实现的，而这是 TensorFlow 的默认行为 2。</li>
</ul>
</li>
</ul>
<p>至此，我们已经强制将输入信号稳定在一个 <code>N(0, 1)</code> 的标准正态分布上。但这又带来了新的问题。</p>
<h5>2.4.2.2 第二部分：可学习的自适应调整 - 恢复模型的表达能力</h5>
<p>这是在 <code>__init__</code> 中定义并在 <code>forward</code> 最后使用的部分：</p>
<pre><code class="language-python"># __init__ 中
self.scale = nn.Parameter(torch.ones(emb_dim))
self.shift = nn.Parameter(torch.zeros(emb_dim))

# forward 的最后一步
return self.scale * norm_x + self.shift
</code></pre>
<p><strong>根本问题</strong>：将每一层的输入都强制变为均值为 0、方差为 1 的分布，这种做法可能<strong>过于暴力和死板</strong>。它虽然稳定了训练，但也可能限制了模型的表达能力。也许对于某个特定层来说，一个均值为 10、方差为 5 的输入分布才是最优的。我们不希望因为追求稳定而扼杀了模型学习这种分布的可能性。</p>
<p><strong>解决方案</strong>：在强制标准化之后，再赋予模型<strong>撤销或重新调整</strong>这种标准化的能力。这是通过两个可学习的参数 <code>scale</code> 和 <code>shift</code> 来实现的。</p>
<ul>
<li><code>self.scale</code> (增益)：这是一个与特征维度相同大小的可学习向量。它与标准化后的 <code>norm_x</code> 进行逐元素相乘。它被初始化为全 1，所以在训练刚开始时，它不起任何作用（乘以 1 等于不变）。</li>
<li><code>self.shift</code> (偏置)：这也是一个可学习的向量。它被加到缩放后的结果上。它被初始化为全 0，所以在训练开始时，它也不起作用（加上 0 等于不变）。</li>
</ul>
<p><strong>这步的精髓在于</strong>：模型在训练过程中，可以通过反向传播自由地学习 <code>scale</code> 和 <code>shift</code> 的最佳值。</p>
<ul>
<li>如果模型发现强制标准化 <code>N(0, 1)</code> 对当前层来说是最好的，它就会让 <code>scale</code> 保持接近 1，<code>shift</code> 保持接近 0。</li>
<li>如果模型发现一个不同的分布更好，它就可以学会相应的 <code>scale</code> 和 <code>shift</code> 值，将 <code>norm_x</code> 线性变换到任何它认为最优的均值和方差。</li>
</ul>
<p>总的来说，<code>LayerNorm</code> 的实现是一个精妙的两步过程：</p>
<ol>
<li><strong>先稳定</strong>：通过强制的标准化，将可能失控的输入信号拉回到一个稳定的 <code>N(0, 1)</code> 分布，解决了深度网络训练不稳定的根本问题。</li>
<li><strong>后放开</strong>：通过引入可学习的 <code>scale</code> 和 <code>shift</code> 参数，赋予模型恢复甚至创造全新分布的自由度，解决了强制标准化可能带来的表达能力受限的问题。</li>
</ol>
<p>最终，这个实现既保证了训练的<strong>稳定性</strong>，又保留了模型的<strong>灵活性和表达能力</strong>。</p>
<h4>2.4.3 实现具有 GELU 激活函数的前馈神经网络</h4>
<p>自注意力机制的核心是<strong>加权求和</strong> (<code>attn_weights @ values</code>)。虽然计算权重时有 <code>softmax</code> 引入了非线性，但信息融合的最后一步本质上是一个线性组合。如果整个 <code>TransformerBlock</code> 只依赖于注意力机制来处理信息，那么模型的表达能力将受到限制。它擅长<strong>融合</strong>信息，但在对融合后的信息进行<strong>深度加工</strong>方面能力不足。</p>
<p><strong>第一性原理解决方案：为每个词元提供独立的非线性处理空间</strong></p>
<p>为了弥补这一不足，我们需要一个专门的组件来对注意力机制输出的上下文向量进行进一步的、更复杂的非线性变换。<strong>前馈网络 (FFN)</strong> 就是这个组件。 它通常由两个线性层和一个非线性激活函数（如 GELU）组成 。它会对序列中的<strong>每一个词元向量独立地</strong>进行一次&quot;升维-非线性激活-降维&quot;的操作 。</p>
<ol>
<li><strong>升维</strong>：第一个线性层将向量维度扩大（例如从 768 维扩展到 3072 维），这为模型提供了更广阔的特征空间来表示和加工信息。</li>
<li><strong>非线性激活 (GELU)</strong>：这是关键一步，打破了线性变换的局限，允许模型学习输入和输出之间更复杂、更抽象的关系。</li>
<li><strong>降维</strong>：第二个线性层将维度恢复到原始大小，以便于下一层 <code>TransformerBlock</code> 处理。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831001916054.png" alt=""></p>
<p>前馈神经网络 <code>FeedForward</code> 的实现代码如下：</p>
<pre><code class="language-python">class FeedForward(nn.Module):
    &quot;&quot;&quot;前馈神经网络&quot;&quot;&quot;
    def __init__(self, cfg):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Linear(cfg[&quot;emb_dim&quot;], 4 * cfg[&quot;emb_dim&quot;]),
            GELU(),
            nn.Linear(4 * cfg[&quot;emb_dim&quot;], cfg[&quot;emb_dim&quot;])
        )

    def forward(self, x):
        return self.layers(x)

class GELU(nn.Module):
    &quot;&quot;&quot;GELU激活函数&quot;&quot;&quot;
    def __init__(self):
        super().__init__()

    def forward(self, x):
        return 0.5 * x * (1 + torch.tanh(
            torch.sqrt(torch.tensor(2.0 / torch.pi)) *
            (x + 0.044715 * torch.pow(x, 3))
        ))
</code></pre>
<ol>
<li><strong>引入非线性</strong>：通过 <code>GELU</code> 激活函数，让模型有能力学习复杂的数据模式。</li>
<li><strong>深度加工信息</strong>：通过&quot;升维-降维&quot;的结构，为模型提供一个更广阔的计算空间来提取和转换特征，同时保持整个 <code>TransformerBlock</code> 输入输出维度的一致性，使其能够被方便地深度堆叠。</li>
</ol>
<h4>2.4.4 添加快捷连接</h4>
<p>当网络非常深时（例如堆叠 12 层 <code>Transformer</code> 块），会遇到两个致命问题：</p>
<ol>
<li><strong>梯度消失 (Vanishing Gradients)</strong>：在训练时，用于更新权重的梯度信号需要从最后一层反向传播到第一层。每经过一层，梯度都会被乘以该层的权重。在深层网络中，这些连乘操作很可能导致梯度信号迅速衰减，等传到浅层网络时已经微乎其微，导致浅层参数几乎不更新，模型无法有效训练 。</li>
<li><strong>信息退化 (Information Degradation)</strong>：输入向量 <code>x</code> 每经过一个 <code>TransformerBlock</code> ，都会被复杂的注意力机制和前馈网络完全重构。在经过多层变换后，最原始、最直接的语义和位置信息可能会被冲淡甚至丢失。</li>
</ol>
<p><strong>第一性原理解决方案：建立信息/梯度的&quot;高速公路&quot;</strong></p>
<p>解决方案出奇地简单而有效：在每个复杂处理单元（如注意力和前馈网络）旁边，建立一条<strong>直连通道</strong>，让输入可以直接跳过这个单元，与该单元的输出相加。这就是<strong>快捷连接</strong>或<strong>残差连接</strong>。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831002424412.png" alt=""></p>
<p>在 <code>TransformerBlock</code> 中，残差连接的实现如下：</p>
<pre><code class="language-python">class TransformerBlock(nn.Module):
    def forward(self, x):
        shortcut = x      # &lt; ------ 保存原始输入
        x = self.norm1(x)
        x = self.att(x)
        x = self.drop_shortcut(x)
        x = x + shortcut  # &lt; ------ 残差连接

        shortcut = x      # &lt; ------ 保存原始输入
        x = self.norm2(x)
        x = self.ff(x)
        x = self.drop_shortcut(x)
        x = x + shortcut  # &lt; ------ 残差连接
        return x
</code></pre>
<p>残差连接在这段代码中体现在以下两个关键操作上：</p>
<ol>
<li><code>shortcut = x</code>: 这是<strong>分叉路口</strong>，将原始信息备份到 <code>shortcut</code> 变量中，开辟了直连通道。</li>
<li><code>x = x + shortcut</code>: 这是<strong>十字路口汇合</strong>，将主干道上经过复杂处理的信息与旁路上的原始信息重新组合。</li>
</ol>
<p>具体如下：</p>
<pre><code class="language-python"># 第一个子层：多头注意力 + 残差连接

# ------------------- 残差连接的起点 -------------------
# 1. 保存原始输入：在进行任何变换之前，我们先把原始的输入 x 保存到一个名为 shortcut 的变量中。
#    这就相当于开辟了一条“快捷通道”或“旁路”，让原始信息可以绕过复杂的处理。
shortcut = x

# ------------------- 主处理路径 (F(x)) -------------------
# 2. 对输入进行复杂变换：
#    - 先进行层归一化
#    - 再通过多头注意力机制
#    - 最后应用 Dropout
#    这一系列操作的结果，更新了变量 x。现在的 x 已经不再是原始输入，
#    而是经过注意力模块深度加工后的“增量信息”或“残差”。
x = self.norm1(x)
x = self.att(x)
x = self.drop_shortcut(x)

# ------------------- 残差连接的终点 -------------------
# 3. 将原始输入与变换后的结果相加：
#    这行代码是残差连接最关键的体现。我们将“快捷通道”中的原始信息 (shortcut)，
#    与“主处理路径”上经过复杂变换后的增量信息 (x) 进行逐元素相加。
#    这样，最终的输出既包含了新学到的上下文关系，又没有丢失最原始的输入信息。
x = x + shortcut
</code></pre>
<h3>2.5 输出层：从向量到概率分布</h3>
<p>我们从顶层的 <code>GPTModel</code> 容器开始，构建了其核心的可堆叠单元 <code>TransformerBlock</code>。在 <code>TransformerBlock</code> 内部，我们不仅实现了其进行上下文信息融合的核心模块——<code>MultiHeadAttention</code>，还集成了确保其稳定和高效运行的关键辅助组件：<code>LayerNorm</code>、<code>FeedForward</code> 网络和残差连接。</p>
<p>现在，我们回到 <code>GPTModel</code> 的结构上，看看还剩余哪些部分：</p>
<pre><code class="language-python">class GPTModel(nn.Module):
    def __init__(self, cfg) -&gt; None:
       	# 前面都已经介绍了...
        self.final_norm = LayerNorm(cfg[&quot;emb_dim&quot;])
        self.out_head = nn.Linear(cfg[&quot;emb_dim&quot;], cfg[&quot;vocab_size&quot;], bias=False)

    def forward(self, in_idx):
        # 前面都已经介绍了...
        x = self.final_norm(x)
        logits = self.out_head(x)
        return logits
</code></pre>
<p>经过 <code>n_layers</code> 层 <code>TransformerBlock</code> 的深度处理后，我们得到了一个张量 <code>x</code>，其维度为 <code>(batch_size, context_length, emb_dim)</code>。这个张量中的每一个向量都蕴含了丰富的上下文信息。然而，这仍然是模型的<strong>内部表示</strong>。模型的最终任务是<strong>预测下一个词元</strong>。</p>
<p><strong>根本问题</strong>：如何将这个 <code>emb_dim</code> 维度的、连续的内部状态向量，转换为一个覆盖整个词汇表（<code>vocab_size</code>）的、离散的预测结果？</p>
<p><strong>第一性原理解决方案：投影回词汇空间</strong></p>
<p>这个转换过程由输出层完成，它包含两个步骤：</p>
<ol>
<li><strong>最终归一化 (<code>self.final_norm</code>)</strong>: 在进行最后的投影之前，对 <code>Transformer</code> 栈的输出再进行一次层归一化。这可以看作是进入最终决策阶段前的一次信号整理，确保输入到输出头的数值分布是稳定的，这有助于后续损失计算和梯度传播的稳定性。</li>
<li><strong>输出头投影 (<code>self.out_head</code>)</strong>: 这是至关重要的一步。<code>self.out_head</code> 是一个标准的线性层，其权重矩阵的维度是 <code>(emb_dim, vocab_size)</code>。<ul>
<li><strong>它的作用</strong>：将每一个经过深度处理的、代表特定位置上下文信息的 <code>emb_dim</code> 维向量，<strong>线性投影</strong>到一个 <code>vocab_size</code> 维的空间中。</li>
<li><strong>输出的含义</strong>：这个 <code>vocab_size</code> 维的新向量被称为 <strong>logits</strong>。它的每一个维度都唯一对应词汇表中的一个词元。该维度上的数值，就代表模型预测该词元是下一个词的<strong>原始置信度分数</strong>（未经归一化的对数概率）。分数越高，模型认为该词元出现的可能性越大。</li>
</ul>
</li>
</ol>
<p>最终，<code>forward</code> 函数返回的 <code>logits</code> 张量，其维度为 <code>(batch_size, context_length, vocab_size)</code>，精确地包含了模型在每一个输入位置上，对词汇表中所有词元的预测分数。这是模型进行思考和计算后，给出的最终答卷。</p>
<h3>2.6 基础文本生成</h3>
<p>至此，我们已经完成了 <code>GPTModel</code> 的全部架构代码实现。</p>
<p>当前，我们拥有一个结构上完整、参数可扩展的 GPT 模型蓝图。然而，必须明确的是，这个模型的所有可训练参数（<code>nn.Embedding</code>、<code>nn.Linear</code>、<code>LayerNorm</code> 中的权重和偏置）均由<strong>随机值</strong>初始化。因此，尽管模型结构已经完备，但它不具备任何语言知识，无法执行任何有意义的任务，其输出将是无意义的随机内容。</p>
<p>不过，在让我们的模型具备输出有意义的内容之前，我们还是先来实现模型文本生成的能力。GPT 模型将输出张量转化为生成文本的过程涉及多个步骤，如下图所示。这些步骤包括解码输出张量、根据概率分布选择词元，以及将这些词元转换为人类可读的文本。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831005826451.png" alt=""></p>
<p>下图更加详细地展示的下一词元生成过程说明了 GPT 模型如何在给定输入的情况下生成下一个词元。在每一步中，模型输出一个矩阵，其中的向量表示有可能的下一个词元。将与下一个词元对应的向量提取出来，并通过 softmax 函数转换为概率分布。在包含这些概率分数的向量中，找到最高值的索引，这个索引对应于词元 ID。然后将这个词元 ID 解码为文本，生成序列中的下一个词元。最后，将这个词元附加到之前的输入中，形成新的输入序列，供下一次迭代使用。这个逐步的过程使得模型能够按顺序生成文本，从最初的输入上下文中构建连贯的短语和句子。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831005901397.png" alt=""></p>
<p>让我们来实现一个文本生成工具，如下代码所示，<code>generate_and_print_sample</code> 是一个用于快速验证和展示的便捷工具，它封装了从编码、生成到解码的全过程，并妥善处理了模型的训练/评估模式切换。</p>
<pre><code class="language-python">def generate_and_print_sample(model, tokenizer, device, start_context):
    &quot;&quot;&quot;生成文本样本&quot;&quot;&quot;
    model.eval()
    context_size = model.pos_emb.weight.shape[0]
    encoded = text_to_token_ids(start_context, tokenizer).to(device)
    with torch.no_grad():
        token_ids = generate_text_simple(
            model=model, idx=encoded,
            max_new_tokens=50, context_length=context_size,
        )
    decoded_text = token_ids_to_text(token_ids, tokenizer)
    print(decoded_text.replace(&quot;\n&quot;, &quot; &quot;))
    model.train()

def generate_text_simple(model, idx, max_new_tokens, context_length):
    &quot;&quot;&quot;使用模型生成文本&quot;&quot;&quot;
    for _ in range(max_new_tokens):
        idx_cond = idx[:, -context_length:]
        with torch.no_grad():
            logits = model(idx_cond)

        logits = logits[:, -1, :]
        probas = torch.softmax(logits, dim=-1)
        idx_next = torch.argmax(probas, dim=-1, keepdim=True)
        idx = torch.cat((idx, idx_next), dim=1)

    return idx

def text_to_token_ids(text, tokenizer):
    &quot;&quot;&quot;将文本转换为token ID&quot;&quot;&quot;
    encoded = tokenizer.encode(text, allowed_special={&#39;&lt;|endoftext|&gt;&#39;})
    encoded_tensor = torch.tensor(encoded).unsqueeze(0)
    return encoded_tensor

def token_ids_to_text(token_ids, tokenizer) -&gt; str:
    &quot;&quot;&quot;将token ID转换回文本&quot;&quot;&quot;
    flat = token_ids.squeeze(0)
    return tokenizer.decode(flat.tolist())
</code></pre>
<p>我们尝试调用一下：</p>
<pre><code class="language-python">generate_and_print_sample(model, tokenizer, device, &quot;Every effort moves you&quot;)
</code></pre>
<p>可以看到输出是毫无意义的：</p>
<pre><code>Every effort moves you Mexican rarity implementing NouPsychCle...&quot; Contributamong enable lacked complications tendon conclud Nearly oddly insign Champions senseless poopuclear shuts dove aspirinentionrous Miniasions fearsomeRanked adore disadvantages disregkeepvocensed eased museums William glovesople Palace shooters increases felony chops Batteryracuse Advertising cease
</code></pre>
<p>那么，如何将这个参数随机化的架构，转变为一个能够理解和生成语言的功能性模型呢？答案是通过<strong>模型训练 (Model Training)</strong>。</p>
<h2>3. 训练模型</h2>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831134852813.png" style="zoom:33%;" />

<p>本篇我们将进入将架构赋予生命的核心环节：<strong>实现训练循环 (Training Loop)</strong>。我们将详细介绍模型如何通过处理大量文本数据，在一个反复迭代的过程中，系统性地调整其内部数以亿计的参数。</p>
<p>我们将具体探讨<strong>损失函数 (Loss Function)</strong> 的计算、<strong>优化器 (Optimizer)</strong> 的作用以及<strong>反向传播 (Backpropagation)</strong> 的机制，这些是驱动模型从随机状态向智能状态收敛的根本动力。</p>
<h3>3.1 模型训练流程</h3>
<p><strong>训练流程</strong>的根本目的，就是通过一个系统性的、迭代的优化过程，让初始化的 <code>GPTModel</code> 这个&quot;空壳大脑&quot;通过学习海量的数据样本，逐步调整其内部参数，最终掌握预测下一个词元的规律。</p>
<p>模型学习的数学基础是<strong>梯度下降 (Gradient Descent)</strong>。其核心思想可以归结为：</p>
<ol>
<li><strong>定义目标</strong>：我们需要一个<strong>损失函数 (Loss Function)</strong> 来量化模型当前预测与真实答案之间的差距。差距越大，损失值越高。</li>
<li><strong>寻找方向</strong>：通过微积分计算损失函数对模型中每一个参数的<strong>梯度 (Gradient)</strong>。梯度指明了在该参数上，能让损失值<strong>上升最快</strong>的方向。</li>
<li><strong>进行修正</strong>：我们让参数朝着梯度的<strong>相反方向</strong>迈出一小步。这一小步的步长由<strong>学习率 (Learning Rate)</strong> 控制。</li>
<li><strong>反复迭代</strong>：不断重复&quot;预测-&gt;计算损失-&gt;计算梯度-&gt;更新参数&quot;的过程，模型的参数就会被逐步优化，使得损失值越来越小，预测越来越准。</li>
</ol>
<p><code>train_model_simple</code> 函数就是这一原理的精确代码实现。</p>
<pre><code class="language-python">def train_model_simple(model, train_loader, val_loader,
                    optimizer, device, num_epochs,
                    eval_freq, eval_iter, start_context, tokenizer):
    &quot;&quot;&quot;训练模型的主循环&quot;&quot;&quot;
    train_losses, val_losses, track_tokens_seen = [], [], []
    tokens_seen, global_step = 0, -1

    for epoch in range(num_epochs):
        model.train()
        for input_batch, target_batch in train_loader:
            optimizer.zero_grad()
            loss = calc_loss_batch(input_batch, target_batch, model, device)
            loss.backward()
            optimizer.step()
            tokens_seen += input_batch.numel()
            global_step += 1

            # 定期评估
            if global_step % eval_freq == 0:
                train_loss, val_loss = evaluate_model(
                    model, train_loader, val_loader, device, eval_iter)
                train_losses.append(train_loss)
                val_losses.append(val_loss)
                track_tokens_seen.append(tokens_seen)
                print(f&quot;Ep {epoch+1} (Step {global_step:06d}):&quot;
                    f&quot;Train loss {train_loss:.3f}, &quot;
                    f&quot;Val loss {val_loss:.3f}&quot;)

        # 每3个epoch生成样本
        if epoch % 3 == 0:
            generate_and_print_sample(model, tokenizer, device, start_context)
    return train_losses, val_losses, track_tokens_seen
</code></pre>
<p>我们可以将这个函数的结构分解为三个层次：<strong>外层循环</strong>、<strong>核心学习循环</strong>和<strong>监控系统</strong>。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831011653589.png" alt=""></p>
<h4>3.1.1 外层循环</h4>
<p><code>for epoch in range(num_epochs):</code> 定义了模型需要完整地看几遍整个训练数据集。一个 <strong>Epoch</strong> 代表对所有训练数据的一次完整遍历。让模型反复看同样的数据，是为了使其有机会从不同的批次组合和随机顺序中，更深入地学习数据中的模式。</p>
<h4>3.1.2 核心学习循环</h4>
<p><code>for input_batch, target_batch in train_loader:</code> 是学习发生的真正场所。对于从 <code>train_loader</code> 中取出的每一个数据批次，模型都会严格执行梯度下降的&quot;四步曲&quot;：</p>
<ol>
<li><p>清空旧梯度<code>optimizer.zero_grad()</code></p>
<p>PyTorch 的梯度计算默认是<strong>累加</strong>的。如果不手动清零，当前批次计算出的梯度会和之前所有批次的梯度叠加在一起，导致错误的更新方向。因此，在每次计算新梯度前，必须先“清空缓存”。</p>
</li>
<li><p>前向传播与计算损失 <code>loss = calc_loss_batch(...)</code></p>
<p>我们需要知道模型在当前参数下的表现有多差。<code>calc_loss_batch</code> 函数内部会调用 <code>model(input_batch)</code>，完成一次<strong>前向传播</strong>，得到预测的 logits。然后，使用<strong>交叉熵损失函数</strong> <code>cross_entropy</code> 来计算预测 logits 和真实 <code>target_batch</code> 之间的差距，得到一个量化误差的标量 <code>loss</code>。</p>
</li>
<li><p>反向传播计算梯度 <code>loss.backward()</code></p>
<p>知道了总误差（<code>loss</code>）后，我们需要将这个误差&quot;分摊&quot;到每一个导致误差的参数上，即计算损失对每一个模型参数的偏导数。这是 PyTorch <code>autograd</code> 引擎的核心功能。这一行代码会自动地、高效地完成整个<strong>反向传播</strong>过程，计算出模型中所有可训练参数的梯度，并存储在它们的 <code>.grad</code> 属性中。</p>
<blockquote>
<p>不熟悉反向传播概念的读者，可参考：<a href="https://hedon.top/2025/07/27/llm/back-propagation/">大白话解释反向传播算法</a></p>
</blockquote>
</li>
<li><p>更新模型参数 <code>optimizer.step()</code></p>
<p>有了修正方向（梯度）后，需要一个执行者来实际地调整参数。优化器（如 <code>AdamW</code>）会根据 <code>loss.backward()</code> 计算出的梯度，以及自身的更新规则（如学习率），去更新模型中的每一个参数，完成一次学习和进化。</p>
</li>
</ol>
<h4>3.1.3 监控系统</h4>
<p>仅仅闷头学习是不够的，我们还需要知道学得怎么样。这个函数内置了两套监控系统：</p>
<ul>
<li><strong>定量评估 (<code>if global_step % eval_freq == 0</code>)</strong>：每隔 <code>eval_freq</code> 步，就调用 <code>evaluate_model</code> 函数。该函数会暂停训练 (<code>model.eval()</code>)，在不计算梯度 (<code>torch.no_grad()</code>) 的模式下，快速计算模型在<strong>训练集</strong>和<strong>验证集</strong>上的损失。通过观察这两个损失的变化，我们可以清晰地了解模型的学习状态。</li>
<li><strong>定性观察 (<code>if epoch % 3 == 0</code>)</strong>：每隔几个 epoch，就调用 <code>generate_and_print_sample</code> 函数。它会给模型一个固定的开头 (<code>start_context</code>)，让模型在当前的学习状态下续写一段文本。通过观察从最初的“胡言乱语”到逐渐生成通顺句子的过程，我们可以获得最直观的反馈。</li>
</ul>
<h3>3.2 计算文本生成损失</h3>
<p>在 <code>train_model_simple</code> 中，有两个关键的辅助函数：</p>
<ul>
<li><code>calc_loss_batch</code> ：对于给定的一个批次数据，计算出模型预测与真实答案之间的差距有多大。</li>
<li><code>evaluate_model</code>：在不更新模型参数的前提下，客观地评估模型在当前阶段的学习效果。</li>
</ul>
<h4>3.2.1 批量损失计算</h4>
<pre><code class="language-python">def calc_loss_batch(input_batch, target_batch, model, device):
    &quot;&quot;&quot;计算单个批次的损失&quot;&quot;&quot;
    input_batch = input_batch.to(device)
    target_batch = target_batch.to(device)
    logits = model(input_batch)
    loss = torch.nn.functional.cross_entropy(
        logits.flatten(0, 1), target_batch.flatten(),
    )
    return loss
</code></pre>
<p>这里我们首先需要弄清楚一个核心问题：<strong><u>什么叫做模型预测与真实答案之间的差距？这个差距怎么算？为什么能这么算？</u></strong></p>
<h5>3.2.1.1 差距的本质是什么？</h5>
<ul>
<li><strong>模型的预测</strong>：<code>logits</code>。对于输入序列中的每一个位置，模型都会输出一个长度为 <code>vocab_size</code> (例如 50257) 的向量。这个向量里的每一个数值，代表模型认为对应的词元是正确答案的<strong>原始置信度分数</strong>。分数越高，代表模型越确信。</li>
<li><strong>真实答案</strong>：<code>target_batch</code>。这是一个具体的、唯一的词元 ID。例如，对于输入 <code>&quot;Time is&quot;</code>，正确的下一个词是 <code>&quot;an&quot;</code>，那么真实答案就是 <code>&quot;an&quot;</code> 对应的那个唯一的 ID。</li>
<li><strong>差距</strong>：差距就是<strong>模型赋予&quot;真实答案&quot;的那个置信度分数，与它本应达到的理想状态（绝对确信）之间的距离</strong>。如果模型给真实答案的置信度分数很高，而给其他所有错误答案的分数都很低，那么这个差距就很小。反之，如果模型给真实答案的分数很低，那么差距就很大。</li>
</ul>
<h5>3.2.1.2 这个差距怎么算？</h5>
<p><code>torch.nn.functional.cross_entropy</code> 这个函数虽然只有一行，但它在内部完成了两个关键的数学步骤，来将我们上面描述的抽象差距转化为一个可量化的数值（损失）。</p>
<p><strong>第一步：使用 <code>Softmax</code> 将置信度分数转为概率</strong></p>
<p>模型的 <code>logits</code> 只是原始分数，有正有负，大小不一，不方便直接比较。我们需要将它转换成一个标准的<strong>概率分布</strong>，即所有可能答案的概率加起来等于 1。<code>Softmax</code> 函数就是做这个的。</p>
<p>例如，假设词汇表只有 5 个词，对于某个位置，模型输出的 <code>logits</code> 是 <code>[1.0, 4.0, 2.0, -1.0, 0.0]</code>。经过 <code>Softmax</code> 转换后，它会变成类似 <code>[0.02, 0.68, 0.06, 0.00, 0.24]</code> 的概率分布。</p>
<p>现在，模型的预测变得清晰了：它有 68% 的把握认为第二个词是正确答案。</p>
<p><strong>第二步：使用负对数似然 (Negative Log-Likelihood) 计算损失</strong></p>
<p>现在我们有了模型的概率预测，也知道了唯一的正确答案（比如就是第二个词）。我们如何量化这个预测的好坏呢？</p>
<p>我们只需要看模型<strong>赋予那个正确答案的概率值</strong>。在这个例子里，是 <code>0.68</code>。</p>
<ul>
<li><strong>理想情况</strong>：如果模型完美，它应该给正确答案 100% 的概率，即 <code>1.0</code>。</li>
<li><strong>我们希望</strong>：让模型赋予正确答案的概率尽可能接近 <code>1.0</code>。</li>
</ul>
<p>负对数似然就是实现这个目标的完美工具。它的公式是 $Loss=−log(p_{correct})$，其中 $p_{correct}$ 是模型赋予正确答案的概率。</p>
<p>让我们看看它的特性：</p>
<ul>
<li>当 $p_{correct}→1.0$ （模型预测很准）时，$Loss=−log(1.0) → 0$ 损失非常小。</li>
<li>当 $p_{correct} → 0$（模型预测离谱）时，$Loss=−log(0) → infty$ 损失会变得非常大。</li>
</ul>
<p>这个特性棒极了！它<strong>极大地惩罚了那些离谱的错误预测</strong>，从而在反向传播时产生巨大的梯度，迫使模型去修正这个严重的错误。</p>
<p><code>cross_entropy</code> 函数将 <code>Softmax</code> 和<strong>负对数似然</strong>这两步合并在了一起，不仅方便使用，而且在数值计算上更加稳定。</p>
<h5>3.2.1.3 为什么能这么算？</h5>
<p>从根本上说，这是因为我们将<strong>语言建模问题，转化为了一个序列性的多分类问题 (Multi-Class Classification Problem)</strong>。</p>
<p>在序列的每一个时间步，模型都在做一个分类任务：从 <code>vocab_size</code> 个可能的类别（词元）中，选出最有可能的那一个。</p>
<p><strong>交叉熵 (Cross-Entropy)</strong> 源自信息论，是衡量两个概率分布之间差异的标准方法。在这里，这两个分布是：</p>
<ol>
<li><strong>模型的预测分布</strong>：经过 <code>Softmax</code> 后的那个概率向量。</li>
<li><strong>真实的理想分布</strong>：一个 <strong>one-hot</strong> 向量。即在正确答案的索引位置为 1，其他所有位置为 0 的向量（例如 <code>[0, 1, 0, 0, 0]</code>）。</li>
</ol>
<p>最小化交叉熵损失，就是在<strong>迫使模型的预测分布去无限逼近那个理想的、尖锐的真实分布</strong>。通过在海量文本上不断地做这件事，模型就不得不去学习语言的内在规律和模式，以便在任何给定的上下文后，都能生成一个最接近真实世界的下一个词的概率分布。</p>
<p>最后，代码中的 <code>.flatten(0, 1)</code> 操作，是将 <code>(batch_size, context_length)</code> 这两个维度压平。这相当于告诉损失函数：&quot;别把它们看作是一批句子，请把这批数据里<strong>所有位置的预测任务，都当作是独立的分类问题</strong>来同等对待&quot;，从而高效地一次性计算出整个批次的总损失。</p>
<blockquote>
<p>[!NOTE]</p>
<p>对交叉熵损失概念依旧不是很熟悉的读者，可以参考：<a href="https://hedon.top/2025/08/13/llm/cross-entropy-loss/">大白话解释交叉熵损失</a></p>
</blockquote>
<h4>3.2.2 模型评估</h4>
<pre><code class="language-python">def evaluate_model(model, train_loader, val_loader, device, eval_iter):
    &quot;&quot;&quot;评估模型性能&quot;&quot;&quot;
    model.eval()
    with torch.no_grad():
        train_loss = calc_loss_loader(train_loader, model, device, num_batches=eval_iter)
        val_loss = calc_loss_loader(val_loader, model, device, num_batches=eval_iter)
    model.train()
    return train_loss, val_loss

def calc_loss_loader(data_loader, model, device, num_batches=None):
    &quot;&quot;&quot;计算数据加载器的平均损失&quot;&quot;&quot;
    total_loss = 0
    if len(data_loader) == 0:
        return float(&quot;nan&quot;)
    elif num_batches is None:
        num_batches = len(data_loader)
    else:
        num_batches = min(num_batches, len(data_loader))
    for i, (input_batch, target_batch) in enumerate(data_loader):
        if i &lt; num_batches:
            loss = calc_loss_batch(input_batch, target_batch, model, device)
            total_loss += loss.item()
        else:
            break
    return total_loss / num_batches
</code></pre>
<p>这段代码的结构清晰地体现了评估的核心原则：</p>
<ol>
<li><strong>状态切换 (<code>model.eval()</code> 和 <code>model.train()</code>)</strong>：这是评估流程的开关。<code>model.eval()</code> 会关闭 <code>Dropout</code> 等只在训练时使用的层，确保评估结果的稳定和可复现。评估结束后，<code>model.train()</code> 会重新打开它们，让训练继续。</li>
<li><strong>隔离环境 (<code>with torch.no_grad()</code>)</strong>：这是为了确保评估的纯粹性。在这个代码块中，PyTorch 不会跟踪计算图和梯度。这不仅能防止任何意外的参数更新，还能大幅减少内存占用和计算时间，让评估更高效。</li>
<li><strong>委托计算 (<code>calc_loss_loader</code>)</strong>：<code>evaluate_model</code> 将具体的计算任务委托给 <code>calc_loss_loader</code>。这个函数是一个通用的损失计算器，它迭代指定数量的批次，调用 <code>calc_loss_batch</code> 累加损失，最后返回平均损失。这种分层设计让代码更整洁。</li>
<li><strong>双重检验</strong>：同时计算<strong>训练损失</strong>和<strong>验证损失</strong>是至关重要的。<ul>
<li>训练损失持续下降，说明模型在努力学习。</li>
<li>验证损失也随之下降，说明模型学到了普适的规律（泛化能力好）。</li>
<li>如果训练损失下降，但验证损失开始上升，这就是<strong>过拟合</strong>的信号，说明模型开始&quot;死记硬背&quot;训练题，而不是真正理解。</li>
</ul>
</li>
</ol>
<h3>3.3 保存和加载模型</h3>
<p>到目前为止，我们已经讨论了如何从数值上评估训练进展，并从头开始预训练了一个大语言模型。尽管样例中使用的大语言模型和数据集都相对较小，但这足以表明预训练大语言模型代价高昂。因此，保存大语言模型的参数非常重要，这样就不必每次使用它时都重新运行训练。</p>
<p>这部分很简单，可以直接参考 <a href="https://docs.pytorch.org/tutorials/beginner/saving_loading_models.html">PyTorch - Saving and Loading Models</a></p>
<ul>
<li><p>保存模型</p>
<pre><code class="language-python">dump_model_file = &quot;model_and_optimizer.pth&quot;
torch.save({
    &quot;model_state_dict&quot;: model.state_dict(),
    &quot;optimizer_state_dict&quot;: optimizer.state_dict(),
}, dump_model_file)
</code></pre>
</li>
<li><p>加载模型</p>
<pre><code class="language-python">checkpoint = torch.load(dump_model_file, map_location=device)
model = GPTModel(GPT_CONFIG_124M)
model.load_state_dict(checkpoint[&quot;model_state_dict&quot;])
optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=5e-4, weight_decay=0.1,
)
optimizer.load_state_dict(checkpoint[&quot;optimizer_state_dict&quot;])
model.train()
</code></pre>
</li>
</ul>
<p>既然我们能加载自己之前训练的模型，那是不是可以加载别人训练的更好的模型呢？当然！幸运的是，OpenAI 公开分享了它们的 GPT-2 模型的权重，从而省去了我们自己在大型语料库上重新训练模型所需投入的数万到数十万美元。因此，我们可以将这些权重加载到 GPTModel 类中，并使用该模型进行文本生成。这里， 权重指的是存储在 PyTorch 的 Linear 层和 Embedding 层的 <code>.weight</code> 属性中的权重参数。前面在训练模型时，我们通过 <code>model.parameters()</code> 访问过。</p>
<p>这部分不在本篇的核心讨论目标之中，感兴趣的读者可以参考：<a href="https://github.com/rasbt/LLMs-from-scratch/tree/main/ch05/01_main-chapter-code">LLMs-from-scratch-ch05</a>。</p>
<h2>4. 文本生成</h2>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831135822616.png" style="zoom:33%;" />

<p>终于来到我们最后一个环节了：文本生成！</p>
<p>前面我们在 2.6 章节已经介绍了使用 <code>generate_text_simple</code> 进行最基本的文本生成了。本篇我们将介绍一个更具实际意义的文本生成函数 <code>generate</code>，它是 <code>generate_text_simple</code> 经过两种技术（温度缩放和 Top-k 采样）改进而来的。</p>
<p>还是回到第一性原理上，<strong><u>为什么我们需要采取额外的技术来优化文本生成？</u></strong></p>
<p>要回答这个问题，我们必须搞清楚 <code>generate_text_simple</code> 中使用的最基本生成方法——<strong>贪婪解码 (Greedy Decoding)</strong>——的根本缺陷是什么。</p>
<p>在 <code>generate_text_simple</code> 函数中，核心决策步骤是 <code>idx_next = torch.argmax(probas, dim=-1, keepdim=True)</code>。这行代码的意思是：在模型预测的所有词元的概率中，永远选择那个<strong>概率最高</strong>的词元作为下一个词。</p>
<p>这种方法虽然简单直接，但存在三个致命的问题：</p>
<ol>
<li><strong>重复和乏味</strong>：模型很容易陷入重复的循环中。例如，如果 &quot;the&quot; 是最常见的下一个词，模型可能会不断生成 &quot;the the the...&quot;。因为它只看眼前概率最高的一步，缺乏全局视野，导致生成的文本非常单调和机械。</li>
<li><strong>确定性和可预测性</strong>：对于同一个输入，贪婪解码的输出永远是完全相同的。这对于需要创造力和多样性的任务（如写故事、回答开放性问题）来说是不可接受的。</li>
<li><strong>错失更优解</strong>：有时，概率第二或第三高的词，可能在长远来看会引导出一个更通顺、更有意义的句子。贪婪解码这种&quot;短视&quot;的策略，会因为眼前的&quot;最优&quot;选择而错失全局的&quot;更优&quot;路径。</li>
</ol>
<p><strong>根本问题在于，语言本身不是一个永远选择最常见单词的确定性过程，它充满了多样性和一定的随机性。</strong> 我们需要一种方法，既能让模型主要选择那些靠谱的、概率高的词，又能引入适度的随机性，让它偶尔能灵光一闪，选择一些不那么常见但同样合理的词，从而生成更自然、更有趣的文本。</p>
<p><strong>温度缩放 (Temperature Scaling)</strong> 和 <strong>Top-k 采样 (Top-k Sampling)</strong> 就是解决这个问题的两种强大技术。</p>
<h3>4.1 温度缩放</h3>
<p>温度缩放是一种在从 <code>logits</code> 计算最终概率时，调节模型&quot;自信度&quot;的技术。</p>
<p>它的核心公式是：</p>
<p>$$
probabilities = Softmax(\frac{logits}{temperature})
$$</p>
<blockquote>
<p>因为关键的 Softmax 函数是非线性的，它会将 logits 被温度缩放后<strong>减小的差值</strong>转换成更<strong>平缓</strong>的概率分布，或将<strong>放大的差值</strong>转换成更<strong>尖锐</strong>的概率分布，所以最终结果会改变。</p>
</blockquote>
<ul>
<li><strong>当 <code>temperature</code> &gt; 1 (例如 1.5)</strong>：<code>logits</code> 会被缩小，使得不同词元之间的分数差距变小。经过 <code>Softmax</code> 后，概率分布会变得更<strong>平缓</strong>。这意味着，模型会降低对高概率词的执念，同时提升对低概率词的关注度，从而有更大的机会选择不那么常见的词。这会增加生成文本的<strong>多样性和创造性</strong>，但过高则可能导致内容不连贯。</li>
<li><strong>当 <code>temperature</code> &lt; 1 (例如 0.7)</strong>：<code>logits</code> 会被放大，使得分数差距拉大。<code>Softmax</code> 后的概率分布会变得更<strong>陡峭</strong>。模型会更加确信那些它认为概率最高的词，降低选择其他词的可能性。这使得生成文本更<strong>稳定和保守</strong>，更贴近训练数据的模式。</li>
<li><strong>当 <code>temperature</code> 趋近于 0</strong>：这会极端地放大最高分数的 <code>logit</code>，使得 <code>Softmax</code> 的结果无限接近于 <code>argmax</code>，最终效果等同于贪婪解码。</li>
</ul>
<p>所以你现在应该知道 ChatGPT 接口中这个参数的来源了吧！</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831141218682.png" alt=""></p>
<h3>4.2 Top-k 采样</h3>
<p>即便我们用温度调节了想象力，仍然存在一个问题：词汇表非常大，有很多词元的概率虽然不为零，但实际上是完全不合理的。如果在采样时不幸选中了它们，就会产生无意义的文本。</p>
<p>Top-k 采样的思想非常直观：<strong>我们只在最靠谱的一小撮候选词中进行采样。</strong></p>
<p>它的步骤如下：</p>
<ol>
<li><strong>筛选</strong>：在模型生成了所有词元的概率分布后，我们只保留其中概率最高的 <code>k</code> 个词元。</li>
<li><strong>重新分配概率</strong>：将这 <code>k</code> 个词元的概率进行归一化，使它们的概率之和为 1。</li>
<li><strong>采样</strong>：在这个小得多的、由靠谱候选词组成的集合中，根据新的概率分布进行随机采样。</li>
</ol>
<p>例如，如果设置 <code>k=5</code>，那么模型在决定下一个词时，只会从它认为最有可能的 5 个词中进行选择。这极大地<strong>降低了生成离谱或不相关词汇的风险</strong>，同时又通过在少数几个好的选项中进行随机抽样，保留了文本的多样性。它在模型的&quot;创造力&quot;和&quot;连贯性&quot;之间取得了绝佳的平衡。</p>
<p>值得一提的是，在 ChatGPT 的 API 中，并没有提供 <code>top_k</code> 这个参数，相反的，它提供的是 <code>top_p</code>。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250831142404381.png" alt=""></p>
<p><code>top_k</code> 和 <code>top_p</code> 都是为了解决同样的问题：如何在一个合理的范围内进行随机采样，以避免模型生成无意义的词。但它们的实现方式决定了各自的优劣。</p>
<p><code>top_k</code> 强制模型只从概率最高的 <code>k</code> 个词中选择。问题在于，这个 <code>k</code> 是一个固定值，无法适应模型在不同情况下的自信度。</p>
<ul>
<li><strong>当模型非常确定时</strong>：比如在 &quot;The capital of France is&quot; 之后，模型对 &quot;Paris&quot; 的预测概率可能高达 99%。此时如果你的 <code>k</code> 设置为 10，你仍然会把 9 个几乎不可能的选项（比如 &quot;London&quot;, &quot;Berlin&quot;）纳入采样范围，这可能会引入不必要的噪声。</li>
<li><strong>当模型非常不确定时</strong>：比如在一个开放式创作的开头 &quot;Once upon a time, there was a&quot;，可能有非常多合理的词，它们的概率分布可能非常平缓（例如，前 20 个词的概率都差不多）。此时如果你的 <code>k</code> 设置为 5，你就会武断地切掉很多同样合理的选项，限制了模型的创造力。</li>
</ul>
<p><code>top_p</code> 不限制候选词的数量，而是限制候选词的<strong>累积概率</strong>。例如，设置 <code>top_p: 0.9</code>，模型会从高到低选择词元，直到它们的概率总和达到 90%，然后只在这个动态生成的候选集里进行采样。</p>
<ul>
<li><strong>在模型非常确定的情况下</strong>： &quot;Paris&quot; 的概率是 99%，已经超过了 90% 的阈值。因此，候选集里<strong>只有 &quot;Paris&quot; 一个词</strong>。这完美地保留了模型的确定性。</li>
<li><strong>在模型非常不确定的情况下</strong>：为了凑够 90% 的概率，可能需要把<strong>前 20 个词</strong>都包含进来。这同样完美地适应了模型的不确定性，允许它在一个更广阔、更具创造力的空间里进行选择。</li>
</ul>
<h3>4.3 结合</h3>
<p>将上述两种技术结合起来，就得到了我们最终实现的文本生成函数 <code>generate</code> 了：</p>
<pre><code class="language-python">def generate(model, idx, max_new_tokens, context_length,
        temperature=0.0, top_k=None, eos_id=None):
    &quot;&quot;&quot;带温度缩放、top_k 筛选的文本生成策略&quot;&quot;&quot;
    for _ in range(max_new_tokens):
        idx_cond = idx[:, -context_length:]
        with torch.no_grad():
            logits = model(idx_cond)
        logits = logits[:, -1, :]

        # 使用 top_k 采样筛选 logits
        if top_k is not None:
            top_logits, _ = torch.topk(logits, top_k)
            min_val = top_logits[:, -1]
            logits = torch.where(
                logits &lt; min_val,
                torch.tensor(float(&#39;-inf&#39;)).to(logits.device),
                logits,
            )

        if temperature &gt; 0.0:
            # 使用温度缩放
            logits = logits / temperature
            probs = torch.softmax(logits, dim=-1)
            idx_next = torch.multinomial(probs, num_samples=1)
        else:
            # 当不使用温度缩放时，执行贪心解码，选取下一个词元
            idx_next = torch.argmax(logits, dim=-1, keepdim=True)

        # 如果遇到序列结束词元，则提前停止生成
        if idx_next == eos_id:
            break
        idx = torch.cat((idx, idx_next), dim=1)
    return idx
</code></pre>
<h3>4.4 其他思路</h3>
<p>除了常见的温度采样和 top-k/top-p 采样，还有多种技术可以用来控制模型的文本生成策略，每种技术都有其独特的优缺点和适用场景。</p>
<p><strong>1. Beam Search (集束搜索)</strong></p>
<p>这是一种基于搜索的启发式算法，旨在找到一个整体概率最高的序列，而不是仅仅关注每一步的最优选择。</p>
<ul>
<li><strong>工作原理</strong>：在生成的每一步，它会保留 <code>k</code> 个（<code>k</code> 在这里被称为集束宽度或 beam width）最可能的候选序列。在下一步，它会从这 <code>k</code> 个序列出发，生成所有可能的下一个词，并计算新序列的总概率，然后再次只保留总概率最高的 <code>k</code> 个序列。这个过程会一直持续到生成结束。</li>
<li><strong>优势</strong>：通过探索多种可能性，它通常能生成比贪婪搜索（Greedy Search，即每步都选概率最高的词）更流畅、更全局最优的序列。</li>
<li><strong>劣势</strong>：它倾向于生成高频、安全的文本，可能会缺乏多样性和创造性。同时，计算开销比简单的采样方法要大。</li>
</ul>
<p><strong>2. Contrastive Search (对比搜索)</strong></p>
<p>这是一种较新的解码方法，旨在通过结合模型的概率和词元间的相似性来提升生成文本的连贯性和多样性，有效减少重复。</p>
<ul>
<li><strong>工作原理</strong>：在每一步选择下一个词元时，它会同时考虑两个因素：<ol>
<li><strong>模型置信度</strong>：下一个词元的概率要高。</li>
<li><strong>多样性/惩罚</strong>：下一个词元不应该和前文已经生成的词元过于相似。它通过计算候选词元与上文的相似性得分，并从模型概率中减去这个相似性得分作为惩罚项。</li>
</ol>
</li>
<li><strong>优势</strong>：在许多评测中，对比搜索被证明可以在不需要对模型进行任何额外训练的情况下，显著优于传统的解码方法，尤其在减少文本重复和提升连贯性方面表现突出。</li>
</ul>
<p><strong>3. Mirostat 采样</strong></p>
<p>这是一种自适应的采样算法，它的目标是让生成文本的&quot;惊奇度&quot;（Perplexity，一种衡量不确定性的指标）维持在一个预设的目标值附近。</p>
<ul>
<li><strong>工作原理</strong>：Mirostat 会在生成过程中持续监控输出文本的困惑度（Perplexity）。如果当前文本的困惑度低于目标值（意味着文本过于平淡、可预测），算法就会动态调整采样策略（如调整 <code>top-k</code> 的 <code>k</code> 值）来增加随机性。反之，如果困惑度太高（文本可能不连贯），它就会降低随机性。</li>
<li><strong>优势</strong>：它通过一个反馈循环来直接控制生成文本的统计特性，可以有效避免陷入无聊陷阱（过度重复）和困惑陷阱（内容不连贯）。</li>
</ul>
<p><strong>总结</strong></p>
<table>
<thead>
<tr>
<th>解码策略</th>
<th>核心思想</th>
<th>优点</th>
<th>缺点</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Beam Search</strong></td>
<td>保留 <code>k</code> 个最可能的序列，追求全局最优。</td>
<td>连贯性好，适合翻译、摘要等任务。</td>
<td>多样性差，计算成本较高。</td>
</tr>
<tr>
<td><strong>Contrastive Search</strong></td>
<td>结合模型概率和与上文的相异度来选择。</td>
<td>显著减少重复，提升连贯性。</td>
<td>算法相对复杂。</td>
</tr>
<tr>
<td><strong>Mirostat</strong></td>
<td>动态调整采样，使文本的困惑度维持在目标水平。</td>
<td>直接控制文本的统计特性，避免重复和不连贯。</td>
<td>需要设定一个合适的目标困惑度值。</td>
</tr>
</tbody></table>
<h2>总结</h2>
<p>至此，我们 500 行代码的旅程也接近了尾声。本文完整记录了从零开始构建一个 GPT 风格语言模型的全过程，旨在将一个复杂的系统拆解为一系列清晰、可执行的步骤。</p>
<p>首先，从数据处理入手，阐述了如何将原始文本语料通过词元化、滑动窗口采样等方法，构建成模型训练所需的、包含输入-目标对的批量化张量（batched tensors）。</p>
<p>接着，深入剖析了 <strong>Transformer 模型的核心架构</strong>。从输入层的词元与位置嵌入，到作为核心处理单元的 TransformerBlock 堆叠。在此过程中，详细解释了多头因果自注意力机制、前馈网络、层归一化和残差连接等关键组件的原理与作用，展示了它们如何协同工作以融合上下文信息并稳定深度网络的训练。</p>
<p>在模型结构之后，文章介绍了完整的<strong>训练循环</strong>。这包括前向传播、交叉熵损失计算、反向传播和优化器更新参数的完整流程，并展示了如何通过验证集监控训练状态，以评估模型的学习效果和泛化能力。</p>
<p>最后，文章探讨了<strong>文本生成阶段的解码策略</strong>，分析了从基础的贪婪解码到更高级的温度采样、Top-k 和 Top-p 等方法的原理，以及它们如何被用于控制生成文本的多样性与连贯性。</p>
<p>本文的核心主线是展示一个复杂的大语言模型系统，实际上可以被拆解为一系列目标明确、逻辑清晰的子问题和对应的工程实现。通过逐一解决从数据表示、上下文融合、深度网络训练稳定性到高质量文本生成等一系列挑战，我们最终将这些独立的模块化解决方案组合成一个功能完备的系统。</p>
<p>通过这种从零开始的构建过程，我们不仅能理解各个技术点的作用，更能把握它们之间如何相互关联、协同工作，从而对整个大语言模型的工作原理形成一个结构化、系统性的认知。希望本文的拆解与实现，能为每一位对大模型内部工作原理感到好奇、并希望从实践中构建体系化认知的开发者，提供一条清晰可循的路径和切实的帮助。</p>
]]></content:encoded>
    </item>
    <item>
      <title>优雅重启的范式转移：从 tableflip 到 Kubernetes 的 Go 服务升级终极指南</title>
      <link>https://hedon.top/blog/graceful-restart-from-tableflip-to-k8s/</link>
      <guid isPermaLink="true">https://hedon.top/blog/graceful-restart-from-tableflip-to-k8s/</guid>
      <pubDate>Sat, 30 Aug 2025 10:31:45 GMT</pubDate>
      <description>本文将带您踏上优雅重启的范式转移之旅，从 tableflip 的第一性原理出发，深入剖析其工作机制；然后，我们将切换视角，审视 Kubernetes 是如何以一种截然不同的哲学来定义和实现优雅；最后，我们将深入 Kubernetes 实践的每一个细节，从探针、竞态条件到有状态服务和多服务进程，为您在云原生世界中构建高可用 Go 应用，提供一份清晰、详尽的终极指南。</description>
      <category>解决方案</category>
      <content:encoded><![CDATA[<h3>前言：同一个目标，两个世界</h3>
<p>在软件开发的世界里，实现服务的&quot;零停机更新&quot;是一个永恒的追求。它意味着我们的服务可以在发布新版本、修复 Bug 甚至变更配置时，依然对用户保持连续可用，这是衡量一个系统成熟与否的关键指标。</p>
<p>在 Go 的生态中，<code>tableflip</code> 库以其精巧绝伦的设计，为我们展示了一种在单机时代实现优雅重启的&quot;魔法&quot;。它通过 <code>fork/exec</code> 和文件描述符传递，实现了进程级的无缝交接，令人拍案叫绝。</p>
<p>然而，当我们踏入 Kubernetes 所引领的云原生时代，会惊奇地发现，这个曾经的屠龙之技似乎变得水土不服，甚至被视为一种反模式 (anti-pattern)。为什么一个如此优雅的方案，会在新的环境中失效？</p>
<p>本文将带您踏上这段优雅重启的范式转移之旅。我们将从 <code>tableflip</code> 的第一性原理出发，深入剖析其工作机制；然后，我们将切换视角，审视 Kubernetes 是如何以一种截然不同的哲学来定义和实现优雅；最后，我们将深入 Kubernetes 实践的每一个细节，从探针、竞态条件到有状态服务和多服务进程，为您在云原生世界中构建高可用 Go 应用，提供一份清晰、详尽的终极指南。</p>
<h3>1. 旧世界的艺术品 —— <code>tableflip</code> 的魔法</h3>
<p><code>tableflip</code> 的核心思想，是在一个稳定的、长生命周期的环境（如一台虚拟机或物理机）中，用一个新的进程实例，<strong>原地、无缝地替换</strong>掉一个旧的进程实例，而对外服务的端口始终保持监听。</p>
<p>它的魔法源于一个经典的 Unix/Linux 系统特性：父进程可以将其打开的文件描述符（File Descriptors, FD）传递给子进程。对于一个网络服务而言，最重要的文件描述符，就是那个监听网络端口的 <code>socket FD</code>。</p>
<p><code>tableflip</code> 的工作流程，可以通过下图清晰地展示：</p>
<pre><code class="language-mermaid">graph TD
    %% Define Node Shapes
    classDef state fill:#d4f0f0,stroke:#333,stroke-width:2px;
    classDef action fill:#fff2cc,stroke:#333,stroke-width:2px;
    classDef process fill:#f8cecc,stroke:#b85450,stroke-width:2px;
    classDef traffic fill:#dae8fc,stroke:#6c8ebf,stroke-width:2px;

    %% Initial State
    A[&quot;服务运行中 (v1)&lt;br/&gt;父进程 accept() 所有连接&quot;]:::state;
    B{&quot;收到 SIGUSR2 更新信号&quot;}:::action;

    %% Core Actions
    C{&quot;fork/exec 创建子进程 (v2)&quot;}:::action;
    D{&quot;通过 UDS 传递 Socket FD&quot;}:::action;

    %% State Split - The core of the graceful restart
    E[&quot;&lt;b&gt;子进程 (v2) 行为&lt;/b&gt;&lt;br/&gt;继承 Socket FD&lt;br/&gt;开始 accept() &lt;b&gt;新&lt;/b&gt;的连接&quot;]:::process;
    F[&quot;&lt;b&gt;父进程 (v1) 行为&lt;/b&gt;&lt;br/&gt;停止 accept() 新连接&lt;br/&gt;继续处理&lt;b&gt;已建立&lt;/b&gt;的连接&quot;]:::process;

    %% Final Action
    G[&quot;所有旧连接处理完毕&lt;br/&gt;父进程干净地退出&quot;]:::action;

    %% Final State
    H[&quot;服务运行中 (v2)&lt;br/&gt;子进程 accept() 所有连接&quot;]:::state;

    %% Traffic Flow
    NewReq(&quot;新的客户端请求&quot;):::traffic;
    OldReq(&quot;已建立的连接&quot;):::traffic;

    %% Chart Flow
    A --&gt; B;
    B --&gt; C;
    C --&gt; D;
    D --&gt; E;
    D --&gt; F;
    F --&gt; G;
    E --&gt; H;
    G --&gt; H;

    NewReq --&gt; E;
    OldReq --&gt; F;
</code></pre>
<p>从外部客户端看来，服务的端口从未关闭，请求始终被处理，一次完美的零停机更新就这样在进程层面完成了。</p>
<h3>2. 新世界的哲学 —— Kubernetes 的宏大编排</h3>
<p>现在，让我们把视角切换到 Kubernetes。Kubernetes 的世界观与 <code>tableflip</code> 的假设完全不同。它的核心哲学是<strong>不可变基础设施 (Immutable Infrastructure)</strong>。</p>
<p>在这个哲学下，运行中的容器 (Pod) 被视为<strong>短暂的、可任意替代的</strong>（ephemeral and disposable），就像牧群中的牛羊 (cattle)，而不是需要精心照料的宠物 (pets)。我们从不&quot;修复&quot;或&quot;升级&quot;一个正在运行的容器，我们只用一个新的、配置好的容器去<strong>替换</strong>它。</p>
<p>Kubernetes 实现零停机更新的机制，是<strong>滚动替换 (Rolling Update)</strong>，这是一场由更高维度（<code>Deployment</code> 控制器）编排的、跨越整个集群的宏大工程。</p>
<h3>3. 范式冲突 —— 为什么 <code>tableflip</code> 水土不服</h3>
<p><code>tableflip</code> 的优雅，建立在一个稳定的、可直接操控进程的底层环境之上。而 Kubernetes 恰恰抽象掉了这个底层，带来了更高维度的管理模型。二者的冲突，源于根本性的“世界观”不合。</p>
<ol>
<li><strong>抽象层级不匹配</strong>: <code>tableflip</code> 在 <strong>Pod 内部</strong> 玩&quot;进程接力&quot;，而 Kubernetes 在 <strong>Pod 外部</strong> 玩&quot;Pod 替换&quot;。你在旧 Pod 内部做的任何进程替换，对于 Kubernetes 的宏大更新流程来说，是毫无意义的。</li>
<li><strong>资源竞争与 OOMKilled</strong>: <code>tableflip</code> 在执行 <code>Upgrade()</code> 的短暂瞬间，父子两个进程会同时存在，这意味着应用的内存和 CPU 消耗可能会瞬间翻倍。在资源受严格限制的 Kubernetes Pod 中，这极易触发 OOMKilled（Out of Memory Killer），优雅重启变成了&quot;暴力猝死&quot;。</li>
<li><strong>功能冗余与复杂化</strong>: Kubernetes 的 <code>Deployment</code> + <code>Service</code> + <code>Readiness Probe</code> 已经提供了一套经过大规模生产验证的、跨节点的零停机更新方案。<code>tableflip</code> 想要解决的问题，在 Kubernetes 的世界里已经由更高维度的架构设计解决了。</li>
</ol>
<h3>4. K8s 的优雅之道 —— Go 开发者深度实践指南</h3>
<p>既然旧世界的魔法已经失效，我们就必须学习并掌握新世界的规则。在 Kubernetes 中，真正的优雅，是应用程序与编排平台之间的一场精妙的“双人舞”。</p>
<h4>4.1 序曲：一切从 <code>server.Shutdown()</code> 开始</h4>
<p>无论平台如何演变，应用自身具备优雅关闭的能力是所有高级实践的起点。一个基础的、具备优雅关闭能力的 Go 服务应该如下：</p>
<pre><code class="language-Go">func main() {
    server := &amp;http.Server{Addr: &quot;:8080&quot;}
    // ... 你的业务 handler ...
    go func() {
        if err := server.ListenAndServe(); err != nil &amp;&amp; err != http.ErrServerClosed {
            log.Fatalf(&quot;ListenAndServe(): %v&quot;, err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    &lt;-quit // 阻塞直到收到信号

    log.Println(&quot;Shutting down server...&quot;)
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := server.Shutdown(ctx); err != nil {
        log.Fatal(&quot;Server shutdown failed:&quot;, err)
    }
    log.Println(&quot;Server exited properly&quot;)
}
</code></pre>
<p>这段代码正确地响应了 Kubernetes 的&quot;请关闭&quot;信号 (<code>SIGTERM</code>)，是优雅之路的第一步。</p>
<h4>4.2 K8s 的眼睛：深入理解探针 (Probes)</h4>
<p>Kubernetes 如何知道你的新 Pod “准备就绪”了？它如何判断一个运行中的 Pod 是否“卡死”了？答案是<strong>探针 (Probes)</strong>。</p>
<pre><code class="language-mermaid">stateDiagram-v2
    state &quot;Pending&quot; as P
    state &quot;ContainerCreating&quot; as CC
    state &quot;Running&quot; as R

    [*] --&gt; P
    P --&gt; CC
    CC --&gt; R

    state R {
        direction LR
        state &quot;Startup Probe&quot; as SP
        state &quot;Liveness/Readiness Probes&quot; as LRP
        state &quot;Ready&quot; as RDY
        state &quot;NotReady&quot; as NRDY
        state &quot;Restarting&quot; as RST

        [*] --&gt; SP : 容器启动
        SP --&gt; LRP : 启动探针成功
        SP --&gt; RST : 启动探针失败

        LRP --&gt; RDY : 就绪探针成功
        LRP --&gt; NRDY : 就绪探针失败
        RDY --&gt; LRP : 周期性检查
        NRDY --&gt; LRP : 周期性检查

        state &quot;Liveness Check&quot; as LC
        state &quot;Readiness Check&quot; as RC
        LRP: LC &amp; RC

        LC --&gt; [*] : 存活探针失败 --&gt; RST
    }
</code></pre>
<ul>
<li><strong>存活探针 (Liveness Probe)</strong>: 像一个心跳检测仪，失败会导致容器<strong>重启</strong>。</li>
<li><strong>就绪探针 (Readiness Probe)</strong>: 像一块营业中/休息中的牌子，失败会导致流量被<strong>停止</strong>。</li>
<li><strong>启动探针 (Startup Probe)</strong>: 为启动缓慢的应用提供额外的宽限期。</li>
</ul>
<p>对于一个需要预热缓存的 Go 应用，我们应该分别实现 <code>/healthz</code> (Liveness) 和 <code>/readyz</code> (Readiness) 端点，并在 Kubernetes YAML 中精确配置。</p>
<h4>4.3 魔鬼在细节中：破解优雅终止的竞态条件</h4>
<p>一个致命的魔鬼隐藏在细节中：当一个 Pod 被终止时，<code>Service</code> 端点列表的更新在整个集群中的传播<strong>不是瞬时的</strong>。这会导致竞态条件。</p>
<p><strong>错误的关闭流程 - 竞态条件</strong></p>
<pre><code class="language-mermaid">sequenceDiagram
    participant Kubelet as Kubelet
    participant App as Go 应用 (Pod)
    participant Endpoints as Endpoints Controller
    participant KubeProxy as Kube-Proxy (在其他节点)
    participant Client as 客户端

    Kubelet-&gt;&gt;App: 发送 SIGTERM 信号
    App-&gt;&gt;App: 立即调用 server.Shutdown()
    Note right of App: 应用停止接受新连接

    Endpoints-&gt;&gt;Endpoints: 将 Pod 从 Service 端点移除 (有延迟)

    Client-&gt;&gt;KubeProxy: 发起新请求
    Note over KubeProxy: 此时，Kube-Proxy 的本地规则还未更新
    KubeProxy-&gt;&gt;App: 转发请求到即将关闭的 Pod

    App--&gt;&gt;KubeProxy: Connection Refused!
    KubeProxy--&gt;&gt;Client: 返回连接错误
</code></pre>
<p><strong>解决方案：<code>preStop</code> 生命周期钩子</strong>，这是 Kubernetes 提供的标准答案。</p>
<p><strong>正确的关闭流程 - <code>preStop</code> Hook</strong></p>
<pre><code class="language-mermaid">sequenceDiagram
    participant Kubelet as Kubelet
    participant App as Go 应用 (Pod)
    participant Endpoints as Endpoints Controller
    participant KubeProxy as Kube-Proxy

    Kubelet-&gt;&gt;Endpoints: Pod 状态变为 &quot;Terminating&quot;, Endpoints Controller 立即移除 Pod
    Note over Endpoints, KubeProxy: Endpoints 更新开始传播到所有 Kube-Proxy

    Kubelet-&gt;&gt;App: 执行 preStop Hook (e.g., &quot;sleep 10&quot;)
    Note over App: 应用仍在运行，但新流量已开始停止

    par 等待期间
        KubeProxy-&gt;&gt;KubeProxy: 更新本地网络规则，不再转发到此 Pod
    and
        App-&gt;&gt;App: &quot;sleep 10&quot; 正在执行
    end

    Kubelet-&gt;&gt;App: preStop 结束后，发送 SIGTERM 信号
    App-&gt;&gt;App: 调用 server.Shutdown()
    Note right of App: 此时已无新流量进入，从容处理存量请求
</code></pre>
<p>配置如下：</p>
<pre><code class="language-yaml"># ... in your container spec
lifecycle:
  preStop:
    exec:
      # 在发送 SIGTERM 信号之前，先执行这个 sleep 命令
      command: [&quot;/bin/sh&quot;, &quot;-c&quot;, &quot;sleep 10&quot;]
</code></pre>
<p>这个小小的 <code>preStop</code> hook，将应用代码与基础设施的传播延迟解耦，是实现真正优雅关闭的点睛之笔。</p>
<h4>4.4 当服务拥有记忆：有状态应用 (<code>StatefulSet</code>)</h4>
<p>对于数据库、消息队列这类有状态服务，<code>Deployment</code> 的随机替换策略是灾难性的。为此，Kubernetes 提供了 <code>StatefulSet</code>，它提供了三大保证：</p>
<ol>
<li><strong>稳定的网络身份</strong>: Pod 名称固定 (<code>-0</code>, <code>-1</code>, ...)，并拥有独立的 DNS 记录。</li>
<li><strong>稳定的持久化存储</strong>: 每个 Pod 绑定一个专属的存储卷 (PV)。</li>
<li><strong>有序的部署和更新</strong>: 严格按照序号 <code>0 -&gt; N</code> 部署，按照 <code>N -&gt; 0</code> 更新和删除。</li>
</ol>
<p>对于有状态服务，平滑更新的内涵变成了<strong>状态的无损交接</strong>，这需要应用本身具备集群和主从切换能力。</p>
<h4>4.5 终极优雅：将复杂性交给服务网格 (Service Mesh)</h4>
<p>有没有一种方式，让应用代码回归纯粹，完全不关心这些运维细节呢？答案是 <strong>服务网格 (Service Mesh)</strong>。它通过 <strong>Sidecar 代理模式</strong>，将所有通用的网络通信逻辑从应用中剥离出来。</p>
<p>在服务网格的世界里，关闭流程变得对应用完全透明，由 Sidecar 代理自动完成所有优雅的流量排空，让你的 Go 应用可以极度简化。</p>
<h4>4.6 融会贯通：应对真实世界的多服务进程</h4>
<p>一个进程可能同时提供多种服务（例如，一个 HTTP 服务 + 一个 TCP 服务）。此时，生命周期的管理也需要&quot;整体思维&quot;。</p>
<ul>
<li><strong>启动时</strong>: 需要一个<strong>聚合健康端点</strong>。在 Go 应用中创建一个唯一的 <code>/readyz</code> 接口，它的逻辑是当且仅当<strong>内部所有服务都就绪</strong>时，才返回 <code>HTTP 200</code>。</li>
<li><strong>关闭时</strong>: 需要一个<strong>编排式的关闭流程</strong>。收到 <code>SIGTERM</code> 后，立刻翻转内部的聚合就绪状态，让 <code>/readyz</code> 失败，然后依赖 <code>preStop</code> hook 等待，最后按顺序优雅地关闭所有内部服务。</li>
</ul>
<h3>结语：拥抱范式转移，在云原生世界中优雅前行</h3>
<p>从 <code>tableflip</code> 到 Kubernetes，我们看到的不是一个技术的&quot;优劣&quot;之争，而是一场深刻的<strong>范式转移</strong>。</p>
<p><code>tableflip</code> 是单机时代，工程师们凭借对底层系统深刻的理解，创造出的精巧艺术品。它代表了一种<strong>面向进程、命令式</strong>的优雅。</p>
<p>而 Kubernetes 的滚动更新，则是在分布式时代，通过<strong>面向 API、声明式</strong>的宏大编排，实现的系统级的优雅。它将复杂性上移到平台，从而将应用开发者解放出来，让他们能更专注于业务逻辑本身。</p>
]]></content:encoded>
    </item>
    <item>
      <title>模型训练核心技巧：学习率预热、余弦衰减与梯度裁剪</title>
      <link>https://hedon.top/blog/guide-to-lr-warmup-cosine-annealing-gradient-clipping/</link>
      <guid isPermaLink="true">https://hedon.top/blog/guide-to-lr-warmup-cosine-annealing-gradient-clipping/</guid>
      <pubDate>Thu, 21 Aug 2025 15:30:20 GMT</pubDate>
      <description>本篇深入探讨了深度学习训练中的三大核心优化技巧，学习率预热解决训练初期不稳定性，余弦衰减实现精细调整和平滑收敛，梯度裁剪防止梯度爆炸。从原理到实践，全面解析如何让模型在高维损失空间中更稳定、更高效地找到最优解。</description>
      <category>机器学习</category><category>深度学习</category><category>大模型</category><category>训练优化</category><category>学习率预热</category><category>余弦衰退</category><category>梯度裁剪</category>
      <content:encoded><![CDATA[<p>本篇我们来深入探讨一下学习率预热（Learning Rate Warmup）、余弦衰减（Cosine Annealing）和梯度裁剪（Gradient Clipping）这三种在深度学习训练中非常实用的优化技巧。</p>
<p>首先，这三个技巧的核心目标是一致的：<strong>让模型在复杂的高维损失函数空间中，更稳定、更高效地找到一个好的解（局部最优解或全局最优解）</strong>。</p>
<p>它们分别从不同角度解决了训练过程中可能遇到的问题：</p>
<ul>
<li><strong>学习率预热 (Warmup)</strong>：解决训练初期的不稳定性。</li>
<li><strong>余弦衰减 (Cosine Annealing)</strong>：解决训练中后期的精细调整和收敛问题。</li>
<li><strong>梯度裁剪 (Gradient Clipping)</strong>：解决训练过程中可能出现的梯度爆炸问题，充当“安全带”。</li>
</ul>
<p>接下来我们逐一解析。</p>
<h2>学习率预热 (Learning Rate Warmup)</h2>
<h3>结论先行</h3>
<p>在训练开始的几个周期（epoch）或迭代（step）内，将学习率（Learning Rate）从一个非常小的值（例如 0）线性或非线性地增加到预设的初始学习率。预热阶段结束后，再采用预设的学习率衰减策略（如余弦衰减）。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250821155358300.png" style="zoom: 33%;" />

<h3>本质是什么</h3>
<p>在训练之初，模型的权重是随机初始化的，可以说它对数据一无所知。如果此时直接用一个较大的学习率（Learning Rate），就好比让一个新手司机上来就踩满油门，结果很可能是车辆失控（模型参数被带到很差的空间），导致训练初期的剧烈震荡，甚至无法收敛。</p>
<p>学习率预热就是为了解决这个问题。它在训练开始的几个周期（epoch）或迭代（step）内，将学习率从一个非常小的值（甚至是 0）逐步提升到你预设的初始学习率。</p>
<p>它的本质是 <strong>在模型尚未稳定时，通过控制更新步长来增加训练的稳定性</strong>。</p>
<p>这是一种 &quot;先慢后快&quot; 的策略。它承认了模型在训练初期处于一个非常不稳定的状态，因此需要一个缓冲期。通过这个缓冲期，模型可以安全地度过最不稳定的阶段，为后续高效的训练打下坚实的基础。</p>
<h3>好处有哪些</h3>
<ol>
<li><strong>防止模型在训练初期&quot;震荡&quot;或&quot;发散&quot;</strong>：在训练刚开始时，模型的权重是随机初始化的，它们距离最优解非常遥远。此时如果直接使用一个较大的学习率，梯度更新的步子会迈得很大。这就像在一张崎岖不平的地图上蒙眼寻宝，一开始就猛冲一步，很可能会直接冲进一个很差的区域（损失函数的“悬崖”），导致损失剧增，模型难以收敛。</li>
<li><strong>给模型时间适应数据</strong>：在训练初期，模型对数据还没有任何认知。一个较小的学习率可以让模型&quot;温柔&quot;地开始学习，逐渐适应数据的分布，稳定地学习到一些浅层的、鲁棒的特征。等模型对数据有了一定的&quot;感觉&quot;后，再增大学习率进行快速优化，效果会更好。</li>
</ol>
<h3>如何评估预热步数</h3>
<p>设定预热步数的核心原则是：<strong>确保在学习率达到其最大值时，模型的训练已经进入了一个相对稳定的状态</strong>。</p>
<ul>
<li><strong>太短的预热</strong>：学习率很快就上升到最大值，此时模型可能还没来得及&quot;适应&quot;数据，依然处于非常不稳定的状态。这可能会导致训练初期的损失出现剧烈震荡甚至不收敛，预热的效果大打折扣。</li>
<li><strong>太长的预热</strong>：模型在很长一段时间内都使用非常小的学习率进行训练，收敛速度过慢，浪费了大量的计算资源和时间。</li>
</ul>
<p>我们的目标就是在这两者之间找到一个平衡点。</p>
<h4>前人经验</h4>
<p>在实践中，预热步数通常有两种设定方式：</p>
<p><strong>1. 按训练总步数的比例设定</strong>：这是最常用、也最推荐的一种方法。它将预热阶段的长度与整个训练过程的长度动态地关联起来。</p>
<ul>
<li><strong>经验法则</strong>：通常将 <strong>总训练步数的 6% - 10%</strong> 作为预热步数。</li>
<li><strong>为什么有效</strong>：这个比例确保了无论你的总训练时间是长是短，预热都只占其中一小部分，既能起到稳定作用，又不会拖慢整体进度。例如，如果你计划总共训练 <code>100,000</code> 步，那么设置 <code>6,000</code> 到 <code>10,000</code>步的预热是一个非常合理的起点。</li>
<li><strong>适用场景</strong>：非常适合训练大型模型（如 BERT, GPT）或在大型数据集上从头开始训练。</li>
</ul>
<p><strong>2. 按固定的周期数（Epochs）设定</strong>：对于某些数据集和训练流程，按 Epoch 设定更为直观。</p>
<ul>
<li><strong>经验法则</strong>：通常设置为 <strong>1 到 2 个 Epoch</strong>。</li>
<li><strong>为什么有效</strong>：一个 Epoch 意味着模型已经完整地看过一遍所有训练数据。经过一轮完整的“阅览”，模型通常已经初步适应了数据分布，此时再提升到最大学习率是比较安全的。</li>
<li><strong>适用场景</strong>：当数据集不是特别巨大，或者在进行微调（Fine-tuning）任务时，这种方法简单有效。</li>
</ul>
<h4>实践是检验真理的唯一标准</h4>
<p>当然，上述 2 个方案都是经验值，最好的方法还是通过实验来验证 —— 评估预热步数是否合适的最佳指标就是 <strong>训练初期的损失曲线 (Loss Curve)</strong>。</p>
<ol>
<li><strong>选择一个基准值</strong>：根据上面的经验法则，选择一个起始值。例如，如果你在微调一个 BERT 模型，可以先尝试 <code>1 epoch</code> 的预热。</li>
<li><strong>观察损失曲线</strong>：开始训练，并密切关注训练日志中前几个 Epoch 的损失变化。<ul>
<li><strong>理想的曲线</strong>：在预热阶段，损失平稳下降。预热结束后，学习率达到最大值，损失开始加速下降，整个过程平滑过渡，没有出现剧烈的尖峰或抖动。</li>
<li><strong>预热可能过短的迹象</strong>：预热结束后，损失突然出现一个明显的 <strong>尖峰 (Spike)</strong>，或者开始剧烈震荡，然后才慢慢恢复下降。这说明学习率增长过快，模型没能平稳过渡。</li>
<li><strong>预热可能过长的迹象</strong>：损失曲线在开始的相当长一段时间内下降得极为缓慢，几乎是一条平线。这说明模型在用一个过小的学习率“浪费时间”。</li>
</ul>
</li>
<li><strong>调整并对比</strong>：<ul>
<li>如果发现损失有尖峰，<strong>增加</strong> 预热步数（例如从 1 epoch 增加到 2 epochs）。</li>
<li>如果发现初始收敛太慢，可以尝试 <strong>减少</strong> 预热步数。</li>
</ul>
</li>
</ol>
<p>通过几次短时间的实验（不需要跑完整个训练，观察前几个 epoch 即可），你就能很快地为你的特定任务找到一个合适的预热步数范围。</p>
<h4>推荐方案</h4>
<table>
<thead>
<tr>
<th>场景</th>
<th>推荐的起始策略</th>
<th>评估方法</th>
</tr>
</thead>
<tbody><tr>
<td><strong>大型模型从头训练</strong> (e.g., GPT, BERT on large corpus)</td>
<td>将总训练步数的 <strong>10%</strong> 作为预热步数。</td>
<td>观察损失曲线是否平滑，没有尖峰。</td>
</tr>
<tr>
<td><strong>中小型模型的微调</strong> (e.g., Fine-tuning ResNet on a custom dataset)</td>
<td><strong>1 到 2 个 Epoch</strong> 对应的步数。</td>
<td>观察损失曲线，确保预热结束后能快速收敛。</td>
</tr>
<tr>
<td><strong>不确定如何选择时</strong></td>
<td><strong>从 1 个 Epoch 开始</strong>，这通常是一个安全且不会太慢的选择。</td>
<td>通过短时实验，观察损失曲线并进行微调。</td>
</tr>
</tbody></table>
<h2>余弦衰减 (Cosine Annealing)</h2>
<h3>结论先行</h3>
<p>一种学习率的衰减策略。它不像传统的步进式衰减（Step Decay，例如每 30 个 epoch 学习率乘以 0.1）那样是跳崖式下降，而是让学习率随着训练的进行，像余弦函数 <code>cos(x)</code> 在 <code>[0, π/2]</code> 区间一样，平滑地从初始值下降到接近 0。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250821155626400.png" style="zoom:33%;" />

<h3>本质是什么</h3>
<p>当模型训练进入中后期，我们通常需要降低学习率，帮助模型在最优点附近进行更精细的搜索。传统的步进式衰减虽然有效，但其&quot;跳崖式&quot;的下降方式有时过于粗暴。</p>
<p>余弦衰减提供了一种更优雅的方案。它让学习率随着训练的进行，像余弦函数一样平滑地从初始值下降到接近 0。</p>
<p>它的本质是：<strong>一种 &quot;先探索，后精调&quot; 的动态调整策略</strong>。</p>
<ul>
<li><strong>前期/中期</strong>：学习率下降缓慢，保持相对较高的值，让模型有能力跳出局部陷阱，探索更广阔的空间。</li>
<li><strong>后期</strong>：学习率下降加速，让模型能以更小的步长在最优解附近精细微调。</li>
</ul>
<blockquote>
<p>这就像飞机降落。飞行员不会在到达目的地后直接关闭引擎（步进衰减），而是会沿着平滑的下滑曲线（余弦曲线）逐渐降低速度和高度，最终实现平稳着陆。</p>
</blockquote>
<h3>好处有哪些</h3>
<ol>
<li><strong>避免在接近最优点时来回震荡</strong>：在训练后期，模型已经非常接近最优解。此时如果学习率依然较大，可能会导致模型在最优解附近来回跳动，始终无法精确收敛。余弦衰减通过缓慢、平滑地降低学习率，使得模型能够以更小的步长，更精细地在最优点附近进行搜索，从而更容易找到那个谷底。</li>
<li><strong>在较长时间内维持相对较大的学习率</strong>：与步进式衰减相比，余弦衰减在前期和中期下降得更慢。这意味着模型有更长的时间在损失空间中进行探索，这有助于它跳出一些不好的局部最优解（saddle points or poor local minima），去寻找一个更好的解。</li>
</ol>
<h2>梯度裁剪 (Gradient Clipping)</h2>
<h3>结论先行</h3>
<p>在进行梯度下降更新权重之前，设定一个梯度的阈值。如果当前计算出的梯度向量的 L2 范数（可以理解为梯度的&quot;长度&quot;或&quot;大小&quot;）超过了这个阈值，就按比例缩小这个梯度向量，使其范数恰好等于该阈值。</p>
<p>$$
\text{if } ||g|| &gt; \text{threshold}: \ \quad g \leftarrow \frac{\text{threshold}}{||g||} \cdot g</p>
<p>$$</p>
<p>其中 $g$ 是梯度向量，$||g||$ 是它的 L2 范数，也称为欧几里得范数 (Euclidean Norm)，公式如下：</p>
<p>$$
||\vec{x}||_2 = \sqrt{x_1^2 + x_2^2 + \dots + x_n^2}
$$</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/gradient-clipping-example.jpg" style="zoom:33%;" />

<h3>本质是什么</h3>
<p>在深度网络（尤其是 RNN, Transformer）中，梯度在反向传播过程中可能会因为连乘效应而变得异常巨大，这就是 <strong>梯度爆炸 (Exploding Gradients)</strong>。一次梯度爆炸带来的权重更新可能是毁灭性的，它会瞬间摧毁模型学到的所有知识，导致损失变为 <code>NaN</code>。</p>
<p>梯度裁剪 (Gradient Clipping) 就是防止这种灾难的&quot;安全带&quot;。它为梯度的大小设定一个上限，如果某次计算出的梯度超过了这个上限，就将其按比例缩小，但 <strong>保持其方向不变</strong>。</p>
<p>它的本质是：<strong>为训练过程增加一个安全约束，牺牲极端情况下的理论最优更新，换取整个训练过程的稳定性和鲁棒性。</strong></p>
<h3>有什么好处</h3>
<p>这个问题的核心在于 <strong>长距离依赖 (Long-term Dependencies)</strong> 和 <strong>深度（层数）</strong>。</p>
<p>在像 RNN 或 Transformer 这样的模型中，信息需要在很长的时间步或很深的层级之间传递。在反向传播计算梯度时，根据链式法则，梯度会涉及到一系列雅可比矩阵（Jacobian Matrix）的连乘。</p>
<p>$$
\frac{\partial L}{\partial h_t} = \frac{\partial L}{\partial h_{t+k}} \cdot \frac{\partial h_{t+k}}{\partial h_{t+k-1}} \cdots \frac{\partial h_{t+1}}{\partial h_t}
$$</p>
<ul>
<li>如果这些矩阵的范数持续大于 1，那么连乘的结果就会呈指数级增长，导致梯度爆炸。</li>
<li>如果持续小于 1，则会导致梯度消失。</li>
</ul>
<p>梯度裁剪正是为了处理前一种情况。RNN 因为在时间维度上共享权重，这种连乘效应尤其显著。Transformer 虽然没有时间上的循环，但其非常深的网络结构（例如，一个接一个的 self-attention 和 FFN block）同样会形成很长的计算路径，使得梯度在反向传播时也容易出现爆炸或消失的问题。</p>
<p>梯度裁剪通过设定一个上限，确保单次更新的步长不会过大，从而防止了这种灾难性事件的发生。</p>
<h3>裁剪方式</h3>
<p>前面我们的描述中默认的裁剪方式是：<strong>范数裁剪 (Clipping by Norm)</strong>，这也是最常用、最推荐的方式。但其实还有另一种方式，叫做<strong>值裁剪（Clipping by Value）</strong>。理解它们的区别非常重要。</p>
<p><strong>范数裁剪 (Clipping by Norm)</strong></p>
<pre><code class="language-python">torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
</code></pre>
<p>计算所有参数梯度的 L2 范数（可以理解为整个梯度向量的“长度”），如果这个范数超过了设定的阈值 <code>max_norm</code>，就将整个梯度向量按比例缩小，使其范数恰好等于 <code>max_norm</code>。</p>
<p>这种裁剪<strong>保持梯度的方向不变</strong>，只缩放其大小。这非常重要，因为梯度的方向指明了损失函数下降最快的方向，我们希望保留这个正确的信息，只是不想让步子迈得太大。</p>
<p><strong>值裁剪 (Clipping by Value)</strong></p>
<pre><code class="language-python">torch.nn.utils.clip_grad_value_(model.parameters(), clip_value=1.0)
</code></pre>
<p>为梯度的每一个元素设定一个区间的 <code>[min_value, max_value]</code>。然后遍历梯度向量中的每一个元素，如果某个元素的值小于 <code>min_value</code>，就把它设为 <code>min_value</code>；如果大于 <code>max_value</code>，就把它设为 <code>max_value</code>。</p>
<p>这种方法会 <strong>改变梯度的方向</strong>。想象一个二维梯度向量 <code>g = [10, 0.1]</code>，如果设置裁剪区间为 <code>[-1, 1]</code>，裁剪后它会变成 <code>g&#39; = [1, 0.1]</code>。原来的方向几乎是沿着 x 轴，但裁剪后的方向明显向 y 轴偏移了。这种方向上的改变可能会误导模型的更新。</p>
<hr>
<p>由于范数裁剪保留了梯度的正确方向，在绝大多数情况下，<strong>范数裁剪是比值裁剪更好的选择</strong>。我们通常所说的梯度裁剪也默认是指范数裁剪。</p>
<h3>如何选择裁剪阈值</h3>
<p>在上述范数裁剪（后续梯度裁剪均默认为范数裁剪）的示例代码中：</p>
<pre><code class="language-python">torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
</code></pre>
<p><code>max_norm</code> 是一个超参数，即裁剪阈值，它的设定没有一个放之四海而皆准的黄金数值，但有一个非常有效的经验法则来确定它：</p>
<ol>
<li><strong>初始阶段不裁剪</strong>：在你的模型和数据集上，先跑几个训练迭代（iterations），但暂时不使用梯度裁剪。</li>
<li><strong>监控梯度范数</strong>：在每个训练步（<code>loss.backward()</code> 之后，<code>optimizer.step()</code> 之前），计算并记录下模型参数的梯度总范数。</li>
<li><strong>分析范数分布</strong>：收集上百个迭代的梯度范数值，观察它们的分布。你会发现，大部分时候梯度范数会处在一个比较稳定的范围内，但偶尔会出现一些非常大的&quot;尖峰&quot;，这些就是梯度爆炸的时刻。</li>
<li><strong>设定阈值</strong>：选择一个比大多数&quot;稳定&quot;梯度范数略大，但又能明显限制住那些&quot;尖峰&quot;的值。通常可以选择梯度范数分布的某个高百分位点，比如 90% 或 95% 分位点，作为一个不错的起始值。</li>
</ol>
<p><strong>例如</strong>：你观察到 95% 的梯度范数都在 0.5 到 5.0 之间，但偶尔会飙升到 50 或 100。那么，将 <code>max_norm</code> 设置为 5.0 或者 10.0 就是一个合理的选择。这样既不会影响正常的训练，又能有效防止极端情况下的训练崩溃。常见的 <code>max_norm</code> 值通常在 1.0 到 10.0 之间。</p>
<p>在 PyTorch 中，梯度裁剪的位置非常关键。它必须在 <code>loss.backward()</code> 之后（此时梯度已经被计算出来）和 <code>optimizer.step()</code> 之前（在用梯度更新权重之前）调用。</p>
<pre><code class="language-python"># 一个标准的训练循环
optimizer.zero_grad()        # 1. 清空旧梯度

loss = model(inputs, labels) # 2. 前向传播计算损失
loss.backward()              # 3. 反向传播计算梯度

# --- 梯度裁剪发生在这里 ---
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # 4. 裁剪梯度

optimizer.step()             # 5. 使用裁剪后的梯度更新权重
</code></pre>
<h2>代码案例</h2>
<p>接下来我们以一个完整的大语言模型（Large Language Model）训练过程，来将这 3 个优化思路串起来，本篇案例参考了 <a href="https://github.com/rasbt/LLMs-from-scratch">LLMs-from-scratch</a>，感兴趣的读者可参阅此书。</p>
<pre><code class="language-python">def train_model(model, train_loader, val_loader, optimizer, device,
                num_epochs, eval_freq, eval_iter, start_context, tokenizer,
                warmup_steps, initial_lr=3e-05, min_lr=1e-6):
    train_losses, val_losses, track_tokens_seen, track_lrs = [], [], [], []
    tokens_seen, global_step = 0, -1

    peak_lr = optimizer.param_groups[0][&quot;lr&quot;]
    total_training_steps = len(train_loader) * num_epochs # 计算训练过程中的所有迭代步数
    lr_increment = (peak_lr - initial_lr) / warmup_steps  # 计算在预热阶段学习率的增量

    for epoch in range(num_epochs):
        model.train()
        for input_batch, target_batch in train_loader:
            global_step +=1

            if global_step &lt; warmup_steps:
                lr = initial_lr + global_step * lr_increment    # &lt;---- 学习率预热阶段
            else:
                progress = ((global_step - warmup_steps) /
                                    (total_training_steps - warmup_steps))
                lr = min_lr + (peak_lr - min_lr) * 0.5 * (      # &lt;---- 余弦衰减阶段
                    1 + math.cos(math.pi * progress))
            for param_group in optimizer.param_groups:  # 在优化器上应用计算后的学习率
                param_group[&quot;lr&quot;] = lr
            track_lrs.append(lr)

            optimizer.zero_grad()  # 清空旧梯度
            loss = calc_loss_batch(input_batch, target_batch, model, device) # 前向传播计算交叉熵损失
            loss.backward() # 反向传播计算梯度
            if global_step &gt;= warmup_steps: # &lt;--- 在预热阶段后使用梯度裁剪来避免梯度爆炸
                torch.nn.utils.clip_grad_norm_(
                    model.parameters(), max_norm=1.0
                )
            optimizer.step() # 使用裁剪后的梯度更新权重

            tokens_seen += input_batch.numel()

            # 输出调试信息，用于观测训练进展
            if global_step % eval_freq == 0:
                train_loss, val_loss = evaluate_model(
                    model, train_loader, val_loader, device, eval_iter
                )
                train_losses.append(train_loss)
                val_losses.append(val_loss)
                track_tokens_seen.append(tokens_seen)
                print(f&quot;Ep {epoch+1} (Step {global_step:06d}):&quot;
                    f&quot;Train loss {train_loss:.3f}, &quot;
                    f&quot;Val loss {val_loss:.3f}&quot;)

        # 运用当前模型进行文本生成，观察模型能力
        generate_and_print_sample(model, tokenizer, device, start_context)
    return train_losses, val_losses, track_tokens_seen, track_lrs
</code></pre>
<p>完整代码可参考：<a href="https://github.com/hedon-ai-road/llm-from-scratch/blob/main/5-%E8%AE%AD%E7%BB%83%E5%A4%A7%E6%A8%A1%E5%9E%8B.ipynb">llm-from-scratch</a></p>
<h2>总结</h2>
<p>深度学习模型的训练过程如同一场充满挑战的远航，不稳定的开局、难以收敛的困境和突如其来的训练崩溃是常见的&quot;风浪&quot;。本文深入探讨了三种为这场远航保驾护航的核心技巧：</p>
<ul>
<li><strong>学习率预热 (Learning Rate Warmup)</strong>：它确保了我们能有一个&quot;温柔的启动&quot;，通过在训练初期使用极小的学习率并逐步提升，有效避免了因模型尚未适应数据而导致的剧烈震荡。</li>
<li><strong>余弦衰减 (Cosine Annealing)</strong>：它为我们规划了&quot;平滑的航程&quot;，以一种先慢后快的方式优雅地降低学习率，兼顾了前中期的广泛探索和后期的精细收敛，帮助模型更精准地抵达最优解。</li>
<li><strong>梯度裁剪 (Gradient Clipping)</strong>：它是全程必备的&quot;安全带&quot;，通过为梯度设置上限，有效防止了因梯度爆炸引发的&quot;核爆&quot;事故，保证了训练过程的稳定和鲁棒。</li>
</ul>
<p>文章最后的代码示例生动地展示了，这三个技巧并非孤立存在，而是三位一体的协同策略。在一个典型的训练流程中，我们以<strong>预热</strong>开启，用<strong>余弦衰减</strong>贯穿全程，并由<strong>梯度裁剪</strong>时刻守护。</p>
<p>掌握并善用三个优化技巧，将不再是玄学调参，而是有章可循的工程科学，能让你的模型训练过程更加稳定、高效，最终得到更优的性能。</p>
]]></content:encoded>
    </item>
    <item>
      <title>告别死记硬背：一份真正理解 PyTorch 核心设计的指南</title>
      <link>https://hedon.top/blog/pytorch/</link>
      <guid isPermaLink="true">https://hedon.top/blog/pytorch/</guid>
      <pubDate>Mon, 18 Aug 2025 15:31:00 GMT</pubDate>
      <description>本文从 PyTorch 的核心设计出发，通过一个简单的例子，帮助读者理解 PyTorch 的核心设计，包括张量、自动求导、神经网络等。</description>
      <category>PyTorch</category><category>深度学习</category><category>机器学习</category><category>大模型</category>
      <content:encoded><![CDATA[<p>如果你正在学习 PyTorch，你很可能和我最初一样，有这样的困惑：PyTorch 的 API 太多了，像一片望不到边的海洋。今天记住 <code>view</code>，明天忘了 <code>permute</code>；刚学会 <code>Dataset</code>，又对 <code>DataLoader</code> 的 <code>num_workers</code> 感到神秘。靠死记硬背来学习，不仅效率低下，而且无法真正建立起解决复杂问题的能力。</p>
<p>这篇博文的目的，就是为了打破这种困境。我们将不再孤立地看待 API，而是从深度学习项目的<strong>第一性原理</strong>出发，去理解：</p>
<ul>
<li><strong>为什么会有这些 API？</strong> 它们各自解决了什么核心问题？</li>
<li><strong>它们之间是什么关系？</strong> 如何协同工作，共同完成一个任务？</li>
</ul>
<p>我们将从两个层面来构建你的 PyTorch 知识体系：</p>
<ol>
<li><strong>宏观篇：思维骨架</strong> - 搭建一个完整的深度学习项目工作流，理解 PyTorch 的顶层设计。</li>
<li><strong>微观篇：数据血液</strong> - 深入模型内部，掌控作为“血液”的 Tensor（张量）如何在其间流动和变换。</li>
</ol>
<h2>宏观篇：搭建你的 PyTorch 思维骨架</h2>
<h3>1. PyTorch 的核心设计哲学：灵活与直观</h3>
<p>要理解 PyTorch，首先要理解它的两个核心特点：</p>
<ol>
<li>动态计算图（Dynamic Computational Graph）</li>
<li>Python 优先（Python-First）</li>
</ol>
<p><strong>动态计算图</strong>：这是 PyTorch 与早期 TensorFlow (TensorFlow 1.x) 最大的区别。传统的静态图是&quot;先定义，后执行&quot;，你必须先构建一个完整的计算图，然后才能送入数据。而 PyTorch 的动态图是&quot;即时执行&quot;(Define-by-Run)，计算图的构建和计算是同时发生的。</p>
<ul>
<li><strong>解决了什么问题？</strong> 极大地增强了灵活性。对于处理动态输入（如长度可变的文本）的 NLP 任务，或者需要复杂控制流（如循环、条件判断）的模型，动态图非常直观和方便。调试也变得异常简单，你可以像调试普通 Python 代码一样，随时停下来查看中间变量的值。</li>
<li><strong>对应的 API 体现：</strong> 你写的每一行 PyTorch 计算代码（例如 <code>c = a + b</code>），都在动态地构建一个微小的计算图。你不需要任何特殊的 session 或 placeholder。</li>
</ul>
<p><strong>Python 优先</strong>：PyTorch 深度整合在 Python 生态中，其设计充满了 Pythonic 的风格。它感觉不像是一个独立的程序，更像是一个 Python 的超强数学和 GPU 计算库。</p>
<ul>
<li><strong>解决了什么问题？</strong> 降低了学习门槛，提高了开发效率。研究人员和开发者可以用最熟悉的方式快速迭代想法。</li>
<li><strong>对应的 API 体现：</strong> 你会发现 PyTorch 的类（如 <code>nn.Module</code>）、数据结构（如 <code>Tensor</code> 的操作）和整体编程范式都与 NumPy 等常见 Python 库非常相似。</li>
</ul>
<h3>2. 典型的深度学习流程与 PyTorch API 的映射</h3>
<p>我们可以将一个完整的深度学习项目分为几个核心阶段。PyTorch 的 API 设计就是为了服务于这个流程中的每一步。</p>
<ol>
<li>数据准备（The Fuel）</li>
<li>模型构建（The Engine）</li>
<li>训练循环（The Driving Process）</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250818155654972.png" alt="典型的深度学习流程与 PyTorch API 的映射"></p>
<h4>阶段 1：数据准备 (The Fuel)</h4>
<p><strong>面临的问题：</strong></p>
<ol>
<li>原始数据格式各异，如何统一读取？</li>
<li>数据集可能非常大，无法一次性载入内存，怎么办？</li>
<li>训练时需要对数据进行批量 (batching)、打乱 (shuffling) 和预处理 (preprocessing)，如何高效实现？</li>
<li>如何利用多核 CPU 来加速数据加载，避免 GPU 等待？</li>
</ol>
<p><strong>PyTorch 的解决方案 (核心 API):</strong> <code>torch.utils.data.Dataset</code> 和 <code>torch.utils.data.DataLoader</code></p>
<p><strong>API 关系与解析：</strong></p>
<ul>
<li><code>Dataset</code>：<strong>它定义了&quot;数据集&quot;是什么</strong>。这是一个抽象类，你只需要继承它并实现两个方法：<code>__len__</code>(返回数据集大小) 和 <code>__getitem__</code> (根据索引 <code>idx</code> 返回一条数据)。它解决了“如何获取单条数据”的问题，将数据访问的逻辑封装起来。</li>
<li><code>DataLoader</code>：<strong>它定义了&quot;如何使用数据集&quot;</strong>。它接收一个 <code>Dataset</code> 对象，并在此基础上，优雅地解决了所有工程问题：<ul>
<li><code>batch_size</code>：自动将单条数据打包成一个 batch。</li>
<li><code>shuffle=True</code>：在每个 epoch 开始时自动打乱数据顺序。</li>
<li><code>num_workers</code>：启动多个子进程并行加载数据，极大地提高了数据供给效率。</li>
<li><code>collate_fn</code>：自定义如何将多条样本合并成一个 batch，对于处理非标准数据（如不同长度的句子）非常有用。</li>
</ul>
</li>
</ul>
<p><strong>一句话总结：<u><code>Dataset</code> 负责“取”，<code>DataLoader</code> 负责“送”。它们共同解决了数据供给的效率和标准化问题</u>。</strong></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250819154449744.png" alt=""></p>
<h4>阶段 2：模型构建 (The Engine)</h4>
<p><strong>面临的问题：</strong></p>
<ol>
<li>如何定义一个神经网络结构？</li>
<li>网络中包含大量需要学习的参数（权重 <code>weights</code> 和偏置 <code>biases</code>），如何有效地管理它们？</li>
<li>如何实现前向传播 (forward pass) 的计算逻辑？</li>
<li>如何方便地在 CPU 和 GPU 之间切换模型？</li>
</ol>
<p><strong>PyTorch 的解决方案 (核心 API):</strong> <code>torch.nn.Module</code></p>
<p><strong>API 关系与解析：</strong></p>
<ul>
<li><p><code>torch.Tensor</code>：<strong>这是 PyTorch 的基石</strong>。它不仅仅是一个像 NumPy <code>ndarray</code> 一样的多维数组，它还承载了另外两个至关重要的信息：</p>
<ul>
<li><code>grad_fn</code>：指向创建这个张量的函数，用于构建反向传播的计算图。</li>
<li><code>grad</code>：存储该张量的梯度。 你可以通过 <code>tensor.to(&#39;cuda&#39;)</code> 轻松地将其移动到 GPU。</li>
</ul>
</li>
<li><p><code>torch.nn.Module</code>：<strong>所有神经网络层的基类</strong>。你可以把它想象成一个容器或一个零件。</p>
<ul>
<li>在 <code>__init__</code> 方法中，我们定义模型的&quot;零件&quot;，例如 <code>self.conv1 = nn.Conv2d(...)</code>，<code>self.fc1 = nn.Linear(...)</code>。当你定义这些层时，PyTorch 会自动将它们的参数注册到这个 <code>Module</code> 中。</li>
<li>在 <code>forward</code> 方法中，我们定义这些&quot;零件&quot;如何连接起来，完成从输入到输出的计算。</li>
</ul>
</li>
<li><p><strong>为什么需要 <code>nn.Module</code> 而不是直接用函数？</strong></p>
<p>因为 <code>nn.Module</code> 帮你自动处理了参数管理。你只需要调用 <code>model.parameters()</code> 就可以获取模型中所有需要训练的参数，而不需要手动去追踪每一个权重和偏置。它还提供了 <code>model.train()</code> 和 <code>model.eval()</code> 模式切换等便利功能，用于控制 <code>Dropout</code> 和 <code>BatchNorm</code> 等层的行为。</p>
</li>
</ul>
<p><strong>一句话总结：<u>我们用 <code>Tensor</code> 作为数据流，用 <code>nn.Module</code> 将神经网络的“骨架”和“参数”组织起来，并在 <code>forward</code> 方法中定义数据如何在这个骨架中流动。</u></strong></p>
<h4>阶段 3：训练循环 (The Driving Process)</h4>
<p>这是整个流程的核心，涉及到损失计算、反向传播和参数更新。</p>
<p><strong>面临的问题：</strong></p>
<ol>
<li>模型输出和真实标签之间的差距（损失）如何计算？</li>
<li>如何根据损失计算出模型中每个参数的梯度 (gradient)？</li>
<li>如何根据梯度来更新参数，以使损失变小？</li>
</ol>
<p><strong>PyTorch 的解决方案 (核心 API):</strong> <code>torch.autograd</code>, <code>loss functions</code>, <code>torch.optim</code></p>
<p><strong>API 关系与解析：</strong></p>
<ol>
<li><strong>损失函数 (Loss Function)</strong> - 例如 <code>nn.CrossEntropyLoss</code>, <code>nn.MSELoss</code><ul>
<li><strong>作用：</strong> 衡量模型预测值 <code>output</code> 和真实值 <code>target</code> 之间的差距，计算出一个标量值 <code>loss</code>。这个 <code>loss</code> 就是我们优化的目标，我们希望它越小越好。</li>
</ul>
</li>
<li><strong>自动求导系统 (Autograd)</strong> - <code>loss.backward()</code><ul>
<li><strong>作用：</strong> 这是 PyTorch 的魔法核心。当你对一个 <code>requires_grad=True</code> 的 <code>Tensor</code>（我们的 <code>loss</code> 就是）调用 <code>.backward()</code> 方法时，PyTorch 会自动沿着计算图反向传播，计算出图中所有 <code>requires_grad=True</code> 的叶子节点（也就是我们模型的参数 <code>model.parameters()</code>）相对于 <code>loss</code>的梯度，并把结果累加到这些参数的 <code>.grad</code> 属性上。</li>
<li><strong>它解决了什么？</strong> 解决了深度学习中最复杂、最容易出错的数学问题——梯度计算。你不需要手动去推导和实现链式法则。</li>
</ul>
</li>
<li><strong>优化器 (Optimizer)</strong> - <code>torch.optim</code> (例如 <code>optim.SGD</code>, <code>optim.Adam</code>)<ul>
<li><strong>作用：</strong> 它根据计算出的梯度来更新模型的参数。</li>
<li><strong>工作流程（三步曲）：</strong> a. <code>optimizer.zero_grad()</code>：清空上一轮迭代中累积的梯度。因为 PyTorch 的梯度是累加的 (<code>+=</code>)，所以每轮更新前必须手动清零。 b. <code>loss.backward()</code>：计算当前 batch 的梯度。 c. <code>optimizer.step()</code>：根据梯度更新参数。优化器会根据自身的算法（如 SGD, Adam）来执行 <code>w = w - learning_rate * w.grad</code> 这样的更新操作。</li>
</ul>
</li>
</ol>
<p><strong>一句话总结：<u><code>损失函数</code> 告诉我们&quot;错的有多离谱&quot;，<code>loss.backward()</code> 告诉我们&quot;每个参数应该朝哪个方向改&quot;，<code>optimizer.step()</code> 负责&quot;实际去改这些参数&quot;。这三者构成了训练的核心闭环</u>。</strong></p>
<h3>3. 代码示例</h3>
<pre><code class="language-python">import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader

# 1. 数据准备 (Data Preparation)
# 假设我们有 100 个样本，每个样本 10 个特征，标签是 0 或 1
X_train = torch.randn(100, 10)
y_train = torch.randint(0, 2, (100,)).float()

# 使用 Dataset 和 DataLoader 封装数据
# TensorDataset 是一个方便的包装器
dataset = TensorDataset(X_train, y_train)
# DataLoader 负责批量、打乱等
dataloader = DataLoader(dataset, batch_size=16, shuffle=True)

# 2. 模型构建 (Model Building)
# 继承 nn.Module 来定义我们自己的模型
class SimpleModel(nn.Module):
    def __init__(self):
        super().__init__()
        # 在 __init__ 中定义模型的层（零件）
        self.layer1 = nn.Linear(10, 5) # 输入 10 特征，输出 5 特征
        self.activation = nn.ReLU()
        self.layer2 = nn.Linear(5, 1)  # 输入 5 特征，输出 1 特征
        self.sigmoid = nn.Sigmoid()

    def forward(self, x):
        # 在 forward 中定义数据如何流动
        x = self.layer1(x)
        x = self.activation(x)
        x = self.layer2(x)
        x = self.sigmoid(x)
        return x

model = SimpleModel()

# 3. 定义损失函数和优化器 (Loss &amp; Optimizer)
criterion = nn.BCELoss() # 二分类交叉熵损失
optimizer = torch.optim.SGD(model.parameters(), lr=0.01) # 随机梯度下降优化器

# 4. 训练循环 (Training Loop)
num_epochs = 5
for epoch in range(num_epochs):
    for inputs, labels in dataloader: # DataLoader 自动提供 batch
        # a. 前向传播
        outputs = model(inputs)
        loss = criterion(outputs.squeeze(), labels)

        # b. 反向传播与优化（三步曲）
        optimizer.zero_grad()  # 1. 梯度清零
        loss.backward()        # 2. 计算梯度
        optimizer.step()       # 3. 更新参数

    print(f&#39;Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}&#39;)
</code></pre>
<blockquote>
<p>或者可以参考笔者在学习 <a href="https://github.com/rasbt/LLMs-from-scratch">Build a Large Language Model (From Scratch)</a> 一书时实践的训练 GPT-2 大模型的<a href="https://github.com/hedon-ai-road/llm-from-scratch/blob/main/5-%E8%AE%AD%E7%BB%83%E5%A4%A7%E6%A8%A1%E5%9E%8B.ipynb">代码</a>，会更复杂具体些。</p>
</blockquote>
<p>现在再回过头看 PyTorch 的众多 API，你会发现它们都可以归入上述的框架中：</p>
<ul>
<li><strong>数据层 (<code>torch.utils.data</code>)</strong>：一切为了高效、标准地提供数据。</li>
<li><strong>模型层 (<code>torch.nn</code>)</strong>：一切为了灵活、方便地搭建和管理模型。<code>nn.Conv2d</code>, <code>nn.LSTM</code>, <code>nn.Transformer</code> 都是预先实现好的 <code>nn.Module</code> &quot;零件&quot;。<code>nn.functional</code> 里是对应的无状态函数版本（例如 <code>F.relu</code>），通常在 <code>forward</code> 中使用。</li>
<li><strong>自动求导层 (<code>torch.autograd</code>)</strong>：训练的幕后英雄，默默地处理最复杂的数学。</li>
<li><strong>优化层 (<code>torch.optim</code>)</strong>：应用梯度的不同策略，决定了模型参数如何被更新。</li>
<li><strong>基础 (<code>torch</code>)</strong>：核心数据结构 <code>Tensor</code> 以及大量的数学运算。</li>
</ul>
<h2>微观篇：掌控 Tensor 的&quot;七十二变&quot;</h2>
<p>如果说理解工作流是掌握了&quot;骨架&quot;，那么理解 Tensor 的形状变化就是掌握了&quot;血液&quot;在骨架中的流动方式。几乎 80% 的 PyTorch 新手 bug 都和 Tensor shape（张量形状）不匹配有关。</p>
<p>延续之前的思路，我们依然不孤立地看 API，而是将它们放入 <strong>&quot;为什么需要变 -&gt; 在哪里变 -&gt; 如何变&quot;</strong> 的逻辑框架中，由浅入深地进行拆解。</p>
<h3>1. 核心心智模型：Shape is Semantics（形状即语义）</h3>
<p>在深入 API 之前，请先建立一个最重要的心智模型：<u><strong>Tensor 的每一个维度 (dimension) 都有其特定的语义含义</strong></u>。</p>
<p>一个典型的 4D Tensor <code>(B, C, H, W)</code> 在计算机视觉中，其形状 <code>(16, 3, 224, 224)</code> 并不是一串孤立的数字，它的意思是：</p>
<ul>
<li><strong>B (Batch size) = 16</strong>: 这个 Tensor 里有 16 张独立的图像。</li>
<li><strong>C (Channels) = 3</strong>: 每张图像有 3 个通道（R, G, B）。</li>
<li><strong>H (Height) = 224</strong>: 每张图像的高度是 224 像素。</li>
<li><strong>W (Width) = 224</strong>: 每张图像的宽度是 224 像素。</li>
</ul>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250818160244493.png" alt="Tensor 的每一个维度都有其特定的语义含义" style="zoom:50%;" />

<p><strong>所有形状变换的根本原因，都是为了匹配下游操作（比如一个网络层）所期望的&quot;语义&quot;。</strong> 当你遇到形状错误时，不要只想着&quot;我要把这个 <code>(16, 512)</code> 变成 <code>(16, 1, 512)</code>&quot;，而应该去想：&quot;我当前的数据语义是 <code>(批量, 特征)</code>，但下一层需要的是 <code>(批量, 通道, 长度)</code>，所以我需要增加一个&#39;通道&#39;维&quot;。</p>
<p>带着这个心智模型，我们来看 Tensor 的形状变换在整个流程中的角色。</p>
<h3>2. Tensor 形状变换的场景与动机</h3>
<h4>阶段 1：数据准备阶段 (标准化)</h4>
<p><strong>面临的问题：</strong> 原始数据（例如一张磁盘上的 JPEG 图片）并不是 Tensor。即使转换成了 Tensor，其维度也可能不符合模型训练的需要。</p>
<p><strong>核心动机：</strong> <strong>标准化</strong>。将千差万别的单个数据点，统一成可以被模型批量处理的标准格式。</p>
<p><strong>关键变换：增加 Batch 维度</strong></p>
<ul>
<li><p><strong>为什么？</strong> 深度学习训练是基于&quot;小批量梯度下降&quot;(Mini-batch Gradient Descent) 的。我们不会一次只喂给模型一张图片，而是喂一批。这有两个好处：</p>
<ol>
<li>硬件（特别是 GPU）并行处理一个 batch 的数据效率极高；</li>
<li>一个 batch 的平均梯度比单个样本的梯度更能代表整体数据，使训练更稳定。</li>
</ol>
</li>
<li><p><strong>如何实现？</strong></p>
<ul>
<li><p><strong>自动处理：</strong> <code>DataLoader</code> 在你从 <code>Dataset</code> 取数据时，会自动帮你把多个单一样本堆叠 (stack) 在一起，在最前面增加一个 Batch 维度。如果你从 <code>Dataset</code> 取出的单张图片 Tensor 是 <code>(C, H, W)</code>，<code>DataLoader</code> 会输出一个 <code>(B, C, H, W)</code> 的 Tensor。</p>
</li>
<li><p><strong>手动处理：</strong> 如果你只有一个样本，但模型需要一个 batch 输入，你可以使用 <code>torch.unsqueeze(0)</code> 在第 0 维增加一个维度。</p>
<pre><code class="language-python"># 一张图片，形状为 (3, 224, 224)
single_image = torch.randn(3, 224, 224)
# 模型需要 batch 输入，手动增加 batch 维
# 形状变为 (1, 3, 224, 224)
batched_image = single_image.unsqueeze(0)
</code></pre>
</li>
</ul>
</li>
</ul>
<h4>阶段 2：模型内部 (<code>forward</code> 传播) (从一种形态到另一种形态)</h4>
<p>这是形状变换最频繁、最核心的区域。</p>
<ul>
<li><strong>面临的问题：</strong> 数据在流经不同类型的神经网络层时，需要符合每一层对输入形状的特定要求。</li>
<li><strong>核心动机：</strong> <strong>匹配接口</strong>。就像不同规格的管道需要转接头一样，不同网络层之间需要形状变换来“转接”。</li>
</ul>
<p>下面是几种最常见的变换场景：</p>
<p><strong>场景 A: &quot;压平&quot; - 从卷积到全连接</strong></p>
<ul>
<li><p><strong>为什么？</strong> 卷积层 (<code>nn.Conv2d</code>) 非常擅长处理具有空间结构的数据（如图像），它的输出通常是 4D 的 <code>(B, C_out, H_out, W_out)</code>，保留了空间信息。但是，全连接层 (<code>nn.Linear</code>) 通常用于最后阶段的分类或回归，它期望的输入是 2D 的 <code>(B, num_features)</code>，即把每个样本的所有特征&quot;拉平&quot;成一个长向量。</p>
</li>
<li><p><strong>如何实现？</strong> <code>view</code>, <code>reshape</code>, <code>flatten</code></p>
<pre><code class="language-python"># 假设经过卷积和池化后，输出形状为 (16, 64, 7, 7)
conv_output = torch.randn(16, 64, 7, 7)
# 我们需要将其送入一个 nn.Linear(64 * 7 * 7, 100) 的层
# batch_size 维度需要保留

# 方法1: 使用 view (效率高，但不保证内存连续)
# -1 会自动计算该维度的大小
linear_input = conv_output.view(16, -1) # 形状变为 (16, 3136)

# 方法2: 使用 reshape (更安全，会自动处理内存问题)
linear_input = conv_output.reshape(16, -1) # 形状变为 (16, 3136)

# 方法3: 使用 flatten (更语义化，推荐)
# start_dim=1 表示从第1个维度（Channels 维）开始压平
linear_input = torch.flatten(conv_output, start_dim=1) # 形状变为 (16, 3136)
</code></pre>
</li>
</ul>
<p><strong>场景 B: &quot;换位&quot; - 调整维度顺序</strong></p>
<ul>
<li><p><strong>为什么？</strong> 不同的库或特定的层对维度的语义顺序有不同的要求。</p>
<ul>
<li><strong>经典案例 1 (图像)：</strong> Matplotlib 或 OpenCV 处理图像时，通道维通常在最后 <code>(H, W, C)</code>。而 PyTorch 的卷积层要求通道维在前 <code>(C, H, W)</code>。</li>
<li><strong>经典案例 2 (NLP)：</strong> PyTorch 的 <code>nn.Transformer</code> 默认期望的输入是 <code>(序列长度, 批量大小, 特征维度)</code>，而很多时候我们处理数据时更习惯 <code>(批量大小, 序列长度, 特征维度)</code>。</li>
</ul>
</li>
<li><p><strong>如何实现？</strong> <code>permute</code></p>
<pre><code class="language-python"># 案例1: H, W, C -&gt; C, H, W
image_hwc = torch.randn(224, 224, 3)
# permute 接收新的维度顺序
image_chw = image_hwc.permute(2, 0, 1) # 形状变为 (3, 224, 224)

# 案例2: Batch-first -&gt; Seq-first for Transformer
nlp_batch_first = torch.randn(16, 100, 512) # (B, Seq, Feat)
# 交换第 0 维和第 1 维
nlp_seq_first = nlp_batch_first.permute(1, 0, 2) # 形状变为 (100, 16, 512)
</code></pre>
<p><code>transpose(dim1, dim2)</code> 是 <code>permute</code> 的一个特例，它只能交换两个维度。</p>
</li>
</ul>
<p><strong>场景 C: &quot;增删&quot; - 增加或移除&quot;占位&quot;维度</strong></p>
<ul>
<li><p><strong>为什么？</strong> 有时为了进行广播 (broadcasting) 计算，或者匹配一个需要特定维度数量的函数，我们需要临时增加或移除大小为 1 的维度。</p>
</li>
<li><p><strong>如何实现？</strong> <code>unsqueeze</code> (增加) 和 <code>squeeze</code> (移除)</p>
<pre><code class="language-python"># 场景：给一个 2D 的 batch (B, F) 增加一个虚拟的“通道”维度
x = torch.randn(16, 100) # (Batch, Features)
# 目标：变成 (16, 1, 100) 以便使用 1D 卷积 nn.Conv1d
x_unsqueezed = x.unsqueeze(1) # 在第 1 维增加一个维度

# 场景：模型输出 (B, 1)，但 loss 函数需要 (B)
model_output = torch.randn(16, 1)
# 移除所有大小为 1 的维度
squeezed_output = model_output.squeeze() # 形状变为 (16)
# 只移除第 1 维 (如果它的大小是 1)
squeezed_output_dim1 = model_output.squeeze(1) # 形状变为 (16)
</code></pre>
</li>
</ul>
<h4>阶段 3：损失计算阶段 (对齐&quot;预测&quot;与&quot;真值&quot;)</h4>
<p><strong>面临的问题：</strong> 模型的输出 Tensor 和标签 (label) Tensor 的形状可能不完全一致。</p>
<p><strong>核心动机：</strong> <strong>对齐</strong>。使预测和真值的形状符合损失函数的要求。</p>
<p><strong>常见变换：</strong> <code>squeeze</code> 或 <code>argmax</code></p>
<ul>
<li><code>nn.BCELoss</code> (二分类交叉熵) 通常要求模型输出和标签都是 <code>(B)</code> 或 <code>(B, 1)</code>。如果你的模型输出了 <code>(B, 1)</code> 而标签是 <code>(B)</code>，你可能需要 <code>model_output.squeeze(1)</code> 来对齐。</li>
<li><code>nn.CrossEntropyLoss</code> (多分类交叉熵) 很智能，它允许模型输出是 <code>(B, num_classes)</code> 的 logits，而标签是 <code>(B)</code> 的类别索引。它内部会自动处理对齐。在计算准确率时，你则需要用 <code>torch.argmax(model_output, dim=1)</code> 来得到 <code>(B)</code> 的预测类别，再和标签进行比较。</li>
</ul>
<h3>3. 我应该用哪个 API？</h3>
<p>当你需要改变 Tensor 形状时，可以按以下流程思考：</p>
<p><strong>我的目的是什么？</strong></p>
<ul>
<li>是为了**&quot;压平&quot;**多维特征给全连接层？ -&gt; <code>flatten</code> 或 <code>reshape/view</code>。</li>
<li>是为了**&quot;交换&quot;**维度的语义顺序（如 B,S,F -&gt; S,B,F）？ -&gt; <code>permute</code> 或 <code>transpose</code>。</li>
<li>是为了**&quot;增加&quot;**一个不存在的维度（如 batch 维，channel 维）？ -&gt; <code>unsqueeze</code>。</li>
<li>是为了**&quot;移除&quot;**一个大小为 1 的多余维度？ -&gt; <code>squeeze</code>。</li>
</ul>
<blockquote>
<p><strong>一个黄金法则：<code>print(tensor.shape)</code></strong> 在 <code>forward</code> 函数的每一行关键操作后，都加上 <code>print(x.shape)</code>。这是调试 PyTorch 模型形状问题的最简单、最有效的方法。它可以让你清晰地看到数据是如何一步步变换的。</p>
</blockquote>
<h3>4. 代码示例</h3>
<p>让我们追踪一个 Tensor 在一个简单 CNN 中的完整旅程：</p>
<pre><code class="language-python">import torch
import torch.nn as nn

class ShapeJourneyCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, stride=1, padding=1)
        self.relu = nn.ReLU()
        self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
        self.fc1 = nn.Linear(16 * 14 * 14, 10) # 28x28 -&gt; 14x14 after pooling

    def forward(self, x):
        # 初始输入 x: (B, 1, 28, 28) - 假设来自 MNIST
        print(f&quot;Initial shape: \t\t{x.shape}&quot;)
        
        # 经过第一个卷积层
        x = self.conv1(x)
        # 形状变为 (B, 16, 28, 28) - 通道数从 1 变为 16
        print(f&quot;After Conv1: \t\t{x.shape}&quot;)
        
        x = self.relu(x)
        
        # 经过最大池化层
        x = self.pool(x)
        # 形状变为 (B, 16, 14, 14) - H 和 W 都减半
        print(f&quot;After MaxPool: \t\t{x.shape}&quot;)
        
        # **关键变换：压平**
        # 为了送入 fc1，需要从 4D 变为 2D
        # 我们保留 batch 维度，将其余维度压平
        x = torch.flatten(x, start_dim=1)
        # 形状变为 (B, 16*14*14) -&gt; (B, 3136)
        print(f&quot;After Flatten: \t\t{x.shape}&quot;)
        
        # 经过全连接层
        x = self.fc1(x)
        # 形状变为 (B, 10) - 10 是最终的类别数
        print(f&quot;Final output shape: \t{x.shape}&quot;)
        
        return x

# 创建一个 dummy input batch
dummy_batch = torch.randn(64, 1, 28, 28) # B=64
model = ShapeJourneyCNN()
model(dummy_batch)
</code></pre>
<p>输出：</p>
<pre><code class="language-txt">Initial shape: 		torch.Size([64, 1, 28, 28])
After Conv1: 		torch.Size([64, 16, 28, 28])
After MaxPool: 		torch.Size([64, 16, 14, 14])
After Flatten: 		torch.Size([64, 3136])
Final output shape: 	torch.Size([64, 10])
</code></pre>
<h2>总结</h2>
<p>让我们回顾一下构建起的这张心智地图：</p>
<ol>
<li><strong>以工作流为纲</strong>：始终将 PyTorch 的 API 放入&quot;数据准备 -&gt; 模型构建 -&gt; 训练循环&quot;的框架中去理解其存在的意义。这构成了你的<strong>宏观骨架</strong>。</li>
<li><strong>以语义为轴</strong>：将 Tensor 的形状变化理解为匹配不同模块语义接口的&quot;翻译&quot;过程。这让你能自如地掌控<strong>微观血液</strong>的流动。</li>
</ol>
<p>希望这篇指南能帮助你摆脱死记硬背的泥潭，从第一性原理出发，真正建立起对 PyTorch 深刻而系统的理解，在&quot;炼丹&quot;之路上走得更远、更稳。</p>
]]></content:encoded>
    </item>
    <item>
      <title>从 ECB 到 GCM：理解加密模式的演进</title>
      <link>https://hedon.top/blog/encryption-mode/</link>
      <guid isPermaLink="true">https://hedon.top/blog/encryption-mode/</guid>
      <pubDate>Fri, 15 Aug 2025 17:31:00 GMT</pubDate>
      <description>加密模式 ECB、CBC、GCM</description>
      <category>ECB</category><category>CBC</category><category>GCM</category><category>加密模式</category>
      <content:encoded><![CDATA[<p>在网络世界中，我们的数据需要被小心保护。对称加密算法，如 AES，就是我们最常用的&quot;保险箱&quot;。但这个保险箱怎么用，却大有讲究。这就引出了我们今天讨论的主题：<strong>加密模式（Encryption Mode）</strong>。</p>
<p>本文将由浅入深地带你理解三种经典的分组加密模式：<strong>ECB、CBC</strong> 和 <strong>GCM</strong>，并解释它们各自的优缺点和演进过程。</p>
<h3>1. 简单的致命弱点：ECB（Electronic Codebook）模式</h3>
<p><strong>ECB 模式</strong>是最简单的一种分组加密模式。它的工作原理非常直接：把明文数据切分成一个个固定大小的块，然后用同一个密钥，独立地加密每一个块。</p>
<p><strong>优点</strong>：</p>
<ul>
<li><strong>简单</strong>：原理清晰，易于实现。</li>
<li><strong>可并行</strong>：每个块的加密互不影响，可以并行处理，提高性能。</li>
<li><strong>可恢复</strong>：某个块损坏，只影响该块，不影响其他块的解密。</li>
</ul>
<p><strong>缺点</strong>：</p>
<ul>
<li><strong>不安全</strong>：这是 ECB 模式的致命弱点。因为相同的明文块会产生相同的密文块，这使得攻击者可以通过分析密文中的重复模式来推断出原始数据的结构和内容。著名的“ECB 企鹅”图片就是最好的例证。</li>
</ul>
<p>正是因为这个巨大的安全漏洞，ECB 模式在大多数情况下都不被推荐使用。</p>
<hr>
<h3>2. 链式反应：CBC（Cipher Block Chaining）模式</h3>
<p>为了解决 ECB 模式的重复性问题，工程师们设计了 <strong>CBC 模式</strong>。它的核心思想是**“链接”**。</p>
<p>在 CBC 模式中，每个明文块在加密前，都会先和<strong>前一个密文块</strong>进行异或运算。而第一个明文块则会和一个随机的**初始化向量（IV）**进行异或运算。</p>
<p><strong>优点</strong>：</p>
<ul>
<li><strong>更安全</strong>：由于引入了链式依赖和 IV，即使有相同的明文块，它们加密后也会产生不同的密文，有效隐藏了数据模式，解决了 ECB 的安全问题。</li>
</ul>
<p><strong>缺点</strong>：</p>
<ul>
<li><strong>无法并行</strong>：由于加密过程是链式的，每个块的加密都依赖于前一个块的结果，因此无法并行处理。</li>
<li><strong>错误传播</strong>：如果某个密文块在传输过程中损坏，它不仅会导致自身解密失败，还会影响后续所有块的解密，产生“多米诺骨牌效应”。</li>
</ul>
<p>CBC 模式大大提高了安全性，在很长一段时间里都是行业标准。但是，它无法并行加密的缺点在面对海量数据时，成为了性能瓶颈。</p>
<hr>
<h3>3. 高性能与高安全：GCM（Galois/Counter Mode）模式</h3>
<p>为了兼顾安全性和性能，<strong>GCM 模式</strong>应运而生。它是一种**认证加密（Authenticated Encryption）**模式，完美结合了加密和数据完整性校验。</p>
<p>GCM 模式的核心思想是 <strong>CTR（Counter Mode）</strong>。它不依赖于前面的密文块，而是通过一个<strong>不断递增的计数器</strong>，生成一个加密用的随机流，再将这个流和明文数据进行异或运算得到密文。</p>
<p><strong>优点</strong>：</p>
<ul>
<li><strong>可并行</strong>：每个加密块都是独立的，可以并行处理，极大地提高了加解密性能。</li>
<li><strong>认证加密</strong>：GCM 模式除了加密，还内置了<strong>认证功能</strong>。它能生成一个<strong>认证标签（Authentication Tag）</strong>，可以验证数据的完整性，确保数据在传输过程中没有被篡改。</li>
</ul>
<p><strong>缺点</strong>：</p>
<ul>
<li><strong>复杂度高</strong>：相对于 ECB 和 CBC，GCM 的实现更复杂。</li>
</ul>
<hr>
<h3>总结与展望</h3>
<p>从 ECB 的简单但危险，到 CBC 的安全但串行，再到 GCM 的安全、高性能和认证，我们可以清晰地看到加密模式的演进。</p>
<table>
<thead>
<tr>
<th align="left">特性</th>
<th align="left">ECB</th>
<th align="left">CBC</th>
<th align="left">GCM</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>工作模式</strong></td>
<td align="left">独立</td>
<td align="left">链接</td>
<td align="left">计数器</td>
</tr>
<tr>
<td align="left"><strong>安全性</strong></td>
<td align="left">极低</td>
<td align="left">较高</td>
<td align="left">极高</td>
</tr>
<tr>
<td align="left"><strong>并行处理</strong></td>
<td align="left">支持</td>
<td align="left">不支持</td>
<td align="left">支持</td>
</tr>
<tr>
<td align="left"><strong>数据完整性</strong></td>
<td align="left">不支持</td>
<td align="left">不支持</td>
<td align="left">支持</td>
</tr>
</tbody></table>
<p>在今天的网络世界中，<strong>GCM 模式</strong>因其卓越的性能和安全性，已经成为最推荐使用的加密模式，广泛应用于 TLS/SSL 等主流安全协议中。</p>
]]></content:encoded>
    </item>
    <item>
      <title>一次由公网流出带宽飙升引发的服务器性能排查实录</title>
      <link>https://hedon.top/blog/record-of-abnormal-investigation-of-public-network-traffic/</link>
      <guid isPermaLink="true">https://hedon.top/blog/record-of-abnormal-investigation-of-public-network-traffic/</guid>
      <pubDate>Fri, 15 Aug 2025 15:30:20 GMT</pubDate>
      <description>本文详细记录了一次由公网流出带宽飙升引发的服务器性能故障排查。我们从监控图表入手，利用 iftop 实时追踪流量去向，并最终通过 nethogs 锁定应用。该案例揭示了新功能配置对网络资源的巨大影响，为解决类似问题提供了宝贵经验。</description>
      <category>服务器</category><category>网络</category><category>故障排查</category><category>iftop</category>
      <content:encoded><![CDATA[<p>最近，我们的服务器监控系统发出了紧急警报：服务器的各项关键性能指标在 <strong>2025 年 8 月 15 日 11:30 左右</strong>出现了同步飙升。面对这一异常，我们并没有急于猜测，而是通过一个核心线索——公网流出流量，一步步揭开了问题的真相。本文将详细记录我们的排查过程，并深入解析每一步的工具应用与背后原理。</p>
<h4>第一步：从宏观监控入手，锁定异常的核心</h4>
<p>故障排查的第一步，是细致分析监控图表，从中提取关键信息，从而圈定问题发生的精确时间。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250815152916476.png" alt="异常现象"></p>
<p>如上图所示，我们发现，在 <strong>2025 年 8 月 15 日 11:30 左右</strong>，服务器的各项指标出现了显著异常：</p>
<ul>
<li><strong>公网流出带宽</strong>：在 11:37:00 这个时间点，公网流出带宽达到了惊人的 <strong>110.899 M bit/s</strong> 的峰值，远超正常水平。与此同时，公网流入带宽也有轻微增加，但量级远小于流出带宽。</li>
<li><strong>CPU 使用率</strong>：在带宽飙升的同时，CPU 使用率也从 25% 左右的正常水平，迅速升高到接近 <strong>100%</strong> 的峰值。</li>
<li><strong>磁盘 I/O</strong>：磁盘的读操作吞吐量和次数也出现了同步的峰值。</li>
</ul>
<p>此外，网络连接数的监控图也揭示了重要线索：</p>
<ul>
<li>在 11:30 左右，服务器的网络连接总数从约 2.5K 激增至 <strong>5.5K 左右</strong>。</li>
<li>其中，<code>NON_ESTABLISHED</code>（非活跃）连接数急剧增加，最高达到了约 <strong>2.475K</strong>，与 <code>ESTABLISHED</code>（已建立）连接数几乎持平。</li>
</ul>
<p><strong>排查原理</strong>：多项关键指标在同一时间点同步异常，这强烈暗示着某个进程或任务正在大量消耗系统资源。公网带宽的异常是本次故障的核心线索，它将我们的排查方向聚焦于网络流量。同时，网络连接数中非活跃连接的激增，表明问题可能与高频率的连接建立与关闭有关，而非简单的持续高流量。这些宏观的监控数据，为我们后续深入排查提供了明确的起点和方向。</p>
<h4>第二步：<code>iftop</code> 定位流量去向，一剑封喉</h4>
<p>既然问题是公网流出流量异常，那么这些流量究竟流向哪里？这是我们排查的下一个关键问题。我们运行了 <code>iftop</code> 工具，它能够实时监控网络流量的流向，结果令人震惊：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250815153946932.png" alt="iftop 命令显示结果"></p>
<ul>
<li><code>iftop</code> 实时监控显示，服务器的公网流出流量（<code>=&gt;</code>）绝大部分都流向了 IP 地址 <code>xxx</code>。</li>
<li>流出速率高达每秒 <strong>165M bits/s</strong>，与监控图上的带宽峰值完全吻合。</li>
<li><code>iftop</code> 底部的 <code>TX</code>（发送）流量峰值达到了 <strong>181M bits</strong>，进一步证实了带宽飙升的根源。</li>
</ul>
<p><strong>排查原理</strong>：<code>iftop</code> 的强大之处在于它的<strong>实时性和直观性</strong>。它将服务器抽象的带宽数据，具象化为&quot;本地 IP A 到远端 IP B 的流量&quot;。通过观察 <code>iftop</code> 的输出，我们立刻将目光从&quot;哪台服务器出了问题&quot;转移到&quot;这台服务器在向哪里发送数据&quot;&quot;，从而大大缩短了排查路径。</p>
<h4>第三步：<code>nethogs</code> 锁定应用进程，确认元凶</h4>
<p>我们已经知道是服务器在向 <code>xxx</code> 发送大量数据，但具体是哪个应用在做这件事？我们使用 <code>nethogs</code>工具，它能够按进程实时监控流量，最终锁定了“元凶”：</p>
<ul>
<li><code>nethogs</code> 的输出明确显示，<strong><code>snakeweb_</code> 应用</strong>是产生这些高流量的进程。</li>
<li>其发送（<code>SENT</code>）和接收（<code>RECEIVED</code>）流量都远超其他进程，证实了它是本次故障的直接“元凶”。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250815154151114.png" alt="nethogs"></p>
<p><strong>排查原理</strong>：<code>nethogs</code> 将流量与具体的进程 ID（PID）和程序路径关联起来，为我们提供了最终的、无可辩驳的证据。至此，我们已经完整地锁定了问题：<code>snakeweb_</code> 应用向 IP <code>xxx</code> 发送大量数据。</p>
<h4><strong>第四步：发现 <code>TIME_WAIT</code> 堆积，理解行为模式</strong></h4>
<p>在确认了应用和流量去向后，我们回过头来审视最初的一些异常现象。网络连接数的监控图显示，<code>NON_ESTABLISHED</code>（非活跃）连接数在 11:30 左右急剧增加，最高达到了约 <strong>2.475K</strong>，与 <code>ESTABLISHED</code>（已建立）连接数几乎持平。</p>
<p><strong>排查原理</strong>：大量的 <code>TIME_WAIT</code> 连接是 TCP 连接在<strong>主动关闭后</strong>保持的一段等待时间。这一现象揭示了问题的另一面：<code>snakeweb_</code> 应用在发送数据时，采用了<strong>高频率的短连接方式</strong>。每一次连接的建立和关闭，都在系统中留下了大量的 <code>TIME_WAIT</code> 状态连接，虽然不直接消耗带宽，但却占用了文件描述符等系统资源，成为了一个需要优化的次要问题。</p>
<h4><strong>第五步：身份确认，解决问题</strong></h4>
<p>通过 <code>whois</code> 查询，我们确认了流量流出的 IP 属于阿里云，也是我们的一个服务之一。至此，整个问题链条已经完整。最后经过排查，内部的另外一个服务，新加了一个实时同步数据的功能，导致了流量的飙升。</p>
<h4>总结与反思</h4>
<p>这次排查完美地展示了工具在故障排查中的巨大作用。我们从公网流量飙升这个<strong>核心问题</strong>入手，利用 <code>iftop</code> 快速将抽象的性能异常转化为清晰的网络通信流；再通过 <code>nethogs</code>，我们锁定了具体进程；最后通过对 <code>TIME_WAIT</code> 等次要症状的分析，我们还原了应用的具体行为模式。整个过程环环相扣，最终成功定位并解决了问题。这提醒我们，在开发过程中，应时刻关注新功能对网络带宽、连接模式等底层资源的影响，避免因<strong>业务逻辑的改动</strong>而引发潜在的性能危机。</p>
]]></content:encoded>
    </item>
    <item>
      <title>大白话解释交叉熵损失</title>
      <link>https://hedon.top/blog/cross-entropy-loss/</link>
      <guid isPermaLink="true">https://hedon.top/blog/cross-entropy-loss/</guid>
      <pubDate>Wed, 13 Aug 2025 19:30:20 GMT</pubDate>
      <description>本篇从 LLM 训练过程概述开始，通过&quot;教学徒写文章&quot;的生动比喻，帮助读者理解交叉熵损失在机器学习中的核心作用，以及如何用它来评估和优化模型的预测能力。</description>
      <category>机器学习</category><category>深度学习</category><category>大模型</category>
      <content:encoded><![CDATA[<h2>LLM 训练过程概述</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250813193726179.png" alt="LLM 训练过程概述"></p>
<p>在介绍交叉熵损失之前，我们先参考 <a href="https://github.com/rasbt/LLMs-from-scratch/blob/main/ch05/01_main-chapter-code/ch05.ipynb">Build a Large Language Model</a> 一书梳理一下训练 LLM 的核心过程。笔者并非这个方向的专业人士，只能尝试从自己理解的角度来尽可能用大白话阐述这个过程在做什么、为什么这么做、能达到什么效果。</p>
<p>为了便于理解，我们可以把整个过程想象成<strong>教一个学徒如何写文章</strong>。</p>
<h3>1. 文本生成（Text generation）</h3>
<blockquote>
<p>这就像让你的学徒开始写文章。一开始，它什么都不懂，只会胡乱地写一些词语。你给它一个开头，比如&quot;从前有座山...&quot;，它可能随便接上&quot;...山里有只大象在跳舞。&quot;完全不合逻辑。</p>
</blockquote>
<p>这是模型还没有训练好时，它根据一些输入，随机生成的一段文本。它生成的文本质量很差，毫无章法。</p>
<h3>2. 文本评估（Text evaluation）</h3>
<blockquote>
<p>你现在需要一个**&quot;老师&quot;<strong>来给这个学徒写的文章打分。你拿着学徒写的文章，和一篇</strong>标准答案（正确文章）<strong>进行对比。这个“老师”会告诉你，学徒写的文章和标准答案之间有多大的差距。这个打分的过程，就是我们本文将提到的</strong>交叉熵损失（Cross-Entropy Loss）**。</p>
</blockquote>
<p>这个步骤是计算模型生成的文本与真实文本之间的损失值。模型会计算出它对下一个词的预测概率，并用交叉熵损失来衡量这个预测概率与真实词的“独热编码”概率有多大差距。<font color="red"><strong>损失值越大，说明模型预测得越差</strong></font>。</p>
<h3>3. 训练集和验证集的损失（Training set and validation set losses）</h3>
<blockquote>
<p>你的学徒现在开始正式学习了。你给他一大堆文章（<strong>训练集</strong>）让他模仿学习，然后定期拿出一小部分它没看过的文章（<strong>验证集</strong>）给他做测试。</p>
<ul>
<li><strong>训练集损失：</strong> 衡量学徒在学习过程中，对那些它看过的文章模仿得有多像。</li>
<li><strong>验证集损失：</strong> 衡量学徒在面对新文章时，能不能把学到的东西举一反三，而不是只会死记硬背。</li>
</ul>
</blockquote>
<p>如果训练集损失一直下降，但验证集损失不降反升，那就说明学徒只会&quot;死记硬背&quot;了，这在机器学习里叫做<strong>过拟合（Overfitting）</strong>。</p>
<h3>4. 大语言模型训练函数（LLM training function）</h3>
<p>这就是学徒的**&quot;大脑&quot;<strong>，也是整个学习的核心。它根据&quot;老师&quot;给出的分数（损失值），调整自己的&quot;大脑结构&quot;（模型参数/权重）。如果某篇文章写得不好，它就会&quot;反思&quot;自己为什么写不好，然后调整下一次的写作方式，争取写得更好。这个调整的过程叫做<a href="https://hedon.top/2025/07/27/llm/back-propagation/"><strong>反向传播（Backpropagation）</strong></a>和</strong>梯度下降（Gradient Descent）**。</p>
<h3>5. 训练模型生成类似人类的文本（Train the model to generate human-like text）</h3>
<p>这就是整个训练的目的：通过不断地重复第 1-4 步，让学徒的写作能力越来越强，最终写出来的文章，就像人类写的一样自然、流畅。</p>
<h3>6. 文本生成策略（Text generation strategies）</h3>
<blockquote>
<p>学徒学得差不多了，但有时候会变得特别死板，只会把训练集里的东西原封不动地背出来。为了让它更有创意，更像人，你需要教它一些“写作技巧”。</p>
<p>例如： 有时候，你不要总是选那个最有可能出现的词，可以偶尔选一些稍微不那么确定，但也很合理的词。</p>
</blockquote>
<p>这就是像<strong>Top-k 采样</strong>、<strong>Top-p（核）采样</strong>、**温度（Temperature）**调节等技术。这些方法会让模型在生成文本时，增加一些随机性，避免总是生成重复、机械化的内容，减少过拟合的风险。</p>
<h3>7. 权重保存和加载（Weight saving &amp; loading）</h3>
<p>学徒经过了长期的学习，终于成才了！现在你需要把它的&quot;大脑&quot;状态（也就是模型参数）保存下来。这样，下次再用的时候，就不用从头开始教了，直接把这个保存好的&quot;大脑&quot;拿出来用就行。</p>
<h3>8. 来自 OpenAI 的预训练权重（Pretrained weights from OpenAI）</h3>
<p>这就像你不是从一个零基础的学徒开始教，而是直接找一个已经很有经验的&quot;天才学徒&quot;来培养。OpenAI 训练了海量的数据，已经把一个 GPT 模型训练得非常强大了。我们直接拿来用，再结合自己的任务，在它的基础上继续微调。这样不仅省时省力，还能得到一个更好的模型。</p>
<h3>总结</h3>
<p>GPT 的训练过程就是，让一个初出茅庐的学徒（模型）写文章，找一个老师（损失函数）给它打分，然后根据分数调整它的大脑（参数）。反复这个过程，直到它写出来的文章像人类一样。为了让它更有创意，我们还教它一些写作技巧。最后，我们会把它的&quot;大脑&quot;保存下来，或者直接用一个&quot;天才学徒&quot;的大脑，在上面继续学习。</p>
<h2>交叉熵损失</h2>
<p>接下来我们回到本文的主题：<font color="red"><strong>交叉熵损失（Cross-Entropy Loss）</strong></font>。</p>
<blockquote>
<p>交叉熵损失是一种衡量模型预测结果与真实结果之间差异的指标。在分类任务中，模型通常会输出一个预测概率分布，而真实标签也可以被看作一个“理想”的概率分布。交叉熵损失的作用就是比较这两个概率分布的相似程度。<strong>如果模型的预测概率分布和真实概率分布越接近，交叉熵损失就越小，反之则越大。</strong> 我们的目标就是通过训练，不断减小这个损失值，从而让模型学会做出更准确的预测。</p>
</blockquote>
<p>是不是一头雾水？哈哈，没关系，下面笔者将从概念、由来、原理和计算四个部分进行展开，尽可能以大白话的方式进行阐述，相信你阅读后回来再看一段定义的时候，会有不一样的理解~</p>
<h3>1. 概念：交叉熵损失，就是给&quot;猜词&quot;打分</h3>
<p>想象一下，你正在教一个学徒写一句话。你告诉他句子的开头是：&quot;今天天气真...&quot;，然后你让他猜下一个词应该是什么。</p>
<ul>
<li><p><strong>学徒的预测：</strong> 他可能会给出一些预测，比如：</p>
<ul>
<li>&quot;好&quot; （他觉得最可能）</li>
<li>&quot;差&quot; （也有一点可能）</li>
<li>&quot;棒&quot; （可能性更小）</li>
<li>&quot;猫&quot; （几乎不可能）</li>
</ul>
<p>这些预测，可以被看作一个<strong>概率分布</strong>。比如，他可能认为&quot;好&quot;的概率是 80%，&quot;差&quot;的概率是 15%，&quot;棒&quot;的概率是 4%，&quot;猫&quot;的概率是 1%。</p>
</li>
<li><p><strong>正确的答案：</strong> 实际上，正确的下一个词是**&quot;好&quot;**。</p>
</li>
<li><p><strong>交叉熵损失的作用：</strong> 交叉熵损失就像一个严厉的老师，它只关注学徒对<strong>正确答案</strong>的预测。它会说：&quot;你对&#39;好&#39;这个词的预测概率是多少？<strong>这个概率越大，你这次的表现就越好，你的&#39;惩罚&#39;（损失）就越小。反之，你的表现越差，你的&#39;惩罚&#39;就越大。</strong>&quot;</p>
</li>
</ul>
<p>简单来说，交叉熵损失的计算公式可以简化为：
$$
损失值 = -log(模型对正确答案的预测概率)
$$</p>
<ul>
<li>如果学徒对“好”的预测概率是 <strong>0.8</strong>，那么损失值大约是 $−log(0.8)≈0.223$。</li>
<li>如果学徒对“好”的预测概率是 <strong>0.01</strong>（很差），那么损失值大约是 $−log(0.01)≈4.605$。</li>
<li>如果学徒猜中率是 <strong>1.0</strong>（完美），那么损失值是 $ −log(1)=0$。</li>
</ul>
<p>由此可见，交叉熵损失完美地实现了我们的教学目标：<strong>预测对了，损失就小；预测错了，损失就大。</strong></p>
<h3>2. 由来：从信息论到机器学习的&quot;迁移&quot;</h3>
<p>要理解交叉熵损失的原理，我们需要追溯到它的老家：<strong>信息论</strong>。</p>
<h4>2.1 熵（Entropy）</h4>
<p>信息论中有一个概念叫&quot;熵&quot;，它衡量的是一个事件的<strong>不确定性</strong>。一个越不确定的事件，它的熵就越高，包含的信息量就越大。</p>
<ul>
<li>比如，我告诉您&quot;太阳从东边升起&quot;，这几乎是 100% 确定的事，您没有获得任何新信息，所以它的熵很低。</li>
<li>但如果我告诉您&quot;今天股市大涨&quot;，这本身是一个不确定的事件，您就获得了新信息，所以它的熵很高。</li>
</ul>
<h4>2.2 交叉熵（Cross-Entropy）</h4>
<p>现在我们有两个概率分布：一个是真实的、完美的概率分布（记为 $p$），另一个是我们模型的预测概率分布（记为 $q$）。</p>
<p>交叉熵衡量的就是，用我们模型的预测分布 $q$ 来表示真实的分布 $p$，需要多少额外的&quot;信息量&quot;或者说&quot;代价&quot;。</p>
<p><strong>理论公式：</strong> 交叉熵的理论公式是 $H(p,q)=−∑_ip_ilog(q_i)$。</p>
<ul>
<li>这里的 $p_i$ 是真实事件的概率。</li>
<li>$q_i$ 是我们模型预测的概率。</li>
</ul>
<p><strong>独热编码（One-hot）的简化</strong>：</p>
<p>在机器学习的分类任务中，我们的真实标签通常是独热编码的，比如正确答案是&quot;猫&#39;&#39;，那么真实分布 $p$ 就是 
$$
[0, 1, 0, ...]
$$
现在，让我们把独热编码的 $p$ 代入到上面的公式中：
$$
H(p,q)=−(0⋅log(q_1)+1⋅log(q_2)+0⋅log(q_3)+...)
$$
你会发现，求和公式里，只有<strong>正确类别（猫）<strong>对应的 $p_i$ 是 1，其他都是 $0$。所以，整个求和公式就只剩下了一项：
$$
H(p,q)=−log(q_{正确类别})
$$
这就是交叉熵损失的最终形式。它之所以这样计算，完全是因为在分类任务中，我们</strong>只关心模型对正确答案的预测概率</strong>，而信息论中的交叉熵公式在遇到独热编码时，正好简化成了这个形式。</p>
<h3>3. 原理：为什么 −log(p) 是一个好的损失函数？</h3>
<p>让我们从数学和直觉两个角度来理解，为什么 $−log(p)$ 是一个完美的损失函数。</p>
<h4>3.1 数学角度</h4>
<p><strong>梯度：</strong> 我们的目标是通过梯度下降法来最小化损失。对于 $−log(p)$，它的导数是 $−1/p$。</p>
<ul>
<li>当 $p$ 接近 1 时（预测得很准），$1/p$ 接近 1，损失的梯度就很小。这意味着模型参数调整的幅度不大，因为它已经做得不错了。</li>
<li>当 $p$ 接近 0 时（预测得很差），$1/p$ 趋近于无穷大，损失的梯度就变得非常大。这意味着模型参数调整的幅度会非常大，因为它犯了一个严重的错误，需要大力纠正。</li>
</ul>
<p>这种特性使得模型在犯错时能快速学习，而在预测准确时则能稳定下来，这非常符合我们对训练过程的期望。</p>
<h4>3.2 直觉角度</h4>
<p><strong>不确定性：</strong> 让我们回到信息论。$−log(p)$ 实际上就是正确事件的信息量。</p>
<ul>
<li>如果模型预测正确事件的概率 $p$ 很低，说明模型对正确答案非常不确定，那么这个正确答案的出现就包含了大量信息。交叉熵损失就用这个巨大的信息量来惩罚模型。</li>
<li>如果模型预测正确事件的概率 $p$ 很高，说明模型很确定答案，那么这个正确答案的出现就包含很少信息。交叉熵损失就用这个很小的信息量来奖励模型。</li>
</ul>
<p>这种**&quot;用信息量来惩罚&quot;**的机制，确保了模型会努力去减少它对正确答案的不确定性，从而让它的预测结果越来越接近真实情况。</p>
<h3>4. 计算</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250813200501445.png" alt="交叉熵损失计算过程"></p>
<p>参考 <a href="https://github.com/rasbt/LLMs-from-scratch/blob/main/ch05/01_main-chapter-code/ch05.ipynb">Build a Large Language Model</a> 一书，交叉熵损失的计算过程大概分成上面所示的 6 个步骤。</p>
<p><strong>步骤 1：Logits（对数几率）</strong></p>
<p>Logits 是模型在 Softmax 层之前的原始输出值，它可以是任意实数。这些值代表了模型对每个类别的&quot;置信度&quot;，但还没有归一化为概率。图片中的 <code>[[0.1113, -0.1057, -0.3666, ...]]</code> 就是一个样本的 Logits 输出。</p>
<p><strong>步骤 2：Probabilities（概率）</strong></p>
<p>通过 Softmax 函数将 Logits 转换为概率分布。这个函数的作用是将一组任意实数转换成一个概率分布，使得所有值都在 0 到 1 之间，并且总和为 1。它的公式是 $q_i=\frac{e^{z_i}}{∑_j^{e^{z_j}}}$， (其中 $z_i$ 是第 $i$ 个类别的 Logit)。<code>[[1.8849e-05, 1.5172e-05, 1.1687e-05, ...]]</code> 就是经过 Softmax 转换后的概率分布。</p>
<p><strong>步骤 3：Target probabilities（目标概率）</strong></p>
<p>这一步的核心是从模型的预测中，提取出与真实答案相对应的概率值。在理论上，我们用独热编码（One-Hot Encoding）来表示真实标签，例如 <code>[0, 1, 0, ...]</code>。图片中的 <code>[7.4541e-05, ...]</code> 正是模型根据这个独热编码所指示的正确索引，给出的预测概率。这些值通常很小，因为在训练初期，模型对正确答案的预测能力还很弱。在计算交叉熵时，我们只关心真实类别对应的预测概率。</p>
<p><strong>步骤 4：Log probabilities（对数概率）</strong></p>
<p>这一步是计算每个目标概率值的自然对数，即 $log(q_i)$。例如，<code>[-9.5042, -10.3796, -11.3677, ...]</code> 就是对目标概率取自然对数的结果。</p>
<p><strong>步骤 5：Average log probability（平均对数概率）</strong></p>
<p>这一步是计算<strong>所有对数概率的平均值</strong>。在步骤 4 中，我们已经得到了模型对每个正确答案的预测概率的对数值。这一步就是将这些值加起来，然后除以样本或序列的长度，以得到一个平均值。</p>
<p><strong>步骤 6：Negative average log probability（负平均对数概率）</strong></p>
<p><strong>这是计算</strong>最终损失值的步骤。在步骤 5 的基础上，我们对平均对数概率取负号。这是为了<strong>将一个衡量模型错误程度的负数，转换成一个衡量模型错误程度的正数</strong>。这个操作没有复杂的数学含义，它只是为了让损失值的符号符合我们的直觉和约定。损失值越小代表模型表现越好。在图片中，对 <code>-10.7940</code> 取负号后，得到的值是 <code>10.7940</code>。这个值就是我们最终要最小化的损失（Loss）。在模型训练中，我们通过反向传播和梯度下降来不断减小这个损失值，从而迫使模型提高对正确答案的预测概率。</p>
<blockquote>
<p>上面 6 个步骤，可以直接使用 pytorch 的 <code>cross_entropy</code> 计算，一步到位！</p>
</blockquote>
<pre><code class="language-python">loss = torch.nn.functional.cross_entropy(logits_flat, targets_flat)
</code></pre>
<p>总结一下，整个计算流程可以概括为：</p>
<ol>
<li>模型输出原始分数（Logits）。</li>
<li>通过 Softmax 函数将分数转换为概率分布。</li>
<li>找出真实类别对应的预测概率。</li>
<li>对这个概率取负对数，得到损失值。</li>
<li>在训练时，我们会对所有样本的损失值求平均，然后进行反向传播更新模型参数。</li>
</ol>
<p>这个计算方式之所以合理，正是因为它完美地结合了信息论和机器学习的目标：<strong>通过最小化这个损失值，我们实际上是在最大化模型对正确类别的预测概率，从而让模型的预测分布越来越接近真实的分布。</strong> 这是一种非常高效且理论基础坚实的训练方法。</p>
]]></content:encoded>
    </item>
    <item>
      <title>大白话解释 GPT 架构中的权重共享</title>
      <link>https://hedon.top/blog/weight-typing/</link>
      <guid isPermaLink="true">https://hedon.top/blog/weight-typing/</guid>
      <pubDate>Wed, 13 Aug 2025 16:30:20 GMT</pubDate>
      <description>本篇用外语学习的比喻，深入浅出地解释 GPT 架构中的权重共享技术，从听写记忆到表达记忆，帮助你理解这个提升大模型效率的核心优化策略</description>
      <category>机器学习</category><category>深度学习</category><category>大模型</category>
      <content:encoded><![CDATA[<p>在当今的大模型时代，GPT 架构以其强大的能力席卷了整个 AI 领域。当你深入探究其内部结构时，会发现许多精妙的设计。其中一个看似简单、却能带来巨大效益的工程技巧，就是我们今天要讨论的——<strong>权重共享（Weight Tying）</strong>。</p>
<h2>1. 什么是权重共享？</h2>
<p>想象一下你在学习一门外语。有两个过程：</p>
<ol>
<li><strong>听写</strong>：听到一个词后，你需要在脑海中构建它的意思。</li>
<li><strong>表达</strong>：你想表达一个意思时，需要从词库中挑出最合适的词。</li>
</ol>
<p>一个高效的学习者会发现，这两个过程是相辅相成的。你对一个词理解得越深（听写），就越能准确地使用它（表达）。反之亦然。</p>
<p>在 GPT 模型中，权重共享就是将这两个过程的&quot;记忆&quot;绑定在一起。</p>
<p>具体来说，模型有两个关键的权重矩阵：</p>
<ul>
<li><strong>输入嵌入（Input Embedding）</strong>：将输入的离散 Token（如单词 &quot;cat&quot;）转换成连续的向量表示。这就像是你的&quot;听写记忆&quot;。</li>
<li><strong>输出线性层（Output Linear Layer）</strong>：将模型内部的向量表示转换回离散的 Token，用于预测下一个词。这就像是你的&quot;表达记忆&quot;。</li>
</ul>
<p>更具体来说：</p>
<ul>
<li><strong>输入嵌入矩阵（Input Embedding Matrix） Wemb</strong>：这是一个将离散的 Token（词汇表中的 ID）映射到连续向量空间（Token Embedding）的矩阵。它的维度是 <code>[词汇表大小, 模型维度]</code>。当一个 Token ID 比如 <code>5234</code> 进来时，模型会查找这个矩阵的第 <code>5234</code> 行，将其作为这个 Token 的向量表示。</li>
<li><strong>输出词表线性层（Output Vocabulary Linear Layer）Wout</strong>：这是模型在最后一步用来预测下一个 Token 的矩阵。它的维度是 <code>[模型维度, 词汇表大小]</code>。模型经过一系列 Transformer Block 处理后，会得到一个 <code>[1, 模型维度]</code> 的输出向量，这个向量会与 Wout 进行矩阵乘法，得到一个 <code>[1, 词汇表大小]</code> 的 Logits 向量。这个向量的每个值代表了词汇表中相应 Token 的概率分数，通过 Softmax 归一化后，就可以得到下一个词的概率分布。</li>
</ul>
<p>权重共享的精髓在于，它<strong>将输出线性层的权重矩阵，设置为输入嵌入矩阵的转置</strong>。这意味着，模型在学习如何编码（理解）一个词时，也在同步学习如何解码（生成）这个词。</p>
<h2>2. 为什么要这样做？</h2>
<h3>浅层原因：参数效率</h3>
<p>这是最直观的好处。一个典型的 GPT 模型，词汇表大小可能达到 5 万，模型维度（<code>d_model</code>）可能达到 4096。</p>
<ul>
<li><strong>不共享参数</strong>：<ul>
<li>输入嵌入矩阵参数量：<code>50000 * 4096</code></li>
<li>输出线性层参数量：<code>4096 * 50000</code></li>
<li>总参数量：<code>2 * 50000 * 4096 ≈ 4.1 亿</code></li>
</ul>
</li>
<li><strong>共享参数</strong>：<ul>
<li>总参数量：<code>50000 * 4096 ≈ 2.05 亿</code></li>
</ul>
</li>
</ul>
<p>通过共享参数，我们直接将这两部分的参数量减少了一半。这对于模型整体的参数规模来说，是一个显著的节省。在大规模模型中，这能有效降低显存占用，让训练和部署更具可行性。</p>
<h3>深层原因：泛化能力与语义对称性</h3>
<p><strong>更好的梯度信号</strong>：当模型学习将一个 Token 映射为有意义的向量时（输入嵌入），这些向量也会通过转置操作，影响到模型对下一个 Token 的预测（输出线性层）。反之，当模型预测某个 Token 概率的梯度回传时，也会同时更新输入嵌入矩阵。</p>
<p>这形成了一种&quot;双向学习&quot;的机制：模型在学习如何编码 Token 的同时，也在学习如何解码 Token，这两个过程相互强化。这就像一个人在学习如何说一个词（输出）时，也在不断加深对这个词的理解（输入）。</p>
<p><strong>增强泛化能力</strong>：</p>
<ul>
<li><strong>处理生僻词</strong>：对于训练语料中出现频率很低的词，模型可能没有足够的样本来学习其精确的向量表示。但通过权重共享，如果这个词作为&quot;输出&quot;被预测过，它的梯度也会回传到输入嵌入矩阵，让其向量表示得到更新。反之亦然。这使得模型对低频词的理解能力和预测能力都能得到提升，从而增强了模型的泛化能力。</li>
<li><strong>语义对称性</strong>：权重共享本质上假设了 Token 的&quot;编码&quot;和&quot;解码&quot;过程应该具有某种对称性。一个 Token 的向量表示，应该直接反映其作为输出时的&quot;预测向量&quot;。这可以看作是一种正则化，迫使模型学习更紧凑、更高效、更具语义一致性的向量空间。</li>
</ul>
<h2>3. 落地实践要点与启示</h2>
<p>在实际的 GPT 实现中，权重共享是一个常见的技巧。例如，OpenAI 的 GPT-2 和许多基于其架构的开源模型都采用了这种做法。</p>
<ul>
<li><p><strong>实现细节</strong>：在 PyTorch 等深度学习框架中，实现非常简单，通常只需要将 <code>nn.Linear(d_model, vocab_size)</code>层的 <code>weight</code> 参数设置为 <code>nn.Embedding(vocab_size, d_model)</code> 层的 <code>weight.T</code> 即可。</p>
<pre><code class="language-python"># 假设我们已经定义好了嵌入层
embedding_layer = nn.Embedding(vocab_size, d_model)

# 定义一个线性层，用于预测下一个词
output_layer = nn.Linear(d_model, vocab_size, bias=False)

# 权重共享的魔法就在这里：
# 将输出层的权重，设置为嵌入层权重的转置
output_layer.weight = embedding_layer.weight
</code></pre>
</li>
<li><p><strong>效果评估</strong>：在早期的研究中，例如在 Transformer 架构中，研究人员就通过消融实验（ablation study）发现，权重共享能够带来约 0.5 到 1 个百分点的精度提升，同时大幅减少参数量。这证明了它在实践中的有效性。</p>
</li>
</ul>
<h2>总结</h2>
<p>权重共享并非 GPT 的&quot;核心&quot;创新，但它是一个非常精巧且有效的工程与理论结合。它通过一个简单的参数绑定，实现了：</p>
<ul>
<li><strong>工程上</strong>：显著减少模型参数量，提升训练和推理效率。</li>
<li><strong>理论上</strong>：建立输入和输出之间的双向学习机制，增强了模型对词汇表（特别是低频词）的泛化能力，并鼓励模型学习更具语义一致性的向量表示。</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 多态的两种实现：Trait Bound 与 Trait Object 深度解析</title>
      <link>https://hedon.top/blog/rust-polymorphism/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-polymorphism/</guid>
      <pubDate>Tue, 05 Aug 2025 11:00:00 GMT</pubDate>
      <description>Rust 多态的两种实现：Trait Bound 与 Trait Object 深度解析</description>
      <category>rust</category>
      <content:encoded><![CDATA[<p>在 Rust 编程中，实现多态（Polymorphism）主要有两种核心机制：<strong>Trait Bound</strong> 和 <strong>Trait Object</strong>。虽然两者都基于 <code>trait</code>，但它们的设计理念、底层实现和适用场景却截然不同。本文将带你从概念到具体的内存布局，深入探究这两种多态方式的本质。</p>
<h3>1. 从一个基本问题说起</h3>
<p>设想我们有一个 <code>trait Draw</code>，它定义了绘制的方法。<code>Square</code> 结构体实现了这个 <code>trait</code>。</p>
<pre><code class="language-rust">trait Draw {
    fn bounds(&amp;self) -&gt; (i32, i32, i32, i32); // 假设定义了边界方法
    // ... 其他绘制相关方法
}

struct Square {
    top_left: Point,
    size: i32,
}

struct Point {
    x: i32,
    y: i32,
}

impl Draw for Square {
    fn bounds(&amp;self) -&gt; (i32, i32, i32, i32) {
        (self.top_left.x, self.top_left.y, self.size, self.size)
    }
}
</code></pre>
<p>现在，我们如何编写一个函数来处理 <code>Square</code>，并调用它的 <code>bounds</code> 方法呢？这就是 <strong>Trait Bound</strong> 和 <strong>Trait Object</strong> 登场的时机。</p>
<h3>2. Trait Bound：编译期的静态多态</h3>
<p><strong>Trait Bound</strong> 的核心思想是<strong>编译期特化（Monomorphization）</strong>。它通过泛型参数 <code>T</code> 来约束类型，确保该类型实现了某个 <code>trait</code>。</p>
<pre><code class="language-rust">fn print_bounds&lt;T: Draw&gt;(item: T) {
    let (x, y, w, h) = item.bounds();
    println!(&quot;边界: x={}, y={}, width={}, height={}&quot;, x, y, w, h);
}

let square = Square { top_left: Point { x: 1, y: 2 }, size: 2 };
print_bounds(square); // T 被特化为 Square
</code></pre>
<p><strong>底层原理：静态分发（Static Dispatch）</strong></p>
<p>在编译时，编译器会为 Square 类型生成一份 print_bounds 函数的独立代码。当调用 print_bounds(square) 时，程序直接调用为 Square 特化的版本，无需在运行时查找。</p>
<p><strong>优点与缺点</strong></p>
<ul>
<li><strong>零运行时开销</strong>：性能极致，与直接调用具体函数无异。</li>
<li><strong>代码膨胀（Code Bloat）</strong>：如果有很多不同的类型都实现了 <code>Draw</code>，编译器就会生成多份 <code>print_bounds</code> 的代码。</li>
<li><strong>语法糖</strong>：<code>fn print_bounds(item: impl Draw)</code> 是 <code>fn print_bounds&lt;T: Draw&gt;(item: T)</code> 的语法糖，两者在底层实现和性能上是完全等价的。</li>
</ul>
<hr>
<h3>3. Trait Object：运行时的动态多态</h3>
<p>现在，我们面临一个新问题：如果想把不同类型但都可绘制的对象放入同一个 <code>Vec</code> 集合中怎么办？例如，我们有一个 <code>Square</code> 和一个 <code>Circle</code>（假设 <code>Circle</code> 也实现了 <code>Draw</code>），我们不能直接 <code>vec![square, circle]</code>，因为 <code>Vec</code> 要求所有元素是<strong>同一种具体类型</strong>。</p>
<p><strong>Trait Object</strong> 的核心思想是<strong>类型擦除（Type Erasure）</strong>，它允许我们将实现了相同 <code>trait</code> 的不同类型实例统一处理。</p>
<pre><code class="language-rust">// 假设 Circle 也实现了 Draw trait
let circle = Circle { /* ... */ };

let square = Square {
  top_left: Point { x: 1, y: 2 },
  size: 2
};

// 这里的 `dyn` 关键字表示动态类型
let draw_object: Box&lt;dyn Draw&gt; = Box::new(square);

// 可以将不同类型但都实现了 Draw 的对象放入 Vec 中
let drawable_items: Vec&lt;Box&lt;dyn Draw&gt;&gt; = vec![Box::new(square), Box::new(circle)];
</code></pre>
<p><strong>底层原理：动态分发（Dynamic Dispatch）</strong></p>
<p>Box&lt;dyn Draw&gt; 是一个胖指针（Fat Pointer）。它包含两个部分：</p>
<ol>
<li><strong>数据指针</strong>：指向堆上实际的对象（例如 <code>Square</code> 实例）。</li>
<li><strong>虚表指针</strong>：指向一张静态生成的<strong>虚函数表（vtable）</strong>。</li>
</ol>
<p>当调用 <code>draw_object.bounds()</code> 时，程序会在<strong>运行时</strong>通过胖指针找到虚表，再从虚表中找到正确的方法地址并执行。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250805124423615.png" alt="trait object layout"></p>
<p>上图展示了 <code>&amp;dyn Draw</code> 这个 <code>trait object</code> 的内存布局：</p>
<p><strong>栈（Stack）</strong>：</p>
<ul>
<li><code>square</code>：原始的 <code>Square</code> 实例，其数据（<code>top_left.x</code>, <code>top_left.y</code>, <code>size</code>）直接存储在栈上，大小在编译时可知。</li>
<li><code>draw</code>：这是一个 <code>&amp;dyn Draw</code> 类型的 <strong>胖指针</strong>。它也存储在栈上，但其大小是固定的（两个指针的大小，通常是 16 字节在 64 位系统上）。<ul>
<li>胖指针的<strong>第一个部分</strong>指向 <code>square</code> 实例的实际数据地址。</li>
<li>胖指针的<strong>第二个部分</strong>指向 <code>Draw for Square vtable</code>。</li>
</ul>
</li>
</ul>
<p><strong>虚表（Vtable）</strong>：</p>
<ul>
<li><code>Draw for Square vtable</code>：这是一个在编译时为 <code>Square</code> 类型和 <code>Draw</code> <code>trait</code> 的组合而生成的<strong>静态只读表</strong>。它包含了 <code>Square</code> 实现 <code>Draw</code> <code>trait</code> 所需的所有信息，其中最重要的是 <code>Square::bounds()</code> 方法的实际内存地址。</li>
</ul>
<p>通过 <code>draw</code> 胖指针调用 <code>draw.bounds()</code> 时，Rust 运行时会：</p>
<ol>
<li>读取 <code>draw</code> 胖指针中的虚表指针。</li>
<li>通过虚表指针找到 <code>Draw for Square vtable</code>。</li>
<li>从虚表中找到 <code>bounds()</code> 方法的地址（即 <code>Square::bounds()</code> 的地址）。</li>
<li>调用该地址处的函数，并将胖指针中的数据指针作为 <code>self</code> 参数传递。</li>
</ol>
<blockquote>
<p><strong>虚表是与类型-trait 组合绑定的，而不是与实例绑定的。</strong> 无论有多少个 <code>&amp;dyn Draw</code> 类型的胖指针，只要它们都引用同一个 <code>Square</code> 实例，或者不同的 <code>Square</code> 实例，它们的虚表指针都会指向<strong>同一张</strong>静态生成的 <code>Draw for Square vtable</code>。虚表是全局唯一的，为每种类型-trait 组合只生成一份。</p>
</blockquote>
<h3>4. 复杂场景下的内存布局：组合 Trait Object</h3>
<p>当 <code>trait object</code> 组合多个 <code>trait</code> 时，比如 <code>&amp;dyn Draw + Shape</code>，底层机制会更加精巧。</p>
<ul>
<li><strong>单 Trait Object</strong>：<code>&amp;dyn Draw</code> 和 <code>&amp;dyn Shape</code> 是两个独立的胖指针，分别指向为 <code>Square</code>-<code>Draw</code> 和 <code>Square</code>-<code>Shape</code> 组合生成的<strong>独立虚表</strong>。</li>
<li><strong>组合 Trait Object</strong>：<code>&amp;dyn Draw + Shape</code> 是一个<strong>单一的胖指针</strong>。它指向一张包含了<strong>所有组合 <code>trait</code> 方法地址的联合虚表</strong>。</li>
</ul>
<p>假如说我们定义的 <code>Shape</code> trait 如下：</p>
<pre><code class="language-rust">/// Anything that implements `Shape` must also implement `Draw`.
trait Shape: Draw {
  /// Render that portion of the shape that falls within `bounds`.
  fn render_in(&amp;self, bounds: Bounds);

  /// Render the shape.
  fn render(&amp;self) {
      // Default implementation renders that portion of the shape
      // that falls within the screen area.
      if let Some(visible) = overlap(SCREEN_BOUNDS, self.bounds()) {
        self.render_in(visible);
      }
  }
}
</code></pre>
<p>现有如下代码：</p>
<pre><code class="language-rust">let square = Square {
  top_left: Point { x: 1, y: 2 },
  size: 2,
};
let draw: &amp;dyn Draw = &amp;square;
let shape: &amp;dyn Shape = &amp;square;
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250805125330619.png" alt="组合 trait objects layout"></p>
<p><strong>栈（Stack）</strong>：</p>
<ul>
<li><code>square</code>：原始 <code>Square</code> 实例，不变。</li>
<li><code>draw</code>：<code>&amp;dyn Draw</code> 胖指针，指向 <code>Square</code> 数据和 <code>Draw for Square vtable</code>。</li>
<li><code>shape</code>：这是一个<strong>新的、独立的</strong> <code>&amp;dyn Shape</code> 胖指针。它同样指向 <code>Square</code> 数据，但其虚表指针指向的是 <code>Shape for Square vtable</code>。</li>
</ul>
<p><strong>虚表（Vtable）</strong>：</p>
<ul>
<li><code>Draw for Square vtable</code>：为 <code>Square</code> 和 <code>Draw</code> 组合生成的虚表，它包含了 <code>bounds()</code> 方法的指针。</li>
<li><code>Shape for Square vtable</code>：为 <code>Square</code> 和 <code>Shape</code> 组合生成的<strong>另一个独立的虚表</strong>。它包含了 <code>Square::render_in()</code> 、<code>Square::bounds()</code>和  <code>Shape::render()</code> 方法的地址。</li>
</ul>
<blockquote>
<p>总结：如果你有<strong>多个独立的 <code>trait object</code> 类型</strong>（如 <code>&amp;dyn Draw</code> 和 <code>&amp;dyn Shape</code>），即使它们引用的是<strong>同一个底层数据</strong>，它们各自的胖指针也会指向<strong>各自独立的虚表</strong>。</p>
</blockquote>
<h3>5. Trait Object 的安全约束</h3>
<p>为了在实现动态多态的同时保证内存安全，Rust 对 <strong>trait object</strong> 施加了严格的限制：</p>
<ul>
<li><strong><code>Sized</code> 约束</strong>：<code>dyn Trait</code> 是一个 DST，其大小在编译时未知。因此，它必须通过指针（<code>&amp;</code>、<code>Box</code>、<code>Rc</code>、<code>Arc</code> 等）引用。</li>
<li><strong>方法限制</strong>：<code>trait object</code> 的 <code>trait</code> 方法不能是泛型方法，也不能返回 <code>Self</code>。这是因为编译器无法为泛型方法生成虚表条目，也无法确定返回 <code>Self</code> 的返回值大小。例如，<code>Clone</code> <code>trait</code> 因为其 <code>clone</code> 方法返回 <code>Self</code>，所以不能直接作为 <code>trait object</code>。</li>
<li><strong>生命周期</strong>：<code>trait object</code> 的生命周期会与它所引用的数据的生命周期绑定，防止悬空指针（<code>use-after-free</code>）问题。</li>
</ul>
<h3>总结</h3>
<table>
<thead>
<tr>
<th>特性</th>
<th>Trait Bound (泛型)</th>
<th>Trait Object (动态)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>多态类型</strong></td>
<td><strong>静态多态</strong></td>
<td><strong>动态多态</strong></td>
</tr>
<tr>
<td><strong>分发方式</strong></td>
<td><strong>静态分发</strong> (编译时)</td>
<td><strong>动态分发</strong> (运行时)</td>
</tr>
<tr>
<td><strong>性能开销</strong></td>
<td><strong>零开销</strong></td>
<td><strong>轻微开销</strong> (虚表查找)</td>
</tr>
<tr>
<td><strong>底层原理</strong></td>
<td><strong>编译期特化</strong></td>
<td><strong>类型擦除 + 胖指针/虚表</strong></td>
</tr>
<tr>
<td><strong>大小类型</strong></td>
<td><code>Sized</code></td>
<td><code>Unsized</code> (必须通过指针引用)</td>
</tr>
<tr>
<td><strong>典型应用</strong></td>
<td>极致性能、类型已知</td>
<td>异构集合、插件化、通用接口</td>
</tr>
</tbody></table>
]]></content:encoded>
    </item>
    <item>
      <title>大白话解释反向传播算法</title>
      <link>https://hedon.top/blog/back-propagation/</link>
      <guid isPermaLink="true">https://hedon.top/blog/back-propagation/</guid>
      <pubDate>Sun, 27 Jul 2025 12:30:20 GMT</pubDate>
      <description>本篇用 CEO 追责分锅的比喻，深入浅出地解释反向传播算法的工作原理，从流水线管理到神经网络训练，帮助你理解这个深度学习的核心算法</description>
      <category>机器学习</category><category>深度学习</category><category>大模型</category>
      <content:encoded><![CDATA[<h3>一、核心思想：一个 “分锅” 大会</h3>
<p>想象一下，你是一个大公司的 CEO，你的公司有一个很长的流水线，用来生产一个精密的产品。这条流水线有很多道工序，每道工序都有一个工人负责。</p>
<ol>
<li><strong>最终产品出问题了</strong>：产品下线后，你发现最终的成品和设计图纸有偏差 (比如，要求重 100 克，结果做出来重 110 克)。这个 “10 克的偏差” 就是 <strong>误差 (Error)</strong>。</li>
<li><strong>你作为 CEO 开始追责</strong>：你肯定不会把所有人都骂一顿，或者随机开除一个工人。最科学的方法是 <strong>从后往前</strong>追查。</li>
<li><strong>追责第一步</strong>：你首先找到 <strong>最后一道工序</strong> 的工人。因为他是直接影响成品的人。你对他说：“产品重了 10 克，你的操作对最终重量影响最大，你先调整一下你的机器参数。”</li>
<li><strong>追责第二步</strong>：这个工人会说：“老板，我这道工序的产出，也受到 <strong>上一道工序</strong> 给我的半成品的影响啊。根据我的机器参数，我可以计算出，上一个工人交给我的半成品大概是重了 8 克导致的。”</li>
<li><strong>追责第三步</strong>：于是，你又拿着这个 “8 克的偏差” 去找 <strong>倒数第二个工人</strong>。这个工人也同样会计算他受到了他上游工序的影响。</li>
<li><strong>一路向前追溯</strong>：就这样，这个 “锅” (误差) 从最后一个工人开始，一层一层地 <strong>向前传递</strong>，每个工人都根据自己的 “责任” 大小，领走一部分 “锅”，并对自己的机器参数做出微小的调整。</li>
</ol>
<p>这个从后往前追责、分锅、调整的过程，就是 <strong>反向传播</strong> 的核心思想。</p>
<h3>二、从比喻到神经网络</h3>
<p>现在，我们把上面的比喻翻译成神经网络的术语：</p>
<table>
<thead>
<tr>
<th>大白话比喻</th>
<th>神经网络术语</th>
<th>解释</th>
</tr>
</thead>
<tbody><tr>
<td><strong>流水线</strong></td>
<td><strong>神经网络 (Neural Network)</strong></td>
<td>由多个层级组成，数据从输入层流向输出层。</td>
</tr>
<tr>
<td><strong>工人</strong></td>
<td><strong>神经元 (Neuron)</strong></td>
<td>网络中的计算单元。</td>
</tr>
<tr>
<td><strong>工人的机器参数</strong></td>
<td><strong>权重 (Weights) 和 偏置 (Biases)</strong></td>
<td>每个神经元里需要学习和调整的参数，就像机器的旋钮。</td>
</tr>
<tr>
<td><strong>最终产品</strong></td>
<td><strong>网络的预测输出 (Prediction)</strong></td>
<td>比如，给一张猫的图片，网络输出 “90% 是狗”。</td>
</tr>
<tr>
<td><strong>设计图纸</strong></td>
<td><strong>真实标签 (True Label)</strong></td>
<td>正确答案，比如 “100% 是猫”。</td>
</tr>
<tr>
<td><strong>产品偏差</strong></td>
<td><strong>损失/误差 (Loss / Error)</strong></td>
<td>预测输出和真实标签之间的差距。由 <strong>损失函数 (Loss Function)</strong> 计算得出。</td>
</tr>
<tr>
<td><strong>从后往前追责分锅</strong></td>
<td><strong>反向传播 (Backpropagation)</strong></td>
<td>将总误差从输出层开始，一层层向输入层传播，计算出每一层权重对总误差的“贡献度”。</td>
</tr>
<tr>
<td><strong>调整机器参数</strong></td>
<td><strong>权重更新 (Weight Update)</strong></td>
<td>使用一种叫做 <strong>梯度下降 (Gradient Descent)</strong> 的方法，根据计算出的“贡献度”来微调网络中所有的权重，目的是让总误差变小。</td>
</tr>
</tbody></table>
<h3>三、核心工具：微积分里的 “链式法则”</h3>
<p>你可能会问，每个工人是怎么精确计算出他应该背多大的“锅”呢？</p>
<p>这里的“锅”在数学上，就是 <strong>梯度 (Gradient)</strong>，简单理解就是 <strong>导数</strong>。导数衡量的是 “如果我稍微动一下这个参数，最终的误差会改变多少”。</p>
<ul>
<li>如果导数很大 (无论是正还是负)，说明这个参数对最终误差的影响很大，是“主要责任人”，需要大幅调整。</li>
<li>如果导数很小，接近 0，说明它基本没啥影响，是“吃瓜群众”，基本不用动。</li>
</ul>
<p>反向传播算法的数学精髓，就是应用了微积分里的 <strong>链式法则 (Chain Rule)</strong>。</p>
<p><strong>链式法则通俗解释</strong>：如果 C 的变化依赖于 B，而 B 的变化又依赖于 A，那么链式法则可以帮助我们计算出 A 的微小变化最终会对 C 产生多大的影响。</p>
<p>在神经网络里，最终的误差 (Loss) 是输出层 (Output Layer) 的函数，输出层又是前一个隐藏层 (Hidden Layer) 的函数，以此类推，直到输入层。反向传播正是利用链式法则，高效地计算出 <strong>总误差</strong> 相对于 <strong>网络中每一个权重</strong>的梯度 (导数)。它就像一套完美的公式，能精确地把“锅”不多不少、恰如其分地分配给每一个相关的参数。</p>
<h3>四、总结：反向传播的完整流程</h3>
<p>所以，神经网络的学习过程（训练）可以总结为以下循环往复的步骤：</p>
<ol>
<li><strong>正向传播 (Forward Pass)</strong>：<ul>
<li>给网络一个输入数据 (例如一张图片)。</li>
<li>数据从输入层开始，经过每一层神经元的计算 (乘以权重，加上偏置，再通过激活函数)，最后到达输出层，得到一个预测结果。</li>
<li>这就像把原材料放上传送带，走完整条流水线，得到最终产品。</li>
</ul>
</li>
<li><strong>计算损失 (Calculate Loss)</strong>：<ul>
<li>用损失函数比较网络的预测结果和真实的正确答案，计算出它们之间的差距，即总误差 (Loss)。</li>
<li>这就像质检员检查最终产品，看它和设计图纸差了多少。</li>
</ul>
</li>
<li><strong>反向传播 (Backward Pass / Backpropagation)</strong>：<ul>
<li>这是最关键的一步。从总误差出发，利用链式法则，从输出层开始，反向逐层计算出网络中 <strong>每一个权重</strong> 对这个总误差的“贡献度”(梯度)。</li>
<li>这就像 CEO 拿着质检报告，从后往前追责，精确地给每个工序“分锅”。</li>
</ul>
</li>
<li><strong>更新权重 (Update Weights)</strong>：<ul>
<li>根据反向传播计算出的“贡献度”(梯度)，使用梯度下降等优化算法，对网络中所有的权重进行微小的调整。调整的方向是 <strong>让总误差变小</strong> 的方向。</li>
<li>这就像每个工人接到“整改通知”后，都去微调自己的机器旋钮。</li>
</ul>
</li>
</ol>
<p>通过成千上万次地重复以上 4 个步骤，网络中的所有权重会逐渐被调整到最优状态，使得网络在接收新的输入时，能够做出非常准确的预测。</p>
<p>简单来说，<strong>反向传播就是神经网络高效学习的秘诀，它通过一个巧妙的“从后往前分锅”机制，告诉网络里的每一个参数应该如何自我调整，才能让最终的预测结果越来越准。</strong></p>
]]></content:encoded>
    </item>
    <item>
      <title>读书笔记丨《Fundamentals of Software Architecture》</title>
      <link>https://hedon.top/blog/note-fosa/</link>
      <guid isPermaLink="true">https://hedon.top/blog/note-fosa/</guid>
      <pubDate>Thu, 24 Jul 2025 11:01:24 GMT</pubDate>
      <description>基于《Fundamentals of Software Architecture》内容，梳理出六步架构设计方法论，从商业理解到组织成长形成闭环，探讨架构师如何在权衡取舍中做出&quot;最不差&quot;的决策，以及如何通过持续交付、监控验证和复盘演进构建可持续的架构能力。</description>
      <category>读书笔记</category><category>fosa</category>
      <content:encoded><![CDATA[<h2>聊架构设计的时候，我们在谈什么？</h2>
<p><strong>第一步：理解商业与组织上下文 (Understand Business &amp; Organizational Context)</strong></p>
<ul>
<li><strong>利益相关方 (Stakeholders)</strong>: 他们的核心诉求和期望是什么？</li>
<li><strong>用户视角 (User Perspective)</strong>: 我们要为用户解决什么核心痛点？</li>
<li><strong>商业目标 (Business Goals)</strong>: 这个项目要达成什么商业指标？（例如：降低成本、提升转化率）</li>
<li><strong>组织能力 (Organizational Capabilities)</strong>:<ul>
<li>公司文化 (Company Culture): 我们的文化是拥抱变化还是追求稳定？</li>
<li>团队现状 (Team Status): 团队的技术栈、技能水平和规模如何？</li>
</ul>
</li>
</ul>
<p><strong>第二步：定义架构特性与约束 (Define Architectural Characteristics &amp; Constraints)</strong></p>
<p>这一步的目标是将第一步中模糊的需求，转化为具体、可度量的技术目标。</p>
<ul>
<li><strong>识别架构特性 (Identify Architectural Characteristics / -ilities)</strong>:<ul>
<li>从性能、可伸缩性、可用性、容错性、可维护性、安全性、成本等特性中，识别出本次设计<strong>最关键</strong>的 3-5 个。</li>
<li><strong>对它们进行排序</strong>。例如，对于一个后台管理系统，“可维护性”的优先级可能就高于“性能”。</li>
</ul>
</li>
<li><strong>明确约束条件 (Define Constraints)</strong>:<ul>
<li>有哪些不可逾越的红线？例如：预算上限、上线日期 (Time to Market)、必须使用公司内某技术平台、法律合规要求等。</li>
</ul>
</li>
</ul>
<p><strong>第三步：探索方案与决策 (Explore Solutions &amp; Make Decisions)</strong></p>
<p>有了第二步清晰的目标和边界，我们现在可以带着这些标准去评估方案。</p>
<ul>
<li><strong>探索可选方案 (Explore Options)</strong>: 至少寻找 2-3 个备选方案。</li>
<li><strong>进行权衡分析 (Analyze Trade-offs)</strong>: 基于第二步定义的<strong>架构特性优先级</strong>，系统地对比各方案的优劣。</li>
<li><strong>评估风险 (Assess Risks)</strong>: 每个方案可能引入哪些短期或长期的技术、成本、人员风险？</li>
<li><strong>记录决策 (Document Decisions)</strong>: 使用 ADR (Architecture Decision Record) 记录最终选择和放弃的原因。</li>
</ul>
<p><strong>第四步：设计实施路径与验证机制 (Design Implementation Path &amp; Verification)</strong></p>
<p>在真正开始大规模编码前，设计好如何走，以及如何验证我们走在正确的路上。</p>
<ul>
<li><strong>实施计划 (Implementation Plan)</strong>:<ul>
<li>是否需要技术原型 (PoC) 来验证关键难点？</li>
<li>如何进行任务拆解和里程碑规划？</li>
</ul>
</li>
<li><strong>构建适应度函数 (Build Fitness Functions)</strong>:<ul>
<li>针对第二步定义的关键架构特性，设计具体的“检验尺”。</li>
<li>例如：为保证“模块解耦”，设计一个静态代码检查规则，禁止模块间的非法调用。</li>
</ul>
</li>
<li><strong>知识沉淀 (Knowledge Sedimentation)</strong>: 准备好核心的架构图、设计文档等。</li>
</ul>
<p><strong>第五步：部署、观测与效果衡量 (Deploy, Observe &amp; Measure Effectiveness)</strong></p>
<p>将架构推向真实世界，并通过数据验证其价值。</p>
<ul>
<li><strong>持续交付 (CI/CD)</strong>: 作为将设计快速、可靠地部署到生产环境的手段。</li>
<li><strong>系统监控 (System Monitoring)</strong>: 观测系统的健康状况（CPU、内存、延迟、错误率等）。</li>
<li><strong>业务指标验证 (Business Metrics Verification)</strong>: <strong>（闭环关键）</strong> 验证是否达成了第一步定义的商业目标？例如，新架构上线后，用户转化率是否真的提升了？</li>
</ul>
<p><strong>第六步：复盘、沉淀与演进 (Retrospect, Internalize &amp; Evolve)</strong></p>
<ul>
<li><strong>问题记录与根因分析 (Problem Record &amp; Root Cause Analysis)</strong>: 发生了什么？为什么会发生？</li>
<li><strong>流程与原则改进 (Process &amp; Principle Improvement)</strong>: 如何优化我们的设计流程、技术原则，避免未来再犯？</li>
<li><strong>人员与组织成长 (Personnel &amp; Organizational Growth)</strong>: 团队通过这次项目学到了什么？需要组织哪些培训？</li>
</ul>
<h2>Fundamentals of Software Architectrue 笔记梳理</h2>
<blockquote>
<p>本章笔者将打散 FOSA 书中的各个知识点，并将它们贯穿在我们上面提到的整个架构设计闭环中，同时会添加一些书中没有的内容进行补充扩展。</p>
</blockquote>
<h3>1. 理解商业与组织上下文</h3>
<blockquote>
<p>利益相关方：他们的核心诉求和期望是什么？</p>
<p>用户视角：我们要为用户解决什么核心痛点？</p>
<p>商业目标：这个项目要达成什么商业指标？</p>
<p>组织能力：我们的文化是拥抱变化还是追求稳定？团队的技术栈、技能水平和规模如何？</p>
</blockquote>
<h4>1.1 谈判技巧</h4>
<p>FOSA 指出，架构师必须理解并驾驭企业的<strong>政治环境</strong>。几乎每一个架构决策都会受到挑战，这可能来自产品负责人、项目经理、业务利益相关方（因为成本或时间增加），甚至是开发者（认为有更好的方法）。</p>
<p>因此，架构师需要具备卓越的<strong>谈判和引导技能</strong> (Negotiation and Facilitation)，以理解各方诉求，并在分歧出现时达成共识。</p>
<p>FOSA 给出了几种谈判思路：</p>
<ol>
<li><strong>利用语法和流行语更好地理解情况。</strong> 软件架构师应注意业务利益相关者在沟通中使用的短语和流行语。例如，像“我们需要零停机时间”或“我昨天就需要这些功能”这样的表述，虽然可能不精确，但却能揭示出对可用性或上市时间等方面的真正关注。通过利用这些“废话语法”，架构师可以更好地理解对方真正的担忧和需求，从而在谈判中占据优势。</li>
<li><strong>在进入谈判之前收集尽可能多的信息。</strong> 在谈判之前，架构师应尽可能多地收集相关信息。例如，如果业务利益相关者坚持“五个九”的可用性（99.999%），架构师应提前研究这意味着什么，并将其转化为实际的停机时间（例如，每年约 31.5 秒的计划外停机时间）。充分掌握事实和数据有助于进行基于现实的讨论。</li>
<li><strong>当一切都失败时，说明成本和时间。</strong> 这是最后的谈判策略。尽管成本和时间（投入的工作量）是任何谈判中的关键因素，但应作为最后的手段使用。过早提及这些可能会使谈判陷入僵局，因为它们可能会被视为阻止或拒绝的借口。</li>
<li><strong>利用“分而治之”的原则来限定需求。</strong> 这一策略借鉴了孙子兵法中的思想，即“其力合者，离之”。当面临不合理或范围过大的要求时（例如，整个系统都需要“五个九”的可用性），架构师可以通过提问来缩小范围，确定哪些特定部分或功能真正需要这种高水平的特性。这样做可以减少困难且昂贵需求的范围，从而简化谈判。</li>
<li><strong>永远记住演示胜于讨论。</strong> 当与同事或开发人员在技术方法上存在分歧时，与其争论不休，不如通过实际的演示来证明你的观点。例如，如果你认为消息队列比 REST 更适合特定的服务间通信，可以在模拟生产环境中进行 A/B 测试，用数据和实际结果来说服对方。实际操作的证据通常比理论争论更有说服力。</li>
<li>**在谈判中避免过于争辩或让事情变得过于个人化——冷静的领导力结合清晰简洁的推理总能赢得谈判。**在讨论中，如果气氛变得过于激烈或个人化，最好的做法是暂停谈判，待双方冷静后再重新进行。作为领导者，保持冷静和专业的态度，并用清晰、简洁的逻辑进行推理，往往能够有效化解冲突，促使对方退让，最终达成共识。</li>
<li><strong>在说服开发人员采纳架构决策或执行特定任务时，提供理由而不是“高高在上地发号施令”。</strong> 架构师不应凭借职位来命令开发人员，而应通过提供充分的理由来说明为什么需要某个架构决策或任务。例如，解释“所有数据库调用都需要通过业务层”是为了“更好地控制变更”，这比单纯命令“你必须通过业务层”更容易被接受。理解背后的原因能促使开发人员更积极地接受并实施决策。</li>
<li><strong>如果开发人员不同意某个决策，让他们自己找到解决方案。</strong> 当开发人员对某个技术决策有异议时，与其直接反驳，不如挑战他们，让他们自己去探索并证明他们的替代方案。例如，如果开发人员坚持使用某个框架但你认为它不符合安全要求，可以让他们自行研究并展示如何解决安全问题。这不仅能促进开发人员的学习和思考，也能让架构师在最终解决方案上获得团队的认可和支持，形成双赢局面。</li>
</ol>
<h4>1.2 业务理解</h4>
<p>架构决策必须<strong>提供业务价值</strong>。如果一个架构决策没有业务价值，它可能就不是一个好的决策，需要重新考虑。</p>
<p>FOSA 强调，架构决策的<strong>商业合理性</strong>至关重要。常见的商业合理性包括：<strong>成本</strong> (Cost)、<strong>上市时间</strong> (Time to Market)、<strong>用户满意度</strong> (User Satisfaction) 和<strong>战略定位</strong> (Strategic Positioning)。在与业务利益相关方谈判时，要重点关注他们最看重的指标。</p>
<p>这里面的一大难点就是：<strong>业务方与开发方使用的不是同一种&quot;语言&quot;</strong>。双方对同一件事情的关注点是不一样的，所以表述出来的述求，也是不同的。所以架构师的职责就是需要将业务领域的关注点和架构特性进行对应。</p>
<p>比如：</p>
<table>
<thead>
<tr>
<th>Domain Concern</th>
<th>Architecture characteristics</th>
</tr>
</thead>
<tbody><tr>
<td>Mergers and acquisitions 合并与收购</td>
<td>互操作性 interoperability<br>可扩展性 scalability<br>适配性 adaptability<br>可扩展性 extensibility</td>
</tr>
<tr>
<td>Time to market 上市时间</td>
<td>灵活性 agility<br/>可测试性 testability<br/>可部署性 deployability</td>
</tr>
<tr>
<td>User satisfaction 用户满意度</td>
<td>性能 performance<br/>可用性 availability<br/>容错性 fault tolerance<br/>可测试性 testability<br/>可部署性 deployability<br/>灵活性 agility<br/>安全性 security</td>
</tr>
<tr>
<td>Competitive advantage 竞争优势</td>
<td>灵活性 agility<br/>可测试性 testability<br/>可部署性 deployability<br/>可扩展性 scalability<br/>可用性 availability<br/>容错性 fault tolerance</td>
</tr>
<tr>
<td>Time and budget 时间和预算</td>
<td>简单性 simplicity<br/>可行性 feasibility</td>
</tr>
</tbody></table>
<p>另外， 随着业务的发展，关注点也是在不断发生变化的，这个时候，架构所侧重的架构特性也是随之改变。</p>
<h3>2. 定义架构特性与约束</h3>
<blockquote>
<p>识别架构特性：从性能、可伸缩性、可用性、容错性、可维护性、安全性、成本等特性中，识别出本次设计最关键的 3-5 个。</p>
<p>明确约束条件：有哪些不可逾越的红线？</p>
</blockquote>
<h4>2.1 架构特性定义</h4>
<p>架构师的核心职责之一就是识别和定义系统的<strong>架构特性</strong> (Architecture Characteristics)。这些特性定义了系统的<strong>成功标准</strong>，并且通常与系统的<strong>功能性</strong> (Functionality) 正交。</p>
<p>一个属性要成为架构特性（Architecture Characteristics），需至少满足 3 个条件：</p>
<ol>
<li><strong>指定非领域设计考量</strong>：架构特性关注的是应用程序&quot;如何&quot;实现需求以及做出某些选择&quot;为何&quot;的原因，而不是应用程序&quot;应该做什么&quot;的业务需求。例如，性能水平通常不会出现在需求文档中，但却是重要的架构特性。</li>
<li><strong>影响设计的某个结构方面</strong>：如果一个架构特性需要特殊结构考虑才能成功，那么它就会上升到架构特性的层面。例如，一般的安全性对于几乎所有项目都是必需的，但当需要设计特定的模块、组件或服务来隔离关键安全问题时，安全才成为一个架构特性。</li>
<li><strong>对应用程序的成功至关重要</strong>：应用程序可以支持大量的架构特性，但并非所有都应该被支持。支持每个架构特性都会增加设计的复杂性，因此，架构师的关键任务是选择最少的、对应用程序成功至关重要或重要的架构特性，而不是尽可能多的。</li>
</ol>
<h4>2.2 架构特性类型</h4>
<ul>
<li><strong>显性架构特性</strong>：是在需求规范中明确列出的，作为必要设计的一部分。它们通常直接出现在需求文档或其他具体说明中。</li>
<li><strong>隐性架构特性</strong>：很少出现在需求文档中，但它们对于项目的成功是必需的。架构师必须利用他们对问题领域的知识，在分析阶段发现这些特征。</li>
</ul>
<p>可进一步细分为：操作特性、结构特性和交叉特性。</p>
<p>操作性架构特性涵盖了系统的<strong>运行能力</strong>，例如性能、可伸缩性、弹性、可用性和可靠性等。这些特性通常与运营和 DevOps 关注点高度重叠。</p>
<table>
<thead>
<tr>
<th>特性</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>Availability</td>
<td>系统需要保持可用的时间长度；例如，如果需要 24/7 可用，则需要采取措施确保系统始终可用。它指的是软件可操作和可访问的程度。</td>
</tr>
<tr>
<td>Continuity</td>
<td>灾难恢复能力。</td>
</tr>
<tr>
<td>Performance</td>
<td>衡量应用程序请求和响应周期所需的时间。它包括压力测试、高峰分析、功能使用频率分析、所需容量和响应时间。它也可以是更具体的度量，例如首屏渲染时间，即网页首次可见的时间。</td>
</tr>
<tr>
<td>Recoverability</td>
<td>业务连续性要求（例如，发生灾难时，系统需要多快才能重新上线？）这将影响备份策略和对复制硬件的要求。它也指软件从故障中恢复的能力，通过恢复任何受影响的数据并重新建立系统的所需状态。</td>
</tr>
<tr>
<td>Reliability/Safety</td>
<td>评估系统是否需要具备故障安全能力，或者其任务关键性是否影响生命。如果系统发生故障，是否会给公司带来巨额损失。它指系统在指定条件下和指定时间内运行的程度。</td>
</tr>
<tr>
<td>Robustness</td>
<td>在互联网连接中断、断电或硬件故障时，处理错误和边界条件的能力。</td>
</tr>
<tr>
<td>Scalability</td>
<td>系统随着用户或请求数量的增加而执行和运行的能力。这意味着处理大量并发用户而不会出现严重的性能下降。</td>
</tr>
</tbody></table>
<p>结构性架构特性关注<strong>代码结构</strong>。在许多情况下，架构师对代码质量问题负有独立或共同的责任，例如良好的模块化、组件间的受控耦合、可读性强的代码以及其他内部质量评估。</p>
<table>
<thead>
<tr>
<th>特性</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>Configurability</td>
<td>最终用户通过可用界面轻松更改软件配置方面的能力。</td>
</tr>
<tr>
<td>Extensibility</td>
<td>系统的可扩展性。</td>
</tr>
<tr>
<td>Installability</td>
<td>系统在所有必要平台上安装的便捷性。它指软件在指定环境中安装和/或卸载的程度。</td>
</tr>
<tr>
<td>Leverageability/Reuse</td>
<td>跨多个产品利用通用组件的能力。它指开发人员在多个系统或构建其他资产中重复使用资产的程度。</td>
</tr>
<tr>
<td>Maintainability</td>
<td>开发人员修改、纠正或使其适应环境和/或需求变化的有效性和效率程度。</td>
</tr>
<tr>
<td>Portability</td>
<td>系统是否需要在多个平台上运行。它指开发人员将系统、产品或组件从一个硬件、软件或其他操作或使用环境转移到另一个环境的程度。</td>
</tr>
<tr>
<td>Supportability</td>
<td>应用程序所需的技术支持级别。系统中调试错误所需的日志记录及其他设施的级别。</td>
</tr>
<tr>
<td>Upgradeability</td>
<td>从该应用程序/解决方案的旧版本轻松/快速升级到新版本的能力。</td>
</tr>
</tbody></table>
<p>交叉架构特性指的是那些难以归类或超出传统类别，但却形成重要设计约束和考虑的特性。</p>
<table>
<thead>
<tr>
<th>特性</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>Accessibility</td>
<td>确保所有用户（包括色盲或听力障碍等残障用户）能够访问系统。它指使软件可供具有最广泛特征和能力的人使用。</td>
</tr>
<tr>
<td>Archivability</td>
<td>数据是否需要在一段时间后归档或删除。</td>
</tr>
<tr>
<td>Authentication</td>
<td>确保用户是其所声称的身份的安全要求。</td>
</tr>
<tr>
<td>Authorization</td>
<td>确保用户只能访问应用程序内特定功能（按用例、子系统、网页、业务规则、字段级别等）的安全要求。</td>
</tr>
<tr>
<td>Legal</td>
<td>系统在哪些法律约束下运行（数据保护、萨班斯-奥克斯利法案、GDPR 等）？公司需要哪些保留权利？关于应用程序构建或部署方式的任何规定。</td>
</tr>
<tr>
<td>Privacy</td>
<td>隐藏内部公司员工交易信息的能力（加密交易，甚至数据库管理员和网络架构师都无法查看）。</td>
</tr>
<tr>
<td>Security</td>
<td>数据是否需要在数据库中加密？内部系统之间网络通信是否需要加密？远程用户访问需要何种类型的认证？它指软件保护信息和数据的程度，以便人员或其他产品或系统具有与其授权类型和级别相称的数据访问程度。</td>
</tr>
<tr>
<td>Supportability</td>
<td>应用程序所需的技术支持级别。系统中调试错误所需的日志记录及其他设施的级别。</td>
</tr>
<tr>
<td>Usability/Achievability</td>
<td>用户使用应用程序/解决方案实现目标所需的培训水平。它指用户可以有效、高效、满意地使用系统达到预期目的。</td>
</tr>
</tbody></table>
<h4>2.3 架构特性选择</h4>
<p>架构特性不是越多越好：</p>
<ul>
<li><strong>增加系统设计的复杂性</strong>：每增加一个架构特性，都会使整个系统设计变得更加复杂。支持过多的架构特性会导致在架构师和开发人员开始解决核心业务问题之前，系统就变得越来越复杂。</li>
<li><strong>分散对核心问题的关注</strong>：架构特性定义了系统的成功标准，通常与系统的功能性正交，关注的是“如何”实现需求以及“为什么”做出某些选择。然而，如果过度追求特性数量，可能会导致偏离原始的业务问题，即开发软件的最初动机。</li>
<li><strong>每个特性都涉及权衡</strong>：软件架构中的每一个方面都存在权衡，有优点也有缺点。例如，在拍卖系统中，选择使用主题（topic）进行通信可能带来架构可扩展性的优势和服务的解耦，但会引入数据访问和数据安全方面的潜在问题，并且不支持异构契约。而使用队列（queue）则允许每个消费者拥有自己的契约，但不具备可扩展性，并且会增加服务间的耦合。架构师需要分析这些权衡，并根据业务驱动因素和环境选择最重要的特性。</li>
<li><strong>过度规范的危害</strong>：架构师过度规范架构特性是常见的陷阱，其破坏性不亚于规范不足，因为它会使系统设计过于复杂。历史案例“瓦萨号”战舰的失败就是一个例证，它是因为过度追求建造最宏伟的战舰（即过度规范架构特性）而最终导致沉没。</li>
<li><strong>陷入“意外复杂性”陷阱</strong>：架构师有时会为解决方案、图表和文档添加不必要的复杂性。正如一位作者所言，“开发者被复杂性吸引，就像飞蛾扑火一样——结果往往相同”。这种“意外复杂性”是由于人为地使问题复杂化，而不是问题本身固有的复杂性。通过识别子领域类型并根据其业务逻辑的复杂性选择合适的实现模式（例如，事务脚本和活动记录适用于简单业务逻辑，而领域模型和事件溯源领域模型适用于复杂的核心子领域），可以避免引入不必要的复杂性。</li>
<li><strong>设计应由业务驱动</strong>：领域驱动设计（DDD）的核心思想在于让业务领域驱动软件设计决策。这意味着设计决策应该基于业务领域的需求和战略，而非盲目地堆砌所有可能的架构特性。</li>
</ul>
<p>因此，与领域利益相关者合作时，架构师应努力使最终的架构特性列表尽可能短，因为每个特性都会增加总体系统设计的复杂性。</p>
<h3>3. 探索方案与决策</h3>
<blockquote>
<p>探索可选方案 ：至少寻找 2-3 个备选方案。</p>
<p>进行权衡分析：基于第二步定义的架构特性优先级，系统地对比各方案的优劣。</p>
<p>评估风险：每个方案可能引入哪些短期或长期的技术、成本、人员风险？</p>
<p>记录决策：使用 ADR (Architecture Decision Record) 记录最终选择和放弃的原因。</p>
</blockquote>
<h4>3.1 架构风格</h4>
<h5>3.1.1 分层架构 Layered Architecture</h5>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/layer.png" alt="3.1.1 分层架构"></p>
<p>分层架构的<strong>核心驱动力</strong>是<strong>关注点分离（Separation of Concerns）</strong>。它将一个复杂的系统按照不同的职责或技术关注点，垂直地划分成若干个水平的“层（Layer）”。</p>
<p>这些层之间存在一个至关重要的约束：<strong>依赖关系是单向的</strong>。通常来说，上层可以依赖下层，但下层绝对不能依赖上层。例如，表现层可以调用业务逻辑层，但业务逻辑层不应该知道任何关于表现层的具体实现细节。</p>
<p>优点：</p>
<ul>
<li><strong>简单性（Simplicity）和低成本（Cost）</strong>：分层架构模式非常成熟，广为人知，开发团队的学习成本极低。对于中小型项目、预算有限的初创公司或内部管理系统，它是一个&quot;足够好&quot;的、性价比极高的起点。</li>
<li><strong>可维护性（Maintainability）</strong>：如前所述，只要遵循了隔离层原则，系统的维护和迭代会非常清晰。对于那些业务逻辑相对稳定、变更不频繁的系统，这是一个巨大的优势。</li>
<li><strong>整体可部署性（Deployability）</strong>：分层架构天然倾向于构建<strong>单体应用（Monolith）</strong>。整个应用被打包成一个单元（例如一个 WAR 包或一个可执行文件）进行部署。这极大地简化了部署和运维的复杂度，尤其是在项目早期或运维能力有限的团队中。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>技术分区而非领域分区</strong>：分层架构是一种技术分区架构。这意味着它的组件是根据其在架构中的技术角色（如表示层、业务层、持久层），而不是根据业务领域（如客户、订单）进行分组的。这会导致任何特定的业务领域（例如“客户”领域）的逻辑都会分散在架构的所有层中。同时，当需要对特定业务领域的需求进行更改时，由于其逻辑分散在多个技术层中，开发人员必须在所有相关层中进行修改，这降低了开发的敏捷性。</li>
<li><strong>部署风险高</strong>：在分层架构中，即使是对少量代码的更改（例如，一个类文件中简单的三行更改），也需要重新部署整个部署单元。这种部署往往会捆绑数十个其他更改，从而显著增加了部署风险，且部署频率受到限制。</li>
<li><strong>测试范围大且不完整</strong>：由于整个应用程序是作为一个大型单体单元部署的，开发人员通常不会为简单的三行更改花费数小时执行完整的回归测试套件。这导致测试覆盖范围不完整，并且难以确保更改不会影响看似不相关的部分。</li>
</ul>
<h5>3.1.2 管道架构 Pipeline Architecture</h5>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250715105907327.png" alt="3.1.2 管道架构"></p>
<p>管道架构，又称为管道与过滤器架构（Pipes and Filters Architecture），是一种用于处理数据流的强大模式。它的核心思想非常直观，就像一条工厂的流水线：原材料从一端进入，经过一系列独立工站的加工、处理、检验，最终在另一端形成成品。</p>
<p>要理解管道架构，首先要理解它的两个基本构件：</p>
<ul>
<li><strong>过滤器 (Filter)</strong>：它是一个独立的、可执行的处理单元，负责接收数据、执行单一任务（例如转换格式、过滤内容、扩充信息），然后将处理后的数据传递出去。关键在于，每个过滤器都是**自包含（Self-Contained）<strong>和</strong>无状态（Stateless）**的，它不关心上一个过滤器是谁，也不关心下一个过滤器是谁。</li>
<li><strong>管道 (Pipe)</strong>：代表流水线上的&quot;传送带&quot;。它是一个<strong>单向</strong>的数据通道，负责将一个过滤器处理完的数据传递给下一个过滤器。</li>
</ul>
<p>过滤器一般又分为 4 种：</p>
<ul>
<li><strong>生产者 (Producer / Source)</strong>：作为整条管道的<strong>起点</strong>。它不接收来自管道的数据，而是负责创建数据，并将这些初始数据泵入管道。</li>
<li><strong>转换器 (Transformer)</strong>：它从上游管道接收数据，对其进行某种形式的<strong>修改或转换</strong>，然后将结果发送到下游管道。</li>
<li><strong>测试器 (Tester)</strong>：它接收数据，并根据一个或多个条件对数据进行<strong>检验</strong>。如果数据满足条件，就将其传递到下游管道；如果不满足，则数据流在此处被中断（或被导向另一条错误处理管道）。</li>
<li><strong>消费者 (Consumer / Sink)</strong>：作为整条管道的<strong>终点</strong>。它从上游管道接收最终处理好的数据，并将其消费掉，通常不会再将数据传递出去。</li>
</ul>
<p>优点:</p>
<ul>
<li><strong>成本低且简单</strong>：作为一种单体架构，管道架构不具备分布式架构风格所带来的复杂性，因此它简单易懂，并且构建和维护成本相对较低。</li>
<li><strong>高模块化</strong>：通过不同过滤器类型之间关注点的分离，实现了架构的模块化。任何过滤器都可以修改或替换而不影响其他过滤器。</li>
<li><strong>部署性和可测试性较好</strong>：由于其模块化程度较高，部署性和可测试性略优于分层架构，但仍受单体应用固有的部署仪式、风险和测试完整性等因素的影响。</li>
</ul>
<p>缺点:</p>
<ul>
<li><strong>单体特性带来的限制</strong>：尽管在模块化方面有所改进，但它仍然是一种单体应用。这意味着部署的仪式感、风险、部署频率以及测试的完整性都会受到单体特性的影响。例如，对任何更改都需要测试和部署整个单体应用。</li>
<li><strong>弹性低</strong>：由于其单体部署和缺乏架构模块化，管道架构的弹性评级非常低（一星）。尽管可以在单体内部实现某些功能的伸缩，但这通常需要复杂的设计技术，而管道架构并不擅长此道。</li>
<li><strong>可伸缩性差</strong>：与弹性类似，由于是单体架构且缺乏模块化，可伸缩性也评级很低。应用程序的伸缩能力受限于单一系统量子。</li>
<li><strong>性能一般</strong>：管道架构不适合高性能系统，因为它缺乏并行处理能力、存在闭合分层（closed layering）以及可能出现&quot;架构下沉&quot;（sinkhole anti-pattern）问题。</li>
</ul>
<h5>3.1.3 微核架构 Microkernel Architecture</h5>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250716105151041.png" alt="3.1.3 微核架构"></p>
<p>微核架构，也被称为<strong>插件化架构（Plug-in Architecture）</strong>，是一种能够提供极高扩展性、灵活性和演化能力的系统设计模式。它的核心思想是将系统功能划分为两部分：一个最小化的、稳定的**核心系统（Core System）<strong>和一个由独立</strong>插件组件（Plug-in Components）**构成的可扩展生态。</p>
<ul>
<li><strong>核心系统 (Core System)</strong>：这是架构的&quot;微核&quot;。它的职责被严格限制在最小且必要的范围内，通常只包含：<ol>
<li>系统运行所必需的通用业务逻辑（例如，一个 IDE 的文件管理和基础编辑器）。</li>
<li>一个至关重要的<strong>插件管理机制</strong>，包括插件的注册、发现、生命周期管理等。这是连接核心与插件的桥梁。</li>
</ol>
</li>
<li><strong>插件组件 (Plug-in Components)</strong>：这些是独立的、可插拔的模块，用于实现<strong>扩展功能或特定业务逻辑</strong>。每个插件都通过一个由核心系统定义的**标准契约（Standard Contract）**来与核心交互。这个契约通常是一个接口或一组 API。</li>
</ul>
<p>优点：</p>
<ul>
<li><strong>高模块化与扩展性</strong>：微内核架构通过插件组件实现了高度模块化和扩展性。应用程序逻辑被划分为核心系统和独立的插件组件，从而提供了可扩展性、适应性以及应用程序特性和自定义处理逻辑的隔离。任何插件都可以修改或替换而不影响其他组件，例如，添加一个新的电子设备评估逻辑只需添加一个新的插件组件并更新注册表。</li>
<li><strong>成本较低且相对简单</strong>：作为一种单体架构，微内核架构避免了分布式架构风格所带来的复杂性，因此它简单易懂，并且构建和维护成本相对较低。</li>
<li><strong>部署性和可测试性较好</strong>：由于其模块化程度较高，功能可以隔离到独立的插件组件中。如果做得好，这可以减少整体测试范围并降低部署风险，尤其是在运行时部署插件组件的情况下。因此，可部署性和可测试性略优于分层架构。</li>
<li><strong>领域与架构的同构性</strong>：微内核架构可以<strong>同时进行领域分区和技术分区</strong>。对于需要针对每个位置或客户端进行不同配置的问题，或者那些强调用户定制和功能扩展性的产品（例如 Jira 或Eclipse IDE），这种架构风格非常适用。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>单体特性带来的限制</strong>：尽管在模块化方面有所改进，但它<strong>仍然是一种单体应用</strong>。这意味着部署的仪式感、风险、部署频率以及测试的完整性都会受到单体特性的影响。</li>
<li><strong>弹性低</strong>：由于其单体部署和缺乏架构模块化，微内核架构的<strong>弹性评级非常低</strong>（一星）。尽管可以在单体内部实现某些功能的伸缩，但这通常需要复杂的设计技术。</li>
<li><strong>可伸缩性差</strong>：与弹性类似，由于是单体架构且缺乏模块化，可伸缩性也<strong>评级很低</strong>（一星）。所有请求都必须<strong>通过核心系统才能到达独立的插件组件</strong>。</li>
</ul>
<h5>3.1.4 基于服务的架构 Service-Based Architecture</h5>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250717114456233.png" alt="3.1.4 基于服务的架构 SBA"></p>
<p>如果说单体（Monolith）和微服务（Microservices）是两个广为人知的端点，那么基于服务的架构（Service-Based Architecture, SBA）就是它们之间那个常常被忽略，却又极具现实意义的&quot;务实中间派&quot;。它既非庞大到笨拙，也非精细到繁杂，为许多成长中的系统提供了一条平滑的演进路径。</p>
<p>SBA 的本质是一种将一个大型的单体应用，<strong>分解为少数几个、逻辑独立的、可独立部署的&quot;服务&quot;</strong> 的架构风格。SBA 的服务数量通常不多，一般在 <strong>4 到 12 个</strong>之间。它不像微服务那样追求极致的拆分（可能会有几十上百个服务），而是将应用按照**核心的业务领域（Domain）**进行划分。</p>
<p>与微服务不同的是 SBA 的典型实现是，所有服务共享<strong>同一个数据库</strong>。这种设计的初衷是为了在享受独立部署带来的好处的同时，最大限度地<strong>降低数据层面的复杂性</strong>。共享数据库可以：</p>
<ul>
<li><strong>简化开发</strong>：开发者无需处理复杂的分布式事务和跨服务数据同步问题。</li>
<li><strong>保证数据一致性</strong>：传统的 ACID 事务可以在数据库层面轻松实现。</li>
<li><strong>降低技术门槛</strong>：团队无需掌握复杂的分布式数据管理技术。</li>
</ul>
<p>随着业务发展，共享数据库的弊端会逐渐显现。在以下情况下，拆分数据库就成了合理的选择：</p>
<ol>
<li><strong>服务资源争用 (Service Contention)</strong>：某个服务（如高流量的商品浏览服务）对数据库产生巨大压力，影响了其他关键服务（如订单服务）的性能。</li>
<li><strong>数据隔离与安全 (Data Isolation and Security)</strong>：某个服务处理的数据高度敏感（如支付服务中的金融信息），需要从主数据库中物理隔离出来，以满足合规性或安全要求。</li>
<li><strong>技术栈不匹配 (Technology Mismatch)</strong>：某个服务有特殊的数据存储需求。例如，搜索服务最适合使用 Elasticsearch，而核心业务数据则存储在关系型数据库中。</li>
</ol>
<p>当这些情况发生时，SBA 允许你&quot;渐进式&quot;地将某个服务连同其数据一起剥离出去，赋予它独立的数据库。</p>
<p>优点：</p>
<ul>
<li><strong>可部署性 (Deployability)</strong>：这是最大的优势之一。每个服务都可以独立部署，使得发布更加频繁、风险更低。</li>
<li><strong>模块化 (Modularity)</strong>：通过按领域划分服务，实现了清晰的业务模块边界。</li>
<li><strong>可维护性 (Maintainability)</strong>：每个服务的代码库规模远小于整个单体，更易于理解、修改和维护。</li>
<li><strong>容错性 (Fault Tolerance)</strong>：一个服务的崩溃不会导致整个应用程序宕机（尽管共享数据库可能成为共同的故障点）。</li>
<li><strong>保留ACID事务</strong>：这是其相对于其他细粒度分布式架构（如微服务）的一大优势。由于领域服务是粗粒度的，事务通常限制在一个服务内部，可以利用传统的 ACID 事务来保证<strong>数据完整性和一致性</strong>。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>弹性低</strong>：尽管可以在单体内部实现某些功能的伸缩，但由于其单体部署和缺乏架构模块化，弹性评级仍然较低。</li>
<li><strong>可伸缩性受限</strong>：虽然可以扩展，但由于服务粒度较粗，与微服务等细粒度服务相比，在机器资源方面效率不高，成本效益也较低。</li>
<li><strong>部署风险</strong>：虽然比传统单体应用有所改进，但由于部署的代码量仍然较大，其<strong>部署风险</strong>仍然高于微服务架构。</li>
</ul>
<h5>3.1.5 事件驱动架构 Event-Driven Architecture</h5>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250718110824820.png" alt="3.1.5 事件驱动架构"></p>
<p>在传统的<strong>请求驱动模型</strong>中，系统接收请求后会确定性地、同步地将请求路由到各个请求处理器来处理数据。而事件驱动模型则不同，它<strong>对特定情况做出反应，并根据该事件采取行动</strong>。</p>
<p>EDA 的力量源泉来自于异步通信，它有以下优点：</p>
<ol>
<li><strong>极高的系统韧性与可用性 (Resiliency and Availability)</strong>：在同步调用中，如果服务 B 宕机，服务 A 的调用会立刻失败，导致整个链路中断。但在异步模式下，服务 A 将事件发送给一个中间人（消息代理），然后就可继续自己的工作。即使服务 B 此时宕机，事件也会被安全地存放在代理中，待 B 恢复后再进行处理。这使得系统能够优雅地处理局部故障，整体可用性大大提高。</li>
<li><strong>卓越的可伸缩性与弹性 (Scalability and Elasticity)</strong>：生产者和消费者被完全解耦，可以独立进行伸缩。如果事件产生的速度突然加快，我们只需要增加消费者实例的数量即可，而无需对生产者做任何改动。这种按需、独立伸缩的能力是构建高弹性系统的关键。</li>
</ol>
<p>典型的 EDA 有 2 种拓扑，分别为代理模式（broker）和中介者模式（mediator），二者最大的区别在于后者具有一个统一的协调者，这会对异常处理、全局统筹有很好的管控手段，当同时也牺牲了系统的解耦程度、灵活度和性能。</p>
<p>在 EDA 中，有几个典型的问题需要关注：</p>
<ul>
<li><p><strong>异常处理</strong>：可采用 workflow event pattern 工作流事件模式。事件处理后，如果失败了，就告知 <code>workflow process</code>。<code>workflow processor</code> 识别错误，如果能自动处理，就自动处理，并丢回原始队列中，重新执行。如果不能处理，就放到 dashbord 上，人工检查、校正或重试。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250725163511396.png" alt="workflow event pattern 工作流事件模式"></p>
</li>
<li><p><strong>数据丢失</strong>：发送事件到 channel 的路上、channel 转发事件到处理器的路上和处理器处理完持久化到 db 的路上都有可能发生数据的丢失。可以通过同步发送、持久化队列、ACK 机制和事务型 DB 来解决这个问题。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250725163644273.png" alt="防止 EDA 数据丢失的思路"></p>
</li>
<li><p><strong>返回响应</strong>：如果希望在事件驱动架构中实现请求-响应的能力，可以消息的两个元数据字段：<strong>回复地址 (Reply-To)</strong> 和 <strong>关联标识 (Correlation ID)</strong> 来通过回传通道返回响应数据。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250718132839093.png" alt="EDA 返回响应数据的处理思路"></p>
</li>
</ul>
<p>优点：</p>
<ul>
<li><strong>可伸缩性与弹性 (Scalability &amp; Elasticity)</strong>：独立伸缩组件的能力是其核心优势。</li>
<li><strong>可扩展性 (Extensibility)</strong>：系统极易扩展。当需要增加新功能时，只需开发一个新的服务来订阅感兴趣的现有事件即可，完全无需改动已有服务。</li>
<li><strong>响应性 (Responsiveness)</strong>：对于需要快速响应用户的系统，可以将耗时任务异步化。例如，用户提交视频后，系统立即返回&quot;上传成功，正在处理中&quot;，然后通过事件驱动后台的转码、审核等一系列复杂流程。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>简单性 (Simplicity)</strong>：EDA 显著增加了系统的复杂性。你需要管理消息代理，处理异步编程的挑战（如调试、错误处理），并应对最终一致性带来的心智负担。</li>
<li><strong>事务性 (Transactional)</strong>：实现跨多个服务的原子性操作（即分布式事务）变得异常困难。虽然可以通过 Saga 等模式来模拟长事务，但其实现复杂，且只能保证最终一致性而非强一致性。</li>
<li><strong>工作流的可观测性 (Observability of Workflow)</strong>：尤其是在代理拓扑中，业务流程被分散到各个独立的处理器中，没有一个集中的地方可以让你直观地看到一个完整的业务流程是如何执行的，这给监控和排错带来了巨大挑战。</li>
</ul>
<h5>3.1.6 空间架构 Space-Based Architecture</h5>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250721175147426.png" alt="3.1.6 空间架构"></p>
<p>传统三层 Web 拓扑在用户量剧增时呈倒三角：Web 层易横向扩容，数据库层最难扩容，最终成为性能上限。为削弱数据库瓶颈，业界先用本地缓存，再出现集中式分布式缓存，但网络跳转仍是热点。把数据直接放到每个处理节点的 <strong>复制型内存网格</strong> 并实时同步，才真正让数据库从&quot;同步路径&quot;上消失，空间架构由此成形。</p>
<p>空间架构的名称来源于**元组空间（Tuple Space）**多个并行处理器通过共享内存进行通信。SBA 的核心理念便是将应用数据保存在内存中（in-memory），并在所有活跃的处理单元（Processing Units）复制，从而移除中心数据库作为同步约束，实现近乎无限的伸缩性。</p>
<p>空间架构由以下几个部分组成：</p>
<ul>
<li><p><strong>处理单元 Processing Unit：</strong></p>
<ul>
<li><p>处理单元包含了<strong>应用逻辑</strong>（包括基于 Web 的组件和后端业务逻辑）。</p>
</li>
<li><p>它还包含一个<strong>内存数据网格</strong>和<strong>复制引擎</strong>，通常由 Hazelcast、Apache Ignite 或 Oracle Coherence 等产品实现。</p>
</li>
<li><p>处理单元可以包含小型、单一用途的服务，类似于微服务</p>
</li>
</ul>
</li>
<li><p>**虚拟化中间件 Virtualized Middleware：**虚拟化中间件负责处理架构中的基础设施问题，控制数据同步和请求处理。它由以下四个关键组件组成：</p>
<ul>
<li><p><strong>消息网格（Messaging Grid）</strong>：它负责将请求转发到任何可用的处理单元。</p>
</li>
<li><p><strong>数据网格（Data Grid）</strong>：它是 SBA 中最重要和关键的组件，通常在处理单元内部以复制缓存的形式实现。它确保每个处理单元都包含完全相同的数据，数据复制是异步且快速的。</p>
</li>
<li><p><strong>处理网格（Processing Grid）</strong>：这是一个可选组件，用于管理<strong>协调请求处理</strong>，当一个业务请求涉及多个处理单元时，它会协调这些处理单元之间的请求。</p>
</li>
<li><p><strong>部署管理器（Deployment Manager）</strong>：该组件根据负载条件管理处理单元实例的<strong>动态启动和关闭</strong>，对于实现应用的弹性伸缩至关重要。</p>
</li>
</ul>
</li>
<li><p><strong>数据泵 Data Pumps：<strong>数据泵是</strong>将数据发送到另一个处理器，然后该处理器更新数据库</strong>的方式。它们总是<strong>异步</strong>的，提供内存缓存与数据库之间的<strong>最终一致性（Eventual Consistency）</strong>。消息机制是数据泵的常用实现方式，因为它支持异步通信、保证消息传递和维护消息顺序。</p>
</li>
<li><p>**数据写入器 Data Writers：**数据写入器（Data Writers）负责接收来自数据泵的消息，并用消息中包含的信息更新数据库。它们可以是服务、应用或数据中心（如 Ab Initio）。写入器的粒度可以根据数据泵和处理单元的范围而变化，例如，领域驱动的数据写入器可以处理特定领域（如客户）内的所有更新。</p>
</li>
<li><p>**数据读取器 Data Readers：**负责从数据库读取数据，并通过反向数据泵将其发送到处理单元。服务需要通过数据读取器访问数据的情况有三种：</p>
<ol>
<li>所有相同命名缓存的处理单元实例都崩溃时。</li>
<li>所有相同命名缓存的处理单元需要重新部署时。</li>
<li>需要检索复制缓存中不包含的归档数据时。</li>
</ol>
</li>
</ul>
<p>空间架构最大的一个问题就是<strong>数据冲突</strong>，不同的 processing unit 处理同一个业务逻辑相关的数据时，由于数据同步存在时序问题，所以很容易出现数据不一致的情况。</p>
<p>可以从以下几个因素进行冲突概率的评估：</p>
<ul>
<li>N：处理相同缓存的 processing unit 的数量</li>
<li>UR：缓存更新频率</li>
<li>S：缓存大小</li>
<li>RL：缓存复制的延迟</li>
</ul>
<blockquote>
<p>CollisitionRate = N × (UR^2^/S)  × RL</p>
</blockquote>
<p>如果估算出来的冲突概率无法接受，或者需要缓存在内存中的业务数据过多而超过单机负载时，也可以使用<strong>分布式缓存</strong>来替代复制缓存。</p>
<p>优点：</p>
<ul>
<li><strong>弹性（Elasticity）</strong>：处理单元可以根据负载动态启停，实现高度弹性。</li>
<li><strong>伸缩性（Scalability）</strong>：通过内存数据缓存和移除数据库约束，支持处理数百万并发用户。</li>
<li><strong>性能（Performance）</strong>：移除了数据库瓶颈，提供了极高的性能。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>简洁性（Simplicity）</strong>：SBA 是一种<strong>非常复杂的架构风格</strong>，因为它涉及到缓存、最终一致性以及众多动态组件。</li>
<li><strong>可测试性（Testability）</strong>：由于需要模拟极高的伸缩性和弹性负载，<strong>测试复杂且成本高昂</strong>，许多高负载测试甚至需要在生产环境中进行，带来巨大风险。</li>
<li><strong>成本（Cost）</strong>：由于缓存产品许可费和高资源利用率，SBA 通常相对昂贵。</li>
</ul>
<h5>3.1.7 面向服务架构 Orchestration-Driven Service-Oriented Architecture</h5>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250722110031795.png" alt="3.1.7 面向服务架构"></p>
<p>编排驱动的面向服务架构（Orchestration-Driven Service-Oriented Architecture，简称 SOA）是一种在特定时代背景下演变而来的软件架构风格。它在 20 世纪 90 年代末企业快速扩张、需要更复杂的 IT 系统来适应增长的背景下出现。</p>
<ul>
<li><strong>资源稀缺性</strong>：在开源操作系统尚未被认为足够可靠用于严肃工作之前，操作系统和商业数据库服务器的许可费用昂贵且按机器收费。这导致架构师们被要求尽可能地实现<strong>重用</strong>，以优化成本。</li>
<li><strong>企业级重用</strong>：SOA 的一个主要目标是实现服务层面的重用，即逐步构建可随时间增量重用的业务行为。大型公司厌倦了重复编写软件，因此采取了逐步解决这个问题的策略。</li>
<li><strong>技术分层</strong>：这种架构风格也将<strong>技术分层</strong>理念推向了极致。其驱动哲学围绕着企业级的重用展开。</li>
</ul>
<p>这个架构在历史进程中是一个反面教材，它的核心思想就俩字：<strong>复用</strong>！</p>
<p>失败的最核心原因：过度重视技术，以技术为导向进行模块划分和复用尝试，而业务是不断演进变化的，最终技术与业务之间的隔阂无法弥补，功亏一篑。</p>
<p>其他原因还有：</p>
<ul>
<li>过度追求复用导致的高度耦合</li>
<li>编排引擎成为巨大的耦合点和瓶颈</li>
<li>技术分区带来的业务流程碎片化</li>
</ul>
<h5>3.1.8 微服务架构 Microservice Architecture</h5>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250723111539943.png" alt="3.1.8 微服务架构"></p>
<p>微服务架构的核心在于<strong>高度解耦</strong>。它<strong>倾向于复制而非耦合</strong>。这意味着，如果架构师的目标是高度解耦，那么他们会选择复制而不是重用。微服务通过物理上建模限界上下文（Bounded Context）的逻辑概念来实现高度解耦。</p>
<p>限界上下文（Bounded Context）是微服务设计理念的核心驱动力。这是一个源于领域驱动设计（DDD）的概念。限界上下文代表了一种<strong>解耦</strong>风格。在限界上下文内，与特定领域相关的所有内部组件（如代码和数据库模式）都是紧密耦合的，但它们与外部限界上下文的任何内容（如其他数据库或类定义）是<strong>解耦</strong>的。</p>
<p>这种隔离使得每个服务可以<strong>独立演进</strong>，定义其自身所需的一切，而不必适应其他部分的约束。它<strong>避免了传统单体架构中常见的共享类和数据库作为集成点导致的紧密耦合问题</strong>。</p>
<p>所以微服务也是一个典型的领域分区架构，并且它倾向于将领域分区推到极致。</p>
<p>在划分微服务粒度时，以下三个方面是需要重点考虑的：</p>
<ol>
<li><strong>目的（Purpose）</strong>：微服务的首要目的应该是<strong>捕获一个领域或工作流</strong>。理想情况下，每个微服务都应该具有<strong>极高的功能内聚性</strong>，为整个应用程序贡献一个<strong>重要的行为</strong>。这意味着，服务应该专注于一个单一的、明确的业务功能。</li>
<li><strong>事务（Transactions）</strong>：限界上下文是业务工作流，通常需要<strong>在事务中协作的实体</strong>可以为服务边界提供良好的指示。由于分布式事务在分布式架构中会带来复杂性，架构师应尽量设计系统以<strong>避免跨服务的事务</strong>。如果需要跨服务事务，这可能表明服务粒度过细。事务边界通常是服务粒度的常见指标。</li>
<li><strong>通信（Communication）</strong>：如果一组服务为了完成功能而需要<strong>大量通信</strong>，那么将这些服务捆绑成一个更大的服务可能有助于<strong>避免过度的通信开销</strong>。换句话说，如果服务变得过于“多话”（chatty），频繁地相互调用，那么它们的边界可能需要重新评估，以减少不必要的<strong>全局复杂性</strong>。</li>
</ol>
<p>此外，业界也有一些其他的常用的判断方法：</p>
<ol>
<li><strong>变更频率</strong>：把一起变更/部署的东西放在一个服务，频率不同的拆开。</li>
<li><strong>耦合指标</strong>：如果拆分后跨服务调用暴增，说明拆太细；反之，如果内部复杂度过高且团队协作困难，可能太粗。</li>
<li><strong>认知负荷</strong>：一个团队能完全理解并独立维护的范围通常就是一个合理服务边界。</li>
</ol>
<p>在微服务架构中，有几个典型的问题需要关注：</p>
<ul>
<li><p><strong>基础设施复用</strong>：虽然微服务倾向于复制而非耦合，不过这更多是在业务层面，对于运维层面的基础设施，包括但不限于：<strong>监控（Monitoring）</strong>、<strong>日志记录（Logging）</strong>、<strong>断路器（Circuit Breakers）<strong>和</strong>服务发现（Service Discovery）</strong>，微服务是主张进行统一建设和复用的。</p>
</li>
<li><p><strong>服务协作方式</strong>：一般有编舞和编排 2 种协作方式：</p>
<ul>
<li><strong>编舞（Choreography）</strong>：是指多个服务<strong>相互之间直接通信</strong>，而<strong>没有中央协调器</strong>。服务（如同舞者）根据彼此发出的事件或信息自主响应和行动。</li>
<li><strong>编排（Orchestration）</strong>：是指通过一个<strong>单独的协调器服务</strong>来管理和控制工作流中多个服务的协调。协调器（如同乐队指挥）负责指导每个服务的执行顺序，并处理整个业务流程的状态和错误。在微服务中，架构师可以创建<strong>局部化的协调器服务</strong>来处理复杂的业务流程。</li>
</ul>
<p>微服务两者都支持。 不过编舞方式更符合微服务的高度解耦哲学，因为它不依赖于中央协调器，而是通过解耦的事件来实现通信，使用起来更简便。当然，在复杂的业务流程中，<strong>编舞环境下的错误处理和协调会变得更加复杂</strong>。如果业务流程<strong>本质上是耦合的</strong>，此时编排可能更为适合。</p>
</li>
<li><p>**数据一致性：**微服务主张尽可能避免分布式事务的问题，如果多个服务经常需要处理分布式事务问题，那最好将它们合而为一，直接在一个 ACID 事务中完成。在万不得已的时候，也可以采用如 saga 和最终一致性、人工补偿等方式来缓解数据一致性问题。</p>
</li>
</ul>
<p>优点：</p>
<ul>
<li><strong>高度解耦与小部署单元</strong>：微服务架构极力推崇<strong>高度解耦</strong>。每个服务都是<strong>极小的部署单元</strong>，且具备<strong>高度的独立性</strong>。这种解耦使得团队可以独立地开发、测试和部署服务，大大减少了对其他服务的依赖，从而提高了敏捷性。</li>
<li><strong>DevOps 革命与自动化</strong>：微服务架构的成功离不开 <strong>DevOps 革命和对操作关注点的自动化</strong>。自动化部署、自动化测试等现代工程实践是微服务存在的基础，它们极大地提高了部署频率、降低了部署风险，并保证了测试的完整性。</li>
<li><strong>更快的变更响应速度</strong>：由于服务范围小且高度解耦，当业务需求发生变化时，团队只需修改受影响的少量服务，而不是整个大型单体。这种<strong>增量式的演进</strong>能力使得组织能够<strong>更快地响应市场变化，提高时间到市场（time-to-market）的速度</strong>。</li>
<li><strong>单一职责与清晰边界</strong>：每个微服务都专注于一个<strong>单一的业务功能或领域</strong>。这种清晰的职责边界使得开发人员更容易理解、测试和维护代码，因为他们不必处理与服务无关的复杂性</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>网络调用开销（Network Call Overhead）</strong>：微服务是分布式架构。这意味着服务之间（乃至用户界面与服务之间）的通信需要通过网络进行。网络调用比本地方法调用耗时更长。当一个业务请求需要链式调用多个微服务时，累积的网络延迟会显著影响整体响应时间。</li>
<li><strong>安全验证开销（Security Verification Overhead）</strong>：在微服务架构中，由于每个服务都是独立的部署单元，因此每个服务端点都需要进行安全验证。这增加了额外的处理时间。这种“在每个入口处进行安全检查”的模式进一步降低了同步、高度分布式架构（如微服务）的性能。</li>
<li><strong>高复杂性（Complexity）</strong>：作为一种分布式架构，微服务固有的缺点在于运行时连接各个部分所带来的复杂性，为了解决由此带来了一系列问题，需要学习、使用甚至开发一系列的组件，会给团队带来更大的心智负担和运维难度。</li>
<li><strong>数据一致性（Data Consistency）</strong>：如上所述，但无法避免分布式事务时，为了处理数据一致性问题，会引入很大的非业务复杂性。</li>
</ul>
<h4>3.2 架构选择</h4>
<p>软件架构第一原理：<font color="red"><strong>一切都是权衡</strong></font>。</p>
<p>软件架构第二原理：<font color="red"><strong>为什么比如何更重要</strong></font>。</p>
<p>在选择架构时，最典型的 3 个问题：</p>
<ol>
<li>单体还是分布式架构？</li>
<li>数据存在哪里？</li>
<li>异步还是同步通信？</li>
</ol>
<h5>3.2.1 单体 vs 分布式</h5>
<p>当团队规模有限、需求节奏温和，而且必须尽快交付可用版本时，单体依旧是上市速度最快且认知成本最低的形态：所有模块共用同一进程，Debug、部署、回滚都异常直接。</p>
<p>然而，随着业务子域越来越多、发布节奏愈发碎片化，巨石应用往往演变成&quot;所有人都必须一起上线或一起停机&quot;的瓶颈。此时把系统拆成若干服务，允许各自独立发布，能显著缓解排期冲突；同时也可以针对流量热点的子域单独扩容，而非整包扩容。</p>
<p>带来的复杂度在于网络调用、链路追踪、容错和 DevOps 自动化，一旦这些配套不到位，分布式的优势就会被运维复杂度和认知成本抵消。换言之，拆分前要先确认组织是否具备持续交付、自动化监控、故障演练等能力，否则分布式只会把&quot;技术债&quot;换成&quot;组织债&quot;。</p>
<h5>3.2.2 数据存储</h5>
<p>如果系统只处理核心交易并且对强一致性要求极高，一体化的关系数据库依旧能提供最成熟、最易掌控的事务保障。随着并发数和存储量攀升，分库分表成为横向扩展的常规做法，但需要额外的分布式事务模式或 Saga 来保证业务完整性。</p>
<p>如果读写模式呈现极强的峰谷或结构多变，就非常适合引入键值、文档、列式乃至时序、图数据库等多模型共存策略。这样做的关键在于为每一类数据访问场景挑选最经济的存储形式，同时在数据治理层面清晰定义数据主权、法务合规和生命周期。</p>
<h5>3.2.3 同步 vs 异步</h5>
<blockquote>
<p><strong>一般原则：优先使用同步通信，必要时使用异步通信。</strong></p>
</blockquote>
<p><strong>同步调用</strong>（如 REST 或 gRPC）带来的是即时反馈和易于调试的调用链，适用于用户交互需要立刻响应的场合。然而它也拉高了两个服务在时间维度上的耦合：只要任意环节超时或故障，整个链路都会受影响。</p>
<p><strong>异步消息</strong>则通过中间件把调用方与被调用方解耦，让系统可以削峰填谷并获得天然的弹性缓冲区；代价是业务体验不再“即时”，而且需要额外处理幂等、重复消费、消息顺序、死信等问题。通常情况下，读取或修改单一资源这一类“命令/查询”仍倾向同步；任务排队、事件通知、工作流编排与数据集成则更适合异步。若核心场景必须保证强一致性，仍可采用同步事务或锁；而能够容忍短暂的不一致时，则转而采用事件驱动的最终一致模式。</p>
<h4>3.3 风险评估</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250725170843538.png" alt="架构风险评估矩阵"></p>
<p><strong>风险的影响面（Impact）</strong>：这个维度主要评估一旦风险发生，会对系统、业务、用户产生多大程度的负面影响。</p>
<ul>
<li><strong>低影响（Low）：</strong> 影响范围小，可控性强。例如，某个非核心模块的性能略有下降，只影响少量用户，且有明确的降级方案。或者，故障恢复时间短，对整体业务影响微乎其微。</li>
<li><strong>中影响（Medium）：</strong> 影响范围较大，但可控。例如，系统某个核心功能出现短暂不可用，影响部分用户，但可以通过人工干预或备用方案快速恢复。业务运营会受到一定影响，但不会造成灾难性的损失。</li>
<li><strong>高影响（High）：</strong> 影响范围广，失控性强。例如，系统核心服务大面积宕机，导致业务全面停止。或者，数据出现严重损坏，造成不可挽回的损失。</li>
</ul>
<p><strong>风险出现的可能性（Likelihood）</strong>：这个维度主要评估风险发生的概率。</p>
<ul>
<li><strong>低可能性（Low）：</strong> 发生概率很小。例如，系统依赖的某个成熟、稳定的第三方服务，过去几年从未出现过故障。或者，经过充分的测试和验证，某个技术方案的潜在问题已经被基本排除。</li>
<li><strong>中可能性（Medium）：</strong> 发生概率一般。例如，某个新技术或新组件，虽然经过了小规模测试，但在大规模生产环境下的表现还未得到充分验证。或者，架构依赖的某个外部系统，其 SLA（服务等级协议）历史记录显示偶尔会出现短暂的抖动。</li>
<li><strong>高可能性（High）：</strong> 发生概率很高。例如，在高峰期对数据库进行无主键大批量更新操作，必然会导致锁表和性能问题。或者，系统设计存在明显的单点故障，一旦该节点出现问题，整个系统就会瘫痪。</li>
</ul>
<p>在分析时，不要企图一次性对所有的架构特性进行分析，拆开了，逐一击破，避免一次性关注点太多，从而不知所向。</p>
<h4>3.4 架构决策</h4>
<h5>3.4.1 Anti-Pattern1: Covering Your Assets</h5>
<blockquote>
<p>害怕承担责任，总是希望有更高级别的人来拍板。决策过程变得极其缓慢，甚至为了规避风险而选择最保守、最平庸的技术方案，而不是最合适的方案。</p>
</blockquote>
<p>应对方案：</p>
<ul>
<li><strong>Fact（事实）:</strong> 聚焦于客观事实和数据。在做技术选型或架构决策时，不要只凭感觉或经验，而是要基于事实，如性能测试报告、技术预研结果、业界最佳实践、开源社区活跃度等。当所有人都基于事实说话时，决策的对错就更容易被评估和追溯，而非个人责任。</li>
<li><strong>Options（可选方案）:</strong> 明确列出所有可行的备选方案，并分析它们的优缺点、成本、风险和收益。当一个决策有多个清晰的选项时，团队可以共同讨论和权衡，而不是只盯着一个保守方案不放。</li>
</ul>
<p>实践建议：</p>
<ul>
<li><strong>建立决策评审机制：</strong> 明确谁是最终的决策者（DRI - Directly Responsible Individual），并设立评审环节。评审会上，每个人都应基于数据和事实来论证自己的观点。</li>
<li><strong>鼓励小步快跑和 PoC：</strong> 对于有争议的技术方案，可以先用小规模的 PoC（概念验证）项目来验证其可行性。用实际结果说话，而不是让大家停留在理论争辩。</li>
</ul>
<h5>3.4.2 Anti-Pattern2: Groundhog Day</h5>
<blockquote>
<p>团队成员在每次会议上都重复同样的讨论，无法达成共识。由于没有明确的决策记录或决策依据，导致下一次讨论又回到原点。</p>
</blockquote>
<p>应对方案：</p>
<ul>
<li><strong>Subject（主题）:</strong> 在每次讨论前，都必须有一个明确的、聚焦的 <strong>Subject</strong>。这次会议要讨论什么？目标是什么？是决定数据库选型？还是讨论消息队列的方案？有了明确的主题，才能避免讨论跑偏。</li>
<li><strong>Decision（决策）:</strong> 讨论结束后，必须得出一个明确的 <strong>Decision</strong>。决策是什么？为什么做出这个决策？这个决策有哪些局限性？明确记录下来，并让所有人都知晓。</li>
</ul>
<p>实践建议：</p>
<ul>
<li><strong>会议纪要：</strong> 每次关键的架构讨论后，都必须有正式的会议纪要。纪要中要包含：<strong>讨论主题、所有备选方案、最终决策、决策依据以及未被采纳方案的理由</strong>。</li>
<li><strong>设立时间限制：</strong> 在讨论时，可以为每个议题设定一个时间限制。如果超过时间仍无法达成一致，可以先暂停，让大家会后去搜集更多数据，再进行下一轮讨论。</li>
</ul>
<h5>3.4.3 Anti-Pattern3: Email-Driven Architecture</h5>
<blockquote>
<p>重要的架构决策都散落在团队成员的邮件、聊天记录或者 Wiki 的各个角落，没有一个集中的、可检索的知识库。当新成员加入或需要回顾历史决策时，很难找到完整的信息。</p>
</blockquote>
<p>应对方案：</p>
<ul>
<li><strong>Subject（主题） 和 Decision（决策）:</strong> 这两个元素是解决这个问题的核心。架构决策不应该只是一个口头或邮件的结论，而是一个完整的 <strong>ADR（Architecture Decision Record）</strong>。ADR 本身就是一个以主题和决策为核心的文档。</li>
</ul>
<p>实践建议：</p>
<ul>
<li><strong>建立 ADR 制度：</strong> 强烈建议引入 ADR 机制。</li>
<li><strong>使用统一的知识管理平台：</strong> 将所有 ADR 存放在一个统一的、可检索的知识管理平台（如飞书文档, Wiki 或 Git）。这样，团队成员可以轻松地查阅历史决策，新成员也能快速理解系统的演进过程。</li>
</ul>
<h5>3.4.4 架构决策记录 ADR</h5>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/fosa-adr.png" alt="架构决策记录 ADR" style="zoom:33%;" />

<p><strong>TITLE（标题）</strong></p>
<ul>
<li><strong>解释：</strong> 标题应该简短、清晰地描述这个 ADR 的核心决策是什么。</li>
<li><strong>示例：</strong> &quot;使用 RabbitMQ 替代 Kafka 作为消息队列&quot; 或 &quot;将数据库从 MySQL 切换到 PostgreSQL&quot;。</li>
<li><strong>作用：</strong> 让读者一眼就能明白这份文档的主题。一个好的标题本身就包含了 <strong>Subject</strong>。</li>
</ul>
<p><strong>STATUS（状态）</strong></p>
<ul>
<li><strong>解释：</strong> ADR 的生命周期状态。通常包括以下几种：<ul>
<li><strong>Proposed（提案中）：</strong> 决策还在讨论阶段，尚未被团队接受。</li>
<li><strong>Accepted（已接受）：</strong> 决策已经通过，可以开始实施。</li>
<li><strong>Superseded（已废弃）：</strong> 这个决策已经被新的 ADR 替代。这对于追踪架构演变历史非常重要。这样要<u><strong>链接到新的 ADR</strong></u>，方便追溯！</li>
</ul>
</li>
<li><strong>作用：</strong> 帮助团队成员了解该决策的当前状态，避免对过时或仍在讨论中的方案产生误解。</li>
</ul>
<p><strong>CONTEXT（背景）</strong></p>
<ul>
<li><strong>解释：</strong> 为什么要做出这个决策？它试图解决什么问题？这里应该详细描述问题的来龙去脉、约束条件以及技术或业务驱动因素。</li>
<li><strong>示例：</strong> &quot;我们现有的系统在处理高并发订单时，MySQL 数据库的写入性能出现了瓶颈，导致订单处理延迟。&quot;</li>
<li><strong>作用：</strong> 提供决策的 <strong>Fact</strong>（事实），让读者理解决策背后的原因，而不是孤立地看待决策本身。</li>
</ul>
<p><strong>DECISION（决策）</strong></p>
<ul>
<li><strong>解释：</strong> 明确描述最终的决策是什么，并给出相应的理由。这个部分是整个 ADR 的核心。</li>
<li><strong>示例：</strong> &quot;我们决定将订单处理服务从同步调用改为异步消息队列。备选方案是采用 Kafka，但我们最终选择了 RabbitMQ，原因是 RabbitMQ 具有更完善的路由机制和更稳定的交付保障，更适合我们对消息可靠性的高要求。&quot;</li>
<li><strong>作用：</strong> 记录决策的 <strong>Decision</strong> 和 <strong>Options</strong>。它清晰地表明我们做了什么选择，以及为什么没有选择其他方案。</li>
</ul>
<p><strong>CONSEQUENCES（影响）</strong></p>
<ul>
<li><strong>解释：</strong> 这个决策会带来什么后果？包括积极的和消极的。</li>
<li><strong>示例：</strong><ul>
<li><strong>积极影响：</strong> &quot;订单处理性能将得到显著提升，系统的可扩展性增强。&quot;</li>
<li><strong>消极影响：</strong> &quot;引入 RabbitMQ 会增加运维复杂性，团队需要学习新的技术栈。需要额外投入人力进行开发和部署。&quot;</li>
</ul>
</li>
<li><strong>作用：</strong> 帮助团队全面评估决策的利弊，提前预见潜在的风险和挑战。这与我们之前讨论的风险评估中的「风险的影响面」有异曲同工之妙。</li>
</ul>
<p><strong>COMPLIANCE（遵循）</strong></p>
<ul>
<li><strong>解释：</strong> 如何确保团队会遵循这个决策？这个部分更多是关于实践和治理。</li>
<li><strong>示例：</strong> &quot;新开发的订单服务必须通过 RabbitMQ 进行异步通信。代码评审时，需要检查是否遵守此规范。运维团队需要负责 RabbitMQ 集群的部署和监控。&quot;</li>
<li><strong>作用：</strong> 将抽象的决策转化为具体的行动和规范，确保决策能够真正落地。</li>
</ul>
<p><strong>NOTES（备注）</strong></p>
<ul>
<li><strong>解释：</strong> 用于记录一些额外的元数据，例如：文档的作者、创建日期、链接到相关的 Jira 工单或会议记录等。</li>
<li><strong>作用：</strong> 便于管理和追溯文档。</li>
</ul>
<pre><code class="language-markdown">**ADR #001 - 使用 RabbitMQ 替代 Kafka 作为消息队列**
---
**TITLE（标题）**

将消息队列从 Kafka 切换至 RabbitMQ

**STATUS（状态）**

Accepted（已接受）

**CONTEXT（背景）**

我们现有的订单服务在业务高峰期时，订单创建和扣减库存的同步处理流程出现了严重的性能瓶颈。MySQL 数据库的写入操作成为单点瓶颈，导致订单处理延迟增加，甚至出现超时。为了解决这一问题，我们决定引入消息队列，将订单创建的后续流程（如库存扣减、积分发放）改为异步处理。

在技术选型阶段，团队提出了两个主要的备选方案：Kafka 和 RabbitMQ。我们希望找到一个能满足以下需求的消息队列：

1.  **高可靠性：** 消息不能丢失，即使在消费者故障或重启时。
2.  **消息时效性：** 消息需要被及时处理，不接受长时间的延迟。
3.  **灵活的路由：** 能够根据不同的业务场景，将消息发送到不同的消费者。
4.  **易于运维：** 团队需要能快速上手，运维成本不能过高。

**DECISION（决策）**

我们决定采用 **RabbitMQ** 作为新的消息队列，用于实现订单处理流程的异步化。

**核心理由：**

* **消息路由的灵活性：** RabbitMQ 提供了多种 exchange 类型（如 direct, fanout, topic），可以实现非常灵活的消息路由。这使得我们可以轻松地根据不同的订单类型或业务事件（例如，秒杀订单、普通订单）将消息发送到不同的消费者队列，满足未来的业务扩展需求。
* **消息的可靠性：** RabbitMQ 提供了成熟的持久化机制（Durable Queues）和消息确认机制（Publisher Confirms），能确保即使在 RabbitMQ 本身或消费者故障时，消息也不会丢失。这对订单处理这种核心业务至关重要。
* **团队学习曲线：** 团队成员在内部技术分享中对 RabbitMQ 的概念（exchange, queue, binding）有了一定的了解，学习成本相对可控。

**备选方案的局限性：**

* **Kafka：** Kafka 的核心设计思想是基于日志和分区，其路由能力相对较弱，主要通过 topic 和分区来实现消息分发。虽然可以通过消费者组来实现负载均衡，但在某些复杂路由场景下，需要额外的开发工作来适配。同时，Kafka 在保证单条消息的精确可靠投递方面，实现起来比 RabbitMQ 复杂一些，而这正是我们当前业务最关注的点。

**CONSEQUENCES（影响）**

* **积极影响：**
    * 显著提升订单处理的并发能力和吞吐量，缓解数据库写入瓶颈。
    * 提升系统的可扩展性，未来可以方便地增加更多异步消费者服务。
    * 系统的响应时间将大大缩短，提升用户体验。

* **消极影响：**
    * 引入 RabbitMQ 会增加系统的运维复杂性，需要额外的监控和维护工作。
    * 团队需要投入时间学习和掌握 RabbitMQ 的相关知识，尤其是如何处理消费者故障、消息死信等问题。
    * 系统架构复杂度增加，需要重新设计和实现订单服务与消息队列的集成部分。

**COMPLIANCE（遵循）**

* 所有与订单相关的异步化处理流程，必须通过 RabbitMQ 进行通信。
* 新的服务代码必须严格遵循消息持久化和确认机制，以确保消息不丢失。
* 运维团队负责 RabbitMQ 集群的部署、监控和维护，并确保其高可用性。
* 在代码评审时，需要确保新引入的异步化服务遵循此 ADR 的设计规范。

**NOTES（备注）**

* **作者：** Gemini AI
* **创建日期：** 2025-07-26
* **关联工单：** PROJECT-1234 - 订单服务高并发性能优化
* **相关会议记录：** 架构评审会议 [2025-07-25]
</code></pre>
<h3>4. 设计实施路径与验证机制</h3>
<blockquote>
<p>实施计划：是否需要技术原型 (PoC) 来验证关键难点？如何进行任务拆解和里程碑规划？</p>
<p>构建适用度函数：针对第二步定义的关键架构特性，设计具体的检验尺。</p>
<p>知识沉淀：准备好核心的架构图、设计文档等。</p>
</blockquote>
<h4>4.1 实施计划</h4>
<p><strong>技术原型 (PoC) 来验证关键难点</strong>：架构师应频繁进行概念验证 (PoC)，以验证架构决策的可行性，并深入了解实施细节。PoC有助于比较不同解决方案，并评估性能、可伸缩性等架构特性。建议架构师在进行PoC时编写生产质量的代码，这是架构师可以用于保持编码手感的有效手段，同时一次性的 PoC 代码往往会成为团队的参考架构。</p>
<p><strong>任务拆解和里程碑规划</strong>：组件识别和架构设计是一个迭代过程，通过反馈不断优化。<strong>敏捷方法论</strong>支持迭代开发和快速反馈，有助于架构师在实践中调整决策。架构师还需要平衡架构工作和实际编码，通过<strong>委派核心路径代码</strong>，避免成为团队瓶颈。</p>
<h4>4.2 适应度函数</h4>
<p><strong>适应度函数是架构治理的核心工具</strong>。它是一种<strong>客观的函数</strong>，用于衡量代码复杂度和架构特性，并<strong>自动化验证</strong>开发团队是否遵循了架构决策和设计原则。适应度函数应<strong>集成到 CI/CD 流程中</strong>，在代码集成时自动检查合规性，从而避免问题积累。</p>
<ul>
<li><strong>检测循环依赖</strong>：可编写适应度函数来检测并防止组件之间的循环依赖，因为这会损害模块化（例如，使用 <strong>JDepend</strong> 工具）。这有助于维护架构中“重要但不紧急”的实践。</li>
<li><strong>分层架构合规性</strong>：利用<strong>ArchUnit</strong>（Java）或<strong>NetArchTest</strong>（.NET）等工具，可以确保分层架构中各层之间的访问限制被遵守。例如，限制表现层不能直接访问数据库，而必须通过业务层和持久层。</li>
<li><strong>验证距主序列距离</strong>：通过适应度函数验证代码抽象性与不稳定性之间的平衡。</li>
<li><strong>自动化编码标准合规性</strong>：例如，检查特定类是否包含必需的注解。</li>
</ul>
<h4>4.3 知识沉淀</h4>
<ul>
<li><strong>ADR</strong>：将每一次关键决策及其动机、权衡、后果记录下来，形成可检索的决策日志。</li>
<li><strong><a href="https://c4model.com/">C4 架构图</a></strong>：在每个里程碑输出更新后的系统上下文、容器、组件图，配合 ADR 链接。</li>
</ul>
<h4>4.4 管理松紧度</h4>
<p>架构师需要根据团队实际情况采用恰到好处的管理松紧度，才能发挥团队的最大潜力。</p>
<p>采取哪种管理松紧度，可以从几个方面进行考量：</p>
<ul>
<li><strong>team familiarity</strong>：团队内部的熟悉程度，越不熟悉，越需要更多投入。</li>
<li><strong>team size</strong>：团队大小，团队越大， 越需要投入。</li>
<li><strong>overall experience</strong>：团队经验，新人越多，越需要投入。</li>
<li><strong>project complexity</strong>：项目越复杂，越需要投入。</li>
<li><strong>project duration</strong>：项目周期，周期越长，越需要投入。</li>
</ul>
<p>按照这 5 个方面，极限 tight 是 20 分，极限 loose 是 -20 分，进行综合评价，看看自己是应该扮演什么角色。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250727144525753.png" alt="管理松紧度计算表盘"></p>
<h3>5. 部署、观测与效果衡量</h3>
<blockquote>
<p>持续交付：作为将设计快速、可靠地部署到生产环境的手段。</p>
<p>系统监控：观测系统的健康状况（CPU、内存、延迟、错误率等）。</p>
<p>业务指标验证：验证是否达成了第一步定义的商业目标？例如，新架构上线后，用户转化率是否真的提升了？</p>
</blockquote>
<h4>5.1 持续交付与部署自动化</h4>
<p>持续交付 (CI/CD) 是架构落地的关键环节。FOSA 强调，现代软件架构的成功离不开 DevOps 革命和对操作关注点的自动化。持续交付不仅仅是技术实践，更是组织文化的体现。</p>
<p>核心要素：</p>
<ul>
<li><p>自动化构建与测试：每次代码提交都触发自动化的构建、单元测试、集成测试流程，确保代码质量。</p>
</li>
<li><p>环境一致性：通过容器化技术（如 Docker）和基础设施即代码（IaC）确保开发、测试、生产环境的一致性。</p>
</li>
<li><p>渐进式部署：采用蓝绿部署、金丝雀发布等策略，降低部署风险，实现零停机时间。</p>
</li>
<li><p>快速回滚机制：当新版本出现问题时，能够快速回滚到上一个稳定版本。</p>
</li>
</ul>
<p>架构师职责：</p>
<ul>
<li><p>设计适合团队规模的 CI/CD 流水线</p>
</li>
<li><p>确保架构决策能够通过自动化流程得到验证</p>
</li>
<li><p>平衡部署频率与系统稳定性</p>
</li>
</ul>
<h4>5.2 系统监控与可观测性</h4>
<p>可观测性 (Observability) 是现代分布式系统的生命线。FOSA 指出，在分布式架构中，一个请求可能会流经数十个甚至上百个服务，要诊断一个问题，需要建立复杂的可观测性体系。</p>
<p>三大支柱：</p>
<ol>
<li><p>指标 (Metrics)：量化系统性能的关键指标</p>
<ul>
<li><p>RED 方法：Rate（请求率）、Error（错误率）、Duration（延迟）</p>
</li>
<li><p>USE 方法：Utilization（利用率）、Saturation（饱和度）、Errors（错误）</p>
</li>
<li><p>业务指标：用户转化率、订单成功率、收入增长率</p>
</li>
</ul>
</li>
<li><p>日志 (Logs)：记录系统运行时的详细信息</p>
<ul>
<li><p>结构化日志：使用 JSON 格式，便于机器解析</p>
</li>
<li><p>日志聚合：集中收集、存储和分析日志</p>
</li>
<li><p>日志级别：根据重要性设置不同的日志级别</p>
</li>
</ul>
</li>
<li><p>追踪 (Tracing)：追踪请求在分布式系统中的完整路径</p>
<ul>
<li><p>分布式追踪：为每个请求分配唯一 ID，追踪其在整个调用链中的路径</p>
</li>
<li><p>链路追踪：记录服务间的调用关系和耗时</p>
</li>
<li><p>性能分析：识别系统瓶颈和性能热点</p>
</li>
</ul>
</li>
</ol>
<p>监控策略：</p>
<ul>
<li><p>分层监控：从基础设施层到应用层，建立完整的监控体系</p>
</li>
<li><p>告警策略：设置合理的告警阈值，避免告警疲劳</p>
</li>
<li><p>可视化仪表板：为不同角色提供定制化的监控视图</p>
</li>
</ul>
<h4>5.3 业务指标验证与闭环反馈</h4>
<p>业务指标验证是架构设计的闭环关键。FOSA 强调，技术架构的最终目标是服务于业务价值，因此必须验证是否达成了第一步定义的商业目标。</p>
<p>验证流程：</p>
<ol>
<li>建立基线：在架构变更前，记录关键业务指标的当前状态</li>
<li>设定目标：基于第一步的商业目标，设定具体的量化指标</li>
<li>持续监控：在架构部署后，持续跟踪业务指标的变化</li>
<li>效果评估：定期评估架构变更对业务指标的实际影响</li>
</ol>
<p>常见业务指标：</p>
<ul>
<li><p>用户相关：日活跃用户数、用户留存率、用户满意度</p>
</li>
<li><p>业务相关：订单转化率、客单价、复购率</p>
</li>
<li><p>技术相关：系统可用性、响应时间、错误率</p>
</li>
</ul>
<p>A/B 测试策略：</p>
<ul>
<li><p>在架构变更时，可以考虑 A/B 测试来验证效果</p>
</li>
<li><p>对比新旧架构在相同条件下的业务表现</p>
</li>
<li><p>基于数据做出是否全面推广的决策</p>
</li>
</ul>
<h4>5.4 性能监控与容量规划</h4>
<p>性能监控是架构健康度的重要指标。FOSA 指出，架构师需要持续监控系统的性能表现，并基于趋势进行容量规划。</p>
<p>关键性能指标：</p>
<ul>
<li><p>响应时间：P50、P95、P99 延迟</p>
</li>
<li><p>吞吐量：每秒处理的请求数</p>
</li>
<li><p>资源利用率：CPU、内存、磁盘、网络使用率</p>
</li>
<li><p>错误率：4xx、5xx 错误的比例</p>
</li>
</ul>
<p>容量规划方法：</p>
<ul>
<li><p>趋势分析：基于历史数据预测未来需求</p>
</li>
<li><p>压力测试：通过模拟高负载验证系统极限</p>
</li>
<li><p>弹性规划：设计自动扩缩容机制应对流量波动</p>
</li>
</ul>
<h3>6. 复盘、沉淀与演进</h3>
<blockquote>
<p>问题记录与根因分析：发生了什么？为什么会发生？</p>
<p>流程与原则改进：如何优化我们的设计流程、技术原则，避免未来再犯？</p>
<p>人员与组织成长：团队通过这次项目学到了什么？需要组织哪些培训？</p>
</blockquote>
<h4>6.1 问题记录与根因分析</h4>
<p>根因分析 (Root Cause Analysis, RCA) 是架构演进的基础。FOSA 强调，架构师需要建立系统性的问题记录和分析机制，避免同样的问题重复发生。</p>
<p>分析框架：</p>
<ol>
<li><p>5W1H 分析法</p>
<ul>
<li><p>What：发生了什么问题？</p>
</li>
<li><p>When：什么时候发生的？</p>
</li>
<li><p>Where：在哪个组件/服务中发生的？</p>
</li>
<li><p>Who：谁发现了这个问题？</p>
</li>
<li><p>Why：为什么会发生？</p>
</li>
<li><p>How：如何避免再次发生？</p>
</li>
</ul>
</li>
<li><p>鱼骨图分析</p>
<ul>
<li><p>人员因素：技能不足、沟通不畅</p>
</li>
<li><p>流程因素：流程缺陷、决策不当</p>
</li>
<li><p>技术因素：架构设计问题、技术选型错误</p>
</li>
<li><p>环境因素：基础设施问题、外部依赖故障</p>
</li>
</ul>
</li>
<li><p>时间线分析</p>
<ul>
<li><p>按时间顺序记录事件发展过程</p>
</li>
<li><p>识别关键决策点和转折点</p>
</li>
<li><p>分析因果关系链</p>
</li>
</ul>
</li>
</ol>
<p>问题分类：</p>
<ul>
<li><p>架构设计问题：组件划分不当、接口设计不合理</p>
</li>
<li><p>技术选型问题：技术栈不匹配、性能瓶颈</p>
</li>
<li><p>流程管理问题：决策流程不清晰、沟通机制缺失</p>
</li>
<li><p>人员技能问题：团队技能不足、知识传递不畅</p>
</li>
</ul>
<h4>6.2 流程与原则改进</h4>
<p>持续改进是架构师的核心职责。FOSA 指出，架构师需要基于实践经验，不断优化设计流程和技术原则。</p>
<p>流程改进方法：</p>
<ol>
<li><p>回顾会议 (Retrospective)</p>
<ul>
<li><p>定期组织团队回顾会议</p>
</li>
<li><p>识别流程中的痛点和改进机会</p>
</li>
<li><p>制定具体的改进行动计划</p>
</li>
</ul>
</li>
<li><p>架构评审机制</p>
<ul>
<li><p>建立正式的架构评审流程</p>
</li>
<li><p>邀请相关方参与评审</p>
</li>
<li><p>记录评审决策和后续行动</p>
</li>
</ul>
</li>
<li><p>决策记录 (ADR) 更新</p>
<ul>
<li><p>定期回顾和更新 ADR</p>
</li>
<li><p>记录决策的后续影响和教训</p>
</li>
<li><p>为未来类似决策提供参考</p>
</li>
</ul>
</li>
</ol>
<p>原则演进：</p>
<ul>
<li><p>技术原则：基于实践经验更新技术选型原则</p>
</li>
<li><p>设计原则：优化组件划分和接口设计原则</p>
</li>
<li><p>流程原则：改进决策流程和沟通机制</p>
</li>
<li><p>质量原则：更新代码质量和测试策略</p>
</li>
</ul>
<h4>6.3 持续学习与团队领导</h4>
<p>组织学习是架构成功的关键。FOSA 强调，架构师不仅要关注技术架构，更要关注团队和组织的成长。优秀的架构师通过培养团队能力和建立学习型组织，实现技术债务的持续偿还和架构能力的持续提升。</p>
<h5>6.3.1 20 分钟法则</h5>
<p>架构师需要持续学习以保持技术广度。FOSA 指出，技术发展日新月异，架构师必须建立系统化的学习机制，避免技术视野的固化。</p>
<p><strong>20分钟法则</strong>：建议每天至少投入20分钟学习新知识或深入特定主题，以系统化地拓展技术广度。这种持续的小剂量学习比偶尔的集中学习更有效，能够保持技术敏锐度。</p>
<p>学习策略：</p>
<ul>
<li><p>技术深度与广度平衡：在保持一个技术领域的深度基础上，系统性地拓展技术广度</p>
</li>
<li><p>问题驱动学习：将实际工作中遇到的问题作为学习的起点</p>
</li>
<li><p>理论与实践结合：通过概念验证（PoC）验证新技术的适用性</p>
</li>
<li><p>跨领域学习：不仅学习技术，还要了解业务、管理、心理学等相关领域</p>
</li>
</ul>
<h5>6.3.2 个人技术雷达</h5>
<p>建立&quot;个人雷达&quot;可以帮助架构师系统化地评估和追踪新兴技术和实践，类似于 ThoughtWorks 的技术雷达。</p>
<p>雷达分类：</p>
<ul>
<li><p>采用 (Adopt)：经过验证的技术，可以安全地在生产环境中使用</p>
</li>
<li><p>试用 (Trial)：有前景的技术，可以在非关键项目中尝试</p>
</li>
<li><p>评估 (Assess)：值得关注的技术，需要进一步研究和评估</p>
</li>
<li><p>保持 (Hold)：暂时不推荐使用的技术，但保持关注</p>
</li>
</ul>
<p>雷达维护：</p>
<ul>
<li><p>定期更新：每季度更新一次技术雷达</p>
</li>
<li><p>团队共享：与团队分享技术雷达，促进集体学习</p>
</li>
<li><p>决策参考：将技术雷达作为技术选型的重要参考</p>
</li>
</ul>
<h5>6.3.3 知识分享</h5>
<p>架构师应通过以身作则而非仅仅凭借头衔来领导团队。他们可以通过主持&quot;午餐分享会&quot; (brown-bag lunches) 来分享技术知识和经验，从而提升在团队中的领导力和影响力。</p>
<h5>6.3.4 团队健康监控与预警</h5>
<p>当出现以下 3 个问题时，意味着团队已经开始进入不健康状态了，作为架构师，需要及时发现和解决团队协作中的问题。</p>
<p><strong>Process Loss（过程损失）</strong>：随着人数的增加，团队效率却在降低。</p>
<ul>
<li><p>表现：团队规模扩大后，沟通成本激增，决策效率下降</p>
</li>
<li><p>原因：信息传递链条过长，协调成本超过协作收益</p>
</li>
<li><p>解决方案：</p>
<ul>
<li><p>建立清晰的信息传递机制</p>
</li>
<li><p>采用敏捷方法，保持小团队结构</p>
</li>
<li><p>定期评估团队规模与效率的关系</p>
</li>
</ul>
</li>
</ul>
<p><strong>Pluralistic Ignorance（多元无知）</strong>：当团队成员因为觉得自己没掌握某些信息的时候，对提出的方案不好提出拒绝，而只能在表面进行同意。</p>
<ul>
<li><p>表现：会议上大家都点头同意，但会后执行时遇到各种问题</p>
</li>
<li><p>原因：团队成员缺乏安全感，不敢提出质疑</p>
</li>
<li><p>解决方案：</p>
<ul>
<li><p>营造安全的讨论环境，鼓励质疑和提问</p>
</li>
<li><p>建立&quot;魔鬼代言人&quot;机制，专门负责提出反对意见</p>
</li>
<li><p>定期进行匿名反馈收集</p>
</li>
</ul>
</li>
</ul>
<p><strong>Diffusion of Responsibility（责任扩散）</strong>：职责混乱，大家不知道谁应该为哪些东西负责任。</p>
<ul>
<li><p>表现：任务推诿，问题无人负责，决策无人执行</p>
</li>
<li><p>原因：角色定义不清晰，责任边界模糊</p>
</li>
<li><p>解决方案：</p>
<ul>
<li><p>建立明确的 RACI 矩阵（Responsible, Accountable, Consulted, Informed）</p>
</li>
<li><p>定期回顾和更新团队职责分工</p>
</li>
<li><p>建立问责机制，确保每个决策都有明确的责任人</p>
</li>
</ul>
</li>
</ul>
<h3>总结</h3>
<p>架构设计一个系统性的六步工程过程，从商业理解到组织成长形成闭环。它强调&quot;为什么&quot;比&quot;怎么做&quot;更重要，要求架构师在理解利益相关方诉求和用户痛点的基础上，将模糊需求转化为可度量的技术目标，通过多方案权衡分析选择&quot;最不差&quot;而非&quot;最佳&quot;的架构方案，并建立持续交付、监控验证和复盘演进的机制，最终实现技术债务的持续偿还和团队能力的持续提升。整个方法论的核心是权衡取舍的艺术，以及架构师在技术决策中始终提供技术和业务双重理由的能力。</p>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨17丨微服务架构</title>
      <link>https://hedon.top/blog/fosa-ch17/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch17/</guid>
      <pubDate>Wed, 23 Jul 2025 11:02:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第十七章的课后思考题，深入探讨微服务架构中限界上下文的核心作用、服务粒度划分的三大原则、sidecar模式的功能特性，以及编排与编舞的通信机制差异、saga分布式事务模式、微服务的敏捷性优势与性能挑战，帮助理解微服务架构的领域驱动设计理念和分布式系统复杂性，提升架构师在构建现代分布式系统时的微服务拆分能力和架构治理水平。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第十七章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>Why is the bounded context concept so critical for microservices architecture?</p>
<p>为什么限界上下文的概念对于微服务来说如此重要？</p>
</li>
<li><p>What are three ways of determining if you have the right level of granularity in a microservice?</p>
<p>在划分微服务粒度的时候，哪三个方面是你需要重点考虑的？</p>
</li>
<li><p>What functionality might be contained within a sidecar?</p>
<p>sidecar 有哪些功能？</p>
</li>
<li><p>What is the difference between orchestration and choreography? Which does microservices support? Is one communication style easier in microservices?</p>
<p>编舞（orchestration）和编排（choreography）的区别是什么？微服务支持哪种模式？在微服务中，哪种通信方式更简便？</p>
</li>
<li><p>What is a saga in microservices?</p>
<p>在微服务中，saga 是什么?</p>
</li>
<li><p>Why are agility, testability, and deployability so well supported in microservices?</p>
<p>为什么敏捷性、可测试性和可部署性在微服务架构中表现良好？</p>
</li>
<li><p>What are two reasons performance is usually an issue in microservices?</p>
<p>在微服务中，性能问题的两个核心因素是什么？</p>
</li>
<li><p>Is microservices a domain-partitioned architecture or a technically partitioned one?</p>
<p>微服务架构是领域分区还是技术分区？</p>
</li>
<li><p>Describe a topology where a microservices ecosystem might be only a single quantum.</p>
<p>描述一种拓扑结构，其中微服务生态系统可能仅有一个架构量子。</p>
</li>
<li><p>How was domain reuse addressed in microservices? How was operational reuse addressed?</p>
<p>微服务中是如何解决领域复用问题的？又是如何解决运维复用问题的呢？</p>
</li>
</ol>
<hr>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250723111539943.png" alt="FOSA Figure 17-1. The topologu of the microservices architecture style"></p>
<h2>业务边界</h2>
<blockquote>
<ol>
<li><p>Why is the bounded context concept so critical for microservices architecture?</p>
<p>为什么限界上下文的概念对于微服务来说如此重要？</p>
</li>
<li><p>Is microservices a domain-partitioned architecture or a technically partitioned one?</p>
<p>微服务架构是领域分区还是技术分区？</p>
</li>
</ol>
</blockquote>
<p>限界上下文（Bounded Context）是微服务设计理念的核心驱动力。微服务架构与领域驱动设计（DDD）紧密相关，尤其是受限界上下文概念的深刻影响。限界上下文代表了一种<strong>解耦</strong>风格。在限界上下文内，与特定领域相关的所有内部组件（如代码和数据库模式）都是紧密耦合的，但它们与外部限界上下文的任何内容（如其他数据库或类定义）是<strong>解耦</strong>的。</p>
<p>微服务架构的首要目标是<strong>高度解耦</strong>。它通过物理地建模限界上下文的逻辑概念来实现这一目标。这意味着，微服务架构鼓励将系统分解为<strong>独立的、自包含的服务</strong>，每个服务都对应一个特定的限界上下文。</p>
<p>这种隔离使得每个服务可以<strong>独立演进</strong>，定义其自身所需的一切，而不必适应其他部分的约束。它<strong>避免了传统单体架构中常见的共享类和数据库作为集成点导致的紧密耦合问题</strong>。</p>
<p>所以微服务也是一个典型的领域分区架构，并且它倾向于将领域分区推到极致。</p>
<h2>服务粒度</h2>
<blockquote>
<ol start="2">
<li><p>What are three ways of determining if you have the right level of granularity in a microservice?</p>
<p>在划分微服务粒度的时候，哪三个方面是你需要重点考虑的？</p>
</li>
</ol>
</blockquote>
<p>在划分微服务粒度时，以下三个方面是需要重点考虑的：</p>
<ol>
<li><strong>目的（Purpose）</strong>：微服务的首要目的应该是<strong>捕获一个领域或工作流</strong>。理想情况下，每个微服务都应该具有<strong>极高的功能内聚性</strong>，为整个应用程序贡献一个<strong>重要的行为</strong>。这意味着，服务应该专注于一个单一的、明确的业务功能。</li>
<li><strong>事务（Transactions）</strong>：限界上下文是业务工作流，通常需要<strong>在事务中协作的实体</strong>可以为服务边界提供良好的指示。由于分布式事务在分布式架构中会带来复杂性，架构师应尽量设计系统以<strong>避免跨服务的事务</strong>。如果需要跨服务事务，这可能表明服务粒度过细。事务边界通常是服务粒度的常见指标。</li>
<li><strong>通信（Communication）</strong>：如果一组服务为了完成功能而需要<strong>大量通信</strong>，那么将这些服务捆绑成一个更大的服务可能有助于<strong>避免过度的通信开销</strong>。换句话说，如果服务变得过于“多话”（chatty），频繁地相互调用，那么它们的边界可能需要重新评估，以减少不必要的<strong>全局复杂性</strong>。</li>
</ol>
<p>书中还强调，<strong>迭代</strong>是确保良好服务设计的唯一途径，架构师很少能在第一次尝试时就发现完美的粒度、数据依赖和通信风格，只有不断适配业务发展、不断思考改善，才能设计出良好的架构。</p>
<p>此外，业界也有一些其他的常用的判断方法：</p>
<ol>
<li><strong>变更与部署频率一致性</strong>：把一起变更/部署的东西放在一个服务，频率不同的拆开。</li>
<li><strong>耦合/通信“积分器 vs. 解耦器”指标</strong>：如果拆分后跨服务调用暴增（“chattiness”），说明拆太细；反之，如果内部复杂度过高且团队协作困难，可能太粗。</li>
<li><strong>团队/认知负荷</strong>：一个团队能完全理解并独立维护的范围通常就是一个合理服务边界。</li>
</ol>
<h2>基础设施</h2>
<blockquote>
<ol start="3">
<li><p>What functionality might be contained within a sidecar?</p>
<p>sidecar 有哪些功能？</p>
</li>
<li><p>How was domain reuse addressed in microservices? How was operational reuse addressed?</p>
<p>微服务中是如何解决领域复用问题的？又是如何解决运维复用问题的呢？</p>
</li>
</ol>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250723111700037.png" alt="FOSA Figure 17-3. The service plane connects the sidecars in a service mesh"></p>
<p>Sidecar 模式用于处理微服务中的<strong>通用运维关注点（operational concerns）</strong>，包括但不限于：<strong>监控（Monitoring）</strong>、<strong>日志记录（Logging）</strong>、<strong>断路器（Circuit Breakers）<strong>和</strong>服务发现（Service Discovery）</strong>。这些功能由一个独立的组件处理，该组件可以由单个团队拥有，也可以由共享的基础设施团队拥有，从而实现了运维方面的复用。</p>
<p>而在领域复用中，由于微服务架构的主要目标是<strong>高度解耦</strong>。为了实现这一目标，微服务<strong>倾向于复制（duplication）而不是传统意义上的复用（reuse）</strong>。这意味着，对于通用实体（如 <code>Address</code> 类），微服务会<strong>避免共享公共类或数据库模式</strong>。相反，每个服务会在其自己的限界上下文内定义和管理其所需的数据和行为，即使这意味着某些概念的重复实现。这种策略牺牲了代码级别的复用，以换取服务之间更高的解耦度和独立演进的能力。</p>
<h2>服务协作</h2>
<blockquote>
<ol start="4">
<li><p>What is the difference between orchestration and choreography? Which does microservices support? Is one communication style easier in microservices?</p>
<p>编舞（orchestration）和编排（choreography）的区别是什么？微服务支持哪种模式？在微服务中，哪种通信方式更简便？</p>
</li>
</ol>
</blockquote>
<ul>
<li><p><strong>编舞（Choreography）</strong>：是指多个服务<strong>相互之间直接通信</strong>，而<strong>没有中央协调器</strong>。服务（如同舞者）根据彼此发出的事件或信息自主响应和行动。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250723112521618.png" alt="FOSA Figure 17-7. Using choreography in microservices to manage coordination"></p>
</li>
<li><p><strong>编排（Orchestration）</strong>：是指通过一个<strong>单独的协调器服务</strong>来管理和控制工作流中多个服务的协调。协调器（如同乐队指挥）负责指导每个服务的执行顺序，并处理整个业务流程的状态和错误。在微服务中，架构师可以创建<strong>局部化的协调器服务</strong>来处理复杂的业务流程。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250723112551443.png" alt="FOSA Figure 17-8. Using orchestration in microservices"></p>
</li>
</ul>
<p> 微服务两者都支持。 不过编舞方式更符合微服务的高度解耦哲学，因为它不依赖于中央协调器，而是通过解耦的事件来实现通信，使用起来更简便。当然，在复杂的业务流程中，<strong>编舞环境下的错误处理和协调会变得更加复杂</strong>。如果业务流程<strong>本质上是耦合的</strong>，此时编排可能更为适合。</p>
<h2>一致性</h2>
<blockquote>
<ol start="5">
<li><p>What is a saga in microservices?</p>
<p>在微服务中，saga 是什么?</p>
</li>
</ol>
</blockquote>
<p>在微服务中，<strong>Saga</strong> 是一种<strong>分布式事务模式</strong>，用于管理跨多个服务的业务事务，因为在微服务中，跨服务边界的传统 ACID 事务是不推荐的（甚至不可能的）。</p>
<p>Saga 模式通过将一个业务流程分解为一系列<strong>本地事务</strong>来实现，每个本地事务由一个服务执行。</p>
<ul>
<li>如果某个本地事务失败，Saga 会通过执行**补偿事务（compensating transactions）**来撤销之前已成功的本地事务所做的更改，从而确保数据的一致性。</li>
<li>Saga 可以通过**事件溯源（event sourcing）<strong>或</strong>有限状态机（finite state machines）**来管理事务的状态。</li>
</ul>
<p>虽然 Saga 可以用于解决分布式事务问题，但也应<strong>谨慎使用 Saga 模式</strong>，因为它会增加系统的复杂性，并且如果它成为架构中的主导特性，则可能表明服务粒度划分不当，违反了微服务解耦的核心原则。</p>
<h2>优点</h2>
<blockquote>
<ol start="6">
<li><p>Why are agility, testability, and deployability so well supported in microservices?</p>
<p>为什么敏捷性、可测试性和可部署性在微服务架构中表现良好？</p>
</li>
</ol>
</blockquote>
<p>敏捷性（Agility）、可测试性（Testability）和可部署性（Deployability）在微服务架构中得到良好支持的原因主要有以下几点：</p>
<ul>
<li><strong>高度解耦与小部署单元</strong>：微服务架构极力推崇<strong>高度解耦</strong>。每个服务都是<strong>极小的部署单元</strong>，且具备<strong>高度的独立性</strong>。这种解耦使得团队可以独立地开发、测试和部署服务，大大减少了对其他服务的依赖，从而提高了敏捷性。</li>
<li><strong>DevOps 革命与自动化</strong>：微服务架构的成功离不开 <strong>DevOps 革命和对操作关注点的自动化</strong>。自动化部署、自动化测试等现代工程实践是微服务存在的基础，它们极大地提高了部署频率、降低了部署风险，并保证了测试的完整性。</li>
<li><strong>更快的变更响应速度</strong>：由于服务范围小且高度解耦，当业务需求发生变化时，团队只需修改受影响的少量服务，而不是整个大型单体。这种<strong>增量式的演进</strong>能力使得组织能够<strong>更快地响应市场变化，提高时间到市场（time-to-market）的速度</strong>。</li>
<li><strong>单一职责与清晰边界</strong>：每个微服务都专注于一个<strong>单一的业务功能或领域</strong>。这种清晰的职责边界使得开发人员更容易理解、测试和维护代码，因为他们不必处理与服务无关的复杂性</li>
</ul>
<h2>缺点</h2>
<blockquote>
<ol start="7">
<li><p>What are two reasons performance is usually an issue in microservices?</p>
<p>在微服务中，性能问题的两个核心因素是什么？</p>
</li>
</ol>
</blockquote>
<p>在微服务中，性能问题通常由以下两个核心因素导致：</p>
<ol>
<li><strong>网络调用开销（Network Call Overhead）</strong>：微服务是分布式架构。这意味着服务之间（乃至用户界面与服务之间）的通信需要通过网络进行。网络调用比本地方法调用耗时更长。当一个业务请求需要链式调用多个微服务时，累积的网络延迟会显著影响整体响应时间。</li>
<li><strong>安全验证开销（Security Verification Overhead）</strong>：在微服务架构中，由于每个服务都是独立的部署单元，因此每个服务端点都需要进行安全验证。这增加了额外的处理时间。这种“在每个入口处进行安全检查”的模式进一步降低了同步、高度分布式架构（如微服务）的性能。</li>
</ol>
<p>尽管性能是微服务常见的问题，但可以通过**数据缓存（caching）和数据复制（replication）**等模式来减少不必要的网络调用，从而提高性能。</p>
<h2>架构量子</h2>
<blockquote>
<ol start="9">
<li><p>Describe a topology where a microservices ecosystem might be only a single quantum.</p>
<p>描述一种拓扑结构，其中微服务生态系统可能仅有一个架构量子。</p>
</li>
</ol>
</blockquote>
<p>通常来讲，微服务架构都意味着存在多个架构量子。但如果其部署或通信模型导致了上述的紧密耦合，例如<strong>共享数据库</strong>或<strong>中央同步协调器</strong>，那么整个微服务生态系统仍可能被归类为一个单一量子。</p>
<p><strong>1. 共享单一数据库</strong>：</p>
<ul>
<li>如果<strong>所有微服务都共享一个单一的、中央化的数据库实例</strong>，那么整个系统很可能构成一个单一量子。在这种情况下，尽管服务是独立的部署单元，但数据库模式的任何更改都可能影响所有服务，导致它们<strong>无法独立演进和部署</strong>。这使得系统在部署和数据一致性方面表现得像一个整体。</li>
<li>例如，传统的**分层单体（layered monolith）**即使有多个逻辑层，但由于共享一个数据库，它也是一个单一量子。</li>
</ul>
<p><strong>2. 强制同步通信与中央协调器</strong>：</p>
<ul>
<li>在某些情况下，即使服务是分离的，如果它们之间存在<strong>大量强制的同步通信依赖（synchronous connascence）</strong>，或者存在一个**中央编排引擎（orchestration engine）**作为所有行为的巨大耦合点，那么整个系统也可能被视为一个单一量子。在这种拓扑中，如果一个服务调用另一个服务是同步的，那么这些服务的操作架构特性（例如，性能和可用性）必须在调用期间保持一致。中央协调器会限制架构中任何部分具有不同架构特性的能力。</li>
<li>例如，<strong>编排驱动的服务导向架构（Orchestration-Driven Service-Oriented Architecture, SOA）</strong>，即使是分布式架构，也通常只有一个量子，因为它普遍使用单一或少量数据库，并且其编排引擎作为巨大的耦合点，阻止了各个部分独立拥有不同的架构特性。</li>
</ul>
<h2>架构全貌</h2>
<p><strong>边界设计</strong>：限界上下文、团队边界。</p>
<p><strong>粒度与组件识别</strong>：功能内聚 vs. 通信复杂度；量子范围思维。</p>
<p><strong>数据拥有权</strong>：每服务独立数据存储（数据库多样性）；避免共享表。</p>
<p><strong>通信风格</strong>：同步 vs. 异步；编排 vs. 编舞。</p>
<p><strong>一致性策略</strong>：最终一致性、Saga、补偿事务。</p>
<p><strong>弹性与可观测性</strong>：Sidecar/Service Mesh、熔断、限流、Tracing。</p>
<p><strong>部署与运营</strong>：CI/CD、容器编排（K8s）、自动化测试策略。</p>
<p><strong>性能与成本权衡</strong>：网络开销、数据复制、缓存策略。</p>
<p><strong>治理与演化</strong>：契约测试、架构健身函数、可观测指标驱动重构。</p>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨16丨面向服务架构</title>
      <link>https://hedon.top/blog/fosa-ch16/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch16/</guid>
      <pubDate>Tue, 22 Jul 2025 11:02:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第十六章的课后思考题，深入探讨面向服务架构的历史驱动力与核心理念、四种主要服务类型的特征与职责、SOA衰落的关键因素分析，以及技术分层与领域分层的架构特性、领域复用与操作复用的实现机制，帮助理解面向服务架构的企业级设计原理和服务编排思想，提升架构师在构建大型企业系统时的服务化架构选择能力和SOA设计水平。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第十六章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>What was the main driving force behind service-oriented architecture?</p>
<p>SOA 的主要驱动力是什么？</p>
</li>
<li><p>What are the four primary service types within a service-oriented architecture?</p>
<p>SOA 的四种主要服务类型是什么？</p>
</li>
<li><p>List some of the factors that led to the downfall of service-oriented architecture.</p>
<p>列举一些导致 SOA 衰落的因素。</p>
</li>
<li><p>Is service-oriented architecture technically partitioned or domain partitioned?</p>
<p>SOA 是技术分层还是领域分层？</p>
</li>
<li><p>How is domain reuse addressed in SOA? How is operational reuse addressed?</p>
<p>SOA 中如何解决领域复用和操作复用问题？</p>
</li>
</ol>
<hr>
<h2>背景</h2>
<blockquote>
<ol>
<li><p>What was the main driving force behind service-oriented architecture</p>
<p>SOA 的主要驱动力是什么？</p>
</li>
<li><p>Is service-oriented architecture technically partitioned or domain partitioned?</p>
<p>SOA 是技术分层还是领域分层？</p>
</li>
</ol>
</blockquote>
<p>编排驱动的面向服务架构（Orchestration-Driven Service-Oriented Architecture，简称 SOA）是一种在特定时代背景下演变而来的软件架构风格。它在 20 世纪 90 年代末企业快速扩张、需要更复杂的 IT 系统来适应增长的背景下出现。</p>
<ul>
<li><strong>资源稀缺性</strong>：在开源操作系统尚未被认为足够可靠用于严肃工作之前，操作系统和商业数据库服务器的许可费用昂贵且按机器收费。这导致架构师们被要求尽可能地实现<strong>重用</strong>，以优化成本。</li>
<li><strong>企业级重用</strong>：SOA 的一个主要目标是实现服务层面的重用，即逐步构建可随时间增量重用的业务行为。大型公司厌倦了重复编写软件，因此采取了逐步解决这个问题的策略。</li>
<li><strong>技术分层</strong>：这种架构风格也将<strong>技术分层</strong>理念推向了极致。其驱动哲学围绕着企业级的重用展开。</li>
</ul>
<h2>拓扑</h2>
<blockquote>
<ol start="2">
<li><p>What are the four primary service types within a service-oriented architecture?</p>
<p>SOA 的四种主要服务类型是什么？</p>
</li>
</ol>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250722110031795.png" alt="FOSA Figure 16-1. Topology of orchestration-driven service-oriented architecture"></p>
<p>围绕企业级复用的目标，SOA 定义了以下几种服务类型：</p>
<ul>
<li><strong>业务服务（Business Services）</strong>：位于架构顶层，提供入口点，代表领域行为（例如 ExecuteTrade 或 PlaceOrder）。这些服务定义通常不包含代码，只包含输入、输出和模式信息，并由业务用户定义。</li>
<li><strong>企业服务（Enterprise Services）</strong>：包含细粒度的共享实现，是构成粗粒度业务服务的构建块，并通过编排引擎连接起来（例如 CreateCustomer、CalculateQuote）。其目标是构建可复用的原子行为，从而逐步建立可复用的企业资产集合。</li>
<li><strong>应用服务（Application Services）</strong>：一次性、单一实现的服务，不要求与企业服务同等程度的复用和粒度。通常由单一应用团队拥有，用于解决特定应用需求。</li>
<li><strong>基础设施服务（Infrastructure Services）</strong>：提供操作层面的关注点，如监控、日志记录、身份验证和授权。这些服务通常是具体的实现，由共享的基础设施团队与运维团队紧密协作拥有。</li>
<li><strong>编排引擎（Orchestration Engine）</strong>：作为分布式架构的核心，负责将业务服务实现通过编排串联起来，包括事务协调和消息转换等功能。它还充当集成中心，允许集成自定义代码、软件包和传统软件系统。由于这个机制是架构的核心，负责这个引擎的集成架构团队往往会成为组织内部的政治力量和官僚瓶颈**...**。</li>
</ul>
<h2>失败原因</h2>
<blockquote>
<ol start="3">
<li><p>List some of the factors that led to the downfall of service-oriented architecture.</p>
<p>列举一些导致 SOA 衰落的因素。</p>
</li>
<li><p>How is domain reuse addressed in SOA? How is operational reuse addressed?</p>
<p>SOA 中如何解决领域复用和操作复用问题？</p>
</li>
</ol>
</blockquote>
<p>这个架构在历史进程中是一个反面教材，它是核心思想就俩字：复用！reuse。</p>
<p>失败的最核心原因：过度重视技术，以技术为导向进行模块划分和复用尝试，而业务是不断演进变化的，最终技术与业务之间的隔阂无法弥补，功亏一篑。其他原因还有：</p>
<ul>
<li>过度追求复用导致的高度耦合</li>
<li>编排引擎成为巨大的耦合点和瓶颈</li>
<li>技术分区带来的业务流程碎片化</li>
</ul>
<p>这里谈到了一对矛盾：复用和耦合。复用必定会带来耦合，解耦，会带来更多的重复。</p>
<p>在 SOA 中，复用是其核心目标，但其实现方式也导致了架构的显著副作用：<strong>紧密耦合</strong>。</p>
<ul>
<li><strong>领域复用（Domain Reuse）</strong>：SOA 通过抽象共享的业务概念（例如 <code>Customer</code> 客户）为可重用服务来解决领域重用问题。其他服务会引用这些&quot;规范的（canonical）&quot;客户服务。</li>
<li><strong>操作复用（Operational Reuse）</strong>：通过基础设施服务（Infrastructure Services）尽可能地重用所有功能，无论是领域功能还是操作功能。</li>
</ul>
<p>然而，这种设计也带来了负面影响：当一个系统主要围绕重用构建时，组件之间也会产生大量的耦合。例如，对规范客户服务的更改可能会波及到所有其他引用该服务的服务，使得变更变得高风险和复杂。</p>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨15丨空间架构</title>
      <link>https://hedon.top/blog/fosa-ch15/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch15/</guid>
      <pubDate>Mon, 21 Jul 2025 11:02:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第十五章的课后思考题，深入探讨空间架构的命名来源与核心特征、虚拟化中间层的组件构成、消息网格与数据读写器的协作机制，以及缓存策略选择、数据冲突管理和架构特性评估分析，帮助理解空间架构的分布式计算原理和高可扩展性设计思路，提升架构师在构建高性能分布式系统时的架构选择能力和空间化设计水平。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第十五章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>Where does space-based architecture get its name from?</p>
<p>空间架构的名字从何而来？</p>
</li>
<li><p>What is a primary aspect of space-based architecture that differentiates it from other architecture styles?</p>
<p>空间架构区别与其他架构的主要方面是什么？</p>
</li>
<li><p>Name the four components that make up the virtualized middleware within a space-based architecture.</p>
<p>说出空间架构的虚拟化中间层的 4 个组成结构。</p>
</li>
<li><p>What is the role of the messaging grid?</p>
<p>消息网格的作用是什么？</p>
</li>
<li><p>What is the role of a data writer in space-based architecture?</p>
<p>数据写入器在空间架构中的作用是什么？</p>
</li>
<li><p>Under what conditions would a service need to access data through the data reader?</p>
<p>一个服务在什么情况下需要通过数据读取器去获取数据？</p>
</li>
<li><p>Does a small cache size increase or decrease the chances for a data collision?</p>
<p>缓存越小，数据冲突概率是增大还是减小？</p>
</li>
<li><p>What is the difference between a replicated cache and a distributed cache? Which one is typically used in space-based architecture?</p>
<p>复制缓存和分布式缓存的区别是什么？空间架构更倾向于使用哪个？</p>
</li>
<li><p>List three of the most strongly supported architecture characteristics in space- based architecture.</p>
<p>列出 3 个空间架构中非常优秀的架构特性。</p>
</li>
<li><p>Why does testability rate so low for space-based architecture?</p>
<p>为什么空间架构的可测性较差？</p>
</li>
</ol>
<hr>
<h2>背景</h2>
<p>基于空间的架构（SBA）是一种专门为解决<strong>高伸缩性（Scalability）</strong>、<strong>高弹性（Elasticity）</strong>、<strong>高并发（High Concurrency）</strong>、<strong>变动剧烈且不可预测</strong>的应用场景，例如在线票务系统或在线拍卖系统。</p>
<p>传统三层 Web 拓扑在用户量剧增时呈倒三角：Web 层易横向扩容，数据库层最难扩容，最终成为性能上限。为削弱数据库瓶颈，业界先用本地缓存，再出现集中式分布式缓存，但网络跳转仍是热点。把数据直接放到每个处理节点的 <strong>复制型内存网格</strong> 并实时同步，才真正让数据库从&quot;同步路径&quot;上消失，空间架构由此成形。</p>
<p>空间架构的名称来源于**元组空间（Tuple Space）**多个并行处理器通过共享内存进行通信。SBA 的核心理念便是将应用数据保存在内存中（in-memory），并在所有活跃的处理单元（Processing Units）复制，从而移除中心数据库作为同步约束，实现近乎无限的伸缩性。</p>
<h2>拓扑</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250721175147426.png" alt="FOSA Figure 15-2. Space-based architecture basic topology"></p>
<h3>处理单元 Processing Unit</h3>
<ul>
<li>处理单元包含了<strong>应用逻辑</strong>（包括基于 Web 的组件和后端业务逻辑）。</li>
<li>它还包含一个<strong>内存数据网格</strong>和<strong>复制引擎</strong>，通常由 Hazelcast、Apache Ignite 或 Oracle Coherence 等产品实现。</li>
<li>处理单元可以包含小型、单一用途的服务，类似于微服务</li>
</ul>
<h3>虚拟化中间件 Virtualized Middleware</h3>
<p>虚拟化中间件负责处理架构中的基础设施问题，控制数据同步和请求处理。它由以下四个关键组件组成：</p>
<ul>
<li><strong>消息网格（Messaging Grid）</strong>：它负责将请求转发到任何可用的处理单元。</li>
<li><strong>数据网格（Data Grid）</strong>：它是 SBA 中最重要和关键的组件，通常在处理单元内部以复制缓存的形式实现。它确保每个处理单元都包含完全相同的数据，数据复制是异步且快速的。</li>
<li><strong>处理网格（Processing Grid）</strong>：这是一个可选组件，用于管理<strong>协调请求处理</strong>，当一个业务请求涉及多个处理单元时，它会协调这些处理单元之间的请求。</li>
<li><strong>部署管理器（Deployment Manager）</strong>：该组件根据负载条件管理处理单元实例的<strong>动态启动和关闭</strong>，对于实现应用的弹性伸缩至关重要。</li>
</ul>
<h3>数据泵 Data Pumps</h3>
<p>数据泵是<strong>将数据发送到另一个处理器，然后该处理器更新数据库</strong>的方式。它们总是<strong>异步</strong>的，提供内存缓存与数据库之间的<strong>最终一致性（Eventual Consistency）</strong>。消息机制是数据泵的常用实现方式，因为它支持异步通信、保证消息传递和维护消息顺序。</p>
<h3>数据写入器 Data Writers</h3>
<p>数据写入器（Data Writers）负责接收来自数据泵的消息，并用消息中包含的信息更新数据库。它们可以是服务、应用或数据中心（如 Ab Initio）。写入器的粒度可以根据数据泵和处理单元的范围而变化，例如，领域驱动的数据写入器可以处理特定领域（如客户）内的所有更新。</p>
<h3>数据读取器 Data Readers</h3>
<p>负责从数据库读取数据，并通过反向数据泵将其发送到处理单元。服务需要通过数据读取器访问数据的情况有三种：</p>
<ol>
<li>所有相同命名缓存的处理单元实例都崩溃时。</li>
<li>所有相同命名缓存的处理单元需要重新部署时。</li>
<li>需要检索复制缓存中不包含的归档数据时。</li>
</ol>
<h2>数据冲突</h2>
<blockquote>
<p>不同的 processing unit 处理同一个业务逻辑相关的数据时，由于数据同步存在时序问题，所以很容易出现数据不一致的情况。</p>
</blockquote>
<p>可以从以下几个因素进行冲突概率的评估：</p>
<ul>
<li>N：处理相同缓存的 processing unit 的数量</li>
<li>UR：缓存更新频率</li>
<li>S：缓存大小</li>
<li>RL：缓存复制的延迟</li>
</ul>
<p>CollisitionRate = N* (UR^2^/S) *RL</p>
<p>其中<strong>缓存大小越小，意味着缓存能够容纳的数据量越少，因此在给定的更新速率和复制延迟下，数据被频繁覆盖和发生冲突的几率就越高。</strong></p>
<h2>分布式缓存</h2>
<p><strong>复制缓存</strong>：每个处理单元包含一个自己的内存数据网格，与其他共享相同命名缓存的处理单元同步。这是 SBA 通常采用的缓存模式，因为它提供高性能和高容错性。适用于小缓存大小（&lt;100MB）、低更新率和相对静态数据。</p>
<p><strong>分布式缓存</strong>：需要一个外部服务器或服务专门用于存放集中式缓存。它支持高水平的数据一致性，但性能较低（需要远程访问），且容错性存在问题（如果缓存服务器宕机）。适用于大缓存大小（&gt;500MB）、高度动态数据和高更新率。</p>
<h2>优点</h2>
<ul>
<li><strong>弹性（Elasticity）</strong>：处理单元可以根据负载动态启停，实现高度弹性。</li>
<li><strong>伸缩性（Scalability）</strong>：通过内存数据缓存和移除数据库约束，支持处理数百万并发用户。</li>
<li><strong>性能（Performance）</strong>：移除了数据库瓶颈，提供了极高的性能。</li>
</ul>
<h2>缺点</h2>
<ul>
<li><strong>简洁性（Simplicity）</strong>：SBA 是一种<strong>非常复杂的架构风格</strong>，因为它涉及到缓存、最终一致性以及众多动态组件。</li>
<li><strong>可测试性（Testability）</strong>：由于需要模拟极高的伸缩性和弹性负载，<strong>测试复杂且成本高昂</strong>，许多高负载测试甚至需要在生产环境中进行，带来巨大风险。</li>
<li><strong>成本（Cost）</strong>：由于缓存产品许可费和高资源利用率，SBA 通常相对昂贵。</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨14丨事件驱动架构</title>
      <link>https://hedon.top/blog/fosa-ch14/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch14/</guid>
      <pubDate>Fri, 18 Jul 2025 10:30:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第十四章的课后思考题，深入探讨事件驱动架构中代理拓扑与中介者拓扑的设计差异、异步通信的优势机制、请求模式与事件模式的应用场景，以及事件类型分类、消息可靠性保障技术和架构特性支持分析，帮助理解事件驱动架构的核心设计原理和实施策略，提升架构师在构建响应式系统时的架构选择能力和事件化设计水平。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第十四章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>What are the primary differences between the broker and mediator topologies?</p>
<p>代理拓扑（broker）和中介者拓扑（mediator）两种拓扑的根本区别是什么？</p>
</li>
<li><p>For better workflow control, would you use the mediator or broker topology?</p>
<p>为了更好的流程控制，你会选择代理拓扑还是中介者拓扑？</p>
</li>
<li><p>Does the broker topology usually leverage a publish-and-subscribe model with topics or a point-to-point model with queues?</p>
<p>在代理拓扑中，是经常使用基于主题的发布订阅模式还是基于队列的点到点模式？</p>
</li>
<li><p>Name two primary advantage of asynchronous communications.</p>
<p>列出 2 个异步通信的主要优势。</p>
</li>
<li><p>Give an example of a typical request within the request-based model.</p>
<p>举一个 request-based 模式的典型例子。</p>
</li>
<li><p>Give an example of a typical request in an event-based model.</p>
<p>举一个 event-based 模式的典型例子。</p>
</li>
<li><p>What is the difference between an initiating event and a processing event in event-driven architecture?</p>
<p>在事件驱动架构中，初始事件和处理中事件二者有什么不同？</p>
</li>
<li><p>What are some of the techniques for preventing data loss when sending and receiving messages from a queue?</p>
<p>有哪些技术可以防止在从队列发送和接收消息时丢失数据？</p>
</li>
<li><p>What are three main driving architecture characteristics for using event-driven architecture?</p>
<p>使用事件驱动架构的三个主要驱动架构特性是什么？</p>
</li>
<li><p>What are some of the architecture characteristics that are not well supported in event-driven architecture?</p>
<p>事件驱动架构不能很好地支持哪些架构特性？</p>
</li>
</ol>
<hr>
<p>传统的软件设计如同一个等级森严的组织，组件 A 直接向组件 B <strong>下达命令</strong>（例如，调用一个函数或 API）。而事件驱动架构则更像一个现代化的、扁平的协作网络。组件 A 只是<strong>发布一个事实</strong>（嘿，我这里发生了一件事！），而其他对此事感兴趣的组件（B, C, D...）可以自行决定如何<strong>响应</strong>。这种从命令到响应的范式革命，是事件驱动架构（Event-Driven Architecture, EDA）的灵魂所在。</p>
<h2>异步通信</h2>
<p>EDA 的力量源泉来自于异步通信，它有以下优点：</p>
<ol>
<li><strong>极高的系统韧性与可用性 (Resiliency and Availability)</strong>：在同步调用中，如果服务 B 宕机，服务 A 的调用会立刻失败，导致整个链路中断。但在异步模式下，服务 A 将事件发送给一个中间人（消息代理），然后就可继续自己的工作。即使服务 B 此时宕机，事件也会被安全地存放在代理中，待 B 恢复后再进行处理。这使得系统能够优雅地处理局部故障，整体可用性大大提高。</li>
<li><strong>卓越的可伸缩性与弹性 (Scalability and Elasticity)</strong>：生产者和消费者被完全解耦，可以独立进行伸缩。如果事件产生的速度突然加快，我们只需要增加消费者实例的数量即可，而无需对生产者做任何改动。这种按需、独立伸缩的能力是构建高弹性系统的关键。</li>
</ol>
<h2>拓扑</h2>
<p>典型的 EDA 有 2 种拓扑，分别为：</p>
<ul>
<li>代理拓扑（broker）</li>
<li>中介者拓扑（mediator）</li>
</ul>
<h3>broker</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250718110824820.png" alt="FOSA Figure 14-2. Broker topology"></p>
<p>一个典型的 broker 拓扑如上图所示，它包含以下几个部分：</p>
<ul>
<li><code>initiating event</code>：初始事件，它用于<strong>启动整个事件流</strong>，一般来源于系统外部。</li>
<li><code>event channel</code>：事件通道，用于传递事件，比如 Go 的 channel，或者分布式系统中的消息队列，如 RabbitMQ、Kafka 等。一个事件通道一般对应一个订阅主题（topic）。</li>
<li><code>event processor</code>：事件处理器，它们会根据需求，订阅自己感兴趣的 topic，从 <code>event channel</code> 中获取事件进行处理。</li>
<li><code>processing event</code>：处理事件，是由<strong>事件处理器生成并异步广播的事件</strong>，用于广告它刚刚完成了什么操作。这些事件是事件流的中间步骤，通知其他事件处理器某个操作已经完成，以便它们可以继续后续的处理。无论是否有其他的 <code>event processor</code> 关心这些事件，最佳实践中还是建议一直发布这些事件，这对于后续的扩展性非常良好。</li>
</ul>
<p>它具有以下特点：</p>
<ul>
<li><strong>核心思想</strong>：它的唯一职责就是高效、可靠地分发事件。所有的业务逻辑和处理步骤都存在于各个独立的事件处理器（服务）中。</li>
<li><strong>工作流</strong>：工作流是<strong>分散且隐式</strong>的。一个事件可能被多个消费者同时处理，触发多个并行的、互不相关的后续流程。</li>
<li><strong>通信模型</strong>：利用<strong>基于主题的发布/订阅（Publish-Subscribe）模型</strong>。一个事件被发布到特定主题（Topic）上，所有订阅了该主题的消费者都能收到一份该事件的副本并进行处理。这使得系统具有极强的扩展性，可以随时增加新的订阅者来响应现有事件，而无需修改任何已有代码。</li>
<li><strong>优点</strong>：事件生产者和事件消费者之间是<strong>完全解耦</strong>的。生产者不知道谁会消费它的事件，消费者也不知道是谁生产了它所消费的事件。它们唯一的共同依赖是<strong>消息代理</strong>以及<strong>事件的契约（Schema）</strong>。</li>
<li><strong>缺点</strong>：端到端的工作流是<strong>隐式</strong>的，缺乏全局视图。如果流程出了问题，很难追踪到底是哪个环节的协同出了错，这对于异常处理和数据一致性要求较高的系统不是很友好。</li>
</ul>
<p>完整例子可参考下图：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250718113630056.png" alt="FOSA Figure 14-4. Example of the broker topology"></p>
<h3>mediator</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250718111638117.png" alt="FOSA Figure 14-5. Mediator topology"></p>
<p>一个典型的 mediator 拓扑如上图所示，它跟 broker 有些许不同：</p>
<ul>
<li><code>event queue</code>：事件队列，它跟 <code>event channel</code> 有所不同，专门用于 <code>event mediator</code> 接收 <code>initiating event</code>。</li>
<li><code>event mediator</code>：事件中介者，了解处理事件所需的步骤，并生成相应的处理事件，这些事件被发送到专用事件通道（event channel），采用<strong>点对点消息传递</strong>方式。在一些复杂的场景中，也可以设置多个 <code>event mediator</code>，并分配到不同的层次中，以更好的管理复杂业务流程。</li>
</ul>
<p>它具有以下特点：</p>
<ul>
<li><strong>核心思想</strong>：它像一个流程编排引擎，包含了实现复杂业务流程的核心逻辑。</li>
<li><strong>工作流</strong>：工作流是<strong>集中且显式</strong>的。中介者接收一个初始事件，然后根据预设的逻辑，一步步地调用不同的服务来完成一个完整的、有状态的业务流程。</li>
<li><strong>通信模型</strong>：利用<strong>基于队列的点对点（Point-to-Point）模型</strong>。</li>
<li><strong>优点</strong>：工作流是<strong>显式</strong>的，易于理解、监控和管理。复杂的错误处理、重试、补偿逻辑都可以在中介者中集中处理。</li>
<li><strong>缺点</strong>：中介者本身可能成为一个<strong>复杂的单点</strong>（但通常是高可用的集群），所有流程的修改都必须在其中进行，降低了系统的灵活性。</li>
</ul>
<p>完整例子可参考下图：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250718113522146.png" alt="FOSA Figure 14-9. Step 2 of the mediator example"></p>
<h3>对比</h3>
<table>
<thead>
<tr>
<th>对比维度</th>
<th>代理拓扑 (Broker Topology)</th>
<th>中介者拓扑 (Mediator Topology)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>核心组件</strong></td>
<td>轻量级、无状态的消息代理</td>
<td>重量级、有状态的流程中介者</td>
</tr>
<tr>
<td><strong>智能位置</strong></td>
<td>分散在各个事件处理器中</td>
<td>集中在中介者中</td>
</tr>
<tr>
<td><strong>工作流</strong></td>
<td><strong>协同式 (Choreography)</strong>，隐式，涌现式</td>
<td><strong>编排式 (Orchestration)</strong>，显式，集中式</td>
</tr>
<tr>
<td><strong>流程控制</strong></td>
<td>弱，难以进行全局控制</td>
<td>强，易于进行精细控制和监控</td>
</tr>
<tr>
<td><strong>耦合模型</strong></td>
<td>极致解耦（仅依赖代理和事件契约）</td>
<td>轮轴式耦合（所有服务都依赖中-介者）</td>
</tr>
<tr>
<td><strong>灵活性</strong></td>
<td>极高，易于增加新的事件响应者</td>
<td>较低，流程变更需修改中介者</td>
</tr>
<tr>
<td><strong>典型技术</strong></td>
<td>消息队列、流平台 (Kafka, RabbitMQ)</td>
<td>工作流引擎、ESB (AWS Step Functions, Camel)</td>
</tr>
<tr>
<td><strong>适用场景</strong></td>
<td>简单通知、数据广播、高度可扩展的系统</td>
<td>复杂、多步、有状态的业务流程，Saga 模式</td>
</tr>
</tbody></table>
<h2>Request-Reply</h2>
<blockquote>
<ol start="5">
<li><p>Give an example of a typical request within the request-based model.</p>
<p>举一个 request-based 模式的典型例子。</p>
</li>
<li><p>Give an example of a typical request in an event-based model.</p>
<p>举一个 event-based 模式的典型例子。</p>
</li>
</ol>
</blockquote>
<h3>request-based vs event-based</h3>
<table>
<thead>
<tr>
<th>对比维度</th>
<th>基于请求的模型 (Request-Based)</th>
<th>基于事件的模型 (Event-Based)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>核心意图</strong></td>
<td><strong>命令 (Command)</strong></td>
<td><strong>通知 (Notification / Fact)</strong></td>
</tr>
<tr>
<td><strong>详细说明</strong></td>
<td>请求方必须知道接收方的确切地址和接口（例如，一个 URL 端点和其 API 契约）。它们之间是点对点的、强依赖的关系。</td>
<td>发布方和消费方互相完全不知道对方的存在。它们唯一的共同依赖是消息中间件和事件的格式。这种解耦是其最大优势。</td>
</tr>
<tr>
<td><strong>通信模式</strong></td>
<td><strong>通常是同步的 (Synchronous)</strong></td>
<td><strong>总是异步的 (Asynchronous)</strong></td>
</tr>
<tr>
<td><strong>详细说明</strong></td>
<td>请求方发送请求后，会<strong>阻塞并等待</strong>一个响应。从请求方的视角看，整个调用是一个连续、不间断的操作。</td>
<td>发布方发送事件后，<strong>立即继续</strong>自己的工作（“发后即忘” Fire-and-Forget）。它不等待任何结果。</td>
</tr>
<tr>
<td><strong>例子</strong></td>
<td><strong>打电话</strong></td>
<td><strong>发布社交动态</strong></td>
</tr>
</tbody></table>
<h3>event-based 实现 reply</h3>
<p>虽然事件驱动架构的核心是异步和解耦，但在很多业务场景中，请求方确实需要得到一个明确的回复。例如，一个 Web 前端请求处理一个复杂的计算，它不能永远等待，而是需要在一个合理的时间内得到计算结果。</p>
<p>在事件模型之上实现请求-响应模式，关键在于解决两个核心问题：</p>
<ol>
<li><strong>响应应该发往何处？</strong> （因为接收方并不知道请求方是谁）</li>
<li><strong>收到的响应如何与当初的请求对应起来？</strong> （因为请求方可能同时发出了多个请求）</li>
</ol>
<p>解决方案是巧妙地利用消息的两个元数据字段：<strong>回复地址 (Reply-To)</strong> 和 <strong>关联标识 (Correlation ID)</strong>。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250718132839093.png" alt="FOSA Figure 14-20. Request-reply message processing using a correlation ID"></p>
<p><strong>Step 1: 请求方 (Requester) 发起请求</strong></p>
<ol>
<li><strong>创建临时回复队列</strong>：请求方首先为自己创建一个唯一的、临时的、专用于接收本次响应的队列。这个队列的生命周期通常与本次请求-响应过程绑定。</li>
<li><strong>生成关联 ID</strong>：请求方生成一个全局唯一的字符串，作为 <code>Correlation ID</code>。</li>
<li><strong>构造请求消息</strong>：请求方创建请求消息，其内容是业务数据。在消息的**属性（Properties）或头信息（Headers）**中，设置两个关键字段：<ul>
<li><code>Reply-To</code>: 填入刚才创建的临时回复队列的名称。</li>
<li><code>Correlation ID</code>: 填入刚才生成的唯一 ID。</li>
</ul>
</li>
<li><strong>发送并等待</strong>：请求方将这个构造好的消息发送到一个众所周知的<strong>请求队列</strong>（例如 <code>calculation-request-queue</code>）。然后，它开始<strong>监听</strong>自己的那个<strong>临时回复队列</strong>，等待一个包含相同 <code>Correlation ID</code> 的消息出现。通常还会设置一个超时时间。</li>
</ol>
<p><strong>Step 2: 响应方 (Replier) 处理请求并回复</strong></p>
<ol>
<li><strong>接收请求</strong>：响应方服务从<strong>请求队列</strong>中消费一条消息。</li>
<li><strong>处理业务逻辑</strong>：执行消息内容所要求的业务计算或操作。</li>
<li><strong>提取元数据</strong>：从收到的请求消息的属性中，提取出 <code>Reply-To</code> 和 <code>Correlation ID</code> 的值。</li>
<li><strong>构造响应消息</strong>：响应方创建响应消息，其内容是业务处理的结果。</li>
<li><strong>设置并发送响应</strong>：在响应消息的属性中，<strong>必须</strong>将从请求中收到的那个 <code>Correlation ID</code> <strong>原封不动地设置回去</strong>。然后，将此响应消息发送到请求消息中 <code>Reply-To</code> 字段所指定的那个队列地址。</li>
</ol>
<p><strong>Step 3: 请求方 (Requester) 接收响应</strong></p>
<ol>
<li><strong>接收消息</strong>：请求方在其临时回复队列上收到了一个消息。</li>
<li><strong>匹配关联 ID</strong>：它检查收到的响应消息中的 <code>Correlation ID</code> 是否与它当初发送的那个 ID 相匹配。</li>
<li><strong>完成闭环</strong>：如果 ID 匹配，则证明这就是它所等待的响应。请求-响应的流程至此完成。请求方可以处理响应结果，然后安全地删除那个临时的回复队列。</li>
</ol>
<h2>可靠性</h2>
<blockquote>
<p>What are some of the techniques for preventing data loss when sending and receiving messages from a queue? </p>
<p>有哪些技术可以防止在从队列发送和接收消息时丢失数据？</p>
</blockquote>
<p>这是一个生产者、消费者和代理三方共同的责任：</p>
<p><strong>1. 代理端 (Broker Side)</strong>：</p>
<ul>
<li><strong>持久化 (Persistence)</strong>：代理在将事件放入队列或主题时，会先将其写入磁盘，确保即使代理重启，事件也不会丢失。</li>
<li><strong>集群与复制 (Clustering and Replication)</strong>：通过将代理部署为集群，并将事件在多个节点间进行复制，可以防止单点故障导致的数据丢失。</li>
</ul>
<p><strong>2. 客户端 (Client Side)</strong>：</p>
<ul>
<li><strong>消费者确认 (ACK)</strong>：消费者在<strong>成功处理完</strong>一个事件后，必须向代理发送一个 ACK 信号。如果消费者在处理过程中崩溃而未发送 ACK，代理会认为该事件未被成功处理，并会将其重新投递给其他消费者。</li>
<li><strong>事务性发件箱 (Transactional)</strong>：这是一个非常关键的高级模式。为了确保&quot;写入业务数据库&quot;和&quot;发送事件&quot;这两个操作的原子性，开发者会将待发送的事件与业务数据变更<strong>放在同一个本地数据库事务中</strong>，写入一个发件箱（Outbox）表。然后由一个独立的轮询进程负责读取发件箱表，并将事件可靠地发送给代理。这彻底解决了&quot;业务成功但事件未发出&quot;的问题。</li>
</ul>
<h2>架构权衡</h2>
<blockquote>
<p>What are three main driving architecture characteristics for using event-driven architecture? </p>
<p>使用事件驱动架构的三个主要驱动架构特性是什么？</p>
</blockquote>
<ul>
<li><strong>可伸缩性与弹性 (Scalability &amp; Elasticity)</strong>：如前所述，独立伸缩组件的能力是其核心优势。</li>
<li><strong>可扩展性 (Extensibility)</strong>：系统极易扩展。当需要增加新功能时，只需开发一个新的服务来订阅感兴趣的现有事件即可，完全无需改动已有服务。</li>
<li><strong>响应性 (Responsiveness)</strong>：对于需要快速响应用户的系统，可以将耗时任务异步化。例如，用户提交视频后，系统立即返回&quot;上传成功，正在处理中&quot;，然后通过事件驱动后台的转码、审核等一系列复杂流程。</li>
</ul>
<blockquote>
<p>What are some of the architecture characteristics that are not well supported in event-driven architecture?</p>
<p>事件驱动架构不能很好地支持哪些架构特性？</p>
</blockquote>
<ul>
<li><strong>简单性 (Simplicity)</strong>：EDA 显著增加了系统的复杂性。你需要管理消息代理，处理异步编程的挑战（如调试、错误处理），并应对最终一致性带来的心智负担。</li>
<li><strong>事务性 (Transactional)</strong>：实现跨多个服务的原子性操作（即分布式事务）变得异常困难。虽然可以通过 Saga 等模式来模拟长事务，但其实现复杂，且只能保证最终一致性而非强一致性。</li>
<li><strong>工作流的可观测性 (Observability of Workflow)</strong>：尤其是在代理拓扑中，业务流程被分散到各个独立的处理器中，没有一个集中的地方可以让你直观地看到一个完整的业务流程是如何执行的，这给监控和排错带来了巨大挑战。</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨13丨基于服务的架构</title>
      <link>https://hedon.top/blog/fosa-ch13/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch13/</guid>
      <pubDate>Thu, 17 Jul 2025 10:30:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第十三章的课后思考题，深入探讨基于服务的架构中服务数量的设计考量、数据库分解策略与变更管理机制、领域服务的容器化部署模式，以及基于服务架构的特性支持分析、弹性限制因素和架构量子扩展方案，帮助理解基于服务架构的核心设计原则和实施要点，提升架构师在构建分布式系统时的架构选择能力和服务化设计水平。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第十三章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>How many services are there in a typical service-based architecture?</p>
<p>在一个经典的基于服务的架构中通常有多少个服务？</p>
</li>
<li><p>Do you have to break apart a database in service-based architecture?</p>
<p>在基于服务的架构中，你是否必须将数据库进行拆分？</p>
</li>
<li><p>Under what circumstances might you want to break apart a database?</p>
<p>在什么场景下你会对数据库进行拆分？</p>
</li>
<li><p>What technique can you use to manage database changes within a service-based architecture?</p>
<p>在基于服务的架构中，你会使用什么样的技术来管理数据库变更？</p>
</li>
<li><p>Do domain services require a container (such as Docker) to run?</p>
<p>领域服务需要在容器（如 Docker）中运行吗？</p>
</li>
<li><p>Which architecture characteristics are well supported by the service-based architecture style?</p>
<p>基于服务的架构在哪些架构特性表现很优异？</p>
</li>
<li><p>Why isn’t elasticity well supported in a service-based architecture?</p>
<p>为什么基于服务的架构的架构弹性不是很好？</p>
</li>
<li><p>How can you increase the number of architecture quanta in a service-based architecture?</p>
<p>在基于服务的架构中，你如何增加架构量子的数量？</p>
</li>
</ol>
<hr>
<h2>简介</h2>
<p>在软件架构的演进光谱中，如果说单体（Monolith）和微服务（Microservices）是两个广为人知的端点，那么基于服务的架构（Service-Based Architecture, SBA）就是它们之间那个常常被忽略，却又极具现实意义的&quot;务实中间派&quot;。它既非庞大到笨拙，也非精细到繁杂，为许多成长中的系统提供了一条平滑的演进路径。</p>
<p>SBA 的本质是一种将一个大型的单体应用，<strong>分解为少数几个、逻辑独立的、可独立部署的&quot;服务&quot;</strong> 的架构风格。SBA 的服务数量通常不多，一般在 <strong>4 到 12 个</strong>之间。它不像微服务那样追求极致的拆分（可能会有几十上百个服务），而是将应用按照**核心的业务领域（Domain）**进行划分。</p>
<h2>拓扑</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250717114456233.png" alt="FOSA Figure 13-8. Electronics recycling example using service-based architecture"></p>
<h2>数据库</h2>
<p>SBA 最具标志性，也是与微服务最根本的区别之一，就在于它对数据库的处理方式。这直接引出了接下来的两个问题。</p>
<blockquote>
<p>Do you have to break apart a database in service-based architecture? </p>
<p>在基于服务的架构中，你是否必须将数据库进行拆分？</p>
</blockquote>
<p>答案是：<strong>通常不，而且默认不拆分是其主要特征。</strong></p>
<p>SBA 的典型实现是，所有服务共享<strong>同一个数据库</strong>。这种设计的初衷是为了在享受独立部署带来的好处的同时，最大限度地<strong>降低数据层面的复杂性</strong>。共享数据库可以：</p>
<ul>
<li><strong>简化开发</strong>：开发者无需处理复杂的分布式事务和跨服务数据同步问题。</li>
<li><strong>保证数据一致性</strong>：传统的 ACID 事务可以在数据库层面轻松实现。</li>
<li><strong>降低技术门槛</strong>：团队无需掌握复杂的分布式数据管理技术。</li>
</ul>
<p>在共享数据库的模式下，如何管理这个公共资产成了一个关键的治理问题。</p>
<blockquote>
<p>What technique can you use to manage database changes within a service-based architecture? </p>
<p>在基于服务的架构中，你会使用什么样的技术来管理数据库变更？</p>
</blockquote>
<p>当多个团队开发的服务都依赖同一个数据库时，随意的 Schema 变更会引发灾难。因此，必须采用严格的数据库治理技术。</p>
<p>核心方法是成立一个跨团队的数据库治理小组，或者由一个专职的数据库管理员（DBA）团队来担当此任。这个团队的职责是：</p>
<ul>
<li><strong>守护数据库 Schema 的所有权</strong>：任何对数据库结构的修改（增删改表、字段等）都必须通过该团队的评审。</li>
<li><strong>执行数据库迁移脚本</strong>：使用专业的数据库迁移工具（如 <strong>Flyway</strong> 或 <strong>Liquibase</strong>）来统一管理和执行所有的变更脚本，确保变更的可追溯性、版本化和一致性。</li>
<li><strong>保证向后兼容性</strong>：确保数据库的变更不会破坏现有服务的正常运行。</li>
</ul>
<p>然而，这种共享模式并非一成不变，这就引出了下一个问题：</p>
<blockquote>
<p>Under what circumstances might you want to break apart a database? </p>
<p>在什么场景下你会对数据库进行拆分？</p>
</blockquote>
<p>随着业务发展，共享数据库的弊端会逐渐显现。在以下情况下，拆分数据库就成了合理的选择：</p>
<ol>
<li><strong>服务资源争用 (Service Contention)</strong>：某个服务（如高流量的商品浏览服务）对数据库产生巨大压力，影响了其他关键服务（如订单服务）的性能。</li>
<li><strong>数据隔离与安全 (Data Isolation and Security)</strong>：某个服务处理的数据高度敏感（如支付服务中的金融信息），需要从主数据库中物理隔离出来，以满足合规性或安全要求。</li>
<li><strong>技术栈不匹配 (Technology Mismatch)</strong>：某个服务有特殊的数据存储需求。例如，搜索服务最适合使用 Elasticsearch，而核心业务数据则存储在关系型数据库中。</li>
</ol>
<p>当这些情况发生时，SBA 允许你&quot;渐进式&quot;地将某个服务连同其数据一起剥离出去，赋予它独立的数据库。</p>
<h2>部署</h2>
<blockquote>
<p>Do domain services require a container (such as Docker) to run? </p>
<p>领域服务需要容器（例如 Docker）来运行吗？</p>
</blockquote>
<p>答案是：<strong>不需要，但强烈推荐。</strong></p>
<p>从技术上讲，你可以将每个服务单独部署服务器上。但是，容器技术（如 Docker）和容器编排工具（如 Kubernetes）与 SBA 的理念天然契合。使用容器可以带来巨大好处：</p>
<ul>
<li>环境一致性</li>
<li>部署简化</li>
<li>资源利用率</li>
</ul>
<h2>架构权衡</h2>
<blockquote>
<p>Which architecture characteristics are well supported by the service-based architecture style? </p>
<p>基于服务的架构在哪些架构特性表现很优异？</p>
</blockquote>
<p>相比于单体架构，SBA 在以下方面有显著提升：</p>
<ol>
<li><strong>可部署性 (Deployability)</strong>：这是最大的优势之一。每个服务都可以独立部署，使得发布更加频繁、风险更低。</li>
<li><strong>模块化 (Modularity)</strong>：通过按领域划分服务，实现了清晰的业务模块边界。</li>
<li><strong>可维护性 (Maintainability)</strong>：每个服务的代码库规模远小于整个单体，更易于理解、修改和维护。</li>
<li><strong>容错性 (Fault Tolerance)</strong>：一个服务的崩溃不会导致整个应用程序宕机（尽管共享数据库可能成为共同的故障点）。</li>
</ol>
<p>然而，SBA 并非银弹，它也有其固有的局限性。</p>
<blockquote>
<p>Why isn’t elasticity well supported in a service-based architecture? </p>
<p>为什么基于服务的架构的架构弹性不是很好？</p>
</blockquote>
<p><strong>弹性</strong>指的是根据实时负载，自动、精细地伸缩应用<strong>特定部分</strong>的能力。</p>
<p>SBA 对弹性的支持不佳，根源在于其服务的<strong>粗粒度</strong>。假设&quot;订单服务&quot;包含了&quot;浏览历史订单&quot;、&quot;创建新订单&quot;和&quot;订单退款&quot;三个功能。如果&quot;创建新订单&quot;功能因为促销活动而流量激增，你无法只针对这一个功能进行扩容。你必须将整个庞大的&quot;订单服务&quot;进行水平扩展，复制出多个实例。这不仅造成了资源浪费（其他两个功能并未承压），也远不如微服务那样能够对具体功能点进行精准、高效的弹性伸缩。</p>
<h2>架构量子</h2>
<blockquote>
<p>How can you increase the number of architecture quanta in a service-based architecture? </p>
<p>在基于服务的架构中，你如何增加架构量子的数量？</p>
</blockquote>
<p>首先要明确，在<strong>典型的、共享数据库的 SBA</strong> 中，整个系统只有<strong>一个架构量子</strong>。因为所有服务都与同一个数据库紧密耦合，它们无法被真正独立地部署和演化，形成了一个不可分割的整体。</p>
<p>增加架构量子的数量，唯一的途径就是<strong>打破这种共享依赖</strong>。具体方法是： <strong>将某个服务连同其数据一起拆分出来，为其分配一个独立的、专用的数据库。</strong></p>
<p>每完成一次这样的拆分，这个被分离出去的服务就演变成了一个独立的架构量子。因此，增加架构量子的过程，就是<strong>逐步从共享数据库模型向&quot;每个服务一个数据库&quot;模型演进的过程</strong>，也就逐渐趋向于微服务架构了。</p>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨12丨微核架构</title>
      <link>https://hedon.top/blog/fosa-ch12/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch12/</guid>
      <pubDate>Wed, 16 Jul 2025 10:20:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第十二章的课后思考题，深入探讨微核架构中插件组件间依赖关系的设计原则、插件管理工具与框架的选择策略、第三方插件契约兼容性处理方案，以及微核架构的分区特性、架构量子特征和领域同构性概念分析，帮助理解微核架构的核心设计模式和扩展机制，提升架构师在构建可扩展系统时的架构选择能力和插件化设计水平。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第十二章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>What is another name for the microkernel architecture style?</p>
<p>微核架构风格的别名是什么？</p>
</li>
<li><p>Under what situations is it OK for plug-in components to be dependent on other plug-in components?</p>
<p>在什么情况下，插件组件之间可以相互依赖？</p>
</li>
<li><p>What are some of the tools and frameworks that can be used to manage plug-ins?</p>
<p>有哪些工具和框架可用于管理插件？</p>
</li>
<li><p>What would you do if you had a third-party plug-in that didn’t conform to the standard plug-in contract in the core system?</p>
<p>如果一个第三方插件不遵循核心系统的标准插件契约，你会怎么做？</p>
</li>
<li><p>Provide two examples of the microkernel architecture style.</p>
<p>举 2 个微核架构的例子。</p>
</li>
<li><p>Is the microkernel architecture style technically partitioned or domain partitioned?</p>
<p>微核架构是技术分区还是领域分区？</p>
</li>
<li><p>Why is the microkernel architecture always a single architecture quantum?</p>
<p>为什么微核架构总是单一的架构量子？</p>
</li>
<li><p>What is domain/architecture isomorphism?</p>
<p>什么是领域/架构同构性？</p>
</li>
</ol>
<hr>
<h2>拓扑</h2>
<p>微核架构，也被称为<strong>插件化架构（Plug-in Architecture）</strong>，是一种能够提供极高扩展性、灵活性和演化能力的系统设计模式。它的核心思想是将系统功能划分为两部分：一个最小化的、稳定的**核心系统（Core System）<strong>和一个由独立</strong>插件组件（Plug-in Components）**构成的可扩展生态。</p>
<p>我们可以将其想象成一个智能手机：操作系统是其微核，提供最基础的功能（通信、电源管理、应用商店接口），而我们安装的每一个 App 就是一个插件，为手机赋予了无穷无尽的新功能。</p>
<p>核心构成：</p>
<ul>
<li><strong>核心系统 (Core System)</strong>：这是架构的“微核”。它的职责被严格限制在<strong>最小且必要</strong>的范围内，通常只包含：<ol>
<li>系统运行所必需的通用业务逻辑（例如，一个 IDE 的文件管理和基础编辑器）。</li>
<li>一个至关重要的<strong>插件管理机制</strong>，包括插件的注册、发现、生命周期管理等。这是连接核心与插件的桥梁。</li>
</ol>
</li>
<li><strong>插件组件 (Plug-in Components)</strong>：这些是独立的、可插拔的模块，用于实现<strong>扩展功能或特定业务逻辑</strong>。每个插件都通过一个由核心系统定义的**标准契约（Standard Contract）**来与核心交互。这个契约通常是一个接口或一组 API。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250716105151041.png" alt="FOSA Figure 12-1. Basic components of the microkernel architecture style"></p>
<p>典型例子：</p>
<ul>
<li>Chrome</li>
<li>VS Code</li>
</ul>
<h2>插件生态</h2>
<p>理想情况下，插件应该只依赖于核心系统，保持彼此的独立性，以获得最大的灵活性。然而，在复杂的现实世界中，插件间的依赖是不可避免的。</p>
<h3>插件依赖</h3>
<blockquote>
<p>Under what situations is it OK for plug-in components to be dependent on other plug-in components? </p>
<p>在什么情况下，插件组件之间可以相互依赖？</p>
</blockquote>
<p>允许插件间依赖的<strong>合理情况</strong>是：当一个插件的功能是另一个插件功能的<strong>逻辑扩展或前提</strong>时。</p>
<blockquote>
<p>例如：一个 <code>PDF 导出</code> 插件，可能需要依赖一个通用的 <code>报表生成</code> 插件。<code>PDF 导出</code> 插件负责将 <code>报表生成</code> 插件产生的数据模型渲染成 PDF 文件。</p>
</blockquote>
<h3>插件管理</h3>
<blockquote>
<p>What are some of the tools and frameworks that can be used to manage plug-ins?</p>
<p>有哪些工具和框架可用于管理插件？</p>
</blockquote>
<p>管理插件的复杂性催生了许多优秀的框架和标准：</p>
<ol>
<li><strong>OSGi (Open Service Gateway initiative)</strong>：这是 Java 平台中最著名、最强大的插件化框架。它提供了一整套完善的模块层（Bundle）和生命周期管理机制，是构建大型、复杂微核系统的首选。Eclipse IDE 就是基于 OSGi 构建的。</li>
<li><strong>Eclipse Rich Client Platform (RCP)</strong>：基于 OSGi，专门用于构建桌面富客户端应用的框架，其本身就是一个微核。</li>
<li><strong>Java Platform Module System (JPMS)</strong>：从 Java 9 开始引入的官方模块化系统，也可以作为实现插件化的基础。</li>
<li><strong>Java ServiceLoader</strong>：Java 内置的一个简单的服务发现机制，适用于较轻量级的插件化场景。</li>
<li><strong>其他生态</strong>：在 .NET 中有 MEF (Managed Extensibility Framework)；在 Web 应用中，可以通过 Webhooks 机制实现一种分布式的插件化思想，允许第三方服务作为“插件”来响应核心系统的事件。</li>
</ol>
<h3>插件适配</h3>
<blockquote>
<p>What would you do if you had a third-party plug-in that didn’t conform to the standard plug-in contract in the core system? </p>
<p>如果一个第三方插件不遵循核心系统的标准插件契约，你会怎么做？</p>
</blockquote>
<p>如果一个第三方插件不遵循核心系统的标准插件契约，最佳解决方案是引入<strong>适配器模式 (Adapter Pattern)</strong>。</p>
<p>具体做法是：</p>
<ul>
<li><p>创建一个新的、我们自己控制的<strong>适配器插件 (Adapter Plug-in)</strong>，这个适配器插件<strong>遵循</strong>我们核心系统的标准契约。</p>
</li>
<li><p>在适配器插件的内部，它负责将核心系统发来的请求<strong>翻译</strong>成第三方插件能够理解的格式，并调用第三方插件。</p>
</li>
<li><p>反之，它也负责将第三方插件的返回结果<strong>翻译</strong>回核心系统期望的格式。</p>
</li>
</ul>
<h2>分区风格</h2>
<blockquote>
<p>Is the microkernel architecture style technically partitioned or domain partitioned? </p>
<p>微核架构是技术分区还是领域分区？</p>
</blockquote>
<p>微核架构是一种<strong>混合分区 (Hybrid Partitioning)</strong> 的风格，这也是它独特的地方。</p>
<ul>
<li><strong>核心系统本身</strong>通常是<strong>技术分区</strong>的。它关注的是底层、通用的技术能力，如插件生命周期管理、安全、通信等，而不涉及具体的业务领域。</li>
<li><strong>插件组件</strong>则通常是<strong>领域分区</strong>的。每一个插件都封装了一个特定的业务功能或领域（例如 <code>税务计算插件</code>、<code>保单审批插件</code>、<code>Git 版本控制插件</code>）。</li>
</ul>
<p>这种混合模式使得系统既有坚实的技术底座，又能灵活地按业务领域进行扩展。</p>
<h2>架构量子</h2>
<blockquote>
<p>Why is the microkernel architecture always a single architecture quantum?</p>
<p>为什么微核架构总是单一的架构量子？</p>
</blockquote>
<p>首先，我们需要定义<strong>架构量子 (Architecture Quantum)</strong>：一个高功能内聚、可独立部署的组件，它包含了所有使其能够正常工作所需的元素（包括数据）。</p>
<p>微核架构在其经典实现中之所以是单一架构量子，是因为它在两个主要维度上表现出强烈的内聚和耦合：</p>
<ul>
<li><strong>在运行时维度上</strong>：组件共享同一个进程和内存空间，通过进程内调用紧密耦合，形成了一个&quot;共同命运共同体&quot;，缺乏独立的容错能力。</li>
<li><strong>在数据维度上</strong>：组件通常共享同一个物理数据库实例和事务上下文，导致在数据管理和演化上紧密耦合。</li>
</ul>
<h2>同构性</h2>
<blockquote>
<p>What is domain/architecture isomorphism?</p>
<p>什么是领域/架构同构性？</p>
</blockquote>
<p><strong>同构性 (Isomorphism)</strong> 是一个源于数学的概念，意为&quot;结构上的相似性&quot;或&quot;一对一的映射关系&quot;。</p>
<p><strong>领域/架构同构性 (Domain/Architecture Isomorphism)</strong> 指的是<strong>软件的架构结构与它所要解决的问题领域（业务领域）的结构高度一致</strong>。一个具备良好同构性的架构，其模块划分、组件关系能够清晰地反映出业务领域的划分和业务流程。</p>
<p>微核架构是展现领域/架构同构性的一个绝佳范例。</p>
<ul>
<li><strong>问题领域</strong>可以被分解为一个&quot;通用基础平台&quot;和多个&quot;特定业务功能&quot;。</li>
<li><strong>微核架构</strong>恰好与之对应：<strong>核心系统</strong>映射了&#39;&#39;通用基础平台&quot;，而每一个<strong>插件</strong>则精确地映射了一个&quot;特定业务功能&quot;。</li>
</ul>
<p>这种一一对应的关系使得系统非常容易被业务人员和开发人员共同理解，需求变更也能快速定位到需要修改的插件，极大地提升了系统的可维护性和演化能力。</p>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨11丨管道架构</title>
      <link>https://hedon.top/blog/fosa-ch11/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch11/</guid>
      <pubDate>Tue, 15 Jul 2025 10:20:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第十一章的课后思考题，深入探讨管道架构中管道双向性的可能性与限制、过滤器类型的分类与作用机制、数据流向的设计原则，以及管道架构的分区特性、模块化支持方式和典型应用场景分析，帮助理解管道与过滤器架构的核心概念和设计模式，提升架构师在处理数据流应用时的架构选择能力和系统设计水平。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第十一章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>Can pipes be bidirectional in a pipeline architecture?</p>
<p>在管道架构中管道可以是双向的吗？</p>
</li>
<li><p>Name the four types of filters and their purpose.</p>
<p>说出 4 种类型的过滤器及它们的作用。</p>
</li>
<li><p>Can a filter send data out through multiple pipes?</p>
<p>一个过滤器能否通过多条管道将数据发送出去？</p>
</li>
<li><p>Is the pipeline architecture style technically partitioned or domain partitioned?</p>
<p>管道架构是技术分区还是领域分区？</p>
</li>
<li><p>In what way does the pipeline architecture support modularity?</p>
<p>管道架构是如何支持模块化的呢？</p>
</li>
<li><p>Provide two examples of the pipeline architecture style.</p>
<p>举 2 个管道架构的例子。</p>
</li>
</ol>
<hr>
<h2>拓扑</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250715105907327.png" alt="FOSA Figure 11-2. Pipeline architecture example"></p>
<p>管道架构，又称为管道与过滤器架构（Pipes and Filters Architecture），是一种用于处理数据流的强大模式。它的核心思想非常直观，就像一条工厂的流水线：原材料从一端进入，经过一系列独立工站的加工、处理、检验，最终在另一端形成成品。</p>
<p>要理解管道架构，首先要理解它的两个基本构件：</p>
<ul>
<li><strong>过滤器 (Filter)</strong>：它是一个独立的、可执行的处理单元，负责接收数据、执行单一任务（例如转换格式、过滤内容、扩充信息），然后将处理后的数据传递出去。关键在于，每个过滤器都是**自包含（Self-Contained）<strong>和</strong>无状态（Stateless）**的，它不关心上一个过滤器是谁，也不关心下一个过滤器是谁。</li>
<li><strong>管道 (Pipe)</strong>：代表流水线上的&quot;传送带&quot;。它是一个<strong>单向</strong>的数据通道，负责将一个过滤器处理完的数据传递给下一个过滤器。</li>
</ul>
<p>在管道架构中，每个<strong>过滤器</strong>通常代表一个具体的技术操作，而不是一个完整的业务领域。整个管道将这些技术步骤串联起来，以完成一个业务流程，但其划分的单元（过滤器）是技术性的。</p>
<h2>管道</h2>
<p>管道的**单向性（Unidirectional）**是该架构风格的基石。原因在于：</p>
<ol>
<li><strong>维持简单性与解耦</strong>：单向流动保证了数据处理的顺序性和可预测性。每个过滤器只需关注自己的输入和输出，无需处理复杂的双向通信或回调逻辑。</li>
<li><strong>避免状态依赖</strong>：如果管道是双向的，就意味着过滤器之间可能存在请求-响应（Request-Response）式的交互。这会引入状态和时间上的耦合，破坏了过滤器作为独立、无状态组件的核心原则。一个需要双向通信的场景，更适合采用其他架构风格（如客户端-服务器模式），而非管道架构。</li>
</ol>
<p>因此，严格意义上的管道架构，其管道必须是单向的。同时，管道也可以支持强大的分支（Forking）和扇出（Fan-out）能力，一个过滤器可以根据处理结果，将数据发送到不同的下游管道，这个过程依旧保持了其单向性。</p>
<h2>过滤器</h2>
<ul>
<li><p><strong>生产者 (Producer / Source)</strong>：作为整条管道的<strong>起点</strong>。它不接收来自管道的数据，而是负责创建数据，并将这些初始数据泵入管道。</p>
</li>
<li><p><strong>转换器 (Transformer)</strong>：它从上游管道接收数据，对其进行某种形式的<strong>修改或转换</strong>，然后将结果发送到下游管道。</p>
</li>
<li><p><strong>测试器 (Tester)</strong>：它接收数据，并根据一个或多个条件对数据进行<strong>检验</strong>。如果数据满足条件，就将其传递到下游管道；如果不满足，则数据流在此处被中断（或被导向另一条错误处理管道）。</p>
</li>
<li><p><strong>消费者 (Consumer / Sink)</strong>：作为整条管道的<strong>终点</strong>。它从上游管道接收最终处理好的数据，并将其消费掉，通常不会再将数据传递出去。</p>
</li>
</ul>
<h2>模块化</h2>
<ul>
<li><strong>高内聚、低耦合（High Cohesion, Low Coupling）</strong>：每个过滤器都是一个高内聚的模块，只专注于完成一件定义明确的任务。同时，过滤器之间通过管道这一标准接口进行通信，实现了极低的耦合，它们互相不知道对方的存在。</li>
<li><strong>可组合性（Composability）</strong>：过滤器就像乐高积木。我们可以通过不同的排列组合，快速地搭建出全新的数据处理流程，而无需修改过滤器本身的代码。</li>
<li><strong>可复用性（Reusability）</strong>：一个通用的过滤器（例如 <code>GzipCompressor</code>）可以被用在任何需要数据压缩的管道中，实现了代码的高度复用。</li>
<li><strong>可替换性（Replaceability）</strong>：只要遵守管道中的数据格式约定，我们可以轻易地用一个性能更好的新过滤器来替换掉一个旧的过滤器，而不会影响到管道的其他部分。</li>
</ul>
<h2>例子</h2>
<h3>1. UNIX/Linux 命令行</h3>
<pre><code class="language-shell">cat access.log | grep &quot;ERROR&quot; | sort | uniq -c
</code></pre>
<ul>
<li><code>cat access.log</code>：生产者，读取日志文件并产生数据流。</li>
<li><code>|</code>：管道，将标准输出连接到下一个命令的标准输入。</li>
<li><code>grep &quot;ERROR&quot;</code>：测试器/转换器，过滤出包含 &quot;ERROR&quot; 的行。</li>
<li><code>sort</code>：转换器，对错误日志进行排序。</li>
<li><code>uniq -c</code>：转换器/消费者，统计重复行并输出最终结果。</li>
</ul>
<h3>2. ELT(Extract, Transform, Load) 流程</h3>
<ul>
<li><strong>Extract（抽取）</strong>：生产者过滤器，从各种源系统（如业务数据库、日志文件、API）中读取原始数据。</li>
<li><strong>Transform（转换）</strong>：一系列转换器和测试器过滤器，对数据进行清洗（去除无效值）、转换（统一格式）、扩充（关联其他数据）、聚合（计算统计值）等操作。</li>
<li><strong>Load（加载）</strong>：消费者过滤器，将最终处理好的、高质量的数据加载到目标数据仓库或数据湖中，供后续分析使用。</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨10丨分层架构</title>
      <link>https://hedon.top/blog/fosa-ch10/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch10/</guid>
      <pubDate>Mon, 14 Jul 2025 10:20:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第十章的课后思考题，深入探讨分层架构中开放层与封闭层的核心差异、隔离层概念的重要价值、架构漏斗反模式的识别与防范，以及分层架构风格的主要驱动特性与局限性分析，帮助理解分层架构的设计原则和适用场景，提升架构师在选择和实施分层架构时的决策能力和风险评估意识。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第十章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>What is the difference between an open layer and a closed layer?</p>
<p>开放层和封闭层有何区别？</p>
</li>
<li><p>Describe the layers of isolation concept and what the benefits are of this concept.</p>
<p>隔离层概念及其益处是什么？</p>
</li>
<li><p>What is the architecture sinkhole anti-pattern?</p>
<p>架构漏斗反模式是什么？</p>
</li>
<li><p>What are some of the main architecture characteristics that would drive you to use a layered architecture?</p>
<p>驱动采用分层架构风格的主要架构特性有哪些？</p>
</li>
<li><p>Why isn’t testability well supported in the layered architecture style?</p>
<p>分层架构风格的可测试性为何不佳？</p>
</li>
<li><p>Why isn’t agility well supported in the layered architecture style?</p>
<p>分层架构风格的敏捷性为何不佳？</p>
</li>
</ol>
<hr>
<h2>概念</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250714113807168.png" alt="FOSA Figure 10-2. Physical topology (deployment) variants"></p>
<p>分层架构的<strong>核心驱动力</strong>是<strong>关注点分离（Separation of Concerns）</strong>。它将一个复杂的系统按照不同的职责或技术关注点，垂直地划分成若干个水平的“层（Layer）”。</p>
<p>每一层都有明确的职责：</p>
<ul>
<li><strong>表现层（Presentation Layer）</strong>：负责处理用户界面和交互，例如 Web 页面或 API 端点。</li>
<li><strong>业务逻辑层（Business Logic Layer）</strong>：实现核心的业务规则和流程，是应用的心脏。</li>
<li><strong>持久化层（Persistence Layer）</strong>：负责数据的存储和检索，与数据库交互。</li>
<li><strong>数据库层（Database Layer）</strong>：即实际的数据库系统。</li>
</ul>
<p>这些层之间存在一个至关重要的约束：<strong>依赖关系是单向的</strong>。通常来说，上层可以依赖下层，但下层绝对不能依赖上层。例如，表现层可以调用业务逻辑层，但业务逻辑层不应该知道任何关于表现层的具体实现细节。</p>
<h2>封闭层 vs 开放层</h2>
<p><strong>封闭层（Closed Layer）</strong>：当一个请求从上层向下层传递时，它<strong>必须</strong>逐层通过。</p>
<ul>
<li><strong>优点</strong>：提供了最高程度的<strong>隔离</strong>。由于每一层都只与它的邻居交流，下层实现细节的变更对上上层的影响被完全隔离。这正是&quot;隔离层&quot;概念的基础。</li>
<li><strong>缺点</strong>：可能会引入不必要的冗余代码和性能开销。</li>
</ul>
<p><strong>开放层（Open Layer）</strong>：这是一种更为灵活的模式，允许上层&quot;跳过&quot;一个或多个中间层，直接访问更下方的层。</p>
<ul>
<li><strong>优点</strong>：当中间层对于某个特定请求没有任何业务逻辑需要添加时，开放该层可以避免编写无意义的传递（pass-through）代码，从而提高开发效率和运行效率。</li>
<li><strong>缺点</strong>：破坏了层与层之间的强隔离性。如果滥用开放层，会导致层级关系混乱，上层与多个下层产生耦合，削弱分层架构带来的可维护性优势。</li>
</ul>
<h2>隔离</h2>
<h3>概念及好处</h3>
<p>隔离指的是<strong>一个层中的变更，应该被隔离在这一层以及与之直接相邻的层中，而不会向上&quot;泄漏&quot;到更远的层</strong>。</p>
<p>想象一下，如果我们决定将数据库从 MySQL 迁移到 PostgreSQL。这个变化发生在最底层的数据库层和持久化层。</p>
<ul>
<li><strong>理想情况（强隔离）</strong>：由于业务逻辑层只依赖于持久化层定义的接口（例如 <code>UserRepository</code>），而不知道其背后是 MySQL 还是 PostgreSQL，因此业务逻辑层代码<strong>完全不需要修改</strong>。表现层就更不受影响了。变更被成功&quot;隔离&quot;在了持久化层内部。</li>
<li><strong>隔离被破坏的情况</strong>：如果持久化层的某些特定实现细节（例如特定的 SQL 方言）泄漏到了业务逻辑层，那么在迁移数据库时，业务逻辑层也必须跟着修改。这就是隔离性的失败。</li>
</ul>
<p>这样做的好处有：</p>
<ul>
<li>极高的可维护性</li>
<li>技术栈的独立性</li>
<li>系统的可理解性</li>
</ul>
<h3>潜在的陷阱：架构漏斗反模式</h3>
<p><strong>架构漏斗反模式</strong>描述了这样一种情况：一个请求在流经多个封闭层时，其中一些中间层<strong>没有执行任何有意义的逻辑</strong>，仅仅是将请求原封不动地传递给下一层。这些&quot;只传话、不干活&quot;的层就成为了架构的&quot;漏斗&quot;或&quot;沉洞&quot;，增加了不必要的复杂度和代码量。</p>
<blockquote>
<p>可以使用二八原则，允许 20% 的 sinkhole，如果过多的 sinkhole，则说明分层架构很可能不适用于当前的业务场景。</p>
</blockquote>
<h2>优点</h2>
<ol>
<li><strong>简单性（Simplicity）和低成本（Cost）</strong>：分层架构模式非常成熟，广为人知，开发团队的学习成本极低。对于中小型项目、预算有限的初创公司或内部管理系统，它是一个&quot;足够好&quot;的、性价比极高的起点。</li>
<li><strong>可维护性（Maintainability）</strong>：如前所述，只要遵循了隔离层原则，系统的维护和迭代会非常清晰。对于那些业务逻辑相对稳定、变更不频繁的系统，这是一个巨大的优势。</li>
<li><strong>整体可部署性（Deployability）</strong>：分层架构天然倾向于构建<strong>单体应用（Monolith）</strong>。整个应用被打包成一个单元（例如一个 WAR 包或一个可执行文件）进行部署。这极大地简化了部署和运维的复杂度，尤其是在项目早期或运维能力有限的团队中。</li>
</ol>
<h2>缺点</h2>
<ul>
<li><strong>技术分区而非领域分区</strong>：分层架构是一种技术分区架构。这意味着它的组件是根据其在架构中的技术角色（如表示层、业务层、持久层），而不是根据业务领域（如客户、订单）进行分组的。这会导致任何特定的业务领域（例如“客户”领域）的逻辑都会分散在架构的所有层中。同时，当需要对特定业务领域的需求进行更改时，由于其逻辑分散在多个技术层中，开发人员必须在所有相关层中进行修改，这降低了开发的敏捷性。</li>
<li><strong>部署风险高</strong>：在分层架构中，即使是对少量代码的更改（例如，一个类文件中简单的三行更改），也需要重新部署整个部署单元。这种部署往往会捆绑数十个其他更改，从而显著增加了部署风险，且部署频率受到限制。</li>
<li><strong>测试范围大且不完整</strong>：由于整个应用程序是作为一个大型单体单元部署的，开发人员通常不会为简单的三行更改花费数小时执行完整的回归测试套件。这导致测试覆盖范围不完整，并且难以确保更改不会影响看似不相关的部分。</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨09丨架构风格基础</title>
      <link>https://hedon.top/blog/fosa-ch9/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch9/</guid>
      <pubDate>Thu, 10 Jul 2025 10:20:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第九章的课后思考题，深入探讨分布式计算的八大谬论及其对架构设计的影响、分布式架构相比单体架构面临的独特挑战、邮票耦合问题的本质与危害，以及解决邮票耦合的有效策略与最佳实践，帮助理解分布式系统设计的核心原则和常见陷阱，提升架构师在分布式环境下的决策能力和风险识别意识。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第九章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>List the eight fallacies of distributed computing.</p>
<p>列举分布式计算中的 8 个谬论。</p>
</li>
<li><p>Name three challenges that distributed architectures have that monolithic architectures don’t.</p>
<p>说出 3 个单体架构没有而分布式架构有的挑战。</p>
</li>
<li><p>What is stamp coupling?</p>
<p>什么是邮票耦合？</p>
</li>
<li><p>What are some ways of addressing stamp coupling?</p>
<p>邮票耦合有哪些解决方案？</p>
</li>
</ol>
<hr>
<h2>分布式八谬论</h2>
<p><strong>1. 网络是可靠的 (The network is reliable)。</strong></p>
<ul>
<li><strong>谬论</strong>：开发者常常假设网络连接永远不会中断，数据总能成功从 A 点传输到 B 点。</li>
<li><strong>现实</strong>：网络硬件可能发生故障、交换机可能崩溃、路由器可能过载、网线可能被拔掉。任何网络调用都有可能失败，数据包可能会丢失、损坏或重复。因此，必须在设计中考虑网络中断的可能性，并加入重试机制 (retry mechanisms)、超时 (timeouts)、熔断器 (circuit breakers) 等容错策略。</li>
</ul>
<p><strong>2. 延迟为零 (Latency is zero)。</strong></p>
<ul>
<li><strong>谬论</strong>：开发者假设通过网络发送请求和接收响应是瞬时完成的，就像本地方法调用一样。</li>
<li><strong>现实</strong>：数据在网络上传输需要时间，这个时间被称为延迟 (latency)。即使在光速的限制下，物理距离也会导致不可避免的延迟。网络拥堵、数据包的路由跳转等因素都会增加延迟。在设计分布式系统时，必须意识到延迟的存在，并尽可能地减少网络往返次数，例如通过批处理请求或使用异步通信模式。</li>
</ul>
<p><strong>3. 带宽是无限的 (Bandwidth is infinite)。</strong></p>
<ul>
<li><strong>谬论</strong>：开发者认为网络的传输能力是无限的，可以随心所欲地发送大量数据。</li>
<li><strong>现实</strong>：每个网络连接都有其最大吞吐量，即带宽 (bandwidth) 限制。过度发送数据会导致网络拥塞，增加延迟，甚至导致数据包丢失。架构师需要关注数据传输的效率，对数据进行压缩，避免在网络上传输不必要的“重量级”数据对象。</li>
</ul>
<p><strong>4. 网络是安全的 (The network is secure)。</strong></p>
<ul>
<li><strong>谬论</strong>：开发者假设内部网络是安全的，传输的数据不会被窃听或篡改。</li>
<li><strong>现实</strong>：任何网络连接都有可能受到攻击。数据在传输过程中可能被中间人 (man-in-the-middle) 截获、窃听或修改。因此，必须采取加密措施（如 TLS/SSL）来保护传输中的数据，并使用认证 (authentication) 和授权 (authorization) 机制来确保只有合法的服务和用户才能访问资源。</li>
</ul>
<p><strong>5. 拓扑结构不会改变 (Topology doesn&#39;t change)。</strong></p>
<ul>
<li><strong>谬论</strong>：开发者假设网络的布局、服务器的地址和服务的部署位置是固定不变的。</li>
<li><strong>现实</strong>：在现代的云原生和微服务环境中，网络拓扑是动态变化的。服务器可能会宕机，新的服务实例可能会被启动，服务可能会被迁移到不同的物理位置或 IP 地址。依赖硬编码的 IP 地址和端口是极其脆弱的。应该使用服务发现 (service discovery) 机制来动态地查找和连接服务。</li>
</ul>
<p><strong>6. 只有一个管理员 (There is one administrator)。</strong></p>
<ul>
<li><strong>谬论</strong>：开发者认为整个分布式系统由一个全知全能的管理员或团队来维护，他们了解并控制系统的所有部分。</li>
<li><strong>现实</strong>：一个大型的分布式系统通常由多个团队共同开发和维护，每个团队只负责其中的一部分。不同团队、不同系统之间可能存在策略、配置和维护窗口的冲突。此外，系统还可能依赖由第三方管理的外部服务。因此，必须通过标准化的监控、日志记录和告警来获得对整个系统的可见性。</li>
</ul>
<p><strong>7. 传输成本为零 (Transport cost is zero)。</strong></p>
<ul>
<li><strong>谬论</strong>：开发者认为进行网络通信本身是不需要成本的。</li>
<li><strong>现实</strong>：这里所说的“成本”不仅指金钱。它包括了运行网络硬件所需的 CPU 周期、内存，以及将数据序列化 (serialization) 和反序列化 (deserialization) 所需的计算资源。在云环境中，网络流量本身通常也是直接收费的。因此，在设计 API 和数据格式时，需要考虑其对性能和运营成本的综合影响。</li>
</ul>
<p><strong>8. 网络是同质的 (The network is homogeneous)。</strong></p>
<ul>
<li><strong>谬论</strong>：开发者假设网络中的所有设备都来自同一个供应商，使用相同的协议栈，并且性能表现一致。</li>
<li><strong>现实</strong>：一个复杂的网络通常由来自不同供应商的硬件（路由器、交换机、防火墙）和运行着不同操作系统（Linux, Windows）的服务器组成。这些异构组件的组合可能导致意想不到的兼容性问题和性能瓶颈。在设计系统时，应依赖于广泛支持的标准化协议，并对系统的端到端性能进行充分测试。</li>
</ul>
<h2>分布式系统挑战</h2>
<p><strong>1. 服务间通信的复杂性 (Inter-service Communication Complexity)</strong></p>
<ul>
<li><strong>在单体架构中</strong>：不同模块或组件之间的调用是进程内的函数调用 (in-process function calls)。这种调用非常快速、可靠，并且事务性可以通过语言层面的机制轻松保证。</li>
<li><strong>在分布式架构中</strong>：服务间的调用变成了跨网络的远程过程调用 (RPC)。这立刻引入了前述“分布式计算的 8 个谬论”中的所有问题：网络可能失败，存在延迟，带宽有限，需要考虑安全等。开发者必须处理部分失败 (partial failure) 的情况——即一个服务可用，而它依赖的另一个服务却不可用。这就需要引入重试、超时、熔断、服务发现等复杂的模式来保证系统的韧性 (resilience)。</li>
</ul>
<p><strong>2. 分布式事务与数据一致性 (Distributed Transactions and Data Consistency)</strong></p>
<ul>
<li><strong>在单体架构中</strong>：通常使用单一的数据库，可以依赖数据库本身提供的 ACID（原子性、一致性、隔离性、持久性）事务来保证跨多个数据表的强一致性。操作要么全部成功，要么全部失败，状态不会处于中间状态。</li>
<li><strong>在分布式架构中</strong>：每个服务通常拥有自己独立的数据库，以实现松耦合和独立部署。当一个业务流程需要跨越多个服务时，就无法使用传统的单数据库事务。这就带来了分布式事务的挑战。实现强一致性的两阶段提交 (Two-Phase Commit, 2PC) 等协议通常非常复杂且性能低下。因此，架构师往往不得不放弃强一致性，转而寻求最终一致性 (eventual consistency)，并采用 Saga 模式、事件溯源 (Event Sourcing) 等更复杂的模式来管理跨服务的数据一致性，这极大地增加了开发的难度和心智负担。</li>
</ul>
<p><strong>3. 运维和监控的复杂性 (Operational and Observability Complexity)</strong></p>
<ul>
<li><p><strong>在单体架构中</strong>：整个应用被部署为一个单元。日志集中在一个地方，调试相对直接（例如，通过附加调试器），监控也相对简单，只需关注单个进程和服务器的 CPU、内存等指标。</p>
</li>
<li><p><strong>在分布式架构中</strong>：一个请求可能会流经数十个甚至上百个服务。要诊断一个问题，你需要追踪这个请求在整个系统中的调用链。这就需要建立复杂的“可观测性” (Observability) 体系，包括：</p>
<ul>
<li><p><strong>集中式日志 (Centralized Logging)</strong>：将所有服务的日志聚合到一起进行分析。</p>
</li>
<li><p><strong>分布式追踪 (Distributed Tracing)</strong>：为每个请求分配一个唯一的 ID，并在整个调用链中传递，以便追踪其路径和耗时。</p>
</li>
<li><p><strong>聚合指标 (Metrics Aggregation)</strong>：从各个服务收集关键性能指标（如请求率、错误率、延迟）并进行聚合展示。</p>
<p>部署、扩缩容、故障排查的难度都呈指数级增长。</p>
</li>
</ul>
</li>
</ul>
<h2>邮票耦合</h2>
<p><strong>邮票耦合 (Stamp Coupling)</strong> 是一种特定类型的<strong>数据耦合 (Data Coupling)</strong>。当一个模块（或服务）向另一个模块传递一个复杂的数据结构（如一个对象或记录），但接收方模块实际上只需要该数据结构中的一小部分字段时，就发生了邮票耦合。</p>
<p>这个名字的比喻来源于：</p>
<blockquote>
<p>你只是想寄一封信，却把整个邮局（包含了所有信件和包裹）都递给了邮递员。接收方不得不从这个庞大的结构中&quot;筛选&quot;出自己需要的信息。</p>
</blockquote>
<p>核心特征：</p>
<ul>
<li><strong>传递了超量信息</strong>：调用者传递了比被调用者实际需要的多得多的数据。</li>
<li><strong>不必要的依赖</strong>：被调用者被迫依赖于一个它并不完全需要的数据结构的具体定义。</li>
</ul>
<p>解决邮票耦合的核心思想是将数据契约 (data contract) 的关注点从&quot;<strong>提供方有什么</strong>&quot;转变为&quot;<strong>消费方要什么</strong>&quot;。</p>
<ul>
<li><strong>创建私有的 RESTful API 端点</strong>：为特定的内部消费者（服务）创建专门的、不对外公开的 API 端点 (endpoint)。这些端点被设计为只返回该消费者完成其特定任务所必需的数据子集。</li>
<li><strong>在契约中使用字段选择器</strong>：允许 API 的调用方通过查询参数 (query parameter) 来动态指定响应中应包含哪些字段。</li>
<li><strong>使用 GraphQL 来解耦契约</strong>：GraphQL 从根本上就是为了解决 REST API 中常见的数据过度获取 (over-fetching) 和数据获取不足 (under-fetching) 问题而设计的，而过度获取正是邮票耦合的表现形式。</li>
<li><strong>使用价值驱动契约与消费者驱动契约</strong>：消费者驱动契约 (CDC) 是一种模式，其中 API 的消费者编写一份&quot;契约&quot;，明确声明它对提供者的期望（需要哪些字段、什么样的数据格式）。这份契约被用作自动化测试的一部分。</li>
<li><strong>使用内部消息端点</strong>：在消息系统中，不发布一个包含完整实体状态的&quot;大而全&quot;的事件，而是发布更细粒度、更具业务意图的事件。</li>
</ul>
<p>总而言之，这五种方案都体现了从 Push 模型向 Pull 模型的转变，是解决分布式系统中耦合问题的关键实践。</p>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨08丨组件思维</title>
      <link>https://hedon.top/blog/fosa-ch8/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch8/</guid>
      <pubDate>Wed, 09 Jul 2025 10:20:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第八章的课后思考题，深入探讨软件组件的核心概念与实现方式、技术导向与领域导向分区策略的差异、实体陷阱问题的本质及解决方案，以及Actor/Actions模式、工作流分析和事件风暴等组件识别技术的适用场景与局限性，帮助理解如何运用领域驱动的思维方式来设计组件边界，提升系统的内聚性、松耦合特性和业务适应能力。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第八章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>We define the term <em>component</em> as a building block of an application—something the application does. A component usually consist of a group of classes or source files. How are components typically manifested within an application or service?</p>
<p>组件在应用程序或服务中通常如何体现？</p>
</li>
<li><p>What is the difference between technical partitioning and domain partitioning? Provide an example of each.</p>
<p>技术分区和领域分区有什么区别？请各举一个例子。</p>
</li>
<li><p>What is the advantage of domain partitioning?</p>
<p>领域分区的优点是什么？</p>
</li>
<li><p>Under what circumstances would technical partitioning be a better choice over domain partitioning?</p>
<p>在什么情况下，技术分区会是比领域分区更好的选择？</p>
</li>
<li><p>What is the entity trap? Why is it not a good approach for component identification?</p>
<p>&quot;实体陷阱&quot;是什么？为什么它不是一种好的组件识别方法？</p>
</li>
<li><p>When might you choose the workflow approach over the Actor/Actions approach when identifying core components?</p>
<p>在识别核心组件时，你何时会选择 workflow 方法而不是 actor/actions 方法？</p>
</li>
</ol>
<hr>
<h2>组件范围</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250709102652672.png" alt="FOSA Figure 8-1. Different varieties of components"></p>
<p>在软件架构中，组件被定义为模块的物理体现。它代表了相关代码的逻辑分组，并通过不同的方式进行物理打包。</p>
<p>组件在应用程序或服务中的典型体现方式包括：</p>
<ul>
<li><strong>库文件</strong>：这是最简单的组件形式，它将代码包装成更高层次的模块，通常在与调用代码相同的内存地址空间中运行，并通过语言函数调用机制进行通信。例如，Java 中的 JAR 文件、.NET 中的 DLL 文件和 Ruby 中的 Gem 文件。</li>
<li><strong>子系统或层</strong>：组件也可以作为架构中的子系统或层来出现。</li>
<li><strong>服务</strong>：特别是在微服务等架构风格中，服务是一种组件，它在自己的地址空间中运行，并通过低级网络协议（如 TCP/IP）或高级格式（如 REST 或消息队列）进行通信，形成独立的、可部署的单元。</li>
<li><strong>逻辑边界</strong>：从领域驱动设计（DDD）的角度来看，有界上下文（Bounded Contexts）物理组件，例如服务、子系统等。每个有界上下文应作为一个独立的服务或项目来实现，这意味着它可以独立于其他有界上下文进行实现、演进和版本控制。有时一个有界上下文可以包含多个子域，此时有界上下文是物理边界，而其每个子域是逻辑边界，这些逻辑边界在不同编程语言中可能被称为命名空间、模块或包。</li>
</ul>
<p>总之，组件是架构中最基本的模块化构建块，它们定义了代码的组织方式以及系统各部分之间的交互方式。</p>
<h2>架构分区</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250709102924588.png" alt="FOSA Figure 8-4. Two types of top-level partitioning in architecture"></p>
<h3>技术分区</h3>
<p><strong>技术分区 (Technical Partitioning):</strong> 是 <strong>根据代码的技术职责</strong> 来组织代码。这是传统分层架构的典型做法。每一层都有明确的技术目标，例如处理 HTTP 请求、执行业务规则或与数据库交互。</p>
<p>典型分层:</p>
<ul>
<li><strong>表现层 (Presentation/UI):</strong> 负责处理用户交互和展示，例如 MVC 框架中的 <code>Controller</code> 和 <code>View</code>。</li>
<li><strong>业务逻辑层 (Business/Service):</strong> 负责实现核心业务规则和流程，是系统的核心。</li>
<li><strong>数据访问层 (Data Access/Persistence):</strong> 负责与数据库或其他数据存储进行交互，执行增删改查 (CRUD)。</li>
</ul>
<p>优点：</p>
<ul>
<li>简单直观，上手快</li>
<li>清晰的技术关注点分离</li>
<li>促进技术层面的代码复用</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>业务内聚性极低 (Low Business Cohesion):</strong> 这是技术分区最大的问题。一个完整的业务功能（例如，&quot;用户下单&quot;）的逻辑被强制拆散，散落在所有三个层次中。当你需要理解或修改这个功能时，必须在多个目录和文件中来回跳转，增加了认知负荷。</li>
<li><strong>功能开发导致高耦合 (High Coupling for Feature Development):</strong> 由于功能代码被分散，任何一个业务需求的变更，都可能导致从上到下的“全垒打”式修改（即 <code>Controller</code> -&gt; <code>Service</code> -&gt; <code>Repository</code> 都需要改）。这使得变更的影响范围变大，回归测试的成本也更高。</li>
<li><strong>容易形成“上帝类”和瓶颈 (Prone to &quot;God Classes&quot; and Bottlenecks):</strong> 随着业务越来越复杂，业务逻辑层 (<code>Business Layer</code>) 很容易膨胀成一个巨大而臃肿的“上帝模块”，它了解所有业务细节，被所有表现层组件依赖。这个模块会变得难以维护和测试，成为整个系统演进的瓶颈。</li>
<li><strong>阻碍团队自治和独立扩展 (Impedes Team Autonomy and Scalability):</strong> 很难将一个完整的业务功能垂直地分配给一个团队。两个团队开发不同功能时，很可能会在共享的业务逻辑层或数据访问层产生代码冲突。在分布式架构中，你也无法仅仅因为订单逻辑复杂就单独扩展业务逻辑层，而必须扩展整个单体应用。</li>
</ul>
<p>适用场景：</p>
<ul>
<li><strong>小型、简单的应用程序：</strong> 尤其是 CRUD 密集型的管理后台、内容管理系统等。</li>
<li><strong>业务领域稳定且不复杂：</strong> 如果业务在可预见的未来不会有大的变化，技术分区的简单性就是一种优势。</li>
<li><strong>项目初期或概念验证 (PoC):</strong> 当业务边界尚不明确，需要快速验证想法时，可以从技术分区开始。</li>
<li><strong>按技术职能划分的团队：</strong> 如果你的公司有独立的前端团队、后端 Java 团队和 DBA 团队，这种分区方式能匹配组织结构。</li>
</ul>
<h3>领域分区</h3>
<p><strong>领域分区 (Domain Partitioning):</strong> 是 <strong>根据业务领域或业务能力</strong> 来组织代码。每个组件都封装了某个特定业务领域所需的所有技术实现。这与领域驱动设计 (DDD) 的思想高度一致。</p>
<p>典型分区:</p>
<ul>
<li><strong>订单组件 (Ordering):</strong> 包含处理订单的所有逻辑，从 API 端点到数据库交互。</li>
<li><strong>库存组件 (Inventory):</strong> 负责管理商品库存。</li>
<li><strong>支付组件 (Payment):</strong> 封装与支付相关的所有功能。</li>
</ul>
<p>优点：</p>
<ul>
<li>业务内聚性极高</li>
<li>领域间的低耦合</li>
<li>支持团队自治和并行开发</li>
<li>易于独立扩展和部署</li>
<li>增强系统的演进能力</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>初期复杂度和设计门槛高 (Higher Initial Complexity):</strong> 正确地识别和划分领域边界是领域分区的核心挑战。这需要架构师对业务有深刻的理解，并投入大量的前期分析设计（例如通过领域驱动设计 DDD 中的事件风暴等实践）。如果边界划分错误，后期的重构成本会非常高。</li>
<li><strong>可能导致代码重复 (Potential for Code Duplication):</strong> 不同的领域组件可能需要相似的功能，例如身份验证、日志记录、数据访问模式等。如果缺乏良好的治理，这些横切关注点 (Cross-cutting Concerns) 可能会在多个组件中被重复实现。这通常需要通过共享库、平台服务或服务网格 (Service Mesh) 来解决。</li>
<li><strong>分布式架构的额外开销 (Overhead of Distributed Architecture):</strong> 如果将每个领域组件实现为微服务，就需要处理分布式系统带来的所有复杂性，如服务发现、网络延迟、数据一致性、分布式事务等。</li>
</ul>
<p>适用场景：</p>
<ul>
<li><strong>大型、复杂的企业级系统：</strong> 尤其是那些业务逻辑复杂、需要长期演进的系统。</li>
<li><strong>微服务架构 (Microservices Architecture):</strong> 领域分区是实现微服务的标准和基础。</li>
</ul>
<h2>组件识别</h2>
<p>组件识别是指在定义了宏观的架构风格（例如，分层单体、微服务）之后，<strong>发现和划定系统中各个功能模块（即组件）边界的过程</strong>。这个过程的目标是创建一组高内聚、低耦合的组件。</p>
<p>组件识别不是一个随意的过程，它需要系统性的方法和深刻的业务理解。如果边界划分错误，将会导致维护困难、扩展不易等一系列问题。接下来我们将要讨论的几个概念，正是服务于这个目的的方法论和需要警惕的陷阱。</p>
<h3>实体陷阱</h3>
<p>这是在组件识别过程中最常见、也最需要警惕的一个反模式 (Anti-pattern)。</p>
<p>实体陷阱是指 <strong>错误地将数据实体（通常直接对应数据库中的表）当作组件来进行划分</strong>。例如，系统中有 <code>User</code>, <code>Product</code>, <code>Order</code> 三张表，就草率地创建 <code>User</code> 组件、<code>Product</code> 组件和 <code>Order</code> 组件。</p>
<p>为什么是陷阱？</p>
<blockquote>
<p>软件的核心价值在于处理 <strong>业务流程 (Business Workflow)</strong>，而不仅仅是管理数据。一个有意义的业务流程往往会跨越多个数据实体。</p>
</blockquote>
<p>我们以经典的&quot;用户下单&quot;流程为例。这个行为需要：</p>
<ol>
<li>读取 <strong>用户信息</strong> (User) 以确认其身份和收货地址。</li>
<li>查询 <strong>商品信息</strong> (Product) 以获取价格并检查库存。</li>
<li>创建一个新的 <strong>订单记录</strong> (Order)。</li>
<li>更新 <strong>商品库存</strong> (Product)。</li>
</ol>
<p>如果 <code>User</code>、<code>Product</code>、<code>Order</code> 各自是一个独立的组件，那么&quot;用户下单&quot;这段核心业务逻辑应该放在哪里呢？</p>
<ul>
<li>放在 <code>Order</code> 组件里？那么它就需要频繁调用 <code>User</code> 组件和 <code>Product</code> 组件，并且可能需要了解它们的内部数据结构，形成了紧密的耦合。</li>
<li>放在一个单独的 <code>PlacingOrderService</code> 里？这个服务本身没有归属，像一个&quot;流浪&quot;的脚本，操纵着另外三个“只有数据没有行为”的贫血组件。</li>
</ul>
<p><strong>结论：</strong> 实体陷阱导致了 <strong>业务逻辑的碎片化</strong> 和 <strong>组件间的高度耦合</strong>。</p>
<blockquote>
<p>👉🏻 正确的做法是围绕 <strong>业务能力</strong> 来划分组件，而不是围绕数据实体。一个更合理的组件应该是 <code>Ordering</code> (订单管理)，它封装了 <code>Order</code> 实体以及所有相关的业务行为（如下单、取消订单、查询订单状态等）。</p>
</blockquote>
<h3>Actor/Actions</h3>
<p>这是一种非常直观且有效的自顶向下的组件识别方法。它的核心是回答：&quot;<strong>谁 (Who) 会对系统做什么 (What)？</strong>&quot;</p>
<p><strong>实施步骤：</strong></p>
<ol>
<li><strong>识别执行者 (Identify Actors):</strong> 列出所有会与系统交互的&quot;人&quot;或&quot;外部系统&quot;。例如：顾客 (Customer)、管理员 (Admin)、仓库管理系统 (WMS)、支付网关 (Payment Gateway)。</li>
<li><strong>识别操作 (Identify Actions):</strong> 针对每一个执行者，列出他们会对系统发起的具体操作（可以理解为用例）。<ul>
<li><strong>顾客</strong> 可以：搜索商品、查看商品详情、添加购物车、提交订单、支付。</li>
<li><strong>管理员</strong> 可以：上架商品、调整价格、查看销售报表。</li>
</ul>
</li>
<li><strong>组件划分：</strong> 将相关的操作进行分组，形成初步的组件。<ul>
<li><code>搜索商品</code>、<code>查看商品详情</code> -&gt; 可能属于 <code>Catalog</code> (商品目录) 组件。</li>
<li><code>添加购物车</code>、<code>提交订单</code> -&gt; 可能属于 <code>Ordering</code> (订单) 组件。</li>
<li><code>上架商品</code>、<code>调整价格</code> -&gt; 可能属于 <code>ProductManagement</code> (商品管理) 组件。</li>
</ul>
</li>
</ol>
<p><strong>优点：</strong></p>
<ul>
<li>简单直观，易于上手。</li>
<li>以用户为中心，能很好地识别出面向用户的核心功能。</li>
</ul>
<p><strong>缺点：</strong></p>
<ul>
<li>可能遗漏那些没有明确执行者的后台流程或系统内部流程。</li>
</ul>
<h3>Event storming</h3>
<p>事件风暴是领域驱动设计 (DDD) 中一种强大的 <strong>协作式工作坊技术</strong>，用于快速、全面地探索复杂的业务领域，并从中识别出聚合 (Aggregates) 和限界上下文 (Bounded Contexts)，而这些正是划分高质量组件（尤其是微服务）的理想边界。</p>
<p><strong>核心过程:</strong> 这是一个由业务专家和技术专家共同参与的会议，大家在一个足够大的墙上，用不同颜色的即时贴 (sticky notes) 来“风暴”出整个业务流程。</p>
<ol>
<li><strong>橙色贴 - 领域事件 (Domain Event):</strong><ul>
<li><strong>规则：</strong> 用过去时态描述业务中发生过的、有价值的事情。这是整个风暴的核心。</li>
<li><strong>例子：</strong> <code>订单已提交</code>、<code>商品已添加到购物车</code>、<code>用户已注册</code>。</li>
<li>大家将所有能想到的事件，按照时间顺序从左到右贴在墙上。</li>
</ul>
</li>
<li><strong>蓝色贴 - 命令 (Command):</strong><ul>
<li><strong>规则：</strong> 触发领域事件的用户操作或系统指令。</li>
<li><strong>例子：</strong> <code>提交订单</code> (触发 <code>订单已提交</code>)、<code>添加商品到购物车</code> (触发 <code>商品已添加到购物车</code>)。</li>
</ul>
</li>
<li><strong>黄色小贴 - 执行者 (Actor):</strong><ul>
<li><strong>规则：</strong> 发出命令的人或系统。</li>
<li><strong>例子：</strong> <code>顾客</code> (发出 <code>提交订单</code> 命令)。</li>
</ul>
</li>
<li><strong>粉色/黄色大贴 - 聚合 (Aggregate):</strong><ul>
<li><strong>规则：</strong> 聚合是处理命令并产生事件的业务实体，它负责维护一组相关对象的数据一致性。</li>
<li><strong>例子：</strong> <code>订单</code> 聚合负责处理 <code>提交订单</code> 命令，并产生 <code>订单已提交</code> 事件。</li>
</ul>
</li>
<li><strong>划定边界 - 限界上下文 (Bounded Context):</strong> 当整个流程可视化之后，团队会发现某些事件、命令和聚合在业务上高度相关，形成了一个个的&quot;簇&quot;。这些&quot;簇&quot;的边界，就是 <strong>限界上下文</strong> 的边界，也是 <strong>组件/微服务</strong> 的理想边界。例如，所有与订单创建、修改、状态流转相关的即时贴会自然地聚集在一起，形成 <code>Ordering</code> 上下文。</li>
</ol>
<p><strong>优点：</strong></p>
<ul>
<li><strong>协作性：</strong> 打破了业务与技术之间的隔阂，让所有人对业务有统一的理解。</li>
<li><strong>深度洞察：</strong> 能发现隐性的业务规则和流程，识别出比 Actor/Actions 更自然的边界。</li>
<li><strong>结果可靠：</strong> 通过事件风暴识别出的边界通常非常稳定，是划分微服务的黄金标准。</li>
</ul>
<h3>Workflow</h3>
<p>这种方法是对 Actor/Actions 方法的一个重要补充，它专注于识别那些 <strong>没有明确、单一执行者的端到端业务流程</strong>。</p>
<p><strong>核心思想：</strong> 寻找系统中的关键业务事件，并追踪由该事件引发的一系列后续处理步骤，将整个流程封装成一个组件。</p>
<p><strong>实施步骤：</strong></p>
<ol>
<li><strong>识别关键业务事件或调度任务：</strong> 例如：&quot;订单支付成功&quot;、&quot;每月一日进行财务结算&quot;。</li>
<li><strong>描绘工作流：</strong> 画出该事件发生后，系统需要依次完成的所有步骤。<ul>
<li><strong>事件：订单支付成功 (Order Paid)</strong></li>
<li><strong>工作流：</strong><ol>
<li>更新订单状态为“待发货”。</li>
<li>调用仓库管理系统 (WMS) 接口，通知发货。</li>
<li>向用户发送“支付成功”的邮件/短信。</li>
<li>为用户增加积分。</li>
</ol>
</li>
</ul>
</li>
<li><strong>组件划分：</strong> 整个工作流可以被识别为一个或多个组件。例如，可以有一个 <code>OrderFulfillment</code> (订单履行) 组件来编排这个流程。</li>
</ol>
<p><strong>适用场景：</strong></p>
<ul>
<li><strong>后台处理：</strong> 如报表生成、数据同步、月末结算等。</li>
<li><strong>异步流程：</strong> 一个操作触发后，后台需要执行一系列复杂的、耗时的任务。</li>
<li><strong>编排服务 (Orchestration):</strong> 一个组件的主要职责是调用其他多个组件/服务来完成一个复杂的业务目标。</li>
</ul>
<h2>架构师职责</h2>
<ul>
<li>架构职责：架构分区<ul>
<li>按层分区</li>
<li>按模块分区</li>
<li>按技术分区</li>
<li>按领域分区</li>
</ul>
</li>
<li>开发职责：组件识别<ul>
<li>识别基础组件</li>
<li>为组件赋予需求</li>
<li>分析组件角色和职责</li>
<li>分析架构特征</li>
<li>重构组件</li>
</ul>
</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 入门丨02 闭包</title>
      <link>https://hedon.top/blog/rust-02-closure/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-02-closure/</guid>
      <pubDate>Tue, 08 Jul 2025 13:22:00 GMT</pubDate>
      <description>本文通过&quot;承诺&quot;的视角深入解析 Rust 闭包中 Fn、FnMut、FnOnce 三个 trait 的包含关系，帮助读者理解闭包设计的本质。</description>
      <category>rust</category><category>Rust 入门</category>
      <content:encoded><![CDATA[<p>笔者之前一直不理解 Rust 中关于闭包的 Fn/FnMut/FnOnce 这 3 个 trait 的包含关系。通过一段时间的学习和分析，终于找到了我思维上的一个错误点 ，特此梳理此文，方便日后查询。</p>
<p>我之前的理解是这样的：</p>
<blockquote>
<p>Fn 只需要引用，所以要求是最容易满足的。FnMut 需要的是可变引用，所以能满足 FnMut，一定能满足 Fn。FnOnce 需要的是所有权，那都有所有权了 ，&amp;mut 和 &amp; 肯定就不在话下了。所以满足 FnOnce 的，一定是 Fn 和 FnMut。满足 FnMut 的，不一定是 FnOnce，但是一定是 Fn。</p>
</blockquote>
<p>我的错误点在于：在<strong>闭包实现者</strong>的角度想&quot;我拥有什么权限&quot;。</p>
<p>正确的思路应该是：<u>站在<strong>函数调用者</strong>的角度想&quot;我得到了什么承诺&quot;</u>。这才是 Trait 设计的本质，即是能力的声明，更是限制的承诺。</p>
<hr>
<h3>核心关键：承诺越强，限制越多，类型越“小”</h3>
<p><code>Fn</code>, <code>FnMut</code>, <code>FnOnce</code> 这三个 Trait，本质上是闭包对<strong>调用者</strong>做出的三种不同强度的<strong>承诺</strong>。</p>
<ol>
<li><strong><code>Fn</code> 的承诺 (最强的承诺)</strong><ul>
<li><strong>承诺内容</strong>：调用我，只需要对我进行不可变借用 (<code>&amp;self</code>)。你甚至可以同时在多个线程里调用我。我保证不会改变任何东西，也不会消耗掉自己。</li>
<li><strong>对闭包的限制</strong>：为了兑现这个最强的承诺，闭包自身受到的限制也最大。它<strong>只能</strong>不可变地借用环境中的变量。</li>
<li><strong>调用者的自由</strong>：调用者获得了最大的自由，可以随心所欲地、不限次数地调用这个闭包。</li>
</ul>
</li>
<li><strong><code>FnMut</code> 的承诺 (中等的承诺)</strong><ul>
<li><strong>承诺内容</strong>：调用我，需要对我进行可变借用 (<code>&amp;mut self</code>)。这意味着你不能同时调用我，但可以一个接一个地调用。我可能会改变我内部的状态。</li>
<li><strong>对闭包的限制</strong>：限制有所放宽。闭包可以<strong>可变地</strong>借用环境变量。</li>
<li><strong>调用者的自由</strong>：调用者的自由受到了一些限制，不能并发调用了。</li>
</ul>
</li>
<li><strong><code>FnOnce</code> 的承诺 (最弱的承诺)</strong><ul>
<li><strong>承诺内容</strong>：你<strong>只能</strong>调用我一次 (<code>self</code>)。调用之后，我就会被消耗掉，不复存在。</li>
<li><strong>对闭包的限制</strong>：对闭包自身的限制最小。它可以随心所欲，甚至可以拿走环境变量的<strong>所有权</strong>。</li>
<li><strong>调用者的自由</strong>：调用者只拥有一次调用的机会，自由度最低。</li>
</ul>
</li>
</ol>
<h3>将之前的逻辑反过来思考</h3>
<p>用&quot;承诺&quot;的视角：</p>
<ul>
<li><strong>错误想法</strong>：<code>FnOnce</code> 有所有权，最厉害，所以它包含了 <code>FnMut</code> 和 <code>Fn</code>。</li>
<li><strong>正确的逻辑</strong>：一个闭包如果能做出 <code>Fn</code> 的承诺（最强承诺），那么它自然也能满足 <code>FnMut</code>（中等承诺）和 <code>FnOnce</code>（最弱承诺）的要求。</li>
</ul>
<p>这就像一个人的信用评级：</p>
<ul>
<li>一个能被评为 <strong>AAA 级信用 (<code>Fn</code>)</strong> 的人，向他借钱（调用他）风险极低，可以随时借。他自然也满足 <strong>AA 级 (<code>FnMut</code>)</strong> 和 <strong>A 级 (<code>FnOnce</code>)</strong> 的标准。</li>
<li>一个被评为 <strong>AA 级信用 (<code>FnMut</code>)</strong> 的人，满足不了 AAA 级的苛刻标准，但他肯定满足 A 级的基本标准。</li>
<li>一个只有 <strong>A 级信用 (<code>FnOnce</code>)</strong> 的人，意味着和他交易有风险，只能“一次性买卖”，他肯定满足不了 AA 级和 AAA 级的要求。</li>
</ul>
<p>所以，这个关系是：</p>
<ul>
<li>凡是 <code>Fn</code>，必然是 <code>FnMut</code> 和 <code>FnOnce</code>。</li>
<li>凡是 <code>FnMut</code>，必然是 <code>FnOnce</code>，但不一定是 <code>Fn</code>。</li>
<li><code>FnOnce</code> 最为宽泛，它不承诺自己是 <code>FnMut</code> 或 <code>Fn</code>。</li>
</ul>
<h3>代码验证</h3>
<p>我们用一个具体的例子来印证这个理论。</p>
<pre><code class="language-rust">// 一个函数，它要求一个“AAA信用”的闭包
fn call_repeatedly&lt;F: Fn()&gt;(closure: F) {
    println!(&quot;--- Calling an Fn closure ---&quot;);
    closure();
    closure();
}

// 一个函数，它要求一个“AA信用”的闭包
fn call_mutably&lt;F: FnMut()&gt;(mut closure: F) {
    println!(&quot;--- Calling an FnMut closure ---&quot;);
    closure();
    closure();
}

// 一个函数，它只要求“A信用”的闭包
fn call_once&lt;F: FnOnce()&gt;(closure: F) {
    println!(&quot;--- Calling an FnOnce closure ---&quot;);
    closure();
}

fn main() {
    let mut my_string = String::from(&quot;Hello&quot;);
    let owned_string = String::from(&quot;World&quot;);

    // 1. 这是一个 Fn 闭包，因为它只对 my_string 进行了不可变借用。
    // 它做出了最强的承诺。
    let closure_fn = || {
        println!(&quot;Fn says: {}&quot;, my_string);
    };

    // 2. 这是一个 FnMut 闭包，因为它对 my_string 进行了可变借用。
    // 它只能做出中等承诺。
    let mut closure_fn_mut = || {
        my_string.push_str(&quot;!&quot;);
        println!(&quot;FnMut says: {}&quot;, my_string);
    };

    // 3. 这是一个 FnOnce 闭包，因为它夺走了 owned_string 的所有权。
    // 它只能做出最弱的承诺。
    let closure_fn_once = || {
        let consumed = owned_string;
        println!(&quot;FnOnce says: {}&quot;, consumed);
    };

    // --- 开始验证 ---

    // `closure_fn` (AAA级) 可以满足所有要求
    call_repeatedly(closure_fn);
    call_mutably(closure_fn);
    call_once(closure_fn);

    println!(&quot;\n&quot;);

    // `closure_fn_mut` (AA级) 满足不了 AAA 级的要求
    // call_repeatedly(closure_fn_mut); // 编译错误！因为它改变了环境，不满足 Fn 的要求
    call_mutably(&amp;mut closure_fn_mut);
    call_once(&amp;mut closure_fn_mut);

    println!(&quot;\n&quot;);

    // `closure_fn_once` (A级) 只能满足最基本的要求
    // call_repeatedly(closure_fn_once); // 编译错误！
    // call_mutably(closure_fn_once);    // 编译错误！因为它被调用后就没了，不能调用第二次
    call_once(closure_fn_once);
    // call_once(closure_fn_once); // 再次调用也会编译错误，因为它已经被消耗了
}
</code></pre>
<h3>总结</h3>
<p>在 Rust 的 Trait 系统中，一个类型如果满足更强的承诺（<code>Fn</code>），它就能被用在要求较弱承诺（<code>FnMut</code>, <code>FnOnce</code>）的任何地方。这就是为什么 <code>Fn</code> 是最小、最核心的那个集合。</p>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨07丨架构特性范围</title>
      <link>https://hedon.top/blog/fosa-ch7/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch7/</guid>
      <pubDate>Tue, 08 Jul 2025 11:00:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第七章的课后思考题，深入探讨架构量子的概念与定义、架构特性的作用范围以及量子边界的识别方法，分析系统组件间的同步依赖关系对架构分解的影响，帮助理解如何基于功能内聚性和部署依赖性来合理划分架构边界，优化系统的可部署性、可测试性和可维护性。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第七章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>What is an architectural quantum, and why is it important to architecture?</p>
<p>为什么是架构量子？它为什么对架构很重要？</p>
</li>
<li><p>Assume a system consisting of a single user interface with four independently deployed services, each containing its own separate database. Would this system have a single quantum or four quanta? Why?</p>
<p>假设一个系统包含一个单一的用户界面，以及四个独立部署的服务，每个服务都包含自己的独立数据库。这个系统会有一个量子还是四个量子？为什么？</p>
</li>
<li><p>Assume a system with an administration portion managing static reference data (such as the product catalog, and warehouse information) and a customer-facing portion managing the placement of orders. How many quanta should this system be and why? If you envision multiple quanta, could the admin quantum and customer-facing quantum share a database? If so, in which quantum would the database need to reside?</p>
<p>假设一个系统包含两个部分：</p>
<ol>
<li><strong>管理后台</strong>：负责管理静态参考数据（例如产品目录、仓库信息）。</li>
<li><strong>用户端</strong>：负责处理客户订单的下达。</li>
</ol>
<p>这个系统应该被划分为多少个 <strong>量子</strong>？为什么？如果您设想这是多个量子，那二者可以共享同一个数据库吗？如果可以，该数据库需要驻留在哪个量子中？</p>
</li>
</ol>
<hr>
<h2>什么是架构量子？</h2>
<p><strong>架构量子 (Architectural Quantum)</strong> 这个概念源于 Neal Ford、Mark Richards 等人在《软件架构：艰难的部分》(Software Architecture: The Hard Parts) 一书中提出的。它的核心定义是：</p>
<blockquote>
<p>一个<strong>架构量子</strong>是指一个系统中，具有<strong>高功能内聚性 (High Functional Cohesion)</strong> 和<strong>同步部署依赖性 (Synchronous Deployable Dependency)</strong> 的、可独立部署的组件的最小集合。</p>
</blockquote>
<p>为了更好地理解这个定义，我们需要拆解其中的关键术语：</p>
<ul>
<li><strong>可独立部署的组件 (Independently Deployable Component)</strong>：这是现代架构（尤其是微服务架构）的基本单元。它可以是一个服务、一个应用或者任何可以独立于系统其他部分进行部署的模块。</li>
<li><strong>高功能内聚性 (High Functional Cohesion)</strong>：这个概念借鉴了软件工程中的“内聚性”，指的是一个组件内部的各个部分为了一个共同、明确的目标而紧密协作。例如，一个“订单处理服务”应该只包含与创建、更新、查询订单相关的逻辑，而不应该包含用户认证或产品推荐的逻辑。一个架构量子内的所有组件，共同构成了一个完整且内聚的业务功能。</li>
<li><strong>同步部署依赖性 (Synchronous Deployable Dependency)</strong>：这是定义中最关键也最“硬核”的部分。它指的是组件之间的行为调用必须是同步的，以保证系统正常工作。如果服务 A 调用服务 B，并且必须等待 B 的响应才能继续执行，那么 A 和 B 之间就存在同步依赖。这种依赖关系会将不同的独立部署组件“捆绑”在一起，形成一个不可分割的整体，也就是一个量子。如果为了让某个功能正常工作，你必须同时部署或更新服务 A 和服务 B，那它们就属于同一个量子。</li>
</ul>
<h2>为什么架构量子很重要？</h2>
<p>理解了定义后，我们来看看它在实践中的重要性。架构量子的概念为我们提供了一个强大的分析工具，帮助我们衡量和决策架构中的关键架构特性，例如：</p>
<ol>
<li><strong>可部署性 (Deployability)</strong>：一个架构量子是<strong>最小的独立部署单元</strong>。整个量子可以作为一个单元进行部署、回滚和发布，而不会破坏系统的其他部分。这极大地简化了 CI/CD 流程。如果你错误地将一个量子拆分成多个，可能会导致部署时的级联失败。</li>
<li><strong>可测试性 (Testability)</strong>：由于量子内部的组件功能高度内聚且存在同步依赖，因此它也成为了一个理想的<strong>测试边界</strong>。你可以对整个量子进行端到端的功能测试和集成测试，而无需启动整个庞大的系统。</li>
<li><strong>可伸缩性 (Scalability)</strong>：不同的量子承载不同的业务功能，其负载模式也可能完全不同。例如，浏览产品目录的量子和处理支付的量子对资源的需求天差地别。将它们划分为不同的量子，使得我们可以<strong>独立地扩展</strong>每一个量子，从而更高效地利用资源。</li>
<li><strong>容错性 (Fault Tolerance)</strong>：一个设计良好的量子边界可以形成一道“防火墙”。一个量子的失败（例如，由于代码缺陷或流量激增）不应该导致其他量子的同步崩溃。这种隔离性是构建高可用系统的基础。</li>
<li><strong>组织结构对齐 (Alignment with Team Structure)</strong>：根据康威定律 (Conway&#39;s Law)，系统架构往往会反映出开发它的团队的沟通结构。一个清晰的量子可以由一个独立的、自治的团队负责，从而减少跨团队沟通的开销，提升开发效率。</li>
</ol>
<p>简而言之，架构量子帮助我们识别出系统中<strong>真正的、不可再分的架构单元</strong>。它提供了一个明确的边界，指导我们如何合理地拆分系统，从而在可部署性、可伸缩性、容错性和团队效率之间取得平衡。</p>
<h2>场景分析一：单一 UI + 四个独立服务</h2>
<blockquote>
<p>假设一个系统包含一个单一的用户界面，以及四个独立部署的服务，每个服务都包含自己的独立数据库。这个系统会有一个量子还是四个量子？为什么？</p>
</blockquote>
<p>答案是：<strong>这个系统最有可能包含四个量子 (Four Quanta)</strong>。</p>
<p><strong>分析如下：</strong></p>
<p>这里的关键信息是“四个<strong>独立部署</strong>的服务，每个服务都包含<strong>自己的独立数据库</strong>”。</p>
<ol>
<li><strong>独立部署与自有数据库</strong>：这个设定强烈暗示了服务之间的高度解耦。在现代架构中，服务独占自己的数据库是实现真正自治和独立部署的黄金法则。如果服务间共享数据库，它们的部署就会产生耦合（例如，一个服务修改了表结构，可能会影响到所有依赖该表的其他服务），也就无法做到真正的独立部署。</li>
<li><strong>同步依赖的缺失</strong>：虽然这四个服务最终都服务于同一个用户界面 (UI)，但题目并未描述它们之间存在<strong>同步调用</strong>的强依赖关系。UI 很可能是通过异步的方式或者直接独立地与这四个服务进行通信。例如，UI 的一个页面可能需要同时展示来自服务 A 的用户信息和服务 B 的产品列表，但 UI 可以分别向 A 和 B 发起两个独立的 API 请求，这两个服务之间并不需要直接对话。</li>
<li><strong>功能内聚性</strong>：每个服务和它自己的数据库共同构成了一个高度内聚的功能单元。例如，服务 A 和它的数据库负责“用户管理”，服务 B 和它的数据库负责“订单管理”，等等。它们各自完成了闭环的业务能力。</li>
</ol>
<p><strong>结论</strong>：由于这四个服务（连同其数据库）可以独立部署，并且它们之间大概率不存在必须同步成功的强依赖，因此它们构成了四个独立的架构量子。单一的用户界面在这里扮演的是一个“集成层”或“客户端”的角色，它本身通常不被视为一个量子，而是作为这些量子的消费者。将系统划分为四个量子，使得每个服务都可以被独立地开发、测试、部署和扩展，从而获得了极大的架构灵活性。</p>
<h2>场景分析二：管理后台 + 用户端</h2>
<blockquote>
<p>假设一个系统包含两个部分：</p>
<ul>
<li>管理后台：负责管理静态参考数据（例如产品目录、仓库信息）。</li>
<li>用户端：负责处理客户订单的下达。</li>
</ul>
<p>这个系统应该被划分为多少个量子？为什么？如果您设想这是多个量子，那二者可以共享同一个数据库吗？如果可以，该数据库需要驻留在哪个量子中？</p>
</blockquote>
<h3>这个系统应该被划分为多少个量子？为什么？</h3>
<p>答案是：<strong>这个系统应该被划分为两个量子 (Two Quanta)</strong>。</p>
<p><strong>分析如下：</strong></p>
<ol>
<li><strong>不同的架构特性需求</strong>：<ul>
<li><strong>用户端 (Customer-facing Portion)</strong>：这是系统的核心交易部分。它需要<strong>高可用性 (High Availability)</strong>、<strong>高可伸缩性 (High Scalability)</strong>（因为用户流量波动大，尤其在促销期间）、以及<strong>低延迟 (Low Latency)</strong>。</li>
<li><strong>管理后台 (Administration Portion)</strong>：这部分主要由内部员工使用。它对可伸缩性的要求远低于用户端，但可能对<strong>数据一致性 (Consistency)</strong> 和安全性有更高的要求。其使用模式也更可预测。</li>
</ul>
</li>
<li><strong>功能内聚性与关注点分离</strong>：管理后台的功能（管理产品目录、仓库信息）和用户端的功能（浏览商品、下单、支付）在业务上是完全不同的。将它们分开，符合单一职责原则，也使得各自的逻辑更清晰。</li>
<li><strong>部署和生命周期的独立性</strong>：用户端的功能可能需要频繁迭代和快速发布（例如，上线一个新的促销活动），而管理后台的功能则相对稳定，更新频率较低。将它们划分为两个量子，可以实现独立的部署和发布节奏，用户端的紧急修复或更新不会被后台的发布流程所拖累。</li>
</ol>
<p><strong>结论</strong>：基于截然不同的架构特性需求、功能内聚性以及部署独立性的考量，将这个系统划分为一个“管理后台量子”和一个“用户端量子”是最佳实践。</p>
<h3>二者可以共享同一个数据库吗？</h3>
<p>答案是：<strong>技术上可以，但强烈不推荐 (Technically possible, but highly discouraged)</strong>。共享数据库会引入我们之前提到的问题，即<strong>耦合 (Coupling)</strong>。</p>
<ul>
<li><strong>性能耦合</strong>：管理后台的一个慢查询或数据批量导入操作，可能会锁住表，从而严重影响用户端的性能，甚至导致用户无法下单。</li>
<li><strong>部署耦合</strong>：如果用户端需要修改某个表的结构来支持新功能，这个修改可能会破坏管理后台的正常工作，反之亦然。这使得两个本应独立的量子在部署上产生了依赖。</li>
<li><strong>安全耦合</strong>：用户端和管理后台的数据库访问权限需求是不同的。共享数据库会增加权限管理的复杂性，可能导致安全漏洞。</li>
</ul>
<h3>如果一定要共享，数据库需要驻留在哪个量子中？</h3>
<p>这是一个权衡和妥协的问题。如果因为历史原因、成本限制或其他因素<strong>不得不</strong>共享数据库，那么决策的关键在于<strong>数据的所有权 (Data Ownership)</strong> 和<strong>服务的关键性 (Service Criticality)</strong>。</p>
<p>在这个场景中，“产品目录”和“仓库信息”这些数据，虽然由管理后台进行维护，但它们的最终消费者和价值实现者是<strong>用户端</strong>。用户下单的逻辑严重依赖于这些数据的可用性和准确性。</p>
<p>因此，如果必须共享，该数据库在逻辑上应该<strong>驻留在用户端量子中</strong>。</p>
<p><strong>原因如下：</strong></p>
<ol>
<li><strong>业务关键性</strong>：用户端是直接产生商业价值的部分，其可用性是第一位的。将数据库置于此量子内，意味着所有架构决策（如扩展、备份、容灾）都将优先保障用户端的需求。</li>
<li><strong>数据所有权</strong>：虽然管理后台是数据的“生产者”，但用户端是数据的核心“消费者”。在领域驱动设计 (Domain-Driven Design) 的思想中，数据应该属于它所支持的核心业务领域 (Core Domain)，在这里显然是用户交易领域。</li>
<li><strong>架构上的清晰性</strong>：这样做可以建立一个清晰的依赖关系：管理后台量子依赖于用户端量子中的数据。这虽然不是最理想的解耦状态，但至少依赖关系是单向且明确的。</li>
</ol>
<p>在这种共享模式下，更好的实践是通过<strong>定义稳定的 API</strong> 来缓解耦合。管理后台不应直接操作数据库，而是应该通过用户端量子提供的 API 来修改产品目录等数据。这样做可以隐藏数据库的物理实现，为未来的数据库拆分创造可能性。</p>
]]></content:encoded>
    </item>
    <item>
      <title>课程笔记丨《手把手带你写一个 Web 框架》</title>
      <link>https://hedon.top/blog/note-write-a-web-framework/</link>
      <guid isPermaLink="true">https://hedon.top/blog/note-write-a-web-framework/</guid>
      <pubDate>Mon, 07 Jul 2025 23:02:00 GMT</pubDate>
      <description>极客时间《手把手带你写一个 Web 框架》课程笔记。</description>
      <category>课程笔记</category>
      <content:encoded><![CDATA[<h2>框架&quot;零件&quot;</h2>
<h3>context</h3>
<ol>
<li>框架级 Context：可以组合优秀框架（如 gin.Context）的基础上，扩展自己常用的功能函数。</li>
<li>业务级 Context：针对具体的业务，组合框架 Context，封装更多的业务工具函数，进一步提升效率。</li>
<li>如果有必要进步提升性能的话，可以使用 sync.Pool 对 Context 进行管理，避免 Context 频繁创建销毁带来的性能损耗。</li>
<li>灵活使用链路调用，有助于提升代码的清晰度和可扩展性。</li>
</ol>
<h3>路由匹配</h3>
<p>Gin 使用 <a href="https://en.wikipedia.org/wiki/Radix_tree">radix tree</a>，尽可能压缩路由的公共前缀，同时使用 indices 加速路由的检索。</p>
<p><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Patricia_trie_var.svg/350px-Patricia_trie_var.svg.png" alt="radix tree"></p>
<h3>中间件</h3>
<p>使用洋葱型中间件，可以很方便地进行 AOP 编程，有很大的扩展性。</p>
<p>如 Gin 框架中：</p>
<pre><code class="language-go">// HandlersChain defines a HandlerFunc slice.
type HandlersChain []HandlerFunc

// HandlerFunc defines the handler used by gin middleware as return value.
type HandlerFunc func(*Context)
</code></pre>
<p>同时为 <code>IRouter</code> 接口也定义 <code>Group</code> 函数，这样可以进一步提升聚合类的逻辑复用：</p>
<pre><code class="language-go">// IRouter defines all router handle interface includes single and group router.
type IRouter interface {
	IRoutes
	Group(string, ...HandlerFunc) *RouterGroup
}
</code></pre>
<p>在执行过程中，使用 <code>c.Next()</code> 和 <code>c.Abort()</code> 来进行处理器调用或提前退出等逻辑控制：</p>
<pre><code class="language-go">const abortIndex int8 = math.MaxInt8 &gt;&gt; 1

func (c *Context) Next() {
	c.index++   // 初始化 c.index = -1
	for c.index &lt; int8(len(c.handlers)) {
		if c.handlers[c.index] != nil {
			c.handlers[c.index](c)
		}
		c.index++
	}
}

func (c *Context) Abort() {
	c.index = abortIndex  // 设置为最大值，后面的 next 就会直接返回
}
</code></pre>
<h3>扩展现有框架</h3>
<p>相比于自己从零开始写一个 Web 框架，完全可以站在前人的肩膀上，将流行的、好用的、开源协议允许的框架代码拷贝到自己的仓库中，进行改造升级，从而快速搭建一个功能完善、经过验证、贴合团队需要的强悍 Web 框架。</p>
<h2>一切皆服务</h2>
<p>按照面向接口编程的理念，将每个模块看成是一个服务，服务的具体实现我们其实并不关心，我们关心的是服务提供的能力，即接口协议。那么框架主体真正要做的事情是什么呢？其实是：<strong>定义好每个模块服务的接口协议，规范服务与服务之间的调用，并且管理每个服务的具体实现</strong>。 </p>
<p>所有的服务都去框架主体中注册自身的模块接口协议，其他的服务调用功能模块的时候，并不是直接去这个服务获取实例，而是从框架主体中获取有这个接口协议的服务实例。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250707232209688.png" alt="一切皆服务"></p>
<h3>容器 container</h3>
<p>服务提供接口定义可参考：</p>
<pre><code class="language-go">// NewInstance is a function that creates a new instance of a service
type NewInstance func(...any) (any, error)

// ServiceProvider is an interface that defines a service provider
type ServiceProvider interface {
	// Register a service provider into the container,
	// whether to initialize the service or not determined by the IsDefer method
	Register(Container) NewInstance
	// Boot the service provider, this method will be called after the container is initialized.
	// Is id recommend to do some initialization work in this method.
	// If returns error, the service initialization will be failed.
	Boot(Container) error
	// IsDefer determines whether the service provider should be deferred.
	// If true, the service provider will be deferred until the first time the service is used.
	IsDefer() bool
	// Params are the parameters which would be passed to the NewInstance function.
	Params(Container) []any
	// Name is a method that returns the unique name of the service provider.
	Name() string
}
</code></pre>
<p>容器接口定义可参考：</p>
<pre><code class="language-go">// Container is a service provider container, provides methods to register and resolve service providers.
type Container interface {
	// Bind binds a service provider into the container,
	// if the service provider is already bound, it would panic.
	Bind(provider ServiceProvider) error
	// IsBind checks if a service provider is bound into the container
	IsBind(key string) bool
	// Make resolves a service provider from the container
	Make(key string) (any, error)
	// MustMake resolves a service provider from the container, if not found, it will panic
	MustMake(key string) any
	// MakeNew creates a new instance of a service provider,
	// it is useful when you need to create a new instance of a service provider
	// and pass some different parameters to the service provider&#39;s constructor.
	MakeNew(key string, params ...any) (any, error)
}
</code></pre>
<p>接口实现可参考：</p>
<pre><code class="language-go">// Helps to check if the Container interface is implemented
var _ Container = (*HdwebContainer)(nil)

// HdwebContainer is the default implementation of the Container interface
type HdwebContainer struct {
	// providers is a map of service providers, key is the name of the service provider
	providers map[string]ServiceProvider
	// instances is a map of service instances, key is the name of the service
	instances map[string]any
	// lock is used to protect the container from concurrent access
	lock sync.RWMutex
}

// NewHdwebContainer creates a new HdwebContainer
func NewHdwebContainer() *HdwebContainer {
	return &amp;HdwebContainer{
		providers: make(map[string]ServiceProvider),
		instances: make(map[string]any),
		lock:      sync.RWMutex{},
	}
}

// Bind binds a service provider into the container,
// if the service provider is already bound, &#39;
// it will replace the existing one and return an error.
func (h *HdwebContainer) Bind(provider ServiceProvider) error {
	h.lock.Lock()
	key := provider.Name()
	if _, ok := h.providers[key]; ok {
		h.lock.Unlock()
		panic(&quot;service provider already bound: &quot; + key)
	}
	h.providers[key] = provider
	h.lock.Unlock()

	if provider.IsDefer() {
		return nil
	}

	if err := provider.Boot(h); err != nil {
		return err
	}

	params := provider.Params(h)
	method := provider.Register(h)

	instance, err := method(params...)
	if err != nil {
		return err
	}

	h.lock.Lock()
	defer h.lock.Unlock()
	if _, ok := h.instances[key]; ok {
		panic(&quot;service provider already resolved: &quot; + key)
	}
	h.instances[key] = instance
	return nil
}

// IsBind checks if a service provider is bound into the container
func (h *HdwebContainer) IsBind(key string) bool {
	return h.findServiceProvider(key) != nil
}

// Make resolves a service provider from the container
func (h *HdwebContainer) Make(key string) (any, error) {
	return h.make(key, nil, false)
}

// MakeNew creates a new instance of a service provider,
// it is useful when you need to create a new instance of a service provider
// and pass some different parameters to the service provider&#39;s constructor.
func (h *HdwebContainer) MakeNew(key string, params ...any) (any, error) {
	return h.make(key, params, true)
}

// MustMake resolves a service provider from the container, if not found, it will panic
func (h *HdwebContainer) MustMake(key string) any {
	ins, err := h.make(key, nil, false)
	if err != nil {
		panic(err)
	}
	return ins
}

func (h *HdwebContainer) make(key string, params []any, forceNew bool) (any, error) {
	sp := h.findServiceProvider(key)
	if sp == nil {
		return nil, errors.New(&quot;service provider not found: &quot; + key)
	}
	if forceNew {
		return h.newInstance(sp, params)
	}

	if ins := h.getInstance(key); ins != nil {
		return ins, nil
	}

	h.lock.Lock()
	defer h.lock.Unlock()
	if ins, ok := h.instances[key]; ok {
		return ins, nil
	}
	ins, err := h.newInstance(sp, params)
	if err != nil {
		return nil, err
	}
	h.instances[key] = ins
	return ins, nil
}

func (h *HdwebContainer) getInstance(key string) any {
	h.lock.RLock()
	defer h.lock.RUnlock()
	return h.instances[key]
}

func (h *HdwebContainer) newInstance(sp ServiceProvider, params []any) (any, error) {
	if err := sp.Boot(h); err != nil {
		return nil, err
	}
	if params == nil {
		params = sp.Params(h)
	}
	method := sp.Register(h)
	return method(params...)
}

func (h *HdwebContainer) findServiceProvider(key string) ServiceProvider {
	h.lock.RLock()
	defer h.lock.RUnlock()
	return h.providers[key]
}
</code></pre>
<h3>服务 service provider</h3>
<ul>
<li>contract：服务功能接口定义</li>
<li>provider：为服务实现 ServiceProvider 接口</li>
<li>service：实现服务 contract 功能接口</li>
</ul>
<h4>框架级服务</h4>
<pre><code class="language-yaml">- contract  # 服务接口定义
	- app.go
	- kernel.go
	- env.go
	- config.go
- provider  # 服务实现
	- app # app 服务
		- provider.go # 实现 Service Provider
		- service.go  # 实现服务接口
	- kernel
		- provider.go
		- service.go
</code></pre>
<h4>业务级服务</h4>
<pre><code class="language-yaml">- {root}  # 根目录
	- app # app 目录
	- provider # 通用业务服务
		- user  # user 服务
			- contract.go # user 服务接口定义
			- service.go  # user 服务接口实现
			- provider.go # 为 user 服务实现 Service Provider
		- mail
			- contract.go
			- service.go
			- provider.go
</code></pre>
<h2>自动化 DRY</h2>
<p>在业务开发过程中，对于那些重复性的类模板劳动，可以使用 CLI 命令行工具，或其他自动化工具，来简化这些劳动输出。</p>
<p>这里有 2 个思路，一个是使用 Makefile，一个是使用 CLI（Go 里面可以使用 <code>cobra</code> 框架）。选择的时候可以考虑以下几个点：</p>
<ol>
<li>命令变动的频率（二者在这一点区别不大，不过如果变动频率比较低，那 CLI 的劣势就相对可以忽略了）</li>
<li>命令使用的复杂性（参数越多，则需要越详尽的帮助说明）</li>
<li>业务逻辑相关性（越相关，则逻辑越复杂，使用代码越好管控）</li>
</ol>
<p>常见的思路有：</p>
<ul>
<li>生成项目脚手架（init）</li>
<li>项目启动管理（build、start、stop、restart、update）</li>
<li>服务模版生成（provider list/new）—— 可以结合  <code>survey</code> 做命令行渐进式输入，<code>template</code> 生成模板代码</li>
<li>命令行系列生成（command list/new）</li>
<li>定时任务（cron list/run）</li>
<li>swagger 生成（swagger gen）</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨06丨评估和管理架构特性</title>
      <link>https://hedon.top/blog/fosa-ch6/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch6/</guid>
      <pubDate>Mon, 07 Jul 2025 11:00:00 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第六章的课后思考题，深入探讨如何衡量和管理架构特性，分析圈复杂度等结构性指标的重要性、架构适应度函数的定义与应用，以及运维性、结构性、流程性指标的评估方法，帮助理解如何建立有效的架构治理机制来持续监控和优化系统架构。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第六章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>Why is cyclomatic complexity such an important metric to analyze for architecture?</p>
<p>为什么圈复杂度是架构分析的重要指标？</p>
</li>
<li><p>What is an architecture fitness function? How can they be used to analyze an architecture?</p>
<p>什么是架构适应度函数？它们如何用于分析架构？</p>
</li>
<li><p>Provide an example of an architecture fitness function to measure the scalability of an architecture.</p>
<p>提供一个衡量架构可伸缩性的架构适应度函数示例。</p>
</li>
<li><p>What is the most important criteria for an architecture characteristic to allow architects and developers to create fitness functions?</p>
<p>允许架构师和开发人员创建适应度函数的最重要标准是什么？</p>
</li>
</ol>
<hr>
<h2>评估架构特性</h2>
<p>评估架构特性一般可以从 3 个方面入手：</p>
<ul>
<li>运维性指标 (operational measures)：主要关注系统在运维层面的能力，涵盖性能、可扩展性、弹性、可用性、可靠性等能力。</li>
<li>结构性指标 (structural measures)：关注代码结构，如模块化、组件间受控的耦合、可读代码以及其他内部质量评估。常用工具有：<ul>
<li><strong>圈复杂度 (Cyclomatic Complexity，CC)</strong>：一个代码层面的度量标准，由 Thomas McCabe, Sr. 于 1976 年开发，通过分析代码的决策点（如 if 语句）来量化代码的复杂性。高圈复杂度可能表明代码难以理解和测试。公式为 <code>CC = E - N + 2</code>（针对单个函数），或 <code>CC = E - N + 2P</code>（针对扇出调用）。行业普遍认为 CC 值低于 10 是可接受的，但更倾向于低于 5。</li>
<li><strong>距主序列距离 (Distance from the Main Sequence，D)</strong>：一个基于抽象性（A）和不稳定性（I）的综合指标，公式为 <code>D = |A + I - 1|</code>。它反映了抽象性和不稳定性之间的理想关系。远离理想线的类可能落入&quot;无用区&quot;（过于抽象难以使用）或&quot;痛苦区&quot;（过于具体且难以维护）</li>
</ul>
</li>
<li>流程性指标 (process measures)：关注软件开发过程中的特性，如敏捷性、可测试性和可部署性。常用工具有：<ul>
<li><strong>代码覆盖率（code coverage）</strong></li>
</ul>
</li>
</ul>
<h2>管理架构特性</h2>
<p>管理架构特性主要通过 4 个方面：</p>
<ul>
<li><strong>架构适应性函数 (Architecture Fitness Functions)</strong>：这是评估系统输出质量的客观函数，用于衡量架构特性。它们将重要的架构原则编码到软件基础中，并自动验证这些原则是否得到遵守。例如：<ul>
<li>检测组件之间的循环依赖 (Cyclic Dependencies)</li>
<li>验证分层架构中的层间依赖关系</li>
<li>衡量距主序列的距离</li>
<li>混沌工程</li>
</ul>
</li>
<li><strong>架构决策记录 (Architecture Decision Records, ADRs)</strong>：ADR 是一种有效的文档化架构决策的方式，通常是一到两页的短文本文件。每个 ADR 应包含标题、状态（例如“已接受”、“已取代”）、上下文、决策（使用肯定性语言）和结果（包括决策的正面和负面影响，以及权衡分析）。ADR 使得架构师能够清晰记录决策的技术和业务理由，避免重复讨论和误解。ADR 中的“合规性 (Compliance)”部分可以强制架构师思考如何衡量和管理决策的合规性，无论是手动还是通过适应性函数自动化。</li>
<li><strong>风险风暴 (Risk Storming)</strong>：这是一种协作活动，用于识别、达成共识并减轻架构风险。它包括识别（个体非协作活动）、共识（协作活动，讨论并统一风险评估）和缓解（协作活动，寻找减少或消除风险的方法）三个主要阶段。风险风暴通常使用风险矩阵 (Risk Matrix)，通过“影响”和“可能性”两个维度来量化风险。风险评估报告还可以显示特定风险类别或领域随时间的改进或恶化，使用加号 (+) 和减号 (-) 表示方向。</li>
<li><strong>持续沟通与协作</strong>：有效的沟通对于知识共享和项目成功至关重要。架构师应与产品负责人、项目经理、业务干系人以及开发人员进行谈判和协商，以获得架构决策的批准。倡导通用语言 (Ubiquitous Language)，确保所有项目相关方使用相同的业务领域术语，从而减少信息丢失和误解。</li>
</ul>
<h2>回答问题</h2>
<p>基于上述对本章的概括回顾，回到本章的 4 个课后题，笔者梳理了一下自己的理解，供读者们参考。</p>
<p>圈复杂度：</p>
<ol>
<li>圈复杂度是一种评估代码复杂性的工具。</li>
<li>如果一个函数（方法）中，条件分支和语句越多，则说明越复杂，一方面可能是业务逻辑本身就足够复杂，另外一方面，也很可能是代码的模块拆分没有做好，逻辑没有梳理清晰，写成了一坨。</li>
<li>这背后体现了模块化、可测试性、可部署性、可扩展性、可迭代性等多种代码结构层面的架构特性。</li>
</ol>
<p>架构适应度函数：</p>
<ol>
<li>架构适应度函数是一种用于持续评估当前架构是否满足需求的机制，可以理解为&quot;架构的单元测试&quot;。</li>
<li>不同的组织、不同的团队、不同的职责对同一个架构特性的理解、定义和需求都是不尽相同的。通过协商、建立其符合具体需求的架构适应度函数，在达成共识的基础上，可以持续对某些架构特性进行达标检测，避免偏离。比如代码测试覆盖率可以用来检测架构的可测试性、部署耗时可以用来横向架构的可部署性、可迭代性等。</li>
<li>要编写 fitness function，最重要也是唯一最重要的标准是<strong>架构特性必须能够被客观地衡量和定义</strong>。通过鼓励客观定义，团队可以拆解复合特性，从而发现可以被客观衡量的功能。一旦特性被具体定义，就可以更容易地建立相应的适应度函数来验证其完整性。</li>
</ol>
<p>衡量系统的可伸缩性：</p>
<ol>
<li>可伸缩性，指的是系统在用户或请求数量增加时，仍然能够维持性能和运行的能力。</li>
<li>假如说我们现在有一个订单服务，如果我们希望它具备良好的可伸缩性，当我们将服务实例从 2 个增加到 4 个时，系统在相同响应时间基准下，应用能处理接近翻倍的请求吞吐量。</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨05丨识别架构特征</title>
      <link>https://hedon.top/blog/fosa-ch5/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch5/</guid>
      <pubDate>Fri, 04 Jul 2025 10:24:26 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第五章的课后思考题，深入探讨如何识别架构特征，分析限制架构特性数量的重要性、架构特性的来源、业务驱动与架构特性的关系，以及可伸缩性与弹性等关键特性的区别，帮助理解如何从业务需求中提取和选择合适的架构特性。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第五章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>Give a reason why it is a good practice to limit the number of characteristics (“-ilities”) an architecture should support.</p>
<p>举一个例子来说明为什么限制系统中支持的架构特性的数量的有必要的。</p>
</li>
<li><p>True or false: most architecture characteristics come from business requirements and user stories.</p>
<p>大多数的架构特性都来自于业务需求和用户故事，这对吗？</p>
</li>
<li><p>If a business stakeholder states that time-to-market (i.e., getting new features and bug fixes pushed out to users as fast as possible) is the most important business concern, which architecture characteristics would the architecture need to support?</p>
<p>如果一个业务利益相关者将上市时间（比如以最快的速度实现新功能和修复 BUG）视为最重要的需求点，这个时候需要支持什么样的架构特性？</p>
</li>
<li><p>What is the difference between scalability and elasticity?</p>
<p>可伸缩性（scalability） 和弹性（elasticity）的区别是什么？</p>
</li>
<li><p>You find out that your company is about to undergo several major acquisitions to significantly increase its customer base. Which architectural characteristics should you be worried about?</p>
<p>如果你发现了你的公司进行了几次重大收购以大幅增加其客户群，这个时候你应该考虑什么架构特性？</p>
</li>
</ol>
<hr>
<h2>架构特性不是越多越好</h2>
<ul>
<li><strong>增加系统设计的复杂性</strong>：每增加一个架构特性，都会使整个系统设计变得更加复杂。支持过多的架构特性会导致在架构师和开发人员开始解决核心业务问题之前，系统就变得越来越复杂。</li>
<li><strong>分散对核心问题的关注</strong>：架构特性定义了系统的成功标准，通常与系统的功能性正交，关注的是“如何”实现需求以及“为什么”做出某些选择。然而，如果过度追求特性数量，可能会导致偏离原始的业务问题，即开发软件的最初动机。</li>
<li><strong>每个特性都涉及权衡</strong>：软件架构中的每一个方面都存在权衡，有优点也有缺点。例如，在拍卖系统中，选择使用主题（topic）进行通信可能带来架构可扩展性的优势和服务的解耦，但会引入数据访问和数据安全方面的潜在问题，并且不支持异构契约。而使用队列（queue）则允许每个消费者拥有自己的契约，但不具备可扩展性，并且会增加服务间的耦合。架构师需要分析这些权衡，并根据业务驱动因素和环境选择最重要的特性。</li>
<li><strong>过度规范的危害</strong>：架构师过度规范架构特性是常见的陷阱，其破坏性不亚于规范不足，因为它会使系统设计过于复杂。历史案例“瓦萨号”战舰的失败就是一个例证，它是因为过度追求建造最宏伟的战舰（即过度规范架构特性）而最终导致沉没。</li>
<li><strong>陷入“意外复杂性”陷阱</strong>：架构师有时会为解决方案、图表和文档添加不必要的复杂性。正如一位作者所言，“开发者被复杂性吸引，就像飞蛾扑火一样——结果往往相同”。这种“意外复杂性”是由于人为地使问题复杂化，而不是问题本身固有的复杂性。通过识别子领域类型并根据其业务逻辑的复杂性选择合适的实现模式（例如，事务脚本和活动记录适用于简单业务逻辑，而领域模型和事件溯源领域模型适用于复杂的核心子领域），可以避免引入不必要的复杂性。</li>
<li><strong>设计应由业务驱动</strong>：领域驱动设计（DDD）的核心思想在于让业务领域驱动软件设计决策。这意味着设计决策应该基于业务领域的需求和战略，而非盲目地堆砌所有可能的架构特性。</li>
</ul>
<p>因此，与领域利益相关者合作时，架构师应努力使最终的架构特性列表尽可能短，因为每个特性都会增加总体系统设计的复杂性。</p>
<h2>如何识别架构特性</h2>
<ol>
<li>从领域焦点中识别架构特性</li>
<li>从业务需求中识别架构特性</li>
</ol>
<p>这里面的一大难点就是：<strong>业务方与开发方使用的不是同一种&quot;语言&quot;</strong>。双方对同一件事情的关注点是不一样的，所以表述出来的述求，也是不同的。</p>
<p>所以在识别架构特性的时候，架构师的职责就是需要将业务领域的关注点和架构特性进行对应。比如：</p>
<table>
<thead>
<tr>
<th>Domain Concern</th>
<th>Architecture characteristics</th>
</tr>
</thead>
<tbody><tr>
<td>Mergers and acquisitions 合并与收购</td>
<td>互操作性 interoperability<br>可扩展性 scalability<br>适配性 adaptability<br>可扩展性 extensibility</td>
</tr>
<tr>
<td>Time to market 上市时间</td>
<td>灵活性 agility<br>可测试性 testability<br>可部署性 deployability</td>
</tr>
<tr>
<td>User satisfaction 用户满意度</td>
<td>性能 performance<br>可用性 availability<br>容错性 fault tolerance<br>可测试性 testability<br>可部署性 deployability<br>灵活性 agility<br>安全性 security</td>
</tr>
<tr>
<td>Competitive advantage 竞争优势</td>
<td>灵活性 agility<br/>可测试性 testability<br/>可部署性 deployability<br/>可扩展性 scalability<br/>可用性 availability<br/>容错性 fault tolerance</td>
</tr>
<tr>
<td>Time and budget 时间和预算</td>
<td>简单性 simplicity<br>可行性 feasibility</td>
</tr>
</tbody></table>
<p>另外， 随着业务的发展，关注点也是在不断发生变化的，这个时候，架构所侧重的架构特性也是随之改变的。</p>
<h2>可扩展性 vs 弹性</h2>
<ul>
<li><strong>可伸缩性（Scalability）</strong>：<u>指的是系统在用户或请求数量增加时，仍然能够维持性能和运行的能力</u>。它衡量的是系统在负载线性增加时，性能是否能够保持相应的线性增长。例如，如果一个系统在用户增加一倍时，其性能也能线性提升，那么它就是可伸缩的。这通常通过增加资源（如服务器实例）来实现，以应对持续增长的用户数量。</li>
<li><strong>弹性（Elasticity）</strong>：<u>指的是系统处理请求突发性增长的能力</u>。它关注的是系统如何有效地应对不可预测和可变的用户流量高峰。例如，音乐会售票系统在门票开售时会经历用户流量的突然飙升，这需要高弹性的支持。一个具有弹性的系统能够在流量高峰时动态地启动新的处理单元（Processing Units），并在负载降低时关闭它们</li>
</ul>
<p>简而言之，可伸缩性是关于处理增加的负载并保持性能，而弹性是关于处理突发性、不可预测的负载波动。</p>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨04丨架构特性定义</title>
      <link>https://hedon.top/blog/fosa-ch4/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch4/</guid>
      <pubDate>Thu, 03 Jul 2025 10:35:26 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第四章的课后思考题，深入探讨架构特性（Architecture Characteristics）的定义与分类，分析架构特性的三个判定标准、隐性与显性特性的区别，以及操作型、结构型、交叉型特性的具体表现形式，帮助理解如何识别和选择适合系统需求的架构特性。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第四章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>What three criteria must an attribute meet to be considered an architecture characteristic?</p>
<p>一个属性要被认定为架构特性，必须满足哪三个条件？</p>
</li>
<li><p>What is the difference between an implicit characteristic and an explicit one? Provide an example of each.</p>
<p>隐性特性和显性特性的区别是什么？请分别举一个例子。</p>
</li>
<li><p>Provide an example of an operational characteristic.</p>
<p>提供一个操作特性的例子。</p>
</li>
<li><p>Provide an example of a structural characteristic.</p>
<p>提供一个结构特性的例子。</p>
</li>
<li><p>Provide an example of a cross-cutting characteristic.</p>
<p>提供一个交叉特性的例子。</p>
</li>
<li><p>Which architecture characteristic is more important to strive for—availability or performance?</p>
<p>在架构特性中，更应努力追求的是可用性还是性能？</p>
</li>
</ol>
<hr>
<h2>架构特性定义</h2>
<p>一个属性要成为架构特性（Architecture Characteristics），需至少满足 3 个条件：</p>
<ol>
<li><strong>指定非领域设计考量</strong>：架构特性关注的是应用程序&quot;如何&quot;实现需求以及做出某些选择&quot;为何&quot;的原因，而不是应用程序&quot;应该做什么&quot;的业务需求。例如，性能水平通常不会出现在需求文档中，但却是重要的架构特性。</li>
<li><strong>影响设计的某个结构方面</strong>：如果一个架构特性需要特殊结构考虑才能成功，那么它就会上升到架构特性的层面。例如，一般的安全性对于几乎所有项目都是必需的，但当需要设计特定的模块、组件或服务来隔离关键安全问题时，安全才成为一个架构特性。</li>
<li><strong>对应用程序的成功至关重要</strong>：应用程序可以支持大量的架构特性，但并非所有都应该被支持。支持每个架构特性都会增加设计的复杂性，因此，架构师的关键任务是选择最少的、对应用程序成功至关重要或重要的架构特性，而不是尽可能多的。</li>
</ol>
<h2>显性 &amp; 隐性</h2>
<ul>
<li><p><strong>显性架构特性 (Explicit Architecture Characteristics)</strong> 是在需求规范中明确列出的，作为必要设计的一部分。它们通常直接出现在需求文档或其他具体说明中。</p>
<blockquote>
<p>在书中的 Silicon Sandwiches 的案例中，用户数量（“当前数千，未来可能数百万”）就隐含地要求了可伸缩性 (Scalability)，即在不严重降低性能的情况下处理大量并发用户的能力。虽然需求没有明确提出可伸缩性，但从用户数量的描述中可以推断出来</p>
</blockquote>
</li>
<li><p><strong>隐性架构特性 (Implicit Architecture Characteristics)</strong> 很少出现在需求文档中，但它们对于项目的成功是必需的。架构师必须利用他们对问题领域的知识，在分析阶段发现这些特征。</p>
<blockquote>
<p>可用性 (Availability) 是一种隐性特征，它确保用户可以访问系统。与可用性密切相关的是可靠性 (Reliability)，它确保网站在交互过程中保持正常运行，不会出现连接中断等问题。这些特征通常不会在设计文档中明确指定，但对于几乎所有应用程序都至关重要。</p>
</blockquote>
</li>
</ul>
<h2>操作特性</h2>
<p>操作性架构特性涵盖了系统的<strong>运行能力</strong>，例如性能、可伸缩性、弹性、可用性和可靠性等。这些特性通常与运营和 DevOps 关注点高度重叠。</p>
<table>
<thead>
<tr>
<th>特性</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>Availability</td>
<td>系统需要保持可用的时间长度；例如，如果需要 24/7 可用，则需要采取措施确保系统始终可用。它指的是软件可操作和可访问的程度。</td>
</tr>
<tr>
<td>Continuity</td>
<td>灾难恢复能力。</td>
</tr>
<tr>
<td>Performance</td>
<td>衡量应用程序请求和响应周期所需的时间。它包括压力测试、高峰分析、功能使用频率分析、所需容量和响应时间。它也可以是更具体的度量，例如首屏渲染时间，即网页首次可见的时间。</td>
</tr>
<tr>
<td>Recoverability</td>
<td>业务连续性要求（例如，发生灾难时，系统需要多快才能重新上线？）这将影响备份策略和对复制硬件的要求。它也指软件从故障中恢复的能力，通过恢复任何受影响的数据并重新建立系统的所需状态。</td>
</tr>
<tr>
<td>Reliability/Safety</td>
<td>评估系统是否需要具备故障安全能力，或者其任务关键性是否影响生命。如果系统发生故障，是否会给公司带来巨额损失。它指系统在指定条件下和指定时间内运行的程度。</td>
</tr>
<tr>
<td>Robustness</td>
<td>在互联网连接中断、断电或硬件故障时，处理错误和边界条件的能力。</td>
</tr>
<tr>
<td>Scalability</td>
<td>系统随着用户或请求数量的增加而执行和运行的能力。这意味着处理大量并发用户而不会出现严重的性能下降。</td>
</tr>
</tbody></table>
<h2>结构特性</h2>
<p>结构性架构特性关注<strong>代码结构</strong>。在许多情况下，架构师对代码质量问题负有独立或共同的责任，例如良好的模块化、组件间的受控耦合、可读性强的代码以及其他内部质量评估。</p>
<table>
<thead>
<tr>
<th>特性</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>Configurability</td>
<td>最终用户通过可用界面轻松更改软件配置方面的能力。</td>
</tr>
<tr>
<td>Extensibility</td>
<td>系统的可扩展性。</td>
</tr>
<tr>
<td>Installability</td>
<td>系统在所有必要平台上安装的便捷性。它指软件在指定环境中安装和/或卸载的程度。</td>
</tr>
<tr>
<td>Leverageability/Reuse</td>
<td>跨多个产品利用通用组件的能力。它指开发人员在多个系统或构建其他资产中重复使用资产的程度。</td>
</tr>
<tr>
<td>Maintainability</td>
<td>开发人员修改、纠正或使其适应环境和/或需求变化的有效性和效率程度。</td>
</tr>
<tr>
<td>Portability</td>
<td>系统是否需要在多个平台上运行。它指开发人员将系统、产品或组件从一个硬件、软件或其他操作或使用环境转移到另一个环境的程度。</td>
</tr>
<tr>
<td>Supportability</td>
<td>应用程序所需的技术支持级别。系统中调试错误所需的日志记录及其他设施的级别。</td>
</tr>
<tr>
<td>Upgradeability</td>
<td>从该应用程序/解决方案的旧版本轻松/快速升级到新版本的能力。</td>
</tr>
</tbody></table>
<h2>交叉特性</h2>
<p>交叉架构特性指的是那些难以归类或超出传统类别，但却形成重要设计约束和考虑的特性。</p>
<table>
<thead>
<tr>
<th>特性</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>Accessibility</td>
<td>确保所有用户（包括色盲或听力障碍等残障用户）能够访问系统。它指使软件可供具有最广泛特征和能力的人使用。</td>
</tr>
<tr>
<td>Archivability</td>
<td>数据是否需要在一段时间后归档或删除。</td>
</tr>
<tr>
<td>Authentication</td>
<td>确保用户是其所声称的身份的安全要求。</td>
</tr>
<tr>
<td>Authorization</td>
<td>确保用户只能访问应用程序内特定功能（按用例、子系统、网页、业务规则、字段级别等）的安全要求。</td>
</tr>
<tr>
<td>Legal</td>
<td>系统在哪些法律约束下运行（数据保护、萨班斯-奥克斯利法案、GDPR 等）？公司需要哪些保留权利？关于应用程序构建或部署方式的任何规定。</td>
</tr>
<tr>
<td>Privacy</td>
<td>隐藏内部公司员工交易信息的能力（加密交易，甚至数据库管理员和网络架构师都无法查看）。</td>
</tr>
<tr>
<td>Security</td>
<td>数据是否需要在数据库中加密？内部系统之间网络通信是否需要加密？远程用户访问需要何种类型的认证？它指软件保护信息和数据的程度，以便人员或其他产品或系统具有与其授权类型和级别相称的数据访问程度。</td>
</tr>
<tr>
<td>Supportability</td>
<td>应用程序所需的技术支持级别。系统中调试错误所需的日志记录及其他设施的级别。</td>
</tr>
<tr>
<td>Usability/Achievability</td>
<td>用户使用应用程序/解决方案实现目标所需的培训水平。它指用户可以有效、高效、满意地使用系统达到预期目的。</td>
</tr>
</tbody></table>
<h2>可用性 vs 性能</h2>
<p>在架构特性中，没有绝对的&quot;更重要&quot;之分，只有权衡取舍 (trade-offs)。这是软件架构的第一定律。架构师很少能够设计一个系统来最大化每一个架构特性。</p>
<p>可用性和性能之间的选择取决于具体的业务驱动因素、环境和一系列其他因素。例如：</p>
<ul>
<li><strong>业务需求</strong>：<ul>
<li>如果一个系统对用户来说必须始终可用，即使偶尔慢一点也可以接受，那么可用性可能是更高的优先级。例如，一个紧急服务系统或金融交易系统，即使延迟增加，也必须确保服务不中断。</li>
<li>如果业务目标是提供极快的响应时间，即使偶尔停机也能接受，那么性能可能更重要。例如，高频交易系统，毫秒级的延迟都可能导致巨大损失。</li>
</ul>
</li>
<li><strong>相互影响与权衡</strong>：提升某一个架构特性往往会对其他特性产生负面影响。例如，为了提高安全性，可能需要进行更多的加密和间接操作，这几乎肯定会负面影响性能。</li>
<li><strong>上下文决定</strong>：不同的系统部分可能需要不同的优先级。例如，在一个在线拍卖系统中，拍卖师界面的可用性和可靠性可能比单个竞拍者界面的可用性更关键。</li>
</ul>
<p>因此，架构师的工作是分析这些权衡，并根据特定情况选择&quot;最不差的架构&quot;（least worst architecture）。这要求架构师深入理解业务领域，并与所有相关利益方（包括开发人员、业务分析师、产品负责人等）进行协作，而不是孤立地做出决策。</p>
]]></content:encoded>
    </item>
    <item>
      <title>RAG 全栈技术</title>
      <link>https://hedon.top/blog/ai-rag-tech-complete/</link>
      <guid isPermaLink="true">https://hedon.top/blog/ai-rag-tech-complete/</guid>
      <pubDate>Thu, 03 Jul 2025 08:00:00 GMT</pubDate>
      <description>RAG 全栈技术</description>
      <category>AI</category><category>RAG</category>
      <content:encoded><![CDATA[<h2>1. 文档加载</h2>
<ul>
<li><a href="https://python.langchain.com/docs/integrations/document_loaders/">langchain-document_loaders</a></li>
</ul>
<h3>langchian Document</h3>
<ul>
<li><code>page_content</code>: 文档内容</li>
<li><code>metadata</code>: 文档元信息</li>
</ul>
<pre><code class="language-python">from langchain.schema import Document

document = Document(
    page_content=&quot;Hello, world!&quot;,
    metadata={&quot;source&quot;: &quot;https://example.com&quot;}
)
</code></pre>
<h3>html</h3>
<ul>
<li><p>在线网页：<em>from</em> langchain<em>community.document_loaders _import</em> WebBaseLoader</p>
</li>
<li><p>本地文件：<em>from</em> langchain<em>community.document_loaders _import</em> BSHTMLLoader</p>
</li>
<li><p>解析代码：<em>from</em> bs4 <em>import</em> BeautifulSoup</p>
<pre><code class="language-python">from bs4 import BeautifulSoup

# 读取 HTML 文件内容
html_txt = &#39;&#39;
with open(&quot;./file_load/test.html&quot;, &#39;r&#39;) as f:
    for line in f.readlines():
        html_txt += line

# 解析 HTML
soup = BeautifulSoup(html_txt, &#39;lxml&#39;)

# 代码块 td class=&quot;code&quot;
code_content = soup.find_all(&#39;td&#39;, class_=&quot;code&quot;)
for ele in code_content:
    print(ele.text)
    print(&quot;+&quot;*100)
</code></pre>
<p>这里对代码块解析时的 <code>class</code> 需要根据具体网页的元素定义进行更换，不过大体思路都一样（也不局限于代码块）。</p>
</li>
</ul>
<h3>PDF</h3>
<ul>
<li><p>加载文件：<em>from</em> langchain<em>community.document_loaders _import</em> PyMuPDFLoader</p>
</li>
<li><p>解析表格：<em>import</em> fitz</p>
<pre><code class="language-python">import fitz

doc = fitz.open(&quot;./file_load/fixtures/zhidu_travel.pdf&quot;)

table_data = []
text_data = []

doc_tables = []
for idx, page in enumerate(doc):
    text = page.get_text()
    text_data.append(text)
    tabs = page.find_tables()
    for i, tab in enumerate(tabs):
        ds = tab.to_pandas()
        table_data.append(ds.to_markdown())

for tab in table_data:
    print(tab)
    print(&quot;=&quot;*100)
</code></pre>
</li>
</ul>
<h3>Unstructured</h3>
<p><a href="https://github.com/Unstructured-IO/unstructured">Unstructured</a> 是由 Unstructured.IO 开发的开源 Python 库，专为处理非结构化数据（如 PDF、Word、HTML、XML 等）设计。在 LangChain 中，它作为文档加载的核心工具，实现以下功能：</p>
<ol>
<li>格式支持广泛：解析 PDF、DOCX、PPTX、HTML、XML、CSV 等格式，甚至支持扫描件中的 OCR 文本提取。</li>
<li>元素分区（Partitioning）：将文档拆分为结构化元素（标题、段落、表格、列表），保留原始布局和元数据。</li>
<li>数据清洗：自动清理文档中的无关符号、页眉页脚，生成纯净文本。</li>
</ol>
<p>使用 langchain_unstructured 需要安装：</p>
<pre><code class="language-shell">uv add unstructured
uv add langchain_unstructured
uv add unstructured_inference
uv add unstructured_pytesseract

# 系统依赖（macOS）
brew install poppler
brew install tesseract
brew install libmagic
brew install ghostscript
brew install pandoc
</code></pre>
<h4>PDF</h4>
<p>需要额外安装：</p>
<pre><code class="language-shell">uv remove camelot-py # 如果有 camelot 需要先移出，在一些版本上存在冲突

uv add &quot;unstructured[pdf]&quot;
</code></pre>
<p>使用时导入包：</p>
<pre><code class="language-python">from langchain_unstructured import UnstructuredLoader
</code></pre>
<h4>PPT</h4>
<p>需要安装额外依赖：</p>
<pre><code class="language-shell">uv add python-pptx
</code></pre>
<p>使用时导入包：</p>
<pre><code class="language-python">from langchain_community.document_loaders import UnstructuredPowerPointLoader
</code></pre>
<p>解析 PPT 中的表格及其他特殊类型，可以使用原始的 <code>python-pptx</code> 库：</p>
<pre><code class="language-python">from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE

ppt = Presentation(&quot;./file_load/fixtures/test_ppt.pptx&quot;)

for slide_number, slide in enumerate(ppt.slides, start=1):
    print(f&quot;Slide {slide_number}:&quot;)
    for shape in slide.shapes:
        if shape.has_text_frame:  # 文本信息
            print(shape.text)

        if shape.has_table:  # 表格信息
            table = shape.table
            for row_idx, row in enumerate(table.rows):
                for col_idx, cell in enumerate(row.cells):
                    cell_text = cell.text
                    print(f&quot;Row {row_idx + 1}, Column {col_idx + 1}: {cell_text}&quot;)

        if shape.shape_type == MSO_SHAPE_TYPE.PICTURE: # 图片信息
            imgae = shape.image
            image_filename = &quot;./file_load/fixtures/pic_from_ppt.jpg&quot;
            with open(image_filename, &#39;wb&#39;) as f:
                f.write(imgae.blob)
</code></pre>
<h4>Word</h4>
<p>需要安装额外依赖：</p>
<pre><code class="language-shell">uv add docx2txt
uv add python-docx
</code></pre>
<p>使用时导入包：</p>
<pre><code class="language-python">from langchain_community.document_loaders import Docx2txtLoader
</code></pre>
<p>解析 Word 中的表格及其他特殊类型，可以使用原始的 <code>python-docx</code> 库：</p>
<pre><code class="language-python">from docx import Document

def read_docx(file_path):
    doc = Document(file_path)
    for para in doc.paragraphs:
        print(para.text)

    for table in doc.tables:
        for row in table.rows:
            for cell in row.cells:
                print(cell.text, end=&#39; | &#39;)
            print()

file_path = &quot;./file_load/fixtures/test_word.docx&quot;
read_docx(file_path=file_path)
</code></pre>
<h4>Excel</h4>
<p>需要安装额外依赖：</p>
<pre><code class="language-shell">uv add openpyxl
</code></pre>
<h3>ragflow.deepdoc</h3>
<p>RAGFlow 是一个开源的、基于&quot;深度文档理解&quot;的 RAG 引擎。</p>
<p><strong>RAGFlow 的主要特点：</strong></p>
<ol>
<li><strong>开箱即用：</strong> 提供 Web UI 界面，用户可以通过简单的几次点击，无需编写代码，就能完成知识库的建立和问答测试。通过 <code>Docker</code> 可以一键部署，非常方便。</li>
<li><strong>工作流自动化 (Automated Workflow)：</strong> RAGFlow 将复杂的 RAG 流程（文档解析、切块、向量化、存储、检索、生成）模板化。用户可以选择不同的模板来适应不同的数据和任务需求，整个过程高度自动化。</li>
<li><strong>可视化与可解释性：</strong> 在处理文档时，RAGFlow 会生成一个可视化的解析结果图，让用户能清晰地看到文档是如何被理解和切分的，大大增强了系统的透明度和可调试性。</li>
<li><strong>企业级特性：</strong> 它支持多种文档格式，能够生成可溯源的答案（即答案会附上来源出处），并且兼容多种 LLM 和向量数据库，易于集成到现有企业环境中。</li>
</ol>
<p>如果说 RAGFlow 是一个高效的问答“工厂”，那么 DeepDoc 就是这个工厂里最核心、最先进的“原材料加工车间”。所有外部文档在进入知识库之前，都必须经过 DeepDoc 的精细处理。</p>
<p>DeepDoc 的全称是 <strong>Deep Document Understanding</strong>（深度文档理解），它是 RAGFlow 实现高质量检索的基石。它并非简单地提取文本，而是试图像人一样“看”和“理解”文档的版面布局和内在逻辑。</p>
<p><strong>DeepDoc 的工作原理与核心能力：</strong></p>
<ol>
<li><strong>视觉版面分析 (Vision-based Layout Analysis)：</strong><ul>
<li><strong>理论：</strong> DeepDoc 首先会利用计算机视觉（CV）模型，像人眼一样扫描整个文档页面。它不是逐行读取字符，而是先识别出页面上的宏观结构，例如：这是标题、那是段落、这是一个表格、这是一张图片、这是一个页眉/页脚。</li>
<li><strong>实践：</strong> 对于一个两栏布局的 <code>PDF</code> 报告，传统的文本提取工具可能会把左边一行的结尾和右边一行的开头错误地拼在一起。而 DeepDoc 的视觉分析能准确识别出两个独立的栏目，并按照正确的阅读顺序（先读完左栏，再读右栏）来处理文本。</li>
</ul>
</li>
<li><strong>智能分块 (Intelligent Chunking)：</strong><ul>
<li><strong>理论：</strong> 这是 DeepDoc 最具价值的一点。在理解了文档布局之后，它会进行“语义分块”而非“物理分块”。传统的 RAG 会把文档切成固定长度（如 500 个字符）的块，这常常会将一个完整的表格或一段逻辑连贯的话拦腰截断。</li>
<li><strong>实践：</strong> DeepDoc 会将一个完整的表格识别出来并视为一个独立的“块”（Chunk）。一个标题和它紧随其后的段落也会被智能地划分在一起。这样做的好处是，当用户提问与表格相关的问题时，系统检索到的就是这个包含完整上下文的表格块，而不是表格的某几行碎片。这极大地保证了提供给 LLM 的上下文信息的完整性和逻辑性。</li>
</ul>
</li>
<li><strong>高质量光学字符识别 (OCR)：</strong><ul>
<li><strong>理论：</strong> 对于扫描的 <code>PDF</code> 文件或者文档中嵌入的图片，DeepDoc 内置了高质量的 OCR 引擎。</li>
<li><strong>实践：</strong> 即便文档是扫描的复印件，它也能尽可能准确地提取出其中的文字内容，并将其融入到上述的版面分析中，确保信息不丢失。</li>
</ul>
</li>
<li><strong>表格解析与转译：</strong><ul>
<li><strong>理论：</strong> 识别出表格只是第一步，更关键的是让 LLM 能“读懂”表格。</li>
<li><strong>实践：</strong> DeepDoc 能够提取出表格的结构化数据，并将其转换为 LLM 更容易理解的格式，例如 Markdown 格式。一个复杂的表格图片，在经过 DeepDoc 处理后，可能会变成一个 Markdown 文本表格，这样 LLM 就能轻松地理解其行列关系，并回答诸如“请总结一下表格中第三季度销售额最高的产品是哪个？”这类的问题。</li>
</ul>
</li>
</ol>
<p>笔者在实践过程中，通过精简 ragflow.deepdoc 中的 pdfparser，抽出了一个组件 <a href="https://github.com/hedon-ai-road/deepdoc_pdfparser">deepdoc_pdfparser</a>.</p>
<h2>2. 分块策略</h2>
<ul>
<li>可视化工具：<a href="https://chunkviz.up.railway.app/">ChunkViz</a></li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/https%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F92c70184-ba0f-4877-9a55-e4add0e311ad_870x1116.gif" alt="RAG 五大分块策略"></p>
<blockquote>
<p>参考：<a href="https://blog.dailydoseofds.com/p/5-chunking-strategies-for-rag-f8b?ref=dailydev">5-chunking-strategies-for-rag</a></p>
</blockquote>
<h2>3. 向量嵌入</h2>
<h3>3.1 嵌入模型评测</h3>
<p>Hugging Face 的 <a href="https://huggingface.co/spaces/mteb/leaderboard">MTEB</a> (Massive Text Embedding Benchmark) 是一个大规模的文本嵌入模型评测基准。它的核心作用是<strong>为各种文本嵌入模型提供一个统一、全面、客观的性能衡量标准</strong>。</p>
<p>涵盖了文本嵌入在现实世界中最常见的 8 种应用场景，共计 58 个数据集和 112 种语言。这 8 大任务分别是：</p>
<ul>
<li><strong>Bitext Mining (双语文本挖掘):</strong> 在不同语言的句子中找出翻译对。</li>
<li><strong>Classification (分类):</strong> 将文本划分到预定义的类别中。</li>
<li><strong>Clustering (聚类):</strong> 将相似的文本分组在一起。</li>
<li><strong>Pair Classification (句子对分类):</strong> 判断两个句子是否具有某种关系 (如释义、矛盾等)。</li>
<li><strong>Reranking (重排序):</strong> 对一个已经排好序的列表 (如搜索结果) 进行重新排序，以提升质量。</li>
<li><strong>Retrieval (检索):</strong> 从一个大规模的文档语料库中找出与查询最相关的文档。这是目前文本嵌入最核心和最热门的应用之一。</li>
<li><strong>Semantic Textual Similarity (STS, 语义文本相似度):</strong> 判断两个句子的语义相似程度，通常给出一个从 0 到 5 的分数。</li>
<li><strong>Summarization (摘要):</strong> 评估生成的摘要与原文的语义相似度。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250725171512447.png" alt="MTEB"></p>
<h3>3.2 稀疏嵌入（Sparse Embedding）</h3>
<table>
<thead>
<tr>
<th align="left"><strong>特征</strong></th>
<th align="left"><strong>说明</strong></th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>维度</strong></td>
<td align="left">通常等于完整词表或特征集合的大小，可达 10⁵ – 10⁶；大多数维度为 0，只有少数位置有权重</td>
</tr>
<tr>
<td align="left"><strong>构造方式</strong></td>
<td align="left">基于词频或词频-逆文档频率（TF-IDF）、BM25 等统计方法，不依赖深度学习</td>
</tr>
<tr>
<td align="left"><strong>权重含义</strong></td>
<td align="left">每个非零维可直观解释为某个词或特征的重要度，具有高度可解释性</td>
</tr>
<tr>
<td align="left"><strong>检索/存储</strong></td>
<td align="left">用倒排索引即可实现 O(1) 级精确匹配；在线增量更新代价低</td>
</tr>
<tr>
<td align="left"><strong>优势</strong></td>
<td align="left">对长文档、术语精确匹配友好 易于调参（停用词、词根化） 资源消耗小、无推理延迟</td>
</tr>
<tr>
<td align="left"><strong>劣势</strong></td>
<td align="left">维度极高，逐向量暴力计算代价大 只捕获词面共现，无法理解语义或同义词 对拼写/语序变化鲁棒性差</td>
</tr>
</tbody></table>
<h4>TF-IDF(Term Frequency - Inverse Document Frequency，词频-逆文档频率)</h4>
<blockquote>
<p><em>一种经典的加权方案，用来衡量 词语 t 对 文档 d 在 语料库 D 中的重要程度。</em></p>
</blockquote>
<ul>
<li>一句话：词在整个语料库中出现得越少，但在本篇文档中出现得越多，那它就越重要。</li>
</ul>
<p>公式：$TF-IDF(t,d,D) = TF(t,d) × IDF(t,D)$</p>
<p>TF（局部权重）：</p>
<ul>
<li>计数：<code>tf = #t 出现次数</code></li>
<li>频率：<code>tf = #t / |d|</code></li>
<li>对数平滑：<code>tf = 1 + log(#t)</code></li>
</ul>
<p>ID（全局权重）：$$IDF(t) = log\frac{N-df(t)+0.5}{df(t)+0.5} $$</p>
<ul>
<li><em>N</em> = 语料中文档总数</li>
<li><em>df(t)</em> = 含词 <em>t</em> 的文档数</li>
<li>加 1 或 0.5 可以避免分母为 0，并抑制长尾噪声。</li>
</ul>
<h4>BM25(Best Matching 25)</h4>
<p>可视为 TF-IDF 的扩展版，进一步引入：</p>
<ul>
<li><em>k₁</em> 控制 TF 饱和：TF 越大，增益递减。</li>
<li><em>b</em> 长度归一化：文档越长，单词 TF 权重被抑制。</li>
</ul>
<p>公式：$$w(t,d)=IDF(t)⋅\frac{TF(k_{1} +1)}{TF+k_{1}·（1-b+b·\frac{文档长度}{平均文档长度} ）} $$</p>
<table>
<thead>
<tr>
<th align="left"><strong>角色</strong></th>
<th align="left"><strong>控制对象</strong></th>
<th align="left"><strong>常见区间</strong></th>
<th align="left"><strong>极值行为</strong></th>
<th align="left"><strong>直觉比喻</strong></th>
</tr>
</thead>
<tbody><tr>
<td align="left">k₁ (saturation factor)</td>
<td align="left">TF 饱和曲线斜率——同一个词在同一文档中重复出现到第 n 次时，还能再加多少分</td>
<td align="left">1.0 – 2.0</td>
<td align="left">k₁ → 0：完全不计重复词；k₁ → ∞：线性计数，退化为 TF-IDF</td>
<td align="left">沾一滴酱油 vs. 倒一瓶酱油：味道总有极限，不会永远 1 → 2 → 3 倍变浓</td>
</tr>
<tr>
<td align="left">b (length normalizer)</td>
<td align="left">文档长度惩罚强度——长文能否用“大块头”刷分</td>
<td align="left">0.3 – 0.9</td>
<td align="left">b = 0：不考虑长度（BM15）b = 1：长度全量归一化（BM11）</td>
<td align="left">打篮球按身高加分：b=0 不管身高；b=1 按身高严格扣分；中间值折中</td>
</tr>
</tbody></table>
<h3>3.3 密集嵌入（Dense Embedding）</h3>
<table>
<thead>
<tr>
<th align="left"><strong>特征</strong></th>
<th align="left"><strong>说明</strong></th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>维度</strong></td>
<td align="left">兼顾效率与表达力，常见 128 – 1536；每一维几乎都非零。</td>
</tr>
<tr>
<td align="left"><strong>构造方式</strong></td>
<td align="left">由深度模型（BERT、Sentence-BERT、OpenAI text-embedding-3-small 等）端对端学习，捕获上下文语义</td>
</tr>
<tr>
<td align="left"><strong>权重含义</strong></td>
<td align="left">单维难以直观解释，但整体向量在低维空间中编码了丰富的语义相似度</td>
</tr>
<tr>
<td align="left"><strong>检索/存储</strong></td>
<td align="left">需专门的 ANN（HNSW、Faiss IVF-PQ 等）索引；向量更新需重新编码</td>
</tr>
<tr>
<td align="left"><strong>优势</strong></td>
<td align="left">具备语义泛化能力，能跨同义词、拼写、语序可跨语言、跨模态（图文）在 RAG/问答场景提升召回率</td>
</tr>
<tr>
<td align="left"><strong>劣势</strong></td>
<td align="left">训练与推理成本高（GPU/CPU 向量化计算）结果可解释性弱 在线增量写入需再编码、重建索引</td>
</tr>
</tbody></table>
<h3>3.4 ColBERT</h3>
<p>ColBERT 是一种让 BERT 用“词级小向量”做快速、精准文本检索的方法 —— 既不像传统 TF-IDF 那样粗糙，也不像跨编码器那样慢。</p>
<p>ColBERT = “把 BERT 的句向量拆成 token 向量，再用 Late Interaction 重新拼起来做检索”的工程化改造版 BERT。</p>
<p><strong>Late Interaction</strong> 就是把查询（Q）和文档（D）<strong>先独立编码</strong>，等到最后打分时再让它们在 <strong>token 级别</strong> 做一次“小范围、轻量级”的互动——既不像 Cross-Encoder 那样“一上来就深度交互”，也不像 Bi-Encoder 那样“全程零交互”。</p>
<blockquote>
<p>换言之，BERT 提供<strong>语言理解底座</strong>，ColBERT 在此之上加了<strong>面向检索的输出格式与打分逻辑</strong>，二者既同宗又分工明确。</p>
</blockquote>
<pre><code class="language-markdown">           预训练阶段（同一个 BERT 权重）

┌───────────────┐
│ Google BERT │ ← 海量文本上做 MLM/NSP
└───────────────┘
│ （加载相同参数）
╭───────────┴───────────╮
│ │
│ ↓ 普通微调 │ ↓ ColBERT 微调
│ （分类、NER…） │ （稠密检索）
│ │
│ 取 [CLS] 整句向量 │ 保留 每个 token 向量
│ + 任务特定头 │ + Late-Interaction 打分
│ │
╰───────────┬───────────╯
│
下游推理/检索
</code></pre>
<h3>3.5 BGE-M3</h3>
<p>BGE-M3 是由智源研究院（BAAI）开发的新一代旗舰文本嵌入模型，它开创性地在单一模型内集成了<strong>多语言</strong>（支持超过 100 种语言）、<strong>长文本</strong> （支持 8192 词符）和<strong>多功能检索</strong>（同时支持稠密、稀疏和多向量检索）的强大能力。</p>
<table>
<thead>
<tr>
<th align="left"><strong>M</strong></th>
<th align="left"><strong>含义</strong></th>
<th align="left"><strong>具体能力</strong></th>
<th align="left"><strong>参考</strong></th>
</tr>
</thead>
<tbody><tr>
<td align="left">Multi-Functionality</td>
<td align="left">多功能</td>
<td align="left">同时产出 稠密向量（dense）、多向量/ColBERT（colbert） 和 稀疏向量（sparse），一套模型即可覆盖混合检索需求。</td>
<td align="left"><a href="https://huggingface.co/BAAI/bge-m3">huggingface.bge-m3</a></td>
</tr>
<tr>
<td align="left">Multi-Linguality</td>
<td align="left">多语种</td>
<td align="left">覆盖 100+ 语言，是目前公开数据集中多语检索任务的 SOTA。</td>
<td align="left"><a href="https://arxiv.org/abs/2402.03216?utm_source=chatgpt.com">arXiv.bge-m3</a></td>
</tr>
<tr>
<td align="left">Multi-Granularity</td>
<td align="left">多粒度</td>
<td align="left">最长输入 8 192 token，既能编码短句也能处理长文档。</td>
<td align="left"><a href="https://huggingface.co/BAAI/bge-m3">huggingface.bge-m3</a></td>
</tr>
</tbody></table>
<h2>4. 查询增强技术</h2>
<h3>4.1 查询构建</h3>
<h4>4.1.1 Text-to-SQL</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/1*wDKR6-ToiX5UgsUYN41JiQ.png" alt="Text-to-SQL"></p>
<ol>
<li>构建 DDL 知识库：schema 提取与切片；</li>
<li>构建 Q-SQL 知识库：示例对注入；</li>
<li>构建 DB 描述知识库：业务描述补充；</li>
<li>提供 RAG 检索上下文；</li>
<li>调用 LLM 进行 SQL 生成；</li>
<li>执行 SQL 并反馈结果；</li>
<li>迭代直到正确解决问题。</li>
</ol>
<p>常用框架：</p>
<ul>
<li>vanna</li>
<li>Chat2DB</li>
<li>DB-GPT</li>
</ul>
<h4>4.1.2 Text-to-Cypher</h4>
<p>跟 Text-to-SQL 一样，只不过是生成图数据库（neo4j）查询语句。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/1*lhHfQGKOkZGNm40QIqTNIQ.png" alt="Text-to-Cypher"></p>
<ol>
<li>构建图元模型（Graph Metamodel）知识库；</li>
<li>构建 Q-Cypher 知识库（示例对注入）；</li>
<li>构建图描述（Graph Description）知识库；</li>
<li>提供 RAG 检索上下文；</li>
<li>调用 LLM 进行 SQL 生成；</li>
<li>执行 SQL 并反馈结果；</li>
<li>迭代直到正确解决问题。</li>
</ol>
<h4>4.1.3 从查询中提取元数据构建过滤器</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/output.png" alt="从查询中提取元数据构建过滤器"></p>
<ol>
<li>将自然语言转为向量查询语句；</li>
<li>利用 LLM 推断出元数据过滤条件；</li>
<li>在查询检索时，根据过滤条件进行文档过滤；</li>
<li>返回过滤后的相似文档；</li>
</ol>
<p>实战案例：</p>
<ul>
<li><a href="https://ragflow.io/blog/implementing-text2sql-with-ragflow">https://ragflow.io/blog/implementing-text2sql-with-ragflow</a></li>
<li><a href="https://medium.com/neo4j/generating-cypher-queries-with-chatgpt-4-on-any-graph-schema-a57d7082a7e7">https://medium.com/neo4j/generating-cypher-queries-with-chatgpt-4-on-any-graph-schema-a57d7082a7e7</a></li>
</ul>
<h3>4.2 查询翻译</h3>
<p>通过对用户查询进行改造和扩展，使其更加清晰、具体，从而提高检索精度。</p>
<p>常用工具：</p>
<table>
<thead>
<tr>
<th align="left"><strong>方案</strong></th>
<th align="left"><strong>链接</strong></th>
<th align="left"><strong>说明</strong></th>
</tr>
</thead>
<tbody><tr>
<td align="left">ragbear</td>
<td align="left"><a href="https://github.com/lexiforest/ragbear">GitHub - lexiforest/ragbear</a></td>
<td align="left">rewrite= 参数多种改写模式</td>
</tr>
<tr>
<td align="left">LangChain</td>
<td align="left"><a href="https://blog.langchain.dev/query-transformations/">Query Transformations</a></td>
<td align="left">内置链式改写</td>
</tr>
<tr>
<td align="left">LlamaIndex</td>
<td align="left"><a href="https://docs.llamaindex.ai/en/stable/examples/query_transformations/query_transform_cookbook/">Query Transform Cookbook ¶</a></td>
<td align="left">多策略组合</td>
</tr>
<tr>
<td align="left">Haystack</td>
<td align="left"><a href="https://haystack.deepset.ai/cookbook/metadata_enrichment">Advanced RAG: Automated Structured Metadata Enrichment | Haystack</a></td>
<td align="left">pipeline node</td>
</tr>
</tbody></table>
<h4>4.2.1 Query2Doc</h4>
<p><a href="https://arxiv.org/pdf/2303.07678">Query2Doc</a> 是指将 query 直接交给 LLM 去生成一份相关文档，然后将 query 和生成的文档一起去进行检索。虽然 LLM 生成的文档可能不对，但是提供了更丰富的信息、丰富了问题的语义，有助于提高检索时的精度。</p>
<pre><code class="language-python">def query2doc(query):
    prompt = f&quot;你是一名公司员工制度的问答助手，熟悉公司规章制度，请简短回答以下问题：{query}&quot;
    doc_info = llm(prompt)
    context_query = f&quot;{query}, {doc_info}&quot;
    return context_query
</code></pre>
<h4>4.2.2 HyDE</h4>
<p><a href="https://docs.haystack.deepset.ai/docs/hypothetical-document-embeddings-hyde">HyDE</a>（Hypothetical Document Embeddings，假设文档向量）让 LLM 根据 query 去生成一系列假设性文档，然后将这些文档跟 query 一起做向量化，取向量均值去进行检索。</p>
<pre><code class="language-python">def hyde(query, include_query=True):
    prompt_template = &quot;&quot;&quot;你是一名公司员工制度的问答助手，熟悉公司规章制度，请简短回答以下问题：
    Question: {question}
    Answer:&quot;&quot;&quot;

    prompt = PromptTemplate(input_variables=[&quot;question&quot;], template=prompt_template)
    embeddings = HypotheticalDocumentEmbedder(llm_chain= prompt | llm,
                                 base_embeddings=embedding_model.get_embedding_fun())
    hyde_embedding = embeddings.embed_query(query)

    if include_query:
        query_embeddings = embedding_model.get_embedding_fun().embed_query(query)
        result = (np.array(query_embeddings) + np.array(hyde_embedding)) / 2
        result = list(result)
    else:
        result = hyde_embedding
    result = list(map(float, result))
    return result
</code></pre>
<h4>4.2.3 子问题查询</h4>
<p>当问题比较复杂时，可以利用 LLM 将问题拆解成子问题，每个子问题都生成检索上下文，可以根据合并后总的上下文回答，也可以每个上下文独立回答后汇总。</p>
<pre><code class="language-python">def sun_question(query):
    prompt_template = &quot;&quot;&quot;你是一名公司员工制度的问答助手，熟悉公司规章制度。
    你的任务是对复杂问题继续拆解，以便理解员工的意图。
    请根据以下问题创建一个子问题列表：

    复杂问题：{question}

    请执行以下步骤：
    1. 识别主要问题：找出问题中的核心概念或主题。
    2. 分解成子问题：将主要问题分解成可以独立理解和解决的多个子问题。
    3. 只返回子问题列表，不包含其他解释信息，格式为：
        1. 子问题1
        2. 子问题2
        3. 子问题3
        ...

    &quot;&quot;&quot;

    prompt = PromptTemplate(input_variables=[&quot;question&quot;], template=prompt_template)

    llm_chain = prompt | llm
    sub_queries = llm_chain.invoke(query).split(&#39;\n&#39;)
    return sub_queries
</code></pre>
<h4>4.2.4 查询改写</h4>
<p>当问题表达不清、措辞差、缺少关键信息时，使用 LLM 根据用户问题<strong>多角度</strong>重写问题，增加额外的信息，提高检索质量。</p>
<pre><code class="language-python">def question_rewrite(query):
    prompt_template = &quot;&quot;&quot;你是一名公司员工制度的问答助手，熟悉公司规章制度。
    你的任务是需要为给定的问题，从不同层次生成这个问题的转述版本，使其更易于检索，转述的版本增加一些公司规章制度的关键词。
    问题：{question}
    请直接给出转述后的问题列表，不包含其他解释信息，格式为：
        1. 转述问题1
        2. 转述问题2
        3. 转述问题3
        ...&quot;&quot;&quot;

    prompt = PromptTemplate(input_variables=[&quot;question&quot;], template=prompt_template)

    llmchain = prompt | llm
    rewrote_question = llmchain.invoke(query)
    return rewrote_question
</code></pre>
<h4>4.2.5 查询抽象</h4>
<p>查询抽象（Take a Step Back）是指当问题包含太多的细节，可能导致检索时忽略了关键的信息，降低检索质量。可以将用户的具体问题转化为一个更高层次的抽象问题，一个更广泛的问题，关注于高级概念或原则，从而提高检索质量。</p>
<pre><code class="language-python">from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts.chat import ChatPromptTemplate
from langchain_core.prompts.few_shot import FewShotChatMessagePromptTemplate

# 将复杂问题抽象化，使其更聚焦在本质问题上
def take_step_back(query):
    examples = [
        {
            &quot;input&quot;: &quot;我祖父去世了，我要回去几天&quot;,
            &quot;output&quot;: &quot;公司丧葬假有什么规定？&quot;,
        },
        {
            &quot;input&quot;: &quot;我去北京出差，北京的消费高，有什么额外的补助？&quot;,
            &quot;output&quot;: &quot;员工出差的交通费、住宿费、伙食补助费的规定是什么？&quot;
        },
    ]

    example_prompt = ChatPromptTemplate.from_messages(
        [
            (&quot;human&quot;, &quot;{input}&quot;),
            (&quot;ai&quot;, &quot;{output}&quot;),
        ]
    )

    few_shot_prompt = FewShotChatMessagePromptTemplate(
        example_prompt=example_prompt,
        examples=examples,
    )

    prompt = ChatPromptTemplate.from_messages(
        [
            (
                &quot;system&quot;,
                &quot;&quot;&quot;你是一名公司员工制度的问答助手，熟悉公司规章制度。
                你的任务是将输入的问题通过归纳、提炼，转换为关于公司规章制度制定相关的一般性问题，使得问题更容易捕捉问题的意图。
                请参考下面的例子，按照同样的风格直接返回一个转述后的问题：&quot;&quot;&quot;
            ),
            # few shot exmaples,
            few_shot_prompt,
            # new question
            (&quot;user&quot;, &quot;{question}&quot;)
        ]
    )

    question_gen = prompt | llm | StrOutputParser()
    res = question_gen.invoke({&quot;question&quot;: query}).removeprefix(&quot;AI: &quot;)
    return res
</code></pre>
<h3>4.3 查询路由</h3>
<p>查询路由（Query Routing）是指根据用户问题的具体意图，自动判断应该将该问题导向最合适的数据源（例如向量知识库、SQL 数据库、图数据库或特定 API）以获取最精准信息的决策过程。</p>
<table>
<thead>
<tr>
<th align="left">思路</th>
<th align="left">图示</th>
</tr>
</thead>
<tbody><tr>
<td align="left">逻辑路由</td>
<td align="left"><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/1831ccf2-5d1b-4a7c-b094-6251d4aa61f5.png" alt="逻辑路由"></td>
</tr>
<tr>
<td align="left">语义路由</td>
<td align="left"><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/3b10f6dc-c598-498a-90c2-c9085c7d1b27.png" alt="语义路由"></td>
</tr>
</tbody></table>
<h2>5. 索引优化技术</h2>
<p>基本思路：</p>
<ul>
<li>父子文档索引</li>
<li>分层索引</li>
<li>多表示索引</li>
</ul>
<h3>5.1 从小块到大上下文</h3>
<blockquote>
<p>向量检索的时候，检索到的是一个小文档，但是通过小文档的 metadata，返回给 LLM 的是查询出来的大文档。</p>
</blockquote>
<p>节点-句子滑动窗口检索 SentenceWindowNodeParser</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/output%20(1).png%3E" alt="SentenceWindowNodeParser"></p>
<p>父子文本块检索：ParentDocumentRetriever</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%20(1).png%3E" alt="ParentDocumentRetriever"></p>
<p>前后串连、自动扩展上下文：PrevNextNodePostprocessor、AutoPrevNextPostprocessor</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%20(2).png%3E" alt="PrevNextNodePostprocessor"></p>
<h3>5.2 构建有层次的索引</h3>
<ul>
<li>构建两个向量数据库（Summary 和 Details），通过 Metadata 进行连接；</li>
<li>通过 <code>llamaindex</code> 的 <code>indexnode</code> 和 <code>PandasQueryEngine</code>；</li>
<li>通过查询先检索相关表名，然后做 <code>Text2SQL</code>。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%20(3).png%3E" alt="Summary & Detail"></p>
<h3>5.3 构建多表示索引</h3>
<p>构建混合索引：EnsembleRetriever</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%20(4).png%3E" alt="EnsembleRetriever"></p>
<p>构建多表示索引：MultiVectorRetriever</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%20(5).png%3E" alt="MultiVectorRetriever"></p>
<h2>6. 检索后优化技术</h2>
<h3>6.1 重排 rerank</h3>
<p>传统的搜索或推荐系统通常分为两步：</p>
<ol>
<li><strong>召回（Recall）</strong>: 从海量的候选集中，快速、粗略地筛选出几百或几千个可能相关的项目。此阶段追求<strong>速度</strong>和<strong>查全率</strong>，常用技术包括基于关键词的搜索（如 BM25）或向量相似度搜索（ANN）。</li>
<li><strong>排序/重排（Ranking/Reranking）</strong>: 对召回的结果进行更精细、更复杂的计算，以确定最终呈现给用户的顺序。此阶段追求<strong>精准度</strong>和<strong>查准率</strong>。我们今天讨论的技术就属于这个范畴。</li>
</ol>
<p>可以把这个过程比作“海选”和“决赛”。召回是海选，快速淘汰掉明显不相关的选手；重排是决赛，评委（重排模型）对入围选手进行全方位的严格评审，最终给出排名。</p>
<h4>6.1.1 RRF 重排</h4>
<blockquote>
<p>民主投票式的融合</p>
</blockquote>
<p>RRF 是一种简单、高效、无需训练的“结果融合”策略。想象一下，你有多个独立的搜索系统（比如一个关键词搜索，一个向量搜索），它们各自对同一批文档给出了自己的排名。RRF 的作用就是将这些不同的排名列表“民主地”融合成一个最终的、可能更优的排名列表。</p>
<p>RRF 的核心思想是：<strong>一个文档在多个列表中的排名越靠前，它在最终列表中的排名就应该越靠前。</strong></p>
<p>与简单地将不同系统的得分相加不同，RRF 采用“倒数排名”（Reciprocal Rank）来计算每个文档的最终分数。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%20(6).png%3E" alt="RRF Rerank"></p>
<p>优势：</p>
<ul>
<li><strong>无需训练</strong>: 即插即用，非常方便。</li>
<li><strong>性能稳健</strong>: 在不同召回源质量参差不齐时，表现通常比简单的分数相加（如 <code>sum fusion</code>）更稳定。</li>
<li><strong>计算开销极低</strong>: 几乎不增加额外的计算负担。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>效果上限不高</strong>: 它只利用了“排名”这一个信息，忽略了不同系统给出的原始“分数”中蕴含的置信度信息。</li>
<li><strong>依赖召回质量</strong>: 如果所有召回源的质量都很差，RRF 也无力回天。</li>
</ul>
<p>适应场景：</p>
<ul>
<li>混合搜索（Hybrid Search）：融合关键词搜索（BM25）和向量搜索的结果。</li>
<li>多模态搜索：融合文本、图片等不同模态的搜索结果。</li>
</ul>
<h4>6.1.2 Cross-Encoder 重排</h4>
<blockquote>
<p>query 和文档的&quot;深度阅读理解&quot;</p>
</blockquote>
<p>如果说召回阶段的向量搜索是&quot;看标题识文章&quot;，那么 Cross-Encoder 重排就是&quot;把 query 和每篇文档放在一起，逐字逐句地做一篇完整的阅读理解&quot;。</p>
<p>它通过深度学习模型（通常是 Transformer 架构，如 BERT）来判断一个 query 和一个文档之间的相关性到底有多强。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/1*hkV7rQlN6OuEin7qPxyafA.jpeg" alt="Cross-Encoder Rerank"></p>
<p>基本流程：</p>
<ol>
<li><strong>输入构建</strong>: 将查询（Query）和待排序的文档（Document）用一个特殊的分隔符（如 <code>[SEP]</code>）拼接在一起，形成一个单一的输入序列。例如：<code>[CLS] 我的问题是什么？[SEP] 这是候选文档的内容... [SEP]</code></li>
<li><strong>模型计算</strong>: 将这个拼接后的序列输入到一个预训练好的 Transformer 模型（如 BERT）中。模型内部的自注意力机制（Self-Attention）会让 Query 中的每个词和 Document 中的每个词都进行充分的交互和信息比对。</li>
<li><strong>分数输出</strong>: 模型在处理完整个序列后，通常会利用起始位置 <code>[CLS]</code> Token 对应的输出向量，接一个简单的线性层，最终输出一个单一的分数（logit），这个分数就代表了 Query 和 Document 之间的相关性得分。</li>
<li><strong>排序</strong>: 根据所有文档得到的相关性得分，从高到低进行排序，得到最终结果。</li>
</ol>
<p>优点：</p>
<ul>
<li><strong>效果极佳</strong>: 由于对 query 和文档进行了深度的、非对称的交互分析，其精度通常是所有重排方法中最高的。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>计算成本极高</strong>: 对于每个 <code>(query, document)</code> 对，都需要进行一次完整的、重量级的模型前向传播。如果召回了 500 个文档，就需要进行 500 次 BERT 模型的计算，这在实时性要求高的场景下是巨大的挑战。</li>
<li><strong>无法提前索引</strong>: 文档的表示不是独立的，必须在查询时与 query 结合才能计算，因此无法像向量搜索那样提前为所有文档建立索引。</li>
</ul>
<p>适用场景：</p>
<ul>
<li>对精度要求极高，且可以容忍较高延迟的场景，如某些法律或医疗文献的精确查找。</li>
<li>作为“黄金标准”来生成高质量的标注数据，用于训练更轻量的召回模型（如 Bi-Encoder）。</li>
</ul>
<h4>6.1.3 ColBERT 重排</h4>
<blockquote>
<p>Contextualized Late Interaction over BERT。</p>
<p>介于“看标题”和“深度阅读”之间的“划重点式”阅读</p>
</blockquote>
<p>ColBERT 试图在 Cross-Encoder 的高精度和 Bi-Encoder (召回阶段常用) 的高效率之间找到一个平衡点。它的核心思想是：<strong>不需要逐字逐句地进行完整对比，而是先把 query 和文档各自的&quot;重点&quot;（关键词向量）划出来，然后再计算这些重点之间的匹配程度。</strong></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/colbert.png" alt="ColBERT Rerank"></p>
<p>基本流程：</p>
<ol>
<li><strong>独立编码</strong>: 首先，使用一个 BERT 类的模型（但稍作修改）分别独立地处理 Query 和 Document。它不再是输出一个单一的 <code>[CLS]</code> 向量，而是为 Query 和 Document 中的<strong>每一个 Token</strong> 都生成一个上下文相关的向量。</li>
<li><strong>Query 端向量</strong>: 对于 Query，我们保留所有 Token 的输出向量。</li>
<li><strong>Document 端向量</strong>: 对于 Document，我们也保留所有 Token 的输出向量。这些向量可以<strong>提前计算并存储</strong>，这是它比 Cross-Encoder 高效的关键。</li>
<li><strong>延迟交互计算</strong>: 在查询时，进行“延迟交互”。具体来说，对于 Query 中的<strong>每一个</strong> Token 向量，我们都去 Document 的所有 Token 向量中寻找一个最相似的（通过最大内积<code>MaxSim</code>操作）。</li>
<li><strong>分数聚合</strong>: 最后，将 Query 中每个 Token 找到的最大相似度分数<strong>相加</strong>，得到最终的相关性总分。</li>
</ol>
<blockquote>
<p>这个过程好比：</p>
<ul>
<li><strong>Query</strong>: &quot;best deep learning framework&quot;</li>
<li><strong>Document</strong>: &quot;PyTorch is a popular framework for deep learning...&quot;</li>
</ul>
<p>ColBERT 会分别计算：</p>
<ul>
<li>&quot;best&quot; 和文档中所有词向量的最大相似度。</li>
<li>&quot;deep&quot; 和文档中所有词向量的最大相似度。</li>
<li>&quot;learning&quot; 和文档中所有词向量的最大相似度。</li>
<li>&quot;framework&quot; 和文档中所有词向量的最大相似度。</li>
<li>然后把这四个最大相似度值加起来作为总分。</li>
</ul>
</blockquote>
<p>优点：</p>
<ul>
<li><strong>性能优越</strong>: 精度远超传统的 Bi-Encoder，并且在很多任务上能逼近 Cross-Encoder。</li>
<li><strong>效率较高</strong>: 由于文档向量可以预计算和索引，查询时的计算开销远低于 Cross-Encoder，只涉及向量的相似度计算。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>存储开销大</strong>: 需要为文档中的每个 Token 都存储一个高维向量，存储成本远高于只存一个文档向量的 Bi-Encoder。</li>
<li><strong>实现相对复杂</strong>: 其索引和查询逻辑比标准向量搜索更复杂。</li>
</ul>
<p>适用场景：</p>
<ul>
<li>需要高精度但又对延迟有一定要求的现代搜索引擎，如微软的 Bing 就在使用类似的技术。</li>
<li>作为 RAG 系统中的高质量重排器。</li>
</ul>
<h4>6.1.4 Cohere 和 Jina 重排</h4>
<blockquote>
<p>商业化的&quot;重排即服务&quot;（Reranking-as-a-Service）</p>
</blockquote>
<p>Cohere 和 Jina AI 都是提供 AI 模型和服务的公司。它们都将高质量的重排模型封装成了简单易用的 API 服务。本质上，它们提供的重排器很可能就是基于类似 Cross-Encoder 架构的、在海量高质量数据上训练和优化的专有模型。</p>
<p>优点：</p>
<ul>
<li><strong>使用简单</strong>: 只需几行代码调用 API 即可，无需关心模型训练、部署和维护。</li>
<li><strong>效果保证</strong>: 通常能获得非常好的开箱即用效果，因为这些模型经过了大量数据的锤炼。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>成本</strong>: 按调用量或 token 数量计费，对于大流量应用可能是一笔不小的开销。</li>
<li><strong>数据隐私</strong>: 需要将你的 query 和文档数据发送给第三方服务商，对于数据敏感的应用需要仔细评估其隐私政策。</li>
<li><strong>灵活性受限</strong>: 无法像自建模型那样进行深度定制或调优。</li>
</ul>
<p>适用场景：</p>
<ul>
<li>快速原型验证（MVP）。</li>
<li>中小型企业或开发者，希望以最小的工程代价获得最好的排序质量。</li>
<li>大型企业中非核心但又需要高质量排序的业务场景。</li>
</ul>
<h4>6.1.5 RankGPT 和 RankLLM</h4>
<p>这是最新的重排范式，直接利用 LLM 强大的语言理解和推理能力来进行排序。它的思路是：<strong>不再让模型输出一个简单的相关性分数，而是让 LLM 直接对召回的文档列表进行&quot;思考&quot;和&quot;比较&quot;，然后输出一个排序好的列表。</strong></p>
<p>基本思路：</p>
<ol>
<li><strong>构建 Prompt</strong>: 将 query 和召回的文档列表（通常是文档的标题和摘要）格式化成一个复杂的 Prompt。这个 Prompt 会明确指示 LLM 作为一个排序专家，对给定的文档列表根据与 query 的相关性进行排序，并按指定的格式输出结果。</li>
<li><strong>LLM 推理</strong>: 将这个 Prompt 发送给 LLM。LLM 会利用其强大的上下文理解能力，分析 query 的深层意图，并比较不同文档之间的细微差别（例如，一个内容更全面，另一个更新颖）。</li>
<li><strong>解析输出</strong>: LLM 会返回一个文本结果，比如一个重新排序好的文档 ID 列表。程序需要解析这个文本输出来获取最终的排序。</li>
</ol>
<p>优点：</p>
<ul>
<li><strong>理解复杂意图</strong>: LLM 能够理解非常复杂和模糊的 query，并能进行一定程度的推理，这是传统模型难以做到的。</li>
<li><strong>零样本/少样本能力强</strong>: 无需针对特定任务进行微调，就能在很多场景下取得惊人的效果。</li>
<li><strong>可解释性</strong>: 有时可以引导 LLM 给出排序的理由，增加了透明度。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>成本和延迟极高</strong>: 调用大型 LLM API 的成本和时间开销是目前所有方法中最高的，通常只能用于非实时或小批量任务。</li>
<li><strong>上下文长度限制</strong>: LLM 的上下文窗口大小有限，一次能处理的文档数量和文档长度都受限。</li>
<li><strong>稳定性问题</strong>: 输出格式可能不稳定，需要设计鲁棒的解析逻辑。结果也可能有一定的随机性。</li>
</ul>
<p>适用场景：</p>
<ul>
<li>对召回结果的“最后一公里”进行精加工，例如对前 10 名结果进行最终排序。</li>
<li>作为生成高质量排序标注数据的强大工具。</li>
<li>对成本不敏感、但对排序质量有极致要求的特定应用。</li>
</ul>
<h4>6.1.6 时效加权重排</h4>
<p>这是一种业务逻辑驱动的重排策略，而非特定的模型或算法。其核心思想是：<strong>对于某些类型的查询，最新的信息比旧的信息更有价值。</strong></p>
<p>基本思路：</p>
<ol>
<li><strong>时间衰减函数 (Time Decay Function)</strong>: 设计一个函数，使得文档的分数随着其发布时间的流逝而衰减。最常用的函数是指数衰减或高斯衰减。</li>
<li><strong>分桶加权</strong>: 将文档按发布时间分到不同的桶里，如&quot;24 小时内&quot;、&quot;一周内&quot;、&quot;一月内&quot;、&quot;更早&quot;。为每个桶设置一个固定的权重或加分项。例如，&quot;24 小时内&quot;的文档分数乘以 1.5，&quot;一周内&quot;的乘以 1.2 等。</li>
</ol>
<p>优点：</p>
<ul>
<li><strong>实现简单</strong>: 逻辑清晰，容易实现和调整。</li>
<li><strong>效果显著</strong>: 对于新闻、社交媒体、产品更新等时效性强的查询，能极大提升用户体验。</li>
</ul>
<p>缺点：</p>
<ul>
<li><strong>&quot;一刀切&quot;风险</strong>: 如果不加区分地对所有查询都增强时效性，可能会伤害那些寻求&quot;&quot;永恒&quot;知识的查询（如&quot;什么是牛顿第一定律&quot;）。</li>
<li><strong>参数难调</strong>: 衰减函数的形状、权重 w 等参数需要根据经验和 A/B 测试来仔细调整。</li>
</ul>
<p>适用场景：</p>
<ul>
<li><strong>新闻搜索</strong>: 用户总是想看最新的报道。</li>
<li><strong>电商新品</strong>: 用户搜索&quot;手机&quot;时，可能更想看到最新款。</li>
<li><strong>社交媒体 Feed</strong>: 最新的帖子通常排在最前面。</li>
<li><strong>需要与查询意图识别结合</strong>: 一个优秀的系统应该能识别出哪些 query 是具有时效性意图的，然后动态地应用时效性加权。</li>
</ul>
<h3>6.2 压缩 compression</h3>
<p>传统的 RAG 流程是“检索-增强-生成”。系统首先根据用户问题从知识库中检索出若干相关文档片段（Chunks），然后将这些片段作为上下文（Context）连同用户问题一起提交给大语言模型（LLM），由 LLM 生成最终答案。</p>
<p>这里面潜藏着几个挑战：</p>
<ol>
<li><strong>上下文窗口限制 (Context Window Limit)</strong>：每个 LLM 都有其上下文长度上限（如 GPT-4 是 128k tokens）。如果检索出的文档过多，会超出窗口限制，导致无法处理。</li>
<li><strong>成本与延迟 (Cost &amp; Latency)</strong>：LLM 的 API 调用费用通常与输入的 Token 数量成正比。上下文越长，费用越高，同时模型的推理时间也越长，导致用户等待时间增加。</li>
<li><strong>“大海捞针”问题 (Lost in the Middle)</strong>：研究表明，当 LLM 的上下文中包含大量信息时，它对位于上下文中间部分信息的注意力会下降。如果关键信息被大量无关或次要信息包围，LLM 可能无法有效利用它，从而影响生成答案的准确性。</li>
<li><strong>噪声干扰 (Noise Interference)</strong>：检索出的文档片段虽然“相关”，但并非每个字、每句话都对回答当前问题至关重要。这些无关信息就是“噪声”，会干扰 LLM 的判断。</li>
</ol>
<p>因此，<strong>RAG 压缩技术的核心目标</strong>，就是在将检索到的信息送入 LLM 之前，对其进行“精炼”——去除无关信息、保留核心内容，从而在<strong>降低成本、提升效率</strong>的同时，<strong>提高最终答案的质量</strong>。</p>
<h4>6.2.1 上下文压缩检索器</h4>
<p>上下文压缩检索器是指 LangChain 提供的 <code>Contextual Compression Retriever</code>。</p>
<p>它不是一个独立的检索器，而是一个&quot;包装器&quot;（Wrapper）。它首先使用一个常规的检索器（如 <code>VectorStoreRetriever</code>）获取一批文档，然后通过一个嵌入的&quot;文档压缩器&quot;（Document Compressor）对这些文档进行筛选或重写，最后只返回那些真正重要的信息。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/0*SL1A95gsICIB5IDA.png" alt="Contextual Compression Retriever"></p>
<p>LangChain 提供了两种主流的压缩器：</p>
<ul>
<li><strong><code>LLMChainExtractor</code></strong>：这个压缩器内部会运行一个 LLM（通常是一个小模型）。它会遍历每个检索到的文档，并向 LLM 提出一个问题，例如：&quot;请从以下文档中抽取出与&#39;[用户原始问题]&#39;相关的句子。&quot; LLM 会根据指令抽取出关键句子，丢弃无关部分，从而实现压缩。这是一种<strong>基于 LLM 的抽取式压缩</strong></li>
<li><strong><code>EmbeddingsFilter</code></strong>：这个压缩器不依赖 LLM。它会计算用户问题和每个检索文档（或文档内更小的句子片段）的嵌入向量（Embedding）之间的相似度。只有当相似度超过预设的阈值（e.g., <code>similarity_threshold=0.8</code>）时，该文档或句子才会被保留。这是一种<strong>基于嵌入相似度的过滤式压缩</strong>。</li>
</ul>
<p>优势：</p>
<ul>
<li><strong>提升信噪比</strong>：直接过滤掉与问题无关的整个文档或文档中的无关部分。</li>
<li><strong>灵活性高</strong>：可以根据需求选择计算成本低但效果略粗糙的<code>EmbeddingsFilter</code>，或选择成本高但更智能的<code>LLMChainExtractor</code>。</li>
<li><strong>模块化</strong>：与 LangChain 生态无缝集成，易于实现。</li>
</ul>
<h4>6.2.2 句子嵌入优化器</h4>
<p>句子嵌入优化器是指 LlamaIndex 提供的 <code>Sentence Embedding Optimizer</code>。</p>
<p>与 LangChain 的 <code>EmbeddingsFilter</code> 思想非常相似，但它在 LlamaIndex 的生态系统内，并专注于句子级别的精细化过滤。</p>
<p>在检索到相关的文档块（Node）之后，不是将整个文档块都丢给 LLM，而是深入到文档块内部，逐一分析每个句子，只保留与用户问题最相关的句子。</p>
<p>基本原理：</p>
<ol>
<li><strong>初始节点检索</strong>：查询引擎首先从索引中检索出 Top-K 个最相关的节点（Nodes，相当于 LangChain 的 Documents）。</li>
<li><strong>句子级分析</strong>：<code>SentenceEmbeddingOptimizer</code>（或类似功能的<code>SimilarityPostprocessor</code>）接收这些节点。它会：<ul>
<li>将每个节点分解成单独的句子。</li>
<li>为每个句子计算一个嵌入向量。</li>
<li>计算每个句子的嵌入向量与用户原始问题嵌入向量之间的相似度得分。</li>
</ul>
</li>
<li><strong>阈值过滤</strong>：它会根据一个预设的相似度阈值（<code>similarity_cutoff</code>）来决定保留哪些句子。只有得分高于阈值的句子才会被保留下来，组合成新的、更精简的节点内容。</li>
<li><strong>合成响应</strong>：最后，只有这些经过精炼的、包含高相关度句子的节点才会被送入响应合成器（Response Synthesizer），由 LLM 生成最终答案。</li>
</ol>
<p>优势：</p>
<ul>
<li><strong>粒度极细</strong>：相比于过滤整个文档，句子级过滤能最大程度地保留一个文档块中的相关信息，同时剔除无关句子，精度更高。</li>
<li><strong>减少上下文割裂</strong>：有时一个文档块整体相关度可能不高，但其中有一两句关键信息。这种方法可以精准地把这两句&quot;捞&quot;出来，避免整个文档块被丢弃。</li>
</ul>
<h4>6.2.3 LLMLingua</h4>
<p>在将包含检索文档的冗长提示词（Prompt）发送给昂贵的大模型（如 GPT-4）之前，先用一个更小、更便宜的语言模型（如 GPT-2 或一个微调过的 Llama）来对这个提示词进行&quot;有损压缩&quot;。这个压缩过程会识别并删除那些对 LLM 理解问题和生成答案不太重要的词语或句子。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/LLMLinguafigure1.png" alt="LLMLingua: Innovating LLM efficiency with prompt compression - Microsoft  Research"></p>
<p>基本原理：</p>
<ol>
<li><strong>构建完整提示词</strong>：将用户问题和所有检索到的文档拼接成一个完整的、非常长的提示词。</li>
<li><strong>小模型介入</strong>：LLMLingua 使用一个小模型来分析这个长提示词。它会评估如果从提示词中删除某个词或某段话，对大模型理解原始提示词的“困惑度”会产生多大影响。</li>
<li><strong>智能删除</strong>：它会优先删除那些对困惑度影响最小的词语和句子，因为这些内容被认为是信息量较低或冗余的。这个过程被设计得非常精巧，旨在保留关键的实体、术语和逻辑关系。</li>
<li><strong>生成压缩提示词</strong>：经过这个过程，原始的长提示词被压缩成一个更短的版本，其中包含了原始上下文的&quot;精华&quot;。</li>
<li><strong>提交大模型</strong>：最后，这个压缩后的、短小精悍的提示词被发送给目标大模型进行处理。</li>
</ol>
<h4>6.2.4 RECOMP 压缩</h4>
<p>RECOMP (REtrieval-and-COMPression) 是一种面向<strong>复杂问题</strong>的、多步骤的 RAG 策略，它将压缩思想融入到了一个更宏大的框架中。</p>
<p>当面对一个需要综合多个信息源才能回答的复杂问题时，传统的 RAG 一次性检索出的文档可能包含大量不相关细节。RECOMP 通过&quot;分而治之&quot;和&quot;先抽取再合成&quot;的方式来创建高度浓缩和相关的上下文。</p>
<p>基本原理：</p>
<ol>
<li><strong>问题分解（可选）</strong>：对于一个非常复杂的问题，可能首先会将其分解为几个更简单的子问题。</li>
<li><strong>检索与抽取 (Retrieve and Extract)</strong>：针对（每个子）问题，执行以下操作：<ol>
<li><strong>检索</strong>：从知识库中检索相关文档。</li>
<li><strong>抽取</strong>：<strong>这是关键步骤</strong>。它不是直接使用这些文档，而是向 LLM 发出指令，要求 LLM 阅读每个文档，并从中<strong>抽取</strong>出与当前（子）问题直接相关的<strong>简明摘要或关键事实点</strong>。例如：&quot;请阅读以下关于 A 公司的财报，并抽取出其 2023 年第四季度的收入和利润数字。&quot;</li>
</ol>
</li>
<li><strong>压缩与合成 (Compress and Synthesize)</strong>：<ol>
<li>将从所有文档中抽取出的摘要或事实点收集起来。</li>
<li>再次调用 LLM，将这些零散但高度相关的信息点<strong>合成</strong>成一段连贯、流畅、无冗余的文本。这段文本就是最终为原始复杂问题量身定制的“完美上下文”。</li>
</ol>
</li>
<li><strong>最终生成</strong>：将这个合成好的、高度浓缩的上下文连同原始问题一起提交给 LLM，生成最终答案。</li>
</ol>
<p>优势：</p>
<ul>
<li><strong>极高的信息密度</strong>：最终生成的上下文几乎不含任何与问题无关的噪声，每一句话都是为了回答问题而存在的。</li>
<li><strong>处理复杂问题的能力强</strong>：非常适合需要整合来自不同文档、不同主题信息的“多跳（multi-hop）”问题。</li>
<li><strong>可解释性</strong>：由于中间步骤生成了摘要和事实点，这个过程比黑盒方法更易于调试和理解。</li>
</ul>
<h4>6.2.5 Prompt Caching 记忆上下文</h4>
<p>它是一种<strong>性能优化</strong>技术，而非内容压缩技术，但常在处理长上下文时被提及。</p>
<p>在 Transformer 模型（所有现代 LLM 的基础）中，当模型处理一个序列时，它会为每个 Token 计算一个键（Key）和值（Value）向量，这个计算过程非常耗时。Prompt Caching（或称 KV Cache）技术的核心就是：<strong>将已经处理过的 Prompt 部分的 KV 向量缓存起来，下次请求时如果 Prompt 前缀相同，则直接复用缓存，无需重新计算。</strong></p>
<p>基本原理：</p>
<ol>
<li><strong>首次请求</strong>：用户发送一个长 Prompt（例如，一篇需要总结的文章）。模型在处理这个长 Prompt 时，会计算其中每个 Token 的 KV 向量，并将它们存储在 GPU 的内存中（即 KV Cache）。</li>
<li><strong>后续交互</strong>：现在，用户基于这篇文章提问（例如，“文章的作者是谁？”）。这个新的请求实际上是 <code>[原始长Prompt] + [新问题]</code>。</li>
<li><strong>缓存命中</strong>：当模型收到这个新请求时，它会发现请求的前半部分（<code>[原始长Prompt]</code>）与上一次完全相同。它会立即从 KV Cache 中加载这部分的 KV 向量，而<strong>只需为新的部分（<code>[新问题]</code>）计算 KV 向量</strong>。</li>
<li><strong>加速生成</strong>：这样一来，模型省去了重复计算长 Prompt 部分的巨大开销，从而极大地加快了对新问题的响应速度。</li>
</ol>
<p>优势：</p>
<ul>
<li><strong>大幅提升多轮对话或连续查询的性能</strong>：对于聊天机器人、文档问答等需要保持长上下文的场景，效果极其显著。</li>
<li><strong>降低总计算成本</strong>：虽然不减少送入的 Token 数，但通过复用计算结果，降低了处理相同前缀的实际计算成本和时间。</li>
</ul>
<h3>6.3 校正 correction</h3>
<p>C-RAG 的核心思想是在检索模块和生成模块之间，引入一个<strong>轻量级的“检索评估器” (Retrieval Evaluator)</strong>，并根据评估结果采取不同的<strong>校正措施</strong>。这项技术主要在学术论文 <em><a href="https://arxiv.org/abs/2401.15884">arXiv:2401.15884</a></em> 中被系统性地提出和阐述。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%20(7).png%3E" alt="C-RAG"></p>
<p>C-RAG 的精髓在于其动态的、差异化的处理策略。</p>
<ul>
<li><strong>当评估为“不正确”时</strong>：C-RAG 会果断地<strong>抛弃</strong>所有从内部知识库检索到的文档。因为它判断这些文档只会误导 LLM。取而代之，它会<strong>重写 (Rewrite)</strong> 用户的查询，使其更适合通用搜索引擎，然后<strong>触发网络搜索 (Web Search)</strong>，从更广阔的、实时更新的互联网中获取信息。这极大地扩展了 RAG 系统的知识边界，尤其适用于回答关于近期事件或内部知识库未覆盖领域的问题。</li>
<li><strong>当评估为“正确”时</strong>：即便文档是相关的，也可能包含大量与问题无关的“噪音”段落。为了让 LLM 更专注于核心信息，C-RAG 采用了一种“分解-再重组” (Decompose-then-Recompose)*的知识精炼算法。<ul>
<li><strong>分解 (Decompose)</strong>：将相关的文档分解成更小的、独立的知识片段 (Knowledge Strips)。</li>
<li><strong>重组 (Recompose)</strong>：再次使用评估器对每个知识片段进行打分，过滤掉无关的片段，只保留最核心、最相关的知识点，然后将这些精华片段“重组”起来，作为最终的上下文。</li>
</ul>
</li>
<li><strong>当评估为“模糊”时</strong>：C-RAG 会采取一种混合策略。它会<strong>同时</strong>对内部检索到的模糊文档进行上述的“分解-再重组”精炼，并启动网络搜索获取外部信息。最后，将两方面的信息<strong>合并</strong>，为 LLM 提供一个更全面、更鲁棒的上下文。</li>
</ul>
<p>实战案例：<a href="https://langchain-ai.github.io/langgraph/tutorials/rag/langgraph_crag/">LangGraph-CRAG</a></p>
<h2>7. 响应生成</h2>
<ul>
<li><a href="https://python.langchain.com/docs/concepts/output_parsers/">Output parsers | 🦜️🔗 LangChain</a></li>
<li><a href="https://docs.llamaindex.ai/en/stable/module_guides/querying/structured_outputs/output_parser/">LlamaIndex 丨 Output Parsing Modules</a></li>
<li><a href="https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses">OpenAI 丨 Structured Outputs</a></li>
</ul>
<h2>8. 系统性优化</h2>
<p>系统性优化指的是从系统层面上，通过优化整个 RAG 流程来达到一个更好的检索效果。</p>
<h3>8.1 自我修正与反思型 RAG</h3>
<ul>
<li>业界标杆：<a href="https://github.com/AkariAsai/self-rag">self-rag</a></li>
<li>笔者实践：<a href="https://github.com/hedon-ai-road/regulation_rag/blob/main/self_rag.py">self-rag.py</a></li>
</ul>
<p>此架构模拟了人类“先思考、再审视、后修正”的决策过程。系统首先生成一个初步答案，然后启动一个内部的&quot;批评家&quot;来评估这个答案的质量。如果发现问题（如信息不完整、逻辑不通顺），系统会生成修正指令，并基于新指令进行迭代优化，直到产出高质量的最终答案。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/d9b68194-3676-4c00-88c2-12f4e1020e64.png" alt="Self-RAG 思想简化"></p>
<h3>8.2 迭代式检索 RAG</h3>
<ul>
<li><a href="https://arxiv.org/pdf/2403.06840">RA-ISF</a></li>
</ul>
<p>此架构专门应对信息不足的问题。当一次检索无法获取回答复杂问题所需的全部信息时，系统会进入一个迭代循环。它会分析已获取的内容，智能地生成新的、更深入的查询，然后再次进行检索。这个过程不断重复，直到收集到足够全面的上下文，最后再进行综合生成。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%20(8).png%3E" alt="Iterative Retrieval RAG 原理简化"></p>
<h3>8.3 自适应/智能体 RAG</h3>
<ul>
<li><a href="https://github.com/asinghcsu/AgenticRAG-Survey">AgenticRAG-Survey</a></li>
</ul>
<p>此架构将 RAG 提升到了一个智能体 （Agent）的高度。系统核心是一个作为大脑的 LLM，它能自主分析用户问题，并决策采取何种行动：是进行知识库检索、上网搜索、调用计算器，还是直接回答。它能制定多步计划并调用不同工具，展现出更高的灵活性和解决复杂问题的能力。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%20(9).png%3E" alt="Agentic RAG 原理简化"></p>
<h2>9. 评估</h2>
<ul>
<li>实践案例：<a href="https://github.com/hedon-ai-road/regulation_rag/blob/main/eval.ipynb">eval.ipynb</a></li>
</ul>
<h3>9.1 三大标准</h3>
<ul>
<li><strong>Context Relevance</strong>：系统检索到的上下文是否紧密围绕用户的问题展开，是否包含了解答问题所需的关键信息。</li>
<li><strong>Faithfulness</strong>：生成的答案与给定的上下文之间的事实一致性。</li>
<li><strong>Answer Relevance</strong>：关注答案是否直接回答了问题，还关注答案是否完整、是否包含冗余信息。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250704094042400.png" alt="RAG 评估三大标准"></p>
<h3>9.2 三大步骤</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250704094056404.png" alt="RAG 评估三大步骤"></p>
<h3>9.3 Ragas</h3>
<p><a href="https://docs.ragas.io/en/stable/">Ragas</a> 评估指标：</p>
<ul>
<li><strong>Faithfulness</strong>: 生成的答案与给定的上下文之间的事实一致性。</li>
<li><strong>Answer relevancy</strong>: 关注答案是否直接回答了问题，还关注答案是否完整、是否包含冗余信息。</li>
<li><strong>Context Precision</strong>: 衡量检索上下文的信噪比。</li>
<li><strong>Context Recall</strong>: 判断是否能检索到回答问题所需的全部相关信息。</li>
</ul>
<p>优点：</p>
<p>优点：</p>
<ol>
<li>轻量易用。</li>
<li>指标专业性：专为 RAG 设计四大核心指标：上下文相关性（Context Relevance）、上下文召回率（Context Recall）、答案忠实度（Faithfulness）、答案相关性（Answer Relevance）。</li>
<li>无参考标签评估：不依赖参考答案即可完成评估，降低标注成本。</li>
</ol>
<p>缺点：</p>
<ol>
<li>结果可解释性弱：仅输出分数，不提供得分原因。</li>
<li>本地化支持不足：主要优化英文场景，对中文等语言支持有限。</li>
<li>功能扩展性弱：不支持自定义指标，灵活性较。</li>
</ol>
<p>代码示例：</p>
<p><strong>1. 构建数据集</strong></p>
<pre><code class="language-python">from datasets import Dataset

questions = [
    &quot;伙食补助费标准是什么?&quot;,
    &quot;出差可以买意外保险吗？需要自己购买吗&quot;,
]
ground_truths = [
    &quot;伙食补助费标准: 西藏、青海、新疆 120元/人、天 其他省份 100元/人、天&quot;,
    &quot;出差可以购买交通意外保险，由单位统一购买，不再重复购买&quot;,
]

answers = []
contexts = []

for query in questions:
    response, context_list = run_rag_pipeline_without_stream(query=query, k=3)
    answers.append(response)
    contexts.append(context_list)

data = {
    &quot;question&quot;: questions,
    &quot;answer&quot;: answers,
    &quot;contexts&quot;: contexts,
    &quot;ground_truth&quot;: ground_truths
}
dataset = Dataset.from_dict(data)
</code></pre>
<p><strong>2. 定义评估指标</strong></p>
<pre><code class="language-python">from ragas.metrics import(
    faithfulness,
    answer_relevancy,
    context_recall,
    context_precision,
)
</code></pre>
<p><strong>3. 执行评估</strong></p>
<pre><code class="language-python">from ragas import evaluate
from ragas import RunConfig

eval_llm = RagLLM()
embedding_model = RagEmbedding()
eval_embedding_fn = embedding_model.get_embedding_fun()

result = evaluate(
    dataset=dataset,
    llm=eval_llm,
    embeddings=eval_embedding_fn,
    metrics=[
        context_precision,
        context_recall,
        faithfulness,
        answer_relevancy,
    ],
    raise_exceptions=True,
    run_config=config
)

df = result.to_pandas()
</code></pre>
<p><strong>4. 评估结果</strong></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%20(10).png%3E" alt="Ragas 评估结果示例"></p>
<h3>9.4 TruLens</h3>
<p>提供一个交互式的仪表板（Dashboard），用于可视化评估结果、比较不同版本的实验并追踪性能变化。它不仅支持 LangChain 和 LlamaIndex 等主流框架，还支持对完全自定义的 RAG 应用进行封装和评估。</p>
<p>典型流程：</p>
<ol>
<li>定义反馈函数（如 <code>Groundedness</code>，<code>AnswerRelevance</code>，<code>ContextRelevance</code>）；</li>
<li>然后用 <code>TruApp</code> 包装 RAG 应用；</li>
<li>再一个 <code>with</code> 上下文管理器中运行查询；</li>
<li><code>run_dashboard</code> 启动仪表盘查看结果。</li>
</ol>
<p>优点：</p>
<ol>
<li>全链路追踪：记录 RAG 全流程（检索、上下文、生成），支持根本原因分析，精准定位故障点（如检索错误或生成偏差）。</li>
<li>可视化与集成：内置 Web 仪表盘，实时展示评估结果；深度集成 LangChain 和 LlamaIndex。</li>
<li>反馈函数组合：支持自定义反馈函数（如毒性检测、语言匹配），灵活适配业务需求。</li>
</ol>
<p>缺点：</p>
<ol>
<li>指标覆盖面窄：核心仅三大指标（上下文相关性、答案忠实度、答案相关性），缺乏上下文召回率等关键维度。</li>
<li>依赖人工标注：答案正确性等指标需参考答案（Ground Truth），增加标注成本。</li>
<li>调试门槛高：全链路追踪需额外配置，对新手不够友好。</li>
</ol>
<p>代码示例：</p>
<pre><code class="language-python">from trulens_eval import TruApp, Feedback, OpenAI, Select
from trulens_eval.app import App

# 初始化反馈函数提供者
provider = OpenAI()

# 定义 RAG 三元组反馈函数
f_groundedness = Feedback(provider.groundedness_measure_with_cot_reasons).on(Select.RecordCalls.retrieve.rets.collect()).on_output()
f_answer_relevance = Feedback(provider.relevance_with_cot_reasons).on_input().on_output()
f_context_relevance = Feedback(provider.context_relevance_with_cot_reasons).on_input().on(Select.RecordCalls.retrieve.rets[:]).aggregate(np.mean)

# 包装 RAG 应用
tru_rag_app = TruApp(rag_query_engine, app_id=&quot;RAG_v1&quot;, feedbacks=[f_groundedness, f_answer_relevance, f_context_relevance])

# 运行并记录评估
with tru_rag_app as recording:
    rag_query_engine.query(&quot;What did the author do growing up?&quot;)

# 启动仪表板
tru.run_dashboard()
</code></pre>
<h3>9.5 DeepEval</h3>
<p>将自身定位为 LLM 应用的&quot;单元测试&quot;框架，理念非常现代化。提供超过 14 种评估指标，不仅覆盖 RAG，还包括微调等场景。其一大亮点是指标具有<strong>自我解释</strong>能力，即在给出分数的同时，会提供具体的理由来解释为何得分不高，极大地便利了调试过程。此外，它与流行的测试框架 Pytest 深度集成，可以无缝地融入 CI/CD 流程。</p>
<p>优点：</p>
<ol>
<li>工程化与自动化：原生支持 pytest，可集成 CI/CD 流水线，实现自动化测试与报告生成。</li>
<li>指标丰富且可定制：内置 30+ 指标（如忠实度、毒性、偏见检测），支持 DAG 自定义指标（决策树结构）满足复杂逻辑。独创上下文召回率计算（基于关键陈述覆盖比例）。</li>
<li>结果可解释性强：提供分数原因及改进建议，支持与 RAGAS 结果联动分析</li>
</ol>
<p>缺点：</p>
<ol>
<li>部分指标非 RAG 专属：如摘要质量、知识保留等指标更通用，需筛选适用场景。</li>
<li>依赖评估模型：默认使用 OpenAI 模型，替换自定义模型需额外开发。</li>
<li>配置复杂：DAG 指标需设计节点逻辑（任务节点、裁决节点等），学习曲线陡峭。</li>
</ol>
<p>示例代码：</p>
<pre><code class="language-python">import deepeval
import deepeval.evaluate
from deepeval.metrics import (
    FaithfulnessMetric,
    AnswerRelevancyMetric,
    ContextualPrecisionMetric,
    ContextualRecallMetric
)
from deepeval.test_case import LLMTestCase

test_cases = []
for i, question in enumerate(questions):
    test_cases.append(LLMTestCase(
        input=question,
        actual_output=answers[i],
        retrieval_context=contexts[i],
        expected_output=ground_truths[i],
    ))

evaluation_metrics = [
    FaithfulnessMetric(threshold=0.7),
    AnswerRelevancyMetric(threshold=0.8),
    ContextualPrecisionMetric(threshold=0.7),
    ContextualRecallMetric(threshold=0.9)
]

results = deepeval.evaluate(
    test_cases=test_cases,
    metrics=evaluation_metrics
)

for i, result in enumerate(results.test_results):
    print(f&quot;--- TestCase {i+1} ---&quot;)
    print(f&quot;Query: {result.input}&quot;)

    if result.success:
        print(f&quot;✅ Overall Result: Passed\n&quot;)
    else:
        print(f&quot;❌ Overall Result: Failed\n&quot;)

    # 打印每个指标的详细得分和原因
    for metric_result in result.metrics_data:
        print(f&quot;  📊 Metric: {metric_result.__class__.__name__}&quot;)
        print(f&quot;     - Score: {metric_result.score:.2f} (Threshold: {metric_result.threshold})&quot;)
        reason = getattr(metric_result, &#39;reason&#39;, &#39;N/A&#39;)
        print(f&quot;     - Reason: {reason}&quot;)

    print(&quot;-&quot; * 25 + &quot;\n&quot;)
</code></pre>
<p>结合单元测试：</p>
<pre><code class="language-python">import pytest
from deepeval import assert_test
@pytest.mark.parametrize(&quot;test_case&quot;, test_cases)
def regualation_rag_eval(test_case: LLMTestCase):
    print(f&quot;Testing Input: {test_case.input}&quot;)

    assert_test(
        test_case=test_case,
        metrics=[
            FaithfulnessMetric(threshold=0.7),
            AnswerRelevancyMetric(threshold=0.8),
            ContextualPrecisionMetric(threshold=0.7),
            ContextualRecallMetric(threshold=0.9)
        ]
    )
</code></pre>
<p>输出示例：</p>
<pre><code class="language-shell">--- TestCase 1 ---
Query: 伙食补助费标准是什么?
✅ Overall Result: Passed

  📊 Metric: MetricData
     - Score: 1.00 (Threshold: 0.7)
     - Reason: The score is 1.00 because there are no contradictions, indicating a perfect alignment between the actual output and the retrieval context. Great job maintaining accuracy and consistency!
  📊 Metric: MetricData
     - Score: 1.00 (Threshold: 0.8)
     - Reason: The score is 1.00 because the response perfectly addresses the question about the standard for meal allowances without any irrelevant information. Great job!
  📊 Metric: MetricData
     - Score: 1.00 (Threshold: 0.7)
     - Reason: The score is 1.00 because the relevant nodes in the retrieval contexts are perfectly ranked above the irrelevant node. The first node provides a clear table with the &#39;伙食补助费标准&#39; for different regions, directly answering the input question. The second node further explains the concept and provides the same standards, reinforcing the relevance. The third node, which discusses hotel recommendations and accommodation fees, is unrelated and correctly ranked last.
  📊 Metric: MetricData
     - Score: 1.00 (Threshold: 0.9)
     - Reason: The score is 1.00 because the expected output perfectly aligns with the information in the nodes in the retrieval context, showcasing a flawless match. Great job!
</code></pre>
<h2>10. Graph RAG</h2>
<h3>10.1 图数据库</h3>
<h4>10.1.1 neo4j</h4>
<p><a href="https://github.com/neo4j/neo4j">neo4j</a> 使用的是语言是 <a href="https://neo4j.com/docs/getting-started/cypher/">cypher</a>。Cypher 的核心是 <code>MATCH</code>（模式匹配） + <code>RETURN</code>（结果返回），辅以 <code>CREATE</code>/<code>MERGE</code>（数据操作）、<code>WHERE</code>（过滤）、<code>WITH</code>（管道传递）。</p>
<p><strong>1. 节点与关系语法</strong></p>
<ul>
<li><strong>节点</strong>：用圆括号 <code>()</code> 表示，可包含变量、标签和属性。<ul>
<li><code>()</code>：匿名节点</li>
<li><code>(p:Person)</code>：变量 <code>p</code> + 标签 <code>Person</code></li>
<li><code>(p:Person {name: &#39;Alice&#39;, age: 30})</code>：带属性的节点。</li>
</ul>
</li>
<li><strong>关系</strong>：用方括号 <code>[]</code> 表示，放在两个短横线中间（<code>--</code>），方向用箭头（<code>→</code> 或 <code>←</code>）指定。<ul>
<li><code>-[:KNOWS]-</code>：无变量、类型为 <code>KNOWS</code> 的无向关系</li>
<li><code>-[r:ACTED_IN {roles: [&#39;Neo&#39;]}]-→</code>：变量 <code>r</code> + 类型 <code>ACTED_IN</code> + 属性</li>
</ul>
</li>
</ul>
<p><strong>2. 模式匹配（MATCH）</strong></p>
<p>核心是通过路径模式描述图结构：</p>
<pre><code class="language-cypher">MATCH (p:Person)-[r:ACTED_IN]-&gt;(m:Movie {title: &#39;The Matrix&#39;})
RETURN p, r.roles
</code></pre>
<ul>
<li>可选匹配：<code>OPTIONAL MATCH</code> 处理可能不存在的关系。</li>
</ul>
<p><strong>3. 数据操作语句</strong></p>
<ul>
<li><p>创建：</p>
<ul>
<li><code>CREATE (p:Person {name: &#39;Alice&#39;})</code>：创建节点。</li>
<li><code>CREATE (a)-[:FRIEND]-&gt;(b)</code>：创建关系（需先匹配 <code>a</code>, <code>b</code>）</li>
</ul>
</li>
<li><p>更新：<code>SET</code> 修改属性</p>
<pre><code class="language-cypher">MATCH (p:Person) SET p.age = 31
</code></pre>
</li>
<li><p>合并：<code>MERGE</code> 存在则匹配，不存在则创建</p>
<pre><code class="language-cypher">MERGE (p:Person {name: &#39;Alice&#39;})
ON CREATE SET p.created_at = timestamp()
</code></pre>
</li>
<li><p>删除：</p>
<ul>
<li><code>DELETE n</code>：删除节点（需先断开关系）</li>
<li><code>DETACH DELETE n</code>：删除节点及关联关系</li>
</ul>
</li>
</ul>
<p><strong>4. 查询控制条件</strong></p>
<ul>
<li><p>过滤：<code>WHERE</code> 条件筛选</p>
<pre><code class="language-cypher">MATCH (p:Person) WHERE p.age &gt; 30 OR p.name STARTS WITH &#39;A&#39;
</code></pre>
</li>
<li><p>返回：<code>RETURN</code> 指定输出</p>
</li>
<li><p>连接查询：<code>WITH</code> 传递中间结果</p>
<pre><code class="language-cypher">MATCH (p)-[:FRIEND]-&gt;(f)
WITH p, count(f) AS friendCount
WHERE friendCount &gt; 10
RETURN p.name
</code></pre>
</li>
<li><p>聚合与排序：</p>
<ul>
<li><code>COUNT()</code>, <code>COLLECT()</code>：聚合函数</li>
<li><code>ORDER BY p.age DESC LIMIT 10</code>：排序和分页</li>
</ul>
</li>
</ul>
<p><strong>5. 索引与约束</strong></p>
<ul>
<li><p>索引：加速节点查找</p>
<pre><code class="language-cypher">CREATE INDEX FOR (p:Person) ON (p.name)
</code></pre>
</li>
<li><p>约束：确保数据唯一性</p>
<pre><code class="language-cypher">CREATE CONSTRAINT ON (m:Movie) ASSERT m.title IS UNIQUE
</code></pre>
</li>
</ul>
<h4>10.1.2 nebula graph</h4>
<p><a href="https://github.com/vesoft-inc/nebula">nebula graph</a> 使用的语言是 <a href="https://docs.nebula-graph.io/3.8.0/3.ngql-guide/1.nGQL-overview/1.overview/">nGQL</a>。</p>
<h3>10.2 典型流程</h3>
<ol>
<li>将问题提交给 LLM，让其提取（总结）关键词；</li>
<li>通过关键词来地毯式查询节点，尝试命中图数据库中定义的节点；</li>
<li>如果有命中的，则通过节点来查询关联的关系和节点信息；</li>
<li>将查询到的信息组织上上下文提交给 LLM，解答最初的问题。</li>
</ol>
<h3>10.3 实战案例</h3>
<ul>
<li><a href="https://github.com/hedon-py-road/learn-neo4j/blob/main/neo4j.ipynb">learn-neo4j</a></li>
<li><a href="https://github.com/hedon-ai-road/ftt_rag/blob/main/graph-rag.ipynb">ftt_graph_rag</a></li>
</ul>
<h2>11. ReAct RAG</h2>
<p>ReAct = Reasoning + Acting = 推理 + 行动</p>
<ul>
<li>核心理念：让大型语言模型像人一样，在解决复杂问题时，能够先思考分析（推理），然后根据思考结果采取行动（行动），再观察行动结果，接着进行新一轮的思考，如此循环，直到问题解决。</li>
<li>核心流程：<ul>
<li>思考（Thought）</li>
<li>行动（Action）</li>
<li>观察（Observation）</li>
<li>思考（Thought）</li>
<li>...</li>
<li>最终答案（Final Answer）</li>
</ul>
</li>
</ul>
<h3>11.1 Prompt</h3>
<p><strong>1. 明确的规则制定（Rule Formulation）</strong></p>
<ol>
<li>循环结构：强制模型遵循 &quot;Thought -&gt; Action -&gt; Observation&quot; 的循环。</li>
<li>输出格式：严格规定每一个环节的输出格式，便于程序解析。</li>
<li>终止条件：明确告诉模型何时任务算完成，以及如何提交最终答</li>
</ol>
<p><strong>2. 精确的工具授权（Tool Granting）</strong></p>
<ol>
<li>功能单一：每个工具最好只做一件事，这让模型更容易选择。</li>
<li>描述清晰：工具的描述 (description) 是模型决定使用哪个工具的唯一依据。描述要用自然语言写得清晰、准确，说明白“这个工具能干什么”。</li>
<li>参数明确：工具的输入参数 (parameters) 必须定义清楚，包括名称、类型和用途。</li>
<li>有了 MCP 后，这一步可以用 MCP 来替代。</li>
</ol>
<p><strong>3. 高质量的示例引导（Example Guidance）</strong></p>
<ol>
<li>展示思维链：清晰地展示从问题到第一个思考，再到行动的逻辑。</li>
<li>覆盖典型场景：展示如何使用不同的工具，甚至是如何组合使用工具。</li>
<li>处理异常情况：最好能包含一个处理错误的示例 (比如搜索不到结果时该怎么办)，这能极大地提升模型的鲁棒性。</li>
</ol>
<p>Prompt 示例骨架：</p>
<pre><code class="language-Markdown">You are an expert assistant capable of solving complex problems by breaking them down into a sequence of thought and action. You must strictly follow the format of &quot;Thought, Action, Observation&quot; to solve the problem.

You have access to the following tools:

[tool_definitions]

Use the following format:

Question: The user&#39;s question you need to answer.
Thought: Your internal reasoning and plan for the next action.
Action: The tool you will use. It must be one of [list_of_tool_names].
Observation: The result returned by the action.
... (this Thought/Action/Observation cycle can repeat N times)
Thought: I now have the final answer.
Action: finish(the final answer)
</code></pre>
<p>Few Shot 示例：</p>
<pre><code class="language-Markdown">Question: 苹果公司的现任 CEO 是谁？他的前任是谁？

Thought: 我需要分两步解决这个问题。第一，找出苹果的现任 CEO。第二，找出他的前任。我先执行第一步。
Action: search(&quot;苹果公司现任 CEO&quot;)
Observation: 苹果公司现任 CEO 是蒂姆·库克 (Tim Cook)。

Thought: 我已经知道了现任 CEO 是蒂姆·库克。现在我需要找出他的前任是谁。
Action: search(&quot;蒂姆·库克的前任是谁&quot;)
Observation: 蒂姆·库克的前任是苹果公司的创始人史蒂夫·乔布斯 (Steve Jobs)。

Thought: 我已经获得了所有需要的信息：现任 CEO 是蒂姆·库克，前任是史蒂夫·乔布斯。我可以给出最终答案了。
Action: finish(&quot;苹果公司的现任 CEO 是蒂姆·库克，他的前任是史蒂夫·乔布斯。&quot;)
</code></pre>
<h3>11.2 实战案例</h3>
<ul>
<li><a href="https://github.com/hedon-ai-road/react_rag">react-rag</a></li>
</ul>
<h2>12. RAG 相关思考</h2>
<ul>
<li><a href="https://www.woshipm.com/ai/6235363.html">企业大模型落地的现实解法：为什么 RAG 是绕不开的技术路径？</a></li>
<li><a href="https://blog.csdn.net/2401_84495872/article/details/148831083">不需要 RAG！手把手教你构建问答 Agent</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨03丨模块化</title>
      <link>https://hedon.top/blog/fosa-ch3/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch3/</guid>
      <pubDate>Wed, 02 Jul 2025 11:00:26 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第三章的课后思考题，深入探讨模块化设计的核心概念——连接性（connascence），分析静态与动态连接性的区别、不同连接性形式的强弱程度，以及如何在代码库中合理选择连接性类型，帮助理解高内聚低耦合的实现原理和最佳实践。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第三章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>What is meant by the term <em>connascence</em>?</p>
<p>&quot;连接性&quot;这个术语是什么意思？</p>
</li>
<li><p>What is the difference between static and dynamic connascence?</p>
<p>静态连接性和动态连接性有什么区别？</p>
</li>
<li><p>What does <em>connascence of type</em> mean? Is it static or dynamic connascence?</p>
<p>类型连接性是什么意思？它是静态的还是动态的？</p>
</li>
<li><p>What is the strongest form of connascence?</p>
<p>连接性最强的形式是什么？</p>
</li>
<li><p>What is the weakest form of connascence?</p>
<p>连接性最弱的形式是什么？</p>
</li>
<li><p>Which is preferred within a code base—static or dynamic connascence?</p>
<p>在一个代码库中，静态连接性和动态连接性哪个更受青睐？</p>
</li>
</ol>
<hr>
<p>本篇的主题是 Modularity（模块化），谈到模块化，我们最常提到的一句话就是：&quot;高内聚（cohesion）、低耦合（coupling）&quot;。</p>
<ul>
<li>内聚是指模块内部各元素之间关联的紧密程度。高内聚意味着模块中的所有元素都紧密相关，并且共同为一个单一的、明确定义的目的服务。高内聚使模块的功能职责更加集中和明确，易于理解和修改。</li>
<li>耦合是指不同模块之间相互依赖的程度。低耦合意味着模块之间的依赖关系很弱，一个模块的改变对其他模块的影响尽可能小。低耦合降低了系统修改、测试和部署的风险。当系统的一个部分发生变化时，由于依赖关系较少，需要修改的其他部分也较少。</li>
</ul>
<p>课后题中提到的 connascence（连接性）跟耦合的概念很像。</p>
<blockquote>
<p>当一个组件的修改需要修改另外一个组件才能保持系统整体的正确性时，这两个组件就处于连接性状态。它衡量了软件组件之间相互依赖的程度。</p>
<p>连接性是对耦合度量的一种细化。虽然传统的耦合度量（如内向耦合和外向耦合）关注的是连接的方向，但连接性更进一步，关心组件之间如何耦合在一起。</p>
</blockquote>
<p>连接性可以分为 2 大类：</p>
<ul>
<li>静态连接性：指的是源代码级别的耦合，它可以通过简单的源代码分析来确定。<ul>
<li>命名连接性：多个组件必须就某个实体的名称达成一致。这是最弱的连接性形式，也是代码库中最理想的耦合方式，因为现代重构工具可以轻松实现系统范围内的名称更改。</li>
<li>类型连接性：多个组件必须就某个实体的类型达成一致。这在许多静态类型语言中很常见，例如变量和参数被限制为特定类型。</li>
<li>意义连接性 / 约定连接性：多个组件必须就某个值的含义或约定达成一致。</li>
<li>位置连接性：多个组件必须就某个实体在列表、记录或参数中的位置达成一致。</li>
<li>算法连接性：多个组件必须就某个特定算法达成一致。</li>
<li>执行连接性：多个组件必须就某个执行顺序或流程达成一致。</li>
</ul>
</li>
<li>动态连接性：指的是运行时的调用，这种很难确定，因为缺乏有效的运行时分析工具。<ul>
<li>执行连接性：不同语句的执行顺序很重要。例如一段代码中某些属性必须按特定顺序设置才能正确运行。</li>
<li>时序连接性：多个组件的执行时序很重要。通常发生在竞态条件中，即两个线程同时执行并影响联合操作的结果</li>
<li>值连接性：多个值之间相互关联，必须一起改变。</li>
<li>身份连接性：多个组件必须引用同一个实体。例如，两个独立的组件必须共享和更新一个公共数据结构。</li>
</ul>
</li>
</ul>
<p>连接性的强弱如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250702130438266.png" alt="FOSA Figure 3-5. The strength on connascence provides a good refactoring guide"></p>
<p>越往左上角，表示连接性越弱，也是我们编写代码时更希望看到的。</p>
<p>Page-Jones 提出了 3 个使用连接性来改善系统模块化的指导原则：</p>
<ol>
<li>通过将系统分解为封装的元素来最小化整体连接性；</li>
<li>最小化任何剩余的跨越封装边界的连接性；</li>
<li>最大化封装边界内的连接性。</li>
</ol>
<p>Jim Weirich 进一步给出了 2 条建议：</p>
<ol>
<li><strong>程度规则（Rule of Degree）</strong>：将强形式的连接性转换为弱形式的连接性。例如，可以通过重构将意义连接性（CoM）转换为命名连接性（CoN），即创建命名常量而不是使用&quot;魔法值&quot;。</li>
<li><strong>局部性规则（Rule of Locality）</strong>：随着软件元素之间距离的增加，使用弱形式的连接性。这意味着，如果组件彼此远离，则它们之间的耦合应尽可能松散，避免强连接性形式。</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨02丨架构思维</title>
      <link>https://hedon.top/blog/fosa-ch2/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch2/</guid>
      <pubDate>Tue, 01 Jul 2025 11:00:26 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第二章的课后思考题，深入探讨传统架构方法的局限性、知识三角的三个层次、架构师技术广度与深度的平衡，以及架构师保持技术敏锐度的实践方法，帮助理解现代架构师应具备的思维模式。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第二章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li><p>Describe the traditional approach of architecture versus development and explain why that approach no longer works.</p>
<p>描述传统意义上的架构与开发的方法，并解释为什么这种方法不再适用。</p>
</li>
<li><p>List the three levels of knowledge in the knowledge triangle and provide an example of each.</p>
<p>列出知识三角中的三个层次，并为每个层次提供一个示例。</p>
</li>
<li><p>Why is it more important for an architect to focus on technical breadth rather than technical depth?</p>
<p>为什么对于一位架构师而言，注重技术的广度而非深度会显得更为重要呢？</p>
</li>
<li><p>What are some of the ways of maintaining your technical depth and remaining hands-on as an architect?</p>
<p>作为架构师，有哪些方法可以保持技术深度并保持动手能力呢？</p>
</li>
</ol>
<hr>
<h2>1. 架构与开发方法</h2>
<p>在传统方法中，架构师与开发人员的职责是分离的，如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250701102459269.png" alt="FOSA Figure 2-1. Traditional view of architecture versus design"></p>
<p>架构师的职责：</p>
<ul>
<li>分析业务逻辑，以提取和定义架构特征</li>
<li>选择适合问题领域的架构模式和风格</li>
<li>创建系统的构建块 —— 组件</li>
</ul>
<p>开发者的职责：</p>
<ul>
<li>根据架构师交付的产出，创建类图</li>
<li>构建用户界面</li>
<li>开发和测试源代码</li>
</ul>
<p>这种传统方法的问题在于它是一种单向的&quot;移交&quot;模式。架构师在设计完成后将产物移交给开发团队，但架构师与开发人员之间存在物理和虚拟的障碍。这种方法不再适用的原因主要有以下几点：</p>
<ol>
<li><strong>信息丢失和脱节</strong>：架构师的决策有时无法有效传达给开发团队，而开发团队在实现过程中对架构产生的改变也鲜有反馈给架构师。</li>
<li><strong>缺乏协作与同步</strong>：唯一不变的就是变，业务是不断发展的，系统架构也是不断演进和迭代的。这种单向模式导致双方缺乏紧密、双向的合作，无法及时响应业务和技术变化，导致架构变得脆弱且难以维护。</li>
</ol>
<p>更适合现代软件开发的方法应如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250701102918334.png" alt="FOSA Figure 2.2. Making architecture work through collaboration"></p>
<p>在这种双向交互的方式中，架构师不再仅仅是交付设计产出，而是在整个软件生命周期中，不断对开发团队进行领导和指导，开发团队在实现过程也，也不断将遇到的问题和改变返回给架构师时，双方紧密合作、互通有无、共同前进。</p>
<h2>2. 知识三角</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250701103512872.png" alt="FOSA Figure 2-3. The pyramid representing all knowledge"></p>
<p><strong>第一层 (底层)：你知道你知道的 (Stuff you know you know)</strong> 这是你知识体系的基石，是你明确掌握、能够熟练运用的技能和知识。它们是你的&quot;舒适区&quot;和核心竞争力。比如掌握编程语言（Go/Rust）的基本语法和常见框架。</p>
<p><strong>第二层 (中层)：你知道你不知道的 (Stuff you know you don&#39;t know)</strong> 这是你已经意识到但尚未掌握的领域。你可能听说过某个技术、某个概念，知道它的存在和价值，但还没有系统学习或实践过。这是你明确的学习目标和成长方向。比如编程语言（Go/Rust）的编译原理和编译期优化技术。</p>
<p><strong>第三层 (顶层)：你不知道你不知道的 (Stuff you don&#39;t know you don&#39;t know)</strong> 这是你的认知盲区。你甚至不知道这些知识、技术或方法论的存在。它们往往是突破瓶颈、实现认知跃迁的关键，也是最大的风险和机遇所在。一个人的成长，很大程度上就是不断将第三层的 &quot;未知未知&quot;转化为第二层的&quot;已知未知&quot;的过程。比如 AI 领域中的各种新兴技术。</p>
<h2>3. 广度还是深度</h2>
<p>对于架构师而言，广度比深度要更重要，原因如下：</p>
<ol>
<li>架构师的职责不是&quot;做&quot;，而是&quot;选择怎么做&quot;。只有当架构师拥有广泛的知识宽度时，能在不同的业务场景中，通过权衡对比，选择出最适合当前业务的架构模式。</li>
<li>每个人的时间和精力是有限的，我们不可能同时精通所有的技术，所以架构师必须在广度和深度之间有所侧重。为了避免&quot;过时专业知识&quot;，架构师需要保持学习最新前沿技术，试图在多个领域保持深度会导致精力耗尽，只能牺牲部分深度以换取更大的广度。</li>
<li>软件架构中一切都是&quot;权衡&quot;。只知道一种解决方案的架构师是做不出权衡的，只有当知道多种解决方案，并清楚其中的优劣，才能在充满各种限制的现实场景中做出&quot;最不坏&quot;的架构设计。</li>
</ol>
<p>在笔者看来，为了后期可以更快、更扎实的扩展我们的广度，前期的&quot;深度探索&quot;尤为重要。拥有一个深度的知识领域，就像在浩瀚的知识海洋中打下了一个坚实的&quot;锚&quot;。当你学习新知识时，可以把它关联到你的“锚点”上，而不是让它漂浮在空中。正所谓：<strong>一通百通</strong>。</p>
<p>如果你深度研究过 Go 的 GMP 调度模型、goroutine 的实现、channel 的底层结构，你不仅仅是学会了 Go 的并发。你真正理解了<strong>用户态线程、M:N 调度、CSP (Communicating Sequential Processes) 模型</strong> 等核心概念。当你再去学习 Rust 的 <code>async/await</code> 和 <code>tokio</code> 时，你会发现虽然语法和所有权规则完全不同，但其背后的<strong>异步运行时、任务调度、Future/Executor 模型</strong> 等思想，都与你已有的知识体系遥相呼应。你的学习过程不再是死记硬背，而是<strong>比较、关联和迁移</strong>，效率极高。</p>
<p>所以，一个更完整、更理想的技术人员成长路径应该是：</p>
<ol>
<li><strong>职业前期 (深耕期)：</strong> <strong>深度优先，广度为辅。</strong> 选择一个你感兴趣且有前景的主航道（如 Go/Rust 后端开发），投入 80% 的精力向下猛扎，直到成为该领域的专家。用 20% 的精力保持对周边领域的关注。这个阶段的目标是 <strong>&quot;立足&quot;</strong>。</li>
<li><strong>职业中期 (拓展期)：</strong> <strong>深度与广度并重。</strong> 在你的根据地已经非常扎实之后，开始有意识地、系统性地拓展你的知识广度，将深耕期遇到的问题和知识点串联起来，形成体系。从 &quot;I 型人才&quot; 向 &quot;T 型人才&quot; 转变。这个阶段的目标是 <strong>&quot;连接&quot;</strong>。</li>
<li><strong>职业后期 (整合期)：</strong> <strong>广度优先，深度为基。</strong> 当你需要承担架构师、技术负责人等角色时，你的主要价值来自于广阔的视野和权衡决策能力。你过往的深度积累，则为你的决策提供了坚实的支撑和深刻的洞察力。这个阶段的目标是 <strong>&quot;引领&quot;</strong>。</li>
</ol>
<h2>4. 维持动手能力</h2>
<p>很多架构师与开发团队的协议日益疏远，很大程度源于架构师脱离一线开发环境太久了，以下是一些简单且有效保持技术深度和动手能力的方法：</p>
<ol>
<li><strong>避免&quot;瓶颈陷阱（bottleneck trap）&quot;并委派核心代码</strong>：架构师不应独自承担关键路径或框架代码的开发，因为这会使其成为团队的瓶颈。应将这些核心代码委派给开发团队。架构师可以专注于编码一到三个迭代后的业务功能（例如，一个服务或一个屏幕），从而既能获得实践经验，又能让开发团队拥有核心代码的所有权，并更好地理解他们在开发过程中可能遇到的问题。</li>
<li><strong>持续做概念验证（POCs）</strong>：POC 不仅要求架构师编写代码，还能通过实践验证架构决策。在这个过程中，架构师需要严格要求自己产出高质量的代码，因为开发团队后续的工作，很大程度会在 POC 的基础上进行扩展。</li>
<li><strong>处理技术债务</strong>：通常这些任务优先级较低，即使未能在一个迭代内完成，也不会对项目造成严重影响。这能让架构师获得实践编码经验，同时解放开发团队去处理关键功能用户故事。</li>
<li><strong>参与修复非紧急漏洞</strong>：处理 BUG 能够帮助架构师识别代码库乃至架构中的问题和弱点。</li>
<li><strong>构建效率提升工具</strong>：开发一些提升效率的小工具，一方面可以帮助开发团队更高效率地开展工作，另一方面也可以有效维持架构师的动手能力。</li>
<li><strong>编写适应性函数（Fitness Functions）</strong>：辅助编写单元测试、集成测试等自动化测试，一来能帮助架构师维持动手能力，二来能帮助架构师更深入理解项目细节，三来可以提高项目架构的健壮性。</li>
<li><strong>参与代码评审（Code Review）</strong>：代码评审能让架构师保持对代码库的参与度，同时确保架构合规性，并发现团队中的指导和辅导机会。</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>Go 底层原理丨深度剖析 Gin 框架核心机制：从 HTTP 请求生命周期到高性能设计哲学</title>
      <link>https://hedon.top/blog/go-gin/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-gin/</guid>
      <pubDate>Mon, 30 Jun 2025 16:43:04 GMT</pubDate>
      <description>基于 Gin v1.10.1 源码，深入解析 HTTP 请求在 Gin 中的完整生命周期，包括高性能 Radix Tree 路由实现、sync.Pool 对象池优化、中间件洋葱模型、以及优雅关闭等核心机制</description>
      <category>Go</category>
      <content:encoded><![CDATA[<p>本篇笔者将尝试基于 <a href="https://github.com/gin-gonic/gin/tree/v1.10.1">Gin v1.10.1</a> 来进行一趟 Gin 源码之旅，为了使我们的学习更有方向，在开始之前，我们来思考一个问题：</p>
<blockquote>
<p>一个 HTTP 请求从抵达 Gin 到返回响应，其完整的旅程是怎样的？</p>
</blockquote>
<ol>
<li><strong>启动服务：</strong> <code>r.Run()</code> 究竟做了什么？（提示：它内部调用了 Go 标准库的 <code>http.ListenAndServe</code>）</li>
<li><strong>请求入口：</strong> 当一个请求到来，Go 的 <code>http.Server</code> 是如何将请求交给 Gin 的核心 <code>Engine</code> 处理的？（提示：<code>Engine</code> 本身就是一个 <code>http.Handler</code>）</li>
<li><strong>上下文创建：</strong> <code>gin.Context</code> 是在何时被创建的？它封装了什么？</li>
<li><strong>路由匹配：</strong> Gin 如何根据请求的 URL 快速找到对应的处理函数？</li>
<li><strong>中间件执行：</strong> <code>r.Use()</code> 添加的中间件是如何形成一个“调用链”的？<code>c.Next()</code> 的作用机制是什么？</li>
<li><strong>业务处理：</strong> 你的业务逻辑处理函数（Handler）是如何被调用的？</li>
<li><strong>响应返回：</strong> <code>c.JSON()</code> 或 <code>c.String()</code> 这样的函数，最终是如何将数据写入到 <code>http.ResponseWriter</code> 的？</li>
<li><strong>资源回收：</strong> <code>gin.Context</code> 对象在请求结束后是如何被回收的？（提示：<code>sync.Pool</code>）</li>
<li><strong>优雅关闭：</strong> 服务在关闭过程中，如何保证当前请求被正确完整处理？</li>
</ol>
<h2>从本篇中你可以学到什么</h2>
<p>阅读本文，你将不仅仅是学会如何使用 Gin 框架，更是能深入到底层，理解其高效运作背后的原理。这趟旅程将为你揭示一个完整的 HTTP 请求在 Gin 中的生命周期，让你在未来的开发与面试中都更具深度和信心。</p>
<p>具体来说，你将收获以下核心知识点：</p>
<ul>
<li><strong>Go Web 服务核心原理</strong><ul>
<li>理解 Gin 的 <code>r.Run()</code> 如何封装并启动标准库的 <code>http.Server</code>。</li>
<li>掌握 Go <code>net/http</code> 服务如何通过 <code>net.Listener</code> 的 <code>Accept()</code> 循环来接收 TCP 连接，并为每个连接开启独立 Goroutine 进行处理的并发模型。</li>
</ul>
</li>
<li><strong>Gin 的高性能设计哲学</strong><ul>
<li>剖析 Gin 如何通过 <code>sync.Pool</code> 对象池技术来复用 <code>gin.Context</code>，从而大幅减少内存分配和 GC 压力，这是 Gin 高性能的关键之一。</li>
<li>学习 Go <code>http.Server</code> 中优雅关闭（<code>Shutdown</code>）的完整实现，包括：<ul>
<li>如何通过 <code>Context</code> 控制超时。</li>
<li>如何区分并分别处理<strong>监听器（Listener）</strong> 和<strong>连接（Connection）</strong>。</li>
<li>高效轮询等待中的<strong>指数退避（Exponential Backoff）</strong> 与<strong>抖动（Jitter）</strong> 策略。</li>
</ul>
</li>
</ul>
</li>
<li><strong>精巧的 Radix Tree 路由实现</strong><ul>
<li>深入理解 Gin 高性能路由的<strong>基数树（Radix Tree）实现原理</strong>，包括 <code>methodTrees</code> 的整体结构。</li>
<li>彻底搞懂路由 <code>node</code> 节点的每个字段的精确含义，特别是 <code>indices</code>（快速索引）和 <code>wildChild</code>（通配符标志）这两个性能优化的法宝。</li>
<li>掌握路由的<strong>查找（<code>getValue</code>）过程</strong>：包括前缀匹配、静态路由匹配、以及如何通过 <code>skippedNodes</code> 实现<strong>回溯（Backtracking）</strong> 机制来保证静态路由的优先级。</li>
<li>掌握路由的<strong>注册（<code>addRoute</code>）过程</strong>：包括最核心的<strong>节点分裂（Split Edge）</strong> 逻辑，以及如何通过 <code>panic</code> 来避免<strong>通配符冲突</strong>，从而在构建时就保证路由树的逻辑正确性。</li>
</ul>
</li>
<li><strong>中间件的洋葱模型</strong><ul>
<li>揭秘 Gin 中间件的核心 <code>c.Next()</code> 的工作机制，理解 <code>HandlersChain</code> 和 <code>index</code> 索引是如何协同工作，实现了优雅的“洋葱模型”调用链。</li>
</ul>
</li>
<li><strong>框架的扩展性与接口设计</strong><ul>
<li>了解 Gin 如何通过 <code>RouterGroup</code> 组合的方式，巧妙地为 <code>Engine</code> 和 <code>RouterGroup</code> 自身都实现 <code>IRouter</code> 接口，从而支持灵活的路由分组与嵌套。</li>
<li>学习 <code>c.JSON()</code> 背后的 <code>render.Render</code> 接口设计，理解其如何将不同格式（JSON、XML、HTML 等）的响应渲染逻辑解耦。</li>
</ul>
</li>
</ul>
<h2>启动服务 r.Run()</h2>
<pre><code class="language-go">func (engine *Engine) Run(addr ...string) (err error) {
	address := resolveAddress(addr)
	err = http.ListenAndServe(address, engine.Handler())
	return
}
</code></pre>
<h3>地址解析</h3>
<p>其中 resolveAddress 就是解析监听地址，默认为 <code>:8080</code>：</p>
<pre><code class="language-go">func resolveAddress(addr []string) string {
	switch len(addr) {
	case 0:  // 如果没传地址
    // 先尝试从环境变量 PORT 中获取监听端口
		if port := os.Getenv(&quot;PORT&quot;); port != &quot;&quot; {
			return &quot;:&quot; + port
		}
    // 默认 8080 端口
		return &quot;:8080&quot;
	case 1:
    // 传了地址，则使用传递的地址参数
		return addr[0]
	default:
    // 只允许传递一个地址，否则 panic
		panic(&quot;too many parameters&quot;)
	}
}
</code></pre>
<h3>启动监听</h3>
<p><code>r.Run()</code> 底层使用的其实还是标准库 <code>http</code> 的 <code>ListenAndServe()</code>：</p>
<pre><code class="language-go">func (s *Server) ListenAndServe() error {
  // 如果服务正在关闭中，直接返回报错
  if s.shuttingDown() {
		return ErrServerClosed
	}

  // ... 省略非核心代码
	ln, _ := net.Listen(&quot;tcp&quot;, addr)
	return s.Serve(ln)
}
</code></pre>
<p>核心看 <code>s.Serve(ln)</code>：</p>
<pre><code class="language-go">// Serve 方法会针对监听器 l 接收到来的连接建立新的服务协程。
// 每个服务协程会读取请求，并随后调用 s.Handler 来作出回应。
// 只有当监听器返回的连接是 [*tls.Conn] 类型，并且这些连接在 TLS 配置的 NextProtos 中设置了“h2”选项时，才会启用 HTTP/2 支持。
// 服务调用函数 always 会返回一个非空的错误，并关闭 l。
// 在 Server.Shutdown 或 Server.Close 之后，返回的错误为 ErrServerClosed。
func (s *Server) Serve(l net.Listener) error {
	// ...

  // 创建 context
	baseCtx := context.Background()
	if s.BaseContext != nil {
    // 如果有自定义的 context 构造器，则使用自定义的来初始化
		baseCtx = s.BaseContext(origListener)
		if baseCtx == nil {
			panic(&quot;BaseContext returned a nil context&quot;)
		}
	}

	ctx := context.WithValue(baseCtx, ServerContextKey, s)
	for {
    // 监听客户端连接
		rw, err := l.Accept()
		// ...
		connCtx := ctx
    // 初始化一个连接对象
		c := s.newConn(rw)
		c.setState(c.rwc, StateNew, runHooks) // before Serve can return
		// 服务这个连接对象
    go c.serve(connCtx)
	}
}
</code></pre>
<ol>
<li>服务基础 context 的初始化和相关状态处理，作为后面在每个连接上的请求响应的 gin.Context 的基础。</li>
<li>for 循环监听客户端连接。</li>
<li>为每个客户端建立 conn 连接对象。</li>
<li><code>c.serve(connCtx)</code> 服务每一个连接。</li>
</ol>
<h2>请求入口 c.serve(connCtx)</h2>
<h3>连接握手</h3>
<p>重点来看 <code>c.serve(connCtx)</code>：</p>
<pre><code class="language-go">func (c *conn) serve(ctx context.Context) {
	if tlsConn, ok := c.rwc.(*tls.Conn); ok {
		// tls 握手
	}

  // 超时控制
	ctx, cancelCtx := context.WithCancel(ctx)
	c.cancelCtx = cancelCtx
	defer cancelCtx()

  // 跟踪当前 listener，里面其实是一个 waitGroup，用于优雅重启确保监听器完全关闭
  if !s.trackListener(&amp;l, true) {  // 可以理解为 wg.Add(1)
		return ErrServerClosed
	}
  defer s.trackListener(&amp;l, false)  // 可以理解为 wg.Done()

	for {
    // 读取请求数据，返回的 w 是一个 response，用于响应数据
		w, err := c.readRequest(ctx)
		if err != nil {
      // 异常处理
			return
		}

    // -----&gt; 核心具体的 HTTP 处理函数
		serverHandler{c.server}.ServeHTTP(w, w.req)

    // 结束请求，响应数据
		w.finishRequest()

    // 非长连接则直接返回，否则继续复用当前连接
		if !w.conn.server.doKeepAlives() {
			return
		}
	}
}
</code></pre>
<ol>
<li>如果配置了 TLS，则进行 TLS 加密握手；</li>
<li>创建超时控制 context，对于超时连接强制关闭；</li>
<li>for 循环 <code>c.readRequest(ctx)</code> 读取请求数据；</li>
<li>执行 <code>ServeHTTP(w, req)</code> 执行具体的 HTTP 处理业务；</li>
<li>结束请求，<code>w.finishRequest()</code> 响应数据；</li>
<li>非长连接则直接返回，释放连接，否则复用当前连接处理后续请求。</li>
</ol>
<h3>读取请求</h3>
<p>其中 <code>c.readRequest()</code> 核心逻辑是：</p>
<pre><code class="language-go">func (c *conn) readRequest(ctx context.Context) (w *response, err error) {
	req, err := readRequest(c.bufr)
	if err != nil {
		return nil, err
	}

  // ... 一些请求检验和请求头的检查设置

	w = &amp;response{
		conn:          c,
		cancelCtx:     cancelCtx,
		req:           req, 			// 请求元数据
		reqBody:       req.Body,  // 请求体
		handlerHeader: make(Header),
		contentLength: -1,
		closeNotifyCh: make(chan bool, 1),
		wants10KeepAlive: req.wantsHttp10KeepAlive(),
		wantsClose:       req.wantsClose(),
	}
	w.cw.res = w
	w.w = newBufioWriterSize(&amp;w.cw, bufferBeforeChunkingSize)
	return w, nil
}
</code></pre>
<h3>写回响应</h3>
<p>其中 <code>c.finishRequest()</code> 核心逻辑是：</p>
<pre><code class="language-go">func (w *response) finishRequest() {
  // 标记处理完毕
	w.handlerDone.Store(true)

  // 写 HTTP Status OK
	if !w.wroteHeader {
		w.WriteHeader(StatusOK)
	}

  // 将响应数据全部写入缓冲区，并回收缓冲区，后续复用
	w.w.Flush()
	putBufioWriter(w.w)
	w.cw.close()
	w.conn.bufw.Flush()

  // 清空请求体相关数据，用于后续请求复用
	w.conn.r.abortPendingRead()
	w.reqBody.Close()
	if w.req.MultipartForm != nil {
		w.req.MultipartForm.RemoveAll()
	}
}
</code></pre>
<h2>上下文创建 &amp; 回收 sync.Pool</h2>
<p>现在我们进入 <code>ServeHTTP(w, req)</code> 执行具体的 HTTP 处理业务，这里会先为每一个请求创建上下文，然后再进行请求处理。</p>
<pre><code class="language-go">func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
	handler := sh.srv.Handler
	handler.ServeHTTP(rw, req)
}

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	// 从 sync.Pool 对象池中获取一个 gin.Context
  c := engine.pool.Get().(*Context)

  // 重置 gin.Context，使其与当前 request 绑定
	c.writermem.reset(w)
	c.Request = req
	c.reset()

  // 处理请求
	engine.handleHTTPRequest(c)

  // 将 gin.Context 返回 sync.Pool
	engine.pool.Put(c)
}
</code></pre>
<p>可以看到这里使用了 <code>sync.Pool</code> 对象池来管理 <code>gin.Context</code> 对象，通过复用对象来避免重复创建和销毁带来的额外开销。</p>
<h2>路由匹配 sh.ServeHTTP</h2>
<p>核心处理逻辑是 <code>engine.handleHTTPRequest(c)</code>：</p>
<pre><code class="language-go">func (engine *Engine) handleHTTPRequest(c *Context) {
	httpMethod := c.Request.Method
	rPath := c.Request.URL.Path

  // 消除请求路径中的重复斜杠，比如 /hello//user 会处理为 /hello/user
  // 这里在不同的版本默认策略是不一样的，在 1.5.0 版本是默认开启的，在 1.10.0 版本是关闭的！
	if engine.RemoveExtraSlash {
		rPath = cleanPath(rPath)
	}

	// 寻找请求路由对应的处理器，并执行。
	t := engine.trees
	for i, tl := 0, len(t); i &lt; tl; i++ {
		// 将下文详细分析
		break
	}

  // 如果设置了 HandleMethodNotAllowed，则会在找不到对应路由的情况下，
  // 尝试在 Allow Header 中返回相同路由，但是不同 HTTP Method 的处理器。
	if engine.HandleMethodNotAllowed {
		allowed := make([]string, 0, len(t)-1)
		for _, tree := range engine.trees {
			if tree.method == httpMethod {
				continue
			}
			if value := tree.root.getValue(rPath, nil, c.skippedNodes, unescape); value.handlers != nil {
				allowed = append(allowed, tree.method)
			}
		}
		if len(allowed) &gt; 0 {
			c.handlers = engine.allNoMethod
			c.writermem.Header().Set(&quot;Allow&quot;, strings.Join(allowed, &quot;, &quot;))
			serveError(c, http.StatusMethodNotAllowed, default405Body)
			return
		}
	}

  // 找不到对应的路由，返回 404
	c.handlers = engine.allNoRoute
	serveError(c, http.StatusNotFound, default404Body)
}
</code></pre>
<h3>路由树结构 methodTrees</h3>
<p>这里我们重点来看一下 Gin 的路由树是怎样的，先看一下数据结构：</p>
<pre><code class="language-go">// Gin Engine
type Engine struct {
	// ...
	trees            methodTrees
}

// 方法路由树
type methodTree struct {
	method string
	root   *node
}

// 路由树节点
type node struct {
	path      string
	indices   string
	wildChild bool
	nType     nodeType
	priority  uint32
	children  []*node // child nodes, at most 1 :param style node at the end of the array
	handlers  HandlersChain
	fullPath  string
}

// 方法路由树列表
type methodTrees []methodTree

// 请求处理函数
func (engine *Engine) handleHTTPRequest(c *Context) {
  // ...

  t := engine.trees
  for i, tl := 0, len(t); i &lt; tl; i++ {
    if t[i].method != httpMethod {
      continue
    }
    root := t[i].root
    value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
    // ...
    break
  }

  // ...
}
</code></pre>
<p>如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250630195928403.png" alt="Gin 路由树结构示意图"></p>
<h3>路由树节点 node</h3>
<p>这里我们重点解释下 <code>node</code> 结果中的字段含义，这对后续的路由查找分析非常重要：</p>
<pre><code class="language-go">type node struct {
	path      string    // 当前节点所代表的 URL 路径片段
	indices   string    // 子节点的索引，用于快速查找
	wildChild bool      // 标志位，表示是否存在通配符子节点（:param 或 *catchall）
	nType     nodeType  // 节点的类型（静态、参数、通配符等）
	priority  uint32    // 节点的优先级，用于路由注册时的排序
	children  []*node   // 子节点列表
	handlers  HandlersChain // 匹配该节点路径时，需要执行的处理函数链（包含中间件和主 handler）
	fullPath  string    // 完整的路由注册路径
}
</code></pre>
<h4>1. path string</h4>
<ul>
<li><strong>含义</strong>：这个字段存储了当前节点所代表的 <strong>URL 路径片段</strong> 或 <strong>公共前缀</strong>。它不是完整的 URL 路径，而是树中一个分支的字符串。基数树会尽可能地将多个路由的公共前缀合并到一个 <code>path</code> 中以节省空间。</li>
<li>举例：假设你注册了两个路由：/user/profile 和 /user/settings。那么可能会有一个父节点的 path 是 /user/，然后它有两个子节点，一个 path 是 profile，另一个是 settings。</li>
</ul>
<h4>2. indices string</h4>
<ul>
<li><p><strong>含义</strong>：这是一个非常巧妙的性能优化字段。它是一个字符串，其中每个字符都是对应 <code>children</code> 切片中子节点的 <code>path</code> 的 <strong>第一个字符</strong>。它的作用是作为 <code>children</code> 的一个快速查找索引。当需要寻找下一个节点时，程序只需用请求路径的下一个字符来和 <code>indices</code> 进行匹配，就能立刻知道应该访问 <code>children</code> 中的哪个元素，而无需遍历整个 <code>children</code> 切片。</p>
</li>
<li><p>举例：一个父节点 n 的 path 是 /。它有三个子节点，path 分别是 articles、blog 和 contact。</p>
<ul>
<li><p><code>n.children[0].path</code> = &quot;articles&quot;</p>
</li>
<li><p><code>n.children[1].path</code> = &quot;blog&quot;</p>
</li>
<li><p><code>n.children[2].path</code> = &quot;contact&quot;</p>
<p>那么，n.indices 的值就会是 &quot;abc&quot;。</p>
<p>当一个请求 /blog/test 到来时，程序匹配完父节点的 / 后，看到下一个字符是 b，它直接在 indices (&quot;abc&quot;) 中找到 b 是第二个字符，于是就直接去访问 children[1]，非常高效。</p>
</li>
</ul>
</li>
</ul>
<h4>3. wildChild bool</h4>
<ul>
<li><strong>含义</strong>：一个布尔标志位。如果为 <code>true</code>，表示这个节点的子节点中 <strong>存在一个通配符节点</strong>（即 <code>:param</code> 或 <code>*catchall</code> 类型的节点）。</li>
<li><strong>作用</strong>：这同样是一个性能优化。在路由查找时，如果静态子节点（通过 <code>indices</code>）没有匹配上，程序只需检查 <code>wildChild</code> 这一个布尔值，就能快速知道是否需要进一步尝试匹配通配符子节点，避免了额外的条件判断。根据约定，通配符子节点永远是 <code>children</code> 数组的最后一个元素。</li>
</ul>
<h4>4. nType nodeType</h4>
<ul>
<li><strong>含义</strong>：表示当前节点的类型。<code>nodeType</code> 是一个整数类型，通常有以下几种值：<ul>
<li><code>static</code> (静态)：节点的 <code>path</code> 是一个固定的字符串，例如 <code>/about</code>。</li>
<li><code>root</code> (根)：整棵树的根节点。</li>
<li><code>param</code> (参数)：表示一个命名参数，例如 <code>:id</code>。路径 <code>/users/:id</code> 中的 <code>:id</code> 部分就是一个 <code>param</code> 类型的节点。</li>
<li><code>catchAll</code> (通配符)：表示一个“全匹配”参数，例如 <code>*filepath</code>。路径 <code>/static/*filepath</code> 中的 <code>*filepath</code> 就是一个 <code>catchAll</code> 类型的节点。</li>
</ul>
</li>
<li><strong>作用</strong>：在路由查找时，<code>getValue</code> 函数通过 <code>switch n.nType</code> 来决定如何处理当前节点和剩余的请求路径。例如，遇到 <code>param</code> 类型就要提取参数值，遇到 <code>catchAll</code> 就要捕获所有剩余路径。</li>
</ul>
<h4>5. priority uint32</h4>
<ul>
<li><strong>含义</strong>：节点的优先级。这个值在 <strong>构建路由树</strong> 的时候使用，而不是在请求时查找时使用。</li>
<li><strong>作用</strong>：它的值是根据注册到这个节点的路由数量以及其子孙节点的路由数量计算出来的。当插入新路由可能导致树结构冲突时，<code>priority</code> 可以帮助算法决定如何拆分和重组节点，以保持树的正确性和高效性。简单来说，它代表了一个节点的&quot;权重&quot;或&quot;繁忙程度&quot;。</li>
</ul>
<h4>6. children []*node</h4>
<ul>
<li><strong>含义</strong>：一个 <code>*node</code> 指针的切片，存储了所有直接的子节点。这是构成树状结构的核心字段。</li>
<li><strong>规则</strong>：这个切片有一个重要规则：如果存在通配符子节点（<code>:param</code> 或 <code>*catchall</code>），它 <strong>必须并且只能是切片中的最后一个元素</strong>。静态子节点（<code>static</code>）则排在前面，它们的顺序与 <code>indices</code> 字符串中字符的顺序一一对应。</li>
</ul>
<h4>7. handlers HandlersChain</h4>
<ul>
<li><strong>含义</strong>：<code>HandlersChain</code> 本质上是一个 <code>[]HandlerFunc</code>，也就是一个处理函数的切片。</li>
<li><strong>作用</strong>：这是路由查找的最终目标。当一个请求的 URL 完整匹配到某个节点时，这个节点的 <code>handlers</code> 字段就包含了需要被执行的所有函数，这其中可能包括多个中间件（Middleware）和最终处理业务逻辑的那个主函数（Handler）。如果一个节点的 <code>handlers</code> 为 <code>nil</code>，说明它只是一个中间路径节点，不能直接处理请求。</li>
</ul>
<h4>8. fullPath</h4>
<ul>
<li><p><strong>含义</strong>：存储了用户在代码中定义的 <strong>完整的、原始的路由注册字符串</strong>。</p>
</li>
<li><p><strong>作用</strong>：</p>
<ol>
<li><strong>调试与日志</strong>：在中间件或日志系统中，你可以通过 <code>c.FullPath()</code> （它读取的就是这个值）获知当前请求匹配到的是哪条原始路由规则，这对于监控和问题排查非常有用。</li>
<li><strong>模板渲染或 URL 生成</strong>：在某些场景下，你可能需要根据路由名称或模式来生成 URL，<code>fullPath</code> 提供了这个原始模式。</li>
</ol>
</li>
<li><p>举例：</p>
<p>你注册了 router.GET(&quot;/users/:id/profile&quot;, ...).</p>
<p>这会被拆分成多个 node。假设最终匹配到 profile 那个 node：</p>
<ul>
<li>它的 <code>path</code> 可能是 <code>&quot;profile&quot;</code>。</li>
<li>但它的 <code>fullPath</code> 会是 <code>&quot;/users/:id/profile&quot;</code>。</li>
</ul>
</li>
</ul>
<h4>总结</h4>
<p>这 8 个字段协同工作，共同构建了一个既节省内存又查找飞快的路由树。</p>
<ul>
<li><code>path</code>, <code>children</code>, <code>nType</code> 定义了树的 <strong>基本结构和逻辑</strong>。</li>
<li><code>indices</code> 和 <code>wildChild</code> 是为了 <strong>极致性能</strong> 而设计的巧妙索引。</li>
<li><code>handlers</code> 和 <code>fullPath</code> 存储了路由的 <strong>最终目标和元数据</strong>。</li>
<li><code>priority</code> 则在幕后默默地保证了这棵树在动态构建过程中的 <strong>稳定性和合理性</strong>。</li>
</ul>
<h3>路由定位 root.GetValue</h3>
<p>接下来我们来看 <code>root.GetValue()</code> 具体是如何定位路由处理器的，这个方法非常长，我们逐一分解。</p>
<p>好的，我们来一起深入解析一下 Gin 框架中这个核心的路由查找函数 <code>(n *node) getValue</code>。</p>
<p>这是一个非常精妙的函数，它的背后是高性能路由技术的典型实现。为了真正理解它，我们需要遵循“由表及里、由浅入深”的原则，从它的目标、使用的数据结构，再到具体的代码执行逻辑，一步步进行剖析。</p>
<h4>第 1 步：前缀匹配</h4>
<pre><code class="language-go">prefix := n.path
if len(path) &gt; len(prefix) {
    if path[:len(prefix)] == prefix {
        path = path[len(prefix):]
        // ... 继续寻找子节点
</code></pre>
<p>这是基数树最基本的操作。代码首先检查当前请求路径 <code>path</code> 是否以当前节点 <code>n</code> 的路径 <code>n.path</code> 为前缀。</p>
<ul>
<li><strong>如果匹配</strong>：说明路径的前半部分对了，然后从 <code>path</code> 中“砍掉”已经匹配上的前缀，准备在子节点中继续匹配剩余的 <code>path</code>。</li>
<li><strong>如果不匹配</strong>：说明走错路了，需要回溯或直接返回未找到。</li>
</ul>
<h4>第 2 步：在子节点中选择&quot;道路&quot; (静态路由)</h4>
<pre><code class="language-go">idxc := path[0]
for i, c := range []byte(n.indices) {
    if c == idxc {
        n = n.children[i]
        continue walk
    }
}
</code></pre>
<p>在砍掉前缀后，<code>path</code> 是剩余的待匹配路径。<code>idxc := path[0]</code> 取出剩余路径的第一个字符。然后，代码遍历 <code>n.indices</code> 这个“索引目录”。</p>
<ul>
<li><code>n.indices</code> 存储了所有子节点的路径的第一个字符。</li>
<li>如果 <code>idxc</code> 在 <code>n.indices</code> 中找到了匹配项 <code>c</code>，就意味着存在一个正确的子节点可以继续走下去。</li>
<li><code>n = n.children[i]</code> 将当前节点 <code>n</code> 更新为找到的子节点。</li>
<li><code>continue walk</code> 跳回到 <code>walk</code> 循环的开始，在新节点上重复 <strong>第 1 步</strong> 的前缀匹配。</li>
</ul>
<p>这个设计非常高效，因为它避免了对 <code>children</code> 切片的完整遍历，而是通过一个字符的比较就快速定位了下一个节点。</p>
<h4>第 3 步：处理&quot;岔路口&quot; - 通配符 (Wildcard)</h4>
<p>如果静态路由没找到（<code>for</code> 循环结束），程序会检查是否存在通配符子节点。</p>
<pre><code class="language-go">if !n.wildChild {
    // ... 没有通配符子节点，处理找不到的情况
    return value
}

// Handle wildcard child, which is always at the end of the array
n = n.children[len(n.children)-1]

switch n.nType {
	case param:
	case catchAll:
}
</code></pre>
<p><code>n.wildChild</code> 是一个布尔值，表示当前节点是否有一个通配符子节点（<code>:param</code> 或 <code>*catchall</code>）。按照约定，通配符子节点永远是 <code>children</code> 数组的最后一个元素。如果存在，就直接跳到这个通配符节点继续匹配。</p>
<p>接着，<code>switch n.nType</code> 根据通配符节点的类型进行处理：</p>
<ul>
<li><strong><code>case param</code> (例如 <code>/users/:id</code>)</strong>:<ul>
<li>它会从剩余的 <code>path</code> 中&quot;截取&quot;出参数值。截取的规则是到下一个 <code>/</code> 或者路径末尾。</li>
<li>例如，如果 <code>path</code> 是 <code>123/profile</code>，它会截取出 <code>123</code> 作为参数值。</li>
<li>然后将参数的键（如 <code>id</code>）和值（如 <code>123</code>）存入 <code>params</code>。</li>
<li>如果 <code>/</code> 后面还有路径（如 <code>profile</code>），则继续在当前参数节点的子节点中进行 <code>walk</code>。</li>
</ul>
</li>
<li><strong><code>case catchAll</code> (例如 <code>/static/*filepath</code>)</strong>:<ul>
<li>这就更简单了，它会把 <strong>所有</strong> 剩余的 <code>path</code> 都作为参数值。</li>
<li>例如，如果 <code>path</code> 是 <code>css/main.css</code>，整个字符串都会被捕获。</li>
<li><code>catchAll</code> 节点一定是路径的终点，找到后直接返回结果。</li>
</ul>
</li>
</ul>
<h4>第 4 步：到达终点与&quot;没路了&quot;的处理</h4>
<pre><code class="language-go">if path == prefix {
  	// ...
  	return value
}
</code></pre>
<p>这里说明已经找到了&quot;终点&quot;，进行最后的一系列检查。</p>
<p>第一种情况：到达真终点。</p>
<pre><code class="language-go">// We should have reached the node containing the handle.
// Check if this node has a handle registered.
if value.handlers = n.handlers; value.handlers != nil {
    value.fullPath = n.fullPath
    return value
}
</code></pre>
<p>这是最完美的情况！我们找到了一个与请求路径完全匹配的节点，并且这个节点上确实注册了至少一个处理函数，这里我们设置好 fullPath 属性然后就可以直接返回了。</p>
<p>第二种情况：到达假终点。</p>
<pre><code class="language-go">// If the current path does not equal &#39;/&#39; and the node does not have a registered handle and the most recently matched node has a child node
// the current node needs to roll back to last valid skippedNode
if n.handlers == nil &amp;&amp; path != &quot;/&quot; {
  	// skippedNodes 记录了所有我们路过的、存在&quot;岔路口&quot;（即有其他路径可选）的节点。
    for length := len(*skippedNodes); length &gt; 0; length-- {  // 从后往前遍历
        skippedNode := (*skippedNodes)[length-1] // 取出最近的一个岔路口
        *skippedNodes = (*skippedNodes)[:length-1] // 将其从&quot;待办列表&quot;中移除。
        if strings.HasSuffix(skippedNode.path, path) {  // 判断这个岔路口的完整路径是否以我们当前这个&quot;死胡同&quot;路径结尾。
          path = skippedNode.path  // 如果检查通过，就意味着我们找到了一个可以&quot;复活&quot;的存档点
          n = skippedNode.node  // 滚到当时路过那个岔路口的状态
          if value.params != nil {
            *value.params = (*value.params)[:skippedNode.paramsCount]
          }
          globalParamsCount = skippedNode.paramsCount
          continue walk // 读档后，选择另一条路重新开始走
        }
      	// 检查不通过，说明没有&quot;后悔药&quot;可以吃了，查找失败。
    }
}
</code></pre>
<p>我们到达了节点 <code>n</code>，但这个节点的 <code>handlers</code> 是 <strong><code>nil</code></strong>！并且，为了避免对根路径 <code>/</code> 的误判，加了 <code>path != &quot;/&quot;</code> 的条件。</p>
<p>这意味着我们走到了一个&quot;死胡同&quot;或者说一个&quot;假终点&quot;。路径虽然匹配了，但这只是一个中间节点（例如 <code>/users</code>），它本身不能处理请求，真正的终点在它的子节点上（例如 <code>/users/list</code> 或 <code>/users/:id</code>）。但我们的请求路径已经用完了，无法再往下走了。</p>
<blockquote>
<p><strong>为什么会发生这种情况？</strong> 这通常发生在有路由冲突或歧义时，路由器“贪婪地”选择了一条看似正确但实际上是死胡同的路。</p>
<p>试想一下情况：</p>
<ol>
<li>注册路由 A: <code>/users/new</code> (静态)</li>
<li>注册路由 B: <code>/users/:id</code> (动态)</li>
<li>用户请求: <code>GET /users/new</code></li>
</ol>
<p>路由器在匹配完 <code>/users/</code> 后，剩下 <code>new</code>。此时它面临一个选择：是匹配静态的 <code>new</code> 节点，还是匹配动态的 <code>:id</code> 节点？虽然 Gin 会优先匹配静态节点，但我们可以设想一个场景：如果 <code>/users/new</code> 这个路由<strong>没有注册 handler</strong>（开发者忘了写），而 <code>/users/:id</code> 注册了。</p>
<p>当请求 <code>/users/new</code> 时，它会先走到 <code>new</code> 节点。发现 <code>handlers</code> 是 <code>nil</code>，于是就进入了这个回溯逻辑。</p>
</blockquote>
<h4><strong>第 5 步：智能建议 - TSR (Trailing Slash Redirect)</strong></h4>
<pre><code class="language-go">// We can recommend to redirect to the same URL without a
// trailing slash if a leaf exists for that path.
value.tsr = path == &quot;/&quot; &amp;&amp; n.handlers != nil
</code></pre>
<p><code>tsr</code> 是 Gin 的一个非常人性化的功能。</p>
<ul>
<li><strong>场景 1</strong>: 你注册了 <code>/users</code>，但用户请求了 <code>/users/</code>。</li>
<li><strong>场景 2</strong>: 你注册了 <code>/users/</code>，但用户请求了 <code>/users</code>。</li>
</ul>
<p>在这两种情况下，Gin 不会直接返回 <code>404 Not Found</code>。<code>getValue</code> 函数在发现“几乎”匹配（就差一个尾部斜杠）时，会将 <code>value.tsr</code> 设置为 <code>true</code>。上层逻辑接收到这个 <code>true</code> 信号后，就会向客户端返回一个 <code>301</code> 或 <code>307</code> 重定向建议，告诉浏览器应该访问另一个带或不带斜杠的 URL。这提升了用户体验。</p>
<h4><strong>第 6 步：回溯 (Backtracking)</strong></h4>
<p>这是函数中最复杂，但也最能体现其强大的部分。这里的逻辑跟第 4 步中到达&quot;假终点&quot;大致是相同的。</p>
<pre><code class="language-go">// the current node needs to roll back to last valid skippedNode
for length := len(*skippedNodes); length &gt; 0; length-- {
    skippedNode := (*skippedNodes)[length-1]
    *skippedNodes = (*skippedNodes)[:length-1]
    if strings.HasSuffix(skippedNode.path, path) {
        // ... 回滚状态，重新 walk
        continue walk
    }
}
</code></pre>
<p>同样是考虑以下路由：</p>
<ol>
<li><code>/users/:id</code></li>
<li><code>/users/new</code></li>
</ol>
<p>我们假设另外一种情况，当一个请求 <code>GET /users/new</code> 到来时：</p>
<ol>
<li>它首先匹配到 <code>/users/</code> 前缀。</li>
<li>剩下的路径是 <code>new</code>。此时，它既可能匹配静态的 <code>new</code>，也可能匹配参数 <code>:id</code>。</li>
<li>大多数路由器的实现会优先匹配静态路径。但<strong>如果 <code>getValue</code> 先进入了 <code>:id</code> 的分支</strong>，它会把 <code>new</code> 当作 <code>:id</code> 的值。如果 <code>:id</code> 节点下没有更多子路径，查找就会失败。</li>
<li>这时，就需要回溯。<code>skippedNodes</code> 记录了&quot;上一个有其他选择的路口&quot;（例如，那个同时存在静态子节点和通配符子节点的 <code>/users/</code> 节点）。</li>
<li>代码会回退到那个路口，并尝试另一条路（即匹配 <code>new</code> 静态路径），最终找到正确的 <code>handlers</code>。</li>
</ol>
<p>这个机制确保了 <strong>静态路由的优先级总是高于通配符路由</strong>，即使它们的路径结构很相似。</p>
<h4>总结</h4>
<p><code>Gin</code> 的 <code>(n *node) getValue</code> 函数是一个基于基数树 (Radix Tree) 的、高度优化的路由查找实现。它的执行过程可以概括为：</p>
<ol>
<li><strong>循路前进</strong>：沿着基数树，通过前缀匹配 (<code>n.path</code>) 和索引查找 (<code>n.indices</code>)，快速匹配 URL 的静态部分。</li>
<li><strong>灵活应变</strong>：当遇到通配符节点 (<code>:param</code> 或 <code>*catchall</code>) 时，能正确解析路径参数。</li>
<li><strong>终点判断</strong>：当路径完全匹配时，检查当前节点是否有 <code>handlers</code>，有则成功返回。</li>
<li><strong>智能容错</strong>：当精确匹配失败，但存在仅差一个尾部斜杠的路由时，会给出重定向建议 (TSR)。</li>
<li><strong>迷途知返</strong>：通过 <code>skippedNodes</code> 机制实现回溯，确保在有多种可能匹配路径（静态 vs 通配符）时，能够做出正确的选择，保证路由匹配的准确性。</li>
</ol>
<p>通过这些精巧的设计，Gin 在保证强大功能的同时，实现了极高的路由性能。</p>
<p>我画了个流程图，供你参考：</p>
<pre><code class="language-mermaid">graph TD
    subgraph MainProcess [主流程]
        direction TB

        A[&quot;getValue(path, ...)&quot;]:::startend

        %% Stage 1: Traversal Loop
        subgraph TraversalPhase [&quot;第一阶段：遍历深入 (WALK 循环)&quot;]
            direction TB
            W[&quot;&lt;b&gt;循环开始&lt;/b&gt;&lt;br&gt;在当前节点 n&quot;]:::process
            C1{&quot;路径前缀匹配 且 路径有剩余?&quot;}:::decision
            P1[&quot;削减已匹配路径&quot;]:::process
            C2{&quot;匹配静态子节点?&quot;}:::decision
            P2[&quot;记录回溯点(skippedNodes)&lt;br&gt;n = &lt;b&gt;进入静态子节点&lt;/b&gt;&quot;]:::process
            C3{&quot;有通配符子节点?&quot;}:::decision
            P3[&quot;n = &lt;b&gt;进入通配符子节点&lt;/b&gt;&quot;]:::process
            C4{&quot;节点类型是 :param?&quot;}:::decision
            P4[&quot;处理 :param, 截取并保存参数&quot;]:::process
            C5{&quot;参数后还有剩余路径?&quot;}:::decision
        end

        %% Stage 2: Final Adjudication
        Junction[&quot;&lt;br&gt;无法继续深入&lt;br&gt;&lt;b&gt;转到最终裁决&lt;/b&gt;&lt;br&gt;&quot;]:::decision
        subgraph FinalAdjudication [第二阶段：最终裁决]
            direction TB
            C_FINAL_BACKTRACK{&quot;需要回溯?&lt;br&gt;(当前节点无 handler)&quot;}:::decision
            P_FINAL_BACKTRACK[&quot;&lt;b&gt;执行回溯&lt;/b&gt;&lt;br&gt;遍历 skippedNodes 查找备用路径&lt;br&gt;若找到, 则恢复现场&quot;]:::process
            C_FINAL_HANDLER{&quot;找到 handler?&lt;br&gt;(n.handlers != nil)&quot;}:::decision
            SUCCESS[&quot;&lt;b&gt;成功&lt;/b&gt;&lt;br&gt;返回 value (含 handlers)&quot;]:::startend
            P_FINAL_TSR[&quot;&lt;b&gt;TSR 检查&lt;/b&gt;&lt;br&gt;检查是否存在 +/- 斜杠的“近亲”路由&quot;]:::process
            FINAL_RETURN[&quot;返回 value&lt;br&gt;(可能含 TSR, 或为空)&quot;]:::startend
        end

        B4_CatchAll[&quot;&lt;b&gt;处理 *catchAll&lt;/b&gt;&lt;br&gt;截取所有剩余路径&lt;br&gt;保存参数, 赋值 handlers&lt;br&gt;&lt;b&gt;直接成功返回&lt;/b&gt;&quot;]:::startend

        %% --- Connections (Corrected Syntax) ---
        A --&gt; W
        W --&gt; C1

        %% Traversal Logic
        C1 -- &quot;是(Yes)&quot; --&gt; P1
        P1 --&gt; C2
        C2 -- &quot;是(Yes)&quot; --&gt; P2
        P2 -- &quot;进入下一轮&quot; --&gt; W
        C2 -- &quot;否(No)&quot; --&gt; C3
        C3 -- &quot;是(Yes)&quot; --&gt; P3
        P3 --&gt; C4
        C4 -- &quot;是(Yes)&quot; --&gt; P4
        P4 --&gt; C5
        C5 -- &quot;是(Yes)&quot; --&gt; W
        C4 -- &quot;否(No), 是 *catchAll&quot; --&gt; B4_CatchAll

        %% Exits from Traversal to Adjudication
        C1 -- &quot;否(No)&quot; --&gt; Junction
        C3 -- &quot;否(No): 无路可走&quot; --&gt; Junction
        C5 -- &quot;否(No): 路径耗尽&quot; --&gt; Junction

        %% Adjudication Logic
        Junction --&gt; C_FINAL_BACKTRACK
        C_FINAL_BACKTRACK -- &quot;是(Yes)&quot; --&gt; P_FINAL_BACKTRACK
        P_FINAL_BACKTRACK -- &quot;回到循环&quot; --&gt; W
        C_FINAL_BACKTRACK -- &quot;否(No): 无需回溯&quot; --&gt; C_FINAL_HANDLER

        C_FINAL_HANDLER -- &quot;是(Yes)&quot; --&gt; SUCCESS
        C_FINAL_HANDLER -- &quot;否(No)&quot; --&gt; P_FINAL_TSR
        P_FINAL_TSR --&gt; FINAL_RETURN

    end

    %% Styling
    classDef startend fill:#9f9,stroke:#333,stroke-width:2px,color:#000
    classDef panic fill:#f99,stroke:#333,stroke-width:2px,color:#000
    classDef decision fill:#ffc,stroke:#333,stroke-width:2px,color:#000
    classDef process fill:#9cf,stroke:#333,stroke-width:2px,color:#000
</code></pre>
<h2>中间件执行 c.Next</h2>
<h3>执行机制</h3>
<p>经过路由匹配，我们找到了处理当前请求的节点，返回的 <code>value</code> 结构如下：</p>
<pre><code class="language-go">type nodeValue struct {
	handlers HandlersChain
	params   *Params
	tsr      bool
	fullPath string
}
</code></pre>
<p>其中处理函数就是 <code>handlers</code>，它是类型的 <code>HandlersChain</code>，其实就是 <code>[]HandleFunc</code>：</p>
<pre><code class="language-go">// HandlersChain defines a HandlerFunc slice.
type HandlersChain []HandlerFunc

// HandlerFunc defines the handler used by gin middleware as return value.
type HandlerFunc func(*Context)
</code></pre>
<p>在 <code>engine.handleHTTPRequest()</code> 中，找到了处理节点后，执行了下面 4 行代码，其中核心就是 <code>c.Next()</code>：</p>
<pre><code class="language-go">c.handlers = value.handlers
c.fullPath = value.fullPath
c.Next()
c.writermem.WriteHeaderNow()
</code></pre>
<p>我们来看一下 <code>c.Next()</code>：</p>
<pre><code class="language-go">type Context struct {
	// ...
	handlers HandlersChain // 请求链路
	index    int8          // 当前处理的 HandleFunc 在 handlers 中的索引
  // ...
}

func (c *Context) Next() {
	c.index++
	for c.index &lt; int8(len(c.handlers)) {
		c.handlers[c.index](c)
		c.index++
	}
}

func (c *Context) reset() {
	// ...
	c.handlers = nil
  c.index = -1  // 这里初始值是 -1，因为第一次执行 Next() 的时候，会 c.index++
	// ...
}
</code></pre>
<p>它的逻辑其实简单，就是通过递增 <code>index</code> 依次执行 <code>HandlersChain</code>。</p>
<p>我们顺带看一下 <code>c.Abort()</code>，真是聪明！将 index 设置为 abortIndex，这样后面的 handler 就执行不到了！</p>
<pre><code class="language-go">func (c *Context) Abort() {
	c.index = abortIndex
}
</code></pre>
<h3>接口实现</h3>
<pre><code class="language-go">// IRouter defines all router handle interface includes single and group router.
type IRouter interface {
	IRoutes
	Group(string, ...HandlerFunc) *RouterGroup
}

// IRoutes defines all router handle interface.
type IRoutes interface {
	Use(...HandlerFunc) IRoutes
	Handle(string, string, ...HandlerFunc) IRoutes
	Any(string, ...HandlerFunc) IRoutes
	GET(string, ...HandlerFunc) IRoutes
	POST(string, ...HandlerFunc) IRoutes
	DELETE(string, ...HandlerFunc) IRoutes
	PATCH(string, ...HandlerFunc) IRoutes
	PUT(string, ...HandlerFunc) IRoutes
	OPTIONS(string, ...HandlerFunc) IRoutes
	HEAD(string, ...HandlerFunc) IRoutes
	Match([]string, string, ...HandlerFunc) IRoutes
	StaticFile(string, string) IRoutes
	StaticFileFS(string, string, http.FileSystem) IRoutes
	Static(string, string) IRoutes
	StaticFS(string, http.FileSystem) IRoutes
}
</code></pre>
<p>注册路由的核心接口是 <code>IRouter</code>，并且为 <code>Engine</code> 和 <code>RouterGroup</code> 实现了 <code>IRouter</code> 接口：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250630222405298.png" alt="Gin IRouter 接口及其实现"></p>
<pre><code class="language-go">type Engine struct {
	RouterGroup
  ...
}

// RouterGroup is used internally to configure router, a RouterGroup is associated with
// a prefix and an array of handlers (middleware).
type RouterGroup struct {
	Handlers HandlersChain
	basePath string
	engine   *Engine
	root     bool
}
</code></pre>
<p>查看源码后我们发现，其实真正实现 <code>IRouter</code> 接口的，只有 <code>RouterGroup</code>！然后在 <code>Engine</code> 中组合 <code>RouterGroup</code>，这样我们既可以直接在 <code>Engine</code> 上（根路径）注册路由，需要注意的是，<code>IRouter</code> 中有一个方法：</p>
<pre><code class="language-go">Group(string, ...HandlerFunc) *RouterGroup
</code></pre>
<p>这样就巧妙地实现了<strong>分组路由</strong>和<strong>递归分组路由</strong>的功能！</p>
<p>我们先来看看 <code>Group()</code> 的实现：</p>
<pre><code class="language-go">func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) *RouterGroup {
	return &amp;RouterGroup{
		Handlers: group.combineHandlers(handlers),  // 组合当前 group 和 handlers（深拷贝），并返回新的 handlers 列表
		basePath: group.calculateAbsolutePath(relativePath), // 合并路径
		engine:   group.engine,
	}
}

// 一个接口最多支持 127-1 个处理器
const abortIndex int8 = math.MaxInt8 &gt;&gt; 1

func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
	finalSize := len(group.Handlers) + len(handlers)
	assert1(finalSize &lt; int(abortIndex), &quot;too many handlers&quot;)
	mergedHandlers := make(HandlersChain, finalSize)
	copy(mergedHandlers, group.Handlers)  // 深拷贝当前 group 拥有的 handler
	copy(mergedHandlers[len(group.Handlers):], handlers) // 深拷贝新注册的 handler
	return mergedHandlers
}

func (group *RouterGroup) calculateAbsolutePath(relativePath string) string {
	return joinPaths(group.basePath, relativePath)
}
</code></pre>
<p>再来看看注册路由的具体实现，以 <code>GET()</code> 为例：</p>
<pre><code class="language-go">func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
	return group.handle(http.MethodGet, relativePath, handlers)
}

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
	absolutePath := group.calculateAbsolutePath(relativePath) // 合并路径
	handlers = group.combineHandlers(handlers)  // 组合当前 group 和 handlers（深拷贝），并返回新的 handlers 列表
	group.engine.addRoute(httpMethod, absolutePath, handlers) // 添加路由
	return group.returnObj()
}
</code></pre>
<p>注册逻辑在 <code>engine.addRoute()</code> 中：</p>
<pre><code class="language-go">func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
	// 获取 method 对应的路由树，不存在则创建
  root := engine.trees.get(method)
	if root == nil {
		root = new(node)
		root.fullPath = &quot;/&quot;
		engine.trees = append(engine.trees, methodTree{method: method, root: root})
	}

  // 添加路由
	root.addRoute(path, handlers)
}
</code></pre>
<h3>路由注册</h3>
<p>核心逻辑在 <code>root.addRoute()</code> 中，这个函数的目标是在路由树中为给定的 <code>path</code> 和 <code>handlers</code> 找到一个安身之处，必要时会重塑树的结构。</p>
<p>整个函数的核心是一个 <code>walk</code> 循环，它模拟了从树的根节点开始，一步步向下走，直到找到或创造出新路由位置的过程。接下来我们细细拆解这个过程。</p>
<h4>初始化与空树处理</h4>
<pre><code class="language-go">fullPath := path
n.priority++

// Empty tree
if len(n.path) == 0 &amp;&amp; len(n.children) == 0 {
    n.insertChild(path, fullPath, handlers)
    n.nType = root
    return
}
</code></pre>
<ul>
<li><strong><code>n.priority++</code></strong>: 每当有一个路由需要经过或终止于当前节点 <code>n</code>，这个节点的优先级（权重）就会增加。这反映了该节点在路由树中的&quot;繁忙&quot;程度。</li>
<li><strong>空树判断</strong>: 这是最简单的基础情况。如果当前节点 <code>n</code> 的 <code>path</code> 和 <code>children</code> 都为空，说明这是一棵空树（或者说我们正处于一个未初始化的根节点）。</li>
<li><strong>操作</strong>: 直接调用 <code>n.insertChild(path, fullPath, handlers)</code>，将整条 <code>path</code> 作为第一个孩子插入，并把自己的类型设置为 <code>root</code>。整个添加过程结束。</li>
</ul>
<hr>
<p>如果不是空树，程序就进入 <code>walk</code> 循环，开始真正的&quot;建树之旅&quot;。</p>
<pre><code class="language-go">i := longestCommonPrefix(path, n.path)
</code></pre>
<p>这是循环体内的第一步，也是最关键的一步。它计算了 <strong>要插入的新路径 <code>path</code></strong> 和 <strong>当前节点路径 <code>n.path</code></strong> 之间的最长公共前缀 (Longest Common Prefix) 的长度 <code>i</code>。这个 <code>i</code> 的值，决定了接下来所有的操作。</p>
<blockquote>
<p>要不，刷个 leetcode 放松一下？🤡🤡🤡 <a href="https://leetcode.com/problems/longest-common-prefix/description/">longestCommonPrefix</a></p>
</blockquote>
<h4>场景一：节点分裂 (Split Edge)</h4>
<pre><code class="language-go">// Split edge
if i &lt; len(n.path) {
    // 分裂当前 n，继承之前的 indices, children, handlers 和 wildChild
    child := node{
      path:      n.path[i:],
      wildChild: n.wildChild,
      nType:     static,
      indices:   n.indices,
      children:  n.children,
      handlers:  n.handlers,
      priority:  n.priority - 1,
      fullPath:  n.fullPath,
    }


    n.children = []*node{&amp;child} // 把分裂出来的节点作为当前节点的 child
    n.indices = bytesconv.BytesToString([]byte{n.path[i]}) // 重置 indices，目前只有一个元素，就是分裂出来的节点的第一个字符
    n.path = path[:i]  		// 缩短前缀
    n.handlers = nil   		// 清空 handlers，因为当前节点已经是中间节点了
    n.wildChild = false  	// 清空通配符标识
    n.fullPath = fullPath[:parentFullPathIndex+i] // 重置 fullPath
}
</code></pre>
<ul>
<li><strong>触发条件</strong>: <code>i &lt; len(n.path)</code>。这意味着公共前缀的长度 <code>i</code> 小于当前节点 <code>n</code> 的路径长度。换句话说，新路径和当前节点路径在中间某个位置出现了&quot;分叉&quot;。</li>
<li><strong>经典例子</strong>: 当前节点 <code>n.path</code> 是 <code>&quot;/hello&quot;</code>，要插入的新路径 <code>path</code> 是 <code>&quot;/help&quot;</code>。<ul>
<li>LCP 是 <code>&quot;/hel&quot;</code>，长度 <code>i</code> 为 4。</li>
<li><code>i &lt; len(&quot;/hello&quot;)</code> (4 &lt; 6) 条件成立。</li>
</ul>
</li>
<li>**操作 (这是最精妙的部分): **<ol>
<li><strong>创建新子节点 <code>child</code></strong>:<ul>
<li>这个 <code>child</code> 节点继承了当前节点 <code>n</code> &quot;后半段&quot;的路径，即 <code>n.path[i:]</code> (例子中是 <code>&quot;lo&quot;</code>)。</li>
<li>它也完全继承了 <code>n</code> 之前的所有子节点 (<code>n.children</code>)、<code>handlers</code>、<code>wildChild</code> 状态等。它的 <code>priority</code> 会减 1，因为父节点 <code>n</code> 的 <code>priority</code> 已经加过了。</li>
</ul>
</li>
<li><strong>改造当前节点 <code>n</code></strong>:<ul>
<li>当前节点 <code>n</code> 被&quot;改造&quot;成一个新的、更短的 <strong>父节点/分支节点</strong>。</li>
<li><code>n.path</code> 被截断为公共前缀 <code>path[:i]</code> (例子中是 <code>&quot;/hel&quot;</code>)。</li>
<li><code>n.handlers</code> 被设为 <code>nil</code>，因为它现在只是一个中间节点。</li>
<li><code>n.children</code> 被重置，现在<strong>只包含</strong>刚刚创建的那个 <code>child</code> 节点。</li>
<li><code>n.indices</code> 也被更新，只包含指向新 <code>child</code> 节点的索引字符 (例子中是 <code>&#39;l&#39;</code>)。</li>
</ul>
</li>
</ol>
</li>
<li><strong>结果</strong>: 执行完分裂后，树的结构从 <code>(node path=&quot;/hello&quot;)</code> 变成了 <code>(node path=&quot;/hel&quot;) -&gt; (child path=&quot;lo&quot;)</code>。此时，我们还没有处理新路径 <code>&quot;/help&quot;</code> 剩下的部分 (<code>&quot;p&quot;</code>)。代码会自然地流转到下面的 <code>if i &lt; len(path)</code> 逻辑，将 <code>&quot;p&quot;</code> 作为 <code>/hel</code> 节点的第二个孩子插入。</li>
</ul>
<hr>
<h4>场景二：继续向下走或创建新分支</h4>
<pre><code class="language-go">// Make new node a child of this node
if i &lt; len(path) {
    path = path[i:]
    c := path[0]

    // 处理 /?a=1&amp;b=2 这种情况
    if n.nType == param &amp;&amp; c == &#39;/&#39; &amp;&amp; len(n.children) == 1 {
      parentFullPathIndex += len(n.path)
      n = n.children[0]
      n.priority++
      continue walk
    }

    // 看看能不能找到匹配的静态子节点
    for i, max := 0, len(n.indices); i &lt; max; i++ {
      if c == n.indices[i] {
        parentFullPathIndex += len(n.path)
        i = n.incrementChildPrio(i)
        n = n.children[i]
        continue walk
      }
    }

    // 找不到匹配的静态子节点，且当前节点非通配符节点，则插入一个新的子节点
    if c != &#39;:&#39; &amp;&amp; c != &#39;*&#39; &amp;&amp; n.nType != catchAll {
      // []byte for proper unicode char conversion, see #65
      n.indices += bytesconv.BytesToString([]byte{c})
      child := &amp;node{
        fullPath: fullPath,
      }
      n.addChild(child)
      n.incrementChildPrio(len(n.indices) - 1)
      n = child
    } else if n.wildChild {
      // 如果新的 path 是通配符路径，当前节点 n 也是通配符节点，那需要检查是否有冲突
      // 按照约定，通配符子节点永远是 children 切片的最后一个元素，取出最后一个元素，成为当前的 n
      n = n.children[len(n.children)-1]
      n.priority++

      // 判断通配符是否不冲突，需要满足 3 个条件
      // 1. len(path) &gt;= len(n.path) &amp;&amp; n.path == path[:len(n.path)]
      if len(path) &gt;= len(n.path) &amp;&amp; n.path == path[:len(n.path)] &amp;&amp;
        // Adding a child to a catchAll is not possible
        n.nType != catchAll &amp;&amp;
        // Check for longer wildcard, e.g. :name and :names
        (len(n.path) &gt;= len(path) || path[len(n.path)] == &#39;/&#39;) {
        continue walk
      }

      // Wildcard conflict
      pathSeg := path
      if n.nType != catchAll {
        pathSeg = strings.SplitN(pathSeg, &quot;/&quot;, 2)[0]
      }
      prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.path
      panic(&quot;&#39;&quot; + pathSeg +
        &quot;&#39; in new path &#39;&quot; + fullPath +
        &quot;&#39; conflicts with existing wildcard &#39;&quot; + n.path +
        &quot;&#39; in existing prefix &#39;&quot; + prefix +
        &quot;&#39;&quot;)
    }

  	// 将 fullPath，handlers 赋予处理后的最终的当前节点
    n.insertChild(path, fullPath, handlers)
    return
}
</code></pre>
<ul>
<li><strong>触发条件</strong>: <code>i &lt; len(path)</code>。这意味着在匹配完公共前缀后（或者说，完整匹配了当前节点的 <code>path</code> 后），要插入的新路径 <code>path</code> <strong>还有剩余部分</strong>。</li>
<li><strong>例子</strong>: 当前节点 <code>n.path</code> 是 <code>&quot;/users&quot;</code>，要插入的新路径是 <code>&quot;/users/new&quot;</code>。<ul>
<li>LCP 是 <code>&quot;/users&quot;</code>，长度 <code>i</code> 为 6。<code>i == len(n.path)</code>。</li>
<li><code>i &lt; len(&quot;/users/new&quot;)</code> (6 &lt; 10) 条件成立。</li>
</ul>
</li>
</ul>
<p>具体过程如下：</p>
<ol>
<li><p><code>path = path[i:]</code>: 更新 <code>path</code> 为剩余未处理的部分 (例子中是 <code>&quot;/new&quot;</code>)。</p>
</li>
<li><p><code>c := path[0]</code>: 取出剩余路径的第一个字符 (例子中是 <code>/</code>)。</p>
</li>
<li><p><strong>检查现有子节点</strong>: <code>for i, max := 0, len(n.indices); ...</code></p>
<p>这是最常见的&quot;向下走&quot;逻辑。程序用字符 <code>c</code> 去匹配 <code>n.indices</code>，如果找到了匹配的静态子节点，就增加那个子节点的 <code>priority</code>，然后 <code>n = n.children[i]</code>，将 <code>n</code> 更新为那个子节点，<code>continue walk</code>，从新的 <code>n</code> 开始下一轮循环。</p>
</li>
<li><p><strong>插入新子节点</strong>:</p>
<ul>
<li><p>如果 <code>for</code> 循环没找到匹配的子节点，并且 <code>c</code> 不是通配符 (<code>:</code> 或 <code>*</code>)，程序就会创建一个新的静态子节点，更新 <code>n.indices</code>，并将 <code>n</code> 指向这个新创建的子节点。</p>
</li>
<li><p>如果 <code>c</code> 是通配符，会进入 <code>else if n.wildChild</code> 逻辑。这个逻辑主要是用来<strong>处理通配符冲突</strong>的。例如，你不能在 <code>/:id</code> 之后再添加 <code>/:user</code>。如果存在冲突，程序会 <code>panic</code> 并给出非常清晰的错误信息。如果没有冲突（例如，在 <code>/:id</code> 节点下添加子节点 <code>/profile</code>），则会继续向下 <code>walk</code>。这里检查了 3 个条件：</p>
<ul>
<li><p>条件 ① <code>len(path) &gt;= len(n.path) &amp;&amp; n.path == path[:len(n.path)]</code>：检查<strong>新路径 <code>path</code> 是否以已存在的通配符路径 <code>n.path</code> 开头</strong>。</p>
<ul>
<li><strong>可能兼容</strong>：已存在 <code>:id</code>，新来 <code>:id/profile</code>。<code>&quot;:id/profile&quot;</code> 以 <code>&quot;:id&quot;</code> 开头，通过。</li>
<li><strong>绝对冲突</strong>：已存在 <code>:id</code>，新来 <code>:user</code>。<code>&quot;:user&quot;</code> 并不以 <code>&quot;:id&quot;</code> 开头，检查失败，将直接跳到 <code>panic</code>。这正是我们之前讨论的，<code>/:id</code> 和 <code>/:user</code> 无法共存的逻辑实现。</li>
</ul>
</li>
<li><p>条件 ② <code>n.nType != catchAll</code>：检查<strong>已存在的通配符节点类型不是 <code>catchAll</code> (<code>*</code> 类型)</strong>。</p>
<p><code>catchAll</code> 类型的通配符（例如 <code>/static/*filepath</code>）是终极的，它会匹配所有后续路径。因此，在它后面再添加任何子节点（例如 <code>/static/*filepath/more</code>）都是没有意义的，也是不被允许的。如果已存在的是 <code>catchAll</code>，此条件不满足，将 <code>panic</code>。</p>
</li>
<li><p>条件 ③ <code>(len(n.path) &gt;= len(path) || path[len(n.path)] == &#39;/&#39;)</code>：这是一个非常精妙的检查，用于确保通配符的&quot;边界清晰&quot;，防止部分重叠的歧义命名。它分为两种允许的情况：</p>
<ul>
<li><strong><code>len(n.path) &gt;= len(path)</code></strong>: 新路径和旧通配符路径完全一样（或更短，但由于条件 A，只能是完全一样）。例如，已存在 <code>:id</code>，新来的也是 <code>:id</code>（后面可能要加子节点）。</li>
<li><strong><code>path[len(n.path)] == &#39;/&#39;</code></strong>: 新路径比旧通配符路径更长，并且紧跟着的第一个字符必须是 <code>/</code>。例如，已存在 <code>:id</code>，新来的是 <code>:id/profile</code>，<code>profile</code> 前面必须有 <code>/</code> 分隔。</li>
</ul>
</li>
</ul>
<p>如果所有这三个条件都奇迹般地满足了，说明新路径是现有通配符的一个完全合法的&quot;子路径&quot;或&quot;扩展&quot;。程序就会执行 <code>continue walk</code>，继续愉快地向下走，处理路径剩下的部分。</p>
</li>
</ul>
</li>
<li><p><code>n.insertChild(path, fullPath, handlers)</code>: 这是创建新节点的最终调用，并将 <code>path</code>、<code>fullPath</code> 和 <code>handlers</code>赋予这个新的叶子节点。然后 <code>return</code>，添加过程结束。</p>
</li>
</ol>
<hr>
<h4>场景三：终点命中，添加 Handlers</h4>
<pre><code class="language-go">// Otherwise add handle to current node
if n.handlers != nil {
    panic(&quot;handlers are already registered for path &#39;&quot; + fullPath + &quot;&#39;&quot;)
}
n.handlers = handlers
n.fullPath = fullPath
return
</code></pre>
<ul>
<li><strong>触发条件</strong>: 循环走到了一个节点 <code>n</code>，并且 <code>i == len(n.path)</code> 且 <code>i == len(path)</code>。这意味着要插入的路径和当前节点的路径<strong>完全一样</strong>。</li>
<li><strong>含义</strong>: 找到了一个已经存在的、路径完全匹配的节点。</li>
<li><strong>操作</strong>:<ol>
<li>检查 <code>n.handlers</code> 是否已经存在。如果存在，说明重复注册路由，这是不允许的，程序会 <code>panic</code>。</li>
<li>如果不存在，就将 <code>handlers</code> 和 <code>fullPath</code> 赋予当前节点 <code>n</code>。</li>
<li><code>return</code>，添加过程结束。</li>
</ol>
</li>
</ul>
<h4>总结</h4>
<p><code>addRoute</code> 函数通过一个 <code>walk</code> 循环，非常精妙地处理了向基数树中插入新路由的所有可能情况：</p>
<ol>
<li><strong>从计算 LCP 开始</strong>，判断新路由与当前节点的关系。</li>
<li>如果新路由与现有节点路径<strong>部分重叠</strong>，则<strong>分裂</strong>现有节点，创造出一个新的分支节点。</li>
<li>如果新路由是现有节点路径的<strong>超集</strong>，则<strong>深入</strong>到子节点中，或<strong>创建</strong>新的子节点。</li>
<li>如果新路由与现有节点 路径<strong>完全重合</strong>，则为其<strong>附加</strong>处理函数，或因重复注册而报错。</li>
</ol>
<p>我画了个流程图，供你参考：</p>
<pre><code class="language-mermaid">graph TD
    subgraph MainProcess [主流程]
        direction TB
        %% Node Definitions
        A[&quot;addRoute(path, handlers)&quot;]:::startend
        A1[&quot;n.priority++&quot;]:::process
        C0{&quot;树为空?&quot;}:::decision

        %% Empty Tree Path
        B0[&quot;insertChild(path, ...)&lt;br&gt;n.nType = root&lt;br&gt;return&quot;]:::startend

        %% Main Walk Loop
        W[&quot;WALK 循环开始&quot;]:::process
        W1[&quot;i := longestCommonPrefix(path, n.path)&quot;]:::process
        C1_SplitEdge{&quot;i &lt; len(n.path)?&lt;br&gt;(需要分裂节点?)&quot;}:::decision

        %% Split Edge Logic
        B1[&quot;&lt;b&gt;节点分裂 (Split Edge)&lt;/b&gt;&lt;br&gt;1. 创建 child 继承 n 的后半段路径和属性&lt;br&gt;2. 改造 n 为父节点, path 截为公共前缀&lt;br&gt;3. n 的 children 重置为 [child]&lt;br&gt;4. n.handlers = nil&quot;]:::process

        %% Continue After Split or No Split
        C2_MorePath{&quot;i &lt; len(path)?&lt;br&gt;(新路径还有剩余?)&quot;}:::decision

        %% Path A: No More Path (Exact Match)
        C3_HasHandlers{&quot;n.handlers != nil?&quot;}:::decision
        P1[&quot;panic(&#39;路由重复注册&#39;)&quot;]:::panic
        B2[&quot;n.handlers = handlers&lt;br&gt;n.fullPath = fullPath&lt;br&gt;return&quot;]:::startend

        %% Path B: More Path Left
        W2[&quot;path = path[i:]&lt;br&gt;c := path[0]&quot;]:::process
        C4{&quot;静态子节点匹配成功?&lt;br&gt;(for c in n.indices)&quot;}:::decision
        B3[&quot;n = 匹配的子节点&lt;br&gt;n.priority++&lt;br&gt;&lt;b&gt;continue walk&lt;/b&gt;&quot;]:::process

        %% Wildcard Logic
        C5{&quot;c 是通配符 : 或 * ?&quot;}:::decision
        C5_1{&quot;n 已有通配符子节点?&lt;br&gt;(n.wildChild)&quot;}:::decision
        C6{&quot;兼容性检查通过?&quot;}:::decision
        B4[&quot;n = 已存在的通配符子节点&lt;br&gt;n.priority++&quot;]:::process
        B5[&quot;&lt;b&gt;continue walk&lt;/b&gt;&quot;]:::process
        P2[&quot;panic(&#39;通配符冲突&#39;)&quot;]:::panic

        %% Insert New Child
        B6[&quot;&lt;b&gt;创建新子节点&lt;/b&gt;&lt;br&gt;1. 创建 child node&lt;br&gt;2. 更新 n.indices&lt;br&gt;3. n.addChild(child)&lt;br&gt;4. n = child&quot;]:::process
        B7[&quot;n.insertChild(path, ...)&lt;br&gt;return&quot;]:::startend


        %% Connections
        A --&gt; A1 --&gt; C0
        C0 -- &quot;是(Yes)&quot; --&gt; B0
        C0 -- &quot;否(No)&quot; --&gt; W

        W --&gt; W1 --&gt; C1_SplitEdge

        C1_SplitEdge -- &quot;是(Yes)&quot; --&gt; B1 --&gt; C2_MorePath
        C1_SplitEdge -- &quot;否(No)&quot; --&gt; C2_MorePath

        C2_MorePath -- &quot;否(No) - 路径完全匹配&quot; --&gt; C3_HasHandlers
        C3_HasHandlers -- &quot;是(Yes)&quot; --&gt; P1
        C3_HasHandlers -- &quot;否(No)&quot; --&gt; B2

        C2_MorePath -- &quot;是(Yes) - 路径有剩余&quot; --&gt; W2
        W2 --&gt; C4
        C4 -- &quot;是(Yes)&quot; --&gt; B3 --&gt; W

        C4 -- &quot;否(No)&quot; --&gt; C5
        C5 -- &quot;是(Yes)&quot; --&gt; C5_1

        C5_1 -- &quot;否(No)&quot; --&gt; B6
        C5_1 -- &quot;是(Yes)&quot; --&gt; B4 --&gt; C6

        C6 -- &quot;是(Yes)&quot; --&gt; B5 --&gt; W
        C6 -- &quot;否(No)&quot; --&gt; P2

        C5 -- &quot;否(No) - 静态&quot; --&gt; B6
        B6 --&gt; B7
    end

    %% Styling
    classDef startend fill:#9f9,stroke:#333,stroke-width:2px,color:#000
    classDef panic fill:#f99,stroke:#333,stroke-width:2px,color:#000
    classDef decision fill:#ffc,stroke:#333,stroke-width:2px,color:#000
    classDef process fill:#9cf,stroke:#333,stroke-width:2px,color:#000
</code></pre>
<h2>响应返回 ctx.Json</h2>
<p>我们的业务逻辑，会在上一步的中间件执行就顺带被执行了。现在我们来看一下请求结果是如何被返还回去的，这里以 <code>ctx.Json()</code> 为例。</p>
<pre><code class="language-go">// JSON serializes the given struct as JSON into the response body.
// It also sets the Content-Type as &quot;application/json&quot;.
func (c *Context) JSON(code int, obj any) {
	c.Render(code, render.JSON{Data: obj})
}

// Render writes the response headers and calls render.Render to render data.
func (c *Context) Render(code int, r render.Render) {
  // 设置 HTTP 响应状态码
	c.Status(code)

  // 检查是否有必要写 response body，没必要则直接返回
	if !bodyAllowedForStatus(code) {
		r.WriteContentType(c.Writer)
		c.Writer.WriteHeaderNow()
		return
	}

  // 写 response body
	if err := r.Render(c.Writer); err != nil {
		_ = c.Error(err)
		c.Abort()
	}
}
</code></pre>
<p>这里的核心是 <code>r.Render</code>，它是一个接口：</p>
<pre><code class="language-go">// Render interface is to be implemented by JSON, XML, HTML, YAML and so on.
type Render interface {
	// Render writes data with custom ContentType.
	Render(http.ResponseWriter) error
	// WriteContentType writes custom ContentType.
	WriteContentType(w http.ResponseWriter)
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250630235422540.png" alt="Render 接口实现类"></p>
<p>这里我们看一下 JSON 的实现：</p>
<pre><code class="language-go">// Render (JSON) writes data with custom ContentType.
func (r JSON) Render(w http.ResponseWriter) error {
	return WriteJSON(w, r.Data)
}

// WriteJSON marshals the given interface object and writes it with custom ContentType.
func WriteJSON(w http.ResponseWriter, obj any) error {
	writeContentType(w, jsonContentType)
	jsonBytes, err := json.Marshal(obj)
	if err != nil {
		return err
	}
	_, err = w.Write(jsonBytes)
	return err
}

// github.com/gin-gonic/gin/response_writer.go
func (w *responseWriter) Write(data []byte) (n int, err error) {
	w.WriteHeaderNow()
	n, err = w.ResponseWriter.Write(data)
	w.size += n
	return
}
</code></pre>
<p>其实逻辑就 2 步：</p>
<ol>
<li>写 content-type: application/json。</li>
<li>调用标准库的 <code>http.responseWriter</code> 将响应结果写回缓冲区。</li>
</ol>
<p>在调用链处理完毕后，最后就回到了我们在<strong>请求入口 c.serve(connCtx)</strong> 中分析到的 <code>finishRequest</code>，然后通过建立好的 TCP 连接传递给客户端。</p>
<h2>优雅关闭 httpServer.Shutdown</h2>
<p>Gin 框架本身不提供优雅关闭的功能，需要使用标准库的 <code>http.Server</code> 来实现优雅关闭（最好结合 tableflip 实现无缝切换）。</p>
<p>基础实现如下：</p>
<pre><code class="language-go">// 创建一个通道来接收系统信号
quit := make(chan os.Signal, 1)
// 监听 SIGINT (Ctrl+C) 和 SIGTERM 信号
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

r := gin.Default()
// ... 注册路由
httpServer := &amp;http.Server{
  Addr:    &quot;:9010&quot;,
  Handler: r,
}
go func() {
  if err := httpServer.ListenAndServe(); err != nil &amp;&amp; err != http.ErrServerClosed {
    log.Fatal(&quot;服务器启动失败: &quot;, err)
  }
}()

// 阻塞等待信号
&lt;-quit

// 使用 Shutdown 进行优雅关闭
if err := httpServer.Shutdown(context.Background()); err != nil {
  log.Fatal(err)
}
log.Println(&quot;服务器已关闭&quot;)
</code></pre>
<p>我们看一下 <code>Shutdown()</code> 的具体逻辑：</p>
<pre><code class="language-go">func (s *Server) Shutdown(ctx context.Context) error {
  // 将服务置为关闭中的状态，拒绝新的连接
	s.inShutdown.Store(true)

  // 执行钩子函数
	s.mu.Lock()
	lnerr := s.closeListenersLocked()
	for _, f := range s.onShutdown {
		go f()
	}
	s.mu.Unlock()

  // 等待现有连接执行完毕 &amp; 关闭
	s.listenerGroup.Wait()

  // 指数退避与抖动
	pollIntervalBase := time.Millisecond
	nextPollInterval := func() time.Duration {
		interval := pollIntervalBase + time.Duration(rand.Intn(int(pollIntervalBase/10)))
		pollIntervalBase *= 2
		if pollIntervalBase &gt; shutdownPollIntervalMax {
			pollIntervalBase = shutdownPollIntervalMax
		}
		return interval
	}

	timer := time.NewTimer(nextPollInterval())
	defer timer.Stop()
	for {
		if s.closeIdleConns() { // 直接关闭空闲的连接，如果全部已经关闭了，就返回 true
			return lnerr
		}
		select {
		case &lt;-ctx.Done(): // 超时强制关闭
			return ctx.Err()
		case &lt;-timer.C:  // 重新设置下次轮询的时间
			timer.Reset(nextPollInterval())
		}
	}
}
</code></pre>
<p>核心逻辑如下：</p>
<ol>
<li><p>将服务置为关闭中的状态，这样在 <code>Accept</code> 的时候，就会拒绝新的连接了。</p>
</li>
<li><p>执行用户自定义的钩子函数，Gin 框架扩展性的体现。</p>
</li>
<li><p><code>s.listenerGroup.Wait()</code> 等待监听器彻底关闭，这里对应了前面 <code>Serve</code> 中的这段代码：</p>
<pre><code class="language-go">func (s *Server) Serve(l net.Listener) error {
    // ...
    if !s.trackListener(&amp;l, true) {
        return ErrServerClosed
    }
    defer s.trackListener(&amp;l, false)
    // ...
}

func (s *Server) trackListener(ln *net.Listener, add bool) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    if s.listeners == nil {
        s.listeners = make(map[*net.Listener]struct{})
    }
    if add {
        if s.shuttingDown() {
            return false
        }
        s.listeners[ln] = struct{}{}
        s.listenerGroup.Add(1)  // &lt;-------- 开启监听
    } else {
        delete(s.listeners, ln)
        s.listenerGroup.Done()  // &lt;-------- 结束监听
    }
    return true
}
</code></pre>
</li>
<li><p>不断轮询关闭空闲的连接，直到整个服务处于静止状态。这里使用的指数退避和抖动：</p>
<pre><code class="language-go">const shutdownPollIntervalMax = 500 * time.Millisecond // 最长轮询等待时间为 500ms

pollIntervalBase := time.Millisecond // 从 1ms 开始
nextPollInterval := func() time.Duration {
  interval := pollIntervalBase + time.Duration(rand.Intn(int(pollIntervalBase/10))) // 加 10% 随机抖动时间
  pollIntervalBase *= 2 // 翻倍
  if pollIntervalBase &gt; shutdownPollIntervalMax { // 限制最长轮询等待时间
    pollIntervalBase = shutdownPollIntervalMax
  }
  return interval
}
</code></pre>
<p><strong>指数退避</strong>：轮询的间隔时间 <code>pollIntervalBase</code> 从 1 毫秒开始，每次轮询后都<strong>翻倍</strong>，直到一个最大值 （<code>shutdownPollIntervalMax</code>）。这非常高效：开始时频繁检查，以便服务能快速关闭；如果耗时较长，就降低检查频率，避免空转浪费 CPU。</p>
<p><strong>抖动</strong>：在基础间隔上增加一个 10% 的随机时间。这在分布式系统中是一个好习惯，可以避免多个服务在同一时刻执行相同操作，造成&quot;惊群效应&quot;。</p>
</li>
<li><p>返回，完成服务的安全关闭。</p>
</li>
</ol>
<h2>流程总结</h2>
<p>最后我们做一个汇总。一个 HTTP 请求在 Gin 框架中的完整旅程可以总结为以下几个核心阶段：</p>
<p><strong>1. 启动与监听：</strong></p>
<ul>
<li>Gin 服务的启动入口是 <code>r.Run()</code>，它首先会解析监听地址，默认使用 <code>:8080</code> 端口，也可以通过 <code>PORT</code> 环境变量或直接传参来指定。</li>
<li>其底层核心是调用了 Go 标准库的 <code>http.ListenAndServe</code>，进一步通过 <code>net.Listen</code> 监听 TCP 端口，并最终在 <code>Serve</code> 方法中进入一个 <code>for</code> 循环，通过 <code>l.Accept()</code> 来接收新的客户端连接。</li>
<li>每当接收到一个新连接，就会创建一个 <code>conn</code> 对象，并为其开启一个独立的 goroutine（<code>go c.serve(connCtx)</code>）来处理后续的请求。</li>
</ul>
<p><strong>2. 请求处理入口：</strong></p>
<ul>
<li>在 <code>c.serve</code> 的 <code>for</code> 循环中，服务器通过 <code>c.readRequest(ctx)</code> 读取和解析原始的 HTTP 请求数据。</li>
<li>最关键的一步是调用 <code>serverHandler{c.server}.ServeHTTP(w, w.req)</code>，这将请求的 <code>ResponseWriter</code> 和 <code>Request</code> 对象传递给了 Gin 引擎的核心处理逻辑。</li>
<li>请求处理完毕后，会调用 <code>w.finishRequest()</code> 来写入响应数据、刷新缓冲区并清理资源，为下一次请求复用做准备。</li>
</ul>
<p><strong>3. 上下文创建与回收：</strong></p>
<ul>
<li>Gin 引擎的 <code>ServeHTTP</code> 方法是处理所有请求的入口。为了提高性能，Gin 使用 <code>sync.Pool</code> 对象池来复用 <code>gin.Context</code> 对象。</li>
<li>每次处理新请求时，会从池中 <code>Get()</code> 一个 <code>Context</code>，重置其内部状态并与当前请求的 <code>ResponseWriter</code> 和 <code>Request</code> 绑定。</li>
<li>请求处理完毕后，<code>Context</code> 对象会被 <code>Put()</code> 回对象池，从而避免了频繁创建和销毁对象带来的垃圾回收压力。</li>
</ul>
<p><strong>4. 路由匹配：</strong></p>
<ul>
<li>Gin 的核心路由机制是基于一个 <code>methodTrees</code> 结构，它为每种 HTTP 方法（GET、POST 等）维护一棵独立的基数树（Radix Tree）。</li>
<li>这棵树由 <code>node</code> 节点构成，<code>node</code> 结构的 <code>path</code>、<code>indices</code>、<code>wildChild</code>、<code>children</code> 等字段协同工作，构建了一个既节省内存又查找飞快的路由树。<code>indices</code> 字段通过存储子节点首字母作为快速索引，是其高性能的关键之一。</li>
<li>路由查找由 <code>root.getValue()</code> 方法执行，它通过一系列步骤（前缀匹配、静态路由查找、通配符匹配）来定位处理器。</li>
<li><code>getValue</code> 的设计非常精巧，它利用 <code>skippedNodes</code> 实现了<strong>回溯（Backtracking）</strong> 机制，以确保在面对静态路由（<code>/users/new</code>）和动态路由（<code>/users/:id</code>）的选择时，能够优先匹配静态路由，保证了路由的准确性。同时，它还支持 <strong>TSR（Trailing Slash Redirect）</strong> 建议，提升了用户体验。</li>
</ul>
<p><strong>5. 中间件与业务逻辑执行：</strong></p>
<ul>
<li>路由匹配成功后，找到的 <code>HandlersChain</code>（一个 <code>[]HandlerFunc</code> 切片）会被赋值给 <code>gin.Context</code>。</li>
<li><code>c.Next()</code> 方法通过一个 <code>for</code> 循环和递增的 <code>index</code> 索引，依次调用 <code>HandlersChain</code> 中的所有处理函数（包括全局中间件、分组中间件和最终的业务 Handler）。</li>
<li><code>c.Abort()</code> 方法通过将 <code>index</code> 设置为一个极大值来巧妙地中断调用链的执行。</li>
</ul>
<p><strong>6. 响应返回：</strong></p>
<ul>
<li>当业务逻辑中调用 <code>c.JSON()</code> 等方法时，实际上是调用了 <code>c.Render()</code>。</li>
<li><code>Render</code> 方法会设置 HTTP 状态码，并通过 <code>render.Render</code> 接口来执行具体的渲染逻辑。例如，<code>render.JSON</code> 会使用标准库的 <code>json.Marshal</code> 将对象序列化，然后通过 <code>http.ResponseWriter</code> 将数据写入响应体。</li>
</ul>
<p><strong>7. 优雅关闭：</strong></p>
<ul>
<li>Gin 自身不提供优雅关闭，但可以与标准库的 <code>http.Server</code> 结合实现。</li>
<li><code>httpServer.Shutdown()</code> 的核心逻辑是：<ol>
<li>首先，通过原子操作 <code>s.inShutdown.Store(true)</code> 设置关闭状态，并调用 <code>s.closeListenersLocked()</code>关闭监听器，从源头阻止新连接的建立。</li>
<li>并发执行通过 <code>onShutdown</code> 注册的钩子函数。</li>
<li>进入一个轮询循环，不断调用 <code>s.closeIdleConns()</code> 来关闭已处理完请求的空闲连接。</li>
<li>这个轮询采用了<strong>指数退避 (Exponential Backoff)</strong> 和<strong>抖动 (Jitter)</strong> 策略，在保证能快速关闭的同时，避免了在等待期间空转浪费 CPU。</li>
<li>整个关闭过程受传入的 <code>context.Context</code> 控制，可以实现超时强制关闭，避免无限期等待。</li>
</ol>
</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>FOSA丨01丨软件架构概述</title>
      <link>https://hedon.top/blog/fosa-ch1/</link>
      <guid isPermaLink="true">https://hedon.top/blog/fosa-ch1/</guid>
      <pubDate>Thu, 26 Jun 2025 11:00:26 GMT</pubDate>
      <description>本篇通过回答《Fundamentals of Software Architecture》第一章的课后思考题，深入探讨软件架构的四个定义维度、架构决策与设计原则的区别、软件架构师的核心职责，以及软件架构的第一定律，帮助建立对软件架构的全面认识。</description>
      <category>读书笔记</category><category>软件架构</category><category>fosa</category><category>架构设计</category>
      <content:encoded><![CDATA[<p>本系列文章通过逐章回答<a href="https://fundamentalsofsoftwarearchitecture.com/">《Fundamentals of Software Architecture》</a>（下文简称 FOSA）一书中的课后思考题，来深入理解书中的核心概念和理论，从而提升我们的软件架构设计能力。本篇为<u>第一章</u>内容。</p>
<p>本章的课后题是：</p>
<ol>
<li>What are the four dimensions that define software architecture?
软件架构定义的四个维度是什么？</li>
<li>What is the difference between an architecture decision and a design principle?
架构决策和设计原则有什么区别？</li>
<li>List the eight core expectations of a software architect.
软件架构师的核心期望有哪些？</li>
<li>What is the First Law of Software Architecture?
软件架构的第一定律是什么？</li>
</ol>
<hr>
<h2>1. 软件架构定义的四个维度</h2>
<ul>
<li><strong>structure of the system</strong>: refres to the type of architecture style (or styles) the system is implemented in (such as microservices, layered, or microkernel).</li>
<li><strong>architecture characteristics</strong>: define the success criteria of a system, which is generally orthogonal to the functionality of the system.</li>
<li><strong>architecture decisions</strong>: define the rules for how a system should be constructed.</li>
<li><strong>design principles</strong>: differ from architecture decisions in that a design principle is a guideline rather than a hard-and-fast rule.</li>
</ul>
<p>FOSA 中强调，软件架构不单单是&quot;架构&quot;本身，因为它无法揭示系统为何如此构建（why is more important than how），所以书中用了 4 个维度来定义软件架构的方方面面。</p>
<h3>1.1 系统结构</h3>
<p>系统结构可能就是我们最常谈到的&quot;架构&quot;，指的的整个软件的架构风格（Architecture Style），例如微服务（Microservices）、分层架构（Layered）或微内核（Microkernel）。</p>
<p>书中后续篇章详细介绍了以下 8 种架构风格：</p>
<ol>
<li>分层架构（Layered Architecture）</li>
<li>流水线架构（Pipeline Architecture）</li>
<li>微内核架构（Microkernel Architecture）</li>
<li>基于服务的架构（Service-Based Architecture）</li>
<li>事件驱动架构（Event-Driven Architecture）</li>
<li>基于空间的架构（Space-Based Architecture）</li>
<li>编排驱动的服务导向架构（Orchestration-Driven Service-Oriented Architecture）</li>
<li>微服务架构（Microservices Architecture）</li>
</ol>
<p>FOSA 强调，只用结构来描述架构是不够的，因为它无法揭示系统为何如此构建。因此我们在设计之初，明确选择和识别现有系统的架构风格是基础，但必须超越这一层去理解其背后的驱动因素。</p>
<h3>1.2 架构特性</h3>
<p>架构特性就是一堆的 <code>-ilities</code>，书中偏爱&quot;架构特性&quot;这个描述，而非&quot;非功能性需求&quot;或&quot;质量属性&quot;，因为这二者带有负面或事后评估的含义，而架构特性，应当是在架构设计之初，就被纳入深入思考。</p>
<p>架构特性有 3 个标准：</p>
<ol>
<li><strong>指定非领域设计考虑</strong>：更关注&quot;如何&quot;实现需求和&quot;为什么&quot;做出某些选择，而不是应用程序&quot;应该做什么&quot;的功能需求。</li>
<li><strong>影响设计的某种结构方面</strong>：架构特性要求在设计中进行特殊的结构考虑。</li>
<li><strong>对应用程序成功其重要作用</strong>：每个架构特性都会带来架构的复杂度，所以要尽可能选择最少的、但对成功至关重要的架构特性予以实施。</li>
</ol>
<p>架构特性可以分为 3 大类：</p>
<ol>
<li>操作型架构特性</li>
<li>结构型架构特性</li>
<li>交叉切面架构特性</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250626114830268.png" alt="架构特性分类"></p>
<p>将任意一个架构特性实现到极致的代价都是极大的，相应也会带来更复杂的架构设计，所以在项目早期，我们需要与领域专家和业务干系人紧密合作，识别和明确最重要的架构特性，用最少的努力，达到最大的产品效果。</p>
<h3>1.3 架构决策</h3>
<p>架构决策定义了系统如何构建的规则。它们构成了系统的约束，并指导开发团队哪些是被允许的，哪些是不允许的。比如在分层架构中，架构师可能会规定只有业务层额和服务层可以访问数据库，从而限制了表示层直接调用数据库。</p>
<p>架构决策应当被文档化，例如使用架构决策记录（Architecture Decision Records，ADRs），这有助于解释决策的背景、理由和后果，避免重复讨论。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image.png" alt="架构决策记录 ADR"></p>
<h3>1.4 设计原则</h3>
<p>设计原则是指导方针（Guideline）而非硬性规则（hard-and-fast rule），用于提供指引和建议，帮助开发团队在面对特定情景时做出最佳选择。</p>
<h2>2. 架构决策和设计原则的区别</h2>
<ul>
<li>架构决策：硬性规则</li>
<li>设计原则：指导方针</li>
</ul>
<h2>3. 软件架构师的核心期望</h2>
<h3>3.1 八大期望</h3>
<ol>
<li><p>Make Architecture Decisions</p>
<p>架构师应定义指导团队技术决策的架构决策和设计原则。核心是&quot;指导（guide）&quot;而非&quot;指定（specify&quot;。</p>
</li>
<li><p>Continually Analyze the Architecture</p>
<p>架构师必须持续、全面地分析技术和问题域的变化，以确保架构的健壮性和业务相关性。</p>
</li>
<li><p>Keep Current with Latest Trends</p>
<p>架构师需要不断跟踪和保持对最新技术、框架、平台和环境的了解。</p>
</li>
<li><p>Ensure Compliance with Decisions</p>
<p>架构师需要持续验证开发团队是否遵循已定义、文档化和沟通的架构决策和设计原则。</p>
</li>
<li><p>Diverse Exposure and Experience</p>
<p>架构师应接触并体验多种多样的技术、框架、平台和环境，从而帮助架构师在面对新问题时，可以从更广阔的技术栈中选择最佳解决方案。</p>
</li>
<li><p>Have Business Domain Knowledge</p>
<p>优秀的软件架构师不仅理解技术，还理解问题空间的业务领域，从而设计出贴合业务发展的有效架构。</p>
</li>
<li><p>Possess Interpersonal Skills</p>
<p>成为一名有效架构师，人际交往能力至少占一半。架构师需要积极与团队协作、进行双向沟通，提供指导和辅导，避免成为象牙塔架构师（Ivory Tower Architecture）。</p>
</li>
<li><p>Understand and Navigate Politics</p>
<p>架构师需要理解企业内部的政治环境，并能驾驭其复杂性。在架构决策受到挑战时，沟通过程中需要始终提供技术和业务上的双重理由，学习将强硬要求转化为请求，并利用同理心和影响力而非头衔来推动决策。</p>
</li>
</ol>
<h3>3.2 成长指南</h3>
<p>成长为一名合格的软件架构师是一个持续学习和实践的过程，涵盖技术、沟通和领导力等多个方面，这里笔者结合 FOSA 书中的 19~24 章内容进行梳理总结：</p>
<ul>
<li><strong>持续学习和扩展技术广度</strong><ul>
<li>20 分钟法则：每天至少投入 20 分钟学习新知识或深入研究特定主题，这里笔者推荐可以每天（或每周）跟 LLM 深入探讨一个技术话题或前沿技术概念。</li>
<li>技术雷达：利用如 ThoughtWorks 技术雷达的方法来组织和评估新技术，识别值得投入时间深入研究的&quot;试用&quot;技术，并根据趋势更新知识库。</li>
<li>社交媒体：积极利用社交媒体发现新趋势和技术，将其放入个人技术雷达的&quot;评估&quot;环中。</li>
</ul>
</li>
<li><strong>实践权衡分析</strong><ul>
<li>失败乃成功之母，实践是检验真理的唯一标准。只有在实践中不断的进行架构设计、权衡抉择、推翻重建，才能不断深化对理论知识、业务领域和现实世界的理解。</li>
<li>培养批判性思维，避免过度设计和&quot;黄金镀层&quot;现象，专注于解决实际问题，而不是为了技术而技术。软件架构中没有错误的答案，只有昂贵的答案。</li>
</ul>
</li>
<li><strong>培养沟通和协作能力</strong><ul>
<li>通用语言：参考 DDD（领域驱动设计）思想，在所有项目相关沟通中（包括代码和文档）都使用通用语言，避免沟通歧义，减少沟通成本。</li>
<li>4C 原则：在沟通中始终关注沟通（Communication）、协作（Collaboration）、清晰（Clarity）和简洁（Concisensess）这 4 个要素。</li>
<li>主动倾听：认真倾听利益相关者的声音，理解他们的业务需求和痛点，并寻求澄清。</li>
</ul>
</li>
<li><strong>通过榜样领导团队</strong><ul>
<li>赢得尊重</li>
<li>辅导和引导</li>
<li>化请求为帮助</li>
<li>使用清单</li>
</ul>
</li>
<li><strong>深入业务领域</strong><ul>
<li>分析业务领域和子域，识别公司的主要活动领域、竞争策略。</li>
<li>学习领域驱动设计，始终让业务驱动软件设计决策，而不是为了应用最新的技术而技术。</li>
</ul>
</li>
</ul>
<h2>4. 软件架构的第一定律</h2>
<ol>
<li>Everything in software architecture is a trade-off.</li>
<li>If an architecture thinks they have discovered something that isn&#39;t a trade-off, more likely they just haven&#39;t identified the trade-off yet.</li>
<li>Why is more important than how.</li>
</ol>
<p>软件架构中的一切都是**<font color="red">权衡</font>**。一个解决方案是否是&quot;最佳&quot;的，取决于部署环境、业务驱动因素、公司文化、预算、时间限制、开发人员技能集以及其他数多种因素。架构师应追求&quot;<strong>最不差架构</strong>（least worst architecture）&quot;，而非&quot;最佳架构&quot;。试图支持过多的架构特性往往会导致过于通用且笨重的设计，使其难以成功。</p>
]]></content:encoded>
    </item>
    <item>
      <title>Q&amp;A丨在 AI 时代，如何应对技术焦虑？</title>
      <link>https://hedon.top/blog/qa-how-to-deal-with-tech-anxiety-in-ai-era/</link>
      <guid isPermaLink="true">https://hedon.top/blog/qa-how-to-deal-with-tech-anxiety-in-ai-era/</guid>
      <pubDate>Sat, 21 Jun 2025 03:06:00 GMT</pubDate>
      <description>本文探讨了在 AI 时代如何应对技术焦虑的问题。通过分析底层技术学习的必要性，提出了&quot;原则重于工具&quot;的核心观点，并构建了包含 T 型知识结构、时间盒管理、即时学习等策略的系统性解决方案，帮助工程师在技术海洋中找到方向，从焦虑走向成长。</description>
      <category>思考</category><category>ai问答</category>
      <content:encoded><![CDATA[<p>长期以来我一直陷于技术海洋之中无法自拔，各种追逐技术，有过偶尔的专注，也获得了很大的提升，但更多时候，还是处于多头乱撞、内耗焦虑当中。</p>
<p>在 AI 快速发展的这段时间，我的焦虑更甚，减法做得越来越差，所以最近专门跟 Google Gemini 聊了这个话题，我觉得它的回答非常好，解答了我很多的疑惑，也让我对未来有了更多的信心，特此整理此篇，以图内心获得更多的宁静。</p>
<h2>在 AI 时代，还有必要学习底层技术吗？</h2>
<p>第一个问题我是跟 ChatGPT 讨论的：<a href="/blog/qa-should-learn-underlying-principles-in-ai-era/">在 AI 时代，程序员还有必要学习底层技术吗？</a></p>
<p>它给了我 5 个直击内心的理由：</p>
<ol>
<li>抽象层终会&quot;泄露&quot;，底层知识是你的救生筏；</li>
<li>问题的根源，往往深藏于你看不到的地方；</li>
<li>真正的工程判断力，建立在对权衡的理解之上；</li>
<li>你的职业天花板，由底层知识决定；</li>
<li>创新，源于对第一性原理的掌握。</li>
</ol>
<p>我觉得非常有道理：</p>
<ol>
<li>当 AI 失灵或表现不佳时，能拯救你的不是另一个 AI，而是你对底层的掌控力。</li>
<li>只懂“驾驶”的人在车坏了时只能打电话求助，而懂“机械”的人能自己打开发动机盖解决问题。我要做后者。</li>
<li>AI 可以成为我的顾问，但最终做出决策、并为之负责的人是我。我的决策质量，直接取决于我的底层知识深度。</li>
<li>AI 会拉平初级和中级工程师的差距，但底层技术功底是区分高级/资深工程师与普通工程师的护城河。</li>
<li>学习底层技术，不仅是为了解决今天的问题，更是为了获得解决明天未知问题、甚至定义未来的能力。</li>
</ol>
<p>所以在保持积极学习前沿技术的同时，我应当将更多的时间，有计划地、持续地加深我的纵向底层知识，从&quot;为什么会这样？&quot;开始，一路向下挖掘，直到触及问题的本质。</p>
<h2>如何应对技术焦虑</h2>
<p>第二个问题我是跟 Google Gemini 讨论的：<a href="https://g.co/gemini/share/819b80654773">后端工程师如何应对技术焦虑</a>。</p>
<h3>原则重于朝生暮死的工具</h3>
<p>后端开发的核心能力，强调那些超越特定语言或框架的底层原则。尽管工具日新月异，但其试图解决的根本问题和所遵循的基本模式却保持着相当的一致性。</p>
<p>后端工程技能三阶模型：</p>
<ol>
<li>是什么：掌握基本工具和概念，能够完成独立任务。</li>
<li>怎么做：熟练运用框架和模式，构建稳定、可维护的系统。</li>
<li>为什么：进行系统级思考，设计高可用、高扩展性的复杂系统。</li>
</ol>
<p>技术焦虑的一个重要来源，是将职业发展视为一个永无止境的&quot;技能清单&quot;勾选过程。今天学会了 Django，明天又要追逐 FastAPI；刚掌握了 Docker，Kubernetes 的生态又让人望而生畏。这种思维模式必然导致疲于奔命。</p>
<p><strong>真正的“不变之核”，不是技能本身，而是该技能所体现的原则。</strong></p>
<p>技术焦虑的逻辑拆解：</p>
<ol>
<li>开发者面临的困境是新技术层出不穷，感到焦虑 [用户问题]。</li>
<li>行业分析罗列了大量&quot;必备技能&quot;，如 Python、Django、Docker、Kubernetes 等 。</li>
<li>如果只是简单地告诉开发者去学习这个清单，只会加剧焦虑，因为清单永远在变长。</li>
<li>与此同时，另一些分析强调了基础概念的重要性，如面向对象、结构化思维、解决问题的能力 。</li>
<li>将这两类信息结合起来，我们能看到更深层的联系：<ul>
<li>Django 和 Ruby on Rails 是 MVC（模型-视图-控制器）架构模式的实现。</li>
<li>Spring Boot 框架深度应用了依赖注入（DI）和面向切面编程（AOP）的思想。</li>
<li>Docker 和 Kubernetes 是为了解决“环境一致性”和“服务编排与生命周期管理”这两个根本问题而诞生的解决方案。</li>
</ul>
</li>
<li>因此，真正持久的技能，是理解这些问题和模式（即&quot;为什么&quot;），而不仅仅是掌握某个工具（即&quot;是什么&quot;）。一个理解了 MVC 模式的工程师，可以快速上手任何一个采用类似模式的新框架。而一个只&quot;会用 Django&quot;的工程师，当行业风向转变时，可能会陷入困境。</li>
</ol>
<p>数据结构、算法、计算机网络、操作系统和计算理论等 CS 基础知识为工程师提供了一个统一的、抽象的框架，用以推理和分析所有计算系统，无论其外在形态如何变化 。这种力量体现在它能够培养一种至关重要的能力：<strong>结构化思维和问题分解</strong>。</p>
<p>CS 基础知识的另一个巨大价值在于，它能帮助工程师&quot;快速理解不熟悉的系统&quot;，比如：</p>
<ul>
<li>当你看到一个 HTML 或 XML 文档时，你不会只看到一堆标签，你会看到一棵<strong>树（Tree）</strong>。这个认知让你立刻能够运用所有关于树的知识来思考它：DOM 遍历算法（深度优先、广度优先）、节点操作的效率、以及如何优化渲染性能。</li>
<li>当你接触到一个新的键值存储（Key-Value Store）系统时，你脑海中浮现的应该是<strong>哈希表（Hash Table）</strong>。这个抽象模型让你能够立即开始推理其核心特性和潜在问题：哈希冲突如何解决？负载因子过高时性能会如何衰减？它的时间复杂度在理想和最坏情况下分别是多少？</li>
<li>当你研究像 Apache Kafka 这样的消息系统时，你会认识到从单个消费者的角度看，一个 Topic 本质上就是一个<strong>队列（Queue）</strong>。这个模型帮助你理解消费者组（Consumer Group）的行为、偏移量（Offset）的管理机制，以及消息的顺序性保证等核心概念。</li>
</ul>
<p>这种通过高层抽象和模式匹配来快速定位和理解新技术核心本质的能力，是在日新月异的技术环境中保持方向感和学习效率的关键。它让你在面对任何一个新框架、新平台或新工具时，都能迅速地抓住其要害，而不是迷失在纷繁复杂的 API 和配置细节之中。</p>
<p>在人工智能时代，我们越来越多地与一些极其复杂的系统打交道，尤其是大型语言模型（LLM），它们在很多开发者眼中就像一个&quot;黑箱&quot;。我们知道如何向它提问并获得惊艳的答案，但对其内部工作原理却知之甚少。这种未知感，正是技术焦虑的重要来源之一——我们称之为&quot;黑箱焦虑&quot;。</p>
<p>一个拥有扎实 CS 基础的工程师面对一个新 AI 系统的场景：</p>
<p><strong>场景一：向量数据库。</strong> 当他听说一个应用使用了向量数据库（Vector Database）来实现语义搜索时 ，他不会仅仅惊叹于其&quot;神奇&quot;的效果。他的大脑会立即启动基于 CS 基础的推理：</p>
<ul>
<li><strong>数据结构层面：</strong> 为了实现高效的近邻搜索，这个向量数据库内部很可能使用了某种空间分割数据结构，比如 k-d 树（k-d tree），或者更现代的、基于图的 HNSW（Hierarchical Navigable Small World）算法。这两种结构在查询速度、内存占用和索引构建时间上有什么不同的权衡？</li>
<li><strong>算法层面：</strong> 它使用的距离度量是欧氏距离还是余弦相似度？这对于不同类型的嵌入向量（Embeddings）意味着什么？</li>
<li><strong>系统层面：</strong> 这是一个单体数据库还是分布式系统？如果是分布式的，它是如何处理数据分片和查询路由的？</li>
</ul>
<p><strong>场景二：AI Agent 系统。</strong> 当他了解到 AI Agent 能够自主规划并执行一系列复杂任务时 ，他不会感到无所适从。他会联想到：</p>
<ul>
<li><strong>算法层面：</strong> 这种任务规划本质上是一个在巨大的状态空间中进行搜索的问题。它可能在内部使用了某种图搜索算法，比如 A* 算法，或者蒙特卡洛树搜索（MCTS）。这些算法的潜在缺陷是什么？比如，是否可能陷入局部最优解，或者面临组合爆炸的问题？</li>
<li><strong>系统层面：</strong> 这个 Agent 系统是如何与外部工具（Tools）进行交互的？是通过结构化的 API 调用吗？那么 API 的可靠性和延迟将成为整个系统的瓶颈。它如何处理工具调用失败的情况？有重试机制或错误处理逻辑吗？</li>
</ul>
<h3>工程师心智模型</h3>
<p>一个工程师最持久、最宝贵的资产，并非某项具体的技术，而是一种特定的思维方式。这种&quot;工程师心智模式&quot;（Engineering Mindset）是运行所有其他技能的底层操作系统，是应对一切变化的最终依仗。</p>
<p>综合多方研究，我们可以将工程师心智模式的核心特质归纳为以下几点：</p>
<ol>
<li>系统性的问题解决方法</li>
<li>数据驱动与逻辑推理</li>
<li>对持续改进的执着</li>
<li>韧性与适应性</li>
<li>主动的好奇心</li>
</ol>
<p>软技能：</p>
<ol>
<li>沟通</li>
<li>协作</li>
<li>时间管理</li>
</ol>
<h3>AI 革命在历史进程中的位置</h3>
<p>软件开发的历史并非一条平滑的直线，而是由一系列深刻的范式转移（Paradigm Shift）所驱动的。这些变革往往是为了应对上一代范式所暴露出的危机或局限性而生 。</p>
<ol>
<li>个体创作时代（Individual Creation）：在软件开发的早期，程序被视为天才程序员在&quot;作坊&quot;中创作的精妙艺术品。</li>
<li>工程范式时代（Engineering Paradigm）：随着软件系统规模和复杂度的急剧增长，个体创作模式难以为继，导致了&quot;软件危机&quot;。为了应对危机，业界引入了工业化生产的管理思想，提出了&quot;软件工程&quot;的概念，强调需求分析、流程分解、文档规范和质量控制，将软件开发从个体创作推向了大规模、有组织的群体生产 。</li>
<li>开源范式时代（Open Source Paradigm）：工程范式在应对互联网时代的需求不确定性和快速变化时显得力不从心。此时，以&quot;代码开源、过程开放、大众参与&quot;为特征的开源运动蓬勃发展，形成了一种新的范式。它不强调预先确定的需求，而是通过&quot;自下而上、演化涌现&quot;的方式，激发大规模群体的创作灵感和智慧 。</li>
<li>群智范式时代（Crowd Intelligence Paradigm）：为了平衡工程范式的确定性和开源范式的不可控性，群智范式应运而生。它试图在规范生产和自由创作之间找到平衡，通过&quot;宏观演化，微观求精&quot;的理念，结合核心团队的引导和外围群体的贡献，实现软件的持续迭代和演化 。</li>
</ol>
<table>
<thead>
<tr>
<th>范式</th>
<th>核心理念</th>
<th>对&quot;需求&quot;的看法</th>
<th>对&quot;质量&quot;的看法</th>
<th>对&quot;效率&quot;的看法</th>
<th>主要瓶颈</th>
</tr>
</thead>
<tbody><tr>
<td><strong>工程范式</strong></td>
<td>自上而下，逐步求精</td>
<td>开发的起点和依据，需预先明确和规范化</td>
<td>满足需求规格的程度，通过验证和测试保障</td>
<td>投入产出比，通过过程控制和自动化提升</td>
<td>协同效率瓶颈（人月神话），无法适应网络时代的需求不确定性</td>
</tr>
<tr>
<td><strong>开源范式</strong></td>
<td>自下而上，演化涌现</td>
<td>不必预先明确，可由开发者自身构思驱动</td>
<td>体现为社区规模和口碑</td>
<td>体现为项目迭代效率（如缺陷修复速率）</td>
<td>结果不可控，将创意作品收敛为产品的成本极高</td>
</tr>
<tr>
<td><strong>群智范式</strong></td>
<td>宏观演化，微观求精</td>
<td>持续获取与凝练的过程，以原型版本和疑修(Issue)集合呈现</td>
<td>宏观上是生态适应能力，微观上是版本满足里程碑的程度</td>
<td>包含激发效率和汇聚效率，体现为迭代演化的成本与时间</td>
<td>如何设计高效的协作机制与智能化工具以保障激发与汇聚效能</td>
</tr>
</tbody></table>
<p>当前企业和社会对 AI 的采纳过程，与十多年的云计算转型惊人地相似，我们可以从中汲取宝贵的经验教训：</p>
<ol>
<li>始于业务问题，而非技术本身（Start with the Business Problem)</li>
<li>清晰评估现状（Assess the Current State）</li>
<li>切勿忽视人的因素（Don&#39;t forget the Humans）</li>
<li>警惕技术蔓延与技术债务（Avoid Sprawl and Techinal Debt）</li>
</ol>
<h3>AI 不是威胁而是工具</h3>
<p>生成式 AI 正以前所未有的深度和广度渗透到软件开发生命周期（Software Development Lifecycle, SDLC）的每一个环节，从根本上改变着开发者的工作方式 。</p>
<ol>
<li>构思与规划（Ideation &amp; Planning）：<ul>
<li>**需求识别与优先级排序: **分析用户反馈、市场趋势数据，辅助产品负责人识别关键需求并进行优先级排序。</li>
<li><strong>可行性与资源预测:</strong> 基于历史项目数据，预测项目成本、时间和资源需求，做出更精准的规划。</li>
<li><strong>数据驱动决策:</strong> 使早期规划更具客观依据，减少主观臆断。</li>
<li><strong>减少需求冲突:</strong> 自动识别不完整或相互矛盾的需求描述，降低后期返工风险。</li>
</ul>
</li>
<li>设计（Design）<ul>
<li><strong>原型加速创建:</strong> 快速生成用户界面原型、线框图和流程图。</li>
<li><strong>架构模式推荐:</strong> 基于项目约束和最佳实践，推荐最优的系统架构或设计模式。</li>
<li><strong>缩短设计周期:</strong> 大幅减少手动绘制原型和设计文档的时间。</li>
<li><strong>避免早期架构失误:</strong> 借助 AI 的知识库，帮助团队在项目初期做出更稳健的架构选择 。</li>
</ul>
</li>
<li>开发（Development）<ul>
<li><strong>智能代码补全与生成:</strong> 基于上下文，自动补全代码行、函数甚至整个逻辑块 (如 GitHub Copilot) 。</li>
<li><strong>从自然语言生成代码:</strong> 根据高层级的功能描述，直接生成多种语言的代码片段 。</li>
<li><strong>代码重构与翻译:</strong> 自动将老旧代码（如 COBOL）重构为更易读的现代语言（如 Java），或在不同语言间进行转换。</li>
</ul>
</li>
<li>测试（Testing）<ul>
<li><strong>自动化测试用例生成:</strong> 从用户故事或需求文档直接生成功能测试用例，包括人类测试者可能忽略的边缘情况。</li>
<li><strong>AI 辅助代码审查:</strong> 在代码提交前，实时扫描潜在的安全漏洞、逻辑错误或性能瓶颈。</li>
<li><strong>提升测试覆盖率:</strong> 自动生成全面的测试套件，确保软件质量。</li>
<li><strong>缺陷左移 (Shift-Left):</strong> 在开发早期发现并修复缺陷，显著降低修复成本和后期风险 。</li>
</ul>
</li>
<li>部署（Deployment）<ul>
<li><strong>自动化部署脚本生成:</strong> 自动生成部署脚本或基础设施即代码（IaC）的配置文件（如 Terraform, Ansible）。</li>
<li><strong>CI/CD 流水线优化:</strong> 辅助编排代码、基础设施和配置管理，实现更快的持续交付。</li>
<li><strong>减少手动部署错误:</strong> 自动化配置过程，降低人为失误的概率。</li>
<li><strong>加速产品上市时间:</strong> 实现更快速、更频繁的生产环境更新，快速响应市场变化。</li>
</ul>
</li>
<li>运维与维护（Maintainice &amp; Operations）<ul>
<li><strong>智能监控与异常检测:</strong> 学习系统正常行为模式，主动识别异常，预测潜在故障 。</li>
<li><strong>自动化事件响应与修复:</strong> 自动执行事件分类、根本原因分析，甚至触发自动化修复脚本 。</li>
<li><strong>文档与知识库维护:</strong> 自动生成或更新技术文档、API 文档和知识库文章 。</li>
<li><strong>从被动响应到主动预防:</strong> 在问题影响用户之前进行干预。</li>
<li><strong>降低平均解决时间 (MTTR):</strong> 快速定位并解决生产问题。</li>
<li><strong>提升知识管理效率:</strong> 确保文档与代码同步，降低团队沟通成本。</li>
</ul>
</li>
</ol>
<h3>开发者角色的转变</h3>
<p>经验丰富的工程师的角色，就从亲自编写每一行代码，转变为更高层次的<strong>审查者、整合者和架构师</strong> 。他们的核心工作变成了：</p>
<ol>
<li><strong>定义问题与设定目标</strong>：清晰地向 AI 描述要实现的功能和约束条件。</li>
<li><strong>批判性审查 AI 的输出</strong>：评估 AI 生成的代码是否符合架构设计、是否遵循编码规范、是否存在潜在的性能和安全问题。</li>
<li><strong>整合与调试</strong>：将 AI 生成的代码片段无缝地整合到现有系统中，并调试其中可能存在的错误。</li>
<li><strong>做出架构权衡</strong>：决定何时使用 AI、使用哪个 AI 工具，并对 AI 无法处理的、需要深刻理解业务和系统长期演进的复杂架构问题做出决策。</li>
</ol>
<h3>智能后端的崛起：AIOps 与系统架构</h3>
<p>AI 不再仅仅是用于<strong>构建</strong>后端的工具，而是成为了后端系统本身的<strong>核心组成部分</strong>。这种融合催生了名为 AIOps 的新领域，正在彻底改变我们对系统运维和架构的认知。</p>
<p>AIOps，即人工智能运维（Artificial Intelligence for IT Operations），是由 Gartner 提出的概念，指的是将大数据和机器学习技术应用于 IT 运维流程，以实现自动化和增强 。在传统的运维模式中，工程师们常常扮演着&quot;救火队员&quot;的角色，在系统发生故障后被动地响应告警、排查问题。而 AIOps 的目标，是利用 AI 的预测和模式识别能力，将运维从<strong>被动响应</strong>转变为<strong>主动预防</strong>，甚至实现<strong>预测性维护</strong> 。</p>
<p>AIOps 通过分析海量的系统遥测数据（包括日志、指标和追踪），为后端系统的稳定性、性能和安全性带来了革命性的提升。</p>
<ul>
<li>主动的事件侦测与预防（Proactive Incident Detection &amp; Prevention）：传统监控系统依赖于预设的静态阈值（例如，CPU 使用率超过 90% 则告警）。而 AIOps 平台通过机器学习算法，能够学习系统在不同负载和时间下的&quot;正常行为&quot;基线。当系统行为偏离这个动态基线时，即使没有触及任何静态阈值，AIOps 也能识别出异常，从而在问题升级为严重故障、影响到终端用户之前，就向工程师发出预警 。</li>
<li>告警降噪与智能关联（Noise Reduction &amp; Intelligent Alerting）：在复杂的微服务架构中，一个底层的故障（如数据库慢查询）可能会引发连锁反应，导致成百上千个相关服务的告警同时爆发，形成&quot;告警风暴&quot;，让待命工程师（On-call Engineer）不堪重负。AIOps 能够自动将这些相关的告警进行关联和分组，并识别出最初的根源事件，将数百条告警压缩为一条包含丰富上下文的、可操作的事件通知，极大地减少了告警噪音，降低了工程师的认知负担。</li>
<li><strong>自动化的根本原因分析 (Automated Root Cause Analysis)</strong>：当故障发生时，最耗时的工作往往是定位根本原因（Root Cause）。AIOps 通过分析跨越整个技术栈（应用、中间件、数据库、网络、基础设施）的数据，能够自动识别不同组件之间的因果关系和依赖关系，快速推断出问题的根源，将工程师从繁琐的手动排查中解放出来 。</li>
<li><strong>自愈与自动化修复 (Self-Healing and Automated Remediation)</strong>：这是 AIOps 最前沿的演进方向，有时也被称为&quot;Agentic AIOps&quot;。在这种模式下，系统不仅能检测和分析问题，还能<strong>自主地采取行动进行修复</strong>。例如：<ul>
<li>检测到某个服务实例无响应时，自动重启该实例。</li>
<li>预测到流量高峰即将来临时，自动扩展相关服务的计算资源。</li>
<li>发现数据库连接池耗尽时，自动调整连接池大小。</li>
<li>识别到安全威胁时，自动执行隔离或封禁 IP 等安全策略 。</li>
</ul>
</li>
</ul>
<p>AIOps 的崛起，意味着后端工程师在运维领域的角色正在发生根本性的转变。他们的工作重心将从<strong>被动的&quot;救火&quot;</strong>，转向<strong>主动的&quot;防火&quot;和&quot;消防系统设计&quot;</strong>。具体来说：</p>
<ol>
<li>成为 AIOps 平台的架构师和维护者；</li>
<li>理解并应用机器机器学习模型的基本原理、适用场景和局限性；</li>
<li>为可观测性而设计（Design for Observability），为 AIOps 提供高质量的&quot;燃料&quot;。</li>
</ol>
<h3>AI 赋能工程师的基础技能栈</h3>
<ol>
<li>数学与统计学基础（Mach &amp; Stats Foundation）：这是理解 AI 模型&quot;如何工作&quot;的基石，而不仅仅是&quot;如何使用&quot;。一个扎实的数学基础能让你在面对模型调优、性能瓶颈分析和结果解读时，具备更深刻的洞察力。<ul>
<li><strong>线性代数 (Linear Algebra)</strong>：理解向量、矩阵、张量及其运算。这是理解数据表示、神经网络结构和各种转换的基础 。</li>
<li><strong>微积分 (Calculus)</strong>：理解导数、偏导数和链式法则。这是理解梯度下降等优化算法如何工作的关键 。</li>
<li><strong>概率论与统计学 (Probability and Statistics)</strong>：掌握概率分布、贝叶斯定理、假设检验等概念。这是理解和评估模型性能、处理不确定性的基础 。</li>
</ul>
</li>
<li>核心机器学习概念（Core ML Concepts）：这是 AI 应用领域的通用语言，构成了解决大多数商业问题的基础。<ul>
<li><strong>学习范式</strong>：清晰地区分监督学习（Supervised Learning）、无监督学习（Unsupervised Learning）和强化学习（Reinforcement Learning）的适用场景 。</li>
<li><strong>关键算法</strong>：了解一些经典的算法，如线性回归、逻辑回归、支持向量机（SVM）、K-均值聚类（K-Means）以及集成方法（如随机森林、梯度提升树）。</li>
<li><strong>核心流程</strong>：掌握特征工程（Feature Engineering）、模型评估（Model Evaluation）和超参数调优（Hyperparameter Tuning）等关键环节 。</li>
</ul>
</li>
<li>深度学习与生成式 AI（Deep Learning &amp; Generative AI）：这是当前 AI 浪潮的核心驱动力，也是后端工程师需要重点关注的新兴领域。<ul>
<li><strong>神经网络基础</strong>：理解神经网络的基本构成，如卷积神经网络（CNNs）和循环神经网络（RNNs）的原理和应用场景 。</li>
<li><strong>Transformer 架构</strong>：深入理解作为现代大语言模型（LLM）基石的 Transformer 架构 。</li>
<li><strong>生成式 AI 核心技术</strong>：熟悉检索增强生成（Retrieval-Augmented Generation, RAG）的原理和实现方式，这是将私有数据与 LLM 结合的关键技术 。</li>
</ul>
</li>
<li>AI 框架与平台（AI Framework and Platforms）：将理论知识转化为实践能力，离不开对主流工具的掌握。<ul>
<li><strong>开发框架</strong>：具备使用 PyTorch 进行模型构建和训练的实践经验 。</li>
<li><strong>模型生态</strong>：熟悉 Hugging Face 等平台，能够利用其丰富的预训练模型生态系统来加速开发 。</li>
<li><strong>云 AI 服务</strong>：了解并能够使用主流云服务商（如 AWS Bedrock, Azure AI, Google Vertex AI）提供的 AI 平台和 API 服务 。</li>
</ul>
</li>
</ol>
<h3>应对焦虑的小建议</h3>
<ul>
<li><strong>高效地休息 (Take Effective Breaks)</strong>：长时间不间断地工作，尤其是在编程这种高强度脑力劳动中，会导致效率下降和精神紧张。采用番茄工作法（Pomodoro Technique），即工作 25-30 分钟后，强制自己休息 5 分钟，或者每工作 1.5-2 小时，进行 10-20 分钟的休息，能够有效恢复精力 。关键在于，休息时要真正地&quot;脱离&quot;，即离开电脑屏幕，站起来走动，或者看看远方，而不是切换到手机上继续浏览信息 。</li>
<li><strong>关注身体健康 (Physical Well-being)</strong>：身心健康密不可分。规律的体育锻炼和健康的营养摄入，是维持长期成功的两个最重要因素 。运动能释放内啡肽，缓解压力；均衡的饮食能为大脑提供稳定的能量。这就像维护一台高性能的服务器，必须为其提供稳定的电力和良好的散热。</li>
<li><strong>正念与减负荷 (Mindfulness and De-stimulation)</strong>：<ul>
<li><strong>正念练习</strong>：每天进行几分钟的冥想练习，可以帮助训练大脑的专注力，减少杂念。可以使用 Headspace、Waking Up 等应用，或者 YouTube 上 的引导式冥想视频 。</li>
<li><strong>减少咖啡因/酒精摄入</strong>：过量的咖啡因会加剧焦虑感。可以尝试用绿茶等含有 L-茶氨酸（L-Theanine）的饮品来替代，它具有镇静作用 。</li>
<li><strong>避免过度刺激</strong>：下班后，尽量避免进行高刺激性的活动，如玩竞技类游戏、看动作大片或无休止地刷社交媒体。可以选择散步、听有声书、阅读、做瑜伽等舒缓的活动，让大脑真正地放松下来 。</li>
</ul>
</li>
<li><strong>设立清晰的边界 (Set Boundaries)</strong>：在远程办公和弹性工作日益普遍的今天，工作与生活的边界变得模糊，这极易导致职业倦怠。必须有意识地设立并捍卫自己的边界。<ul>
<li><strong>明确工作时间</strong>：设定固定的上下班时间，并严格遵守。</li>
<li><strong>管理通知</strong>：使用手机的&quot;专注模式&quot;或类似功能，在非工作时间屏蔽工作相关的通知，在工作时间屏蔽不必要的干扰 。</li>
<li><strong>优先排序</strong>：要清醒地认识到，家庭、健康和个人生活，远比修复一个明天才到截止日期的 BUG 更重要。学会对不合理的要求说&quot;不&quot;。</li>
</ul>
</li>
<li><strong>关注过程，而非终点 (Focus on Process, Not Outcome)</strong>：拥抱成长型思维，将你的目标从&quot;完全掌握 AI&quot;这个不切实际的终点，转变为&quot;保持持续学习的状态&quot;这个可控的过程。技术的演进没有终点，因此你的学习也不应有终点。接受这一点，能让你从对&quot;完成&quot;的焦虑中解脱出来，转而享受学习和进步本身带来的乐趣。</li>
</ul>
<h2>如何高效学习底层与新技术</h2>
<p>应对当前挑战的最优策略，是向&quot; AI 增强的 T 型工程师（AI-Augmented T-Shaped Engineer）&quot;转型：</p>
<ol>
<li><strong>深度的垂直支柱</strong>：投入时间系统性学习那些具有长期价值的计算机科学基础原理，如算法、系统设计、编译原理等。这是职业生涯的&quot;压舱石&quot;，构成了 T 型的垂直笔画。</li>
<li><strong>广阔的水平横梁</strong>：建立一个高效的机制，以保持对新兴技术，特别是 AI 领域的广泛、自适应的认知。</li>
</ol>
<h3>精通技艺：用费曼学习法实现深度理解</h3>
<p>需要掌握：</p>
<ol>
<li>计算机理论与编程范式：《计算机程序的构造和解释》。</li>
<li>编译器与语言原理：《用 go 语言自制解释器》、《用 go 语言自制编译器》。</li>
<li>算法与数据结构：《业务开发算法 50 讲》、《数据结构与算法之美》。</li>
<li>操作系统：《手写 OS》、《Writing an OS in Rust》。</li>
<li>软件工艺：《程序员的修炼：从优秀到卓越》。</li>
</ol>
<p>费曼学习法：</p>
<ol>
<li>选择一个概念；</li>
<li>尝试教会一个 12 岁的孩子。</li>
<li>识别理解的缺口。</li>
<li>回顾与简化。</li>
</ol>
<h3>水平横梁：构建情报引擎 &amp; 微实践</h3>
<p>优质信息源：</p>
<table>
<thead>
<tr>
<th>信息源名称</th>
<th>关注领域</th>
<th>频率</th>
<th>为何具有高信噪比</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Benedict&#39;s Newsletter</strong></td>
<td>宏观科技战略与趋势</td>
<td>每周</td>
<td>由顶尖分析师提供深刻的行业洞察，帮助理解技术背后的商业逻辑</td>
</tr>
<tr>
<td><strong>The Pragmatic Engineer</strong></td>
<td>软件工程实践、文化、职业发展</td>
<td>每周</td>
<td>由前 Uber 工程领导者撰写，提供来自一线的、深入的工程管理和技术决策分析</td>
</tr>
<tr>
<td><strong>TLDR Newsletter</strong></td>
<td>科技、编程、网络安全新闻摘要</td>
<td>每日</td>
<td>极其简明扼要，用几句话总结当日最重要的技术新闻，适合快速扫描</td>
</tr>
<tr>
<td><strong>Import AI</strong></td>
<td>AI 研究、政策与安全</td>
<td>每周</td>
<td>由 Anthropic 联合创始人策划，提供对 AI 领域重大进展和伦理影响的专业解读</td>
</tr>
<tr>
<td><strong>ByteByteGo Newsletter</strong></td>
<td>系统设计与架构</td>
<td>每周</td>
<td>深入浅出地讲解复杂的系统设计概念，对后端工程师极具价值</td>
</tr>
</tbody></table>
<p>学习新技术的最佳方式是动手实践。然而，为了不影响核心的深度学习，这种实践必须是目标明确且时间受限的。这里引入&quot;即时学习&quot;（Just-in-Time Learning）和&quot;微问题解决&quot;（Micro-Problem Solving）两个概念。</p>
<ul>
<li><p><strong>即时学习（JIT Learning）</strong>：这是一种按需学习的模式，即在需要应用某项知识或技能时才去学习它，而不是进行大规模的预先学习 。这种方式可以减轻认知负担，并将学习与实际应用紧密结合，从而提高知识留存率。</p>
</li>
<li><p><strong>玩具项目（Toy Projects）</strong>：当一个新技术（例如一个新的 AI 框架如 LangGraph 或一个向量数据库）出现并显得重要时，为其分配一个严格限定时间的&quot;玩具项目&quot;，比如一个周末或几个晚上的时间 。项目的目标不是构建一个生产级应用，而是通过动手实践，获得对该技术核心概念、API 设计和工作流程的直观理解。</p>
</li>
<li><p><strong>微问题解决（Micro-Problem Solving）</strong>：将玩具项目分解为一系列最小的可执行任务 。例如，学习构建一个 RAG（检索增强生成）应用，可以分解为以下微问题：</p>
<ol>
<li>加载一篇 PDF 文档；</li>
<li>将文本分割成块（chunking）；</li>
<li>为文本块生成向量嵌入（embeddings）；</li>
<li>将嵌入存储到向量数据库；</li>
<li>根据用户查询检索最相关的文本块；</li>
<li>将检索到的内容与原始查询结合，生成最终答案 。</li>
</ol>
<p>这种分解使得学习过程 manageable，并能提供持续的、小步快跑式的成就感。</p>
</li>
</ul>
<h3>拓展横梁：认知 AI 产品生态系统</h3>
<p>在 AI 时代，一名高级工程师的&quot;广度&quot;已不再局限于技术本身。由于 AI 技术的选择（如模型、框架）直接影响到产品的成本、延迟、用户信任乃至商业模式，工程决策与商业战略的联系变得前所未有地紧密 。</p>
<p>因此，现代工程师的 T 型横梁需要延伸至对 AI 产品生态的理解。这包括：</p>
<ul>
<li><strong>AI 产品管理框架</strong>：了解如何识别和验证 AI 用例，评估其商业价值、技术可行性和用户可用性 。</li>
<li><strong>AI 产品上市策略（GTM）</strong>：理解 AI 产品，尤其是具有不确定性输出的概率性产品的市场定位、定价模型和营销渠道策略 。</li>
<li><strong>构建可防御的&quot;护城河&quot;</strong>：明白在 AI 技术本身易于复制的背景下，产品的长期竞争力更多地来自于专有数据、独特的工作流集成、强大的用户体验和生态系统 。</li>
</ul>
<h3>时间管理：深度工作与扫描协议</h3>
<p><strong>深度工作模块 (The Vertical Bar)</strong>：这是为 T 型模型的&quot;垂直支柱&quot;——即基础原理学习——专门预留的时间。</p>
<ul>
<li><p><strong>方法</strong>：每周规划 2 到 3 个<strong>不可协商的、时长为 2 小时的“深度工作”时间块</strong> 。将这些时间块像对待最重要的会议一样标记在日历上，并告知团队成员在此期间除非紧急情况，否则不要打扰。</p>
<blockquote>
<p>认知科学研究表明，一次中断后，人需要平均 23 分钟才能重新进入专注状态，对于复杂的编程任务，这个时间更长 。因此，长时间、不受干扰的模块对于攻克 SICP 或 DDIA 中的复杂概念至关重要。</p>
</blockquote>
</li>
</ul>
<p><strong>扫描与即时学习模块 (The Horizontal Bar)</strong>：这是为 T 型模型的“水平横梁”——即技术雷达扫描和即时探索——设计的时间。</p>
<ul>
<li><strong>方法</strong>：安排更短、更频繁的时间块，例如<strong>每日 30-45 分钟</strong>，用于&quot;扫描&quot;活动。利用这段时间阅读筛选过的新闻通讯、浏览新技术发布、或推进你的&quot;玩具项目&quot;。这个协议的目的是防止对新技术的追逐侵占宝贵的深度工作时间。</li>
</ul>
<p><strong>任务批处理 (Task Batching)</strong>：</p>
<ul>
<li><strong>方法</strong>：将性质类似的&quot;浅层工作&quot;（shallow work）归集在一起，在指定的时间块内一次性处理完毕 。例如，将所有非紧急的邮件回复、代码审查（Code Review）或行政事务集中在下午的某个固定时段处理。这可以有效减少任务切换带来的认知损耗，为深度工作保留宝贵的精力。</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Q&amp;A丨在 AI 时代，还有必要学习底层技术吗？</title>
      <link>https://hedon.top/blog/qa-should-learn-underlying-principles-in-ai-era/</link>
      <guid isPermaLink="true">https://hedon.top/blog/qa-should-learn-underlying-principles-in-ai-era/</guid>
      <pubDate>Tue, 17 Jun 2025 19:13:00 GMT</pubDate>
      <description>本文讨论了在 AI 时代继续扎根学习底层技术的重要性，并给出 5 个说服自己的理由。</description>
      <category>思考</category><category>ai问答</category>
      <content:encoded><![CDATA[<p>毫无疑问，<strong>绝对有必要，甚至比以往任何时候都更加重要。</strong></p>
<p>AI 时代，应用层的技术和工具变得越来越“智能”和“易用”，这反而造成了一种危险的错觉：似乎我们不再需要理解那些复杂的底层原理了。但真相恰恰相反。</p>
<p>把自己想象成一位 F1 赛车手。AI 工具就像一个极其先进的驾驶辅助系统，它能帮你自动换挡、优化过弯路线，甚至在某些直道上自动驾驶。这能让你开得更快，但如果你想成为冠军，你必须理解：</p>
<ul>
<li><strong>引擎的工作原理（操作系统、CPU）</strong>：为什么在某个转速下引擎会抖动？如何压榨出极限动力？</li>
<li><strong>空气动力学（网络、I/O）</strong>：为什么今天赛车感觉有点“飘”？是风向变了还是下压力调校出了问题？</li>
<li><strong>轮胎与悬挂（数据结构、数据库）</strong>：如何根据赛道温度和磨损情况选择轮胎？悬挂的软硬如何影响抓地力？</li>
</ul>
<p>只依赖驾驶辅助系统的车手，在赛车一切正常时表现尚可。一旦出现任何异常——轮胎异常磨损、引擎过热、通信系统延迟——他将束手无策，只能无奈退赛。而理解底层的车手，能通过细微的反馈感知到问题，与工程师沟通，调整策略，最终赢得比赛。</p>
<hr>
<h3>五个无法回避的理由</h3>
<p>当你再对学习底层技术产生动摇时，请用以下几点来说服自己：</p>
<h4>1. 抽象层终会“泄漏”，底层知识是你的救生筏</h4>
<p>这是软件工程颠扑不破的“<strong>抽象泄漏定律</strong>”（The Law of Leaky Abstractions）。任何为了简化而存在的上层工具（包括 AI），都无法完美隐藏其底层的复杂性。当问题发生时，这个“泄漏”就会出现。</p>
<ul>
<li><strong>场景 A</strong>：AI 帮你生成了一段代码，用于从数据库查询数据，但在生产环境压力下响应极慢。AI 无法告诉你原因是索引失效、发生了锁竞争还是因为 N+1 查询。这时，你需要<strong>数据库底层知识</strong>来分析执行计划、优化索引。</li>
<li><strong>场景 B</strong>：一个由 AI 调度的微服务出现随机性高延迟。AI 无法告诉你这是因为容器的 CPU 被限制、发生了网络丢包，还是因为 JVM 的垃圾回收（GC）暂停。这时，你需要<strong>操作系统和网络的底层知识</strong>来定位根源。</li>
<li><strong>场景 C</strong>：你的 RAG 应用召回结果不理想。AI 无法告诉你是因为向量嵌入模型选择不当，还是向量数据库的索引策略（如 HNSW）参数需要调优。这需要你理解<strong>数据结构和算法</strong>。</li>
</ul>
<p><strong>说服自己：当 AI 失灵或表现不佳时，能拯救你的不是另一个 AI，而是你对底层的掌控力。</strong></p>
<h4>2. 问题的根源，往往深藏于你看不到的地方</h4>
<p>高级的故障排查（Troubleshooting）是后端工程师的核心价值之一。问题的表象（Symptom）和根源（Root Cause）往往不在同一个层面。</p>
<ul>
<li>一个 API 超时，表象是应用层错误。根源可能是 TCP 连接池耗尽、是 DNS 解析缓慢、是磁盘 I/O 达到瓶颈。</li>
<li>AI 可以帮你分析日志，找到那个超时的 API。但它很难跨越多个技术栈，将零散的线索串联起来，形成一个完整的证据链，最终定位到那个深藏的根源。这种系统性的诊断能力，源于你脑中那张完整的技术底层地图。</li>
</ul>
<p><strong>说服自己：只懂“驾驶”的人在车坏了时只能打电话求助，而懂“机械”的人能自己打开发动机盖解决问题。我要做后者。</strong></p>
<h4>3. 真正的工程判断力，建立在对权衡（Trade-off）的理解之上</h4>
<p>AI 可以提供“方案”，但无法为你做出最佳的“决策”。工程的核心是权衡。</p>
<ul>
<li>用关系型数据库还是 NoSQL？用 Redis 还是本地缓存？服务间通信用 gRPC 还是 RESTful API？</li>
<li>每一个选择背后，都是对性能、成本、一致性、可用性、开发效率等一系列因素的综合考量。而这些考量的依据，正是来自你对各种技术底层实现机制的理解。不了解 TCP 和 HTTP/2 的底层差异，你如何能在 gRPC 和 REST 之间做出最合理的选择？不了解 B+树和 LSM 树，你如何为特定场景选择最合适的数据库？</li>
</ul>
<p><strong>说服自己：AI 可以成为我的顾问，但最终做出决策、并为之负责的人是我。我的决策质量，直接取决于我的底层知识深度。</strong></p>
<h4>4. 你的职业天花板，由底层知识决定</h4>
<p>随着 AI 自动化掉越来越多重复性的、模式化的编码工作，未来后端工程师的价值会更加向两个方向集中：</p>
<ul>
<li><strong>向上</strong>：深入理解业务，进行业务建模和架构设计。</li>
<li><strong>向下</strong>：解决硬核的技术难题，进行极致的性能优化和系统稳定性保障。</li>
</ul>
<p>这两个方向，都极度依赖底层技术。没有底层知识，你的架构设计就是空中楼阁；没有底层知识，你永远无法成为解决最棘手问题的那个关键人物。</p>
<p><strong>说服自己：AI 会拉平初级和中级工程师的差距，但底层技术功底是区分高级/资深工程师与普通工程师的护城河。</strong></p>
<h4>5. 创新，源于对第一性原理的掌握</h4>
<p>我们使用的所有 AI 工具、框架、平台，本身就是由那些深刻理解底层技术的人创造出来的。他们不是简单地“使用”技术，而是基于对计算、存储、网络等第一性原理的理解，去“创造”新的技术。</p>
<p>如果你满足于只做一个技术的使用者，或许可以忽略底层。但如果你心中还有一丝火花，想在技术的世界里留下自己的印记，想创造出真正有价值的东西，那么掌握底层就是你唯一的路径。</p>
<p><strong>说服自己：学习底层技术，不仅是为了解决今天的问题，更是为了获得解决明天未知问题、甚至定义未来的能力。</strong></p>
<hr>
<h3>结论：如何平衡学习</h3>
<p>这并非要求你立刻放下所有 AI 学习，去埋头啃《计算机程序的构造和解释》。正确的做法是：</p>
<ul>
<li><strong>保持“T 型”结构</strong>：在积极拥抱和学习 AI 这个横向技能的同时，有计划地、持续地加深你的纵向底层知识。</li>
<li><strong>问题驱动学习</strong>：在应用 AI 或开发业务时，遇到任何性能、稳定性、或“匪夷所思”的问题，都不要轻易放过。把它当作一个深入学习底层技术的绝佳契机。从“为什么会这样？”开始，一路向下挖掘，直到你触及问题的本质。</li>
</ul>
<p>AI 时代，底层技术不是你的“备选项”，而是你的“压舱石”。它能让你的技术大船在 AI 的巨浪中，行得更稳、更快、更远。</p>
]]></content:encoded>
    </item>
    <item>
      <title>读书笔记丨《Rust Atomics and Locks》</title>
      <link>https://hedon.top/blog/note-rust-atomics-and-locks/</link>
      <guid isPermaLink="true">https://hedon.top/blog/note-rust-atomics-and-locks/</guid>
      <pubDate>Thu, 12 Jun 2025 09:01:24 GMT</pubDate>
      <description>本篇是 Rust 并发编程系列文章的最后一篇，笔者将尝试对书中涉及到的关键知识点进行统一梳理，主要是为了便于查阅，所以不会再过多深入细节，感兴趣的读者可以跳转到对应篇章进行详细查阅。</description>
      <category>读书笔记</category><category>rust</category>
      <content:encoded><![CDATA[<p>系列文章：</p>
<ul>
<li><a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a></li>
<li><a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a></li>
<li><a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a></li>
<li><a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a></li>
<li><a href="/blog/rust-action-arc/">Rust 实战丨手写一个 Arc</a></li>
<li><a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a></li>
<li><a href="/blog/rust-action-mutex/">Rust 实战丨手写一个 Mutex</a></li>
<li><a href="/blog/rust-action-condvar/">Rust 实战丨手写一个 Condvar</a></li>
<li><a href="/blog/rust-action-rwlock/">Rust 实战丨手写一个 RwLock</a></li>
<li><a href="/blog/note-rust-atomics-and-locks/">读书笔记丨《Rust Atomics and Locks》</a>👈 本篇</li>
</ul>
<hr>
<p>本文整理总结 <em>Mara Bos</em> 所著《Rust Atomics and Locks》一书的核心内容，系统归纳 Rust 并发编程中原子操作与锁机制的关键概念和技术细节。内容涵盖原子操作与内存顺序、Rust 并发的基础工具（内部可变性、线程安全保证、操作系统并发原语），以及多种常用并发原语（自旋锁、一次性通道、原子引用计数、互斥锁、条件变量、读写锁）的实现要点。通过这些内容的串联，展示概念之间的联系、底层原理、Rust 标准库实现方式与实际工程实践的关系，方便读者快速回顾并用作知识索引。</p>
<h2>原子操作基础</h2>
<p>Rust 提供了一系列<strong>原子类型</strong>（如 <code>AtomicBool</code>、<code>AtomicUsize</code> 等）来实现线程之间的<strong>无锁并发</strong>数据共享。对原子类型的操作分为三大类别：</p>
<ul>
<li><strong>Load（加载）</strong> 与 <strong>Store（存储）</strong>：分别用于原子地读取和写入值。</li>
<li><strong>Fetch-and-Modify（获取并修改）</strong>：在返回旧值的同时，原子地对值进行修改，例如 <code>fetch_add</code>、<code>fetch_sub</code>、<code>fetch_or</code>、<code>fetch_and</code>、<code>fetch_xor</code>、<code>swap</code> 等。</li>
<li><strong>Compare-and-Exchange（比较并交换）</strong>：即原子地执行“如果当前值等于预期值则交换”的操作，包括 <code>compare_exchange</code> 和 <code>compare_exchange_weak</code>。</li>
</ul>
<p>不同计算机体系结构对原子操作的支持方式有所差异，主要体现在<strong>指令集类别</strong>和<strong>内存模型</strong>上。现代CPU通常分为两种指令集架构：</p>
<ul>
<li><strong>CISC（复杂指令集）</strong>：典型代表是 x86 架构（包括 x86-64）。CISC 指令集功能丰富、单条指令可执行复杂操作，但硬件实现复杂。x86-64 是基于 CISC 的 64 位扩展架构，由 AMD 设计主导，追求通过硬件复杂性换取高性能和广泛兼容性，主导了桌面与服务器领域。</li>
<li><strong>RISC（精简指令集）</strong>：典型代表是 ARM 架构（如 ARM64）。RISC 指令集精简、每条指令功能单一，硬件实现相对简单且能效更高。ARM64 基于 RISC 的 64 位架构，由 ARM 设计，指令简洁、低功耗，在移动设备占主导地位并逐步进入服务器和 PC 领域。</li>
</ul>
<p><strong>原子性的实现机制：</strong> 在 x86-64 上，保证原子性的关键是使用 <code>lock</code> 前缀锁定总线或缓存行来原子执行指令；而在 ARM64 上，则依赖 Load-Linked/Store-Conditional（<strong>LL/SC</strong>）指令对实现原子操作。例如，x86 上原子的 compare-and-swap 通常通过 <code>LOCK CMPXCHG</code> 指令完成，而 ARM 上通过一对原子链式的加载/存储指令完成。如果处理器不支持所需的原子指令，Rust 会在编译期报错以防止不安全的并发操作。</p>
<p><strong>内存序模型差异：</strong> 不同架构的内存序保证也不同。x86-64 属于<strong>强顺序</strong>（Total Store Order, TSO）模型，对内存操作的重排序有限制，而 ARM64 属于<strong>弱顺序</strong>模型，需要显式的内存屏障来保证顺序。具体来说，x86-64 默认禁止以下重排序：</p>
<ul>
<li><strong>Load → 后续操作</strong> 不允许乱序：例如在同一线程中，先执行的读取不能被后执行的操作越过。</li>
<li><strong>Store → 前序操作</strong> 不允许乱序：一个存储不能提前到先前未执行完的操作之前。</li>
<li><strong>Store → 随后的 Load</strong> 则<strong>可能</strong>乱序：即一个存储操作后紧跟的加载操作在实际执行中可能被处理器提升到存储之前执行（典型的 Store-Load 重排）。</li>
</ul>
<p>相比之下，ARM 等弱序模型中，大多数内存操作在缺乏同步指令时都可能被重排序，因此所有原子操作默认<strong>可能乱序</strong>，需要利用内存屏障指令（ARM 上如 <code>dmb ish</code>、<code>dmb ishld</code>）来提供顺序保证。此外，在 compare-and-exchange 操作上，x86-64 并未提供真正的“弱”版本（即不会出现无故失败的情况），因而 Rust 中的 <code>compare_exchange_weak</code> 在 x86 上实际上与强语义等价；而 ARM64 的 LL/SC 实现存在可能的自发失败，因此区分了 weak 版本以便需要时重试。</p>
<p>综上，<strong>硬件架构对原子操作的支持</strong>直接影响Rust并发库的实现策略：在强序的 x86 上，一些内存序保证可由硬件天然提供，而在弱序的 ARM 上则必须借助显式屏障指令来达成。Rust 的原子类型实现会针对不同架构插入相应的汇编指令，以确保提供声明的原子性和内存序语义。</p>
<h2>内存顺序与内存模型</h2>
<p>多线程环境下面临两大核心问题：</p>
<ol>
<li><strong>指令乱序执行（Out-of-Order Execution）</strong>：现代CPU会对指令进行乱序执行和优化，这可能导致程序实际执行顺序与源码顺序不一致。</li>
<li><strong>跨线程内存可见性</strong>：不同线程对内存修改的可见性无法保证——一个线程写入的数据，何时及如何对其他线程可见，需要通过同步手段来控制。</li>
</ol>
<p>为解决上述问题，需要了解内存模型中的两类关键顺序关系：</p>
<ul>
<li><strong>Sequenced-Before（先行顺序）</strong>：描述单个线程内操作的先后顺序。按照程序中的先后关系确定，在同一线程内如果操作 A 在源码中位于操作 B 之前，那么 A <em>sequenced-before</em> B。先行顺序遵循以下规则：若操作 B 数据依赖于 A，则 A 必定在 B 之前执行；针对同一原子变量的操作按程序顺序执行；若两个独立操作无数据依赖且访问不同变量，那么处理器可能对它们重排。总之，先行顺序限定单线程的执行次序，为编译器和 CPU 提供本线程内优化的依据。</li>
<li><strong>Happens-Before（先发生）</strong>：描述跨线程的操作顺序和可见性。如果操作 A <em>happens-before</em> 操作 B，意味着 A 的所有内存效果对于 B 是可见的。Happens-Before 建立在线程间的同步关系上，例如：在同一线程中，函数 <code>f()</code> 调用在前而 <code>g()</code> 在后，则 <code>f()</code> happens-before <code>g()</code>；一个线程调用 <code>thread::spawn</code> 创建新线程发生在对该新线程的 <code>join</code> 之前；再如互斥锁的<strong>加锁</strong>先于<strong>解锁</strong>操作（unlock 发生在 lock 之前释放锁的线程完成临界区之后）。这些 Happens-Before 规则确保了特定事件的<strong>跨线程可见性</strong>和执行顺序。</li>
</ul>
<p>Rust 的原子操作支持五种内存顺序（<code>Ordering</code> 枚举），从最松弛到最严格依次为 <strong>Relaxed</strong>、<strong>Release</strong>、<strong>Acquire</strong>、<strong>AcqRel</strong>、<strong>SeqCst</strong>。不同的内存顺序决定了原子操作在乱序和可见性方面的保证强度。下表总结了它们的语义、保证、使用场景和示例：</p>
<table>
<thead>
<tr>
<th>内存顺序</th>
<th>说明</th>
<th>保证</th>
<th>适用场景</th>
<th>示例</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Relaxed</strong></td>
<td>最宽松的内存顺序</td>
<td>- 仅保证操作的原子性<br>- 不提供任何同步保证<br>- 不建立 happens-before 关系</td>
<td>- 简单计数器- 极高性能要求且确定不需要跨线程同步<br/>- 已通过其他方式确保数据可见性同步</td>
<td><code>counter.fetch_add(1, Ordering::Relaxed)</code></td>
</tr>
<tr>
<td><strong>Release</strong></td>
<td>用于存储操作</td>
<td>- 此操作之前的所有内存访问不会被重排到它之后<br/>- 与后续线程的 Acquire 操作配对可建立 happens-before 关系</td>
<td>- 典型“生产者-消费者”模型- 发布共享数据给其他线程<br/>- 写入一个“初始化完成”标志</td>
<td><code>data.store(val, Ordering::Release)</code></td>
</tr>
<tr>
<td><strong>Acquire</strong></td>
<td>用于加载操作</td>
<td>- 此操作之后的所有内存访问不会被重排到它之前<br/>- 与另一个线程先前的 Release 操作配对可建立 happens-before 关系</td>
<td>- “生产者-消费者”模型中获取数据- 读取共享数据（需确保数据已由其他线程准备好）<br/>- 检查某个初始化完成的标志</td>
<td><code>let val = data.load(Ordering::Acquire)</code></td>
</tr>
<tr>
<td><strong>AcqRel</strong></td>
<td>读改写操作的组合语义</td>
<td>- 同时具有 Acquire 和 Release 的所有内存顺序保证<br/>- 只能用于原子读-改-写操作（RMW），对读取部分提供 Acquire 保证，对写入部分提供 Release 保证</td>
<td>- 需要双向内存同步的原子操作v- 实现锁等同步原语（例如原子自增既读取又写入）<br/>- 较复杂的原子同步场景</td>
<td><code>value.fetch_add(1, Ordering::AcqRel)</code></td>
</tr>
<tr>
<td><strong>SeqCst</strong></td>
<td>全局顺序一致的最强顺序</td>
<td>- 包含 AcqRel 的所有保证<br/>- <strong>所有线程</strong>对所有 SeqCst 原子操作的观察顺序一致（总排序）<br/>- 提供跨线程全局的内存顺序一致性</td>
<td>- 需要严格的全局一致性场景<br/>- 不确定使用哪种顺序时采用（保守策略）<br/>- 对性能要求不敏感的代码</td>
<td><code>flag.store(true, Ordering::SeqCst)</code></td>
</tr>
</tbody></table>
<p>需要注意的是，<strong>Release</strong> 通常与 <strong>Acquire</strong> 搭配使用，共同建立线程间的同步关系。当线程 A 使用 Release 语义写入某个共享变量，线程 B 之后使用 Acquire 语义读取到了该值，那么可以保证：线程 A 中那次 Release 写入之前的所有内存写操作，对线程 B 在 Acquire 读取之后的所有操作都是可见的。换言之，通过 Release-Acquire 的配对建立了跨线程的 <em>happens-before</em>：生产者线程写入的数据对消费者线程可见。这就是典型的生产者-消费者模式同步的原理。例如，一个线程完成初始化后将标志位设为 true（Release），另一个线程反复以 Acquire 读取该标志，当读到 true 时即可安全地读取之前初始化的数据。</p>
<p>另一个重要概念是 <strong>释放序列（Release Sequence）</strong>。释放序列指的是：“以一次 Release 操作为开头，紧跟其后的、在同一原子变量上的所有<strong>同一线程</strong>的写操作或读改写操作（RMW），共同形成一个连续序列”。如果某线程在 Acquire 读取时读到了这个序列中的任意一个写，那么该 Acquire 将和序列开头的那次 Release 建立同步关系。简单理解，Release 序列涵盖了 Release 写入线程接下来对同一原子变量的后续修改，以及可能由其他线程执行的 RMW 操作，从而确保 Acquire 端读取到序列中任何结果时，都能看到序列开头 Release 之前的所有内存效果。</p>
<p>底层实现上，编译器和CPU通过**内存屏障（Memory Barrier）**指令来实现上述内存顺序保证。主要有三类内存屏障：</p>
<ol>
<li><strong>读屏障（Load Barrier）</strong>：确保屏障之前的所有读操作都已完成，并阻止后续读操作提前执行（即后面的读不会跑到屏障之前）。这相当于 Acquire 语义的效果，在很多架构上，Acquire Load 会在汇编层插入读屏障指令或使用带Acquire语义的特殊读指令。</li>
<li><strong>写屏障（Store Barrier）</strong>：确保屏障之前的所有写操作都已对内存可见，并阻止后续写操作提前执行（不让后面的写越到屏障之前）。这对应 Release 语义，Release Store 常通过写屏障指令或带Release语义的原子写指令实现。</li>
<li><strong>全屏障（Full Barrier）</strong>：同时具有读屏障和写屏障效果，禁止任何读或写的重排序。这通常用于 SeqCst 场景，确保全局一致的内存顺序。在 x86 上 <code>MFENCE</code> 指令就是一个全屏障，而 ARM 上则需要 <code>DMB ISH</code> 等全内存屏障指令来达到 SeqCst 效果。</li>
</ol>
<p>通过合理选择内存顺序和屏障，程序员可以在性能和内存可见性保证之间取得平衡：在确保数据跨线程可见性和操作顺序正确的前提下，尽量减少不必要的开销。</p>
<h2>Rust 并发的基础工具</h2>
<h3>UnsafeCell：内部可变性的支柱</h3>
<p>Rust 的所有权和借用规则在编译期就保证了单线程情况下的数据安全，例如通过不可变引用 <code>&amp;T</code> 阻止对数据的修改，通过可变引用 <code>&amp;mut T</code> 确保独占访问。然而，在实现并发数据结构时，经常需要突破这一限制：比如在只有不可变引用的情况下也能修改内部数据（典型场景是通过锁对象的不可变引用来获取内部数据的可变引用）。为此，Rust 提供了一个底层机制 <strong>UnsafeCell</strong> 来支持<em>内部可变性</em>。</p>
<p><strong>UnsafeCell</strong> 是 Rust 标准库中的一个关键类型，它包装了一个数据，使得即使只有对该容器的不可变引用，也可以通过 UnsafeCell 提供的方法来修改内部的数据。当然，这种内部修改必须在 <code>unsafe</code> 块中进行，因为它突破了编译器的借用检查。很多线程同步原语（例如 <code>Mutex</code>、<code>RwLock</code>，甚至原子类型如 <code>AtomicBool</code> 本身）内部都使用了 UnsafeCell 来绕过编译期的限制，从而允许在并发场景下安全地修改受保护的数据。</p>
<p>基于 UnsafeCell，Rust 标准库封装了几种提供不同程度内部可变性的类型：</p>
<ul>
<li><strong>Cell</strong>：提供最小程度的内部可变性，只能用于复制语义的类型（实现 <code>Copy</code> 的类型）。通过 <code>get</code> 和 <code>set</code> 方法在不违反借用规则的情况下读取或修改内部的值。</li>
<li><strong>RefCell</strong>：提供<em>运行时</em>的借用检查，实现更灵活的内部可变性，可以在运行时确保不可变借用和可变借用的不混淆。但 <code>RefCell</code> 不是线程安全的（不实现 <code>Sync</code>），只能用于单线程场景。</li>
<li><strong>Mutex</strong>：它利用锁机制保证线程安全的内部可变性。Mutex 本质上也是通过 UnsafeCell 允许内部数据可变访问，但同时提供了线程间互斥保护。相应地，Mutex的使用成本较高，需要在多线程间进行加锁和解锁的开销。</li>
</ul>
<p>通过 UnsafeCell 提供的内部可变性支撑，Rust 才能编写出安全的并发数据结构。在这些结构中，我们将看到 UnsafeCell 的身影，它保证了在编译器视角下“不可能”的行为（即在不可变引用下修改数据）在运行时被合理地使用。</p>
<h3>Send 与 Sync：无数据竞争的基石</h3>
<p>Rust 类型系统通过两个重要的<em>标记 trait</em>（Marker Trait）来实现<strong>线程安全</strong>的静态检查，这两个 trait 就是 <strong>Send</strong> 和 <strong>Sync</strong>。它们没有具体的方法，实现这两个 trait 的类型会自动享有编译器的特殊处理，用于标记跨线程的使用安全性：</p>
<ul>
<li><strong>Send</strong> 表示一个类型的所有权可以在线程间安全地传递。如果类型 <code>T</code> 实现了 Send，则 <code>T</code> 或 <code>&amp;mut T</code> 可以从一个线程转移到另一个线程。例如，大部分基础类型和完全由 Send 组成的复合类型都是 Send。如果某个类型内部包含不安全的共享状态且未保护，那它可能不会实现 Send（编译器会自动推导决定，开发者也可手动禁止）。</li>
<li><strong>Sync</strong> 表示一个类型的<strong>不可变引用</strong>可以在线程间安全共享。也就是说，如果 <code>&amp;T</code> 实现了 Sync，则允许多个线程同时拥有对该类型的不可变引用。这要求类型内部的所有可能被并发访问的可变状态都已经被保护（比如用了原子或锁）。大部分基本类型的不可变引用都是 Sync，像 <code>&amp;i32</code> 等是可以多线程共享的。如果某类型不是 Sync，则即使它本身是只读的，也无法同时被多个线程引用（例如 <code>Rc&lt;T&gt;</code> 因为不是线程安全的引用计数，因此 <code>&amp;Rc&lt;T&gt;</code> 也不是 Sync）。</li>
</ul>
<p>Send 和 Sync 这两个标记 trait 是 Rust 保证无数据竞争（Data Race Free）的基石。编译器通过这两个trait禁止不安全的跨线程操作：任何在多个线程间共享或转移的值必须是 Send/Sync 的，否则将无法编译。这种机制在编译期就杜绝了绝大部分数据竞争情况，开发者只有在显式使用 <code>unsafe</code> 绕过时才可能违背这个保证。因此，在实现并发原语时，确保正确实现 Send/Sync（或适当地禁止它们）是非常重要的。Rust 标准库多数类型默认实现 Send/Sync（如果其组成部分实现的话），但像 <code>Rc&lt;T&gt;</code>（非线程安全引用计数）或 <code>RefCell&lt;T&gt;</code>（仅供单线程内部可变性）则没有实现，以防误用。</p>
<h3>操作系统并发原语：原子等待与唤醒</h3>
<p>高效的阻塞式并发离不开操作系统提供的底层同步原语。目前主流的桌面/服务器操作系统（如 Linux、macOS、Windows）都提供了类似的原子等待/唤醒机制，以构建更高级的锁和并发结构。这些机制允许线程在等待某个条件时挂起睡眠，避免忙轮询浪费CPU，并在条件达成时高效地被唤醒。概括来说，有三个最重要的底层操作：</p>
<ul>
<li><strong>wait( &amp;AtomicU32, expected_value )</strong>：当指定的原子变量值等于给定期待值时，让当前线程陷入休眠等待；如果原子值不匹配则立即返回。这个操作通常是原子级的检查并挂起，如 Linux 上的 <code>futex</code> 系统调用 (<code>futex_wait</code>) 或 Windows 上的 <code>WaitOnAddress</code> 等，它们允许用户态线程在内核中高效睡眠，等待变量改变。</li>
<li><strong>wake_one( &amp;AtomicU32 )</strong>：唤醒<strong>一个</strong>在指定原子变量上等待的线程。对应地，Linux futex 提供 <code>futex_wake</code> 可以唤醒等待在同一个地址上的一个线程；Windows 提供 <code>WakeByAddressSingle</code> 等。</li>
<li><strong>wake_all( &amp;AtomicU32 )</strong>：唤醒<strong>所有</strong>在该原子变量上等待的线程。对应 futex 的 <code>wake</code> 可以指定唤醒所有等待的线程，Windows 有 <code>WakeByAddressAll</code> 等。</li>
</ul>
<p>这些原语本质上将<strong>原子变量的变化</strong>和<strong>线程调度</strong>结合起来，实现了类似 “当原子变量处于某值时让线程睡眠，直到变量改变再唤醒” 的机制。这为构建更高级的同步工具奠定了基础：例如 Mutex 在获取不到锁时，可以调用 wait 挂起线程等待锁的原子标志变为可用；当释放锁时，调用 wake_one 唤醒一个等待线程继续争夺锁。相比纯用户态的自旋，这种机制大幅提高了效率，因为等待线程无需占用 CPU。Rust 标准库的实现以及第三方并发库（如 <code>parking_lot</code>）都会利用操作系统提供的此类原语（在 Linux 上通常就是 futex，在其他平台可能用条件变量等模拟）来实现高性能的阻塞同步。</p>
<p>本书的示例实现中多次使用了 <code>atomic-wait</code> crate 来模拟 futex 等行为，以便在稳定版上构建自定义锁。</p>
<h2>自旋锁（SpinLock）</h2>
<p><strong>自旋锁</strong>是一种最简单的锁实现，其特性是当一个线程尝试获取锁而锁已被占用时，该线程不会阻塞睡眠，而是在循环中反复检查锁是否可用（“忙等待”）。自旋锁避免了线程切换的开销，适用于临界区极短、锁持有时间非常小的场景，因为忙等待会浪费CPU时间。如果临界区较长，使用自旋锁会导致CPU空转浪费资源，此时应采用会阻塞线程的锁（如 Mutex）。Rust 标准库并未提供显式的自旋锁类型，但可以使用原子操作很容易地实现一个。书中通过实现一个简化的 SpinLock 展示了原子操作和 RAII 等在并发中的应用：</p>
<ul>
<li><strong>v0：最小化原子标记的实现</strong> – 采用一个原子布尔标志表示锁状态（如 <code>AtomicBool</code>），最初版本只实现了基本的 <code>lock()</code> 和 <code>unlock()</code>。当标志从 <code>false</code> 变为 <code>true</code> 表示获取锁成功。线程获取锁时使用原子交换 (<code>swap</code>) 将标志设为 true，并在获取失败时不断重试（忙等）。该版本功能最小，不负责保护任何数据。</li>
<li><strong>v1：绑定受保护数据</strong> – 将锁和需要保护的数据封装在一起，定义为如 <code>SpinLock&lt;T&gt;</code>，内部持有一个 <code>UnsafeCell&lt;T&gt;</code> 来存储数据。这保证了数据只能通过获取锁后才能访问，提升了数据安全性。接口上提供 <code>lock()</code> 返回一个包裹了 <code>&amp;mut T</code> 的锁守卫类型，以确保在持有锁期间才能访问内部数据。</li>
<li><strong>v2：引入 RAII 实现自动解锁</strong> – 利用 Rust 的 RAII（Resource Acquisition Is Initialization）惯用法，返回一个锁守卫（如 <code>SpinLockGuard</code>），其 <code>Drop</code> 实现中自动调用解锁操作。这样，当锁守卫离开作用域时（不论是正常离开还是因为 panic 等非常路径），都能确保锁被正确释放，不必依赖用户手动调用 <code>unlock()</code>。这一版本显著提高了易用性和安全性，防止了因忘记解锁导致的死锁。</li>
</ul>
<p>通过这三个版本的迭代，我们看到了从<strong>低级原子操作</strong>构建锁的基本过程，以及<strong>RAII 模式</strong>在确保资源释放中的威力。自旋锁虽然简单，但在Rust并发中并不作为公开接口广泛使用，更多是用于底层实现。例如标准库 Mutex 在短暂自旋优化时内部也会自旋尝试锁，以减少进入内核的机会。一般来说，高层代码应尽量使用更高级的 Mutex 而非自旋锁，除非在非常性能敏感且确定临界区极短的场景下才考虑自旋锁。</p>
<h2>一次性通道（One-shot Channel）</h2>
<p><strong>一次性通道</strong>指的是只发送一次消息并被接收一次的通信通道。它可以理解为只能容纳单个消息的生产者-消费者队列，在某些场景下（例如线程初始化后将结果发送给主线程）非常有用。Rust 标准库提供的 <code>std::sync::mpsc</code> 通道是多次发送的通用通道，而一次性通道可以做特别的优化。书中通过多个版本构建了一个一次性通道，以展示<strong>锁与无锁编程</strong>、<strong>内存管理</strong>以及<strong>线程同步</strong>的技巧：</p>
<ul>
<li><strong>v0：基于互斥锁的简单通道</strong> – 首先实现一个通用版本，内部用 <code>Mutex</code> + <code>Condvar</code> 等构造一个能发送任意条消息的通道，然后限制只发送一次。这是最直接的实现，但完全依赖锁，性能较低。</li>
<li><strong>v1：引入 UnsafeCell 与 AtomicBool 去锁化</strong> – 利用内部可变性和原子操作，实现一个无锁的一次性通道。使用 <code>AtomicBool</code> 标志消息是否已被接收，<code>UnsafeCell</code> 存放实际消息数据，实现 send 时设置数据和标志位，recv 时读取数据。通过原子操作保证发送和接收的同步，不再使用锁，从而减少开销。</li>
<li><strong>v2：使用 <code>MaybeUninit&lt;T&gt;</code> 代替 <code>Option&lt;T&gt;</code></strong> – 优化内存布局。由于一次性通道中的消息在发送前不存在，发送后存在，而 Option 构造会在内存中额外存储一个枚举标志，占用空间且可能导致不必要的初始化检查。改用 <code>MaybeUninit</code> 后，只在内存中保留消息所需空间，避免了初始化与未初始化状态的双重判断，减少内存开销。</li>
<li><strong>v3：增加动态检查以提高安全性</strong> – 在发送和接收操作中增加运行时检查，以确保不会出现重复发送、重复接收等误用情况。例如，如果重复调用 send 就立即 panic，防止破坏内部状态。这些检查提升了组件的健壮性。</li>
<li><strong>v4：实现 <code>Drop</code> Trait 以自动清理</strong> – 为通道的发送端和接收端实现 <code>Drop</code>，在对象被销毁时自动清理未被接收的值或通知另一端。这样可以避免内存泄漏，并在接收端丢弃前通知发送端等，提高使用时的正确性。</li>
<li><strong>v5：拆分 Sender 和 Receiver 类型</strong> – 将通道的发送者和接收者角色分开成不同的类型，确保编译层面对各自操作的权限限制。例如 Sender 只能发送不能接收，Receiver 只能接收不能发送。这一版本还通过类型系统防范了一些误用（如接收端克隆等）。</li>
<li><strong>v6：用生命周期替代 Arc，消除引用计数开销</strong> – 之前版本为了让 Sender 和 Receiver 在不同线程中存在，可能使用了 <code>Arc</code> 来共享通道内部状态。然而在一次性通道中，发送和接收两者的作用域其实可以静态地确定（如在线程函数中先发送后接收）。通过引入 Rust 的生命周期参数，将通道限定在创建它的作用域内传递引用，而非通过 Arc，在编译期保证了内存安全的同时，免去了原子引用计数的运行时开销。</li>
<li><strong>v7：使用线程停放（park/unpark）机制，实现阻塞等待</strong> – 先前版本的接收操作可能需要不停查询标志（自旋等待消息就绪），v7 引入了 Rust 标准库的 <code>park()</code>/<code>unpark()</code> 机制：当接收端发现消息尚未准备好时，调用 <code>thread::park</code> 挂起当前线程；发送端在放入消息后调用接收线程的句柄的 <code>unpark</code> 来唤醒它。这种做法避免了忙等占用 CPU，转而让线程阻塞等待，提升效率。同时通过去掉先前用于轮询的如 <code>is_ready</code> 标志，使误用的可能性进一步降低。</li>
<li><strong>v8：使用 PhantomData 防止跨线程误用</strong> – 最终版本利用 PhantomData 标记保证类型安全。通过在 Sender/Receiver 中加入带有生命周期或非 Send/Sync 标记的 PhantomData，编译器可以禁止用户将 Sender/Receiver 在不安全的方式跨线程使用。例如，可以防止 Sender/Receiver 被不恰当地发送到其他线程导致未定义行为。PhantomData 不占用空间，但可以在类型系统中充当标记，确保一次性通道的用法受到静态约束，彻底封堵错误用法。</li>
</ul>
<p>经过上述多次迭代，一次性通道从最初的锁实现逐步演进为<strong>无锁</strong>且<strong>高效</strong>的实现，并且在类型系统层面保证了安全使用。这个过程展示了 Rust 并发编程从<strong>简单正确</strong>到<strong>性能优化</strong>再到<strong>类型安全</strong>的完整思路。在实际工程中，类似的思想被用于实现各种高性能通道和同步数据结构，如 Rust 标准库和 <code>crossbeam</code> 库中的通道实现等。</p>
<h2>Arc 原子引用计数智能指针</h2>
<p><strong>Arc</strong>（Atomically Reference Counted）是 Rust 标准库提供的多线程引用计数智能指针类型，允许在多个线程间共享所有权。通过原子增减引用计数，Arc 能确保即使多个线程同时持有指针，底层数据也只会在最后一个指针被释放后销毁。书中通过实现一个简化版的 Arc 及其改进版本，解释了 Arc 的工作原理和演进：</p>
<ul>
<li><strong>v0：基础版本，单原子计数的多所有权</strong> – 使用一个原子计数器（如 <code>AtomicUsize</code>）记录引用数，每次克隆 Arc 增加计数，删除时减少计数，减到零时销毁数据。这个版本实现了跨线程的多所有权共享。然而，此时 Arc 依然缺乏两方面能力：<strong>内部可变性</strong>（无法获得可变引用去修改内部数据），以及<strong>防止循环引用</strong>（如果两个 Arc 相互引用会导致计数永不为零，内存泄漏）。</li>
<li><strong>v1：引入独占借用 <code>&amp;mut Arc&lt;T&gt;</code> 及内部锁，实现可变访问</strong> – 标准库的 Arc 并不支持在存在多个引用时直接获得内部数据的可变引用，但本书尝试了一个改进思路：当我们持有 Arc 的独占引用 <code>&amp;mut Arc&lt;T&gt;</code> 时（意味着当前线程拥有 Arc 对象的独占管理权，没有其他 Arc 克隆存在），可以安全地修改内部数据。为此，v1 在 Arc 内部增加了一把原子锁或标志，用于在独占修改内部数据时临时阻止其他线程的访问。从而提供一个安全的 <code>get_mut()</code> 方法：当检测到当前只有一个强引用且锁定成功时，返回内部数据的可变引用。这实现了<strong>条件下的内部可变性</strong>：在不破坏线程安全的前提下，允许独占 Arc 进行内部修改。</li>
<li><strong>v2：加入弱引用（Weak）以解决循环引用问题</strong> – 弱引用是 Arc 非常重要的补充。Weak 指针不计入强引用计数，因此不会阻止数据被回收。v2 引入 <code>Weak&lt;T&gt;</code> 类型，当需要引用可能形成环的对象时，用 Weak 替代 Arc 中的一部分引用关系。这样即使对象之间形成环，相互的弱引用不会使强计数增加到无法归零，一旦没有强引用，数据可以正确释放。Weak 指针需要通过 <code>upgrade()</code> 尝试提升为 Arc 使用，在数据已被释放时会得到 <code>None</code>，从而避免了悬垂引用。</li>
<li><strong>v3：区分强引用计数和弱引用计数，优化无循环场景性能</strong> – 在 v2 基础上，v3 将引用计数拆分为两个独立的计数：强引用计数和弱引用计数。强计数为 0 时表示数据可销毁，但实际销毁要等弱计数也为 0（表示没有悬挂的 Weak 指针）。通过分离计数，Arc 在没有 Weak 场景下也不需要去增减弱计数，减少了不必要的开销。只有在存在 Weak 的情况下才维护弱计数。这一优化与实际 Rust 标准库 Arc 的实现一致：标准库 Arc 在内部使用两个计数（一个原子usize记录强引用数，一个记录弱引用数），并据此管理内存回收。</li>
</ul>
<p>Arc 智能指针的实现展示了<strong>原子操作在内存管理中的应用</strong>。由于使用了原子引用计数，Arc 的克隆、释放可以由多线程安全地执行。此外，引入 Weak 指针解决循环引用、双计数优化性能等设计，都是实际工业级智能指针需要考虑的问题。Rust 标准库的 <code>Arc&lt;T&gt;</code> 正是采用类似机制，保证了线程安全又兼顾性能。在并发编程实践中，Arc 被广泛用来共享只读数据或通过内部加锁来共享可变数据（比如 <code>Arc&lt;Mutex&lt;T&gt;&gt;</code> 组合），理解其实现对深入掌握 Rust 的内存管理和线程安全非常有帮助。</p>
<h2>互斥锁（Mutex）</h2>
<p>**Mutex（互斥锁）**是一种常用的线程同步原语，用于保证同一时刻只有一个线程可以访问某份数据。Rust 提供了 <code>std::sync::Mutex&lt;T&gt;</code> 实现安全且高效的互斥锁，在内部结合了自旋和操作系统锁机制。书中构建了一个简化的 Mutex 实现，通过多个版本逐步逼近实际库中的优化：</p>
<ul>
<li><strong>v1：基本可用的互斥锁</strong> – 首先利用 <code>atomic-wait</code> 等底层原语实现一个基本的 Mutex。内部使用一个原子标志表示锁状态，采用类似自旋锁的方式尝试获取锁，如果失败则调用底层 <code>wait()</code> 将线程阻塞，等待锁可用时被唤醒。同时运用 RAII 思想，实现一个锁守卫，在 <code>Drop</code> 中释放锁。这一版本已经具备 Mutex 的核心功能：能阻塞等待，避免忙等；用 RAII 确保解锁。</li>
<li><strong>v2：避免不必要的系统调用</strong> – 优化 Mutex 在无竞争情况下的性能。设想如果锁空闲，线程应尽量避免调用内核提供的 <code>wait</code> 等系统调用，因为进入内核态有开销。v2 改进在于：获取锁时先用原子操作快速检查和设置锁标志，如果成功则直接进入临界区，不需要任何系统调用；只有当锁已被占用且需要等待时，才调用 <code>wait</code> 进入睡眠。同样，释放锁时如果发现没有任何线程在等待（可通过一个等待计数或标志判断），则不调用 <code>wake_one</code>，避免无谓的系统调用开销。这个版本通过“先检查再睡眠”的策略，提高了低争用情况下的性能。</li>
<li><strong>v3：加入短暂自旋进一步减少系统调用</strong> – 在锁竞争发生时，立即阻塞线程进入内核可能不是最优选择。如果锁很快就会被释放，先忙等一小段时间可避免不必要的上下文切换。v3 引入了<strong>自旋等待</strong>的优化：当发现锁被占用时，不马上调用 <code>wait</code> 阻塞线程，而是让线程自旋循环尝试获取锁若干次（或等待若干纳秒）。如果在短暂自旋期间锁被释放获取成功，就无需进入内核；只有超过自旋时限仍未成功，才执行阻塞等待。这个优化利用了临界区很短这种常见情况，大幅减少了内核调度开销。在实际实现中，Rust 标准库和 <code>parking_lot</code> 库的 Mutex 都采用了类似的自旋-休眠结合策略，以平衡延迟和吞吐量。</li>
</ul>
<p>经过这些改进，Mutex 实现已经非常接近真实场景：在无竞争时开销极低（原子操作和用户态逻辑），在有轻微竞争时通过自旋避免陷入内核，在竞争激烈或锁长时间被持有时再进入内核等待，从而综合优化不同场景下的性能。值得一提的是，Rust 标准库的 Mutex 实际上在底层是调用操作系统的原生实现（例如 Windows 临界区，pthread mutex等），而社区提供的 <code>parking_lot</code> crate 则使用了自定义的高性能实现（正是类似书中描述的策略）。理解这些机制有助于选择和使用锁，以及调优并发性能。</p>
<h2>条件变量（Condvar）</h2>
<p><strong>条件变量</strong>是一种配合互斥锁使用的同步原语，用于线程间等待和通知机制。一个典型的 Condvar 场景是：线程 A 获取锁并检查某个条件，如果条件不满足则在条件变量上等待（这会释放锁并挂起线程A）；另一个线程B稍后满足条件后获取同一把锁，改变条件并通知唤醒条件变量，线程A被唤醒后重新获取锁继续执行。Rust 提供了 <code>std::sync::Condvar</code> 来实现这一机制。书中通过两步实现了 Condvar 的核心原理并进行了优化：</p>
<ul>
<li><strong>v1：利用 atomic-wait 实现等待/唤醒</strong> – 条件变量需要将线程挂起和唤醒与某个共享条件相结合。v1 实现中，每个 Condvar 内部维护一个原子计数或标志，当线程调用 <code>wait()</code> 时，先解锁关联的 Mutex，然后调用 <code>atomic_wait</code> 在该原子上休眠等待。另一线程调用 <code>notify_one</code> 或 <code>notify_all</code> 时，对同一个原子值执行修改并调用 <code>wake_one</code> 或 <code>wake_all</code> 来唤醒等待线程。这样利用前述操作系统原语，就实现了条件变量的等待和唤醒机制。需要注意唤醒后线程会重新尝试获取最初的Mutex锁，以恢复对共享状态的保护（这一过程通常由Condvar实现封装好）。</li>
<li><strong>v2：增加等待者计数避免无效唤醒</strong> – 为了优化唤醒通知的性能，v2 在 Condvar 内部引入了一个原子计数 <code>num_waiters</code>，记录当前有多少线程在此 Condvar 上等待。这样，当调用 <code>notify_one/all</code> 时，可以先检查如果没有等待者就直接返回，避免进行系统调用唤醒。同理，在等待时也增加和减少这个计数。这个简单的计数避免了无谓的唤醒操作调用（例如没有线程在等待却调用了唤醒系统调用）。实际中，大部分条件变量实现都会维护类似的状态来提升效率。Rust 标准库的 <code>Condvar</code> 在具体实现上依赖于系统提供的条件变量（如 pthread_cond_t），但原理一致。</li>
</ul>
<p>Condvar 的实现难点在于要安全地结合互斥锁使用，以及防止经典的“丢失信号”问题（即信号发送与等待错过时机导致永久沉睡）。通过精心设计的顺序（例如在 Mutex 解锁和 wait 挂起之间避免竞争）以及必要的重试循环（被唤醒后通常需要再次检查条件），可以确保 Condvar 的正确性。这部分内容体现了操作系统原语与高级并发抽象之间的结合：Rust 的实现隐藏了许多细节，但理解它有助于我们正确使用 Condvar（例如明白必须在循环中等待条件、防止虚假唤醒等）。</p>
<h2>读写锁（RwLock）</h2>
<p><strong>读写锁</strong>允许多个读者并行地读取数据，但在有写者持有锁时所有读者都被阻止。这样在读多写少的场景下能提高并发性能。Rust 提供了 <code>std::sync::RwLock&lt;T&gt;</code> 来实现读写锁。与 Mutex 类似，RwLock 在实现上也可以结合自旋和操作系统原语，并需考虑读者与写者的公平性。书中实现的简易 RwLock 经过了三步演进，着重解决写者可能的饥饿问题：</p>
<ul>
<li><strong>v1：核心读写锁语义</strong> – 初始版本使用 <code>atomic-wait</code> 等机制和 RAII，支持多个读者或单个写者的互斥。具体来说，可用一个原子计数来表示锁状态：例如计数为非负时表示当前有该数目的读者持锁，计数为 -1 表示有写者持锁。实现 <code>read_lock</code> 时尝试将计数递增（若当前不是写模式），<code>write_lock</code> 则尝试将计数从0变为 -1 来独占。如果操作失败则调用 wait 挂起等待。当持锁线程释放锁时，更新计数并根据情况调用 wake_one/all 唤醒等待的线程。这一版本实现了基本的 RwLock 功能。</li>
<li><strong>v2：增加独立的写者唤醒计数</strong> – 为了避免写线程在竞争中空转，v2 引入了一个单独的 <code>writer_wake_counter</code> 或标志。因为读锁可能同时唤醒多个等待的读者，而写者通常只需单独唤醒。当一个写线程在等待时，如果持续有读锁进来，它可能长时间得不到机会。通过维护一个专门针对写者的等待计数或标志，释放锁时如果发现有写者在等待，可以更有针对性地唤醒写线程。这避免了写线程无谓地循环等待所有读者离开锁。</li>
<li><strong>v3：使用奇偶数编码巧妙解决写者饥饿</strong> – 最终版本采取了一个巧妙的方案：利用原子计数的<strong>最低位或奇偶性</strong>来标记写者意图，从而平衡读/写公平性。一种常见做法是，将计数的某一位作为“有写者等待”的标记。当有写线程等待时，设置该标记使新的读锁请求被延迟（即不再允许新的读者获取锁），从而逐步清空现有读者并最终让写者上锁。一旦写者获取锁并释放后，再清除标记放行读者。这种通过奇偶位（或其他位）区分读写状态的编码方式，可以防止写者一直被源源不断的读者饿死，又不会过度牺牲读性能。实际工程中，不少读写锁实现（包括 Rust 标准库和 <code>parking_lot</code> 的 RwLock）都采用类似思路：既避免写者饥饿，又保持尽可能高的读并发。</li>
</ul>
<p>读写锁相对于互斥锁有更复杂的状态管理，需要处理多读者、单写者之间的切换，以及公平性策略。通过上述改进，我们既实现了基本功能，又确保在竞争激烈时系统不会偏科（比如始终偏向读者或写者）。在 Rust 标准库中，<code>RwLock</code> 使用系统的 pthread_rwlock 或相似机制实现，而更优化的方案如 <code>parking_lot::RwLock</code> 则采用自己的算法，和书中描述的策略不谋而合。对于开发者来说，了解这些实现细节能够帮助理解 <code>RwLock</code> 的特性（例如为什么有时写锁可能拿不到是因为读锁频繁进来等），从而做出更好的并发设计决策。</p>
<h2>总结</h2>
<p>通过对《Rust Atomics and Locks》全书内容的梳理，我们系统了解了 Rust 并发编程从<strong>底层原理</strong>到<strong>高级抽象</strong>的一系列知识点：</p>
<ul>
<li>原子操作及其内存序保证构成了无锁并发的基础，不同架构对其支持有所区别，理解硬件内存模型有助于写出正确高效的原子操作代码。</li>
<li>内存模型中的 happens-before 等概念和五种内存顺序提供了分析并发行为的工具，Rust 的类型系统和内存屏障一起确保了跨线程操作的可见性和有序性。</li>
<li>UnsafeCell 和 Send/Sync 等机制是 Rust 提供的编译期保障，既允许必要的“不安全”修改又保证线程安全边界分明，使我们能放心地构建并使用并发原语。</li>
<li>操作系统提供的 futex 等原语连接了用户态原子操作和内核调度，Rust 并发库巧妙地加以利用，实现既省CPU又高吞吐的锁和阻塞结构。</li>
<li>通过几个典型并发原语（自旋锁、通道、Arc、Mutex、Condvar、RwLock）的实现迭代，我们看到如何将上述原理应用于实际：从简单正确的初始版本，不断优化以提高性能和安全性，最终达到与工业级实现类似的效果。这些案例也强调了概念之间的联系——如自旋锁和 Mutex 体现了忙等与阻塞两种等待策略，Arc 的内部可变性依赖 UnsafeCell 而其计数安全依赖原子操作，条件变量依赖 Mutex 配合以及操作系统原语等等。</li>
</ul>
<p>Rust 并发的设计追求“<strong>无数据竞争</strong>”的同时，通过类型系统和底层优化取得了很好平衡。《Rust Atomics and Locks》深入浅出地展示了这一领域的方方面面。本笔记希望帮助读者快速回顾书中内容，在需要时可将各章节要点作为知识索引参考。相信将来面对具体并发编程挑战时，这些基础理论和实现经验将成为宝贵的指导，帮助我们写出健壮高效安全的 Rust 并发代码。</p>
<p>Happy Coding! Peace~</p>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨手写一个 RwLock</title>
      <link>https://hedon.top/blog/rust-action-rwlock/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-rwlock/</guid>
      <pubDate>Wed, 11 Jun 2025 08:56:08 GMT</pubDate>
      <description>通过三个渐进式版本深入理解 RwLock 的实现原理，从基础功能到性能优化再到公平性保证，掌握原子操作、内存顺序、条件变量等并发编程核心技术，并学会解决写饥饿等实际问题。</description>
      <category>rust</category><category>并发编程</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<p>系列文章：</p>
<ul>
<li><a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a></li>
<li><a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a></li>
<li><a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a></li>
<li><a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a></li>
<li><a href="/blog/rust-action-arc/">Rust 实战丨手写一个 Arc</a></li>
<li><a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a></li>
<li><a href="/blog/rust-action-mutex/">Rust 实战丨手写一个 Mutex</a></li>
<li><a href="/blog/rust-action-condvar/">Rust 实战丨手写一个 Condvar</a></li>
<li><a href="/blog/rust-action-rwlock/">Rust 实战丨手写一个 RwLock</a> 👈 本篇</li>
</ul>
<hr>
<p>本篇我们继续参考 <a href="https://marabos.nl/atomics/building-locks.html#reader-writer-lock">Rust Atomics and Locks</a> 一书来手写一个读写锁：<em><em>RwLock</em></em>。这也是这本书中的最后一个实战案例了，很幸运我们能坚持到现在，为自己鼓掌 👏🏻 ！</p>
<p>在本章开始之前，我们假设你已经：</p>
<ol>
<li>熟悉并理解 Rust 的各种原子操作。</li>
<li>阅读过 <a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a> 和 <a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a>，并理解内存顺序和内存屏障的原理和使用方法。</li>
<li>理解 Rust <code>UnsafeCell&lt;T&gt;</code> 提供的内部可变性允许我们在持有共享引用 <code>&amp;</code> 的时候可以对数据进行修改。</li>
<li>阅读过 <a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a> 并了解 <a href="https://crates.io/crates/atomic-wait">atomic-wait</a> crate 中 <code>wait/wake_one/wake_all</code> 的适用场景和使用方法。</li>
</ol>
<h2>读完本篇你能学到什么</h2>
<ol>
<li><p>掌握读写锁的核心语义和基本原理</p>
<ul>
<li><p>理解读锁可共享、写锁需独占的设计哲学</p>
</li>
<li><p>学会使用原子操作和 Guard 模式实现线程安全</p>
</li>
<li><p>掌握 <code>UnsafeCell</code> 实现内部可变性的技巧</p>
</li>
</ul>
</li>
<li><p>解决写线程无用循环问题</p>
<ul>
<li><p>识别并发程序中的性能瓶颈</p>
</li>
<li><p>学会通过独立原子变量避免竞争</p>
</li>
<li><p>理解 <code>atomic-wait</code> 的高效使用方式</p>
</li>
</ul>
</li>
<li><p>巧妙解决写饥饿难题</p>
<ul>
<li><p>掌握奇偶数编码表达复杂状态的艺术</p>
</li>
<li><p>理解并发系统中性能与公平性的权衡</p>
</li>
<li><p>学会设计状态机解决复杂同步问题</p>
</li>
</ul>
</li>
</ol>
<h2>v1：基础实现</h2>
<p>按照惯例，我们先来思考数据结构该如何定义。所谓读写锁，即读锁与读锁之间是可以共存，而读锁与写锁、写锁与写锁之间是互斥的。</p>
<ol>
<li>我们需要一个变量 <code>state</code> 来表示当前是否有读线程、有多少读线程、是否有写线程，这里可以用 <code>state</code> 的值来表示有多少读线程，当其为最大值时，表示有写线程。因为 <a href="https://crates.io/crates/atomic-wait">atomic-wait</a> 仅支持 AtomicU32，所以这里我们的数据类型也定义为 AtomicU32.</li>
<li>抢到写锁的时候，是可以对数据 <code>value</code> 进行修改的，但因我们只持有 <code>&amp;RwLock</code> 共享引用，为了修改数据，我们依旧需要依赖 <code>UnsafeCell</code> 提供内部可变性。</li>
<li>我们需要 2 个守卫类型，分别作为读守卫和写守卫，它们都持有 RwLock 的引用。<ul>
<li>读守卫只能获取共享引用，所以我们为其实现 <code>Deref</code> trait。</li>
<li>写守卫可以获取独占引用，所以我们为其实现 <code>Deref</code> 和 <code>DerefMut</code> trait。</li>
</ul>
</li>
<li>另外，因为读锁是共享，写锁是独占，所以我们在为 <code>RwLock&lt;T&gt;</code> 实现 <code>Sync</code> trait 的时候，要求 <code>T</code> 必须满足 <code>Sync+Send</code>。</li>
</ol>
<p>综上，我们可以定出以下的数据结构：</p>
<pre><code class="language-rust">pub struct RwLock&lt;T&gt; {
    /// The number of read locks, u32::MAX if write locked.
    state: AtomicU32,
    value: UnsafeCell&lt;T&gt;,
}

pub struct ReadGuard&lt;&#39;a, T&gt; {
    rwlock: &amp;&#39;a RwLock&lt;T&gt;,
}

pub struct WriteGuard&lt;&#39;a, T&gt; {
    rwlock: &amp;&#39;a RwLock&lt;T&gt;,
}

unsafe impl&lt;T&gt; Sync for RwLock&lt;T&gt; where T: Sync + Send {}

impl&lt;T&gt; Deref for WriteGuard&lt;&#39;_, T&gt; {
    type Target = T;
    fn deref(&amp;self) -&gt; &amp;Self::Target {
        unsafe { &amp;*self.rwlock.value.get() }
    }
}

impl&lt;T&gt; DerefMut for WriteGuard&lt;&#39;_, T&gt; {
    fn deref_mut(&amp;mut self) -&gt; &amp;mut Self::Target {
        unsafe { &amp;mut *self.rwlock.value.get() }
    }
}

impl&lt;T&gt; Deref for ReadGuard&lt;&#39;_, T&gt; {
    type Target = T;
    fn deref(&amp;self) -&gt; &amp;Self::Target {
        unsafe { &amp;*self.rwlock.value.get() }
    }
}

impl&lt;T&gt; RwLock&lt;T&gt; {
    pub fn new(value: T) -&gt; Self {
        Self {
            state: AtomicU32::new(0),
            value: UnsafeCell::new(value),
        }
    }
}
</code></pre>
<p>OK，现在我们需要来实现最重要的功能：上锁和解锁。</p>
<p>我们先来看上读锁和解锁：当 <code>state != u32::MAX</code> 的时候，我们就可以尝试对 <code>state</code> 进行加 1 抢占读锁，在销毁 <code>ReadGuard</code> 的时候，我们只需要对 <code>state</code> 进行减 1，当最后一个读锁释放的时候，还需要唤醒一个潜在的阻塞中的写线程。且这里，加 1 和减 1 需要用一对 Acquire 和 Release 建立 happens-before 关系。</p>
<p>代码如下：</p>
<pre><code class="language-rust">impl&lt;T&gt; RwLock&lt;T&gt; {
    // ...

    pub fn read(&amp;self) -&gt; ReadGuard&lt;&#39;_, T&gt; {
        let mut s = self.state.load(Relaxed);
        loop {
            if s &lt; u32::MAX {
                assert!(s != u32::MAX - 1, &quot;too many readers&quot;);
              	// 尝试上读锁
                match self.state.compare_exchange_weak(s, s + 1, Acquire, Relaxed) {
                    Ok(_) =&gt; return ReadGuard { rwlock: self },
                    Err(e) =&gt; s = e,
                }
            }
            if s == u32::MAX {	// 如果已经上了写锁，就陷入等待
                wait(&amp;self.state, u32::MAX);
                s = self.state.load(Relaxed)
            }
        }
    }
}

impl&lt;T&gt; Drop for ReadGuard&lt;&#39;_, T&gt; {
    fn drop(&amp;mut self) {
        if self.rwlock.state.fetch_sub(1, Release) == 1 { // 最后一个读锁释放
            wake_one(&amp;self.rwlock.state);		// 唤醒一个潜在的写线程
        }
    }
}
</code></pre>
<p>现在来看上写锁和解锁：我们只需要尝试将 <code>state</code> 置为 <code>u32::MAX</code>，如果成功了，则说明上写锁成功，否则需要陷入等待。在解锁的时候，只需要将 <code>state</code> 置为 <code>0</code>，并唤醒所有潜在的阻塞的读线程和写线程。同样，这里也需要一对 Acquire 和 Release 建立 happens-before 关系。</p>
<p>代码如下：</p>
<pre><code class="language-rust">impl&lt;T&gt; RwLock&lt;T&gt; {
    // ...

    pub fn write(&amp;self) -&gt; WriteGuard&lt;&#39;_, T&gt; {
      	// 尝试置为 u32::MAX
        while let Err(s) = self.state.compare_exchange(0, u32::MAX, Acquire, Relaxed) {
            wait(&amp;self.state, s); // 陷入阻塞
        }
        WriteGuard { rwlock: self }
    }
}

impl&lt;T&gt; Drop for WriteGuard&lt;&#39;_, T&gt; {
    fn drop(&amp;mut self) {
        self.rwlock.state.store(0, Release);
        wake_all(&amp;self.rwlock.state); // 唤醒所有阻塞的写线程和读线程
    }
}
</code></pre>
<p>到这里，我们一个基本可用的 <code>RwLock</code> 就实现完毕了，你可以参考下图进行辅助理解：</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant A as 线程A (读者)
    participant B as 线程B (读者)
    participant C as 线程C (写者)
    participant State as RwLock State
    participant Wait as Wait/Wake 机制

    Note over State: state = 0 (未锁定)

    A-&gt;&gt;State: read() - compare_exchange_weak(0, 1)
    Note over State: state: 0 → 1
    State--&gt;&gt;A: 成功，返回 ReadGuard

    B-&gt;&gt;State: read() - compare_exchange_weak(1, 2)
    Note over State: state: 1 → 2 (两个读锁)
    State--&gt;&gt;B: 成功，返回 ReadGuard

    Note over A, B: 💡 读锁可以并发持有

    C-&gt;&gt;State: write() - compare_exchange(0, u32::MAX)
    State--&gt;&gt;C: 失败 (state=2, 不等于0)
    C-&gt;&gt;Wait: wait(&amp;state, 2)
    Note over C: 🔒 写线程进入等待状态

    Note over A: 线程A完成读操作
    A-&gt;&gt;State: drop(ReadGuard) - fetch_sub(1)
    Note over State: state: 2 → 1
    Note over A: 不是最后一个读锁，不唤醒

    Note over B: 线程B完成读操作
    B-&gt;&gt;State: drop(ReadGuard) - fetch_sub(1)
    Note over State: state: 1 → 0
    Note over B: ✨ 最后一个读锁释放！
    B-&gt;&gt;Wait: wake_one(&amp;state)

    Note over C: 🎯 写线程被唤醒
    C-&gt;&gt;State: write() - compare_exchange(0, u32::MAX)
    Note over State: state: 0 → u32::MAX
    State--&gt;&gt;C: 成功，返回 WriteGuard

    Note over C: 线程C执行写操作...

    C-&gt;&gt;State: drop(WriteGuard) - store(0)
    Note over State: state: u32::MAX → 0
    C-&gt;&gt;Wait: wake_all(&amp;state)
    Note over Wait: 📢 唤醒所有等待的线程
</code></pre>
<p>我们来写下单元测试验证功能是否正确：</p>
<pre><code class="language-rust">#[cfg(test)]
mod tests {
    use std::{
        thread::{self, sleep},
        time::Duration,
    };

    use super::*;

    #[test]
    fn one_thread_should_work() {
        let rwl = RwLock::new(vec![1, 2, 3]);

        let r1 = rwl.read();
        assert_eq!(r1.len(), 3);

        let r2 = rwl.read();
        assert_eq!(r2.len(), 3);

        drop(r1);
        drop(r2);

        let mut w = rwl.write();
        w.push(4);
        drop(w);

        let r3 = rwl.read();
        assert_eq!(r3.len(), 4);
    }

    #[test]
    fn cross_thread_should_work() {
        let rwl = RwLock::new(vec![]);

        thread::scope(|s| {
            s.spawn(|| {
                let mut w = rwl.write();
                w.push(1);
                w.push(2);
            });

            s.spawn(|| {
                sleep(Duration::from_millis(100));
                let r1 = rwl.read();
                println!(&quot;{:?}&quot;, *r1);
                let r2 = rwl.read();
                println!(&quot;{:?}&quot;, *r2);
            });
        })
    }
}
</code></pre>
<p>执行结果是通过的：</p>
<pre><code class="language-shell">running 2 tests
test rwlock::tests::one_thread_should_work ... ok
test rwlock::tests::cross_thread_should_work ... ok
</code></pre>
<h2>v2：避免写线程无用循环</h2>
<pre><code class="language-rust">impl&lt;T&gt; RwLock&lt;T&gt; {
    // ...

    pub fn write(&amp;self) -&gt; WriteGuard&lt;&#39;_, T&gt; {
        while let Err(s) = self.state.compare_exchange(0, u32::MAX, Acquire, Relaxed) {
            wait(&amp;self.state, s);
        }
        WriteGuard { rwlock: self }
    }
}
</code></pre>
<p>观察一下我们上个版本的 <code>write()</code> 实现，这里可能会出现一种情况：当上读锁非常频繁的时候，<code>state</code> 的值是一直在变化的，这个时候，<code>wait(&amp;self.state, s)</code> 就有可能因为 <code>state</code> 的值发生了变化，不等于 <code>s</code> 了，就不会陷入等待，而是继续循环尝试将 <code>state</code> 从 <code>0</code> 置为 <code>u32::MAX</code>。这时如果上读锁非常频繁的话，那这里会一直尝试获取，且是无意义的尝试。</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant W as 写线程
    participant R1 as 读线程1
    participant R2 as 读线程2
    participant State as RwLock State
    participant Wait as Wait 机制

    Note over State: state = 1 (有一个读锁)

    W-&gt;&gt;State: write() - compare_exchange(0, u32::MAX)
    State--&gt;&gt;W: 失败，返回 s=1

    Note over W: 准备调用 wait(&amp;state, 1)

    R1-&gt;&gt;State: drop(ReadGuard) - fetch_sub(1)
    Note over State: state: 1 → 0 ⚡️ (在 wait 调用前状态就变了！)

    W-&gt;&gt;Wait: wait(&amp;state, 1)
    Note over Wait: ❌ 当前 state=0，不等于期望值1，立即返回！
    Wait--&gt;&gt;W: 立即返回，没有真正等待

    Note over R2: 💨 新读线程抢在 compare_exchange 前获取锁！
    R2-&gt;&gt;State: read() - compare_exchange_weak(0, 1)
    Note over State: state: 0 → 1

    Note over W: 🔄 进入下一轮循环
    W-&gt;&gt;State: compare_exchange(0, u32::MAX)

    State--&gt;&gt;W: 失败，返回 s=1 (锁又被抢了！)

    Note over W: 又准备调用 wait(&amp;state, 1)

    R2-&gt;&gt;State: drop(ReadGuard) - fetch_sub(1)
    Note over State: state: 1 → 0 ⚡️ (又在 wait 前变化了！)

    W-&gt;&gt;Wait: wait(&amp;state, 1)
    Wait--&gt;&gt;W: 又是立即返回！

    Note over W: 🔄 无用循环开始...
    Note over W: ⚠️ 写线程永远无法真正进入等待状态！
    Note over W: 💀 CPU 被白白消耗在无效的重试上

    Note over State: 🎯 问题核心：wait 调用前 state 总是被读锁改变
    Note over State: 💡 解决：独立的 writer_wake_counter 避免这种竞争
</code></pre>
<p>这里问题的根源在于 <strong><code>read()</code> 和 <code>write()</code> 监听的都是同一个原子变量 <code>state</code></strong>。</p>
<p>解决这个问题的方案之一，就是可以另外加一个原子变量，专门给 <code>write()</code>，从而与 <code>read()</code> 区分开来，避免互相影响，从而造成 <code>write()</code> 时产生无用循环。</p>
<p>我们在 <code>RwLock</code> 中新增一个属性：<code>writer_wake_counter</code>，用于唤醒抢占写锁的线程。</p>
<pre><code class="language-rust">pub struct RwLock&lt;T&gt; {
    /// The number of read locks, u32::MAX if write locked.
    state: AtomicU32,
    /// Incremented to wake up waiters.
    writer_wake_counter: AtomicU32,
    value: UnsafeCell&lt;T&gt;,
}

impl&lt;T&gt; RwLock&lt;T&gt; {
    pub fn new(value: T) -&gt; Self {
        Self {
            state: AtomicU32::new(0),
            writer_wake_counter: AtomicU32::new(0),
            value: UnsafeCell::new(value),
        }
    }

  	// ...
}
</code></pre>
<p>这个时候：</p>
<ol>
<li>我们在 <code>write()</code> 时没抢到锁，就不去 <code>wait(&amp;state)</code> 了，而是 <code>wait(&amp;writer_wake_counter)</code>。</li>
<li>最后一个 <code>ReadGuard</code> 在 drop 的时候，我们修改 <code>writer_wake_counter</code> 的值，并唤醒一个潜在的阻塞的写线程。</li>
<li><code>WriteGuard</code> 的时候，不仅要唤醒所有阻塞的读线程，还要唤醒一个潜在的阻塞的写线程。</li>
</ol>
<pre><code class="language-rust">impl&lt;T&gt; RwLock&lt;T&gt; {
    // ...

    pub fn write(&amp;self) -&gt; WriteGuard&lt;&#39;_, T&gt; {
        while self
            .state
            .compare_exchange(0, u32::MAX, Acquire, Relaxed) // 尝试抢锁
            .is_err()
        {
          	// 失败的话取出 writer_wake_counter，供待会 `wait` 使用。
            let w = self.writer_wake_counter.load(Acquire);
            if self.state.load(Relaxed) != 0 { // 再次检查 state，如果为 0，就不阻塞了，再次尝试抢锁
                wait(&amp;self.writer_wake_counter, w); // 否则陷入等待
            }
        }
        WriteGuard { rwlock: self } // 抢锁成功，返回守卫对象
    }
}

impl&lt;T&gt; Drop for ReadGuard&lt;&#39;_, T&gt; {
    fn drop(&amp;mut self) {
        if self.rwlock.state.fetch_sub(1, Release) == 1 {
            self.rwlock.writer_wake_counter.fetch_add(1, Release);
            wake_one(&amp;self.rwlock.writer_wake_counter);
        }
    }
}

impl&lt;T&gt; Drop for WriteGuard&lt;&#39;_, T&gt; {
    fn drop(&amp;mut self) {
        self.rwlock.state.store(0, Release);
        self.rwlock.writer_wake_counter.fetch_sub(1, Release);
        wake_one(&amp;self.rwlock.writer_wake_counter);  // 唤醒一个写线程
        wake_all(&amp;self.rwlock.state);	// 唤醒所有的读线程
    }
}
</code></pre>
<p>通过这个简单的优化，我们就可以避免写线程因为 <code>state</code> 的频繁变化而产生无用循环了。再次运行之前的测试用例，顺利通过的话就没有问题啦！</p>
<h2>v3：避免写饥饿</h2>
<p>这个版本我们来做最后一个可尝试优化：<strong>避免写饥饿</strong>。</p>
<p>在之前的实现中，只要没上锁，或者上的是读锁，那么就可以一直不断地上读锁。而只有当没有任何读线程和写线程存在的时候，才可以上写锁。这就非常容易造成写饥饿了，因为那些后来的读线程依旧可以成功上读锁，而写线程抢到锁的条件太苛刻了。</p>
<p>为了解决这个问题，我们可以修改一下 <code>state</code> 的定义：</p>
<ol>
<li>当 <code>state</code> 为 <code>0</code> 的时候，说明没上锁。</li>
<li>当 <code>state</code> 为 <code>u32::MAX</code> 的时候，说明上了写锁。</li>
<li>当 <code>state</code> 为非 0 的偶数的时候，说明上了读锁，<strong>且没有阻塞中的写锁，这个时候可以继续上读锁</strong>。</li>
<li>当 <code>state</code> 为其他奇数的时候，说明这个时候有阻塞中的写锁，<strong>后面来的读锁也需要阻塞</strong>。</li>
</ol>
<pre><code class="language-rust">pub struct RwLock&lt;T&gt; {
    /// 读线程的数量 *2，当有阻塞中的写线程的时候 +1
  	/// 如果 state=u32::MAX，说上了写锁。
  	///
  	/// 这意味着，当 state 是偶数的时候，可以继续上读锁，
  	/// 但是，如果 state 是奇数的话，上读锁会被阻塞。
    state: AtomicU32,
    /// Incremented to wake up waiters.
    writer_wake_counter: AtomicU32,
    value: UnsafeCell&lt;T&gt;,
}
</code></pre>
<p>通过这样的调整，我们就可以相对公平地去避免写饥饿的问题了。为此，我们需要调整读写锁的上锁和解锁的逻辑。</p>
<p><code>read()</code>:</p>
<ol>
<li>当 <code>state</code> 为偶数的时候，我们可以尝试对其进行 <strong>+2</strong> 继续抢占读锁；</li>
<li>当 <code>state</code> 为奇数的时候，我们需要调用 <code>wait</code> 陷入等待。</li>
</ol>
<p><code>ReadGuard.drop()</code>:</p>
<ol>
<li>解锁的时候对 <code>state</code> 进行 <strong>-2</strong>；</li>
<li>如果之前的值是 <code>3</code> 的话，说明这是最后一个释放的读锁，且存在被阻塞的写线程，这个时候需要调用 <code>wake_one</code> 进行唤醒。</li>
</ol>
<p><code>write()</code>:</p>
<ol>
<li>如果 <code>state</code> 为 <code>0</code>，则说明此时没有上锁：可以尝试将其置为 <code>u32::MAX</code> 抢占锁：<ol>
<li>如果成功，直接返回。</li>
<li>如果抢锁失败，则重新读取 <code>state</code> 值进行重新判断。</li>
</ol>
</li>
<li>如果 <code>state</code> 为非 0 偶数，则说明此时已经上了读锁了，我们需要将 <code>state</code> <strong>+1</strong> 设置为奇数，表示有写线程被阻塞着，阻止后面新来的读线程抢占读锁。</li>
<li>如果 <code>state</code> 为非 0 奇数：<ol>
<li>如果 <code>state</code> 等于 1，那说明既没有上写锁，也没有上读锁，但是有一个阻塞中的写线程，那有可能就是当前线程自己了！这个时候，我们依旧是可以尝试将其置为 <code>u32::MAX</code> 抢占锁，所以 <code>state</code> 为 <code>1</code> 的情况，可以跟 <code>state</code> 为 <code>0</code> 的情况进行合并处理。</li>
<li>如果 <code>state</code> 大于 1，则说明已经上了读锁，这个时候，需要陷入等待，等前面的读锁都释放了，才能进入下一轮循环，重新尝试抢占写锁。</li>
</ol>
</li>
</ol>
<p><code>WriteGuard.drop()</code>:</p>
<ol>
<li>将 <code>state</code> 置为 <code>0</code>；</li>
<li>唤醒潜在的所有阻塞的读线程和一个写线程。</li>
</ol>
<p>我画了个流程图，供你辅助理解：</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant R1 as 读线程1 (已持有)
    participant R2 as 读线程2 (已持有)
    participant W as 写线程 (等待)
    participant R3 as 读线程3 (新来)
    participant State as RwLock State
    participant Wait as Wait/Wake 机制

    Note over State: state = 4 (两个读锁，4 = 2×2)
    Note over R1, R2: 💡 两个读线程正在工作...

    W-&gt;&gt;State: write() - 尝试获取写锁
    Note over W: 发现有读锁，无法获取

    W-&gt;&gt;State: compare_exchange(4, 5) - 设置写等待标志
    Note over State: state: 4 → 5 (奇数！有等待的写线程)

    W-&gt;&gt;Wait: wait(&amp;writer_wake_counter, w)
    Note over W: 🔒 写线程进入等待，但已设置奇数标志

    Note over R3: 💨 新的读线程想要获取锁
    R3-&gt;&gt;State: read() - 检查 state % 2
    Note over R3: state=5 是奇数，发现有等待的写线程！

    R3-&gt;&gt;Wait: wait(&amp;state, 5)
    Note over R3: 🚫 读线程被阻塞，无法抢占！

    Note over R1: 读线程1完成工作
    R1-&gt;&gt;State: drop(ReadGuard) - fetch_sub(2)
    Note over State: state: 5 → 3 (仍然是奇数)
    Note over R1: 不是最后一个读锁，不唤醒

    Note over R2: 读线程2完成工作
    R2-&gt;&gt;State: drop(ReadGuard) - fetch_sub(2)
    Note over State: state: 3 → 1
    Note over R2: ✨ 检测到 state=3，是最后一个读锁！

    R2-&gt;&gt;Wait: wake_one(&amp;writer_wake_counter)

    Note over W: 🎯 写线程被唤醒
    W-&gt;&gt;State: write() - compare_exchange(1, u32::MAX)
    Note over State: state: 1 → u32::MAX
    State--&gt;&gt;W: ✅ 成功获取写锁！

    Note over W: 写线程执行关键操作...
    Note over R3: 读线程3仍在等待，公平性得到保证！

    W-&gt;&gt;State: drop(WriteGuard) - store(0)
    Note over State: state: u32::MAX → 0
    W-&gt;&gt;Wait: wake_all(&amp;state) - 唤醒所有等待的读线程

    Note over R3: 🎯 读线程3被唤醒，现在可以获取锁了
    R3-&gt;&gt;State: read() - compare_exchange_weak(0, 2)
    Note over State: state: 0 → 2 (偶数，正常读锁)
</code></pre>
<p>综上分析，我们最新的代码如下：</p>
<pre><code class="language-rust">impl&lt;T&gt; RwLock&lt;T&gt; {
   	// ...

    pub fn read(&amp;self) -&gt; ReadGuard&lt;&#39;_, T&gt; {
        let mut s = self.state.load(Relaxed);
        loop {
          	// 如果是 0，则说明现在没有上锁，
          	// 如果是偶数，则说明现在上的是读锁，写没有阻塞中的写线程，
          	// 这两种情况，都可以尝试将 state+2 进行抢占读锁。
            if s % 2 == 0 {
                assert!(s != u32::MAX - 2, &quot;too many readers&quot;);
                match self.state.compare_exchange_weak(s, s + 2, Acquire, Relaxed) {
                    Ok(_) =&gt; return ReadGuard { rwlock: self },	// 抢占成功，直接返回。
                    Err(e) =&gt; s = e,
                }
            }

          	// 如果 state 为奇数，说明有阻塞中的写线程。
            if s % 2 == 1 {
              	// 这时候，后面来的读线程都需要阻塞，等待写锁的获取和释放。
                wait(&amp;self.state, s);
                s = self.state.load(Relaxed)
            }
        }
    }

    pub fn write(&amp;self) -&gt; WriteGuard&lt;&#39;_, T&gt; {
        let mut s = self.state.load(Relaxed);
        loop {
          	// 0: 没有上锁，可以尝试抢锁。
          	// 1: 没有上锁，但有阻塞的写线程，可能就是自己，依旧可以尝试抢锁。
            if s &lt;= 1 {
              	// 尝试将 state 置为 u32::MAX，成功则直接返回
                match self.state.compare_exchange(s, u32::MAX, Acquire, Relaxed) {
                    Ok(_) =&gt; return WriteGuard { rwlock: self },
                    Err(e) =&gt; {
                        s = e;
                        continue;
                    }
                }
            }

          	// 非 0 的偶数，说明现在上的是读锁，且之前没有阻塞的写线程。
            if s % 2 == 0 {
              	// 将 state+1 置为奇数，表示已经有阻塞的写线程了，阻止后来的读线程抢占读锁。
                match self.state.compare_exchange(s, s + 1, Relaxed, Relaxed) {
                    Ok(_) =&gt; {}
                    Err(e) =&gt; {
                        s = e;
                        continue;
                    }
                }
            }
            let w = self.writer_wake_counter.load(Acquire);
            s = self.state.load(Relaxed);
            if s &gt;= 2 {
              	// 如果已经上了读锁，则陷入等待，等待前面所有读锁的释放。
                wait(&amp;self.writer_wake_counter, w);
                s = self.state.load(Relaxed);
            }
        }
    }
}

// 释放读锁
impl&lt;T&gt; Drop for ReadGuard&lt;&#39;_, T&gt; {
    fn drop(&amp;mut self) {
      	// 对 state -=2，如果之前的值是 3，则说明当前是最后一个释放的读锁，且存在阻塞中的写线程。
        if self.rwlock.state.fetch_sub(2, Release) == 3 {
          	// 唤醒一个阻塞中的写线程。
            self.rwlock.writer_wake_counter.fetch_add(1, Release);
            wake_one(&amp;self.rwlock.writer_wake_counter);
        }
    }
}

// 释放写锁
impl&lt;T&gt; Drop for WriteGuard&lt;&#39;_, T&gt; {
    fn drop(&amp;mut self) {
      	// 将 state 重置为 0。
        self.rwlock.state.store(0, Release);
      	// 唤醒一个潜在的阻塞的写线程。
        self.rwlock.writer_wake_counter.fetch_sub(1, Release);
        wake_one(&amp;self.rwlock.writer_wake_counter);
      	// 唤醒所有潜在的阻塞的读线程。
        wake_all(&amp;self.rwlock.state);
    }
}
</code></pre>
<p>这个时候，我们重新运行之前的测试用例，可以发现的顺利通过的！不过我们还需要加一个测试用例，来测试写饥饿是否被解决了！</p>
<pre><code class="language-rust">#[test]
fn writer_starvation_should_resolved() {
   for _ in 0..100 {	// 跑 100 次，避免偶然

      let rwl = RwLock::new(vec![]);
      thread::scope(|s| {
          s.spawn(|| {	// 线程 1
              let mut w = rwl.write();
              w.push(1);
              w.push(2);
          });

          s.spawn(|| { // 线程 2
              sleep(Duration::from_millis(10));
              let r1 = rwl.read();
              println!(&quot;{:?}&quot;, *r1);
              let r2 = rwl.read();
              println!(&quot;{:?}&quot;, *r2);
              sleep(Duration::from_millis(50)); // stay locked to block after writers and readers
          });

          s.spawn(|| { // 线程 3
              sleep(Duration::from_millis(20));
              let mut w2 = rwl.write();
              w2.push(3);
          });

          s.spawn(|| { // 线程 4
              sleep(Duration::from_millis(30));
              let r = rwl.read();
              assert_eq!(r.len(), 3); // must get lock after w2
          });
      })
  }
}
</code></pre>
<p>在上面这个测试用例中，线程 2/3/4 在开始之前分别会睡眠 10/20/30ms，所以抢占锁的时间顺序是 <code>w-&gt;r1-&gt;r2-&gt;w2-&gt;r</code>。并且线程 2 在退出之前，还睡眠了 50ms，所以线程 3 和线程 4 在抢占锁的时候，<code>r1/r2</code> 都还没释放。</p>
<p>如果我们成功解决了写饥饿问题的话，那这个时候，线程 3 应该会将 <code>state</code> 置为奇数，防止线程 4 抢占读锁。所以当线程 4 抢到读锁的时候，线程 3 一定已经释放写锁了，即 <code>vec</code> 里面一定有 3 条数据！通过运行 <code>writer_starvation_should_resolved</code> 测试用例，很幸运，我们已经成功解决了写饥饿的问题了！</p>
<p>完整代码可以参考：<a href="https://github.com/hedon-rust-road/conutils/blob/main/src/rwlock.rs">conutils/rwlock</a>。</p>
<h2>总结</h2>
<p>本篇文章通过三个渐进式版本完整展示了如何从零开始手写一个功能完备的读写锁（RwLock）。</p>
<p>我们的实现历程体现了系统编程中常见的优化思路：</p>
<ul>
<li><p><strong>v1 基础实现</strong>：实现了核心的读写锁语义，确保功能正确性</p>
</li>
<li><p><strong>v2 性能优化</strong>：通过独立的 <code>writer_wake_counter</code> 避免写线程无用循环，提升了性能</p>
</li>
<li><p><strong>v3 公平性保证</strong>：巧妙使用奇偶数编码解决写饥饿问题，在性能与公平性间找到平衡</p>
</li>
</ul>
<p>通过这次实战，我们不仅掌握了 RwLock 的实现细节，更重要的是学会了并发编程的系统性思维方式。这些经验将在后续的系统编程实践中发挥重要作用。</p>
<p>这也是我们学习 <a href="https://marabos.nl/atomics/">Rust Atomics and Locks</a> 这本优秀书籍的收官之战，笔者由衷地佩服 <a href="https://github.com/m-ou-se">Mara Bos</a> 能在短短 200 多页的篇幅将 Rust 的并发编程阐述得如何透彻、清晰且足够深入细节，笔者在整理本系列笔记的过程中，也真的是获益颇丰，希望也能给你带来一些收获，那我们下篇文章见！</p>
<p>Happy Coding! Peace~</p>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨手写一个 Condvar</title>
      <link>https://hedon.top/blog/rust-action-condvar/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-condvar/</guid>
      <pubDate>Mon, 09 Jun 2025 08:51:46 GMT</pubDate>
      <description>在多线程编程中，你是否遇到过这样的困扰：消费者线程不断轮询检查数据是否准备好，白白浪费 CPU 资源？或者在生产者-消费者模式中，如何让消费者优雅地等待数据到来？本文将带你手写一个高效的 Condvar（条件变量），解决线程间的等待与唤醒问题。我们将从最基础的实现开始，逐步优化到减少不必要的系统调用，并深入分析内存顺序的选择，让你彻底理解条件变量背后的设计哲学。</description>
      <category>rust</category><category>并发编程</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<p>系列文章：</p>
<ul>
<li><a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a></li>
<li><a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a></li>
<li><a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a></li>
<li><a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a></li>
<li><a href="/blog/rust-action-arc/">Rust 实战丨手写一个 Arc</a></li>
<li><a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a></li>
<li><a href="/blog/rust-action-mutex/">Rust 实战丨手写一个 Mutex</a></li>
<li><a href="/blog/rust-action-condvar/">Rust 实战丨手写一个 Condvar</a> 👈 本篇</li>
<li><a href="/blog/rust-action-rwlock/">Rust 实战丨手写一个 RwLock</a></li>
</ul>
<hr>
<p>本篇我们继续参考 <a href="https://marabos.nl/atomics/building-locks.html#condition-variable">Rust Atomics and Locks</a> 一书来手写一个条件变量：<em>Condition Variable</em>，简称 <code>Condvar</code>。</p>
<p>在本章开始之前，我们假设你已经：</p>
<ol>
<li>熟悉并理解 Rust 的各种原子操作。</li>
<li>阅读过 <a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a> 和 <a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a>，并理解内存顺序和内存屏障的原理和使用方法。</li>
<li>理解 Rust <code>UnsafeCell&lt;T&gt;</code> 提供的内部可变性允许我们在持有共享引用 <code>&amp;</code> 的时候可以对数据进行修改。</li>
<li>阅读过 <a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a> 并了解 <a href="https://crates.io/crates/atomic-wait">atomic-wait</a> crate 中 <code>wait/wake_one/wake_all</code> 的适用场景和使用方法。</li>
</ol>
<p>在 Rust 中，<code>Condvar</code> 是一个配合 <code>Mutex</code> 使用的线程同步原语，主要作用是让线程在满足某些“条件”之前主动<strong>睡眠</strong>（阻塞），待条件达成时再被唤醒。</p>
<p>典型的就是生产者和消费者模式，我们先来看一下标准库的 <code>Condvar</code> 如何使用：</p>
<pre><code class="language-rust">#[cfg(test)]
mod tests {
    use std::{
        collections::VecDeque,
        sync::{Condvar, Mutex},
        thread,
        time::Duration,
    };

    #[test]
    fn condvar_usage() {
        let queue = Mutex::new(VecDeque::new());
        let not_empty = Condvar::new();

        thread::scope(|s| {
          	// 消费者
            s.spawn(|| loop {
                let mut q = queue.lock().unwrap();
                let item = loop {
                    if let Some(item) = q.pop_front() { // 从队列中获取数据
                        break item; // 获取到则返回
                    } else {
                        q = not_empty.wait(q).unwrap(); // 获取不到数据则阻塞等待
                    }
                };
                drop(q);
                dbg!(item);
            });

          	// 生产者
            for i in 0..10 {
                queue.lock().unwrap().push_back(i);  // 往队列里面投放数据
                not_empty.notify_one();  // 唤醒潜在的阻塞线程
                thread::sleep(Duration::from_secs(1));
            }
        });
    }
}
</code></pre>
<p>在上面这个例子中，我们实现了一对生产者和消费者：</p>
<ol>
<li>消费尝试获取锁，并从队列中获取数据：<ol>
<li>如果有，则释放锁并返回。</li>
<li>如果没有，则调用条件变量的 <code>wait</code> 陷入阻塞<strong>并释放锁</strong>。</li>
</ol>
</li>
<li>生产者尝试获取锁，并往队列中投放数据，并调用条件变量的 <code>notify_one</code> 唤醒潜在的阻塞线程。</li>
</ol>
<pre><code class="language-mermaid">flowchart LR
    A[生产者] --&gt;|添加数据| B[队列]
    B --&gt;|取数据| C[消费者]
    A --&gt;|notify_one| C
    C --&gt;|wait| A
</code></pre>
<h2>读完本篇你能学到什么</h2>
<p>你是否在多线程编程中遇到过这些令人头疼的问题：</p>
<p>🤔 <strong>性能浪费问题</strong>：消费者线程需要不断轮询检查数据是否就绪，即使没有数据也要持续占用 CPU，这种&quot;忙等待&quot;让程序效率低下？</p>
<p>🤔 <strong>复杂的同步逻辑</strong>：在生产者-消费者模式中，如何让消费者在没有数据时优雅地进入休眠，而不是无休止地检查？</p>
<p>🤔 <strong>竞态条件的困扰</strong>：如何确保在多线程环境下，唤醒操作不会丢失，线程不会因丢失唤醒而永远沉睡？</p>
<p>🤔 <strong>性能优化的疑惑</strong>：系统调用开销很大，如何避免在没有等待线程时进行无意义的唤醒操作？</p>
<p>🤔 <strong>内存顺序的选择</strong>：在实现同步原语时，到底该用 <code>Acquire</code>、<code>Release</code> 还是 <code>Relaxed</code>？如何分析 happens-before 关系？</p>
<p>如果这些问题曾经让你困惑，那么本文正是为你准备的。下面我们就正式开始从零开始构建一个条件变量（Condvar），用最直观的方式解答这些并发编程中的经典难题。</p>
<h2>v1：基础实现</h2>
<p>先来思考一下如何定义 <code>Condvar</code> 这个数据结构，参考标准库，它会有 3 个方法：</p>
<ul>
<li><code>wait(MutexGuard)</code>: 释放 MutexGuard 并陷入等待。</li>
<li><code>notify_one()</code>: 唤醒一个 <code>wait</code> 的线程。</li>
<li><code>notify_all()</code>: 唤醒所有 <code>wait</code> 的线程。</li>
</ul>
<p>看过本系列前面几篇的读者应该可以敏锐觉察到，这里就是对应了 <code>atomic-wait</code> 中的 <code>wait/wake_one/wake_all</code>。</p>
<p>那局势就比较明朗了，我们可以在 <code>condvar.wait(guard)</code> 的时候调用 <code>atomic_wait::wait(&amp;atomic)</code> ，然后在 <code>condvar.notify_one()</code> 的时候修改 <code>&amp;atomic</code> 然后调用 <code>atomic_wait::wake_one()</code> 唤醒线程，<code>condvar.notify_all()</code> 也同理。</p>
<p>因此 <code>Condvar</code> 需要有一个 <code>AtomicU32</code> 类型的属性，这里我们称为 <code>counter</code>。故 <code>Condvar</code> 结构暂且定义如下：</p>
<pre><code class="language-rust">pub struct Condvar {
    counter: AtomicU32,
}

impl Condvar {
    pub fn new() -&gt; Self {
        Self {
            counter: AtomicU32::new(0),
        }
    }
}
</code></pre>
<p><code>notify_one</code> 和 <code>notify_all</code> 所上所述，就非常简单了：</p>
<pre><code class="language-rust">impl Condvar {
    // ...

    pub fn notify_one(&amp;self) {
        self.counter.fetch_add(1, Relaxed);
        wake_one(&amp;self.counter);
    }

    pub fn notify_all(&amp;self) {
        self.counter.fetch_add(1, Relaxed);
        wake_all(&amp;self.counter);
    }
}
</code></pre>
<p>现在就剩下 <code>wait</code> 了，它的基本原理：</p>
<ol>
<li>接收一个 MutexGuard；</li>
<li>释放 MutexGuard；</li>
<li>陷入等待，等待唤醒；</li>
<li>被唤醒后，再次抢占锁。</li>
</ol>
<p>综上，我们可以有以下实现：</p>
<pre><code class="language-rust">pub struct MutexGuard&lt;&#39;a, T&gt; {
    pub(crate) mutex: &amp;&#39;a Mutex&lt;T&gt;,  // &lt;---- 需要公开 mutex 字段，这里使用 pub(crate) 限制 crate 外部访问
}

impl Condvar {
   	//...

    pub fn wait&lt;&#39;a, T&gt;(&amp;self, guard: MutexGuard&lt;&#39;a, T&gt;) -&gt; MutexGuard&lt;&#39;a, T&gt; {
        let counter_value = self.counter.load(Relaxed);

        // Unlock the mutex by dropping the guard,
        // but remember the mutex so we can lock it again.
        let mutex = guard.mutex;
        drop(guard);

        // Wait, but only if the counter hasn&#39;t changed since unlocking.
        wait(&amp;self.counter, counter_value);

        mutex.lock()
    }
}
</code></pre>
<p>注意，为了访问 <code>guard.mutex</code>，这里我们使用的是之前自己手写的 <a href="https://github.com/hedon-rust-road/conutils/blob/main/src/mutex.rs">MutexGuard</a>，并将 <code>mutex</code> 字段的私有程度修改为 <code>pub(crate)</code>。代码比较简单，这里就不赘述了，完整流程可参考下图理解。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250610230511147.png" alt=""></p>
<p>我们修改一下测试用例，运行后发现也是可以通过的！</p>
<pre><code class="language-rust">#[cfg(test)]
mod tests {
    use std::{collections::VecDeque, thread, time::Duration};

    use crate::{condvar::Condvar, mutex::Mutex};

    #[test]
    fn condvar_usage() {
        let queue = Mutex::new(VecDeque::new());
        let not_empty = Condvar::new();

        thread::scope(|s| {
            s.spawn(|| loop {
                let mut q = queue.lock();
                let item = loop {
                    if let Some(item) = q.pop_front() {
                        break item;
                    } else {
                        q = not_empty.wait(q);
                    }
                };
                drop(q);
                dbg!(item);
            });

            for i in 0..10 {
                queue.lock().push_back(i);
                not_empty.notify_one();
                thread::sleep(Duration::from_secs(1));
            }
        });
    }
}
</code></pre>
<h2>v2：减少不必要的系统调用</h2>
<p>第 1 个版本中，我们在 <code>notify_one</code> 和 <code>notify_all</code> 分别都无条件调用了 <code>wake_one</code> 和 <code>wake_all</code> 尝试唤醒潜在的线程，但是这个时候可能并没有线程被阻塞着，那这个系统调用就白白浪费了。</p>
<p>所以在这个版本中，我们尝试来优化这一点。为此，我们需要记录当前阻塞中的线程的数量，所以需要给 <code>Condvar</code> 加一个属性 <code>num_waiters</code>：</p>
<pre><code class="language-rust">pub struct Condvar {
    counter: AtomicU32,
    num_waiters: AtomicUsize,
}

impl Condvar {
    pub fn new() -&gt; Self {
        Self {
            counter: AtomicU32::new(0),
            num_waiters: AtomicUsize::new(0),
        }
    }
}
</code></pre>
<p>在 <code>notify_one</code> 和 <code>notify_all</code> 的时候，我们仅当 <code>num_waiters&gt;0</code> 的时候，才进行系统调用：</p>
<pre><code class="language-rust">impl Condvar {
    // ...

    pub fn notify_one(&amp;self) {
        if self.num_waiters.load(Relaxed) &gt; 0 {
            self.counter.fetch_add(1, Relaxed); // TODO: memory order
            wake_one(&amp;self.counter);
        }
    }

    pub fn notify_all(&amp;self) {
        if self.num_waiters.load(Relaxed) &gt; 0 {
            self.counter.fetch_add(1, Relaxed); // TODO: memory order
            wake_all(&amp;self.counter);
        }
    }
}
</code></pre>
<p>在 <code>wait</code> 的时候，我们先标记自己是等待的，即 <code>num_waiters++</code>，然后在被唤醒后，解除这个标记，即 <code>num_waiters--</code>：</p>
<pre><code class="language-rust">impl Condvar {
    // ...

    pub fn wait&lt;&#39;a, T&gt;(&amp;self, guard: MutexGuard&lt;&#39;a, T&gt;) -&gt; MutexGuard&lt;&#39;a, T&gt; {
        self.num_waiters.fetch_add(1, Relaxed); // TODO: memory order  &lt;----  New!!!

      	let counter_value = self.counter.load(Relaxed);

        // Unlock the mutex by dropping the guard,
        // but remember the mutex so we can lock it again.
        let mutex = guard.mutex;
        drop(guard);

        // Wait, but only if the counter hasn&#39;t changed since unlocking.
        wait(&amp;self.counter, counter_value);

        self.num_waiters.fetch_sub(1, Relaxed); //TODO: memory order   &lt;----  New!!!

        mutex.lock()
    }
}
</code></pre>
<p>OK，这里又到了最关键的问题了：<strong><font color="red">操作 num_waiters 时该用什么内存顺序？</font></strong></p>
<p>这个关键的问题的关键是什么呢？是要**<font color="green">确定哪些地方需要建立 happens-before 关系！</font>**</p>
<p>很明显，我们这里的关键就是要**<font color="red">防止 <code>wake_one</code> 的丢失</font>**，即确保如果一个线程即将进入等待状态，那么后续的通知操作能够看到这个等待者的存在。所以这里我们需要 <code>notify_one()</code> 中的 <code>load</code> 和 <code>condvar.wait()</code> 中的 <code>fetch_add</code> 建立 happens-before 关系。至于 <code>fetch_sub</code> 就无所谓了，因为这个时候已经被唤醒了，丢失或者重复唤醒都无所谓了。</p>
<p>不过这里其实可以省掉这对 <code>Release</code> 和 <code>Acquire</code>，直接用 <code>Relaxed</code>！为什么呢？</p>
<ol>
<li><p>在 <code>condvar.wait</code> 的 <code>fetch_add</code> 之前，我们必须先拿到 MutexGuard，即通过 <code>lock()</code> 抢占到锁，<code>lock()</code> 里面是啥操作？是一个 <strong><code>Acquire</code></strong>!</p>
<pre><code class="language-rust">impl&lt;T&gt; Mutex&lt;T&gt; {
    // ...

    pub fn lock(&amp;self) -&gt; MutexGuard&lt;T&gt; {
        lock_contended(&amp;self.state);
        // Swap successfully, means locked.
        MutexGuard { mutex: self }
    }
}

fn lock_contended(state: &amp;AtomicU32) {
    let mut spin_count = 0;
    while let Err(s) = state.compare_exchange(0, 1, Ordering::Acquire, Ordering::Relaxed) {
        if s == 1 {
            if spin_count &lt; 100 {
                spin_count += 1;
                std::hint::spin_loop();
                continue;
            }
            _ = state.compare_exchange(1, 2, Ordering::Acquire, Ordering::Relaxed);
        }
        wait(state, 2)
    }
}
</code></pre>
</li>
<li><p>我们在调用 <code>atomic-wait::wait</code> 陷入等待之前，要先 <code>drop(guard</code>)，别忘了，<code>drop(guard)</code> 里面是啥操作？是一个 <strong><code>Release</code></strong>！</p>
<pre><code class="language-rust">impl&lt;T&gt; Drop for MutexGuard&lt;&#39;_, T&gt; {
    fn drop(&amp;mut self) {
        // If there are threads waiting for the lock, wait one of them.
        if self.mutex.state.swap(0, Ordering::Release) == 2 {
            wake_one(&amp;self.mutex.state);
        }
    }
}
</code></pre>
</li>
</ol>
<p>所以呀，这里其实天然就已经有一对 <code>Release</code> 和 <code>Acquire</code> 了！happens-before 关系是成立的！所以我们之前的代码就已经满足要求了，再次运行前面的测试用例，依旧是顺利通过的！</p>
<p>完整代码可参考：<a href="https://github.com/hedon-rust-road/conutils/blob/main/src/condvar.rs">conutils/condvar</a>。具体流程你可以参考下图辅助理解。</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant Consumer as 消费者线程
    participant Producer as 生产者线程
    participant Mutex as Mutex状态
    participant NumWaiters as num_waiters
    participant Counter as counter

    Consumer-&gt;&gt;Mutex: lock() [Acquire]
    Consumer-&gt;&gt;Consumer: 检查条件，发现需要等待
    Consumer-&gt;&gt;NumWaiters: fetch_add(1) [Relaxed]
    Consumer-&gt;&gt;Counter: load() -&gt; counter_value
    Consumer-&gt;&gt;Mutex: drop(guard) [Release]
    Consumer-&gt;&gt;Counter: wait(counter_value)

    Note over Producer: 生产者在另一个线程
    Producer-&gt;&gt;Mutex: lock() [Acquire] ✅ 与Consumer的Release同步
    Producer-&gt;&gt;Producer: 修改共享数据
    Producer-&gt;&gt;Mutex: drop(guard) [Release]
    Producer-&gt;&gt;NumWaiters: load() &gt; 0? ✅ 看到Consumer的increment
    Producer-&gt;&gt;Counter: fetch_add(1) [Relaxed]
    Producer-&gt;&gt;Counter: wake_one()

    Consumer-&gt;&gt;Consumer: 被唤醒
    Consumer-&gt;&gt;Mutex: lock() [Acquire]
    Consumer-&gt;&gt;NumWaiters: fetch_sub(1) [Relaxed]
</code></pre>
<blockquote>
<p>另外，即使 <code>notify_one()</code> 在 <code>wait()</code> 之前调用，<code>atomic_wait::wait()</code> 的语义也能保证正确性。因为 <code>wait(&amp;counter, expected_value)</code> 只有在 <code>counter</code> 的值等于 <code>expected_value</code> 时才会阻塞，如果 <code>counter</code> 已经被修改，<code>wait</code> 会立即返回。</p>
</blockquote>
<h2>总结</h2>
<p>通过本文的学习，我们从零开始实现了一个功能完整的条件变量（Condvar），并在这个过程中解决了多个重要问题：</p>
<ol>
<li><p><strong>理解条件变量的本质</strong>：Condvar 本质上是一个配合 Mutex 使用的线程同步工具，它解决了&quot;如何让线程在条件不满足时休眠，条件满足时被唤醒&quot;这一经典并发编程问题。</p>
</li>
<li><p><strong>掌握两种实现策略</strong>：</p>
<ul>
<li><strong>v1 基础版本</strong>：直接使用 <code>atomic-wait</code> 实现等待与唤醒机制</li>
<li><strong>v2 优化版本</strong>：通过 <code>num_waiters</code> 计数器避免不必要的系统调用</li>
</ul>
</li>
<li><p><strong>深入理解内存顺序</strong>：通过分析 happens-before 关系，我们发现可以使用 <code>Relaxed</code> 内存顺序，因为 Mutex 的 <code>Release</code>/<code>Acquire</code> 操作已经提供了必要的同步保障。</p>
</li>
</ol>
<p>掌握了这些知识后，你可以：</p>
<ul>
<li>在生产者-消费者场景中高效地同步线程</li>
<li>理解标准库 <code>std::sync::Condvar</code> 的实现原理</li>
<li>在设计自己的同步原语时做出正确的内存顺序选择</li>
<li>识别并避免并发编程中的常见陷阱</li>
</ul>
<p>条件变量虽然概念简单，但其背后涉及的原子操作、内存顺序、操作系统原语等知识却相当深入。通过亲手实现，我们不仅掌握了工具的使用，更重要的是理解了其背后的设计思想，这为我们后续学习更复杂的并发编程技巧打下了坚实基础。</p>
<p>下篇，我们将完成 <a href="https://marabos.nl/atomics/building-locks.html#reader-writer-lock">Rust Atomics and Locks</a> 的最后一个实战案例：手写一个读写锁（RwLock）！</p>
<p>Happy Coding! Peace~</p>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨手写一个 Mutex</title>
      <link>https://hedon.top/blog/rust-action-mutex/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-mutex/</guid>
      <pubDate>Mon, 09 Jun 2025 08:51:46 GMT</pubDate>
      <description>本文带你从零开始实现一个 Rust 中的 Mutex 锁，结合 Rust 原子操作和内存顺序的核心知识，逐步揭示 Mutex 背后的等待与唤醒机制。通过阅读本文，你不仅可以掌握如何使用 Rust 的原子 API 实现一个高效的互斥锁，还能深入理解原子操作背后的内存模型，为掌握更复杂的并发编程技巧打下坚实基础。</description>
      <category>rust</category><category>并发编程</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<p>系列文章：</p>
<ul>
<li><a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a></li>
<li><a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a></li>
<li><a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a></li>
<li><a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a></li>
<li><a href="/blog/rust-action-arc/">Rust 实战丨手写一个 Arc</a></li>
<li><a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a></li>
<li><a href="/blog/rust-action-mutex/">Rust 实战丨手写一个 Mutex</a> 👈 本篇</li>
<li><a href="/blog/rust-action-condvar/">Rust 实战丨手写一个 Condvar</a></li>
<li><a href="/blog/rust-action-rwlock/">Rust 实战丨手写一个 RwLock</a></li>
</ul>
<hr>
<p>继上篇 <a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a>，我们学习了不同操作系统下的并发原语实现，理解了它们最重要的贡献就是提供了一套 <code>wait/wake_one/wake_all</code> 的机制。本篇，我们将借助 <a href="https://marabos.nl/atomics/">Rust Atomics and Locks</a> 的作者 <a href="https://github.com/m-ou-se">Mara Bos</a> 封装的 <a href="https://github.com/m-ou-se/atomic-wait">atomic-wait</a> crate，来手写一个自己的 <code>Mutex</code>！</p>
<h2>v1：基本实现</h2>
<p>首先我们来思考一下如何定义数据结构：</p>
<ol>
<li>我们需要 1 个原子变量 <strong>state</strong> 来记录锁的状态（0: unlocked, 1: locked），因为 <code>atomic-wait</code> 只支持 AtomicU32，所以这里我们的类型也定义为 AtomicU32。</li>
<li>另外我们需要一个 <strong>value</strong> 字段来保存数据，当抢到锁的时候，是可以对 <strong>value</strong> 进行修改的，但是这个时候只有共享引用，所以我们需要 <code>UnsafeCell</code> 来提供内部可变性。</li>
</ol>
<p>同时，贯彻 *RAII（Resource Acquisition Is Initialization，资源获取即初始化）*原则：</p>
<ol>
<li>我们在 <code>lock(&amp;self)</code> 成功时返回一个 <code>MutexGuard</code>，它包含 <code>&amp;Mutex</code>。</li>
<li>在 <code>MutexGuard</code> drop 的时候，我们将 <strong>state</strong> 重置为 0，表示释放锁，并唤醒一个潜在的阻塞线程。</li>
</ol>
<p>为了让 <code>Mutex</code> 可以在线程之间共享，我们需要为其实现 <code>Sync</code> trait，而又因为 <code>Mutex</code> 实现的是独占访问，上锁成功的线程是拥有 T 的所有权的，即要求 T 可以在线程中转移，即要求 T 需要实现 <code>Send</code> trait。</p>
<p>综上，我们定义的 <code>Mutex</code> 和 <code>MutexGuard</code> 结构如下：</p>
<pre><code class="language-rust">pub struct Mutex&lt;T&gt; {
    /// 0: unlocked
    /// 1: locked
    state: AtomicU32,
    value: UnsafeCell&lt;T&gt;,
}

pub struct MutexGuard&lt;&#39;a, T&gt; {
    mutex: &amp;&#39;a Mutex&lt;T&gt;,
}

unsafe impl&lt;T&gt; Sync for Mutex&lt;T&gt; where T: Send {}

impl&lt;T&gt; Mutex&lt;T&gt; {
    pub fn new(value: T) -&gt; Self {
        Self {
            state: AtomicU32::new(0),
            value: UnsafeCell::new(value),
        }
    }
}
</code></pre>
<p>为了方面访问内部数据，我们为 <code>MutexGuard</code> 实现 <code>Deref</code> 和 <code>DerefMut</code> 这 2 个 trait:</p>
<pre><code class="language-rust">impl&lt;T&gt; Deref for MutexGuard&lt;&#39;_, T&gt; {
    type Target = T;
    fn deref(&amp;self) -&gt; &amp;Self::Target {
        unsafe { &amp;*self.mutex.value.get() }
    }
}

impl&lt;T&gt; DerefMut for MutexGuard&lt;&#39;_, T&gt; {
    fn deref_mut(&amp;mut self) -&gt; &amp;mut Self::Target {
        unsafe { &amp;mut *self.mutex.value.get() }
    }
}
</code></pre>
<p>当 <code>MutexGuard</code> 离开作用域的时候，即被 drop 的时候，我们需要释放锁，并调用 <code>wake_one</code> 去唤醒一个潜在的阻塞线程：</p>
<pre><code class="language-rust">impl&lt;T&gt; Drop for MutexGuard&lt;&#39;_, T&gt; {
    fn drop(&amp;mut self) {
        self.mutex.state.store(0, Release);
        wake_one(&amp;self.mutex.state);
    }
}
</code></pre>
<p>这里将 <code>state</code> 设置为 0，使用的内存顺序是 <code>Release</code>，是为了跟 <code>lock</code> 的时候使用 <code>Acquire</code> 建立 happens-before 原则，确保 state 的真实值在各个线程中都是可见的。</p>
<p>在 <code>lock</code> 的时候，我们需要将 <code>state</code> 从 0 替换为 1，如果成功，则说明上锁成功，直接返回 MutexGuard，如果失败，则说明锁已经被抢占了，这个时候我们使用 <code>atomic-wait</code> 的 <code>wait()</code> 陷入休眠，等待 <code>wake_one</code> 信号唤醒，再尝试抢锁。</p>
<pre><code class="language-rust">impl&lt;T&gt; Mutex&lt;T&gt; {
  	// ...

    pub fn lock(&amp;self) -&gt; MutexGuard&lt;T&gt; {
        while self.state.swap(1, Acquire) == 1 {
            wait(&amp;self.state, 1);
        }
        MutexGuard { mutex: self }
    }
}
</code></pre>
<p>至此，我们第一个版本的 <code>Mutex</code> 就完工了！是不是很简单！我们来写 2 个单元测试验证一下基本逻辑是否正确：</p>
<pre><code class="language-rust">#[test]
fn one_thread_should_work() {
    let l = Mutex::new(vec![]);
    let mut guard = l.lock();
    guard.push(1);
    drop(guard);

    let guard = l.lock();
    assert_eq!(guard[0], 1);
}

#[test]
fn cross_thread_should_work() {
    let l = Mutex::new(vec![]);

    thread::scope(|s| {
        s.spawn(|| {
            let mut guard = l.lock();
            guard.push(1);
            sleep(Duration::from_millis(100)); // sleep for makeing the second thread to be blcoked.
        });

        sleep(Duration::from_millis(10)); // make sure the first thread get the lock
        s.spawn(|| {
            let mut guard = l.lock();
            guard.push(2);
        });
    });

    let guard = l.lock();
    assert_eq!(guard.len(), 2);
}
</code></pre>
<p>运行成功：</p>
<pre><code class="language-shell">running 2 tests
test mutex::tests::one_thread_should_work ... ok
test mutex::tests::cross_thread_should_work ... ok
</code></pre>
<h2>v2：减少系统调用</h2>
<p>当 <code>MutexGuard</code> 的时候，我们将 <code>state</code> 置为 0，并调用 <code>wake_one</code> 唤醒一个潜在的线程，这个时候如果没有阻塞中的线程的话，那这个系统调用就比较浪费了。</p>
<p>所以在 v2 版本我们尝试来优化这一点。为此，我们需要扩展我们的 <code>state</code> 字段，新增<strong>表示是否有阻塞线程</strong>的能力。</p>
<pre><code class="language-rust">pub struct Mutex&lt;T&gt; {
    /// 0: unlocked
    /// 1: locked, but no blocked thread
    /// 2: locked, but has blocked threads
    state: AtomicU32,
    value: UnsafeCell&lt;T&gt;,
}
</code></pre>
<p>修改了 <code>state</code> 的定义后我们需要修改上锁和解锁的逻辑，在上锁的时候，我们先尝试将 <code>state</code> 从 <code>0</code> 置为 <code>1</code>，如果成功了，说明抢到了锁，否则，我们将 <code>state</code> 置为 <code>2</code>，表示有线程被阻塞了。</p>
<p>这里书中的实现是这样的：</p>
<pre><code class="language-rust">impl&lt;T&gt; Mutex&lt;T&gt; {
		// ...

    pub fn lock(&amp;self) -&gt; MutexGuard&lt;T&gt; {
        lock_contended(&amp;self.state);
        MutexGuard { mutex: self }
    }
}

fn lock_contended(state: &amp;AtomicU32) {
    if state.compare_exchange(0, 1, Acquire, Relaxed).is_err() {
        while state.swap(2, Acquire) != 0 {
            wait(&amp;state, 2)
        }
    }
}

impl&lt;T&gt; Drop for MutexGuard&lt;&#39;_, T&gt; {
    fn drop(&amp;mut self) {
        if self.mutex.state.swap(0, Release) == 2 {
            println!(&quot;wake_one&quot;);
            wake_one(&amp;self.mutex.state);
        }
    }
}
</code></pre>
<p><code>lock()</code>:</p>
<ul>
<li>如果成功将 <code>state</code> 从 <code>0</code> 变换为 <code>1</code>，则说明当前线程抢锁成功，直接返回 <code>MutexGuard</code>。</li>
<li>如果失败了，就将 <code>state</code> 置为 <code>2</code>，然后调用 <code>wait</code> 进入休眠。</li>
</ul>
<p><code>unlock()</code>:</p>
<ul>
<li>将 <code>state</code> 置为 <code>0</code>，如果之前是 <code>2</code> 的话，那就说明有线程被阻塞着，这个时候才调用 <code>wake_one</code> 唤醒一个阻塞的线程。</li>
</ul>
<p>这个地方，笔者觉得有一些问题， <code>state.swap(2, Acquire)</code> 这一行代码会无条件将 <code>state</code> 置为 <code>2</code>，也就是说，当这个线程抢到锁后，它在 <code>unlock()</code> 的时候，无论有没有在阻塞的线程，这个时候 <code>state</code> 都是 <code>2</code>，所以都会调用 <code>wake_one</code>。</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant A as 线程A (持有锁)
    participant B as 线程B (尝试获锁)
    participant State as Mutex State
    participant System as 系统调用

    Note over State: state = 1 (线程A持有锁)

    B-&gt;&gt;State: compare_exchange(0, 1)
    State--&gt;&gt;B: 失败 (返回 1)

    Note over B: 进入 while 循环
    B-&gt;&gt;State: swap(2)
    Note over State: state: 1 → 2
    State--&gt;&gt;B: 返回 1 (≠ 0)

    B-&gt;&gt;System: wait(&amp;state, 2)
    Note over B: 线程B进入等待状态

    Note over A: 线程A释放锁
    A-&gt;&gt;State: swap(0)
    Note over State: state: 2 → 0
    State--&gt;&gt;A: 返回 2

    A-&gt;&gt;System: wake_one()
    Note over System: 正确的唤醒！线程B确实在等待

    Note over B: 线程B被唤醒，继续循环
    B-&gt;&gt;State: swap(2)
    Note over State: state: 0 → 2
    State--&gt;&gt;B: 返回 0，退出循环

    Note over B: 获得锁，但state=2 (问题所在)
    Note over B: 使用锁...

    B-&gt;&gt;State: unlock() - swap(0)
    Note over State: state: 2 → 0
    State--&gt;&gt;B: 返回 2

    B-&gt;&gt;System: wake_one()
    Note over System: 不必要的调用！此时没有等待者
</code></pre>
<p>我们可以运行上面的测试用例 <code>cross_thread_should_work</code>，可以看到输出了 2 个 wake_one，但是通过分析，应该只需要调用一次 <code>wake_one</code> 就足够了。</p>
<pre><code class="language-shell">test mutex::tests::cross_thread_should_work ... ok

successes:

---- mutex::tests::cross_thread_should_work stdout ----
wake_one
wake_one
</code></pre>
<p>笔者的实现如下：</p>
<pre><code class="language-rust">fn lock_contended(state: &amp;AtomicU32) {
    while let Err(s) = state.compare_exchange(0, 1, Acquire, Relaxed) {
        if s == 1 {
            _ = state.compare_exchange(1, 2, Acquire, Relaxed);
        }
        wait(&amp;state, 2)
    }
}
</code></pre>
<ol>
<li>我们获取 <code>state.compare_exchange(0,1)</code> 的返回值，如果成功，说明抢到锁，直接返回。</li>
<li>如果失败了：<ul>
<li>原始值是 <code>1</code>，那我们就尝试将 <code>state</code> 从 <code>1</code> 交换为 <code>2</code>，然后调用 <code>wait</code> 陷入休眠。</li>
<li>原始值是 <code>2</code>，说明已经有别的线程也被阻塞了，这个时候直接调用 <code>wait</code> 陷入休眠。</li>
</ul>
</li>
<li>当被 <code>wake_one</code> 唤醒时，重新执行 <code>state.compare_exchange(0,1)</code> 抢占锁。</li>
</ol>
<p>这个新的流程中，我们抢到锁的时候，<code>state</code> 会被正确的设置为 <code>1</code> 而不是 <code>2</code>，这个时候，在 <code>drop</code> 的时候就不会有不必要的 <code>wake_one</code> 的调用了。</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant A as 线程A (持有锁)
    participant B as 线程B (尝试获锁)
    participant State as Mutex State
    participant System as 系统调用

    Note over State: state = 1 (线程A持有锁)

    B-&gt;&gt;State: compare_exchange(0, 1)
    State--&gt;&gt;B: 失败，返回 s=1

    Note over B: s == 1，尝试设置等待者标志
    B-&gt;&gt;State: compare_exchange(1, 2)
    Note over State: state: 1 → 2
    State--&gt;&gt;B: 成功

    B-&gt;&gt;System: wait(&amp;state, 2)
    Note over B: 线程B进入等待状态

    Note over A: 线程A完成工作，释放锁
    A-&gt;&gt;State: unlock() - swap(0)
    Note over State: state: 2 → 0
    State--&gt;&gt;A: 返回 2

    A-&gt;&gt;System: wake_one()
    Note over System: 正确唤醒线程B

    Note over B: 线程B被唤醒，重新尝试获取锁
    B-&gt;&gt;State: compare_exchange(0, 1)
    Note over State: state: 0 → 1 (关键！)
    State--&gt;&gt;B: 成功！退出循环

    Note over B: 🎯 获得锁，state=1 (正确状态)
    Note over B: 使用锁进行工作...

    B-&gt;&gt;State: unlock() - swap(0)
    Note over State: state: 1 → 0
    State--&gt;&gt;B: 返回 1 (不是2！)

    Note over B: ✅ 返回值是1，不调用wake_one
    Note over System: 🎯 避免了不必要的系统调用
</code></pre>
<p>我们重新运行上面的测试用例 <code>cross_thread_should_work</code>，可以看到只输出了 1 个 wake_one：</p>
<pre><code class="language-shell">test mutex::tests::cross_thread_should_work ... ok

successes:

---- mutex::tests::cross_thread_should_work stdout ----
wake_one
</code></pre>
<blockquote>
<p>不过笔者在做 benchmark 后发现书中的实现性能其实更高，在 macbook m2max 机器上，书中的版本要比我的版本快 5~10% 左右，猜测大概率是 <code>swap</code> 的性能要比 <code>compare_exchange</code> 高。</p>
</blockquote>
<h2>v3：短暂自旋进一步避免系统调用</h2>
<p>还有一种潜在的优化是，我们可以在抢锁失败且返回 <code>state</code> 为 <code>1</code> 的时候，进行短暂的自旋，如果实际场景中占用锁的时间非常短，那我们就可以再省略一次 <code>wake</code> 的系统调用了。</p>
<p>不过值得注意的是，这种优化未必是正向的，一方面，如果锁占用时间比较长，那前面的自旋就白白浪费了，另一方面，自旋的次数带来的性能消耗，未必就比系统调用要小（不同的平台表现可能很不一样）。</p>
<p>书中给出的经验值是自选 <strong>100</strong> 次。</p>
<p>优化后的 <code>lock_contended</code> 如下：</p>
<pre><code class="language-rust">fn lock_contended(state: &amp;AtomicU32) {
    let mut spin_count = 0;
    while let Err(s) = state.compare_exchange(0, 1, Acquire, Relaxed) {
        if s == 1 {
            if spin_count &lt; 100 {
                spin_count += 1;
                std::hint::spin_loop();
                continue;
            }
            _ = state.compare_exchange(1, 2, Acquire, Relaxed);
        }
        wait(&amp;state, 2)
    }
}
</code></pre>
<blockquote>
<p>感兴趣的读者可以使用 <a href="https://bheisler.github.io/criterion.rs/book/getting_started.html">criterion</a> 做一个 benchmark 看看自旋与之前的版本的性能差异有多少。</p>
</blockquote>
<p>完整代码可参考：<a href="https://github.com/hedon-rust-road/conutils/blob/main/src/mutex.rs">conutils/mutex</a>。</p>
<h2>总结</h2>
<p>在本篇中，我们从零开始，结合 Rust 原子操作和内存顺序的核心知识，实现一个 Rust 中的 Mutex 锁，逐步揭示 Mutex 背后的等待与唤醒机制，为更好理解标准库中的 Mutex 奠定了良好的基础。下篇，我们将尝试手写一个条件变量 <strong>Condition Variable</strong>！</p>
<p>Happy Coding! Peace~</p>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 原理丨操作系统并发原语</title>
      <link>https://hedon.top/blog/rust-os-primitives/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-os-primitives/</guid>
      <pubDate>Sun, 08 Jun 2025 17:45:28 GMT</pubDate>
      <description>操作系统的并发原语是实现各种锁、条件变量和同步工具的核心基础。无论是 Linux 中被广泛使用的 futex、macOS 中的 pthread 和 osunfairlock，还是 Windows 系统上的重量级内核对象、轻量级原语及地址等待机制，本质上都是围绕着三个基本动作展开：wait、wakeone 和 wakeall。本文将通过对三大主流操作系统底层并发原语的梳理与对比，帮助你建立统一的认知框架，更深入地理解并发编程背后的系统级支持，避免在实践和学习中被五花八门的概念搞晕。</description>
      <category>rust</category><category>并发编程</category><category>操作系统</category><category>Rust 原理</category>
      <content:encoded><![CDATA[<p>系列文章：</p>
<ul>
<li><a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a></li>
<li><a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a></li>
<li><a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a></li>
<li><a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a></li>
<li><a href="/blog/rust-action-arc/">Rust 实战丨手写一个 Arc</a></li>
<li><a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a> 👈 本篇</li>
<li><a href="/blog/rust-action-mutex/">Rust 实战丨手写一个 Mutex</a></li>
<li><a href="/blog/rust-action-condvar/">Rust 实战丨手写一个 Condvar</a></li>
<li><a href="/blog/rust-action-rwlock/">Rust 实战丨手写一个 RwLock</a></li>
</ul>
<hr>
<p>在本系列的前面所有篇章中，我们对非阻塞类的并发操作进行了详细的阐述和实践（除了 SpinLock，不过自旋锁是通过自旋来实现阻塞作用，本质上线程并没有陷入阻塞等待的状态）。</p>
<p>后面我们将继续参考 <a href="https://marabos.nl/atomics/">Rust Atomics and Locks</a> 书中的后续篇章，继续手写几个阻塞类的并发工具，有 Mutex（互斥锁）、RwLock（读写锁）和 CondVar（条件变量）。它们都有一个共同的特点：<strong>线程会陷入阻塞，让出 CPU，在等待某个条件满足要求后，会被唤醒并重新调度执行</strong>。这就需要借助内核的能力了，我们需要内核支持：</p>
<ol>
<li>记住那些陷入阻塞的线程；</li>
<li>在满足条件后，能够唤醒对应的正确的线程。</li>
</ol>
<p>熟悉操作系统原理的读者应该清楚，我们编写的应用程序，一般是处于<strong>用户态</strong>，而想要跟内核进行交互，需要陷入<strong>内核态</strong>，而这种切换，很大程度需要依赖于操作系统提供的系统调用能力，即 <code>syscall</code>。</p>
<p>所以在进入手写 Mutex、RwLock 和 CondVar 篇章之前，我们需要先来学习一下，不同的操作系统，都为我们在并发操作中提供了什么样的能力和限制。</p>
<p>在 <a href="https://marabos.nl/atomics/os-primitives.html">Rust Atomics and Locks</a> 第八章（Operating System Primitives）中，作者介绍并比较了各平台提供的操作系统级并发原语，包括 POSIX 的 <code>pthread</code> 系列、Linux 的 <code>futex</code>、macOS 的 <code>os_unfair_lock</code>，以及 Windows 的<code>重量级内核对象</code>、<code>轻量级对象</code>和<code>基于地址的等待机制</code>。</p>
<p>在本篇，笔者将基于自己的理解，尝试对这章进行梳理和总结，以便为后面的手写实践篇章奠定一个良好的理论基础，这里还是建议读者去阅读原文，以便获得更多的细节，加深理解。</p>
<h2>POSIX 线程原语 pthread</h2>
<p>在 Unix 类操作系统中，比如 Linux，<code>libc</code> 就承担了跟内核进行交互的标准接口。在 <code>libc</code> 的基础之前，诞生了一个标准：<em>Portable Operationg System Interface</em>，即熟知的 <strong>POSIX</strong>。在 Rust 中，对应了 <a href="https://crates.io/crates/libc">libc</a> crate。</p>
<p>Windows 系统并不遵循 POSIX 标准，而是一系列的系统库来提供内核交互能力，比如 <a href="https://www.geoffchappell.com/studies/windows/win32/kernel32/api/index.htm">kernel32.dll</a>。</p>
<p>针对线程操作，POSIX 定义了一系列的数据类型和函数，即所谓的 <strong>pthreads</strong>。它提供了以下几个比较重要的并发原语，我将其归纳为一个表格，供你参考。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250608190516988.png" alt=""></p>
<h2>Linux：Futex 用户态等待与唤醒</h2>
<p>在 Linux 中，所有 <code>pthread</code> 原语的实现，都是通过 <strong>futex</strong> 这个系统调用。它是全程是 <em>fast user-space mutex</em>。它的实现核心是：<strong>通过操作一个 32 位的原子变量来实现等待和唤醒</strong>。等待操作会将一个线程陷入睡眠，而唤醒操作会唤醒那些操作同一个原子变量的睡眠中的线程。</p>
<p>这里我们简单进行一下展开，思考一下这个 <code>futex</code> 这个名字的含义，<em>fast user-space mutex</em> 翻译成中文就是<em>快速用户空间互斥锁</em>。我们知道，系统调用的代价是比较昂贵的，需要频繁地在用户态和内核态之间进行切换，对性能是很不友好的。</p>
<p>在 Linux 系统中，<strong>futex</strong> 机制并非独立存在，而是与互斥锁、条件变量等同步原语协同工作，形成 “<strong>用户态自旋 + 内核态等待</strong>” 的分层设计，以兼顾性能与功能。</p>
<p>比如在 Mutex 互斥锁场景下，采用 “两级等待” 策略：</p>
<ul>
<li><strong>用户态自旋阶段</strong>：尝试获取锁时先通过原子操作（如<code>atomic_compare_exchange</code>）自旋尝试，避免内核调用。</li>
<li><strong>内核态等待阶段</strong>：若自旋失败，通过 Futex 的 <code>FUTEX_WAIT</code> 陷入内核，将线程挂起，直到其他线程通过 <code>FUTEX_WAKE</code> 唤醒。</li>
</ul>
<p>这样多数短时间持锁场景可在用户态完成，仅在长时间竞争时陷入内核，相比纯内核互斥锁（如 spinlock）大幅降低系统调用开销。</p>
<p>这里有个很重要的点：<strong>判断和陷入等待，是原子的</strong>。也就是说，线程 A 在确定陷入等待时，如果关联的原子变量已经发生了变化，这个时候，不会陷入等待，而是会直接返回。这也就避免了唤醒信号的丢失。</p>
<p>这里我整理了 <strong>futex</strong> 的核心操作，供你参考：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250608190538982.png" alt=""></p>
<p>我们知道了 Linux 通过 Futex 实现等待与唤醒，内核究竟是如何实现这个等待与唤醒的能力的呢？我们需要解决三个核心问题：</p>
<ol>
<li><strong>寻址问题</strong>：内核怎么知道用户态传入的一个内存地址（虚拟地址），对应的是哪个物理锁？</li>
<li><strong>存储问题</strong>：成千上万个线程在休眠，内核把它们存在哪里？如何高效找到它们？</li>
<li><strong>调度问题</strong>：线程是如何从&quot;运行&quot;变成&quot;睡眠&quot;，又是如何被放回 CPU 的？</li>
</ol>
<p><strong>1. 寻址问题：从虚拟地址到 Futex Key</strong></p>
<p>用户态调用 <code>futex_wait(uaddr, val)</code> 时，传入的是一个 虚拟地址（Virtual Address）。但是，不同的进程可能将同一块物理内存映射到不同的虚拟地址（共享内存场景）。如果内核只看虚拟地址，就无法让不同进程的线程锁住同一个锁。</p>
<p>因此，内核的第一步是将 <strong>用户态的虚拟地址</strong> 转换为内核态唯一的 <strong>Futex Key</strong>。</p>
<p>内核根据锁的类型（进程内私有 vs 进程间共享）生成唯一的 Key：</p>
<ul>
<li><strong>私有锁（Private）</strong>：<code>mm_struct</code>（当前进程的内存描述符地址） + <code>虚拟地址</code>。</li>
<li><strong>共享锁（Shared）</strong>：<code>inode</code>（物理文件索引节点） + <code>page_offset</code>（页内偏移量）。</li>
</ul>
<p><strong>本质</strong>：Futex Key 就像是给这一块内存打了一个身份证号。无论你在哪个进程、哪个虚拟地址，只要最终指向同一块物理内存，算出来的 Key 就是一样的。</p>
<p><strong>2. 存储问题：全局哈希表（Futex Hash Bucket）</strong></p>
<p>解决了身份识别，接下来是存储。内核不能给每个锁都分配一个独立的等待队列（太浪费内存），也不能把所有睡眠线程放在一个大链表里（查找太慢）。</p>
<p>Linux 采用了一个折中方案：<strong>全局哈希表 (<code>futex_queues</code>)</strong>。</p>
<p>内核维护了一个固定大小的哈希表，每个槽位（Slot）被称为一个 Bucket（桶）。每个 Bucket 内部维护：</p>
<ol>
<li><strong>自旋锁（Spinlock）</strong>：保护这个桶的操作。</li>
<li><strong>链表（Linked List）</strong>：串联挂在这个桶上的所有睡眠线程。</li>
</ol>
<p>不管系统里有多少个 futex 锁，所有的等待线程都会根据 Futex Key 的哈希值，散列到这些有限的 Bucket 中。这意味着，不同的锁（若哈希冲突）可能会落在同一个 Bucket 里，但这没关系，链表遍历时会再次比对 Key。</p>
<p><strong>3. 休眠的底层实现（<code>futex_wait</code>）</strong></p>
<p>当线程决定休眠时，内核执行以下步骤：</p>
<ol>
<li><strong>生成 Key</strong>：根据 <code>uaddr</code> 计算 <code>futex_key</code>。</li>
<li><strong>查找 Bucket</strong>：计算哈希，找到对应的 <code>hash_bucket</code>。</li>
<li><strong>加锁 Bucket</strong>：获取该 Bucket 的自旋锁（Spinlock）。这是为了防止你在检查的时候，别人把你唤醒了（竞态条件）。</li>
<li><strong>原子检查（最后一道防线）</strong>：<ul>
<li>这是 <code>futex</code> 极其关键的一步。内核读取用户态 <code>uaddr</code> 的值。</li>
<li><strong>如果值 != expected</strong>：说明用户态判断错了（或者在进内核途中锁变了），立即释放 Bucket 锁，返回 <code>EWOULDBLOCK</code>。<strong>线程不会休眠</strong>。</li>
<li><strong>如果值 == expected</strong>：准备休眠。</li>
</ul>
</li>
<li><strong>入队</strong>：创建一个代表当前线程的等待对象 <code>futex_q</code>，填入 Key 和当前线程信息（<code>task_struct</code>），挂入 Bucket 的链表中。</li>
<li><strong>切换状态</strong>：将当前线程的状态从 <code>TASK_RUNNING</code> 修改为 <code>TASK_INTERRUPTIBLE</code>（可中断睡眠）。</li>
<li><strong>释放 Bucket 锁</strong>：入队完成，不再需要锁 Bucket。</li>
<li><strong>让出 CPU（Schedule）</strong>：调用核心调度函数 <code>schedule()</code>。CPU 保存当前线程的上下文，切换到下一个任务。当前线程停在代码的这一行，不再执行，直到被唤醒。</li>
</ol>
<p><strong>4. 唤醒的底层实现（<code>futex_wake</code>）</strong></p>
<p>当另一个线程释放锁并调用 <code>futex_wake(uaddr, count)</code> 时：</p>
<ol>
<li><strong>生成 Key</strong>：同样计算出 <code>futex_key</code>。</li>
<li><strong>查找 Bucket</strong>：找到对应的哈希桶。</li>
<li><strong>加锁 Bucket</strong>：锁住整个桶。</li>
<li><strong>遍历链表</strong>：遍历桶里的链表，逐个检查 <code>futex_q</code> 对象的 Key 是否与当前 <code>uaddr</code> 的 Key 相同。</li>
<li><strong>出队与唤醒</strong>：<ul>
<li>找到匹配的线程（<code>futex_q</code>）。</li>
<li>将其从链表中移除。</li>
<li>调用 <code>wake_up_process(task)</code>。<ul>
<li>这个函数会将线程的状态改回 <code>TASK_RUNNING</code>。</li>
<li>将线程加入 CPU 的 <strong>运行队列（Run Queue）</strong>。</li>
</ul>
</li>
<li>如果唤醒数量达到了 <code>count</code>（比如唤醒 1 个），就停止遍历。</li>
</ul>
</li>
<li><strong>释放 Bucket 锁</strong>。</li>
<li><strong>返回用户态</strong>。</li>
</ol>
<p>如果我们要用一句话概括 <code>futex</code> 的底层休眠唤醒机制，那就是：</p>
<blockquote>
<p>[!IMPORTANT]</p>
<p><strong>利用物理内存地址（Key）作为唯一标识，通过全局哈希表（Hash Table）对等待线程进行分片管理，最终利用内核调度器（Scheduler）的 <code>schedule()</code> 和 <code>wake_up()</code> 能力来实现 CPU 资源的让出与回收。</strong></p>
</blockquote>
<h2>macOS：公平的 pthread 与非公平的 os_unfair_lock</h2>
<p>在 macOS 上，线程/锁的内核 syscalls（<code>__psynch_*</code> 等）<strong>不是公开稳定 ABI</strong>，官方要求开发者只通过 <strong>LibSystem</strong>（libc + libpthread + Objective-C/Swift runtime 等）来访问，它们都完全实现了 <code>pthread</code>。</p>
<p>不过值得注意的是，在 macOS 10.12 版本之前，macOS 的 pthread lock 默认都是公平锁（fair locks），不过在 macOS 10.12 (Sierra, 2016) 起新增了 <a href="https://developer.apple.com/documentation/os/os_unfair_lock">os_unfair_lock</a>，它是一个不公平、阻塞型、低开销的锁，取代了已弃用的 <code>OSSpinLock</code>。</p>
<p>需要注意，<code>os_unfair_lock</code> 没有提供对应的条件变量或读写锁功能 。也就是说，如果需要使用条件等待或读写锁语义，仍需使用 <code>pthread_cond_t</code> 或 <code>pthread_rwlock_t</code> 等 POSIX 原语，或者使用更高层的 GCD（Grand Central Dispatch）并发模型。Apple 将<code>os_unfair_lock</code> 定位为替代早期的 <code>OSSpinLock</code> 的低级锁，以解决 <code>OSSpinLock</code> 存在的优先级反转问题，同时提供比 <code>pthread_mutex</code> 更快的性能。<code>os_unfair_lock</code> 内部会在必要时让出 CPU 而非自旋等待，从而避免高优先级线程饥饿，但调度上又不像 <code>pthread_mutex</code> 那样严格 FIFO。</p>
<h2>Windows</h2>
<p>Windows 提供了一系列独特的并发原语，可分为<a href="https://learn.microsoft.com/en-us/windows/win32/sysinfo/kernel-objects">重量级内核对象</a>、轻量级对象（如 <a href="https://learn.microsoft.com/en-us/windows/win32/sync/critical-section-objects">Critical Section</a>、<a href="https://learn.microsoft.com/en-us/windows/win32/sync/slim-reader-writer--srw--locks">SRW 锁</a>、<a href="https://learn.microsoft.com/en-us/windows/win32/sync/condition-variables">Condition Variable 条件变量</a>等）和<a href="https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitonaddress">基于地址的等待机制</a>三大类。它们在 API 设计、用法和实现上各不相同，体现了 Windows 从早期到现代的演进。</p>
<h3>重量级内核对象：基于 HANDLE 的 wait 与 notify</h3>
<p>Windows 的重量级同步原语是由内核完全管理的对象，典型代表包括：<strong>Mutex</strong>（互斥量）、<strong>Event</strong>（事件）、<strong>Semaphore</strong>（信号量）、<strong>WaitableTimer</strong>（可等待计时器）等 。这些对象通过 Windows API 创建，相当于创建了一个内核对象句柄（<strong>HANDLE</strong>），类似打开文件会得到文件句柄一样 。每个对象在内核有对应的数据结构，操作系统维护其状态和等待队列。具体可以参考： <a href="https://learn.microsoft.com/en-us/windows/win32/sysinfo/kernel-objects">重量级内核对象</a>。</p>
<p>我整理了它们的基本使用方式，供你参考：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250608192945913.png" alt=""></p>
<h3>轻量级对象：CriticalSection、SRWLock 与 ConditionVariable</h3>
<p>&quot;轻量级&quot;同步原语是指<strong>不以独立内核对象形式存在、主要在用户态运作、仅在必要时调用内核的机制</strong>。</p>
<blockquote>
<p>是不是已经开始有点 futex 的感觉了？🤭</p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250608224608594.png" alt=""></p>
<p><strong>CRITICAL_SECTION</strong></p>
<p>它并非通过 Create 函数得到句柄，而是定义为结构体 <code>CRITICAL_SECTION</code>，需调用 <code>InitializeCriticalSection()</code> 初始化，之后直接用地址操作。本质上是一个递归互斥锁，同一个线程可以多次 <code>Enter</code>，内部有一个递归计数，必须对应次数的 <code>Leave</code> 才能完全释放。</p>
<p>Critical Section 在未争用情况下尝试通过用户态 Atomic 操作获取，比如 CAS 交换为当前线程，成功则进入，失败则可能先自旋尝试，依旧失败再进入内核等待。</p>
<p><strong>SRW Locks</strong></p>
<p>SRW Locks 不支持递归获取，同一线程如果持有写锁，再请求写锁会死锁。SRW 之所以被称为 &quot;slim&quot; 锁，是因为其实现相当高效，无锁时获取和释放都是用户态的 Atomic 操作，发生争用时，内核用一个优化的等待机制管理等待队列。</p>
<p><strong>Condition Variable</strong></p>
<p>是 <a href="https://wuu.wikipedia.org/wiki/Windows_Vista">Vista</a> 时代引入的新原语，它必须搭配 Critical Section 或 SWR Lock 使用。</p>
<h3>基于地址的等待机制：WaitOnAddress</h3>
<p>Windows 在 8 版（2012）引入了全新的底层同步机制，与 Linux futex 非常相似，主要函数有：</p>
<ul>
<li><code>WaitOnAddress(address, compare_address, _,_)</code>: 让当前线程在 address 指向的内存值满足特定条件前进入睡眠，函数会将 address 处提供的值和 compare_address 提高的值逐字节比较，如果全等，则线程睡眠，等待后续唤醒，如果不等，函数立即返回。与 futex_wait 相同，<strong>比较与睡眠是一个原子操作</strong>：在检查内存值与期望值决定休眠的过程中，若有其他线程改变了 address 或发起唤醒，系统会保证不漏掉信号。</li>
<li><code>WakeByAddressSingle(address)</code>: 唤醒在指定地址上等待的一个线程。</li>
<li><code>WakeByAddressAll(address)</code>: 唤醒在指定地址上等待的所有线程。</li>
</ul>
<p>在实现上，WaitOnAddress 非常轻量，没有显式的内存对象或句柄。当线程等待时，内核只是将线程放入与那块内存地址相关联的等待队列中，唤醒时根据地址找到等待线程列表进行唤醒。</p>
<h2>总结</h2>
<p>通过对 3 个不同的操作系统的分析，从大的角度来讲，我们会发现它们的并发原语最重要的就是要利用原子变量，在用户态实现 3 个操作，以减少系统调用的出现，进一步提升性能。这 3 个操作可以归纳为：</p>
<ul>
<li><strong>wait(&amp;AtomicU32)</strong>: 在原子变量等于期望值的时候陷入等待，否则直接返回。</li>
<li><strong>wake_one(&amp;AtomicU32)</strong>: 唤醒某个 <code>wait()</code> 在当前变量的线程。</li>
<li><strong>wake_all(&amp;AtomicU32)</strong>: 唤醒所有 <code>wait()</code> 在当前变量的线程。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250608232051766.png" alt=""></p>
<p>所以下一步如果我们想在编程语言的层面上（Rust）实现自己的 <code>Mutex</code>、<code>REMutex</code> 和 <code>CondVar</code>，第一步就是需要针对不同的操作系统实现一套 <code>wait/wake_one/wake_all</code> 以屏蔽不同操作系统的实现差异，幸运的是 <a href="https://marabos.nl/atomics/">Rust Atomics and Locks</a> 的作者 <a href="https://github.com/m-ou-se">Mara Bos</a> 已经帮我们实现好了：<a href="https://github.com/m-ou-se/atomic-wait">atomic-wait</a>。下篇，我们就利用这个 crate，来一步步手写一个自己的 <code>Mutex</code>！</p>
<p>Happy Coding! Peace~</p>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 原理丨从汇编角度看原子操作</title>
      <link>https://hedon.top/blog/rust-atomic-in-processor/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-atomic-in-processor/</guid>
      <pubDate>Thu, 05 Jun 2025 08:36:03 GMT</pubDate>
      <description>本篇文章沿着 “CPU → 汇编指令 → Rust 原子语义” 的链路，带你拆解 Atomic 背后到底发生了什么。我们先用 x86-64 与 ARM64 的真实编译结果对比 Ordering 的生成代码，再结合缓存一致性协议与编译器重排规则，解释为什么同一行 Rust 代码在不同平台会呈现截然不同的机器级行为。读完后，你不必死记硬背五种内存顺序，也能判断何时选 Relaxed、何时必须上 SeqCst，并掌握一套“看 asm → 辨语义 → 做权衡”的分析方法，为写锁、并发容器或性能调优提供根底。</description>
      <category>rust</category><category>Rust 原理</category><category>并发原理</category><category>原子操作</category><category>汇编</category>
      <content:encoded><![CDATA[<p>系列文章：</p>
<ul>
<li><a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a></li>
<li><a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a> 👈 本篇</li>
<li><a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a></li>
<li><a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a></li>
<li><a href="/blog/rust-action-arc/">Rust 实战丨手写一个 Arc</a></li>
<li><a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a></li>
<li><a href="/blog/rust-action-mutex/">Rust 实战丨手写一个 Mutex</a></li>
<li><a href="/blog/rust-action-condvar/">Rust 实战丨手写一个 Condvar</a></li>
<li><a href="/blog/rust-action-rwlock/">Rust 实战丨手写一个 RwLock</a></li>
</ul>
<hr>
<p>继上篇 <a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a>，我们详细介绍了 Rust 中的原子操作及内存顺序和内存屏障的诸多概念。我们知道，之所以要在硬件层面之上的编程语言中，抽象出这些顶层概念，是为屏蔽底层硬件的差异。那么本篇，我们就尝试从汇编代码和硬件层面来分析在不同的计算机架构下这些概念是如何被实现的，它们之间就有哪些具体的差异。</p>
<p>在展开之前，我们先来复习一下 Rust 中的内存顺序和内存屏障。</p>
<p>Rust 支持五种内存顺序（Ordering），从最松散到最严格依次为：</p>
<table>
<thead>
<tr>
<th>内存顺序</th>
<th>说明</th>
<th>保证</th>
<th>适用场景</th>
<th>示例</th>
</tr>
</thead>
<tbody><tr>
<td>Relaxed</td>
<td>最宽松的内存顺序</td>
<td>- 仅保证操作的原子性<br>- 不提供任何同步保证<br>- 不建立 happens-before 关系</td>
<td>- 简单计数器<br>- 性能要求极高且确定不需要同步<br>- 已通过其他方式确保同步</td>
<td><code>counter.fetch_add(1, Ordering::Relaxed)</code></td>
</tr>
<tr>
<td>Release</td>
<td>用于存储操作</td>
<td>- 之前的内存访问不会被重排到此操作之后<br>- 与 Acquire 配对使用可建立 happens-before 关系</td>
<td>- 生产者-消费者模式<br>- 发布共享数据<br>- 初始化完成标志</td>
<td><code>data.store(42, Ordering::Release)</code></td>
</tr>
<tr>
<td>Acquire</td>
<td>用于加载操作</td>
<td>- 之后的内存访问不会被重排到此操作之前<br>- 与 Release 配对使用可建立 happens-before 关系</td>
<td>- 生产者-消费者模式<br>- 获取共享数据<br>- 检查初始化标志</td>
<td><code>data.load(Ordering::Acquire)</code></td>
</tr>
<tr>
<td>AcqRel</td>
<td>同时包含 Acquire 和 Release 语义</td>
<td>- 结合了 Acquire 和 Release 的所有保证<br>- 用于读改写操作</td>
<td>- 需要双向同步的原子操作<br>- 锁的实现<br>- 复杂的同步原语</td>
<td><code>value.fetch_add(1, Ordering::AcqRel)</code></td>
</tr>
<tr>
<td>SeqCst</td>
<td>最严格的内存顺序</td>
<td>- 包含 AcqRel 的所有保证<br>- 所有线程看到的所有 SeqCst 操作顺序一致<br>- 提供全局的顺序一致性</td>
<td>- 需要严格的全局顺序<br>- 不确定使用哪种顺序时<br>- 对性能要求不高的场景</td>
<td><code>flag.store(true, Ordering::SeqCst)</code></td>
</tr>
</tbody></table>
<p>内存屏障主要分为以下几种类型：</p>
<ol>
<li><p><strong>Load Barrier（读屏障）</strong></p>
<ul>
<li>确保在屏障之前的所有读操作都执行完成</li>
<li>防止后续读操作被重排到屏障之前</li>
<li>对应 Acquire 语义</li>
</ul>
</li>
<li><p><strong>Store Barrier（写屏障）</strong></p>
<ul>
<li>确保在屏障之前的所有写操作都执行完成</li>
<li>防止后续写操作被重排到屏障之前</li>
<li>对应 Release 语义</li>
</ul>
</li>
<li><p><strong>Full Barrier（全屏障）</strong></p>
<ul>
<li>同时包含读屏障和写屏障的功能</li>
<li>防止任何内存操作的重排序</li>
<li>对应 SeqCst 语义</li>
</ul>
</li>
</ol>
<h2>读完本篇你能学到什么</h2>
<ol>
<li><p><strong>汇编分析能力</strong>：掌握从 Rust 代码到汇编指令的完整分析链路，能够使用 <code>cargo-show-asm</code> 或 Compiler Explorer 等工具深入理解代码的底层实现。</p>
</li>
<li><p><strong>跨平台差异洞察</strong>：深刻理解 x86-64（CISC）与 ARM64（RISC）两大主流架构在原子操作实现上的本质差异，为性能优化和平台适配提供理论基础。</p>
</li>
<li><p><strong>内存顺序选择策略</strong>：不再需要死记硬背五种内存顺序，而是基于硬件特性和性能考量做出明智选择 —— 知道何时用 <code>Relaxed</code> 追求极致性能，何时必须上 <code>SeqCst</code> 保证正确性。</p>
</li>
<li><p><strong>原子性保证机制</strong>：理解为什么同样的汇编代码，普通操作与原子操作在编译器层面有本质区别，以及对齐访问与跨缓存行访问的不同行为。</p>
</li>
<li><p><strong>硬件协议原理</strong>：掌握 MESI 缓存一致性协议、x86 的 <code>lock</code> 机制、ARM 的 <code>LL/SC</code> 机制等底层实现原理，能够解释多核环境下的数据同步过程。</p>
</li>
<li><p><strong>性能优化洞察</strong>：理解不同架构下内存屏障的开销差异，为高性能并发代码提供优化方向（如 ARM64 上 <code>compare_exchange_weak</code> 的真实优势）。</p>
</li>
<li><p><strong>并发问题调试</strong>：当遇到并发 bug 时，能够从汇编层面分析问题根因，判断是内存顺序问题还是原子性问题。</p>
</li>
<li><p><strong>架构适配能力</strong>：在跨平台开发中，能够针对不同架构的特性（如 x86-64 的强顺序 vs ARM64 的弱顺序）做出相应的代码调整。</p>
</li>
<li><p><strong>锁与无锁数据结构设计</strong>：基于硬件原理设计高效的同步原语，理解何时选择基于 CAS 的无锁算法，何时选择传统锁机制。</p>
</li>
</ol>
<p>在进入汇编代码的世界之前，我们先简单补充 2 个重要概念，分别是<strong>指令集</strong>和 CPU 缓存一致性协议 <strong>MESI</strong>。</p>
<h2>指令集</h2>
<p>两种指令集：</p>
<ul>
<li>CISC（Complex Instruction Set Computing，复杂指令集）</li>
<li>RISC（Reduced Instruction Set Computing，精简指令集）</li>
</ul>
<p>二者对比：</p>
<table>
<thead>
<tr>
<th align="left">特征</th>
<th align="left">RISC</th>
<th align="left">CISC</th>
</tr>
</thead>
<tbody><tr>
<td align="left">指令集</td>
<td align="left">精简，指令数目少</td>
<td align="left">复杂，指令数目多</td>
</tr>
<tr>
<td align="left">指令复杂性</td>
<td align="left">指令简单，每条指令执行单一功能</td>
<td align="left">指令复杂，可以执行多个功能</td>
</tr>
<tr>
<td align="left">寻址方式</td>
<td align="left">简单寻址方式</td>
<td align="left">复杂寻址方式</td>
</tr>
<tr>
<td align="left">硬件实现</td>
<td align="left">易于实现</td>
<td align="left">实现复杂</td>
</tr>
<tr>
<td align="left">编译器</td>
<td align="left">高效编译器</td>
<td align="left">编译器效率相对较低</td>
</tr>
<tr>
<td align="left">运算速度</td>
<td align="left">快速</td>
<td align="left">相对慢</td>
</tr>
</tbody></table>
<blockquote>
<p>具体可参考：<a href="https://cs.stanford.edu/people/eroberts/courses/soco/projects/risc/risccisc/">risc vs. cisc</a>。</p>
</blockquote>
<p>两种指令集分别对应两种最典型的计算机架构：</p>
<ul>
<li><strong>x86-64</strong>：<strong>基于 CISC（复杂指令集）的 64 位扩展架构</strong>，由 AMD 设计并主导，兼容 x86 32 位生态，通过硬件复杂性换取高性能与广泛兼容性，主导桌面与服务器领域。</li>
<li><strong>arm64</strong>：<strong>基于 RISC（精简指令集）的 64 位架构</strong>，由 ARM 设计，以精简指令、高能效为核心，原生支持低功耗场景，主导移动设备并逐步扩展至服务器与 PC 领域。</li>
</ul>
<p>在本篇中，我们只涉及 2 个平台：</p>
<ul>
<li>x86_64-unknown-linux-musl（以下简称 x86-64）</li>
<li>aarch64-unknown-linux-musl（以下简称 ARM64）</li>
</ul>
<p>要将 Rust 代码编译为指定平台的可执行文件：</p>
<ol>
<li><p>安装对应的目标平台</p>
<pre><code class="language-shell">rustup target add x86_64-unknown-linux-musl  # x86-64
rustup target add aarch64-unknown-linux-musl # ARM64
</code></pre>
</li>
<li><p>编译时使用 <code>--target</code> 标志</p>
<pre><code class="language-shell">cargo build --release --target x86_64-unknown-linux-musl
cargo build --release --target aarch64-unknown-linux-musl
</code></pre>
</li>
</ol>
<h2>缓存一致性协议 MESI</h2>
<p>在多核系统中，每个核心都有自己的缓存（L1/L2 Cache），而内存中的数据可能被多个核心同时读取或修改。如果不加控制，会导致以下问题：</p>
<ul>
<li><strong>缓存不一致（Cache Coherence Problem）</strong>：不同核心的缓存可能持有同一内存地址的不同副本。</li>
<li><strong>脏数据（Dirty Data）</strong>：某个核心修改了数据，但其他核心仍使用旧值。</li>
</ul>
<p>MESI（<strong>Modified, Exclusive, Shared, Invalid</strong>）是一种广泛使用的 <strong>缓存一致性协议</strong>（Cache Coherence Protocol），用于确保多核处理器系统中各个核心的缓存数据保持一致。它定义了缓存行的 <strong>4 种状态</strong>，并通过状态转换和消息传递机制来协调多核间的数据访问。</p>
<table>
<thead>
<tr>
<th align="center">状态</th>
<th align="left">含义</th>
<th align="left">特点</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><strong>M (Modified)</strong></td>
<td align="left">当前核心独占此数据，且已修改（与内存不一致）</td>
<td align="left">只有本核心有最新数据，必须写回内存后才能被其他核心读取。</td>
</tr>
<tr>
<td align="center"><strong>E (Exclusive)</strong></td>
<td align="left">当前核心独占此数据，但未修改（与内存一致）</td>
<td align="left">可以安全读取或修改，无需通知其他核心。</td>
</tr>
<tr>
<td align="center"><strong>S (Shared)</strong></td>
<td align="left">多个核心共享此数据（与内存一致）</td>
<td align="left">所有核心只能读取，不能直接修改（需先升级为 <code>M</code> 或 <code>E</code>）。</td>
</tr>
<tr>
<td align="center"><strong>I (Invalid)</strong></td>
<td align="left">缓存行无效（数据已过期或未加载）</td>
<td align="left">必须从内存或其他核心重新加载最新数据。</td>
</tr>
</tbody></table>
<p>更多细节可参考：<a href="https://zh.wikipedia.org/wiki/MESI%E5%8D%8F%E8%AE%AE">维基百科 MESI</a>。</p>
<h2>查看 Rust 汇编代码</h2>
<p>查看 Rust 汇编代码的常用方式有以下几种：</p>
<ol>
<li><p>cargo rustc --lib --release --target x86_64-unknown-linux-musl -- --emit asm</p>
</li>
<li><p><a href="https://crates.io/crates/cargo-show-asm">cargo-show-asm</a>（推荐 ✅）</p>
<pre><code class="language-shell">cargo asm --release --target=x86_64-unknown-linux-musl --lib {module}::{func_name}
</code></pre>
</li>
<li><p><a href="https://godbolt.org/">Compiler Explorer</a> （推荐 ✅）</p>
</li>
</ol>
<p>接下来我们来看下各种 Atomic 操作的汇编代码是什么样的。</p>
<h3>Store</h3>
<p>x86-64：</p>
<ul>
<li>普通类型的赋值操作跟原子操作在 <code>Relaxed</code> 顺序下生成的汇编的一模一样的！</li>
<li>在强顺序一致性要求的 <code>SeqCst</code> 下，使用了带有 <code>Lock</code> 语义的 <code>xchg</code> 指令保证内存顺序。</li>
</ul>
<p>ARM64：</p>
<ul>
<li>普通类型的赋值操作跟原子操作在 <code>Relaxed</code> 顺序下生成的汇编的也是一模一样的！</li>
<li>在强顺序一致性要求的 <code>SeqCst</code> 下，使用了原子存储指令 <code>stlr</code> 保证内存顺序。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250607135548507.png" alt=""></p>
<p>那么问题就来了：普通类型的赋值操作与 Relaxed 的原子操作生成的汇编一样，那凭什么后者就有原子性的保证呢？</p>
<ol>
<li>在上述 2 个架构中，这仅能说明 <code>mov</code> 和 <code>str</code> 在（当前选择的）硬件层面是原子的，无论是否使用 Atomic 类型。这因为 CPU 的缓存一致性协议（MESI）和总线锁定机制确保<strong>对齐操作</strong>不会撕裂（tearing）。</li>
<li>但是对于<strong>未对齐或跨缓存行访问</strong>，普通操作不保证原子性，可能被拆分为多次访问（如未对齐的 i64 可能拆为 2 个 32 位写入）。</li>
</ol>
<p>所以在 Rust 编译器上：</p>
<ul>
<li><strong>普通操作（*x=0）</strong>：Rust 不将其视为原子操作，即使生成的汇编与 <code>Relaxed</code> 原子操作相同。编译器可能优化或重排普通操作，破坏原子性假设。<ul>
<li>如：循环中的多次普通写入可能被合并为一次（优化后仅保留最后一次写入）。</li>
</ul>
</li>
<li><strong>原子操作（x.store(0, Relaxed)）</strong>：Rust 强制保证原子性，无论硬件是否隐式支持：<ul>
<li>对齐访问：直接生成 <code>mov</code>（利用硬件原子性）。</li>
<li>未对齐访问：插入额外指令（如 <code>lock cmpxchg</code>）确保原子性。</li>
<li>禁止编译器优化重排或消除操作。</li>
</ul>
</li>
</ul>
<h3>Load</h3>
<p>x86-64：</p>
<ul>
<li>三段代码生成的汇编代码一模一样！这是因为 x86-64 的强顺序策略默认保证 <code>mov</code> 具有顺序一致性（类似 SeqCst），因此无需显示内存屏障。</li>
</ul>
<p>ARM64：</p>
<ul>
<li>对于普通类型的加载操作和 load Relaxed 生成的汇编代码是一样的。</li>
<li>对于 load SeqCst，使用了专门的原子加载指令 <code>ldar</code>，它会隐式插入内存屏障，保证该操作之前的所有内存访问对其他线程可见。</li>
</ul>
<p>虽然 x86-64 对于上面的 3 段代码生成的汇编是一样的，但这只是 x86-64 硬件层面上的保证，且跟之前一样，仅在对齐时是原子的，如果未对齐或跨缓存行访问，是可能被撕裂成 2 个操作的。</p>
<p>在 ARM64 中，不依靠硬件层面的复杂性，而通过 <code>ldar</code> 原子加载指令来保证原子性。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250607135525872.png" alt=""></p>
<h3>Read-Modify-Write</h3>
<p>x86-64:</p>
<ul>
<li>使用 <code>lock</code> 指令来锁定总线或缓存行，从而实现原子性。</li>
</ul>
<p>ARM64:</p>
<ul>
<li>使用 <code>LL/SC</code> 机制来实现原子操作（有点类似与乐观锁的味道）。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250607135850075.png" alt=""></p>
<h3>Compare-and-Exchange</h3>
<p>x86-64:</p>
<ul>
<li>二者没有任何区别，或者可以理解为，x86-64 就没有专门实现 <code>compare_exchange_weak</code>。</li>
</ul>
<p>ARM64:</p>
<ul>
<li>二者实现是不同的，在 ARM64 上，<code>compare_exchange_weak</code> 是真的具备 <code>weak</code> 的特性。所以如果在特定场景下想用 <code>compare_exchange_weak</code> 来进一步提升性能，在上层也一定要用循环来主动重试，避免虚假失败。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250607135449552.png" alt=""></p>
<h3>Fence</h3>
<p>x86-64:</p>
<ul>
<li>Release 和 Acquire 并没有额外使用的指令。只有使用 SeqCst 内存屏障的时候，会插入一条 <code>mfence</code> (memory fence) 指令，这条指令会保证在越过它之前，前面所有的内存操作都已经完成。</li>
</ul>
<p>ARM64:</p>
<ul>
<li>Release、AcqRel 和 SeqCst 都插入了一条 <code>dmb ish</code>(data memory barrier, inner shared domain)。而 Acquire 则插入了一条 <code>dmb ishld</code>，它只会等待 load 操作的完成，但是允许 store 操作重排序到它后面。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250607144937786.png" alt=""></p>
<h3>总结对比</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250607144757100.png" alt=""></p>
<p>到这里我们可以得到以下结论：</p>
<ol>
<li>x86-64 保证原子性的关键是 <code>lock</code> 机制，ARM64 保证原子性的关键是 <code>LL/SC</code> 机制。</li>
<li>x86-64 保证内存顺序的关键是 <code>mfence</code> 指令，ARM64 保证内存顺序的关键是 <code>dmb ish</code> 和 <code>dmb ishld</code> 指令。</li>
<li>x86-64 没有实现真实的 <code>compare_exchange_weak</code>，ARM64 实现了 <code>compare_exchange_weak</code>。</li>
<li>x86-64 使用的是强顺序策略，具体来说：<ul>
<li><strong>Load→ 后续操作</strong>：禁止重排序（如 <code>Load A</code> → <code>Store B</code> 必须保持顺序）。</li>
<li><strong>Store→ 前序操作</strong>：禁止重排序（如 <code>Load A</code> → <code>Store B</code> 中 <code>Store B</code> 不能提前到 <code>Load A</code> 前）。</li>
<li><strong>Store→ 后续 Load</strong>：允许重排序（如 <code>Store A</code> → <code>Load B</code> 可能实际执行为 <code>Load B</code> → <code>Store A</code></li>
</ul>
</li>
<li>ARM 使用的是弱顺序策略，即所有的原子操作都可能被重排序。</li>
<li>x86-64 中，Relaxed、Acquire、Release 和 AcqRel 的内存顺序效果是一致的。ARM64 中，Relaxed 没有任何内存顺序的保证，而 Release、AcqRel 和 SeqCst 是一样昂贵的，Acquire 稍微轻量一点，只保证了前面的 load 不会重排到后面。</li>
</ol>
<p><a href="https://marabos.nl/atomics/">Rust Atomics and Locks</a> 书中给出了一张更细节的图，感兴趣的读者可以研究一下。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250607151428218.png" alt="Rust Atomics and Locks: An overview of the instructions that the various atomic operations compile down to on ARM64 and x86-64 for each memory ordering"></p>
<h2>硬件原理</h2>
<p>最后我们尝试从硬件层面来进一步理解原子操作的底层实现。这块笔者并不专业，更多的是尝试通过 ChatGPT 等 LLM 查阅资料，进行梳理总结。</p>
<p>原子操作的底层实现（如 x86 的 <code>lock</code> 前缀或 ARM 的 <code>LL/SC</code>）依赖于硬件级别的协同机制，其核心是通过 <strong>缓存一致性协议</strong>、<strong>总线仲裁</strong> 和 <strong>指令集层面的特殊支持</strong> 来保证多核环境下的原子性和内存顺序。</p>
<h3>x86 的 <code>lock</code> 前缀：总线锁定与缓存一致性</h3>
<ol>
<li><p><strong>总线锁定（Bus Locking）</strong></p>
<p>当 CPU 执行 <code>lock cmpxchg</code> 时，<code>lock</code> 前缀会向总线（或缓存一致性协议）发送信号，<strong>临时独占内存地址的访问权</strong>，阻止其他核心的干扰。</p>
<ol>
<li><strong>锁定范围</strong>：现代 CPU 通常锁定缓存行（通常 64 字节），而非整个总线。</li>
<li><strong>硬件支持</strong>：通过处理器的 <strong>原子操作单元</strong> 和 <strong>缓存控制器</strong> 协同实现。</li>
</ol>
</li>
<li><p><strong>MESI 缓存一致性协议</strong></p>
<blockquote>
<p>缓存一致性协议（如 MESI）会在硬件层面上确保所有核心对内存修改的观察一致：任何核心的修改会立即（或按协议约定）传播到其他核心的缓存。</p>
</blockquote>
<p><code>lock</code> 操作会强制目标缓存行进入 <strong>Modified（独占修改）</strong> 状态，并通知其他核心的缓存行失效（Invalid）。 如：</p>
<ol>
<li><p>核心 A 执行 <code>lock inc [x]</code>，缓存行 <code>x</code> 变为 Modified。</p>
</li>
<li><p>核心 B 尝试读取 <code>x</code>，触发缓存一致性协议：</p>
<ul>
<li><p>核心 A 将修改后的值写回主存或核心 B 的缓存（取决于协议变种如 MESIF/MOESI）。</p>
</li>
<li><p>核心 B 的缓存行 <code>x</code> 变为 <strong>Shared</strong> 或 <strong>Exclusive</strong>。</p>
</li>
</ul>
</li>
</ol>
</li>
<li><p><strong>内存屏障的隐含保证</strong></p>
<p>即使代码使用 <code>Relaxed</code> 内存序，<code>lock</code> 会隐式插入 <strong>StoreLoad</strong> 屏障，确保：</p>
<ol>
<li>该指令前的所有写操作对其他核心可见。</li>
<li>该指令后的读操作不会重排到指令前。</li>
</ol>
</li>
<li><p><strong>现代优化：缓存锁定（Cache Locking）</strong></p>
<p>新式 CPU（如 Intel Skylake+）优先在缓存层面实现原子性，仅当跨缓存行或未对齐时才降级为总线锁定，减少性能损耗。</p>
</li>
</ol>
<h3>ARM 的 LL/SC（Load-Linked/Store-Conditional）：轻量级独占标记</h3>
<ol>
<li><p><strong>独占访问标记（Exclusive Monitor）</strong></p>
<p><strong>硬件状态机</strong>：每个 CPU 核心维护一个 <strong>独占访问标记</strong>，记录最近通过 <code>ldxr</code> 加载的内存地址。</p>
<ul>
<li><p><strong>标记触发</strong>：<code>ldxr [x]</code> 会标记地址 <code>x</code> 为当前核心的独占访问区域。</p>
</li>
<li><p><strong>标记清除条件</strong>：</p>
<ul>
<li><p>其他核心修改了 <code>x</code> 的缓存行（通过缓存一致性协议）。</p>
</li>
<li><p>当前核心执行 <code>clrex</code> 或上下文切换。</p>
</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>条件存储（**</strong><code>stxr</code>*<strong>*）的原子性校验</strong></p>
<p><strong>校验独占标记</strong>：<code>stxr</code> 执行时，硬件会检查目标地址的独占标记是否仍属于当前核心：</p>
<ol>
<li><strong>若标记有效</strong>：存储成功，返回 0。</li>
<li><strong>若标记失效</strong>：存储失败，返回 1（需重试）。</li>
</ol>
</li>
<li><p><strong>与缓存一致性协议的交互</strong></p>
<p><strong>ARM 的 ACE 协议</strong>：LL/SC 依赖缓存一致性协议（如 CHI 或 ACE）监听其他核心的修改：</p>
<ol>
<li>核心 A 执行 <code>ldxr [x]</code>，缓存行 <code>x</code> 进入 <strong>Exclusive</strong> 状态。</li>
<li>若核心 B 写入 <code>x</code>，缓存行在核心 A 中变为 <strong>Invalid</strong>，独占标记被清除。</li>
<li>核心 A 的后续 <code>stxr</code> 会因标记失效而失败。</li>
</ol>
</li>
<li><p><strong>内存顺序的灵活控制</strong></p>
<p>ARM 的内存序（如 <code>Relaxed</code>/<code>SeqCst</code>）通过显式屏障指令实现：</p>
<ol>
<li><code>ldapr</code>（Load-Acquire）：确保后续操作不重排到加载前。</li>
<li><code>stlr</code>（Store-Release）：确保前序操作不重排到存储后。</li>
</ol>
</li>
</ol>
<h2>总结</h2>
<p>本篇文章通过查看 <code>x86_64-unknown-linux-musl</code> 和 <code>aarch64-unknown-linux-musl</code> 两大平台下的汇编代码 ，深入剖析了 Rust 原子操作的底层实现机制，揭示了同一行 Rust 代码在不同平台上截然不同的机器级行为。</p>
<p>到目前为止，我们学习的都是无锁（non-blocking）操作，下篇，我们将继续学习 <a href="https://marabos.nl/atomics/">Rust Atomics and Locks</a> 中的第八章《Operating System Primitives》，为手写阻塞类组件（Mutex、RwLock、CondVar）做理论准备，咱们下篇见！</p>
<p>Happy Coding! Peace~</p>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨手写一个 Arc</title>
      <link>https://hedon.top/blog/rust-action-arc/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-arc/</guid>
      <pubDate>Tue, 03 Jun 2025 13:00:00 GMT</pubDate>
      <description>本文手把手带你拆解并重构 Arc：从单线程引用计数，到跨线程 Weak 防环，再到剥离强/弱引用与内存序优化，层层深入 Rust 并发与内存模型核心。</description>
      <category>rust</category><category>并发编程</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<p>系列文章：</p>
<ul>
<li><a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a></li>
<li><a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a></li>
<li><a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a></li>
<li><a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a></li>
<li><a href="/blog/rust-action-arc/">Rust 实战丨手写一个 Arc</a> 👈 本篇</li>
<li><a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a></li>
<li><a href="/blog/rust-action-mutex/">Rust 实战丨手写一个 Mutex</a></li>
<li><a href="/blog/rust-action-condvar/">Rust 实战丨手写一个 Condvar</a></li>
<li><a href="/blog/rust-action-rwlock/">Rust 实战丨手写一个 RwLock</a></li>
</ul>
<hr>
<p>继上篇 <a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a>，本篇我们继续参考 <a href="https://marabos.nl/atomics/">Rust Atomics and Locks</a> 一书，来实现一个 <code>Arc</code>。</p>
<p>在本章开始之前，我们假设你已经：</p>
<ol>
<li>熟悉并理解 Rust 的各种原子操作。</li>
<li>阅读过 <a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a>，并理解内存顺序和内存屏障的原理和使用方法。</li>
<li>理解 Rust <code>UnsafeCell&lt;T&gt;</code> 提供的内部可变性允许我们在持有共享引用 <code>&amp;</code> 的时候可以对数据进行修改。</li>
</ol>
<h2>Arc 简介</h2>
<p><code>Arc</code>（<em>Atomic Reference Counted</em>）是 Rust 标准库里位于 <code>std::sync</code> 模块中的智能指针，用于 <strong>在多个线程之间安全地共享只读数据</strong>。和只适用于单线程场景的 <code>Rc&lt;T&gt;</code> 不同，<code>Arc&lt;T&gt;</code> 的引用计数增减操作使用原子指令，从而保证跨线程的内存安全。</p>
<p><strong>为什么需要 Arc？</strong></p>
<pre><code class="language-rust">let p = Person { age: 18, name: &quot;hedon&quot;.to_string(), address: &quot;China&quot;.to_string() };

thread::scope(|s| {
    s.spawn(|| println!(&quot;{:?}&quot;, &amp;p)); // ✅ scope 内的线程可以借用
});

thread::spawn(move || println!(&quot;{:?}&quot;, &amp;p)); // ❌ 需要 &#39;static 生命周期
</code></pre>
<p><strong>问题</strong>：<code>thread::spawn</code> 要求 <code>&#39;static</code> 生命周期，但 <code>&amp;p</code> 只是栈上变量的借用。<br><strong>解决</strong>：使用 <code>Arc::new(p)</code> 把数据移到堆上，通过原子引用计数实现多所有权：</p>
<pre><code class="language-rust">let p = Arc::new(Person { /* ... */ });
let p_clone = p.clone(); // 原子计数 +1
thread::spawn(move || println!(&quot;{:?}&quot;, p_clone)); // ✅ 编译通过
</code></pre>
<h2>读完本篇你能学到什么</h2>
<ul>
<li><strong>原子操作的内存顺序选择</strong>：通过引用计数这个具体例子，理解 <code>Relaxed / Acquire / Release / fence</code> 的使用场景。</li>
<li><strong>弱引用解决循环引用</strong>：<code>Weak&lt;T&gt;</code> 的设计原理和在图结构中的应用。</li>
<li><strong>Arc::get_mut 的安全性保证</strong>：理解「非原子两步校验」的巧妙设计。</li>
<li><strong>零成本抽象的实现细节</strong>：<code>UnsafeCell</code> + <code>ManuallyDrop</code> 相比 <code>Option&lt;T&gt;</code> 的优势。</li>
</ul>
<h2>v0: 基础引用计数</h2>
<h3>数据结构</h3>
<p>我们先来分析一下基本的数据结构该如何定义，在之前的 <a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a>，我们讲到了 C++/Rust 常用的并发编程方式 RAII（Resource Acquisition Is Initialization，资源获取即初始化），其核心思想是：<strong>在对象构造函数中获取资源，在析构函数中释放资源</strong>。上面的案例中，我们在初始化 <code>Person</code> 的时候其实也是应用了这种思路。</p>
<pre><code class="language-rust">let p = Arc::new(Person {
    age: 18,
    name: &quot;hedon&quot;.to_string(),
    address: &quot;China&quot;.to_string(),
});
</code></pre>
<p>所以我们的自定义 <code>Arc</code> 需要泛型 <code>T</code>，使其可以承载不同的数据类型的数据 <code>data</code>，同时为了做引用计数，它需要一个数值类型 <code>ref_count</code> 来计数，为了并发安全，我们可以选择原子类型 <code>AtomicUsize</code>。</p>
<p>在 <code>Arc</code> 中，我们需要自己管理 <code>data</code> 的生命周期，除了使用裸指针 <code>*mut T</code> 或 <code>*const T</code> 之外，我们可以使用 <code>std::ptr::NonNull&lt;T&gt;</code> ，它是一个零成本、保证非空、支持协变（可安全向子类型转换）的裸指针包装器，具体可参考<strong>附录 1. NonNull&lt;T&gt;</strong>。</p>
<p>综上，我们可以定义以下数据结构：</p>
<pre><code class="language-rust">struct ArcData&lt;T&gt; {
    ref_count: AtomicUsize,
    data: T,
}

pub struct Arc&lt;T&gt; {
    ptr: NonNull&lt;ArcData&lt;T&gt;&gt;,
}

impl&lt;T&gt; Arc&lt;T&gt; {
    pub fn new(data: T) -&gt; Self {
        Arc {
            ptr: NonNull::from(Box::leak(Box::new(ArcData {
                ref_count: AtomicUsize::new(1),
                data,
            }))),
        }
    }
}
</code></pre>
<ol>
<li>我们定义了 <code>ArcData</code> 和 <code>Arc</code> 两个结构，其中 <code>ArcData</code> 包含引用计数 <code>ref_count</code> 和实际数据 <code>data</code>。</li>
<li>在 <code>Arc</code> 中，我们使用 <code>NonNull</code> 来管理 <code>ArcData</code> 的生命周期。初始化时，先用 <code>Box::new()</code> 在堆上分配内存，再通过 <code>Box::leak()</code> 放弃 <code>Box&lt;T&gt;</code> 的所有权，交由 <code>Arc</code> 自行管理。</li>
</ol>
<pre><code class="language-mermaid">graph TB
    subgraph Stack [&quot;栈内存 Stack&quot;]
        ArcStruct[&quot;Arc&amp;lt;T&amp;gt;&lt;br/&gt;ptr: NonNull&amp;lt;ArcData&amp;lt;T&amp;gt;&amp;gt;&quot;]
    end

    subgraph Heap [&quot;堆内存 Heap&quot;]
        ArcDataStruct[&quot;ArcData&amp;lt;T&amp;gt;&lt;br/&gt;ref_count: AtomicUsize(1)&lt;br/&gt;data: T&quot;]
    end

    ArcStruct --&gt;|指向| ArcDataStruct

    subgraph Process [&quot;内存分配过程&quot;]
        Step1[&quot;Box::new(ArcData)&quot;]
        Step2[&quot;Box::leak()&quot;]
        Step3[&quot;NonNull::from()&quot;]

        Step1 --&gt; Step2
        Step2 --&gt; Step3
    end

    classDef stackStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
    classDef heapStyle fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
    classDef processStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px

    class ArcStruct stackStyle
    class ArcDataStruct heapStyle
    class Step1,Step2,Step3 processStyle
</code></pre>
<p>另外，跨线程发送 <code>Arc&lt;T&gt;</code> 会导致 <code>T</code> 对象被共享，即 <code>T</code> 需要满足 <code>Sync</code> trait，而跨线程发送 <code>Arc&lt;T&gt;</code> 也会导致需要由另外一个线程来释放 <code>T</code>，所以需要 <code>T</code> 满足 <code>Send</code> trait。所以只有当 <code>T</code> 满足 <code>Send+Sync</code> 的时候，<code>Arc&lt;T&gt;</code> 才是 <code>Send</code> 的，对于 <code>Sync</code> 也是同理，因为我们可以为 <code>Arc&lt;T&gt;</code> 分别实现 <code>Sync</code> 和 <code>Send</code> trait：</p>
<pre><code class="language-rust">unsafe impl&lt;T: Send + Sync&gt; Send for Arc&lt;T&gt; {}
unsafe impl&lt;T: Send + Sync&gt; Sync for Arc&lt;T&gt; {}
</code></pre>
<p>为了能够便捷的获取 <code>data</code>，我们先为 <code>Arc&lt;T&gt;</code> 实现一个 <code>data()</code> 用于获取 <code>ArcData&lt;T&gt;</code>，同时为其实现 <code>Deref</code> trait，用于像指针一样无感操作 <code>data: T</code>：</p>
<pre><code class="language-rust">impl&lt;T&gt; Arc&lt;T&gt; {
    fn data(&amp;self) -&gt; &amp;ArcData&lt;T&gt; {
        unsafe { self.ptr.as_ref() }
    }
}

impl&lt;T&gt; Deref for Arc&lt;T&gt; {
    type Target = T;
    fn deref(&amp;self) -&gt; &amp;Self::Target {
        &amp;self.data().data
    }
}
</code></pre>
<h3>维护引用计数</h3>
<p>基础部分我们已经铺垫完了，现在我们需要来实现 2 个最重要的 trait 了：</p>
<ul>
<li><strong>Clone</strong>: <code>Arc</code> 引用计数的关键，在每次 <code>clone()</code> 的时候，我们不拷贝 <code>data</code>，而是让 <code>ref_count</code> 自增，进行引用计数。</li>
<li><strong>Drop</strong>: 在 <code>Arc&lt;T&gt;</code> 实例离开作用域的时候，我们需要让 <code>ref_count</code> 自减，同时在最后一个 <code>Arc&lt;T&gt;</code> 被销毁时，我们需要主动释放 <code>ArcData&lt;T&gt;</code> 的内存资源。</li>
</ul>
<p>我们先来实现 <code>Clone</code>：</p>
<pre><code class="language-rust">impl&lt;T&gt; Clone for Arc&lt;T&gt; {
    fn clone(&amp;self) -&gt; Self {
      	// TODO: 处理整型溢出的情况
        self.data().ref_count.fetch_add(1, Ordering::Relaxed)
        Arc { ptr: self.ptr }
    }
}
</code></pre>
<p>因为这里没有其他原子操作需要跟当前操作建立严格的 <code>happens-before</code> 关系，所以这里我们可以使用最松的 <code>Relaxed</code> 内存顺序。</p>
<p>接下来看下 <code>Drop</code>：</p>
<pre><code class="language-rust">impl &lt;T&gt; Drop for Arc&lt;T&gt; {
    fn drop(&amp;mut self) {
        if self.data().ref_count.fetch_sub(1, ???) == 1 { // &lt;----- 这里需要什么使用内存顺序约束？
            unsafe {
                drop(Box::from_raw(self.ptr.as_ptr()));
            }
        }
    }
}
</code></pre>
<p>对于 <code>Drop</code>，我们就需要好好想一想需要什么内存顺序约束了。在最后一个 <code>drop</code> 的时候，我们有 2 个目标：</p>
<ol>
<li><strong>不能过早释放</strong>：确保在引用计数减到 0 并销毁对象之前，没有别的线程仍在使用这份数据；</li>
<li><strong>要看得见别人写的东西</strong>：如果别的线程在它们各自的 <code>drop</code> 里面对共享对象做了写入，最后一个线程做析构时必须&quot;看到&quot;这些写入，否则就可能出现数据竞争或次序错误。</li>
</ol>
<p>换言之，我们需要最后一个 <code>fetch_sub</code> 跟前面其他每一个 <code>fetch_sub</code> 都建立起 happens before 关系，也即我们需要一对 Release 和 Acquire 来保证 happens-before。</p>
<p>这里简单回顾一下 <code>Release</code> 和 <code>Acquire</code>，不熟悉的读者可以参阅：<a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a>。</p>
<ul>
<li><code>Release</code>: 作用于写操作（store），确保该操作之前的所有内存访问不会被重排到这个 Release 操作之后。</li>
<li><code>Acquire</code>: 作用于读操作（load），确保该操作之后的所有内存访问不会被重排到这个 Acquire 操作之前。</li>
<li>当一个线程通过 <code>Acquire</code> 读取到另一个线程通过 <code>Release</code> 写入的值时，会建立一个 happens-before 关系：<strong>线程 A 中 Release 写入之前的所有内存写操作，对于线程 B 中 Acquire 读取之后的所有内存读操作都是可见的</strong>。</li>
</ul>
<blockquote>
<p><strong>Release-Sequence 概念补充</strong>：当多个线程对同一个原子变量执行 Release 操作时，这些操作会形成一个 &quot;release-sequence&quot;。后续任何一个 Acquire 操作读取到这个序列中的任意值，都能与整个序列建立 happens-before 关系。这正是为什么我们的 <code>fence(Acquire)</code> 能够与之前<strong>所有线程</strong>的 <code>fetch_sub(Release)</code> 形成同步的关键。</p>
</blockquote>
<p>我们当然可以使用 <code> Release</code> 和 <code>Acquire</code> 的结合体 <code>AcqRel</code> 来一步到位解决这个问题。不过考虑到只有最后一个 <code>drop</code> 需要满足这个关系，我们可以尝试做得更优雅一些。</p>
<p><strong>在这种仅需在临界值保证 happens-before 的场景下，我们都可以单独在临界情况下使用一个 <code>fence</code> 来建立起 happens-before。</strong></p>
<p>具体来说：</p>
<ul>
<li><strong>对于非最终 <code>drop</code></strong>：我们只需要使用 <code>Release</code>，即 <code>fetch_sub(1, Ordering::Release)</code>，它保证了别的线程如果最终做&quot;最后一次 drop&quot;，只要它对相同原子执行一条 <em>Acquire</em> 操作，它就能同步到前面所有线程对数据做过的改动。</li>
<li><strong>对于最终 <code>drop</code></strong>：在调用 <code>fetch_sub</code> 的时候我们仍需要 <code>Release</code> 语义，但是调用时，我们并不知道自己是不是&quot;最后那个线程&quot;。当 <code>fetch_sub</code> 返回 <code>1</code> 的时候，说明我们是&quot;最后那个线程&quot;。这个时候，我们需要建立一个 <code>fetch(Acquire)</code>，与之前的所有 <code>Release</code> 形成配对，确保看到<strong>之前所有的历史写入</strong>，这个时候我们才能确定已经没有别的线程在使用数据了，我们才可以安全地销毁对象。</li>
</ul>
<p>如下图所示；</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant A as Thread A
    participant B as Thread B
    participant C as Thread C&lt;br/&gt;(最后 drop)

    %% 普通写入
    A-&gt;&gt;A: write shared data …
    B-&gt;&gt;B: write shared data …

    %% 非最终 drop：Release 写
    A-&gt;&gt;A: fetch_sub (Release)  ⬅ 计数 n→n-1
    B-&gt;&gt;B: fetch_sub (Release)  ⬅ 计数 n-1→n-2

    %% 形成 release-sequence
    Note over A,B: 这两次 Release 写组成&lt;br/&gt;同一条 release-sequence

    %% 最终 drop：Release 写 &amp; 返回 1
    C-&gt;&gt;C: fetch_sub (Release)  ⬅ 返回 1 → 计数 0

    %% Acquire fence 同步
    C--&gt;&gt;C: fence (Acquire)&lt;br/&gt;【接收 release-sequence】

    %% 安全析构
    C-&gt;&gt;C: drop(Box::from_raw)

    %% 结果说明
    Note over C: fence (Acquire) 使 A/B&lt;br/&gt;对共享数据的写必定&lt;br/&gt;在析构前可见
</code></pre>
<p>经过这么一顿分析后，我们最终的 <code>Drop</code> 实现如下：</p>
<pre><code class="language-rust">impl&lt;T&gt; Drop for Arc&lt;T&gt; {
    fn drop(&amp;mut self) {
        // ❶ 所有 `drop` 都执行：Release
        if self.data().ref_count.fetch_sub(1, Ordering::Release) == 1 {
            // ❷ 只有最后一个引用才会进来
            std::sync::atomic::fence(Ordering::Acquire); // ❸ 补上 Acquire
            unsafe {
                // ❹ 现在可以安全地回收并析构
                drop(Box::from_raw(self.ptr.as_ptr()));
            }
        }
    }
}
</code></pre>
<p>实现逻辑：</p>
<ol>
<li><strong>所有 drop</strong> 都用 <code>Release</code> 语义减引用计数，为可能的&quot;最后一次 drop&quot;做准备</li>
<li><strong>只有最后一次 drop</strong>（返回值为 1）才需要额外的 <code>fence(Acquire)</code> 与之前所有的 Release 建立 happens-before</li>
<li><strong>安全析构</strong>：现在可以确保看到所有历史写入，没人再持有引用</li>
</ol>
<h3>完整代码</h3>
<p>自此，我们第一个版本的 <code>Arc</code> 就实现完毕了，我们来看一下最终完成的代码：</p>
<pre><code class="language-rust">use std::sync::atomic::{AtomicUsize, Ordering, fence};
use std::ptr::NonNull;
use std::ops::Deref;

struct ArcData&lt;T&gt; {
    ref_count: AtomicUsize,
    data: T,
}

pub struct Arc&lt;T&gt; {
    ptr: NonNull&lt;ArcData&lt;T&gt;&gt;,
}

impl&lt;T&gt; Arc&lt;T&gt; {
    pub fn new(data: T) -&gt; Self {
        Arc {
            ptr: NonNull::from(Box::leak(Box::new(ArcData {
                ref_count: AtomicUsize::new(1),
                data,
            }))),
        }
    }

    fn data(&amp;self) -&gt; &amp;ArcData&lt;T&gt; {
        unsafe { self.ptr.as_ref() }
    }
}

unsafe impl&lt;T: Send + Sync&gt; Send for Arc&lt;T&gt; {}
unsafe impl&lt;T: Send + Sync&gt; Sync for Arc&lt;T&gt; {}

impl&lt;T&gt; Deref for Arc&lt;T&gt; {
    type Target = T;
    fn deref(&amp;self) -&gt; &amp;Self::Target {
        &amp;self.data().data
    }
}

impl&lt;T&gt; Clone for Arc&lt;T&gt; {
    fn clone(&amp;self) -&gt; Self {
        self.data().ref_count.fetch_add(1, Ordering::Relaxed);
        Arc { ptr: self.ptr }
    }
}

impl&lt;T&gt; Drop for Arc&lt;T&gt; {
    fn drop(&amp;mut self) {
        if self.data().ref_count.fetch_sub(1, Ordering::Release) == 1 {
            fence(Ordering::Acquire);
            unsafe {
                drop(Box::from_raw(self.ptr.as_ptr()));
            }
        }
    }
}
</code></pre>
<h3>单元测试</h3>
<p>写个单元测试验证一下：</p>
<pre><code class="language-rust">#[test]
fn arc_should_work() {
    static NUM_DROPS: AtomicUsize = AtomicUsize::new(0);

    struct DetectDrop;
    impl Drop for DetectDrop {
        fn drop(&amp;mut self) {
            NUM_DROPS.fetch_add(1, Ordering::Relaxed);
        }
    }

    // 创建 2 个 Arc 共享一个元组，包含一个字符串和 DetectDrop 对象
    let x = Arc::new((&quot;hedon&quot;, DetectDrop));
    let y = x.clone();

    // 将 x 转移到另外一个线程并使用它
    let t = thread::spawn(move || {
        assert_eq!(x.0, &quot;hedon&quot;); // 可以正常使用
    });

    // 这里，y 应该也是可以正常使用的
    assert_eq!(y.0, &quot;hedon&quot;);

    // 等待 t 线程执行完毕
    t.join().unwrap();

    // 这个时候 `x` 已经被释放了，但是`y` 还没有被释放，
    // 所以 `DetectDrop` 应该还没被释放。
    assert_eq!(NUM_DROPS.load(Ordering::Relaxed), 0);

    // 手动释放掉 `y`，这是最后一个 `Arc`，所以 `DetectDrop` 应该会被释放
    drop(y);
    assert_eq!(NUM_DROPS.load(Ordering::Relaxed), 1);
}
</code></pre>
<p>执行结果是 ok 的：</p>
<pre><code class="language-shell">running 1 test
test arc::tests::arc_should_work ... ok

successes:

successes:
    arc::tests::arc_should_work

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
</code></pre>
<h2>v1: 实现 get_mut 获取可变引用</h2>
<p>按照前面版本的实现，我们是不能为 <code>Arc&lt;T&gt;</code> 实现 <code>DerefMut</code> trait 的，因为我们不能无条件地提供 <code>&amp;mut T</code> 可变引用，因为它可能同时被其他的 <code>Arc&lt;T&gt;</code> 所访问中。</p>
<p>不过，当满足一定条件的时候，我们还是可以提供 <code>&amp;mut T</code> 可变引用的。具体来说，需要满足 2 个条件：</p>
<ol>
<li>使用 <code>&amp;mut Arc&lt;T&gt;</code> 保证当前 <code>Arc&lt;T&gt;</code> 只有一个地方在使用；</li>
<li>需要确保全局只有一个 <code>Arc&lt;T&gt;</code>。</li>
</ol>
<p>为了避免跟 <code>DerefMut</code> 混淆，我们将其声明为一个静态方法，并将 <code>Arc&lt;T&gt;</code> 作为参数，同时因为只有满足特定条件的情况下，才能返回 <code>&amp;mut T</code>，所以我们使用 <code>Option</code> 作为返回值。如下：</p>
<pre><code class="language-rust">pub fn get_mut(arc: &amp;mut Arc&lt;T&gt;) -&gt; Option&lt;&amp;mut T&gt; {
    if arc.data().ref_count.load(Ordering::Relaxed) == 1 {
        fence(Ordering::Acquire);
        // Safety: 没有其他地方可以访问数据，
        // 因为我们使用了 &amp;mut 独占引用，而且此时只有一个 `Arc`。
        unsafe { Some(&amp;mut arc.ptr.as_mut().data) }
    } else {
        None
    }
}
</code></pre>
<ol>
<li><p>这里我们在确定只有一个 <code>Arc</code> 实例的时候，使用 <code>fetch(Acquire)</code> 来跟所有其他 <code>Arc</code> 执行 <code>drop()</code> 的 <code>fetch_sub(Release)</code> 建立 happens-before 关系。</p>
</li>
<li><p>因为此时 <code>ref_count=1</code>，说明仅有当前 <code>Arc</code> 这一个实例，而 <code>get_mut</code> 需要的又是独占引用，所以当前 <code>Arc</code> 不可能再被拿去做 <code>clone()</code> 操作，所以在这个情况下，是可以保证有且仅有一个 <code>Arc</code> 实例，所以我们是可以安全返回 <code>&amp;mut T</code> 的。</p>
</li>
<li><p>返回的 <code>&amp;mut T</code> 会隐式地借用参数 <code>&amp;mut Arc&lt;T&gt;</code> 的生命周期，而 Rust 不允许可变引用和只读引用交叉存在，所以当前仅剩的这个 <code>Arc</code>，直到 <code>&amp;mut T</code> 作用域结束之前，都是不可用的，所以在那之前，不会再被拿去执行 <code>clone()</code> 和 <code>Deref</code> 等操作，所以是安全的。</p>
<pre><code class="language-rust">#[test]
fn get_mut_should_be_safe() {
    let mut x = Arc::new(vec![]);
    let v = Arc::get_mut(&amp;mut x).unwrap();
    v.push(1);

    let y = x.clone(); // &lt;---- cannot borrow `x` as immutable
    v.push(2);
}
</code></pre>
<p>如上面这个例子，就会报错：</p>
<pre><code class="language-shell">error[E0502]: cannot borrow `x` as immutable because it is also borrowed as mutable
   --&gt; src/arc.rs:117:17
    |
114 |         let v = Arc::get_mut(&amp;mut x).unwrap();
    |                              ------ mutable borrow occurs here
...
117 |         let y = x.clone();
    |                 ^ immutable borrow occurs here
118 |         v.push(2);
    |         - mutable borrow later used here
</code></pre>
</li>
</ol>
<h2>v2: 弱指针 Weak&lt;T&gt; 解决循环引用</h2>
<h3>循环引用问题分析</h3>
<pre><code class="language-mermaid">graph TD
	A --&gt; B;
	A --&gt; C;
</code></pre>
<p>引用计数在各种数据结构的表示中非常有用，如上图所示的树形结构，当释放 A 的时候，因为 B 和 C 不再被引用，所以也可以顺带释放了。</p>
<p>但是，如果 B 和 C 也持有对 A 的引用，即形成了循环引用，那按照我们之前的实现，A、B、C 都将永远不会被释放了，因为它们的引用计数永不为 0，哪怕它们三者均不再被使用。如下图所示：</p>
<pre><code class="language-mermaid">graph TD
	A &lt;--&gt; B;
	A &lt;--&gt; C;
	B -.-&gt; A;
	C -.-&gt; A;
</code></pre>
<p>为了应对这种场景，Rust 的标准库中提出的解决方案是：<strong><code>Weak&lt;T&gt;</code>，也称弱指针</strong>。<strong><code>T</code> 可以在 <code>Arc&lt;T&gt;</code> 和 <code>Weak&lt;T&gt;</code> 之间共享，当所有的 <code>Arc&lt;T&gt;</code> 都失效的时候，<code>T</code> 被释放，无论此时是否有 <code>Weak&lt;T&gt;</code> 的存在。</strong></p>
<p>如下图所示，父节点对子节点使用的是强引用，确保了父节点存活的时候，子节点都存在。而子节点对父节点使用的是弱引用，只即保留了子节点回溯父节点的能力，也不会阻止父节点的释放。</p>
<p>当父节点被释放时，所有强引用计数归零，节点可以依次被释放。</p>
<pre><code class="language-mermaid">graph TD
	A -- Arc --&gt; B;
	A -- Arc --&gt; C;
	B -.-&gt;|Weak| A;
	C -.-&gt;|Weak| A;
</code></pre>
<h3>数据结构</h3>
<p>OK，做了这么多铺垫后，我们来思考一下现在的 <code>ArcData</code> 该如何调整。</p>
<ol>
<li>之前我们用 <code>ref_count</code> 做引用计数，它代表的都是强引用，现在我们需要记录弱引用数量的相关字段。</li>
<li>当只有弱引用的时候，<code>data</code> 就已经被释放了，我们需要使用 <code>None</code> 来表示这种情况，所以 <code>data</code> 应该是一个 <code>Option&lt;T&gt;</code> 类型。</li>
<li>当 <code>ArcData&lt;T&gt;</code> 被一个 <code>Arc</code> 和多个 <code>Weak</code> 共享时，释放最后一个 <code>Arc</code> 时，我们仅拥有 <code>&amp;ArcData&lt;T&gt;</code> 不可变引用，这个时候我们需要将其从 <code>Some(T)</code> 置为 <code>None</code>，即要在不可变引用上实现修改，这就涉及到了前几篇提到的：<strong>内部可变性</strong>。<strong>UnsafeCell</strong> 是 Rust 提供的一个内部可变性工具类型，它包装一个数据，使得即使在只有不可变引用的情况下也可以进行修改（当然需要在 <code>unsafe</code> 块中操作）。所以我们需要将 <code>data</code> 再用 <code>UnsafeCell</code> 包一层，以满足此场景的需求。</li>
</ol>
<p>综上，最新的 <code>ArcData</code> 定义如下：</p>
<pre><code class="language-rust">struct ArcData&lt;T&gt; {
    // `Arc` 的数量，也即数据 T 的强引用计数。
    data_ref_count: AtomicUsize,
    // `Arc` + `Weak` 的数量。
    alloc_ref_count: AtomicUsize,
    // 数据，当只有 `Weak` 的时候，为 None。
    data: UnsafeCell&lt;Option&lt;T&gt;&gt;,
}
</code></pre>
<p>然后我们需要定义一个新结构 <code>Weak&lt;T&gt;</code> ，用来表示弱引用，假定这个时候，我们将维护 <code>ArcData</code> 存活的职责交给 <code>Weak&lt;T&gt;</code>，那就可以将 <code>ArcData</code> 转给 <code>Weak&lt;T&gt;</code> 持有，然后在 <code>Arc&lt;T&gt;</code> 中持有一个 <code>Weak&lt;T&gt;</code>：</p>
<pre><code class="language-rust">struct ArcData&lt;T&gt; {
    // `Arc` 的数量，也即数据 T 的强引用计数。
    data_ref_count: AtomicUsize,
    // `Arc` + `Weak` 的数量。
    alloc_ref_count: AtomicUsize,
    // 数据，当只有 `Weak` 的时候，为 None。
    data: UnsafeCell&lt;Option&lt;T&gt;&gt;,
}

pub struct Arc&lt;T&gt; {
    weak: Weak&lt;T&gt;,
}

pub struct Weak&lt;T&gt; {
    ptr: NonNull&lt;ArcData&lt;T&gt;&gt;,
}

unsafe impl&lt;T: Send + Sync&gt; Send for Weak&lt;T&gt; {}
unsafe impl&lt;T: Send + Sync&gt; Sync for Weak&lt;T&gt; {}

impl&lt;T&gt; Arc&lt;T&gt; {
    pub fn new(data: T) -&gt; Self {
        Arc {
            weak: Weak {
                ptr: NonNull::from(Box::leak(Box::new(ArcData {
                    alloc_ref_count: AtomicUsize::new(1),
                    data_ref_count: AtomicUsize::new(1),
                    data: UnsafeCell::new(Some(data)),
                }))),
            },
        }
    }
}

impl&lt;T&gt; Weak&lt;T&gt; {
    fn data(&amp;self) -&gt; &amp;ArcData&lt;T&gt; {
        unsafe { self.ptr.as_ref() }
    }
}

impl&lt;T&gt; Deref for Arc&lt;T&gt; {
    type Target = T;
    fn deref(&amp;self) -&gt; &amp;Self::Target {
        let ptr = self.weak.data().data.get();
        // Safety: 这个时候还有 Arc 存在，所以 data 肯定是生效的。
        unsafe { (*ptr).as_ref().unwrap() }
    }
}
</code></pre>
<p>在上述代码中，除了数据结构之外，我们做了 3 点调整：</p>
<ol>
<li>只需要为 <code>Weak&lt;T&gt;</code> 实现 <code>Sync</code> 和 <code>Send</code> trait，这个时候 <code>Arc&lt;T&gt;</code> 就会被自动实现这 2 个 triat。</li>
<li><code>data(&amp;self)</code> 辅助函数，移到了 <code>Weak&lt;T&gt;</code> 身上。</li>
<li><code>Arc&lt;T&gt;</code> 要从 <code>Deref</code> 获取 <code>&amp;T</code>，需要先经过一道 <code>Weak&lt;T&gt;</code>。</li>
</ol>
<pre><code class="language-mermaid">graph LR
    subgraph Stack [&quot;栈内存 Stack&quot;]
        ArcStruct[&quot;Arc&amp;lt;T&amp;gt;&lt;br/&gt;weak: Weak&amp;lt;T&amp;gt;&quot;]
        WeakStruct[&quot;Weak&amp;lt;T&amp;gt;&lt;br/&gt;ptr: NonNull&amp;lt;ArcData&amp;lt;T&amp;gt;&amp;gt;&quot;]
    end

    subgraph Heap [&quot;堆内存 Heap&quot;]
        ArcDataStruct[&quot;ArcData&amp;lt;T&amp;gt;&lt;br/&gt;data_ref_count: AtomicUsize(1)&lt;br/&gt;alloc_ref_count: AtomicUsize(1)&lt;br/&gt;data: UnsafeCell&amp;lt;Option&amp;lt;T&amp;gt;&amp;gt;&quot;]
    end

    ArcStruct --&gt;|包含| WeakStruct
    WeakStruct --&gt;|指向| ArcDataStruct


    classDef stackStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
    classDef heapStyle fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
    classDef processStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px
    classDef refStyle fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px

    class ArcStruct,WeakStruct stackStyle
    class ArcDataStruct heapStyle
    class Step1,Step2,Step3,Step4,Step5 processStyle
    class DataRef,AllocRef,DataContent refStyle
</code></pre>
<h3>维护引用计数</h3>
<p>现在重点来了，我们需要来思考 <code>Weak&lt;T&gt;</code> 和 <code>Arc&lt;T&gt;</code> 的 <code>Clone</code> 和 <code>Drop</code> 该如何实现。其实重点就是该<strong>如何管理 <code>alloc_ref_count</code> 和 <code>data_ref_count</code> 的计数</strong>。</p>
<p>再次明确下我们的定义：</p>
<ul>
<li><code>data_ref_count</code>: 代表的是强引用 <code>Arc&lt;T&gt;</code> 的数量。</li>
<li><code>alloc_ref_count</code>: 代表的是 <code>Arc&lt;T&gt;</code> + <code>Weak&lt;T&gt;</code> 的数量。</li>
</ul>
<p>因此：</p>
<ul>
<li>Clone:<ul>
<li>当 <code>Weak&lt;T&gt;</code> 拷贝时，仅增加了弱引用的数量，所以我们只需对 <code>alloc_ref_count</code> 进行自增。</li>
<li>当 <code>Arc&lt;T&gt;</code> 拷贝时，不仅需要拷贝内部的 <code>Weak&lt;T&gt;</code>，还增加了强引用的数量，所以我们还需要对 <code>data_ref_count</code> 进行自增。</li>
</ul>
</li>
<li>Drop:<ul>
<li>当 <code>Arc&lt;T&gt;</code> 被释放时，不仅释放了其内部的 <code>Weak&lt;T&gt;</code>，还减少了一个强引用，所以需要对 <code>data_ref_count</code> 进行自减。另外，如果是最后一个 <code>Arc&lt;T&gt;</code> 被释放，我们需要将 <code>ArcData&lt;T&gt;.data</code> 置为 <code>None</code>。</li>
<li>当 <code>Weak&lt;T&gt;</code> 被释放时，我们仅需减少弱引用的数量，即对 <code>alloc_ref_count</code> 进行自减。另外，当最后一个 <code>Weak&lt;T&gt;</code> 被释放时（此时肯定也没有 <code>Arc&lt;T&gt;</code> 了），我们还需要负责释放 <code>ArcData&lt;T&gt;</code> 。</li>
</ul>
</li>
</ul>
<p>综上，我们的实现如下：</p>
<pre><code class="language-rust">impl&lt;T&gt; Clone for Weak&lt;T&gt; {
    fn clone(&amp;self) -&gt; Self {
        if self.data().alloc_ref_count.fetch_add(1, Ordering::Relaxed) &gt; usize::MAX / 2 {
            std::process::abort();
        };
        Self { ptr: self.ptr }
    }
}

impl&lt;T&gt; Clone for Arc&lt;T&gt; {
    fn clone(&amp;self) -&gt; Self {
        let weak = self.weak.clone();
        if weak.data().data_ref_count.fetch_add(1, Ordering::Relaxed) &gt; usize::MAX / 2 {
            std::process::abort();
        }
        Arc { weak }
    }
}

impl&lt;T&gt; Drop for Weak&lt;T&gt; {
    fn drop(&amp;mut self) {
        if self.data().alloc_ref_count.fetch_sub(1, Ordering::Release) == 1 {
            fence(Ordering::Acquire);
            // Safety: 最后一个 Weak&lt;T&gt; 已经被释放了。
            unsafe {
                drop(Box::from_raw(self.ptr.as_ptr()));
            }
        }
    }
}

impl&lt;T&gt; Drop for Arc&lt;T&gt; {
    fn drop(&amp;mut self) {
        if self
            .weak
            .data()
            .data_ref_count
            .fetch_sub(1, Ordering::Release)
            == 1
        {
            fence(Ordering::Acquire);
            let ptr = self.weak.data().data.get();
            // Safety: data_ref_count 已经为 0 了，没有地方在使用 data 了。
            unsafe { (*ptr) = None }
        }
    }
}
</code></pre>
<blockquote>
<p>这里考虑到实际场景中的内存限制，我们对引用计数进行了简单的限制，当其超过 <code>usize::Max/2</code> 的时候，就执行 <code>std::process:abort()</code> 让整个进程崩溃。</p>
</blockquote>
<h3>get_mut()</h3>
<p>接下来我们来考虑下 <code>get_mut(arc: &amp;mut Arc&lt;T&gt;)</code> 该如何修改。什么时候可以返回 <code>&amp;mut T</code>，很明显，<strong>当且仅当只有一个 <code>Arc&lt;T&gt;</code> 的时候才可以</strong>。</p>
<p>故 <code>get_mut</code> 的实现修改如下：</p>
<pre><code class="language-rust">pub fn get_mut(arc: &amp;mut Arc&lt;T&gt;) -&gt; Option&lt;&amp;mut T&gt; {
    if arc.weak.data().alloc_ref_count.load(Ordering::Relaxed) == 1 {
        fence(Ordering::Acquire);
        // Safety: 这个时候没有其他地方能使用数据，因为只有一个 `Arc`，
        // 同时我们拥有仅存的这个 `Arc` 的不可变引用。
        let arcdata = unsafe { arc.weak.ptr.as_mut() };
        let option = arcdata.data.get_mut();
        let data = option.as_mut().unwrap();
        Some(data)
    } else {
        None
    }
}
</code></pre>
<p>到这里，我们执行之前的 <code>arc_should_work</code> 测试用例，可以发现是顺利通过的。</p>
<h3>dowgrade()</h3>
<p>回顾下面这张图，为了提供 A 的 <code>Weak&lt;T&gt;</code> 指针，我们需要给 <code>Arc&lt;T&gt;</code> 提供一个 <code>downgrade()</code> 方法，用于将强引用降为弱引用，这是必然可以成功的。同时，当我们也可以为 <code>Weak&lt;T&gt;</code> 提供一个 <code>upgrade()</code> 方法，用于持有弱引用的情况下可以尝试访问数据，当然这未必能成功。</p>
<pre><code class="language-mermaid">graph TD
	A -- Arc --&gt; B;
	A -- Arc --&gt; C;
	B -.-&gt;|Weak| A;
	C -.-&gt;|Weak| A;
</code></pre>
<p><code>downgrade()</code> 比较简单，我们直接 <code>clone()</code> 一个 <code>Weak&lt;T&gt;</code> 就可以了：</p>
<pre><code class="language-rust">impl&lt;T&gt; Arc&lt;T&gt; {
    pub fn downgrade(&amp;self) -&gt; Weak&lt;T&gt; {
        self.weak.clone()
    }
}
</code></pre>
<h3>upgrade()</h3>
<p><code>upgrade()</code> 就比较复杂了，只有当存在 <code>Arc</code> 的时候，<code>data</code> 才没被释放，这个时候，才能返回升级后的 <code>Arc&lt;T&gt;</code>，即要求 <code>data_ref_count&gt;0</code>。</p>
<pre><code class="language-rust">impl&lt;T&gt; Weak&lt;T&gt; {
    pub fn upgrade(&amp;self) -&gt; Option&lt;Arc&lt;T&gt;&gt; {
      	// 获取 data_ref_count 的值
        let mut n = self.data().data_ref_count.load(Ordering::Relaxed);
        loop {
          	// 如果等于 0，则说明 data 已经被释放了，直接返回 None
            if n == 0 {
                return None;
            }
            assert!(n &lt; usize::MAX);
          	// 不为 0 的话，data_ref_count 尝试进行 +1，失败了就重试
            if let Err(e) = self.data().data_ref_count.compare_exchange_weak(
                n,
                n + 1,
                Ordering::Relaxed,
                Ordering::Relaxed,
            ) {
                n = e;
                continue;
            }
          	// 成功了，则 clone weak 即可（执行 alloc_ref_count++）
            return Some(Arc { weak: self.clone() });
        }
    }
}
</code></pre>
<p>我们来写一下测试用例，验证使用了 <code>Weak&lt;T&gt;</code> 后，我们的资源能否正确释放。在这之前，我们先写一个非 <code>Weak&lt;T&gt;</code> 版本的，看看资源是否真的没有被释放：</p>
<pre><code class="language-rust">#[test]
fn no_weak_should_not_free_resource() {
    static NUM_DROPS: AtomicUsize = AtomicUsize::new(0);
    struct Node {
      	// 使用 RefCell，利用其内部可变性，方便我们建立父子关系
        child: RefCell&lt;Vec&lt;Arc&lt;Node&gt;&gt;&gt;,
        parent: RefCell&lt;Option&lt;Arc&lt;Node&gt;&gt;&gt;, // &lt;---- 这里持有的是强引用
    }

    impl Drop for Node {
        fn drop(&amp;mut self) {
            NUM_DROPS.fetch_add(1, Ordering::Relaxed);
        }
    }

    impl Node {
        pub fn new() -&gt; Self {
            Self {
                child: RefCell::new(vec![]),
                parent: RefCell::new(None),
            }
        }

      	// 建立父子关系
        pub fn add_child(parent: &amp;Arc&lt;Node&gt;, child: Arc&lt;Node&gt;) {
            *child.parent.borrow_mut() = Some(parent.clone());
            parent.child.borrow_mut().push(child);
        }
    }

  	// 限定作用域，离开作用域后，root/child1/child2 正常情况应该被释放。
    {
        let root = Arc::new(Node::new());
        let child1 = Arc::new(Node::new());
        let child2 = Arc::new(Node::new());

        Node::add_child(&amp;root, child1);
        Node::add_child(&amp;root, child2);
    }

    assert_ne!(NUM_DROPS.load(Ordering::Relaxed), 3); // 不为 3
}
</code></pre>
<p>执行上述用例后，我们可以发现 <code>NUM_DROPS</code> 还是 0，即 <code>root/child1/child2</code> 均没有被释放资源。</p>
<p>你可以将 <code>parent</code> 的引用修改为弱引用，然后再重新执行测试用例，就会发现 <code>root/child1/child2</code> 的资源都被正确释放了。</p>
<pre><code class="language-rust">struct Node {
    child: RefCell&lt;Vec&lt;Arc&lt;Node&gt;&gt;&gt;,
    parent: RefCell&lt;Option&lt;Weak&lt;Node&gt;&gt;&gt;, // &lt;---- 这里持有的是弱引用
}

impl Node {
    pub fn new() -&gt; Self {
        Self {
            child: RefCell::new(vec![]),
            parent: RefCell::new(None),
        }
    }

    // 建立父子关系
    pub fn add_child(parent: &amp;Arc&lt;Node&gt;, child: Arc&lt;Node&gt;) {
        *child.parent.borrow_mut() = Some(parent.downgrade()); // &lt;---- 使用 downgrade 转为弱引用
        parent.child.borrow_mut().push(child);
    }
}
</code></pre>
<h2>v3: 分离强弱引用，避免无用消耗</h2>
<p>上个版本我们通过引入了弱引用 <code>Weak&lt;T&gt;</code>，成功解决了循环引用的问题，这是个非常大的进步！</p>
<p>不过我们仍然有进一步优化的空间，可以观察到，<code>Arc&lt;T&gt;</code> 的每次拷贝，都会伴随一次拷贝 <code>Weak&lt;T&gt;</code>，但是，很多时候，我们其实没有循环引用关系的，也即我们并不是每一次都需要 <code>Weak&lt;T&gt;</code>，所以上个版本的实现，其实是有很多的浪费的。</p>
<h3>数据结构</h3>
<p>所以我们可以考虑将 <code>Weak&lt;T&gt;</code> 从 <code>Arc&lt;T&gt;</code> 中分离出来：</p>
<pre><code class="language-rust">pub struct Arc&lt;T&gt; {
    ptr: NonNull&lt;ArcData&lt;T&gt;&gt;,
}

unsafe impl&lt;T: Send + Sync&gt; Send for Arc&lt;T&gt; {}
unsafe impl&lt;T: Send + Sync&gt; Sync for Arc&lt;T&gt; {}

pub struct Weak&lt;T&gt; {
    ptr: NonNull&lt;ArcData&lt;T&gt;&gt;,
}

unsafe impl&lt;T: Send + Sync&gt; Send for Weak&lt;T&gt; {}
unsafe impl&lt;T: Send + Sync&gt; Sync for Weak&lt;T&gt; {}

impl&lt;T&gt; Arc&lt;T&gt; {
  	// 调整 Arc 的构造函数
    pub fn new(data: T) -&gt; Self {
        Arc {
            ptr: NonNull::from(Box::leak(Box::new(ArcData {
                data_ref_count: AtomicUsize::new(1),
                alloc_ref_count: AtomicUsize::new(1),
                data: UnsafeCell::new(ManuallyDrop::new(data)),
            }))),
        }
    }

  	// Arc 已经没有 Weak 了，这个时候，时候给它补一个 data() 辅助函数
    fn data(&amp;self) -&gt; &amp;ArcData&lt;T&gt; {
        unsafe { self.ptr.as_ref() }
    }
}

// 相应地调整 Deref
impl&lt;T&gt; Deref for Arc&lt;T&gt; {
    type Target = T;
    fn deref(&amp;self) -&gt; &amp;Self::Target {
        unsafe { &amp;*self.data().data.get() }
    }
}
</code></pre>
<p>同时我们需要对 <code>ArcData&lt;T&gt;</code> 结构中的引用技术进行重新定义：</p>
<pre><code class="language-rust">struct ArcData&lt;T&gt; {
    // `Arc` 的数量
    data_ref_count: AtomicUsize,
    // `Weak` 的数量，当存在 `Arc` 时，额外加 1
    alloc_ref_count: AtomicUsize,
    // 数据，当只有 `Weak` 的时候，释放它。
    data: UnsafeCell&lt;ManuallyDrop&lt;T&gt;&gt;,
}
</code></pre>
<p>我们总共做了 3 个重要的调整：</p>
<ol>
<li><p><code>Arc&lt;T&gt;</code> 不再包含 <code>Weak&lt;T&gt;</code>，而是各自独立，不过它们之前会共享底层的 <code>ArcData&lt;T&gt;</code>。</p>
</li>
<li><p><code>alloc_ref_count</code> 的语义，从**&quot;Arc + Weak 的数量**&quot;，变成&quot;<strong>Weak 的数量，当存在 Arc 时，额外加 1</strong>&quot;。</p>
<blockquote>
<p>换言之，对于所有的 <code>Arc&lt;T&gt;</code>，都共有一个隐式的 <code>Weak&lt;T&gt;</code> ，当释放最后一个 <code>Arc&lt;T&gt;</code> 的时候，这个隐式的 <code>Weak&lt;T&gt;</code> 也需要被释放（本质是执行 alloc_ref_count 减一，同时如果没有其他的 <code>Weak&lt;T&gt;</code>，需要顺带释放 <code>ArcData&lt;T&gt;</code>。</p>
</blockquote>
</li>
<li><p><code>data</code> 字段，我们使用 <code>ManuallyDrop</code> 来替代 <code>Option</code>，我们之前使用 <code>None</code> 来表示数据已经被释放，但其实 <code>data_ref_count</code> 已经能表达这层意思了，所以我们这里使用 <code>ManuallyDrop</code> 来进一步节省内存资源。</p>
</li>
</ol>
<blockquote>
<p><code>std::mem::ManuallyDrop&lt;T&gt;</code> 是一个零成本（zero-cost）包装器。它不改变 <code>T</code> 的布局，也不会产生额外的字节，在一些情况下，可以比 <code>Option&lt;T&gt;</code> 少一些标记位和字节对齐所占据的额外空间。具体可参考：<strong>附录 2. ManuallyDrop&lt;T&gt;</strong>。</p>
</blockquote>
<pre><code class="language-mermaid">graph TB
    subgraph Stack [&quot;栈内存 Stack&quot;]
        ArcStruct[&quot;Arc&amp;lt;T&amp;gt;&lt;br/&gt;ptr: NonNull&amp;lt;ArcData&amp;lt;T&amp;gt;&amp;gt;&quot;]
        WeakStruct[&quot;Weak&amp;lt;T&amp;gt;&lt;br/&gt;ptr: NonNull&amp;lt;ArcData&amp;lt;T&amp;gt;&amp;gt;&quot;]
    end

    subgraph Heap [&quot;堆内存 Heap&quot;]
        ArcDataStruct[&quot;ArcData&amp;lt;T&amp;gt;&lt;br/&gt;data_ref_count&lt;br/&gt;alloc_ref_count&lt;br/&gt;data&quot;]
    end

    ArcStruct --&gt;|直接指向| ArcDataStruct
    WeakStruct --&gt;|直接指向| ArcDataStruct

    classDef stackStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
    classDef heapStyle fill:#f3e5f5,stroke:#4a148c,stroke-width:2px

    class ArcStruct,WeakStruct stackStyle
    class ArcDataStruct heapStyle
</code></pre>
<h3>维护引用计数</h3>
<p>修改了引用计数的语义后，我们需要重新思考如何管理引用计数。</p>
<ul>
<li>Clone:<ul>
<li>当 <code>Weak&lt;T&gt;</code> 拷贝时，仅增加了弱引用的数量，所以我们依旧只需对 <code>alloc_ref_count</code> 进行自增。</li>
<li>当 <code>Arc&lt;T&gt;</code> 拷贝时，这个时候，我们就只需要对 <code>data_ref_count</code> 进行自增即可。</li>
</ul>
</li>
<li>Drop:<ul>
<li>当 <code>Arc&lt;T&gt;</code> 被释放时，我们需要对 <code>data_ref_count</code> 进行自减。另外，如果是最后一个 <code>Arc&lt;T&gt;</code> 被释放，我们需要释放 <code>data</code> ，同时，我们还需要释放那个代表所有 <code>Arc</code> 的隐式 <code>Weak</code>。</li>
<li>当 <code>Weak&lt;T&gt;</code> 被释放时，我们仅需减少弱引用的数量，即对 <code>alloc_ref_count</code> 进行自减。另外，当最后一个 <code>Weak&lt;T&gt;</code> 被释放时，我们还需要负责释放 <code>ArcData&lt;T&gt;</code> 。</li>
</ul>
</li>
</ul>
<p>具体的实现如下：</p>
<pre><code class="language-rust">impl&lt;T&gt; Clone for Weak&lt;T&gt; {
    fn clone(&amp;self) -&gt; Self {
        if self.data().alloc_ref_count.fetch_add(1, Ordering::Relaxed) &gt; usize::MAX / 2 {
            std::process::abort();
        };
        Self { ptr: self.ptr }
    }
}

impl&lt;T&gt; Clone for Arc&lt;T&gt; {
    fn clone(&amp;self) -&gt; Self {
        if self.data().data_ref_count.fetch_add(1, Ordering::Relaxed) &gt; usize::MAX / 2 {
            std::process::abort();
        }
        Self { ptr: self.ptr }
    }
}

impl&lt;T&gt; Drop for Weak&lt;T&gt; {
    fn drop(&amp;mut self) {
      	// 思考下这里为什么要用 Release？后面我们会揭晓！
        if self.data().alloc_ref_count.fetch_sub(1, Ordering::Release) == 1 {
            fence(Ordering::Acquire);
            // Safety: 最后一个 Weak&lt;T&gt; 已经被释放了。
            unsafe {
                // 释放 ArcData&lt;T&gt;
                drop(Box::from_raw(self.ptr.as_ptr()));
            }
        }
    }
}

impl&lt;T&gt; Drop for Arc&lt;T&gt; {
    fn drop(&amp;mut self) {
        if self.data().data_ref_count.fetch_sub(1, Ordering::Release) == 1 {
            fence(Ordering::Acquire);
            // 最后一个 `Arc` 被释放，需要做 2 件事情：
            // 1. 释放 ArcData.data
            // 2. 释放那个代表所有 `Arc` 的隐式 `Weak`
            unsafe {
                ManuallyDrop::drop(&amp;mut *self.data().data.get());
            }
            // 在 `Weak` 那边 `drop()` 的时候：
            // 1. 会执行 alloc_ref_count--;
            // 2. 如果刚好是最后一个 `Weak`，那会顺带销毁 `AraData`。
            drop(Weak { ptr: self.ptr });
        }
    }
}
</code></pre>
<p>对于这个隐式 <code>Weak&lt;T&gt;</code>，如果你一时无法很好地 Get 到那个点，可以尝试手动写写整个实现的过程，相信多走几遍流程，你会有那种茅塞顿开的感觉！</p>
<pre><code class="language-mermaid">flowchart TD
    subgraph Clone[&quot;Clone 操作&quot;]
        WeakClone[&quot;Weak&amp;lt;T&amp;gt;::clone()&quot;]
        ArcClone[&quot;Arc&amp;lt;T&amp;gt;::clone()&quot;]

        WeakClone --&gt; WeakInc[&quot;alloc_ref_count.fetch_add(1)&quot;]
        ArcClone --&gt; ArcInc[&quot;data_ref_count.fetch_add(1)&quot;]

        WeakInc --&gt; WeakCheck{&quot;计数 &gt; usize::MAX/2?&quot;}
        ArcInc --&gt; ArcCheck{&quot;计数 &gt; usize::MAX/2?&quot;}

        WeakCheck --&gt;|是| Abort1[&quot;std::process::abort()&quot;]
        ArcCheck --&gt;|是| Abort2[&quot;std::process::abort()&quot;]
        WeakCheck --&gt;|否| WeakNew[&quot;返回新的 Weak&amp;lt;T&amp;gt;&quot;]
        ArcCheck --&gt;|否| ArcNew[&quot;返回新的 Arc&amp;lt;T&amp;gt;&quot;]
    end

    subgraph Drop[&quot;Drop 操作&quot;]
        WeakDrop[&quot;Weak&amp;lt;T&amp;gt;::drop()&quot;]
        ArcDrop[&quot;Arc&amp;lt;T&amp;gt;::drop()&quot;]

        WeakDrop --&gt; WeakDec[&quot;alloc_ref_count.fetch_sub(1, Release)&quot;]
        ArcDrop --&gt; ArcDec[&quot;data_ref_count.fetch_sub(1, Release)&quot;]

        WeakDec --&gt; WeakDropCheck{&quot;返回值 == 1?&lt;br/&gt;最后一个 Weak?&quot;}
        ArcDec --&gt; ArcDropCheck{&quot;返回值 == 1?&lt;br/&gt;最后一个 Arc?&quot;}

        WeakDropCheck --&gt;|是| WeakFence[&quot;fence(Acquire)&quot;]
        WeakDropCheck --&gt;|否| WeakEnd[&quot;结束&quot;]

        ArcDropCheck --&gt;|是| ArcFence[&quot;fence(Acquire)&quot;]
        ArcDropCheck --&gt;|否| ArcEnd[&quot;结束&quot;]

        WeakFence --&gt; WeakFree[&quot;释放 ArcData&amp;lt;T&amp;gt;&lt;br/&gt;Box::from_raw(ptr)&quot;]

        ArcFence --&gt; ArcDataFree[&quot;释放 data&lt;br/&gt;ManuallyDrop::drop()&quot;]
        ArcDataFree --&gt; ImplicitWeak[&quot;释放隐式 Weak&lt;br/&gt;drop(Weak { ptr })&quot;]

        ImplicitWeak --&gt; WeakDrop
    end

    subgraph Memory[&quot;内存释放顺序&quot;]
        Step1[&quot;① Arc 释放 data 内容&quot;]
        Step2[&quot;② Arc 释放隐式 Weak&quot;]
        Step3[&quot;③ Weak 释放 ArcData 结构&quot;]

        Step1 --&gt; Step2
        Step2 --&gt; Step3
    end

    classDef cloneStyle fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px
    classDef dropStyle fill:#ffebee,stroke:#c62828,stroke-width:2px
    classDef memStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px
    classDef criticalStyle fill:#fce4ec,stroke:#880e4f,stroke-width:3px

    class WeakClone,ArcClone,WeakInc,ArcInc,WeakNew,ArcNew cloneStyle
    class WeakDrop,ArcDrop,WeakDec,ArcDec,WeakEnd,ArcEnd dropStyle
    class WeakFree,ArcDataFree,ImplicitWeak criticalStyle
    class Step1,Step2,Step3 memStyle
</code></pre>
<p>至此，我们重新运行之前的测试用例 <code>arc_should_work()</code>，可以发现的成功通过的。</p>
<p>下面我们继续来调整 <code>get_mut</code>、<code>downgrade()</code> 和 <code>upgrade()</code>。</p>
<h3>upgrade()</h3>
<p><code>upgrade()</code> 比较简单，它的规则还是不变，只有当存在 <code>Arc&lt;T&gt;</code> 时，即 <code>data_ref_count&gt;0</code> 时，才能升级成功：</p>
<pre><code class="language-rust">impl&lt;T&gt; Weak&lt;T&gt; {
  	//...

    pub fn upgrade(&amp;self) -&gt; Option&lt;Arc&lt;T&gt;&gt; {
      	// 获取 data_ref_count 的值。
        let mut n = self.data().data_ref_count.load(Ordering::Relaxed);
        loop {
          	// 如果为 0，表示已经没有 Arc 了，升级失败。
            if n == 0 {
                return None;
            }
            assert!(n &lt; usize::MAX);

          	// 存在 Arc，则对 data_ref_count 尝试进行 CAS 加 1。
            if let Err(e) = self.data().data_ref_count.compare_exchange_weak(
                n,
                n + 1,
                Ordering::Relaxed,
                Ordering::Relaxed,
            ) {
              	// CAS 失败，则重试。
                n = e;
                continue;
            }

          	// CAS 成功，则返回升级后的 Arc。
            return Some(Arc { ptr: self.ptr });
        }
    }
}
</code></pre>
<h3>get_mut()</h3>
<p>接一下我们看 <code>get_mut</code>，规则也是不变：<strong>当前仅当只有一个 <code>Arc&lt;T&gt;</code>，且不存在 <code>Weak&lt;T&gt;</code> 时，才可以返回可变引用。</strong></p>
<pre><code class="language-rust">// 两个条件
// 1. 没有 Weak&lt;T&gt;
// 2. 没有其他的 Arc&lt;T&gt;
pub fn get_mut(arc: &amp;mut Arc&lt;T&gt;) -&gt; Option&lt;&amp;mut T&gt; {
    // 将 alloc_ref_count 从 1 置为 usize::Max。
    //  如果失败：说明之前不是 1，即存在其他的 Weak&lt;T&gt;，无法获取 &amp;mut T， None
    //  如果成功：说明当前没有 Weak&lt;T&gt;，第一步校验通过。
    if arc
        .data()
        .alloc_ref_count
        .compare_exchange(1, usize::MAX, Ordering::Acquire, Ordering::Relaxed)
        .is_err()
    {
        return None;
    }

    let is_unique = arc.data().data_ref_count.load(Ordering::Relaxed) == 1;

    // 释放 alloc_ref_count
    arc.data().alloc_ref_count.store(1, Ordering::Release);

    // 如果存在其他的 Arc，则无法获取 &amp;mut T，返回 None
    if !is_unique {
        return None;
    }

    // 不存在其他的 Arc，返回 &amp;mut T
    fence(Ordering::Acquire);
    unsafe { Some(&amp;mut *arc.data().data.get()) }
}
</code></pre>
<p>代码的逻辑就不赘述了，但是这里有 <strong>3 个</strong>原子操作的内存顺序我们需要讨论一下！</p>
<p>分别是：</p>
<pre><code class="language-rust">// 获取 Weak&lt;T&gt; 的数量并暂时锁定
arc
  .data()
  .alloc_ref_count
  .compare_exchange(1, usize::MAX, Ordering::Acquire, Ordering::Relaxed)
</code></pre>
<pre><code class="language-rust">// 获取 Arc&lt;T&gt; 的数量
let is_unique = arc.data().data_ref_count.load(Ordering::Relaxed) == 1;
...
fence(Ordering::Acquire);
</code></pre>
<pre><code class="language-rust">// 释放 alloc_ref_count
arc.data().alloc_ref_count.store(1, Ordering::Release);
</code></pre>
<hr>
<p>首先看第 1 个，我们要判断当前是否已经没有 <code>Weak&lt;T&gt;</code> 了，所以我们需要保证看到之前所有 <code>drop(Weak&lt;T&gt;)</code> 的写入操作，所以这里需要建立起一个 happens-before，因此我们之前为 <code>Weak&lt;T&gt;</code> 实现 <code>Drop</code> trait 的时候，使用的是 <code>Release</code>，而这里，需要使用 <code>Acquire</code> 来进行配对，建立 happens-before。</p>
<hr>
<p>然后看第 2 个，这里我们要判断是否仅有一个 <code>Arc&lt;T&gt;</code>，所以我们需要保证看到之前所有 <code>drop&lt;Arc&lt;T&gt;</code> 的写入操作，因此我们之前为 <code>Arc&lt;T&gt;</code> 实现 <code>Drop</code> trait 的时候，使用的是 <code>Release</code>。但是在这里，我们仅需在仅剩 1 个 <code>Arc</code> 的时候，才有必要建立 happens-before，所以当 <code>is_unique=true</code> 时，我们补一个 <code>fence(Acquire)</code> 屏障，来建立跟 <code>drop(Arc&lt;T&gt;)</code> 的 happens-before。</p>
<hr>
<p>在分析第 3 个之前，我们需要来尝试挖一下当前 <code>get_mut</code> 的漏洞！相信有部分读者在看到上述实现后，跟笔者一样，会有一个疑惑：<strong><font color="red">上述 2 个条件的检查，并不是原子的，这样的检查还安全可靠吗？</font></strong></p>
<p>比如说：</p>
<pre><code class="language-rust">pub fn get_mut(arc: &amp;mut Arc&lt;T&gt;) -&gt; Option&lt;&amp;mut T&gt; {
    if arc
        .data()
        .alloc_ref_count
        .compare_exchange(1, usize::MAX, Ordering::Acquire, Ordering::Relaxed)
        .is_err()
    {
        return None;
    }

    // &lt;------- 在这个间隙：另外一个 Arc downgrade() -&gt; Weak，然后 drop(Arc)
    // &lt;------- 那它也是检查通过的。

    let is_unique = arc.data().data_ref_count.load(Ordering::Relaxed) == 1;
    arc.data().alloc_ref_count.store(1, Ordering::Release);
    if !is_unique {
        return None;
    }
    fence(Ordering::Acquire);
    unsafe { Some(&amp;mut *arc.data().data.get()) }
}
</code></pre>
<p>在上面这个例子中：</p>
<ol>
<li>假设我们已经通过了第一个检查；</li>
<li>在进行第二个检查之前的这个间隙中，存在另外一个 <code>Arc&lt;T&gt;</code>；</li>
<li>然后它通过 <code>downgrade()</code> 生成了一个 <code>Weak&lt;T&gt;</code>；</li>
<li>然后再 <code>drop(Arc&lt;T&gt;)</code>；</li>
<li>这个时候我们再检查 <code>Arc&lt;T&gt;</code> 的时候，会发现只有 1 个，检查就通过了。但是其实这个时候，存在了其他的 <code>Weak&lt;T&gt;</code>，所以是有问题的！</li>
</ol>
<pre><code class="language-mermaid">sequenceDiagram
    participant A as T1 :get_mut()
    participant B as T2 :downgrade()+drop(Arc)
    participant C as ArcData

    A-&gt;&gt;C: CAS alloc_ref 1→MAX ✔
    Note over A,B: 非原子窗口
    B-&gt;&gt;C: clone Weak &lt;br&gt; alloc_ref++ (Relaxed)
    B-&gt;&gt;C: drop(Arc) &lt;br&gt; fetch_sub data_ref (Release)
    A-&gt;&gt;C: load data_ref ==1 ✔
    A-&gt;&gt;C: store alloc_ref MAX→1 (Release)
    A--&gt;&gt;A: fence(Acquire) → 返回 &amp;mut T（已失效）
</code></pre>
<p>所以：**<font color="green">我们不能让  <code>downgrade()</code>  在这个间隙中成功执行！</font>**这也是我们在前面将 <code>alloc_ref_count</code> 置为 <code>usize::Max</code>（上锁）的原因，在后面的 <code>downgrade()</code> 中，我们肯定要检查这个值，如果 <code>alloc_ref_count=usize::Max</code>，就不能 <code>downgrade()</code> 成功，直到 <code>alloc_ref_store(1, _)</code> 的时候，才能 <code>downgrade()</code> 成功。不过这个时候已经晚了！如果是这样，那 <code>is_unique</code> 肯定就不为 <code>true</code>，所以会返回 <code>None</code>，这个时候，我们不会返回 <code>&amp;mut T</code>，所以，危机就解除了！</p>
<h3>downgrade()</h3>
<p>我们先来看一下 <code>downgrade()</code> 的实现：</p>
<pre><code class="language-rust">impl&lt;T&gt; Arc&lt;T&gt; {
		// ...

    pub fn downgrade(&amp;self) -&gt; Weak&lt;T&gt; {
        // 获取 Weak 的引用计数
        let mut n = self.data().alloc_ref_count.load(Ordering::Relaxed);
        loop {
            // 如果为 usize::MAX，说明已经被锁住了，这个时候自旋重试！
            if n == usize::MAX {
                std::hint::spin_loop();
                n = self.data().alloc_ref_count.load(Ordering::Relaxed);
            }
            assert!(n &lt; usize::MAX - 1);

            // alloc_ref_count 没被锁住，尝试进行 +1 操作。
            if let Err(e) = self.data().alloc_ref_count.compare_exchange_weak(
                n,
                n + 1,
                Ordering::Acquire,
                Ordering::Relaxed,
            ) {
                // +1 失败，重试。
                n = e;
                continue;
            }
            // +1 成功，表示降级成功，返回 Weak。
            return Weak { ptr: self.ptr };
        }
    }
}
</code></pre>
<p>在 <code>downgrade()</code> 的实现中，我们跟 <code>get_mut</code> 遥相呼应：</p>
<ol>
<li><p>如果 <code>alloc_ref_count=usize::MAX</code>，说明被锁住，这个时候需要自旋等待并重试。</p>
<blockquote>
<p><code>std::hint::spin_loop() </code>会向 CPU 发送特定指令（如 x86 的 pause 或 ARM 的 yield），提示当前处于忙等待状态，有利于优化 CPU 行为。</p>
</blockquote>
</li>
<li><p>如果 <code>alloc_ref_count</code> 没被锁住，我们尝试进行 CAS，这里成功的时候使用的 <code>Acquire</code> ，为什么呢？这是为了跟前面还未讨论的<strong>第 3 个原子操作</strong>建立 happens-before！</p>
</li>
</ol>
<p>现在我们终于可以来解开这第 3 个原子操作的原子顺序谜团了：</p>
<pre><code class="language-rust">// 释放 alloc_ref_count
arc.data().alloc_ref_count.store(1, Ordering::Release);
</code></pre>
<p>这里我们必须保证跟 <code>download()</code> 的 <code>compare_exchange(_,_,Acquire,_)</code> 建立起 happens-before 关系，即将 <code>alloc_ref_count</code> 置为 1 的结果必须被 <code>downgrade()</code> 所在的线程看到。不然的话，这里 <code>compare_exchange</code> 成功了，将 <code>alloc_ref_count</code> 置为 2 了，但是 <code>get_mut</code> 又将其置为 1 了，就乱套了！</p>
<h3>完整代码</h3>
<p>到这里我们终于是完成了 v3 版本的优化工作了，真棒！介于篇幅已经够长了，这里就不再贴出完整的代码了，感兴趣的读者可以参阅：<a href="https://github.com/hedon-rust-road/conutils/blob/main/src/arc.rs">hedon-rust-road/conutils/arc</a>。</p>
<h2>浅探标准库的 Arc&lt;T&gt;</h2>
<p>在最后，我们来看一下标准库的 <code>Arc&lt;T&gt;</code> 是如何实现的，看文章开头说的<strong>实现一个可以媲美标准库的 <code>Arc&lt;T&gt;</code></strong> 是不是在吹牛！</p>
<p>标准库（rustc 1.87.0）的 <code>Arc&lt;T&gt;</code> 位于 <a href="https://github.com/rust-lang/rust/blob/1.87.0/library/alloc/src/sync.rs">sync.rs</a> 文件中，我们来看下它的数据结构定义：</p>
<pre><code class="language-rust">pub struct Arc&lt;
    T: ?Sized,
    #[unstable(feature = &quot;allocator_api&quot;, issue = &quot;32838&quot;)] A: Allocator = Global,
&gt; {
    ptr: NonNull&lt;ArcInner&lt;T&gt;&gt;,
    phantom: PhantomData&lt;ArcInner&lt;T&gt;&gt;,
    alloc: A,
}

pub struct Weak&lt;
    T: ?Sized,
    #[unstable(feature = &quot;allocator_api&quot;, issue = &quot;32838&quot;)] A: Allocator = Global,
&gt; {
    ptr: NonNull&lt;ArcInner&lt;T&gt;&gt;,
    alloc: A,
}
</code></pre>
<p>其他一些莫名其妙的标记咱就不管了，映入眼帘可以看到一个 <code>ArcInner</code>，这不就是咱的 <code>ArcData</code> 吗！点进去看下：</p>
<pre><code class="language-rust">struct ArcInner&lt;T: ?Sized&gt; {
    strong: atomic::AtomicUsize,

    // the value usize::MAX acts as a sentinel for temporarily &quot;locking&quot; the
    // ability to upgrade weak pointers or downgrade strong ones; this is used
    // to avoid races in `make_mut` and `get_mut`.
    weak: atomic::AtomicUsize,

    data: T,
}
</code></pre>
<p>好家伙！这不就是咱的 <code>ArcData</code> !</p>
<ul>
<li><code>strong</code>: 对应我们的 <code>data_ref_count</code></li>
<li><code>weak</code>: 对应我们的 <code>alloc_ref_count</code></li>
</ul>
<p><code>weak</code> 字段上面的注释也揭示了其核心逻辑跟咱是高度一致的！</p>
<p>我们简单看下最重要的 <code>Drop</code> 和 <code>Clone</code> 的实现，因为这涉及到引用计数的维护：</p>
<pre><code class="language-rust">const MAX_REFCOUNT: usize = (isize::MAX) as usize;

// Arc: Clone
impl&lt;T: ?Sized, A: Allocator + Clone&gt; Clone for Arc&lt;T, A&gt; {
    fn clone(&amp;self) -&gt; Arc&lt;T, A&gt; {
        let old_size = self.inner().strong.fetch_add(1, Relaxed); // fetch_add
        if old_size &gt; MAX_REFCOUNT { // 溢出保护
            abort();
        }
      	// 返回
        unsafe { Self::from_inner_in(self.ptr, self.alloc.clone()) }
    }
}

// Arc: Drop
unsafe impl&lt;#[may_dangle] T: ?Sized, A: Allocator&gt; Drop for Arc&lt;T, A&gt; {
    #[inline]
    fn drop(&amp;mut self) {
        // fetch_sub release 强引用
        if self.inner().strong.fetch_sub(1, Release) != 1 {
            return;
        }
      	// 最后一个 Arc 释放的时候，补一个 fence(Acquire)，
      	// 与前面所有的 release 建立 happens-before。
        acquire!(self.inner().strong);

      	// 最后一个 Arc 释放的时候，释放 ArcInner 里面的 data
        unsafe {
            self.drop_slow();
        }
    }
}

// Weak: Clone
impl&lt;T: ?Sized, A: Allocator + Clone&gt; Clone for Weak&lt;T, A&gt; {
    #[inline]
    fn clone(&amp;self) -&gt; Weak&lt;T, A&gt; {
        if let Some(inner) = self.inner() {
            let old_size = inner.weak.fetch_add(1, Relaxed); // fetch_add 弱引用数量
            if old_size &gt; MAX_REFCOUNT { // 溢出保护
                abort();
            }
        }

      	// 返回 Weak
        Weak { ptr: self.ptr, alloc: self.alloc.clone() }
    }
}


// Weak: Drop
unsafe impl&lt;#[may_dangle] T: ?Sized, A: Allocator&gt; Drop for Weak&lt;T, A&gt; {
    fn drop(&amp;mut self) {
        let inner = if let Some(inner) = self.inner() { inner } else { return };

        if inner.weak.fetch_sub(1, Release) == 1 { // fetch_sub release 弱引用
            acquire!(inner.weak);  // 最后一个补一个 fence(acquire)
            unsafe {  // 释放 ArcInner
                self.alloc.deallocate(self.ptr.cast(), Layout::for_value_raw(self.ptr.as_ptr()))
            }
        }
    }
}
</code></pre>
<p>可以看到跟咱前面的实现是一样一样的！为啥？因为 <a href="https://marabos.nl/atomics/">Rust Atomics and Locks</a> 一书的作者<a href="https://github.com/m-ou-se">Mara Bos</a> 就是 Rust 标准库团队的领导之一！牛逼！</p>
<h2>总结</h2>
<p>通过三轮迭代，我们不仅实现了一个媲美了标准库的 <code>Arc&lt;T&gt;</code> ，还更进一步体验了 <strong>Rust 并发抽象的设计哲学</strong>：</p>
<ul>
<li><strong>v0 —— 能用</strong>：单计数 <code>ArcData&lt;T&gt;</code> 解决了跨线程多所有权，但仍缺乏可变视图与断环能力。</li>
<li><strong>v1 —— 能改</strong>：借助独占 <code>&amp;mut Arc&lt;T&gt;</code> + 原子 <em>锁</em>，在 <strong>不破坏并发安全</strong> 的前提下开放了 <code>get_mut</code>。</li>
<li><strong>v2 —— 能回收</strong>：双计数 + <code>Weak&lt;T&gt;</code> 消除了父子互指造成的泄漏，展示了 <em>弱引用</em> 在所有权图中的价值。</li>
<li><strong>v3 —— 更轻巧</strong>：将 <code>Weak</code> 独立、用 <code>ManuallyDrop</code> 替换 <code>Option&lt;T&gt;</code>，让没有循环引用需求的场景不再为弱引用买单。</li>
</ul>
<p>下篇我们将尝试实现一个 <code>Mutex&lt;T&gt;</code>，敬请期待！</p>
<p>Happy Coding! Peace~</p>
<h2>附录</h2>
<h3>1. NonNull&lt;T&gt;</h3>
<p><code>std::ptr::NonNull&lt;T&gt;</code> 是一个 <strong>零成本、非空、协变</strong> 的裸指针包装器。它本质上只是把一个原始指针塞进 <code>#[repr(transparent)]</code> 的新类型中，但强制保证 <strong>绝不为空</strong>，因此可以拿到「空值当作枚举判别位」这份额外信息 —— <code>Option&lt;NonNull&lt;T&gt;&gt;</code> 与一个普通指针占用同样大小。</p>
<p>重要特性：</p>
<ul>
<li><strong>永远非空</strong>：创建时若传入空指针即触发 <strong>未定义行为</strong>。编译器可依赖这一点做优化，例如把 <code>Option&lt;NonNull&lt;T&gt;&gt;</code> 合并为一个指针宽度。</li>
<li><strong>协变</strong>：与 <code>*mut T</code> 不同，<code>NonNull&lt;T&gt;</code> 可以在 <code>U: Deref&lt;Target = T&gt;</code> 的场景下安全向子类型转换（因为禁止空值带来了额外保证），这使它非常适合构建自定义智能指针。</li>
<li><strong>无自动 Drop</strong>：<code> NonNull</code> 只存地址，不持有所有权；销毁与释放内存仍由外部逻辑决定（如 <code>Box::from_raw</code>、<code>Vec::dealloc</code> 等）。</li>
</ul>
<p>我们将其与裸指针、 <code>Box&lt;T&gt;</code> 智能指针和引用 <code>&amp;T</code> 做一个简单的比较：</p>
<table>
<thead>
<tr>
<th>特性</th>
<th><code>*mut T</code> / <code>*const T</code></th>
<th><code>NonNull&lt;T&gt;</code></th>
<th><code>Box&lt;T&gt;</code> / <code>&amp;T</code></th>
</tr>
</thead>
<tbody><tr>
<td><strong>是否允许为空</strong></td>
<td>✅</td>
<td><strong>❌ 必须非空</strong></td>
<td>不适用</td>
</tr>
<tr>
<td><strong>协变性</strong></td>
<td><code>*mut</code> 不协变</td>
<td><strong>协变</strong>（可安全向子类型转换）</td>
<td><code>&amp;T</code> 协变</td>
</tr>
<tr>
<td><strong>Option 优化</strong></td>
<td>❌ 多 1 B 判别字节</td>
<td><strong>✅ 与裸指针同尺寸</strong></td>
<td>已自带优化</td>
</tr>
<tr>
<td><strong>所有权 / drop 责任</strong></td>
<td>没有</td>
<td>没有（纯指针）</td>
<td>有</td>
</tr>
<tr>
<td><strong>Send / Sync</strong></td>
<td>与 <code>T</code> 无关</td>
<td>默认 <code>!Send !Sync</code></td>
<td>取决于 <code>T</code></td>
</tr>
<tr>
<td><strong>常见用途</strong></td>
<td>FFI、底层算法</td>
<td><strong>智能指针内部、侵入式容器、裁掉空判</strong></td>
<td>高层所有权模型</td>
</tr>
</tbody></table>
<p>关键的 API：</p>
<table>
<thead>
<tr>
<th>分组</th>
<th>代表方法</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td><strong>构造</strong></td>
<td><code>NonNull::new(ptr)</code> → <code>Option&lt;NonNull&lt;T&gt;&gt;</code> <br><code>unsafe { NonNull::new_unchecked(ptr) }</code> <br><code>NonNull::dangling()</code></td>
<td>安全 / 不安全 / 占位</td>
</tr>
<tr>
<td><strong>引用视图</strong></td>
<td><code>unsafe</code> <br><code>fn as_ref</code> / <code>as_mut</code></td>
<td>把裸指针临时借用成 <code>&amp;T</code> / <code>&amp;mut T</code></td>
</tr>
<tr>
<td><strong>裸指针互转</strong></td>
<td><code>fn as_ptr</code></td>
<td>取回 <code>*mut T</code>，解引用仍需 <code>unsafe</code></td>
</tr>
<tr>
<td><strong>类型转换</strong></td>
<td><code>fn cast&lt;U&gt;</code> <br><code>fn cast_mut</code> / <code>cast_const</code></td>
<td>保留地址，换类型或可变性</td>
</tr>
<tr>
<td><strong>地址运算</strong></td>
<td><code>unsafe fn add / byte_add / sub / offset</code></td>
<td>指针算术，与 <code>ptr::add</code> 族一致</td>
</tr>
<tr>
<td><strong>切片助手</strong></td>
<td><code>NonNull::slice_from_raw_parts(data, len)</code> <br><code>fn len()</code></td>
<td>1.70+ 稳定，为 <code>[T]</code> 提供非空裸切片构造 (<a href="https://rustwiki.org/zh-CN/std/ptr/struct.NonNull.html?utm_source=chatgpt.com">rustwiki.org</a>)</td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ 任何把 <code>NonNull</code> 重新解释为引用或解引用的操作，都必须在 <code>unsafe</code> 块里手动保证内存有效性与别名规则。</p>
</blockquote>
<p>典型使用场景：</p>
<table>
<thead>
<tr>
<th>场景</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td><strong>智能指针内部实现</strong> (<code>Box</code>/<code>Rc</code>/<code>Arc</code>)</td>
<td>需要 <em>非空</em> 原始指针存放被管对象，且要在 <code>Option</code> 等场景下节省空间</td>
</tr>
<tr>
<td><strong>自研侵入式链表 / 红黑树</strong></td>
<td>节点自身持有前后指针字段 <code>NonNull&lt;Node&lt;T&gt;&gt;</code>，天然避免空判分支</td>
</tr>
<tr>
<td><strong>惰性初始化 / <code>Vec::new</code></strong></td>
<td>先用 <code>NonNull::dangling()</code> 占位，等真正分配后再写入正确地址</td>
</tr>
<tr>
<td><strong>FFI</strong></td>
<td>C API 明确保证参数永不为空时，用 <code>NonNull</code> 在类型层面表达前置条件</td>
</tr>
<tr>
<td><strong>自引用结构（Pin 工作区）</strong></td>
<td>在完成真正初始化前使用 <code>NonNull::dangling()</code> 保存指向自身字段的指针</td>
</tr>
</tbody></table>
<h3>2. ManuallyDrop&lt;T&gt;</h3>
<p><code>std::mem::ManuallyDrop&lt;T&gt;</code> 是一个零成本（zero-cost）包装器。把值包在 <code>ManuallyDrop</code> 里会告诉编译器：<strong>请不要在作用域结束时自动调用它的 <code>Drop</code> 实现，什么时候释放（或是否释放）由我手动决定。</strong></p>
<ul>
<li><code>ManuallyDrop&lt;T&gt;</code> 只是把 <strong>&quot;是否自动 drop&quot;</strong> 这一语义从编译器搬到了程序员身上；</li>
<li>它<strong>不改变</strong> <code>T</code> 的布局，也不会产生额外的字节，因此是零开销；</li>
<li><strong>unsafe 责任</strong>：你必须保证一份值<strong>恰好析构一次</strong>（不能漏掉，也不能多调）。</li>
</ul>
<p>它的核心 API 如表所示：</p>
<table>
<thead>
<tr>
<th>API</th>
<th>作用</th>
<th>重要注意点</th>
</tr>
</thead>
<tbody><tr>
<td><strong><code>ManuallyDrop::new(value)</code></strong></td>
<td>把 <code>T</code> 包装成 <code>ManuallyDrop&lt;T&gt;</code>，关闭自动析构</td>
<td>零开销；之后需显式 drop 或移动走</td>
</tr>
<tr>
<td><strong><code>ManuallyDrop::drop(&amp;mut self)</code></strong> <em>(unsafe)</em></td>
<td>手动触发内部值的析构</td>
<td>必须保证之后不会再访问 / 再 drop</td>
</tr>
<tr>
<td><strong><code>ManuallyDrop::take(&amp;mut self)</code></strong> <em>(unsafe)</em></td>
<td>从包装里&quot;搬走&quot;值（等价于 <code>ptr::read</code>）</td>
<td><code>v</code> 里留下未初始化内存，不能再用或再 drop</td>
</tr>
<tr>
<td><strong><code>ManuallyDrop::into_inner(self)</code></strong> <em>(unsafe)</em></td>
<td>消费 <code>ManuallyDrop</code> 返回内部值</td>
<td>取得所有权后，原包装已被移走（不会 double-drop）</td>
</tr>
</tbody></table>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨手写一个 oneshot channel</title>
      <link>https://hedon.top/blog/rust-action-oneshot-channel/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-oneshot-channel/</guid>
      <pubDate>Thu, 29 May 2025 22:11:03 GMT</pubDate>
      <description>本文从零开始，通过多版本迭代，实现一个安全的 Rust oneshot channel。我们将深入 AtomicBool、UnsafeCell、MaybeUninit 的使用，通过 Drop 管理内存，并最终以 Sender/Receiver 模式和所有权机制封装 unsafe，构建健壮的并发原语。</description>
      <category>rust</category><category>并发编程</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<p>系列文章：</p>
<ul>
<li><a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a></li>
<li><a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a></li>
<li><a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a></li>
<li><a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a> 👈 本篇</li>
<li><a href="/blog/rust-action-arc/">Rust 实战丨手写一个 Arc</a></li>
<li><a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a></li>
<li><a href="/blog/rust-action-mutex/">Rust 实战丨手写一个 Mutex</a></li>
<li><a href="/blog/rust-action-condvar/">Rust 实战丨手写一个 Condvar</a></li>
<li><a href="/blog/rust-action-rwlock/">Rust 实战丨手写一个 RwLock</a></li>
</ul>
<hr>
<p>继上篇 <a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a>，本篇我们继续参考 <a href="https://marabos.nl/atomics/">Rust Atomics and Locks</a> 一书，来实现一个 <code>oneshot channel</code>。</p>
<p>在 Go 语言中，有一句名言：</p>
<blockquote>
<p>Don&#39;t communicate by sharing memory, share memory by communicating. 不要通过共享内存来通信，而要通过通信来共享内存。</p>
</blockquote>
<p>讲的就是通道 <code>channel</code>。使用 <code>channel</code> 来通信，一方面可以避免共享状态的并发竞争问题，另一方面可以解耦生产者和消费者。</p>
<p><code>channel</code> 根据生产者和消费者的数量，可以分为以下几种：</p>
<ol>
<li><strong>单生产者单消费者</strong> (SPSC)</li>
<li><strong>单生产者多消费者</strong> (SPMC)</li>
<li><strong>多生产者单消费者</strong> (MPSC)</li>
<li><strong>多生产者多消费者</strong> (MPMC)</li>
</ol>
<p>在<strong>单生产者单消费者</strong>这个分类中，有一种特殊且常用的场景，叫<strong>一次性通道</strong>（oneshot channel）。它在 SPSC 的基础上增加了额外约束：整个生命周期内只传递一次数据，传递完成后通道就失效了。</p>
<p>熟悉 Go 语言的读者应该对以下使用场景很熟悉，这些都是典型的 oneshot channel 应用：</p>
<pre><code class="language-go">func demo1() {
	done := make(chan struct{})
	go func() {
		// ... do something
		close(done)
	}()

	&lt;-done
}
</code></pre>
<pre><code class="language-go">func demo2() {
	oneShot := make(chan string, 1)
	go func() {
		oneShot &lt;- generateText()
	}()

	text := &lt;-oneShot
	doSthWithText(text)
}

func generateText() string {
	// ...
}

func doSthWithText(text string) {
	// ...
}
</code></pre>
<p>在 Rust 社区里面，就有一个非常优秀的 <a href="https://docs.rs/oneshot/latest/oneshot/">oneshot</a> 实现，在详细深入它的实现之前，我们先参考 <a href="https://marabos.nl/atomics/building-channels.html">Rust Atomics and Locks</a> 一书，来尝试实现一个 <code>oneshot channel</code>!</p>
<h2>读完本篇你能学到什么</h2>
<ol>
<li><p><strong>一次性 (oneshot) 通道的场景与优势</strong></p>
<ul>
<li>了解它与多生产者/多消费者通道的区别</li>
<li>掌握常见使用模式（如线程同步、单次结果返回）</li>
</ul>
</li>
<li><p><strong>Rust 并发核心原语的渐进式实践</strong></p>
<ul>
<li><code>UnsafeCell</code>：内部可变性基石</li>
<li><code>AtomicBool</code> + <code>Ordering::{Release,Acquire}</code>：最小化同步原语</li>
<li><code>MaybeUninit&lt;T&gt;</code>：零成本延迟初始化</li>
</ul>
</li>
<li><p><strong>用所有权与生命周期设计零误用 API</strong></p>
<ul>
<li>将 <code>Sender</code> / <code>Receiver</code> 拆分并一次性消费</li>
<li>生命周期引用 vs <code>Arc</code> 的权衡与替换技巧</li>
</ul>
</li>
<li><p><strong>线程挂起/唤醒机制</strong></p>
<ul>
<li><code>std::thread::park / unpark</code> 的阻塞式等待模型</li>
</ul>
</li>
<li><p><strong>类型系统层面的“防呆”手段</strong></p>
<ul>
<li>利用 <code>PhantomData</code> 禁止跨线程误用</li>
<li><code>Drop</code> 手动回收，避免内存泄漏</li>
</ul>
</li>
<li><p><strong>一步步优化的思考路径</strong></p>
<ul>
<li>如何发现问题 → 提出假设 → 实现 → 验证 → 再迭代</li>
</ul>
</li>
</ol>
<p>带着这些目标，跟随本文一路迭代到 <strong>v8</strong>，你将拥有一个高性能、零误用的 oneshot channel，以及一整套可迁移到其它并发场景的设计思维。</p>
<h2>热身版 v0：基于锁的通道</h2>
<p>我们先来实现一个<strong>万能版</strong> <code>channel</code> 热热身。顾名思义，<code>channel</code> 分为 2 个功能，<code>send</code> 和 <code>receive</code>，其中：</p>
<ul>
<li><code>send</code> 往 <code>channel</code> 一头放数据。</li>
<li><code>receive</code> 从 <code>channel</code> 另外一头取数据，如果没有数据，则阻塞住，直到有数据时返回取出数据并返回。</li>
</ul>
<p>在 Rust 中，我们可以用队列 <code>VecQueue</code> 来作为数据的承载，同时为了对队列访问的并发安全，我们需要使用锁 <code>Mutex</code> 来保护它，另外，在消费者取数据时，如果没有数据，则需要阻塞并等待唤醒（使用循环等待就太耗 CPU 了），所以我们可以使用条件变量 <code>Condvar</code> 来实现挂起和唤醒。</p>
<p>经过以上分析，我们可以定义如下的结构：</p>
<pre><code class="language-rust">use std::{
    collections::VecDeque,
    sync::{Condvar, Mutex},
};

pub struct Channel&lt;T&gt; {
    queue: Mutex&lt;VecDeque&lt;T&gt;&gt;,
    item_ready: Condvar,
}
</code></pre>
<p><code>send</code> 和 <code>receive</code> 方法也比较简单，如下所示：</p>
<pre><code class="language-rust">impl&lt;T&gt; Channel&lt;T&gt; {
    pub fn new() -&gt; Self {
        Self {
            queue: Mutex::new(VecDeque::new()),
            item_ready: Condvar::new(),
        }
    }

    pub fn send(&amp;self, message: T) {
      	// 上锁并从队列后面插入数据
        self.queue.lock().unwrap().push_back(message);
        // 唤醒一个等待数据的线程
        self.item_ready.notify_one();
    }

    pub fn receive(&amp;self) -&gt; T {
      	// 抢占队列
        let mut b = self.queue.lock().unwrap();
        loop {
          	// 尝试从队列中获取数据，如果获取到，则直接返回（并释放锁）
            if let Some(message) = b.pop_front() {
                return message;
            }
          	// 没有数据，则挂起当前线程（同时释放锁）
            b = self.item_ready.wait(b).unwrap();
        }
    }
}
</code></pre>
<p>这里需要注意的是，<code>self.queue.lock().unwrap()</code> 返回的 <code>b</code> 是一个 <code>MutextGuard</code>，所以当执行 <code>self.item_ready.wait(b)</code> 的时候，在挂起当前线程的时候，会释放 <code>b</code>，所以这里不会一直占用锁，而导致其他线程抢不到锁。</p>
<p>这个版本的实现在功能上当然没有问题，但是在性能上还有非常多可以优化的地方，尤其是在锁的使用上，在高并发的情况下，锁的竞争会非常激烈。</p>
<p>OK，热完身后，我们开始基于这个实现，来一步步实现一个高性能的 <code>oneshot channel</code>！</p>
<h2>基础版 v1：unsafe 提醒使用者</h2>
<pre><code class="language-rust">pub struct Channel&lt;T&gt; {
    queue: Mutex&lt;VecDeque&lt;T&gt;&gt;,
    item_ready: Condvar,
}
</code></pre>
<p>我们先来分析一下，一个 <code>oneshot channel</code> 的结构，需要包含哪些字段。</p>
<ol>
<li>首先它可能会有 0 条数据或 1 条数据，所以很当然，数据可以用一个 <code>Option</code> 来承载。</li>
<li>另外，<code>send</code> 和 <code>receive</code> 可以在不同的线程中被调用，所以我们只能用共享只读引用，而不是用 <code>mut</code> 独享可变引用，但是 <code>send</code> 和 <code>receive</code> 都需要对数据进行修改，所以我们这里就需要一个支持内部可变性的数据结构，这个时候，就用到了上篇 <a href="https://hedon.top/2025/05/13/rust-action-spinlock/#%E5%8D%87%E7%BA%A7%E7%89%88-v1">Rust 实战丨手写一个 SpinLock</a> 介绍的 <code>UnsafeCell&lt;T&gt;</code>，它允许在共享引用下进行内部可变性修改，是 Rust 并发原语的基石，这里不再赘述。</li>
<li>最后，我们需要一个变量来表明是否有数据，为了并发安全，这里可以用 <code>AtomicBool</code>，为此，我们也增加了一个 <code>is_ready</code> 的方法，用于判断数据是否已准备好。对于原子变量，我们使用一对 <code>Release</code> 和 <code>Acquire</code>（<code>Release</code> 确保之前的写入对其他线程可见，<code>Acquire</code> 确保能看到之前的 <code>Release</code> 写入）来确保原子变量的跨线程可见性。</li>
</ol>
<p>基于以上分析，我们定出了新的 <code>Channel</code> 结构：</p>
<pre><code class="language-rust">pub struct Channel&lt;T&gt; {
    message: UnsafeCell&lt;Option&lt;T&gt;&gt;,
    ready: AtomicBool,
}
</code></pre>
<p>对应的 <code>send</code>、<code>receive</code> 和 <code>is_ready</code> 实现如下：</p>
<pre><code class="language-rust">impl&lt;T&gt; Channel&lt;T&gt; {
    pub fn new() -&gt; Self {
        Self {
            message: UnsafeCell::new(None),
            ready: AtomicBool::new(false),
        }
    }

    /// Safety: Only call this once!
    pub unsafe fn send(&amp;self, message: T) {
        unsafe {
            self.message.get().write(Some(message));
        }
        self.ready.store(true, Ordering::Release);
    }

    pub fn is_ready(&amp;self) -&gt; bool {
        self.ready.load(Ordering::Acquire)
    }

    /// Safety: Only call this once,
    /// and only after is_ready() returns true!
    pub unsafe fn receive(&amp;self) -&gt; T {
        unsafe { self.message.get().read().unwrap() }
    }
}
</code></pre>
<p>在这个版本中：</p>
<ol>
<li>我们暂且使用 <code>unsafe</code> 加注释的方式，来 <strong>提醒</strong> 使用者，<code>send</code> 和 <code>receive</code> 只能被调用一次，同时，在调用 <code>receive</code> 之前，必须先使用 <code>is_ready</code> 进行数据检查。</li>
<li>对于原子变量，我们使用一对 Release 和 Acquire 来确保原子变量的跨线程可见性，具体可参考 <a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a>。</li>
</ol>
<p>另外别忘了，<code>UnsafeCell&lt;T&gt;</code> 是不支持 <code>Sync</code> 的，所以为了我们的 <code>Channel</code> 可以跨线程使用，我们需要为其实现 <code>Sync</code> trait：</p>
<pre><code class="language-rust">// 1. Channel&lt;T&gt; 可以在不同的线程中被分别执行 send 和 receive，所以它的引用可以在线程中共享，所以需要实现 Sync；
// 2. T 由线程 1 生成并放入 Channel，然后由线程 2 从 Channel 中获取，所以它需要从一个线程转移到另外一个线程，所以需要实现 Send。
unsafe impl&lt;T&gt; Sync for Channel&lt;T&gt; where T: Send {}
</code></pre>
<p>使用方法如下：</p>
<pre><code class="language-rust">#[test]
fn one_thread_should_work() {
    let channel = Channel::new();
    unsafe {
        channel.send(1);
    };
    if channel.is_ready() {
        let msg = unsafe { channel.receive() };
        assert_eq!(msg, 1);
    }
}

#[test]
fn cross_thread_should_work() {
    let channel = Channel::new();
    thread::scope(|s| {
        s.spawn(|| {
            sleep(Duration::from_millis(10));
            unsafe {
                channel.send(1);
            };
        });

        loop {
            if channel.is_ready() {
                let res = unsafe { channel.receive() };
                assert_eq!(res, 1);
                break;
            }
        }
    });
}
</code></pre>
<h2>基础版 v2：使用 MaybeUninit 替代 Option 减少内存开销</h2>
<p>我们先来思考一个问题：<strong>Option&lt;T&gt; 的内存占用是多少？</strong></p>
<blockquote>
<p>结论是：：<strong><code>Option&lt;T&gt;</code> 相比于 T，可能需要额外消耗标记位和填充位的空间</strong>。具体可参考<u>附录：1. Option&lt;T&gt; 的内存占用是多少</u>。</p>
</blockquote>
<p>另外一点是，<code>Option&lt;T&gt;</code> 其实已经包含了是否存在值的信息了，它跟 <code>ready</code> 这个标志的作用其实重复了，有一些浪费。</p>
<p>在当下场景，我们可以使用另外一个数据结构来替代 <code>Option&lt;T&gt;</code> —— <code>MaybeUninit&lt;T&gt;</code>，相比于 <code>Option&lt;T&gt;</code>，它有以下优势：</p>
<ol>
<li><strong>内存占用优化</strong>：在 <code>Option&lt;T&gt;</code> 中，对于非空指针优化（Niche Optimization）的类型，<code>None</code> 会占用额外的空间（一个字节的标签+可能的对齐填充）。而 <code>MaybeUninit&lt;T&gt;</code> 本身就是一个大小与 <code>T</code> 相同的未初始化内存，它没有标签，因此不会引入额外的内存开销。</li>
<li><strong>避免初始化开销</strong>：使用 <code>Option&lt;T&gt;</code> 时，在初始化时设置为 <code>None</code>，实际上会写入一个表示 <code>None</code> 的值（即进行初始化）。而 <code>MaybeUninit&lt;T&gt;</code> 的 <code>uninit()</code> 不会对内存进行任何初始化，这在性能敏感的场景下可以避免不必要的初始化开销（特别是当 <code>T</code> 很大时）。</li>
<li><strong>更灵活地控制初始化</strong>：在通道的实现中，消息可能由生产者写入，然后通过设置 <code>ready</code> 标志来通知消费者。使用 <code>MaybeUninit</code> 允许我们延迟初始化，直到实际需要写入消息的时候。这样，在通道创建时，我们不需要为 <code>T</code> 类型的值进行任何初始化（即使是 <code>None</code>），而是留出一块未初始化的内存，在后续由生产者写入实际的值。</li>
<li><strong>与原子标志配合更高效</strong>：在上个版本的视线中，<code>ready</code> 是一个 <code>AtomicBool</code>，用于指示消息是否就绪。在 <code>Option&lt;T&gt;</code> 版本中，我们需要检查 <code>Option</code> 是否为 <code>Some</code>，同时还要检查 <code>ready</code> 标志。而使用 <code>MaybeUninit</code> 后，我们完全依赖 <code>ready</code> 标志来判断消息是否可用，避免了双重检查（因为 <code>MaybeUninit</code> 本身不携带状态，所以状态完全由 <code>ready</code> 控制）。这样，结构体的内存布局更紧凑，且访问模式更直接。</li>
<li><strong>潜在的性能提升</strong>：由于避免了额外的标签和初始化，以及更紧凑的内存布局，可能会提高缓存利用率，从而提升性能。</li>
</ol>
<p><code>MaybeUninit&lt;T&gt;</code> 有以下常用方法：</p>
<table>
<thead>
<tr>
<th>常用方法</th>
<th>作用</th>
<th>安全级别</th>
</tr>
</thead>
<tbody><tr>
<td><code>MaybeUninit::uninit()</code></td>
<td>创建一块<strong>完全未初始化</strong>的内存</td>
<td><code>const fn</code>、<code>safe</code></td>
</tr>
<tr>
<td><code>as_mut_ptr()</code> / <code>as_ptr()</code></td>
<td>取出裸指针，供外部写入或读取</td>
<td><code>safe</code></td>
</tr>
<tr>
<td><code>assume_init()</code> / <code>assume_init_read()</code></td>
<td>告诉编译器“这里已经是一个合法的 <code>T</code> 了”，并返回它</td>
<td><code>unsafe</code>（因为你得保证真初始化过）</td>
</tr>
<tr>
<td><code>write(val)</code></td>
<td><strong>按位把 <code>val</code> 复制/移动</strong> 到这块未初始化内存；此后视为已初始化</td>
<td><code>unsafe</code></td>
</tr>
</tbody></table>
<p>经过上面一顿分析，我们来使用 <code>MaybeUninit&lt;T&gt;</code> 来替代 <code>Option&lt;T&gt;</code>，进一步减少内存占用和提升性能，新的 <code>Channel</code> 结构如下：</p>
<pre><code class="language-rust">pub struct Channel&lt;T&gt; {
    message: UnsafeCell&lt;MaybeUninit&lt;T&gt;&gt;,
    ready: AtomicBool,
}

impl&lt;T&gt; Channel&lt;T&gt; {
    pub fn new() -&gt; Self {
        Self {
          	// 创建一块完全未初始化的内存，先占位
            message: UnsafeCell::new(MaybeUninit::uninit()),
            ready: AtomicBool::new(false),
        }
    }
}
</code></pre>
<p>对应的 <code>send</code> 和 <code>receive</code> 实现更新如下：</p>
<pre><code class="language-rust">impl&lt;T&gt; Channel&lt;T&gt; {
    /// Safety: Only call this once!
    pub unsafe fn send(&amp;self, message: T) {
        unsafe {
            // 从 UnsafeCell&lt;MaybeUninit&lt;T&gt;&gt; 中取出 MaybeUninit&lt;T&gt; 并写入数据。
            (*self.message.get()).write(message);
        }
        self.ready.store(true, Ordering::Release);
    }

    /// Safety: Only call this once,
    /// and only after is_ready() returns true!
    pub unsafe fn receive(&amp;self) -&gt; T {
      	// 从 UnsafeCell&lt;MaybeUninit&lt;T&gt;&gt; 中取出 MaybeUninit&lt;T&gt; 并读出数据。
        unsafe { (*self.message.get()).assume_init_read() }
    }
}
</code></pre>
<p>其中 <code>send</code> 中，<code>(*self.message.get())</code> 从 <code>UnsafeCell&lt;MaybeUninit&lt;T&gt;&gt;</code> 中取出 <code>MaybeUninit&lt;T&gt;</code>，然后调用 <code>write</code> 方法把 <code>message</code> 写入这块未初始化的内存中，此后视为已初始化，并可以使用使用 <code>assume_init_read</code> 进行读取。</p>
<p>修改后，测试代码没有发生变化，我们执行之前的测试代码，发现还是可以通过的！</p>
<h2>基础版 v3：增加动态检查提高安全性</h2>
<p>上述版本中，我们通过 <code>unsafe</code> 和注释去“要求”调用者严格遵循以下约束：</p>
<ol>
<li><code>send</code> 和 <code>receive</code> 最多只调用一次。</li>
<li><code>receive</code> 调用之前，必须先经过 <code>is_ready</code> 的检查。</li>
</ol>
<p>在这个版本中，我们加一下动态检查，如果调用者不按要求做事，那就直接 <code>panic</code> 给出告警。</p>
<p>在 <code>receive</code> 中，我们需要做 2 点保证：① 已经有数据了，② 数据只被消耗了一次。</p>
<pre><code class="language-rust">pub unsafe fn receive(&amp;self) -&gt; T {
    if !self.ready.swap(false, Ordering::Acquire) {
        panic!(&quot;no message available!&quot;)
    }
    unsafe { (*self.message.get()).assume_init_read() }
}
</code></pre>
<p>这里我们使用 <code>swap</code>，将 <code>ready</code> 从 <code>false</code> 转为 <code>true</code>，达到了 2 个目的：</p>
<ol>
<li>如果返回了 <code>false</code>，则说明之前的 <code>ready</code> 为 <code>false</code>，即数据没准备好。</li>
<li>如果返回了 <code>true</code>，则说明数据已经准备好了，这个时候，也已经将 <code>ready</code> 置为 <code>false</code>，这样后面调用的 <code>receive</code> 也将失败。</li>
</ol>
<p>对于 <code>send</code>，我们需要保证只写入一次，所以这里我们需要引入一个新的变量 <code>in_user</code>，表示 <code>send</code> 是否已经使用了：</p>
<pre><code class="language-rust">pub struct Channel&lt;T&gt; {
    message: UnsafeCell&lt;MaybeUninit&lt;T&gt;&gt;,
    in_use: AtomicBool, // 新变量，表示 send 是否已经使用了。
    ready: AtomicBool,
}

impl&lt;T&gt; Channel&lt;T&gt; {
    pub fn new() -&gt; Self {
        Self {
            message: UnsafeCell::new(MaybeUninit::uninit()),
            in_use: AtomicBool::new(false),
            ready: AtomicBool::new(false),
        }
    }
}
</code></pre>
<p>在 <code>send</code> 中，我们依旧使用 <code>swap</code>，来将 <code>in_use</code> 从转为 <code>true</code>，如果返回 <code>true</code>，则说明之前已经执行过 <code>send</code> 了，这个时候将执行 panic 进行告警。</p>
<pre><code class="language-rust">/// Panics when trying to send more than one message.
pub unsafe fn send(&amp;self, message: T) {
    if self.in_use.swap(true, Ordering::Relaxed) {
        panic!(&quot;can&#39;t send more than one message&quot;)
    }
    unsafe {
        (*self.message.get()).write(message);
    }
    self.ready.store(true, Ordering::Release);
}
</code></pre>
<p>通过上述的 2 个优化，我们的 <code>Channel</code> 又“安全”了一丢丢！</p>
<h2>基础版 v4：实现 Drop 自动清理无用内存</h2>
<p>因为我们使用了 <code>MaybeUninit&lt;T&gt;</code>，所以我们需要自己管理 <code>T</code> 的内存管理，但在上述的实现中，可能存在一种情况，导致内存得不到释放：<strong>我们只执行了 <code>send</code>，但直到 <code>Channel</code> 超过作用域的时候，都没有被 <code>receive</code></strong>。</p>
<p>为此，我们可以为 <code>Channel</code> 实现 <code>Drop</code> trait，当有数据的时候，对<code>MaybeUninit&lt;T&gt;</code> 进行内存释放：</p>
<pre><code class="language-rust">impl&lt;T&gt; Drop for Channel&lt;T&gt; {
    fn drop(&amp;mut self) {
        if *self.ready.get_mut() {
            unsafe {
                self.message.get_mut().assume_init_drop();
            }
        }
    }
}
</code></pre>
<h2>安全非阻塞版 v5：提供安全方法，减少使用者误用</h2>
<p>在这个版本中，我们来解决前面实现的最大问题：<strong>方法是不安全的，严重依赖调用者的自觉性，没有充分发挥 Rust 强大编译器的检查能力</strong>。</p>
<p>回顾我们的需求：<strong>我们要实现的是一个 <code>oneshot channel</code>，即只能调用一次 <code>send</code> 和 <code>receive</code></strong>。</p>
<p>第一个问题是：如何利用 Rust 天然的编译器检查能力来约束这一点呢？很明显，就是<strong>所有权机制</strong>！什么东西只能执行一次呢？消耗所有权的东西！</p>
<pre><code class="language-rust">// 对于第一个参数为 self 的方法，执行时，会转移所有权，执行后，原变量就不能再用了，因为所有权已经转移了。
fn do(self) {}
</code></pre>
<p>好，那第二个问题就来了：<code>self</code> 方法只能调用一次，但很明显我们总共需要 2 次的调用（<code>send</code> 和 <code>receive</code>），所以这里我们可以将 <code>Channel</code> 进行拆开，分成 <code>Sender</code> 和 <code>Receiver</code>。</p>
<p>那第三个问题也就随之而来了，<code>Sender</code> 和 <code>Receiver</code> 都需要持有 <code>Channel</code>，并且可能处于不同的线程，这里我们可以先用 <code>Arc</code> 来对 <code>Channel</code> 进行引用。</p>
<p>解决了上述 3 个问题，我们可以梳理新的数据结构：</p>
<pre><code class="language-rust">pub struct Sender&lt;T&gt; {
    channel: Arc&lt;Channel&lt;T&gt;&gt;,
}

pub struct Receiver&lt;T&gt; {
    channel: Arc&lt;Channel&lt;T&gt;&gt;,
}

struct Channel&lt;T&gt; {
    message: UnsafeCell&lt;MaybeUninit&lt;T&gt;&gt;,
    ready: AtomicBool,
}

pub fn channel&lt;T&gt;() -&gt; (Sender&lt;T&gt;, Receiver&lt;T&gt;) {
    let a = Arc::new(Channel {
        message: UnsafeCell::new(MaybeUninit::uninit()),
        ready: AtomicBool::new(false),
    });
    (Sender { channel: a.clone() }, Receiver { channel: a })
}
</code></pre>
<ol>
<li><code>Channel</code> 中移除了 <code>in_use</code> 属性，因为我们已经有 <code>self</code> 做所有权检查了，不再需要 <code>in_use</code> 来避免重复调用 <code>send</code> 了。</li>
<li>新增了 <code>Sender&lt;T&gt;</code> 和 <code>Receiver&lt;T&gt;</code> 两个结构，它们都各自持有了一个 <code>Arc&lt;Channel&gt;</code>。</li>
</ol>
<p>对应的 <code>send</code> 和 <code>receive</code> 方法当然也就转移到 <code>Sender&lt;T&gt;</code> 和 <code>Receiver&lt;T&gt;</code> 身上了，实现也和之前基本一致：</p>
<pre><code class="language-rust">impl&lt;T&gt; Sender&lt;T&gt; {
    pub fn send(self, messgae: T) {
        unsafe { (*self.channel.message.get()).write(messgae) };
        self.channel.ready.store(true, Ordering::Release);
    }
}

impl&lt;T&gt; Receiver&lt;T&gt; {
    pub fn is_ready(&amp;self) -&gt; bool {
        self.channel.ready.load(Ordering::Relaxed)
    }

    /// Safety: only after is_ready() returns true!
    pub fn receive(self) -&gt; T {
        if !self.channel.ready.swap(false, Ordering::Acquire) {
            panic!(&quot;no message available!&quot;);
        }
        unsafe { (*self.channel.message.get()).assume_init_read() }
    }
}

// Drop 没变
</code></pre>
<p>修改了结构了，我们需要修改对应的测试代码：</p>
<pre><code class="language-rust">#[test]
fn one_thread_should_work() {
    let (sender, receiver) = channel();
    sender.send(1);
    if receiver.is_ready() {
        let msg = receiver.receive();
        assert_eq!(msg, 1);
    }
}

#[test]
fn cross_thread_should_work() {
    let (sender, receiver) = channel();
    thread::scope(|s| {
        s.spawn(|| {
            sleep(Duration::from_millis(10));
            sender.send(1); // 没有 unsafe 了！
        });

        loop {
            if receiver.is_ready() {
                let res = receiver.receive(); // 没有 unsafe 了！
                assert_eq!(res, 1);
                break;
            }
        }
    });
}
</code></pre>
<p>在最新的测试代码中，我们已经不再需要 <code>unsafe</code> 代码了！这对于使用者来说，就非常友好了！</p>
<p>而且这个时候，你如果尝试执行多次 <code>send</code> 和 <code>receive</code> 的时候，编译器就会报错了！</p>
<p>不过它还是有 2 个缺点：</p>
<ol>
<li><code>Arc</code> 的复制还是有一些开销的。</li>
<li>我们依旧依赖使用者提前用 <code>is_ready</code> 来检查，否则直接调用 <code>receive</code> 就有可能会 <code>panic</code>。</li>
</ol>
<p>我们先来解决第 1 个问题。</p>
<h2>安全非阻塞版 v6：使用生命周期加引用，避免 Arc 的复制开销</h2>
<p>为了避免 Arc 的开销，我们需要在 <code>Sender&lt;T&gt;</code> 和 <code>Receiver&lt;T&gt;</code> 中持有 <code>Channel&lt;T&gt;</code> 的引用，而引用的对象的生命周期是不确定的，所以我们需要加入生命周期标注，来告诉编译器我们的引用是逻辑自洽的。</p>
<p>新的结构和构造函数如下：</p>
<pre><code class="language-rust">pub struct Sender&lt;&#39;a, T&gt; {
    channel: &amp;&#39;a Channel&lt;T&gt;, // 使用引用替代 Arc，并加入生命周期标注
}

pub struct Receiver&lt;&#39;a, T&gt; {
    channel: &amp;&#39;a Channel&lt;T&gt;, // 使用引用替代 Arc，并加入生命周期标注
}

pub struct Channel&lt;T&gt; {
    message: UnsafeCell&lt;MaybeUninit&lt;T&gt;&gt;,
    ready: AtomicBool,
}

impl&lt;T&gt; Channel&lt;T&gt; {
    pub const fn new() -&gt; Self {
        Self {
            message: UnsafeCell::new(MaybeUninit::uninit()),
            ready: AtomicBool::new(false),
        }
    }

    pub fn split&lt;&#39;a&gt;(&amp;&#39;a mut self) -&gt; (Sender&lt;&#39;a, T&gt;, Receiver&lt;&#39;a, T&gt;) {
        *self = Self::new();
        (Sender { channel: self }, Receiver { channel: self })
    }
}

// Drop 没变
</code></pre>
<p>在这个版本的实现中，我们新增了 <code>split</code> 方法：</p>
<ol>
<li>其中参数 <code>&amp;&#39;a mut self</code> 表明它是一个独占引用，即 <code>channel.split()</code> 不会有并发问题。</li>
<li>第一行代码 <code>*self = Self::new()</code> 我们对原有的 <code>Channel</code> 进行重置，保证<strong>拆分之前通道里绝对没有残留数据</strong>，避免旧消息被下一对 <code>Sender/Receiver</code> 误使用。</li>
<li><code>&#39;a</code> 直接来自于 <code>&amp;&#39;a mut self</code>，保证两端把手<strong>绝不会比原始 <code>Channel</code> 活得更久</strong>。</li>
</ol>
<p>新的测试代码如下：</p>
<pre><code class="language-rust">#[test]
fn one_thread_should_work() {
    let mut channel = Channel::new();
    let (sender, receiver) = channel.split();
    sender.send(1);
    if receiver.is_ready() {
        let msg = receiver.receive();
        assert_eq!(msg, 1);
    }
}

#[test]
fn cross_thread_should_work() {
    let mut channel = Channel::new();
    let (sender, receiver) = channel.split();
    thread::scope(|s| {
        s.spawn(|| {
            sleep(Duration::from_millis(100));
            sender.send(1);
        });

        while !receiver.is_ready() {}
        assert_eq!(receiver.receive(), 1);
    });
}
</code></pre>
<h2>安全阻塞版 v7：去掉 is_ready 完全避免使用者误调用</h2>
<p>截止目前的实现版本中，我们还是依赖使用者在执行 <code>receive</code> 之前先执行 <code>is_ready</code> 进行数据检查，还是存在一定的误操作性。现在我们来实现一个完全阻塞的版本，来完全避免这个情况。</p>
<p>我们需要做几件事情：</p>
<ol>
<li>去掉 <code>is_ready</code> 方法；</li>
<li>在 <code>receive</code> 中，根据 <code>ready</code> 判断是否存在数据：<ol>
<li>如果存在，则直接取出数据并返回；</li>
<li>如果不存在，则需要先挂起当前线程，等待唤醒（直接 CPU 循环检查肯定可以，但不够优雅！咱不干！）；</li>
</ol>
</li>
<li>在 <code>send</code> 中，放入数据后，尝试唤醒可能处于挂起中的线程。</li>
</ol>
<p>那现在最重要的一个问题是：**如何唤醒处于挂起中的线程？**更进一步，<strong>唤醒哪个线程？</strong></p>
<p>这里其实是说不定的，因为 <code>Sender</code> 和 <code>Receiver</code> 都可能被放入任何一个线程中，不过在 <a href="https://marabos.nl/atomics/building-channels.html">Rust Atomics and Locks</a> 书中，作者假定了 <code>Receiver</code> 会固定在调用 <code>split</code> 的那个线程。</p>
<p>笔者认为这个假设是简单且有效的，回顾一下我们前面举的 Go 语言的 2 个例子：</p>
<pre><code class="language-go">func demo1() {
	done := make(chan struct{}) // 类似于 split
	go func() {
		// ... do something
		close(done)
	}()

	&lt;-done  // receiver
}
</code></pre>
<pre><code class="language-go">func demo2() {
	oneShot := make(chan string, 1)  // 类似于 split
	go func() {
		oneShot &lt;- generateText()
	}()

	text := &lt;-oneShot  // receiver
	doSthWithText(text)
}
</code></pre>
<p>在这两个最常见的 <code>oneshot channel</code> 的例子中，<code>Receiver</code> 就是处于调用 <code>split</code> 的线程中。所以我们可以基于这个假设来实现这个版本。</p>
<p>先回顾下 <code>Thread</code> 2 个最核心的方法：</p>
<ul>
<li><code> Thread.park(thread)</code>: 挂起线程，等待唤醒。</li>
<li><code>thread.unpark()</code>: 唤醒线程。</li>
</ul>
<p>首先我们需要在 <code>Sender</code> 中保存待唤醒的线程：</p>
<pre><code class="language-rust">pub struct Sender&lt;&#39;a, T&gt; {
    channel: &amp;&#39;a Channel&lt;T&gt;,
    receiving_thread: Thread,
}
</code></pre>
<p>在 <code>split()</code> 的时候，我们需要获取当前线程并保存在 <code>Sender</code> 中：</p>
<pre><code class="language-rust">pub fn split&lt;&#39;a&gt;(&amp;&#39;a mut self) -&gt; (Sender&lt;&#39;a, T&gt;, Receiver&lt;&#39;a, T&gt;) {
    *self = Self::new();
    (
        Sender {
            channel: self,
          	// 获取当前线程。记住！这里我们假设了 receiver 会固定在 split 的线程中！
            receiving_thread: thread::current(),
        },
        Receiver { channel: self },
    )
}
</code></pre>
<p>在 <code>recevie</code> 的时候，如果没有数据，我们就可以挂起当前线程，等待唤醒：</p>
<pre><code class="language-rust">impl&lt;T&gt; Receiver&lt;&#39;_, T&gt; {
    pub fn receive(self) -&gt; T {
        while !self.channel.ready.swap(false, Ordering::Acquire) {
            thread::park(); // 挂起当前线程，即 thread::current() 线程
        }
        unsafe { (*self.channel.message.get()).assume_init_read() }
    }
}
</code></pre>
<p>在 <code>send</code> 完数据后，唤醒可能挂起的线程：</p>
<pre><code class="language-rust">impl&lt;T&gt; Sender&lt;&#39;_, T&gt; {
    pub fn send(self, messgae: T) {
        unsafe { (*self.channel.message.get()).write(messgae) };
        self.channel.ready.store(true, Ordering::Release);
        Thread::unpark(&amp;self.receiving_thread); // 唤醒 receiver 线程
    }
}
</code></pre>
<p>最后删除之前的 <code>is_ready</code> 方法，然后更新我们的测试代码：</p>
<pre><code class="language-rust">#[test]
fn one_thread_should_work() {
    let mut channel = Channel::new();
    let (sender, receiver) = channel.split();
    sender.send(1);
    let msg = receiver.receive();
    assert_eq!(msg, 1);
}

#[test]
fn cross_thread_should_work() {
    let mut channel = Channel::new();
    let (sender, receiver) = channel.split();
    thread::scope(|s| {
        s.spawn(|| {
            sleep(Duration::from_millis(100));
            sender.send(1);
        });
        assert_eq!(receiver.receive(), 1); // 不再需要检查 is_ready，这里会阻塞一直直到有数据到来
    });
}
</code></pre>
<h2>最终版 v8：使用 PhantomData 来保证 Receiver 处于 split() 线程</h2>
<p>是不是觉得，上述实现已经完美无瑕了！其实不然，我们虽然假设了 <code>Receiver</code> 处于调用 <code>split()</code> 的线程中，但是还是无法阻止使用者将 <code>Receiver</code> 转移到其他线程。</p>
<p>再次回顾下我们现在的 <code>Channel</code> 和 <code>Receiver</code>：</p>
<pre><code class="language-rust">pub struct Receiver&lt;&#39;a, T&gt; {
    channel: &amp;&#39;a Channel&lt;T&gt;,
}

pub struct Channel&lt;T&gt; {
    message: UnsafeCell&lt;MaybeUninit&lt;T&gt;&gt;,
    ready: AtomicBool,
}

unsafe impl&lt;T&gt; Sync for Channel&lt;T&gt; where T: Send {}
</code></pre>
<p>我们为 <code>Channel&lt;T&gt;</code> 实现了 <code>Sync</code> trait，而标准库中有这 2 行代码：</p>
<pre><code class="language-rust">impl&lt;T: ?Sized + Sync&gt; Sync for &amp;T {}
impl&lt;T: ?Sized + Sync&gt; Send for &amp;T {}
</code></pre>
<p>所以 <code>&amp;Channel&lt;T&gt;</code> 实现了 <code>Sync/Send</code> trait，而 <code>Receiver</code> 只持有了一个 <code>&amp;&#39;a Channel&lt;T&gt;</code>，所以它也是 <code>Send</code> 的！</p>
<p>所以 <code>Receiver</code> 是可以被转移到其他线程的，即下述的测试代码在编译上也是通过的：</p>
<pre><code class="language-rust">#[test]
fn cross_thread_should_work() {
    let mut channel = Channel::new();
    let (sender, receiver) = channel.split(); // 执行 split() 的线程
    thread::scope(|s| {
        s.spawn(|| {
            sleep(Duration::from_millis(100));
            sender.send(1);
        });

      	// 这里在另外一个线程中，执行了 `receiver.receive()`
        s.spawn(|| {
            assert_eq!(receiver.receive(), 1);
        });
    });
}
</code></pre>
<p>但是我们执行后会发现，<code>receiver.receive()</code> 会被永久阻塞住，这是因为 <code>sender.send(1)</code> 只会唤醒执行 <code>split()</code> 的线程。</p>
<p>为了避免这种情况的发生，我们需要强行防止 <code>Receiver</code> 实现 <code>Send</code> trait！</p>
<p>怎么办呢？我们需要做到 2 件事情：</p>
<ol>
<li>让 <code>Receiver</code> 持有一个非 <code>Sync</code> 的属性；</li>
<li>这个属性除了标记没有其他作用，最好不要占用任何的资源。</li>
</ol>
<p>这里我们介绍一位新朋友：<code>PhantomData</code>：</p>
<blockquote>
<p><code>PhantomData</code> 是一个零大小类型（Zero-Sized Type, ZST），用于在编译期向类型系统传递额外信息，而不占用运行时内存。</p>
<p>我们可以用它在包一个 <code>!Send</code> 的类型，这样 <code>Receiver</code> 就是 <code>!Send</code> 的了。关于 <code>PhantomData</code> 的更多介绍，可以参考<u>附录 2：PhantomData</u>。</p>
</blockquote>
<p>在介绍完 <code>PhantomData</code> 后，我们就可以使用它来防止 <code>Receiver</code> 实现 <code>Send</code> trait 了：</p>
<pre><code class="language-rust">pub struct Receiver&lt;&#39;a, T&gt; {
    channel: &amp;&#39;a Channel&lt;T&gt;,
    _no_send: PhantomData&lt;*const ()&gt;,
}
</code></pre>
<p>其中 <code>*const()</code> 是 <code>!Send</code> 的，所以我们的 <code>Receiver</code> 再也不会被转移到其他线程了，而 <code>send</code> 是要求 <code>self</code>，所以即便是 <code>Sync</code> 的也无所谓了，因为无法通过引用来执行 <code>receive()</code> 方法。</p>
<p>现在我们可以再次执行上面的测试代码（强行将 Receiver 移动到其他线程中），将会得到以下的报错：</p>
<pre><code class="language-shell">error[E0277]: `*const ()` cannot be sent between threads safely
   --&gt; src/oneshotchannel.rs:103:21
    |
103 |               s.spawn(|| {
    |                 ----- ^-
    |                 |     |
    |  _______________|_____within this `{closure@oneshotchannel.rs:103:21}`
    | |               |
    | |               required by a bound introduced by this call
104 | |                 assert_eq!(receiver.receive(), 1);
105 | |             });
    | |_____________^ `*const ()` cannot be sent between threads safely
</code></pre>
<p>到这里，通过 8 个版本，我们一步步实现了一个高性能、低内存占用且安全可用的 <code>oneshot channel</code> 了！</p>
<h2>总结</h2>
<p>至此，通过 8 个小版本，我们不仅手写了一个 <strong>高性能、安全友好的 oneshot channel</strong>，还更进一步体验了 Rust 在并发领域 <strong>“以类型系统驱动正确性”</strong> 的威力。我们来做一个简单的小结。</p>
<p>关键收获：</p>
<table>
<thead>
<tr>
<th>版本</th>
<th>新增能力</th>
<th>解决了什么问题</th>
</tr>
</thead>
<tbody><tr>
<td><strong>v0</strong></td>
<td><code>Mutex</code> + <code>Condvar</code> 通用通道</td>
<td>打开话题、对比后续无锁方案</td>
</tr>
<tr>
<td><strong>v1</strong></td>
<td><code>UnsafeCell&lt;Option&lt;T&gt;&gt;</code> + <code>AtomicBool</code></td>
<td>去锁化、最小可行一次性通道</td>
</tr>
<tr>
<td><strong>v2</strong></td>
<td>替换为 <code>MaybeUninit&lt;T&gt;</code></td>
<td>节省内存&amp;避免双状态检查</td>
</tr>
<tr>
<td><strong>v3</strong></td>
<td>运行时检查 (<code>swap</code>)</td>
<td>阻止未准备/二次调用导致 UB</td>
</tr>
<tr>
<td><strong>v4</strong></td>
<td><code>Drop</code> 清理</td>
<td>防止“只 send 不 recv”泄漏</td>
</tr>
<tr>
<td><strong>v5</strong></td>
<td><code>Sender</code> / <code>Receiver</code> 所有权 API</td>
<td>编译期保证“仅调用一次”</td>
</tr>
<tr>
<td><strong>v6</strong></td>
<td>生命周期引用替换 <code>Arc</code></td>
<td>消除引用计数开销</td>
</tr>
<tr>
<td><strong>v7</strong></td>
<td><code>park / unpark</code> 阻塞模型</td>
<td>使用者不再需要轮询 <code>is_ready</code></td>
</tr>
<tr>
<td><strong>v8</strong></td>
<td><code>PhantomData</code> 防跨线程误用</td>
<td>类型系统彻底封死错误用法</td>
</tr>
</tbody></table>
<p>核心记忆点：</p>
<ul>
<li><strong>内部可变性</strong>：<code>UnsafeCell</code> 是所有并发原语的基石。</li>
<li><strong>延迟初始化</strong>：<code>MaybeUninit&lt;T&gt;</code> + “就绪标志” 是零成本组合。</li>
<li><strong>Release / Acquire</strong>：最轻量的跨线程可见性保障。</li>
<li><strong>所有权设计 API</strong>：让编译器替你兜底逻辑约束。</li>
<li><strong>PhantomData</strong>：零大小但能影响 <code>Send/Sync</code> 的类型级标记。</li>
<li><strong>迭代思路</strong>：先跑通，再收口安全性与性能，最后用类型系统“防呆”。</li>
</ul>
<p>完整的代码可以参考：<a href="https://github.com/hedon-rust-road/conutils/blob/main/src/oneshot.rs">conutils-oneshot</a>。</p>
<h2>oneshot crate 浅探</h2>
<h2>附录</h2>
<h3>1. Option&lt;T&gt; 的内存占用是多少？</h3>
<p>在 Rust 中，<code>Option&lt;T&gt;</code> 类型占用的内存，取决于泛型参数 <code>T</code> 的类型特性。具体可分为两种情况：</p>
<ul>
<li>当 T 是<strong>非指针类型</strong>（如基本类型、结构体等）时，<code>Option&lt;T&gt;</code> 需要额外的空间存储 Some 或 None 的标签，此时：<ul>
<li>内存布局：包含一个 1 字节的标签（标识 Some 或 None）和 <code>T</code> 类型的数据空间（可能包含对齐填充）。</li>
<li>即使为 <code>None</code>，仍需保留 <code>T</code> 所需的内存空间（含填充），以保障枚举值大小统一。如 <code>Option&lt;i32&gt;</code> 占用 8 字节（1 字节标签 + 4 字节 i32 + 3 字节填充）。</li>
</ul>
</li>
<li>当 T 是<strong>不可为空的指针类型</strong>（如 Box&lt;T&gt;、&amp;T、&amp;mut T）时，Rust 编译器会启用<strong>空指针优化</strong>（Niche Optimization）。即利用<strong>指针不能为 0</strong> 的特性，将 <code>None</code> 标识为全零位模式（0x00），而 <code>Some(ptr)</code> 存储实际指针地址，此时无需额外标签。这个时候，<code>None</code> 和 <code>Some(T)</code> 不占用任何的额外空间，大小与 T 相同。</li>
</ul>
<p>我们可以写个程序来简单验证一下：</p>
<pre><code class="language-rust">fn test_option() {
    // 1. 基本数据类型
    let i: i32 = 1;
    let i_none: Option&lt;i32&gt; = None;
    let i_some: Option&lt;i32&gt; = Some(1);
    println!(&quot;基本类型：&quot;);
    println!(&quot;i32: {} bytes, ptr: {:p}&quot;, mem::size_of_val(&amp;i), &amp;i); // 4 bytes
    println!(&quot;None&lt;i32&gt;: {} bytes, ptr: {:p}&quot;, mem::size_of_val(&amp;i_none), &amp;i_none); // 8 bytes
    println!(&quot;Some&lt;i32&gt;: {} bytes, ptr: {:p}&quot;, mem::size_of_val(&amp;i_some), &amp;i_some); // 8 bytes

    // 2. 自定义类型
    #[repr(C)]
    struct Data {
        a: u64,
        b: u32,
    }
    let data = Data { a: 1, b: 1 };
    let data_none: Option&lt;Data&gt; = None;
    let data_some: Option&lt;Data&gt; = Some(Data { a: 1, b: 1 });
    println!(&quot;\n自定义结构体：&quot;);
    println!(&quot;Data: {} bytes, ptr: {:p}&quot;, mem::size_of_val(&amp;data), &amp;data); // 16 bytes
    println!(&quot;None&lt;Data&gt;: {} bytes, ptr: {:p}&quot;, mem::size_of_val(&amp;data_none), &amp;data_none); // 24 bytes
    println!(&quot;Some&lt;Data&gt;: {} bytes, ptr: {:p}&quot;, mem::size_of_val(&amp;data_some), &amp;data_some); // 24 bytes

    // 3. 指针类型
    let b = Box::new(1);
    let b_none: Option&lt;Box&lt;i32&gt;&gt; = None;
    let b_some: Option&lt;Box&lt;i32&gt;&gt; = Some(Box::new(1));
    println!(&quot;\n指针类型：&quot;);
    println!(&quot;Box&lt;i32&gt;: {} bytes, ptr: {:p}&quot;, mem::size_of_val(&amp;b), &amp;b); // 8 bytes
    println!(&quot;None&lt;Box&lt;i32&gt;&gt;: {} bytes, ptr: {:p}&quot;, mem::size_of_val(&amp;b_none), &amp;b_none); // 8 bytes
    println!(&quot;Some&lt;Box&lt;i32&gt;&gt;: {} bytes, ptr: {:p}&quot;, mem::size_of_val(&amp;b_some), &amp;b_some); // 8 bytes
    let none_value = unsafe { *(&amp;b_none as *const _ as *const i64) };
    println!(&quot;None&lt;Box&lt;i32&gt;&gt; bit pattern: {:#x}&quot;, none_value); // 0x0
  	let some_value = unsafe { *(&amp;b_some as *const _ as *const i64) };
		println!(&quot;None&lt;Box&lt;i32&gt;&gt; bit pattern: {:#x}&quot;, some_value); // 0x15d0043c0
}
</code></pre>
<p>在笔者的电脑下，输出如下：</p>
<pre><code class="language-shell">基本类型：
i32: 4 bytes, ptr: 0x16bef203c
None&lt;i32&gt;: 8 bytes, ptr: 0x16bef2040
Some&lt;i32&gt;: 8 bytes, ptr: 0x16bef2048

自定义结构体：
Data: 16 bytes, ptr: 0x16bef2200
None&lt;Data&gt;: 24 bytes, ptr: 0x16bef2210
Some&lt;Data&gt;: 24 bytes, ptr: 0x16bef2228

指针类型：
Box&lt;i32&gt;: 8 bytes, ptr: 0x16bef23f8
None&lt;Box&lt;i32&gt;&gt;: 8 bytes, ptr: 0x16bef2400
Some&lt;Box&lt;i32&gt;&gt;: 8 bytes, ptr: 0x16bef2408
None&lt;Box&lt;i32&gt;&gt; bit pattern: 0x0
</code></pre>
<p>通过输出我们可以观察到经过<strong>空指针优化</strong>，<code>Option&lt;Box&lt;i32&gt;&gt;</code> 的 <code>None</code> 和 <code>Some</code> 都只占 <strong>8 字节</strong>（与 <code>Box&lt;i32&gt;</code> 相同），同时通过为 <code>None</code> 复用类型的无效位模式（如 <code>0x0</code>）消除枚举标签，实现零成本抽象。</p>
<h3>2. PhantomData</h3>
<p>Rust 中的 <code>PhantomData</code> 是一个零大小类型（Zero-Sized Type, ZST），用于在编译期向类型系统传递额外信息，而不占用运行时内存。它在泛型编程、生命周期管理和所有权标记中扮演关键角色。</p>
<p>它有以下的核心特性和作用：</p>
<ol>
<li><p><strong>零内存开销</strong>：<code>PhantomData&lt;T&gt;</code> 本身不存储任何数据，编译后会被优化掉，因此<strong>不会增加结构体的实际内存占用</strong>。</p>
<pre><code class="language-rust">use std::marker::PhantomData;
struct Wrapper&lt;T&gt; {
    data: u32,
    _marker: PhantomData&lt;T&gt;, // 不占空间
}
</code></pre>
</li>
<li><p><strong>标记未使用的泛型参数</strong>：Rust 要求泛型参数必须在结构体中被显式使用。若泛型参数未直接出现在字段中，可通过 <code>PhantomData</code> 标记其存在性，避免编译错误。</p>
<pre><code class="language-rust">struct Resource&lt;T&gt; {
    handle: *mut (),
    _phantom: PhantomData&lt;T&gt;, // 标记类型 T
}
</code></pre>
</li>
<li><p><strong>声明生命周期依赖</strong>：当结构体包含原始指针（如 <code>*const T</code>）时，<code>PhantomData</code> 可绑定生命周期，确保引用的数据有效性。</p>
<pre><code class="language-rust">struct Slice&lt;&#39;a, T&gt; {
    start: *const T,
    end: *const T,
    _phantom: PhantomData&lt;&amp;&#39;a T&gt;, // 绑定生命周期 &#39;a
}
</code></pre>
</li>
<li><p><strong>协变与逆变控制</strong>：通过 <code>PhantomData&lt;&amp;&#39;a T&gt;</code> 或 <code>PhantomData&lt;*mut T&gt;</code> 等不同形式，调整类型的协变/逆变行为。</p>
<pre><code class="language-rust">use std::marker::PhantomData;
use std::rc::Rc;

// 标记类型为 !Send 且 !Sync
struct NotThreadSafe {
    _marker: PhantomData&lt;Rc&lt;()&gt;&gt;, // Rc&lt;()&gt; 本身是 !Send + !Sync
}
</code></pre>
</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨手写一个 SpinLock</title>
      <link>https://hedon.top/blog/rust-action-spinlock/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-spinlock/</guid>
      <pubDate>Tue, 13 May 2025 12:58:25 GMT</pubDate>
      <description>本文以三段迭代示例演示如何在 Rust 中手写自旋锁：从最小化原子标志实现，到绑定受保护数据，再到借助 RAII 实现自动解锁。过程中深入讲解 Atomic 内存顺序、UnsafeCell 内部可变性、Send/Sync 并发标记，以及 Drop/Deref 零成本抽象，帮助读者理解自旋锁适用场景与潜在陷阱，并掌握将并发安全问题前移到编译期的工程思维。</description>
      <category>rust</category><category>并发编程</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<p>系列文章：</p>
<ul>
<li><a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a></li>
<li><a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a></li>
<li><a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a> 👈 本篇</li>
<li><a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a></li>
<li><a href="/blog/rust-action-arc/">Rust 实战丨手写一个 Arc</a></li>
<li><a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a></li>
<li><a href="/blog/rust-action-mutex/">Rust 实战丨手写一个 Mutex</a></li>
<li><a href="/blog/rust-action-condvar/">Rust 实战丨手写一个 Condvar</a></li>
<li><a href="/blog/rust-action-rwlock/">Rust 实战丨手写一个 RwLock</a></li>
</ul>
<hr>
<p>在并发编程中，<strong>锁</strong>（lock）是一种常用的同步机制，用于保护共享数据避免竞态条件。然而，在许多编程语言中，锁的使用往往需要手动“加锁”和“解锁”。<strong>手动解锁</strong>的时机很难控制——如果程序在临界区出现错误而跳出了正常流程，开发者可能会忘记解锁锁，从而导致其他线程永远无法取得该锁，发生死锁。另外，还可能发生<strong>重复解锁</strong>的问题：比如线程 A 解锁后，线程 B 很快加锁，这时如果线程 A 的异常处理代码再次执行了解锁操作，就会把线程 B 的锁过早释放，造成数据竞态。另外，大部分语言中<strong>锁和它所保护的数据缺乏关联</strong>：编译器并不知道某个数据必须在特定锁保护下访问，这样一来，程序员很容易犯“未加锁就访问数据”的错误。这些问题对于新手来说尤其常见，而且<strong>编译器无法帮助检查</strong>并发使用上的这些 Bug。</p>
<p>为了解决上述问题，理想情况是让<strong>锁的管理和资源的生命周期绑定</strong>，由语言帮我们自动管理解锁。Rust 正是通过所有权和生命周期机制，实现了资源与作用域生命周期的绑定，即典型的 <strong>RAII</strong> 技术（<em>Resource Acquisition Is Initialization</em>，资源获取即初始化）。</p>
<p>在 Rust 标准库中，像 <code>Mutex</code>（互斥锁）就利用了 RAII：获取锁会返回一个守卫对象（例如 <code>MutexGuard</code>），当守卫对象被丢弃（析构）时自动解锁，从而避免显式解锁的麻烦。Rust 标准库的 <code>Mutex</code> 底层利用了操作系统的锁机制，在线程争用时会使线程休眠挂起，以避免浪费 CPU。然而，在一些场景下，比如<strong>无操作系统环境（no_std）<strong>的内核开发、<strong>中断处理</strong>、或者</strong>临界区极短</strong>的场合，我们可能希望使用<strong>自旋锁</strong>（SpinLock）来忙等待锁，而不进入休眠。自旋锁在锁竞争短暂时能省去线程切换的开销，但如果锁被占用时间过长，会浪费大量 CPU 时间，因此需要慎重使用。</p>
<p>接下来，我们将参考 <a href="https://marabos.nl/atomics/">Rust Atomics and Locks</a> 一书，<strong>从零开始实现一个 Rust 版本的 SpinLock</strong>。我们会按三个版本逐步引入功能和概念：</p>
<ol>
<li>首先实现基本的 <strong>v0</strong> 版本（不保护具体数据，仅提供加锁/解锁机制）；</li>
<li>然后扩展为能够保护数据的 <strong>v1</strong> 版本；</li>
<li>最后加入 RAII 机制实现自动解锁的<strong>v2</strong>版本。</li>
</ol>
<p>过程中，我们会讨论相关的 Rust 并发概念，包括 <strong>Atomic</strong> 原子类型、<strong>Ordering</strong> 内存序、<strong>UnsafeCell</strong>、<strong>Send/Sync</strong> 并发安全标记、以及 RAII 中的 <strong>Deref/Drop</strong> trait 等。</p>
<p>让我们一步步实现这个自旋锁吧！</p>
<h2>读完本篇你能学到什么</h2>
<ol>
<li><strong>自旋锁与互斥锁的权衡</strong>：了解自旋锁适合的场景（极短临界区、内核/中断上下文、无 OS 环境），以及为何在锁竞争时间较长时应优先选择休眠式互斥锁。</li>
<li><strong>原子操作 + 内存顺序的实战用法</strong>：学会使用 <code>AtomicBool</code>，并理解 <code>Acquire / Release</code> 在加锁、解锁时建立的 <em>happens-before</em> 关系；掌握 <code>swap</code> 与 <code>spin_loop</code> 的配合细节。</li>
<li><strong>内部可变性（UnsafeCell）</strong>：掌握如何在持有不可变引用的情况下对数据进行安全修改。</li>
<li><strong>RAII + <code>Drop</code> 机制消除“忘记解锁”Bug</strong>：通过 <code>SpinLockGuard</code> + <code>Drop</code>，体验如何把“资源释放”交给作用域管理，彻底根除忘记/重复解锁的风险。</li>
<li><strong><code>Deref</code> / <code>DerefMut</code> 的零成本抽象</strong>：掌握为守卫对象实现 <code>Deref</code>/<code>DerefMut</code>，让使用者像操作普通引用一样操作受保护数据，而不引入额外运行时开销。</li>
</ol>
<h2>基础版 v0：自旋锁的基本实现</h2>
<p>我们先从最基础的版本开始，我们在 <code>lock</code> 的时候，如果失败了，就一直循环尝试，直到成功获取锁：</p>
<pre><code class="language-rust">pub struct SpinLock {
  	// 原子布尔标志，表示锁是否被占用
    locked: AtomicBool,
}

impl SpinLock {
    pub const fn new() -&gt; Self {
        Self {
            locked: AtomicBool::new(false),
        }
    }

    pub fn lock(&amp;self) {
        // 使用原子操作尝试将 flag 变为 true，并返回之前的值：
      	//   如果返回的是 true，则说明锁已经被其他线程抢走了。
      	//	 如果返回的是 false，则说明当前线程抢占锁成功。
        // 获取锁使用 Acquire 语义以确保后续对受保护数据的内存访问不会被重排到锁获取之前。
        while self.locked.swap(true, Ordering::Acquire) {
            // 向处理器发出一个提示，表示当前线程正忙等待。
          	// 这在某些架构上可以减少功耗或让处理器优化性能（比如 x86 上的 PAUSE 指令），避免无效地占用总线。
            std::hint::spin_loop();
        }
    }

    pub fn unlock(&amp;self) {
        // 将标志置回 false，释放锁。使用 Release 语义以确保之前临界区的修改对后续获取锁的线程可见。
        self.locked.store(false, Ordering::Release);
    }
}
</code></pre>
<p>在上述实现中，我们定义了结构 <code>SpinLock</code>，它包含一个原子变量 <code>locked</code>。</p>
<p>在 <code>lock</code> 方法中，我们尝试对 <code>locked</code> 原子变量进行 <code>swap</code> 为 <code>true</code> 的操作，<code>swap</code> 会返回交换之前的值，如果是 <code>false</code>，那就说明抢锁成功了，这个时候 <code>lock</code> 就成功返回，否则，则调用 <code>std::hint::spin_loop()</code> 进行自旋，在下一次 <code>while</code> 循环中再尝试获取锁。</p>
<p>在 <code>unlock</code> 方法中，我们只需要将 <code>locked</code> 设置为 <code>false</code> 即可。</p>
<p>示例图如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250513131603680.png" alt=""></p>
<p>这里有几个需要关注的点：</p>
<ol>
<li><code>std::hint::spin_loop()</code> 会向 CPU 发送特定指令（如 x86 的 pause 或 ARM 的 yield），提示当前处于忙等待状态。这允许 CPU 优化执行行为：<ul>
<li><strong>降低功耗</strong>：减少自旋期间的计算资源消耗。</li>
<li><strong>提升多线程效率</strong>：在超线程架构中，避免单个核心的忙等待阻塞其他线程的执行。</li>
</ul>
</li>
<li><code>locked</code> 是一个原子变量，对其的操作称为原子操作（Atomic Operation）。<strong>原子操作是指在多线程情况下不可被中断的操作，能保证对变量的读/写要么完整完成要么不发生，因此不存在数据竞争</strong>。在 Rust 中，每个原子操作都需要指定内存顺序（Memory Ordering）参数，用于约束编译器和 CPU 对指令重排的规则。更详细的规则可参阅：<a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a>。</li>
<li>这里我们内存顺序使用了一对 <code>Acquire</code> 和 <code>Release</code>。其中：<ul>
<li>获取锁的时候使用 <code>Acquire</code> 确保后续对受保护数据的内存访问不会被重排到锁获取之前。</li>
<li>释放锁的时候使用 <code>Release</code> 确保之前临界区内的所有修改都完成发布（对其他线程可见），再让其他线程获取锁。</li>
</ul>
</li>
</ol>
<p>我们来撰写单元测试：</p>
<pre><code class="language-rust">#[cfg(test)]
mod tests {
		#[test]
    fn one_thread_should_work() {
        let lock = SpinLock::new();
        let mut data = vec![]; // 临界区资源

        lock.lock();
        data.push(1); // 临界区代码
        lock.unlock();

        lock.lock();
        print!(&quot;{:?}&quot;, data); // 临界区代码
        lock.unlock();
    }

    #[test]
    fn cross_thread_should_work() {
        let data = vec![];  // 临界区资源
        let lock = SpinLock::new();
        thread::scope(|s| {
            s.spawn(|| {
                lock.lock();
                unsafe {
                    let data_ptr = &amp;data as *const Vec&lt;i32&gt; as *mut Vec&lt;i32&gt;;
                    (*data_ptr).push(1);  // 临界区代码
                }
                lock.unlock();
            });
            sleep(Duration::from_millis(100));
            lock.lock();
            unsafe {
                let data_ptr = &amp;data as *const Vec&lt;i32&gt; as *mut Vec&lt;i32&gt;;
                (*data_ptr).push(2); // 临界区代码
            }
            lock.unlock();
        });

        lock.lock();
        print!(&quot;{:?}&quot;, data);  // 临界区代码
        lock.unlock();
    }
}
</code></pre>
<p>在跨线程的测试用例 <code>cross_thread_should_work</code> 中，为了对 <code>data</code> 进行修改，我们只能在 <code>unsafe</code> 里面强行使用裸指针来进行操作，否则编译就会失败。</p>
<h2>升级版 v1：将锁与数据关联</h2>
<p>在上一个基础版本中，虽然这么做能起到互斥的作用，但是存在 2 个问题：</p>
<ol>
<li>我们会发现操作临界资源非常麻烦，因为临界资源的类型，可能是不满足 <code>Sync</code> 和 <code>Send</code> 的，所以它们无法在跨线程中进行传递或转移，所以即便我们能从逻辑上断定它们是并发安全的，但是编译器可没那么聪明，所以我们只能通过 <code>unsafe</code> 强行绕过编译期的检查。</li>
<li>锁和被保护的数据是分离的。程序员必须小心确保每次访问共享数据都正确地调用了 <code>lock()</code> 和 <code>unlock()</code>。一旦忘记调用 <code>unlock()</code>，或者搞错了加锁解锁的配对关系，编译器都不会报错，但程序的并发行为就可能出问题。</li>
</ol>
<p>显然，我们希望让<strong>锁与数据关联</strong>起来，从语法层面降低误用的可能，同时便于我们为临界资源的数据类型限定相关的 trait，提高资源访问的便捷性。这正是下一步要做的改进。</p>
<p>我们看看标准库的 <code>Mutex</code> 是怎么实现的：</p>
<pre><code class="language-rust">pub fn lock(&amp;self) -&gt; LockResult&lt;MutexGuard&lt;&#39;_, T&gt;&gt; {
    unsafe {
        self.inner.lock();
        MutexGuard::new(self)
    }
}

pub struct MutexGuard&lt;&#39;a, T: ?Sized + &#39;a&gt; {
    lock: &amp;&#39;a Mutex&lt;T&gt;,
    poison: poison::Guard,
}
</code></pre>
<p>可以发现，标准的锁是将要保护的临界资源放在了锁里，在获取锁的时候，就返回这个临界资源的 <code>Guard</code>。</p>
<p>OK，我们先不着急引入这个 <code>Guard</code>，我们就直接在获取锁的时候返回临界资源的可变引用即可。</p>
<p>更新后的版本如下所示；</p>
<pre><code class="language-rust">pub struct SpinLock&lt;T&gt; {
    locked: AtomicBool,
    value: UnsafeCell&lt;T&gt;,
}

impl&lt;T&gt; SpinLock&lt;T&gt; {
    pub fn new(value: T) -&gt; Self {
        Self {
            locked: AtomicBool::new(false),
            value: UnsafeCell::new(value),
        }
    }

    pub fn lock(&amp;self) -&gt; &amp;mut T {
        while self.locked.swap(true, Ordering::Acquire) {
            std::hint::spin_loop();
        }
      	// Safety: 我们知道这个时候同时只可能有一个线程能获取到 value，
      	// 也知道这个 value 一定存在，所以可以直接 unwrap()。
        unsafe { self.value.get().as_mut().unwrap() }
    }

    pub fn unlock(&amp;self) {
        self.locked.store(false, Ordering::Release);
    }
}


unsafe impl&lt;T&gt; Send for SpinLock&lt;T&gt; where T: Send {}
unsafe impl&lt;T&gt; Sync for SpinLock&lt;T&gt; where T: Send {}
</code></pre>
<p>可以看到，我们在 <code>SpinLock</code> 中加入了类型为 <code>UnsafeCell&lt;T&gt;</code> 的字段 <code>value</code>，然后在 <code>lock()</code> 抢到锁的时候，通过 <code>self.value.get().as_mut().unwrap()</code> 获取 <code>value</code> 的可变引用，我们知道这里是安全的，所以 <code>unsafe</code> 是安全的。</p>
<p>在这个版本中，我们见到了一个新朋友 <code>UnsafeCell</code>，事实上它是 Rust 标准库中所有的并发工具的基石，它涉及到了一个概念：<strong>内部可变性</strong>。</p>
<p>在 Rust 的类型系统中，如果我们只有一个对锁的不可变引用（<code>&amp;SpinLock&lt;T&gt;</code>），按正常规则是无法直接获得对内部数据的可变引用（<code>&amp;mut T</code>）的——<strong>毕竟 Rust 不允许在仅持有不可变引用的情况下修改数据</strong>。但对于实现锁这种特殊结构，我们清楚只有获取锁后才会独占数据的访问权，此时产生一个可变引用是安全的。为了突破编译器的限制，我们需要借助 <code>std::cell::UnsafeCell</code>。</p>
<p><strong>UnsafeCell</strong> 是 Rust 提供的一个内部可变性工具类型，它包装一个数据，使得即使在只有不可变引用的情况下也可以进行修改（当然需要在 <code>unsafe</code> 块中操作）。很多线程同步原语（比如 <code>Mutex</code>、<code>AtomicBool</code> 自身等）内部都用 <code>UnsafeCell</code> 来允许内部数据的可变访问。</p>
<p>标准库中，基于 <code>UnsafeCell&lt;T</code>&gt;，封装了一些满足<strong>内部可变性</strong>的类型：</p>
<ul>
<li><p><code>Cell&lt;T&gt;</code>: 只允许 Copy 类型，通过 get()/set() 操作。</p>
</li>
<li><p><code>RefCell&lt;T&gt;</code>: 运行时借用检查，但不是 Sync。</p>
</li>
<li><p><code>Mutex&lt;T&gt;</code>: 线程安全，但性能开销大。</p>
</li>
</ul>
<p>同时也因为 <code>UnsafeCell&lt;T</code>&gt; 并不满足 <code>Send</code> 和 <code>Sync</code> trait，所以我们需要手动为其实现：</p>
<pre><code class="language-rust">unsafe impl&lt;T&gt; Send for SpinLock&lt;T&gt; where T: Send {}
unsafe impl&lt;T&gt; Sync for SpinLock&lt;T&gt; where T: Send {}
</code></pre>
<p>我们修改我们的测试用例：</p>
<pre><code class="language-rust">#[cfg(test)]
mod tests {
		#[test]
    fn one_thread_should_work() {
        let lock = SpinLock::new(vec![]); // 临界资源包在锁里面了

        let data = lock.lock();
        data.push(1); // 临界区代码
        lock.unlock();

        let data = lock.lock();
        print!(&quot;{:?}&quot;, data); // 临界区代码
        lock.unlock();
    }

    #[test]
    fn cross_thread_should_work() {
        let lock = SpinLock::new(vec![]); // 临界资源包在锁里面了
        thread::scope(|s| {
            s.spawn(|| {
                let data1 = lock.lock();
                data1.push(1); // 临界区代码
                lock.unlock();
            });
            sleep(Duration::from_millis(100));
            let data2 = lock.lock();
            data2.push(2); // 临界区代码
            lock.unlock();
        });

        let data = lock.lock();
        print!(&quot;{:?}&quot;, data); // 临界区代码
        lock.unlock();
    }
}
</code></pre>
<p>这个版本的测试用例中，对于使用者来说，很明显就简洁很多了，再也不需要使用 <code>unsafe</code> 这种危险工具了。</p>
<h2>最终版 v2：引入 RAII 的自旋锁守卫</h2>
<p>v1 的实现仍然存在隐患，它要求调用者严格按照正确的顺序使用。我们可以想象一些误用场景：</p>
<ul>
<li><strong>忘记解锁：</strong> 如果线程获得了锁却没有调用 <code>unlock()</code> 就结束了，那么锁将一直保持锁定状态，导致其他线程永远自旋等待，无法前进。</li>
<li><strong>重复解锁：</strong> 如果调用者不小心对同一个锁调用了两次 <code>unlock()</code>，第二次解锁会将另一个线程持有的锁误释放，造成数据同时被两个线程访问的风险。</li>
<li><strong>未加锁访问：</strong> 由于我们提供了 <code>lock()</code> 返回 <code>&amp;mut T</code> 的接口，调用者理论上可以持有这个引用不放，然后调用 <code>unlock()</code> 解锁。这样一来，就出现了一个悬空引用——锁已经释放但仍持有先前的 <code>&amp;mut T</code>，如果此时另一线程加锁并修改数据，两个线程将同时持有对同一数据的可变引用，发生数据竞争！换言之，v1 的接口并不能防止调用者违反“先锁后用、用完解锁”的约定，Rust 编译器也无法帮我们检查这种逻辑错误。</li>
</ul>
<p>综上，v1 尽管把数据和锁绑定在一起，但<strong>正确使用仍然完全依赖程序员自觉</strong>，稍有不慎就可能出错。这显然不符合 Rust 一贯的“编译期保证安全”的理念。有没有办法在<strong>编译阶段</strong>就防止上述误用呢？这就是我们下一步要做的：引入 <strong>RAII</strong> 机制，用 Rust 的所有权来管理锁的获取和释放。</p>
<blockquote>
<p>RAII（Resource Acquisition Is Initialization，资源获取即初始化）是 C++/Rust 中的核心编程范式，通过将资源的生命周期与对象的生命周期绑定，实现资源的自动管理。其核心思想是：<strong>在对象构造函数中获取资源，在析构函数中释放资源</strong>，确保资源在任何情况下（包括异常）都能被正确释放。</p>
</blockquote>
<p>这个时候，<code>Guard</code> 就可以登场了，我们可以参考标准库一样，在 <code>lock</code> 的时候返回一个 <code>Guard</code>，当这个 <code>Guard</code> 离开作用域的时候，它的 <code>drop</code> 就会被调用，我们可以在里面，执行 <code>unlock</code> 操作，这有 2 个好处：</p>
<ol>
<li><code>drop(guard)</code> 是要消耗所有权的，所以可以避免重复释放锁；</li>
<li><code>drop(guard)</code> 在变量离开作用域后会被自动调用，所以可以避免忘记释放锁的情况发生。</li>
</ol>
<p>更新后的版本如下所示：</p>
<pre><code class="language-rust">pub struct SpinLock&lt;T&gt; {
    locked: AtomicBool,
    value: UnsafeCell&lt;T&gt;,
}

pub struct SpinLockGuard&lt;&#39;a, T&gt; {
    lock: &amp;&#39;a SpinLock&lt;T&gt;,
}

unsafe impl&lt;T&gt; Send for SpinLock&lt;T&gt; where T: Send {}
unsafe impl&lt;T&gt; Sync for SpinLock&lt;T&gt; where T: Send {}

impl&lt;T&gt; SpinLock&lt;T&gt; {
    pub fn new(value: T) -&gt; Self {
        Self {
            locked: AtomicBool::new(false),
            value: UnsafeCell::new(value),
        }
    }

    pub fn lock(&amp;self) -&gt; SpinLockGuard&lt;T&gt; {
        while self.locked.swap(true, Ordering::Acquire) {
            std::hint::spin_loop();
        }
        SpinLockGuard::new(&amp;self)
    }
}

impl&lt;&#39;a, T&gt; SpinLockGuard&lt;&#39;a, T&gt; {
    pub fn new(lock: &amp;&#39;a SpinLock&lt;T&gt;) -&gt; SpinLockGuard&lt;&#39;a, T&gt; {
        Self { lock }
    }
}

impl&lt;T&gt; Drop for SpinLockGuard&lt;&#39;_, T&gt; {
    fn drop(&amp;mut self) {
      	// 释放锁。
        self.lock.locked.store(false, Ordering::Release);
    }
}

impl&lt;T&gt; Deref for SpinLockGuard&lt;&#39;_, T&gt; {
    type Target = T;
    fn deref(&amp;self) -&gt; &amp;Self::Target {
        // Safety: 这里我们已经拿到锁（SpinLockGuard）了，
        // 所以可以确保数据的存在且独占的。
        unsafe { &amp;*self.lock.value.get() }
    }
}

impl&lt;T&gt; DerefMut for SpinLockGuard&lt;&#39;_, T&gt; {
    fn deref_mut(&amp;mut self) -&gt; &amp;mut Self::Target {
        // Safety: 这里我们已经拿到锁（SpinLockGuard）了，
        // 所以可以确保数据的存在且独占的。
        unsafe { &amp;mut *self.lock.value.get() }
    }
}
</code></pre>
<ol>
<li>我们引入了类型 <code>SpinLockGuard</code>，它包含了一个 <code>SpinLock</code> 的引用，所以我们需要用生命周期 <code>&#39;a</code> 进行标注。</li>
<li><code>SpinLock</code> 在 <code>lock()</code> 成功时，返回一个 <code>SpinLockGuard</code>。</li>
<li>我们为 <code>SpinLockGuard</code> 实现 <code>drop</code> trait，让其在被 drop 时自动执行 <code>unlock</code>，这样就实现了离开作用域自动 unlock 的功能。</li>
<li>同时为了操作数据的简单性，我们为 <code>SpinLockGuard</code> 实现了 <code>Deref</code> 和 <code>DerefMut</code> 这 2 个 trait。</li>
</ol>
<p>修改一下我们的测试代码，可以发现更加简洁了，同时有编译器的保护，我们想犯错都难了！</p>
<pre><code class="language-rust">#[cfg(test)]
mod tests {
    #[test]
    fn one_thread_should_work() {
        let lock = SpinLock::new(vec![]);  // 临界资源包在锁里面了

        let mut data = lock.lock();
        data.push(1); // 临界代码区
        drop(data); // 主动调用 drop 释放锁。

        let data = lock.lock();
        print!(&quot;{:?}&quot;, *data);
      	// 离开作用域后，这里编译器会自动调用 drop(data) 释放锁。
    }

    #[test]
    fn cross_thread_should_work() {
        let lock = SpinLock::new(vec![]);  // 临界资源包在锁里面了
        thread::scope(|s| {
            s.spawn(|| {
                let mut data1 = lock.lock();
                data1.push(1); // 临界代码区
              	// data1 离开作用域，自动调用 drop(data1)，释放锁。
            });
            sleep(Duration::from_millis(100));
            let mut data2 = lock.lock();
            data2.push(2);
        });

        let data = lock.lock();
        print!(&quot;{:?}&quot;, *data);
    }
}
</code></pre>
<h2>总结</h2>
<p>总结一下，在本实战篇中，我们从最初简单的原子标志锁出发，逐步演进，最终实现了一个拥有 RAII 机制的自旋锁 <code>SpinLock</code>。让我们回顾一下这个自旋锁的特点：</p>
<ul>
<li><strong>忙等待实现：</strong> 使用原子变量和循环实现锁的争用等待，而不涉及线程休眠。这样做在临界区很短时可以省去线程切换的开销，但如果锁持有时间较长，会浪费大量 CPU 时间。因此，本实现适合在<strong>短临界区</strong>或者<strong>无操作系统</strong>环境（如内核/中断上下文）使用。</li>
<li><strong>RAII 保证解锁：</strong> 通过引入 <code>SpinLockGuard</code> 守卫并实现 <code>Drop</code>，我们将解锁操作自动化。开发者无须显式调用解锁函数，避免了因遗忘或异常路径导致的死锁。同时也防止了双重解锁的发生——同一把锁只有一个守卫，Rust 不允许守卫被意外复制或重复释放。</li>
<li><strong>锁与数据绑定：</strong> 自旋锁内部直接持有被保护的数据，并通过类型系统将两者关联。任何对数据的访问都必须经由自旋锁提供的方法，这使“未加锁就访问数据”在语法上变得不可能（否则无法拿到数据的引用）。</li>
<li><strong>编译期并发检查：</strong> 利用 Rust 的所有权和借用规则，我们实现了<strong>一定程度的编译期并发安全检查</strong>。只要代码编译通过，就已经避免了绝大多数常见并发错误（数据竞争、未解锁等）。当然，这不意味着可以高枕无忧，我们仍需注意避免死锁等逻辑问题，但 Rust 会提供最大程度的帮助。</li>
</ul>
<p>同时，我们更进一步地理解原子变量和内存顺序的应用，也结识了一个新的朋友 <code>UnsafeCell</code>，它是 Rust 中同步原语的基础，后面我们还会经常见到。</p>
<p>下篇我们将尝试实现一个非常实用的工具：<code>oneshot-channel</code>（一次性通道），敬请期待！</p>
<p>Happy Coding! Peace~</p>
]]></content:encoded>
    </item>
    <item>
      <title>RAG 技术概览</title>
      <link>https://hedon.top/blog/ai-rag-tech-overview/</link>
      <guid isPermaLink="true">https://hedon.top/blog/ai-rag-tech-overview/</guid>
      <pubDate>Sun, 13 Apr 2025 22:23:22 GMT</pubDate>
      <description>本文是在笔者学习了极客时间《RAG 快速开发实战》课程后，对 RAG 相关技术进行一个梳理归纳，帮助开发者在 RAG 开发中进行快速定位、系统学习。</description>
      <category>AI</category><category>RAG</category>
      <content:encoded><![CDATA[<p>RAG 的整个流程可概括为如下图所示，主要分成<strong>索引</strong>、<strong>检索</strong>和<strong>生成</strong>三个部分。</p>
<p>本文提供配套的完整案例，源码可参考：<a href="https://github.com/hedon-ai-road/rag-demo">hedon-ai-road/rag-demo</a>，每个 commit 都引入了一个新的技术点，感兴趣的读者可根据 commit 记录一一查探。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250413223908403.png" alt=""></p>
<h2>文档解析技术</h2>
<blockquote>
<p><a href="https://github.com/hedon-ai-road/rag-demo/commit/26d52ac964ca099e1726fccb5f4c18adc0fd940f#diff-b10564ab7d2c520cdd0243874879fb0a782862c3c902ab535faabe57d5a505e1">代码示例</a></p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/c60419a3c73f3090651a4c2761e05583.png" alt=""></p>
<h3>PDF</h3>
<ul>
<li>基于规则的开源库<ul>
<li>pyPDF2</li>
<li>PyMuPDF</li>
<li>pdfminer</li>
<li>pdfplumber</li>
<li>papermage</li>
</ul>
</li>
<li>基于深度学习的开源库<ul>
<li>Layout-parser</li>
<li>PP-StructureV2</li>
<li>PDF-Extract-Kit</li>
<li>pix2text</li>
<li>MinerU</li>
<li>marker</li>
<li>Gptpdf（基于 LLM API）</li>
</ul>
</li>
<li>商业闭源库<ul>
<li>Textln.com</li>
<li>Doc2x</li>
<li>mathpix</li>
<li>庖丁 PDFlux</li>
<li>腾讯云文档识别</li>
</ul>
</li>
</ul>
<h2>分块策略</h2>
<blockquote>
<p><a href="https://github.com/hedon-ai-road/rag-demo/commit/a26ae8895da7a9673660b31904857307a149c983">代码示例</a></p>
<p><a href="https://chunkviz.up.railway.app/">分块演示工具</a></p>
</blockquote>
<p>3 个关键部分组成：</p>
<ul>
<li>大小</li>
<li>重叠</li>
<li>拆分</li>
</ul>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/63b052c1b1639bfa66c23342cf28d9ef.jpg" style="zoom: 33%;" />

<h3>固定大小分块（Fixed Size Chunking）</h3>
<p>适用场景：</p>
<ol>
<li>作为分块策略的基准线；</li>
<li>对大型数据集进行初步分析；</li>
<li>实现简单且可观测性高，分块便于管理；</li>
<li>适用于格式和大小相似的同质数据集，如新闻文章或博客文章。</li>
</ol>
<p>问题：</p>
<ol>
<li>不考虑内容上下文，容易导致无意义的文本块；</li>
<li>缺乏灵活性，无法适应文本的自然结构</li>
</ol>
<h3>重叠分块（Overlap Chunking）</h3>
<p>使用场景：</p>
<ol>
<li>需要深入理解语义并保持上下文完整性的文档，如法律文档、技术手册或科研论文；</li>
<li>提升分块内容的连贯性，以提高分析质量。</li>
</ol>
<p>问题：</p>
<ol>
<li>计算复杂度增加，处理效率降低；</li>
<li>冗余信息的存储和管理成为负担。</li>
</ol>
<h3>递归分块（Recursive Chunking）</h3>
<ul>
<li>通过预定义的文本分隔符（如换行符 \n\n、\n、句号、逗号、感叹号、空格等）迭代地将文本分解为更小的块，以实现段大小的均匀性和语义完整性。</li>
<li>先按较大的逻辑单元分割，再逐步递归到较小单元，确保在分块大小限制内保留最强的语义片段。</li>
</ul>
<p>使用场景：</p>
<ol>
<li>需要逐层分析的文本文档或需要分解成长片段、长段落的长文档，如研究报告、法律文档等。</li>
</ol>
<p>问题：</p>
<ol>
<li>在块边界处模糊语义，容易将完整的语义单元切分开。</li>
</ol>
<h3>文档特定分块（Document Specific Chunking）</h3>
<ul>
<li>根据文档的格式（如 Markdown、Latex、或编程语言如 Python 等）进行定制化分割的技术。此方法依据文档的特定格式和结构规则，例如 Markdown 的标题、列表项，或 Python 代码中的函数和类定义等，来确定分块边界。</li>
</ul>
<p>适用场景：</p>
<ol>
<li>有特定的文档结构，如编程语言、Markdown、Latex 等结构文档。</li>
</ol>
<p>问题：</p>
<ol>
<li>格式依赖性强，不同格式之间的分块策略不通用；</li>
<li>无法处理格式不规范及混合多种格式的情况。</li>
</ol>
<h3>语义分块（Semantic Chunking）</h3>
<ul>
<li>基于文本的自然语言边界（如句子、段落或主题中断）进行分段的技术，需要使用 NLP 技术根据语义分词分句，旨在确保每个分块都包含语义连贯的信息单元。</li>
</ul>
<p>适用场景：</p>
<ol>
<li>确保每个文档块的信息完整性且语义连贯；</li>
<li>提高检索结果的相关性和准确性；</li>
<li>适用于复杂文档和上下文敏感的精细化分析。</li>
</ol>
<p>问题：</p>
<ol>
<li>需要额外的高计算资源，特别是在处理大型或动态变化的文档数据时；</li>
<li>处理效率降低。</li>
</ol>
<h3>混合分块（Mix Chunking）</h3>
<ul>
<li>在初始阶段使用固定长度分块快速整理大量文档，而在后续阶段使用语义分块进行更精细的分类和主题提取。根据实际业务场景，设计多种分块策略的混合，能够灵活适应各种需求，提供更强大的分块方案。</li>
</ul>
<p>适用场景：</p>
<ol>
<li>适用于多层次的精细化分块场景；</li>
<li>数据集动态变化，包含多种文档格式与结构；</li>
<li>平衡处理速度与准确性的场景。</li>
</ol>
<p>问题：</p>
<ol>
<li>实现复杂度高；</li>
<li>策略调优难度高；</li>
<li>资源消耗增加。</li>
</ol>
<h2>Embedding 技术</h2>
<ul>
<li>Embedding 嵌入是指将文本、图像、音频、视频等形式的信息映射为高维空间中的密集向量表示。这些向量在语义空间中起到坐标的作用，捕捉对象之间的语义关系和隐含的意义。通过在向量空间中进行计算（例如余弦相似度），可以量化和衡量这些对象之间的语义相似性。</li>
<li>向量检索（Vector Retrieval）是一种基于向量表示的搜索技术，通过计算查询向量与已知文本向量的相似度来识别最相关的文本数据。向量检索的高效性在于，它能在大规模数据集中快速、准确地找到与查询最相关的内容，这得益于向量表示中蕴含的丰富语义信息。</li>
</ul>
<p>评估指标：（MTEB、C-MTEB）</p>
<ol>
<li>特定领域的适用性</li>
<li>检索精度</li>
<li>支持的语言</li>
<li>文本块长度</li>
<li>模型大小</li>
<li>检索效率</li>
</ol>
<h2>向量数据库</h2>
<blockquote>
<p><a href="https://github.com/hedon-ai-road/rag-demo/commit/1f9c07ec6b10d6bfdb55c17c2c6a7ad461b00ab2">代码示例</a></p>
</blockquote>
<ul>
<li>向量数据库是一种专门用于存储和检索多维向量的数据库类型，与传统的基于行列结构的数据库不同，它主要处理高维空间中的数据点。</li>
<li>向量数据库的操作逻辑是基于相似性搜索，即在查询时，应用特定的相似性度量（如余弦相似度、欧几里得距离等）来查找与查询向量最相似的向量。</li>
</ul>
<p>向量数据库的核心在于其高效的索引和搜索机制。为了优化查询性能，它采用了如哈希、量化和基于图形的多种算法。</p>
<ul>
<li>层次化可导航小世界（<strong>HNSW</strong>）：通过在多层结构中将相似向量连接在一起，快速缩小搜索范围。</li>
<li>产品量化（<strong>PQ</strong>）：通过压缩高维向量，减少内存占用并加速检索。</li>
<li>位置敏感哈希（<strong>LSH</strong>）：通过哈希函数将相似向量聚集在一起，便于快速定位。</li>
</ul>
<p>向量数据库的工作流程：</p>
<ol>
<li>数据处理与向量化</li>
<li>向量存储</li>
<li>向量索引</li>
<li>向量搜索<ul>
<li>余弦相似度：主要用于文本处理和信息检索，关注向量之间的角度，以捕捉语义相似性。</li>
<li>欧几里得距离：测量向量之间的实际距离，适用于密集特征集的聚类或分类。</li>
<li>曼哈顿距离：通过计算笛卡尔坐标中的绝对差值之和，适用于稀疏数据的处理。</li>
</ul>
</li>
<li>数据检索</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/c27b4yy6748a414dae0c679835a1eccc.jpg" alt=""></p>
<h2>混合检索</h2>
<blockquote>
<p><a href="https://github.com/hedon-ai-road/rag-demo/commit/38f1739c7ac1d3fc58023cb12bf863d3f502c419">代码示例</a></p>
</blockquote>
<ul>
<li>混合检索（Hybrid Search）通过结合关键词检索和语义匹配的优势，可以首先利用关键词检索精确定位到“订单 12345”的信息，然后通过语义匹配扩展与该订单相关的其他上下文或客户操作的信息，例如“12 开头的订单、包装破损严重”等。这样不仅能够获取精确的订单详情，还能获得与之相关的额外有用信息。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/7eddf214ee696b2e1f5977d72a4db134.jpg" alt=""></p>
<h2>重排序技术</h2>
<blockquote>
<p><a href="https://github.com/hedon-ai-road/rag-demo/commit/4837ef3220d177297169f28fbff5f52eaaee720e">代码示例</a></p>
</blockquote>
<ul>
<li>重排序技术（Reranking）通过对初始检索结果进行重新排序，改善检索结果的相关性，为生成模型提供更优质的上下文，从而提升整体 RAG 系统的效果。</li>
<li>重排序模型大多是基于<strong>双塔</strong>或<strong>交叉编码架构</strong>的模型，在此基础上进一步计算更精确的相关性分数，能够捕捉查询词与文档块之间更细致的相关性，从而在细节层面上提高检索精度。</li>
</ul>
<h2>提示工程</h2>
<p>一个提示（prompt）通常包含以下几个元素：</p>
<ol>
<li><strong>指令（Instruction）</strong>：指明模型要执行的特定任务或操作。</li>
<li><strong>上下文（Context）</strong>：为模型提供额外信息或背景，可以帮助引导模型生成更准确的响应。</li>
<li><strong>输入数据（Input Data）</strong>：我们希望模型回答的问题或感兴趣的输入内容。</li>
<li><strong>输出指示符（Output Indicator）</strong>：指定模型的输出类型或格式，例如格式、是否要生成代码、总结文本或回答具体问题。</li>
</ol>
<p>核心技巧：</p>
<ul>
<li><strong>具体指令法</strong>：具体、细致地告诉大模型要做什么。</li>
<li><strong>示例学习</strong>：给出具体详尽的期望示例。</li>
<li><strong>默认回复策略</strong>：设定默认回复策略，避免模型产生“幻觉”，让它不知道就说不知道。</li>
<li><strong>任务角色设定</strong>：设定身份，可以帮助模型更好地理解任务要求和角色责任，从而输出更加一致、专业的内容。</li>
<li><strong>解释理由法</strong>：向模型解释为什么某些任务需要特定的处理方式，帮助其理解任务背景。</li>
<li><strong>文档基础说明</strong>：提供文档的背景信息和文本来源。</li>
</ul>
<h2>优化技术</h2>
<h3>数据清洗和预处理</h3>
<blockquote>
<p>在 RAG 索引流程中，文档解析之后、文本块切分之前，进行数据清洗和预处理能够有效减少脏数据和噪声，提升文本的整体质量和信息密度。</p>
<p>通过清除冗余信息、统一格式、处理异常字符等手段，数据清洗和预处理过程确保文档更加规范和高质量，从而提高 RAG 系统的检索效果和信息准确性。</p>
</blockquote>
<ul>
<li>处理冗余的模型内容。</li>
<li>消除文档中的额外空白和格式不一致。</li>
<li>去除无用的文档脚注、页眉页脚、版权信息。</li>
</ul>
<h3>查询扩展</h3>
<blockquote>
<p>查询扩展策略通过大模型从原始查询语句生成多个语义相关的查询，可以覆盖向量空间中的不同区域，从而提高检索的全面性和准确性。</p>
</blockquote>
<p>查询扩展的指令模版：</p>
<pre><code class="language-txt">你是一个AI语言模型助手。
你的任务是生成五个不同版本的用户问题，以便从向量数据库中检索相关文档。
通过从多个角度生成用户问题，你的目标是帮助用户克服基于距离的相似性搜索的一些局限性。
请将这些替代问题用换行符分隔。原始问题：{查询原文}
</code></pre>
<p>假设问题：</p>
<pre><code class="language-txt">下面报告中涉及了哪几个行业的案例以及总结各自面临的挑战？
</code></pre>
<p>结果示例：</p>
<pre><code class="language-txt">请问报告中提到的案例涉及了哪些行业？这些行业各自面临的挑战有哪些？
报告中有哪些行业的案例被讨论？每个行业在报告中描述的挑战是什么？
这个报告中具体提到了哪些行业的案例？能否总结一下这些行业当前面临的主要挑战？
该报告中涵盖了哪些行业案例，并对各行业的挑战进行了哪些讨论？
在报告中提到的行业案例有哪些？这些行业分别遇到的主要问题和挑战是什么？
</code></pre>
<p>通过这种查询扩展策略，原始问题被分解为多个子查询，每个子查询独立检索相关文档并生成相应的结果。随后，系统将所有子查询的检索结果进行合并和重新排序，效果会更全面更准确。</p>
<h3>自查询</h3>
<blockquote>
<p>自查询策略通过大语言模型自动提取查询中对业务场景至关重要的元数据字段（如标签、作者 ID、评论数量等关键信息），并将这些信息结合到嵌入检索过程中。</p>
</blockquote>
<p>自查询的指令模版：</p>
<pre><code class="language-txt">你是一个AI语言模型助手。
你的任务是从用户问题中提取关键信息，你的回复应仅包含提取的关键信息。
用户问题：{查询原文}
</code></pre>
<p>假设问题：</p>
<pre><code class="language-txt">下面报告中涉及了哪几个行业的案例以及总结各自面临的挑战？
</code></pre>
<p>结果示例：</p>
<pre><code class="language-txt">行业，案例，挑战
</code></pre>
<h3>提示压缩</h3>
<blockquote>
<p>提示压缩通过精简上下文、过滤掉不相关的信息，确保系统只处理与查询最相关、最重要的内容。</p>
</blockquote>
<p>提示压缩的指令模版：</p>
<pre><code class="language-txt">你是一个AI语言模型助手，负责对检索到的文档进行上下文压缩。
你的目标是从文档中提取与用户查询高度相关的段落，并删除与查询无关或噪声较大的部分。
你应确保保留所有能够直接回答用户查询的问题核心信息。

输入：
用户查询：{用户的原始查询}
检索到的文档：{检索到的文档内容}

输出要求：
提取与用户查询最相关的段落和信息。
删除所有与查询无关的内容，包括噪声、背景信息或扩展讨论。
压缩后的内容应简洁清晰，直指用户的核心问题。

输出格式：
{压缩段落1}
{压缩段落2}
{压缩段落3}
</code></pre>
<h2>RAG 效果评估</h2>
<ol>
<li>大模型打分：通过 LLM 对 RAG 的输出进行自动评分。效率高，但是准确性一般。</li>
<li>人工打分：手工针对 RAG 的输出进行逐一打分。更精确、细致的反馈，成本高。</li>
</ol>
<p>评估指标：</p>
<ol>
<li>**CR（Context Relevancy）**检索相关性：检索到的信息是否偏离了原始查询。</li>
<li>**AR（Answer Relevancy）**答案相关性：是否能解决用户的问题，且内容是否逻辑连贯。</li>
<li>**F（Faithfulness）**可信度：是否存在幻觉或不准确之处。</li>
</ol>
<p>打分标准：</p>
<ol>
<li>完美（Perfect）1.0 分</li>
<li>可接受（Acceptable）0.75 分</li>
<li>缺失（Missing）0.5 分</li>
<li>错误（Incorrect）0.25 分</li>
</ol>
<h2>更高级的 RAG</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/7c3ebbe6ec9d9f2886881d7e534396a0.jpg" alt=""></p>
<h2>GraphRAG</h2>
<p>GraphRAG 通过构建知识图谱，将实体和实体之间的关系结构化地表示出来，克服了传统 RAG 的复杂推理局限性。</p>
<p>有以下优势：</p>
<ol>
<li>提高答案的准确性和完整性</li>
<li>提高数据理解和迭代效率</li>
<li>提升可解释性和可追溯性</li>
</ol>
<p>知识图谱的构建步骤：</p>
<ol>
<li><strong>实体识别</strong>：从文本或数据源中识别出关键实体。</li>
<li><strong>关系抽取</strong>：确定实体之间的关系，可能通过自然语言处理技术实现。</li>
<li><strong>三元组生成</strong>：将实体和关系表示为 (主体，关系，客体) 的形式。</li>
<li><strong>图谱存储</strong>：使用图数据库或专门的存储系统保存知识图谱。</li>
</ol>
<p>知识图谱的主要<strong>成本</strong>挑战：</p>
<ol>
<li>数据收集与清洗成本</li>
<li>知识图谱构建成本</li>
<li>图谱的维护与更新</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>读书笔记丨《Unit Testing Principles, Practices, and Patterns》</title>
      <link>https://hedon.top/blog/note-unit-testing/</link>
      <guid isPermaLink="true">https://hedon.top/blog/note-unit-testing/</guid>
      <pubDate>Wed, 09 Apr 2025 13:19:20 GMT</pubDate>
      <description>本文总结了读者在阅读《Unit Testing》书籍中的收获和思考。</description>
      <category>读书笔记</category><category>单元测试</category>
      <content:encoded><![CDATA[<p>本篇在上一篇的基础上，梳理下笔者的个人见解，感兴趣的读者可参考原文对比阅读：</p>
<p><a href="https://hedon.top/2025/04/09/note/note-unit-testing-excerpt/">https://hedon.top/2025/04/09/note/note-unit-testing-excerpt/</a></p>
<h2>揪心疑惑</h2>
<p>在撰写单元测试的过程中，你是否曾经被以下问题困扰过？</p>
<ol>
<li>为什么要写单元测试？单元测试的目标是什么？</li>
<li>单元测试的粒度是怎样的？什么叫单元？a class, a function, or a behavior, or an observable behavior?</li>
<li>单测覆盖率真的有用吗？有什么用？又有哪些限制？</li>
<li>怎样才能写好单元测试？怎样才能写出性价比最高的单元测试？</li>
<li>如何判断一个单元测试的好坏？有没有具体可供参阅的维度？</li>
<li>哪些代码需要写单元测试，哪些代码没必要写单元测试？</li>
<li>单元测试和集成测试的边界是什么？</li>
<li>（单元丨集成）测试到底是要测什么东西？</li>
<li>单元测试的侧重点是什么？集成测试的侧重点是什么？二者的比例该是怎样的？</li>
<li>如何使用 Mock？哪些东西是需要 Mock 的？哪些东西是不应该 Mock 的？需要 Mock 的东西，应该在哪个层次进行 Mock？（你的 repository 层需要 Mock 吗？）</li>
<li>为什么你的测试代码很脆弱，总是需要频繁修改，维护起来难度很大？</li>
<li>如何减少测试结果的假阳性和假阴性？</li>
</ol>
<h2>四根柱子</h2>
<p>对于第 5 个问题，作者提出了 4 个维度：</p>
<ul>
<li><strong>Protection against regressions：防止回归</strong>，通过自动化验证代码修改后原有功能不受破坏。<ul>
<li><em>The amount of code that is executed during the test.</em></li>
<li><em>The complexity of that code.</em></li>
<li><em>The code’s domain significance.</em></li>
</ul>
</li>
<li><strong>Resistance to refactoring：抗重构性</strong>，重构业务代码时，测试代码无需过多变动便可通过用例，证明重构无误。<ul>
<li><em>Tests provide an early warning when you break existing functionality</em>.</li>
<li><em>You become confident that your code changes won’t lead to regressions</em>.</li>
</ul>
</li>
<li><strong>Fast feedback：快速反馈</strong>。</li>
<li><strong>Maintainability：可维护性</strong>。<ul>
<li><em>How hard it is to understand the test.</em></li>
<li><em>How hard it is to run the test.</em></li>
</ul>
</li>
</ul>
<p>对于这 4 个问题，你是否又有以下疑问：</p>
<ol>
<li>哪个维度是最重要的？</li>
<li>怎样才能写出满足各个维度的测试代码？</li>
<li>如果维度之间存在矛盾，如何 trade off？</li>
</ol>
<h2>为什么要写单元测试？</h2>
<p>三个最重要的原因：</p>
<ol>
<li><p>验证你的程序逻辑正确性。</p>
</li>
<li><p>带来更好的代码设计。</p>
<p>因为单元测试能够让你站在使用者的角度去使用暴露的接口，如果接口不好用，逻辑不好测，测试条件不好构建，大概率说明代码的设计本身是有缺陷的，包括但不限于：抽象不合理、逻辑划分不清晰、与其他模块耦合严重等。</p>
</li>
<li><p>使软件项目更可持续发展。</p>
<p>如果你的需求没有发生变化，那原本能运行通过的单测应该一直都能运行，这有助于避免在团队协作中不小心改坏你不知道的代码，也有助于你执行各种重构措施。</p>
</li>
</ol>
<p>这三个原因的重要性是显而易见的，但笔者个人觉得还有一个更深层次的最重要的原因：</p>
<ul>
<li>你要对你做的事情负责，好的代码一定要先过自己这关。</li>
</ul>
<h2>单元测试的粒度是什么？</h2>
<p>这是一个很有争议的话题，单元测试的「单元」到底是什么？</p>
<ul>
<li>一个类？</li>
<li>一个函数？</li>
<li>还是多个类组成的一个模块？</li>
<li>还是多个函数组成的一个大逻辑？</li>
</ul>
<p>在《Unit Testing》书中，作者指出：<strong>「单元」指的是 an observable behavior，即一个外部系统可观测到的行为</strong>。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2010.png" alt=""></p>
<p>为什么是可观测行为：</p>
<ol>
<li>一个帮助客户端实现目标的操作（operation）。</li>
<li>一个帮助客户端实现目标的状态（state）。</li>
</ol>
<p>换言之，也就是我在撰写单元测试的时候，我就是在使用系统提供的能力，我就是个使用者， 我只要验证你能提供我要的功能，就 OK 了，你背后怎么做，为了这个功能所拆分的类也好，小的辅助函数也好，都不重要，都不属于我要验证的范畴。</p>
<p>所以这更像是黑盒测试（black-box test）。</p>
<p>当然，会有例外，如果你底层有一个特别特别复杂的逻辑，你有必要专门花精力去验证它的逻辑正确性，那是可以针对它撰写专门的白盒测试（white-box test）的。针对这个情况，作者其实也提出了一个观点，对于这个复杂的逻辑，也可以抽成一个单独的模块，由它来提供能力给你当前模块使用。</p>
<p>总结：</p>
<ol>
<li>优先选择黑盒测试。</li>
<li>对于涉及复杂算法的逻辑，单独撰写白盒测试。</li>
<li>结合覆盖率工具去看哪些代码没被覆盖，然后再站在使用者的角度去思考为什么没被覆盖，是这个分支压根没必要存在，还是还有未考虑到的使用场景。</li>
</ol>
<h2>如何组织单元测试？</h2>
<p>两种结构：</p>
<ul>
<li>AAA: Arrange-Act-Assert</li>
<li>GWT: Given-When-Then</li>
</ul>
<p>其实都是一个思路：准备前置条件 → 执行待验证代码 → 验证逻辑正确性。</p>
<p>几个建议：</p>
<ol>
<li>尽量避免一个单元测试中包含多个 AAA/GWT。</li>
<li>避免在单元测试中使用 <code>if</code> 等分支语句。</li>
<li>命名的时候，尽可能让非程序员也能看懂，即这个命名需要描述一个领域问题。</li>
</ol>
<h2>如何发挥单测的最大价值？</h2>
<ol>
<li>单元测试用例必须持续不断反复执行验证。</li>
<li>用最小的维护代价提供最大价值的单元测试。<ol>
<li>识别一个有价值的测试</li>
<li>撰写一个有价值的测试</li>
</ol>
</li>
<li>验证代码中最重要的部分（领域模型）。</li>
</ol>
<h3>1. 单元测试用例必须持续不断反复执行验证</h3>
<p>这里推荐笔者的个人实践：</p>
<ul>
<li>在 <code>pre-commit</code> 执行<strong>增量单元测试</strong>，确保本次修改的代码涉及的单测可正确通过。</li>
<li>在 <code>gitlab-ci/github-action</code> 流程中执行<strong>全量单元测试</strong>，全面覆盖，避免本次修改的代码影响到其他模块的正常功能。同时如果是合并到主分支的请求，加入增量覆盖率阈值检测，不满足阈值的，发送飞书消息卡片进行告警通知。</li>
</ul>
<h4>pre-commit 增量单测</h4>
<p><code>.pre-commit-config.yaml</code> 配置如下：</p>
<pre><code class="language-yml">repos:
  - repo: local
    hooks:
      - id: go-unit-tests
        name: go-unit-tests
        description: run go tests with race detector
        entry: bash -c &#39;./script/run_diff_go_test.sh&#39;
        language: golang
        files: \.*$
        pass_filenames: false
</code></pre>
<p><code>run_diff_go_test.sh</code> 脚本如下：</p>
<pre><code class="language-shell">#!/bin/bash
export GOTOOLCHAIN=auto

# 获取当前改动的 Go 文件
changed_files=$(git diff --name-only --cached --diff-filter=d | grep &#39;\.go$&#39;)

# 如果没有改动的 Go 文件，退出
if [ -z &quot;$changed_files&quot; ]; then
    echo &quot;No Go files changed.&quot;
    exit 0
fi

# 提取改动文件所在的包路径（使用相对路径），并排除 vendor 目录
test_dirs=$(echo &quot;$changed_files&quot; | xargs -n1 dirname | grep -v &#39;^vendor&#39; | sort -u)

# 对每个改动的包路径运行 go test
for dir in $test_dirs; do
    # 检查目录是否存在
    if [ ! -d &quot;$dir&quot; ]; then
        echo &quot;Directory $dir does not exist. Skipping...&quot;
        continue
    fi

    # 检查是否存在 go.mod 文件，确保在 Go 模块路径中
    if [ -f &quot;$dir/go.mod&quot; ] || [ -f &quot;./go.mod&quot; ]; then
        echo &quot;Running tests in $dir...&quot;
        (cd &quot;$dir&quot; &amp;&amp; go test -mod=vendor -gcflags=all=-l -short ./...)
        if [ $? -ne 0 ]; then
            echo &quot;Tests failed in $dir&quot;
            exit 1
        fi
    else
        echo &quot;Skipping $dir (no go.mod found)&quot;
    fi
done

echo &quot;All tests passed.&quot;
</code></pre>
<h4>gitlab-ci 全量单测</h4>
<p><code>gitlab-ci.yml</code> 配置如下：</p>
<pre><code class="language-yml">go-unit-test:
  stage: go-unit-test
  script:
    - sh script/unittest.sh &quot;$CI_MERGE_REQUEST_TITLE&quot; &quot;$GITLAB_USER_EMAIL&quot; &quot;$CI_PIPELINE_ID&quot; &quot;$CI_MERGE_REQUEST_TARGET_BRANCH_NAME&quot; &quot;$CI_JOB_ID&quot;
  rules:
    - if: $CI_PIPELINE_SOURCE == &quot;merge_request_event&quot; &amp;&amp; $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == &#39;dev&#39;
    - if: $CI_PIPELINE_SOURCE == &quot;merge_request_event&quot; &amp;&amp; $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == &#39;release&#39;
  coverage: &#39;/coverage: \d+.\d+% of statements/&#39;
</code></pre>
<p><code>unittest.sh</code> 单测执行脚本如下：</p>
<pre><code class="language-shell">#!/bin/bash
export GOPROXY=&quot;https://goproxy.cn,direct&quot;

function generate_coverage_report {
  gocover-cobertura &lt; coverage.out &gt; coverage.xml
}

# 使用 gotestsum 执行单元测试
# 如果单测执行失败，会发送飞书消息卡片到告警群中
if ! gotestsum --junitfile report.xml --post-run-command=&quot;./script/send_fs_card.sh \&quot;$1\&quot; \&quot;$2\&quot; \&quot;$3\&quot; \&quot;$5\&quot;&quot;  -- ./... -timeout 3s -short -mod=vendor -gcflags=all=-l -coverpkg=./... -coverprofile=coverage.out ; then
  generate_coverage_report
  sh script/cal_diff_coverage.sh &quot;$4&quot;
  exit 1
fi

# 生成单元测试覆盖率报告
generate_coverage_report
# 如果增量覆盖率不满足阈值，会发送飞书消息卡片到告警群中
source script/cal_diff_coverage.sh &quot;$4&quot;

if [[ &quot;$4&quot; == &quot;release&quot; ]]; then
  source script/check_test_coverage.sh &quot;$1&quot; &quot;$2&quot; &quot;$3&quot;
fi
</code></pre>
<p>其中 <code>cal_diff_coverage.sh</code> 用于计算增量覆盖率：</p>
<pre><code class="language-shell">#!/bin/bash

export COVERAGE_PERCENT=0.0

# 确保 coverage.xml 文件存在
if [ ! -f coverage.xml ]; then
  echo &quot;coverage: 0.0% of statements&quot;
  exit 0
fi

# 使用 diff-cover 生成覆盖率报告
diff-cover coverage.xml --exclude **/docs.go --html-report report.html --compare-branch &quot;$1&quot; &gt; diff_detail.txt

# 检查是否成功生成报告
if [ ! -f report.html ]; then
  echo &quot;coverage: 0.0% of statements&quot;
  exit 0
fi

# 使用 grep 和 awk 提取覆盖率信息
COVERAGE=$(grep &quot;Coverage:&quot; diff_detail.txt | awk &#39;{print $2}&#39;)

# 如果找到了覆盖率数据，检查是否包含小数点
if [ -n &quot;$COVERAGE&quot; ]; then
  if [[ &quot;$COVERAGE&quot; != *&quot;.&quot;* ]]; then
    # 如果没有小数点，在百分号前面加上 &#39;.0&#39;
    # 这里这么做的目的是不知道为什么 gitlab ci 无法正确解析下面这个正则
    # /coverage: \d+(.\d+)?% of statements/
    # 只能解析这个
    # /coverage: \d+.\d+% of statements/
    COVERAGE=&quot;${COVERAGE/\%/.0%}&quot;
  fi
  echo &quot;coverage: $COVERAGE of statements&quot;
else
  COVERAGE=&quot;0.0%&quot;
  echo &quot;coverage: 0.0% of statements&quot;
fi

# 将 COVERAGE 的百分号去掉，只保留数字
export COVERAGE_PERCENT=$(echo &quot;$COVERAGE&quot; | sed &#39;s/%//&#39;)
</code></pre>
<h3>2. 用最小的维护代价提供最大价值的单元测试</h3>
<p>如何评价一个单元测试价值是否足够大呢？或者，更简单的说法是，如何评价一个单元测试写得好不好？</p>
<p>可以从 4 个角度进行评估：</p>
<ol>
<li>protection against regressions</li>
<li>resistance to refactoring</li>
<li>fast feedback</li>
<li>maintainability</li>
</ol>
<p>更具体地说：</p>
<h4>2.1 回归保护</h4>
<blockquote>
<p>防止你改坏代码。换言之，这个测试能帮你发现多少 Bug？</p>
</blockquote>
<p>评价指标：</p>
<ol>
<li>被测试代码执行到的业务代码数量（测试覆盖率）。</li>
<li>业务代码的复杂度。</li>
<li>业务代码的领域重要性。</li>
</ol>
<h4>2.2 抵抗重构</h4>
<blockquote>
<p>非功能性重构，测试仍能通过，确保功能一致性。</p>
</blockquote>
<p>评价指标：</p>
<ol>
<li>越少的“假阳性”越好。</li>
<li>在重构代码时，引入了破坏性变更，测试代码能否快速反馈，即越少的“假阴性”越好。</li>
<li>测试代码是否为你重构代码提供了足够的信心。</li>
<li>测试代码测试的是业务代码的 observable behavior，而不是其背后的每一个步骤。</li>
</ol>
<h4>2.3 快速反馈</h4>
<blockquote>
<p>测试代码执行时间越快，则反馈间隔越短，缺陷修复效率和质量就越高。</p>
</blockquote>
<p>评价指标：</p>
<ol>
<li>代码执行速度</li>
</ol>
<h4>2.4 可维护性</h4>
<blockquote>
<p>测试代码的修改成本，可维护的测试代码更有利于适应需求变更。</p>
</blockquote>
<p>评价指标：</p>
<ol>
<li>测试代码有多难理解？</li>
<li>测试代码的代码行数有多少？</li>
<li>测试代码的执行难度有多高？即有多少的外部依赖？</li>
</ol>
<h4>2.5 如何权衡</h4>
<p>单元测试的价值可以通过上述 4 个指标的<strong>乘积</strong>来进行估算，但现实是，这 4 者，往往无法兼得。那我们如何做权衡呢？</p>
<p>首先回顾「为什么要写单元测试」，核心目的是为了<strong>程序逻辑正确性、使软件项目更可持续发展</strong>。所以：</p>
<ul>
<li>**可维护性（maintainability）**是不可商量的，必须要撰写可维护的测试代码。</li>
<li>**抵抗重构（resistance to refactoring）**是不可商量的，我们的测试代码应尽可能对错误的逻辑进行告警，也应避免对正确的逻辑进行误告警。</li>
</ul>
<p>所以我们能权衡的其实就是 protection againts regressions 和 fast feedback，二者的矛盾很清晰：</p>
<ol>
<li>如果执行的代码越多，相应的效率就越低。</li>
<li>如果执行的代码太少，那验证的逻辑范围就越小。</li>
</ol>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%206.png" style="zoom:33%;" />

<p>为了权衡这二者，业界提出了“测试金字塔”的概念。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%207.png" style="zoom:33%;" />

<ol>
<li>单元测试的单位更小，涉及的外部依赖也更少，更加 <strong>fast feedback</strong>，所以在这个层次我们要撰写更多的测试，去尽可能覆盖更多的单元逻辑。</li>
<li>集成测试、端到端测试的逻辑覆盖范围更大，更加 <strong>resistance to refactoring</strong>，但是往往会依赖更多的组件，执行的效率也更低，所以在这 2 个层次，我们可以只撰写覆盖最重要（乐观）的业务路径的测试代码，在牺牲有限的执行效率的情况下，尝试更大的防止回归效果。</li>
</ol>
<h3>3. 验证代码中最重要的部分</h3>
<p>什么是代码中最重要的部分呢？我们可以将代码分成以下 4 个种类：</p>
<ol>
<li><strong>领域模型和算法（Domain Model and Algorithms）</strong>：领域模型是对业务领域核心概念和逻辑的抽象，算法则是解决特定问题的计算步骤。两者共同构成系统的核心业务逻辑。</li>
<li><strong>琐碎代码（Trivial Code）</strong>：实现简单功能、无复杂逻辑的代码片段，通常为工具方法或数据转换层。</li>
<li><strong>控制器（Controllers）</strong>：协调业务逻辑与外部交互的中间层，常见于 MVC 或分层架构中。</li>
<li><strong>过度复杂代码（Overcomplicated Code）</strong>：既包含核心业务逻辑，又包含控制器逻辑。</li>
</ol>
<p>作者建议：</p>
<ol>
<li>永远为 <strong>Domain Model and Algorithms</strong> 撰写全面细致的单元测试。</li>
<li>永远不为 <strong>Trivial Code</strong> 撰写单元测试。</li>
<li>为 <strong>Controllers</strong> 撰写集成测试，而不是单元测试。</li>
<li>避免写 <strong>Overcomplicated Code</strong>，将其拆分成 <strong>Domain Model and Algorithms</strong> 和 <strong>Controllers</strong> 。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2015.png" alt=""></p>
<h2>如何让代码更容易测试？</h2>
<p>根据不同处理架构的业务代码，可以将测试代码分成以下 3 个种类：</p>
<ol>
<li><code>output-based</code>：业务代码只产生输出结果，所以只需要<strong>验证输出</strong>。</li>
<li><code>state-based</code>：业务代码会修改内部状态或依赖状态，所以需要<strong>验证状态变化</strong>。</li>
<li><code>communication-based</code>：业务代码会跟协作方进行交互，所以需要<strong>验证交互情况</strong>。对于这种场景，我们会使用 <code>mock</code> 工具来进行验证。关于 <code>mock</code> 这个话题，文章后续会进行详细讨论。</li>
</ol>
<p>我们按照上述 4 个分析维度，对这 3 种测试代码进行比较：</p>
<table>
<thead>
<tr>
<th></th>
<th>protection againts regressions</th>
<th>resistance to refactoring</th>
<th>fast feedback</th>
<th>maintainability</th>
</tr>
</thead>
<tbody><tr>
<td><strong>output-based</strong></td>
<td>⭐️⭐️⭐️</td>
<td>⭐️⭐️⭐️</td>
<td>⭐️⭐️⭐️</td>
<td>⭐️⭐️⭐️ 最好，不需要外部依赖。</td>
</tr>
<tr>
<td><strong>state-based</strong></td>
<td>⭐️⭐️⭐️</td>
<td>⭐️⭐️</td>
<td>⭐️⭐️⭐️</td>
<td>⭐️⭐️ 比较差，需要外部依赖。</td>
</tr>
<tr>
<td><strong>communication-based</strong></td>
<td>⭐️⭐️ 过度使用会导致需要到处 mock，而真正执行的业务代码数量很少。</td>
<td>⭐️ 最差，因为验证交互情况，往往会陷入实现细节，很容易在重构过程中出现误警告。</td>
<td>⭐️⭐️ 大差不差，但是 mock 工具效率可能会相对低一点点。</td>
<td>⭐️ 最差，需要引入大量的 mock 工具和 mock 代码。</td>
</tr>
</tbody></table>
<p>所以我们应该尽可能写 <strong>output-based</strong> 测试，减少 <strong>communication-based</strong> 测试。</p>
<p>可以采取 <code>functional architecture</code>，将代码分成 2 个阶段：</p>
<ol>
<li>根据业务规则做出决定</li>
<li>根据决定做出行为</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2014.png" alt=""></p>
<p>为此，在可能的场景下，我们可以尝试通过 2 个步骤来优化我们的测试代码：</p>
<ol>
<li>使用 <code>mock</code> 来替代外部依赖 <code>out-of-process dependency</code>。</li>
<li>使用 <code>functional architecture</code> 来替代 <code>mock</code>。</li>
</ol>
<h2>聊一下 Mock</h2>
<p>在撰写单元测试的过程中，如果业务逻辑依赖的组件不好实例化的时候，我们常常会借助各种 <code>Mock</code> 工具来实现“模拟”功能，使单测更易撰写，这里有一个更准确的词叫 <code>test doubles</code>（测试替身）。</p>
<h3>test double 的种类</h3>
<p>从大的方面可以分为 2 种：</p>
<ol>
<li>用于模拟和验证对象间的输出交互（如方法调用次数、参数匹配），则为 <code>mock</code>。</li>
<li>用于模拟输入交互，提供预定义的数据，则为 <code>stub</code>。</li>
</ol>
<p>更进一步可以分为：</p>
<ul>
<li><code>mock</code><ul>
<li><code>mock</code>: 由 mock 工具生成。</li>
<li><code>spy</code>: 手工撰写。</li>
</ul>
</li>
<li><code>stub</code><ul>
<li><code>stub</code>: 可以通过配置在不同的场景下返回不同的数据。</li>
<li><code>dummy</code>: 占位符，仅用于填充参数，不参与实际逻辑。</li>
<li><code>fake</code>: 跟 <code>stub</code> 几乎一样，唯一的区别是 <code>fake</code> 经常用于替代尚未开发或复杂的依赖。</li>
</ul>
</li>
</ul>
<blockquote>
<p>需要注意的是：永远不要去验证（assert）跟 <code>stub</code> 的交互，没必要！</p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%208.png" alt=""></p>
<h3>哪些东西需要 Mock？</h3>
<p>在回答这个问题之前，我们先做下铺垫，聊一下接口的误解、依赖的种类和两种交互的概念。</p>
<h4>接口的误解</h4>
<p>在谈如何更好地利用 mock 之前，我们先来聊一下接口（interface）的误解。</p>
<p>在业务开发当中，我们经常能看到一些企图进行“优雅”架构设计的代码，上来每一层都定义接口，每一层都使用接口进行交互，反正遇到问题先定义接口再说。</p>
<p>目的有二：</p>
<ol>
<li>抽象外部依赖，进行解耦。</li>
<li>可以在不修改既有代码的情况下扩展功能，即所谓的开闭原则（Open-Closed principle）。</li>
</ol>
<p>但这其实存在一些误区，作者在书中指出：</p>
<ol>
<li><strong>只有一个实现的接口</strong>，并不是抽象，也并没有比具体的对象起到太多所谓的解耦作用。</li>
<li>上述第 2 点违反了一个更重要的原则 <strong>YAGNI（You are not gonna need it）</strong>，也就是你所谓的功能扩展大概率是不需要的。</li>
<li>上述做法的唯一好处是什么：**使测试成为可能！**因为你不隔离掉外部依赖的话，你的单元测试撰写会非常困难，也无法做到 fast feedback。</li>
</ol>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--success" aria-hidden="true"></span></p>
<p>🙋🏻‍♀️ 抽象是发现出来的，而不是发明出来的！</p>
</blockquote>
<h4>依赖的种类</h4>
<ul>
<li><code>shared dependency</code>: 一个在测试代码中的共享对象。</li>
<li><code>out-of-process dependency</code>: 独立于当前应用程序的另外一个进程对象，如数据库、STMP 服务器等。<ul>
<li><code>managed dependency</code>: 仅当前应用程序可访问的依赖（对其他程序、服务是不可见的）。</li>
<li><code>unmanaged dependency</code>: 除了当前应用，其他应用也可见。</li>
</ul>
</li>
<li><code>private dependency</code>: 一个私有对象。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2019.png" alt=""></p>
<h4>两种交互</h4>
<ul>
<li><code>intra-system communication</code>: 应用程序内部的交互。</li>
<li><code>inter-system communication</code>: 应用程序之间的交互。</li>
</ul>
<h4>哪些东西需要 Mock？</h4>
<p>铺垫完接口的误解、依赖的种类和两种交互的概念之后，我们来聊一下哪些东西需要 Mock？</p>
<p>在抉择的时候，需要牢记我们测试粒度和评价指标。</p>
<p>测试粒度：<strong>an observable behavior ⭐️⭐️⭐️⭐️⭐️</strong></p>
<p>评价指标：</p>
<ul>
<li>防止回归：protection against regressions</li>
<li>抵抗重构：resistance to refactoring</li>
<li>快速反馈：fast feedback</li>
<li>可维护性：maintainability</li>
</ul>
<p>集合测试粒度和评价指标，Mock 哪些东西可以用一句话来概括：</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--success" aria-hidden="true"></span></p>
<p>✅ Mock 那些外部可观测到的交互，而尽量避免 Mock 内部的实现细节。</p>
</blockquote>
<p>更具体来说：</p>
<ol>
<li>**仅对 <code>unmanaged dependency</code> 应用 <code>mock</code> 对象。**因为我们无法预知其他应用会对这些依赖进行什么操作，所以只能隔离开。</li>
<li>**对系统最外围的边界进行 <code>mock</code>。**只有系统边界，才是可观测行为，内部都是实现细节，对实现细节过多 Mock，意味着破坏了 resistance to refactoring。</li>
<li><strong>尽量只在集成测试中使用 mock，避免在单元测试中使用 mock。</strong></li>
<li><strong>只 mock 属于你的对象，不去 mock 依赖库中的对象。</strong><ul>
<li>始终在第三方库之上编写自己的适配器，并对这些适配器进行 mock，而不是 mock 底层类型。</li>
<li>仅从库中暴露你所需要的功能。</li>
<li>使用项目的领域语言（domain language）来完成上述操作。</li>
</ul>
</li>
</ol>
<p>举个例子：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2021.png" alt=""></p>
<p>在上图中，右侧是我们的应用程序，它依赖了左下角的 <code>Message bus</code> 这个外部依赖，准确说是 <code>unmanaged dependency</code>。对此，我们为其创建了适配器接口 <code>IBus</code>，在这个通用接口之上，我们又根据具体业务创建了 <code>IMessageBus</code>。</p>
<p>针对这种情况，我们在进行 mock 的时候，只需要 mock <code>IBus</code> 对象，而不是去 mock <code>Message bus</code> 和 <code>IMessageBus</code>。</p>
<h3>数据库要不要 Mock？</h3>
<p>这个话题比较有意思，作者的建议是：</p>
<ol>
<li>如果这个数据库只有你这个应用可以访问，那就不要 mock。</li>
<li>如果这个数据库存在可以被其他应用访问的部分，那就只 mock 这一部分，不去 mock 独属于你应用的那部分。</li>
</ol>
<p>要践行上述标准，需要做到以下前提：</p>
<ol>
<li>将数据库的信息也放在源码控制系统中（git），包括：<ul>
<li>schema</li>
<li>reference data（项目启动必须要的初始数据）</li>
<li>migration（数据变更记录）</li>
</ul>
</li>
<li>每个开发者有一个单独的数据库（测试环境下）</li>
<li>但数据库变更的时候，不要直接修改，而是要写一条对应 sql 去进行修改，同时将这条 sql 也纳入源码控制系统中。</li>
</ol>
<p>作者不建议 <code>mock</code> 数据库，包括使用内存数据库替代，如 sqlite 替代 MySQL，核心原因是：你无法保证这些数据库能跟线上环境的行为一致，可能会导致一些无效测试用例，即假阴性。</p>
<p>笔者并不完全采纳这个建议，诚然，如果能做到以上前提，是可以考虑践行的。然而，它的要求很高，收益却相对较小，在单元测试环境下，使用内存数据库进行 mock，在保证了 fast feedback 和 maintainability 的情况下，也能够避免绝大多数的逻辑漏洞了，假阴性的情况会非常少，即便有，也可以交给集成测试和端到端测试去解决。</p>
<h2>反面案例</h2>
<ol>
<li>测试私有方法。</li>
<li>暴露私有状态。</li>
<li>泄露领域知识到测试中。</li>
<li>在业务代码中撰写只用于测试的代码。</li>
<li>mock 具体的类。</li>
</ol>
<p>第 3 点比较有意思，比如下面这个例子：</p>
<pre><code class="language-c#">public class CalculatorTests
{
    [Fact]
    public void Adding_two_numbers()
    {
        int value1 = 1;
        int value2 = 3;
        int expected = value1 + value2;   // &lt;-----The leakage
        // int expected = 4 // the better one
        int actual = Calculator.Add(value1, value2);
        Assert.Equal(expected, actual);
    }
}
</code></pre>
<p>什么叫做泄露领域知识呢？</p>
<p>比如你要验证一个加法 <code>Add</code> 对不对，但是在测试代码中，你的期望值也是用加法来获得的，这个“加法”就是领域知识，因为这样测的话，就很有可能会出现“<strong>负负得正</strong>”的情况。</p>
<p>正确的做法是<strong>直接断言你预期的最终结果</strong>，以确保逻辑符合预期。</p>
<h2>Go 实践案例</h2>
<p>本章将分享一些笔者在 Go 项目实战过程中的一些实践案例，希望对读者撰写单元测试能提供一些帮助。</p>
<h3>依赖 Redis 的逻辑怎么测</h3>
<p>可以使用 <code>miniredis</code>，这是一个使用 Go 语言实现的内存版 Redis。</p>
<p><a href="https://github.com/alicebob/miniredis">https://github.com/alicebob/miniredis</a></p>
<p>可以封装一个函数，用于快速启动 miniredis 并返回客户端对象：</p>
<pre><code class="language-go">func NewMiniRedis() *redis.Client {
    var redisClient *redis.Client
    var miniRedisClient *miniredis.Miniredis
    var err error
    miniRedisClient, err = miniredis.Run()
    if err != nil {
       panic(err)
    }
    redisClient = redis.NewClient(&amp;redis.Options{
       Addr: miniRedisClient.Addr(),
    })
    return redisClient
}
</code></pre>
<blockquote>
<p>这里可能会出现作者提到的不要使用内存数据库替代真实的数据库，因为你无法保证它们的行为一致。</p>
<p>比如这里是单机的，而生产环境可能是集群的，在 Redis Cluster 中，涉及到 lua 脚本和事务的所有 key，都必须保证在同一个 slot 上，在这种情况下，使用 <code>miniredis</code> 是测不出问题的。</p>
</blockquote>
<h3>依赖 MySQL 的逻辑怎么测</h3>
<p>核心挑战：</p>
<ol>
<li>依赖真实 MySQL 则容易因为网络原因而导致测试失败（不可重复性）</li>
<li>依赖真实 MySQL 会严重影响单侧执行效率</li>
<li>数据预备</li>
<li>数据清洗</li>
<li>单测之间的数据隔离，互不影响</li>
<li>并发安全</li>
</ol>
<p>为了解决上述问题，提供更优雅的 MySQL 单测解决方案，笔者借助 <code>dolthub/go-mysql-server</code> 和 <code>gorm</code> 的能力，实现了一个 <code>go-mysql-mocker</code>，简称 <code>gmm</code>。</p>
<p>其中：</p>
<ul>
<li><code>dolthub/go-mysql-server</code> 提供了内存 MySQL 引擎。</li>
<li><code>gorm</code> 提供了快速建表和插入数据的能力。</li>
</ul>
<p><a href="https://github.com/hedon954/go-mysql-mocker">https://github.com/hedon954/go-mysql-mocker</a></p>
<p>核心功能：</p>
<ol>
<li>内存版数据库，无网络依赖；</li>
<li>每个单测可单独启动一个数据库，天然做到数据隔离和清洗；</li>
<li>支持 struct、slice、sql stmt、sql file 多种方式进行数据初始化，支持需要前置数据的业务逻辑测试。</li>
</ol>
<h3>随机概率逻辑怎么测</h3>
<p>场景：随机抽奖</p>
<p>难点：随机概率的结果是不确定的，直接通过 <code>assert.Equal</code> 是无法写出可稳定重复运行的单测的。</p>
<pre><code class="language-go">// AssertMapRatioEqual 检查实际计数的比例是否符合预期权重的比例
// actual: 实际获得的计数 map[id]count
// expected: 预期的权重 map[id]weight
// tolerance: 允许的误差范围（如 0.05 表示允许 5% 的误差）
func AssertMapRatioEqual(t *testing.T, actual map[int64]int64, expected map[int64]int64, tolerance float64) {
    t.Helper()

    // 计算总数
    var actualTotal, expectedTotal int64
    for _, count := range actual {
        actualTotal += count
    }
    for _, weight := range expected {
        expectedTotal += weight
    }

    // 检查每个 ID 的比例
    for id, expectedWeight := range expected {
        actualCount, exists := actual[id]
        if !exists {
            t.Errorf(&quot;ID %d 在实际结果中不存在&quot;, id)
            continue
        }

        expectedRatio := float64(expectedWeight) / float64(expectedTotal)
        actualRatio := float64(actualCount) / float64(actualTotal)

        if diff := math.Abs(expectedRatio - actualRatio); diff &gt; tolerance {
            t.Errorf(&quot;ID %d 的比例不符合预期: 期望 %.3f, 实际 %.3f, 差异 %.3f, 超出允许误差 %.3f&quot;,
                id, expectedRatio, actualRatio, diff, tolerance)
        }
    }

    // 检查是否有多余的 ID
    for id := range actual {
        if _, exists := expected[id]; !exists {
            t.Errorf(&quot;实际结果中存在未预期的 ID: %d&quot;, id)
        }
    }
}
</code></pre>
<p>案例：</p>
<pre><code class="language-go">func TestLottery_randOnce(t *testing.T) {
    t.Run(&quot;大量抽取应符合权重配置比例&quot;, func(t *testing.T) {
        gotCount := make(map[int64]int64)
        totalCount := int64(10000)
        for i := int64(0); i &lt; totalCount; i++ {
            reward, err := lotteryOnce(0, 0, nil)
            assert.Nil(t, err)
            assert.NotNil(t, reward)
            gotCount[reward.Id] += 1
        }

        expectedRatio := map[int64]int64{
            1: 10,
            2: 20,
            3: 30,
            4: 40,
            5: 40,
        }
        testutil.AssertMapRatioEqual(t, gotCount, expectedRatio, 0.05)
    })
}
</code></pre>
<h3>HTTP 接口怎么测</h3>
<p>挑战：</p>
<ol>
<li>如何快速构建请求体并发送请求？</li>
<li>如何快速断言异常情况？</li>
<li>如何快速断言成功情况，并解析出期望的返回值？</li>
</ol>
<h4>1. 构造请求</h4>
<pre><code class="language-go">// 快速创建请求体 form 表单格式
func NewHTTPPostRequest(path string, data any) *http.Request {
    req := httptest.NewRequest(&quot;POST&quot;, path, NewHTTPBody(data))
    req.Header.Set(&quot;Content-Type&quot;, &quot;application/x-www-form-urlencoded&quot;)
    return req
}

func NewHTTPBody(data any) io.Reader {
    values := url.Values{}
    v := reflect.ValueOf(data)
    t := v.Type()

    if v.Kind() == reflect.Ptr {
        v = v.Elem()
        t = v.Type()
    }

    if v.Kind() != reflect.Struct {
        return strings.NewReader(values.Encode())
    }

    for i := 0; i &lt; t.NumField(); i++ {
        field := t.Field(i)
        value := v.Field(i)

        tag := field.Tag.Get(&quot;form&quot;)
        if tag == &quot;&quot; {
            continue
        }

        // 处理复杂类型（结构体、切片、map）
        switch value.Kind() {
        case reflect.Struct, reflect.Slice, reflect.Map:
            jsonBytes, err := json.Marshal(value.Interface())
            if err == nil {
                values.Set(tag, string(jsonBytes))
                continue
            } else {
                log.Printf(&quot;Error marshaling %v: %v&quot;, value.Kind(), err)
            }
        default:
            // 处理其他类型
            values.Set(tag, fmt.Sprintf(&quot;%v&quot;, value.Interface()))
        }
    }

    return strings.NewReader(values.Encode())
}
</code></pre>
<h4>2. 发送请求</h4>
<pre><code class="language-go">w := httptest.NewRecorder()
sfRouterTest.ServeHTTP(w, request)
</code></pre>
<h4>3. 断言异常</h4>
<pre><code class="language-go">// AssertRspErr 断言 http 响应异常，expectedErr 为期望的错误信息
func AssertRspErr(w *httptest.ResponseRecorder, t *testing.T, expectedErr string) {
    assert.Equal(t, http.StatusOK, w.Code)
    body := w.Result().Body
    defer body.Close()
    rsp, err := FromHTTPResp[any](body)
    assert.Nil(t, rsp)
    assert.Equal(t, expectedErr, err.Error())
}
</code></pre>
<h4>4. 断言正确且返回响应值</h4>
<pre><code class="language-go">// FromHTTPResp 从 http 响应中解析出数据
func FromHTTPResp[T any](resp io.ReadCloser) (*T, error) {
    body, err := io.ReadAll(resp)
    if err != nil {
        return nil, err
    }
    defer func() { _ = resp.Close() }()

    var t httpResp[T]
    err = json.Unmarshal(body, &amp;t)
    if err != nil {
        return nil, err
    }
    if t.Code != 200 {
        return nil, errors.New(t.Message)
    }
    return &amp;t.Data, nil
}

// AssertRspOk 断言 http 响应成功，并返回响应体 T
func AssertRspOk[T any](w *httptest.ResponseRecorder, t *testing.T) *T {
    assert.Equal(t, http.StatusOK, w.Code)
    body := w.Result().Body
    defer body.Close()
    rsp, err := FromHTTPResp[T](body)
    assert.Nil(t, err)
    assert.NotNil(t, rsp)
    return rsp
}
</code></pre>
<blockquote>
<p>这里其实就违反了上一张反面案例中的第 3 点”泄露领域知识到测试中“，因为这里接受响应的时候，还是使用的领域对象结构，所以可能会出现负负得正的情况，比如你的对象字段名就是拼写错误了，但是因为你业务逻辑和断言处都是用的一个结构，所以内部形成了循环，就负负得正了，但是真正到了客户端那，就解析失败了。</p>
<p>不过在这个情况下，笔者认为这个情况下的这种风险是可以接受的，远盖不住其带来的效率提升。</p>
</blockquote>
<h4>5. 组合起来</h4>
<pre><code class="language-go">func SendHTTPRequest[Rsp any](t *testing.T, server HTTPServer, path string, data any, errMsg ...string) *Rsp {
    req := NewHTTPPostRequest(path, data)
    w := httptest.NewRecorder()
    server.ServeHTTP(w, req)
    if len(errMsg) &gt; 0 {
        AssertRspErr(w, t, errMsg[0])
        return nil
    } else {
        return AssertRspOk[Rsp](w, t)
    }
}
</code></pre>
<h4>6. 案例</h4>
<pre><code class="language-go">func Test_GetCollectReward(t *testing.T) {
    t.Run(&quot;重复领取&quot;, func(t *testing.T) {
        uid := buildUserInfo(&amp;UserInfo{
            Got: map[int][]int{
                1: {1},
            },
        }, apiTest.svc)
        _ = testutil.SendHTTPRequest[GetCollectRewardResp](t, routerTest,
            &quot;/get_collect_reward&quot;, &amp;GetCollectRewardReq{
                UID:       uid,
                CollectID: 1,
            }, &quot;重复领取&quot;) // 错误信息
    })

    t.Run(&quot;领取成功&quot;, func(t *testing.T) {
      	uid := uuid.NewString()
        rsp := testutil.SendHTTPRequest[GetCollectRewardResp](t, routerTest,
            &quot;/get_collect_reward&quot;, &amp;GetCollectRewardReq{
                UID:       uid,
                CollectID: 1,
            },
        )
        assert.NotNil(t, rsp.Reward) // 正确结果
    })
}
</code></pre>
<h3>依赖时间的逻辑怎么测</h3>
<ol>
<li>尽量不要依赖时间。</li>
<li>考虑将时间作为参数，避免 <code>time.Now()</code>。</li>
</ol>
<p>也可以参考：</p>
<p><a href="https://hedon.top/2025/03/06/go/go-lib-synctest/">https://hedon.top/2025/03/06/go/go-lib-synctest/</a></p>
<h3>并发逻辑怎么测</h3>
<ul>
<li><code>go test</code> 推荐开启 <code>-race</code> 用于检测并发冲突。</li>
</ul>
<p>更多可参考：</p>
<p><a href="https://hedon.top/2025/03/06/go/go-lib-synctest/">https://hedon.top/2025/03/06/go/go-lib-synctest/</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>书籍摘抄丨《Unit Testing Principles, Practices, and Patterns》</title>
      <link>https://hedon.top/blog/note-unit-testing-excerpt/</link>
      <guid isPermaLink="true">https://hedon.top/blog/note-unit-testing-excerpt/</guid>
      <pubDate>Wed, 09 Apr 2025 12:52:14 GMT</pubDate>
      <description>本文对《Unit Testing》的关键观点进行了梳理总结。</description>
      <category>书籍摘抄</category><category>单元测试</category>
      <content:encoded><![CDATA[<p>Learning unit testing doesn’t stop at mastering the technical bits of it, such as your favorite test framework, mocking library, and so on. There’s much more to unit testing than the act of writing tests. You always have to achieve the best return on the time you invest in unit testing, minimizing the effort you put into tests and maximizing the benefits they provide. Achieving both things isn’t an easy task.</p>
<ul>
<li>They grow effortlessly, don’t require much maintenance, and can quickly adapt to their customers’ ever-changing needs.</li>
</ul>
<p>The ratio between the production code and the test code could be anywhere between <code>1:1</code> and <code>1:3</code>.</p>
<h2>Coverage limitations</h2>
<ul>
<li>You can’t guarantee that the test verifies all the possible outcomes of the system under test.</li>
<li>No coverage metric can take into account code paths in external libraries.</li>
</ul>
<h2>The goal of unit testing</h2>
<ol>
<li>lead to a better code design</li>
<li>enable sustainable(可持续的) growth of the software project</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image.png" alt=""></p>
<p>the cost components of writing unit tests:</p>
<ol>
<li>refactoring the test when you refactor the underlying code</li>
<li>running the test on each code change</li>
<li>dealing with false alarms raised by the test</li>
<li>spending time reading the test when you’re trying to understanding how the underlying code behaves</li>
</ol>
<p>a successful test suite must:</p>
<ol>
<li>integrated into the development cycle</li>
<li>targets only the most important parts of the code base<ol>
<li>👉🏻 domain logic</li>
<li>infrastructure code</li>
<li>external services and dependencies</li>
<li>code that glues everything together</li>
</ol>
</li>
<li>provides maximum value with minimum maintenance costs<ol>
<li>recognize a valuable test (and, by extension, a test of low value)</li>
<li>write a valuable test</li>
</ol>
</li>
</ol>
<h2>What is a unit test?</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%201.png" alt=""></p>
<ol>
<li>verifies a single unit of behavior</li>
<li>dose it quickly</li>
<li>dost it in isolation from other tests</li>
</ol>
<p>An integration test, then, is a test that doesn’t meet one of these criteria.</p>
<p>End-to-end tests are a subset of integration tests.</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%202.png" alt=""></p>
<h2>How to structure a unit test?</h2>
<table>
<thead>
<tr>
<th>Type</th>
<th>Components</th>
</tr>
</thead>
<tbody><tr>
<td>AAA</td>
<td>Arrange - Act - Assert</td>
</tr>
<tr>
<td>GWT</td>
<td>Given - When - Then</td>
</tr>
</tbody></table>
<ol>
<li>avoid multiple arrange, act, and assert sections.</li>
<li>avoid if statements in tests.</li>
<li>name the test as if you were describing the scenario to a non-programmer who is familiar with the problem domain.</li>
<li>separate words with underscores.</li>
<li>structure a test is to make it tell a story about the problem domain</li>
</ol>
<h2>Four pillars of a good unit test</h2>
<blockquote>
<p><em>code is not an asset, it’s a liability.</em></p>
</blockquote>
<ol>
<li><p><strong>protection against regressions</strong></p>
<ol>
<li>the amount of code that is executed during the test</li>
<li>the complexity of that code</li>
<li>the code’s domain significance</li>
</ol>
</li>
<li><p><strong>resistance to refactoring</strong></p>
<ol>
<li>the fewer false positives the test generates, the better</li>
<li>tests provides an early warning when you break exisiting functionality</li>
<li>you become confident that your code changes won’t lead to regressions</li>
<li>the more the test is coupled to the implementation details of the system under set(SUT), the more false alarms it generates</li>
<li>you need to make sure the test verifies the end result the SUT delivers: its observable behavior, not the steps it takes to do that.</li>
<li>the best way to structure a test is to make it tell a story about the problem domain</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%203.png" alt=""></p>
</li>
<li><p><strong>fast feedback</strong></p>
</li>
<li><p><strong>maintainability</strong></p>
<ol>
<li>how hard it is to understand the test, which is a function of the test’s size</li>
<li>how hard it is to run the test, which is a function of how many out-of-process dependencies the test works with directly</li>
</ol>
</li>
</ol>
<h3>The intrinsic connection between the first two attributes</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%204.png" alt=""></p>
<p>Type II Error:</p>
<ul>
<li>if functionality is broken, the test should fail, but if the test also passed, means it is not a good unit test, should if it fails, means it offers protection against regressions.</li>
</ul>
<p>Type I Error:</p>
<ul>
<li>if functionality is correct but the test fails, means that the test dose not test the nature of behavior. The good unit test should always pass when the functionality is correct. This would help us a lot when we try to do refactor. If we refactor the code correctly, but the unit tests always failed, means that the unit tests are not good enough, we need to optimize them.</li>
</ul>
<h2>An ideal test</h2>
<ul>
<li>value = <code>[0..1] * [0..1] * [0..1] * [0..1]</code>(corresponding to the four pillars)</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%205.png" alt=""></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%206.png" alt=""></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%207.png" alt=""></p>
<h3>black-box and white-box testing</h3>
<ol>
<li>choose black-box testing over white-box testing by default.</li>
<li>the only exception is when the test covers utility code with high algorithmic complexity</li>
<li>use code coverage tools to see which code branches are not exercised, but then turn around and test them as if you know nothing about the code’s internal structure.</li>
</ol>
<h2>Mock</h2>
<h3>types of test doubles</h3>
<ul>
<li>mock: help to emulate and examine <code>outcoming</code> interactions —— change state<ul>
<li>mock: generated by tools</li>
<li>spy: written manually</li>
</ul>
</li>
<li>stub: help to emulate <code>incoming</code> interactions —— get input data<ul>
<li>stub: can configure to return different values for different scenarios.</li>
<li>dummy: a simple, hardcoded value such as a null value or a made-up string.</li>
<li>fake: the same as a stub for most purposes, only except for its creation, it is usually implemented to replace a dependency that dose not yet exist</li>
</ul>
</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%208.png" alt=""></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%209.png" alt=""></p>
<blockquote>
<p>never asserting interactions with stubs.</p>
</blockquote>
<h3>Observable behavior</h3>
<ol>
<li>expose an <code>operation</code> that helps the client achieve one of its goals. An operation is a method that performs a calculation or incurs a side effect or both.</li>
<li>expose a <code>state</code> that helps the client achieve one of its goals. State is the current condition of the system.</li>
</ol>
<blockquote>
<p>Whether the code is observable behavior depends on who its client is and what the goals of that client are.</p>
</blockquote>
<blockquote>
<p>Ideally, the system’s public API surface should coincide with its observable behavior, and all its implementation details should be hidden from the eyes of the clients.</p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2010.png" alt=""></p>
<h3>Mocks and test fragility</h3>
<ul>
<li><code>Intra-system</code> communications are communications between classes inside your application.</li>
<li><code>Inter-system</code> communications are when your application talks to other applications.</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2011.png" alt=""></p>
<ol>
<li>The use of mocks is <code>beneficial</code> when verifying the communication pattern between your system and external applications.</li>
<li>Using mocks to verify communications between classes inside your system results in tests that couple to implementation details and therefore fall short of the <code>resistance-to-refactoring</code> metric.</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2012.png" alt=""></p>
<h3>Types of dependencies</h3>
<ul>
<li><code>shared dependency</code>: a dependency shared by test (not production code)</li>
<li><code>out-of-process dependency</code>: a dependency hosted by a process other than the program’s execution process (database, stmp server)</li>
<li><code>private dependency</code>: any dependency that is not shared</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2013.png" alt=""></p>
<h2>Styles of unit testing</h2>
<ul>
<li><code>output-based</code>: only need to verify the output.</li>
<li><code>state-based</code>: the underlying code changes its own state, the state of its collaborators, or the state of an out-of-process dependency.</li>
<li><code>communication-based</code>: use mocks to verify communications between the SUT and its collaborators, to verify the communication situations.</li>
</ul>
<h3>compare</h3>
<h4>protection against regressions</h4>
<ul>
<li>for the most part, they are not very different</li>
<li>but overusing the <code>communication-based</code> style can result in shallow tests that verify only a thin slice of code and mock out everything else.</li>
</ul>
<h4>fast feedback</h4>
<ul>
<li>for the most part, they are not very different</li>
<li><code>communication-based</code> testing can be slightly worse because the cost of mocks.</li>
</ul>
<h4>resistance to refactoring</h4>
<ul>
<li><code>state-based</code> is the best one.</li>
<li><code>communication-based</code> is the worse one, because it is the most vulnerable to false alarms.</li>
</ul>
<h4>maintainability</h4>
<ul>
<li><code>output-based</code> is the best one, because they do not deal with out-of-process dependencies.</li>
<li><code>state-based</code> is less maintainable because state verification takes up more space than output verification.</li>
<li><code>communication-based</code> is the worst one, it requires setting up test doubles and interaction assertions, and that takes up a lot of space.</li>
</ul>
<h3>functional architecture</h3>
<p><code>*Functional architecture</code>* maximizes the amount of code written in a purely functional (immutable) way, while minimizing code that deals with side effects. <em>Immutable</em> means unchangeable: once an object is created, its state can’t be modified. This is in contrast to a *mutable* object (changeable object), which can be modified after it is created.</p>
<p>Separate two kinds of code:</p>
<ul>
<li>code that make a decision</li>
<li>code that acts upon that decision</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2014.png" alt=""></p>
<h3>Tips</h3>
<ul>
<li>Moving from using an out-of-process dependency to using mocks.</li>
<li>Moving from using mocks to using functional architecture</li>
</ul>
<h2>Four kinds of code</h2>
<ul>
<li>Domain model and algorithms</li>
<li>Trivial code</li>
<li>Controllers</li>
<li>Overcomplicated code</li>
</ul>
<h3>Tips</h3>
<ol>
<li>always write completed unit tests for <code>domain model the algorithms</code> code</li>
<li>never test <code>trivial code</code></li>
<li>write integration test for <code>controllers</code></li>
<li>do not write overcomplicated code, try to separate it into <code>domain model and algorithms</code> and <code>controllers</code></li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2015.png" alt=""></p>
<h3>Trade-off</h3>
<ul>
<li>domain model testability</li>
<li>controller simplicity</li>
<li>performance</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2016.png" alt=""></p>
<ol>
<li>push all external reads and writes to the edges anyway</li>
<li>inject the out-of-process dependencies into the domain model</li>
<li><strong>split the decision-making process into more granular steps</strong> 👈<ol>
<li><code>CanExecute/Execute</code> pattern</li>
<li>domain events</li>
</ol>
</li>
</ol>
<h3>CanExecute/Execute pattern</h3>
<p>You can use <code>CanExecute/Execute</code> pattern to balance the <code>performance</code> and <code>testability</code> , but concedes controller simplicity, but it is manageable in most cases.</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2017.png" alt=""></p>
<h3>Domain events</h3>
<p>Domain events help track important changes in the domain model, and then convert those changes to calls to out-of-process dependencies. This pattern removes the tracking responsibility from the controller.</p>
<ul>
<li>extract a <code>DomainEvent</code> base class and introduce a base class for all domain classes, which would contain a collection of such events: <code>List&lt;DomainEvent&gt; events</code></li>
</ul>
<h2>Integration tests</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2018.png" alt=""></p>
<ol>
<li>check as many of the business scenario’s edge cases as possible with unit tests</li>
<li>use integration tests to cover one happy path, as well as any edge cases that can’t be covered by unit tests</li>
<li>if there’s no one path that goes through all happy paths, write additional integration tests—as many as needed to capture communications with <strong>every</strong> external system</li>
<li>attempt to apply the <code>fail-fast principle</code> as a viable alternative to integration test.</li>
</ol>
<h3>two types of out-of-process dependencies</h3>
<ul>
<li><code>managed dependencies</code>: only accessible through your application. it is implement details and should not be mock.</li>
<li><code>unmanaged dependencies</code>: you don’t have full control over it. It is observable behavior and you should mock it.</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2019.png" alt=""></p>
<h3>interface misunderstand</h3>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--info" aria-hidden="true"></span></p>
<p><strong>🙋🏻‍♀️ Genuine abstractions are discovered, not invented.</strong></p>
</blockquote>
<p>👉🏻 For an interface to be a genuine abstraction, it must have at lease two implemtations.</p>
<p>The common reasoning behind the use of interfaces is that they help to:</p>
<ol>
<li>Abstract out-of-process dependencies, thus achieving loose coupling.</li>
<li>Add new functionality without changing the existing code, thus adhering to the <code>Open-Closed principle</code></li>
</ol>
<p>Misconceptions:</p>
<ol>
<li>Interfaces with a single implementation are not abstractions and don’t provide loose coupling any more than concrete classes that implement those interfaces.</li>
<li>The second reason violates a more foundational principle: <code>YAGNI (You are not gonna need it)</code>.</li>
<li>The only reason to use interfaces for out-of-process dependencies it is to <code>enable testing</code>!</li>
<li>Do not introduce interfaces for out-of-process dependencies unless you need to mock out those dependencies.</li>
</ol>
<h3>integration test best practices</h3>
<ol>
<li>making domain model boundaries explicit</li>
<li>reducing the number of layers in the application</li>
<li>eliminating circular dependencies</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2020.png" alt=""></p>
<h3>maximozing mock’s value</h3>
<ol>
<li><p>when mocking, always try to <strong>verify interactions with unmanaged dependencies at the very edges of your system</strong>.</p>
<blockquote>
<p>Mocking <code>IBus</code> instead of <code>IMessageBus</code> maximizes the mock’s protection against regressions.</p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2021.png" alt=""></p>
</li>
<li><p>A call to an unmanaged dependency goes through several stages before it leaves your application. <strong>Pick the last such stage</strong>. It is the best way to ensure backward compatibility with external systems, which is the goal that mocks help you achieve.</p>
</li>
<li><p>In some cases, you can use <code>spy</code> instead of <code>mock</code> for more succinct and expressive.</p>
<pre><code class="language-csharp">[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
    var busSpy = new BusSpy();
    var messageBus = new MessageBus(busSpy);
    var loggerMock = new Mock&lt;IDomainLogger&gt;();
    var sut = new UserController(db, messageBus, loggerMock.Object);
        /* ... */
    busSpy.ShouldSendNumberOfMessages(1)
        .WithEmailChangedMessage(user.UserId, &quot;new@gmail.com&quot;);
}
</code></pre>
</li>
</ol>
<h3>mocking best practices</h3>
<ol>
<li>applying mocks to unmanaged dependencies only</li>
<li>verifying the interactions with those dependencies at the very edges of your system</li>
<li>using mocks in integration tests only, not in unit test</li>
<li>always verifying the number of calls made to the mock</li>
<li>do not rely on production code when making assertions. Use a separate set of literals and constants in tests.</li>
<li>mocking only types that you own<ol>
<li>always write your own adapters on top of third-party libraries and mock those adapters instead of the underlying types.</li>
<li>only expose features you need from the library</li>
<li>do that using your project’s domain language</li>
<li>this guideline dose not apply to in-process dependencies. There is no need to abstract in-memory or managed dependencies. Similarly, there’s no need to abstract an ORM as long as it’s used for accessing a database that isn’t visible to external applications.</li>
</ol>
</li>
</ol>
<h2>Testing the database</h2>
<h3>prerequisites</h3>
<ol>
<li><p>keeping the database in the source control system</p>
<ol>
<li>database schemas</li>
<li>reference data</li>
</ol>
</li>
<li><p>using a separate database instance for every developer</p>
</li>
<li><p>applying the migration-based approach to database delivery</p>
<blockquote>
<p>applying every modification to the database schema (including reference data) through migrations. Do not modify migrations once they are committed to the source control. If a migration is incorrect, create a new migration instead of fixing the old one. Make exceptions to this rule only when the incorrect migration can lead to data loss.</p>
</blockquote>
</li>
</ol>
<h3>transaction</h3>
<p>split the <code>Database</code> class into <code>repositories</code> and a <code>transaction</code>:</p>
<ul>
<li><code>repositories</code> are classes that enable access to and modification of the data in the database.</li>
<li><code>transaction</code> is a class that either commits or rolls back data updates in full. This will be a custom class relying on the underlying database’s transactions to provide atomicity of data modification.</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2022.png" alt=""></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image%2023.png" alt=""></p>
<h3>tips</h3>
<ol>
<li>use at least three transactions or units of work in an integrations test: one per each arrange, act and assert section.</li>
<li>your tests should not depend on the state of the database. Your tests should bring that state to the required condition on their own.</li>
<li>create two collections for unit and integrations, and then disable test parallelization in the collection with the integration test.</li>
<li>clean up data at the beginning of a test</li>
<li>write the SQL script manually. It’s simpler and gives you more granular control over the deletion process.</li>
<li>the best way to shorten integration is by extracting technical, non-business-related bits into private methods or helper classes.</li>
<li>only the most complex or important read operations should be test, disregard the rest.</li>
<li>do not test repositories directly, only as part of the overarching integration test suite.</li>
</ol>
<h2>Unit testing anti-patterns</h2>
<blockquote>
<p>⚠️ Do not do the things like below!</p>
</blockquote>
<ol>
<li><p>unit testing private methods</p>
<blockquote>
<p>Private methods are implementation details! Just test observable behaviors!</p>
</blockquote>
<blockquote>
<p><strong>If the private method is too complex to be tested as part of the public API that uses it, that’s an indication of a missing abstraction. Extract this abstraction into a separate class instead of making the private method public.</strong></p>
</blockquote>
</li>
<li><p>expose private state</p>
</li>
<li><p>leaking domain knowledges to tests</p>
<pre><code class="language-csharp">public class CalculatorTests
{
    [Fact]
    public void Adding_two_numbers()
    {
        int value1 = 1;
        int value2 = 3;
        int expected = value1 + value2;   // &lt;-----The leakage
        // int expected = 4 // the better one
        int actual = Calculator.Add(value1, value2);
        Assert.Equal(expected, actual);
    }
}
</code></pre>
</li>
<li><p>code pollution</p>
<blockquote>
<p>Code pollution is adding production code that’s only needed for testing.</p>
</blockquote>
</li>
<li><p>mocking concrete classes</p>
</li>
<li><p>working with time</p>
</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>一步步推导出 MySQL 数据的底层存储结构</title>
      <link>https://hedon.top/blog/mysql-ibd/</link>
      <guid isPermaLink="true">https://hedon.top/blog/mysql-ibd/</guid>
      <pubDate>Tue, 08 Apr 2025 12:54:13 GMT</pubDate>
      <description>本文从最简单的数据格式开始，通过不断解决一个个关键问题，最终推导出 MySQL 数据的底层存储结构，即 B+ 树。</description>
      <category>MySQL</category><category>数据库</category>
      <content:encoded><![CDATA[<p>以下均以 InnoDB 引擎为基础进行分析。假设我们现在有 3 行数据，如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250408125932725.png" alt=""></p>
<p>其中：</p>
<ul>
<li><code>id</code> 是主键索引。</li>
<li><code>a</code> 和 <code>b</code> 都是数据字段。</li>
<li><code>tx_id</code> 是隐藏字段，表示事务 id，用于实现 MVCC。</li>
<li><code>rollback_ptr</code> 是回指针，用于 undo log。</li>
</ul>
<p>在将数据存储到文件的时候，我们会将这三行数据进行序列化，然后以二进制流的形式存储到文件中。</p>
<p>现在我们要解决第一个问题：</p>
<h4>如何按照主键（id）排序？</h4>
<p>在 InnoDB 中，会在每一行的前面，加一个 <code>next_record</code> 字段，用于指向比当前数据 id 大的下一条数据，我们假设一行数据占 20 个字节，那么就如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250408130510505.png" alt=""></p>
<p>另外，为了便于定位每一行，InnoDB 会在每一行前面再加一个字段 <code>heap_no</code>，它的规则很简单，就是自增，在内部会用于定位一行记录，方便上锁等各种操作。</p>
<p>所以现在的存储结构如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250408130840634.png" alt=""></p>
<p>现在我们来解决解决第二个问题：</p>
<h4>如何快速定位到起点（最小）和终点（最大）？</h4>
<p>在最前面加 2 条特殊的记录：</p>
<ul>
<li><code>PAGE_NEW_INFIMUM</code>：指向最小记录。</li>
<li><code>PAGE_NEW_SUPREMUM</code>：最大记录，最大的一个 id 会指向它。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251204173438139.png" alt=""></p>
<p>第三个问题：</p>
<h4>每次 select * from t where id = ? 都要进行 I/O 操作吗？</h4>
<p>很显然是不行的，效率太低了。这个相信绝大多数读者都知道 ，InnoDB 会以 Page（默认 16KB）为最小单位，一次性将数据从磁盘加载到内存中。为此，需要在最前面再加一条记录，且该记录的前三行分别为：</p>
<ul>
<li><code>page_no</code>：页号，自增，InnoDB 最多支持 32 位页号，所以存储上限是 16KB * 2^32^ = 64T。</li>
<li><code>prev_page</code>：指向上一页。</li>
<li><code>next_page</code>：指向下一页。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250409000055738.png" alt=""></p>
<h4>如果一个 Page 放不下呢？</h4>
<p>很显然，那就要进行分页，即按照 ID 的顺序进行一分为二，前者取范围 <code>[a, b)</code>，后者取范围 <code>[b, c)</code>。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250408131632390.png" alt=""></p>
<h4>如何快速定位到数据在哪个 Page 上呢？</h4>
<p>这个时候，我们需要新创建一个 Page，专门用于管理这些数据 Page 的，这个 Page 我们这里暂且称为索引 Page。</p>
<p>其中核心数据就是 2 个：</p>
<ul>
<li><code>min_id</code>：即当前页存储的最小主键 ID。</li>
<li><code>page_no</code>：页号，用于定位到 Page。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250408131810905.png" alt=""></p>
<p>这是什么呀？这其实就是 B+ 树！在文件层面的存储，是连续存储的，但是为了便于理解，我们可以在逻辑层面将其绘制成 B+ 树的形态。如下图可以看到这其实就是一颗 B+ 树。</p>
<p>在主键索引树上：</p>
<ol>
<li>叶子节点存储的就是具体某一行的数据（聚簇索引）。</li>
<li>非叶子节点存储的是索引。</li>
<li>每一层的节点，都是一条有序的双向链表。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250408132026228.png" alt=""></p>
<h4>如果对非主键索引 a 创建索引呢？</h4>
<p>因为要建索引，所以需要先对 a 进行排序，然后针对 a 建立一颗 b+ 树。而且由于 a 是非主键索引，即辅助索引，所以叶子节点存储的是主键的值，用于回表。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250408132300926.png" alt=""></p>
<h4>假设 a =15 的数据非常多，一个 page 放不下呢？</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250408132323227.png" alt=""></p>
<p>对于非唯一索引，InnoDB 会<strong>隐式地</strong>将主键 ID 追加到索引列之后，构建成 <code>(索引列, 主键)</code> 的组合键。这样在处理大量重复索引值（如 a=15）时，B+ 树依然可以利用主键 ID 的唯一性和有序性，确定记录在叶子节点中的位置，以及在页分裂时确定准确的分割点。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250408132350638.png" alt=""></p>
<h4>估算一下一个三层的 B+ 树可以存储多少条数据？</h4>
<ul>
<li>一个 Page 是 16KB</li>
<li>假设 1 个 Page 可以存放 1000 个 key</li>
<li>假设 1 个 Page 可以存放 200 条记录</li>
</ul>
<p>基于这种估算：</p>
<ul>
<li>第 1 层：1 个节点是 1 个 Page，存放 1000 个 key，对应 1000 个分叉</li>
<li>第 2 层：1000 个节点 1000 个 Page，存放 1000*1000 个 Key，对应 1000*1000 个分叉</li>
<li>第 3 层：1000*1000 个 Page，每个 Page 200 条数据，共 1000*1000*200=2 亿条数据 = 16KB*1000*1000=16GB</li>
</ul>
<p>🐂🐂🐂</p>
]]></content:encoded>
    </item>
    <item>
      <title>在 Hexo 博客中优雅地集成 Markmap 思维导图</title>
      <link>https://hedon.top/blog/mindmap-for-hexo/</link>
      <guid isPermaLink="true">https://hedon.top/blog/mindmap-for-hexo/</guid>
      <pubDate>Mon, 17 Mar 2025 18:21:18 GMT</pubDate>
      <description>本文详细介绍了如何在 Hexo 博客中优雅地集成 Markmap 思维导图，让你能够直接在 Markdown 文件中创建交互式思维导图。同时这也是一个 Hexo 插件的标准实现案例。</description>
      <category>hexo</category><category>markmap</category><category>小技术</category>
      <content:encoded><![CDATA[<p>在技术博客写作中，思维导图是一个非常有用的工具，它可以帮助我们更清晰地展示知识结构和概念关系。本文将介绍如何在 Hexo 博客中集成 Markmap，让你能够直接在 Markdown 文件中创建交互式思维导图。</p>
<h2>什么是 Markmap？</h2>
<p>Markmap 是一个将 Markdown 格式的文本转换为思维导图的开源工具。它允许我们使用熟悉的 Markdown 语法来创建漂亮的、交互式的思维导图。</p>
<p><a href="https://markmap.js.org/">https://markmap.js.org/</a></p>
<h2>实现方案</h2>
<h3>1. 安装必要依赖</h3>
<p>首先，我们需要安装 <code>uuid</code> 包，这是用来给我们每一个思维导图生成一个唯一的 ID：</p>
<pre><code class="language-bash">npm install uuid --save
</code></pre>
<h3>2. 创建自定义标签插件</h3>
<p>在 <code>scripts/markmap_tag.js</code> 中创建自定义标签：</p>
<pre><code class="language-javascript">/**
 * Markmap Tag Plugin for Hexo
 */
&quot;use strict&quot;;

const { v4: uuidv4 } = require(&quot;uuid&quot;);

hexo.extend.tag.register(
  &quot;markmap&quot;,
  function (args, content) {
    const id = uuidv4();

    return `
&lt;div class=&quot;markmap&quot; id=&quot;markmap-${id}&quot;&gt;
  &lt;script type=&quot;text/template&quot;&gt;
${content}
  &lt;/script&gt;
&lt;/div&gt;
&lt;script&gt;
document.addEventListener(&#39;DOMContentLoaded&#39;, () =&gt; {
  const template = document.querySelector(&#39;#markmap-${id} script[type=&quot;text/template&quot;]&#39;);
  if (template) {
    const content = template.textContent;
    window.markmap.autoLoader.renderString(content, null, document.querySelector(&#39;#markmap-${id}&#39;));
  }
});
&lt;/script&gt;
  `;
  },
  { ends: true }
);
</code></pre>
<h3>3. 添加样式</h3>
<p>创建 <code>source/css/markmap.css</code>：</p>
<pre><code class="language-css">/* Markmap Styles */
.markmap {
  width: 100%;
  height: 500px;
  margin: 20px 0;
  border: 1px solid #eaeaea;
  border-radius: 5px;
  overflow: hidden;
}

.markmap svg {
  width: 100%;
  height: 100%;
}

/* 响应式设计 */
@media (max-width: 768px) {
  .markmap {
    height: 400px;
  }
}

@media (max-width: 480px) {
  .markmap {
    height: 300px;
  }
}
</code></pre>
<h3>4. 更新主题配置</h3>
<p>在主题配置文件中添加必要的资源引用：</p>
<pre><code class="language-yaml">inject:
  head:
    - &lt;link rel=&quot;stylesheet&quot; href=&quot;/css/markmap.css&quot;&gt;
    - &lt;script src=&quot;https://cdn.jsdelivr.net/npm/markmap-autoloader@0.18&quot;&gt;&lt;/script&gt;
</code></pre>
<h2>使用方法</h2>
<!-- tab 代码块 -->

<pre><code class="language-markdown">```text


- 技术栈
  - 前端
    - Vue.js
    - React
    - Angular
  - 后端
    - Node.js
    - Python
    - Go
  - 数据库
    - MySQL
    - MongoDB
    - Redis

</code></pre>
<pre><code>
&lt;!-- tab 效果 --&gt;

```text


- 技术栈
  - 前端
    - Vue.js
    - React
    - Angular
  - 后端
    - Node.js
    - Python
    - Go
  - 数据库
    - MySQL
    - MongoDB
    - Redis

</code></pre>
<h2>工作原理</h2>
<h3>1. Hexo 插件系统</h3>
<ul>
<li>Hexo 提供了强大的插件系统，允许我们通过 <code>hexo.extend</code> API 来扩展功能</li>
<li>我们使用了 <code>hexo.extend.tag</code> 来注册自定义标签，这是 Hexo 提供的标准扩展点之一</li>
</ul>
<h3>2. 标签插件的工作原理</h3>
<pre><code class="language-javascript">hexo.extend.tag.register(
  &quot;markmap&quot;,
  function (args, content) {
    const id = uuidv4(); // 生成唯一ID

    return `
    &lt;div class=&quot;markmap&quot; id=&quot;markmap-${id}&quot;&gt;
      &lt;script type=&quot;text/template&quot;&gt;
        ${content}
      &lt;/script&gt;
    &lt;/div&gt;
    ...
  `;
  },
  { ends: true }
);
</code></pre>
<ul>
<li>当 Hexo 解析到 <code>markmap</code> 标签时，会调用这个注册的函数</li>
<li><code>content</code> 参数包含了标签之间的所有内容（你的 markdown 结构）</li>
<li><code>{ends: true}</code> 表示这是一个闭合标签（需要 endmarkmap 结束）</li>
</ul>
<h3>3. Markmap 库的渲染过程</h3>
<ul>
<li>Markmap 库使用 <code>markmap-autoloader</code> 自动处理 markdown 到思维导图的转换</li>
<li>转换过程：<ol>
<li>Markdown 文本被解析成层级结构</li>
<li>层级结构被转换为 SVG 路径</li>
<li>SVG 被渲染到页面上，并添加交互功能</li>
</ol>
</li>
</ul>
<h3>4. HTML 结构设计</h3>
<pre><code class="language-html">&lt;div class=&quot;markmap&quot; id=&quot;markmap-${id}&quot;&gt;
  &lt;script type=&quot;text/template&quot;&gt;
    ${content}
  &lt;/script&gt;
&lt;/div&gt;
</code></pre>
<ul>
<li>使用 <code>script type=&quot;text/template&quot;</code> 来存储原始 markdown</li>
<li>每个思维导图都有唯一 ID，避免页面上多个图表互相干扰</li>
</ul>
<h3>5. JavaScript 初始化</h3>
<pre><code class="language-javascript">document.addEventListener(&quot;DOMContentLoaded&quot;, () =&gt; {
  const template = document.querySelector(
    &#39;#markmap-${id} script[type=&quot;text/template&quot;]&#39;
  );
  if (template) {
    const content = template.textContent;
    window.markmap.autoLoader.renderString(
      content,
      null,
      document.querySelector(&quot;#markmap-${id}&quot;)
    );
  }
});
</code></pre>
<ul>
<li>等待页面加载完成</li>
<li>获取模板中的 markdown 内容</li>
<li>使用 markmap 库渲染思维导图</li>
</ul>
<h3>6. 样式控制</h3>
<pre><code class="language-css">.markmap {
  width: 100%;
  height: 500px;
  margin: 20px 0;
  border: 1px solid #eaeaea;
  border-radius: 5px;
  overflow: hidden;
}
</code></pre>
<ul>
<li>提供响应式布局</li>
<li>确保思维导图在各种屏幕尺寸下都能正常显示</li>
</ul>
<h3>7. 主题集成</h3>
<ul>
<li>在主题配置中注入必要的 CSS 和 JavaScript</li>
<li>确保资源在正确的时机加载</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>为什么 OpenTelemetry 的 SDK 中不支持尾采样 Hook？</title>
      <link>https://hedon.top/blog/opentelemetry-tail-sampler/</link>
      <guid isPermaLink="true">https://hedon.top/blog/opentelemetry-tail-sampler/</guid>
      <pubDate>Thu, 13 Mar 2025 15:10:27 GMT</pubDate>
      <description>本文介绍了 OpenTelemetry 的采样机制，特别是为什么它在 SDK 层面不支持尾采样。</description>
      <category>opentelemetry</category><category>服务监控</category>
      <content:encoded><![CDATA[<p>在分布式追踪系统中，采样策略直接影响着系统的性能和可观测性。OpenTelemetry 作为当前最流行的可观测性框架，其采样机制设计有着深刻的考量。本文将深入探讨 OpenTelemetry 的采样机制，特别是为什么它在 SDK 层面不支持尾采样。</p>
<h2>前置采样 vs 尾采样</h2>
<p>在讨论 OpenTelemetry 的采样机制前，我们需要理解两种主要的采样策略：</p>
<p><strong>前置采样（Head-based Sampling）</strong>：</p>
<ul>
<li>在链路开始时就决定是否采样</li>
<li>决策一旦做出，整个链路都遵循这个决策</li>
<li>不需要缓存完整的链路数据</li>
</ul>
<p><strong>尾采样（Tail-based Sampling）</strong>：</p>
<ul>
<li>在链路结束后决定是否保留</li>
<li>可以基于完整链路信息（如总耗时、是否有错误）做决策</li>
<li>需要临时缓存所有链路数据</li>
</ul>
<h2>OpenTelemetry 的采样实现</h2>
<p>通过分析 <a href="https://github.com/open-telemetry/opentelemetry-go/blob/v1.35.0/sdk/trace/tracer.go#L65">OpenTelemetry Go SDK 的源码</a>，我们可以清晰地看到它采用的是前置采样策略。关键代码如下：</p>
<pre><code class="language-go">func (tr *tracer) newSpan(ctx context.Context, name string, config *trace.SpanConfig) trace.Span {
    // ... 前面的代码 ...

    // 执行采样决策
    samplingResult := tr.provider.sampler.ShouldSample(SamplingParameters{
        ParentContext: ctx,
        TraceID:       tid,
        Name:          name,
        Kind:          config.SpanKind(),
        Attributes:    config.Attributes(),
        Links:         config.Links(),
    })

    // 设置采样标志
    if isSampled(samplingResult) {
        scc.TraceFlags = psc.TraceFlags() | trace.FlagsSampled
    } else {
        scc.TraceFlags = psc.TraceFlags() &amp;^ trace.FlagsSampled
    }

    // ... 后面的代码 ...
}
</code></pre>
<p>这段代码揭示了几个关键点：</p>
<ol>
<li>采样决策在 span 创建时就已经做出</li>
<li>采样标志通过位操作设置在 TraceFlags 中</li>
<li>这个标志会随着 SpanContext 传播到整个分布式系统</li>
</ol>
<h2>采样标志的传播机制</h2>
<p>特别值得注意的是设置采样标志的代码：</p>
<pre><code class="language-go">if isSampled(samplingResult) {
    scc.TraceFlags = psc.TraceFlags() | trace.FlagsSampled
} else {
    scc.TraceFlags = psc.TraceFlags() &amp;^ trace.FlagsSampled
}
</code></pre>
<p>这段代码使用位操作来设置或清除采样标志：</p>
<ul>
<li><code>|</code> 操作用于设置采样标志，保留其他标志位不变</li>
<li><code>&amp;^</code> 操作用于清除采样标志，同样保留其他标志位不变</li>
</ul>
<p>这确保了采样决策能够一致地传播到整个分布式链路中。</p>
<h2>为什么 OpenTelemetry 不支持尾采样？</h2>
<p>最重要的原因是：<font color="red">在 SDK 中找不到尾巴！因为不知道链路什么时候结束！</font></p>
<p>在分布式系统中，一条链路可能跨越多个服务，所以你在某一个服务中，是不知道链路是否结束的，而 OpenTelemetry 也不是一次性上报一整条链路，而是每个 <code>span</code> 独立上报，最后再拼接到一起。</p>
<h3>OpenTelemetry 上报原理</h3>
<ol>
<li><p>独立上报</p>
<ul>
<li><p>每个 <code>span</code> 在结束时（调用 <code>span.End()</code>）会被传递给 <code>SpanProcessor</code></p>
</li>
<li><p><code>SpanProcessor</code> 决定如何处理这个 <code>span</code>（立即导出或批量导出）</p>
</li>
<li><p>导出是独立的，不会等待整个 <code>trace</code> 完成</p>
</li>
</ul>
</li>
<li><p>批处理机制</p>
<ul>
<li><p>默认使用 <code>BatchSpanProcessor</code>，它会收集一定数量的 <code>spans</code> 或等待一定时间然后批量导出</p>
</li>
<li><p>但这个批处理与 <code>trace</code> 完整性无关，只是为了效率</p>
</li>
</ul>
</li>
</ol>
<h3>Collector 如何实现尾采样</h3>
<p>Collector 通过以下方式解决这些问题：</p>
<ol>
<li><p>设置等待时间窗口</p>
<ul>
<li><p>为每个 trace 设置一个等待期（如 10 秒）</p>
</li>
<li><p>在此期间收集该 trace 的所有 spans</p>
</li>
<li><p><strong>超过等待期后，基于已收集的 spans 做决策</strong></p>
</li>
</ul>
</li>
<li><p>集中式收集</p>
<ul>
<li><p>所有服务的 spans 都发送到 Collector</p>
</li>
<li><p>Collector 有更全面的视图来关联 spans</p>
</li>
</ul>
</li>
<li><p>专门的资源分配：Collector 作为独立组件，有专门的资源处理这种复杂逻辑，不会影响应用性能。</p>
</li>
</ol>
<h2>如何在 OpenTelemetry 生态中实现尾采样？</h2>
<p>虽然 SDK 不直接支持尾采样，但 OpenTelemetry 生态提供了其他方式实现类似功能：</p>
<h3>1. 使用 OpenTelemetry Collector</h3>
<p>Collector 提供了 Tail Sampling Processor，可以在数据聚合层实现尾采样：</p>
<pre><code class="language-yaml">processors:
  tail_sampling:
    decision_wait: 10s
    num_traces: 100
    expected_new_traces_per_sec: 10
    policies:
      - name: error-policy
        type: status_code
        status_code: ERROR
</code></pre>
<h3>2. 结合前置采样和错误捕获</h3>
<p>可以实现一个智能的前置采样器，对特定场景（如包含错误属性）强制采样：</p>
<pre><code class="language-go">type SmartSampler struct {
    baseSamplingRate float64
}

func (s *SmartSampler) ShouldSample(p trace.SamplingParameters) trace.SamplingResult {
    // 错误请求必采样
    for _, attr := range p.Attributes {
        if attr.Key == &quot;error&quot; {
            return trace.SamplingResult{Decision: trace.RecordAndSample}
        }
    }

    // 其他请求使用基础采样率
    if float64(p.TraceID[0])/255.0 &lt; s.baseSamplingRate {
        return trace.SamplingResult{Decision: trace.RecordAndSample}
    }

    return trace.SamplingResult{Decision: trace.Drop}
}
</code></pre>
<h3>3. 使用专门的后端系统</h3>
<p>一些专门的可观测性后端系统提供了尾采样功能：</p>
<ul>
<li><p>Jaeger 的 Adaptive Sampling</p>
</li>
<li><p>SkyWalking 的 Trace Sampling</p>
</li>
<li><p>Grafana Tempo 的 Trace Sampling</p>
</li>
</ul>
<h2>结论</h2>
<p>OpenTelemetry SDK 采用前置采样而非尾采样，是基于分布式系统一致性、性能优化和架构分层等多方面考虑的结果。虽然这意味着无法基于完整链路信息做采样决策，但 OpenTelemetry 生态提供了多种方式来弥补这一限制。</p>
<p>在实际应用中，我们可以：</p>
<ol>
<li>在 SDK 层使用智能前置采样策略，确保关键链路被采样</li>
<li>在 Collector 层实现尾采样，进一步筛选有价值的链路</li>
<li>结合使用多种采样策略，平衡性能和可观测性</li>
</ol>
<p>通过这种分层设计，OpenTelemetry 既保证了高效的数据收集，又为高级采样策略提供了可能性，满足了不同场景的需求。</p>
<h2>实战案例</h2>
<p>笔者实现一个 Go 语言的开源项目 <code>goapm</code>，对多个 Go 语言中常用的组件进行了 trace、log 和 metrics 的集成封装，用于快速在 Go 语言项目中实现可观测性，同时还提供了 <code>goapm-example</code> 实战案例，可供参考。</p>
<p><a href="https://github.com/hedon954/goapm">https://github.com/hedon954/goapm</a></p>
<p><a href="https://github.com/hedon954/goapm-example">https://github.com/hedon954/goapm-example</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>读书笔记丨《悟道领域驱动设计》</title>
      <link>https://hedon.top/blog/note-ddd-awareness/</link>
      <guid isPermaLink="true">https://hedon.top/blog/note-ddd-awareness/</guid>
      <pubDate>Tue, 11 Mar 2025 14:45:32 GMT</pubDate>
      <description>阅读《悟道领域驱动设计》后的一些笔记和思考。</description>
      <category>读书笔记</category><category>ddd</category>
      <content:encoded><![CDATA[<h2>思维转变</h2>
<p>领域驱动设计（Domain-Driven Design，以下简称 DDD）的核心价值在于其对「业务领域」的深度聚焦。这里的「领域」并非单纯的技术范畴，而是指代软件系统所要映射的现实业务场景及其核心价值主张。DDD 通过建立与业务高度契合的领域模型，使得技术实现与业务本质形成同频共振，从而有效解决复杂业务场景下的认知鸿沟问题。</p>
<p>在 VUCA（Volatile 易变性、Uncertain 不确定性、Complex 复杂性、Ambiguous 模糊性）特征愈发显著的现代商业环境中，任何架构设计都面临固有局限。这种局限性既源于业务需求本身的动态演进，也受制于人类认知的有限性——正如 Eric Evans 在开山之作中强调的“<strong>模型永远是对现实的近似抽象</strong>”。但正是这种局限性，凸显了 DDD 方法论的战略意义：</p>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--info" aria-hidden="true"></span></p>
<p>它通过&quot;战略设计&quot;构建业务全景图，运用限界上下文划定领域边界，通过&quot;战术设计&quot;落地聚合根、实体/值对象等模式，形成应对复杂性的结构化解决方案。</p>
</blockquote>
<p>需要特别指出的是，DDD 的复杂性并非方法论本身的缺陷，而是其应对现实业务复杂度的必要代价。这种复杂性体现在三个维度：</p>
<ol>
<li><strong>认知复杂性</strong>：要求开发团队与领域专家共建&quot;通用语言&quot;，实现业务概念与代码模型的精准映射。</li>
<li><strong>架构复杂性</strong>：通过分层架构实现业务逻辑与技术实现的解耦，采用防腐层处理系统集成问题。</li>
<li><strong>演进复杂性</strong>：借助子域划分和上下文映射，为持续演进的业务提供可扩展的架构基础。</li>
</ol>
<p>对于实践者而言，DDD 的价值不在于提供完美无缺的终极方案，而是为 VUCA 环境下的系统建设提供基础性指引。其核心思想——无论是通过限界上下文实现的领域自治，还是通过聚合根维护的业务一致性——都为控制软件熵增提供了可落地的模式库。即便不完全采用 DDD 完整体系，其领域建模思想、分层架构理念等核心要素，仍能显著提升复杂系统的可维护性和演进能力。这种开放包容的哲学，恰是 DDD 历经二十年仍保持生命力的关键所在。</p>
<h3>贫血模型 vs. 充血模型</h3>
<ul>
<li>贫血模型：指的是只有属性而没有行为的模型。</li>
<li>充血模型：指的是既有属性又有行为的模型。</li>
</ul>
<p>笔者过往的实践中，基本上都使用类似于 <code>controller→service→repository[model]</code> 的三层架构：</p>
<ul>
<li><code>conrtoller</code> 负责暴露对外接口。</li>
<li><code>service</code> 负责执行所有的业务逻辑。</li>
<li><code>repository</code> 复杂数据的存储和缓存，包含数据对象 <code>model</code> 的定义。</li>
</ul>
<p>在这个模式下，基本上所有的核心逻辑都充斥在 <code>service</code> 层中，所以 <code>service</code> 层一般都会非常大，它要扮演多面手，即要负责跟各个模块协作，还要负责处理具体的业务规则，最终完成一个业务行为。这个过程中，<code>model</code> 即为贫血模型，因为逻辑都给 <code>service</code> 处理了，这种架构也称为<strong>贫血三层架构</strong>。</p>
<p>在 DDD 的理念下，很多的核心业务概念都会被建模为「领域对象」，这些「领域对象」本身就是一种业务规则的体现，所以把业务的处理逻辑，都归属到这些「领域对象」的行为当中了，即所谓的充血模型。</p>
<p>在这个理念下，一个优化后的<strong>充血四层架构</strong>如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250311152623058.png" alt="充血四层架构"></p>
<p>贫血模型推荐场景：业务简单、迭代快速、团队技术栈偏传统（如 Spring Boot+MyBatis）时，避免过度设计。</p>
<p>充血模型推荐场景：业务复杂、需长期演进（如核心交易系统）、团队具备 DDD 经验时，通过实体、值对象、领域服务等战术设计理念降低系统熵增。</p>
<p>混合使用的场景：部分核心领域用充血模型（如订单、支付），非核心模块用贫血模型（如日志、配置），平衡效率与质量。</p>
<p>实际上，充血模型因其状态完整，适合进行<strong>状态变更类</strong>的操作，以确保业务操作符合领域规则；贫血模型由于其轻量级，更适合作为不会涉及状态变更的操作的数据容器。这其实就是 CQRS 的理念。</p>
<h2>概念清单</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/ddd.jpg" alt=""></p>
<h3>战术设计</h3>
<details open>
<summary>实体</summary>


<p><strong>定义</strong>：会随着业务变化发生变化的业务概念叫作实体对象。</p>
<p><strong>关键点</strong>：实体需要唯一表示</p>
</details>

<details open>
<summary>值对象</summary>


<p><strong>定义</strong>：一些对象在表达业务概念时是必须的，可业务并不围绕着它们进行，它们仅是对这些重要业务概念的描述，这一类对象叫作值对象。</p>
<p><strong>关键点</strong>：</p>
<ol>
<li>值对象的意义取决于属性，只要对象的属性一模一样，那么对象就是相同的。</li>
<li>尽量把值对象实现为不可变对象。</li>
</ol>
</details>

<details open>
<summary>领域服务</summary>


<p><strong>定义</strong>：领域服务自身是没有数据的，只是表达了某种业务计算逻辑，或者业务的某种策略。</p>
<p><strong>关键点</strong>：</p>
<ol>
<li>领域服务是无状态的。</li>
<li>只有在确实表达了一个相对独立的业务概念或者业务策略，并且不能简单地把它归结到某个既有的业务对象上时，才是一个真正的领域服务。</li>
</ol>
</details>

<details open>
<summary>领域事件</summary>


<p><strong>定义</strong>：领域事件代表从业务专家视角看到的某种重要的事情发生了。</p>
<p><strong>关键点</strong>：</p>
<ol>
<li>领域事件是一种特殊的值对象。</li>
<li>应该根据限界上下文中的通用语言来命名事件：AccountActivited。</li>
<li>应该将事件建模成值对象或贫血对象。</li>
</ol>
</details>

<details open>
<summary>聚合</summary>


<p><strong>定义</strong>：聚合从本质上讲是在基础的构造块上增加了一层边界，用边界把那些紧密相关的对象放到了一起。</p>
<p><strong>关键点</strong>：</p>
<ol>
<li>紧密相关的对象存在数据一致性问题；</li>
<li>缺乏边界时，维护数据一致性是困难的；</li>
<li>划分边界的关键在于既不要让整个系统成为一个整体，又让每个单独划分出的聚合具有明确的业务意义；</li>
<li>聚合需要关注三条法则：<ol>
<li>生命周期一致性：如果一个对象在聚合根消失之后仍然有意义，那么说明此时在系统中必然存在能够访问该对象的方法。这和聚合的定义矛盾，所以聚合内的其他元素必然在聚合根消失后失效。</li>
<li>问题域一致性：不属于同一个问题域的对象，不应该出现在同一个聚合中。</li>
<li>尽量小的聚合：聚合的本质作用是提升对象系统的粒度，确保一致性、降低复杂度。不过，粒度绝不是越大越好。如果聚合的粒度太大，那内部的逻辑复杂度也会大大增加还会影响到复用度。因此，要能够比较容易地断开聚合。</li>
</ol>
</li>
</ol>
</details>

<details open>
<summary>资源库</summary>


<p><strong>定义</strong>：对于查询、创建、修改、删除数据的操作，领域模型使用“资源库(Repository)”这个概念来承载它们。</p>
<p><strong>关键点</strong>：一个聚合对应一个资源库，应以聚合根命名资源库，除了聚合根之外的其他对象，都不应该提供资源库对象。</p>
</details>

<details open>
<summary>工厂</summary>


<p><strong>定义</strong>：工厂用于构建聚合。</p>
<p><strong>关键点</strong>：一个聚合往往包含多个对象，这些对象的数据之间又可能存在联系，如果允许分别创建这些对象，就会让聚合是业务完整性的单元这个定义面临失败。</p>
</details>

<h3>战略设计</h3>
<details open>
<summary>统一语言</summary>


<p><strong>定义</strong>：与业务专家协作定义全团队通用的术语表，消除沟通歧义。</p>
<p><strong>关键点</strong>：</p>
<ol>
<li>同一个概念在不同的上下文中可能存在不同的含义；</li>
<li>同一个概念在同一上下文中的不同环节，也可能存在不同的含义，需要非常明确清晰的界定，降低沟通成本。</li>
</ol>
</details>

<details open>
<summary>子域</summary>


<p><strong>定义</strong>：子域是对业务领域的逻辑划分，用于分解复杂问题。通常分为<strong>核心子域</strong>（业务核心竞争力）、<strong>支撑子域</strong>（辅助核心业务）和<strong>通用子域</strong>（可复用的标准化能力）。</p>
<p><strong>关键点</strong>：因业务目标、团队定位和组织发展阶段等方面的不同，这三个子域的划分并非一成不变，而是会互相转换。</p>
</details>

<details open>
<summary>限界上下文</summary>


<p><strong>定义</strong>：限界上下文本质上是一个自治的小世界，它有完备的职责，还有清晰的边界。</p>
<p><strong>关键点</strong>：</p>
<ol>
<li>一个子域的一切资产，包括领域模型、数据库、包、可执行程序、接口声明等，都应该封装在限界上下文中，避免跨越边界。</li>
<li>如何平衡边界的价值和不利影响，是划分边界时要做的一种重要取舍。<strong>一个较为稳妥的策略是考虑认知的渐进特征，不要过早隔离。在已经确定的边界上进行划分，延缓划分那些尚具模糊性的边界，在这些边界逐渐变得清晰时再分离它们。</strong></li>
</ol>
</details>

<details open>
<summary>上下文映射</summary>


<p><strong>定义</strong>：限界上下文约定了基于领域模型的架构层次的设计分解，而分解必然意味着集成和协作。上下文映射就是对限界上下文之间的协作关系的模式总结。</p>
<p><strong>关键点</strong>：</p>
<ol>
<li>在边界上完成概念映射是一种基本模式。通过在应用层组装或者使用适配器完成概念映射，可以保持领域概念的清晰，避免领域模型遭到不必要的污染。</li>
<li>防腐层模式、标准开放服务模式、客户-供应商模式、追随者模式。</li>
</ol>
</details>

<h3>串讲</h3>
<p>在应对复杂业务系统时，DDD 通过<strong>分治策略</strong>将业务领域拆分为多个<strong>子域</strong>（如电商系统的订单、支付子域），每个子域对应一个<strong>限界上下文</strong>——这是技术与业务对齐的关键边界，既承载领域模型的实现，也通过<strong>上下文映射</strong>（如防腐层、共享内核等模式）实现跨子域协作，避免模型污染。</p>
<p>限界上下文内的<strong>领域对象</strong>是业务逻辑的载体：具备唯一标识和生命周期的<strong>实体</strong>（如订单实体通过 ID 跟踪状态变化）、描述特征且不可变的<strong>值对象</strong>（如地址由省市构成，修改需整体替换），以及通过<strong>聚合根</strong>统一操作保证一致性的<strong>聚合</strong>（如订单聚合根管理订单项和配送信息）。当业务逻辑跨越多个聚合时，由无状态的<strong>领域服务</strong>协调（如支付计算需整合订单、账户聚合）。</p>
<p>对象的创建与持久化分别由<strong>工厂</strong>（封装复杂初始化逻辑）和<strong>资源库</strong>（隔离存储细节）负责，而<strong>领域事件</strong>（如订单支付成功事件）则驱动跨上下文的异步协作。</p>
<h2>战术设计</h2>
<h3>factory</h3>
<ul>
<li>factory 用于构建复杂的领域对象。</li>
</ul>
<h3>repository</h3>
<ul>
<li>只有聚合根有 repository。</li>
<li>repository 就只提供 <code>load</code> 和 <code>save</code> 功能，且要保证事务一致性。</li>
<li>尽可能提供行级的 repository，而不是表级的 repository，对于表级的 repository，可以抽成一个领域服务。</li>
</ul>
<h3>设计模式</h3>
<h4>责任链模式</h4>
<blockquote>
<p>将请求的发送者和接受者解耦，使多个对象都有机会处理请求。</p>
</blockquote>
<ul>
<li>责任链模式的使用要点在于要将维护责任链的代码和业务代码分开。</li>
<li>在 DDD 中使用责任链模式时，应创建一个领域服务，在领域服务中完成责任链的创建和执行。</li>
<li>尽量不要在责任链的处理器中通过 <code>set</code> 修改领域对象（聚合根）的状态，责任链应仅用于某些值的计算，最终将计算结果交给聚合根完成业务操作。</li>
</ul>
<p>笔者实现了一个快速构建责任链的工具：</p>
<p><a href="https://github.com/hedon954/devkit-go/blob/main/designmode/responsibility/builder.go">https://github.com/hedon954/devkit-go/blob/main/designmode/responsibility/builder.go</a></p>
<h4>策略模式</h4>
<blockquote>
<p>允许在运行时根据需要选择不同的实现。</p>
</blockquote>
<ul>
<li>在 DDD 中使用策略模式时，通常先定义一个领域服务接口，再在其实现类中完成策略的加载、选择和执行。</li>
<li>注意屏蔽策略模式的实现细节，避免上层关注领域服务内的设计模式细节。</li>
</ul>
<h4>桥接模式</h4>
<blockquote>
<p>旨在通过解耦抽象和实现，使两者能够独立扩展和变化。</p>
</blockquote>
<ul>
<li><strong>多维解耦机制</strong>：桥接模式通过组合/聚合关系替代继承关系，将原本紧密耦合的抽象层（功能定义）与实现层（具体操作）分离例如遥控器（抽象）与电视（实现）的协作，遥控器通过接口控制电视，无需关注具体品牌。</li>
<li><strong>正交扩展能力</strong>：支持两个独立变化维度（如消息类型与通知渠道、图形与渲染方式），避免类数量呈指数级增长（M×N 组合问题）。电商物流系统中，新增微信通知渠道时，无需修改所有消息类即可实现扩展。</li>
</ul>
<h4>规约模式</h4>
<blockquote>
<p>规约模式是一种用于定义业务领域中规则和约束的模式，通常由规约接口（Specification）和验证器（Validator）两个部分组成。</p>
</blockquote>
<ul>
<li>在 DDD 中，规约模式并不是在聚合根进行业务操作之前做前置校验，而是在聚合根完成业务操作之后做后置校验，确保 Repository 保存的聚合根符合业务规则。</li>
</ul>
<h4>适配器模式</h4>
<blockquote>
<p>将<strong>被适配者（Adaptee）的接口</strong>转换为<strong>目标接口（Target）</strong>，使原本因接口不兼容而无法协同工作的类能够协同。</p>
</blockquote>
<ul>
<li>在 DDD 中，可以使用适配器模式来实现防腐层，以将外部上下文接口（如开放主机服务）返回的模型转换为本地上下文定义的领域模型，并将本地上下文的操作转换为对外部上下文的操作。可以有效隔离外部上下文的领域模型，避免互相污染。</li>
</ul>
<h3>领域事件</h3>
<h4>幂等性</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image.png" alt=""></p>
<h4>领域事件的定义</h4>
<blockquote>
<p>领域事件是领域模型的组成部分，它通常由聚合根产生，并被其他聚合或者限界上下文订阅和处理，触发相应的业务逻辑。</p>
</blockquote>
<p>注意点：</p>
<ul>
<li>应该根据限界上下文中的通用语言来命名事件：AccountActivited。</li>
<li>应该将事件建模成值对象或贫血对象。</li>
</ul>
<p>应用：</p>
<ol>
<li>解耦领域对象之间的关系；</li>
<li>触发其他领域对象的行为；</li>
<li>记录领域内已发生的状态变化；</li>
<li>实现跨聚合的最终一致性；</li>
<li>进行限界上下文集成。</li>
</ol>
<p>消息体：</p>
<pre><code class="language-json">{
  &quot;event_id&quot;: &quot;&quot;,
  &quot;event_type&quot;: &quot;&quot;,
  &quot;entity_id&quot;: &quot;&quot;,
  &quot;event_time&quot;: 0,
  &quot;extra_data&quot;: &quot;{}&quot;
}
</code></pre>
<h4>领域事件的生成</h4>
<ol>
<li>应用层创建领域事件。</li>
<li>聚合根创建领域事件。</li>
</ol>
<p>要避免在聚合根内部调用基础实施发布领域事件，而是生成后返回给应用层，由应用层去发布。</p>
<pre><code class="language-go">type Entity struct {
  Events []Event
}

func(e *Entity)ResgisterEvent(event Event) {
  e.Events = append(e.Events. event)
}

func(e *Entity) GetEvents() []Event {
  res := e.Events()
  e.Events = []Event{}
  return res
}
</code></pre>
<h4>领域事件的发布</h4>
<ol>
<li>直接发布并轮询补偿：为事件存储一个发布状态标识，用于记录是否补发成功。并提供定时任务检索超时未发布成功的事件进行重新发布。</li>
<li>采用事务日志拖尾：引入变更数据捕获组件（Change Data Capture，简称 CDC），捕获数据的变更日志，解析后获得领域事件并发布。</li>
</ol>
<h4>领域事件的订阅</h4>
<p>将领域事件订阅者放置在用户接口层 <code>user-interface-subscriber</code>，收到事件后调用应用服务执行业务逻辑。</p>
<h3>事件溯源</h3>
<p>事件溯源（Event Sourcing）是一种将所有的领域事件（Domain Event）存储到事件存储（Event Store）中，并通过重放历史事件来还原领域对象状态的模式。</p>
<p>核心思想是将系统中所有的状态变更都视为事件，将这些事件以事件顺序记录下来，并存储到事件存储中。这样，可以通过重放这些事件，来还原任意时刻的系统状态。</p>
<p>三种方案：</p>
<ol>
<li>通过回放所有的历史事件重建聚合根。</li>
<li>通过快照提高重建聚合根的效率。</li>
<li><strong>通过拉链表生成所有事件对应的快照。</strong></li>
</ol>
<blockquote>
<p><span class="garden-callout-marker garden-callout-marker--success" aria-hidden="true"></span></p>
<p>拉链表是一种用于处理缓慢变化维度问题的数据结构，它可以有效地处理维度数据的历史变化。在拉链表中，每个记录都有一个开始时间和结束时间，用于描述该记录的存活时间，即该记录的有效期。</p>
</blockquote>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250312183214921.png" alt="拉链法示意图"></p>
<h3>CQRS</h3>
<p>CQRS 将系统的操作分为两类：</p>
<ul>
<li><strong>命令（Command）</strong>：负责数据的写操作（增、删、改），不返回数据。</li>
<li><strong>查询（Query）</strong>：负责数据的读操作，仅返回结果且不修改数据。</li>
</ul>
<p>两者的数据模型可独立设计，甚至使用不同的数据库或存储技术。</p>
<details open>
<summary>适用场景</summary>


<p><strong>应对高并发读写场景</strong></p>
<ul>
<li><p>案例 1：B 站点赞系统</p>
<p>在日均活跃用户近亿的 B 站，点赞功能通过 CQRS 分离读写操作。写入端通过消息队列（如 Kafka）异步处理请求，避免数据库锁竞争；查询端通过缓存优化读取性能，显著提升系统吞吐量和稳定性。</p>
</li>
<li><p>案例 2：实时答题 PK 游戏</p>
<p>高并发的答题得分计算场景中，CQRS 结合事件溯源（Event Sourcing）记录每个操作事件，确保读写模型的最终一致性，同时支持复杂战况数据的实时展示。</p>
</li>
</ul>
<p><strong>解决复杂查询需求</strong></p>
<ul>
<li><p>案例 3：电商订单查询</p>
<p>随着订单查询需求多样化（如按时间筛选、跨实体聚合数据），CQRS 通过独立读模型简化查询逻辑，避免领域模型被复杂查询逻辑污染。</p>
</li>
<li><p>案例 4：微服务数据聚合</p>
<p>在微服务架构中，CQRS 允许通过事件同步跨服务数据到专用读库，避免跨服务联表查询的性能瓶颈（如行程管理服务与用户信息服务的聚合查询）。</p>
</li>
</ul>
<p><strong>提升数据模型灵活性</strong></p>
<ul>
<li><p>案例 5：文本增量更新</p>
<p>针对大型文本编辑场景，CQRS 拆分读写模型，增量保存修改记录并通过事件合并，减少网络传输数据量，同时支持任意版本的历史数据恢复。</p>
</li>
</ul>
</details>

<details open>
<summary>不适用场景</summary>


<ul>
<li>简单 CRUD 系统（如小型管理后台）</li>
<li>强一致性要求的金融交易场景（如实时扣款）</li>
<li>团队缺乏事件驱动架构经验时</li>
</ul>
</details>

<h3>一致性</h3>
<h4>聚合内事务实现</h4>
<ul>
<li>聚合内事务控制不要放在应用层，会使应用层承担过多的责任。应用层应专注于协调领域对象和基础设施以完成业务操作，不应过多涉及数据访问和事务控制的细节。</li>
<li>聚合内事务控制可以交给 <code>Repository</code> 来实现，采用乐观锁解决并发问题，可以基于版本号和时间戳，一般重试 1-3 次即可。</li>
</ul>
<h4>聚合间事务实现</h4>
<ul>
<li><p>聚合间控制可以单独建立一个领域服务 Domain Service 来完成。</p>
</li>
<li><p>对于实时性要求不高，仅需最终一致性，可以使用<strong>本地消息表</strong>或者<strong>最大努力通知</strong>的方案。</p>
</li>
<li><p>对于实时性一致性要求比较高，可以采用 <strong>TCC（Try-Confirm-Cancel）</strong> 事务方案。</p>
</li>
<li><p>对于长事务场景，或者涉及外部系统、遗留系统，可以考虑 <strong>Saga</strong> 事务方案。</p>
<blockquote>
<p>Saga 将事务分为多个事务，这些分支事务按照一定的顺序执行。当某个分支事务执行成功后，会通过消息通知下一个分支执行；当某个分支事务执行失败时，会按照正常事务执行顺序的相反方向进行一系列的补偿操作，以确保全局事务的一致性。</p>
</blockquote>
</li>
</ul>
<h2>战略设计</h2>
<h3>事件风暴</h3>
<h4>核心概念与元素</h4>
<table>
<thead>
<tr>
<th>元素名称</th>
<th>颜色标识</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td><font color="#FFA500"><strong>领域事件（Domain Event）</strong></font></td>
<td>橙色</td>
<td>表示已发生的业务事实，以“动词过去式”命名（如“订单已提交”），是事件风暴的核心起点。</td>
</tr>
<tr>
<td><font color="#00008B"><strong>命令（Command）</strong></font></td>
<td>深蓝色</td>
<td>触发领域事件的操作或意图（如“提交订单”），通常由用户或系统触发。</td>
</tr>
<tr>
<td><font color="#FFFF00"><strong>参与者（Actor）</strong></font></td>
<td>黄色</td>
<td>执行命令的角色，包括用户、部门或外部系统（如“客户”触发支付命令）。</td>
</tr>
<tr>
<td><font color="#FFC0CB"><strong>外部系统（External System）</strong></font></td>
<td>粉色</td>
<td>与当前系统交互的第三方服务（如支付网关回调生成事件）。</td>
</tr>
<tr>
<td><font color="#800080"><strong>策略（Policy）</strong></font></td>
<td>紫色</td>
<td>业务规则或约束条件（如“库存不足时取消订单”），决定事件触发的逻辑。</td>
</tr>
<tr>
<td><font color="#008000"><strong>读模型（Read Model）</strong></font></td>
<td>绿色</td>
<td>为查询优化的数据视图（如“用户订单列表”），支持决策展示。</td>
</tr>
<tr>
<td><font color="#FFD700"><strong>聚合（Aggregate）</strong></font></td>
<td>大黄色</td>
<td>业务对象集合（如“订单聚合”包含订单项和状态），维护一致性和完整性。</td>
</tr>
<tr>
<td><font color="#FF0000"><strong>问题（Question）</strong></font></td>
<td>红色</td>
<td>未达成共识的争议点（如事件定义分歧），需后续专项讨论。</td>
</tr>
</tbody></table>
<h4>实施流程与步骤</h4>
<ol>
<li><p><strong>准备工作</strong></p>
<ul>
<li><strong>参与人员</strong>：业务专家、开发、产品、测试等跨职能角色，需领域专家主导。</li>
<li><strong>物料</strong>：多色便签、白板、马克笔，线上工具辅助远程协作。</li>
</ul>
</li>
<li><p><strong>识别领域事件</strong>
团队通过头脑风暴罗列所有可能事件（如电商场景的“订单已创建”“库存已扣减”），按时间轴排列，争议事件用红色便签标记并暂存。</p>
</li>
<li><p><strong>补充命令与角色</strong>
为每个事件关联触发命令及执行者（如“客户”执行“支付订单”命令生成“支付完成”事件），区分内部操作与外部系统调用。</p>
</li>
<li><p><strong>定义策略与读模型</strong>
添加业务规则（如“订单金额 ≥1000 元需审核”）和数据展示需求（如“实时库存看板”）。</p>
</li>
<li><p><strong>构建聚合与划分子域</strong>
将相关事件、命令归类为聚合（如“支付聚合”），划分限界上下文（如“订单服务”“库存服务”），明确微服务边界。</p>
</li>
</ol>
<h4>注意事项</h4>
<ol>
<li><p><strong>事件粒度的把控</strong>：避免过度细化（如“用户已睁眼&quot;）或过于宽泛（如“订单已修改”），需聚焦业务关键节点。</p>
</li>
<li><p><strong>争议处理与迭代</strong>：对未达成共识的事件标记为“问题”（红色便签），后续专题讨论；定期回顾模型，修正错误或补充遗漏。</p>
</li>
<li><p><strong>技术实现衔接</strong> ：事件风暴的输出需转化为代码模型，例如通过事件溯源（Event Sourcing）持久化事件流，或结合 CQRS 分离读写逻辑。</p>
</li>
</ol>
<h3>C4 架构模型</h3>
<p><a href="https://c4model.com/">https://c4model.com/</a></p>
<table>
<thead>
<tr>
<th><strong>层级</strong></th>
<th><strong>核心目标</strong></th>
<th><strong>受众</strong></th>
<th><strong>关键元素</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Context（上下文）</strong></td>
<td>描述系统与外部实体（用户、第三方系统）的交互关系</td>
<td>非技术人员（如业务方、客户）</td>
<td>系统边界、用户角色、外部依赖（如支付网关）</td>
</tr>
<tr>
<td><strong>Container（容器）</strong></td>
<td>展示系统内部的高阶技术组件（进程级单元）</td>
<td>技术管理者、架构师</td>
<td>Web 应用、数据库、消息队列等独立进程单元，关注技术选型与通信协议（如 REST API、gRPC）</td>
</tr>
<tr>
<td><strong>Component（组件）</strong></td>
<td>细化容器内部的业务模块与交互逻辑</td>
<td>开发团队</td>
<td>服务、模块、接口（如订单服务、库存服务），强调职责划分与依赖关系</td>
</tr>
<tr>
<td><strong>Code（代码）</strong></td>
<td>展示组件实现的代码结构</td>
<td>开发者</td>
<td>类、方法、数据库表（如 UML 类图、ER 图），通常由 IDE 工具自动生成</td>
</tr>
</tbody></table>
<p>除了四层核心视图，C4 模型还提供：</p>
<ul>
<li><strong>部署图</strong>：展示容器在物理环境中的分布（如 Kubernetes 集群部署）。</li>
<li><strong>动态图</strong>：描述业务流程（如用户下单到支付完成的时序交互）。</li>
<li><strong>系统景观图</strong>：多系统协同的全局视图（如企业级中台架构）。</li>
</ul>
<!-- tab Context -->

<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/SystemContext-20250312173256060.png" alt=""></p>
<!-- tab Container -->

<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/Containers-20250312173305921.png" alt=""></p>
<!-- tab Component -->

<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/Components-20250312173320890.png" alt=""></p>
<!-- tab Code -->

<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/MainframeBankingSystemFacade-20250312173313572.png" alt=""></p>
<h2>实践案例</h2>
<p>参考作者的 <a href="https://github.com/feiniaojin/ddd-archetype">ddd-archetype</a> ，笔者实现了一个 Go 版本的 <code>ddd-archetype-go</code>：</p>
<p><a href="https://github.com/hedon-go-road/ddd-archetype-go">https://github.com/hedon-go-road/ddd-archetype-go</a></p>
<p>整体架构如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/ddd-ruoyi.drawio.png" alt=""></p>
]]></content:encoded>
    </item>
    <item>
      <title>Go 1.24 新特性解读：使用 testing/synctest 优雅地测试并发代码</title>
      <link>https://hedon.top/blog/go-lib-synctest/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-lib-synctest/</guid>
      <pubDate>Thu, 06 Mar 2025 15:00:18 GMT</pubDate>
      <description>本文介绍了 Go 1.24 版本引入的实验性包 testing/synctest，并详细讲解了如何使用它优雅地测试并发代码。</description>
      <category>Go</category><category>单元测试</category>
      <content:encoded><![CDATA[<p>在 Go 语言开发中，并发编程一直是其最引人注目的特性之一。然而，如何有效地测试并发代码却常常让开发者感到头疼。Go 1.24 版本引入的实验性包 <code>testing/synctest</code> 为这个问题带来了优雅的解决方案。今天，让我们深入了解这个新特性。</p>
<h2>并发测试的传统困境</h2>
<p>在介绍新方案之前，我们先看看传统的并发测试面临哪些问题：</p>
<pre><code class="language-go">func TestTraditional(t *testing.T) {
    done := false
    go func() {
        // 执行某些操作
        time.Sleep(100 * time.Millisecond)
        done = true
    }()

    // 等待操作完成
    time.Sleep(200 * time.Millisecond)
    if !done {
        t.Fatal(&quot;操作未完成&quot;)
    }
}
</code></pre>
<p>这种方式存在明显的问题：</p>
<ol>
<li><strong>时间依赖</strong>：需要通过 Sleep 等待，导致测试运行缓慢</li>
<li><strong>不稳定性</strong>：在不同环境下可能产生不同结果</li>
<li><strong>精确性差</strong>：难以准确把握检查时机</li>
</ol>
<h2>synctest：优雅的解决方案</h2>
<p><code>testing/synctest</code> 包通过两个核心函数改变了这一切：</p>
<ul>
<li><code>Run()</code>: 创建隔离的测试环境（bubble）</li>
<li><code>Wait()</code>: 等待所有 goroutine 进入稳定状态</li>
</ul>
<p>让我们看看如何改写上面的测试：</p>
<pre><code class="language-go">func TestWithSynctest(t *testing.T) {
    synctest.Run(func() {
        done := false
        go func() {
            // 执行某些操作
            time.Sleep(100 * time.Millisecond)
            done = true
        }()

        synctest.Wait()  // 等待所有 goroutine 进入稳定状态
        if !done {
            t.Fatal(&quot;操作未完成&quot;)
        }
    })
}
</code></pre>
<h2>深入理解 Wait 机制</h2>
<h3>Wait 的本质</h3>
<p>很多开发者初次接触 <code>Wait()</code> 时可能会感到困惑：它到底在等待什么？什么时候会返回？</p>
<p>想象一个场景：你在拍摄一张全家福，需要等待所有人都找到自己的位置，站好不动，才能按下快门。<code>Wait()</code> 就像这个摄影师，它在等待所有 goroutine（就像照片中的人）都进入一个稳定的状态（站好不动）。</p>
<pre><code class="language-go">synctest.Run(func() {
    // 类比：三个人要拍全家福
    go person1()  // 第一个人找位置
    go person2()  // 第二个人找位置
    go person3()  // 第三个人找位置

    synctest.Wait()  // 等待所有人都站好不动
    // 这时可以安全地&quot;按下快门&quot;（检查程序状态）
})
</code></pre>
<h3>为什么需要 Wait？</h3>
<p>在并发程序中，我们经常需要在特定时刻检查程序状态。但是，如果某些 goroutine 还在运行，这个状态可能随时发生变化。<code>Wait()</code> 通过确保所有 goroutine 都进入稳定状态，为我们提供了一个&quot;快照&quot;时刻。</p>
<pre><code class="language-go">synctest.Run(func() {
    result := false
    go func() {
        // 模拟耗时操作
        time.Sleep(1 * time.Second)
        result = true
    }()

    synctest.Wait()  // 等待 goroutine 进入稳定状态
    // 此时 result 的值是确定的，不会突然改变
    fmt.Println(result)
})
</code></pre>
<h3>持久阻塞的概念</h3>
<p>哪些操作会导致持久阻塞？</p>
<ul>
<li>channel 操作（同一 bubble 内）</li>
<li>time.Sleep</li>
<li>sync.WaitGroup.Wait</li>
<li>sync.Cond.Wait</li>
</ul>
<p>哪些操作不算持久阻塞？</p>
<ul>
<li>互斥锁操作</li>
<li>外部 I/O</li>
<li>外部 channel 操作</li>
</ul>
<h2>虚拟时钟：测试的神器</h2>
<p><code>synctest</code> 的另一个强大特性是虚拟时钟机制。在 bubble 内部，所有时间相关的操作都使用虚拟时钟，这意味着：</p>
<pre><code class="language-go">synctest.Run(func() {
    // 看似等待24小时
    time.Sleep(24 * time.Hour)
    // 实际上立即执行完成！
})
</code></pre>
<p>这个特性让我们能够：</p>
<ol>
<li>快速测试长时间操作</li>
<li>精确控制时间流逝</li>
<li>避免测试的不确定性</li>
</ol>
<h2>实战案例：深入理解 HTTP 100 Continue 测试</h2>
<h3>背景知识</h3>
<p>HTTP 的 100 Continue 机制是一个优化大文件上传的协议特性：</p>
<ol>
<li>客户端想上传大文件时，先发送带有 &quot;Expect: 100-continue&quot; 头的请求</li>
<li>服务器可以决定是否接受这个上传：<ul>
<li>如果接受，返回 &quot;100 Continue&quot;</li>
<li>如果拒绝，可以直接返回错误状态码</li>
</ul>
</li>
<li>客户端根据服务器的响应决定是否发送文件内容</li>
</ol>
<h3>详细测试实现</h3>
<pre><code class="language-go">func TestHTTPContinue(t *testing.T) {
    synctest.Run(func() {
        // 第一步：建立测试环境
        srvConn, cliConn := net.Pipe()
        defer srvConn.Close()
        defer cliConn.Close()

        // 第二步：配置 HTTP 客户端
        tr := &amp;http.Transport{
            DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
                return cliConn, nil
            },
            ExpectContinueTimeout: 5 * time.Second,
        }

        // 第三步：准备测试数据
        body := &quot;request body&quot;

        // 第四步：发送请求
        go func() {
            req, _ := http.NewRequest(&quot;PUT&quot;, &quot;http://test.tld/&quot;,
                strings.NewReader(body))
            req.Header.Set(&quot;Expect&quot;, &quot;100-continue&quot;)
            resp, err := tr.RoundTrip(req)
            if err != nil {
                t.Errorf(&quot;请求失败: %v&quot;, err)
            } else {
                resp.Body.Close()
            }
        }()

        // 第五步：验证请求头
        req, err := http.ReadRequest(bufio.NewReader(srvConn))
        if err != nil {
            t.Fatalf(&quot;读取请求失败: %v&quot;, err)
        }

        // 第六步：验证请求体未发送
        var gotBody strings.Builder
        go io.Copy(&amp;gotBody, req.Body)
        synctest.Wait()
        if got := gotBody.String(); got != &quot;&quot; {
            t.Fatalf(&quot;在发送 100 Continue 之前，意外收到请求体: %q&quot;, got)
        }

        // 第七步：发送 100 Continue
        srvConn.Write([]byte(&quot;HTTP/1.1 100 Continue\r\n\r\n&quot;))

        // 第八步：验证请求体
        synctest.Wait()
        if got := gotBody.String(); got != body {
            t.Fatalf(&quot;收到的请求体 %q，期望 %q&quot;, got, body)
        }

        // 第九步：完成请求
        srvConn.Write([]byte(&quot;HTTP/1.1 200 OK\r\n\r\n&quot;))
    })
}
</code></pre>
<h3>测试的关键点解析</h3>
<ol>
<li><p><strong>使用 net.Pipe()</strong></p>
<ul>
<li>创建内存中的网络连接</li>
<li>避免依赖真实网络</li>
<li>保证测试的可重复性</li>
</ul>
</li>
<li><p><strong>请求发送过程</strong></p>
<ul>
<li>在独立的 goroutine 中发送请求</li>
<li>设置 &quot;Expect: 100-continue&quot; 头</li>
<li>准备要发送的请求体</li>
</ul>
</li>
<li><p><strong>验证关键行为</strong></p>
<ul>
<li>确认请求头正确发送</li>
<li>验证请求体在收到 100 Continue 之前未发送</li>
<li>验证请求体在收到 100 Continue 后正确发送</li>
</ul>
</li>
<li><p><strong>使用 Wait 的时机</strong></p>
<ul>
<li>在检查请求体之前调用 Wait</li>
<li>确保所有数据传输操作都已完成或阻塞</li>
<li>获得稳定的程序状态进行验证</li>
</ul>
</li>
</ol>
<h2>使用建议</h2>
<ol>
<li><strong>明确边界</strong>：理解什么操作会导致持久阻塞，什么不会</li>
<li><strong>清理资源</strong>：确保所有 goroutine 在测试结束前退出</li>
<li><strong>模拟 I/O</strong>：使用内存管道替代真实网络连接</li>
<li><strong>合理使用 Wait</strong>：在需要检查状态的关键点调用</li>
</ol>
<h2>注意事项</h2>
<ol>
<li>目前是实验性功能，需要设置 <code>GOEXPERIMENT=synctest</code></li>
<li>不支持测试真实的外部 I/O 操作</li>
<li>互斥锁操作不被视为持久阻塞</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>直播系统推拉流原理</title>
      <link>https://hedon.top/blog/live-stream-push-pull/</link>
      <guid isPermaLink="true">https://hedon.top/blog/live-stream-push-pull/</guid>
      <pubDate>Tue, 04 Mar 2025 11:34:09 GMT</pubDate>
      <description>本文介绍了直播系统推拉流的基本原理，包括推流和拉流的过程、协议选择、关键指标等。</description>
      <category>直播系统</category><category>解决方案</category>
      <content:encoded><![CDATA[<h2>直播系统推拉流原理概述</h2>
<p>直播系统的核心功能是实现主播端视频采集后的实时传输，以及观众端的实时观看。整个过程主要包含：推流、服务器处理、拉流三个环节。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250304121833913.png" alt="直播系统架构"></p>
<h3>核心概念解析</h3>
<h4>1. 推流（Push）</h4>
<p>推流是指主播端将视频数据传输到服务器的过程。主要使用 <code>RTMP</code> 协议（Real Time Messaging Protocol）。</p>
<p>比如可能有如下推流 URL 的生成逻辑：</p>
<pre><code class="language-java">public static String generatePushUrl(String pushDomain, String pushKey, String appName, String streamName, long expireTime) {
    String pushUrl = &quot;&quot;;
    // 推流域名未开启鉴权功能的情况下
    if (StringUtils.isBlank(pushKey)) {
        pushUrl = &quot;rtmp://&quot; + pushDomain + &quot;/&quot; + appName + &quot;/&quot; + streamName;
    } else {
        long timeStamp = System.currentTimeMillis() / 1000L + expireTime;
        String stringToMd5 = &quot;/&quot; + appName + &quot;/&quot; + streamName + &quot;-&quot; + Long.toString(timeStamp) + &quot;-0-0-&quot; + pushKey;
        String authKey = md5(stringToMd5);
        pushUrl = &quot;rtmp://&quot; + pushDomain + &quot;/&quot; + appName + &quot;/&quot; + streamName + &quot;?auth_key=&quot; + Long.toString(timeStamp) + &quot;-0-0-&quot; + authKey;
    }
    return pushUrl;
}
</code></pre>
<p>推流地址的组成部分：</p>
<ul>
<li>rtmp:// - 协议</li>
<li>pushDomain - 推流域名</li>
<li>appName - 应用名称</li>
<li>streamName - 流名称</li>
<li>auth_key - 鉴权参数（可选）</li>
</ul>
<h4>2. 拉流（Pull）</h4>
<p>拉流是观众观看直播的过程。支持多种协议：</p>
<ul>
<li>RTMP：延迟低（1-3秒）</li>
<li>HTTP-FLV：延迟适中（2-5秒）</li>
<li>HLS(m3u8)：延迟较高（5-30秒）</li>
</ul>
<pre><code class="language-java">// FLV 格式
public static String generalPullUrlFlv(String pullDomain, String pullKey, String appName, String streamName, long expireTime) {
    if (StringUtils.isBlank(pullKey)) {
        return &quot;http://&quot; + pullDomain + &quot;/&quot; + appName + &quot;/&quot; + streamName + &quot;.flv&quot;;
    }
    // ... 鉴权逻辑
}

// HLS 格式
public static String generalPullUrlHls(String pullDomain, String pullKey, String appName, String streamName, long expireTime) {
    if (StringUtils.isBlank(pullKey)) {
        return &quot;http://&quot; + pullDomain + &quot;/&quot; + appName + &quot;/&quot; + streamName + &quot;.m3u8&quot;;
    }
    // ... 鉴权逻辑
}
</code></pre>
<h3>直播流程</h3>
<ol>
<li><p><strong>主播开播</strong>：</p>
<ul>
<li>系统生成唯一的 streamId</li>
<li>生成带鉴权的推流地址</li>
<li>主播端推流软件（如 OBS）开始推流</li>
</ul>
</li>
<li><p><strong>服务器处理</strong>：</p>
<ul>
<li>流媒体服务器接收推流</li>
<li>进行转码、录制等处理</li>
<li>将流分发到 CDN 节点</li>
</ul>
</li>
<li><p><strong>观众观看</strong>：</p>
<ul>
<li>获取对应格式的拉流地址</li>
<li>通过播放器拉取直播流</li>
<li>实现实时观看</li>
</ul>
</li>
</ol>
<h3>实现建议</h3>
<ol>
<li><p><strong>选择合适的流媒体服务器</strong>：</p>
<ul>
<li>商业云服务：阿里云直播、腾讯云直播</li>
<li>开源方案：SRS、Nginx-RTMP</li>
</ul>
</li>
<li><p><strong>根据业务场景选择协议</strong>：</p>
<ul>
<li>普通直播：HTTP-FLV</li>
<li>低延迟场景：RTMP</li>
<li>移动端兼容性要求高：HLS</li>
</ul>
</li>
<li><p><strong>关注关键指标</strong>：</p>
<ul>
<li>延迟控制</li>
<li>卡顿率</li>
<li>首屏时间</li>
<li>带宽成本</li>
</ul>
</li>
<li><p><strong>安全鉴权：</strong></p>
<ul>
<li>防盗链机制</li>
</ul>
</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>网络数据包的完整旅程：从发送到接收的全过程</title>
      <link>https://hedon.top/blog/net-data-journey/</link>
      <guid isPermaLink="true">https://hedon.top/blog/net-data-journey/</guid>
      <pubDate>Sat, 01 Mar 2025 12:58:37 GMT</pubDate>
      <description>通过一个 HTTP 请求与响应，深入探索背后的网络通信机制，从 DNS 解析、TCP 连接到数据封装与传输，全面解析数据包如何穿越局域网与公网到达目标服务器。</description>
      <category>计算机网络</category><category>计算机基础</category>
      <content:encoded><![CDATA[<p>不知道你是否曾经好奇你发出的一个网络请求，最终是怎么到达对端，并将你想要的信息返回给你的。本文将通过一个 HTTP 请求与响应，从一个比较宏观的角度来梳理下一个数据包在网络中的旅途，旨在帮助笔者和各位读者建立起对计算机网络模型一个比较全面的认知。</p>
<blockquote>
<p>本文参考极客时间《网络架构实战课（谢友鹏）》，再根据笔者的知识面、按照个人理解，补充更多丰富具体的内容。</p>
</blockquote>
<h2>实战</h2>
<p>好，那我们直接开始，我们先使用 <code>curl</code> 来发起一个 HTTP 请求，看看这过程中发生了什么：</p>
<pre><code class="language-sh">curl -o /dev/null -v https://example.com
</code></pre>
<p>在笔者的 mac 机器上，这行命令的输出如下：</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250301131247066.png" data-fancybox="true" alt="curl https//example.com 结果分析" style="width: 100%; height: auto;">

<p>当我们发起请求时，首先会对 <code>example.com</code> 进行域名解析，分别尝试解析到它的 <code>IPv6</code> 和 <code>IPv4</code>。</p>
<pre><code class="language-bash">* IPv6: (none)
* IPv4: 23.215.0.138, 96.7.128.198, 23.192.228.80, 23.192.228.84, 23.215.0.136, 96.7.128.175
</code></pre>
<p>因为我们使用的是 <code>https</code> 协议，所以会尝试跟这些地址的 <code>443</code> 端口建立 <code>TCP</code> 连接，（如果是 <code>https</code> 则跟 <code>80</code> 端口），并进行 <code>TLS 握手验证</code>，如果成功了，则会建立 <code>TCP</code> 连接。</p>
<pre><code class="language-go">*   Trying 23.215.0.138:443...
...[TLS handshake]
* Connected to example.com (23.215.0.138) port 443
</code></pre>
<p>建立连接后，就开始发送 <code>HTTP</code> 请求，这里使用的是 HTTP2 协议。</p>
<pre><code class="language-bash">* using HTTP/2
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0* [HTTP/2] [1] OPENED stream for https://example.com/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: example.com]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.10.1]
* [HTTP/2] [1] [accept: */*]
} [5 bytes data]
&gt; GET / HTTP/2
&gt; Host: example.com
&gt; User-Agent: curl/8.10.1
&gt; Accept: */*
&gt;
* Request completely sent off
</code></pre>
<p>最后，服务器返回了 HTTP 200 OK 的响应。</p>
<pre><code class="language-bash">{ [5 bytes data]
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
{ [265 bytes data]
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
{ [265 bytes data]
&lt; HTTP/2 200
&lt; content-type: text/html
&lt; etag: &quot;84238dfc8092e5d9c0dac8ef93371a07:1736799080.121134&quot;
&lt; last-modified: Mon, 13 Jan 2025 20:11:20 GMT
&lt; cache-control: max-age=1374
&lt; date: Sat, 01 Mar 2025 05:01:03 GMT
&lt; alt-svc: h3=&quot;:443&quot;; ma=93600,h3-29=&quot;:443&quot;; ma=93600,quic=&quot;:443&quot;; ma=93600; v=&quot;43&quot;
&lt; content-length: 1256
&lt;
} [5 bytes data]
100  1256  100  1256    0     0   1172      0  0:00:01  0:00:01 --:--:--  1172
* Connection #0 to host example.com left intact
</code></pre>
<p>要进一步了解网络数据包的细节，我们可以通过抓包工具进行分析。你可以使用 <code>tcpdump</code> 抓取与 example.com 的通信数据包。</p>
<p>运行如下命令：</p>
<pre><code class="language-bash">sudo tcpdump host example.com -w example.com.pcap
</code></pre>
<p>然后再另外一个命令行窗口再次发送请求：</p>
<pre><code class="language-bash">curl -o /dev/null -v https://example.com
</code></pre>
<p>回到 <code>tcpdump</code> 的窗口并结束监听，我们就会得到 <code>example.com.pcap</code> 的抓包文件，可以通过 <code>Wireshark</code> 软件打开该文件：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250301133015523.png" alt="tcpdump 分析结果"></p>
<h2>网络分层</h2>
<p>通过上述实验，我们可以清晰看到网络是分层的，主流的分层模型有 OSI 七层模型和 TCP/IP 四层模型，它们的对应关系及常见的协议如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/OSI-vs-TCP.png" alt="OSI-vs-TCP/IP"></p>
<p>我们在 Wireshark 上方随便选择一个数据包，使用鼠标点击下方左侧的每一层，可以在右侧看到对应的层级数据。从链路层到应用层，每一层的数据都是对下一层的进一步封装。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250301134103404.png" alt="数据包封装"></p>
<p>在发送方，用户程序需要传输的数据会经过逐层封装。首先添加应用层的 HTTP Header，然后是传输层的 TCP Header，接着是网络层的 IP Header，最后在链路层添加以太网帧的帧头和帧尾，包括源 MAC 地址、目的 MAC 地址等链路层信息，最终形成网络中传输的完整数据包。</p>
<p>在接收方，数据包会按相反的顺序逐层解封装。接收设备从链路层开始解析数据，依次解读网络层、传输层和应用层的信息，最后将数据传递给接收方的应用程序。</p>
<p>如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/cn1.png" alt="数据包封装 & 解析"></p>
<p>我们在 Wireshark 中点开下面的每一层，可以看到如下信息，我在图标注了最重要的几个信息：<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250301135433547.png" alt="网络数据包关键信息"></p>
<h2>网络之旅</h2>
<p>经过上述实验，我们可以做个小总结：</p>
<p>通过上述实验，我们可以清晰理解数据包的传输过程：</p>
<ul>
<li>HTTP 请求是网络通信的应用层内容，它需要通过各层网络协议的封装才能实现端到端传输。</li>
<li>从发送方角度，数据传输遵循一个明确的逻辑顺序：首先将域名（example.com）解析为 IP 地址，然后基于该 IP 地址和目标端口（443）建立 TCP 连接，接着找到目标 IP 的 MAC 地址，最终由网卡将完整封装的数据包发送到网络中。</li>
<li>从接收方角度，服务器处理数据包的过程是一个自下而上的解封装过程：数据链路层接收到的帧包含源 MAC 地址，网络层解析出 IPv4 地址和协议类型，传输层识别出 TCP 协议和源端口号，最终在应用层获取并处理 HTTP 请求数据。服务器根据这些信息构建响应，并按相反顺序封装返回给客户端。</li>
</ul>
<p>这种分层处理机制确保了网络通信的灵活性和可靠性，每层只需关注自己的职责，共同完成端到端的数据传输任务。</p>
<p>好，那么这里就有 2 个最关键的问题：</p>
<ol>
<li>如何通过域名获得 IP 地址？</li>
<li>如何通过 IP 地址获取 MAC 地址？</li>
</ol>
<h3>DNS 解析</h3>
<p>DNS（Domain Name System，域名系统）是互联网的一项核心服务，它允许我们使用易记的域名（如 <code>example.com</code>）而不是数字 IP 地址（如 <code>93.184.216.34</code>）来访问网站。</p>
<p>当你在浏览器中输入一个域名时，DNS 解析按以下步骤进行：</p>
<ol>
<li><p><strong>浏览器缓存检查</strong>：浏览器首先检查自己的缓存，看是否已经存储了该域名对应的 IP 地址。</p>
</li>
<li><p><strong>操作系统缓存检查</strong>：如果浏览器缓存中没有，系统会检查操作系统的 DNS 缓存（如 Windows 的 DNS Client 服务）。</p>
</li>
<li><p><strong>路由器缓存检查</strong>：若系统缓存中也没有，请求会被发送到你的路由器，它也维护着一个 DNS 缓存。</p>
</li>
<li><p><strong>ISP DNS 服务器查询</strong>：如果以上缓存都未命中，请求会被发送到你的 ISP（互联网服务提供商）的 DNS 服务器。</p>
</li>
<li><p><strong>递归查询</strong>：ISP 的 DNS 服务器会执行递归查询：</p>
<ul>
<li>首先查询根域名服务器（Root DNS Server）</li>
<li>根服务器会引导到顶级域名服务器（TLD DNS Server，如 .com, .net, .org 等）</li>
<li>顶级域名服务器会引导到权威域名服务器（Authoritative DNS Server）</li>
<li>权威服务器会返回该域名的 IP 地址</li>
</ul>
</li>
<li><p><strong>结果返回与缓存</strong>：一旦获取到 IP 地址，它会被沿着查询路径返回，并在各个层级上缓存一段时间（由 TTL 值决定）。</p>
</li>
</ol>
<p>你可以使用以下工具查询 DNS 信息：</p>
<ul>
<li><strong>nslookup</strong>：<code>nslookup example.com</code></li>
<li><strong>dig</strong>：<code>dig example.com</code></li>
<li><strong>host</strong>：<code>host example.com</code></li>
</ul>
<p>这些工具可以帮助你了解域名的解析过程和结果。</p>
<pre><code class="language-bash">➜  ~ host example.com
example.com has address 23.215.0.138
example.com has address 23.192.228.84
example.com has address 23.215.0.136
example.com has address 23.192.228.80
example.com has address 96.7.128.175
example.com has address 96.7.128.198
example.com has IPv6 address 2600:1408:ec00:36::1736:7f31
example.com has IPv6 address 2600:1406:3a00:21::173e:2e65
example.com has IPv6 address 2600:1406:3a00:21::173e:2e66
example.com has IPv6 address 2600:1406:bc00:53::b81e:94c8
example.com has IPv6 address 2600:1406:bc00:53::b81e:94ce
example.com has IPv6 address 2600:1408:ec00:36::1736:7f24
example.com mail is handled by 0 .
</code></pre>
<p>通过 DNS 解析将域名转换为 IP 地址后，网络通信的下一步就是确定如何将数据包发送到目标 IP 地址，这就需要用到 ARP 协议来获取目标设备的 MAC 地址。</p>
<h3>穿越客户端局域网</h3>
<p><strong>当我们发送一个网络请求时，数据包如何找到离开家庭/办公网络的&quot;出口&quot;？</strong></p>
<p>数据包首先需要解决的是&quot;该往哪走&quot;的问题：</p>
<ol>
<li><p><strong>问题：我需要直接联系目标设备还是找个&quot;中介&quot;？</strong></p>
<p>解决方案：子网判断</p>
<ul>
<li>设备会比较目标 IP 与自己的 IP 和子网掩码</li>
<li>就像判断收件人是不是住在同一个小区</li>
</ul>
</li>
<li><p><strong>问题：如何找到同一网络中的设备？</strong></p>
<p>解决方案：ARP 协议</p>
<ul>
<li>类似于小区广播：&quot;谁是 202 号房的？请告诉我你的门牌号！&quot;</li>
<li>目标设备回应自己的 MAC 地址（设备的&quot;身份证号&quot;）</li>
</ul>
</li>
<li><p><strong>问题：目标在远方，如何离开本地网络？</strong></p>
<p>解决方案：默认网关</p>
<ul>
<li>就像不认识远方收件人的地址，先交给小区门卫（路由器）</li>
<li>数据包头上标注最终目的地 IP，但先送到网关的 MAC 地址</li>
</ul>
</li>
<li><p><strong>问题：数据如何在本地网络中转发？</strong></p>
<p>解决方案：交换机的 MAC 地址表</p>
<ul>
<li>交换机就像小区内的快递员，记住了每家每户的门牌号</li>
<li>它查表后将包裹精确送到对应的门口，不会打扰其他住户</li>
</ul>
</li>
</ol>
<p>简单来说，数据包在本地网络中的旅程就像是快递先确认收件人是否在同一小区，如果是，直接送达；如果不是，则交给小区出口的保安，由他负责进一步转发。</p>
<h3>穿越公网</h3>
<p><strong>数据包离开了本地网络，如何在茫茫互联网中找到遥远的目标服务器？</strong></p>
<p>数据包在互联网上的旅程就像一次跨国旅行：</p>
<ol>
<li><p><strong>问题：如何从私人区域进入公共世界？</strong></p>
<p>解决方案：NAT（网络地址转换）</p>
<ul>
<li><p>就像多人共用一个护照出国，本地设备共享一个公网 IP</p>
</li>
<li><p>路由器会记住谁发了什么请求，回程时能送回正确的设备</p>
</li>
</ul>
</li>
<li><p><strong>问题：互联网如此庞大复杂，谁来管理这些网络？</strong></p>
<p>解决方案：自治系统（Autonomous System, AS）</p>
<ul>
<li><p>AS 就像互联网世界的&quot;国家&quot;或&quot;独立王国&quot;</p>
</li>
<li><p>每个 AS 由单一技术管理机构控制（如 ISP、大企业或教育机构）</p>
</li>
<li><p>你的数据包首先进入你的 ISP 所在的 AS，然后可能穿越多个 AS</p>
</li>
<li><p>每个 AS 有唯一的 AS 号（ASN），如 AS7018(AT&amp;T) 或 AS8075(Microsoft)</p>
</li>
</ul>
</li>
<li><p><strong>问题：这些&quot;网络王国&quot;如何相互通信和合作？</strong></p>
<p>解决方案：BGP 协议(边界网关协议)</p>
<ul>
<li><p>BGP 是 AS 之间的&quot;外交语言&quot;，用于宣告路由信息</p>
</li>
<li><p>它告诉其他 AS：&quot;通过我可以到达这些网络&quot;</p>
</li>
<li><p>路由器根据 BGP 信息，决定数据包应该经过哪些 AS</p>
</li>
</ul>
</li>
<li><p><strong>问题：如何决定数据包在 AS 内部该走哪条路？</strong></p>
<p>解决方案：内部路由协议</p>
<ul>
<li><p>AS 内部使用 OSPF 或 IS-IS 等协议来找到最佳路径</p>
</li>
<li><p>路由器像城市中的交通指挥，根据&quot;路况&quot;决定下一个方向</p>
</li>
</ul>
</li>
<li><p><strong>问题：不同运营商之间如何连接？</strong></p>
<p>解决方案：互联网交换中心（IXP）</p>
<ul>
<li><p>就像不同航空公司在大型枢纽机场交换乘客</p>
</li>
<li><p>数据包在 IXP 从一个 AS “转机”到另一个 AS</p>
</li>
<li><p>这减少了路径长度，提高了传输效率</p>
</li>
</ul>
</li>
<li><p><strong>问题：我能知道我的数据经过了哪些地方吗？</strong></p>
<p>解决方案：路径追踪工具</p>
<ul>
<li><p>traceroute/tracert 就像给数据包装上 GPS</p>
</li>
<li><p>你可以看到数据包穿越的不同 AS 和路由器</p>
</li>
</ul>
</li>
</ol>
<p>互联网就像一个巨大的全球快递网络，你的数据包可能穿越多个国家、经过海底电缆，由不同的运营商接力传递，最终到达目的地的网络。</p>
<h3>穿越服务端局域网</h3>
<p><strong>数据包到达目标所在网络后，如何找到并到达最终的服务器？</strong></p>
<p>数据包抵达目的地网络，就像国际快递到达目标城市，还需要最后一段&quot;本地配送&quot;：</p>
<ol>
<li><p><strong>问题：如何确保只有合法请求能进入网络？</strong></p>
<p>解决方案：防火墙和安全策略</p>
<ul>
<li>就像机场海关，检查入境者是否符合入境条件</li>
<li>只有合法的数据包才能通过安全检查</li>
</ul>
</li>
<li><p><strong>问题：大型网站如何处理海量请求？</strong></p>
<p>解决方案：负载均衡</p>
<ul>
<li>像大型医院的分诊台，将病人分配到不同的医生处</li>
<li>根据服务器负载、用户位置等因素智能分发请求</li>
</ul>
</li>
<li><p><strong>问题：如何在数据中心复杂环境中找到目标服务器？</strong></p>
<p>解决方案：内部路由与最后一跳 ARP</p>
<ul>
<li>数据中心内部有自己的&quot;地图&quot;和&quot;道路系统&quot;</li>
<li>最后一个路由器会通过 ARP 找到服务器的具体位置</li>
</ul>
</li>
<li><p><strong>问题：现代云环境中，服务器可能是虚拟的，怎么处理？</strong></p>
<p>解决方案：虚拟网络</p>
<ul>
<li>物理服务器上可能运行多个虚拟机或容器</li>
<li>虚拟交换机将数据包准确送达虚拟环境中的目标应用</li>
</ul>
</li>
</ol>
<p>这就像国际快递最后的“最后一公里”配送 - 从目的地城市的分拣中心，经过层层筛选，最终送到收件人手中。</p>
<h3>总结</h3>
<p>网络请求就像一封国际信件的旅程：</p>
<ol>
<li><p>本地投递：从你家出发，判断收件人是否在同小区。如不在，交给小区出口的门卫（网关）。</p>
</li>
<li><p>国际运输：</p>
<ul>
<li>先经过你所在“国家”（你 ISP 的 AS）的海关（NAT）</li>
<li>然后可能穿越多个“国家”（不同的 AS）</li>
<li>各国海关（路由器）通过“国际条约”（BGP）决定包裹走向</li>
<li>有时通过“国际中转站”（IXP）快速转运到其他“国家”</li>
</ul>
</li>
<li><p>目的地配送：</p>
<ul>
<li><p>通过目的地“海关”（防火墙）入境检查</p>
</li>
<li><p>经过“分拣中心”（负载均衡器）分配处理人员</p>
</li>
<li><p>最终通过“本地快递员”（内部路由和交换）送达收件人手中</p>
</li>
</ul>
</li>
</ol>
<p>数据包就这样完成了客户端设备到服务器的全程旅行，然后服务器的响应再沿着类似的路径返回到客户端设备，完成整个请求-响应循环。</p>
<h2>参考</h2>
<ul>
<li><p><a href="https://time.geekbang.org/column/article/846257">极客时间《网络架构实战课》</a></p>
</li>
<li><p><a href="https://www.geeksforgeeks.org/difference-between-osi-model-and-tcp-ip-model/">Difference Between OSI Model and TCP/IP Model</a></p>
</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>解决方案丨游戏后端中的 Push-ACK 机制设计与内存优化</title>
      <link>https://hedon.top/blog/solution-push-ack/</link>
      <guid isPermaLink="true">https://hedon.top/blog/solution-push-ack/</guid>
      <pubDate>Thu, 27 Feb 2025 20:31:45 GMT</pubDate>
      <description>本文介绍了游戏后端中的 Push-ACK 机制的设计与实现，特别关注如何避免内存暴涨问题，分享了在多个大型游戏项目中积累的经验与教训。</description>
      <category>解决方案</category><category>游戏后端</category><category>push-ack</category>
      <content:encoded><![CDATA[<h2>引言</h2>
<p>在现代在线游戏开发中，服务器与客户端之间实时、可靠的通信机制是游戏体验的基石。作为一名游戏后端开发者，我曾经遇到过这样的场景：更新了一个公会系统的新功能，服务器需要向成千上万个在线玩家推送公会状态变更。短短几小时后，服务器内存使用率飙升至 90%，系统告警不断。问题出在哪里？Push 消息的可靠性机制实现不当导致了内存泄漏。</p>
<p>本文将深入探讨游戏后端中 Push-ACK 机制的设计与实现，特别关注如何避免内存暴涨问题，分享我在多个大型游戏项目中积累的经验与教训。</p>
<h2>背景：为什么需要应用层的 ACK 机制？</h2>
<p>TCP 协议确实提供了可靠的数据传输保证，包括数据包的序列号、校验和、超时重传等机制。那么，为什么我们还需要在应用层实现额外的 ACK 机制呢？</p>
<h3>TCP 可靠性的边界</h3>
<p>TCP 只能保证<strong>数据被送达到客户端的网络栈</strong>，但无法保证：</p>
<ol>
<li>数据被客户端应用程序正确处理</li>
<li>处理过程中没有出现异常</li>
<li>客户端的业务逻辑正确执行</li>
</ol>
<p>想象这样一个场景：服务器向玩家推送了一条&quot;获得稀有装备&quot;的消息，TCP 确保了数据送达客户端，但如果客户端在处理这个消息时崩溃了呢？对于游戏这类状态敏感的应用，我们需要知道消息是否被<strong>成功处理</strong>，而不仅仅是<strong>成功传输</strong>。</p>
<h3>业务可靠性需求</h3>
<p>实际游戏开发中，不同类型的消息有不同的可靠性需求：</p>
<table>
<thead>
<tr>
<th>消息类型</th>
<th>示例</th>
<th>可靠性需求</th>
</tr>
</thead>
<tbody><tr>
<td>关键状态变更</td>
<td>道具获取、货币变化</td>
<td>极高（必须确认处理）</td>
</tr>
<tr>
<td>游戏进程通知</td>
<td>任务更新、成就解锁</td>
<td>高（需要确认）</td>
</tr>
<tr>
<td>实时位置同步</td>
<td>玩家位置、NPC 移动</td>
<td>中（新数据可覆盖旧数据）</td>
</tr>
<tr>
<td>环境信息</td>
<td>天气变化、背景音乐</td>
<td>低（可接受偶尔丢失）</td>
</tr>
</tbody></table>
<h2>设计通用的 Push-ACK 机制</h2>
<p>一个完善的 Push-ACK 机制需要考虑以下几个方面：消息唯一标识、优先级分级、超时重试、批量确认和失败处理。下面是基于 Go 语言的设计实现：</p>
<h3>核心数据结构</h3>
<pre><code class="language-go">// Message 表示服务器推送的消息
type Message struct {
    MsgID        string      `json:&quot;msg_id&quot;`        // 唯一消息标识
    MsgType      string      `json:&quot;msg_type&quot;`      // 消息类型
    Timestamp    int64       `json:&quot;timestamp&quot;`     // 发送时间戳
    Priority     int         `json:&quot;priority&quot;`      // 优先级：1-高，2-中，3-低
    Payload      interface{} `json:&quot;payload&quot;`       // 消息内容
    RequiresAck  bool        `json:&quot;requires_ack&quot;`  // 是否需要确认
    Expiration   int64       `json:&quot;expiration&quot;`    // 过期时间戳
}

// AckMessage 表示客户端的确认消息
type AckMessage struct {
    AckID            string  `json:&quot;ack_id&quot;`           // 对应原消息ID
    Status           string  `json:&quot;status&quot;`           // 状态：success/failed/partial
    ClientTimestamp  int64   `json:&quot;client_timestamp&quot;` // 客户端处理时间
    ErrorCode        int     `json:&quot;error_code&quot;`       // 错误码
    ErrorMessage     string  `json:&quot;error_message&quot;`    // 错误信息
}

// BatchAckMessage 表示批量确认消息
type BatchAckMessage struct {
    BatchAck        bool     `json:&quot;batch_ack&quot;`       // 批量确认标志
    AckIDs          []string `json:&quot;ack_ids&quot;`         // 消息ID列表
    Status          string   `json:&quot;status&quot;`          // 状态
    ClientTimestamp int64    `json:&quot;client_timestamp&quot;`// 确认时间
}

// PendingMessageInfo 表示等待确认的消息信息
type PendingMessageInfo struct {
    ClientID    string      // 客户端ID
    Message     *Message    // 原始消息
    SentTime    int64       // 发送时间
    RetryCount  int         // 重试次数
}
</code></pre>
<h3>服务器端 Push 管理器实现</h3>
<pre><code class="language-go">// PushManager 负责管理推送消息和确认
type PushManager struct {
    pendingMessages    map[string]*PendingMessageInfo  // 等待确认的消息
    clientMessageCount map[string]int                  // 每个客户端的消息数量
    ackTimeout         int64                           // 确认超时时间(秒)
    maxRetries         int                             // 最大重试次数
    maxPendingPerClient int                            // 每客户端最大消息数
    maxMessageAge      int64                           // 消息最大生存时间(秒)

    // 内存监控相关
    memoryThresholdMB  int64                           // 内存阈值(MB)
    criticalThresholdMB int64                          // 危险内存阈值(MB)

    mutex              sync.RWMutex                    // 保护并发访问

    // 网络接口（依赖外部实现）
    networkLayer       NetworkInterface
}

// NewPushManager 创建一个新的推送管理器
func NewPushManager(networkLayer NetworkInterface) *PushManager {
    pm := &amp;PushManager{
        pendingMessages:     make(map[string]*PendingMessageInfo),
        clientMessageCount:  make(map[string]int),
        ackTimeout:          10,
        maxRetries:          3,
        maxPendingPerClient: 1000,
        maxMessageAge:       300,
        memoryThresholdMB:   1000,  // 1GB
        criticalThresholdMB: 1500,  // 1.5GB
        networkLayer:        networkLayer,
    }

    // 启动后台任务
    go pm.checkTimeoutsLoop()
    go pm.cleanupLoop()
    go pm.memoryMonitorLoop()

    return pm
}

// PushMessage 向客户端推送消息
func (pm *PushManager) PushMessage(clientID string, message *Message) bool {
    // 如果不需要确认，直接发送
    if !message.RequiresAck {
        return pm.networkLayer.SendToClient(clientID, message)
    }

    pm.mutex.Lock()
    defer pm.mutex.Unlock()

    // 检查客户端消息数是否超限
    if pm.clientMessageCount[clientID] &gt;= pm.maxPendingPerClient {
        pm.handleQueueOverflow(clientID, message)
        return false
    }

    // 存储待确认消息
    pm.pendingMessages[message.MsgID] = &amp;PendingMessageInfo{
        ClientID:    clientID,
        Message:     message,
        SentTime:    time.Now().Unix(),
        RetryCount:  0,
    }

    // 更新客户端消息计数
    pm.clientMessageCount[clientID]++

    // 发送消息
    return pm.networkLayer.SendToClient(clientID, message)
}

// ProcessAck 处理客户端的确认消息
func (pm *PushManager) ProcessAck(clientID string, ack *AckMessage) bool {
    pm.mutex.Lock()
    defer pm.mutex.Unlock()

    info, exists := pm.pendingMessages[ack.AckID]
    if !exists || info.ClientID != clientID {
        return false
    }

    // 确认成功，删除消息
    delete(pm.pendingMessages, ack.AckID)
    pm.clientMessageCount[clientID]--

    // 如果客户端没有待确认消息了，清理计数器
    if pm.clientMessageCount[clientID] &lt;= 0 {
        delete(pm.clientMessageCount, clientID)
    }

    return true
}

// ProcessBatchAck 处理批量确认
func (pm *PushManager) ProcessBatchAck(clientID string, batchAck *BatchAckMessage) int {
    pm.mutex.Lock()
    defer pm.mutex.Unlock()

    confirmedCount := 0

    for _, ackID := range batchAck.AckIDs {
        info, exists := pm.pendingMessages[ackID]
        if exists &amp;&amp; info.ClientID == clientID {
            delete(pm.pendingMessages, ackID)
            pm.clientMessageCount[clientID]--
            confirmedCount++
        }
    }

    // 如果客户端没有待确认消息了，清理计数器
    if pm.clientMessageCount[clientID] &lt;= 0 {
        delete(pm.clientMessageCount, clientID)
    }

    return confirmedCount
}

// 后台任务：超时检查与重试
func (pm *PushManager) checkTimeoutsLoop() {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        pm.checkTimeouts()
    }
}

// 超时检查与重试
func (pm *PushManager) checkTimeouts() {
    pm.mutex.Lock()
    defer pm.mutex.Unlock()

    now := time.Now().Unix()

    for msgID, info := range pm.pendingMessages {
        // 检查是否超时
        if now - info.SentTime &gt; pm.ackTimeout {
            if info.RetryCount &lt; pm.maxRetries {
                // 增加重试次数
                info.RetryCount++
                info.SentTime = now

                // 重新发送
                pm.networkLayer.SendToClient(info.ClientID, info.Message)
                log.Printf(&quot;Retrying message %s to client %s, attempt %d&quot;,
                          msgID, info.ClientID, info.RetryCount)
            } else {
                // 超出最大重试次数，放弃并记录
                log.Printf(&quot;Message %s to client %s failed after %d attempts&quot;,
                          msgID, info.ClientID, pm.maxRetries)

                delete(pm.pendingMessages, msgID)
                pm.clientMessageCount[info.ClientID]--

                // 通知业务层处理失败
                go pm.notifyMessageFailed(info.ClientID, info.Message)
            }
        }
    }
}
</code></pre>
<h2>解决内存暴涨问题</h2>
<p>在大型游戏中，服务器可能同时维护数十万甚至上百万个连接，如果每个连接都有数百条待确认消息，服务器内存很快就会爆满。以下是我在实践中总结的几种高效内存管理策略：</p>
<h3>1. 周期性过期消息清理</h3>
<pre><code class="language-go">// 清理过期消息的后台循环
func (pm *PushManager) cleanupLoop() {
    ticker := time.NewTicker(1 * time.Minute)
    defer ticker.Stop()

    for range ticker.C {
        pm.cleanExpiredMessages()
    }
}

// 清理过期消息
func (pm *PushManager) cleanExpiredMessages() {
    pm.mutex.Lock()
    defer pm.mutex.Unlock()

    now := time.Now().Unix()
    expiredCount := 0

    for msgID, info := range pm.pendingMessages {
        // 检查消息是否过期
        if now - info.SentTime &gt; pm.maxMessageAge {
            delete(pm.pendingMessages, msgID)
            pm.clientMessageCount[info.ClientID]--
            expiredCount++

            // 记录日志
            log.Printf(&quot;Cleaned expired message %s to client %s (age: %d seconds)&quot;,
                      msgID, info.ClientID, now - info.SentTime)
        }
    }

    if expiredCount &gt; 0 {
        log.Printf(&quot;Cleanup: Removed %d expired messages&quot;, expiredCount)
    }
}
</code></pre>
<h3>2. 消息压缩与合并</h3>
<pre><code class="language-go">// CompressMessage 压缩消息以减少内存占用
func CompressMessage(message *Message) []byte {
    // 将消息转为JSON
    jsonData, err := json.Marshal(message)
    if err != nil {
        log.Printf(&quot;Error marshaling message: %v&quot;, err)
        return nil
    }

    // 使用gzip压缩
    var buf bytes.Buffer
    writer := gzip.NewWriter(&amp;buf)

    _, err = writer.Write(jsonData)
    if err != nil {
        log.Printf(&quot;Error compressing message: %v&quot;, err)
        return nil
    }

    if err := writer.Close(); err != nil {
        log.Printf(&quot;Error closing gzip writer: %v&quot;, err)
        return nil
    }

    return buf.Bytes()
}

// DecompressMessage 解压缩消息
func DecompressMessage(compressed []byte) (*Message, error) {
    reader, err := gzip.NewReader(bytes.NewReader(compressed))
    if err != nil {
        return nil, fmt.Errorf(&quot;create gzip reader: %w&quot;, err)
    }
    defer reader.Close()

    var buf bytes.Buffer
    if _, err := io.Copy(&amp;buf, reader); err != nil {
        return nil, fmt.Errorf(&quot;decompress data: %w&quot;, err)
    }

    var message Message
    if err := json.Unmarshal(buf.Bytes(), &amp;message); err != nil {
        return nil, fmt.Errorf(&quot;unmarshal json: %w&quot;, err)
    }

    return &amp;message, nil
}
</code></pre>
<h3>3. 分级存储策略</h3>
<pre><code class="language-go">// PushManager 增加分级存储功能
type PushManager struct {
    // ... 之前的字段 ...

    // 内存中存储高优先级消息
    memoryPending     map[string]*PendingMessageInfo

    // Redis客户端，用于存储低优先级消息
    redisClient      *redis.Client
    redisKeyPrefix   string
    redisExpiry      time.Duration
}

// PushMessage 分级存储版本
func (pm *PushManager) PushMessage(clientID string, message *Message) bool {
    // 如果不需要确认，直接发送
    if !message.RequiresAck {
        return pm.networkLayer.SendToClient(clientID, message)
    }

    pm.mutex.Lock()
    defer pm.mutex.Unlock()

    // 检查客户端消息数量限制
    if pm.clientMessageCount[clientID] &gt;= pm.maxPendingPerClient {
        pm.handleQueueOverflow(clientID, message)
        return false
    }

    pm.clientMessageCount[clientID]++

    // 根据优先级选择存储位置
    if message.Priority &lt;= 2 { // 高优先级和中优先级
        // 存入内存
        pm.memoryPending[message.MsgID] = &amp;PendingMessageInfo{
            ClientID:    clientID,
            Message:     message,
            SentTime:    time.Now().Unix(),
            RetryCount:  0,
        }
    } else { // 低优先级
        // 存入Redis
        messageInfo := &amp;PendingMessageInfo{
            ClientID:    clientID,
            Message:     message,
            SentTime:    time.Now().Unix(),
            RetryCount:  0,
        }

        jsonData, err := json.Marshal(messageInfo)
        if err != nil {
            log.Printf(&quot;Error marshaling message: %v&quot;, err)
            pm.clientMessageCount[clientID]--
            return false
        }

        redisKey := pm.redisKeyPrefix + message.MsgID
        err = pm.redisClient.Set(context.Background(), redisKey, jsonData, pm.redisExpiry).Err()
        if err != nil {
            log.Printf(&quot;Error storing message in Redis: %v&quot;, err)
            pm.clientMessageCount[clientID]--
            return false
        }
    }

    // 发送消息
    return pm.networkLayer.SendToClient(clientID, message)
}

// ProcessAck 分级存储版本
func (pm *PushManager) ProcessAck(clientID string, ack *AckMessage) bool {
    pm.mutex.Lock()
    defer pm.mutex.Unlock()

    // 先检查内存中的消息
    info, existsInMemory := pm.memoryPending[ack.AckID]
    if existsInMemory &amp;&amp; info.ClientID == clientID {
        delete(pm.memoryPending, ack.AckID)
        pm.clientMessageCount[clientID]--

        if pm.clientMessageCount[clientID] &lt;= 0 {
            delete(pm.clientMessageCount, clientID)
        }

        return true
    }

    // 再检查Redis中的消息
    redisKey := pm.redisKeyPrefix + ack.AckID
    exists, err := pm.redisClient.Exists(context.Background(), redisKey).Result()
    if err != nil {
        log.Printf(&quot;Error checking message in Redis: %v&quot;, err)
        return false
    }

    if exists == 1 {
        // 获取消息以验证客户端ID
        jsonData, err := pm.redisClient.Get(context.Background(), redisKey).Bytes()
        if err != nil {
            log.Printf(&quot;Error getting message from Redis: %v&quot;, err)
            return false
        }

        var messageInfo PendingMessageInfo
        if err := json.Unmarshal(jsonData, &amp;messageInfo); err != nil {
            log.Printf(&quot;Error unmarshaling message from Redis: %v&quot;, err)
            return false
        }

        if messageInfo.ClientID == clientID {
            // 从Redis删除并更新计数
            pm.redisClient.Del(context.Background(), redisKey)
            pm.clientMessageCount[clientID]--

            if pm.clientMessageCount[clientID] &lt;= 0 {
                delete(pm.clientMessageCount, clientID)
            }

            return true
        }
    }

    return false
}
</code></pre>
<h3>4. 内存自适应调整</h3>
<p>内存自适应调整是我在实际项目中解决突发流量问题的关键策略。它能够根据当前系统负载动态调整消息处理参数，确保系统稳定性。</p>
<pre><code class="language-go">// 内存监控循环
func (pm *PushManager) memoryMonitorLoop() {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        memoryMB := pm.getMemoryUsageMB()

        if memoryMB &gt; pm.criticalThresholdMB {
            // 紧急情况，进行应急清理
            pm.emergencyCleanup(memoryMB)
        } else if memoryMB &gt; pm.memoryThresholdMB {
            // 超过警戒线，调整参数
            pm.adjustParameters(memoryMB)
        }
    }
}

// 获取当前进程内存使用量（MB）
func (pm *PushManager) getMemoryUsageMB() int64 {
    var memStats runtime.MemStats
    runtime.ReadMemStats(&amp;memStats)
    return int64(memStats.Alloc / 1024 / 1024)
}

// 根据内存使用情况调整参数
func (pm *PushManager) adjustParameters(currentMemoryMB int64) {
    pm.mutex.Lock()
    defer pm.mutex.Unlock()

    // 计算内存超出比例
    excessRatio := float64(currentMemoryMB - pm.memoryThresholdMB) / float64(pm.memoryThresholdMB)

    // 调整每客户端最大消息数
    newMaxPerClient := int(float64(pm.maxPendingPerClient) * (1 - excessRatio*0.5))
    if newMaxPerClient &lt; 100 {
        newMaxPerClient = 100 // 确保至少保留100条
    }

    // 调整消息最大生存时间
    newMaxAge := int64(float64(pm.maxMessageAge) * (1 - excessRatio*0.5))
    if newMaxAge &lt; 60 {
        newMaxAge = 60 // 至少60秒
    }

    // 更新参数
    pm.maxPendingPerClient = newMaxPerClient
    pm.maxMessageAge = newMaxAge

    log.Printf(&quot;Memory usage: %d MB, adjusted parameters: maxPending=%d, maxAge=%ds&quot;,
               currentMemoryMB, pm.maxPendingPerClient, pm.maxMessageAge)

    // 执行一次清理
    pm.cleanExpiredMessages()
}

// 紧急清理
func (pm *PushManager) emergencyCleanup(currentMemoryMB int64) {
    pm.mutex.Lock()
    defer pm.mutex.Unlock()

    log.Printf(&quot;CRITICAL: Memory usage at %d MB, performing emergency cleanup&quot;, currentMemoryMB)

    // 大幅降低参数
    pm.maxPendingPerClient = 100
    pm.maxMessageAge = 60

    // 清理低优先级消息
    for msgID, info := range pm.memoryPending {
        if info.Message.Priority &gt; 1 { // 只保留最高优先级
            delete(pm.memoryPending, msgID)
            pm.clientMessageCount[info.ClientID]--
        }
    }

    log.Printf(&quot;Emergency cleanup completed&quot;)
}
</code></pre>
<h3>5. 队列溢出处理策略</h3>
<pre><code class="language-go">// 处理队列溢出
func (pm *PushManager) handleQueueOverflow(clientID string, newMessage *Message) {
    log.Printf(&quot;Queue overflow for client %s&quot;, clientID)

    // 策略1: 根据消息优先级决定是否替换现有消息
    if newMessage.Priority == 1 { // 高优先级消息
        // 查找并替换该客户端的一条低优先级消息
        for msgID, info := range pm.memoryPending {
            if info.ClientID == clientID &amp;&amp; info.Message.Priority &gt; 1 {
                // 记录
                log.Printf(&quot;Replacing low priority message %s with high priority message&quot;, msgID)

                // 删除旧消息
                delete(pm.memoryPending, msgID)

                // 添加新消息
                pm.memoryPending[newMessage.MsgID] = &amp;PendingMessageInfo{
                    ClientID:    clientID,
                    Message:     newMessage,
                    SentTime:    time.Now().Unix(),
                    RetryCount:  0,
                }

                // 发送新消息
                pm.networkLayer.SendToClient(clientID, newMessage)
                return
            }
        }
    }

    // 策略2: 丢弃旧消息以腾出空间
    // 查找该客户端最旧的消息
    var oldestMsgID string
    var oldestTime int64 = math.MaxInt64

    for msgID, info := range pm.memoryPending {
        if info.ClientID == clientID &amp;&amp; info.SentTime &lt; oldestTime {
            oldestMsgID = msgID
            oldestTime = info.SentTime
        }
    }

    if oldestMsgID != &quot;&quot; {
        log.Printf(&quot;Dropping oldest message %s for client %s&quot;, oldestMsgID, clientID)
        delete(pm.memoryPending, oldestMsgID)

        // 添加新消息
        pm.memoryPending[newMessage.MsgID] = &amp;PendingMessageInfo{
            ClientID:    clientID,
            Message:     newMessage,
            SentTime:    time.Now().Unix(),
            RetryCount:  0,
        }

        // 发送新消息
        pm.networkLayer.SendToClient(clientID, newMessage)
    } else {
        // 极端情况，无法找到可替换的消息
        log.Printf(&quot;Cannot find message to replace for client %s&quot;, clientID)
    }
}
</code></pre>
<h2>客户端实现</h2>
<p>客户端实现同样关键，特别是批量确认机制能显著减少网络流量：</p>
<pre><code class="language-go">// PushReceiver 客户端推送接收处理器
type PushReceiver struct {
    connection        Connection          // 网络连接接口
    processedMsgIDs   map[string]int64    // 已处理消息ID及处理时间
    pendingAcks       []string            // 待确认的消息ID
    ackBatchSize      int                 // 批量确认大小
    ackInterval       time.Duration       // 批量确认间隔
    messageHandlers   map[string]MessageHandler // 消息处理函数

    mutex             sync.Mutex          // 保护并发访问
    stopChan          chan struct{}       // 停止信号
}

// MessageHandler 消息处理函数类型
type MessageHandler func(payload interface{}) error

// NewPushReceiver 创建推送接收器
func NewPushReceiver(conn Connection) *PushReceiver {
    receiver := &amp;PushReceiver{
        connection:       conn,
        processedMsgIDs:  make(map[string]int64),
        pendingAcks:      make([]string, 0, 100),
        ackBatchSize:     50,
        ackInterval:      time.Second,
        messageHandlers:  make(map[string]MessageHandler),
        stopChan:         make(chan struct{}),
    }

    // 启动批量确认任务
    go receiver.ackLoop()

    // 启动过期消息ID清理任务
    go receiver.cleanupLoop()

    return receiver
}

// RegisterHandler 注册消息处理函数
func (r *PushReceiver) RegisterHandler(msgType string, handler MessageHandler) {
    r.mutex.Lock()
    defer r.mutex.Unlock()

    r.messageHandlers[msgType] = handler
}

// HandleMessage 处理收到的消息
func (r *PushReceiver) HandleMessage(message *Message) {
    r.mutex.Lock()
    defer r.mutex.Unlock()

    msgID := message.MsgID

    // 检查是否已处理过该消息
    if _, exists := r.processedMsgIDs[msgID]; exists {
        // 已处理过，再次发送确认
        if message.RequiresAck {
            r.pendingAcks = append(r.pendingAcks, msgID)

            // 如果积累的确认数量超过批量大小，立即发送
            if len(r.pendingAcks) &gt;= r.ackBatchSize {
                go r.sendBatchAcks()
            }
        }
        return
    }

    // 查找处理函数
    handler, exists := r.messageHandlers[message.MsgType]
    if !exists {
        log.Printf(&quot;No handler for message type: %s&quot;, message.MsgType)

        // 未知消息类型也需要确认
        if message.RequiresAck {
            r.sendErrorAck(msgID, &quot;Unknown message type&quot;)
        }
        return
    }

    // 处理消息
    err := handler(message.Payload)
    if err != nil {
        log.Printf(&quot;Error processing message %s: %v&quot;, msgID, err)

        if message.RequiresAck {
            r.sendErrorAck(msgID, err.Error())
        }
        return
    }

    // 记录已处理的消息
    r.processedMsgIDs[msgID] = time.Now().Unix()

    // 如果需要确认，加入待确认队列
    if message.RequiresAck {
        r.pendingAcks = append(r.pendingAcks, msgID)

        // 如果积累的确认数量超过批量大小，立即发送
        if len(r.pendingAcks) &gt;= r.ackBatchSize {
            go r.sendBatchAcks()
        }
    }
}

// 发送批量确认
func (r *PushReceiver) sendBatchAcks() {
    r.mutex.Lock()

    // 如果没有待确认消息，直接返回
    if len(r.pendingAcks) == 0 {
        r.mutex.Unlock()
        return
    }

    // 复制当前的待确认ID列表
    ackIDs := make([]string, len(r.pendingAcks))
    copy(ackIDs, r.pendingAcks)

    // 清空待确认列表
    r.pendingAcks = r.pendingAcks[:0]

    r.mutex.Unlock()

    // 创建批量确认消息
    batchAck := &amp;BatchAckMessage{
        BatchAck:        true,
        AckIDs:          ackIDs,
        Status:          &quot;success&quot;,
        ClientTimestamp: time.Now().Unix(),
    }

    // 发送确认
    r.connection.Send(batchAck)
}

// 发送错误确认
func (r *PushReceiver) sendErrorAck(msgID string, errorMessage string) {
    ack := &amp;AckMessage{
        AckID:           msgID,
        Status:          &quot;failed&quot;,
        ClientTimestamp: time.Now().Unix(),
        ErrorCode:       1001,
        ErrorMessage:    errorMessage,
    }

    r.connection.Send(ack)
}

// 批量确认定时器
func (r *PushReceiver) ackLoop() {
    ticker := time.NewTicker(r.ackInterval)
    defer ticker.Stop()

    for {
        select {
        case &lt;-ticker.C:
            r.sendBatchAcks()
        case &lt;-r.stopChan:
            return
        }
    }
}

// 清理过期的已处理消息ID
func (r *PushReceiver) cleanupLoop() {
    // 每小时清理一次
    ticker := time.NewTicker(1 * time.Hour)
    defer ticker.Stop()

    for {
        select {
        case &lt;-ticker.C:
            r.cleanupProcessedIDs()
        case &lt;-r.stopChan:
            return
        }
    }
}

// 清理过期的已处理消息ID
func (r *PushReceiver) cleanupProcessedIDs() {
    r.mutex.Lock()
    defer r.mutex.Unlock()

    now := time.Now().Unix()
    expireTime := int64(86400) // 24小时过期

    for msgID, processTime := range r.processedMsgIDs {
        if now - processTime &gt; expireTime {
            delete(r.processedMsgIDs, msgID)
        }
    }
}

// Close 关闭推送接收器
func (r *PushReceiver) Close() {
    // 发送所有待确认消息
    r.sendBatchAcks()

    // 停止所有后台任务
    close(r.stopChan)
}
</code></pre>
<h2>实战经验与最佳实践</h2>
<p>在多个千万用户级别的游戏项目实践中，我总结了以下几点 Push-ACK 机制的最佳实践：</p>
<h3>1. 消息分级是关键</h3>
<p>不是所有消息都需要相同级别的可靠性保证。在一个 MMORPG 项目中，我们将消息分为四级：</p>
<ul>
<li><strong>关键级</strong>：直接影响游戏平衡和经济的消息，如道具获取、货币变化</li>
<li><strong>重要级</strong>：影响游戏进程的消息，如任务更新、排行榜变动</li>
<li><strong>普通级</strong>：一般游戏状态信息，如其他玩家动作、环境变化</li>
<li><strong>低优先级</strong>：可以容忍丢失的背景信息，如聊天、天气效果</li>
</ul>
<p>高级别消息使用完整的 ACK 机制，低级别消息可以简化甚至取消 ACK 需求，这样大大减轻了服务器内存压力。</p>
<h3>2. 利用统计指标进行调优</h3>
<p>监控以下关键指标：</p>
<ul>
<li>ACK 响应时间分布</li>
<li>消息重试率</li>
<li>每客户端平均待确认消息数</li>
<li>内存使用增长曲线</li>
</ul>
<p>在一个足球经理类游戏中，通过这些指标我们发现，将 ACK 超时时间从 10 秒调整到 5 秒，并将最大重试次数从 3 次增加到 5 次，可以将消息最终确认率从 99.2%提高到 99.8%，同时减少了 25%的内存使用。</p>
<h3>3. 针对不同网络环境优化</h3>
<p>移动网络环境差异很大，针对不同网络条件动态调整策略：</p>
<pre><code class="language-go">// 根据网络条件调整参数
func (pm *PushManager) adjustForNetworkCondition(clientID string, rtt time.Duration) {
    // 网络条件良好
    if rtt &lt; 100*time.Millisecond {
        pm.clientTimeouts[clientID] = 3 // 3秒超时
        pm.clientRetries[clientID] = 2  // 2次重试
    } else if rtt &lt; 300*time.Millisecond {
        pm.clientTimeouts[clientID] = 5 // 5秒超时
        pm.clientRetries[clientID] = 3  // 3次重试
    } else {
        pm.clientTimeouts[clientID] = 10 // 10秒超时
        pm.clientRetries[clientID] = 5   // 5次重试
    }
}
</code></pre>
<h3>4. 定期压力测试</h3>
<p>在一个大型开放世界游戏中，我们每月进行一次&quot;混沌测试&quot;，模拟极端情况：</p>
<ol>
<li>突发 50%客户端同时掉线然后重连</li>
<li>模拟网络延迟突然从 50ms 增加到 500ms</li>
<li>模拟 10%的确认消息丢失</li>
</ol>
<p>这种测试让我们发现了很多边缘情况，并建立了更健壮的防御机制。</p>
<h2>结论</h2>
<p>一个设计良好的 Push-ACK 机制是现代游戏服务器架构的核心组件。它确保了游戏状态的一致性，提升了玩家体验，同时也为运营团队提供了可靠的数据基础。最重要的是，它必须是高性能且资源友好的。</p>
<p>通过采用本文介绍的多级存储、自适应参数调整、消息优先级和过期策略等技术，我们可以构建一个既可靠又高效的推送确认系统，即使在面对数十万并发</p>
]]></content:encoded>
    </item>
    <item>
      <title>服务监控丨Prometheus 四大数据类型详解</title>
      <link>https://hedon.top/blog/prometheus-data-type/</link>
      <guid isPermaLink="true">https://hedon.top/blog/prometheus-data-type/</guid>
      <pubDate>Wed, 26 Feb 2025 15:52:10 GMT</pubDate>
      <description>本文介绍了 Prometheus 的四大数据类型及其 PromQL 查询语言，帮助开发团队构建强大的可观测性系统。</description>
      <category>prometheus</category><category>服务监控</category>
      <content:encoded><![CDATA[<h2>前言</h2>
<p>在微服务和云原生架构的世界中，一套强大的监控系统是保障服务稳定性的基石。Prometheus 作为 CNCF 的明星项目，凭借其简单高效的特性，已成为事实上的云原生监控标准。本文将深入剖析 Prometheus 的四大数据类型及其 PromQL 查询语言，帮助开发团队构建强大的可观测性系统。</p>
<h2>结论先行：Prometheus 四大数据类型速览</h2>
<table>
<thead>
<tr>
<th>特性</th>
<th>Counter</th>
<th>Gauge</th>
<th>Histogram</th>
<th>Summary</th>
</tr>
</thead>
<tbody><tr>
<td><strong>定义</strong></td>
<td>只增不减的累积计数器</td>
<td>可增可减的瞬时值</td>
<td>观测值分布的分桶统计</td>
<td>客户端计算的分位数统计</td>
</tr>
<tr>
<td><strong>重置行为</strong></td>
<td>服务重启时归零</td>
<td>保持当前值</td>
<td>桶计数归零</td>
<td>计数归零</td>
</tr>
<tr>
<td><strong>典型应用</strong></td>
<td>请求计数、错误数、流量统计</td>
<td>温度、内存使用、连接数</td>
<td>请求延迟、响应大小</td>
<td>请求延迟、队列等待时间</td>
</tr>
<tr>
<td><strong>数据点</strong></td>
<td>单一值</td>
<td>单一值</td>
<td>_bucket、_sum、count</td>
<td>{quantile=&quot;x&quot;}、_sum、_count</td>
</tr>
<tr>
<td><strong>查询重点</strong></td>
<td>rate()、increase()</td>
<td>直接使用、预测函数</td>
<td>histogram_quantile()</td>
<td>直接读取分位数</td>
</tr>
<tr>
<td><strong>分布式聚合</strong></td>
<td>可以（sum、rate）</td>
<td>可以（avg、max、min）</td>
<td>可以（百分位也可聚合）</td>
<td>有限（分位数不可聚合）</td>
</tr>
<tr>
<td><strong>资源消耗</strong></td>
<td>低</td>
<td>低</td>
<td>中（依赖桶数量）</td>
<td>中（客户端计算）</td>
</tr>
</tbody></table>
<h2>一、Prometheus 核心数据类型详解</h2>
<h3>1. Counter（计数器）：持续增长的累积值</h3>
<p>Counter 是最简单但也最常用的指标类型，代表一个只增不减的累积数值。每当事件发生，计数器增加；当监控目标重启时，计数器归零。</p>
<p><strong>适用场景</strong>：</p>
<ul>
<li>API 请求总数</li>
<li>错误发生次数</li>
<li>处理任务的数量</li>
<li>网络流量字节数</li>
</ul>
<p><strong>正确的代码实现</strong>：</p>
<pre><code class="language-go">// 声明带标签的计数器
requestCounter := prometheus.NewCounterVec(
    prometheus.CounterOpts{
        Name: &quot;http_requests_total&quot;,
        Help: &quot;Total number of HTTP requests&quot;,
    },
    []string{&quot;method&quot;, &quot;path&quot;, &quot;status&quot;}, // 定义标签维度
)
prometheus.MustRegister(requestCounter)

// 使用标签记录请求
requestCounter.WithLabelValues(&quot;GET&quot;, &quot;/api/users&quot;, &quot;200&quot;).Inc()
</code></pre>
<p><strong>PromQL 查询技巧</strong>：</p>
<pre><code class="language-promql"># 每秒请求率（5分钟窗口）
rate(http_requests_total{status=&quot;200&quot;}[5m])

# 错误率计算
sum(rate(http_requests_total{status=~&quot;5..&quot;}[5m])) / sum(rate(http_requests_total[5m]))

# 1小时内的请求增量
increase(http_requests_total[1h])
</code></pre>
<p><strong>最佳实践</strong>：</p>
<ul>
<li>永远不要直接使用 Counter 的原始值，总是使用 <code>rate()</code> 或 <code>increase()</code></li>
<li>使用有意义的标签进行多维度分析，但避免高基数标签</li>
<li>Counter 重置（如服务重启）会被 <code>rate()</code> 函数自动处理</li>
</ul>
<h3>2. Gauge（仪表盘）：可变的瞬时值</h3>
<p>Gauge 表示一个可增可减的瞬时测量值，反映系统的当前状态。</p>
<p><strong>适用场景</strong>：</p>
<ul>
<li>内存使用量</li>
<li>CPU 使用率</li>
<li>当前活跃连接数</li>
<li>队列深度</li>
<li>温度等物理量</li>
</ul>
<p><strong>正确的代码实现</strong>：</p>
<pre><code class="language-go">// 声明带标签的仪表盘
memoryGauge := prometheus.NewGaugeVec(
    prometheus.GaugeOpts{
        Name: &quot;app_memory_usage_bytes&quot;,
        Help: &quot;Current memory usage in bytes&quot;,
    },
    []string{&quot;component&quot;, &quot;instance&quot;},
)
prometheus.MustRegister(memoryGauge)

// 设置当前值
memoryGauge.WithLabelValues(&quot;api-server&quot;, &quot;instance-1&quot;).Set(float64(getCurrentMemoryUsage()))
</code></pre>
<p><strong>PromQL 查询技巧</strong>：</p>
<pre><code class="language-promql"># 直接使用当前值
app_memory_usage_bytes{component=&quot;api-server&quot;}

# 统计聚合
avg_over_time(app_memory_usage_bytes[1h])
max_over_time(app_memory_usage_bytes[24h])

# 趋势预测（线性回归）
predict_linear(app_memory_usage_bytes[6h], 4 * 3600)

# 计算变化率
(app_memory_usage_bytes - app_memory_usage_bytes offset 1h) / app_memory_usage_bytes offset 1h
</code></pre>
<p><strong>最佳实践</strong>：</p>
<ul>
<li>Gauge 可以直接使用其瞬时值，不需要像 Counter 那样使用 rate</li>
<li>对于容易波动的指标，考虑使用 <code>avg_over_time</code> 平滑数据</li>
<li>利用 <code>predict_linear</code> 进行容量规划和趋势预测</li>
</ul>
<h3>3. Histogram（直方图）：观测值分布的分桶统计</h3>
<p>Histogram 允许对观测值（如请求延迟）进行分布式统计，将数据分散到预定义的桶中，是分析性能分布的理想工具。</p>
<p><strong>自动生成的指标</strong>：</p>
<ul>
<li><code>&lt;metric&gt;_bucket{le=&quot;&lt;upper bound&gt;&quot;}</code>: 小于等于特定阈值的观测值计数</li>
<li><code>&lt;metric&gt;_sum</code>: 所有观测值的总和</li>
<li><code>&lt;metric&gt;_count</code>: 观测值总数</li>
</ul>
<p><strong>适用场景</strong>：</p>
<ul>
<li>请求延迟分布</li>
<li>响应大小分布</li>
<li>批处理任务执行时间</li>
<li>任何需要百分位数分析的场景</li>
</ul>
<p><strong>正确的代码实现</strong>：</p>
<pre><code class="language-go">// 声明带标签的直方图
durationHistogram := prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    &quot;http_request_duration_seconds&quot;,
        Help:    &quot;HTTP request duration in seconds&quot;,
        Buckets: prometheus.ExponentialBuckets(0.001, 2, 10), // 从1ms开始指数增长
    },
    []string{&quot;method&quot;, &quot;path&quot;},
)
prometheus.MustRegister(durationHistogram)

// 记录请求延迟
durationHistogram.WithLabelValues(&quot;GET&quot;, &quot;/api/users&quot;).Observe(responseTime)
</code></pre>
<p><strong>PromQL 查询技巧</strong>：</p>
<pre><code class="language-promql"># 计算平均响应时间
rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m])

# 计算P90延迟
histogram_quantile(0.9, rate(http_request_duration_seconds_bucket[5m]))

# 按API路径分析P95延迟
histogram_quantile(0.95, sum by(path, le) (rate(http_request_duration_seconds_bucket[5m])))

# 计算SLO：延迟小于100ms的请求比例
sum(rate(http_request_duration_seconds_bucket{le=&quot;0.1&quot;}[5m])) / sum(rate(http_request_duration_seconds_count[5m]))
</code></pre>
<p><strong>最佳实践</strong>：</p>
<ul>
<li>仔细设计桶边界，覆盖关键分位数区域</li>
<li>对于延迟指标，通常使用指数桶比线性桶更合理</li>
<li>利用 <code>histogram_quantile</code> 计算任意分位数</li>
<li>桶的数量会影响存储和性能，权衡精度和开销</li>
</ul>
<h3>4. Summary（摘要）：客户端计算的分位数统计</h3>
<p>Summary 与 Histogram 类似，但在客户端直接计算并存储分位数，无需服务器端计算。</p>
<p><strong>自动生成的指标</strong>：</p>
<ul>
<li><code>&lt;metric&gt;{quantile=&quot;&lt;φ&gt;&quot;}</code>: φ 分位数的值</li>
<li><code>&lt;metric&gt;_sum</code>: 所有观测值的总和</li>
<li><code>&lt;metric&gt;_count</code>: 观测值总数</li>
</ul>
<p><strong>适用场景</strong>：</p>
<ul>
<li>需要高精度分位数的场景</li>
<li>客户端计算分位数更高效的情况</li>
<li>对服务器端聚合要求不高的场景</li>
</ul>
<p><strong>正确的代码实现</strong>：</p>
<pre><code class="language-go">// 声明带标签的摘要
durationSummary := prometheus.NewSummaryVec(
    prometheus.SummaryOpts{
        Name:       &quot;http_request_duration_seconds_summary&quot;,
        Help:       &quot;HTTP request duration in seconds&quot;,
        Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
    },
    []string{&quot;method&quot;, &quot;path&quot;},
)
prometheus.MustRegister(durationSummary)

// 记录请求延迟
durationSummary.WithLabelValues(&quot;POST&quot;, &quot;/api/login&quot;).Observe(responseTime)
</code></pre>
<p><strong>PromQL 查询技巧</strong>：</p>
<pre><code class="language-promql"># 直接读取P99延迟
http_request_duration_seconds_summary{quantile=&quot;0.99&quot;, method=&quot;GET&quot;, path=&quot;/api/users&quot;}

# 计算平均响应时间
rate(http_request_duration_seconds_summary_sum[5m]) / rate(http_request_duration_seconds_summary_count[5m])

# 每个服务的中位数延迟
max by(service) (http_request_duration_seconds_summary{quantile=&quot;0.5&quot;})
</code></pre>
<p><strong>最佳实践与限制</strong>：</p>
<ul>
<li>Summary 预计算的分位数不能跨实例聚合（这是关键限制）</li>
<li>适用于分位数精度要求高且实例相对独立的场景</li>
<li>客户端计算分位数会增加应用资源消耗</li>
<li>分位数设置后不可更改，需提前规划好监控需求</li>
</ul>
<h2>二、PromQL 查询语言精通</h2>
<p>PromQL 是 Prometheus 的强大武器，掌握它能让我们精确提取所需的监控数据。</p>
<h3>1. 基础查询与标签选择</h3>
<pre><code class="language-promql"># 基本查询与精确匹配
http_requests_total{status=&quot;200&quot;, method=&quot;GET&quot;}

# 正则表达式匹配
http_requests_total{path=~&quot;/api/v1/.+&quot;, method!=&quot;OPTIONS&quot;}

# 范围查询（返回时间序列）
http_requests_total{status=&quot;500&quot;}[5m]
</code></pre>
<h3>2. 操作符与函数</h3>
<p><strong>算术运算符</strong>：</p>
<pre><code class="language-promql"># 计算内存使用率百分比
100 * (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes
</code></pre>
<p><strong>聚合函数</strong>：</p>
<pre><code class="language-promql"># 按服务和路径分组求和
sum by(service, path) (rate(http_requests_total[5m]))

# 丢弃instance标签求最大值
max without(instance) (node_cpu_seconds_total)
</code></pre>
<p><strong>瞬时向量函数</strong>：</p>
<pre><code class="language-promql"># 标签替换
label_replace(up, &quot;host&quot;, &quot;$1&quot;, &quot;instance&quot;, &quot;(.*):.*&quot;)

# 按标签分组取topk
topk by(path) (5, http_request_duration_seconds_sum / http_request_duration_seconds_count)
</code></pre>
<h3>3. 复杂查询模式</h3>
<p><strong>SLI/SLO 监控</strong>：</p>
<pre><code class="language-promql"># 服务可用性SLI
sum(rate(http_requests_total{status=~&quot;2..|3..&quot;}[5m])) / sum(rate(http_requests_total[5m]))

# 延迟SLO
histogram_quantile(0.99, sum by(le) (rate(http_request_duration_seconds_bucket[5m]))) &lt; 0.3
</code></pre>
<p><strong>异常检测</strong>：</p>
<pre><code class="language-promql"># 相对于历史同期的异常增长
rate(http_requests_total[5m])
  &gt; 2 * avg_over_time(rate(http_requests_total[5m])[1d:5m] offset 1d)
</code></pre>
<p><strong>预测分析</strong>：</p>
<pre><code class="language-promql"># 磁盘空间预测
predict_linear(node_filesystem_free_bytes{mountpoint=&quot;/&quot;}[6h], 7 * 24 * 3600) &lt; 10 * 1024 * 1024 * 1024
</code></pre>
<h2>三、实战应用场景</h2>
<h3>1. 服务健康度监控</h3>
<p><strong>RED 方法实现</strong>：</p>
<pre><code class="language-promql"># Rate - 请求率
sum by(service) (rate(http_requests_total[5m]))

# Error - 错误率
sum by(service) (rate(http_requests_total{status=~&quot;5..&quot;}[5m])) / sum by(service) (rate(http_requests_total[5m]))

# Duration - P95延迟
histogram_quantile(0.95, sum by(service, le) (rate(http_request_duration_seconds_bucket[5m])))
</code></pre>
<p><strong>服务依赖健康度</strong>：</p>
<pre><code class="language-promql"># 数据库查询错误率
sum(rate(database_query_errors_total[5m])) / sum(rate(database_queries_total[5m]))

# 第三方API调用延迟
histogram_quantile(0.99, sum by(api_name, le) (rate(api_request_duration_seconds_bucket[5m])))
</code></pre>
<h3>2. 性能瓶颈分析</h3>
<p><strong>热点 API 发现</strong>：</p>
<pre><code class="language-promql"># 延迟最高的10个接口
topk(10,
  histogram_quantile(0.95, sum by(method, path, le) (rate(http_request_duration_seconds_bucket[5m])))
)

# 请求量最大的接口
topk(10, sum by(method, path) (rate(http_requests_total[5m])))
</code></pre>
<p><strong>数据库性能分析</strong>：</p>
<pre><code class="language-promql"># 平均查询时间趋势
rate(db_query_duration_seconds_sum[5m]) / rate(db_query_duration_seconds_count[5m])

# 慢查询比例
sum(rate(db_query_duration_seconds_bucket{le=&quot;+Inf&quot;}[5m])) - sum(rate(db_query_duration_seconds_bucket{le=&quot;0.1&quot;}[5m])) / sum(rate(db_query_duration_seconds_bucket{le=&quot;+Inf&quot;}[5m]))
</code></pre>
<h3>3. 容量规划与告警</h3>
<p><strong>资源预测</strong>：</p>
<pre><code class="language-promql"># CPU使用率预测
predict_linear(avg by(instance) (rate(node_cpu_seconds_total{mode!=&quot;idle&quot;}[6h])) [3d:], 7 * 24 * 3600) &gt; 0.85

# 内存压力告警
(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes &gt; 0.9
</code></pre>
<p><strong>流量容量规划</strong>：</p>
<pre><code class="language-promql"># 带宽使用预测
predict_linear(rate(node_network_transmit_bytes_total[12h])[7d:], 30 * 24 * 3600)
</code></pre>
<h2>四、最佳实践与性能优化</h2>
<h3>1. 指标命名与标签设计</h3>
<p><strong>命名规范</strong>：</p>
<ul>
<li>使用 snake_case</li>
<li>包含单位后缀（_bytes, _seconds, _total）</li>
<li>保持风格一致性</li>
</ul>
<p><strong>标签最佳实践</strong>：</p>
<pre><code class="language-go">// 合理设计标签维度
apiLatency := prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    &quot;api_request_duration_seconds&quot;,
        Help:    &quot;API request duration in seconds&quot;,
        Buckets: prometheus.ExponentialBuckets(0.001, 2, 10),
    },
    []string{&quot;service&quot;, &quot;endpoint&quot;, &quot;status_code&quot;}, // 合理的低基数标签
)

// 不可变标签使用ConstLabels
prometheus.NewGaugeVec(
    prometheus.GaugeOpts{
        Name:        &quot;service_info&quot;,
        Help:        &quot;Service information&quot;,
        ConstLabels: prometheus.Labels{&quot;version&quot;: &quot;v2.1.3&quot;, &quot;environment&quot;: &quot;production&quot;},
    },
    []string{&quot;instance&quot;},
)
</code></pre>
<h3>2. 客户端性能优化</h3>
<pre><code class="language-go">// 缓存常用标签组合以提高性能
getCounter := requestCounter.WithLabelValues(&quot;GET&quot;, &quot;/api/users&quot;, &quot;200&quot;)
for i := 0; i &lt; 100; i++ {
    getCounter.Inc() // 重用标签组合，避免重复创建
}

// 批量更新方式
var rpcDurations = prometheus.NewSummaryVec(
    prometheus.SummaryOpts{
        Name:       &quot;rpc_durations_seconds&quot;,
        Help:       &quot;RPC latency distributions.&quot;,
        Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
    },
    []string{&quot;service&quot;},
)

func ObserveBatch(durations map[string]float64) {
    for service, duration := range durations {
        rpcDurations.WithLabelValues(service).Observe(duration)
    }
}
</code></pre>
<h3>3. 查询优化</h3>
<pre><code class="language-promql"># 优化前：高基数查询
sum(rate(http_requests_total{path=~&quot;/api/.*&quot;}[5m])) by (path, method, status)

# 优化后：降低基数，按需聚合
sum(rate(http_requests_total{path=~&quot;/api/.*&quot;}[5m])) by (method, status)

# 优化聚合顺序（先聚合再求和）
sum(
  avg by(instance) (rate(node_cpu_seconds_total{mode!=&quot;idle&quot;}[5m]))
)
</code></pre>
<h2>五、常见陷阱与解决方案</h2>
<h3>1. 高基数问题</h3>
<p><strong>问题</strong>：标签组合过多导致时间序列爆炸
<strong>解决方案</strong>：</p>
<ul>
<li>限制标签基数，避免使用 UserID、SessionID 等作为标签</li>
<li>使用<code>label_replace</code>和正则表达式转换高基数标签</li>
<li>考虑使用 Exemplars 而非标签存储高基数数据</li>
</ul>
<h3>2. 数据类型选择误区</h3>
<p><strong>Counter vs Gauge</strong>：请求数应使用 Counter 而非 Gauge
<strong>Histogram vs Summary</strong>：需要聚合分析请使用 Histogram，精确分位数可选 Summary</p>
<h3>3. 查询性能问题</h3>
<p><strong>问题</strong>：复杂查询导致 Prometheus 高负载
<strong>解决方案</strong>：</p>
<ul>
<li>使用记录规则预计算常用查询</li>
<li>合理设置 scrape 间隔，避免过度采集</li>
<li>对高请求量接口使用客户端聚合</li>
</ul>
<h2>总结与展望</h2>
<p>Prometheus 的四种数据类型各有所长：Counter 适合累积事件计数，Gauge 适合瞬时状态测量，Histogram 适合分布统计和百分位分析，Summary 适合客户端精确分位数计算。与之配合的 PromQL 提供了强大的数据查询和分析能力，共同构成了完整的监控解决方案。</p>
<p>随着云原生技术的发展，Prometheus 生态也在不断壮大，与 Grafana、Alertmanager、Thanos 等工具集成，能够构建更完善的监控告警平台。在微服务架构中，结合 RED（Rate、Error、Duration）和 USE（Utilization、Saturation、Errors）方法论，可以构建全面的可观测性系统。</p>
<p>无论你是刚开始使用 Prometheus 的新手，还是寻求优化监控系统的资深工程师，希望本文对你理解和应用 Prometheus 有所帮助。记住，好的监控不仅能及时发现问题，更能预测和防范问题，最终服务于业务可靠性和用户体验的提升。</p>
<hr>
<p><em>参考资源:</em></p>
<ul>
<li>Prometheus 官方文档: <a href="https://prometheus.io/docs/">https://prometheus.io/docs/</a></li>
<li>Google SRE 书籍: <a href="https://sre.google/sre-book/monitoring-distributed-systems/">https://sre.google/sre-book/monitoring-distributed-systems/</a></li>
<li>Prometheus 实战: <a href="https://prometheusbook.com/">https://prometheusbook.com/</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>在 Go 项目中实现 JWT 用户认证与续期机制</title>
      <link>https://hedon.top/blog/go-action-jwt/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-action-jwt/</guid>
      <pubDate>Sat, 15 Feb 2025 23:28:10 GMT</pubDate>
      <description>本文将结合实际代码，详细讲解如何在 Go 项目中实现 JWT 认证机制，并探讨两种常见的 Token 续期策略：自动续期和 Refresh Token。</description>
      <category>Go</category><category>JWT</category><category>Go 实战</category>
      <content:encoded><![CDATA[<p>JWT (JSON Web Token) 是一种广泛使用的用户认证方案，因其无状态、跨域支持和灵活性而受到欢迎。本文将结合实际代码，详细讲解如何在 Go 项目中实现 JWT 认证机制，并探讨两种常见的 Token 续期策略：自动续期和 Refresh Token。</p>
<h2>1. JWT 基础概念</h2>
<p>JWT 由三部分组成：Header、Payload 和 Signature。使用 JWT 进行登录认证的基本工作流程是：</p>
<ol>
<li>用户登录成功后，服务器生成 JWT。</li>
<li>服务器将 token 返回给客户端。</li>
<li>客户端后续请求携带 token。</li>
<li>服务器验证 token 的有效性。</li>
</ol>
<p>我们可以在 <a href="https://jwt.io/">https://jwt.io/</a> 网站对 JWT 进行分析，查看其具体的组成成分。</p>
<h2>2. 基本准备</h2>
<p>在本篇，我们将使用 Go 语言，通过一个完整的案例实现在 HTTP 接口中，使用 JWT 进行用户登录和认证流程。本文假设读者已掌握基本的 Go 语言语法和网络编程经验，并对 <a href="https://github.com/gin-gonic/gin">Gin</a> 框架有基本的了解。</p>
<p>为了快速响应失败，本文案例中使用了封装好的异常处理机制：</p>
<pre><code class="language-go">package utils

var (
	ErrUser = errors.New(&quot;&quot;)
	ErrSys  = errors.New(&quot;&quot;)
)

// 定义用户侧错误，会直接将错误内容返回给用户，不打印日志。
func UserErr(msg string) error {
	return fmt.Errorf(&quot;%w%v&quot;, ErrUser, msg)
}

func UserErrf(format string, a ...any) error {
	return fmt.Errorf(&quot;%w%v&quot;, ErrUser, fmt.Sprintf(format, a...))
}

// 定义系统内部错误，会固定返回 internal server error 给用户，但是会将原始错误信息输出到日志中，便于内部排查。
func SystemErr(err error) error {
	return fmt.Errorf(&quot;%w%v&quot;, ErrSys, err)
}

func SystemErrf(format string, a ...any) error {
	return fmt.Errorf(&quot;%w%v&quot;, ErrSys, fmt.Sprintf(format, a...))
}

func GinErr(c *gin.Context, req any, err error, msgs ...string) {
	if errors.Is(err, ErrUser) {
		c.JSON(http.StatusOK, err.Error())
		return
	}

	msg := &quot;internal server error&quot;
	if len(msgs) &gt; 0 {
		msg = msgs[0]
	}
	slog.Error(msg,
		slog.Any(&quot;req&quot;, req),
		slog.String(&quot;err&quot;, err.Error()),
	)
	c.JSON(http.StatusOK, &quot;internal server error&quot;)
}
</code></pre>
<h2>3. 实现用户认证</h2>
<p>在进行实际代码编写之前，你需要先初始化好项目并引入 <code>jwt</code> 依赖：</p>
<pre><code class="language-shell">go get -u github.com/golang-jwt/jwt/v5
</code></pre>
<p>在代码中使用的时候，可以：</p>
<pre><code class="language-go">import &quot;github.com/golang-jwt/jwt/v5&quot;
</code></pre>
<p>那接下来我们就正式开始我们的功能实现。</p>
<h3>3.1 定义 Claims 结构</h3>
<p>首先，我们需要定义 JWT 的载荷（Payload）结构，即决定将什么信息存储在 token 当中。</p>
<pre><code class="language-go">type UserClaims struct {
    jwt.RegisteredClaims
    UserID    uint64 `json:&quot;user_id&quot;`    // 用户ID
    UserAgent string `json:&quot;user_agent&quot;`  // 用户设备信息
}
</code></pre>
<p>这里我们：</p>
<ul>
<li><p>组合了 <code>jwt.RegisteredClaims</code>，它包含了标准的 JWT 字段（如过期时间），帮助我们实现了 <code>jwt.Clamis</code> 接口：</p>
<pre><code class="language-go">type Claims interface {
    GetExpirationTime() (*NumericDate, error)
    GetIssuedAt() (*NumericDate, error)
    GetNotBefore() (*NumericDate, error)
    GetIssuer() (string, error)
    GetSubject() (string, error)
    GetAudience() (ClaimStrings, error)
}
</code></pre>
<p><code>jwt.RegisteredClaims</code> 的实现如下：</p>
<pre><code class="language-go">type RegisteredClaims struct {
    Issuer string `json:&quot;iss,omitempty&quot;`
    Subject string `json:&quot;sub,omitempty&quot;`
    Audience ClaimStrings `json:&quot;aud,omitempty&quot;`
    ExpiresAt *NumericDate `json:&quot;exp,omitempty&quot;`
    NotBefore *NumericDate `json:&quot;nbf,omitempty&quot;`
    IssuedAt *NumericDate `json:&quot;iat,omitempty&quot;`
    ID string `json:&quot;jti,omitempty&quot;`
}
func (c RegisteredClaims) GetExpirationTime() (*NumericDate, error) {
    return c.ExpiresAt, nil
}
func (c RegisteredClaims) GetNotBefore() (*NumericDate, error) {
    return c.NotBefore, nil
}
func (c RegisteredClaims) GetIssuedAt() (*NumericDate, error) {
    return c.IssuedAt, nil
}
func (c RegisteredClaims) GetAudience() (ClaimStrings, error) {
    return c.Audience, nil
}
func (c RegisteredClaims) GetIssuer() (string, error) {
    return c.Issuer, nil
}
func (c RegisteredClaims) GetSubject() (string, error) {
    return c.Subject, nil
}
</code></pre>
</li>
<li><p>添加了自定义字段 <code>UserID</code> 和 <code>UserAgent</code> 用于安全控制。你可以根据自己的业务需求，添加任意非敏感信息到这个结构中。</p>
</li>
</ul>
<h3>3.2 登录接口实现</h3>
<pre><code class="language-go">const (
   AccessTokenDuration = time.Minute * 15
   RefreshTokenDuration = time.Hour * 24 * 7
)

func (u *UserHandler) LoginJWT(ctx *gin.Context) {
    // 1. 校验用户信息，在本案例中，使用邮箱加密码进行登录
    user, err := u.svc.Login(ctx.Request.Context(), req.Email, req.Password)
    if err != nil {
        utils.GinErr(ctx, req, utils.UserErr(err), &quot;login failed&quot;)
        return
    }

    // 2. 创建 JWT Claims
    accessClaims := UserClaims{
        UserID:    user.ID,
        UserAgent: ctx.Request.UserAgent(),
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)), // 15分钟过期
        },
    }

    // 3. 生成 Access Token
    accessToken := jwt.NewWithClaims(jwt.SigningMethodHS512, accessClaims)
    accessTokenStr, err := accessToken.SignedString(AccessTokenKey)
    if err != nil {
        utils.GinErr(ctx, req, utils.SystemErr(err), &quot;generate access token failed&quot;)
        return
    }

    // 4. 生成 Refresh Token，用于 Token 续期
    refreshClaims := RefreshClaims{
        UserID:    user.ID,
        UserAgent: ctx.Request.UserAgent(),
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)), // 7天过期
        },
    }
    refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS512, refreshClaims)
    refreshTokenStr, err := refreshToken.SignedString(RefreshTokenKey)
    if err != nil {
        utils.GinErr(ctx, req, utils.SystemErr(err), &quot;generate refresh token failed&quot;)
        return
    }

    // 5. 返回两个 token
    ctx.Header(&quot;x-jwt-token&quot;, accessTokenStr)
    ctx.Header(&quot;x-refresh-token&quot;, refreshTokenStr)
    ctx.JSON(http.StatusOK, &quot;login success&quot;)
}
</code></pre>
<h3>3.3 JWT 中间件实现</h3>
<pre><code class="language-go">type LoginJWTMiddlewareBuilder struct {
	whiteList []string
}

func NewLoginJWTMiddlewareBuilder() *LoginJWTMiddlewareBuilder {
	return &amp;LoginJWTMiddlewareBuilder{
		whiteList: []string{},
	}
}

func (b *LoginJWTMiddlewareBuilder) IgnorePaths(paths ...string) *LoginJWTMiddlewareBuilder {
	b.whiteList = append(b.whiteList, paths...)
	return b
}

func (b *LoginJWTMiddlewareBuilder) Build() gin.HandlerFunc {
    return func(ctx *gin.Context) {
        // 1. 提取 token
        authCode := ctx.GetHeader(&quot;Authorization&quot;)
        tokenStr := strings.TrimPrefix(authCode, &quot;Bearer &quot;)

        // 2. 解析和验证 token
        uc := web.UserClaims{}
        token, err := jwt.ParseWithClaims(tokenStr, &amp;uc, func(token *jwt.Token) (interface{}, error) {
            return web.AccessTokenKey, nil
        })

        // 3. 验证 token 有效性
        if token == nil || !token.Valid {
            ctx.AbortWithStatus(http.StatusUnauthorized)
            return
        }

        // 4. 验证 UserAgent
        if uc.UserAgent != ctx.Request.UserAgent() {
            ctx.AbortWithStatus(http.StatusUnauthorized)
            return
        }

        // 5. 设置用户信息到上下文
        ctx.Set(&quot;user_id&quot;, uc.UserID)
      	ctx.Set(&quot;claims&quot;, uc)
    }
}
</code></pre>
<h3>3.4 注册中间件</h3>
<pre><code class="language-go">func initWebServer() *gin.Engine {
	server := gin.Default()

	server.Use(
		middleware.CORS(),
		middleware.NewLoginJWTMiddlewareBuilder().
			IgnorePaths(&quot;/users/signup&quot;).
			IgnorePaths(&quot;/users/login&quot;).
			Build(),
	)
	web.RegisterRoutes(server)
	return server
}

func RegisterRoutes(server *gin.Engine) {
  // ...
	userHandler.RegisterRoutes(server)
}

func (u *UserHandler) RegisterRoutes(server *gin.Engine) {
  ur := server.Group(&quot;/users&quot;)
  ur.POST(&quot;/login&quot;, u.LoginJWT)
  // ...
}
</code></pre>
<h2>4. 在其他接口中使用 Token 的相关信息</h2>
<pre><code class="language-go">func (u *UserHandler) Profile(ctx *gin.Context) {
  // 可以获取 user_id
	userID := ctx.GetUint64(&quot;user_id&quot;)
  // 也可以直接获取整个 claims。
  // 这里我们可以选择不进行断言，因为理论上我们的可以保证这里通过断言。
  // 如果这里发生 panic 了，则说明我们的内部逻辑没有形成闭环，存在问题。
  // panic 可以第一时间暴露问题，然后被解决掉。
  // 不过这个时候建议你使用 gin 的 recover 中间件进行全局保护，避免整个服务因为 panic 而宕机。
  uc, _ := ctx.Get(&quot;claims&quot;)
	userClaims := uc.(*UserClaims)
  // ...
}
</code></pre>
<h2>5. Refresh Token 机制</h2>
<h3>5.1 添加刷新 Token 接口</h3>
<pre><code class="language-go">func (u *UserHandler) RefreshToken(ctx *gin.Context) {
    // 从请求头获取 Refresh Token
    refreshTokenStr := ctx.GetHeader(&quot;x-refresh-token&quot;)
    if refreshTokenStr == &quot;&quot; {
        ctx.AbortWithStatus(http.StatusUnauthorized)
        return
    }

    // 解析和验证 Refresh Token
    var refreshClaims RefreshClaims
    refreshToken, err := jwt.ParseWithClaims(refreshTokenStr, &amp;refreshClaims, func(token *jwt.Token) (interface{}, error) {
        return RefreshTokenKey, nil
    })
    if err != nil || !refreshToken.Valid {
        ctx.AbortWithStatus(http.StatusUnauthorized)
        return
    }

    // 验证 User Agent
    if refreshClaims.UserAgent != ctx.Request.UserAgent() {
        ctx.AbortWithStatus(http.StatusUnauthorized)
        return
    }

    // 生成新的 Access Token
    accessClaims := UserClaims{
        UserID:    refreshClaims.UserID,
        UserAgent: ctx.Request.UserAgent(),
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
        },
    }
    newAccessToken := jwt.NewWithClaims(jwt.SigningMethodHS512, accessClaims)
    newAccessTokenStr, err := newAccessToken.SignedString(AccessTokenKey)
    if err != nil {
        utils.GinErr(ctx, nil, utils.SystemErr(err), &quot;generate new access token failed&quot;)
        return
    }

  	// 对 Refresh Token 进行续期
  	refreshClaims := RefreshClaims{
        UserID:    user.ID,
        UserAgent: ctx.Request.UserAgent(),
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)), // 7天过期
        },
    }
    newRefreshToken := jwt.NewWithClaims(jwt.SigningMethodHS512, refreshClaims)
    newRefreshTokenStr, err := newRefreshToken.SignedString(RefreshTokenKey)
    if err != nil {
        utils.GinErr(ctx, req, utils.SystemErr(err), &quot;generate new refresh token failed&quot;)
        return
    }


    // 返回新的 Access Token 和续期后的 Refresh Token
    ctx.Header(&quot;x-jwt-token&quot;, newAccessTokenStr)
   	ctx.Header(&quot;x-refresh-token&quot;, newRefreshTokenStr)
    ctx.JSON(http.StatusOK, &quot;token refreshed&quot;)
}
</code></pre>
<h3>5.2 注册路由</h3>
<p>在 <code>RegisterRoutes</code> 方法中添加新路由：</p>
<pre><code class="language-go">func (u *UserHandler) RegisterRoutes(server *gin.Engine) {
  ur := server.Group(&quot;/users&quot;)
  ur.POST(&quot;/login&quot;, u.LoginJWT)
  ur.GET(&quot;/profile&quot;, u.Profile)
  ur.POST(&quot;/refresh&quot;, u.RefreshToken)
}
</code></pre>
<h2>6. 客户端使用流程</h2>
<ol>
<li>登录后获取 Access Token 和 Refresh Token</li>
<li>使用 Access Token 访问受保护资源</li>
<li>当 Access Token 过期时调用 /refresh 接口获取新的 Access Token</li>
<li>使用新的 Access Token 继续访问</li>
</ol>
<p>刷新 token 的客户端示例代码（笔者并不擅长写前端代码 hhh，所以这是让 ChatGPT 帮忙写的 😄）：</p>
<pre><code class="language-go">async function refreshAccessToken() {
    const response = await fetch(&#39;/users/refresh&#39;, {
        method: &#39;POST&#39;,
        headers: {
            &#39;x-refresh-token&#39;: localStorage.getItem(&#39;refreshToken&#39;)
        }
    });

    if (response.ok) {
        const newAccessToken = response.headers.get(&#39;x-jwt-token&#39;);
        localStorage.setItem(&#39;accessToken&#39;, newAccessToken);
        const newRefreshToken = response.headers.get(&#39;x-refresh-token&#39;);
        localStorage.setItem(&#39;refreshToken&#39;, newRefreshToken);
        return newAccessToken;
    }

    // 如果刷新失败，重定向到登录页
    window.location.href = &#39;/login&#39;;
}
</code></pre>
<h2>7. Token 续期策略对比</h2>
<p>在前面案例中，细心的读者可以观察到我们对 <code>AccessToken</code> 和 <code>RefreshToken</code> 分别采用了 2 种不同的续期策略。</p>
<h3>自动续期</h3>
<p><strong>优点：</strong></p>
<ul>
<li>简单易用：在每次请求时自动检查并续期 Token，用户体验流畅。</li>
<li>无额外存储需求：不需要存储 Refresh Token，减少了存储和管理的复杂性</li>
</ul>
<p><strong>缺点：</strong></p>
<ul>
<li>安全性较低：如果 Token 被盗用，攻击者可以通过自动续期保持长时间的访问。</li>
<li>Token 过期时间不固定：Token 的有效期会不断延长，难以控制。</li>
</ul>
<h3>Refresh Token</h3>
<p><strong>优点：</strong></p>
<ul>
<li>更高的安全性：即使 Access Token 被盗用，攻击者也无法续期，除非同时获取 Refresh Token。</li>
<li>可控的 Token 生命周期：Access Token 有固定的短期有效期，Refresh Token 有较长的有效期。</li>
<li>支持 Token 撤销：可以实现 Refresh Token 的黑名单机制，支持手动撤销。</li>
</ul>
<p><strong>缺点：</strong></p>
<ul>
<li>实现复杂度较高：需要额外的接口和逻辑来处理 Refresh Token。</li>
<li>存储需求：需要安全存储 Refresh Token，可能需要数据库支持。</li>
</ul>
<h2>8. 总结</h2>
<p>JWT 实现用户认证的优势在于无状态、跨域支持和灵活性。通过合理使用 JWT 和选择合适的 Token 续期策略，我们可以构建安全、可靠的用户认证系统。希望本文能帮助您在 Go 项目中更好地实现 JWT 认证。</p>
]]></content:encoded>
    </item>
    <item>
      <title>深入 Go 语言核心：map 和 slice 的传参有什么不同</title>
      <link>https://hedon.top/blog/go-slice-vs-map/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-slice-vs-map/</guid>
      <pubDate>Fri, 14 Feb 2025 15:34:05 GMT</pubDate>
      <description>本文通过一个令人困惑的例子开始，探讨 Go 语言中 map 和 slice 动态扩容机制与传参时需要注意的问题。</description>
      <category>Go</category>
      <content:encoded><![CDATA[<p>在 Go 开发中，经常会遇到需要在函数中修改 map 或 slice 的场景。虽然它们都支持动态扩容，但在函数传参时的行为却大不相同。今天，让我们通过实例深入理解这个问题。</p>
<h2>一个困惑的开始</h2>
<p>看这样一个例子：</p>
<pre><code class="language-go">func main() {
    // Map 示例
    m := map[string]int{&quot;old&quot;: 1}
    modifyMap(m)
    fmt.Println(m) // 输出: map[new:1]

    // Slice 示例
    s := []int{1, 2, 3}
    modifySlice(s)
    fmt.Println(s) // 输出: [100 2 3]，而不是 [100 2 3 200]
}

func modifyMap(m map[string]int) {
    m[&quot;new&quot;] = 1        // 会影响原始 map
    delete(m, &quot;old&quot;)    // 也会影响原始 map
}

func modifySlice(s []int) {
    s[0] = 100          // 会影响原始 slice
    s = append(s, 200)  // 不会影响原始 slice
}
</code></pre>
<p>有趣的是：</p>
<ol>
<li>map 的所有操作都会影响原始数据</li>
<li>slice 的简单索引修改会影响原始数据，但 append 可能不会</li>
</ol>
<p>为什么会这样？让我们从内部结构开始分析。</p>
<h2>内部结构解析</h2>
<h3>Map 的内部结构</h3>
<pre><code class="language-go">type hmap struct {
    count      int            // 元素个数
    flags      uint8          // 状态标志
    B          uint8          // 桶的对数 B
    buckets    unsafe.Pointer // 指向桶数组的指针
    // ... 其他字段
}
</code></pre>
<p>当我们声明一个 map 变量时：</p>
<pre><code class="language-go">m := make(map[string]int)
// 实际上 m 是 *hmap，即指向 hmap 结构的指针
</code></pre>
<h3>Slice 的内部结构</h3>
<pre><code class="language-go">type slice struct {
    array unsafe.Pointer  // 指向底层数组的指针
    len   int            // 当前长度
    cap   int            // 当前容量
}
</code></pre>
<p>当我们声明一个 slice 变量时：</p>
<pre><code class="language-go">s := make([]int, 0, 10)
// s 是一个完整的 slice 结构体，而不是指针
</code></pre>
<h2>深入理解传参行为</h2>
<h3>场景一：简单修改（不涉及扩容）</h3>
<pre><code class="language-go">func modifyBoth(m map[string]int, s []int) {
    m[&quot;key&quot;] = 1   // 通过指针修改原始 map
    s[0] = 100     // 通过指向相同底层数组的指针修改
}
</code></pre>
<p>图解：</p>
<pre><code>Map:
main()中的 m  -----&gt; hmap{...}  &lt;----- modifyBoth()中的 m
(同一个底层结构)

Slice:
main()中的 s      = slice{array: 指向数组1, len: 3, cap: 3}
                           |
                           v
                        [1 2 3]
                           ^
modifyBoth()中的 s = slice{array: 指向数组1, len: 3, cap: 3}
</code></pre>
<h3>场景二：涉及扩容的操作</h3>
<pre><code class="language-go">func expandBoth(m map[string]int, s []int) {
    // map 扩容
    for i := 0; i &lt; 100; i++ {
        m[fmt.Sprintf(&quot;key%d&quot;, i)] = i
    }

    // slice 扩容
    s = append(s, 200)
}
</code></pre>
<p>图解：</p>
<pre><code>Map 扩容过程：
Before:
main()中的 m  -----&gt; hmap{buckets: 指向存储A}
                           ^
expandBoth()中的 m ---------|

After:
main()中的 m  -----&gt; hmap{buckets: 指向更大的存储B}  // 同一个 hmap，只是更新了内部指针
                           ^
expandBoth()中的 m ---------|


Slice 扩容过程：
Before:
main()中的 s      = slice{array: 指向数组A, len: 3, cap: 3}
                           |
                           v
                        [1 2 3]
                           ^
expandBoth()中的 s = slice{array: 指向数组A, len: 3, cap: 3}

After append:
main()中的 s      = slice{array: 指向数组A, len: 3, cap: 3}     // 保持不变
                           |
                           v
                        [1 2 3]

expandBoth()中的 s = slice{array: 指向数组B, len: 4, cap: 6}    // 新的结构体，指向新数组
                           |
                           v
                     [1 2 3 200]
</code></pre>
<h2>关键区别解析</h2>
<ol>
<li><p><strong>传递方式不同</strong>：</p>
<ul>
<li>map 传递的是指针，函数内外使用的是同一个 hmap 结构</li>
<li>slice 传递的是结构体副本，函数内的修改发生在副本上</li>
</ul>
</li>
<li><p><strong>扩容行为不同</strong>：</p>
<ul>
<li>map 扩容时，原有的 hmap 结构保持不变，只更新内部的 buckets 指针</li>
<li>slice 扩容时，会创建新的底层数组，并返回一个指向新数组的新 slice 结构体</li>
</ul>
</li>
<li><p><strong>修改效果不同</strong>：</p>
<ul>
<li>map 的所有操作（包括扩容）都会反映到原始数据</li>
<li>slice 的行为分两种情况：<ul>
<li>不涉及扩容的修改会影响原始数据（因为指向同一个底层数组）</li>
<li>涉及扩容的操作（如 append）会创建新的底层数组，修改不会影响原始数据</li>
</ul>
</li>
</ul>
</li>
</ol>
<h2>最佳实践</h2>
<p>基于以上原理，在编码时应注意：</p>
<ol>
<li>对于 map：</li>
</ol>
<pre><code class="language-go">func modifyMap(m map[string]int) {
    m[&quot;key&quot;] = 1    // 直接修改即可，不需要返回
}
</code></pre>
<ol start="2">
<li>对于 slice：</li>
</ol>
<pre><code class="language-go">func modifySlice(s []int) []int {
    // 如果需要 append 或其他可能导致扩容的操作
    return append(s, 1)
}

// 使用时
s = modifySlice(s)
</code></pre>
<h2>总结</h2>
<p>理解 map 和 slice 的这些差异，关键在于：</p>
<ol>
<li>map 是指针类型，始终指向同一个 hmap 结构</li>
<li>slice 是结构体，包含了指向底层数组的指针</li>
<li>扩容时 map 只更新内部指针，而 slice 需要创建新的底层数组</li>
</ol>
<p>这种设计各有优势：</p>
<ul>
<li>map 的行为更加统一和直观</li>
<li>slice 的设计提供了更多的灵活性和控制权</li>
</ul>
<p>在实际编程中，正确理解和处理这些差异，是写出健壮 Go 代码的关键。</p>
]]></content:encoded>
    </item>
    <item>
      <title>读书笔记丨解密 QUIC/HTTP3：未来互联网的基石</title>
      <link>https://hedon.top/blog/book-quic-http3/</link>
      <guid isPermaLink="true">https://hedon.top/blog/book-quic-http3/</guid>
      <pubDate>Wed, 15 Jan 2025 19:17:20 GMT</pubDate>
      <description>整理阅读《解密 QUIC/HTTP3：未来互联网的基石》笔记。</description>
      <category>quic</category><category>http3</category><category>计算机网络</category><category>计算机基础</category>
      <content:encoded><![CDATA[<h2>1. QUIC 产生背景</h2>
<h3>常见网络协议</h3>
<ul>
<li><code>UDP</code></li>
<li><code>TCP</code></li>
<li><code>SCTP</code>（Stream Control Transmission Protocol）：用于电话网络。</li>
<li><code>KCP</code>：基于 UDP 在应用层实现可靠性传输，牺牲带宽换取效率。</li>
<li><code>RTP</code>（Real-time Transport Protocol）：与 RTCP 配合传输实时数据，如交互式音频和视频数据。<ul>
<li>RTCP：传输控制信息</li>
<li>RTP：传输实时数据</li>
</ul>
</li>
</ul>
<h3>TSL 版本演化</h3>
<ul>
<li><p><code>SSLv2</code>：安全性低</p>
</li>
<li><p><code>SSLv3</code>：分为握手阶段和数据传输阶段。</p>
<ul>
<li>握手阶段完成对端点的认证和确定保护数据传输的密钥。</li>
<li>一旦确定了密钥，后面的数据传输和 SSL 协议过程都受到加密和完整性保护。</li>
</ul>
</li>
<li><p><code>TSL1.0</code>：基于 SSLv3，存在 CBC（Cipher Block Chaining，密文分组链接）加密和解密模式漏洞，使得主动攻击者可以观察到当前记录的 IV（Intiallization Vector，初始化向量），猜测一个数据库，进行数据注入。</p>
</li>
<li><p><code>TSL1.1</code>：修复了 TSL1.0 的一些关键安全问题：</p>
<ul>
<li>BC 加密使用每条记录一个的显式 IV；</li>
<li>为了防止 CBC 填充攻击，使用 bad_record_mac 错误码代替 decryption_failed 回复填充错误；</li>
<li>支持传输参数的 IANA（Internet Assigned Numbers Authority，互联网数字分配机构）注册，增加了传输参数的灵活性；</li>
<li>改进了连接关闭过早情况下的连接恢复问题。</li>
</ul>
<p>有些加密算法还是存在安全漏洞，使用的 MD5 也不安全。</p>
</li>
<li><p><code>TSL1.2</code>：主要关注了架构灵活性和安全问题。</p>
<ul>
<li>架构：<ul>
<li>客户端可以指定自己支持的签名和 hash 算法列表；</li>
<li>支持非协议固定的算法；</li>
</ul>
</li>
<li>安全：<ul>
<li>增加了对 AEAD（Authenticated Encryption with Associated Data 关联数据认证加密）的支持，可以在加密中认证没有加密部分的关键数据，甚至是不在报文中的关键数据，可以保护更大的范围。</li>
<li>规定必须实现密码套件 TLS_RSA_WITH_AES_128_CBC_SHA。</li>
<li>增加了 HMAC-SHA256 密码套件。</li>
<li>删除了包含已废弃算法的 IDEA 和 DES 密码套件。</li>
<li>对 EncryptedPreMasterSecret 版本号进行了更严格的检查。</li>
</ul>
</li>
</ul>
</li>
<li><p><code>TSL1.3</code>：除了增加安全性，重点改进了连接速度，首次连接发送数据最低可以 1-RTT，恢复连接发送数据最低可以 0-RTT。</p>
<ul>
<li>安全：<ul>
<li>删除了所有被证明有问题的对称加密算法，只保留了 AEAD 的加密套件。密码套件的概念也已经改变，将认证和密钥交换机制与加密算法和散列（用于密钥导出函数和握手消息认证码）分离。</li>
<li>删除 RSA 和静态 DH 密码套件，因为静态 RSA 加密预主密钥的方式和使用静态 DH 私钥都不能保证前向安全性，很容易泄露密钥。只保留能保证前向安全的密钥交换算法，如使用临时私钥的 ECDHE（Elliptic Curve Diffie-Hellman Ephemeral，椭圆曲线 DH 临时密钥交换算法）和 DHE（Diffie-Hellman Ephemeral, DH 临时密钥交换算法）。</li>
<li>ServerHello 之后的消息都加密传输。</li>
<li>删除了压缩功能。之前版本的压缩功能由于存在被攻击的风险实际上很少使用，而且现代的压缩基本都在应用层实现，比如 HTTP 就自己实现的压缩。</li>
</ul>
</li>
</ul>
</li>
</ul>
<h3>HTTP 版本演化</h3>
<ul>
<li><p><code>HTTP0.9</code>：仅支持简单的请求响应，只能访问<strong>简单的文本</strong>文档。</p>
</li>
<li><p><code>HTTP1.0</code>：HTTP1 中引入了<strong>请求头和响应头</strong>，请求时可以指定 HTTP 版本号、用户代理、接收类型等，响应可以指明响应状态、内容长度、内容类型等。</p>
</li>
<li><p><code>HTTP1.1</code>：增加了<strong>重用 TCP 连接</strong>（keep-alive）的方法，默认保持连接，除非显式通知关闭连接[插图]。这样可以在一个 TCP 连接上完成多个请求-响应，消除了 TCP 建立的延迟，也避免了新建立的 TCP 连接的慢启动过程。</p>
<ul>
<li>HTTP1.1 在 HTTP 请求首部中增加了 Host 字段，用来支持共享 IP 地址的虚拟主机服务器。</li>
<li>同时支持了更多的方法，如 PUT、PATCH、DELETE、OPTIONS。</li>
<li>引入分块传输支持动态内容。</li>
<li>引入了更多的缓存控制策略。</li>
<li>支持请求部分内容。</li>
</ul>
</li>
<li><p><code>HTTP2</code>：修改了 HTTP1.1 的封装格式，增加了一个二进制分帧层。基于二进制分层，HTTP2 实现了 HTTP 的<strong>多路复用</strong>。HTTP2 为每个请求分配了一个流标识，服务器响应时带上相同的流标识，客户端就可以方便地将响应与请求关联起来，而不用依赖顺序，从而可以降低延迟和提高吞吐量。</p>
<ul>
<li>HTTP2 还增加了首部压缩 HPACK（Header Compression for HTTP2，HTTP2 首部压缩算法）。</li>
<li>支持请求优先级。</li>
<li>支持服务器主动推送。</li>
<li>增加了 ALPN（Application-Layer Protocol Negotiation，应用层协议协商）。</li>
<li>支持认证、加密和完整性保护，即 <code>HTTPS</code>。</li>
</ul>
<p>但多个请求或响应在同一个 TCP 上发送时，仍然受制于 TCP 的队首阻塞问题。</p>
</li>
<li><p><code>HTTP3</code>：基于 <code>QUIC</code> 协议，底层使用 UDP 实现，摆脱了 TCP 的队首阻塞问题。同时改进了 TCP 中存在的一些其他问题，比如拥塞控制、协议僵化、启动慢、重连慢、安全弱等。</p>
<ul>
<li>实现了没有队首阻塞的并发。如果 QUIC 丢了一个报文，仅仅影响对应流的交付，不会阻塞其他流。</li>
<li>与 TLS1.3 紧密合作，尽可能的加密。还增加了 QUIC 报文的首部加密，除保证了报文安全性，提高了攻击门槛，还避免了协议僵化。</li>
<li>选择 UDP 作为底层实现。一方面避免了 TCP 的首部阻塞，另一方面互联网中绝大部分的主机和中间件都是 TCP 和 UDP 的天下，所以天然支持。</li>
<li>用户态实现。不依赖于内核，容易单独升级。</li>
<li>低延迟的建立。实现了首次最低 1-RTT 发送应用数据，恢复连接时发送应用数据最低只需 0-RTT。</li>
<li>无缝的连接迁移。QUIC 的连接基于连接标识，改变 IP 或者 UDP 端口号并不影响连接的识别，因此可以实现无缝的连接迁移。但是负载均衡就麻烦了。</li>
<li>改进的流量控制。</li>
<li>协议行为作为负载。</li>
</ul>
</li>
</ul>
<h2>2. QUIC 报文</h2>
<ul>
<li>长首部报文：用于建立 QUIC 连接和建立连接前发送应用数据。</li>
<li>短首部报文：用于在 QUIC 连接建立后发送应用数据和 QUIC 协议内容。</li>
<li>无状态重置报文：当服务器丢失了连接状态但仍然收到该连接的数据包时，可以发送无状态重置报文通知客户端立即终止连接。</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250122195252117.png" alt="QUIC 报文类型"></p>
<p>初始报文：客户端使用初始报文来发起连接，服务器使用初始报文和握手报文回应客户端的请求。</p>
<p>0-RTT 报文：用于承载 QUIC 连接之前想要发送的数据，一般用于恢复连接后立即发送数据。</p>
<p>握手报文：用来携带服务器和客户端的 TLS 加密握手信息和确认，载荷一般是 CRYPTO 帧和 ACK 帧。</p>
<p>重试报文：是服务器用来验证客户端地址的报文，可以防止源地址欺骗。</p>
<blockquote>
<p>服务器使用重试报文通知客户端按照要求重新发送初始报文，在重试报文中携带重试令牌给客户端，并使用服务器选择的连接标识作为重试报文的源连接标识；客户端需要使用服务器指定的连接标识作为目的连接标识，携带服务器指定的重试令牌，构建新的初始报文，重新发送给服务器。</p>
</blockquote>
<p>版本协商报文：当服务器收到包含自己不支持的版本号的初始报文时，就会发送版本协商报文。客户端收到版本协商报文后需要在其中选择一个自己支持的版本号，重新以新版本号发送初始报文。</p>
<p>短首部报文：一般也叫作 1-RTT 报文，连接在协商出 1-RTT 密钥后就可以发送短首部报文，用于携带应用数据。</p>
]]></content:encoded>
    </item>
    <item>
      <title>匠心码道丨01 编写优质代码的十大黄金法则</title>
      <link>https://hedon.top/blog/clean-code-10-rules/</link>
      <guid isPermaLink="true">https://hedon.top/blog/clean-code-10-rules/</guid>
      <pubDate>Thu, 12 Dec 2024 10:22:49 GMT</pubDate>
      <description>详解编写整洁代码的十大原则，帮你写出更好的代码。</description>
      <category>编程规范</category><category>代码质量</category><category>最佳实践</category><category>匠心码道</category>
      <content:encoded><![CDATA[<p>代码质量的优劣直接影响着项目的可维护性和团队的开发效率。一个经验丰富的开发者不仅要能实现功能，更要善于编写清晰易懂、结构合理的代码。本文将介绍 10 条帮助你编写清晰、易维护且可扩展代码的重要规则。</p>
<h2>规则</h2>
<h3>1. 使用有意义的变量和函数名称</h3>
<p>变量、函数和类的命名应该具有描述性和意义。你的代码应该能够清晰地表达其意图，而无需额外的注释来解释。</p>
<p><strong>反面示例：</strong></p>
<pre><code class="language-javascript">let a = 10;
const d = new Date();
const res = await api.get();
const arr = users.filter(u =&gt; u.a === true);
</code></pre>
<p><strong>正面示例：</strong></p>
<pre><code class="language-javascript">let maxRetries = 10;
const currentDate = new Date();
const userResponse = await api.getUserProfile();
const activeUsers = users.filter(user =&gt; user.isActive === true);
</code></pre>
<p>有意义的命名能讲述代码的故事。读者应该能够仅通过名称就理解变量或函数的用途。</p>
<p>💡实践建议：</p>
<ul>
<li>使用动词前缀命名函数：<code>getUserProfile()</code>、<code>validateInput()</code>、<code>calculateTotal()</code></li>
<li>使用名词命名变量：<code>userCount</code>、<code>activeUsers</code>、<code>orderStatus</code></li>
<li>布尔值使用 is/has/should 等前缀：<code>isValid</code>、<code>hasPermission</code>、<code>shouldUpdate</code></li>
</ul>
<h3>2. 保持函数简短且专注</h3>
<p>函数应该保持简短，并且只做一件事。函数承担的责任越多，测试、调试和理解起来就越困难。</p>
<p><strong>反面示例：</strong></p>
<pre><code class="language-python">def process_order(order):
    # 多个责任：验证、定价、折扣、配送等
    pass
</code></pre>
<p><strong>正面示例：</strong></p>
<pre><code class="language-python">def validate_order(order):
    pass

def calculate_total(order):
    pass

def apply_discount(order):
    pass
</code></pre>
<p>每个函数应该只有一个责任。如果你需要用&quot;和&quot;来描述函数的功能，那么这个函数可能做得太多了。</p>
<p>💡 最佳实践：</p>
<ul>
<li>函数建议保持在 20-30 行以内</li>
<li>如果超过 50 行，应该考虑拆分</li>
<li>一个函数最好不要超过 3 个参数</li>
</ul>
<h3>3. 避免深层嵌套</h3>
<p>深层嵌套的循环和条件语句会使代码难以理解。通过使用提前返回、函数拆分或将大问题分解为小问题来使代码扁平化。</p>
<p><strong>反面示例：</strong></p>
<pre><code class="language-javascript">if (user != null) {
    if (user.isActive()) {
        if (order != null) {
            processOrder(order);
        }
    }
}
</code></pre>
<p><strong>正面示例：</strong></p>
<pre><code class="language-javascript">if (user == null || !user.isActive()) return;
if (order == null) return;
processOrder(order);
</code></pre>
<p>提前返回可以减少读者的认知负担，使代码更简单、更容易理解。</p>
<h3>4. 明智地使用注释</h3>
<p>注释不应该解释代码做了什么；代码本身应该是自解释的。只在必要时使用注释来解释复杂逻辑背后的&quot;原因&quot;，而不是&quot;是什么&quot;。</p>
<p><strong>反面示例：</strong></p>
<pre><code class="language-php">// 设置用户状态为激活
$user-&gt;isActive = true;
</code></pre>
<p><strong>正面示例：</strong></p>
<pre><code class="language-php">// 登录成功后将用户标记为激活状态
$user-&gt;isActive = true;
</code></pre>
<p>注释应该增加价值，解释特定实现背后的原因或解释复杂的业务逻辑。</p>
<h3>5. 保持一致的格式</h3>
<p>一致的代码格式使代码更容易阅读和导航。在项目中使用统一的缩进、间距和对齐方式。</p>
<p><strong>反面示例：</strong></p>
<pre><code class="language-javascript">function calculate(a,b){return a+b;}
</code></pre>
<p><strong>正面示例：</strong></p>
<pre><code class="language-javascript">function calculate(a, b) {
    return a + b;
}
</code></pre>
<p>许多团队使用 Prettier 或 ESLint 等工具来自动格式化并强制执行代码风格规则。</p>
<h3>6. 不要重复自己（DRY 原则）</h3>
<p>代码重复会导致不一致、bug 和不必要的复杂性。应用 DRY 原则可以保持代码库精简，更易于维护。</p>
<p><strong>反面示例：</strong></p>
<pre><code class="language-php">if ($userType == &quot;admin&quot;) {
    // 复杂逻辑
}
if ($userType == &quot;superadmin&quot;) {
    // 相同的复杂逻辑
}
</code></pre>
<p><strong>正面示例：</strong></p>
<pre><code class="language-php">if (userIsAdmin($userType)) {
    // 复杂逻辑
}
</code></pre>
<p>通过将共同逻辑抽象到函数、类或工具中来避免代码重复。</p>
<h3>7. 单一责任原则（SRP）</h3>
<p>每个类和函数应该只有一个改变的理由。遵循单一责任原则使代码模块化，更容易重构。</p>
<p><strong>反面示例：</strong></p>
<pre><code class="language-java">class User {
    void register();
    void login();
    void sendEmail();
}
</code></pre>
<p><strong>正面示例：</strong></p>
<pre><code class="language-java">class User {
    void register();
    void login();
}

class EmailService {
    void sendEmail();
}
</code></pre>
<p>承担太多责任的类更难维护。SRP 使代码更模块化，更容易测试。</p>
<h3>8. 避免魔法数字和字符串</h3>
<p>魔法数字（或字符串）是没有上下文或解释的硬编码值。使用常量或枚举代替，这样可以增加代码的清晰度。</p>
<p><strong>反面示例：</strong></p>
<pre><code class="language-python">discount = 0.05
if user.role == &quot;admin&quot;:
</code></pre>
<p><strong>正面示例：</strong></p>
<pre><code class="language-python">DISCOUNT_RATE = 0.05
ADMIN_ROLE = &quot;admin&quot;
discount = DISCOUNT_RATE
if user.role == ADMIN_ROLE:
</code></pre>
<p>常量为数字或字符串提供了含义，使代码更容易理解。</p>
<h3>9. 编写测试</h3>
<p>单元测试和集成测试确保你的代码按预期工作，并且在进行更改时不会出错。编写测试使代码更可靠，长期更易于维护。</p>
<p><strong>反面示例：</strong></p>
<pre><code class="language-java">// 这个方法没有测试
public void processOrder(Order order) {
    // 逻辑
}
</code></pre>
<p><strong>正面示例：</strong></p>
<pre><code class="language-java">@Test
public void testProcessOrder() {
    Order order = new Order();
    // 断言
}
</code></pre>
<p>测试应该成为你工作流程的一部分，确保代码无 BUG 且稳定。</p>
<h3>10. 保持简单（KISS 原则）</h3>
<p>KISS（Keep It Simple, Stupid）原则提醒我们简单是关键。复杂的解决方案会导致混淆，更难维护。在面对决策时，选择最简单、最直接的方案来满足需求。</p>
<p><strong>反面示例：</strong></p>
<pre><code class="language-javascript">// 过度复杂的购物车商品总价计算
function calculateTotal(items) {
    let total = 0;
    let discount = 0;
    
    // 复杂的折扣计算逻辑
    items.forEach(item =&gt; {
        if (item.category === &#39;electronics&#39;) {
            if (item.price &gt; 1000) {
                discount += item.price * 0.1;
            } else if (item.price &gt; 500) {
                discount += item.price * 0.05;
            }
        } else if (item.category === &#39;books&#39;) {
            if (item.quantity &gt; 3) {
                discount += item.price * item.quantity * 0.15;
            }
        }
        total += item.price * item.quantity;
    });
    
    return total - discount;
}
</code></pre>
<p><strong>正面示例：</strong></p>
<pre><code class="language-javascript">// 将复杂逻辑拆分成小函数
function calculateDiscount(item) {
    if (item.category === &#39;electronics&#39;) {
        return item.price &gt; 1000 ? 0.1 : (item.price &gt; 500 ? 0.05 : 0);
    }
    if (item.category === &#39;books&#39; &amp;&amp; item.quantity &gt; 3) {
        return 0.15;
    }
    return 0;
}

function calculateTotal(items) {
    return items.reduce((total, item) =&gt; {
        const discount = calculateDiscount(item);
        const itemTotal = item.price * item.quantity;
        return total + itemTotal * (1 - discount);
    }, 0);
}
</code></pre>
<p>💡 最佳实践：</p>
<ul>
<li>将复杂逻辑拆分成小的、容易理解的函数</li>
<li>避免在一个函数中处理过多的条件判断</li>
<li>使用清晰的命名来表达意图</li>
<li>保持函数的单一职责</li>
</ul>
<h2>总结</h2>
<p>干净的代码对于可维护性、可读性和协作至关重要。遵循这 10 条规则——使用有意义的命名、保持函数简短、避免魔法数字、编写测试等，将会带来更健壮、更易理解和更易扩展的代码库。编写代码不仅仅是要让它能工作，更要让其他人（包括未来的你）能够轻松理解和扩展。</p>
<h2>代码审查清单</h2>
<p>在提交代码前，可以使用以下清单进行自查：</p>
<ul>
<li><input disabled="" type="checkbox"> 变量和函数名称是否具有描述性</li>
<li><input disabled="" type="checkbox"> 函数是否只做一件事</li>
<li><input disabled="" type="checkbox"> 是否存在重复代码</li>
<li><input disabled="" type="checkbox"> 是否有未使用的魔法数字</li>
<li><input disabled="" type="checkbox"> 是否编写了相应的测试</li>
<li><input disabled="" type="checkbox"> 代码格式是否统一</li>
<li><input disabled="" type="checkbox"> 注释是否有价值</li>
<li><input disabled="" type="checkbox"> 嵌套是否过深</li>
</ul>
<h2>参考</h2>
<ul>
<li><a href="https://www.thecodingdev.com/2024/09/top-10-clean-code-rules-every-developer.html?ref=dailydev">top-10-clean-code-rules-every-developer-should-follow</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>KCP 源码分析与原理总结</title>
      <link>https://hedon.top/blog/kcp/</link>
      <guid isPermaLink="true">https://hedon.top/blog/kcp/</guid>
      <pubDate>Sun, 01 Dec 2024 10:08:02 GMT</pubDate>
      <description>本文详细介绍了游戏开发中常用的网络协议 KCP 的底层原理和源码实现。通过大量图示和原理总结,帮助读者深入理解 KCP 协议的工作机制，包括其快速重传、选择性确认、流量控制等核心特性。</description>
      <category>kcp</category><category>tcp</category><category>网络</category><category>计算机基础</category><category>计算机网络</category>
      <content:encoded><![CDATA[<h2>序言</h2>
<p>本文很大部分参考了 <a href="https://luyuhuang.tech/2020/12/09/kcp.html">详解 KCP 协议的原理和实现</a>，非常感谢该文作者的讲解。本文再此基础上，加入了一些笔者的思考和分析图示，以期更好地理解 KCP 的底层原理。</p>
<h2>结论先行</h2>
<p>KCP 是一个快速可靠协议，能以比 TCP 浪费 10%-20% 的带宽的代价，换取平均延迟降低 30%-40%，且最大延迟降低三倍的传输效果。</p>
<p>TCP 是为流量设计的（每秒内可以传输多少 KB 的数据），讲究的是充分利用带宽。而 KCP 是为流速设计的（单个数据包从一端发送到一端需要多少时间），以 10%-20% 带宽浪费的代价换取了比 TCP 快 30%-40% 的传输速度。TCP 信道是一条流速很慢，但每秒流量很大的大运河，而 KCP 是水流湍急的小激流。</p>
<h3>KCP 增加的带宽在哪里？增加的速度又在哪里？</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240612111337839.png" alt="为什么 KCP 能以比 TCP 浪费 10%-20% 的带宽的代价，换取平均延迟降低 30%-40%？"></p>
<h3>KCP 核心特性</h3>
<p><strong>快速重传</strong>： KCP 支持快速重传机制，不像 TCP 那样依赖超时重传。KCP 可以根据接收方返回的确认信息快速判断哪些数据包已经丢失，并迅速进行重传。</p>
<p><strong>选择性确认（Selective Acknowledgment, SACK）</strong>： KCP 支持 SACK，这允许接收端告知发送端哪些包已经收到，从而仅重传未被确认接收的数据包，减少不必要的重传。</p>
<p><strong>无连接操作</strong>： 基于 UDP 的实现使得 KCP 在传输数据前不需要像 TCP 那样进行三次握手建立连接，这减少了初始的延迟，并使其能在连接性较差的网络环境下更加灵活和快速。</p>
<p><strong>拥塞控制</strong>： KCP 实现了类似 TCP 的拥塞控制算法，但更为简化，能够快速适应网络条件的变化，如带宽波动和丢包。</p>
<p><strong>流量控制</strong>： KCP 允许调整发送和接收的窗口大小，使得发送方可以根据接收方的处理能力和网络条件调整数据发送速率，优化网络利用率和减少拥塞。</p>
<p><strong>可配置的传输策略</strong>： KCP 允许用户根据应用需求调整内部参数，如传输间隔、窗口大小等，以达到最优的传输效率和延迟。</p>
<p><strong>前向错误校正（Forward Error Correction, FEC）</strong>： KCP 还可以结合使用 FEC 技术，通过发送额外的冗余数据来恢复丢失的包，进一步提高在高丢包环境下的数据传输可靠性。</p>
<h3>为什么 TCP 做不到 KCP 这样？</h3>
<p>TCP 作为一种成熟且广泛使用的传输协议，在设计上注重可靠性和通用性，因此在拥塞控制和流量控制方面相对保守，以确保在各种网络条件下都能稳定运行。然而，这些设计上的保守性也导致了 TCP 在某些情况下的灵活性和自适应性不如 KCP。</p>
<table>
<thead>
<tr>
<th>特性类别</th>
<th>协议</th>
<th>描述</th>
</tr>
</thead>
<tbody><tr>
<td>拥塞控制机制</td>
<td>TCP</td>
<td>固定算法（慢启动、拥塞避免等），保守的调整策略（指数和线性增长）</td>
</tr>
<tr>
<td></td>
<td>KCP</td>
<td>灵活算法，动态调整策略，快速调整窗口大小</td>
</tr>
<tr>
<td>重传机制的延迟</td>
<td>TCP</td>
<td>固定重传间隔（RTO），多次确认触发重传，需要主动开启选择性重传（SACK）</td>
</tr>
<tr>
<td></td>
<td>KCP</td>
<td>快速重传，选择性重传，减少重传延迟</td>
</tr>
<tr>
<td>流量控制</td>
<td>TCP</td>
<td>固定流量控制（依赖接收窗口和发送窗口），通用性设计</td>
</tr>
<tr>
<td></td>
<td>KCP</td>
<td>自适应流量控制，应用层反馈调整发送窗口和重传策略</td>
</tr>
<tr>
<td>应用场景</td>
<td>TCP</td>
<td>广泛应用于各种网络环境，标准化要求高</td>
</tr>
<tr>
<td></td>
<td>KCP</td>
<td>优化特定场景（如高丢包率和高延迟网络），灵活实现</td>
</tr>
</tbody></table>
<h4>1. 拥塞控制机制的固定性</h4>
<p><strong>TCP</strong>：</p>
<ul>
<li><strong>固定算法</strong>：TCP 的拥塞控制算法，如慢启动（Slow Start）、拥塞避免（Congestion Avoidance）、快速重传（Fast Retransmit）和快速恢复（Fast Recovery），在设计时考虑了广泛的兼容性和可靠性。这些算法虽然有效，但其调整机制相对固定，响应速度较慢。</li>
<li><strong>保守的调整策略</strong>：TCP 的拥塞控制算法采用了保守的调整策略，例如指数增长和线性增长，这在高丢包率或高延迟网络中，可能会导致拥塞窗口（cwnd）增长速度较慢，影响传输效率。</li>
</ul>
<p><strong>KCP</strong>：</p>
<ul>
<li><strong>灵活算法</strong>：KCP 的拥塞控制机制更为灵活，可以根据实时网络状况进行快速调整。例如，KCP 的快速重传和选择性重传机制，使其能更快速地响应网络丢包情况。</li>
<li><strong>动态调整策略</strong>：KCP 的拥塞窗口调整更为灵活，可以根据网络状况快速增加或减少窗口大小，提高传输效率。</li>
</ul>
<h4>2. 重传机制的延迟</h4>
<p><strong>TCP</strong>：</p>
<ul>
<li><strong>固定重传间隔</strong>：TCP 使用固定的重传超时（RTO），并随着每次重传逐渐增加（指数回退），这种保守的重传机制在高延迟和高丢包率网络中可能导致重传延迟较长。</li>
<li><strong>多次确认触发重传</strong>：TCP 的快速重传需要等待三个重复的 ACK 才能触发，这在丢包率较高的情况下，可能会导致较长的延迟。</li>
</ul>
<p><strong>KCP</strong>：</p>
<ul>
<li><strong>快速重传</strong>：KCP 在检测到丢包后立即进行重传，而不需要等待多个重复的 ACK，这显著减少了重传延迟。</li>
<li><strong>选择性重传</strong>：KCP 只重传丢失的数据包，而不是所有未确认的数据包，减少了不必要的重传开销。（TCP 其实也支持选择性重传 SACK）</li>
</ul>
<h4>3. 流量控制的灵活性</h4>
<p><strong>TCP</strong>：</p>
<ul>
<li><strong>固定流量控制</strong>：TCP 的流量控制主要依赖于接收窗口（rwnd）和发送窗口（swnd），在处理突发流量或变化较大的网络条件时，调整速度较慢。</li>
<li><strong>通用性设计</strong>：TCP 作为一种通用协议，其设计必须兼顾各种网络环境，因此在流量控制上相对保守，以确保在任何环境下都能稳定运行。</li>
</ul>
<p><strong>KCP</strong>：</p>
<ul>
<li><strong>自适应流量控制</strong>：KCP 的流量控制机制可以根据实际应用需求进行更细粒度的调整。例如，KCP 可以根据延迟抖动、丢包率等动态参数调整发送速率，确保在不同网络条件下都能保持高效传输。</li>
<li><strong>应用层反馈</strong>：KCP 可以根据应用层的实时反馈，动态调整发送窗口和重传策略，进一步优化传输效率。</li>
</ul>
<h4>4. 应用场景的差异</h4>
<p><strong>TCP</strong>：</p>
<ul>
<li><strong>广泛应用</strong>：TCP 设计用于广泛的网络环境，包括稳定的有线网络和不稳定的无线网络，因此其机制必须足够通用和保守，保证在各种情况下的可靠性。</li>
<li><strong>标准化要求</strong>：作为互联网的基础协议，TCP 的各项机制经过严格标准化，任何修改都需要广泛测试和验证，以确保不会影响现有网络的稳定性。</li>
</ul>
<p><strong>KCP</strong>：</p>
<ul>
<li><strong>特定优化</strong>：KCP 设计初衷是优化特定场景下的传输性能，特别是高丢包率和高延迟网络，因此在设计上更加灵活，能够根据实时网络状况进行调整。</li>
<li><strong>灵活实现</strong>：KCP 可以根据具体应用需求进行优化，例如在实时通信和在线游戏等场景中，灵活的流量控制和快速重传机制显著提升了传输效率。</li>
</ul>
<h4>结论</h4>
<p>虽然 TCP 在拥塞控制和流量控制方面具备基本的动态调整能力，但其保守的设计和标准化要求使得其在高丢包率和高延迟网络中的适应性和灵活性不如 KCP。KCP 通过灵活的拥塞控制、快速重传和自适应流量控制机制，能够更有效地应对不同网络条件下的传输需求，提供更高效的传输性能。</p>
<h3>KCP 一定比 TCP 快吗？</h3>
<p><font color="red">不一定</font>。KCP 并不一定在所有情况下都比 TCP 快。虽然 KCP 在某些特定网络环境（如高丢包率和高延迟的网络）中表现更优异，但在某些情况下，TCP 可能更合适。</p>
<h4>1. 网络环境</h4>
<p><strong>高丢包率和高延迟网络</strong>：</p>
<ul>
<li><strong>KCP</strong>：KCP 通过快速重传和选择性重传机制，以及动态调整的窗口和重传间隔，能够更好地应对高丢包率和高延迟网络，减少传输延迟，提高传输效率。</li>
<li><strong>TCP</strong>：TCP 的重传机制和保守的拥塞控制在这种环境中可能导致较高的延迟和较低的带宽利用率。</li>
</ul>
<p><strong>低丢包率和低延迟网络</strong>：</p>
<ul>
<li><strong>KCP</strong>：在稳定的低丢包率和低延迟网络中，KCP 的频繁重传和控制报文可能会导致额外的带宽开销，未必有明显的性能优势。</li>
<li><strong>TCP</strong>：TCP 在这种环境中表现稳定，且由于其带宽开销较小，可能比 KCP 更高效。</li>
</ul>
<h4>2. 带宽利用率</h4>
<p><strong>带宽充足的网络</strong>：</p>
<ul>
<li><strong>KCP</strong>：KCP 由于其频繁的重传和控制报文，可能会占用更多的带宽，但如果带宽充足，这种开销对整体性能影响较小，且其低延迟优势可能更明显。</li>
<li><strong>TCP</strong>：TCP 的带宽利用率较高，适合带宽充足的环境。</li>
</ul>
<p><strong>带宽受限的网络</strong>：</p>
<ul>
<li><strong>KCP</strong>：KCP 的额外带宽开销在带宽受限的网络中可能会显著影响整体传输效率。</li>
<li><strong>TCP</strong>：TCP 的较低带宽开销使其在带宽受限的环境中更有优势。</li>
</ul>
<h4>3. 应用场景</h4>
<p><strong>实时应用</strong>（如在线游戏、视频会议）：</p>
<ul>
<li><strong>KCP</strong>：KCP 的低延迟和快速响应能力使其非常适合实时应用，在这些场景中，传输的及时性比带宽利用率更重要。</li>
<li><strong>TCP</strong>：TCP 在这些场景中的表现可能不如 KCP，特别是在高丢包率和高延迟的网络中。</li>
</ul>
<p><strong>非实时应用</strong>（如文件传输、网页浏览）：</p>
<ul>
<li><strong>KCP</strong>：KCP 在这些场景中可能不如 TCP 高效，特别是在网络稳定且带宽有限的情况下。</li>
<li><strong>TCP</strong>：TCP 的可靠性和高带宽利用率使其非常适合非实时应用。</li>
</ul>
<h4>4. 实现和配置</h4>
<p><strong>实现复杂性</strong>：</p>
<ul>
<li><strong>KCP</strong>：实现和配置 KCP 可能比 TCP 更复杂，需要根据具体应用和网络环境进行优化和调整。</li>
<li><strong>TCP</strong>：TCP 是一个成熟的协议，系统和库的支持较好，配置和使用相对简单。</li>
</ul>
<h4>总结</h4>
<p>KCP 在某些特定环境和应用场景中确实比 TCP 更快，尤其是高丢包率和高延迟的网络环境，以及对低延迟要求较高的实时应用。但在网络稳定、带宽有限或非实时应用场景中，TCP 可能表现更好。因此，选择使用 KCP 还是 TCP 应根据具体的网络条件和应用需求进行权衡。</p>
<h2>前置准备</h2>
<p>笔者不想那么快就贴出大段大段的代码进行分析，这可能会使读者不知所云。为了更好地阐述 KCP 的底层原理，笔者的设想是先对原理部分进行概要总结，然后再带着这些结论去分析源码，进一步填充里面的边角细节。</p>
<p>但是呢，为了更好地理解 KCP 的原理，又不得不对涉及源码的一些重要设计，为了避免在原理分析阶段，对源码进行过多的涉及，笔者决定添加这单独的一章内容，对 KCP 的“接口设计”、“报文段”、“KCP 控制块”以及“队列和缓冲区”先进行简要概述，以辅助读者更好地理解后续的内容。</p>
<h3>接口设计</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240612171839051.png" alt="KCP 工作简约图"></p>
<p>在 <a href="https://github.com/skywind3000/kcp/blob/master/ikcp.h">kcp.h</a> 文件中，定义了 KCP 最核心的几个接口：</p>
<pre><code class="language-c">// 创建一个新的 KCP 控制对象
ikcpcb* ikcp_create(IUINT32 conv, void *user);

// 释放一个 KCP 控制对象。
void ikcp_release(ikcpcb *kcp);

// 设置 KCP 的输出回调函数，这个回调函数在 KCP 需要发送数据时被调用。
void ikcp_setoutput(ikcpcb *kcp, int (*output)(const char *buf, int len,
	ikcpcb *kcp, void *user));

// 从 KCP 的接收队列中接收数据，用于上层从 KCP 中读取数据。
int ikcp_recv(ikcpcb *kcp, char *buffer, int len);

// 向 KCP 的发送队列中添加数据，用于上层向 KCP 发送数据，KCP 会管理这些数据并负责其可靠传输。
int ikcp_send(ikcpcb *kcp, const char *buffer, int len);

// 更新 KCP 的内部状态，通常需要定期调用。
// 这个函数负责处理 KCP 的超时、重传等操作，需要在一定的时间间隔内反复调用（通常每 10-100 毫秒）。
void ikcp_update(ikcpcb *kcp, IUINT32 current);

// 判断是否要调用 ikcp_update
IUINT32 ikcp_check(const ikcpcb *kcp, IUINT32 current);

// 处理接收到的低层数据包（例如 UDP 包）。
int ikcp_input(ikcpcb *kcp, const char *data, long size);

// 将缓冲区可以发送的包发送出去，会在 ikcp_update 中被调用。
void ikcp_flush(ikcpcb *kcp);
</code></pre>
<ul>
<li><p><code>ikcp_create</code>:</p>
<ul>
<li><code>conv</code>: 会话标识符，用于标识两个端点之间的连接。这个标识符在两个通信端点之间必须一致。</li>
<li><code>user</code>: 用户数据指针，可以传递任意用户数据，这个数据在 KCP 的 <code>output</code> 回调中会被传递回去。</li>
<li><strong>返回值</strong>: 一个指向新创建的 KCP 控制块（<code>ikcpcb</code>）的指针。</li>
</ul>
</li>
<li><p><code>ikcp_release</code>: 释放一个 KCP 控制对象。</p>
</li>
<li><p><code>ikcp_setoutput</code>: 设置 KCP 的输出回调函数。</p>
<ul>
<li><p><code>output</code>: 输出回调函数指针。这个回调函数在 KCP 需要发送数据时被调用。</p>
<ul>
<li><code>buf</code>: 要发送的数据缓冲区。</li>
<li><code>len</code>: 数据长度。</li>
<li><code>kcp</code>: 当前的 KCP 对象。</li>
<li><code>user</code>: 用户数据。</li>
</ul>
<p>通过这个回调，KCP 可以将要发送的数据传递给下层的网络层，比如 UDP 套接字。</p>
</li>
</ul>
</li>
<li><p><code>ikcp_recv</code>: 从 KCP 的接收队列中接收数据。</p>
<ul>
<li><code>kcp</code>: KCP 控制对象的指针。</li>
<li><code>buffer</code>: 用户提供的缓冲区，用于存储接收到的数据。</li>
<li><code>len</code>: 缓冲区的长度。</li>
<li><strong>返回值</strong>: 成功接收的数据大小；如果没有数据可接收，返回负值（例如，EAGAIN）。</li>
</ul>
<p>这个函数用于上层从 KCP 中读取数据。</p>
</li>
<li><p><code>ikcp_send</code>: 向 KCP 的发送队列中添加数据。</p>
<ul>
<li><code>kcp</code>: KCP 控制对象的指针。</li>
<li><code>buffer</code>: 要发送的数据缓冲区。</li>
<li><code>len</code>: 数据的长度。</li>
<li><strong>返回值</strong>: 成功发送的数据大小；如果发送失败，返回负值。</li>
</ul>
<p>这个函数用于上层向 KCP 发送数据，KCP 会管理这些数据并负责其可靠传输。</p>
</li>
<li><p><code>ikcp_update</code>: 更新 KCP 的内部状态，通常需要定期调用。</p>
<ul>
<li><code>kcp</code>: KCP 控制对象的指针。</li>
<li><code>current</code>: 当前的时间戳（以毫秒为单位）。</li>
</ul>
<p>这个函数负责处理 KCP 的超时、重传等操作，需要在一定的时间间隔内反复调用（通常每 10-100 毫秒）。</p>
</li>
<li><p><code>ikcp_input</code>: 处理接收到的低层数据包（例如 UDP 包）。</p>
<ul>
<li><code>kcp</code>: KCP 控制对象的指针。</li>
<li><code>data</code>: 收到的数据缓冲区。</li>
<li><code>size</code>: 数据的长度。</li>
<li><strong>返回值</strong>: 成功处理的数据大小；如果处理失败，返回负值。</li>
</ul>
</li>
<li><p><code>ikcp_flush</code>: 刷新待发送的数据。</p>
</li>
</ul>
<p>其中最重要的是这 4 个：</p>
<ul>
<li><code>ikcp_send</code>: 将数据放在发送队列中等待发送。</li>
<li><code>ikcp_recv</code>: 从接收队列中读取数据。</li>
<li><code>ikcp_input</code>: 读取下层协议输入数据，解析报文段，如果是数据，就将数据放入接收缓冲区，如果是 ACK，就在发送缓冲区中标记对应的报文段已送达。</li>
<li><code>ikcp_flush</code>: 调用输出回调将发送缓冲区的数据发送出去。</li>
</ul>
<p>这里就先简要介绍到这里，后面在源码分析篇章再对这些接口进行详细分析。</p>
<h3>报文段</h3>
<p>KCP 的报文段大小为 24 字节，结构如下图所示：</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240612172557464.png" alt="KCP 报文段" style="zoom: 33%;" />

<p>每个字段的含义如下：</p>
<ul>
<li><code>conv</code>: 连接标识</li>
<li><code>cmd</code>：报文类型</li>
<li><code>frg</code>：分片数量，表示随后还有多少个报文属于同一个包</li>
<li><code>wnd</code>：发送方剩余接收窗口的大小</li>
<li><code>ts</code>：时间戳</li>
<li><code>sn</code>：报文编号</li>
<li><code>una</code>：发送方的接收缓冲区中最小还未收到的报文段的编号，也就是说，比它小的报文段都已全部接收</li>
<li><code>len</code>：数据段长度</li>
<li><code>data</code>：数据段，只有数据报文会有这个字段</li>
</ul>
<p>其中 <code>cmd</code> 共有 4 种报文类型：</p>
<ul>
<li>数据报文：IKCP_CMD_PUSH</li>
<li>确认报文：IKCP_CMD_ACK</li>
<li>窗口探测报文：IKCP_CMD_WASK 询问对端剩余接收窗口的大小</li>
<li>窗口通知报文：IKCP_CMD_WINS 通知对端剩余接收窗口的大小</li>
</ul>
<p>在 KCP 中，报文段结构定义在 <a href="https://github.com/skywind3000/kcp/blob/master/ikcp.h">kcp.h</a> 文件中，如下：</p>
<pre><code class="language-c">struct IKCPSEG
{
	struct IQUEUEHEAD node;
	IUINT32 conv;
	IUINT32 cmd;
	IUINT32 frg;
	IUINT32 wnd;
	IUINT32 ts;
	IUINT32 sn;
	IUINT32 una;
	IUINT32 len;
	IUINT32 resendts;
	IUINT32 rto;
	IUINT32 fastack;
	IUINT32 xmit;
	char data[1];
};
</code></pre>
<p><code>IKCPSEG</code> 结构还多出了几个字段，这是为了支持 KCP 协议的可靠性和效率：</p>
<ul>
<li><code>resendts</code>: 记录报文的下次重传时间，用于实现重传机制。如果报文在一定时间内没有被确认收到，就会在这个时间戳之后被重新发送。</li>
<li><code>rto</code>: 表示当前报文的重传超时时间（RTT 的估计值）。用于计算每个报文的重传时间，如果超过 <code>rto</code> 时间没有收到 ACK，会触发重传。</li>
<li><code>fastack</code>: 快速重传计数，记录该报文被跳过的次数。如果一个报文的 ACK 连续接收到多个对同一报文的确认，而不是新的报文，会增加这个计数，用于实现快速重传机制。</li>
<li><code>xmit</code>: 记录报文已经被发送的次数。用于统计一个报文的重传次数，帮助判断传输的可靠性。如果操作 <code>dead_link</code> 次，则会判断为连接失效，KCP 会断开连接。</li>
<li><code>node</code>: 链表节点，用于将多个 <code>IKCPSEG</code> 结构体链接在一起。KCP 的队列和缓冲区都是循环双链表结构。</li>
</ul>
<p>这些字段共同作用，帮助 KCP 实现以下功能：</p>
<ul>
<li><strong>可靠性</strong>：通过 <code>sn</code>、<code>una</code> 和 <code>ack</code> 确保数据包按顺序接收和重传。</li>
<li><strong>流量控制</strong>：通过 <code>wnd</code> 控制数据流量，避免接收方过载。</li>
<li><strong>高效传输</strong>：通过 <code>resendts</code> 和 <code>rto</code> 进行超时和重传控制，<code>fastack</code> 提供快速重传机制。</li>
<li><strong>灵活管理</strong>：使用链表节点 <code>node</code> 组织数据，便于内部管理。</li>
</ul>
<h3>KCP 控制块 ikcpcb</h3>
<p>上面我们提到的 <code>ikcp_create</code> 和 <code>ikcp_release</code> 就是对 KCP 控制块 <code>ikcpcb</code> 的创建和释放，每个 KCP 连接都对应一个 KCP 控制块。它定义在 <a href="https://github.com/skywind3000/kcp/blob/master/ikcp.h#L343">kcp.h</a> 中：</p>
<pre><code class="language-c">struct IKCPCB
{
	IUINT32 conv, mtu, mss, state;
	IUINT32 snd_una, snd_nxt, rcv_nxt;
	IUINT32 ts_recent, ts_lastack, ssthresh;
	IINT32 rx_rttval, rx_srtt, rx_rto, rx_minrto;
	IUINT32 snd_wnd, rcv_wnd, rmt_wnd, cwnd, probe;
	IUINT32 current, interval, ts_flush, xmit;
	IUINT32 nrcv_buf, nsnd_buf;
	IUINT32 nrcv_que, nsnd_que;
	IUINT32 nodelay, updated;
	IUINT32 ts_probe, probe_wait;
	IUINT32 dead_link, incr;
	struct IQUEUEHEAD snd_queue;
	struct IQUEUEHEAD rcv_queue;
	struct IQUEUEHEAD snd_buf;
	struct IQUEUEHEAD rcv_buf;
	IUINT32 *acklist;
	IUINT32 ackcount;
	IUINT32 ackblock;
	void *user;
	char *buffer;
	int fastresend;
	int fastlimit;
	int nocwnd, stream;
	int logmask;
	int (*output)(const char *buf, int len, struct IKCPCB *kcp, void *user);
	void (*writelog)(const char *log, struct IKCPCB *kcp, void *user);
};
</code></pre>
<p>字段的含义如下，读者可在后续分析过程回过来查阅：</p>
<table>
<thead>
<tr>
<th>字段名</th>
<th>含义</th>
</tr>
</thead>
<tbody><tr>
<td><code>conv</code></td>
<td>连接标识符，用于识别一个特定的会话。</td>
</tr>
<tr>
<td><code>mtu</code></td>
<td>最大传输单元（Maximum Transmission Unit），表示网络层传输数据包的最大字节数。</td>
</tr>
<tr>
<td><code>mss</code></td>
<td>最大报文段长度（Maximum Segment Size），表示应用层传输数据的最大字节数。</td>
</tr>
<tr>
<td><code>state</code></td>
<td>连接状态，标识当前的传输状态。</td>
</tr>
<tr>
<td><code>snd_una</code></td>
<td>未确认的发送序号，表示最早未确认的包的序号。</td>
</tr>
<tr>
<td><code>snd_nxt</code></td>
<td>下一个发送序号，表示即将发送的包的序号。</td>
</tr>
<tr>
<td><code>rcv_nxt</code></td>
<td>下一个接收序号，表示期望接收的下一个包的序号。</td>
</tr>
<tr>
<td><code>ts_recent</code></td>
<td>最近的时间戳，用于延迟测量。</td>
</tr>
<tr>
<td><code>ts_lastack</code></td>
<td>最近的确认时间戳，用于 RTT 计算。</td>
</tr>
<tr>
<td><code>ssthresh</code></td>
<td>拥塞避免的慢启动阈值。</td>
</tr>
<tr>
<td><code>rx_rttval</code></td>
<td>RTT 的偏差，用于计算 RTT 的波动。</td>
</tr>
<tr>
<td><code>rx_srtt</code></td>
<td>平滑的 RTT 值，用于计算平均 RTT。</td>
</tr>
<tr>
<td><code>rx_rto</code></td>
<td>重新传输超时时间，根据 RTT 动态调整。</td>
</tr>
<tr>
<td><code>rx_minrto</code></td>
<td>最小的重新传输超时时间。</td>
</tr>
<tr>
<td><code>snd_wnd</code></td>
<td>发送窗口大小，控制发送流量的窗口。</td>
</tr>
<tr>
<td><code>rcv_wnd</code></td>
<td>接收窗口大小，控制接收流量的窗口。</td>
</tr>
<tr>
<td><code>rmt_wnd</code></td>
<td>远端窗口大小，表示对方接收窗口的大小。</td>
</tr>
<tr>
<td><code>cwnd</code></td>
<td>拥塞窗口大小，控制发送流量的窗口，用于拥塞控制。</td>
</tr>
<tr>
<td><code>probe</code></td>
<td>探测标志，表示是否需要进行窗口探测。</td>
</tr>
<tr>
<td><code>current</code></td>
<td>当前的时间戳。</td>
</tr>
<tr>
<td><code>interval</code></td>
<td>刷新间隔时间，表示定期刷新 KCP 状态的间隔。</td>
</tr>
<tr>
<td><code>ts_flush</code></td>
<td>下次刷新时间戳，用于确定何时执行下一次状态刷新。</td>
</tr>
<tr>
<td><code>xmit</code></td>
<td>发送次数，表示数据包重传的次数。</td>
</tr>
<tr>
<td><code>nrcv_buf</code></td>
<td>接收缓冲区的数据包数量。</td>
</tr>
<tr>
<td><code>nsnd_buf</code></td>
<td>发送缓冲区的数据包数量。</td>
</tr>
<tr>
<td><code>nrcv_que</code></td>
<td>接收队列中的数据包数量。</td>
</tr>
<tr>
<td><code>nsnd_que</code></td>
<td>发送队列中的数据包数量。</td>
</tr>
<tr>
<td><code>nodelay</code></td>
<td>延迟模式标志，表示是否启用无延迟模式。</td>
</tr>
<tr>
<td><code>updated</code></td>
<td>更新标志，表示是否需要更新 KCP 状态。</td>
</tr>
<tr>
<td><code>ts_probe</code></td>
<td>下次探测时间戳，用于窗口探测。</td>
</tr>
<tr>
<td><code>probe_wait</code></td>
<td>探测等待时间，表示等待多长时间后进行下一次窗口探测。</td>
</tr>
<tr>
<td><code>dead_link</code></td>
<td>死链标志，表示连接是否已经失效。</td>
</tr>
<tr>
<td><code>incr</code></td>
<td>增量，用于控制流量的增加速率。</td>
</tr>
<tr>
<td><code>snd_queue</code></td>
<td>发送队列，用于存储待发送的数据包。</td>
</tr>
<tr>
<td><code>rcv_queue</code></td>
<td>接收队列，用于存储待处理的数据包。</td>
</tr>
<tr>
<td><code>snd_buf</code></td>
<td>发送缓冲区，用于存储已经发送但未确认的数据包。</td>
</tr>
<tr>
<td><code>rcv_buf</code></td>
<td>接收缓冲区，用于存储已经接收到但未处理的数据包。</td>
</tr>
<tr>
<td><code>acklist</code></td>
<td>确认列表，用于存储待发送的确认序号。</td>
</tr>
<tr>
<td><code>ackcount</code></td>
<td>确认计数，表示确认列表中的条目数量。</td>
</tr>
<tr>
<td><code>ackblock</code></td>
<td>确认块大小，表示确认列表的内存分配大小。</td>
</tr>
<tr>
<td><code>user</code></td>
<td>用户数据指针，用于存储用户自定义的数据。</td>
</tr>
<tr>
<td><code>buffer</code></td>
<td>缓冲区，用于临时存储发送的数据。</td>
</tr>
<tr>
<td><code>fastresend</code></td>
<td>快速重传标志，表示启用快速重传功能。</td>
</tr>
<tr>
<td><code>fastlimit</code></td>
<td>快速重传限制，表示在一个 RTT 内允许的最大重传次数。</td>
</tr>
<tr>
<td><code>nocwnd</code></td>
<td>无拥塞窗口控制标志，表示是否禁用拥塞窗口控制。</td>
</tr>
<tr>
<td><code>stream</code></td>
<td>流模式标志，表示是否启用流模式。</td>
</tr>
<tr>
<td><code>logmask</code></td>
<td>日志掩码，用于控制日志输出的级别。</td>
</tr>
<tr>
<td><code>output</code></td>
<td>发送数据回调函数，用于发送数据。</td>
</tr>
<tr>
<td><code>writelog</code></td>
<td>日志回调函数，用于输出日志。</td>
</tr>
</tbody></table>
<h3>队列和缓冲区</h3>
<pre><code class="language-c">struct IKCPCB
{
  ...
	struct IQUEUEHEAD snd_queue;
	struct IQUEUEHEAD rcv_queue;
	struct IQUEUEHEAD snd_buf;
	struct IQUEUEHEAD rcv_buf;
  ...
};

struct IQUEUEHEAD {
    struct IQUEUEHEAD *next, *prev;
};
</code></pre>
<p>KCP 中队列和缓冲区都是循环双链表，链表由宏实现，笔者并不擅长，所以本文就不探讨该链表的实现了，有数据结构基础的笔者应该很好理解这一块。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240612175626865.png" alt="队列和缓冲区的实现：循环双链表"></p>
<p>队列和缓冲区是 KCP 最核心的部分，它们的作用流程大概如下图所示，读者可以自行阅读尝试理解，后续我们会进行详细的分析。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240612175950432.png" alt="KCP 队列和缓冲区作用流程"></p>
<h2>原理分析</h2>
<p>这一节我们详细讨论 KCP 的整个 ARQ 流程。首先我们会对整体流程进行简要概述，然后详细讨论滑动窗口中的发送和接收过程，接着讨论超时重传和快速重传，在这之后我们会将 KCP 和 TCP 的重传策略进行简单对比，最后介绍一下拥塞控制策略。</p>
<h3>1. 整体流程</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240612192523848.png" alt="KCP 全流程"></p>
<p>KCP 的全流程如上图所示：</p>
<ol>
<li>发送方调用 <code>ikcp_send</code> 将发送数据，这个时候会创建报文段实例，并放入 <code>snd_queue</code> 发送队列中。</li>
<li>KCP 会定时调用 <code>ikcp_update</code> 判断是否要调用 <code>ikcp_flush</code>。</li>
<li>调用 <code>ikcp_flush</code> 时会将合适的报文段放入 <code>snd_buf</code> 缓冲区中，具体包括：<ol>
<li>发送 ACK 列表中所有 ACK；</li>
<li>根据是否需要发送窗口探测和通知报文，需要则发；</li>
<li>根据发送窗口大小，将适量的报文段从 <code>snd_queue</code> 移入 <code>snd_buf</code> 中；</li>
<li>发送 <code>snd_buf</code> 中的报文，包括<strong>新加入的</strong>、<strong>RTO 内未收到 ACK</strong> 的和 <strong>ACK 失序若干次</strong>的；</li>
<li>根据丢包情况计算 <code>ssthresh</code> 和 <code>cwnd</code>。</li>
</ol>
</li>
<li>发送的时候会调用由 <code>ikcp_setoutput</code> 设置的回调函数，将数据发送到对端。</li>
<li>接收方收到数据后，会调用 <code>ikcp_input</code>，将数据放入 <code>rcv_buf</code> 缓冲区，具体包括：<ol>
<li>根据所有报文的 una 将相应的报文标记为已送达；</li>
<li>如果是 ACK，就将相应的报文标记为已送达；</li>
<li>如果是数据报文，就将它放入 <code>rcv_buf</code>，然后将 <code>rcv_buf</code> 中顺序正确的报文移入 <code>rcv_queue</code> 接收队列中，接着将相关信息插入 ACK 列表，在稍后的 <code>ikcp_flush</code> 中会发送相应的 ACK；</li>
<li>如果是窗口探测报文，就标记“需要发送窗口通知”，在稍后的 <code>ikcp_flush</code> 中会发送窗口通知报文；</li>
<li>包括窗口通知报文在内的所有报文都有 wnd 字段，据此更新 rmt_wnd；</li>
<li>根据 ACK 失序情况决定是否进行快速重传；</li>
<li>计算 cwnd。</li>
</ol>
</li>
<li>调用 <code>ikcp_recv</code> 从 <code>rcv_queue</code> 中接收数据。</li>
</ol>
<h3>2. 滑动窗口</h3>
<p>发送缓冲区 <code>snd_buf</code> 和接收缓冲区 <code>rcv_buf</code> 中活动的报文都是在滑动窗口之中的。这对于我们理解 KCP 的发送和接收流程非常重要，所有我们先从滑动窗口开始介绍。</p>
<p>滑动窗口实际是一个抽象的概念, 不能简单地认为它是缓冲区的一部分，准确的说，滑动窗口是由队列加缓冲区共同组成的。</p>
<h4>2.1 发送</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240612193023604.png" alt="发送窗口"></p>
<p><code>snd_una</code> 和 <code>snd_nxt</code> 会努力往<strong>右</strong>移动：</p>
<ol>
<li><code>ikcp_flush</code> 时，会从 <code>snd_queue</code> 中取出报文插入到 <code>snd_nxt</code> 的位置上；</li>
<li>如果 <code>snd_nxt - snd_una &gt;= cwnd</code>，则不允许新的报文插入；</li>
<li>当 <code>snd_una</code> 的 ACK 报文到达时，<code>snd_una</code> 就会右移到第一个没有收到 ACK 报文的位置；</li>
</ol>
<p>发送窗口中未确认到达的报文何时重传？</p>
<ul>
<li>报文在一个 RTO 时间内仍未确认到达，就会重传。报文 RTO 初始值是 rx_rto ，会持续增长，速率支持配置。</li>
</ul>
<h4>2.2 接收</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240612193238859.png" alt="接收窗口"></p>
<ol>
<li>每收到一个数据报文, 都会根据它的编号将它插入到 <code>rcv_buf</code> 对应的位置中；</li>
<li>接着检查 <code>rcv_nxt</code> 能否向右移动, 只有当报文的顺序正确且连续才能移动；</li>
<li>在上图的例子中由于 4 号报文的缺失, <code>rcv_nxt</code> 只能处于 4 号位置等待，5, 6 号报文也不能移动到 <code>rcv_queue</code> 中；</li>
<li>等到 4 号报文到达后，才能将 4, 5, 6 号报文一并移动到 <code>rcv_queue</code> 中，同时 <code>rcv_nxt</code> 会右移到 7 号位置。</li>
</ol>
<h4>2.3 案例分析</h4>
<p>我们举个简单的例子演示整个 ARQ 的流程。下图中实线箭头表示数据报文，虚线箭头表示 ACK。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/kcp_5.svg" alt="KCP ARQ 流程"></p>
<p>① t1 时刻发送方发送 1 号报文, 1 号报文放入发送缓冲区中, snd_una 指向 1, snd_nxt 指向 2.</p>
<p>② t2 至 t3 时刻发送方依次发送 2 至 3 号报文, snd_nxt 依次后移.</p>
<p>③ 1 号报文丢包.</p>
<p>④ t4, t5 时刻接收方收到 3 号和 2 号报文, 放入 rcv_buf 中; 随后回复 3 号和 2 号 ACK. 此时由于 1 号报文缺失, rcv_nxt 始终指向 1.</p>
<p>⑤ 3 号 ACK 丢包.</p>
<p>⑥ t7 时刻发送方收到 2 号 ACK, 将 2 号报文标记为已送达. 此时由于 3 号 ACK 丢包, 3 号报文未标记为已送达. 由于 1 号报文未确认送达, snd_una 亦指向 1.</p>
<p>⑦ t8 时刻 1 号报文超时, 重传.</p>
<p>⑧ t9 时刻接收方收到 1 号报文, 放入 rcv_buf 中; 这时 1, 2, 3 号报文顺序正确, rcv_nxt 右移到 4 号位置. 接收方回复 1 号 ACK, 同时带上 una = 4.</p>
<p>⑨ t10 时刻发送方收到 1 号 ACK, 将 1 号报文标记为已送达. 同时 una 表明 1, 2, 3 号报文均已送达, 因此也将 3 号报文标记为已送达. snd_una 移动到 4.</p>
<h3>3. 超时重传</h3>
<p>超时重传是当发送的数据包在预定时间内未被确认时，重新发送该数据包的机制。在 KCP 中，这个时间由重新传输超时（RTO）决定。KCP 计算 RTO 初始值的方法是 TCP 的标准方法, 规定在 <a href="https://www.rfc-editor.org/rfc/rfc6298.html">RFC 6298</a> 中。</p>
<p>这里还是贴出源码讲比较直观：</p>
<pre><code class="language-c">static void ikcp_update_ack(ikcpcb *kcp, IINT32 rtt)
{
	IINT32 rto = 0;
	if (kcp-&gt;rx_srtt == 0) {
		kcp-&gt;rx_srtt = rtt;
		kcp-&gt;rx_rttval = rtt / 2;
	}	else {
		long delta = rtt - kcp-&gt;rx_srtt;
		if (delta &lt; 0) delta = -delta;
		kcp-&gt;rx_rttval = (3 * kcp-&gt;rx_rttval + delta) / 4;
		kcp-&gt;rx_srtt = (7 * kcp-&gt;rx_srtt + rtt) / 8;
		if (kcp-&gt;rx_srtt &lt; 1) kcp-&gt;rx_srtt = 1;
	}
	rto = kcp-&gt;rx_srtt + _imax_(kcp-&gt;interval, 4 * kcp-&gt;rx_rttval);
	kcp-&gt;rx_rto = _ibound_(kcp-&gt;rx_minrto, rto, IKCP_RTO_MAX);
}
</code></pre>
<p>这个计算过程笔者就不做详细介绍了，代码里面的公式读者可以尝试自行画图进行理解，这里就不花大篇幅画公式了，下面我尝试以更通俗易懂的话语解释 RTO，只需要理解它在做什么，为什么这么做，就可以了，个人觉得对公式的细节可以暂且忽略。</p>
<h4>3.1 RTO 计算目的</h4>
<p>KCP 的 RTO 计算是为了确定在多长时间内未收到确认（ACK）时，应该重新发送数据包。这段时间被称为重传超时时间（RTO）。计算 RTO 的目的是在网络条件变化的情况下，既能快速响应数据丢失，也能避免不必要的重传，从而保持高效的传输。</p>
<h4>3.2 RTO 计算涉及的变量解释</h4>
<p><strong>RTT 和 SRTT 的概念:</strong></p>
<ul>
<li>RTT（Round-Trip Time）: 是从发送一个数据包到收到其确认（ACK）所花的时间。</li>
<li>SRTT（Smoothed RTT）: 是 RTT 的加权平均值，它代表了 RTT 的一个更稳定的估计值。SRTT 的目的是减少 RTT 的短期波动对 RTO 的影响。</li>
</ul>
<p><strong>RTT 变化值（RTT variance）</strong>：网络传输时间并不总是固定的，有时会因为网络拥塞或其他原因出现波动。我们通过计算 RTT 变化值（RTT variance）来估计这种波动的大小。</p>
<p><strong>为什么需要 SRTT 和 RTT 变化值：</strong></p>
<ul>
<li>SRTT 给我们一个平均的 RTT 估计值。</li>
<li>RTT 变化值告诉我们网络的波动性。如果波动很大，我们希望 RTO 更大，以免因为短暂的网络延迟就触发不必要的重传。</li>
</ul>
<h4>3.3 RTO 计算步骤</h4>
<p><strong>1. 初始化</strong>：初次计算时，我们没有历史 RTT 值，所以直接用第一次测量的 RTT 来初始化 SRTT，并将 RTT 变化值设为 RTT 的一半。</p>
<p><strong>2. 更新 SRTT 和 RTT 变化值</strong>:</p>
<ul>
<li>每次我们测量新的 RTT，就用它来更新 SRTT 和 RTT 变化值。</li>
<li>更新 SRTT：我们不直接替换旧的 SRTT，而是用一个平滑的方式（即加权平均），使得 SRTT 逐渐靠近新 RTT，但又不会剧烈变化。</li>
<li>更新 RTT 变化值：计算新的 RTT 与 SRTT 的差值，用这个差值来更新 RTT 变化值，使其反映当前网络波动的大小。</li>
</ul>
<p><strong>3. 计算 RTO</strong>:</p>
<ul>
<li>用 SRTT 加上四倍的 RTT 变化值来计算 RTO，这样可以确保 RTO 足够长，能涵盖大部分的网络波动。</li>
<li>我们还要确保 RTO 不小于一个最小值（<code>rx_minrto</code>），以防止 RTO 过小导致频繁重传；也不能大于一个最大值（<code>IKCP_RTO_MAX</code>），以防止 RTO 过大影响响应速度。</li>
</ul>
<h4>4. RTO 计算效果</h4>
<ul>
<li><strong>稳定的传输</strong>: SRTT 提供了一个稳定的平均 RTT 估计，使得 RTO 能适应网络的长期变化。</li>
<li><strong>适应网络波动</strong>: RTT 变化值使得 RTO 能够应对网络的短期波动，减少因短暂延迟而导致的重传。</li>
<li><strong>快速响应</strong>: RTO 设置合理后，能够在数据丢失时快速重传，保持传输的高效和及时性。</li>
</ul>
<p>通过这样的计算方式，KCP 能够在不同的网络条件下，自动调整重传策略，从而在保证数据可靠性的同时，保持较高的传输效率。</p>
<h3>4. 快速重传</h3>
<p>在网络传输中，数据包可能会由于网络拥塞、丢包等原因而丢失。超时重传依赖于重传超时时间（RTO）来判断是否需要重传，这可能会导致响应延迟。而快速重传通过检测重复的确认包（ACK）来快速判断数据包的丢失，并立即触发重传，显著缩短了数据丢失的恢复时间。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240612200038895.png" alt="KCP 快速重传"></p>
<h4>4.1 何时快速重传？</h4>
<ul>
<li>每个报文的 <code>fastack</code> 记录了它检测到 ACK 失序的次数，每当 KCP 收到一个编号为 sn 的 ACK 时，就会检查 snd_buf 中编号小于 sn 且未确认送达的报文，并将其 <code>fastack</code> 加 1。</li>
<li>可以通过配置 <code>fastresend</code> 指定失序多少次就执行快速重传。</li>
<li>每次调用 ikcp_flush 都会重传 snd_buf 中 <code>fastask &gt;= fastresend</code> 的报文。</li>
</ul>
<h4>4.2 无限快速重传吗？</h4>
<ul>
<li>每个报文的 <code>xmit</code> 记录它被传输的次数，可以配置 <code>fastlimit</code> 规定传输次数小于 <code>fastlimit</code> 的报文才能执行快速重传。</li>
</ul>
<h3>5. 比较 TCP 的超时重传和快速重传</h3>
<p>TCP 也实现了类似的机制，但在复杂性和应用场景上有所不同。</p>
<h4>5.1 TCP 的超时重传</h4>
<p><strong>1. RTT 估算</strong>:</p>
<ul>
<li><p>TCP 通过接收确认包来估算 RTT，并使用 RTT 的变化范围来计算 RTO。</p>
</li>
<li><p>TCP 使用 Jacobson/Karels 算法进行 RTT 估算和 RTO 计算：</p>
<pre><code class="language-c">// SRTT and RTTVAR calculation
RTTVAR = (1 - β) * RTTVAR + β * |RTTsample - SRTT|
SRTT = (1 - α) * SRTT + α * RTTsample
RTO = SRTT + 4 * RTTVAR
</code></pre>
<p>其中，SRTT 是平滑的 RTT，RTTVAR 是 RTT 的变化范围，α 和 β 是权重因子。</p>
</li>
</ul>
<p><strong>2. 重传策略</strong>:</p>
<ul>
<li>如果在 RTO 时间内未收到 ACK，TCP 会重传未确认的数据包。</li>
<li>每次重传，RTO 值会按照指数增长（指数退避算法）。</li>
</ul>
<p><strong>3. 拥塞控制</strong>:</p>
<ul>
<li>TCP 使用复杂的拥塞控制机制，如慢启动、拥塞避免等，来调整发送窗口和传输速率。</li>
</ul>
<h4>5.2 TCP 的快速重传</h4>
<ul>
<li>当接收到三个重复的 ACK 时，TCP 会立即重传丢失的数据包，而不等待 RTO 超时。</li>
<li>快速重传后，TCP 进入快速恢复状态，调整拥塞窗口，避免拥塞窗口过度收缩。</li>
</ul>
<h4>5.3 比较分析</h4>
<table>
<thead>
<tr>
<th>特性</th>
<th>KCP</th>
<th>TCP</th>
</tr>
</thead>
<tbody><tr>
<td><strong>RTT 估算</strong></td>
<td>基于加权移动平均，较为简单</td>
<td>使用 Jacobson/Karels 算法，复杂但精确</td>
</tr>
<tr>
<td><strong>RTO 计算</strong></td>
<td>简化的计算公式</td>
<td>基于 RTT 的复杂计算</td>
</tr>
<tr>
<td><strong>重传机制</strong></td>
<td>超时重传和快速重传</td>
<td>超时重传和快速重传</td>
</tr>
<tr>
<td><strong>拥塞控制</strong></td>
<td>简单的拥塞控制，适合低延迟应用</td>
<td>复杂的拥塞控制，适合广泛的传输场景</td>
</tr>
<tr>
<td><strong>适用场景</strong></td>
<td>实时应用，如游戏、视频会议</td>
<td>通用应用，如文件传输、HTTP</td>
</tr>
<tr>
<td><strong>实现复杂度</strong></td>
<td>较为简单，易于理解和实现</td>
<td>复杂，需处理更多的网络状态和控制</td>
</tr>
<tr>
<td><strong>可靠性</strong></td>
<td>依赖于用户自定义的重传和控制策略</td>
<td>内置可靠性和流控制机制</td>
</tr>
<tr>
<td><strong>响应速度</strong></td>
<td>高效快速，适用于低延迟和高吞吐量场景</td>
<td>可靠但响应速度较慢，适合稳定传输场景</td>
</tr>
</tbody></table>
<p>KCP 和 TCP 都提供了可靠的传输机制，但它们适用于不同的应用场景。KCP 设计简单，适合对延迟敏感的实时应用，而 TCP 拥有完善的拥塞控制和可靠性机制，适合广泛的网络应用。</p>
<h3>6. 拥塞控制</h3>
<p>拥塞控制是网络传输协议中的一个重要机制，用于防止发送过多的数据包导致网络拥塞。在 KCP 中，拥塞控制相对简单，主要通过发送窗口（<code>snd_wnd</code>）和拥塞窗口（<code>cwnd</code>）来管理数据发送速率。</p>
<h4>6.1 三种策略</h4>
<p>KCP 有 3 种拥塞控制的策略：</p>
<ul>
<li>慢启动（slow start）</li>
<li>拥塞避免（congestion avoidance）</li>
<li>快速恢复（fast recovery）</li>
</ul>
<p><strong>慢启动</strong>：先将 cwnd 设置为 1，随后平均每经过一个 RTT 时间，<code>cwnd = cwnd * 2</code>，直到阈值 <code>ssthresh</code>。</p>
<p><strong>拥塞避免</strong>：cwnd 到 <code>ssthresh</code> 后，cwnd 呈<strong>线性</strong>增长。</p>
<p>当慢启动或者拥塞避免造成 <strong>丢包</strong> 后，就采取相应的退让策略：</p>
<ol>
<li><code>fastack &gt;= fastresend</code> -&gt; 发生快速重传：将 <code>ssthresh = cwnd / 2</code>，<code>cwnd = ssthresh + fastresend</code> 进入<strong>快恢复</strong>。</li>
<li><code>current &gt;= resentts</code> -&gt; 超时重传：<code>ssthresh = ssthresh / 2</code>，<code>cwnd = 1</code>，进入<strong>慢启动</strong>。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/kcp_7.svg" alt="拥塞控制中 cwnd 和 ssthresh 的变化情况"></p>
<h4>6.2 核心概念</h4>
<p>KCP 的拥塞控制基于以下几个核心概念：</p>
<ul>
<li><strong>发送窗口 (<code>snd_wnd</code>)</strong>：表示发送端在未收到接收端确认之前，允许发送的数据包的数量。它类似于 TCP 中的发送窗口，控制了数据流的速率。</li>
<li><strong>接收窗口 (<code>rcv_wnd</code>)</strong>：表示接收端能够处理的最大数据包数量。发送端通过接收端的窗口大小来调整自己的发送速率。</li>
<li><strong>远端窗口 (<code>rmt_wnd</code>)</strong>：表示接收端的窗口大小，发送端会根据这个值调整自己的发送窗口，以避免发送的数据超出接收端的处理能力。</li>
<li><strong>拥塞窗口 (<code>cwnd</code>)</strong>：用于控制传输中的数据包数量。它基于网络的拥塞情况动态调整，以避免网络拥塞。</li>
<li><strong>慢启动阈值 (<code>ssthresh</code>)</strong>：用于确定拥塞控制的模式。当 <code>cwnd</code> 小于 <code>ssthresh</code> 时，KCP 处于慢启动模式，否则进入拥塞避免模式。</li>
</ul>
<h4>6.3 窗口探测（Window Probing）</h4>
<p>在某些情况下，接收端的窗口可能会被关闭（即 <code>rmt_wnd</code> 为 0），这意味着接收端无法接收任何新的数据。为了应对这种情况，KCP 实现了窗口探测机制：</p>
<ul>
<li>当 <code>rmt_wnd</code> 为 0 时，KCP 不会立即停止发送数据，而是会定期发送一个探测包，以检测接收端窗口是否已经打开。</li>
<li>这个探测包会触发接收端返回一个 ACK，其中包含最新的接收窗口大小信息。</li>
</ul>
<h4>6.4 调节和配置</h4>
<p>KCP 的拥塞控制机制提供了一些配置参数，用户可以通过调整这些参数来优化传输性能：</p>
<ul>
<li><strong><code>snd_wnd</code></strong>: 发送窗口大小，用户可以根据应用的需求调整该值，以控制数据发送的最大量。</li>
<li><strong><code>rcv_wnd</code></strong>: 接收窗口大小，表示接收端能够处理的最大数据包数量。</li>
<li><strong><code>ssthresh</code></strong>: 慢启动阈值，初始值通常设置为较大的一个常量，用户可以根据网络情况调整。</li>
<li><strong><code>cwnd</code></strong>: 拥塞窗口大小，初始值通常设置为 1，随传输情况动态调整。</li>
</ul>
<h3>7. 比较 TCP 的拥塞控制</h3>
<h4>7.1 四个阶段</h4>
<p>TCP 拥塞控制有四个关键阶段</p>
<p><strong>慢启动（Slow Start）</strong>：</p>
<ul>
<li><strong>目的</strong>：快速探测网络的可用带宽。</li>
<li><strong>机制</strong>：当一个连接刚建立或者从丢包恢复时，<code>cwnd</code>（拥塞窗口）从一个较小的值（通常是 1 个 MSS，即最大报文段大小）开始，并以指数增长的方式增加。</li>
<li><strong>过程</strong>：每次收到一个 ACK，<code>cwnd</code> 增加一个 MSS，使得 <code>cwnd</code> 每 RTT 增加一倍，直到 <code>cwnd</code> 达到慢启动阈值（<code>ssthresh</code>）。</li>
</ul>
<p><strong>拥塞避免（Congestion Avoidance）</strong>:</p>
<ul>
<li><strong>目的</strong>：逐步探测网络的最大容量，并避免拥塞。</li>
<li><strong>机制</strong>：当 <code>cwnd</code> 达到或超过 <code>ssthresh</code> 时，TCP 进入拥塞避免阶段，此时 <code>cwnd</code> 以线性增长的方式增加。</li>
<li><strong>过程</strong>：每个 RTT，<code>cwnd</code> 增加 <code>1/cwnd</code> 个 MSS，这种增长方式较为保守，旨在防止过度发送导致的拥塞。</li>
</ul>
<p><strong>快速重传（Fast Retransmit）</strong>:</p>
<ul>
<li><strong>目的</strong>：快速响应丢包，提高传输效率。</li>
<li><strong>机制</strong>：当发送端收到三个重复的 ACK 时，立即重传被确认丢失的数据包，而不等待 RTO 超时。</li>
<li><strong>过程</strong>：快速重传的目的是迅速恢复丢失的数据包，从而减少因丢包导致的等待时间。</li>
</ul>
<p><strong>快速恢复（Fast Recovery）</strong>:</p>
<ul>
<li><strong>目的</strong>：在拥塞后快速恢复到适当的传输速率。</li>
<li><strong>机制</strong>：在快速重传后，TCP 不会直接进入慢启动，而是保持 <code>cwnd</code> 的一部分，以较快的速度恢复到拥塞避免状态。</li>
<li><strong>过程</strong>：将 <code>ssthresh</code> 设置为当前 <code>cwnd</code> 的一半，<code>cwnd</code> 被临时减小，然后在接收新 ACK 时快速增加 <code>cwnd</code>，直到恢复到 <code>ssthresh</code> 为止。</li>
</ul>
<h4>7.2 比较分析</h4>
<table>
<thead>
<tr>
<th>特性</th>
<th>TCP</th>
<th>KCP</th>
</tr>
</thead>
<tbody><tr>
<td><strong>实现复杂度</strong></td>
<td>复杂，包含多个阶段和算法</td>
<td>简单，主要通过窗口大小控制</td>
</tr>
<tr>
<td><strong>拥塞检测</strong></td>
<td>通过 RTT 估算和 ACK 检测丢包</td>
<td>主要通过 ACK 和窗口大小检测丢包</td>
</tr>
<tr>
<td><strong>响应速度</strong></td>
<td>响应相对较慢，适合稳定传输</td>
<td>响应较快，适合实时性高的传输</td>
</tr>
<tr>
<td><strong>适应性</strong></td>
<td>能适应广泛的网络条件</td>
<td>适应性较好，但更适合低延迟网络</td>
</tr>
<tr>
<td><strong>配置灵活性</strong></td>
<td>较为固定，依赖于系统配置和优化</td>
<td>提供更多的配置选项，用户可根据需求调整</td>
</tr>
<tr>
<td><strong>应用场景</strong></td>
<td>适用于各种需要可靠传输的应用</td>
<td>适用于实时性要求高的应用，如游戏和视频会议</td>
</tr>
<tr>
<td><strong>窗口调整</strong></td>
<td>慢启动、拥塞避免、快速重传、快速恢复等机制</td>
<td>主要通过发送窗口和拥塞窗口调整</td>
</tr>
<tr>
<td><strong>丢包响应</strong></td>
<td>丢包时通过减小 <code>cwnd</code> 和 <code>ssthresh</code> 来调整</td>
<td>丢包时迅速调整 <code>cwnd</code> 和重传</td>
</tr>
<tr>
<td><strong>拥塞控制策略</strong></td>
<td>慢启动、拥塞避免、快速重传、快速恢复等多种策略</td>
<td>主要通过调整 <code>cwnd</code> 和 <code>ssthresh</code> 进行简单控制</td>
</tr>
<tr>
<td><strong>优点</strong></td>
<td>稳定可靠、机制全面、应用广泛</td>
<td>实现简单、响应快、灵活性高、适合实时应用</td>
</tr>
<tr>
<td><strong>缺点</strong></td>
<td>复杂、响应慢、初始阶段保守</td>
<td>无法应对更加复杂的网络状况、应用场景有限</td>
</tr>
</tbody></table>
<p>TCP 和 KCP 都有各自的拥塞控制机制，适用于不同的应用场景。TCP 提供了复杂而全面的拥塞控制，适合于各种网络条件下的可靠传输，而 KCP 提供了简单高效的控制机制，适合于低延迟和高响应速度的实时应用。选择使用哪种协议取决于具体的应用需求和网络环境。</p>
<h2>源码分析</h2>
<h3>1. 核心数据结构</h3>
<h4>1.1 IKCPSEG 报文段结构</h4>
<pre><code class="language-c">struct IKCPSEG {
    struct IQUEUEHEAD node;  // 链表节点
    IUINT32 conv;     // 会话ID
    IUINT32 cmd;      // 命令类型
    IUINT32 frg;      // 分片序号
    IUINT32 wnd;      // 窗口大小
    IUINT32 ts;       // 时间戳
    IUINT32 sn;       // 序列号
    IUINT32 una;      // 待接收的下一个包序号
    IUINT32 len;      // 数据长度
    IUINT32 resendts; // 重传时间戳
    IUINT32 rto;      // 超时重传时间
    IUINT32 fastack;  // 快速重传计数器
    IUINT32 xmit;     // 传输次数
    char data[1];     // 数据
};
</code></pre>
<h4>1.2 IKCPCB 控制块</h4>
<pre><code class="language-c">struct IKCPCB {
    // === 基础配置 ===
    IUINT32 conv;          // 会话ID，用于标识一个会话
    IUINT32 mtu;          // 最大传输单元，默认1400字节
    IUINT32 mss;          // 最大报文段大小，默认mtu-24字节
    IUINT32 state;        // 连接状态，0=正常，-1=断开

    // === 发送和接收序号 ===
    IUINT32 snd_una;      // 第一个未确认的包序号
    IUINT32 snd_nxt;      // 下一个待发送的包序号
    IUINT32 rcv_nxt;      // 待接收的下一个包序号

    // === 时间戳相关 ===
    IUINT32 ts_recent;    // 最近一次收到包的时间戳
    IUINT32 ts_lastack;   // 最近一次收到ACK的时间戳
    IUINT32 ssthresh;     // 慢启动阈值，默认为IKCP_THRESH_INIT(2)

    // === RTT相关 ===
    IINT32 rx_rttval;     // RTT的变化量
    IINT32 rx_srtt;       // 平滑后的RTT
    IINT32 rx_rto;        // 超时重传时间，初始为IKCP_RTO_DEF(200ms)
    IINT32 rx_minrto;     // 最小重传超时时间，默认为IKCP_RTO_MIN(100ms)

    // === 窗口相关 ===
    IUINT32 snd_wnd;      // 发送窗口大小，默认32
    IUINT32 rcv_wnd;      // 接收窗口大小，默认128
    IUINT32 rmt_wnd;      // 远端窗口大小，默认128
    IUINT32 cwnd;         // 拥塞窗口大小，初始为0
    IUINT32 probe;        // 探测标志，用于窗口探测

    // === 时间相关 ===
    IUINT32 current;      // 当前时间
    IUINT32 interval;     // 内部更新时间间隔，默认100ms
    IUINT32 ts_flush;     // 下次刷新时间
    IUINT32 xmit;         // 总重传次数

    // === 队列计数器 ===
    IUINT32 nrcv_buf;     // 接收缓存中的包数量
    IUINT32 nsnd_buf;     // 发送缓存中的包数量
    IUINT32 nrcv_que;     // 接收队列中的包数量
    IUINT32 nsnd_que;     // 发送队列中的包数量

    // === 配置标志 ===
    IUINT32 nodelay;      // 是否启用nodelay模式，0=不启用
    IUINT32 updated;      // 是否调用过update

    // === 探测相关 ===
    IUINT32 ts_probe;     // 下次探测时间
    IUINT32 probe_wait;   // 探测等待时间

    // === 链路控制 ===
    IUINT32 dead_link;    // 最大重传次数，默认为IKCP_DEADLINK(20)
    IUINT32 incr;         // 可发送的最大数据量

    // === 数据队列 ===
    struct IQUEUEHEAD snd_queue;  // 发送队列
    struct IQUEUEHEAD rcv_queue;  // 接收队列
    struct IQUEUEHEAD snd_buf;    // 发送缓存
    struct IQUEUEHEAD rcv_buf;    // 接收缓存

    // === ACK相关 ===
    IUINT32 *acklist;     // ACK列表
    IUINT32 ackcount;     // ACK数量
    IUINT32 ackblock;     // ACK列表大小

    // === 用户相关 ===
    void *user;           // 用户数据指针
    char *buffer;         // 临时缓存

    // === 快速重传相关 ===
    int fastresend;       // 触发快速重传的重复ACK个数
    int fastlimit;        // 快速重传次数限制，默认IKCP_FASTACK_LIMIT(5)

    // === 其他配置 ===
    int nocwnd;          // 是否关闭拥塞控制，0=不关闭
    int stream;          // 是否为流模式，0=消息模式(默认)，1=流模式
    int logmask;        // 日志掩码，控制日志输出级别

    // === 回调函数 ===
    // 数据输出回调，用于发送数据
    int (*output)(const char *buf, int len, struct IKCPCB *kcp, void *user);
    // 日志输出回调
    void (*writelog)(const char *log, struct IKCPCB *kcp, void *user);
};
</code></pre>
<p>这个结构体可以大致分为几个主要部分：</p>
<ul>
<li>基础配置：包含基本的会话标识和传输单元大小设置</li>
<li>序号追踪：用于追踪发送和接收的包序号</li>
<li>时间管理：包含各种时间戳和定时器</li>
<li>窗口控制：实现流量控制和拥塞控制</li>
<li>队列管理：管理数据的发送和接收</li>
<li>ACK 处理：处理确认包</li>
<li>配置选项：各种功能开关和参数设置</li>
<li>回调函数：用于数据输出和日志记录</li>
</ul>
<h3>2. 核心函数</h3>
<p>在进入具体的核心函数分析之前，需要先点明 2 点，<code>kcp</code> 的实现者期望其尽可能地简单和减少依赖，所以数据的输出甚至是当前时间都是由使用者来设置的，即 <code>kcp</code> 本身是不依赖于机器时钟的。具体体现在下面 2 个函数：</p>
<pre><code class="language-c">//---------------------------------------------------------------------
// set output callback, which will be invoked by kcp
//---------------------------------------------------------------------
void ikcp_setoutput(ikcpcb *kcp, int (*output)(const char *buf, int len,
	ikcpcb *kcp, void *user))
{
	kcp-&gt;output = output;
}

//---------------------------------------------------------------------
// update state (call it repeatedly, every 10ms-100ms), or you can ask
// ikcp_check when to call it again (without ikcp_input/_send calling).
// &#39;current&#39; - current timestamp in millisec.
//---------------------------------------------------------------------
void ikcp_update(ikcpcb *kcp, IUINT32 current)
{
  ...
}
</code></pre>
<h4>2.1 ikcp_send 发送数据</h4>
<p><code>ikcp_send</code> 是应用层接口，负责将用户数据分片并加入到发送队列（<code>snd_queue</code>）。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241129154115523.png" alt="ikcp_send"></p>
<pre><code class="language-c">//---------------------------------------------------------------------
// user/upper level send, returns below zero for error
//---------------------------------------------------------------------
int ikcp_send(ikcpcb *kcp, const char *buffer, int len)
{
	IKCPSEG *seg;
	int count, i;
	int sent = 0;

	// mtu: 最大传输单元
	// mss: 最大报文段大小
	// mss = mtu - 包头长度(24)
	assert(kcp-&gt;mss &gt; 0);
	if (len &lt; 0) return -1;

	// append to previous segment in streaming mode (if possible)
	// 如果是流模式，则将数据追加到前一个分段中（如果可能）
	if (kcp-&gt;stream != 0) {
		// 如果当前发送队列不为空，且前一个分段未满，则将数据追加到前一个分段中
		if (!iqueue_is_empty(&amp;kcp-&gt;snd_queue)) {
			IKCPSEG *old = iqueue_entry(kcp-&gt;snd_queue.prev, IKCPSEG, node);
			if (old-&gt;len &lt; kcp-&gt;mss) {
				int capacity = kcp-&gt;mss - old-&gt;len;
				int extend = (len &lt; capacity)? len : capacity;
				seg = ikcp_segment_new(kcp, old-&gt;len + extend);
				assert(seg);
				if (seg == NULL) {
					return -2;
				}
				// 将新的 seg-&gt;node 放入 snd_queue 中等待发送
				iqueue_add_tail(&amp;seg-&gt;node, &amp;kcp-&gt;snd_queue);
				// 把上一个报文的数据拷贝过来
				memcpy(seg-&gt;data, old-&gt;data, old-&gt;len);
				if (buffer) {
					memcpy(seg-&gt;data + old-&gt;len, buffer, extend);
					buffer += extend;
				}
				seg-&gt;len = old-&gt;len + extend;
				seg-&gt;frg = 0;
				len -= extend;
				iqueue_del_init(&amp;old-&gt;node);
				// 释放之前老数据的 kcp node
				ikcp_segment_delete(kcp, old);
				sent = extend;
			}
		}
		if (len &lt;= 0) {
			return sent;
		}
	}

	// 1. 非流模式，不追加到上一个报文后面
	// 2. 流模式，但是上一个报文已满，则创建新的报文

	// 计算需要的报文数量，kcp 会对数据进行分段传输
	if (len &lt;= (int)kcp-&gt;mss) count = 1;
	else count = (len + kcp-&gt;mss - 1) / kcp-&gt;mss;

	// 接收窗口位置不够，则暂停发送
	if (count &gt;= (int)IKCP_WND_RCV) {
		if (kcp-&gt;stream != 0 &amp;&amp; sent &gt; 0)
			return sent;
		return -2;
	}

	if (count == 0) count = 1;

	// 发送所有的报文段
	for (i = 0; i &lt; count; i++) {
		int size = len &gt; (int)kcp-&gt;mss ? (int)kcp-&gt;mss : len;
		seg = ikcp_segment_new(kcp, size);
		assert(seg);
		if (seg == NULL) {
			return -2;
		}
		if (buffer &amp;&amp; len &gt; 0) {
			memcpy(seg-&gt;data, buffer, size);
		}
		seg-&gt;len = size;
		seg-&gt;frg = (kcp-&gt;stream == 0)? (count - i - 1) : 0;
		iqueue_init(&amp;seg-&gt;node);

		// 将报文段放入 snd_queue 中
		iqueue_add_tail(&amp;seg-&gt;node, &amp;kcp-&gt;snd_queue);
		kcp-&gt;nsnd_que++;
		if (buffer) {
			buffer += size;
		}
		len -= size;
		sent += size;
	}

	return sent;
}
</code></pre>
<h4>2.2 ikcp_input 接收数据</h4>
<p><code>ikcp_input</code> 负责处理从网络接收到的原始 KCP 数据包，它会处理协议层面的数据，包括 ACK、窗口控制等协议信息，并将接收到的数据放入 KCP 的内部接收缓冲区（<code>rcv_buf</code> 和 <code>rcv_queue</code>）。</p>
<h4>2.3 ikcp_recv 获取数据</h4>
<p><code>ikcp_recv</code> 是应用层函数，供上层应用调用以获取完整的消息数据，它从 KCP 的接收队列(rcv_queue)中读取已经排序好的数据，处理分片重组，确保返回完整的消息。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241129163431276.png" alt="ikcp_recv"></p>
<pre><code class="language-c">//---------------------------------------------------------------------
// user/upper level recv: returns size, returns below zero for EAGAIN
// 从 rcv_queue 中获取数据
//---------------------------------------------------------------------
int ikcp_recv(ikcpcb *kcp, char *buffer, int len)
{
	struct IQUEUEHEAD *p;
	int ispeek = (len &lt; 0)? 1 : 0;
	int peeksize;
	int recover = 0;
	IKCPSEG *seg;
	assert(kcp);

	// 如果 rcv_queue 为空，则直接返回
	if (iqueue_is_empty(&amp;kcp-&gt;rcv_queue))
		return -1;

	// 如果 len &lt; 0，则说明是 peek 操作，准备只查看数据
	if (len &lt; 0) len = -len;

	// 计算 rcv_queue 中数据的大小
	peeksize = ikcp_peeksize(kcp);

	// 无法获得大小，返回 -2
	if (peeksize &lt; 0)
		return -2;

	// 数据过大，返回 -3
	if (peeksize &gt; len)
		return -3;

	// nrcv_que: rcv_queue 的长度
	// rcv_wnd: 接收窗口的大小
	// 如果 nrcv_que &gt;= rcv_wnd，则需要进行快恢复
	// 因为 nrcv_que &gt;= rcv_wnd，说明接收窗口已经满了，
	// 这个时候需要发送 IKCP_CMD_WINS 告诉发送方窗口大小，
	// 这个时候发送方需要进行快恢复，减小数据传输，以尽快释放接收窗口
	if (kcp-&gt;nrcv_que &gt;= kcp-&gt;rcv_wnd)
		recover = 1;

	// merge fragment
	// 将多个片段合并成一个完整的片段
	// 合并后，将合并后的片段从 rcv_queue 中删除
	for (len = 0, p = kcp-&gt;rcv_queue.next; p != &amp;kcp-&gt;rcv_queue; ) {
		int fragment;
		seg = iqueue_entry(p, IKCPSEG, node);
		p = p-&gt;next;

		if (buffer) {
			memcpy(buffer, seg-&gt;data, seg-&gt;len);
			buffer += seg-&gt;len;
		}

		len += seg-&gt;len;
		fragment = seg-&gt;frg;

		if (ikcp_canlog(kcp, IKCP_LOG_RECV)) {
			ikcp_log(kcp, IKCP_LOG_RECV, &quot;recv sn=%lu&quot;, (unsigned long)seg-&gt;sn);
		}

		if (ispeek == 0) {
			iqueue_del(&amp;seg-&gt;node);
			ikcp_segment_delete(kcp, seg);
			kcp-&gt;nrcv_que--;
		}

		if (fragment == 0)
			break;
	}

	assert(len == peeksize);

	// move available data from rcv_buf -&gt; rcv_queue
	// 尝试将 rcv_buf 中编号连续的数据，移动到 rcv_queue 中
	// 移动后，将移动的数据从 rcv_buf 中删除
	while (! iqueue_is_empty(&amp;kcp-&gt;rcv_buf)) {
		seg = iqueue_entry(kcp-&gt;rcv_buf.next, IKCPSEG, node);
		if (seg-&gt;sn == kcp-&gt;rcv_nxt &amp;&amp; kcp-&gt;nrcv_que &lt; kcp-&gt;rcv_wnd) {
			iqueue_del(&amp;seg-&gt;node);
			kcp-&gt;nrcv_buf--;
			iqueue_add_tail(&amp;seg-&gt;node, &amp;kcp-&gt;rcv_queue);
			kcp-&gt;nrcv_que++;
			kcp-&gt;rcv_nxt++;
		}	else {
			break;
		}
	}

	// 快恢复
	if (kcp-&gt;nrcv_que &lt; kcp-&gt;rcv_wnd &amp;&amp; recover) {
		// 在ikcp_flush 中返回 IKCP_CMD_WINS
		// 通知本段窗口大小给对端
		kcp-&gt;probe |= IKCP_ASK_TELL;
	}

	return len;
}
</code></pre>
<h4>2.4 ikcp_update 定时时钟</h4>
<p>前面我们看了 <code>ikcp_send</code> 、<code>ikcp_input</code> 和 <code>ikcp_recv</code> 三个核心流程的函数，其中的一些细节，你可以回到本文前面的「原理分析」再对照源码仔细阅读。</p>
<p>在前面的原理分析中，我们提到，为了提高传输和处理数据的效率，<code>kcp</code> 设计了队列和缓冲区，同时为了实现可靠性，<code>kcp</code> 也提供了 <code>ACK</code> 和重试、拥塞控制等机制，这些事情都是周期定时去处理的。这里是由 <code>ikcp_update</code> 函数去处理的。</p>
<p><code>ikcp_update</code> 是 KCP 的定时器函数，负责以固定间隔调用 <code>ikcp_flush</code> 处理数据发送和协议更新，是 KCP 的&quot;心跳&quot;机制。</p>
<pre><code class="language-c">//---------------------------------------------------------------------
// update state (call it repeatedly, every 10ms-100ms), or you can ask
// ikcp_check when to call it again (without ikcp_input/_send calling).
// &#39;current&#39; - current timestamp in millisec.
//---------------------------------------------------------------------
void ikcp_update(ikcpcb *kcp, IUINT32 current)
{
	IINT32 slap;

	kcp-&gt;current = current;

	if (kcp-&gt;updated == 0) {
		kcp-&gt;updated = 1;
		kcp-&gt;ts_flush = kcp-&gt;current;
	}

	// 计算间隔
	slap = _itimediff(kcp-&gt;current, kcp-&gt;ts_flush);

	if (slap &gt;= 10000 || slap &lt; -10000) {
		kcp-&gt;ts_flush = kcp-&gt;current;
		slap = 0;
	}

	// 达到调用间隔，则执行 ikcp_flush 进行接收数据或发送数据
	if (slap &gt;= 0) {
		kcp-&gt;ts_flush += kcp-&gt;interval;
		if (_itimediff(kcp-&gt;current, kcp-&gt;ts_flush) &gt;= 0) {
			kcp-&gt;ts_flush = kcp-&gt;current + kcp-&gt;interval;
		}
		ikcp_flush(kcp);
	}
}
</code></pre>
<p>这个函数很简单，根据注释所说，通常情况下会每 <code>10ms~100ms</code> 执行一次，然后核心是去调用 <code>ikcp_flush</code> 函数，所有的逻辑都在里面。</p>
<h4>2.5 ikcp_flush 定时处理</h4>
<p>如上所述，<code>ikcp_flush</code> 是 KCP 的核心发送函数，负责将发送队列 <code>snd_queue</code> 中的数据移入发送缓存 <code>snd_buf</code> 并通过 <code>output</code> 回调发送出去，同时处理 ACK 发送、快速重传、超时重传和窗口探测等协议细节。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241129170905325.png" alt="ikcp_flush"></p>
<pre><code class="language-c">void ikcp_flush(ikcpcb *kcp)
{
	IUINT32 current = kcp-&gt;current;	// 当前时间
	char *buffer = kcp-&gt;buffer;		// 临时缓冲区
	char *ptr = buffer;
	int count, size, i;
	IUINT32 resent, cwnd;
	IUINT32 rtomin;
	struct IQUEUEHEAD *p;
	int change = 0;		// 是否执行过快速重传
	int lost = 0;		// 是否执行过超时重传
	IKCPSEG seg;

	// 检查是否已调用 ikcp_update
	if (kcp-&gt;updated == 0) return;

	// 初始化一个段用于构建各种控制包
	seg.conv = kcp-&gt;conv;  				// 连接标识
	seg.cmd = IKCP_CMD_ACK;				// 报文类型：IKCP_CMD_ACK 表示确认报文
	seg.frg = 0;						// 分片数量，表示随后还有多少个报文属于同一个包
	seg.wnd = ikcp_wnd_unused(kcp);		// 发送方剩余接收窗口的大小
	seg.una = kcp-&gt;rcv_nxt;				// 发送方的接收缓冲区中最小还未收到的报文段的编号，也就是说，编号比它小的报文段都已全部接收
	seg.len = 0;						// 数据段长度
	seg.sn = 0;							// 报文编号
	seg.ts = 0;							// 时间戳

	// flush acknowledges
	// ① 发送 ACK 队列中的所有 ACK
	count = kcp-&gt;ackcount;
	for (i = 0; i &lt; count; i++) {
		size = (int)(ptr - buffer);
		// buffer 中累计的数据将要超过 mtu 的时候
		// 就调用 ikcp_output 将数据发送出去
		if (size + (int)IKCP_OVERHEAD &gt; (int)kcp-&gt;mtu) {
			ikcp_output(kcp, buffer, size);
			ptr = buffer;
		}
		// 从 ACK 列表中取出 sn(报文编号)和 ts(时间戳)
		ikcp_ack_get(kcp, i, &amp;seg.sn, &amp;seg.ts);
		// 将 ACK 报文写入 buffer
		ptr = ikcp_encode_seg(ptr, &amp;seg);
	}
	// ② ACK 队列已清空
	kcp-&gt;ackcount = 0;

	// probe window size (if remote window size equals zero)
	// 对端剩余接收窗口大小为 0，则意味着可能需要发送窗口探测报文：IKCP_CMD_WASK
	if (kcp-&gt;rmt_wnd == 0) {
		// 根据 ts_probe 和 probe_wait 确定当前时刻是否需要发送探测报文
		// probe_wait: 等待发送探测报文的时间，IKCP_PROBE_INIT=7s, IKCP_PROBE_LIMIT=
		if (kcp-&gt;probe_wait == 0) {
			kcp-&gt;probe_wait = IKCP_PROBE_INIT; // 7s 后去发探测报文
			kcp-&gt;ts_probe = kcp-&gt;current + kcp-&gt;probe_wait;
		}
		else {
			if (_itimediff(kcp-&gt;current, kcp-&gt;ts_probe) &gt;= 0) {
				if (kcp-&gt;probe_wait &lt; IKCP_PROBE_INIT)
					kcp-&gt;probe_wait = IKCP_PROBE_INIT;
				kcp-&gt;probe_wait += kcp-&gt;probe_wait / 2;
				if (kcp-&gt;probe_wait &gt; IKCP_PROBE_LIMIT)
					kcp-&gt;probe_wait = IKCP_PROBE_LIMIT;
				kcp-&gt;ts_probe = kcp-&gt;current + kcp-&gt;probe_wait;
				kcp-&gt;probe |= IKCP_ASK_SEND; // 设置是否需要去发送 IKCP_ASK_SEND
			}
		}
	} else {
		kcp-&gt;ts_probe = 0;
		kcp-&gt;probe_wait = 0;
	}

	// flush window probing commands
	// ③ 如果需要，则发送窗口探测报文：IKCP_CMD_WASK
	if (kcp-&gt;probe &amp; IKCP_ASK_SEND) {
		seg.cmd = IKCP_CMD_WASK;
		size = (int)(ptr - buffer);
		if (size + (int)IKCP_OVERHEAD &gt; (int)kcp-&gt;mtu) {
			ikcp_output(kcp, buffer, size);
			ptr = buffer;
		}
		ptr = ikcp_encode_seg(ptr, &amp;seg);
	}

	// flush window probing commands
	// ④ 如果需要，则发送窗口通知报文：IKCP_CMD_WINS
	if (kcp-&gt;probe &amp; IKCP_ASK_TELL) {
		seg.cmd = IKCP_CMD_WINS;
		size = (int)(ptr - buffer);
		if (size + (int)IKCP_OVERHEAD &gt; (int)kcp-&gt;mtu) {
			ikcp_output(kcp, buffer, size);
			ptr = buffer;
		}
		ptr = ikcp_encode_seg(ptr, &amp;seg);
	}

	kcp-&gt;probe = 0;

	// calculate window size
	// ⑤ 计算当前窗口大小
	cwnd = _imin_(kcp-&gt;snd_wnd, kcp-&gt;rmt_wnd);
	if (kcp-&gt;nocwnd == 0) cwnd = _imin_(kcp-&gt;cwnd, cwnd);

	// move data from snd_queue to snd_buf
	// 5.1 如果符合发送的条件，则创建新的 newseg 并放入 snd_buf 的尾部
	while (_itimediff(kcp-&gt;snd_nxt, kcp-&gt;snd_una + cwnd) &lt; 0) {
		IKCPSEG *newseg;
		if (iqueue_is_empty(&amp;kcp-&gt;snd_queue)) break;

		newseg = iqueue_entry(kcp-&gt;snd_queue.next, IKCPSEG, node);

		iqueue_del(&amp;newseg-&gt;node);
		iqueue_add_tail(&amp;newseg-&gt;node, &amp;kcp-&gt;snd_buf);
		kcp-&gt;nsnd_que--;
		kcp-&gt;nsnd_buf++;

		newseg-&gt;conv = kcp-&gt;conv;
		newseg-&gt;cmd = IKCP_CMD_PUSH;
		newseg-&gt;wnd = seg.wnd;
		newseg-&gt;ts = current;
		newseg-&gt;sn = kcp-&gt;snd_nxt++;
		newseg-&gt;una = kcp-&gt;rcv_nxt;
		newseg-&gt;resendts = current;
		newseg-&gt;rto = kcp-&gt;rx_rto;
		newseg-&gt;fastack = 0;
		newseg-&gt;xmit = 0;
	}

	// calculate resent
	// 失序多少次就快速重传。如果 fastresend 大于 0，则取其值；否则，设为最大值 0xffffffff。
	resent = (kcp-&gt;fastresend &gt; 0)? (IUINT32)kcp-&gt;fastresend : 0xffffffff;
	// 最小超时重传时间。如果 nodelay 为 0，则为 rx_rto 的八分之一，否则为 0。
	rtomin = (kcp-&gt;nodelay == 0)? (kcp-&gt;rx_rto &gt;&gt; 3) : 0;

	// flush data segments
	for (p = kcp-&gt;snd_buf.next; p != &amp;kcp-&gt;snd_buf; p = p-&gt;next) {
		// 从 snd_buf 取出一个报文
		IKCPSEG *segment = iqueue_entry(p, IKCPSEG, node);
		int needsend = 0;
		// 条件1：第一次发送的报文，直接发送
		if (segment-&gt;xmit == 0) {   //  该报文的 xmit 传输次数
			needsend = 1;
			segment-&gt;xmit++;
			segment-&gt;rto = kcp-&gt;rx_rto;
			segment-&gt;resendts = current + segment-&gt;rto + rtomin;
		}
		else if (_itimediff(current, segment-&gt;resendts) &gt;= 0) {
			// 条件2：且重传时间到了，则重传
			needsend = 1;
			segment-&gt;xmit++;
			kcp-&gt;xmit++;
			if (kcp-&gt;nodelay == 0) {
				segment-&gt;rto += _imax_(segment-&gt;rto, (IUINT32)kcp-&gt;rx_rto);
			}	else {
				IINT32 step = (kcp-&gt;nodelay &lt; 2)?
					((IINT32)(segment-&gt;rto)) : kcp-&gt;rx_rto;
				segment-&gt;rto += step / 2;
			}
			segment-&gt;resendts = current + segment-&gt;rto;
			lost = 1;
		}
		else if (segment-&gt;fastack &gt;= resent) {
			// 条件3：达到快速重传次数，则重传
			if ((int)segment-&gt;xmit &lt;= kcp-&gt;fastlimit ||
				kcp-&gt;fastlimit &lt;= 0) {
				needsend = 1;
				segment-&gt;xmit++;
				segment-&gt;fastack = 0;
				segment-&gt;resendts = current + segment-&gt;rto;
				change++;
			}
		}

		if (needsend) {
			int need;
			segment-&gt;ts = current;
			segment-&gt;wnd = seg.wnd;
			segment-&gt;una = kcp-&gt;rcv_nxt;

			size = (int)(ptr - buffer);
			need = IKCP_OVERHEAD + segment-&gt;len;

			if (size + need &gt; (int)kcp-&gt;mtu) {
				ikcp_output(kcp, buffer, size);
				ptr = buffer;
			}

			ptr = ikcp_encode_seg(ptr, segment);

			if (segment-&gt;len &gt; 0) {
				memcpy(ptr, segment-&gt;data, segment-&gt;len);
				ptr += segment-&gt;len;
			}

			// 如果某个数据包的重传次数超过阈值，则标记连接断开。
			if (segment-&gt;xmit &gt;= kcp-&gt;dead_link) {
				kcp-&gt;state = (IUINT32)-1;
			}
		}
	}

	// flash remain segments
	size = (int)(ptr - buffer);
	if (size &gt; 0) {
		ikcp_output(kcp, buffer, size);
	}

	// update ssthresh
	// 1. 如果发生了快速重传，让 ssthresh 减半，进入快恢复
	if (change) {
		IUINT32 inflight = kcp-&gt;snd_nxt - kcp-&gt;snd_una;
		kcp-&gt;ssthresh = inflight / 2;
		if (kcp-&gt;ssthresh &lt; IKCP_THRESH_MIN)
			kcp-&gt;ssthresh = IKCP_THRESH_MIN;
		kcp-&gt;cwnd = kcp-&gt;ssthresh + resent;
		kcp-&gt;incr = kcp-&gt;cwnd * kcp-&gt;mss;
	}

	// 2. 如果发生了超时重传，则让 ssthresh 减半，然后 cwnd = 1，进入慢启动
	if (lost) {
		kcp-&gt;ssthresh = cwnd / 2;
		if (kcp-&gt;ssthresh &lt; IKCP_THRESH_MIN)
			kcp-&gt;ssthresh = IKCP_THRESH_MIN;
		kcp-&gt;cwnd = 1;
		kcp-&gt;incr = kcp-&gt;mss;
	}

	// 兜底，cwnd 至少为 1
	if (kcp-&gt;cwnd &lt; 1) {
		kcp-&gt;cwnd = 1;
		kcp-&gt;incr = kcp-&gt;mss;
	}
}
</code></pre>
<h2>参考</h2>
<ul>
<li><a href="https://github.com/skywind3000/kcp">KCP repo</a></li>
<li><a href="https://luyuhuang.tech/2020/12/09/kcp.html">详解 KCP 协议的原理和实现</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 入门丨01 类型系统概述</title>
      <link>https://hedon.top/blog/rust-01-type-system/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-01-type-system/</guid>
      <pubDate>Thu, 28 Nov 2024 17:52:47 GMT</pubDate>
      <description>本文从编程语言的角度介绍了类型系统的基本概念，并详细阐述了 Rust 类型系统的特点，包括静态类型、强类型、所有权系统等核心特性。</description>
      <category>rust</category><category>Rust 入门</category>
      <content:encoded><![CDATA[<p>在 Rust 编程世界中，绝大部分的特性和能力都离不开 Rust 强大的类型系统，所以在这个系列的第 1 篇我们先来对 Rust 的类型系统做一个全局概述，希望可以帮助你建立起对 Rust 的基本印象。在后续的实践过程中，我推荐你可以经常回来思考下为什么 Rust 要构建这样的类型系统，在每一个分支点是如何做出决策的，这些决策又体现在代码的哪些地方。相信这样可以帮助你更好地入门 Rust。</p>
<p>废话不多说，进入正文。</p>
<h2>什么是类型系统？</h2>
<p>在进入 Rust 类型系统讨论之前，我们先尝试占在更高的角度，即整个编程语言界的角度去思考，<font color="red">什么是类型系统？</font></p>
<blockquote>
<p>编程语言的类型系统是指一套规则，用于定义和管理程序中数据的类型。类型系统的主要目的是帮助捕获程序中的错误，提高代码的可靠性和可读性。</p>
</blockquote>
<p>类型系统可以根据多种特性进行分类，主要包括以下几个方面：</p>
<ol>
<li><p><strong>静态类型和动态类型</strong>：</p>
<ul>
<li><strong>静态类型</strong>：在编译时检查变量类型。例如，Java、C++ 和 Haskell 都是静态类型语言。在这些语言中，变量的类型必须在编译时确定，这样可以在编译阶段捕获许多类型错误。</li>
<li><strong>动态类型</strong>：在运行时检查变量类型。例如，Python、Ruby 和 JavaScript 是动态类型语言。在这些语言中，变量的类型是在程序运行时确定的，这提供了更大的灵活性，但也可能导致运行时错误。</li>
</ul>
</li>
<li><p><strong>强类型和弱类型</strong>：</p>
<ul>
<li><strong>强类型</strong>：严格限制不同类型之间的操作。例如，Python 和 Java 是强类型语言。强类型系统通常不允许隐式类型转换，这意味着在进行不同类型之间的操作时，必须显式地进行类型转换。</li>
<li><strong>弱类型</strong>：允许更多隐式类型转换。例如，JavaScript 和 Perl 是弱类型语言。在这些语言中，编译器或解释器会在需要时自动进行类型转换，这可能导致难以预料的行为。</li>
</ul>
</li>
<li><p><strong>显式类型和隐式类型</strong>：</p>
<ul>
<li><strong>显式类型</strong>：程序员必须明确声明每个变量的类型。例如，Java 和 C++ 要求在声明变量时指定其类型。</li>
<li><strong>隐式类型</strong>：编译器或解释器会根据上下文自动推断变量的类型。例如，Python 和 JavaScript 使用隐式类型，程序员不需要显式声明变量类型。</li>
</ul>
</li>
<li><p><strong>子类型和多态</strong>：</p>
<ul>
<li><strong>子类型</strong>：一种类型系统允许一种类型作为另一种类型的子集。例如，在面向对象编程中，子类是父类的子类型。</li>
<li><strong>多态</strong>：允许一个接口被多种不同类型实现。多态性有多种形式，包括参数多态（如泛型）和子类型多态（如继承）。</li>
</ul>
</li>
<li><p><strong>类型推断</strong>：</p>
<ul>
<li>类型推断是指编译器自动确定表达式的类型，而无需明确的类型注释。例如，Haskell 和 Scala 使用类型推断来减少程序员的负担，同时保持静态类型的安全性。</li>
</ul>
</li>
<li><p><strong>代数数据类型和类型构造</strong>：</p>
<ul>
<li>代数数据类型（ADT）是通过组合其他类型来构造新类型的机制，常见于函数式编程语言，如 Haskell 和 OCaml。ADT 包括产品类型（如元组）和和类型（如枚举）。</li>
</ul>
</li>
<li><p><strong>结构类型和名义类型</strong>：</p>
<ul>
<li><strong>结构类型</strong>：基于对象的结构来确定类型的兼容性。例如，TypeScript 和 Go 使用结构类型系统。</li>
<li><strong>名义类型</strong>：基于名称来确定类型的兼容性。例如，Java 和 C++ 使用名义类型系统。</li>
</ul>
</li>
</ol>
<p>这里我梳理了一张图，供你参考：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241128183852984.png" alt="编程语言类型系统"></p>
<blockquote>
<p>注：本图参考了陈天老师在 Rust 训练营课程上提供的教案并进行了增改。</p>
</blockquote>
<h2>Rust 类型系统</h2>
<p>Rust 为了在提供高性能的同时保证内存安全和线程安全，花了大量力气构建了一个强大的类型系统。</p>
<p>基于之前提到的七个方面，我们来梳理下 Rust 的类型系统：</p>
<ol>
<li><p><strong>静态类型</strong>：Rust 是静态类型语言，这意味着变量的类型在编译时就被确定。这种设计使得 Rust 在编译阶段就可以捕获许多类型错误，从而提高代码的安全性和性能。</p>
<pre><code class="language-rust">fn main() {
    let x: i32 = 10; // 明确指定类型
}
</code></pre>
</li>
<li><p><strong>强类型</strong>：Rust 是强类型语言，它严格限制不同类型之间的操作。Rust 不允许隐式类型转换（例如，不能自动将整数转换为浮点数），需要显式地使用 as 进行类型转换。这种严格性有助于避免许多常见的编程错误。</p>
<pre><code class="language-rust">fn main() {
    let x: i32 = 5;
    let y: f64 = 10.0;

    // 错误：不能将 i32 隐式转换为 f64
    // let sum = x + y;

    // 正确：需要显式转换
    let sum = x as f64 + y;
    println!(&quot;Sum = {}&quot;, sum);
}
</code></pre>
</li>
<li><p><strong>显式类型和类型推断</strong>：虽然 Rust 是显式类型语言，要求在某些情况下声明变量类型，但它也具有强大的类型推断能力。编译器可以根据上下文推断出大多数变量的类型，减少了程序员的负担。例如：</p>
<pre><code class="language-rust">let mut v = vec![];
v.push(5u8);  // 结合这里，Rust 编译器可以推断出 v 的类型是 Vec&lt;u8&gt;
</code></pre>
</li>
<li><p><strong>子类型和多态</strong>：Rust 支持泛型和 trait，这是一种多态性的实现方式。trait 类似于接口，允许定义类型可以实现的一组方法。泛型允许定义函数、结构体和枚举时使用占位类型，从而实现代码的重用和灵活性。</p>
<pre><code class="language-rust">// 这里 std::fmt::Display 就是一个 trait，目前，你可以先简单理解为 trait 就是接口
fn print_value&lt;T: std::fmt::Display&gt;(value: T) {
    println!(&quot;{}&quot;, value);
}

fn main() {
    print_value(42);  // 42 默认为 i32，标准库为其是实现了 Display trait
    print_value(&quot;Hello, world!&quot;); // &amp;str 也实现了 Display trait
}
</code></pre>
</li>
<li><p><strong>类型推断</strong>：Rust 的类型推断系统非常强大，能够根据代码上下文自动推断变量和表达式的类型。这使得代码更简洁，同时保持了类型安全性。</p>
</li>
<li><p><strong>代数数据类型和类型构造</strong>：Rust 支持代数数据类型，通过枚举（enum）和结构体（struct）来实现。枚举允许定义一个类型，该类型可以是几种不同的变体之一，每个变体可以携带不同的数据。</p>
<pre><code class="language-rust">enum Option&lt;T&gt; {
    Some(T),
    None,
}
</code></pre>
</li>
<li><p><strong>结构类型和名义类型</strong>：Rust 使用名义类型系统。每个类型都有一个显式的名称，类型的兼容性基于名称而不是结构。这意味着即使两个结构体有相同的字段，它们也被视为不同的类型，除非通过特征或显式转换来实现兼容性。</p>
</li>
</ol>
<p>除此之外，Rust 的类型系统还提供了其他非常强大且有用的特效，如所有权和借用、生命周期以及模式匹配。</p>
<ul>
<li><p><strong>所有权和借用（Ownership and Borrowing）</strong>：</p>
<ul>
<li>Rust 的类型系统与其所有权模型紧密结合。所有权模型通过所有权、借用和生命周期的概念来管理内存，从而在无垃圾回收器的情况下确保内存安全。</li>
</ul>
<pre><code class="language-rust">fn main() {
    let s = String::from(&quot;Hello&quot;);
    let len = calculate_length(&amp;s); // 借用
    println!(&quot;The length of &#39;{}&#39; is {}.&quot;, s, len);
}

fn calculate_length(s: &amp;String) -&gt; usize {
    s.len()
}
</code></pre>
</li>
<li><p><strong>生命周期（Lifetimes）</strong>：</p>
<ul>
<li>Rust 使用生命周期标注来跟踪引用的有效范围，确保引用在使用时始终有效。这是 Rust 类型系统中一个独特的特性，帮助防止悬空引用和数据竞争。</li>
</ul>
<pre><code class="language-rust">fn longest&lt;&#39;a&gt;(x: &amp;&#39;a str, y: &amp;&#39;a str) -&gt; &amp;&#39;a str {
    if x.len() &gt; y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from(&quot;long string is long&quot;);
    let string2 = &quot;xyz&quot;;

    let result = longest(string1.as_str(), string2);
    println!(&quot;The longest string is &#39;{}&#39;&quot;, result);
}
</code></pre>
</li>
<li><p><strong>模式匹配</strong>：</p>
<ul>
<li>Rust 提供强大的模式匹配功能，尤其是在处理枚举和复杂数据结构时，使得代码更具表达力和安全性。</li>
</ul>
<pre><code class="language-rust">enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn process_message(msg: Message) {
    match msg {
        Message::Quit =&gt; {
            println!(&quot;Quit the application&quot;);
        }
        Message::Move { x, y } =&gt; {  // 模式匹配能根据数据类型直接拆解出来，使用起来非常方便
            println!(&quot;Move to coordinates: ({}, {})&quot;, x, y);
        }
        Message::Write(text) =&gt; {
            println!(&quot;Text message: {}&quot;, text);
        }
        Message::ChangeColor(r, g, b) =&gt; {
            println!(&quot;Change color to RGB({}, {}, {})&quot;, r, g, b);
        }
    }
}
</code></pre>
</li>
</ul>
<p>Rust 的类型系统通过上述特性实现了高效、安全和灵活的编程模型，适合系统编程和高性能应用。它在编译期捕获许多潜在错误，使得运行时更为安全可靠。</p>
<p>当然，如果你之前没有学习过 Rust，那这些概念和代码对你来说大概率是云里雾里，不要着急，我们先建立起一个大概的印象就行了。这里我针对 Rust 类型系统梳理了一张图，你可以在以后的学习中时常回来看看：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241128185615522.png" alt="Rust 类型系统"></p>
<blockquote>
<p>注：本图参考了陈天老师在 Rust 训练营课程上提供的教案并进行了增改。</p>
</blockquote>
<p>本篇就到这里，下篇我们将介绍 Rust 的数据类型，enjoy coding~</p>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 训练营总结丨第三次入门 Rust</title>
      <link>https://hedon.top/blog/rust-bootcamp/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-bootcamp/</guid>
      <pubDate>Tue, 26 Nov 2024 19:10:08 GMT</pubDate>
      <description>本文记录了我在 Rust 训练营的学习历程，也映射了我 2024 年全年的成长轨迹。</description>
      <category>rust</category><category>总结</category><category>2024</category>
      <content:encoded><![CDATA[<h2>缘起</h2>
<p>2023 年我给自己定了很多个目标，最终的结果是每个都做了一些事情，但是没有一个是做得比较彻底的，印证了《孙子兵法》的那句：“无所不备，则无所不寡”。</p>
<p>在 2023.10.23 出于好奇，我订阅了《Rust 语言从入门到实战》的专栏，跟着课程的更新节奏学习完了整个专栏。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/Rust%E8%AF%AD%E8%A8%80%E4%BB%8E%E5%85%A5%E9%97%A8%E5%88%B0%E5%AE%9E%E6%88%98%E7%BB%93%E8%AF%BE%E8%AF%81%E4%B9%A6.png" alt="Rust语言从入门到实战结课证书" style="zoom:33%;" />

<p>虽然我第一次入门 Rust 失败了，但也被 Rust 的种种特性所吸引。我是个特别喜欢“痛苦前置”的人，而 Rust 编译器&quot;睚眦必报&quot;的编译器检查正给予了我被虐的爽感，编译通过后程序的稳定运行也符合我追求成为一位“靠谱”工程师的愿景。</p>
<p>加之我的主力语言是 Go，一门应用编程语言，所以我一直希望学习一门系统编程语言，以期将来有能力窥探一些底层的细节原理。C/C++ 太古老了，特性太多了，大神太多了，我怎么学都不可能赶得上别人，嘿嘿，学个新的，大家都没学过，这不就舒服了么。</p>
<p>后来极客时间决定开设《Rust 训练营》，讲师是<a href="https://www.zhihu.com/people/tchen">陈天</a>老师，我去搜了关于陈天老师的一些资料，看了一些他写的文章和技术分享视频，甚至油管上还有他之前面试的视频。OK，这个人得到了我的认可，我想跟这样的人交个朋友，哪怕只是加个微信，至少我多了个口子，得以窥探精英阶层人士的生活一角。</p>
<p>结合 2023 年的教训，2024 年年初我就给自己制定了一年的目标，只有一个，就是<strong>踏踏实实、完完整整学习完整个 Rust 训练营，其他所有事情和目标，都要为其让步</strong>。</p>
<blockquote>
<p>其实是 2 个目标 hhh，另外一个目标是：完成人生的第一场半程马拉松。</p>
</blockquote>
<h2>筑基</h2>
<p>为了更好服务于《Rust 训练营》，在 1-4 月份，我花了差不多 3 个多月的时间啃下了<a href="https://book.douban.com/subject/36547630/">《Rust 程序设计（第二版）》</a>，对整个 Rust 的语言特性建立了更加完善的体系基础，也多奠定了一些基础，当然，这是我第二次入门 Rust 失败。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241127172804063.png" alt="Rust 程序设计（第二版）阅读计划" style="zoom: 25%;" />

<h2>修炼</h2>
<p>4 月 18 号开营，本来是预计 7 月份结营的，不过陈天老师分享的欲望刹不住车，硬是“拖堂”到了 11 月 22 号。事实上，这是有点难受的，一个事情拖太久，思维上很容易疲惫，懒惰也愈难克服。不过从消费者的角度，这是赚翻了，毕竟，学着学着，花呗的 12 期无息分期也差不多要还完了。</p>
<p>所以，其实一个 1095 的程序员，在 4.20 到 11.22 是可以花 279 小时 54 分钟学完 202 讲课程的。</p>
<blockquote>
<p>即使你将来不使用 Rust，相信你学完这门课程后也能成为一位更好的软件工程师。 —— 陈天</p>
</blockquote>
<p>是的，在学习中，更多时候感受到的不仅仅是在学习 Rust，而是在重学软件工程，我开始切身接触优秀的软件开发具备了哪些不可或缺的流程。为了效仿这些优秀的思想和实践，在实际工作中，今年我做了一些尝试：</p>
<ol>
<li>引入更丰富的 CI/CD 流程，尽可能发挥机器的能力，让机器不厌其烦地做那些的重复劳动，而这些不起眼的重复劳动，却能以最小代码为我们排查出最多难以发现的“失误” BUG。</li>
<li>开始学习写单测，开始学习如何将代码写得能单测、易单测，学习着如何将那些不能单测的 💩 代码改造成可单测的代码，也将单测运行加入了 CI/CD 的流程中。在单测多次帮我揪出那些我意识不到的不小心改错的逻辑的时候，我才切身感受到单测的作用，也真正理解了“写单测并不会影响开发效率，如果影响了，那也是提高了开发效率”。幸运的是，截至目前（11.27），我已经连续 2 次，在上千行代码的需求开发中，提测阶段和线上发布阶段，都是 0 Bug，运气不错。</li>
<li>引入监控系统，在指标上，存储层、应用层、业务层和网关层进行分层监控，在开发时，从业务无关组件（<a href="https://github.com/hedon954/goapm">goapm</a>），到业务相关通用组件，最后再到应用程序特定组件的分阶段分层次开发，开始学习着“先解决业务背后的领域问题，顺带解决业务问题”。</li>
<li>开始思考一些架构层面的东西，开始思考一些代码组织、接口契约、领域模块划分的问题，以期写出质量更好的代码。</li>
</ol>
<p>为了支撑上面这些事情，今年我又顺带读了一些书，我是个很少读书的人，因为我总觉得：“读书好慢”。而且我读书也确实很慢，主要是，很困 😅。然而，当我回望来时路，一切却都在我的意料之外。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241127180300408.png" alt="hedon 2024 的书单"></p>
<p>这个时候我才知道：</p>
<ul>
<li>慢就是快</li>
<li>少就是多</li>
</ul>
<h2>历劫</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241127161520649.png" alt="rust-road"></p>
<p>这些书其实都不在我的计划之内，因为 2024 我只有一个目标：<strong>完成 Rust 训练营的学习</strong>。它们只不过是我完成既定计划之余的加餐罢了。</p>
<p>而幸好我只有一个目标，所以才能有更多时间和精力去应对跟随训练营学习中的一些困难：</p>
<ul>
<li>晚上 9 点下班，真累啊，休息下吧，真不想学了。</li>
<li>工作了一周，真累啊，周末要不就休息吧，真不想学了。</li>
<li>编译器报错好多啊，算了，要不直接 copy 现成的代码吧。</li>
<li>这知识点在讲啥啊，算了，先不懂装懂吧，后面还那么多课，先赶进度再说。</li>
<li>前端和客户端的知识，好像跟我没啥关系，算了，不听了，过过过。</li>
<li>单测我就不写了，浪费时间。</li>
<li>学完咯，感觉没啥好总结的，算了，下一个吧。</li>
<li>....</li>
</ul>
<p>运气不错，上述的 n 多种情况，至少在 50-70% 的时候，我能做到：</p>
<ul>
<li>学一下再说，累了再停。</li>
<li>下午出去玩，早上先学了再说。</li>
<li>算了，狠点，盲写，自己尝试解决一下，咦，也就那么回事。写完后再对比下，哦，其实这块没听懂。</li>
<li>弄懂再说，多听几遍课，重新看几遍书，再搜一些相关博客，哦，这个知识点是这个意思，读书百遍其义自见原来是这味？</li>
<li>算了，试试现在 LLM 是否如吹的那么牛，嗯，好像用 LLM 来实现前端和客户端的基础功能还真可以，也没那么无聊嘛。</li>
<li>算了，先试着写下单测吧。哦，我的代码这么难测啊，哦，这行代码怎么就犯蠢了呢，哦，花不了多少时间嘛。</li>
<li>要不还是总结下吧，哦，原来这个地方是这个意思，哦，原来还讲到了这个点。</li>
</ul>
<p>所以这个时候我又知道了：</p>
<ul>
<li>慢就是快</li>
<li>少就是多</li>
</ul>
<h2>小成</h2>
<pre><code>➜  hedon-rust-road ll
total 0
drwxr-xr-x  21 wangjiahan  staff   672B Nov 27 18:26 aicomm
drwxr-xr-x  23 wangjiahan  staff   736B Sep 11 13:55 chat
drwxr-xr-x  17 wangjiahan  staff   544B Sep 11 18:31 chatapp
drwxr-xr-x  26 wangjiahan  staff   832B Nov 27 18:26 crm
drwxr-xr-x  22 wangjiahan  staff   704B Nov 27 18:26 dino
drwxr-xr-x  16 wangjiahan  staff   512B Nov 27 18:29 error-info
drwxr-xr-x  18 wangjiahan  staff   576B Sep  4 19:00 hackernews
drwxr-xr-x  22 wangjiahan  staff   704B Sep 12 15:54 hedon-bot
drwxr-xr-x   9 wangjiahan  staff   288B Nov 27 18:29 httpie
drwxr-xr-x  13 wangjiahan  staff   416B Aug 22 10:40 inverted-index-concurrency
drwxr-xr-x   7 wangjiahan  staff   224B Nov 27 18:28 json-macro
drwxr-xr-x  26 wangjiahan  staff   832B Sep  3 19:30 learn-ffi
drwxr-xr-x   8 wangjiahan  staff   256B Nov 27 18:29 learn-proc-macro
drwxr-xr-x   7 wangjiahan  staff   224B Nov 27 18:30 mandelbrot
drwxr-xr-x  10 wangjiahan  staff   320B Aug 22 10:40 matrix-multi
drwxr-xr-x   7 wangjiahan  staff   224B Nov 27 18:29 pest-parser-collection
drwxr-xr-x  19 wangjiahan  staff   608B Nov 27 18:27 r-redis
drwxr-xr-x  21 wangjiahan  staff   672B Aug 22 10:40 rcli
drwxr-xr-x  17 wangjiahan  staff   544B Aug 22 10:40 simple-chat
drwxr-xr-x  17 wangjiahan  staff   544B Aug 22 10:40 simple-shortener
drwxr-xr-x  21 wangjiahan  staff   672B Aug 22 10:40 taotie
drwxr-xr-x@ 18 wangjiahan  staff   576B Nov 27 15:38 thumbor
drwxr-xr-x  19 wangjiahan  staff   608B Aug 29 10:56 winnow-parser-collection
➜  hedon-rust-road tokei -t rust
===============================================================================
 Language            Files        Lines         Code     Comments       Blanks
===============================================================================
 Rust                  336        25451        21615          644         3192
 |- Markdown            53          546            0          476           70
 (Total)                          25997        21615         1120         3262
===============================================================================
 Total                 336        25451        21615          644         3192
===============================================================================
</code></pre>
<p>看老师画了那么多牛逼的图，要不“邯郸学步”模仿一下吧。故而又忍着“下一个吧”的念头，梳理了下这几个月到底做了些什么。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/8abb5b2a2f3020ca36f75087ae76a53c.PNG" alt="rcli"></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/4ac783c6adaa38a45853861753112e35.PNG" alt="r-redis"></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/4bdd0cba75aa773c6f4e941cf5c5fe29.PNG" alt="macro-json"></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/969e023d9003b3c28ea1c95a7c1d9388.PNG" alt="macro-error-info"></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/f25b8a0f1c95ac306ecda6c4ff3954a3.PNG" alt="rust-ecosystem"></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/d04da8bf3577f1d09b7fce647451a700.PNG" alt="crm"></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/c85939619088cda8c9a763ba514d235e.PNG" alt="taotie"></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/2d3892c57b0742707c6ff3a1267f532a.PNG" alt="dino"></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/874082e0f9f9410f342d875f8559bd15.PNG" alt="aicomm"></p>
<h2>归元</h2>
<ul>
<li>知是行之始，行是知之成。</li>
<li>遇事不决，可问春风。春风不语，既随本心。</li>
</ul>
<p>2025 见！</p>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</title>
      <link>https://hedon.top/blog/rust-memory-order/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-memory-order/</guid>
      <pubDate>Mon, 11 Nov 2024 13:06:49 GMT</pubDate>
      <description>本文深入探讨了 Rust 中的原子操作和内存顺序模型。从硬件层面的原子操作实现原理出发,详细介绍了 Rust 提供的各种原子类型及其操作,并重点阐述了内存顺序(Memory Ordering)的概念、分类及其在并发编程中的应用。通过大量示例代码和图解,帮助读者全面理解 Rust 的内存模型和并发安全机制。</description>
      <category>rust</category><category>Go</category><category>内存顺序</category><category>内存屏障</category><category>并发控制</category><category>atomic</category><category>happens-before</category><category>Rust 原理</category>
      <content:encoded><![CDATA[<p>系列文章：</p>
<ul>
<li><a href="/blog/rust-memory-order/">Rust 原理丨聊一聊 Rust 的 Atomic 和内存顺序</a> 👈 本篇</li>
<li><a href="/blog/rust-atomic-in-processor/">Rust 原理丨从汇编角度看原子操作</a></li>
<li><a href="/blog/rust-action-spinlock/">Rust 实战丨手写一个 SpinLock</a></li>
<li><a href="/blog/rust-action-oneshot-channel/">Rust 实战丨手写一个 oneshot channel</a></li>
<li><a href="/blog/rust-action-arc/">Rust 实战丨手写一个 Arc</a></li>
<li><a href="/blog/rust-os-primitives/">Rust 原理丨操作系统并发原语</a></li>
<li><a href="/blog/rust-action-mutex/">Rust 实战丨手写一个 Mutex</a></li>
<li><a href="/blog/rust-action-condvar/">Rust 实战丨手写一个 Condvar</a></li>
<li><a href="/blog/rust-action-rwlock/">Rust 实战丨手写一个 RwLock</a></li>
</ul>
<hr>
<h2>Atomic</h2>
<p>在 Rust 的 <code>std::sync::atomic</code> 模块中包含了无锁并发编程的原子化类型，与通常的算术运算符和逻辑运算符不同，原子化类型会暴露执行原子化操作的方法，单独的加载、存储、交换和算术运算都会作为一个单元安全地进行，哪怕其他线程也在执行操作同一内存的原子化操作也没问题。</p>
<p>Rust 提供了以下几种原子化类型：</p>
<ul>
<li><code>AtomicIsize</code> 和 <code>AtomicUsize</code> 是与单线程 <code>isize</code> 类型和 <code>usize</code> 类型对应的共享整数类型。</li>
<li><code>AtomicI8</code>、<code>AtomicI16</code>、<code>AtomicI32</code>、<code>AtomicI64</code> 及其无符号变体（如 <code>AtomicU8</code>）是共享整数类型，对应于单线程中的类型 <code>i8</code>、<code>i16</code> 等。</li>
<li><code>AtomicBool</code> 是一个共享的 <code>bool</code> 值。</li>
<li><code>AtomicPtr</code> 是不安全指针类型 <code>*mut T</code> 的共享值。</li>
</ul>
<p>这些类型都会以下几类核心功能：</p>
<ul>
<li><code>Load</code> 、<code>Store</code>: 存取值</li>
<li><code>Fetch-and-Modify</code>: 获取并修改</li>
<li><code>Compare-and-Exchange</code>: 比较并交换</li>
</ul>
<p>下面我们对上述提到的几种核心功能进行举例。</p>
<h3>Load &amp; Store</h3>
<ul>
<li><strong>load</strong>: 从原子化类型中获取起对应的基本数据类型的值。</li>
<li><strong>store</strong>: 将一个基本数据类型的值存储到其对应的原子化类型中。</li>
</ul>
<p>在下面的例子中，我们使用 <code>AtomicUsize::new(0)</code> 初始化了一个原子类型，它对应的基本数据类型是 <code>usize</code>。</p>
<p>我们起了一个子线程，在 for 循环中不断地使用 <code>store</code> 函数修改 <code>num_done</code> 的值，然后在主线程中使用 <code>load</code> 获取起对应的值，当发现值为 <code>100</code> 时，就退出循环，进程结束。</p>
<p>得益于原子化类型的并发安全特性，所以这里两个线程对 <code>num_done</code> 进行并发读写都是安全的。</p>
<pre><code class="language-rust">fn main() {
    let num_done = AtomicUsize::new(0);

    let main_thread = thread::current();

    thread::scope(|s| {
        s.spawn(|| {
            for i in 0..100 {
                sleep(Duration::from_millis(10));
                num_done.store(i + 1, std::sync::atomic::Ordering::Relaxed);  // store 存储
                main_thread.unpark();
            }
        });

        loop {
            let n = num_done.load(std::sync::atomic::Ordering::Relaxed); // load 获取
            if n == 100 {
                break;
            }
            println!(&quot;Working... {n}/100 done&quot;);
            thread::park_timeout(Duration::from_millis(1));
        }
    });
    println!(&quot;Done!&quot;);
}
</code></pre>
<blockquote>
<p>这里我们暂且忽略 <code>std::sync::atomic::Ordering::Relaxed</code> 这个参数的含义，在后续的「内存顺序」章节会进行详细阐述。</p>
</blockquote>
<h3>Fetch-and-Modify</h3>
<p><strong>Fetch-and-Modify</strong> 操作用于在获取当前值的同时对其进行修改。这类操作包括 <code>fetch_add</code>、<code>fetch_sub</code>、<code>fetch_and</code>、<code>fetch_or</code>、<code>fetch_xor</code> 等。</p>
<p>我们将上面的例子修改一下，不再是直接 <code>store</code> 一个值，而是不断进行加 1 操作：</p>
<pre><code class="language-rust">fn main() {
    let num_done = &amp;AtomicUsize::new(0);

    thread::scope(|s| {
        s.spawn(|| {
            for _ in 0..100 {
                num_done.fetch_add(1, std::sync::atomic::Ordering::Relaxed); // 使用 fetch_add 进行加 1
            }
        });

        loop {
            let n = num_done.load(std::sync::atomic::Ordering::Relaxed);
            if n == 100 {
                break;
            }
            println!(&quot;Working... {n}/100 done&quot;);
        }
    });
    println!(&quot;Done!&quot;);
}
</code></pre>
<h3>Compare-and-Exchange</h3>
<p><strong>Compare-and-Exchange</strong> 是一种条件更新操作，只有在当前值等于预期值时才会更新。</p>
<p>下面的例子中我们实现了一个函数 <code>allocate_new_id</code>，它支持在并发环境下分配新的 <code>id</code>，这里我们使用了 <code>compare_exchange(id, id+1)</code> 进行条件更新，只有当 <code>id</code> 没有发生变化的时候，才运行对其进行加 1，这就保证了在并发下，只有一个线程可以成功执行该语句，从而保证 <code>id</code> 的递增性和唯一性。</p>
<pre><code class="language-rust">fn allocate_new_id() -&gt; u32 {
    static NEXT_ID: AtomicU32 = AtomicU32::new(0);
    let mut id = NEXT_ID.load(std::sync::atomic::Ordering::Relaxed);
    loop {
        assert!(id &lt; 1000, &quot;Too many IDs!&quot;);
        match NEXT_ID.compare_exchange(  // 只有 id 没有发生变化，才允许进行加 1
            id,
            id + 1,
            std::sync::atomic::Ordering::Relaxed,
            std::sync::atomic::Ordering::Relaxed,
        ) {
            Ok(_) =&gt; return id,
            Err(v) =&gt; id = v,
        }
    }
}
</code></pre>
<details open>
<summary>在 Rust 中，原子化类型还提供了另外一个函数：`compare_exchange_weak`，它与 `compare_exchange` 的主要区别在于它们在**失败时**的行为：</summary>


<p><strong>compare_exchange</strong>:</p>
<ul>
<li>只会在实际值不等于期望值时失败。</li>
<li>提供更强的保证，但可能性能较低。</li>
<li>适用于不在循环中的单次比较交换操作。</li>
</ul>
<p><strong>compare_exchange_weak</strong>:</p>
<ul>
<li>即使实际值等于期望值时也可能失败（称为“虚假失败”或“spurious failure”）。</li>
<li>性能可能更好，因为允许在某些架构上生成更高效的代码。</li>
<li>最适合在循环中使用，因为需要处理可能的虚假失败。</li>
</ul>
<p>在实际应用中:</p>
<ul>
<li>如果操作在循环中,使用 <code>compare_exchange_weak</code> 通常更好。</li>
<li>如果是单次操作,使用 <code>compare_exchange</code> 更合适。</li>
<li>在某些平台上，这两个操作可能没有性能差异,但 <code>compare_exchange_weak</code> 的行为仍然可能不同。</li>
</ul>
<p>这种区别的存在是因为在某些 CPU 架构上,允许虚假失败可以生成更高效的机器码。比如在 ARM 架构上，<code>compare_exchange_weak</code> 可以直接映射到单个 LL/SC（Load-Link/Store-Conditional）指令。</p>
</details>

<h3>硬件原理</h3>
<p>在一些处理器架构中，当一个 CPU 执行需要原子性的操作时，它可以通过锁定内存总线来确保在操作完成之前，其他 CPU 无法访问相关的内存地址。</p>
<p>基本工作流程如下：</p>
<pre><code class="language-plaintext">CPU 发出 LOCK 信号
   └── 激活处理器的 LOCK# 引脚
      └── 获得总线的独占访问权
          └── 执行原子操作
              └── 释放 LOCK 信号
                  └── 其他处理器可以访问内存
</code></pre>
<p>主流的有 2 种锁定机制：</p>
<ul>
<li><p><strong>总线锁定（Bus Locking）</strong>：总线锁定是一种机制，它通过锁定内存总线来确保在执行原子操作时，其他处理器无法访问内存。这种方法虽然简单，但会导致总线的其他操作被阻塞，从而影响系统性能。</p>
<pre><code class="language-markdown">优点：

- 绝对的原子性保证
- 适用于所有内存位置

缺点：

- 性能开销大
- 会阻塞其他 CPU 对内存的访问
</code></pre>
</li>
<li><p><strong>缓存锁定（Cache Locking）</strong>：现代处理器通常使用缓存锁定来实现原子操作。缓存锁定通过锁定处理器的缓存行来实现，而不是锁定整个总线。这种方法可以减少对总线的影响，提高系统的并发性能。</p>
<pre><code class="language-markdown">优点：

- 性能更好
- 不会完全阻塞内存访问

条件：

- 数据必须在缓存行中
- 缓存行必须是独占状态
</code></pre>
</li>
</ul>
<p>缓存锁定通常依赖于缓存一致性协议（如 <strong>MESI</strong> 协议）来确保在多个处理器之间的数据一致性。通过这些协议，处理器可以在本地缓存中执行原子操作，并在必要时与其他处理器同步。</p>
<p><strong>MESI</strong> 协议即：</p>
<pre><code class="language-markdown">M (Modified)：已修改
E (Exclusive)：独占
S (Shared)：共享
I (Invalid)：无效

操作流程：

1. 检查数据是否在缓存中
2. 如果在，将状态改为 Exclusive
3. 执行原子操作
4. 通知其他 CPU 使其缓存失效
</code></pre>
<p>不同的架构有不同的锁定方式：</p>
<ul>
<li>x86/x64：使用 LOCK 前缀</li>
<li>ARM：使用 exclusive load/store 指令</li>
<li>PowerPC：使用 load-linked/store-conditional</li>
</ul>
<p>以下是 x86 汇编的一个示例：</p>
<pre><code class="language-assembly">; 原子加法操作
lock add dword ptr [memory], 1

; 比较并交换
lock cmpxchg dword ptr [memory], eax
</code></pre>
<p>为了充分利用<strong>缓存锁定</strong>的优势，我们在编写代码时，可以有以下的性能考虑：</p>
<ul>
<li><p><strong>缓存行对齐，避免伪共享</strong></p>
<pre><code class="language-rust">use std::sync::atomic::{AtomicI32, Ordering};

// 在 Rust 中，可以使用 #[repr(align(N))] 属性来确保结构体或变量的对齐方式，以避免伪共享。
// 伪共享是指多个线程访问不同的变量，但这些变量共享同一个缓存行，从而导致不必要的缓存一致性流量。
#[repr(align(64))]
struct AlignedCounter {
    counter: AtomicI32,
}

fn main() {
    let counter = AlignedCounter {
        counter: AtomicI32::new(0),
    };
    // 使用 counter.counter.fetch_add(...) 进行操作
}
</code></pre>
</li>
<li><p><strong>避免频繁的总线锁定</strong></p>
<pre><code class="language-rust">use std::sync::atomic::{AtomicI32, Ordering};

fn main() {
    let counter = AtomicI32::new(0);

    // 不好的做法：频繁的原子操作
    for _ in 0..1000 {
        counter.fetch_add(1, Ordering::SeqCst);
    }

    // 更好的做法：本地累加后一次性更新
    let mut local_sum = 0;
    for _ in 0..1000 {
        local_sum += 1;
    }
    counter.fetch_add(local_sum, Ordering::SeqCst);
}
</code></pre>
</li>
</ul>
<h4>Rust 实战查看汇编</h4>
<blockquote>
<p>笔者使用的是 ARM64 架构的 macbook。</p>
</blockquote>
<pre><code class="language-rust">use std::sync::atomic::{AtomicI64, Ordering};
use std::thread;

static ATOMIC: AtomicI64 = AtomicI64::new(0);

fn main() {
    let t1 = thread::spawn(|| {
        ATOMIC.store(10086, Ordering::Release);
    });

    let t2 = thread::spawn(|| {
        let val = ATOMIC.load(Ordering::Acquire);
        println!(&quot;{val}&quot;);
    });

    t1.join().unwrap();
    t2.join().unwrap();
}
</code></pre>
<p>使用 <code>rustc</code> 编译并输出汇编代码：</p>
<pre><code class="language-shell">rustc -O --emit asm src/main.rs
</code></pre>
<p>代码中我特地设置了 <code>10086</code> 这个特殊的值，这是为了可以在输出的 <code>main.s</code> 文件中快速找到 <code>store</code> 对应的位置：</p>
<pre><code class="language-assembly">__ZN3std3sys9backtrace28__rust_begin_short_backtrace17h750d7a3a9c81fc67E:
	.cfi_startproc
Lloh8:
	adrp	x8, __ZN4main6ATOMIC17hd0b0dbf92e477148E.0@PAGE
Lloh9:
	add	x8, x8, __ZN4main6ATOMIC17hd0b0dbf92e477148E.0@PAGEOFF
	mov	w9, #10086 ; 将值 10086 移入寄存器
	stlr	x9, [x8] ; Store-Release 指令，原子地存储值
	; InlineAsm Start
	; InlineAsm End
	ret
	.loh AdrpAdd	Lloh8, Lloh9
	.cfi_endproc
</code></pre>
<p>在这个代码中，<code>stlr</code> 就是 <code>Store Release</code> 的意思，另外一个关键字是 <code>ladpr</code>，表示 <code>Load Acquire</code> 的意思，通过这个关键字，你可以找到 <code>load</code> 对应的汇编代码：</p>
<pre><code class="language-assembly">Lloh11:
	add	x8, x8, __ZN4main6ATOMIC17hd0b0dbf92e477148E.0@PAGEOFF
	ldapr	x8, [x8] ; ; Load-Acquire 指令，原子地加载值
	str	x8, [sp, #8]
</code></pre>
<h4>Go 实战查看汇编</h4>
<blockquote>
<p>笔者使用的是 ARM64 架构的 macbook。</p>
</blockquote>
<pre><code class="language-go">package main

import (
	&quot;sync/atomic&quot;
)

func main() {
	data := atomic.Int64{}
	go func() {
		data.Store(10086)
	}()

	go func() {
		a := data.Load()
		println(a)
	}()
}
</code></pre>
<p>使用如下命令，可以输出优化后的汇编代码：</p>
<pre><code class="language-shell">go build -gcflags=-S -ldflags=-w main.go 2&gt; assembly.txt
</code></pre>
<p>查看输出的文件，我们同样搜索 <code>10086</code>，可以快速找到 <code>store</code> 的位置：</p>
<pre><code class="language-assembly">	0x0008 00008 (/Users/wangjiahan/go/go1.23.2/src/sync/atomic/type.go:109)	MOVD	$10086, R1
	0x000c 00012 (/Users/wangjiahan/go/go1.23.2/src/sync/atomic/type.go:109)	STLR	R1, (R0)
</code></pre>
<p>可以看到，这里同样也是使用了 <code>STLR</code> 指令。接着我们看第 14 行代码的位置对应的汇编：可以发现这里使用的 <code>LDAR</code> 指令，也就是 <code>Load Acuqire</code>。</p>
<pre><code class="language-assembly">	0x001c 00028 (/Users/wangjiahan/goStudy/go-atomic/main.go:14)	HINT	$0
	0x0020 00032 (/Users/wangjiahan/go/go1.23.2/src/sync/atomic/type.go:106)	LDAR	(R0), R0
	0x0024 00036 (/Users/wangjiahan/go/go1.23.2/src/sync/atomic/type.go:106)	MOVD	R0, main..autotmp_6-8(SP)
</code></pre>
<h2>内存顺序</h2>
<p>在了解了 Rust Atomic 的基本用法和基本原理之后，我们回过头来谈一谈原子操作参数中的 <code>std::sync::atomic::Ordering::Relaxed</code>，这个就是本篇的主题：<strong>内存顺序</strong>。内存顺序要解决的核心问题是<u>如何合理地限制单一线程中的代码执行顺序，使得在不使用锁的情况下，既能最大化利用 CPU 的计算能力，又能保证多线程环境下不会出现逻辑错误。</u></p>
<h3>指令乱序</h3>
<p>CPU 和编译器都会在保证程序运行结果不发生改变的前提下，尽一切可能让我们的程序运行得尽可能快。</p>
<pre><code class="language-rust">fn f(a: &amp;mut i32, b: &amp;mut i32) {
  *a += 1;
  *b += 1;
  *a += 1;
}
</code></pre>
<p>像上述代码，编译器完全可以优化成下面的代码，从而提高程序的运行效率：</p>
<pre><code class="language-rust">fn f(a: &amp;mut i32, b: &amp;mut i32) {
  *a += 2;
  *b += 1;
}
</code></pre>
<p>在这个过程中，就可能会出现<strong>指令重排</strong>，甚至是<strong>代码重写</strong>，不过这带来了指令乱序的问题，即<u>程序的实际执行顺序跟我们的代码顺序是不一致的</u>。</p>
<p>不过，编译器保证的是<strong>在单线程环境下，执行的结果最终一致</strong>，所以，指令乱序在单线程环境下完全是允许的。对于编译器来说，它只知道：在当前线程中，数据的读写以及数据之间的依赖关系。但是，<strong>编译器并不知道哪些数据是在线程间共享，而且是有可能会被修改的</strong>。而这些是需要开发人员去保证的。</p>
<h3>内存模型</h3>
<p>为了解决指令乱序带来的并发问题，Rust 采用了内存模型（Memory Model）这一概念。这个概念主要借鉴自 C++11 中引入的内存模型，它定义了在多线程环境下内存访问的行为规范。</p>
<p>内存模型的核心目标是在以下三方面之间取得平衡：</p>
<ol>
<li><strong>正确性保证</strong>：确保多线程程序的行为是可预测和一致的。</li>
<li><strong>性能优化</strong>：允许编译器和 CPU 在不违反正确性的前提下进行优化。</li>
<li><strong>跨平台兼容</strong>：提供一个统一的抽象层，使代码可以在不同的硬件架构上正确运行。</li>
</ol>
<p>具体来说，内存模型：</p>
<ul>
<li>为开发者提供了清晰的规则，说明在多线程环境下，什么样的内存访问行为是合法的，什么样的行为会导致未定义行为。</li>
<li>为编译器开发者提供了明确的标准，指导他们在不同平台上实现必要的内存同步原语。</li>
<li>通过定义不同的内存顺序级别（如 Relaxed、Release/Acquire、SeqCst 等），让开发者可以根据需要选择合适的同步强度。</li>
</ul>
<p>这种抽象让开发者可以专注于并发逻辑本身，而不必过分关
注底层硬件的具体实现细节。</p>
<h3>Sequenced-Before</h3>
<p>在讨论内存顺序之前，我们需要先对 2 个重要关系术语进行简单阐述，分别是 <code>Sequenced-Before</code> 和 <code>Happens-Before</code>。</p>
<p><strong>Sequenced-Before</strong> 描述的是<strong>单个线程内</strong>的操作顺序。它基于程序的源代码顺序，表示在同一线程中，一个操作在程序中出现在另一个操作之前。</p>
<p>具体来说，如果操作 A sequenced-before 操作 B，那么：</p>
<ol>
<li><p><strong>数据依赖关系</strong>：如果 B 依赖于 A 的结果，那么 A 一定会在 B 之前执行。例如：</p>
<pre><code class="language-rust">let x = 1;      // 操作 A
let y = x + 1;  // 操作 B - 依赖于 A 的结果
</code></pre>
</li>
<li><p><strong>原子操作的顺序</strong>：对同一个原子变量的操作会保持程序顺序。例如：</p>
<pre><code class="language-rust">X.fetch_add(5, Relaxed);    // 一定先执行
X.fetch_add(10, Relaxed);   // 一定后执行
</code></pre>
</li>
<li><p><strong>独立操作的可重排性</strong>：如果两个操作之间没有数据依赖关系，且操作的是不同的变量，那么它们可能会被重排序。例如：</p>
<pre><code class="language-rust">X.store(1, Relaxed);  // 这两个操作可能会被重排序
Y.store(2, Relaxed);  // 因为它们操作的是不同的变量
</code></pre>
</li>
</ol>
<h3>Happens-Before</h3>
<p><strong>Happens-Before</strong> 则描述了<strong>跨线程</strong>的操作顺序。它定义了不同线程中的操作之间的可见性和顺序关系。如果操作 A Happens-Before 操作 B，那么 A 的内存写入对 B 是可见的。</p>
<p>典型的 Happens-Before 有：</p>
<ol>
<li>同一线程内，如果先调用 <code>f()</code>，再调佣 <code>g()</code>，则 <code>f()</code> happens-before <code>g()</code>，其实这就是 <code>sequenced-before</code>。</li>
<li><code>spawing</code> happens-before <code>joining</code>。</li>
<li><code>lock</code> happens-before <code>unlock</code>。</li>
</ol>
<p>举个例子：</p>
<pre><code class="language-rust">static X: AtomicI32 = AtomicI32::new(0);

fn main() {
    X.store(1, Relaxed);
    let t = thread::spawn(f);
    X.store(2, Relaxed);
    t.join().unwrap();
    X.store(3, Relaxed);
}

fn f() {
    let x = X.load(Relaxed);
    assert!(x == 1 || x == 2);
}
</code></pre>
<p>上面这个例子的执行顺序如下图所示，因为 <code>spawn</code> happens-before <code>join</code>，所以我们可以确定的执行顺序是：<strong>“store 1 to X”→“store 2 to X”→“store 3 to X”</strong>。而 <strong>load from X</strong> 介于 spawn 和 join 之间，且没有进行任何其他的内存顺序限制，所以它和 <strong>store 2 to X</strong> 之间的顺序是不确定的，但是可以肯定的是，它一定在 <strong>store 3 to X</strong> 之前，所以 <code>assert!(x == 1 || x == 2);</code> 是永远成立的。</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241111160041436.png" alt="spawn-happens-before-join" style="zoom:33%;" />

<p>到这里，相信不少读者已经能够理解为什么需要内存顺序这个东西了，核心问题就是在于 <strong>store 2 to X</strong> 和 <strong>load from X</strong> 的执行顺序是否会影响我们的业务逻辑，如果不会，那么我们可以指定最松散的内存顺序要求，如果会，那么我们就要利用指定合适的内存顺序来使得其按照我们的预期顺序进行执行，从而保证业务逻的正确。</p>
<h3>Rust 内存顺序</h3>
<p>Rust 支持五种内存顺序（Ordering），从最松散到最严格依次为：</p>
<table>
<thead>
<tr>
<th>内存顺序</th>
<th>说明</th>
<th>保证</th>
<th>适用场景</th>
<th>示例</th>
</tr>
</thead>
<tbody><tr>
<td>Relaxed</td>
<td>最宽松的内存顺序</td>
<td>- 仅保证操作的原子性<br>- 不提供任何同步保证<br>- 不建立 happens-before 关系</td>
<td>- 简单计数器<br>- 性能要求极高且确定不需要同步<br>- 已通过其他方式确保同步</td>
<td><code>counter.fetch_add(1, Ordering::Relaxed)</code></td>
</tr>
<tr>
<td>Release</td>
<td>用于存储操作</td>
<td>- 之前的内存访问不会被重排到此操作之后<br>- 与 Acquire 配对使用可建立 happens-before 关系</td>
<td>- 生产者-消费者模式<br>- 发布共享数据<br>- 初始化完成标志</td>
<td><code>data.store(42, Ordering::Release)</code></td>
</tr>
<tr>
<td>Acquire</td>
<td>用于加载操作</td>
<td>- 之后的内存访问不会被重排到此操作之前<br>- 与 Release 配对使用可建立 happens-before 关系</td>
<td>- 生产者-消费者模式<br>- 获取共享数据<br>- 检查初始化标志</td>
<td><code>data.load(Ordering::Acquire)</code></td>
</tr>
<tr>
<td>AcqRel</td>
<td>同时包含 Acquire 和 Release 语义</td>
<td>- 结合了 Acquire 和 Release 的所有保证<br>- 用于读改写操作</td>
<td>- 需要双向同步的原子操作<br>- 锁的实现<br>- 复杂的同步原语</td>
<td><code>value.fetch_add(1, Ordering::AcqRel)</code></td>
</tr>
<tr>
<td>SeqCst</td>
<td>最严格的内存顺序</td>
<td>- 包含 AcqRel 的所有保证<br>- 所有线程看到的所有 SeqCst 操作顺序一致<br>- 提供全局的顺序一致性</td>
<td>- 需要严格的全局顺序<br>- 不确定使用哪种顺序时<br>- 对性能要求不高的场景</td>
<td><code>flag.store(true, Ordering::SeqCst)</code></td>
</tr>
</tbody></table>
<p>在 C++ 中，其实还有另外一种内存顺序 <code>Consume</code>，它是 <code>Acquire</code> 的一个更弱的版本：</p>
<ul>
<li><p><strong>Acquire</strong>: 保证后续的所有读写操作不会重排到这个操作前面</p>
</li>
<li><p><strong>Consume</strong>: 只保证后续与这个操作结果相关的读写操作不会重排到这个操作前面</p>
</li>
</ul>
<p>理论上，Consume 在某些架构上可以提供比 Acquire 更好的性能，因为它只需要对数据依赖的操作进行同步。</p>
<p>然而，由于以下原因，Rust 选择不支持 Consume 顺序：</p>
<ol>
<li><strong>实现复杂性</strong>：很多编译器实现者发现正确实现 Consume 语义非常困难。</li>
<li><strong>性能收益不确定</strong>：在实践中，大多数编译器都将 Consume 视为 Acquire 来处理。</li>
<li><strong>标准困惑</strong>：C++ 标准委员会也承认当前的 Consume 语义定义存在问题，正在考虑重新设计。</li>
</ol>
<details open>
<summary>选择建议：</summary>


<ol>
<li><strong>不确定选择哪种顺序时</strong>：<ul>
<li>使用 SeqCst（最安全但性能最低）</li>
<li>或咨询有经验的开发者</li>
</ul>
</li>
<li><strong>性能优化时</strong>：<ul>
<li>先使用 SeqCst 开发</li>
<li>在性能测试后，根据需要降低到 Release/Acquire</li>
<li>只有在确实需要时才使用 Relaxed</li>
</ul>
</li>
<li><strong>常见组合</strong>：<ul>
<li>Release 写 + Acquire 读：最常见的生产者-消费者模式</li>
<li>AcqRel：用于原子的读改写操作</li>
<li>Relaxed：用于简单的计数器场景</li>
</ul>
</li>
</ol>
</details>

<p>下面我们来对每种内存顺序进行举例阐述。</p>
<h4>Relaxed</h4>
<p><code>Relaxed</code> 是最宽松的内存顺序，它只保证了原子操作在并发下的安全性，但不保证执行顺序。</p>
<p>考虑如下代码：</p>
<pre><code class="language-rust">static X: AtomicI32 = AtomicI32::new(0);

fn a() {
    X.fetch_add(5, Relaxed);
    X.fetch_add(10, Relaxed);
}

fn b() {
    let a = X.load(Relaxed);
    let b = X.load(Relaxed);
    let c = X.load(Relaxed);
    let d = X.load(Relaxed);
    println!(&quot;{a} {b} {c} {d}&quot;);  // 这个输出不一定
}
fn main() {
    thread::scope(|s| {
        s.spawn(a);
        s.spawn(b);
    });

    println!(&quot;{:?}&quot;, X.load(Relaxed)); // 最终结果一定是 15
}
</code></pre>
<p>基于我们上面提到的 <code>sequenced-before</code> 规则，我们可以确定 <code>a</code> 和 <code>b</code> 两个线程内的 <code>happens-before</code> 规则，但是二者之间的 <code>happens-before</code> 是无法确定的，但是我们可以确定最后的结果是 <code>15</code>。下图展示了上述代码的执行顺序示意图：</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241111164259913.png" alt="relaxed-ordering" style="zoom:50%;" />

<p>虽然两个线程之间的 <code>happens-before</code> 是无法确定的，但是我们可以确定 <code>X</code> 的变化顺序：0→5→15。所以线程 <code>b</code> 输出 <code>0 0 0 0</code>、<code>0 0 5 15</code> 和 <code>0 15 15 15</code> 都是可能的，而永远不可能输出 <code>0 5 0 15</code> 或 <code>0 0 10 15</code> 类似的结果。</p>
<p>但是如果是这样子的话，就不一定了：</p>
<pre><code class="language-rust">static X: AtomicI32 = AtomicI32::new(0);

fn a1() {
    X.fetch_add(5, Relaxed);
}

fn a2() {
    X.fetch_add(10, Relaxed);
}

fn b() {
    let a = X.load(Relaxed);
    let b = X.load(Relaxed);
    let c = X.load(Relaxed);
    let d = X.load(Relaxed);
    println!(&quot;{a} {b} {c} {d}&quot;);  // 这个输出不一定
}
fn main() {
    thread::scope(|s| {
        s.spawn(a1);
      	s.apawn(a2);
        s.spawn(b);
    });

    println!(&quot;{:?}&quot;, X.load(Relaxed)); // 最终结果一定是 15
}
</code></pre>
<p>上面这个例子，<code>X</code> 的变化顺序可以是 0→5→15，也可以是 0→10→15，这取决于哪个 <code>fetch_add</code> 先被执行。</p>
<p>再举个例子：</p>
<pre><code class="language-rust">static DATA: AtomicI32 = AtomicI32::new(0);
static READY: AtomicBool = AtomicBool::new(false);

fn main() {
    thread::scope(|s| {
        // 线程 A - 写入者
        s.spawn(|| {
            DATA.store(123, Ordering::Relaxed);     // ① 准备数据
            READY.store(true, Ordering::Relaxed);   // ② 发出数据就绪信号
        });

        // 线程 B - 读取者
        s.spawn(|| {
            while !READY.load(Ordering::Relaxed) {  // ③ 等待数据就绪信号
                thread::yield_now();
            }
            assert_eq!(DATA.load(Ordering::Relaxed), 123); // ④ 获取数据，这里断言一定成功吗？
        });
    });
}
</code></pre>
<p>上面这个例子中，线程 A 执行了：</p>
<pre><code class="language-rust">DATA.store(123, Ordering::Relaxed);     // 准备数据
READY.store(true, Ordering::Relaxed);   // 发出数据就绪信号
</code></pre>
<p>这是 2 个没有依赖关系的原子操作，且使用的是 <code>Relaxed</code> 内存顺序，所以对于线程 B 来说，这 2 个操作的顺序是不确定的。所以是很可能在 <code>READY.load(Ordering::Relaxed)</code> 返回 <code>true</code> 的时候，<code>DATA.load(Ordering::Relaxed)</code> 依旧还是 <code>0</code>。</p>
<p>那如何确保这个断言一定成功呢？那就需要“升级”一下了~ 这个时候就轮到 <code>Release</code> 和 <code>Acquire</code> 的出场了。</p>
<h4>Release &amp; Acquire</h4>
<p><code>Release</code> 和 <code>Acquire</code> 一般成对出现，它们共同建立了线程间的同步关系：</p>
<ul>
<li><code>Release</code>: 作用于写操作（store），确保该操作之前的所有内存访问不会被重排到这个 Release 操作之后。</li>
<li><code>Acquire</code>: 作用于读操作（load），确保该操作之后的所有内存访问不会被重排到这个 Acquire 操作之前。</li>
</ul>
<p>当一个线程通过 <code>Acquire</code> 读取到另一个线程通过 <code>Release</code> 写入的值时，会建立一个 happens-before 关系：<strong><font color="orange">线程 A 中 Release 写入之前的所有内存写操作，对于线程 B 中 Acquire 读取之后的所有内存读操作都是可见的</font></strong>。</p>
<p>修改一下上面的例子：</p>
<pre><code class="language-rust">static DATA: AtomicI32 = AtomicI32::new(0);
static READY: AtomicBool = AtomicBool::new(false);

fn main() {
    thread::scope(|s| {
        s.spawn(|| {
            DATA.store(123, Ordering::Relaxed);
            READY.store(true, Ordering::Release);   // 这里改为 release
        });

        s.spawn(|| {
            while !READY.load(Ordering::Acquire) {  // 这里改为 acquire
                thread::yield_now();
            }
            assert_eq!(DATA.load(Ordering::Relaxed), 123); // 必定成功
        });
    });
}
</code></pre>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241111165714772.png" alt="release-acquire-ordering" style="zoom:50%;" />

<p>如上图所示，在这个例子中：</p>
<ol>
<li>Release-Acquire 同步确保了 <code>READY</code> 的写入和读取之间建立了 happens-before 关系</li>
<li>由于 <code>DATA</code> 的写入在 <code>READY</code> 的 Release 写入之前，而 <code>DATA</code> 的读取在 <code>READY</code> 的 Acquire 读取之后</li>
<li>因此可以保证线程 B 一定能看到线程 A 写入的值 123</li>
</ol>
<p>更进一步，我们通过观察，可以发现 <code>DATA</code> 都没必要使用 <code>Atomic</code> 类型，因为由 <code>READY</code> 建议的 <code>happens-before</code> 规则已经能保证对 <code>DATA</code> 的读写不可能并发执行了。不过因为 Rust 的类型系统并不允许跨线程进行非原子类型的读写操作，所以这里我们需要使用 <code>unsafe</code> 才能使编译通过，但通过我们之前的分析，我们可以确保下面这段代码是安全的：</p>
<pre><code class="language-rust">static mut DATA: u64 = 0;
static READY: AtomicBool = AtomicBool::new(false);

fn main() {
    thread::spawn(|| {
        // Safety: 此时没有其他线程访问 DATA，
        // 因为我们还没有设置 READY 标志
        unsafe { DATA = 123 };
        READY.store(true, Release); // 在这个存储操作之前的所有内存操作 ..
    });
    while !READY.load(Acquire) { // .. 在这个加载操作返回 true 后都是可见的
        thread::sleep(Duration::from_millis(100));
        println!(&quot;waiting...&quot;);
    }
    // Safety: 没有线程会修改 DATA，因为 READY 已经被设置
    println!(&quot;{}&quot;, unsafe { DATA });
}
</code></pre>
<details open>
<summary>释放序列（Release Sequence）</summary>


<p>我们再来看一段代码示例：</p>
<pre><code class="language-rust">use std::{sync::atomic::AtomicU8, thread};

static mut DATA: Vec&lt;i64&gt; = vec![];
static FLAG: AtomicU8 = AtomicU8::new(0);

fn thread_1() {
    unsafe {
        DATA.push(42);
    }
    FLAG.store(1, std::sync::atomic::Ordering::Release);
}

fn thread_2() {
    let mut expected = 1;
    // memory_order_relaxed is okay because this is an RMW,
    // and RMWs (with any ordering) following a release form a release sequence
    while FLAG
        .compare_exchange(
            expected,
            2,
            std::sync::atomic::Ordering::Relaxed,
            std::sync::atomic::Ordering::Relaxed,
        )
        .is_err()
    {
        expected = 1
    }
}

fn thread_3() {
    while FLAG.load(std::sync::atomic::Ordering::Acquire) &lt; 2 {}
    // if we read the value 2 from the atomic flag, we see 42 in the vector
    unsafe {
        assert_eq!(DATA[0], 42); // will never fire
    }
}

fn main() {
    thread::scope(|s| {
        s.spawn(thread_1);
        s.spawn(thread_2);
        s.spawn(thread_3);
    });
}
</code></pre>
<p>这段代码是参考 <a href="https://en.cppreference.com/w/cpp/atomic/memory_order">cppreference</a> 而翻译成 Rust 代码的，在上述代码中，即使 <code>thread_2</code> 中我们使用的是 <code>Relaxed</code>， 这段代码中的 <code>assert_eq!(DATA[0], 42)</code> 也是一定成功的。为什么呢？这涉及到一个重要的概念——<strong>释放序列（Release Sequence）</strong>：</p>
<p>对某个<strong>原子对象</strong> M 的一段<strong>连续修改</strong>序列的定义，用于保证 <code>acquire-加载</code> 能够同步到对应的 <code>release-存储</code>，形成 <em>happens-before</em> 关系。具体来说：</p>
<ol>
<li><p><strong>起始于一次释放操作（release operation）</strong></p>
<p>该释放操作是对原子对象 M 的一次写操作，且其内存语义为 <code>release</code>、<code>acq_rel</code> 或 <code>seq_cst</code>。</p>
<p>这条操作在修改顺序（modification order）中作为释放序列的头部。</p>
</li>
<li><p><strong>后续紧随其后的所有修改</strong></p>
<p>自头部释放操作之后，凡是在 M 的修改顺序中紧跟出现的原子操作，且满足以下之一，皆被纳入同一释放序列：</p>
<ol>
<li><strong>同一线程</strong>对 M 执行的任意原子写操作；</li>
<li><strong>任意线程</strong>对 M 执行的“读-改-写”（RMW）原子操作（如 <code>fetch_add</code>、<code>compare_exchange</code> 等）。</li>
</ol>
<p>只要序列中没有出现其它线程的普通（非 RMW）store，就形成一个<strong>最大连续子序列</strong>，这就是完整的释放序列。</p>
</li>
</ol>
<p>序列中继发的这些写或 RMW 操作本身无需再指定 <code>memory_order_release</code>（它们即便是 relaxed），也都被“挂到”最初那次 release 操作上，从而被后续的 acquire 加载所“看到”。</p>
<p>在这段代码中：当 <code>thread_2</code> 的 <code>RMW</code> 操作成功的时候，说明 <code>FLAG</code> 是 <code>1</code>，即 <code>thread_1</code> 已经执行了 <code>release</code> 操作，这个时候：</p>
<ol>
<li><code>thread_1</code> 的 <code>release</code> 操作建立了同步点</li>
<li><code>thread_2</code> 的 <code>RMW</code> 操作自动成为释放序列的一部分</li>
<li>当 <code>thread_3</code> 通过 <code>acquire</code> 看到值 2 时，它能看到整个释放序列的所有修改。</li>
<li>因此能保证看到 <code>DATA</code> 中的 42。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20250612094505443.png" alt=""></p>
<p>所以在这种场景下使用 <code>relaxed</code> 既安全又高效，因为：</p>
<ul>
<li>它是释放序列的一部分</li>
<li>不需要额外的同步开销</li>
<li>仍然能保证正确的内存顺序</li>
</ul>
<p>为什么这样设计呢？</p>
<ul>
<li><strong>原子性保证</strong>：RMW 操作本身就是原子的，不会产生数据竞争</li>
<li><strong>连续性</strong>：每个 RMW 操作都直接或间接地基于前一个操作的结果</li>
<li><strong>因果关系</strong>：形成了一个清晰的修改链条</li>
<li><strong>性能考虑</strong>：中间的 RMW 操作不需要额外的同步开销</li>
</ul>
</details>

<h4>Sequentially Consistent</h4>
<p><code>SeqCst</code> 是最严格的内存顺序，它包括获取 <code>release</code> 和 <code>acquire</code> 的所有保证，还保证了全局一致的操作顺序。简单理解就是，你代码的顺序是怎么样，实际的执行顺序就是什么样。</p>
<p>我们来看一段代码：</p>
<pre><code class="language-rust">use std::sync::atomic::Ordering::SeqCst;

static A: AtomicBool = AtomicBool::new(false);
static B: AtomicBool = AtomicBool::new(false);

static mut S: String = String::new();

fn main() {
    let a = thread::spawn(|| {
        A.store(true, SeqCst);
        if !B.load(SeqCst) {
            unsafe { S.push(&#39;!&#39;) };
        }
    });

    let b = thread::spawn(|| {
        B.store(true, SeqCst);
        if !A.load(SeqCst) {
            unsafe { S.push(&#39;!&#39;) };
        }
    });

    a.join().unwrap();
    b.join().unwrap();
}
</code></pre>
<p>在这段代码中，两个线程都是希望将自己的原子变量设置为 <code>true</code>，从而阻止另外一个线程对 <code>S</code> 进行 <code>push</code> 操作，其实就类似于锁。因为这里使用了 <code>SeqCst</code>，所以代码的执行顺序是跟代码编写顺序是一致的，那么就可能出现以下 3 种执行情况：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241112113535084.png" alt="seqcst-memory-order"></p>
<p>即：同一时刻，<strong>最多</strong>只可能有一个线程会对 <code>S</code> 进行操作。</p>
<h2>内存屏障</h2>
<p>除了内存顺序（Memory Order），还有另外一种方式可以控制程序的执行顺序，就是内存屏障（Memory Barrier）。内存屏障是一种底层的同步原语，它能强制处理器按照特定的顺序执行内存操作。内存屏障通过阻止或限制指令重排序，来确保内存操作的可见性和顺序性。</p>
<h3>基本概念</h3>
<p>内存屏障主要分为以下几种类型：</p>
<ol>
<li><p><strong>Load Barrier（读屏障）</strong></p>
<ul>
<li>确保在屏障之前的所有读操作都执行完成</li>
<li>防止后续读操作被重排到屏障之前</li>
<li>对应 Acquire 语义</li>
</ul>
</li>
<li><p><strong>Store Barrier（写屏障）</strong></p>
<ul>
<li>确保在屏障之前的所有写操作都执行完成</li>
<li>防止后续写操作被重排到屏障之前</li>
<li>对应 Release 语义</li>
</ul>
</li>
<li><p><strong>Full Barrier（全屏障）</strong></p>
<ul>
<li>同时包含读屏障和写屏障的功能</li>
<li>防止任何内存操作的重排序</li>
<li>对应 SeqCst 语义</li>
</ul>
</li>
</ol>
<p>即下面这 2 种实现方式是等价的：</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20251122100410416.png" style="zoom: 33%;" />

<p>所以到这里，我们可以更好地理解<strong>为什么 <code>release</code> 是阻止其前面的内存访问越过它，而 <code>acquire</code> 是阻止其后面的内存访问越过它了</strong>。因为有个 <code>fence</code> 在前面或后面拦着！</p>
<p>但是一般来说，下面的写法相比上面的写法会有一丢丢的性能损失，因为这会增加一些额外的处理指令。那 <code>fence</code> 的用武之地是什么呢？</p>
<ol>
<li>可以同时对多个原子操作进行 <code>fench</code>；</li>
<li>可以根据条件判断，选择是否进行 <code>fench</code>。</li>
</ol>
<p>举个例子：</p>
<img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20241112125023036.png" alt="fence-multi-atomics" style="zoom:50%;" />

<p>这个例子的关键点是：</p>
<ol>
<li><p>如果线程 2 中的任何一个 load 操作观察到了线程 1 中对应的 store 操作的值：</p>
<ul>
<li>比如 A.load() 读到了值 1，或</li>
<li>B.load() 读到了值 2，或</li>
<li>C.load() 读到了值 3</li>
</ul>
</li>
<li><p>那么：线程 1 中的 release fence 就会 happens-before 线程 2 中的 acquire fence。这意味着线程 1 中 release fence 之前的所有内存操作对线程 2 中 acquire fence 之后的操作都是可见的。</p>
</li>
</ol>
<p>这展示了内存屏障的一个重要优势：<strong>一个屏障可以同时为多个原子操作建立同步关系，而不需要在每个原子操作上都使用 Release/Acquire 内存序。这在某些场景下可能会更高效。</strong></p>
<p>用更通俗的话说：这就像在线程 1 设置了一个&quot;检查点&quot;（release fence），在线程 2 也设置了一个&quot;检查点&quot;（acquire fence），只要线程 2 看到了线程 1 在其检查点之后做的任何一个改动，那么线程 1 检查点之前的所有操作对线程 2 的检查点之后都是可见的。</p>
<h3>硬件实现</h3>
<p>不同的处理器架构实现内存屏障的方式不同：</p>
<pre><code class="language-assembly">; x86/x64
MFENCE  ; 全屏障
LFENCE  ; 读屏障
SFENCE  ; 写屏障

; ARM
DMB     ; 数据内存屏障
DSB     ; 数据同步屏障
ISB     ; 指令同步屏障
</code></pre>
<h3>与内存顺序的关系</h3>
<p>Rust 的内存顺序实际上是通过内存屏障来实现的：</p>
<pre><code class="language-rust">// Release 写入会插入 Store Barrier
atomic.store(42, Ordering::Release);  // 编译器会在此处插入 Store Barrier

// Acquire 读取会插入 Load Barrier
let x = atomic.load(Ordering::Acquire);  // 编译器会在此处插入 Load Barrier

// SeqCst 操作会插入 Full Barrier
atomic.store(42, Ordering::SeqCst);  // 编译器会在此处插入 Full Barrier
</code></pre>
<details open>
<summary>展开阅读</summary>


<p>注意：直接使用内存屏障是非常底层的操作，通常我们应该使用 Rust 提供的高级抽象（如原子类型和它们的内存顺序）来实现同步。内存屏障的知识主要用于理解这些高级抽象的工作原理。</p>
</details>

<h2>Go Atomic</h2>
<p>熟悉 Go 语言的读者应该会意识到在使用 Go 语言的原子类型的时候，好像都没见过 Memory Order 这个东西，如下：</p>
<pre><code class="language-go">package main

import (
	&quot;sync/atomic&quot;
)

func main() {
	data := atomic.Int64{}
	data.Add(1)
	data.And(2)
	data.Or(3)
	data.Swap(4)
	data.Store(5)
	data.Load()
	data.CompareAndSwap(6, 7)
}
</code></pre>
<p>在 <a href="https://github.com/golang/go/blob/release-branch.go1.23/src/sync/atomic/doc.go">atomic/doc.go</a> 源码中我们可以看到这段话：</p>
<pre><code class="language-go">// The load and store operations, implemented by the LoadT and StoreT
// functions, are the atomic equivalents of &quot;return *addr&quot; and
// &quot;*addr = val&quot;.
//
// In the terminology of [the Go memory model], if the effect of
// an atomic operation A is observed by atomic operation B,
// then A “synchronizes before” B.
// Additionally, all the atomic operations executed in a program
// behave as though executed in some sequentially consistent order.
// This definition provides the same semantics as
// C++&#39;s sequentially consistent atomics and Java&#39;s volatile variables.
//
// [the Go memory model]: https://go.dev/ref/mem
</code></pre>
<p>Go 语言设计者认为让程序员选择内存序会增加复杂性和出错的可能，所以为了程序的简单性和可预测性，直接就<strong>使用了最安全的 <code>Seq-Cst</code> 内存顺序</strong>了。</p>
<p><a href="https://go.dev/ref/mem">the Go memory model</a> 中还提了一句：</p>
<pre><code class="language-text">If you must read the rest of this document to understand the behavior of your program, you are being too clever.
Don&#39;t be clever.
</code></pre>
<p>这也呼应了 Go 的设计理念：</p>
<pre><code class="language-text">Share memory by communicating; don&#39;t communicate by sharing memory.
</code></pre>
<p>所以总结一下：</p>
<ol>
<li>Go 的原子操作采用了最强的顺序一致性内存序；</li>
<li>这是一个有意识的设计选择，为了简单性和可预测性；</li>
<li>如果你需要更细粒度的内存序控制，那么 Go 可能不是最佳选择；</li>
<li>Go 更推荐使用 channels 和其他同步原语来进行并发控制。</li>
</ol>
<h2>参考</h2>
<ul>
<li><a href="https://marabos.nl/atomics/memory-ordering.html">Rust Atomics And Lock</a></li>
<li><a href="https://mp.weixin.qq.com/s/t5_Up2YZEZt1NLbvgYz9FQ">聊一聊内存模型与内存序</a></li>
<li><a href="https://en.cppreference.com/w/cpp/atomic/memory_order">cppreference</a></li>
<li><a href="https://go.dev/ref/mem">the Go memory model</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨SSE(Server-Sent Events)</title>
      <link>https://hedon.top/blog/rust-action-sse/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-sse/</guid>
      <pubDate>Thu, 06 Jun 2024 21:30:51 GMT</pubDate>
      <description>本文详细介绍了 SSE 的工作原理，并通过示例代码展示了如何使用 Go 和 Rust 实现一个简单的 SSE 服务端，展示了在实际项目中应用 SSE 的方法。</description>
      <category>rust</category><category>Go</category><category>sse</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<p>📌 SSE（Server-Sent Events）是一种允许服务器向客户端浏览器推送信息的技术。它是 HTML5 的一部分，专门用于建立一个单向的从服务器到客户端的通信连接。SSE 的使用场景非常广泛，包括实时消息推送、实时通知更新等。</p>
<h2>SSE 的本质</h2>
<p>严格地说，<a href="https://en.wikipedia.org/wiki/HTTP">HTTP </a>无法做到服务器主动推送信息。但是，有一种变通方法，就是服务器向客户端声明，接下来要发送的是流信息（streaming）。</p>
<p>也就是说，发送的不是一次性的数据包，而是一个数据流，会连续不断地发送过来。这时，客户端不会关闭连接，会一直等着服务器发过来的新的数据流，视频播放就是这样的例子。本质上，这种通信就是以流信息的方式，完成一次用时很长的下载。</p>
<p>SSE 就是利用这种机制，使用流信息向浏览器推送信息。它基于 HTTP 协议，目前除了 IE/Edge，其他浏览器都支持。</p>
<h2>特点</h2>
<ol>
<li><strong>持续连接</strong>：与传统的 HTTP 请求不同，SSE 保持连接开放，服务器可以随时发送消息。</li>
<li><strong>文本数据流</strong>：SSE 主要传输文本数据，这些数据以特定的格式流式传输，使得每条消息都是简单的文本格式。</li>
<li><strong>内置重连机制</strong>：浏览器会自动处理连接中断和重连，包括在重连请求中发送最后接收的事件 ID，以便服务器从正确的位置恢复发送事件。</li>
<li><strong>简单的客户端处理</strong>：在浏览器中，使用 JavaScript 的 <code>EventSource</code> 接口处理 SSE 非常简单，只需几行代码即可监听服务器发来的事件。</li>
</ol>
<h2>工作原理</h2>
<ol>
<li><strong>建立连接</strong>：客户端通过创建一个 <code>EventSource</code> 对象请求特定的 URL 来启动 SSE 连接。这个请求是一个标准的 HTTP 请求，但会要求服务器以特定方式响应。</li>
<li><strong>服务器响应</strong>：服务器响应必须设置 <code>Content-Type</code> 为 <code>text/event-stream</code>，然后保持连接打开。</li>
<li><strong>发送消息</strong>：服务器可以通过持续发送数据格式为特定事件流的消息来推送更新。每个消息包括一个可选的事件类型、数据和一个可选的 ID。<ul>
<li><strong>数据</strong>：实际的消息内容，以 <code>data:</code> 开头，多行数据以双换行符 <code>\n\n</code> 结束。</li>
<li><strong>事件类型</strong>：允许客户端根据事件类型来监听，以 <code>event:</code> 开头。</li>
<li><strong>ID</strong>：如果连接中断，客户端将发送包含上次接收的最后一个 ID 的 <code>Last-Event-ID</code> 头，以便服务器从断点继续发送数据。</li>
</ul>
</li>
</ol>
<h2>实战</h2>
<h3>客户端</h3>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;title&gt;SSE Test&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;Server-Sent Events Test&lt;/h1&gt;
    &lt;div id=&quot;events&quot;&gt;&lt;/div&gt;
    &lt;script&gt;
      // 确保这里的URL匹配你的服务器地址和端口
      var eventSource = new EventSource(&quot;http://localhost:8000/events&quot;);
      eventSource.onmessage = function (event) {
        console.log(&quot;New event:&quot;, event.data);
        document.getElementById(&quot;events&quot;).innerHTML += event.data + &quot;&lt;br&gt;&quot;;
      };
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h3>Rust 服务端</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/20240606213652.gif" alt="Rust 实现演示"></p>
<p>依赖：</p>
<pre><code class="language-toml">anyhow = &quot;1.0.86&quot;
axum = { version = &quot;0.7.5&quot; }
chrono = &quot;0.4.38&quot;
futures-core = &quot;0.3.30&quot;
tokio = { version = &quot;1.38.0&quot;, features = [&quot;macros&quot;, &quot;rt-multi-thread&quot;, ] }
tokio-stream = &quot;0.1.15&quot;
tower-http = { version = &quot;0.5.2&quot;, features = [&quot;cors&quot;] }
</code></pre>
<p>代码：</p>
<pre><code class="language-rust">use std::time::Duration;

use axum::{
    response::{sse::Event, Sse},
    routing::get,
    Router,
};
use tokio::{net::TcpListener, time::interval};
use tokio_stream::{wrappers::IntervalStream, StreamExt};
use tower_http::cors::{Any, CorsLayer};

#[tokio::main]
async fn main() -&gt; anyhow::Result&lt;()&gt; {
    let cors = CorsLayer::new()
        .allow_headers(Any)
        .allow_origin(Any)
        .allow_headers(Any)
        .allow_credentials(false);

    let listener = TcpListener::bind(&quot;0.0.0.0:8000&quot;).await?;
    let app = Router::new().route(&quot;/events&quot;, get(sse_handler)).layer(cors);
    axum::serve(listener, app).await?;
    Ok(())
}

async fn sse_handler() -&gt; Sse&lt;impl futures_core::Stream&lt;Item = Result&lt;Event, axum::Error&gt;&gt;&gt; {
    let interval = interval(Duration::from_secs(1));
    let stream = IntervalStream::new(interval).map(|_| {
        let data = format!(&quot;{}\n\n&quot;, chrono::Local::now().to_rfc2822());
        Ok(Event::default().data(data))
    });

    Sse::new(stream)
}
</code></pre>
<h3>Go 服务端</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/20240606195749.gif" alt="Go 实现演示"></p>
<pre><code class="language-go">package main

import (
	&quot;fmt&quot;
	&quot;log&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

func sseHandler(w http.ResponseWriter, r *http.Request) {
	// 设置头部信息，确保允许跨域，并且告诉浏览器这是一个事件流
	w.Header().Set(&quot;Content-Type&quot;, &quot;text/event-stream&quot;)
	w.Header().Set(&quot;Cache-Control&quot;, &quot;no-cache&quot;)
	w.Header().Set(&quot;Connection&quot;, &quot;keep-alive&quot;)
	w.Header().Set(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;)

	// 不断发送消息
	for {
		// 生成服务器时间，并发送给客户端
		now := time.Now()
		// 生成消息，格式为 data: {content} \n\n
		msg := fmt.Sprintf(&quot;data: %s\n\n&quot;, now.Format(time.DateTime))
		// 发送消息
		if _, err := fmt.Fprintf(w, msg); err != nil {
			log.Println(&quot;write error:&quot;, err)
			break
		}

		// 刷新响应缓冲，确保即时发送
		flusher, ok := w.(http.Flusher)
		if !ok {
			log.Println(&quot;Streaming unsupported!&quot;)
			break
		}
		flusher.Flush()

		// 每秒发送一次
		time.Sleep(1 * time.Second)
	}
}

func main() {
	http.HandleFunc(&quot;/events&quot;, sseHandler)
	log.Println(&quot;Server started on port 8000...&quot;)
	log.Fatal(http.ListenAndServe(&quot;:8000&quot;, nil))
}
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨通过实现 json! 掌握声明宏</title>
      <link>https://hedon.top/blog/rust-action-macro-json/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-macro-json/</guid>
      <pubDate>Tue, 28 May 2024 15:37:23 GMT</pubDate>
      <description>本文分步展示了实现 json! 宏的过程，包括定义 Json 枚举和不同类型的匹配规则。通过这个过程，读者可以掌握声明宏的基本概念和实现方法。</description>
      <category>rust</category><category>宏</category><category>元编程</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<p>在 Rust 编程语言中，宏是一种强大的工具，可以用于在编译时生成代码。<code>json!</code> 是一个在 Rust 中广泛使用的宏，它允许我们在 Rust 代码中方便地创建 JSON 数据。</p>
<p>声明宏（declarative macros）是 Rust 中的一种宏，它们使用 <code>macro_rules!</code> 关键字定义。</p>
<p>本文将参考《Rust 程序设计（第二版）》，通过实现 <code>json!</code> 宏，深入理解声明宏的工作原理。</p>
<h2>结论先行</h2>
<p>本文我们将构建一个 <code>json!</code> 宏，它支持我们以字符串 JSON 风格的语法来编写 Json 值。如下面这个例子：</p>
<pre><code class="language-rust">let students = json![
	{
		&quot;name&quot;: &quot;Hedon Wang&quot;,
		&quot;class_of&quot;: 2022,
		&quot;major&quot;: &quot;Software engineering&quot;
	},
	{
		&quot;name&quot;: &quot;Jun Lei&quot;,
		&quot;class_of&quot;: 1991,
		&quot;major&quot;: &quot;Computor science&quot;
	}
]
</code></pre>
<blockquote>
<p><a href="#%E5%AE%8C%E6%95%B4%E4%BB%A3%E7%A0%81">完整代码</a></p>
</blockquote>
<h2>实现 <code>json!</code></h2>
<h3>定义 Json enum</h3>
<p>首先我们需要思考一下 Json 结构是什么样子的？主要是以下 3 种模式：</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;hedon&quot;,
  &quot;age&quot;: 18,
  &quot;school&quot;: {
    &quot;name&quot;: &quot;Wuhan University&quot;,
    &quot;address&quot;: &quot;Hubwi Wuhan&quot;
  }
}
</code></pre>
<pre><code class="language-json">[
  {
    &quot;name&quot;: &quot;hedon&quot;
  },
  {
    &quot;name&quot;: &quot;john&quot;
  }
]
</code></pre>
<pre><code class="language-json">null
</code></pre>
<p>为此我们定义一个 Json 结构的枚举：</p>
<pre><code class="language-rust">#[derive(Clone, PartialEq, Debug)]
pub enum Json {
    Null,
    Boolean(bool),
    Number(f64),
    String(String),
    Array(Vec&lt;Json&gt;),
    Object(HashMap&lt;String, Json&gt;),
}
</code></pre>
<p>你应该可以感到非常奇妙，使用一个这么简单的枚举，居然就可以表示所有的 Json 结构了。遗憾的是，现在这个结构编写 Json 值的语法相当冗长。</p>
<pre><code class="language-rust">let people = Json::Object(HashMap::from([
    (&quot;name&quot;.to_string(), Json::String(&quot;hedon&quot;.to_string())),
    (&quot;age&quot;.to_string(), Json::Number(10.0)),
    (&quot;is_student&quot;.to_string(), Json::Boolean(true)),
    (
        &quot;detail&quot;.to_string(),
        Json::Object(HashMap::from([
            (&quot;address&quot;.to_string(), Json::String(&quot;beijing&quot;.to_string())),
            (&quot;phone&quot;.to_string(), Json::String(&quot;1234567890&quot;.to_string()))
        ]))
    )
]))
</code></pre>
<p>我们期望可以以下面这种方式来声明 Json 变量，这看起来就清爽许多了。</p>
<pre><code class="language-rust">let students = json!([
    {
        &quot;name&quot;: &quot;Jim Blandy&quot;,
        &quot;class_of&quot;: 1926,
        &quot;major&quot;: &quot;Tibetan throat singing&quot;
    },
    {
        &quot;name&quot;: &quot;Jason Orendorff&quot;,
        &quot;class_of&quot;: 1702,
        &quot;major&quot;: &quot;Knots&quot;
    }
]);
</code></pre>
<h3>猜想 <code>json!</code></h3>
<p>我们可以预见 Json 宏内部将会有多条规则，因为 JSON 数据有多种类型：对象、数组、数值等。事实上，我们可以合理地猜测每种 JSON 类型都将有一条规则：</p>
<pre><code class="language-rust">macro_rules! json {
    (null)    =&gt; { Json::Null };
    ([ ... ]) =&gt; { Json::Array(...) };
    ({ ... }) =&gt; { Json::Object(...) };
    (???)     =&gt; { Json::Boolean(...) };
    (???)     =&gt; { Json::Number(...) };
    (???)     =&gt; { Json::String(...) };
}
</code></pre>
<p>然而这不太正确，因为宏模式无法区分最后 3 种情况，稍后我们会讨论如何处理。至于前 3 种情况，显然它们是以不同的语法标记开始的，所以这几种情况比较好处理。</p>
<h3>实现 Null</h3>
<p>我们先从最简单的 <code>Null</code> 分支开始，先编写如下测试用例：</p>
<pre><code class="language-rust">#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_null_json() {
        let json = json!(null);
        assert_eq!(json, Json::Null);
    }
}
</code></pre>
<p>想要通过上述测试用例非常简单，我们只需要在 <code>macro_rules!</code> 支持中匹配这种情况即可：</p>
<pre><code class="language-rust">#[macro_export]
macro_rules! json {
    (null) =&gt; {
        Json::Null
    };
}
</code></pre>
<ul>
<li><code>#[macro_export]</code> 注解是 Rust 中的一个属性，用于指示这个宏应该被导出到调用者的作用域中，这样其他模块也可以使用它。</li>
<li><code>macro_rules!</code> 宏定义了一个自定义的宏。在这里，它创建了一个名为 <code>json</code> 的宏，用于生成 JSON 数据。</li>
<li>宏定义中 <code>(null)</code> 是匹配模式。这意味着当你调用 <code>json!</code> 宏并传递 <code>null</code> 作为参数时，将会触发这个规则。</li>
<li><code>=&gt;</code> 符号用于指示匹配模式后的代码块。在这里，它指定了当匹配 <code>(null)</code> 时应该生成的代码块。</li>
<li><code>Json::Null</code> 是一个 JSON 类型的枚举值，表示 JSON 中的 null 值。这个宏的目的是将传入的 <code>null</code> 转换为 <code>Json::Null</code>。</li>
</ul>
<h3>实现 Boolean/Number/String</h3>
<p>我们先准备如下测试用例：</p>
<pre><code class="language-rust">#[test]
fn test_boolean_number_string_json() {
    let json = json!(true);
    assert_eq!(json, Json::Boolean(true));

    let json = json!(1.0);
    assert_eq!(json, Json::Number(1.0));

    let json = json!(&quot;hello&quot;);
    assert_eq!(json, Json::String(&quot;hello&quot;.to_string()));
}
</code></pre>
<p>通过观察分析，它们其实都是同一种模式：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240528114725679.png" alt="Boolean/Number/String 分析"></p>
<p>现在需要解决的问题就是，如何将这 3 种模式进行统一，这样在 <code>macro_rules!</code> 中才可以统一匹配模式并进行代码生成。</p>
<p>这里我们其实需要做的就是将 <code>bool</code>、<code>f64</code> 和 <code>&amp;str</code> 转为对应的 <code>Json</code> 类型。那就需要用到标准库中的 <code>From</code> trait 了。</p>
<p>做法很简单，我们实现如下代码：</p>
<pre><code class="language-rust">impl From&lt;bool&gt; for Json {
    fn from(value: bool) -&gt; Self {
        Json::Boolean(value)
    }
}

impl From&lt;&amp;str&gt; for Json {
    fn from(value: &amp;str) -&gt; Self {
        Json::String(value.to_string())
    }
}

impl From&lt;f64&gt; for Json {
    fn from(value: f64) -&gt; Self {
        Json::Number(value)
    }
}
</code></pre>
<p>然后完善我们的 <code>json!</code>，目前的实现如下：</p>
<pre><code class="language-rust">#[macro_export]
macro_rules! json {
    (null) =&gt; {
        Json::Null
    };
    ($value: tt) =&gt; {
        Json::from($value)
    };
}
</code></pre>
<p>这里我们使用 <code>$value</code>作 为变量来承接匹配到的元素，其类型为 <code>tt</code> ，表示任意的语法标记树。具体可以参考：<a href="#%E7%89%87%E6%AE%B5%E7%B1%BB%E5%9E%8B">片段类型</a>。</p>
<p>这时运行上述测试用例，是没有问题的：</p>
<pre><code class="language-bash">  PASS [   0.004s] json-macro tests::test_boolean_number_string_json
  PASS [   0.004s] json-macro tests::test_null_json
</code></pre>
<p>美中不足的是，JSON 结构中的数字类型，其实不一定是 f64，也可以是 i32、u32、f32 或其他的数字类型，如果我们要为这全部的数字类型都实现到 Json 的 <code>From</code> trait，那就多冗余。</p>
<p>这个时候我们又可以实现一个宏，用于快速生成 <code>impl From&lt;T&gt; for Json</code> 。这个实现比较简单，本文就不赘述了，代码如下：</p>
<pre><code class="language-rust">#[macro_export]
macro_rules! impl_from_for_primitives {
    (  $( $type: ty ) * ) =&gt; {
        $(
            impl From&lt;$type&gt; for Json {
                fn from(value: $type) -&gt; Self {
                    Json::Number(value as f64)
                }
            }
        )*
    }
}
</code></pre>
<p>然后我们只需要用下面这一行代码，就可以为所有的数字类型实现 <code>From</code> trait 了：</p>
<pre><code class="language-rust">impl_from_for_primitives!(u8 u16 u32 u64 i8 i16 i32 i64 f32 f64 isize usize);
</code></pre>
<p>记得这个时候你要删除上面手动实现的 <code>impl From&lt;f64&gt; for Json</code>，不然会有 impl 冲突错误。</p>
<p>再次运行测试，也是可以通过的。</p>
<h3>实现 Array</h3>
<p>准备如下测试用例：</p>
<pre><code class="language-rust">#[test]
fn test_array_json() {
    let json = json!([1, null, &quot;string&quot;, true]);
    assert_eq!(
        json,
        Json::Array(vec![
            Json::Number(1.0),
            Json::Null,
            Json::String(&quot;string&quot;.to_string()),
            Json::Boolean(true)
        ])
    )
}
</code></pre>
<p>要匹配 <code>[1, null, &quot;string&quot;, true]</code>这个模式，笔者的分析过程如下：</p>
<ol>
<li>首先是外面的两个中括号 <code>[</code> 和 <code>]</code> ；</li>
<li>再往里，是一个重复匹配的模式，以 <code>,</code> 分割，可以匹配 0 到任意多个元素，所以是 <code>$(  ,*)</code> ，具体可以参考：<a href="#%E9%87%8D%E5%A4%8D%E6%A8%A1%E5%BC%8F">重复模式</a>；</li>
<li>最里面就是第 2 步要匹配的元素了，我们先用 <code>$element</code> 作为变量来承接每一个元素，其类型为 <code>tt</code> ，表示任意的语法标记树。</li>
</ol>
<p>分析完匹配的表达式后，我们就可以得到：</p>
<pre><code class="language-rust">([ $( $element:tt ), * ]) =&gt; { /* TODO */ }
</code></pre>
<p>我们要生成的代码长这个样子：</p>
<pre><code class="language-rust">Json::Array(vec![
    Json::Number(1.0),
    Json::Null,
    Json::String(&quot;string&quot;.to_string()),
    Json::Boolean(true)
])
</code></pre>
<p>其实就是一个 <code>vec!</code>，然后里面每个元素都是一个 <code>Json</code>，如此递归下去。</p>
<p>即可以得到代码生成部分的逻辑为：</p>
<pre><code class="language-rust">Json::Array(vec![$(json!($element)),* ])
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240528134133088.png" alt="Json::Array 宏分析"></p>
<p>综上，我们实现的代码如下：</p>
<pre><code class="language-rust">#[macro_export]
macro_rules! json {
    (null) =&gt; {
        Json::Null
    };
    ([ $( $element: tt),* ]) =&gt; {
        Json::Array(vec![ $( json!($element)), * ])
    };
    ($value: tt) =&gt; {
        Json::from($value)
    };
}
</code></pre>
<p>运行测试用例：</p>
<pre><code class="language-bash">PASS [   0.003s] json-macro tests::test_null_json
PASS [   0.003s] json-macro tests::test_boolean_number_string_json
PASS [   0.004s] json-macro tests::test_array_json
</code></pre>
<h3>实现 Object</h3>
<p>写好如下测试用例，这次我们顺带把 Null、Boolean、Number 和 String 带上了：</p>
<pre><code class="language-rust">#[test]
fn test_object_json() {
    let json = json!({
        &quot;null&quot;: null,
        &quot;name&quot;: &quot;hedon&quot;,
        &quot;age&quot;: 10,
        &quot;is_student&quot;: true,
        &quot;detail&quot;: {
            &quot;address&quot;: &quot;beijing&quot;,
            &quot;phone&quot;: &quot;1234567890&quot;
        }
    });
    assert_eq!(
        json,
        Json::Object(HashMap::from([
            (&quot;name&quot;.to_string(), Json::String(&quot;hedon&quot;.to_string())),
            (&quot;age&quot;.to_string(), Json::Number(10.0)),
            (&quot;is_student&quot;.to_string(), Json::Boolean(true)),
            (
                &quot;detail&quot;.to_string(),
                Json::Object(HashMap::from([
                    (&quot;address&quot;.to_string(), Json::String(&quot;beijing&quot;.to_string())),
                    (&quot;phone&quot;.to_string(), Json::String(&quot;1234567890&quot;.to_string()))
                ]))
            )
        ]))
    )
}
</code></pre>
<p>对比预期的 <code>json!</code> 宏内容和展开后的代码：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240528133040062.png" alt="Json::Object 宏分析"></p>
<p>完善我们的 <code>macro_rules! json</code> ：</p>
<pre><code class="language-rust">#[macro_export]
macro_rules! json {
    (null) =&gt; {
        Json::Null
    };
    ([ $( $element: tt),* ]) =&gt; {
        Json::Array(vec![ $( json!($element)), * ])
    };
    ({ $( $key:tt : $value:tt ),* }) =&gt; {
        Json::Object(HashMap::from([
            $(
                ( $key.to_string(), json!($value) )
            ), *
        ]))
    };
    ($value: tt) =&gt; {
        Json::from($value)
    };
}
</code></pre>
<p>运行测试用例：</p>
<pre><code class="language-bash">PASS [   0.004s] json-macro tests::test_object_json
PASS [   0.005s] json-macro tests::test_array_json
PASS [   0.004s] json-macro tests::test_null_json
PASS [   0.005s] json-macro tests::test_boolean_number_string_json
</code></pre>
<p>至此，我们就完成了 <code>json!</code> 宏的构建了！完整源码可见：<a href="https://www.notion.so/e90c161d8e3743b2a4f788e3d7b75181?pvs=21">完整代码</a></p>
<p>Peace! Enjoy coding~</p>
<h2>附录</h2>
<h3>重复模式</h3>
<p>在 <a href="https://www.notion.so/Array-c914526ab9474ccd9cb92c7eb17225b6?pvs=21">实现 Array</a> 中，我们匹配了这样一个模式：</p>
<pre><code class="language-rust">([ $( $element:tt ), * ]) =&gt; { /* TODO */ }
</code></pre>
<p>其中 <code>$($element:tt), *)</code> 就是一个重复模式，其可以进一步抽象为 <code>$( ... ),*</code> ，表示匹配 0 次或多次，以 <code>,</code> 分隔。</p>
<p>Rust 支持以下全部重复模式：</p>
<table>
<thead>
<tr>
<th>模式</th>
<th>含义</th>
</tr>
</thead>
<tbody><tr>
<td>$( … ) *</td>
<td>匹配 0 次或多次，没有分隔符</td>
</tr>
<tr>
<td>$( … ), *</td>
<td>匹配 0 次或多次，以逗号分隔</td>
</tr>
<tr>
<td>$( … ); *</td>
<td>匹配 0 次或多次，以分号分隔</td>
</tr>
<tr>
<td>$( … ) +</td>
<td>匹配 1 次或多次，没有分隔符</td>
</tr>
<tr>
<td>$( … ), +</td>
<td>匹配 1 次或多次，以逗号分隔</td>
</tr>
<tr>
<td>$( … ); +</td>
<td>匹配 1 次或多次，以分号分隔</td>
</tr>
<tr>
<td>$( … ) ?</td>
<td>匹配 0 次或 1 次，没有分隔符</td>
</tr>
</tbody></table>
<p>即：</p>
<ul>
<li><code>*</code> 表示 0 次或多次</li>
<li><code>+</code> 表示 1 次或多次</li>
<li><code>?</code> 表示 0 次或 1 次</li>
<li>可在上述 3 者之前加入分隔符</li>
</ul>
<h3>片段类型</h3>
<p>在 <a href="#%E5%AE%9E%E7%8E%B0-array">实现 Array</a> 中，我们匹配了这样一个模式：</p>
<pre><code class="language-rust">([ $( $element:tt ), * ]) =&gt; { /* TODO */ }
</code></pre>
<p>这里我们将 <code>$element</code> 指定为 <code>tt</code>，这个 <code>tt</code> 就是宏中的一种片段类型。</p>
<p><code>tt</code> 能匹配单个语法标记树，包含：</p>
<ul>
<li>一对括号，如 <code>(..)</code>、<code>[..]</code>、或 <code>{..}</code> ，以及位于其中的所有内容，包括嵌套的语法标记树。</li>
<li>单独的非括号语法标记，比如 <code>1926</code> 或 <code>Knots</code> 。</li>
</ul>
<p>所以为了匹配任意类型的 <code>Json</code> ，我们选择了 <code>tt</code> 作为 <code>$element</code> 的片段类型。</p>
<p><code>macro_rules!</code> 支持的片段类型如下所示：</p>
<table>
<thead>
<tr>
<th>片段类型</th>
<th>匹配（带例子）</th>
<th>后面可以跟 ······</th>
</tr>
</thead>
<tbody><tr>
<td>expr</td>
<td>表达式：2 + 2, &quot;udon&quot;, x.len()</td>
<td>=&gt;,;</td>
</tr>
<tr>
<td>stmt</td>
<td>表达式或声明，不包括任何尾随分号（很难用，请尝试使用 expr 或 block）</td>
<td>=&gt;,;</td>
</tr>
<tr>
<td>ty</td>
<td>类型：String, Vec<u8>, (&amp;str, bool), dyn Read + Send</td>
<td>=&gt;,; =</td>
</tr>
<tr>
<td>path</td>
<td>路径：ferns, ::std::sync::mpsc</td>
<td>=&gt;,; =</td>
</tr>
<tr>
<td>pat</td>
<td>模式：_, Some(ref x)</td>
<td>=&gt;,=</td>
</tr>
<tr>
<td>item</td>
<td>语法项：struct Point { x: f64, y: f64 }, mod ferns;</td>
<td>任意</td>
</tr>
<tr>
<td>block</td>
<td>块：{ s += &quot;ok\n&quot;; true }</td>
<td>任意</td>
</tr>
<tr>
<td>meta</td>
<td>属性的主体：inline, derive(Copy, Clone), doc=&quot;3D models.&quot;</td>
<td>任意</td>
</tr>
<tr>
<td>literal</td>
<td>字面量值：1024, &quot;Hello, world!&quot;, 1_000_000f64</td>
<td>任意</td>
</tr>
<tr>
<td>lifetime</td>
<td>生命周期：&#39;a, &#39;item, &#39;static</td>
<td>任意</td>
</tr>
<tr>
<td>vis</td>
<td>可见性说明符：pub, pub(crate), pub(in module::submodule)</td>
<td>任意</td>
</tr>
<tr>
<td>ident</td>
<td>标识符：std, Json, longish_variable_name</td>
<td>任意</td>
</tr>
<tr>
<td>tt</td>
<td>语法标记树：;, &gt;=, {}, [0 1 (+ 0 1)]</td>
<td>任意</td>
</tr>
</tbody></table>
<h3>完整代码</h3>
<pre><code class="language-rust">use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq)]
#[allow(unused)]
enum Json {
    Null,
    Boolean(bool),
    String(String),
    Number(f64),
    Array(Vec&lt;Json&gt;),
    Object(HashMap&lt;String, Json&gt;),
}

impl From&lt;bool&gt; for Json {
    fn from(value: bool) -&gt; Self {
        Json::Boolean(value)
    }
}

impl From&lt;&amp;str&gt; for Json {
    fn from(value: &amp;str) -&gt; Self {
        Json::String(value.to_string())
    }
}

impl From&lt;String&gt; for Json {
    fn from(value: String) -&gt; Self {
        Json::String(value)
    }
}

#[macro_export]
macro_rules! impl_from_for_primitives {
    (  $( $type: ty ) * ) =&gt; {
        $(
            impl From&lt;$type&gt; for Json {
                fn from(value: $type) -&gt; Self {
                    Json::Number(value as f64)
                }
            }
        )*
    }
}

impl_from_for_primitives!(u8 u16 u32 u64 i8 i16 i32 i64 f32 f64 isize usize);

#[macro_export]
macro_rules! json {
    (null) =&gt; {
        Json::Null
    };
    ([ $( $element: tt),* ]) =&gt; {
        Json::Array(vec![ $( json!($element)), * ])
    };
    ({ $( $key:tt : $value:tt ),* }) =&gt; {
        Json::Object(HashMap::from([
            $(
                ( $key.to_string(), json!($value) )
            ), *
        ]))
    };
    ($value: tt) =&gt; {
        Json::from($value)
    };
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_null_json() {
        let json = json!(null);
        assert_eq!(json, Json::Null);
    }

    #[test]
    fn test_boolean_number_string_json() {
        let json = json!(true);
        assert_eq!(json, Json::Boolean(true));

        let json = json!(1.0);
        assert_eq!(json, Json::Number(1.0));

        let json = json!(&quot;hello&quot;);
        assert_eq!(json, Json::String(&quot;hello&quot;.to_string()));
    }

    #[test]
    fn test_object_json() {
        let json = json!({
            &quot;null&quot;: null,
            &quot;name&quot;: &quot;hedon&quot;,
            &quot;age&quot;: 10,
            &quot;is_student&quot;: true,
            &quot;detail&quot;: {
                &quot;address&quot;: &quot;beijing&quot;,
                &quot;phone&quot;: &quot;1234567890&quot;
            }
        });
        assert_eq!(
            json,
            Json::Object(HashMap::from([
                (&quot;null&quot;.to_string(), Json::Null),
                (&quot;name&quot;.to_string(), Json::String(&quot;hedon&quot;.to_string())),
                (&quot;age&quot;.to_string(), Json::Number(10.0)),
                (&quot;is_student&quot;.to_string(), Json::Boolean(true)),
                (
                    &quot;detail&quot;.to_string(),
                    Json::Object(HashMap::from([
                        (&quot;address&quot;.to_string(), Json::String(&quot;beijing&quot;.to_string())),
                        (&quot;phone&quot;.to_string(), Json::String(&quot;1234567890&quot;.to_string()))
                    ]))
                )
            ]))
        )
    }

    #[test]
    fn test_array_json() {
        let json = json!([1, null, &quot;string&quot;, true]);
        assert_eq!(
            json,
            Json::Array(vec![
                Json::Number(1.0),
                Json::Null,
                Json::String(&quot;string&quot;.to_string()),
                Json::Boolean(true)
            ])
        )
    }
}
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>xgo 原理探索</title>
      <link>https://hedon.top/blog/go-xgo-explore/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-xgo-explore/</guid>
      <pubDate>Thu, 23 May 2024 21:15:10 GMT</pubDate>
      <description>xgo 是一个通过代码重写来实现 mock、trace 和 coverage 功能的单元测试框架。本文将探讨 xgo 最核心的底层原理 -toolexec，并通过 6 个简单的小阶段，一步步实现一个丐版 xgo，进一步展示 xgo 的设计理念。</description>
      <category>Go</category><category>开源项目</category><category>单元测试</category>
      <content:encoded><![CDATA[<h2>Go 单测 mock 方案</h2>
<table>
<thead>
<tr>
<th>Mock 方法</th>
<th>原理</th>
<th>依赖</th>
<th>优点</th>
<th>缺点</th>
</tr>
</thead>
<tbody><tr>
<td>接口 Mock</td>
<td>为依赖项定义接口，并提供接口的 Mock 实现。</td>
<td>需要定义接口和 Mock 实现。</td>
<td>灵活，遵循 Go 的类型系统；易于替换实现。</td>
<td>需要更多的样板代码来定义接口和 Mock 实现。</td>
</tr>
<tr>
<td>Monkey Patching（bouk/moneky）</td>
<td>直接修改函数指针的内存地址来实现对函数的替换。</td>
<td>内存保护；汇编代码。</td>
<td>强大，可以 Mock 任何函数，甚至第三方库的函数。</td>
<td>复杂，容易出错；线程不安全；依赖系统指令集。</td>
</tr>
</tbody></table>
<h2>bouk/monkey 弊端</h2>
<blockquote>
<p><a href="https://github.com/bouk/monkey">bouk/monkey</a> 🐒</p>
</blockquote>
<p>monkey 的核心功能是能够在运行时替换某个函数的实现。</p>
<p><strong>原理：</strong></p>
<ol>
<li><strong>函数指针替换</strong>：在 Go 语言中，函数的地址存储在内存中。bouk/monkey 通过直接修改函数指针的内存地址来实现对函数的替换。</li>
<li><strong>汇编代码</strong>：使用了汇编代码来实现对函数入口的跳转。这些汇编代码会在函数被调用时，将执行流重定向到新的函数实现。</li>
<li><strong>内存保护</strong>：为了修改内存中的函数指针，bouk/monkey 需要临时修改内存页面的保护属性（例如，将页面设为可写）。在修改完毕后，它会恢复原来的保护属性。</li>
<li><strong>反射与 unsafe 包</strong>：利用 Go 的反射机制和 unsafe 包，bouk/monkey 可以获取并操作函数的底层实现细节。</li>
</ol>
<p><strong>实现步骤：</strong></p>
<ol>
<li><strong>保存原函数</strong>：在替换函数之前，bouk/monkey 会保存原始函数的指针，以便在需要时恢复或调用原始函数。</li>
<li><strong>生成跳转代码</strong>：bouk/monkey 生成一段汇编跳转代码，这段代码会在函数调用时，将执行流跳转到新的函数实现。</li>
<li><strong>修改函数指针</strong>：使用 unsafe 包，bouk/monkey 修改目标函数的入口地址，指向生成的跳转代码。</li>
<li><strong>恢复内存保护</strong>：在完成上述修改后，恢复内存页面的保护属性。</li>
</ol>
<p><strong>有以下几个弊端：</strong></p>
<ol>
<li>如果启用了内联，Monkey 有时无法修补函数。尝试在禁用内联的情况下运行测试，例如: <code>go test -gcflags=-l</code>。同样的命令行参数也可以用于构建。</li>
<li>Monkey 不能在一些面向安全的操作系统上工作，这些操作系统不允许同时写入和执行内存页。目前的方法并没有真正可靠的解决方案。</li>
<li>线程不安全的。</li>
<li>依赖指令集。</li>
</ol>
<h2>先看 xgo 怎么用</h2>
<blockquote>
<p><a href="https://github.com/xhd2015/xgo">xgo</a> 😈</p>
</blockquote>
<p>代码结构如下：</p>
<pre><code class="language-go">.
├── greet.go
└── greet_test.go
</code></pre>
<p>现在在 <code>greet.go</code> 中有一个函数 <code>greet</code>：</p>
<pre><code class="language-go">func greet(s string) string {
	return &quot;hello &quot; + s
}
</code></pre>
<p>在真实的生产环境中，<code>greet</code> 可能要复杂得多，它可能会依赖各种第三方 API，也可能会依赖数据库等多种外部组件。所以在测试的时候，我们希望对其进行 <strong>mock</strong>，使其返回一个固定的值，便于我们撰写单元测试。</p>
<p><code>xgo</code> 参考了 <code>go-monkey</code> 的思想，但是不从 <strong>修改指令</strong> 这个途径入手，而是另辟蹊径，从 <strong>代码重写</strong> 的角度实现了 <strong>mock</strong> 的能力。</p>
<p>为了使用 <code>xgo</code>，我们需要先安装 <code>xgo</code> 这个命令：</p>
<pre><code class="language-bash">go install github.com/xhd2015/xgo/cmd/xgo@latest
</code></pre>
<p>同时在我们的项目中需要引入 <code>xgo</code> 依赖：</p>
<pre><code class="language-bash">go get &quot;github.com/xhd2015/xgo/runtime/mock&quot;
</code></pre>
<p>我们编写的 <code>greet_test.go</code> 如下：</p>
<pre><code class="language-go">package xgo_use

import (
	&quot;testing&quot;

	&quot;github.com/xhd2015/xgo/runtime/mock&quot;
)

func TestOriginGreet(t *testing.T) {
	res := greet(&quot;world&quot;)
	if res != &quot;hello world&quot; {
		t.Fatalf(&quot;greet() = %q; want %q&quot;, res, &quot;hello world&quot;)
	}
}

func TestMockGreet(t *testing.T) {
	mock.Patch(greet, func(s string) string {
		return &quot;mock &quot; + s
	})
	res := greet(&quot;world&quot;)
	if res != &quot;mock world&quot; {
		t.Fatalf(&quot;greet() = %q; want %q&quot;, res, &quot;mock world&quot;)
	}
}
</code></pre>
<p>可以看到在 <code>TestMockGreet</code> 这个单元测试中，我们将 <code>greet</code> 进行了 mock，返回 <code>&quot;mock &quot; + s</code>。</p>
<pre><code class="language-go">mock.Patch(greet, func(s string) string {
  return &quot;mock &quot; + s
})
</code></pre>
<p>为了使用 <code>xgo</code> 的能力，我们在执行单元测试的时候，需要运行以下命令：</p>
<pre><code class="language-bash">xgo test -v ./
</code></pre>
<p>输出大致如下：</p>
<pre><code class="language-bash">➜  xgo-use git:(master) xgo test -v ./
xgo is taking a while to setup, please wait...
=== RUN   TestOriginGreet
--- PASS: TestOriginGreet (0.00s)
=== RUN   TestMockGreet
--- PASS: TestMockGreet (0.00s)
PASS
ok      xgo-explore/xgo-use     (cached)
</code></pre>
<h2>xgo 的核心原理</h2>
<p><code>xgo</code> 的核心原理是利用 <code>go build -toolexec</code> 的能力。</p>
<p>运行以下命令：</p>
<pre><code class="language-bash">go help build
</code></pre>
<p>找到 <code>toolexec</code> 的相关说明：</p>
<pre><code class="language-bash">-toolexec &#39;cmd args&#39;
        a program to use to invoke toolchain programs like vet and asm.
        For example, instead of running asm, the go command will run
        &#39;cmd args /path/to/asm &lt;arguments for asm&gt;&#39;.
        The TOOLEXEC_IMPORTPATH environment variable will be set,
        matching &#39;go list -f {{.ImportPath}}&#39; for the package being built.
</code></pre>
<p>一言以蔽之：<code>-toolexec</code> 允许对 go 工具链进行拦截，包括 <code>vet</code>、<code>asm</code>、<code>compile</code> 和 <code>link</code>。</p>
<p>这种技术也被称为：插桩（stubbing）、增强（instrumentation）和代码重写（rewriting）。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/Km9eLWtYFJMiFYP0uC-B29J__5mGvLlSsrD52hWgS_S2nOGSx9PnMybHuqcQljAtTUr5QVVqaHGyyAEwqVPowhPqJvAZHLALdQpj6gzHzb60NLLe91tX87_sAerIGq2mwYMVdBcptglQpJ0QkfmDC-ZHoQ=s2048.png" alt="-toolexec 示意图（来源：https://blog.xhd2015.xyz/zh/posts/xgo-monkey-patching-in-go-using-toolexec/）"></p>
<p>基于上述分析，<code>xgo</code> 提出了 <strong>代码重写</strong> 的思路，实现了 <strong>在编译过程中插入拦截器代码</strong> 的功能：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240523164815047.png" alt="xgo 在 go build 中的作用位置（来源：https://blog.xhd2015.xyz/zh/posts/xgo-monkey-patching-in-go-using-toolexec/）"></p>
<p>所以上述我们的 <code>greet.go</code> 文件中的源代码：</p>
<pre><code class="language-go">func greet(s string) string {
	return &quot;hello &quot; + s
}
</code></pre>
<p>经过 <code>xgo</code> 编译后最终实际编译的代码如下：</p>
<pre><code class="language-go">import &quot;runtime&quot;

func greet(s string) (r0 string) {
  stop, post := runtime.__xgo_trap(Greet, &amp;s, &amp;r0)
  if stop {
    return
  }
  defer post()
  return &quot;hello&quot; + s
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/5e020865-fd1d-49d3-be72-7ee2233a3c5f.png" alt="greet 函数重写变化示意图（来源：https://blog.xhd2015.xyz/zh/posts/xgo-monkey-patching-in-go-using-toolexec/）"></p>
<p>如图所示，一旦函数被调用，它的控制流首先转移到 <code>Trap</code>，然后一系列拦截器将根据其目的检查当前调用是否应该被 Mock、修改、记录或停止。</p>
<p>如果 <code>greet</code> 注册了 mock 函数，那么就会在 <code>__xgo_trap</code> 中调用 mock 的函数，并将返回值设置到 <code>r0</code> 上进行返回，而跳过原始的执行逻辑。</p>
<h2>第 1 步：死代码实现</h2>
<pre><code class="language-bash">➜  01-deadcode git:(master) tree
.
├── greet.go
├── greet_test.go
└── mock.go
</code></pre>
<p>我们先从最简单的实现开始，采用侵入性代码实现 <code>xgo</code> 的核心功能，这里我们还用不到 <code>-toolexec</code>。</p>
<p>代码结构如上所示，在 <code>mock.go</code> 中，我们有如下代码：</p>
<pre><code class="language-go">var mockFuncs = sync.Map{}

func RegisterMockFunc(funcName string, fun interface{}) {
	mockFuncs.Store(funcName, fun)
}
</code></pre>
<ul>
<li><code>mockFuncs</code>: 用于承载函数与 mock 函数的对应关系，其中 key 为函数名称，value 为 mock 函数。我们使用 <code>sync.Map</code> 来保证并发安全。</li>
<li><code>RegisterMockFunc</code> 用于为指定的 funcName 注册 mock 函数。</li>
</ul>
<p>在 <code>greet.go</code> 中，我们有一个 <code>Greet</code> 函数：</p>
<pre><code class="language-go">func Greet(s string) string {
	return &quot;hello &quot; + s
}
</code></pre>
<p>如果我们要对其支持 mock，那么需要修改其实现为：</p>
<pre><code class="language-go">func Greet(s string) string {
	fun, ok := mockFuncs.Load(&quot;Greet&quot;)
	if ok {
		f, ok := fun.(func(s string) string)
		if ok {
			return f(s)
		}
	}
	return &quot;hello &quot; + s
}
</code></pre>
<p>在修改后的代码中，我们先判断是否存在 mock 函数，如果存在，则执行 mock 函数，否则执行原始逻辑。</p>
<p>现在我们在 <code>greet_test.go</code> 中编写测试代码：</p>
<pre><code class="language-go">func TestMockGreet(t *testing.T) {
	RegisterMockFunc(&quot;Greet&quot;, func(s string) string {
		return &quot;mock &quot; + s
	})
	res := Greet(&quot;world&quot;)
	if res != &quot;mock world&quot; {
		t.Fatalf(&quot;Greet() = %q; want %q&quot;, res, &quot;mock world&quot;)
	}
}

func TestOriginGreet(t *testing.T) {
	res := Greet(&quot;world&quot;)
	if res != &quot;hello world&quot; {
		t.Fatalf(&quot;Greet() = %q; want %q&quot;, res, &quot;hello world&quot;)
	}
}
</code></pre>
<p>执行测试：</p>
<pre><code class="language-bash"># 单独执行 TestMockGreet
➜  01-deadcode git:(master) ✗ go test -v -run TestMockGreet
=== RUN   TestMockGreet
--- PASS: TestMockGreet (0.00s)
PASS
ok      xgo-explore/01-deadcode 0.103s

# 单独执行 TestOriginGreet
➜  01-deadcode git:(master) ✗ go test -v -run TestOriginGreet
=== RUN   TestOriginGreet
--- PASS: TestOriginGreet (0.00s)
PASS
ok      xgo-explore/01-deadcode 0.102s

# 一起执行
➜  01-deadcode git:(master) ✗ go test -v -run $Test$
=== RUN   TestMockGreet
--- PASS: TestMockGreet (0.00s)
=== RUN   TestOriginGreet
    greet_test.go:20: Greet() = &quot;mock world&quot;; want &quot;hello world&quot;
--- FAIL: TestOriginGreet (0.00s)
FAIL
exit status 1
FAIL    xgo-explore/01-deadcode 0.102s
</code></pre>
<p>我们会发现单独执行都是 ok 的，不过一起执行的话 <code>TestOriginGreet</code> 就失败了，这是因为先执行了 <code>TestMockGreet</code>，这个时候已经往 <code>mockFunc</code> 中注册了 mock 函数了，所以 <code>TessOriginGreet</code> 就执行失败了。</p>
<p>这里需要在协程层面上做 mock 隔离，<code>xgo</code> 的思路是在编译时注入 <code>getg()</code> 函数来获取当前协程信息从而实现在注册 mock 函数时进行协程隔离。本文将聚焦在 <code>xgo</code> 的核心原理 <strong>代码重写</strong> 上，故暂时不考虑这一块。</p>
<p>Ok，那么短短几行代码，我们就将 <code>xgo</code> 的最核心思想给展示出来了。可以看到，<code>xgo</code> 的核心思想是往源代码中加入 <strong>合法的 Go 代码</strong>，所以不涉及指令重写，故而只要你的机器能执行 Go 程序，天然就支持 mock 功能，这就天然达到了架构无关的兼容性了。同时我们也使用了 <code>sync.Map</code> 来保证了并发安全。</p>
<h2>第 2 步：死代码拦截器</h2>
<pre><code class="language-bash">➜  02-deadcode-interceptor git:(master) tree
.
├── greet.go
├── greet_test.go
└── mock.go
</code></pre>
<p>在第 1 步中，这段代码我觉得有点冗长了：</p>
<pre><code class="language-go">fun, ok := mockFuncs.Load(&quot;Greet&quot;)
if ok {
  f, ok := fun.(func(s string) string)
  if ok {
    return f(s)
  }
}
</code></pre>
<p>参考 <code>xgo</code> 的函数签名，我们对其进行优化，在 <code>mock.go</code> 中加入一个 <strong>丐版拦截器</strong>：</p>
<pre><code class="language-go">// mock.go
func InterceptMock(funcName string, arg string, result *string) bool {
	fn, ok := mockFuncs.Load(funcName)
	if ok {
		f, ok := fn.(func(s string) string)
		if ok {
			*result = f(arg)
			return true
		}
	}
	return false
}
</code></pre>
<p>对应 <code>greet.go</code> 中 <code>Greet</code> 函数就修改为：</p>
<pre><code class="language-go">func Greet(s string) (res string) {
	if InterceptMock(&quot;Greet&quot;, s, &amp;res) {
		return res
	}
	return &quot;hello &quot; + s
}
</code></pre>
<p>这看起来就清爽多了。再次执行测试代码，一样是可以通过的。</p>
<pre><code class="language-bash">➜  02-deadcode-interceptor git:(master) go test -v -run TestOriginGreet
=== RUN   TestOriginGreet
--- PASS: TestOriginGreet (0.00s)
PASS
ok      xgo-explore/02-deadcode-interceptor     0.331s

➜  02-deadcode-interceptor git:(master) go test -v -run TestMockGreet
=== RUN   TestMockGreet
--- PASS: TestMockGreet (0.00s)
PASS
ok      xgo-explore/02-deadcode-interceptor     0.103s
</code></pre>
<h2>第 3 步：toolexec 初探</h2>
<pre><code class="language-bash">➜  03-toolexec-static git:(master) tree
.
├── cmd
│   └── mytool
│       └── mytool.go
├── greet.go
├── main.go
├── mock.go
└── script.sh
</code></pre>
<p>这里 <code>mock.go</code> 没有任何变化。我们期望使用 <code>-toolexec</code> 来修改源代码，以实现 mock 无源代码侵入的特性，所以我们在 <code>greet.to</code> 中将 <code>Greet</code> 函数恢复为只关注实际功能的样子：</p>
<pre><code class="language-go">func Greet(s string) (res string) {
	return &quot;hello &quot; + s
}
</code></pre>
<p>同时为了更好地测试使用 <code>-toolexec</code> 编译后的运行结果，这里将 <code>greet_test.go</code> 删除了并新增了 <code>main.go</code> 文件，内容如下：</p>
<pre><code class="language-go">func main() {
	res := Greet(&quot;world&quot;)
	if res != &quot;hello world&quot; {
		log.Fatalf(&quot;Greet() = %q; want %q&quot;, res, &quot;hello world&quot;)
	}

	RegisterMockFunc(&quot;Greet&quot;, func(s string) string {
		return &quot;mock &quot; + s
	})
	res = Greet(&quot;world&quot;)
	if res != &quot;mock world&quot; {
		log.Fatalf(&quot;Greet() = %q; want %q&quot;, res, &quot;mock world&quot;)
	}

	log.Println(&quot;run successfully&quot;)
}
</code></pre>
<p>那么 <code>-toolexec</code> 要执行的命令怎么实现呢？在 Google 搜索 <strong>go toolexec</strong> 你会看到官方给出的一个案例：<a href="https://go.dev/src/cmd/go/testdata/script/toolexec.txt">toolexec.txt</a>。</p>
<p>核心部分在最下面，参考这个示例，我们来实现自己的 <code>toolexec</code>：</p>
<pre><code class="language-bash">mkdir -p cmd/mytool
touch cmd/mytool/mytool.go
</code></pre>
<p>在<code>mytool.go</code> 中，我们先写这么点代码，看一下会输出什么。</p>
<pre><code class="language-go">func main() {
	tool, args := os.Args[1], os.Args[2:]
	if len(args) &gt; 0 &amp;&amp; args[0] == &quot;-V=full&quot; {
		// don&#39;t do anything to infuence the version full output.
	} else if len(args) &gt; 0 {
		fmt.Printf(&quot;tool: %s\n&quot;, tool)
		fmt.Printf(&quot;args: %v\n&quot;, args)
	}
  // 继续执行之前的命令
	cmd := exec.Command(tool, args...)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		log.Fatalf(&quot;run command error: %v\n&quot;, err)
	}
}
</code></pre>
<p>这里我们企图输出执行的工具 <code>tool</code> 及传给它的参数 <code>args</code>。由于 <code>-V=full</code> 的作用是在终端输出版本信息，所以我们要跳过它，避免产生干扰。输出日志后，我们暂且先继续执行原始的命令，不对编译过程做其他的干扰。</p>
<p>Ok，现在就来看看这个 <code>-toolexec</code> 到底做了什么，在 <code> 03-toolexec-static</code> 目录下执行以下命令：</p>
<pre><code class="language-bash"># 清除缓存，一直使用最新的编译结果
go clean -cache -modcache -i -r
# 编译 mytool
go build ./cmd/mytool
# 编译业务程序
go build -toolexec=./mytool -o main
</code></pre>
<p>因为这几个命令经常会用到，所以我们可以将其封装到 <code>script.sh</code> 文件中：</p>
<pre><code class="language-bash">touch script.sh
chmod +x script.sh
</code></pre>
<p>内容如下：</p>
<pre><code class="language-bash">#!/bin/bash

go clean -cache -modcache -i -r
go build ./cmd/mytool
go build -toolexec=./mytool -o main
</code></pre>
<p>执行上述命令后，可以看到以下输出：</p>
<pre><code class="language-bash">➜  03-toolexec-static git:(master) ./script.sh
# xgo-explore/03-toolexec-static
tool: /opt/homebrew/Cellar/go/1.22.3/libexec/pkg/tool/darwin_arm64/compile
args: [-o $WORK/b001/_pkg_.a -trimpath $WORK/b001=&gt; -p main -lang=go1.22 -complete -buildid PcS9clqF_ny_Ds5N0i_s/PcS9clqF_ny_Ds5N0i_s -goversion go1.22.3 -c=4 -shared -nolocalimports -importcfg $WORK/b001/importcfg -pack ./greet.go ./main.go ./mock.go]
# xgo-explore/03-toolexec-static
tool: /opt/homebrew/Cellar/go/1.22.3/libexec/pkg/tool/darwin_arm64/link
args: [-o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=pie -buildid=KgnnCoU_6enHkOm-T62Z/PcS9clqF_ny_Ds5N0i_s/H80dtgGZw1L8mTtVqJBf/KgnnCoU_6enHkOm-T62Z -extld=cc $WORK/b001/_pkg_.a]
</code></pre>
<p>可以看到执行了 <code>compile</code> 和 <code>link</code> 两个工具，<code>compile</code> 是编译过程，将生成 <code>{}.out</code> 文件，而 <code>link</code> 是将多个 <code>{}.out</code> 文件链接成一个可执行文件。这是很经典的编译过程，如果对 Go 语言的编译过程感兴趣，也可以参考官方的 <a href="https://github.com/golang/go/tree/release-branch.go1.22/src/cmd/compile">Go Compile Readme</a>，或者笔者撰写的 <a href="/blog/go-compilation/">Go1.21.0 程序编译过程</a>。</p>
<p>这里我们需要重点关注的是 <code>compile</code> 命令，它是负责编译源代码的，涉及到的源代码文件会通过 <code>-pack ./greet.go ./main.go ./mock.go</code> 传递给 <code>compile</code> 命令。</p>
<p>结合 <code>-toolexec</code> 的帮助信息：</p>
<pre><code class="language-bash">-toolexec &#39;cmd args&#39;
        a program to use to invoke toolchain programs like vet and asm.
        For example, instead of running asm, the go command will run
        &#39;cmd args /path/to/asm &lt;arguments for asm&gt;&#39;.
        The TOOLEXEC_IMPORTPATH environment variable will be set,
        matching &#39;go list -f {{.ImportPath}}&#39; for the package being built.
</code></pre>
<p>我们只需要在执行 <code>compile</code> 命令之前，在 <code>cmd args</code> 这个环节，进行 <strong>代码重写</strong> 就可以实现我们想要的功能了。</p>
<p>我们现在是要对 <code>greet.go</code> 里面的 <code>Greet</code> 函数进行重写，先看看之前的代码：</p>
<pre><code class="language-go">package main

func Greet(s string) (res string) {
	return &quot;hello &quot; + s
}
</code></pre>
<p>重写后的代码应该跟我们之前 <strong>第 2 步</strong> 是一样的：</p>
<pre><code class="language-go">package main

func Greet(s string) (res string) {
	if InterceptMock(&quot;Greet&quot;, s, &amp;res) {
	 	return res
    }
	return &quot;hello &quot; + s
}
</code></pre>
<p>这里有 n 多种方式可以做到，现在笔者决定使用最暴力的方式，直接临时创建一个包含这段代码的文件 <code>tmp.go</code>，并替换掉传给 <code>compile</code> 的参数，即将 <code>-pack ./greet.go ./main.go ./mock.go</code> 替换为 <code>-pack tmp.go ./main.go ./mock.go</code></p>
<p>综上，<code>cmd/mytool/mytool/go</code> 实现的代码如下：</p>
<pre><code class="language-go">func main() {
	tool, args := os.Args[1], os.Args[2:]

	if len(args) &gt; 0 &amp;&amp; args[0] == &quot;-V=full&quot; {
		// don&#39;t do anything to infuence the version full output.
	} else if len(args) &gt; 0 {
		if filepath.Base(tool) == &quot;compile&quot; {
			index := findGreetFile(args)
			if index &gt; -1 {
				f, err := os.Create(&quot;tmp.go&quot;)
				if err != nil {
					log.Fatalf(&quot;create tmp.go error: %v\n&quot;, err)
				}
				defer f.Close()
				defer os.Remove(&quot;tmp.go&quot;)
				_, _ = f.WriteString(newCode)
				args[index] = &quot;tmp.go&quot;
			}
		}
		fmt.Printf(&quot;tool: %s\n&quot;, tool)
		fmt.Printf(&quot;args: %v\n&quot;, args)
	}
	// 继续执行之前的命令
	cmd := exec.Command(tool, args...)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		log.Fatalf(&quot;run command error: %v\n&quot;, err)
	}
}

func findGreetFile(args []string) int {
	for i, arg := range args {
		if strings.Contains(arg, &quot;greet.go&quot;) {
			return i
		}
	}
	return -1
}

var newCode = `
package main

func Greet(s string) (res string) {
	if InterceptMock(&quot;Greet&quot;, s, &amp;res) {
	 	return res
    }
	return &quot;hello &quot; + s
}
`
</code></pre>
<p>这里我先使用 <code>findGreetFile</code> 来查找 <code>greet.go</code> 文件所处的参数位置，如果找到了，则生成新的 <code>tmp.go</code> 文件，并替换参数，最后在 本次 <code>compile</code> 命令执行完毕后，删除 <code>tmp.go</code>，“毁尸灭迹”。</p>
<p>执行 <code>./script.sh</code> 重新编译：</p>
<pre><code class="language-bash">➜  03-toolexec-static git:(master) ✗ ./script.sh
# xgo-explore/03-toolexec-static
tool: /opt/homebrew/Cellar/go/1.22.3/libexec/pkg/tool/darwin_arm64/compile
args: [-o $WORK/b001/_pkg_.a -trimpath $WORK/b001=&gt; -p main -lang=go1.22 -complete -buildid PcS9clqF_ny_Ds5N0i_s/PcS9clqF_ny_Ds5N0i_s -goversion go1.22.3 -c=4 -shared -nolocalimports -importcfg $WORK/b001/importcfg -pack tmp.go ./main.go ./mock.go]
# xgo-explore/03-toolexec-static
tool: /opt/homebrew/Cellar/go/1.22.3/libexec/pkg/tool/darwin_arm64/link
args: [-o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=pie -buildid=KgnnCoU_6enHkOm-T62Z/PcS9clqF_ny_Ds5N0i_s/H80dtgGZw1L8mTtVqJBf/KgnnCoU_6enHkOm-T62Z -extld=cc $WORK/b001/_pkg_.a]
</code></pre>
<p>输出的结果中可以看到已经将 <code>compile</code> 的参数替换为 <code>-pack tmp.go ./main.go ./mock.go</code> 了。</p>
<p>现在我们来执行生成的程序文件，可以看到是执行成功的。</p>
<pre><code class="language-bash">➜  03-toolexec-static git:(master) ✗ ./main
2024/05/23 17:53:52 run successfully
</code></pre>
<p>如果我们不使用 <code>-toolexec</code>，是执行不成功的：</p>
<pre><code class="language-bash">➜  03-toolexec-static git:(master) ✗ go clean -cache -modcache -i -r
➜  03-toolexec-static git:(master) ✗ go build -o main
➜  03-toolexec-static git:(master) ✗ ./main
2024/05/23 17:54:33 Greet() = &quot;hello world&quot;; want &quot;mock world&quot;
</code></pre>
<h2>第 4 步：使用 AST 在函数前插入代码</h2>
<pre><code class="language-bash">➜  04-toolexec-ast git:(master) ✗ tree
.
├── cmd
│   └── mytool
│       └── mytool.go
├── greet.go
├── main.go
├── mock.go
└── script.sh
</code></pre>
<p>暴力替换源代码文件的方式可能是不太优雅哈，假如我们的 <code>greet.go</code> 内容改成下面这样：</p>
<pre><code class="language-go">package main

func Greet(s string) (res string) {
	return &quot;hello &quot; + s
}

func Greet2(s string) (res string) {
	return &quot;hello 2 &quot; + s
}
</code></pre>
<p>如果我们想对 <code>Greet2</code> 也进行 <strong>代码重写</strong>，那就需要修改前面 <code>newCode</code> 字段的内容，而且它是写死的，确实不太优雅。现在我们正式来面对这件事，对比修改后的函数：</p>
<pre><code class="language-go">func Greet(s string) (res string) {
	if InterceptMock(&quot;Greet&quot;, s, &amp;res) {
	 	return res
    }
	return &quot;hello &quot; + s
}
</code></pre>
<p>其实就是在每个函数前加上这么一段：</p>
<pre><code class="language-go">if InterceptMock(&quot;Greet&quot;, s, &amp;res) {
	return res
}
</code></pre>
<p>了解过编译原理的读者应该可以想到，我们可以通过操作源代码的 AST 结构，往函数的开头插入这段代码即可。如果我们先不考虑参数和返回值的话，那这段代码我们需要替换的地方就是函数名称了，所以它的结构如下：</p>
<pre><code class="language-go">if InterceptMock(&quot;${funcName}&quot;, s, &amp;res) {
	return res
}
</code></pre>
<p>这里我们需要用到几个标准库工具：</p>
<ul>
<li><code>go/ast</code>: 包定义了 Go 编程语言的抽象语法树（AST），核心有以下几种类型：<ul>
<li><code>File</code>: 表示一个 Go 源文件。</li>
<li><code>Decl</code>: 表示一个声明，包括函数声明、变量声明、类型声明等。</li>
<li><code>Stmt</code>: 表示一个语句。</li>
<li><code>Expr</code>: 表示一个表达式。</li>
</ul>
</li>
<li><code>go/token</code>: 定义了处理 Go 源代码的词法元素的基础设施，包括位置、标记和标识符等。这个包提供了用于管理源代码位置的信息，可以帮助定位代码中的特定部分。</li>
<li><code>go/parser</code>: 将一个 <code>.go</code> 文件以解析成 AST 结构。</li>
<li><code>go/printer</code>: 提供了将 AST 格式化并输出为 Go 源码的功能</li>
</ul>
<p>修改后的 <code>cmd/mytool/mytool.go</code> 代码如下：</p>
<pre><code class="language-go">func main() {
	tool, args := os.Args[1], os.Args[2:]

	if len(args) &gt; 0 &amp;&amp; args[0] == &quot;-V=full&quot; {
		// don&#39;t do anything to infuence the version full output.
	} else if len(args) &gt; 0 {
		if filepath.Base(tool) == &quot;compile&quot; {
			index := findGreetFile(args)
			if index &gt; -1 {
				filename := args[index]
				f, err := os.Create(&quot;tmp.go&quot;)
				defer f.Close()
				defer os.Remove(&quot;tmp.go&quot;)
				if err != nil {
					log.Fatalf(&quot;create tmp.go error: %v\n&quot;, err)
				}
				_, _ = f.WriteString(insertCode(filename))
				args[index] = &quot;tmp.go&quot;
			}
		}
		fmt.Printf(&quot;tool: %s\n&quot;, tool)
		fmt.Printf(&quot;args: %v\n&quot;, args)
	}
	// 继续执行之前的命令
	cmd := exec.Command(tool, args...)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		log.Fatalf(&quot;run command error: %v\n&quot;, err)
	}
}

func findGreetFile(args []string) int {
	for i, arg := range args {
		if strings.Contains(arg, &quot;greet.go&quot;) {
			return i
		}
	}
	return -1
}

func insertCode(filename string) string {
	fset := token.NewFileSet()
	fast, err := parser.ParseFile(fset, filename, nil, parser.AllErrors)
	if err != nil {
		log.Fatalf(&quot;parse file error: %v\n&quot;, err)
	}

	for _, decl := range fast.Decls {
		fun, ok := decl.(*ast.FuncDecl)
		if !ok {
			continue
		}

		f, err := os.Create(&quot;tmp2.go&quot;)
		if err != nil {
			log.Fatalf(&quot;create tmp2.go error: %v\n&quot;, err)
		}
		_, _ = f.WriteString(fmt.Sprintf(newCodeFormat, fun.Name.Name))
		f.Close()

		tmpFset := token.NewFileSet()
		tmpF, err := parser.ParseFile(tmpFset, &quot;tmp2.go&quot;, nil, parser.AllErrors)
		if err != nil {
			log.Fatalf(&quot;parse tmp2.go error: %v\n&quot;, err)
		}
		fun.Body.List = append(tmpF.Decls[0].(*ast.FuncDecl).Body.List, fun.Body.List...)
		os.Remove(&quot;tmp2.go&quot;)
	}

	var buf bytes.Buffer
	printer.Fprint(&amp;buf, fset, fast)

	fmt.Println(buf.String())

	return buf.String()
}

var newCodeFormat = `
package main

func TmpFunc() {
	if InterceptMock(&quot;%s&quot;, s, &amp;res) {
	 	return res
    }
}
`
</code></pre>
<p>核心的修改在于 <code>insertCode</code> 函数：</p>
<ol>
<li><p>使用 <code>parser.ParseFile</code> 将源代码文件解析成 AST 结构；</p>
</li>
<li><p>遍历 AST 结构，找到所有的声明（Decl）结构，并使用 <code>decl(.ast.FuncDecl)</code> 找到所有的函数；</p>
<pre><code class="language-go">FuncDecl struct {
  Doc  *CommentGroup // associated documentation; or nil
  Recv *FieldList    // receiver (methods); or nil (functions)
  Name *Ident        // function/method name
  Type *FuncType     // function signature: type and value parameters, results, and position of &quot;func&quot; keyword
  Body *BlockStmt    // function body; or nil for external (non-Go) function
}

BlockStmt struct {
  Lbrace token.Pos // position of &quot;{&quot;
  List   []Stmt
  Rbrace token.Pos // position of &quot;}&quot;, if any (may be absent due to syntax error)
}
</code></pre>
</li>
<li><p>查看 <code>ast.FuncDecl</code> 的结构后，可以得出下一步就是往 <code>FuncDecl.Body.List</code> 列表前面插入一些 <code>Stmt</code>；</p>
</li>
<li><p>笔者没找到类似 <code>parseStmt</code> 方法，所以取了个巧，我定义了一段代码的 <code>format</code>，里面的 <code>%s</code> 会使用 <code>fun.Name.Name</code> 获取函数名并进行替换。</p>
<pre><code class="language-go">var newCodeFormat = `
package main

func TmpFunc() {
    if InterceptMock(&quot;%s&quot;, s, &amp;res) {
         return res
    }
}
`
</code></pre>
</li>
<li><p>创建一个临时文件 <code>tmp2.go</code> 并写入格式化后的代码，然后再次调用 <code>parser.ParseFile</code> 得到解析这段代码的抽象语法树结构 <code>tmpF</code> 了；</p>
</li>
<li><p>然后通过 <code>tmpF.Decls[0].(*ast.FuncDecl).Body.List</code> 就可以得到 <code>TmpFunc</code> 中的语句 <code>Stmt</code> 了；</p>
</li>
<li><p>将其加在源代码函数的前面即可：<code>fun.Body.List = append(tmpF.Decls[0].(*ast.FuncDecl).Body.List, fun.Body.List...)</code>；</p>
</li>
<li><p>然后再使用 <code>go/printer</code> 将修改后的 AST 输出为新文件内容。</p>
</li>
</ol>
<p>通过上述步骤，我们就可以为 <code>greet.go</code> 中的每个函数前面都插入打桩代码了。</p>
<p>修改 <code>main.go</code> 里面的内容，加入对 <code>Greet2</code> 的测试：</p>
<pre><code class="language-go">func main() {
	res := Greet(&quot;world&quot;)
	if res != &quot;hello world&quot; {
		log.Fatalf(&quot;Greet() = %q; want %q&quot;, res, &quot;hello world&quot;)
	}

	RegisterMockFunc(&quot;Greet&quot;, func(s string) string {
		return &quot;mock &quot; + s
	})
	res = Greet(&quot;world&quot;)
	if res != &quot;mock world&quot; {
		log.Fatalf(&quot;Greet() = %q; want %q&quot;, res, &quot;mock world&quot;)
	}

	log.Println(&quot;run greet 1 successfully&quot;)

	RegisterMockFunc(&quot;Greet2&quot;, func(s string) string {
		return &quot;mock 2 &quot; + s
	})
	res = Greet2(&quot;world&quot;)
	if res != &quot;mock 2 world&quot; {
		log.Fatalf(&quot;Greet2() = %q; want %q&quot;, res, &quot;mock 2 world&quot;)
	}

	log.Println(&quot;run greet 2 successfully&quot;)
}
</code></pre>
<p>执行脚本：</p>
<pre><code class="language-bash">./script.sh
</code></pre>
<p>输出应该还是跟之前是一样的，我们运行生成的可执行函数，得到如下结果那就说明我们又成功进了一步了~</p>
<pre><code class="language-bash">➜  04-toolexec-ast git:(master) ✗ ./main
2024/05/23 20:03:22 run greet 1 successfully
2024/05/23 20:03:22 run greet 2 successfully
</code></pre>
<h2>第 5 步：使用 reflect 反射动态获取参数和返回值名称</h2>
<pre><code class="language-bash">➜  05-toolexec-general git:(master) ✗ tree
.
├── cmd
│   └── mytool
│       └── mytool.go
├── greet.go
├── main.go
├── mock.go
└── script.sh
</code></pre>
<p>接下来我们来处理函数签名中的参数和返回值部分，我们的样板代码中，写死了参数的名称和返回值的名称，现在我们需要来动态获取函数参数的名称和返回值的名称，如果返回值没有名称，那我们还需要手动设置名称。</p>
<p>我们将 <code>greet.to</code> 修改为以下内容：</p>
<pre><code class="language-go">func Greet(s string) (res string) {
	return &quot;hello &quot; + s
}

func Greet2(s2 string) (res2 string) {
	return &quot;hello 2 &quot; + s2
}

func Greet3(s3 string) string {
	return &quot;hello 3 &quot; + s3
}
</code></pre>
<p>函数的信息当然都在前面获得的 <code>ast.FuncDecl</code> 结构中，再次观察其结构：</p>
<pre><code class="language-go">FuncDecl struct {
		Doc  *CommentGroup // associated documentation; or nil
		Recv *FieldList    // receiver (methods); or nil (functions)
		Name *Ident        // function/method name
		Type *FuncType     // function signature: type and value parameters, results, and position of &quot;func&quot; keyword
		Body *BlockStmt    // function body; or nil for external (non-Go) function
	}
</code></pre>
<p>通过注释就可以知道 <code>Type</code> 字段就包含了参数和返回值的相关信息，查看 <code>FuncType</code> 结构，如下：</p>
<pre><code class="language-go">FuncType struct {
  Func       token.Pos  // position of &quot;func&quot; keyword (token.NoPos if there is no &quot;func&quot;)
  TypeParams *FieldList // type parameters; or nil
  Params     *FieldList // (incoming) parameters; non-nil
  Results    *FieldList // (outgoing) results; or nil
}
</code></pre>
<ul>
<li><code>Params</code>：函数参数</li>
<li><code>Results</code>：函数返回值</li>
</ul>
<p>查看 <code>FieldList</code> 结构，可知参数列表和返回值列表都在相应的 <code>List</code> 字段中，而其中的 <code>Names</code> 字段就是参数的名称了。</p>
<pre><code class="language-go">type FieldList struct {
	Opening token.Pos // position of opening parenthesis/brace/bracket, if any
	List    []*Field  // field list; or nil
	Closing token.Pos // position of closing parenthesis/brace/bracket, if any
}

type Field struct {
	Doc     *CommentGroup // associated documentation; or nil
	Names   []*Ident      // field/method/(type) parameter names; or nil
	Type    Expr          // field/method/parameter type; or nil
	Tag     *BasicLit     // field tag; or nil
	Comment *CommentGroup // line comments; or nil
}
</code></pre>
<p>补充一下，这里为什么 <code>Names</code> 类型是 <code>[]*Ident</code> 呢？因为函数有以下的命名方式：</p>
<pre><code class="language-go">func hello(s1, s2 string) (r1, r1 string) {}
</code></pre>
<p>那么在当下，只有 1 个参数和只有 1 个返回值的情况下，我们就可以通过 <code>fun.Type.Params.List[0].Names[0].Name</code> 来获取参数名称，也可以通过 <code>fun.Type.Results.List[0].Names</code> 来获取返回值名称，如果返回值没有名称，那我们就为其设置名称 <code>__xgo_res_1</code> 并写回源 AST 结构。这样就都有名称，就很好处理了。</p>
<p>经上分析， <code>cmd/mytool/mytool.go</code> 中我们只需要修改 <code>insertCode</code> 部分，修改的结果如下：</p>
<pre><code class="language-go">func insertCode(filename string) string {
	fset := token.NewFileSet()
	fast, err := parser.ParseFile(fset, filename, nil, parser.AllErrors)
	if err != nil {
		log.Fatalf(&quot;parse file error: %v\n&quot;, err)
	}

	for _, decl := range fast.Decls {
		fun, ok := decl.(*ast.FuncDecl)
		if !ok {
			continue
		}

		f, err := os.Create(&quot;tmp.go&quot;)
		if err != nil {
			log.Fatalf(&quot;create tmp.go error: %v\n&quot;, err)
		}
		_, _ = f.WriteString(newCode(fun))
		f.Close()

		tmpFset := token.NewFileSet()
		tmpF, err := parser.ParseFile(tmpFset, &quot;tmp.go&quot;, nil, parser.AllErrors)
		if err != nil {
			log.Fatalf(&quot;parse tmp.go error: %v\n&quot;, err)
		}
		fun.Body.List = append(tmpF.Decls[0].(*ast.FuncDecl).Body.List, fun.Body.List...)
		os.Remove(&quot;tmp.go&quot;)
	}

	var buf bytes.Buffer
	printer.Fprint(&amp;buf, fset, fast)

	fmt.Println(buf.String())

	return buf.String()
}

func newCode(fun *ast.FuncDecl) string {

	/*
		&amp;{Doc:&lt;nil&gt; Names:[s] Type:string Tag:&lt;nil&gt; Comment:&lt;nil&gt;}
		&amp;{Doc:&lt;nil&gt; Names:[res] Type:string Tag:&lt;nil&gt; Comment:&lt;nil&gt;}
		&amp;{Doc:&lt;nil&gt; Names:[s2] Type:string Tag:&lt;nil&gt; Comment:&lt;nil&gt;}
		&amp;{Doc:&lt;nil&gt; Names:[res2] Type:string Tag:&lt;nil&gt; Comment:&lt;nil&gt;}
		&amp;{Doc:&lt;nil&gt; Names:[s3] Type:string Tag:&lt;nil&gt; Comment:&lt;nil&gt;}
		&amp;{Doc:&lt;nil&gt; Names:[] Type:string Tag:&lt;nil&gt; Comment:&lt;nil&gt;}
	*/

	// 函数名称
	funcName := fun.Name.Name

	// 参数列表
	argName := fun.Type.Params.List[0].Names[0].Name

	// 返回值列表
	resNames := fun.Type.Results.List[0].Names
	if len(resNames) == 0 {
		resNames = append(resNames, &amp;ast.Ident{Name: &quot;_xgo_res_1&quot;})
		fun.Type.Results.List[0].Names = resNames
	}
	resName := resNames[0].Name
	return fmt.Sprintf(newCodeFormat, funcName, argName, resName, resName)
}

var newCodeFormat = `
package main

func TmpFunc() {
	if InterceptMock(&quot;%s&quot;, %s, &amp;%s) {
	 	return %s
    }
}
`
</code></pre>
<p>现在我们就可以动态获取参数名称和返回值名称了。</p>
<p>修改我们的 <code>main.go</code>，以测试所有的情况：</p>
<pre><code class="language-go">func main() {
	res := Greet(&quot;world&quot;)
	if res != &quot;hello world&quot; {
		log.Fatalf(&quot;Greet() = %q; want %q&quot;, res, &quot;hello world&quot;)
	}

	RegisterMockFunc(&quot;Greet&quot;, func(s string) string {
		return &quot;mock &quot; + s
	})
	res = Greet(&quot;world&quot;)
	if res != &quot;mock world&quot; {
		log.Fatalf(&quot;Greet() = %q; want %q&quot;, res, &quot;mock world&quot;)
	}

	log.Println(&quot;run greet 1 successfully&quot;)

	RegisterMockFunc(&quot;Greet2&quot;, func(s string) string {
		return &quot;mock 2 &quot; + s
	})
	res = Greet2(&quot;world&quot;)
	if res != &quot;mock 2 world&quot; {
		log.Fatalf(&quot;Greet2() = %q; want %q&quot;, res, &quot;mock 2 world&quot;)
	}

	log.Println(&quot;run greet 2 successfully&quot;)

	RegisterMockFunc(&quot;Greet3&quot;, func(s string) string {
		return &quot;mock 3 &quot; + s
	})
	res = Greet3(&quot;world&quot;)
	if res != &quot;mock 3 world&quot; {
		log.Fatalf(&quot;Greet3() = %q; want %q&quot;, res, &quot;mock 3 world&quot;)
	}

	log.Println(&quot;run greet 3 successfully&quot;)
}
</code></pre>
<p>执行编译脚本：</p>
<pre><code class="language-bash">./script.sh
</code></pre>
<p>执行编译产生的可执行程序，输出如下就说明我们又成功进了一大步~</p>
<pre><code class="language-bash">➜  05-toolexec-general git:(master) ✗ ./main
2024/05/23 20:15:08 run greet 1 successfully
2024/05/23 20:15:08 run greet 2 successfully
2024/05/23 20:15:08 run greet 3 successfully
</code></pre>
<h2>第 6 步：支持多参数和多返回值</h2>
<pre><code class="language-bash">➜  06-toolexec-multi git:(master) ✗ tree
.
├── cmd
│   └── mytool
│       └── mytool.go
├── greet.go
├── main.go
├── mock.go
└── script.sh
</code></pre>
<p>本文的最后一步，我们来面对一下多参数和多返回值的问题。假设我们又如下函数：</p>
<pre><code class="language-go">func Pair1(s1, s2 string) (res string) {
	return &quot;pair 1 &quot; + s1 + &quot; &quot; + s2
}
</code></pre>
<p>这个时候我们 <strong>代码重写</strong> 后应该长什么样子呢？可以是下面这样的：</p>
<pre><code class="language-go">func Pair1(s1, s2 string) (res string) {
	if InterceptMock(&quot;Pair1&quot;, s1, s2, &amp;res) {
		return res
	}
	return &quot;pair 1 &quot; + s1 + &quot; &quot; + s2
}
</code></pre>
<p>按照这个思路，下面这个函数呢？</p>
<pre><code class="language-go">func Pair2(s1, s2 string) (res1, res2 string) {
	return &quot;pair 1 &quot; + s1, &quot;pair 2 &quot; + s2
}
</code></pre>
<p>那就是这样的？</p>
<pre><code class="language-go">func Pair2(s1, s2 string) (res1, res2 string) {
	if InterceptMock(&quot;Pair2&quot;, s1, s2, &amp;res1, &amp;res2) {
		return res1, res2
	}
	return &quot;pair 1 &quot; + s1, &quot;pair 2 &quot; + s2
}
</code></pre>
<p>这种思路当然也能实现，换一种更优雅的思路呢？既然是一个列表，那么就可以用切片来承载，也就是可以是这样的：</p>
<pre><code class="language-go">func Pair2(s1, s2 string) (res1, res2 string) {
	if InterceptMock(&quot;Pair2&quot;, []interface{}{s1, s2}, []interface{}{&amp;res1, &amp;res2}) {
		return res1, res2
	}
	return &quot;pair 1 &quot; + s1, &quot;pair 2 &quot; + s2
}
</code></pre>
<p>那我们就可以抽象出插入代码的模板了：</p>
<pre><code class="language-go">if InterceptMock(&quot;${funcName}&quot;, []interface{}{${paramList}}, []interface{}{${returnListWith&amp;}}) {
  return ${returnListWithout&amp;}
}
</code></pre>
<p>为了实现这个，我们需要先修改一下 <code>mock.go</code> 中的 <code>InterceptMock</code> 函数：</p>
<pre><code class="language-go">func InterceptMock(funcName string, args []interface{}, results []interface{}) bool {
	mockFn, ok := mockFuncs.Load(funcName)
	if !ok {
		return false
	}


	in := make([]reflect.Value, len(args))
	for i, arg := range args {
		in[i] = reflect.ValueOf(arg)
	}

  mockFnValue := reflect.ValueOf(mockFn)
	out := mockFnValue.Call(in)
	if len(out) != len(results) {
		panic(&quot;mock function return value number is not equal to results number&quot;)
	}

	for i, result := range results {
		reflect.ValueOf(result).Elem().Set(out[i])
	}
	return true
}
</code></pre>
<p>拦截器的具体实现如下：</p>
<ol>
<li>判断是否注册了 mock 函数，没有则直接返回；</li>
<li>将所有参数都放到 <code>[]refect.Value</code> 中；</li>
<li>通过反射 <code>refect.ValueOf</code> 获取 mockFn 的值；</li>
<li>调用 <code>mockFnValue.Call()</code> 来执行函数，并返回结果列表；</li>
<li>遍历传进来的返回值引用列表，调用 <code>reflect.ValueOf(result).Elem().Set(out[i])</code> 将返回值设置回去。</li>
</ol>
<p>现在我们来修改我们的 <code>-toolexec</code> 工具，来根据函数的 AST 结构，获取参数列表和返回值列表，生成代插入的模板代码，并将其插入到每个函数的开头。这次在 <code>cmd/mytool/mytool.go</code> 中，我们只需修改 <code>newCode</code> 函数：</p>
<pre><code class="language-go">func insertCode(filename string) string {
	fset := token.NewFileSet()
	fast, err := parser.ParseFile(fset, filename, nil, parser.AllErrors)
	if err != nil {
		log.Fatalf(&quot;parse file error: %v\n&quot;, err)
	}

	for _, decl := range fast.Decls {
		fun, ok := decl.(*ast.FuncDecl)
		if !ok {
			continue
		}

		f, err := os.Create(&quot;tmp.go&quot;)
		if err != nil {
			log.Fatalf(&quot;create tmp.go error: %v\n&quot;, err)
		}
		_, _ = f.WriteString(newCode(fun))
		f.Close()

		tmpFset := token.NewFileSet()
		tmpF, err := parser.ParseFile(tmpFset, &quot;tmp.go&quot;, nil, parser.AllErrors)
		if err != nil {
			log.Fatalf(&quot;parse tmp.go error: %v\n&quot;, err)
		}
		fun.Body.List = append(tmpF.Decls[0].(*ast.FuncDecl).Body.List, fun.Body.List...)
		os.Remove(&quot;tmp.go&quot;)
	}

	var buf bytes.Buffer
	printer.Fprint(&amp;buf, fset, fast)

	fmt.Println(buf.String())

	return buf.String()
}

func newCode(fun *ast.FuncDecl) string {
	// 函数名称
	funcName := fun.Name.Name

	// 参数列表
	args := make([]string, 0)
	for _, arg := range fun.Type.Params.List {
		for _, name := range arg.Names {
			args = append(args, name.Name)
		}
	}
	// 返回值列表
	returns := make([]string, 0)
	returnRefs := make([]string, 0)
	returnNames := fun.Type.Results.List[0].Names
	if len(returnNames) == 0 {
		for i := 0; i &lt; fun.Type.Results.NumFields(); i++ {
			fun.Type.Results.List[0].Names = append(fun.Type.Results.List[0].Names,
				&amp;ast.Ident{Name: fmt.Sprintf(&quot;_xgo_res_%d&quot;, i+1)})
		}
	}
	for _, re := range fun.Type.Results.List[0].Names {
		returns = append(returns, re.Name)
		returnRefs = append(returnRefs, &quot;&amp;&quot;+re.Name)
	}
	return fmt.Sprintf(newCodeFormat,
		funcName,
		strings.Join(args, &quot;,&quot;),
		strings.Join(returnRefs, &quot;,&quot;),
		strings.Join(returns, &quot;,&quot;))
}

var newCodeFormat = `
package main

func TmpFunc() {
	if InterceptMock(&quot;%s&quot;, []interface{}{%s}, []interface{}{%s}) {
		return %s
	}
}
`
</code></pre>
<p>思路跟之前第 5 步大同小异，不过是用遍历的方式来支持多个参数和多个返回值罢了。</p>
<p>现在我们为 <code>greet.go</code> 添加更多的测试函数，代码如下：</p>
<pre><code class="language-go">func Greet(s string) (res string) {
	return &quot;hello &quot; + s
}

func Greet2(s2 string) (res2 string) {
	return &quot;hello 2 &quot; + s2
}

func Greet3(s3 string) string {
	return &quot;hello 3 &quot; + s3
}

func Pair1(s1, s2 string) (res string) {
	return &quot;pair 1 &quot; + s1 + &quot; &quot; + s2
}

func Pair2(s1, s2 string) (res1, res2 string) {
	return &quot;pair 1 &quot; + s1, &quot;pair 2 &quot; + s2
}

func Other(i int, s string, f float64) string {
	return fmt.Sprintf(&quot;int: %d, string: %s, float: %f&quot;, i, s, f)
}
</code></pre>
<p>为了测试，我们再次修改 <code>main.go</code>，使其覆盖所有的情况：</p>
<pre><code class="language-go">func main() {

	RegisterMockFunc(&quot;Other&quot;, func(i int, s string, f float64) string {
		return fmt.Sprintf(&quot;mock %d %s %.2f&quot;, i, s, f)
	})
	res := Other(1, &quot;hello&quot;, 3.14)
	if res != &quot;mock 1 hello 3.14&quot; {
		log.Fatalf(&quot;Other() = %q; want %q&quot;, res, &quot;mock 1 hello 3.14&quot;)
	}
	log.Println(&quot;run other successfully&quot;)

	RegisterMockFunc(&quot;Pair1&quot;, func(s1, s2 string) string {
		return &quot;mock 1 &quot; + s1 + &quot; &quot; + s2
	})
	res = Pair1(&quot;hello&quot;, &quot;world&quot;)
	if res != &quot;mock 1 hello world&quot; {
		log.Fatalf(&quot;Pair1() = %q; want %q&quot;, res, &quot;mock 1 hello world&quot;)
	}
	log.Println(&quot;run pair1 successfully&quot;)

	RegisterMockFunc(&quot;Pair2&quot;, func(s1, s2 string) (string, string) {
		return &quot;mock 2 &quot; + s1, &quot;mock 2 &quot; + s2
	})
	res1, res2 := Pair2(&quot;hello&quot;, &quot;world&quot;)
	if res1 != &quot;mock 2 hello&quot; || res2 != &quot;mock 2 world&quot; {
		log.Fatalf(&quot;Pair2() = %q, %q; want %q, %q&quot;, res1, res2, &quot;mock 2 hello&quot;, &quot;mock 2 world&quot;)
	}
	log.Println(&quot;run pair2 successfully&quot;)

	res = Greet(&quot;world&quot;)
	if res != &quot;hello world&quot; {
		log.Fatalf(&quot;Greet() = %q; want %q&quot;, res, &quot;hello world&quot;)
	}

	RegisterMockFunc(&quot;Greet&quot;, func(s string) string {
		return &quot;mock &quot; + s
	})
	res = Greet(&quot;world&quot;)
	if res != &quot;mock world&quot; {
		log.Fatalf(&quot;Greet() = %q; want %q&quot;, res, &quot;mock world&quot;)
	}

	log.Println(&quot;run greet 1 successfully&quot;)

	RegisterMockFunc(&quot;Greet2&quot;, func(s string) string {
		return &quot;mock 2 &quot; + s
	})
	res = Greet2(&quot;world&quot;)
	if res != &quot;mock 2 world&quot; {
		log.Fatalf(&quot;Greet2() = %q; want %q&quot;, res, &quot;mock 2 world&quot;)
	}

	log.Println(&quot;run greet 2 successfully&quot;)

	RegisterMockFunc(&quot;Greet3&quot;, func(s string) string {
		return &quot;mock 3 &quot; + s
	})
	res = Greet3(&quot;world&quot;)
	if res != &quot;mock 3 world&quot; {
		log.Fatalf(&quot;Greet3() = %q; want %q&quot;, res, &quot;mock 3 world&quot;)
	}

	log.Println(&quot;run greet 3 successfully&quot;)
}
</code></pre>
<p>编译代码：</p>
<pre><code class="language-bash">./script.sh
</code></pre>
<p>执行生成的可执行程序，如果有以下输出，那我们就又成功进了一大大步了~</p>
<pre><code class="language-bash">➜  06-toolexec-multi git:(master) ✗ ./main
2024/05/23 20:31:10 run other successfully
2024/05/23 20:31:10 run pair1 successfully
2024/05/23 20:31:10 run pair2 successfully
2024/05/23 20:31:10 run greet 1 successfully
2024/05/23 20:31:10 run greet 2 successfully
2024/05/23 20:31:10 run greet 3 successfully
</code></pre>
<h2>更进一步</h2>
<p>通过上面 6 个简单的小阶段，我们就已经把 <code>xgo</code> 最最核心的功能给实现了，在一些小场景下还勉强能用？🤡</p>
<p>我们来看看包含测试代码和样例函数，总共用了多少代码：</p>
<pre><code class="language-bash">➜  06-toolexec-multi git:(master) ✗ tokei .
===============================================================================
 Language            Files        Lines         Code     Comments       Blanks
===============================================================================
 Go                      4          281          224           11           46
 Shell                   1            5            3            1            1
===============================================================================
 Total                   5          286          227           12           47
===============================================================================
</code></pre>
<p>短短 <strong>224</strong> 行代码，这是一个非常了不起的成就！</p>
<p>当然，优秀的读者肯定可以发现我们这个 <strong>丐版 xgo</strong> 有太多的不足和缺陷了。这是必然的，我们来看看 <code>xgo</code> 截止 <code>1.0.37</code> 版本，总共有多少行代码：</p>
<pre><code class="language-go">➜  xgo git:(master) tokei .
===============================================================================
 Language            Files        Lines         Code     Comments       Blanks
===============================================================================
 BASH                    1          104           81           11           12
 CSS                     1          153          118            5           30
 Go                    369        33232        26836         2588         3808
 JavaScript              1          170          146           10           14
 JSON                    2          435          435            0            0
 PowerShell              1           28           16            3            9
 Shell                   3          288          251            4           33
 SVG                     1           41           41            0            0
 Plain Text              7          192            0          174           18
-------------------------------------------------------------------------------
 HTML                    1           19           16            3            0
 |- JavaScript           1            6            6            0            0
 (Total)                             25           22            3            0
-------------------------------------------------------------------------------
 Markdown               17         1455            0         1083          372
 |- Go                   8          820          635           72          113
 |- JSON                 1           80           80            0            0
 (Total)                           2355          715         1155          485
===============================================================================
 Total                 404        36117        27940         3881         4296
===============================================================================
</code></pre>
<p>光 Go 代码就有 <strong>26836</strong> 行了。所以可知 <code>xgo</code> 的作者是做了很多的付出和努力的。不过我们用了不到百分之一的代码量，就将 <code>xgo</code> 最核心的原理展示得淋漓尽致了，感兴趣的读者可以进一步阅读 <code>xgo</code> 的源码，可以进一步探索如何抽象出更通用更简洁更易扩展的 interceptor，如何支持协程隔离，如何优化依赖管理，以及如何实现其他的 trace、coverage 功能。再次为 <code>xgo</code> 打 call 👏！</p>
<h2>参考</h2>
<ul>
<li><a href="https://github.com/xhd2015/xgo">xgo repo</a></li>
<li><a href="https://docs.google.com/presentation/d/1U5yTdrUjnManxztzsMPGBAePrE3HvJcDtRjV6h3HelA/edit#slide=id.g2705943ae25_0_50">xgo: 基于代码重写实现 Monkey Patch 和 Trace</a></li>
<li><a href="https://github.com/golang/go/tree/release-branch.go1.22/src/cmd/compile">go compile README</a></li>
<li><a href="https://blog.xhd2015.xyz/zh/posts/xgo-monkey-patching-in-go-using-toolexec/">xgo: 在 go 中使用-toolexec 实现猴子补丁</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Kafka 负载均衡挑战及解决思路</title>
      <link>https://hedon.top/blog/kafka-load-balance/</link>
      <guid isPermaLink="true">https://hedon.top/blog/kafka-load-balance/</guid>
      <pubDate>Mon, 20 May 2024 10:37:10 GMT</pubDate>
      <description>本文转载自 Agoda Enginnering, 介绍了 Kafka 负载均衡的实际应用过程中的负载均衡挑战及解决思路。</description>
      <category>Kafka</category><category>中间件</category><category>消息队列</category>
      <content:encoded><![CDATA[<p>本文转载自 Agoda Engineering，介绍了在实际应用中，如何应对 Kafka 负载均衡所遇到的各种挑战，并提出相应的解决思路。本文简要阐述了 Kafka 的并行性机制、常用的分区策略以及在实际操作中遇到的异构硬件、不均匀工作负载等问题。通过深入分析这些挑战，并提供具体的解决方案，本文旨在帮助读者更好地理解和应用 Kafka 的负载均衡技术，从而提高系统的整体性能和稳定性。</p>
<p>以下大部分内容翻译自原文 <a href="https://medium.com/agoda-engineering/how-we-solve-load-balancing-challenges-in-apache-kafka-8cd88fdad02b">how-we-solve-load-balancing-challenges-in-apache-kafka</a>，并已获得原作者同意。</p>
<h2>思维导图</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/Kafka%20%E8%B4%9F%E8%BD%BD%E5%9D%87%E8%A1%A1%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88.png" alt="Kafka 负载均衡解决方案"></p>
<h2>Kafka 并行性</h2>
<p>Kafka 通过分区来实现并行性，如下图所示，生产者（Producer）产生的消息会按照一定的分区策略分配到多个分区（Partition）中，消费组中的每个消费者会分别负责消费其中的若干个分区。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/0*ixosAmhBDsyBBhoS.png" alt="Kafka 分区演示"></p>
<p>分区策略：</p>
<ul>
<li>轮询（Round Robin）：默认情况下，Kafka 使用轮询策略将消息均匀地分配到所有分区。</li>
<li>哈希（Key Hashing）：如果消息有分区键，Kafka 会对键进行哈希计算，将消息分配到特定的分区。</li>
<li>自定义分区策略：开发者可以实现自定义的分区器（Partitioner）逻辑，以满足特定需求。</li>
</ul>
<p>如果要使用轮询或者哈希策略来达到“负载均衡”的目的，那么需要满足以下 2 个假设：</p>
<ol>
<li>消费者拥有相同的处理能力，</li>
<li>消息的工作量相等。</li>
</ol>
<p>然而，在实践中，这些假设往往不成立。</p>
<h2>现实挑战</h2>
<h3>1. 异构硬件</h3>
<p>不同代的服务器硬件性能不同，导致处理速率存在差异。例如，使用不同代硬件进行处理的基准显示性能存在显着差异：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/1*B6svY0ZjYVy-uJ7ZA18Jtg.png" alt="不同服务器处理速率差异举例"></p>
<h3>2. 每条 Kafka 消息的工作负载不均匀</h3>
<p>下图显示了在一个时间窗口内到达的 12 条消息。在这里，生产者向该主题中的六个分区中的每一个发布两条消息。因此，每个 worker 消耗来自 2 个分区的数据，这意味着每个 worker 需要处理 4 条消息。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/0*VwPda5gNsHRL2tJV.png" alt="使用循环分区器和循环分配器来分发消息的先前供应系统的演示。每个 worker 都分配有相同数量的消息。"></p>
<p>不同的消息可能需要不同的处理步骤集。例如，处理消息可能涉及调用第三方 HTTP 端点，并且不同的响应大小或延迟可能会影响处理速率。此外，对于涉及数据库操作的应用程序，其数据库查询的延迟可能会根据查询参数而波动，从而导致处理速率发生变化。</p>
<h3>3. 过度配置问题</h3>
<p>由于工作负载和处理效率不同，为了达到系统吞吐量的需求，可能会出现过度配置问题，从而导致资源浪费。</p>
<p>假设我们的高吞吐量和低吞吐量的处理速率分别为 20 msg/s 和 10 msg/s（根据表 1 中的数据进行简化）。使用两个较快的处理器和一个较慢的处理器，我们预计总容量为 20+20+10 = 50 条消息/秒。但是，当保持消息的循环分配时，我们无法达到此容量。下图显示了如果流量持续达到每秒 50 条消息时会发生什么情况。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/0*P-Qa3gyPgXtIeZMx.png" alt="如果传入流量保持在 50 条消息/秒，则慢速处理器无法处理总体消息 1/3 的负载，从而导致累积延迟。为了避免高延迟，向该系统添加了额外的资源以维持处理。"></p>
<p>从这个例子中我们可以看到，我们的处理器服务一次最多只能接受 30 条消息，以防止滞后并确保及时传递更新。</p>
<p>在这种情况下，要实际每秒处理 50 条消息，我们必须总共扩展到 5 台机器，以保证及时处理所有消息。由于这种不适当的分配逻辑（66.7％的过度配置），我们会向该系统过度配置额外的两台机器。</p>
<p>为了每秒处理 50 条消息，我们需要扩展到五台机器以确保及时处理所有消息。由于这种不适当的分配逻辑（66.7% 的过度配置），这会导致向该系统过度配置两台额外的机器。</p>
<h2>静态解决方案</h2>
<h3>1. 在相同的 Pod（机器）上部署</h3>
<p>考虑控制服务部署中使用的硬件类型以缓解问题。如果您在虚拟机上部署服务并拥有充足的资源和性能相同的硬件，则此方法是可行的。</p>
<p>然而，由于成本效益和灵活性下降，在私有云环境中通常不建议采用这种策略，主要是因为同时升级所有现有硬件可能具有挑战性。如果它非常适合您的情况，则可以使用<a href="https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/">Kubernetes 关联性将 Pod 分配给某些类型的节点。</a></p>
<h3>2. 加权负载均衡</h3>
<p>如果容量是可预测的并且大部分时间保持静态，则为不同的消费者分配不同的权重可以帮助最大限度地利用可用资源。例如，在为表现较好的消费者赋予更高的权重后，我们可以将更多流量路由给这些消费者。</p>
<h2>动态解决方案</h2>
<p>虽然我们可以估计消息的容量和工作负载来设计静态规则来确定加权负载平衡策略，但由于以下几个因素，这种方法在实际生产环境中可能并不总是可行：</p>
<ul>
<li>消息的工作负载并不统一，这使得估计机器容量变得困难。</li>
<li>依赖关系（例如网络和第三方连接）不稳定，有时会导致实际处理中的容量发生变化。</li>
<li>该系统经常添加新功能，增加额外的维护工作以保持权重更新。</li>
</ul>
<p>为了解决这些问题，我们可以动态监控每个分区中的当前滞后并根据当前流量状况做出相应响应。</p>
<p>有 2 种思路：</p>
<ol>
<li><strong>生产者角度</strong>：使用自定义算法根据滞后的消息数量来确定每个分区的流量，这种生产者称为滞后感知生产者（Lag-aware Producer）。</li>
<li><strong>消费者角度</strong>：这些消费者旨在监控当前滞后的消息数量，并可以在必要时取消订阅以触发负载重新平衡。通常，可以采用自定义的重新平衡策略来调整分区分配。这种消费者称为滞后感知消费者（Lag-aware Comsumer）。</li>
</ol>
<h3>1. 从生产者角度出发</h3>
<p>如此图所示，生产者可以使用自定义算法根据滞后确定每个分区的流量。为了减少对 Kafka 代理的调用次数，系统可以维护一个内部延迟缓存，而不是在发布每条消息之前调用 Kafka 代理。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/0*Mg1lxKzMTy7LRAXT.png" alt="在此示例中，分区 4 和 6 的延迟比其他分区高得多。应减少从内部生产者发送到这些分区的流量。"></p>
<p>使用滞后数据，定制的算法被设计为向经历高滞后的分区发布更少的流量，向低滞后的分区发布更多流量，以平衡每个分区上的工作负载。当滞后平衡且稳定时，此方法应确保消息的均匀分布。</p>
<p>不适用情况：</p>
<ol>
<li><strong>纯消费者应用程序</strong>：您的应用程序不控制消息生成。</li>
<li>**多个消费者组：**当生成的消息被多个消费者组消费时，生产者可能会为其他消费者组产生不必要的倾斜负载，因为滞后只是特定于一个消费者组的信息。</li>
</ol>
<h4>相同队列长度算法</h4>
<p>该算法将每个分区滞后视为处理的队列大小。获取滞后信息后，它会发布适当数量的消息以填充短队列。此方法更适合由于异构硬件而导致的倾斜滞后分布，其中高性能 Pod（机器）在大多数情况下能够更快地处理。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/0*zp-S1Y_GbIzbjCX4.png" alt="相同队列长度算法的演示。最初，不同队列的长度不同。该算法尝试生成不同数量的消息，以在所有队列中实现相同的队列长度。这里，队列长度和 Kafka lag 是同一个概念，代表尚未处理的消息数量"></p>
<h4>异常值检测算法</h4>
<p>该算法利用统计方法来确定所有分区的上离群值，并暂时停止那些慢速离群值的发布过程。在原文章中，针对 Agoda 的特定需求，他们提出了 IQR（四分位距）和 STD（标准差）异常值检测算法。算法流程图如下所示。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/0*pjkj5kF6aFcWwBwU.png" alt="异常值检查算法流程"></p>
<ul>
<li><strong>慢速分区：</strong>（已关闭）由于存在延迟，这些分区的消息生成已停止。</li>
<li><strong>好的分区</strong>：（打开）照常发布并均匀分发到所有好的分区。</li>
<li><strong>OK 分区：</strong>（观察/半开放）为了提高性能不佳的机器的性能，当系统尝试将慢速分区提升为良好分区时，会添加一个观察期。通过仅生成一小部分消息并进行观察，可以将该观察阶段优化为“半开放”状态。当滞后获取间隔相对较长时，半开放是有益的，因为它可以防止消费者延迟等待传入消息而更新的滞后数据尚未查询的情况。</li>
</ul>
<h3>2. 从消费者角度出发</h3>
<p>这里 Adoga 提出的思路是：<strong>遇到高延迟的实例可以主动取消订阅主题以触发重新平衡。在重新平衡期间，可以使用自定义的分配器来平衡所有消费者实例之间的分区。</strong></p>
<p>触发重新平衡的成本非常昂贵，因为急切的重新平衡会停止消费者组中的所有处理。Kafka 2.4 中引入的<a href="https://www.confluent.io/blog/incremental-cooperative-rebalancing-in-kafka/">增量协作再平衡协议</a>已经最大限度地减少了性能影响，允许更频繁的再平衡以更好地分配每个分区上的负载。</p>
<p>为了增强重新分配的灵活性，分区的数量应该大于 worker 的数量。这一比率应根据应用程序而有所不同，并假设一个工作线程至少可以处理来自一个分区的负载以避免饥饿。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/0*68P7QtdFGeIzwZSs.png" alt="在此示例中，工作程序 3 在速度较慢的硬件上运行，导致分区 5 和 6 出现更高的延迟。因此，工作程序 3 可能会主动取消订阅主题以触发重新平衡并更有效地重新分配分区。在此示例中，应实现自定义分配器以根据机器指标和滞后信息重新分配分区。"></p>
<h2>总结</h2>
<p>本文从 Kafka 并行性的一般实现出发，探讨了 Kafka 实现负载均衡在现实实践中可能遇到的各种挑战，并从静态调整和动态调整两个方面给出了解决思路，特别注重讨论了动态调整策略，并分别从生产者和消费者的角度提出了解决方案。</p>
<p>总之，通过在 Kafka 中实现负载均衡，可以有效地将工作负载分配到可用资源之间，从而显著提高服务性能。具体的算法和策略需要根据实际情况进行选择和调整。</p>
]]></content:encoded>
    </item>
    <item>
      <title>学习记录：用 Go 自制解释器 Monkey</title>
      <link>https://hedon.top/blog/monkey-language/</link>
      <guid isPermaLink="true">https://hedon.top/blog/monkey-language/</guid>
      <pubDate>Sun, 12 May 2024 03:44:15 GMT</pubDate>
      <description>本文主要是记录笔者在学习《用 Go 自制解释器 Monkey》过程中涉及的重要设计理念和思考。</description>
      <category>Go</category><category>编译原理</category><category>Go 实战</category>
      <content:encoded><![CDATA[<h2>词法分析</h2>
<p>TDD：测试驱动开发</p>
<p>先写测试用例，再进行词法分析逻辑的完善。</p>
<h2>语法分析</h2>
<p>递归下降语法分析伪代码</p>
<pre><code class="language-go">function parseProgram() {
    program = newProgramASTNode()
    advanceTokens()
    for (currentToken() != EOF_TOKEN) {
        statement = null
        if (currentToken() == LET_TOKEN) {
            statement = parseLetStatement()
        } else if (currentToken() == RETURN_TOKEN) {
            statement = parseReturnStatement()
        } else if (currentToken() == IF_TOKEN) {
            statement = parseIfStatement()
        }
        if (statement != null) {
            program.Statements.push(statement)
        }
        advanceTokens()
    }
    return program
}
function parseLetStatement() {
    advanceTokens()
    identifier = parseIdentifier()
    advanceTokens()
    if currentToken() != EQUAL_TOKEN {
        parseError(&quot;no equal sign!&quot;)
        return null
    }
    advanceTokens()
    value = parseExpression()
    variableStatement = newVariableStatementASTNode()
    variableStatement.identifier = identifier
    variableStatement.value = value
    return variableStatement
}
function parseIdentifier() {
    identifier = newIdentifierASTNode()
    identifier.token = currentToken()
    return identifier
}
function parseExpression() {
    if (currentToken() == INTEGER_TOKEN) {
        if (nextToken() == PLUS_TOKEN) {
            return parseOperatorExpression()
        } else if (nextToken() == SEMICOLON_TOKEN) {
            return parseIntegerLiteral()
        }
    } else if (currentToken() == LEFT_PAREN) {
        return parseGroupedExpression()
    }
// [...]
}
function parseOperatorExpression() {
    operatorExpression = newOperatorExpression()
    operatorExpression.left = parseIntegerLiteral()
    operatorExpression.operator = currentToken()
    operatorExpression.right = parseExpression()
    return operatorExpression()
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240512042659060.png" alt="递归下降分析法"></p>
<h3>let x=5</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240512035446100.png" alt="let stmt AST structure"></p>
<h3>return 5</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240512045650041.png" alt="return stmt AST structue"></p>
<h3>普拉特解析</h3>
]]></content:encoded>
    </item>
    <item>
      <title>时间处理基础：Rust 的 chrono 库教程</title>
      <link>https://hedon.top/blog/rust-crate-chrono/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-crate-chrono/</guid>
      <pubDate>Sat, 11 May 2024 23:52:55 GMT</pubDate>
      <description>本文全面的指南深入介绍了如何在 Rust 中使用 chrono 库来精确处理和转换时间与日期。从基本概念到高级功能，本文提供了实用的代码示例和详尽的解释，帮助你在任何 Rust 项目中高效管理时间。</description>
      <category>rust</category><category>Rust 常用库</category>
      <content:encoded><![CDATA[<p>在开发过程中，我们经常有对时间和日期处理的需求。不论是日历应用、日程安排、还是时间戳记录，准确的时间数据处理都是必不可少的。Rust 社区提供的 <code>chrono</code> 库以其强大的功能和灵活的接口，在 Rust 开发者中广受欢迎。本文将简单介绍 <code>chrono</code> 库，展示如何利用它来精确处理和转换时间和日期，帮助你在任何 Rust 项目中都能高效地管理时间。</p>
<h2>版本</h2>
<ul>
<li><code>chrono</code>: 0.4.38</li>
</ul>
<h2>结论先行</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240512010241356.png" alt="chrono 各种时间类型转换图"></p>
<h2>时间相关概念</h2>
<table>
<thead>
<tr>
<th>概念</th>
<th>理解</th>
</tr>
</thead>
<tbody><tr>
<td>UNIX 时间戳（UNIX Timestamp）</td>
<td>也称为 POSIX 时间或 Epoch 时间，是自 1970 年 1 月 1 日（UTC 时区）以来经过的秒数，不计入闰秒。这是一种非常通用的时间表示方法，在编程中广泛使用，因为它可以简化时间差的计算。</td>
</tr>
<tr>
<td>UTC（协调世界时）</td>
<td>全称为协调世界时（Coordinated Universal Time），是目前国际上广泛采用的时间标准。它基本上与格林威治平均时（GMT）相同，但在技术上更加精确，因为它使用原子钟来保持时间准确。世界各地的时间都是以 UTC 为基础，加上或减去一定的小时数来定义的。</td>
</tr>
<tr>
<td>时区（Time Zone）</td>
<td>时区是地球上划分的标准时间区域。由于地球自西向东旋转，每向东移动一定角度，当地的太阳时间就会相应地提前。世界被分成了 24 个时区，每个时区通常相差一小时。时区允许地区内的人们能在大致相同的时间内，经历类似的日夜更替模式。</td>
</tr>
<tr>
<td>UTC+8</td>
<td>UTC+8 是 UTC 时间加上 8 小时的时间区。中国大陆就是位于这个时区。例如，当 UTC 时间为 00:00 时，UTC+8 的时间就是 08:00。</td>
</tr>
</tbody></table>
<h2>chrono 关键类型</h2>
<table>
<thead>
<tr>
<th>类型</th>
<th>含义</th>
<th>适用场景</th>
</tr>
</thead>
<tbody><tr>
<td>DateTime&lt;Tz&gt;</td>
<td>一个带有时区的日期和时间类型，其中 <code>Tz</code> 是实现了 <code>TimeZone</code> 特质的类型，如 <code>Utc</code> 和 <code>Local</code> 。这意味着 <code>DateTime</code> 考虑了时区的影响，可以表示全球任意地点的精确时间。</td>
<td>广泛用于需要考虑时区转换的场景，如存储用户的本地时间或在不同地区之间转换时间。</td>
</tr>
<tr>
<td>NaiveDateTime</td>
<td>一个“天真的”日期和时间，即不包含任何时区信息的日期和时间。这种类型仅仅表示一个日历日期和一天中的时间，而没有任何关于地理或政治时区的数据。</td>
<td>对于一些时区不重要的场景非常有用，比如记录电影的发行日期或历史事件的日期。</td>
</tr>
<tr>
<td>NaiveDate</td>
<td>仅表示一个日历日期，不包括时间或时区信息。</td>
<td>它用于处理只需要日期而不关心具体时间的场景，如生日、节日等。</td>
</tr>
<tr>
<td>NaiveTime</td>
<td>是一个只表示一天中时间的类型，它不包含日期或时区信息。</td>
<td>这个类型适用于需要处理具体某个时间点（如开会时间、日常活动的开始时间）但不需要日期数据的情景。</td>
</tr>
</tbody></table>
<h2>chrono 时区类型</h2>
<p><code>chrono</code> 支持多种时区类型，方便进行全球时间的转换和计算：</p>
<ul>
<li><strong><code>Utc</code></strong>: 用于处理协调世界时。</li>
<li><strong><code>Local</code></strong>: 代表服务器或用户的本地时区。</li>
<li><strong><code>FixedOffset</code></strong>: 允许定义任意的小时和分钟偏移量，适合固定偏移的时间计算。</li>
</ul>
<h2>常用功能</h2>
<h3>获取当前时间</h3>
<pre><code class="language-rust">let local_datetime: DateTime&lt;Local&gt; = Local::now();
let utc_datetime: DateTime&lt;Utc&gt; = Utc::now();
</code></pre>
<h3>DateTime 转 String</h3>
<pre><code class="language-rust">println!(&quot;{}&quot;, local_datetime.to_rfc2822()); // Sun, 12 May 2024 00:15:55 +0800
println!(&quot;{}&quot;, local_datetime.to_rfc3339()); // 2024-05-12T00:15:55.325058+08:00
println!(&quot;{}&quot;, local_datetime.to_string()); // 2024-05-12 00:15:55.325058 +08:00
println!(&quot;{}&quot;, local_datetime.format(&quot;%Y-%m-%d %H:%M:%S&quot;)) // 2024-05-12 00:15:55
</code></pre>
<h3>String 转 DateTime</h3>
<p>字符串带时区信息，使用 <code>DateTime::parse_from_str(s, f)</code>。</p>
<pre><code class="language-rust">let format_withzone = &quot;%Y-%m-%d %H:%M:%S %z&quot;;
let datetime_withzone_str = &quot;2024-01-01 00:00:00 +08:00&quot;;
let local_datetime =
    DateTime::parse_from_str(&amp;datetime_withzone_str, &amp;format_withzone).unwrap();
</code></pre>
<p>字符串无时区信息，使用 <code>NaiveDateTime::parse_from_str(s, f)</code>。</p>
<pre><code class="language-rust">let format = &quot;%Y-%m-%d %H:%M:%S&quot;;
let datetime_str = &quot;2024-01-01 00:00:00&quot;;
let local_datetime = NaiveDateTime::parse_from_str(&amp;datetime_str, &amp;format)
    .unwrap()
    .and_local_timezone(Local) // 转为带时区的 DateTime
    .unwrap();
</code></pre>
<h3>DateTime 转 timestamp</h3>
<pre><code class="language-rust">let local_datetime = Local::now();
println!(&quot;seconds: {}&quot;, local_datetime.timestamp()); // 1715444324
println!(&quot;millis: {}&quot;, local_datetime.timestamp_millis()); // 1715444338610
println!(&quot;micros: {}&quot;, local_datetime.timestamp_micros()); // 1715444338610873
println!(&quot;nacos: {}&quot;, local_datetime.timestamp_nanos_opt().unwrap()); // 1715444338610873000
</code></pre>
<h3>timestamp 转 DateTime</h3>
<pre><code class="language-rust">let utc_datetime: DateTime&lt;Utc&gt; = DateTime::from_timestamp(1704139200, 0).unwrap(); // 默认是 Utc
let local_datetime: DateTime&lt;Local&gt; = DateTime::from_timestamp(1704139200, 0).unwrap().into(); // 使用 into() 转为 Local
</code></pre>
<h3>时区转换</h3>
<pre><code class="language-rust">use chrono::{DateTime, FixedOffset, Utc};

fn main() {
    let utc_date_time: DateTime&lt;Utc&gt; = Utc::now();
    let fixed_offset = FixedOffset::east(8 * 3600); // 转为 utc+8 东八区
    let local_date_time = utc_date_time.with_timezone(&amp;fixed_offset);
    println!(&quot;Local time in UTC+8: {}&quot;, local_date_time);
}
</code></pre>
<h3>时间计算</h3>
<p>时间加减：</p>
<pre><code class="language-rust">use chrono::{Duration, Local};

let now = Local::now();
let yesterday = now - Duration::hours(24);
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240512002330526.png" alt="chrono time duration methods"></p>
<p>时间间隔：</p>
<pre><code class="language-rust">use chrono::{Duration, Local};

let now = Local::now();
let yesterday = now - Duration::hours(24);
let hour_interval = (now - yesterday).num_hours();
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240512002106738.png" alt="chrono time interval methods"></p>
<h2>总结</h2>
<p>通过本文的详细介绍和实用示例，我们了解了如何使用 Rust 的 <code>chrono</code> 库来精确处理时间和日期。<code>chrono</code> 不仅支持复杂的时区计算和全球时间管理，还提供了方便的日期时间解析和格式化工具，以及灵活的时间运算功能。掌握了这些技能后，你将能够在任何需要精确时间数据处理的 Rust 应用中，提供稳定和高效的解决方案。</p>
<p>时间是每个程序的基石，而 <code>chrono</code> 就是那把能够操纵时间的魔杖。</p>
<p>希望本文能对你有帮助，peace! enjoy coding~</p>
<blockquote>
<p>参考：</p>
<ul>
<li><a href="https://docs.rs/chrono/latest/chrono/">chrono crate</a></li>
<li><a href="https://blog.stackademic.com/rust-working-with-date-and-time-30e003cd59e8">rust-working-with-date-and-time</a></li>
</ul>
<p>作图：</p>
<ul>
<li><a href="https://excalidraw.com/">https://excalidraw.com/</a></li>
</ul>
</blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨并发构建倒排索引</title>
      <link>https://hedon.top/blog/rust-action-inverted-index-concurrency/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-inverted-index-concurrency/</guid>
      <pubDate>Tue, 23 Apr 2024 23:13:27 GMT</pubDate>
      <description>本文详细阐述了使用 Rust channel 并发构建倒排索引的详细过程。</description>
      <category>rust</category><category>倒排索引</category><category>并发编程</category><category>通道</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<h2>引言</h2>
<p>继上篇 <a href="/blog/rust-action-inverted-index-demo/">Rust 实战丨倒排索引</a>，本篇我们将参考《Rust 程序设计（第二版）》中并发编程篇章来实现高并发构建倒排索引。</p>
<p>本篇主要分为以下几个部分：</p>
<ol>
<li>功能展示：展示我们最终实现的 2 个工具的效果（构建索引、搜索功能）</li>
<li>阅读源码：阅读书中源码的实现，理清大体思路。</li>
<li>构建索引：实战构建索引的每个具体环节，并对核心逻辑进行解释和阐述缘由。</li>
<li>搜索功能：这是书中未曾提供的功能，笔者根据自身理解，对齐上篇提供的功能，实现了一个搜索功能。</li>
</ol>
<p>能学到：</p>
<ul>
<li>Rust 各种迭代器的使用</li>
<li>Rust 文件常用操作</li>
<li>Rust 字符串常用操作</li>
<li>Rust channel 实战</li>
<li>Rust 并发编程</li>
<li>多路合并文件实际应用</li>
<li>使用 <code>byteorder</code> 进行位操作</li>
<li>使用 <code>clap</code> 进行 CLI 开发</li>
<li>终端高亮输出</li>
<li>深入理解倒排索引高性能的核心细节</li>
</ul>
<h2>阅读建议</h2>
<p>本篇内容较为冗长，涉及到的细节讲解可能比较啰嗦，推荐<strong>直接阅读源码，然后对不理解的地方再来本篇对应的章节进行阅读</strong>。</p>
<p>完成源码位于：<a href="https://github.com/hedon-rust-road/inverted-index-concurrency">https://github.com/hedon-rust-road/inverted-index-concurrency</a></p>
<h2>版本声明</h2>
<ul>
<li>Rust: 1.76</li>
<li>byteordrr: 1.5.0</li>
<li>clap: 4.5.0</li>
<li>运行环境：macbookPro Apple M2 Max</li>
</ul>
<h2>功能展示</h2>
<h3>create.rs</h3>
<pre><code class="language-bash">Usage: create [OPTIONS] &lt;FILENAMES&gt;...

Arguments:
  &lt;FILENAMES&gt;...

Options:
  -s, --single-threaded  Default false
  -h, --help             Print help
</code></pre>
<p>指定文件目录，构建索引，可以使用 <code>-s</code> 使用单线程构建，默认使用并发构建。</p>
<p>执行示例如下：</p>
<pre><code class="language-bash">➜  inverted-index-concurrency git:(master) ✗ cargo run --bin create ./texts
    Finished dev [unoptimized + debuginfo] target(s) in 0.08s
     Running `/Users/wangjiahan/rust-target/debug/create ./texts`
indexed document 0:&quot;./texts/text1.txt&quot;, 22 bytes, 5 words
indexed document 1:&quot;./texts/text3.txt&quot;, 27 bytes, 5 words
indexed document 2:&quot;./texts/text2.txt&quot;, 39 bytes, 6 words
word count: 16
351 bytes main, 736 bytes total
wrote file &quot;./tmp00000001.dat&quot;
</code></pre>
<h3>search.rs</h3>
<pre><code class="language-bash">Usage: search --index-file &lt;INDEX_FILE&gt; --term &lt;TERM&gt;

Options:
  -i, --index-file &lt;INDEX_FILE&gt;  Specify index file path
  -t, --term &lt;TERM&gt;              Specify search term
  -h, --help                     Print help
</code></pre>
<p>指定索引文件和搜索词来进行搜索。</p>
<p>执行示例如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240423201406903.png" alt="search.rs 执行示例"></p>
<h2>阅读源码</h2>
<blockquote>
<p>书中的源码位于：<a href="https://github.com/ProgrammingRust/fingertips/tree/master">fingertips</a></p>
</blockquote>
<p>第一部分我们先来阅读源码，书中展示了这样一张图：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image00882.jpeg" alt="索引构建器管道，其中箭头表示通过通道将值从一个线程发送到另一个线程（未展示磁盘 I/O）"></p>
<p>从这张图我们大概可以猜想本案例中构建并发索引的过程可能是：</p>
<ol>
<li>读取文件内容；</li>
<li>根据文件内容构建索引；</li>
<li>多个索引进行合并；</li>
<li>将索引写入文件；</li>
<li>多个索引文件进行合并。</li>
</ol>
<p>按照这个思路的指引，我们打开源码，从 <code>main.rs</code> 的 <code>main()</code> 出发：</p>
<pre><code class="language-rust">fn main() {
    let mut single_threaded = false;
    let mut filenames = vec![];

  	// 命令行参数解析
    {
        let mut ap = ArgumentParser::new();
        ap.set_description(&quot;Make an inverted index for searching documents.&quot;);
        ap.refer(&amp;mut single_threaded).add_option(
            &amp;[&quot;-1&quot;, &quot;--single-threaded&quot;],
            StoreTrue,
            &quot;Do all the work on a single thread.&quot;,
        );
        ap.refer(&amp;mut filenames).add_argument(
            &quot;filenames&quot;,
            Collect,
            &quot;Names of files/directories to index. \
                           For directories, all .txt files immediately \
                           under the directory are indexed.&quot;,
        );
        ap.parse_args_or_exit();
    }

  	// 构建索引
    match run(filenames, single_threaded) {
        Ok(()) =&gt; {}
        Err(err) =&gt; println!(&quot;error: {}&quot;, err),
    }
}
</code></pre>
<ol>
<li>解析命令行参数，这里使用 <code>argparse</code> 这个比较古老的 crate 来解析，现在一般是使用 <code>clap</code>。<ul>
<li><code>single_threaded:</code> 是否使用单线程，默认是多线程。</li>
<li><code>filenames</code>: 指定的文本文件或目录。</li>
</ul>
</li>
<li><code>run</code> 函数执行构建索引。</li>
</ol>
<p>看一下 <code>run</code>：</p>
<pre><code class="language-rust">/// Generate an index for a bunch of text files.
fn run(filenames: Vec&lt;String&gt;, single_threaded: bool) -&gt; io::Result&lt;()&gt; {
    let output_dir = PathBuf::from(&quot;.&quot;);
    let documents = expand_filename_arguments(filenames)?;

    if single_threaded {
        run_single_threaded(documents, output_dir)
    } else {
        run_pipeline(documents, output_dir)
    }
}
</code></pre>
<ul>
<li>单线程：run_single_threaded</li>
<li>多线程：run_pipeline</li>
</ul>
<p>先从简单看，单线程，忽略掉源码中定义的特殊数据结构，可以发现跟我们上篇介绍的简单版倒排索引思路基本是一致的，只不过本案例中数据是从文件中读，最后又会将索引写入到文件中。</p>
<pre><code class="language-rust">fn run_single_threaded(documents: Vec&lt;PathBuf&gt;, output_dir: PathBuf) -&gt; io::Result&lt;()&gt; {

    let mut accumulated_index = InMemoryIndex::new();
    let mut merge = FileMerge::new(&amp;output_dir);
    let mut tmp_dir = TmpDir::new(&amp;output_dir);

    // 迭代每个文本文件
    for (doc_id, filename) in documents.into_iter().enumerate() {
      	// 打开文件，并将内容读取到 `text` 上
        let mut f = File::open(filename)?;
        let mut text = String::new();
        f.read_to_string(&amp;mut text)?;

        // 构建索引
        let index = InMemoryIndex::from_single_document(doc_id, text);
        accumulated_index.merge(index);
        if accumulated_index.is_large() {
            // 当索引足够大的时候，将其写到文件中
            let file = write_index_to_tmp_file(accumulated_index, &amp;mut tmp_dir)?;
            merge.add_file(file)?;
            accumulated_index = InMemoryIndex::new();
        }
    }

    // 将最后一个索引写入到文件中
    if !accumulated_index.is_empty() {
        let file = write_index_to_tmp_file(accumulated_index, &amp;mut tmp_dir)?;
        merge.add_file(file)?;
    }
    merge.finish()
}
</code></pre>
<p>再来看本文的重头戏，多线程：</p>
<pre><code class="language-rust">fn run_pipeline(documents: Vec&lt;PathBuf&gt;, output_dir: PathBuf) -&gt; io::Result&lt;()&gt; {
    // 将构建索引分为 5 个过程
    let (texts, h1) = start_file_reader_thread(documents);
    let (pints, h2) = start_file_indexing_thread(texts);
    let (gallons, h3) = start_in_memory_merge_thread(pints);
    let (files, h4) = start_index_writer_thread(gallons, &amp;output_dir);
    let result = merge_index_files(files, &amp;output_dir);

    // 等待所有线程执行完毕
    let r1 = h1.join().unwrap();
    h2.join().unwrap();
    h3.join().unwrap();
    let r4 = h4.join().unwrap();

    r1?;
    r4?;
    result
}
</code></pre>
<p>首先将索引构建分成 5 个阶段：</p>
<p><strong>1. start_file_reader_thread</strong></p>
<p>就是从文件中读取文本信息，并将其扔进 <code>Receiver&lt;String&gt;</code> channel 中，传到下一个阶段。</p>
<pre><code class="language-rust">fn start_file_reader_thread(
    documents: Vec&lt;PathBuf&gt;,
) -&gt; (Receiver&lt;String&gt;, JoinHandle&lt;io::Result&lt;()&gt;&gt;) {
    let (sender, receiver) = channel();
    let handle = spawn(move || {
        for filename in documents {
            let mut f = File::open(filename)?;
            let mut text = String::new();
          	// 读取文件内容
            f.read_to_string(&amp;mut text)?;
            if sender.send(text).is_err() {
                break;
            }
        }
        Ok(())
    });
    (receiver, handle)
}
</code></pre>
<p><strong>2. start_file_indexing_thread</strong></p>
<p>从第 1 步传过来的文本信息中调用 <code>InMemoryIndex::from_single_document</code> 构建索引。</p>
<pre><code class="language-rust">fn start_file_indexing_thread(
    texts: Receiver&lt;String&gt;,
) -&gt; (Receiver&lt;InMemoryIndex&gt;, JoinHandle&lt;()&gt;) {
    let (sender, receiver) = channel();
    let handle = spawn(move || {
        for (doc_id, text) in texts.into_iter().enumerate() {
          	// 构建索引
            let index = InMemoryIndex::from_single_document(doc_id, text);
            if sender.send(index).is_err() {
                break;
            }
        }
    });
    (receiver, handle)
}
</code></pre>
<p><strong>3. start_in_memory_merge_thread</strong></p>
<p>将第 2 步构建的单一索引进行合并，并将合并后的索引传到下一个阶段。</p>
<pre><code class="language-rust">fn start_in_memory_merge_thread(
    file_indexes: Receiver&lt;InMemoryIndex&gt;,
) -&gt; (Receiver&lt;InMemoryIndex&gt;, JoinHandle&lt;()&gt;) {
    let (sender, receiver) = channel();
    let handle = spawn(move || {
        let mut accumulated_index = InMemoryIndex::new();
        for fi in file_indexes {
          	// 将索引进行合并
            accumulated_index.merge(fi);
            if accumulated_index.is_large() {
              	// 如果索引大小到达阈值，则传到下一阶段
                if sender.send(accumulated_index).is_err() {
                    return;
                }
                accumulated_index = InMemoryIndex::new();
            }
        }
        if !accumulated_index.is_empty() {
            let _ = sender.send(accumulated_index);
        }
    });
    (receiver, handle)
}
</code></pre>
<p><strong>4. start_index_writer_thread</strong></p>
<p>将第 3 步传来的内存索引写入到临时文件中。</p>
<pre><code class="language-rust">fn start_index_writer_thread(
    big_indexes: Receiver&lt;InMemoryIndex&gt;,
    output_dir: &amp;Path,
) -&gt; (Receiver&lt;PathBuf&gt;, JoinHandle&lt;io::Result&lt;()&gt;&gt;) {
    let (sender, receiver) = channel();
    let mut tmp_dir = TmpDir::new(output_dir);
    let handle = spawn(move || {
        for index in big_indexes {
          	// 将索引写入临时文件中
            let file = write_index_to_tmp_file(index, &amp;mut tmp_dir)?;
            if sender.send(file).is_err() {
                break;
            }
        }
        Ok(())
    });
    (receiver, handle)
}
</code></pre>
<p><strong>5. merge_index_files</strong></p>
<p>将临时文件进行合并，生成最终的索引文件。</p>
<pre><code class="language-rust">fn merge_index_files(files: Receiver&lt;PathBuf&gt;, output_dir: &amp;Path) -&gt; io::Result&lt;()&gt; {
    let mut merge = FileMerge::new(output_dir);
    for file in files {
        merge.add_file(file)?;
    }
    merge.finish()
}
</code></pre>
<p>这 5 个步骤跟书中给出的示意图基本一致，我们再来看 <code>run_pipeline</code> 是如何合并并行的：</p>
<pre><code class="language-rust">  // 使用 join() 等待所有线程完成
  let r1 = h1.join().unwrap();
  h2.join().unwrap();
  h3.join().unwrap();
  let r4 = h4.join().unwrap();

  // 阶段 2 和阶段 3 都是纯内存操作，不会有错误
  // 阶段 1 是读文件，阶段 4 是写文件，所以有可能会报错
  r1?;
  r4?;
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240416001044618.png" alt="run_pipeline 示意图"></p>
<p>源码阅读部分差不多就到这了，大的思想架构你应该都能 Get 到了，其中每个数据结构的具体实现细节，我们在后面的实战中进行拆解。</p>
<h2>构建索引</h2>
<h3>代码结构</h3>
<p>书中源码代码结构如下所示：</p>
<pre><code>➜  fingertips git:(master) ✗ tree
.
├── Cargo.lock
├── Cargo.toml
├── LICENSE-MIT
├── README.md
├── src
│   ├── index.rs
│   ├── main.rs
│   ├── merge.rs
│   ├── read.rs
│   ├── tmp.rs
│   └── write.rs
</code></pre>
<p>书中给出的源码并没有实现使用构建好的索引文件进行搜索的功能，笔者将在此基础上实现该功能，所以对代码结构进行了简单的调整：</p>
<pre><code>➜  inverted_index git:(master) ✗ tree
.
├── Cargo.lock
├── Cargo.toml
├── index.bat
├── src
│   ├── bin
│   │   ├── create.rs
│   │   └── search.rs
│   ├── index.rs
│   ├── lib.rs
│   ├── merge.rs
│   ├── read.rs
│   ├── tmp.rs
│   └── write.rs
└── texts
    ├── text1.txt
    ├── text2.txt
    └── text3.txt
</code></pre>
<p>可以看到我将核心代码从 <code>bin</code> 改成了 <code>lib</code> ，这是为了支持我后面要实现的两个 <code>bin</code>:</p>
<ul>
<li><code>create</code>: 构建索引，基本上就是源代码中的 <code>main.rs</code></li>
<li><code>search</code>: 基于生成的索引文件实现搜索功能</li>
</ul>
<p><code>texts</code> 是我提供的文本文件样例。</p>
<p><code>src</code> 目录中的代码阅读顺序及功能划分如下：</p>
<ul>
<li><code>index</code>: 定义了内存索引数据结构 InMemoryIndex，实现了从文件内容中构建内存索引的基本逻辑，也实现了从索引文件重建内存索引的功能。</li>
<li><code>tmp</code>: 定义了临时目录数据结构 TmpDir，用于存放临时索引文件。</li>
<li><code>write</code>: 定义了索引文件写入器 IndexFileWriter，实现了将 InMemoryIndex 写入文件中的逻辑。</li>
<li><code>merge</code>: 定义了文件合并器 FileMerge，用于合并 TmpDir 的所有索引文件。</li>
<li><code>read</code>: 定义了索引文件读取器 IndexFileWrite，实现了解析索引文件的逻辑。</li>
</ul>
<h3>项目准备</h3>
<pre><code class="language-bash">cargo new --lib inverted_index_concurrency
</code></pre>
<p>Cargo.toml</p>
<pre><code class="language-toml">[package]
name = &quot;inverted-index-concurrency&quot;
version = &quot;0.1.0&quot;
edition = &quot;2021&quot;
license = &quot;mit&quot;
authors = [&quot;hedon&quot;]
description = &quot;a tool to concurrently build an inverted index.&quot;

[[bin]]
name=&quot;create&quot;
path=&quot;src/bin/create.rs&quot;

[[bin]]
name=&quot;search&quot;
path=&quot;src/bin/search.rs&quot;

[dependencies]
byteorder = &quot;1.5.0&quot;
clap = { version = &quot;4.5.4&quot;, features = [&quot;derive&quot;] }
</code></pre>
<h3>lib.rs</h3>
<pre><code class="language-rust">pub mod index;
pub mod merge;
pub mod read;
pub mod tmp;
pub mod write;
</code></pre>
<p>在 <code>lib.rs</code> 中我们将这 5 个 mod 公开出去，这样就可以给 <code>bin</code> 目录中的 <code>crate.rs</code> 和 <code>search.rs</code> 使用了。</p>
<h3>index.rs</h3>
<blockquote>
<p>完整源码：<a href="https://github.com/hedon-rust-road/inverted-index-concurrency/blob/master/src/index.rs">index.rs</a></p>
</blockquote>
<p>第一部分是内存索引的构建。</p>
<h4>tokenize</h4>
<p>我们先定义一个分词函数：</p>
<pre><code class="language-rust">fn tokenize(text: &amp;str) -&gt; Vec&lt;(&amp;str, usize, usize)&gt; {
    let mut res = Vec::new();
    let mut token_start = None;
    for (idx, ch) in text.char_indices() {
        match (ch.is_alphanumeric(), token_start) {
            (true, None) =&gt; token_start = Some(idx),  // 每个单词的开始
            (false, Some(start)) =&gt; {  // 每个单词的结尾
                res.push((&amp;text[start..idx], start, idx - 1));
                token_start = None
            }
            _ =&gt; {}
        }
    }
    if let Some(start) = token_start {
        res.push((&amp;text[start..], start, text.len() - 1))
    }
    res
}
</code></pre>
<p>这个分词函数跟书中源码提供的不一样，为了实现文本高亮，我们需要记录每个分词在原文本中的起始位置和结束位置。它的核心逻辑如下：</p>
<ol>
<li><p>通过 <code>char_indices()</code> 获取 <code>text</code> 的字符迭代器，这是一种懒加载的方法，避免一次性将所有 char 加载到内存中。</p>
</li>
<li><p>匹配 <code>(ch.is_alphanumeric(), token_start)</code>：</p>
<ul>
<li>如果是 <code>(true, None)</code> 则表示这是一个单词的开始，我们纪录其开始的位置 <code>Some(idx)</code>；</li>
<li>如果是 <code>(false, Some(idx))</code> 则表示这是一个单词的结束，我们将其加入到 <code>res</code> 中，并记录起始位置和结束位置。</li>
<li>其他情况，不做处理，要么是非法字符，要么是处于单词中间。</li>
</ul>
<p>从这个简单的理解中，你应该可以感受到 Rust 中 match pattern 的强大和便捷了，666 👍🏻</p>
</li>
</ol>
<h4>struct: InMemoryIndex</h4>
<p>在 <code>index.rs</code> 中，我们定义了三个数据结构：</p>
<pre><code class="language-rust">pub struct InMemoryIndex {
    pub word_count: usize,
    pub terms: HashMap&lt;String, Vec&lt;Hit&gt;&gt;,
    pub docs: HashMap&lt;usize, Document&gt;,
}

pub struct Document {
    pub id: u32,
    pub path: PathBuf,
}

pub type Hit = Vec&lt;u8&gt;;
</code></pre>
<ul>
<li><p><code>Document</code>: 文档封装。</p>
<ul>
<li><code>id</code>: 文档 id，唯一标识符。</li>
<li><code>path</code>: 源文件路径。</li>
</ul>
</li>
<li><p><code>Hit</code>: 它是一个字节数组，我们按照小端序进行存储，它的存储结构如下：</p>
<ul>
<li>[0..3] 存储一个 <code>HITS_SEPERATOR = -1</code>，表示一个 <code>Hit</code> 的开始。</li>
<li>[4..7] 存储一个 u32 的 <code>document_id</code>。</li>
<li>后面每 8 个 u8 会存在一个 u32 的 <code>start_pos</code> 和一个 u32 的 <code>end_pos</code>。</li>
</ul>
</li>
<li><p><code>InMemoryIndex</code>: 内存索引。</p>
<ul>
<li><code>word_count</code>: 包含的单词（word/term）个数，记录它是为了判断索引是否过大，以便对索引进行分片存储。</li>
<li><code>terms</code>: 存储 word 到 Hits 的映射，每个 <code>word</code> 是一个搜索项。</li>
<li><code>docs</code>: 存储了 document_id 到文档的映射，用于查询原始文档信息。</li>
</ul>
</li>
</ul>
<p>接下来我们来为 <code>InMemoryIndex</code> 实现一系列方法，因为我们期望使用小端序存储 <code>Hit</code> 中的数据，所以我们需要引入 <code>byteorder</code> 这个 crate:</p>
<pre><code class="language-bash">cargo add byteorder
</code></pre>
<p>具体实现可参考源码，核心逻辑是 <code>from_single_document</code> 和 <code>merge</code>。</p>
<h4>from_single_document</h4>
<p><strong>from_single_document</strong> 的核心逻辑在这一段，它其实跟我们之前实现的简易版倒排索引很相似：</p>
<pre><code class="language-rust">for (token, start_pos, end_pos) in tokens.iter() {
    let hits = index.terms.entry(token.to_string()).or_insert_with(|| {
        let mut hits = Vec::with_capacity(4 + 4 + 4 + 4);
        hits.write_i32::&lt;LittleEndian&gt;(Self::HITS_SEPERATOR)
            .unwrap();
        hits.write_u32::&lt;LittleEndian&gt;(document_id).unwrap();
        vec![hits]
    });

    hits[0].write_u32::&lt;LittleEndian&gt;(*start_pos as u32).unwrap();
    hits[0].write_u32::&lt;LittleEndian&gt;(*end_pos as u32).unwrap();
    index.word_count += 1;
}
</code></pre>
<ul>
<li>遍历每个 <code>token</code> 和它在文本中的位置。</li>
<li>对于每个 <code>token</code>，尝试在索引的 <code>map</code> 中查找一个现有的条目。如果不存在，则创建一个新的 <code>Hit</code> 记录，并初始化它：<ul>
<li>创建一个新的 <code>Hit</code> 向量，预留 24 字节的容量，这是因为至少要存储 1 个分隔符、1 个 document_id、1 个 start_pos 和 1 个 end_pos。</li>
<li>首先写入 <code>HITS_SEPERATOR</code> 和 <code>document_id</code>（使用小端序）。</li>
</ul>
</li>
<li>向对应的 <code>Hit</code> 向量中添加当前单词的位置。</li>
<li>累加处理的单词总数到 <code>index.word_count</code>。</li>
</ul>
<p>这里给个示例，希望可以帮助你理解 <code>InMemoryIndex</code> 的内存结构：</p>
<pre><code class="language-yaml">InMemoryIndex
│
├── word_count: usize
│
├── terms: HashMap&lt;String, Vec&lt;Hit&gt;&gt;
│   │
│   ├── Key: &quot;example&quot; (String)
│   │   └── Value: Vec&lt;Hit&gt;
│   │       ├── [HITS_SEPERATOR, Document ID: 1, Positions: [10, 19, 30, 39]] (Hit)
│   │       └── [HITS_SEPERATOR, Document ID: 2, Positions: [15, 25]] (Hit)
│   │
│   └── Key: &quot;test&quot;
│       └── Value: Vec&lt;Hit&gt;
│           └── [HITS_SEPERATOR, Document ID: 1, Positions: [20, 24, 50, 69]] (Hit)
│
└── docs: HashMap&lt;u32, Document&gt;
    ├── Key: 1 (u32)
    │   └── Value: Document { id: 1, path: &quot;path/to/file1.txt&quot;}
    └── Key: 2
        └── Value: Document { id: 2, path: &quot;path/to/file2.txt&quot;}
</code></pre>
<h4>merge</h4>
<p><strong>merge</strong> 是用于合并多个 <code>InMemoryIndex</code>，起到批处理的目的。</p>
<pre><code class="language-rust">pub fn merge(&amp;mut self, other: InMemoryIndex) {
    for (term, hits) in other.terms {
        self.terms.entry(term).or_default().extend(hits)
    }
    self.word_count += other.word_count;
    self.docs.extend(other.docs);
}
</code></pre>
<p>实现完了 <code>InMemoryIndex</code> 后，我们就可以先来完成 <code>create.rs</code> 的 <code>run_pipeline</code> 的前 3 个阶段了。</p>
<h4>step1: start_file_reader_thread</h4>
<ol>
<li>读取文件信息：我们需要在独立的线程中依次打开给定的文件列表，并将文件内容读取到一个 String 中，并利用 channel 传送出去。</li>
</ol>
<pre><code class="language-rust">fn start_file_reader_thread(
    documents: Vec&lt;PathBuf&gt;,
) -&gt; (Receiver&lt;(PathBuf, String)&gt;, JoinHandle&lt;io::Result&lt;()&gt;&gt;) {
    let (sender, receiver) = channel();

    let handler = spawn(move || {
        for filename in documents {
            let mut f = File::open(filename.clone())?;
            let mut text = String::new();
            f.read_to_string(&amp;mut text)?;
            if sender.send((filename, text)).is_err() {
                break;
            }
        }
        Ok(())
    });

    (receiver, handler)
}
</code></pre>
<h4>step2: start_file_indexing_thread</h4>
<ol start="2">
<li>构建索引：通过 channel 从第 1 阶段中获取文档文本信息，通过 from_single_document 构建索引 InMemoryIndex 后，将索引通过 channel 传送出去。</li>
</ol>
<pre><code class="language-rust">fn start_file_indexing_thread(
    docs: Receiver&lt;(PathBuf, String)&gt;,
) -&gt; (Receiver&lt;InMemoryIndex&gt;, JoinHandle&lt;()&gt;) {
    let (sender, receiver) = channel();

    let handler = spawn(move || {
        for (doc_id, (path, text)) in docs.into_iter().enumerate() {
            let index = InMemoryIndex::from_single_document(doc_id as u32, path, text);
            if sender.send(index).is_err() {
                break;
            }
        }
    });

    (receiver, handler)
}
</code></pre>
<h4>step3: start_in_memory_merge_thread</h4>
<ol start="3">
<li>合并索引：通过 channel 从第 2 阶段中获得构建的 InMemoryIndex 并将其合并成大索引，然后通过 channel 传送出去。</li>
</ol>
<pre><code class="language-rust">fn start_in_memory_merge_thread(
    indexes: Receiver&lt;InMemoryIndex&gt;,
) -&gt; (Receiver&lt;InMemoryIndex&gt;, JoinHandle&lt;()&gt;) {
    let (sender, receiver) = channel();

    let handle = spawn(move || {
        let mut accumulated_index = InMemoryIndex::new();
        for i in indexes {
            accumulated_index.merge(i);
            if accumulated_index.is_large() {
                if sender.send(accumulated_index).is_err() {
                    return;
                }
                accumulated_index = InMemoryIndex::new();
            }
        }
        if !accumulated_index.is_empty() {
            let _ = sender.send(accumulated_index);
        }
    });

    (receiver, handle)
}
</code></pre>
<details open>
<summary>补充：为什么采用这种“复杂”的方式来存储数据呢？可否使用 JSON 或者 Protobuf 呢？</summary>


<p>选择如何组织和存储数据，特别是在实现一个搜索引擎或数据库索引时，是一个关键决策，这会直接影响到程序的性能、可维护性以及扩展性。在这些情况下，使用像 <code>byteorder</code> 这样的低级数据格式存储索引信息可能比使用 JSON 或 Protobuf 等高级格式更有优势。</p>
<p>读写速度：</p>
<ul>
<li><strong>二进制格式</strong>：直接操作二进制格式通常比解析文本或半结构化的数据格式（如 JSON）要快，因为它减少了解析时间和内存使用。在二进制格式中，数据通常是紧密打包的，没有额外的格式标记（如 JSON 中的花括号和逗号），这减少了磁盘 I/O 需求。</li>
<li><strong>文本/半结构化格式</strong>：例如 JSON，每次读取时都需要解析文本，转换数据类型，这会增加 CPU 的负担，尤其是在大规模数据处理时。</li>
</ul>
<p>空间效率：</p>
<ul>
<li><strong>二进制格式</strong>：使用最少的字节表示数据，例如使用定长的整数存储文档 ID 和位置索引，不仅节省空间，还能提高缓存利用率。</li>
<li><strong>文本/半结构化格式</strong>：文本格式需要存储额外的字符来标识数据（例如引号和键名），这增加了存储需求。</li>
</ul>
<p>适用场景：</p>
<ul>
<li><strong>二进制格式</strong>：非常适合需要高性能和大数据处理的后端系统，如搜索引擎和数据库索引。这种格式可以有效地支持快速的数据读取和写入，特别是在资源受限的环境中（如嵌入式系统或低延迟应用）。</li>
<li><strong>JSON/Protobuf</strong>：更适合需要跨平台兼容性和易于调试的应用场景。例如，在 Web 应用中使用 JSON 作为数据交换格式，可以简化前后端的集成和测试。</li>
</ul>
</details>

<h3>tmp.rs</h3>
<p>完成内存索引的构建后，我们需要将构建过程中产生的大索引先临时落盘，后面再进行合并。为了临时存储这些数据文件，我们需要将他们放在一个临时目录中，为此，我们定义了 <code>TmpDir</code> 数据结构：</p>
<pre><code class="language-rust">#[derive(Clone)]
pub struct TmpDir {
    dir: PathBuf,
    n: usize,
}
</code></pre>
<ul>
<li><code>dir</code>: 目录</li>
<li><code>n</code>: 自增器，用于区分临时文件命名</li>
</ul>
<p>接下来为 <code>TmpDir</code> 实现 2 个方法：</p>
<pre><code class="language-rust">impl TmpDir {
    pub fn new&lt;P: AsRef&lt;Path&gt;&gt;(dir: P) -&gt; TmpDir {
        TmpDir {
            dir: dir.as_ref().to_owned(),
            n: 1,
        }
    }

    pub fn create(&amp;mut self) -&gt; io::Result&lt;(PathBuf, BufWriter&lt;File&gt;)&gt; {
        let mut r#try = 1;
        loop {
            let filename = self
                .dir
                .join(PathBuf::from(format!(&quot;tmp{:08x}.dat&quot;, self.n)));
            self.n += 1;
            match fs::OpenOptions::new()
                .write(true)
                .create_new(true)
                .open(&amp;filename)
            {
                Ok(f) =&gt; return Ok((filename, BufWriter::new(f))),
                Err(exc) =&gt; {
                    if r#try &lt; 999 &amp;&amp; exc.kind() == io::ErrorKind::AlreadyExists {
                        // keep going
                    } else {
                        return Err(exc);
                    }
                }
            }
            r#try += 1;
        }
    }
}
</code></pre>
<p><strong>new</strong> 方法是 <code>TmpDir</code> 的构造函数，其中我们将 <code>n</code> 设置为 1，即文件名从 1 开始生成。<code>dir.as_ref().to_owned()</code> 接受一个可能是任何类型的路径，将其标准化为一个 <code>Path</code> 类型的引用，然后再复制这个引用，创建一个完全独立的、拥有所有权的 <code>PathBuf</code> 对象，</p>
<p><strong>create</strong> 方法是在 <code>TmpDir</code> 目录下创建一个临时文件。</p>
<h3>write.rs</h3>
<blockquote>
<p>完整源码：<a href="https://github.com/hedon-rust-road/inverted-index-concurrency/blob/master/src/write.rs">write.rs</a></p>
</blockquote>
<p>准备好内存索引和临时文件，那我们就需要实现将内存索引写入到文件中的功能了。</p>
<h4>struct: InMemoryIndex</h4>
<p>我们先来分析一下如何将 <code>InMemoryIndex</code> 落盘。首先 <code>InMemoryIndex</code> 的结构如下：</p>
<pre><code class="language-rust">pub struct InMemoryIndex {
    pub word_count: usize,
    pub terms: HashMap&lt;String, Vec&lt;Hit&gt;&gt;,
    pub docs: HashMap&lt;u32, Document&gt;,
}

pub struct Document {
    pub id: u32,
    pub path: PathBuf,
}
</code></pre>
<p>其中 <code>word_count</code> 不需要存储，我们可以计算出来。那我们就需要存储索引 <code>map</code> 和文档原数据 <code>docs</code>。为了能精确定位到各个数据，我们需要：</p>
<p>terms:</p>
<ul>
<li>写入 Vec&lt;Hit&gt;</li>
</ul>
<p>docs:</p>
<ul>
<li>写入 docs 中的每个 Document<ul>
<li>写入 id</li>
<li>写入 path 大小</li>
<li>写入 path</li>
</ul>
</li>
</ul>
<p>而为了快速定位到每个 term 和 doc 的位置，我们需要下面几个值，这几个值将组合起来辅助我们快速定位 terms 或 docs，我们后面会将其称为 <strong>Entry</strong>，它包含以下几个值：</p>
<ul>
<li><code>term</code>: 索引单词。为了统一，如果 <code>term</code> 为空，则表示当前表示的是 doc，否则为 terms。</li>
<li><code>df</code>: term 的出现次数。为了统一，如果 <code>df</code> 为 0，则表示当前表示的是 doc，否则为 terms。</li>
<li><code>offset</code>: 对应的 terms 或 docs 在文件中的偏移。</li>
<li><code>nbytes</code>: 对应的 terms 或 docs 的总长度。</li>
</ul>
<p>所以文件的内存结构大概如下：</p>
<table>
<thead>
<tr>
<th>文件区域</th>
<th>描述</th>
<th>指向内容</th>
</tr>
</thead>
<tbody><tr>
<td>头部 （8 字节）</td>
<td>包含一个指向目录表开始位置的偏移量。</td>
<td>header</td>
</tr>
<tr>
<td>主条目</td>
<td>这些条目按顺序紧密存储，没有额外的元数据。这部分包含实际的数据条目。</td>
<td>terms + docs</td>
</tr>
<tr>
<td>目录表</td>
<td>存储在文件的最后，包括每个条目的术语信息、文档频率、偏移和大小。</td>
<td>entries</td>
</tr>
</tbody></table>
<p>示意图如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240423194529529.png" alt="索引文件内存结构示意图"></p>
<p>为此我们定义了 <code>IndexFileWriter</code>，它专门用于将 <code>InMemoryIndex</code> 写入到临时文件中，定义如下：</p>
<pre><code class="language-rust">/// A structure to manage writing to an index file efficiently.
pub struct IndexFileWriter {
    offset: u64,
    writer: BufWriter&lt;File&gt;,
    contents_buf: Vec&lt;u8&gt;,
}
</code></pre>
<ul>
<li><code>offset</code>: 用于追踪文件中当前的写入位置。</li>
<li><code>writer</code>: 一个缓冲写入器，它包装了一个文件，用于输出操作。</li>
<li><code>contents_buf</code>: 一个向量，用来存储内容条目，在全部写入文件之前暂存在这个缓冲区。</li>
</ul>
<p>接下来我们为 <code>IndexFileWriter</code> 实现几个方法：</p>
<ul>
<li><code>new</code>: 这是一个构造函数，它初始化文件并设置初始偏移量。在文件的开始处写入一个占位符作为头部，这个头部最终会存储主数据区的大小。</li>
<li><code>write_document</code>: 用于将一个文档以二进制格式写入到文件中，同时更新偏移量。</li>
<li><code>write_main</code>: 这个方法接受一段数据，并将它写入文件中，同时更新偏移量。</li>
<li><code>write_contents_entry</code>: 将一个内容 Entry 追加到内部的缓冲区中。Entry 包括一个术语、文档频率、术语数据的起始偏移和大小，它用于快速定位 terms 或 docs。</li>
<li><code>finish</code>: 完成文件写入过程，将内部缓冲区的内容写入文件，并更新文件头部的主数据大小。</li>
</ul>
<h4>new</h4>
<p>我们先来看构造方法：</p>
<pre><code class="language-rust">pub fn new(mut f: BufWriter&lt;File&gt;) -&gt; io::Result&lt;IndexFileWriter&gt; {
    const HEADER_SIZE: u64 = 8;
    f.write_u64::&lt;LittleEndian&gt;(0)?; // content start
    Ok(IndexFileWriter {
        offset: HEADER_SIZE,
        writer: f,
        contents_buf: vec![],
    })
}
</code></pre>
<p><strong>new</strong> 分为以下几步：</p>
<ol>
<li><strong>定义头部大小</strong>：<code>const HEADER_SIZE: u64 = 8;</code>：定义一个常量 <code>HEADER_SIZE</code>，其值为 8 字节，这表示文件头部的大小。这个头部将用于后续在文件的开始处写入主数据区的起始位置。</li>
<li><strong>写入头部占位符</strong>：<code>f.write_u64::&lt;LittleEndian&gt;(0)?;</code>：在文件的开始处写入一个 8 字节的占位符，这个值是以小端字节序（<code>LittleEndian</code>）存储的。初始时这里写入的是 0，意味着“主数据区的起始位置未知”，这个值在后续的 <code>finish</code> 函数中会被更新。</li>
<li><strong>返回一个新的 IndexFileWriter 实例</strong>：<code>Ok(IndexFileWriter { offset: HEADER_SIZE, writer: f, contents_buf: vec![], })</code>：构造并返回一个 <code>IndexFileWriter</code> 实例。这个实例的 <code>offset</code> 字段被初始化为 <code>HEADER_SIZE</code>（8 字节），表示实际数据将从文件的第 17 个字节开始写入。<code>writer</code> 字段就是传入的文件写入器，<code>contents_buf</code> 是一个新的空向量，用于临时存储内容条目数据。</li>
</ol>
<details open>
<summary>为什么这样设计？</summary>


<p>这个实现方式有几个设计上的考虑：</p>
<ol>
<li><strong>预留头部空间</strong>：通过在文件开始处预留 8 字节空间来存储主数据区的大小，这样做可以在数据写入完成后，方便地回填这个信息。这是文件格式设计中常见的做法，允许读取者快速定位主数据区和内容索引区。</li>
<li><strong>使用小端字节序</strong>：小端字节序是一种在二进制文件中常用的字节序，尤其是在 Windows 平台下。使用小端字节序可以提高文件的兼容性，并且对于多数处理器架构来说，小端字节序的读写操作更为高效。</li>
<li><strong>灵活的数据写入</strong>：通过将 <code>writer</code> 和 <code>contents_buf</code> 组合使用，这个结构体可以灵活地处理不同的数据写入需求。<code>writer</code> 直接写入文件，适合连续大块数据的写入；而 <code>contents_buf</code> 用于聚集多个小片段的数据，可以在最后统一写入，减少磁盘操作次数。</li>
</ol>
<p>总的来说，这个构造函数的实现为高效和灵活的文件写操作提供了良好的基础，同时通过合理的错误处理和数据组织方式，确保了程序的健壮性和高性能。</p>
</details>

<h4>write_main</h4>
<p>Hit 本身就是一个 Vec&lt;u8&gt;， 将其写入文件很简单，调用 <code>write_all</code>，即可，我们为其封装 <code>write_main</code> 方法：</p>
<pre><code class="language-rust">pub fn write_main(&amp;mut self, buf: &amp;[u8]) -&gt; io::Result&lt;()&gt; {
    self.writer.write_all(buf)?;
    self.offset += buf.len() as u64;
    Ok(())
}
</code></pre>
<h4>write_document</h4>
<p>为了将 Docuemnt 本以二进制结构写入到文件中，我们需要拆分成几个部分：</p>
<ol>
<li>文件 id</li>
<li>文件路径大小</li>
<li>文件路径</li>
</ol>
<p>为此我们为 <code>IndexFileWriter</code> 封装了 <code>write_document</code>：</p>
<pre><code class="language-rust">pub fn write_document(&amp;mut self, doc: &amp;Document) -&gt; io::Result&lt;()&gt; {
    self.writer.write_u32::&lt;LittleEndian&gt;(doc.id)?;
    self.writer
        .write_u64::&lt;LittleEndian&gt;(doc.path.as_os_str().len() as u64)?;
    self.writer.write_all(doc.path.as_os_str().as_bytes())?;
    self.offset += 4 + 8 + doc.path.as_os_str().len() as u64;
    Ok(())
}
</code></pre>
<h4>write_contents_entry</h4>
<p>Entry 的数据量一般较小，我们会先写入缓冲中，后面再一次性刷盘，为此我们为 <code>IndexFileWriter</code> 封装了 <code>write_contents_entry</code>：</p>
<pre><code class="language-rust">/// Appends a content entry to the internal buffer.
///
/// # Arguments
/// * `term` - The term associated with the entry
/// * `df` - Document frequency for the term
/// * `offset` - Offset where the term data starts in the file
/// * `nbytes` - Number of bytes of the term data
pub fn write_contents_entry(&amp;mut self, term: String, df: u32, offset: u64, nbytes: u64) {
    self.contents_buf.write_u64::&lt;LittleEndian&gt;(offset).unwrap();
    self.contents_buf.write_u64::&lt;LittleEndian&gt;(nbytes).unwrap();
    self.contents_buf.write_u32::&lt;LittleEndian&gt;(df).unwrap();
    let bytes = term.bytes();
    self.contents_buf
        .write_u32::&lt;LittleEndian&gt;(bytes.len() as u32)
        .unwrap();
    self.contents_buf.extend(bytes);
}
</code></pre>
<h4>finish</h4>
<p>刷盘的过程我们封装在 <code>finish</code> 中：</p>
<pre><code class="language-rust">pub fn finish(mut self) -&gt; io::Result&lt;()&gt; {
    let contents_start = self.offset;
    self.writer.write_all(&amp;self.contents_buf)?;
    self.writer.seek(SeekFrom::Start(0))?;
    self.writer.write_u64::&lt;LittleEndian&gt;(contents_start)?;
    Ok(())
}
</code></pre>
<h4>write_index_to_tmp_file</h4>
<p>综合下来，我们就可以实现最核心的函数 <code>write_index_to_tmp_file</code> 了：</p>
<pre><code class="language-rust">pub fn write_index_to_tmp_file(index: InMemoryIndex, tmp_dir: &amp;mut TmpDir) -&gt; io::Result&lt;PathBuf&gt; {
    let (filename, f) = tmp_dir.create()?;
    let mut writer = IndexFileWriter::new(f)?;

    let mut index_as_vec: Vec&lt;_&gt; = index.terms.into_iter().collect();
    index_as_vec.sort_by(|(a, _), (b, _)| a.cmp(b));

    for (term, hits) in index_as_vec {
        let df = hits.len() as u32;
        let start = writer.offset;
        for buffer in hits {
            writer.write_main(&amp;buffer)?;
        }
        let stop = writer.offset;
        writer.write_contents_entry(term, df, start, stop - start);
    }

    // if term == &quot;&quot; &amp;&amp; df == 0 { type = document }
    for (_, doc) in index.docs {
        let start = writer.offset;
        writer.write_document(&amp;doc)?;
        let stop = writer.offset;
        writer.write_contents_entry(&quot;&quot;.to_string(), 0, start, stop - start)
    }

    writer.finish()?;
    println!(&quot;wrote file {:?}&quot;, filename);
    Ok(filename)
}
</code></pre>
<ol>
<li>我们在临时目录中创建一个临时文件，并初始化 IndexFileWriter；</li>
<li>将索引的 <code>terms</code> 转换成一个向量并按照键排序；</li>
<li>对于每个 <code>term</code>，计算文档频率（<code>df</code>），记录开始和结束位置，然后调用 <code>write_main</code> 方法将数据写入文件，然后使用 <code>write_contents_entry</code> 方法写入 <code>Entry</code> 的元数据到目录表；</li>
<li>对于 <code>index.docs</code> 中的每个文档，计算起止位置，并使用一个特殊的条目（空字符串作为条目名和 0 作为文档频率）标记在文件中；</li>
<li>最后我们使用 <code>finish</code> 将缓存中所有的 <code>Entry</code> 刷盘，并设置 <code>entries</code> 的起始位置。</li>
</ol>
<p>文件的内存结构如上面给出的图一样，这里我们可以再看一次：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240423194529529-20240423195227516.png" alt="索引文件内存结构示意图"></p>
<h4>step4: start_index_writer_thread</h4>
<p>实现了将内存索引写入到文件的功能后，我们就可以继续在 <code>create.rs</code> 中实现下一个流程了：</p>
<pre><code class="language-rust">fn start_index_writer_thread(
    big_indexes: Receiver&lt;InMemoryIndex&gt;,
    output_dir: &amp;Path,
) -&gt; (Receiver&lt;PathBuf&gt;, JoinHandle&lt;io::Result&lt;()&gt;&gt;) {
    let (sender, receiver) = channel();

    let mut tmp_dir = TmpDir::new(output_dir);
    let handle = spawn(move || {
        for i in big_indexes {
            println!(&quot;word count: {}&quot;, i.word_count);
            let file = write_index_to_tmp_file(i, &amp;mut tmp_dir)?;
            if sender.send(file).is_err() {
                break;
            }
        }
        Ok(())
    });

    (receiver, handle)
}
</code></pre>
<p>在 <code>start_index_writer_thread</code> 流程中，我们将构建好的内存索引一个个写入到文件中，并将生成的文件句柄传入下一个流程。</p>
<h3>merge.rs</h3>
<blockquote>
<p>完整源码：<a href="https://github.com/hedon-rust-road/inverted-index-concurrency/blob/master/src/merge.rs">merge.rs</a></p>
</blockquote>
<p>前面 <code>start_index_writer_thread</code> 是将一个个 <code>InMemoryIndex</code> 写入到 <code>TmpDir</code> 临时目录中。现在我们要将这些临时文件合并成一个最终的索引文件，以优化查询效率和节省存储空间。</p>
<h4>srtuct: FileMerge</h4>
<p>我们定义一下结构：</p>
<pre><code class="language-rust">pub struct FileMerge {
    output_dir: PathBuf,
    tmp_dir: TmpDir,
    stacks: Vec&lt;Vec&lt;PathBuf&gt;&gt;,
}
</code></pre>
<ul>
<li><code>output_dir</code>: 用于存储最终合并文件的输出目录。</li>
<li><code>tmp_dir</code>: 前面 <code>tmp.rs</code> 定义的结构，用于管理合并过程中产生的临时文件。</li>
<li><code>stacks</code>: 这是一个二维向量，每个内部向量代表一个合并“层”，存储了该层待合并的文件路径。</li>
</ul>
<p>关于 <code>stacks</code>，再多说两点：</p>
<ul>
<li><strong>多级合并策略</strong>: <code>FileMerge</code> 使用一个多层合并策略，这种策略在处理大量文件时尤为有效。基本思想是，当一层的文件数量达到一个预设的阈值（<code>NSTREAMS</code>）时，这些文件会被合并成一个新的文件，新文件则被推送到上一层。这种层级式的处理方式可以显著减少最终合并步骤需要处理的文件数量，从而优化性能。</li>
<li><strong>动态扩展</strong>：使用 <code>Vec&lt;Vec&lt;PathBuf&gt;&gt;</code> 允许动态地添加新的合并层，这在处理不确定数量的文件时非常有用。向量的灵活性意味着无需预先知道将处理多少文件，它可以根据实际需要进行扩展。</li>
</ul>
<p>接下来我们会为 <code>FileMerge</code> 实现 2 个方法：</p>
<ul>
<li><code>add_file</code>: 添加一个文件到合并栈中，并使用多级合并策略进行合并。</li>
<li><code>finish</code>: 执行最后的合并操作，生成最终的索引文件，输出到 <code>output_dir</code> 中。</li>
</ul>
<h4>add_file</h4>
<p>首先我们来看<code>add_file</code>，它的实现如下：</p>
<pre><code class="language-rust">pub fn add_file(&amp;mut self, mut file: PathBuf) -&gt; io::Result&lt;()&gt; {
		// 从第一层开始检查
    let mut level = 0;

	  // 使用循环来处理文件的添加和可能的合并。
    loop {

      	// 如果当前的 level （层级）不存在于 stacks 中，
        // 就在 stacks 中添加一个新的空向量。
        // 这是为了存放该层级的文件。
        if level == self.stacks.len() {
            self.stacks.push(vec![]);
        }

        // 将当前的文件添加到对应层级的向量中。
        self.stacks[level].push(file);

        // 如果这个级别的堆栈已满，就合并这个级别的文件。
      	// 如果没满，则不进行合并，直接退出。
        if self.stacks[level].len() &lt; NSTREAMS {
            break;
        }

        // 创建一个新文件来存储合并结果，并更新堆栈。
        let (filename, out) = self.tmp_dir.create()?;

        // 初始化一个空的 to_merge 向量，
      	// 然后使用 mem::swap 交换当前层级的文件列表和这个空向量，
        // 这样 to_merge 向量就包含了需要合并的文件，
        // 而当前层级变为空，可以用来存放新的合并文件。
        let mut to_merge = vec![];
        mem::swap(&amp;mut self.stacks[level], &amp;mut to_merge);

        // 调用 merge_streams 函数将 to_merge 中的文件合并到新创建的文件中。
        merge_streams(to_merge, out)?;

        // 将合并后得到的新文件路径赋值给 file 变量，用于下一轮循环。
        file = filename;
        // level 加一，表示移动到下一个层级。
        level += 1;
    }
    Ok(())
}
</code></pre>
<p>这个方法通过层级的方式管理文件合并，每个层级可以有多个文件，但数量上限为 <code>NSTREAMS</code>。如果某层满了，就将该层的文件合并成一个新文件，并将这个新文件移动到上一层继续参与合并。这种设计有效地将多个文件逐步合并成一个文件，同时控制内存和 I/O 资源的使用。</p>
<p>其中 <code>merge_streams</code> 就是具体的合并过程，它的实现如下：</p>
<pre><code class="language-rust">fn merge_streams(files: Vec&lt;PathBuf&gt;, out: BufWriter&lt;File&gt;) -&gt; io::Result&lt;()&gt; {
  	// 从索引文件中构建 IndexFileReader 列表
  	let mut streams: Vec&lt;IndexFileReader&gt; = files
        .into_iter()
        .map(|p| IndexFileReader::open_and_delete(p, true))
        .collect::&lt;io::Result&lt;_&gt;&gt;()?;

  	// 针对输出文件生成一个 IndexFileWriter 用于写入索引信息
    let mut output = IndexFileWriter::new(out)?;
  	// 用于记录当前写入的位置（或者数据偏移量）。
    let mut point: u64 = 0;
  	// 记录还有数据未处理的文件流数量，用 peek() 方法检查。
    let mut count = streams.iter().filter(|s| s.peek().is_some()).count();

  	// 只要 count 大于0，表示还有文件未完全处理，就继续循环。
    while count &gt; 0 {
        let mut term = None;
        let mut nbytes = 0;
        let mut df = 0;

        // 这段代码通过遍历每个文件流，使用 peek() 方法预览每个文件的当前数据条目
        for s in &amp;streams {
            match s.peek() {
                None =&gt; {}
                Some(entry) =&gt; {
                  	// term 是空的，则说明这是表示 doc 的 entry。
                  	// 直接退出 for 循环，因为 doc 的 entry 没有顺序且唯一，不会进行累加。
                    if entry.term.is_empty() {
                        term = Some(entry.term.clone());
                        nbytes = entry.nbytes;
                        df = entry.df;
                        break;
                    }

                  	// term 不是空的，则说明这是表示 terms 的 entry。
                    // 选择词条最小的一个（字典序），并且累加其出现的频次和字节大小。
                    // 这是多路归并的核心，确保输出文件是有序的。
                    if term.is_none() || entry.term &lt; *term.as_ref().unwrap() {
                        term = Some(entry.term.clone());
                        nbytes = entry.nbytes;
                        df = entry.df
                    } else if entry.term == *term.as_ref().unwrap() {
                        nbytes += entry.nbytes;
                        df += entry.df
                    }
                }
            }
        }
        let term = term.expect(&quot;bug in algorithm&quot;);

      	// 对于每个文件流，如果当前数据条目与选择的 term 相同，
        // 则将该条目写入输出文件，并更新该流的读取位置。
      	for s in &amp;mut streams {
            if s.is_at(&amp;term) {
                s.move_entry_to(&amp;mut output)?;
                if s.peek().is_none() {
                    count -= 1;
                }
                if term.is_empty() {
                    break;
                }
            }
        }

        output.write_contents_entry(term, df, point, nbytes);
        point += nbytes
    }
    Ok(())
}
</code></pre>
<p>这里涉及到了一个新的结构 <code>IndexFileReader</code>，它是索引文件的读取器，我们将在 <code>read.rs</code> 中实现它。这里先不展开，你只需要知道：</p>
<ul>
<li><code>IndexFileReader::open_and_delete(p, true)</code>: 打开一个索引文件，并根据传入的参数判断是否要删除这个文件，在合并过程中，因为都是临时文件，所以我们会指定为删除文件。但是在后面从索引文件中重建 <code>InMemoryIndex</code> 的时候，我们不希望删除原始的索引文件。</li>
<li><code>s.peek()</code>: 查看下一个 Entry，它的返回值是 Option&lt;Entry&gt;。</li>
<li><code>s.move_entry_to(&amp;mut output)</code>: 将 <code>s.peek()</code> 指向的 Entry 写入到 output 文件中，并移动到一下 Entry。</li>
</ul>
<p>总结下来，这个函数实现多路归并的核心部分，它将多个索引文件合并成一个单一的有序文件。</p>
<h4>finish</h4>
<p>我们再来看 <code>FileMerge</code> 的另外一个方法 <code>finish</code>：</p>
<pre><code class="language-rust">pub fn finish(mut self) -&gt; io::Result&lt;()&gt; {

    // 初始化一个临时向量 tmp，用来暂存需要合并的文件路径。
    // 这个向量的容量设置为 NSTREAMS，这是预先定义的常量，表示一次可以合并的最大文件数。
    let mut tmp = Vec::with_capacity(NSTREAMS);

    // 方法遍历 self.stacks 中的每个堆栈。每个堆栈代表一个合并层级，包含若干待合并的文件。
    for stack in self.stacks {
        // 对于每个堆栈，方法使用 .into_iter().rev() 迭代器反向遍历文件，
      	// 以确保按正确的顺序处理（先进后出）。
        for file in stack.into_iter().rev() {
            // 将文件逐个添加到 tmp 向量中。
            tmp.push(file);
            // 当 tmp 的长度达到 NSTREAMS 时，
          	// 调用 merge_reversed 函数进行合并。
            if tmp.len() == NSTREAMS {
                merge_reversed(&amp;mut tmp, &amp;mut self.tmp_dir)?;
            }
        }
    }

  	// 对于剩余文件进行最终的合并。
    if tmp.len() &gt; 1 {
        merge_reversed(&amp;mut tmp, &amp;mut self.tmp_dir)?;
    }

  	// 最后应该只有一个最终文件
    assert!(tmp.len() == 1);
    match tmp.pop() {
      	// 对文件进行重命名
        Some(last_file) =&gt; fs::rename(last_file, self.output_dir.join(MERGED_FILENAME)),
        None =&gt; Err(io::Error::new(
            io::ErrorKind::Other,
            &quot;no ducuments were parsed or none contained any words&quot;,
        )),
    }
}
</code></pre>
<p>这里涉及到了另外一个函数 <code>merge_reversed</code>：</p>
<pre><code class="language-rust">fn merge_reversed(filenames: &amp;mut Vec&lt;PathBuf&gt;, tmp_dir: &amp;mut TmpDir) -&gt; io::Result&lt;()&gt; {
    filenames.reverse();
    let (merge_filename, out) = tmp_dir.create()?;
    let mut to_merge = Vec::with_capacity(NSTREAMS);
    mem::swap(filenames, &amp;mut to_merge);
    merge_streams(to_merge, out)?;
    filenames.push(merge_filename);
    Ok(())
}
</code></pre>
<p>它其实就是将 <code>filenames</code> 翻转，清空并将内容转移到 <code>to_merge</code>，然后调用 <code>merge_streams</code> 合并，并将合并后的文件重新放回被清空的 <code>filenames</code>，也就是我们在 <code>finish</code> 中声明的 <code>tmp</code> 变量。</p>
<details open>
<summary>为什么这里需要翻转 filenames？</summary>


<p>假设 NSTREAMS = 3，我们执行 <code>add_file</code>，从 <code>file1</code> 到 <code>file8</code>，那么过程如下：</p>
<table>
<thead>
<tr>
<th>Action</th>
<th>Stack 0</th>
<th>Stack 1</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Add file1</td>
<td>file1</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Add file2</td>
<td>file1, file2</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Add file3</td>
<td>file1, file2, file3</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Merge S1</td>
<td>(empty)</td>
<td>merge1</td>
<td><code>merge1</code> is the result of merging file1-file3</td>
</tr>
<tr>
<td>Add file4</td>
<td>file4</td>
<td>merge1</td>
<td></td>
</tr>
<tr>
<td>Add file5</td>
<td>file4, file5</td>
<td>merge1</td>
<td></td>
</tr>
<tr>
<td>Add file6</td>
<td>file4, file5, file6</td>
<td>merge1</td>
<td></td>
</tr>
<tr>
<td>Merge S2</td>
<td>(empty)</td>
<td>merge1, merge2</td>
<td><code>merge2</code> is the result of merging file4-file6</td>
</tr>
<tr>
<td>Add file7</td>
<td>file7</td>
<td>merge1, merge2</td>
<td></td>
</tr>
<tr>
<td>Add file8</td>
<td>file7, file8</td>
<td>merge1, merge2</td>
<td>Trigger merge because 8 files are reached</td>
</tr>
</tbody></table>
<p>最后我们获得的结果是：</p>
<table>
<thead>
<tr>
<th>stack0</th>
<th>stack1</th>
</tr>
</thead>
<tbody><tr>
<td>file7, file8</td>
<td>merge1, merge2</td>
</tr>
</tbody></table>
<p>按照文件的添加顺序，我们期望在 <code>finish</code> 中合并的顺序应该是：merge1, merge2, file7, file8。所以我们遍历 <code>stacks</code> 的时候，从第 1 层开始遍历的话，我们就需要反向遍历 <code>rev()</code>，这个时候我们组成的 <code>tmp</code> 就是：file8, file7, merge2, merge1。最后我们传入 <code>merge_reversed</code> 的时候，再进行 <code>reverse()</code>，就可以获得我们期望的顺序 merge1, merge2, file7, file8。</p>
</details>

<p>回过头来，我们总结一下 <code>finish</code>：这个方法通过多级合并的方式，逐层处理并最终合并所有文件到一个文件。这个方法确保在多个文件频繁合并的环境中，能有效地管理和减少临时存储使用，并保持合并操作的效率。通过最后的重命名操作，它还处理了文件的最终存放，确保合并结果的正确性和可用性。</p>
<p>实现了 <code>merge.rs</code> 的相关内容，我们就可以来实现 <code>create.rs</code> 中的最后一步了。</p>
<h4>step5: merge_index_files</h4>
<p>我们将第 4 阶段构建的临时文件合并成一个最终的索引文件并输出到 <code>output_dir</code> 目录中。</p>
<pre><code class="language-rust">fn merge_index_files(files: Receiver&lt;PathBuf&gt;, output_dir: &amp;Path) -&gt; io::Result&lt;()&gt; {
    let mut merge = FileMerge::new(output_dir);
    for file in files {
        merge.add_file(file)?;
    }
    merge.finish()
}
</code></pre>
<h4>run_pipeline</h4>
<p>至此，我们就完成了并发构建倒排索引的 5 个步骤了，对其进行组织，就可以实现我们的并发构建函数 <code>run_pipeline</code>：</p>
<pre><code class="language-rust">fn run_pipeline(documents: Vec&lt;PathBuf&gt;, output_dir: PathBuf) -&gt; io::Result&lt;()&gt; {
    // Launch all five stages of the pipeline.
    let (texts, h1) = start_file_reader_thread(documents);
    let (pints, h2) = start_file_indexing_thread(texts);
    let (gallons, h3) = start_in_memory_merge_thread(pints);
    let (files, h4) = start_index_writer_thread(gallons, &amp;output_dir);
    let result = merge_index_files(files, &amp;output_dir);

    // Wait for threads to finish, holding on to any errors that they encounter.
    let r1 = h1.join().unwrap();
    h2.join().unwrap();
    h3.join().unwrap();
    let r4 = h4.join().unwrap();

    // Return the first error encountered, if any.
    // (As it happens, h2 and h3 can&#39;t fail: those threads
    // are pure in-memory data processing.)
    r1?;
    r4?;
    result
}
</code></pre>
<h3>read.rs</h3>
<blockquote>
<p>完整源码：<a href="https://github.com/hedon-rust-road/inverted-index-concurrency/blob/master/src/read.rs">read.rs</a></p>
</blockquote>
<p>在 <code>merge.rs</code> 中，我们还剩最后一个结构没有解析，那就是 <code>IndexFileReader</code>，它是索引文件的读取器。</p>
<h4>struct: IndexFileReader</h4>
<pre><code class="language-rust">pub struct IndexFileReader {
    pub terms_docs: BufReader&lt;File&gt;,
    entries: BufReader&lt;File&gt;,
    next: Option&lt;Entry&gt;,
}

pub struct Entry {
    pub term: String,
    pub df: u32,
    pub offset: u64,
    pub nbytes: u64,
}
</code></pre>
<p>我们在 <code>IndexFileReader</code> 结构体中定义两个 <code>BufReader&lt;File&gt;</code> ，这是为了有效管理和操作索引文件中的不同数据段。具体来说，这种设计使得代码能够更加灵活和高效地处理索引文件中的“主数据区”和“内容表区”。</p>
<p>即用来分别处理下图的 <code>terms&amp;doc</code> 和 <code>entries</code> 两个区域：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240423194529529-20240423195227516.png" alt="索引文件内存结构示意图"></p>
<p>这有几个好处：</p>
<ul>
<li><strong>独立的文件指针</strong>：每个 <code>BufReader&lt;File&gt;</code> 维护自己的文件读取位置（文件指针）。这意味着读取或搜索内容表时，不会影响主数据区的文件指针，反之亦然。这样可以避免频繁地重新定位文件指针，提高文件操作的效率。</li>
<li><strong>缓冲读取</strong>：<code>BufReader</code> 提供了缓冲读取功能，可以减少直接对硬盘的读取次数，从而优化读取性能。对于需要频繁读取小块数据的索引操作，使用缓冲读取可以显著提高效率。</li>
<li><strong>并行操作</strong>：在多线程环境中，可能需要同时读取主数据区和内容表区。使用两个独立的 <code>BufReader</code> 实例可以简化并行读取的管理，每个读取操作都可以在不干扰另一个操作的情况下独立进行。</li>
</ul>
<p><code>Entry</code> 就是我们在 <code>write.rs</code> 中 <code>write_contents_entry</code> 时传入的参数，这里我们将其封装成一个 struct，再次回顾下这几个字段的含义：</p>
<ul>
<li><code>term</code>: 索引单词。为了统一，如果 <code>term</code> 为空，则表示当前表示的是 doc，否则为 terms。</li>
<li><code>df</code>: term 的出现次数。为了统一，如果 <code>df</code> 为 0，则表示当前表示的是 doc，否则为 terms。</li>
<li><code>offset</code>: 对应的 terms 或 docs 在文件中的偏移。</li>
<li><code>nbytes</code>: 对应的 terms 或 docs 的总长度。</li>
</ul>
<h4>read_entry</h4>
<p>这里我们重点解释一下 <code>read_entry</code> 方法，其他的都比较简单，请在源码中查找。</p>
<pre><code class="language-rust">fn read_entry(f: &amp;mut BufReader&lt;File&gt;) -&gt; io::Result&lt;Option&lt;Entry&gt;&gt; {
  	// 获取偏移值
    let offset = match f.read_u64::&lt;LittleEndian&gt;() {
        Ok(value) =&gt; value,
        Err(err) =&gt; {
            if err.kind() == io::ErrorKind::UnexpectedEof {
                return Ok(None);
            } else {
                return Err(err);
            }
        }
    };

  	// 读取 nbytes
    let nbytes = f.read_u64::&lt;LittleEndian&gt;()?;
  	// 读取 df
    let df = f.read_u32::&lt;LittleEndian&gt;()?;
  	// 读取 term_len，并初始化一块内存 bytes 用来读取完整的 term
    let term_len = f.read_u32::&lt;LittleEndian&gt;()? as usize;
    let mut bytes = vec![0; term_len];
    f.read_exact(&amp;mut bytes)?;
    let term = match String::from_utf8(bytes) {
        Ok(s) =&gt; s,
        Err(_) =&gt; return Err(io::Error::new(io::ErrorKind::Other, &quot;unicode fail&quot;)),
    };

  	// 返回构建的 Entry
    Ok(Some(Entry {
        term,
        df,
        offset,
        nbytes,
    }))
}
</code></pre>
<p>结合下面这张图，很容易理解 <code>read_entry</code> 就是前面 <code>write_contents_entry</code> 的逆向过程。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240423224035544.png" alt="entries 区域布局，每个 entry 紧贴排布"></p>
<h3>create.rs</h3>
<blockquote>
<p>完整源码：<a href="https://github.com/hedon-rust-road/inverted-index-concurrency/blob/master/src/bin/create.rs">create.rs</a></p>
</blockquote>
<p>至此，我们就分析完并发构建索引的整个过程了，在 <code>create.rs</code> 中，我们使用 <code>clap</code> 命令解析框架来构建一个 CLI 工具用以支持构建索引，我们同时支持单线程构建和并发构建，具体可看完整源码。</p>
<p>如果对 <code>clap</code> 不熟悉的读者，可参考：<a href="/blog/rust-crate-clap/">深入探索 Rust 的 clap 库：命令行解析的艺术</a></p>
<pre><code class="language-rust">#[derive(Parser)]
struct Opts {
    #[arg(short, long, default_value_t = false, help = &quot;Default false&quot;)]
    single_threaded: bool,

    #[arg(required = true)]
    filenames: Vec&lt;String&gt;,
}

fn main() {
    let opts = Opts::parse();
    match run(opts.filenames, opts.single_threaded) {
        Ok(()) =&gt; {}
        Err(err) =&gt; println!(&quot;error: {}&quot;, err),
    }
}
</code></pre>
<h2>搜索功能</h2>
<p>在《Rust 程序设计（第二版）》中，作者并没有实现搜索功能，笔者对其进行扩展，目标是对标我们前篇所构建的 <a href="/blog/rust-action-inverted-index-demo/">Rust 实战丨倒排索引</a>。这个搜索功能，会根据现有的索引文件重建内存索引 <code>InMemoryIndex</code>，支持指定 <code>term</code> 进行搜索，并将包含这个 <code>term</code> 的文件在响应的位置中进行高亮显示并输出到终端。</p>
<h3>search.rs</h3>
<blockquote>
<p>完整源码：<a href="https://github.com/hedon-rust-road/inverted-index-concurrency/blob/master/src/bin/search.rs">search.rs</a></p>
</blockquote>
<p>程序入口如下所示，比较简单，就不赘述了。</p>
<pre><code class="language-rust">#[derive(Parser)]
struct Opts {
    #[arg(short, long, required = true, help = &quot;Specify index file path&quot;)]
    index_file: String,
    #[arg(short, long, required = true, help = &quot;Specify search term&quot;)]
    term: String,
}

fn main() -&gt; io::Result&lt;()&gt; {
    let opts = Opts::parse();
    let index = InMemoryIndex::from_index_file(opts.index_file)?;
    index.search(&amp;opts.term)?;
    Ok(())
}
</code></pre>
<p>这里有 2 个核心逻辑：</p>
<ul>
<li><code>InMemoryIndex::from_index_file</code>: 根据索引文件重建内存索引。</li>
<li><code>index.search(term)</code>: 搜索。</li>
</ul>
<h3>index.rs</h3>
<p>我们在 <code>index.rs</code> 中为 <code>InMemoryIndex</code> 实现上述 2 个方法。</p>
<h4>from_index_file</h4>
<pre><code class="language-rust">pub fn from_index_file&lt;P: AsRef&lt;Path&gt;&gt;(filename: P) -&gt; io::Result&lt;InMemoryIndex&gt; {
    let mut index = InMemoryIndex::new();

  	// 获取 IndexFileReader
    let mut reader = IndexFileReader::open_and_delete(filename, false)?;

  	// 依次解析每个 Entry
    while let Some(entry) = reader.iter_next_entry() {
        if entry.term.is_empty() &amp;&amp; entry.df == 0 {
            // 当前 Entry 指向的是一个 Document。
          	// 通过 terms_docs 读取 Document 所在位置并进行解析。
            reader.terms_docs.seek(io::SeekFrom::Start(entry.offset))?;
            let doc_id = reader.terms_docs.read_u32::&lt;LittleEndian&gt;()?;
            let path_len = reader.terms_docs.read_u64::&lt;LittleEndian&gt;()?;
            let mut path = vec![0u8; path_len as usize];
            reader.terms_docs.read_exact(&amp;mut path)?;
            index.docs.insert(
                doc_id,
                Document {
                    id: doc_id,
                    path: vec_to_pathbuf(path),
                },
            );
        } else {
            // 当前 Entry 指向的是一个 terms。
          	// 通过 terms_docs 读取 terms 所在位置并进行解析。
            let mut hits = vec![];
            reader.terms_docs.seek(io::SeekFrom::Start(entry.offset))?;
            let mut data = vec![0u8; entry.nbytes as usize];
            reader.terms_docs.read_exact(&amp;mut data)?;
            let mut cursor = Cursor::new(data);

            let mut i = entry.df;
            let mut has_hit = false;
            let mut quit = false;

            while i &gt; 0 &amp;&amp; !quit {
                let mut hit = Vec::with_capacity(4 + 4 + 4); // cannot use vec![0;12]
                loop {
                    if let Ok(item) = cursor.read_i32::&lt;LittleEndian&gt;() {
                        // the start of next hit
                        if item == Self::HITS_SEPERATOR &amp;&amp; has_hit {
                            hits.push(hit);
                            i -= 1;
                            index.word_count -= 2;
                            hit = Vec::with_capacity(4 + 4 + 4);
                        }
                        has_hit = true;
                        hit.write_u32::&lt;LittleEndian&gt;(item as u32).unwrap();
                        index.word_count += 1;
                    } else {
                        quit = true;
                        if !hit.is_empty() {
                            hits.push(hit);
                            index.word_count -= 2;
                        }
                        break;
                    }
                }
            }
            index.terms.insert(entry.term, hits);
        }
    }
    index.word_count /= 2;
    Ok(index)
}
</code></pre>
<h4>search</h4>
<pre><code class="language-rust">pub fn search(&amp;self, term: &amp;str) -&gt; io::Result&lt;()&gt; {
  	// 获取 term 出现的位置
    let m: Option&lt;&amp;Vec&lt;Vec&lt;u8&gt;&gt;&gt; = self.terms.get(term);
    if m.is_none() {
        println!(&quot;can not found {} in all documents&quot;, term);
        return Ok(());
    }
    let hits = m.unwrap();

  	// 遍历每个出现的位置
    for hit in hits {
        let mut cursor = Cursor::new(hit);
        let _ = cursor.read_i32::&lt;LittleEndian&gt;().unwrap();

      	// 获取文档原始信息
        let document_id = cursor.read_u32::&lt;LittleEndian&gt;().unwrap();
        let doc = self.docs.get(&amp;document_id);
        if doc.is_none() {
            println!(&quot;cannot found document {}&quot;, document_id);
            continue;
        }
        let doc = doc.unwrap();

      	// hits 存储的内容：[HITS_SEPERATOR, document_id, start_pos1, end_pos1, ...]
      	// 解析 term 出现在 doc 中的每个位置
        let mut poss = Vec::with_capacity(hits.len() / 4);
        let mut pos = TokenPos::default();
        let mut has_pos = false;
        while let Ok(p) = cursor.read_u32::&lt;LittleEndian&gt;() {
            if !has_pos {
                pos.start_pos = p;
                has_pos = true;
            } else {
                pos.end_pos = p;
                poss.push(pos);
                pos = TokenPos::default();
                has_pos = false;
            }
        }

      	// 对每个出现的位置进行高亮处理
        let result = highlight_file(doc.path.clone(), &amp;mut poss)?;
      	// 输出高亮后的结果
        println!(&quot;\n{:?}: \n{}&quot;, doc.path, result);
    }
    Ok(())
}
</code></pre>
<hr>
<p>至此，我们就实现了高并发构建索引和根据索引进行搜索的功能，本篇某些部分可能比较复杂，篇幅也比较冗长，笔者在阅读书中原实现的时候，也是获益颇丰，想不到一个简单的倒排索引竟涉及这么多的处理细节。也希望本篇文章能对感兴趣的读者有些许帮助。</p>
<p>peace! enjoy coding~</p>
<h2>绘图工具</h2>
<ul>
<li><a href="https://link.zhihu.com/?target=https%3A//excalidraw.com/">https://excalidraw.com/</a></li>
</ul>
<h2>参考资料</h2>
<ul>
<li><a href="https://link.zhihu.com/?target=https%3A//en.wikipedia.org/wiki/Inverted_index">维基百科·倒排索引</a></li>
<li><a href="https://link.zhihu.com/?target=https%3A//book.douban.com/subject/36547630/">Rust 程序设计（第二版）</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨倒排索引</title>
      <link>https://hedon.top/blog/rust-action-inverted-index-demo/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-inverted-index-demo/</guid>
      <pubDate>Mon, 15 Apr 2024 10:24:17 GMT</pubDate>
      <description>本文将使用 Rust 实现一个简单的倒排索引。</description>
      <category>rust</category><category>倒排索引</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<h2>引言</h2>
<p>倒排索引（Inverted Index）是一种索引数据结构，用于存储某个单词（词项）在一组文档中的所有出现情况的映射。它是搜索引擎执行快速全文搜索的核心技术，也广泛用于数据库中进行文本搜索。我们熟知的 ElasticSearch 最核心底层原理便就是倒排索引。</p>
<p>倒排索引的基本原理是<strong>将文档中的词汇进行反转，形成倒排列表</strong>。 在倒排列表中，每个词汇都对应一个文档标识符的列表，这些标识符指明了该词汇出现在哪些文档中。 通过查询倒排列表，可以快速地找到包含特定词汇的文档。</p>
<p>本文将使用 Rust 语言来实现一个简单的倒排索引，包括倒排索引的构建和搜索过程。在下一篇文章中，笔者会基于《Rust 程序设计（第二版）》并发编程篇章，解读该书作者是如何基于 Rust 通道实现更优秀、更高性能的倒排索引。</p>
<h2>可以学到</h2>
<ol>
<li>倒排索引的原理、优势和使用</li>
<li>常用 crate：<code>colored</code>、<code>regex</code></li>
<li>Rust HashMap</li>
<li>Rust 迭代器</li>
</ol>
<h2>开发思路</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/imgimage-20240414112337590.png" alt="倒排索引构建过程"></p>
<p>一个简单的倒排索引开发思路大概如上图所示：</p>
<ol>
<li>读取文档</li>
<li>分词</li>
<li>构建每个词到每个文档的映射</li>
</ol>
<h2>开发过程</h2>
<blockquote>
<p>完整源码位于：<a href="https://github.com/hedon-rust-road/inverted-index">inverted_index</a>。</p>
</blockquote>
<h3>最终效果</h3>
<pre><code class="language-rust">fn main() {
    let mut index = InvertedIndex::new();
    index.add(1, &quot;Rust is safe and fast.&quot;);
    index.add(2, &quot;Rust is a systems programming language.&quot;);
    index.add(3, &quot;Programming in Rust is fun.&quot;);

    // query &quot;Rust&quot;
    let results = index.query(&quot;Rust&quot;);
    for result in results {
        println!(&quot;{}&quot;, result);
    }

    println!(&quot;&quot;);

    // query &quot;Programming&quot;
    let results = index.query(&quot;Programming&quot;);
    for result in results {
        println!(&quot;{}&quot;, result);
    }
}
</code></pre>
<p>执行：</p>
<pre><code class="language-bash">cargo run
</code></pre>
<p>输出：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/imgimage-20240414122848805.png" alt="inverted index 输出示例"></p>
<h3>版本声明</h3>
<pre><code class="language-toml">[package]
name = &quot;inverted_index&quot;
version = &quot;0.1.0&quot;
edition = &quot;2021&quot;

[dependencies]
colored = &quot;2.1.0&quot;
regex = &quot;1.10.4&quot;
</code></pre>
<h3>项目准备</h3>
<p>首先我们创建项目：</p>
<pre><code class="language-bash">cargo new inverted_index
</code></pre>
<p>准备依赖：</p>
<pre><code class="language-bash">cargo add regex
cargo add colored
</code></pre>
<ul>
<li>colored: 终端高亮，后面我们将实现搜索词的高亮显示，使结果更美观。</li>
<li>regex: 正则库，用于实现不区分大小写替换匹配到的搜索词。</li>
</ul>
<h3>实现过程</h3>
<p>首先我们定义两个数据结构：</p>
<pre><code class="language-rust">struct Document {
    id: usize,
    content: String,
}

struct InvertedIndex {
    indexes: HashMap&lt;String, Vec&lt;usize&gt;&gt;,
    documents: HashMap&lt;usize, Document&gt;,
}

impl InvertedIndex {
    fn new() -&gt; InvertedIndex {
        InvertedIndex {
            indexes: HashMap::new(),
            documents: HashMap::new(),
        }
    }
}
</code></pre>
<ul>
<li>Document: 封装原始文档</li>
<li>IndexedIndex: 我们将构建的倒排索引</li>
</ul>
<p>接下来我们要实现 2 个辅助函数，一个是 <code>tokenize</code>，用于将原始的文档信息拆分成独立的词（word/term），另一个是 <code>hightlight</code>，用于将匹配到的文本进行替换，使其在中断可以以<font color="purple">紫色</font>输出。</p>
<p><code>tokenize</code> 实现如下：</p>
<pre><code class="language-rust">fn tokenize(text: &amp;str) -&gt; Vec&lt;&amp;str&gt; {
    text.split(|ch: char| !ch.is_alphanumeric())
        .filter(|c| !c.is_empty())
        .collect()
}

#[test]
fn tokenize_test() {
    assert_eq!(
        tokenize(&quot;This is\nhedon&#39;s tokenize function.&quot;),
        vec![&quot;This&quot;, &quot;is&quot;, &quot;hedon&quot;, &quot;s&quot;, &quot;tokenize&quot;, &quot;function&quot;]
    )
}
</code></pre>
<p><code>highlight</code> 实现如下：</p>
<pre><code class="language-rust">fn highlight(term: &amp;str, content: &amp;str) -&gt; String {
    let regex = Regex::new(&amp;format!(r&quot;(?i){}&quot;, term)).unwrap();
    let highlighted_content = regex
        .replace_all(content, |caps: &amp;regex::Captures| {
            caps[0].to_string().purple().to_string()
        })
        .to_string();
    highlighted_content
}

#[test]
fn highlight_test() {
    assert_eq!(
        highlight(&quot;programming&quot;, &quot;I like programming with Rust Programming&quot;),
        &quot;I like \u{1b}[35mprogramming\u{1b}[0m with Rust \u{1b}[35mProgramming\u{1b}[0m&quot;
    );
}
</code></pre>
<p>现在我们可以为 <code>InvertedIndex</code> 实现构建索引的方法 <code>add</code> 了，它会接收原始文档，对其进行分词，并将记录每个分词和文档 id 的映射。</p>
<pre><code class="language-rust">impl InvertedIndex {
  	fn add(&amp;mut self, doc_id: usize, content: &amp;str) {
        let content_lowercase = content.to_lowercase();
        let words = tokenize(&amp;content_lowercase);
        for word in words {
            self.indexes
                .entry(word.to_string())
                .or_insert(vec![])
                .push(doc_id)
        }

        self.documents.insert(
            doc_id,
            Document {
                id: doc_id,
                content: content.to_string(),
            },
        );
    }
}
</code></pre>
<p>然后我们再实现对应的根据分词 <code>term</code> 搜索原始文档的方法：</p>
<pre><code class="language-rust">impl InvertedIndex {
  	fn query(&amp;self, term: &amp;str) -&gt; Vec&lt;String&gt; {
        let term_lowercase = term.to_lowercase();
        if let Some(doc_ids) = self.indexes.get(&amp;term_lowercase) {
            doc_ids
                .iter()
                .filter_map(|doc_id| {
                    self.documents
                        .get(doc_id)
                        .map(|doc| highlight(&amp;term_lowercase, &amp;doc.content))
                })
                .collect()
        } else {
            Vec::new()
        }
    }
}
</code></pre>
<p>这样一个简单的倒排索引构建和搜索功能就完成了，具体的执行效果你可以回到前面的「最终效果」进行查阅。</p>
<h2>总结预告</h2>
<p>本文实现的倒排索引虽然非常简单，但是也基本体现了倒排索引的最核心思想和应用方式了。在《Rust 程序设计（第二版）》的并发编程篇章中，该书提出了使用通道 channel 来并发构建倒排索引，同时给出了更加丰富和优雅的实现。在下篇文章中，笔者将阅读这部分的源码，解析并重现当中的实战过程，并进行适当扩展。</p>
<p>peace! enjoy coding~</p>
<h2>绘图工具</h2>
<ul>
<li><a href="https://excalidraw.com/">https://excalidraw.com/</a></li>
</ul>
<h2>参考资料</h2>
<ul>
<li><a href="https://en.wikipedia.org/wiki/Inverted_index">维基百科·倒排索引</a></li>
<li><a href="https://book.douban.com/subject/36547630/">Rust 程序设计（第二版）</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>深入浅出 Go 语言的 defer 机制</title>
      <link>https://hedon.top/blog/go-defer/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-defer/</guid>
      <pubDate>Thu, 28 Mar 2024 20:34:50 GMT</pubDate>
      <description>本文将深入浅出地介绍 defer 的工作原理，探究其背后的机制，并通过丰富的案例来展示它的实际应用。</description>
      <category>Go</category>
      <content:encoded><![CDATA[<p>Go 语言以其简洁的语法和强大的并发支持而闻名。在这些特性中，<code>defer</code> 语句是 Go 语言提供的一项独特功能，它允许我们推迟函数的执行直到包含它的函数即将返回。这个简单而强大的机制不仅可以帮助我们处理资源释放和错误处理，还能让代码更加简洁和安全。本文将深入浅出地介绍 <code>defer</code> 的工作原理，探究其背后的机制，并通过丰富的案例来展示它的实际应用。</p>
<p>笔者本来以为 Go 语言的 <code>defer</code> 其实东西不多，就是类似于“栈”的操作罢了，无非就是用于释放资源、后进先出而已。但是最近在阅读完《深入理解 Go 语言》、《Go 底层原理剖析》和《Go 语言设计与实现》中关于 <code>defer</code> 的篇章。发现其中隐含的道道和坑还是比较有意思的，特此整理这篇文章，希望能对 Go <code>defer</code> 原理感兴趣的读者带来一些帮助。</p>
<p>本文具体会包含以下内容：</p>
<ul>
<li><strong><code>defer</code> 机制简介</strong>：介绍 <code>defer</code> 关键字的基本概念和它在 Go 语言中的作用。</li>
<li><strong><code>defer</code> 的工作原理</strong>：深入探讨 <code>defer</code> 在函数执行结束时如何工作的细节。</li>
<li><strong><code>defer</code> 的执行顺序</strong>：解释 <code>defer</code> 语句是如何按照后进先出（LIFO）的顺序执行的。</li>
<li><strong>参数预计算和值传递</strong>：讨论 <code>defer</code> 语句中参数是如何被预先计算和传递的。</li>
<li><strong>环境变量和闭包</strong>：探讨 <code>defer</code> 如何与闭包一起工作，以及如何捕获和影响环境变量。</li>
<li><strong><code>defer</code> 与错误处理</strong>：说明如何利用 <code>defer</code> 和 <code>recover</code> 进行错误处理和异常捕获。</li>
<li><strong><code>defer</code> 的实现细节</strong>：深入分析 <code>defer</code> 的不同实现策略，包括堆上分配、栈上分配和开放编码。</li>
</ul>
<h2>版本声明</h2>
<ul>
<li>Go1.22</li>
</ul>
<h2>思维导图</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/Go%20defer.png" alt="Go defer"></p>
<h2>核心要点</h2>
<p>对于后面将要分析的各种各样的情况，在分析的时候只要遵循以下几个核心点，基本上就不会跑偏：</p>
<ol>
<li>延迟执行：在函数结束时执行，包括正常返回或遭遇 panic。</li>
<li>栈式执行顺序：后定义的 <code>defer</code> 先执行（LIFO）。</li>
<li>参数预计算：<code>defer</code> 语句定义时即计算并固定参数值。</li>
<li>值传递原则：<code>defer</code> 拷贝参数，使用定义时的值。</li>
<li>环境变量捕获：在 <code>defer</code> 中可以跟一个闭包，闭包可以捕获环境变量，当然这包括具名返回值。</li>
</ol>
<p>特别说明的是，虽然我们通常将 <code>defer</code> 想象为使用栈进行管理，但是实际实现上，<code>defer</code> 并不都是存放在栈上的，我们后面会具体分析到。这种实现细节通常对于编写正确的 Go 代码并不重要，但了解这一点对于深入理解语言内部机制可能是有帮助的。</p>
<h2>基本用法</h2>
<p>在 Go 语言中，<code>defer</code> 语句通常用于确保一个函数调用在程序执行结束时发生，常见的用例包括文件关闭、锁释放、资源回收等。</p>
<pre><code class="language-go">func readFile(filename string) error {
    f, err := os.Open(filename)
    if err != nil {
        return err
    }
    // 确保文件在函数返回时关闭
    defer f.Close()

    // ... 处理文件 ...

    return nil
}
</code></pre>
<p>在上面的例子中，<code>defer f.Close()</code> 保证了无论 <code>readFile</code> 函数如何返回（正常返回或发生错误），<code>f.Close()</code> 都会被调用，从而避免了资源泄露。</p>
<h2>执行顺序</h2>
<p><code>defer</code> 的执行顺序是先进后出，即“栈”操作。这里借用刘丹冰老师的一张图来演示这个过程：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/1651037338287-fd17c81d-a1ad-4bc7-ae7e-eec8a264af5f.jpeg" alt="Go defer 执行顺序"></p>
<p>我们可以通过以下代码进行验证：</p>
<pre><code class="language-go">func func1() {
	fmt.Println(&quot;func1...&quot;)
}

func func2() {
	fmt.Println(&quot;func2...&quot;)
}

func func3() {
	fmt.Println(&quot;func3...&quot;)
}

func main() {
	defer func1()
	defer func2()
	defer func3()
}
</code></pre>
<p>输出如下：</p>
<pre><code>func3...
func2...
func1...
</code></pre>
<h2>参数求值与陷阱</h2>
<p>关于 <code>defer</code> 参数这一块，是一个比较容易出错的地方。我们先来看一个例子，你可以分析下它的输出会是什么？</p>
<pre><code class="language-go">func printI(i int) {
	fmt.Println(&quot;printI i:&quot;, i)
}

func main() {
	i := 10
	defer printI(i * 10)
	i = i + 1
	fmt.Println(&quot;main i:&quot;, i)
}
</code></pre>
<p>按照我们之前总结的核心点：<strong>参数预计算：<code>defer</code> 语句定义时即计算并固定参数值</strong>。具体来说，在把 <code>defer</code> 压入“栈”时，会同时压入<strong>函数地址</strong>和<strong>函数形参</strong>，也就是会在这个时候就把参数先算好。所以在执行到第 7 行代码的时候，就会把 <code>i*10</code> 算好，然后同 <code>printI</code> 一同压入到延迟执行栈中。</p>
<p>所以最后的结果就是：</p>
<pre><code>main i: 11
printI i: 100
</code></pre>
<p>关于<strong>参数值传递</strong>，笔者这里再举两个例子进行比较，体会后你应该就理解了。</p>
<p>第一个例子中，<code>defer</code> 后面参数是指针，本质上<strong>值传递</strong>，但是拷贝的是指针，所以在 <code>defer</code> 中修改的东西，最后会反馈到指针指向的对象，所以对 <code>testUser</code> 的返回值是有影响的。</p>
<pre><code class="language-go">type User struct{
	name string
}

func testUser() *User {
	user := &amp;User{}
	user.name = &quot;name-1&quot;

	defer func(u *User) {
		u.name = &quot;name-defer&quot;
	}(user)

	user.name = &quot;name-2&quot;
	return user
}

func main() {
	user := testUser()
	fmt.Println(user)
}
// &amp;{name-defer}
</code></pre>
<p>第二个例子中，我们传入的就是结构体示例本身了，因为值传递，即拷贝了一份新的 <code>user</code>，所以闭包内的修改对外面是不产生影响的。</p>
<pre><code class="language-go">type User struct {
	name string
}

func testUser() User {
	user := User{}
	user.name = &quot;name-1&quot;

	defer func(u User) {
		u.name = &quot;name-defer&quot;
	}(user)

	user.name = &quot;name-2&quot;
	return user
}

func main() {
	user := testUser()
	fmt.Println(user)
}
// {name-2}
</code></pre>
<h2>环境变量捕获</h2>
<p>将上面的一个例子进行简单修改，会输出什么呢？</p>
<pre><code class="language-go">func printI(i int) {
	fmt.Println(&quot;printI i:&quot;, i)
}

func main() {
	i := 10
	defer func() {
		printI(i * 10)
	}()
	i = i + 1
	fmt.Println(&quot;main i:&quot;, i)
}
</code></pre>
<p>这个时候其实没有参数，所以会直接将下面闭包压入延迟栈中。</p>
<pre><code class="language-go">func() {
  printI(i * 10)
}
</code></pre>
<p>而闭包是可以捕获环境变量的，所以在 <code>main</code> return 后，<code>defer</code> 可以捕获到 <code>i</code> 的值，为更新后的 <code>i+1</code>，最后再进行 <code>printI(i * 10)</code>。</p>
<p>所以输出结果是：</p>
<pre><code class="language-go">main i: 11
printI i: 110
</code></pre>
<p>所以说，<code>defer</code> 后面的闭包，是可以捕获环境变量的，如果这个变量是返回值的话，那么理所应当也是可以对其产生作用的，如：</p>
<pre><code class="language-go">func getI() (i int) {
	i = 1
	defer func() {
		i *= 10
	}()
	return 20
}

func main() {
	fmt.Println(getI())
}
</code></pre>
<p>这段代码中，<code>getI</code> 的返回值是有名字的 <code>i</code>，<code>getI</code> 执行了 <code>return 20</code>，其实就是将 <code>i</code> 设置为 <code>20</code>，所以在执行到 <code>defer </code> 闭包的时候，捕获到了 <code>i=20</code>，并将其进行了修改。所以最终输出：</p>
<pre><code class="language-go">200
</code></pre>
<h2>错误处理与 defer</h2>
<p>我们都知道 Go 程序中遇到 <code>panic</code> 就会中断后面的执行流程直接返回，这个时候我们可以在 <code>defer</code> 中结合 <code>recover</code> 来捕获这个 <code>panic</code>，从而保护程序不崩溃。</p>
<p>如：</p>
<pre><code class="language-go">func panicAndRecover() {
	defer func() {
		if err := recover(); err != nil {
			fmt.Println(err)
		}
	}()
	fmt.Println(&quot;函数中正常流程&quot;)
	panic(&quot;出现异常&quot;)
	fmt.Println(&quot;panic 后的语句永远执行不到&quot;)
}

func main() {
	panicAndRecover()
	fmt.Println(&quot;正常回到 main&quot;)
}

// 函数中正常流程
// 出现异常
// 正常回到 main
</code></pre>
<p>更进一步，如果我们在 <code>defer</code> 中也有 <code>panic</code> 呢？请思考下列代码：</p>
<pre><code class="language-go">func panicAndRecover() {
	defer func() {
		fmt.Println(&quot;第 1 个入栈的 defer&quot;)
		if err := recover(); err != nil {
			fmt.Println(&quot;最终捕获的 panic:&quot;, err)
		}
	}()

	defer func() {
		fmt.Println(&quot;第 2 个入栈的 defer&quot;)
		panic(&quot;第 2 个入栈的 defer 发生 panic&quot;)
	}()

	fmt.Println(&quot;panicAndRecover 函数中正常流程&quot;)
	panic(&quot;panicAndRecover 出现异常&quot;)
	fmt.Println(&quot;panic 后的语句永远执行不到&quot;)
}

func main() {
	panicAndRecover()
	fmt.Println(&quot;正常回到 main&quot;)
}
</code></pre>
<p>上述代码中，我们在 <code>panicAndRecover</code> 强行抛出 <code>panic</code>，由于 <code>defer</code> 先进后出，所以我们会先执行第 2 个 <code>defer</code>，其中也发生了 <code>panic</code>，我们在第 1 个 <code>defer</code> 中对 <code>panic</code> 进行 <code>recover</code>，最终的现象是只捕获到了后面抛出的 <code>panic</code>：</p>
<pre><code>panicAndRecover 函数中正常流程
第 2 个入栈的 defer
第 1 个入栈的 defer
最终捕获的 panic: 第 2 个入栈的 defer 发生 panic
正常回到 main
</code></pre>
<p>这是为什么呢？</p>
<p>在 Go 语言中，<code>panic</code> 函数实际上是创建了一个 <code>panic</code> 对象，并抛出这个对象。</p>
<p>当一个 <code>panic</code> 发生并开始向上传播时，Go 运行时会检查每个 <code>defer</code>。如果 <code>defer</code> 中包含 <code>recover</code> 调用，并且它被执行，那么 <code>recover</code> 会捕获当前的 <code>panic</code>，并且防止它继续向上传播。如果 <code>defer</code> 中再次发生 <code>panic</code>，那么原来的 <code>panic</code> 就不会被 <code>recover</code> 捕获，因为 <code>defer</code> 函数已经退出了。在这种情况下，新的 <code>panic</code> 会导致程序崩溃，因为没有更多的 <code>defer</code> 函数去 <code>recover</code> 这个新的 <code>panic</code>。</p>
<p>这说明了 Go 程序中不允许同时有多个活跃的 <code>panic</code> 存在，这个设计确保了在任何给定的时刻，只有一个 <code>panic</code> 能够被处理。这样做有几个原因：</p>
<ol>
<li><strong>简化错误处理：</strong> 如果同时存在多个 <code>panic</code>，就会变得非常复杂去确定如何处理它们，尤其是在它们之间存在依赖关系的时候。一个 <code>panic</code> 应该表示一个不可恢复的错误，如果有多个这样的错误同时存在，程序的状态可能会变得非常不确定。</li>
<li><strong>保持一致性：</strong> <code>panic</code> 通常表示程序中出现了严重错误，可能会破坏程序的一致性或安全性。如果允许多个 <code>panic</code> 同时存在，就很难保证程序状态的一致性，因为不同的 <code>panic</code> 可能需要回退不同的操作。</li>
<li><strong>避免资源泄漏：</strong> <code>defer</code> 语句用于确保资源被释放，例如文件和锁。如果在处理一个 <code>panic</code> 的过程中，又发生了另一个 <code>panic</code>，可能会导致 <code>defer</code> 语句中剩余的清理代码无法执行，从而引起资源泄漏。</li>
<li><strong>控制流程清晰：</strong> <code>panic</code> 和 <code>recover</code> 的设计使得错误的控制流程清晰且可预测。一旦一个 <code>panic</code> 被 <code>recover</code> 捕获，程序可以选择是否继续执行，或者是通过重新 <code>panic</code> 来终止程序。这种决策过程在多个 <code>panic</code> 情况下会变得复杂且难以管理。</li>
</ol>
<p>因此，在 Go 的设计中，不允许同时存在多个活跃的 <code>panic</code>。一旦发生 <code>panic</code>，它必须被 <code>recover</code> 处理，否则程序将会终止。这确保了错误处理的清晰性和程序的稳定性。</p>
<h2>defer 放在哪</h2>
<p><code>defer</code> 实际上不一定是放在栈上的，截止 Go1.22，<code>defer</code> 其实用 3 种分配策略：</p>
<ul>
<li>堆上分配</li>
<li>栈上分配</li>
<li>开放编码</li>
</ul>
<h3>执行机制</h3>
<p>在 <a href="https://github.com/golang/go/blob/release-branch.go1.22/src/cmd/compile/internal/ssagen/ssa.go">ssa.go</a> 文件中，我们可以找到 <code>state.stmt()</code>，这个函数是负责在 Go 程序编译过程中中间代码生成阶段时对不同语句的处理过程，其中对于 <code>ODEFER</code> 即 <code>defer</code> 语句的处理逻辑如下：</p>
<pre><code class="language-go">// stmt converts the statement n to SSA and adds it to s.
func (s *state) stmt(n ir.Node) {
	s.stmtList(n.Init())
	switch n.Op() {
			case ir.ODEFER:
      n := n.(*ir.GoDeferStmt)
      if base.Debug.Defer &gt; 0 {
        var defertype string
        if s.hasOpenDefers {
          defertype = &quot;open-coded&quot;
        } else if n.Esc() == ir.EscNever {
          defertype = &quot;stack-allocated&quot;
        } else {
          defertype = &quot;heap-allocated&quot;
        }
        base.WarnfAt(n.Pos(), &quot;%s defer&quot;, defertype)
      }
			...
  }
}
</code></pre>
<p>可以看到，总共有 3 种分配策略：</p>
<ul>
<li><strong>open-coded</strong>: s.hasOpenDefers == true</li>
<li><strong>stack-allocated</strong>: n.Esc() == ir.EscNever</li>
<li><strong>heap-allocated</strong>: 默认</li>
</ul>
<p>默认是堆分配，在 Go1.13 以前，也只有堆分配这一种策略，不过该实现的性能较差。Go 语言在 1.13 中引入栈上分配的结构体，<a href="https://go-review.googlesource.com/c/go/+/171758">减少了 30% 的额外开销</a>，并在 1.14 中引入了基于开放编码的 <code>defer</code>，使得该关键字的额外开销<a href="https://go-review.googlesource.com/c/go/+/190098/6">几乎可以忽略不计</a>。</p>
<p>本文中不对具体的分配机制进行分析，这一块会比较复杂，笔者本身也不是很感兴趣，便决定对此不过分深究，感兴趣的读者推荐详细阅读《Go 语言设计与实现》中关于 <code>defer</code> 关键字的分析：<a href="https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-defer/%E3%80%82">https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-defer/。</a></p>
<p>本文只讨论什么情况下会使用什么分配策略。由于堆分配是默认的，我们就不作分析了，具体来看看 <code>s.hasOpenDefers == true</code> 和 <code>n.Esc() == ir.EscNever</code> 什么时候会成立。</p>
<h3>栈上分配</h3>
<p>我们先来看栈上分配，要满足栈上分配，则需要满足 <code>n.Esc() == ir.EscNever</code>。</p>
<pre><code class="language-go">const (
	EscUnknown = iota
	EscNone    // Does not escape to heap, result, or parameters.
	EscHeap    // Reachable from the heap
	EscNever   // By construction will not escape.
)
</code></pre>
<p>当 <code>n</code> 的逃逸分析结果是 <code>ir.EscNever</code>，则表明该 <code>defer</code> 语句从不逃逸（不会在函数调用结束后仍然被引用），这种情况下 <code>defer</code> 将被分配到栈上（stack-allocated）。否则，如果 <code>defer</code> 逃逸了，就会被分配到堆上（heap-allocated）。</p>
<p>那 <code>defer</code> 语句什么时候会逃逸呢？</p>
<blockquote>
<p>在 Go 中，一个变量的逃逸意味着它的生命周期超出了当前函数的范围。在函数内定义的变量通常分配在栈上，而在堆上分配内存需要更复杂的管理。在一些情况下，编译器可能会选择将变量分配在堆上，这种情况下我们称之为逃逸。</p>
</blockquote>
<p>对于 <code>defer</code> 语句，如果它引用了函数外的变量，这个 <code>defer</code> 就会逃逸。例如：</p>
<pre><code class="language-go">var x = 10
func someFunction() {
    defer func() {
        fmt.Println(x) // 这里引用了外部变量 x
    }()
}
</code></pre>
<p>在这个例子中，<code>defer</code> 函数内部引用了 <code>x</code> 这个外部变量，因此 <code>defer</code> 语句需要确保 <code>x</code> 在 <code>defer</code> 函数执行时仍然有效。为了满足这个条件，编译器可能会将 <code>x</code> 分配在堆上，而不是栈上。</p>
<h3>开放编码</h3>
<p>先给结论，在开发过程中，要使用开放编码策略，你只需要关注以下 4 点即可：</p>
<ol>
<li>函数的 <code>defer</code> 数量不能超过 8 个；</li>
<li>函数的 <code>defer</code> 关键字不能在循环中执行；</li>
<li>函数的 <code>defer</code> 中不能发生逃逸；</li>
<li>函数的 <code>return</code> 语句与 <code>defer</code> 语句的乘积小于或者等于 15 个；</li>
</ol>
<hr>
<p>Ok，下面是具体的分析过程。</p>
<p>借助 Goland 的能力，将鼠标光标放在 <code>s.hasOpenDefers</code> 上，按住 <strong>Command</strong> 加点击鼠标，可以看到该属性的使用情况：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/imgimage-20240328133312845-20240328202233278.png" alt="s.hasOpenDefers"></p>
<p>可以看到该属性的判断逻辑都在 <a href="https://github.com/golang/go/blob/release-branch.go1.22/src/cmd/compile/internal/ssagen/ssa.go">ssa.go</a> 文件中的 <code>buildssa()</code> 函数中。去掉一些无关的代码，核心逻辑如下：</p>
<pre><code class="language-go">// buildssa builds an SSA function for fn.
// worker indicates which of the backend workers is doing the processing.
func buildssa(fn *ir.Func, worker int) *ssa.Func {
	...
  // ①
	s.hasOpenDefers = base.Flag.N == 0 &amp;&amp; s.hasdefer &amp;&amp; !s.curfn.OpenCodedDeferDisallowed()
	switch {
  // ②
	case base.Debug.NoOpenDefer != 0:
		s.hasOpenDefers = false
	case s.hasOpenDefers &amp;&amp; (base.Ctxt.Flag_shared || base.Ctxt.Flag_dynlink) &amp;&amp; base.Ctxt.Arch.Name == &quot;386&quot;:
    // ③
		// Don&#39;t support open-coded defers for 386 ONLY when using shared
		// libraries, because there is extra code (added by rewriteToUseGot())
		// preceding the deferreturn/ret code that we don&#39;t track correctly.
		s.hasOpenDefers = false
	}
  // ④
	if s.hasOpenDefers &amp;&amp; len(s.curfn.Exit) &gt; 0 {
		// Skip doing open defers if there is any extra exit code (likely
		// race detection), since we will not generate that code in the
		// case of the extra deferreturn/ret segment.
		s.hasOpenDefers = false
	}
  // ⑤
	if s.hasOpenDefers {
		// Similarly, skip if there are any heap-allocated result
		// parameters that need to be copied back to their stack slots.
		for _, f := range s.curfn.Type().Results().FieldSlice() {
			if !f.Nname.(*ir.Name).OnStack() {
				s.hasOpenDefers = false
				break
			}
		}
	}
  // ⑥
	if s.hasOpenDefers &amp;&amp;
		s.curfn.NumReturns*s.curfn.NumDefers &gt; 15 {
		// Since we are generating defer calls at every exit for
		// open-coded defers, skip doing open-coded defers if there are
		// too many returns (especially if there are multiple defers).
		// Open-coded defers are most important for improving performance
		// for smaller functions (which don&#39;t have many returns).
		s.hasOpenDefers = false
	}
  ...
	return s.f
}
</code></pre>
<p>可以看到总共有 6 个条件，我已在注释中进行标注，我们来进行逐一分析：</p>
<h4>① base.Flag.N == 0 &amp;&amp; s.hasdefer &amp;&amp; !s.curfn.OpenCodedDeferDisallowed()</h4>
<blockquote>
<p>如果<code>base.Flag.N</code> 等于 0 且当前函数有延迟调用且没有禁止开放式延迟，那么设置<code>s.hasOpenDefers</code>为<code>true</code>。</p>
</blockquote>
<p>在 Go 编译器中，<code>-N</code>标志通常用于禁用优化。在这段代码中，如果<code>base.Flag.N</code>等于 0，意味着没有禁用优化，因此编译器可能会尝试使用更高级的优化技术，比如开放式延迟（open-coded defers）。</p>
<p><code>OpenCodedDeferDisallowed()</code> 即禁用开放编码，它的实现如下：</p>
<pre><code class="language-go">const funcOpenCodedDeferDisallowed // can&#39;t do open-coded defers

func (f *Func) OpenCodedDeferDisallowed() bool { return f.flags&amp;funcOpenCodedDeferDisallowed != 0 }
</code></pre>
<p>按住 Command 后点击 <code>funcOpenCodedDeferDisallowed</code> 可以看到只有 <code>funcOpenCodedDeferDisallowed(b)</code> 可以修改它的值。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/imgimage-20240328140733594.png" alt="funcOpenCodedDeferDisallowed"></p>
<p>我们来看看哪个地方会调用 <code>funcOpenCodedDeferDisallowed()</code>，并将 <code>funcOpenCodedDeferDisallowed</code> 设置为 <code>true</code>：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/imgimage-20240328140859879.png" alt="将 funcOpenCodedDeferDisallowed 设置为 true 的地方 "></p>
<p>调用它的地方在 <a href="https://github.com/golang/go/blob/release-branch.go1.22/src/cmd/compile/internal/walk/stmt.go">stmt.go</a> 文件中的 <code>walkStmt()</code> 函数，具体如下：</p>
<pre><code class="language-go">// The max number of defers in a function using open-coded defers. We enforce this
// limit because the deferBits bitmask is currently a single byte (to minimize code size)
const maxOpenDefers = 8

// The result of walkStmt MUST be assigned back to n, e.g.
//
//	n.Left = walkStmt(n.Left)
func walkStmt(n ir.Node) ir.Node {
  ...
	switch n.Op() {
    ...
    case ir.ODEFER:
      n := n.(*ir.GoDeferStmt)
      ir.CurFunc.SetHasDefer(true)
      ir.CurFunc.NumDefers++
      if ir.CurFunc.NumDefers &gt; maxOpenDefers {
        // Don&#39;t allow open-coded defers if there are more than
        // 8 defers in the function, since we use a single
        // byte to record active defers.
        ir.CurFunc.SetOpenCodedDeferDisallowed(true)
      }
      if n.Esc() != ir.EscNever {
        // If n.Esc is not EscNever, then this defer occurs in a loop,
        // so open-coded defers cannot be used in this function.
        ir.CurFunc.SetOpenCodedDeferDisallowed(true)
      }
      fallthrough
    ...
  }
  ...
}
</code></pre>
<p>第一点是：当前函数中 <code>defer</code> 个数超过 8 的话，则禁用开放编码。</p>
<p>第二点是当 <code>n.Esc() != ir.EscNever</code> 使，就禁用开放编码。这个要求跟前面分析的“栈上分配”要求是一样的。</p>
<p>这里再补充一点：什么时候 <code>n.Esc()</code> 会被设置为 <code>ir.EscNever</code> 呢？</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/imgimage-20240328192005520.png" alt="n.SetEsc(ir.EscNever)"></p>
<p>这里面核心点是第一个，它对应的代码如下：</p>
<pre><code class="language-go">func (e *escape) goDeferStmt(n *ir.GoDeferStmt) {
	k := e.heapHole()
	if n.Op() == ir.ODEFER &amp;&amp; e.loopDepth == 1 {
		...
		n.SetEsc(ir.EscNever)
	}
  ...
}
</code></pre>
<p><code>e.loopDepth == 1</code> 时就设置，换言之，<code>defer</code> 不在循环中的时候，才允许开放编码。</p>
<p>总而言之，第 ① 个条件约束了要采用 <code>open-coded 开放编码</code>策略的 3 个条件：</p>
<ol>
<li>函数中 <code>defer</code> 个数不能超过 <strong>8</strong>；</li>
<li><code>defer</code> 不能在循环中；</li>
<li><code>defer</code> 不能发生逃逸。</li>
</ol>
<h4>② base.Debug.NoOpenDefer != 0</h4>
<blockquote>
<p>如果<code>base.Debug.NoOpenDefer</code>不为 0，那么禁用开放式延迟。</p>
</blockquote>
<pre><code class="language-go">NoOpenDefer           int    `help:&quot;disable open-coded defers&quot; concurrent:&quot;ok&quot;`
</code></pre>
<h4>③ (base.Ctxt.Flag_shared || base.Ctxt.Flag_dynlink) &amp;&amp; base.Ctxt.Arch.Name == &quot;386&quot;</h4>
<blockquote>
<p>如果当前架构是<code>386</code>，并且使用共享库或动态链接，那么不支持开放式延迟，因为存在一些额外的代码（由<code>rewriteToUseGot()</code>添加）可能无法正确追踪。</p>
</blockquote>
<h4>④ len(s.curfn.Exit)</h4>
<blockquote>
<p>如果存在任何额外的退出代码（比如可能是竞态检测相关的代码），则跳过开放式延迟。</p>
</blockquote>
<h4>⑤ !f.Nname.(*ir.Name).OnStack()</h4>
<blockquote>
<p>如果有任何堆分配的结果参数需要复制回它们的栈槽，也跳过开放式延迟。</p>
</blockquote>
<h4>⑥ s.curfn.NumReturns*s.curfn.NumDefers &gt; 15</h4>
<blockquote>
<p>如果函数的返回数乘以延迟调用数大于 <strong>15</strong>，考虑到每个退出点都要生成延迟调用，并且开放式延迟对于小函数（没有多个返回）的性能提升最为重要，所以在这种情况下也不使用开放式延迟。</p>
</blockquote>
<h3>堆上分配</h3>
<p>当不满足开放编码和栈上分配的时候，默认就是堆上分配（heap-allocated），性能最差，这里不做分析。</p>
<hr>
<p>以上就是本文关于 Go 语言中 <code>defer</code> 关键字的具体分析，Happy Coding! Peace~</p>
<h2>参考</h2>
<ul>
<li><a href="https://book.douban.com/subject/36403287/">深入理解 Go 语言</a></li>
<li><a href="https://book.douban.com/subject/35556889/">Go 语言底层原理剖析</a></li>
<li><a href="https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-defer/">Go 语言设计与实现</a></li>
<li>ChatGPT4</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>深入 Go 语言核心：结构体的全方位解析</title>
      <link>https://hedon.top/blog/go-struct/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-struct/</guid>
      <pubDate>Sat, 09 Mar 2024 16:59:34 GMT</pubDate>
      <description>本文将带您全面深入地探索 Go 语言中结构体的各个方面，从基本定义、初始化和使用，到高级特性如结构体的组合、方法定义、内存对齐等。</description>
      <category>Go</category>
      <content:encoded><![CDATA[<p>Go 语言，作为一种高效、静态类型的编程语言，自其问世以来便以其并发处理能力和简洁的语法结构广受开发者欢迎。虽然 Go 不是传统意义上的面向对象语言，它却以独特的方式支持面向对象编程的核心概念，其中结构体扮演了非常关键的角色。</p>
<p>结构体在 Go 语言中是一种复合数据类型，允许我们将不同类型的数据聚合到一起。它不仅提高了数据管理的效率和逻辑清晰度，还是 Go 语言中实现面向对象编程思想如封装、组合等概念的基石。了解和掌握结构体的使用，对于深入理解 Go 语言的特性和编写高效、可维护的 Go 代码至关重要。</p>
<p>本文将带您全面深入地探索 Go 语言中结构体的各个方面，从基本定义、初始化和使用，到高级特性如结构体的组合、方法定义、内存对齐等，每一个细节都将一一展开。无论您是 Go 语言的新手，还是有一定经验的开发者，相信本文都能为您提供有价值的见解和帮助。让我们一起探索 Go 结构体的奥秘，揭开其背后的原理，优化我们的代码结构，提升编程效率。</p>
<h2>版本声明</h2>
<ul>
<li>Go 1.22.1</li>
<li>gopkg.in/yaml.v3 v3.0.1</li>
<li>os: m2max</li>
</ul>
<h2>全文概览</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/go-struct.png" alt="Go 语言结构体"></p>
<h2>1. 结构体的基本使用</h2>
<h3>1.1 定义结构体</h3>
<p>结构体类型的定义形式如下：</p>
<pre><code class="language-go">type T struct {
  Field T1,
  Field T2,
  ....
  FieldN Tn,
}
</code></pre>
<p>比如：</p>
<pre><code class="language-go">type Person struct {
  Name string
  Age int
  ExtraInfo map[string]interface{}
}
</code></pre>
<p>结构体内部，也可以内嵌<strong>匿名结构体</strong>，如：</p>
<pre><code class="language-go">type Person struct {
	Name string
  Age int
  School struct {
    Name string
    Address string
    Phone string
  }
}
</code></pre>
<p><strong>但是！注意，如果 Person 中包含了 Person 呢？</strong></p>
<pre><code class="language-go">type Person struct {
	person  Person
}
</code></pre>
<p>这里会报错：不允许引用自身。</p>
<pre><code class="language-bash">./main.go:5:6: invalid recursive type: Person refers to itself
</code></pre>
<p>这是因为 Go 语言在编译时需要知道每个类型的确切大小，以便正确地分配内存。但在这个定义中，因为 <code>Person</code> 包含自身，编译器无法确定 <code>Person</code> 的大小，因此会报错。</p>
<p>如果你需要在一个结构体中引用相同类型的数据，你应该使用指针。指针的大小是固定的，因此编译器可以确定结构体的大小。</p>
<pre><code class="language-go">type Person struct {
  person *Person
}
</code></pre>
<h3>1.2 初始化结构体</h3>
<p>假设我们有以下结构体：</p>
<pre><code class="language-go">type Person struct {
	Name      string
	Age       int
	ExtraInfo map[string]interface{}
}
</code></pre>
<p>可以有以下几种初始化：</p>
<pre><code class="language-go">// 逐个字段赋值，顺序不重要，也可以只赋值部分字段
person1 := Person{
  Age:       18,
  Name:      &quot;hedon&quot;,
  ExtraInfo: make(map[string]interface{}),
}
fmt.Println(person1) // {hedon 18 map[]}

// 可以不指定字段，严格按照顺序
person2 := Person{&quot;hedon2&quot;, 19, make(map[string]interface{})}
fmt.Println(person2) // {hedon2 19 map[]}

// 默认初始化，则结构体中的每个字段都会被默认赋予其对应类型的“零值”
var person3 Person
fmt.Println(person3)                  // { 0 map[]}
fmt.Println(person3.ExtraInfo == nil) // true

// 也可以使用 new() 或 &amp; 来初始化并返回指针
person3 := new(Person)
fmt.Println(person3)  // &amp;{ 0 map[]}
</code></pre>
<h3>1.3 空结构体</h3>
<p>有一种特殊的结构体，它一个字段都没有，我们称之为“空结构体”：</p>
<pre><code class="language-go">type Empty struct{}
</code></pre>
<p>空结构体非常特殊，它不占据任何空间！你可以自己验证一下：</p>
<pre><code class="language-go">type Empty struct{}

func main() {
  fmt.Println(&quot;the size of empty:&quot;, unsafe.Sizeof(Empty{})) // the size of empty: 0
}
</code></pre>
<p>而且，所有空结构体的地址都一样：</p>
<pre><code class="language-go">type Empty struct{}
type Empty1 struct{}

func main() {
	e := Empty{}
	e1 := Empty1{}
	fmt.Printf(&quot;the address of empty: %p\n&quot;, &amp;e) // the address of empty: 0x10460f520
	fmt.Printf(&quot;the address of empty1: %p\n&quot;, &amp;e1)  // the address of empty1: 0x10460f520
}
</code></pre>
<p>这是因为 Go 语言为所有大小为 0 的变量都指向了同一个值：</p>
<pre><code class="language-go">// base address for all 0-byte allocations
var zerobase uintptr
</code></pre>
<p>好处就是减少了内存的浪费。典型的用法就是我们可以使用 map 来实现 Set，这样就只花费了存储键的空间，而值不占用任何空间。</p>
<pre><code class="language-go">type Set = map[string]struct{}
</code></pre>
<h3>1.4 访问和修改结构体</h3>
<ul>
<li>结构体属性的可见性跟 Go 包的可见性规则一样：大写对包外可见，小写仅包内可见。</li>
<li>使用 <code>.</code> 访问和修改结构体中的属性。</li>
<li>Go 语言中只有“<strong>值传递</strong>”，所以如果你要将结构体示例传入一个 func 进行修改，则需要传入其引用。</li>
</ul>
<pre><code class="language-go">type Person struct {
	Name string
	Age  int
}

func main() {
	p := Person{Name: &quot;hedon&quot;, Age: 18}
	UpdatePersonName(p)
	fmt.Println(&quot;1:&quot;, p)
	UpdatePersonNameWithRef(&amp;p)
	fmt.Println(&quot;2:&quot;, p)
}

func UpdatePersonName(p Person) {
	p.Name = &quot;hedon-1&quot;
}

func UpdatePersonNameWithRef(p *Person) {
	p.Name = &quot;hedon-2&quot;
}
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">1: {hedon 18}
2: {hedon-2 18}
</code></pre>
<h2>2. 结构体的高级特性</h2>
<h3>2.1 结构体组合</h3>
<p>在 Go 语言中，倡导的是“组合优于继承”的哲学，即倡导使用组合而不是继承来实现代码的复用。该理念鼓励开发者通过组合和接口来构建灵活、可维护的代码，而不是依赖于更严格、更易出错的继承关系。这种方式促进了代码的解耦，增强了代码的灵活性和可重用性，同时也使得代码更加清晰和易于理解。</p>
<p>在 Go 中，组合是通过将一个或多个类型（通常是结构体）嵌入到另一个结构体中来实现的。这使得嵌入的类型的方法被“提升”到包含它的结构体中，允许你调用这些方法就像它们是外部结构体的一部分一样。</p>
<pre><code class="language-go">type Engine struct {
    Power int
}

func (e *Engine) Start() {
    // 启动引擎的逻辑
}

type Car struct {
    Engine // 通过组合的方式嵌入 Engine
}

// 现在 Car 可以直接调用 Start 方法
car := Car{Engine{Power: 100}}
car.Start() // 调用的是 Engine 的 Start 方法
</code></pre>
<h3>2.2 结构体的方法</h3>
<p>假设我们定义了一个结构体 Person：</p>
<pre><code class="language-go">type Person struct {
	Name string
}
</code></pre>
<p>在 Go 中，你可以为结构体的值或指针实现特定的方法：</p>
<pre><code class="language-go">func(p Person) SetName(name string) string {
  p.Name = name
}
</code></pre>
<pre><code class="language-go">func(p *Person) SetName(name string) string {
  p.Name = name
}
</code></pre>
<p>这两者最核心的区别是：<strong>当你为结构体的指针类型定义方法时，该方法会在原始结构体实例上操作。这意味着方法内部对结构体的任何修改都会影响到原始结构体。</strong></p>
<p>所以这两段代码的输出是不一样的：</p>
<pre><code class="language-go">func main() {
	p := Person{Name: &quot;hedon&quot;}
	p.SetName(&quot;new_name&quot;)
	fmt.Println(p.Name)   // name_name
}

func (p *Person) SetName(name string) {
	p.Name = name
}
</code></pre>
<pre><code class="language-go">func main() {
	p := Person{Name: &quot;hedon&quot;}
	p.SetName(&quot;new_name&quot;)
	fmt.Println(p.Name)  // hedon
}

func (p Person) SetName(name string) {
	p.Name = name
}
</code></pre>
<p>但是这里我想再补充两个小点。请先思考一下下面这两段代码是否可以编译通过？如果可以输出是什么？</p>
<pre><code class="language-go">func main() {
	p := Person{Name: &quot;hedon&quot;}
	p.SetName(&quot;new_name&quot;)
	fmt.Println(&quot;person name after set:&quot;, p.Name)

	pt := reflect.TypeOf(p)
	fmt.Println(&quot;the number of person&#39;s method: &quot;, pt.NumMethod())

	p2 := &amp;Person{}
	pt = reflect.TypeOf(p2)
	fmt.Println(&quot;the number of &amp;person&#39;s method: &quot;, pt.NumMethod())
}

func (p *Person) SetName(name string) {
	p.Name = name
}
</code></pre>
<pre><code class="language-go">func main() {
	p := Person{Name: &quot;hedon&quot;}
	p.SetName(&quot;new_name&quot;)
	fmt.Println(&quot;person name after set:&quot;, p.Name)

	pt := reflect.TypeOf(p)
	fmt.Println(&quot;the number of person&#39;s method: &quot;, pt.NumMethod())

	p2 := &amp;Person{}
	pt = reflect.TypeOf(p2)
	fmt.Println(&quot;the number of &amp;person&#39;s method: &quot;, pt.NumMethod())
}

func (p Person) SetName(name string) {
	p.Name = name
}
</code></pre>
<p>很明显这两段代码的唯一区别就是，第一段代码我们是为 <code>*Person</code> 实现了 <code>SetName</code> 方法，而第二段代码我们是为 <code>Person</code> 实现了 <code>SetName</code> 方法。两段代码我们都打印了调用 <code>SetName</code> 后 <code>p.name</code> 的值，以及利用方式分别获取 <code>Person</code> 和 <code>*Person</code> 实现的方法个数。</p>
<p>第一段代码的输出如下：</p>
<pre><code class="language-tex">person name after set: new_name
the number of person&#39;s method:  0
the number of &amp;person&#39;s method:  1
</code></pre>
<p>第二段代码的输出如下：</p>
<pre><code class="language-tex">person name after set: hedon
the number of person&#39;s method:  1
the number of &amp;person&#39;s method:  1
</code></pre>
<p>这里我们可以得出 2 个结论：</p>
<p><strong>① 结构体的修改依赖于方法接收器的类型</strong>：</p>
<ul>
<li>当方法的接收器为值类型（<code>Person</code>）时，对结构体的修改不会影响原始结构体实例，因为方法作用于结构体的副本上。</li>
<li>当方法的接收器为指针类型（<code>*Person</code>）时，对结构体的修改会影响原始结构体实例，因为方法作用于结构体的引用上。</li>
</ul>
<p><strong>② 方法集依赖于接收器的类型</strong>：</p>
<ul>
<li>为值类型（<code>Person</code>）实现的方法，既属于值类型也属于指针类型（<code>*Person</code>）的方法集。</li>
<li>为指针类型（<code>*Person</code>）实现的方法，只属于指针类型的方法集。</li>
</ul>
<p>对于 ②，我们可以通过 Plan9 汇编代码一探究竟。</p>
<p>我们为第一段代码执行以下命令：</p>
<pre><code class="language-bash">go build -gcflags -S main.go
</code></pre>
<p>在输出的最上面，可以看到只有 <code>main.(*Person).GetName</code>。</p>
<pre><code class="language-go"># command-line-arguments
main.main STEXT size=128 args=0x0 locals=0x48 funcid=0x0 align=0x0
        ...
main.(*Person).GetName STEXT size=16 args=0x8 locals=0x0 funcid=0x0 align=0x0 leaf
       ...
</code></pre>
<p>我们再来为第二段代码执行相同的命令。可以在输出的最上面，看到不仅有 <code>main.Person.GetName</code>，还可以发现编译器自动帮我们生成了 <code>main.(*Person).GetName</code>。</p>
<pre><code class="language-bash"># command-line-arguments
main.main STEXT size=480 args=0x0 locals=0xe8 funcid=0x0 align=0x0
	...
main.Person.SetName STEXT size=16 args=0x28 locals=0x0 funcid=0x0 align=0x0 leaf
   ...
main.(*Person).SetName STEXT dupok size=128 args=0x18 locals=0x8 funcid=0x16 align=0x0
	...
</code></pre>
<p>对于 ②，笔者其实有一个不太理解的地方，比如下面这段代码：</p>
<pre><code class="language-go">func main() {
	p := &amp;Person{Name: &quot;hedon&quot;}
	p.SetName(&quot;new_name&quot;)
	fmt.Println(&quot;person name after set:&quot;, p.Name) // hedon
}

func (p Person) SetName(name string) {
	p.Name = name
}
</code></pre>
<p>这里 <code>p</code> 是引用类型，下面实现的是 <code>Person.SetName</code>，按照我们上面的结论，编译器会自动帮我们实现 <code>(*Person).SetName</code>。按照这种思路，输出 <code>new_name</code> 也是解释得通的。因为既然我们声明的是一个引用类型，那么 <code>p</code> 完全可以去调用自动生成的 <code>(*Person).SetName</code>。但是最终的结果还是输出 <code>hedon</code>，所以这里编译器自动帮我们将 <code>p</code> 进行解引用，然后调用了 <code>Person.SetName</code>。</p>
<p>这是比较困扰笔者的一个地方，欢迎评论区讨论~</p>
<p>可能编译器还是更希望对于开发者来说“所见即所得”，既然开发者实现的是 <code>Person.SetName</code>，那么对于开发者来说，应该就是希望不影响原始结构体的值，所以编译器还是选择遵循这种“意愿”，不乱操作。</p>
<h3>2.3 结构体比较</h3>
<p>Go 允许直接比较两个结构体实例，但有一定的限制：</p>
<ol>
<li><strong>可比较性</strong>：只有当结构体中的所有字段都是可比较的时，结构体才是可比较的。基本数据类型（如 int、string 等）是可比较的，但切片、映射、函数等类型不可比较。</li>
<li><strong>相等性检测</strong>：当两个结构体的对应字段都相等时，这两个结构体被认为是相等的。可以使用 <code>==</code> 和 <code>!=</code> 操作符来进行比较。</li>
</ol>
<p>下面这段示例，<code>p3==p4</code> 返回了 <code>true</code>，这符合我们上面总结的结论。<code>p1==p2</code> 返回了 <code>false</code>，因为这其实不是结构体之间的比较了，这是指针的比较了。</p>
<pre><code class="language-go">p1 := &amp;Person{Name: &quot;hedon&quot;, Age: 18}
p2 := &amp;Person{Name: &quot;hedon&quot;, Age: 18}
fmt.Println(p1 == p2) // false
p3 := Person{Name: &quot;hedon&quot;, Age: 18}
p4 := Person{Name: &quot;hedon&quot;, Age: 18}
fmt.Println(p3 == p4) // true
</code></pre>
<p>结构体的比较只支持 <code>==</code> 和 <code>!=</code>，不支持 <code>&lt;</code> 和 <code>&gt;</code> 等其他运算符的比较。而 Go 语言又不支持比较符重载。所以如果你要比较两个结构体的大小，那么只能自行封装类型 <code>compare</code> 的函数。在这我们排序结构体数组或切片的时候，经常使用到，比如我们希望按 <code>Age</code> 字段从小到大排序：</p>
<pre><code class="language-go">sort.Slice(persons, func(i, j int) bool {
  return persons[i].Age &lt; persons[j].Age
})
</code></pre>
<h3>2.4 结构体复制</h3>
<p>在 Go 中，结构体也是值类型，这意味着当它们被赋值给新的变量或作为函数参数传递时，实际上是进行了一次深拷贝：</p>
<ol>
<li><strong>值复制</strong>：当将一个结构体赋值给一个新变量时，新变量会获得原始结构体的一个副本，它们在内存中占有不同的位置。</li>
<li><strong>独立性</strong>：因为是深拷贝，所以原始结构体和副本结构体是完全独立的；修改其中一个不会影响另一个。</li>
</ol>
<pre><code class="language-go">type Point struct {
    X, Y int
}

original := Point{1, 2}
copy := original
copy.X = 3

fmt.Println(original) // {1, 2}
fmt.Println(copy)     // {3, 2}
</code></pre>
<h2>3. 结构体与接口</h2>
<p>在 Go 语言中，如果一个类型实现了接口中所有的方法，则这个类型就实现了该接口。关于接口部分的知识点，比如接口定义、多态和断言等，本文就不赘述了。</p>
<p>在这里我主要想从另外一个角度继续来验证前面我们总结的：<strong>为值类型（<code>Person</code>）实现的方法，既属于值类型也属于指针类型（<code>*Person</code>）的方法集</strong>。</p>
<p>请看这段代码：</p>
<pre><code class="language-go">package main

import &quot;fmt&quot;

type Person interface {
	GetName() string
}

type Man struct {
	Name string
}

func (m Man) GetName() string {
	return m.Name
}

func PrintPersonName(p Person) {
	fmt.Println(p.GetName())
}

func main() {
	m1 := Man{Name: &quot;hedon1&quot;}
	PrintPersonName(m1)
	m2 := &amp;Man{Name: &quot;hedon2&quot;}
	PrintPersonName(m2)
}
</code></pre>
<p>这段代码我们定义了 <code>Person</code> 接口，它只有一个方法 <code>GetName</code>。然后我们定义了一个结构体 <code>Man</code>，并为它的值类型实现了 <code>Person</code> 接口。通过我们上面的结论，这里 <code>Man</code> 和 <code>*Man</code> 其实都实现了 <code>Person</code> 接口，所以上面的代码是可以编译通过的。</p>
<p>如果改成为指针类型实现接口呢？你可以试一下~</p>
<pre><code class="language-go">func (m *Man) GetName() string {
	return m.Name
}
</code></pre>
<h2>4. 泛型结构体</h2>
<p>Go 语言在其 1.18 版本中引入了泛型支持，这包括了对泛型结构体的支持。通过使用泛型，你可以创建更灵活和可重用的数据结构和函数。</p>
<pre><code class="language-go">type Container[T any] struct {
	items []T
}
</code></pre>
<p>可以看到 Go 语言用 <code>[]</code> 来实现泛型，而不像其他语言一样用 <code>&lt;&gt;</code>，真是喜欢搞特殊啊 🤡，又丑又容易跟 map 和 slice 混淆。</p>
<h2>5. 结构体的标签（Tag）</h2>
<p>在结构体字段后面，我们可以用 <strong>``</strong> 来指定标签，这允许我们对结构体定制化一些常用操作，最经典的就是序列化与反序列化。</p>
<h3>5.1 序列化与反序列化</h3>
<p>对于常见的数据结构，如 <code>json</code>、<code>yaml</code>、<code>xml</code> 或 <code>toml</code>，我们都可以通过在结构体中指定标签，然后使用对应解析库进行序列化和反序列化。比如：</p>
<pre><code class="language-go">package main

import (
	&quot;encoding/json&quot;
	&quot;fmt&quot;
)

type Person struct {
	Name string `json:&quot;name&quot;`
	Age  int    `json:&quot;age&quot;`
}

func main() {
	p := Person{Name: &quot;hedon&quot;, Age: 18}
	bs, _ := json.Marshal(p) // 序列化
	fmt.Println(string(bs))  // {&quot;name&quot;:&quot;hedon&quot;,&quot;age&quot;:18}
	newP := Person{}
	_ = json.Unmarshal(bs, &amp;newP) // 反序列化
	fmt.Println(newP)             // {hedon 18}
}
</code></pre>
<p>在笔者的实践过程中，在结构体组合的场景下，不同数据格式的解析会有一些小差别，这在实战过程中你需要重点关注和验证。比如 <code>json</code> 和 <code>yaml</code> 就会有一些不同。</p>
<p>比如说我这里定义了下面 2 个结构体，其中 <code>Person</code> 组合了 <code>School</code>：</p>
<pre><code class="language-go">type Person struct {
	Name string `json:&quot;name&quot; yaml:&quot;name&quot;`
	Age  int    `json:&quot;age&quot; yaml:&quot;age&quot;`
	School
}

type School struct {
	SchoolName    string `json:&quot;school_name&quot; yaml:&quot;school_name&quot;`
	SchoolAddress string `json:&quot;school_address&quot; json:&quot;school_address&quot;`
}
</code></pre>
<p>它们都加上了 <code>json</code> 和 <code>yaml</code> 标签，对于 <code>json</code> 类型，你可以用标准库的 <code>encoding/json</code> 来进行序列化和反序列化，而 <code>yaml</code> 你可以使用第三方库：<a href="https://github.com/go-yaml/yaml">go-yaml</a>。</p>
<p>先来看系列化结果：</p>
<pre><code class="language-go">func main() {
	p := Person{Name: &quot;hedon&quot;, Age: 18, School: School{SchoolName: &quot;nb_school&quot;, SchoolAddress: &quot;a_good_school_place&quot;}}
	bs, _ := json.Marshal(p)
	fmt.Println(&quot;json:\n&quot;, string(bs))
	bs, _ = yaml.Marshal(p)
	fmt.Println(&quot;yaml:\n&quot;, string(bs))
}
</code></pre>
<p>输出如下：</p>
<pre><code class="language-tex">json:
 {&quot;name&quot;:&quot;hedon&quot;,&quot;age&quot;:18,&quot;school_name&quot;:&quot;nb_school&quot;,&quot;school_address&quot;:&quot;a_good_school_place&quot;}
yaml:
 name: hedon
 age: 18
 school:
    school_name: nb_school
    school_address: a_good_school_place
</code></pre>
<p>通过观察你可以发现哈，在 <code>json</code> 中，组合的时候（没有给 School 加标签）直接将 <code>School</code> 平铺在 <code>Person</code> 中，所以在序列化的结果中，找不到 <code>&quot;school&quot;: {}</code>。而在 <code>yaml</code> 中，并不是直接平铺的。</p>
<p>这个区别在你解析配置文件的时候尤其重要，如果不注意，那么可能会导致配置解析失败。</p>
<p>我准备了 4 个配置文件，分别是：</p>
<pre><code class="language-json">// person1.json
{
  &quot;name&quot;: &quot;hedon_json&quot;,
  &quot;age&quot;: 18,
  &quot;school&quot;: {
    &quot;school_name&quot;: &quot;nb_json_school&quot;,
    &quot;school_address&quot;: &quot;a_good_place_in_json&quot;
  }
}
</code></pre>
<pre><code class="language-yaml"># person1.yaml
name: &quot;hedon_yaml&quot;
age: 18
school:
  school_name: &quot;nb_yaml_school&quot;
  school_address: &quot;a_good_price_in_yaml&quot;
</code></pre>
<pre><code class="language-json">// person2.json
{
  &quot;name&quot;: &quot;hedon_json&quot;,
  &quot;age&quot;: 18,
  &quot;school_name&quot;: &quot;nb_json_school&quot;,
  &quot;school_address&quot;: &quot;a_good_place_in_json&quot;
}
</code></pre>
<pre><code class="language-yaml"># person2.yaml
name: &quot;hedon_yaml&quot;
age: 18
school_name: &quot;nb_yaml_school&quot;
school_address: &quot;a_good_price_in_yaml&quot;
</code></pre>
<p>解析代码如下：</p>
<pre><code class="language-go">func main() {
	filenames := []string{&quot;person1.json&quot;, &quot;person1.yaml&quot;, &quot;person2.json&quot;, &quot;person2.yaml&quot;}
	for i, fn := range filenames {
		bs := readFileIntoBytes(fn)
		p := Person{}
		if i%2 == 0 {
			_ = json.Unmarshal(bs, &amp;p)
		} else {
			_ = yaml.Unmarshal(bs, &amp;p)
		}
		fmt.Printf(&quot;%s -&gt; %v\n&quot;, fn, p)
	}
}

func readFileIntoBytes(filename string) []byte {
	f, err := os.Open(filename)
	if err != nil {
		panic(err)
	}
	bs, _ := io.ReadAll(f)
	return bs
}
</code></pre>
<p>输出：</p>
<pre><code class="language-tex">person1.json -&gt; {hedon_json 18 { }}
person1.yaml -&gt; {hedon_yaml 18 {nb_yaml_school a_good_price_in_yaml}}
person2.json -&gt; {hedon_json 18 {nb_json_school a_good_place_in_json}}
person2.yaml -&gt; {hedon_yaml 18 { }}
</code></pre>
<p>如果给 <code>School</code> 字段加上 <code>json tag</code> 的话，结果又是不同：</p>
<pre><code class="language-go">type Person struct {
	Name   string `json:&quot;name&quot; yaml:&quot;name&quot;`
	Age    int    `json:&quot;age&quot; yaml:&quot;age&quot;`
	School `json:&quot;school&quot; yaml:&quot;school&quot;`
}
</code></pre>
<p>输出：</p>
<pre><code class="language-tex">person1.json -&gt; {hedon_json 18 {nb_json_school a_good_place_in_json}}
person1.yaml -&gt; {hedon_yaml 18 {nb_yaml_school a_good_price_in_yaml}}
person2.json -&gt; {hedon_json 18 { }}
person2.yaml -&gt; {hedon_yaml 18 { }}
</code></pre>
<p>可以看到受影响的只有 <code>json</code>。</p>
<p>到这里我们可以总结：<strong>在组合场景下，如果不明确指定 <code>tag</code>，<code>yaml</code> 解析期望字段是嵌套的，而 <code>json</code> 解析期望字段是平铺的</strong>。</p>
<h3>5.2 自定义 Tag</h3>
<p>在 Go 中，你可以为结构体字段定义任意的标签。这些标签在编译时会被存储，并且可以在运行时通过反射（reflection）来访问。</p>
<p>假设我们定义一个名为 <code>check</code> 的标签，它用于我们对结构体字段的检查，假设我们这个标签支持以下功能：</p>
<ul>
<li><code>check:&quot;strnoempty&quot;</code>: 字符串不可以为空。</li>
</ul>
<p>假如加入 <code>check</code> 标签的 <code>Person</code> 结构体如下：</p>
<pre><code class="language-go">type Person struct {
	Name string `check:&quot;strnoempty&quot;`
}
</code></pre>
<p>我们来为 <code>check</code> 实现解析函数：</p>
<pre><code class="language-go">func CheckPerson(p Person) error {
	pt := reflect.TypeOf(p)
	pv := reflect.ValueOf(p)
	for i := 0; i &lt; pt.NumField(); i++ {
		field := pt.Field(i)
		tagValue := field.Tag.Get(&quot;check&quot;)
		if tagValue == &quot;&quot; {
			continue
		}
		if field.Type.Kind() == reflect.String &amp;&amp; tagValue == &quot;strnoempty&quot; {
			if err := checkStrNoEmpty(field.Name, pv.Field(i).Interface()); err != nil {
				return err
			}
		}
	}
	return nil
}

func checkStrNoEmpty(fieldName string, v any) error {
	s, ok := v.(string)
	if !ok {
		return fmt.Errorf(&quot;%v is not string&quot;, v)
	}
	if s == &quot;&quot; {
		return fmt.Errorf(&quot;[check] %s should not be empty&quot;, fieldName)
	}
	return nil
}
</code></pre>
<p>测试如下：</p>
<pre><code class="language-go">func main() {
	p1 := Person{}
	p2 := Person{Name: &quot;hedon&quot;}
	fmt.Println(CheckPerson(p1)) // [check] Name should not be empty
	fmt.Println(CheckPerson(p2)) // &lt;nil&gt;
}
</code></pre>
<h2>6. 结构体内存对齐</h2>
<p>在本小节中，我们将探讨 Go 语言结构体的内存结构和对齐策略。</p>
<h3>6.1 问题引出</h3>
<p>思考下面这段代码的输出：</p>
<pre><code class="language-go">type S1 struct {
	num2 int8
	num1 int16
	flag bool
}

type S2 struct {
	num1 int8
	flag bool
	num2 int16
}

func main() {
	fmt.Println(unsafe.Sizeof(S1{}))
	fmt.Println(unsafe.Sizeof(S2{}))
}
</code></pre>
<p>为什么仅是字段顺序不同，<code>S1{}</code> 和 <code>S2{}</code> 的大小就不一样了？</p>
<p>我们可以写个简单的程序来输出 <code>S1</code> 和 <code>S2</code> 的内存结构：</p>
<pre><code class="language-go">func main() {
	s1 := S1{}
	s2 := S2{}
	fmt.Print(&quot;s1: &quot;)
	printMemory(s1)
	fmt.Print(&quot;s2: &quot;)
	printMemory(s2)
}

func printMemory(a any) {
	t := reflect.TypeOf(a)
	mem := make([]int, int(t.Size()))
	for i := 0; i &lt; t.NumField(); i++ {
		field := t.Field(i)
		offset := int(field.Offset)
		size := int(field.Type.Size())
		for j := 0; j &lt; size; j++ {
			mem[j+offset] = i + 1
		}
	}
	fmt.Println(mem)
}
</code></pre>
<p>输出：</p>
<pre><code class="language-go">s1: [1 0 2 2 3 0]
s2: [1 2 3 3]
</code></pre>
<p>其中 <code>1</code>、<code>2</code>、<code>3</code> 分别替代结构体中的第 1/2/3 个字段所占用的内存。这里可以看到 <code>s1</code> 的长度是 6 字节，而 <code>s2</code> 是 4 字节。这里 <code>s1</code> 比 <code>s2</code> 多出的 2 个字节就是这两个填充的 <code>0</code>。这而 2 个字节的填充，就是为了<strong>内存对齐</strong>。</p>
<h3>6.2 内存对齐</h3>
<p>如上分析，<code>s1</code> 的内存结构如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240310154903219.png" alt="s1 内存结构"></p>
<p>如果没有内存对齐呢？<code>s1</code> 的结构可能如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240310155353882.png" alt="没有内存对齐的 s1 内存结构"></p>
<p>如果是 16 位系统的话，那么没有内存对齐的情况下，要访问 <code>s1.num2</code> 字段，就需要跨过 2 个系统字长的内存，效率就低了。具体来说，内存对齐是计算机内存分配的一种优化方式，用于确保数据结构的存储按照特定的字节边界对齐。这种对齐是为了提高计算机处理数据的效率。</p>
<h3>6.3 对齐系数</h3>
<ul>
<li>对齐系数：变量的内存地址必须被对齐系数整除。</li>
<li><code>unsafe.Alignof()</code>: 可以查看值在内存中的对齐系数。</li>
</ul>
<h3>6.4 基本类型对齐</h3>
<pre><code class="language-go">fmt.Printf(&quot;bool size: %d, align: %d\n&quot;, unsafe.Sizeof(bool(true)), unsafe.Alignof(bool(true)))
fmt.Printf(&quot;byte size: %d, align: %d\n&quot;, unsafe.Sizeof(byte(0)), unsafe.Alignof(byte(0)))
fmt.Printf(&quot;int8 size: %d, align: %d\n&quot;, unsafe.Sizeof(int8(0)), unsafe.Alignof(int8(0)))
fmt.Printf(&quot;int16 size: %d, align: %d\n&quot;, unsafe.Sizeof(int16(0)), unsafe.Alignof(int16(0)))
fmt.Printf(&quot;int32 size: %d, align: %d\n&quot;, unsafe.Sizeof(int32(0)), unsafe.Alignof(int32(0)))
fmt.Printf(&quot;int64 size: %d, align: %d\n&quot;, unsafe.Sizeof(int64(0)), unsafe.Alignof(int64(0)))
</code></pre>
<p>输出：</p>
<pre><code class="language-go">bool size: 1, align: 1
byte size: 1, align: 1
int8 size: 1, align: 1
int16 size: 2, align: 2
int32 size: 4, align: 4
int64 size: 8, align: 8
</code></pre>
<p>结论：基本类型的对齐系数跟它的长度一致。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240310160412598.png" alt="基本类型内存对齐"></p>
<h3>6.5 结构体内部对齐</h3>
<p>结构体内存对齐分为内部对齐和结构体之间对齐。</p>
<p>我们先来看结构体内部对齐：</p>
<ul>
<li>指的是结构体内部成员的相对位置（偏移量）；</li>
<li>每个成员的偏移量是 <strong>自身大小</strong> 和 <strong>对齐系数</strong> 的较小值的倍数</li>
</ul>
<pre><code class="language-go">type Demo struct {
  a bool
  b string
  c int16
}
</code></pre>
<p>假如我们定义了上面的结构体 <code>Demo</code>，如果在 64 位系统上（字长为 8 字节）通过上面的规则，可以判断出：（单位为字节）</p>
<ul>
<li>a: size=1, align=1</li>
<li>b: size=16, align=8</li>
<li>c: size=2, align=2</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240310163126556.png" alt="Demo 内存结构"></p>
<p>当然我们也可以通过程序输出来验证：</p>
<pre><code class="language-go">type Demo struct {
	a bool   // size=1, align=1
	b string // size=16, align=8
	c int16  // size=2, align=2
}

func main() {
	d := Demo{}
	fmt.Printf(&quot;a: size=%d, align=%d\n&quot;, unsafe.Sizeof(d.a), unsafe.Alignof(d.a))
	fmt.Printf(&quot;b: size=%d, align=%d\n&quot;, unsafe.Sizeof(d.b), unsafe.Alignof(d.b))
	fmt.Printf(&quot;c: size=%d, align=%d\n&quot;, unsafe.Sizeof(d.c), unsafe.Alignof(d.c))
	printMemory(d)
}

func printMemory(a any) {
	t := reflect.TypeOf(a)
	mem := make([]int, int(t.Size()))
	for i := 0; i &lt; t.NumField(); i++ {
		field := t.Field(i)
		offset := int(field.Offset)
		size := int(field.Type.Size())
		for j := 0; j &lt; size; j++ {
			mem[j+offset] = i + 1
		}
	}
	fmt.Println(mem)
}
</code></pre>
<p>输出：</p>
<pre><code class="language-go">a: size=1, align=1
b: size=16, align=8
c: size=2, align=2
[1 0 0 0 0 0 0 0 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 0 0 0 0 0 0]
</code></pre>
<h3>6.6 结构体长度填充</h3>
<p>上面 Demo 结构体最后还填了 6 个字节的 0，这就是结构体长度填充：</p>
<ul>
<li>结构体通过填充长度，来对齐系统字长。</li>
<li>结构体长度是 <strong>最大成员长度</strong> 和 <strong>系统字长</strong> 较小值的整数倍。</li>
</ul>
<p>我的系统环境是 m2max，系统字长是 8 字节，Demo 最大成员长度是 <code>b string</code>，即 16 个字节，所以 <code>Demo</code> 的长度应该是 <code>8</code> 的倍数，所以最后填充了 6 个字节的 0。</p>
<h3>6.7 结构体之间对齐</h3>
<ul>
<li>结构体之间对齐，是为了确定结构体的第一个成员变量的内存地址，以让后面的成员地址都合法。</li>
<li>结构体的对齐系数是 <strong>其成员的最大对齐系数</strong>；</li>
</ul>
<h3>6.8 空结构体对齐</h3>
<p>前面我们专门讨论了空结构体 <code>struct{}</code>，它们的内存地址统一指向 <code>zerobase</code>，而且内存长度为 0。这也导致了它的内存对齐规则，有一些不同。具体可以分为以下 4 个情况。</p>
<h4>6.8.1 空结构体单独存在</h4>
<p>空结构体单独存在时，其内存地址为 <code>zerobase</code>，不额外分配内存。</p>
<h4>6.8.2 空结构体在结构体最前</h4>
<p>空结构体是结构体第一个字段时，它的地址跟结构体本身及结构体第 2 个字段一样，不占据内存空间。</p>
<pre><code class="language-go">type TestEmpty struct {
	empty struct{}
	a     bool
	b     string
}

func main() {
	te := TestEmpty{}
	fmt.Printf(&quot;address of te: %p\n&quot;, &amp;te)
	fmt.Printf(&quot;address of te.empty: %p\n&quot;, &amp;(te.empty))
	fmt.Printf(&quot;address of te.a: %p\n&quot;, &amp;(te.a))
	fmt.Printf(&quot;empty: size=%d, align=%d\n&quot;, unsafe.Sizeof(te.empty), unsafe.Alignof(te.empty))
	fmt.Printf(&quot;a: size=%d, align=%d\n&quot;, unsafe.Sizeof(te.a), unsafe.Alignof(te.a))
	fmt.Printf(&quot;b: size=%d, align=%d\n&quot;, unsafe.Sizeof(te.b), unsafe.Alignof(te.b))
	printMemory(te)
}
</code></pre>
<p>输出：</p>
<pre><code class="language-go">address of te: 0x140000ba000
address of te.empty: 0x140000ba000
address of te.a: 0x140000ba000
empty: size=0, align=1
a: size=1, align=1
b: size=16, align=8
[2 0 0 0 0 0 0 0 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3]
</code></pre>
<h4>6.8.3 空结构体在结构体中间</h4>
<p>空结构体出现在结构体中时，地址跟随前一个变量。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240310165025700.png" alt="空结构体在结构体中间内存对齐"></p>
<pre><code class="language-go">type TestEmpty struct {
	a     bool
	empty struct{}
	b     string
}

func main() {
	te := TestEmpty{}
	fmt.Printf(&quot;address of te: %p\n&quot;, &amp;te)
	fmt.Printf(&quot;address of te.a: %p\n&quot;, &amp;(te.a))
	fmt.Printf(&quot;address of te.empty: %p\n&quot;, &amp;(te.empty))
	fmt.Printf(&quot;a: size=%d, align=%d\n&quot;, unsafe.Sizeof(te.a), unsafe.Alignof(te.a))
	fmt.Printf(&quot;empty: size=%d, align=%d\n&quot;, unsafe.Sizeof(te.empty), unsafe.Alignof(te.empty))
	fmt.Printf(&quot;b: size=%d, align=%d\n&quot;, unsafe.Sizeof(te.b), unsafe.Alignof(te.b))
	printMemory(te)
}
</code></pre>
<p>输出：</p>
<pre><code class="language-go">address of te: 0x14000128000
address of te.a: 0x14000128000
address of te.empty: 0x14000128001
a: size=1, align=1
empty: size=0, align=1
b: size=16, align=8
[1 0 0 0 0 0 0 0 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3]
</code></pre>
<h4>6.8.4 空结构体在结构体最后</h4>
<p>空结构体出现在结构体最后，如果开启了一个新的系统字长，则需要补零，防止与其他结构体混用地址。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240310164933867.png" alt="空结构体在结构体最后内存对齐"></p>
<pre><code class="language-go">type TestEmpty struct {
	a     bool
	b     string
	empty struct{}
}

func main() {
	te := TestEmpty{}
	fmt.Printf(&quot;address of te: %p\n&quot;, &amp;te)
	fmt.Printf(&quot;address of te.a: %p\n&quot;, &amp;(te.a))
	fmt.Printf(&quot;address of te.empty: %p\n&quot;, &amp;(te.empty))
	fmt.Printf(&quot;a: size=%d, align=%d\n&quot;, unsafe.Sizeof(te.a), unsafe.Alignof(te.a))
	fmt.Printf(&quot;b: size=%d, align=%d\n&quot;, unsafe.Sizeof(te.b), unsafe.Alignof(te.b))
	fmt.Printf(&quot;empty: size=%d, align=%d\n&quot;, unsafe.Sizeof(te.empty), unsafe.Alignof(te.empty))
	printMemory(te)
}
</code></pre>
<p>输出：</p>
<pre><code class="language-go">address of te: 0x1400006a020
address of te.a: 0x1400006a020
address of te.empty: 0x1400006a038
a: size=1, align=1
b: size=16, align=8
empty: size=0, align=1
[1 0 0 0 0 0 0 0 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 0 0 0 0 0 0 0 0]
</code></pre>
<h3>6.9 使用 fieldalignment -fix 工具优化结构体内存对齐</h3>
<p>还记得我们最开始提出的问题吗？</p>
<pre><code class="language-go">type S1 struct {
	num2 int8
	num1 int16
	flag bool
}

type S2 struct {
	num1 int8
	flag bool
	num2 int16
}

func main() {
	fmt.Println(unsafe.Sizeof(S1{}))
	fmt.Println(unsafe.Sizeof(S2{}))
}
</code></pre>
<p><code>S1</code> 和 <code>S2</code> 提供的程序功能是一样的，但是 <code>S1</code> 却比 <code>S2</code> 花费了更多的内存空间。所以有时候我们可以通过仅仅调整结构体内部字段的顺序就减少不少的内存空间消耗。在这个时候 <code>fieldalignment</code> 可以帮助我们自动检测并优化。</p>
<p>你可以运行下面命令安装 <code>fieldalignment</code> 命令：</p>
<pre><code class="language-bash">go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
</code></pre>
<p>然后在项目根目录下运行下面命令，对我们的代码进行检查：</p>
<pre><code class="language-bash">go vet -vettool=$(which fieldalignment) ./...
</code></pre>
<p>这里会输出：</p>
<pre><code class="language-bash">./main.go:9:9: struct of size 6 could be 4
</code></pre>
<p>这个时候可以执行 <code>fieldalignment -fix 目录|文件</code> ，它会自动帮我们的代码进行修复，但是<strong>强烈建议你在运行之前，备份你的代码，因为注释会被删除！</strong></p>
<pre><code class="language-bash">fieldalignment -fix ./...
</code></pre>
<p>输出：</p>
<pre><code class="language-go">	/Users/hedon/GolandProjects/learn-go-struct/main.go:9:9: struct of size 6 could be 4
</code></pre>
<p>这个时候 <code>S1</code> 已经被优化好了：</p>
<pre><code class="language-go">type S1 struct {
	num1 int16
	num2 int8
	flag bool
}
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨HTTPie</title>
      <link>https://hedon.top/blog/rust-action-httpie/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-httpie/</guid>
      <pubDate>Wed, 06 Mar 2024 22:22:02 GMT</pubDate>
      <description>我们深入探讨了如何使用 Rust 语言来实现一个类似于 HTTPie 的命令行工具。</description>
      <category>rust</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<h2>概述</h2>
<p>之前学习过《陈天·Rust 编程第一课 - 04 ｜ get hands dirty：来写个实用的 CLI 小工具》，学的时候迷迷糊糊。后来在系统学习完 Rust 后，重新回过头来看这个实战小案例，基本上都能掌握，并且有了一些新的理解。所以我决定以一个 Rust 初学者的角度，并以最新版本的 Rust（1.7.6）和 clap（4.5.1）来重新实现这个案例，期望能对 Rust 感兴趣的初学者提供一些帮助。</p>
<p>本文将实现的应用叫 HTTPie，HTTPie 是一个用 Python 编写的命令行 HTTP 客户端，其目标是使 CLI 与 web 服务的交互尽可能愉快。它被设计为一个 <code>curl</code> 和 <code>wget</code> 的替代品，提供易于使用的界面和一些用户友好的功能，如 JSON 支持、语法高亮和插件。它对于测试、调试和通常与 HTTP 服务器或 RESTful API 进行交云的开发人员来说非常有用。</p>
<p>HTTPie 的一些关键特性包括：</p>
<ol>
<li><strong>JSON 支持</strong>：默认情况下，HTTPie 会自动发送 JSON，并且可以轻松地通过命令行发送 JSON 请求体。</li>
<li><strong>语法高亮</strong>：它会为 HTTP 响应输出提供语法高亮显示，使得结果更加易于阅读。</li>
<li><strong>插件</strong>：HTTPie 支持插件，允许扩展其核心功能。</li>
<li><strong>表单和文件上传</strong>：可以很容易地通过表单上传文件。</li>
<li><strong>自定义 HTTP 方法和头部</strong>：可以发送任何 HTTP 方法的请求，自定义请求头部。</li>
<li><strong>HTTPS、代理和身份验证支持</strong>：支持 HTTPS 请求、使用代理以及多种 HTTP 身份验证机制。</li>
<li><strong>流式上传和下载</strong>：支持大文件的流式上传和下载。</li>
<li><strong>会话支持</strong>：可以保存和重用常用的请求和集合。</li>
</ol>
<p>本文我们将实现其中的 <code>1</code>、<code>2</code> 和 <code>5</code>。我们会支持发送 GET 和 POST 请求，其中 POST 支持设置请求头和 JSON 数据。</p>
<p>在本文中，你可以学习到：</p>
<ul>
<li>如何用 <code>clap</code> 解析命令行参数。</li>
<li>如何用 <code>tokio</code> 进行异步编程。</li>
<li>如何用 <code>reqwest</code> 发送 HTTP 请求。</li>
<li>如何用 <code>colored</code> 在终端输出带颜色的内容。</li>
<li>如何用 <code>jsonxf</code> 美化 json 字符串。</li>
<li>如何用 <code>anyhow</code> 配合 <code>?</code> 进行错误传播。</li>
<li>如何使用 <code>HTTPie</code> 来进行 HTTP 接口测试。</li>
</ul>
<p>在进行实际开发之前，推荐你先了解一下：</p>
<p><a href="https://hedon.top/2024/03/02/rust-crate-reqwest/">https://hedon.top/2024/03/02/rust-crate-reqwest/</a>
<a href="https://hedon.top/2024/03/05/rust-crate-anyhow/">https://hedon.top/2024/03/05/rust-crate-anyhow/</a>
<a href="https://hedon.top/2024/03/02/rust-crate-clap/">https://hedon.top/2024/03/02/rust-crate-clap/</a></p>
<p>本文完整代码：</p>
<p><a href="https://github.com/hedon-rust-road/httpie">https://github.com/hedon-rust-road/httpie</a></p>
<h2>开发思路</h2>
<h3>HTTP 协议</h3>
<p>回顾一下 HTTP 协议的请求体和响应体结构。</p>
<p>请求结构：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240303131157545.png" alt="http request structure"></p>
<p>响应结构：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240303131123618.png" alt="http response structure"></p>
<h3>命令分析</h3>
<p>在本文中，我们就实现 HTTPie cli 官方的这个<a href="https://github.com/httpie/cli?tab=readme-ov-file#examples">示例</a>：即允许指定请求方法、携带 headers 和 json 数据发送请求。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240303131445159.png" alt="HTTPie 官方示例"></p>
<p>我们来拆解一下，这个命令可以分为以下几个部分：</p>
<pre><code class="language-tex">httpie &lt;METHOD&gt; &lt;URL&gt; [headers | params]...
</code></pre>
<ul>
<li><code>&lt;METHOD&gt;</code>: 请求方法，本案例中，我们仅支持 GET 和 POST。</li>
<li><code>&lt;URL&gt;</code>: 请求地址。</li>
<li><code>&lt;HEADERS&gt;</code>: 请求头，格式为 <code>h1:v1</code>。</li>
<li><code>&lt;PARAMS&gt;</code>: 请求参数，格式为 <code>k1=v1</code>，最终以 json 结构发送。</li>
</ul>
<h3>效果展示</h3>
<pre><code class="language-bash">➜  httpie git:(master) ✗ ./Httpie --help
Usage: Httpie &lt;COMMAND&gt;

Commands:
  get
  post
  help  Print this message or the help of the given subcommand(s)

Options:
  -h, --help     Print help
  -V, --version  Print version
</code></pre>
<p>其中 post 子命令：</p>
<pre><code class="language-bash">Usage: Httpie post &lt;URL&gt; &lt;BODY&gt;...

Arguments:
  &lt;URL&gt;      Specify the url you wanna request to
  &lt;BODY&gt;...  Set the request body. Examples: headers: header1:value1 params: key1=value1

Options:
  -h, --help  Print help
</code></pre>
<p>请求示例：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240305225846224.png" alt="httpie response demo"></p>
<h3>思路梳理</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240306223319742.png" alt="httpie 开发思路梳理"></p>
<p><strong>第 1 步：解析命令行参数</strong></p>
<p>本案例中 httpie 支持 2 个子命令：</p>
<ul>
<li>get 支持 url 参数</li>
<li>post 支持 url、body 参数，因为其中 headers 和 params 是变长的，我们统一用 <code>Vec&lt;String&gt;</code> 类型的 body 来接收，然后用 <code>:</code> 和 <code>=</code> 来区分它们。</li>
</ul>
<p><strong>第 2 步：发送请求</strong></p>
<ol>
<li>使用 reqwest 创建 http client；</li>
<li>设置 url；</li>
<li>设置 method；</li>
<li>设置 headers；</li>
<li>设置 params；</li>
<li>发送请求；</li>
<li>获取响应体。</li>
</ol>
<p><strong>第 3 步：打印响应</strong></p>
<ol>
<li>打印 http version 和 status，并使用 colored 赋予蓝色；</li>
<li>打印 response headers，并使用 colored 赋予绿色；</li>
<li>确定 content-type，如果是 json，我们就用 jsonxf 美化 json 串并使用 colored 赋予蓝绿色输出，如果是其他类型，这里我们就输出原文即可。</li>
</ol>
<h2>实战过程</h2>
<h3>1. 创建项目</h3>
<pre><code class="language-bash">cargo new httpie
</code></pre>
<h3>2. 添加依赖</h3>
<pre><code class="language-toml">[package]
name = &quot;httpie&quot;
version = &quot;0.1.0&quot;
edition = &quot;2021&quot;

[dependencies]
anyhow = &quot;1.0.80&quot;
clap = { version = &quot;4.5.1&quot;, features = [&quot;derive&quot;] }
colored = &quot;2.1.0&quot;
jsonxf = &quot;1.1.1&quot;
mime = &quot;0.3.17&quot;
reqwest = { version = &quot;0.11.24&quot;, features = [&quot;json&quot;] }
tokio = { version = &quot;1.36.0&quot;, features = [&quot;rt&quot;, &quot;rt-multi-thread&quot;, &quot;macros&quot;] }
</code></pre>
<ul>
<li><code>anyhow</code>: 用于简化异常处理。</li>
<li><code>clap</code>: 解析命令行参数。</li>
<li><code>colored</code>: 为终端输出内容赋予颜色。</li>
<li><code>jsonxf</code>: 美化 json 串。</li>
<li><code>mime</code>: 提供了各种 Media Type 的类型封装。</li>
<li><code>reqwest</code>: http 客户端。</li>
<li><code>tokio</code>: 异步库，本案例种我们使用 reqwest 的异步功能。</li>
</ul>
<h3>3. 完整源码</h3>
<pre><code class="language-rust">// src/main.rs  为减小篇幅，省略了单元测试，读者可自行补充。
use std::collections::HashMap;
use reqwest::{Client, header, Response};
use std::str::FromStr;
use anyhow::anyhow;
use clap::{Args, Parser, Subcommand};
use colored::Colorize;
use mime::Mime;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::Url;

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Httpie {
    #[command(subcommand)]
    methods: Method,
}

#[derive(Subcommand)]
enum Method {
    Get(Get),
    Post(Post)
}

#[derive(Args)]
struct Get {
    #[arg(value_parser = parse_url)]
    url: String,
}

#[derive(Args)]
struct Post {
    /// Specify the url you wanna request to.
    #[arg(value_parser = parse_url)]
    url: String,

    /// Set the request body.
    /// Examples:
    ///     headers:
    ///         header1:value1
    ///     params:
    ///         key1=value1
    #[arg(required = true, value_parser = parse_kv_pairs)]
    body: Vec&lt;KvPair&gt;
}

#[derive(Debug, Clone)]
struct KvPair {
    k: String,
    v: String,
    t: KvPairType,
}

#[derive(Debug,Clone)]
enum KvPairType {
    Header,
    Param,
}

impl FromStr for KvPair {
    type Err = anyhow::Error;
    fn from_str(s: &amp;str) -&gt; Result&lt;Self, Self::Err&gt; {
        let pair_type: KvPairType;
        let split_char = if s.contains(&#39;:&#39;) {
            pair_type = KvPairType::Header;
            &#39;:&#39;
        } else {
            pair_type = KvPairType::Param;
            &#39;=&#39;
        };

        let mut split = s.split(split_char);
        let err = || anyhow!(format!(&quot;failed to parse pairs {}&quot;,s));
        Ok(Self {
            k: (split.next().ok_or_else(err)?).to_string(),
            v: (split.next().ok_or_else(err)?).to_string(),
            t: pair_type,
        })
    }
}

fn parse_url(s: &amp;str) -&gt; anyhow::Result&lt;String&gt; {
    let _url: Url = s.parse()?;
    Ok(s.into())
}

fn parse_kv_pairs(s: &amp;str) -&gt; anyhow::Result&lt;KvPair&gt; {
    Ok(s.parse()?)
}

async fn get(client: Client, args: &amp;Get) -&gt; anyhow::Result&lt;()&gt; {
   let resp = client.get(&amp;args.url).send().await?;
    Ok(print_resp(resp).await?)
}

async fn post(client: Client, args: &amp;Post) -&gt; anyhow::Result&lt;()&gt; {
    let mut body = HashMap::new();
    let mut header_map = HeaderMap::new();
    for pair in args.body.iter() {
        match pair.t {
            KvPairType::Param =&gt;  {body.insert(&amp;pair.k, &amp;pair.v);}
            KvPairType::Header =&gt; {
                if let Ok(name) = HeaderName::from_str(pair.k.as_str()) {
                    if let Ok(value) = HeaderValue::from_str(pair.v.as_str()) {
                        header_map.insert(name,value);
                    } else {
                        println!(&quot;Invalid header value for key: {}&quot;, pair.v);
                    }
                } else {
                    println!(&quot;Invalid header key: {}&quot;, pair.k);
                }
            }
        }
    }
    let resp = client.post(&amp;args.url)
        .headers(header_map)
        .json(&amp;body).send().await?;
    Ok(print_resp(resp).await?)
}

async fn print_resp(resp: Response) -&gt; anyhow::Result&lt;()&gt; {
    print_status(&amp;resp);
    print_headers(&amp;resp);
    let mime = get_content_type(&amp;resp);
    let body = resp.text().await?;
    print_body(mime, &amp;body);
    Ok(())
}

fn print_status(resp: &amp;Response) {
    let status = format!(&quot;{:?} {}&quot;, resp.version(), resp.status()).blue();
    println!(&quot;{}\n&quot;, status);
}

fn print_headers(resp: &amp;Response) {
    for (k,v) in resp.headers() {
        println!(&quot;{}: {:?}&quot;, k.to_string().green(), v);
    }
    print!(&quot;\n&quot;);
}

fn print_body(mime: Option&lt;Mime&gt;, resp: &amp;String) {
    match mime {
        Some(v) =&gt; {
            if v == mime::APPLICATION_JSON {
                println!(&quot;{}&quot;, jsonxf::pretty_print(resp).unwrap().cyan())
            }
        }
        _ =&gt; print!(&quot;{}&quot;, resp),
    }
}

fn get_content_type(resp: &amp;Response) -&gt; Option&lt;Mime&gt; {
    resp.headers()
        .get(header::CONTENT_TYPE)
        .map(|v|v.to_str().unwrap().parse().unwrap())
}

#[tokio::main]
async fn main() -&gt; anyhow::Result&lt;()&gt;{
    let httpie = Httpie::parse();
    let client = Client::new();
    let result = match httpie.methods {
        Method::Get(ref args) =&gt; get(client, args).await?,
        Method::Post(ref args) =&gt; post(client, args).await?,
    };
    Ok(result)
}
</code></pre>
<p>可以看到，即使算上 <code>use</code> 部分，总代码也不过 160 行左右，Rust 的 <code>clap</code> 库在 CLI 开发上确实 yyds！</p>
<p>接下来我们来一一拆解这部分的代码，其中关于 <code>clap</code> 的部分我不会过多展开，刚兴趣的读者可以参阅：<a href="/blog/rust-crate-clap/">深入探索 Rust 的 clap 库：命令行解析的艺术</a>。</p>
<h4>3.1 命令行解析</h4>
<p>我们先从 <code>main()</code> 开始：</p>
<pre><code class="language-rust">#[tokio::main]
async fn main() -&gt; anyhow::Result&lt;()&gt;{
    let httpie = Httpie::parse();
    let client = Client::new();
    let result = match httpie.methods {
        Method::Get(ref args) =&gt; get(client, args).await?,
        Method::Post(ref args) =&gt; post(client, args).await?,
    };
    Ok(result)
}
</code></pre>
<p>我们希望使用 <code>clap</code> 的异步功能，所以使用了 <code>async</code> 关键字，同时加上了 <code>tokio</code> 提供的属性宏 <code>#[tokio::main]</code>，用于设置异步环境。为了能够使用 <code>?</code> 快速传播错误，我们设置返回值为 <code>anyhow::Result&lt;()&gt;</code>，本项目中我们不对错误进行过多处理，所以这种方式可以大大简化我们的错误处理过程。</p>
<p><code>main()</code> 中我们使用 <code>Httpie::parse()</code> 解析命令行中的参数，使用 <code>Client::new()</code> 创建一个 http client，根据解析到的命令行参数，我们匹配子命令 <code>methods</code>，分别调用 <code>get()</code> 和 <code>post()</code> 来发送 GET 和 POST 请求。</p>
<p><code>Httpie</code> 的定义如下：</p>
<pre><code class="language-rust">#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Httpie {
    #[command(subcommand)]
    methods: Method,
}
</code></pre>
<p><code>#[derive(Parser)]</code> 是一个过程宏（procedural macro），用于自动为结构体实现 <code>clap::Parser</code> trait。这使得该结构体可以用来解析命令行参数。</p>
<p>在 <code>Httpie</code> 中我们定义了子命令 <code>Method</code>：</p>
<pre><code class="language-rust">#[derive(Subcommand)]
enum Method {
    Get(Get),
    Post(Post)
}
</code></pre>
<p><code>#[derive(Subcommand)]</code> 属性宏会自动为枚举派生一些代码，以便它可以作为子命令来解析命令行参数。目前支持 <code>Get</code> 和 <code>Post</code> 两个子命令，它们分别接收 <code>Get</code> 和 <code>Post</code> 参数：</p>
<pre><code class="language-rust">#[derive(Args)]
struct Get {
    #[arg(value_parser = parse_url)]
    url: String,
}

#[derive(Args)]
struct Post {
    #[arg(value_parser = parse_url)]
    url: String,

    #[arg(value_parser = parse_kv_pairs)]
    body: Vec&lt;KvPair&gt;
}
</code></pre>
<p><code>#[derive(Args)]</code> 属性宏表明当前 struct 是命令的参数，其中 <code>Get</code> 仅支持 <code>url</code> 参数，<code>Post</code> 支持 <code>url</code> 和 <code>body</code> 参数。</p>
<p><code>url</code> 参数我们使用 <code>parse_url</code> 函数来进行解析：</p>
<pre><code class="language-rust">use reqwest::Url;
fn parse_url(s: &amp;str) -&gt; anyhow::Result&lt;String&gt; {
    let _url: Url = s.parse()?;
    Ok(s.into())
}
</code></pre>
<p>这里 <code>reqwest::Url</code> 已经实现了 <code>FromStr</code> trait，所以这里我们可以直接调用 <code>s.parse()</code> 来解析 <code>url</code>。</p>
<p>而 <code>body</code>，因为我们期望 CLI 使用起来像：</p>
<pre><code class="language-bash">httpie url header1:value1 param1=v1
</code></pre>
<p><code>body</code> 就是 <code>header1:value1 param1=v1</code>，一对 kv 就代表着一个 header 或者 param，用 <code>:</code> 和 <code>=</code> 来区分。因为 kv 对的个数的变长的，所以我们使用 <code>Vec&lt;KvPair&gt;</code> 来接收 <code>body</code> 这个参数，并使用 <code>parse_kv_pairs</code> 来解析 kv 对。</p>
<p><code>KvPair</code> 是我们自定义的类型：</p>
<pre><code class="language-rust">#[derive(Debug, Clone)]
struct KvPair {
    k: String,
    v: String,
    t: KvPairType,
}

#[derive(Debug,Clone)]
enum KvPairType {
    Header,
    Param,
}
</code></pre>
<p><code>parse_kv_pairs</code> 的实现如下：</p>
<pre><code class="language-rust">fn parse_kv_pairs(s: &amp;str) -&gt; anyhow::Result&lt;KvPair&gt; {
    Ok(s.parse()?)
}
</code></pre>
<p>在这里，你可以在 <code>parse_kv_pairs()</code> 函数中，对 <code>s</code> 进行解析并返回 <code>anyhow::Result&lt;KvPair&gt;</code>。不过，更优雅，更统一的方式是什么呢？就是像 <code>reqwest::Url</code> 一样，为 <code>KvPair</code> 实现 <code>FromStr</code> trait，这样就可以直接调用 <code>s.parse()</code> 来进行解析了。</p>
<pre><code class="language-rust">impl FromStr for KvPair {
    type Err = anyhow::Error;
    fn from_str(s: &amp;str) -&gt; Result&lt;Self, Self::Err&gt; {
        ...
    }
}
</code></pre>
<h4>3.2 发送请求</h4>
<p>参数解析完，就到了发送请求的地方了，这里使用 <code>reqwest</code> crate 就非常方便了，这里就不赘述了，具体可以参考：<a href="/blog/rust-crate-reqwest/">Rust reqwest 简明教程</a>。</p>
<pre><code class="language-rust">async fn get(client: Client, args: &amp;Get) -&gt; anyhow::Result&lt;()&gt; { ... }
async fn post(client: Client, args: &amp;Post) -&gt; anyhow::Result&lt;()&gt; { ... }
</code></pre>
<h4>3.3 打印响应</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240305225846224.png" alt="httpie response demo"></p>
<p>响应分为 3 个部分：</p>
<ul>
<li>print_status()</li>
<li>print_headers()</li>
<li>print_body()</li>
</ul>
<pre><code class="language-rust">async fn print_resp(resp: Response) -&gt; anyhow::Result&lt;()&gt; {
    print_status(&amp;resp);
    print_headers(&amp;resp);
    let mime = get_content_type(&amp;resp);
    let body = resp.text().await?;
    print_body(mime, &amp;body);
    Ok(())
}
</code></pre>
<p><code>print_status()</code> 比较简单，就是打印 HTTP 版本和响应状态码，然后我们使用 <code>colored</code> crate 的 <code>blue()</code> 使其在终端以<font color="blue">蓝色</font>输出。</p>
<pre><code class="language-rust">fn print_status(resp: &amp;Response) {
    let status = format!(&quot;{:?} {}&quot;, resp.version(), resp.status()).blue();
    println!(&quot;{}\n&quot;, status);
}
</code></pre>
<p><code>print_headers()</code> 中，我们使用 <code>green()</code> 使 header_name 在终端以<font color="green">绿色</font>输出。</p>
<pre><code class="language-rust">fn print_headers(resp: &amp;Response) {
    for (k,v) in resp.headers() {
        println!(&quot;{}: {:?}&quot;, k.to_string().green(), v);
    }
    print!(&quot;\n&quot;);
}
</code></pre>
<p>响应体的格式（Media Type）有很多，本案例中我们仅支持 <code>application/json</code>，所以在 <code>print_body()</code> 之前，我们需要先读取 response header 中的 content-type：</p>
<pre><code class="language-rust">fn get_content_type(resp: &amp;Response) -&gt; Option&lt;Mime&gt; {
    resp.headers()
        .get(header::CONTENT_TYPE)
        .map(|v|v.to_str().unwrap().parse().unwrap())
}
</code></pre>
<p>在 <code>print_resp()</code> 中，对于 <code>application/json</code>，我们使用 <code>jsonxf</code> crate 对进行美化，并使用 <code>cyan()</code> 使其在终端以<font color="cyan">蓝绿色</font>输出。对于其他类型，我们姑且照原文输出。</p>
<pre><code class="language-rust">fn print_body(mime: Option&lt;Mime&gt;, resp: &amp;String) {
    match mime {
        Some(v) =&gt; {
            if v == mime::APPLICATION_JSON {
                println!(&quot;{}&quot;, jsonxf::pretty_print(resp).unwrap().cyan())
            }
        }
        _ =&gt; print!(&quot;{}&quot;, resp),
    }
}
</code></pre>
<h2>总结</h2>
<p>在本文中，我们深入探讨了如何使用 Rust 语言来实现一个类似于 HTTPie 的命令行工具。这个过程包括了对 HTTP 协议的理解、命令行参数的解析、HTTP 客户端的创建和请求发送，以及对响应的处理和展示。通过本文，读者不仅能够获得一个实用的命令行工具，还能够学习到如何使用 Rust 的库来构建实际的应用程序，包括 <code>clap</code>、<code>reqwest</code>、<code>tokio</code> 和 <code>colored</code> 等。此外，文章也说明了在 Rust 中进行异步编程和错误处理的一些常见模式。尽管示例代码的错误处理较为简单，但它提供了一个良好的起点，开发者可以在此基础上进行扩展和改进，以适应更复杂的应用场景。</p>
]]></content:encoded>
    </item>
    <item>
      <title>Rust anyhow 简明教程</title>
      <link>https://hedon.top/blog/rust-crate-anyhow/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-crate-anyhow/</guid>
      <pubDate>Tue, 05 Mar 2024 23:44:00 GMT</pubDate>
      <description>探索 Rust 的 anyhow 库，它提供了一个简单而强大的方式来处理错误。本教程将引导你了解 anyhow 的核心特性，包括易用性、错误链、调试便利性，以及如何在不同场景下利用 anyhow 来简化错误处理。无论是快速原型开发还是应用程序顶层错误处理，anyhow 都是 Rust 开发者的得力助手。</description>
      <category>rust</category><category>Rust 常用库</category>
      <content:encoded><![CDATA[<p><a href="https://docs.rs/anyhow/latest/anyhow/index.html">anyhow</a> 是 Rust 中的一个库，旨在提供灵活的、具体的错误处理能力，建立在 <code>std::error::Error</code> 基础上。它主要用于那些需要简单错误处理的应用程序和原型开发中，尤其是在错误类型不需要被严格区分的场景下。</p>
<p>以下是 <code>anyhow</code> 的几个关键特性：</p>
<ul>
<li><strong>易用性</strong>: <code>anyhow</code> 提供了一个 <code>Error</code> 类型，这个类型可以包含任何实现了 <code>std::error::Error</code> 的错误。这意味着你可以使用 <code>anyhow::Error</code> 来包装几乎所有类型的错误，无需担心具体的错误类型。</li>
<li><strong>简洁的错误链</strong>: <code>anyhow</code> 支持通过 <code>?</code> 操作符来传播错误，同时保留错误发生的上下文。这让错误处理更加直观，同时还能保留错误链，便于调试。</li>
<li><strong>便于调试</strong>: <code>anyhow</code> 支持通过 <code>{:#}</code> 格式化指示符来打印错误及其所有相关的上下文和原因，这使得调试复杂的错误链变得更加简单。</li>
<li><strong>无需关心错误类型</strong>: 在很多情况下，特别是在应用程序的顶层，你可能不需要关心错误的具体类型，只需要知道出错了并且能够将错误信息传递给用户或日志。<code>anyhow</code> 让这一过程变得简单，因为它可以包装任何错误，而不需要显式地指定错误类型。</li>
</ul>
<p>使用 <code>anyhow</code> 的典型场景包括快速原型开发、应用程序顶层的错误处理，或者在库中作为返回错误类型的一个简便选择，尤其是在库的使用者不需要关心具体错误类型的时候。</p>
<h2>anyhow::Error</h2>
<p><code>anyhow::Error</code> 是 <code>anyhow</code> 库定义的一个错误类型。它是一个包装器（wrapper）类型，可以包含任何实现了 <code>std::error::Error</code> trait 的错误类型。这意味着你可以将几乎所有的错误转换为 <code>anyhow::Error</code> 类型，从而在函数之间传递，而不需要在意具体的错误类型。这在快速原型开发或应用程序顶层错误处理中特别有用，因为它简化了错误处理的逻辑。</p>
<p>它的定义如下：</p>
<pre><code class="language-rust">#[cfg_attr(not(doc), repr(transparent))]
pub struct Error {
    inner: Own&lt;ErrorImpl&gt;,
}
</code></pre>
<p>其中核心是 <code>ErrorImpl</code>：</p>
<pre><code class="language-rust">#[repr(C)]
pub(crate) struct ErrorImpl&lt;E = ()&gt; {
    vtable: &amp;&#39;static ErrorVTable,
    backtrace: Option&lt;Backtrace&gt;,
    // NOTE: Don&#39;t use directly. Use only through vtable. Erased type may have
    // different alignment.
    _object: E,
}
</code></pre>
<p><code>ErrorImpl</code> 是一个内部结构体，用于实现 <code>anyhow::Error</code> 类型的具体功能。它包含了三个主要字段：</p>
<ul>
<li><code>vtable</code> 是一个指向静态虚拟表的指针，用于动态派发错误相关的方法。</li>
<li><code>backtrace</code> 是一个可选的回溯（Backtrace）类型，用于存储错误发生时的调用栈信息。</li>
<li><code>_object</code> 字段用于存储具体的错误对象，其类型在编译时被擦除以提供类型安全的动态错误处理。</li>
</ul>
<p>这种设计允许 <code>anyhow</code> 错误封装并表示各种不同的错误类型，同时提供了方法动态派发和回溯功能，以便于错误调试。</p>
<p><code>anyhow::Error</code> 可以包含任何实现了 <code>std::error::Error</code> trait 的错误类型，这里因为下面的 <code>impl</code>：</p>
<pre><code class="language-go">impl&lt;E&gt; StdError for ErrorImpl&lt;E&gt;
where
    E: StdError,
{
    fn source(&amp;self) -&gt; Option&lt;&amp;(dyn StdError + &#39;static)&gt; {
        unsafe { ErrorImpl::error(self.erase()).source() }
    }

    #[cfg(error_generic_member_access)]
    fn provide&lt;&#39;a&gt;(&amp;&#39;a self, request: &amp;mut Request&lt;&#39;a&gt;) {
        unsafe { ErrorImpl::provide(self.erase(), request) }
    }
}
</code></pre>
<h2>anyhow::Result</h2>
<p><code>anyhow::Result</code> 是一个别名（type alias），它是 <code>std::result::Result&lt;T, anyhow::Error&gt;</code> 的简写。在使用 <code>anyhow</code> 库进行错误处理时，你会频繁地看到这个类型。它基本上是标准的 <code>Result</code> 类型，但错误类型被固定为 <code>anyhow::Error</code>。这使得你可以很容易地在函数之间传递错误，而不需要声明具体的错误类型。</p>
<pre><code class="language-rust">pub type Result&lt;T, E = Error&gt; = core::result::Result&lt;T, E&gt;;
</code></pre>
<p>使用 <code>anyhow::Result</code> 的好处在于它提供了一种统一的方式来处理错误。你可以使用 <code>?</code> 操作符来传播错误，同时保留错误的上下文信息和回溯。这极大地简化了错误处理代码，尤其是在多个可能产生不同错误类型的操作链中。</p>
<h2>3 个核心使用技巧</h2>
<ul>
<li>使用 <code>Result&lt;T, anyhow::Error&gt;</code> 或者 <code>anyhow::Result&lt;T&gt;</code> 作为返回值，然后利用 <code>?</code> 语法糖无脑传播报错。</li>
<li>使用 with_context(f) 来附加错误信息。</li>
<li>使用 downcast 反解具体的错误类型。</li>
</ul>
<h2>实战案例</h2>
<p>下面我们用一个案例来体会 <code>anyhow</code> 的使用方式：</p>
<p>我们的需求是：打开一个文件，解析文件中的数据并进行大写化，然后输出处理后的数据。</p>
<pre><code class="language-rust">use anyhow::{Result, Context};
use std::{fs, io};

// 1. 读取文件、解析数据和执行数据操作都可能出现错误，
// 所以我们需要返回 Result 来兼容异常情况。
// 这里我们使用 anyhow::Result 来简化和传播错误。
fn read_and_process_file(file_path: &amp;str) -&gt; Result&lt;()&gt; {
    // 尝试读取文件
    let data = fs::read_to_string(file_path)
        // 2. 使用 with_context 来附加错误信息，然后利用 ? 语法糖传播错误。
        .with_context(||format!(&quot;failed to read file `{}`&quot;, file_path))?;

    // 解析数据
    let processed_data = parse_data(&amp;data)
        .with_context(||format!(&quot;failed to parse data from file `{}`&quot;, file_path))?;

    // 执行数据操作
    perform_some_operation(processed_data)
        .with_context(|| &quot;failed to perform operation based on file data&quot;)?;

    Ok(())
}

fn parse_data(data: &amp;str) -&gt; Result&lt;String&gt; {
    Ok(data.to_uppercase())
}

fn perform_some_operation(data: String) -&gt; Result&lt;()&gt; {
    println!(&quot;processed data: {}&quot;, data);
    Ok(())
}

fn main() {
    let file_path = &quot;./anyhow.txt&quot;;
  	// 执行处理逻辑
    let res =  read_and_process_file(file_path);
  	// 处理结果
    match res {
        Ok(_) =&gt; println!(&quot;successfully!&quot;),
        Err(e) =&gt; {
            // 3. 使用 downcast 来反解出实际的错误实例，本案例中可能出现的异常是 io::Error。
            if let Some(my_error) = e.downcast_ref::&lt;io::Error&gt;() {
                println!(&quot;has io error: {:#}&quot;, my_error);
            } else {
                println!(&quot;unknown error: {:?}&quot;, e);
            }
        }
    }
}
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>深入探索 Rust 的 clap 库：命令行解析的艺术</title>
      <link>https://hedon.top/blog/rust-crate-clap/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-crate-clap/</guid>
      <pubDate>Sat, 02 Mar 2024 20:08:59 GMT</pubDate>
      <description>本文将深入探索 Rust 中一个非常流行的命令行解析工具 clap，本文会先详细介绍 clap Derive 和 Builder 两种构建命令行工具的方式，并实战 httpie 工具，最后还将 clap 与 Go 语言中在命令行解析同样流行的 cargo 进行比较。</description>
      <category>rust</category><category>clap</category><category>Rust 常用库</category>
      <content:encoded><![CDATA[<h2>版本声明</h2>
<ul>
<li>Rust: 1.76</li>
<li><a href="https://docs.rs/clap/4.5.1/clap/index.html">clap: 4.5.1</a></li>
<li><a href="https://docs.rs/clap_complete/4.5.1/clap_complete/index.html">clap_complete 4.5.1</a></li>
<li><a href="https://docs.rs/rpassword/7.3.1/rpassword/index.html">rpassword: 7.3.1</a></li>
</ul>
<h2>结论先行</h2>
<p>本文将从 CLI（Command Line Interface）命令行工具的概述讲起，介绍一个优秀的命令行工具应该具备的功能和特性。然后介绍 Rust 中一个非常优秀的命令行解析工具 <code>clap</code> 经典使用方法，并利用 <code>clap</code> 实现一个类似于 <code>curl</code> 的工具 <code>httpie</code>。文章最后还将 <code>clap</code> 于 Go 语言中同样优秀的命令行解析工具 <code>cobra</code> 进行一个简单对比，便于读者进一步体会 <code>clap</code> 的简洁和优秀。</p>
<p>本文将包含以下几个部分：</p>
<ol>
<li><strong>CLI 概述</strong>：从 CLI 的基本概念出发，介绍优秀命令行工具应该具备的功能特性，并以 curl 作为经典范例进行说明。</li>
<li><strong>详细介绍 clap</strong>：基于 clap 官方文档，分别详细介绍 clap 以 derive 和 builder 两个方式构建 cli 的常用方法。</li>
<li><strong>实战 httpie</strong>：参考陈天老师的《Rust 编程第一课》，用最新的 clap 版本（1.7.6）实现 httpie 工具。</li>
<li><strong>对比 cobra</strong>：从设计理念和目标、功能特点、使用场景等方面简要对比 clap 和 Go 流行的命令行解析库 cobra。</li>
</ol>
<p>特此声明，本文包含 AI 辅助生成内容，如有错误遗漏之处，敬请指出。</p>
<h2>CLI 概述</h2>
<p>CLI（Command Line Interface，命令行界面）是一种允许用户通过文本命令与计算机程序或操作系统进行交互的接口。与图形用户界面（GUI，Graphical User Interface）相比，CLI 不提供图形元素，如按钮或图标，而是依赖于文本输入。用户通过键盘输入特定的命令行指令，命令行界面解释这些指令并执行相应的操作。</p>
<p>一款优秀的 CLI 工具应该具备以下的功能和特性，以提升用户体验和效率：</p>
<p>一个优秀的命令行工具（CLI, Command Line Interface）应该具备以下功能和特性，以提升用户体验和效率：</p>
<ol>
<li><strong>直观易用</strong>：<ul>
<li><strong>简洁的命令语法</strong>：命令和参数的设计应直观易懂，方便用户记忆和使用。</li>
<li><strong>自动补全</strong>：支持命令和参数的自动补全功能，提高用户输入效率。</li>
<li><strong>命令别名</strong>：提供常用命令的简短别名，减少输入的工作量。</li>
</ul>
</li>
<li><strong>强大的帮助系统</strong>：<ul>
<li><strong>详细的帮助文档</strong>：每个命令和参数都应有清晰的说明文档。</li>
<li><strong>示例使用方式</strong>：提供常见的使用示例，帮助用户快速理解和应用。</li>
<li><strong>内置帮助命令</strong>：通过如<code>--help</code>或<code>-h</code>参数轻松访问帮助信息。</li>
</ul>
</li>
<li><strong>错误处理与反馈</strong>：<ul>
<li><strong>清晰的错误信息</strong>：出现错误时，提供明确、具体的错误信息，帮助用户快速定位问题。</li>
<li><strong>建议和解决方案</strong>：在可能的情况下，给出错误解决的建议或自动修复选项。</li>
</ul>
</li>
<li><strong>高效的执行和输出</strong>：<ul>
<li><strong>快速响应</strong>：命令执行应迅速，减少用户等待时间。</li>
<li><strong>格式化的输出</strong>：提供易于阅读和解析的输出格式，如表格、JSON 或 XML 等。</li>
<li><strong>输出过滤和排序</strong>：允许用户根据需要过滤和排序输出结果，提高信息的查找效率。</li>
</ul>
</li>
<li><strong>跨平台兼容</strong>：<ul>
<li><strong>多平台支持</strong>：能够在不同的操作系统上运行，如 Windows、macOS、Linux 等。</li>
<li><strong>环境适应性</strong>：自动适应不同的终端环境和字符编码，确保输出显示正确。</li>
</ul>
</li>
<li><strong>安全性</strong>：<ul>
<li><strong>安全的默认设置</strong>：默认配置应强调安全，避免暴露敏感信息。</li>
<li><strong>数据加密</strong>：在处理敏感信息（如密码）时，应使用加密手段保护数据安全。</li>
</ul>
</li>
<li><strong>版本管理</strong>：<ul>
<li><strong>版本控制</strong>：提供命令查看工具版本，支持多版本共存或升级。</li>
<li><strong>向后兼容</strong>：新版本应尽量保持与旧版本的兼容性，避免破坏用户现有的工作流程。</li>
</ul>
</li>
</ol>
<p>这些特性不仅能够提高用户的工作效率，还能增强用户体验，使命令行工具更加强大和易用。</p>
<p>下面我们以 <code>curl</code> 为例，看看优秀的 CLI 工具大概长什么样子。</p>
<p><code>curl</code> 是一种命令行工具和库，用于传输数据。它支持多种协议，包括 HTTP、HTTPS、FTP、FTPS、SCP、SFTP、TFTP、TELNET、DICT、LDAP、LDAPS、IMAP、POP3、SMTP 和 RTSP 等。<code>curl</code> 是一个非常强大和灵活的工具，广泛应用于自动化脚本、系统测试、数据收集和许多其他用途。</p>
<p>进入终端，我们可以用下面命令查看 <code>curl</code> 的说明文档：</p>
<pre><code class="language-bash">➜  ~ curl --help
Usage: curl [options...] &lt;url&gt;
 -d, --data &lt;data&gt;          HTTP POST data
 -f, --fail                 Fail fast with no output on HTTP errors
 -h, --help &lt;category&gt;      Get help for commands
 -i, --include              Include protocol response headers in the output
 -o, --output &lt;file&gt;        Write to file instead of stdout
 -O, --remote-name          Write output to a file named as the remote file
 -s, --silent               Silent mode
 -T, --upload-file &lt;file&gt;   Transfer local FILE to destination
 -u, --user &lt;user:password&gt; Server user and password
 -A, --user-agent &lt;name&gt;    Send User-Agent &lt;name&gt; to server
 -v, --verbose              Make the operation more talkative
 -V, --version              Show version number and quit

This is not the full help, this menu is stripped into categories.
Use &quot;--help category&quot; to get an overview of all categories.
For all options use the manual or &quot;--help all&quot;.
</code></pre>
<p>使用示例：</p>
<ul>
<li>下载文件：<pre><code class="language-bash">curl -O http://example.com/file.txt
</code></pre>
</li>
<li>发送 POST 请求：<pre><code class="language-bash">curl -d &quot;param1=value1&amp;param2=value2&quot; http://example.com/resource
</code></pre>
</li>
<li>使用 HTTPS 并忽略证书验证：<pre><code class="language-bash">curl -k https://example.com
</code></pre>
</li>
<li>使用基本认证：<pre><code class="language-bash">curl -u username:password http://example.com
</code></pre>
</li>
</ul>
<p><code>curl</code> 的这些特性使其成为开发者、系统管理员和自动化脚本中广泛使用的工具之一。</p>
<h2>clap</h2>
<h3>概述</h3>
<p><code>clap</code>，代表 <em>Command Line Argument Parser</em>，是一个旨在创建直观、易用且功能强大的命令行界面的 Rust 库。截至目前（2024.2），<code>clap</code> 已经发展到了 4.5.1 版本，它通过简化命令行参数的处理，让开发者能更专注于应用逻辑的构建。</p>
<p><code>clap</code> 之所以在 Rust 社区如此流行，得益于以下几个优点：</p>
<p><strong>1. 易于使用</strong></p>
<p><code>clap</code> 的设计理念是让命令行参数的解析变得简单而直观。即使是没有经验的开发者也能快速上手，通过几行代码就能实现复杂的命令行参数解析。</p>
<p><strong>2. 功能丰富</strong></p>
<p><code>clap</code> 提供了广泛的功能来满足各种命令行解析需求，包括但不限于：</p>
<ul>
<li><strong>自动生成的帮助信息</strong>：<code>clap</code> 能根据定义的参数自动生成帮助信息，包括参数的说明、类型、默认值等。</li>
<li><strong>强大的错误提示</strong>：当用户输入无效的命令行参数时，<code>clap</code> 会提供清晰且有用的错误提示，帮助用户快速定位问题。</li>
<li><strong>参数验证</strong>：开发者可以为参数设定验证规则，确保输入的参数符合预期。</li>
<li><strong>复杂的命令结构</strong>：支持子命令的嵌套，允许构建复杂的命令行应用结构。</li>
<li><strong>自定义派生</strong>：通过 <code>clap</code> 的派生宏，可以简化命令行解析器的定义，使代码更加清晰。</li>
</ul>
<p><strong>3. 高度可定制</strong></p>
<p><code>clap</code> 允许开发者高度定制命令行解析的行为和外观，包括自定义帮助信息的格式、控制错误消息的显示方式等。这种灵活性意味着你可以根据应用程序的需求调整 <code>clap</code> 的行为。</p>
<p><strong>4. 性能优异</strong></p>
<p>尽管 <code>clap</code> 功能强大，但它仍然非常注重性能。<code>clap</code> 经过优化，以尽可能少的性能开销处理命令行参数。</p>
<p><strong>5. 活跃的社区支持</strong></p>
<p><code>clap</code> 有一个非常活跃的社区，在 GitHub 上不断有新的贡献者加入。这意味着 <code>clap</code> 不断地得到改进和更新，同时也有大量的社区资源可供参考。</p>
<h3>Derive vs Builder (1) 初探</h3>
<p><code>clap</code> 提供了 2 种构建命令行的方式，分别为 <code>Derive</code> 和 <code>Builder</code>。顾名思义，<code>Derive</code> 就是利用宏强大的功能来构建命令行，而 <code>Builder</code> 则采用构建者模式链式构建命令行工具。</p>
<p>在这里我们先给出示例来直观感受这 2 种构建方式的不同：</p>
<p>Derive:</p>
<pre><code class="language-rust">#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    /// Specify your name
    name: String,

    /// Specify your age optionally
    #[arg(short, long)]
    age: Option&lt;i8&gt;,
}

fn main() {
    let cli = Cli::parse();
    println!(&quot;name: {}&quot;, cli.name);
    println!(&quot;age: {:?}&quot;, cli.age);
}
</code></pre>
<p>Builder:</p>
<pre><code class="language-rust">fn main() {
    let matches = Command::new(&quot;myapp&quot;)
  			.version(&quot;1.0.0&quot;)
  			.author(&quot;hedon&quot;)
  			.about(&quot;this is the short about&quot;)
  			.long_about(&quot;this is the long about&quot;)
        .arg(arg!([NAME]).required(true).help(&quot;Specify your name&quot;))
        .arg(arg!(-a --age &lt;AGE&gt;)
            .value_parser(clap::value_parser!(u8))
            .help(&quot;Specify your age optionally&quot;))
        .get_matches();

    println!(&quot;name: {:?}&quot;, matches.get_one::&lt;String&gt;(&quot;NAME&quot;));
    println!(&quot;age: {:?}&quot;, matches.get_one::&lt;u8&gt;(&quot;age&quot;));
}
</code></pre>
<p>这 2 个程序都实现了相同的功能，使用 <code>--help</code> ，输出的内容大致都如下：</p>
<pre><code class="language-bash">Usage: derive [OPTIONS] &lt;NAME&gt;

Arguments:
  &lt;NAME&gt;  Specify your name

Options:
  -a, --age &lt;AGE&gt;  Specify your age optionally
  -h, --help       Print help
</code></pre>
<p>通过观察，可以发现 Derive 模式下，宏中的每一个属性，如 <code>version</code>、<code>author</code> 等，都对应到 Builder 模式下一个同名的函数。</p>
<p>下面我们将从**「应用配置」<strong>、</strong>「参数类型」<strong>和</strong>「参数校验」**三个方面，分别介绍 <code>clap</code> 中 Derive 和 Builder 两种模式构建 CLI 的常用方法。</p>
<p>特别说明：后续的例子均在 <code>examples</code> 目录下实现，故编译和执行命令都包含 example。</p>
<p>目录结构大概如下：</p>
<pre><code class="language-bash">➜  learn-clap git:(master) ✗ tree
.
├── Cargo.lock
├── Cargo.toml
├── examples
│   ├── optional.rs
├── src
│   └── main.rs
└── target
    └── release
        └── examples
        		└── optional
</code></pre>
<h3>Derive</h3>
<p>要使用 <code>clap</code> 的 Derive 模式，需要：</p>
<pre><code class="language-bash">cargo add clap --features derive
</code></pre>
<h4>1. 应用配置</h4>
<p>我们需要定义一个 <code>strut</code> 来表示我们的 <code>application</code>，利用它来承载应用的参数：</p>
<pre><code class="language-rust">/// The example of clap derive
#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    /// Specify your name
    name: String,

    /// Specify your age optionally
    #[arg(short, long)]
    age: Option&lt;i8&gt;,
}

fn main() {
    let cli = Cli::parse();
    println!(&quot;name: {}&quot;, cli.name);
    println!(&quot;age: {:?}&quot;, cli.age);
}
</code></pre>
<p><code>#[derive(Parser)]</code> 是一个过程宏（procedural macro），用于自动为结构体实现 <code>clap::Parser</code> trait。这使得该结构体可以用来解析命令行参数。</p>
<ul>
<li>使用 <code>#[derive(Parser)]</code>，你可以简化命令行解析的代码，因为 <code>clap</code> 会根据结构体的字段自动生成命令行解析的逻辑。</li>
<li>每个字段都对应一个命令行参数，字段的类型和属性用来决定参数的解析方式和验证规则。</li>
</ul>
<p><code>#[command(version, about, long_about = None)]</code> 属性用于为整个命令行程序提供元信息，它支持以下几个元素：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240301202114143.png" alt="#[command] 支持的元素"></p>
<p><code>#[arg(short, long)]</code> 属性用于配置命令参数的元信息，它支持以下几个属性：</p>
<table>
<thead>
<tr>
<th>属性</th>
<th>方法</th>
<th>默认值/行为</th>
<th>备注</th>
</tr>
</thead>
<tbody><tr>
<td>id</td>
<td>Arg::id</td>
<td>field’s name</td>
<td>当属性不存在时，使用字段名</td>
</tr>
<tr>
<td>value_parser</td>
<td>Arg::value_parser</td>
<td>auto-select based on field type</td>
<td>当属性不存在时，会基于字段类型自动选择实现</td>
</tr>
<tr>
<td>action</td>
<td>Arg::action</td>
<td>auto-select based on field type</td>
<td>当属性不存在时，会基于字段类型自动选择动作</td>
</tr>
<tr>
<td>help</td>
<td>Arg::help</td>
<td>Doc comment summary</td>
<td>当属性不存在时，使用文档注释摘要</td>
</tr>
<tr>
<td>long_help</td>
<td>Arg::long_help</td>
<td>Doc comment with blank line, else nothing</td>
<td>当属性不存在时，使用文档注释，如果有空行</td>
</tr>
<tr>
<td>verbatim_doc_comment</td>
<td>Minimizes preprocessing</td>
<td>-</td>
<td>将文档注释转换为 help/long_help 时最小化预处理</td>
</tr>
<tr>
<td>short</td>
<td>Arg::short</td>
<td>no short set</td>
<td>当属性不存在时，没有短名称设置</td>
</tr>
<tr>
<td>long</td>
<td>Arg::long</td>
<td>no long set</td>
<td>当属性不存在时，没有长名称设置</td>
</tr>
<tr>
<td>env</td>
<td>Arg::env</td>
<td>no env set</td>
<td>当属性不存在时，没有环境变量设置</td>
</tr>
<tr>
<td>from_global</td>
<td>Read Arg::global</td>
<td>-</td>
<td>无论在哪个子命令中，都读取 Arg::global 参数</td>
</tr>
<tr>
<td>value_enum</td>
<td>Parse with ValueEnum</td>
<td>-</td>
<td>使用 ValueEnum 解析值</td>
</tr>
<tr>
<td>skip</td>
<td>Ignore this field</td>
<td>fills the field with Default::default()</td>
<td>忽略此字段，用 &lt;expr&gt; 或 Default::default() 填充</td>
</tr>
<tr>
<td>default_value</td>
<td>Arg::default_value</td>
<td>Arg::required(false)</td>
<td>设置默认值，并将 Arg 设置为非必须</td>
</tr>
<tr>
<td>default_value_t</td>
<td>Arg::default_value</td>
<td>Arg::required(false)</td>
<td>要求 std::fmt::Display 与 Arg::value_parser 相匹配</td>
</tr>
<tr>
<td>default_values_t</td>
<td>Arg::default_values</td>
<td>Arg::required(false)</td>
<td>要求字段类型为 Vec&lt;T&gt;，T 实现 std::fmt::Display</td>
</tr>
<tr>
<td>default_value_os_t</td>
<td>Arg::default_value_os</td>
<td>Arg::required(false)</td>
<td>要求 std::convert::Into&lt;OsString&gt;</td>
</tr>
<tr>
<td>default_values_os_t</td>
<td>Arg::default_values_os</td>
<td>Arg::required(false)</td>
<td>要求字段类型为 Vec&lt;T&gt;，T 实现 std::convert::Into&lt;OsString&gt;</td>
</tr>
</tbody></table>
<h4>2. 参数类型</h4>
<h5>2.1 Arguments &amp; Options</h5>
<p>从上面这个输出样例中：</p>
<pre><code class="language-bash">the example of clap derive

Usage: derive [OPTIONS] &lt;NAME&gt;

Arguments:
  &lt;NAME&gt;  Specify your name

Options:
  -a, --age &lt;AGE&gt;  Specify your age optionally
  -h, --help       Print help
</code></pre>
<p>可以看到跟在命令后面有 2 中参数类型：</p>
<ul>
<li><strong>Arguments</strong>: 直接在命令后面指定值，如 <code>cmd hedon</code>，有严格的顺序要求。</li>
<li><strong>Options</strong>: 需要用 <code>-{short}</code> 或 <code>--{long}</code> 来指定是哪个参数，无严格的顺序要求。</li>
</ul>
<p>它们的定义区别就是是否使用了 <code>#[arg]</code>：</p>
<ul>
<li><strong>Options</strong>: 指定了 short 或 long。</li>
<li><strong>Arguments</strong>: 没有 short 和 long。</li>
</ul>
<pre><code class="language-rust">#[derive(Parser)]
struct Cli {
  /// 会被解析成 [NAME]
  name: String,

  /// 会被解析成 -a &lt;AGE&gt;
  #[arg(short, long)]
  age: u8,
}
</code></pre>
<h5>2.2 可选参数</h5>
<p>可以使用 <code>Option</code> 来实现可选参数：</p>
<pre><code class="language-rust">use clap::Parser;

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    name: Option&lt;String&gt;,

    #[arg(short, long)]
    age: Option&lt;u8&gt;,
}

fn main() {
    let cli = Cli::parse();
    println!(&quot;name: {:?}&quot;, cli.name);
    println!(&quot;age: {:?}&quot;, cli.age);
}
</code></pre>
<p>编译：</p>
<pre><code class="language-bash">cargo build --example optional --release
</code></pre>
<p>执行：</p>
<pre><code class="language-bash">/target/release/examples/optional --help
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">this is the about from Cargo.toml

Usage: optional [OPTIONS] [NAME]

Arguments:
  [NAME]

Options:
  -a, --age &lt;AGE&gt;
  -h, --help       Print help
  -V, --version    Print version
</code></pre>
<p>测试：</p>
<pre><code class="language-bash">➜  learn-clap git:(master) ✗ ./target/release/examples/optional
name: None
age: None
➜  learn-clap git:(master) ✗ ./target/release/examples/optional -a 1
name: None
age: Some(1)
➜  learn-clap git:(master) ✗ ./target/release/examples/optional hedon
name: Some(&quot;hedon&quot;)
age: None
➜  learn-clap git:(master) ✗ ./target/release/examples/optional hedon -a 18
name: Some(&quot;hedon&quot;)
age: Some(18)
</code></pre>
<h5>2.3 枚举参数</h5>
<p>可以使用 <code>enum</code> 搭配 <code>value_enum</code> 来实现多选一参数，并限制可选参数的取值。</p>
<pre><code class="language-rust">use clap::{Parser, ValueEnum};

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    /// Choose the program mode run in
    #[arg(value_enum)]
    mode: Mode,
}

#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Mode {
    /// run in fast mode
    Fast,
    /// run in slow mode
    Slow,
}

fn main() {
    let cli = Cli::parse();
    match cli.mode {
        Mode::Fast =&gt; println!(&quot;fast!!!!!&quot;),
        Mode::Slow =&gt; println!(&quot;slow......&quot;),
    }
}
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">Usage: enum &lt;MODE&gt;

Arguments:
  &lt;MODE&gt;
          Choose the program mode run in

          Possible values:
          - fast: run in fast mode
          - slow: run in slow mode

Options:
  -h, --help
          Print help (see a summary with &#39;-h&#39;)

  -V, --version
          Print version
</code></pre>
<h5>2.4 累计参数</h5>
<p>累积参数允许用户通过重复指定同一个标志（例如 <code>-d</code>）来累加值或效果，通常用于控制命令行应用的详细级别（verbosity level）或其他需要根据次数变化的行为。</p>
<p>在很多命令行工具中，累积参数常见于控制日志输出的详细程度。例如，一个 <code>-v</code>（verbose）标志可能每被指定一次，就增加一层详细级别。所以，<code>-vvv</code>（等价于 <code>-v -v -v</code>） 会比单个 <code>-v</code> 提供更多的详细信息。</p>
<p>在 <code>clap</code> 中可以通过 <code>clap::ArgAction::Count</code> 来实现这种累积参数。</p>
<pre><code class="language-rust">use clap::Parser;

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    #[arg(short, long, action = clap::ArgAction::Count)]
    verbose: u8,
}

fn main() {
    let cli = Cli::parse();
    println!(&quot;verbose: {}&quot;, cli.verbose);
}
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">➜  learn-clap git:(master) ✗ ./target/release/examples/accurate --help
this is the about from Cargo.toml

Usage: accurate [OPTIONS]

Options:
  -v, --verbose...
  -h, --help        Print help
  -V, --version     Print version
➜  learn-clap git:(master) ✗ ./target/release/examples/accurate -v
verbose: 1
➜  learn-clap git:(master) ✗ ./target/release/examples/accurate -vvvv
verbose: 4
</code></pre>
<h5>2.5 变长参数</h5>
<p>有时候我们希望接收变长参数，比如说：</p>
<pre><code class="language-bash">del file1 file2 file3
</code></pre>
<p>这个时候可以使用 <code>Vec&lt;&gt;</code> 来实现。</p>
<pre><code class="language-rust">use clap::Parser;

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    files: Vec&lt;String&gt;,
}

fn main() {
    let cli = Cli::parse();
    println!(&quot;files: {:?}&quot;, cli.files);
}
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">➜  learn-clap git:(master) ✗ ./target/release/examples/var_length --help
this is the about from Cargo.toml

Usage: var_length [FILES]...

Arguments:
  [FILES]...

Options:
  -h, --help     Print help
  -V, --version  Print version
➜  learn-clap git:(master) ✗ ./target/release/examples/var_length
files: []
➜  learn-clap git:(master) ✗ ./target/release/examples/var_length file1
files: [&quot;file1&quot;]
➜  learn-clap git:(master) ✗ ./target/release/examples/var_length file1 file2
files: [&quot;file1&quot;, &quot;file2&quot;]
➜  learn-clap git:(master) ✗ ./target/release/examples/var_length file1 file2 file3
files: [&quot;file1&quot;, &quot;file2&quot;, &quot;file3&quot;]
</code></pre>
<h5>2.6 标志参数</h5>
<p>对于标志参数，只要指定类型为 <code>bool</code>，就可以自动实现了。</p>
<pre><code class="language-rust">use clap::Parser;

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    #[arg(short, long)]
    verbose: bool,
}

fn main() {
    let cli = Cli::parse();
    println!(&quot;verbose: {}&quot;, cli.verbose);
}
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">➜  learn-clap git:(master) ✗ ./target/release/examples/flag --help
Usage: flag [OPTIONS]

Options:
  -v, --verbose
  -h, --help     Print help
  -V, --version  Print version
➜  learn-clap git:(master) ✗ ./target/release/examples/flag
verbose: false
➜  learn-clap git:(master) ✗ ./target/release/examples/flag -v
verbose: true
</code></pre>
<h5>2.7 子命令</h5>
<p>在更复杂的命令行工具中，除了主命令，还有子命令，甚至子命令下面还有子命令，其实就是一颗命令树。</p>
<p><img src="https://cdn.jsdelivr.net/gh/hedon954/mapStorage/img/image-20240228183301763.png" alt="command tree"></p>
<p>在 <code>clap</code> 中可以使用 <code>#[command(subcommand)]</code> 搭配 <code>#[derive(Subcommand)]</code> 实现子命令功能。</p>
<pre><code class="language-rust">use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    #[command(subcommand)]
    test: Option&lt;Test&gt;,
}

#[derive(Subcommand)]
enum Test {
    /// Add a number
    Add {
        #[arg(short, long)]
        num: u16,
    },
    /// Sub a number
    Sub {
        #[arg(short, long)]
        num: u16,
    }
}

fn main() {
    let cli = Cli::parse();

    if let Some(test) = cli.test {
        match test {
            Test::Add {num} =&gt; println!(&quot;test add num: {:?}&quot;, num),
            Test::Sub {num} =&gt; println!(&quot;test sub num: {:?}&quot;, num),
        }
    }
}
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">➜  learn-clap git:(master) ✗ ./target/release/examples/subcommand --help
this is the about from Cargo.toml

Usage: subcommand [COMMAND]

Commands:
  add   Add a number
  sub   Sub a number
  help  Print this message or the help of the given subcommand(s)

Options:
  -h, --help     Print help
  -V, --version  Print version
➜  learn-clap git:(master) ✗ ./target/release/examples/subcommand add --help
Add a number

Usage: subcommand add --num &lt;NUM&gt;

Options:
  -n, --num &lt;NUM&gt;
  -h, --help       Print help
➜  learn-clap git:(master) ✗ ./target/release/examples/subcommand add -n 1
test add num: 1
➜  learn-clap git:(master) ✗ ./target/release/examples/subcommand sub -n 2
test sub num: 2
</code></pre>
<h4>3. 参数校验</h4>
<h5>3.1 类型校验</h5>
<p>可以发现，使用 Derive 模式的时候，我们在参数后面指定参数类型的时候，<code>clap</code> 就会对我们输入参数进行类型检查，不匹配的时候会输出丰富的报错信息和指导建议。</p>
<pre><code class="language-bash">error: invalid value &#39;xxxx&#39; for &#39;--num &lt;NUM&gt;&#39;: invalid digit found in string

For more information, try &#39;--help&#39;.
</code></pre>
<p>默认支持：</p>
<ul>
<li>原生类型：<code>bool</code>, <code>String</code>, <code>OsString</code>, <code>PathBuf</code>、<code>usize</code>、<code>isize</code></li>
<li>范围数据：<code>u8</code>, <code>i8</code>, <code>u16</code>, <code>i16</code>, <code>u32</code>, <code>i32</code>, <code>u64</code>, <code>i64</code></li>
<li>实现了 <code>ValueEnum</code> 的 enum 类型</li>
<li>实现了 <code>From&lt;OsString&gt;</code>、<code>From&lt;&amp;OsStr&gt;</code> 、<code>FromStr</code> 的类型</li>
</ul>
<p>这是因为他们都实现了 <code>TypedValueParser</code> trait，你自定义的类型也可以实现这个 triat，这样就可以自动进行类型校验了。</p>
<p><code>clap</code> 还提供了一些更加严格的参数校验功能。👇🏻</p>
<h5>3.2 枚举校验</h5>
<p>对于实现 <code>ValueEnum</code> 的枚举类型，如果输入的值不是枚举中定义的，则 <code>clap</code> 会报错并提示可选值。</p>
<p>我们复用上面介绍「多选一参数」的代码：</p>
<pre><code class="language-rust">use clap::{Parser, ValueEnum};

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    /// Choose the program mode run in
    #[arg(value_enum)]
    mode: Mode,
}

#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Mode {
    /// run in fast mode
    Fast,
    /// run in slow mode
    Slow,
}

fn main() {
    let cli = Cli::parse();
    match cli.mode {
        Mode::Fast =&gt; println!(&quot;fast!!!!!&quot;),
        Mode::Slow =&gt; println!(&quot;slow......&quot;),
    }
}
</code></pre>
<p>使用错误的值进行尝试：</p>
<pre><code class="language-bash">➜  learn-clap git:(master) ✗ ./target/release/examples/enum xxxx
error: invalid value &#39;xxxx&#39; for &#39;&lt;MODE&gt;&#39;
  [possible values: fast, slow]

For more information, try &#39;--help&#39;.
</code></pre>
<h5>3.3 范围校验</h5>
<p>如果你想要实现数字类型范围限制的话，比如端口号参数的范围应该是 [1, 65535]，那可以使用 <code>value_parser! = clap::value_parser!(u16).range(1..)</code> 来实现这个功能：</p>
<pre><code class="language-rust">use clap::Parser;

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    #[arg(short, long, value_parser = clap::value_parser!(u16).range(1..))]
    port: u16,
}

fn main() {
    let cli = Cli::parse();
    println!(&quot;port: {:?}&quot;, cli.port);
}
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">➜  learn-clap git:(master) ✗ ./target/release/examples/range --help
this is the about from Cargo.toml

Usage: range --port &lt;PORT&gt;

Options:
  -p, --port &lt;PORT&gt;
  -h, --help         Print help
  -V, --version      Print version

➜  learn-clap git:(master) ✗ ./target/release/examples/range -p 0
error: invalid value &#39;0&#39; for &#39;--port &lt;PORT&gt;&#39;: 0 is not in 1..=65535

For more information, try &#39;--help&#39;.

➜  learn-clap git:(master) ✗ ./target/release/examples/range -p 11111111
error: invalid value &#39;11111111&#39; for &#39;--port &lt;PORT&gt;&#39;: 11111111 is not in 1..=65535

For more information, try &#39;--help&#39;.

➜  learn-clap git:(master) ✗ ./target/release/examples/range -p 1111
port: 1111
</code></pre>
<p>在这个例子中，<code>value_parser = clap::value_parser!(u16).range(1..)</code> 的含义可以分为两部分解释：</p>
<p><strong>1. clap::value_parser!(u16)</strong></p>
<p>这部分使用 <code>value_parser!</code> 宏为命令行参数指定了 <code>u16</code> 类型的解析器。这意味着输入的参数值会被尝试解析为无符号 16 位整数（<code>u16</code>）。如果输入不能被成功解析为 <code>u16</code> 类型（例如，输入是非数字字符串或者数字过大/过小而不符合 <code>u16</code> 的范围），<code>clap</code> 会报错并提示用户输入有效的参数值。</p>
<p><strong>2. .range(1..)</strong></p>
<p>这部分进一步限制了参数值的有效范围。<code>.range(1..)</code> 指定了参数值必须大于或等于 1（包含 1），但没有上限。换句话说，任何小于 1 的值都将被认为是无效的，<code>clap</code> 会因此报错并要求用户输入一个符合范围要求的值。这在需要限定参数值为正数时非常有用。</p>
<p>结合起来，<code>value_parser = clap::value_parser!(u16).range(1..)</code> 创建了一个规则，要求命令行参数必须是一个大于或等于 1 的 <code>u16</code> 类型的数值。这在很多场景下都非常有用，比如当你需要用户指定一个正数端口号时。</p>
<p>在 RustRover 中，你可以在 Builder 模式，通过在 <code>clap::value_parser!()</code> 中指定其他的类型，然后输入 <code>.</code> 获得其他类型的内置校验规则。</p>
<h5>3.4 自定义校验</h5>
<p>对于更复杂的规则，<code>clap</code> 还支持自定义校验规则。比如上面对 port 的校验，也可以自己实现。</p>
<pre><code class="language-rust">use std::ops::RangeInclusive;
use clap::Parser;

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    #[arg(short, long, value_parser = parse_port)]
    port: u16,
}

const PORT_RANGE: RangeInclusive&lt;usize&gt; = 1..=65535;

fn parse_port(s: &amp;str) -&gt; Result&lt;u16, String&gt; {
    let port: usize = s
        .parse()
        .map_err(|_| format!(&quot;`{s}` isn&#39;t a port number`&quot;))?;

    if PORT_RANGE.contains(&amp;port) {
        Ok(port as u16)
    } else {
        Err(format!(
            &quot;port not in range {}-{}&quot;,
            PORT_RANGE.start(),
            PORT_RANGE.end(),
        ))
    }
}

fn main() {
    let cli = Cli::parse();
    println!(&quot;port: {:?}&quot;, cli.port);
}
</code></pre>
<p>在代码中，我们直接使用 <code>value_parser = parse_port</code> 来指定自定义的校验规则。</p>
<p>我们自定义的校验规则为：</p>
<pre><code class="language-rust">fn parse_port(s: &amp;str) -&gt; Result&lt;u16, String&gt; {}
</code></pre>
<p>它需要满足：</p>
<ul>
<li>入参是 &amp;str</li>
<li>出参是 Result&lt;参数类型, String&gt;</li>
</ul>
<p>可以测试输出：</p>
<pre><code class="language-bash">➜  learn-clap git:(master) ✗ ./target/release/examples/custom --help
this is the about from Cargo.toml

Usage: custom --port &lt;PORT&gt;

Options:
  -p, --port &lt;PORT&gt;
  -h, --help         Print help
  -V, --version      Print version

➜  learn-clap git:(master) ✗ ./target/release/examples/custom -p xxx
error: invalid value &#39;xxx&#39; for &#39;--port &lt;PORT&gt;&#39;: `xxx` isn&#39;t a port number`

For more information, try &#39;--help&#39;.

➜  learn-clap git:(master) ✗ ./target/release/examples/custom -p 0
error: invalid value &#39;0&#39; for &#39;--port &lt;PORT&gt;&#39;: port not in range 1-65535

For more information, try &#39;--help&#39;.

➜  learn-clap git:(master) ✗ ./target/release/examples/custom -p 9527
port: 9527
</code></pre>
<h5>3.5 关联参数</h5>
<p>有时候参数直接还有关联关系，比如说：</p>
<ul>
<li>依赖：必须存在 <code>-a</code> 参数，<code>-b</code> 参数才有意义，即要使用 <code>-b</code> 参数时，必须指定 <code>-a</code> 参数。</li>
<li>互斥：<code>-a</code> 和 <code>-b</code> 只能同时存在一个。</li>
</ul>
<p><strong>可以使用 requires 实现依赖关系：</strong></p>
<pre><code class="language-rust">use clap::Parser;

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    #[arg(short, long)]
    a: Option&lt;String&gt;,

    #[arg(short, long,requires = &quot;a&quot;)]
    b: Option&lt;String&gt;,
}

fn main() {
    let cli = Cli::parse();
    println!(&quot;a: {:?}&quot;, cli.a);
    println!(&quot;b: {:?}&quot;, cli.b);
}
</code></pre>
<p>上述代码中，我们在参数 <code>b</code> 中加入了 <code>requires = &quot;a&quot;</code>，表示要使用 <code>b</code> 参数必须要有 <code>a</code> 参数。</p>
<p>输出：</p>
<pre><code class="language-bash">➜  learn-clap git:(master) ✗ ./target/release/examples/relation
a: None
b: None
➜  learn-clap git:(master) ✗ ./target/release/examples/relation -b 1
error: the following required arguments were not provided:
  --a &lt;A&gt;

Usage: relation --a &lt;A&gt; --b &lt;B&gt;

For more information, try &#39;--help&#39;.

➜  learn-clap git:(master) ✗ ./target/release/examples/relation -a 1
a: Some(&quot;1&quot;)
b: None

➜  learn-clap git:(master) ✗ ./target/release/examples/relation -a 1 -b 2
a: Some(&quot;1&quot;)
b: Some(&quot;2&quot;)
</code></pre>
<p><strong>可以使用 #[group(required = true, mutiple = false)] 来实现互斥关系：</strong></p>
<pre><code class="language-rust">use clap::{Args, Parser};

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    #[command(flatten)]
    args: Only,
}

#[derive(Args, Debug)]
#[group(required = true, multiple = false)]
struct Only {
    #[arg(long)]
    a: Option&lt;String&gt;,
    #[arg(long)]
    b: Option&lt;String&gt;,
    #[arg(long)]
    c: Option&lt;String&gt;,
    #[arg(long)]
    d: Option&lt;String&gt;,
}

fn main() {
    let cli = Cli::parse();
    println!(&quot;only: {:?}&quot;, cli.args)
}
</code></pre>
<p><code>#[command(flattern)] </code> 直接将结构体里面的参数平铺。</p>
<p><code>#[group]</code> 用于将一组参数归为一个组，<code>required = true</code> 表示必须提供该 group 中的参数，<code>multiple = false</code> 表示只能有一个参数被提供。</p>
<p>测试输出如下：</p>
<pre><code class="language-bash">➜  learn-clap git:(master) ✗ ./target/release/examples/only_one --help
this is the about from Cargo.toml

Usage: only_one &lt;--a &lt;A&gt;|--b &lt;B&gt;|--c &lt;C&gt;|--d &lt;D&gt;&gt;

Options:
      --a &lt;A&gt;
      --b &lt;B&gt;
      --c &lt;C&gt;
      --d &lt;D&gt;
  -h, --help     Print help
  -V, --version  Print version

➜  learn-clap git:(master) ✗ ./target/release/examples/only_one
error: the following required arguments were not provided:
  &lt;--a &lt;A&gt;|--b &lt;B&gt;|--c &lt;C&gt;|--d &lt;D&gt;&gt;

Usage: only_one &lt;--a &lt;A&gt;|--b &lt;B&gt;|--c &lt;C&gt;|--d &lt;D&gt;&gt;

For more information, try &#39;--help&#39;.

➜  learn-clap git:(master) ✗ ./target/release/examples/only_one --a 1
only: Only { a: Some(&quot;1&quot;), b: None, c: None, d: None }

➜  learn-clap git:(master) ✗ ./target/release/examples/only_one --a 1 --b 2
error: the argument &#39;--a &lt;A&gt;&#39; cannot be used with &#39;--b &lt;B&gt;&#39;

Usage: only_one &lt;--a &lt;A&gt;|--b &lt;B&gt;|--c &lt;C&gt;|--d &lt;D&gt;&gt;

For more information, try &#39;--help&#39;.

➜  learn-clap git:(master) ✗ ./target/release/examples/only_one --b 2
only: Only { a: None, b: Some(&quot;2&quot;), c: None, d: None }
</code></pre>
<h3>Builder</h3>
<p>使用 <code>clap</code> 的 Builder 模式，一般情况下不需要额外引入其他的 features：</p>
<pre><code class="language-bash">cargo add clap
</code></pre>
<p>但是如果要使用 <code>command!</code> 来构建应用的话，需要引入 <code>cargo</code> 这个 features：</p>
<pre><code class="language-bash">cargo add clap --features cargo
</code></pre>
<h4>1. 应用配置</h4>
<p>在 Builder 模式下，你可以使用 <code>command!() </code> 或 <code>Command::new(&quot;appname&quot;)</code> 来构建一个命令行应用，其中 <code>command!()</code> 默认将 appname 设置应用名称，而 <code>Command::new()</code> 必须显示指定 appname。</p>
<pre><code class="language-rust">use clap::{arg, Arg, Command, command, value_parser};

fn main() {
    // let matches = command!()
    let matches = Command::new(&quot;MyApp&quot;)
        // Application configuration
        .version(&quot;1.0.0&quot;)
        .author(&quot;hedon&quot;)
        .about(&quot;This the intro of the cli application&quot;)
        // Application args
        .arg(arg!([NAME]).help(&quot;Specify your name&quot;))
        .arg(
            Arg::new(&quot;age&quot;).short(&#39;a&#39;).long(&quot;age&quot;).value_parser(value_parser!(u8))
        )
    .get_matches();

    // Read and parse command args
    if let Some(name) = matches.get_one::&lt;String&gt;(&quot;NAME&quot;) {
        println!(&quot;Value for name: {name}&quot;);
    }
    if let Some(age) = matches.get_one::&lt;u8&gt;(&quot;age&quot;) {
        println!(&quot;Value for age: {age}&quot;);
    }
}
</code></pre>
<p>这段代码分为以下几个部分：</p>
<p><strong>1. 创建命令行应用实例</strong>：</p>
<pre><code class="language-rust">let matches = Command::new(&quot;MyApp&quot;)
</code></pre>
<p>这里使用 <code>Command::new</code> 方法创建了一个新的命令行应用实例，命名为 <code>&quot;MyApp&quot;</code>。</p>
<p><strong>2. 配置应用</strong>：</p>
<pre><code class="language-rust">.version(&quot;1.0.0&quot;)
.author(&quot;hedon&quot;)
.about(&quot;This the intro of the cli application&quot;)
</code></pre>
<p>接下来，使用链式调用方法配置应用的版本号为 <code>&quot;1.0.0&quot;</code>，作者为 <code>&quot;hedon&quot;</code>，并添加了一个简短的描述。</p>
<p>这里等价于 Builder 模式下的：</p>
<pre><code class="language-rust">#[command(version, author, about)]
</code></pre>
<p><strong>3. 添加命令行参数</strong>：</p>
<pre><code class="language-rust">.arg(arg!([NAME]).help(&quot;Specify your name&quot;))
.arg(
    Arg::new(&quot;age&quot;).short(&#39;a&#39;).long(&quot;age&quot;).value_parser(value_parser!(u8))
)
</code></pre>
<p>这部分代码添加了两个命令行参数：</p>
<ul>
<li><code>.arg(arg!([NAME]).required(true).help(&quot;Specify your name&quot;))</code> 使用 <code>arg!</code> 宏添加了一个名为 <code>NAME</code> 的必需参数，并提供了一些帮助信息。</li>
<li><code>.arg(Arg::new(&quot;age&quot;).short(&#39;a&#39;).long(&quot;age&quot;).value_parser(value_parser!(u8)))</code> 创建了另一个参数 <code>age</code>，可以通过 <code>-a</code> 或 <code>--age</code> 来指定。这个参数使用了 <code>value_parser</code> 宏来指明它的值应被解析为 <code>u8</code> 类型的数字。</li>
</ul>
<p><strong>4. 解析命令行参数</strong>：</p>
<pre><code class="language-rust">.get_matches();
</code></pre>
<p>使用 <code>.get_matches()</code> 方法来解析命令行参数并将结果存储在 <code>matches</code> 变量中。</p>
<p><strong>5. 读取并打印参数值</strong>：</p>
<pre><code class="language-rust">if let Some(name) = matches.get_one::&lt;String&gt;(&quot;NAME&quot;) {
    println!(&quot;Value for name: {name}&quot;);
}
if let Some(age) = matches.get_one::&lt;u8&gt;(&quot;age&quot;) {
    println!(&quot;Value for age: {age}&quot;);
}
</code></pre>
<p>最后，使用 <code>matches.get_one::&lt;T&gt;(&quot;arg_name&quot;)</code> 方法尝试获取指定名称的参数值。如果成功找到，则将其打印出来。这里分别尝试获取 <code>&quot;NAME&quot;</code> 和 <code>&quot;age&quot;</code> 参数的值，并使用 <code>println!</code> 宏将它们打印到控制台。</p>
<p>使用 <code>-- help</code> 测试输出如下：</p>
<pre><code class="language-bash">This the intro of the cli application

Usage: app2 [OPTIONS] [NAME]

Arguments:
  [NAME]  Specify your name

Options:
  -a, --age &lt;AGE&gt;
  -h, --help       Print help
  -V, --version    Print version
</code></pre>
<p>你可以将其与「Derive - 应用配置」进行比较，应该很容易找到它们之间的对应关系。</p>
<p>在 Derive 中 <code>#[command]</code> 和 <code>#[arg]</code> 支持的属性，都可以在 Builder 中找到对应的同名的函数，这里就不赘述了。</p>
<h4>2. 参数类型</h4>
<p>在 Builder 模式中，配置参数有两种方式：</p>
<ul>
<li>arg!([-short] [--long] id)</li>
<li>Args::new(&quot;id&quot;).short(&#39;s&#39;).long(&quot;long&quot;)</li>
</ul>
<h5>2.1 Arguments &amp; Options</h5>
<pre><code class="language-bash">Arguments:
  [NAME]  Specify your name

Options:
  -a, --age &lt;AGE&gt;
</code></pre>
<ul>
<li><strong>Argument:</strong> 不包含 <code>-{short}</code> 和 <code>--{long}</code>。</li>
<li><strong>Options:</strong> 包含 <code>-{short}</code> 或 <code>--{long}</code>。</li>
</ul>
<pre><code class="language-rust">.arg(arg!([NAME]).help(&quot;Specify your name&quot;))
.arg(arg!(-a --age &lt;AGE&gt;).value_parser(value_parser!(u16)))
</code></pre>
<h5>2.2 可选参数</h5>
<p>根据约定，<code>&lt;&gt;</code> 表示必须，而 <code>[]</code> 表示可选：</p>
<pre><code class="language-rust">.arg(arg!(&lt;NAME&gt;)   // 必须
.arg(arg!([ADDRESS]))  // 可选
</code></pre>
<p>你也可以使用 <code>.required(bool)</code> 函数明确指出是否必须：</p>
<pre><code class="language-rust">.arg(arg!(&lt;NAME&gt;).required(true))
</code></pre>
<p><code>.required()</code> 的优先级高于 <code>&lt;&gt;</code> 和 <code>[]</code>，但是建议你在构建的时候还是遵循约定。</p>
<h5>2.3 枚举参数</h5>
<p><strong>第 1 种：在 value_parser() 中直接指定可选的枚举参数</strong></p>
<pre><code class="language-rust">.arg(arg!(&lt;MODE&gt;).value_parser([&quot;fast, slow&quot;]))
</code></pre>
<p><strong>第 2 种：使用枚举，但是枚举需要实现 ValueEnum trait</strong></p>
<p>这里又有 2 种方式，你可以向 Derive 一样引入 <code>derive</code> features，然后直接 <code>#[derive(ValueElem)]</code> 使用默认实现，也可以手动实现。我更倾向于前者。</p>
<pre><code class="language-rust">use clap::{arg, command, value_parser, ValueEnum};

fn main() {
    let matches = command!()
        // .arg(arg!(&lt;MODE&gt;).value_parser([&quot;fast, slow&quot;]))
        .arg(
            arg!(&lt;MODE&gt;).value_parser(value_parser!(Mode)).required(true)
        )
        .get_matches();

    match matches.get_one::&lt;Mode&gt;(&quot;MODE&quot;)
        .expect(&quot;&#39;Mode&#39; is required and parsing will fail if its missing&quot;){
        Mode::Fast =&gt; println!(&quot;fast&quot;),
        Mode::Slow =&gt; println!(&quot;slow&quot;),
    }
}

#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, ValueEnum)]
enum Mode {
    /// Run in fast mode
    Fast,
    /// Run in slow mode
    Slow,
}
</code></pre>
<h5>2.4 累计参数</h5>
<p>使用 <code>clap::ArgAction::Count</code> 设置参数为累计参数，然后使用 <code>get_count(id)</code> 获取参数的值：</p>
<pre><code class="language-rust">use clap::{arg, command};

fn main() {
    let matches = command!()
        .arg(arg!(-v --verbose...).action(clap::ArgAction::Count))
        .get_matches();
    println!(&quot;v count: {:?}&quot;, matches.get_count(&quot;verbose&quot;));
}
</code></pre>
<p>这里要注意，<code>arg!()</code> 中参数的定义，也要符合累计参数的格式 <code>-{short} --{long}...</code>。</p>
<h5>2.5 变长参数</h5>
<p>使用 <code>clap::ArgAction::Append</code> 设置参数为变长参数，然后使用 <code>get_many::&lt;类型&gt;(&quot;id&quot;)</code> 获取参数的值：</p>
<pre><code class="language-rust">use clap::{arg, Command};

fn main() {
    let matches = Command::new(&quot;append-application&quot;)
        .arg(arg!([FILES]...).action(clap::ArgAction::Append))
        .get_matches();

    let files = matches
        .get_many::&lt;String&gt;(&quot;FILES&quot;)
        .unwrap_or_default()
        .map(|v|v.as_str())
        .collect::&lt;Vec&lt;_&gt;&gt;();

    println!(&quot;files: {:?}&quot;, files);
}
</code></pre>
<p>这里要注意，<code>arg!()</code> 中参数的定义，也要符合变长参数的格式 <code>[arg]|&lt;arg&gt;...</code>。</p>
<h5>2.6 标志参数</h5>
<p>使用 <code>clap::ArgAction::SetTrue</code> 或 <code>clap::ArgAction::SetFalse</code> 设置参数为标志参数，然后使用 <code>get_flag()</code> 获取参数的值：</p>
<pre><code class="language-rust">use clap::{arg, command};

fn main() {
    let matches = command!()
        .arg(arg!(-d --debug).action(clap::ArgAction::SetTrue))
        .arg(arg!(-v --verbose).action(clap::ArgAction::SetFalse))
        .get_matches();
    println!(&quot;debug: {:?}&quot;, matches.get_flag(&quot;debug&quot;));
    println!(&quot;verbose: {:?}&quot;, matches.get_flag(&quot;verbose&quot;))
}
</code></pre>
<p>其中：</p>
<ul>
<li><code>clap::ArgAction::SetTrue</code> : 设置参数的话，则为 true，否则 false（默认）。</li>
<li><code>clap::ArgAction::SetFalse</code> : 设置参数的话，则为 false，否则 true（默认）。</li>
</ul>
<p>测试：</p>
<pre><code class="language-bash">➜  learn-clap-builder git:(master) ✗ ./target/release/examples/flag
debug: false
verbose: true
➜  learn-clap-builder git:(master) ✗ ./target/release/examples/flag -d
debug: true
verbose: true
➜  learn-clap-builder git:(master) ✗ ./target/release/examples/flag -v
debug: false
verbose: false
</code></pre>
<h5>2.7 子命令</h5>
<p>可以使用 <code>subcommand(sub_cmd)</code> 和 <code>subcommand([sub_cmd1, sub_cmd2])</code> 来添加子命令，解析的时候使用 <code>matches.subcommand()</code> 匹配子命令，再按照之前的规则解析子命令中对应的参数即可。</p>
<pre><code class="language-rust">use clap::{arg, Command, value_parser};

fn main() {
    let matches = Command::new(&quot;myapp&quot;)
        .subcommands([
            Command::new(&quot;add&quot;)
                .arg(arg!(&lt;NUM&gt;).value_parser(value_parser!(i16))),
            Command::new(&quot;sub&quot;)
                .arg(arg!(&lt;NUM&gt;).value_parser(value_parser!(i16))),
        ])
        .get_matches();

    match matches.subcommand() {
        Some((&quot;add&quot;, add_cmd)) =&gt; println!(
            &quot;&#39;myapp add&#39; was used, num is: {:?}&quot;,
            add_cmd.get_one::&lt;i16&gt;(&quot;NUM&quot;),
        ),
        Some((&quot;sub&quot;, sub_cmd)) =&gt; println!(
            &quot;&#39;myapp sub&#39; was used, num is: {:?}&quot;,
            sub_cmd.get_one::&lt;i16&gt;(&quot;NUM&quot;),
        ),
        _ =&gt; unreachable!()
    }
}
</code></pre>
<h4>3. 参数校验</h4>
<h5>3.1 类型校验</h5>
<p>使用 <code>value_parser!()</code> 在括号中指定类型，<code>clap</code> 就会自动帮我们对参数进行类型校验，当然你在获取参数值 <code>get_one::&lt;类型&gt;()</code> 的时候，类型要对上，否则会 panic。</p>
<p>默认支持：</p>
<ul>
<li>原生类型：<code>bool</code>, <code>String</code>, <code>OsString</code>, <code>PathBuf</code>、<code>usize</code>、<code>isize</code></li>
<li>范围数据：<code>u8</code>, <code>i8</code>, <code>u16</code>, <code>i16</code>, <code>u32</code>, <code>i32</code>, <code>u64</code>, <code>i64</code></li>
<li>实现了 <code>ValueEnum</code> 的 enum 类型</li>
<li>实现了 <code>From&lt;OsString&gt;</code>、<code>From&lt;&amp;OsStr&gt;</code> 、<code>FromStr</code> 的类型</li>
</ul>
<h5>3.2 枚举校验</h5>
<p>2.3 中枚举参数的说明中，已经体现了枚举校验的功能了，这里不赘述。</p>
<h5>3.3 范围校验</h5>
<p>对于上述提到的「范围数据」，可以使用 <code>value_parser!(类型).range()</code> 进行范围校验。</p>
<pre><code class="language-rust">arg!(&lt;PORT&gt;)
  .value_parser(value_parser!(u16).range(1..))
</code></pre>
<h5>3.4 自定义校验</h5>
<p><code>value_parser()</code> 中也可以传自定义的校验函数，该函数的签名需要满足的条件跟我们在介绍 Derive 时一样。</p>
<pre><code class="language-rust">use std::ops::RangeInclusive;
use clap::{arg, command};

fn main() {
    let matches = command!()
        .arg(arg!(&lt;PORT&gt;).value_parser(port_in_range))
        .get_matches();

    println!(&quot;port: {:?}&quot;, matches.get_one::&lt;u16&gt;(&quot;PORT&quot;))
}

const PORT_RANGE: RangeInclusive&lt;usize&gt; = RangeInclusive::new(1, 65535);

fn port_in_range(s: &amp;str) -&gt; Result&lt;u16, String&gt; {
    let port: usize = s
        .parse()
        .map_err(|_|format!(&quot;`{s}` is not a port number&quot;))?;
    if PORT_RANGE.contains(&amp;port) {
        Ok(port as u16)
    } else {
        Err(format!(
            &quot;port not in range {}-{}&quot;,
            PORT_RANGE.start(),
            PORT_RANGE.end(),
        ))
    }
}
</code></pre>
<h5>3.5 关联参数</h5>
<ul>
<li>**依赖关系：**使用 <code>requires(id | group)</code></li>
<li><strong>排斥关系</strong>：使用 <code>group().multiple(false).required(true)</code></li>
</ul>
<pre><code class="language-rust">.group(
    ArgGroup::new(&quot;vers&quot;)
  			// 表示 &quot;set-ver&quot;, &quot;major&quot;, &quot;minor&quot;, &quot;patch&quot; 必须有一个且只能有一个存在
        .multiple(false)
        .required(true)
        .args([&quot;set-ver&quot;, &quot;major&quot;, &quot;minor&quot;, &quot;patch&quot;]),
)
.arg(
    arg!([INPUT_FILE] &quot;some regular input&quot;)
        .value_parser(value_parser!(PathBuf))
        .group(&quot;input&quot;),
)
.arg(
    arg!(config: -c &lt;CONFIG&gt;)
        .value_parser(value_parser!(PathBuf))
   			// 表示 -c 需要有 group 为 input 的命令存在才可以使用
        .requires(&quot;input&quot;),
)
</code></pre>
<h3>Derive vs Builder (2) 对比</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/screencapture-wepie-feishu-cn-wiki-Bdb3wl4cViE6hqkUuykctkrqn8i-2024-03-01-20_52_38.png" alt="Derive vs Builder"></p>
<h3>clap + rpassword 实现加密输入</h3>
<p>对于密码、密钥等关键信息的输入，为了信息安全，我们一般会使用加密输出，<code>clap</code> 本身不支持加密输入功能。若你有这方面的需求，可以使用 <code>rpassword</code> crate 辅助完成。</p>
<p>示例：</p>
<pre><code class="language-rust">use clap::Parser;
use rpassword::read_password;

#[derive(Parser)]
#[command(version, author, about, long_about = None)]
struct Cli {
    #[arg(short, long)]
    username: String,

    #[arg(short, long, required = true)]
    password: bool,
}

fn main() {
    let cli = Cli::parse();

    let password = if cli.password {
        // Prompt user to enter password
        read_password().expect(&quot;Failed to read password&quot;)
    } else {
        &quot;&quot;.to_string()
    };

    // Use username and password to do something
    println!(&quot;username: {}, password: {}&quot;, cli.username, password);
}
</code></pre>
<h3>clap_complete 实现自动补全</h3>
<p>要实现自动补全，需要在 <code>.zshrc</code> 或 <code>.bashrc</code> 等 <code>SHELL</code> 文件中加入命令自动补全脚本。这时候可以使用 <code>clap_complete</code> 来实现这个功能。</p>
<p>下面的示例目录结构如下：</p>
<pre><code class="language-bash">├── Cargo.lock
├── Cargo.toml
├── build.rs
└── src
    ├── cli.rs
    └── main.rs
</code></pre>
<p>首先我们需要引入 <code>clap</code> 和 <code>clap_complete</code> crate，其中 <code>clap_complete</code> 只需在 build 环境下即可，所以我们的 <code>Cargo.tmol</code> 如下：</p>
<pre><code class="language-toml">[package]
name = &quot;myapp&quot;
version = &quot;0.1.0&quot;
edition = &quot;2021&quot;

build = &quot;build.rs&quot;

[dependencies]
clap = { version = &quot;4.5.1&quot; }
dirs = &quot;5.0.1&quot;

[build-dependencies]
clap = { version = &quot;4.5.1&quot;}
clap_complete = &quot;4.5.1&quot;
</code></pre>
<p>我们先在 <code>src/cli.rs</code> 中实现一个简单的命令行程序 <strong>myapp</strong>：</p>
<pre><code class="language-rust">use clap::{Arg, ArgAction, Command};

pub fn build_cli() -&gt; Command {
    Command::new(&quot;myapp&quot;)
        .about(&quot;Tests completions&quot;)
        .arg(Arg::new(&quot;file&quot;)
            .help(&quot;some input file&quot;))
        .subcommand(Command::new(&quot;test&quot;)
            .about(&quot;tests things&quot;)
            .arg(Arg::new(&quot;case&quot;)
                .long(&quot;case&quot;)
                .action(ArgAction::Set)
                .help(&quot;the case to test&quot;)))
}
</code></pre>
<p>我们主要是演示这个自动补全功能，为了省事，<code>src/main.rs</code> 中就不实现具体逻辑了：</p>
<pre><code class="language-rust">mod cli;

fn main() {
    let _m = cli::build_cli().get_matches();
}
</code></pre>
<p>接着，我们在项目根目录下实现 <code>build.rs</code>，它将为我们指定的命令生成自动补全脚本：</p>
<pre><code class="language-bash">touch build.rs
</code></pre>
<pre><code class="language-rust">use clap_complete::{generate_to, shells::Bash};
use std::env;
use std::io::Error;

include!(&quot;src/cli.rs&quot;);

fn main() -&gt; Result&lt;(), Error&gt; {
    let outdir = match env::var_os(&quot;OUT_DIR&quot;) {
        None =&gt; return Ok(()),
        Some(outdir) =&gt; outdir,
    };

    let mut cmd = build_cli();
    let path = generate_to(
        Bash,
        &amp;mut cmd, // We need to specify what generator to use
        &quot;myapp&quot;,  // We need to specify the bin name manually
        outdir,   // We need to specify where to write to
    )?;

    println!(&quot;cargo:warning=completion file is generated: {path:?}&quot;);

    Ok(())
}
</code></pre>
<p>你需要把其中的 <strong>myapp</strong> 替换为你的命令。</p>
<p>执行构建命令：</p>
<pre><code class="language-bash">cargo build
</code></pre>
<p>可以看到输出：</p>
<pre><code class="language-bash">warning: myapp@0.1.0: completion file is generated: &quot;/Users/hedon/RustroverProjects/learn-clap-complete/target/debug/build/myapp-42e401d08c044ca3/out/myapp.bash&quot;
    Finished dev [unoptimized + debuginfo] target(s) in 1.90s
</code></pre>
<p>这里会输出生成脚本所在的位置，我这里是 <code>/Users/hedon/RustroverProjects/learn-clap-complete/target/debug/build/myapp-42e401d08c044ca3/out/myapp.bash</code>。</p>
<p>我的终端使用的是 zsh：</p>
<pre><code class="language-bash">➜  echo $SHELL
/bin/zsh
</code></pre>
<p>所以我需要将这个文件的内容加到 <code>~/.zshrc</code> 文件的末尾：</p>
<pre><code class="language-bash">cat /Users/hedon/RustroverProjects/learn-clap-complete/target/debug/build/myapp-42e401d08c044ca3/out/myapp.bash &gt;&gt; ~/.zshrc
</code></pre>
<p>重新加载配置文件：</p>
<pre><code class="language-bash">source ~/.zshrc
</code></pre>
<p>这个时候你使用 <strong>myapp</strong> 命令的时候，按 <code>tap</code> 键，就有自动补全了：</p>
<pre><code class="language-bash">➜  ./target/debug/myapp
--help    -h        \[file\]  help      test
</code></pre>
<h2>HTTPie</h2>
<p>由于篇幅原因，实战 HTTPie 部分请看：<a href="https://hedon.top/wiki/rust/04-action/rust-action-httpie.html">Rust 实战丨 HTTPie</a></p>
<h2>与 Go 语言 cobra 比较</h2>
<p>Go 的 <code>cobra</code> 也是用于构建命令行应用程序的库，它在 Go 语言生态中非常受欢迎。</p>
<p>为了直观展示这 2 个库构建命令行应用程序的区别，我们来设计一个简单的命令行程序，用 <code>clap</code> 和 <code>cobra</code> 分别实现，以展示如何用这两个库实现相同的功能。</p>
<p>让我们创建一个 CLI 程序，它有一个 <code>greet</code> 子命令，接受一个 <code>-n</code> 或 <code>--name</code> 参数，并打印出一条欢迎信息。</p>
<h3>Rust clap 实现</h3>
<pre><code class="language-rust">use clap::{ Parser, Subcommand};

#[derive(Parser)]
#[command(bin_name = &quot;greet_app&quot;)]
struct Cli {
    #[command(subcommand)]
    sub: Option&lt;Sub&gt;,
}

#[derive(Subcommand)]
enum Sub {
    Greet {
        #[arg(short, long)]
        name: String,
    }
}

fn main() {
    let cli = Cli::parse();
    if let Some(sub) = cli.sub {
        match sub {
            Sub::Greet{name} =&gt; println!(&quot;greeting: {:?}&quot;, name),
        }
    }
}
</code></pre>
<h3>Go cobra 实现</h3>
<pre><code class="language-go">package main

import (
	&quot;fmt&quot;
	&quot;os&quot;

	&quot;github.com/spf13/cobra&quot;
)

var rootCmd = &amp;cobra.Command{
	Use:   &quot;greet_app&quot;,
	Short: &quot;A simple greeting application&quot;,
	Long:  `This is a simple greeting application with a greet command.`,
}

var greetCmd = &amp;cobra.Command{
	Use:   &quot;greet&quot;,
	Short: &quot;Greets a user&quot;,
	Long:  `Prints a greeting message for the specified user.`,
	Run: func(cmd *cobra.Command, args []string) {
		name, _ := cmd.Flags().GetString(&quot;name&quot;)
		fmt.Printf(&quot;Hello, %s!\n&quot;, name)
	},
}

func init() {
	rootCmd.AddCommand(greetCmd)
	greetCmd.Flags().StringP(&quot;name&quot;, &quot;n&quot;, &quot;&quot;, &quot;Sets the name to greet&quot;)
	greetCmd.MarkFlagRequired(&quot;name&quot;)
}

func main() {
	if err := rootCmd.Execute(); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">This is a simple greeting application with a greet command.

Usage:
  greet_app [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  greet       Greets a user
  help        Help about any command

Flags:
  -h, --help   help for greet_app

Use &quot;greet_app [command] --help&quot; for more information about a command.
</code></pre>
<h3>对比</h3>
<h4>设计哲学和易用性</h4>
<p><strong>clap</strong>:</p>
<ul>
<li>使用 Rust 的宏来提供强大的编译时功能，如参数解析、验证等。</li>
<li>利用 Rust 的类型安全特性，减少运行时错误。</li>
<li>支持通过派生宏自动从结构体生成命令行解析代码，简化开发流程。</li>
</ul>
<p><strong>cobra</strong>:</p>
<ul>
<li>采用更传统的命令式编程模型，直观且易于上手。</li>
<li>通过组合命令对象来构建复杂的命令行应用。</li>
<li>提供了一套完整的生成工具来创建命令和配置，促进了开发速度。</li>
</ul>
<h4>功能和特性</h4>
<p><strong>clap</strong>:</p>
<ul>
<li>自动生成帮助信息、版本信息等。</li>
<li>支持多级子命令。</li>
<li>支持自定义验证器和复杂的参数关系（如互斥、依赖等）。</li>
</ul>
<p><strong>cobra</strong>:</p>
<ul>
<li>支持自动生成帮助文档。</li>
<li><strong>内置命令自动补全脚本生成功能</strong>。</li>
<li><strong>支持持久化命令行标志到配置文件</strong>。</li>
<li>通过插件支持增加额外的子命令。</li>
<li>能够轻松地与其他 Go 库集成，如 Viper 用于配置管理。</li>
</ul>
<h4>性能</h4>
<p><strong>clap</strong>:</p>
<ul>
<li>由于 Rust 的编译时优化，<code>clap</code> 在解析命令行参数时通常会有更好的性能。</li>
<li>更少的运行时开销，尤其是在处理大量复杂命令行参数时。</li>
</ul>
<p><strong>cobra</strong>:</p>
<ul>
<li>性能对于大多数命令行应用来说已经足够，但可能不如 <code>clap</code> 优化。</li>
<li>Go 的运行时可能会引入额外的开销，尤其是在并发处理时。</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Rust reqwest 简明教程</title>
      <link>https://hedon.top/blog/rust-crate-reqwest/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-crate-reqwest/</guid>
      <pubDate>Sat, 02 Mar 2024 18:46:56 GMT</pubDate>
      <description>本文介绍了 Rust 一个非常流程和强大的 HTTP 客户端库 reqwest 的基本使用方法。</description>
      <category>rust</category><category>Rust 常用库</category>
      <content:encoded><![CDATA[<h2>概述</h2>
<p><code>reqwest</code> 是 Rust 中一个非常流行和强大的 HTTP 客户端库，它提供了一种简单的方式来发送 HTTP 请求并处理响应。<code>reqwest</code> 支持阻塞和非阻塞（异步）请求，使其适合于各种不同的应用场景。在这篇博文中，我们将详细介绍如何使用 <code>reqwest</code> 发送各种 HTTP 请求，并处理返回的响应。</p>
<h2>开始之前</h2>
<p>在开始编写代码之前，你需要在你的 Rust 项目中添加 <code>reqwest</code> 依赖。打开你的 <code>Cargo.toml</code> 文件，并添加以下内容：</p>
<pre><code class="language-toml">[dependencies]
reqwest = { version = &quot;0.12.4&quot;, features = [&quot;json&quot;] }
tokio = { version = &quot;1&quot;, features = [&quot;full&quot;] }
serde = { version = &quot;1.0.197&quot;, features = [&quot;derive&quot;] }
serde_json = &quot;1.0.114&quot;
</code></pre>
<p>这里我们还添加了其他几个依赖：</p>
<ul>
<li><code>tokio</code>: 在后面的示例中，我们将使用 <code>reqwest</code> 的异步功能。</li>
<li><code>serde</code>: 用于数据解析，在示例中，我们会演示 json 数据的解析。</li>
<li><code>serde_json</code>: 便于使用 <code>json!</code> 宏快速构建 json 数据。</li>
</ul>
<h2>发送 GET 请求</h2>
<p>发送一个 GET 请求是最基本的 HTTP 操作。以下是如何使用 <code>reqwest</code> 发送 GET 请求并设置请求头的示例：</p>
<pre><code class="language-rust">use reqwest::header;

#[tokio::main]
async fn main() -&gt; Result&lt;(), reqwest::Error&gt; {
    let params = [(&quot;key1&quot;, &quot;value1&quot;), (&quot;key2&quot;, &quot;values&quot;)];
    let client = reqwest::Client::new();
    let body = client
        .get(&quot;http://httpbin.org/get&quot;)
        // set query params
        .form(&amp;params)
        // set request headers
        .header(header::USER_AGENT, &quot;My Rust Program&quot;)
        .header(header::CONTENT_TYPE, &quot;application/json&quot;)
        .send()
        .await?
        .text()
        .await?;
    println!(&quot;body = {:?}&quot;, body);
    Ok(())
}
</code></pre>
<p>在这个例子中，我们使用 <code>reqwest::get</code> 函数发送一个 GET 请求到 &quot;<a href="https://httpbin.org/get%22%EF%BC%8C%E5%B9%B6%E9%80%9A%E8%BF%87">https://httpbin.org/get&quot;，并通过</a> <code>text</code> 方法获取响应的文本内容。</p>
<h2>发送 POST - text 请求</h2>
<pre><code class="language-rust">use reqwest::Client;

#[tokio::main]
async fn main() -&gt; Result&lt;(), reqwest::Error&gt; {
    let client = Client::new();
    let res = client.post(&quot;http://httpbin.org/post&quot;)
        .body(&quot;the exact body that is sent&quot;)
        .send()
        .await?
        .text()
        .await?;

    println!(&quot;body: {:?}&quot;, res);
    Ok(())
}
</code></pre>
<h2>发送 POST - form 请求</h2>
<pre><code class="language-rust">#[tokio::main]
async fn main() -&gt; Result&lt;(), reqwest::Error&gt; {
    let params = [(&quot;key1&quot;, &quot;value1&quot;), (&quot;key2&quot;, &quot;values&quot;)];
    let client = reqwest::Client::new();
    let res = client.post(&quot;http://httpbin.org/post&quot;)
        .form(&amp;params)
        .send()
        .await?
        .text()
        .await?;

    println!(&quot;body: {:?}&quot;, res);
    Ok(())
}
</code></pre>
<h2>发送 POST - json 请求</h2>
<p>发送 POST 请求通常用于向服务器提交数据。以下是如何使用 <code>reqwest</code> 发送包含 JSON 数据的 POST 请求的示例：</p>
<pre><code class="language-rust">use reqwest;
use serde_json::json;

#[tokio::main]
async fn main() -&gt; Result&lt;(), reqwest::Error&gt; {
    let client = reqwest::Client::new();
    let res = client.post(&quot;https://httpbin.org/post&quot;)
        .json(&amp;json!({&quot;key&quot;: &quot;value&quot;}))
        .send()
        .await?;

    let body = res.text().await?;
    println!(&quot;Body:\n{}&quot;, body);

    Ok(())
}
</code></pre>
<p>这里我们使用 <code>Client::post</code> 方法创建一个 POST 请求，并通过 <code>json</code> 方法设置 JSON 负载。然后，我们调用 <code>send</code> 方法发送请求。</p>
<h2>处理 JSON 响应</h2>
<pre><code class="language-rust">use reqwest;
use serde::Deserialize;

#[derive(Deserialize)]
struct Ip {
    origin: String,
}

#[tokio::main]
async fn main() -&gt; Result&lt;(), reqwest::Error&gt; {
    let ip: Ip = reqwest::get(&quot;https://httpbin.org/ip&quot;)
        .await?
        .json()
        .await?;

    println!(&quot;IP: {}&quot;, ip.origin);
    Ok(())
}
</code></pre>
<p>在这个示例中，我们定义了一个 <code>Ip</code> 结构体来表示 JSON 响应，然后使用 <code>json</code> 方法将响应反序列化为 <code>Ip</code> 类型。</p>
<h2>总结</h2>
<p><code>reqwest</code> 库为 Rust 提供了一个功能丰富而灵活的 HTTP 客户端，适用于各种网络编程任务。无论是简单的数据获取还是复杂的 API 交互，<code>reqwest</code> 都能帮助你以简洁的 Rust 代码完成任务。希望这篇博文能帮助你开始使用 <code>reqwest</code> 来开发网络相关的 Rust 应用！</p>
]]></content:encoded>
    </item>
    <item>
      <title>深入浅出 Go 语言的 GPM 模型（Go1.21）</title>
      <link>https://hedon.top/blog/go-gpm/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-gpm/</guid>
      <pubDate>Sat, 20 Jan 2024 13:10:41 GMT</pubDate>
      <description>本文基于 Go1.21.0 版本详细介绍了 Go 语言的 GPM 模型。</description>
      <category>Go</category>
      <content:encoded><![CDATA[<h2>引言</h2>
<p>在现代软件开发中，有效地利用并发是提高应用性能和响应速度的关键。随着多核处理器的普及，编程语言和框架如何高效、简便地支持并发编程，成为了软件工程师们评估和选择工具时的一个重要考量。在这方面，Go 语言凭借其创新的并发模型—GPM（Goroutine, P, M）—在众多编程语言中脱颖而出，为开发者提供了强大的工具，以简单、高效的方式实现并发。</p>
<p>自从 2009 年首次发布以来，Go 语言就以其出色的性能、简洁的语法和对并发的原生支持赢得了广泛的关注。尤其是其并发模型，被设计为能够充分利用现代多核处理器的能力，同时隐藏底层的线程管理和同步复杂性，让开发者能够以更直观、更高级的抽象来构建并发程序。GPM 模型，作为 Go 语言并发编程的核心，通过 Goroutine、P（processor）、M（machine）三者的协同工作，实现了一种高效且易于管理的并发机制。</p>
<p>本文将基于 <strong>Go1.21</strong> 深入浅出地探讨 Go 语言的 GPM 模型，主要分为几个部分：</p>
<ul>
<li>首先从其设计理念出发，详细解析 Goroutine、P 和 M 三者的角色、工作原理及其相互之间的交互方式。</li>
<li>然后引入几个关键问题，我们会从结论上先总结 GPM 的核心要点，内容包括协程调度循环、调度策略和调度时机。</li>
<li>接着我们会深入源码，去一步步洞察 Go 语言设计者是如何实现 GPM 模型中的各个要点的，这个过程会比较繁琐，但其实也比较有趣，感兴趣的读者可以阅读这一块，若只是想对 GPM 模型有个大概了解，那么停留在上一步也足矣了。</li>
<li>最后我们基于前面的分析，总结 G、P、M 三大组件在 Go 程序运行过程中的状态流转图。</li>
</ul>
<p>通过对 GPM 模型的探讨，我们不仅能够理解 Go 语言如何在众多现代编程语言中以其并发编程能力脱颖而出，还能够洞察其设计背后的智慧，以及这一模型如何随着 Go 语言版本的迭代而不断进化和优化。无论你是对 Go 语言充满好奇的新手，还是希望深化理解其并发模型的经验开发者，本文都将为你提供宝贵的视角和深刻的洞见。</p>
<h2>结论先行</h2>
<h3>GPM 调度原理图</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240126214830929.png" alt="Goroutine 调度原理图"></p>
<h3>Goroutine 底层结构</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h569pjiiosj21cu0u040p.jpg" alt="Goroutine 底层结构示例"></p>
<h3>调度器 P 底层结构</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h57sl36b40j20o40u8js7.jpg" alt="P 底层结构"></p>
<h3>GPM 调度循环</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h57qxoptk4j21hm0simyy.jpg" alt="GPM 调度循环图"></p>
<h3>GPM 协程调度优先级与顺序</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240127161341751.png" alt="Go 协程调度优先级与顺序"></p>
<h3>寻找可执行 G 过程</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240128212451142.png" alt="findRunnable()"></p>
<h3>协程切换时机</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240129000028727.png" alt="Go 协程切换时机"></p>
<h2>GPM 模型</h2>
<h3>1. 概览</h3>
<p>这里有一张很流行的 Goroutine 调度原理图：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240126214830929.png" alt="Goroutine 调度原理图"></p>
<table>
<thead>
<tr>
<th>代号</th>
<th>名称</th>
<th>定义位置</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td>Sched</td>
<td>调度器</td>
<td>proc.c</td>
<td>维护有存储 M 和 G 的队列以及调度器的一些状态信息等。</td>
</tr>
<tr>
<td>M</td>
<td>Machine 系统线程</td>
<td>runtime.h</td>
<td>它由操作系统管理的，Goroutine 就是跑在 M 之上的；M 是一个很大的结构，里面维护小对象内存 cache（mcache）、当前执行的 Goroutine、随机数发生器等等非常多的信息。</td>
</tr>
<tr>
<td>P</td>
<td>Processor 处理器</td>
<td>runtime.h</td>
<td>它的主要用途就是用来执行 Goroutine 的，它维护了一个 Goroutine 队列，即 runqueue。Processor 是让我们从 N:1 调度到 M:N 调度的重要部分。所有的 P 都在程序启动时创建，并保存在数组中，最多有 GOMAXPROCS（可配置）个。</td>
</tr>
<tr>
<td>G</td>
<td>Goroutine 实现的核心结构</td>
<td>runtime.h</td>
<td>它包含了栈，指令指针，以及其他对调度 Goroutine 很重要的信息，例如其阻塞的 channel。</td>
</tr>
<tr>
<td>Global Queue</td>
<td>全局队列</td>
<td>proc.h</td>
<td>存放等待运行的 G。全局队列可能被任意的 P 加锁去获取里面的 G。</td>
</tr>
<tr>
<td>P Local Queue</td>
<td>P 的本地队列</td>
<td>proc.h</td>
<td>同全局队列类似，存放的也是等待运行的 G，但存放的数据有限，不会超过 256 个。新建 G 时，G 会优先加入本地队列。如果队列满了，则会把本地队列中一半的 G 以及新 G 一起移动到全局队列。</td>
</tr>
</tbody></table>
<p>通过这个原理图我们知道 Go 语言的 GPM 模型的作用非常简单，它就是一个“精打细算”的工具。以前单进程无法充分利用 CPU 资源，所以引入了多进程。又因为进程拥有的资源太多，其创建、切换和销毁都会占用很长时间，所以引入了更小粒度的线程。随着计算机科学的进步，现在看来，线程拥有的资源也是“比较多”的，所以线程的创建、切换和销毁代价也是“相对大”的。所以很多编程语言就引入了协程这个概念，其核心目的就是应用层自己抽象一个比线程更小粒度的调度单元，应用层结合操作系统的多线程能力，自己来管理“调度单元”的创建、切换和销毁，从而尽可能减少由线程切换带来的开销，以做到更轻量级的并发。</p>
<p>不同的编程语言可能有不同的实现，而关键就在于如何让调度更快、开销更小。这便是我们本文要探讨的主要内容。</p>
<details open>
<summary>Go 语言的实现</summary>


<p>线程想运行任务就得获取 P，从 P 的本地队列获取 G，当 P 的本地队列为空时，M 会尝试从全局队列获得一批 G 放到 P 的本地队列，或者从其他 P 的本地队列中“偷”一半 G 放到自己的本地队列。然后 M 运行 G，G 执行之后，再从 P 获取下一个 G，如此不断重复下去。</p>
</details>

<p>在进入更加具体深入的讨论之前，我们需要重点思考以下几个问题：</p>
<ol>
<li>G 我们可以随便创建，可能有成千上万个，那 P 和 M 有多少个呢？</li>
<li>P 和 M 什么时候被创建呢？</li>
<li>操作系统只知道线程，所以实际上还是线程在执行任务，那么 G 是如何调度到线程上并执行的呢？</li>
<li>如何防止协程饥饿？</li>
<li>如何减少频繁地创建和销毁线程？</li>
<li>多个线程从全局队列拿 G 如何解决并发问题？又如何减少这种数据竞争呢？</li>
<li>在整个 Go 调度协程的过程中，G、P、M 有哪些状态？它们又是如何轮转的呢？</li>
</ol>
<p>如果你对这几个问题有兴趣，请继续阅读下文。</p>
<h3>2. P 和 M 的个数问题</h3>
<ol>
<li>P 的数量由启动时环境变量 <code>$GOMAXPROCS</code> 或者程序中 <code>runtime.GOMAXPROCS()</code> 决定。这意味着在程序执行的任意时刻都只有 <code>GOMAXPROCS</code> 个 Goroutine 在同时运行。</li>
<li>M 的数量由 Go 语言本身的限制决定，Go 程序启动时会设置 M 的最大数量为 <strong>10000</strong> 个，但是内核很难支持这么多的线程数，所以这个限制可以忽略。可以使用 <code>runtime.SetMaxThreads()</code> 设置 M 的最大数量。</li>
</ol>
<h3>3. P 和 M 何时被创建</h3>
<ol>
<li>P 的创建时机在确定了 P 的最大数量 n 后，runtime 会根据这个数量创建 n 个 P。</li>
<li>M 创建的时机是在当没有足够的 M 来关联 P 并运行其中可运行的 G 的时候，如所有的 M 此时都阻塞住了，而 P 中还有很多就绪任务，就会去寻找空闲的 M，如果没有空闲的 M，就会去创建新的 M。</li>
</ol>
<h3>4. 调度循环</h3>
<p>在讨论 G 是如何被调度到 M 去执行的时候，我们需要先介绍 GPM 模型中两个比较特殊的角色：<code>m0</code> 和 <code>g0</code>。</p>
<h4>4.1 m0</h4>
<ul>
<li><strong>定义</strong>：m0 是 Go 程序启动时创建的第一个 M。它是由 Go 运行时系统直接从操作系统线程创建的，不是从线程池中获取的。</li>
<li><strong>作用</strong>：m0 负责初始化和启动 Go 运行时环境，包括创建调度器、分配第一个 P（p0），并创建其他系统级别的资源。在程序的整个生命周期中，m0 会继续存在，即使它可能不执行任何 Go 代码。</li>
<li><strong>特点</strong>：m0 不同于其他 M，因为它不是从线程池中获取的。它可能没有绑定任何 P，除非程序中只有一个 P（即 GOMAXPROCS 设置为 1）。</li>
</ul>
<h4>4.2 g0</h4>
<ul>
<li><strong>定义</strong>：g0 是每个 M 的特殊 Goroutine，它不执行任何实际的 Go 代码。每个 M 在创建时都会分配一个 g0。</li>
<li><strong>作用</strong>：g0 主要用于执行调度器代码和进行系统调用。当 M 需要执行这些非用户代码时，会切换到 g0 的栈上运行。</li>
<li><strong>特点</strong>：g0 拥有自己的栈，这个栈用于存放调度器函数和系统调用的数据。这意味着当执行这些操作时，不会影响当前运行的用户 Goroutine 的栈。</li>
</ul>
<h4>4.3 协程栈切换</h4>
<p>g0 是 M 中负责调度其他 g 的协程，所以一个 M 中的协程调度其实就是在 g 和 g0 之间不断切换：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240127155030695.png" alt="协程 g 与 协程 g0 的对应关系"></p>
<p>大致过程如下：</p>
<ol>
<li>当 M 执行一个 G（用户 Goroutine）时，它使用 G 的栈来运行用户代码。</li>
<li>当需要执行系统调用或调度器相关的代码时，M 会切换到 g0。g0 拥有自己的栈，专门用于执行系统调用和调度器代码，这样可以避免污染用户 Goroutine 的栈空间。在 g0 上，M 可以执行如内存分配、调度决策、处理 Goroutine 的创建和销毁等操作。</li>
<li>完成系统调用或调度器任务后，M 会切换回之前的 G，继续执行用户代码。这个过程会从 g0 的栈切换回 G 的栈。</li>
</ol>
<p>详细细节我们留到后面的源码分析揭晓。</p>
<h3>5. 调度策略</h3>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240127161341751.png" alt="Go 协程调度优先级与顺序"></p>
<h4>5.1 获取本地运行队列</h4>
<p>P 会优先尝试从自己的本地队列中寻找就绪的 G，它<strong>一般</strong>会优先调度最近加入的。</p>
<p>因为这个时候可能由其他 P 来窃取 G，所以这里是需要同步机制的，Go 采用原子操作来降低同步开销。</p>
<p>本地队列 G 的个数不超过 <strong>256</strong> 个，<strong>如果在创建 G 的时候本地队列满了，会将本地队列中 1/2 的 G 连同新创建的 G 一起放入全局队列中。</strong></p>
<h4>5.2 获取全局运行队列</h4>
<p>P 会优先本地队列，然后才全局队列，这有个好处：</p>
<ul>
<li>如果只有全局队列，那么所有的 P 都需要去竞争全局队列中的 G，这个时候需要上锁，且数据竞争会比较激烈，性能较差。</li>
<li>通过每个 P 维护一个自己的本地队列可以减少并发冲突，如果实在需要去全局队列拿 G，也可以一次性拿多个，大大减少了并发冲突的情况发生。</li>
</ul>
<p>但这又带来了一个问题，全局队列中协程的饥饿问题，因为 P 会优先调度最近加入到自己本地队列中的 G，那可能会一直有新的 G 被创建，导致全局队列中的 G 没有机会被调度到。Go 的解决思路是：</p>
<ul>
<li>P 每调度 <strong>61</strong> 次后，就会从全局队列中获取一个 G 来运行。</li>
</ul>
<h4>5.3 获取准备就绪的网络协程</h4>
<p>如果本地队列和全局队列都找不到就绪的 G 可以执行的话。调度会通过 <code>runtime.netpoll</code> 获取可以运行的网络协程。</p>
<p>Go 语言的网络模型是对不同操作系统平台上 I/O 多路复用技术的封装。</p>
<p>当 Goroutine 在进行网络 I/O 时，它会被挂起，线程会去执行其他 Goroutine。一旦 I/O 操作完成，该 Goroutine 会被唤醒并重新排队等待执行。</p>
<h4>5.4 系统调用</h4>
<p>当一个 Goroutine 执行系统调用时，它可能会被阻塞，这时它的执行线程（M）可能会释放当前绑定的处理器（P），以便其他 Goroutine 可以在该 P 上运行。</p>
<h4>5.5 协程窃取</h4>
<p>空闲的 M 如果绑定了 P，那么它的 P 会一直尝试从其他 P 的队列中窃取 Goroutine，以平衡负载和避免空闲。这个时候为了让每个 P 都有可能被窃取，Go 没有直接顺序遍历 P 列表，而是采用了一种相对随机的方式去遍历 P 列表，直到找到可以运行的协程就返回。M 不断寻找可执行 G 的这段期间，它被称为<strong>自旋线程</strong>。</p>
<p>所以为减少创建、切换和销毁线程的开销，Go 做了至少两点努力：</p>
<ol>
<li><p>偷取（Work Stealing）机制</p>
<p>当本线程无可运行的 G 时，它所绑定的 P 会尝试从其他线程绑定的 P 窃取 G，而不是销毁线程。</p>
</li>
<li><p>移交（Hand Off）机制</p>
<p>当本线程因为 G 进行系统调用而阻塞时，线程会释放绑定的 P，把 P 移交给其他空闲的线程执行。</p>
</li>
</ol>
<h3>6. 调度时机</h3>
<p>Go 语言的调度器结合了抢占式调度和协作式调度，以下是 Go 中这两种调度方式的具体实现和特性：</p>
<h4>6.1 协作式调度（Cooperative Scheduling）</h4>
<p><strong>阻塞操作</strong>：</p>
<ul>
<li>当 Goroutine 执行阻塞操作（如通道操作、等待锁、系统调用等）时，它会主动放弃 CPU 控制权，允许调度器切换到其他 Goroutine。</li>
</ul>
<p><strong>显式调度</strong>：</p>
<ul>
<li>Goroutine 显式请求 <code>runtime.Gosched()</code> 调用，调度器进行调度。</li>
<li>这个时候回从当前协程切换到 g0 协程，取消 G 与 M 之间的绑定关系，把 G 放入全局队列中。</li>
</ul>
<h4>6.2 抢占式调度（Preemptive Scheduling）</h4>
<p><strong>基于时间的抢占</strong>：</p>
<ul>
<li>从 Go 1.14 开始，调度器引入了基于时间的抢占机制。如果一个 Goroutine 运行时间超过 10 毫秒，或者在系统调用中超过了 20 微妙，调度器会在安全点（如函数调用、循环迭代、阻塞操作等）尝试暂停该 Goroutine。</li>
<li>这种抢占不依赖于 Goroutine 的显式放弃控制，而是由调度器主动触发。</li>
<li>安全点的选择旨在减少对 Goroutine 执行的干扰，同时确保调度的公平性和响应性。</li>
</ul>
<p><strong>基于信号的抢占：</strong></p>
<ul>
<li>当程序在执行过程中既无法主动挂起，也不能进行系统调用，且无法进行函数调用时，就可以使用信号来调度。</li>
<li>信号其实就是线程信号，在操作系统中有很多基于信号的底层通信方式（SIGPIPE / SIGURG / SIGHUP），而我们的线程可以注册对应信号的处理函数。</li>
<li>当线程接收到抢占信号时，会进入一个专门的信号处理器。这个处理器会检查是否处于安全点，如果是，则暂停当前 Goroutine 并进行上下文切换。</li>
</ul>
<h2>源码分析</h2>
<p>前面我们对 Go 语言的 GPM 模型在基本概念、调度循环、调度策略和调度时机各个方面都进行了详细的阐述。如果读者只是想简单了解一下 GPM 模型的一些概念和设计思想，那么阅读到这里就基本足够了。如果对其源码实现有兴趣的话，那么请继续往下阅读~</p>
<p>接下来我们会从以下几个方面来对 Go 语言的 GPM 模型进行源码分析：</p>
<ol>
<li>G、P、M 在 Go 语言中的表示。</li>
<li>G 的创建过程。</li>
<li>g 和 g0 的切换过程。</li>
<li>GPM 的调度机制。</li>
</ol>
<h3>1. G 的底层结构</h3>
<p>G 在 Go 里面就是 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/runtime2.go">runtime2.go</a> 里面定义的 <code>g</code> 结构体：</p>
<pre><code class="language-go">type g struct {
	// 栈参数。
	// stack 描述实际的栈内存：[stack.lo, stack.hi)。
	// stackguard0 是在 Go 栈增长序言中比较的栈指针。
	// 通常是 stack.lo+StackGuard，但可以是 StackPreempt 来触发抢占。
	// stackguard1 是在 C 栈增长序言中比较的栈指针。
	// 在 g0 和 gsignal 栈上是 stack.lo+StackGuard。
	// 在其他 goroutine 栈上是 ~0，以触发对 morestackc 的调用（并崩溃）。
	stack       stack   // 运行时/CGO 已知的偏移
	stackguard0 uintptr // liblink 已知的偏移
	stackguard1 uintptr // liblink 已知的偏移

	_panic    *_panic // 最内层的 panic - liblink 已知的偏移
	_defer    *_defer // 最内层的 defer
	m         *m      // 当前 m；arm liblink 已知的偏移
	sched     gobuf   // 当前协程的运行现场
	syscallsp uintptr // 如果 status==Gsyscall, syscallsp = sched.sp 在 gc 期间使用
	syscallpc uintptr // 如果 status==Gsyscall, syscallpc = sched.pc 在 gc 期间使用
	stktopsp  uintptr // 栈顶的预期 sp，用于回溯检查
	// param 是一个通用的指针参数字段，用于在特定上下文中传递值，
	// 其他存储参数的方式难以找到。目前有三种用途：
	// 1. 当通道操作唤醒一个阻塞的 goroutine 时，它将 param 设置为
	//    指向已完成阻塞操作的 sudog。
	// 2. 由 gcAssistAlloc1 使用，以向其调用者信号，表明 goroutine 完成了 GC 周期。
	//    以任何其他方式这样做是不安全的，因为此时 goroutine 的栈可能已经移动。
	// 3. 由 debugCallWrap 使用，以将参数传递给新的 goroutine，因为在运行时分配闭包是被禁止的。
	param        unsafe.Pointer
	atomicstatus atomic.Uint32
	stackLock    uint32 // sigprof/scang 锁；TODO: 合并到 atomicstatus
	goid         uint64
	schedlink    guintptr
	waitsince    int64      // g 变为阻塞的大致时间
	waitreason   waitReason // 如果 status==Gwaiting

	preempt       bool // 抢占信号，复制 stackguard0 = stackpreempt
	preemptStop   bool // 在抢占时转换为 _Gpreempted；否则，只是取消调度
	preemptShrink bool // 在同步安全点缩小栈

	// asyncSafePoint 设置为 true 表示 g 在异步安全点停止。
	// 这意味着栈上有没有精确指针信息的帧。
	asyncSafePoint bool

	paniconfault bool // 在意外的故障地址上触发 panic（而不是崩溃）
	gcscandone   bool // g 已扫描栈；由 _Gscan 位在状态中保护
	throwsplit   bool // 必须不分割栈
	// activeStackChans 表示有未锁定的通道指向这个 goroutine 的栈。
	// 如果为 true，栈复制需要获取通道锁来保护这些栈区域。
	activeStackChans bool
	// parkingOnChan 表示 goroutine 即将在 chansend 或 chanrecv 上停车。
	// 用于标记栈缩小的不安全点。
	parkingOnChan atomic.Bool

	raceignore    int8  // 忽略竞态检测事件
	tracking      bool  // 是否跟踪此 G 以获取调度延迟统计
	trackingSeq   uint8 // 用于决定是否跟踪此 G
	trackingStamp int64 // G 最后开始被跟踪的时间戳
	runnableTime  int64 // 可运行时间，运行时清除，仅在跟踪时使用
	lockedm       muintptr
	sig           uint32
	writebuf      []byte
	sigcode0      uintptr
	sigcode1      uintptr
	sigpc         uintptr
	parentGoid    uint64          // 创建此 goroutine 的 goroutine 的 goid
	gopc          uintptr         // 创建此 goroutine 的 go 语句的 pc
	ancestors     *[]ancestorInfo // 创建此 goroutine 的祖先 goroutine 的信息（仅在 debug.tracebackancestors 使用）
	startpc       uintptr         // goroutine 函数的 pc
	racectx       uintptr
	waiting       *sudog         // 此 g 正在等待的 sudog 结构（具有有效的 elem 指针）；按锁顺序
	cgoCtxt       []uintptr      // cgo 回溯上下文
	labels        unsafe.Pointer // 分析器标签
	timer         *timer         // 缓存的计时器，用于 time.Sleep
	selectDone    atomic.Uint32  // 我们是否参与 select 并且有人赢得了竞赛？

	// goroutineProfiled 指示当前 goroutine 的栈状态
  // 是否已经被记录在进行中的 goroutine 性能分析中。
	goroutineProfiled goroutineProfileStateHolder

	// 每个 G 的追踪状态。
	trace gTraceState

	// 每个 G 的 GC 状态

	// gcAssistBytes 是此 G 的 GC 协助信用，以分配的字节为单位。
	// 如果为正，则 G 有信用分配 gcAssistBytes 字节而不协助。
	// 如果为负，则 G 必须通过执行扫描工作来纠正这一点。
	// 我们以字节为单位跟踪这一点，以便在 malloc 热路径中快速更新和检查债务。
	// 协助比率决定了这如何对应于扫描工作债务。
	gcAssistBytes int64
}
</code></pre>
<p>可以看到 <code>g</code> 结构字段非常多，这个结构体的设计反映了 Go 语言对并发和协程管理的底层机制，包括栈管理、调度、垃圾回收、异常处理等多个方面。通过这种抽象，Go 语言能够有效地管理成千上万的 <code>goroutine</code>，使得并发编程变得更加简单和高效。</p>
<p>这里我们只关注 GPM 模型相关的内容，需要重点关心以下几个字段：</p>
<pre><code class="language-go">type g struct {
	stack     stack   						// 当前协程的协程栈
	m         *m      						// 当前线程
	sched     gobuf	  						// 保存协程的运行现场
	atomicstatus atomic.Uint32		// 协程状态
	goid         uint64						// 协程ID
}
</code></pre>
<h4>1.1 协程栈 stack</h4>
<p>其中 <code>stack</code> 结构如下，它存储了协程栈的低地址和高地址。</p>
<pre><code class="language-go">type stack struct {
	lo uintptr // 栈的低地址
	hi uintptr // 栈的高地址
}
</code></pre>
<h4>1.2 线程抽象 m</h4>
<p>而 <code>m</code> 就是 Go 语言对操作系统线程的抽象，这不是实际的线程，这只是 Go 语言对线程相关信息的抽象，以方便更好地调度协程。</p>
<pre><code class="language-go">type m struct {
	g0      *g     		// g0 协程，Go 中的主协程
	curg    *g       	// 现在正在运行的协程
	id      int64		 	// 线程ID
	mOS								// 当前操作系统对线程的额外描述信息
	...
}
</code></pre>
<p><code>m</code> 结构体包含了许多字段，这些字段涉及到线程管理、调度、信号处理、系统调用、锁管理等多个方面。这个结构体是 Go 并发模型的核心部分之一，它与 <code>g</code>（goroutine）和 <code>p</code>（processor）结构体一起，构成了 Go 的调度系统的基础。通过这种设计，Go 能够有效地在多个操作系统线程之间调度成千上万的 goroutine，实现高效的并发执行。</p>
<h4>1.3 协程上下文 gobuf</h4>
<p><code>gobuf</code> 结构体在 Go 语言的运行时系统中用于保存 <code>Goroutine</code> 的执行上下文，特别是在调度和系统调用中。这个结构体保存了足够的信息以便在 <code>Goroutine</code> 被暂停后能够恢复执行。</p>
<p>下面是对 <code>gobuf</code> 结构体中各个字段的解释：</p>
<pre><code class="language-go">type gobuf struct {
	// sp, pc 和 g 的偏移量是已知的（在 libmach 中硬编码）。
	//
	// ctxt 在 GC 方面比较特殊：它可能是一个堆分配的 funcval，
	// 因此 GC 需要跟踪它，但它需要在汇编中设置和清除，
	// 在那里实现写屏障比较困难。然而，ctxt 实际上是一个保存的、活跃的寄存器，
	// 我们只在真实寄存器和 gobuf 之间交换它。因此，我们在栈扫描期间将其视为根，
	// 这意味着保存和恢复它的汇编不需要写屏障。它仍然被类型化为指针，
	// 以便任何其他从 Go 进行的写操作都会获得写屏障。
	sp   uintptr        // 栈指针
	pc   uintptr        // 程序计数器
	g    guintptr       // 指向当前 goroutine 的指针
	ctxt unsafe.Pointer // 上下文，用于保存额外的状态或信息
	ret  uintptr        // 用于保存函数返回值
	lr   uintptr        // 链接寄存器（在某些架构中用于函数调用）
	bp   uintptr        // 基指针（在启用帧指针的架构中使用）
}
</code></pre>
<p>我们重点需要关注 2 个字段：</p>
<ul>
<li><code>sp</code>：栈指针，表示当前协程运行到栈中的哪个位置了。</li>
<li><code>pc</code>：程序计数器，表示当前协程运行到哪一行代码了。</li>
</ul>
<h4>1.4 协程状态 atomicstatus</h4>
<p>我记得在 Go1.16 版本中，这个字段的类型还是 <code>uint32</code>：</p>
<pre><code class="language-go">atomicstatus uint32
</code></pre>
<p>现在 Go1.21 版本中，已经用了原子操作来减少并发冲突了：</p>
<pre><code class="language-go">atomicstatus atomic.Uint32
</code></pre>
<p>可以看到 Go 的底层也是随着版本更新不断优化中的。</p>
<p><a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/runtime2.go">runtime2.go</a> 定义了 G 的各种状态，如：</p>
<ul>
<li><code>_Gidle (0)</code>: 表示 G 刚刚被分配，尚未初始化。</li>
<li><code>_Grunnable (1)</code>: 表示 G 在运行队列上。它当前没有执行用户代码。栈不被该 <code>goroutine</code> 拥有。</li>
<li>...</li>
</ul>
<p>后面我们会给出 G 状态的流转图。</p>
<h4>1.5 举个例子</h4>
<p>假设我们现在有以下 Go 代码：main() 调用 do1()，do1() 调用 do2()，do2() 调用 do3()。</p>
<pre><code class="language-go">func do3() {
	fmt.Println(&quot;here is do3&quot;)
}

func do2() {
	do3()		//	&lt;---------------
}

func do1() {
	do2()
}

func main() {
	do1()
}
</code></pre>
<p>那么当这段程序运行到第 6 行的时候，它的底层结构大概如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h569pjiiosj21cu0u040p.jpg" alt="Goroutine 底层结构示例"></p>
<p>至于为什么这里有个 <code>goexit()</code>，其实就是为了可以跳回到 <code>g0</code> 协程，后面我们会具体分析到。</p>
<h3>2. P 的底层结构</h3>
<p>P 的本质是 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/runtime2.go">runtime2.go</a> 里面定义的 <code>p</code> 结构体：</p>
<pre><code class="language-go">type p struct {
    id          int32          // P 的唯一标识符
    status      uint32         // P 的状态，如 pidle/prunning/...
    link        puintptr       // P 链接
    schedtick   uint32         // 每次调度器调用时递增
    syscalltick uint32         // 每次系统调用时递增
    sysmontick  sysmontick     // sysmon 观察到的最后一个 tick
    m           muintptr       // 关联的 M 的反向链接（如果空闲则为 nil）
    mcache      *mcache        // M 缓存
    pcache      pageCache      // 页面缓存
    raceprocctx uintptr        // 用于竞态检测的上下文

    // 延迟结构体池
    deferpool    []*_defer
    deferpoolbuf [32]*_defer

    // Goroutine ID 缓存，减少对 runtime·sched.goidgen 的访问
    goidcache    uint64
    goidcacheend uint64

    // 可运行 goroutine 队列，无锁访问
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr
    runnext  guintptr // 下一个要运行的 G

    // 空闲 G 的列表（状态 == Gdead）
    gFree struct {
        gList
        n int32
    }

    // sudog 缓存
    sudogcache []*sudog
    sudogbuf   [128]*sudog

    // 堆上 mspan 对象的缓存
    mspancache struct {
        len int
        buf [128]*mspan
    }

    // pinner 对象的缓存
    pinnerCache *pinner

  	// P 状态跟踪
    trace pTraceState

    // 每个 P 的持久分配，避免互斥
    palloc persistentAlloc

    // 定时器相关字段
    timer0When             atomic.Int64
    timerModifiedEarliest  atomic.Int64
    timersLock             mutex
    timers                 []*timer
    numTimers              atomic.Uint32
    deletedTimers          atomic.Uint32
    timerRaceCtx           uintptr

    // GC 相关字段
    gcAssistTime         int64
    gcFractionalMarkTime int64
    gcw                  gcWork
    wbBuf                wbBuf

    // 指示是否在下一个安全点运行特定的函数
    runSafePointFn uint32

    // 指示当前 P 是否正在写入任何统计数据。
  	// 偶数时表示没有写入，奇数时表示正在写入。
    statsSeq       atomic.Uint32

    // 指示当前的 P 应该尽快进入调度器，无论其上运行的是哪个 G。
		// 这是实现抢占式调度的一部分，允许调度器在必要时中断长时间运行的 goroutine，
    // 以便其他 goroutine 有机会运行。
    preempt        bool

    // 记录页面分配、释放和清理跟踪信息的缓冲区。
    pageTraceBuf   pageTraceBuf
}
</code></pre>
<p><code>p</code> 结构体在 Go 语言的运行时系统中代表了一个处理器（processor），它是调度器的核心组成部分。每个 <code>p</code> 负责管理一组 <code>goroutine</code> 的运行。这个结构体包含了许多字段，涉及到 <code>goroutine</code> 的调度、内存分配、垃圾回收和其他系统级别的操作。</p>
<p>我们重点关注以下几个字段：</p>
<pre><code class="language-go">type p struct {
	m           muintptr   // 当前负责的线程

	// 本地可运行的协程的队列，可无锁访问
	runqhead uint32					 // 队头
	runqtail uint32					 // 队尾
	runq     [256]guintptr   // 长度为 256
	runnext guintptr				 // 下一个可用的协程的指针

  // 抢占标识，指示当前的 P 应该尽快进入调度器，无论其上运行的是哪个 G。
  preempt bool
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h57sl36b40j20o40u8js7.jpg" alt="P 底层结构"></p>
<h3>3. Goroutine 的创建</h3>
<p>Go 并发能力的优秀之处，就在于它启动一个新的协程实在是太方便了：</p>
<pre><code class="language-go">go func() { ... }()
</code></pre>
<p>那么底层究竟做了什么呢？</p>
<h4>3.1 newproc()</h4>
<p>Goroutine 通过 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/proc.go">proc.go</a> 中的 <code>newproc()</code> 创建：</p>
<pre><code class="language-go">func newproc(fn *funcval) {
    gp := getg()
    pc := getcallerpc()
    systemstack(func() {
        newg := newproc1(fn, gp, pc)

        pp := getg().m.p.ptr()
        runqput(pp, newg, true)

        if mainStarted {
            wakep()
        }
    })
}
</code></pre>
<ol>
<li><strong>获取当前 goroutine 和调用者 PC</strong>: <code>getg()</code> 获取当前正在执行的 <code>goroutine</code>，<code>getcallerpc()</code> 获取调用者的程序计数器地址。</li>
<li><strong>在系统栈上执行 <code>newproc1</code></strong>: <code>systemstack</code> 确保 <code>newproc1</code> 在系统栈上执行，而不是当前 <code>goroutine</code> 的栈。这是因为新的 <code>goroutine</code> 可能需要更多的栈空间。</li>
<li><strong>创建新的 goroutine</strong>: <code>newproc1</code> 被调用来实际创建新的 <code>goroutine</code>。</li>
<li><strong>将新的 goroutine 放入运行队列</strong>: <code>runqput</code> 将新创建的 <code>goroutine</code> 放入运行队列。</li>
<li><strong>唤醒处理器</strong>: 如果主函数已经开始执行，<code>wakep</code> 用于唤醒一个空闲的 P 来运行新的 <code>goroutine</code>。</li>
</ol>
<h4>3.2 newproc1()</h4>
<pre><code class="language-go">func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
    // ... (省略了错误检查和获取 M 的代码)

  	// 尝试从 P 的空闲列表获取一个 G，如果没有则创建一个新的
    newg := gfget(pp)
    if newg == nil {
        newg = malg(stackMin)
        casgstatus(newg, _Gidle, _Gdead)
        allgadd(newg)
    }

    // 设置新 G 的栈
    totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize)
    totalSize = alignUp(totalSize, sys.StackAlign)
    sp := newg.stack.hi - totalSize
  	spArg := sp

    // 清空并设置新 G 的调度器相关字段
    memclrNoHeapPointers(unsafe.Pointer(&amp;newg.sched), unsafe.Sizeof(newg.sched))
    newg.sched.sp = sp
    newg.stktopsp = sp
    newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum
    newg.sched.g = guintptr(unsafe.Pointer(newg))

    // 设置新 G 的其他字段
    gostartcallfn(&amp;newg.sched, fn)
    newg.parentGoid = callergp.goid
    newg.gopc = callerpc
    newg.startpc = fn.fn

  	// ... (省略了跟踪和调试相关的代码)

    casgstatus(newg, _Gdead, _Grunnable)

    return newg
}
</code></pre>
<ol>
<li><strong>创建或获取一个新的 goroutine</strong>: <code>gfget</code> 尝试从 P 的空闲列表中获取一个 <code>goroutine</code>，如果没有可用的，则通过 <code>malg</code> 分配一个新的。</li>
<li><strong>初始化 goroutine 的栈和调度器</strong>: 设置新 <code>goroutine</code> 的栈、程序计数器、调用函数等。这里有个非常核心的点 <code>newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum</code>，我们前面留了个疑问，协程栈顶的 <code>goexit</code> 是哪里来的，就是这里来的。这里设置新 <code>goroutine</code> 的程序计数器（<code>pc</code>）指向 <code>goexit</code> 函数。<code>goexit</code> 是每个 <code>goroutine</code> 在退出时必须调用的函数，用于执行清理工作并切换到 g0 栈。</li>
<li><strong>设置父 goroutine ID 和创建点</strong>: 记录创建这个新 <code>goroutine</code> 的父 <code>goroutine</code> 的 ID 和 <code>go</code> 语句的位置。</li>
<li><strong>更改 goroutine 状态</strong>: 将新 <code>goroutine</code> 的状态从 <code>_Gdead</code> 改为 <code>_Grunnable</code>，使其准备好被调度。</li>
<li><strong>返回新的 goroutine</strong>: 函数返回新创建的 <code>goroutine</code>。</li>
</ol>
<h4>3.3 runqput()</h4>
<pre><code class="language-go">// runqput tries to put g on the local runnable queue.
// If next is false, runqput adds g to the tail of the runnable queue.
// If next is true, runqput puts g in the pp.runnext slot.
// If the run queue is full, runnext puts g on the global queue.
// Executed only by the owner P.
func runqput(pp *p, gp *g, next bool) {
	if randomizeScheduler &amp;&amp; next &amp;&amp; fastrandn(2) == 0 {
		next = false
	}

	if next {
	retryNext:
		oldnext := pp.runnext
		if !pp.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
			goto retryNext
		}
		if oldnext == 0 {
			return
		}
		gp = oldnext.ptr()
	}

retry:
	h := atomic.LoadAcq(&amp;pp.runqhead)
	t := pp.runqtail
	if t-h &lt; uint32(len(pp.runq)) {
		pp.runq[t%uint32(len(pp.runq))].set(gp)
		atomic.StoreRel(&amp;pp.runqtail, t+1)
		return
	}
	if runqputslow(pp, gp, h, t) {
		return
	}
	goto retry
}
</code></pre>
<ol>
<li><strong>随机调度器</strong>：如果启用了调度器的随机化（<code>randomizeScheduler</code>），并且 <code>next</code> 为 <code>true</code>（newproc 调用的时候永远都是传的 true），则有一半的概率将 <code>next</code> 设置为 <code>false</code>。这有助于防止调度器的行为过于可预测。</li>
<li><strong>处理 <code>runnext</code> 槽</strong>：如果 <code>next</code> 为 <code>true</code>，函数尝试将 <code>gp</code> 放入 <code>pp.runnext</code> 槽。如果该槽已被占用，则将原有的 <code>goroutine</code> 移动到常规运行队列，并重试将新的 <code>gp</code> 放入 <code>runnext</code>。</li>
<li><strong>放入本地运行队列</strong>：如果 <code>next</code> 为 <code>false</code> 或 <code>runnext</code> 槽已满，函数尝试将 <code>gp</code> 放入本地运行队列的尾部。如果队列未满，<code>gp</code> 将被成功添加。</li>
<li><strong>处理队列满的情况</strong>：如果本地运行队列已满，<code>runqputslow</code> 被调用，尝试将 <code>gp</code> 连同自己队列中一半的 g 放入全局运行队列。如果这也失败了，函数会重试将 <code>gp</code> 放入本地队列。</li>
<li><strong>原子操作</strong>：函数使用原子操作来加载和存储队列头（<code>runqhead</code>）和尾（<code>runqtail</code>）指针，以确保多线程环境下的数据一致性和线程安全。</li>
</ol>
<h4>3.4 runqputslow()</h4>
<p><code>runqputslow</code> 函数处理本地运行队列满的情况，将 <code>goroutine</code> 批量转移到全局队列。这个函数通过原子操作和锁来确保操作的原子性和线程安全。随机化调度器的使用增加了调度的随机性和公平性。</p>
<pre><code class="language-go">// Put g and a batch of work from local runnable queue on global queue.
// Executed only by the owner P.
func runqputslow(pp *p, gp *g, h, t uint32) bool {

  // 从 pp 的本地队列中获取一半的 goroutine
  var batch [len(pp.runq)/2 + 1]*g
	n := t - h
	n = n / 2
	if n != uint32(len(pp.runq)/2) {
		throw(&quot;runqputslow: queue is not full&quot;)
	}
	for i := uint32(0); i &lt; n; i++ {
		batch[i] = pp.runq[(h+i)%uint32(len(pp.runq))].ptr()
	}
	if !atomic.CasRel(&amp;pp.runqhead, h, h+n) {
		return false
	}
	batch[n] = gp

  // 随机打乱 goroutine 的顺序，以增加调度的随机性
	if randomizeScheduler {
		for i := uint32(1); i &lt;= n; i++ {
			j := fastrandn(i + 1)
			batch[i], batch[j] = batch[j], batch[i]
		}
	}

	// 串成队列
	for i := uint32(0); i &lt; n; i++ {
		batch[i].schedlink.set(batch[i+1])
	}
	var q gQueue
	q.head.set(batch[0])
	q.tail.set(batch[n])

	// 放入全局队列中
	lock(&amp;sched.lock)
	globrunqputbatch(&amp;q, int32(n+1))
	unlock(&amp;sched.lock)
	return true
}
</code></pre>
<ol>
<li><strong>创建批处理数组</strong>：函数首先创建一个 <code>batch</code> 数组，用于存储从本地队列中取出的 <code>goroutine</code>。</li>
<li><strong>从本地队列中获取一批 <code>goroutine</code></strong>：函数计算出要从本地队列中取出多少个 <code>goroutine</code>（通常是队列长度的一半），并将它们添加到 <code>batch</code> 数组中。</li>
<li><strong>原子操作更新队列头部</strong>：使用原子操作 <code>atomic.CasRel</code> 更新本地运行队列的头部索引，这是一个释放（release）操作，确保之前的读取操作完成。</li>
<li><strong>将当前 <code>goroutine</code> 添加到批处理中</strong>：将传入的 <code>gp</code> 添加到 <code>batch</code> 数组的末尾。</li>
<li><strong>随机化调度器</strong>：如果启用了随机调度器，函数会随机打乱 <code>batch</code> 数组中的 <code>goroutine</code> 顺序，以增加调度的随机性。</li>
<li><strong>链接 <code>goroutine</code></strong>：将 <code>batch</code> 数组中的 <code>goroutine</code> 链接起来，形成一个队列。</li>
<li><strong>准备全局队列</strong>：创建一个 <code>gQueue</code> 结构，并设置其头部和尾部指向 <code>batch</code> 数组中的第一个和最后一个 <code>goroutine</code>。</li>
<li><strong>将批处理放入全局队列</strong>：加锁访问全局调度器的锁，然后将整个 <code>batch</code> 队列放入全局运行队列。</li>
</ol>
<h3>4. 调度过程 schedule()</h3>
<p>Go 的调度器核心执行逻辑都在 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/proc.go">proc.go</a> 的 <code>schedule()</code> 函数中。我们先不探讨过多的细节，我们先把整个大体脉络理清楚再说。</p>
<p>简化后的 <code>schedule()</code> 如下：</p>
<pre><code class="language-go">func schedule() {
  // 获取当前正在执行的 M
	mp := getg().m
  ...
  // 查找一个可运行的 G，会阻塞住直到返回。
	gp, inheritTime, tryWakeP := findRunnable()
  ...
	// 执行 g
	execute(gp, inheritTime)
}
</code></pre>
<p><code>execute()</code> 执行 g，它简化后如下：</p>
<pre><code class="language-go">func execute(gp *g, inheritTime bool) {
	mp := getg().m
	..
	gogo(&amp;gp.sched)
}
</code></pre>
<p>它调用了 <code>gogo()</code>：</p>
<pre><code class="language-go">func gogo(buf *gobuf)
</code></pre>
<p>一般这种格式说明函数是用汇编实现的，我们在 Goland 上可以双击 shift 然后搜索 <code>runtime·gogo</code>：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240128130637007.png" alt="runtime·gogo"></p>
<p>不同的平台有不同的实现，但是核心逻辑都是一样的， 它直接操作处理器的寄存器和栈，以实现从一个 <code>goroutine</code> 切换到另一个 <code>goroutine</code> 的功能。</p>
<p>我们后面的发内心会发现 <code>goexit()</code> 最终会调用 <code>schedule()</code>。</p>
<p>这就串起来了，Go 程序启动后会创建 m0 和 g0，所以第一个<code>schedule()</code> 是 g0 调用的，最后通过 <code>gogo</code> 切换到用户协程 g 上面执行业务方法，完事后 g 通过 <code>goexit</code> 回到 <code>schedule()</code>，以此循环反复下去。</p>
<p>现在我们可以来总结一下 GPM 调度循环的过程，大概如下图表示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h57qxoptk4j21hm0simyy.jpg" alt="GPM 调度循环图"></p>
<p>下面我们再对这个过程中的关键函数进行细致分析：</p>
<ul>
<li><code>schedule()</code>：调度入口。</li>
<li><code>findRunnable()</code>：寻找可执行的 G。</li>
<li><code>execute()</code>：执行 G。</li>
<li><code>gogo()</code>：切换协程栈 g0 到 g。</li>
<li><code>goexit()</code>：退出 g 协程，切换回 g0 栈。</li>
</ul>
<h4>4.1 schedule()</h4>
<pre><code class="language-go">func schedule() {
  // 获取当前正在执行的 M
	mp := getg().m

  // 有锁的话抛出异常，避免该情况下调度出现死锁或其他问题
	if mp.locks != 0 {
		throw(&quot;schedule: holding locks&quot;)
	}

  // M 被锁定了特定的 G，这个时候直接执行这个锁定的 G。
	if mp.lockedg != 0 {
		stoplockedm()
		execute(mp.lockedg.ptr(), false) // Never returns.
	}

	// CGO 调用需要 g0 栈，所以这个时候不继续调度了，抛出异常。
	if mp.incgo {
		throw(&quot;schedule: in cgo&quot;)
	}

top:
	pp := mp.p.ptr()
	pp.preempt = false

	// 安全点检查：如果当前 M 在自旋的话，应该是没有可执行 G 的。
	if mp.spinning &amp;&amp; (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
		throw(&quot;schedule: spinning with local work&quot;)
	}

  // 查找一个可运行的 G，会阻塞住直到返回。
	gp, inheritTime, tryWakeP := findRunnable()

  // 调试的时候系统会处于“冻结”状态，
  // 这里故意通过两次 lock 引入死锁使当前 M 陷入无限等待，
  // 以在调试时保持当前的调度器和运行时状态不变。
	if debug.dontfreezetheworld &gt; 0 &amp;&amp; freezing.Load() {
		lock(&amp;deadlock)
		lock(&amp;deadlock)
	}

  // 如果当前 M 之前是自旋的，但是现在要准备执行 G 了，那就不是自旋了。
	if mp.spinning {
		resetspinning()
	}

  // 当用户级调度被禁用时，采用双重检查后如果确实被禁用了，
  // 那么就把当前 g 放在 sched.disable.runnable 列表中，
  // 等待调度重启启用时再处理。
  // 在 gc 的时候会出现这种情况：
  // gcStart()    -&gt;  schedEnableUser(false)
  // gcMarkDone() -&gt;  schedEnableUser(true)
	if sched.disable.user &amp;&amp; !schedEnabled(gp) {
		lock(&amp;sched.lock)
		if schedEnabled(gp) {
			unlock(&amp;sched.lock)
		} else {
			sched.disable.runnable.pushBack(gp)
			sched.disable.n++
			unlock(&amp;sched.lock)
			goto top
		}
	}

	// 检查是否需要唤醒一个 P。
  // 如果返回的 g 比较特殊，比如要负责 gc，那么这个值会是 true。
	if tryWakeP {
		wakep()
	}

  // 如果 g 已经绑定了 M，则直接启动该 M 去执行 g。
	if gp.lockedm != 0 {
		startlockedm(gp)
		goto top
	}

  // 执行 g
	execute(gp, inheritTime)
}
</code></pre>
<p><code>schedule()</code> 函数是 Go 调度器的核心，负责管理 <code>goroutine</code> 的执行。它包括多个步骤，如检查当前 M 的状态，处理特殊情况（如 <code>goroutine</code> 被锁定到特定的 M，或者 M 正在执行 CGO 调用），以及选择和执行可运行的 <code>goroutine</code>。</p>
<h4>4.2 findRunnable()</h4>
<pre><code class="language-go">// Finds a runnable goroutine to execute.
// Tries to steal from other P&#39;s, get g from local or global queue, poll network.
// tryWakeP indicates that the returned goroutine is not normal (GC worker, trace reader) so the caller should try to wake a P.
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
</code></pre>
<p>通过注释就可以知道这个函数的作用：寻找一个可执行的 goroutine：</p>
<ol>
<li>尝试从其他 P 窃取 g、从本地获取 g、从全局队列获取 g、从网络轮询器获取 g；</li>
<li>如果是一个特殊的 g，如要负责 gc 或 trace，那么会将 <code>tryWakeP</code> 置为 <code>true</code>，表示调度器需要尝试唤醒或启动一个新的 P 来运行这个 g，以确保了即使在系统负载较低时，这些特殊的 g 也能得到及时处理。</li>
</ol>
<p>我们只关心它的核心部分：</p>
<pre><code class="language-go">func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
  // 获取当前 M
	mp := getg().m
top:

  // 获取 M 绑定的 P
	pp := mp.p.ptr()

	// 1. 每 61 次循环调度，就会去全局队列中获取一个 g 来执行
	if pp.schedtick%61 == 0 &amp;&amp; sched.runqsize &gt; 0 {
		lock(&amp;sched.lock)
		gp := globrunqget(pp, 1)
		unlock(&amp;sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}


	// 2. 从本地队列中获取 g
	if gp, inheritTime := runqget(pp); gp != nil {
		return gp, inheritTime, false
	}

	// 3. 从全局队列中获取 g
	if sched.runqsize != 0 {
		lock(&amp;sched.lock)
		gp := globrunqget(pp, 0)
		unlock(&amp;sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}

	// 4. 从网络轮询器中获取 g
	if netpollinited() &amp;&amp; netpollWaiters.Load() &gt; 0 &amp;&amp; sched.lastpoll.Load() != 0 {
		if list := netpoll(0); !list.empty() { // non-blocking
			gp := list.pop()
			injectglist(&amp;list)
			casgstatus(gp, _Gwaiting, _Grunnable)
			if traceEnabled() {
				traceGoUnpark(gp, 0)
			}
			return gp, false, false
		}
	}

	// 5. 自旋，从其他 P 窃取 g
  // mp.spinning 这个条件检查当前 M（操作系统线程）是否应该进入自旋状态。
  // 自旋状态意味着 M 会积极地寻找工作，而不是休眠。
  // 2*sched.nmspinning.Load() &lt; gomaxprocs-sched.npidle.Load()
  // 这个条件确保系统中自旋的 M 的数量不会超过一定比例。
  // 这是为了防止在低并发情况下过多的 CPU 使用。
	if mp.spinning || 2*sched.nmspinning.Load() &lt; gomaxprocs-sched.npidle.Load() {
		if !mp.spinning {
			mp.becomeSpinning()
		}

		gp, inheritTime, tnow, w, newWork := stealWork(now)
		if gp != nil {
			return gp, inheritTime, false
		}
		if newWork {
			goto top
		}

		now = tnow
		if w != 0 &amp;&amp; (pollUntil == 0 || w &lt; pollUntil) {
			pollUntil = w
		}
	}
  ...
	goto top
}
</code></pre>
<p>这个过程涉及到几个重要的函数：</p>
<ul>
<li><code>globrunqget()</code>：从全局队列中寻找可运行的 G。</li>
<li><code>runqget()</code>：从本地队列中寻找可运行的 G。</li>
<li><code>netpoll()</code>：寻找可以运行的网络协程。</li>
<li><code>stealWork()</code>：从其他 P 窃取可运行的 G。</li>
</ul>
<h4>4.3 globrunqget()</h4>
<pre><code class="language-go">func globrunqget(pp *p, max int32) *g {
  	// 抢全局列表的锁
    assertLockHeld(&amp;sched.lock)

  	// 如果为空则直接返回
    if sched.runqsize == 0 {
       return nil
    }

  	// 确定 n 的大小，即要从全局队列中获取的 g 的个数。
  	// 这里会结合入参 max 对边界值进行判断，以获得一个合理的 n。
  	// 一次性最多拿 len(pp.runq)/2 个 g。
    n := sched.runqsize/gomaxprocs + 1
    if n &gt; sched.runqsize {
       n = sched.runqsize
    }
    if max &gt; 0 &amp;&amp; n &gt; max {
       n = max
    }
    if n &gt; int32(len(pp.runq))/2 {
       n = int32(len(pp.runq)) / 2
    }

    sched.runqsize -= n

  	// 通过 pop() 从全局队列中弹出 g
    gp := sched.runq.pop()
    n--
    for ; n &gt; 0; n-- {
       gp1 := sched.runq.pop()
       // 将 g 放入 pp 的本地队列中
       // runqput 在前面创建协程的地方已经介绍过了，这里不赘述。
       runqput(pp, gp1, false)
    }
    return gp
}
</code></pre>
<h4>4.4 runqget()</h4>
<pre><code class="language-go">func runqget(pp *p) (gp *g, inheritTime bool) {
	// runnext 的 g 会优先执行
	next := pp.runnext
	if next != 0 &amp;&amp; pp.runnext.cas(next, 0) {
		return next.ptr(), true
	}

	for {
    // 原子操作获取队头指针
		h := atomic.LoadAcq(&amp;pp.runqhead)
		t := pp.runqtail
		if t == h {
			return nil, false
		}
    // 从队头获取 g，并通过原子操作更新队头（即抢这个 g）
		gp := pp.runq[h%uint32(len(pp.runq))].ptr()
		if atomic.CasRel(&amp;pp.runqhead, h, h+1) {
			return gp, false
		}
	}
}
</code></pre>
<p><code>runqget()</code> 函数用于从本地运行队列中获取一个可运行的 <code>goroutine</code>。这个函数只能由拥有该队列的处理器（P）执行。下面是对这个函数的详细解释：</p>
<p><strong>1. 检查 <code>runnext</code></strong>：</p>
<ul>
<li><code>runnext</code> 是一个特殊的字段，用于存储下一个要运行的 <code>goroutine</code>。如果 <code>runnext</code> 非零，并且能成功通过原子操作（CAS）将其设置为零，则直接返回这个 <code>goroutine</code>。</li>
<li>如果成功获取 <code>runnext</code> 指向的 <code>goroutine</code>，<code>inheritTime</code> 被设置为 <code>true</code>，表示这个 <code>goroutine</code> 应该继承当前时间片的剩余时间。</li>
<li>如果没成功，意味着 runnext 的这个 g 已经被其他 P 给抢了，因为我们可以发现本 P 只可能将其设置为 0，只有其他 P 才会将其设置以为非 0。</li>
</ul>
<p><strong>2. 从本地队列中获取 <code>goroutine</code></strong>：</p>
<ul>
<li>使用原子操作加载 <code>runqhead</code>（队列头指针），<code>runqtail</code>（队列尾指针）。</li>
<li>如果 <code>runqhead</code> 等于 <code>runqtail</code>，表示队列为空，返回 <code>nil</code>。</li>
<li>否则，从队列中获取 <code>runqhead</code> 指向的 <code>goroutine</code>，并尝试通过原子操作（CAS）更新 <code>runqhead</code>。</li>
<li>如果更新成功，返回获取到的 <code>goroutine</code>，<code>inheritTime</code> 被设置为 <code>false</code>，表示这个 <code>goroutine</code> 应该开始一个新的时间片。</li>
</ul>
<p>两个问题：</p>
<p><strong>1. 为什么获取 runqhead 需要上锁，获取 runqtail 就不需要？</strong></p>
<p><strong>单一生产者</strong>：每个本地运行队列只有一个生产者，即与之关联的当前 P。只有这个 P 可以向队列尾部添加新的 <code>goroutine</code>。由于不存在多个生产者的并发写入问题，因此不需要锁来保护队尾。</p>
<p><strong>2. inheritTime 有什么用？</strong></p>
<p><code>inheritTime</code> 的主要作用是决定新调度的 <code>goroutine</code> 是否应该立即开始一个新的时间片，或者继续使用当前时间片的剩余部分。这在以下两种情况下尤为重要：</p>
<ul>
<li><strong>继承时间片</strong> (<code>inheritTime == true</code>)：当 <code>runqget</code> 从 <code>runnext</code> 字段获取 <code>goroutine</code> 时，这个 <code>goroutine</code> 被认为是特别优先的，因此它继承了当前时间片的剩余时间。这通常发生在 <code>goroutine</code> 通过特定的同步机制（如通道操作）被明确唤醒时。</li>
<li><strong>开始新的时间片</strong> (<code>inheritTime == false</code>)：当 <code>runqget</code> 从本地运行队列中正常获取 <code>goroutine</code> 时，这个 <code>goroutine</code> 将开始一个全新的时间片。这确保了调度的公平性，使得每个 <code>goroutine</code> 都有机会在给定的时间片内运行。</li>
</ul>
<h4>4.5 netpoll()</h4>
<p><code>netpoll()</code> 函数是 Go 语言运行时网络轮询机制的一部分，用于检查网络连接是否准备好进行非阻塞 I/O 操作。这个函数返回一组已经变为可运行状态的 <code>goroutine</code>，这些 <code>goroutine</code> 之前可能因等待网络 I/O 而被挂起。</p>
<p>这里涉及到 Go 语言网络编程原理，在本文中不细究，就简单带过了。</p>
<pre><code class="language-go">func netpoll(delay int64) gList {
  // 检查轮询器是否初始化。
	if kq == -1 {
		return gList{}
	}
  // 设置轮询超时。
	var tp *timespec
	var ts timespec
	if delay &lt; 0 {
		tp = nil
	} else if delay == 0 {
		tp = &amp;ts
	} else {
		ts.setNsec(delay)
		if ts.tv_sec &gt; 1e6 {
			ts.tv_sec = 1e6
		}
		tp = &amp;ts
	}
  // 使用 kevent 进行轮询操作，结果放在 events 中。
	var events [64]keventt
retry:
	n := kevent(kq, nil, 0, &amp;events[0], int32(len(events)), tp)
	if n &lt; 0 {
		if n != -_EINTR {
			println(&quot;runtime: kevent on fd&quot;, kq, &quot;failed with&quot;, -n)
			throw(&quot;runtime: netpoll failed&quot;)
		}
		if delay &gt; 0 {
			return gList{}
		}
		goto retry
	}
  // 遍历 events 处理轮询事件。
	var toRun gList
	for i := 0; i &lt; int(n); i++ {
		ev := &amp;events[i]

    // netpollBreakRd 用于唤醒轮询，即唤醒等待中的 goroutine。
		if uintptr(ev.ident) == netpollBreakRd {
			if ev.filter != _EVFILT_READ {
				println(&quot;runtime: netpoll: break fd ready for&quot;, ev.filter)
				throw(&quot;runtime: netpoll: break fd ready for something unexpected&quot;)
			}
			if delay != 0 {
				var tmp [16]byte
				read(int32(netpollBreakRd), noescape(unsafe.Pointer(&amp;tmp[0])), int32(len(tmp)))
				netpollWakeSig.Store(0)
			}
			continue
		}

    // 根据轮询事件的类型（读或写），唤醒相应等待网络 I/O 的 groutine。
		var mode int32
		switch ev.filter {
		case _EVFILT_READ:
			mode += &#39;r&#39;
			if ev.flags&amp;_EV_EOF != 0 {
				mode += &#39;w&#39;
			}
		case _EVFILT_WRITE:
			mode += &#39;w&#39;
		}
		if mode != 0 {
			var pd *pollDesc
			var tag uintptr
			if goarch.PtrSize == 4 {
				pd = (*pollDesc)(unsafe.Pointer(ev.udata))
				tag = 0
			} else {
				tp := taggedPointer(uintptr(unsafe.Pointer(ev.udata)))
				pd = (*pollDesc)(tp.pointer())
				tag = tp.tag()
				if pd.fdseq.Load() != tag {
					continue
				}
			}
			pd.setEventErr(ev.flags == _EV_ERROR, tag)
      // 标记 goroutine 可执行。
			netpollready(&amp;toRun, pd, mode)
		}
	}

  // 返回可运行的 goroutine 列表。
	return toRun
}
</code></pre>
<h4>4.6 stealWork()</h4>
<p><code>stealWork()</code> 函数用于尝试从其他处理器（P）窃取可运行的 <code>goroutine</code> 或定时器。</p>
<pre><code class="language-go">func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {

  // 获取当前 M 绑定的 P。
	pp := getg().m.p.ptr()

	ranTimer := false

  // 尝试 4 次。
	const stealTries = 4
	for i := 0; i &lt; stealTries; i++ {
    // 前 3 次尝试窃取 g。
    // 第 4 次尝试窃取 timer，并且尝试获取其他 P 的 runnext 中的 g。
		stealTimersOrRunNextG := i == stealTries-1

    // 随机选一个 P。
		for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
			// 如果系统正在 GC，则可以直接返回 true，因为可能要负责 gc 了，有事干了。
      if sched.gcwaiting.Load() {
				return nil, false, now, pollUntil, true
			}

      // 获取选中的 P，如果是当前 P 则直接 continue，重试。
			p2 := allp[enum.position()]
			if pp == p2 {
				continue
			}

      // 第 4 次尝试去窃取 p2 的 timer。
			if stealTimersOrRunNextG &amp;&amp; timerpMask.read(enum.position()) {
        // 检查并可能执行 timer。
				tnow, w, ran := checkTimers(p2, now)
				now = tnow
				if w != 0 &amp;&amp; (pollUntil == 0 || w &lt; pollUntil) {
					pollUntil = w
				}
        // 如果执行了 timer，则检查本地队列是否有 g 可以运行，
        // 因为 timer 会唤醒被挂起的 g。
				if ran {
					if gp, inheritTime := runqget(pp); gp != nil {
						return gp, inheritTime, now, pollUntil, ranTimer
					}
					ranTimer = true
				}
			}

      // 前 3 次尝试或者第 4 次尝试没有窃取到 timer 的时候，
      // 就从其他非空闲 P 的本地队列中尝试窃取 g。
			if !idlepMask.read(enum.position()) {
        // 如果 stealTimersOrRunNextG 为 true，
        // 那么会在窃取的时候，尝试窃取 p2 的 runnext。
				if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
					return gp, false, now, pollUntil, ranTimer
				}
			}
		}
	}
	return nil, false, now, pollUntil, ranTimer
}
</code></pre>
<p>阅读源码的好处这就体现了，所有人都告诉我们 P 找不到可运行 G 的时候就会去窃取其他 P 的 G，但没人告诉我们，在这个过程<strong>还可能会去窃取其他 P 的 timer 和 runnext</strong>。</p>
<p>所谓 <code>timer</code>，即定时器，用于在指定的时间后执行某些操作。这些操作通常包括唤醒等待特定时间的 <code>goroutine</code>，或执行与时间相关的任务。定时器在 Go 的并发模型中扮演着重要的角色，特别是在涉及到时间延迟或周期性任务的场景中。在调度器层面，定时器的管理对于确保及时响应时间相关的事件和维持高效的调度至关重要。通过合理地处理定时器事件，Go 能够在保持高并发性的同时，有效地管理时间延迟和周期性任务。</p>
<p>在 Go 语言的调度器中，跨 P 的定时器窃取是一种优化机制，它有 2 个好处：</p>
<ul>
<li><strong>保持处理器活跃</strong>：当一个 P 没有足够的本地工作时，它可以尝试从其他 P 窃取定时器任务。这样做可以保持该 P 活跃，避免它进入休眠状态，从而提高整体系统的效率。</li>
<li><strong>平衡系统负载</strong>：在多核系统中，不同的 P 可能会有不同的负载。跨 P 的定时器窃取有助于在 P 之间平衡负载，特别是在一些 P 非常忙碌而其他 P 相对空闲的情况下。</li>
</ul>
<p>好的，回过头来，为什么我们会说窃取的时候会从队头窃取呢？为什么是窃取 p2 一半的 g 呢？这个过程就在 <code>runqsteal()</code> 中：</p>
<pre><code class="language-go">func runqsteal(pp, p2 *p, stealRunNextG bool) *g {
	t := pp.runqtail

  // 从 p2 中获取 n 个 g。
	n := runqgrab(p2, &amp;pp.runq, t, stealRunNextG)
	if n == 0 {
		return nil
	}

  // 返回第 1 个 g，因为它可以直接执行了。
	n--
	gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr()
	if n == 0 {
		return gp
	}
  // 如果还有剩下的 g，那么就加入到本地队列中。
  // 这里可以看到是从队头加入的，所以需要使用原子操作获取队头。
	h := atomic.LoadAcq(&amp;pp.runqhead)
	if t-h+n &gt;= uint32(len(pp.runq)) {
		throw(&quot;runqsteal: runq overflow&quot;)
	}
	atomic.StoreRel(&amp;pp.runqtail, t+n)
	return gp
}
</code></pre>
<p><code>runqgrab()</code> 是窃取 n 个 g 的过程：</p>
<pre><code class="language-go">func runqgrab(pp *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
  // 使用无限循环来尝试窃取工作，直到成功或确定没有可窃取的工作。
	for {
		h := atomic.LoadAcq(&amp;pp.runqhead)
		t := atomic.LoadAcq(&amp;pp.runqtail)
		n := t - h

    // 这里可以看到，要窃取的个数，就是 pp 本地队列中 g 个数的一半
		n = n - n/2

    // 如果 n 为 0，且 stealRunNextG == true，
    // 那么就尝试窃取 pp 的 runnext 中的 g。
		if n == 0 {
			if stealRunNextG {
				if next := pp.runnext; next != 0 {
					if !pp.runnext.cas(next, 0) {
						continue
					}
					batch[batchHead%uint32(len(batch))] = next
					return 1
				}
			}
			return 0
		}

    // 如果 n 不为队列长度的一半，则说明队列发生了变化，
    // 这个时候重新尝试窃取。
		if n &gt; uint32(len(pp.runq)/2) {
			continue
		}
    // 将要窃取的 g 从 pp.runq 中转移到 batch 中。
		for i := uint32(0); i &lt; n; i++ {
			g := pp.runq[(h+i)%uint32(len(pp.runq))]
			batch[(batchHead+i)%uint32(len(batch))] = g
		}
    // 使用原子操作尝试更新 pp 的队列头部，即将 g 从 pp.runq 中移除。
		if atomic.CasRel(&amp;pp.runqhead, h, h+n) {
			return n
		}
	}
}
</code></pre>
<p><code>findRunnable()</code> 的全部过程我们总算是梳理完了，这个过程确实非常精彩，Go 调度器在提高调度性能、确保调度的公平性、平衡系统负载、降低同步开销、减少资源再分配等方面都做了很多的努力，这才让 Go 语言的并发又强大又易用。</p>
<p>下面是对 <code>findRunnable()</code> 一个简单的总结：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240128212451142.png" alt="findRunnable()"></p>
<h4>4.7 execute()</h4>
<p><code>findRunnable()</code> 之后就是 <code>execute()</code>，它的核心过程如下（有删减）：</p>
<pre><code class="language-go">func execute(gp *g, inheritTime bool) {
	mp := getg().m
	// 将 g0 d 线程信息复制到即将要调用的协程 gp 中。
	mp.curg = gp
	gp.m = mp
  // 修改 gp 的状态为 _Grunning，即运行中。
	casgstatus(gp, _Grunnable, _Grunning)
	gp.waitsince = 0
  // 标记为非抢占
	gp.preempt = false
  // 用于栈保护，检测栈溢出
	gp.stackguard0 = gp.stack.lo + stackGuard
  // gogo 会完成 g0 到 g 的协程栈的切换，并从 gp.sched 开始执行。
  // sched 字段我们前面介绍过，它是 gobuf 结构体，存储了 sp 和 pc。
	gogo(&amp;gp.sched)
}
</code></pre>
<p>所以 <code>execute()</code> 的工作非常简单，其实就是将 g0 的线程信息复制到 gp 上，并修改状态和一些元数据，核心部分其实在 <code>gogo()</code> 中。</p>
<h4>4.8 gogo()</h4>
<p>前面我们说过，<code>gogo()</code> 会完成 g0 栈到 g 栈的切换，且在不同平台下有不同的视线，这里我们以 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/asm_arm64.s">asm_arm64.s</a> 为代表来看一下 <code>gogo()</code> 的汇编实现：</p>
<pre><code class="language-assembly">TEXT runtime·gogo(SB), NOSPLIT|NOFRAME, $0-8
	MOVD	buf+0(FP), R5
	MOVD	gobuf_g(R5), R6
	MOVD	0(R6), R4	// make sure g != nil
	B	gogo&lt;&gt;(SB)

TEXT gogo&lt;&gt;(SB), NOSPLIT|NOFRAME, $0
	MOVD	R6, g
	BL	runtime·save_g(SB)

	MOVD	gobuf_sp(R5), R0
	MOVD	R0, RSP
	MOVD	gobuf_bp(R5), R29
	MOVD	gobuf_lr(R5), LR
	MOVD	gobuf_ret(R5), R0
	MOVD	gobuf_ctxt(R5), R26
	MOVD	$0, gobuf_sp(R5)
	MOVD	$0, gobuf_bp(R5)
	MOVD	$0, gobuf_ret(R5)
	MOVD	$0, gobuf_lr(R5)
	MOVD	$0, gobuf_ctxt(R5)
	CMP	ZR, ZR // set condition codes for == test, needed by stack split
	MOVD	gobuf_pc(R5), R6
	B	(R6)
</code></pre>
<p>具体过程如下：</p>
<ol>
<li><strong><code>runtime·gogo</code> 函数</strong>：这个函数用于设置新的 <code>goroutine</code> 上下文。它接收一个指向 <code>gobuf</code> 结构的指针（<code>buf+0(FP)</code>），该结构包含了 <code>goroutine</code> 的上下文信息。</li>
<li><strong>加载 <code>gobuf</code> 并检查 <code>g</code></strong>：加载 <code>gobuf</code> 结构，并检查 <code>g</code> 是否为 <code>nil</code>。</li>
<li><strong>跳转到 <code>gogo&lt;&gt;</code></strong>：执行无条件跳转到 <code>gogo&lt;&gt;</code> 函数。</li>
<li><strong><code>gogo&lt;&gt;</code> 函数</strong>：这个函数实际上完成了上下文切换。<ul>
<li>设置当前 <code>goroutine</code>：将 <code>R6</code> 寄存器中的值（新的 <code>goroutine</code>）设置为当前 <code>goroutine</code>。</li>
<li>保存当前 <code>goroutine</code>：调用 <code>runtime·save_g</code> 保存当前 <code>goroutine</code> 的状态。</li>
<li>恢复栈指针和其他寄存器：从 <code>gobuf</code> 结构中恢复栈指针（<code>RSP</code>）、基指针（<code>R29</code>）、链接寄存器（<code>LR</code>）、返回值（<code>R0</code>）和上下文（<code>R26</code>）。</li>
<li>清空 <code>gobuf</code> 结构：将 <code>gobuf</code> 结构中的字段清零。</li>
<li>准备跳转到新的程序计数器位置：从 <code>gobuf</code> 中加载新的程序计数器地址（<code>gobuf_pc(R5)</code>）到 <code>R6</code>。</li>
<li>跳转执行：通过 <code>B (R6)</code> 跳转到新的程序计数器地址，继续执行新 <code>goroutine</code> 的代码。</li>
</ul>
</li>
</ol>
<p>这段汇编代码是 Go 运行时中处理 <code>goroutine</code> 上下文切换的关键部分。它直接操作处理器的寄存器和栈，以实现从一个 <code>goroutine</code> 切换到另一个 <code>goroutine</code> 的功能。</p>
<p>在 <code>execute()</code> 中是这么调用 <code>gogo()</code> 的：</p>
<pre><code class="language-go">gogo(&amp;gp.sched)
</code></pre>
<p>所以完成栈的切换后会从 <code>gp.sched</code> 开始，执行代码，前面我们介绍过 <code>sched</code> 是一个 <code>gobuf</code> 结构体：</p>
<pre><code class="language-go">type gobuf struct {
	sp   uintptr
	pc   uintptr
	g    guintptr
	ctxt unsafe.Pointer
	ret  uintptr
	lr   uintptr
	bp   uintptr
}
</code></pre>
<p>所以会从 pc 处开始执行业务代码，前面在 <code>newproc()</code> 的时候，我们提过一行代码：</p>
<pre><code class="language-go">newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum
</code></pre>
<p>这行代码的作用，是在协程创建的时候插入一个 <code>goexit</code> 函数的地址，因为这个时候 <code>g</code> 刚创建，所以其实就是往协程栈顶插入了 <code>goexit</code> 的地址。所以当 <code>g</code> 执行完业务代码后，当栈中元素不断弹出后，最终就会弹出 <code>goexit</code> 的地址，然后执行 <code>goexit()</code> 函数，退出当前 <code>g</code>，切换回 <code>g0</code>。</p>
<h4>4.9 goexit()</h4>
<p><code>goexit</code> 定义在 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/stubs.go">runtime/stubs.go</a> 中：</p>
<pre><code class="language-go">// goexit is the return stub at the top of every goroutine call stack.
// Each goroutine stack is constructed as if goexit called the
// goroutine&#39;s entry point function, so that when the entry point
// function returns, it will return to goexit, which will call goexit1
// to perform the actual exit.
//
// This function must never be called directly. Call goexit1 instead.
// gentraceback assumes that goexit terminates the stack. A direct
// call on the stack will cause gentraceback to stop walking the stack
// prematurely and if there is leftover state it may panic.
func goexit(neverCallThisFunction)
</code></pre>
<p>通过注释我们可以得到 2 个信息：</p>
<ul>
<li><code>goexit</code> 的位于每个 goroutine 调用栈的顶部。每个 goroutine 的栈被构造得好像 <code>goexit</code> 调用了 goroutine 的入口函数。这意味着当入口函数返回时，它实际上返回到 <code>goexit</code>。</li>
<li>不要直接调用 <code>goexit</code>，应该调用 <code>goexit1</code>。</li>
</ul>
<p><code>goexit1</code> 位于 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/proc.go">runtime/proc.go</a> 中：</p>
<pre><code class="language-go">// Finishes execution of the current goroutine.
func goexit1() {
	if raceenabled {
		racegoend()
	}
	if traceEnabled() {
		traceGoEnd()
	}
	mcall(goexit0)
}
</code></pre>
<p>好吧，它调用了 <code>goexit0</code>，原来这才是真正的退出入口，它也位于 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/proc.go">runtime/proc.go</a> 中：</p>
<pre><code class="language-go">func goexit0(gp *g) {
  // 获取当前的 M 和 P
	mp := getg().m
	pp := mp.p.ptr()

  // 修改 gp 的状态为 _Gdead，标志它的终止
	casgstatus(gp, _Grunning, _Gdead)
  // 标记 gc 的栈内存是可以进行 gc 扫描的
	gcController.addScannableStack(pp, -int64(gp.stack.hi-gp.stack.lo))
	// 如果 gp 是系统 goroutine，则将系统 goroutine 的计数减少
  if isSystemGoroutine(gp, false) {
		sched.ngsys.Add(-1)
	}
  // 清理 gp 的状态
	gp.m = nil
	locked := gp.lockedm != 0
	gp.lockedm = 0
	mp.lockedg = 0
	gp.preemptStop = false
	gp.paniconfault = false
	gp._defer = nil
	gp._panic = nil
	gp.writebuf = nil
	gp.waitreason = waitReasonZero
	gp.param = nil
	gp.labels = nil
	gp.timer = nil

  // 如果启用了垃圾回收（GC）并且 gp.gcAssistBytes 大于 0，
  // 则将辅助信用归还给全局池。这有助于更好地控制垃圾回收进程。
	if gcBlackenEnabled != 0 &amp;&amp; gp.gcAssistBytes &gt; 0 {
		assistWorkPerByte := gcController.assistWorkPerByte.Load()
		scanCredit := int64(assistWorkPerByte * float64(gp.gcAssistBytes))
		gcController.bgScanCredit.Add(scanCredit)
		gp.gcAssistBytes = 0
	}

  // 将当前 g 从 P 的运行队列中移除
	dropg()

  // WebAssembly 平台特殊处理
	if GOARCH == &quot;wasm&quot; {
		gfput(pp, gp)
		schedule()
	}

  // 将 gp 放回处理器的可用队列中，这样可以复用 g
	gfput(pp, gp)

  // 如果 goroutine 被绑定到当前线程上，
  // 那可能是在做系统调用，cgo 调用或其他特殊任务，
  // 那么就需要切到 g0，让 g0 来完成后面的调度。
  if locked {
		// 如果 goroutine 在终止前曾锁定当前线程，
    // 则根据不同的操作系统执行不同的处理。
    // 在大多数操作系统上，会跳转到 mstart 函数，释放 P 并退出线程。
    // 但在 Plan 9 操作系统上，会清除 lockedExt。
		if GOOS != &quot;plan9&quot; { // See golang.org/issue/22227.
			gogo(&amp;mp.g0.sched)
		} else {
			mp.lockedExt = 0
		}
	}

  // 继续调度
  // 如果执行了 gogo，那就是 g0 在调度。
  // 如果没有执行 gogo，那就是 gp 在调度。
	schedule()
}
</code></pre>
<p>到这里我们就完成了对 GPM 调度循环的全过程源码分析了，你可以回到 [4. 调度过程 schedule()](##4. 调度过程 schedule()) 看一下我总结的那张图，这回你应该会有更加深入的理解了。</p>
<h3>5. 协程切换</h3>
<p>如果要一个协程要一直到执行完毕才退出的话，那很可能会造成其他协程饥饿的问题。所以 Go 其实会在一些特殊的时机对协程进行切换，这个过程有抢占式调度，也有协作式的调度。</p>
<p>协程切换的时候，最核心的就是要保存当前协程的现场，以方便回到该协程的时候继续执行剩下的内容。大致过程如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h57vi6wrdqj21ho0sqjtn.jpg" alt="Go 协程切换"></p>
<p>有哪些时机会触发切换呢，这里我直接给出结论：</p>
<p>基于协作的抢占式调度</p>
<ul>
<li>主动挂起：<code>runtime.gopark()</code></li>
<li>系统调用结束时：<code>exitsyscall()</code></li>
<li>函数跳转时：<code>morestack()</code></li>
</ul>
<p>基于信号的抢占式调度</p>
<ul>
<li>信号调度：<code>doSigPreempt()</code></li>
</ul>
<h4>5.1 主动挂起 runtime.gopack()</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h57vq4flf5j21j20s4jto.jpg" alt="gopack 协程切换"></p>
<p>这个函数位于 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/proc.go">runtime/proc.go</a> 中：</p>
<pre><code class="language-go">func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) {
	if reason != waitReasonSleep {
		checkTimeouts()
	}
	mp := acquirem()
	gp := mp.curg
	status := readgstatus(gp)
	if status != _Grunning &amp;&amp; status != _Gscanrunning {
		throw(&quot;gopark: bad g status&quot;)
	}
	mp.waitlock = lock
	mp.waitunlockf = unlockf
	gp.waitreason = reason
	mp.waitTraceBlockReason = traceReason
	mp.waitTraceSkip = traceskip
	releasem(mp)
	mcall(park_m)
}
</code></pre>
<p><code>gopark</code> 函数的主要目的是使 G 进入休眠状态，等待被唤醒。</p>
<p>最后它调用了 <code>mcall()</code>：</p>
<pre><code class="language-go">// mcall switches from the g to the g0 stack and invokes fn(g)
// mcall 切换到 g0，并执行 fn。
func mcall(fn func(*g))
</code></pre>
<p>所以这里切换回 <code>g0</code>，并执行了 <code>pack_m</code>：</p>
<pre><code class="language-go">func park_m(gp *g) {
	...
	schedule()
}
</code></pre>
<p><code>pack_m()</code> 其实就是调用了 <code>schedule()</code> 去进行下一轮调度，这就完成了协程的切换。</p>
<p>当协程被阻塞的时候，就会去调用 <code>runtime.gopark()</code> 主动让出 CPU，切回 <code>g0</code>，等待被唤醒，以此保证最大化利用 CPU 资源。比如以下几种情况：</p>
<ul>
<li>休眠</li>
<li>channel 通道阻塞</li>
<li>网络 I/O 阻塞</li>
<li>因为执行垃圾回收而暂停</li>
</ul>
<h4>5.2 系统调用结束时 exitsyscall()</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/e6c9d24egy1h57vx0tfayj21g60r4tb4.jpg" alt="exitsyscall 协程切换"></p>
<p>Go 通过 <code>entersyscall()</code> 进行系统调用，完事后会执行 <code>exitsyscall()</code>，它也位于 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/proc.go">runtime/proc.go</a> 中：</p>
<pre><code class="language-go">func exitsyscall() {
	...
	mcall(exitsyscall0)
}
</code></pre>
<p>其实它最终也是调用 <code>mcall()</code> 切换到 <code>g0</code>， 我们不难猜出，它这里让 <code>g0</code> 去执行 <code>exitsyscall0</code> 函数，做完系统调用的善后后，肯定还是会执行 <code>schedule()</code> 函数进行协程调度。</p>
<pre><code class="language-go">func exitsyscall0(gp *g) {
	...
	schedule()
}
</code></pre>
<h4>5.3 函数跳转时 morestack()</h4>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h57w74gg19j21iq0smwgs.jpg" alt="morestack 协程切换"></p>
<p>因为函数跳转意味着“压栈”，函数跳转时都会调用这个方法，它的本意在于检查当前协程栈空间是否有足够内存，如果不够就要扩大该栈空间。</p>
<p>为了让每个协程都有执行的机会，并且最大化利用 CPU 资源，Go 语言在初始化时会启动一个特殊的线程来执行系统监控任务，系统监控在一个独立的 M 上运行，不用绑定逻辑处理器 P。当系统监控到协程运行超过 <code>10ms</code>，就将 <code>g.stackguard0</code> 置为 <code>stackPreempt</code>（该值是一个抢占标志）。</p>
<pre><code class="language-go">const forcePreemptNS = 10 * 1000 * 1000 // 10ms

func retake(now int64) uint32 {
	// 遍历所有的 P
	for i := 0; i &lt; len(allp); i++ {
		pp := allp[i]
		pd := &amp;pp.sysmontick
		s := pp.status
		sysretake := false
		if s == _Prunning || s == _Psyscall {
			t := int64(pp.schedtick)
			if int64(pd.schedtick) != t {
				pd.schedtick = uint32(t)
				pd.schedwhen = now
			} else if pd.schedwhen+forcePreemptNS &lt;= now {
        // 如果 G 运行时间过长，超过了 forcePreemptNS(10ms)，
        // 则标记抢占
				preemptone(pp)
				sysretake = true
			}
		}
		if s == _Psyscall {
      // 如果是系统调用，且已经超过了一个系统监控的 tick(20us)，
      // 则从系统调用中抢占 p。
      t := int64(pp.syscalltick)
			if !sysretake &amp;&amp; int64(pd.syscalltick) != t {
				pd.syscalltick = uint32(t)
				pd.syscallwhen = now
				continue
			}
			...
		}
	}
}

// 标记抢占
func preemptone(pp *p) bool {
	mp := pp.m.ptr()
	gp := mp.curg
	gp.preempt = true
	gp.stackguard0 = stackPreempt
	return true
}
</code></pre>
<p>巧的就是，Go 设计者，让程序在执行 <code>morestack()</code> 函数时顺便判断一下 <code>g</code> 中的 <code>stackguard</code> 是否已经被置为抢占 <code>stackPreempt</code>，如果的确被标记抢占，就回到 <code>schedule()</code> 方法，并将当前协程放回队列中。</p>
<p><code>morestack</code> 是汇编实现：</p>
<pre><code class="language-assembly">TEXT runtime·morestack(SB),NOSPLIT|NOFRAME,$0-0
	...
	BL	runtime·newstack(SB)
</code></pre>
<p>它最终会调用 <code>newstack()</code>：</p>
<pre><code class="language-go">func newstack() {
  thisg := getg()
  gp := thisg.m.curg
  // 1. 判断 gp.stackguard0 是否被标记为抢占
  stackguard0 := atomic.Loaduintptr(&amp;gp.stackguard0)
	preempt := stackguard0 == stackPreempt
  // 2. 如果被标记位抢占，调用 gopreempt_m()
  if preempt {
		// 3. 最终会去调用  schedule() 去调新的协程执行
		gopreempt_m(gp) // never return
	}
}

func gopreempt_m(gp *g) {
	if traceEnabled() {
		traceGoPreempt()
	}
	goschedImpl(gp)
}

func goschedImpl(gp *g) {
	...
	schedule()
}
</code></pre>
<h4>5.4 信号调度 doSigPreempt()</h4>
<p>当程序在执行过程中既无法主动挂起，也不能进行系统调用，且无法进行函数调用时，就可以使用信号来调度。</p>
<p>信号其实就是线程信号，在操作系统中有很多基于信号的底层通信方式（SIGPIPE / SIGURG / SIGHUP），而我们的线程可以注册对应信号的处理函数。</p>
<p>Go 中是注册了 <code>SIGURG</code> 信号的处理函数 <code>doSigPreempt()</code>，在 GC 工作时，向目标线程发送信号。线程收到信号后，会触发调度。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h57wecvgwdj21hk0ssgny.jpg" alt="doSigPreempt 协程切换"></p>
<p><code>doSigPreempt</code> 位于 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/signal_unix.go">runtime.signal_unix.go</a> 中：</p>
<pre><code class="language-go">func doSigPreempt(gp *g, ctxt *sigctxt) {
	// 检查此 g 是否要被抢占并且安全抢占
	if wantAsyncPreempt(gp) {
		if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
			// 2. 调整程序计数器 PC 并异步调用 asyncPreempt
			ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
		}
	}
}
</code></pre>
<p>其中 <code>asyncPreempt</code> 的实现如下：</p>
<pre><code class="language-go">func asyncPreempt()

//
func asyncPreempt2() {
	gp := getg()
	gp.asyncSafePoint = true
  //
	if gp.preemptStop {
		mcall(preemptPark)
	} else {
		mcall(gopreempt_m)
	}
	gp.asyncSafePoint = false
}
</code></pre>
<p><code>asyncPreempt</code> 是汇编实现，最终是调的 <code>asyncPreempt2</code>，它会调用 <code>mcall</code> 切回 <code>g0</code>，并执行 <code>preemptPark</code> 或 <code>gopreempt_m</code>， <code>gopreempt_m</code> 就是前面 <code>morestack</code> 最后调的！不出意外，<code>preemptPack</code> 最后肯定还是调的 <code>schedule()</code>。</p>
<pre><code class="language-go">func preemptPark(gp *g) {
	...
	schedule()
}
</code></pre>
<h4>5.5 runtime.Gosched()</h4>
<p>在我们实际编程中，你可以通过显式调用 <code>runtime.Gosched()</code> 来主动让出 CPU，促进 Go 的下一轮调度，我们来看它的具体实现，肯定还是调的 <code>schedule()</code>，没有意外！</p>
<pre><code class="language-go">func Gosched() {
	checkTimeouts()
	mcall(gosched_m)
}

func gosched_m(gp *g) {
	if traceEnabled() {
		traceGoSched()
	}
	goschedImpl(gp)
}

func goschedImpl(gp *g) {
	status := readgstatus(gp)
	if status&amp;^_Gscan != _Grunning {
		dumpgstatus(gp)
		throw(&quot;bad g status&quot;)
	}
	casgstatus(gp, _Grunning, _Grunnable)
	dropg()
	lock(&amp;sched.lock)
	globrunqput(gp)
	unlock(&amp;sched.lock)

	schedule()
}
</code></pre>
<h4>5.6 总结</h4>
<p>Go 语言为了确保 P 不会因为 G 运行时间过长或系统调用阻塞时间过长而导致性能下降。它会尝试进行协程切换，以确保任务可以适时地被分配和执行。这有助于保持 Go 程序的并发性能和响应性。而协程切换的方式有基于协作的抢占式调度（主动挂起 <code>runtime.gopark()</code>，系统调用结束时<code>exitsyscall()</code>，函数跳转时 <code>morestack()</code>），也有基于信号的抢占式调度 <code>doSigPreempt()</code>，他们都无一例外的最终调用了 <code>schedule()</code>。</p>
<p>所以总结下来其实还是这张图：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/e6c9d24egy1h57vi6wrdqj21ho0sqjtn.jpg" alt="Go 协程切换"></p>
<h2>G、P、M 状态流转</h2>
<p>经过我们前面的分析，你可以自行整理 G、P、M 状态的流转，这里我给出几张图供你参考：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240126224853429.png" alt="Go 协程（G）状态转换图"></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240126225021035.png" alt="Go 处理器（P）状态转换图"></p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240126225849169.png" alt="Go M（操作系统线程）状态转换"></p>
<h2>总结</h2>
<p>以上便是对 Go 语言 GPM 模型的全部分享啦！GPM 模型使得 Go 语言能够并发执行成千上万个协程。</p>
<p>为了减少线程“相对昂贵”的切换代价，Go 引入了 GPM，将大量的 Goroutine 分配到少量的系统线程上去执行，并利用多核并行，实现更强大的并发。</p>
<p>为了减小并发冲突，Go 在全局队列的基础上引入了本地队列。</p>
<p>为了避免协程饥饿，Go 又引入了多种协程调度的策略。</p>
<p>为了避免协程阻塞浪费 CPU，Go 引入了多种协程切换的方式。</p>
<p>Go 语言设计者进行了如此复杂的调度器实现，最终交付给 Gopher 的，仅仅是一个 <code>go</code> 关键字这么简单，真的是大道至简，这也是充分印证了那句话：“Go 为并发而生”。</p>
<p>希望本文能对你有所帮助，enjoy~ happy coding~</p>
<h2>参考</h2>
<ul>
<li><a href="https://book.douban.com/subject/36403287/">深入理解 Go 语言</a></li>
<li><a href="https://book.douban.com/subject/35556889/">Go 语言底层原理剖析</a></li>
<li><a href="https://coding.imooc.com/class/576.html">深入 Go 底层原理</a></li>
<li>ChatGPT4</li>
</ul>
<h2>作图工具</h2>
<ul>
<li><a href="https://excalidraw.com/">excalidraw</a></li>
<li><a href="https://whimsical.com/">whimsical</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Rust 实战丨绘制曼德博集</title>
      <link>https://hedon.top/blog/rust-action-mandelbrot/</link>
      <guid isPermaLink="true">https://hedon.top/blog/rust-action-mandelbrot/</guid>
      <pubDate>Wed, 17 Jan 2024 22:39:05 GMT</pubDate>
      <description>本文参考《Rust 程序设计（第二版）》中第二章的示例，与读者分享曼德博集的绘制过程。</description>
      <category>rust</category><category>Rust 实战</category>
      <content:encoded><![CDATA[<h2>曼德博集</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/2560px-Mandelset_hires-20240118170914026.png" alt="曼德博集"></p>
<p>曼德博集其实是一个“没什么用”的发现。</p>
<p>曼德博集（Mandelbrot Set）是一种在复平面上形成独特且复杂图案的点的集合。这个集合是以数学家本华·曼德博（Benoit Mandelbrot）的名字命名的，他在研究复杂结构和混沌理论时发现了这个集合。曼德博集是分形几何的一个经典例子，显示了一个简单的数学公式如何能产生无限复杂和美丽的图案。</p>
<p>曼德博集的定义相对简单。对于每一个复数 $c$，我们考虑以下迭代序列：</p>
<p>$$
Z_{n+1} = z_n^2 + c ;;\ 其中 ;; (z_0 = 0)
$$</p>
<p><strong>曼德博集合由那些使得上述序列不趋于无限大的复数 $c$ 组成</strong>。在复平面上，这些点形成了一种独特的图案，通常以一种美丽且艺术的方式呈现。这个图案的边界非常复杂，包含了无限的细节和自相似的结构。这意味着无论你放大图案的哪一部分，你都会发现越来越精细的结构，这些结构在形式上与整体图案相似。</p>
<p>曼德博集合不仅在数学上有意义，也在艺术和科学中有广泛的应用，尤其是在研究混沌理论和复杂系统时。</p>
<p>具体可以看</p>
<ul>
<li><a href="https://zh.wikipedia.org/wiki/%E6%9B%BC%E5%BE%B7%E5%8D%9A%E9%9B%86%E5%90%88">维基百科-曼德博集</a></li>
<li><a href="https://www.bilibili.com/video/BV1kA411T7at">bilibili - 2000 亿倍放大曼德博集</a></li>
</ul>
<h2>目标功能</h2>
<p>最终我们将实现一个命令行工具，它会根据我们输入的参数生成曼德博集图，使用如下：</p>
<pre><code class="language-bash">./mandelbrot &lt;FILE&gt; &lt;PIXELS&gt; &lt;UPPERLEFT&gt; &lt;LOWERRIGHT&gt;
</code></pre>
<ul>
<li><code>FILE</code>: 曼德博集图生成的图片路经。</li>
<li><code>PIXELS</code>: 图片分辨率，如 <code>4x3</code>。</li>
<li><code>UPPERLEFT</code>: 指定在复平面中图片覆盖的左上角，如 <code>4.0,3.0</code>。</li>
<li><code>LOWERRIGHT</code>: 制定在复平面中图片覆盖的右下角。</li>
</ul>
<p>所以我们最终会根据指定的图片范围，截取 <code>PIXELS</code> 分辨率大小的曼德博集图：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240118171322832.png" alt="截取曼德博集示意图"></p>
<p>基于以上目标，我们拆分成几个问题：</p>
<ol>
<li>如何表示复数？</li>
<li>如何解析分辨率和坐标？</li>
<li>如何将图上像素映射到复数？</li>
<li>如何生成曼德博集图？即如何找到那些符合曼德博集的点，并将其进行着色标注？</li>
<li>如何写入图片文件？</li>
<li>如何渲染曼德博集？</li>
<li>如何解析命令行参数？</li>
<li>如何并发写入图片文件？</li>
</ol>
<h2>能学到什么</h2>
<ol>
<li>曼德博集是什么？</li>
<li>Rust 中的复数的原理与应用。</li>
<li>Rust 泛型初探。</li>
<li>Rust 中的 Option 和 Result 初探。</li>
<li>Rust 并发初探。</li>
<li>Rust 中如何解析命令行参数？</li>
<li>Rust 如何写入图像文件？</li>
<li>Rust 如何写测试用例？</li>
<li>Rust 实用 crate <code>num</code>、<code>image</code>、<code>crossbeam</code>。</li>
</ol>
<h2>版本</h2>
<pre><code class="language-toml">[package]
name = &quot;mandelbrot&quot;
version = &quot;0.1.0&quot;
edition = &quot;2021&quot;

[dependencies]
image = {version = &quot;0.13.0&quot;, features = [&quot;default&quot;, &quot;png&quot;]}
num = &quot;0.4.1&quot;
crossbeam = &quot;0.8&quot;
rayon = &quot;1.10.0&quot;
</code></pre>
<p>完整代码：<a href="https://github.com/hedon954/mandelbrot/blob/master/src/main.rs">https://github.com/hedon954/mandelbrot/blob/master/src/main.rs</a></p>
<h2>编码实现</h2>
<h3>0. 创建项目</h3>
<pre><code class="language-bash">cargo new mandelbrot
</code></pre>
<pre><code class="language-bash">cd mandelbrot
</code></pre>
<h3>1. 复数表示</h3>
<p>使用复数，我们需要引入一个 crete：<code>num</code>：</p>
<pre><code class="language-bash">cargo add num
</code></pre>
<p>其中定义了一个复数类型 <code>Complex</code>：</p>
<pre><code class="language-rust">pub struct Complex&lt;T&gt; {
    /// 复数的实部
    pub re: T,
    /// 复数的虚部
    pub im: T,
}
</code></pre>
<p>这里 <code>T</code> 是 Rust 中的泛型功能，表示任意类型 <code>T</code>，确定好这个结构体的 <code>T</code> 的类型后，其中的属性 <code>re</code> 和 <code>im</code> 的类型也就随之确定了。</p>
<h3>2. 解析分辨率和坐标</h3>
<ul>
<li>分辨率格式为：4000x3000</li>
<li>坐标格式为：-1.0,2.0</li>
</ul>
<h4>2.1 解析数对</h4>
<p>我们要做的就是，将分辨率拆成 (4000,3000)，将坐标拆为 (-1.0, 2.0)。这里：</p>
<ul>
<li>带解析的元素 <code>s</code> 是一个字符串 <code>&amp;str</code>。</li>
<li>分隔符 <code>separator</code> 是一个字符 <code>char</code>。</li>
<li>返回值是一个元组 <code>(T, T)</code>，其中 <code>T</code> 这里可以是 u64/f32 等数字，它们都需要能从字符串转化而来，即 <code>&lt;T:FromStr&gt;</code>。</li>
<li>因为解析可能出错，所以我们使用 <code>Option</code> 来承载。</li>
</ul>
<pre><code class="language-rust">/// 把字符串 `s`（形如 `&quot;400×600&quot;` 或 ``&quot;1.0,0.5&quot;）解析成一个坐标对
///
/// 具体来说，`s` 应该具有&lt;left&gt;&lt;sep&gt;&lt;right&gt;的格式，其中&lt;sep&gt;是由`separator`
/// 参数给出的字符，而&lt;left&gt;和&lt;right&gt;是可以被 `T:from_str` 解析的字符串。
/// `separator` 必须是 ASCII 字符
///
/// 如果 `s` 具有正确的格式，就返回 `Some(x,y)`，否则返回 `None`
fn parse_pair&lt;T: FromStr&gt;(s: &amp;str, separator: char) -&gt; Option&lt;(T, T)&gt; {
    match s.find(separator) {
        None =&gt; None,
        Some(index) =&gt; match (T::from_str(&amp;s[..index]), T::from_str(&amp;s[index + 1..])) {
            (Ok(l), Ok(r)) =&gt; Some((l, r)),
            _ =&gt; None,
        },
    }
}
</code></pre>
<p>我们可以写几个测试用例来验证一下这个函数的正确性，这里我们用到 <code>#[test]</code> 和 <code>assert_eq!</code>：</p>
<pre><code class="language-rust">#[test]
fn test_parse_pair() {
    assert_eq!(parse_pair::&lt;i32&gt;(&quot;&quot;, &#39;,&#39;), None);
    assert_eq!(parse_pair::&lt;i32&gt;(&quot;10,&quot;, &#39;,&#39;), None);
    assert_eq!(parse_pair::&lt;i32&gt;(&quot;,10&quot;, &#39;,&#39;), None);
    assert_eq!(parse_pair::&lt;i32&gt;(&quot;10,20&quot;, &#39;,&#39;), Some((10, 20)));
    assert_eq!(parse_pair::&lt;i32&gt;(&quot;10,20xy&quot;, &#39;,&#39;), None);
    assert_eq!(parse_pair::&lt;f64&gt;(&quot;0.5x&quot;, &#39;x&#39;), None);
    assert_eq!(parse_pair::&lt;f64&gt;(&quot;0.5x1.5&quot;, &#39;x&#39;), Some((0.5, 1.5)));
}
</code></pre>
<h4>2.2 转为复数</h4>
<p>我们需要的参数 <code>upper_left</code> 和 <code>lower_right</code> 都是复平面中的一个点，所以从字符串中将数对解析完毕后，我们将其赋值到复数的实部和虚部，转为复数实例。</p>
<pre><code class="language-rust">// 把一对用逗号隔开的浮点数解析为复数
fn parse_complex(s: &amp;str) -&gt; Option&lt;Complex&lt;f64&gt;&gt; {
    match parse_pair(s, &#39;,&#39;) {
        Some((re, im)) =&gt; Some(Complex { re, im }),
        None =&gt; None,
    }
}
</code></pre>
<h3>3. 将像素点映射成复数</h3>
<p>第 2 步我们其实确定了两件事：</p>
<ol>
<li>确定截取曼德博集的哪一部分。</li>
<li>要在这个部分中画多少个点。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240118174407537.png" alt="目标区域中的像素点"></p>
<p>这一步我们需要把 <code>x</code> 点转为复数，即确定它的横坐标和纵坐标。这部分可能需要发挥一下你的几何数学能力了（🤡🤡🤡）。</p>
<pre><code class="language-rust">/// 给定输出图像重像素的行和列，返回复平面中对应的坐标
///
/// `pixed` 是表示给图片中特定像素的 (column, row) 二元组。
/// `upper_left` 参数和 `lower_right` 参数是在复平面中表示指定图像覆盖范围的点。
fn pixed_to_point(
    /*
    ·--------------------&gt; bounds.0  re
    丨
    丨
    丨
    bounds.1  im
     */
    bounds: (usize, usize),
    pixed: (usize, usize),
    upper_left: Complex&lt;f64&gt;,
    lower_right: Complex&lt;f64&gt;,
) -&gt; Complex&lt;f64&gt; {
    let (width, height) = (
        lower_right.re - upper_left.re, // 右-左
        upper_left.im - lower_right.im, // 上-下
    );

    Complex {
        re: upper_left.re + pixed.0 as f64 * width / bounds.0 as f64,
        im: upper_left.im - pixed.1 as f64 * height / bounds.1 as f64,
    }
}

#[test]
fn test_pixed_to_point() {
    assert_eq!(
        pixed_to_point(
            (100, 200),
            (25, 175),
            Complex { re: -1.0, im: 1.0 },
            Complex { re: 1.0, im: -1.0 }
        ),
        Complex {
            re: -0.5,
            im: -0.75,
        }
    );
}
</code></pre>
<h3>4. 寻找曼德博集点</h3>
<p>什么是曼德博集点？看看上面的定义：<strong>曼德博集合由那些使得上述序列不趋于无限大的复数 $c$ 组成</strong>。</p>
<p>现在我们可以来表示上述的公式 $Z_{n+1} = z_n^2 + c$ 了：</p>
<pre><code class="language-rust">fn complex_square_add_loop(c: Complex&lt;f64&gt;) {
    let mut z = Complex { re: 0.0, im: 0.0 };
    loop {
        z = z * z + c
    }
}
</code></pre>
<p>其中我们将泛型结构体 <code>Complex</code> 的 <code>T</code> 确定为 <code>f64</code>，并使用 <code>loop</code> 关键字进行无限循环。</p>
<p>所以我们的目标是什么？<strong>找到令 <code>z</code> 不会“飞到”无穷远的 <code>c</code></strong>。</p>
<p>由于复数 $c$ 具有实部 re 和虚部 im，因此可以把它们视为笛卡尔平面上某个点的 x 坐标和 y 坐标，如果 $c$ 在曼德博集中，就在其中用黑色着色，否则就用浅色。因此，对于图像中的每个像素，必须在复平面上相应点位运行前面的循环，看看它是否逃逸到无穷远还是永远绕着原点运行，并相应将其着色。</p>
<p>无限循环肯定是不现实的，我们总要找到退出循环的机会，有 2 个思路：</p>
<ol>
<li>进行有限次数的迭代，这样可以获得该集合的一个不错的近似值，迭代的次数取决了精度的需要；</li>
<li>业界已证明，<strong>一旦 <code>z</code> 离开了以原点为中心的半径 2 的圆，它最终一定会“飞到”无穷远</strong>。</li>
</ol>
<p>所以我们最终确定的函数如下，其中 <code>norm_sqr()</code> 会返回 <code>z</code> 跟复平面原点的距离的平方：</p>
<pre><code class="language-rust">/// 尝试测试 `c` 是否位于曼德博集中，使用最多 `limit` 次迭代来判定
///
/// 如果 `c` 不是集合成员之一，则返回 `Some(i)`，其中 `i` 是 `c` 离开以原点
/// 为中心的半径为 2 的圆时所需的迭代次数。如果 `c` 似乎是集群成员之一（确
/// 切而言是达到了迭代次数限制但仍然无法证明 `c` 不是成员），则返回 `None`
fn escape_time(c: Complex&lt;f64&gt;, limit: usize) -&gt; Option&lt;usize&gt; {
    let mut z = Complex { re: 0.0, im: 0.0 };
    for i in 0..limit {
        if z.norm_sqr() &gt; 4.0 {
            return Some(i);
        }
        z = z * z + c
    }
    None
}
</code></pre>
<h3>5. 写入图片文件</h3>
<p>我们可以使用 <code>image</code> 这个 crate 来写入图片文件，它支持多种格式图片的读写，并内置了多种颜色色值。</p>
<p>这里我们准备生成 png 图片，且需要对图片进行不同颜色的着色，所以我们引入 <code>default</code> 和 <code>png</code> 这两个 feature。</p>
<pre><code class="language-bash">cargo add image --features default,png
</code></pre>
<h4>5.1 创建文件 File::create()</h4>
<p>我们可以用标准库中的 <code>File::create(filename)</code> 来创建一个文件，成功的话会返回一个文件句柄：</p>
<pre><code class="language-rust">let output = File::create(filename)?;
</code></pre>
<h4>5.2 写入图片 PNGEncoder</h4>
<p><code>image</code> 中提供了 <code>PNGEncoder</code> 用于写入 png 图片，它有两个核心方法：</p>
<pre><code class="language-rust">impl&lt;W: Write&gt; PNGEncoder&lt;W&gt; {
    /// Create a new encoder that writes its output to ```w```
    pub fn new(w: W) -&gt; PNGEncoder&lt;W&gt; {
        PNGEncoder {
            w: w
        }
    }

    /// Encodes the image ```image```
    /// that has dimensions ```width``` and ```height```
    /// and ```ColorType``` ```c```
    pub fn encode(self, data: &amp;[u8], width: u32, height: u32, color: ColorType) -&gt; io::Result&lt;()&gt; {
        let (ct, bits) = color.into();
        let mut encoder = png::Encoder::new(self.w, width, height);
        encoder.set(ct).set(bits);
        let mut writer = try!(encoder.write_header());
        writer.write_image_data(data).map_err(|e| e.into())
    }
}
</code></pre>
<ul>
<li><code>new(w)</code>: 传进目标 writer，即我们上面创建的 <code>output</code>。</li>
<li><code>encode()</code>: 写入图片信息，这里有几个参数：<ul>
<li><code>width: u32</code>: 图片宽度。</li>
<li><code>height: u32</code>: 图片高度。</li>
<li><code>color: ColorType</code>: 颜色类型，可以是 RGB, Gray(8) 等。</li>
<li><code>data: &amp;[u8]</code>: 像素色值列表，它的长度应该由上面 3 个字段共同决定，如果选取的颜色是 RGB，意味着需要 3 个 u8 才能表示一个像素点的颜色，所以长度为 width _ height _ 3，如果选取的颜色是 Gray(8)，那么我们用 1 个 u8 就可以表示一个像素点的灰度值，所以长度为 width _ height _ 1。本文中我们会采用 Gray(8) 来汇总曼德博集的黑白图。</li>
</ul>
</li>
</ul>
<h3>6. 渲染曼德博集</h3>
<p>这一步我们需要来确定上述 <code>PNGEncoder::encode()</code> 的 4 个参数：</p>
<ul>
<li><p><code>width: u32</code>: 图片宽度由命令行参数中指定即可。</p>
</li>
<li><p><code>height: u32</code>: 图片高度由命令行参数中指定即可。</p>
</li>
<li><p><code>color: ColorType</code>: 本文我们只绘制黑白图，这里使用 <code>ColorType::Gray(8)</code>，它表示图像是一个灰度（单色）图像，每个像素用 8 位（即 1 个字节）来表示。在这种格式中，每个像素的灰度值范围是 0 到 255，其中 0 通常表示黑色，255 表示白色，中间值表示不同的灰度。</p>
</li>
<li><p><code>data: &amp;[u8]</code>: 像素色值列表，我们需要确定 width * height 个像素的灰度值。</p>
<p>首先我们根据第 3 步将像素点映射成复数 $c$，然后使用第 4 步中的 <code>escape_time()</code> 函数来判断复数 $c$ 是否位于曼德博集中，如果是，则着黑色，即赋值 <code>0</code>，如果不是，则看它迭代了多少次才失败，次数越多，则越接近曼德博集，颜色越深，即越靠近 0，所以赋值 <code>255-time</code>。</p>
</li>
</ul>
<p>最终我们实现的函数如下：</p>
<pre><code class="language-rust">/// 将曼德博集对应的矩形渲染到像素缓冲区中
///
/// `bounds` 参数会给缓冲区 `pixels` 的宽度和高度，此缓冲区的每个字节都
/// 包含一个灰度像素。`upper_left` 和 `lower_right` 参数分别指定了
/// 复平面中对应于像素缓冲区左上角和右上角的点。
fn render(
    pixels: &amp;mut [u8],
    bounds: (usize, usize),
    upper_left: Complex&lt;f64&gt;,
    lower_right: Complex&lt;f64&gt;,
) {
    assert_eq!(pixels.len(), bounds.0 * bounds.1);

    for raw in 0..bounds.1 {
        for column in 0..bounds.0 {
            let point = pixed_to_point(bounds, (column, raw), upper_left, lower_right);
            pixels[raw * bounds.0 + column] = match escape_time(point, 255) {
                None =&gt; 0,
                Some(count) =&gt; 255 - count as u8,
            }
        }
    }
}
</code></pre>
<h3>7. 解析命令行参数</h3>
<p>核心逻辑部分到这里其实就完成了，现在我们要做最后一步，就是解析命令行参数，让程序可以根据我们的要求绘制曼德博集图。</p>
<h4>7.1 解析 std::env::args()</h4>
<p>在 Rust 中解析命令行参数的一个常用方法是使用<code>std::env::args</code>函数，这个函数返回一个迭代器，它包含了命令行上传递给程序的所有参数。对于更复杂的命令行参数解析，可以使用像<code>clap</code>或<code>structopt</code>这样的第三方库，这些库提供了更高级的功能和更好的错误处理。</p>
<p>下面是一个使用<code>std::env::args</code>的基本例子：</p>
<pre><code class="language-rust">use std::env;

fn main() {
    let args: Vec&lt;String&gt; = env::args().collect();

    for arg in args.iter() {
        println!(&quot;{}&quot;, arg);
    }
}
</code></pre>
<h4>7.2 基础版程序</h4>
<p>到这里，我们就可以实现完整的基础版程序了。</p>
<pre><code class="language-rust">fn main() {
  	// 读取参数
    let args: Vec&lt;String&gt; = env::args().collect();
  	// 参数个数 = 1 + 4，其中第 1 个是应用程序名
    if args.len() != 5 {
        eprintln!(&quot;Usage: {} FILE PIXELS UPPERLEFT LOWERRIGHT&quot;, args[0]);
        eprintln!(
            &quot;Example: {} mandel.png 1000x700 -1.20,0.35 -1,0.20&quot;,
            args[0]
        );
        std::process::exit(1);
    }
		// 解析参数
    let bounds = parse_pair(&amp;args[2], &#39;x&#39;).expect(&quot;error parsing image dimensions&quot;);
    let upper_left = parse_complex(&amp;args[3]).expect(&quot;error parsing upper left corner point&quot;);
    let lower_right = parse_complex(&amp;args[4]).expect(&quot;error parsing lower right corner point&quot;);
    let mut pixels = vec![0; bounds.0 * bounds.1];
    // 渲染曼德博集
    render(&amp;mut pixels, bounds, upper_left, lower_right);
  	// 输出图片
    write_image(&amp;args[1], &amp;pixels, bounds).expect(&quot;error writing PNG file&quot;);
}
</code></pre>
<p>我们在项目根目录下编译一下程序：</p>
<pre><code class="language-bash">cargo build --release
</code></pre>
<p>会在 target/release 下生成可执行文件，执行：</p>
<pre><code class="language-bash">./target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35 -1,0.20
</code></pre>
<p>执行后你应该可以看到我们生成的曼德博集图如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/mandel.png" alt="程序生成的曼德博集"></p>
<p>大概是处于这个位置：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20240118191624303.png" alt="程序截取的局部曼德博集处于整个曼德博集中的位置"></p>
<h3>8. 并发渲染</h3>
<p>在 macOS 或 linux 系统下，我们可以使用 <code>time</code> 来输出程序的执行时间：</p>
<pre><code class="language-txt">time ./target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35 -1,0.20
./target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35 -1,0.20  3.30s user 0.01s system 98% cpu 3.341 total
</code></pre>
<p>笔者使用的电脑为 macbook Pro m2 max 芯片 32 G 内存 12 核，可以看到在单核模式下，差不多需要 3~4s 的时间。</p>
<p>几乎所有的现代机器都有多个处理器核心，而当前这个程序只使用了一个。如果可以把此工作分派个机器提供的多个处理器核心，则应该可以更快地画完图像。</p>
<p>为此，我们可以将图像划分成多个部分，每个处理器负责其中的一个部分，并让每个处理器为分派给它的像素着色。为简单起见，可以将其分成一些水平条带，如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/CleanShot%202024-01-18%20at%2021.28.20.jpg" alt="将像素缓冲区划分为一些条带以进行并发渲染"></p>
<p>crossbeam 是 Rust 中的一个并发编程工具箱，它广泛用于提供各种并发和多线程编程的组件。</p>
<p><code>crossbeam::scope</code> 是 crossbeam 提供的一个非常有用的功能，它允许你安全地创建临时的线程，并确保这些线程在离开作用域之前结束。</p>
<p>这里我们引入 crossbeam：</p>
<pre><code class="language-bash">cargo add crossbeam
</code></pre>
<p>我们将 <code>fn main()</code> 中的：</p>
<pre><code class="language-rust">render(&amp;mut pixels, bounds, upper_left, lower_right);
</code></pre>
<p>替换成：</p>
<pre><code class="language-rust">// 使用 8 个线程来并发执行
let threads = 8;
// 计算每个线程负责渲染的高度，向上取整
let rows_per_band = bounds.1 / threads + 1;
{
  	// chunks_mut() 会返回一个迭代器，该迭代器会生成此缓冲区的可变且不可迭代的切片
    let bands: Vec&lt;&amp;mut [u8]&gt; = pixels.chunks_mut(rows_per_band * bounds.0).collect();
  	// crossbeam::scope 确保所有子线程在作用域结束之前完成，
  	// 这防止了悬垂指针和其他数据竞争问题。
    crossbeam::scope(|spawner| {
      	// 遍历像素缓冲区的各个条带，
      	// 这里 into_iter() 迭代器会为循环体的每次迭代赋予独占一个条带的所有权，
      	// 确保一次只有一个线程可以写入它。
        for (i, band) in bands.into_iter().enumerate() {
          	// 确定每个条带的参数
            let top = rows_per_band * i;
            let height = band.len() / bounds.0;
            let band_bounds = (bounds.0, height);
            let band_upper_left = pixed_to_point(bounds, (0, top), upper_left, lower_right);
            let band_lower_right =
                pixed_to_point(bounds, (bounds.0, top + height), upper_left, lower_right);
          	// 创建一个线程，渲染图像
          	// move 表示这个闭包会接手它所用遍历的所有权，
          	// 所以只有此闭关，即只有此线程可以使用可变切片 band。
            spawner.spawn(move |_| {
                render(band, band_bounds, band_upper_left, band_lower_right);
            });
        }
    })
    .unwrap();
}
</code></pre>
<p>再次执行：</p>
<pre><code>time ./target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35 -1,0.20
./target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35 -1,0.20  3.57s user 0.01s system 335% cpu 1.067 total
</code></pre>
<p>可以看到虽然总共使用的 CPU 时间还是 3~4s，但是整个程序的执行时间只缩短到 1s 左右了。</p>
<h3>9. rayon 工作窃取</h3>
<p>前面我们使用 8 个工作线程优化了曼德博集的绘制速度，大概是 4 倍的速度提升。其实这还不够快。</p>
<p>问题的根源在于我们没有平均分配工作量。计算图像的一个像素相当于运行一个循环。事实上，图像的浅灰色部分（循环会快速退出的地方）比黑色部分（循环会运行整整 255 次迭代的地方）渲染速度要快得多。因此，虽然我们将整个区域划分成了大小相等的水平条带，但创建了不均等的工作负载，</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image00879.jpeg" alt="曼德博集程序中的工作分配不均等"></p>
<p>使用 rayon 很容易解决这个问题。我们可以为输出中的每一行像素启动一个并行任务。这会创建数百个任务，而 rayon 可以在其线程中分配这些任务。有了工作窃取机制，任务的规模是无关紧要的。rayon 会对这些工作进行平衡。</p>
<p>我们先引入 <code>rayon</code>：</p>
<pre><code class="language-bash">cargo add rayon
</code></pre>
<p>在 <code>main.rs</code> 中引入 <code>rayon</code>:</p>
<pre><code class="language-rust">use rayon::prelude::*;
</code></pre>
<p>然后 <code>main</code> 中并发绘制的部分替换为下面的代码：</p>
<pre><code class="language-rust">let bands: Vec&lt;(usize, &amp;mut [u8])&gt; = pixels.chunks_mut(bounds.0).enumerate().collect();

bands.into_par_iter().for_each(|(i, band)| {
    let top = i;
    let band_bounds = (bounds.0, 1);
    let band_upper_left = pixed_to_point(bounds, (0, top), upper_left, lower_right);
    let band_lower_right = pixed_to_point(bounds, (bounds.0, top + 1), upper_left, lower_right);
    render(band, band_bounds, band_upper_left, band_lower_right);
});
</code></pre>
<p>首先，创建 bands，也就是要传给 rayon 的任务集合。每个任务只是一个元组类型 (usize, &amp;mut [u8])：第一个是计算所需的行号，第二个是要填充的 pixels 切片。我们使用 chunks_mut 方法将图像缓冲区分成一些行，enumerate 则会给每一行添加行号，然后 collect 会将所有数值切片对放入一个向量中。（这里需要一个向量，因为 rayon 只能从数组和向量中创建并行迭代器。）</p>
<p>编译：</p>
<pre><code class="language-bash">cargo build --release
</code></pre>
<p>再次执行：</p>
<pre><code class="language-bash">time ./target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35 -1,0.20
./target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35 -1,0.20  3.96s user 0.01s system 973% cpu 0.408 total
</code></pre>
<p>可以看到，这次速度提升更加明显，总共只用了 0.4s 左右的时间。</p>
<p>以上就是实用 Rust 绘制曼德博集实战的全部内容，enjoy，happy coding~</p>
]]></content:encoded>
    </item>
    <item>
      <title>一文彻底掌握浮点数</title>
      <link>https://hedon.top/blog/floating-point-number/</link>
      <guid isPermaLink="true">https://hedon.top/blog/floating-point-number/</guid>
      <pubDate>Sat, 23 Dec 2023 21:51:52 GMT</pubDate>
      <description>本文从一个经典问题 0.1+0.2 != 0.3 出发，详细介绍了 IEEE-754 标准下的浮点数表示方法，细致阐述了 3 种浮点数类型的表示逻辑，包括规格化值、非规格化值和特殊值。还介绍了浮点数舍入的 4 种模式，以及浮点数的基本运算。最后，本文结合 Go 语言给出了浮点数不同的输出方式的例子，以及简单介绍了 Go 语言中的 math/big 库在大数运算和精度更高的运算场景中的应用。本文包含大量实例和推演过程，希望能帮助读者彻底掌握浮点数。</description>
      <category>计算机原理</category><category>浮点数</category><category>计算机基础</category>
      <content:encoded><![CDATA[<h2>经典问题</h2>
<p>0.1 + 0.2 = ？</p>
<p>我们写个 Go 程序来测试一下：</p>
<pre><code class="language-go">func main() {
	var f1 float64 = 0.1
	var f2 float64 = 0.2
	fmt.Println(f1+f2 == 0.3)
	fmt.Println(f1 + f2)
}
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">false
0.30000000000000004
</code></pre>
<p>如此违背 “常识” 的结果，其实是因为当下计算机体系中小数的表示方式是浮点数，而计算机中对浮点数的表示并非百分百精确的，在表示和计算过程中都有可能会丢失精度。</p>
<p>这迫使必须深入理解浮点数在计算机中的存储方式及性质，才能正确处理关于数字的计算问题。</p>
<h2>结论先行</h2>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/IEEE-754%20%E6%B5%AE%E7%82%B9%E6%95%B0.jpg" alt="IEEE-754 浮点数"></p>
<h2>定点数</h2>
<p>要理解浮点数的第一步是考虑含有小数值的二进制数字。在这之前，我们来看看更加熟悉的十进制表示法：</p>
<p>$$
d_md_{m-1} ··· d_1d_0 . d_{-1}d_{-2}··· d_{-n}
$$</p>
<p>小数点 <code>.</code> 左边是整数部分，右边是小数部分。其中每个十进制数 <code>di</code> 的取值范围是 0~9。</p>
<p>如十进制的 <code>12.34</code> 即可以表示为：</p>
<p>$$
1×10^1+2×10^0+3×10^{-1}+4×10^{-2}
$$</p>
<p>那其实二进制也是一样的道理，只不过把其中的 <code>10</code> 换成 <code>2</code>，而 <code>di</code> 的取值范围为 0~1。</p>
<p>如二进制的 <code>101.11</code> 可以表示为：</p>
<p>$$
1×2^2+0×2^1+1×2^0+1×2^{-1}+1×2^{-2}
$$</p>
<p>如果我们仅考虑有限长度的编码，那么十进制表示法不能准确表达像 1/3 和 5/7 这样的数。类似的，小数的二进制表示法只能表示那些能够被写成以下形式的数：</p>
<p>$$
x × 2^y
$$</p>
<p>其他的值就只能近似地表示。</p>
<p>定点数的整数部分是小数部分的位数是固定不变的，在位数有限的情况下，定点数的取值范围和精度都比较差。于是就有了 IEEE-754 提出的浮点数表示法。</p>
<h2>浮点数</h2>
<p>所谓“浮点数”（Floating-point numbers），即小数点可以“<strong>浮动</strong>”，即小数点的位置不是固定的，而是可以根据数值的大小和精度需求移动的。这种表示法允许在广泛的范围内表示数值，同时保持相对恒定的精度。</p>
<p>在计算机中，浮点数通常遵循 IEEE-754 标准。这个标准定义了浮点数的存储和运算方式，确保了不同计算机系统之间的一致性。IEEE-754 用以下形式来表示一个数：</p>
<p>$$
V = (-1)^s×M×2^E
$$</p>
<p>其中：</p>
<ul>
<li><strong>s 符号位（Sign bit）</strong>：表示数值的正负。</li>
<li><strong>M 尾数（Mantissa）</strong>：表示数值的有效数字。</li>
<li><strong>E 指数（Exponent）</strong>：决定小数点的位置。</li>
</ul>
<p>IEEE-754 将浮点数的位表示划分成三个部分，分别对各个部分进行编码，对应上面公式右边的 3 个字母：</p>
<ul>
<li>一个单独的符号位 <code>s</code> 直接编码符号 <code>s</code>。</li>
<li>$k$ 位的阶码字段 $exp=e_{k-1}\cdots e_1e_0$ 编码阶码 <code>E</code>。</li>
<li>$n$ 位小数字段 $frac=f_{n-1}\cdots f_1f_0$ 编码尾数 <code>M</code>，但是编码出来的值也依赖于阶码字段的值是否等于 0。</li>
</ul>
<p>在 IEEE-754 标准中，定义了两种精度的浮点数，分别是单精度浮点数（32 位）和双精度浮点数（64 位）。</p>
<p>单精度：</p>
<ul>
<li>1 位符号位 s</li>
<li>8 位指数 exp</li>
<li>23 位尾数 frac</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231224102703581.png" alt="单精度浮点数"></p>
<p>双精度：</p>
<ul>
<li>1 位符号位 s</li>
<li>11 位指数 exp</li>
<li>52 位尾数 frac</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231224102756244.png" alt="双精度浮点数"></p>
<p>根据 <code>exp</code> 的值，浮点数又可以分成三类：</p>
<ol>
<li>规格化的</li>
<li>非规格化的</li>
<li>特殊的</li>
</ol>
<p>其中第三类“特殊的”又可以根据 <code>frac</code> 分成两类：</p>
<ol>
<li>无穷大</li>
<li>不是一个数 NaN（Not a Number）</li>
</ol>
<p>具体如下表所示：</p>
<table>
<thead>
<tr>
<th></th>
<th align="center">exp</th>
<th align="center">frac</th>
</tr>
</thead>
<tbody><tr>
<td>规格化的</td>
<td align="center">≠0 &amp; ≠ 255</td>
<td align="center">f</td>
</tr>
<tr>
<td>非规格化的</td>
<td align="center">0</td>
<td align="center">f</td>
</tr>
<tr>
<td>特殊的</td>
<td align="center">1</td>
<td align="center">f</td>
</tr>
<tr>
<td>- 无穷大</td>
<td align="center">1</td>
<td align="center">0</td>
</tr>
<tr>
<td>- NaN</td>
<td align="center">1</td>
<td align="center">≠0</td>
</tr>
</tbody></table>
<p>对于不同类型的浮点数，在计算公式 $V=(-1)^s×M×2^E$ 中，<code>exp -&gt; E</code> 和 <code>frac -&gt; M</code> 的方式有所不同。</p>
<p>下面我们来对这几种不同类型进行详细讨论，其中不乏有一些很有趣且充满智慧的设计理念。</p>
<h3>特殊值 Special Values</h3>
<ul>
<li><strong>指数部分</strong>：全为 0。</li>
<li><strong>尾数部分</strong>：全为 0 则表示无穷大，不全为 0 则表示 NaN。</li>
<li><strong>作用</strong>：特殊值用于表示那些无法用常规数值表示的情况，如无穷大、非数（NaN）等。这些值通常用于操作的错误或特殊情况的结果，如除以 0、无效操作等。</li>
</ul>
<h3>规格化的值 Normalize Values</h3>
<ul>
<li><strong>指数部分</strong>：不全为 0 且不全为 1。</li>
<li><strong>尾数部分</strong>：可以是任意值。</li>
<li><strong>作用</strong>：用于表示大多数非零数值</li>
</ul>
<p>在规格化值中：</p>
<ul>
<li>$E=e-bias$</li>
<li>$M=1+f$</li>
</ul>
<p>其中 <code>e</code> 即为 <code>exp</code>，，<code>bias</code> 是偏置量，它的值为 $2^{k-1} -1$，其中 <code>k</code> 为 <code>exp</code> 的位数，故：</p>
<ul>
<li>在单精度中，$bias=2^{8-1}-1=2^7-1=128-1=127$</li>
<li>在双精度中，$bias=2^{11-1}-1=2^{10}-1=1024-1=1023$</li>
</ul>
<p>其中 <code>f</code> 为 <code>frac</code> 表示的数，范围 $0≤f&lt;1$。</p>
<p>所以一个规格化数，具体可以表示为：</p>
<p>$$
V=(-1)^{sign}×1.frac×2^{(exp-bias)}
$$</p>
<p>这里有 4 个问题：</p>
<ol>
<li>这个 <code>bias</code> 是什么？</li>
<li>为什么 <code>E</code> 要 <code>e</code> 去减掉一个 <code>bias</code>？</li>
<li><code>bias</code> 的值是怎么定下的，如单精度为什么是 127，不是 126 或 128？</li>
<li><code>M</code> 为什么需要 <code>f</code> 去加上一个 <code>1</code>？</li>
</ol>
<p>下面我们来对这 4 个问题进行一一解答。</p>
<p><strong>第 1 个问题，这个 bias 是什么？</strong></p>
<blockquote>
<p><code>bias</code> 是一个预设的偏移量，用于将指数部分的值偏移到全正数，从而简化处理。</p>
</blockquote>
<p><strong>第 2 个问题：为什么 E 要 e 去减去一个 bias？</strong></p>
<blockquote>
<p>先说结论：使用 bias（偏置指数，biased exponent）可以允许浮点数以统一的方式表示，同时也使得浮点数的排序和比较变得简单。</p>
</blockquote>
<p>首先指数肯定得支持正负形式的出现，那么直接使用无符号整型来表示指数肯定是不行的，因为它无法表示负指数。暂时先抛开 IEEE-754 定下的标准，我们可以尝试用补码来表示指数。</p>
<p>假设我们有两个 32 位的浮点数 <code>A</code> 和 <code>B</code>，并且我们假设它们的指数部分使用 8 位二进制补码表示（这与 IEEE-754 标准不同）。</p>
<ul>
<li><code>A</code> 的二进制表示：<code>0 0000010 00000000000000000000000</code></li>
<li><code>B</code> 的二进制表示：<code>0 1111110 00000000000000000000000</code></li>
</ul>
<p>在这里，第一位是符号位（0 表示正数），接下来的 8 位是以补码形式表示的指数，剩下的 23 位是尾数。</p>
<p>我们想要比较这两个数的大小，需要怎么做呢？</p>
<p>我们先解析这 2 个数：</p>
<ul>
<li>符号位：对于 <code>A</code> 和 <code>B</code>，符号位都是 0，表示这是两个正数。</li>
<li>指数部分（使用补码表示）<ul>
<li><code>A</code> 的指数为 <code>0000010</code>，解读为正数 +2。</li>
<li><code>B</code> 的指数为 <code>1111110</code>，在补码表示中，这是一个负数。先加取反后加 1 转换为正数 <code>00000010</code>，它表示 -2。</li>
</ul>
</li>
</ul>
<p>要比较这 2 个数：</p>
<ul>
<li>当我们比较 <code>A</code> 和 <code>B</code> 时，首先需要考虑它们的指数。</li>
<li>指数 <code>A</code> 为 +2，而 <code>B</code> 为 -2。即使它们的尾数部分相同（在这个例子中都是 0），<code>A</code> 的实际值要大于 <code>B</code>，因为正指数表示的数值范围远大于负指数。</li>
</ul>
<p>可以看出：使用补码表示指数增加了比较过程的复杂性，因为我们需要解读补码并考虑其正负。特别是在涉及到负指数的情况下，我们不能仅仅比较二进制表示的大小，而必须将补码转换为实际的数值，然后再进行比较。</p>
<p>现在回过头来看看 IEEE-754 的设计，假设我们有两个单精度（32 位）浮点数 <code>A</code> 和 <code>B</code>：</p>
<ul>
<li><code>A</code> 的二进制表示为：<code>0 10000010 00000000000000000000000</code></li>
<li><code>B</code> 的二进制表示为：<code>0 01111110 00000000000000000000000</code></li>
</ul>
<p>解析这两个数：</p>
<ul>
<li><code>A</code>：符号位为 0（正数），指数部分为 <code>10000010</code>（二进制，对应十进制的 130），尾数部分为全 0。</li>
<li><code>B</code>：符号位为 0（正数），指数部分为 <code>01111110</code>（二进制，对应十进制的 126），尾数部分为全 0。</li>
</ul>
<p>计算实际指数值：单精度浮点数的偏置值 <code>bias</code> 为 127，故：</p>
<ul>
<li><code>A</code> 的实际指数 <code>E = 130 - 127 = 3</code>。</li>
<li><code>B</code> 的实际指数 <code>E = 126 - 127 = -1</code>。</li>
</ul>
<p>比较这两个数：</p>
<ul>
<li>在减去 <code>bias</code> 后，我们可以直接比较指数部分的二进制表示来确定数值的大小。</li>
<li>由于 <code>10000010</code>（130）大于 <code>01111110</code>（126），因此我们可以直接得出 <code>A</code> 大于 <code>B</code>，而无需考虑负指数的复杂表示问题。</li>
</ul>
<p>这个例子说明了通过减去偏置值，IEEE-754 标准能够简化浮点数的比较和排序操作。偏置后的指数表示方法允许计算机以统一和高效的方式处理浮点数，无论它们的实际数值大小如何。</p>
<p><strong>第 3 个问题：bias 的值是怎么定下的，如单精度为什么是 127，而不是 126 或 128？</strong></p>
<blockquote>
<p><code>bias</code> 值的选择，是为了平衡正负指数的表示范围，并且充分利用指数部分的存储空间。</p>
</blockquote>
<p>以单精度为例，<code>exp</code> 占了 8 位，8 位二进制可以表示的值的范围是 $[0,255]$。如果我们选择 127 作为 <code>bias</code>，则存储的指数范围就是 $[-127,128]$。这样可以使得指数部分可以均匀地表示从负大数到正大数的范围（对称）。</p>
<p>在 IEEE-754 标准中，全 0 的指数表示为非规格化数或 0，而全 1 的指数用于表示无穷大或 NaN）。选择 127 作为 <code>bias</code> 可以在保留这些特殊值的同时，提供最大的有效指数范围。</p>
<p><strong>第 4 个问题：M 为什么需要 f 去加上一个 1？</strong></p>
<blockquote>
<p>在规格化数中隐含最高位 1 是为了提高尾数部分的表示效率，从而增加精度。</p>
</blockquote>
<p>其实这跟科学计数法的很像的，为了确保浮点数表示的<strong>唯一性</strong>，IEEE-754 规定规格化浮点数最高位一定是非零的。如果不规定最高位非零，同一个数可以有多种不同的浮点表示，例如，在二进制中 <code>0.5</code> 可以表示为 $1.0×2^{-1}$，也可以表示位 $0.1×2^0$ 或 $0.01×2^1$ 等等。这种多重表示会使浮点运算变得复杂且低效。</p>
<p>那既然最高位总是 1，那就没必要显示存储了，还可以使尾数部分中多 1 位的存储空间，从而允许存储更多的有效数字，以<strong>提高精度</strong>。</p>
<h3>非规格化的值 Denormalized Values</h3>
<ul>
<li><strong>指数部分</strong>：全为 0。</li>
<li><strong>尾数部分</strong>：可以是任意值。</li>
<li><strong>作用</strong>：<ul>
<li>提供表示数值 0 的方法。因为规格化中 $M≥1$，所以无法表示 0。</li>
<li>用于表示非常接近于 0 的数值，这些数值太小，无法用规格化格式表示。它们填补了 0 和最小规格化正数之间的间隙，提供了渐近于 0 的连续表示，防止了所谓的“下溢”。</li>
</ul>
</li>
</ul>
<p>在非规格化值中：</p>
<ul>
<li>$E=1-bias$</li>
<li>$M=f$</li>
</ul>
<p>所以一个规格化数，具体可以表示为：</p>
<p>$$
V=(-1)^{sign}×0.frac×2^{(1-bias)}
$$</p>
<p>那这里又有 2 个问题了：</p>
<ol>
<li>为什么指数部分不是 $0-bias$ 而是 $1-bias$？</li>
<li>为什么 M 不需要隐含的 1 了？</li>
</ol>
<p><strong>第 1 个问题：为什么指数部分不是 0-bias 而是 1-bias？</strong></p>
<blockquote>
<p>这是一个特殊的设计，旨在使非规格化数能够平滑地连接到规格化数的最小正值。</p>
</blockquote>
<p>最小的规格化数的指数为 <code>1 - bias</code>。为了在数值上平滑地过渡到非规格化数，非规格化数的实际指数也被设定为 <code>1 - bias</code>。这样，非规格化数就可以代表那些小于最小规格化正数的数值，而不会出现一个数值的“间隙”。</p>
<p><strong>第 2 个问题：为什么 M 不需要隐含的 1 了？</strong></p>
<blockquote>
<p>不包含隐含的 1 使得非规格化数能够在浮点数表示中填补 0 和最小规格化数之间的空隙，提供对极小数值的连续表示。</p>
</blockquote>
<ul>
<li><strong>避免下溢</strong>：非规格化数通过允许尾数部分不以隐含的 1 开始（而是以显式的 0 开始），使得它们可以表示比最小规格化数还要小的数值。这对于避免数值下溢至 0 非常重要，尤其是在累积了多次运算后的场合。</li>
<li><strong>精度牺牲</strong>：使用非规格化数的代价是牺牲了一些精度。由于没有隐含的最高位 1，非规格化数的精度较低。但这是为了在非常小的数值范围内提供数值的连续性所做的必要妥协。</li>
</ul>
<h3>总结</h3>
<p>规格化值、非规格化值和特殊值三种类型共同构成了 IEEE-754 浮点数标准的完整表示体系，使得浮点数能够在计算机中有效低处理从非常小到非常大的数值范围，同时还能应对特殊的计算情况。</p>
<h3>举例</h3>
<p>参考《深入理解计算机系统》，我们以 8 位浮点数为例，其中：</p>
<ul>
<li>1 位符号 s</li>
<li>4 位指数 exp</li>
<li>3 位尾数 frac</li>
</ul>
<p>可以算出 $bias=2^{4-1}-1=2^3-1=8-1=7$。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/544558-20211104211145834-189368139.png" alt="8 位浮点数（≥0部分）"></p>
<p>其中靠近 0 的是非规格化值：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231224130157112.png" alt="8 位浮点数 - 非规格化值"></p>
<p>以 <code>0 0000 001</code> 为例：</p>
<p>$$
V = (-1)^s×M×2^E \
= (-1)^s×0.frac×2^{1-bias} \
= (-1)^0×(0+(1/8))×2^{1-7} \
= 1×1/8×2^{-6} \
=2^{(-9)} \
= 1/512
$$</p>
<p>再往下，就是规格化值：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231224130849986.png" alt="8 位浮点数 - 规格化值"></p>
<p>以 <code>0 0110 110</code> 为例：</p>
<p>$$
V = (-1)^s×M×2^E \
= (-1)^s×1.frac×2^{e-bias} \
= (-1)^0×(1+6/8)×2^{6-7} \
=1×14/8×2^{-1} \
= 14/16 \
=7/8
$$</p>
<h2>整型转为浮点型</h2>
<p>下面以一个例子来直观感受一下一个整型是如何转为浮点型的。</p>
<p>现在我们有一个 int32 的整型 <code>123</code>，我们希望将其转为单精度浮点型 <code>123.0</code>。</p>
<p><strong>1. 将整型用二进制表示出来</strong></p>
<p>$$
12345_{(10)} = 1111011_{(2)}
$$</p>
<p><strong>2. 规范化表示</strong></p>
<p>$$
1111011= 1.111011×2^6
$$</p>
<p><strong>3. 计算指数</strong></p>
<p>$$
exp = 6 + 127 = 133_{(10)} = 10000101_{(2)}
$$</p>
<p><strong>4. 确定尾数**</strong></p>
<p>这是个规范化值，所以 <code>1.frac</code> 的 <code>1</code> 省略，又因为单精度浮点数 <code>frac</code> 占 23 位，所以我们需要在 <code>111011</code> 后面再填 17 个 0，即：</p>
<p>$$
frac = 111011 0000 0000 0000 0000 0
$$</p>
<p><strong>5. 确定符号位</strong></p>
<p>$$
s = 0_{(2)}
$$</p>
<p><strong>6. 组合起来</strong></p>
<p><code>12345.0</code> = <code>0  10000101 11101100000000000000000</code></p>
<h2>浮点数舍入</h2>
<p>由于浮点数的表示具有固定的精度，在进行运算或表示时，经常会遇到无法精确表示的数值，这就需要采用舍入方法来近似表示这些数值。IEEE-754 标准定义了几种不同的舍入模式，以适应不同的计算需求。</p>
<h3>舍入模式</h3>
<p><strong>最近舍入（Round to Nearest）</strong>:</p>
<ul>
<li>这是最常用的舍入模式，也是默认的模式。</li>
<li>规则是向最接近的可表示值舍入。如果精确结果位于两个可表示值的中点，通常舍入到最近的偶数（即尾数的最后一位为 0）。</li>
<li>这种方法减少了累积误差，确保了在多次运算后的总体精度。</li>
</ul>
<p><strong>向零舍入（Round Toward Zero）</strong>:</p>
<ul>
<li>这种模式总是舍入到零的方向，即舍去小数部分。</li>
<li>对于正数，这相当于取下限，对于负数，相当于取上限。</li>
</ul>
<p><strong>向上舍入（Round Up）</strong>:</p>
<ul>
<li>无论正负，总是向<strong>正无穷大</strong>的方向舍入。</li>
<li>对于任何数值，舍入结果都是<strong>不小于</strong>原值的最小整数。</li>
</ul>
<p><strong>向下舍入（Round Down）</strong>:</p>
<ul>
<li>无论正负，总是向<strong>负无穷大</strong>的方向舍入。</li>
<li>对于任何数值，舍入结果都是<strong>不大于</strong>原值的最大整数。</li>
</ul>
<h3>舍入的影响</h3>
<ul>
<li><strong>精度损失</strong>：由于固定的尾数位数，舍入可能导致精度的损失。</li>
<li><strong>舍入误差</strong>：舍入操作本身可能引入误差，这些误差在连续运算中可能会累积。</li>
<li><strong>选择合适的舍入模式</strong>：不同的舍入模式适合不同的应用场景。例如，金融计算可能更倾向于使用向零舍入，而科学计算通常使用最近舍入以减少累积误差。</li>
</ul>
<h3>实例</h3>
<table>
<thead>
<tr>
<th>Mode</th>
<th align="center">1.40</th>
<th align="center">1.60</th>
<th align="center">1.50</th>
<th align="center">2.50</th>
<th align="center">-1.50</th>
</tr>
</thead>
<tbody><tr>
<td>最近舍入</td>
<td align="center">1</td>
<td align="center">2</td>
<td align="center">2</td>
<td align="center">2</td>
<td align="center">-2</td>
</tr>
<tr>
<td>向零舍入</td>
<td align="center">1</td>
<td align="center">1</td>
<td align="center">1</td>
<td align="center">2</td>
<td align="center">-1</td>
</tr>
<tr>
<td>向上舍入</td>
<td align="center">2</td>
<td align="center">2</td>
<td align="center">2</td>
<td align="center">3</td>
<td align="center">-1</td>
</tr>
<tr>
<td>向下舍入</td>
<td align="center">1</td>
<td align="center">1</td>
<td align="center">1</td>
<td align="center">2</td>
<td align="center">-2</td>
</tr>
</tbody></table>
<h2>浮点数运算</h2>
<p>因为浮点数本身就存在精度问题，所以浮点数运算在计算机中是一个近似过程，涉及到精确度的权衡、特殊值的处理、错误的传播，以及舍入规则的应用。</p>
<h3>浮点数加减</h3>
<ol>
<li>浮点数加法和减法首先需要对操作数进行对齐，使得它们的指数相同。这可能涉及将尾数的二进制表示向右移位，可能导致精度损失。</li>
<li>然后执行加法或减法操作。</li>
<li>对结果进行规范化和舍入。</li>
</ol>
<p>注意，浮点数的加减法<strong>不满足</strong>结合律、交换律和分配律，这你简单分析下应该就可以理解了，这里不赘述了。</p>
<p>假设我们要在单精度浮点数格式下计算：</p>
<p>$$
12.375 + 0.1 = ?
$$</p>
<p><strong>第 1 步：转为二进制表示</strong></p>
<p>其中 12.375 我们可以用二进制精确表示：</p>
<p>$$
12.375_{(10)} = 1100.011_{(2)}
$$</p>
<p>而 0.1 就比较特殊了，用二进制表示的话它会无限循环。</p>
<blockquote>
<p>将十进制小数转换为二进制表示涉及到重复乘以 2 的过程，并提取每次乘法后整数部分作为二进制位。这个过程是一个不断重复的过程，直到小数部分变为 0 或开始循环。</p>
</blockquote>
<ol>
<li><p>取 <code>0.1</code> 的小数部分乘以 2（即 <code>0.1 × 2 = 0.2</code>），整数部分是 <code>0</code>，小数部分是 <code>0.2</code>。</p>
</li>
<li><p>再次取小数部分乘以 2（即 <code>0.2 × 2 = 0.4</code>），整数部分是 <code>0</code>，小数部分是 <code>0.4</code>。</p>
</li>
<li><p>继续这个过程，我们得到以下序列：</p>
<p><code>0.4 × 2 = 0.8</code> → 整数部分 <code>0</code></p>
<p><code>0.8 × 2 = 1.6</code> → 整数部分 <code>1</code></p>
<p><code>0.6 × 2 = 1.2</code> → 整数部分 <code>1</code></p>
<p><code>0.2 × 2 = 0.4</code> → 整数部分 <code>0</code></p>
<p>…（循环开始）</p>
</li>
</ol>
<p>所以，<code>0.1</code> 的二进制表示开始为 <code>0.0001100110011…</code>，并且这个模式会无限循环下去。</p>
<p><strong>第 2 步：规格化</strong></p>
<p>回顾一下这张图：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231224102703581.png" alt="单精度浮点数"></p>
<p>所以 12.375 规格化表示为：</p>
<ul>
<li>先规范化为 1.xxxx 形式： $1100.011_{(2)} = 1.100011 × 2^3$</li>
<li>指数为：$3 + 127 = 130 = 10000010_{(2)}$</li>
<li>尾数为：$10001100000000000000000（23;位，右边补;0）$</li>
<li>汇总：$0;10000010;10001100000000000000000$</li>
</ul>
<p>而 0.1 由于无限循环，我们在单精度下只能保留 23 位，并采用<strong>最近舍入</strong>，所以 0.1 规格化表示为：</p>
<ul>
<li>先规范为 1.xxxx 形式：$0.00011001100110011001100(循环) = 1.10011001100110011001100 × 2^-4$</li>
<li>指数为：$-4 + 127 = 123 = 01111011_{(2)}$</li>
<li>尾数为：$10011001100110011001100$</li>
<li>汇总：$0;01111011;10011001100110011001100$</li>
</ul>
<p><strong>第 3 步：对齐指数</strong></p>
<p>先把 2 个浮点表示放在一起，好对比：</p>
<ul>
<li>$0;10000010;10001100000000000000000$</li>
<li>$0;01111011;10011001100110011001100$</li>
</ul>
<p>将两个数的指数对齐，较小的指数增加，同时相应地调整尾数。</p>
<p>这里需要调整将 <code>0.1</code> 的指数从 <code>01111011</code> 调整到 <code>10000010</code>，这里加了 <code>7</code>，所以 <code>0.1</code> 的尾数 <code>1.10011001100110011001100</code>需要右移 <code>7</code> 位，即：<code>0.00000011001100110011001</code>。</p>
<p><strong>第 4 步：相加</strong></p>
<p>现在两个数的指数相同了，我们可以直接把它们的尾数相加：</p>
<p>$$
;;;1.10001100000000000000000 \
+;0.00000011001100110011001 \
=;1.10001111001100110011001
$$</p>
<p><strong>第 5 步：规范化结果</strong></p>
<p>这里无需规范化。</p>
<p><strong>第 6 步：舍入</strong></p>
<p>这里没有进位，不需要舍入。</p>
<p><strong>第 7 步：浮点化表示</strong></p>
<p>$$
0;10000010;10001111001100110011001
$$</p>
<p><strong>第 8 步：转为十进制</strong></p>
<p>$$
V =(-1)^s×M×2^E \
= (-1)^s×1.frac×2^{e-bias} \
= 1.10001111001100110011001 × 2^3 \
= 1100.01111001100110011001_{(2)} \
= 12.47499942779541015625_{(10)} \
≈ 12.475_{(10)}
$$</p>
<h3>浮点数乘法</h3>
<ol>
<li><strong>符号位计算</strong>：结果的符号由两个操作数的符号位决定。如果符号位相同（都是正数或都是负数），结果为正；如果符号位不同，结果为负。</li>
<li><strong>指数相加</strong>：两个数的指数相加，并减去偏置值（单精度浮点数中为 127，双精度为 1023）。</li>
<li><strong>尾数相乘</strong>：两个数的尾数相乘。这里的尾数包括隐含的最高位 1。</li>
<li><strong>结果规范化</strong>：如果乘法的结果需要规范化（即调整为 <code>1.xxxx</code> 的形式），则相应调整指数。</li>
<li><strong>舍入处理</strong>：如果需要，对结果进行舍入以适应目标格式。</li>
<li><strong>检查溢出或下溢</strong>：如果指数超出了表示范围，则发生溢出（结果可能为无穷大或特殊值）；如果指数太小，发生下溢（结果可能为 0 或非规格化数）。</li>
</ol>
<p>假设我们要在单精度浮点数格式下计算：</p>
<p>$$
2.0 × 3.0 = ?
$$</p>
<p><strong>第 1 步：转为二进制表示</strong></p>
<p>$$
2.0_{(10)} = 1_{(2)} \
3.0_{(10)} = 11_{(2)}
$$</p>
<p><strong>第 2 步：规范化</strong></p>
<p>$$
1 = 1.0 × 2^0 \
11 = 1.1 × 2^1
$$</p>
<p><strong>第 3 步：浮点化</strong></p>
<p>$$
2.0 = 0;00000001;00000000000000000000000 \
3.0 = 0;00000001;10000000000000000000000
$$</p>
<p><strong>第 4 步：乘法操作</strong></p>
<ul>
<li>符号位：正正得正：$0_{(2)} × 0_{(2)} = 0_{(2)}$</li>
<li>指数相加并减去偏置值：$(127+1)+(127+1)-127=129$</li>
<li>尾数相乘：$1.0_{(2)}×1.1_{(2)} = 1.1_{(2)}$</li>
</ul>
<p><strong>第 5 步：规范化</strong></p>
<p>这里无需规范化。</p>
<p><strong>第 6 步：舍入</strong></p>
<p>这里无需舍入。</p>
<p><strong>第 7 步：浮点化结果</strong></p>
<p>$$
0;00000010;10000000000000000000000
$$</p>
<p><strong>第 8 步：转为十进制</strong></p>
<p>$$
V = (-1)^s×1.frac×2^{(e-127)} \
= 0 × 1.1 × 2^2 \
= 110_{(2)} \
= 6.0_{(10)}
$$</p>
<h3>浮点数除法</h3>
<p>浮点数除法类似于乘法，但有一些不同：</p>
<ol>
<li><strong>符号位计算</strong>：与乘法类似，结果的符号由两个操作数的符号位决定。</li>
<li><strong>指数相减</strong>：被除数的指数减去除数的指数，再加上偏置值。</li>
<li><strong>尾数相除</strong>：被除数的尾数除以除数的尾数。</li>
<li><strong>结果规范化</strong>：如果必要，调整结果使其规范化。</li>
<li><strong>舍入处理</strong>：如果需要，对结果进行舍入。</li>
<li><strong>检查溢出或下溢</strong>：与乘法类似的检查。</li>
</ol>
<p>假设我们要在单精度浮点数格式下计算：</p>
<p>$$
6.0 ÷ 3.0 =?
$$</p>
<p><strong>第 1 步：转为二进制表示</strong></p>
<p>$$
6.0_{(10)} = 110_{(2)} \
3.0_{(10)} = 11_{(2)}
$$</p>
<p><strong>第 2 步：规范化</strong></p>
<p>$$
6.0 = 110 = 1.10 × 2^2 \
3.0 = 11 = 1.1 × 2^1
$$</p>
<p><strong>第 3 步：浮点化</strong></p>
<p>$$
6.0 = 0;00000020;10000000000000000000000 \
3.0 = 0;00000001;10000000000000000000000
$$</p>
<p><strong>第 4 步：除法操作</strong></p>
<ul>
<li>符号位：正正得正：$0_{(2)} × 0_{(2)} = 0_{(2)}$</li>
<li>指数减并加上偏置值：$(127+2)-(127+1)+127=128$</li>
<li>尾数相除：$1.1_{(2)}×1.1_{(2)} = 1.0_{(2)}$</li>
</ul>
<p><strong>第 5 步：规范化</strong></p>
<p>这里无需规范化。</p>
<p><strong>第 6 步：舍入</strong></p>
<p>这里无需舍入。</p>
<p><strong>第 7 步：浮点化结果</strong></p>
<p>$$
0;00000001;00000000000000000000000
$$</p>
<p><strong>第 8 步：转为十进制</strong></p>
<p>$$
V = (-1)^s×1.frac×2^{(e-127)} \
= 0 × 1.0 × 2^1 \
= 10_{(2)} \
= 2.0_{(10)}
$$</p>
<h2>Go 语言输出浮点数</h2>
<pre><code class="language-go">func main() {
	var number float32 = 12.375
	fmt.Printf(&quot;浮点数：%f\n&quot;, number)
	fmt.Printf(&quot;科学计数法：%e\n&quot;, number)
	fmt.Printf(&quot;保留 2 位小数：%.2f\n&quot;, number)

	bits := math.Float32bits(number)
	bitsStr := fmt.Sprintf(&quot;%.32b&quot;, bits)
	fmt.Printf(&quot;输出32位浮点表示：%s %s %s\n&quot;, bitsStr[:1], bitsStr[1:9], bitsStr[9:])
}
</code></pre>
<h2>math/big</h2>
<p>Go 语言的 <code>math/big</code> 包提供了对大数的精确计算支持，这些大数的大小超出了标准整数类型（如 <code>int64</code>）或浮点类型（如 <code>float64</code>）的范围。这个包主要用于需要高精度计算的领域，如加密、科学计算等。</p>
<p>主要功能：</p>
<ul>
<li><strong>算术运算</strong>：支持基本的加、减、乘、除等算术运算。</li>
<li><strong>比较操作</strong>：可以比较两个大数的大小。</li>
<li><strong>位操作</strong>：对大整数进行位操作，如位移、与、或、异或等。</li>
<li><strong>解析和格式化</strong>：可以从字符串解析大数，也可以将大数格式化为字符串。</li>
</ul>
<p>示例：</p>
<pre><code class="language-go">func main() {
	a := big.NewFloat(math.MaxFloat64)
	b := big.NewFloat(math.MaxFloat64)
	sum := big.NewFloat(0)
	sum.Add(a, b)
	fmt.Println(&quot;a:&quot;, a)
	fmt.Println(&quot;sum:&quot;, sum)
	sum2 := big.NewFloat(0).
		SetPrec(15).        // 设置精度，prec 越大，精度越高，计算越复杂
		SetMode(big.ToZero) // 设置舍入策略
	sum2.Add(a, b)
	fmt.Println(&quot;sum2:&quot;, sum2)
}

// a: 1.7976931348623157e+308
// sum: 3.5953862697246314e+308
// sum2: 3.5953e+308
</code></pre>
<p>注意事项：</p>
<ul>
<li><strong>性能考虑</strong>：由于 <code>math/big</code> 提供的是任意精度计算，其性能通常低于原生的固定大小数值类型。</li>
<li><strong>内存使用</strong>：大数运算可能会消耗更多的内存。</li>
<li><strong>方法链式调用</strong>：<code>math/big</code> 的许多方法返回接收者本身，支持链式调用。</li>
</ul>
<h2>参考资料</h2>
<ul>
<li><a href="https://people.eecs.berkeley.edu/~wkahan/ieee754status/IEEE754.PDF">IEEE-754</a></li>
<li>深入理解计算机系统</li>
<li>Go 语言底层原理剖析</li>
<li><a href="https://www.bilibili.com/video/BV1zK4y1j7Cn">https://www.bilibili.com/video/BV1zK4y1j7Cn</a></li>
<li>ChatGPT-4</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Go1.21.0 程序启动过程</title>
      <link>https://hedon.top/blog/go-start/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-start/</guid>
      <pubDate>Thu, 07 Dec 2023 21:45:13 GMT</pubDate>
      <description>本文基于 Go1.21.0 版本详细介绍了 Go 语言程序的启动过程。开头有总结，方便读者快速浏览或回顾，后面是对整个 Go 启动过程的详细讨论，感兴趣的读者可以深入阅读这一部分。</description>
      <category>Go</category>
      <content:encoded><![CDATA[<h2>版本说明</h2>
<ul>
<li>Go 1.21.0</li>
<li>操作系统：Windows11 Intel64</li>
</ul>
<h2>结论先行</h2>
<h3>开发关注版</h3>
<p>在 Go 语言中，启动顺序通常如下：</p>
<ol>
<li><strong>导入包</strong>：首先，Go 编译器按照源文件中的 <code>import</code> 语句导入所有需要的包。</li>
<li><strong>初始化常量和变量</strong>：接着，编译器会初始化包级别（全局）的常量和变量。它们的初始化顺序按照它们在源文件中出现的顺序进行。</li>
<li><strong>执行 init 函数</strong>：然后，编译器会执行包级别的 <code>init</code> 函数。如果一个包有多个 <code>init</code> 函数，它们的执行顺序和它们在源文件中出现的顺序一致。</li>
<li><strong>执行 main.main 函数</strong>：最后，编译器会执行 <code>main</code> 函数。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/710735-20211213134644475-1604352048.png" alt="Go 程序启动流程 - 开发关注版"></p>
<h3>深入原理版</h3>
<ol>
<li><strong>命令行参数复制</strong>：读取命令行参数，复制到 argc 和 argv。</li>
<li><strong>初始化 g0 栈</strong>：g0 是运行时系统的一个特殊的 goroutine，它在程序启动时被创建，用于执行系统调用和协程调度。</li>
<li><strong>runtime.check 运行时检查</strong>：<ol>
<li>类型长度</li>
<li>指针操作</li>
<li>结构体字段偏移量</li>
<li>CAS</li>
<li>atomic 操作</li>
<li>栈大小是否为 2 的幂次。</li>
</ol>
</li>
<li><strong>runtime.args 参数初始化</strong>：将 argc 和 argv 的参数赋值到 Go 的变量中。</li>
<li><strong>runtime.osinit 初始化操作系统特点的设置</strong>：主要是判断系统字长和 CPU 核数。</li>
<li><strong>runtime.schedinit 初始化调度器</strong>：<ol>
<li>锁初始化</li>
<li>竞态检测器初始化</li>
<li>调度器设置，设置调度器可以管理的最大线程（M）数目</li>
<li>系统初始化，初始化内存管理、CPU 设置、算法等，这些都是调度器正常工作的基础</li>
<li>设置当前 M 的信号掩码</li>
<li>解析程序参数和环境变量</li>
<li>垃圾收集器初始化</li>
<li>设置 process 的数量</li>
</ol>
</li>
<li><strong>runtime.newproc 创建主协程 g0 并将其放入队列中等待执行</strong>。</li>
<li><strong>runtime. mstart 启动调度器</strong>：初始化 m0，并调度 g0 去执行 <code>runtime.main</code>。</li>
<li><strong>runtime.main 程序真正入口</strong>：<ol>
<li>runtime.init</li>
<li>启动 gc</li>
<li>执行用户包 init</li>
<li>执行用户函数 main.main</li>
</ol>
</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231210224929126.png" alt="Go 程序启动流程 - 深入原理版"></p>
<blockquote>
<p>如果只是想对 Go 语言程序的启动过程有一个简单的了解，那么阅读到这里就可以结束了。</p>
</blockquote>
<h2>Runtime</h2>
<p>在分析 Go 程序的启动过程之前，我们需要先了解一下 Go 中的 Runtime。所谓 Runtime，即 Go 的运行时环境，可以理解为 Java 的 JVM、JavaScript 依赖的浏览器内核。</p>
<p>Go 的 Runtime 是一份代码，它会随着用户程序一起打包成二进制文件，随着程序一起运行。</p>
<p>Runtime 具有内存管理、GC、协程、屏蔽不同操作系统调用等能力。</p>
<p>综上，Go 程序的运行都依赖于 Runtime 运行，所以我们在分析 Go 语言程序的启动过程的时候，首先要确定程序的入口，即 Runtime。</p>
<p>这部分代码位于 go 源码中 <a href="https://github.com/golang/go/tree/go1.21.0/src/runtime">src/runtime</a> 目录下，当你在本机安装 go 后，你可以进入相应的代码目录下，在 Windows 上，你可以在该目录下运行下面命令：</p>
<pre><code class="language-powershell">dir | findstr &quot;rt0&quot; |  findstr &quot;amd&quot;
</code></pre>
<p>这里我们输出 go 官方为多种 <code>amd</code> 处理器架构的操作系统所实现的 runtime，如：</p>
<pre><code class="language-powershell">-a----         2023/8/25     23:44            754 rt0_android_amd64.s
-a----         2023/8/25     23:44            399 rt0_darwin_amd64.s
-a----         2023/8/25     23:44            448 rt0_dragonfly_amd64.s
-a----         2023/8/25     23:44            442 rt0_freebsd_amd64.s
-a----         2023/8/25     23:44            311 rt0_illumos_amd64.s
-a----         2023/8/25     23:44            425 rt0_ios_amd64.s
-a----         2023/8/25     23:44            307 rt0_linux_amd64.s
-a----         2023/8/25     23:44            309 rt0_netbsd_amd64.s
-a----         2023/8/25     23:44            311 rt0_openbsd_amd64.s
-a----         2023/8/25     23:44            481 rt0_plan9_amd64.s
-a----         2023/8/25     23:44            311 rt0_solaris_amd64.s
-a----         2023/8/25     23:44           1166 rt0_windows_amd64.s
</code></pre>
<p>到这里也就明白了，前面所说的 Go Runtime 能力之 “屏蔽不同操作系统调用能力” 的方式便是针对每一种操作系统单独做实现，最后在编译的时候根据操作系统选择对应的实现即可。</p>
<p>这里我们以 <a href="https://github.com/golang/go/blob/go1.21.0/src/runtime/rt0_windows_amd64.s">rt0_windows_amd64.s</a> 为例，看看这个文件写了些什么：</p>
<pre><code class="language-armasm">TEXT _rt0_amd64_windows(SB),NOSPLIT|NOFRAME,$-8
	JMP	_rt0_amd64(SB)
</code></pre>
<p>这里我们可以看到它会直接跳到 <code>_rt0_amd64(SB)</code> 这里，在 Goland IDE 中，你可以双击 Shift 键打开搜索，搜索 <code>TEXT _rt0_amd64</code>，就可以发现这个函数位于 <a href="https://github.com/golang/go/blob/go1.21.0/src/runtime/asm_amd64.s">asm_amd64.s</a> 文件中，查看该文件：</p>
<pre><code class="language-armasm">// _rt0_amd64 is common startup code for most amd64 systems when using
// internal linking. This is the entry point for the program from the
// kernel for an ordinary -buildmode=exe program. The stack holds the
// number of arguments and the C-style argv.
TEXT _rt0_amd64(SB),NOSPLIT,$-8
	MOVQ	0(SP), DI	// argc
	LEAQ	8(SP), SI	// argv
	JMP	runtime·rt0_go(SB)
</code></pre>
<p>翻译一下上面的注释：<code>_rt0_amd64</code> 是大多数 <code>amd64</code> 系统在使用内部链接时的通用启动代码。这是 <code>exe</code> 程序从内核进入程序的入口点。堆栈保存了参数的数量和 C 语言风格的 argv。</p>
<p>到这里我们就可以非常确定地找到了对应操作系统的 Go 语言程序启动入口了，接下来只需要沿着该入口继续分析即可。</p>
<h2>runtime·rt0_go</h2>
<p>上面我们分析到 <code>_rt0_adm64</code> 会 JMP 到 <code>runtime·rt0_go</code> 执行，这个函数也位于 <a href="https://github.com/golang/go/blob/go1.21.0/src/runtime/asm_amd64.s">asm_amd64.s</a> 文件中，通过分析这个函数，我们可以了解到 Go 语言程序的整个启动过程。</p>
<p>下面将对这整个函数进行一个概览，后面会对重点过程逐个详述。</p>
<pre><code class="language-armasm">TEXT runtime·rt0_go(SB),NOSPLIT|NOFRAME|TOPFRAME,$0
	// 读取命令行参数，复制参数变量 argc 和 argv 到栈上
	MOVQ	DI, AX		// argc
	MOVQ	SI, BX		// argv
	SUBQ	$(5*8), SP
	ANDQ	$~15, SP
	MOVQ	AX, 24(SP)
	MOVQ	BX, 32(SP)

	// 从给定的（操作系统）堆栈创建istack。
	// 这是在设置 g0 的堆栈，g0 是运行时系统的一个特殊的 goroutine。
	// 它在程序启动时被创建，用于执行系统调用和协程调度。
	// 这里只是初始化 g0 的堆栈，还没有启动 g0。
	MOVQ	$runtime·g0(SB), DI
	LEAQ	(-64*1024)(SP), BX
	MOVQ	BX, g_stackguard0(DI)
	MOVQ	BX, g_stackguard1(DI)
	MOVQ	BX, (g_stack+stack_lo)(DI)
	MOVQ	SP, (g_stack+stack_hi)(DI)

	// 检查 CPU 的厂商 ID：
	//	如果没有 CPU 信息，则跳转到 nocpuinfo；
	//	如果是 Intel 的 CPU，就设置 runtime·isIntel=1，否则跳到 notintel。
	MOVL	$0, AX
	CPUID
	CMPL	AX, $0
	JE	nocpuinfo
	CMPL	BX, $0x756E6547  // &quot;Genu&quot;
	JNE	notintel
	CMPL	DX, $0x49656E69  // &quot;ineI&quot;
	JNE	notintel
	CMPL	CX, $0x6C65746E  // &quot;ntel&quot;
	JNE	notintel
	MOVB	$1, runtime·isIntel(SB)

notintel:  // 加载 EXA=1 的 cpuid 标志和版本信息
	MOVL	$1, AX
	CPUID
	MOVL	AX, runtime·processorVersionInfo(SB)

nocpuinfo:
	// 如果有 _cgo_init 就调用它
	MOVQ	_cgo_init(SB), AX
	TESTQ	AX, AX
	// 如果 _cgo_init 不存在，那么跳过后面的代码，
	// 直接进入到 needtls 进行 TLS 的初始化。
	// TLS，全称为Thread-Local Storage（线程局部存储），
	// 是操作系统提供的一种机制，允许每个线程拥有一份自己的数据副本。
	// 这些数据在同一线程的所有函数中都是可见的，但对其他线程是不可见的。
	// 这样，每个线程可以访问和修改自己的数据，而不会影响其他线程。
	JZ	needtls
	// 将 setg_gcc 函数的地址加载到 SI 寄存器中。
	// 这是 _cgo_init 函数的第二个参数。
	MOVQ	$setg_gcc&lt;&gt;(SB), SI // arg 2: setg_gcc
	// 在使用平台的TLS时不使用这第3和第4个参数。
	MOVQ	$0, DX
	MOVQ	$0, CX
#ifdef GOOS_android
	MOVQ	$runtime·tls_g(SB), DX 	// arg 3: &amp;tls_g
	// arg 4: TLS base, stored in slot 0 (Android&#39;s TLS_SLOT_SELF).
	// Compensate for tls_g (+16).
	MOVQ	-16(TLS), CX
#endif
#ifdef GOOS_windows
	MOVQ	$runtime·tls_g(SB), DX 	// arg 3: &amp;tls_g
	// 调整 Win64 的调用约定。
	MOVQ	CX, R9 // arg 4
	MOVQ	DX, R8 // arg 3
	MOVQ	SI, DX // arg 2
	MOVQ	DI, CX // arg 1
#endif
	// 前面 MOVQ	_cgo_init(SB), AX，这里就是调用 _cgo_init
	CALL	AX
	// 在 _cgo_init 之后更新 stackguard
	MOVQ	$runtime·g0(SB), CX
	MOVQ	(g_stack+stack_lo)(CX), AX
	ADDQ	$const_stackGuard, AX
	MOVQ	AX, g_stackguard0(CX)
	MOVQ	AX, g_stackguard1(CX)

#ifndef GOOS_windows
	JMP ok
#endif

// 针对不同操作系统对 TLS 进行设置
needtls:
#ifdef GOOS_plan9
	// skip TLS setup on Plan 9
	JMP ok
#endif
#ifdef GOOS_solaris
	// skip TLS setup on Solaris
	JMP ok
#endif
#ifdef GOOS_illumos
	// skip TLS setup on illumos
	JMP ok
#endif
#ifdef GOOS_darwin
	// skip TLS setup on Darwin
	JMP ok
#endif
#ifdef GOOS_openbsd
	// skip TLS setup on OpenBSD
	JMP ok
#endif

#ifdef GOOS_windows
	CALL	runtime·wintls(SB)
#endif

	LEAQ	runtime·m0+m_tls(SB), DI
	CALL	runtime·settls(SB)

	// 检查 TLS 是否正常工作
	get_tls(BX)
	MOVQ	$0x123, g(BX)
	MOVQ	runtime·m0+m_tls(SB), AX
	CMPQ	AX, $0x123
	JEQ 2(PC)
	CALL	runtime·abort(SB)
ok:
	//设置 g0 和 m0 和 TLS
	get_tls(BX)
	LEAQ	runtime·g0(SB), CX
	MOVQ	CX, g(BX)
	LEAQ	runtime·m0(SB), AX
	MOVQ	CX, m_g0(AX)
	MOVQ	AX, g_m(CX)
	CLD

// 下面的 ifdef NEED_xxx 主要是在检查 CPU 是否支持 Go 运行时系统需要的特性。
// 我们需要在设置了 TLS 之后做这个，
// 如果失败就跳转到 bad_cpu 报告错误。
#ifdef NEED_FEATURES_CX
	MOVL	$0, AX
	CPUID
	CMPL	AX, $0
	JE	bad_cpu
	MOVL	$1, AX
	CPUID
	ANDL	$NEED_FEATURES_CX, CX
	CMPL	CX, $NEED_FEATURES_CX
	JNE	bad_cpu
#endif

#ifdef NEED_MAX_CPUID
	MOVL	$0x80000000, AX
	CPUID
	CMPL	AX, $NEED_MAX_CPUID
	JL	bad_cpu
#endif

#ifdef NEED_EXT_FEATURES_BX
	MOVL	$7, AX
	MOVL	$0, CX
	CPUID
	ANDL	$NEED_EXT_FEATURES_BX, BX
	CMPL	BX, $NEED_EXT_FEATURES_BX
	JNE	bad_cpu
#endif

#ifdef NEED_EXT_FEATURES_CX
	MOVL	$0x80000001, AX
	CPUID
	ANDL	$NEED_EXT_FEATURES_CX, CX
	CMPL	CX, $NEED_EXT_FEATURES_CX
	JNE	bad_cpu
#endif

#ifdef NEED_OS_SUPPORT_AX
	XORL    CX, CX
	XGETBV
	ANDL	$NEED_OS_SUPPORT_AX, AX
	CMPL	AX, $NEED_OS_SUPPORT_AX
	JNE	bad_cpu
#endif

#ifdef NEED_DARWIN_SUPPORT
	MOVQ	$commpage64_version, BX
	CMPW	(BX), $13  // cpu_capabilities64 undefined in versions &lt; 13
	JL	bad_cpu
	MOVQ	$commpage64_cpu_capabilities64, BX
	MOVQ	(BX), BX
	MOVQ	$NEED_DARWIN_SUPPORT, CX
	ANDQ	CX, BX
	CMPQ	BX, CX
	JNE	bad_cpu
#endif

	// 检查完 AMD64 不同操作系统是否支持 Go 运行时系统需要的特性后，
	// 这里执行 runtime·check 对代码做一下运行时检查。
	CALL	runtime·check(SB)

	// 复制 argc（命令行参数的数量）到 AX 寄存器，
	// 然后把 AX 寄存器的值存到栈上。
	MOVL	24(SP), AX
	MOVL	AX, 0(SP)
	// 复制 argv（命令行参数的数组）到 AX 寄存器，
	// 然后把 AX 寄存器的值存到栈上。
	MOVQ	32(SP), AX
	MOVQ	AX, 8(SP)
	// 调用 runtime·args 函数处理命令行参数。
	CALL	runtime·args(SB)
	// 调用 runtime·osinit 函数初始化操作系统特定的设置。
	CALL	runtime·osinit(SB)
	// 调用 runtime·schedinit 函数初始化调度器。
	CALL	runtime·schedinit(SB)

   /**
   	补充：这是该文件下面对 runtime·mainPC 的声明
    // mainPC is a function value for runtime.main, to be passed to newproc.
    // The reference to runtime.main is made via ABIInternal, since the
    // actual function (not the ABI0 wrapper) is needed by newproc.
    DATA	runtime·mainPC+0(SB)/8,$runtime·main&lt;ABIInternal&gt;(SB)
    */

    // 取 runtime·mainPC 的地址，这其实就是 runtime 包下的 main() 方法。
    // 它是 Go 语言程序的真正入口，而不是 main.main()。
	MOVQ	$runtime·mainPC(SB), AX
	PUSHQ	AX
    // 创建一个新的 goroutine 来运行程序的主函数。
    // 这里还没有正在的运行，因为调度器还没有启动，
    // 只是将 runtime.main 放进 goroutine 的 queue 中等待执行。
	CALL	runtime·newproc(SB)
	POPQ	AX

	// 调用 runtime·mstart 函数启动 M（machine，代表一个操作系统线程），
	// 开始执行 goroutines。
	CALL	runtime·mstart(SB)

	//  如果 runtime·mstart 函数返回，那么就调用 runtime·abort 函数终止程序。
	// 因为 runtime·mstart 函数在正常情况下是不应该返回的，如果返回了，说明有错误发生。
	CALL	runtime·abort(SB)	// mstart should never return
	RET

bad_cpu: // 当前 CPU 不支持 Go 运行时系统需要的时候的错误报告。
	MOVQ	$2, 0(SP)
	MOVQ	$bad_cpu_msg&lt;&gt;(SB), AX
	MOVQ	AX, 8(SP)
	MOVQ	$84, 16(SP)
	CALL	runtime·write(SB)
	MOVQ	$1, 0(SP)
	CALL	runtime·exit(SB)
	CALL	runtime·abort(SB)
	RET

	// Prevent dead-code elimination of debugCallV2, which is
	// intended to be called by debuggers.
	MOVQ	$runtime·debugCallV2&lt;ABIInternal&gt;(SB), AX
	RET
</code></pre>
<p>整理一下，<code>runtime·rt0_go</code> 的大体过程如下：</p>
<ol>
<li>读取命令行参数，复制参数变量 argc 和 argv 到栈上。</li>
<li>初始化 g0 栈，g0 是为了调度协程而产生的协程，是 g0 是运行时系统的一个特殊的 goroutine，它在程序启动时被创建，用于执行系统调用和协程调度。</li>
<li>获取 CPU 信息。</li>
<li>如果存在 <code>_cgo_init</code>，这调用它。</li>
<li>检查并设置线性局部存储（TLS）。</li>
<li>检查 CPU 是否支持 Go 运行时系统需要的特性。</li>
<li>完成运行时系统检查和初始化：<ul>
<li>调用 <code>runtime·check</code> 对代码进行运行时检查。</li>
<li>调用 <code>runtime·args</code> 函数处理命令行参数。</li>
<li>调用 <code>runtime·osinit</code> 函数初始化操作系统特定的设置。</li>
<li>调用 <code>runtime·schedinit</code> 函数初始化调度器。</li>
</ul>
</li>
<li>调用 <code>runtime·newproc</code> 创建一个新的 goroutine 来运行程序的主函数。</li>
<li>调用 <code>runtime·mstart</code> 启动当前的 machine，执行 goroutines，执行程序。</li>
</ol>
<p>一句话：<code>runtime·rt0_go</code> 是 Go 语言运行时的入口点，它负责设置和初始化运行时环境，然后创建 g0 和 m0 来运行程序的主函数。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/3EGyo5Q3LRKr4XdNg1dHFr.png" alt="Go 运行时系统初始化流程"></p>
<p>了解完 Go 程序的整体启动流程后，我们重点来分析一下其中的 <code>runtime·check</code>、<code>runtime·args</code>、<code>runtime·osinit</code>、<code>runtime·schedinit</code>、<code>runtime·newproc</code> 和 <code>runtime·mstart</code>。</p>
<blockquote>
<p>对了，充分理解 Go 启动流程，可能需要你对 Go 的 GMP 模型有一定的了解。 // TODO</p>
</blockquote>
<h2>runtime·check</h2>
<p>在 Goland IDE 上，我们双击 Shift，全局搜索 <code>runtime·check</code> 会发现找不到函数的实现。</p>
<p>Go 语言的运行时系统大部分是用 Go 自己编写的，但是有一部分，特别是与平台相关的部分，是用汇编语言编写的。在汇编语言中，调用 Go 函数的一种方式是使用 <code>CALL</code> 指令和函数的全名，包括包名和函数名。在这种情况下，<code>runtime·check</code> 就是调用 <code>runtime</code> 包下的 <code>check()</code> 函数。</p>
<p>所以我们需要双击 Shift，搜索 <code>runtine.check</code>，即将 <code>·</code> 换成 <code>.</code>（后面所有函数均是这个道理）。我们会发现 <code>check()</code> 位于 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/runtime1.go">runtime/runtime1.go</a> 中。</p>
<pre><code class="language-go">func check() {
	var (
		a     int8
		b     uint8
		c     int16
		d     uint16
		e     int32
		f     uint32
		g     int64
		h     uint64
		i, i1 float32
		j, j1 float64
		k     unsafe.Pointer
		l     *uint16
		m     [4]byte
	)
	type x1t struct {
		x uint8
	}
	type y1t struct {
		x1 x1t
		y  uint8
	}
	var x1 x1t
	var y1 y1t
    // 检查各种类型的变量的大小是否符合预期
	if unsafe.Sizeof(a) != 1 {
		throw(&quot;bad a&quot;)
	}
    ...
    // 检查指针操作
	if unsafe.Sizeof(k) != goarch.PtrSize {
		throw(&quot;bad k&quot;)
	}
    ...
	// 检查结构体中字段的偏移量是否符合预期
	if unsafe.Offsetof(y1.y) != 1 {
		throw(&quot;bad offsetof y1.y&quot;)
	}
    // timediv 函数的目的是在 32 位处理器上实现 64 位的除法运算。
    // 由于在 32 位处理器上，64 位的除法运算会被转换为 _divv() 函数调用，
    // 这可能会超出 nosplit 函数的栈限制，所以需要这个特殊的函数来进行处理。
    // //go:nosplit 是一个编译器指令，它告诉编译器不要在这个函数中插入栈分割检查。
    // 这意味着这个函数必须在当前的栈帧中运行，不能增加栈的大小。
    // 如果这个函数需要更多的栈空间，那么它将会导致栈溢出。
	if timediv(12345*1000000000+54321, 1000000000, &amp;e) != 12345 || e != 54321 {
		throw(&quot;bad timediv&quot;)
	}
    // CAS 操作检查
	var z uint32
	z = 1
	if !atomic.Cas(&amp;z, 1, 2) {
		throw(&quot;cas1&quot;)
	}
	...
    // 检查 atomic 原子操作
	m = [4]byte{1, 1, 1, 1}
	atomic.Or8(&amp;m[1], 0xf0)
	if m[0] != 1 || m[1] != 0xf1 || m[2] != 1 || m[3] != 1 {
		throw(&quot;atomicor8&quot;)
	}
    // 测试浮点数 NaN（Not a Number）的行为
	*(*uint64)(unsafe.Pointer(&amp;j)) = ^uint64(0)
	if j == j {
		throw(&quot;float64nan&quot;)
	}
	if !(j != j) {
		throw(&quot;float64nan1&quot;)
	}
    // 测试 64 位原子操作
	testAtomic64()
    // 检查栈大小是否是 2 的 n 次幂
	if fixedStack != round2(fixedStack) {
		throw(&quot;FixedStack is not power-of-2&quot;)
	}
    // 上报编代码的运行时检查中是否有异常
	if !checkASM() {
		throw(&quot;assembly checks failed&quot;)
	}
}
</code></pre>
<p>综上：<code>runtime·check</code> 主要是做一些运行时的检查。</p>
<ol>
<li>使用 <code>unsafe.Sizeof</code> 函数检查各种类型的变量的大小是否符合预期。</li>
<li>使用 <code>unsafe.Offsetof</code> 函数检查结构体中字段的偏移量是否符合预期。</li>
<li>测试 <code>timediv</code> 函数检查在 32 位机器上进行 64 位除法运算的结果是否符合预期。</li>
<li>使用 <code>atomic.Cas</code> 函数（Compare and Swap）进行原子比较和交换测试。</li>
<li>使用 <code>atomic.Or8</code> 和 <code>atomic.And8</code> 函数进行原子位操作测试。</li>
<li>测试浮点数 NaN（Not a Number）的行为。</li>
<li>调用 <code>testAtomic64</code> 函数测试 64 位的原子操作。</li>
<li>检查 <code>fixedStack</code> 栈大小是否是 2 的幂。</li>
<li>调用 <code>checkASM</code> 函数检查汇编代码检查运行时中是否有异常。</li>
</ol>
<h2>runtime·args</h2>
<pre><code class="language-go">package runtime

var (
	argc int32
	argv **byte
)

func args(c int32, v **byte) {
	argc = c
	argv = v
	sysargs(c, v)
}
</code></pre>
<p>这个函数比较简单，就是将命令行参数拷贝到 <code>runtime</code> 包下的全局变量 <code>argc</code> 和 <code>argv</code> 上。后面在 <code>shcedinit()</code> 函数中会调用 <code>goargs()</code> 来遍历 argv 将参数复制到 slice 上。</p>
<pre><code class="language-go">func goargs() {
	if GOOS == &quot;windows&quot; {
		return
	}
	argslice = make([]string, argc)
	for i := int32(0); i &lt; argc; i++ {
		argslice[i] = gostringnocopy(argv_index(argv, i))
	}
}
</code></pre>
<h2>runtime·osinit</h2>
<p>这里函数主要是初始化操作系统特点的设置，可以看到这里针对不同操作系统都做了实现：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231209131939116.png" alt="osinit"></p>
<p>这里我们以 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/os_windows.go">os_windows.go</a> 为例：</p>
<pre><code class="language-go">func osinit() {

    // 获取 asmstdcall 函数的地址，并将其转换为一个不安全的指针。
    // 这通常在需要直接操作内存或进行系统调用的时候使用。
	asmstdcallAddr = unsafe.Pointer(abi.FuncPCABI0(asmstdcall))

    // 加载一些可选的系统调用。
	loadOptionalSyscalls()

    // 阻止显示错误对话框。这可能是为了防止在出现错误时打断用户。
	preventErrorDialogs()

    // 初始化异常处理器，用于处理运行时发生的异常。
	initExceptionHandler()

    // 初始化高分辨率计时器，用于精确的时间测量。
	initHighResTimer()
	timeBeginPeriodRetValue = osRelax(false)

    // 初始化系统目录
	initSysDirectory()

    // 启用长路径支持。
    // 在 Windows 中，路径的长度通常限制为 260 个字符。启用长路径支持可以突破这个限制。
	initLongPathSupport()

    // 码获取处理器的数量并将其赋给 ncpu。
    ncpu = getproccount()

    // 获取内存页的大小并将其赋给 physPageSize，为了后面进行内存管理。
	physPageSize = getPageSize()

    // 调用 SetProcessPriorityBoost 函数，禁用动态优先级提升。
    // 在 Windows 中，动态优先级提升是一种机制，可以根据线程的类型和行为自动调整其优先级。
    // 但在 Go 的环境中，所有的线程都是等价的，都可能进行 GUI、IO、计算等各种操作，
    // 所以动态优先级提升可能会带来问题，因此这里选择禁用它。
	stdcall2(_SetProcessPriorityBoost, currentProcess, 1)
}
</code></pre>
<h2>runtime·schedinit ★</h2>
<p>这个函数就非常重要了，从名字就可以看出来，这是 Go 语言调度器的初始化过程。这个函数位于：<a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/proc.go">runtime/proc.go</a>。</p>
<p>我们可以先来看看 <code>schedinit()</code> 的函数注释，这里也透露了 Go 语言程序的启动流程的核心顺序。</p>
<pre><code class="language-go">// The bootstrap sequence is:				启动流程顺序：
//
//	call osinit							  	1. 调用 osinit
//	call schedinit							2. 调用 schedinit
//	make &amp; queue new G						3. 创建一个协程 G
//	call runtime·mstart						4. 调用 mstart
//
// The new G calls runtime·main.			 5. G 执行 runtime.main
func schedinit() {}
</code></pre>
<p>接下来我们详细来看看 <code>schedinit()</code> 都做了些什么：</p>
<pre><code class="language-go">func schedinit() {

    // 初始化各种锁，其中 lockRankXXX 指定锁的级别。
	lockInit(&amp;sched.lock, lockRankSched)
	lockInit(&amp;sched.sysmonlock, lockRankSysmon)
	lockInit(&amp;sched.deferlock, lockRankDefer)
	lockInit(&amp;sched.sudoglock, lockRankSudog)
	lockInit(&amp;deadlock, lockRankDeadlock)
	lockInit(&amp;paniclk, lockRankPanic)
	lockInit(&amp;allglock, lockRankAllg)
	lockInit(&amp;allpLock, lockRankAllp)
	lockInit(&amp;reflectOffs.lock, lockRankReflectOffs)
	lockInit(&amp;finlock, lockRankFin)
	lockInit(&amp;cpuprof.lock, lockRankCpuprof)
	traceLockInit()
	lockInit(&amp;memstats.heapStats.noPLock, lockRankLeafRank)

	// 如果启用了竞态检测，则初始化竞态检测器，
    // 即我们使用 -race 的时候会执行这里。
	gp := getg()
	if raceenabled {
		gp.racectx, raceprocctx0 = raceinit()
	}

    // 限制 M 的数量，即线程的数量。
    // maxmcount    int32    // maximum number of m&#39;s allowed (or die)
	sched.maxmcount = 10000

	// 将调度器设置为初始暂停状态，在必要的初始化完成之前不调度任何协程。
	worldStopped()

    // 进行一系列的系统初始化（内存管理、CPU 设置、栈、算法等）
	moduledataverify()
	stackinit()
	mallocinit()
	godebug := getGodebugEarly()
	initPageTrace(godebug) // must run after mallocinit but before anything allocates
	cpuinit(godebug)       // must run before alginit
	alginit()              // maps, hash, fastrand must not be used before this call
	fastrandinit()         // must run before mcommoninit
	mcommoninit(gp.m, -1)
	modulesinit()   // provides activeModules
	typelinksinit() // uses maps, activeModules
	itabsinit()     // uses activeModules
	stkobjinit()    // must run before GC starts

    // 设置和保存当前 M 的信号掩码
	sigsave(&amp;gp.m.sigmask)
	initSigmask = gp.m.sigmask

    // 解析程序参数和环境变量
	goargs()
	goenvs()
	secure()
	parsedebugvars()

    // 初始化垃圾回收器
	gcinit()

	// 如果设置了 disableMemoryProfiling，即禁用内存分析，
    // 则将 MemProfileRate 置为 0，关闭内存分析。
	if disableMemoryProfiling {
		MemProfileRate = 0
	}

    // 锁定调度器，处理环境变量 GOMAXPROCS，这是开发者可以设置的允许的最多的 P 的数量。
	lock(&amp;sched.lock)
	sched.lastpoll.Store(nanotime())
	procs := ncpu
	if n, ok := atoi32(gogetenv(&quot;GOMAXPROCS&quot;)); ok &amp;&amp; n &gt; 0 {
		procs = n
	}
	if procresize(procs) != nil {
		throw(&quot;unknown runnable goroutine during bootstrap&quot;)
	}
	unlock(&amp;sched.lock)

	// 将调度器设置为开始状态。
	worldStarted()

    // 确保构建版本和模块信息被保留在最终的二进制文件中。
	if buildVersion == &quot;&quot; {
		buildVersion = &quot;unknown&quot;
	}
	if len(modinfo) == 1 {
		modinfo = &quot;&quot;
	}
}
</code></pre>
<p>总结：<code>schedinit</code> 是 Go 语言运行时中的一个函数，负责初始化调度器及其相关组件，如锁、信号掩码、内存分配、以及其他系统级别的设置，确保并发执行环境的正确配置和高效运作。</p>
<p>具体过程如下：</p>
<ol>
<li><strong>锁初始化</strong>:<ul>
<li>函数开始时，通过 <code>lockInit</code> 调用初始化了多个锁。在 Go 的调度器中，锁用于保护共享资源和调度数据结构，确保在多个线程或协程中的安全访问。每个锁都有一个特定的级别，这有助于防止死锁。</li>
</ul>
</li>
<li><strong>竞态检测器初始化</strong>:<ul>
<li>如果启用了竞态检测 (<code>raceenabled</code>)，则初始化竞态上下文。这对于在开发阶段检测和避免竞态条件非常重要。</li>
</ul>
</li>
<li><strong>调度器设置</strong>:<ul>
<li><code>sched.maxmcount = 10000</code> 设置调度器可以管理的最大线程（M）数目，这对于控制资源使用和性能调优很重要。</li>
<li><code>worldStopped()</code> 将调度器设置为初始暂停状态，在必要的初始化完成之前不调度任何协程。</li>
</ul>
</li>
<li><strong>系统初始化</strong>:<ul>
<li>接下来调用一系列函数（如 <code>moduledataverify</code>, <code>mallocinit</code>, <code>cpuinit</code>, <code>alginit</code> 等）来初始化内存管理、CPU 设置、算法等，这些都是调度器正常工作的基础。</li>
</ul>
</li>
<li><strong>环境和调试变量设置</strong>:<ul>
<li>解析程序参数、环境变量、安全设置和调试变量。</li>
</ul>
</li>
<li><strong>垃圾收集器初始化</strong>:<ul>
<li><code>gcinit()</code> 初始化垃圾收集器，这是 Go 运行时的关键组成部分，负责自动内存管理。</li>
</ul>
</li>
<li><strong>内存分析设置</strong>:<ul>
<li>根据 <code>disableMemoryProfiling</code> 标志决定是否关闭内存分析功能。</li>
</ul>
</li>
<li><strong>处理器数量设置和调度器锁</strong>:<ul>
<li>锁定调度器来安全地基于环境变量 <code>GOMAXPROCS</code> 设置处理器（<code>procs</code>）数量。</li>
<li>使用 <code>procresize</code> 函数根据处理器数量调整调度器的内部结构。</li>
</ul>
</li>
<li><strong>最终步骤和错误检查</strong>:<ul>
<li>调用 <code>worldStarted()</code> 表示调度器已准备好开始调度协程。</li>
<li>检查和设置构建版本和模块信息，保证这些信息在最终的二进制文件中。</li>
</ul>
</li>
</ol>
<p>这里有几个地方比较有趣，我们来做一下简单的了解。（可跳过）</p>
<h3>初始化锁 lockInit(mutex, rank)</h3>
<p>我们知道 <code>lockInit(mutex,rank)</code> 是用来初始化锁的，第 2 个参数 <code>rank</code> 便是锁的等级。如果这个时候你链接到 <code>lcokInit</code> 实现的地方，你会发现默认会跳到 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/lockrank_off.go">lockrank_off.go</a>，而且你会发现，它的实现是空的：</p>
<pre><code class="language-go">//go:build !goexperiment.staticlockranking

package runtime

func lockInit(l *mutex, rank lockRank) {
}
</code></pre>
<p>其实 <code>lockInit</code> 还有另外一个实现，在 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/lockrank_on.go">lockrank_on.go</a> 文件中：</p>
<pre><code class="language-go">//go:build goexperiment.staticlockranking

package runtime

const staticLockRanking = true

func getLockRank(l *mutex) lockRank {
	return l.rank
}
</code></pre>
<p>这什么意思呢？通过文件名称我们其实就可以猜到了，<code>lockrank_off.go</code> 是提供了无锁级别的锁，而 <code>lockrank_on.go</code> 是提供了有锁级别的锁。至于应该采用哪一个，是通过 go build 中的 <code>goexperiment.staticlockranking</code> 参数来控制的。</p>
<p>这里涉及一个概念，叫做锁排序（Lock Ranking）：</p>
<ul>
<li>锁排序是一种用于避免死锁的技术。在这种机制中，每个锁都被赋予一个等级（或称为 “rank”），并且有规则确保锁的获取遵循这些等级的顺序。</li>
<li>通常，这意味着一个线程在获取等级较低的锁之前，必须先释放所有等级较高的锁。这样可以防止死锁，因为它避免了循环等待条件的发生。</li>
<li>Go 语言中锁的等级和顺序定义在 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/lockrank.go">lockrank.go</a> 文件中。</li>
</ul>
<p>锁排序的作用：</p>
<ul>
<li>在 Go 的并发模型中，锁是同步共享资源访问的重要机制。<code>lockInit</code> 函数在运行时初始化锁，为其分配等级，有助于维护程序的稳定性和性能。</li>
<li>锁排序功能的开启或关闭取决于是否需要额外的死锁检测。在开发和调试阶段，开启锁排序可以帮助发现死锁问题。然而，它可能引入额外的性能开销，因此在生产环境中可能会被关闭。</li>
</ul>
<p>最后我们来看一下 <code>schedinit()</code> 都初始化了哪些锁：</p>
<table>
<thead>
<tr>
<th>Lock</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td>sched.lock</td>
<td>初始化调度器的主锁。这个锁用于控制对调度器的访问，保证调度过程的正确性。</td>
</tr>
<tr>
<td>sched.sysmonlock</td>
<td>系统监控锁，用于保护系统监控相关的数据结构。</td>
</tr>
<tr>
<td>sched.deferlock</td>
<td>用于控制延迟执行函数列表的锁。</td>
</tr>
<tr>
<td>sched.sudoglock</td>
<td>sudog 是 Go 中表示等待通信的 goroutine 的结构。这个锁保护与 sudog 相关的操作。</td>
</tr>
<tr>
<td>deadlock</td>
<td>可能用于检测或防止死锁的锁。</td>
</tr>
<tr>
<td>paniclk</td>
<td>在处理 panic 时使用的锁。</td>
</tr>
<tr>
<td>allglock</td>
<td>用于控制对所有 goroutine 列表的访问。</td>
</tr>
<tr>
<td>allpLock</td>
<td>控制对所有处理器（P）的访问。</td>
</tr>
<tr>
<td>reflectOffs.lock</td>
<td>用于反射操作的锁。</td>
</tr>
<tr>
<td>finlock</td>
<td>管理终结器列表的锁。</td>
</tr>
<tr>
<td>cpuprof.lock</td>
<td>用于 CPU 分析数据的锁。</td>
</tr>
<tr>
<td>traceLockInit()</td>
<td>专门用于追踪系统的锁初始化函数。</td>
</tr>
<tr>
<td>memstats.heapStats.noPLock</td>
<td>这是一个特殊的锁，被标记为 <code>lockRankLeafRank</code>，意味着它应该是锁层级中的最末端（leaf）。这样的锁应该只在非常短的关键部分中使用，以避免成为死锁的源头。</td>
</tr>
</tbody></table>
<h3>信号掩码 initSigmask</h3>
<p>这两行代码是在搞啥呢？</p>
<pre><code class="language-go">sigsave(&amp;gp.m.sigmask)
initSigmask = gp.m.sigmask
</code></pre>
<ul>
<li><code>sigmask</code> 的中文意思是 <code>信号掩码</code>。</li>
</ul>
<p>先看一下源码中 <code>initSigmast</code> 的注释：</p>
<pre><code class="language-go">// Value to use for signal mask for newly created M&#39;s.
var initSigmask sigset
</code></pre>
<ul>
<li><code>initSigmask</code> 是一个变量，存储着用于新创建的 M（Machine，即操作系统线程）的初始信号掩码。</li>
</ul>
<p>什么是信号掩码：</p>
<ul>
<li>信号掩码是操作系统中用于控制信号传递给进程或线程的一种机制。它允许进程或线程指定哪些信号可以被阻塞（暂时忽略）或允许。在多线程环境中，这个机制尤其重要，因为它帮助确保线程安全地处理信号。</li>
</ul>
<p>信号掩码的作用：</p>
<ul>
<li>信号掩码定义了一组信号，这些信号在特定时间内不会传递给进程或线程，即使这些信号发生了也会被系统挂起。这允许进程或线程在一个稳定的状态下运行，不被特定信号中断。</li>
<li>这种机制对于处理那些可能在关键操作期间导致不稳定状态的信号特别重要。</li>
</ul>
<p>信号掩码的重要性：</p>
<ul>
<li>在多线程程序中，不同的线程可能需要响应不同的信号或以不同方式处理相同的信号。通过为每个线程设置适当的信号掩码，可以确保线程只处理对它们来说重要的信号。</li>
<li>这有助于防止线程在执行关键代码时被不相关的信号打断。</li>
</ul>
<p><strong>sigsave(&amp;gp.m.sigmask)</strong>：</p>
<ul>
<li><code>sigsave(&amp;gp.m.sigmask)</code> 这个调用是在保存当前 M 的信号掩码。<code>gp</code> 指的是当前的 goroutine，<code>gp.m</code> 是该 goroutine 正在运行的 M（操作系统线程）。</li>
<li><code>sigsave</code> 函数的作用是将 <code>gp.m</code> 的当前信号掩码保存到提供的地址（在这里是 <code>&amp;gp.m.sigmask</code>）。这对于恢复线程的信号掩码到一个已知状态是非常有用的。</li>
</ul>
<p><strong>initSigmask = gp.m.sigmask</strong>：</p>
<ul>
<li>这一行将 <code>gp.m</code> 的信号掩码赋值给 <code>initSigmask</code>。这意味着 <code>initSigmask</code> 现在保存了当前 M 的信号掩码，这个掩码将被用作新创建的 M 的初始信号掩码。</li>
<li>这是一个重要的步骤，因为它确保了所有新创建的 M 都将具有与当前 M 相同的信号处理行为。</li>
<li>这意味着所有新线程都会以一致的信号掩码启动，这有助于避免由于不同线程处理信号的不一致性导致的问题。</li>
</ul>
<p>总体来说，Go 语言在其运行时中这样处理信号掩码，是为了确保在并发执行和线程调度中能够安全、一致地处理信号，这对于维护高效和稳定的运行时环境至关重要。</p>
<h3>初始化垃圾回收器 gcinit()</h3>
<pre><code class="language-go">func gcinit() {
    // 检查 workbuf 结构体的大小是否等于预期的 _WorkbufSize。
    // 如果不是，抛出异常。这是为了确保 workbuf 的大小是最优的，
    // workbuf 用于垃圾回收过程中的内部工作。
    if unsafe.Sizeof(workbuf{}) != _WorkbufSize {
       throw(&quot;size of Workbuf is suboptimal&quot;)
    }
    // 第一个垃圾回收周期不进行扫描操作。
    // 在 Go 的垃圾回收过程中，扫描是回收前清理内存的重要步骤。
    sweep.active.state.Store(sweepDrainedMask)

    // 使用环境变量 GOGC 和 GOMEMLIMIT 来设置初始的垃圾回收百分比和内存限制。
    gcController.init(readGOGC(), readGOMEMLIMIT())

    // 初始化用于控制垃圾回收工作流程的信号量。
    // 这些信号量用于同步垃圾回收过程中的不同阶段。
    work.startSema = 1
    work.markDoneSema = 1

    // 初始化了用于垃圾回收过程中的各种锁。
    // 这些锁用于保护垃圾回收相关数据结构的并发访问，确保垃圾回收过程的线程安全。
    lockInit(&amp;work.sweepWaiters.lock, lockRankSweepWaiters)
    lockInit(&amp;work.assistQueue.lock, lockRankAssistQueue)
    lockInit(&amp;work.wbufSpans.lock, lockRankWbufSpans)
}

func (c *gcControllerState) init(gcPercent int32, memoryLimit int64) {
    // 设置 heapMinimum 为默认的最小堆大小。
    // 这是垃圾回收器考虑启动新回收周期前的最小堆内存大小。
	c.heapMinimum = defaultHeapMinimum

    // 将 triggered 设置为 uint64 的最大值。
    // 这个字段用于表示触发垃圾回收的内存阈值，
    // 这里的设置意味着在初始状态下不会自动触发垃圾回收
	c.triggered = ^uint64(0)

    // 设置垃圾回收的百分比阈值。
    // gcPercent 参数表示触发垃圾回收的内存增长百分比。
    // 这个设置控制了堆内存增长到多少百分比时会触发垃圾回收。
	c.setGCPercent(gcPercent)

    // 设置内存限制。
    // memoryLimit 参数可能表示堆内存的最大限制，
    // 用于控制垃圾回收器在内存使用方面的行为。
	c.setMemoryLimit(memoryLimit)

    // 提交垃圾回收控制器的当前设置，并指示第一次垃圾回收周期没有扫描（sweep）阶段。
    // 在 Go 的垃圾回收中，扫描是回收周期的一部分，这里指明在第一次垃圾回收时跳过扫描阶段。
	c.commit(true)
}
</code></pre>
<h2>runtime·newproc ★</h2>
<p>初始化完调度器后，就进入到创建 g0 的阶段了，我们需要一个协程来运行程序的入口：<code>runtime.main</code>。</p>
<p><code>newproc()</code> 的作用如注释所说：<strong>创建一个新的 goroutine 来执行 <code>fn</code>，并将它放入等待运行的 g 队列中。</strong></p>
<pre><code class="language-go">// Create a new g running fn.
// Put it on the queue of g&#39;s waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
	gp := getg()				// 获取当前协程
	pc := getcallerpc()			// 获取当前程序计数器
	systemstack(func() {		// 在系统栈上执行新 goroutine 的创建
		newg := newproc1(fn, gp, pc)	// 创建新 goroutine

		pp := getg().m.p.ptr()	// 获取当前 M 绑定的 P
		runqput(pp, newg, true)	// 将新创建的 goroutine 放入 P 的本地队列中

		if mainStarted {
			wakep()	// 如果主程序已经启动，则唤醒或启动一个 M，以确保新的 goroutine 有机会被执行
		}
	})
}
</code></pre>
<p>重点来看一下 <code>newproc1()</code> 和 <code>runqput()</code>。</p>
<h3>创建协程 newproc1()</h3>
<p>这段代码的主要作用是创建一个新的 goroutine 并设置其初始状态，以便它可以被调度器安排运行。它处理了从分配 goroutine 的内存到设置其栈空间和调度信息等一系列步骤。</p>
<pre><code class="language-go">// 创建一个新的 goroutine，状态为 _Grunnable，从函数 fn 开始执行。
// callerpc 是创建此 goroutine 的 go 语句的地址。
// 调用者负责将新的 g 添加到调度器中。
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
	if fn == nil {
		fatal(&quot;go of nil func value&quot;) // 如果 fn 是 nil，抛出致命错误
	}

	mp := acquirem() // 禁用抢占，因为我们在局部变量中持有 M 和 P
	pp := mp.p.ptr()
	newg := gfget(pp) // 尝试从 P 的空闲列表中获取一个 g
	if newg == nil {
		newg = malg(stackMin) // 如果没有空闲的 g，创建一个新的
		casgstatus(newg, _Gidle, _Gdead) // 将 g 的状态从 _Gidle 改为 _Gdead
		allgadd(newg) // 将新的 g 添加到所有 goroutine 的列表中
	}
	if newg.stack.hi == 0 {
		throw(&quot;newproc1: newg missing stack&quot;) // 如果新的 g 没有栈，抛出异常
	}

	if readgstatus(newg) != _Gdead {
		throw(&quot;newproc1: new g is not Gdead&quot;) // 确保新的 g 的状态为 _Gdead
	}

	totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize) // 计算额外的栈空间大小
	totalSize = alignUp(totalSize, sys.StackAlign) // 栈空间对齐
	sp := newg.stack.hi - totalSize // 设置栈指针
	spArg := sp
	if usesLR {
		*(*uintptr)(unsafe.Pointer(sp)) = 0 // 针对 LR 架构，设置调用者的 LR
		prepGoExitFrame(sp)
		spArg += sys.MinFrameSize
	}

	memclrNoHeapPointers(unsafe.Pointer(&amp;newg.sched), unsafe.Sizeof(newg.sched)) // 清除调度器的内存
	newg.sched.sp = sp // 设置调度器的栈指针
	newg.stktopsp = sp // 设置栈顶指针
	// 设置调度器的程序计数器，+PCQuantum 使得前一个指令在同一函数中
	newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum
	newg.sched.g = guintptr(unsafe.Pointer(newg)) // 设置调度器的 g 指针
	gostartcallfn(&amp;newg.sched, fn) // 启动新的 g 执行函数 fn
	newg.parentGoid = callergp.goid // 设置新 g 的父 goroutine ID
	newg.gopc = callerpc // 设置新 g 的创建位置
	newg.ancestors = saveAncestors(callergp) // 保存祖先信息
	newg.startpc = fn.fn // 设置新 g 的起始函数地址
	if isSystemGoroutine(newg, false) {
		sched.ngsys.Add(1) // 如果是系统 goroutine，增加计数
	} else {
		if mp.curg != nil {
			newg.labels = mp.curg.labels // 只有用户 goroutines 继承 pprof 标签
		}
		if goroutineProfile.active {
			newg.goroutineProfiled.Store(goroutineProfileSatisfied) // 标记不需要纳入 goroutine 分析
		}
	}
	newg.trackingSeq = uint8(fastrand()) // 设置追踪序列号
	if newg.trackingSeq%gTrackingPeriod == 0 {
		newg.tracking = true // 是否启用追踪
	}
	casgstatus(newg, _Gdead, _Grunnable) // 将新 g 的状态从 _Gdead 改为 _Grunnable
	gcController.addScannableStack(pp, int64(newg.stack.hi-newg.stack.lo)) // 将新 g 的栈添加到可扫描栈列表

	if pp.goidcache == pp.goidcacheend {
		pp.goidcache = sched.goidgen.Add(_GoidCacheBatch) // 分配新的 goroutine ID
		pp.goidcache -= _GoidCacheBatch - 1
		pp.goidcacheend = pp.goidcache + _GoidCacheBatch
	}
	newg.goid = pp.goidcache // 设置新 g 的 ID
	pp.goidcache++
	if raceenabled {
		newg.racectx = racegostart(callerpc) // 设置竞态检测上下文
		newg.raceignore = 0
		if newg.labels != nil {
			racereleasemergeg(newg, unsafe.Pointer(&amp;labelSync)) // 同步竞态检测和信号处理
		}
	}
	if traceEnabled() {
		traceGoCreate(newg, newg.startpc) // 记录追踪信息
	}
	releasem(mp) // 释放当前 M

	return newg // 返回新创建的 goroutine
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231210231623561.png" alt="newproc1() 函数概述"></p>
<p>有几个地方比较有趣，我们可以来研究一下。</p>
<h4>获取 g</h4>
<pre><code class="language-go">// The minimum size of stack used by Go code
stackMin = 2048

func newproc1() {
    ...
    newg := gfget(pp) // 尝试从 P 的空闲列表中获取一个 g
    if newg == nil {
        newg = malg(stackMin) // 如果没有空闲的 g，创建一个新的
        casgstatus(newg, _Gidle, _Gdead) // 将 g 的状态从 _Gidle 改为 _Gdead
        allgadd(newg) // 将新的 g 添加到所有 goroutine 的列表中
    }
    ...
}
</code></pre>
<p>这里可以看出，Go 语言每个新创建的协程分配的默认大小就是 <code>stackMin</code>，即 <code>2KB</code>。</p>
<p>其实 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/stack.go">statck.go</a> 还定义了另外一个字段，即栈最大为 <code>2GB</code>，所以我们可以知道，Go 协程栈大小 <code>[2KB, 2GB]</code>。</p>
<pre><code class="language-go">var maxstacksize uintptr = 1 &lt;&lt; 20 // enough until runtime.main sets it for real
</code></pre>
<p>另外我们可以看出来，Go 语言会尽可能重用现有的空闲 goroutine，以减少内存分配的开销，提供创建新 goroutine 的效率。</p>
<p>重用的具体逻辑在 <code>gfget(pp)</code> 中，这个函数的作用是从与当前 M 绑定的 P 的空闲列表中获取一个空闲的 g，如果没有，则尝试从全局空闲列表中获取 g。</p>
<pre><code class="language-go">// Get from gfree list.
// If local list is empty, grab a batch from global list.
func gfget(pp *p) *g {
retry:
    // 如果当前 P 的空闲队列为空，并且全局空闲队列中有可用的 goroutine，则进行下列操作。
	if pp.gFree.empty() &amp;&amp; (!sched.gFree.stack.empty() || !sched.gFree.noStack.empty()) {
        // 枷锁全局空闲列表
		lock(&amp;sched.gFree.lock)
		// 将最多 32 个空闲的 g 从全局列表中移动到当前 P 的空闲列表
		for pp.gFree.n &lt; 32 {
			// 优先选择有栈的 g
			gp := sched.gFree.stack.pop()
			if gp == nil {
                // 实在没有栈，也可以接受
				gp = sched.gFree.noStack.pop()
				if gp == nil {
					break
				}
			}
			sched.gFree.n--
			pp.gFree.push(gp)
			pp.gFree.n++
		}
		unlock(&amp;sched.gFree.lock)
        // 一直尝试，知道 P 有空闲 g，或者全局列表也没有空闲 g 了，就退出 for 循环，进行下面的操作。
		goto retry
	}

    // 从 P 的空闲列表中取出一个 g
	gp := pp.gFree.pop()
	if gp == nil {
		return nil
	}
	pp.gFree.n--

    // 检查获取到的 g 是否有一个有效的栈，如果栈不符合预期的大小，则释放旧栈
	if gp.stack.lo != 0 &amp;&amp; gp.stack.hi-gp.stack.lo != uintptr(startingStackSize) {
		systemstack(func() {
			stackfree(gp.stack)
			gp.stack.lo = 0
			gp.stack.hi = 0
			gp.stackguard0 = 0
		})
	}
    // 如果 g 没有有效的栈，或者刚刚被释放了，则分配新栈给 g
	if gp.stack.lo == 0 {
		systemstack(func() {
			gp.stack = stackalloc(startingStackSize)
		})
		gp.stackguard0 = gp.stack.lo + stackGuard
	} else {
		if raceenabled {
			racemalloc(unsafe.Pointer(gp.stack.lo), gp.stack.hi-gp.stack.lo)
		}
		if msanenabled {
			msanmalloc(unsafe.Pointer(gp.stack.lo), gp.stack.hi-gp.stack.lo)
		}
		if asanenabled {
			asanunpoison(unsafe.Pointer(gp.stack.lo), gp.stack.hi-gp.stack.lo)
		}
	}
	return gp
}
</code></pre>
<p>其中 <code>startingStackSize</code> 表示新创建的 goroutine 开始时的栈大小。它被初始化为 <code>fixedStack</code> 的值。<code>startingStackSize</code> 在每次垃圾回收（GC）后可能会更新，以反映在 GC 过程中扫描的栈的平均大小。</p>
<pre><code class="language-go">var startingStackSize uint32 = fixedStack
func gcComputeStartingStackSize() {
	...
    // 求出栈平均大小
	avg := scannedStackSize/scannedStacks + stackGuard

	if avg &gt; uint64(maxstacksize) {
		avg = uint64(maxstacksize)
	}
	if avg &lt; fixedStack {
		avg = fixedStack
	}
    // 更新 startingStackSize
	startingStackSize = uint32(round2(int32(avg)))
}
</code></pre>
<h4>初始化协程栈</h4>
<pre><code class="language-go">totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize) // 计算额外的栈空间大小
totalSize = alignUp(totalSize, sys.StackAlign) // 栈空间对齐
sp := newg.stack.hi - totalSize // 设置栈指针
spArg := sp
if usesLR {
    *(*uintptr)(unsafe.Pointer(sp)) = 0 // 针对 LR 架构，设置调用者的 LR
    prepGoExitFrame(sp)
    spArg += sys.MinFrameSize
}
</code></pre>
<p><strong>1. 计算额外的栈空间大小</strong>:</p>
<p><code>totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize)</code> 这行代码计算新 goroutine 需要的额外栈空间大小。</p>
<p><code>4*goarch.PtrSize</code> 是为了留出足够的空间来存储函数调用过程中的一些额外信息（例如返回地址、寄存器保存等）。</p>
<p><code>sys.MinFrameSize</code> ：是系统为每个栈帧保留的最小空间，用于存储一些特定于架构的信息。</p>
<pre><code class="language-go">// MinFrameSize is the size of the system-reserved words at the bottom
// of a frame (just above the architectural stack pointer).
// It is zero on x86 and PtrSize on most non-x86 (LR-based) systems.
// On PowerPC it is larger, to cover three more reserved words:
// the compiler word, the link editor word, and the TOC save word.
const MinFrameSize = goarch.MinFrameSize
</code></pre>
<p><code>goarch.PtrSize</code>：指针大小。</p>
<pre><code class="language-go">// PtrSize is the size of a pointer in bytes - unsafe.Sizeof(uintptr(0)) but as an ideal constant.
// It is also the size of the machine&#39;s native word size (that is, 4 on 32-bit systems, 8 on 64-bit).
const PtrSize = 4 &lt;&lt; (^uintptr(0) &gt;&gt; 63)
</code></pre>
<p><strong>2. 栈空间对齐</strong>:</p>
<p><code>totalSize = alignUp(totalSize, sys.StackAlign)</code>: 根据系统的栈对齐要求调整 <code>totalSize</code> 的大小。栈对齐是为了确保栈上的数据按照硬件要求的边界对齐，这通常是为了提高访问效率或满足特定的硬件要求。</p>
<p><strong>3. 设置栈指针 (<code>sp</code>)</strong>:</p>
<p><code>sp := newg.stack.hi - totalSize</code>: 计算新 goroutine 的栈顶指针。<code>newg.stack.hi</code> 是分配给这个 goroutine 的栈的高地址（栈顶），从这里向下分配空间。通过从栈顶地址减去计算出的 <code>totalSize</code>，设置新的栈顶位置。</p>
<p><strong>4. 处理链接寄存器（LR）架构</strong>:</p>
<p>在某些架构上（如 ARM、PowerPC），函数调用的返回地址不是存储在栈上，而是存储在一个名为链接寄存器（LR）的特殊寄存器中。这几行代码检查是否在这种架构上运行 (<code>usesLR</code>)。</p>
<ul>
<li>如果是，则在栈上的适当位置存储一个 0 值作为返回地址，并调用 <code>prepGoExitFrame</code> 来准备 goroutine 退出时的栈帧。这是为了模拟在非 LR 架构上的栈帧结构。</li>
<li><code>spArg</code> 是一个辅助变量，用于记录参数传递时应该使用的栈地址。在 LR 架构上，它需要根据 <code>sys.MinFrameSize</code> 进行调整，以保证函数参数的正确位置。</li>
</ul>
<h3>放入队列 runqput()</h3>
<p><code>runqput()</code> 负责将 goroutine (<code>gp</code>) 放入到本地可运行队列或全局队列中。</p>
<pre><code class="language-go">// runqput 尝试将 g 放入当前的执行队列中
// 如果 next=false，则将 g 放在队列末尾，
// 如果 next=true，则将 g 放在 pp.runnext，即下一个要执行的 goroutine。
// 如果本地队列满了，则加入到全局队列。
func runqput(pp *p, gp *g, next bool) {
    // 如果启用了随机调度器 (randomizeScheduler)，
    // 并且调用者指示将 goroutine 放入 runnext 位置 (next 为 true)，
    // 则有 50% 的概率将 next 设置为 false，以随机地将 goroutine 放入队列尾部。
	if randomizeScheduler &amp;&amp; next &amp;&amp; fastrandn(2) == 0 {
		next = false
	}

	if next {
	retryNext:
        // 如果为 next，则尝试将 p 放入 pp.runnext 插槽
		oldnext := pp.runnext
		if !pp.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
			goto retryNext
		}
        // 如果这个槽之前没有被占用，则直接返回
		if oldnext == 0 {
			return
		}
        // 如果这个槽之前已经被占用了，则剔除旧 goroutine，
        // 然后进行下面的逻辑，即将其放入常规的运行队列中
		gp = oldnext.ptr()
	}

retry:
	h := atomic.LoadAcq(&amp;pp.runqhead) // 取出队列头部
	t := pp.runqtail				// 取出队列尾部
    // 如果还没满，则将 gp 放入本地队列中（可能是新 g，也可能是之前在 runnext 的 g
	if t-h &lt; uint32(len(pp.runq)) {
		pp.runq[t%uint32(len(pp.runq))].set(gp)
		atomic.StoreRel(&amp;pp.runqtail, t+1) // store-release, makes the item available for consumption
		return
	}
    // 如果本地队列满了，则尝试将其放入全局队列中
	if runqputslow(pp, gp, h, t) {
		return
	}
	goto retry
}
</code></pre>
<p>其中 <code>runqputslow()</code> 不仅会尝试将 <code>gp</code> 放入全局队列中，还会尝试将本地队列的部分 g 放入全局队列中，因为这个时候本地队列已经满了，放入全局队列中就有机会被其他 P 所调度，减少饥饿。</p>
<pre><code class="language-go">// Put g and a batch of work from local runnable queue on global queue.
// Executed only by the owner P.
func runqputslow(pp *p, gp *g, h, t uint32) bool {

    // 初始化一个数组 batch，大小为本地队列的一半，
    // 它用来存储将要移动到全局队列的 goroutine。
	var batch [len(pp.runq)/2 + 1]*g

	n := t - h
	n = n / 2
    // 只有本地队列满才这么操作
	if n != uint32(len(pp.runq)/2) {
		throw(&quot;runqputslow: queue is not full&quot;)
	}
    // 将本地队列一半的 g 复制到 batch 中
	for i := uint32(0); i &lt; n; i++ {
		batch[i] = pp.runq[(h+i)%uint32(len(pp.runq))].ptr()
	}
    // CAS 更新本地队列头指针，如果失败，则返回
	if !atomic.CasRel(&amp;pp.runqhead, h, h+n) { // cas-release, commits consume
		return false
	}
    // 将当前要调度的 gp 放入 batch 的末尾
	batch[n] = gp

    // 如果启动了随机调度器，则随机化 batch 数组
	if randomizeScheduler {
		for i := uint32(1); i &lt;= n; i++ {
			j := fastrandn(i + 1)
			batch[i], batch[j] = batch[j], batch[i]
		}
	}

	// 链接 goroutine，以便它们可以作为一个连续的队列被处理
	for i := uint32(0); i &lt; n; i++ {
		batch[i].schedlink.set(batch[i+1])
	}
	var q gQueue
	q.head.set(batch[0])
	q.tail.set(batch[n])

	// 将 batch 放入全局队列中
	lock(&amp;sched.lock)
	globrunqputbatch(&amp;q, int32(n+1))
	unlock(&amp;sched.lock)
	return true
}
</code></pre>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231210231353833.png" alt="runqput() 函数概述"></p>
<h3>总结</h3>
<p>到这里我们可以总结，<code>runtime·newproc</code> 的核心功能就是初始化一个新的 goroutine，并将其放入队列中进行调度。其中</p>
<ul>
<li><code>newproc1()</code> 会新建或复用空闲的 goroutine，然后初始化其栈空间和调度信息；</li>
<li><code>runqput()</code> 会优先将 g 放入本地队列中调度，如果本地队列满了，会连带本地队列中一半的 goroutine 一起转移到全局队列中调度。</li>
</ul>
<h2>runtime·mstart</h2>
<p>调度器 m0 和主协程 g0 都初始化完毕了，这个时候就可以启动调度器来调度协程工作了。</p>
<p>我们找到 <code>mstart()</code> 函数的声明，位于：<a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/proc.go">proc.go</a></p>
<pre><code class="language-go">// mstart is the entry-point for new Ms.
// It is written in assembly, uses ABI0, is marked TOPFRAME, and calls mstart0.
func mstart()
</code></pre>
<p>可以看到，这里 <code>mstart()</code> 是没用函数体的，通过注释我们可以知道这个函数的实现部分是用汇编实现的，Go 编译器会在编译的时候往这个函数里面插入相关指令。另外注释也告诉我们，这里最终其实就是调用 <code>mstart0()</code> 函数。我们找到相关的汇编代码，果然是如此：</p>
<pre><code class="language-armasm">TEXT runtime·mstart(SB),NOSPLIT|TOPFRAME,$0
	CALL	runtime·mstart0(SB)
	RET // not reached
</code></pre>
<p><code>mstart0()</code> 就在 <code>mstart()</code> 的下面：</p>
<pre><code class="language-go">func mstart0() {
	gp := getg()
	osStack := gp.stack.lo == 0
	if osStack {
		// Initialize stack bounds from system stack.
		// Cgo may have left stack size in stack.hi.
		// minit may update the stack bounds.
		size := gp.stack.hi
		if size == 0 {
			size = 8192 * sys.StackGuardMultiplier
		}
		gp.stack.hi = uintptr(noescape(unsafe.Pointer(&amp;size)))
		gp.stack.lo = gp.stack.hi - size + 1024
	}
	// Initialize stack guard so that we can start calling regular
	// Go code.
	gp.stackguard0 = gp.stack.lo + stackGuard
	// This is the g0, so we can also call go:systemstack
	// functions, which check stackguard1.
	gp.stackguard1 = gp.stackguard0
	mstart1()
	// Exit this thread.
	if mStackIsSystemAllocated() {
		// Windows, Solaris, illumos, Darwin, AIX and Plan 9 always system-allocate
		// the stack, but put it in gp.stack before mstart,
		// so the logic above hasn&#39;t set osStack yet.
		osStack = true
	}
	mexit(osStack)
}
</code></pre>
<p>简单过一下 <code>mstart0()</code> 后，我们会发现其实 <code>mstart0()</code> 也不是关键，关键是 <code>mstart1()</code>。</p>
<p>我们先对 <code>mstart0()</code> 做一个简单的总结：它是 Go 语言运行时中新 M（操作系统线程）的入口点。这个函数负责初始化新线程的栈和一些其他设置，然后调用 <code>mstart1</code> 来继续线程的初始化过程。</p>
<p>我们继续来看 <code>mstart1()</code>，它用于进一步设置新线程并最终将控制权交给调度器。</p>
<pre><code class="language-go">func mstart1() {
    // 获取当前协程
	gp := getg()
    // 只有 g0 协程可以执行 mstart1()，即启动 m0。
    // 每一个 M 都有一个特殊的 goroutine，其被称为 g0，它用于执行系统级任务。
	if gp != gp.m.g0 {
		throw(&quot;bad runtime·mstart&quot;)
	}

	// 设置 gp.sched 调度信息，以便 goroutine 能够在未来被正确调度。
	gp.sched.g = guintptr(unsafe.Pointer(gp))	// goroutine 指针
	gp.sched.pc = getcallerpc()	// 程序计数器
	gp.sched.sp = getcallersp()	// 栈指针

    // 初始化于汇编相关的设置。
	asminit()
    // 初始化当前 M 的线程局部存储和其他线程相关的数据。
	minit()

	// 如果是 m0，则安装信号处理器
	if gp.m == &amp;m0 {
		mstartm0()
	}

    // 如果 M 启动时配置了函数，则调用它
	if fn := gp.m.mstartfn; fn != nil {
		fn()
	}

    // 如果当前现场不是 m0（主线程），则获取一个 P，准备开始执行 goroutine
	if gp.m != &amp;m0 {
		acquirep(gp.m.nextp.ptr())
		gp.m.nextp = 0
	}
    // 调用 schedule() 将控制权交给调度器，开始执行 goroutine
	schedule()
}

func mstartm0() {
	if (iscgo || GOOS == &quot;windows&quot;) &amp;&amp; !cgoHasExtraM {
		cgoHasExtraM = true
		newextram()
	}
    // 安装信号处理器
	initsig(false)
}
</code></pre>
<p>其中 <code>schedule()</code> 是调度器的具体调度过程，这部分会在 GMP 篇章进行展开（TODO 😁）。</p>
<p>注意这里：</p>
<pre><code class="language-go">// 如果 M 启动时配置了函数，则调用它
if fn := gp.m.mstartfn; fn != nil {
    fn()
}
</code></pre>
<p>前面我们提到 <code>runtime·newproc</code> 的时候，获取并设置了 <code>runtime.main</code> 的函数地址：</p>
<pre><code class="language-armasm">// 取 runtime·mainPC 的地址，这其实就是 runtime 包下的 main() 方法。
// 它是 Go 语言程序的真正入口，而不是 main.main()。
MOVQ	$runtime·mainPC(SB), AX
PUSHQ	AX
// 创建一个新的 goroutine 来运行程序的主函数。
// 这里还没有正在的运行，因为调度器还没有启动，
// 只是将 runtime.main 放进 goroutine 的 queue 中等待执行。
CALL	runtime·newproc(SB)
POPQ	AX
</code></pre>
<p>所以这里其实就是调用 <code>runtime.main()</code>，到这里，我们终于开始执行程序的主函数了。</p>
<h2>runtime.main ★</h2>
<p>终于我们到了 <code>runtime.main</code> 这个 Go 语言世界中 “真正” 的主函数了，它位于：<a href="https://github.com/golang/go/blob/release-branch.go1.21/src/runtime/proc.go">proc.go</a>。</p>
<pre><code class="language-go">// The main goroutine.
func main() {

    // 获取当前的 M
	mp := getg().m

	mp.g0.racectx = 0

	// 限制栈大小的上限，64 位系统为 1G，32 位系统为 250M
	if goarch.PtrSize == 8 {
		maxstacksize = 1000000000
	} else {
		maxstacksize = 250000000
	}

	// 这里就将栈的上限提升 2 倍，用于避免在分配过大的栈时崩溃。
    // 所以其实 64 位系统最大栈 2G，32 位系统最大栈 500M。
	maxstackceiling = 2 * maxstacksize

	// 将 mainStarted 置为 true，允许 newproc 启动新的 M。
	mainStarted = true

    // WebAssemby 上暂时没有线程和系统监视器，所以这里过滤掉。
	if GOARCH != &quot;wasm&quot; {
        // 其他架构就启动系统监视器。
		systemstack(func() {
			newm(sysmon, nil, -1)
		})
	}

	// 在初始化期间将 g0 锁定在 m0 上。
	lockOSThread()

    // 只有 m0 可以运行 runtime.main
	if mp != &amp;m0 {
		throw(&quot;runtime.main not on m0&quot;)
	}

    // 记录 runtime 的开始时间，需要在 doInit() 之前，因为这样才把 init 也追踪上。
	runtimeInitTime = nanotime()
	if runtimeInitTime == 0 {
		throw(&quot;nanotime returning zero&quot;)
	}

    // 初始化 trace
	if debug.inittrace != 0 {
		inittrace.id = getg().goid
		inittrace.active = true
	}

    // 执行 runtime 的 init() 方法
	doInit(runtime_inittasks) // Must be before defer.

	// defer 解锁，以便在初始化期间调用 runtime.Goexit 时也能解锁。
	needUnlock := true
	defer func() {
		if needUnlock {
			unlockOSThread()
		}
	}()

    // 启动垃圾回收期
	gcenable()

    // 监听初始化完成的信号
	main_init_done = make(chan bool)

    // 如果使用 cgo，则进行相关的初始化
	if iscgo {
		...
		cgocall(_cgo_notify_runtime_init_done, nil)
	}

    // 执行所有模块的 init()
	for _, m := range activeModules() {
		doInit(m.inittasks)
	}

	// 初始化任务都完成后，则禁用初始化 trace。
	inittrace.active = false

    // 关闭初始化完成的信号通道
	close(main_init_done)

    // 解锁 m0 线程
	needUnlock = false
	unlockOSThread()

    // 以 -buildmode=(c-archive|c-shared) 方式进行构建程序的话，则不执行 main.main
	if isarchive || islibrary {
		// A program compiled with -buildmode=c-archive or c-shared
		// has a main, but it is not executed.
		return
	}

    // 执行 main.main() 函数，也就是我们自己写的 main()
	fn := main_main
	fn()  // 如果我们启动了一个 server 服务，这里就会被阻塞住，直到我们的 main 返回。

    // 静态检测输出
	if raceenabled {
		runExitHooks(0) // run hooks now, since racefini does not return
		racefini()
	}

	// 处理在 main 返回时同时存在的其他 goroutine 的 panic
	if runningPanicDefers.Load() != 0 {
		for c := 0; c &lt; 1000; c++ {
            // 执行 defer
			if runningPanicDefers.Load() == 0 {
				break
			}
			Gosched()
		}
	}
    // 阻塞 g0 的执行，直到所有的 panic 都处理完毕
	if panicking.Load() != 0 {
		gopark(nil, nil, waitReasonPanicWait, traceBlockForever, 1)
	}

    // 执行 hook 退出函数
	runExitHooks(0)
    // 退出程序，0 表示正常退出
	exit(0)
    // 理论上 exit(0) 应该退出程序的，
    // 如果还没退出，使用 nil 指针强行赋值，引发崩溃，强行退出程序。
	for {
		var x *int32
		*x = 0
	}
}
</code></pre>
<p>总的来说，<code>runtime.main()</code> 干这么几件事：</p>
<ol>
<li>首先进行一些基本的设置和检查，包括设置栈大小限制和锁定主 goroutine 到主 OS 线程。</li>
<li>然后，函数执行一系列初始化操作，包括启动垃圾回收器、处理 CGo 交互、执行包的 init()。</li>
<li>在完成所有 init() 后，函数调用用户定义的 <code>main.main</code> 函数。</li>
<li>最后，函数处理程序退出，包括执行 defer、等待 panic 处理完成，并正式退出程序。</li>
</ol>
<p>所以这里我们就知道了为什么 <code>init()</code> 会在 <code>main.main()</code> 之前被执行，而且如果一个 package 在整个程序路径都没有被 import 的时候，<code>init()</code> 是不会被执行的，就是因为 <code>runtime.main()</code> 只处理了 <code>activeModules()</code> 的 <code>initTasks()</code>。</p>
<details open>
<summary>补充说明：全局变量的初始化</summary>


<p>到这里，还有个遗留问题，开发时我们需要关注的 <code>init()</code> 和 <code>main.main()</code> 可以讨论过了，那全局变量的初始化是在哪里做的呢？</p>
<p>在 Go 语言的编译过程中，全局变量的初始化主要发生在链接阶段。编译器首先编译每个包，生成对象文件。然后在链接阶段，编译器或链接器将这些对象文件合并成一个可执行文件。在这个过程中，编译器或链接器负责生成初始化全局变量的代码，并安排这些代码在程序启动时执行。</p>
<p>这些初始化代码通常嵌入在程序的启动序列中，确保在执行任何包级 <code>init</code> 函数或用户定义的 <code>main</code> 函数之前，所有全局变量已经被初始化。由于这些操作是编译器在内部执行的，它们不会直接显示在源代码或运行时代码中。</p>
</details>

<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231210232155964.png" alt="runtime.main 函数概述"></p>
<hr>
<p>至此，我们就分析完 Go 语言程序的整个启动过程了。具体的启动流程总结，可以回到开头的 “结论先行” 查看。</p>
<h2>参考</h2>
<ul>
<li><a href="https://coding.imooc.com/class/576.html">慕课网-深入 Go 底层原理</a></li>
<li>Go1.21.0 官方源码</li>
<li>ChatGPT-4</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Go1.21.0 程序编译过程</title>
      <link>https://hedon.top/blog/go-compilation/</link>
      <guid isPermaLink="true">https://hedon.top/blog/go-compilation/</guid>
      <pubDate>Wed, 29 Nov 2023 10:49:51 GMT</pubDate>
      <description>本文基于 Go1.21.0 版本详细介绍了 Go 程序的编译过程。</description>
      <category>Go</category><category>编译原理</category>
      <content:encoded><![CDATA[<h2>版本说明</h2>
<ul>
<li>Go 1.21</li>
</ul>
<h2>官方文档</h2>
<p>Go 语言官方文档详细阐述了 Go 语言编译器的具体执行过程，Go1.21 版本可以看这个：<a href="https://github.com/golang/go/tree/release-branch.go1.21/src/cmd/compile">https://github.com/golang/go/tree/release-branch.go1.21/src/cmd/compile</a></p>
<p>大致过程如下：</p>
<ol>
<li><p><strong>解析</strong> (<code>cmd/compile/internal/syntax</code>):</p>
<ul>
<li><strong>词法分析器和语法分析器</strong>：源代码被分词（词法分析）并解析（语法分析）。</li>
<li><strong>语法树构建</strong>：为每个源文件构建一个语法树。</li>
</ul>
</li>
<li><p><strong>类型检查</strong> (<code>cmd/compile/internal/types2</code>):</p>
<ul>
<li><strong>类型检查</strong>：<code>types2</code> 包是 <code>go/types</code> 的一个移植版本，它使用 <code>syntax</code> 包的 AST（抽象语法树）而不是 <code>go/ast</code>。</li>
</ul>
</li>
<li><p><strong>IR 构建（&quot;noding&quot;）</strong>:</p>
<ul>
<li><strong>编译器类型</strong> (<code>cmd/compile/internal/types</code>)</li>
<li><strong>编译器 AST</strong> (<code>cmd/compile/internal/ir</code>)</li>
<li><strong>AST 转换</strong> (<code>cmd/compile/internal/typecheck</code>)</li>
<li><strong>创建编译器 AST</strong> (<code>cmd/compile/internal/noder</code>)</li>
<li>这个阶段使用自己的 AST 定义和 Go 类型的表示，这些定义和表示形式是用 C 编写时遗留下来的。它的所有代码都是根据这些编写的，因此类型检查后的下一步是转换语法和 <code>types2</code> 表示形式到 <code>ir</code> 和 <code>types</code>。这个过程被称为“noding”。</li>
</ul>
</li>
<li><p><strong>中间阶段</strong>:</p>
<ul>
<li><strong>死代码消除</strong> (<code>cmd/compile/internal/deadcode</code>)</li>
<li><strong>函数调用内联</strong> (<code>cmd/compile/internal/inline</code>)</li>
<li><strong>已知接口方法调用的去虚拟化</strong> (<code>cmd/compile/internal/devirtualize</code>)</li>
<li><strong>逃逸分析</strong> (<code>cmd/compile/internal/escape</code>)</li>
<li>在 IR 表示上执行几个优化过程：死代码消除、（早期的）去虚拟化、函数调用内联和逃逸分析。</li>
</ul>
</li>
<li><p><strong>Walk</strong> (<code>cmd/compile/internal/walk</code>):</p>
<ul>
<li><strong>求值顺序和语法糖</strong>：这是对 IR 表示的最后一次遍历，它有两个目的：将复杂的语句分解为简单的单个语句，引入临时变量并遵守求值顺序；将高级 Go 构造转换为更原始的构造。</li>
</ul>
</li>
<li><p><strong>通用 SSA</strong> (<code>cmd/compile/internal/ssa</code> 和 <code>cmd/compile/internal/ssagen</code>):</p>
<ul>
<li>在这个阶段，IR 被转换为静态单赋值（SSA）形式，这是一种具有特定属性的低级中间表示，使得从中实现优化并最终生成机器代码变得更容易。</li>
</ul>
</li>
<li><p><strong>生成机器代码</strong> (<code>cmd/compile/internal/ssa</code> 和 <code>cmd/internal/obj</code>):</p>
<ul>
<li>这是编译器的机器依赖阶段，以“lower”过程开始，将通用值重写为它们的机器特定变体。然后进行最终的代码优化过程。最后，Go 函数被转换为一系列 <code>obj.Prog</code> 指令，这些指令被汇编器（<code>cmd/internal/obj</code>）转换为机器代码，并写出最终的目标文件。</li>
</ul>
</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img5eRkfKJ8cMojTjPoZzjr1i-20231129143006857.png" alt="Go编译器概览"></p>
<h2>编译过程</h2>
<p>Go 程序的编译过程符合经典编译原理的过程拆解，即三阶段编译器，分别为编译器前端、中端和后端：</p>
<ul>
<li><strong>前端（Front End）：</strong> 前端的任务是进行语法分析和语义分析。这一阶段会将源代码转换为一个中间表示。在这个过程中，编译器会检查代码的语法和语义，比如语法错误、类型错误等。前端通常是依赖于具体语言的，比如 Go 的前端和 C++ 的前端就会有很大的不同。</li>
<li><strong>中间端（Middle End）：</strong> 中间端的任务是对中间表示进行优化。这一阶段的优化是语言无关的，比如常量折叠、死代码消除、循环优化等。这些优化可以提高生成的代码的性能，但是不会改变程序的语义。</li>
<li><strong>后端（Back End）：</strong> 后端的任务是将优化后的中间表示转换为目标机器代码。这一阶段会进行更多的优化，比如寄存器分配、指令选择、指令调度等。后端通常是依赖于具体机器的，比如 x86 的后端和 ARM 的后端就会有很大的不同。</li>
</ul>
<p>参考《Go 语言底层原理剖析（郑建勋）》一书，本文将 Go 语言编译器执行流程拆分为以下几个阶段：</p>
<ul>
<li>词法解析</li>
<li>语法解析</li>
<li>抽象语法树构建</li>
<li>类型检查</li>
<li>死代码消除</li>
<li>去虚拟化</li>
<li>函数内联</li>
<li>逃逸分析</li>
<li>变量捕获</li>
<li>闭包重写</li>
<li>遍历函数</li>
<li>SSA 生成</li>
<li>机器码生成</li>
</ul>
<p>下面本文将以此书为参考并结合 Go1.21.0 版本，对每个过程进行阐述。</p>
<blockquote>
<p>如果只想对 Go 程序的编译过程做一个简单的了解，那阅读到这里就已经足够了。</p>
</blockquote>
<h2>词法解析</h2>
<p>词法解析过程主要负责将源代码中的字符序列转换成一系列的标记（tokens），这些标记是编译器更进一步处理的基本单位。在 Go 语言的编译器中，<code>tokens.go</code> 文件包含了与词法分析有关的标记定义。</p>
<p>词法解析的过程可以分为几个关键步骤：</p>
<ol>
<li><strong>扫描（Scanning）</strong>：编译器的扫描器会逐字符读取源代码，识别出基本的语法单位，如标识符、关键字、字面量、运算符等。</li>
<li><strong>标记生成（Token Generation）</strong>：每当扫描器识别出一个有效的语法单位时，它会生成一个相应的标记。例如，对于一个变量名，扫描器会生成一个标识符标记。</li>
<li><strong>去除空白字符和注释</strong>：在生成标记的过程中，扫描器还会忽略空白字符（如空格、换行符）和注释，因为它们对程序的逻辑没有影响。</li>
<li><strong>错误处理</strong>：如果扫描器在源代码中遇到无法识别的字符或序列，它会生成一个错误消息。</li>
</ol>
<p>我们来看以下 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/syntax/tokens.go">tokens.go</a> 文件中的 <code>token</code> 定义，它们实质上是用 <code>iota</code> 声明的一系列整数：</p>
<pre><code class="language-go">const (
	_    token = iota
	_EOF       // EOF

	// names and literals
	_Name    // name
	_Literal // literal

	// operators and operations
	// _Operator is excluding &#39;*&#39; (_Star)
	_IncOp    // opop
    _Define   // :=
    ...

	// delimiters
	_Lparen    // (
	_Rparen    // )
	...

	// keywords
	_Break       // break
	...

	// empty line comment to exclude it from .String
	tokenCount //
)
</code></pre>
<p>举个例子，<code>a := b + c(12)</code> 这个表达式，被解析后，如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231202200135678.png" alt="Go 语言编译器词法解析示例图"></p>
<h2>语法解析</h2>
<p>语法解析发生在词法解析之后，其主要目的是分析源代码中标记（tokens）的排列和结构，以确定它们是否形成了有效的语句。核心算法位于两个文件中：</p>
<ul>
<li><a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/syntax/nodes.go">syntax/nodes.go</a>：定义了语法节点（Syntax Nodes），这些节点是构成抽象语法树（AST）的基本元素。每个节点代表了 Go 语法中的一个构造，比如变量声明、函数调用、表达式等。通过这些节点，编译器能够理解和表示程序代码的结构。</li>
<li><a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/syntax/parser.go">syntax/parser.go</a>：包含了解析器的实现。解析器负责读取词法分析阶段生成的标记流，并根据这些标记构建 AST。它遵循 Go 语言的语法规则，确保代码符合语法结构，并在遇到语法错误时提供相应的反馈。</li>
</ul>
<p>Go 语言采用了标准的自上而下的递归下降（Top-Down Recursive-Descent）算法，以简单高效的方式完成无须回溯的语法扫描。</p>
<p>下面我们来看下 <code>nodes.go</code> 文件中对各个节点的声明（以下都省略了 struct 中的具体属性）：</p>
<h3>声明 Declarations</h3>
<pre><code class="language-go">type (
	Decl interface {
		Node
		aDecl()
	}
	ImportDecl struct {}   	// 导入声明
	ConstDecl struct {}	   	// 常量声明
	TypeDecl struct {}	   	// 类型声明
	VarDecl struct {}		// 变量声明
	FuncDecl struct {}		// 函数声明
)
</code></pre>
<h3>表达式 Expressions</h3>
<pre><code class="language-go">type (
	Expr interface {
		Node
		typeInfo
		aExpr()
	}
	// 省略了结构体属性
	BadExpr struct {}			// 无效表达式
	Name struct {}				// Value
	BasicLit struct {}			// Value
	CompositeLit struct {}		// Type { ElemList[0], ElemList[1], ... }
    KeyValueExpr struct {}		// Key: Value
	FuncLit struct {}			// func Type { Body }
	ParenExpr struct {}			// (X)
	SelectorExpr struct {}		// X.Sel
	IndexExpr struct {}			// X[Index]
	SliceExpr struct {}			// X[Index[0] : Index[1] : Index[2]]
	AssertExpr struct {}		// X.(Type)
	TypeSwitchGuard struct {}	// Lhs := X.(type)
	Operation struct {}			// 操作 +-*\
	CallExpr struct {}			// Fun(ArgList[0], ArgList[1], ...)
	ListExpr struct {}			// ElemList[0], ElemList[1], ...
	ArrayType struct {}			// [Len]Elem
	SliceType struct {}			// []Elem
	DotsType struct {}			// ...Elem
	StructType struct {}		// struct { FieldList[0] TagList[0]; FieldList[1] TagList[1]; ... }
	Field struct {}				// Name Type
	InterfaceType struct {}		// interface { MethodList[0]; MethodList[1]; ... }
    FuncType struct {}			// type FuncName func (param1, param2) return1, return2
	MapType struct {}			// map[Key]Value
	ChanType struct {}			// chan Elem, &lt;-chan Elem, chan&lt;- Elem
)
</code></pre>
<h3>语句 Statements</h3>
<pre><code class="language-go">type (
    // 所有语句的通用接口
    Stmt interface {
       Node
       aStmt()
    }
	// 更加简单语句的通用接口
    SimpleStmt interface {}

    EmptyStmt struct {}		// 空语句
    LabeledStmt struct {}	// 标签语句
    BlockStmt struct {}		// 代码块语句
    ExprStmt struct {}		// 表达式语句
    SendStmt struct {}		// 发送语句，用于 channel
    DeclStmt struct {}		// 声明语句
    AssignStmt struct {}	// 赋值语句
    BranchStmt struct {}	// 分支语句，break, continue
    CallStmt struct {}		// 调用语句
    ReturnStmt struct {}	// 返回语句
    IfStmt struct {}		// if 条件语句
    ForStmt struct {}		// for 循环语句
    SwitchStmt struct {}	// switch 语句
    SelectStmt struct {}	// select 语句
)
</code></pre>
<p>我们可以重点来看一下最常用的赋值语句：</p>
<pre><code class="language-go">type AssignStmt struct {
    Op       Operator // 操作符 0 means no operation
    Lhs, Rhs Expr     // 左右两个表达式 Rhs == nil means Lhs++ (Op == Add) or Lhs-- (Op == Sub)
    simpleStmt
}
</code></pre>
<h3>举例</h3>
<pre><code class="language-go">package main
import &quot;fmt&quot;
const name = &quot;hedon&quot;
type String string
var s String = &quot;hello &quot; + word
func main() {
	fmt.Println(s)
}
</code></pre>
<p>上面的源代码会被解析成如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231202195439495.png" alt="Go 编译器语法解析示例图"></p>
<p>再来看一个赋值语句是如何解析的，就以之前的 <code>a := b + c(12)</code> 为例：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231203003155036.png" alt="特定赋值语句的语法解析示例"></p>
<h2>抽象语法树构建</h2>
<p>编译器前端必须构建程序的中间表示形式，以便在编译器中端及后端使用，抽象语法树（Abstract Syntax Tree，AST）是一种常见的树状结构的中间态。</p>
<h3>抽象语法树</h3>
<p>抽象语法树（AST，Abstract Syntax Tree）是源代码的树状结构表示，它用于表示编程语言的语法结构，但不包括所有语法细节。AST 是编译器设计中的关键概念，广泛应用于编译器的各个阶段。</p>
<p>基本概念：</p>
<ul>
<li>结构：AST 是一种树形结构，其中每个节点代表程序中的一种构造（如表达式、语句等）。</li>
<li>抽象性：它抽象出了代码的语法结构，省略了某些语法细节（如括号、特定的语法格式等）。</li>
</ul>
<p>节点类型：</p>
<ul>
<li>根节点：代表整个程序或一段完整代码。</li>
<li>内部节点：通常代表控制结构（如循环、条件语句）和操作符（如加、减、乘、除）。</li>
<li>叶节点：代表程序中的基本元素，如常量、变量和标识符。</li>
</ul>
<p>构建过程：</p>
<ul>
<li>词法分析：源代码首先经过词法分析，分解为一系列标记（tokens）。</li>
<li>语法分析：然后，基于这些标记，语法分析器根据编程语言的语法规则构建 AST。</li>
<li>树的构建：在这个过程中，分析器会根据语言的语法创建不同类型的节点，并按照程序的结构将它们组织成树。</li>
</ul>
<p>使用场景：</p>
<ul>
<li>语义分析：编译器使用 AST 来进行类型检查和其他语义分析。</li>
<li>代码优化：在优化阶段，编译器会对 AST 进行变换，以提高代码的执行效率。</li>
<li>代码生成：编译器根据 AST 生成中间代码或目标代码。</li>
</ul>
<p>优点：</p>
<ul>
<li>简化处理：由于省略了不必要的语法细节，AST 使得编译器的设计更为简洁和高效。</li>
<li>灵活性：AST 可以轻松地进行修改和扩展，便于实现各种编译器功能。</li>
<li>可视化：AST 的树形结构使得代码的逻辑结构一目了然，有助于理解和调试。</li>
</ul>
<h3>Go 构建抽象语法树</h3>
<p>在 Go 语言源文件中的任何一种 Declarations 都是一个根节点，如下 <code>pkgInit(decls)</code> 函数将源文件中的所有声明语句都转换为节点（Node），代码位于：<a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/syntax/syntax.go">syntax/syntax.go</a> 和 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/syntax/parser.go">syntax/parser.go</a> 中。</p>
<h4>Parse()</h4>
<pre><code class="language-go">func Parse(base *PosBase, src io.Reader, errh ErrorHandler, pragh PragmaHandler, mode Mode) (_ *File, first error) {
	defer func() {
		if p := recover(); p != nil {
			if err, ok := p.(Error); ok {
				first = err
				return
			}
			panic(p)
		}
	}()

	var p parser
	p.init(base, src, errh, pragh, mode)
	p.next()
	return p.fileOrNil(), p.first
}
</code></pre>
<p>下面是对 <code>Parse()</code> 函数的一个简单解释：</p>
<ul>
<li>作用：解析单个 Go 源文件并返回相应的语法树。</li>
<li>参数<ul>
<li><code>base</code>: 位置基础信息。</li>
<li><code>src</code>: 要解析的源文件。</li>
<li><code>errh</code>: 错误处理函数。</li>
<li><code>pragh</code>: 用于处理每个遇到的编译指令（pragma）。</li>
<li><code>mode</code>: 解析模式。</li>
</ul>
</li>
<li>返回值：返回一个 <code>File</code> 类型的指针，表示解析后的 AST，以及可能的错误。</li>
<li>错误处理<ul>
<li>如果 <code>errh</code> 不为空：将会调用它处理每个遇到的错误，解析器尽可能多地处理源文件。此时，只有在没有找到正确的包声明时，返回的语法树才为 <code>nil</code>。</li>
<li>如果 <code>errh</code> 为空：解析器在遇到第一个错误时立即终止，返回的语法树为 <code>nil</code>。</li>
</ul>
</li>
</ul>
<p>其中 <code>File</code> 类型结构如下：</p>
<pre><code class="language-go">type File struct {
	Pragma    Pragma // 编译指令
	PkgName   *Name  // 包名
	DeclList  []Decl // 源文件中的各种声明
	EOF       Pos	 // 解析位置
	GoVersion string // go 版本
	node			// 该源文件的 AST 根节点
}
</code></pre>
<h4>parser.fileOrNil()</h4>
<p>具体的解析过程在 <code>parser.fileOrNil()</code> 方法中：</p>
<pre><code class="language-go">func (p *parser) fileOrNil() *File {
	if trace {
		defer p.trace(&quot;file&quot;)()
	}

    // 1. 初始化文件节点
	f := new(File)
	f.pos = p.pos()

	// 2. 解析包声明
	f.GoVersion = p.goVersion
	p.top = false
	if !p.got(_Package) {	// 包声明必须放在第一位，这跟我们学 Go 语法对应上了
		p.syntaxError(&quot;package statement must be first&quot;)
		return nil
	}
	f.Pragma = p.takePragma() // 获取编译指令
	f.PkgName = p.name()	// 获取包名
    p.want(_Semi) // _Semi 在之前的 tokens.go 中可以发现是分号（;)，是的，包声明后面就是得带分号

	// 3. 处理包声明错误
	if p.first != nil {
		return nil
	}

	// 4. 循环解析顶层声明
    // 循环处理文件中的所有声明，包括 import、const、type、var 和 func
    // 对每种类型的声明，调用其解析函数，如 importDecl、constDecl 进行解析
	prev := _Import
	for p.tok != _EOF {
		if p.tok == _Import &amp;&amp; prev != _Import {
			p.syntaxError(&quot;imports must appear before other declarations&quot;)
		}
		prev = p.tok

		switch p.tok {
		case _Import:
			p.next()
			f.DeclList = p.appendGroup(f.DeclList, p.importDecl)

		case _Const:
			p.next()
			f.DeclList = p.appendGroup(f.DeclList, p.constDecl)

		case _Type:
			p.next()
			f.DeclList = p.appendGroup(f.DeclList, p.typeDecl)

		case _Var:
			p.next()
			f.DeclList = p.appendGroup(f.DeclList, p.varDecl)

		case _Func:
			p.next()
			if d := p.funcDeclOrNil(); d != nil {
				f.DeclList = append(f.DeclList, d)
			}

		default:
             // 5. 处理异常和错误
			if p.tok == _Lbrace &amp;&amp; len(f.DeclList) &gt; 0 &amp;&amp; isEmptyFuncDecl(f.DeclList[len(f.DeclList)-1]) {
				p.syntaxError(&quot;unexpected semicolon or newline before {&quot;)
			} else {
				p.syntaxError(&quot;non-declaration statement outside function body&quot;)
			}
			p.advance(_Import, _Const, _Type, _Var, _Func)
			continue
		}

		// Reset p.pragma BEFORE advancing to the next token (consuming &#39;;&#39;)
		// since comments before may set pragmas for the next function decl.
		p.clearPragma()

		if p.tok != _EOF &amp;&amp; !p.got(_Semi) {
			p.syntaxError(&quot;after top level declaration&quot;)
			p.advance(_Import, _Const, _Type, _Var, _Func)
		}
	}

    // 6. 完成解析，记录文件结束的位置
	p.clearPragma()
	f.EOF = p.pos()

	return f
}
</code></pre>
<p>总结 <code>parser.fileOrNil()</code> 方法的处理过程大致如下：</p>
<ol>
<li><strong>初始化文件节点</strong>：<ul>
<li><code>f := new(File)</code>: 创建一个新的 <code>File</code> 节点。</li>
<li><code>f.pos = p.pos()</code>: 设置节点的位置信息。</li>
</ul>
</li>
<li><strong>解析包声明（Package Clause）</strong>：<ul>
<li><code>f.GoVersion = p.goVersion</code>: 记录 Go 版本。</li>
<li><code>p.top = false</code>: 设置状态，表示不再处于文件顶层。</li>
<li><code>if !p.got(_Package) {...}</code>: 检查是否存在包声明，如果没有，则报错并返回 <code>nil</code>。</li>
<li><code>f.Pragma = p.takePragma()</code>: 获取与包声明相关的编译指令。</li>
<li><code>f.PkgName = p.name()</code>: 获取包名。</li>
<li><code>p.want(_Semi)</code>: 确认包声明后有分号。</li>
</ul>
</li>
<li><strong>处理包声明错误</strong>：<ul>
<li><code>if p.first != nil {...}</code>: 如果已有错误，停止解析并返回 <code>nil</code>。</li>
</ul>
</li>
<li><strong>解析顶层声明</strong>：<ul>
<li>通过一个循环处理文件中的所有声明，包括导入（import）、常量（const）、类型（type）、变量（var）和函数（func）。</li>
<li>对每种类型的声明，调用相应的解析函数（如 <code>p.importDecl</code>、<code>p.constDecl</code> 等）。</li>
<li>将解析得到的声明添加到 <code>f.DeclList</code> 中。</li>
</ul>
</li>
<li><strong>处理异常和错误</strong>：<ul>
<li>在解析过程中遇到的任何不符合语法的情况都会触发错误处理。</li>
<li>使用 <code>p.syntaxError</code> 报告语法错误。</li>
<li>使用 <code>p.advance</code> 在遇到错误时跳过一些标记，以尝试恢复到一个已知的稳定状态。</li>
</ul>
</li>
<li><strong>完成解析</strong>：<ul>
<li>当遇到文件结束标记（EOF）时，完成解析。</li>
<li><code>f.EOF = p.pos()</code>: 记录文件结束的位置。</li>
<li>返回构建的 <code>File</code> 节点。</li>
</ul>
</li>
</ol>
<h3>Op 字段</h3>
<p>AST 每个节点都包含了当前节点属性的 Op 字段，定义在 <code>ir/node.go</code> 中，以 O 开头。与词法解析阶段中的 token 相同的是，Op 字段也是一个整数。不同的是，每个 Op 字段都包含了语义信息。例如，当一个节点的 Op 操作为 OAS 时，该节点代表的语义为 Left := Right，而当节点的操作为 OAS2 时，代码的语义为 x,y,z = a,b,c。</p>
<p>这里仅展示部分 Op 字段的定义：</p>
<pre><code class="language-go">type Op uint8

// Node ops.
const (
	OXXX Op = iota

	// names
	ONAME // var or func name
	// Unnamed arg or return value: f(int, string) (int, error) { etc }
	// Also used for a qualified package identifier that hasn&#39;t been resolved yet.
	ONONAME
	OTYPE    // type name
	OLITERAL // literal
	ONIL     // nil

	// expressions
	OADD          // X + Y
	...
	// X = Y or (if Def=true) X := Y
	// If Def, then Init includes a DCL node for X.
	OAS
	// Lhs = Rhs (x, y, z = a, b, c) or (if Def=true) Lhs := Rhs
	// If Def, then Init includes DCL nodes for Lhs
	OAS2
	...

    // statements
    OLABEL    // Label:
    ...
	OEND
)
</code></pre>
<p>以前面举例的赋值语句 <code>a := b + c(12)</code> 为例，该赋值语句最终会编程如下图所示的抽象语法树，节点之间具有从上到下的层次结构和依赖关系。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231203003058976.png" alt="抽象语法树示例图"></p>
<h2>类型检查</h2>
<p>完成 AST 的初步构建后，就进入类型检查阶段遍历节点树并决定节点的类型。具体的代码在 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/types2/check.go">types2/check,go</a>。</p>
<h3>checker.CheckFiles()</h3>
<p>其中最核心的方法就是 <code>checker.CheckFiles()</code>：</p>
<pre><code class="language-go">func (check *Checker) checkFiles(files []*syntax.File) (err error) {
    // 1. 不检查 unsafe 包
	if check.pkg == Unsafe {
		return nil
	}

    // 2. 检查 go 版本
	check.version, err = parseGoVersion(check.conf.GoVersion)
	if err != nil {
		return err
	}
	if check.version.after(version{1, goversion.Version}) {
		return fmt.Errorf(&quot;package requires newer Go version %v&quot;, check.version)
	}
	if check.conf.FakeImportC &amp;&amp; check.conf.go115UsesCgo {
		return errBadCgo
	}

    // 3. 错误处理
	defer check.handleBailout(&amp;err)

    // 4. 详细检查每个地方
	print := func(msg string) {
		if check.conf.Trace {
			fmt.Println()
			fmt.Println(msg)
		}
	}
	print(&quot;== initFiles ==&quot;)
	check.initFiles(files)
	print(&quot;== collectObjects ==&quot;)
	check.collectObjects()
	print(&quot;== packageObjects ==&quot;)
	check.packageObjects()
	print(&quot;== processDelayed ==&quot;)
	check.processDelayed(0) // incl. all functions
	print(&quot;== cleanup ==&quot;)
	check.cleanup()
	print(&quot;== initOrder ==&quot;)
	check.initOrder()
	if !check.conf.DisableUnusedImportCheck {
		print(&quot;== unusedImports ==&quot;)
		check.unusedImports()
	}
	print(&quot;== recordUntyped ==&quot;)
	check.recordUntyped()
	if check.firstErr == nil {
		check.monomorph()
	}
	check.pkg.goVersion = check.conf.GoVersion
	check.pkg.complete = true

	// 5. 更新和清理
	check.imports = nil
	check.dotImportMap = nil
	check.pkgPathMap = nil
	check.seenPkgMap = nil
	check.recvTParamMap = nil
	check.brokenAliases = nil
	check.unionTypeSets = nil
	check.ctxt = nil
	return
}
</code></pre>
<p>总结 <code>checker.checkFiles()</code> 的过程大致如下：</p>
<ol>
<li><strong>检查特殊包</strong>：如果是 <code>Unsafe</code> 包，则直接返回，因为它不能进行类型检查且不应被修改。</li>
<li><strong>解析 Go 版本</strong>：根据配置解析 Go 版本，并进行兼容性检查。</li>
<li><strong>错误处理</strong>：设置一个延迟函数来处理任何可能出现的错误。</li>
<li><strong>类型检查的步骤</strong>：<ul>
<li><code>initFiles</code>: 初始化文件。</li>
<li><code>collectObjects</code>: 收集对象。</li>
<li><code>packageObjects</code>: 打包对象。</li>
<li><code>processDelayed</code>: 处理延迟的任务（包括所有函数）。</li>
<li><code>cleanup</code>: 清理。</li>
<li><code>initOrder</code>: 初始化顺序。</li>
<li><code>unusedImports</code>: 检查未使用的导入。</li>
<li><code>recordUntyped</code>: 记录未定类型。</li>
<li><code>monomorph</code>: 如果没有错误，进行单态化处理。</li>
</ul>
</li>
<li><strong>更新和清理</strong>：<ul>
<li>更新包的 Go 版本和完成状态。</li>
<li>清理不再需要的内部数据结构，释放内存。</li>
</ul>
</li>
<li><strong>返回</strong>：函数完成类型检查并返回。</li>
</ol>
<p>可以看出具体的检查步骤都封装在第 4 点的各个函数中，其实检查的东西我们学习 Go 语言时所需要掌握的那些语法，我们以 <code>initFiles</code> 为例子来分析一下，对于其他检查函数，你有兴趣的话也可以了解一下，这里推荐将函数源代码拷贝发给 <strong>ChatGPT-4</strong>，相信对你会有很大的帮助。</p>
<h3>checker.initFiles()</h3>
<pre><code class="language-go">// initFiles 初始化与文件相关的类型检查器
// 参数中的 files 必须都属于同一个 package
func (check *Checker) initFiles(files []*syntax.File) {
	// 1. 初始化
	check.files = nil
	check.imports = nil
	check.dotImportMap = nil
	check.firstErr = nil
	check.methods = nil
	check.untyped = nil
	check.delayed = nil
	check.objPath = nil
	check.cleaners = nil

	// 2. 确定包名和有效文件
	pkg := check.pkg
	for _, file := range files {
		switch name := file.PkgName.Value; pkg.name {
		case &quot;&quot;:
			if name != &quot;_&quot; {
				pkg.name = name
			} else {
				check.error(file.PkgName, BlankPkgName, &quot;invalid package name _&quot;)
			}
			fallthrough

		case name:
			check.files = append(check.files, file)

		default:
			check.errorf(file, MismatchedPkgName, &quot;package %s; expected %s&quot;, name, pkg.name)
			// ignore this file
		}
	}

    // 3. 对每个文件，解析其中指定的 Go 版本
	for _, file := range check.files {
		v, _ := parseGoVersion(file.GoVersion)
		if v.major &gt; 0 {
			if v.equal(check.version) {
				continue
			}
			// Go 1.21 introduced the feature of setting the go.mod
			// go line to an early version of Go and allowing //go:build lines
			// to “upgrade” the Go version in a given file.
			// We can do that backwards compatibly.
			// Go 1.21 also introduced the feature of allowing //go:build lines
			// to “downgrade” the Go version in a given file.
			// That can&#39;t be done compatibly in general, since before the
			// build lines were ignored and code got the module&#39;s Go version.
			// To work around this, downgrades are only allowed when the
			// module&#39;s Go version is Go 1.21 or later.
			// If there is no check.version, then we don&#39;t really know what Go version to apply.
			// Legacy tools may do this, and they historically have accepted everything.
			// Preserve that behavior by ignoring //go:build constraints entirely in that case.
			if (v.before(check.version) &amp;&amp; check.version.before(version{1, 21})) || check.version.equal(version{0, 0}) {
				continue
			}
			if check.posVers == nil {
				check.posVers = make(map[*syntax.PosBase]version)
			}
			check.posVers[base(file.Pos())] = v
		}
	}
}
</code></pre>
<p>总结 <code>checker.initFiles()</code> 方法的大致流程如下：</p>
<ol>
<li><strong>初始化状态</strong>：清空 <code>Checker</code> 结构体中与文件相关的多个字段，如 <code>files</code>, <code>imports</code>, <code>dotImportMap</code> 等，为新的检查过程做准备。</li>
<li><strong>确定包名和有效文件</strong>：<ul>
<li>遍历提供的文件，确定包名，并收集有效的文件。</li>
<li>如果文件的包名与 <code>Checker</code> 中的包名不匹配，则报错并忽略该文件。</li>
</ul>
</li>
<li><strong>处理 Go 版本</strong>：<ul>
<li>对每个文件，解析其中指定的 Go 版本。</li>
<li>处理 Go 版本的兼容性和升级逻辑，尤其是在 Go 1.21 引入的一些特性，如 <code>//go:build</code> 行的处理。</li>
</ul>
</li>
</ol>
<p>可以看到 Go 语言开发团队在这里写了一大段关于 Go1.21 的注释，这段注释描述了 Go 1.21 版本引入的关于 Go 版本设置的两个新特性,这里简单解释一下：</p>
<ol>
<li><strong>升级 Go 版本的特性</strong>：在 Go 1.21 版本中，可以在 <code>go.mod</code> 文件里设置一个较旧的 Go 版本，同时允许在源文件中通过 <code>//go:build</code> 行来指定一个更高的 Go 版本。这样做可以向后兼容，即允许旧版本代码在新版本的 Go 环境中运行。</li>
<li><strong>降级 Go 版本的限制</strong>：Go 1.21 也允许通过 <code>//go:build</code> 行来降低源文件中的 Go 版本。但这通常不是向后兼容的，因为在以前，<code>//go:build</code> 行被忽略，代码总是使用模块定义的 Go 版本。为了避免兼容性问题，仅当模块的 Go 版本为 1.21 或更高时，才允许这种降级。</li>
</ol>
<p><strong>未指定版本的情况</strong>：如果没有明确指定 <code>check.version</code>，编译器就不确定应该使用哪个 Go 版本。为了保持与旧工具的兼容，如果没有明确的版本约束，编译器将忽略 <code>//go:build</code> 行的限制。</p>
<h2>死代码消除</h2>
<p>类型检查阶段完成后，编译器前端工作基本完成，后面就进入中端了。这个阶段 Go 语言编译器将对 AST 进行分析和重构，从而完成一系列优化。</p>
<p>第一部分是死代码消除（dead code elimination），过程识别并移除不会在运行时执行的代码。这包括未使用的变量、函数、常量等。通过删除这些无用代码片段，可以减小最终程序的大小并提高运行效率。</p>
<p>这部分的代码在：<a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/deadcode/deadcode.go">deadcode/deadcode.go</a>。打开代码文件，可以看到核心就是 <code>Func()</code> 和 <code>stmt()</code> 这 2 个函数。</p>
<h3>Func()</h3>
<pre><code class="language-go">func Func(fn *ir.Func) {

    // 1. 对函数体进行预处理
	stmts(&amp;fn.Body)

    // 2. 空函数体直接返回
	if len(fn.Body) == 0 {
		return
	}

    // 3. 遍历函数体，对其中每个节点进行处理
	for _, n := range fn.Body {
        // 节点有任何初始化操作，则不可消除，提前返回。
		if len(n.Init()) &gt; 0 {
			return
		}
		switch n.Op() {
		case ir.OIF:
			n := n.(*ir.IfStmt)
            // 如果 if 语句判断条件不是常量，或者 if else 中的 body 不为空，则不可消除，提前返回
			if !ir.IsConst(n.Cond, constant.Bool) || len(n.Body) &gt; 0 || len(n.Else) &gt; 0 {
				return
			}
		case ir.OFOR:
			n := n.(*ir.ForStmt)
            // 如果 for 循环条件不是常量或一直为真，则不可消除，提前返回
			if !ir.IsConst(n.Cond, constant.Bool) || ir.BoolVal(n.Cond) {
				return
			}
		default:
			return
		}
	}

    // 4. 标记隐藏闭包为死代码
	ir.VisitList(fn.Body, markHiddenClosureDead)
    // 5. 重置函数体，替换为一个空语句，进行清理和优化
	fn.Body = []ir.Node{ir.NewBlockStmt(base.Pos, nil)}
}
</code></pre>
<ol>
<li><strong>语句处理（<code>stmts(&amp;fn.Body)</code>）</strong>：对函数体中的语句进行预处理或转换，以便于后续的分析和优化。</li>
<li><strong>空函数体直接返回</strong>：如果函数体为空，没有任何代码需要执行，因此函数直接返回。这是一种优化，避免对空函数体进行不必要的分析。</li>
<li><strong>遍历函数体</strong>:<ul>
<li><strong>节点初始化检查</strong>：如果任何节点有初始化操作，意味着可能存在副作用或必要的代码执行，因此函数提前返回。</li>
<li><code>If</code> 和 <code>For</code> 语句特殊处理<ul>
<li><code>ir.OIF</code>：如果 <code>If</code> 语句的条件不是常量布尔值，或者 <code>If</code> 语句有非空的 body 或 else 分支，则提前返回，因为这些分支可能包含重要的代码。</li>
<li><code>ir.OFOR</code>：对于 <code>For</code> 循环，如果条件不是常量布尔值或者布尔值为真，意味着循环可能执行，因此提前返回。</li>
</ul>
</li>
</ul>
</li>
<li><strong>标记隐藏闭包为死代码（<code>markHiddenClosureDead</code>）</strong>：如果所有节点都不触发提前返回，意味着整个函数体可能没有有效的代码执行。此时，将隐藏的闭包标记为死代码，可能是为了进一步的优化处理，如移除这些代码。</li>
<li><strong>重置函数体</strong>：最后，将函数体替换为一个空的新块语句，这表明原始的函数体被认为是无效的或不会被执行，从而进行了代码的清理和优化。</li>
</ol>
<h3>stmt()</h3>
<p>这个函数的目的是通过分析和简化控制流结构，来识别和移除那些在程序执行中永远不会到达的代码部分。这样的优化可以减少编译后的代码量，并提高程序运行时的效率。</p>
<pre><code class="language-go">func stmts(nn *ir.Nodes) {

    // 1. 标记最后一个标签，其对应的 Op 字段就是 OLABEL
	var lastLabel = -1
	for i, n := range *nn {
		if n != nil &amp;&amp; n.Op() == ir.OLABEL {
			lastLabel = i
		}
	}

    // 2. 处理 if 和 switch 语句
	for i, n := range *nn {
		cut := false
		if n == nil {
			continue
		}
		if n.Op() == ir.OIF {
			n := n.(*ir.IfStmt)
			n.Cond = expr(n.Cond)
             // if 语句根据条件是否为常量来保留和移除分支
			if ir.IsConst(n.Cond, constant.Bool) {
				var body ir.Nodes
				if ir.BoolVal(n.Cond) {
					ir.VisitList(n.Else, markHiddenClosureDead)
					n.Else = ir.Nodes{}
					body = n.Body
				} else {
					ir.VisitList(n.Body, markHiddenClosureDead)
					n.Body = ir.Nodes{}
					body = n.Else
				}
                 // 如果 then 或 else 分支以 panic 或 return 语句结束，那么可以安全地移除该节点之后的所有语句。
                 // 这是因为 panic 或 return 会导致函数终止，后续的代码永远不会被执行。
                 // 同时，注释提到要避免移除标签（labels），因为它们可能是 goto 语句的目标，
                 // 而且为了避免 goto 相关的复杂性，没有使用 isterminating 标记。
                 // might be the target of a goto. See issue 28616.
				if body := body; len(body) != 0 {
					switch body[(len(body) - 1)].Op() {
					case ir.ORETURN, ir.OTAILCALL, ir.OPANIC:
						if i &gt; lastLabel {
							cut = true
						}
					}
				}
			}
		}
        // 尝试简化 switch 语句，根据条件值决定哪个分支始终被执行
		if n.Op() == ir.OSWITCH {
			n := n.(*ir.SwitchStmt)
			func() {
				if n.Tag != nil &amp;&amp; n.Tag.Op() == ir.OTYPESW {
					return // no special type-switch case yet.
				}
				var x constant.Value // value we&#39;re switching on
				if n.Tag != nil {
					if ir.ConstType(n.Tag) == constant.Unknown {
						return
					}
					x = n.Tag.Val()
				} else {
					x = constant.MakeBool(true) // switch { ... }  =&gt;  switch true { ... }
				}
				var def *ir.CaseClause
				for _, cas := range n.Cases {
					if len(cas.List) == 0 { // default case
						def = cas
						continue
					}
					for _, c := range cas.List {
						if ir.ConstType(c) == constant.Unknown {
							return // can&#39;t statically tell if it matches or not - give up.
						}
						if constant.Compare(x, token.EQL, c.Val()) {
							for _, n := range cas.Body {
								if n.Op() == ir.OFALL {
									return // fallthrough makes it complicated - abort.
								}
							}
							// This switch entry is the one that always triggers.
							for _, cas2 := range n.Cases {
								for _, c2 := range cas2.List {
									ir.Visit(c2, markHiddenClosureDead)
								}
								if cas2 != cas {
									ir.VisitList(cas2.Body, markHiddenClosureDead)
								}
							}

							// Rewrite to switch { case true: ... }
							n.Tag = nil
							cas.List[0] = ir.NewBool(c.Pos(), true)
							cas.List = cas.List[:1]
							n.Cases[0] = cas
							n.Cases = n.Cases[:1]
							return
						}
					}
				}
				if def != nil {
					for _, n := range def.Body {
						if n.Op() == ir.OFALL {
							return // fallthrough makes it complicated - abort.
						}
					}
					for _, cas := range n.Cases {
						if cas != def {
							ir.VisitList(cas.List, markHiddenClosureDead)
							ir.VisitList(cas.Body, markHiddenClosureDead)
						}
					}
					n.Cases[0] = def
					n.Cases = n.Cases[:1]
					return
				}

				// TODO: handle case bodies ending with panic/return as we do in the IF case above.

				// entire switch is a nop - no case ever triggers
				for _, cas := range n.Cases {
					ir.VisitList(cas.List, markHiddenClosureDead)
					ir.VisitList(cas.Body, markHiddenClosureDead)
				}
				n.Cases = n.Cases[:0]
			}()
		}

        // 3. 对节点的初始化语句递归调用 stmt 函数进行处理
		if len(n.Init()) != 0 {
			stmts(n.(ir.InitNode).PtrInit())
		}
        // 4. 遍历其他控制结构，递归处理它们的内部语句
		switch n.Op() {
		case ir.OBLOCK:
			n := n.(*ir.BlockStmt)
			stmts(&amp;n.List)
		case ir.OFOR:
			n := n.(*ir.ForStmt)
			stmts(&amp;n.Body)
		case ir.OIF:
			n := n.(*ir.IfStmt)
			stmts(&amp;n.Body)
			stmts(&amp;n.Else)
		case ir.ORANGE:
			n := n.(*ir.RangeStmt)
			stmts(&amp;n.Body)
		case ir.OSELECT:
			n := n.(*ir.SelectStmt)
			for _, cas := range n.Cases {
				stmts(&amp;cas.Body)
			}
		case ir.OSWITCH:
			n := n.(*ir.SwitchStmt)
			for _, cas := range n.Cases {
				stmts(&amp;cas.Body)
			}
		}

        // 5. 如果确定了是可以消除的代码，则对函数体进行阶段，且标记其中的闭包为死代码
		if cut {
			ir.VisitList((*nn)[i+1:len(*nn)], markHiddenClosureDead)
			*nn = (*nn)[:i+1]
			break
		}
	}
}
</code></pre>
<ol>
<li><strong>标记最后一个标签</strong>：遍历所有节点，记录最后一个标签（<code>OLABEL</code>）的位置。这对于后面判断是否可以安全地移除代码非常重要。</li>
<li><strong>处理 <code>if</code> 和 <code>switch</code> 语句</strong>：<ul>
<li>对于 <code>if</code> 语句，它根据条件是否为常量来决定保留哪个分支，移除另一个分支。</li>
<li>对于 <code>switch</code> 语句，它尝试简化 <code>switch</code>，根据条件值决定哪个分支将始终被执行。</li>
</ul>
</li>
<li><strong>节点初始化</strong>：如果节点有初始化语句，对这些初始化语句递归调用 <code>stmts</code> 函数。</li>
<li><strong>遍历其他控制结构</strong>：对于 <code>for</code>、<code>if</code>、<code>range</code>、<code>select</code> 和 <code>switch</code> 等控制结构，递归地处理它们的内部语句。</li>
<li><strong>消除死代码</strong>：如果判断一个节点之后的所有代码都是无效的，它会标记这些代码为死代码并截断函数体。</li>
</ol>
<h2>去虚拟化</h2>
<p>去虚拟化（Devirtualization）是编译器优化的一种技术，用于提高面向对象程序的性能。在面向对象编程中，方法调用通常是通过虚拟函数表（vtable）动态解析的，这被称为虚拟调用。虚拟调用允许对象在运行时表现出多态行为，但这也带来了一定的性能开销。</p>
<p>去虚拟化的目的是在编译时静态确定方法调用的目标，从而避免运行时的动态查找。如果编译器能够确定一个特定的接口调用总是调用同一个方法，它可以将这个虚拟调用替换为直接调用，减少运行时开销。这种优化特别适用于那些调用目标不会因为程序执行的不同路径而改变的情况。</p>
<p>这部分的代码在 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/devirtualize/devirtualize.go">devirtuailze/devirtualize.go</a>。</p>
<p>核心就 2 个函数：</p>
<ul>
<li><code>Static()</code> ：遍历函数中的所有节点，尤其注意跳过在 <code>go</code> 或 <code>defer</code> 语句中的调用，并对其他接口方法调用尝试进行静态去虚拟化优化。</li>
<li><code>staticCall()</code> ：针对一个具体的接口方法调用，如果可能，将其替换为直接的具体类型方法调用，以优化性能。</li>
</ul>
<h3>Static()</h3>
<pre><code class="language-go">func Static(fn *ir.Func) {
	ir.CurFunc = fn
	goDeferCall := make(map[*ir.CallExpr]bool)
    // 1. VisitList 对 fn.Body 中所有节点调用后面的 func
	ir.VisitList(fn.Body, func(n ir.Node) {
		switch n := n.(type) {
        // 2. 跳过 go 和 defer 语句
		case *ir.GoDeferStmt:
			if call, ok := n.Call.(*ir.CallExpr); ok {
				goDeferCall[call] = true
			}
			return
        // 3. 调用 staticCall 尝试进行去虚拟化
		case *ir.CallExpr:
			if !goDeferCall[n] {
				staticCall(n)
			}
		}
	})
}
</code></pre>
<ol>
<li>设定当前函数为 <code>fn</code>。</li>
<li>遍历函数体内的节点，特别注意 <code>go</code> 和 <code>defer</code> 语句。如果调用发生在这些语句中，它会被跳过，因为去虚拟化可能改变程序的语义。</li>
<li>对于不在 <code>go</code> 或 <code>defer</code> 语句中的接口方法调用，调用 <code>staticCall</code> 函数尝试进行去虚拟化。</li>
</ol>
<h3>staticCall()</h3>
<pre><code class="language-go">func staticCall(call *ir.CallExpr) {
    // 1. 检查调用是否为接口方法调用，如果不是，直接返回
	if call.Op() != ir.OCALLINTER {
		return
	}

    // 2. 获取接收器和相关类型
	sel := call.X.(*ir.SelectorExpr)
	r := ir.StaticValue(sel.X)

     // 3. 检查接收器是否是接口转换，如果不是，直接返回
	if r.Op() != ir.OCONVIFACE {
		return
	}
	recv := r.(*ir.ConvExpr)

   	// 4. 提取接收器类型
	typ := recv.X.Type()
	if typ.IsInterface() {
		return
	}

	// 5. shape 类型直接返回，因为这一般涉及到泛型，需要通过字典进行间接调用
	if typ.IsShape() {
		return
	}
	if typ.HasShape() {
		if base.Flag.LowerM != 0 {
			base.WarnfAt(call.Pos(), &quot;cannot devirtualize %v: shaped receiver %v&quot;, call, typ)
		}
		return
	}
	if sel.X.Type().HasShape() {
		if base.Flag.LowerM != 0 {
			base.WarnfAt(call.Pos(), &quot;cannot devirtualize %v: shaped interface %v&quot;, call, sel.X.Type())
		}
		return
	}

    // 6. 类型断言和方法选择，尝试确定调用的具体方法
	dt := ir.NewTypeAssertExpr(sel.Pos(), sel.X, nil)
	dt.SetType(typ)
	x := typecheck.Callee(ir.NewSelectorExpr(sel.Pos(), ir.OXDOT, dt, sel.Sel))
	switch x.Op() {
	case ir.ODOTMETH:
		x := x.(*ir.SelectorExpr)
		if base.Flag.LowerM != 0 {
			base.WarnfAt(call.Pos(), &quot;devirtualizing %v to %v&quot;, sel, typ)
		}
		call.SetOp(ir.OCALLMETH)
		call.X = x
	case ir.ODOTINTER:
		x := x.(*ir.SelectorExpr)
		if base.Flag.LowerM != 0 {
			base.WarnfAt(call.Pos(), &quot;partially devirtualizing %v to %v&quot;, sel, typ)
		}
		call.SetOp(ir.OCALLINTER)
		call.X = x
	default:
		if base.Flag.LowerM != 0 {
			base.WarnfAt(call.Pos(), &quot;failed to devirtualize %v (%v)&quot;, x, x.Op())
		}
		return

    // 7. 根据类型断言的结果，尝试将接口方法调用转换为直接方法调用或保留为接口方法调用。
	types.CheckSize(x.Type())
	switch ft := x.Type(); ft.NumResults() {
	case 0:
	case 1:
		call.SetType(ft.Results().Field(0).Type)
	default:
		call.SetType(ft.Results())
	}

	// 8. 对可能修改后的方法调用进行进一步的类型检查和调整。
	typecheck.FixMethodCall(call)
}
</code></pre>
<ol>
<li><strong>检查是否为接口方法调用</strong>：函数首先判断传入的调用是否是接口方法调用（<code>ir.OCALLINTER</code>），这是去虚拟化的前提条件。</li>
<li><strong>处理形状类型</strong>：代码中提到，如果接收器的类型是形状类型（用于泛型），则无法去虚拟化，因为这需要通过字典进行间接调用。</li>
<li><strong>处理形状类型的接收器</strong>：如果接收器的类型具有形状类型，则当前无法进行去虚拟化。注释中还提到了一些待实现（TODO）的优化点，例如处理非泛型的提升方法。</li>
<li><strong>处理形状类型的接口</strong>：如果调用的接口本身是一个形状类型，由于指针身份的不同，类型断言可能会失败，因此在这种情况下也无法去虚拟化。</li>
<li><strong>转换方法调用</strong>：根据调用的具体情况，将接口方法调用转换为直接的方法调用（<code>OCALLMETH</code>）或保留为接口方法调用（<code>OCALLINTER</code>）。</li>
<li><strong>更新调用类型</strong>：为了正确处理函数返回值，需要更新调用的类型，确保参数大小和栈偏移量正确。</li>
<li><strong>反糖化方法调用</strong>：如果创建了直接方法调用，需要对其进行后续的类型检查和调整。</li>
</ol>
<h2>函数内联</h2>
<p>函数内联是将一个函数的代码直接插入到每个调用点，而不是进行常规的函数调用。这意味着函数的整个体被复制到每个调用该函数的地方。</p>
<p>优点：</p>
<ul>
<li><strong>减少开销</strong>：内联消除了函数调用的开销，如参数传递、栈操作等。</li>
<li><strong>提升性能</strong>：有助于其他优化，比如循环展开、常量传播，因为编译器可以看到函数体内的代码。</li>
</ul>
<p>选择哪些函数内联：</p>
<ul>
<li><strong>小函数</strong>：通常是小函数，因为它们的内联带来的性能提升相对于代码膨胀的代价来说是值得的。</li>
<li><strong>调用频率高的函数</strong>：这些函数如果内联，可以显著减少运行时的调用开销。</li>
</ul>
<p>在 Go 语言中，可以通过 <code>//go:noinline</code> 来禁止函数内联。</p>
<p>这部分的主要实现在 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal~/inl.go">inline.inl.go</a>，核心函数是：<code>CanInline()</code> 和 <code>InlineImpossible()</code>。</p>
<h3>CanInline()</h3>
<pre><code class="language-go">// Inlining budget parameters, gathered in one place
const (
    // budget 是内联复杂度的衡量，
    // 超过 80 表示编译器认为这个函数太复杂了，就不进行函数内联了
	inlineMaxBudget       = 80
)

// CanInline 用于判断 fn 是否可内联。
// 如果可以，会将 fn.Body 和 fn.Dcl 拷贝一份放到 fn.Inl，
// 其中 fn 和 fn.Body 需要确保已经经过类型检查了。
func CanInline(fn *ir.Func, profile *pgo.Profile) {

    // 函数名必须有效
	if fn.Nname == nil {
		base.Fatalf(&quot;CanInline no nname %+v&quot;, fn)
	}

    // 如果不能内联，输出原因
	var reason string
	if base.Flag.LowerM &gt; 1 || logopt.Enabled() {
		defer func() {
			if reason != &quot;&quot; {
				if base.Flag.LowerM &gt; 1 {
					fmt.Printf(&quot;%v: cannot inline %v: %s\n&quot;, ir.Line(fn), fn.Nname, reason)
				}
				if logopt.Enabled() {
					logopt.LogOpt(fn.Pos(), &quot;cannotInlineFunction&quot;, &quot;inline&quot;, ir.FuncName(fn), reason)
				}
			}
		}()
	}

    // 检查是否符合不可能内联的情况，如果返回的 reason 不为空，则表示有不可以内联的原因
	reason = InlineImpossible(fn)
	if reason != &quot;&quot; {
		return
	}
	if fn.Typecheck() == 0 {
		base.Fatalf(&quot;CanInline on non-typechecked function %v&quot;, fn)
	}

	n := fn.Nname
	if n.Func.InlinabilityChecked() {
		return
	}
	defer n.Func.SetInlinabilityChecked(true)

	cc := int32(inlineExtraCallCost)
	if base.Flag.LowerL == 4 {
		cc = 1 // this appears to yield better performance than 0.
	}

	// 设置内联预算，后面如果检查函数的复杂度超过预算了，就不内联了
	budget := int32(inlineMaxBudget)
	if profile != nil {
		if n, ok := profile.WeightedCG.IRNodes[ir.LinkFuncName(fn)]; ok {
			if _, ok := candHotCalleeMap[n]; ok {
				budget = int32(inlineHotMaxBudget)
				if base.Debug.PGODebug &gt; 0 {
					fmt.Printf(&quot;hot-node enabled increased budget=%v for func=%v\n&quot;, budget, ir.PkgFuncName(fn))
				}
			}
		}
	}

    // 遍历函数体，计算复杂度，判断是否超过内联预算
	visitor := hairyVisitor{
		curFunc:       fn,
		budget:        budget,
		maxBudget:     budget,
		extraCallCost: cc,
		profile:       profile,
	}
	if visitor.tooHairy(fn) {
		reason = visitor.reason
		return
	}

    // 前面检查都没问题，则标记为可以内联，并复制其函数体和声明到内联结构体中
	n.Func.Inl = &amp;ir.Inline{
		Cost: budget - visitor.budget,
		Dcl:  pruneUnusedAutos(n.Defn.(*ir.Func).Dcl, &amp;visitor),
		Body: inlcopylist(fn.Body),

		CanDelayResults: canDelayResults(fn),
	}

    // 日志和调试
	if base.Flag.LowerM &gt; 1 {
		fmt.Printf(&quot;%v: can inline %v with cost %d as: %v { %v }\n&quot;, ir.Line(fn), n, budget-visitor.budget, fn.Type(), ir.Nodes(n.Func.Inl.Body))
	} else if base.Flag.LowerM != 0 {
		fmt.Printf(&quot;%v: can inline %v\n&quot;, ir.Line(fn), n)
	}
	if logopt.Enabled() {
		logopt.LogOpt(fn.Pos(), &quot;canInlineFunction&quot;, &quot;inline&quot;, ir.FuncName(fn), fmt.Sprintf(&quot;cost: %d&quot;, budget-visitor.budget))
	}
}
</code></pre>
<ol>
<li><strong>基本检查</strong>：验证函数是否已经进行了类型检查，以及函数名是否有效。</li>
<li><strong>判断是否可以内联</strong>：调用 <code>InlineImpossible</code> 函数来检查是否有任何基本的限制条件阻止内联（例如函数太大、递归等）。</li>
<li><strong>内联预算设置</strong>：根据函数的特征和可能的性能剖析信息来设定内联预算。这个预算是内联决策的关键参数之一。</li>
<li><strong>详细分析</strong>：<code>hairyVisitor</code> 结构用于遍历函数体，判断是否超出了内联预算。这涉及对函数体的复杂度和大小的评估。</li>
<li><strong>内联决策</strong>：如果函数通过了所有检查并且未超出预算，则标记为可以内联，并复制其函数体和声明（Dcl）到内联结构体中。</li>
<li><strong>日志和调试</strong>：根据编译器的日志级别，输出关于内联决策的详细信息，例如为什么一个函数不能被内联或者它的内联成本是多少。</li>
</ol>
<h3>InlineImpossible()</h3>
<pre><code class="language-go">func InlineImpossible(fn *ir.Func) string {
	var reason string // reason, if any, that the function can not be inlined.
	if fn.Nname == nil {
		reason = &quot;no name&quot;
		return reason
	}

	// If marked &quot;go:noinline&quot;, don&#39;t inline.
	if fn.Pragma&amp;ir.Noinline != 0 {
		reason = &quot;marked go:noinline&quot;
		return reason
	}

	// If marked &quot;go:norace&quot; and -race compilation, don&#39;t inline.
	if base.Flag.Race &amp;&amp; fn.Pragma&amp;ir.Norace != 0 {
		reason = &quot;marked go:norace with -race compilation&quot;
		return reason
	}

	// If marked &quot;go:nocheckptr&quot; and -d checkptr compilation, don&#39;t inline.
	if base.Debug.Checkptr != 0 &amp;&amp; fn.Pragma&amp;ir.NoCheckPtr != 0 {
		reason = &quot;marked go:nocheckptr&quot;
		return reason
	}

	// If marked &quot;go:cgo_unsafe_args&quot;, don&#39;t inline, since the function
	// makes assumptions about its argument frame layout.
	if fn.Pragma&amp;ir.CgoUnsafeArgs != 0 {
		reason = &quot;marked go:cgo_unsafe_args&quot;
		return reason
	}

	// If marked as &quot;go:uintptrkeepalive&quot;, don&#39;t inline, since the keep
	// alive information is lost during inlining.
	//
	// TODO(prattmic): This is handled on calls during escape analysis,
	// which is after inlining. Move prior to inlining so the keep-alive is
	// maintained after inlining.
	if fn.Pragma&amp;ir.UintptrKeepAlive != 0 {
		reason = &quot;marked as having a keep-alive uintptr argument&quot;
		return reason
	}

	// If marked as &quot;go:uintptrescapes&quot;, don&#39;t inline, since the escape
	// information is lost during inlining.
	if fn.Pragma&amp;ir.UintptrEscapes != 0 {
		reason = &quot;marked as having an escaping uintptr argument&quot;
		return reason
	}

	// The nowritebarrierrec checker currently works at function
	// granularity, so inlining yeswritebarrierrec functions can confuse it
	// (#22342). As a workaround, disallow inlining them for now.
	if fn.Pragma&amp;ir.Yeswritebarrierrec != 0 {
		reason = &quot;marked go:yeswritebarrierrec&quot;
		return reason
	}

	// If a local function has no fn.Body (is defined outside of Go), cannot inline it.
	// Imported functions don&#39;t have fn.Body but might have inline body in fn.Inl.
	if len(fn.Body) == 0 &amp;&amp; !typecheck.HaveInlineBody(fn) {
		reason = &quot;no function body&quot;
		return reason
	}

	// If fn is synthetic hash or eq function, cannot inline it.
	// The function is not generated in Unified IR frontend at this moment.
	if ir.IsEqOrHashFunc(fn) {
		reason = &quot;type eq/hash function&quot;
		return reason
	}

	return &quot;&quot;
}
</code></pre>
<ol>
<li><strong>无函数名</strong>：如果函数没有名字，不能内联。</li>
<li><strong>有 <code>go:noinline</code> 指令</strong>：显式标记为不内联。</li>
<li><strong>有 <code>go:norace</code> 指令并在 <code>-race</code> 编译模式下</strong>：在竞态检测编译模式下不内联标记为 <code>norace</code> 的函数。</li>
<li><strong>有 <code>go:nocheckptr</code> 指令并在 <code>-d checkptr</code> 编译模式下</strong>：在指针检查编译模式下不内联标记为 <code>nocheckptr</code> 的函数。</li>
<li><strong>有 <code>go:cgo_unsafe_args</code> 指令</strong>：对于标记为 <code>cgo_unsafe_args</code> 的函数，由于参数布局的假设，不内联。</li>
<li><strong>有 <code>go:uintptrkeepalive</code> 指令</strong>：不内联标记为 <code>uintptrkeepalive</code> 的函数。</li>
<li><strong>有 <code>go:uintptrescapes</code> 指令</strong>：不内联标记为 <code>uintptrescapes</code> 的函数。</li>
<li><strong>有 <code>go:yeswritebarrierrec</code> 指令</strong>：为了防止写屏障记录检查器的混淆，不内联标记为 <code>yeswritebarrierrec</code> 的函数。</li>
<li><strong>无函数体</strong>：本地定义但没有函数体的函数（外部定义的 Go 函数）不可内联。</li>
<li><strong>是合成的 hash 或 eq 函数</strong>：不能内联这些类型的函数。</li>
</ol>
<h3>举例</h3>
<p>我们通过一段代码来看看编译器的函数内联情况。</p>
<pre><code class="language-go">func SayHello() string {
	s := &quot;hello, &quot; + &quot;world&quot;
	return s
}

func Fib(index int) int {
	if index &lt; 2 {
		return index
	}
	return Fib(index-1) + Fib(index-2)
}

func ForSearch() int {
	var s = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 0}
	res := 0
	for i := 0; i &lt; len(s); i++ {
		if s[i] == i {
			res = i
		}
	}
	return res
}

func main() {
	SayHello()
	Fib(65)
	ForSearch()
}
</code></pre>
<p>在编译时我们可以加入 <code>-m=2</code> 标签，来打印函数的内联调试信息。在 <code>main.go</code> 目录下执行：</p>
<pre><code class="language-bash">go tool compile -m=2 main.go
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">main.go:3:6: can inline SayHello with cost 7 as: func() string { s := &quot;hello, &quot; + &quot;world&quot;; return s }
main.go:8:6: cannot inline Fib: recursive
main.go:15:6: can inline ForSearch with cost 45 as: func() int { s := []int{...}; res := 0; for loop; return res }
main.go:26:6: cannot inline main: function too complex: cost 116 exceeds budget 80
main.go:27:10: inlining call to SayHello
main.go:29:11: inlining call to ForSearch
main.go:16:15: []int{...} does not escape
main.go:29:11: []int{...} does not escape
</code></pre>
<p>可以看到 <code>SayHello()</code> 和 <code>ForSearch</code> 都被内联了，而 <code>Fib()</code> 因为有递归，所以不会被内联。</p>
<h2>逃逸分析</h2>
<p>逃逸分析是 Go 语言中非常重要的优化阶段，<strong>用于标识变量内存应该被分配在栈上还是堆上</strong>。</p>
<p>在传统的 C 或 C++ 开发中，开发者经常会犯的错误就是函数返回了一个栈上的对象指针，在函数执行完毕后，函数栈会被销毁，如果继续访问被销毁栈上的对象指针，那么就会出现问题。</p>
<p>Go 语言能够通过编译时的逃逸分析识别这种问题，自动将这类变量放置到堆区，并借助 Go 运行时的垃圾回收机制自动释放内存。编译器会尽可能地将变量放置在栈上，因为栈中的对象会随着函数调用结束被自动销毁，这可以减轻运行时分配和垃圾回收的负担。</p>
<p>在 Go 语言中，开发者模糊了栈区和堆区的区别，不管是字符串、数组字面量，还是通过 new、make 标识符创建的对象，都既可能被分配到栈上，也可能被分配到堆上。但是，整体上会遵循 2 个原则：</p>
<ol>
<li>指向栈上对象的指针不能被存储到堆上；</li>
<li>指向栈上对象的指针不能超过该栈对象的生命周期。</li>
</ol>
<p>这部分的代码主要在 <a href="https://github.com/golang/go/tree/release-branch.go1.21/src/cmd/compile/internal/escape">escape</a>。</p>
<h3>分析过程</h3>
<p>Go 语言通过对 AST 的静态数据流分析来实现逃逸分析（<a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/escape/graph.go">escape/graph.go</a>），在这个过程，它会构建带权重的有向图，其中权重可以表面当前变量引用和解引用的数量。</p>
<ul>
<li>引用（&amp;a） 减 1</li>
<li>解引用（*a）加 1</li>
</ul>
<pre><code class="language-go">func (k hole) deref(where ir.Node, why string) hole { return k.shift(1).note(where, why) } // 解引用
func (k hole) addr(where ir.Node, why string) hole  { return k.shift(-1).note(where, why) } // 引用
</code></pre>
<p>具体来说，Go 逃逸分析会按照如下规则生成数据流图（带权重的有向图）：</p>
<ol>
<li>每个变量作为一个节点（location）；</li>
<li>每个赋值动作是一个有向边（edge），赋值给谁则指向谁；</li>
<li>解引用（deref），即 <code>*</code>操作会给边的权重 +1；</li>
<li>引用（addr），即 <code>&amp;</code> 操作会给边权重 -1。</li>
</ol>
<p>其中：<strong>节点权重 = 指向的节点权重 + 边权重</strong></p>
<p>逃逸分析的目标就是<strong>找到其中节点权重为 -1 的变量</strong>，并结合上述提到的 2 个原则，来判断要不要将变量分配到堆上。</p>
<h3>分析实例</h3>
<p>我们举一个例子来进行分析：</p>
<pre><code class="language-go">package main
var o *int
func main() {
	l := new(int)
	*l = 42
	m := &amp;l
	n := &amp;m
	o = **n
}
</code></pre>
<p>再次回顾一下，<code>*</code> 是加 1，<code>&amp;</code> 是减一。按照常规思路，我们从上往下分析：</p>
<p>先画出节点的赋值顺序，赋值给谁，边就指向谁：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231203193913047.png" alt="第1步：梳理节点顺序"></p>
<p>然后根据引用和解引用给边赋权重，因为 <code>new(int)</code> 其实就是分配一个 <code>int(0)</code> 并取地址，相当于 <code>&amp;</code>，所以指向 <code>l</code> 的边权重是 <code>-1</code>：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231203194101882.png" alt="第2步：给边赋值"></p>
<p>节点权重 = 边权重 + 指向节点权重，因为没有对 <code>o</code> 变量进行任何的操作，所以 <code>o</code> 权重为 0，从右往左推可以得到：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231203194432349.png" alt="第3步：计算节点权重"></p>
<p>经过分析，我们就找到了节点权重为 <code>-1</code> 的节点 <code>new(int)</code>，又由于它的节点变量地址最终会被传递到变量 <code>o</code> 上，结合之前的 2 个原则，<code>o</code> 是一个全局变量，声明周期是超过函数栈的，所以 <code>new(int)</code> 会被分配到堆上。</p>
<p>可以执行下面语句输出逃逸结果：</p>
<pre><code class="language-bash">go tool compile -m main.go
</code></pre>
<p>如：</p>
<pre><code class="language-bash">/escape/main.go:5:6: can inline main
/escape/main.go:6:10: new(int) escapes to hea
</code></pre>
<p>也可以执行下面语句输出数据流图构建过程：</p>
<pre><code class="language-bash"> go build -gcflags=&quot;-m -m -l&quot; main.go
</code></pre>
<p>如：</p>
<pre><code class="language-bash"># command-line-arguments
./main.go:6:10: new(int) escapes to heap:
./main.go:6:10:   flow: l = &amp;{storage for new(int)}:
./main.go:6:10:     from new(int) (spill) at ./main.go:6:10
./main.go:6:10:     from l := new(int) (assign) at ./main.go:6:4
./main.go:6:10:   flow: m = &amp;l:
./main.go:6:10:     from &amp;l (address-of) at ./main.go:8:7
./main.go:6:10:     from m := &amp;l (assign) at ./main.go:8:4
./main.go:6:10:   flow: n = &amp;m:
./main.go:6:10:     from &amp;m (address-of) at ./main.go:9:7
./main.go:6:10:     from n := &amp;m (assign) at ./main.go:9:4
./main.go:6:10:   flow: {heap} = **n:
./main.go:6:10:     from *n (indirection) at ./main.go:10:7
./main.go:6:10:     from *(*n) (indirection) at ./main.go:10:6
./main.go:6:10:     from o = *(*n) (assign) at ./main.go:10:4
./main.go:6:10: new(int) escapes to heap
</code></pre>
<p>如果我们试一下，把 <code>o</code> 放在 <code>main()</code> 里面呢？</p>
<pre><code class="language-go">func main() {
	var o *int
	l := new(int)
	*l = 42
	m := &amp;l
	n := &amp;m
	o = **n
	o = o   // 让编译通过
}
</code></pre>
<p>执行下面语句：</p>
<pre><code class="language-bash">go tool compile -m main.go
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">/escape/main.go:3:6: can inline main
/escape/main.go:5:10: new(int) does not escape
</code></pre>
<p>如我们所想，虽然 <code>new(int)</code> 的权重为 <code>-1</code>，但是它的声明周期始终没有超过 <code>main()</code>，所以没必要逃逸到堆上。</p>
<h2>变量捕获</h2>
<p>变量捕获主要是针对闭包（closure）场景而言的，由于闭包函数中可能引用闭包外的变量，因此变量捕获需要明确在闭包中通过值引用或者地址引用的方式来捕获变量。</p>
<p>这一过程在前面提到的逃逸分析过程中进行，具体实现在 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/escape/escape.go">escape/escape.go</a> 的 <code>flowClosure()</code> 函数中：</p>
<pre><code class="language-go">func (b *batch) flowClosure(k hole, clo *ir.ClosureExpr) {
    // 遍历闭包中的所有变量
    for _, cv := range clo.Func.ClosureVars {
        n := cv.Canonical()
        loc := b.oldLoc(cv)
        // 如果变量未被捕获，则触发错误
        if !loc.captured {
            base.FatalfAt(cv.Pos(), &quot;closure variable never captured: %v&quot;, cv)
        }

        // 根据变量的特性决定是通过值还是引用捕获
        // 如果变量未被重新赋值或取址，并且小于等于 128 字节，则通过值捕获
        n.SetByval(!loc.addrtaken &amp;&amp; !loc.reassigned &amp;&amp; n.Type().Size() &lt;= 128)
        if !n.Byval() {
            n.SetAddrtaken(true)
            // 特殊情况处理：字典变量不通过值捕获
            if n.Sym().Name == typecheck.LocalDictName {
                base.FatalfAt(n.Pos(), &quot;dictionary variable not captured by value&quot;)
            }
        }

        // 记录闭包捕获变量的方式（值或引用）
        if base.Flag.LowerM &gt; 1 {
            how := &quot;ref&quot;
            if n.Byval() {
                how = &quot;value&quot;
            }
            base.WarnfAt(n.Pos(), &quot;%v capturing by %s: %v (addr=%v assign=%v width=%d)&quot;, n.Curfn, how, n, loc.addrtaken, loc.reassigned, n.Type().Size())
        }

        // 建立闭包变量的数据流
        k := k
        if !cv.Byval() {
            k = k.addr(cv, &quot;reference&quot;)
        }
        b.flow(k.note(cv, &quot;captured by a closure&quot;), loc)
    }
}
</code></pre>
<p>举个例子：</p>
<pre><code class="language-go">package main

func main() {
	a := 1
	b := 2
	go func() {
		add(a, b)
	}()
	a = 99
}

func add(a, b int) {
	a = a + b
}
</code></pre>
<p>执行下面语句看看变量的捕获方式：</p>
<pre><code class="language-bash">go tool compile -m=2 main.go  | grep &quot;capturing&quot;
</code></pre>
<p>输出：</p>
<pre><code class="language-bash">main.go:4:2: main capturing by ref: a (addr=false assign=true width=8)
main.go:5:2: main capturing by value: b (addr=false assign=false width=8)
</code></pre>
<p>可以看到 <code>a</code> 是通过 <code>ref 地址引用</code> 的方式进行引用的，而 <code>b</code> 是通过 <code>value 值传递</code> 的方式进行引用的。</p>
<p>简单分析一下：上述例子中，闭包引用了 <code>a</code> 和 <code>b</code> 这 2 个闭包外声明的变量，而变量 <code>a</code> 在闭包之前又做了一些其他的操作，而 b 没有，所以对于 <code>a</code>，因为闭包外有操作，所以闭包内的操作可能是有特殊意义的，需要反馈到闭包外，就需要用 <code>ref 地址引用</code>了，而 <code>b</code> 在闭包外并不关心，所以闭包内的操作不会影响到闭包外，故直接使用 <code>value 值传递</code> 即可。</p>
<h2>闭包重写</h2>
<p>逃逸分析后，现在我们进入 <code>walk</code> 阶段了。这里首先会进行闭包重写。其核心逻辑在 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/walk/closure.go">walk/closure.go</a> 中。</p>
<p>闭包重写分为 2 种情况：</p>
<ul>
<li>闭包定义后被立即调用</li>
<li>闭包定义后不立即调用</li>
</ul>
<h3>闭包定义后被立即调用</h3>
<p>在闭包定义后被立即调用的情况下，闭包只会被调用一次，这时可以将闭包转换为普通函数的调用形式。</p>
<p>如：</p>
<pre><code class="language-go">func main() {
	a := 1
	b := 2
	go func() {
		add(a, b)
	}()
	a = 99
}

func add(a, b int) {
	a = a + b
}
</code></pre>
<p>会被转换为普通函数的调用形式：</p>
<pre><code class="language-go">func main() {
	a := 1
	b := 2
	go func1(&amp;a, b)
	a = 99
}

// 注意这里 a 的类型的 *int，因为在变量捕获阶段，判断了 a 应该用地址引用
func func1(a *int, b int) {
	add(*a, b)
}

func add(a, b int) {
	a = a + b
}
</code></pre>
<p>编译器具体的处理逻辑在 <code>directClosureCall()</code> 中：</p>
<pre><code class="language-go">// directClosureCall rewrites a direct call of a function literal into
// a normal function call with closure variables passed as arguments.
// This avoids allocation of a closure object.
//
// For illustration, the following call:
//
//	func(a int) {
//		println(byval)
//		byref++
//	}(42)
//
// becomes:
//
//	func(byval int, &amp;byref *int, a int) {
//		println(byval)
//		(*&amp;byref)++
//	}(byval, &amp;byref, 42)
func directClosureCall(n *ir.CallExpr) {
	clo := n.X.(*ir.ClosureExpr)
	clofn := clo.Func

    // 如果闭包足够简单，不进行处理，留给 walkClosure 处理。
	if ir.IsTrivialClosure(clo) {
		return // leave for walkClosure to handle
	}

	// 将闭包中的每个变量转换为函数的参数。对于引用捕获的变量，创建相应的指针参数。
	var params []*types.Field
	var decls []*ir.Name
	for _, v := range clofn.ClosureVars {
		if !v.Byval() {
			// 对于引用捕获的变量，创建相应的指针参数。
			addr := ir.NewNameAt(clofn.Pos(), typecheck.Lookup(&quot;&amp;&quot;+v.Sym().Name))
			addr.Curfn = clofn
			addr.SetType(types.NewPtr(v.Type()))
			v.Heapaddr = addr
			v = addr
		}

		v.Class = ir.PPARAM
		decls = append(decls, v)

		fld := types.NewField(src.NoXPos, v.Sym(), v.Type())
		fld.Nname = v
		params = append(params, fld)
	}

	// 创建一个新的函数类型，将捕获的变量作为前置参数，并更新函数的声明。
	f := clofn.Nname
	typ := f.Type()
	typ = types.NewSignature(nil, append(params, typ.Params().FieldSlice()...), typ.Results().FieldSlice())
	f.SetType(typ)
	clofn.Dcl = append(decls, clofn.Dcl...)

	// 将原始的闭包调用重写为对新函数的调用，并将捕获的变量作为实际参数传递。
	n.X = f
	n.Args.Prepend(closureArgs(clo)...)

	// 调整调用表达式的类型，以反映参数和返回值类型的变化。
	if typ.NumResults() == 1 {
		n.SetType(typ.Results().Field(0).Type)
	} else {
		n.SetType(typ.Results())
	}

	// 虽然不再是传统意义上的闭包，但为了确保函数被编译，将其添加到待编译列表中。
	ir.CurFunc.Closures = append(ir.CurFunc.Closures, clofn)
}
</code></pre>
<p>这段代码是 Go 编译器中的 <code>directClosureCall</code> 函数，用于将直接调用的函数字面量重写为正常的函数调用，同时将闭包变量作为参数传递。这避免了闭包对象的分配。</p>
<p>主要步骤如下：</p>
<ol>
<li><strong>检查闭包是否简单</strong>：如果闭包足够简单，不进行处理，留给 <code>walkClosure</code> 处理。</li>
<li><strong>处理闭包变量</strong>：将闭包中的每个变量转换为函数的参数。对于引用捕获的变量，创建相应的指针参数。</li>
<li><strong>更新函数类型和声明</strong>：创建一个新的函数类型，将捕获的变量作为前置参数，并更新函数的声明。</li>
<li><strong>重写调用</strong>：将原始的闭包调用重写为对新函数的调用，并将捕获的变量作为实际参数传递。</li>
<li><strong>更新调用表达式类型</strong>：调整调用表达式的类型，以反映参数和返回值类型的变化。</li>
<li><strong>添加到待编译列表</strong>：虽然不再是传统意义上的闭包，但为了确保函数被编译，将其添加到待编译列表中。</li>
</ol>
<p>这个函数的目的是优化闭包的调用，通过避免闭包对象的分配来提高性能。</p>
<h3>闭包定义后不立即调用</h3>
<p>如果闭包定义后不被立即调用，而是后续调用，那么同一个闭包可能会被调用多次，这个时候就必须创建闭包对象了。</p>
<p>编译器具体的处理逻辑在 <code>walkClosure()</code> 中：</p>
<pre><code class="language-go">func walkClosure(clo *ir.ClosureExpr, init *ir.Nodes) ir.Node {
	clofn := clo.Func

	// 如果没有闭包变量，闭包被视为全局函数，直接返回函数名。
	if ir.IsTrivialClosure(clo) {
		if base.Debug.Closure &gt; 0 {
			base.WarnfAt(clo.Pos(), &quot;closure converted to global&quot;)
		}
		return clofn.Nname
	}

	// 对于复杂闭包，设置需要上下文标记，并进行运行时检查。
	ir.ClosureDebugRuntimeCheck(clo)
	clofn.SetNeedctxt(true)

	// 确保闭包函数不会被重复添加到编译队列。
	if !clofn.Walked() {
		clofn.SetWalked(true)
		ir.CurFunc.Closures = append(ir.CurFunc.Closures, clofn)
	}

	// 构造一个复合字面量表达式来表示闭包实例。
	typ := typecheck.ClosureType(clo)

    // 将闭包函数和捕获的变量作为字段添加到闭包结构中。
	clos := ir.NewCompLitExpr(base.Pos, ir.OCOMPLIT, typ, nil)
	clos.SetEsc(clo.Esc())
	clos.List = append([]ir.Node{ir.NewUnaryExpr(base.Pos, ir.OCFUNC, clofn.Nname)}, closureArgs(clo)...)
	for i, value := range clos.List {
		clos.List[i] = ir.NewStructKeyExpr(base.Pos, typ.Field(i), value)
	}

    // 创建闭包结构的地址，并进行类型转换以符合闭包类型。
	addr := typecheck.NodAddr(clos)
	addr.SetEsc(clo.Esc())
	cfn := typecheck.ConvNop(addr, clo.Type())

	// 如果存在预分配的闭包对象，进行相关处理。
	if x := clo.Prealloc; x != nil {
		if !types.Identical(typ, x.Type()) {
			panic(&quot;closure type does not match order&#39;s assigned type&quot;)
		}
		addr.Prealloc = x
		clo.Prealloc = nil
	}

    // 对最终构建的闭包表达式进行进一步处理。
	return walkExpr(cfn, init)
}
</code></pre>
<ol>
<li><strong>检查是否为简单闭包</strong>：如果没有闭包变量，闭包被视为全局函数，直接返回函数名。</li>
<li><strong>处理非简单闭包</strong>：对于复杂闭包，设置需要上下文标记，并进行运行时检查。</li>
<li><strong>防止重复处理</strong>：确保闭包函数不会被重复添加到编译队列。</li>
<li><strong>创建闭包结构</strong>：构造一个复合字面量表达式来表示闭包实例。</li>
<li><strong>填充闭包参数</strong>：将闭包函数和捕获的变量作为字段添加到闭包结构中。</li>
<li><strong>地址和类型转换</strong>：创建闭包结构的地址，并进行类型转换以符合闭包类型。</li>
<li><strong>处理预分配的闭包</strong>：如果存在预分配的闭包对象，进行相关处理。</li>
<li><strong>表达式处理</strong>：对最终构建的闭包表达式进行进一步处理。</li>
</ol>
<h2>遍历函数</h2>
<p>闭包重写后，会进入 walk 阶段，如官方 文档所说：这是对 IR 表示的最后一次遍历，它有两个目的：</p>
<ol>
<li>将复杂的语句分解为简单的单个语句，引入临时变量并遵守求值顺序；</li>
<li>将高级 Go 构造转换为更原始的构造。</li>
</ol>
<p>举个例子，<code>walkRange()</code> 函数针对不同类型的 <code>range</code> 语句（数组、切片、映射、通道和字符串）进行处理，将其转换为更基本的循环结构，并应用必要的变换。</p>
<pre><code class="language-go">func walkRange(nrange *ir.RangeStmt) ir.Node {
    // ... 省略代码 ...

    // 遍历 range 语句的不同情况
    switch t.Kind() {
    default:
        base.Fatalf(&quot;walkRange&quot;)

    // 处理数组、切片、指针（指向数组）的情况
    case types.TARRAY, types.TSLICE, types.TPTR:
        // ... 省略代码 ...

    // 处理映射的情况
    case types.TMAP:
        // ... 省略代码 ...

    // 处理通道的情况
    case types.TCHAN:
        // ... 省略代码 ...

    // 处理字符串的情况
    case types.TSTRING:
        // ... 省略代码 ...
    }

    // ... 省略代码 ...

    // 构建并返回新的 for 语句
    nfor.PtrInit().Append(init...)
    typecheck.Stmts(nfor.Cond.Init())
    nfor.Cond = typecheck.Expr(nfor.Cond)
    nfor.Cond = typecheck.DefaultLit(nfor.Cond, nil)
    nfor.Post = typecheck.Stmt(nfor.Post)
    typecheck.Stmts(body)
    nfor.Body.Append(body...)
    nfor.Body.Append(nrange.Body...)

    var n ir.Node = nfor
    n = walkStmt(n)

    base.Pos = lno
    return n
}
</code></pre>
<p>这部分代码在 <a href="https://github.com/golang/go/tree/release-branch.go1.21/src/cmd/compile/internal/walk">walk</a>，对其他优化感兴趣的读者可以阅读这部分的代码。</p>
<h2>SSA 生成</h2>
<p>遍历函数（Walk）阶段后，编译器会将 AST 转换为下一个重要的中间表示形态，称为 SSA，其全称为 Static Single Assignment，静态单赋值。SSA 被大多数现代的编译器（包括 GCC 和 LLVM）使用，用于编译过程中的优化和代码生成。其核心特点和用途如下：</p>
<ol>
<li><strong>变量唯一赋值</strong>：在 SSA 形式中，每个变量只被赋值一次，使得变量的使用和修改更加清晰。</li>
<li><strong>方便的数据流分析</strong>：SSA 使得数据流分析更加直接和高效，因为每个变量的赋值点只有一个。</li>
<li><strong>优化算法的基础</strong>：许多编译器优化技术，如死代码消除、常量传播、强度削减等，在 SSA 形式下更易实现。</li>
<li><strong>Phi 函数</strong>：SSA 引入了 Phi 函数来处理变量在不同控制流路径上的不同赋值。</li>
<li><strong>代码生成</strong>：SSA 形式简化了目标代码生成的过程，因为它提供了更清晰的操作和变量使用视图。</li>
</ol>
<p>官方对 SSA 生成阶段进行了详细的描述：<a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/ssa/README.md">Introduction to the Go compiler&#39;s SSA backend</a></p>
<p>Go 提供了强有力的工具查看 SSA 初始及其后续优化阶段生成的代码片段，可以通过编译时指定 <code>GOSSAFUNC={pkg.func}</code> 实现。</p>
<p>以下面代码为例：</p>
<pre><code class="language-go">package main

var d uint8

func main() {
	var a uint8 = 1
	a = 2
	if true {
		a = 3
	}
	d = a
}
</code></pre>
<p>我们可以自行简单分析一下，这段代码前面 <code>a</code> 的所有操作其实都是无意义的，整段代码其实就在说 <code>d = 3</code> 这件事。</p>
<p>在 linux 或者 mac 上执行：</p>
<pre><code class="language-go">GOSSAFUNC=main.main go build main.go
</code></pre>
<p>在 Windows 上执行：</p>
<pre><code class="language-powershell">$env:GOSSAFUNC=&quot;main&quot;
go build .\main.go
</code></pre>
<p>可以看到输出：</p>
<pre><code class="language-powershell">dumped SSA to .\ssa.html
</code></pre>
<p>通过浏览器打开生成的 <code>ssa.html</code> 文件，我们可以看到 SSA 的初始阶段、优化阶段和最终阶段的代码片段。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/ssa.png" alt="ssa.html 文件示例"></p>
<p>我们直接看最终的结果，来看看我们前面的分析正确与否：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231203211001137.png" alt="ssa 最终结果"></p>
<p>可以看到这一行：<code>00003 (**+11**) MOVB $3, main.d(SB)</code>，那其实就是直接 <code>d = 3</code>。</p>
<h2>机器码生成</h2>
<p>在 SSA 阶段，编译器先执行与特定指令集无关的优化，再执行与特定指令集有关的优化，并最终生成与特定指令集有关的指令和寄存器分配方式。如 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/ssa/_gen/genericOps.go">ssa/_gen/genericOps.go</a> 中包含了与特定指令集无关的 Op 操作，在 <a href="https://github.com/golang/go/blob/release-branch.go1.21/src/cmd/compile/internal/ssa/_gen/S390XOps.go">ssa/_gen/AMD64Ops.go</a> 中包含了和 AMD64 指令集相关的 Op 操作。</p>
<p>机器码生成阶段是编译器的机器依赖阶段，主要过程如下：</p>
<ol>
<li><strong>Lowering 过程</strong>：这个过程将通用的 SSA 形式转换为特定于目标机器的变体。这包括将通用操作符替换为针对特定硬件优化的操作。</li>
<li><strong>代码优化</strong>：在机器特定的形式上执行最终优化，进一步提高代码效率。</li>
<li><strong>生成机器指令</strong>：将 Go 函数转换为 <code>obj.Prog</code> 指令序列。</li>
<li><strong>汇编和输出</strong>：这些指令由 <code>cmd/internal/obj</code> 模块的汇编器处理，转换为机器代码，并输出最终的目标文件。</li>
</ol>
<p>Go 为我们了解 Go 语言程序的编译和链接过程提供了一个非常好用的命令：</p>
<pre><code class="language-bash">go build -n
</code></pre>
<p>其中 <code>-n</code> 表示<strong>只输出编译过程中将要执行的 shell 命令，但不执行</strong>。</p>
<p>以下面程序为例：</p>
<pre><code class="language-go">package main

import (
	&quot;fmt&quot;

	&quot;github.com/spf13/cast&quot;
)

func main() {
	i := cast.ToInt(&quot;1&quot;)
	fmt.Println(i)
}
</code></pre>
<p>这个程序引入了标准库 <code>fmt</code> 以及第三方库 <code>github.com/spf13/cast</code>。</p>
<p>在工程目录下执行：</p>
<pre><code class="language-bash">go build -n -o main
</code></pre>
<p>可以看到输出：</p>
<pre><code class="language-bash">mkdir -p $WORK/b001/
cat &gt;$WORK/b001/importcfg.link &lt;&lt; &#39;EOF&#39; # internal
packagefile go-compilation=/Users/wangjiahan/Library/Caches/go-build/48/48745ff5ef7f8945297b5894ec377f47e246d94739e0b8f00e86b6d58879e71d-d
packagefile fmt=/Users/wangjiahan/Library/Caches/go-build/10/10ab74ff0df27a2f4bdbe7651290f13ad466f3df63e11241e07ccd21c169b206-d
packagefile github.com/spf13/cast=/Users/wangjiahan/Library/Caches/go-build/77/77eed0b7028cfc4c90d78d6670325d982325399573dff9d7f82ffbf76e4559e8-d
...
packagefile net/url=/Users/wangjiahan/Library/Caches/go-build/72/72d0ef9b8f99a52bf1de760bb2f630998d6bb66a3d2a3fa66bd66f4efddfbc71-d
modinfo &quot;0w\xaf\f\x92t\b\x02A\xe1\xc1\a\xe6\xd6\x18\xe6path\tgo-compilation\nmod\tgo-compilation\t(devel)\t\ndep\tgithub.com/spf13/cast\tv1.6.0\th1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=\nbuild\t-buildmode=exe\nbuild\t-compiler=gc\nbuild\tCGO_ENABLED=1\nbuild\tCGO_CFLAGS=\nbuild\tCGO_CPPFLAGS=\nbuild\tCGO_CXXFLAGS=\nbuild\tCGO_LDFLAGS=\nbuild\tGOARCH=arm64\nbuild\tGOOS=darwin\n\xf92C1\x86\x18 r\x00\x82B\x10A\x16\xd8\xf2&quot;
EOF
mkdir -p $WORK/b001/exe/
cd .
/opt/homebrew/opt/go/libexec/pkg/tool/darwin_arm64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=pie -buildid=FDJiS-4glijTlqBbjVbe/UWsngURatTblImv3DE6-/OjO-hZGekrr-XpHFs_zA/FDJiS-4glijTlqBbjVbe -extld=cc /Users/wangjiahan/Library/Caches/go-build/48/48745ff5ef7f8945297b5894ec377f47e246d94739e0b8f00e86b6d58879e71d-d
/opt/homebrew/opt/go/libexec/pkg/tool/darwin_arm64/buildid -w $WORK/b001/exe/a.out # internal
mv $WORK/b001/exe/a.out main
</code></pre>
<p>这里建议你先尝试自行分析一下这个编译过程，再继续往下阅读。</p>
<p>经过分析，上述过程可以分为以下 8 个步骤：</p>
<ol>
<li><strong>创建工作目录</strong>：<code>mkdir -p $WORK/b001/</code> 创建一个临时工作目录，用于存放编译过程中的临时文件。</li>
<li><strong>生成导入配置文件</strong>：<code>cat &gt;$WORK/b001/importcfg.link &lt;&lt; &#39;EOF&#39;</code> 命令开始创建一个名为 <code>importcfg.link</code> 的文件，这个文件包含了编译过程中需要的包文件路径。</li>
<li><strong>写入包文件路径</strong>：接下来的多行内容是对 <code>importcfg.link</code> 文件的填充，指定了各个依赖包的存储位置。</li>
<li><strong>结束文件写入</strong>：<code>EOF</code> 标志着 <code>importcfg.link</code> 文件内容的结束。</li>
<li><strong>创建可执行文件目录</strong>：<code>mkdir -p $WORK/b001/exe/</code> 创建一个目录，用于存放最终的可执行文件。</li>
<li><strong>编译链接</strong>：<code>/opt/homebrew/opt/go/libexec/pkg/tool/darwin_arm64/link -o $WORK/b001/exe/a.out ...</code> 这一步是编译链接的核心，它使用 Go 的链接工具，根据之前生成的 <code>importcfg.link</code> 文件，将代码编译成可执行文件。</li>
<li><strong>更新构建 ID</strong>：<code>/opt/homebrew/opt/go/libexec/pkg/tool/darwin_arm64/buildid -w $WORK/b001/exe/a.out</code> 这一步更新了可执行文件的构建 ID。</li>
<li><strong>移动可执行文件</strong>：<code>mv $WORK/b001/exe/a.out main</code> 将编译好的可执行文件移动到当前目录，并重命名为 <code>main</code>。</li>
</ol>
<p>如下图所示：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img6sR2uDuHwbkSNk3FuUxHmY-20231129143014805.png" alt="Go语言编译和链接过程"></p>
<h2>参考资料</h2>
<ul>
<li><a href="https://github.com/golang/go/tree/release-branch.go1.21/src/cmd/compile">Go1.21 官方文档</a></li>
<li><a href="https://book.douban.com/subject/35556889/">《Go 语言底层原理剖析》</a></li>
<li><a href="https://draveness.me/golang/docs/part1-prerequisite/ch02-compile/golang-compile-intro/">《Go 语言设计与实现》</a></li>
<li><a href="https://medium.com/a-journey-with-go/go-overview-of-the-compiler-4e5a153ca889">Go: Overview of the Compiler</a></li>
<li><a href="https://en.wikipedia.org/wiki/Abstract_syntax_tree">维基百科 - AST</a></li>
<li><a href="https://en.wikipedia.org/wiki/Static_single-assignment_form">维基百科 - SSA</a></li>
<li><a href="https://zhuanlan.zhihu.com/p/592602585">Go 机制：逃逸分析学习笔记</a></li>
<li>ChatGPT-4</li>
</ul>
<hr>
<p>以上便是 Go 语言在 1.21.0 这个版本下编译过程的整个过程，笔者会在阅读完《用 Go 语言自制解释器》和《用 Go 语言自制编译器》这两本书后，若有对编译原理有更深入的体会和感悟，再回过来对本文的内容进行勘误和进一步提炼。</p>
]]></content:encoded>
    </item>
    <item>
      <title>Kafka 顺序消息实现</title>
      <link>https://hedon.top/blog/kafka-ordered-msg/</link>
      <guid isPermaLink="true">https://hedon.top/blog/kafka-ordered-msg/</guid>
      <pubDate>Thu, 23 Nov 2023 23:42:34 GMT</pubDate>
      <description>本文详细介绍了如何实现 Kafka 的顺序消息，同时给出了消息队列顺序消息的通用实现思路，并简单介绍了 RabbitMQ、RocketMQ 和 Pulsar 在顺序消息方面的实现思路。</description>
      <category>Kafka</category><category>中间件</category><category>消息队列</category>
      <content:encoded><![CDATA[<h2>版本说明</h2>
<p>本文所有的讨论均在如下版本进行，其他版本可能会有所不同。</p>
<ul>
<li>Kafka: 3.6.0</li>
<li>Pulsar: 2.9.0</li>
<li>RabbitMQ 3.7.8</li>
<li>RocketMQ 5.0</li>
<li>Go1.21</li>
<li>github.com/segmentio/kafka-go v0.4.45</li>
</ul>
<h2>结论先行</h2>
<p>Kafka 只能保证单一分区内的顺序消息，无法保证多分区间的顺序消息。具体来说，要在 Kafka 完全实现顺序消息，至少需要保证以下几个条件：</p>
<ol>
<li>同一生产者生产消息；</li>
<li>同步发送消息到 Kafka broker；</li>
<li>所有消息发布到同一个分区；</li>
<li>同一消费者同步按照顺序消费消息。</li>
</ol>
<p>而要满足第 3 点，常用的有 2 种思路：</p>
<ol>
<li>固定消息的 key，生产端采用 <code>key hash</code> 的方式写入 broker；</li>
<li>自定义分区策略，要保证顺序的消息都写入到指定的分区。</li>
</ol>
<h2>消息队列中的顺序消息如何实现</h2>
<h3>顺序消息定义</h3>
<p>生产端发送出来的消息的顺序和消费端接收到消息的顺序是一样的。</p>
<h3>消息存储结构</h3>
<p>一般来说，消息队列都是基于<strong>顺序存储结构</strong>来存储数据的，不需要 B 树、B+ 树等复杂数据结构，利用文件的顺序读写，性能也很高。所以理想情况下，生产者按顺序发送消息，broker 会按顺序存储消息，消费者再按顺序消费消息，那么天然就实现了我们要的<strong>顺序消息</strong>了，如下：</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231124235214392.png" alt="消息队列顺序存储结构"></p>
<h3>基本条件</h3>
<p>但是一般情况下，消息队列为了支持更高的并发和吞吐，大多数都有分区（partition）和消费者组（consumer group）机制，而为了高可用，一般也会有副本（replica）机制，所以情况就复杂得多了，如下面几个例子，就会导致消息失序：</p>
<ol>
<li>多个生产者同时发送消息，那么到达 broker 的时间也是不确定的，所以 broker 就无法保证落盘的顺序性了；</li>
<li>单个生产者，但是采用异步发送，因为异步线程是并发执行的，由 CPU 进行调度，且有可能会因为发送失败而重试，所以也无法保证消息可以按照顺序到达 broker，同理，消费者异步处理消息，也无法保证顺序性；</li>
<li>一个 topic 有多个分区，那么即使是同一个生产者，由于分区策略，消息可能会被分发到多个分区中，消费者也就无法保证顺序性了。</li>
</ol>
<p>所以到这里，我们可以总结出实现顺序消息，至少需要满足以下 3 点：</p>
<ol>
<li>单一生产者同步发送；</li>
<li>单一分区；</li>
<li>单一消费者同步消费；</li>
</ol>
<p>第 1、3 点比较简单，Kafka 通过分区和 offset 的方式保证了消息的顺序。每个分区都是一个有序的、不可变的消息序列，每个消息在分区中都有一个唯一的序数标识，称为 <code>offset</code>。生产者在发送消息到分区时，Kafka 会自动为消息分配一个 offset。消费者在读取消息时，会按照 offset 的顺序来读取，从而保证了消息的顺序。</p>
<p>下面我们主要来谈一谈第 2 点。</p>
<h2>Kafka 顺序消息的实现</h2>
<h3>写入消息的过程</h3>
<ol>
<li><strong>配置生产者</strong>：首先，你需要配置 Kafka 生产者。这包括指定 Kafka 集群的地址和端口，以及其他相关配置项，如消息序列化器、分区策略等。</li>
<li><strong>创建生产者实例</strong>：在应用程序中，你需要创建一个 Kafka 生产者的实例。这个实例将用于与 Kafka 集群进行通信。</li>
<li><strong>序列化消息</strong>：在将消息发送到 Kafka 集群之前，你需要将消息进行序列化。Kafka 使用字节数组来表示消息的内容，因此你需要将消息对象序列化为字节数组。这通常涉及将消息对象转换为 JSON、Avro、Protobuf 等格式。</li>
<li><strong>选择分区</strong>：Kafka 的主题（topic）被分为多个分区（partition），每个分区都是有序且持久化的消息日志。当你发送消息时，你可以选择将消息发送到特定的分区，或者让 Kafka 根据分区策略自动选择分区。</li>
<li><strong>发送消息</strong>：一旦消息被序列化并选择了目标分区，你可以使用 Kafka 生产者的 <code>send()</code> 方法将消息发送到 Kafka 集群。发送消息时，生产者会将消息发送到对应分区的 leader 副本。</li>
<li><strong>异步发送</strong>：Kafka 生产者通常使用异步方式发送消息，这样可以提高吞吐量。生产者将消息添加到一个发送缓冲区（send buffer）中，并在后台线程中批量发送消息到 Kafka 集群。</li>
<li><strong>消息持久化</strong>：一旦消息被发送到 Kafka 集群的 leader 副本，它将被持久化并复制到其他副本，以确保数据的高可靠性和冗余性。只有当消息被成功写入到指定数量的副本后，生产者才会收到确认（acknowledgement）。</li>
<li><strong>错误处理和重试</strong>：如果发送消息时发生错误，生产者可以根据配置进行错误处理和重试。你可以设置重试次数、重试间隔等参数来控制重试行为。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231125013322459.png" alt="Kafka 生产者组件 -《Kafka权威指南第2版》"></p>
<h3>实现单一分区</h3>
<p>再 Kafka 中，我们要实现将消息写入到同一个分区，有 3 种思路：</p>
<ul>
<li>配置 <code>num.partitions=1</code> 或者创建 topic 的时候指定只有 1 个分区，但这会显著降低 Kafka 的吞吐量。</li>
<li><strong>固定消息的 key</strong>，然后采用 <strong>key hash</strong> 的分区策略，这样就可以让所有消息都被分到同一个分区中。</li>
<li>实现并指定<strong>自定义分区策略</strong>，可以根据业务需求，将需要顺序消费的消息都分到固定一个分区中。</li>
</ul>
<pre><code class="language-java">// 如下例子，所有使用&quot;same-key&quot;作为key的消息都会被发送到同一个Partition
ProducerRecord&lt;String, String&gt; record = new ProducerRecord&lt;String, String&gt;(&quot;topic&quot;, &quot;same-key&quot;, &quot;message&quot;);
producer.send(record);
</code></pre>
<h3>重平衡带来的问题</h3>
<p>如果采用上述的第 2 种思路：<strong>固定消息 key，依靠 key hash 分区策略，实现单一分区</strong>。在我们只有 1 个消费者的情况下是没有问题的，但是如果我们使用的是消费者组，那么，在发生<strong>重平衡</strong>操作的时候，就可能会有问题了。</p>
<p>Kafka 的重平衡（Rebalance）是指 Kafka 消费者组（Consumer Group）中的消费者实例对分区的重新分配。这个过程主要发生在以下几种情况：</p>
<ol>
<li>消费者组中新的消费者加入。</li>
<li>消费者组中的消费者离开或者挂掉。</li>
<li>订阅的 Topic 的分区数发生变化。</li>
<li>消费者调用了 <code>#unsubscribe()</code> 或者 <code>#subscribe()</code> 方法。</li>
</ol>
<p>重平衡的过程主要包括以下几个步骤：</p>
<ol>
<li><strong>Revoke</strong>：首先，Kafka 会撤销消费者组中所有消费者当前持有的分区。</li>
<li><strong>Assignment</strong>：然后，Kafka 会重新计算分区的分配情况，然后将分区分配给消费者。</li>
<li><strong>Resume</strong>：最后，消费者会开始消费新分配到的分区。</li>
</ol>
<p>重平衡的目的是为了保证消费者组中的消费者能够公平地消费 Topic 的分区。通过重平衡，Kafka 可以在消费者的数量发生变化时，动态地调整消费者对分区的分配，从而实现负载均衡。</p>
<p>然而，当发生重平衡时，分区可能会被重新分配给不同的消费者，这可能会影响消息的消费顺序。</p>
<p>举个例子：</p>
<ol>
<li>假设消费者 A 正在消费分区 P 的消息，它已经消费了消息 1，消息 2，正在处理消息 3。</li>
<li>此时，发生了重平衡，分区 P 被重新分配给了消费者 B。</li>
<li>消费者 B 开始消费分区 P，它会从上一次提交的偏移量（offset）开始消费。假设消费者 A 在处理消息 3 时发生了故障，没有提交偏移量，那么消费者 B 会从消息 3 开始消费。</li>
<li>这样，消息 3 可能会被消费两次，而且如果消费者 B 处理消息 3 的速度快于消费者 A，那么消息 3 可能会在消息 2 之后被处理，这就打破了消息的顺序性。</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img/image-20231125003222491.png" alt="重平衡导致消息失序"></p>
<p>再举个例子：</p>
<ol>
<li>topic-A 本来只有 3 个分区，按照 key hash，key 为 <code>same-key</code> 的消息应该都发到 第 2 个分区；</li>
<li>但是后来 topic-A 变成了 4 个分区，按照 key hash，key 为 <code>same-key</code> 的消息可能就被发到第 3 个分区了；</li>
<li>这就无法做到单一分区，可能会导致消息失序。</li>
</ol>
<p>当然这个例子不是由重平衡直接引起的，但是这种情况也是有可能导致消息失序的。</p>
<h3>缓解重平衡的问题</h3>
<ul>
<li><strong>避免动态改变分区数</strong>：在需要严格保持消息顺序的场景下，应避免动态地改变分区数。这意味着在设计 Kafka 主题时，应提前规划好所需的分区数，以避免日后需要进行更改。</li>
<li><strong>使用单个分区</strong>：对于严格顺序要求的场景，可以考虑使用单分区主题。虽然这会限制吞吐量和并发性，但可以保证消息的全局顺序。</li>
<li><strong>使用其他策略保持顺序</strong>：在某些情况下，可以通过在应用层实现逻辑来保持顺序，比如在消息中包含顺序号或时间戳，并在消费时根据这些信息重建正确的顺序。</li>
<li><strong>使用静态成员功能</strong>：它允许消费者在断开和重新连接时保持其消费者组内的身份，这可以减少因短暂的网络问题或消费者重启导致的不必要的重平衡。</li>
</ul>
<p>上面这些措施，只能减少重平衡带来的问题，并无法根除，如果非要实现严格意义上的顺序消息，要么在消息中加入时间戳等标记，在业务层保证顺序消费，要么就只能采用 <code>单一生产者同步发送 + 单一分区 +单一消费者同步消费</code> 这种模式了。</p>
<h3>静态成员功能</h3>
<p>Kafka 2.3.0 版本引入了一项新功能：静态成员（Static Membership）。这个功能主要是为了减少由于消费者重平衡（rebalance）引起的开销和延迟。在传统的 Kafka 消费者组中，当新的消费者加入或离开消费者组时，会触发重平衡。这个过程可能会导致消息的处理延迟，并且在高吞吐量的场景下可能会对性能造成影响。静态成员功能旨在缓解这些问题。以下是它的一些关键点：</p>
<p>静态成员的工作原理：</p>
<ol>
<li><p><strong>静态成员标识</strong>：消费者在加入消费者组时可以提供一个静态成员标识（Static Member ID）。这允许 Kafka Broker 识别特定的消费者实例，而不是仅仅依赖于消费者组内的动态分配。</p>
</li>
<li><p><strong>重平衡优化</strong>：当使用静态成员功能时，如果一个已知的消费者由于某种原因（如网络问题）短暂断开后重新连接，Kafka 不会立即触发重平衡。相反，Kafka 会等待一个预设的超时期限（session.timeout.ms），在此期间如果消费者重新连接，它将保留原来的分区分配。</p>
</li>
<li><p><strong>减少重平衡次数</strong>：这大大减少了由于消费者崩溃和恢复、网络问题或维护操作引起的不必要的重平衡次数。</p>
</li>
</ol>
<p>使用静态成员的优点：</p>
<ol>
<li><p><strong>提高稳定性</strong>：减少重平衡可以提高消费者组的整体稳定性，尤其是在大型消费者组和高吞吐量的情况下。</p>
</li>
<li><p><strong>减少延迟</strong>：由于减少了重平衡的次数，可以减少因重平衡导致的消息处理延迟。</p>
</li>
<li><p><strong>持久的消费者分区分配</strong>：这使得消费者在分区分配上更加持久，有助于更好地管理和优化消息的消费。</p>
</li>
</ol>
<p>如何使用：</p>
<ul>
<li>要使用静态成员功能，需要在 Kafka 消费者的配置中设置 <code>group.instance.id</code>。这个 ID 应该是唯一的，并且在消费者重启或重新连接时保持不变。同时，还需要配置 <code>session.timeout.ms</code>，以决定在触发重平衡之前消费者可以离线多长时间。</li>
</ul>
<p>注意事项：</p>
<ul>
<li>虽然静态成员功能可以减少重平衡的发生，但它不会完全消除重平衡。在消费者组成员的长期变化（如新消费者的加入或永久离开）时，仍然会发生重平衡。</li>
<li>需要合理设置 <code>session.timeout.ms</code>，以避免消费者由于短暂的网络问题或其他原因的断开而过早触发重平衡。</li>
</ul>
<p>静态成员功能在处理大规模 Kafka 应用时尤其有用，它提供了一种机制来优化消费者组的性能和稳定性。</p>
<h3>幂等性</h3>
<p>Kafka 0.11 版本后提供了幂等性生产者，这意味着即使生产者因为某些错误重试发送相同的消息，这些消息也只会被记录一次。这是通过给每一批发送到 Kafka 的消息分配一个序列号实现的，broker 使用这个序列号来删除重复发送的消息。使用幂等性生产者，可以减少重复消息的风险，这意味着即使在网络重试等情况下，消息的顺序也能得到更好的保证。因为重复消息不会被多次记录，所以不会破坏已有消息的顺序。</p>
<h2>其他常见消息队列顺序消息的实现</h2>
<h3>Pulsar</h3>
<p>Pulsar 和 Kafka 一样，都是通过生产端按 Key Hash 的方案将数据写入到同一个分区。</p>
<h3>RabbitMQ</h3>
<p>RabbitMQ 在生产时没有生产分区分配的过程。它是通过 <code>Exchange</code> 和 <code>Route Key</code> 机制来实现顺序消息的。<code>Exchange</code> 会根据设置好的 <code>Route Key</code> 将数据路由到不同的 <code>Queue</code> 中存储。此时 <code>Route Key</code> 的作用和 Kafka 的消息的 <code>Key</code> 是一样的。</p>
<h3>RocketMQ</h3>
<p>RocektMQ 支持<code>消息组（MessageGroup）</code>的概念。在生产端指定消息组，则同一个消息组的消息就会被发送到同一个分区中。此时这个消息组起到的作用和 Kakfa 的消息的 Key 是一样的。</p>
<h2>实战 Kafka 实现顺序消息</h2>
<blockquote>
<p>代码仓库：<a href="https://github.com/hedon954/kafka-go-examples/tree/master/orderedmsg">https://github.com/hedon954/kafka-go-examples/tree/master/orderedmsg</a></p>
</blockquote>
<p>下面我们来写一写实战用例，更加直观地感受一下 Kafka 顺序消息的实现细节。</p>
<p>首先我们在集群上创建一个 topic <code>ordered-msg-topic</code>，分区为 <code>3</code> 个，运行以下命令：</p>
<pre><code class="language-sh">/opt/kafka-3.6.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic ordered-msg-topic --partitions 3 --replication-factor 1
</code></pre>
<p>搭建 Kafka 集群可以看这两篇：<a href="/blog/kakfa-cluster-deploy/">Kafka 集群搭建(Zookeeper)</a>、<a href="/blog/kafka-kraft-deploy/">Kafka 集群搭建(KRaft)</a>。</p>
<h3>单生产者单消费者</h3>
<p>正常情况下，使用单一生产者同步发送和单一消费者同步发送，只要我们保证 key 是固定的，则所有消息都会写到同一个分区，是可以实现顺序消息的。</p>
<p>代码目录如下：</p>
<pre><code class="language-sh">├─config
│      config.go		# 常量定义
├─consumer
│      consumer.go		# 消费者
└─producer
        producer.go		# 生产者
</code></pre>
<p>首先我们先定义一些常量：</p>
<pre><code class="language-go">import &quot;github.com/segmentio/kafka-go&quot;

var (
	Topic      = &quot;ordered-msg-topic&quot;
	Brokers    = []string{&quot;kafka1.com:9092&quot;, &quot;kafka2.com:9092&quot;, &quot;kafka3.com:9092&quot;}
	Addr       = kafka.TCP(Brokers...)
	GroupId    = &quot;ordered-msg-group&quot;
	MessageKey = []byte(&quot;message-key&quot;)
)
</code></pre>
<p>我们先实现生产者端，主要是不断往 <code>ordered-msg-topic</code> 中写入数据：</p>
<pre><code class="language-go">package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;time&quot;

	&quot;kafka-go-examples/orderedmsg/config&quot;

	&quot;github.com/segmentio/kafka-go&quot;
)

func NewProducer() *kafka.Writer {
	return &amp;kafka.Writer{
		Addr:     config.Addr,
		Topic:    config.Topic,
		Balancer: &amp;kafka.Hash{}, // 哈希分区
	}
}

func NewMessages(count int) []kafka.Message {
	res := make([]kafka.Message, count)
	for i := 0; i &lt; count; i++ {
		res[i] = kafka.Message{
			Key:   config.MessageKey,
			Value: []byte(fmt.Sprintf(&quot;msg-%d&quot;, i+1)),
		}
	}
	return res
}

func main() {
	producer := NewProducer()
	messages := NewMessages(100)
	if err := producer.WriteMessages(context.Background(), messages...); err != nil {
		panic(err)
	}
	_ = producer.Close()
}
</code></pre>
<p>我们再来实现消费者，目前我们就启动 1 个消费者：</p>
<pre><code class="language-go">package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;time&quot;

	&quot;kafka-go-examples/orderedmsg/config&quot;

	&quot;github.com/segmentio/kafka-go&quot;
)

type Consumer struct {
	Id string
	*kafka.Reader
}

// NewConsumer 创建一个消费者，它属于 config.GroupId 这个消费者组
func NewConsumer(id string) *Consumer {
	c := &amp;Consumer{
		Id: id,
		Reader: kafka.NewReader(kafka.ReaderConfig{
			Brokers: config.Brokers,
			GroupID: config.GroupId,
			Topic:   config.Topic,
			Dialer: &amp;kafka.Dialer{
				ClientID: id,
			},
		}),
	}
	return c
}

// Read 读取消息，intervalMs 用来控制消费者的消费速度
func (c *Consumer) Read(intervalMs int) {
	fmt.Printf(&quot;%s start read\n&quot;, c.Id)
	for {
		msg, err := c.ReadMessage(context.Background())
		if err != nil {
			fmt.Printf(&quot;%s read msg err: %v\n&quot;, c.Id, err)
			return
		}
		// 模拟消费速度
		time.Sleep(time.Millisecond * time.Duration(intervalMs))
		fmt.Printf(&quot;%s read msg: %s, time: %s\n&quot;, c.Id, string(msg.Value), time.Now().Format(&quot;03-04-05&quot;))
	}
}

func main() {
	c1 := NewConsumer(&quot;consumer-1&quot;)
	c1.Read(500)
}
</code></pre>
<p>启动生产者生产消息，然后启动消费者，观察控制台，不难看出这种情况下就是顺序消费：</p>
<pre><code class="language-tex">consumer-1 read msg: msg-10, time: 04:29:10
consumer-1 read msg: msg-11, time: 04:29:11
consumer-1 read msg: msg-12, time: 04:29:12
consumer-1 read msg: msg-13, time: 04:29:13
consumer-1 read msg: msg-14, time: 04:29:14
consumer-1 read msg: msg-15, time: 04:29:15
consumer-1 read msg: msg-16, time: 04:29:16
</code></pre>
<h3>重平衡带来的问题</h3>
<p>我们先重建 topic，清楚掉之前的数据：</p>
<pre><code class="language-sh">/opt/kafka-3.6.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --delete --topic ordered-msg-topic
</code></pre>
<pre><code class="language-sh">/opt/kafka-3.6.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic ordered-msg-topic --partitions 3 --replication-factor 1
</code></pre>
<p>下面我们来采用消费者组的形式消费消息，在这期间，我们不断往消费者组中新增消费者，使其发生重平衡，我们来观察下消息的消费情况。</p>
<p>修改消费者端的 main()：</p>
<pre><code class="language-go">func main() {
	// 先启动 c1
	c1 := NewConsumer(&quot;consumer-1&quot;)
	go func() {
		c1.Read(500)
	}()

	// 5 秒后启动 c2
	time.Sleep(5 * time.Second)
	go func() {
		c2 := NewConsumer(&quot;consumer-2&quot;)
		c2.Read(300)
	}()

	// 再 10 秒后启动 c3 和 c4
	time.Sleep(10 * time.Second)
	go func() {
		c3 := NewConsumer(&quot;consumer-3&quot;)
		c3.Read(100)
	}()
	go func() {
		c4 := NewConsumer(&quot;consumer-4&quot;)
		c4.Read(100)
	}()

	select {}
}
</code></pre>
<p>先启动生产者重新生产数据，然后再启动消费者消费数据，观察控制台：</p>
<pre><code class="language-bash">consumer-1 start read
consumer-1 read msg: msg-1, time: 04:44:28
consumer-1 read msg: msg-2, time: 04:44:28
consumer-1 read msg: msg-3, time: 04:44:29		# consumer-1 按顺序消费
consumer-2 start read						  # consumer-2 进来
consumer-1 read msg: msg-4, time: 04:44:30
consumer-1 read msg: msg-5, time: 04:44:30
consumer-1 read msg: msg-6, time: 04:44:31      # 这里相差了 6s，就是在进行重平衡
consumer-2 read msg: msg-7, time: 04:44:37      # 重平衡后发现原来的分区给 consumer-2 消费了
consumer-1 read msg: msg-7, time: 04:44:37	    # 这里发生了重复消费
consumer-2 read msg: msg-8, time: 04:44:37
consumer-2 read msg: msg-9, time: 04:44:37
consumer-2 read msg: msg-10, time: 04:44:38
consumer-2 read msg: msg-11, time: 04:44:38
consumer-2 read msg: msg-12, time: 04:44:38
consumer-2 read msg: msg-13, time: 04:44:39
consumer-2 read msg: msg-14, time: 04:44:39
consumer-2 read msg: msg-15, time: 04:44:39      # consumer-2 按顺序消息
consumer-4 start read						   # consumer-3 和 consumer-4 进来
consumer-3 start read
consumer-2 read msg: msg-16, time: 04:44:40
consumer-4 read msg: msg-17, time: 04:44:46      # 这里发生重平衡
consumer-4 read msg: msg-18, time: 04:44:46      # 重平衡后由 consumer-4 负责该分区
consumer-2 read msg: msg-17, time: 04:44:46      # 这里由于 2 的速度比 4 慢很多，所以就乱序了，还重复消费
consumer-4 read msg: msg-19, time: 04:44:46
consumer-4 read msg: msg-20, time: 04:44:46
# ...
</code></pre>
<h3>总结</h3>
<p>当我们采用消费者组的时候，由于重平衡机制的存在，单纯从 Kafka 的角度来说是无法完全实现顺序消息的，只能通过静态成员功能、避免分区数量变化和减少消费者组成员数量变化等方式来尽可能减少重平衡的发生，进而尽可能维持消息的顺序性。</p>
<h2>参考</h2>
<ul>
<li><a href="https://time.geekbang.com/column/intro/100552001">极客时间 - 深入拆解消息队列 47 讲（许文强）</a></li>
<li><a href="https://www.qidian.com/book/1035938080/">《Kafka 权威指南（第 2 版）》</a></li>
<li><a href="https://pulsar.staged.apache.org/docs/zh-CN/next/concepts-messaging/#%E9%A1%BA%E5%BA%8F%E4%BF%9D%E8%AF%81">Pulsar 官方文档-分区 topic-顺序保证</a></li>
<li><a href="https://rocketmq.apache.org/zh/docs/featureBehavior/03fifomessage">RocketMQ 官方文档-功能特性-顺序消息</a></li>
<li><a href="https://www.rabbitmq.com/tutorials/tutorial-two-go.html">RabbitMQ 官方文档</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Kafka 集群部署（KRaft）</title>
      <link>https://hedon.top/blog/kafka-kraft-deploy/</link>
      <guid isPermaLink="true">https://hedon.top/blog/kafka-kraft-deploy/</guid>
      <pubDate>Wed, 22 Nov 2023 21:52:31 GMT</pubDate>
      <description>Kafka 在 3.3.1 版本发布了第一个可以在生产环境使用的 KRaft 版本，正式拜托了 Zookeeper。本文基于 Kafka 3.6.0 版本，详细介绍了 Kafka KRaft 版本的集群部署过程。</description>
      <category>Kafka</category><category>部署</category><category>中间件</category><category>消息队列</category>
      <content:encoded><![CDATA[<h2>版本说明</h2>
<ul>
<li>Ubuntu 18.04.6</li>
<li>Kafka 3.6.0</li>
<li>JDK8</li>
</ul>
<h2>集群配置</h2>
<table>
<thead>
<tr>
<th align="center">操作系统</th>
<th align="center">ip</th>
<th align="center">域名</th>
<th align="center">Kafka Broker 端口</th>
<th align="center">Kafka Controller 端口</th>
</tr>
</thead>
<tbody><tr>
<td align="center">Ubuntu 18.04.6</td>
<td align="center">192.168.50.131</td>
<td align="center">kafka1.com</td>
<td align="center">9092</td>
<td align="center">9093</td>
</tr>
<tr>
<td align="center">Ubuntu 18.04.6</td>
<td align="center">192.168.50.132</td>
<td align="center">kafka2.com</td>
<td align="center">9092</td>
<td align="center">9093</td>
</tr>
<tr>
<td align="center">Ubuntu 18.04.6</td>
<td align="center">192.168.50.133</td>
<td align="center">kafka3.com</td>
<td align="center">9092</td>
<td align="center">9093</td>
</tr>
</tbody></table>
<h2>安装 vim, curl</h2>
<pre><code class="language-sh">sudo apt update
sudo apt install vim
sudo apt install curl
</code></pre>
<h2>配置静态 ip 和 hosts</h2>
<p>为了使用域名，更加方便的进行配置，这里将虚拟机的 DHCP 改成了静态分配 IP，所以需要手动设置一下每台机器 IP 地址，这里以 <code>192.168.50.131</code> 为例。</p>
<ol>
<li><p>找到网络接口名称，运行以下命令：</p>
<pre><code class="language-sh">ip addr
</code></pre>
<p>查找以 <code>ens</code> 或 <code>eth</code> 开头的接口名称。例如，<code>ens33</code> 或 <code>eth0</code>。</p>
<pre><code class="language-sh">hedon@ubuntu:~$ ip addr
1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: ens33: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 00:0c:29:82:9e:69 brd ff:ff:ff:ff:ff:ff
    inet 192.168.50.133/24 brd 192.168.50.255 scope global dynamic noprefixroute ens33
       valid_lft 1644sec preferred_lft 1644sec
    inet6 fe80::c367:c7cc:3ad4:23b3/64 scope link
       valid_lft forever preferred_lft forever
</code></pre>
<p>可以找到 <code>ens33</code>，其中 <code>inet 192.168.50.133/24</code> 表示 IP 地址为 <code>192.168.50.133</code>，子网掩码为 <code>/24</code>（等于 <code>255.255.255.0</code>）。</p>
<p>这个 IP 地址是 DHCP 动态分配的，说明宿主机分配给虚拟机的 IP 范围就在 <code>192.168.50.xxx</code>，所以我们会将静态 IP 配置在这个范围内。</p>
</li>
<li><p>获取网关地址</p>
<pre><code class="language-sh">ip route | grep default
</code></pre>
<p>输出：</p>
<pre><code class="language-sh">hedon@ubuntu:~$ ip route | grep default
default via 192.168.50.2 dev ens33 proto dhcp metric 100
</code></pre>
<p>说明默认网关是 <code>192.168.50.2</code>，</p>
</li>
<li><p>编辑 <code>/etc/network/interfaces</code> 文件，配置静态 IP 地址，内容如下：</p>
<pre><code class="language-tex">auto ens33
iface ens33 inet static
   address 192.168.50.131
   netmask 255.255.255.0
   gateway 192.168.50.2
   dns-nameservers 8.8.8.8 8.8.4.4
</code></pre>
</li>
<li><p>重启</p>
<pre><code class="language-sh">su reboot
</code></pre>
</li>
<li><p>再次查看 ip 地址</p>
<pre><code class="language-sh">ip addr
</code></pre>
<p>有以下输出便说明静态 IP 配置成功了。</p>
<pre><code class="language-sh">1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: ens33: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 00:0c:29:82:9e:69 brd ff:ff:ff:ff:ff:ff
    inet 192.168.50.131/24 brd 192.168.50.255 scope global ens33
       valid_lft forever preferred_lft forever
    inet6 fe80::20c:29ff:fe82:9e69/64 scope link
       valid_lft forever preferred_lft forever
</code></pre>
</li>
<li><p>配置域名</p>
<pre><code class="language-sh">sudo vim /etc/hosts
</code></pre>
<p>追加内容如下：</p>
<pre><code class="language-sh">192.168.50.131 kafka1.com
192.168.50.132 kafka2.com
192.168.50.133 kafka3.com
</code></pre>
</li>
<li><p>ping 一下</p>
<pre><code class="language-sh">hedon@ubuntu:~$ ping kafka1.com
PING kafka1.com (192.168.50.131) 56(84) bytes of data.
64 bytes from kafka1.com (192.168.50.131): icmp_seq=1 ttl=64 time=0.024 ms
64 bytes from kafka1.com (192.168.50.131): icmp_seq=2 ttl=64 time=0.021 ms
64 bytes from kafka1.com (192.168.50.131): icmp_seq=3 ttl=64 time=0.029 ms
^C
--- kafka1.com ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2029ms
rtt min/avg/max/mdev = 0.021/0.024/0.029/0.006 ms
</code></pre>
</li>
<li><p>ping 一下百度，看看能不能访问外网</p>
<pre><code class="language-sh">hedon@ubuntu:~$ ping baidu.com
ping: baidu.com: Name or service not known
</code></pre>
<p>如果这里可以访问，则直接跳过进入下一步，不可以的话，需要配置一下域名解析系统。</p>
</li>
<li><p>配置域名解析系统</p>
<p>Ubuntu 系统使用 <code>systemd-resolved</code> 服务来管理 DNS，你可以在 <code>/etc/systemd/resolved.conf</code> 文件中进行 DNS 配置。</p>
<pre><code class="language-sh">sudo vim /etc/systemd/resolved.conf
</code></pre>
<p>取消或添加 <code>DNS</code> 的注释，并修改为：</p>
<pre><code class="language-tex">[Resolve]
DNS=8.8.8.8 8.8.4.4
</code></pre>
<p>重启启动 <code>systemd-resolved</code>：</p>
<pre><code class="language-sh">sudo systemctl restart systemd-resolved
</code></pre>
<p>再尝试 ping 一下百度：</p>
<pre><code class="language-sh">hedon@ubuntu:~$ ping www.baidu.com
PING www.a.shifen.com (153.3.238.110) 56(84) bytes of data.
64 bytes from 153.3.238.110 (153.3.238.110): icmp_seq=1 ttl=128 time=15.9 ms
64 bytes from 153.3.238.110 (153.3.238.110): icmp_seq=2 ttl=128 time=15.9 ms
64 bytes from 153.3.238.110 (153.3.238.110): icmp_seq=3 ttl=128 time=16.1 ms
64 bytes from 153.3.238.110 (153.3.238.110): icmp_seq=4 ttl=128 time=15.3 ms
^C
--- www.a.shifen.com ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 14104ms
rtt min/avg/max/mdev = 15.368/15.850/16.145/0.291 ms
</code></pre>
</li>
</ol>
<details open>
<summary>补充说明：`/etc/network/interfaces` 文件的配置</summary>


<p>这是一个用于配置 Linux 系统上网络接口的文件。在这个示例中，我们为名为 <code>ens33</code> 的网络接口配置了静态 IP 地址和相关的网络设置。下面是各行的解释：</p>
<ol>
<li><p><code>auto ens33</code>: 这一行表示在系统启动时自动激活 <code>ens33</code> 网络接口。<code>auto</code> 关键字后面跟着接口名称。</p>
</li>
<li><p><code>iface ens33 inet static</code>: 这一行定义了 <code>ens33</code> 网络接口的配置。<code>iface</code> 关键字后面跟着接口名称，<code>inet</code> 表示我们正在配置 IPv4 地址，<code>static</code> 表示我们要为接口分配一个静态 IP 地址（而不是通过 DHCP 获得）。</p>
</li>
<li><p><code>address 192.168.50.131</code>: 这一行设置了网络接口的静态 IP 地址。在这个例子中，我们为 <code>ens33</code> 接口分配了 <code>192.168.50.131</code> IP 地址。</p>
<blockquote>
<p>IP 地址是 Internet 协议（IP）用于在网络中唯一标识设备的数字标签。每个连接到网络的设备都需要一个唯一的 IP 地址，以便其他设备可以找到并与之通信。IP 地址通常分为两种版本：IPv4 和 IPv6。在此示例中，我们使用了一个 IPv4 地址。</p>
</blockquote>
</li>
<li><p><code>netmask 255.255.255.0</code>: 这一行定义了子网掩码。在这个例子中，子网掩码是 <code>255.255.255.0</code>，表示前三个字节（24 位）是网络地址，最后一个字节（8 位）是主机地址。</p>
<blockquote>
<p>子网掩码用于划分 IP 地址的网络部分和主机部分。子网掩码与 IP 地址进行按位与操作，从而得到网络地址。这有助于确定哪些 IP 地址属于同一子网，以便正确地将数据包路由到目的地。子网划分有助于组织网络、提高安全性和管理性。</p>
</blockquote>
</li>
<li><p><code>gateway 192.168.50.2</code>: 这一行设置了默认网关。在这个例子中，我们将默认网关设置为 <code>192.168.50.2</code>。默认网关是用于将数据包发送到其他网络的路由器或设备的 IP 地址。</p>
<blockquote>
<p>网关是一个充当网络中数据包传输的中继点的设备，通常是一个路由器。当一个设备需要将数据包发送到不同子网的另一个设备时，它会将数据包发送到网关。网关负责将数据包路由到正确的目的地。默认网关是设备用于将数据包发送到其他网络的首选网关。</p>
</blockquote>
</li>
<li><p><code>dns-nameservers 8.8.8.8 8.8.4.4</code>: 这一行指定了 DNS 服务器的 IP 地址。在这个例子中，我们使用了谷歌的公共 DNS 服务器 <code>8.8.8.8</code> 和 <code>8.8.4.4</code>。DNS 服务器用于将主机名解析为 IP 地址。</p>
<blockquote>
<p>域名系统（DNS）是将人类可读的域名（例如 <a href="http://www.baidu.com%EF%BC%89IP">www.baidu.com）IP</a> 地址的系统。DNS 服务器是负责执行此解析过程的服务器。当您在浏览器中输入一个网址时，计算机会向 DNS 服务器查询该域名对应的 IP 地址，然后将请求发送到该 IP 地址以获取网页内容。</p>
</blockquote>
</li>
</ol>
<p>配置文件中的这些设置将在系统启动时生效。要立即应用更改，您可以使用以下命令重启网络服务：</p>
<pre><code class="language-bash">sudo systemctl restart networking
</code></pre>
</details>

<h2>安装 jdk</h2>
<pre><code class="language-sh">sudo apt update
sudo apt install openjdk-8-jdk
</code></pre>
<p>验证 java8 是否已经安装成功：</p>
<pre><code class="language-sh">java -version
</code></pre>
<p>有以下类似输出的话则表明安装成功：</p>
<pre><code class="language-sh">openjdk version &quot;1.8.0_362&quot;
OpenJDK Runtime Environment (build 1.8.0_362-8u372-ga~us1-0ubuntu1~18.04-b09)
OpenJDK 64-Bit Server VM (build 25.362-b09, mixed mode)
</code></pre>
<h2>安装 Kafka</h2>
<ol>
<li><p>下载并解压 Kafka</p>
<pre><code class="language-sh">wget https://archive.apache.org/dist/kafka/3.6.0/kafka_2.13-3.6.0.tgz
tar -zxvf kafka_2.13-3.6.0.tgz
</code></pre>
</li>
<li><p>将解压缩后的文件夹移动到 <code>/opt</code> 目录中：</p>
<pre><code class="language-sh">sudo mv kafka_2.13-3.6.0 /opt/kafka-3.6.0
</code></pre>
</li>
<li><p>使用 Kafka 提供的脚本生成一个 ClusterID</p>
<pre><code class="language-sh">export KAFKA_CLUSTER_ID=&quot;$(/opt/kafka-3.6.0/bin/kafka-storage.sh random-uuid)&quot;
</code></pre>
<p>输出 ClusterID</p>
<pre><code class="language-sh">hedon@ubuntu:/opt/kafka-3.6.0$ echo $KAFKA_CLUSTER_ID
XiMRcbJ-QEO694L7sfDdBQ
</code></pre>
<p>在其他节点上将 <code>KAFKA_CLUSTER_ID</code> 设置为上面的值：</p>
<pre><code class="language-sh">export KAFKA_CLUSTER_ID=XiMRcbJ-QEO694L7sfDdBQ
</code></pre>
</li>
<li><p>备份配置文件，注意这里的配置文件是 <code>config/kraft/server.properties</code>，在 <code>config</code> 目录下的 <code>kraft</code> 目录中：</p>
<pre><code class="language-sh">cp /opt/kafka-3.6.0/config/kraft/server.properties /opt/kafka-3.6.0/config/kraft/server.properties.bak
</code></pre>
</li>
<li><p>修改配置</p>
<pre><code class="language-sh">vim /opt/kafka-3.6.0/config/kraft/server.properties
</code></pre>
<p>主要修改内容如下：</p>
<pre><code class="language-properties"># 节点 ID，分别为 1，2，3
node.id=1
# 日志目录
log.dirs=/opt/kafka-3.6.0/kafka-combined-logs
# 可以成为控制器的节点和它们的端口
controller.quorum.voters=1@kafka1.com:9093,2@kafka2.com:9093,3@kafka3.com:9093
# 定义 Kafka Broker 如何向外部公布它的地址。
# 这是 Kafka Broker 通知 Producer 和 Consumer 如何连接到自己的方式。
# 例如，如果你设置 advertised.listeners=PLAINTEXT://my.public.ip:9092，
# 那么 Kafka Broker 将告诉 Producer 和 Consumer 它的公共 IP 地址是 my.public.ip，并且它在 9092 端口上监听连接。
# 这里我们需要在 3 个节点分别设置对应的地址
advertised.listeners=PLAINTEXT://kafka1.com:9092
</code></pre>
</li>
<li><p>格式化日志目录</p>
<pre><code class="language-sh">/opt/kafka-3.6.0/bin/kafka-storage.sh format -t $KAFKA_CLUSTER_ID -c /opt/kafka-3.6.0/config/kraft/server.properties
</code></pre>
<p>输出：</p>
<pre><code class="language-sh">Formatting /opt/kafka-3.6.0/kraft-combined-logs with metadata.version 3.6-IV2.
</code></pre>
</li>
<li><p>三个节点都启动 Kafka</p>
<pre><code class="language-sh">/opt/kafka-3.6.0/bin/kafka-server-start.sh -daemon /opt/kafka-3.6.0/config/kraft/server.properties
</code></pre>
</li>
<li><p>选择任意一个节点创建一个新 topic</p>
<pre><code class="language-sh">/opt/kafka-3.6.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic test --replication-factor 1 --partitions=2
</code></pre>
<p>输出：</p>
<pre><code class="language-sh">Created topic test.
</code></pre>
</li>
<li><p>在其他节点获取 <code>test</code> 这个 <code>topic</code> 的信息</p>
<pre><code class="language-sh">/opt/kafka-3.6.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic test
</code></pre>
<p>可以看到关于 <code>test</code> 这个 <code>topic</code> 的信息是可以获取到的，说明集群之前信息是互通的，集群搭建完毕。</p>
<pre><code class="language-sh">Topic: test    TopicId: svJClTUpSFa9Z6FWDvkARg    PartitionCount: 2    ReplicationFactor: 1    Configs: segment.bytes=1073741824
    Topic: test    Partition: 0    Leader: 2    Replicas: 2    Isr: 2
    Topic: test    Partition: 1    Leader: 3    Replicas: 3    Isr: 3
</code></pre>
</li>
<li><p>随便选择一个节点，往 <code>test</code> 里面写入数据：</p>
<pre><code class="language-sh">/opt/kafka-3.6.0/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic test
</code></pre>
<p>输入数据后按回车即发送一条数据，可以随时按 <code>Ctrl + C</code> 退出：</p>
<pre><code class="language-sh">hedon@ubuntu:~/Downloads$ /opt/kafka-3.6.0/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic test
&gt;msg1
&gt;msg2
&gt;msg 3
&gt;^
</code></pre>
</li>
<li><p>随便选择一个节点，启动消费者消费 <code>topic</code> 中的数据：</p>
<pre><code class="language-sh">/opt/kafka-3.6.0/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test --from-beginning
</code></pre>
<p>输出：</p>
<pre><code class="language-sh">hedon@ubuntu:/opt/kafka-3.6.0$ /opt/kafka-3.6.0/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test --from-beginning
msg1
msg2
msg 3
^CProcessed a total of 3 messages
</code></pre>
</li>
</ol>
<p>至此，Kafka 的 KRaft 版本集群就部署完毕了！</p>
<details open>
<summary>补充说明 - KRaft 配置文件</summary>


<p>下面是 Kafka KRaft 版本配置文件每个配置项的解释：</p>
<table>
<thead>
<tr>
<th>配置项</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>process.roles</td>
<td>Kafka 服务器的角色，设置此项将 Kafka 置于 KRaft 模式。可能的值包括 &quot;broker&quot; 和 &quot;controller&quot;。</td>
</tr>
<tr>
<td>node.id</td>
<td>与此实例关联的节点 ID。</td>
</tr>
<tr>
<td>controller.quorum.voters</td>
<td>控制器选举的投票节点，格式为 <code>node-id@host:port</code>。</td>
</tr>
<tr>
<td>listeners</td>
<td>服务器监听的地址，格式为 <code>listener_name://host_name:port</code>。</td>
</tr>
<tr>
<td>inter.broker.listener.name</td>
<td>用于 broker 之间通信的监听器名称。</td>
</tr>
<tr>
<td>advertised.listeners</td>
<td>服务器向客户端宣告的监听器名称、主机名和端口。</td>
</tr>
<tr>
<td>controller.listener.names</td>
<td>控制器使用的监听器名称列表。</td>
</tr>
<tr>
<td>listener.security.protocol.map</td>
<td>监听器名称到安全协议的映射。默认情况下，它们是相同的。</td>
</tr>
<tr>
<td>num.network.threads</td>
<td>服务器用于从网络接收请求和向网络发送响应的线程数。</td>
</tr>
<tr>
<td>num.io.threads</td>
<td>服务器用于处理请求（可能包括磁盘 I/O）的线程数。</td>
</tr>
<tr>
<td>socket.send.buffer.bytes</td>
<td>服务器用于发送数据的缓冲区大小。</td>
</tr>
<tr>
<td>socket.receive.buffer.bytes</td>
<td>服务器用于接收数据的缓冲区大小。</td>
</tr>
<tr>
<td>socket.request.max.bytes</td>
<td>服务器接受的请求的最大大小（用于防止内存溢出）。</td>
</tr>
<tr>
<td>log.dirs</td>
<td>用于存储日志文件的目录列表。</td>
</tr>
<tr>
<td>num.partitions</td>
<td>每个主题的默认日志分区数。</td>
</tr>
<tr>
<td>num.recovery.threads.per.data.dir</td>
<td>每个数据目录在启动时用于日志恢复和关闭时用于刷新的线程数。</td>
</tr>
<tr>
<td>offsets.topic.replication.factor</td>
<td>内部主题 &quot;**consumer_offsets&quot; 和 &quot;**transaction_state&quot; 的复制因子。</td>
</tr>
<tr>
<td>transaction.state.log.replication.factor</td>
<td>事务状态日志的复制因子。</td>
</tr>
<tr>
<td>transaction.state.log.min.isr</td>
<td>事务状态日志的最小同步副本数。</td>
</tr>
<tr>
<td>log.flush.interval.messages</td>
<td>强制将数据刷新到磁盘之前接受的消息数。</td>
</tr>
<tr>
<td>log.flush.interval.ms</td>
<td>消息在日志中停留的最大时间，超过这个时间就会强制刷新到磁盘。</td>
</tr>
<tr>
<td>log.retention.hours</td>
<td>由于年龄而使日志文件有资格被删除的最小年龄。</td>
</tr>
<tr>
<td>log.retention.bytes</td>
<td>基于大小的日志保留策略。</td>
</tr>
<tr>
<td>log.segment.bytes</td>
<td>日志段文件的最大大小。</td>
</tr>
<tr>
<td>log.retention.check.interval.ms</td>
<td>检查日志段是否可以根据保留策略被删除的间隔。</td>
</tr>
</tbody></table>
<p>请注意，这只是 Kafka 配置的一部分，Kafka 配置的完整列表可以在 <a href="https://kafka.apache.org/36/documentation.html#configuration">Kafka 的官方文档</a>中找到。</p>
</details>]]></content:encoded>
    </item>
    <item>
      <title>Kafka 集群部署</title>
      <link>https://hedon.top/blog/kakfa-cluster-deploy/</link>
      <guid isPermaLink="true">https://hedon.top/blog/kakfa-cluster-deploy/</guid>
      <pubDate>Wed, 22 Nov 2023 00:28:59 GMT</pubDate>
      <description>本文总结了在 Ubuntu18.04 虚拟机上部署 Kafka 集群的具体过程。</description>
      <category>Kafka</category><category>部署</category><category>中间件</category><category>消息队列</category>
      <content:encoded><![CDATA[<h2>版本说明</h2>
<ul>
<li>Ubuntu 18.04.6</li>
<li>Zookeeper 3.5.9</li>
<li>Kafka 2.7.0</li>
<li>JDK8</li>
</ul>
<h2>集群配置</h2>
<table>
<thead>
<tr>
<th align="center">操作系统</th>
<th align="center">ip</th>
<th align="center">域名</th>
<th align="center">Zookeeper 端口</th>
<th align="center">Kafka 端口</th>
</tr>
</thead>
<tbody><tr>
<td align="center">Ubuntu 18.04.6</td>
<td align="center">192.168.50.131</td>
<td align="center">kafka1.com</td>
<td align="center">2181</td>
<td align="center">9092</td>
</tr>
<tr>
<td align="center">Ubuntu 18.04.6</td>
<td align="center">192.168.50.132</td>
<td align="center">kafka2.com</td>
<td align="center">2181</td>
<td align="center">9092</td>
</tr>
<tr>
<td align="center">Ubuntu 18.04.6</td>
<td align="center">192.168.50.133</td>
<td align="center">kafka3.com</td>
<td align="center">2181</td>
<td align="center">9092</td>
</tr>
</tbody></table>
<h2>安装 vim, curl</h2>
<pre><code class="language-sh">sudo apt update
sudo apt install vim
sudo apt install curl
</code></pre>
<h2>配置静态 ip 和 hosts</h2>
<p>为了使用域名，更加方便的进行配置，这里将虚拟机的 DHCP 改成了静态分配 IP，所以需要手动设置一下每台机器 IP 地址，这里以 <code>192.168.50.131</code> 为例。</p>
<ol>
<li><p>找到网络接口名称，运行以下命令：</p>
<pre><code class="language-sh">ip addr
</code></pre>
<p>查找以 <code>ens</code> 或 <code>eth</code> 开头的接口名称。例如，<code>ens33</code> 或 <code>eth0</code>。</p>
<pre><code class="language-sh">hedon@ubuntu:~$ ip addr
1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: ens33: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 00:0c:29:82:9e:69 brd ff:ff:ff:ff:ff:ff
    inet 192.168.50.133/24 brd 192.168.50.255 scope global dynamic noprefixroute ens33
       valid_lft 1644sec preferred_lft 1644sec
    inet6 fe80::c367:c7cc:3ad4:23b3/64 scope link
       valid_lft forever preferred_lft forever
</code></pre>
<p>可以找到 <code>ens33</code>，其中 <code>inet 192.168.50.133/24</code> 表示 IP 地址为 <code>192.168.50.133</code>，子网掩码为 <code>/24</code>（等于 <code>255.255.255.0</code>）。</p>
<p>这个 IP 地址是 DHCP 动态分配的，说明宿主机分配给虚拟机的 IP 范围就在 <code>192.168.50.xxx</code>，所以我们会将静态 IP 配置在这个范围内。</p>
</li>
<li><p>获取网关地址</p>
<pre><code class="language-sh">ip route | grep default
</code></pre>
<p>输出：</p>
<pre><code class="language-sh">hedon@ubuntu:~$ ip route | grep default
default via 192.168.50.2 dev ens33 proto dhcp metric 100
</code></pre>
<p>说明默认网关是 <code>192.168.50.2</code>，</p>
</li>
<li><p>编辑 <code>/etc/network/interfaces</code> 文件，配置静态 IP 地址，内容如下：</p>
<pre><code class="language-tex">auto ens33
iface ens33 inet static
    address 192.168.50.131
    netmask 255.255.255.0
    gateway 192.168.50.2
    dns-nameservers 8.8.8.8 8.8.4.4
</code></pre>
</li>
<li><p>重启</p>
<pre><code class="language-sh">su reboot
</code></pre>
</li>
<li><p>再次查看 ip 地址</p>
<pre><code class="language-sh">ip addr
</code></pre>
<p>有以下输出便说明静态 IP 配置成功了。</p>
<pre><code class="language-sh">1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: ens33: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 00:0c:29:82:9e:69 brd ff:ff:ff:ff:ff:ff
    inet 192.168.50.131/24 brd 192.168.50.255 scope global ens33
       valid_lft forever preferred_lft forever
    inet6 fe80::20c:29ff:fe82:9e69/64 scope link
       valid_lft forever preferred_lft forever
</code></pre>
</li>
<li><p>配置域名</p>
<pre><code class="language-sh">sudo vim /etc/hosts
</code></pre>
<p>追加内容如下：</p>
<pre><code class="language-sh">192.168.50.131 kafka1.com
192.168.50.132 kafka2.com
192.168.50.133 kafka3.com
</code></pre>
</li>
<li><p>ping 一下</p>
<pre><code class="language-sh">hedon@ubuntu:~$ ping kafka1.com
PING kafka1.com (192.168.50.131) 56(84) bytes of data.
64 bytes from kafka1.com (192.168.50.131): icmp_seq=1 ttl=64 time=0.024 ms
64 bytes from kafka1.com (192.168.50.131): icmp_seq=2 ttl=64 time=0.021 ms
64 bytes from kafka1.com (192.168.50.131): icmp_seq=3 ttl=64 time=0.029 ms
^C
--- kafka1.com ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2029ms
rtt min/avg/max/mdev = 0.021/0.024/0.029/0.006 ms
</code></pre>
</li>
<li><p>ping 一下百度，看看能不能访问外网</p>
<pre><code class="language-sh">hedon@ubuntu:~$ ping baidu.com
ping: baidu.com: Name or service not known
</code></pre>
<p>如果这里可以访问，则直接跳过进入下一步，不可以的话，需要配置一下域名解析系统。</p>
</li>
<li><p>配置域名解析系统</p>
<pre><code class="language-sh">sudo vim /etc/resolv.conf
</code></pre>
<p>追加下面内容：</p>
<pre><code class="language-tex">nameserver 8.8.8.8
nameserver 8.8.4.4
</code></pre>
<p>再尝试 ping 一下百度：</p>
<pre><code class="language-sh">hedon@ubuntu:~$ ping www.baidu.com
PING www.a.shifen.com (153.3.238.110) 56(84) bytes of data.
64 bytes from 153.3.238.110 (153.3.238.110): icmp_seq=1 ttl=128 time=15.9 ms
64 bytes from 153.3.238.110 (153.3.238.110): icmp_seq=2 ttl=128 time=15.9 ms
64 bytes from 153.3.238.110 (153.3.238.110): icmp_seq=3 ttl=128 time=16.1 ms
64 bytes from 153.3.238.110 (153.3.238.110): icmp_seq=4 ttl=128 time=15.3 ms
^C
--- www.a.shifen.com ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 14104ms
rtt min/avg/max/mdev = 15.368/15.850/16.145/0.291 ms
</code></pre>
</li>
</ol>
<details open>
<summary>补充说明：`/etc/network/interfaces` 文件的配置</summary>


<p>这是一个用于配置 Linux 系统上网络接口的文件。在这个示例中，我们为名为 <code>ens33</code> 的网络接口配置了静态 IP 地址和相关的网络设置。下面是各行的解释：</p>
<ol>
<li><p><code>auto ens33</code>: 这一行表示在系统启动时自动激活 <code>ens33</code> 网络接口。<code>auto</code> 关键字后面跟着接口名称。</p>
</li>
<li><p><code>iface ens33 inet static</code>: 这一行定义了 <code>ens33</code> 网络接口的配置。<code>iface</code> 关键字后面跟着接口名称，<code>inet</code> 表示我们正在配置 IPv4 地址，<code>static</code> 表示我们要为接口分配一个静态 IP 地址（而不是通过 DHCP 获得）。</p>
</li>
<li><p><code>address 192.168.50.131</code>: 这一行设置了网络接口的静态 IP 地址。在这个例子中，我们为 <code>ens33</code> 接口分配了 <code>192.168.50.131</code> IP 地址。</p>
<blockquote>
<p>IP 地址是 Internet 协议（IP）用于在网络中唯一标识设备的数字标签。每个连接到网络的设备都需要一个唯一的 IP 地址，以便其他设备可以找到并与之通信。IP 地址通常分为两种版本：IPv4 和 IPv6。在此示例中，我们使用了一个 IPv4 地址。</p>
</blockquote>
</li>
<li><p><code>netmask 255.255.255.0</code>: 这一行定义了子网掩码。在这个例子中，子网掩码是 <code>255.255.255.0</code>，表示前三个字节（24 位）是网络地址，最后一个字节（8 位）是主机地址。</p>
<blockquote>
<p>子网掩码用于划分 IP 地址的网络部分和主机部分。子网掩码与 IP 地址进行按位与操作，从而得到网络地址。这有助于确定哪些 IP 地址属于同一子网，以便正确地将数据包路由到目的地。子网划分有助于组织网络、提高安全性和管理性。</p>
</blockquote>
</li>
<li><p><code>gateway 192.168.50.2</code>: 这一行设置了默认网关。在这个例子中，我们将默认网关设置为 <code>192.168.50.2</code>。默认网关是用于将数据包发送到其他网络的路由器或设备的 IP 地址。</p>
<blockquote>
<p>网关是一个充当网络中数据包传输的中继点的设备，通常是一个路由器。当一个设备需要将数据包发送到不同子网的另一个设备时，它会将数据包发送到网关。网关负责将数据包路由到正确的目的地。默认网关是设备用于将数据包发送到其他网络的首选网关。</p>
</blockquote>
</li>
<li><p><code>dns-nameservers 8.8.8.8 8.8.4.4</code>: 这一行指定了 DNS 服务器的 IP 地址。在这个例子中，我们使用了谷歌的公共 DNS 服务器 <code>8.8.8.8</code> 和 <code>8.8.4.4</code>。DNS 服务器用于将主机名解析为 IP 地址。</p>
<blockquote>
<p>域名系统（DNS）是将人类可读的域名（例如 <a href="http://www.baidu.com%EF%BC%89IP">www.baidu.com）IP</a> 地址的系统。DNS 服务器是负责执行此解析过程的服务器。当您在浏览器中输入一个网址时，计算机会向 DNS 服务器查询该域名对应的 IP 地址，然后将请求发送到该 IP 地址以获取网页内容。</p>
</blockquote>
</li>
</ol>
<p>配置文件中的这些设置将在系统启动时生效。要立即应用更改，您可以使用以下命令重启网络服务：</p>
<pre><code class="language-bash">sudo systemctl restart networking
</code></pre>
</details>

<h2>安装 jdk</h2>
<pre><code class="language-sh">sudo apt update
sudo apt install openjdk-8-jdk
</code></pre>
<p>验证 java8 是否已经安装成功：</p>
<pre><code class="language-sh">java -version
</code></pre>
<p>有以下类似输出的话则表明安装成功：</p>
<pre><code class="language-sh">openjdk version &quot;1.8.0_362&quot;
OpenJDK Runtime Environment (build 1.8.0_362-8u372-ga~us1-0ubuntu1~18.04-b09)
OpenJDK 64-Bit Server VM (build 25.362-b09, mixed mode)
</code></pre>
<h2>安装 zookeeper</h2>
<p>在 Ubuntu 上，您可以通过以下步骤安装 Apache Zookeeper 3.5.9：</p>
<ol>
<li>下载 Apache Zookeeper 3.5.9 的二进制文件。使用以下命令下载并解压缩 Zookeeper：</li>
</ol>
<pre><code class="language-sh">wget https://archive.apache.org/dist/zookeeper/zookeeper-3.5.9/apache-zookeeper-3.5.9-bin.tar.gz
tar -xzf apache-zookeeper-3.5.9-bin.tar.gz
</code></pre>
<ol start="4">
<li>将解压缩后的文件夹移动到 <code>/opt</code> 目录中：</li>
</ol>
<pre><code class="language-sh">sudo mv apache-zookeeper-3.5.9-bin /opt/zookeeper-3.5.9
</code></pre>
<ol start="5">
<li>在 <code>/opt/zookeeper-3.5.9</code> 目录中创建一个名为 <code>data</code> 的文件夹，用于存储 Zookeeper 的数据：</li>
</ol>
<pre><code class="language-sh">sudo mkdir /opt/zookeeper-3.5.9/data
</code></pre>
<ol start="6">
<li>在 <code>/opt/zookeeper-3.5.9/data</code> 下创建 myid 文件并设置内容为 <code>1</code>，其他两台机器则为 <code>2</code> 和 <code>3</code>：</li>
</ol>
<pre><code class="language-sh">echo 1 | sudo tee /opt/zookeeper-3.5.9/data/myid
</code></pre>
<ol start="7">
<li>复制 Zookeeper 配置文件样本，并将其命名为 <code>zoo.cfg</code>：</li>
</ol>
<pre><code class="language-sh">sudo cp /opt/zookeeper-3.5.9/conf/zoo_sample.cfg /opt/zookeeper-3.5.9/conf/zoo.cfg
</code></pre>
<ol start="8">
<li>使用文本编辑器（例如 vim）编辑 <code>zoo.cfg</code> 文件：</li>
</ol>
<pre><code class="language-sh">sudo vim /opt/zookeeper-3.5.9/conf/zoo.cfg
</code></pre>
<ol start="9">
<li>修改 <code>zoo.cfg</code> 文件：</li>
</ol>
<pre><code class="language-sh"># The number of milliseconds of each tick
tickTime=2000
# The number of ticks that the initial
# synchronization phase can take
initLimit=10
# The number of ticks that can pass between
# sending a request and getting an acknowledgement
syncLimit=5
# the directory where the snapshot is stored.
# 设置数据存储目录
dataDir=/opt/zookeeper-3.5.9/data
# the port at which the clients will connect
clientPort=2181
# 设置集群信息
server.1=kafka1.com:2888:3888
server.2=kafka2.com:2888:3888
server.3=kafka3.com:2888:3888
</code></pre>
<blockquote>
<p>在 Zookeeper 的配置文件中，<code>server.x=hostname:port1:port2</code> 这种格式的配置项是用来设置 Zookeeper 集群（集群模式下）的。其中，<code>x</code> 是服务器的 ID，<code>hostname</code> 是服务器的主机名或 IP 地址，<code>port1</code> 和 <code>port2</code> 是用于集群间通信的端口。</p>
<p>具体来说：</p>
<ul>
<li><p><code>port1（2888）</code>：这是服务器之间用于相互通信的端口。Zookeeper 服务器使用这个端口进行 leader 选举以及同步 follower 和 leader 之间的状态。</p>
</li>
<li><p><code>port2（3888）</code>：这个端口用于服务器之间的 leader 选举。在 Zookeeper 集群启动或者在 leader 服务器崩溃后，follower 服务器会通过这个端口进行新一轮的 leader 选举。</p>
</li>
</ul>
<p>这两个端口可以根据你的网络配置进行修改，但必须在所有的 Zookeeper 服务器上保持一致。</p>
</blockquote>
<ol start="10">
<li>三个节点都启动 Zookeeper 服务器：</li>
</ol>
<pre><code class="language-sh">/opt/zookeeper/bin/zkServer.sh start
</code></pre>
<p>可以连接到 Zookeeper 的端口上（默认是 <code>2181</code>），通过发送四字命令 <code>srvr</code> 来验证 Zookeeper 是否安装正确（部署集群的话需要把所有 Zookeeper 启动）：</p>
<pre><code class="language-sh">hedon@ubuntu:/opt/zookeeper-3.5.9$ telnet localhost 2181
Trying 127.0.0.1...
Connected to localhost.
Escape character is &#39;^]&#39;.
srvr
Zookeeper version: 3.5.9-83df9301aa5c2a5d284a9940177808c01bc35cef, built on 01/06/2021 19:49 GMT
Latency min/avg/max: 0/0/0
Received: 1
Sent: 0
Connections: 1
Outstanding: 0
Zxid: 0x0
Mode: standalone
Node count: 5
Connection closed by foreign host.
</code></pre>
<ol start="11">
<li>要停止 Zookeeper 服务器，可以使用以下命令：</li>
</ol>
<pre><code class="language-sh">/opt/zookeeper/bin/zkServer.sh stop
</code></pre>
<h2>安装 Kafka</h2>
<ol>
<li><p>下载并解压 Kafka</p>
<pre><code class="language-sh">wget https://archive.apache.org/dist/kafka/2.7.0/kafka_2.13-2.7.0.tgz
tar -zxvf kafka_2.13-2.7.0.tgz
</code></pre>
</li>
<li><p>将解压缩后的文件夹移动到 <code>/opt</code> 目录中：</p>
<pre><code class="language-sh">sudo mv kafka_2.13-2.7.0 /opt/kafka-2.7.0
</code></pre>
</li>
<li><p>创建日志目录</p>
<pre><code class="language-sh">sudo mkdir /opt/kafka-2.7.0/kafka-logs
</code></pre>
</li>
<li><p>备份 Kafka 默认配置</p>
<pre><code class="language-sh">sudo cp /opt/kafka-2.7.0/config/server.properties /opt/kafka-2.7.0/config/server.properties.bak
</code></pre>
</li>
<li><p>修改 Kafka 配置</p>
<pre><code class="language-sh">sudo vim /opt/kafka-2.7.0/config/server.properties
</code></pre>
<p>主要是修改下面几个配置：</p>
<pre><code class="language-properties"># 集群中每个 broker 的 id 必须唯一，这里分别为 1，2，3
broker.id=1
# 日志目录
log.dirs=/opt/kafka-2.7.0/kafka-logs
# 配置 Zookeeper
zookeeper.connect=kafka1.com:2181,kafka2.com:2181,kafka3.com:2181
# 定义 Kafka Broker 在哪些网络地址上监听连接，下面配置表示在所有的 IP 地址上监听 9092 端口
listeners=PLAINTEXT://:9092
# 定义 Kafka Broker 如何向外部公布它的地址。这是 Kafka Broker 通知 Producer 和 Consumer 如何连接到自己的方式。例如，如果你设置 advertised.listeners=PLAINTEXT://my.public.ip:9092，那么 Kafka Broker 将告诉 Producer 和 Consumer 它的公共 IP 地址是 my.public.ip，并且它在 9092 端口上监听连接。
advertised.listeners=PLAINTEXT://kafka1.com:9092
</code></pre>
</li>
<li><p>三个节点都启动 Kafka</p>
<pre><code class="language-sh">/opt/kafka-2.7.0/bin/kafka-server-start.sh -daemon /opt/kafka-2.7.0/config/server.properties
</code></pre>
</li>
<li><p>选择任意一个节点创建一个新 topic</p>
<pre><code class="language-sh">/opt/kafka-2.7.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic test --replication-factor 1 --partitions=2
</code></pre>
<p>输出：</p>
<pre><code class="language-sh">Created topic test.
</code></pre>
</li>
<li><p>在其他节点获取 <code>test</code> 这个 <code>topic</code> 的信息</p>
<pre><code class="language-sh">/opt/kafka-2.7.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic test
</code></pre>
<p>可以看到关于 <code>test</code> 这个 <code>topic</code> 的信息是可以获取到的，说明集群之前信息是互通的，集群搭建完毕。</p>
<pre><code class="language-sh">Topic: test    PartitionCount: 2    ReplicationFactor: 1    Configs: segment.bytes=1073741824
    Topic: test    Partition: 0    Leader: 1    Replicas: 1    Isr: 1
    Topic: test    Partition: 1    Leader: 2    Replicas: 2    Isr: 2
</code></pre>
</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>Raft-Extended 论文翻译</title>
      <link>https://hedon.top/blog/raft/</link>
      <guid isPermaLink="true">https://hedon.top/blog/raft/</guid>
      <pubDate>Sat, 18 Nov 2023 22:29:59 GMT</pubDate>
      <description>本文对 Raft-Extended 进行了一比一的翻译，其中有些地方加入了额外的注解，这些都是笔者在学习 Raft 算法时遇到的比较困惑的难点，希望这些注解能对其他读者有帮助。</description>
      <category>分布式</category><category>raft</category><category>原创</category><category>论文翻译</category>
      <content:encoded><![CDATA[<blockquote>
<p>原文：<a href="https://pdos.csail.mit.edu/6.824/papers/raft-extended.pdf">https://pdos.csail.mit.edu/6.824/papers/raft-extended.pdf</a></p>
</blockquote>
<h2>辨析</h2>
<p><strong>consensus</strong> vs <strong>consistency</strong></p>
<p>一致性（consistency）往往指分布式系统中多个副本对外呈现的数据的状态。如顺序一致性、线性一致性，描述了多个节点对数据状态的维护能力。</p>
<p>共识（consensus）则描述了分布式系统中多个节点之间，彼此对某个提案达成一致结果的过程。</p>
<p>因此，一致性描述的是<strong>结果</strong>，共识则是一种<strong>手段</strong>。</p>
<p>有的人会说一致性和共识实际上是一个问题的一体两面，某种程度上来说，共识方法确实可以看作是实现强一致性的一种方法。事实上在工业界有许多以共识算法作为核心组件的多副本状态机（Replicated State Machine）实现，本质上利用了共识算法保证了所有副本的操作日志具有完全相同的顺序，从而实现了副本的一致性。但是，即使是在这样的场景下，讨论一个共识算法的一致性也是不合适的，因<strong>为整个分布式系统最终的一致性并不单单取决于共识算法，共识算法只是解决了其中一个问题。</strong></p>
<blockquote>
<p>参考：<a href="https://zhuanlan.zhihu.com/p/68743917">https://zhuanlan.zhihu.com/p/68743917</a></p>
</blockquote>
<h2>0. 摘要</h2>
<p>Raft 是用来管理复制日志（replicated log）的一致性协议。它跟 multi-Paxos 作用相同，效率也相当。但是它的组织结构跟 Paxos 不同，也是因为 Raft 更简单的架构使得它更容易被理解，并且更容易在实际工程中得以实现。</p>
<p>为了让 Raft 更容易被理解，Raft 将共识算法的关键性因素切分成几个部分，比如：</p>
<ul>
<li>leader election（领导者选举）</li>
<li>log replication（日志复制）</li>
<li>safety（安全性）</li>
</ul>
<p>并且 Raft 实施了一种更强的共识性以便减少必须要考虑的状态（states）的数量。</p>
<p>用户研究表明，对于学生来说，Raft 相比于 Paxos 是更容易学习的。</p>
<p>Raft 还包括一个用于解决<strong>变更集群成员问题</strong>的新机制，它使用重写多数来保证安全性。</p>
<h2>1. 介绍</h2>
<p>共识算法允许多台机器作为一个集群协同工作，并且在其中的某几台机器出故障时集群仍然能正常工作。正因为如此，共识算法在建立可靠的大规模软件系统方面发挥了重要作用。在过去十年中，Paxos [15,16] 主导了关于共识算法的讨论：大多数共识性的实现都是基于 Paxos 或受其影响，Paxos 已经成为教授学生关于共识知识的主要工具。</p>
<p>比较遗憾的是，尽管很多人一直在努力尝试使 Paxos 更易懂，Paxos 还是太难理解了。此外，Paxos 的架构需要复杂的改变来支持实际系统。这导致的结果就是系统开发者和学生在学生和使用 Paxos 过程中都很挣扎。</p>
<p>在我们自己与 Paxos 斗争之后，我们开始着手寻找一个新的共识算法，希望可以为系统开发和教学提供更好的基础。 我们的方法是不寻常的，因为我们的主要目标是可理解性：我们可以设计一个比 Paxos 更适合用于实际工程实现并且更易懂的共识算法吗？</p>
<p>在该算法的设计中，重要的不仅是如何让算法起作用，还要清晰地知道该算法为什么会起作用。</p>
<p>这项工作的结果是一个称为 Raft 的共识性算法。在设计 Raft 时，我们使用了特定的技术来提高它的可理解性，包括：</p>
<ul>
<li>分解（Raft 分离出三个关键点：leader election、log replication、safety）</li>
<li>减少状态空间（相比于 Paxos，Raft 降低了不确定性的程度和服务器之间的不一致）</li>
</ul>
<p>一项针对 2 所大学共 43 名学生的用户研究表明，Raft 比 Paxos 更容易理解：在学习两种算法后，其中 33 名学生能够更好地回答 Raft 的相关问题。</p>
<p>Raft 在许多方面类似于现有的公式算法（尤其是 Oki、Liskov 的 Viewstamped Replication [29,22]），但它有几个新特性：</p>
<ul>
<li><strong>Strong leader（强领导性）</strong>：相比于其他算法，Raft 使用了更强的领导形式。比如，日志条目只能从 leader 流向 follower（集群中除 leader 外其他的服务器）。这在使 Raft 更易懂的同时简化了日志复制的管理流程。</li>
<li><strong>Leader election（领导选举）</strong>：Raft 使用随机计时器来进行领导选举。任何共识算法都需要心跳机制（heartbeats），Raft 只需要在这个基础上，添加少量机制，就可以简单快速地解决冲突。</li>
<li><strong>Membership changes（成员变更）</strong>：Raft 在更改集群中服务器集的机制中使用了一个 <strong>联合共识（joint consensus）</strong> 的方法。在联合共识（joint consensus）下，在集群配置的转换过程中，新旧两种配置大多数是重叠的，这使得集群在配置更改期间可以继续正常运行。</li>
</ul>
<p>我们认为 Raft 跟 Paxos 以及其他共识算法相比是更优的，这不仅体现在教学方面，还体现在工程实现方面。</p>
<ul>
<li>它比其他算法更简单且更易于理解</li>
<li>它被描述得十分详细足以满足实际系统的需要</li>
<li>它有多个开源实现，并被多家公司使用</li>
<li>它的安全性已被正式规定和验证</li>
<li>它的效率与其他算法相当</li>
</ul>
<p>本文剩余部分：</p>
<table>
<thead>
<tr>
<th>所在节</th>
<th>内容</th>
</tr>
</thead>
<tbody><tr>
<td>第 2 节</td>
<td>复制状态机问题（replicated state machine problem）</td>
</tr>
<tr>
<td>第 3 节</td>
<td>Paxos 的优缺点</td>
</tr>
<tr>
<td>第 4 节</td>
<td>实现 Raft 易理解性的措施</td>
</tr>
<tr>
<td>第 5-8 节</td>
<td>Raft 共识性算法详细阐述</td>
</tr>
<tr>
<td>第 9 节</td>
<td>评估 Raft</td>
</tr>
<tr>
<td>第 10 节</td>
<td>其他相关工作</td>
</tr>
</tbody></table>
<h2>2. 复制状态机</h2>
<p>共识算法一般都是在复制状态机 [37] 的背景下实现的。在这种方法下，一组服务器在的状态机计算相同状态的相同副本，即使某些服务器崩溃，它们也可以继续运行。</p>
<p>复制状态机是用来解决分布式系统中的各种容错问题。比如说，具有单个 leader 的大规模的系统，如 GFS [8]，HDFS [38] 和 RAMCloud [33] ，他们通常都使用单独的复制状态机来管理 leader election 和保存 leader 崩溃后重新选举所需的配置信息。像 Chubby [2] 和 ZooKeeper [11] 都是复制状态机。</p>
<p>复制状态机通常都是使用日志复制（log replication）来实现。如图 1：每个服务器都保存着一份拥有一系列命令的日志，然后服务器上的状态机会按顺序执行日志中的命令。每一份日志中命令相同并且顺序也相同，因此每个状态机可以处理相同的命令序列。所以状态机是可确定的，每个状态机都执行相同的状态和相同的输出序列。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gsmihlh9ptj322u0nsgpj.jpg" alt="image-20210719200404010"></p>
<p>共识算法的主要工作就是保证复制日志（replicated log）的一致性。每台服务器上的共识模块接收来自客户端的命令，并将这些命令添加到其日志当中。它（指共识模块）与其他服务器上的共识模块进行通信，以确保每台服务器上最终以相同的顺序包含相同的命令，即使部分服务器崩溃了，这个条件也可以满足。一旦命令被正确复制，每台服务器上的状态机就会按日志顺序处理它们，并将输出返回给客户端。这样就形成了高可用的复制状态机。</p>
<p>适用于实际系统的共识算法通常都包含以下几点特征：</p>
<ul>
<li><p>它们确保在所有非拜占庭错误下的安全性，也就是从不返回一个错误的结果。（即使是网络延迟、分区、数据包丢失、数据包重复和数据包乱序）</p>
<blockquote>
<p><strong><a href="https://zh.wikipedia.org/wiki/%E6%8B%9C%E5%8D%A0%E5%BA%AD%E5%B0%86%E5%86%9B%E9%97%AE%E9%A2%98">拜占庭错误</a>：</strong></p>
<p>出现故障（crash 或 fail-stop，即不响应）但不会伪造信息的情况称为“非拜占庭错误”。</p>
<p>伪造信息恶意响应的情况称为“拜占庭错误”，对应节点称为拜占庭节点。</p>
</blockquote>
</li>
<li><p>只要任何大多数（过半）服务器是可运行的，并且可以互相通信和与客户端通信，那么共识算法就可用。假设服务器崩溃了，一小段时间后，它们很可能会根据已经稳定存储的状态来进行恢复，并重新加入集群。</p>
</li>
<li><p>它们在保证日志一致性上不依赖于时序：错误的时钟和极端消息延迟在最坏的情况下会产生影响可用性的一系列问题。</p>
</li>
<li><p>在通常情况下，只要集群中大部分（过半）服务器已经响应了单轮远程过程调用（RPC），命令就可以被视为完成。少数（一半以下）慢服务器不会影响整个系统的性能。</p>
</li>
</ul>
<h2>3. Paxos 存在的问题</h2>
<p>在过去的十年间，Leslie Lamport 的 Paxos 协议 [15] 几乎成为共识性（consensus）的同义词。它是课堂上被教授最多的共识协议，大多数共识性的实现也是以它为起点。Paxos 首先定义了能在单个决策问题（例如单个复制日志条目）上达成共识的协议。我们将这个子集称为 <em>signle-degree Paxos</em>。然后 Paxos 组合该协议的多个实例去实现一系列决策，比如日志（<em>mutil-Paxos</em>）。Paxos 保证了安全性和活性，它也支持改变集群中的成员，它的安全性也已经被论证了，并且大多数情况下都是高效的。</p>
<p>美中不足的是，Paxos 有两个严重的缺点：</p>
<ol>
<li><p><strong>Paxos 非常难理解</strong></p>
<p>众所周知，Paxos 非常晦涩难懂，除非下了很大的功夫，很少有人能够成功理解它。因此，尽管目前已经有几个尝试希望将 Paxos [16,20,21] 解释得通俗易懂一些，而且这些解释都集中在 <code>single-decree Paxos</code>，但是它们还是很难懂。</p>
<p>在对 NSDI 2012 参会者的非正式调查中，我们发现很少人会喜欢 Paxos，即使是经验丰富的研究人员。我们自己也一直在跟 Paxos 作斗争，我们也无法完全理解整个 Paxos 协议，直到阅读了几个更简单的描述和自己设计了替代 Paxos 的协议，我们才对 Paxos 有了比较深刻的理解。但这个过程，花了将近一年。</p>
<p>我们推测 Paxos 这么晦涩难懂，主要是因为作者选择了 <code>Single-decree Paxos</code> 来作为基础。<code>Single-decree Paxso</code> 非常搞人：它分为两个阶段，但是并没有对这两个阶段进行简单直观的说明，而且这两个阶段也不能分开了单独理解，所以使用者将就很难理解为什么该算法能起作用。<code>Multi-Paxos</code> 的合成规则又增加了许多复杂性。我们相信，对多个决定（日志，并非单个日志条目）达成共识的总体问题可以用其他更直接和更明显的方式进行分解。</p>
</li>
<li><p><strong>Paxos 没有为实际实现提供一个良好的基础</strong></p>
<p>其中一个原因是没有广泛认同的针对 <code>Multi-Paxos</code> 的算法。Lamport 的描述主要是针对 <code>signle-decree Paxos</code> 的，他描述了针对 <code>multi-Paxos</code> 的可能方法，但缺少了很多细节。</p>
<p>目前已经有人在尝试具体化和优化 Paxos，比如 [26]，[39] 和 [13]，但是这些尝试都互不相同并且它们跟 Lamport 描述的也不尽相同。虽然像 Chubby [4] 这样的系统已经实现了类 Paxos（Paxos-like）算法，但是他们并没有透露出很多的实现细节。</p>
</li>
</ol>
<p>此外，Paxos 的架构对于构建实际系统来说其实是一个糟糕的设计，这是 <code>single-decree Paxos</code> 分解的另一个结果。举个例子，这对于独立选择地日志条目的集合，然后再将它们合并到顺序日志当中没有任何好处，这只会增加复杂性。围绕日志来设计系统是更加简单和高效的方法，其中新条目按受约束的顺序依次附加。另外一个问题是 Paxos 在其核心使用了<strong>对称对等方法</strong>（尽管它最终表明了这会被用作一种性能优化的弱领导模式）。这在只有一个决策的情况下是有意义的，但是尽管如此，还是很少有实际系统采用了这种方法。如果有一系列的决策需要制定，更简单和更快速的方法应该是首先选择一个 leader，然后由 leader 去协调这些决策。</p>
<p>因此，按照 Paxos 来实现的实际系统往往跟 Paxos 相差很大。几乎所有的实现都是从 Paxos 开始，然后在实现的过程中发现了一系列的难题，在解决难题的过程中，开发出了跟 Paxos 完全不一样的架构。这样既费时又容易出错，而且 Paxos 本身的晦涩难懂又使得问题变得更加严重。Paxos 公式可能是证明其正确性的一个很好的公式，但真正的实现与 Paxos 又相差很大，这证明了它其实没有什么价值。下面来自 Chubby 作者的评论非常典型：</p>
<blockquote>
<p>在 Paxos 算法描述和现实实现系统之间有着巨大的鸿沟... （如果一直按照 Paxos 算法走下去），最终的系统往往会建立在一个还未被证明的协议之上。</p>
</blockquote>
<p>综合上述问题，我们觉得 Paxos 在教学端和系统构建端都没有提供一个良好的基础。考虑到共识性在大规模软件系统中的重要性，我们决定去尝试一下看看能不能设计一个替代 Paxos 并且具有更好特性的共识算法。Raft 就是这次实验的结果。</p>
<h2>4. 为可理解性而设计</h2>
<p>在设计 Raft 算法过程中我们有几个目标：</p>
<ul>
<li>它必须为系统构建提供一个完整且实际的基础，这样才能大大减少开发者的工作</li>
<li>它必须在任何情况下都是安全的并且在典型的应用条件下是可用的，并且在正常情况下是高效的</li>
</ul>
<p>但是我们最重要的目标，也是我们遇到的最大的挑战：</p>
<ul>
<li>它必须具有易理解性，它必须保证能够被大多数人轻松地理解。而且它必须能够让人形成直观的认识，这样系统构建者才能在实现过程中对它进行不可避免的拓展。</li>
</ul>
<p>在设计 Raft 算法的过程中，很多情况下我们需要在多个备选方案下做出抉择。在这种情况下，我们往往会基于可理解性来进行抉择：</p>
<ul>
<li>解释各个备选方案的难度有多大？例如，它的状态空间有多复杂？它是否具有难以理解的含义？</li>
<li>对于一个读者来说，完成理解这个方案和方案中的各种含义是否简单？</li>
</ul>
<p>我们意识到这一的分析具有高度的主观性。所以我们采取了两种通用的措施来解决这个问题。</p>
<ol>
<li>第一个措施就是众所周知的问题分解：只要有可能，我们就将问题划分成几个相对独立地解决、解释和理解的子问题。例如，Raft 算法被我们划分成 leader 选举、日志复制、安全性和成员变更几个部分。</li>
<li>第二个措施是通过减少状态的数量来简化状态空间，尽可能地使系统变得更加连贯和尽可能地消除不确定性。很明显的一个例子就是，所有的日志都是不允许有空挡的，并且 Raft 限制了日志之间可能不一样的方式。尽管在大多数情况下我们都极力去消除不确定性，但是在某些情况下不确定性却可以提高可理解性。一个重要的例子就是随机化方法，它们虽然引入了不确定性，但是它们往往能够通过以类似的方式处理所有可能的选择来减少状态空间（随便选，没关系）。所有我们使用了随机化来简化 Raft 中的 leader election 算法。</li>
</ol>
<h2>5. Raft 共识算法</h2>
<p>Raft 是一种用来管理第 2 节中提到的复制日志（replicated log）的算法。图 2 是该算法的浓缩，可以作为参考。图 3 列举了该算法的一些关键特性。这两张图中的内容将会在后面的各个章节中逐一介绍。</p>
<p>Raft 在实现共识算法的过程中，首先选举一个 distinguished leader，然后由该 leader 全权负责复制日志的一致性。Leader 从客户端接收日志条目，然后将这些日志条目复制给其他服务器，并且在保证安全性的情况下通知其他服务器将日志条目应用到他们的状态机中。拥有一个 leader 大大简化了对复制日志的管理流程。例如，leader 可以在不跟其他服务器商议的情况下决定新的日志条目应该存放在日志的什么位置，并且数据都是从 leader 流向其他服务器。当然了，一个 leader 可能会崩溃，也可能与其他服务器断开连接，那么这个时候，Raft 就会选举出一个新的 leader 出来。</p>
<p>通过选举一个 leader 的方式，Raft 将共识问题分解成三个独立的子问题，这些问题将会在接下来的子章节中进行讨论：</p>
<ul>
<li><p><strong>Leader election（领导选举）</strong></p>
<p>一个 leader 倒下之后，一定会有一个新的 leader 站起来。</p>
</li>
<li><p><strong>Log replication（日志复制）</strong></p>
<p>leader 必须接收来自客户端的日志条目然后复制到集群中的其他节点，并且强制其他节点的日志和自己的保持一致。</p>
</li>
<li><p><strong>Safety（安全性）</strong></p>
<p>Raft 中安全性的关键是图 3 中状态机的安全性：只要有任何服务器节点将一个特定的日志条目应用到它的状态机中，那么其他服务器节点就不能在同一个日志索引位置上存储另外一条不同的指令。第 5.4 节将会描述 Raft 如何保证这种特性，而且该解决方案在 5.2 节描述的选举机制上还增加了额外的限制。</p>
</li>
</ul>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gsar1v5rklj32300pc4bj.jpg" alt="image-20210709155333989"></p>
<p>在展示了 Raft 共识算法后，本章节将讨论可用性的一些问题以及时序在系统中的所用。</p>
<h3>5.1 Raft 基础</h3>
<p>一个 Raft 集群中包含若干个服务器节点，<font color="green"><strong>5 个一个比较典型的数字，5 个服务器的集群可以容忍 2 个节点的失效</strong></font>。在任何一个时刻，集群中的每一个节点都只可能是以下是三种身份之一：</p>
<ul>
<li>leader：它会处理所有来自客户端的请求（如果一个客户端和 follower 通信，follower 会将请求重定向到 leader 上）</li>
<li>follower：它们被动的：它们不会发送任何请求，只是简单的响应来自 leader 和 candidate 的请求</li>
<li>candidate：这是用来选举一个新的 leader 的时候出现的一种临时状态，这将在第 5.2 节中详细描述</li>
</ul>
<p>在正常情况下，集群中只有一个 leader，然后剩下的节点都是 follower。图 4 展示了这些状态和它们之间的转换关系，这些转换关系将会在接下来进行讨论。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gsap8d6ijjj322s0l47g5.jpg" alt="image-20210709145034498"></p>
<p>如图 5 所示，Raft 将时间划分成任意长度的任期（term）。每一段任期从一次选举开始，在这个时候会有一个或者多个 candidate 尝试去成为 leader。如果某一个 candidate 赢得了选举，那么它就会在任期剩下的时间里承担一个 leader 的角色。在某些情况下，一次选举无法选出 leader，这个时候这个任期会以没有 leader 而结束。同时一个新的任期（包含一次新的选举）会很快重新开始。这是因为 Raft 会保证在任意一个任期内，至多有一个 leader。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gsapcmhhi4j31ym0ig78s.jpg" alt="image-20210709145441879"></p>
<p>集群中不同的服务器观察到的任期转换的次数也许是不同的，在某些情况下，一个节点可能没有观察到 leader 选举过程甚至是整个任期过程。</p>
<p>任期在 Raft 中还扮演着一个逻辑时钟（logical clock）的角色，这使得服务器可以发现一些过期的信息，比如过时的 leader。</p>
<p>每一个节点都存储着一个当前任期号（current term number），该任期号会随着时间<strong>单调递增</strong>。节点之间通信的时候会交换当前任期号，如果一个节点的当前任期号比其他节点小，那么它就将自己的任期号更新为较大的那个值。如果一个 candidate 或者 leader 发现自己的任期号过期了，它就会立刻回到 follower 状态。如果一个节点接收了一个带着过期的任期号的请求，那么它会拒绝这次请求。</p>
<p>Raft 算法中服务器节点之间采用 RPC 进行通信，一般的共识算法都只需要两种类型的 RPC。</p>
<ul>
<li><strong>RequestVote RPCs（请求投票）</strong>：由 candidate 在选举过程中发出（5.2 节中描述）</li>
<li><strong>AppendEntries RPCs（追加条目）</strong>：由 leader 发出，用来做日志复制和提供心跳机制（5.3 节中描述）。</li>
</ul>
<p>在第 7 节中为了在节点之间传输快照（snapshot）增加了第三种 RPC。当节点没有及时的收到 RPC 的响应时，会进行重试，而且节点之间都是以并行（parallel）的方式发送 RPC 请求，以此来获得最佳的性能。</p>
<h3>5.2 Leader election</h3>
<p>Raft 采用一种心跳机制来触发 leader 选举。当服务器启动的时候，他们都会称为 follower。一个服务器节点只要从 candidate 或者 leader 那接收到有效的 RPC 就一直保持 follower 的状态。Leader 会周期性地向所有的 follower 发起心跳来维持自己的 leader 地位，所谓心跳，就是不包含日志条目的 AppendEntries RPC。如果一个 follower 在一段时间内没有收到任何信息（这段时间我们称为<strong>选举超时 election timeout</strong>），那么它就会假定目前集群中没有一个可用的 leader，然后开启一次选举来选择一个新的 leader。</p>
<p>开始进行选举的时候，一个 follower 会自增当前任期号然后切换为 candidate 状态。然后它会给自己投票，同时以并行的方式发送一个 RequestVote RPCs 给集群中的其他服务器节点（企图得到它们的投票）。一个 candidate 会一直保持当前状态直到以下的三件事之一发生（这些情况都会在下面的章节里分别讨论）：</p>
<ul>
<li>它赢得选举，成为了 leader</li>
<li>其他节点赢得了选择，那么它会变成 follower</li>
<li>一段时间之后没有任何节点在选举中胜出</li>
</ul>
<p>当一个 candidate 获取集群中过半服务器节点针对同一任期的投票时，它就赢得了这次选举并成为新的 leader。对于同一个任期，每一个服务器节点会按照 <strong>先来先服务原则（first-come-first-served）</strong> 只投给一个 candidate（在 5.4 节会在投票上增加额外的限制）。这种要求获得过半投票才能成为 leader 的规则确保了最多只有一个 candidate 赢得此次选举（图 3 中的选举安全性）。只要有一个 candidate 赢得选举，它就会成为 leader。然后它就会向集群中其他节点发送心跳消息来确定自己的地位并阻止新的选举。</p>
<p>一个 candidate 在等待其他节点给它投票的时候，它也有可能接收到另外一个自称为 leader 的节点给它发过来的 AppendEntries RPC。</p>
<ul>
<li>如果这个 leader 的任期号（这个任期号会在这次 RPC 中携带着）不小于这个 candidate 的当前任期号，那么这个 candidate 就会觉得这个 leader 是合法的，然后将自己转变为 follower 状态。</li>
<li>如果这个 leader 的任期号小于这个 candidate 的当前任期号，那么这个 candidate 就会拒绝这次 RPC，然后继续保持 candidate 状态。</li>
</ul>
<p>第三种可能的结果是 candidate 既没有赢得选举也没有输。可以设想一下这么一个情况。所有的 follower 同时变成 candidate，然后它们都将票投给自己，那这样就没有 candidate 能得到超过半数的投票了，投票无果。当这种情况发生的时候，每个 candidate 都会进行一次超时响应（time out），然后通过自增任期号来开启一轮新的选举，并启动另一轮的 RequestVote RPCs。然而，如果没有额外的措施，这种无结果的投票可能会无限重复下去。</p>
<p>为了解决上述问题，Raft 采用 <strong>随机选举超时时间（randomized election timeouts）</strong> 来确保很少发生无果的投票，并且就算发生了也能很快地解决。<strong>为了防止选票一开始就被瓜分，选举超时时间是从一个固定的区间（比如，150-300ms）中随机选择。这样可以把服务器分散开来以确保在大多数情况下会只有一个服务器率先结束超时，那么这个时候，它就可以赢得选举并在其他服务器结束超时之前发送心跳</strong>（译者注：乘虚而入，不讲武德）。</p>
<p>同样的机制也可以被用来解决选票被瓜分（split votes）的情况。每个 candidate 在开始一轮选举之前会重置一个随机选举超时时间，然后一直等待直到结束超时状态。这样减少了在一次投票无果后再一次投票无果的可能性。9.3 节展示了该方案能够快速地选出一个 leader。</p>
<p>选举的例子可以很好地展现可理解性是如何指导我们在多种备选设计方案中做出抉择的。在一开始，我们本打算使用一种等级系统（rank system）：每一个 candidate 被赋予一个一次的等级（rank），如果一个 candidate 发现另外一个 candidate 有着更高的登记，那么它就会返回 follower 状态，这样可以使高等级的 candidate 更加容易地赢得下一轮选举。但是我们发现这种方法在可用性方面会有一些小问题： <strong>如果等级较高的服务器崩溃了，那么等级较低的服务器可能需要进入超时状态，然后重新成为一个 candidate。如果这种操作出现得太快，那么它可能会重启进程去开启一轮新的选举。</strong> 经过我们对该算法做出了多次的调整，我们最终还是认为随机重试的方法更加通俗易懂。</p>
<h3>5.3 Log replication</h3>
<p>Leader 一旦被选举出来，它就要开始为客户端的请求提供服务了。每一个客户端请求都包含一条将被复制状态机执行的命令。leader 会以一个新条目的方式将该命令追加到自己的日志中，并且以同步的方式向集群中的其他节点发起 AppendEntires RPCs，让它们复制该条目。当条目被安全地复制（何为安全复制，后面会介绍）之后，leader 会将该条目应用到自己的状态机中，状态机执行该指令，然后把执行的结果返回给客户端。如果 follower 崩溃了或者运行缓慢，或者网络丢包，leader 会不断地重试 AppendEntiries RPCs（即使已经对客户端作出了响应）直到所有的 follower 都成功存储了所有的日志条目。</p>
<p>日志以图 6 展示的方式组织着。每条日志条目都存储着一条状态机指令和 leader 收到该指定时的任期号。日志条目中的任期号可以用来检测多个日志副本之间是否不一致，以此来保证图 3 中的某些性质。每个日志条目还有一个整数索引值来表明它在日志中的位置。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gsasp7ss8gj32220ssjy9.jpg" alt="image-20210709165036190"></p>
<p>那么问题就来了，<strong>leader 什么时候会觉得把日志条目应用到状态机是安全的呢？</strong> 这种日志条目被称为已提交的日志条目。Raft 保证这种已提交的日志条目都是持久化的并且最终都会被所有可用的状态机执行。 <strong>一旦创建该日志条目的 leader 将它复制到过半的节点上时（比如图 6 中的条目 7），该日志条目就会被提交。</strong> 同时，leader 日志中该日志条目之前的所有日志条目也都会被提交，包括由之前的其他 leader 创建的日志条目。5.4 节会讨论在 leader 变更之后应用该规则的一些细节，并证明这种提交的规则是安全的。leader 会追踪它所知道的要提交的最高索引，并将该索引包含在未来的 AppendEntries RPC 中（包括心跳），以便其他的节点可以发现这个索引。一旦一个 follower 知道了一个日志条目被提交了。它就会将该日志条目按日志顺序应用到自己的状态机中。</p>
<p>我们设计 Raft 日志机制来使得不同节点上的日志之间可以保持高水平的一致性。这么做不仅简化了系统的行为也使得系统更加可预测，同时该机制也是保证安全性的重要组成部分。Raft 会一直维护着以下的特性，这些特性也同时构成了图 3 中的日志匹配特性（Log Matching Property）：</p>
<ul>
<li>如果不同日志中的两个条目有着相同的索引和任期值，那么它们就存储着相同的命令</li>
<li>如果不同日志中的两个条目有着相同的索引和任期值，那么他们之前的所有日志条目也都相同</li>
</ul>
<p>第一条特性源于这样一个事实，在给定的一个任期值和给定的一个日志索引中，一个 leader 最多创建一个日志条目，而且日志条目永远不会改变它们在日志中的位置。</p>
<p>第二条特性是由 AppendEntries RPC 执行的一个简单的一致性检查所保证的。当 leader 发送一个 AppendEntries RPC 的时候，leader 会将前一个日志条目的索引位置和任期号包含在里面（紧邻最新的日志条目）。如果一个 follower 在它的日志中找不到包含相同索引位置和任期号的条目，那么它就会拒绝该新的日志条目。一致性检查就像一个归纳步骤：一开始空的日志状态肯定是满足日志匹配特性（Log Matching Property）的，然后一致性检查保证了日志扩展时的日志匹配特性。因此，当 AppendEntries RPC 返回成功时，leader 就知道 follower 的日志一定和自己相同（从第一个日志条目到最新条目）。</p>
<p>正常操作期间，leader 和 follower 的日志都是保持一致的，所以 AppendEntries 的一致性检查从来不会失败。但是，如果 leader 崩溃了，那么就有可能会造成日志处于不一致的状态，比如说老的 leader 可能还没有完全复制它日志中的所有条目它就崩溃了。这些不一致的情况会在一系列的 leader 和 follower 崩溃的情况下加剧。图 7 解释了什么情况下 follower 的日志可能和新的 leader 的日志不同。follower 可能会确实一些在新 leader 中有的日志条目，也有可能拥有一些新的 leader 没有的日志条目，或者同时存在。缺失或多出日志条目的情况有可能会涉及到多个任期。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gse5vxj5sfj31se0u017c.jpg" alt="image-20210712144330139"></p>
<p>在 Raft 算法中，leader 通过强制 follower 复制 leader 日志来解决日志不一致的问题。也就是说，follower 中跟 leader 冲突的日志条目会被 leader 的日志条目所覆盖。5.4 节会证明通过增加一个限制，这种方式就可以保证安全性。</p>
<p>为了使 follower 的日志跟自己（leader）一致，leader 必须找到两者达成一致的最大的日志条目索引，删除 follower 日志中从那个索引之后的所有日志条目，并且将自己那个索引之后的所有日志条目发送给 follower。所有的这些操作都发生在 AppendEntries RPCs 的一致性检查的回复中。leader 维护着一个针对每一个 follower 的 <strong>nextIndex</strong>，这个 nextIndex 代表的就是 leader 要发送给 follower 的下一个日志条目的索引。<strong>当选出一个新的 leader 时，该 leader 将所有的 nextIndex 的值都初始化为自己最后一个日志条目的 index 加 1（图 7 中的 11）</strong>。如果一个 follower 的日志跟 leader 的是不一致的，那么下一次的 AppendEntries RPC 的一致性检查就会失败。<strong>AppendEntries RPC 在被 follower 拒绝之后，leader 对 nextIndex 进行减 1，然后重试 AppendEntries RPC。最终 nextIndex 会在某个位置满足 leader 和 follower 在该位置及之前的日志是一致的，此时，AppendEntries RPC 就会成功，将 follower 跟 leader 冲突的日志条目全部删除然后追加 leader 中的日志条目（需要的话）</strong>。一旦 AppendEntries RPC 成功，follower 的日志就和 leader 的一致了，并且在该任期接下来的时间里都保持一致。</p>
<blockquote>
<p>如果需要的话，下面的协议可以用来优化被拒绝的 AppendEntries RPCs 的个数。</p>
<p>比如说，当拒绝一个 AppendEntries RPC 的时候，follower 可以包含冲突条目的任期号和自己存储的那个任期的第一个 index。借助这些信息，leader 可以跳过那个任期内所有的日志条目来减少 indexIndex。这样就变成了每个有冲突日志条目的任期只需要一个 AppendEntries RPC，而不是每一个日志条目都需要一次 AppendEntires RPC。</p>
<p>在实践中，我们认为这种优化是没有必要的，因为失败不经常发生并且也不可能有很多不一致的日志条目。</p>
</blockquote>
<p>通过上述机制，leader 在当权之后就不需要任何特殊的操作来使日志恢复到一致状态。leader 只需进行正常的操作，然后日志就能在回复 AppendEntries RPC 一致性检查的时候自动趋于一致。leader 从来不会重写或者删除自己的日志条目（图 3 中的 Leader Append-Only 属性）。</p>
<p>上述这种日志复制机制展现了第 2 节中描述的 Raft 算法的共识特性：只要过半的节点能正常运行，Raft 就能接受、复制并处理新的日志条目。在通常情况下，一个新的条目可以在一轮 RPC 中被复制给集群中过半的节点，并且单个运行缓慢的 follower 并不会影响整个集群的性能。</p>
<blockquote>
<p>译者注：<strong>总结</strong></p>
<p>Leader 收到 Client 的写请求，向所有 Follower 发起一个日志同步请求，得到集群内过半节点（包括 Leader 自己）的响应，就推进 commitIndex，然后 apply 日志到状态机，再推进 applyIndex，返回 Client 成功。</p>
<p>状态机同步分为两轮 RPC 广播：</p>
<ul>
<li>第一轮：同步日志 AppendEntries，得到过半节点回复，Leader 状态机推进，返回 Client 成功。</li>
<li>第二轮：在下一次的 AppendEntries 中附带上一次的 commitIndex，Follower 收到后，apply 日志条目到各自的状态机。</li>
</ul>
</blockquote>
<h3>5.4 Safety</h3>
<p>前面的章节描述了 Raft 如何做 Leader Election 和 Log Replication。然而，到目前为止所讨论的机制并不能充分地保证每一个状态机会按相同的顺序执行相同的指令。比如说，一个 follower 可能会进入不可用状态，在此期间，leader 可能提交了若干的日志条目，然后这个 follower 可能被选举为新的 leader 并且用新的日志条目去覆盖这些日志条目。这样就会造成不同的状态机执行不同的指令的情况。</p>
<p>本节通过对 Leader Election 增加一个限制来完善 Raft 算法。这个限制保证了对于给定的任意任期号，该任期号对应的 leader 都包含了之前各个任期所有被提交的日志条目（图 3 中的 Leader Completeness 性质）。有了这个限制，我们也可以使日志提交规则更加清晰。最后，我们会展示对于 Leader Completeness 性质的简要证明并且说该性质是如何保证状态机执行正确的行为的。</p>
<h4>5.4.1 选举限制</h4>
<p>在任何基于 leader 的共识算法中，leader 最终都必须存储所有已经提交的日志条目。在某些共识算法中，例如 Viewstamped Replication [22]，即使一个节点它一开始并没有包含所有已经提交的日志条目，它也有可能被选举为 leader。这些算法包含一些额外的机制来识别丢失的日志条目并将它们传送给新的 leader，这个机制要么发生在选举阶段，要么在选举完成之后很快进行。比较遗憾的是，这种方法会增加许多额外的机制，使得算法复杂性大大增加。Raft 使用了一种更加简单的方法，它可以保证新 leader 在当选时就包含了之前所有任期中已经提交的日志条目，根本就不需要再传送这些日志条目给新的 leader。这就意味着<strong>日志条目的传送只有一个方向，那就是从 leader 到 follower，leader 从来不会覆盖本地日志中已有的日志。</strong></p>
<p>Raft 采用投票的方式来保证一个 candidate 只有拥有之前所有任期中已经提交的日志条目之后，才有可能赢得选举。一个 candidate 如果想要被选为 leader，那它就必须跟集群中超过半数的节点进行通信，这就意味这些节点中至少一个包含了所有已经提交的日志条目。如果 candidate 的日志至少跟过半的服务器节点一样新，那么它就一定包含了所有以及提交的日志条目，一旦有投票者自己的日志比 candidate 的还新，那么这个投票者就会拒绝该投票，该 candidate 也就不会赢得选举。</p>
<blockquote>
<p>所谓 “<strong>新</strong>” ：</p>
<p>Raft 通过比较两份日志中的最后一条日志条目的索引和任期号来定义谁的日志更新。</p>
<ul>
<li>如果两份日志最后条目的任期号不同，那么任期号大的日志更新</li>
<li>如果两份日志最后条目的任期号相同，那么谁的日志更长，谁就更新</li>
</ul>
</blockquote>
<h4>5.4.2 提交之前任期内的日志条目</h4>
<blockquote>
<p>译者注：注意！这一节「提交之前任期内的日志条目」这种操作 Raft 的不允许的！本小节只是用来举一种错误情况！</p>
</blockquote>
<p>如 5.3 节中提到的那样，一旦当前任期内的某个日志条目以及存储到过半的服务器节点上，leader 就知道该日志可以被提交了。如果这个 leader 在提交某个日志条目之前崩溃了，以后的 leader 会尝试完成该日志条目的复制。然而，如果是之前任期内的某个日志条目已经存储到了过半的服务器节点上了，新任期内的 leader 也无法立即断定该日志条目已经被提交了。图 8 展示了一种情况：一个已经被存储到过半节点的老日志条目，仍然有可能会被未来的 leader 覆盖掉。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gse88t4iicj31wc0u0ts1.jpg" alt="image-20210712160506841"></p>
<blockquote>
<p>译者注：<strong>对图 8 的理解的补充</strong>。</p>
<p><font color="orange">参考：</font></p>
<ul>
<li><a href="https://zhuanlan.zhihu.com/p/369989974">知乎</a></li>
</ul>
<p><font color="orange">核心：</font></p>
<ul>
<li><strong>图 8 用来说明为什么 leader 不能提交之前任期的日志，只能通过提交自己任期的日志，从而间接提交之前任期的日志。</strong></li>
</ul>
<p><font color="orange">分析：</font></p>
<ol>
<li>先按错误的情况，也就是 leader 提交之前任期的日志，那么上述的流程：<ol>
<li>(a) S1 是任期 2 的 leader，日志已经复制给了 S2，此时还没过半；</li>
<li>(b) S1 崩溃，S5 获得了 S3、S4、S5 的投票成为 leader，然后写了一个日志条目（index=2，term=3）；</li>
<li>(c) S5 刚写完日志，还没来得及复制，就崩溃了，此时 S1 和 S2 都可能当选，加入 S1 当选（currentTerm=4），此刻还没有新的请求进来，S1 将日志条目（index=2，term=2）复制给了 S3，多数派达成，S1 提交了这个日志条目（index=2，term=2）， <strong>注意，该日志不是当前任期内的日志，我们在讨论错误的情况！</strong> 然后请求进来，S1 写日志条目（index=3，term=4），然后 S1 崩溃。</li>
<li>情况一：(d) S5 重启，因为 S5 最后的日志条目的任期号比 S2、S3 大，所以 S5 可以赢得选举（currentTerm=5），S5 将日志条目（index=2，item=3）复制给其他所有节点并提交， <strong>此时 index=2 的日志条目被提交了两次！一次 term=2，一次 term=3，这是不被允许的，因为已经提交的日志条目是不能被覆盖的！</strong> ✖️</li>
<li>情况二：(e) S1 在崩溃之前将自己的日志条目（index=3，term=4）复制到了过半节点上，这种情况下，S5 不可能选举成功。这是 S1 不发生故障，这是正确复制的情况。✔️</li>
</ol>
</li>
</ol>
<p>所以 <strong>「leader 可以提交之前任期的日志」</strong> 这种操作是不允许的，我们需要加上约束： <strong>「leader 只能提交自己任期的日志」</strong> 。</p>
<ol start="2">
<li><p>加了约束之后，前面的 (a) 和 (b) 没有改变，从 (c) 开始：</p>
<ol>
<li><p>(c) S1 还是将日志条目（index=2，term=2）复制给其他节点，它复制给了 S3，此时已经复制给了过半的节点了，但是<strong>由于 currentTerm=4，所以 S1 还是不能提交该日志条目</strong>。如果 S1 将日志条目（index=3，term=4）也复制给了过半的节点，S1 是可以提交该日志条目的，那么这个时候，前面的日志条目（index=2，term=2）也会被间接提交，这就是 (e) 所展示的情况。</p>
</li>
<li><p>(d) S1 还是将日志条目（index=2，term=2）复制给其他节点，它复制给了 S3，此时已经复制给了过半的节点了，但是<strong>由于 currentTerm=4，所以 S1 还是不能提交该日志条目</strong>。但是这个时候，S1 只是日志条目（index=3，term=4）写入自己的日志，还没来得及复制就崩溃了。然后 S5 重启并赢得了选举（currentTerm=5），然后将日志条目（index=2，term=3）复制给其他所有节点，现在 index=2 的日志条目是没有提交过的，S5 能提交该日志吗？</p>
<p><strong>不能！因为 leader 不能提交之前任期的日志！只有等新的请求进来，超过半数节点复制了 1-3-5 之后，term=3 的日志才能跟着 term=5 的日志一起被提交。</strong></p>
</li>
</ol>
</li>
</ol>
<p><font color="orange">延伸：</font></p>
<p>加了上述约束后，就不会出现同一个 index 上的日志条目被重复提交的情况了，但是这又多出了另外一个问题了：<strong>如果一直没有新的请求进来，那么日志条目（index=2，term=3）岂不是就一直不能提交？那不就阻塞了吗？</strong></p>
<p>这里如果是 kv 数据库，问题就很明显了。假设 (c) 或 (d) 中的日志条目（index=2）里的 Command 是 <code>Set(&quot;k&quot;, &quot;1&quot;)</code>，S5 当选 leader 后，客户端来查询 <code>Get(&quot;k&quot;)</code>，leader 查到日志有记录但又不能回复 1 给客户端（因为按照约束这条日志未提交），线性一致性要求不能返回陈旧的数据，leader 迫切地需要知道这条日志到底能不能提交。</p>
<p>所以 Raft 论文提高了引入 <strong>no-op 日志</strong>来解决这个问题，这个在 etcd 中有实现。</p>
<p><font color="orange">no-op 日志：</font></p>
<p>no-op 日志即只有 index 和 term 信息，command 信息为空。也是要写到磁盘存储的。</p>
<p>具体流程是<strong>在 leader 刚选举成功的时候，立即追加一条 no-op 日志，并立即复制到其它节点，no-op 日志一经提交，leader 前面那些未提交的日志全部间接提交，问题就解决了。像上面的 kv 数据库，有了 no-op 日志之后，Leader 就能快速响应客户端查询了。</strong></p>
<p>本质上，no-op 日志使 leader 隐式地快速提交之前任期未提交的日志，确认当前 <code>commitIndex</code>，这样系统才会快速对外正常工作。</p>
</blockquote>
<p>为了解决图 8 中描述的问题，Raft 永远不会通过计算副本数目的方式来提交之前任期内的日志条目。只有 leader 当期内的日志条目才通过计算副本数目的方式来提交。一旦当前任期内的某个日志条目以这种方式被提交（如图 8 中的 e），那么由于日志匹配特性（Log Matching），之前的所有日志条目也会被间接地提交。在某些情况下，leader 可以安全地断定一个老的日志条目已经被提交（例如，如果该条目已经被存储到每一个节点上了）。但是 Raft 为了简化问题，采取了上述描述的更加保守的方法。</p>
<p>Raft 会在提交规则上增加额外的复杂性是因为当 leader 复制之前任期内的日志条目时，这些日志条目都保留原来的任期号。在其他的共识算法中，如果一个新的 leader 要重新复制之前任期里的日志时，它必须使用当前新的任期号。Raft 的做法使得更加容易推导出日志条目，因为它们自始至终都使用同一个任期号。另外，和其他的算法相比，Raft 中的新 leader 只需要发送更少的日志条目（其他算法中必须在它们被提交之前发送更多的冗余日志条目来给它们重新编号）。</p>
<h4>5.4.3 安全性论证</h4>
<p>给出了完整的 Raft 算法后，我们现在可以更严格地来论证 leader 完整性特性（Leader Completeness Property）（这一讨论基于 9.2 节的安全性证明）。我们先假设 Leader Completeness Property 是不满足的，然后再推出矛盾来。</p>
<p><strong>假设：</strong></p>
<p>假设任期 T 的 leader<sub>T</sub> 在任期内提交了一个日志条目，但是该日志条目没有存在未来某些任期的 leader 中，假设 U 是大于 T 的没有存储该日志条目的最小任期号，处在任期 U 的 leader 称为 leader<sub>U</sub>。</p>
<p><strong>论证：</strong></p>
<ol>
<li><p>因为 leader 从来不删除或重写自己的日志条目，所以如果一个已提交的日志要做到不存在未来的 leader<sub>U</sub> 中的话，那么它只可能在 leader<sub>U</sub> 选举的过程中被丢失。</p>
</li>
<li><p>leader<sub>T</sub> 将该日志复制给了集群中过半的节点，leader<sub>U</sub> 从集群中过半的节点得到了投票。因此，至少有一个节点（这里称它为 voter）同时接收了来自 leader<sub>T</sub> 的日志条目并且给 leader<sub>U</sub> 投票了。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gsfipd3u1tj322c0j2wju.jpg" alt="image-20210713185232381"></p>
</li>
<li><p>voter 必然在给 leader<sub>U</sub> 投票之前就已经接收了这个已经提交的日志条目了。否则，它就会拒绝来自 leader<sub>T</sub> 的 AppendEntries RPC 请求，因为如果它在给 leader<sub>U</sub> 投票之后再接收条目的话，那么它的当前任期号会比 T 大。</p>
<blockquote>
<p>译者注：因为要举行 Leader election 的话需要开一轮新的任期，这个时候前一轮任期已经结束了。我们这里假设了 T &lt; U，上述所说的已提交日志条目是在任期 T 中的，如果 voter 先投票的话，那么就说明它已经进入了任期 U 了，而 U &gt; T，voter 是不可能接受 leader<sub>T</sub> 的 AppendEntries 请求的。</p>
</blockquote>
</li>
<li><p>而且，voter 在给 leader<sub>U</sub> 投票的时候，它依旧保有该日志条目，因为任何 U、T 之间的 leader 都包含该日志条目（因为我们前面假设了 U 是大于 T 的没有存储该日志条目的最小任期号），而且 leader 从来不会删除条目，并且 follower 只有再跟 leader 冲突的时候才会删除条目。</p>
</li>
<li><p>该投票者把自己的选票投给 leader<sub>U</sub> 的时候，leader<sub>U</sub> 的日志至少跟 voter 一样新（可以更新），这就导致了以下的两个矛盾之一了。</p>
</li>
<li><p><strong>第一个矛盾：</strong></p>
<p><strong>如果 voter 和 leader<sub>U</sub> 最后一个日志条目的任期号相同的话，那么 leader<sub>U</sub> 的日志至少和 voter 的一样长，所以 leader<sub>U</sub> 的日志一定包含 voter 日志中的所有日志条目。 这是一个矛盾，因为 voter 包含了该已提交的日志条目，所以 leader<sub>U</sub> 必定也包含该日志条目，而前面我们假设了 leader<sub>U</sub> 是不包含的，这就产生了矛盾。</strong></p>
</li>
<li><p><strong>第二个矛盾：</strong></p>
<p><strong>如果不是上面描述的情况的话，那么 leader<sub>U</sub> 最后一个日志条目的任期号必然需要比 voter 的更大。此外，它还比 T 要大，因为 voter 拥有在任期号为 T 提交的日志条目，所以 voter 最后一个日志条目的任期号至少为 T。创建了 leader<sub>U</sub> 的最后一个日志条目的之前的 leader 一定已经包含了该已被提交的日志条目（因为我们上面假设了 leader<sub>U</sub> 是第一个没有该日志条目的 leader）。所以，根据日志匹配特性，leader<sub>U</sub> 一定也包含了该已被提交的日志条目，这样也产生了矛盾</strong>。</p>
</li>
<li><p>上述讨论就证明了假设是不成立的。因此，所有比 T 大的任期的 leader 一定包含了任期 T 中提交的所有日志条目。</p>
</li>
<li><p>日志匹配特性保证了未来的 leader 也会包含被间接提交的日志条目，如图 8 (d) 中的索引 2。</p>
</li>
</ol>
<p>通过 leader 的完整性特性，我们就可以证明图 3 中的状态机安全特性了，即如果某个节点已经将某个给定的索引处的日志条目应用到自己的状态机里了，那么其他的节点就不会在相同的索引处应用一个不同的日志条目。在一个节点应用一个日志条目到自己的状态机中时，它的日志和 leader 的日志从开始到该日志条目都是相同的，并且该日志条目必须被提交。现在考虑一个最小的任期号，在该任期中任意节点应用了一个给定的最小索引上面的日志条目，那么 Log 的完整性特性就会保证该任期之后的所有 leader 将存储相同的日志条目，因此在后面的任期中应用该索引上的日志条目的节点会应用相同的值。所以，状态机安全特性是可以得到保证的。</p>
<p>最后，因为 Raft 要求服务器节点按照日志索引顺序应用日志条目，再加上状态机安全特性，这样就意味着我们可以保证所有的服务器都会按照相同的顺序应用相同的日志条目到自己的状态机中了。</p>
<h3>5.5 follower 和 candidate 崩溃</h3>
<p>到目前为止，我们只关注了 leader 崩溃的情况。follower 和 candidate 崩溃后的处理方式要比 leader 崩溃简单得多，而且它们的处理方式是相同的。如果一个 follower 或者 candidate 崩溃的话，后面发送给它们的 RequestVote 和 AppendEntries RPCs 都会失败。Raft 通过无限重试来处理这种失败。如果崩溃的节点重启了，那么这些 RPC 就会被成功地完成。如果一个节点在完成了一个 RPC，但是还没来得及响应就崩溃了的话，那么在它重启之后它会再次收到同样的请求。Raft 的 RPCs 都是幂等的，所以重复发送相同的 RPCs 不会对系统造成危害。实际情况下，一个 follower 如果接收了一个 AppendEntries 请求，但是这个请求里面的这些日志条目在它日志中已经有了，它就会直接忽略这个新的请求中的这些日志条目。</p>
<blockquote>
<p>译者注：<strong>幂等</strong></p>
<p>在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数，或幂等方法，是指可以使用相同参数重复执行，并能获得相同结果的函数。这些函数不会影响系统状态，也不用担心重复执行会对系统造成改变。例如，“setTrue()”函数就是一个幂等函数,无论多次执行，其结果都是一样的.更复杂的操作幂等保证是利用唯一交易号(流水号)实现。</p>
</blockquote>
<h3>5.6 时序和可用性</h3>
<p>Raft 中有一个要求就是 Raft 的安全性不能依赖于时序（timing）：整个系统不能因为某些事件运行得比预期快一点或者慢一点就产生错误的结果。然而，可用性（即系统能够及时响应客户端的请求）不可避免的要依赖于时序。比如说，如果信息交换的时间比一般服务器崩溃所持续的时间还要长的话，那么 candidate 可能等不到赢得选举了，而缺少了一个稳定的 leader，Raft 将无法工作。</p>
<p>Raft 中时序最关键的地方就是 Leader election。只要整个系统满足下面的时间要求，Raft 就可以选举并维持一个稳定的 leader：</p>
<blockquote>
<p>广播时间（broadcastTime） &lt;&lt; 选举超时时间（electionTimeout） &lt;&lt; 平均故障间隔时间（MTBF）</p>
</blockquote>
<p>在这个不等式中，广播时间指的是一个节点并行地发送 RPCs 给集群中其他所有的节点并得到响应的平均时间。选举超时时间就是在 5.2 节中介绍的选举超时时间。平均故障间隔时间就是对于一台服务器而言，两次故障间隔时间的平均值。广播时间必须选举超时时间小一个量级，这样 leader 才能够有效发送心跳信息来组织 follower 进入选举状态。再加上随机化选举超时时间的方法，这个不等式也使得无果选票（split vote）变得几乎不可能。而选举超时时间需要比平均故障间隔时间小上几个数量级，这样整个系统才可以稳定地运行。有了这个限制后，当 leader 崩溃后，整个系统会有一段大约选举超时时间的时长不可用，我们希望该情况在整个系统运行时间里只占一小部分。</p>
<p>广播时间和平均故障间隔时间是由系统决定的，但是选举超时时间是我们可以自定义的。Raft 的 RPCs 需要接收方将信息持久化地保存到稳定存储中，所以广播时间大约是 0.5ms ~ 20ms 之间，取决于存储的技术。因此，选举超时时间可能需要在 10ms ~ 500ms 之间。而大多数的服务器的平均故障间隔时间都在几个月甚至更长，所以很容易满足时间的要求。</p>
<h2>6. 集群成员变更</h2>
<p>到目前为止，我们都假设集群的配置（参与共识算法的服务器节点集合）是固定不变的。但是在实际情况中，我们有时候是需要去改变集群配置的，比如说在服务器崩溃的时候去更换服务器或者是更改副本的数量。尽管可以通过下线整个集群，更新所有配置，然后重启整个集群的方式来实现这个需求，但是这会导致集群在更改过程中是不可用的。另外，如果这个过程中存在一些操作需要人工干预，那么就会有操作失误的风险。为了避免这些问题，我们决定将配置变更自动化并将其纳入到 Raft 的共识算法中来。</p>
<p>为了使配置变更机制足够安全，在配置变更过程中不能存在任何一个时刻使得同一任期中选出两个 leader。遗憾的是，任何服务器直接从旧的配置转换为新的配置的方案都是不安全的。一次性自动地转换所有服务器的配置的不可能的，所以在转换期间整个集群可能划分为两个独立的大多数（如图 10 所示）。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gsgq3skl4gj322c0nwaes.jpg" alt="image-20210714195412552"></p>
<blockquote>
<p>译者注：图 10 补充</p>
<p>上图中，在中间位置 Server1 可以通过自身和 Server2 的选票成为 leader（满足旧配置下收到大多数选票的原则）；Server3 可以通过自身和 Server4、Server5 的选票成为 leader（满足新配置线，即集群有 5 个节点的情况下的收到大多数选票的原则）；此时整个集群可能在同一任期中出现了两个 leader，这和 Raft 协议是违背的。</p>
</blockquote>
<p>为了保证安全性，配置变更必须采取一种两段式方法。目前有很多种两段式的实现。例如，有些系统（如 [22] ）在第一阶段停掉旧的配置，所以在这个阶段不能处理用户的请求，然后在第二阶段启用新的配置。在 Raft 中，集群先切换到一个过渡的配置，我们称之为 <strong>联合共识（joint consensus）</strong> 。一旦联合共识配置已经被提交了，系统就可以切换到新的配置上了。<strong>联合共识配置是新旧配置的并集</strong>：</p>
<ul>
<li>日志条目被复制给集群中处于新、老配置的所有节点</li>
<li>新、旧配置的节点都可能成为 leader</li>
<li>达成一致（针对选举和提交）需要分别得到在两种配置上过半的支持</li>
</ul>
<p>联合共识允许每一个节点在不妥协安全性的前提下，在不同的时刻进行配置转换过程。此外，联合共识还允许在集群配置变更期间响应客户端的请求。</p>
<p>集群配置在复制日志中以特殊的日志条目来存储和通信。图 11 展示了配置变更的过程。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gsgpnv9f98j32060p6agz.jpg" alt="image-20210714193852320"></p>
<p>当 leader 接收到一个更新配置的请求的时候，它就创建一个联合共识日志条目 C<sub>old,new</sub>，并以前面描述的方式复制该条目。 <strong>一旦某个节点将该配置日志条目增加到自己的日志中。那么这个节点就会用该配置来做出未来的所有决策（一个节点总是使用日志中最新的配置，无论该日志是否已经被提交）。</strong> 这就意味着 leader 会使用 C<sub>old,new</sub> 的规则来判断 C<sub>old,new</sub> 日志条目是什么时候被提交的。如果 leader 崩溃了，新的 leader 有可能处于 C<sub>old</sub> 配置，也可能处于 C<sub>old,new</sub> 配置，这取决于赢得选举的 candidate 是否已经接收到了 C<sub>old,new</sub> 配置。在任何情况下，处于 C<sub>new</sub> 状态的节点在此期间都是不能单独做出决定的。</p>
<p>当 C<sub>old,new</sub> 被提交了，那么 C<sub>old</sub> 和 C<sub>new</sub> 都不能在没有得到对方认可的情况下做出决定，并且 Leade 完整特性（Leader Completeness Property）保证了只有拥有 C<sub>old,new</sub> 日志的 candidate 有可能被选为 leader。所以现在 leader 就可以安全地创建一个描述 C<sub>new</sub> 的日志条目并将其复制给集群中的其他节点了。一样的，新的配置被节点收到后就会立刻生效。当新的配置在 C<sub>new</sub> 的规则下被提交了之后，旧配置就变得无关紧要了，处于旧配置的节点也可以关闭了。如图 11 所示，没有任何一个时刻 C<sub>old</sub> 和 C<sub>new</sub> 是可以单独做决定的，这保证了安全性。</p>
<p>关于配置变更有三个问题需要解决：</p>
<ul>
<li><p>第一个问题：新的节点可能在一开始并没有存储任何的日志条目。当这些节点以这种状态加入到集群中的时候，它们需要一段时间来更新自己的日志，以便赶上其他节点，在这个时间段里面它们是不可能提交一个新的日志条目的。 <strong>为了避免因此造成的系统短时间的不可用，Raft 在配置变更前引入了一个额外的阶段。在该阶段中，新的节点以没有投票权身份加入到集群中来（leader 会把日志复制给它们，但是考虑过半的时候不需要考虑它们）。</strong> 一旦新节点的日志已经赶上了集群中的其他节点，那么配置变更就可以按照之前描述的方式进行了。</p>
</li>
<li><p>第二个问题：leader 有可能不是新配置中的一员（译者注：也就是说这个 leader 后面是需要被下线的）。在这种情况下，leader 一旦提交了 C<sub>new</sub> 日志条目，它就会退位为 follower（译者注：C<sub>old,new</sub> 状态下依旧可用）。这就意味着有这样一段时间（leader 提交 C<sub>new</sub> 期间）：leader 管理着一个不包括自己的集群，它会复制日志给其他节点，但是算副本数量的时候不会算上自己。leader 转换发生在 C<sub>new</sub> 被提交的时候，因为这是新配置可以独立运行的最早时刻（在这个时刻之后，一定是从 C<sub>new</sub> 中选出新的 leader）。在这个时间点之前，有可能只能从 C<sub>old</sub> 中选出 leader。</p>
</li>
<li><p>第三个问题：那么被移除的节点（不处于 C<sub>new</sub> 状态的节点）有可能会扰乱集群。这些节点将不会收到心跳信息，所以当选举超时时，它们就会进行新的选举过程。它们会发送带有新任期号的 RequestVote RPCs，这样会导致当前的 leader 回到 follower 状态，然后选出一个新的 leader。但是这些被移除的节点还是会收不到心跳，然后再次超时，再次循环这个过程，导致系统的可用性很差。</p>
<p>为了避免这个问题，当节点认为当前有 leader 存在时，节点会忽略 RequestVote RPCs。具体来说，当一个节点在最小选举超时时间内收到一个 RequestVote RPC，它不会更新它的任期或授予它的投票。这不会影响正常的选举，每个节点在开启一轮选举之前，它会至少等待一次最小选举超时时间。相反，这有利于避免被移除的节点的扰乱：如果一个 leader 能够发送心跳给集群，那它就不会被更大的任期号废黜。</p>
</li>
</ul>
<blockquote>
<p>译者注：<strong>对配置变更的归纳</strong></p>
<ol>
<li><p>配置变更过程</p>
<ol>
<li><p>leader 在本地生成一个新的日志条目，其内容是 C<sub>old</sub> ∪ C<sub>new</sub>，代表当前时刻新旧成员配置共存，写入本地日志，称为 C<sub>old,new</sub>。后面 leader 就以该日志作为自己的配置了。同时将该日志条目复制集群中是所有节点中。在此之后新的日志同步需要保证得到 C<sub>old</sub> 和 C<sub>new</sub> 两个多数派的确认。</p>
<p>follower 收到 C<sub>old.new</sub> 的日志后更新本地日志，并且此时就以该配置作为自己的成员配置。</p>
<p>如果 C<sub>old</sub> 和 C<sub>new</sub> 中的两个多数派确认了 C<sub>old.new</sub> 这个日志条目，leader 就提交它。</p>
</li>
<li><p>接下来 leader 生成一条新的日志条目，其内容是新成员配置 C<sub>new</sub>，同样将该日志条目写入本地日志，同时复制给集群中其他节点。</p>
<p>follower 收到新成员配置 C<sub>new</sub> 后，将其写入日志，并且从此刻起，就以该配置作为自己的成员配置，并且如果发现自己不在 C<sub>new</sub> 这个成员配置中会自动退出。</p>
<p>leader 收到 C<sub>new</sub> 的多数派确认后，表示成员变更成功，后续的日志只要得到 C<sub>new</sub> 多数派确认即可。</p>
</li>
</ol>
<p>完成上述两阶段后，leader 就可以给客户端回复配置变更执行成功。</p>
</li>
<li><p>如果当前的 leader 不在 C<sub>new</sub> 的配置中会怎么样？</p>
<p>因为当前 leader 不在 C<sub>new</sub> 配置中，所以当 C<sub>new</sub> 日志条目被提交的时候，leader 其实是要被下线的（比如说集群节点数从 5 缩容为 3，且刚好下线的节点中包含当前 leader）。那这样的话，在 C<sub>old,new</sub> 状态下，leader 还是可用的，但是一旦 C<sub>new</sub> 日志条目被提交了，leader 就需要下线了，这个时候不用当心，因为 C<sub>new</sub> 已经被复制过半了，重新选 leader 也一定是选有 C<sub>new</sub> 的。</p>
</li>
<li><p>如果在配置分发过程中 leader 崩溃了怎么办？</p>
<p>分两种情况：</p>
<ol>
<li><p>C<sub>new</sub> 已经分发过半</p>
<p>集群开始重新选举，此时在 C<sub>new</sub> 的规则下，不存在新配置中的节点不会赢得选举（因为他们要在 C<sub>old,new</sub> 的情况下决定，但是拿不到 C<sub>new</sub> 的选票），只有拿到 C<sub>new</sub> 的节点可能成为 leader 并继续下发 C<sub>new</sub> 配置，流程恢复。</p>
</li>
<li><p>C<sub>new</sub> 没有分发过半</p>
<p>这种情况下，C<sub>old,new</sub> 和 C<sub>new</sub> 的节点都可以成为 leader，但是无所谓，因为无论谁成为 leader，都能根据当前的配置继续完成后续流程（如果是 C<sub>new</sub> 那么相当与完成了最终的配置，不在 C<sub>new</sub> 的节点会因为没有心跳数据而失效）。</p>
</li>
</ol>
</li>
<li><p>旧配置节点下线造成的问题</p>
<p>Raft 的处理方式：当节点确信有 leader 存在时，不会进行投票（在 leader 超时之前收到新的投票请求时不会提升任期号和做出投票）。且开始选举之前等待一个选举超时时间，这样在新 leader 正常工作的情况下，不会受到旧节点的影响。</p>
<p>旧配置节点在发起选举前需要等待一段时间，那么这段时间新 leader 可以发送心跳，这样就减少了影响。 对正常流程的影响不大。（leader 失效后要等一段时间，没有及时触发，然而本身这里就有一个判断失效的时间，好像影响不大；比如原先超时时间是 10s，那么如果设置成 5s，原策略下 10s 超时就是 10s 后开始选举，新策略下 5s 超时就是超时后再等 5s 再开始选举，影响就是超时时间变短）</p>
</li>
<li><p>无数据的新节点加入集群中的问题</p>
<p>新加入的节点需要时间复制数据，在这个过程完成之前，Raft 采用以下机制来保证可用性： 新加入节点没有投票权（ leader 复制日志给他们，但计算已复制日志条目的副本数的时候不考虑它们），直到这些节点的日志追上其他节点。</p>
</li>
<li><p>如果在配置变更过程中接收到用户请求的话，是用旧配置响应还是用新配置响应？</p>
<p><strong>按照笔者的理解，这个方面，对 Raft 协议的具体实现可以根据自身需求来自定义实现，Raft 的联合共识是为了避免同一时刻出现了 2 个 leader，避免了对客户端的一个请求同时有两个不同的响应出现。而在具体实现中，在某个阶段，究竟是采取新配置响应还是旧配置响应，可以再斟酌。</strong></p>
<p>比如说可以这样：</p>
<ol>
<li>C<sub>old</sub> 阶段：使用旧配置，需要过半旧配置节点确认</li>
<li>C<sub>new</sub> 已提交阶段：使用新配置，需要过半新配置节点确认</li>
<li>C<sub>old,new</sub> 阶段：配置信息中有节点数量（这样才可能判断是否过半），这个时候新旧配置都需要过半节点确认，而响应新配置执行的结果还是响应旧配置执行的结果，就看 old 多还是 new 多，谁多用谁。</li>
</ol>
</li>
<li><p>如果 leader 要下线，客户端发来的新的请求如何处理？</p>
<ol>
<li>如果是在 leader 复制 C<sub>new</sub> 之后，提交 C<sub>new</sub> 之前的话，leader 工作在新的集群配置下，所以会将日志复制到新集群的节点下，当收到新集群（不包含 leader 本身）超过半数节点确认后，就可以提交日志。</li>
<li>在其他阶段，leader 就是正常可用的。</li>
</ol>
</li>
<li><p>所谓 C<sub>new</sub> 和 C<sub>old,new</sub> 日志条目，里面没有数据，只有指令，里面的指令就是让节点执行对应的配置项。</p>
</li>
</ol>
</blockquote>
<h2>7. 日志压缩</h2>
<p>在正常情况下，Raft 的日志会随着客户端请求的增加而不断增长。但在实际系统中，日志不可能无限制地增长。随着日志越来越长，它会占用越来越多的空间，并且需要花更多的时间来重新执行日志中的日志条目。如果没有一定的机制来清除日志中积累的过期的信息，那么最终一定会影响系统的可用性。</p>
<p><strong>快照技术（snapshotting）</strong> 是日志压缩最简单的方法。在快照技术中，某个时间点下的前整个系统的状态都会以快照的形式持久化起来，然后该时间点之前的日志会被全部丢弃。快照技术呗使用在 Chubby 和 ZooKeeper 当中，接下来的章节会介绍 Raft 中的快照技术。</p>
<p><strong>增量压缩方法（Incremental approach to compaction）</strong>，例如<strong>日志清洗（log cleaning）</strong>[36] 和<strong>日志结构合并树（log-structured merge trees）</strong>[30, 5]，都是可行的。这些方法每次只对一小部分数据进行操作，这样就分散了压缩的负载压力。首先，选择一个积累了大量被删除或被覆盖的对象的数据区域，然后重写该区域内还活着的对象，之后释放该区域。和快照技术相比，这需要大量额外的机制，并且增加了更多的复杂性，快照技术通过操作整个数据集来简化问题。虽然日志清理需要对 Raft 进行修改，但是状态机可以使用与快照技术相同的接口来实现 LSM（日志结构合并） 树。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gshvublij8j321s0lsadj.jpg" alt="image-20210715195808192"></p>
<p>图 12 展示了 Raft 快照技术的基本思想。每一个节点独立地生成快照，快照中只包含自己日志中已经被提交的条目，这个过程主要的工作是状态机将自己的状态写入快照中。Raft 在快照中还保留了少量的元数据：</p>
<ul>
<li>last included index：指的是最后一个被快照取代的日志条目的索引值（状态机最后应用的日志条目）</li>
<li>last included term：指的是该条目所处的任期号</li>
</ul>
<p>保留这些元数据是为了支持快照后第一个条目的 AppendEntries 一致性检查，因为该条目需要一个之前的日志索引和任期号。为了支持集群成员变更（第 6 节中讨论的），快照中还包含日志中到 last included index 为止的最新的配置。一旦节点完成了快照的写入，它可能就会删除 last included index 及之前的所有日志条目，以及之前的快照。</p>
<p>尽管通常情况下，节点都是独立生成快照的，但是 leader 不可避免偶尔需要发送快照给一些落后的 follower。这通常发生在 leader 已经丢弃了需要发给 follower 的下一条日志条目的时候。幸运的是，这种情况在正常操作中是不会出现的：一个与 leader 保持同步的 follower 通常都会拥有该日志条目。不过如果一个 follower 运行比较缓慢，或者是它刚加入集群，那么它就可能会没有该日志条目。这个时候 leader 会通过网络将该快照发送给该 follower，以使得该 follower 可以更新到最新的状态。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gshxf2bjwej31gg0u0k01.jpg" alt="image-20210715205247442"></p>
<p>这个时候 leader 使用了一种新的 RPC 来发送快照给那些太落后的 followers，如图 13 所示，这种 RPC 叫做 <strong>InstallSnapshot</strong>。当一个 follower 通过这种 RPC 收到快照的时候，它必须决定如何处理当前已经存在的日志条目。通常情况下，这份快照会包含接受者日志者没有的信息。所以这种情况下 follower 会丢弃它的整个日志，它的日志会全部被快照取代，并且可能有与快照冲突的未提交的条目。相反，如果一个 follower 收到一个描述其日志前缀的快照（可能是由于重传或错误），则被快照覆盖的日志条目将被删除，但是快照之后的条目仍然有效，且必须要保留。</p>
<p>这种快照的方式违反了 Raft 的 strong leader 原则，因为 follower 可能在不知道 leader 的情况下创建快照。但是我们认为这种违背是合乎情理的。leader 的存在，是为了防止在达成共识的时候产生冲突，但是在创建快照的时候，共识已经达成了，因此没有决策会出现冲突。这种情况下，数据还是跟之前一样，只能从 leader 流向 follower，只不过现在允许 follower 可以重新组织它们的数组而已。</p>
<p>我们曾经考虑过一种可替代的方案，那就是只有 leader 可以创建快照，然后由 leader 将这份快照发送给其他所有的 follower。但是，这种方案有两个缺点：</p>
<ol>
<li>发送快照给每个 follower 会浪费网络带宽和延缓了快照处理过程。实际上每一个 follower 已经拥有了创建自己快照所需要的全部信息了，所以很显然，follower 根据本地的状态创建快照要比通过网络来接收别人发过来的要更加实惠。</li>
<li>这会造成 leader 的实现更加复杂。比如说，leader 发送快照给 follower 的同时要能够做到并行地将新的日志条目发送给它们，这样才不会阻塞新的客户端请求，这就复杂得多了。</li>
</ol>
<p>还有两个问题会影响快照的性能：</p>
<ol>
<li><p>每一个节点必须判断何时去生成快照。如果一个节点生成快照的频率太高，那么就会浪费大量的磁盘带宽和其他资源；如果一个节点生成快照的频率太低，那么就要承担耗尽存储容量的风险，同时也增加了重启时重新执行日志的时间。</p>
<p>一个简单的策略就是当日志大小达到一个固定的阈值的时候就生成一份快照。如果这个阈值设置得显著大于期望的快照的大小，那么快照的磁盘带宽开销将较小。</p>
</li>
<li><p>第二个影响性能的就是写快照需要花费一定的时间，而我们又不希望它会影响到正常的操作。</p>
<p>解决方案就是使用 <strong>写时复制的技术（copy-on-write）</strong> ，这样新的更新就可以在不影响正在写的快照的情况下被接收。例如，具有泛型函数结构的状态机天然支持这样的功能。另外，操作系统对写时复制技术的支持（如 Linux 上的 fork）可以被用来创建整个状态机的内存快照（我们的实现用的就是这种方法）。</p>
</li>
</ol>
<h2>8. 客户端交互</h2>
<p>本节介绍客户端如何和 Raft 进行交互，包括客户端如何找到 leader 和 Raft 是如何支持线性化语义的 [10]。这些问题对于所有的基于共识算法的系统都是存在的，Raft 的解决方案也跟其他的系统差不多。</p>
<p>Raft 的客户端们将所有的请求发送给 leader。当客户端第一次启动的时候，它会随机挑选一个节点来进行通信。如果客户端首选的不是 leader，那么被客户端选中的节点就会拒绝客户端的请求并且提供关于它最近收到的 leader 的信息（AppendEntries RPC 包含了 leader 的网络地址）。如果 leader 崩溃了，客户端请求就会超时，这个时候客户端需要随机选择一个节点来重试发送请求。</p>
<p>我们对 Raft 的期许是希望它可以实现线性化语义（即每次操作看起来似乎都是在调用和响应之间的某个点上即时执行一次）。但是，按照上面描述的，Raft 可能会对同一条指令执行多次。例如，如果 leader 在提交了某个日志条目后，在还没来得及响应客户端的时候就崩溃了，那么客户端会和新的 leader 重试该指令，这就造成了同一指令被执行了两次。解决方案是客户端对于每一条指令都赋予一个唯一的序列号。然后，状态机跟踪每个客户端已经处理的最新的序列号以及相关联的响应。如果状态机接收到了一条已经执行过的指令了，就立即作出响应，并且不会重复执行该指令。</p>
<p>只读操作（Read-Only）可以直接处理而不记录日志。但是，如果不采取任何措施的话，这可能会有返回过期数据（stale data）的风险。<strong>因为 leader 响应客户端请求的时候它可能已经被新的 leader 代替了，但是它还不知道自己已经不是最新的 leader 了。</strong></p>
<blockquote>
<p>译者补充：<strong>为什么一个 leader 好好的会有另外一个 leader 出现？</strong></p>
<p>参考：<a href="https://segmentfault.com/a/1190000039264427">https://segmentfault.com/a/1190000039264427</a></p>
<p>实际上，老的 leader 可能不会马上消失，例如：网络分区将 leader 与集群的其余部分分隔，其余部分选举出了一个新的 leader。然后老的 leader 崩溃后重新连接，可能会不知道新的 leader 已经被选出来了。</p>
</blockquote>
<p>线性化的操作肯定不会返回过期的数据。Raft 需要使用两个额外的预防措施来在不适用日志的时候保证这一点。</p>
<ol>
<li><p>leader 必须拥有那些已提交的日志条目的最新信息。Leader 完整性特性（Leader Completeness Property）保证了 leader 一定拥有所有已被提交的日志条目，但是在它任期刚开始的时候，它可能还不知道哪些是已经被提交的。为了知道这些信息，它需要在它的任期里提交一个日志条目。</p>
<p><strong>Raft 通过让 leader 在任期开始的时候提交一个空的日志条目到日志中来解决该问题</strong>。（译者注：这就是前面 5.4.2 节提到的 no-op 日志）</p>
</li>
<li><p>leader 在处理只读请求的时候必须检查自己是否已经被替代了（因为如果一个新 leader 被选出来了，那么这个旧 leader 的数据可能就过时了）。</p>
<p>Raft 通过让 leader 在响应只读请求之前，先和集群中过半的节点交换一次心跳信息来解决该问题。</p>
<p>另一种可选的方案，leader 可以依赖心跳机制来实现一种租约的形式 [9]，但是这种方式的安全性需要依赖于时序（假设时间误差是有界的）。</p>
</li>
</ol>
<h2>9. 算法实现与评估</h2>
<p>我们已经实现了 Raft 作为复制状态机的一部分，该状态机存储了 RAMCloud [33] 的配置信息，并帮助 RAMCloud 协调器进行故障转移。这个 Raft 实现大概包含了 2000+ 行 C++ 代码，但是这里面没有包含测试、注释和空行。这些代码是开源的 [23]。同时也有大约 25 个其他独立的第三方、针对不同的开发场景、基于这篇论文草稿的开源实现。同时，很多公司已经部署了基于 Raft 算法的系统了。</p>
<p>本节剩下的篇幅将从三个方面来评估 Raft 算法：</p>
<ul>
<li>可理解性</li>
<li>正确性</li>
<li>性能</li>
</ul>
<h3>9.1 可理解性</h3>
<p>为了衡量 Raft 相对于 Paxos 的可理解性，我们针对高层次的本科生和研究生，在斯坦福大学的高级操作系统课程和加州大学伯克利分校的分布式计算课程上，进行了一项实验研究。我们为 Raft 和 Paxos 分别录制了一个视频教程，并且准备了相应的小测验。其中 Raft 课程覆盖了本篇论文除了日志压缩之外的全部内容，而 Paxos 课程涵盖了创建一个与 Raft 等价的复制状态机的全部资料，包括 signle-decree Paxos、multi-decree Paxos、重新配置和一切实际系统需要的性能优化（比如 leader 选举）。这个小测验主要是测试一些对算法的理解和解释一些边缘情况。每个学生都是看完第一个视频，然后做对应的测验，然后再看第二个视频，再做第二份测验。为了解释个人表现与从第一部分研究中获得的经验差异的原因，大约有一半的学生先进行 Paxos 的部分，然后另一半学生先进行 Raft 的部分。我们通过计算参与人员的每一份测验的得分来看参与者是否更加容易理解 Raft 算法。</p>
<p>我们尽可能的使得在比较 Raft 和 Paxos 过程中是公平的。这个实验从两个方面偏向了 Paxos：</p>
<ol>
<li>43 个参与者中有 15 个人在之前有一些 Paxos 的经验</li>
<li>Paxos 视频教程的时长要长 14%</li>
</ol>
<p>如表格 1 总结的那样，我们采取了一些措施来减轻这种潜在的偏向。我们所有的材料都可供审查 [28, 31]。</p>
<table>
<thead>
<tr>
<th>关注点</th>
<th>缓和偏向采取的手段</th>
<th>可供查看的材料</th>
</tr>
</thead>
<tbody><tr>
<td>相同的讲课质量</td>
<td>两份教程采用同一个讲师。Paxos 的教程是在现有的一些大学使用的材料基础上改进的。Paxos 的教程要长 14%。</td>
<td>视频</td>
</tr>
<tr>
<td>相同的测验难度</td>
<td>问题以难度分组，在两个测验里成对出现。</td>
<td>小测验</td>
</tr>
<tr>
<td>公平评分</td>
<td>使用评价量规。随机顺序打分，两个测验交替进行。</td>
<td>评分细则</td>
</tr>
</tbody></table>
<center>表格1：考虑到潜在的实验偏向，我们对于每种情况的解决方法，以及相应的材料。</center>

<p>平均上看，参与者在 Raft 测验上的得分要比在 Paxos 测验上的得分高处 4.9 分（在 60 分中，Raft 的平均得分是 25.7 分，Paxos 的平均得分是 20.8 分）。图 14 展示了每个参与者的得分。配对 t 检验（paired t-test）表明，在 95% 的置信度下，Raft 分数的真实分布的平均值至少要比 Paxos 的大 2.5 分。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gsixtffkroj32220my76y.jpg" alt="image-20210716175208106"></p>
<p>我们也建立了一个线性回归模型来预测一个新的学生的测验成绩，这个模型基于以下三点：</p>
<ol>
<li>他们使用的是哪个测验</li>
<li>之前对于 Paxos 的经验</li>
<li>学习算法的顺序</li>
</ol>
<p>该模型预测，对小测验的选择会产生 12.5 分的有利于 Raft 的差别，这很明显高于观察到的 4.9 分的分差。这是因为实际上许多的学生之前有学习过 Paxos，这对 Paxos 的有很大帮助的，但是对 Raft 的帮助就较小了。但是奇怪的是，模型预测对于先进行 Paxos 小测验的人而言，Raft 的得分低了 6.3 分。虽然我们不知道这是为什么，但是这似乎在统计上是有意义的。</p>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gsiz66oe4yj323m0jgdj0.jpg" alt="image-20210716183850084"></p>
<p>我们同时也在测验之后对参与者进行了调查，调查的内容是他们认为哪个算法更容易去实现或解释。这些调查结果展示在图 15。调查结果是碾压性的，结果表明 Raft 算法更加容易实现和解释（41 人中的 33 个）。然而，这种自我报告的感觉可能没有参与者的测试分数来得可靠，而且参与者可能由于我们假设 Raft 更容易理解而存在偏向。</p>
<p>在参考文献 [33] 中有一个关于 Raft 用户学习的更加详细的讨论。</p>
<h3>9.2 正确性</h3>
<p>在第 5 节中，我们已经对共识机制制定了正式的规范并且对其安全性做了证明。这份正式的规范使用 TLA+ 规范语言 [17] 使图 2 中对算法的总结的信息非常清晰。它差不多有 400 行并且作为了我们要证明的核心。同时这份规范对于任何想实现 Raft 的人都是十分有用的。我们用 TLA 证明系统 [7] 机械地证明了日志完整性（Log Completeness Property）。然而，这个证明依赖的约束前提还没有被机械证明（例如，我们还没有证明规范中的类型安全）。而且，我们已经编写了状态机安全特性的非正式证明 [31]，它是完整的（它仅依赖于规范）和相对精确的（大约 3500 字长）。</p>
<h3>9.3 性能</h3>
<p>Raft 的性能跟其他像 Paxos 的共识算法很接近。在性能方面，最重要的关注点就是，当一个 leader 被选举出来后，它要在什么时候复制新的日志条目。Raft 通过很少量的消息包（一轮从 leader 到集群中过半节点的的消息传递）就解决了这个问题。同时，进一步提升 Raft 的性能也是有可能的。比如说，很容易通过支持批量操作和管道操作来提高吞吐量和降低延迟。对于其他共识算法已经提出过很多性能优化方案，其中很多都可以应用到 Raft 上，但是我们暂时把这些工作放到未来的工作中。</p>
<p>我们使用我们自己的 Raft 实现来衡量 Raft 的 leader election 算法的性能并且回答两个问题：</p>
<ol>
<li>leader 选举过程收敛是否足够快？</li>
<li>在 leader 崩溃之后，最小的系统崩溃时间是多久？</li>
</ol>
<p><img src="https://hedonspace.oss-cn-beijing.aliyuncs.com/img2/008i3skNly1gsj0yuh34ej61km0u043r02.jpg" alt="image-20210716194110554"></p>
<p>为了衡量 leader election 的性能，我们反复使一个拥有 5 个节点的集群的 leader 宕机，并计算它检测崩溃和重新选一个新的 leader 所需的时间（见图 16）。为了构建一个最坏的情景，我们使各个节点中的日志长度都是不同的，这样某些 candidate 是无法成为 leader 的。而已，为了尽可能出现无结果的投票（split vote）情况，我们的测试脚本在终止 leader 的进程之前从 leader 那触发了一个同步的发送了一次心跳广播（类似于 leader 在崩溃前复制一个日志条目给其他节点）。leader 在其心跳间隔内均匀随机地崩溃，这个心跳间隔也是所有测试中最小选举超时时长的一半。因此，<strong>最小宕机时间大约就是最小选举超时时间的一半</strong>。</p>
<p>图 16 中上面的图表明，只需要在选举超时时间上使用很小的随机化就可以大大避免出现没有结果的投票的情况。在没有随机化的情况下（译者注：见图 16 中上面的图右边的橙色虚线），由于出现了很多没有结果的投票的情况，leader election 往往都需要花费超过 10s 的时间。仅仅加入 5ms 的随机化时间，就大大改善了选举过程，现在平均的宕机时间只有 287ms。继续增大随机性可以大大改善最坏的情况：通过增加 50ms 的随机化时间，最坏的完成情况（即完成 1000 次实验）只需要 513 ms。</p>
<p>图 16 中下面的图表明，通过减少选举超时时间可以禁烧系统的宕机时间。在选举超时时间为 12~24ms 的情况下，只需要平均 35ms 就可以选举出新的 leader（最长的一次花费了 152ms）。然而，进一步降低选举超时时间可能就会违反 Raft 不等式的要求。</p>
<blockquote>
<p>广播时间（broadcastTime） &lt;&lt; 选举超时时间（electionTimeout） &lt;&lt; 平均故障间隔时间（MTBF）</p>
</blockquote>
<p>因为这会使得在其他节点开启一轮新的选举之前，当前的 leader 要完成发送一次心跳广播变得很难。这会造成不必要的 leader 更换，从而降低了系统的可用性。我们推荐使用一个更为保守的选举超时时间，比如 150~300ms。这样的时间不大可能导致不必要的 leader 更换，同时还能提供不错的可用性。</p>
<h2>10. 相关工作</h2>
<p>现在已经有很多关于共识算法相关的产物了，其中很多都属于以下类别之一：</p>
<ul>
<li>Lamport 对于 Paxos 的最初的描述 [15]，以及尝试将 Paxos 解释地更清晰的描述 [16, 20, 21 ]。</li>
<li>关于 Paxos 的更详尽的描述，补充遗漏的细节并修改算法，使得可以提供更加容易的实现基础 [26, 39, 13]。</li>
<li>实现共识算法的系统，例如 Chubby [2, 4]，ZooKeeper [11, 12] 和 Spanner [6]。对于 Chubby 和 Spanner 的算法并没有公开发表其技术细节，尽管他们都声称是基于 Paxos 的。ZooKeeper 的算法细节已经发表，但是和 Paxos 着实有着很大的差别。</li>
<li>对于 Paxos 的性能优化 [18, 19, 3, 25, 1, 27]。</li>
<li>Oki 和 Liskov 的 Viewstamped Replication（VR），一种和 Paxos 差不多的替代算法。原始的算法描述 [29] 和分布式传输协议耦合在了一起，但是核心的共识算法在最近更新的版本 [22] 里被分离了出来。VR 使用了一种基于 leader 的方法，和 Raft 有很多相似之处。</li>
</ul>
<p>Raft 和 Paxos 最大的不同就在于 Raft 的<strong>强领导性（strong leadership）</strong>。Raft 将 leader election 作为共识协议中非常重要的一环，并且将尽可能多的功能集中到了 leader 身上。这种方法使得算法更加简单和更容易理解。比如说，在 Paxos 中，leader election 和基本的共识协议是正交的：它只是作为一种性能优化，而不是实现共识所必需的。然而，这带来了很多额外的机制：</p>
<ul>
<li>Paxos 中包含了一个两段式的基本共识协议</li>
<li>Paxos 中还包含了一个单独的 leader election 机制</li>
</ul>
<p>相比之下，Raft 将 leader election 直接纳入了共识算法并且将其作为共识两阶段中的第一个阶段，这使得 Raft 使用的机制要比 Paxos 少得多。</p>
<p>像 Raft 一样，VR 和 ZooKeeper 也是基于 leader 的，因此他们也拥有一些 Raft 的优点。但是，Raft 比 VR 和 ZooKeeper 拥有更少的机制。因为 Raft 尽可能的减少了非 leader 者的功能。例如，Raft 中日志条目都遵循着从 leader 发送给 follower 这一个方向：AppendEntries RPCs 是向外发送的。在 VR 中，日志条目的流动是双向的（leader 人可以在选举过程中接收日志）；这就导致了额外的机制和复杂性。根据 ZooKeeper 公开的资料看，它的日志条目也是双向传输的，但是它的实现更像 Raft。</p>
<p>跟我们上述提到的其他基于共识性的日志复制算法相比，Raft 的消息类型更少。例如，我们计算了一下 VR 和 ZooKeeper 用来实现基本功能和集群成员变更（不包括日志压缩和客户端交互，因为这些都比较独立且和算法关系不大）所需要的消息类型。VR 和 ZooKeeper 都分别定义了 10 种不同的消息类型。相比之下，Raft 只有 4 种消息类型（两种 RPC Request 及其对应的两种 RPC Response）。Raft 的消息的消息量比其他算法的要大一点，但总的来说，它们更加简单。另外，VR 和 ZooKeeper 都在 leader 改变的时候传输了整个日志，所以这些算法为了能在实践中使用，就不得不增加额外的消息类型了。</p>
<p>Raft 的强 leader 模型简化了整个算法，但是同时也排斥了一些性能优化的方法。例如，平等主义 Paxos （EPaxos）在某些没有 leader 的情况下可以达到很高的性能 [27]。平等主义 Paxos 充分发挥了在状态机指令中的交换性。任何服务器都可以在一轮通信下就提交指令，除非其他指令同时被提出了。然而，如果指令都是并发的被提出，并且互相之间不通信沟通，那么 EPaxos 就需要额外的一轮通信。因为任何服务器都可以提交指令，所以 EPaxos 在服务器之间的负载均衡做的很好，并且很容易在 WAN 网络环境下获得很低的延迟。但是，他在 Paxos 上增加了非常明显的复杂性。</p>
<p>一些集群成员变更的方法已经被提出或者在其他的工作中被实现，包括 Lamport 的原始的讨论 [15]，VR [22] 和 SMART [24]。我们选择使用联合共识的方法是因为它利用了共识协议的其余部分，这样我们只需要很少的一些机制就可以实现成员变更。Lamport 的基于 α 的方法之所以没有被 Raft 选择是因为它假设在没有 leader 的情况下也可以达到共识性。和 VR 和 SMART 相比较，Raft 的重新配置算法可以在不限制正常请求处理的情况下进行。相比之下，VR 在配置变更期间需要停止所有正常的处理过程，而 SMART 对未完成请求的数量实施了类似 α 方法的限制。另外，和 VR、SMART 相比，Raft 的方法也只需要增加更少的额外机制来实现。</p>
<h2>11. 结论</h2>
<p>算法的设计通常以正确性、效率和简洁性为主要目标。虽然这些都是有价值的目标，但我们相信可理解性同样重要。在开发人员将算法转化为实际实现之前，其他任何目标都不能实现，而实际实现将不可避免地偏离和扩展发布的形式。除非开发人员对算法有深刻的理解，并能对算法有直观的认识，否则他们很难在实现中保留算法理想的特性。</p>
<p>在本文中，我们讨论了分布式共识的问题，在这个问题上，一个被广泛接受但难以理解的算法：Paxos，多年来一直让学生和开发人员非常挣扎。我们开发了一种新的算法：Raft，我们已经证明它比 Paxos 更容易理解。我们也相信 Raft 会为系统建设提供更好的基础。将可理解性作为主要设计目标改变了我们处理 Raft 设计的方式。随着设计的进展，我们发现自己反复使用了一些技术，比如分解问题和简化状态空间。这些技术不仅提高了 Raft 的可理解性，而且使我们更容易证实它的正确性。</p>
<h2>12. 致谢</h2>
<p>这项研究必须感谢以下人员的支持：Ali Ghodsi，David Mazie`res，和伯克利 CS 294-91 课程、斯坦福 CS 240 课程的学生，没有他们的大力支持，这项研究是不可能完成的。Scott Klemmer 帮我们设计了用户调查，Nelson Ray 建议我们进行统计学的分析。在用户调查时使用的关于 Paxos 的幻灯片很大一部分是从 Lorenzo Alvisi 的幻灯片上借鉴过来的。特别的，非常感谢 DavidMazieres 和 Ezra Hoch，他们找到了 Raft 中一些难以发现的漏洞。许多人提供了关于这篇论文十分有用的反馈和用户调查材料，包括 Ed Bugnion，Michael Chan，Hugues Evrard，Daniel Giffin，Arjun Gopalan，Jon Howell，Vimalkumar Jeyakumar，Ankita Kejriwal，Aleksandar Kracun，Amit Levy，Joel Martin，Satoshi Matsushita，Oleg Pesok，David Ramos，Robbert van Renesse，Mendel Rosenblum，Nicolas Schiper，Deian Stefan，Andrew Stone，Ryan Stutsman，David Terei，Stephen Yang，Matei Zaharia 以及 24 位匿名的会议审查人员（可能有重复），并且特别感谢我们的领导人 Eddie Kohler。Werner Vogels 发了一条早期草稿链接的推特，给 Raft 带来了极大的关注。我们的工作由 Gigascale 系统研究中心和 Multiscale 系统研究中心给予支持，这两个研究中心由关注中心研究程序资金支持，一个是半导体研究公司的程序，由 STARnet 支持，一个半导体研究公司的程序由 MARCO 和 DARPA 支持，在国家科学基金会的 0963859 号批准，并且获得了来自 Facebook，Google，Mellanox，NEC，NetApp，SAP 和 Samsung 的支持。Diego Ongaro 由 Junglee 公司，斯坦福的毕业团体支持。</p>
<h2>参考文献</h2>
<p>[1] BOLOSKY, W. J., BRADSHAW, D., HAAGENS, R. B., KUSTERS, N. P., AND LI, P. Paxos replicated state machines as the basis of a high-performance data store. In <em>Proc. NSDI’11, USENIX Conference on Networked Systems Design and Implementation</em> (2011), USENIX, pp. 141–154.</p>
<p>[2] BURROWS, M. The Chubby lock service for loosely- coupled distributed systems. In <em>Proc. OSDI’06, Sympo- sium on Operating Systems Design and Implementation</em> (2006), USENIX, pp. 335–350.</p>
<p>[3] CAMARGOS, L. J., SCHMIDT, R. M., AND PEDONE, F. Multicoordinated Paxos. In <em>Proc. PODC’07, ACM Sym- posium on Principles of Distributed Computing</em> (2007), ACM, pp. 316–317.</p>
<p>[4] CHANDRA, T. D., GRIESEMER, R., AND REDSTONE, J. Paxos made live: an engineering perspective. In <em>Proc. PODC’07, ACM Symposium on Principles of Distributed Computing</em> (2007), ACM, pp. 398–407.</p>
<p>[5] CHANG, F., DEAN, J., GHEMAWAT, S., HSIEH, W. C., WALLACH, D. A., BURROWS, M., CHANDRA, T., FIKES, A., AND GRUBER, R. E. Bigtable: a distributed storage system for structured data. In <em>Proc. OSDI’06, USENIX Symposium on Operating Systems Design and Implementation</em> (2006), USENIX, pp. 205–218.</p>
<p>[6] CORBETT, J. C., DEAN, J., EPSTEIN, M., FIKES, A., FROST, C., FURMAN, J. J., GHEMAWAT, S., GUBAREV, A., HEISER, C., HOCHSCHILD, P., HSIEH, W., KAN- THAK, S., KOGAN, E., LI, H., LLOYD, A., MELNIK, S., MWAURA, D., NAGLE, D., QUINLAN, S., RAO, R., ROLIG, L., SAITO, Y., SZYMANIAK, M., TAYLOR, C., WANG, R., AND WOODFORD, D. Spanner: Google’s globally-distributed database. In <em>Proc. OSDI’12, USENIX Conference on Operating Systems Design and Implemen- tation</em> (2012), USENIX, pp. 251–264.</p>
<p>[7] COUSINEAU, D., DOLIGEZ, D., LAMPORT, L., MERZ, S., RICKETTS, D., AND VANZETTO, H. TLA+ proofs. In <em>Proc. FM’12, Symposium on Formal Methods</em> (2012), D. Giannakopoulou and D. Me ́ry, Eds., vol. 7436 of <em>Lec- ture Notes in Computer Science</em>, Springer, pp. 147–154.</p>
<p>[8] GHEMAWAT, S., GOBIOFF, H., AND LEUNG, S.-T. The Google file system. In <em>Proc. SOSP’03, ACM Symposium on Operating Systems Principles</em> (2003), ACM, pp. 29–43.</p>
<p>[9] GRAY,C.,ANDCHERITON,D.Leases:Anefficientfault- tolerant mechanism for distributed file cache consistency. In <em>Proceedings of the 12th ACM Ssymposium on Operating Systems Principles</em> (1989), pp. 202–210.</p>
<p>[10] HERLIHY, M. P., AND WING, J. M. Linearizability: a correctness condition for concurrent objects. <em>ACM Trans- actions on Programming Languages and Systems 12</em> (July 1990), 463–492.</p>
<p>[11] HUNT, P., KONAR, M., JUNQUEIRA, F. P., AND REED, B . ZooKeeper: wait-free coordination for internet-scale systems. In <em>Proc ATC’10, USENIX Annual Technical Con- ference</em> (2010), USENIX, pp. 145–158.</p>
<p>[12] JUNQUEIRA, F. P., REED, B. C., AND SERAFINI, M. Zab: High-performance broadcast for primary-backup sys- tems. In <em>Proc. DSN’11, IEEE/IFIP Int’l Conf. on Depend- able Systems &amp; Networks</em> (2011), IEEE Computer Society, pp. 245–256.</p>
<p>[13] KIRSCH, J., AND AMIR, Y. Paxos for system builders. Tech. Rep. CNDS-2008-2, Johns Hopkins University, 2008.</p>
<p>[14] L A M P O RT, L . Time, clocks, and the ordering of events in a distributed system. <em>Commununications of the ACM 21</em>, 7 (July 1978), 558–565.</p>
<p>[15] L A M P O RT, L . The part-time parliament. <em>ACM Transac- tions on Computer Systems 16</em>, 2 (May 1998), 133–169.</p>
<p>[16] LAMPORT, L. Paxos made simple. <em>ACM SIGACT News 32</em>, 4 (Dec. 2001), 18–25.</p>
<p>[17] L A M P O RT, L . <em>Specifying Systems, The TLA+ Language and Tools for Hardware and Software Engineers</em>. Addison- Wesley, 2002.</p>
<p>[18] LAMPORT, L. Generalized consensus and Paxos. Tech. Rep. MSR-TR-2005-33, Microsoft Research, 2005.</p>
<p>[19] L A M P O RT, L . Fast paxos. (2006), 79–103.</p>
<p>[20] LAMPSON, B. W. How to build a highly available system using consensus. In <em>Distributed Algorithms</em>, O. Baboaglu and K. Marzullo, Eds. Springer-Verlag, 1996, pp. 1–17.</p>
<p>[21] LAMPSON, B. W. The ABCD’s of Paxos. In <em>Proc. PODC’01, ACM Symposium on Principles of Distributed Computing</em> (2001), ACM, pp. 13–13.</p>
<p>[22] LISKOV, B., AND COWLING, J. Viewstamped replica- tion revisited. Tech. Rep. MIT-CSAIL-TR-2012-021, MIT, July 2012.</p>
<p>[23] LogCabin source code. <a href="http://github.com/">http://github.com/</a> logcabin/logcabin.</p>
<p>[24] LORCH, J. R., ADYA, A., BOLOSKY, W. J., CHAIKEN, R., DOUCEUR, J. R., AND HOWELL, J. The SMART way to migrate replicated stateful services. In <em>Proc. Eu- roSys’06, ACM SIGOPS/EuroSys European Conference on Computer Systems</em> (2006), ACM, pp. 103–115.</p>
<p>[25] MAO, Y., JUNQUEIRA, F. P., AND MARZULLO, K. Mencius: building efficient replicated state machines for WANs. In <em>Proc. OSDI’08, USENIX Conference on Operating Systems Design and Implementation</em> (2008), USENIX, pp. 369–384.</p>
<p>[26] MAZIE` RES, D. Paxos made practical. <a href="http://www.scs.stanford.edu/">http://www.scs.stanford.edu/</a> ̃dm/home/ papers/paxos.pdf, Jan. 2007.</p>
<p>[27] MORARU, I., ANDERSEN, D. G., AND KAMINSKY, M. There is more consensus in egalitarian parliaments. In <em>Proc. SOSP’13, ACM Symposium on Operating System Principles</em> (2013), ACM.</p>
<p>[28] Raft user study. <a href="http://ramcloud.stanford">http://ramcloud.stanford</a>. edu/ ̃ongaro/userstudy/.</p>
<p>[29] OKI, B. M., AND LISKOV, B. H. Viewstamped replication: A new primary copy method to support highly-available distributed systems. In <em>Proc. PODC’88, ACM Symposium on Principles of Distributed Computing</em> (1988), ACM, pp. 8–17.</p>
<p>[30] O’NEIL, P., CHENG, E., GAWLICK, D., AND ONEIL, E. The log-structured merge-tree (LSM-tree). <em>Acta Informat- ica 33</em>, 4 (1996), 351–385.</p>
<p>[31] ONGARO, D. <em>Consensus: Bridging Theory and Practice</em>. PhD thesis, Stanford University, 2014 (work in progress).</p>
<p>[32] ONGARO, D., AND OUSTERHOUT, J. In search of an understandable consensus algorithm. In <em>Proc ATC’14, USENIX Annual Technical Conference</em> (2014), USENIX.</p>
<p>[33] OUSTERHOUT, J., AGRAWAL, P., ERICKSON, D., KOZYRAKIS, C., LEVERICH, J., MAZIE`RES, D., MI- TRA, S., NARAYANAN, A., ONGARO, D., PARULKAR, G., ROSENBLUM, M., RUMBLE, S. M., STRATMANN, E., AND STUTSMAN, R. The case for RAMCloud. <em>Com- munications of the ACM 54</em> (July 2011), 121–130.</p>
<p>[34] Raft consensus algorithm website. <a href="http://raftconsensus.github.io">http://raftconsensus.github.io</a>.</p>
<p>[35] REED, B. Personal communications, May 17, 2013.</p>
<p>[36] ROSENBLUM, M., AND OUSTERHOUT, J. K. The design and implementation of a log-structured file system. <em>ACM Trans. Comput. Syst. 10</em> (February 1992), 26–52.</p>
<p>[37] S C H N E I D E R , F. B . Implementing fault-tolerant services using the state machine approach: a tutorial. <em>ACM Com- puting Surveys 22</em>, 4 (Dec. 1990), 299–319.</p>
<p>[38] SHVACHKO, K., KUANG, H., RADIA, S., AND CHANSLER, R. The Hadoop distributed file system. In <em>Proc. MSST’10, Symposium on Mass Storage Sys- tems and Technologies</em> (2010), IEEE Computer Society, pp. 1–10.</p>
<p>[39] VAN RENESSE, R. Paxos made moderately complex. Tech. rep., Cornell University, 2012.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
