Golang併發程式設計——goroutine、channel、sync

平凡鍵客 發表於 2022-07-02
Go

併發與並行

併發和並行是有區別的,併發不等於並行。

併發

兩個或多個事件在同一時間不同時間間隔發生。對應在Go中,就是指多個 goroutine 在單個CPU上的交替執行。

並行

兩個或者多個事件在同一時刻發生。對應在Go中,就是指多個 goroutine 在多個CPU上同時執行。

goroutine

介紹

goroutine 是 Go 中一種輕量級執行緒。也稱為使用者態執行緒。由 Go 的 runtime 進行管理。Go 的程式會智慧地將 goroutine 中的任務合理地分配給每個 CPU。

在程式中,我們只要使用 go 關鍵字,就可以輕易開啟一個 goroutine

建議

在使用 goroutine 時,以下兩個建議可以有效避免 goroutine 洩露。

  1. 呼叫者清楚 goroutine 什麼時候結束
  2. 呼叫者可以控制 goroutine 的生命週期

來看一個洩露的例子

func leak() {
        ch := make(chan int)
	go func() {
		<-ch//leak 函式阻塞在接受 ch 
		fmt.Println("receive a value")
	}()
}
func main() {
	leak(ch)//函式返回,
}

這個channel將無法被關閉,leak 函式裡開啟的 goroutine 也永遠無法返回,當然,這個例子中 leak 函式返回了,main 函式結束,leak 函式裡開啟的 goroutine 也就返回了。

1.呼叫者不清楚什麼時候結束,也無法控制 goroutine 的生命週期。只能被動等待 channel 接受訊號,然後執行函式邏輯,如你所見,造成的後果便是容易產生 goroutine 洩露。

來看下面一個例子

type Worker struct {
	wg sync.WaitGroup
}

func (w *Worker) Do() {
	w.wg.Add(1)

	go func() {
		defer w.wg.Done()
		//do someting
		time.Sleep(800 * time.Millisecond)
		fmt.Println("finish")
	}()

}

func (w *Worker) Shutdown(ctx context.Context) error {
	ch := make(chan struct{})
	go func() {
		w.wg.Wait()
		close(ch)
	}()

	select {
	case <-ch:
		return nil
	case <-ctx.Done():
		// time out
		// close(ch)
		return errors.New("time out")
	}
}

func main() {
	worker := &Worker{
		wg: sync.WaitGroup{},
	}
	ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(1000*time.Millisecond))
	defer cancel()
	worker.Do()
	if err := worker.Shutdown(ctx); err != nil {
		fmt.Println(err)
	}
}

有一個 worker 物件,這個物件會做一些耗時操作。我們在 Do() 方法中使用 goroutine 來處理具體邏輯,在開啟goroutine 之前呼叫 wg.Add(1), 然後在 goroutine 的 defer 函式中 呼叫 wg.Done(),在 Shutdown() 方法中使用 wg.Wait() 來等待 Do() 方法執行結束。在 Shutdown() 方法中,如果 goroutine 執行結束了,就會往 ch channel 中傳送訊息,底下 select {} 中收到 ch channel 訊息後,Shutdown 方法就可以正常返回,函式到此執行結束。如果 Do() 方法執行太長超出了 ctx 的最長時間。Shutdown 會返回 "time out" 異常。返回之前可以進行資源的處理。

在這個例子中呼叫者可以通過控制上下文控制來控制 Worker 物件的生命週期。

sync.Mutex、_s_ync.RWMutex

Go 的 sync 包提供了 mutex、RwMutex,分別是互斥鎖與讀寫鎖。

在需要共享記憶體的地方,如果有多個物件同時對這個地方進行讀寫操作,就會產生競態條件。我們需要使用程式語言提供的同步原語對讀寫操作進行保護。互斥鎖就是同一時刻一段程式碼只能被一個執行緒/協程執行。Mutex 在大量併發的情況下,會造成鎖等待,對效能的影響比較大。在讀多寫少的場景下可以使用讀寫鎖。讀寫鎖主要遵循以下原則:

  1. 讀寫鎖的讀鎖可以重入,在已經有讀鎖的情況下,可以繼續加讀鎖。
  2. 在讀鎖沒有全部解鎖時,寫操作會阻塞直到所有讀鎖解鎖。
  3. 在寫鎖沒有解鎖時,其他協程的讀寫操作都會被阻塞,直到寫鎖解鎖。

下面是一個互斥鎖簡單示例。在需要訪問共享資源的地方使用 Lock 和 Unlock 方法。表示這部分操作屬於“原子操作”。使用時需要注意鎖粒度。我們要儘可能的減小鎖粒度。鎖粒度小了,鎖競爭就少。對程式的效能影響就小。

var l sync.Mutex
var a string

func f() {
	a = "hello, world"
	l.Unlock()
}

func main() {
	l.Lock()
	go f()
	l.Lock()
	print(a)
}

sync/atomic

sync/atomic 提供了用於實現同步演算法的底層原子記憶體原語

copy-on-write 思路在微服務降級或者 local cache 經常使用。我們可以使用 atomic 來實現。atmic 依賴於原子 CPU 指令而不是依賴外部鎖,效能不俗。

type NumberArray struct {
	array []int
}

func main() {
	var atomic atomic.Value

	go func() {
		var i int
		for {
			i++
			numArray := &NumberArray{
				array: []int{i, i + 1, i + 2, i + 3},
			}
			atomic.Store(numArray)
			time.Sleep(100 * time.Millisecond)
		}
	}()

	time.Sleep(500 * time.Millisecond) //先讓資料更新

	var wg sync.WaitGroup
	for n := 0; n < 100000; n++ {
		wg.Add(1)
		time.Sleep(100 * time.Millisecond)
		go func() {
			numArray := atomic.Load()
			fmt.Println(numArray)
			wg.Done()
		}()
	}
	wg.Wait()
}

errgroup

errgroup 為處理公共任務的子任務的 goroutine 組提供同步、錯誤傳播和上下文取消。

https://github.com/go-kratos/kratos/blob/main/app.go

func (a *App) Run() error {
	instance, err := a.buildInstance()
	if err != nil {
		return err
	}
	eg, ctx := errgroup.WithContext(NewContext(a.ctx, a))
	wg := sync.WaitGroup{}
	for _, srv := range a.opts.servers {
		srv := srv
		eg.Go(func() error {
			<-ctx.Done() // wait for stop signal
			stopCtx, cancel := context.WithTimeout(NewContext(a.opts.ctx, a), a.opts.stopTimeout)
			defer cancel()
			return srv.Stop(stopCtx)
		})
		wg.Add(1)
		eg.Go(func() error {
			wg.Done()
			return srv.Start(NewContext(a.opts.ctx, a))
		})
	}
	wg.Wait()
	if a.opts.registrar != nil {
		rctx, rcancel := context.WithTimeout(ctx, a.opts.registrarTimeout)
		defer rcancel()
		if err := a.opts.registrar.Register(rctx, instance); err != nil {
			return err
		}
		a.lk.Lock()
		a.instance = instance
		a.lk.Unlock()
	}
	c := make(chan os.Signal, 1)
	signal.Notify(c, a.opts.sigs...)
	eg.Go(func() error {
		for {
			select {
			case <-ctx.Done():
				return ctx.Err()
			case <-c:
				if err := a.Stop(); err != nil {
					a.opts.logger.Errorf("failed to stop app: %v", err)
					return err
				}
			}
		}
	})
	if err := eg.Wait(); err != nil && !errors.Is(err, context.Canceled) {
		return err
	}
	return nil
}

channels

channel 是 Go 語言中一種型別安全的訊息佇列,充當兩個 goroutine 之間的通道,通過它可以進行任意資源的的交換。同時通過 channel 實現 Go 的同步機制。

無緩衝通道

當建立的 channel 沒有緩衝時,稱為無緩衝通道。無緩衝管道必須讀寫同時操作才會有效果,如果只進行讀或者只進行寫那麼會被阻塞,等待另外一方的操作。

緩衝通道

建立的 channel 具有緩衝時,稱為緩衝通道。緩衝通道是固定容量的先進先出(FIFO)佇列。容量在佇列建立的時候就已經固定,執行是無法更改。消費者從佇列中取出元素並處理它們。如果佇列為空並且消費者無事可做,就會發生阻塞,直到生產者放入一個元素。如果佇列已滿,並且消費者未開始消費,則會發生阻塞,知道消費者消費一個元素。

不論是無緩衝通道還是緩衝通道,都不能往一個已關閉的 channel 傳送訊息,否則程式會直接 panic ,因此,最好是由傳送端進行關閉 channel。

func main() {
	ch := make(chan int)
	close(ch)
	fmt.Println(<-ch)//0
	//close(ch)  //panic: close of closed channel
	//ch <- 2  //panic: send on closed channel

	chs := make(chan int, 2)
	chs <- 1
	chs <- 3
	close(chs)
	fmt.Println(<-chs)
	fmt.Println(<-chs)
	fmt.Println(<-chs)//0
	// chs <- 2 //panic: send on closed channel
}

關於channel 還可以檢視這篇文章 polarisxu:無緩衝和有緩衝通道