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

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

C++多執行緒系統程式設計精要

  • 學習多執行緒程式設計面臨的最大思維方式的轉變有兩點:
    • 當前執行緒可能被切換出去, 或者說被搶佔(preempt)了;
    • 多執行緒程式中事件的發生順序不再有全域性統一的先後關係;
  • 當執行緒被切換出去回來繼續執行下一條語句(指令)的時候, 全域性資料(包括當前程序在作業系統核心中的狀態)可能已經被其他執行緒修改了;
    • 訪問非法地址, 會產生段錯誤(segfualt);
  • 在沒有適當的同步的情況下, 多個CPU上執行的多個執行緒中的事件發生先後順序是無法確定的;
    • 在引入適當同步後, 事件之間才有了happens-before關係;
    • 必須通過適當的同步來讓當前執行緒能看到其他執行緒的事件的結果(被作業系統切換出去得越多, 執行越慢);
    • 加延遲是不能解決執行緒間同步的問題的;
    • pthread_create()是具有happens-before語義的;

基本執行緒原語的選用

  • POSIX threads的函式有110多個, 真正常用的不過十幾個;
    • 2個: 執行緒的建立和等待結束(join);
    • 4個: mutex的建立、銷燬、加鎖、解鎖;
    • 5個: 條件變數的建立、銷燬、等待、通知、廣播;
  • 利用(thread, mutex, condition)可以完成任何多執行緒程式設計任務;
    • 一般也不會直接使用他們, 而是使用更層的封裝;
    • threadPool和countDownLatch;
  • 酌情使用的原語: pthread_once, 其實不如直接用全域性變數;
    • pthread_key*, 可以考慮用__thread替換之;
  • 不建議使用:
    • pthread_rwlock, 讀寫鎖應慎用;
    • sem_*, 避免使用訊號量(semaphore), 它的功能與條件變數重合, 但容易用錯;
    • pthread_{cancel, kill}, 程式中出現了他們, 則通常意味著出現了設計問題;
  • 多執行緒系統程式設計的難點不在於學習執行緒原語(primitives), 而在於理解多執行緒與現有的C/C++庫函式和系統呼叫的互動關係, 以進一步學習如何設計並實現執行緒安全且高效的程式;

C/C++系統庫的執行緒安全性

  • 新版的C/C++標準(C11和C++11)規定了程式在多執行緒下的語義, C++11還定義了一個執行緒庫(std::thread);
  • 對標準而言, 關鍵不是定義執行緒庫, 而是規定記憶體模型(memory model);
    • 特別是規定一個執行緒對某個共享變數的修改何時能被其他執行緒看到, 這稱為記憶體序(memory ordering)或者記憶體能見度(memory visibility);
  • Linux作業系統核心本身也可以是搶佔的(preemptive);
  • Unix系統庫(libc和系統呼叫)的介面風格在20世紀70年代早期確定的;
    • 現在Linux glibc把errno定義為一個巨集, 注意errno是一個lvalue(左值);
      • 不能簡單定義為某個函式的返回值, 而必須定義為對函式返回指標的dereference;
    • 最早的SGI STL自己定製了記憶體分配器, 而現在g++自帶的STL已經直接用malloc來分配記憶體, std::allocator已經變成了雞肋;
  • 不用擔心繫統呼叫的執行緒安全性, 因為系統呼叫對於使用者態程式來說是原子性的;
    • 但是要注意系統呼叫對於核心狀態的改變可能影響其他執行緒;
    • system、getenv/putenv/setenv等函式都是執行緒不安全的;
    • FILE*系列函式是安全的;
    • 儘管兩個函式都是執行緒安全的, 但是兩個函式放在一起就不是執行緒安全的了(比如fseek和fread);
      • 可以用flockfile(FILE)和funlockfile(FILE)函式來顯示地加鎖;
      • 並且由於FILE*的鎖是可重入的, 加鎖之後再呼叫fread()不會造成死鎖;
  • 編寫執行緒安全程式的一個難點在於執行緒安全是不可組合的(composable);
  • 執行緒安全遵循一個基本原則:
    • 凡是非共享的物件都是彼此獨立的,如果一個物件從始至終只被一個執行緒用到, 那麼就是執行緒安全的;
    • 共享物件的read-only操作是安全的, 前提是不能有併發的寫操作;
  • C++標準庫中的絕大多數泛型演算法是執行緒安全的, 因為這些都是無狀態純函式;
    • 只要輸入區間是執行緒安全的, 那麼泛型函式就是執行緒安全的;
  • C++的iostream不是執行緒安全的, 因為流式輸出等價於兩個函式呼叫, 即便ostream::operator<<()做到了執行緒安全, 也不能保證其他執行緒不會在兩次函式呼叫之前向stdout輸出其他字元;
  • 在多執行緒程式中高效的日誌需要特殊設計;

Linux上的執行緒標識

  • POSIX threads庫提供了pthread_self函式用於返回當前執行緒的識別符號, 其型別為pthread_t;
    • pthread_t不一定是一個數值型別(整數或指標), 也有可能是一個結構體;
    • Pthreads專門提供了pthread_equal函式用於對比兩個執行緒識別符號是否相等;
    • pthread_t值只在程序內有意義, 與作業系統的任務排程之間無法建立有效關聯, 比如說在/proc檔案系統中找不到pthread_t對應的task;
    • glibc的Pthreads實現實際把pthread_t用作一個結構體指標(它的型別是unsigned long), 指向一塊動態分配的記憶體, 而這塊記憶體可以反覆使用;
      • 這就造成了pthread_t的值很容易重複;
      • Pthreads只保證統一程序之內, 同一時刻各個執行緒的id不同;
  • 在Linux系統上用gittid系統呼叫的返回值來作為執行緒id, 好處有:
    • 它的型別是pid_t, 其值通常是一個小整數, 便於在日誌中輸出;
    • 在現代Linux系統中, 它直接表示核心的任務排程id, 因此在/proc檔案系統中可以輕易找到對應項: /proc/tid或/proc/task/tid;
    • 在其他系統工具中也容易定位到具體某一個執行緒, top可以找出CPU使用率最高的執行緒id, 再根據日誌判斷到低那個執行緒在耗用CPU;
    • 任何時刻都是全域性唯一的, 並且由於Linux分配新pid採用遞增輪迴法, 短時間內啟動的多個執行緒也會具有不同的執行緒id;
    • 0是非法值, 因為作業系統第一個程序init的pid是1;
  • 每次都呼叫一次gettid很浪費, 可以採用__thread變數來快取gettid的返回值, 這樣只有在本執行緒第一次呼叫的時候才才進行系統呼叫, 以後直接從thread_local快取的執行緒id拿到結果, 效率無憂;
  • 用pthread_atfork()註冊一個回撥, 用於清空快取的執行緒id;

執行緒的建立與銷燬的守則

  • 執行緒的建立比銷燬要容易得多:
    • 程式不應該在未提前告知的情況下建立自己的"背景執行緒";
    • 儘量用相同的方式建立執行緒, 例如:muduo::thread;
    • 在進入main()函式之前不應該啟動執行緒;
    • 程式中執行緒的建立最好能在初始化階段全部完成;
  • 執行緒是稀缺資源:
    • 一個程序可以建立的併發執行緒數目受限於地址空間的大小和核心引數;
    • 一臺機器可以同時並行執行的執行緒數目受限與CPU的數目;
  • 一旦程式中有不止一個執行緒, 就很難安全地fork()了;
    • 因此庫不能偷偷地建立執行緒;
    • 如果確實要使用背景執行緒, 至少應該讓使用者知道;
    • 如果有可能, 可以讓使用者在初始化庫的時候傳入執行緒池或event loop物件, 這樣的程式就可以統籌執行緒數目和用途, 避免低優先順序的任務獨佔某個執行緒;
  • 如果庫提供非同步回撥, 一定要明確說明會在那個(那些)執行緒呼叫使用者提供的回撥函式, 這樣的使用者就可以知道在回撥函式中能不能執行耗時的操作, 會不會阻塞其他任務的執行;
  • 在main函式之前不應該啟動執行緒, 因為這會影響全域性物件的安全構造;
  • 各個編譯單元之間的物件構造順序是不確定的, 只能通過一些辦法影響初始化順序;
  • 全域性物件不能建立執行緒;
    • 如果一個庫需要建立執行緒, 那麼應該進入main()函式之後再呼叫庫的初始化函式去做;
  • 不要為每個計算任務, 每次請求去建立執行緒, 一般也不會為每個網路連線建立執行緒, 那麼應該進入main()函式之後再呼叫庫的初始化函式去做;
  • 一個伺服器程式的執行緒數目應該與當前負載無關, 而應該與機器的CPU數相近, 即load average有比較小(最好不大於CPU數目)的上限;
  • 如果有實時性方面的要求, 執行緒數目不應該超過CPU數目, 這樣可以基本保證新任務總能及時得到執行, 因為總有CPU是空閒的;
  • 最好在函式程式的初始化階段建立全部工作執行緒, 在程式執行期間不再建立或銷燬執行緒;
  • 執行緒的銷燬有幾種方式:
    • 自然死亡 -- 從執行緒主函式返回, 執行緒正常退出;
    • 非正常死亡 -- 從執行緒主函式丟擲異常或執行緒觸發segfualt訊號等非法操作;
    • 自殺 -- 線上程中呼叫pthread_exit()來立刻退出執行緒;
    • 他殺 -- 其他執行緒呼叫pthread_cancel()來強制終止某個執行緒;
    • pthread_kill()是往執行緒發訊號;
    • 強行終止執行緒的話(無論是自殺還是他殺), 它沒有機會清理資源;
    • 殺死一個程序比殺死本程序內的執行緒要安全得多;
  • fork()的新程序與本程序的通訊方式也要慎重選擇, 最好用檔案描述符(pipe/socketpair/TCP socket)來收發資料, 而不要用共享記憶體和跨程序的互斥器等IPC, 因為這樣有死鎖的可能;
  • 如果thread物件的生命期短於執行緒, 那麼析構時會自動detach執行緒, 避免了資源洩漏;
  • 如果做到了****程式中執行緒的建立最好能在初始化階段全部完成****, 則執行緒是不必銷燬的, 伴隨程序一直執行, 徹底避開了執行緒安全退出可能面臨的各種困難, 包括thread物件生命期管理, 資源釋放等等;

pthread_cancel與C++

  • POSIX threads有cancellation point這個概念, 意思是執行緒執行到這裡有可能會被終止(cancel)(如果別的執行緒對它呼叫了pthread_cancel()的話);
  • 在C++中, cancellation point的實現與C語言有所不同, 執行緒不是執行到此函式為止, 而是該函式會丟擲異常;
    • 這樣可以有機會執行stack unwind, 析構棧上物件(特別是釋放持有的鎖);

exit在C++中不是執行緒安全的

  • exit函式在C++中的作用除了終止程序, 還會析構全域性物件和已經構造完的函式靜態物件;
    • 這有潛在的死鎖的可能;
  • 用全域性物件實現無狀態策略在多執行緒中析構可能是很危險的;
  • C++標準沒有照顧全域性物件在多執行緒環境下的析構, 也沒有更好的辦法;
  • _exit()系統呼叫不會析構全域性物件, 但是也不會執行其他所謂的清理工作, 比如flush標準輸出;
  • 安全地退出一個多執行緒序並不是一件容易的事情;
    • 這需要精心設計共享物件的析構順序,防止各個執行緒在退出時訪問已失效的物件;
  • 在編寫長期執行的多執行緒服務程式的時候, 可以不必追求安全地退出, 而是讓程序進入拒絕服務狀態, 然後就可以直接殺死掉;

善用__thread關鍵字

  • __thread是GCC內建的執行緒區域性儲存設施(thread local storage);
    • 它的實現非常高效, 比pthread_t快得多;
    • __thread變數的存取效率可與全域性變數相比;
  • __thread使用規則:
    • 只能用於修飾POD型別(plain old data), 不能修飾class型別, 因為無法自動呼叫建構函式和解構函式;
    • __thread可以用於修飾全域性變數、函式內的靜態變數, 但是不能修飾函式的區域性變數或者是class的普通成員變數;
    • __thread變數的初始化只能用編譯期常量;
    • __thread變數是每個執行緒有一份獨立實體, 各個執行緒的變數值互不干擾;
    • __thread變數還可以修飾值可能會改變, 帶有全域性性, 但是又不值得用全域性鎖保護的變數;

多執行緒與IO

  • 同步IO: 阻塞和非阻塞;
  • 操作檔案描述符的系統呼叫本身是執行緒安全的, 我們也不用擔心多個執行緒同時操作檔案描述符會造成程序崩潰或核心崩潰;
  • socket讀寫的特點是不保證完整性, 讀100位元組有可能只返回20位元組, 寫操作也是一樣的;
  • 多執行緒read或write同一個檔案也不會提速, 多執行緒分別read或write同一個磁碟上的多個檔案也不見得能提速;
    • 因為磁碟都有一個操作佇列, 多個執行緒的讀寫請求到了核心是排隊執行的;
  • 只有核心快取了大部分資料的情況下, 多執行緒讀這些熱資料才可能比單執行緒快;
  • 一個執行緒可以操作多個檔案描述符, 但一個執行緒不能操作別的執行緒擁有的檔案描述符;
    • epoll也遵循相同的原則;
  • 當一個執行緒正阻塞在epoll_wait()上時, 另一個執行緒修改此epoll fd的事件關注列表(watch list)會發生什麼;
    • 為了穩妥起見, 我們應該把對同一個epoll fd的操作(新增, 刪除, 修改, 等待)都放到同一個執行緒中執行;
  • 一般程式不會直接使用epoll, read, write, 這些底層操作都由網路庫代勞了;
  • 對於磁碟檔案, 在必要的時候多個執行緒可以同時呼叫pread/pwrite來讀寫同一個檔案;
  • 對於UDP, 由於協議本身保證訊息的原子性, 在適當的條件下(比如訊息之間彼此獨立)可以多個執行緒同時讀寫同一個UDP檔案描述符;

用RAII包裝檔案描述符

  • Linux的檔案描述符(file descriptor)是小整數, 在程式剛剛啟動的時候, 0是標準輸入, 1是標準輸出, 2是標準錯誤;
  • POSIX標準要求每次新開啟檔案(含socket)的時候必須使用當前最小可用檔案描述符號碼;
  • POSIX這種分配檔案描述符的方式稍不注意就會造成串話;
  • 用socket物件包裝檔案描述符, 所有對此檔案描述符的讀寫操作都通過此物件進行, 在UI想的解構函式裡關閉檔案描述符;
  • 現代C++的一個特點是物件生命期管理的進步, 體現在不需要手工delete物件;
  • muduo庫使用shared_ptr來管理tcpConnection的生命期, 這是唯一一個採用引用計數方式管理生命期的物件;

RAII與fork()

  • 我們用物件來包裝資源, 把資源管理與物件生命期管理統一起來(RAII);
    • 但是如果程式會fork(), 這一假設就會被破壞了;
  • fork後, 子程序會繼承父程序的幾乎全部狀態, 但也有少數例外;
    • 子程序會繼承虛擬地址空間和檔案描述符;
    • 子程序不會繼承:
      • 父程序的記憶體鎖, mlock, mlockall;
      • 父程序的檔案鎖, fcntl;
      • 父程序的某些定時器, settime, alarm, timer_create等;
  • 因此在編寫伺服器程式的時候, 是否允許fork()是在一開始就應該慎重考慮的問題;

多執行緒與fork()

  • 多執行緒和fork()的協作性很差;
  • fork()一般不能在多執行緒中呼叫, 因為Linux的fork()只克隆當前執行緒的thread of control, 不克隆其他執行緒;
  • fork()之後, 除了當前執行緒之外, 其他執行緒都消失了;
  • 也就是說不能一下子fork出一個和父程序一樣的多執行緒子程序;
  • fork()之後, 子系統不能呼叫:
    • malloc, 因為malloc()在訪問全域性變數狀態時幾乎肯定會加鎖;
    • 任何pthreads函式, 不能用pthread_cond_signal()去通知父程序, 只能通過讀寫pipe來同步;
    • printf()系列函式, 因為其他執行緒可能恰好持有stdout/stderr的鎖;
    • 除了man 7 signal中明確列出的signal安全函式之外的任何函式;
  • 照此一來, 唯一安全的做法是在fork()之後立即呼叫exec()執行另一個程式, 徹底隔斷子程序與父程序的聯絡;

多執行緒與signal

  • Linux/Unix的訊號(signal)與多執行緒可謂是水火不容;
  • 多執行緒時代, signal的語義更為複雜, 訊號分為兩類: 傳送給某一執行緒(SIGSEGV), 傳送給程序中的任一執行緒(SIGTERM), 還要考慮掩碼(mask)對訊號的遮蔽等;
  • 特別是在signal handler中不能呼叫任何pthreads函式, 不能通過condition variable來通知其他執行緒;
  • 在多執行緒中, 使用signal的第一原則不要使用signal;
    • 不要用signal作為IPC的手段, 包括不要用SIGUSR1等訊號來觸發服務端的行為;
    • 也不要使用基於signal實現的定時器函式, 包括alarm/ualarm/settime/time_create, sleep/usleep等;
    • 不主動處理各種異常(SIGTERM, SIGINT), SIGPIPE伺服器程式通常的做法是忽略這個訊號;
    • 在沒有辦法的情況下, 把非同步訊號轉換為同步的檔案描述符事件;
      • 現代Linux採用signalfd把訊號直接轉換為檔案描述符事件, 從而根本不用使用signal handler;

Linux新增系統呼叫的啟示

  • signalfd, timerfd, eventfd;