<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Bun on Kevin Cui's Blog</title><link>http://bugs.cc/zh/tags/bun/</link><description>Recent content in Bun on Kevin Cui's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh-CN</language><managingEditor>bh@bugs.cc (Kevin Cui)</managingEditor><webMaster>bh@bugs.cc (Kevin Cui)</webMaster><copyright>Kevin Cui (CC BY 4.0)</copyright><lastBuildDate>Tue, 25 Aug 2026 15:00:00 +0800</lastBuildDate><follow_challenge><feedId>67444337223193600</feedId><userId>67028119038586880</userId></follow_challenge><atom:link href="http://bugs.cc/zh/tags/bun/index.xml" rel="self" type="application/rss+xml"/><item><title>Bun 服务周期性 CPU 100% 排查</title><link>http://bugs.cc/zh/posts/troubleshooting-bun-kafkajs-cpu-spin/</link><pubDate>Tue, 25 Aug 2026 15:00:00 +0800</pubDate><author>bh@bugs.cc (Kevin Cui)</author><guid>http://bugs.cc/zh/posts/troubleshooting-bun-kafkajs-cpu-spin/</guid><description>&lt;p&gt;前几天线上遇到一个挺有意思的问题：一个用 Bun 写的服务，三个 pod 的 CPU 会周期性打满一个核，每次正好 10 分钟，然后自己掉下去。服务本身没报错，接口正常，健康检查也从来没失败过，就是 CPU 监控很难看。&lt;/p&gt;&#10;&lt;p&gt;排查完发现是两个单独看都不严重的问题叠在一起，中间还踩上了内核版本和 Bun 小版本的坑，记录一下。&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;环境：&lt;/strong&gt;&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Bun 1.3.14（Dockerfile 里写死的）&lt;/li&gt;&#10;&lt;li&gt;kafkajs 2.2.4&lt;/li&gt;&#10;&lt;li&gt;Kubernetes 节点内核 5.10.134&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;复现仓库: &lt;a href="https://github.com/BlackHole1/bun-epoll-timer-spin-repro"&gt;bun-epoll-timer-spin-repro&lt;/a&gt;，后面会用到。&lt;/p&gt;&#10;&lt;h2 id="现象"&gt;&#10; &lt;a href="#%e7%8e%b0%e8%b1%a1"&gt;现象&lt;/a&gt;&#10;&lt;/h2&gt;&#10;&lt;p&gt;&lt;img src="http://bugs.cc/images/troubleshooting-bun-kafkajs-cpu-spin/cpu-day.png" alt="某一天三个 pod 的分钟级 CPU"&gt;&lt;/p&gt;&#10;&lt;p&gt;这是某一天三个 pod 的分钟级 CPU。图里下面的竖线是每一次 Kafka 发送，后面会说到。几个特征很明显:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;CPU 顶在 0.99 核，从来没有超过 1.0，说明只有一条线程在跑满。&lt;/li&gt;&#10;&lt;li&gt;每一段都是 600 秒的整数倍，起来和落下去都在一个采样间隔内，不是慢慢爬上去的。&lt;/li&gt;&#10;&lt;li&gt;三个 pod 经常同时开始这段占用。&lt;/li&gt;&#10;&lt;li&gt;凌晨基本没有，看着跟业务量有关。&lt;/li&gt;&#10;&lt;li&gt;占用上来的时候，用户态大概 2/3，内核态大概 1/3，网络流量、fd 数、内存跟平时几乎一样。&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;最后一条其实已经能排除不少东西: 不是在处理大包，也不是纯 JS 计算（那样内核态不会这么高），更像是一个带着系统调用的空转。&lt;/p&gt;&#10;&lt;p&gt;我一开始也怀疑过是 worker 在某个任务上死循环，但是 24 小时内所有跟任务超时、重试、心跳相关的日志全是 0 条，忙的时候和闲的时候日志量也几乎一样。也不是哪次部署带进来的，指标能看到的 16 天里天天都有。&lt;/p&gt;&#10;&lt;h2 id="先在-pod-里读-proc"&gt;&#10; &lt;a href="#%e5%85%88%e5%9c%a8-pod-%e9%87%8c%e8%af%bb-proc"&gt;先在 pod 里读 /proc&lt;/a&gt;&#10;&lt;/h2&gt;&#10;&lt;p&gt;线上不能随便动，容器里也没有 perf，所以我直接用 &lt;code&gt;kubectl exec&lt;/code&gt; 读 &lt;code&gt;/proc&lt;/code&gt;，这个是完全只读的。&lt;/p&gt;</description><content:encoded><![CDATA[<p>前几天线上遇到一个挺有意思的问题：一个用 Bun 写的服务，三个 pod 的 CPU 会周期性打满一个核，每次正好 10 分钟，然后自己掉下去。服务本身没报错，接口正常，健康检查也从来没失败过，就是 CPU 监控很难看。</p>
<p>排查完发现是两个单独看都不严重的问题叠在一起，中间还踩上了内核版本和 Bun 小版本的坑，记录一下。</p>
<p><strong>环境：</strong></p>
<ul>
<li>Bun 1.3.14（Dockerfile 里写死的）</li>
<li>kafkajs 2.2.4</li>
<li>Kubernetes 节点内核 5.10.134</li>
</ul>
<p>复现仓库: <a href="https://github.com/BlackHole1/bun-epoll-timer-spin-repro">bun-epoll-timer-spin-repro</a>，后面会用到。</p>
<h2 id="现象">
  <a href="#%e7%8e%b0%e8%b1%a1">现象</a>
</h2>
<p><img src="/images/troubleshooting-bun-kafkajs-cpu-spin/cpu-day.png" alt="某一天三个 pod 的分钟级 CPU"></p>
<p>这是某一天三个 pod 的分钟级 CPU。图里下面的竖线是每一次 Kafka 发送，后面会说到。几个特征很明显:</p>
<ul>
<li>CPU 顶在 0.99 核，从来没有超过 1.0，说明只有一条线程在跑满。</li>
<li>每一段都是 600 秒的整数倍，起来和落下去都在一个采样间隔内，不是慢慢爬上去的。</li>
<li>三个 pod 经常同时开始这段占用。</li>
<li>凌晨基本没有，看着跟业务量有关。</li>
<li>占用上来的时候，用户态大概 2/3，内核态大概 1/3，网络流量、fd 数、内存跟平时几乎一样。</li>
</ul>
<p>最后一条其实已经能排除不少东西: 不是在处理大包，也不是纯 JS 计算（那样内核态不会这么高），更像是一个带着系统调用的空转。</p>
<p>我一开始也怀疑过是 worker 在某个任务上死循环，但是 24 小时内所有跟任务超时、重试、心跳相关的日志全是 0 条，忙的时候和闲的时候日志量也几乎一样。也不是哪次部署带进来的，指标能看到的 16 天里天天都有。</p>
<h2 id="先在-pod-里读-proc">
  <a href="#%e5%85%88%e5%9c%a8-pod-%e9%87%8c%e8%af%bb-proc">先在 pod 里读 /proc</a>
</h2>
<p>线上不能随便动，容器里也没有 perf，所以我直接用 <code>kubectl exec</code> 读 <code>/proc</code>，这个是完全只读的。</p>
<p>先对 <code>/proc/&lt;pid&gt;/task/*/stat</code> 里每条线程的 utime / stime 做 3 秒差分，找占 CPU 的线程。CPU 打满时的结果（100 ticks 等于一个核）:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-txt" data-lang="txt"><span class="line"><span class="cl">tid   comm        d_utime  d_stime
</span></span><span class="line"><span class="cl">8     bun         197      100
</span></span><span class="line"><span class="cl">95    HTTP        0        0
</span></span><span class="line"><span class="cl">9     bun         0        0
</span></span><span class="line"><span class="cl">16    Bun         0        0
</span></span><span class="line"><span class="cl">15    Bun         0        0
</span></span><span class="line"><span class="cl">14    Bun         0        0
</span></span><span class="line"><span class="cl">13    Bun         0        0
</span></span><span class="line"><span class="cl">12    HeapHelper  0        0
</span></span><span class="line"><span class="cl">11    HeapHelper  0        0
</span></span><span class="line"><span class="cl">10    HeapHelper  0        0</span></span></code></pre></div>
<p>占 CPU 的就是主线程（tid 和 pid 相同的那条）。然后高频去读它的 <code>/proc/&lt;pid&gt;/task/&lt;tid&gt;/syscall</code>。这个文件第一列是线程当前卡在哪个 syscall 号上，后面跟着 6 个参数；线程在用户态时，写的是 <code>running</code>:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl"><span class="nv">i</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="o">[</span> <span class="nv">$i</span> -lt <span class="m">3000</span> <span class="o">]</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  cat /proc/8/task/8/syscall &gt;&gt; /tmp/sc.txt
</span></span><span class="line"><span class="cl">  <span class="nv">i</span><span class="o">=</span><span class="k">$((</span>i+1<span class="k">))</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 停在哪个 syscall 上</span>
</span></span><span class="line"><span class="cl">awk <span class="s1">&#39;{print $1}&#39;</span> /tmp/sc.txt <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn
</span></span><span class="line"><span class="cl"><span class="c1"># epoll_pwait 的 timeout 参数（第 4 个参数，也就是第 5 列）</span>
</span></span><span class="line"><span class="cl">awk <span class="s1">&#39;$1 == 281 {print $5}&#39;</span> /tmp/sc.txt <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn</span></span></code></pre></div>
<p>syscall 号是跟架构走的。x86_64 可以去内核源码的 <a href="https://github.com/torvalds/linux/blob/master/arch/x86/entry/syscalls/syscall_64.tbl">syscall_64.tbl</a> 里查，本机装了 auditd 的话也可以直接 <code>ausyscall x86_64 281</code>。后面会碰到这几个:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-txt" data-lang="txt"><span class="line"><span class="cl">202  futex
</span></span><span class="line"><span class="cl">281  epoll_pwait
</span></span><span class="line"><span class="cl">441  epoll_pwait2</span></span></code></pre></div>
<p>arm64 上 <code>epoll_pwait</code> 是 22。不过 <code>epoll_pwait2</code> 这种近几年才加进来的，所有架构都是 441。</p>
<p>空闲时的输出:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-txt" data-lang="txt"><span class="line"><span class="cl">   2969 281
</span></span><span class="line"><span class="cl">     29 running
</span></span><span class="line"><span class="cl">      2 202
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">   1850 0x63
</span></span><span class="line"><span class="cl">    325 0x53
</span></span><span class="line"><span class="cl">    201 0x56
</span></span><span class="line"><span class="cl">    192 0x57
</span></span><span class="line"><span class="cl">     90 0x5d</span></span></code></pre></div>
<p>3000 次采样里 2969 次停在 syscall 281，也就是 <code>epoll_pwait</code>，timeout 是几十到几百毫秒，很正常。但是有个细节: 从头到尾没有出现过 441（<code>epoll_pwait2</code>）。这个后面会用到。</p>
<p>CPU 打满时的输出:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-txt" data-lang="txt"><span class="line"><span class="cl">   2979 running
</span></span><span class="line"><span class="cl">     21 281
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">     21 0x1</span></span></code></pre></div>
<p>几乎全在用户态，偶尔停在 <code>epoll_pwait</code> 上，而且 timeout 是 1ms。</p>
<p>再看 <code>/proc/&lt;pid&gt;/net/tcp</code>，空闲时只有 4 条连接（Redis、Postgres），CPU 打满时多出了两条到 9095 端口的连接，那是 Kafka broker。</p>
<h2 id="每次都是-kafka-发送">
  <a href="#%e6%af%8f%e6%ac%a1%e9%83%bd%e6%98%af-kafka-%e5%8f%91%e9%80%81">每次都是 Kafka 发送</a>
</h2>
<p>这个服务用 kafkajs 往 Kafka 发计量事件。于是我把 48 小时内所有会触发 Kafka 发送的日志拉出来，和 115 段 CPU 占用对了一下时间:</p>
<ul>
<li>每段占用的起点，距离最近一次发送在 -13 秒到 +1 秒之内（采样误差内）</li>
<li>每段占用的终点，都是这段里最后一次发送再加 600 秒到 611 秒</li>
<li>反过来，373 次发送全部落在某段占用里，一次不漏</li>
</ul>
<p>600 秒是 Kafka broker <code>connections.max.idle.ms</code> 的默认值: 连接空闲 10 分钟后，broker 会主动把连接关掉。所以这段 CPU 占用的时长，就是一条 Kafka 连接从建立到被掐掉的时间。三个 pod 同时打满，是因为同一个用户的一批任务被分到了三台机器上，几秒内各发了一条消息。</p>
<h2 id="kafkajs-里的-1ms-settimeout">
  <a href="#kafkajs-%e9%87%8c%e7%9a%84-1ms-settimeout">kafkajs 里的 1ms setTimeout</a>
</h2>
<p>看 kafkajs 2.2.4 的 <code>src/network/requestQueue/index.js</code>:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="nx">checkPendingRequests</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">pending</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="k">this</span><span class="p">.</span><span class="nx">canSendSocketRequestImmediately</span><span class="p">())</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ...
</span></span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">scheduleCheckPendingRequests</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">scheduleCheckPendingRequests</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kd">let</span> <span class="nx">scheduleAt</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">throttledUntil</span> <span class="o">-</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="k">this</span><span class="p">.</span><span class="nx">throttleCheckTimeoutId</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">pending</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">scheduleAt</span> <span class="o">=</span> <span class="nx">scheduleAt</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="o">?</span> <span class="nx">scheduleAt</span> <span class="o">:</span> <span class="nx">CHECK_PENDING_REQUESTS_INTERVAL</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">this</span><span class="p">.</span><span class="nx">throttleCheckTimeoutId</span> <span class="o">=</span> <span class="nx">setTimeout</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">this</span><span class="p">.</span><span class="nx">throttleCheckTimeoutId</span> <span class="o">=</span> <span class="kc">null</span>
</span></span><span class="line"><span class="cl">      <span class="k">this</span><span class="p">.</span><span class="nx">checkPendingRequests</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span> <span class="nx">scheduleAt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
<p><code>checkPendingRequests</code> 不管怎么样都会再调一次 <code>scheduleCheckPendingRequests</code>。后者在 pending 为空、也没有限流的情况下，依然会 <code>setTimeout</code> 一下，回调里再调 <code>checkPendingRequests</code>。而 <code>throttledUntil</code> 的初始值是 -1，所以 <code>scheduleAt</code> 是一个很大的负数。Node 和 Bun 都会把负数延迟当成 1ms 来处理。</p>
<p>于是从连接收到第一个响应开始，每条 broker 连接上就挂着一个 1ms 的 <code>setTimeout</code> 循环，直到 <code>destroy()</code> 被调用。而 <code>destroy()</code> 只在连接断开时才会调。</p>
<p>这个问题上游早有人报过（<a href="https://github.com/tulios/kafkajs/issues/1556">kafkajs#1556</a>、<a href="https://github.com/tulios/kafkajs/issues/1704">kafkajs#1704</a>），修复的 PR 也早就有了（<a href="https://github.com/tulios/kafkajs/pull/1572">kafkajs#1572</a>），但是一直没合进去。我们仓库里之前也为它打过一个 patch，不过只是把负数兜底成了 <code>Math.max(1, scheduleAt || 1)</code>，等于把这个 1ms 的循环留下来了，并没有消掉。</p>
<p>但是说到底，一个 1ms 的 <code>setTimeout</code> 循环，在 Node 上也就是 2% 左右的 CPU，怎么会打满一个核？</p>
<h2 id="为什么-bun-会打满一个核">
  <a href="#%e4%b8%ba%e4%bb%80%e4%b9%88-bun-%e4%bc%9a%e6%89%93%e6%bb%a1%e4%b8%80%e4%b8%aa%e6%a0%b8">为什么 Bun 会打满一个核</a>
</h2>
<p>答案在 Bun 的事件循环里。Bun 1.3.14 的 <code>packages/bun-usockets/src/eventing/epoll_kqueue.c</code>:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl"><span class="k">static</span> <span class="kt">int</span> <span class="nf">bun_epoll_pwait2</span><span class="p">(</span><span class="kt">int</span> <span class="n">epfd</span><span class="p">,</span> <span class="k">struct</span> <span class="n">epoll_event</span> <span class="o">*</span><span class="n">events</span><span class="p">,</span> <span class="kt">int</span> <span class="n">maxevents</span><span class="p">,</span> <span class="k">const</span> <span class="k">struct</span> <span class="n">timespec</span> <span class="o">*</span><span class="n">timeout</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">has_epoll_pwait2</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// ... sys_epoll_pwait2(...)
</span></span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kt">int</span> <span class="n">timeoutMs</span> <span class="o">=</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">timeout</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">timeoutMs</span> <span class="o">=</span> <span class="n">timeout</span><span class="o">-&gt;</span><span class="n">tv_sec</span> <span class="o">*</span> <span class="mi">1000</span> <span class="o">+</span> <span class="n">timeout</span><span class="o">-&gt;</span><span class="n">tv_nsec</span> <span class="o">/</span> <span class="mi">1000000</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">do</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">ret</span> <span class="o">=</span> <span class="nf">epoll_pwait</span><span class="p">(</span><span class="n">epfd</span><span class="p">,</span> <span class="n">events</span><span class="p">,</span> <span class="n">maxevents</span><span class="p">,</span> <span class="n">timeoutMs</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">mask</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span> <span class="k">while</span> <span class="p">(</span><span class="nf">IS_EINTR</span><span class="p">(</span><span class="n">ret</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">ret</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
<p>Bun 优先用 <code>epoll_pwait2</code>，它接受纳秒级的 <code>timespec</code>。但是 <code>epoll_pwait2</code> 是 Linux 5.11 才有的，Bun 会检查内核版本，低于 5.11 就退回 <code>epoll_pwait</code>，超时按毫秒传。问题就在 <code>tv_nsec / 1000000</code> 这一行: 向下取整。</p>
<p>一个 1ms 的定时器，在回调里又 <code>setTimeout</code> 了自己。事件循环算下一次要等多久，剩余 0.9x ms，取整之后变成 0，<code>epoll_pwait(…, 0)</code> 立刻返回，再算一次还是 0，就这样一直转到定时器到期。然后回调再设一个新的 1ms 定时器，接着转。</p>
<p>这也解释了前面 <code>/proc</code> 里看到的现象: 主线程从来没调用过 <code>epoll_pwait2</code>，因为我们的节点内核是 5.10.134。CPU 打满时采到的那些 <code>0x1</code>，是每一轮里唯一真正会睡的一次调用。timeout 为 0 的那些调用立刻就返回了，几乎采不到。</p>
<p>这段向下取整的代码 1.3.11 里就有了，但是只有 1.3.14 会出问题。差别在 1.3.14 的 <a href="https://github.com/oven-sh/bun/pull/29806">bun#29806</a>: 它把 <code>timespec.now()</code> 底层的时钟，从 Linux 上的 <code>CLOCK_MONOTONIC_COARSE</code> 换成了纳秒级的 <code>hw_timer</code>（rdtsc）。旧时钟是毫秒级的 jiffy 粒度，1ms 定时器的剩余时间要么是整 1ms，要么已经到期，不会出现 0.9ms 这种值，所以取整也取不出 0。换成纳秒时钟之后，这个早就存在的取整问题才第一次被撞上。</p>
<p>Bun 上游在 <a href="https://github.com/oven-sh/bun/pull/34780">bun#34780</a> 修掉了这个问题（改成向上取整），但只进了 1.4.0，1.3.14 之后再没有发过 1.3.x。所以受影响的就只有 1.3.14 这一个版本，而且还得跑在没有 <code>epoll_pwait2</code> 的内核上。</p>
<h2 id="本地复现">
  <a href="#%e6%9c%ac%e5%9c%b0%e5%a4%8d%e7%8e%b0">本地复现</a>
</h2>
<p>复现这个问题不需要真去找一台 5.10 内核的机器。Docker 的 seccomp 可以让某个 syscall 直接返回 ENOSYS。Bun 检测到 <code>epoll_pwait2</code> 返回 ENOSYS 时，会走同一条退回 <code>epoll_pwait</code> 的逻辑:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;defaultAction&#34;</span><span class="p">:</span> <span class="s2">&#34;SCMP_ACT_ALLOW&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;syscalls&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;names&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;epoll_pwait2&#34;</span><span class="p">],</span> <span class="nt">&#34;action&#34;</span><span class="p">:</span> <span class="s2">&#34;SCMP_ACT_ERRNO&#34;</span><span class="p">,</span> <span class="nt">&#34;errnoRet&#34;</span><span class="p">:</span> <span class="mi">38</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
<p>我把整个复现放在了 <a href="https://github.com/BlackHole1/bun-epoll-timer-spin-repro">bun-epoll-timer-spin-repro</a> 里，只依赖 Docker:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">git clone https://github.com/BlackHole1/bun-epoll-timer-spin-repro.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> bun-epoll-timer-spin-repro
</span></span><span class="line"><span class="cl">./run.sh</span></span></code></pre></div>
<p>它会在 Bun 1.3.13 / 1.3.14 / 1.4.0 三个镜像里，分别在有和没有 <code>epoll_pwait2</code> 的情况下跑三种定时器: 直接 <code>setTimeout(fn, 1)</code>、直接 <code>setTimeout(fn, 10)</code>，以及直接构造 kafkajs 的 <code>RequestQueue</code> 并调一次 <code>checkPendingRequests()</code>（不需要真的连 Kafka）。每个 case 跑 5 秒，统计定时器触发频率和 CPU:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-txt" data-lang="txt"><span class="line"><span class="cl">bun       epoll_pwait2   mode        iter/s   user%    sys%
</span></span><span class="line"><span class="cl">1.3.13    available      timer1         330     1.2     1.4
</span></span><span class="line"><span class="cl">1.3.13    available      timer10         84     1.4     0.8
</span></span><span class="line"><span class="cl">1.3.13    available      kafkajs        327     2.2     0.9
</span></span><span class="line"><span class="cl">1.3.13    blocked        timer1         327     1.7     1.1
</span></span><span class="line"><span class="cl">1.3.13    blocked        timer10         84     1.5     1.2
</span></span><span class="line"><span class="cl">1.3.13    blocked        kafkajs        321     2.3     1.5
</span></span><span class="line"><span class="cl">1.3.14    available      timer1         336     1.2     0.7
</span></span><span class="line"><span class="cl">1.3.14    available      timer10         85       1     0.2
</span></span><span class="line"><span class="cl">1.3.14    available      kafkajs        335     2.3     0.7
</span></span><span class="line"><span class="cl">1.3.14    blocked        timer1         996      56    43.5
</span></span><span class="line"><span class="cl">1.3.14    blocked        timer10         92       2     0.7
</span></span><span class="line"><span class="cl">1.3.14    blocked        kafkajs        993    62.9    36.6
</span></span><span class="line"><span class="cl">1.4.0     available      timer1         334     1.3     1.6
</span></span><span class="line"><span class="cl">1.4.0     available      timer10         86     0.8     0.4
</span></span><span class="line"><span class="cl">1.4.0     available      kafkajs        337       2     1.3
</span></span><span class="line"><span class="cl">1.4.0     blocked        timer1         338       1     1.1
</span></span><span class="line"><span class="cl">1.4.0     blocked        timer10         85     0.7     0.5
</span></span><span class="line"><span class="cl">1.4.0     blocked        kafkajs        334     1.3     1.1</span></span></code></pre></div>
<p>只有 1.3.14 加上 blocked 那两行会打满一个核。所以其实跟 kafkajs 没什么关系，任何一条 1ms 的 <code>setTimeout</code> 循环在这个组合下都一样。</p>
<p>strace 看到的就是 2 秒内 14.8 万次 <code>epoll_pwait(4, [], 1024, 0, [], 8) = 0 &lt;0.000001&gt;</code>，每次间隔 6 到 7 µs。这就是用户态 2/3、内核态 1/3 的来源。</p>
<h2 id="修复">
  <a href="#%e4%bf%ae%e5%a4%8d">修复</a>
</h2>
<p>改 kafkajs 的 patch: pending 为空且没有限流时，直接不设定时器，其余逻辑不动。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl">       if (this.pending.length &gt; 0) {
</span></span><span class="line"><span class="cl">         scheduleAt = scheduleAt &gt; 0 ? scheduleAt : CHECK_PENDING_REQUESTS_INTERVAL
</span></span><span class="line"><span class="cl">       }
</span></span><span class="line"><span class="cl"><span class="gd">-      // Prevent negative or invalid delays
</span></span></span><span class="line"><span class="cl"><span class="gd">-      scheduleAt = Math.max(1, scheduleAt || 1)
</span></span></span><span class="line"><span class="cl"><span class="gi">+      if (scheduleAt &lt;= 0) {
</span></span></span><span class="line"><span class="cl"><span class="gi">+        return
</span></span></span><span class="line"><span class="cl"><span class="gi">+      }
</span></span></span><span class="line"><span class="cl">       this.throttleCheckTimeoutId = setTimeout(() =&gt; {
</span></span></code></pre></div>
<p>复现环境里 CPU 从 99% 降到 0.2%。</p>
<p>另外顺手做了两件事:</p>
<ul>
<li>把 kafkajs 从 <code>^2.2.4</code> 改成写死 <code>2.2.4</code>。Bun 的 <code>patchedDependencies</code> 是按 <code>kafkajs@2.2.4</code> 这个 key 匹配的，一旦 lockfile 解析到别的版本，Bun 会静默跳过补丁，<code>bun install</code> 退出码还是 0，一个字都不提示。</li>
<li>加了一个直接引用 <code>kafkajs/src/network/requestQueue/index.js</code> 的测试，断言无 pending 时不会 <code>setTimeout</code>。以后 patch 丢了，测试会先挂。</li>
</ul>
<h2 id="最后">
  <a href="#%e6%9c%80%e5%90%8e">最后</a>
</h2>
<p>线上已经打上这个 patch，那段周期性打满一个核的曲线没了。等 Bun 升到 1.4.0 之后，就算 patch 丢了，这个组合也不会再出现。</p>
]]></content:encoded></item></channel></rss>