理解執行緒同步

oliver-l發表於2021-12-14

前言

我們經常會談到併發和並行這兩個詞,對於作業系統而言,併發是指一個處理器同時處理多個任務。
並行是指多個處理器或者是多核的處理器同時處理多個不同的任務。

以下這張圖可以比較形象的講解併發和並行,併發是兩個佇列交替使用一臺咖啡機,並行是兩個佇列同時使用兩臺咖啡機,這裡的佇列可以代表執行的程式。

理解執行緒同步

正文

這裡聚焦在併發處理上,對於不同的程式,我們知道是在處理器上交替執行的,這裡就存在臨界資源的競爭問題。這裡的臨界資源可以指代機器某個儲存器儲存的值。

來看看下面這個例子

package main

import (
    "fmt"
    "sync"
)
//臨界資源
var a int = 0
var loop int = 10000000
var wg sync.WaitGroup

func main(){
    wg.Add(2)
    go add()
    go sub()
    wg.Wait()
    fmt.Printf("a = %d",a)
}

func add(){
    for i := 0 ;i < loop;i++ {
        a++
    }
    wg.Done()
}

func sub(){
    for j := 0 ;j < loop;j++ {
        a--
    }
    wg.Done()
}

這裡用變數a代表作業系統中的臨界資源,go協程表示作業系統執行的程式,程式是併發執行的,由於程式之間沒有同步的機制,所以每次輸出的結果都不為0

理解執行緒同步

所以在作業系統執行過程中,需要引入同步的機制,去實現對臨界資源的控制,下面就來講講執行緒同步的幾種機制

互斥量

特點:

  • 互斥量是最簡單的執行緒同步方法
  • 互斥量(互斥鎖),處於兩態之一的變數:解鎖和加鎖
  • 兩個狀態可以保證資源訪問的序列

go為我們提供了原生的互斥量

package main

import (
    "fmt"
    "sync"
)
//臨界資源
var a int = 0
var loop int = 10000000
var wg sync.WaitGroup
var lock sync.Mutex

func main(){
    wg.Add(2)
    go add()
    go sub()
    wg.Wait()
    fmt.Printf("a = %d",a)
}

func add(){
    for i := 0 ;i < loop;i++ {
        lock.Lock()
        a++
        lock.Unlock()
    }
    wg.Done()
}

func sub(){
    for j := 0 ;j < loop;j++ {
        lock.Lock()
        a--
        lock.Unlock()
    }
    wg.Done()
}

以上例子中,對臨界資源的操作,我們需要先獲取鎖,才能對臨界資源進行操作,這樣就保證了訪問的序列化。以下是執行的結果

理解執行緒同步

自旋鎖

自旋鎖是指當一個執行緒在獲取鎖的時候,如果鎖已經被其他執行緒獲取,那麼該執行緒將迴圈等待,然後不斷地判斷是否能夠被成功獲取,直到獲取到鎖才會退出迴圈。

特點:

  • 自旋鎖也是一種多執行緒同步的變數
  • 使用自旋鎖的執行緒會反覆檢查鎖變數是否可用
  • 自旋鎖不會讓出CPU,是一種忙等待狀態

這裡用go實現自旋鎖

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)
//臨界資源
var a int = 0
var loop int = 10000000
var wg sync.WaitGroup

// Spin是一個鎖變數,實現了Lock和Unlock方法
type Spin int32

func (l *Spin) Lock() {
    // 原子交換,0換成1
    for !atomic.CompareAndSwapInt32((*int32)(l), 0, 1) {}
}

func (l *Spin) Unlock() {
    // 原子置零
    atomic.StoreInt32((*int32)(l), 0)
}

type Locker interface {
    Lock()
    Unlock()
}

func main(){
    wg.Add(2)
    l := new(Spin)
    go add(l)
    go sub(l)
    wg.Wait()
    fmt.Printf("a = %d",a)
}

func add(l Locker){
    for i := 0 ;i < loop;i++ {
        l.Lock()
        a++
        l.Unlock()
    }
    wg.Done()
}

func sub(l Locker){
    for j := 0 ;j < loop;j++ {
        l.Lock()
        a--
        l.Unlock()
    }
    wg.Done()
}

在程式碼實現上,獲取臨界資源時,執行Lock()方法會迴圈等待,直到獲取,自旋鎖的設計避免了程式或執行緒上下文切換的開銷,但是缺點也很明顯,執行緒處於忙等待的狀態,若某個執行緒持有鎖的時間過長,其他等待鎖的執行緒會迴圈等待,消耗CPU的效能

理解執行緒同步

讀寫鎖

理解執行緒同步

特點:

  • 讀寫鎖是一種特殊的自旋鎖
  • 允許多個讀者同時訪問資源以提高讀效能
  • 對於寫操作則是互斥的

go語言為我們提供了原生的讀寫鎖

package main

import (
    "fmt"
    "sync"
    "time"
)
//臨界資源
var a int = 0
var loop int = 10
var wg sync.WaitGroup
var rw sync.RWMutex

func main(){
    wg.Add(4)
    go writer()
    go reader("小明")
    go reader("小紅")
    go reader("小蘭")
    wg.Wait()
    fmt.Printf("a = %d\n",a)
}

func reader(name string){
    for i := 0 ;i < loop;i++ {
        fmt.Printf("我是%s,我準備讀了\n",name)
        rw.RLock()
        fmt.Printf("我是%s,a = %d\n",name,a)
        time.Sleep(time.Second * 3)
        rw.RUnlock()
    }
    wg.Done()
}

func writer(){
    for i := 0 ;i < loop;i++ {
        fmt.Printf("我準備寫入了,當前a = %d\n",a)
        rw.Lock()
        a++
        fmt.Printf("我寫完了,當前a = %d,先歇一歇\n",a)
        time.Sleep(time.Second)
        rw.Unlock()
    }
    wg.Done()
}

執行結果如下所示,可以看到,在執行的過程中,當讀者和寫者持有鎖的操作時互斥的,而讀者和讀者持有鎖是可以同步的

理解執行緒同步

條件變數

特點:

  • 條件變數時一種相對複雜的執行緒同步方法
  • 條件變數允許執行緒睡眠,直到滿足某種條件
  • 當滿足條件時,可以向該執行緒傳送訊號,通知喚醒

go語言為我們提供了原生的條件變數

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

var cond sync.Cond
//產品區
var products []int

//生產者
func produce(nu int) {
    for {
        cond.L.Lock()
        //產品區滿 等待消費者消費
        for len(products) == 3 {
            fmt.Printf("我是生產者%d號,產品區滿了,我只能等了\n", nu)
            cond.Wait()
        }
        num := rand.Intn(1000)
        products = append(products,num)
        fmt.Printf("我是生產者%d號,當前生產了%d,產品區長度為%d\n", nu, num, len(products))
        cond.L.Unlock()

        //生產了產品喚醒 消費者執行緒
        cond.Signal()
        //生產完了歇一會,給其他協程機會
        time.Sleep(time.Second * 5)
    }
}

//消費者
func consume(nu int) {
    for {
        cond.L.Lock()
        //產品區空 等待生產者生產
        for len(products) == 0 {
            fmt.Printf("我是消費者%d號,產品區空了,我只能等了\n", nu)
            cond.Wait()
        }
        num := products[0]
        products = products[1:]
        fmt.Printf("我是消費者%d號,當前消費了%d,產品區長度為%d\n", nu, num, len(products))
        cond.L.Unlock()
        cond.Signal()

        //消費完了歇一會,給其他協程機會
        time.Sleep(time.Second * 2)
    }
}

func main() {
    quit := make(chan bool)

    //建立互斥鎖和條件變數
    cond.L = new(sync.Mutex)

    //5個消費者
    for i := 0; i < 5; i++ {
        go produce(i)
    }
    //3個生產者
    for i := 0; i < 3; i++ {
        go consume(i)
    }
    //主協程阻塞 不結束
    <-quit
}

執行結果如下所示,當生產者生產完畢後,會通知消費者進行消費,當消費者消費完畢後,會通知生產者進行生產,當產品區等於0時,不允許消費者消費,消費者必須等待。當產品區滿時,不允許生產者繼續生產,生產者必須等待,這一過程都是通過條件變數去實現的

理解執行緒同步

實際應用

mysql讀寫鎖

對於臨界資源的概念,我們在實際開發過程中都會有所接觸。如mysql的讀寫鎖

在處理併發讀或寫時,可以通過實現一個由兩種型別組成的鎖系統來解決問題。這兩種鎖通常被稱為共享鎖和排他鎖,也叫讀鎖和寫鎖。

  • 讀鎖是共享的,相互不阻塞,多個使用者同一時刻可以讀取同一個資源而不相互干擾。
  • 寫鎖是排他的,一個寫鎖會阻塞其他的寫鎖和讀鎖,確保在給定時間內只有1個使用者能執行寫入並防止其他使用者讀取正在寫入的同一資源。

可以針對單表的讀寫鎖

表獨佔寫鎖(lock table A write)

  • 獲得表A的WRITE鎖定
  • 當前session對鎖定表的查詢、更新、插入操作都可以執行,其他session對鎖定表的查詢被阻塞,需要等待鎖被釋放,陷入等待狀態
  • 釋放鎖後,其他session獲得鎖,查詢結果返回

表共享讀鎖(lock table A read)

  • 獲得表A的READ鎖定
  • 當前session可以查詢該表記錄,其他session也可以查詢該表的記錄
  • 當前session不能查詢沒有鎖定的表,其他session可以查詢或者更新未鎖定的表
  • 當前session插入或者更新鎖定的表都會提示錯誤,其他session更新鎖定表會等待獲得鎖,陷入等待狀態
  • 釋放鎖後,其他session獲得鎖,更新操作完成

針對整張表的讀寫鎖對於已正常上線的系統來說,效能非常低效,Innodb儲存引擎提供了行級鎖

共享鎖(S):SELECT * FROM table_name WHERE …LOCK IN SHARE

允許一個事務去讀一行,阻止其他事務獲得相同資料集的排他鎖
又稱讀鎖,若事務T對資料物件A加上S鎖,則事務T可以讀A但不能修改A。其他事務只能再對A加S鎖,而不能加X鎖,直到T釋放A上的S鎖。

這保證了其他事務可以讀A,但在T釋放A上的S鎖之前不能對A做任何修改。

  • set autocommit = 0(通過以上設定autocommit=0,則使用者將一直處於某個事務中,直到執行一條commit提交或rollback語句才會結束當前事務重新開始一個新的事務。)
  • 當前session對id = 10的記錄加share mode的共享鎖;其他session仍然可以查詢記錄,並也可以對該記錄加share mode的共享鎖
  • 當前session對鎖定的記錄進行更新操作,等待鎖;其他session也對該記錄進行更新操作,則會導致死鎖退出
  • 當前session獲得鎖後,可以成功更新

排他鎖(X):SELECT * FROM table_name WHERE …FOR UPDATE

允許獲得排他鎖的事務更新資料,阻止其他事務取得相同資料集的共享讀鎖和排他寫鎖。

又稱寫鎖。若事務T對資料物件A加上X鎖,事務T可以讀A也可以修改A。其他事務不能再對A加任何鎖,直到T釋放A上的鎖。

這保證了其他事務在T釋放A上的鎖之前不能再讀取和修改A。

  • set autocommit = 0
  • 當前session對id = 10的記錄加for update的排他鎖;其他session可以查詢該記錄,但是不能對該記錄加排他鎖,會等待獲得鎖
  • 當前session可以對鎖定的記錄進行更新操作,更新後釋放鎖
  • 其他session獲得鎖,得到其他session提交的記錄

秒殺庫存

對於一個秒殺系統來說,秒殺庫存也是一個臨界資源,在秒殺過程中,如果出現超賣的現象,可能會導致公司在秒殺活動中的嚴重虧本。

在高併發下,為了確保資料的一致性,通常採用事務來運算元據。但是,直接使用事務會影響系統的併發效能。為此,我們通常會通過佇列採用非同步的方式將請求排隊和序列化,這樣可以大大降低事務的併發操作,提升系統效能。

記憶體佇列主要用於接收請求後,在服務內部進行初步排隊。具體來說,在佇列的生產端,通過扣減記憶體庫存的方式對請求進行初步過濾,然後推送到佇列中;在消費端,以固定速度消費佇列中的請求,並過濾掉超時的請求,再扣減 Redis 庫存。

總結

同步方法 描述
互斥鎖 最簡單的一種執行緒同步方法,會阻塞執行緒
自旋鎖 避免切換的一種執行緒同步方法,屬於“忙等待”
讀寫鎖 為“讀多寫少”的資源設計的執行緒同步方法,可以顯著提高效能
條件變數 相對複雜的一種執行緒同步方法,有更靈活的使用場景
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章