Go語言基礎(3)
Go語言的併發通過goroutine實現。goroutine類似於線程,屬於用戶態的線程,可以根據需要創建成千上萬個goroutine併發工作。goroutine是由Go語言的運行時(runtime)調度完成,而線程是由操作系統調度完成。
Go語言還提供channel在多個goroutine間進行通信。goroutine和channel是 Go 語言秉承的 CSP(Communicating Sequential Process)併發模式的重要實現基礎
Go語言中的goroutine就是這樣一種機制,goroutine的概念類似於線程,但 goroutine是由Go的運行時(runtime)調度和管理的。Go程序會智能地將 goroutine 中的任務合理地分配給每個CPU。Go語言之所以被稱爲現代化的編程語言,就是因爲它在語言層面已經內置了調度和上下文切換的機制。
在Go語言編程中你不需要去自己寫進程、線程、協程,你的技能包裏只有一個技能–goroutine,當你需要讓某個任務併發執行的時候,你只需要把這個任務包裝成一個函數,開啓一個goroutine去執行這個函數就可以了,就是這麼簡單粗暴。
goroutine併發
Go語言中使用goroutine非常簡單,只需要在調用函數的時候在前面加上go關鍵字,就可以爲一個函數創建一個goroutine。
一個goroutine必定對應一個函數,可以創建多個goroutine去執行相同的函數。
如下:
func hello() {
fmt.Println("Hello")
}
func main(){
go hello()
}
在程序啓動時,Go程序就會爲main()函數創建一個默認的goroutine。
當main()函數返回的時候該goroutine就結束了,所有在main()函數中啓動的goroutine會一同結束
啓動多個goroutine
在Go語言中可以啓動多個goroutine
可增長的棧
OS線程(操作系統線程)一般都有固定的棧內存(通常爲2MB),一個goroutine的棧在其生命週期開始時只有很小的棧(典型情況下2KB),goroutine的棧不是固定的,他可以按需增大和縮小,goroutine的棧大小限制可以達到1GB,雖然極少會用到這麼大。所以在Go語言中一次創建十萬左右的goroutine也是可以的。
goroutine調度
GPM是Go語言運行時(runtime)層面的實現,是go語言自己實現的一套調度系統。區別於操作系統調度OS線程。
- G就是個goroutine的,裏面除了存放本goroutine信息外 還有與所在P的綁定等信息。
- P管理着一組goroutine隊列,P裏面會存儲當前goroutine運行的上下文環境(函數指針,堆棧地址及地址邊界),P會對自己管理的goroutine隊列做一些調度(比如把佔用CPU時間較長的goroutine暫停、運行後續的goroutine等等)當自己的隊列消費完了就去全局隊列裏取,如果全局隊列裏也消費完了會去其他P的隊列裏搶任務。
- M(machine)是Go運行時(runtime)對操作系統內核線程的虛擬, M與內核線程一般是一一映射的關係, 一個groutine最終是要放到M上執行的;
P與M一般也是一一對應的。他們關係是: P管理着一組G掛載在M上運行。當一個G長久阻塞在一個M上時,runtime會新建一個M,阻塞G所在的P會把其他的G 掛載在新建的M上。當舊的G阻塞完成或者認爲其已經死掉時 回收舊的M。
P的個數是通過runtime.GOMAXPROCS設定(最大256),Go1.5版本之後默認爲物理線程數。 在併發量大的時候會增加一些P和M,但不會太多,切換太頻繁的話得不償失。
單從線程調度講,Go語言相比起其他語言的優勢在於OS線程是由OS內核來調度的,goroutine則是由Go運行時(runtime)自己的調度器調度的,這個調度器使用一個稱爲m:n調度的技術(複用/調度m個goroutine到n個OS線程)。 其一大特點是goroutine的調度是在用戶態下完成的, 不涉及內核態與用戶態之間的頻繁切換,包括內存的分配與釋放,都是在用戶態維護着一塊大的內存池, 不直接調用系統的malloc函數(除非內存池需要改變),成本比調度OS線程低很多。 另一方面充分利用了多核的硬件資源,近似的把若干goroutine均分在物理線程上, 再加上本身goroutine的超輕量,以上種種保證了go調度方面的性能
Go運行時的調度器使用GOMAXPROCS參數來確定需要使用多少個OS線程來同時執行Go代碼。默認值是機器上的CPU核心數。例如在一個8核心的機器上,調度器會把Go代碼同時調度到8個OS線程上(GOMAXPROCS是m:n調度中的n)。
Go語言中可以通過runtime.GOMAXPROCS()
函數設置當前程序併發時佔用的CPU邏輯核心數。
Go1.5版本之前,默認使用的是單核心執行。Go1.5版本之後,默認使用全部的CPU邏輯核心數。
例:
func main() {
runtime.GOMAXPROCS(1)
go a()
go b()
time.Sleep(time.Second)
}
Go語言中的操作系統線程和goroutine的關係:
- 一個操作系統線程對應用戶態多個goroutine。
- go程序可以同時使用多個操作系統線程。
- goroutine和OS線程是多對多的關係,即m:n。
關於sync.WaitGroup的用法
func f1() {
rand.Seed(time.Now().UnixNano())
for i :=0; i<5; i++{
r1 := rand.Int()
r2 := rand.Intn(10)
fmt.Println(r1, r2)
}
}
var wg sync.WaitGroup
func main(){
fmt.Println("start ######")
for i:=0; i<100; i++{
wg.Add(1)
go func(x int) {
fmt.Println(x)
defer wg.Done()
}(i)
}
fmt.Println("main .....")
wg.Wait()
f1()
}
通道channel
函數與函數間需要交換數據才能體現併發執行函數的意義。
Go語言的併發模型是CSP(Communicating Sequential Processes),提倡通過通信共享內存而不是通過共享內存而實現通信。
如果說goroutine是Go程序併發的執行體,channel就是它們之間的連接。channel是可以讓一個goroutine發送特定值到另一個goroutine的通信機制。
Go 語言中的通道(channel)是一種特殊的類型。通道像一個傳送帶或者隊列,總是遵循先入先出(First In First Out)的規則,保證收發數據的順序。每一個通道都是一個具體類型的導管,也就是聲明channel的時候需要爲其指定元素類型。
channel類型
channel是一種類型,一種引用類型。聲明通道類型的格式如下:
var 變量 chan 元素類型
創建channel
<u>通道是引用類型,通道類型的空值是nil</u>。
var ch chan int
fmt.Println(ch) // <nil>
<u>聲明的通道後需要使用make函數初始化之後才能使用</u>。
創建channel的格式如下:
make(chan 元素類型, [緩衝大小])
channel的緩衝大小是可選的。
channel操作
通道有發送(send)、接收(receive)和關閉(close)三種操作。
發送和接收都使用<-符號。
現在我們先使用以下語句定義一個通道:
ch := make(chan int)
發送
將一個值發送到通道中。
ch <- 10 // 把10發送到ch中
接收
從一個通道中接收值。
x := <- ch // 從ch中接收值並賦值給變量x
<-ch // 從ch中接收值,忽略結果
關閉
我們通過調用內置的close函數來關閉通道。
close(ch)
關於關閉通道需要注意的事情是,只有在通知接收方goroutine所有的數據都發送完畢的時候才需要關閉通道。通道是可以被垃圾回收機制回收的,它和關閉文件是不一樣的,在結束操作之後關閉文件是必須要做的,但關閉通道不是必須的。
關閉後的通道有以下特點:
- 對一個關閉的通道再發送值就會導致panic。
- 對一個關閉的通道進行接收會一直獲取值直到通道爲空。
- 對一個關閉的並且沒有值的通道執行接收操作會得到對應類型的零值。
- 關閉一個已經關閉的通道會導致panic。
無緩衝的通道
無緩衝的通道又稱爲阻塞的通道。
例:
func main() {
var ch chan int
ch = make(chan int)
ch <- 10
fmt.Println(ch)
}
在編譯時:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
無緩衝的通道只有在有接收值的情況下才可以發送值,否則會產生死鎖。所以上例中ch是一個無緩衝區的通道,不能直接把值放入通道。
以上代碼需要改爲:
var wg sync.WaitGroup
func recv(ch chan int){
fmt.Println( <- ch)
defer wg.Done()
}
func main() {
var ch chan int
ch = make(chan int)
wg.Add(1)
go recv(ch)
ch <- 10
wg.Wait()
}
編譯運行並輸出結果爲: 10
無緩衝通道上的發送操作會阻塞,直到另一個goroutine在該通道上執行接收操作,這時值才能發送成功,兩個goroutine將繼續執行。相反,如果接收操作先執行,接收方的goroutine將阻塞,直到另一個goroutine在該通道上發送一個值。
使用無緩衝通道進行通信將導致發送和接收的goroutine同步化。因此,無緩衝通道也被稱爲同步通道。
有緩衝的通道
解決上面問題的方法還有一種就是使用有緩衝區的通道。可以在使用make函數初始化通道的時候爲其指定通道的容量。只要通道的容量大於零,那麼該通道就是有緩衝的通道,通道的容量表示通道中能存放元素的數量。可以使用內置的len函數獲取通道內元素的數量,使用cap函數獲取通道的容量。
例:
var ch02 chan int
ch02 = make(chan int, 1)
ch02 <- 20
fmt.Println("發送成功")
tmp := <- ch02
fmt.Println("取值成功, ", tmp)
}
當向通道中發送完數據時,可以通過close函數來關閉通道。
當通道被關閉時,再往該通道發送值會引發panic。從一個已關閉的channel中讀取數據不會報錯,也不會阻塞接收者。如果一個已被close的通道中的值已經被取完,然後繼續取值,取到的值是對應類型的零值。可以通過指定接受狀態位來觀察接受的數據是否是從一個已關閉的channel所發送出來的數據。例如j, ok := <-c
,則ok爲false時,則代表channel已經被關閉。
例:
var ch01 chan int
ch01 = make(chan int, 3)
wg.Add(1)
go func() {
ch01 <- 10
ch01 <- 20
ch01 <- 15
defer wg.Done()
close(ch01)
}()
wg.Add(1)
go func() {
defer wg.Done()
for {
val , ok := <-ch01
if !ok {
break
}
fmt.Println(val)
}
}()
wg.Wait()
}
單向通道
- chan<- int是一個只寫單向通道(只能對其寫入int類型值),可以對其執行發送操作但是不能執行接收操作;
- <-chan int是一個只讀單向通道(只能從其讀取int類型值),可以對其執行接收操作但是不能執行發送操作。
例:
func srcNumber(ch chan<- int){
for i:=1;i<=100;i++{
ch <- i
}
close(ch)
defer wg.Done()
}
func destNumber(destCh chan <- int, srcCh <-chan int) {
for i := range srcCh{
destCh <- i * i
}
close(destCh)
defer wg.Done()
}
func output(ch <-chan int) {
for i:=range ch {
fmt.Println(i)
}
}
func main() {
var ch03 chan int
var ch04 chan int
ch03 = make(chan int)
ch04 = make(chan int)
wg.Add(1)
go srcNumber(ch03)
wg.Add(1)
go destNumber(ch04,ch03)
output(ch04)
wg.Wait()
通道總結
channel常見的異常總結,如下圖:
關閉已經關閉的channel也會引發panic
work pool
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("worker:%d start job:%d\n", id, j)
time.Sleep(time.Second)
fmt.Printf("worker:%d end job:%d\n", id, j)
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// 開啓3個goroutine
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// 5個任務
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
// 輸出結果
for a := 1; a <= 5; a++ {
<-results
}
select多路複用
Go內置了select關鍵字,可以同時響應多個通道的操作。
select的使用類似於switch語句,它有一系列case分支和一個默認的分支。每個case會對應一個通道的通信(接收或發送)過程。select會一直等待,直到某個case的通信操作完成時,就會執行case分支對應的語句。具體格式如下:
select{
case <-ch1:
...
case data := <-ch2:
...
case ch3<-data:
...
default:
默認操作
}
例:
ch05 := make(chan int, 1)
for i:=1; i<=10; i++ {
select {
case x:=<-ch05:
fmt.Println(x)
case ch05 <- i:
}
}
}
使用select語句能提高代碼的可讀性。
1) 可處理一個或多個channel的發送/接收操作。
2) 如果多個case同時滿足,select會隨機選擇一個。
3) 對於沒有case的select{}會一直等待,可用於阻塞main函數。
併發安全
例:
var x int64
var wg sync.WaitGroup
func add() {
for i:=0; i<5000; i++ {
x = x+1
}
defer wg.Done()
}
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
上例代碼中開啓了兩個goroutine去累加變量x的值,這兩個goroutine在訪問和修改x變量的時候就會存在數據競爭,導致最後的結果與期待的不符。
互斥鎖
互斥鎖是一種常用的控制共享資源訪問的方法,它能夠保證同時只有一個goroutine可以訪問共享資源。Go語言中使用sync包的Mutex類型來實現互斥鎖。 使用互斥鎖來修復上面代碼的問題:
var x int64
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
for i:=0; i<5000; i++ {
lock.Lock()
x = x+1
lock.Unlock()
}
defer wg.Done()
}
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
使用互斥鎖能夠保證同一時間有且只有一個goroutine進入臨界區,其他的goroutine則在等待鎖;當互斥鎖釋放後,等待的goroutine纔可以獲取鎖進入臨界區,多個goroutine同時等待一個鎖時,喚醒的策略是隨機的。
讀寫互斥鎖
互斥鎖是完全互斥的,但是有很多實際的場景下是讀多寫少的,當併發的去讀取一個資源不涉及資源修改的時候是沒有必要加鎖的,這種場景下使用讀寫鎖是更好的一種選擇。讀寫鎖在Go語言中使用sync包中的RWMutex類型。
讀寫鎖分爲兩種:讀鎖和寫鎖。當一個goroutine獲取讀鎖之後,其他的goroutine如果是獲取讀鎖會繼續獲得鎖,如果是獲取寫鎖就會等待;當一個goroutine獲取寫鎖之後,其他的goroutine無論是獲取讀鎖還是寫鎖都會等待。
讀寫鎖非常適合讀多寫少的場景,如果讀和寫的操作差別不大,讀寫鎖的優勢就發揮不出來。
func write() {
//lock.Lock()
rwlock.Lock()
x++
time.Sleep(time.Millisecond*10)
rwlock.Unlock()
//lock.Unlock()
wg.Done()
}
func read(){
//lock.Lock()
rwlock.Lock()
time.Sleep(time.Millisecond*10)
rwlock.Unlock()
//lock.Unlock()
wg.Done()
}
func main() {
start := time.Now()
for i:=0; i<10; i++{
wg.Add(1)
go write()
}
for i:=0;i<1000;i++{
wg.Add(1)
go read()
}
wg.Wait()
end := time.Now()
fmt.Println(end.Sub(start))
}
sync
sync.WaitGroup
在代碼中生硬的使用time.Sleep肯定是不合適的,Go語言中可以使用sync.WaitGroup來實現併發任務的同步。 sync.WaitGroup有以下幾個方法:
方法名 | 功能 |
---|---|
(wg * WaitGroup) Add(delta int) | 計數器+delta |
(wg *WaitGroup) Done() | 計數器-1 |
(wg *WaitGroup) Wait() | 阻塞直到計數器變爲0 |
sync.WaitGroup內部維護着一個計數器,計數器的值可以增加和減少。例如當我們啓動了N 個併發任務時,就將計數器值增加N。每個任務完成時通過調用Done()方法將計數器減1。通過調用Wait()來等待併發任務執行完,當計數器值爲0時,表示所有併發任務已經完成。
sync.Once
在編程的很多場景下需要確保某些操作在高併發的場景下只執行一次,例如只加載一次配置文件、只關閉一次通道等。
Go語言中的sync包中提供了一個針對只執行一次場景的解決方案–sync.Once。
sync.Once只有一個Do方法,其簽名如下:
func (o *Once) Do(f func()) {}
備註:如果要執行的函數f需要傳遞參數就需要搭配閉包來使用。
例
var icons map[string]image.Image
func loadIcons() {
icons = map[string]image.Image{
"left": loadIcon("left.png"),
"up": loadIcon("up.png"),
"right": loadIcon("right.png"),
"down": loadIcon("down.png"),
}
}
// Icon 被多個goroutine調用時不是併發安全的
func Icon(name string) image.Image {
if icons == nil {
loadIcons()
}
return icons[name]
}
多個goroutine併發調用Icon函數時不是併發安全的,現代的編譯器和CPU可能會在保證每個goroutine都滿足串行一致的基礎上自由地重排訪問內存的順序。loadIcons函數可能會被重排爲以下結果:
func loadIcons() {
icons = make(map[string]image.Image)
icons["left"] = loadIcon("left.png")
icons["up"] = loadIcon("up.png")
icons["right"] = loadIcon("right.png")
icons["down"] = loadIcon("down.png")
}
在這種情況下就會出現即使判斷了icons不是nil也不意味着變量初始化完成了。考慮到這種情況,能想到的辦法就是添加互斥鎖,保證初始化icons的時候不會被其他的goroutine操作,但是這樣做又會引發性能問題。
使用sync.Once改造的示例代碼如下:
var icons map[string]image.Image
var loadIconsOnce sync.Once
func loadIcons() {
icons = map[string]image.Image{
"left": loadIcon("left.png"),
"up": loadIcon("up.png"),
"right": loadIcon("right.png"),
"down": loadIcon("down.png"),
}
}
// Icon 是併發安全的
func Icon(name string) image.Image {
loadIconsOnce.Do(loadIcons)
return icons[name]
}
再例如:多個函數都要close某個channel時,使用sync.Once處理衝突。
sync.Once其實內部包含一個互斥鎖和一個布爾值,互斥鎖保證布爾值和數據的安全,而布爾值用來記錄初始化是否完成。這樣設計就能保證初始化操作的時候是併發安全的並且初始化操作也不會被執行多次。
sync.Map
關於Go語言內置的map類型:
var m = make(map[string]int)
func get(key string) int {
return m[key]
}
func set(key string, value int) {
m[key] = value
}
func main() {
for i:=0; i<20; i++{
wg.Add(1)
go func(n int) {
key := strconv.Itoa(n) //把n的值轉換字符串傳給key
set(key,n)
fmt.Printf("key:=%v,value:=%v\n", key,get(key))
wg.Done()
}(i)
}
wg.Wait()
在執行上述代碼時經常出現異常,提示如下:fatal error: concurrent map writes
說明:內置map不是併發安全的。
Go語言的sync包中提供了一個開箱即用的併發安全版map–sync.Map。開箱即用表示不用像內置的map一樣使用make函數初始化就能直接使用。同時sync.Map內置了諸如Store、Load、LoadOrStore、Delete、Range等操作方法。
使用方法如下例所示:
var m2 = sync.Map{}
func main() {
for i:=0; i<20; i++{
wg.Add(1)
go func(n int) {
key := strconv.Itoa(n)
m2.Store(key,n)
value, _ := m2.Load(key)
fmt.Printf("k=:%v,v:=%v\n", key, value)
wg.Done()
}(i)
}
wg.Wait()
atomic原子操作
代碼中的加鎖操作因爲涉及內核態的上下文切換會比較耗時、代價比較高。針對基本數據類型我們還可以使用原子操作來保證併發安全,因爲原子操作是Go語言提供的方法它在用戶態就可以完成,因此性能比加鎖操作更好。Go語言中原子操作由內置的標準庫sync/atomic提供。
例:
var x int64
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
for i:=0; i<5000; i++ {
//lock.Lock()
//x = x+1
//lock.Unlock()
atomic.AddInt64(&x, 1)
}
defer wg.Done()
}
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
atomic包提供了底層的原子級內存操作,對於同步算法的實現很有用。這些函數必須謹慎地保證正確使用。除了某些特殊的底層應用,使用通道或者sync包的函數/類型實現同步更好。
atomic包
方法 | 解釋 |
---|---|
func LoadInt32(addr int32) (val int32) <br />func LoadInt64(addr int64) (val int64) <br />func LoadUint32(addr uint32) (val uint32) <br />func LoadUint64(addr uint64) (val uint64) <br />func LoadUintptr(addr uintptr) (val uintptr) <br />func LoadPointer(addr unsafe.Pointer) (val unsafe.Pointer) | 讀取操作 |
func StoreInt32(addr int32, val int32) <br />func StoreInt64(addr int64, val int64) <br />func StoreUint32(addr uint32, val uint32) <br />func StoreUint64(addr uint64, val uint64) <br />func StoreUintptr(addr uintptr, val uintptr) <br />func StorePointer(addr unsafe.Pointer, val unsafe.Pointer) | 寫入操作 |
func AddInt32(addr int32, delta int32) (new int32) <br />func AddInt64(addr int64, delta int64) (new int64) <br />func AddUint32(addr uint32, delta uint32) (new uint32) <br />func AddUint64(addr uint64, delta uint64) (new uint64) <br />func AddUintptr(addr *uintptr, delta uintptr) (new uintptr) | 修改操作 |
func SwapInt32(addr int32, new int32) (old int32) <br />func SwapInt64(addr int64, new int64) (old int64) <br />func SwapUint32(addr uint32, new uint32) (old uint32) <br />func SwapUint64(addr uint64, new uint64) (old uint64) <br />func SwapUintptr(addr uintptr, new uintptr) (old uintptr) <br />func SwapPointer(addr unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer) | 交換操作 |
func CompareAndSwapInt32(addr int32, old, new int32) (swapped bool) <br />func CompareAndSwapInt64(addr int64, old, new int64) (swapped bool) <br />func CompareAndSwapUint32(addr uint32, old, new uint32) (swapped bool) <br />func CompareAndSwapUint64(addr uint64, old, new uint64) (swapped bool) <br />func CompareAndSwapUintptr(addr uintptr, old, new uintptr) (swapped bool) <br />func CompareAndSwapPointer(addr unsafe.Pointer, old, new unsafe.Pointer) (swapped bool) | 比較並交換操作 |