go併發 - channel

喜歡嗑瓜子發表於2023-11-19

概述

併發程式設計是利用多核心能力,提升程式效能,而多執行緒之間需要相互協作、共享資源、執行緒安全等。任何併發模型都要解決執行緒間通訊問題,毫不誇張的說執行緒通訊是併發程式設計的主要問題。go使用著名的CSP(Communicating Sequential Process,通訊順序程式)併發模型,從設計之初 Go 語言就注重如何在程式語言層級上設計一個簡潔安全高效的抽象模型,讓程式設計師專注於分解問題和組合方案,而且不用被執行緒管理和訊號互斥這些繁瑣的操作分散精力。channel是執行緒簡通訊的具體實現之一,本質就是一個執行緒安全的 FIFO 阻塞佇列(先進先出),向佇列中寫入資料,在另一個執行緒從佇列讀取資料。很多語言都有類似實現,比如 Java 的執行緒池任務佇列。

基本使用

通道是引用型別,需要使用 make 建立,格式如下

通道例項 := make(chan 資料型別, 通道長度)
  • 資料型別:通道內傳輸的元素型別,可以基本資料型別,也可以使自定義資料型別。
  • 通道例項:透過make建立的通道控制程式碼,與函式名稱一樣,指向通道的記憶體首地址。
  • 通道長度:通道本質是佇列,建立時候可指定長度,預設為0

建立通道

ch1 := make(chan int)                 // 建立一個整型型別的通道
ch2 := make(chan interface{})         // 建立一個空介面型別的通道, 可以存放任意格式
ch3 := make(chan *Equip)             // 建立Equip指標型別的通道, 可以存放*Equip
ch4 := make(chan *Equip, 10)         // 建立Equip指標型別的通道, 並指定佇列長度

通道本質就是執行緒安全的佇列,建立時候可以指定佇列長度,預設為0。

向通道寫入資料,使用語法非常形象,寫入channel <-,讀取<-channel

ch2 := make(chan interface{}, 10)
ch2<- 10			// 向佇列寫入
n := <-ch2 			// 從佇列讀取
fmt.Println(n)		// 10

箭頭語法雖然很形象,但是有些奇怪,也不利於擴充套件。使用函式方式感覺更好,也更主流,如func (p *chan) get() any func (p *chan) put(any) err,擴充套件性也更強,透過引數可增加超時、同步、非同步等技能。

箭頭符號並沒有規定位置,與C指標一樣,如下兩個語句等效

ch1 := make(chan int)
i := <-ch1			
i := <- ch1

箭頭語法的讀寫有相對性,可讀性一般,有時候無法分辨是讀或寫,看起來很奇怪,如下虛擬碼

func main() {
	input := make(chan int, 2)
	output := make(chan int, 2)

	go func() {
		input <- 10
	}()
	output<- <-input
	fmt.Println(<-output)
}

管道是用於協程之間通訊,主流使用方式如下

ch2 := make(chan interface{}, 10)

go func() {
    data := <-ch2			// 使用者協程讀取
    fmt.Println(data)
}()
     
ch2 <- "hello"				// 主協程寫入
time.Sleep(time.Second)

管道也支援遍歷,與箭頭符號一樣,無資料時候迴圈將被阻塞,迴圈永遠不會結束,除非關閉管道

chanInt := make(chan int, 10)

for chanInt, ok := range chanInts {
    fmt.Println(chanInt)
}

管道也支援關閉,關閉後的管道不允許寫入,panic 異常

chanInts := make(chan int, 10)
close(chanInts)
chanInts <- 1		// panic: send on closed channel

讀取則不同,已有資料可繼續讀取,無資料時返回false,不阻塞

if value, ok := <-chanInts; ok {			// 從管道讀取資料不在阻塞
    fmt.Println("從管讀取=", value)
} else {
    fmt.Println("從管道讀取失敗", ok)
    return
}

單向管道

管道也支援單向模式,僅允許讀取、或者寫入

var queue <-chan string = make(chan string)

函式形參也可以定義定向管道

func customer(channel <-chan string) {		// 形參為只讀管道
    for {		
        message := <-channel				// 只允許讀取資料
        fmt.Println(message)
    }
}
channel := make(chan string)
go customer(channel)

管道阻塞

Go管道的讀寫都是同步模式,當管道容量還有空間,則寫入成功,否則將阻塞直到寫入成功。從管道讀取也一樣,有資料直接讀取,否則將阻塞直到讀取成功。

var done = make(chan bool)

func aGoroutine() {
    fmt.Println("hello")
    done <- true			// 寫管道
}

func main() {
    go aGoroutine()
    <-done					// 讀阻塞
}

主協程從管道讀取資料時將被阻塞,直到使用者協程寫入資料。管道非常適合用於生產者消費者模式,需要平滑兩者的效能差異,可透過管道容量實現緩衝,所以除非特定場景,都建議管道容量大於零。

有些場景可以使用管道控制執行緒併發數

// 待補充

阻塞特性也帶來了些問題,程式無法控制超時(箭頭函式語法的後遺症),go 也提供瞭解決方案, 使用select關鍵,與網路程式設計的select函式類似,監測多個通道是否可讀狀態,都可讀隨機選擇一個,都不可讀進入Default分支,否則阻塞

select {
    case n := <-input:
        fmt.Println(n)
    case m := <-output:
        fmt.Println(m)
    default:
        fmt.Println("default")
}

當然也可以使用select向管道寫入資料,只要不關閉管道總是可寫入,此時加入default分支永遠不會被執行到,如下隨機石頭剪刀布

ch := make(chan string)
go func() {
    for {
        select {
            case ch <- "石頭":
            case ch <- "剪刀":
            case ch <- "布":
        }
    }
}()

for value := range ch {
    log.Println(value)
    time.Sleep(time.Second)
}

模擬執行緒池

由於go的管道非常輕量且簡潔,大部分直接使用,封裝執行緒池模式並不常見。案例僅作為功能演示,非常簡單幾十行程式碼即可實現執行緒池的基本功能,體現了go併發模型的簡潔、高效。

type Runnable interface {
	Start()
}

// 執行緒池物件
type ThreadPool struct {
	queueSize int
	workSize  int
	channel   chan Runnable
	wait      sync.WaitGroup
}

// 工作執行緒, 執行非同步任務
func (pool *ThreadPool) doWorker(name string) {
	log.Printf("%s 啟動工作協程", name)
	for true {
		if runnable, ok := <-pool.channel; ok {
			log.Printf("%s 獲取任務, %v\n", name, runnable)
			runnable.Start()
			log.Printf("%s 任務執行成功, %v\n", name, runnable)
		} else {
			log.Printf("%s 執行緒池關閉, 退出工作協程\n", name)
			pool.wait.Done()
			return
		}
	}
}

// 啟動工作執行緒
func (pool *ThreadPool) worker() {
	pool.wait.Add(pool.workSize)
	for i := 0; i < pool.workSize; i++ {
		go pool.doWorker(fmt.Sprintf("work-%d", i))
	}
}

// Submit 提交任務
func (pool *ThreadPool) Submit(task Runnable) bool {
	defer func() { recover() }()
	pool.channel <- task
	return true
}

// Close 關閉執行緒池
func (pool *ThreadPool) Close() {
	defer func() { recover() }()
	close(pool.channel)
}

// Wait 等待執行緒池任務完成
func (pool *ThreadPool) Wait() {
	pool.Close()
	pool.wait.Wait()
}

// NewThreadPool 工廠函式,建立執行緒池
func NewThreadPool(queueSize int, workSize int) *ThreadPool {
	pool := &ThreadPool{queueSize: queueSize, workSize: workSize, channel: make(chan Runnable, queueSize)}
	pool.worker()
	return pool
}

使用執行緒池

type person struct {
	name string
}

func (p *person) Start() {
	fmt.Println(p.name)
}

func main() {
	threadPool := executor.NewThreadPool(10, 2)		// 建立執行緒池, 佇列長度10, 工作執行緒2

	for i := 0; i < 5; i++ {
		threadPool.Submit(&person{name: "xx"})		// 提交十個任務
	}
        
	threadPool.Wait()								// 阻塞等待所有任務完成
}

模擬管道

任何執行緒之間的通訊都依賴底層鎖機制,channel是對鎖機制封裝後的實現物件,與Java中執行緒池任務佇列機制幾乎一樣,但要簡潔很多。使用切片簡單模擬
介面宣告

type Queue interface {
	// Put 向佇列新增任務, 新增成功返回true, 新增失敗返回false, 佇列滿了則阻塞直到新增成功
	Put(task interface{}) bool

	// Get 從佇列獲取任務, 一直阻塞直到獲取任務, 佇列關閉且沒有任務則返回false
	Get() (interface{}, bool)

	// Size 檢視佇列中的任務數
	Size() int

	// Close 關閉佇列, 關閉後將無法新增任務, 已有的任務可以繼續獲取
	Close()
}

基於切片實現

// SliceQueue 使用切片實現, 自動擴容屬性佇列永遠都不會滿, 擴容時候會觸發資料複製, 效能一般
type SliceQueue struct {
	sync.Mutex
	cond  *sync.Cond
	queue []interface{}
	close atomic.Bool
}

func (q *SliceQueue) Get() (data interface{}, ok bool) {
	q.Lock()
	defer q.Unlock()

	for true {
		if len(q.queue) == 0 {
			if q.close.Load() == true {
				return nil, false
			}
			q.cond.Wait()
		}
		if data := q.doGet(); data != nil {
			return data, true
		}
	}
	return
}

func (q *SliceQueue) doGet() interface{} {
	if len(q.queue) >= 1 {
		data := q.queue[0]
		q.queue = q.queue[1:]
		return data
	}
	return nil
}

func (q *SliceQueue) Put(task interface{}) bool {
	q.Lock()
	defer func() {
		q.cond.Signal()
		q.Unlock()
	}()

	if q.close.Load() == true {
		return false
	}
	q.queue = append(q.queue, task)
	return true
}

func (q *SliceQueue) Size() int {
	return len(q.queue)
}

func (q *SliceQueue) Close() {
	if q.close.Load() == true {
		return
	}

	q.Lock()
	defer q.Unlock()

	q.close.Store(true)
	q.cond.Broadcast()
}

func NewSliceQueue() Queue {
	sliceQueue := &SliceQueue{queue: make([]interface{}, 0, 2)}
	sliceQueue.cond = sync.NewCond(sliceQueue)
	return sliceQueue
}

基於環行陣列實現

type ArrayQueue struct {
	sync.Mutex
	readCond     *sync.Cond
	writeCond    *sync.Cond
	readIndex    int
	writeIndex   int
	queueMaxSize int
	close        atomic.Bool
	queue        []interface{}
}

func (q *ArrayQueue) Put(task interface{}) bool {
	q.Lock()
	defer q.Unlock()

	for true {
		if q.close.Load() == true {
			return false
		}
		if q.IsFull() {
			q.writeCond.Wait()
			if q.IsFull() {
				continue
			}
		}
		q.queue[q.writeIndex] = task
		q.writeIndex = (q.writeIndex + 1) % q.queueMaxSize
		q.readCond.Signal()
		return true
	}
	return true
}

func (q *ArrayQueue) Get() (interface{}, bool) {
	q.Lock()
	defer q.Unlock()

	for true {
		if q.IsEmpty() {
			if q.close.Load() == true {
				return nil, false
			}
			q.readCond.Wait()
			if q.IsEmpty() {
				continue
			}
		}
		task := q.queue[q.readIndex]
		q.readIndex = (q.readIndex + 1) % q.queueMaxSize
		q.writeCond.Signal()
		return task, true
	}
	return nil, true
}

func (q *ArrayQueue) Size() int {
	return q.queueMaxSize
}

func (q *ArrayQueue) Close() {
	if q.close.Load() == true {
		return
	}
	q.Lock()
	q.Unlock()
	q.close.Store(true)
	q.readCond.Broadcast()
}

func (q *ArrayQueue) IsFull() bool {
	return (q.writeIndex+1)%q.queueMaxSize == q.readIndex
}

func (q *ArrayQueue) IsEmpty() bool {
	return q.readIndex == q.writeIndex
}

func NewArrayQueue(size int) Queue {
	queue := &ArrayQueue{queue: make([]interface{}, size), readIndex: 0, writeIndex: 0, queueMaxSize: size}
	queue.readCond = sync.NewCond(queue)
	queue.writeCond = sync.NewCond(queue)
	return queue
}

測試用例

func TestWith(t *testing.T) {
	q := NewSliceQueue()
	go func() {
		time.Sleep(time.Second * 2)
		q.Put(true)  // 向佇列寫入資料, 與 chan<- 功能相同
	}()

	q.Get()			// 阻塞直到讀取資料, 與 <-chan 功能相同
}

相關文章