高併發下map和chan實現的鏈接池的線程安全及效率

1.背景

上一次blog寫着寫着崩掉了,這次一定寫完一節保存一節。
目前從事go語言的後臺開發,在集羣通信時需要用到thrift的rpc。由於集羣間通信非常頻繁且併發需求很高,所以只能採用連接池的形式。由於集羣規模是有限的,每個節點都需要保存平行節點的連接,所以鏈接池的實現方式應該是map[host]chan conn。在go語言中,我們知道channel是線程安全的,但map卻不是線程安全的。所以我們需要適當的加鎖來保證其線程安全同時兼顧效率。

2. 鏈接池的一般設計

  1. 新建鏈接時,需要給定目標地址
  2. 獲取鏈接時,需要給定目標地址,若鏈接池存在鏈接,則從鏈接池返回,若不存在鏈接,則新建鏈接返回
  3. 鏈接池中存放的鏈接總是空閒鏈接
  4. 連接使用完後需放回鏈接池
  5. 放回鏈接池需要給定目標地址
  6. 放回鏈接池時若鏈接池已滿,則關閉該鏈接並將其交給gc

3.鏈接池的一般定義

type factory func(host string) conn

type conn interface {
   Close() error
}
type pool struct {
   m map[string]chan conn
   mu sync.RWMutex
   fact factory
}

以上是一個通用鏈接池的實現,用了channel和讀寫鎖,暫時不考慮鏈接超時等問題。我們的目的是探索這個鏈接池在高併發情況下的線程安全和get,put效率問題。所以下來我們給出實驗主程序。

4.測試主程序

測試主程序如下所示
pt是記錄的get,put次數,採用原子操作進行累加,其耗時忽略不計。
hosts爲集羣規模,也是map的大小,一般來說不會太大。
threadnum爲併發的協程數
爲了方便,此處直接使用net.dial作爲工廠方法實現
每一個協程是一個死循環,不斷地進行get,put操作。每次操作會使pts加1。

func main(){
   var pts uint64
   p := &pool{
      m :make(map[string]chan conn),
      mu:sync.RWMutex{},
      fact:func(target string)conn{
         c,_ :=net.Dial("","8080")
         return c
      },
   }
   //打印線程,打印get,put效率
   be := time.Now()
   go func (){
      for true{
         //此處先休眠一秒是爲了避免第一次時差計算爲0導致的除法錯誤
         time.Sleep(1 *time.Second)
         cost := time.Since(be) / time.Second
         println(atomic.LoadUint64(&pts)/uint64(cost),"pt/s")
      }
   }()
   time.Sleep(1*time.Second)
   //打印線程完,此處等待一秒是爲對應打印線程第一次休眠,儘量減少誤差


   //集羣規模
   hosts := []string{"192.168.0.1","192.168.0.2","192.168.0.3","192.168.0.4"}
   //併發線程數量
   threadnum := 1
   for i:=0;i<threadnum;i++{
      go func(){
         for true{
            target := hosts[rand.Int() % len(hosts)]
            conn := p.Get(target)
            //------------------使用連接開始
            //time.Sleep(1*time.Nanosecond)
            //------------------使用連接完畢
            p.Put(target,conn)
            atomic.AddUint64(&pts,1)
         }
      }()
   }
   time.Sleep(100 * time.Second)
}

5.單協程情況下的效率

5.1 單協程get & put實現

單協程模式下,我們不必考慮線程安全的問題,也就不必加鎖。此時的get,put實現如下

func (p *pool)Get(host string) (c conn){
   if _,ok := p.m[host];!ok{
      p.m[host] = make(chan conn,100)
   }
   select {
   case c  = <- p.m[host]:
      {}
   default:
      c = p.New(host)
   }
   return
}
func (p *pool)Put(host string,c conn){
   select {
   case p.m[host] <- c:
      {}
   default:
      c.Close()
   }
}
func (p *pool)New(host string)conn{
   return p.fact(host)
}

5.2 測試結果

我們設置threadnum爲1,測試結果如下。其速度大概在5,000,000 次/秒
在這裏插入圖片描述

6.併發情況下效率-全寫鎖

6.1 全寫鎖的get & put 實現

爲了保證併發情況下的線程安全,我們需要使用讀寫鎖,那麼對get和put操作究竟該如何加鎖呢,最安全的形式當然是全寫鎖的形式,單其效率肯定是最低的,因爲這樣同一時刻總是隻有一個協程在進行寫或者讀。

func (p *pool)Get1(host string) (c conn){
   p.mu.Lock()
   defer p.mu.RLock()
   if _,ok := p.m[host];!ok{
      p.m[host] = make(chan conn,100)
   }
   select {
   case c  = <- p.m[host]:
      {}
   default:
      c = p.New(host)
   }
   return
}
func (p *pool)Put1(host string,c conn){
   p.mu.Lock()
   defer p.mu.Unlock()
   select {
   case p.m[host] <- c:
      {}
   default:
      c.Close()
   }
}

6.2 測試結果

6.2.1 全寫鎖下 的多協程測試結果

我們設置threadnum爲4,測試結果如下,其速度大概在1,000,000次/秒
在這裏插入圖片描述

6.2.2 全寫鎖下單協程測試結果

如果我們將threadnum設置爲1,再次測試,其速度爲2,800,00次/秒。可以看到,多協程會降低效率,因爲協程間切換也會有時間消耗。但我們經常聽說多協程會提高運行速度,這也是對的,那麼什麼時候多協程會提高運速度呢,這就是我說的鏈接使用時間的問題,當連接使用時間大於鎖競爭和協程切換時間的時候,我們用多協程會提高效率。而實際使用中,連接的使用時間總是存在的且一般都大於鎖競爭時間和協程切換時間。
在這裏插入圖片描述

6.2.3 單協程下存在鏈接使用時間的的測試結果

在主程序中,我們在get和put間加上休眠時間,此處設置休眠時間爲1毫秒即鏈接使用1毫秒後放鏈接池。同時協程數設置爲1。單協程情況下,其速度大概如下500次/秒。可以看到實際的效率大幅度降低。
在這裏插入圖片描述

6.2.4 多協程下存在鏈接使用時間的測試結果

同樣保持鏈接使用時間爲1毫秒,協程數量設置爲4,測試結果如下。其速度大概爲2,000次/秒,剛好是單協程的4倍。所以實際情況下多協程的使用需要慎重考慮,並不是多協程一定能提高程序的處理速度,相反在某些情況下會降低程序的執行速度。由於本次測試的是鏈接池的性能和安全,接下來的測試不再添加鏈接使用時間,只單純的測試讀寫鎖和效率的問題。本小節算是一個附加測試。
在這裏插入圖片描述

7.併發情況下效率-讀寫鎖1

由於全寫鎖沒有實際的使用意義,所以我們需要使用讀寫鎖來提高效率,那麼如何保證線程安全添加讀寫鎖呢。首先對於我們的map結構來說,當有寫操作的時候,我們的讀操作應該是不可靠的,所以不能進行,當讀操作時,我們不希望有寫操作但其他協程也能同時讀取,這橋恰符合讀寫鎖的作用原理。
當加寫鎖時,所有的讀寫均不可用
當加讀鎖時,所有的寫操作不可用,讀操作可用

7.1 讀寫鎖1 get & put實現

考察我們的put程序,只有對map的讀,所以只需要加讀鎖,而在get中,包含了兩部分,第一次寫操作和第二次的讀操作,所以我們很簡單的我們想到,需要使用兩次鎖,第一次寫鎖,第二次讀鎖。

func (p *pool)Get2(host string) (c conn){
   p.mu.Lock()
   if _,ok := p.m[host];!ok{
      p.m[host] = make(chan conn,100)
   }
   p.mu.Unlock()
   
   p.mu.RLock()
   defer p.mu.RUnlock()
   select {
   case c  = <- p.m[host]:
      {}
   default:
      c = p.New(host)
   }
   return
}
func (p *pool)Put1(host string,c conn){
   p.mu.RLock()
   defer p.mu.RUnlock()
   select {
   case p.m[host] <- c:
      {}
   default:
      c.Close()
   }
}

7.2 測試結果

我們本來期望的是效率應該比全寫鎖要高一些,但實際情況是低一些,只有800,000次/秒。那問題出在哪裏呢。從程序上來看,get多了一次加鎖,所以導致鎖競爭次數比全寫鎖要高一些,但我們並不能減少鎖次數直接使用讀鎖,這樣是不安全的,程序也會報錯。所以我們給出另一種安全的讀寫鎖形式。
在這裏插入圖片描述

8.併發情況下效率-讀寫鎖

8.1 讀寫鎖2 get & put實我們從實際的使用來看一下get程序,由於我們給定了hosts,所以其實對map的寫入操作只會進行四次,但後來每次進行get時都會加一次寫鎖,這是沒有必要的。仔細看一下第一次寫鎖,我們加的有些草率,因爲首先會讀取一次map來判斷是否應該進行寫入操作,所以我們可以通過增加一次讀鎖,來減少後來的加寫鎖。當然有人會說爲什麼不直接初始化map,這樣就沒有寫操作,這我也考慮過,但是集羣規模有可能會擴張並且會動態變化,直接初始化map會顯得有些刻意,並且通用性也不強,與其他模塊會產生耦合。所以這種做法並沒有多少設計上的美感,相反會顯得比較low。我們給出第二種讀寫鎖如下。


func (p *pool)Get3(host string) (c conn){
   p.mu.RLock()
   if _,ok := p.m[host];!ok{
      p.mu.RUnlock()
      p.mu.Lock()
      p.m[host] = make(chan conn,100)
      p.mu.Unlock()
   }else{
      p.mu.RUnlock()
   }
   p.mu.RLock()
   defer p.mu.RUnlock()
   select {
   case c = <- p.m[host]:
      {}
   default:
      c = p.New(host)
   }
   return
}
func Put3(host string,c conn){
   p.mu.RLock()
   defer p.mu.RUnlock()
   select {
   case p.m[host] <- c:
      {}
   default:
      c.Close()
   }
}

8.2 測試結測試結果如下,其速度大概在3,400,000次/秒。是全寫鎖性能的4倍左右。

在這裏插入圖片描述

9.defer對鎖的性能影響

我們經常聽說defer的執行效率低,其實是因爲defer在函數返回時才執行,這對普通的函數並沒有影響,但對所來說,如果我們可以提前釋放鎖,那麼肯定能減少很多鎖的無效佔用。順便我們測試一下defer函數對鎖的性能影響,對8.1的get & put實現,我們將其中的defer全部替換爲函數結束之前手動釋放鎖。其實只在put中有defer

9.1 無defer的 get & put實現


func (p *pool)Get4(host string) (c conn){
   p.mu.RLock()
   if _,ok := p.m[host];!ok{
      p.mu.RUnlock()
      p.mu.Lock()
      p.m[host] = make(chan conn,100)
      p.mu.Unlock()
   }else{
      p.mu.RUnlock()
   }
   select {
   case c = <- p.m[host]:
      {}
   default:
      c = p.New(host)
   }
   return
}
func (p *pool)Put4(host string,c conn){
   p.mu.RLock()
   select {
   case p.m[host] <- c:
      {}
   default:
      c.Close()
   }
   p.mu.RUnlock()
}

9.2 測試結果

測試結果如下,僅僅修改了一處defer,速度達到接近4,000,000次/秒,性能提高了15%。還是非常可觀的。
在這裏插入圖片描述

10.總結

1. 這篇bolg真不容易,寫了我好久

  1. 多協程提高程序執行速度是有前提的,並不能無腦提高程序速度
  2. map是非線程安全的,需要謹慎使用
  3. 讀寫鎖性能比單純的寫鎖(互斥鎖)要高很多,儘量使用讀寫鎖
  4. 讀寫鎖的使用可以針對具體情況進行優化,還可以使用go race detector來檢測是否安全
  5. 鎖儘量手動釋放,當然defer是一種非常優雅的寫法,對效率要求不高的程序中我還是喜歡用defer
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章