1. 程式人生 > >Mudo C++網路庫第二章學習筆記

Mudo C++網路庫第二章學習筆記

執行緒同步的精要

  • 併發有兩種基本的模型:
    • 一種是message passing(訊息傳遞);
    • 另一種是shared memory(共享記憶體);
  • 在分散式系統中(有多臺物理機需要通訊), 執行在多臺機器上的多個程序只有一種實用模型:message passing(訊息傳遞), 因為多個物理機基本上不能共享記憶體;
  • 併發(concurrency);
  • 執行緒同步的四項原則, 按重要性排列:
    • 首要原則是儘量最低限度地共享物件, 減少需要同步的場合;
      • 一個物件能不暴露給別的執行緒就不要暴露;
      • 如果暴露就優先考慮immutable物件(const);
      • 實在不行才暴露可修改的物件,並用同步措施來充分保護它;n
    • 其次, 使用高階的併發程式設計構件, 如任務佇列(TaskQueue), 生產者消費者佇列(Producer-Consumer Queue), 閉鎖(CountDownLatch)等;
    • 最後不得已才使用底層同步原語(primitives)時, 只用非遞迴的互斥器和條件變數, 慎用讀寫鎖, 不要用訊號量;
    • 除了使用atomic整數之外, 不要自己編寫lock-free程式碼, 也不要用"核心級"同步原語; 不能憑空猜測'那種做法效能會更好', 比如spin lock(自旋鎖) vs mutex(互斥量);

互斥量(mutex)

  • 主要是為了保護共享資料:
    • 用RAII(Resource Acquisition Is Initialization -- 資源申請即初始化)手法封裝mutex的建立, 銷燬, 加鎖, 解鎖這四個操作;
      • 保證鎖的生效期間等於一個作用域(scope), 不會因異常而忘記解鎖;
  • 只用非遞迴的mutex(即不可重入的mutex);
    • mutex分為遞迴(recursive)和非遞迴(non-recursive)兩種, 這是POSIX的叫法, 另外的名字是可重入(reentrant)和非可重入;
      • 這兩種mutex對執行緒間(inter-thread)的同步基本沒有區別, 他們的唯一區別就是: 同一執行緒可以重複對recursive mutex加鎖, 但是不能重複對non-recursive mutex加鎖;
    • recursive mutex(可重入的互斥量)可能會隱藏程式碼裡的一些問題:
      • 典型情況是, 以為拿到一把鎖就可以修改物件了, 沒想到外層程式碼已經拿到了鎖, 正在修改(或讀取)同一個物件呢;
  • 不手工呼叫lock()和unlock()函式, 一切交給棧上的Guard物件的構造和解構函式負責;
    • 這種做法叫做Scoped Locking(作用域加鎖);
  • 在每次構造Guard物件時, 思考一路上(呼叫棧上)已經持有的鎖, 防止因加鎖順序不同而導致死鎖(deadlock);
    • 由於Guard物件是棧上物件, 看函式呼叫棧就能分析用鎖的情況, 非常便利;
  • 不使用跨程序的mutex, 程序間通訊只用TCP sockets;
    • 加鎖和解鎖在同一個執行緒, 執行緒a不能去unlock執行緒b已鎖住的mutex(RAII自動保證);
    • 不要忘記解鎖(RAII自動保證);
    • 不重複解鎖(RAII自動保證);
    • 必要時可以考慮用PTHREAD_MUTEX_ERRORCHECK來排錯;
  • 利用thread apply all bt命令可以在gdb除錯中檢視所有的堆疊呼叫資訊;

死鎖(dead lock)

  • 堅持使用Scoped Locking(作用域加鎖), 很容易在出現死鎖的時候定位bug;

條件變數(condition variable)

  • 互斥器(mutex), 是加鎖原語, 用來排他性地訪問共享資料, 它不是等待原語;
  • 如果需要等待某個條件成立, 應該使用條件變數(condition variable);
    • 條件變數是一個或多執行緒等待某個布林表示式為真, 即等待別的執行緒喚醒它;
    • 條件變數的學名叫管程(monitor);
  • 條件變數只有一種使用方式, 幾乎不可能用錯:
    • 對於wait端:
      • 必須與mutex一起使用, 該布林表示式的讀寫虛受此mutex保護;
      • 在mutex已上鎖的時候才能呼叫wait()函式;
      • 把判斷布林條件和wait()放到while迴圈中;
    • 對於signal/broadcast端:
      • 不一定要在mutex已上鎖的情況下呼叫signal(理論上);
      • 在signal之前一般要修改布林表示式;
      • 修改布林表示式通常要用mutex保護(至少用作full memory barrier);
      • 注意區分signal和broadcast:
        • broadcast通常用於表明狀態變化;
        • signal通常用於表示資源可用;
  • 條件變數是非常底層的同步原語, 很少直接使用一般都是用它來實現高層的同步措施, 如BlockingQueue
  • 互斥量和條件變數構成了多執行緒程式設計的全部必備同步原語, 用它們即可完成任何執行緒同步任務, 二者不能相互代替;
    • 千萬不要連mutex都沒有學會、用好,一上來就考慮lock-free設計;

不要用讀寫鎖和訊號量

  • 讀寫鎖(Readers-Writer lock, 簡寫為rwlock)是個看上去很美的抽象, 它明確區分了read和write兩種行為;
    • 首選rwlock來保護共享狀態, 這不見得是正確的;
      • 從正確性方面來說, 在持有read lock的時候修改了共享資料,在程式維護階段容易犯的錯誤;
      • 從效能方面來說, 讀寫鎖不一定比普通的mutex更高效, 讀寫鎖要更新當前reader的數目, 如果臨界區很小, 鎖競爭不激烈, 那麼mutex會更快;
      • reader lock允許提升為writer lock, 也可能不允許提升;
      • 通常reader lock是可重入的, writer lock是不可重入的, 但是為了防止writer lock飢餓, writer lock通常會阻塞後來的reader lock, 所以reader lock在重入的時候可能死鎖;
      • 追求低延遲的讀取場合也不適合讀寫鎖;
      • 如果確實併發讀寫有極高的效能要求, 可以考慮read-copy-update;

封裝Mutexlock、MutexLockGuard、Condition類

  • 這幾個類都不允許拷貝構造和賦值;
  • 用mutexattr來顯示指定mutex的型別;
  • 檢查返回值儘量不用assert函式, 因為assert函式在release build裡是空語句;
  • 需要non-debug的assert時, 或許google-glog的CHECK巨集是一個不錯的思路;
  • muduo庫的特點是隻提供最常用、最基本的功能, 特別有意避擴音供多種功能近似的選擇;
  • muduo庫刪繁就簡, 舉重若輕;
  • trylock函式的一個用途是用來觀察lock contention;
  • 提供靈活性固然是本事, 然而在不需要靈活性的地方把程式碼寫死, 更需要大智慧;
  • 一個多執行緒程式如果大量使用mutex和condition variable來同步, 基本跟用鉛筆刀鋸大樹沒啥區別;

執行緒安全的singleton實現

  • double checked locking(DCL)兼顧了效率與正確性;
    • 但有神牛指出由於亂序執行的影響, DCL是靠不住的;
    • C++ 實現要麼次次鎖, 要麼eager initialize(餓的單例), 或者動用memory barrier這樣的大殺器;
  • 在實踐中, 用pthread_once就行(保證函式只執行一次);
  • 用pthread_once_t來保證lazy-initialization的執行緒安全, 執行緒安全由pthread庫保證;

sleep不是同步原語

  • sleep, usleep, nanosleep只能出現在測試程式碼中, 比如寫單元測試的時候;
  • 或者用於有意延長臨界區, 加速復現死鎖的情況;
  • sleep不具備memory barrier語義, 它不保證記憶體的可見性;
  • 生產程式碼中執行緒的等待可分為兩種:
    • 一種是等待資源可用(要麼等在select/poll/epoll_wait上, 要麼等在條件變數上);
    • 一種是等著進入臨界區(等在mutex上), 以便讀寫共享資料; 這種等待極短, 否則程式效能和伸縮性就會有問題;
  • 在程式的正常執行中, 如果需要等待一段已知的時間, 應該向event loop裡註冊一個timer, 然後在timer的回撥函式裡接著幹活, 因為執行緒是個珍貴的共享資源, 不能輕易浪費(阻塞也是浪費);
  • 如果等待某個事件發生, 那麼應該採用條件變數或IO事件回撥, 不能用sleep來輪詢;
  • 如果多執行緒的安全性和效率要靠程式碼主動呼叫sleep來保證, 這顯然是設計出了問題;
  • 等待某個事件發生, 正確的做法是用select等價物或condition, 抑或(更理想的)高層同步工具;
  • 在使用者態做輪詢(polling)是低效的;

歸納與總結

  • 執行緒同步的四原則, 儘量用高層同步設施(執行緒池, 佇列, 倒計時);
  • 使用普通互斥量和條件變數完成剩餘的同步任務, 採用RAII慣用手法(idiom)和Scoped Locking(作用域加鎖);
  • 用好這幾樣東西, 基本上能應付多執行緒服務端的各種場合;
  • 讓正確的程式變快遠比讓一個快的程式變正確容易得多;
  • 有些高階語言通過framework來遮蔽多執行緒, 讓多執行緒看起來像是寫單執行緒程式(Java Servlet);
  • 掌握多執行緒程式設計, 才能更理智地選擇用還是不用多執行緒, 因為你能預估多執行緒實現的難度與收益;
    • 把一個單執行緒程式改成多執行緒, 往往比從頭實現一個多執行緒更困難;
    • 掌握同步原語和他們的使用場合是多執行緒程式設計的基本功;
  • Unix的signal在多執行緒下的行為比較複雜, 一般要靠底層的網路庫(如Reactor)加以遮蔽, 避免干擾上層應用程式的開發;
  • 不能聽信傳言或是憑感覺優化;
  • 真正影響鎖效能的不是鎖, 而是鎖爭用(lock condition);
  • 在分散式系統中, 多機伸縮性(scale out)比單機效能優化更值得投入精力;

借shared_ptr實現copy-on-write

  • 用shared_ptr來管理共享資料;
    • shared_ptr 是引用計數型智慧指標, 如果當前只有一個觀察者, 那麼引用計數的值為1;
    • 對於write端, 如果發現引用計數為1, 這時可以安全的修改共享物件, 不必擔心有人正在讀它;
    • 對於read端, 在讀之前把引用技術加1, 讀完之後減1, 這樣保證在讀的期間其引用計數大於1, 可以阻止併發寫;
    • 比較難的是write端, 如果發現引用計數大於1, 該如何處理, sleep一小段時間肯定是錯的;
      • 一般不能在原來上面修改, 得建立一個副本, 在副本上修改, 修改完了再替換;
      • 如果沒有使用者在讀, 那麼就直接修改, 節約一次拷貝;