十一. Go併發程式設計--singleflight

failymao發表於2021-11-27

一.前言

1.1 為什麼需要Singleflight?

很多程式設計師可能還是第一次聽說,本人第一次聽說這個的時候以為翻譯過來就是程式設計中被稱為的是 "單例模式"。 google之後二者天壤之別。

一般情況下我們在寫一寫對外的服務的時候都會有一層 cache 作為快取,用來減少底層資料庫的壓力,但是在遇到例如 redis 抖動或者其他情況可能會導致大量的 cache miss 出現。

1.2 使用場景

如下圖所示,可能存在來自桌面端和移動端的使用者有 1000 的併發請求,他們都訪問的獲取文章列表的介面,獲取前 20 條資訊,如果這個時候我們服務直接去訪問 redis 出現 cache miss 那麼我們就會去請求 1000 次資料庫,這時可能會給資料庫帶來較大的壓力(這裡的 1000 只是一個例子,實際上可能遠大於這個值)導致我們的服務異常或者超時。

這時候就可以使用singleflight 庫了,直譯過來就是 單飛.

這個庫的主要作用就是: 將一組相同的請求合併成一個請求,實際上只會去請求一次,然後對所有的請求返回相同的結果。 如下圖所示

二. slingleFligh 庫(使用教程)

2.1 函式簽名

主要是一個Group結構體, 結構體包含三個方法,相見程式碼註釋

type Group
    // Do 執行函式, 對同一個 key 多次呼叫的時候,在第一次呼叫沒有執行完的時候
	// 只會執行一次 fn 其他的呼叫會阻塞住等待這次呼叫返回
	// v, err 是傳入的 fn 的返回值
	// shared 表示是否真正執行了 fn 返回的結果,還是返回的共享的結果
    func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)

	// DoChan 和 Do 類似,只是 DoChan 返回一個 channel,也就是同步與非同步的區別
	func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result

    // Forget 用於通知 Group 刪除某個 key 這樣後面繼續這個 key 的呼叫的時候就不會在阻塞等待了
	func (g *Group) Forget(key string)

2.2 使用示例

先使用一個普通的例子,這時一個獲取文章詳情的函式,我們在函式裡面使用一個 count 模擬不同併發下的耗時的不同,併發越多請求耗時越多。

func getArticle(id int) (article string, err error) {
	// 假設這裡會對資料庫進行呼叫, 模擬不同併發下耗時不同
	// 使用原子操作保證併發安全
	atomic.AddInt32(&count, 1)
	time.Sleep(time.Duration(count) * time.Millisecond)

	return fmt.Sprintf("article: %d", id), nil
}

使用 singlefight 的時候就只需要 new(singleflight.Group) 然後呼叫以下相對應的Do方法

func singleflightGetArticle(sg *singleflight.Group, id int) (string, error) {
	
	v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
		return getArticle(id)
	})

	return v.(string), err
}

3. 測試

寫一個簡單的測試程式碼,模擬啟動1000個goroutine 去併發呼叫這兩個方法

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"

	"golang.org/x/sync/singleflight"
)

var count int32

func getArticle(id int) (article string, err error) {
	// 假設這裡會對資料庫進行呼叫, 模擬不同併發下耗時不同
	atomic.AddInt32(&count, 1)
	time.Sleep(time.Duration(count) * time.Millisecond)

	return fmt.Sprintf("article: %d", id), nil
}

func singleFlightGetArticle(sg *singleflight.Group, id int) (string, error) {
	v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
		return getArticle(id)
	})

	return v.(string), err
}

func main() {
	// 使用一個定時器,
	time.AfterFunc(1*time.Second, func() {
		atomic.AddInt32(&count, -count)
	})

	var (
		wg  sync.WaitGroup
		now = time.Now()
		n   = 1000
		sg  = &singleflight.Group{}
	)

	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			res, _ := singleFlightGetArticle(sg, 1)
			// res, _ := getArticle(1)
			if res != "article: 1" {
				panic("err")
			}
			wg.Done()
		}()
	}

	wg.Wait()
	fmt.Printf("同時發起 %d 次請求,耗時: %s", n, time.Since(now))
}

呼叫 getArticle 方法的耗時,花費了 1s 多

go run demo.go
同時發起 1000 次請求,耗時: 1.0157721s

切換到singleflight方法測試發現花費20ms

go run demo.go
同時發起 1000 次請求,耗時: 21.1962ms

測試發現,使用singleflight這種方式的確在這種場景下能減少執行執行,提高效率。

3.實現原理

這是非內建庫,倉庫golang.org/x/sync/singleflight 原始碼解析如下

Group
是一個結構體

type Group struct {
	mu sync.Mutex       // protects m
	m  map[string]*call // lazily initialized
}

Group 結構體由一個互斥鎖和一個 map 組成,可以看到註釋 map 是懶載入的,所以 Group 只要宣告就可以使用,不用進行額外的初始化零值就可以直接使用。call 儲存了當前呼叫對應的資訊,map 的鍵就是我們呼叫 Do 方法傳入的 key

call 也是一個結構體,結構體如下

type call struct {
	wg sync.WaitGroup                          

	// 函式的返回值,在 wg 返回前只會寫入一次
	val interface{}
	err error

	// 使用呼叫了 Forgot 方法
	forgotten bool

    // 統計呼叫次數以及返回的 channel
	dups  int
	chans []chan<- Result
}

Do
Do方法用來執行傳入函式

func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
	g.mu.Lock()

    // 前面提到的懶載入
    if g.m == nil {
		g.m = make(map[string]*call)
	}

    // 會先去看 key 是否已經存在
	if c, ok := g.m[key]; ok {
       	// 如果存在就會解鎖
		c.dups++
		g.mu.Unlock()

        // 然後等待 WaitGroup 執行完畢,只要一執行完,所有的 wait 都會被喚醒
		c.wg.Wait()

        // 這裡區分 panic 錯誤和 runtime 的錯誤,避免出現死鎖,後面可以看到為什麼這麼做
		if e, ok := c.err.(*panicError); ok {
			panic(e)
		} else if c.err == errGoexit {
			runtime.Goexit()
		}
		return c.val, c.err, true
	}

    // 如果我們沒有找到這個 key 就 new call
	c := new(call)

    // 然後呼叫 waitgroup 這裡只有第一次呼叫會 add 1,其他的都會呼叫 wait 阻塞掉
    // 所以這要這次呼叫返回,所有阻塞的呼叫都會被喚醒
	c.wg.Add(1)
	g.m[key] = c
	g.mu.Unlock()

    // 然後我們呼叫 doCall 去執行
	g.doCall(c, key, fn)
	return c.val, c.err, c.dups > 0
}

doCall
這個方法種有個技巧值得學習,使用了兩個 defer 巧妙的將 runtime 的錯誤和我們傳入 function 的 panic 區別開來避免了由於傳入的 function panic 導致的死鎖。

func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
	normalReturn := false
	recovered := false

    // 第一個 defer 檢查 runtime 錯誤
	defer func() {
        ...
	}()

    // 使用一個匿名函式來執行
	func() {
		defer func() {
			if !normalReturn {
                // 如果 panic 了我們就 recover 掉,然後 new 一個 panic 的錯誤
                // 後面在上層重新 panic
				if r := recover(); r != nil {
					c.err = newPanicError(r)
				}
			}
		}()

		c.val, c.err = fn()

        // 如果 fn 沒有 panic 就會執行到這一步,如果 panic 了就不會執行到這一步
        // 所以可以通過這個變數來判斷是否 panic 了
		normalReturn = true
	}()

    // 如果 normalReturn 為 false 就表示,我們的 fn panic 了
    // 如果執行到了這一步,也說明我們的 fn  recover 住了,不是直接 runtime exit
	if !normalReturn {
		recovered = true
	}
}

第一個defer函式如下

defer func() {
	// 如果既沒有正常執行完畢,又沒有 recover 那就說明需要直接退出了
	if !normalReturn && !recovered {
		c.err = errGoexit
	}

	c.wg.Done()
	g.mu.Lock()
	defer g.mu.Unlock()

     // 如果已經 forgot 過了,就不要重複刪除這個 key 了
	if !c.forgotten {
		delete(g.m, key)
	}

	if e, ok := c.err.(*panicError); ok {
		// 如果返回的是 panic 錯誤,為了避免 channel 死鎖,我們需要確保這個 panic 無法被恢復
		if len(c.chans) > 0 {
			go panic(e)
			select {} // Keep this goroutine around so that it will appear in the crash dump.
		} else {
			panic(e)
		}
	} else if c.err == errGoexit {
		// 已經準備退出了,也就不用做其他操作了
	} else {
		// 正常情況下向 channel 寫入資料
		for _, ch := range c.chans {
			ch <- Result{c.val, c.err, c.dups > 0}
		}
	}
}()

DoChan

Do chan 和 Do 類似,其實就是一個是同步等待,一個是非同步返回,主要實現上就是,如果呼叫 DoChan 會給 call.chans 新增一個 channel 這樣等第一次呼叫執行完畢之後就會迴圈向這些 channel 寫入資料

func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
	ch := make(chan Result, 1)
	g.mu.Lock()
	if g.m == nil {
		g.m = make(map[string]*call)
	}
	if c, ok := g.m[key]; ok {
		c.dups++
		c.chans = append(c.chans, ch)
		g.mu.Unlock()
		return ch
	}
	c := &call{chans: []chan<- Result{ch}}
	c.wg.Add(1)
	g.m[key] = c
	g.mu.Unlock()

	go g.doCall(c, key, fn)

	return ch
}

Forget
forget 用於手動釋放某個 key 下次呼叫就不會阻塞等待了

func (g *Group) Forget(key string) {
	g.mu.Lock()
	if c, ok := g.m[key]; ok {
		c.forgotten = true
	}
	delete(g.m, key)
	g.mu.Unlock()
}

三.注意事項

3.1 一個阻塞,全員等待

使用 singleflight 我們比較常見的是直接使用 Do 方法,但是這個極端情況下會導致整個程式 hang 住,如果我們的程式碼出點問題,有一個呼叫 hang 住了,那麼會導致所有的請求都 hang 住

還是之前的例子, 加入一個 select 模擬阻塞

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"

	"golang.org/x/sync/singleflight"
)

var count2 int32

func getArticle2(id int) (article string, err error) {
	// 假設這裡會對資料庫進行呼叫, 模擬不同併發下耗時不同
	atomic.AddInt32(&count2, 1)
	time.Sleep(time.Duration(count2) * time.Millisecond)

	return fmt.Sprintf("article: %d", id), nil
}

func singleFlightGetArticle2(sg *singleflight.Group, id int) (string, error) {
	v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
		// 模擬阻塞
		select {}
		return getArticle2(id)
	})

	return v.(string), err
}

func main() {
	// 使用一個定時器,
	time.AfterFunc(1*time.Second, func() {
		atomic.AddInt32(&count2, -count2)
	})

	var (
		wg  sync.WaitGroup
		now = time.Now()
		n   = 1000
		sg  = &singleflight.Group{}
	)

	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			res, _ := singleFlightGetArticle2(sg, 1)
			if res != "article: 1" {
				panic("err")
			}
			wg.Done()
		}()
	}

	wg.Wait()
	fmt.Printf("同時發起 %d 次請求,耗時: %s", n, time.Since(now))
}

執行就會發現死鎖

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [select (no cases)]:

這時候DoChan就能派上用場了,結合 select 做超時控制

func singleflightGetArticle(ctx context.Context, sg *singleflight.Group, id int) (string, error) {
	result := sg.DoChan(fmt.Sprintf("%d", id), func() (interface{}, error) {
		// 模擬出現問題,hang 住
		select {}
		return getArticle(id)
	})

	select {
	case r := <-result:
		return r.Val.(string), r.Err
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

呼叫的時候傳入一個含 超時的 context 即可

func main() {
	// 使用一個定時器,
	time.AfterFunc(1*time.Second, func() {
		atomic.AddInt32(&count2, -count2)
	})

	var (
		wg     sync.WaitGroup
		now    = time.Now()
		n      = 1000
		sg     = &singleflight.Group{}
		// 超時控制
		ctx, _ = context.WithTimeout(context.Background(), 1*time.Second)
	)

	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			res, _ := singleFlightGetArticle2(ctx, sg, 1)
			if res != "article: 1" {
				panic("err")
			}
			wg.Done()
		}()

	}

	wg.Wait()
	fmt.Printf("同時發起 %d 次請求,耗時: %s", n, time.Since(now))
}

執行時就會返回超時錯誤.

❯ go run demo2.go
panic: context deadline exceeded

3.2 一個出錯,全部出錯

本身不是什麼問題,因為singleflight就是這麼設計的。 但是實際使用的時候,可能並不是想要這樣的效果。 比如 如果一次呼叫要1s. 我們的資料庫請求或者下游服務服務可以支撐10rps(即每秒可以支援10次的請求)的請求的時候會導致我們的錯誤閾值提高。 因為我們可以1s嘗試10次,但是用了singleflight之後只能嘗試一次。只要出錯這段時間內的所有請求都會收到影響。 那這種情況該如何解決呢?

我們可以啟動一個Goroutine定時forget一下,相當於將rps 從1rps提高到10rps

go func() {
       time.Sleep(100 * time.Millisecond)
       // logging
       g.Forget(key)
   }()

四. 使用場景

如開頭場景所講, singleflige 可以有效解決在使用 Redis 對資料庫中的資料進行快取,發生快取擊穿時,大量的流量都會打到資料庫上進而影響服務的尾延時。

使用singleflight能有效解決這個問題,限制對同一個鍵值對的多次重複請求,減少對下游的瞬時流量

通過一段程式碼可以檢視如何解決這種快取擊穿的問題


type service struct {
    requestGroup singleflight.Group
}

func (s *service) handleRequest(ctx context.Context, request Request) (Response, error) {
    // request.Hash就是傳入的建,請求的雜湊在業務上一般表示相同的請求,所以上述程式碼使用它作為請求的鍵
    v, err, _ := requestGroup.Do(request.Hash(), func() (interface{}, error) {
        rows, err := // select * from tables
        if err != nil {
            return nil, err
        }
        return rows, nil
    })
    if err != nil {
        return nil, err
    }
    return Response{
        rows: rows,
    }, nil
}

五. 總結

golang/sync/singleflight.Group.Dogolang/sync/singleflight.Group.DoChan 分別提供了同步和非同步的呼叫方式,這讓我們使用起來也更加靈活。

當我們需要減少對下游的相同請求時,可以使用 golang/sync/singleflight.Group 來增加吞吐量和服務質量,不過在使用的過程中我們也需要注意以下的幾個問題:

  • golang/sync/singleflight.Group.Dogolang/sync/singleflight.Group.DoChan 一個用於同步阻塞呼叫傳入的函式,一個用於非同步呼叫傳入的引數並通過 Channel 接收函式的返回值;
  • golang/sync/singleflight.Group.Forget 可以通知 golang/sync/singleflight.Group 在持有的對映表中刪除某個鍵,接下來對該鍵的呼叫就不會等待前面的函式返回了;
  • 一旦呼叫的函式返回了錯誤,所有在等待的 Goroutine 也都會接收到同樣的錯誤;

六.參考

  1. https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives/#singleflight
  2. https://lailin.xyz/post/go-training-week5-singleflight.html
  3. https://pkg.go.dev/golang.org/x/sync/singleflight

相關文章