前言
我們經常會談到併發和並行這兩個詞,對於作業系統而言,併發是指一個處理器同時處理多個任務。
並行是指多個處理器或者是多核的處理器同時處理多個不同的任務。
以下這張圖可以比較形象的講解併發和並行,併發是兩個佇列交替使用一臺咖啡機,並行是兩個佇列同時使用兩臺咖啡機,這裡的佇列可以代表執行的程式。
正文
這裡聚焦在併發處理上,對於不同的程式,我們知道是在處理器上交替執行的,這裡就存在臨界資源的競爭問題。這裡的臨界資源可以指代機器某個儲存器儲存的值。
來看看下面這個例子
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 協議》,轉載必須註明作者和本文連結