Golang-併發編程原理解析

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,提倡通過通信來進行內存共享,而不是通過共享內存來實現通信

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章