多線程情況下 很多變數 頻繁訪問 難道每個都要加鎖訪問嗎?

如題,雖然我之前每個都加鎖,但是我一直有個心病 就是我這種寫代碼姿勢到底對不對。我看不少項目也和我是一樣的。

但是如果變得 N多鎖,我就會覺得 為了所謂的並行,代碼搞這麼複雜,感覺好噁心。。。。(當然go語言寫某些程序 例如網路 由於是阻塞讀寫 幾乎必須並行)

比如 我要實現一個最大連接數。

1.每次新的連接的時CurrentCount要自增+1 要加鎖。

2.完了再次調用Accept的時候要檢查CurrentCount是否大於MaxCount 要加鎖。

3.有某個連接斷開CurrentCount要自減-1 要加鎖。

4.顯示當前有幾個連接CurrentCount 要加鎖。

5.如果我希望動態改變MaxCount那麼MaxCount改變的時候要加鎖,那我還要在2處鎖住MaxCount 要加鎖

6.SessionMgr 內部實現又是要加鎖 增刪查 每個動作都要加鎖。

7.如果我要把 Session 發過來的封包 做轉發 我還要鎖住 另一個 Client 檢查狀態是否斷開

8.比如我還想讓伺服器的一個模塊暫停工作 要加鎖(雖然這功能看上去很奇葩)。

9.遍歷session的時候又要加鎖。

10.內心無數個--鎖 飛過。

鎖一多心好累。大家寫多線程(並行)代碼都是這樣的嗎?只要想在多線程下多加點功能 並且想精細化控制 就感覺離不開鎖。無限加鎖?繞不開嗎?


需不需要加鎖,完全取決於場景,安全和效率,原子性和可見性的取捨。以 Java 為例子:

1.只讀型或者不可變數據,無需任何加鎖。這時候類似 clojure 里的不可變數據結構的優勢就體現出來了。

2.只確保可見性,使用 volatile 聲明。

3.同時確保可見性和原子性,synchronized 和 lock 等。

針對更具體的場景還能展開:

1.讀多寫少的場景,引入讀寫鎖或者 CopyOnWrite(結合 volatile) 技術。比如題目中的 session 集合的處理。

2.計數型操作,使用 Atomic 類組,或者 Java8 引入的 Addr 類,都是現代 CPU 提供的原子指令 。比如題目中的 session 計數。

3.不同變數獨立保護,分拆鎖 (lock splitting) 和分離鎖 (lock striping)。比如 session 集合是否可以採用分離鎖來分段保護, Java 可以直接用 ConcurrentHashMap 。

4.處理資源有限,可以非同步處理的,生產者-消費者模型,中間的隊列也可以用一些 lock-free 演算法。消費者如果就一個,臨界區的保護也可以做到適當縮減。比如消息轉發之類可以採用生產者消費者模型,最常見就是線程池。

5.臨界區處理很快,也許用 spin lock 或者 lock-free 演算法,臨界區處理很慢, mutex 更合適。

我覺的並發編程很大程度上就是針對這些具體場景,在安全和效率、原子性和可見性之間做各種取捨,對於性能,更重要的是實際的測量,而非臆測。


引用 @陳碩 的一句話:

既然你問了,那就要加鎖。


基本上是,只要你要保證多線程的一致性,代碼順序要嚴格同步就得鎖。但實際應用中可以優化下,有些時候也不那麼嚴,比如退出標誌一般不用鎖,早點晚點不那麼急。

c++11的話,1-4可以用atomic_int,本身的增減都是原子操作。4的話如果是是查看,實際工程應用上不用鎖,如果有連帶操作還是得鎖一段。

5的話如果增減不是強約束要立即生效通常也可以不用鎖,否則得考慮一致性,比如已有100連接了你改成最大50多出來的怎麼辦。

----

67的話你通過修改設計也可能不加所的,主要還是看需求。比如查出來的東西如果只是顯示,可以不加鎖。7的話,恐怕是避免不了鎖,但是你可以通過帶鎖隊列來實現,可能更方便。

總之,根據業務需求有些地方鎖可以免掉,有些地方實現方式修改一下就不會給人一種到處都是鎖的感覺了。


鎖多不關鍵,關鍵是線程同時競爭比較耗 Locks Aren"t Slow; Lock Contention Is


怎麼我感覺是設計問題,很多共享變數要訪問。這種設計就是成問題了。

應該設計一條線,專門處理Session管理。其它線程訪問通過消息或者actor來交流,啟用非同步等待可以簡化流程。當然我不懂Go。不知道它有那些實現不了。


推薦看一下linux的RCU的發明者,Paul McKenny 在《Is Parallel Programming Hard, And, If So, What Can You Do About It?》中的計數器相關代碼。可以完美回答你的問題,我就不在這裡複製粘貼了。

這裡需要思考的一個問題是:在並行環境下,你用頻繁的鎖或者原子操作來得到一個精確的current connection(cc),以此來限制cc不超過一個max值;cc這個值之所以會存在,其中的一個緣由(很可能)是我們人類習慣串列化的思維,傾向於認為在某個時刻只能創建/釋放單個連接;而在真實的並行環境,同一個時刻創建/釋放多個連接是很正常的。這個時候你應該想想:真的需要這個cc嗎?可以類比量子力學中的一個說法:觀察會帶來影響,你引入cc就是破壞了並行環境。


大家說的都很對,設計構建程序時,為了提升系統性能,通常有以下幾大原則:

  1. 除非必要,盡量少用lock;

  2. 能用Cas,就不要用lock;

  3. 必須lock,盡量優化設計,減少lock、unlock使用次數,可以提升性能;

  4. 減少時間,減少lock和unlock之間的耗時,簡單點說減少代碼,讓cpu快速執行完成;
  5. 減少碰撞,盡量減少對個線程(Go程)同時競爭lock和cas;可以使用數據分區,每個區使用一個lock,操作時先根據一個關鍵字,找到數據區,在對數據執行:加鎖、操作、解鎖,這樣並發時,就減少出現碰撞的可能。

介紹一篇文章:golang 核心開發者 Dmitry Vyukov(1.1 調度器作者) 關於性能剖析,講的非常好。

針對第5點,如何減少lock碰撞,這裡提供一個示例

// mutexHit
package main

import (
"fmt"
"sync"
"time"
)

type Lane struct {
mu sync.Mutex
m map[byte]byte
}

func testMutexLane(Go, Gnt int) {
var lanes [256]Lane
for i := range lanes {
lanes[i].m = make(map[byte]byte)
}
var wg sync.WaitGroup
wg.Add(Go)

start := time.Now()
for g := 0; g &< Go; g++ { go func() { for i := 0; i &< Gnt; i++ { index := byte(i) l := lanes[index] l.mu.Lock() l.m[index] = index _ = l.m[index] l.mu.Unlock() } wg.Done() }() } wg.Wait() end := time.Now() use := end.Sub(start) op := use / time.Duration(Go*Gnt) fmt.Printf("Times=%10v, Go=%4v, Gnt=%8v, Use=%12v %10v/op ", Go*Gnt, Go, Gnt, use, op) } func main() { testMutexLane(1, 10000000) testMutexLane(2, 10000000) testMutexLane(4, 10000000) testMutexLane(8, 10000000) testMutexLane(16, 1000000) testMutexLane(32, 1000000) testMutexLane(64, 1000000) testMutexLane(128, 100000) testMutexLane(512, 100000) testMutexLane(1014, 10000) testMutexLane(2048, 10000) }

測試結果如下:

Times= 10000000, Go= 1, Gnt=10000000, Use= 875.7202ms 87ns/op
Times= 20000000, Go= 2, Gnt=10000000, Use= 1.3859863s 69ns/op
Times= 40000000, Go= 4, Gnt=10000000, Use= 1.9903467s 49ns/op
Times= 80000000, Go= 8, Gnt=10000000, Use= 3.4608053s 43ns/op
Times= 16000000, Go= 16, Gnt= 1000000, Use= 655.9362ms 40ns/op
Times= 32000000, Go= 32, Gnt= 1000000, Use= 1.277849s 39ns/op
Times= 64000000, Go= 64, Gnt= 1000000, Use= 2.5617031s 40ns/op
Times= 12800000, Go= 128, Gnt= 100000, Use= 501.3332ms 39ns/op
Times= 51200000, Go= 512, Gnt= 100000, Use= 2.2099692s 43ns/op
Times= 10140000, Go=1014, Gnt= 10000, Use= 420.7795ms 41ns/op
Times= 20480000, Go=2048, Gnt= 10000, Use= 838.0572ms 40ns/op

希望對你有幫助。


變數被多線程共享時,需要被保護,而保護到什麼什麼程度,取決於業務場景上對嚴謹性的需求有多高,這個問題跟「一致性」是一樣的道理。

如果業務需求嚴格一致,例如賬單業務,那麼首先要保證的是正確性,其次再追求性能,實現上考慮 RWLock, atomic, CAS, etc.

如果業務上沒有這樣的強需求,就可以捨棄一定的正確性來提高效率。

具體到你提出的問題,對於最大連接數的限制,如果限制最大100個連接,程序在某些 case 下允許了103個連接,或者第98個連接意外被拒了,這些情況能夠接受的話,那麼不妨使 atomic.Add,atomic.Load, atomic.Store 來操作 current, max 之類的變數,多個 atomic 操作之間也不用加鎖,這就基本滿足了需求。

再比如,對於 map[string]*Foo 這樣的結構,原則上對於 map 的讀寫操作需要被保護,但如果你已經確定這個 map 一旦被初始化之後,結構就不會再修改,會修改的只是 Foo 裡面的數據,那麼只需要對 Foo 讀寫操作作保護即可,從而把鎖的粒度從整個 map 弱化為每個 Foo 結構。

[2017.01.17] 補充:碰巧看到 bradfitz 在一個 golang proposal 下的回答思路與此相同.

Note that it"s perfectly safe to access maps concurrently from multiple goroutines, as long as all the goroutines are reading. Only once you need to mutate something do you need to grab an exclusive lock. If you need to concurrently update items, you can make your map values be pointers, and have mutexes or atomics inside your values.

Link: Proposal: Atomic maps · Issue #17043 · golang/go]

最後,保證多線程訪問共享變數的一致性的方法,沒有最好,只有在給定業務場景的前提下,才有最好的那個實現方法。退一步說,對於絕大多數互聯網應用,使用 RWLock 就夠了,沒什麼心智負擔,要知道 Lock/Unlock 這些 nanosecond 量級的操作,對於動輒幾十幾百 millisecond 的HTTP請求而言,談不上瓶頸。


對於Go語言,建議你看一下這個文檔

https://golang.org/pkg/sync/atomic/


就你這個場景來說可以不用os鎖。用cmpxchg指令,這是彙編指令,一般的語言都沒提供這個語義。1,3,4沒必要加鎖,用原子操作即可(彙編指令加lock),2用cmpxchg


加鎖就加唄,不然就用chan來非同步串列執行。共享變數並發有什麼理由不加鎖?其實也可以不加鎖,只要你的代碼控制隊列是串列的執行就行。


只要是多線程共享就必須加鎖,除非共享的是不可變對象。

如果只是獲取count計數,有時候只需要用atomic就夠了,不需要加鎖。

不是每個操作單獨一把鎖,而是很多操作共享同一把鎖,鎖多了容易發生死鎖。

並發編程是比較複雜和容易出錯,但是也是有辦法簡化的。使用Actor並發模型,不需要顯式加鎖,代碼按照你所想的順序運行。這是我寫的一個Actor模型的實現:chunquedong/cppfan


首先,你要明白一點所有的無鎖結構都會帶來一些性能上或者操作上負擔。鎖存在的意義是為了保證一致性,你所謂問題不是鎖的問題,你把你的操作封裝成一個struct,在sturct內部進行鎖的操作,外部調用是無感知的,就可以了。


很久之前做多線程讀也有差不多的問題,但是不是網路IO,都是本地磁碟的。

說一下當時的解決思路,僅供參考。

創建一個全局數組,創建線程的時候傳給線程一個index,用來給線程索引它可以操作的(在數組中)元素。所有寫的操作,都是對應的線程在做,所以不存在加鎖的問題。主線程可以進行讀來做統計。一些參數的設置,也只在主線程進行修改,其它線程都只進行讀操作。不存在多個線程同時寫入的問題,也就省事了。

補充:這種方式有很大的局限性。例如你要把主線程動態申請的內存首地址傳遞給子線程,就要考慮哪裡釋放的問題。


你要明白事情的優先順序。

數據正確 &> 代碼優雅

數據如果無法保證正確,再優雅的代碼也是垃圾。


atomic庫。

func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)


用atomic ,smp_mb,rb,wb


阻塞並發基本上都是要這樣寫的,唯一可以精簡的就是用原子操作來控制連接數的+-,為什麼不用非阻塞+多線程的方式呢,這樣可以避免絕大多數的鎖。

剛畢業的時候也是這麼寫代碼的,無論怎麼樣,來個鎖總不會錯。最後被自己的代碼噁心到了,慢慢改成非阻塞……


表面上看,確實只要有可能有衝突,就一定得加鎖,無論是顯示lock,還是利用無鎖cas(本質也互斥),所有的東西都考慮,那麼估計你的代碼很難沒有bug,因為多線程的各種情況太複雜了。

怎麼解決?盡量用成熟的庫,例如java的concurrent庫,cpp的tbb等,讀懂一些實例和用法,你就已經可以大概使用了,當然這也只是部分解決了一些問題,具體問題還得具體分析。

盡量把客戶分開,使得每個連接任務綁定在一個線程,這樣也能減少一些鎖,例如陳碩大大的muduo和java的netty4,都是類似思路。

還有就是前面答題者說的封裝,結構內部使用鎖,外部用戶無感知,但是一定要注意避免死鎖。

還有注意異常情況下,鎖的釋放,java一般放在finally,cpp是RAII


gcc的__sync開頭的操作


推薦閱讀:

如何看待 Dropbox 從 Go 轉向 Rust ?
nodejs與go語言比較如何,它們的發展前景怎樣,網站後台開發,選擇nodejs好還是go好?
選擇學習 C 語言、Go 語言、C++11 各有哪些優缺點?
golang中怎麼處理socket長連接?

TAG:編程 | 並行編程 | 多線程 | Go語言 | golang最佳實踐 |