一文讀懂goroutine和channel


開源庫「go home」聚焦Go語言技術棧與面試題,以協助Gopher登上更大的舞台,歡迎go home~

背景介紹

大家都知道進程是操作系統資源分配的基本單位,有獨立的內存空間,線程可以共享同一個進程的內存空間,所以線程相對輕量,上下文切換開銷也小。雖然線程已經比較輕量了,但還是占近1M的內存,而今天介紹的有“輕量級線程”之稱的Goroutine,可以小至幾十K甚至幾K,切換的開銷更小。

除此之外,在傳統Socket編程時,需要維護一個線程池來為每個Socket收發包分配線程,而且需要將CPU與線程數建立對應關系,確保每個任務都能被及時分配給CPU,而Go程序可以智能地將goroutine中的任務分配到CPU。

如何使用

我們現在假設一個場景,你是一家公司老總,每天要花兩小時處理郵件,六小時開會,那么,程序可以這樣編寫。

func main() {

	time.Sleep(time.Hour * 2) //處理郵件
	time.Sleep(time.Hour * 6) //開會

	fmt.Println("工作完成了")
}

運行一下

file

果然,要8小時才能完成工作,那么怎么簡化工作呢?沒錯,請一個助理小姐姐幫忙,就讓她來處理郵件,這樣你就只需6小時開會就行了。

file

開始寫代碼吧,先來定義一個助理函數。

func assistant() {
	time.Sleep(time.Hour * 2)
}

然后在主函數用一條神器的命令go調用它,這樣助理的耗時久不再占用你的時間了。

func main() {

	go assistant()

	time.Sleep(time.Hour * 6) //開會

	fmt.Println("工作完成了")
}

運行一下

file

真的只花了六個小時就完成工作了。各位看官們,看到沒,這就是協程,只需要go命令加上函數名,就這么簡單。

file

但是我知道勤奮的你是不會滿足於現狀的。

匿名函數

另外,既然goroutine支持普通函數,當然也就支持匿名函數。

go func() {
  time.Sleep(time.Hour * 2)
}()

協程間如何通訊

雖然我們可以輕松地創建一堆協程,但是不能通信的協程是沒有靈魂的。假如助理正在幫你處理郵件時,你突然想請她喝奶茶,那是不是要通知她?

file

那怎么通知呢?這就引出了大名鼎鼎的channel,漢譯“通道”,顧名思義它的作用就是在協程之間建立通道,一端可以將數據源源不斷地傳送到通道的另一端。

file

而聲明方式也非常簡單,只需要make一下。拿下方代碼為例,它代表初始化一個通道類型變量,並且通道里只能存放string類型的數據。

ch := make(chan string)

初始化完成后,要想與協程函數建立連接,得先把chan變量傳給協程函數。

go assistant(ch)

當然,協程函數要能接收chan才行,我們縱深到函數內部,看看都干了些什么。

func assistant(ch chan string) {

	go func() {
		for {
			fmt.Println("看了一封郵件")
			time.Sleep(time.Second * 1)
		}
	}()

	msg := <-ch
	if msg == "喝奶茶去唄" {
		ch <- "好啊"
	}
}

函數內部又起了一個協程專門處理郵件,同時另外一邊等待老板通知。細心的你應該看出如何取通道數據了,沒錯,只需要在通道變量前加上<-符號就可以將值取出,同樣的,符號加在后面就是往通道塞數據。

ch <- "pingyeaa"
<- ch

file

如果通道沒有數據,消費端就會一直阻塞,直到有數據為止。當然編譯器是很聰明的,在編譯的時候如果發現沒有地方往通道里塞數據,它就會panic,提示死鎖。

fatal error: all goroutines are asleep - deadlock!

繼續來看代碼,大致意思就是老板如果發“喝奶茶去唄”,就返回“好啊”,因為通道里一開始是沒數據的,所以該協程會一直阻塞,直到主函數往通道中寫入了消息。

現在來看下主函數的實現邏輯,聲明通道和傳入通道變量就不再贅述了,我們只需要等待5秒鍾之后往通道里寫入喝奶茶消息即可。因為剛才assistant協程接收到消息后會往ch寫入“好啊”消息,所以主函數在發完請求之后應該再讀取從助理那邊傳遞來的消息。

ch := make(chan string)

go assistant(ch)

time.Sleep(time.Second * 5)
ch <- "喝奶茶去唄"

resp := <-ch
fmt.Println(resp)

同樣,主函數的<-ch也會一直阻塞,直到助理回復消息。另外有兩點需要注意,第一,如果main函數趕在goroutine之前執行完畢,那么goroutine也會銷毀;第二,main也是goroutine。

最后,關閉通道,其實通道關閉不是必須的,它與文件不同,如果沒有goroutine使用到channel,就會自動銷毀,而close的作用是用來通知通道的另一端不再發送消息了,另一端可以通過<-ch的第二個參數來獲取通道關閉情況。

close(ch)

data, ok := <-ch

通道的多路復用select

剛才的示例中的<-ch只能讀取通道的一條消息,如果通道里不止一條消息,該怎么讀取呢?

file

應該很多同學跟我一樣想到的是遍歷,沒錯,遍歷確實可以拿到通道數據。

for {
  fmt.Println(<-ch)
}

也可以這么遍歷。

for d := range ch {
  fmt.Println(d)
}

但是,如果需要同時接收多個通道數據該怎么辦?循環中接收兩個通道變量?

for {
  data, ok := <-ch1
  data, ok := <-ch2
}

這種方式雖然可以取出數據,但是性能較差,官方給我們提供的select關鍵詞就是專門用來解決多通道數據讀取問題的,語法與switch非常相似。select會將多個通道傳來的數據分發到不同的處理邏輯中。

func main() {

	ch1 := make(chan int)
	ch2 := make(chan int)

	go func() {
		for {
			select {
			case d := <-ch1:
				fmt.Println("ch1", d)
			case d := <-ch2:
				fmt.Println("ch2", d)
			}
		}
	}()

	ch1 <- 1
	ch1 <- 2
	ch2 <- 2
	ch1 <- 3
}

模擬超時

除此之外,有些情況下我們不希望通道阻塞太久,假設5秒鍾還取不出通道的數據,就超時退出,那我們可以使用time.After方法來實現。time.After會返回一個通道類型,它的作用是傳入一個目標時間(比如5s),我們在5秒后就可以通過通道獲取預設置的超時通知,這樣就達到了定時器的目的。

func main() {

	ch1 := make(chan int)
	ch2 := make(chan int)

	go func() {
		for {
			select {
			case d := <-ch1:
				fmt.Println("ch1", d)
			case d := <-ch2:
				fmt.Println("ch2", d)
			case <-time.After(time.Second * 5):
				fmt.Println("接收超時")
			}
		}
	}()

	time.Sleep(time.Second * 6)
}

通道關閉延伸閱讀

已關閉的通道再發送數據會觸發panic

ch := make(chan int)
close(ch)
ch <- 1
panic: send on closed channel

通道設置長度

可以通過make方法設置通道長度,作為緩沖區,通道滿時生產者端會阻塞,通道取空后消費端會阻塞。

ch := make(chan int, 3)

ch <- 1
ch <- 2
ch <- 2
ch <- 2

fmt.Println(len(ch))

已關閉的通道依然可以讀取數據

ch := make(chan int, 3)

ch <- 1
ch <- 2
ch <- 2

close(ch)

for d := range ch {
  fmt.Println(d)
}

感謝大家的觀看,如果覺得文章對你有所幫助,歡迎關注公眾號「平也」,聚焦Go語言與技術原理。
關注我


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM