理解并發和并行
并發:同時管理多件事情。
并行:同時做多件事情。表示同時發生了多件事情,通過時間片切換,哪怕只有單一的核心,也可以實現“同時做多件事情”這個效果。
預熱——用戶級線程和內核級線程
線程被分為兩類:用戶級線程(User Level Thread)和內核級線程(Kernal Level Thread)
用戶級線程
· 用戶級線程只存在于用戶空間,有關它的創建、調度和管理工作都由用戶級線程庫來支持。用戶級線程庫是用于用戶級線程管理的例程包,支持線程的創建、終止,以及調度線程的執行并保存和恢復線程的上下文,這些操作都在用戶空間運行,無需內核的支持。
· 由于內核無法感知用戶級線程的存在,因此內核是以進程為單位進行調度的。當內核調度一個進程運行時,用戶級線程庫調度該進程的一個線程運行,如果時間片允許,該進程的其他線程也可能被運行。即該進程的多個線程共享該進程的運行時間片。
· 若該進程的一個線程進行IO操作,則該線程調用系統調用進入內核,啟動IO設備后,內核會把該進程阻塞,并把CPU交給其他進程。即使被阻塞的進程的其他線程可以執行,內核也不會發現這一情況。在該進程的狀態變為就緒前,內核不會調度它運行。屬于該進程的線程都不可能運行,因而用戶級線程的并行性會受到一定的限制。
內核級線程
· 內核級線程的所有創建、調度及管理操作都由操作系統內核完成,內核保存線程的狀態及上下文信息。
· 當一個線程引起阻塞的系統調用時,內核可以調度進程的其他線程執行,多處理器系統上,內核分派屬于同一進程的多個線程在多個處理器上執行,提升進程的并行度。
· 內核管理線程效率比用戶態管理線程慢的多。
操作系統的三種線程模型
1.多對一模型
允許將多個用戶級線程映射到一個內核線程。線程管理是在用戶空間進行的,效率比較高。如果有一個線程執行了阻塞系統調用,那么整個進程就會阻塞。所以任意時刻只允許一個線程訪問內核,這樣多個線程不能并行運行在多處理器上。雖然多對一模型對創建用戶級線程的數目并沒有限制,但這些線程在同一時刻只能有一個被執行。
2.一對一模型
每個用戶線程映射到一個內核線程。當一個線程執行阻塞系統調用,該模型允許另一個線程繼續執行。這樣提供了更好的并發功能。該模型也允許多個線程運行在多核處理器上。一對一模型可以獲得高并發性,但因耗費資源而使線程數會受到限制。
3.多對多模型
在多對一模型和一對一模型中取了個折中,克服了多對一模型的并發度不高的缺點,又克服了一對一模型的一個用戶進程占用太多內核級線程,開銷太大的缺點。又擁有多對一模型和一對一模型各自的優點,可謂集兩者之所長。
GO并發調度模型——G-P-M模型
GO可以使用如下方式創建一個"線程"(GO語言中所謂的goroutine)。
go func(paramName paramType, ...){
//函數體
}(param, ...)
等價于Java代碼
new java.lang.Thread(() -> {
// do something in one new thread
}).start();
G-P-M模型圖解
其圖中的G, P和M都是Go語言運行時系統(其中包括內存分配器,并發調度器,垃圾收集器等組件,可以想象為Java中的JVM)抽象出來概念和數據結構對象。
G:G就是goroutine,通過go關鍵字創建,封裝了所要執行的代碼邏輯,可以稱為是用戶線程。屬于用戶級資源,對OS透明,具備輕量級,可以大量創建,上下文切換成本低等特點。
P:Processor即邏輯處理器,默認GO運行時的Processor數量等于CPU數量,也可以通過GOMAXPROCS函數指定P的數量。P的主要作用是管理G運行,每個P擁有一個本地隊列,并為G在M上的運行提供本地化資源。
M:是操作系統創建的系統線程,作用就是執行G中包裝的并發任務,被稱為物理處理器。其屬于OS資源,可創建的數量上也受限了OS,通常情況下G的數量都多于活躍的M的。Go運行時調度器將G公平合理的安排到多個M上去執行。
·G和M的關系:G是要執行的邏輯,M具體執行G的邏輯。Java中Thread實際上就是對M的封裝,通過指定run()函數指定要執行的邏輯。GO語言中講二者分開,通過P建立G和M的聯系從而執行。
·G和P的關系:P是G的管理者,P將G交由M執行,并管理一定系統資源供G使用。一個P管理存儲在其本地隊列的所有G。P和G是1:n的關系。
·P和M的關系:P和M是1:1的關系。P將管理的G交由M具體執行,當遇到阻塞時,P可以與M解綁,并找到空閑的M進行綁定繼續執行隊列中其他可執行的G。
問題:為什么要有P?
G是對需要執行的代碼邏輯的封裝,M具體執行G,P存在的意義是什么?
Go語言運行時系統早期(Go1.0)的實現中并沒有P的概念,Go中的調度器直接將G分配到合適的M上運行。但這樣帶來了很多問題,例如,不同的G在不同的M上并發運行時可能都需向系統申請資源(如堆內存),由于資源是全局的,將會由于資源競爭造成很多系統性能損耗。
Go 1.1起運行時系統加入了P,讓P去管理G對象,M要想運行G必須先與一個P綁定,然后才能運行該P管理的G。P對象中預先申請一些系統資源作為本地資源,G需要的時候先向自己的P申請(無需鎖保護),如果不夠用或沒有再向全局申請,而且從全局拿的時候會多拿一部分,以供后面高效的使用。
P的存在解耦了G和M,當M執行的G被阻塞時,P可以綁定到其他M上繼續執行其管理的G,提升并發性能。
GO調度過程
①創建一個goroutine,調度器會將其放入全局隊列。
②調度器為每個goroutine分配一個邏輯處理器。并放到邏輯處理器的本地隊列中。
③本地隊列中的goroutine會一直等待直到被邏輯處理器運行。
func task1() {
go task2()
go task3()
}
假設現在task1在稱為G1的goroutine中運行,并在運行過程中創建兩個新的goroutine,新創建的兩個goroutine將會被放到全局隊列中,調度器會再將他們分配給合適的P。
問題:如果遇到阻塞的情況怎么處理?
假設正在運行的goroutine要執行一個阻塞的系統調用,如打開一個文件,在這種情況下,這個M將會被內核調度器調度出CPU并處于阻塞狀態。相應的與M相關聯的P的本地隊列中的其他G將無法被運行,但Go運行時系統的一個監控線程(sysmon線程)能探測到這樣的M,將M與P解綁,M將繼續被阻塞直到系統調用返回。P則會尋找新的M(沒有則創建一個)與之綁定并執行剩余的G。
當之前被阻塞的M得到返回后,相應的G將會被放回本地隊列,M則會保存好,等待再次使用。
問題:GO有時間片概念嗎?
和操作系統按時間片調度線程不同,Go并沒有時間片的概念。如果一個G沒有發生阻塞的情況(如系統調用或阻塞在channel上),M是如何讓G停下來并調度下一個G的呢?
G是被搶占調度的。GO語言運行時,會啟動一個名為sysmon的M,該M無需綁定P。sysmon每20us~10ms啟動一次,按照《Go語言學習筆記》中的總結,sysmon主要完成如下工作:
1.回收閑置超過5分鐘的span物理內存
2.如果超過2分鐘沒有垃圾回收,強制執行
3.向長時間運行的G任務發出搶占調度
4.收回因syscall長時間阻塞的P
5.將長時間未處理的netpoll結果添加到任務隊列
注:go的內存分配也是基于兩種粒度的內存單位:span和object。span是連續的page,按page的數量進行歸類,比如分為2個page的span,4個page的span等。object是span中按預設大小劃分的塊,也是按大小分類。同一個span中,只有一種類型大小的object。
sysmom的大致工作思路:
//$GOROOT/src/runtime/proc.go
// main方法
func main() {
... ...
systemstack(func() {
newm(sysmon, nil)
})
.... ...
}
func sysmon() {
// 回收閑置超過5分鐘的span物理內存
scavengelimit := int64(5 * 60 * 1e9)
... ...
if .... {
... ...
// 如果P因syscall阻塞
//通過retake方法搶占G
if retake(now) != 0 {
idle = 0
} else {
idle++
}
... ...
}
}
搶占方法retake
// forcePreemptNS是指定的時間片,超過這一時間會嘗試搶占
const forcePreemptNS = 10 * 1000 * 1000 // 10ms
func retake(now int64) uint32 {
... ...
// 實施搶占
t := int64(_p_.schedtick)
if int64(pd.schedtick) != t {
pd.schedtick = uint32(t)
pd.schedwhen = now
continue
}
if pd.schedwhen+forcePreemptNS > now {
continue
}
preemptone(_p_)
... ...
}
可以看出,如果一個G任務運行10ms,sysmon就會認為其運行時間太久而發出搶占式調度的請求。
goroutine
1.gouroutine切換
package main
import (
"runtime"
"sync"
"fmt"
)
func main() {
//指定調度器所能調度的邏輯處理器數量
runtime.GOMAXPROCS(1)
//使用wg等待程序完成
var wg sync.WaitGroup
//計數器+2,等待兩個goroutine
wg.Add(2)
fmt.Println("Start GoRoutine")
//聲明一個匿名函數,使用go關鍵字創建goroutine
go func() {
//函數退出時通過Done通知main函數工作已經完成
defer wg.Done()
for i := 1; i <= 3; i++ {
for char := 'a'; char < 'a' + 26; char++ {
fmt.Printf("%c ", char)
}
}
}()
go func() {
defer wg.Done()
for i := 1; i <= 3; i++ {
for char := 'A'; char < 'A' + 26; char++ {
fmt.Printf("%c ", char)
}
}
}()
//等待goroutine結束
fmt.Println("Waiting for finish")
wg.Wait()
fmt.Println("Program end")
}
輸出:
Start GoRoutine
Waiting for finish
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z
Program end
這個程序給人的感覺是串行的,原因是當調度器還沒有準備切換打印小寫字母的goroutine時,打印大寫字母的goroutine就執行完了。
修改下,打印6000以內的素數。
package main
import (
"sync"
"runtime"
"fmt"
)
var wg sync.WaitGroup
func main(){
runtime.GOMAXPROCS(1)
wg.Add(2)
go printPrime("A")
go printPrime("B")
fmt.Println("Wait for finish")
wg.Wait()
fmt.Println("Program End")
}
func printPrime(prefix string){
defer wg.Done()
nextNum:
for i := 2; i < 6000; i++ {
for j := 2; j < i; j++ {
if i % j == 0 {
continue nextNum
}
}
fmt.Printf("%s:%d\n", prefix, i)
}
fmt.Printf("complete %s\n", prefix)
}
輸出結果:
Wait for finish
B:2
B:3
B:5
B:7
B:11
...
B:457
B:461
B:463
B:467
A:2
A:3
A:5
A:7
...
A:5981
A:5987
complete A
B:5939
B:5953
B:5981
B:5987
complete B
Program End
在打印素數的過程中,goroutine的輸出是混在一起的,由于runtime .GOMAXPROCS(1),可以看出兩個G是在一個P上并發執行的。
package main
import (
"runtime"
"sync"
"fmt"
)
func main() {
runtime.GOMAXPROCS(2)
var wgp sync.WaitGroup
wgp.Add(2)
fmt.Println("Start Goroutines")
//第一個goroutine打印3遍小寫字母
go func() {
defer wgp.Done()
for i := 0; i < 3; i++ {
for char := 'a'; char < 'a'+26; char++ {
fmt.Printf("%c ", char)
}
}
}()
//第二個goroutine打印三遍大寫字母
go func() {
defer wgp.Done()
for i := 0; i < 3; i++ {
for char := 'A'; char < 'A'+26; char++ {
fmt.Printf("%c ", char)
}
}
}()
fmt.Println("Waiting for finish")
wgp.Wait()
fmt.Println("\nAll finish")
}
輸出:
Start Goroutines
Waiting for finish
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d M N O P Q R S T U V e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m W X Y Z n o p q r s t u v w x y z
All finish
設置兩個P,短時間內會有類似上面打印0-6000所有素數的效果,證明兩個goroutine是并行執行的,但只有在多個P且每個可以同時讓每個G運行再一個可用的M上時,G才會達到并行的效果。
競爭狀態
多個goroutine再沒有同步的情況下,同時讀寫某個共享資源就會產生競爭狀態。對一個資源的讀寫必須是原子化的,即同一時刻只能有一個goroutine進行讀寫操作。
包含競爭狀態的實例:
package main
import (
"sync"
"runtime"
"fmt"
)
var (
counter int
wgs sync.WaitGroup
)
func main(){
wgs.Add(2)
go incrCounter()
go incrCounter()
fmt.Println("adding")
wgs.Wait()
fmt.Printf("now counter is %d\n", counter)
}
//非原子操作加法
func incrCounter() {
defer wgs.Done()
for i := 1; i <= 2000; i++ {
val := counter
//讓出處理器
runtime.Gosched()
val++
counter = val
}
}
輸出:
adding
now counter is 2000
每次讀取完count后存入副本,手動讓出M,再次輪到G執行時會將之前的副本的值進行增1的操作,覆蓋了另一個goroutine的操作。
goroutine同步的幾種方式
①原子函數
原子函數以操作系統底層的枷鎖機制同步訪問變量,使用原子鎖方式修改之前的代碼:
package main
import (
"sync"
"fmt"
"sync/atomic"
"runtime"
)
var (
counter2 int64
wg2 sync.WaitGroup
)
func main() {
wg2.Add(2)
go incrCounterAtomic()
go incrCounterAtomic()
fmt.Println("adding...")
wg2.Wait()
fmt.Printf("now counter is : %d\n", counter2)
fmt.Println("Program end")
}
func incrCounterAtomic() {
defer wg2.Done()
for i := 1; i <= 2000; i++ {
atomic.AddInt64(&counter2, 1)
runtime.Gosched()
}
}
輸出:
adding...
now counter is : 4000
Program end
互斥鎖
互斥鎖用于在代碼當中建立一個臨界區,保證同一時間只有一個G執行臨界區的代碼。
package main
import (
"sync"
"fmt"
"runtime"
)
var (
counter3 int
wg3 sync.WaitGroup
mutex sync.Mutex
)
func main() {
wg3.Add(2)
go incrCounterMutex()
go incrCounterMutex()
fmt.Println("Adding...")
wg3.Wait()
fmt.Printf("now counter is : %d\n", counter3)
}
func incrCounterMutex() {
defer wg3.Done()
for i := 1; i <= 2000; i++ {
//建立臨界區
mutex.Lock()
{
val := counter3
runtime.Gosched()
val++
counter3 = val
}
mutex.Unlock()
}
}
通道
資源再goroutine之間共享時,可以使用通道實現同步。聲明通道時,需要指定將要被共享的數據類型,可以通過通道共享內置類型、命名類型、結構類型、引用類型的值或指針。GO語言使用make函數創建通道。
①無緩沖通道
無緩沖通道使用make(chan type)聲明,通道中存取消息都是阻塞的。
樣例:
func main() {
var messages chan string = make(chan string)
go func(message string) {
messages <- message // 存消息
}("hello!")
fmt.Println(<-messages) // 取消息
}
阻塞即無緩沖的通道道在取消息和存消息的時候都會掛起當前的goroutine,除非另一端已經準備好。例:
package main
import (
"fmt"
"strconv"
)
var ch chan int = make(chan int)
func main() {
go input(0)
<- ch
fmt.Println("main finish")
}
func input(i int) {
for i := 0; i < 10; i++ {
fmt.Print(strconv.Itoa(i) + " ")
}
ch <- i
}
嘗試注釋掉ch的輸入和輸出,對比運行結果
不注釋:
0 1 2 3 4 5 6 7 8 9 main finish
注釋:
main finish
如果不用通道來阻塞主線的話,主線就會過早跑完,loop線將沒有機會執行。
無緩沖的通道永遠不會存儲數據,只負責數據的流通。
如果從無緩沖通道讀數據,必須要有數據寫入通道,否則讀取操作的goroutine將一直阻塞。
如果向無緩沖通道寫數據,必須要有其他goroutine讀取,否則該goroutine將一直被阻塞。
如果無緩沖通道中有數據,再向其寫入,或者從無流入的通道數據中讀取,則會形成死鎖。
死鎖
為什么會死鎖?非緩沖信道上如果發生了流入無流出,或者流出無流入,也就導致了死鎖。或者這樣理解 Go啟動的所有goroutine里的非緩沖信道一定要一個線里存數據,一個線里取數據,要成對才行 。
c, quit := make(chan int), make(chan int)
go func() {
c <- 1 // c通道的數據沒有被其他goroutine讀取走,堵塞當前goroutine
quit <- 0 // quit始終沒有辦法寫入數據
}()
<- quit // quit 等待數據的寫
是否所有不成對向信道存取數據的情況都是死鎖?反例:
package main
var ch chan int = make(chan int)
func main() {
go input(0)
}
func input(i int) {
ch <- i
}
通道ch中只有流入沒有流出,但運行不會報錯。原因是main沒等待其它goroutine,自己先跑完了, 所以沒有數據流入c信道,一共執行了一個goroutine, 并且沒有發生阻塞,所以沒有死鎖錯誤。
②緩沖通道
緩沖信道不僅可以流通數據,還可以緩存數據。它是有容量的,存入一個數據的話 , 可以先放在信道里,不必阻塞當前線而等待該數據取走。但是當緩沖信道達到滿的狀態的時候,就會表現出阻塞了。
package main
var ch chan int = make(chan int, 3)
func main() {
ch <- 1
ch <- 1
ch <- 1
//ch <- 1
}
如果是非緩沖通道,這段代碼會報死鎖。但是當有緩沖的通道時,代碼正常執行。如果再加入一行數據流入,才會報死鎖。
通道可以看做是一個先進先出的隊列。
package main
import "fmt"
var ch chan int = make(chan int, 3)
func main() {
ch <- 1
ch <- 2
ch <- 3
fmt.Println(<- ch)
fmt.Println(<- ch)
fmt.Println(<- ch)
}
會按照數據寫入的順序依次讀取
1
2
3
通道的其他基本操作
除了<-外,range也可以進行讀取。
package main
import (
"fmt"
)
var ch chan int = make(chan int, 3)
func main() {
ch <- 1
ch <- 2
ch <- 3
for v := range ch {
fmt.Println(v)
}
}
輸出
1
2
3
fatal error: all goroutines are asleep - deadlock!
可以讀取但產生死鎖,原因是range不等到通道關閉不結束讀取,在沒有數據流入的情況下,產生死鎖。有兩種方式可以避免這一情況。
1.通道長度為0即結束
package main
import (
"fmt"
)
var ch chan int = make(chan int, 3)
func main() {
ch <- 1
ch <- 2
ch <- 3
for v := range ch {
fmt.Println(v)
if len(ch) == 0 {
break
}
}
}
2.手動關閉通道
關閉通道只是關閉了向通道寫入數據,但可以從通道讀取。
package main
import (
"fmt"
)
var ch chan int = make(chan int, 3)
func main() {
ch <- 1
ch <- 2
ch <- 3
close(ch)
for v := range ch {
fmt.Println(v)
}
}
有緩沖通道圖解: