励健 發表於 2021-6-8 10:44:00

Go timer 是如何被调度的?

<p>hi,大家好,我是 haohongfan。</p>
<p>本篇文章剖析下 Go 定时器的相关内容。定时器不管是业务开发,还是基础架构开发,都是绕不过去的存在,由此可见定时器的重要程度。</p>
<p>我们不管用 NewTimer, timer.After,还是 timer.AfterFun 来初始化一个 timer, 这个 timer 最终都会加入到一个全局 timer 堆中,由 Go runtime 统一管理。</p>
<p>全局的 timer 堆也经历过三个阶段的重要升级。</p>
<ul>
<li>Go 1.9 版本之前,所有的计时器由全局唯一的四叉堆维护,协程间竞争激烈。</li>
<li>Go 1.10 - 1.13,全局使用 64 个四叉堆维护全部的计时器,没有本质解决 1.9 版本之前的问题</li>
<li>Go 1.14 版本之后,每个 P 单独维护一个四叉堆。</li>
</ul>
<p>Go 1.14 以后的 timer 性能得到了质的飞升,不过伴随而来的是 timer 成了 Go 里面最复杂、最难梳理的数据结构。本文不会详细分析每一个细节,我们从大体来了解 Go timer 的工作原理。</p>
<h2 id="1-使用场景">1. 使用场景</h2>
<p>Go timer 在我们代码中会经常遇到。</p>
<p>场景1:RPC 调用的防超时处理(下面代码节选 dubbogo)</p>
<pre><code class="language-go">func (c *Client) Request(request *remoting.Request, timeout time.Duration, response *remoting.PendingResponse) error {
    _, session, err := c.selectSession(c.addr)
    // .. 省略
    if totalLen, sendLen, err = c.transfer(session, request, timeout); err != nil {
      if sendLen != 0 &amp;&amp; totalLen != sendLen {
          // .. 省略
      }
      return perrors.WithStack(err)
    }

    // .. 省略
    select {
    case &lt;-getty.GetTimeWheel().After(timeout):
      return perrors.WithStack(errClientReadTimeout)
    case &lt;-response.Done:
      err = response.Err
    }
    return perrors.WithStack(err)
}
</code></pre>
<p>场景2:Context 的超时处理</p>
<pre><code class="language-go">func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()
    go doSomething()
   
    select {
    case &lt;-ctx.Done():
      fmt.Println("main", ctx.Err())
    }
}
</code></pre>
<h2 id="2-图解源码">2. 图解源码</h2>
<h3 id="21-四叉堆原理">2.1 四叉堆原理</h3>
<p>timer 的全局堆是一个四叉堆,特别是 Go 1.14 之后每个 P 都会维护着一个四叉堆,减少了 Goroutine 之间的并发问题,提升了 timer 了性能。</p>
<p>四叉堆其实就是四叉树,Go timer 是如何维护四叉堆的呢?</p>
<ul>
<li>Go runtime 调度 timer 时,触发时间更早的 timer,要减少其查询次数,尽快被触发。所以四叉树的父节点的触发时间是一定小于子节点的。</li>
<li>四叉树顾名思义最多有四个子节点,为了兼顾四叉树插、删除、重排速度,所以四个兄弟节点间并不要求其按触发早晚排序。</li>
</ul>
<p>这里用两张动图简单演示下 timer 的插入和删除</p>
<p><strong>把 timer 插入堆</strong><br>
<img src="https://cdn.jsdelivr.net/gh/georgehao/img/add_timer.gif"></p>
<p><strong>把 timer 从堆中删除</strong><br>
<img src="https://cdn.jsdelivr.net/gh/georgehao/img/delete_timer.gif"></p>
<h3 id="22-timer-是如何被调度的">2.2 timer 是如何被调度的?</h3>
<ul>
<li>调用 NewTimer,timer.After, timer.AfterFunc 生产 timer, 加入对应的 P 的堆上。</li>
<li>调用 timer.Stop, timer.Reset 改变对应的 timer 的状态。</li>
<li>GMP 在调度周期内中会调用 checkTimers ,遍历该 P 的 timer 堆上的元素,根据对应 timer 的状态执行真的操作。</li>
</ul>
<p><img src="https://cdn.jsdelivr.net/gh/georgehao/img/timer4.png"></p>
<h3 id="23-timer-是如何加入到-timer-堆上的">2.3 timer 是如何加入到 timer 堆上的?</h3>
<p>把 timer 加入调度总共有下面几种方式:</p>
<ul>
<li>通过 NewTimer, time.After, timer.AfterFunc 初始化 timer 后,相关 timer 就会被放入到对应 p 的 timer 堆上。</li>
<li>timer 已经被标记为 timerRemoved,调用了 timer.Reset(d),这个 timer 也会重新被加入到 p 的 timer 堆上</li>
<li>timer 还没到需要被执行的时间,被调用了 timer.Reset(d),这个 timer 会被 GMP 调度探测到,先将该 timer 从 timer 堆上删除,然后重新加入到 timer 堆上</li>
<li>STW 时,runtime 会释放不再使用的 p 的资源,p.destroy()-&gt;timer.moveTimers,将不再被使用的 p 的 timers 上有效的 timer(状态是:timerWaiting,timerModifiedEarlier,timerModifiedLater) 都重新加入到一个新的 p 的 timer 上</li>
</ul>
<h3 id="24-reset-时-timer-是如何被操作的">2.4 Reset 时 timer 是如何被操作的?</h3>
<p>Reset 的目的是把 timer 重新加入到 timer 堆中,重新等待被触发。不过分为两种情况:</p>
<ul>
<li>被标记为 timerRemoved 的 timer,这种 timer 是已经从 timer 堆上删除了,但会重新设置被触发时间,加入到 timer 堆中</li>
<li>等待被触发的 timer,在 Reset 函数中只会修改其触发时间和状态(timerModifiedEarlier或timerModifiedLater)。这个被修改状态的 timer 也同样会被重新加入到 timer堆上,不过是由 GMP 触发的,由 checkTimers 调用 adjusttimers 或者 runtimer 来执行的。</li>
</ul>
<p><img src="https://cdn.jsdelivr.net/gh/georgehao/img/timer_reset110.png"></p>
<h3 id="25-stop-时-timer-是如何被操作的">2.5 Stop 时 timer 是如何被操作的?</h3>
<p>time.Stop 为了让 timer 停止,不再被触发,也就是从 timer 堆上删除。不过 timer.Stop 并不会真正的从 p 的 timer 堆上删除 timer,只会将 timer 的状态修改为 timerDeleted。然后等待 GMP 触发的 adjusttimers 或者 runtimer 来执行。</p>
<p>真正删除 timer 的函数有两个 dodeltimer,dodeltimer0。</p>
<p><img src="https://cdn.jsdelivr.net/gh/georgehao/img/timer_stop110.png"></p>
<h3 id="26-timer-是如何被真正执行的">2.6 Timer 是如何被真正执行的?</h3>
<p>timer 的真正执行者是 GMP。GMP 会在每个调度周期内,通过 runtime.checkTimers 调用 timer.runtimer(). timer.runtimer 会检查该 p 的 timer 堆上的所有 timer,判断这些 timer 是否能被触发。</p>
<p>如果该 timer 能够被触发,会通过回调函数 sendTime 给 Timer 的 channel C 发一个当前时间,告诉我们这个 timer 已经被触发了。</p>
<p>如果是 ticker 的话,被触发后,会计算下一次要触发的时间,重新将 timer 加入 timer 堆中。</p>
<p><img src="https://cdn.jsdelivr.net/gh/georgehao/img/timer_runtimer110.png"></p>
<h2 id="3-timer-使用中的坑">3. Timer 使用中的坑</h2>
<p>确实 timer 是我们开发中比较常用的工具,但是 timer 也是最容易导致内存泄露,CPU 狂飙的杀手之一。</p>
<p>不过仔细分析可以发现,其实能够造成问题就两个方面:</p>
<ul>
<li>错误创建很多的 timer,导致资源浪费</li>
<li>由于 Stop 时不会主动关闭 C,导致程序阻塞</li>
</ul>
<h3 id="31-错误创建很多-timer导致资源浪费">3.1 错误创建很多 timer,导致资源浪费</h3>
<pre><code class="language-go">func main() {
    for {
      // xxx 一些操作
      timeout := time.After(30 * time.Second)
      select {
      case &lt;- someDone:
            // do something
      case &lt;-timeout:
            return
      }
    }
}
</code></pre>
<p>上面这段代码是造成 timer 异常的最常见的写法,也是我们最容易忽略的写法。</p>
<p>造成问题的原因其实也很简单,因为 timer.After 底层是调用的 timer.NewTimer,NewTimer 生成 timer 后,会将 timer 放入到全局的 timer 堆中。</p>
<p>for 会创建出来数以万计的 timer 放入到 timer 堆中,导致机器内存暴涨,同时不管 GMP 周期 checkTimers,还是插入新的 timer 都会疯狂遍历 timer 堆,导致 CPU 异常。</p>
<p>要注意的是,不只 time.After 会生成 timer, NewTimer,time.AfterFunc 同样也会生成 timer 加入到 timer 中,也都要防止循环调用。</p>
<p><strong>解决办法:</strong> 使用 time.Reset 重置 timer,重复利用 timer。</p>
<p>我们已经知道 time.Reset 会重新设置 timer 的触发时间,然后将 timer 重新加入到 timer 堆中,等待被触发调用。</p>
<pre><code class="language-go">func main() {
    timer := time.NewTimer(time.Second * 5)   
    for {
      t.Reset(time.Second * 5)

      select {
      case &lt;- someDone:
            // do something
      case &lt;-timer.C:
            return
      }
    }
}
</code></pre>
<h3 id="32-程序阻塞造成内存或者-goroutine-泄露">3.2 程序阻塞,造成内存或者 goroutine 泄露</h3>
<pre><code class="language-go">func main() {
    timer1 := time.NewTimer(2 * time.Second)
    &lt;-timer1.C
    println("done")
}
</code></pre>
<p>上面的代码可以看出来,只有等待 timer 超时 "done" 才会输出,原理很简单:程序阻塞在 &lt;-timer1.C 上,一直等待 timer 被触发时,回调函数 time.sendTime 才会发送一个当前时间到 timer1.C 上,程序才能继续往下执行。</p>
<p>不过使用 timer.Stop 的时候就要特别注意了,比如:</p>
<pre><code class="language-go">func main() {
    timer1 := time.NewTimer(2 * time.Second)
    go func() {
      timer1.Stop()
    }()
    &lt;-timer1.C

    println("done")
}
</code></pre>
<p>程序就会一直死锁了,因为 timer1.Stop 并不会关闭 channel C,使程序一直阻塞在 timer1.C 上。</p>
<p>上面这个例子过于简单了,试想下如果 &lt;- timer1.C 是阻塞在子协程中,timer 被的 Stop 方法被调用,那么子协程可能就会被永远的阻塞在那里,造成 goroutine 泄露,内存泄露。</p>
<p>Stop 的正确的使用方式:</p>
<pre><code class="language-go">func main() {
    timer1 := time.NewTimer(2 * time.Second)
    go func() {
      if !timer1.Stop() {
            &lt;-timer1.C
      }
    }()

    select {
    case &lt;-timer1.C:
      fmt.Println("expired")
    default:
    }
    println("done")
}
</code></pre>
<p>到此,Go timer 基本已经结束了,有想跟我讨论的可以在留言区评论。</p>


</div>
<div id="MySignature" role="contentinfo">
    博客文章如无特殊说明,都是作者原创,转载请在醒目的位置链接文章出处及作者,谢谢!<br><br>
来源:https://www.cnblogs.com/457220157-FTD/p/14861842.html
頁: [1]
查看完整版本: Go timer 是如何被调度的?