Golang非CSP併發模型外的其他並行方法總結

RyuGou發表於2018-12-22

Golang最為讓人熟知的併發模型當屬CSP併發模型,也就是由goroutine和channel構成的GMP併發模型,具體內容不在贅述了,可以翻回之前的文章檢視。在這裡,要講講Golang的其他併發方式。

Golang不僅可以使用CSP併發模式,還可以使用傳統的共享資料的併發模式。

臨界區(critical section)

這是傳統語言比較常用的的方式,即加鎖。加鎖使其執行緒同步,每次只允許一個goroutine進入某個程式碼塊,此程式碼塊區域稱之為"臨界區(critical section)”。

Golang為*臨界區(critical section)*提供的是互斥鎖的包和條件變數的包。

互斥鎖

就是通常使用的鎖,用來讓執行緒序列用的。Golang提供了互斥鎖sync.Mutex和讀寫互斥鎖 sync.RWMutex,用法極其簡單:

var s sync.Mutex
    
s.Lock()
    
// 這裡的程式碼就是序列了,吼吼吼。。。
    
s.Unlock()


複製程式碼

Lock和Unlock

sync.Mutexsync.RWMutex的區別

沒啥大的區別,只不過sync.RWMutex更加細膩,可以將“讀操作”和“寫操作”區別對待。 sync.RWMutex中的Lock和unLock針對寫操作

var s sync.RWMutex

s.Lock()

// 上寫鎖了,吼吼

s.Unlock()
複製程式碼

sync.RWMutex中的RLock和RUnLock針對讀操作

var s sync.RWMutex

s.RLock()

// 上讀鎖了,吼吼..

s.RUnlock()
複製程式碼

讀寫鎖有以下規則:

  • 寫鎖被鎖定,(再試圖進行)讀鎖和寫鎖都阻塞
  • 讀鎖被鎖定,(再試圖進行)寫鎖阻塞,(再試圖進行)讀鎖不阻塞

即:多個寫操作不能同時進行,寫操作和讀操作也不能同時進行,多個讀操作可以同時進行

注意事項:

  • 不要重複鎖定互斥鎖;因為程式碼寫起來麻煩,容易出錯,萬一死鎖(deadlock)了就廢了。Go語言執行時系統自己丟擲的panic都屬於致命錯誤,都是無法恢復的,呼叫recover函式對它們起不到任何作用。一旦產生死鎖,程式必然崩潰。
  • 鎖定和解鎖一定要成對出現,如果怕忘記解鎖,最好是使用defer語句來解鎖;但是,一定不要對未鎖定的或者已經鎖定的互斥鎖解鎖,因為會觸發panic,而且此panic和死鎖一樣,屬於致命錯誤,程式肯定崩潰
  • sync.Mutex是個結構體,儘量不要其當做引數,在多個函式直接傳播。因為沒啥意義,Golang的引數都是副本,多個副本之間都是相互獨立的。

條件變數Cond

互斥鎖是用來鎖住資源,“創造”臨界區的。而條件變數Cond可以認為是用來自行排程執行緒(在此即為groutine)的,當某個狀態時,阻塞等待,當狀態改變時,喚醒。

Cond的使用,離不開互斥鎖,即離不開sync.Mutexsync.RWMutex。 Cond初始化都需要有個互斥鎖。(ps:哪怕初始化不需要,就應用場景而言,也得需要個互斥鎖)

Cond提供Wait、Signal、Broadcast 三種方法。 Wait表示執行緒(groutine)阻塞等待; Signal表示喚醒等待的groutine; Broadcast表示喚醒等待的所有groutine;

初始化:

cond := sync.NewCond(&sync.Mutex{})
複製程式碼

在其中一個groutine中:

cond.L.Lock()
for status == 0 {
     cond.Wait()
}
//狀態改變,goroutine被喚醒後,乾點啥。。。
cond.L.Unlock()
複製程式碼

以上算是模板

在另外一個groutine中:

cond.L.Lock()
status = 1
cond.Signal() // 或者使用cond.Broadcast()來喚醒以上groutine中沉睡的groutine
cond.L.Unlock()
複製程式碼

原子操作(atomicity)

原子操作是硬體晶片級別的支援,所以可以保證絕對的執行緒安全。而且執行效率比其他方式要高出好幾個數量級。

Go語言的原子操作當然也是基於CPU和作業系統的,Go語言提供的原子操作的包是sync/atomic,此包提供了加(Add)、CAS(交換並比較 compare and swap)、成對出現的儲存(store)和載入(load)以及交換(swap)。

此包提供的大多數函式針對的資料型別也非常的單一:只有整型!使用方式十分的簡單,看著函式直接呼叫就好。

var a int32
a = 1
a = atomic.AddInt32(&a, 2) //此處是原子操作,就這麼簡單,吼吼
複製程式碼

在此特別強調一下CAS,CAS對應的函式字首是“CompareAndSwap”,含義和用法正如英文翻譯:比較並交換。在進行CAS操作的時候,函式會先判斷被操作變數的當前值是否與我們預期的舊值相等,如果相等,它就把新值賦給該變數,並返回true,反之,就忽略此操作,並返回false。

可能是Golang提供的原子操作的資料型別實在是有限,Go又補充了一個結構體atomic.Value,此結構體相當於一個小容器,可以提供原子操作的儲存store和提取load

var atomicVal atomic.Value
str := "hello"

atomicVal.Store(str) //此處是原子操作哦

newStr := atomicVal.Load() //此處是原子操作哦 
複製程式碼

其他

為了能更好的排程goroutine,Go提供了sync.WaitGroupsync.Once還有context

sync.WaitGroup

sync.WaitGroup的作用就是在多goroutine併發程式中,讓主goroutine等待所有goroutine執行結束。(直接檢視程式碼註釋) sync.WaitGroup提供了三個函式AddDoneWait三者用法如下:

  • Add 寫在主goroutine中,引數為將要執行的goroutine的數量
  • Done 寫在各個非主goroutine中,表示執行結束
  • Wait 寫在主goroutine中,block主goroutine,等待所有其他goroutine執行結束
var wait sync.WaitGroup

    wait.Add(2) //必須是執行的goroutine的數量

    go func() {
        //TODO 一頓小操作
        defer wait.Done() // done函式用在goroutine中,表示goroutine操作結束
    }()

    go func() {
        //TODO 一頓小操作
        defer wait.Done() // done函式用在goroutine中,表示goroutine操作結束
    }()

    wait.Wait() // block住了,直到所有goroutine都結束

複製程式碼

注意

sync.WaitGroup中有一個計數器,記錄的是需要等待的goroutine的數量,預設值是0,可以通過Add方法來增加或者減少值,但是切記,千萬不能讓計數器的值小於零,會觸發panic!

sync.WaitGroup呼叫Wait方法的時候,sync.WaitGroup中計數器的值一定要為0。因此Add中的值一定要等於非主goroutine的數量! 且不要把Add和Wait方法放到不同的goroutine中執行!

sync.Once

真真正正的只執行一次。

sync.Once只要一個方法:Do,裡面就一個引數:func。多說無益,複製下面程式碼,猜猜執行結果就知道了。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var once sync.Once
    onceBody := func() {
        fmt.Println("Only once")
    }
    done := make(chan bool)
    for i := 0; i < 10; i++ {
        go func() {
            once.Do(onceBody)
            done <- true
        }()
    }
    for i := 0; i < 10; i++ {
        <-done
    }
}

複製程式碼

執行結果

Only once
複製程式碼

沒錯,只有一行。真只執行了一次。

context

context可以用來實現一對多的goroutine協作。這個包的應用場景主要是在API中。字面意思也很直接,上下文。當一個請求來時,會產生一個goroutine,但是這個goroutine往往要衍生出許多額外的goroutine去處理操作,例如連結database、請求rpc請求。。等等,這些衍生的goroutine和主goroutine有很多公用資料的,例如同一個請求生命週期、使用者認證資訊、token等,當這個請求超時或者被取消的時候,這裡所有的goroutine都應該結束。context就可以幫助我們達到這個效果。

很顯然,主goroutine和衍生的所有子goroutine之間形成了一顆樹結構。我們的context可以從根節點遍佈整棵樹,當然,是執行緒安全的。

執行緒之間的基本是這樣的:

func DoSomething(ctx context.Context, arg Arg) error {
    // ... use ctx ...
}
複製程式碼

有兩個根context:background和todo;這兩個根都是contenxt空的,沒有值的。兩者也沒啥太本質的區別,Background是最常用的,作為Context這個樹結構的最頂層的Context,它不能被取消。當不知道用啥context的時候就可以用TODO。

根生成子節點有以下方法:

//生成可撤銷的Context (手動撤銷)
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

//生成可定時撤銷的Context (定時撤銷)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

//也是生成可定時撤銷的Context (定時撤銷)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

//不可撤銷的Context,可以存一個kv的值
func WithValue(parent Context, key, val interface{}) Context
複製程式碼

可撤銷的Context

以下是每個方法的呼叫方式(全都來自godoc,可貼上複用): 可撤銷的func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

    gen := func(ctx context.Context) <-chan int {
        dst := make(chan int)
        n := 1
        go func() {
            for {
                select {
                case <-ctx.Done(): //只有撤銷函式被呼叫後,才會觸發
                    return 
                case dst <- n:
                    n++
                }
            }
        }()
        return dst
    }

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()  //呼叫返回的cancel方法來讓 context宣告週期結束

    for n := range gen(ctx) {
        fmt.Println(n)
        if n == 5 {
            break
        }
    }
複製程式碼

要想結束所有執行緒,就呼叫ctx, cancel := context.WithCancel(context.Background())函式返回的cancel函式即可,當撤銷函式被呼叫之後,對應的Context值會先關閉它內部的接收通道,也就是它的Done方法返回的通道。

WithDeadlineWithTimeout用法基本類似,而且WithTimeout函式內部呼叫了WithDeadline函式。兩者唯一區別是WithTimeout表示從現在開始xxx超時,而WithDeadline的時間可以是之前的時間:意思是說WithTimeout表示從現在開始, xxx時間後超時。而WithDeadline表示xx時間點,結束!這個時間點可以是昨天,時間點不收任何限制。

以下是godoc給出的列子:

WithDeadline

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    d := time.Now().Add(50 * time.Millisecond)
    ctx, cancel := context.WithDeadline(context.Background(), d)

    // Even though ctx will be expired, it is good practice to call its
    // cancelation function in any case. Failure to do so may keep the
    // context and its parent alive longer than necessary.
    defer cancel() //時間超時會自動呼叫

    select {
    case <-time.After(1 * time.Second):
        fmt.Println("overslept")
    case <-ctx.Done():
        fmt.Println(ctx.Err())
    }

}

複製程式碼

輸出:

context deadline exceeded
複製程式碼

WithTimeout

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // Pass a context with a timeout to tell a blocking function that it
    // should abandon its work after the timeout elapses.
    ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel() //時間超時會自動呼叫

    select {
    case <-time.After(1 * time.Second):
        fmt.Println("overslept")
    case <-ctx.Done():
        fmt.Println(ctx.Err()) // prints "context deadline exceeded"
    }

}

複製程式碼

輸出:

context deadline exceeded
複製程式碼

不可撤銷的context,傳遞值

WithValue可以用來在傳遞值的,值的存取是以KV的形式來進行的。直接上例子

type favContextKey string

    f := func(ctx context.Context, k favContextKey) {
        if v := ctx.Value(k); v != nil {
            fmt.Println("found value:", v)
            return
        }
        fmt.Println("key not found:", k)
    }

    k := favContextKey("language")
    k1 := favContextKey("Chinese")
    ctx := context.WithValue(context.Background(), k, "Go")
    ctx1 := context.WithValue(ctx, k1, "Go1")

    f(ctx1, k1)
    f(ctx1, k)
複製程式碼

輸出:

found value: Go1
found value: Go
複製程式碼

更多精彩內容,請關注我的微信公眾號 網際網路技術窩 或者加微信共同探討交流:

Golang非CSP併發模型外的其他並行方法總結

相關文章