<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>三点水</title>
  
  <subtitle>不积小流，无以成江海</subtitle>
  <link href="https://lotabout.me/atom.xml" rel="self"/>
  
  <link href="https://lotabout.me/"/>
  <updated>2025-11-26T12:53:48.065Z</updated>
  <id>https://lotabout.me/</id>
  
  <author>
    <name>Jinzhou Zhang</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>Wireshark tcptrace 图解读</title>
    <link href="https://lotabout.me/2025/wireshark-tcptrace/"/>
    <id>https://lotabout.me/2025/wireshark-tcptrace/</id>
    <published>2025-11-22T15:40:00.000Z</published>
    <updated>2025-11-26T12:53:48.065Z</updated>
    
    <content type="html"><![CDATA[<p>tcptrace 一般用来排查长肥管道的吞吐问题，之前一直看不懂，最近完整学习并着手做了些验证，整理如下。</p><p>建议对照着 laixintao 老师的 <a href="https://www.kawabangga.com/posts/4794">这篇文章</a> 来补齐一些概念</p><h2 id="tcptrace-图中的基本元素"><a class="header-anchor" href="#tcptrace-图中的基本元素"></a>tcptrace 图中的基本元素</h2><img src="/2025/wireshark-tcptrace/tcptrace-elements.svg" class="" title="tcptrace elements"><p>上面的 tcptrace 示例中（关注其中的元素，其它的时序不真实）：</p><ul><li>横轴是时间，纵轴是 Sequence Number，可以理解为包的字节数</li><li>其中每 <strong>发送</strong> 一个数据包，就会画一条蓝色的 <code>I</code> 型竖线段，长度表示数据量</li><li>上方的绿色阶梯线代表接收方的窗口大小（Receive Window），即最多能发多少数据</li><li>下方的棕黄色阶梯线代表已经被 ACK 的数据量，即接收方实际收到的数据</li><li>红色的线段代表选择确认（SACK），即接收方收到的数据中间有缺失，红线代表收到了哪些数据</li><li>棕黄线下方的小竖线代表重复 ACK（dup ACK），通常代表接收方收到了乱序的包</li></ul><p>所以这个图也只是把发的包和收到包的信息以图形化的方式展现出来，方便我们分析。</p><h2 id="tcptrace-图中的-距离-信息"><a class="header-anchor" href="#tcptrace-图中的-距离-信息"></a>tcptrace 图中的“距离”信息</h2><p>除了直接标记出来的元素，还可以通过图中的一些“距离”，得知其它一些概念</p><img src="/2025/wireshark-tcptrace/tcptrace-distance-meaning.jpg" class="" title="tcptrace meaning"><ul><li>Bytes in Flight: 发送方已经发出去但还没有被 ACK 的数据量，某时刻蓝色线和棕黄色线的垂直距离</li><li>Window Room（窗口余量）： 接收方还能接收的数据量，某时刻绿色线和棕黄色线的垂直距离</li><li>RTT（往返时间）: 数据发送到确认接收所需的时间，图中蓝色线和棕黄色线的水平距离</li></ul><p>还有就是整体的斜率，代表吞吐量（Throughput），斜率越大，吞吐量越高。</p><h2 id="发送方与接收方抓的包有差异"><a class="header-anchor" href="#发送方与接收方抓的包有差异"></a>发送方与接收方抓的包有差异</h2><p>发送方作为最初发包和接收 ACK 的一方，可以看到整体的 RTT 及链路信息，如下图中可以看到蓝线和 ACK 线的距离(代表 RTT)：</p><img src="/2025/wireshark-tcptrace/tcptrace-sender.jpg" class="" title="tcptrace sender"><p>而接收方中中的蓝线和 ACK 线的距离，则代表的是本机接收到包后本机发 ACK 的距离，并不能代表 RTT。但是接收方额外地可以看到发送方的一些乱序包，而发送方是看不到的。</p><img src="/2025/wireshark-tcptrace/tcptrace-receiver.png" class="" title="tcptrace receiver"><p>结论是两方的抓包有各自无法替代的信息，如果可能尽量两方同时抓包，如果只有一方，尽量是发送方。</p><h2 id="常见网络问题下-tcptrace-图的表现"><a class="header-anchor" href="#常见网络问题下-tcptrace-图的表现"></a>常见网络问题下 tcptrace 图的表现</h2><h3 id="丢包"><a class="header-anchor" href="#丢包"></a>丢包</h3><img src="/2025/wireshark-tcptrace/tcptrace-packet-loss.jpg" class="" title="tcptrace packet loss"><ol><li>看到红色的 SACK 线，说明接收方有包没收到</li><li>看到不断有递增的红色 SACK 线，说明后续的包都收到了，但是丢失的包依旧没收到</li><li>丢包期间，接收方的 buffer 不能释放，导致窗口没有增长</li><li>终于收到丢失的包后，综色的 ACK 线跳跃式上升，且绿色窗口线也上升，说明接收方释放了 buffer</li><li>由于接收方窗口增大，发送方马上利用了新增的窗口，蓝色线也跟着上升</li></ol><p>额外注意这个图里可以看到发送方重传了没有收到数据。</p><img src="/2025/wireshark-tcptrace/tcptrace-retransmit.jpg" class="" title="tcptrace retransmit"><h3 id="吞吐受到接收方窗口限制"><a class="header-anchor" href="#吞吐受到接收方窗口限制"></a>吞吐受到接收方窗口限制</h3><img src="/2025/wireshark-tcptrace/tcptrace-rx-window-limit.jpg" class="" title="tcptrace rx window limit"><ol><li>蓝线和距离绿线非常接近，说明发送方一直发送，接近了接收方窗口的上限</li><li>绿线一上升，蓝线也跟着上升，说明发送方马上利用了接收方新增的窗口</li></ol><p>结合起来看，说明接收方窗口太小，限制了发送方的吞吐。</p><h3 id="吞吐受到发送方窗口限制"><a class="header-anchor" href="#吞吐受到发送方窗口限制"></a>吞吐受到发送方窗口限制</h3><p>发送方能发多少数据除了受接收方窗口限制外，还受两个因素影响，一个是发送窗口（代码里使用 <code>SO_SNDBUF</code> 指定，内核里通过 <code>net.ipv4.tcp_wmem</code> 指定），另一个是拥塞窗口(<code>cwnd</code>)。例如下面的示例（构造时把 <code>net.ipv4.tcp_wmem</code> 设置成了 <code>4096 4096 4096</code>）<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>:</p><img src="/2025/wireshark-tcptrace/tcptrace-buf-limit-4k.png" class="" title="tcptrace Buf limit 4K"><p>上图中蓝线不断上升，但远不到绿线的位置就停止了，直到有 ACK 一定数据后才继续发送。说明是发送方做了限制。但是究竟是 <code>send_buffer</code> 的限制还是 <code>cwnd</code> 的限制？这就需要使用 “Window Scaling”：</p><img src="/2025/wireshark-tcptrace/tcptrace-buf-limit-4k-window.png" class="" title="tcptrace Buf limit 4K Window Scaling"><p>简单理解 Windown Scaling 里计算的是每个包对应的 “Bytes In Flight” 的数据，也就是“发出但未被 ACK”的数据，通常这个信息可以认为约等于发送方的 <code>cwnd</code>。但是上图中这个窗口信息很“平”，而一般 <code>cwnd</code> 是会波动的，因此可以认为不是 <code>cwnd</code> 引起的，那么就只能是发送窗口了。作为对比，我们看一个不显式设置 send buffer 的示例：</p><img src="/2025/wireshark-tcptrace/tcptrace-buf-auto-window.png" class="" title="tcptrace window scaling reflecting cwnd"><p>可以看到上图中随着一些丢包的发生，窗口也在不断变化，这与 <code>cwnd</code> 随着 RTT 和丢包不断变化相匹配。</p><h3 id="特例：零窗口"><a class="header-anchor" href="#特例：零窗口"></a>特例：零窗口</h3><p>上面提到速率可能跟发送方窗口有关，还有一种特例是因为各种原因（如消费的速度太慢）导致发送方窗口为 0, 此时 wireshark 会特地标记为 <code>x</code>：</p><img src="/2025/wireshark-tcptrace/tcptrace-zero-window.png" class="" title="tcptrace zero window"><h3 id="网络状况很差"><a class="header-anchor" href="#网络状况很差"></a>网络状况很差</h3><p>模拟 5% 的丢包与 10% 的乱序得到如下示例，一眼看就是很“不均匀”，有红色的丢包，棕色线有坑坑挖挖的乱序，整体的上升趋势（斜率）一会高一会低：</p><img src="/2025/wireshark-tcptrace/tcptrace-bad-net.png" class="" title="tcptrace bad network"><p>我们再放大看一些特征：</p><img src="/2025/wireshark-tcptrace/tcptrace-bad-net-detail.png" class="" title="tcptrace bad network detail"><h2 id="完美的连接"><a class="header-anchor" href="#完美的连接"></a>完美的连接</h2><p>完美的情况下，会是一个非常干净的图，笔直的线，没有 SACK、乱序等复杂元素，也能达到网络的带宽：</p><img src="/2025/wireshark-tcptrace/tcptrace-perfect.png" class="" title="tcptrace perfect"><h2 id="附：模拟网络条件用到的一些命令"><a class="header-anchor" href="#附：模拟网络条件用到的一些命令"></a>附：模拟网络条件用到的一些命令</h2><p>上面的实验是在 Mac 上用 podman 的两个 container 完成的，使用以下方式模拟网络条件:</p><ol><li>先 <code>podman machine ssh</code> 进入 podman 虚拟机</li><li>这个虚拟机缺少 <code>sch_netem</code> 内核模块，通过 <code>rpm-ostree install kernel-modules-extra</code> 安装</li><li>重启 podman 的虚拟机</li><li>通过 <code>podman machine ssh</code> 进入虚拟机执行 <code>modprobe sch_netem</code></li><li>视情况执行如下 <code>tc</code> 命令来设置网络条件</li></ol><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">tc qdisc del dev veth0 root</span><br><span class="line">tc qdisc del dev veth1 root</span><br><span class="line">tc qdisc add dev veth0 root handle 1: netem delay 30ms loss 5% reorder 10%</span><br><span class="line">tc qdisc add dev veth0 parent 1:1 handle 10: tbf rate 10mbit burst 500kbit limit 1m</span><br><span class="line">tc qdisc add dev veth1 root handle 1: netem delay 30ms loss 5% reorder 10%</span><br><span class="line">tc qdisc add dev veth1 parent 1:1 handle 10: tbf rate 10mbit burst 500kbit limit 1m</span><br></pre></td></tr></table></figure></div><p>其中 <code>veth0</code>, <code>veth1</code> 这两个网卡，是在虚拟机中通过如下命令定位</p><ol><li><code>podman ps</code> 获取 container id</li><li><code>podman inspect &lt;container id&gt; | grep Pid</code> 获取 Pid</li><li><code>nsenter -t &lt;pid&gt; -n ip link</code> 看到 container 网卡对应的 id 为 <code>eth0@if5</code>，注意这里的 <code>5</code></li><li><code>ip link</code> 找到编号为 <code>5</code> 的网卡 <code>5: veth1@if2</code>，因此为 <code>veth1</code></li></ol><h2 id="pcap-文件"><a class="header-anchor" href="#pcap-文件"></a>pcap 文件</h2><ul><li><a href="/2025/wireshark-tcptrace/iperf-bad-net.pcap" title="iperf-bad-net.pcap">iperf-bad-net.pcap</a></li><li><a href="/2025/wireshark-tcptrace/iperf-client-1mb.pcap" title="iperf-client-1mb.pcap">iperf-client-1mb.pcap</a></li><li><a href="/2025/wireshark-tcptrace/iperf-client-buf-4k.pcap" title="iperf-client-buf-4k.pcap">iperf-client-buf-4k.pcap</a></li><li><a href="/2025/wireshark-tcptrace/iperf-client-buf-auto-with-loss.pcap" title="iperf-client-buf-auto-with-loss.pcap">iperf-client-buf-auto-with-loss.pcap</a></li><li><a href="/2025/wireshark-tcptrace/iperf-client-buf-auto.pcap" title="iperf-client-buf-auto.pcap">iperf-client-buf-auto.pcap</a></li><li><a href="https://www.cloudshark.org/captures/f5eb7c033728">tcptrace-client-defaultwindow.pcapng</a></li><li><a href="https://www.cloudshark.org/captures/c967765aef38">tcptrace-client-loss.pcapng</a></li><li><a href="https://github.com/packetpioneer/youtube/blob/main/Lab1-GreerBombal_ItsNotTheNetwork.pcapng">Lab1-GreerBombal_ItsNotTheNetwork.pcapng</a></li></ul><h2 id="参考"><a class="header-anchor" href="#参考"></a>参考</h2><ul><li><a href="https://www.kawabangga.com/posts/4794">用 Wireshark 分析 TCP 吞吐瓶颈</a>laixintao 老师的博客，看了之后才第一次真正读懂了 tcptrace 图的含义</li><li><a href="https://www.packetsafari.com/blog/2021/10/31/wireshark-tcp-graphs/">Wireshark TCP Trace Graph Tutorial</a> 细致地介绍了一些常见网络问题下 tcptrace 图的表现，可以作为 laixintao 老师博客的补充</li><li><a href="https://blog.csdn.net/dog250/article/details/53227203">在Wireshark的tcptrace图中看清TCP拥塞控制算法的细节(CUBIC/BBR算法为例)</a> 逻辑性地分析不同拥塞算法下 tcptrace 图的表现，但我本身对算法不熟悉，只能浅尝则止</li></ul><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>思考题：这张图里，为什么不是一收到 ACK 后就立马发送新的包？ <a href="#fnref1" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;tcptrace 一般用来排查长肥管道的吞吐问题，之前一直看不懂，最近完整学习并着手做了些验证，整理如下。&lt;/p&gt;
&lt;p&gt;建议对照着 laixintao 老师的 &lt;a href=&quot;https://www.kawabangga.com/posts/4794&quot;&gt;这篇文章&lt;/a&gt;</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="WireShark" scheme="https://lotabout.me/tags/WireShark/"/>
    
    <category term="tcptrace" scheme="https://lotabout.me/tags/tcptrace/"/>
    
  </entry>
  
  <entry>
    <title>关于 RAG 的一些碎碎念</title>
    <link href="https://lotabout.me/2024/RAG-Limitations/"/>
    <id>https://lotabout.me/2024/RAG-Limitations/</id>
    <published>2024-12-31T22:44:20.000Z</published>
    <updated>2025-11-26T12:53:48.024Z</updated>
    
    <content type="html"><![CDATA[<p>检索增强生成(Retrival Augmented Generation, RAG)技术随大模型的兴起而备受关注，还有一众向量数据库也随之一起“鸡犬升天”，另外还有最近火热的 AI 搜索也可认为是RAG 的变种。但是随着应用的深入，RAG 的局限性也越来越明显。本文算是对自己近两年RAG 从业的不完全梳理，希望能对大家有所帮助。</p><h2 id="rag-的作用"><a class="header-anchor" href="#rag-的作用"></a>RAG 的作用</h2><p>RAG 能解决纯靠大模型生成的一些问题：</p><ol><li>幻觉问题：大模型可能会“凭空捏造”一些看似合理的信息，但实际上是错误的。</li><li>知识更新问题：大模型无法及时更新知识，如新闻、最新研究等。</li><li>私有知识问题：企业内部的资料非公开，大模型训练时不包含这些信息。</li></ol><p>当然还有一些其它工程问题，如生成速度、资源消耗、上下文限制等。</p><h2 id="naive-rag"><a class="header-anchor" href="#naive-rag"></a>Naive RAG</h2><p><img src="2024-12-31-RAG-naive.svg" alt="Naive RAG Model"></p><p>一个简单的 RAG 流程主要包含三个部分：</p><ol><li>索引器 Indexer: 解析文档，对文档做切片并向量化，存储在向量数据库中。</li><li>检索器 Retriever: ，从向量库中检索与问题最相关的 top-N 文本切片。</li><li>生成器 Generator: 根据检索到的文本切片生成回答。</li></ol><p>为什么要这么做？本质上还是因为“知识”太多，大模型的上下文放不下，因此需要提前筛选出相关的内容。但由于筛选的过程是很耗时的（不管是因为数量太大，还是筛选的逻辑很复杂），因此需要提前建立索引。</p><h2 id="rag-缺少大局观"><a class="header-anchor" href="#rag-缺少大局观"></a>RAG 缺少大局观</h2><p>我们看下面这个文档，假设它被切成了右边的 4 个切片并分别建立索引:</p><p><img src="2024-12-31-RAG-segments.svg" alt="RAG segmentation example"></p><p>现在我们提出问题：<code>RAG 有什么优点</code>，假设 Retriever 只返回 top-1 的结果，上帝视角来看就会遇到问题：</p><ol><li>单看片段 ③，我们并不知道它说的是什么东西的优缺点，它并没有提到 RAG，很可能不会被召回</li><li>因为只返回 top-1，但实际上“优点”可能即在片段 ③，也在片段 ④ 中，于是回答不完整</li></ol><p>这就是 RAG 缺少大局观的问题，处理的目标对象是“文档的切片”，而切片的局部性注定了它无法看到整篇文档的信息，也无法看到整个知识库的信息，因此：</p><ol><li>RAG 检索切片时，可能会因为切片本身包含的上下文信息不足，而漏掉正确的切片</li><li>RAG 无法判断需要多少个切片才能回答问题，于是诸如“列举 XXX”，“总结 XXX”的问题常常回答不完整</li><li>RAG 无法判断文档间的联系，如法律条文，新的解释覆盖旧的解释，但 RAG 无法判断哪个是最新的</li></ol><h2 id="无法望文生义"><a class="header-anchor" href="#无法望文生义"></a>无法望文生义</h2><p>RAG 是基于文档的，但文档都有自己的语境，有时仅凭文档内容根本无法理解，必须要结合语境。有时需要参考其它文档，有时需要了解文档所处的背景（如是在哪个知识库下的），有时需要有一些先验知识（如行业黑话）。</p><p>例如银行发了一张卡叫“宝贝成长卡”，你问“宝贝成长卡怎么销卡”，可能找遍所有的文档都找不到答案。这时需要分析知道“宝贝成长卡”是一张“借计卡”，再去找“借记卡销卡”相关的流程才能找到答案。</p><p>再例如有一篇文章说的是“A 卡的种类”，你可以理解是“AMD 显卡的种类”，但如果金融背景下，它可能就是指“信用卡评分模型的种类”了。单看这个文章是很难决定是否和问题匹配的。</p><p>再有像是一些行业黑话，口语，网络用语等，这些都是文档本身没有的但是用户会问的，RAG 仅靠文档是无法建立这两者之间的联系的。</p><h2 id="一些提升手段"><a class="header-anchor" href="#一些提升手段"></a>一些提升手段</h2><h3 id="文档结构化"><a class="header-anchor" href="#文档结构化"></a>文档结构化</h3><p>做法是提取文档的结构化信息，如标题、目录等，将这些信息也加入到切片中一起做索引。</p><p><img src="2024-12-31-RAG-heading-as-context.svg" alt="Add Heading as context"></p><p>这种方法背后的想法是文档的各级标题通常包含大量信息，在切片中加入可极大增加切片的信息量。另外还有很多细节需要优化，如目录相可能会干扰正文，可视情况移除；遇到列表最好放在同一个切片。</p><p>当然文档结构化本身是个脏活累活，一方面有许多不同类型的文档需要支持，如 PDF、Word、Excel、PPT 等。另一方面即便只是 doc，如何提取标题、目录也是个大问题。毕竟绝大多数文档都是不规范的。</p><h3 id="多路召回与重排序"><a class="header-anchor" href="#多路召回与重排序"></a>多路召回与重排序</h3><p>上面的 Naive RAG 模型中，只使用了向量化这一种手段，通常也称为语义检索(SemanticSearch)或 Dense Retrieval。但向量化在一些场景下效果并不好，例如：</p><ul><li>专有名词：一些领域特定的词汇在向量模型训练时可能没有出现过</li><li>缺乏精确性：返回的可能是语义相近但并不是精确匹配的内容</li><li>长度敏感：文档切片、Query 的长度不同，很可能有不同的效果</li></ul><p>所以通常会使用多路召回，再加上重排序，以提高检索的效果。</p><p><img src="2024-12-31-RAG-multi-retrieval.svg" alt="Multi Retrieval"></p><p>实践中最重要的是加上关键词检索，例如 ElasticSearch、Solr 等。但由于评分体系不同，最后需要重新对所有的文档进行排序。常见的做法有：</p><ul><li>可以专门使用一个重排序模型，如 bge-reranker</li><li>也可以使用 Reciprocal Rank Fusion (RRF) 这类简单的融合方法</li><li>还可以对各种分数归一化后再加权求和（如 Milvus 会对关键词搜索的分数使用arctan 归一化<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>）</li></ul><h3 id="查询改写-扩展"><a class="header-anchor" href="#查询改写-扩展"></a>查询改写/扩展</h3><p>对于像行业黑话，或者其它很难直接通过文档建立联系的概念，可以考虑由人工来提供一些信息，在查询前对 Query 进行改写或扩展。亦或者使用知识图谱或 LLM 来补充信息。</p><p><img src="2024-12-31-RAG-query-rewritten.svg" alt="Query Rewritten"></p><h3 id="文档-meta-利用与查询理解"><a class="header-anchor" href="#文档-meta-利用与查询理解"></a>文档 meta 利用与查询理解</h3><p>用户的问题常常也会直接或间接包含“结构化”的查询条件，如“二孩的专项附加扣除是多少”隐含时间信息“今年”；再如“XX 公司 2023 年的营收是多少”指定了公司名和时间。这些信息是过滤条件的强表达，如果在检索时不体现，则通常意味着结果不准确。</p><p>这通常需要结合文档的 meta 信息，例如维护文档的时间信息、作者信息、来源信息等。再利用 LLM 从用户 Query 中抽取相关的过滤条件，在检索时加入这些条件。</p><p><img src="2024-12-31-RAG-doc-meta.svg" alt="Query with meta"></p><h2 id="解决大局观问题其它尝试"><a class="header-anchor" href="#解决大局观问题其它尝试"></a>解决大局观问题其它尝试</h2><p>上面提到的文档结构化、多路召回、查询改写等都可以认为是在打补丁，怎么为文档片段增加上下文信息。但它们依旧解决不了”如何决定需要多少文档片段”这类问题。</p><p>目前看到最多的尝试就是使用知识图谱来解决这个问题。其中的典型是GraphRAG<sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup>，它的流程如下:</p><p><img src="2024-12-31-RAG-graphrag.svg" alt="GraphRAG pipeline"></p><p>它的问答流程其实已经不是一个传统的 RAG 了，可以理解成它是在索引阶段，通过对文档片段抽取实体关系及对应的描述，聚类成了一个（非典型的）知识图谱。而在检索阶段，就直接使用生成的聚类描述来回答问题。说实话我觉得这套方法大概率是无法落地的，有如下质疑：</p><ol><li>如何正确的且无监督地抽取实体关系</li><li>如何决定何时使用聚类来回答，何时使用普通的文档片段</li><li>如何保证响应时间（GraphRAG 回答时需要先使用聚类描述生成答案，由 LLM 筛选正确后再汇总）</li></ol><p>但这个方法其实让我想到传统机器学习中的混合高斯模型(GMM<sup class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup>)，它代表的混合模型思想，我粗浅的理解是：如果用一个分布无法解释数据，那就用多个分布来解释。在RAG 中，可以理解成：如果用一个长度的文档片段无法解释问题，那就用多个文档片段来解决。</p><p>例如在建索引时，可以分别以 200、400、600、… 个字为单位建立索引。并在检索时定位到对应长度的片段。这样可以保证大概率有一个足够长的片段是包含了足够多上下文的。当然，向量模型的能力可能并足以理解这么长的文本，导致效果不佳。</p><h2 id="落地的最大问题-人力"><a class="header-anchor" href="#落地的最大问题-人力"></a>落地的最大问题: 人力</h2><p>遇到效果问题，很多客户都会提“微调行不行”，可以，但是得加钱。</p><p>我只调过 Ranking 模型和 LLM，都需要高质量的标注数据。但一般产出这些数据需要对应的业务专家，尤其是 LLM 的 SFT 数据，还需要提供结果的回答。我们见到的很多客户，连知识库的数据预期从哪来，哪些合适放在知识库都回答不了，更别说调动资源来准备这些数据了。</p><p>也因此，ToB 的 RAG 项目很难落地，无监督的各种方法很快就会达到上限，而后续的数据治理又没法推进，于是死局。</p><h2 id="未来在哪？"><a class="header-anchor" href="#未来在哪？"></a>未来在哪？</h2><p>其实这个问题才是我的这碟醋，上面的内容都是为这碟醋包的饺子。当然也是在脑洞与胡思乱想。</p><p>我觉得不管是简单的向量化，还是像 GraphRAG，或者其它使用 LLM 提前建图谱的各种方法，本质上都是在尝试提前更好地“理解”文档，甚至建立文档间的联系。这个问题其实是“知识的表示”。</p><p>如果说大模型是世界知识的“无损”压缩，那么 RAG 就是在尝试压缩知识库中的这些知识，并在回答问题时提取出最相关的知识。但显然不管是向量化还是 GraphRAG，都不是一个足够好的压缩方式。</p><p>因此我认为未来会有一种高效的训练方式，能够在“理解”知识库的内容后，高效地压缩成一个模型，并且具有很高的压缩比。同时知识可以叠加，在问答时，可以结合世界知识模型和知识库浓缩模型来回答问题。</p><p>另一个问题是：搜索引擎是一个足够好的压缩方式吗？我认为对绝大多数场景来说是的。我也认为当前的 RAG 如果做的足够好，检索方面可能也就是一个复杂的搜索引擎了。</p><h2 id="参考"><a class="header-anchor" href="#参考"></a>参考</h2><ul><li><a href="https://arxiv.org/abs/2312.10997v5">Retrieval-Augmented Generation for Large Language Models: A Survey</a> 23年12月的一篇综述论文</li><li><a href="https://arxiv.org/pdf/2402.19473">Retrieval-Augmented Generation for AI-Generated Content: A Survey</a> 另一篇 24 年 2 月的综述论文</li><li><a href="https://arxiv.org/pdf/2408.08921">Graph Retrieval-Augmented Generation: A Survey</a> 使用图来增强 RAG 的综述，24 年 8 月</li><li><a href="https://zhuanlan.zhihu.com/p/673465732">大模型RAG 场景、数据、应用难点与解决（四）</a> 知乎的一篇 RAG 文章，里面很多痛我们也实际遇到了</li></ul><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>参见 <a href="https://github.com/milvus-io/milvus/discussions/34415">https://github.com/milvus-io/milvus/discussions/34415</a> <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p><a href="https://arxiv.org/pdf/2404.16130">https://arxiv.org/pdf/2404.16130</a> <a href="#fnref2" class="footnote-backref">↩</a></p></li><li id="fn3"  class="footnote-item"><p>参考文章: <a href="https://builtin.com/articles/gaussian-mixture-model">https://builtin.com/articles/gaussian-mixture-model</a> <a href="#fnref3" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;检索增强生成(Retrival Augmented Generation, RAG)技术随大模型的兴起而备受关注，还有一众向量数据库也随之一起“鸡犬升天”，另外还有最近火热的 AI 搜索也可认为是
RAG 的变种。但是随着应用的深入，RAG 的局限性也越来越明显。本文算是对</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="RAG" scheme="https://lotabout.me/tags/RAG/"/>
    
    <category term="LLM" scheme="https://lotabout.me/tags/LLM/"/>
    
  </entry>
  
  <entry>
    <title>KMP 字符串匹配算法原理详解</title>
    <link href="https://lotabout.me/2024/KMP-algorithm-explained/"/>
    <id>https://lotabout.me/2024/KMP-algorithm-explained/</id>
    <published>2024-12-25T23:21:30.000Z</published>
    <updated>2025-11-26T12:53:48.002Z</updated>
    
    <content type="html"><![CDATA[<p>KMP 算法非常精妙，代码写出来没几行，但它的原理却不容易理解。之前学习和遗忘了很多次。正好这次也忘得差不多了，记录下重新理解的过程。</p><h2 id="字符串匹配难在哪里"><a class="header-anchor" href="#字符串匹配难在哪里"></a>字符串匹配难在哪里</h2><p>字符串匹配其实就是要实现字符串的 <code>contains</code> 方法，判断一个字符串 <code>s</code> 中是否包含另一个字符串 <code>p</code>。例如 <code>&quot;hello&quot;.contains(&quot;ll&quot;)</code> 应该返回 <code>true</code>。应该如何实现呢？</p><p>一个很自然的想法是一个个匹配（如下图左侧）。从 <code>s</code> 的第一个字符开始，依次和 <code>p</code> 进行匹配 ①，如果匹配失败，从 <code>s</code> 的下一个字符开始，再次尝试匹配 ②，依此类推 ③。</p><p><img src="kmp-naive.svg" alt="How to do String Match Naively"></p><p>这种方法的问题是需要的匹配次数太多。它的时间复杂度是 <code>O(mn)</code>，<code>m</code> 和 <code>n</code>分别是 <code>s</code> 和 <code>p</code> 的长度。</p><h2 id="哪里可以优化"><a class="header-anchor" href="#哪里可以优化"></a>哪里可以优化</h2><p>通常在匹配过程中，待查找/匹配的字符串 <code>p</code> 是固定的，而被查找的字符串 <code>s</code> 是未知的，这意味着我们可以充分对 <code>p</code> 进行分析，从而优化匹配过程。</p><p><img src="kmp-should-do.svg" alt="Some matches could be skipped"></p><p>观察匹配过程，虽然我们并不知道 <code>s</code> 有哪些字符，但在 ① 的匹配过程中，前几个字符和 <code>p</code> 是匹配的，因此我们能确认 <code>s</code> 的前 5 个字符一定是 <code>s[0:5] = &quot;ababa&quot;</code>。</p><p>那么，当我们尝试把 <code>s</code> 向后移位，去匹配 <code>s[1:5]</code> 与 <code>p</code> 时，我们其实是在浪费时间，因此我们已经知道 <code>s[1] = 'b'</code> 而 <code>p[0] = 'a'</code>，肯定是不匹配的。这就是 ② 的情况。</p><p>再考虑 ③ 中的匹配，同样由于已经知道了 <code>s[0:5] = &quot;ababa&quot;</code>，移 2 位后 <code>s[2:5] = &quot;aba&quot;</code>，肯定是能和 <code>p</code> 的前三个字符 <code>p[0:3] = &quot;aba&quot;</code> 匹配的。没必要再匹配一遍，可以直接从<code>s[5]</code> 和 <code>p[3]</code> 开始匹配。</p><p>换句话说，通过 ① 中的匹配得到的关于 <code>s</code> 的信息，以及对字符串 <code>p</code> 的分析，在 ①匹配失败后，我们其实可以直接跳到 <code>s[5]</code> 和 <code>p[3]</code> 开始匹配（如上图右侧）。以此节省许多无效的匹配。</p><h2 id="跳到哪里"><a class="header-anchor" href="#跳到哪里"></a>跳到哪里</h2><p>上面的分析中，为什么我们能判断 ① 匹配失败后，直接尝试匹配 <code>s[5]</code> 和 <code>p[3]</code> 就可以呢？这是因为我们不断地向后移位，直到移了 <code>x</code> 位时，发现 <code>s[x:5]</code> 与<code>p[0: 5-x]</code> 匹配。在上例中，<code>x = 2</code>。于是下一次就可以从 <code>p[5-x]</code> 开始匹配。</p><p><img src="kmp-shift.svg" alt="KMP: Shift and Match process"></p><ul><li>这个分析其实完全不需要 <code>s</code>，因为所有 <code>s</code> 中要用到的信息（匹配的字符）都包含在 <code>p</code> 中了</li><li>移位和跳过匹配的过程，可以看作是在 <code>p</code> 中找到一个最长的前缀，使得这个前缀同时也是 <code>p</code> 的后缀</li></ul><p><img src="kmp-same-prefix-postfix.svg" alt="KMP algorithm's key: Searching for same prefix and postfix"></p><p>因此我们实际上要求的是：对于一个字符串 <code>p[0:n]</code>，找到一个最长的前缀，使得这个前缀（长度为 <code>k</code>）同时也是 <code>p</code> 的后缀，即 <code>p[0:k] = p[n-k:n]</code>。（注意<code>k</code>与上面的 <code>x</code> 是不同的，<code>x = n-k</code>）。</p><p>注：下文开始，我们提到“前缀”时指的都是同时既是前缀，也是后缀的字符串。</p><h2 id="如何找到最长前缀"><a class="header-anchor" href="#如何找到最长前缀"></a>如何找到最长前缀</h2><p>这个问题需要递归地考虑，我们记 <code>T[i]</code> 为 <code>p[0:i]</code> 的最长前缀长度（满足前缀等于后缀）。假设我们已经知道了所有的 <code>T[0], ..., T[i-1]</code>，现在我们要求 <code>T[i]</code>。</p><p>已知 <code>p[0:i]</code> 字符串最长前缀长度为 <code>T[i-1]</code>，前缀和后缀字符串分别记为 <code>X</code> 和 <code>Y</code>，有 <code>X=Y</code>:</p><p><img src="kmp-step-1.svg" alt="KMP longest prefix search: step 1"></p><p>① 现在 <code>T[i]</code> 最好的情况是 <code>T[i-1] + 1</code>。此时需要满足 <code>p[i] = p[T[i-1]]</code>:</p><p><img src="kmp-step-2.svg" alt="KMP longest prefix search: step 2"></p><p>② 如果 <code>p[i] != p[T[i-1]]</code>，则我们可以假设已经找到了 <code>T[i]</code>，看看它满足什么条件。首先我们知道 <code>T[i]</code> 一定小于 <code>T[i-1]</code>，所以假设找到了 <code>T[i]</code> 并记前缀为 <code>A</code>，后缀为 <code>B</code>，则有下图：</p><p><img src="kmp-step-3.svg" alt="KMP longest prefix search: step 3"></p><p>由于 <code>X = Y</code>，所以一定可以在 X 中找到后缀字符串 <code>C</code>，满足 <code>C = B = A</code>。于是我们发现，前缀 <code>A</code> 即是 <code>p[0:i]</code> 的前缀，也是 <code>p[0:T[i-1]]</code> 的前缀。因此我们可以得出结论：<code>T[i] &lt;= T[T[i-1]]</code>。</p><p><img src="kmp-step-4.svg" alt="KMP longest prefix search: step 4"></p><p>接着可以从最大值 <code>T[T[i-1]]</code> 开始，判断 <code>p[i]</code> 和 <code>p[T[T[i-1]]]</code> 是否相等，此时就递归加了情况 ①。</p><p>最终，如果匹配到 <code>p[0]</code> 还是不匹配，则认为不存在前缀，此时 <code>T[0] = 0</code>。</p><h2 id="建表代码"><a class="header-anchor" href="#建表代码"></a>建表代码</h2><p>建表的逻辑就是上面描述的递归过程，只是用循环来实现：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">kmp_build_table</span>(<span class="params">pattern</span>):</span><br><span class="line">    table = [<span class="number">0</span>] * <span class="built_in">len</span>(pattern)</span><br><span class="line">    i = <span class="number">0</span></span><br><span class="line">    <span class="keyword">for</span> j <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">1</span>, <span class="built_in">len</span>(pattern)):  <span class="comment"># ①</span></span><br><span class="line">        <span class="keyword">while</span> i &gt; <span class="number">0</span> <span class="keyword">and</span> pattern[i] != pattern[j]:</span><br><span class="line">            i = table[i - <span class="number">1</span>]  <span class="comment"># ②</span></span><br><span class="line">        <span class="keyword">if</span> pattern[i] == pattern[j]:</span><br><span class="line">            i += <span class="number">1</span> <span class="comment"># ③</span></span><br><span class="line">        table[j] = i <span class="comment"># ④</span></span><br><span class="line">    <span class="keyword">return</span> table</span><br></pre></td></tr></table></figure></div><p>这个代码的时间复杂度是 <code>O(n)</code>，其中 <code>n</code> 是 <code>pattern</code> 的长度。</p><p>外层循环 ① 从 <code>1</code> 到 <code>n = len(pattern)</code> 运行 <code>O(n)</code> 次比较容易理解。内层循环首先注意到 ③ 中，<code>i</code> 每次循环最多只增加 <code>1</code>，而 ④ 中 <code>table</code> 赋值为 <code>i</code>，因此可推出 <code>table</code> 中任意 <code>m &gt; n</code> 两个元素，满足 <code>table[m] - table[n] &lt;= (m-n)</code>。换句话说，在 ② 中的操作使得 <code>i</code> 是不断减小的，且全局减小的次数 <code>&lt; n</code>，于是减少的次数是 <code>O(n)</code> 的。另一方面 <code>i</code> 每次最多增加 <code>1</code>，增加的次数也是 <code>O(n)</code> 的。因此整体的时间复杂度是 <code>O(n)</code>。</p><h2 id="字符串匹配代码"><a class="header-anchor" href="#字符串匹配代码"></a>字符串匹配代码</h2><p>KMP 的算法就很简单了，只需要在匹配失败时，根据表中的值跳到下一个需要匹配的位置即可：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">kmp_search</span>(<span class="params">text, pattern</span>):</span><br><span class="line">    table = kmp_build_table(pattern)</span><br><span class="line">    i = <span class="number">0</span></span><br><span class="line">    <span class="keyword">for</span> j <span class="keyword">in</span> <span class="built_in">range</span>(<span class="built_in">len</span>(text)):</span><br><span class="line">        <span class="comment"># skip the non matching part</span></span><br><span class="line">        <span class="keyword">while</span> i &gt; <span class="number">0</span> <span class="keyword">and</span> text[j] != pattern[i]:</span><br><span class="line">            i = table[i - <span class="number">1</span>] <span class="comment"># next char in pattern to match</span></span><br><span class="line">        <span class="keyword">if</span> text[j] == pattern[i]:</span><br><span class="line">            i += <span class="number">1</span> <span class="comment"># text shift to next</span></span><br><span class="line">        <span class="keyword">if</span> i == <span class="built_in">len</span>(pattern):</span><br><span class="line">            <span class="keyword">return</span> j - i + <span class="number">1</span></span><br><span class="line">    <span class="keyword">return</span> -<span class="number">1</span></span><br></pre></td></tr></table></figure></div><p>这里的分析和建表代码一样，重点是内层循环是回退操作，且它的值不会因为 <code>j</code> 而被重置，因此内层的总时间复杂度是 <code>O(m)</code>，<code>m</code> 是 <code>pattern</code> 的长度。因此总的搜索时间为 <code>O(m+n)</code>。</p><h2 id="总结"><a class="header-anchor" href="#总结"></a>总结</h2><p>个人觉得理解过程中有两个关键点：</p><ol><li>为什么最后问题等价于找到一个最长的前缀，使得这个前缀同时也是后缀</li><li>在建表的过程中，为什么可以递归？（即分析中 <code>X = Y</code> 的性质，使得可以找到 <code>C = B = A</code> 的字符串）</li></ol><p>如果能理解这两点，KMP 其它的部分相信就不难理解了。希望这篇文章对你有帮助。</p><h2 id="参考"><a class="header-anchor" href="#参考"></a>参考</h2><ul><li><a href="https://oi-wiki.org/string/kmp/#%E7%AC%AC%E4%BA%8C%E4%B8%AA%E4%BC%98%E5%8C%96">前缀函数与 KMP 算法</a> 这里的分析更详细，里面学习到了分析中 <code>X = Y</code> 的性质</li></ul><h2 id="题外话"><a class="header-anchor" href="#题外话"></a>题外话</h2><p>在写代码的时候 Github Copilot 给我补全了下面的错误代码。怎么看都觉得不对，但是像这种很经典的代码它又不太应该出错，于是怀疑自己怀疑了半天。问 ChatGPT 倒是直接指出了错误，但让它给 test case 也老是给不对。</p><p>感慨下目前 AI 写代码，还是需要很强的鉴别能力的。这个代码在很多常见 case 下和正确代码是一样的，但它的逻辑就是错误的，如果埋在代码里得被坑死。</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">kmp_build_table</span>(<span class="params">pattern</span>):</span><br><span class="line">    table = [<span class="number">0</span>] * <span class="built_in">len</span>(pattern)</span><br><span class="line">    i = <span class="number">0</span></span><br><span class="line">    <span class="keyword">for</span> j <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">1</span>, <span class="built_in">len</span>(pattern)):</span><br><span class="line">        <span class="keyword">if</span> pattern[i] == pattern[j]:</span><br><span class="line">            i += <span class="number">1</span></span><br><span class="line">            table[j] = i</span><br><span class="line">        <span class="keyword">else</span>:</span><br><span class="line">            i = <span class="number">0</span></span><br><span class="line">    <span class="keyword">return</span> table</span><br></pre></td></tr></table></figure></div>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;KMP 算法非常精妙，代码写出来没几行，但它的原理却不容易理解。之前学习和遗忘了很多次。正好这次也忘得差不多了，记录下重新理解的过程。&lt;/p&gt;
&lt;h2 id=&quot;字符串匹配难在哪里&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#字符串匹配难在哪里&quot;&gt;&lt;/a</summary>
      
    
    
    
    <category term="Knowledge" scheme="https://lotabout.me/categories/Knowledge/"/>
    
    
    <category term="Algorithm" scheme="https://lotabout.me/tags/Algorithm/"/>
    
  </entry>
  
  <entry>
    <title>Webflux 线程模型理解</title>
    <link href="https://lotabout.me/2024/Webflux-Threading-Model/"/>
    <id>https://lotabout.me/2024/Webflux-Threading-Model/</id>
    <published>2024-06-07T21:00:00.000Z</published>
    <updated>2025-11-26T12:53:48.043Z</updated>
    
    <content type="html"><![CDATA[<p>使用 Webflux/Reactor 编程，如果对其中的原理了解不够全面，容易掉坑里。</p><h2 id="引子"><a class="header-anchor" href="#引子"></a>引子</h2><p>一个业务系统是用 Webflux 写的，发现后台在做批量任务时，会卡住页面的访问。排查发现是把 r2dbc 的 IO 线程给卡住了，导致页面请求时从数据库捞数据的请求卡死。但这个批量操作本身并没有特别多的 DB 操作，为什么会卡住呢？</p><h2 id="reactor-stream-简介"><a class="header-anchor" href="#reactor-stream-简介"></a>Reactor Stream 简介</h2><p>Java 9 引入了 <code>java.util.concurrent.Flow</code> 接口，支持[Reactive Streams 规范](<a href="https://www.reactive-streams.org/">https://www.reactive-streams.org/</a>)。规范的核心是定义了发布者(Publisher)和订阅者(Subscriber)的交互逻辑，规定Subscriber 必须以 PULL (拉取)的方式获取数据，以此解决异步流式处理中的背压问题<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>。</p><p>Reactive Streams 规定的交互流程如下（由于标准中有些部分留白，实际有两种常见模式）：</p><img src="/2024/Webflux-Threading-Model/2024-05-27-webflux-threading-model-reactive-streams.svg" class="" title="Reactive Stream"><p>主体流程分为这么几步：</p><ol><li>Subscriber 向 Publisher 订阅。<code>onSubscribe</code> 的入参是订阅者 Subscriber。</li><li>Publisher 通知 Subscriber 订阅成功，并发送一个 <code>Subscription</code> 对象用于后续交互。</li><li>当 Subscriber 有处理能力时，调用 Subscription 的 <code>request</code> 方法通知Publisher 发送 N 个数据</li><li>每有一个新数据，调用 Subscriber 的 <code>onNext</code> 方法一次，直到发送了 N 个数据。</li></ol><p>为什么需要有 <code>Subscription</code> 这个接口呢？为什么不直接把 <code>request</code> 方法定义在Publisher 中呢？有个大前提是 Reactive Streams 规范中，一个 Publisher 可以有多个 Subscriber，于是如果没有 <code>Subscription</code>，则 Publisher 需要在内部维护这个Subscriber 与数据的关系，增加了复杂度。因此不管是从概念上的解耦还是减小实现复杂性及提高性能性能方面考虑，把 Subscriber 与 Publisher 之间交互的生命周期抽象成<code>Subscription</code>，都是一个不错的选择。</p><p>另外注意到图里有两种模式。Reactive Sterams 只规定调用了 <code>Subscription.request</code>之后，如果有新的数据需要调用 Subscriber 的 <code>onNext</code> 方法。但是并没有规定<code>onNext</code> 谁来调用。于是根据 Publisher 中数据是否需要共享，可以分为 Cold 和 Hot两种模式。</p><p>Cold 模式下数据是分离的，每个 Subscriber 都有自己的数据流，例如<code>Flux.range</code>，每个 subscriber 都会从头开始计数。于是 Publisher 可以把当前消费的位置保存在 Subscription 中，由 Subscription 来调用 <code>onNext</code> 方法。</p><p>Hot 模式下数据是共享的，例如 <code>Flux.interval(..).share()</code>，记录了开始到现有的秒数，每个 Subscriber 在订阅时都希望得到当前秒数，而不是从第 1s 开始。于是秒数信息必须由 Publisher 保存，并且对 Subscriber 共享，此时 <code>subscription.request</code>就只是个传话筒了。</p><h2 id="reactor-与-reactive-streams-规范"><a class="header-anchor" href="#reactor-与-reactive-streams-规范"></a>Reactor 与 Reactive Streams 规范</h2><p>在流式代码中，通常只有一个数据源（例如调用某个 API），之后会对这个数据做一系列的 <code>map</code>, <code>filter</code> 等操作，每个这样的操作符，从逻辑上都可以等价于既是一个publisher 又是一个 subscriber。例如下面这样的代码:</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="type">var</span> <span class="variable">myPub</span> <span class="operator">=</span> Flux.range(<span class="number">1</span>, <span class="number">10</span>)</span><br><span class="line">                .map(x -&gt; x * <span class="number">2</span>)</span><br><span class="line">                .filter(x -&gt; x &gt; <span class="number">10</span>);</span><br><span class="line"></span><br><span class="line">myPub.subscribe(System.out::println);</span><br></pre></td></tr></table></figure></div><p>首先是构建 <code>publisher</code>，的过程，每个操作符<sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup>都会保留它的父 publisher：</p><img src="/2024/Webflux-Threading-Model/2024-05-27-webflux-threading-model-assemble.svg" class="" title="Assemble Stage"><p>于是当我们执行 <code>myPub.subscribe</code><sup class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup> 时，每个操作符本身作为一个 Subscriber，会不断调用父 Publisher 的 <code>subscribe</code> 方法；而父 Publisher 在调用 <code>onSubscribe</code> 时，每个操作符作为一个 Subscriber，会不断调用下一个操作符的 <code>onSubscribe</code> 方法:</p><img src="/2024/Webflux-Threading-Model/2024-05-27-webflux-threading-model-subscribe.svg" class="" title="Subscribe"><p>而当 Subscriber 调用 <code>request</code> 方法时，也是相同的路径<sup class="footnote-ref"><a href="#fn4" id="fnref4">[4]</a></sup>:</p><img src="/2024/Webflux-Threading-Model/2024-05-27-webflux-threading-model-request.svg" class="" title="request and onNext"><h2 id="线程如何调度"><a class="header-anchor" href="#线程如何调度"></a>线程如何调度</h2><p>上面我们讲解了如何组装流式代码以及它的内部执行流程，但这些代码是在哪个线程上执行的呢？我们知道对于同步代码，代码会在同一个线程上执行，于是上面的示例中，所有的调用都在同一个线程上：</p><img src="/2024/Webflux-Threading-Model/2024-05-27-webflux-threading-model-same-thread.svg" class="" title="Same Thread"><p>图中的棕线代表线程。但是这个例子比较特殊，因为 <code>Flux.range</code> 的数据是就绪的，而如果需要使用诸如 <code>WebClient</code> 调用 API 后做处理，则涉及到异步调用 IO，此时则会是这样：</p><img src="/2024/Webflux-Threading-Model/2024-05-27-webflux-threading-model-two-thread.svg" class="" title="Run on Different Thread"><p>上图会假设 WebClient 调用了外部服务，当外部服务返回时会在另一个线程上执行回调函数 callback，而这个 callback 会调用 <code>B.onNext</code> 方法，以此类推后续的 <code>onNext</code>都会在这个线程上执行。</p><p>这就有大问题了！例如底层调用使用的是 Netty，则执行 callback 的线程一般就是Netty的 worker 线程，但现在我们必须在这个线程上执行所有的 onNext 方法，如果某个操作符（如某个 <code>map</code>）是 CPU 密集型的，就会导致该 worker 线程被长时间阻塞，此时 Netty 的 Worker 线程池成为瓶颈，造成其它子模块的请求没有 worker 线程能处理而卡死，子功能之间互相耦合、干扰。</p><h2 id="无奈下的-subscribeon-与-publishon"><a class="header-anchor" href="#无奈下的-subscribeon-与-publishon"></a>无奈下的 subscribeOn 与 publishOn</h2><p>为了解决上面的问题，Reactor 提供了 <code>subscribeOn</code> 和 <code>publishOn</code> 两个方法，可以分别影响 <code>request</code> 和 <code>onNext</code> 方法的执行线程。例如：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="type">var</span> <span class="variable">myPub</span> <span class="operator">=</span> Flux.range(<span class="number">1</span>, <span class="number">10</span>)</span><br><span class="line">                .map(x -&gt; x * <span class="number">2</span>)</span><br><span class="line">                .subscribeOn(Schedulers.elastic())</span><br><span class="line">                .filter(x -&gt; x &gt; <span class="number">10</span>);</span><br><span class="line">myPub.subscribe(System.out::println);</span><br></pre></td></tr></table></figure></div><p>则执行的流程如下:</p><img src="/2024/Webflux-Threading-Model/2024-05-27-webflux-threading-model-subscribeOn.svg" class="" title="subscribeOn"><p>可以看到 <code>subscribeOn</code> 方法会影响 <code>request</code> 方法的执行线程，另外由于整个流程没有另外的线程切换（如上节提到的 <code>WebClient</code>），因此 <code>onNext</code> 方法也会在同一个线程执行。我们又知道诸如 <code>map(x -&gt; x * 2)</code> 这样的操作是在 <code>onNext</code> 方法中执行的，于是也会在新的线程上执行。</p><p>由于 <code>request</code> 方法调用顺序从代码的视角是由下到上的，因此一般说 <code>subscribeOn</code>影响的是向上的调用链，直到 <code>publishOn</code> 或其它的 <code>subscribeOn</code> 方法为止。</p><p>同样的，<code>publishOn</code> 方法会影响 <code>onNext</code> 方法的执行线程，例如：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="type">var</span> <span class="variable">myPub</span> <span class="operator">=</span> Flux.range(<span class="number">1</span>, <span class="number">10</span>)</span><br><span class="line">                .map(x -&gt; x * <span class="number">2</span>)</span><br><span class="line">                .publishOn(Schedulers.elastic())</span><br><span class="line">                .filter(x -&gt; x &gt; <span class="number">10</span>);</span><br><span class="line">myPub.subscribe(System.out::println);</span><br></pre></td></tr></table></figure></div><img src="/2024/Webflux-Threading-Model/2024-05-27-webflux-threading-model-publishOn.svg" class="" title="publishOn"><p>由于 <code>onNext</code> 方法调用顺序从代码的视角是由上到下的，因此一般说 <code>publishOn</code>影响的是向下的调用链，直到其它的 <code>publishOn</code> 为止。</p><p>但要注意，如果 <code>subscribeOn</code> 和 <code>publishOn</code> 同时存在，则 <code>subscribeOn</code> 的作用会“穿过” <code>publishOn</code>：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">var</span> <span class="variable">myPub</span> <span class="operator">=</span> Flux.range(<span class="number">1</span>, <span class="number">10</span>)</span><br><span class="line">                .map(x -&gt; x * <span class="number">2</span>)</span><br><span class="line">                .publishOn(Schedulers.elastic())</span><br><span class="line">                .subscribeOn(Schedulers.elastic())</span><br><span class="line">                .filter(x -&gt; x &gt; <span class="number">10</span>);</span><br><span class="line">myPub.subscribe(System.out::println);</span><br></pre></td></tr></table></figure></div><img src="/2024/Webflux-Threading-Model/2024-05-27-webflux-threading-model-mixed.svg" class="" title="publishOn"><p>在这种情况下，第 2 行的 <code>map</code> 还是会被第 <code>4</code> 行的 <code>subscribeOn</code> 影响；而第 5行的 <code>fitler</code> 最终会被 <code>publishOn</code> 影响。</p><h2 id="后记"><a class="header-anchor" href="#后记"></a>后记</h2><p>学习这个线程模型距离我开始学习 Webflux 几乎有 4 年以上的时间了，在我自认为对Webflux 了解还算充分的时候被教育了。时至今日，我依然有两个暴论：</p><ol><li>Webflux 只适用于诸如网关这样的业务简单但高并发的场景。</li><li>对于绝大多数人来说，green thread 类型的异步模型才是最好的。</li></ol><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>背压问题还有其它处理手段，可以参考我之前的文章 <a href="https://lotabout.me/2020/Back-Pressure">背压与流量控制</a> <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p>这里简化了很多实现细节，如实际上操作符并没有实现<code>Subscriber</code> 接口，而是在调用 <code>subscribe</code> 时才生成对应的 Subscriber。但并不影响整体逻辑的理解。 <a href="#fnref2" class="footnote-backref">↩</a></p></li><li id="fn3"  class="footnote-item"><p>严格来说 <code>System.out::println</code> 不是一个 Subscriber，实际上 <code>subscribe</code> 方法会将它包装成一个 <code>LambdaSubscriber</code>。 <a href="#fnref3" class="footnote-backref">↩</a></p></li><li id="fn4"  class="footnote-item"><p>这里只是抽象的模型，省略了 Subscription 与Publisher 的交互，以及 Cold Hot publisher 的区别等等。 <a href="#fnref4" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;使用 Webflux/Reactor 编程，如果对其中的原理了解不够全面，容易掉坑里。&lt;/p&gt;
&lt;h2 id=&quot;引子&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#引子&quot;&gt;&lt;/a&gt;引子&lt;/h2&gt;
&lt;p&gt;一个业务系统是用 Webflux 写的，发现后台在做</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="java" scheme="https://lotabout.me/tags/java/"/>
    
    <category term="Reactive" scheme="https://lotabout.me/tags/Reactive/"/>
    
    <category term="Streams" scheme="https://lotabout.me/tags/Streams/"/>
    
  </entry>
  
  <entry>
    <title>Java Agent 入门教程</title>
    <link href="https://lotabout.me/2024/Java-Agent-101/"/>
    <id>https://lotabout.me/2024/Java-Agent-101/</id>
    <published>2024-05-05T14:22:39.000Z</published>
    <updated>2025-11-26T12:53:48.000Z</updated>
    
    <content type="html"><![CDATA[<p>Java 提供了动态修改字节码的能力，而 Java Agent 提供了外挂修改的能力，能不动已有的 jar 包，在运行时动态修改 jar 内的字节码。</p><p>本文会从零构建一个 Java Agent，让Jar 包在运行时打印每一个调用的方法名，其中涉及到 Java Agent 的整体结构，ASM 库的基础操作，文章较长，建议跟着走一遍。</p><h2 id="java-agent-项目结构"><a class="header-anchor" href="#java-agent-项目结构"></a>Java Agent 项目结构</h2><p>先创建如下目录结构：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight text"><table><tr><td class="code"><pre><span class="line">.</span><br><span class="line">├── pom.xml</span><br><span class="line">└── src</span><br><span class="line">    └── main</span><br><span class="line">        ├── java</span><br><span class="line">        │   └── me</span><br><span class="line">        │       └── lotabout</span><br><span class="line">        │           └── Launcher.java</span><br><span class="line">        └── resources</span><br><span class="line">            └── META-INF</span><br><span class="line">                └── MANIFEST.MF</span><br></pre></td></tr></table></figure></div><h3 id="premain-与-agentmain"><a class="header-anchor" href="#premain-与-agentmain"></a>premain 与 agentmain</h3><p>我们知道常规 Java 程序的入口是 <code>main</code> 函数，而 Java Agent 在不同的架构模式下有不同的入口<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>：</p><ul><li>静态加载入口为 <code>premain</code>：如 <code>java -javaagent:my-agent.jar -jar app.jar</code>，在启动 Jar 包时指定要加载的 agent，权限较高。</li><li>动态加载入口为 <code>agentmain</code>：已经通过 <code>java -jar app.jar</code> 等方式运行的 JVM，可以动态 Attach 后加载 Agent，权限较低，如无法新增属性、方法等。</li></ul><p>两个方法定义如下（定义放在哪个类中都可以，下面会在 <code>MANIFEST.MF</code> 文件中声明）：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><figcaption><span>Launcher.java</span></figcaption><table><tr><td class="code"><pre><span class="line"><span class="keyword">package</span> me.lotabout;</span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">Launcher</span> &#123;</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">premain</span><span class="params">(String agentArgs, Instrumentation inst)</span> &#123;&#125;</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">agentmain</span><span class="params">(String agentArgs, Instrumentation inst)</span> &#123;&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><ul><li>参数中的 <code>agentArgs</code> 是传递给 Agent 的参数。例如这样调用 <code>java -javaagent:my-agent.jar=my-agent-args app.jar</code>，则 <code>my-agent.jar</code> 中的<code>premain</code> 函数中的 <code>agentArgs</code> 参数的值，就是字符串 <code>&quot;my-agent-args&quot;</code>。</li><li>参数中的 <code>Instrumentation</code> 是 Java 提供的修改字节码的 API. 通常 Java Agent作者的任务，就是利用 <code>Instrumentation</code> 定位到希望修改的类并做出修改。</li></ul><p>另外容易踩坑的一点是，调用 <code>Instrumentation.addTransformer</code> 添加的 transformer默认只对“ <strong>未来加载的类</strong> ”才会生效。而动态加载(<code>agentmain</code>)通常是在应用程序启动后才加载，就会出现添加的 transformer 不生效的情况。对静态加载(<code>premain</code>)则一般不会有这个问题，因为它是在 <code>main</code> 函数之前加载的，</p><p>动态加载(<code>agentmain</code>) 如果想修改 <code>main</code> 中就已经加载的类，则需要在添加transformer 再调用<code>Instrumentation#retransformClasses</code> 对已加载的类执行转换才能生效。</p><h3 id="manifest"><a class="header-anchor" href="#manifest"></a>MANIFEST</h3><p>上面提到 <code>premain</code> 和 <code>agentmain</code> 可以定义在任何类中，那 JVM 怎么知道去哪找呢？我们需要在 jar包的 <code>MANIFEST.MF</code> 文件<sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup> 中指定 agent 的入口类是什么，以及 agent会有哪些能力：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight text"><figcaption><span>MANIFEST.MF</span></figcaption><table><tr><td class="code"><pre><span class="line">Premain-Class: me.lotabout.Launcher # 静态加载(premain) Agent 时的入口类</span><br><span class="line">Agent-Class: me.lotabout.Launcher   # 动态加载(agentmain) Agent 时的入口类</span><br><span class="line">Can-Redefine-Classes: true          # 该 Agent 能否重新定义类</span><br><span class="line">Can-Retransform-Classes: true       # 该 Agent 能否修改已有类</span><br><span class="line">Can-Set-Native-Method-Prefix: true  # 是否允许修改 Native 方法的前缀</span><br></pre></td></tr></table></figure></div><ul><li>Premain-Class: 静态加载(premain) Agent 时的入口类</li><li>Agent-Class: 动态加载(agentmain) Agent 时的入口类</li><li>Can-Redefine-Classes: 该 Agent 能否重新定义类</li><li>Can-Retransform-Classes: 该 Agent 能否修改已有类</li><li>Can-Set-Native-Method-Prefix: 是否允许修改 Native 方法的前缀。Native 方法不是字节码实现的，Agent 修改不了它的逻辑。通常修改 Native 是Proxy 的做法，把原有的 Native 方法重命名，新建同名的 Java 方法来调用老方法。此时需要修改Native 方法前缀的能力。</li></ul><h3 id="pom-xml"><a class="header-anchor" href="#pom-xml"></a>pom.xml</h3><p>打包本身也比较烦，比如 maven 打包时需要指定 <code>MANIFEST.MF</code> 路径，示例如下：</p><div class="noise-code-block" style="--code-block-max-height:300px;"><figure class="highlight xml"><figcaption><span>pom.xml</span></figcaption><table><tr><td class="code"><pre><span class="line"><span class="meta">&lt;?xml version=<span class="string">&quot;1.0&quot;</span> encoding=<span class="string">&quot;UTF-8&quot;</span>?&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">project</span> <span class="attr">xmlns</span>=<span class="string">&quot;http://maven.apache.org/POM/4.0.0&quot;</span></span></span><br><span class="line"><span class="tag">  <span class="attr">xmlns:xsi</span>=<span class="string">&quot;http://www.w3.org/2001/XMLSchema-instance&quot;</span></span></span><br><span class="line"><span class="tag">  <span class="attr">xsi:schemaLocation</span>=<span class="string">&quot;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd&quot;</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">modelVersion</span>&gt;</span>4.0.0<span class="tag">&lt;/<span class="name">modelVersion</span>&gt;</span></span><br><span class="line"></span><br><span class="line">  <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>me.lotabout<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>my-agent<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">version</span>&gt;</span>1.0-SNAPSHOT<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line"></span><br><span class="line">  <span class="tag">&lt;<span class="name">properties</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">maven.compiler.source</span>&gt;</span>11<span class="tag">&lt;/<span class="name">maven.compiler.source</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">maven.compiler.target</span>&gt;</span>11<span class="tag">&lt;/<span class="name">maven.compiler.target</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">project.build.sourceEncoding</span>&gt;</span>UTF-8<span class="tag">&lt;/<span class="name">project.build.sourceEncoding</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;/<span class="name">properties</span>&gt;</span></span><br><span class="line"></span><br><span class="line">  <span class="tag">&lt;<span class="name">dependencies</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">      <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.ow2.asm<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line marked">      <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>asm<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">      <span class="tag">&lt;<span class="name">version</span>&gt;</span>9.4<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">      <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.ow2.asm<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line marked">      <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>asm-tree<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">      <span class="tag">&lt;<span class="name">version</span>&gt;</span>9.4<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;/<span class="name">dependencies</span>&gt;</span></span><br><span class="line"></span><br><span class="line">  <span class="tag">&lt;<span class="name">build</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">plugins</span>&gt;</span></span><br><span class="line">      <span class="tag">&lt;<span class="name">plugin</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.apache.maven.plugins<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>maven-assembly-plugin<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">version</span>&gt;</span>3.6.0<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">configuration</span>&gt;</span></span><br><span class="line">          <span class="tag">&lt;<span class="name">descriptorRefs</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">descriptorRef</span>&gt;</span>jar-with-dependencies<span class="tag">&lt;/<span class="name">descriptorRef</span>&gt;</span></span><br><span class="line">          <span class="tag">&lt;/<span class="name">descriptorRefs</span>&gt;</span></span><br><span class="line">          <span class="tag">&lt;<span class="name">archive</span>&gt;</span></span><br><span class="line marked">            <span class="tag">&lt;<span class="name">manifestFile</span>&gt;</span>src/main/resources/META-INF/MANIFEST.MF<span class="tag">&lt;/<span class="name">manifestFile</span>&gt;</span></span><br><span class="line">          <span class="tag">&lt;/<span class="name">archive</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;/<span class="name">configuration</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">executions</span>&gt;</span></span><br><span class="line">          <span class="tag">&lt;<span class="name">execution</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">phase</span>&gt;</span>package<span class="tag">&lt;/<span class="name">phase</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">goals</span>&gt;</span></span><br><span class="line">              <span class="tag">&lt;<span class="name">goal</span>&gt;</span>single<span class="tag">&lt;/<span class="name">goal</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;/<span class="name">goals</span>&gt;</span></span><br><span class="line">          <span class="tag">&lt;/<span class="name">execution</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;/<span class="name">executions</span>&gt;</span></span><br><span class="line">      <span class="tag">&lt;/<span class="name">plugin</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">plugins</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;/<span class="name">build</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">project</span>&gt;</span></span><br></pre></td></tr></table></figure></div><p>我们额外引入了 <code>asm</code> 与 <code>asm-tree</code> 库，我们后面要用它们来操作字节码。</p><p><code>mvn clean package</code> 后得到 <code>target/my-agent-1.0-SNAPSHOT-jar-with-dependencies.jar</code>，之后就可以用<code>java -javaagent:target/my-agent-1.0-SNAPSHOT-jar-with-dependencies.jar -jar app.jar</code> 来调用了。这个名字太长了，后面我们写命令时会简写成 <code>my-agent.jar</code>。</p><h3 id="动态加载-attach"><a class="header-anchor" href="#动态加载-attach"></a>动态加载 Attach</h3><p>假设我们已经执行了 <code>java -jar app.jar</code>，希望加载 <code>my-agent.jar</code>，要怎么做？需要利用 <a href="https://docs.oracle.com/en/java/javase/21/docs/api/jdk.attach/module-summary.html">Attach API</a>。</p><ol><li>先得到 <code>app.jar</code> 进程的 PID，并 attach 得到 <code>app.jar</code> 的 <code>VirtualMachine</code> 实例：<code>VirtualMachine vm = VirtualMachine.attach(PID);</code></li><li>调用 <code>VirtualMachine#loadAgent(&quot;my-agent.jar&quot;)</code> 让 <code>app.jar</code> 进程加载 agent</li></ol><p>为了方便上述操作，我们可以把这段逻辑写到 <code>Launcher</code> 的 <code>main</code> 函数中：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">main</span><span class="params">(String[] args)</span></span><br><span class="line">    <span class="keyword">throws</span> IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException &#123;</span><br><span class="line">    <span class="type">String</span> <span class="variable">pid</span> <span class="operator">=</span> args[<span class="number">0</span>];</span><br><span class="line">    <span class="type">String</span> <span class="variable">path</span> <span class="operator">=</span> Launcher.class.getProtectionDomain().getCodeSource().getLocation().getPath();</span><br><span class="line">    <span class="type">VirtualMachine</span> <span class="variable">vm</span> <span class="operator">=</span> VirtualMachine.attach(pid);</span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">        vm.loadAgent(path);</span><br><span class="line">    &#125; <span class="keyword">finally</span> &#123;</span><br><span class="line">        vm.detach();</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>再在 <code>MANIFEST.MF</code> 中增加一行：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">Main-Class: me.lotabout.Launcher</span><br></pre></td></tr></table></figure></div><p>现在就可以使用 <code>java -jar my-agent.jar &lt;目标 PID&gt;</code> 来动态加载 agent 了。注意此时调用的是 agent 的 <code>agentmain</code> 方法。</p><h2 id="instrumentation"><a class="header-anchor" href="#instrumentation"></a>Instrumentation</h2><p>上节中的内容是建立 java agent 项目结构，目标是产出一个能被 JVM 识别的Agent。接下来的任务是找到 <code>app.jar</code> 中感兴趣的类并修改这些类的字节码。这些工作都要基于JDK 提供的 Instrumentation API。</p><h3 id="instrumentation-api"><a class="header-anchor" href="#instrumentation-api"></a>Instrumentation API</h3><p><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/Instrumentation.html">Instrumentation</a>的核心抽象是 <a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/ClassFileTransformer.html">ClassFileTransformer</a>，对字节码的修改逻辑都在这个接口中实现，而 Instrumentation 接口则是用来添加、删除 transformer 的。Instrumentation 常见的使用流程（伪代码）为:</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="comment">// 对于未加载的类，addTransformer 后就能生效</span></span><br><span class="line">instrument.addTransformer(myTransformer, <span class="literal">true</span>);</span><br><span class="line"><span class="comment">// 对于已经加载的类，需要调用 retransformClasses 来触发修改</span></span><br><span class="line"><span class="keyword">for</span> (Class clazz: instrument.getAllLoadedClasses()) &#123;</span><br><span class="line">    <span class="keyword">if</span> (needToTransform(clazz)) &#123;</span><br><span class="line">        instrument.retransformClasses(clazz);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p><code>Instrumentation</code> 的一些常用接口定义如下：</p><ul><li><code>getAllLoadedClasses()</code> 获取所有加载的类，得到数组后我们可以自己筛选出关心的类</li><li><code>redefineClasses(ClassDefinition... definitions)</code> 使用参数中的类定义重新定义类</li><li><code>retransformClasses(Class&lt;?&gt;... classes)</code> 使用添加的 transformers 修改指定的类</li><li><code>addTransformer(ClassFileTransformer transformer)</code> 注册 <code>transformer</code></li><li><code>removeTransformer(ClassFileTransformer transformer)</code> 注销 <code>transformer</code></li></ul><h3 id="classfiletransformer"><a class="header-anchor" href="#classfiletransformer"></a>ClassFileTransformer</h3><p>对字节码的修改逻辑需要定义在<a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/ClassFileTransformer.html">ClassFileTransformer</a>的 <code>transform</code> 方法中，方法的签名如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="type">byte</span>[] transform(ClassLoader loader,</span><br><span class="line">                 String className,</span><br><span class="line">                 Class&lt;?&gt; classBeingRedefined,</span><br><span class="line">                 ProtectionDomain protectionDomain,</span><br><span class="line">                 <span class="type">byte</span>[] classfileBuffer)</span><br><span class="line">    <span class="keyword">throws</span> IllegalClassFormatException &#123;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><ul><li>通常我们会使用各种信息来过滤掉不感兴趣的类（不想修改就直接直接返回原字节码）。</li><li>核心输入输出是 <code>class</code> 二进制流(<code>byte[]</code>)，即 transformer 假定字节码的修改是在二进制层面进行的。</li></ul><p>直接修改类的二进制不是人能干的事，于是通常会使用一些库把 <code>byte[]</code> 转成一些库定义的结构，操作后再转回 <code>byte[]</code> 返回。下面是常用的一些库：</p><ul><li><a href="https://asm.ow2.io/">asm</a> JDK 内部也用了它，性能好，但 API 的抽象层度很低</li><li><a href="https://www.javassist.org">javaassist</a> API 的抽象比 ASM 更高，更适合普通用户，支持直接写 Java 源码</li><li><a href="https://bytebuddy.net">bytebuddy</a> API 抽象度更高，例如有专门的 builder 来创建 Agent</li></ul><h2 id="asm-api-简介"><a class="header-anchor" href="#asm-api-简介"></a>ASM API 简介</h2><h3 id="asm-核心-api"><a class="header-anchor" href="#asm-核心-api"></a>ASM 核心 API</h3><p>ASM<sup class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup> 有两套 API： Event-Based 和 Tree-Based。简单来说 Event-Based 就是 visitor模式，用户需要定义各种元素的 visitor，扫描字节码中过程中遇到什么元素就调用对应元素的 visitor；Tree-Based 可以理解成先扫一遍字节码组装成一棵树，再对这棵树做后续编辑、修改等操作。Event-Based API 性能更好但 Tree-Based API 更容易理解和使用。</p><p>ASM 的整体流程是 <code>byte[] -&gt; ClassNode -&gt; (修改) -&gt; byte[]</code>，其中<code>ClassNode</code> 是Tree-Based API 对“类”的抽象。基于 Tree-Based API 来修改字节码的 pattern 如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="type">ClassNode</span> <span class="variable">cn</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">ClassNode</span>(ASM4);                       <span class="comment">// 定义解析后的类</span></span><br><span class="line"><span class="type">ClassReader</span> <span class="variable">cr</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">ClassReader</span>(origin_classfile_bytes); <span class="comment">// 创建 reader 读取原始字节码</span></span><br><span class="line">cr.accept(cn, <span class="number">0</span>);                                         <span class="comment">// 解析原始字节码，填充到 cn 中</span></span><br><span class="line">...                                                       <span class="comment">// 这里可对 cn 做修改</span></span><br><span class="line"><span class="type">ClassWriter</span> <span class="variable">cw</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">ClassWriter</span>(<span class="number">0</span>);                      <span class="comment">// 创建 writer</span></span><br><span class="line">cn.accept(cw);                                            <span class="comment">// 把修改后的 cn 写回到 writer</span></span><br><span class="line"><span class="type">byte</span>[] b = cw.toByteArray();                              <span class="comment">// 把 writer 中的字节码转成 byte[]</span></span><br></pre></td></tr></table></figure></div><p>对 <code>ClassNode</code> 的操作，最常见的是遍历其中的 <code>cn.methods</code> 属性来遍历该类的所有方法，之后通过修改 <code>method.instructions</code> 来修改字节码。</p><h3 id="类型描述符-type-descriptor"><a class="header-anchor" href="#类型描述符-type-descriptor"></a>类型描述符(Type Descriptor)</h3><p>ASM 中对于类型的描述有自己的一套规则，严格来说也不是 ASM 自创的，而是 JVM Spec中定义的<sup class="footnote-ref"><a href="#fn4" id="fnref4">[4]</a></sup>，定义如下：</p><table><thead><tr><th>Java Type</th><th>Type Descriptor</th></tr></thead><tbody><tr><td><code>boolean</code></td><td><code>Z</code></td></tr><tr><td><code>byte</code></td><td><code>B</code></td></tr><tr><td><code>char</code></td><td><code>C</code></td></tr><tr><td><code>double</code></td><td><code>D</code></td></tr><tr><td><code>float</code></td><td><code>F</code></td></tr><tr><td><code>int</code></td><td><code>I</code></td></tr><tr><td><code>long</code></td><td><code>J</code></td></tr><tr><td><code>short</code></td><td><code>S</code></td></tr><tr><td><code>Object</code></td><td><code>Ljava/lang/Object;</code></td></tr><tr><td><code>int[]</code></td><td><code>[I</code></td></tr><tr><td><code>Object[][]</code></td><td><code>[[Ljava/lang/Object;</code></td></tr></tbody></table><p>基本类型的描述符就是对应的大写字母（除了 <code>boolean</code> 用 <code>Z</code> 代替，因为字母冲突）；其中类的描述符是 <code>L&lt;classname&gt;;</code> 的格式，数组的描述符是 <code>[&lt;array_type&gt;</code>，如果多维就以此类推。</p><h3 id="方法描述符-method-descriptor"><a class="header-anchor" href="#方法描述符-method-descriptor"></a>方法描述符(Method Descriptor)</h3><p>方法描述符是一个字符串，格式为 <code>(&lt;参数类型1&gt;&lt;参数类型2&gt;...)&lt;返回类型&gt;</code>，其中参数类型就是上节的类型描述符，如果返回 <code>void</code> 则写 <code>V</code>，例如：</p><table><thead><tr><th>源文件中类的定义</th><th>类型描述符</th></tr></thead><tbody><tr><td><code>void m(int i, float f)</code></td><td><code>(IF)V</code></td></tr><tr><td><code>int m(Object o)</code></td><td><code>(Ljava/lang/Object;)I</code></td></tr><tr><td><code>int[] m(int i, String s)</code></td><td><code>(ILjava/lang/String;)[I</code></td></tr><tr><td><code>Object m(int[] i)</code></td><td><code>([I)Ljava/lang/Object;</code></td></tr></tbody></table><h2 id="示例-打印每个调用的方法"><a class="header-anchor" href="#示例-打印每个调用的方法"></a>示例-打印每个调用的方法</h2><p>由于 ASM 的 API 基本是直接添加字节码，但如果对字节码不熟悉其实很难直接写出，于是一种方法是先用 javap 等工具把一个类的字节码反编译出来，再根据反编译的结果来写。</p><h3 id="println-字节码"><a class="header-anchor" href="#println-字节码"></a>println 字节码</h3><p>例如我们想在方法被调用时执行如下代码：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line">System.out.println(<span class="string">&quot;&gt;&gt; calling Method: &lt;my_method&gt;&quot;</span>);</span><br></pre></td></tr></table></figure></div><p>于是我们先写一个类，然后用 <code>javap -c</code> 来反编译：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">Hello</span> &#123;</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">main</span><span class="params">(String[] args)</span> &#123;</span><br><span class="line">        System.out.println(<span class="string">&quot;&gt;&gt; calling Method: &lt;my_method&gt;&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>执行如下命令：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight sh"><table><tr><td class="code"><pre><span class="line">$ javac Hello.java</span><br><span class="line">$ javap -c Hello</span><br><span class="line">Compiled from <span class="string">&quot;Hello.java&quot;</span></span><br><span class="line">class Hello &#123;</span><br><span class="line">  Hello();</span><br><span class="line">    Code:</span><br><span class="line">       0: aload_0</span><br><span class="line">       1: invokespecial <span class="comment">#1                  // Method java/lang/Object.&quot;&lt;init&gt;&quot;:()V</span></span><br><span class="line">       4: <span class="built_in">return</span></span><br><span class="line"></span><br><span class="line">  public static void main(java.lang.String[]);</span><br><span class="line">    Code:</span><br><span class="line">       0: getstatic     <span class="comment">#2                  // Field java/lang/System.out:Ljava/io/PrintStream;</span></span><br><span class="line">       3: ldc           <span class="comment">#3                  // String &gt;&gt; calling Method: &lt;my_method&gt;</span></span><br><span class="line">       5: invokevirtual <span class="comment">#4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V</span></span><br><span class="line">       8: <span class="built_in">return</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>比较关键的是 <code>getstatic</code>, <code>ldc</code>, <code>invokevirtual</code> 这三个指令，分别代表先获取<code>System.out</code>，再加载常量 <code>&quot;&gt;&gt; calling Method: &lt;my_method&gt;&quot;</code>，最后调用<code>println</code> 三个操作。</p><h3 id="自定义-transformer"><a class="header-anchor" href="#自定义-transformer"></a>自定义 Transformer</h3><p>接下来我们定义一个 <code>ClassFileTransformer</code> 来实现上述逻辑：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><figcaption><span>MyTransformer.java</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">MyTransformer</span> <span class="keyword">implements</span> <span class="title class_">ClassFileTransformer</span> &#123;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">private</span> <span class="type">String</span> <span class="variable">prefixOfclassToPrint</span> <span class="operator">=</span> <span class="string">&quot;&quot;</span>;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">public</span> <span class="title function_">MyTransformer</span><span class="params">(String prefixOfclassToPrint)</span> &#123;</span><br><span class="line">      <span class="built_in">this</span>.prefixOfclassToPrint = prefixOfclassToPrint.replace(<span class="string">&quot;.&quot;</span>, <span class="string">&quot;/&quot;</span>);</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="meta">@Override</span></span><br><span class="line">  <span class="keyword">public</span> <span class="type">byte</span>[] transform(ClassLoader loader, String className, Class&lt;?&gt; classBeingRedefined,</span><br><span class="line">      ProtectionDomain protectionDomain, <span class="type">byte</span>[] classfileBuffer) &#123;</span><br><span class="line">    <span class="keyword">if</span> (!className.startsWith(<span class="built_in">this</span>.prefixOfclassToPrint)) &#123;</span><br><span class="line">      <span class="keyword">return</span> classfileBuffer;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    System.out.println(<span class="string">&quot;transforming class: &quot;</span> + className);</span><br><span class="line">    <span class="type">ClassNode</span> <span class="variable">cn</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">ClassNode</span>(Opcodes.ASM4);</span><br><span class="line">    <span class="type">ClassReader</span> <span class="variable">cr</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">ClassReader</span>(classfileBuffer);</span><br><span class="line">    cr.accept(cn, <span class="number">0</span>);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> (<span class="keyword">var</span> method : cn.methods) &#123;</span><br><span class="line">      System.out.println(<span class="string">&quot;patching Method: &quot;</span> + method.name);</span><br><span class="line">      <span class="type">var</span> <span class="variable">list</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">InsnList</span>();</span><br><span class="line">      list.add(<span class="keyword">new</span> <span class="title class_">FieldInsnNode</span>(Opcodes.GETSTATIC, <span class="string">&quot;java/lang/System&quot;</span>, <span class="string">&quot;out&quot;</span>,</span><br><span class="line">          <span class="string">&quot;Ljava/io/PrintStream;&quot;</span>));</span><br><span class="line">      list.add(<span class="keyword">new</span> <span class="title class_">LdcInsnNode</span>(<span class="string">&quot;&gt;&gt; calling Method: &quot;</span> + method.name));</span><br><span class="line">      list.add(<span class="keyword">new</span> <span class="title class_">MethodInsnNode</span>(Opcodes.INVOKEVIRTUAL, <span class="string">&quot;java/io/PrintStream&quot;</span>, <span class="string">&quot;println&quot;</span>,</span><br><span class="line">          <span class="string">&quot;(Ljava/lang/String;)V&quot;</span>, <span class="literal">false</span>));</span><br><span class="line">      method.instructions.insert(list);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="type">ClassWriter</span> <span class="variable">cw</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">ClassWriter</span>(ClassWriter.COMPUTE_MAXS);</span><br><span class="line">    cn.accept(cw);</span><br><span class="line">    <span class="keyword">return</span> cw.toByteArray();</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><ul><li>第 6 行要注意在 transformer 中拿到的类，包名是以 <code>/</code> 分隔的，而<code>Instrument.getAllLoadedClasses()</code> 中拿到的类名是以 <code>.</code> 分隔的我们允许传入的参数是以 <code>.</code> 分隔的，所以需要转换一下。</li><li>第 12~14 行是过滤掉不感兴趣的类，不感兴趣的类直接返回原字节码。</li><li>第 16~19, 32~34 行是上文所说的 ASM 框架代码，反序列化二进制和序列化二进制的过程。</li><li>第 21 行开始遍历该类的所有方法，每个方法都插入我们的逻辑</li><li>第 24~28 行是插入字节码的逻辑，对应上小节说的 <code>getstatic</code>, <code>ldc</code>,<code>invokevirtual</code> 三个指令。其中也看到了类型描述符、方法描述符的使用。</li><li>第 29 行是把生成的字节码 “insert” 到方法的字节码中，“insert” 是在最前面插入</li></ul><h3 id="组装与测试"><a class="header-anchor" href="#组装与测试"></a>组装与测试</h3><p>最后，我们在 <code>premain</code> 和 <code>agentmain</code> 中注册我们的 <code>MyTransformer</code>，最终 <code>Launcher</code> 类如下：</p><div class="noise-code-block" style="--code-block-max-height:300px;"><figure class="highlight java"><figcaption><span>Launcher.java</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">Launcher</span> &#123;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">main</span><span class="params">(String[] args)</span></span><br><span class="line">      <span class="keyword">throws</span> IOException, AttachNotSupportedException,</span><br><span class="line">             AgentLoadException, AgentInitializationException &#123;</span><br><span class="line">    <span class="type">String</span> <span class="variable">pid</span> <span class="operator">=</span> args[<span class="number">0</span>];</span><br><span class="line">    <span class="type">String</span> <span class="variable">prefix</span> <span class="operator">=</span> args[<span class="number">1</span>];</span><br><span class="line">    <span class="type">String</span> <span class="variable">path</span> <span class="operator">=</span> Launcher.class.getProtectionDomain().getCodeSource().getLocation().getPath();</span><br><span class="line">    <span class="type">VirtualMachine</span> <span class="variable">vm</span> <span class="operator">=</span> VirtualMachine.attach(pid);</span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">      vm.loadAgent(path, prefix);</span><br><span class="line">    &#125; <span class="keyword">finally</span> &#123;</span><br><span class="line">      vm.detach();</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">premain</span><span class="params">(String agentArgs, Instrumentation inst)</span></span><br><span class="line">      <span class="keyword">throws</span> UnmodifiableClassException &#123;</span><br><span class="line">    inst.addTransformer(<span class="keyword">new</span> <span class="title class_">MyTransformer</span>(agentArgs), <span class="literal">true</span>);</span><br><span class="line">    <span class="keyword">for</span> (<span class="keyword">var</span> clazz : inst.getAllLoadedClasses()) &#123;</span><br><span class="line">      <span class="keyword">if</span> (inst.isModifiableClass(clazz) &amp;&amp; clazz.getName().startsWith(agentArgs)) &#123;</span><br><span class="line">        inst.retransformClasses(clazz);</span><br><span class="line">      &#125;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">agentmain</span><span class="params">(String agentArgs, Instrumentation inst)</span></span><br><span class="line">      <span class="keyword">throws</span> UnmodifiableClassException &#123;</span><br><span class="line">    premain(agentArgs, inst);</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">class</span> <span class="title class_">MyTransformer</span> <span class="keyword">implements</span> <span class="title class_">ClassFileTransformer</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="type">String</span> <span class="variable">prefixOfclassToPrint</span> <span class="operator">=</span> <span class="string">&quot;&quot;</span>;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="title function_">MyTransformer</span><span class="params">(String prefixOfclassToPrint)</span> &#123;</span><br><span class="line">        <span class="built_in">this</span>.prefixOfclassToPrint = prefixOfclassToPrint.replace(<span class="string">&quot;.&quot;</span>, <span class="string">&quot;/&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> <span class="type">byte</span>[] transform(ClassLoader loader, String className, Class&lt;?&gt; classBeingRedefined,</span><br><span class="line">        ProtectionDomain protectionDomain, <span class="type">byte</span>[] classfileBuffer) &#123;</span><br><span class="line">      <span class="keyword">if</span> (!className.startsWith(<span class="built_in">this</span>.prefixOfclassToPrint)) &#123;</span><br><span class="line">        <span class="keyword">return</span> classfileBuffer;</span><br><span class="line">      &#125;</span><br><span class="line"></span><br><span class="line">      System.out.println(<span class="string">&quot;transforming class: &quot;</span> + className);</span><br><span class="line">      <span class="type">ClassNode</span> <span class="variable">cn</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">ClassNode</span>(Opcodes.ASM4);</span><br><span class="line">      <span class="type">ClassReader</span> <span class="variable">cr</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">ClassReader</span>(classfileBuffer);</span><br><span class="line">      cr.accept(cn, <span class="number">0</span>);</span><br><span class="line"></span><br><span class="line">      <span class="keyword">for</span> (<span class="keyword">var</span> method : cn.methods) &#123;</span><br><span class="line">        System.out.println(<span class="string">&quot;patching Method: &quot;</span> + method.name);</span><br><span class="line">        <span class="type">var</span> <span class="variable">list</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">InsnList</span>();</span><br><span class="line">        list.add(<span class="keyword">new</span> <span class="title class_">FieldInsnNode</span>(Opcodes.GETSTATIC, <span class="string">&quot;java/lang/System&quot;</span>, <span class="string">&quot;out&quot;</span>,</span><br><span class="line">            <span class="string">&quot;Ljava/io/PrintStream;&quot;</span>));</span><br><span class="line">        list.add(<span class="keyword">new</span> <span class="title class_">LdcInsnNode</span>(<span class="string">&quot;&gt;&gt; calling Method: &quot;</span> + method.name));</span><br><span class="line">        list.add(<span class="keyword">new</span> <span class="title class_">MethodInsnNode</span>(Opcodes.INVOKEVIRTUAL, <span class="string">&quot;java/io/PrintStream&quot;</span>, <span class="string">&quot;println&quot;</span>,</span><br><span class="line">            <span class="string">&quot;(Ljava/lang/String;)V&quot;</span>, <span class="literal">false</span>));</span><br><span class="line">        method.instructions.insert(list);</span><br><span class="line">      &#125;</span><br><span class="line"></span><br><span class="line">      <span class="type">ClassWriter</span> <span class="variable">cw</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">ClassWriter</span>(ClassWriter.COMPUTE_MAXS);</span><br><span class="line">      cn.accept(cw);</span><br><span class="line">      <span class="keyword">return</span> cw.toByteArray();</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>之后有两种调用方式（类名前缀的包名以 <code>.</code> 分隔）：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight sh"><table><tr><td class="code"><pre><span class="line">$ java -javaagent:my-agent-1.0-SNAPSHOT-jar-with-dependencies.jar=&lt;类名前缀&gt; -jar app.jar</span><br><span class="line">$ java -jar my-agent-1.0-SNAPSHOT-jar-with-dependencies.jar &lt;PID&gt; &lt;类名前缀&gt;</span><br></pre></td></tr></table></figure></div><p>对于如下的示例 <code>Hello</code> 类：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">Hello</span> &#123;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">main</span><span class="params">(String[] args)</span> <span class="keyword">throws</span> Exception &#123;</span><br><span class="line">    <span class="keyword">for</span> (<span class="type">int</span> <span class="variable">i</span> <span class="operator">=</span> <span class="number">0</span>; i &lt; <span class="number">2</span>; i++) &#123;</span><br><span class="line">      outer();</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">outer</span><span class="params">()</span> &#123;</span><br><span class="line">    test();</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">test</span><span class="params">()</span> &#123;</span><br><span class="line">    System.out.println(<span class="string">&quot;Hello world!&quot;</span>);</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>可以在运行时挂上 agent 来看到输出:</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight text"><table><tr><td class="code"><pre><span class="line">$ javac Hello.java</span><br><span class="line">$ java -javaagent:my-agent-1.0-SNAPSHOT-jar-with-dependencies.jar=Hello Hello</span><br><span class="line">transforming class: Hello</span><br><span class="line">patching Method: &lt;init&gt;</span><br><span class="line">patching Method: main</span><br><span class="line">patching Method: outer</span><br><span class="line">patching Method: test</span><br><span class="line">&gt;&gt; calling Method: main</span><br><span class="line">&gt;&gt; calling Method: outer</span><br><span class="line">&gt;&gt; calling Method: test</span><br><span class="line">Hello world!</span><br><span class="line">&gt;&gt; calling Method: outer</span><br><span class="line">&gt;&gt; calling Method: test</span><br><span class="line">Hello world!</span><br></pre></td></tr></table></figure></div><p>可以看到我们成功的在每个方法调用时打印了一行信息。</p><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>本文介绍了 Java Agent 的基本代码结构，简单介绍了 ASM 库来修改字节码的方法，最后给出了示例，让 Agent 能动态修改类的方法，在方法开始处打印一行信息。</p><p>另外一些常见的字节码修改场景可以参考 ASM 的文档或使用其它字节码修改库。例如希望打印每个方法的返回值，理论上需要遍历每个方法的字节码，找到 <code>return</code> 指令，然后在该指令前插入打印指令，这种常见 pattern 通常都有库封装好，可以直接使用。</p><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>这里的机制在 <a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/package-summary.html">java.lang.instrument 文档</a> 中有详细说明 <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p>manifest 的属性参考 <a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/package-summary.html">java.lang.instrument 文档</a> <a href="#fnref2" class="footnote-backref">↩</a></p></li><li id="fn3"  class="footnote-item"><p><a href="https://asm.ow2.io/asm4-guide.pdf">ASM 官方教程</a> <a href="#fnref3" class="footnote-backref">↩</a></p></li><li id="fn4"  class="footnote-item"><p><a href="https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3.4">JVM Spec: Field Descriptors</a> <a href="#fnref4" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;Java 提供了动态修改字节码的能力，而 Java Agent 提供了外挂修改的能力，能不动已有的 jar 包，在运行时动态修改 jar 内的字节码。&lt;/p&gt;
&lt;p&gt;本文会从零构建一个 Java Agent，让Jar 包在运行时打印每一个调用的方法名，其中涉及到 Java </summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="Java" scheme="https://lotabout.me/tags/Java/"/>
    
    <category term="Agent" scheme="https://lotabout.me/tags/Agent/"/>
    
  </entry>
  
  <entry>
    <title>Percona 线程池行为验证</title>
    <link href="https://lotabout.me/2023/Verification-of-Percona-Thread-Pool-Behavior/"/>
    <id>https://lotabout.me/2023/Verification-of-Percona-Thread-Pool-Behavior/</id>
    <published>2023-05-26T15:56:15.000Z</published>
    <updated>2025-11-26T12:53:48.041Z</updated>
    
    <content type="html"><![CDATA[<h2 id="背景"><a class="header-anchor" href="#背景"></a>背景</h2><p>看到 plantegg 大佬的文章 <a href="https://plantegg.github.io/2020/11/17/MySQL%E7%BA%BF%E7%A8%8B%E6%B1%A0%E5%AF%BC%E8%87%B4%E7%9A%84%E5%BB%B6%E6%97%B6%E5%8D%A1%E9%A1%BF%E6%8E%92%E6%9F%A5/">MySQL线程池导致的延时卡顿排查</a>中提到 MySQL 线程池中 oversubscribe 的行为。也看到有小伙伴<a href="https://github.com/wych42">wych42</a>在<a href="https://gist.github.com/wych42/87df731da394a14d926c87f51fa9469d">尝试复现</a>文章里提到的现象。</p><p>自己也尝试复现（基于 Percona 8.0.29-21），但现象和 wych42 的结果有细节上的差异，引申发现自己对 Percona 线程池模型的理解有问题，记录一下。</p><h2 id="假想的-oversubscribe-模型"><a class="header-anchor" href="#假想的-oversubscribe-模型"></a>假想的 oversubscribe 模型</h2><p>我们知道 oversubscribe 是用来限制 thread group 内同时运行的线程数量的，于是猜想工作原理（类比 Java 中的 <code>ThreadPoolExecutor</code>，oversubscribe 类比<code>corePoolSize</code>）：</p><ol><li>由 thread group 中的 listener 线程将任务从网络连接挪到 thread group 中的队列</li><li>在需要时创建若干个 worker 来消费队列，此时如果数量超过 oversubscribe 则停止创建</li><li>worker 在空闲若干时间后退出</li></ol><h2 id="差异一：耗时不稳定"><a class="header-anchor" href="#差异一：耗时不稳定"></a>差异一：耗时不稳定</h2><p>仿照在 wych42 的 <a href="https://gist.github.com/wych42/87df731da394a14d926c87f51fa9469d#%E6%89%A7%E8%A1%8C%E6%96%B9%E6%A1%88-1">执行方案一</a>中，设置如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">| thread_handling                         | pool-of-threads |</span><br><span class="line">| thread_pool_high_prio_mode              | transactions    |</span><br><span class="line">| thread_pool_high_prio_tickets           | 4294967295      |</span><br><span class="line">| thread_pool_idle_timeout                | 60              |</span><br><span class="line">| thread_pool_max_threads                 | 100000          |</span><br><span class="line">| thread_pool_oversubscribe               | 1               |</span><br><span class="line">| thread_pool_size                        | 1               |</span><br><span class="line">| thread_pool_stall_limit                 | 500             |</span><br></pre></td></tr></table></figure></div><ul><li>执行SQL <code>select sleep(2)</code></li><li>执行并发：<code>8</code></li></ul><p>按前一节的假想模型，预期 thread group 每次有 2 个线程执行(1+oversubscribe)，结果是每批次 2 个 SQL 输出，耗时分别是 2s, 4s, 6s, 8s。</p><p>实测发现并不总是符合预期，各种情况都有，比如会有两批次就跑完的(4 个SQL 2s 加上2 个 SQL 4s）：</p><div class="noise-code-block" style="--code-block-max-height:300px;"><figure class="highlight text"><table><tr><td class="code"><pre><span class="line">16:05:08.765+08:00: thread 0 iteration 1 start</span><br><span class="line">16:05:08.765+08:00: thread 7 iteration 1 start</span><br><span class="line">16:05:08.765+08:00: thread 3 iteration 1 start</span><br><span class="line">16:05:08.765+08:00: thread 4 iteration 1 start</span><br><span class="line">16:05:08.765+08:00: thread 1 iteration 1 start</span><br><span class="line">16:05:08.765+08:00: thread 5 iteration 1 start</span><br><span class="line">16:05:08.766+08:00: thread 2 iteration 1 start</span><br><span class="line">16:05:08.766+08:00: thread 6 iteration 1 start</span><br><span class="line">16:05:10.985+08:00: thread 4 iteration 1 took 2220 ms</span><br><span class="line">16:05:10.985+08:00: thread 0 iteration 1 took 2220 ms</span><br><span class="line">16:05:10.985+08:00: thread 3 iteration 1 took 2220 ms</span><br><span class="line">16:05:11.029+08:00: thread 5 iteration 1 took 2264 ms</span><br><span class="line">16:05:11.029+08:00: thread 7 iteration 1 took 2264 ms</span><br><span class="line">16:05:13.080+08:00: thread 6 iteration 1 took 4313 ms</span><br><span class="line">16:05:13.080+08:00: thread 1 iteration 1 took 4314 ms</span><br><span class="line">16:05:13.085+08:00: thread 2 iteration 1 took 4320 ms</span><br><span class="line">16:05:13.086+08:00: thread 2 iteration 2 start</span><br><span class="line">16:05:13.086+08:00: thread 6 iteration 2 start</span><br><span class="line">16:05:13.086+08:00: thread 5 iteration 2 start</span><br><span class="line">16:05:13.086+08:00: thread 3 iteration 2 start</span><br><span class="line">16:05:13.086+08:00: thread 1 iteration 2 start</span><br><span class="line">16:05:13.086+08:00: thread 4 iteration 2 start</span><br><span class="line">16:05:13.086+08:00: thread 0 iteration 2 start</span><br><span class="line">16:05:13.086+08:00: thread 7 iteration 2 start</span><br><span class="line">16:05:15.167+08:00: thread 6 iteration 2 took 2081 ms</span><br><span class="line">16:05:15.167+08:00: thread 1 iteration 2 took 2081 ms</span><br><span class="line">16:05:17.165+08:00: thread 2 iteration 2 took 4080 ms</span><br><span class="line">16:05:17.165+08:00: thread 3 iteration 2 took 4079 ms</span><br><span class="line">16:05:19.170+08:00: thread 5 iteration 2 took 6084 ms</span><br><span class="line">16:05:19.170+08:00: thread 4 iteration 2 took 6084 ms</span><br><span class="line">16:05:21.164+08:00: thread 0 iteration 2 took 8078 ms</span><br><span class="line">16:05:21.164+08:00: thread 7 iteration 2 took 8078 ms</span><br><span class="line">16:05:21.165+08:00: thread 1 iteration 3 start</span><br><span class="line">16:05:21.165+08:00: thread 4 iteration 3 start</span><br><span class="line">16:05:21.165+08:00: thread 7 iteration 3 start</span><br><span class="line">16:05:21.165+08:00: thread 2 iteration 3 start</span><br><span class="line">16:05:21.165+08:00: thread 3 iteration 3 start</span><br><span class="line">16:05:21.165+08:00: thread 6 iteration 3 start</span><br><span class="line">16:05:21.165+08:00: thread 5 iteration 3 start</span><br><span class="line">16:05:21.165+08:00: thread 0 iteration 3 start</span><br><span class="line">16:05:23.252+08:00: thread 6 iteration 3 took 2087 ms</span><br><span class="line">16:05:23.252+08:00: thread 7 iteration 3 took 2087 ms</span><br><span class="line">16:05:25.271+08:00: thread 2 iteration 3 took 4106 ms</span><br><span class="line">16:05:25.271+08:00: thread 4 iteration 3 took 4106 ms</span><br><span class="line">16:05:27.252+08:00: thread 3 iteration 3 took 6087 ms</span><br><span class="line">16:05:27.251+08:00: thread 1 iteration 3 took 6086 ms</span><br><span class="line">16:05:29.243+08:00: thread 0 iteration 3 took 8078 ms</span><br><span class="line">16:05:29.243+08:00: thread 5 iteration 3 took 8078 ms</span><br><span class="line">16:05:29.244+08:00: thread 5 iteration 4 start</span><br><span class="line">16:05:29.244+08:00: thread 2 iteration 4 start</span><br><span class="line">16:05:29.244+08:00: thread 6 iteration 4 start</span><br><span class="line">16:05:29.244+08:00: thread 7 iteration 4 start</span><br><span class="line">16:05:29.244+08:00: thread 4 iteration 4 start</span><br><span class="line">16:05:29.244+08:00: thread 1 iteration 4 start</span><br><span class="line">16:05:29.244+08:00: thread 3 iteration 4 start</span><br><span class="line">16:05:29.244+08:00: thread 0 iteration 4 start</span><br><span class="line">16:05:31.342+08:00: thread 7 iteration 4 took 2098 ms</span><br><span class="line">16:05:31.343+08:00: thread 5 iteration 4 took 2099 ms</span><br><span class="line">16:05:33.324+08:00: thread 2 iteration 4 took 4080 ms</span><br><span class="line">16:05:33.324+08:00: thread 1 iteration 4 took 4080 ms</span><br><span class="line">16:05:33.411+08:00: thread 3 iteration 4 took 4167 ms</span><br><span class="line">16:05:33.417+08:00: thread 6 iteration 4 took 4173 ms</span><br><span class="line">16:05:35.424+08:00: thread 0 iteration 4 took 6180 ms</span><br><span class="line">16:05:35.424+08:00: thread 4 iteration 4 took 6180 ms</span><br></pre></td></tr></table></figure></div><p>这说明要么是我们的假想模型有问题，要么是 Percona 实现有 BUG。那实际情况是怎么样的呢？拉代码看半天也看不出所以然，只能自行编译并加了很多 debug 日志，大概是明白了。</p><h2 id="percona-thread-group-模型"><a class="header-anchor" href="#percona-thread-group-模型"></a>Percona thread group 模型</h2><p>这里只说明 thread group 内的机制（不考虑全局的限制）。</p><ol><li>线程有两种状态：active &amp; waiting，执行 SQL 过程中阻塞则记为 waiting，如等锁或 SLEEP</li><li>线程的角色分成 listener 和 worker。listener 将网络上的请求挪到队列中，worker 从队列中获取任务来执行。一些情况下 listener 会自己变成worker 执行任务，worker 发现没有 listener 时也会变成 listener</li><li>每个 group 内部有两个队列，高优队列和低优队列。如默认模式下，处于 XA 事务、持有表锁等情形下会被认为高优<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></li><li>thread_pool_oversubscribe 用来限制同时运行的线程数量，它是通过限制从队列获取任务来达到目的：<ol><li>active 线程数 &gt;= 1 + oversubscribe 时 worker 不取任务，直接休眠，取任务时额外考虑下面规则</li><li>active + waiting 线程数 &gt; 1 + oversubscribe 时 worker 不取任务，但只限制低优队列中的任务</li></ol></li><li>为了防止各种假死的情况，会有专门的定时线程，检测两次执行间是否有进展，如果没有进展则会创建新的 worker（且此时规则 4.1 失效）。如 listener 变成 worker后创建新的线程承担工作</li><li>worker 线程在空闲一段时间(<code>thread_pool_idle_timeout</code>)后会退出</li></ol><p>有几个推论：</p><ol><li>thread group 的线程数可能大于 1+oversubscribe。没有机制限制线程的生成</li><li>同时运行的任务可能会大于 1+oversubscribe。一方面 listener 变成 worker 时接任务不通过队列，因此不受限制；另一方面 waiting 的任务不参与计算规则 4.1；再者高优任务不参与计算规则 4.2.</li><li>即使允许执行，任务的开始时间也可能会有延迟。如当前 listener 在干活，新任务只能等定时任务生成新的 worker 来执行，运气差的，延时可能会接近<code>2*thread_pool_stall_limit</code>。</li></ol><h2 id="差异一解释"><a class="header-anchor" href="#差异一解释"></a>差异一解释</h2><p>结合更新后的模型以及实际的 debug 日志，差异一解释如下：</p><ol><li><code>SELECT SLEEP</code> 语句在执行时，线程会变成 waiting 状态</li><li>由于 active 线程为 0, 很多代码位置上会尝试创建新的线程</li><li>新的 worker 线程由于规则 4.2 的限制会取不到任务，进入休眠</li><li>但由于某些时刻所有线程都在做任务，没有 listener，此时 worker 不休眠，而进入listener 模式</li><li>进入 listener 模式的的线程在一些情况（发现有新任务且已有的队列为空）下会决定自己执行任务</li><li>当 listener 决定自己执行任务，它会直接从网络连接中获取任务而不经过任务队列，因此不受限制</li><li>listener 开始执行任务时变成 worker 角色，有可能重新触发情况 #4</li></ol><h2 id="差异一补充-case：高优队列不受规则-4-2-限制"><a class="header-anchor" href="#差异一补充-case：高优队列不受规则-4-2-限制"></a>差异一补充 case：高优队列不受规则 4.2 限制</h2><p>已知锁表能让事务成为高优，我们把负载改成两句 SQL：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">LOCK TABLES t? READ ; 这里每个线程锁不同的表</span><br><span class="line">SELECT SLEEP(2)</span><br></pre></td></tr></table></figure></div><p>测试的结果如下，可以看到在第一个迭代中创建了 N 个 worker，第二个迭代中每个worker 都实际执行了任务，因此结果只有一个批次，都是 2s 左右。</p><div class="noise-code-block" style="--code-block-max-height:300px;"><figure class="highlight text"><table><tr><td class="code"><pre><span class="line">11:47:36.251+08:00: thread 0 iteration 0 start</span><br><span class="line">11:47:36.251+08:00: thread 7 iteration 0 start</span><br><span class="line">11:47:36.251+08:00: thread 5 iteration 0 start</span><br><span class="line">11:47:36.251+08:00: thread 2 iteration 0 start</span><br><span class="line">11:47:36.251+08:00: thread 1 iteration 0 start</span><br><span class="line">11:47:36.251+08:00: thread 6 iteration 0 start</span><br><span class="line">11:47:36.251+08:00: thread 4 iteration 0 start</span><br><span class="line">11:47:36.251+08:00: thread 3 iteration 0 start</span><br><span class="line">11:47:39.059+08:00: thread 0 iteration 0 took 2808 ms</span><br><span class="line">11:47:39.162+08:00: thread 5 iteration 0 took 2911 ms</span><br><span class="line">11:47:39.425+08:00: thread 7 iteration 0 took 3174 ms</span><br><span class="line">11:47:41.282+08:00: thread 1 iteration 0 took 5031 ms</span><br><span class="line">11:47:41.352+08:00: thread 6 iteration 0 took 5101 ms</span><br><span class="line">11:47:41.463+08:00: thread 2 iteration 0 took 5212 ms</span><br><span class="line">11:47:41.613+08:00: thread 4 iteration 0 took 5362 ms</span><br><span class="line">11:47:43.532+08:00: thread 3 iteration 0 took 7281 ms</span><br><span class="line">11:47:43.533+08:00: thread 3 iteration 1 start</span><br><span class="line">11:47:43.533+08:00: thread 5 iteration 1 start</span><br><span class="line">11:47:43.533+08:00: thread 7 iteration 1 start</span><br><span class="line">11:47:43.533+08:00: thread 0 iteration 1 start</span><br><span class="line">11:47:43.533+08:00: thread 2 iteration 1 start</span><br><span class="line">11:47:43.533+08:00: thread 4 iteration 1 start</span><br><span class="line">11:47:43.533+08:00: thread 1 iteration 1 start</span><br><span class="line">11:47:43.533+08:00: thread 6 iteration 1 start</span><br><span class="line">11:47:45.727+08:00: thread 3 iteration 1 took 2194 ms</span><br><span class="line">11:47:45.751+08:00: thread 7 iteration 1 took 2218 ms</span><br><span class="line">11:47:45.751+08:00: thread 2 iteration 1 took 2218 ms</span><br><span class="line">11:47:45.751+08:00: thread 4 iteration 1 took 2218 ms</span><br><span class="line">11:47:45.755+08:00: thread 5 iteration 1 took 2222 ms</span><br><span class="line">11:47:45.756+08:00: thread 1 iteration 1 took 2223 ms</span><br><span class="line">11:47:45.760+08:00: thread 0 iteration 1 took 2227 ms</span><br><span class="line">11:47:45.765+08:00: thread 6 iteration 1 took 2232 ms</span><br></pre></td></tr></table></figure></div><h2 id="差异二：select-sleep-可能不是好负载"><a class="header-anchor" href="#差异二：select-sleep-可能不是好负载"></a>差异二：SELECT SLEEP 可能不是好负载</h2><p>通过源码我们知道执行 SLEEP 的线程是 waiting 状态，会绕过某些 oversubscribe 的限制。我们尝试使用下面的负载：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">MySQL&gt; select benchmark(9999999, md5(&#x27;when will it end?&#x27;));</span><br><span class="line">1 row in set</span><br><span class="line">Time: 2.079s</span><br></pre></td></tr></table></figure></div><p>测试的结果就更看不出“批次”的模式了。当然由于多个任务并行执行，实际的耗时也增加了（3.8s）。</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">16:58:01.905+08:00: thread 2 iteration 0 start</span><br><span class="line">16:58:01.905+08:00: thread 6 iteration 0 start</span><br><span class="line">16:58:01.905+08:00: thread 1 iteration 0 start</span><br><span class="line">16:58:01.905+08:00: thread 4 iteration 0 start</span><br><span class="line">16:58:01.905+08:00: thread 7 iteration 0 start</span><br><span class="line">16:58:01.905+08:00: thread 5 iteration 0 start</span><br><span class="line">16:58:01.905+08:00: thread 3 iteration 0 start</span><br><span class="line">16:58:01.905+08:00: thread 0 iteration 0 start</span><br><span class="line">16:58:05.757+08:00: thread 0 iteration 0 took 3852 ms</span><br><span class="line">16:58:06.679+08:00: thread 5 iteration 0 took 4774 ms</span><br><span class="line">16:58:08.376+08:00: thread 1 iteration 0 took 6471 ms</span><br><span class="line">16:58:10.425+08:00: thread 2 iteration 0 took 8520 ms</span><br><span class="line">16:58:11.620+08:00: thread 6 iteration 0 took 9715 ms</span><br><span class="line">16:58:13.604+08:00: thread 4 iteration 0 took 11699 ms</span><br><span class="line">16:58:14.413+08:00: thread 3 iteration 0 took 12508 ms</span><br><span class="line">16:58:16.843+08:00: thread 7 iteration 0 took 14938 ms</span><br></pre></td></tr></table></figure></div><p>但是，我们预期仍是一个批次执行两个 SQL，为什么第二个请求 <code>4774ms</code> 才返回？下面我们看看在 Percona 中增加的 debug 信息，来了解内部工作的机制</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">   time         thread id       message</span><br><span class="line">   16:58:02.197 123145417097216 command: 3: select benchmark(9999999, md5(&#x27;when will it end?&#x27;));</span><br><span class="line">   16:58:02.206 4863544832 add connection(active: 1, waiting: 0, stalled: 0)</span><br><span class="line">① 16:58:02.899 123145390891008 check_stall #1&gt; wake_or_create(active: 1, waiting: 0, stalled: 0)</span><br><span class="line">   16:58:02.899 123145390891008 wake or create thread</span><br><span class="line">   16:58:02.899 123145390891008 thread waked (active: 1, waiting: 0, stalled: 0)</span><br><span class="line">   16:58:02.899 123145418162176 get_event &gt; after wakeup(active: 2, waiting: 0, stalled: 0)</span><br><span class="line">② 16:58:02.899 123145418162176 get_event poll (active: 2, waiting: 0, stalled: 0, oversubscribed: 1)</span><br><span class="line">   16:58:02.899 123145418162176 get_event current listener(0)</span><br><span class="line">③ 16:58:02.899 123145418162176 get_event become listener(active: 1, waiting: 0, stalled: 0)</span><br><span class="line">   16:58:02.899 123145418162176 get_event become listener get lock(active: 1, waiting: 0, stalled: 0)</span><br><span class="line">   16:58:02.899 123145418162176 listener #0(active: 1, waiting: 0, stalled: 0)</span><br><span class="line">④ 16:58:03.402 123145390891008 check_stall #2&gt; wake_or_create(active: 1, waiting: 0, stalled: 1)</span><br><span class="line">   16:58:03.402 123145390891008 wake or create thread</span><br><span class="line">   16:58:03.402 123145390891008 waked failed (active: 1, waiting: 0, stalled: 1)</span><br><span class="line">   16:58:03.402 123145390891008 throttle create worker #2(active: 1, waiting: 0, stalled: 1)</span><br><span class="line">   16:58:03.402 123145390891008 create worker called(active: 1, waiting: 0, stalled: 1)</span><br><span class="line">   16:58:03.402 123145419227136 worker main start (active: 2, waiting: 0, stalled: 1)</span><br><span class="line">   16:58:03.402 123145419227136 get_event start (active: 2, waiting: 0, stalled: 1)</span><br><span class="line">   16:58:03.402 123145419227136 get_event poll (active: 2, waiting: 0, stalled: 1, oversubscribed: 0)</span><br><span class="line">⑤ 16:58:03.402 123145419227136 queue_get #0 (active: 2, waiting: 0, stalled: 1, toomany: 0)</span><br><span class="line">   16:58:03.402 123145419227136 queue_get #2 (active: 2, waiting: 0, stalled: 1)</span><br><span class="line">   16:58:03.402 123145419227136 get_event connection = 8c148620(active: 2, waiting: 0, stalled: 1, oversubscribed: 0)</span><br><span class="line">   16:58:03.402 123145419227136 get_event end (active: 2, waiting: 0, stalled: 1)</span><br><span class="line">⑥ 16:58:03.402 123145419227136 command: 3: select benchmark(9999999, md5(&#x27;when will it end?&#x27;));</span><br></pre></td></tr></table></figure></div><ul><li>由于第一个 listener 线程执行了第一个任务，① 处 check stall 线程触发，尝试创建一个新的 worker。</li><li>在 ② 处，该 worker 尝试获取任务，但因为此时 active 为 2 (<code>&gt;= 1+oversubscribed=2</code>），触发了限制，因此从队列中获取不到任务。</li><li>接着 ③ 中，worker 发现当前没有 listener，于是自己成为 listener，但此时网络上没有数据，进入休眠。</li><li>④ 中 check stall 第二次唤醒，发现有 listener，但队列不为空，于是尝试唤醒或新建线程。此时没有 waiting 中的线程，于是创建新的线程。</li><li>⑤ 中新建的线程从队列中获取任务，虽然当前 oversubscribed，但由于状态是 stalled，于是不受规则 4.1 限制，而此时 (<code>active+waiting = 2 &lt;= 1+oversubscribed=2</code>)，也不受规则 4.2 限制，于是获取任务并执行。看到 ⑥ 中执行命令，此时距离接受到命令过去了 1s+，也因此整个请求是 4s+。</li></ul><p>这个例子给出两个信息：</p><ol><li>waiting 和 active 的负载对 thread group 调度来说是有差异的</li><li>由于创建线程的滞后性（由 check stall 定时线程），任务执行会有延迟，且延迟不低</li></ol><h2 id="能不能简化？"><a class="header-anchor" href="#能不能简化？"></a>能不能简化？</h2><p>Percona 实际的线程模型显然没有我们假想模型简单，那能不能简化呢？</p><p>例如为什么不用全职 listener，listener 完成不参与执行任务？这是因为直接让listener 处理任务效率更高，listener 刚从等待网络中被唤醒，不需要从再唤醒一个worker，减少线程切换。但 listener 擅离职守会造成后续任务的延时，因此 listener一方面只在当前任务队列为空时才转为 worker，另一方面有定时的check_stall 线程来保底。但如差异二中看到的，还是会造成任务执行的延时<sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup>。</p><p>再例如能不能只用一个 queue？早期的实现其实就没有区分高优低优队列，Percona 后来实现优先队列是为了缩短服务端内部的 XA 事务<sup class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup>。对表锁的高优操作也是后来才添加的。</p><p>还有为什么不在创建线程时就限制总数不能超过 oversubscribed？（以下是猜想）oversubscribe 从设计来看<strong>不应该</strong>是一个硬限制，它要达到的目的是在全局限制线程数的前提下，防止某个 thread group 疯狂创建吃掉所有限额，造成其它 group 创建不了线程的情况。但是适当允许某个 group 创建超过 oversubscribe 的线程数是有助于提高整体效率的。而且绝对限死线程数也更可能造成 group 内的死锁，保持弹性能应对更多异常的情况。</p><h2 id="参考"><a class="header-anchor" href="#参考"></a>参考</h2><ul><li><a href="https://docs.percona.com/percona-server/8.0/performance/threadpool.html">Percona thread pool</a> 对 threadpool 的行为有一些说明，但并不是很全面</li><li>添加 debug 信息的源码文件，有兴趣的可以自己编译验证<ul><li><a href="/2023/Verification-of-Percona-Thread-Pool-Behavior/threadpool_unix.cc" title="threadpool_unix.cc">threadpool_unix.cc</a> </li><li><a href="/2023/Verification-of-Percona-Thread-Pool-Behavior/sql_parse.cc" title="sql_parse.cc">sql_parse.cc</a> </li></ul></li><li><a href="https://mariadb.com/kb/en/thread-groups-in-the-unix-implementation-of-the-thread-pool/">MySQL 线程组实现文档</a> oversubscribed 实现不同，但其它的如线程创建方面可以参考</li></ul><p>另外关于 Percona 线程机制的描述一搜一大把，可以结合本文案例理解。</p><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>参考 <a href="https://github.com/percona/percona-server/blob/8.0/sql/threadpool_unix.cc#L390">connection_is_high_prio</a>中定义了各种条件。除了要满足条件，Percona 会给每个 connection 发放 N 个高优的 ticket，只有ticket 有剩余，其中的 SQL 才会认为是高优。另外除了默认的transactions 模式，还有 statement 模式，则每个 statement 都认为是高优。 <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p>参考原代码的注释 <a href="https://github.com/percona/percona-server/blob/8.0/sql/threadpool_unix.cc#L656">https://github.com/percona/percona-server/blob/8.0/sql/threadpool_unix.cc#L656</a> <a href="#fnref2" class="footnote-backref">↩</a></p></li><li id="fn3"  class="footnote-item"><p><a href="https://github.com/percona/percona-server/commit/5be2144799ced62217f252b0cb0dd9917784e868">这个 commit</a>提到目标是 “minimize the number of open transactions in the server”，结合代码看到 open transactions 指的是 XA 的事务。 <a href="#fnref3" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;h2 id=&quot;背景&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#背景&quot;&gt;&lt;/a&gt;背景&lt;/h2&gt;
&lt;p&gt;看到 plantegg 大佬的文章 &lt;a href=&quot;https://plantegg.github.io/2020/11/17/MySQL%E7%BA%</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="case study" scheme="https://lotabout.me/tags/case-study/"/>
    
    <category term="mysql" scheme="https://lotabout.me/tags/mysql/"/>
    
    <category term="percona" scheme="https://lotabout.me/tags/percona/"/>
    
    <category term="thread pool" scheme="https://lotabout.me/tags/thread-pool/"/>
    
  </entry>
  
  <entry>
    <title>TIL: 使用 einsum 进行复杂的矩阵计算</title>
    <link href="https://lotabout.me/2023/TIL-einsum/"/>
    <id>https://lotabout.me/2023/TIL-einsum/</id>
    <published>2023-05-01T09:58:04.000Z</published>
    <updated>2025-11-26T12:53:48.030Z</updated>
    
    <content type="html"><![CDATA[<p>很多复杂的矩阵计算可以使用 einsum 来表示，方便 PoC，性能也还过得去。</p><h2 id="einstein-notation"><a class="header-anchor" href="#einstein-notation"></a>Einstein notation</h2><p>你没有看错，<a href="https://en.wikipedia.org/wiki/Einstein_notation">Einstein notation</a>是那位著名的爱因斯坦发明的，用来对线性代数中求和的表示做的约定。我们还是看个例子<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>：</p><p>$$y = \sum_{i=1}^{3}{c_i x_i} = c_1 x_1 + c_2 x_2 + c_3 x_3$$</p><p>如果一个下标（如 $i$）在公式中出现两次，则隐式地认为需要遍历它的所有可能性。上面公式可以简化成：</p><p>$$y = c_i x_i$$</p><p>于是矩阵乘法中，每个输出元素可以这样表示：</p><p>$$c_{ij} = \sum_{k}{a_{ik} b_{kj}} \implies c_{ik} = a_{ik} b_{kj}$$</p><h2 id="einsum"><a class="header-anchor" href="#einsum"></a>Einsum</h2><p>在 <a href="https://numpy.org/doc/stable/reference/generated/numpy.einsum.html">Numpy</a>和 <a href="https://pytorch.org/docs/stable/generated/torch.einsum.html">Pytorch</a> 中都实现了类似的机制。<code>einsum</code> 函数的第一个参数就是把上节公式中的各个下标按<code>a,b-&gt;c</code> 的格式写下来：</p><img src="/2023/TIL-einsum/2023-05-einsum.svg" class="" title="Einsum subscript illustration"><p>代码实例如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line">In [<span class="number">6</span>]: a = np.asarray(<span class="built_in">range</span>(<span class="number">1</span>,<span class="number">9</span>)).reshape(<span class="number">2</span>,<span class="number">4</span>)</span><br><span class="line"></span><br><span class="line">In [<span class="number">7</span>]: a</span><br><span class="line">Out[<span class="number">7</span>]:</span><br><span class="line">array([[<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>, <span class="number">4</span>],</span><br><span class="line">       [<span class="number">5</span>, <span class="number">6</span>, <span class="number">7</span>, <span class="number">8</span>]])</span><br><span class="line"></span><br><span class="line">In [<span class="number">8</span>]: b = np.asarray(<span class="built_in">range</span>(<span class="number">1</span>,<span class="number">9</span>)).reshape(<span class="number">4</span>,<span class="number">2</span>)</span><br><span class="line"></span><br><span class="line">In [<span class="number">9</span>]: b</span><br><span class="line">Out[<span class="number">9</span>]:</span><br><span class="line">array([[<span class="number">1</span>, <span class="number">2</span>],</span><br><span class="line">       [<span class="number">3</span>, <span class="number">4</span>],</span><br><span class="line">       [<span class="number">5</span>, <span class="number">6</span>],</span><br><span class="line">       [<span class="number">7</span>, <span class="number">8</span>]])</span><br><span class="line"></span><br><span class="line">In [<span class="number">10</span>]: a @ b</span><br><span class="line">Out[<span class="number">10</span>]:</span><br><span class="line">array([[ <span class="number">50</span>,  <span class="number">60</span>],</span><br><span class="line">       [<span class="number">114</span>, <span class="number">140</span>]])</span><br><span class="line"></span><br><span class="line">In [<span class="number">11</span>]: np.einsum(<span class="string">&#x27;ik,kj-&gt;ij&#x27;</span>, a, b)</span><br><span class="line">Out[<span class="number">11</span>]:</span><br><span class="line">array([[ <span class="number">50</span>,  <span class="number">60</span>],</span><br><span class="line">       [<span class="number">114</span>, <span class="number">140</span>]])</span><br></pre></td></tr></table></figure></div><h2 id="复杂应用"><a class="header-anchor" href="#复杂应用"></a>复杂应用</h2><p>例如在 CNN 求卷积时，输入是 <code>(n, c, ih, iw)</code> 的矩阵，卷积权重是 <code>(C, c, h, w)</code>（这里大写字母代表输出维度）。可以把原图像按卷积大小的各个子图求出，得到 <code>(n, c, H, W, h, w)</code> 的输入矩阵，于是可以使用 einsum 直接求结果：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">conv2d</span>(<span class="params">x, weight, stride=(<span class="params"><span class="number">1</span>,<span class="number">1</span></span>), padding=(<span class="params"><span class="number">0</span>,<span class="number">0</span></span>)</span>):</span><br><span class="line">    <span class="comment"># x is a 4d matrix (n, c, ih, iw), where n is batchsize, c is input channel</span></span><br><span class="line">    <span class="comment"># weight is a 4d matrix (oc, c, h, w), where o_c is output channel</span></span><br><span class="line">    <span class="comment"># out is (n, oc, h, w)</span></span><br><span class="line">    <span class="keyword">if</span> padding != (<span class="number">0</span>,<span class="number">0</span>):</span><br><span class="line">        p_h, p_w = padding</span><br><span class="line">        x_padded = np.pad(x, ((<span class="number">0</span>,<span class="number">0</span>), (<span class="number">0</span>,<span class="number">0</span>), (p_h,p_h), (p_w, p_w)))</span><br><span class="line">    <span class="keyword">else</span></span><br><span class="line">        x_padded = x</span><br><span class="line"></span><br><span class="line">    i_n, i_c, i_h, i_w = x_padded.shape</span><br><span class="line">    s_h, s_w = stride</span><br><span class="line">    wo_c, wi_c, w_h, w_w = weight.shape</span><br><span class="line"></span><br><span class="line">    o_n = i_n</span><br><span class="line">    o_c = wo_c</span><br><span class="line">    o_h = (i_h - w_h) // s_h + <span class="number">1</span></span><br><span class="line">    o_w = (i_w - w_w) // s_w + <span class="number">1</span></span><br><span class="line"></span><br><span class="line">    view_shape = (i_n, i_c, o_h, o_w, w_h, w_w)</span><br><span class="line">    view_strides = np.array([(i_c*i_h*i_w), (i_h*i_w) , s_h*i_w, s_w, i_w, <span class="number">1</span>]) * x_padded.itemsize</span><br><span class="line">    submatrix = np.lib.stride_tricks.as_strided(x_padded, view_shape, view_strides)</span><br><span class="line">    <span class="keyword">return</span> np.einsum(<span class="string">&#x27;ncHWhw,Cchw-&gt;nCHW&#x27;</span>, submatrix, weight, optimize=<span class="literal">True</span>)</span><br></pre></td></tr></table></figure></div><p>看到 <code>ncHWhw,Cchw-&gt;nCHW</code> 的输入中，<code>c, h, w</code> 下标是重复的，按约定要遍历所有三个下标的元素相乘，要是裸写代码的话，类似下面这样：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line">n,c,H,W,h,w = submatrix.shape</span><br><span class="line">C,c,h,w = weight.shape</span><br><span class="line"></span><br><span class="line">result = <span class="number">0</span></span><br><span class="line"><span class="keyword">for</span> cc <span class="keyword">in</span> <span class="built_in">range</span>(c):</span><br><span class="line">    <span class="keyword">for</span> hh <span class="keyword">in</span> <span class="built_in">range</span>(h):</span><br><span class="line">        <span class="keyword">for</span> ww <span class="keyword">in</span> <span class="built_in">range</span>(w):</span><br><span class="line">            result += a[n, cc, H, W, hh, ww] * b[C, cc, hh, ww]</span><br><span class="line">out[n, C, H, W] = result</span><br></pre></td></tr></table></figure></div><h2 id="性能"><a class="header-anchor" href="#性能"></a>性能</h2><p>一般比裸写 for 循环是要快不少的（比如上面的卷积，比我自己裸写的快 3x~5x）。但比专门优化的肯定还是不能比的（pytorch 的 conv2d 是用 C++ 专门优化的，比相同的einsum 快 10x）。</p><p>另外 <a href="https://zhuanlan.zhihu.com/p/71639781">这篇文章</a> 建议无脑开 Numpy 中的<code>optimize</code> 参数。</p><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>wiki 中 $x_i$ 表示成 $x^i$，我们这里还是以普通人视角来看 <a href="#fnref1" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;很多复杂的矩阵计算可以使用 einsum 来表示，方便 PoC，性能也还过得去。&lt;/p&gt;
&lt;h2 id=&quot;einstein-notation&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#einstein-notation&quot;&gt;&lt;/a&gt;Einstein n</summary>
      
    
    
    
    <category term="TIL" scheme="https://lotabout.me/categories/TIL/"/>
    
    
    <category term="TIL" scheme="https://lotabout.me/tags/TIL/"/>
    
    <category term="einsum" scheme="https://lotabout.me/tags/einsum/"/>
    
    <category term="numpy" scheme="https://lotabout.me/tags/numpy/"/>
    
    <category term="torch" scheme="https://lotabout.me/tags/torch/"/>
    
  </entry>
  
  <entry>
    <title>自动微分（Automatic Differentiation）：实现篇</title>
    <link href="https://lotabout.me/2023/Auto-Differentiation-Part-2-Implementation/"/>
    <id>https://lotabout.me/2023/Auto-Differentiation-Part-2-Implementation/</id>
    <published>2023-04-16T08:43:25.000Z</published>
    <updated>2025-11-26T12:53:47.990Z</updated>
    
    <content type="html"><![CDATA[<p>前情提要：在<a href="/2023/Auto-Differentiation-Part-1-Algorithm/" title="自动微分（Automatic Differentiation）：算法篇">算法篇</a>中，我们介绍了深度学习领域基本都是使用自动微分(Automatic Differentiation)来计算偏导的。本篇中我们要尝试自己做一个实现。</p><h2 id="目标"><a class="header-anchor" href="#目标"></a>目标</h2><p>如果有函数 $f(x_1, \cdots, x_n)$，我们要使用链式法则计算函数 $f$ 对所有输入$x_i$ 的偏导。我们记中间函数为 $v$，记 $\bar{v_i} = \frac{\partial f}{\partialv_i}$，则最核心的计算公式为：</p><p>$$\bar{v_i} = \frac{\partial f}{\partial v_i}= \sum_{j \in next(i)}{\overline{v_{i\to j}}}= \sum_{j \in next(i)}{\frac{\partial f}{\partial v_{j}} \frac{\partial v_{j}}{\partial v_i}= \sum_{j \in next(i)}{\overline{v_j} \frac{\partial v_{j}}{\partial v_i}}}$$</p><p>大家可以配合算法篇的图来理解：</p><img src="/2023/Auto-Differentiation-Part-2-Implementation/2023-04-AD-example-computation-graph.svg" class=""><h2 id="整体思路"><a class="header-anchor" href="#整体思路"></a>整体思路</h2><p>首先需要允许用户构建计算图，很自然地关心 3 个部分：</p><ol><li>节点。计算图中的节点代表了计算，如 <code>sin</code> 这样的函数，我们把它叫作算子(operator)。在 AD 的场景下，算子既要关心前向计算，也需要关心后向求导</li><li>边。需要有办法找到算子的上下游，在 AD 中我们使用邻接表来表示<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></li><li>边上流转的数据。边上流转的有正向的计算数据，逆向的偏导数据，我们会统一用张量(Tensor)来表示</li></ol><p>要注意的是为了符合用户的使用习惯，我们并不是要求用户直接给出一个“节点” List，再给出一个“边” List。计算图是隐式构建的。因此实际上是 <code>数据 --(来源)--&gt; 节点 --(输入)--&gt; 数据</code> 这样的引用关系。</p><p>计算图构建好之后，需要有遍历引擎，按拓扑排序顺序，正向地、逆向地遍历所有节点，正向计算输出，逆向计算偏导。这里的执行引擎其实有很多可以优化的空间，比如多线程计算，多节点合并计算等，但本文里就是简单地走流程。</p><p>最终希望怎么使用呢？</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line">x1 = Tensor(np.array([<span class="number">0.5</span>]), requires_grad=<span class="literal">True</span>)</span><br><span class="line">x2 = Tensor(np.array([<span class="number">0.5</span>]), requires_grad=<span class="literal">True</span>)</span><br><span class="line">v3 = sin(x1)</span><br><span class="line">v4 = mul(x1, x2)</span><br><span class="line">v5 = add(v3, v4)</span><br><span class="line">grad = np.array([<span class="number">1</span>])</span><br><span class="line">v5.backward(grad)</span><br><span class="line"><span class="built_in">print</span>(x1.grad) <span class="comment"># expected to be 1.37758</span></span><br><span class="line"><span class="built_in">print</span>(x2.grad) <span class="comment"># expected to be 0.5</span></span><br></pre></td></tr></table></figure></div><h2 id="框架实现"><a class="header-anchor" href="#框架实现"></a>框架实现</h2><h3 id="tensor"><a class="header-anchor" href="#tensor"></a>Tensor</h3><p>我们用张量 <code>Tensor</code> 来定义数据部分。代码如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">Tensor</span>(<span class="title class_ inherited__">object</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;tensor&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, ndarray: NDArray, requires_grad=<span class="literal">False</span>, grad_fn=<span class="literal">None</span></span>):</span><br><span class="line">        <span class="built_in">super</span>(Tensor, <span class="variable language_">self</span>).__init__()</span><br><span class="line">        <span class="variable language_">self</span>.ndarray = ndarray            <span class="comment"># ①</span></span><br><span class="line">        <span class="variable language_">self</span>.requires_grad = requires_grad  <span class="comment"># ②</span></span><br><span class="line">        <span class="variable language_">self</span>.grad_fn = grad_fn            <span class="comment"># ③</span></span><br><span class="line">        <span class="variable language_">self</span>.grad = <span class="literal">None</span>                  <span class="comment"># ④</span></span><br><span class="line">        <span class="variable language_">self</span>._grad_accmulator = <span class="literal">None</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">is_leaf</span>(<span class="params">self</span>) -&gt; <span class="built_in">bool</span>:</span><br><span class="line">        <span class="keyword">return</span> <span class="variable language_">self</span>.requires_grad <span class="keyword">and</span> <span class="variable language_">self</span>.grad_fn <span class="keyword">is</span> <span class="literal">None</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">backward</span>(<span class="params">self, output_grad</span>):</span><br><span class="line">        <span class="keyword">if</span> <span class="variable language_">self</span>.grad_fn <span class="keyword">is</span> <span class="literal">None</span>:</span><br><span class="line">            <span class="keyword">raise</span> <span class="string">&quot;backward could not be called if grad_fn is None&quot;</span></span><br><span class="line">        execute_graph(<span class="variable language_">self</span>.grad_fn, output_grad)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__str__</span>(<span class="params">self</span>):</span><br><span class="line">        grad_info = <span class="string">f&#x27; grad_fn=<span class="subst">&#123;self.grad_fn&#125;</span>&#x27;</span> <span class="keyword">if</span> <span class="variable language_">self</span>.grad_fn <span class="keyword">is</span> <span class="keyword">not</span> <span class="literal">None</span> <span class="keyword">else</span> <span class="string">&#x27;&#x27;</span></span><br><span class="line">        <span class="keyword">return</span> <span class="string">f&#x27;tensor(<span class="subst">&#123;self.ndarray&#125;</span><span class="subst">&#123;grad_info&#125;</span>)&#x27;</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__repr__</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="keyword">return</span> <span class="variable language_">self</span>.__str__()</span><br></pre></td></tr></table></figure></div><p>① 中使用 <code>numpy.ndarray</code> 保存前向数据，直接使用 numpy 来减少复杂度，毕竟我们只关心 AD 部分</p><p>③ 的 <code>grad_fn</code> 可以理解成保存的是 <code>Tensor</code> 的来源算子。实际上当 Tensor 生成时，对应的数据就计算完成了，记录它的来源也没有意义，但由于后续还要反向计算偏导，才需要记录来源来反查。因此只有在 ② <code>requires_grad = True</code> 时才有记录的必要。</p><p>④ 的 <code>grad</code> 就是偏导的结果，即 $\bar{v_i}$ 的值。</p><h3 id="operator"><a class="header-anchor" href="#operator"></a>Operator</h3><p>首先算子既需要管前向计算，也需要关心后向求导，于是框架性的定义如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="comment"># 注意 Operator 里计算的都是 Tensor 内部的数据，即 NDArray</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">Operator</span>(<span class="title class_ inherited__">object</span>):</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="built_in">super</span>(Operator, <span class="variable language_">self</span>).__init__()</span><br><span class="line">        <span class="variable language_">self</span>.next_ops = [] <span class="comment"># ①</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">forward</span>(<span class="params">self, *args: <span class="type">Tuple</span>[NDArray]</span>) -&gt; NDArray:</span><br><span class="line">        <span class="keyword">raise</span> NotImplementedError(<span class="string">&quot;Should be override by subclass&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">backward</span>(<span class="params">self, output_grad: <span class="type">Tuple</span>[NDArray]</span>) -&gt; <span class="type">Union</span>[NDArray, <span class="type">Tuple</span>[NDArray]]:</span><br><span class="line">        <span class="keyword">raise</span> NotImplementedError(<span class="string">&quot;Should be override by subclass&quot;</span>)</span><br></pre></td></tr></table></figure></div><p><code>forward</code> 代表前向计算，可以有多个输入。<code>backward</code> 则相反，给定输出的偏导，需要为每个输入输出一个偏导。即如果 $op = f(x, y)$，则 <code>forward</code> 输出的是$f(x, y)$ 的值，而 <code>backward</code> 输出为 $[\frac{\partial op}{\partial x}, \frac{\partial op}{\partial y}]$</p><p>但仅有两个计算方法是不够的，在 <code>forward</code> 计算时，算子还需要维护“边”的信息，在后向计算偏导时使用。①中的 <code>next_ops</code> 就是用来计算边的信息的，例如样例代码中，执行完 <code>v5 = add(v3, v4)</code> 后，内部信息如下图：</p><img src="/2023/Auto-Differentiation-Part-2-Implementation/2023-04-AD-Op-Graph.svg" class=""><p>但我们不希望建图的操作在每个算子中都实现一遍，因此我们在父类上实现 <code>__call__</code>函数，在使用时用户不应该直接调用 <code>forward</code> 函数，而应该直接调用 <code>__call__</code> 函数，实现如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">__call__</span>(<span class="params">self, *args: <span class="type">Tuple</span>[Tensor]</span>) -&gt; Tensor:</span><br><span class="line">    grad_fn = <span class="literal">None</span></span><br><span class="line">    requires_grad = <span class="built_in">any</span>((t.requires_grad <span class="keyword">for</span> t <span class="keyword">in</span> args)) <span class="comment"># ①</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> requires_grad:</span><br><span class="line">        <span class="comment"># add edges</span></span><br><span class="line">        <span class="keyword">for</span> <span class="built_in">input</span> <span class="keyword">in</span> args:</span><br><span class="line">            <span class="keyword">if</span> <span class="built_in">input</span>.is_leaf(): <span class="comment"># ②</span></span><br><span class="line">                <span class="keyword">if</span> <span class="built_in">input</span>._grad_accmulator <span class="keyword">is</span> <span class="literal">None</span>:</span><br><span class="line">                    <span class="built_in">input</span>._grad_accmulator = OpAccumulate(<span class="built_in">input</span>)</span><br><span class="line">                <span class="variable language_">self</span>.next_ops.append(<span class="built_in">input</span>._grad_accmulator)</span><br><span class="line">            <span class="keyword">else</span>:</span><br><span class="line">                <span class="variable language_">self</span>.next_ops.append(<span class="built_in">input</span>.grad_fn) <span class="comment"># ③</span></span><br><span class="line">        grad_fn = <span class="variable language_">self</span></span><br><span class="line"></span><br><span class="line">    inputs = [t.ndarray <span class="keyword">for</span> t <span class="keyword">in</span> args]</span><br><span class="line">    output = <span class="variable language_">self</span>.forward(*inputs) <span class="comment"># ④</span></span><br><span class="line">    <span class="keyword">return</span> Tensor(output, requires_grad=requires_grad, grad_fn=grad_fn) <span class="comment"># ⑤</span></span><br></pre></td></tr></table></figure></div><p>其中 ① 会将输入 Tensor 的 <code>requires_grad</code> 值传染给输出，算子任意输入 Tensor 中，只要有一个需要算梯度，则输出的 Tensor 也需要计算梯度。另外④中可以看出<code>__call__</code> 就是 <code>forward</code> 方法的包装。注意到 <code>forward</code> 的输出是 ndarray，而因为算子输出也需要是 Tensor，因此在 ⑤ 中做了封装。</p><p>在构造计算图时，会将 <code>input.grad_fn</code> 指向的算子，加入 <code>next_ops</code> 中，如 ③ 所示。只有②的例外，如果输入本身就是叶子节点，则它的 <code>grad_fn</code> 没有指向任何节点，因此这里构造了一个特殊的 <code>OpAccumulate</code> 算子来累加并设置梯度，如下所示：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">OpAccumulate</span>(<span class="title class_ inherited__">Operator</span>):</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, tensor</span>):</span><br><span class="line">        <span class="built_in">super</span>(OpAccumulate, <span class="variable language_">self</span>).__init__()</span><br><span class="line">        <span class="variable language_">self</span>.tensor = tensor</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">backward</span>(<span class="params">self, grad</span>):</span><br><span class="line">        <span class="variable language_">self</span>.tensor.grad = Tensor(grad)</span><br><span class="line">        <span class="keyword">return</span> grad</span><br></pre></td></tr></table></figure></div><h3 id="计算图遍历"><a class="header-anchor" href="#计算图遍历"></a>计算图遍历</h3><p>计算图是一个有向无环图（简称 DAG），DAG 遍历的重点是需要按拓扑排序遍历，在一个算子的所有输入都被满足时才能执行该算子的 <code>backward</code> 方法。于是我们先搞个辅助函数，按拓扑的顺序，统计每个算子依赖的输入个数。</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">compute_dependencies</span>(<span class="params">root</span>):</span><br><span class="line">    <span class="comment"># deps: &#123;op: num&#125;</span></span><br><span class="line">    deps = &#123;&#125;</span><br><span class="line">    q = deque()</span><br><span class="line">    traversed = &#123;root&#125;</span><br><span class="line">    q.append(root)</span><br><span class="line">    <span class="keyword">while</span> <span class="built_in">len</span>(q) != <span class="number">0</span>:</span><br><span class="line">        cur = q.pop()</span><br><span class="line">        <span class="keyword">if</span> <span class="built_in">len</span>(cur.next_ops) == <span class="number">0</span>:</span><br><span class="line">            <span class="keyword">continue</span></span><br><span class="line">        <span class="keyword">for</span> <span class="built_in">next</span> <span class="keyword">in</span> cur.next_ops:</span><br><span class="line">            deps[<span class="built_in">next</span>] = deps.get(<span class="built_in">next</span>, <span class="number">0</span>) + <span class="number">1</span></span><br><span class="line">            <span class="keyword">if</span> <span class="built_in">next</span> <span class="keyword">not</span> <span class="keyword">in</span> traversed:</span><br><span class="line">                q.append(<span class="built_in">next</span>)</span><br><span class="line">                traversed.add(<span class="built_in">next</span>)</span><br><span class="line">    <span class="keyword">return</span> deps</span><br></pre></td></tr></table></figure></div><p>在样例代码里，最终会以 <code>root = op:+</code> 来调用，因此它会返回类似如下信息（当然key 会是各个实例化的算子，而不是字符串）：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  &quot;op:+&quot;: 1,</span><br><span class="line">  &quot;op:sin&quot;: 1,</span><br><span class="line">  &quot;op:*&quot;: 1,</span><br><span class="line">  &quot;op:acc|x1&quot;: 2,</span><br><span class="line">  &quot;op:acc|x2&quot;: 1,</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>接下来我们会遍历整个图：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">execute_graph</span>(<span class="params">root, output_grad</span>):</span><br><span class="line">    deps = compute_dependencies(root)</span><br><span class="line">    inputs = &#123;root: output_grad&#125;  <span class="comment"># ①</span></span><br><span class="line"></span><br><span class="line">    q = deque()</span><br><span class="line">    q.append(root)</span><br><span class="line">    <span class="keyword">while</span> <span class="built_in">len</span>(q) != <span class="number">0</span>:</span><br><span class="line">        task = q.pop()</span><br><span class="line">        <span class="built_in">input</span> = inputs[task]</span><br><span class="line">        outputs = task.backward(<span class="built_in">input</span>)</span><br><span class="line">        <span class="keyword">if</span> <span class="keyword">not</span> <span class="built_in">isinstance</span>(outputs, collections.abc.<span class="type">Sequence</span>):</span><br><span class="line">            outputs = [outputs]</span><br><span class="line"></span><br><span class="line">        <span class="keyword">for</span> next_op, output <span class="keyword">in</span> <span class="built_in">zip</span>(task.next_ops, outputs):</span><br><span class="line">            <span class="keyword">if</span> next_op <span class="keyword">is</span> <span class="literal">None</span>:</span><br><span class="line">                <span class="keyword">continue</span></span><br><span class="line"></span><br><span class="line">            <span class="comment"># accumulate the &quot;inputs&quot; for next_op # ②</span></span><br><span class="line">            op_input = inputs.get(next_op, <span class="number">0</span>)</span><br><span class="line">            inputs[next_op] = op_input + output</span><br><span class="line"></span><br><span class="line">            deps[next_op] -= <span class="number">1</span></span><br><span class="line">            <span class="keyword">if</span> deps[next_op] == <span class="number">0</span>: <span class="comment"># ③</span></span><br><span class="line">                q.append(next_op)</span><br></pre></td></tr></table></figure></div><p>这个遍历过程可说的内容也不多，就是将 ready 的算子一个个放进队列 <code>q</code> 中，一个个执行它们的 <code>backward</code> 方法。其中比较关键的是，如果算子 <code>backward</code> 的输入如果有多个，则需要在 ① 中缓存部分输入，并且在 ② 中当新的输入到来需要进行累加，这里对应了开头公式 $\bar{v_i} = \sum_{j \in next(i)}{\overline{v_{i\to j}}}$ 的部分。最后在 ③ 中，要确保目标算子的所有输入都计算完成，才认为目标算子 ready 了。</p><p>如此，所有“框架”层面的内容均实现完毕。</p><h2 id="具体算子"><a class="header-anchor" href="#具体算子"></a>具体算子</h2><p>有了框架还不够，还需要实现算子，而实现算子最关键的是可能需要在 <code>forward</code> 过程中记录输入信息，在 <code>backward</code> 中用来计算偏导。例如文章开头的样例中 $\bar{v_2}= \bar{v_4} v_1$ 就需要在 <code>forward</code> 时记录 $v_1$ 的值。下面补齐示例中需要的几个算子</p><p>另外注意下面的代码中除了实现算子，我们还实现了诸如 <code>add, mul</code> 等函数，方便对Tensor 构建计算图。</p><h3 id="元素加法"><a class="header-anchor" href="#元素加法"></a>元素加法</h3><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">OpEWiseAdd</span>(<span class="title class_ inherited__">Operator</span>):</span><br><span class="line">    <span class="comment"># func: y = a + b</span></span><br><span class="line">    <span class="comment"># deri: y&#x27;/a&#x27; = 1</span></span><br><span class="line">    <span class="comment"># deri: y&#x27;/b&#x27; = 1</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">forward</span>(<span class="params">self, a: NDArray, b: NDArray</span>):</span><br><span class="line">        <span class="keyword">return</span> a + b</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">backward</span>(<span class="params">self, grad: NDArray</span>):</span><br><span class="line">        ret = grad, grad</span><br><span class="line">        <span class="keyword">return</span> ret</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">add</span>(<span class="params">a, b</span>):</span><br><span class="line">    <span class="keyword">return</span> OpEWiseAdd()(a, b)</span><br></pre></td></tr></table></figure></div><h3 id="元素乘法"><a class="header-anchor" href="#元素乘法"></a>元素乘法</h3><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">OpEWiseMul</span>(<span class="title class_ inherited__">Operator</span>):</span><br><span class="line">    <span class="comment"># func: y = a * b</span></span><br><span class="line">    <span class="comment"># deri: y&#x27;/a&#x27; = b</span></span><br><span class="line">    <span class="comment"># deri: y&#x27;/b&#x27; = a</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">forward</span>(<span class="params">self, a: NDArray, b: NDArray</span>):</span><br><span class="line">        <span class="variable language_">self</span>.a = a</span><br><span class="line">        <span class="variable language_">self</span>.b = b</span><br><span class="line">        <span class="keyword">return</span> a * b</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">backward</span>(<span class="params">self, grad: NDArray</span>):</span><br><span class="line">        <span class="keyword">return</span> <span class="variable language_">self</span>.b * grad, <span class="variable language_">self</span>.a * grad</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">mul</span>(<span class="params">a, b</span>):</span><br><span class="line">    <span class="keyword">return</span> OpEWiseMul()(a, b)</span><br></pre></td></tr></table></figure></div><h3 id="sin"><a class="header-anchor" href="#sin"></a>sin</h3><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">OpSin</span>(<span class="title class_ inherited__">Operator</span>):</span><br><span class="line">    <span class="comment"># func: y = sin(x)</span></span><br><span class="line">    <span class="comment"># deri: y&#x27;/x&#x27; = cos(x)</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">forward</span>(<span class="params">self, x: NDArray</span>):</span><br><span class="line">        <span class="variable language_">self</span>.x = x</span><br><span class="line">        <span class="keyword">return</span> np.sin(x)</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">backward</span>(<span class="params">self, grad: NDArray</span>):</span><br><span class="line">        ret = np.cos(<span class="variable language_">self</span>.x) * grad</span><br><span class="line">        <span class="keyword">return</span> ret</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">sin</span>(<span class="params">x</span>):</span><br><span class="line">    <span class="keyword">return</span> OpSin()(x)</span><br></pre></td></tr></table></figure></div><h2 id="向量与实验"><a class="header-anchor" href="#向量与实验"></a>向量与实验</h2><h3 id="样例实跑"><a class="header-anchor" href="#样例实跑"></a>样例实跑</h3><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">&gt;&gt;&gt; x1 = Tensor(np.array([0.5]), requires_grad=True)</span><br><span class="line">&gt;&gt;&gt; x2 = Tensor(np.array([0.5]), requires_grad=True)</span><br><span class="line">&gt;&gt;&gt; v3 = sin(x1)</span><br><span class="line">&gt;&gt;&gt; v4 = mul(x1, x2)</span><br><span class="line">&gt;&gt;&gt; v5 = add(v3, v4)</span><br><span class="line">&gt;&gt;&gt; grad = np.array([1])</span><br><span class="line">&gt;&gt;&gt; v5.backward(grad)</span><br><span class="line">&gt;&gt;&gt; print(x1.grad)</span><br><span class="line">tensor([1.37758256])</span><br><span class="line">&gt;&gt;&gt; print(x2.grad)</span><br><span class="line">tensor([0.5])</span><br></pre></td></tr></table></figure></div><p>大家可以算算，跟公式算出来是一样的</p><h3 id="扩展到向量"><a class="header-anchor" href="#扩展到向量"></a>扩展到向量</h3><p>如果 $x_1, x_2$ 是向量呢？这里关系到向量的求导到底要怎么算，但整体来说，咱们实现的框架还是成立的。例如上面例子中的 <code>+, *, sin</code>，如果都只考虑是按元素的操作（不涉及矩阵乘法），则上面的算子定义依旧适用，下面我们对应在 Pytorch 运行的结果和我们刚实现的框架的结果：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="comment">#------------------- torch -------------------------|====================== Ours ========================</span></span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span><span class="keyword">import</span> torch</span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span>x1 = torch.tensor([<span class="number">0.0140</span>, <span class="number">0.5773</span>, <span class="number">0.0469</span>],      &gt;&gt;&gt; x1 = Tensor(np.array([<span class="number">0.0140</span>, <span class="number">0.5773</span>, <span class="number">0.0469</span>]),</span><br><span class="line">        requires_grad=<span class="literal">True</span>)                                  requires_grad=<span class="literal">True</span>)</span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span>x2 = torch.tensor([<span class="number">0.3232</span>, <span class="number">0.4903</span>, <span class="number">0.9395</span>],      &gt;&gt;&gt; x2 = Tensor(np.array([<span class="number">0.3232</span>, <span class="number">0.4903</span>, <span class="number">0.9395</span>]),</span><br><span class="line">        requires_grad=<span class="literal">True</span>)                                  requires_grad=<span class="literal">True</span>)</span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span>v3 = torch.sin(x1)                               &gt;&gt;&gt; v3 = sin(x1)</span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span>v4 = torch.mul(x1, x2)                           &gt;&gt;&gt; v4 = mul(x1, x2)</span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span>v5 = torch.add(v3, v4)                           &gt;&gt;&gt; v5 = add(v3, v4)</span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span>grad = torch.tensor([<span class="number">0.4948</span>, <span class="number">0.8746</span>, <span class="number">0.7076</span>])    &gt;&gt;&gt; grad = np.array([<span class="number">0.4948</span>, <span class="number">0.8746</span>, <span class="number">0.7076</span>])</span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span>v5.backward(grad)                                &gt;&gt;&gt; v5.backward(grad)</span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span><span class="built_in">print</span>(x1.grad)                                   &gt;&gt;&gt; <span class="built_in">print</span>(x1.grad)</span><br><span class="line">tensor([<span class="number">0.6547</span>, <span class="number">1.1617</span>, <span class="number">1.3716</span>])                     tensor([<span class="number">0.65467087</span> <span class="number">1.16167806</span> <span class="number">1.37161212</span>])</span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span><span class="built_in">print</span>(x2.grad)                                   &gt;&gt;&gt; <span class="built_in">print</span>(x2.grad)</span><br><span class="line">tensor([<span class="number">0.0069</span>, <span class="number">0.5049</span>, <span class="number">0.0332</span>])                     tensor([<span class="number">0.0069272</span>  <span class="number">0.50490658</span> <span class="number">0.03318644</span>])</span><br></pre></td></tr></table></figure></div><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>本文中我们实现了一个自动微分（Automatic Differentiation）的框架。主要是 Tensor、Operator 的定义，以及后向计算的引擎。</p><p>整体的实现和 PyTorch 的实现是比较类似的，但为了示例简单也做了些取舍。如Pytorch 中 <code>Operator</code> 的第一个参数是 <code>ctx</code>，也鼓励算子把信息记录在 <code>ctx</code> 中，但我们是直接用 <code>self.x</code> 来记录；再如 PyTorch 中在计算结束后会把计算图销毁，我们没有做；再有 PyTorch 在 Tensor 中重载了一些基本操作（如 <code>+ - * /</code>），方便操作，但我们直接额外定义了 <code>add, mul</code> 等函数。等等等等。</p><p>总的来说，希望通过 AD 的简单实现，让大家认识到机器学习背后的一些原理，实际上也并没有特别复杂。当然我们也要认识到，能 Work 距离能在工业上使用，中间还隔了个太平洋。</p><h2 id="参考"><a class="header-anchor" href="#参考"></a>参考</h2><ul><li>CMU 的课程 Deep Learning Systems: Algorithms and Implementation<ul><li><a href="https://www.youtube.com/watch?v=56WUlMEeAuA">Lecture 4 - Automatic Differentiation</a> AD 算法讲解</li><li><a href="https://www.youtube.com/watch?v=cNADlHfHQHg">Lecture 5 - Automatic Differentiation Implementation</a> AD 算法实现，着重讲解了诸如 Tensor，Operator 部分，图遍历的部分留做作业了。</li><li><a href="https://github.com/dlsyscourse/lecture4/blob/main/5_automatic_differentiation_implementation.ipynb">5_automatic_differentiation_implementation.ipynb</a> Lecture 5 的部分代码</li></ul></li><li><a href="https://www.52coding.com.cn/2019/05/05/PyTorch4/">PyTorch源码浅析(4)：Autograd</a> PyTorch 源码解析，版本比较老，但整体逻辑依旧适用</li></ul><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>除了邻接表外还有邻接矩阵的表示方法，参考<a href="https://en.wikipedia.org/wiki/Graph_theory#Tabular:_Graph_data_structures">维基百科</a> <a href="#fnref1" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;前情提要：在&lt;a href=&quot;/2023/Auto-Differentiation-Part-1-Algorithm/&quot; title=&quot;自动微分（Automatic Differentiation）：算法篇&quot;&gt;算法篇&lt;/a&gt;中，我们介绍了深度学习领域基本都是使用自动微分(A</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="Automatic Differentiation" scheme="https://lotabout.me/tags/Automatic-Differentiation/"/>
    
    <category term="Neural Network" scheme="https://lotabout.me/tags/Neural-Network/"/>
    
  </entry>
  
  <entry>
    <title>深度学习中的矩阵运算</title>
    <link href="https://lotabout.me/2023/Matrix-Operator-in-Neural-Network/"/>
    <id>https://lotabout.me/2023/Matrix-Operator-in-Neural-Network/</id>
    <published>2023-04-15T07:46:48.000Z</published>
    <updated>2025-11-26T12:53:48.021Z</updated>
    
    <content type="html"><![CDATA[<p>作为数学学渣，最近复习深度学习中的一些矩阵运算，做一些推导并记录如下。</p><div style="display: none">$$\require{color}\require{unicode}\definecolor{blue}{rgb}{0.16, 0.32, 0.75}\definecolor{red}{rgb}{0.9, 0.17, 0.31}$$</div><h2 id="点积-dot-product"><a class="header-anchor" href="#点积-dot-product"></a>点积(dot product)</h2><h3 id="向量点积"><a class="header-anchor" href="#向量点积"></a>向量点积</h3><p>Dot product 运算仅定义在两个向量上，输出一个标量，也称为 “scalar product”。</p><p>坐标定义：假设有两个向量$\color {red}{\mathbf {a} =[a_{1},a_{2},\cdots ,a_{n}]}$ 和$\color {blue}{\mathbf {b} =[b_{1},b_{2},\cdots ,b_{n}]}$ <sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>，则 dot product 定义为：</p><p>$$\mathbf {\color {red}{a}} \cdot \mathbf {\color {blue}{b}}= \sum_{i=1}^{n}{\color {red}{a}_{i} \color {blue}{b}_{i}}={\color {red} a_1}{\color {blue}b_1}+{\color {red}a_2}{\color {blue}b_2}+\cdots +{\color {red}a_n}{\color {blue}b_n}$$</p><p>还可以把 dot product 理解成是矩阵的线性变换，写成矩阵乘法，此时$\color{red}{\mathbf{a}}$ 与 $\color{blue}{\mathbf{b}}$ 都是列向量，定义如下：</p><p>$$\mathbf {\color {red}{a}} \cdot \mathbf {\color {blue}{b}}= \begin{bmatrix}{\color{red} a_1} \\\vdots \\ {\color{red}a_n} \end{bmatrix}\cdot\begin{bmatrix}{\color{blue}b_1} \\\vdots \\ {\color{blue}b_n} \end{bmatrix}= {\color {red} a_1}{\color {blue}b_1}+{\color {red}a_2}{\color {blue}b_2}+\cdots +{\color {red}a_n}{\color {blue}b_n}= \begin{bmatrix}{\color{red}a_1} &amp;\cdots &amp; {\color{red}a_n}\end{bmatrix}\begin{bmatrix}{\color{blue}b_1} \\\vdots \\ {\color{blue}b_n} \end{bmatrix}= \mathbf {\color {red}{a}} ^T \mathbf {\color {blue}{b}}$$</p><h3 id="矩阵与矩阵点积"><a class="header-anchor" href="#矩阵与矩阵点积"></a>矩阵与矩阵点积</h3><p>严格来说，点积的输入只能是两个向量，不存在矩阵和矩阵，矩阵和向量的点积，但矩阵计算方便，人们扩充了定义。先看矩阵和矩阵，可以认为矩阵就是列向量的集合，因此点积就是列向量分别做点积<sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup>。</p><p>$${\color{red}\mathbf {A}} \cdot {\color{blue}\mathbf {B}}= \begin{bmatrix}{\color{red}\mathbf{a}_1} &amp; \cdots &amp; {\color{red}\mathbf{a}_n}\end{bmatrix}\cdot\begin{bmatrix}{\color{blue}\mathbf{b}_1} &amp;\cdots &amp; {\color{blue}\mathbf{b}_n}\end{bmatrix}= \begin{bmatrix}{\color{red}\mathbf{a}_1} \cdot {\color{blue}\mathbf{b}_1}&amp; \cdots&amp; {\color{red}\mathbf{a}_n} \cdot {\color{blue}\mathbf{b}_n}\end{bmatrix}$$</p><p>上式中的 $\mathbf{a}_i, \mathbf{b}_i$ 都是列向量。另外注意由于 $\mathbf{a}_i\cdot \mathbf{b}_i$ 的结果是标量，所以最终的结果是一个行向量。</p><h3 id="矩阵与向量点积"><a class="header-anchor" href="#矩阵与向量点积"></a>矩阵与向量点积</h3><p>矩阵和向量的点积本质上是将向量扩充再当成矩阵和矩阵的点积，定义如下：</p><p>$${\color{red}\mathbf {A}_{n \times m}} \cdot {\color{blue}\mathbf {v}}= \begin{bmatrix}{\color{red}\mathbf{a}_1} &amp; \cdots &amp; {\color{red}\mathbf{a}_m}\end{bmatrix}\cdot\begin{bmatrix}{\color{blue}\mathbf{v}} &amp;\cdots &amp; {\color{blue}\mathbf{v}}\end{bmatrix}= \begin{bmatrix}{\color{red}\mathbf{a}_1} \cdot {\color{blue}\mathbf{v}}&amp; \cdots&amp; {\color{red}\mathbf{a}_m} \cdot {\color{blue}\mathbf{v}}\end{bmatrix}$$</p><p>此时结果为行向量。考虑到向量的点积也可以写成矩阵乘法的形式${\color{red}\mathbf{a}} \cdot {\color{blue}\mathbf{b}} = {\color{red}\mathbf{a}^T} {\color{blue}\mathbf{b}}$，因此有</p><p>$$\begin{align}{\color{red}\mathbf {A}_{n \times m}} \cdot {\color{blue}\mathbf {v}}&amp;= \begin{bmatrix}{\color{red}\mathbf{a}_1} \cdot {\color{blue}\mathbf{v}}&amp; \cdots&amp; {\color{red}\mathbf{a}_m} \cdot {\color{blue}\mathbf{v}}\end{bmatrix}= \begin{bmatrix}{\color{red}\mathbf{a}_1^T} {\color{blue}\mathbf{v}}&amp; \cdots&amp; {\color{red}\mathbf{a}_m^T} {\color{blue}\mathbf{v}}\end{bmatrix}\\({\color{red}\mathbf {A}} \cdot {\color{blue}\mathbf {v}})^T&amp;= \begin{bmatrix}{\color{red}\mathbf{a}_1^T} {\color{blue}\mathbf{v}}\\ \cdots\\ {\color{red}\mathbf{a}_m^T} {\color{blue}\mathbf{v}}\end{bmatrix}=\begin{bmatrix}{\color{red}\mathbf{a}_1^T} \\ \vdots \\ {\color{red}\mathbf{a}_m^T}\end{bmatrix} {\color{blue}\mathbf{v}}= {\color{red}(\mathbf{A}^T)_{m \times n}}{\color{blue}\mathbf{v}_{n \times 1}}\\{\color{red}\mathbf {A}} \cdot {\color{blue}\mathbf {v}}&amp;= \big({\color{red}\mathbf{A}^T}{\color{blue}\mathbf{v}}\big)^T= {\color{blue}\mathbf{v}^T}{\color{red}\mathbf{A}}\end{align}$$</p><p>当然，上述式子中，我们严格按数学上的定义：向量就是“列”向量。这个假设不太方便，因为输入 $\mathbf{x}$ 是列向量，但输出 $\mathbf{A} \cdot \mathbf{x}$ 却是行向量。但实际上为了方便，我们也可以按“列”来排列输出结果，例如在深度学习中，单个输出 $y_i = \mathbf{w_i} \cdot \mathbf{x} + b$，则结果列向量：</p><p>$$\mathbf{y}_{m \times 1}= \begin{bmatrix} y_1 \\ y_2 \\ \vdots \\ y_m \end{bmatrix}= \begin{bmatrix}\mathbf{w_1} \cdot \mathbf{x} + b \\\mathbf{w_2} \cdot \mathbf{x} + b \\\vdots \\\mathbf{w_m} \cdot \mathbf{x} + b\end{bmatrix}= \begin{bmatrix}\mathbf{w_1}^T \mathbf{x} + b \\\mathbf{w_2}^T \mathbf{x} + b \\\vdots \\\mathbf{w_m}^T \mathbf{x} + b\end{bmatrix}= \begin{bmatrix}\mathbf{w_1}^T \\\mathbf{w_2}^T \\\vdots \\\mathbf{w_m}^T\end{bmatrix} \mathbf{x} + b= \mathbf{W}^T \mathbf{x} + b= (\mathbf{W}^T)_{m \times n} \mathbf{x}_{n \times 1} + b$$</p><p>因此不管是按行还是按列切片，关键在于点积 dot product 可以转换成矩阵乘法的形式。</p><h2 id="矩阵乘法"><a class="header-anchor" href="#矩阵乘法"></a>矩阵乘法</h2><h3 id="矩阵与向量相乘"><a class="header-anchor" href="#矩阵与向量相乘"></a>矩阵与向量相乘</h3><p>考虑一个矩阵 $\mathbf{A} \in \mathbb{R}^{m \times n}$ 和向量 $\mathbf{x} \in \mathbb{R}^n$，矩阵和向量的乘法定义为<sup class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup>：</p><p>$$\mathbf{A}\mathbf{x} = x_1 \mathbf{a}_{*,1} + x_2 \mathbf{a}_{*,2} + \cdots + x_n \mathbf{a}_{*,n}$$</p><p>其中 $\mathbf{a}_{*, i}$ 代表矩阵 $\mathbf{A}$ 的第 $i$ 个列向量。</p><p>矩阵乘法有几种不同的理解方式，其中一种理解方式是“线性变换”<sup class="footnote-ref"><a href="#fn4" id="fnref4">[4]</a></sup>，即向量 $\mathbf{x}$ 所在空间的基坐标，分别经过变换后，$\mathbf{x}$ 所在的坐标。因此，跟上述的定义基本一致：</p><p>$$\mathbf{A}{\color{brown}\mathbf{x}} =\begin{bmatrix}{\color {red} a_{11}} &amp; {\color {blue} a_{12}} &amp; \cdots &amp; {\color {green}a_{1n}} \\{\color {red} a_{21}} &amp; {\color {blue} a_{22}} &amp; \cdots &amp; {\color {green}a_{2n}} \\\vdots &amp; \vdots &amp; \ddots &amp; \vdots \\{\color {red} a_{m1}} &amp; {\color {blue} a_{m2}} &amp; \cdots &amp; {\color {green}a_{mn}}\end{bmatrix}\begin{bmatrix}{\color {brown} x_1} \\ {\color {brown} x_2} \\ \vdots \\ {\color {brown} x_n}\end{bmatrix}= {\color{brown} x_1} \begin{bmatrix}{\color {red} a_{11}} \\{\color {red} a_{21}} \\\vdots \\{\color {red} a_{m1}}\end{bmatrix} +{\color{brown} x_2} \begin{bmatrix}{\color {blue} a_{12}} \\{\color {blue} a_{22}} \\\vdots \\{\color {blue} a_{m2}}\end{bmatrix} + \cdots +{\color{brown} x_n} \begin{bmatrix}{\color {green} a_{1n}} \\{\color {green} a_{2n}} \\\vdots \\{\color {green} a_{mn}}\end{bmatrix}=\begin{bmatrix}{\color{brown}x_1}{\color {red} a_{11}} + {\color{brown}x_2}{\color {blue} a_{12}} + \cdots + {\color{brown}x_n}{\color {green}a_{1n}} \\{\color{brown}x_1}{\color {red} a_{21}} + {\color{brown}x_2}{\color {blue} a_{22}} + \cdots + {\color{brown}x_n}{\color {green}a_{2n}} \\\vdots \\{\color{brown}x_1}{\color {red} a_{m1}} + {\color{brown}x_2}{\color {blue} a_{m2}} + \cdots + {\color{brown}x_n}{\color {green}a_{mn}}\end{bmatrix}$$</p><p>还有一种理解和上面的“点积”类似，把矩阵看作是 $m$ 个行向量，每个向量都和 $x$ 作点积。如下：</p><p>$$\mathbf{A}{\color{brown}\mathbf{x}} =\begin{bmatrix}{\color {red} a_{11}} &amp; {\color {red} a_{12}} &amp; \cdots &amp; {\color {red}a_{1n}} \\{\color {blue} a_{21}} &amp; {\color {blue} a_{22}} &amp; \cdots &amp; {\color {blue}a_{2n}} \\\vdots &amp; \vdots &amp; \ddots &amp; \vdots \\{\color {green} a_{m1}} &amp; {\color {green} a_{m2}} &amp; \cdots &amp; {\color {green}a_{mn}}\end{bmatrix}\begin{bmatrix}{\color {brown} x_1} \\ {\color {brown} x_2} \\ \vdots \\ {\color {brown} x_n}\end{bmatrix}= \begin{bmatrix}{\color {red} \mathbf{a}_{1,*}} \cdot {\color{brown}\mathbf{x}} \\{\color {blue} \mathbf{a}_{2,*}} \cdot {\color{brown}\mathbf{x}} \\\vdots \\{\color {green} \mathbf{a}_{m,*}} \cdot {\color{brown}\mathbf{x}}\end{bmatrix}$$</p><h3 id="矩阵与矩阵乘法"><a class="header-anchor" href="#矩阵与矩阵乘法"></a>矩阵与矩阵乘法</h3><p>延续线性变换的观点，矩阵和矩阵的相乘，可以看作变换的组合。矩阵 $\mathbf{B}$ 的每个列可以认为是变换后的基向量，而 $\mathbf{A} \mathbf{B}$ 可以认为是把每个基向量再做一次线性变换 $\mathbf{A}$。如下：</p><p>$$\mathbf{A}\mathbf{B}= \mathbf{A} \begin{bmatrix}{\color{red}\mathbf{b}_{*,1}} &amp; {\color{blue}\mathbf{b}_{*,2}} &amp; \cdots &amp; {\color{green}\mathbf{b}_{*,n}}\end{bmatrix}= \begin{bmatrix}\mathbf{A} {\color{red}\mathbf{b}_{*,1}}&amp; \mathbf{A} {\color{blue}\mathbf{b}_{*,2}}&amp; \cdots&amp; \mathbf{A} {\color{green}\mathbf{b}_{*,n}}\end{bmatrix}$$</p><p>如果再用上面的“点积”观点展开，则会是这样：</p><p>$$\mathbf{A}\mathbf{B}= \mathbf{A} \begin{bmatrix}\mathbf{b}_{*,1} &amp; \mathbf{b}_{*,2} &amp; \cdots &amp; \mathbf{b}_{*,n}\end{bmatrix}= \begin{bmatrix}\begin{bmatrix}\mathbf{a}_{1,*} \\\mathbf{a}_{2,*} \\\vdots \\\mathbf{a}_{m,*}\end{bmatrix}\mathbf{b}_{*,1}&amp;\begin{bmatrix}\mathbf{a}_{1,*} \\\mathbf{a}_{2,*} \\\vdots \\\mathbf{a}_{m,*}\end{bmatrix}\mathbf{b}_{*,2}&amp; \cdots&amp;\begin{bmatrix}\mathbf{a}_{1,*} \\\mathbf{a}_{2,*} \\\vdots \\\mathbf{a}_{m,*}\end{bmatrix}\mathbf{b}_{*,n}\end{bmatrix}= \begin{bmatrix}\mathbf{a}_{1,*} \cdot \mathbf{b}_{*,1} &amp; \mathbf{a}_{1,*} \cdot \mathbf{b}_{*,2} &amp; \cdots &amp; \mathbf{a}_{1,*} \cdot \mathbf{b}_{*,n} \\\mathbf{a}_{2,*} \cdot \mathbf{b}_{*,1} &amp; \mathbf{a}_{2,*} \cdot \mathbf{b}_{*,2} &amp; \cdots &amp; \mathbf{a}_{2,*} \cdot \mathbf{b}_{*,n} \\\vdots &amp; \vdots &amp; \ddots &amp; \vdots \\\mathbf{a}_{m,*} \cdot \mathbf{b}_{*,1} &amp; \mathbf{a}_{m,*} \cdot \mathbf{b}_{*,2} &amp; \cdots &amp; \mathbf{a}_{m,*} \cdot \mathbf{b}_{*,n}\end{bmatrix}$$</p><p>这个也就是我们熟悉的，每个元素等于行乘列的形式：</p><p>$$(\mathbf{A}\mathbf{B})_{ij}= {\color{red}\mathbf{a}_{i,*}} \cdot {\color{blue}\mathbf{b}_{*,j}}= \begin{bmatrix}\cdots &amp; \cdots &amp; \cdots &amp; \cdots \\{\color{red}a_{i1}} &amp; {\color{red}a_{i2}} &amp; \cdots &amp; {\color{red}a_{in}} \\\cdots &amp; \cdots &amp; \cdots &amp; \cdots\end{bmatrix}\begin{bmatrix}\vdots &amp; {\color{blue}b_{1,j}} &amp; \vdots \\\vdots &amp; {\color{blue}b_{2,j}} &amp; \vdots \\\vdots &amp; \vdots &amp; \vdots \\\vdots &amp; {\color{blue}b_{n,j}} &amp; \vdots\end{bmatrix}$$</p><h2 id="按元素操作"><a class="header-anchor" href="#按元素操作"></a>按元素操作</h2><p>即将两个矩阵对应位置上的元素做相应的操作。也称为 element-wise operation.</p><h3 id="按元素乘法"><a class="header-anchor" href="#按元素乘法"></a>按元素乘法</h3><p>按元素乘法有个特殊的名字，叫 <a href="https://en.wikipedia.org/wiki/Hadamard_product_(matrices)">Hadamard product</a>，一般记作 $A \circ B$ 或 $A \odot B$，即将相同位置的元素相乘</p><p>$$\begin{bmatrix}a_{11} &amp; a_{12} &amp; \cdots &amp; a_{1n} \\a_{21} &amp; {\color{red}a_{22}} &amp; \cdots &amp; a_{2n} \\\vdots &amp; \vdots &amp; \ddots &amp; \vdots \\a_{m1} &amp; a_{m2} &amp; \cdots &amp; a_{mn}\end{bmatrix}\circ\begin{bmatrix}b_{11} &amp; b_{12} &amp; \cdots &amp; b_{1n} \\b_{21} &amp; {\color{blue}b_{22}} &amp; \cdots &amp; b_{2n} \\\vdots &amp; \vdots &amp; \ddots &amp; \vdots \\b_{m1} &amp; b_{m2} &amp; \cdots &amp; b_{mn}\end{bmatrix}= \begin{bmatrix}a_{11}b_{11}  &amp; a_{12}b_{12}  &amp; \cdots &amp; a_{1n}b_{1n}  \\a_{21}b_{21}  &amp; {\color{red}a_{22}}{\color{blue}b_{22}}  &amp; \cdots &amp; a_{2n}b_{2n}  \\\vdots &amp; \vdots &amp; \ddots &amp; \vdots \\a_{m1}b_{m1}  &amp; a_{m2}b_{m2}  &amp; \cdots &amp; a_{mn}b_{mn}\end{bmatrix}$$</p><p>其它操作也类似，如加法减法等</p><h3 id="广播-broadcast"><a class="header-anchor" href="#广播-broadcast"></a>广播 broadcast</h3><p>数学上，按元素操作只在矩阵的形状相同时才有效。但在实际应用中，可以尝试把“低维”的元素复制 N 份做填充，这就是 broadcast 机制。其实在介绍矩阵和向量的点积是已经用过这个操作了。示例如下：</p><p>$$\mathbf{A} \circ \mathbf{v}= \mathbf{A} \circ \begin{bmatrix}\mathbf{v} &amp; \cdots &amp; \mathbf{v} \end{bmatrix}$$</p><p>另外注意，这里依旧是以“列”为第一维，“行”为第二维。而在 numpy 中，第 <code>0</code> 维是行：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">&gt;&gt;&gt; w = np.array([[1,2,3],[4,5,6],[7,8,9]])</span><br><span class="line">&gt;&gt;&gt; w</span><br><span class="line">array([[1, 2, 3],</span><br><span class="line">       [4, 5, 6],</span><br><span class="line">       [7, 8, 9]])</span><br><span class="line">&gt;&gt;&gt; x = np.array([1,2,3])</span><br><span class="line">&gt;&gt;&gt; x</span><br><span class="line">array([1, 2, 3])</span><br><span class="line">&gt;&gt;&gt; w * x</span><br><span class="line">array([[ 1,  4,  9],</span><br><span class="line">       [ 4, 10, 18],</span><br><span class="line">       [ 7, 16, 27]])</span><br></pre></td></tr></table></figure></div><p>这个特性可以一直往高维推广，具体机制参考 <a href="https://numpy.org/doc/stable/user/basics.broadcasting.html">numpy broadcast</a>。</p><h2 id="求导"><a class="header-anchor" href="#求导"></a>求导</h2><p>深度学习中最重要的数学知识就是对矩阵求导了，<a href="https://arxiv.org/abs/1802.01528">The Matrix Calculus You Need For Deep Learning</a>这篇论文针对性地做了综述。下面的知识算是摘录其中一些部分加深记忆<sup class="footnote-ref"><a href="#fn5" id="fnref5">[5]</a></sup>。矩阵求导更复杂的内容，参考 wiki: <a href="https://en.wikipedia.org/wiki/Matrix_calculus">Matrix calculus</a></p><h3 id="向量点积求导"><a class="header-anchor" href="#向量点积求导"></a>向量点积求导</h3><p>考虑 $y = \mathbf{w} \cdot \mathbf{x}$，因为有多个输入，于是导数为偏导向量，这里我们用行向量表示：</p><p>$$\frac{\partial y}{\partial \mathbf{x}}= \begin{bmatrix}\frac{\partial y}{\partial x_1} &amp;\cdots &amp;\frac{\partial y}{\partial x_n}\end{bmatrix}= \begin{bmatrix}\frac{\partial (w_1 x_1 + \cdots + w_n x_n)}{\partial x_1} &amp;\cdots &amp;\frac{\partial (w_1 x_1 + \cdots + w_n x_n)}{\partial x_n}\end{bmatrix}= \begin{bmatrix} w_1 &amp; \cdots &amp; w_n \end{bmatrix}= \mathbf{w}^T$$</p><p>同理对 $\mathbf{w}$ 求导的值为：</p><p>$$\frac{\partial y}{\partial \mathbf{w}}= \mathbf{x}^T$$</p><h3 id="jacobian-matrix"><a class="header-anchor" href="#jacobian-matrix"></a>Jacobian Matrix</h3><p>我们知道“导数”要求的是“变化”，即如果输入 $x$ 有微小的变化 $\Delta x$ 时，输入$y$ 的变化 $\Delta y$。那么如果有多个输入 $x_1, \cdots, x_n$ 和多个输出 $y_1 =f_1(\mathbf{x}), \cdots, y_m = f_m(\mathbf{x})$，则任意输入 $x_i$ 有变化，任意输出 $y_j$ 就有可能有变化。于是它们间的偏导关系是一个矩阵，记做：</p><p>$$\mathbf {J}=\begin{bmatrix}\frac{\partial y_1}{\partial \mathbf{x}} \\ \vdots \\ \frac{\partial y_m}{\partial \mathbf{x}} \end{bmatrix}=\begin{bmatrix}{\dfrac {\partial f_{1}}{\partial x_{1}}}&amp;\cdots &amp;{\dfrac {\partial f_{1}}{\partial x_{n}}}\\\vdots &amp;\ddots &amp;\vdots \\{\dfrac {\partial f_{m}}{\partial x_{1}}}&amp;\cdots &amp;{\dfrac {\partial f_{m}}{\partial x_{n}}}\end{bmatrix}$$</p><h3 id="矩阵与向量点积求导"><a class="header-anchor" href="#矩阵与向量点积求导"></a>矩阵与向量点积求导</h3><p>如果有 $m$ 个输出，$y_j = \mathbf{w_j} \cdot \mathbf{x}$（注意 $\mathbf{w_j}$本身是 $n$ 维的向量，向量个数是 $m$）。对 $\mathbf{x}$ 的求导比较直观：</p><p>$$\frac{\partial \mathbf{y}}{\partial \mathbf{x}}= \begin{bmatrix}\frac{\partial y_1}{\partial \mathbf{x}} \\\frac{\partial y_2}{\partial \mathbf{x}} \\\vdots \\\frac{\partial y_m}{\partial \mathbf{x}}\end{bmatrix}= \begin{bmatrix}\mathbf{w}_1^T \\\mathbf{w}_2^T \\\vdots \\\mathbf{w}_m^T\end{bmatrix}$$</p><p>但是，如果对 $\mathbf{w}$ 求导，$\mathbf{w}$ 有 $m \times n$ 个元素，因此求导的结果是一个 $m \times (m \times n)$ 的 Jacobian 矩阵。特别复杂。所幸，在深度学习中，求导的目的是为了做梯度下降，所以为 <code>0</code> 的导数实际上也没用。而通过$y_j$ 的定义，我们知道如果 $i \ne j$ 则 $\frac{\partial y_i}{\partial \mathbf{w}_j} = 0$。于是我们去除这些为 $0$ 的项，保留：</p><p>$$\frac{\partial \mathbf{y}}{\partial \mathbf{w}}= \begin{bmatrix}\frac{\partial y_1}{\partial \mathbf{w}_1} \\\frac{\partial y_2}{\partial \mathbf{w}_2} \\\vdots \\\frac{\partial y_m}{\partial \mathbf{w}_m}\end{bmatrix}= \begin{bmatrix}\mathbf{x}^T \\\mathbf{x}^T \\\vdots \\\mathbf{x}^T\end{bmatrix}$$</p><p>如果我们把 $\mathbf{w}$ 写成矩阵形式 $\mathbf{W} = [\mathbf{w}_1 \cdots,\mathbf{w}_m]$，此时 $\mathbf{y} = \mathbf{W} \cdot \mathbf{x} = \mathbf{W}^T \mathbf{x}$，则上面的结论可以写成：</p><p>$$\begin{eqnarray}\frac{\partial \mathbf{y}}{\partial \mathbf{x}} = \mathbf{W}^T\end{eqnarray}$$</p><p>$$\begin{eqnarray}\frac{\partial \mathbf{y}}{\partial \mathbf{W}}= \begin{bmatrix}\mathbf{x}^T \\\mathbf{x}^T \\\vdots \\\mathbf{x}^T\end{bmatrix}= \begin{bmatrix}\mathbf{x} \cdots \mathbf{x} \end{bmatrix}^T\end{eqnarray}$$</p><h3 id="矩阵与向量乘法求导"><a class="header-anchor" href="#矩阵与向量乘法求导"></a>矩阵与向量乘法求导</h3><p>其实上一节的矩阵与向量点积的求导已经得出结论了。当 $\mathbf{y} = \mathbf{A} \mathbf{x}$ 时，有</p><p>$$\begin{align}\frac{\partial \mathbf{y}}{\partial \mathbf{x}} &amp;= \mathbf{A} \\\frac{\partial \mathbf{y}}{\partial \mathbf{A}} &amp;= \begin{bmatrix}\mathbf{x} \cdots \mathbf{x} \end{bmatrix} ^T\end{align}$$</p><p>这里我们再从数值的角度分析一下，已知 $y_i = \sum_{j}{a_{ik}x_j}$，则有：</p><p>$$\frac{\partial y_i}{\partial x_j}= \frac{a_{i1}x_1+\cdots+a_{mn}{x_n}}{\partial x_j} = a_{ij}$$</p><p>再次</p><p>$$\frac{\partial y_i}{\partial w_{ij}}= \frac{a_{i1}x_1+\cdots+a_{mn}{x_n}}{\partial w_{ij}} = x_j$$</p><p>写成矩阵形式就是结论部分。</p><h3 id="矩阵与矩阵乘法求导"><a class="header-anchor" href="#矩阵与矩阵乘法求导"></a>矩阵与矩阵乘法求导</h3><p>这部分过于复杂，且符号也没有统一，后续如果有用到再进行补充。</p><h3 id="按元素-element-wise-操作"><a class="header-anchor" href="#按元素-element-wise-操作"></a>按元素(element-wise)操作</h3><p>将按元素操作记为 $\unicode{x2D54}$，考虑 $\mathbf{y} = \mathbf{f(u)} \unicode{x2D54} \mathbf{g(v)}$，且向量 $\mathbf{u}, \mathbf{v}, \mathbf{y}$ 有相同的维度。写成如下形式：</p><p>$$\begin{bmatrix}y_1 \\ y_2 \\ \vdots \\ y_n\end{bmatrix}= \begin{bmatrix}f_1(\mathbf{u}) \unicode{x2D54} g_1(\mathbf{v}) \\f_2(\mathbf{u}) \unicode{x2D54} g_2(\mathbf{v}) \\\vdots \\f_n(\mathbf{u}) \unicode{x2D54} g_n(\mathbf{v})\end{bmatrix}$$</p><p>于是偏导则变成了 Jacobian 矩阵的形式：</p><p>$$\mathbf{J_u}= \frac{\partial \mathbf{y}}{\partial \mathbf{u}}= \begin{bmatrix}\frac{\partial y_1}{\partial \mathbf{u}} \\\frac{\partial y_2}{\partial \mathbf{u}} \\\vdots \\\frac{\partial y_n}{\partial \mathbf{u}} \\\end{bmatrix}= \begin{bmatrix}\frac{\partial}{\partial u_1} f_1(\mathbf{u}) \unicode{x2D54} g_1(\mathbf{v})&amp; \frac{\partial}{\partial u_2} f_1(\mathbf{u}) \unicode{x2D54} g_1(\mathbf{v})&amp; \cdots&amp; \frac{\partial}{\partial u_n} f_1(\mathbf{u}) \unicode{x2D54} g_1(\mathbf{v})\\\frac{\partial}{\partial u_1} f_2(\mathbf{u}) \unicode{x2D54} g_2(\mathbf{v})&amp; \frac{\partial}{\partial u_2} f_2(\mathbf{u}) \unicode{x2D54} g_2(\mathbf{v})&amp; \cdots&amp; \frac{\partial}{\partial u_n} f_2(\mathbf{u}) \unicode{x2D54} g_2(\mathbf{v})\\\vdots &amp; \vdots &amp; \ddots &amp; \vdots\\\frac{\partial}{\partial u_1} f_n(\mathbf{u}) \unicode{x2D54} g_n(\mathbf{v})&amp; \frac{\partial}{\partial u_2} f_n(\mathbf{u}) \unicode{x2D54} g_n(\mathbf{v})&amp; \cdots&amp; \frac{\partial}{\partial u_n} f_n(\mathbf{u}) \unicode{x2D54} g_n(\mathbf{v})\end{bmatrix}$$</p><p>但是注意到 $\unicode{x2D54}$ 是 element-wise 操作，于是 $y_i$ 只跟$f_i(\mathbf{u})$ 和 $g_i(\mathbf{v})$ 相关，换句话说，对于 $i \ne j$ 的情况，有 $\frac{\partial y_i}{\partial u_j} = 0$。更进一步，element-wise 操作代表着 $f_i(\mathbf{u})$ 可以退化成 $f_i(u_i)$，而跟其它所有 $u_k (k \ne i)$ 无关。</p><p>$$\mathbf{J_u}= \begin{bmatrix}\frac{\partial}{\partial u_1} (f_1(u_1) \unicode{x2D54} g_1(v_1))&amp; 0&amp; \cdots&amp; 0\\0&amp; \frac{\partial}{\partial u_2} (f_2(u_2) \unicode{x2D54} g_2(v_2))&amp; \cdots&amp; 0\\\vdots &amp; \vdots &amp; \ddots &amp; \vdots\\0&amp; 0&amp; \cdots&amp; \frac{\partial}{\partial u_n} (f_n(u_n) \unicode{x2D54} g_n(v_n))\end{bmatrix}$$</p><p>注意到只有对象元素有值，是对角矩阵，于是写成下式：</p><p>$$\frac{\partial \mathbf{y}}{\partial \mathbf{u}} = \mathbf{J_u}= diag \left(\frac{\partial}{\partial u_1} \left(f_1(u_1) \unicode{x2D54} g_1(v_1)\right),\cdots,\frac{\partial}{\partial u_n} \left(f_n(u_n) \unicode{x2D54} g_n(v_n)\right)\right)$$</p><p>再进一步，深度学习中一般 $f(u_i) = u_i$ 和 $g(v_i) = v_i$，所以还能简化：</p><p>$$\frac{\partial \mathbf{y}}{\partial \mathbf{u}} = \mathbf{J_u}= diag \left(\frac{\partial}{\partial u_1} \left(u_1 \unicode{x2D54} v_1\right),\cdots,\frac{\partial}{\partial u_n} \left(u_n \unicode{x2D54} v_n\right)\right)$$</p><p>于是常见的 element-wise 操作及其导数如下</p><table><thead><tr><th>Op</th><th>对 $u$ 导数</th></tr></thead><tbody><tr><td>+</td><td>$\frac{\partial (\mathbf{u}+\mathbf{v})}{\partial \mathbf{u}} = diag(\mathbf{1}) = \mathbf{I} $</td></tr><tr><td>-</td><td>$\frac{\partial (\mathbf{u}-\mathbf{v})}{\partial \mathbf{u}} = diag(\mathbf{1}) = \mathbf{I} $</td></tr><tr><td>$\otimes$</td><td>$\frac{\partial (\mathbf{u}\otimes\mathbf{v})}{\partial \mathbf{u}} = diag(\cdots, \frac{\partial (u_i\times v_i)}{\partial u_i}, \cdots) = diag(\mathbf{v}) $</td></tr><tr><td>$\oslash$</td><td>$\frac{\partial (\mathbf{u}\oslash\mathbf{v})}{\partial \mathbf{u}} = diag(\cdots, \frac{\partial (u_i / v_i)}{\partial u_i}, \cdots) = diag(\cdots, \frac{1}{v_i}, \cdots) = \frac{1}{\mathbf{v}}$</td></tr></tbody></table><table><thead><tr><th>Op</th><th>对 $v$ 导数</th></tr></thead><tbody><tr><td>+</td><td>$\frac{\partial (\mathbf{u}+\mathbf{v})}{\partial \mathbf{v}} = diag(-\mathbf{1}) = -\mathbf{I} $</td></tr><tr><td>-</td><td>$\frac{\partial (\mathbf{u}-\mathbf{v})}{\partial \mathbf{v}} = diag(-\mathbf{1}) = -\mathbf{I} $</td></tr><tr><td>$\otimes$</td><td>$\frac{\partial (\mathbf{u}\otimes\mathbf{v})}{\partial \mathbf{v}} = diag(\cdots, \frac{\partial (u_i\times v_i)}{\partial v_i}, \cdots) = diag(\mathbf{u}) $</td></tr><tr><td>$\oslash$</td><td>$\frac{\partial (\mathbf{u}\oslash\mathbf{v})}{\partial \mathbf{v}} = diag(\cdots, \frac{\partial (u_i / v_i)}{\partial v_i}, \cdots) = diag(\cdots, \frac{-u_i}{v_i^2}, \cdots) = -\frac{\mathbf{u}}{\mathbf{v}^2}$</td></tr></tbody></table><p>由于一般拿导数是用来更新向量的，对角矩阵经常也直接当成向量来使用。</p><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>主要回顾了 dot product、矩阵乘法与 element-wise 乘法的关系，以及这些操作求偏导的矩阵形式。</p><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>注意这里的表示要求向量的座标是基于一对正交基的，另外注意这里没有定义行向量或列向量，因为这是坐标形式，不关心向量是行向量还是列向量 <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p>此处参考 matlab dot product 定义：<a href="https://www.mathworks.com/help/matlab/ref/dot.html#bt9p8vi-1_1">https://www.mathworks.com/help/matlab/ref/dot.html#bt9p8vi-1_1</a> <a href="#fnref2" class="footnote-backref">↩</a></p></li><li id="fn3"  class="footnote-item"><p>参考：<a href="https://mbernste.github.io/posts/matrix_vector_mult/">https://mbernste.github.io/posts/matrix_vector_mult/</a> <a href="#fnref3" class="footnote-backref">↩</a></p></li><li id="fn4"  class="footnote-item"><p>关于线性变换，强推 3blue1brown 的线性代数系列视频，其中<a href="https://www.youtube.com/watch?v=kYB8IZa5AuE">第三章</a> 关于线性变换，<a href="https://www.youtube.com/watch?v=XkY2DOUCWMU">第四章</a> 关于矩阵乘法 <a href="#fnref4" class="footnote-backref">↩</a></p></li><li id="fn5"  class="footnote-item"><p>这里就不谈链式法则相关的内容了，感兴趣的可以参考我的前一篇文章 <a href="http://lotabout.me/2023/Auto-Differentiation-Part-1-Algorithm/">自动微分（Automatic Differentiation）：算法篇</a> <a href="#fnref5" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;作为数学学渣，最近复习深度学习中的一些矩阵运算，做一些推导并记录如下。&lt;/p&gt;
&lt;div style=&quot;display: none&quot;&gt;
$$
&#92;require{color}
&#92;require{unicode}
&#92;definecolor{blue}{rgb}{0.16, 0.</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="Neural Network" scheme="https://lotabout.me/tags/Neural-Network/"/>
    
    <category term="Matrix" scheme="https://lotabout.me/tags/Matrix/"/>
    
  </entry>
  
  <entry>
    <title>自动微分（Automatic Differentiation）：算法篇</title>
    <link href="https://lotabout.me/2023/Auto-Differentiation-Part-1-Algorithm/"/>
    <id>https://lotabout.me/2023/Auto-Differentiation-Part-1-Algorithm/</id>
    <published>2023-04-09T09:44:12.000Z</published>
    <updated>2025-11-26T12:53:47.990Z</updated>
    
    <content type="html"><![CDATA[<p>自动微分（Automatic Differentiation，下面简称 AD）是用来计算偏导的一种手段，在深度学习框架中广泛使用（如 Pytorh, Tensorflow）。最近想学习这些框架的实现，先从 AD 入手，框架的具体实现比较复杂，我们主要是理解 AD 的思想并做个简单的实现。</p><p>本篇只介绍算法的基础知识，实现部分请参考<a href="/2023/Auto-Differentiation-Part-2-Implementation/" title="自动微分（Automatic Differentiation）：实现篇">实现篇</a>。</p><h2 id="ad-能干什么？"><a class="header-anchor" href="#ad-能干什么？"></a>AD 能干什么？</h2><p>AD 能用来求偏导<strong>值</strong>的。</p><p>例如有一个 $\mathbb{R}^2 \mapsto \mathbb{R}$ 的函数（函数有 <code>2</code> 个输入，<code>1</code> 个输出）：$f(x, y)$ ，对于 $x$、$y$ 的偏导分别计为$\frac{\partial f}{\partial x}$ 和 $\frac{\partial f}{\partial y}$。通常我们不关心偏导的解析式，只关心具体某个 $x_i$, $y_i$ 取值下偏导$\frac{\partial f}{\partial x} \vert_{x=x_i,y=y_i}$ 和$\frac{\partial f}{\partial y} \vert_{x=x_i,y=y_i}$ 的值。</p><p>另外注意在神经网络在使用“梯度下降”学习时，我们关心的是“参数 $w$”的偏导。而不是“输入 $x$”的偏导。假设有 $f(x) = ax^2 + b$ 这样的神经网络，损失函数是 $l(f(x), y)$，现在给了一个样本标签对$(x_0, y_0)$，我们要计算的是$\frac{\partial l}{\partial a}\vert_{x=x_0,y=y_0,a=a_0,b=b_0}$ 和$\frac{\partial l}{\partial b}\vert_{x=x_0,y=y_0,a=a_0,b=b_0}$。在对号入座时要牢记这点。</p><h2 id="为什么用-ad？"><a class="header-anchor" href="#为什么用-ad？"></a>为什么用 AD？</h2><p>求偏导有很多做法，例如 <a href="https://en.wikipedia.org/wiki/Symbolic_differentiation">symbolic differentiation</a>使用“符号计算” 得到准确的偏导解析式，但对于复杂的函数，偏导解析式会特别复杂，占用大量内存且计算慢，并且通常应用也不需要解析式；再比如<a href="https://en.wikipedia.org/wiki/Numerical_differentiation">numerical differentiation</a>通过引入很小的位移 $h$，计算 $\frac{f(x+h) - f(h)}{h}$ 得到偏导，这种方法编码容易，但受 float 误差影响大，且计算慢（有几个输入就要算几次 $f$）。</p><p>AD 认为所有的计算最终都可以拆解成基础操作（如加减乘除，<code>exp</code>, <code>log</code>, <code>sin</code>,<code>cos</code> 等基本函数）的组合。然后通过<a href="https://en.wikipedia.org/wiki/Chain_rule">链式法则</a>逐步计算偏导。这样使用方只需要正常组合基础操作，就能自动计算偏导，且不受 float误差的影响，还可以复用一些中间结果来减少计算量（等价于动态规划）。</p><h2 id="链式法则回顾"><a class="header-anchor" href="#链式法则回顾"></a>链式法则回顾</h2><p>AD 的数学基础就是<a href="https://en.wikipedia.org/wiki/Chain_rule">链式法则(chain rule)</a>：</p><p>对于函数 $z = h(x)$，如果有子函数 $y = f(x)$，满足 $z = h(x) = g(y) = g(f(x))$，则求偏导有如下关系：</p><p>$$h’(x) = g’(f(x))f’(x)\iff\frac{\partial z}{\partial x} \bigg\vert_{x_0} = \frac{\partial z}{\partial y}\bigg\vert_{y=f(x_0)} \frac{\partial y}{\partial x} \bigg\vert_{x_0}$$</p><p>上述两种写法是一致的。另外如果涉及多个变量，例如 $z = f(x, y)$，而 $x = g(t),y = h(t)$，则有：</p><p>$$\frac{\partial z}{\partial t} = \frac{\partial z}{\partial x}\frac{\partial x}{\partial t} +\frac{\partial z}{\partial y}\frac{\partial y}{\partial t}$$</p><p>上面的式子叫 <a href="https://en.wikipedia.org/wiki/Chain_rule#Multivariable_case">multivariable case</a>：多变量的链式法则。也可以认为是<a href="https://en.wikipedia.org/wiki/Total_derivative#The_chain_rule_for_total_derivatives">Total Derivative</a>全微分的链式法则。</p><h2 id="ad-具体是怎么做的？"><a class="header-anchor" href="#ad-具体是怎么做的？"></a>AD 具体是怎么做的？</h2><p>AD 其实就是链式法则的具体实现。它有两种模式：前向模式(Forward accumulation)和反向模式(Reverse accumulation)，我们只考虑反向模式。那么具体是怎么工作的呢？考虑下面的复杂函数<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></p><p>$$\begin{aligned}y &amp;= f(x_{1},x_{2})\\&amp;= \sin x_{1} + x_{1}x_{2}\\&amp;= \sin v_{1} + v_{1}v_{2}\\&amp;= v_{3}+v_{4}\\&amp;= v_{5}\end{aligned}$$</p><p>上述公式中，我们用了一些子函数来简化整个函数，画成图如下左图：</p><img src="/2023/Auto-Differentiation-Part-1-Algorithm/2023-04-AD-example-computation-graph.svg" class=""><p>于是为了求偏导 $\frac{\partial f}{\partial x_1}$ 与 $\frac{\partial f}{\partial x_2}$的值，我们可以先定义中间值 $\bar{v_i} = \frac{\partial f}{\partial v_i}$，根据链式法则，有</p><p>$$\bar{v_i} = \frac{\partial f}{\partial v_i} = \frac{\partial f}{\partial v_{i+1}} \frac{\partial v_{i+1}}{\partial v_i} = \bar{v_{i+1}} \frac{\partial v_{i+1}}{\partial v_i}$$</p><p>于是计算时需要先“前向”计算一次，得到 $v_1, v_2, \cdots, v_5$ 的值，之后再“后向”计算 $\bar{v_5}, \bar{v_4}, \cdots, \bar{v_1}$ 的值（参考上右图），最终得到的$\bar{v_1}, \bar{v_2}$ 就是我们要计算的结果。而需要先“前向”计算一次，是因为后向计算时会用到前向的值，例如 $\bar{v_2} = \bar{v_4} v_1$ 就需要用到前向的$v_1$。</p><p>注意图里 $\bar{v_1}$ 的计算依赖了链式法则中多变量的情况，等于它所有后继节点偏导（即图中的 $\bar{v_1^a}, \bar{v_1^b}$）的和。当计算图中存在$v_i$ 指向 $v_j$ 的箭头时，我们记 $\overline{v_{i \to j}}$ 为 $f$ 从 $v_j$ 方向对 $v_i$ 的偏导，则公式可以扩充如下：</p><p>$$\bar{v_i} = \frac{\partial f}{\partial v_i}= \sum_{j \in next(i)}{\overline{v_{i\to j}}}= \sum_{j \in next(i)}{\frac{\partial f}{\partial v_{j}} \frac{\partial v_{j}}{\partial v_i}= \sum_{j \in next(i)}{\overline{v_j} \frac{\partial v_{j}}{\partial v_i}}}$$</p><h2 id="多输出情形"><a class="header-anchor" href="#多输出情形"></a>多输出情形</h2><p>多输出的情况偏理论，跳过也影响不大。神经网络的输出，在训练时最终都会接入损失函数，得到 <code>loss</code> 值，一般都是一个标量，可以认为神经网络的学习总是单输出的。</p><p>在多输出的情况下，链式法则依然生效。</p><p>刚才都假设函数是 $\mathbb{R}^n \mapsto \mathbb{R}$，即 <code>n</code> 个输入，<code>1</code> 个输出。考虑 <code>m</code> 个输出，即 $\mathbb{R}^n \mapsto \mathbb{R}^m$ 的情况。假设输入是$x_1, x_2, \cdots, x_n$，而输出是$f_1(x_1, \cdots, x_n), f_2(x_1, \cdots, x_n), \cdots, f_m(x_1, \cdots, x_n)$。此时我们要计算的偏导就不是 <code>n</code> 个值了，而是一个 <code>m×n</code> 的矩阵<sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup>，每个元素 $J_{ij} = \frac{\partial f_i}{\partial x_j}$。这个矩阵一般称为<a href="https://en.wikipedia.org/wiki/Jacobian_matrix_and_determinant">Jacobian Matrix</a>：</p><p>$$\mathbf {J_{m\times n}} =\begin{bmatrix}{\dfrac {\partial \mathbf {f} }{\partial x_{1}}}&amp;\cdots &amp;{\dfrac {\partial \mathbf {f} }{\partial x_{n}}}\end{bmatrix}=\begin{bmatrix}\nabla ^{\mathrm {T} }f_{1}\\\vdots \\\nabla ^{\mathrm {T} }f_{m}\end{bmatrix}=\begin{bmatrix}{\dfrac {\partial f_{1}}{\partial x_{1}}}&amp;\cdots &amp;{\dfrac {\partial f_{1}}{\partial x_{n}}}\\\vdots &amp;\ddots &amp;\vdots \\{\dfrac {\partial f_{m}}{\partial x_{1}}}&amp;\cdots &amp;{\dfrac {\partial f_{m}}{\partial x_{n}}}\end{bmatrix}$$</p><p>其中 $\nabla^{\mathrm{T}}f_i$ 代表 $f_i$ 对于所有输入的偏导（行向量）的转置。</p><p>考虑函数 $g: \mathbb{R}^n \mapsto\mathbb{R}^k$，$h: \mathbb{R}^k \mapsto \mathbb{R}^m$，而函数 $f$ 是二者的组合：$f(x) = h \circ g(x) = h(g(x))$，则有</p><p>$$J = J_{h \circ g} = J_h(g(x)) \cdot J_g(x)$$</p><p>此时 $\mathbf{J}$ 中的每个元素：</p><p>$$J_{ij} = \frac{\partial f_i}{\partial x_j}= \sum_{l = 1}^{k}{\frac{\partial h_i}{\partial g_l} \frac{\partial g_l}{\partial x_j}}= \begin{bmatrix}{\dfrac {\partial h_i}{\partial g_{1}}}&amp;\cdots &amp;{\dfrac {\partial h_i }{\partial g_{k}}}\end{bmatrix}\begin{bmatrix}{\dfrac {\partial g_1}{\partial x_{j}}} \\ \vdots \\ {\dfrac {\partial g_k }{\partial x_{j}}}\end{bmatrix}$$</p><p>可以看到和 $J_h \cdot J_g$ 的结果是一致的。不过这些性质其实都是链式法则的内容，这里也只是扩充视野。</p><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>AD 把复杂的函数看成是许多小函数的组合，再利用链式法则来计算偏导。它有不同的模式，其中“后向模式”在计算偏导时先“前向”计算得到一些中间结果，之后再“反向”计算偏导。从工程的视角看，由于中间的偏导可以重复利用，能减少许多计算量。深度学习的反向传播算法（BP）是 AD 的一种特例。</p><p>所以回过头来，什么是 AD？AD 就是利用链式法则算偏导的一种实现。</p><h2 id="参考"><a class="header-anchor" href="#参考"></a>参考</h2><ul><li><a href="https://arxiv.org/abs/1811.05031">A Review of automatic differentiation and its efficient implementation</a> 一篇综述，对 AD “是什么”、“为什么”的描述比较清晰</li><li><a href="https://www.youtube.com/watch?v=wG_nF1awSSY">What is Automatic Differentiation?</a> Youtube 视频，回过头来看它介绍了 AD 的各个方面，但第一次直接看还是比较懵的，视频也有对应的综述论文，也是比较好的补充材料</li><li><a href="https://www.youtube.com/watch?v=56WUlMEeAuA">Lecture 4 - Automatic Differentiation</a> 一个 DL 的课程，前面的内容和其它材料差不多，最后通过扩展计算图来计算 AD 的方式对理解一些框架的具体实现很有帮助</li></ul><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>例子取自<a href="https://en.wikipedia.org/wiki/Automatic_differentiation#Forward_accumulation">维基百科</a>，修改了其中的符号 <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p><code>m×n</code> 还是 <code>n×m</code> 取决于是行矩阵还是列矩阵，其实关系不大。 <a href="#fnref2" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;自动微分（Automatic Differentiation，下面简称 AD）是用来计算偏导的一种手段，在深度学习框架中广泛使用（如 Pytorh, Tensorflow）。最近想学习这些框架的实现，先从 AD 入手，框架的具体实现比较复杂，我们主要是理解 AD 的思想并做</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="Automatic Differentiation" scheme="https://lotabout.me/tags/Automatic-Differentiation/"/>
    
    <category term="Neural Network" scheme="https://lotabout.me/tags/Neural-Network/"/>
    
  </entry>
  
  <entry>
    <title>BUG 定位：ZK 引起 HDFS 集群问题，导致 Spark 任务失败</title>
    <link href="https://lotabout.me/2023/hadoop-federation-delegation-token-error/"/>
    <id>https://lotabout.me/2023/hadoop-federation-delegation-token-error/</id>
    <published>2023-01-13T18:50:46.000Z</published>
    <updated>2025-11-26T12:53:48.058Z</updated>
    
    <content type="html"><![CDATA[<p>某一天 QA 报测试环境所有的 Spark 任务都鉴权失败。排查了好几天，分享下排查过程，大家就当看个故事。</p><p>环境信息：</p><ul><li>Spark 3.1.2, 运行采用 Spark on k8s operator</li><li>数据存储在华为 FusionInsight 6.5.1, 并开启了联邦</li></ul><h2 id="简单排查都没问题"><a class="header-anchor" href="#简单排查都没问题"></a>简单排查都没问题</h2><p>Spark driver 看到的报错如下(省略了一些不重要的):</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">Caused by: java.io.IOException: Failed on local exception: java.io.IOException: org.apache.hadoop.security.AccessControlException: Client cannot authenticate via:[TOKEN, KERBEROS]; Host Details : local host is: &quot;spark-sql-xcjgz-43aabd8581a44cae-exec-4/10.244.55.49&quot;; destination host is: &quot;xxx-hdp02&quot;:25019; </span><br><span class="line">at org.apache.hadoop.net.NetUtils.wrapException(NetUtils.java:776)</span><br><span class="line">        ...</span><br><span class="line">Caused by: java.io.IOException: org.apache.hadoop.security.AccessControlException: Client cannot authenticate via:[TOKEN, KERBEROS]</span><br><span class="line">at org.apache.hadoop.ipc.Client$Connection$1.run(Client.java:688)</span><br><span class="line">at java.security.AccessController.doPrivileged(Native Method)</span><br><span class="line">at javax.security.auth.Subject.doAs(Subject.java:422)</span><br><span class="line">at org.apache.hadoop.security.UserGroupInformation.doAs(UserGroupInformation.java:1746)</span><br><span class="line">at org.apache.hadoop.ipc.Client$Connection.handleSaslConnectionFailure(Client.java:651)</span><br><span class="line">at org.apache.hadoop.ipc.Client$Connection.setupIOstreams(Client.java:738)</span><br><span class="line">at org.apache.hadoop.ipc.Client$Connection.access$2900(Client.java:376)</span><br><span class="line">at org.apache.hadoop.ipc.Client.getConnection(Client.java:1529)</span><br><span class="line">at org.apache.hadoop.ipc.Client.call(Client.java:1452)</span><br><span class="line">... 39 more</span><br><span class="line">Caused by: org.apache.hadoop.security.AccessControlException: Client cannot authenticate via:[TOKEN, KERBEROS]</span><br><span class="line">at org.apache.hadoop.security.SaslRpcClient.selectSaslClient(SaslRpcClient.java:172)</span><br><span class="line">at org.apache.hadoop.security.SaslRpcClient.saslConnect(SaslRpcClient.java:396)</span><br><span class="line">at org.apache.hadoop.ipc.Client$Connection.setupSaslConnection(Client.java:561)</span><br><span class="line">at org.apache.hadoop.ipc.Client$Connection.access$1900(Client.java:376)</span><br><span class="line">at org.apache.hadoop.ipc.Client$Connection$2.run(Client.java:730)</span><br><span class="line">at org.apache.hadoop.ipc.Client$Connection$2.run(Client.java:726)</span><br><span class="line">at java.security.AccessController.doPrivileged(Native Method)</span><br><span class="line">at javax.security.auth.Subject.doAs(Subject.java:422)</span><br><span class="line">at org.apache.hadoop.security.UserGroupInformation.doAs(UserGroupInformation.java:1746)</span><br><span class="line">at org.apache.hadoop.ipc.Client$Connection.setupIOstreams(Client.java:726)</span><br><span class="line">... 42 more</span><br></pre></td></tr></table></figure></div><p>由于环境之前都是正常的，大概率没有人动过环境。基于之前的经验，先排查一些常见的问题：</p><ul><li>确定 Spark Operator 需要的（<code>k get mutatingwebhookconfiguration</code>）存在。之前遇到过未知原因导致 webhook 消失，driver 没有 mount 上认证信息导致鉴权失败</li><li>确认 namespace 加了 label，可以被 webhook 监测到。之前遇到因为 namespace 没有相关 label，webhook 失效 mount 缺少认证信息，鉴权失败</li><li>尝试重启 Spark Operator Controller，无效。之前遇到过 controller 本身问题导致mount 失效，可重启解决</li><li>确认 driver pod mount 进了 hadoop-conf, krb5.conf, keytab</li></ul><p>说明问题应该不是由 Spark Operator 引起的。然后怀疑是不是 mount 的鉴权信息有过变动：</p><ul><li>由于 driver 里没有 <code>kinit</code>，尝试把鉴权相关信息(<code>core-site.xml</code>,<code>hdfs-site.xml</code>, <code>krb5.conf</code>, <code>keytab</code>) 放到另一台机器上，kinit 能成功。</li><li>在 kinit 的基础上，尝试 <code>hdfs mkdir</code> 和 <code>hdfs rmdir</code> 都能成功。</li></ul><p>初步认定 Hadoop 集群没问题<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>。</p><p>接着再排除代码问题，使用 driver 镜像在另一个环境（连的另一个 CDH 集群）能正常运行。</p><p>于是环境、集群、代码看起来都没有问题，那问题在哪呢？</p><h2 id="知识不够-debug-来凑"><a class="header-anchor" href="#知识不够-debug-来凑"></a>知识不够，Debug 来凑</h2><p>最开始还是怀疑环境有问题，怀疑 spark operator 依赖环境的某些东西被修改了。但实在不知道从何查起，于是考虑远程 debug。走了一些弯路，最后是这么操作的：</p><ol><li>修改提交的 spark application，加上参数<code>spark.driver.extraJavaOptions=&quot;-agentlib:jdwp=transport=dt_socket,server=y, suspend=y,address=5005&quot;</code>，这样 driver 启动后就会开启 5005 端口等待 debug</li><li>之后通过 <code>kubectl port-forward --address 0.0.0.0 &lt;driver pod&gt; 5005: 5005</code> 来开启宿主机到 pod 的流量转发</li><li>开启 IDEA 远程 debug，连上宿主机的 5005 端口。由于报错的是 hadoop 相关的，随便开了一个项目引入 hadoop 依赖，打断点就能用了。</li></ol><p>断点打在 <code>UserGroupInformation.doAs</code> 上。发现 driver 调用时的用户信息都正常。再通过添加 <code>spark.executor.extracJavaOptions</code> 参数来 debug executor（记得把executor数调成 1）。结果发现 executor 调用 <code>doAs</code> 时，使用的用户名是<code>root</code>（预期是 <code>work</code>），鉴权模式是 <code>SIMPLE</code>（预期是 <code>KERBEROS</code>）。</p><p>这妥妥的是 executor pod 创建的问题呀。于是开始排查 executor，发现 executor 的环境变量 <code>SPARK_USER=root</code>，同时它没有mount krb5.conf 和 keytab，难道发现了root cause?</p><p>可惜几番折腾后都不生效。最后对比运行成功的环境，发现运行成功的 exeucotr 环境变量也是一样的，也没有 mount 任何鉴权相关的信息。</p><h2 id="无从下手-恶补知识"><a class="header-anchor" href="#无从下手-恶补知识"></a>无从下手，恶补知识</h2><p>既然没有任何鉴权相关的信息，executor 里是怎么鉴权的？涉及到知识盲区，怎么办？</p><p>下载 Spark 3.1.1 代码，但代码太多又无从看起，于是上网搜索相关 Feature 的PR，找到下面几个信息：</p><ul><li><a href="https://github.com/apache/spark/pull/21669">https://github.com/apache/spark/pull/21669</a> 增加 kerberos support for k8s</li><li><a href="https://docs.google.com/document/d/1RBnXD9jMDjGonOdKJ2bA1lN4AAV_1RwpU_ewFuCNWKg/edit">https://docs.google.com/document/d/1RBnXD9jMDjGonOdKJ2bA1lN4AAV_1RwpU_ewFuCNWKg/edit</a> spark kerberos support 的设计文档</li><li><a href="https://github.com/apache/spark/pull/22911">https://github.com/apache/spark/pull/22911</a> 增加了 client mode 的支持</li></ul><p>最重要的是最后这个链接，这个 PR 的留言里有这样的信息：</p><blockquote><p>In either of those cases, the driver code will handle delegation tokens: incluster mode by creating a secret and stashing them, in client mode by usingexisting mechanisms to <strong>send DTs to executors</strong>.</p></blockquote><p>说明鉴权都是 driver 做的，而 executor 会从 driver 拿到 delegation token。</p><p>不过 “delegation token” 又是啥玩意？大概搜到它是 Hadoop 发的 token，目标是减少鉴权压力，一般在 Map Reduce, Spark 这些有多个 worker 要访问 HDFS 的时候使用。但这些信息并没有本质帮助。</p><p>好在这样有了 debug 的头绪。一开始尝试使用 spark 的源码进行 remote debug，发现有许多问题搞不定。于是尝试直接下载 driver 里的所有 jar 包，导入到一个空project 中，由于是 scala 直接依赖 jar 包不好单步，于是再下载对应 jar 包的sources.jar，就可以在 IDEA 里打断点单步执行了。</p><p>同时 debug 成功和失败环境里的 executor，在对比一些步骤的变量后，终于发现问题所在：executor 在执行下面代码时，失败的环境里获取到的 token 是空。</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight scala"><table><tr><td class="code"><pre><span class="line">cfg.hadoopDelegationCreds.foreach &#123; tokens =&gt;</span><br><span class="line">  <span class="type">SparkHadoopUtil</span>.get.addDelegationTokens(tokens, driverConf)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>考虑到 DT 是从 driver 获取的，看来是 driver 里存储的 delegation token 本来就是空的。另外回过头来发现 driver 的日志里有这么一句日志：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">23/01/08 21:03:31 INFO DFSClient: Cannot get delegation token from work</span><br></pre></td></tr></table></figure></div><p>为什么 driver token 是空？继续 debug driver 发现 driver 在获取 delegationtoken 时返回的是 null: <code>FileSystem.collectDelegationTokens</code>。最终缩小到最小的复现代码：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="type">var</span> <span class="variable">conf</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">org</span>.apache.hadoop.conf.Configuration();</span><br><span class="line">conf.addResource(<span class="keyword">new</span> <span class="title class_">Path</span>(<span class="string">&quot;./core-site.xml&quot;</span>));</span><br><span class="line">conf.addResource(<span class="keyword">new</span> <span class="title class_">Path</span>(<span class="string">&quot;./hdfs-site.xml&quot;</span>));</span><br><span class="line">System.setProperty(<span class="string">&quot;java.security.krb5.conf&quot;</span>, <span class="string">&quot;./krb5.conf&quot;</span>);</span><br><span class="line">UserGroupInformation.setConfiguration(conf);</span><br><span class="line">UserGroupInformation.loginUserFromKeytab(<span class="string">&quot;work&quot;</span>, <span class="string">&quot;./user.keytab&quot;</span>);</span><br><span class="line"><span class="type">var</span> <span class="variable">fs</span> <span class="operator">=</span> FileSystem.get(URI.create(<span class="string">&quot;hdfs:///&quot;</span>), conf);</span><br><span class="line"><span class="type">var</span> <span class="variable">creds</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">Credentials</span>();</span><br><span class="line">fs.addDelegationTokens(<span class="string">&quot;work&quot;</span>, creds);</span><br><span class="line">Assertions.assertFalse(creds.getAllTokens().isEmpty());</span><br></pre></td></tr></table></figure></div><h2 id="client-or-server"><a class="header-anchor" href="#client-or-server"></a>Client or Server?</h2><p>是 hadoop client 有 BUG？还是 hadoop server 有问题？</p><p>查代码看到 delegation token 是 namenode 创建的。于是上集群的 namenode，用arthas 监控 <code>FSNamesystem.getDelegationToken</code> 方法。</p><p>结果……执行复现代码，发现没有输出，尽管同时监控了所有的 4 个 namenode，没有任何一个有输出。然而在正常的环境里是有输出的。难道是 Client 有 BUG 没把请求发出去？</p><p>开始用 wireshark 抓包，发现还是有请求包发出的，当然因为是 RPC，内容看不出来，但 namenode 的 arthas 就是没有输出……最后在对比正常和错误环境的请求包，突然发现错误环境连接的是 <code>25019</code> 端口，这又是啥端口？</p><p>在集群上通过 <code>netstat -natp</code> 找到了进程，进程的命令显示它是集群的 <code>router</code> 角色。不管是 arthas 还是它的日志（如下），都发现它才是罪魁祸首：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">2023-01-09 20:09:59,312 | WARN  | IPC Server handler 43 on 25019 | trying to get DT with no secret manager running | RouterSecurityManager.java:124</span><br><span class="line">2023-01-09 20:09:59,391 | WARN  | IPC Server handler 39 on 25019 | trying to get DT with no secret manager running | RouterSecurityManager.java:124</span><br></pre></td></tr></table></figure></div><p>其实之前在看 hadoop 相关代码时就注意到如果 server 出错应该要有这个日志，但在namenode 日志里没有找到。现在看到这个日志，基本确定就是它的问题。最后重启router 之后发现世界和平了。</p><h2 id="为什么-router-会出错？"><a class="header-anchor" href="#为什么-router-会出错？"></a>为什么 router 会出错？</h2><p>Router 生成 <a href="https://github.com/apache/hadoop/blob/trunk/hadoop-hdfs-project/hadoop-hdfs-rbf/src/main/java/org/apache/hadoop/hdfs/server/federation/router/security/RouterSecurityManager.java">getDelegationToken</a>的逻辑如下所示，通过 arthas 发现是因为 <code>dtSecretManager.isRunning</code> 判断失败。再追代码发现 <code>isRunning</code> 失败的唯一可能就是调用了<code>AbstractDelegationTokenSecretManager::stopThreads</code>，但是 <code>stopThreads</code> 只有停止 router的时候才会调用，与当前的现象不相符。</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="keyword">public</span> Token&lt;DelegationTokenIdentifier&gt; <span class="title function_">getDelegationToken</span><span class="params">(Text renewer)</span></span><br><span class="line">    <span class="keyword">throws</span> IOException &#123;</span><br><span class="line">  LOG.debug(<span class="string">&quot;Generate delegation token with renewer &quot;</span> + renewer);</span><br><span class="line">  <span class="keyword">final</span> <span class="type">String</span> <span class="variable">operationName</span> <span class="operator">=</span> <span class="string">&quot;getDelegationToken&quot;</span>;</span><br><span class="line">  <span class="type">boolean</span> <span class="variable">success</span> <span class="operator">=</span> <span class="literal">false</span>;</span><br><span class="line">  <span class="type">String</span> <span class="variable">tokenId</span> <span class="operator">=</span> <span class="string">&quot;&quot;</span>;</span><br><span class="line">  Token&lt;DelegationTokenIdentifier&gt; token;</span><br><span class="line">  <span class="keyword">try</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (!isAllowedDelegationTokenOp()) &#123;</span><br><span class="line">      <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">IOException</span>(</span><br><span class="line">          <span class="string">&quot;Delegation Token can be issued only &quot;</span> +</span><br><span class="line">              <span class="string">&quot;with kerberos or web authentication&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> (dtSecretManager == <span class="literal">null</span> || !dtSecretManager.isRunning()) &#123;</span><br><span class="line">      LOG.warn(<span class="string">&quot;trying to get DT with no secret manager running&quot;</span>);</span><br><span class="line">      <span class="keyword">return</span> <span class="literal">null</span>;</span><br><span class="line">    &#125;</span><br><span class="line">    ...</span><br></pre></td></tr></table></figure></div><p>可惜的是 <code>stopThreads</code> 的调用链路并不会输出日志。只能尝试人肉看一看从最后一次重启，到出问题之间的日志，开头结尾如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">2022-12-29 18:54:18,187 | INFO  | pool-1-thread-1 | Stopping security manager | RouterSecurityManager.java:62</span><br><span class="line">2022-12-29 21:14:37,456 | WARN  | IPC Server handler 5 on 25019 | trying to get DT with no secret manager running | RouterSecurityManager.java:124</span><br></pre></td></tr></table></figure></div><p>看了不久发现，在创建 secret manager，调用 <code>startThreads</code> 时因为 ZK 的原因有报错：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">2022-12-29 21:13:06,167 | ERROR | main | Error starting threads for zkDelegationTokens  | ZKDelegationTokenSecretManagerImpl.java:48</span><br><span class="line">java.io.IOException: Could not start Sequence Counter</span><br><span class="line">at org.apache.hadoop.security.token.delegation.ZKDelegationTokenSecretManager.startThreads(ZKDelegationTokenSecretManager.java:324)</span><br><span class="line">at org.apache.hadoop.hdfs.server.federation.router.security.token.ZKDelegationTokenSecretManagerImpl.&lt;init&gt;(ZKDelegationTokenSecretManagerImpl.java:46)</span><br><span class="line">at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)</span><br><span class="line">at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)</span><br><span class="line">at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)</span><br><span class="line">at java.lang.reflect.Constructor.newInstance(Constructor.java:423)</span><br><span class="line">at org.apache.hadoop.hdfs.server.federation.router.FederationUtil.newSecretManager(FederationUtil.java:214)</span><br><span class="line">at org.apache.hadoop.hdfs.server.federation.router.security.RouterSecurityManager.&lt;init&gt;(RouterSecurityManager.java:54)</span><br><span class="line">at org.apache.hadoop.hdfs.server.federation.router.RouterRpcServer.&lt;init&gt;(RouterRpcServer.java:262)</span><br><span class="line">at org.apache.hadoop.hdfs.server.federation.router.Router.createRpcServer(Router.java:385)</span><br><span class="line">at org.apache.hadoop.hdfs.server.federation.router.Router.serviceInit(Router.java:194)</span><br><span class="line">at org.apache.hadoop.service.AbstractService.init(AbstractService.java:164)</span><br><span class="line">at org.apache.hadoop.hdfs.server.federation.router.DFSRouter.main(DFSRouter.java:69)</span><br><span class="line">Caused by: org.apache.zookeeper.KeeperException$ConnectionLossException: KeeperErrorCode = ConnectionLoss for /hdfs-federation/RouterZkDtSecretManager/ZKDTSMRoot/ZKDTSMSeqNumRoot</span><br><span class="line">at org.apache.zookeeper.KeeperException.create(KeeperException.java:104)</span><br><span class="line">at org.apache.zookeeper.KeeperException.create(KeeperException.java:51)</span><br><span class="line">at org.apache.zookeeper.ZooKeeper.create(ZooKeeper.java:1480)</span><br><span class="line">at org.apache.curator.framework.imps.CreateBuilderImpl$11.call(CreateBuilderImpl.java:740)</span><br><span class="line">at org.apache.curator.framework.imps.CreateBuilderImpl$11.call(CreateBuilderImpl.java:723)</span><br><span class="line">at org.apache.curator.RetryLoop.callWithRetry(RetryLoop.java:109)</span><br><span class="line">at org.apache.curator.framework.imps.CreateBuilderImpl.pathInForeground(CreateBuilderImpl.java:720)</span><br><span class="line">at org.apache.curator.framework.imps.CreateBuilderImpl.protectedPathInForeground(CreateBuilderImpl.java:484)</span><br><span class="line">at org.apache.curator.framework.imps.CreateBuilderImpl.forPath(CreateBuilderImpl.java:474)</span><br><span class="line">at org.apache.curator.framework.imps.CreateBuilderImpl$4.forPath(CreateBuilderImpl.java:349)</span><br><span class="line">at org.apache.curator.framework.imps.CreateBuilderImpl$4.forPath(CreateBuilderImpl.java:291)</span><br><span class="line">at org.apache.curator.framework.recipes.shared.SharedValue.start(SharedValue.java:229)</span><br><span class="line">at org.apache.curator.framework.recipes.shared.SharedCount.start(SharedCount.java:155)</span><br><span class="line">at org.apache.hadoop.security.token.delegation.ZKDelegationTokenSecretManager.startThreads(ZKDelegationTokenSecretManager.java:321)</span><br><span class="line">... 12 more</span><br><span class="line">2022-12-29 21:13:06,168 | INFO  | main | Secret manager instance created | FederationUtil.java:215</span><br></pre></td></tr></table></figure></div><p>但如果创建的时候就失败了，按<a href="https://github.com/apache/hadoop/blob/trunk/hadoop-hdfs-project/hadoop-hdfs-rbf/src/main/java/org/apache/hadoop/hdfs/server/federation/router/security/RouterSecurityManager.java#L54">代码逻辑</a>，启动的时候应该失败：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="title function_">RouterSecurityManager</span><span class="params">(Configuration conf)</span> <span class="keyword">throws</span> IOException &#123;</span><br><span class="line">  <span class="type">AuthenticationMethod</span> <span class="variable">authMethodConfigured</span> <span class="operator">=</span></span><br><span class="line">      SecurityUtil.getAuthenticationMethod(conf);</span><br><span class="line">  <span class="type">AuthenticationMethod</span> <span class="variable">authMethodToInit</span> <span class="operator">=</span></span><br><span class="line">      AuthenticationMethod.KERBEROS;</span><br><span class="line">  <span class="keyword">if</span> (authMethodConfigured.equals(authMethodToInit)) &#123;</span><br><span class="line">    <span class="built_in">this</span>.dtSecretManager = FederationUtil.newSecretManager(conf);</span><br><span class="line">    <span class="keyword">if</span> (<span class="built_in">this</span>.dtSecretManager == <span class="literal">null</span> || !<span class="built_in">this</span>.dtSecretManager.isRunning()) &#123;</span><br><span class="line">      <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">IOException</span>(<span class="string">&quot;Failed to create SecretManager&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>最后发现看的代码和集群的版本不一致，FI 6.5.1 是基于 hadoop 3.1.1 版本，但是3.1.1 版本代码里并没有 <code>RouterSecurityManager</code>，于是拉到集群里的 jar 包反编译，发现，FI 实现的 RouterSecurityManager 没有任何的校验：</p><p><img src="2023-01-fi-router-security-manager-constructor.png" alt=""></p><p>而开源的 hadoop 在创建完后是有校验的，也找到了相关的修改<a href="https://github.com/apache/hadoop/commit/524b553a5f1c10bf41c723302cf42b592ffa1631">commit</a> 。<img src="2023-01-open-sourcing-hadoop-router-security-manager-constructor.png" alt=""></p><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>最终的问题链路是：</p><ol><li>FI 集群 Router 重启，启动过程需要创建 secret manager，具体的实现是基于 ZK的</li><li>由于某些原因在启动 <code>ZKDelegationTokenSecretManagerImpl</code> 连接 ZK 失败，导致相关的服务启动失败</li><li>但是 <code>RouterSecurityManager</code> 并没有对 secret manager 的启动状态做校验（后续版本修复），仍然继续运行</li><li>在实际创建 delegation token 时，由于 secret manager 的状态异常，创建的请求也失败</li><li>Spark Driver 在获取 delegation token 时得到一个空值</li><li>Spark Executor 需要访问 HDFS 时，会从 Driver 的 Resource Manager 中获取profile，其中包含 driver 获取的 token，由于 token 是空的，于是鉴权失败。</li></ol><p>另外几点感想：</p><ul><li>所幸问题是发生在测试环境，要发生在客户环境，估计一万年都查不出来（当然任务肯定也会失败）。</li><li>另外虽然看代码很重要，但还是应该补齐相关模块的理论知识，这样事半功倍。</li><li>重启大法好</li></ul><h2 id="参考"><a class="header-anchor" href="#参考"></a>参考</h2><ul><li><a href="https://blog.cloudera.com/hadoop-delegation-tokens-explained/">https://blog.cloudera.com/hadoop-delegation-tokens-explained/</a> Hadoopdelegation token 介绍，比较偏使用而非实现原理</li><li><a href="https://docs.google.com/document/d/1RBnXD9jMDjGonOdKJ2bA1lN4AAV_1RwpU_ewFuCNWKg/edit">https://docs.google.com/document/d/1RBnXD9jMDjGonOdKJ2bA1lN4AAV_1RwpU_ewFuCNWKg/edit</a> spark on k8s kerberos 支持的设计文档，跟实现还是有点差距</li><li><a href="https://medium.com/agile-lab-engineering/spark-remote-debugging-371a1a8c44a8">https://medium.com/agile-lab-engineering/spark-remote-debugging-371a1a8c44a8</a>spark driver/executor 远程 debug 的方法</li></ul><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>这里犯了一个错误，就是通过 kinit 成功推断集群正常。这里因为不了解 hadoop 额外的一些机制导致的，不太好避免 <a href="#fnref1" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;某一天 QA 报测试环境所有的 Spark 任务都鉴权失败。排查了好几天，分享下排查过程，大家就当看个故事。&lt;/p&gt;
&lt;p&gt;环境信息：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Spark 3.1.2, 运行采用 Spark on k8s operator&lt;/li&gt;
&lt;li&gt;数据存储在华为</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="BUG" scheme="https://lotabout.me/tags/BUG/"/>
    
    <category term="hadoop" scheme="https://lotabout.me/tags/hadoop/"/>
    
    <category term="spark" scheme="https://lotabout.me/tags/spark/"/>
    
    <category term="delegation token" scheme="https://lotabout.me/tags/delegation-token/"/>
    
  </entry>
  
  <entry>
    <title>记一次 BUG 定位：时钟偏移引起 K8S 鉴权失败</title>
    <link href="https://lotabout.me/2022/k8s-jwt-and-clock-skew/"/>
    <id>https://lotabout.me/2022/k8s-jwt-and-clock-skew/</id>
    <published>2022-11-27T17:08:36.000Z</published>
    <updated>2025-11-26T12:53:48.059Z</updated>
    
    <content type="html"><![CDATA[<h2 id="先上结论"><a class="header-anchor" href="#先上结论"></a>先上结论</h2><ol><li>K8S 中使用 ServiceAccount 时，内部本质上是用 JWT 做校验</li><li>JWT 中的 <code>nbf</code> 字段代表 token 的“开始时间”。开始时间不得早于“机器当前时间”，实际允许有 1min 偏差</li><li>如果集群节点的时钟偏差(clock skew)超过 1min，可能出现 A 节点签发的 token 开始时间过早，导致 token 在 B节点校验失败</li></ol><h2 id="排查过程"><a class="header-anchor" href="#排查过程"></a>排查过程</h2><h3 id="显示-sa-没权限-但-sa-配置都正确"><a class="header-anchor" href="#显示-sa-没权限-但-sa-配置都正确"></a>显示 SA 没权限，但 SA 配置都正确</h3><p>在 k8s 上启动的任务，会通过 <a href="http://fabric8.io">fabric8.io</a> java client 创建 SparkApplication 的Custom Resource(CR)。然而某一天开始，测试环境提交的任务全都失败，报下面的错误：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">Exception in thread &quot;main&quot; io.fabric8.kubernetes.client.KubernetesClientException: Failure executing: GET at: https://10.233.0.1/api/v1/namespaces/.../pods/xxx-pod. Message: Unauthorized! Configured service account doesn&#x27;t have access. Service account may have been revoked. Unauthorized.</span><br><span class="line">        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.requestFailure(OperationSupport.java:682)</span><br><span class="line">        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.requestFailure(OperationSupport.java:661)</span><br><span class="line">        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.assertResponseCode(OperationSupport.java:610)</span><br><span class="line">        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.handleResponse(OperationSupport.java:555)</span><br><span class="line">        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.handleResponse(OperationSupport.java:518)</span><br><span class="line">        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.handleGet(OperationSupport.java:487)</span><br><span class="line">        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.handleGet(OperationSupport.java:457)</span><br><span class="line">        at io.fabric8.kubernetes.client.dsl.base.BaseOperation.handleGet(BaseOperation.java:698)</span><br><span class="line">        at io.fabric8.kubernetes.client.dsl.base.BaseOperation.getMandatory(BaseOperation.java:184)</span><br><span class="line">        at io.fabric8.kubernetes.client.dsl.base.BaseOperation.get(BaseOperation.java:151)</span><br><span class="line">        at io.fabric8.kubernetes.client.dsl.base.BaseOperation.get(BaseOperation.java:83)</span><br><span class="line">        ...</span><br></pre></td></tr></table></figure></div><p>由于近期刚做过部署操作，开始怀疑是不是 SA 配置错了。于是人肉检查 SA</p><ul><li>get pod 检查 <code>serviceAccount</code> 和 <code>serviceAccountName</code> 的值都是符合预期的。</li><li><code>get clusterrole</code> 和 <code>get rolebinding -n &lt;namespace&gt;</code> 检查都是正确的</li></ul><h3 id="发现跟节点有关系"><a class="header-anchor" href="#发现跟节点有关系"></a>发现跟节点有关系</h3><p>SA 检查无误，于是想先抓包看看是不是网络相关的问题，在 <code>get pods -o wide</code> 时发现所有任务都调度到 <code>node2</code> 这个节点。于是先把 <code>node2</code> 下掉，直接搜了个命令：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">kubectl taint nodes node2 key1=value1:NoSchedule</span><br></pre></td></tr></table></figure></div><p>新起的任务调度到 <code>node1</code> 后发现任务都 OK。因为暂时还在搞其它事情，把这个问题汇报给 SRE 同事，就暂停了。</p><h3 id="sleep-40s-后能跑过"><a class="header-anchor" href="#sleep-40s-后能跑过"></a>sleep 40s 后能跑过</h3><p>SRE 同事做了一些尝试，发现有即使在 node2 提交，偶尔也是能通过的。期间有两个怀疑：</p><ol><li>SA 是不是过期了，但搜了搜发现一般时间还是挺长的，应该不是这个问题</li><li>由于任务是由 argo workflow 提交的，开始怀疑是不是 argo 的问题（命令是argoexec 运行的）</li></ol><p>另外 SRE 同事试着在命令执行前增加 <code>sleep 40s</code> 发现就能提交通过。</p><h3 id="修改系统时间确认相关-但解释不通"><a class="header-anchor" href="#修改系统时间确认相关-但解释不通"></a>修改系统时间确认相关，但解释不通</h3><p>在查资料时有提到是不是有同步问题，于是灵光一闪会不会跟系统时间有关，一查，两台节点的时间差是大约是 1min30s。于是把时间拔到 1min 内，发现提交的任务正常了。于是确定是和系统时间相关。但是具体的机制搞不清楚。</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">sudo date $(date +%m%d%H%M%Y.%S -d &#x27;-1 minutes&#x27;)</span><br></pre></td></tr></table></figure></div><h3 id="更多测试"><a class="header-anchor" href="#更多测试"></a>更多测试</h3><p>期间已经把测试的内容把成单纯的 curl:</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">       find / -name &quot;*.crt&quot;</span><br><span class="line">       TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)</span><br><span class="line">1. 401 curl -vik --header &quot;Authorization: Bearer $&#123;TOKEN&#125;&quot; -k https://node1:6443/api/v1/namespaces/.../pods/..</span><br><span class="line">2. 404 curl -vik --header &quot;Authorization: Bearer $&#123;TOKEN&#125;&quot; -k https://node2:6443/api/v1/namespaces/.../pods/..</span><br><span class="line">       sleep 30</span><br><span class="line">3. 404 curl -vik --header &quot;Authorization: Bearer $&#123;TOKEN&#125;&quot; -k https://node1:6443/api/v1/namespaces/.../pods/..</span><br><span class="line">4. 404 curl -vik --header &quot;Authorization: Bearer $&#123;TOKEN&#125;&quot; -k https://node2:6443/api/v1/namespaces/.../pods/..</span><br><span class="line">       find / -name &quot;*.crt&quot;</span><br></pre></td></tr></table></figure></div><p>这里有一个失误，这个 curl 用的 API 即使成功也是 404, 导致在测试的过程中会有误判，实际上测试结果 #2 几乎都是 404, 但有时候会看成是 401. 也尝试人工进入 pod执行 curl，都是通的，想不通开始的 30s 究竟触发了什么机制导致认证失败。</p><p>401 的 curl 如下所示：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">* ALPN, offering h2</span><br><span class="line">* ALPN, offering http/1.1</span><br><span class="line">* successfully set certificate verify locations:</span><br><span class="line">*  CAfile: /etc/ssl/certs/ca-certificates.crt</span><br><span class="line">*  CApath: /etc/ssl/certs</span><br><span class="line">&#125; [5 bytes data]</span><br><span class="line">* TLSv1.3 (OUT), TLS handshake, Client hello (1):</span><br><span class="line">&#125; [512 bytes data]</span><br><span class="line">* TLSv1.3 (IN), TLS handshake, Server hello (2):</span><br><span class="line">&#123; [122 bytes data]</span><br><span class="line">* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):</span><br><span class="line">&#123; [15 bytes data]</span><br><span class="line">* TLSv1.3 (IN), TLS handshake, Request CERT (13):</span><br><span class="line">&#123; [105 bytes data]</span><br><span class="line">* TLSv1.3 (IN), TLS handshake, Certificate (11):</span><br><span class="line">&#123; [1002 bytes data]</span><br><span class="line">* TLSv1.3 (IN), TLS handshake, CERT verify (15):</span><br><span class="line">&#123; [264 bytes data]</span><br><span class="line">* TLSv1.3 (IN), TLS handshake, Finished (20):</span><br><span class="line">&#123; [52 bytes data]</span><br><span class="line">* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):</span><br><span class="line">&#125; [1 bytes data]</span><br><span class="line">* TLSv1.3 (OUT), TLS handshake, Certificate (11):</span><br><span class="line">&#125; [8 bytes data]</span><br><span class="line">* TLSv1.3 (OUT), TLS handshake, Finished (20):</span><br><span class="line">&#125; [52 bytes data]</span><br><span class="line">* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384</span><br><span class="line">* ALPN, server accepted to use h2</span><br><span class="line">* Server certificate:</span><br><span class="line">*  subject: CN=kube-apiserver</span><br><span class="line">*  start date: Nov  7 08:38:44 2022 GMT</span><br><span class="line">*  expire date: Nov  7 08:38:45 2023 GMT</span><br><span class="line">*  issuer: CN=kubernetes</span><br><span class="line">*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.</span><br><span class="line">* Using HTTP2, server supports multi-use</span><br><span class="line">* Connection state changed (HTTP/2 confirmed)</span><br><span class="line">* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0</span><br><span class="line">&#125; [5 bytes data]</span><br><span class="line">* Using Stream ID: 1 (easy handle 0x55975bd06560)</span><br><span class="line">&#125; [5 bytes data]</span><br><span class="line">&gt; GET /api/v1/namespaces/argo-run/pods/4622-4622-import-529855050 HTTP/2</span><br><span class="line">&gt; Host: 172.27.128.212:6443</span><br><span class="line">&gt; user-agent: curl/7.74.0</span><br><span class="line">&gt; accept: */*</span><br><span class="line">&gt; authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImhGdk15S3F6REdtUkdXMUprelhzRTF0RHJuT2kwdlhrQWktdnphclJSSG8ifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmt1YmVybmV0ZXMiXSwiZXhwIjoxNzAxMDcyODQxLCJpYXQiOjE2Njk1MzY4NDEsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5rdWJlcm5ldGVzIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJhcmdvLXJ1biIsInBvZCI6eyJuYW1lIjoianotMjAtNTY0My01NjQzLWltcG9ydC0zOTY2MDk2NDU0IiwidWlkIjoiOTM1NzY3M2UtNWNiMi00YjVkLTk1NDAtYTM4NDY1YTE5YWQ2In0sInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJsb29mYWgiLCJ1aWQiOiI0ZTM1ZDIwZi01OGI3LTQxNWItOGZhMS01YTk5MjlkM2YyZWEifSwid2FybmFmdGVyIjoxNjY5NTQwNDQ4fSwibmJmIjoxNjY5NTM2ODQxLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6YXJnby1ydW46bG9vZmFoIn0.bfxySo3e2rT3mToSEmSN5Pmi4YI4X2kE4aXA_BVIPyrg8DKc9pDUFEo_kvS608pm0u5b7e7wG3A48upBUjtm2uAMwEYiDqSLning7kCdycXT1-_aXVQjeASio4dZL6w3ddi_JGyFoZA76e9cQVfaWB9PGenKlg2uJXe5xFNJA12EuCvXgTLC7rXrNZIPksI0ZR6bRBt2ENWf_aaYPLTE7H7g8TJlYfP__H5DBaBr6sRkO15q8mCKpEyIqCx-t9mf6pCWfJ3D2KOBMc01n8g55EUvlaPDFngn5eV3izfMxuJADB4QqrVt_-mIgpPbJr3j3H5wYHmzcCSvTVg_Cp32Zg</span><br><span class="line">&gt;</span><br><span class="line">&#123; [5 bytes data]</span><br><span class="line">* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):</span><br><span class="line">&#123; [146 bytes data]</span><br><span class="line">* Connection state changed (MAX_CONCURRENT_STREAMS == 250)!</span><br><span class="line">&#125; [5 bytes data]</span><br><span class="line">&lt; HTTP/2 401</span><br><span class="line">&lt; audit-id: 1bdd5211-54ea-4b3e-ac14-d7716730166a</span><br><span class="line">&lt; cache-control: no-cache, private</span><br><span class="line">&lt; content-type: application/json</span><br><span class="line">&lt; content-length: 165</span><br><span class="line">&lt; date: Sun, 27 Nov 2022 08:12:15 GMT</span><br><span class="line">&lt;</span><br><span class="line">&#123; [5 bytes data]</span><br><span class="line">100   165  100   165    0     0  11785      0 --:--:-- --:--:-- --:--:-- 12692</span><br><span class="line">* Connection #0 to host 172.27.128.212 left intact</span><br><span class="line">HTTP/2 401</span><br><span class="line">audit-id: 1bdd5211-54ea-4b3e-ac14-d7716730166a</span><br><span class="line">cache-control: no-cache, private</span><br><span class="line">content-type: application/json</span><br><span class="line">content-length: 165</span><br><span class="line">date: Sun, 27 Nov 2022 08:12:15 GMT</span><br><span class="line"></span><br><span class="line">&#123;</span><br><span class="line">  &quot;kind&quot;: &quot;Status&quot;,</span><br><span class="line">  &quot;apiVersion&quot;: &quot;v1&quot;,</span><br><span class="line">  &quot;metadata&quot;: &#123;</span><br><span class="line"></span><br><span class="line">  &#125;,</span><br><span class="line">  &quot;status&quot;: &quot;Failure&quot;,</span><br><span class="line">  &quot;message&quot;: &quot;Unauthorized&quot;,</span><br><span class="line">  &quot;reason&quot;: &quot;Unauthorized&quot;,</span><br><span class="line">  &quot;code&quot;: 401</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure></div><p>接下来走了个弯路，因为看到日志时的 <code>HTTP/2 401</code>，扫了一眼看到是中间 Debug 日志输出，就以为是 TLS 握手的过程中出的错。中途 Debug 了很久 TLS 相关的内容。后来看 k8s apiserver 的日志才恍然大悟这个 401 是 apiserver 给出来的。</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">E1123 11:53:33.385964       1 claims.go:126] unexpected validation error: *errors.errorString</span><br><span class="line">E1123 11:53:33.386042       1 authentication.go:63] &quot;Unable to authenticate the request&quot; err=&quot;[invalid bearer token, Token could not be validated.]&quot;</span><br></pre></td></tr></table></figure></div><h3 id="jwt-有-1min-差异-但时间是在哪定义的？"><a class="header-anchor" href="#jwt-有-1min-差异-但时间是在哪定义的？"></a>JWT 有 1min 差异，但时间是在哪定义的？</h3><p>找到是 K8S 的问题，就去找 k8s 的日志，找了很长时间找到了</p><ul><li><a href="https://github.com/kubernetes/kubernetes/blob/release-1.21/pkg/serviceaccount/claims.go#L126">错误位置</a> 显示它是在校验 JWT 的 public claims 里的时间字段，和当前字段是否一致</li><li>在<a href="https://github.com/kubernetes/kubernetes/blob/release-1.21/vendor/gopkg.in/square/go-jose.v2/jwt/validation.go#L97">校验时</a> 默认会有 1min 的余地</li></ul><p>并且看代码它会对比 JWT 里的 <code>NotBeforeTime</code> 字段。于是通过 <code>get secrets</code> 拿到token，并在 <a href="https://jwt.io/">jwt.io</a> 里解析，奇怪的是并没有看到时间相关的字段</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">k -n &lt;ns&gt; get secret &lt;SA-secret-name&gt; -o jsonpath=&#x27;&#123;.data.token&#125;&#x27; | base64 --decode</span><br></pre></td></tr></table></figure></div><p>payload 如下</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;iss&quot;</span><span class="punctuation">:</span> <span class="string">&quot;kubernetes/serviceaccount&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;kubernetes.io/serviceaccount/namespace&quot;</span><span class="punctuation">:</span> <span class="string">&quot;...&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;kubernetes.io/serviceaccount/secret.name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;...&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;kubernetes.io/serviceaccount/service-account.name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;...&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;kubernetes.io/serviceaccount/service-account.uid&quot;</span><span class="punctuation">:</span> <span class="string">&quot;4e35d20f-58b7-415b-8fa1-5a9929d3f2ea&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;sub&quot;</span><span class="punctuation">:</span> <span class="string">&quot;system:serviceaccount:argo-run:loofah&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure></div><p>这里其实又有个失误，其实很早之前就已经在 pod 里打印出 pod 里读到的 token，但一直以为 pod 里拿到的 token 和 <code>get secrets</code> 的结果是一样的。对比了半天才发现它们不一样，终于找到时间字段：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;aud&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="string">&quot;https://kubernetes.default.svc.kubernetes&quot;</span></span><br><span class="line">  <span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;exp&quot;</span><span class="punctuation">:</span> <span class="number">1701074189</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;iat&quot;</span><span class="punctuation">:</span> <span class="number">1669538189</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;iss&quot;</span><span class="punctuation">:</span> <span class="string">&quot;https://kubernetes.default.svc.kubernetes&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;kubernetes.io&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;namespace&quot;</span><span class="punctuation">:</span> <span class="string">&quot;argo-run&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;pod&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;...&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;uid&quot;</span><span class="punctuation">:</span> <span class="string">&quot;d7f59189-7050-4922-804f-075e5411b950&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;serviceaccount&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;...&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;uid&quot;</span><span class="punctuation">:</span> <span class="string">&quot;4e35d20f-58b7-415b-8fa1-5a9929d3f2ea&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;warnafter&quot;</span><span class="punctuation">:</span> <span class="number">1669541796</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;nbf&quot;</span><span class="punctuation">:</span> <span class="number">1669538189</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;sub&quot;</span><span class="punctuation">:</span> <span class="string">&quot;system:serviceaccount:...&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure></div><p>通过查 JWT 的说明知道 <code>nbf</code> 字段就是 <code>NotBefore</code> 的时间。对比实际的值也发现和<code>node2</code> 运行任务的时间非常接近。终于破案。</p><h2 id="情况复盘"><a class="header-anchor" href="#情况复盘"></a>情况复盘</h2><ol><li>B 节点的时间比 A 节点快 1min30s</li><li>任务被调度到 B 节点，B 节点的 kubelet 为 Pod 生成 SA token，token 的 <code>nbf</code>时间为 B 节点的当前时间。（这里应该是创建 token 的请求会发往 B 的 apiserver，目前没找到方法验证）</li><li>B 节点里需要访问 apiserver，会访问 <code>kubernetes.default</code>，请求被路由到节点 A</li><li>A 节点在校验 JWT 时发现 token 的 <code>nbf</code> 在 A 节点当前时间+ 1min 之后，拒绝请求</li></ol><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>这个问题从断断续续排查了近一周，中间还是有不少失误</p><ol><li>构造测试用例，结果的判断最好清晰明确，这次排查依赖看结果是 401 还是 404, 看错了好几次，影响判断</li><li>有条件的话，一些现象要相互印证。中途跑去怀疑 TLS 握手浪费了不少时间</li><li>数据和信息要贴源，因为没有识别出 <code>get secrets</code> 和 pod 里 token 的区别，又浪费了半天时间</li><li>底层机制是会咬人的，从方案和运维的视角，要会机制上防止出现相关问题，太难查了</li></ol>]]></content>
    
    
      
      
    <summary type="html">&lt;h2 id=&quot;先上结论&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#先上结论&quot;&gt;&lt;/a&gt;先上结论&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;K8S 中使用 ServiceAccount 时，内部本质上是用 JWT 做校验&lt;/li&gt;
&lt;li&gt;JWT 中的 &lt;code&gt;nb</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="k8s" scheme="https://lotabout.me/tags/k8s/"/>
    
    <category term="BUG" scheme="https://lotabout.me/tags/BUG/"/>
    
    <category term="ServiceAccount" scheme="https://lotabout.me/tags/ServiceAccount/"/>
    
    <category term="JWT" scheme="https://lotabout.me/tags/JWT/"/>
    
  </entry>
  
  <entry>
    <title>去 TM 的全局最优</title>
    <link href="https://lotabout.me/2022/Greedy-me/"/>
    <id>https://lotabout.me/2022/Greedy-me/</id>
    <published>2022-07-09T15:12:36.000Z</published>
    <updated>2025-11-26T12:53:47.996Z</updated>
    
    <content type="html"><![CDATA[<p>现在还是未来？相信绝大多数人会选择现在。</p><p>常在想，自己工作里遇到的代码，为什么有那么多屎山？不管是加入前已经存在的，还是加入后新写的；不管是别人写的还是自己写的，仿佛不是屎山就是在变成屎山。今天主要在长短期决策上吐吐糟。</p><p><strong>生存永远大于发展</strong>。这个功能如果这样做，未来修改的代价比较大。没事，我们先做成这样，如果没有成果，说不准明年就没有我们了。真的活到第二年了，似乎当时的困难就不存在了，要求继续全速前进。</p><p><strong>未来的未来再说</strong>。“这期先这样设计，下个迭代我们再优化”，第二个迭代到来，“这个迭代这些需求优先级比较高，优化放下一个迭代吧”，子子孙孙无穷尽也。</p><p><strong>先看看怎么跑通</strong>。这期能跑通就不错了，哪顾得上代码优雅不优雅，反正后面屎山维护不了，大不了跑路呗？我又不和公司和团队共存亡。</p><p>贪心算法和动态规划，我们知道通常贪心算法得不到全局最优。软件开发上，如果总是选择现在的利益，忽略未来，则注定会走向死亡。但一来不这么搞我现在就没了，二来说不准未来锅不是我背呢？再来谁知道现做的准备未来能用上呢？去 TM 的全局最优。</p>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;现在还是未来？相信绝大多数人会选择现在。&lt;/p&gt;
&lt;p&gt;常在想，自己工作里遇到的代码，为什么有那么多屎山？不管是加入前已经存在的，还是加入后新写的；不管是别人写的还是自己写的，仿佛不是屎山就是在变成屎山。今天主要在长短期决策上吐吐糟。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;生存永远</summary>
      
    
    
    
    <category term="Post" scheme="https://lotabout.me/categories/Post/"/>
    
    
  </entry>
  
  <entry>
    <title>iptables 实用教程</title>
    <link href="https://lotabout.me/2022/Horrible-Iptables-tutorials/"/>
    <id>https://lotabout.me/2022/Horrible-Iptables-tutorials/</id>
    <published>2022-06-23T20:38:11.000Z</published>
    <updated>2025-11-26T12:53:47.997Z</updated>
    
    <content type="html"><![CDATA[<p>最近在搞科学上网，抄了一些 iptables 的规则不管用，干脆好好学习一番，写一写我的理解。</p><h2 id="iptables-是一门配置语言"><a class="header-anchor" href="#iptables-是一门配置语言"></a>Iptables 是一门配置语言</h2><p>它是一门配置语言。用来在网络处理的各个环节里加 Hook。常见的用途是做防火墙，做流量的转发等等。</p><p>像学习其它语言一样，语言本身有语法，语法之外还需要学习库函数。iptables 的语法大概如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight sh"><table><tr><td class="code"><pre><span class="line">iptables [-t table] &#123;-I | -A | -D | -R&#125; chain rule_specification</span><br></pre></td></tr></table></figure></div><p>iptables 里有 <code>table</code> 和 <code>chain</code> 的概念，代表机器处理网络包的各个阶段，因此在指定配置时需要先指定配置在哪个阶段生效。之后是配置处理的规则，规则语法如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">rule-specification = [matches...] [target]</span><br><span class="line"></span><br><span class="line">match = -m matchname [per-match-options]</span><br><span class="line"></span><br><span class="line">target = -j targetname [per-target-options]</span><br></pre></td></tr></table></figure></div><p>一个规则可以有多个 <code>match</code> 匹配条件，以及一个 <code>target</code> 作为目标。它表明当一个网络包命中这些规则时，执行 <code>target</code> 目标。另外，iptables 是可（由其它模块）扩展的，扩展会提供新的 match 和新的 target。我们先看一个典型示例：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight sh"><table><tr><td class="code"><pre><span class="line">iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-ports 7892</span><br></pre></td></tr></table></figure></div><p>这个规则的作用是将所有的 <code>tcp</code> 流量，全部转发到 <code>7892</code> 端口。这里的 <code>-p tcp</code>条件选中 tcp 流量是 iptables 默认支持的，但 <code>REDIRECT</code> 转发操作是扩展提供的。</p><h2 id="table-and-chain"><a class="header-anchor" href="#table-and-chain"></a>Table and Chain</h2><p>要学习语言，要先了解语言背后的执行模型（类比栈、指针等），iptables 的作用是在各个环节里增加 hook，那有哪些 hook 可以用呢？先看下图<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight text"><table><tr><td class="code"><pre><span class="line">---&gt;PRE------&gt;[ROUTE]---&gt;FWD----------&gt;POST------&gt;</span><br><span class="line">                 |                ^</span><br><span class="line">                 |                |</span><br><span class="line">                 |             [ROUTE]</span><br><span class="line">                 v                |</span><br><span class="line">                 IN              OUT</span><br><span class="line">                 |                ^</span><br><span class="line">                 v                |</span><br></pre></td></tr></table></figure></div><p>一个包从左侧进入系统，先到 <code>PRE</code> 环节。接着进入 <code>[ROUTE]</code> 阶段做路由，来决定包的去向。如果本机是目标地址则接收，否则尝试转发，亦或者丢弃。</p><p>对于本机接收的包，触发 <code>IN</code> 环节后交给对应的应用程序；转发的包在触发 <code>FWD</code>环节后尝试向外发包。外出的包最后还会经过 <code>POST</code> 环节，做最后的处理后发往网卡。</p><p>本机应用程序发出的包，会先经过 <code>OUT</code> 环节处理，之后经过 <code>[ROUTE]</code> 决定去向<sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup>，最终再经过 <code>POST</code> 环节后发出。</p><p>在这些 hook 的基础上，iptables 用 “table” 的概念来组织常见的包修改需求。例如：</p><ul><li>Filter: 来做包过度</li><li>Nat: 做地址转换</li><li>Mangle: 其它的通用的包修改</li><li>Raw: 处理一些 connection track 生效之前的修改</li></ul><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight text"><table><tr><td class="code"><pre><span class="line"># modified from https://www.netfilter.org/documentation/HOWTO/netfilter-hacking-HOWTO.txt</span><br><span class="line"></span><br><span class="line">---&gt;PRE------&gt;[ROUTE]---&gt;FWD----------&gt;POST------&gt;</span><br><span class="line">    Raw          |       Mangle   ^    Mangle</span><br><span class="line">    ConnTrack    |       Filter   |    NAT (Src)</span><br><span class="line">    Mangle       |                |</span><br><span class="line">    NAT (Dst)    |             [ROUTE]</span><br><span class="line">                 v                |</span><br><span class="line">                 IN Mangle       OUT Filter</span><br><span class="line">                 |  NAT           ^  NAT (Dst)</span><br><span class="line">                 |  Filter        |  Mangle</span><br><span class="line">                 |                |  ConnTrack</span><br><span class="line">                 v                |  Raw</span><br></pre></td></tr></table></figure></div><p>具体使用时，先决定要做的修改是什么内容，决定 table 名，然后找到 hook 的时机，决定 chain 的名字。当然 iptables 允许用户增加自己的 chain，但用户增加的 chain并不能决定 hook 的时机。</p><p>例如下面的例子里，我们要把所有流量转发到 <code>7892</code> 端口，我们通过 <code>man iptables-extensions</code> 查到，它只能加到 <code>nat</code> 表的 <code>PREROUTING</code> 或 <code>OUTPUT</code>链，由于我们要转发入口流量，所以修改的是 <code>PREROUTING</code> chain。</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight sh"><table><tr><td class="code"><pre><span class="line">iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-ports 7892</span><br></pre></td></tr></table></figure></div><p><code>REDIRECT</code> 的限制也很容易理解，转发需要支持源、目标地址的改写，因此属于 <code>nat</code>表的范畴，而它需要在路由之前做修改（否则改了也发不出去），所以只能在<code>PREROUTING</code> 和 <code>OUTPUT</code> hook 里处理。</p><h2 id="rule-执行顺序"><a class="header-anchor" href="#rule-执行顺序"></a>Rule 执行顺序</h2><p>上面我们提到 iptables 是通过 table, chain 来组织切入点的，一个 chain 上可以配置多条规则，用户还可以自己创建 chain 来管理规则。那么 iptables 在是如何使用这些规则的呢？</p><p>正常情况下规则会一条条向下匹配，iptables 有一些特殊的 target 也提供了一些特殊的操作来在规则中跳转的能力（可以类比编程语言中的 <code>continue</code>, <code>break</code>），如下图：</p><img src="/2022/Horrible-Iptables-tutorials/iptables-order.svg" class="" title="execution order"><ul><li>JUMP(<code>-j &lt;chain&gt;</code>)：跳转到自定义的 chain 里</li><li>ACCEPT：流量通过当前 table + chain，不再匹配任何规则</li><li>RETURN：从当前 chain 跳出，回到上一个 chain 跳转的位置</li><li>DROP：丢弃流量，不再匹配任何 table 任何 chain</li></ul><p>此外也得注意一些扩展 target 的语义，如 <code>REDIRECT</code> 相当于 <code>ACCEPT</code>；如 <code>REJECT</code>相当于 <code>DROP</code>，会在发送终止包后丢弃数据包。实操如果发现有问题，要注意是不是规则顺序引起的。</p><h2 id="常用的扩展"><a class="header-anchor" href="#常用的扩展"></a>常用的扩展</h2><p>上面我们了解了 iptables 的语法和执行顺序，接下来要学习“库函数”，表面上学习库函数就是学习“扩展”提供了哪些 match 和 target，但真正的难点是学习它们背后的网络处理机制。这里我们简单提几个。</p><h3 id="fwmark"><a class="header-anchor" href="#fwmark"></a>fwmark</h3><p>Firewall Mark(fwmark) 可以理解成一个 iptables 的扩展，它提供了 <code>MARK</code> 和<code>CONNMARK</code> 的 target，允许我们把一个数据包或一个连接打上标记。之后在其它地方可以使用这个标记。</p><p>典型的使用方式是让有某个标记的流量走某个特殊的路由表<sup class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup>，例如：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight sh"><table><tr><td class="code"><pre><span class="line">ip rule add fwmark 1 table 100</span><br><span class="line">ip route add <span class="built_in">local</span> default dev lo table 100</span><br><span class="line"></span><br><span class="line">iptables -t mangle -A OUTPUT -p udp -d 198.18.0.0/16 -j MARK --set-mark 1</span><br></pre></td></tr></table></figure></div><p>其中的 <code>ip rule add fwmark 1 table 100</code> 是创建了一张名为 <code>100</code> 的路由表，并指定当 <code>fwmark</code> 为 1 时才查这张表。而下面的规则指定了 <code>-p udp</code> 匹配 UDP 流量，且目标地址为 <code>-d 198.18.0.0/16</code> 时执行 <code>-j MARK</code> 操作，把数据包打上 <code>--set-mark 1</code> 这个标记。</p><p>成果是目标地址为 <code>198.18.0.0/16</code> 的 UDP 流量会查 100 路由表。</p><h3 id="nat-snat-dnat-masquerade"><a class="header-anchor" href="#nat-snat-dnat-masquerade"></a>NAT: SNAT, DNAT, MASQUERADE</h3><p>Network Address Translation 的变种比较多，但思路还是容易理解的。在网络隔离的情况下，如果想两个网段里交换网络包，则需要在路由器（能同时访问两个网段）里对包做地址转换，如下所示：</p><img src="/2022/Horrible-Iptables-tutorials/iptables-NAT.svg" class="" title="NAT"><p>SNAT 是换了源 IP 字段，所以一般用于出口流量；DNAT 换了目标 IP 字段，所以一般用于做“端口映射”来穿透内网。可以看到不论是 SNAT 还是 DNAT 都需要提供目标的 IP 地址。而 <code>MASQUERADE</code> 可以理解成 SNAT 的变种，它可以自动填写对应网卡的 IP，不需要手工指定了，一般用于路由器流量内外网转发。</p><p>另外从图里看到，无论是 SNAT 还是 DNAT，都需要维护一张 NAT 映射表，可以通过<code>conntrack -L</code> 看到。如果在路由器的 SNAT 里，<code>--to-source</code> IP 不是本机会怎么样呢？连接会建立失败，路由还是正常记录了 NAT 映射表，但 ACK 包会直接发到<code>--to-source</code> IP 上，被丢弃。</p><p>额外的，TCP 流量只有在连接建立时会查 iptables NAT 表，同个连接后续的包会沿用建立连接时的规则。</p><h2 id="参考"><a class="header-anchor" href="#参考"></a>参考</h2><ul><li><a href="https://www.digitalocean.com/community/tutorials/a-deep-dive-into-iptables-and-netfilter-architecture">A Deep Dive into Iptables and Netfilter Architecture</a> 介绍了 hook 和 table 的作用</li><li><a href="https://lucid.app/lucidchart/eb1b46d7-653f-4c5a-b421-ba8c075fb278/view?page=0_0#">Iptables Flow</a> 一个简化但容易理解的 iptables 流程图</li><li><a href="https://upload.wikimedia.org/wikipedia/commons/3/37/Netfilter-packet-flow.svg">Packet flow in Netfilter and General Networking</a> 一张复杂但全面的流程图</li><li><code>man iptables-extensions</code> 各种扩展支持的 match, target 都有说明</li></ul><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p><a href="https://www.netfilter.org/documentation/HOWTO/netfilter-hacking-HOWTO.txt">https://www.netfilter.org/documentation/HOWTO/netfilter-hacking-HOWTO.txt</a> <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p>按文档所说，实际上路由的代码在 <code>OUT</code> 之前就被调用，用来获取源 IP 和一些其它的 IP 选项 <a href="#fnref2" class="footnote-backref">↩</a></p></li><li id="fn3"  class="footnote-item"><p><a href="https://lancellc.gitbook.io/clash/start-clash/clash-udp-tproxy-support">https://lancellc.gitbook.io/clash/start-clash/clash-udp-tproxy-support</a> <a href="#fnref3" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;最近在搞科学上网，抄了一些 iptables 的规则不管用，干脆好好学习一番，写一写我的理解。&lt;/p&gt;
&lt;h2 id=&quot;iptables-是一门配置语言&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#iptables-是一门配置语言&quot;&gt;&lt;/a&gt;Iptab</summary>
      
    
    
    
    <category term="Knowledge" scheme="https://lotabout.me/categories/Knowledge/"/>
    
    
    <category term="network" scheme="https://lotabout.me/tags/network/"/>
    
    <category term="iptables" scheme="https://lotabout.me/tags/iptables/"/>
    
  </entry>
  
  <entry>
    <title>Lamport 时钟与 Vector 时钟</title>
    <link href="https://lotabout.me/2022/Lamport-Clock-and-Vector-Clock/"/>
    <id>https://lotabout.me/2022/Lamport-Clock-and-Vector-Clock/</id>
    <published>2022-05-08T20:26:57.000Z</published>
    <updated>2025-11-26T12:53:48.004Z</updated>
    
    <content type="html"><![CDATA[<p>Lamport 时钟之前一直似懂非懂，今天看了 Martin Kleppmann 的<a href="https://www.youtube.com/watch?v=x-D8iFU1d-o">教学视频</a>，觉得自己又行了。</p><h2 id="因果关系与物理时钟"><a class="header-anchor" href="#因果关系与物理时钟"></a>因果关系与物理时钟</h2><p>假设你发了朋友圈，有两个朋友评论：</p><ul><li>A 说：“这是在北京吧”</li><li>B 回复 A 说：“应该不是，看着像上海”</li></ul><p>我们人肉能识别出两句话之间的因果关系：<code>#A</code> 是因，<code>#B</code> 是果，但是计算机怎么判断呢？</p><p>一种思路是给评论加上生成时间，比如 <code>#A_10:01</code>, <code>#B_10:02</code>，系统按时间对评论排序，就能判断 <code>#B</code> 发生成 <code>#A</code> 之后。这个方法逻辑上没问题，但现实中没有一种可靠的方法，能准确地同步各个机器上的时间（也称为物理时间）。于是可能出现下面的情况：</p><img src="/2022/Lamport-Clock-and-Vector-Clock/causal.svg" class="" title="Physical Clock"><p>处理 <code>B</code> 评论的机器时钟慢了，导致 <code>B</code> 评论的时间戳更小，系统排序时把 <code>#B</code> 放在了前面，因果错乱。</p><h2 id="lamport-时钟"><a class="header-anchor" href="#lamport-时钟"></a>Lamport 时钟</h2><p>Lamport 时钟<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>是一种逻辑上的机制，用来给各个事件打标签，保证如果事件 <code>A</code> 发生于 <code>B</code> 之前，则 <code>A</code> 的标签 <code>L(A)</code> 一定小于 <code>B</code> 的标签 <code>L(B)</code>。</p><p>具体要怎么做呢？每个机器各自维护一个计数器 <code>t</code>，然后：</p><ol><li>初始化时，每个机器都把 <code>t</code> 置为 <code>0</code></li><li>本机产生一个事件时，先执行 <code>t = t+1</code>，再用自增后的 <code>t</code> 来标记事件</li><li>要发送一个事件时，执行 <code>t = t+1</code>，并发送 <code>(t, m)</code>，即把计数器和事件都发出去</li><li>接收到一个事件 <code>(t', m)</code> 时，则需要更新本地的计数器 <code>t = max(t, t') + 1</code>，并把 <code>m</code> 发送到本地</li></ol><p>于是如果使用这个算法，则上面朋友圈的例子就变成了：</p><img src="/2022/Lamport-Clock-and-Vector-Clock/lamport-clock.svg" class="" title="Lamport Clock"><p>可以看到事件 <code>(4, 这是北京吧)</code> 发生在 <code>(6, 应该不是)</code>之前，它们的标签 <code>t</code> 能反映出这一点。</p><h3 id="lamport-时钟的局限"><a class="header-anchor" href="#lamport-时钟的局限"></a>Lamport 时钟的局限</h3><p>为什么 Lamport 时钟能体现事件发生的“因果”关系？如果两个事件有“因果”，它们一定是有“同步”的操作，而 Lamport 时钟则是在“同步”时（第 #4 点），通过 <code>max(t, t')</code>同步了二者的逻辑时间。</p><img src="/2022/Lamport-Clock-and-Vector-Clock/lamport-clock-sync.svg" class="" title="Lamport Clock"><p>由于 <code>A-Before</code> 的事件满足 <code>t &lt;= T</code>，而 <code>B-After</code> 的事件满足 <code>t &gt;= T+1</code>，所以能保证 <code>A-before &lt;= T &lt; T+1 &lt;= B-after</code>，而 <code>B-after</code> 中的事件逻辑上是发生成<code>A-before</code> 的事件之后的，且标签 <code>t</code> 也满足先后关系，因此保证了因果顺序。</p><p>但是在上图中，我们虽然推出 <code>A-before &lt; B-after</code>，但其它几个区域发生的事件就没法有确定的对比结论了。例如所有 <code>B-before</code> 中的事件，一定发生成 <code>A-after</code> 中的事件之前吗？（<code>B-before &lt; A-after</code>），细想一下会发现并没有办法得出这个结论。明确可比的有这几个区域：</p><ul><li><code>A-before &lt; A-after</code>，A 机事件发生的先后决定</li><li><code>B-before &lt; B-after</code>，B 机事件发生的先后决定</li><li><code>A-before &lt; B-after</code>，A、B 之间的因果性决定</li></ul><p>从另一个角度看，Lamport 时钟可以保证如果事件 <code>a &lt; b</code>（<code>a</code> 发生在 <code>b</code> 之前），就可以推出它们的标签满足 <code>L(a) &lt; L(b)</code>。但反过来，如果看到两个标签 <code>L(a) &lt; L(b)</code>，能反推出 <code>a &lt; b</code> 吗？其实是不行的，因为我们能判定的只有 <code>A-before</code> 和<code>B-after</code> 两个区域的事件，但只看 <code>L(a)</code> 和 <code>L(b)</code> 我们并不知道 <code>a</code> 和 <code>b</code> 落在哪个区域，因此无法判断 <code>a</code> 和 <code>b</code> 发生的先后。这就是 Lamport 时钟的局限性，</p><h2 id="vector-时钟"><a class="header-anchor" href="#vector-时钟"></a>Vector 时钟</h2><p>vector 时钟可以解决这个问题：如果两个事件落在可比较的区域，则通过对比 vector时钟产生的标记，可以得出对应事件发生的先后顺序，即通过 <code>L(a) &lt; L(b)</code> 可以得出<code>a &lt; b</code> 的结论。那 vector 时钟是怎么做到的？</p><ol><li>假设有 N 台机器，记为 <code>N[1], N[2], ..N[n]</code></li><li>每台机器需要维护一个 N 维向量作为计数器，记为 <code>T = &lt;t1, t2, ..., tn&gt;</code></li><li><code>N[i]</code> 本机产生一个事件时，就把本机向量里的 <code>ti</code> 递增，即 <code>T[i]++</code></li><li>机器 <code>N[i]</code> 发送消息 <code>m</code> 时，先执行 <code>T[i]++</code>，再发送 <code>(T, m)</code></li><li>机器 <code>N[j]</code> 收到消息 <code>(T', m)</code> 时，执行 <code>T = max(T, T')</code>，再执行 <code>T[j]++</code></li></ol><p>这些规则看起来很复杂，但实际上它和 Lamport 时钟的“同步逻辑”一样，只是每个节点都保存了其它所有节点，最后一次同步过的计数器。执行起来如下图<sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup>：</p><img src="/2022/Lamport-Clock-and-Vector-Clock/vector-clock.svg" class="" title="Vector Clock"><p>Vector 时钟最后的标签有多维，如何比较呢？vector 时钟要求，如果每一维上，都有<code>T[i] &lt; T'[i]</code>，则认为 <code>T &lt; T'</code>；如果每一维都有 <code>T[i] = T'[i]</code>，则认为 <code>T = T'</code>；其它情况，都认为 <code>T</code> 和 <code>T'</code> 不可比。</p><p>条件 <code>a &lt; b</code> 推出 <code>T(a) &lt; T(b)</code> 的结论是比较简单的，与 Lamport 时钟类似，这里给个图，不多说明了：</p><img src="/2022/Lamport-Clock-and-Vector-Clock/vector-clock-sync.svg" class="" title="Vector Clock Sync"><p>从 <code>T(a) &lt; T(b)</code> 反推 <code>a &lt; b</code> 呢？其实从 <code>T</code> 的定义来看，可以理解成 <code>T</code> 代表的是当前事件及之前发生的所有事件的集合，而 <code>T(a) &lt; T(b)</code> 可以等价于集合的从属关系，那么事件 <code>a</code> 一定包含在 <code>T(b)</code> 里，因此 <code>a &lt; b</code><sup class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup>。</p><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>Lamport 时钟解决的是分布式系统下的因果一致性问题，方式是在多机有交互时求计数器的 <code>max</code>。它的局限是无法从计数器的大小反推事件的先后顺序。</p><p>Vector 时钟基本思路和 Lamport 时钟一样，但它在每个机器上都维护了最后看到的，其它机器的计数器。</p><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>Lamport Clock，也称为<a href="https://en.wikipedia.org/wiki/Lamport_timestamp">Lamport Timestamp</a>，以发明者 Leslie Lamport 命名，Lamport 也是著名的 Paxos 的发明者。 <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p>注意这张图和上面 lamport 时钟的示例，算法的细节上有简化，收到信息时没有递增计数器 <a href="#fnref2" class="footnote-backref">↩</a></p></li><li id="fn3"  class="footnote-item"><p>写到这里的时候受到知识的诅咒了，不管是从图像来看，还是从集合的视角来看，都太显然了，如果读者没理解的话，推荐看 Martin Kleppmann 的教程，说得比我明白。当然他的教程里有数学表示，更精确。 <a href="#fnref3" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;Lamport 时钟之前一直似懂非懂，今天看了 Martin Kleppmann 的
&lt;a href=&quot;https://www.youtube.com/watch?v=x-D8iFU1d-o&quot;&gt;教学视频&lt;/a&gt;，觉得自己又行了。&lt;/p&gt;
&lt;h2 id=&quot;因果关系与物理时钟&quot;</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="consistency" scheme="https://lotabout.me/tags/consistency/"/>
    
    <category term="lamport clock" scheme="https://lotabout.me/tags/lamport-clock/"/>
    
    <category term="vector clock" scheme="https://lotabout.me/tags/vector-clock/"/>
    
  </entry>
  
  <entry>
    <title>伪共享（False Sharing）简介</title>
    <link href="https://lotabout.me/2022/False-Sharing-Introduction/"/>
    <id>https://lotabout.me/2022/False-Sharing-Introduction/</id>
    <published>2022-05-01T14:22:45.000Z</published>
    <updated>2025-11-26T12:53:47.995Z</updated>
    
    <content type="html"><![CDATA[<p>如果你阅读过 Java 的 <code>Striped64</code> 源码（没看过的可以看看博主的<a href="https://lotabout.me/books/Java-Concurrency/Source-Atomic/Striped64.html">这篇文章</a>），可能遇到过 <code>@Contended</code> 注解。如果你经常看 C 语言的代码，也可能遇到过在结构体加 padding 的情形。它们都是为了提高缓存的性能，解决伪共享（False Sharing）的问题。</p><h2 id="缓存行-cache-line"><a class="header-anchor" href="#缓存行-cache-line"></a>缓存行（cache line）</h2><p>首先需要知道一个概念：cache line（缓存行）。缓存从内存加载数据时，并不是只加载我们请求的那部分，而是会多加载一些，例如我们想访问一个 int，只有 4 字节，但缓存会一次性加载 64 字段（不同机器不同）。缓存每次处理的这一“块”数据，就叫 cacheline。</p><p>为什么缓存要多加载数据呢？为了利用空间局部性（space locality）来提高性能<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>。相当于是缓存做了猜想，后续地址的数据，通常就是接下来马上要访问的数据，提前加载能提高性能。</p><h2 id="缓存一致性与-mesi-协议"><a class="header-anchor" href="#缓存一致性与-mesi-协议"></a>缓存一致性与 MESI 协议</h2><p>现代 CPU 体系中，一般每个核都单独配备了自己的（L1）缓存，为了保证多个CPU 在读写缓存时保证整体数据的一致性，通常需要使用缓存一致性协议，MESI 就是其中一种（可以参考博主的<a href="https://lotabout.me/2022/MESI-Protocol-Introduction/">这篇文章</a>）。</p><p>MESI 协议可以简单理解为“踢人协议”，如果一个 CPU 写数据到缓存里，则需要“踢”掉其它缓存里的副本。</p><img src="/2022/False-Sharing-Introduction/MESI-invalidate.svg" class="" title="MESI invalidate"><p>上图中，第 ⑦ 步就是“踢人”的操作。同时要注意如果 CPU 对缓存只做“读”操作，缓存也是需要同步的，如上图的第 ⑤ 步，只是它的开销更小。</p><h2 id="伪共享-false-sharing"><a class="header-anchor" href="#伪共享-false-sharing"></a>伪共享（False Sharing）</h2><p>缓存一致性说的是一个 cache line 在不同缓存间的同步操作。那如果一个 cache line上存了两个变量，并且两个变量分别被不同的线程写入呢？</p><img src="/2022/False-Sharing-Introduction/MESI-false-sharing.svg" class="" title="MESI invalidate"><p>可以看到，虽然 CPU A 和 CPU B 各自在写自己关心的变量 <code>x</code> 和 <code>y</code>，但由于它们存在于同一个 cache line，每次写入都会造成另一个 CPU 的缓存失效。造成严重的性能问题。</p><h2 id="常见场景与解法"><a class="header-anchor" href="#常见场景与解法"></a>常见场景与解法</h2><p>我们看到伪共享的发生有两个条件：</p><ol><li>两个变量在同一个缓存行里。通常是一个类/结构体的两个 field，或是同一个数组的相邻元素</li><li>不同线程同时对两个地址读写<sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup>。因此通常发生在高并发的场景下。</li></ol><p>由于 #2 条件多线程处理一般是业务要求，解法通常是打破 #1 条件：<strong>加 padding，让一个cache line 里只保留一个变量</strong><sup class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup>。例如 <code>int</code> 只占<code>4</code> 字节，可以在后面加 15个没用的 int 变量，撑满 <code>64</code> 字节<sup class="footnote-ref"><a href="#fn4" id="fnref4">[4]</a></sup>。而Java 专门提供了<code>@Contended</code><sup class="footnote-ref"><a href="#fn5" id="fnref5">[5]</a></sup> 来简化这种情形。</p><h2 id="false-sharing-的影响有多大？"><a class="header-anchor" href="#false-sharing-的影响有多大？"></a>False Sharing 的影响有多大？</h2><p>JMH 有一个测 False Sharing 的<a href="http://hg.openjdk.java.net/code-tools/jmh/file/251f914ff0c1/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_22_FalseSharing.java">Benchmark</a>，在我的机器上（20c、Java 11）运行结果<sup class="footnote-ref"><a href="#fn6" id="fnref6">[6]</a></sup>如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">Benchmark                          Mode  Cnt      Score     Error   Units</span><br><span class="line">BenchmarkRunner.baseline          thrpt   25  10145.377 ± 240.354  ops/us</span><br><span class="line">BenchmarkRunner.baseline:reader   thrpt   25   1305.421 ± 120.908  ops/us</span><br><span class="line">BenchmarkRunner.baseline:writer   thrpt   25   8839.956 ± 211.739  ops/us</span><br><span class="line">BenchmarkRunner.contended         thrpt   25  11329.372 ± 105.857  ops/us</span><br><span class="line">BenchmarkRunner.contended:reader  thrpt   25   2845.015 ±  48.006  ops/us</span><br><span class="line">BenchmarkRunner.contended:writer  thrpt   25   8484.357 ± 112.052  ops/us</span><br><span class="line">BenchmarkRunner.hierarchy         thrpt   25  11373.481 ±  39.691  ops/us</span><br><span class="line">BenchmarkRunner.hierarchy:reader  thrpt   25   2885.091 ±  56.386  ops/us</span><br><span class="line">BenchmarkRunner.hierarchy:writer  thrpt   25   8488.389 ±  78.959  ops/us</span><br><span class="line">BenchmarkRunner.padded            thrpt   25  11338.519 ±  49.043  ops/us</span><br><span class="line">BenchmarkRunner.padded:reader     thrpt   25   2868.776 ±  51.762  ops/us</span><br><span class="line">BenchmarkRunner.padded:writer     thrpt   25   8469.743 ±  78.427  ops/us</span><br><span class="line">BenchmarkRunner.sparse            thrpt   25   9288.740 ±  63.073  ops/us</span><br><span class="line">BenchmarkRunner.sparse:reader     thrpt   25   2582.364 ±  26.045  ops/us</span><br><span class="line">BenchmarkRunner.sparse:writer     thrpt   25   6706.376 ±  77.000  ops/us</span><br></pre></td></tr></table></figure></div><p>baseline、contended 及 padded 吞吐上的差别大概 <code>10%</code>（网上一些文章差异在 2 倍、3 倍，和我的结果出入这么大的原因还没找到）。我们再用 perf 对比 cache misses：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">---------------------------- Baseline ------------------------------------</span><br><span class="line">      1,012,415.83 msec task-clock                #   19.258 CPUs utilized</span><br><span class="line">            39,279      context-switches          #    0.039 K/sec</span><br><span class="line">             1,813      cpu-migrations            #    0.002 K/sec</span><br><span class="line">            82,632      page-faults               #    0.082 K/sec</span><br><span class="line"> 4,855,609,024,017      cycles                    #    4.796 GHz                      (50.03%)</span><br><span class="line"> 4,682,761,843,004      instructions              #    0.96  insn per cycle           (62.52%)</span><br><span class="line">   685,213,954,685      branches                  #  676.811 M/sec                    (62.53%)</span><br><span class="line">       160,820,719      branch-misses             #    0.02% of all branches          (62.51%)</span><br><span class="line"> 2,551,078,768,362      L1-dcache-loads           # 2519.793 M/sec                    (62.47%)</span><br><span class="line">    23,872,018,958      L1-dcache-load-misses     #    0.94% of all L1-dcache hits    (62.47%)</span><br><span class="line">    12,410,125,606      LLC-loads                 #   12.258 M/sec                    (49.98%)</span><br><span class="line">         2,810,037      LLC-load-misses           #    0.02% of all LL-cache hits     (50.01%)</span><br><span class="line"></span><br><span class="line">---------------------------- Contended -----------------------------------</span><br><span class="line">      1,011,118.07 msec task-clock                #   19.219 CPUs utilized</span><br><span class="line">            38,773      context-switches          #    0.038 K/sec</span><br><span class="line">             1,830      cpu-migrations            #    0.002 K/sec</span><br><span class="line">            82,441      page-faults               #    0.082 K/sec</span><br><span class="line"> 4,849,385,107,175      cycles                    #    4.796 GHz                      (49.99%)</span><br><span class="line"> 6,794,835,895,672      instructions              #    1.40  insn per cycle           (62.48%)</span><br><span class="line"> 1,006,635,368,787      branches                  #  995.567 M/sec                    (62.49%)</span><br><span class="line">       147,370,270      branch-misses             #    0.01% of all branches          (62.49%)</span><br><span class="line"> 3,585,031,069,557      L1-dcache-loads           # 3545.611 M/sec                    (62.50%)</span><br><span class="line">       615,324,166      L1-dcache-load-misses     #    0.02% of all L1-dcache hits    (62.51%)</span><br><span class="line">       167,043,519      LLC-loads                 #    0.165 M/sec                    (50.01%)</span><br><span class="line">         2,434,845      LLC-load-misses           #    1.46% of all LL-cache hits     (50.01%)</span><br></pre></td></tr></table></figure></div><p>对比其中的 <code>L1-dcache-load-misses</code>，可以看出，加了 <code>@Contended</code> 的 cache miss只有 baseline 的 <code>3%</code>。</p><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>缓存的加载写入以 cache line 为单位，典型的大小为 64B。为了保证多 CPU 下缓存数据的一致性，需要使用一些缓存一致性协议，MESI 是其中的一个经典协议，写入缓存行时会“踢掉”其它 CPU 上的缓存。如果两个变量在同一个 cache line 中，且多线程频繁读写这两个变量，会导致多 CPU “互踢”对方的 cache line，导致性能下降。在博主的机器上 False Sharing 实测大概慢 10%，而 cache miss 大概是正常的 33 倍。</p><h2 id="参考"><a class="header-anchor" href="#参考"></a>参考</h2><ul><li><a href="https://www.baeldung.com/java-false-sharing-contended">A Guide to False Sharing and @Contended</a>里面基本把 False Sharing 涉及的知识都说清楚了，推荐阅读</li><li><a href="https://shipilev.net/talks/jvmls-July2013-contended.pdf">@Contended (a.k.a. JEP 142)</a> PPT 介绍 <code>@Contended</code> 功能及实现</li><li><a href="https://www.programmersought.net/article/343975650.html">JVM series: Contend annotation and false-sharing</a> 其中的实验显示 padding 版本的吞吐大概是没有 padding 版本的 2 倍</li></ul><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>注意一般 C 语言里 padding 还有另一个作用，将数据按“字”来对齐地址，这也有助于提高性能，但缓存的视角主要还是在局部性上 <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p>两个线程同时写入的情况比较明显；一写一读也有问题；两个线程都是读则没有问题 <a href="#fnref2" class="footnote-backref">↩</a></p></li><li id="fn3"  class="footnote-item"><p>采用 padding 的方式其实不一定靠谱，因为编译器优化有可能会把没用的字段去掉 <a href="#fnref3" class="footnote-backref">↩</a></p></li><li id="fn4"  class="footnote-item"><p><code>64</code> 这个数字并不是固定的，有些机器会设置为 <code>128</code> 字节，Linux 下可以执行 <code>getconf LEVEL1_DCACHE_LINESIZE</code> 来查看 cache line 大小，MacOS 下执行 <code>sysctl hw.cachelinesize</code> <a href="#fnref4" class="footnote-backref">↩</a></p></li><li id="fn5"  class="footnote-item"><p>Java 8 中通过 <code>@sun.misc.Contended</code> 引用，<a href="https://www.javaspecialists.eu/archive/Issue249-Contended-since-9.html">Java 9 及之后</a>，通过<code>@jdk.internal.vm.annotation.Contended</code> 引用，但需要额外 export 一些包 <a href="#fnref5" class="footnote-backref">↩</a></p></li><li id="fn6"  class="footnote-item"><p>需要在启动参数上加上 <code>-XX:-RestrictContended</code>，用户代码里加的 <code>@Contended</code> 才能生效。 <a href="#fnref6" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;如果你阅读过 Java 的 &lt;code&gt;Striped64&lt;/code&gt; 源码（没看过的可以看看博主的
&lt;a href=&quot;https://lotabout.me/books/Java-Concurrency/Source-Atomic/Striped64.html&quot;&gt;这篇文</summary>
      
    
    
    
    <category term="Knowledge" scheme="https://lotabout.me/categories/Knowledge/"/>
    
    
    <category term="false sharing" scheme="https://lotabout.me/tags/false-sharing/"/>
    
    <category term="cache" scheme="https://lotabout.me/tags/cache/"/>
    
    <category term="MESI" scheme="https://lotabout.me/tags/MESI/"/>
    
  </entry>
  
  <entry>
    <title>MESI 协议学习笔记</title>
    <link href="https://lotabout.me/2022/MESI-Protocol-Introduction/"/>
    <id>https://lotabout.me/2022/MESI-Protocol-Introduction/</id>
    <published>2022-04-24T20:31:09.000Z</published>
    <updated>2025-11-26T12:53:48.020Z</updated>
    
    <content type="html"><![CDATA[<p>MESI 是一个（CPU 级别的）缓存一致性协议。看过 N 次 MESI 的 wiki 页面，一起看不进去，网上搜的一些文章，经常会介绍 MESI 的状态机和各种状态，也看得云里雾里。最近硬着头皮啃完了 wiki，感觉理解 MESI 协议的核心其实在 wiki 的第一句：</p><blockquote><p>The MESI protocol is an <strong>Invalidate-based</strong> cache coherence protocol, andis one of the most common protocols that support write-back caches.</p></blockquote><p>发现其实只要能理解什么是 “Invalidate-based”，MESI 协议就很容易理解了。在这之前先补充些相关知识。</p><h2 id="write-back-cache-写回"><a class="header-anchor" href="#write-back-cache-写回"></a>Write-Back Cache 写回</h2><p>当一份内存的数据存储在缓存时，我们有必要保证两者是一致的。假设我们修改了缓存上的数据，这份数据要如何同步回内存呢？常见的有两种方法<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>：</p><ol><li>Write-Though（直写），每次修改都同步更新到缓存和内存中</li><li>Write-Back（写回），修改先更新到缓存上，缓存快失效时才更新回内存中</li></ol><p>它们的核心区别在于更新操作是“同步”还是“异步”。显然异步的写入性能更高。</p><h2 id="cache-coherence-缓存一致性"><a class="header-anchor" href="#cache-coherence-缓存一致性"></a>Cache-Coherence 缓存一致性</h2><p>“一致性”这个词的含义深挖的话还挺深奥的，类似的内容可以参考博主的另一篇文章：<a href="https://lotabout.me/2019/QQA-What-is-Sequential-Consistency/">什么是顺序一致性</a>。这里举一个可能容易理解但不太准确的例子：</p><p>假设没有缓存，多个 CPU 对同一个内存地址做读写，逻辑上，我们会认为这些操作是原子的，有顺序的。假设当前内存的值是 <code>0</code>，CPU1 先发出写操作 <code>W(1)</code>, CPU2 再发出读操作 <code>R</code>，则逻辑上我们理解 CPU2 一定要读到 <code>1</code> 这个值。</p><p>现在假设两个 CPU 都有自己的缓存，CPU1 先发出 <code>W(1)</code> 写到自己的缓存，因为使用了Write-Back 技术，还没有更新到内存，此时 CPU2 发出 <code>R</code>，读到的是自己的缓存（或者缓存不存在从内存加载），读到的还是 <code>0</code>，和我们上面说的预期不一致。</p><p>缓存一致性是指：通过在缓存之间做同步，达到仿佛系统不存在缓存时的行为。一般有<a href="https://en.wikipedia.org/wiki/Cache_coherence#Overview">如下要求</a>：</p><ul><li>Write Propagation（写传播）：即写入一个缓存要让其它缓存能看到</li><li>Transaction Serialization（事务顺序化）：即不同 CPU 对同一个地址发出读写指令，不管这些指令最终的先后顺序如何，不同 CPU 看到的顺序要一样。</li></ul><p>这也对应我们一般说的可见性和顺序性。</p><h2 id="invalidate-based-基于缓存失效"><a class="header-anchor" href="#invalidate-based-基于缓存失效"></a>Invalidate-Based 基于缓存失效</h2><p>一份数据，缓存 A 有副本，缓存 B 也有副本，这时如果对 A 有修改，那 A、B 就不一致了，怎么办？Invalidate-based 的思路是，对 A 有修改，就想办法让其它副本都失效，只剩下 A 这么一个副本，不就没有“不一致”的情况了？</p><p>那其它缓存要再读数据时怎么办？简单，让剩下的那个副本把数据写回到内存，再从内存里把最新的数据捞到缓存即可。</p><p>MESI 就是用 4 个状态实现了状态机，实现了这个逻辑，我喜欢把它叫作“踢人”逻辑。</p><h2 id="mesi-逻辑简述"><a class="header-anchor" href="#mesi-逻辑简述"></a>MESI 逻辑简述</h2><p>MESI 的状态机包含了 4 个状态，也是名字的由来：</p><ul><li>(M)odified: 单副本 + 脏数据（即缓存改变，未写回内存）</li><li>(E)xclusive: 单副本 + 干净数据</li><li>(S)hared: 多副本 + 干净数据</li><li>(I)nvalid: 数据未加载或缓存已失效</li></ul><p>CPU 会有读写操作，记为 <code>PrRd</code> 和 <code>PrWr</code>，缓存接收到操作后需要与其它缓存同步并更新状态，同步的信息通过总线传递，同步信号有 5 种：<code>BusRd</code>, <code>BusRdX</code>,<code>BusUpgr</code>, <code>Flush</code>, <code>FlushOpt</code>，不用记具体的含义，我们只需要知道，这些信号的作用和目的，就是为了在自己接收到写入操作时，把其它缓存踢掉。</p><p>考虑缓存 A 和缓存 B 都有一个副本，都处于 Shared 状态，此时 A 接收到写入操作<code>PrRd</code>，则有如下变化：</p><ol><li>A 会向总线发出 <code>BusUpgr</code>，代表自己要更新缓存上的数据</li><li>A 发出信号后，状态变为 Modified（单副本＋脏数据），这就需要 B 的配合了</li><li>B 处于 Shared 状态，在接收到总线上的 <code>BusUpgr</code> 信号后，主动把状态变为 <code>Invalid</code></li><li>于是只剩下 A 一个副本了</li></ol><h2 id="mesi-与内存屏障"><a class="header-anchor" href="#mesi-与内存屏障"></a>MESI 与内存屏障</h2><p>MESI 如果简单粗暴地实现，会有两个很明显的性能问题：</p><ol><li>当尝试写入一个 Invalid 缓存行时，需要等待从其它处理器或主存中读取最新数据，有较长的延时</li><li>将 cache line 置为 Invalid 状态也很慢</li></ol><p>因此 CPU 在实现时一般会通过 Store Buffer 和 Invalidate Queue 机制来做优化。</p><h3 id="store-buffer"><a class="header-anchor" href="#store-buffer"></a>Store buffer</h3><p>在写入 Invalid 状态的缓存时，CPU 会先发出 read-invalid（这样其它 CPU 的缓存行会写入更改并变成 Invalid 的状态），然后把要写入的内容先放在 Store buffer 上，等收到其它 CPU 或内存发送过来的缓存行，做合并后才真正完成写入操作。</p><p>这会导致虽然 CPU 以为某个修改写入缓存了，但其实还在 Store buffer 里。此时如果要读数据，则需要先扫描 Store buffer，此外，其它 CPU 在数据真正写入缓存之前是看不到这次写入的。</p><h3 id="invalidate-queue"><a class="header-anchor" href="#invalidate-queue"></a>Invalidate Queue</h3><p>当收到 Invalidate 申请时（如 Shared 状态收到 BusUpgr），CPU 会将申请记录到内部的Invalidate Queue，并立马返回/响应。缓存会尽快处理这些请求，但不保证“立马完成”。此时 CPU 可能以为缓存已经失效，但真的尝试读取时，缓存还没有置为 Invalid状态，于是读到旧的数据。</p><h3 id="内存屏障"><a class="header-anchor" href="#内存屏障"></a>内存屏障</h3><p>这些优化的存在，要求我们在代码里使用内存屏障，插入 store barrier 会强制将store buffer 的数据写到缓存中，这样保证数据写到了所有的缓存里；插入 readbarrier 会保证 invalidate queue 的请求都已经被处理，这样其它 CPU 的修改都已经对当前 CPU可见。</p><h2 id="mesi-与-msi-的区别"><a class="header-anchor" href="#mesi-与-msi-的区别"></a>MESI 与 MSI 的区别</h2><p>不做相关工作也不用太深入。大概就是如果 CPU 要读的数据在其它 CPU 中都不存在，则对于 MSI 来说需要通过 2 个总线事务才能捞到数据，但 MESI 只需要一次。</p><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>本文所有内容均来源于 <a href="https://en.wikipedia.org/wiki/MESI_protocol">MESI 的 wiki</a>。文章的核心想是指出要理解 MESI 协议，关键在于理解它是一个“基于缓存失效”的协议，理解了这点，就能理解 MESI 的状态机为什么要这么做。</p><p>另外简单讨论了 MESI 之下为什么还需要内存屏障，以及 MESI 和同类 MSI 的区别。</p><p>博主做的是上层的应用开发，点到为止已经够用了。</p><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p><a href="https://en.wikipedia.org/wiki/Cache_(computing)#Writing_policies">https://en.wikipedia.org/wiki/Cache_(computing)#Writing_policies</a> <a href="#fnref1" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;MESI 是一个（CPU 级别的）缓存一致性协议。看过 N 次 MESI 的 wiki 页面，一起看不进去，网上搜的一些文章，经常会介绍 MESI 的状态机和各种状态，也看得云里雾里。最近硬着头皮啃完了 wiki，感觉理解 MESI 协议的核心其实在 wiki 的第一句：&lt;</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="MESI" scheme="https://lotabout.me/tags/MESI/"/>
    
    <category term="Cache Coherence" scheme="https://lotabout.me/tags/Cache-Coherence/"/>
    
    <category term="Consistency" scheme="https://lotabout.me/tags/Consistency/"/>
    
  </entry>
  
  <entry>
    <title>异步编程（async）底层实现机制</title>
    <link href="https://lotabout.me/2022/async-implementation-domain-concepts/"/>
    <id>https://lotabout.me/2022/async-implementation-domain-concepts/</id>
    <published>2022-03-27T21:08:35.000Z</published>
    <updated>2025-11-26T12:53:48.051Z</updated>
    
    <content type="html"><![CDATA[<p>本文主要梳理 Rust 和 Python 的 async 实现中涉及的一些通用概念和实现机制。头脑中储备一些异步编程底层的实现原理，可以帮助我们更好地掌握异步编程。</p><h2 id="协程：可暂停可恢复"><a class="header-anchor" href="#协程：可暂停可恢复"></a>协程：可暂停可恢复</h2><p>正常函数调用的控制流是“单入单出”，从调用开始，正常或异常返回后结束，调用的栈帧也随之销毁。而异步编程要求在函数执行到一半时，“暂停”控制流，在未来的某个时刻再“恢复”。由于控制流尚未结束，因此调用链路上的栈帧还不能被销毁，这些信息需要以某种形式保存。可暂停可恢复的控制流，加上它所保存的信息，就可以称为“协程”。</p><h3 id="栈帧-stack-frame"><a class="header-anchor" href="#栈帧-stack-frame"></a>栈帧（Stack Frame）</h3><p>函数调用过程中使用的临时变量会记录到栈上，这些信息是与某个函数的某次调用绑定的，调用结束后就被废弃，这些数据就是栈帧。物理形态上，通常栈帧是“叠”在一起的，例如函数 A 中调用了函数 B，而 B 又调用了 C，则在 C 运行中，栈的状态类似下图：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">|    ...     |</span><br><span class="line">| Frame of C |</span><br><span class="line">+------------+</span><br><span class="line">| Frame of B |</span><br><span class="line">+------------+</span><br><span class="line">| Frame of A |</span><br><span class="line">+------------+</span><br></pre></td></tr></table></figure></div><h3 id="python-记录栈帧"><a class="header-anchor" href="#python-记录栈帧"></a>Python 记录栈帧</h3><p>Python coroutine<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup> 的处理方式是直接保存栈帧。调用的最内层通过 <code>yield</code>暂停控制流，中间层通过 <code>yield from</code> 或 <code>await</code><sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup> 将内层的coroutine 一路往外传，需要恢复时，再使用 <code>send</code> 方法恢复执行<sup class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup>：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">inner</span>():</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;pause inner&#x27;</span>)</span><br><span class="line">    <span class="keyword">yield</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;resumed inner&#x27;</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="number">10</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">middle</span>():</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;pause middle&#x27;</span>)</span><br><span class="line">    value = (<span class="keyword">yield</span> <span class="keyword">from</span> inner())</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;resumed middle&#x27;</span>)</span><br><span class="line">    <span class="keyword">return</span> value * <span class="number">2</span></span><br><span class="line"></span><br><span class="line">coro = middle()   <span class="comment"># 因为 yield from 的机制，coro 指向 inner 的状态</span></span><br><span class="line">coro.send(<span class="literal">None</span>)</span><br><span class="line"><span class="comment"># pause middle</span></span><br><span class="line"><span class="comment"># pause inner</span></span><br><span class="line"></span><br><span class="line">x.send(<span class="literal">None</span>)      <span class="comment"># 可以看到是从 inner 开始恢复的</span></span><br><span class="line"><span class="comment"># resumed inner</span></span><br><span class="line"><span class="comment"># resumed middle</span></span><br><span class="line"><span class="comment"># ---------------------------------------------------------------------------</span></span><br><span class="line"><span class="comment"># StopIteration                             Traceback (most recent call last)</span></span><br><span class="line"><span class="comment"># &lt;ipython-input-19-9cc02a983a52&gt; in &lt;module&gt;</span></span><br><span class="line"><span class="comment"># ----&gt; 1 coro.send(None)</span></span><br><span class="line"><span class="comment">#</span></span><br><span class="line"><span class="comment"># StopIteration: 20</span></span><br></pre></td></tr></table></figure></div><p>注意在 coroutine 中，最终的返回值是通过 <code>StopIteration</code> 带出来的。</p><p>此外，外层拿到的 <code>coro</code> 其实包含了最内层 <code>inner</code> 的栈帧（需要了解<a href="https://peps.python.org/pep-0380/">yield from</a> 的机制），因此第二次调用<code>coro.send(None)</code> 时，会从 <code>inner</code> 函数 <code>yield</code> 处恢复执行。</p><h3 id="rust-编译成状态机"><a class="header-anchor" href="#rust-编译成状态机"></a>Rust 编译成状态机</h3><p>对于缺少 GC 的语言来说，移动、复制栈帧是个原理可行，实际几乎不可行的操作。这些语言里手工创建的指针，可以指向栈上分配的内存，指针还可能被其它线程引用。栈帧移动时，这些指针都需要“修复”；栈帧复制时，数据多了份引用，内存释放又成问题。</p><p>Rust 使用了“状态机”的方式来实现控制流的暂停、恢复的能力<sup class="footnote-ref"><a href="#fn4" id="fnref4">[4]</a></sup>。</p><p>首先是最内层的暂停逻辑，与 Python 不同，内层没有专门的暂停机制，只约定了接口，如果（因为资源未就绪）要暂停，则返回一个特殊值（<code>Poll::Pending</code>），由调用方来决定是否真的暂停和处理恢复。</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight rust"><table><tr><td class="code"><pre><span class="line"><span class="keyword">pub</span> <span class="keyword">trait</span> <span class="title class_">Future</span> &#123;</span><br><span class="line">    <span class="keyword">type</span> <span class="title class_">Output</span>;</span><br><span class="line">    <span class="keyword">fn</span> <span class="title function_">poll</span>(<span class="keyword">self</span>: Pin&lt;&amp;<span class="keyword">mut</span> <span class="keyword">Self</span>&gt;, cx: &amp;<span class="keyword">mut</span> Context) <span class="punctuation">-&gt;</span> Poll&lt;<span class="keyword">Self</span>::Output&gt;;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">pub</span> <span class="keyword">enum</span> <span class="title class_">Poll</span>&lt;T&gt; &#123;</span><br><span class="line">    <span class="title function_ invoke__">Ready</span>(T),</span><br><span class="line">    Pending,</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>Python 的中间层会通过 <code>yield from</code> 向外传递栈帧<sup class="footnote-ref"><a href="#fn5" id="fnref5">[5]</a></sup>，那 Rust的中间层如何对外层提供暂停、恢复的能力呢？Rust 里提供了 <code>await</code> 关键词来表达等待内层的 future<sup class="footnote-ref"><a href="#fn6" id="fnref6">[6]</a></sup>：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight rust"><table><tr><td class="code"><pre><span class="line"><span class="keyword">fn</span> <span class="title function_">inner1</span>() <span class="punctuation">-&gt;</span> <span class="keyword">impl</span> <span class="title class_">Future</span>&lt;Output = <span class="type">u32</span>&gt; &#123;</span><br><span class="line">    future::<span class="title function_ invoke__">ready</span>(<span class="number">1</span>) <span class="comment">// 返回的是 Future 的一个具体实现，这里省略</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">inner2</span>() <span class="punctuation">-&gt;</span> <span class="keyword">impl</span> <span class="title class_">Future</span>&lt;Output = <span class="type">u32</span>&gt; &#123;</span><br><span class="line">    future::<span class="title function_ invoke__">ready</span>(<span class="number">2</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">fn</span> <span class="title function_">middle</span>() <span class="punctuation">-&gt;</span> <span class="type">usize</span> &#123;</span><br><span class="line">    <span class="keyword">let</span> <span class="variable">x</span> = <span class="title function_ invoke__">inner1</span>().<span class="keyword">await</span>; <span class="comment">// await 代表等待内层的 future</span></span><br><span class="line">    <span class="keyword">let</span> <span class="variable">y</span> = <span class="title function_ invoke__">inner2</span>().<span class="keyword">await</span>;</span><br><span class="line">    x + y</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>那么 <code>async/await</code> 底层发生了什么？Rust 编译器会做这么几件事：</p><ol><li>遇到 <code>async fn</code> 定义时，会把 <code>middle</code> 方法的返回改为 <code>Future&lt;Output=...&gt;</code></li><li>将代码逻辑以 <code>await</code> 为拆分点，拆成状态机的 N 个状态，每个状态存储下个await 可见的变量和 future</li><li>将两个 await 之间的代码，转换成状态机的转移逻辑</li></ol><p>上面的例子编译器会编译成类似下面的这些代码<sup class="footnote-ref"><a href="#fn7" id="fnref7">[7]</a></sup>：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight rust"><table><tr><td class="code"><pre><span class="line"><span class="comment">// 状态存储</span></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">StartState</span> &#123;&#125;</span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">WaitingInner1State</span> &#123;</span><br><span class="line">    inner1_future: <span class="keyword">impl</span> <span class="title class_">Future</span>&lt;Output = <span class="type">usize</span>&gt;,</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">WaitingInner2State</span> &#123;</span><br><span class="line">    x: <span class="type">usize</span>,</span><br><span class="line">    inner2_future: <span class="keyword">impl</span> <span class="title class_">Future</span>&lt;Output = <span class="type">usize</span>&gt;,</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">EndState</span> &#123;&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 状态机</span></span><br><span class="line"><span class="keyword">enum</span> <span class="title class_">StateMachine</span> &#123;</span><br><span class="line">    <span class="title function_ invoke__">Start</span>(StartState),</span><br><span class="line">    <span class="title function_ invoke__">WaitingInner1</span>(WaitingInner1State),</span><br><span class="line">    <span class="title function_ invoke__">WaitingInner2</span>(WaitingInner2State),</span><br><span class="line">    <span class="title function_ invoke__">End</span>(EndState),</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 转移逻辑</span></span><br><span class="line"><span class="keyword">impl</span> <span class="title class_">Future</span> <span class="keyword">for</span> <span class="title class_">StateMachine</span> &#123;</span><br><span class="line">    <span class="keyword">type</span> <span class="title class_">Output</span> = <span class="type">usize</span>; <span class="comment">// return type of `middle`</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">fn</span> <span class="title function_">poll</span>(<span class="keyword">self</span>: Pin&lt;&amp;<span class="keyword">mut</span> <span class="keyword">Self</span>&gt;, cx: &amp;<span class="keyword">mut</span> Context) <span class="punctuation">-&gt;</span> Poll&lt;<span class="keyword">Self</span>::Output&gt; &#123;</span><br><span class="line">        <span class="keyword">loop</span> &#123;</span><br><span class="line">            <span class="keyword">match</span> <span class="keyword">self</span> &#123; <span class="comment">// <span class="doctag">TODO:</span> handle pinning</span></span><br><span class="line">                StateMachine::<span class="title function_ invoke__">Start</span>(state) =&gt; &#123;         <span class="comment">// 开始到第一个 await</span></span><br><span class="line">                    inner1_future = <span class="title function_ invoke__">inner1</span>();</span><br><span class="line">                    <span class="keyword">let</span> <span class="variable">state</span> = WaitingInner1State &#123;inner1_future&#125;;</span><br><span class="line">                    *<span class="keyword">self</span> = StateMachine::<span class="title function_ invoke__">WaitingInner1</span>(state);</span><br><span class="line">                &#125;</span><br><span class="line">                StateMachine::<span class="title function_ invoke__">WaitingInner1</span>(state) =&gt; &#123; <span class="comment">// 第一个 await 到第二个 await</span></span><br><span class="line">                    <span class="keyword">match</span> state.inner1_future.<span class="title function_ invoke__">poll</span>(cx) =&gt; &#123;</span><br><span class="line">                        Poll::Pending =&gt; <span class="keyword">return</span> Poll::Pending,</span><br><span class="line">                        Poll::<span class="title function_ invoke__">Ready</span>(x) =&gt; &#123;</span><br><span class="line">                            inner2_future = <span class="title function_ invoke__">inner1</span>();</span><br><span class="line">                            <span class="keyword">let</span> <span class="variable">state</span> = WaitingInner2State &#123;x, inner2_future&#125;;</span><br><span class="line">                            *<span class="keyword">self</span> = StateMachine::<span class="title function_ invoke__">WaitingInner2</span>(state);</span><br><span class="line">                &#125;&#125;&#125;</span><br><span class="line">                StateMachine::<span class="title function_ invoke__">WaitingInner2</span>(state) =&gt; &#123; <span class="comment">// 第二个 await 到结束</span></span><br><span class="line">                    <span class="keyword">match</span> state.inner2_future.<span class="title function_ invoke__">poll</span>(cx) =&gt; &#123;</span><br><span class="line">                        Poll::Pending =&gt; <span class="keyword">return</span> Poll::Pending,</span><br><span class="line">                        Poll::<span class="title function_ invoke__">Ready</span>(y) =&gt; &#123;</span><br><span class="line">                            <span class="keyword">let</span> <span class="variable">ret</span> = state.x + y;</span><br><span class="line">                            *<span class="keyword">self</span> = StateMachine::<span class="title function_ invoke__">End</span>(EndState);</span><br><span class="line">                            <span class="keyword">return</span> Poll::<span class="title function_ invoke__">Ready</span>(ret)</span><br><span class="line">                &#125;&#125;&#125;</span><br><span class="line">                StateMachine::<span class="title function_ invoke__">End</span>(state) =&gt; &#123;</span><br><span class="line">                    <span class="built_in">panic!</span>(<span class="string">&quot;poll called after Poll::Ready was returned&quot;</span>);</span><br><span class="line">                &#125;</span><br><span class="line">&#125;&#125;&#125;&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// async def 编译成了返回 Future</span></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">middle</span>() <span class="punctuation">-&gt;</span> <span class="keyword">impl</span> <span class="title class_">Future</span>&lt;Output = <span class="type">usize</span>&gt; &#123;</span><br><span class="line">    StateMachine::<span class="title function_ invoke__">Start</span>(StartState&#123;&#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>可以看到中间层返回的 StateMachine 本身记录了内部调用的 Future 所处的状态。最外层的调用方如果需要恢复执行，只需再调用 <code>middle</code> 返回 future 的 <code>poll</code> 方法即可，<code>middle</code> 会根据当前状态决定去 <code>poll</code> 哪个内层 future。</p><h2 id="轮询与中断-回调"><a class="header-anchor" href="#轮询与中断-回调"></a>轮询与中断/回调</h2><p>异步编程的特征之一，是当资源未就绪时，先暂停当前控制流，先执行其它可推进的逻辑，等资源就绪时，再恢复之前暂停的控制流。那什么时候才知道资源就绪呢？一般有两种方法：轮询与中断。</p><h3 id="轮询"><a class="header-anchor" href="#轮询"></a>轮询</h3><p>轮询很好理解，就是外围调用方不断调用 <code>poll</code> 方法去查看当前资源的状态是否就绪：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight rust"><table><tr><td class="code"><pre><span class="line">future = <span class="title function_ invoke__">middle</span>();</span><br><span class="line"><span class="keyword">loop</span> &#123;</span><br><span class="line">    <span class="keyword">match</span> future.<span class="title function_ invoke__">poll</span>() &#123;</span><br><span class="line">        Poll::Pending =&gt; &#123;&#125;</span><br><span class="line">        Poll::<span class="title function_ invoke__">Ready</span>(ret_val) =&gt; &#123;</span><br><span class="line">            <span class="comment">// 执行逻辑</span></span><br><span class="line">&#125;&#125;&#125;</span><br></pre></td></tr></table></figure></div><p>但如果是这么做，资源未就绪前会不断执行 <code>future.poll</code>，浪费 CPU。此时空闲的 CPU可以用来处理其它就绪的 future，于是可以把所有需要轮询的协程添加到一个队列里，这样一个线程就可以处理 N 个协程。伪代码如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight rust"><table><tr><td class="code"><pre><span class="line"><span class="keyword">loop</span> &#123;</span><br><span class="line">    future = waiting_queue.<span class="title function_ invoke__">pop_front</span>() <span class="comment">// 队列存放所有 future</span></span><br><span class="line">    <span class="keyword">match</span> future.<span class="title function_ invoke__">poll</span>() &#123;</span><br><span class="line">        Poll::Pending =&gt; &#123;</span><br><span class="line">            waiting_queue.<span class="title function_ invoke__">push_back</span>(future)</span><br><span class="line">        &#125;</span><br><span class="line">        Poll::<span class="title function_ invoke__">Ready</span>(ret_val) =&gt; &#123;</span><br><span class="line">            <span class="comment">// ① 执行正常逻辑</span></span><br><span class="line">        &#125;&#125;&#125;</span><br></pre></td></tr></table></figure></div><p>这会引申出一个问题：在 ① 中，如果 middle future 的结果就绪了，接下来需要执行哪部分代码呢？显然需要从 future 暂停的地方接着执行（即 outer 的后续逻辑），但我们怎么找到外层的逻辑？</p><p>一种想法是把外层逻辑也封装成一个 future<sup class="footnote-ref"><a href="#fn8" id="fnref8">[8]</a></sup>，队列里直接存放outerfuture 而不是 middle future，恢复时只要执行 outer future 的 <code>poll</code>方法即可。这就是<strong>异步编程的传染性</strong>，只要内部有一处异步，它的每个调用方都需要是异步的，一直到顶层的 main 函数<sup class="footnote-ref"><a href="#fn9" id="fnref9">[9]</a></sup>。</p><p>于是就像有多个线程一样，我们的队列里可以存放 N 个顶层的 future，可以类比成轮询N 个 main 函数。这个不断从队列中获取新的协程并调用 <code>poll</code> 的角色在 Rust 里叫executor<sup class="footnote-ref"><a href="#fn10" id="fnref10">[10]</a></sup>。</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight rust"><table><tr><td class="code"><pre><span class="line"><span class="keyword">loop</span> &#123;</span><br><span class="line">    main_future = waiting_queue.<span class="title function_ invoke__">pop_front</span>()</span><br><span class="line">    <span class="keyword">match</span> main_future.<span class="title function_ invoke__">poll</span>() &#123;</span><br><span class="line">        Poll::Pending =&gt; &#123;</span><br><span class="line">            <span class="comment">// ② 处理队列中的下一个</span></span><br><span class="line">            waiting_queue.<span class="title function_ invoke__">push_back</span>(future)</span><br><span class="line">        &#125;</span><br><span class="line">        Poll::<span class="title function_ invoke__">Ready</span>() =&gt; &#123;</span><br><span class="line">            <span class="comment">// future 完成退出</span></span><br><span class="line">        &#125;&#125;&#125;</span><br></pre></td></tr></table></figure></div><p>② 中的逻辑会不断把未就绪的 future 放入队列，这样每轮轮询时都会 poll 所有future，这样依旧会浪费很多资源（CPU &amp; IO），最理想的方式是每次 poll 时只poll 那些“很有希望 ready”的 future。这就是我们下面要说的“中断”的模式，当资源就绪时，再把future 加入队列。</p><h3 id="中断与-waker"><a class="header-anchor" href="#中断与-waker"></a>中断与 waker</h3><p>我们希望 future 只在资源就绪时才被重新放回队列<sup class="footnote-ref"><a href="#fn11" id="fnref11">[11]</a></sup>，于是executor 需要提供如下方法（伪代码）：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight rust"><table><tr><td class="code"><pre><span class="line"><span class="keyword">let</span> <span class="keyword">mut </span><span class="variable">ready_queue</span> = Queue::<span class="title function_ invoke__">new</span>();</span><br><span class="line"><span class="keyword">let</span> <span class="keyword">mut </span><span class="variable">futures</span>: HashMap&lt;<span class="type">usize</span>, RefCell&lt;Future&lt;Output=()&gt;&gt;&gt; = HashMap::<span class="title function_ invoke__">new</span>();</span><br><span class="line"><span class="keyword">let</span> <span class="keyword">mut </span><span class="variable">num</span>: <span class="type">usize</span> = <span class="number">0</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ① 监听 ready_queue 并对其中的元素进行 poll</span></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">run</span>() &#123;</span><br><span class="line">    <span class="keyword">loop</span> &#123;</span><br><span class="line">        <span class="keyword">let</span> <span class="variable">_</span> = ready_queue.<span class="title function_ invoke__">pop_front</span>().<span class="title function_ invoke__">poll</span>();</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ② 提供方法监听新的 future，需要将其加入 ready_queue 进行首次 poll</span></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">add_future</span>(future: RefCell&lt;Future&lt;Output=()&gt;&gt;) &#123;</span><br><span class="line">    ready_queue.<span class="title function_ invoke__">push_back</span>(future.<span class="title function_ invoke__">clone</span>())</span><br><span class="line">    num += <span class="number">1</span>;</span><br><span class="line">    futures.<span class="title function_ invoke__">insert</span>(num)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ③ 提供机制在 future 就绪时将其加入 ready_queue 中，等待下次 poll</span></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">wake_up</span>(n: <span class="type">usize</span>) &#123;</span><br><span class="line">    <span class="keyword">let</span> <span class="variable">future</span> = futures.<span class="title function_ invoke__">remove</span>(&amp;n).<span class="title function_ invoke__">unwrap</span>();</span><br><span class="line">    ready_queue.<span class="title function_ invoke__">push_back</span>(future);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>现在的问题是：“谁”负责在“什么时候”调用 <code>wake_up</code> 方法？</p><p>先来看“谁”的问题，唤醒的条件是资源就绪，那必然是资源的拥有者来唤醒，而只有“最内层”的协程才知道它等待的是什么资源，因此需要最内层的协程（通过注册回调函数）来触发。但是 <code>wake_up</code> 唤醒的时候得唤醒最外层的协程，即上面伪代码的参数 <code>n</code>，于是每次调用 poll 都需要把 <code>n</code> 一路下传到最内层：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight rust"><table><tr><td class="code"><pre><span class="line"><span class="keyword">fn</span> <span class="title function_">run</span>() &#123;</span><br><span class="line">    <span class="keyword">loop</span> &#123;</span><br><span class="line">        <span class="keyword">let</span> (future, index) = ready_queue.<span class="title function_ invoke__">pop_front</span>();</span><br><span class="line">        future.<span class="title function_ invoke__">poll</span>(index);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>当然，伪代码里用 future 的序号 <code>n</code> 来唤醒外层 future 是一个实现细节。回过头来看 rust <code>Future</code> 接口，它包含了一个 <code>Context</code> 的引用，<code>cx.waker()</code> 可以获得“唤醒器”，再调用<code>wake</code> 方法即可唤醒对应的最外层的协程。与 <code>n</code> 一样，每次对<code>poll</code> 的调用，都需要把 <code>cx</code> 一路下传到最内层。</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight rust"><table><tr><td class="code"><pre><span class="line"><span class="keyword">pub</span> <span class="keyword">trait</span> <span class="title class_">Future</span> &#123;</span><br><span class="line">    <span class="keyword">type</span> <span class="title class_">Output</span>;</span><br><span class="line">    <span class="keyword">fn</span> <span class="title function_">poll</span>(<span class="keyword">self</span>: Pin&lt;&amp;<span class="keyword">mut</span> <span class="keyword">Self</span>&gt;, cx: &amp;<span class="keyword">mut</span> Context) <span class="punctuation">-&gt;</span> Poll&lt;<span class="keyword">Self</span>::Output&gt;;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></div><p>另一个问题是“什么时候”调用，显然是“资源就绪”时。那怎么知道资源什么时候就绪？这就需要资源的提供方来通知了。通常异步编程多是在处理 IO，对于 IO 一般是操作系统通过 <code>select</code> 或者 <code>epoll</code> 等等机制提供了异步通知的能力。代码里需要在等待资源时加上回调函数。整体逻辑如下图：</p><img src="/2022/async-implementation-domain-concepts/rust-async-process.svg" class="" title="Rust Async Process"><p>其中的 reactor 会监听所有在等待的资源，如果某个资源就绪了，同步的 <code>poll</code> 会返回就绪的资源，reactor 会调用它们的回调函数（即 <code>wake</code> 方法来唤醒）。Rust 里一般把 executor 和 reactor 合起来称为 Runtime。</p><h3 id="python-实现"><a class="header-anchor" href="#python-实现"></a>Python 实现</h3><p>前文的描述都是以 Rust 为样例，这是因为 Rust 里的角色分得相对更清楚一些。像 executor 和 reactor 的能力，在 Python 里都囊括在<a href="https://github.com/python/cpython/blob/788154919c2d843a0a995994bf2aed2d074761ec/Lib/asyncio/events.py#L203">event loop</a>里了，能监听什么资源，也被安排得明明白白了。</p><p>Python 里也经常用到<a href="https://github.com/python/cpython/blob/788154919c2d843a0a995994bf2aed2d074761ec/Lib/asyncio/futures.py#L31">Future</a>，但它的概念和 Rust 里的不太一样，Python 中的 <code>Future</code> 本身是一个协程（实现了<a href="https://github.com/python/cpython/blob/788154919c2d843a0a995994bf2aed2d074761ec/Lib/asyncio/futures.py#L289"><strong>await</strong></a>方法），另外有一个 <code>set_result</code> 方法能设置最终结果，结果设置后，协程就能正常返回了（类似Rust里返回 <code>Poll::Ready</code>）。</p><p>Python 里的一个典型协程工作流如下所示：</p><img src="/2022/async-implementation-domain-concepts/python-async-process.svg" class="" title="Python Async Process"><p>图里包含了比较多的细节，整体逻辑和 Rust 类似，注意几点：</p><ol><li><code>inner</code> 注册监听事件时，Python 的做法是创建一个 future、注册事件，<code>await future</code></li><li>事件的注册最终都是调用 loop 的 API 来完成，也说明 Python 的 loop 包含了多个角色</li><li>几乎所有的操作都是异步的，包括注册，也是通过 <code>loop.call_soon</code> 延迟执行的</li><li><code>future.set_result</code> 之后，也是通过 <code>call_soon</code> 延迟唤醒协程</li><li>唤醒后的协程，是直接从断点处恢复的（通过栈帧机制），与 Rust 不同</li><li>Event Loop 直接操作的是 <code>task</code> 而不是 <code>coroutine</code>，它是一个包装类，提供了取消、唤醒等功能</li></ol><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>异步编程的优势主要是节省线程数量（从而节省线程占用的栈等资源），也有说减少线程切换来节省 CPU 消耗。但总的来说，异步的最大作用和目标是提高吞吐而非降低延时。</p><p>但是，异步编程的缺点也很明显，最关键的是它的“传染性”，只要有一处要异步，所有地方都需要异步。另一个是“隔离性”，它的生态和同步的方法天然不通，一般为了支持异步，几乎所有同步的标准库都需要重写一个异步版本的。我甚至认为如果“高吞吐”不是产品的核心特性（如网关），就不应该使用异步框架。</p><p>本文尝试挖掘 Rust 和 Python 实现异步框架的模式，让我们对异步的底层实现建立一个概念，希望借助这些概念，去理解、解决编程中遇到的异步相关问题。文章主要讲解了三方面的内容：</p><ol><li>协程的核心是控制流的中断和恢复，Python 为代表的 GC 语言用的是存储栈帧的方式，而以 Rust 为代表的非 GC 语言使用了编译成状态机的方式。</li><li>异步的优势想要体现，需要满足一个线程可以处理多个协程的能力。轮询的想法引导我们创建了 executor 处理协程队列的思路；中断的想法引导我们理清 reactor 的作用以及上下层需要传递的信息。</li><li>最后是过程中列举了 Rust 和 Python 典型的协程工作流，可以从实现上相互印证两种具体的实现思路。但在编程的使用方来看二者的 API 又没有太大的差异。</li></ol><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p>Python 的 coroutine 和 generator 基本是同一套实现机制，本文里有时会混用两个术语 <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p>如果用 await 则要求内层调用实现了 <code>__await__</code> 方法 <a href="#fnref2" class="footnote-backref">↩</a></p></li><li id="fn3"  class="footnote-item"><p>ref: <a href="https://peps.python.org/pep-0342/#new-generator-method-send-value">https://peps.python.org/pep-0342/#new-generator-method-send-value</a> <a href="#fnref3" class="footnote-backref">↩</a></p></li><li id="fn4"  class="footnote-item"><p>推荐看这篇文章：<a href="https://os.phil-opp.com/async-await/#the-async-await-pattern">https://os.phil-opp.com/async-await/#the-async-await-pattern</a> <a href="#fnref4" class="footnote-backref">↩</a></p></li><li id="fn5"  class="footnote-item"><p>这里说法不太准确，但不影响理解。<code>yield from</code> 只是把各个coroutine 连接在一起，不会真的返回栈帧 <a href="#fnref5" class="footnote-backref">↩</a></p></li><li id="fn6"  class="footnote-item"><p>在有 await 及编译器支持之前，基本是需要人肉做状态的保存和恢复的 <a href="#fnref6" class="footnote-backref">↩</a></p></li><li id="fn7"  class="footnote-item"><p>代码改编自 <a href="https://os.phil-opp.com/async-await/#the-async-await-pattern">https://os.phil-opp.com/async-await/#the-async-await-pattern</a> <a href="#fnref7" class="footnote-backref">↩</a></p></li><li id="fn8"  class="footnote-item"><p>这里的含义是 outer 方法也使用 <code>await</code> 来获取结果。 <a href="#fnref8" class="footnote-backref">↩</a></p></li><li id="fn9"  class="footnote-item"><p>如果调用方自己不做成异步，则需要在代码里“同步”等待 future.poll返回 ready，或者等待统一轮询队列的就绪通知，无论如何，它所在的线程在内部的异步任务完成前是不会释放的，就达不到异步编程“节省线程”的目的了。 <a href="#fnref9" class="footnote-backref">↩</a></p></li><li id="fn10"  class="footnote-item"><p>文中只展示了简单的模型，executor 的实现可以相当复杂，参考 <a href="https://tokio.rs/blog/2019-10-scheduler">Making the Tokio scheduler 10x faster</a> <a href="#fnref10" class="footnote-backref">↩</a></p></li><li id="fn11"  class="footnote-item"><p>当 future 刚被创建时我们并不知道它是否就绪，此时也需要放入队列触发第一次 poll，在 poll 里如果资源未就绪，由 future 来注册后续的回调，因此当 future 第二次通过回调再被加入队列时，就“有信心”它依赖的资源就绪了。 <a href="#fnref11" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;本文主要梳理 Rust 和 Python 的 async 实现中涉及的一些通用概念和实现机制。头脑中储备一些异步编程底层的实现原理，可以帮助我们更好地掌握异步编程。&lt;/p&gt;
&lt;h2 id=&quot;协程：可暂停可恢复&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;</summary>
      
    
    
    
    <category term="Notes" scheme="https://lotabout.me/categories/Notes/"/>
    
    
    <category term="async" scheme="https://lotabout.me/tags/async/"/>
    
    <category term="python" scheme="https://lotabout.me/tags/python/"/>
    
    <category term="rust" scheme="https://lotabout.me/tags/rust/"/>
    
    <category term="coroutine" scheme="https://lotabout.me/tags/coroutine/"/>
    
  </entry>
  
  <entry>
    <title>Kubernetes Service iptables 网络通信验证</title>
    <link href="https://lotabout.me/2022/Kubernetes-Service-Model-Verification/"/>
    <id>https://lotabout.me/2022/Kubernetes-Service-Model-Verification/</id>
    <published>2022-01-24T23:01:26.000Z</published>
    <updated>2025-11-26T12:53:48.003Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p>Kubernetes gives Pods their own IP addresses and a single DNS name for a setof Pods, and can load-balance across them.</p></blockquote><p>K8s <a href="https://kubernetes.io/docs/concepts/services-networking/service/">Service</a>会为每个 Pod 都设置一个它自己的 IP，并为一组 Pod 提供一个统一的 DNS 域名，还可以提供在它们间做负载均衡的能力。这篇文章会对 kube-proxy 的 iptables 模式内部的机制做一个验证。大体上涉及的内容如下：</p><img src="/2022/Kubernetes-Service-Model-Verification/service.svg" class="" title="Service with IP tables"><h2 id="实验配置"><a class="header-anchor" href="#实验配置"></a>实验配置</h2><p>创建一个 Service，配置如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight yaml"><table><tr><td class="code"><pre><span class="line"><span class="attr">apiVersion:</span> <span class="string">v1</span></span><br><span class="line"><span class="attr">kind:</span> <span class="string">Service</span></span><br><span class="line"><span class="attr">metadata:</span></span><br><span class="line">  <span class="attr">creationTimestamp:</span> <span class="string">&quot;2022-01-23T02:32:38Z&quot;</span></span><br><span class="line">  <span class="attr">name:</span> <span class="string">spring-test</span></span><br><span class="line">  <span class="attr">namespace:</span> <span class="string">default</span></span><br><span class="line">  <span class="attr">resourceVersion:</span> <span class="string">&quot;94418&quot;</span></span><br><span class="line">  <span class="attr">uid:</span> <span class="string">cdaab6bc-a518-4235-a161-a4cae6f564cf</span></span><br><span class="line"><span class="attr">spec:</span></span><br><span class="line">  <span class="attr">clusterIP:</span> <span class="number">10.1</span><span class="number">.68</span><span class="number">.7</span></span><br><span class="line">  <span class="attr">clusterIPs:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="number">10.1</span><span class="number">.68</span><span class="number">.7</span></span><br><span class="line">  <span class="attr">externalTrafficPolicy:</span> <span class="string">Cluster</span></span><br><span class="line">  <span class="attr">internalTrafficPolicy:</span> <span class="string">Cluster</span></span><br><span class="line">  <span class="attr">ipFamilies:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">IPv4</span></span><br><span class="line">  <span class="attr">ipFamilyPolicy:</span> <span class="string">SingleStack</span></span><br><span class="line">  <span class="attr">ports:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="attr">nodePort:</span> <span class="number">31080</span></span><br><span class="line">    <span class="attr">port:</span> <span class="number">8080</span></span><br><span class="line">    <span class="attr">protocol:</span> <span class="string">TCP</span></span><br><span class="line">    <span class="attr">targetPort:</span> <span class="number">8080</span></span><br><span class="line">  <span class="attr">selector:</span></span><br><span class="line">    <span class="attr">app:</span> <span class="string">spring-test</span></span><br><span class="line">  <span class="attr">sessionAffinity:</span> <span class="string">None</span></span><br><span class="line">  <span class="attr">type:</span> <span class="string">NodePort</span></span><br><span class="line"><span class="attr">status:</span></span><br><span class="line">  <span class="attr">loadBalancer:</span> &#123;&#125;</span><br></pre></td></tr></table></figure></div><p>创建后的 service 如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">$ k get svc -o wide -A</span><br><span class="line">NAMESPACE     NAME          TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                  AGE     SELECTOR</span><br><span class="line">default       kubernetes    ClusterIP   10.1.0.1       &lt;none&gt;        443/TCP                  2d22h   &lt;none&gt;</span><br><span class="line">default       sender        NodePort    10.1.177.169   &lt;none&gt;        8081:31081/TCP           46h     app=sender</span><br><span class="line">default       spring-test   NodePort    10.1.68.7      &lt;none&gt;        8080:31080/TCP           2d4h    app=spring-test</span><br><span class="line">kube-system   kube-dns      ClusterIP   10.1.0.10      &lt;none&gt;        53/UDP,53/TCP,9153/TCP   2d22h   k8s-app=kube-dns</span><br></pre></td></tr></table></figure></div><p>注意其中的 spring-test 和 kube-dns 两项，后面会用到。另外 service 对应的 podIP 如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">$ k get ep</span><br><span class="line">NAME          ENDPOINTS                         AGE</span><br><span class="line">kubernetes    192.168.50.48:6443                2d23h</span><br><span class="line">sender        10.244.1.7:8080,10.244.2.7:8080   47h</span><br><span class="line">spring-test   10.244.1.3:8080,10.244.2.3:8080   2d4h</span><br></pre></td></tr></table></figure></div><h2 id="dns"><a class="header-anchor" href="#dns"></a>DNS</h2><p>K8s 会为 Service 创建一个 <a href="https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#a-aaaa-records">DNS</a>域名，格式为 <code>&lt;svc&gt;.&lt;namespace&gt;.svc.&lt;cluster-domain&gt;</code>，例如我们创建的<code>spring-test</code> Service 则会有<code>spring-test.default.svc.cluster.local</code><sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup> 域名。</p><p>我们首先进入 pod，看一下 <code>/etc/resolv.conf</code> 文件，关于域名解析的配置：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">nameserver 10.1.0.10</span><br><span class="line">search default.svc.cluster.local svc.cluster.local cluster.local</span><br><span class="line">options ndots:5</span><br></pre></td></tr></table></figure></div><ul><li><p>这里的 <code>10.1.0.10</code> 是 kube-dns service 的 cluster IP</p></li><li><p>文件中配置了多个 search 域，因此我们写 <code>spring-test</code> 或<code>spring-test.default</code> 或 <code>spring-test.default.svc</code> 都是可以解析的，另外注意解析后的 IP 也不是具体哪个 POD 的地址，而是为 Service 创建的虚拟地址ClusterIP。</p>  <div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">root@spring-test-77d9d6dcb5-m9mvr:/# nslookup spring-test</span><br><span class="line">Server:         10.1.0.10</span><br><span class="line">Address:        10.1.0.10#53</span><br><span class="line"></span><br><span class="line">Name:   spring-test.default.svc.cluster.local</span><br><span class="line">Address: 10.1.68.7</span><br><span class="line"></span><br><span class="line">root@spring-test-77d9d6dcb5-m9mvr:/# nslookup spring-test.default</span><br><span class="line">Server:         10.1.0.10</span><br><span class="line">Address:        10.1.0.10#53</span><br><span class="line"></span><br><span class="line">Name:   spring-test.default.svc.cluster.local</span><br><span class="line">Address: 10.1.68.7</span><br><span class="line"></span><br><span class="line">root@spring-test-77d9d6dcb5-m9mvr:/# nslookup spring-test.default.svc</span><br><span class="line">Server:         10.1.0.10</span><br><span class="line">Address:        10.1.0.10#53</span><br><span class="line"></span><br><span class="line">Name:   spring-test.default.svc.cluster.local</span><br><span class="line">Address: 10.1.68.7</span><br></pre></td></tr></table></figure></div></li><li><p><code>ndots:5</code> 指的是如果域名中的 <code>.</code> 大于等于 5 个，则不走 search 域，目的是减少常规域名的解析次数<sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup></p></li></ul><h2 id="iptables-转发"><a class="header-anchor" href="#iptables-转发"></a>iptables 转发</h2><p>DNS 里创建的记录解决了域名到 ClusterIP 的转换问题，发送到 ClusterIP 的请求，如何转发到对应的 POD 里呢？K8s Service 有几种实现方式，这里验证的是 iptables 的实现方式：kube-proxy 会监听 etcd 中关于 k8s 的事件，并动态地对 iptables 做配置，最终由 iptables 来完成转发。先看看跟这个 Service 相关的规则如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">0.  -A PREROUTING -j KUBE-SERVICES</span><br><span class="line">1.  -A KUBE-NODEPORTS -p tcp -m tcp --dport 31080 -j KUBE-SVC-S</span><br><span class="line">2.  -A KUBE-SEP-A -s 10.244.2.3/32 -j KUBE-MARK-MASQ</span><br><span class="line">3.  -A KUBE-SEP-A -p tcp -m tcp -j DNAT --to-destination 10.244.2.3:8080</span><br><span class="line">4.  -A KUBE-SEP-B -s 10.244.1.3/32 -j KUBE-MARK-MASQ</span><br><span class="line">5.  -A KUBE-SEP-B -p tcp -m tcp -j DNAT --to-destination 10.244.1.3:8080</span><br><span class="line">6.  -A KUBE-SERVICES -d 10.1.68.7/32 -p tcp -m tcp --dport 8080 -j KUBE-SVC-S</span><br><span class="line">7.  -A KUBE-SVC-S ! -s 10.244.0.0/16 -d 10.1.68.7/32 -p tcp -m tcp --dport 8080 -j KUBE-MARK-MASQ</span><br><span class="line">8.  -A KUBE-SVC-S -p tcp -m tcp --dport 31080 -j KUBE-MARK-MASQ</span><br><span class="line">9.  -A KUBE-SVC-S -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-B</span><br><span class="line">10. -A KUBE-SVC-S -j KUBE-SEP-A</span><br></pre></td></tr></table></figure></div><p>我们先用 <code>iptables-save</code> 打印出所有的规则，筛选出和 <code>spring-test</code> service相关的规则，删除了一些 comment，并对名字做了简化。可以看到有这么几类：</p><ul><li><code>KUBE-NODEPORTS</code>，这类规则用来将发送到 NodePort 的报文转到 <code>KUBE-SVC-*</code></li><li><code>KUBE-SERVICES</code>：是识别目标地址为 ClusterIP(<code>10.1.68.7</code>)，命中的报文转到<code>KUBE-SVC-*</code> 做处理</li><li><code>KUBE-SVC</code> 的作用是做负载均衡，将请求分配到 <code>KUBE-SEP</code> 中</li><li><code>KUBE-SEP</code> 通过 DNAT 替换目标地址为 Pod IP，转发到具体的 POD 中</li></ul><p>另外经常看到 <code>-j KUBE-MARK-MASQ</code>，它的作用是在请求里加上 mark，在<code>POSTROUTING</code> 规则中做 SNAT，这点后面再细说。</p><p>我们开启 iptables 的 trace 模式<sup class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup>，并在其中一个 pod 发送一个请求，检查 TRACE 中规则的命中情况（由于输出特别多，这里挑选了重要的输出并做了精简）：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">0:  nat:PREROUTING    IN=cni0 OUT=           SRC=10.244.1.7 DST=10.1.68.7  DPT=8080</span><br><span class="line">6:  nat:KUBE-SERVICES IN=cni0 OUT=           SRC=10.244.1.7 DST=10.1.68.7  DPT=8080</span><br><span class="line">10: nat:KUBE-SVC-S    IN=cni0 OUT=           SRC=10.244.1.7 DST=10.1.68.7  DPT=8080</span><br><span class="line">3:  nat:KUBE-SEP-A    IN=cni0 OUT=           SRC=10.244.1.7 DST=10.1.68.7  DPT=8080</span><br><span class="line">    mangle:FORWARD    IN=cni0 OUT=flannel.1  SRC=10.244.1.7 DST=10.244.2.3 DPT=8080 </span><br></pre></td></tr></table></figure></div><ul><li>在 <code>PREROUTING</code> 时，进入第 6 条进判定</li><li><code>KUBE-SERVICES</code> 判断目标地址为 <code>10.1.68.7</code> 且目标端口为 <code>8080</code>，于是跳转进入 <code>KUBE-SVC-S</code> 链的判断</li><li><code>KUBE-SVC-S</code> 有多条规则，从日志看最终是从第 10 条退出，进入 <code>KUBE-SEP-A</code> 链</li><li><code>KUBE-SEP-A</code> 最终命中第 3 条规则退出，但此时会进行 DNAT 转换目标地址</li><li>下一条日志显示，<code>DST</code> 目标地址已经变成 pod 地址 <code>10.244.2.3</code> 了</li></ul><p>类似的，如果我们是通过 NodePort 来访问 Service，则 Trace 日志如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">0: nat:PREROUTING:      IN=eth0 OUT=     SRC=192.168.50.135 DST=192.168.50.238 DPT=31080</span><br><span class="line">6: nat:KUBE-SERVICES:   IN=eth0 OUT=     SRC=192.168.50.135 DST=192.168.50.238 DPT=31080</span><br><span class="line">1: nat:KUBE-NODEPORTS:  IN=eth0 OUT=     SRC=192.168.50.135 DST=192.168.50.238 DPT=31080</span><br><span class="line">9: nat:KUBE-SVC-S:      IN=eth0 OUT=     SRC=192.168.50.135 DST=192.168.50.238 DPT=31080</span><br><span class="line">9: nat:KUBE-SVC-S:      IN=eth0 OUT=     SRC=192.168.50.135 DST=192.168.50.238 DPT=31080</span><br><span class="line">5: nat:KUBE-SEP-A:      IN=eth0 OUT=     SRC=192.168.50.135 DST=192.168.50.238 DPT=31080</span><br><span class="line">   mangle:FORWARD:      IN=eth0 OUT=cni0 SRC=192.168.50.135 DST=10.244.1.3     DPT=8080</span><br></pre></td></tr></table></figure></div><h2 id="iptables-负载均衡"><a class="header-anchor" href="#iptables-负载均衡"></a>iptables 负载均衡</h2><p>上一节我们比较关注 iptables 转发的内容，那么如何做负载均衡？这部分是比较纯粹的iptables 知识<sup class="footnote-ref"><a href="#fn4" id="fnref4">[4]</a></sup>:</p><p>首先：iptables 对于规则的解析是严格顺序的，所以如果只是单纯列出两个条目，则会永远命中第一条：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">-A KUBE-SVC-S -j KUBE-SEP-A</span><br><span class="line">-A KUBE-SVC-S -j KUBE-SEP-B</span><br></pre></td></tr></table></figure></div><p>于是，我们需要第一条规则在某些条件下不命中。这样 iptables 就有机会执行后面的规则。iptables 提供了两种方法，第一种是有随机数，也是上一节我们看到的：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">-A KUBE-SVC-S -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-B</span><br></pre></td></tr></table></figure></div><p>这条规则在执行时，iptables 会随机生成一个数，并以 <code>probability</code> 的概率命中当前规则。换句话说，第一条命中的概率是 <code>p</code>，则第二条规则就是 <code>1-p</code>。如果有 3 个副本，则会类似下面这样的规则，大家可以计算下最后三个 Pod 是不是平均分配：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">-A KUBE-SVC-S --mode random --probability 0.33333333349 -j KUBE-SEP-A</span><br><span class="line">-A KUBE-SVC-S --mode random --probability 0.50000000000 -j KUBE-SEP-B</span><br><span class="line">-A KUBE-SVC-S -j KUBE-SEP-C</span><br></pre></td></tr></table></figure></div><p>另外一种模式是 round-robin，但是 kubernetes 的 iptables 模式不支持，这里就不细说了。猜想 kubernetes iptables 模式下不支持的原因是虽然单机 iptables 能支持round-robin，但多机模式下，无法做到全局的 round-robin。</p><h2 id="snat"><a class="header-anchor" href="#snat"></a>SNAT</h2><p>前面我们提到 KUBE 系列的规则经常看到 <code>-j KUBE-MARK-MASQ</code>，和它相关的规则有这些：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">-A KUBE-MARK-MASQ -j MARK --set-xmark 0x4000/0x4000</span><br><span class="line">-A KUBE-POSTROUTING -m mark ! --mark 0x4000/0x4000 -j RETURN</span><br><span class="line">-A KUBE-POSTROUTING -j MARK --set-xmark 0x4000/0x0</span><br><span class="line">-A KUBE-POSTROUTING -m comment --comment &quot;kubernetes service traffic requiring SNAT&quot; -j MASQUERADE</span><br></pre></td></tr></table></figure></div><p>首先 <code>KUBE-MARK-MASQ</code> 的作用是把报文打上 <code>0x4000/0x4000</code> 的标记，在<code>KUBE-POSTROUTING</code> 时，如果报文中包含这个标记，会执行 <code>-j MASQUERADE</code> 操作，而这个操作的作用就是做源地址转换（SNAT）。那 SNAT 是什么，为什么要做 SNAT 呢？</p><p>这里引用<a href="https://www.asykim.com/blog/deep-dive-into-kubernetes-external-traffic-policies">这篇文章</a>里的图做说明：</p><img src="/2022/Kubernetes-Service-Model-Verification/SNAT.svg" class="" title="SNAT or Not"><p>如果没有 SNAT，被转发到 POD 的请求返回时，会尝试把请求直接返回给 Client，我们知道一个 TCP 连接的依据是(src_ip, src_port, dst_ip, dst_port)，现在client 在等待 <code>eIP/NP</code> 返回的报文，等到的却是 <code>pod IP</code> 的返回，client 不认这个报文。换句话说，经过 proxy 的流量都正常情况下都应该原路返回才能工作。</p><p>在一些情况下可能希望关闭 SNAT，K8S 提供 <code>externalTrafficPolicy: Local</code> 的配置项，但流量的流转也会发生变化，这里不深入。</p><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>这篇文章和上一篇<a href="https://lotabout.me/2022/Flannel-Verification/">Flannel 网络通信验证</a>类似，都是尝试搭建环境，在学习 kube-proxy 工作机制的同时，对 kube-proxy 的产出iptables 做一些验证。文章中验证了这些内容：</p><ol><li>验证了 service ClusterIP 和 domain 的创建，及 pod 中 <code>/etc/resolv.conf</code> 中搜索域的设置</li><li>验证了 kube-proxy 生成的 iptables 规则，并验证请求在这些规则中的流转</li><li>学习了 iptables 负载均衡的工作机制</li><li>了解了 SNAT 是什么，kube-proxy 需要做 SNAT 的原因</li></ol><p>这篇文章的信息量不大，希望读者也撸起袖子，实打实地做一些验证，能让我们对kube-proxy 涉及的 iptables 的操作有更深刻的理解。</p><h2 id="参考"><a class="header-anchor" href="#参考"></a>参考</h2><ul><li><a href="https://sookocheff.com/post/kubernetes/understanding-kubernetes-networking-model/">A Guide to the Kubernetes Networking Model</a> 讲解了 K8S 的网络模型，有一些（动）图描述网络包的走向</li><li><a href="https://serenafeng.github.io/2020/03/26/kube-proxy-in-iptables-mode/">Deep Dive kube-proxy with iptables mode</a> 深挖 kube-proxy 在 iptables 模式下的工作原理，比本文更深入</li><li><a href="https://kubernetes.io/docs/tasks/debug-application-cluster/debug-service/">Debug Service</a> K8S 官方文档，讲解 Service 不工作时常见的 Debug 方法</li></ul><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p><code>cluster.local</code> 是可以改的，但是比较麻烦，参考：<a href="https://stackoverflow.com/a/66106716">https://stackoverflow.com/a/66106716</a> <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p>参考 <a href="https://hansedong.github.io/2018/11/20/9/">https://hansedong.github.io/2018/11/20/9/</a> <a href="#fnref2" class="footnote-backref">↩</a></p></li><li id="fn3"  class="footnote-item"><p><a href="https://www.opensourcerers.org/2016/05/27/how-to-trace-iptables-in-rhel7-centos7/">https://www.opensourcerers.org/2016/05/27/how-to-trace-iptables-in-rhel7-centos7/</a> <a href="#fnref3" class="footnote-backref">↩</a></p></li><li id="fn4"  class="footnote-item"><p><a href="https://scalingo.com/blog/iptables">Turning IPTables into a TCP load balancer for fun and profit</a> <a href="#fnref4" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;blockquote&gt;
&lt;p&gt;Kubernetes gives Pods their own IP addresses and a single DNS name for a set
of Pods, and can load-balance across them.&lt;/p&gt;
</summary>
      
    
    
    
    <category term="Knowledge" scheme="https://lotabout.me/categories/Knowledge/"/>
    
    
    <category term="k8s" scheme="https://lotabout.me/tags/k8s/"/>
    
    <category term="iptables" scheme="https://lotabout.me/tags/iptables/"/>
    
    <category term="kube-proxy" scheme="https://lotabout.me/tags/kube-proxy/"/>
    
    <category term="service" scheme="https://lotabout.me/tags/service/"/>
    
  </entry>
  
  <entry>
    <title>Flannel 网络通信验证</title>
    <link href="https://lotabout.me/2022/Flannel-Verification/"/>
    <id>https://lotabout.me/2022/Flannel-Verification/</id>
    <published>2022-01-23T15:21:47.000Z</published>
    <updated>2025-11-26T12:53:47.995Z</updated>
    
    <content type="html"><![CDATA[<p>本文对 Kubernetes 使用 Flannel + vxlan 的网络通信做一个验证，并尝试说明其中使用的一些机制。整体的流程如下图：</p><img src="/2022/Flannel-Verification/Flannel-Overall.svg" class="" title="Flannel overall"><p>Kubernetes 规定了网络模型，要求<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>如下，flannel 只是其中一种实现。</p><ol><li>任意两个 pod 之间其实是可以直接通信的，无需经过显式地使用 NAT 来接收数据和地址的转换；</li><li>node 与 pod 之间是可以直接通信的，无需使用明显的地址转换；</li><li>pod 看到自己的 IP 跟别人看见它所用的 IP 是一样的，中间不能经过转换。</li></ol><h2 id="实验配置"><a class="header-anchor" href="#实验配置"></a>实验配置</h2><p>使用 3 个虚拟机搭建的 Kubernetes 1.23 集群，其中 Flannel 版本为 0.16.1. 上面起了两个服务，分别为两副本。Pod 信息如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">$ k get pods -o wide</span><br><span class="line">NAME                           READY   STATUS    RESTARTS   AGE     IP           NODE       NOMINATED NODE   READINESS GATES</span><br><span class="line">sender-779db554f9-d796q        1/1     Running   0          83s     10.244.2.7   centos73   &lt;none&gt;           &lt;none&gt;</span><br><span class="line">sender-779db554f9-kr69b        1/1     Running   0          84s     10.244.1.7   centos72   &lt;none&gt;           &lt;none&gt;</span><br><span class="line">spring-test-77d9d6dcb5-2cgs5   1/1     Running   0          5h28m   10.244.1.3   centos72   &lt;none&gt;           &lt;none&gt;</span><br><span class="line">spring-test-77d9d6dcb5-m9mvr   1/1     Running   0          5h28m   10.244.2.3   centos73   &lt;none&gt;           &lt;none&gt;</span><br></pre></td></tr></table></figure></div><p>实验里会尝试说明 <code>sender-779db554f9-kr69b</code>(<code>10.244.1.7</code>) 到<code>spring-test-77d9d6dcb5-m9mvr</code>(<code>10.244.2.3</code>)之间的网络通信。</p><h2 id="pod-与虚拟网卡"><a class="header-anchor" href="#pod-与虚拟网卡"></a>Pod 与虚拟网卡</h2><p>首先要说明的是 Pod 里看到的网卡，在宿主机上是如何实现的，这部分知识强烈推荐这篇文章：<a href="https://dustinspecker.com/posts/how-do-kubernetes-and-docker-create-ip-addresses/">How Do Kubernetes and Docker Create IP Addresses?!</a>。具体来说，是要确认下面这部分内容：</p><img src="/2022/Flannel-Verification/pod-interface-bridge.svg" class="" title="Pod and VTEP"><h3 id="pod-的网卡在哪？"><a class="header-anchor" href="#pod-的网卡在哪？"></a>Pod 的网卡在哪？</h3><p>首先，我们进入 <code>sender-779db554f9-kr69b</code> 所在 pod，看到网卡信息如下（省略了loopback）：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">root@sender-779db554f9-kr69b:/# ifconfig</span><br><span class="line">eth0: flags=4163&lt;UP,BROADCAST,RUNNING,MULTICAST&gt;  mtu 1450</span><br><span class="line">        inet 10.244.1.7  netmask 255.255.255.0  broadcast 10.244.1.255</span><br><span class="line">        ether 22:5e:27:43:63:fa  txqueuelen 0  (Ethernet)</span><br><span class="line">...</span><br></pre></td></tr></table></figure></div><p>注意 pod 的 IP 地址和 MAC 地址，之后我们在 centos71 机器上列出所有网卡信息：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ ip link</span><br><span class="line">1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000</span><br><span class="line">    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00</span><br><span class="line">2: eth0: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000</span><br><span class="line">    link/ether 52:54:00:a0:f0:57 brd ff:ff:ff:ff:ff:ff</span><br><span class="line">3: docker0: &lt;NO-CARRIER,BROADCAST,MULTICAST,UP&gt; mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default</span><br><span class="line">    link/ether 02:42:19:9e:c1:e1 brd ff:ff:ff:ff:ff:ff</span><br><span class="line">4: flannel.1: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue state UNKNOWN mode DEFAULT group default</span><br><span class="line">    link/ether f2:17:d1:67:5c:94 brd ff:ff:ff:ff:ff:ff</span><br><span class="line">5: cni0: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue state UP mode DEFAULT group default qlen 1000</span><br><span class="line">    link/ether 0a:07:55:0f:84:7f brd ff:ff:ff:ff:ff:ff</span><br><span class="line">7: veth45885375@if3: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue master cni0 state UP mode DEFAULT group default</span><br><span class="line">    link/ether a6:f6:90:57:ea:33 brd ff:ff:ff:ff:ff:ff link-netnsid 1</span><br><span class="line">11: veth8360c992@if3: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue master cni0 state UP mode DEFAULT group default</span><br><span class="line">    link/ether 26:73:c7:95:d2:0f brd ff:ff:ff:ff:ff:ff link-netnsid 2</span><br></pre></td></tr></table></figure></div><p>并没有发现 Pod 里使用的这张虚拟网卡（MAC 地址没有匹配上的）。发现不了的原因是Kubernetes/Docker 等虚拟化方案，本质上是用 namespace/cgroups 对资源进行隔离，Pod 里使用的虚拟网卡，其实在另一个网络 namespace 下，那么如何确认这一点呢？<a href="https://stackoverflow.com/a/62193064">参考这里</a> 需要如下步骤：</p><ol><li><p>查找 pod 对应的 docker container id（这里找的是 k8s 起的 pause container）:</p> <div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">$ sudo docker ps --format &#x27;&#123;&#123;.ID&#125;&#125; &#123;&#123;.Names&#125;&#125; &#123;&#123;.Image&#125;&#125;&#x27;</span><br><span class="line">7f780a596b66 k8s_app_sender-779db554f9-kr69b_default_f8c7cac8-680a-45ad-a091-2b8ada73d289_0 baobao:5000/jz/sender</span><br><span class="line">d7226b120121 k8s_POD_sender-779db554f9-kr69b_default_f8c7cac8-680a-45ad-a091-2b8ada73d289_0 registry.aliyuncs.com/google_containers/pause:3.6</span><br><span class="line">...</span><br></pre></td></tr></table></figure></div></li><li><p>这里我们要找的是 <code>k8s_POD</code> 开头的镜像，然后查找它的 pid:</p> <div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">$ sudo docker inspect --format &#x27;&#123;&#123;.State.Pid&#125;&#125;&#x27; d7226b120121</span><br><span class="line">513</span><br></pre></td></tr></table></figure></div></li><li><p>查询 PID=513 进程对应的 veth 网卡</p> <div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ sudo nsenter -t 513 -n ip link</span><br><span class="line">...</span><br><span class="line">3: eth0@if11: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue state UP mode DEFAULT group default</span><br><span class="line">    link/ether 22:5e:27:43:63:fa brd ff:ff:ff:ff:ff:ff link-netnsid 0</span><br></pre></td></tr></table></figure></div></li></ol><p>可以看到它的 MAC 地址和 POD 里看到的 MAC 地址是一样的。说明 POD 里使用的网卡就是这一张。</p><h3 id="vet-虚拟网卡"><a class="header-anchor" href="#vet-虚拟网卡"></a>VET 虚拟网卡</h3><p>上文提到每个 POD 的网卡是在自己的 namespace 下的，既然 namespace 是用来做网络隔离的，不同 namespace 下的网络自然是不通的。但是 k8s 又要求“node 与 pod 之间是可以直接通信”，于是我们需要打通两个 namespace，让宿主机和 POD 能直接通信。</p><p>这里使用的技术是 Virtual Ethernet(<a href="https://man7.org/linux/man-pages/man4/veth.4.html">VETH</a>)，VETH 是成对出现的，可以理解成创建了一条隧道，两端各是一张网卡，可以分别位于两个 namespace 之中，发往其中一端的包等价于发给另一端，这样就可以打通两个namespace。我们看 pod namespace 下的网卡：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ sudo nsenter -t 513 -n ip link</span><br><span class="line">...</span><br><span class="line">3: eth0@if11: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue state UP mode DEFAULT group default</span><br><span class="line">    link/ether 22:5e:27:43:63:fa brd ff:ff:ff:ff:ff:ff link-netnsid 0</span><br></pre></td></tr></table></figure></div><p>注意到网卡中的 <code>@if11</code> 字样，另一个关键信息是 <code>link-netnsid 0</code>，说明它关联的是ID 为 <code>0</code> 的 namespace 下的 ID 为 <code>11</code> 的网卡。我们首先确定namespace<sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup>：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">$ sudo ls /var/run/netns # docker 创建的 namespace 需要软链后才能查到</span><br><span class="line">$ sudo ip netns list</span><br><span class="line">c9e7f13179fa (id: 2)</span><br><span class="line">5cc5ba76a35a (id: 1)</span><br><span class="line">default</span><br></pre></td></tr></table></figure></div><p>虽然没有直接展示，但 <code>0</code> 对应的是默认的 namespace，也就是宿主机的 namespace。再结合之前的输出：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">11: veth8360c992@if3: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue master cni0 state UP mode DEFAULT group default</span><br><span class="line">    link/ether 26:73:c7:95:d2:0f brd ff:ff:ff:ff:ff:ff link-netnsid 2</span><br></pre></td></tr></table></figure></div><p>可以确认它关联的是 <code>veth8360c992@if3</code> 这个网卡。同理也可以反推 <code>veth8360c992</code>关联的是 <code>netnsid = 2</code> 的 <code>id = 3</code> 的网卡，也是符合预期的。</p><h3 id="虚拟网卡与桥接"><a class="header-anchor" href="#虚拟网卡与桥接"></a>虚拟网卡与桥接</h3><p>如果我们尝试通过 <code>ip addr</code> 查看 <code>veth</code> 网卡的 IP 地址，会发现它们是没有 IP 的：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ ip addr</span><br><span class="line">5: cni0: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue state UP group default qlen 1000</span><br><span class="line">    link/ether 0a:07:55:0f:84:7f brd ff:ff:ff:ff:ff:ff</span><br><span class="line">    inet 10.244.1.1/24 brd 10.244.1.255 scope global cni0</span><br><span class="line">       valid_lft forever preferred_lft forever</span><br><span class="line">7: veth45885375@if3: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue master cni0 state UP group default</span><br><span class="line">    link/ether a6:f6:90:57:ea:33 brd ff:ff:ff:ff:ff:ff link-netnsid 1</span><br><span class="line">11: veth8360c992@if3: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue master cni0 state UP group default</span><br><span class="line">    link/ether 26:73:c7:95:d2:0f brd ff:ff:ff:ff:ff:ff link-netnsid 2</span><br></pre></td></tr></table></figure></div><p>这是因为对于每个 POD，宿主机上都会创建 <code>veth</code> 虚拟网卡，而为了更方便这些卡的管理，k8s 会创建一张桥接的网卡 <code>cni0</code>。可以通过下面的命令查看：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ brctl show cni0</span><br><span class="line">bridge name     bridge id               STP enabled     interfaces</span><br><span class="line">cni0            8000.0a07550f847f       no              veth45885375</span><br><span class="line">                                                        veth8360c992</span><br></pre></td></tr></table></figure></div><p>桥接(bridge)网卡可以认为是一个 2 层的交换机，当它收到一个报文时，会根据自己维护的 MAC 地址映射表将报文从不同的端口发出，如果没有找到 MAC 地址则会往所有端口都发一份。它的 MAC 映射表如下：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ brctl showmacs cni0</span><br><span class="line">port no mac addr                is local?       ageing timer</span><br><span class="line">  3     26:73:c7:95:d2:0f       yes                0.00</span><br><span class="line">  3     26:73:c7:95:d2:0f       yes                0.00</span><br><span class="line">  2     a6:f6:90:57:ea:33       yes                0.00</span><br><span class="line">  2     a6:f6:90:57:ea:33       yes                0.00</span><br></pre></td></tr></table></figure></div><p>对数据敏感一些会发现出现的两个 MAC 地址分别对应 <code>veth45885375</code> 和<code>veth8360c992</code>。</p><h2 id="发送方"><a class="header-anchor" href="#发送方"></a>发送方</h2><p>那么当 Pod 中向另一个宿主机上的 Pod 发请求时，会发生什么呢？整体流程如下：</p><img src="/2022/Flannel-Verification/Sender.svg" class="" title="Sender"><ol><li><p>首先请求发到 Pod 内的 eth0 网卡，通过我们上面说的 VETH 的机制，相当于发送到<code>cni0</code> 网卡</p></li><li><p>此时内核需要查路由表，决定发送到哪个网卡：</p> <div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ route</span><br><span class="line">Destination     Gateway         Genmask         Flags Metric Ref    Use Iface</span><br><span class="line">default         RT-AC86U-D830   0.0.0.0         UG    0      0        0 eth0</span><br><span class="line">10.244.0.0      10.244.0.0      255.255.255.0   UG    0      0        0 flannel.1</span><br><span class="line">10.244.2.0      10.244.2.0      255.255.255.0   UG    0      0        0 flannel.1</span><br></pre></td></tr></table></figure></div><p>我们发现目标地址 <code>10.244.2.3</code> 命中 <code>10.244.2.0</code> 网段，于是发往 <code>flannel.1</code> 网卡</p></li><li><p>接下去需要由 <code>flannel.1</code> 将报文通过 <code>eth0</code> 端口发到 <code>centos73</code> 机器上，这里涉及 vxlan 的工作机制，下面详细说。</p></li></ol><h3 id="vxlan"><a class="header-anchor" href="#vxlan"></a>vxlan</h3><p>vxlan 可以这么理解：如果有一个 2 层的包，源地址是：MAC-A，目标地址是：MAC-B，但 MAC-B 可能在一个遥远的机器上，通过链路层无法直接到达。vxlan 的想法是把这个二层的包封装成一个 3 层的UDP，将 UDP 包发送到目标机器上，目标机器再把 2 层的包拆出来，发送到 MAC-B 所在的网卡。</p><p>Flannel 创建的 <code>flannel.1</code> 网卡就配置了 vxlan：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ ip -d link show</span><br><span class="line">4: flannel.1: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue state UNKNOWN mode DEFAULT group default</span><br><span class="line">    link/ether f2:17:d1:67:5c:94 brd ff:ff:ff:ff:ff:ff promiscuity 0</span><br><span class="line">    vxlan id 1 local 192.168.50.238 dev eth0 srcport 0 0 dstport 8472 nolearning ageing 300 noudpcsum noudp6zerocsumtx noudp6zerocsumrx addrgenmode eui64 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535</span><br></pre></td></tr></table></figure></div><p>可以看到输出里有 <code>vxlan</code> 字样，代表它的类型是 vxlan。那么 vxlan 具体如何工作呢？</p><ol><li><p><code>flannel.1</code> 收到请求，查找目标的 MAC 地址。请求包需要发往 <code>10.244.2.0</code>，<code>flannel.1</code> 需要决定，转发给哪个 MAC 地址才有可能到最终的目的地，这里和传统的转发没有区别，需要查找 ARP 表：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ arp</span><br><span class="line">Address                  HWtype  HWaddress           Flags Mask            Iface</span><br><span class="line">10.244.2.0               ether   16:c7:83:3b:52:63   CM                    flannel.1</span><br></pre></td></tr></table></figure></div></li><li><p><code>flannel.1</code> 决定将包发往 <code>16:c7:83:3b:52:63</code> 地址，此时 vxlan 机制介入，将这个包封装成 UDP 包，但是它需要知道，<code>16:c7:83:3b:52:63</code> 物理地址对应的包，需要发到哪台机器上，此时需要查找转发表 fdb:</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ bridge fdb show</span><br><span class="line">16:c7:83:3b:52:63 dev flannel.1 dst 192.168.50.145 self permanent</span><br></pre></td></tr></table></figure></div></li><li><p>根据 fdb 表中的 <code>dst 192.168.50.145</code>，<code>flannel.1</code> 知道需要将 UDP 包发往<code>192.168.50.145</code> 这台机器。但真正发送又需要查找路由表：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ route</span><br><span class="line">Destination     Gateway         Genmask         Flags Metric Ref    Use Iface</span><br><span class="line">192.168.50.0    0.0.0.0         255.255.255.0   U     0      0        0 eth0</span><br></pre></td></tr></table></figure></div></li><li><p>于是 UDP 包从 <code>eth0</code> 网卡发出，当然过程中也需要查找 ARP，这些常规操作不再赘述。</p></li></ol><h2 id="接收方"><a class="header-anchor" href="#接收方"></a>接收方</h2><p>接收方主要处理 vxlan 报文进行解包，同时要在网桥处需要转发到正确发送方，整体流程如下：</p><img src="/2022/Flannel-Verification/Receiver.svg" class="" title="Receiver"><ol><li><p>接收方 centos73 机器的 <code>eth0</code> 网卡接到 vxlan 的 UDP 包，将包解开发现是一个2 层的包，需要发往 <code>16:c7:83:3b:52:63</code>，即 centos73 上的 <code>flannel.1</code> 网卡</p></li><li><p><code>flannel.1</code> 接收到包，发现是 3 层的发往 <code>10.244.2.3</code> 的包，查找路由表决定转发给 <code>cni0</code>：</p> <div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos73 ~]$ route</span><br><span class="line">Kernel IP routing table</span><br><span class="line">Destination     Gateway         Genmask         Flags Metric Ref    Use Iface</span><br><span class="line">10.244.2.0      0.0.0.0         255.255.255.0   U     0      0        0 cni0</span><br></pre></td></tr></table></figure></div></li><li><p><code>cni0</code> 接收到报文，需要决定发给哪个 MAC 地址，此时需要查 ARP 表：</p> <div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos73 ~]$ arp</span><br><span class="line">Address                  HWtype  HWaddress           Flags Mask            Iface</span><br><span class="line">10.244.2.3               ether   ee:28:c4:70:20:89   C                     cni0</span><br></pre></td></tr></table></figure></div></li><li><p>于是 <code>cni0</code> 需要将包发给 <code>ee:28:c4:70:20:89</code>，但是 <code>cni0</code> 本身是个网桥(bridge)，相当于一个交换机连接了两根网线，现在要往哪个口发呢？先看 MAC 表</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos73 ~]$ brctl showmacs cni0</span><br><span class="line">port no mac addr                is local?       ageing timer</span><br><span class="line">  3     26:89:75:90:a4:6f       yes                0.00</span><br><span class="line">  3     26:89:75:90:a4:6f       yes                0.00</span><br><span class="line">  2     f2:7f:88:e2:e9:b6       yes                0.00</span><br><span class="line">  2     f2:7f:88:e2:e9:b6       yes                0.00</span><br></pre></td></tr></table></figure></div></li><li><p>由于 MAC 表里没有 <code>ee:28:c4:70:20:89</code> 的条目，于是 <code>cni0</code> 会先将请求广播，两个口都发包，等待请求，当然最终会由 <code>vethc3fdc583</code> 网卡响应，也可以看到MAC 表的更新：</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos73 ~]$ brctl showmacs cni0</span><br><span class="line">port no mac addr                is local?       ageing timer</span><br><span class="line">  3     26:89:75:90:a4:6f       yes                0.00</span><br><span class="line">  3     26:89:75:90:a4:6f       yes                0.00</span><br><span class="line">  2     ee:28:c4:70:20:89       no                 3.13      # 新条目</span><br><span class="line">  2     f2:7f:88:e2:e9:b6       yes                0.00</span><br><span class="line">  2     f2:7f:88:e2:e9:b6       yes                0.00</span><br></pre></td></tr></table></figure></div></li><li><p>于是，请求发往 <code>vethc3fdc583</code> 网卡，并由于 VETH 的作用，相当于发到了 pod<code>spring-test-77d9d6dcb5-m9mvr</code> 对应的网卡上，到达目的地。</p></li></ol><h2 id="flannel-的作用"><a class="header-anchor" href="#flannel-的作用"></a>Flannel 的作用</h2><p>上面提到的内容里，除了 <code>flannel.1</code> 网卡的名字，其它内容似乎看不到 Flannel 的身影，那么 flannel 做了哪些事呢<sup class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup>？</p><ol><li><p>flanneld 在宿主机启动时会为宿主机注册子网，如 <code>10.244.1.0</code>；添加到其它宿主机的路由条目；同时为 <code>flannel.1</code> 配置 vxlan 模式（当然也支持其它模式）</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ route</span><br><span class="line">Kernel IP routing table</span><br><span class="line">Destination     Gateway         Genmask         Flags Metric Ref    Use Iface</span><br><span class="line">10.244.0.0      10.244.0.0      255.255.255.0   UG    0      0        0 flannel.1</span><br><span class="line">10.244.1.0      0.0.0.0         255.255.255.0   U     0      0        0 cni0</span><br><span class="line">10.244.2.0      10.244.2.0      255.255.255.0   UG    0      0        0 flannel.1</span><br></pre></td></tr></table></figure></div></li><li><p>配置宿主机 ARP 条目，将其它宿主机的子网，如 <code>10.244.2.0</code> 指向 <code>flannel.1</code>网卡，且目标地址是对方宿主机上 <code>flannel.1</code> 的 MAC，如 <code>16:c7:83:3b:52:63</code></p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ arp</span><br><span class="line">Address                  HWtype  HWaddress           Flags Mask            Iface</span><br><span class="line">10.244.0.0               ether   86:e4:96:71:0a:45   CM                    flannel.1</span><br><span class="line">10.244.2.0               ether   16:c7:83:3b:52:63   CM                    flannel.1</span><br></pre></td></tr></table></figure></div></li><li><p>配置 FDB 表，将发送给 <code>16:c7:83:3b:52:63</code> 的请求，通过 <code>192.168.50.145</code> 发送</p><div class="noise-code-block" style="--code-block-max-height:inherit;"><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">[jinzhouz@centos72 ~]$ bridge fdb show</span><br><span class="line">16:c7:83:3b:52:63 dev flannel.1 dst 192.168.50.145 self permanent</span><br><span class="line">86:e4:96:71:0a:45 dev flannel.1 dst 192.168.50.48 self permanent</span><br></pre></td></tr></table></figure></div></li></ol><p>可以看到 flannel 的主要作用就是自动创建资源，然后（监听 etcd 中关于节点变动的消息）动态对 ARP、FDB 表做维护。</p><h2 id="小结"><a class="header-anchor" href="#小结"></a>小结</h2><p>本文是博主自己在学习 Flannel 过程中，结合现有的环境做的一些“验证”，尝试去理解Flannel 中各个环节的机制，具体来说有：</p><ul><li>namespace 隔离和 veth 机制打通 namespace</li><li>bridge 的工作原理，可以类比交换机</li><li>vxlan 的工作机制，以及 fdb 表的工作机制</li><li>复习了 2 层、3 层网络知识，复习路由表、ARP 表的作用</li></ul><p>另外在实验过程中尝试过用 tcpdump 抓包验证，的确可以验证一些关键信息，如发送接收了 UDP 封装的 vxlan 包，包的 MAC 地址在流转中变化等。但具体流经哪张网卡，以及其中的查表机在tcpdump中无法体现，因此这里也没有做记录。</p><p>当然，计算机网络是非常复杂的，博主也并非网工专业人士，如有理解不到位之处，请评论区指出。</p><h2 id="参考"><a class="header-anchor" href="#参考"></a>参考</h2><ul><li><a href="https://dustinspecker.com/posts/how-do-kubernetes-and-docker-create-ip-addresses/">How Do Kubernetes and Docker Create IP Addresses?!</a> 详细介绍了宿主机和 pod/container 的网络机制</li><li><a href="https://cloud.tencent.com/developer/article/1871939">深入理解kubernetes（k8s）网络原理之五-flannel原理</a> 对 flannel 运行过程讲解得比较详细</li><li><a href="https://github.com/containernetworking/cni/issues/702">In k8s, how the bridge cni0 know which veth to go for a packet</a> 解释 cni0 的工作原理，但感觉不是特别明确，需要有额外的知识才能理解</li></ul><hr class="footnotes-sep"><section class="footnotes"><ol class="footnotes-list"><li id="fn1"  class="footnote-item"><p><a href="https://www.infoq.cn/article/ERuLek5gPfUxdHC5cMTO">从零开始入门 K8s：Kubernetes 网络概念及策略控制</a> <a href="#fnref1" class="footnote-backref">↩</a></p></li><li id="fn2"  class="footnote-item"><p><a href="https://openterprise.it/2020/09/working-with-kernel-network-namespaces-created-by-docker/">https://openterprise.it/2020/09/working-with-kernel-network-namespaces-created-by-docker/</a> <a href="#fnref2" class="footnote-backref">↩</a></p></li><li id="fn3"  class="footnote-item"><p><a href="http://yangjunsss.github.io/2018-07-21/%E5%AE%B9%E5%99%A8%E7%BD%91%E7%BB%9C-Flannel-%E4%B8%BB%E8%A6%81-Backend-%E5%9F%BA%E6%9C%AC%E5%8E%9F%E7%90%86%E5%92%8C%E9%AA%8C%E8%AF%81/">容器网络 flannel 主要 backend 基本原理和验证</a> <a href="#fnref3" class="footnote-backref">↩</a></p></li></ol></section>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;本文对 Kubernetes 使用 Flannel + vxlan 的网络通信做一个验证，并尝试说明其中使用的一些机制。整体的流程如下图：&lt;/p&gt;
&lt;img src=&quot;/2022/Flannel-Verification/Flannel-Overall.svg&quot; class</summary>
      
    
    
    
    <category term="Knowledge" scheme="https://lotabout.me/categories/Knowledge/"/>
    
    
    <category term="k8s" scheme="https://lotabout.me/tags/k8s/"/>
    
    <category term="network" scheme="https://lotabout.me/tags/network/"/>
    
    <category term="flannel" scheme="https://lotabout.me/tags/flannel/"/>
    
  </entry>
  
</feed>
