go併發編程
一.背景知識介紹
1.進程和線程
- 進程是程序在操作系統中一次執行的過程,系統進行資源分配和調度的基本單位
- 線程是進程的一個執行實體,是CPU調度和分派的基本單位,它是比進程更小的能獨立運行的基本單位
- 一個進程可以創建和撤銷多個線程,同一個進程中的多個線程之間可以併發執行
2.併發和並行
- 多線程程序在單核CPU上運行,就是併發
- 多線程程序在多核CPU上運行,就是並行
- 併發不是並行,併發主要是由切換時間片來實現同時運行,並行則是直接利用多核實現多線程的運行,go可以設置使用的核數,以發揮多核計算機的能力
3.協程和線程
- 協程:獨立的棧空間,共享堆空間,調度由用戶自己控制,本質上有點類似於用戶級線程,這些用戶級線程的調度也是自己實現的
- 線程,一個線程上可以跑多個協程,協程是輕量級的線程
4.goroutine
- 每個goroutine實例只有4-5KB的內存佔用(可伸縮),和由於實現機制而大幅度減少的創建和銷燬開銷是go高併發的根本原因
- goroutine 奉行通過通信來共享內存,而不是通過共享內存來通信
二.Channel
- Go語言的併發模型是CSP,提倡通過通信來進行內存共享,而不是通過共享內存來實現通信
- goroutine是Go程序併發的實體,channel就是他們之間的連接,channel是可以讓一個goroutine發送特定值到另一個goroutine的通信機制
- Go語言中的channel是一種特殊的類型,channel像是一個傳送帶或者隊列,總是遵循FIFO先入先出的原則,保證收發數據的順序
- 每一個Channel都有其具體的傳輸數據類型,也就是說聲明的需要爲其指定元素類型
- 聲明的Channel需要make初始化之後才能使用
- 聲明並初始化channel
ch := make(chan int)
1.channle的操作
channel有三種操作,接收,發送,關閉。發送和接收都使用 <- 符號
- 接收
x := <- ch // 從ch中接收值並賦值給變量x
- 發送
ch <- 10 // 把10發送到ch中
- 關閉
close(ch)
注意事項:
- 關於關閉通道,只有在需要通知接收方的goroutine,所有的數據都已經發送完畢的時候,才需要關閉通道,也就是說,關閉通道,可以告訴接收方的goroutine,所有的數據都已經發送完畢了
- 通道是可以被GC回收的,關閉通道不是必須的,不像文件那樣,在結束操作後必須關閉文件
- 對一個關閉的通道,再發送數據,就會導致panic
- 對一個關閉的通道進行接收會一直獲取值,直到通道爲空爲止
- 對一個關閉的,並且沒有值的通道,進行接收操作,會得到對應類型的零值
- 關閉一個已經關閉的通道會導致panic
2.無緩衝的Channel
- 無緩衝通道,又叫做阻塞通道
- 無緩衝通道只有在有人接收值的時候才能發送值,否則就會一直阻塞住,同樣的,只有在有人發送值的時候,才能接收值
func main() {
ch := make(chan int)
ch <- 10
fmt.Println("發送成功")
// fatal error: all goroutines are asleep - deadlock!
}
func recv(c chan int) {
ret := <-c
fmt.Println("接收成功", ret)
}
func main() {
ch := make(chan int)
go recv(ch) // 啓用goroutine從通道接收值
ch <- 10
fmt.Println("發送成功")
}
- 使用無緩衝通道進行通信將導致發送和接收的goroutine同步化,因此,無緩衝通道也被稱爲同步通道
3.有緩衝的Channel
- 在通道使用make函數初始化的時候可以指定緩衝容量
ch := make(chan int, 10) // 創建一個容量爲10的有緩衝區通道
- 只要通道的容量大於0,那麼該通道就是有緩衝通道
- 通道的容量表示通道中能最多存放元素的數量
- 通過len函數獲取當前通道內元素數量,通過cap函數獲取通道的容量
4.單向channel
- 限制通道在函數中只能發送或者接收
- chan <- int是一個只能發送的通道,可以發送但是不能接收;
- <-chan int是一個只能接收的通道,可以接收但是不能發送。
- 在函數傳參以及任何賦值操作中,將雙向通道轉爲單向通道是可以的,但反過來不行
func counter(out chan<- int) {
for i := 0; i < 100; i++ {
out <- i
}
close(out)
}
func squarer(out chan<- int, in <-chan int) {
for i := range in {
out <- i * i
}
close(out)
}
func printer(in <-chan int) {
for i := range in {
fmt.Println(i)
}
}
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go counter(ch1)
go squarer(ch2, ch1)
printer(ch2)
}
5.優雅的從通道循環取值
- 通過通道發送有限的數據時,可以通過close函數關閉通道來告知接收該通道值的goroutine停止等待
- 當通道關閉時,再往該通道發送值會導致panic,並且從該通道接收到值,全部接收完了之後再接收的話,會接收到類型零值
- 那我們應該如何判斷通道被關閉了呢?有下面兩種方法可供參考
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
// 開啓goroutine將0~100的數發送到ch1中
go func() {
for i := 0; i < 100; i++ {
ch1 <- i
}
close(ch1)
}()
// 開啓goroutine從ch1中接收值,並將該值的平方發送到ch2中
go func() {
for {
i, ok := <-ch1 // 通道關閉後再取值ok=false
if !ok {
break
}
ch2 <- i * i
}
close(ch2)
}()
// 在主goroutine中從ch2中接收值打印
for i := range ch2 { // 通道關閉後會退出for range循環
fmt.Println(i)
}
}
6.channel異常情況總結
三.select多路複用
- 在某些場景下,我們需要從多個通道接收數據,通道在接收數據時,如果沒有數據可以接收得阻塞住,等待數據過來,爲了應對這種場景,GO內置了select關鍵字,可以同時響應多個通道的操作
- go select的思想來源於網絡IO模型中的select,本質上也是IO多路複用,只不過這裏的IO是基於channel而不是基於網絡
- select的使用類似switch,有一系列case和default,每個case對應一個通道的接收或發送過程
select {
case <-chan1:
// 如果chan1成功讀到數據,則進行該case處理語句
case chan2 <- 1:
// 如果成功向chan2寫入數據,則進行該case處理語句
default:
// 如果上面都沒有成功,則進入default處理流程
}
- select會一直等待,直到某個case的通信操作完成時,就會執行case分支對應的語句
- select可以同時監聽多個channel,直到其中一個channel 就緒,如果多個channel同時就緒,則隨機選擇一個執行
- select可以用來判斷管道是否已滿,比如僅有一個case和default,該case是往管道寫,當管道滿了之後,該case會失敗,因僅有一個case,所以會走default,那就可以在default中通知該通道已滿
package main
import (
"fmt"
"time"
)
// 判斷管道有沒有存滿
func main() {
// 創建管道
output1 := make(chan string, 10)
// 子協程寫數據
go write(output1)
// 取數據
for s := range output1 {
fmt.Println("res:", s)
time.Sleep(time.Second)
}
}
func write(ch chan string) {
for {
select {
// 寫數據
case ch <- "hello":
fmt.Println("write hello")
default: // 因爲通道滿了,往執行不了寫數據了,就會走default
fmt.Println("channel full")
}
time.Sleep(time.Millisecond * 500)
}
}
四.goroutine池
- 本質是生產者消費者模型
- 可以有效的控制goroutine數量,防止暴漲
- 需求:
- 計算一個數字的各個數位之和,例如數字123,結果是1+2+3=6
- 隨機生成數字進行計算
package main
import (
"fmt"
"math/rand"
)
type Job struct {
// id
Id int
// 需要計算的隨機數
RandNum int
}
type Result struct {
// 這裏必須傳對象實例
job *Job
// 求和
sum int
}
func main() {
// 需要2個管道
// 1.job管道
jobChan := make(chan *Job, 128)
// 2.結果管道
resultChan := make(chan *Result, 128)
// 3.創建工作池
createPool(64, jobChan, resultChan)
// 4.開個打印的協程
go func(resultChan chan *Result) {
// 遍歷結果管道打印
for result := range resultChan {
fmt.Printf("job id:%v randnum:%v result:%d\n", result.job.Id,
result.job.RandNum, result.sum)
}
}(resultChan)
var id int
// 循環創建job,輸入到管道
for {
id++
// 生成隨機數
r_num := rand.Int()
job := &Job{
Id: id,
RandNum: r_num,
}
jobChan <- job
}
}
// 創建工作池
// 參數1:開幾個協程
func createPool(num int, jobChan chan *Job, resultChan chan *Result) {
// 根據開協程個數,去跑運行
for i := 0; i < num; i++ {
go func(jobChan chan *Job, resultChan chan *Result) {
// 執行運算
// 遍歷job管道所有數據,進行相加
for job := range jobChan {
// 隨機數接過來
r_num := job.RandNum
// 隨機數每一位相加
// 定義返回值
var sum int
for r_num != 0 {
tmp := r_num % 10
sum += tmp
r_num /= 10
}
// 想要的結果是Result
r := &Result{
job: job,
sum: sum,
}
//運算結果扔到管道
resultChan <- r
}
}(jobChan, resultChan)
}
}
五.併發安全和鎖
- 當多個goroutine操作同一個臨界區的資源時,可能會有數據競態問題,導致非預期結果
樣例如下,我們啓動兩個goroutine去累加x的值,但這兩個goroutine訪問x時會存在數據競爭
var x int64
var wg sync.WaitGroup
func add() {
for i := 0; i < 5000; i++ {
x = x + 1
}
wg.Done()
}
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
1.互斥鎖
- 互斥鎖是一種常用的控制共享資源訪問的方法
- Go語言中使用sync包的Mutex類型來實現互斥鎖
- 使用互斥鎖可以保證同一時間有且只有一個goroutine進入臨界區,其他goroutine則在等待鎖
- 當互斥鎖釋放後,等待的goroutine纔可以獲取鎖進入臨界區
- 多個goroutine同時等待一個鎖時,喚醒的策略是隨機的
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() // 解鎖
}
wg.Done()
}
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
2.讀寫鎖
- 很多場景下是讀多寫少的,當我們併發去讀取一個資源不涉及資源修改時,完全沒有必要使用互斥鎖,因爲互斥鎖是完全互斥的
- 讀多寫少的情況下,使用讀寫鎖是一個更好的選擇
- Go語言中使用sync包的RWMutex來實現讀寫鎖
- 讀寫鎖分爲兩種:讀鎖和寫鎖
- 當一個goroutine獲取讀鎖之後,其他的goroutine如果是獲取讀鎖則會獲得讀鎖,如果是獲取寫鎖就會等待
- 當一個goroutine獲取寫鎖之後,其他的goroutine無論是獲取讀鎖還是獲取寫鎖,都會等待
- 讀寫鎖非常適合讀多寫少的場景,如果讀和寫差別不大,則讀寫鎖的優勢就發揮不出來
樣例如下:
var (
x int64
wg sync.WaitGroup
lock sync.Mutex
rwlock sync.RWMutex
)
func write() {
// lock.Lock() // 加互斥鎖
rwlock.Lock() // 加寫鎖
x = x + 1
time.Sleep(10 * time.Millisecond) // 假設讀操作耗時10毫秒
rwlock.Unlock() // 解寫鎖
// lock.Unlock() // 解互斥鎖
wg.Done()
}
func read() {
// lock.Lock() // 加互斥鎖
rwlock.RLock() // 加讀鎖
time.Sleep(time.Millisecond) // 假設讀操作耗時1毫秒
rwlock.RUnlock() // 解讀鎖
// 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))
}
3.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() {
wg := sync.WaitGroup{}
for i := 0; i < 20; i++ {
wg.Add(1)
go func(n int) {
key := strconv.Itoa(n)
set(key, n)
fmt.Printf("k=:%v,v:=%v\n", key, get(key))
wg.Done()
}(i)
}
wg.Wait()
// fatal error: concurrent map writes
}
- Go語言的sync包中提供了一個開箱即用的併發安全版Map,sync.Map,開箱即用表示不用像內置的Map一樣使用make函數初始化就能用,同時sync.Map內置了諸如Store,Load,LoadOrStore,Delete,Range等操作方法。
var m = sync.Map{}
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 20; i++ {
wg.Add(1)
go func(n int) {
key := strconv.Itoa(n)
m.Store(key, n)
value, _ := m.Load(key)
fmt.Printf("k=:%v,v:=%v\n", key, value)
wg.Done()
}(i)
}
wg.Wait()
}
六.併發同步
- Go語言中使用sync包的WaitGroup來實現併發任務的同步
1.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時間,表示所有併發任務都已經完成
var wg sync.WaitGroup
func hello() {
defer wg.Done()
fmt.Println("Hello Goroutine!")
}
func main() {
wg.Add(1)
go hello() // 啓動另外一個goroutine去執行hello函數
fmt.Println("main goroutine done!")
wg.Wait()
}
2.sync.Once
- 很多場景下,我們需要確保某些操作在高併發時只執行一次,例如只加載一次配置文件
- Go語言中的sync包提供了一個針對只執行一次場景的解決方案-sync.Once
- sync.Once只有一個Do方法,Do(f func())
樣例如下:
- 延遲一個開銷很大的初始化操作到真正用到它的時候再執行
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]
}
sync.Once內部包含一個互斥鎖和一個布爾值,互斥鎖保證布爾值和數據的安全,而布爾值用來記錄初始化是否完成,這樣設計就能保證初始化操作的時候是併發安全的,並且初始化操作也不會執行多次
七.原子操作
- 代碼中加鎖操作因爲設計到內核態的上下文切換,耗時比較高,代價也大
- 針對基本數據類型我們可以使用原子操作來保證併發安全
- Go語言中原子操作由內置的標準庫sync/atomic提供
樣例如下:
- 比較互斥鎖和原子操作的性能
var x int64
var l sync.Mutex
var wg sync.WaitGroup
// 普通版加函數
func add() {
// x = x + 1
x++ // 等價於上面的操作
wg.Done()
}
// 互斥鎖版加函數
func mutexAdd() {
l.Lock()
x++
l.Unlock()
wg.Done()
}
// 原子操作版加函數
func atomicAdd() {
atomic.AddInt64(&x, 1)
wg.Done()
}
func main() {
start := time.Now()
for i := 0; i < 10000; i++ {
wg.Add(1)
// go add() // 普通版add函數 不是併發安全的
// go mutexAdd() // 加鎖版add函數 是併發安全的,但是加鎖性能開銷大
go atomicAdd() // 原子操作版add函數 是併發安全,性能優於加鎖版
}
wg.Wait()
end := time.Now()
fmt.Println(x)
fmt.Println(end.Sub(start))
}
- atomic包提供了底層的原子級內存操作,對於同步算法的實現很有幫助,但是除了某些特殊的底層應用,使用通道或者sync包實現同步更好!
八.總結
Go語言的併發模型是CSP,提倡通過通信來進行內存共享,而不是通過共享內存來實現通信