Go併發實踐
廢話不多說,先來幾行代檢驗下你是否適合本文,如果你發現看不懂建議先去看看簡單點的東西。
go f()
go f("abc", 123)
ch := make(chan int)
go func() { c <- 123}()
fmt.Println(<-ch)
簡單的例子
ok,下面假設這樣一個場景,有一家新聞媒體會持續向官方網站輸出最新消息,剛好他們的後端提供了一個api可以獲取指定分類的最新消息以及該類別預計下次有新消息的時間。我們再假設一下,他們還提供了一個SDK來幫助我們封裝好了api:
func FetchNews(kind string) (newsList []News, next time.Time, err error)
type News struct {
Kind string
Title string
Text string
}
翻看了一下SDK之後,發現還有這樣倆玩意:
type Fetcher interface {
Fetch() (newsList []News, next time.Time, err error)
}
func Fetch(kind string) Fetcher
現在希望有個這樣一個channel,我們可以一直從中讀到news,剩下的邏輯交給channel的另一頭來搞定。同時我們還希望之歌channel中可以讀到多個不同分類的news。下面先來定義一下消費者
type Subscription interface {
// 返回一個可以讀到news的channel
Flow() <-chan News
// 用來關閉channel
Close() error
}
// 用來將Fetcher轉換成消費者
func Subscribe(fetcher Fetcher) Subscription
// 用來合併多個channel的消息
func Merge(subs ...Subscription) Subscription
現在把代碼主體結構搭一下
merged := Merge(
Subscribe(Fetch("體育")),
Subscribe(Fetch("財經")),
Subscribe(Fetch("房產")),
)
// 假設只需要保持3秒,然後關閉
time.AfterFunc(time.Second*3, func() {
fmt.Println("close msg:", merged.Close())
})
for news := range merged.Flow() {
fmt.Println(news.Title)
}
接下來定義個sub
結構體來實現一下Subscription
這個接口,同時用它來完成Subscribe
方法
type sub struct {
fetcher Fetcher
flow chan News
}
// sub的主體邏輯,持續網channel裏放數據
func (s *sub) loop() {
}
// 返回channel
func (s *sub) Flow() <-chan News {
return s.flow
}
// 用來關閉channel
func (s *sub) Close() error {
return nil
}
// 用來將Fetcher轉換成消費者
func Subscribe(fetcher Fetcher) Subscription {
s := &sub{
fetcher: fetcher,
flow: make(chan News),
}
go s.loop()
return s
}
loop怎麼搞
那麼問題來了,既然主要幹活的是loop,那麼loop需要做什麼呢?首先肯定需要調用Fetch方法來拿消息,然後將拿到的消息放進channel去,然後別忘了響應Close方法調用,即退出循環。來試着寫下loop
// sub的主體邏輯,持續網channel裏放數據
func (s *sub) loop() {
// 首先大部分時候肯定是死循環讀消息了
for {
// 既然是死循環,那麼肯定要有個地方判斷是不是該關閉了,給s加個closed字段
if s.closed {
close(s.flow)
return
}
// 用它的fetcher來拿數據
newsList, next, err := s.fetcher.Fetch()
if err != nil {
// 錯誤也要存下來,在Close的時候返回,那再給s加個err字段吧
s.err = err
time.Sleep(time.Second * 3)
continue
}
// 把拿到的數據都丟進channel
for _, news := range newsList {
s.flow <- news
}
// 既然提供了下次有消息的事件,那就在到點之前"睡"一會吧
if now := time.Now(); next.After(now) {
time.Sleep(next.Sub(now))
}
}
}
這一波操作之後,sub結構體就變成了這樣:
type sub struct {
fetcher Fetcher
flow chan News
closed bool // 是否已經關了
err error // 存放出現的錯誤
}
順便把Close也可以寫出來了:
// 用來關閉channel
func (s *sub) Close() error {
s.closed = true
return s.err
}
好像少點什麼
大部分都完成了,然後需要把上面的坑填一下,首先給SDK來點假數據
func FetchNews(kind string) (newsList []News, next time.Time, err error) {
newsList = append(newsList, News{
Kind: kind,
Title: time.Now().Format("2006-01-02"),
Text: time.Now().Format("15:04:05"),
})
next = time.Now().Add(time.Second * 5)
return
}
SDK裏還有個fetcher
type Fetcher interface {
Fetch() (newsList []News, next time.Time, err error)
}
type fet struct {
kind string
}
func (f fet) Fetch() (newsList []News, next time.Time, err error) {
return FetchNews(f.kind)
}
func Fetch(kind string) Fetcher {
return fet{kind: kind}
}
還有一個merge
// 用來合併多個channel的消息
func Merge(subs ...Subscription) Subscription {
res := &sub{
flow: make(chan News),
}
for _, s := range subs {
go func(s Subscription) {
res.flow <- <-s.Flow()
}(s)
}
return res
}
到這就算"完成"了,代碼總是有bug的,來找找bug吧
bug
來看看sub的closed跟err字段,它在loop及Close方法中都用到了,而且這倆方法應該是在不同的goroutine。這樣就有了併發安全問題。
if s.closed {}
s.closed = true
另外這倆time.Sleep
也不太妙,假如在他“睡”的時候調用了Close呢,豈不是要等它醒了才能關掉
time.Sleep(time.Second * 3)
time.Sleep(next.Sub(now))
再一個比較難發現,就是有可能會導致假死的地方,仔細看看這句
s.flow <- news
我們知道channel都是阻塞的(槓精別拿緩衝區說事,想讓這裏保證不阻塞,你說緩衝區需要多大?),如果我在調用了Close之後這行代碼碰巧執行了,那麼實際上它會永遠卡在這裏。找到這仨問題之後,來藉助select一次性搞定這仨bug
改bug
select是個神奇的東西,它就像站在常年堵車的十字路口的交警,讓可以通行的車輛先走(這個比喻起始不太恰當,堵車是互相堵,channel阻塞多半跟其他channel無關)。現在請select出場
// sub的主體邏輯,持續網channel裏放數據
func (s *sub) loop() {
// 將這三個遍歷定義在外面複用
var err error
var next time.Time
var newsList []News
// 做個緩衝區,可以讓讀寫獨立工作
var cache []News
// 首先大部分時候肯定是死循環讀消息了
for {
// 首次執行的時候,delay爲0
// 非首次執行時,計算下次請求數據是什麼時候
var delay time.Duration
if now := time.Now(); next.After(now) {
delay = next.Sub(now)
}
// 這裏可以將之前的`time.Sleep`做成一個通道,到時會從通道讀出超時的時刻
chFetch := time.After(delay)
select {
// 循環控制改成了用channel控制,在Close不調用的情況下,這個寫操作會一直阻塞,select就會執行其他case
case s.closeCh <- err:
// 進這個case說明closeCh可寫了,也就是說它的讀操作正在發生,也就是說Close被調用了
close(s.flow)
return
case <-chFetch:
// 進這個case說明`time.After`時間到了,該執行下一次fetch了
newsList, next, err = s.fetcher.Fetch()
if err != nil {
// 原來的3秒後重試,可以調整爲3秒後再觸發下一次fetch,效果完全一樣
next = time.Now().Add(time.Second * 3)
break // 需要break掉當前case
}
// 將數據放進緩衝區,然後就不管後續邏輯了,讀寫分離
cache = append(cache, newsList...)
case s.flow <- cache[0]:
// 進這個case說明成功“消費”一個news,把它從cache中踢走,然後開始下一個循環
cache = cache[1:]
}
}
}
相應的sub結構體與Close方法:
type sub struct {
fetcher Fetcher
flow chan News
closeCh chan error // 把error放進channel,跟關閉用同一個
}
// 用來關閉channel
func (s *sub) Close() error {
// 從這裏讀出error並返回,其他時候是不會從中讀數據的,而不讀數據就會讓寫操作阻塞
return <-s.closeCh
}
到這還有點問題,如果cache裏沒數據了怎麼辦,繼續改
向值爲nil的channel發數據
給for循環里加個局部變量ch,每次循環一開始這個ch就變成了nil,當cache有數據就緊接着給ch賦值。下面只放了有改動的代碼
這裏有個知識點,對值爲nil的channel執行讀寫操作不會panic,只會block
for {
// 每次循環ch都是nil
var ch chan News
// 也定義個news變量,防止select裏面空指針
var news News
if len(cache) > 0 {
// cache有數據時纔給ch賦值
ch = s.flow
news = cache[0]
}
select {
// ch爲空則不會進這個case,ch不爲空纔會執行邏輯
case ch <- news:
// 進這個case說明成功“消費”一個news,把它從cache中踢走,然後開始下一個循環
cache = cache[1:]
}
}
到這裏其實還有可優化的地方,比如這個cache現在理論上可以無限“膨脹”,現在來給它加個限制
限制緩衝區大小
沒什麼好囉嗦的,直接上代碼
// 來個變量定義緩衝區最大值
const cacheSize = 10
chFetch := time.After(delay)
// 在這加個邏輯,如果緩衝區滿了,就給這個ch置爲nil,讓下面阻塞,就不會有新數據了
if len(cache) >= cacheSize {
chFetch = nil
}
結語
代碼優化總是無止境的,這裏也只是相對優化而已。代碼中還有一些地方可以精雕細琢,本文主要關注併發這塊東西,所以其他的歡迎大家在評論區嗨所欲嗨。