Go併發實踐

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
}

結語

代碼優化總是無止境的,這裏也只是相對優化而已。代碼中還有一些地方可以精雕細琢,本文主要關注併發這塊東西,所以其他的歡迎大家在評論區嗨所欲嗨。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章