前言
學習和使用golang也有一段時間了,golang最近2年在國內很火,提起golang和其它語言最大區別莫過於協程,不過咱今天先不說協程,我先說一下自己的一些理解。
對c熟悉的人應該對go不陌生,它們都屬於強類型靜態編譯型語言,在語法上和PHP這種弱類型動態解釋型語言不一樣,雖然差異很大,但是基本語法都是差不多,掌握一種語言之后再去學其它語言語法不是什么大問題。
在IT行業,編程語言之爭一直是個很熱鬧的話題,編程語言之間的區別不僅僅在於語法和特性,語法只是表達編程思想的方式,一個編程語言的背后往往是其強大的生態圈,比如c語言之所以經久不衰,那是因為它幾乎可以認為是創世紀語言,是當代編程的起點,而PHP則以快速處理文本,快速搭建web網站出名,JS則是瀏覽器編程的唯一選擇,Python擁有的科學計算庫是其它語言沒有的。
說到go的優點,一般都集中在靜態編譯、毫秒級GC、簡潔、並發並行等特性上面,go是2008年誕生的,由C語言之父設計,相對其它語言來說比較年輕,可以說在設計之初吸收了各大語言的優點。
協程到底是什么東西?
說到go必須得說協程,先說說為什么需要協程,都說go是為並發編程而生,指的就是go很容易寫出高並發的程序,現代計算機硬件早已步入多核時代,前段時間AMD剛剛發布最新的銳龍3代,作為民用級的CPU現在已達到16核32線程,然而大部分編程語言依然弱智,只能利用單核性能,傳說中一核有難多核圍觀...
但是操作系統提供了多進程的能力,除了多進程之外,還有一個叫多線程,線程和進程區別不大,線程是程序執行的最小單位,一個進程可以有多個線程,編程語言可以使用多進程或多線程利用多核CPU的能力,然而現實並不是那么簡單...
進程和線程都可以解決多核CPU利用率的問題,比如PHP就整出來一個fpm,采用了master-worker模型,實際上采用多進程解決並發問題,已經非常不錯了,但是依然存在問題,支持不了太高的並發。
現在的Linux和Windows都是分時復用的多任務操作系統,上面跑着很多程序,所以操作系統需要在不同進程之間切換,這時候就產生了CPU上下文切換,具體技術細節咱可以不了解,但是存在的問題就是切換的時候非常消耗資源,默認情況下Linux只可以創建1024個進程,雖然可以修改,但是一旦進程或線程數過多,CPU的時間基本上都浪費在上下文切換上面了,何談高效?
可見,多進程和多線程並不是很完美,對於編程來說,難度非常大,所以目前只有Java有比較好的多線程模型,PHP雖然有相關擴展,但是很少有人使用,JS壓根不支持!
但是並不是必須使用多進程或多線程才可以實現高並發,很多時候,特別是web相關應用,當你讀取文件或者調用API都會產生IO,但是由於計算機硬盤、網絡傳輸速度比較慢,CPU就會一直在那等...時間就浪費了!后來有人想,既然在等IO,你就把CPU讓出來讓其它人用啊,當硬盤數據讀取到、接口返回數據的時候我通知你一聲就行了,這就是異步非阻塞IO,JS目前使用就是這種模型,Golang的協程也會用到。
在我理解,go的協程是為了解決多核CPU利用率問題,go語言層面並不支持多進程或多線程,但是協程更好用,協程被稱為用戶態線程,不存在CPU上下文切換問題,效率非常高。
實例
1.Hello World
package main func main() { go say("Hello World") } func say(s string) { println(s) }
go啟動協程的方式就是使用關鍵字go,后面一般接一個函數或者匿名函數,但是如果你運行上面第一段代碼,你會發現什么結果都沒有,what???
這至少說明你代碼寫的沒問題,當你使用go啟動協程之后,后面沒有代碼了,這時候主線程結束了,這個協程還沒來得及執行就結束了... 聰明的小伙伴會想到,那我主線程先睡眠1s等一等? Yes, 在main代碼塊最后一行加入:
time.Sleep(time.Second*1) # 表示睡眠1s
2.WaitGroup
上面睡眠這種做法肯定是不靠譜的,WaitGroup可以解決這個問題, 代碼如下:
package main import ( "sync" ) var wg = sync.WaitGroup{} func main() { wg.Add(1) go say("Hello World") wg.Wait() } func say(s string) { println(s) wg.Done() }
簡單說明一下用法,var 是聲明了一個全局變量 wg,類型是sync.WaitGroup,wg.add(1) 是說我有1個協程需要執行,wg.Done 相當於 wg.Add(-1) 意思就是我這個協程執行完了。wg.Wait() 就是告訴主線程要等一下,等協程都執行完再退出。
3.並發還並行?
當你同時啟動多個協程的時候,會怎么執行呢?
package main import ( "strconv" "sync" ) var wg = sync.WaitGroup{} func main() { wg.Add(5) for i := 0; i < 5; i++ { go say("Hello World: " + strconv.Itoa(i)) } wg.Wait() } func say(s string) { println(s) wg.Done() }
如果去掉go,直接在循環里面調用這個函數5次,毫無疑問會一次輸出 Hello World: 0 ~ 4, 但是在協程里面,輸出的順序是無序的,看上去像是“同時執行”,其實這只是並發。
有一個問題,上面的例子里面是並發還並行呢?
首先,我們得區分什么是並發什么是並行,舉個比較熟悉的例子,並發就是一個鍋同時炒2個菜,2個菜來回切換,並行就是你有多個鍋,每個鍋炒不同的菜,多個鍋同時炒!
對於計算機來說,這個鍋就是CPU,單核CPU同一時間只能執行一個程序,但是CPU卻可以在不同程序之間快速切換,所以你在瀏覽網頁的同時還可以聽歌!但是多核CPU就不一樣了,操作系統可以一個CPU核心用來瀏覽網頁,另一個CPU核心拿來聽歌,所以多核CPU還是有用的。
但是對於單一程序來說,基本上是很難利用多核CPU的,主要是編程實現非常麻煩,這也是為什么很多人都說多核CPU是一核有難多核圍觀...特別是一些比較老的程序,人家在設計的時候壓根沒考慮到多核CPU,畢竟10年前CPU還沒有這么多核心。
回到上面的例子,如果當前CPU是單核,那么上面程序就是並發執行,如果當前CPU是多核,那就是並行執行,結果都是一樣的,如何證明請看下面的例子:
package main import ( "runtime" "strconv" ) func main() { runtime.GOMAXPROCS(1) for i := 0; i < 5; i++ { go say("Hello World: " + strconv.Itoa(i)) } for { } } func say(s string) { println(s) }
默認情況下,最新的go版本協程可以利用多核CPU,但是通過runtime.GOMAXPROCS() 我們可以設置所需的核心數(其實並不是CPU核心數),在上面的例子我們設置為1,也就是模擬單核CPU,運行這段程序你會發現無任何輸出,如果你改成2,你會發現可以正常輸出。
這段程序邏輯很簡單,使用一個for循環啟動5個協程,然后寫了一個for死循環,如果是單核CPU,當運行到for死循環的時候,由於沒有任何io操作(或者能讓出CPU的操作),會一直卡在那里,但是如果是多核CPU,go協程就會調用其它CPU去執行。
如果你在for循環里面加入一個sleep操作,比如下面這樣:
for { time.Sleep(time.Second) }
4.channel
channel,又叫管道,在go里面的管道是協程之間通信的渠道,類似於咱們常用的消息隊列。在上面的例子里面我們是直接打印出來結果,假如現在的需求是把輸出結果返回到主線程呢?
package main import ( "strconv" ) func main() { var result = make(chan string) for i := 0; i < 5; i++ { go say("Hello World: "+strconv.Itoa(i), result) } for s := range result { println(s) } } func say(s string, c chan string) { c <- s }
簡單說明一下,這里就是實例化了一個string類型的管道,在調用函數的時候會把管道當作參數傳遞過去,然后在調用函數里面我們不輸出結果,然后把結果通過管道返還回去,然后再主線程里面我們通過for range循環依次取出結果!
結果如下,但是這個程序是有bug的,在程序的運行的最后會出現一個fatal error,提示所有的協程都進入睡眠狀態,死鎖!
Hello World: 4 Hello World: 1 Hello World: 0 Hello World: 2 Hello World: 3 fatal error: all goroutines are asleep - deadlock!
go的管道默認是阻塞的(假如你不設置緩存的話),你那邊放一個,我這頭才能取一個,如果你那邊放了東西這邊沒人取,程序就會一直等下去,死鎖了,同時,如果那邊沒人放東西,你這邊取也取不到,也會發生死鎖!
如何解決這個問題呢?標准的做法是主動關閉管道,或者你知道你應該什么時候關閉管道, 當然你結束程序管道自然也會關掉!針對上面的演示代碼,可以這樣寫:
var i = 0 for s := range result { println(s) if i >= 4 { close(result) } i++ }
因為我們明確知道總共會輸出5個結果,所以這里簡單做了一個判斷,大於5就關閉管道退出for循環,就不會報錯了!雖然丑了點,但是能用
5.生產者消費者模型
利用channel和協程,我們可以非常容易的實現了一個生產者消費者模型,代碼如下:
package main import ( "strconv" "fmt" "time" ) func main() { ch1 := make(chan int) ch2 := make(chan string) go pump1(ch1) go pump2(ch2) go suck(ch1, ch2) time.Sleep(time.Duration(time.Second*30)) } func pump1(ch chan int) { for i := 0; ; i++ { ch <- i * 2 time.Sleep(time.Duration(time.Second)) } } func pump2(ch chan string) { for i := 0; ; i++ { ch <- strconv.Itoa(i+5) time.Sleep(time.Duration(time.Second)) } } func suck(ch1 chan int, ch2 chan string) { chRate := time.Tick(time.Duration(time.Second*5)) // 定時器 for { select { case v := <-ch1: fmt.Printf("Received on channel 1: %d\n", v) case v := <-ch2: fmt.Printf("Received on channel 2: %s\n", v) case <-chRate: fmt.Printf("Log log...\n") } } }
輸出結果如下:
Received on channel 1: 0 Received on channel 2: 5 Received on channel 2: 6 Received on channel 1: 2 Received on channel 1: 4 Received on channel 2: 7 Received on channel 1: 6 Received on channel 2: 8 Received on channel 2: 9 Received on channel 1: 8 Log log... Received on channel 2: 10 Received on channel 1: 10 Received on channel 1: 12 Received on channel 2: 11 Received on channel 2: 12 Received on channel 1: 14
這個程序建立了2個管道一個傳輸int,一個傳輸string,同時啟動了3個協程,前2個協程非常簡單,就是每隔1s向管道輸出數據,第三個協程是不停的從管道取數據,和之前的例子不一樣的地方是,pump1 和 pump2是2個不同的管道,通過select可以實現在不同管道之間切換,哪個管道有數據就從哪個管道里面取數據,如果都沒數據就等着,還有一個定時器功能可以每隔一段時間向管道輸出內容!而且我們可以很容易啟動多個消費者。
應用
1.Web應用
使用go自帶的http庫幾行代碼就可以啟動一個http server,代碼如下:
http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { _, _ = fmt.Fprintln(writer, "Hello World") }) _ = http.ListenAndServe("127.0.0.1:8080", nil)
雖然簡單,但是非常高效,因為其底層使用了go協程,對於每一個請求都會啟動一個協程去處理,所以並發可以輕輕松松達到上萬QPS。
2.並發編程
舉一個非常簡單的例子,假設我們在業務里面需要從3個不同的數據庫獲取數據,每次耗時100ms,正常寫法就是從上到下依次執行,總耗時300ms,但是使用協程這3個操作可以“同時”進行,耗時大大減少。
幾乎所有IO密集型的應用,都可以利用協程提高速度,提高程序並發能力,不必把CPU時間浪費在等待的過程中,同時還可以充分利用多核CPU的計算能力。