DelayedOperationPurgatory之purgatory的實現

devos發表於2015-12-27

purgatory的超時檢測

當一個DelayedOpeartion超時(timeout)時,它需要被檢測出來,然後呼叫它的回撥方法。這個事情看起來很簡單,但做好也並不容易。

0.8.x的Kafka的實現簡單明瞭,但是效率不高。這些版本的Kafka的delayed request實現了java.util.concurrent.DelayQueue要求的DelayedItem介面。這些請求被放個DelayQueue, 然後有一個專門的執行緒從DelayQueue裡poll這些請求出來,所以被poll出來的元素也就是已過期的元素。

這樣做的壞處是DelayQueue裡插入和刪除的時間複雜度為O(logn),當其中元素很多時,還是挺費CPU。而且這個實現中,當一個請求被satisfied以後,它並沒有被立即從DelayedQueue裡移除(因為刪除特定的元素的時間複雜度為O(n)),而是隔一定數目的請求就遍歷這個Queue,從中移除元素(這樣做的開銷會小於單獨移除特定的元素)。所以,如果這個purge interval設定不好的話,就可能會OOM~

0.9.0開始,purgatory採用了新的基於timing wheel的實現,可以參見之前的blog中翻譯的Kafka之Purgatory Redesign Proposal (翻譯)

下面看一下0.9.0的原始碼裡對於超時檢測的實現。

TimingWheel

TimingWheel的原理可以看一下這篇文章驚豔的時間輪定時器Kafka之Purgatory Redesign Proposal (翻譯)也有提到其實現的原理。

Kafka的實現大體上跟通用的Timing Wheel差不多,但是結合了一些Kafka使用的特點,比如使用DelayQueue來驅動時間輪,以及bucket採用雙端連結串列的實現。

註釋裡講的例子

TimingWheel類的註釋裡也講到了它的原理,並且舉了一個例子,可以方便理解它的運作。下面介紹一下注釋裡提到的例子。不過大體看下是怎麼回事就行了,這個註釋本身的邏輯就有些問題(如果不是我理解錯的話)。而且這個例子只是說明了一下概念。

u代表最小的時間粒度,n代表時間輪的大小,即一個時間輪有多少個桶。設u等於1,等於3,起始時間是c。那麼此時,不同級別的桶是下面這樣的:

* level    buckets
* 1 [c,c] [c+1,c+1] [c+2,c+2]
* 2 [c,c+2] [c+3,c+5] [c+6,c+8]
* 3 [c,c+8] [c+9,c+17] [c+18,c+26]

bucket超時(expire)的時間,依據於bucket的起始時間。所以在c+1時刻,[c, c], [c, c+2]和[c, c+8]就都超時了。

級別1的時鐘移動到到c+1, 並且建立了[c+3, c+3]

級別2和級別3的時鐘停在c,因為它們的時鐘移動的單位分別是3和9。所以,級別2和3並不會有新的桶建立。

需要說明的是,級別2的[c, c+2]不會收到任何task,因為這個區間已經被級別1覆蓋了。對於級別3的[c, c+8]也是一樣,因為它的這個區間被級別2覆蓋了。這樣做有些浪費,但是卻簡化了實現。 

* 1        [c+1,c+1]  [c+2,c+2]  [c+3,c+3]
* 2 [c,c+2] [c+3,c+5] [c+6,c+8]
* 3 [c,c+8] [c+9,c+17] [c+18,c+26]

在c+2時刻,[c+1, c+1]變得超時。級別1的時鐘走到了c+2, 並且建立了[c+4, c+4]。

* 1        [c+2,c+2]  [c+3,c+3]  [c+4,c+4]
* 2 [c,c+2] [c+3,c+5] [c+6,c+8]
* 3 [c,c+8] [c+9,c+17] [c+18,c+18]

在c+3時刻,[c+2, c+2]變得超時,級別2移動到了c+3,並且建立了[c+5, c+5]和[c+9, c+11]

* 1        [c+3,c+3]  [c+4,c+4]  [c+5,c+5]
* 2 [c+3,c+5] [c+6,c+8] [c+9,c+11]
* 3 [c,c+8] [c+9,c+17] [c+8,c+11]

設計TimingWheel應考慮的問題

整體的設計就是按照proposal提到的來,但是有些細節需要考慮一下

先得明確一些東西的定義:

  • time unit 對一個TimingWheel,它走一個格對應的物理時間定義為這個TimingWheel的time unit。在原始碼中,tickMs這個TimingWheel的構造器引數決定了它的time unit。
  • bucket 一個bucket代表了一個時間段,它的結束時間 - 開始時間 + 1  = time unit,開始時間(物理時間)總是time unit的整數倍。一個TimingWheel由固定數量的bucket組成,這些bucket的代表的時間段互不重疊,並且完全覆蓋了這個TimingWheel當前代表的整個時間段。
  • size 一個TimingWheel的大小即是它包含多少個bucket,也就是它走一圈的時間/time unit
  • current time 當前這個TimgWheel的指標指向的那個bucket的開始時間。因此current time也總是time unit的整數倍。

實際上,上述的概念可以有不同的定義,這些定義也決定了TimingWheel的一個特定實現。上邊提到的定義,是跟原始碼裡的TimingWheel的行為一致的。

當前時間的bucket的expire time

有了這些概念,就可確定一個事情,這個事情就是上邊提到的註釋裡有些混亂的一個概念:是否認為current time所指的那個bucket是expire的?這個概念也決定了當TimingWheel tick一次的時候,是新指向的bucket變得過期,還是之前的bucket變得過期。在一個hierachical timing wheel中,高階的bucket在過期以後,需要把它裡邊的元素重新插入低階的timing wheel, 以保證整個timer(所以這些timing wheel構成一個timer)的計時精度。這就決定了,一個bucket的expire time就是這個bucket的start time。根據current time的定義,這也就決定了一個TimingWheel當前指向的那個bucket是一個已經expire的bucket,也就是在這個wheel 走動的時候,它新指向的那個bucket變得過期。

在Kafka中,bucket對應於TimerTaskList類。

何時會overflow

一個timing wheel有它可以host的時間段。這個時間段由幾個引數決定

  • currenTime 當前時間
  • tickMs 每個bucket的大小,在Kafka中物理時間的單位是毫秒。所以,tickMs是指一個桶有多少毫秒
  • wheelSize 這個timing wheel有多少個bucket

根據這些引數,一個timing wheel可以存放的request的過期時間分佈在[currentTime, currentTime + tickMs * wheelSize - 1], 包含兩端的時間。

當把一個request加到一個timing wheel,而這個request的expire time超過了上邊時間段的右端,就需要把它溢位到更高階timing wheel。

根據expire time把元素放在適當的桶內

這個操作的關鍵在於控制它的時間複雜度,它實際上可以分成兩步(這個有點像把大象放進冰箱要幾步……):

  1. 找到合適的桶
  2. 把item放到桶裡

找到合適的桶

每個timing wheel的桶的數目是固定的,這比較適於構建一個bucket array來儲存桶,況且陣列的定址更放一些。Kafka裡的實現是使用的陣列。

那麼已知一個item, 已知它的expire time(並且沒有溢位),它應該放在哪個桶裡呢?

如果我們把整個時間域按照這個timing wheel的tickMs無數多的桶,第一個桶的開始時間是Unix epoch的0毫秒,那麼可以根據expire time求出這個item應該放入的桶的編號,也就是expire time/tickMs。

例如,當前的timing wheel裡的桶的編號為0, 1, 2, 3。 currentTime為0。

在tick一次以後,0號桶就可以被重用了,我們自然可以把4號桶放進去。這時,這個timing wheel的桶編號為4, 1, 2, 3。仍然符合我們前邊提到的一個timing wheel的邊界。同樣,再tick一次以後,這個timing wheel的桶的編號變成了4, 5, 2, 3。這種方式,相當於每個桶能放的值為它在陣列裡的index + n * wheelSize (n為非負整數)。

這樣就有了根據expireTime確定桶的index的公式: (expireTime/tickMs) % wheelSize。

      // Put in its own bucket
      val virtualId = expiration / tickMs
      val bucket = buckets((virtualId % wheelSize.toLong).toInt)
      bucket.add(timerTaskEntry)

      // Set the bucket expiration time
      if (bucket.setExpiration(virtualId * tickMs)) {
        queue.offer(bucket)
      }

這裡也可以看出來一個bucket的expirationTime是virtualId * tickMs, 也就是它的起始時間。

注意,只有保證每個timing wheel裡所有元素的時間都在[currentTime, currentTime + tickMs * wheelSize - 1],這個公式才不違反我們之前對timing wheel規則的定義。

放在桶裡

在Kafka裡,bucket是用雙端連結串列實現的,因此insert一個元素的開銷是O(1)的。

Timer的運轉

以上對於Timing Wheel的規則的約定,只是定義了一種資料結構和它的運轉規則。但是Timing Wheel自身是不會隨著物理時間的前進而改變的,也是就說它需要外部驅動。

如何驅動 

對於Timing Wheel而言,這走動就是它的current time前進。隨著current time的改變,會有bucket變得過期,其它人可以從Timing Wheel中取出這些桶處理(這裡需要考慮到同步的問題,即tick這個動作需要持有timing wheel的鎖)。改變current time,對於Kafka的實現對應於Timing Wheel的advance方法。

現在有兩個問題需要解決:

  1. 如何根據使得TimingWheel根據物理時間走動。前邊的那個purgatory redesign proposal中提到過,一個簡單的方式是使用一個執行緒週期性地醒來,驅動TimingWheel前進,但這樣做的壞處是當Timing Wheel裡的元素(準確是說是非空的桶)很稀疏時,週期性地喚醒執行緒檢查的是一種浪費。所以Kafka使用了一種“按需”喚醒執行緒的方式,也就是DelayQueue。每個bucket的實現了Delay介面,getDelay(獲取超時時間)返回這個bucket的expire time,也就是它的開始時間。ExpiredOperationReaper執行緒會通過DelayQueue的poll方法阻塞自己(當前的實現是最多阻塞200毫秒,因為預設的最低階的wheel的tickMs為1ms, 所以這個timeout時間是可以接受的),當有bucket expire,它會從DelayQueue裡取出它來。Kafka的ExpiredOperationReaper的doWork方法(它會被執行緒一執呼叫)是這樣的
        override def doWork() {
          timeoutTimer.advanceClock(200L)
            estimatedTotalOperations.getAndSet(delayed)
            debug("Begin purging watch lists")
            val purged = allWatchers.map(_.purgeCompleted()).sum
            debug("Purged %d elements from watch lists.".format(purged))
          }

    它會呼叫timeoutTimer的advanceClock方法,來改變timer的當前時間(不大對呀,還有很多事要做的)。是的,不只是改變當前時間這會簡單,改變timer的當前時間一定和處理超時的bucket是一體的。而且advanceClock(200L)並不是把Timing wheel前進200ms的意思,而是傳進去了一個200ms的超時時間……。Timer的advanceClock方法實際上長這樣,感覺叫做"processExpiredBucketsAndAdvanceClock"更靠譜點~

      def advanceClock(timeoutMs: Long): Boolean = {
        var bucket = delayQueue.poll(timeoutMs, TimeUnit.MILLISECONDS) //取出超時的bucket,最多阻塞timeoutMs,當前的實現中就是200ms
        if (bucket != null) {
          writeLock.lock() //持有寫鎖(ReentrantReadWriteLock裡的寫鎖),開始處理超時的bucket
          try {
            while (bucket != null) {
              timingWheel.advanceClock(bucket.getExpiration()) //改變timing wheel的當前時間至這個超時的bucket的expire time
              bucket.flush(reinsert)//處理bucket裡的元素
              bucket = delayQueue.poll()//繼續poll其它超時的元素
            }
          } finally {
            writeLock.unlock()//釋放鎖
          }
          true
        } else {
          false
        }
      }

    這裡邊的點讓人困惑的是timingWheel.advanceClock(bucket.getExpiration())這一句,它把timing wheel的當前時間設成了bucket的expire time。為什麼是這個時間呢?因為bucket的expire time就是它的起始時間,由於poll總是取出最新過期的bucket,所以poll出來的bucket的expire time基本就是當前的物理時間。所以這麼設定以後,當writeLock被釋放後,往timer插入新的元素時,才能根據這個元素的物理時間放入到合適的桶裡。如果bucket的curernt time跟物理時間差距太大,那麼timer的計時就會不準(timer的準確性在後邊會分析一下)。

  2. 過期的桶如何處理?由於Kafka的bucket是一個陣列裡的元素,因此除非在過期後複製出裡邊的所有元素,否則就需要在持有Timing Wheel的鎖期間完成所有元素在過期後的回撥函式,清空這個bucket,然後才能釋放鎖。因此,如果在驅動器timer前進的執行緒裡呼叫這些這些元素的回撥函式(比如返回響應啥的),那麼這個驅動器執行緒持有鎖的時間可能會相當長,而且在它處理完所有過期元素之前,是不能往Timer中加入新的TimerTask的,所以Reaper不能阻塞在這些元素的回撥上,否則可能無法及時處理後邊的已過期的元素。所以Kafka使用了一個單獨的執行緒池來執行回撥。
     private def addTimerTaskEntry(timerTaskEntry: TimerTaskEntry): Unit = {
        if (!timingWheel.add(timerTaskEntry)) {
          // Already expired or cancelled
          if (!timerTaskEntry.cancelled)
            taskExecutor.submit(timerTaskEntry.timerTask)
        }
      }

    當timingWheel.add返回false時,代表這個timerTaskEntry(也就是bucket裡的一個元素,它持有一個TimerTask,DelayedOperation繼承了TimerTask)要不是已期expire了,要不是被cancel了。如果是expire了,就把它持有的TimerTask提交到taskExecutor這個ExecutorSerivce中(TimerTask實現了runnable介面)。對於DelayedOperation,也就是是它的forceComplete回撥會被執行,對於DelayedFetch和DelayedOperation,也就是會產生和傳送響應。

    需要注意的是,這個taskExecutor是一個FixedThreadPool,

      // timeout timer
      private[this] val executor = Executors.newFixedThreadPool(1, new ThreadFactory() {
        def newThread(runnable: Runnable): Thread =
          Utils.newThread("executor-"+purgatoryName, runnable, false)
      })
      private[this] val timeoutTimer = new Timer(executor)

    由於FixedThreadPool使用一個unbound queue,因此可以認為submit是非阻塞的。但是這樣帶來的問題是這個執行緒池的Queue有可能會積壓元素。(它貌似無法產生back-pressure給產生TimerTask的源頭)

    對於過期的bucket,分成兩類:1.裡邊的item都過期了,需要提交給taskExecutor執行 2. 這是一個高階的Timing Wheel,因此裡邊的元素需要放入低階別的Timing Wheel。Kafka對二者進行了統一抽象,即用一個reinsert方法完成上邊兩種處理,而reinsert呼叫的也就是上邊的addTimerTaskEntry方法。

  3. private[this] val reinsert = (timerTaskEntry: TimerTaskEntry) => addTimerTaskEntry(timerTaskEntry)

    addTimerTaskEntry方法會呼叫TimingWheel的add方法,它會根據TaskEntry的不同狀態,進行不同的處理

      def add(timerTaskEntry: TimerTaskEntry): Boolean = {
        val expiration = timerTaskEntry.timerTask.expirationMs
    
        if (timerTaskEntry.cancelled) {
          // Cancelled
          false
        } else if (expiration < currentTime + tickMs) {
          // Already expired
          false
        } else if (expiration < currentTime + interval) {
          // Put in its own bucket
          ...
          true
        } else {
          // Out of the interval. Put it into the parent timer
          if (overflowWheel == null) addOverflowWheel()
          overflowWheel.add(timerTaskEntry)
        }
      } 

 Timer的準確性

理想情況下,Timer應該在一個TimerTask的expiration time檢測到它超時,然後執行回撥。但是,實際情況是這樣的嗎? 之所以會有此疑問,是由於以下幾點:

  1. 驅動它的是前邊提到的這些邏輯,而不是直接呼叫的JDK跟時間有關的方法(像是Thread.sleep這種的)。那就得看一下這些邏輯能不能使timer準確
  2. timer本身是有精度的,就是tickMs。

bucket的expiration time是由TimingWheel的add方法確定的

      val virtualId = expiration / tickMs
      val bucket = buckets((virtualId % wheelSize.toLong).toInt)
      bucket.add(timerTaskEntry)
      // Set the bucket expiration time
      if (bucket.setExpiration(virtualId * tickMs)) {
        queue.offer(bucket)
      }

所以,一個TimerTask被放進的那個桶的的expiration time實際上是根據這個TimerTask的expiration time確定的,這是一個物理時間。由此可知,一個TimerTask應該會在自己所屬的那個相於Unix epoch 0時刻的全域性bucket(也就是id為virtualId那個bucket)的起始時刻被poll出來。但是被poll出來並不代表著這個TimerTask的回撥會被執行,實際的執行時刻取決於接下怎麼辦。

被poll出來的bucket裡的元素會經過Timer#reinsert -> Timer#addTimerTaskEntry ->  TimingWheel#add 方法,來決定怎麼辦。這個處理過程前邊提到過,重點是,只有被add方法認為是expire和TimerTask,才會被提交到執行緒池執行。而add方法是根據current time來決定TimerTask是否過期的。

    val expiration = timerTaskEntry.timerTask.expirationMs

    if (timerTaskEntry.cancelled) {
      // Cancelled
      false
    } else if (expiration < currentTime + tickMs) {
      // Already expired
      false
    } else if (expiration < currentTime + interval) {
      // Put in its own bucket

這裡就遇到了物理時間和Timer的本地時間不一致的問題,即expiration和currentTime的區別。

currentTime時在poll出來一個bucket以後確定的,邏輯是在Timer的advanceClock方法裡,前邊也提到過。

  def advanceClock(timeoutMs: Long): Boolean = {
    var bucket = delayQueue.poll(timeoutMs, TimeUnit.MILLISECONDS)
    if (bucket != null) {
      writeLock.lock()
      try {
        while (bucket != null) {
          timingWheel.advanceClock(bucket.getExpiration())
          bucket.flush(reinsert)
          bucket = delayQueue.poll()
        }
      } finally {
        writeLock.unlock()
      }
      true
    } else {
      false
    }
  }

所以bucket的本地時間,總是落後於物理時間的,落後多少取決於poll返回的時刻與bucket的expiration time的差別,以及獲取寫鎖的時間。這裡需要注意的是,這個timingWheel就是最低層的那個timingWheel,而且advanceClock方法會更新它的所有上層TimingWheel的currentTime。

這裡需要注意的是writeLock的釋放時機。Reaper執行緒會執行writeLock直至不能從delayQueue中取出元素,並且在while迴圈中的delayQueue.poll方法是沒有timeout時間的,這是為什麼呢?為什麼不在poll出來一個bucket並且處理完它以後就釋放鎖,然後再去poll呢?原因就是TimingWheel的advanceClock方法也會更新它的上級TimingWheel的currentTime。但是上級的TimingWheel裡的expire的那個bucket還並沒有處理(如果存在這樣的bucket的話)。此時,如果釋放鎖,這個上級的TimingWheel就可能處於不一致的狀態,這樣會造成過期的bucket裡的TimerTask沒有被清空時這個bucket的expiration time就被更新了,從而帶來錯誤。所以Reaper執行緒需要把所有TimingWheel的已過期的bucket全部取出來,處理完畢之後,才能允許往Timer里加入新的TimerTask。

注意到,reinsert方法執行前已經更新了timingWheel的currentTime,而TimerTaskList的flush方法會對這個list中的的TimerTask執行resinsert。

現在的問題在於,如果poll出來的是一個高層級的Timing那Wheel裡的bucket,那麼接下來的處理會不會帶來誤差。

假設最低階別的TimingWheel的tickMs是1ms, wheelSize是4,那麼第二級的bucket的tickMs就是4,設它的wheelSize也是3。那麼,第三級的TimingWheel的tickMs就是12.

1. 設最低階別的TimingWheel比物理時間落後2ms。

假設poll出來的這個bucket裡元素為a b c, 它們的過期時間分別為4 5 6 (物理時間)。reinsert執行時的物理時間為6, 而TimingWheel的currentTime為4.

那麼在reinsert執行時,所有這個bucket裡的元素都被認為已經過期,這是正確的。

2. 假設最低階別的TimingWheel比物理時間落後超過了tickMs, 設落後8ms。

poll出來的是第三級別的bucket,reinsert執行時的物理時間為 21, 這個bucket是在物理時間13被poll出來的,因此前三級wheel的currenTime被設成了12。

poll出來的這個bucket的元素a b c的過期時間分別是 14 20 22。 那麼最低層TimingWheel的add方法會認為expiration time在[12, 15]的元素已經過期,所以b和c不會被立即提交給taskExecutor執行緒池執行,而是被重新插入到最低階的TimingWheel。而由於最低階的TimingWheel的溢位閥值為15,所以b和c會被提交給它的上一級TimingWheel,而第二級TimingWheel可以管理的TimerTask的範圍是[12, 23], 因此b和c會被交給第二級的TimingWheel。

但無論如何,它總是會被加入到正確的桶裡(這個桶的過期時間符合這個TimerTask的過期時間),這個bucket會在下一次(或者再隔幾次……)的poll中被取出來。

只要最低階的TimingWheel的currentTime不會一直固定在一個值,已過期的TimerTask就一定會被提交執行。而且它被執行時間取決於它之前有多少個已經過期的元素,即它不會被無限期地延後(starve), 儘管它可能會在過期後仍然在TimingWheel的層次結構間倒騰。

那麼,就可以認為一個TimerTask實際被執行的時間取決於

  1. tickMs帶來的誤差。由於tickMs的原因,一個TimerTask可能會被分配到一個expiration time比它小的bucket裡。但是DelayedOprationPurgatory把tickMs設成1ms, 所以這個誤差可以忽略。
  2. Reaper執行緒從DelayQueue裡poll元素的延遲和處理poll出來的元素帶來的延遲。這些基本是不可控的。這會使得一個TimerTask肯定會在expiration time時刻之後的某時刻被提交執行。GC和CPU時間的分配都會影響這個延遲。別一個重要因素是獲取writeLock的時間,由於只有reapder執行緒會嘗試獲取writeLock,所以它只需要面對獲取readLock執行緒的競爭。而每次往Timer add一個TimerTask都會獲取readLock。因此只要這個ReentranceReadWriteLock是一個公平鎖,這麼做就沒問題。但是Kafka在實現Timer時並沒有把這個ReentrantReadWriteLock設成fair mode。
      // Locks used to protect data structures while ticking
      private[this] val readWriteLock = new ReentrantReadWriteLock()
    因此,如果對鎖的競爭很厲害,理論上來說,Reaper可能獲取不了寫鎖,也就是說可能會使JVM產生OOM。

總結

總之,可以認為在壓力低時(reaper可以迅速地從DelayQueue中獲取元素,並且對讀寫鎖的競爭不激烈,reaper執行緒可以獲取充足的CPU時間), Timer理論上還是挺準確的。如果吞吐量非常大,那就難說了。而且,非公平的讀寫鎖可能會使得插入TimerTask的速度大於取出TimerTask的速度,或者taskExecutor這個執行緒池處理請求的速度跟不上TimerTask生成的速度,造成OOM。


 

總結

DelayedOperationPurgatory的新設計避免了之前版本簡單地把所有DelayedOperatoin作為DelayedQueue中的元素時,DelayedQueue裡元素數目過多造成的CPU開銷。它把一同過期的所有DelayedOperatoin放在一個bucket裡,而只從DelayQueue裡poll bucket,這樣就大大減少了DelayQueue中的元素數目。為此,引入了hierachical timing wheel來組織DelayedOperation到合適的bucket裡。

ExpiredOperationReaper執行緒負責從DelayedQueue中取出expired bucket,然後根據bucket裡的TimerTask的過期時間放入適當的TimingWheel或者提交給一個執行緒池執行已經過期的TimerTask的回撥函式。由Reaper執行緒和Timer一起維護TimingWheel裡面元素的一致性(使TimingWheel裡的各個屬性和元素符合它們的定義)。

原始碼裡所有這些類: DelayedOperationPurgatory, Timer, TimingWheel, TimerTaskList 的實現都是很簡潔的,但是實現這套系統的難度還是挺高的,特別是一些併發方面的考慮(參與的主要執行緒有一個Reaper執行緒和多個處理請求的執行緒)。

有些地方分析的可能不正確,請指出。

相關文章