基於多 goroutine 實現令牌桶

菜菜蔡偉發表於2019-02-16

前言

令牌桶是一種常見用於控制速率的控流演算法。原理於 Wikipedia 上描述如下:

  • 每秒會有 r 個令牌被放入桶中,即每 1 / r 秒向桶中放入一個令牌。

  • 一個桶最多可以存放 b 個令牌。當令牌被放入桶時,若桶已滿,則令牌被直接丟棄。

  • 當一個 n 位元組的資料包抵達時,消耗 n 個令牌,然後放行之。

  • 若桶中的令牌不足 n ,則該資料包要麼被快取要麼被丟棄。

下面我們便根據上述描述,使用 Go 語言,基於多 goroutine ,來實現是一個併發安全的令牌桶。後述程式碼的完整實現的倉庫地址在:https://github.com/DavidCai19…

基本設計

最基本的結構便是,定義一個令牌桶 struct ,該 struct 每一個新生成的令牌桶例項,各自帶有一個 goroutine ,像守護程式一樣以固定時間向例項桶中放入令牌:

type TokenBucket struct {
    interval          time.Duration  // 時間間隔
    ticker            *time.Ticker   // 定時器 timer
  // ...
    cap               int64          // 桶總容量
    avail             int64          // 桶內現有令牌數
}

func (tb *TokenBucket) adjustDaemon() {
    for now := range tb.ticker.C {
        var _ = now

        if tb.avail < tb.cap {
            tb.avail++
        }
    }
}

func New(interval time.Duration, cap int64) *TokenBucket {
    tb := &TokenBucket{
    // ...
    }

    go tb.adjustDaemon()

    return tb
}

該 struct 最終會提供以下 API :

  • TryTake(count int64) bool: 嘗試從桶中取出 n 個令牌。立刻返回,返回值表示該次取出是否成功。

  • Take(count int64):嘗試從桶中取出 n 個令牌,若當前桶中的令牌數不足,則保持等待,直至桶內令牌數量達標然後取出。

  • TakeMaxDuration(count int64, max time.Duration) bool:嘗試從桶中取出 n 個令牌,若當前桶中的令牌數不足,則保持等待,直至桶內令牌數量達標然後取出。不過設定了一個超時時間 max ,若超時,則不再等待立刻返回,返回值表示該次取出是否成功。

  • Wait(count int64):保持等待直至桶內令牌數大於等於 n

  • WaitMaxDuration(count int64, max time.Duration) bool 保持等待直至桶內令牌數大於等於 n ,但設定了一個超時時間 max

TryTake: 一次性取出嘗試

TryTake(count int64) bool 這樣的一次性取出嘗試,即可返回,實現起來最為簡易。唯一需要注意的問題為當前我們在一個多 goroutine 環境下,令牌是我們的共享資源,為了防止競爭條件,最簡單的解決方案即為存取都加上。Go 語言自帶的 sync.Mutex 類提供了鎖的實現。

type TokenBucket struct {
  // ...
    tokenMutex        *sync.Mutex // 令牌鎖
}

func (tb *TokenBucket) tryTake(count int64) bool {
    tb.tokenMutex.Lock() // 檢查共享資源,加鎖
    defer tb.tokenMutex.Unlock()

    if count <= tb.avail {
        tb.avail -= count

        return true
    }

    return false
}

func (tb *TokenBucket) adjustDaemon() {
    for now := range tb.ticker.C {
        var _ = now

    tb.tokenMutex.Lock() // 檢查共享資源,加鎖

        if tb.avail < tb.cap {
            tb.avail++
        }

    tb.tokenMutex.Unlock()
    }
}

TakeTakeMaxDuration 等待型取出(嘗試)

對於 Take(count int64)TakeMaxDuration(count int64, max time.Duration) bool 這樣的等待型取出(嘗試),情況別就有所不同了:

  1. 由於這兩個操作都是需要進行等待被通知,故原本的主動加鎖檢查共享資源的方案已不再適合。

  2. 由於可能存在多個正在等待的操作,為了避免混亂,我們需要有個先來後到,最早等待的操作,首先獲取令牌。

我們可以使用 Go 語言提供的第二種共享多 goroutine 間共享資源的方式:channel 來解決第一個問題。channel 可以是雙向的,完全符合我們需要被動通知的場景。而面對第二個問題,我們需要為等待的操作維護一個佇列。這裡我們使用的是 list.List 來模擬 FIFO 佇列,不過值得留意的是,這樣一來,佇列本身也成了一個共享資源,我們也需要為了它,來配一把鎖。

跟著上述思路,我們先來實現 Take(count int64)

type TokenBucket struct {
  // ...
    waitingQuqueMutex: &sync.Mutex{}, // 等到操作的佇列
    waitingQuque:      list.New(),    // 列隊的鎖
}

type waitingJob struct {
    ch        chan struct{}
    count     int64
}

func (tb *TokenBucket) Take(count int64) {
    w := &waitingJob{
        ch:    make(chan struct{}),
        count: count,
    }

    tb.addWaitingJob(w) // 將 w 放入列隊,需為佇列加鎖。

    <-w.ch

    close(w.ch)
}

func (tb *TokenBucket) adjustDaemon() {
  var waitingJobNow *waitingJob

    for now := range tb.ticker.C {
        var _ = now

    tb.tokenMutex.Lock() // 檢查共享資源,加鎖

        if tb.avail < tb.cap {
            tb.avail++
        }

    element := tb.getFrontWaitingJob() // 取出佇列頭,需為佇列加鎖。

    if element != nil {
            if waitingJobNow == nil {
                waitingJobNow = element.Value.(*waitingJob)

                tb.removeWaitingJob(element) // 移除佇列頭,需為佇列加鎖。
            }

      if tb.avail >= waitingJobNow.need {
        tb.avail -= waitingJobNow.count
        waitingJobNow.ch <- struct{}{}

        waitingJobNow = nil
      }
        }

    tb.tokenMutex.Unlock()
    }
}

接著我們來實現 TakeMaxDuration(count int64, max time.Duration) bool ,該操作的超時部分,我們可以使用 Go 自帶的 select 關鍵字結合定時器 channel 來實現。並且為 waitingJob 加上一個標識欄位來表明該操作是否已超時被棄用。由於檢查棄用的操作會在 adjustDaemon 中進行,而標識棄用的操作會在 TakeMaxDuration 內的 select 中,為了再次避免競爭狀態,我們將使用的令牌的操作從 adjustDaemon 內通過 channel 返回給 select 中,並阻塞,來避免了競爭條件並且享受了令牌鎖的保護:

func (tb *TokenBucket) TakeMaxDuration(count int64, max time.Duration) bool {
    w := &waitingJob{
        ch:        make(chan struct{}),
        count:     count,
        abandoned: false, // 超時棄置標識
    }

    defer close(w.ch)

    tb.addWaitingJob(w)

    select {
    case <-w.ch:
        tb.avail -= use
        w.ch <- struct{}{}
        return true
    case <-time.After(max):
        w.abandoned = true
        return false
    }
}

func (tb *TokenBucket) adjustDaemon() {
    // ...

    if element != nil {
            if waitingJobNow == nil || waitingJobNow.abandoned {
                waitingJobNow = element.Value.(*waitingJob)

                tb.removeWaitingJob(element)
            }

            if tb.avail >= waitingJobNow.need && !waitingJobNow.abandoned {
                waitingJobNow.ch <- struct{}{}
                <-waitingJobNow.ch

                waitingJobNow = nil
            }
        }

    // ...
}

最後

最後總結一些關鍵點:

  • 對於共享資源的存取,要麼使用鎖,要麼使用 channel ,視場景選擇最好用的用之。

  • channel 可被動等待共享資源,而鎖則使用十分簡易。

  • 非同步的多個等待操作,可使用佇列進行協調。

  • 可以在鎖的保護下,結合 channel 來對共享資源實現一個處理 pipeline ,結合兩者優勢,十分好用。

相關文章