Linux多線程服務器端編程


Linux多線程服務器端編程

  • 源碼鏈接
  • muduo的編譯安裝.
  • 陳碩的編譯教程
  • bazel編譯文件不能有中文路徑。
  • 安裝到指定目錄:
    • /usrdata/usingdata/studying-coding/server-development/server-muduo/build/release-install-cpp11/lib/libmuduo_base.a.
  • 這本書前前后后看了三四遍,寫得非常有深度,值得推薦。
  • 編譯和安裝.

線程安全的對象生命期管理

  • 利用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取代虛函數。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM