Linux多執行緒伺服器端程式設計

self發表於2019-06-04

Linux多執行緒伺服器端程式設計

執行緒安全的物件生命期管理

  • 利用shared_ptr和weak_ptr避免物件析構時存在的競爭條件(race conditon).
  • 當一個物件被多個執行緒同時看到,那麼物件的銷燬時機就會變得模糊不清,可能出現多種競爭條件(race condition).
  • 用RAII(Resource Acquire Is Initialization, 資源申請即初始化)封裝互斥量的建立和銷燬, MutexLock封裝臨界區(critical section), 資源管理類。
  • MutexLockGuard封裝臨界區的進入和退出,即加鎖和解鎖,MutexLockGuard一般是個棧上物件,它的作用域剛好等於臨界區域。
  • 不可拷貝類.
    • 把copy建構函式和複製操作符宣告為私有函式並不宣告。
    • 在C++11中使用delete關鍵字,muduo採用了這種方式。
      namespace muduo
      {
    
      class noncopyable
      {
          public:
          noncopyable(const noncopyable&) = delete;
          void operator=(const noncopyable&) = delete;
    
          protected:
          noncopyable() = default;
          ~noncopyable() = default;
      };
    
      }  // namespace muduo
  • Linux的capability機制.
  • 物件構造要做到執行緒安全,唯一的要求是在構兆期間不要洩漏this指標。
    • 不要在建構函式中註冊任何回撥;(利用二段式構造(建構函式+initialization()),或直接呼叫register_函式)
    • 不要把在建構函式中把this傳給跨執行緒的物件;
    • 即便在建構函式的最後一行也不行。(建構函式執行期間物件還沒有完成初始化。)

物件的銷燬執行緒比較難

  • 單執行緒物件析構要注意避免空懸指標和野指標。多線執行緒每個成員函式的臨界區域不得重疊,而且成員函式用來保護臨界區的互斥器本身必須是有效的。
  • 在解構函式中直接呼叫互斥器進行多執行緒的同步是不可取的,沒有完全達到執行緒安全的效果。
  • 作為資料成員的mutex不能保護析構, 因為成員的生命週期最多與物件一樣長,而析構動作可以發生在物件死亡之後。(呼叫基類解構函式時,派生類的解構函式已經被呼叫)
  • 解構函式本身不需要保護,因為只有別的執行緒都訪問不到這個物件時,析構才是安全的。
  • 如果要鎖住相同型別的多個物件,為了保證始終按相同的順序加鎖,可以比較mutex物件的地址,始終先加鎖地址較小的mutex.(防止死鎖)
  • 判斷一個指標是不是合法指標沒有高效的辦法,這是C/C++指標問題的根源。
  • 呼叫正在析構物件的任何非靜態成員函式都是不安全的,更何況是虛擬函式。
  • 指向物件的原始指標(raw pointer)最好不要暴露給別的執行緒。--- 一般用智慧指標
  • 解決空懸指標的辦法是,引入一層間接性。(handle/body慣用技法)
  • shared_ptr指標原始碼分析.
    • shared_ptr控制物件的生命期,是強引用,只要有一個指向x物件的shared_ptr存在,該x物件就不會析構,當指向物件x的最後一個shared_ptr析構或reset()呼叫時,x保證會被銷燬。
    • weak_ptr不控制物件的生命期,但它知道物件是否還或者;
      • 如果物件還活著,weak_ptr可以提升為有效的shared_ptr;
      • 如果物件已經死了,提升失敗,返回一個空的shared_ptr;
      • 提升函式lock()行為是執行緒安全的。
    • shared_ptr/weak_ptr的計數在主流平臺上是原子操作,沒有用鎖,效能不俗。
  • 資源(包括複雜物件本省)都是通過物件(只能指標或容器)來管理,不要直接呼叫delete來刪除資源。
  • shared_ptr本身的引用計數本身是執行緒安全的,但是讀寫操作不是原子化的。
  • shared_ptr技術與陷阱:
    • 如果不小心多進行了拷貝或賦值就會意外延長物件的生命週期。
    • 析構動作在建立時被捕獲;
      • shared_ptr可以持有任何物件,而且能安全地釋放。(析構動作可以定製)
    • 為了不影響關鍵執行緒的速度,可以用一個單獨的執行緒來做shared_ptr物件的析構。
    • 要避免shared_ptr管理共享資源時引起的迴圈引用,通常做法是owner持有指向child的shared_ptr,child持有指向owner的weak_ptr.
    • shared_ptr的解構函式可以有一個額外的模板類引數,傳入一個函式指標或反函式d,在析構物件時執行d(ptr),其中ptr是shared_ptr儲存的物件指標。
    • 弱回撥技術會在事件通知中會非常有用。

執行緒同步精要

  • 執行緒同步的四原則:
    • 首要原則是儘量最低限度地共享物件,減少需要同步的場合。
    • 其次使用高階的併發程式設計構建(TaskQueue, Producer-Consumer Queue, CountDownLatch(倒數計時)).
    • 最後不得已必須使用底層同步原語(promitives)時,只用非遞迴的互斥器和條件變數,慎用讀寫鎖,不要用訊號量。
      • 使用非遞迴(non-recursive)互斥量可以把程式錯誤儘早地暴露出來。
    • 除了使用atomic整數之外,不自己編寫lock-free程式碼,也不要用核心級的同步原語。
  • 如果堅持Scoped Locking,那麼出現死鎖的時候就很容易定位。
    • gdb ./self_deadlock core --- 除錯定位死鎖。
    • __attribute__可以用來防止函式inline內聯展開。
  • 條件變數(condition variable): 一個或多個執行緒等待某個布林表示式為真,即等待別的執行緒喚醒它。
    • 條件變數的學名叫作管程(monitor);
    • 必須和mutex一起使用, 該布林表示式的讀寫受此mutex保護。
    • 在mutex已上鎖的時候才能呼叫wait().
    • 把判斷布林條件和wait()放到while迴圈中。
    • 虛假喚醒(spurious wakeup):
  • 倒數計時(CountDownLatch)是一種常用且易用的同步手段,主要用途:
    • 主執行緒等待多個子執行緒完成初始化;
    • 多個子執行緒等待主執行緒發起起跑命令。
    • 使用CountDownLatch使程式邏輯更清楚。
  • pthread_onece保證函式只執行一次。
  • sleep並不是同步原語。
    • 如果需要等待一段已知的時間,應該往event loop裡註冊一個timer,然後在timer的回撥函式裡接著幹活,因為執行緒是個珍貴的資源,不能輕易浪費(阻塞也是浪費)。

借shared_ptr實現寫時拷貝(copy-on-write)

  • 寫時如果引用計數大於1,該如何處理?
  • 用普通的mutex替換讀寫鎖。
  • 大多數情況下更新都是在原來資料上進行的,拷貝的比例還不到1%.

多執行緒伺服器的適用場合與常用程式設計模型

  • 程式(process)是作業系統裡最重要的兩個概念之一(另一個是檔案),一個程式就是記憶體中正在執行的程式。
  • 每個程式有自己獨立的地址空間(adress space), 在同一個程式還是不在同一個程式是系統功能劃分的重要決策。
  • 把一個程式比喻成一個人,週期性的心跳判斷對方是否還活著。
    • 容錯 --- 萬一有人突然死了。
    • 擴容 --- 新人中途加進來。
    • 負載均衡 --- 把甲的活挪給乙做。
    • 退休 --- 甲要修復Bug, 先別派新任務,等他做完手上的活就把他重啟。
  • 執行緒的特點是共享地址空間,從而可以高效地共享資料。
    • 多程式可以高效地共享程式碼段,但是不能共享資料。
    • 多執行緒可以高效地發揮多核的效能。(單核,按照狀態機程式設計思想比較高效)

單執行緒伺服器的常用程式設計模型

  • “nonblocking IO + IO multiplexing(非阻塞IO + IO 多路複用)”, 即Reactor模式(反應堆模式)。
    • lighttpd, 單執行緒伺服器(Nginx與之類似,每個工作程式有一個事件迴圈(event loop)).
    • libevent, libev.
    • ACE, Poco C++ Libaries.
    • Java NIO, 包括Aache Mina 和 Netty.
    • POE(Perl).
    • Twisted(Python).
  • “nonblocking IO + IO multiplexing(非阻塞IO + IO 多路複用)”, 即Reactor模式(反應堆模式)的基本結構是一個事件迴圈:
    • 以事件驅動和事件回撥的方式實現業務邏輯。
    • Reactor模式(Linux採用epoll機制)對於IO密集的應用是一個不錯的選擇。
    • 巧妙地使用fdevent結構。
    • 基於事件驅動的程式設計模式的本質缺點是: 要求事件回撥函式必須是非阻塞的,容易割裂業務邏輯,將其散佈於各個回撥函式中。

多執行緒伺服器的常用程式設計模型

  • 每個請求建立一個執行緒,使用阻塞式IO操作(可伸展性不佳)。
  • 使用執行緒池。
    • 阻塞的任務佇列(blocking queue TaskQueue)。
    • Intel Threading Building Blocks的concurrent_queue效能比較好。
    • 執行緒池(thread pool)用來做計算, 可以用任務佇列或者是生產者消費者佇列實現。
  • 使用nonblocking IO + IO multiplexing(非阻塞IO + IO 多路複用)。
    • 每個IO執行緒有一個event loop(或者叫Reactor)用於處理讀寫和定時事件。
    • 對實時性要求高的連線(connection)可以單獨使用一個執行緒。
    • 資料量大的連線可以獨佔一個執行緒,並把資料處理任務分攤到另幾個計算執行緒中(用執行緒池)。
    • 其他輔助性的連線可以共享一個執行緒。
  • Leader/Follower等高階模式。

  • 程式間通訊:
    • 匿名管道(pipe);
      • 用來非同步喚醒select(或等價的poll或epoll_wait)呼叫。
    • 具名管道(FIFO);
    • POSIX訊息佇列;
    • 共享記憶體;
      • 共享記憶體是訊息協議,a進行填好一塊記憶體讓b程式來讀,基本上是停等方式(stop wait).
    • 訊號(signals);
    • 套接字(socket),一般用TCP, 不考慮domain協議和UDP(可以跨主機,具有伸縮性)。
      • TCP是雙向的,管道pipe是單向的(程式間雙向通訊需要開啟兩個檔案描述符,父子程式才能用pipe).
      • 套接字由一個程式獨佔,且作業系統會自動回收(關閉檔案描述符時)。
      • 套接字是埠是獨佔的,可以防止程式重複啟動。
      • 可以用tcpcopy工具進行壓力測試。
      • TCP是位元組流協議,只能順序讀取,有寫緩衝區;
      • RPC/HTTP/SOAP上層通訊協議都是用的TCP網路層協議。
  • 同步原語(synchronization primitives):
    • 互斥器(mutex);
    • 條件變數(condition variable);
    • 讀寫鎖(reader-writer lock);
    • 檔案鎖(recode locking);
    • 訊號量(semaphore)

分散式系統中使用TCP長連線通訊

  • 分散式系統是由執行在多臺機器上的多個程式組成的,程式間採用TCP長連線通訊(建立連線後不會立即關閉)。
  • 在實現每一類伺服器程式時,在必要時可以通過多執行緒提高效能。
  • 對整個分散式系統,要做到能scale out, 即享受增加機器帶來的好處。
  • TCP長連線的好處:
    • 容易定位分散式系統中服務之間的依賴關係。 --- netstat -tpna | grep :port
      • 客戶端用netstat或lsof找到那個程式發起的連線。
    • 通過接收和傳送佇列的長度也較容易定位網路或程式故障。 --- netstat -tn觀察Recv-Q和Send-Q的變化情況。
  • Event Loop(事件迴圈): 事件迴圈的最明顯的缺點是非搶佔的(non-preemptive), 可能會發生優先順序發轉(通過多執行緒克服)。
  • 多執行緒的使用適用場合:
    • 提高響應速度,讓IO和計算相互重疊,降低latency.
  • 多執行緒不能提高併發度(併發連線數)。
  • 多執行緒也不能提高吞吐量,但多執行緒能夠降低響應時間。
  • 執行緒池的經驗公式 T=C/P(一個有C個CPU, 密集計算所佔的時間比重為P( 0 < P <= 1)).
  • 如果一次請求響應中要和別的程式打多次交道,那麼Proactor模型往往能做到更高的併發度。
  • Proactor模式依賴作業系統或庫來高效地排程這些子任務,每個子任務都不會阻塞,因此能用比較少的執行緒達到很高的IO併發度。
  • Proactor能提高吞吐量,但不能降低延遲。

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

  • 多執行緒程式設計面臨的最大思維方式的轉變有兩點:
    • 當前執行緒可能隨時會被切換出去,或者說被搶佔(preempt)了。
    • 多執行緒程式中,事件的發生順序不再有全域性同喜的先後關係。
  • 多執行緒程式的正確性不能依賴於任何一個執行緒的執行速度,不能通過原地等待(sleep())來假定其他執行緒的時間已經發生,而必須通過適當的同步來讓當前執行緒能看到其他執行緒的時間的結果
  • 執行緒(thread),互斥量(mutex),條件變數(condition)這三個執行緒原語可以完成任何多執行緒任務。
  • 記憶體序(memory ordering),記憶體模型(memory model),記憶體能見度(memory visibility)。
    • Linux系統本身是可以被搶佔的(preemptive).
    • errno是一個全域性變數。
    • 不用擔心繫統呼叫的執行緒安全性,因為系統呼叫對使用者態程式來說是原子的。
      • 但系統呼叫對於核心態的改變可能影響其他執行緒
  • C++中的泛型函式一般都是執行緒安全的。C++ iostream不是執行緒安全的。
  • pthreads只能保證同一程式內,同一時刻的各個執行緒的id不同;不能保證同一程式先後過個執行緒具有不同的id.
  • 如果程式中不止一個執行緒,就很難安全地fork()了。
  • 在main()函式之前不應該啟動執行緒,因為這會影響全域性物件的安全構造。
    • 全域性物件不能建立執行緒。
  • kill一個程式比殺死本地程式內的執行緒要安全得多。
  • 不要用共享記憶體和跨程式的互斥器等IPC, 因為這樣仍然有死鎖的可能。
    • Thread的析構不會等待執行緒結束。
    • 如果Thread物件的生命期短於執行緒,那麼析構時會自動detach執行緒(僵死執行緒的感覺),避免資源洩漏。
    • 程式中的執行緒建立最好能在初始化階段全部完成,則程式是不必銷燬的。
    • 最好不要通過外部殺死執行緒。
  • exit()可能導致析構物件時造成死鎖。
  • 善用__thread關鍵字,但只能用於內建型別。
    • __thread變數是每個執行緒有一份獨立實體,各個執行緒的變數值都互不干擾。
  • 多執行緒磁碟IO的一個思路是每個磁碟配一個執行緒,把所有針對此磁碟的IO挪到同一個執行緒,可以避免或者減少核心中的鎖競爭。
    • 每個檔案描述符只由一個執行緒操作。
  • Linux/Unix中, 訊號(signal)與多執行緒可謂是水火不相容,訊號打斷了正在執行的執行緒控制權。
  • fork()之後, 子程式幾乎繼承父程式的所有狀態,但子程式不會繼承:
    • 父程式的記憶體鎖: mlock,mlockall.
    • 父程式的檔案鎖: fcntl.
    • 父程式的某些定時器,setitimer,alarm,timer_create等(man 2 fork).
  • 多執行緒和fork協作很差,fork一般不能在多執行緒程式中呼叫,因為Linux中的fork只會克隆當前執行緒的執行緒控制權,不克隆其他執行緒。
    • fork之後,除了當前執行緒之外,其他執行緒都消失了。
    • 呼叫fork後,立即呼叫exec()執行另一個程式,徹底隔斷子程式與父程式的聯絡。

高效的多執行緒日誌

  • 日誌可以分為兩類:
    • 診斷日誌(diagnostic log), 用於故障診斷和追蹤(trace), 也可用於效能分析。
      • 每條日誌都要有對應的時間戳。
      • 生產者-消費者模型: 對生產者(前端)而言,要儘量做到低延遲、低CPU開銷、無阻塞;對消費者(後端)而言,要做到足夠大的吞吐量,並佔用較少的資源。
      • 整個程式最好使用相同的日誌庫(庫程式和主程式)。 --- 日誌庫最好是一個單例(singleton).
    • 交易日誌(transaction log), 用於記錄狀態變更,通過回放日誌可以逐步恢復每一次修改的狀態。

日誌功能的需求

  1. 日誌訊息有多種級別(level): TRACE, DEBUG, INFO, WARN, ERROR,FATAL等。
  2. 日誌訊息的格式可配置(layout)。
  3. 日誌訊息可能有多個目的地(appender),如檔案、socket,SMTP等。
  4. 可以設定執行時過濾器(filter),控制不同元件的日誌訊息的級別和目的地。
  5. 日誌的輸出級別需要在執行時進行動態調整(不需要重新編譯,也不要重新啟動程式)。
  • muduo庫只要呼叫muduo::Logger::setLogLevel()就能即時生效。
  1. 分散式系統中,日誌的目的地(destination)只有一個: 本地檔案。
    • 因為診斷日誌的功能之一就是診斷網路故障:
      • 連結斷開(網路卡或交換機故障);
      • 網路暫時不通(若干秒之內沒有心跳訊息);
      • 網路擁塞(訊息延遲明顯加大)等。
    • 也應該避免往網路檔案系統(NFS)上寫日誌。
  • 日誌回滾(rolling):
    • 回滾(rolling)通常具有兩個條件:
      • 檔案大小(如寫滿1GB就換下一個檔案);
      • 時間(如每天零點新建一個日誌檔案,不論前一個檔案有沒有寫滿)。
  • 日誌檔案壓縮和歸檔(archive)不是日誌庫應有的功能,應該交給專門的指令碼去做。
  • 定期(預設3秒)將緩衝區內的日誌訊息flush到硬碟;
  • 每條記憶體中的日誌訊息都帶有cookie(或者叫哨兵值/sentry),其值為某個函式地址,通過core dump查詢cookie就能找到尚未來得及寫入磁碟的訊息。

  • muduo庫的優化措施:
    • 時間戳字串中的日期和時間兩部分是快取的,一秒內的多條日誌只需要重新格式化微妙部分。
    • 日誌訊息的前4個欄位是定長的,避免在執行期求字串長度。
    • 執行緒id是預格式化為字串,在輸出日誌訊息時只需簡單拷貝幾個位元組。
    • 檔名basename採用編譯期計算。

多執行緒非同步日誌

  • 多執行緒寫多個檔案也不一定會提速,所以儘量一個程式的多執行緒寫一個檔案。
    • 用一個背景執行緒負責收集日誌訊息,並寫入日誌檔案,其他執行緒只管往這個日誌執行緒傳送日誌訊息,這稱為"非同步訊息"("非阻塞日誌")。
  • 在正常的實時業務處理流程中應該測底避免磁碟IO。
  • muduo日誌庫採用雙緩衝技術(double buffering):
    1. 準備兩塊buffer: A和B, 前端負責往A填資料(日誌訊息),後端負責將buffer B的資料寫入檔案;
    2. 當buffer A寫滿之後,交換A和B,讓後端將buffer A的資料寫入檔案,而前端則往buffer B填入新的日誌訊息,如此往復。
    • 這麼做的好處是:
      • 新建日誌訊息的時候不必等待磁碟檔案操作,也避免每條新日誌訊息都觸發(喚醒)後端日誌執行緒。
      • 即便buffer A未填滿,日誌庫也會每3秒執行一次交換寫入操作。
    • 壞處是: 前端訊息速度(前端buffer寫速度)要和buffer大小做好平衡,否則會出現後端寫入磁碟還沒有寫完,前端的buffer就已經填滿。
  • Java的ConcurrentHashmap那樣用多個筒子(bucket),前端寫日誌的時候再按執行緒id雜湊到不同的bucket, 以減少競爭。
  • Linux預設會把core dump寫到當前目錄,而且檔名是固定的core。(sysctl可以進行設定core dump的一些引數)

muduo網路庫簡介

  • 高階語言(Java, Python等)Socket庫並沒有對Sockets API提供更高層的封裝,直接呼叫很容易掉入到陷阱中;網路庫的價值在於能方便地處理併發連線。
  • muduo使用了較新的系統呼叫(主要是timefd和eventfd),要求linux核心的版本大於2.6.28.
  • muduo是基於Reactor模式的網路庫,其核心是個時間迴圈EventLoop, 用於響應計時器和IO事件。
  • muduo採用基於物件而非物件導向的設計風格,其事件回撥介面多以boost::function+boost::bind表達。
  • muduo主要掌握關鍵的5個類: Buffer, EventLoop, TcpConnection, TcpClient, TcpSever.
    網路核心標頭檔案.
    muduo的簡化類圖.
  • 一個檔案描述符(file descriptor)只能由一個執行緒讀寫。
  • muduo支援非併發阻塞TCP網路程式設計,它的核心是每個IO執行緒一個事件迴圈,把IO事件分發到回撥函式上。減少網路程式設計中的偶發複雜性(accidental complexity).
  • muduo擅長的領域是TCP長連線(建立連線後一直收發、處理資料)。

TCP網路程式設計最本質的是處理三個半事件:

  1. 連線的建立: 服務端成功接受(accept)新連線和客戶端成功發起(connect)連線。
  2. 連線的斷開: 包括主動斷開(close、shutdown)和被動斷開(read(2) 返回0)。
  3. 訊息到達,檔案描述符可讀: 對它的處理決定了網路程式設計的風格(阻塞還是非阻塞,如何處理分包,應用層的緩衝如何設計等)。
  4. 訊息傳送完畢,這算半個: 傳送完畢是指資料寫入作業系統的緩衝區,將由TCP協議棧負責資料的傳送與重傳,不代表對方已經收到了資料。

在一個埠上提供服務,並且要發揮多核處理器的計算能力

  • 高效能httpd(httpd是一個開源軟體,且一般用作web伺服器來使用)普遍採用的是單執行緒Reactor方式。
    12種併發模型
  • 推薦的C++多執行緒服務端程式設計模式: one loop per thread + threadpool.
    • event loop 用作non-blocking IO和定時器。
    • threadpool用來做計算,具體可以是任務佇列或生產者消費者佇列。
  • 實用的5種方案,muduo支援後4種:
    實用的5種網路程式設計方案

muduo程式設計示例

  • daytime是短連線協議,在傳送完當前時間後,由服務端主動斷開連線。
  • 非阻塞網路程式設計必須在使用者態使用接收緩衝區。
  • TcpConnection物件表示一次TCP連線,連線斷開後不能重建,重試後連線的會是另一個TcpConnection物件。
  • Chargen協議很特殊,它只傳送資料,不接收資料,而且,它傳送資料的速度不能快過客戶端接收的速度。
  • 在非阻塞網路程式設計種,傳送訊息通常是由網路庫完成,使用者程式碼不會直接呼叫write或send等系統呼叫。
  • TCP是一個全雙工協議,同一個檔案描述符即可讀又可寫,shutdownWrite()關閉了"寫"方向的連線,保留了"讀方向"的連線,這稱為TCP半關閉(half-close).
    • 如果直接close(socket_fd), 那麼sock_fd就不能讀或寫了。
      muduo關閉連線.
  • 在TCP這種位元組流協議上做應用層分包是網路程式設計的基本需求。
    • 分包指的是在發生一個訊息(message)或一幀(frame)資料時,通過一定的處理,讓接收方能從位元組流種識別並擷取(還原)一個個訊息。
    • 對於短連線,只要傳送方主動關閉連線,分包不是一個問題。
    • 對於長連線,分包有四種方法:
      1. 訊息長度固定。
      2. 使用特殊字元或者字串作為訊息的邊界(如'\r\n')。
      3. 在每條訊息的頭部加一個長度欄位(最常用的做法)。
      4. 利用訊息本身的格式來分包(XML, JSON, 但歇息這種訊息格式通常會用到狀態機(state machine))。
  • non-blocking(非阻塞)IO的核心思想是避免阻塞在read()或write()或其他IO呼叫上,這樣可以最大限度地複用thread-of-control, 讓一個執行緒能服務於多個socket連線。
    • IO執行緒只阻塞在IO multiplexing(複用)函式上(如select/poll/epoll_wait等)。
  • muduo庫採用的是水平觸發(level trigger), 而不是邊沿觸發(edge trigger)。
    1. 為了與傳統的poll相容,在檔案描述符較少,活動檔案描述符比例較高時,epoll不見得比poll更高效。
    • 必要時可以在程式中切換Poller.
    1. 水平觸發(level trigger)程式設計更加容易,不可能發生漏掉事件的bug.
    2. 讀寫的時候不必等候出現EAGAIN, 可以節省系統呼叫次數,降低延遲。
  • muduo所有的IO都是帶有緩衝的IO(buffered IO)。
    • 在棧上準備一個65536位元組的額外快取(extrabuf), 利用readv來讀取資料,iovec有兩塊,第一塊指向muduo的Buffer中的可寫位元組,另一塊指向extrabuf。
      • 資料不多,直接存到Buffer中,如果較多,剩餘的放到extrabuf中進行快取,然後再存到Buffer中。
  • send()是執行緒安全原子的,多個執行緒可以同時呼叫send(),訊息之間不會混疊或交織。
  • FILE是一個在stdio.h中預先定義的一個檔案型別.

    typedef struct{
      short level;              /*緩衝區“滿/空”的程度*/
      unsigned flags;           /*檔案狀態標誌字*/
      char fd;
      unsigned char hold;
      short bsize;              /*緩衝區大小*/
      unsigned char *buffer;    /*資料緩衝區的位置*/
      unsigned char *curp;      /*當前讀寫位置指標*/
      unsigned istemp;
      short token;
    }FILE;
    • boost::any可以表示任意型別,所以boost::any用不了多型的特性。
  • pintf()函式是執行緒安全的,但std::cout<<不是執行緒安全的。
  • 解析資料往往比生成資料更加複雜。
  • 非阻塞讀(nonblocking read)必須和input buffer一起使用,在接收方(decoder)一定要在收到完整的訊息之後再retrieve(取出)整條訊息。
  • Buffer其實就像是一個佇列(queue), 從末尾寫入資料,從頭部讀出資料。
    • Buffer內部是一個std::vector,它是一塊連續的記憶體,參考netty的ChannelBuffer(prependable是微創新)。
      • 如果readIndex太靠右,就不會重新分配記憶體,而是把已有資料移動到前面,騰出writable空間。
      • 前方新增(prepend):提供prependable空間,讓程式能夠以很低的代價在資料前面新增傑哥位元組。
        buffer結構
    • muduo Buffer不是固定長度的,它可以自動增長(使用vector的好處)。
    • readIndex和writeIndex是整數(因為指標的話,在新建陣列時會失效)。
    • Buffer沒有自動shink, 陣列只會越來越大。
      vector的好處
    • libevent 2.0.x的設計方案:
      • 實現分段連續的zero copy buffer再配合gather scatter IO(mbuf方案, Linux的sk_buff方案),基本思路是不要求資料在記憶體中是連續的,而是用連結串列把資料連線到一起。
        zero_copy_buffer
    • muduo的設計目標之一是吞吐量能讓千兆乙太網飽和,每秒收發120MB的資料。

一種自動反射訊息型別的Google Protobuf網路傳輸方案

  • Google Protocol Buffers(簡稱Protobuf)是一款非常優秀的庫,它定義了一種緊湊的可擴充套件二進位制訊息格式,特別適合網路資料傳輸。
    protobuf反射自動建立message物件
    • 拿到Message*指標,不用知道它的具體型別,就能建立和其他型別一樣的具體Message type的物件。
    • 通過DescriptorPool可以根據type name查到Descriptor*, 再呼叫DescriptorPool::findMessageTypeByName(const string& type_name)即可。
  • 使用步驟:
    1. 用DescriptorPool::generated_pool()找到一個DescriptorPool物件(它包含了編譯時所連線的全部Protobuf Message types).
    2. 根據type name用DescriptorPool::FindMessageTypeByName()查詢Descriptor.
    3. 再用MessageFactory::generated_factory()找到MessageFactory物件,它能建立程式編譯的時候所連結的全部Protobuf Message types.
    4. 然後用MessageFactory::GetPrototype()找到具體Message type的default instance(預設例項)。
    5. 最後用prototype->New()建立物件。
      • 返回的是動態物件,呼叫方需要釋放它,可以使用智慧指標管理資源。(訊息分發器dispatcher)
        protobuf的傳輸格式
  • java沒有unsigned型別。protobuf一般用於打包小於1MB的資料。
    • adler32校驗演算法,計算量小,速度比較快,強度和CRC-32差不多。
    • Protobuf Message的一個突出有點是用optional fields來避免協議的版本號.
  • 只有再使用TCP長連線,並且在一個連線上傳遞不知一種訊息的情況下,才需要打包方案。(還需要一個分發器,把不同型別的訊息分給各個訊息小狐狸函式)。
  • non-trivial的網路服務常旭通常會以訊息為單位來通訊,每條訊息有明確的長度與界限。
    • codec(編解碼器)的基本功能是TCP分包: 確定每條訊息的長度,為訊息劃分界限。
      protobuf codec訊息流程圖
    • Protobuf RPC.
  • ProtobufCodec攔截了TcpConnection的資料,把它轉換為Message, ProtobufDispatcher攔截了ProtobufCodec的回撥函式(callback). 按照訊息具體型別把它分派給多個callbacks.
    protobuf RPC
  • filedescriptor是稀缺資源,如果出現檔案描述符(filedescriptor)耗盡,很棘手。
  • 處理空閒連線超時: 如果一個長連線長時間(若干秒)沒有輸入資料,就踢掉此連線。--- 用timing wheel解決。
  • 定時器(時間):
    • 計時只使用gettimeofday(2)來獲取當前時間。 --- 精度為1微妙。
    • 定時只使用timerfd_*系列函式來處理定時任務。
  • Netty是一個非常好的Java NIO網路庫,帶有流量統計功能的echo和discard服務端。
  • 兩臺機器的網路延遲和時間差(簡單網路程式roundtrip).
    • NTP協議進行時間校準。
  • 應該用心跳訊息來判斷對方程式是否能正常工作,timing wheel(時間輪盤)避免空閒連線佔用資源。
    • timing wheel只用檢查第一個桶中的連線。
    • 層次化的timing wheel與hash timing wheel.
    • timing wheel中的每個格子是hash set,可以容納不止一個連線。
    • 不會真的把一個連線從一個格子移動到另一個格子,而是採用引用計數的辦法,用shared_ptr來管理Entry.
      • 如果連線接收到了資料,就把對應的EntryPtr放到這個格子裡,引用計數為零,那麼就析構掉(斷開連線)。
  • 簡單的訊息廣播服務:
    簡單的訊息廣播服務
    • 可以增加多個Subscriber而不用修改Subscriber(分散式的觀察者模式Observer pattern).
    • 應用層廣播在分散式系統中用處很大。 --- 訊息應該是snapshot, 而不是delta(現在比分是幾比幾,而不是誰剛才又得分了)。
    • ·sub<topic>\r\n, 表示訂閱, 以後該topic有任何更新都會發給這個TCP連線。
      • Hub會把上最近的訊息發給此Subscriber.
    • unsub<topic>\r\n, 表示退訂.
    • pub<topic>\r\n<content>\r\n, 表示往傳送訊息,內容為.
    • 利用thread local的辦法解決多執行緒廣播的鎖競爭。
  • 資料串並轉換:
    • 連線伺服器把多個客戶連線匯聚為一個內部TCP連線,起到資料串並轉換的作用,後端(backend)的邏輯伺服器專心處理業務。
    • 分為四步:
      1. 當client connection到達或斷開時,向backend發出通知。
      2. 當從client connection收到資料時,把資料連同connection id一同發給backend.
      3. 當從backend connection收到資料時,辨別資料是發給那個client connection,並執行相應的轉發操作.
      4. 如果backend connection斷開連線,則斷開所有client connections(假設client會自動重試).
    • multiplexer的功能與proxy頗為相似。
  • 中繼器(relay)主要把client與Server之間的通訊內容記錄下來(tcpdump的功能)。
    relay功能框圖
    • Sockets API來實現TcpRelay,需要splice系統呼叫。
    • 需要考慮的問題:
      需要考慮的問題

短址服務

  • muduo HTTP伺服器可以處理簡單的HTTP請求,也可以用來實現一個簡單的短URL轉發服務。
  • 一種真正高效的優化手段是修改Linux核心,例如Google的SO_REUSEPORT核心補丁。

  • muduo的Channel class類,可以把其他一些現成的網路庫融入muduo的event loop中。
    • Channel class是IO事件回撥的分發器(dispatcher), 它在handleEvent()中根據事件的具體型別分別回撥ReadCallback, WriteCallback等。
    • 每個Channel物件服務於一個檔案描述符,但並不擁有fd, 在解構函式中也不會close(fd).
    • Channel與EventLoop的內部互動有兩個函式:
      • EventLoop::updateChannel(Channel*);
      • EventLoop::removeChannel(Channel*).
      • 客戶需要在Channel析構前自己呼叫Channel::remove().
  • libcurl是一個常用的HTTP客戶端庫,可以方便地下載HTTP和HTTPS資料。
  • muduo提供Channel::tie(const boost::shared_ptr&)這個函式,用於延長某些物件的生命期,使其壽命長過Channel::handleEvent()函式。
  • POSIX作業系統總是選用當前最小可用的檔案描述符。

muduo庫設計與實現

  • EventLoop的解構函式會記住本物件所屬的執行緒(threadId_), 建立了EventLoop的執行緒是IO執行緒。
    • 其主要同能時執行事件迴圈EventLoop::loop().
    • EventLoop物件的生命期通常和其所屬的執行緒一樣長,不必是heap物件。
  • Reactor的關鍵結構: Reactor 最核心的事件分發機制,即將IO multiplexing拿到的IO事件分發給各個檔案描述符(fd)的事件處理函式。
  • Channel的成員函式都只能在IO執行緒呼叫,因此更新資料成員都不必加鎖。
    reactor 流程圖
    poll框架
  • TcpConnection簡單的狀態圖:
    TcpConnection簡單的狀態圖
  • SIGPIPE的預設行為時終止程式,在網路程式設計中,意味著如果對方斷開連線而本地繼續寫入的話,會造成服務程式意外退出。
  • TCPNoDelay和TCPkeepalive都是常用的TCP選項:
    • TCPNoDelay的作用是禁用Nagle演算法,避免連續發包出現延遲,對編寫低延遲網路服務很重要。
    • TCPkeepalive是定期檢查TCP連線是否還存在,如果有應用層心跳,TCPkeepalive不是必須的,但一般需要暴露其介面。
  • 用one loop per thread的思想多執行緒TcpServer的關鍵步驟是在新建TcpConnection時從event loop pool裡挑選一個loop給TcpConnection用.
  • 在併發連線較大而活動連線比例不高時, epoll比poll更有效.
  • 右值引用(rvalue reference)有助於提高效能與資源管理的便利性。

分散式系統工程實踐

  • 分散式系統設計以程式為基本單位.
  • 不要把時間浪費在解決錯誤的問題,應集中精力應付更本質的業務問題。
  • 只用TCP為程式間通訊,因為程式一退出,連線與埠自動關閉;而且無論連線的哪一方斷連,都可以重建TCP連線,恢復通訊。
  • 分散式系統中心跳協議的設計:
    • 心跳除了說明應用程式還活著(程式還在,網路暢通), 更重要的是表明應用程式還能正常工作。
    • TCP keepalive由作業系統負責探查,即便程式死鎖或阻塞,作業系統也會如常收發TCP keepalive訊息。對方無法得知這一異常。
    • 一般是服務端向客戶端傳送心跳。
    • Sender和Receiver的計時器是獨立的。
    • 心跳協議的內在矛盾: 高置信度與低反應時間不可兼得。
    • timeout的選擇要能容忍網路訊息延時波動和定時器的波動。
      心跳協議
      • 傳送週期和檢查週期均為\(T{_C}\), 通常可取\(timeout=2T{_C}\).
    • 心跳應該包含傳送方的識別符號,也應包含當前負載,便於客戶端做負載均衡。
    • 考慮閏秒的影響,尤其在考慮容錯協議的時候。
    • 心跳協議的實現上有兩個關鍵的點(防止偽心跳):
      • 要在工作執行緒中傳送,不要單獨起一個心跳執行緒。
      • 與業務訊息用同一個連線,不要單獨用心跳連線。
  • 分散式系統沒有全域性瞬時的狀態,不存在立刻判斷對方故障的方法,這是分散式系統的本質困難。
  • 埠只有6萬多個,是稀缺資源,在公司內部也有分配完的一天,一般到高階階段會採用動態分配埠號。
  • 如果客戶端與服務端之間用某種訊息中介軟體來回轉發訊息,那麼客戶端必須通過程式識別符號才能識別服務端。
    • 設定SO_REUSEADDR, 為了快速重啟。
    • linux的pid的最大預設值是32768。
    • 用四元組ip::port::tart_time::pid作為分散式系統中程式的gpid, 其中start_time是64-bit整數,表示程式的啟動時刻(UTC時區)。
  • 在服務程式內建監控介面的必要性;HTTP協議的便利性。
  • Hadoop有四種主要services:NameNode,DataNode, JobTracker和TaskTracker.
    • 每種service都內建了HTTP狀態頁面。
  • 在自己辯詞額分散式程式的時候,提供一個維修通道是很有必要的,它能幫助日常運維,而且在出現故障的時候幫助排查。
  • 如果不在程式開發的時候統一預留一些維修通道,那麼運維起來就抓瞎了 --- 每個程式都是黑盒子,出點什麼情況都得拼命查log試圖(猜想)程式的狀態,工作效率不高。
  • 使用跨語言的可擴充套件訊息格式。
    • 可擴充套件訊息格式的第一條原則是避免協議的版本號。
  • protobuf的可選欄位(optional fields)就解決了服務端和客戶端升級的難題。
    • proto檔案就像C/C++動態庫的標頭檔案,其中定義的訊息就是庫(分散式服務)的介面,一單釋出就不能做有損二進位制相容性的修改。
  • 分散式程式的自動化迴歸測試:
    • 自動化測試的必要性:
      • 自動化測試的作用是把程式已經事項的features以test case的形式固話下來,將來任何程式碼改動如果破壞了現有功能需求就會觸發測試failure.
      • 單元測試(unit testing): 主要測試一個函式、一個類(class)活著相關的幾個class。
        • 單元測試的缺點:
          • 阻礙大型重構,單元測試是白盒測試,測試程式碼直接呼叫被測試程式碼,測試程式碼和被測試程式碼緊耦合。
          • java有動態代理,還可以用cglib來操作位元組碼以實現注入。
          • 網路連線不上,資料庫超時,系統資源不足等都無法測試。
          • 單元測試對多執行緒程式無能為力
    • 分散式系統測試的要點:
      • 測試程式間互動。
      • 用指令碼來模擬客戶,自動化地測試系統的整體運作情況,作為系統的冒煙測試。
      • 一個分散式系統就是一堆機器,每臺機器的"屁股"上拖著兩根線:電源線和網線(不考慮SAN等儲存裝置)。
        分散式架構
  • 千兆網的吞吐量不太於125MB/S. --- 只要能讓千兆網的吞吐量飽和或接近飽和,那麼程式語言就無所謂了。
  • Hadoop的分散式檔案系統HDFS的架構簡圖:
    HDFS
    • HDFS有四個角色參與其中: NameNode(儲存後設資料)、DataNode(儲存節點,多個)、Secondary NameNode(定期寫check point)、Client(客戶,系統的使用者)。
    • 這些程式執行在多型機器上,之間通過TCP協議互聯。但一個程式其實不知道與自己打交道的到底是什麼。
  • Test harness(獨立的程式),模冒(mock)了與被測程式打交道的全部程式。
    迴歸測試方案
  • 壓力測試,test harness少加改進還可以變功能測試為壓力測試, 公程式設計師profiling用。
    • 發覆不間斷髮送請求,向被測程式加壓,用C++寫一個負載生成器。
  • 單獨的程式作為test harness對於開發分散式程式相當有幫助,它能達到單元測試的自動化程度和細緻程度,又避免了單元測試對功能程式碼結構的侵入與依賴。

分散式系統部署、監控與程式管理的幾重境界

  • 以Host指代伺服器硬體。
  1. 境界1: 全手工操作,過家家級別,系統時靈時不靈,可以跑跑測試,發發parper.
  2. 境界2: 使用零散的自動化指令碼和第三方元件
  • 公司的開發中心放在實現核心業務,天健新功能方面,暫時還顧不上高效的運維。
  • host的IP地址由DHCP配置,公司的軟硬體配置比較統一。
  • 使用cron、at、logrotate、rrdtool等標準的Linux工具來將部分運維任務自動化.
    • QA簽署後部署(md5), md5sum檢查拷貝之後的檔案是否與原始檔相同。
    • Monit開源工具進行監控(記憶體、CPU、磁碟空間、網路頻寬等)。
    • netstart-tpn | grep port(埠號)查詢哪些用到了程式。
  1. 境界3: 自制機制管理系統,幾種化配置
    境界3
  2. 境界4: 機群管理與nameing service結合:
  • naming service的功能是把一個service_name解析成list of ip:port,比方說,查詢"sudo_solver",返回host1:9981、host2:9981、host3:9981.
  • naming_servive與DNS最大的不同在於它能把新的地址資訊推送給客戶端。
  • gethostbyname()和getaddrinfo()解析DNS是阻塞的(除非使用UDNS等非同步DNS庫),在大規模分散式系統中DNS的作用不大,寧願花時間實現一個naming service,併為它編寫name resolve library.

C++編譯連結模型精要

  • C++語言的三大約束: 與C相容、零開銷(zero overhead)原則、值語義。
  • 檢視編譯時開啟的檔案命令: strace -f -e open cpp hello.cc -o /dev/null 2>&1 |grep -v ENONT|awk {'print $3'}
  • C++也繼承了單遍編譯的約束,Java編譯器不受單遍編譯的約束,調整成員函式的順序不會影響程式碼語義。
  • 按照C++模板的侷限話規則,編譯期會為每一個用到的類别範本成員函式具現化一份例項。
  • 在現在的C++實現中,虛擬函式的動態呼叫(動態繫結、執行期決議)是通過虛擬函式表(vtable)進行的,每個多型class都應該有一根vtable.
    • 定義或繼承了虛擬函式的物件中會有一個隱含成員:指向vtable的指標,即vptr。
    • 在構造和析構物件的時候,編譯期生成的程式碼會修改這個vptr成員,這就要用到vtable的定義(使用其地址)。
  • 原始碼編譯才是王道。
  • 實用當頭,樸實為貴,好用才是王道。
  • 避免使用虛擬函式作為庫的介面。
  • 以boost::function和boost::bind取代虛擬函式。

相關文章