ZeroMQ 教程 002 : 高階技巧

張浮生發表於2018-05-10

本文主要譯自 zguide - chapter two. 但並不是照本翻譯.

上一章我們簡單的介紹了一個ZMQ, 並給出了三個套路的例子: 請求-迴應, 訂閱-釋出, 流水線(分治). 這一章, 我們將深入的探索一下ZMQ中的socket, 以及"套路"

socket API

如果熟悉linux socket程式設計的同學閱讀完了第一章, 一定有一種說不上來的彆扭感覺.因為通常情況下, 當我們討論socket的時候, 我們一般指的是作業系統提供的網路程式設計介面裡的那個socket概念. 而在ZMQ中, 只是借用了這個概念的名字, 在ZMQ中, 我們討論到socket的時候, 一般指代的是呼叫zmq_socket()介面返回的那個socket, 具體一點: zmq socket.

zmq socket比起linux socket來說, 邏輯理解起來比較類似, 雖然兩者內部完全就不是同一種東西.

  1. socket需要被建立, 以及關閉. zmq_socket(), zmq_close()
  2. socket有配置項. zmq_setsockopt(), zmq_getsockopt()
  3. socket有繫結和連線兩種操作. zmq_bind(), zmq_connect()
  4. 收發socket上的資料. zmq_msg_send(), zmq_msg_recv(), zmq_send(), zmq_recv()

但與linux socket不同的是, zmq socket沒有listen這個邏輯概念.

需要注意的是, zmq socket是void指標, 而訊息則是結構例項. 這就意味著, 在C語言的API中, 需要zmq socket的地方, 傳遞的一定是值, 而需要傳遞訊息的時候, 比如使用zmq_msg_send()zmq_msg_recv()這樣的介面, 訊息引數則傳遞其地址. 其設計哲學是: 在zmq中, socket不歸程式設計師掌控, 所以你可能拿到一個控制程式碼(地址), 但不能看到它長什麼樣(不能看到socket例項), 但訊息是程式設計師建立的, 是受程式設計師掌控的.

將socket接入網路拓撲中

在兩個結點上用ZMQ實現通訊, 你需要分別為兩個結點建立socket, 並在其中一個結點上呼叫zmq_bind(), 在另一個結點上建立對應的zmq_connect(). 在ZMQ中, 請不要再以死板的"客戶端", "服務端"來區分網路結點. 而要這樣理解: zmq_bind()呼叫應該發生在網路拓撲中那些不易變的結點上, 而zmq_connect()應該發生在網路拓撲中那些易變的結點上.

ZMQ建立起的資料連線和常見的TCP連線有一些不同, 但也有一些共通之處, 如下:

  1. TCP是TCP/IP協議棧的四層協議, 當建立一個TCP連線的時候, 雙方都必須使用TCP/IP協議棧. 從這個角度看, ZMQ是四層之上的4.5層, ZMQ下面統一了很多連線協議, 對於TCP/IP協議棧來說, ZMQ下面有TCP, 除了TCP/IP, ZMQ還能通過共享記憶體線上程間建立連線, 在程式間建立連線(具體連線手段對上層是透明的), 使用TCP/IP協議棧的PGM協議(建立在IP層上的一種多播協議)建立連線.
  2. 在linux socket中, 一個連線就是一個socket, 但在ZMQ中, 一個socket上可以承載多個資料連線. 這裡socket和connection不再是同個層次上的等價詞彙, 要把socket理解為程式設計師訪問資料連線的一個入口, 一個大門, 門推開, 可能有多個連線, 而不止一個. 有多個資料流等待吞吐.
  3. 上面說了, 用ZMQ在結點間建立連線, 程式設計師操作ZMQ相關API的時候, 實際上位於的是類似於TCP/IP裡的第4.5層, 反過來看, 即具體的連線是如何建立, 如何保持, 如何維護的, 這ZMQ庫的工作, 不應該由使用ZMQ庫的人去關心. 也就是說, 自從使用了ZMQ庫, 你再也不需要關心TCP是如何握手了. 並且, 對於合適的協議, 對端結點上線下線時, ZMQ庫將負責優雅的處理接連中斷, 重試連線等髒活.
  4. 再次重申, 如何連線, 是ZMQ庫的工作, 你不應該插手. 你只需要關心資料, 套路, 拓撲.

在請求-迴應套路中, 我們把比較不易變的邏輯結點稱為服務端, 把易變, 也就是會經常性的退出, 或重新加入網路拓撲的結點稱為客戶端. 服務端向外提供服務, 必須提供一個"地址"供客戶端去上門, 換句話說, 在這個套路拓撲中, 那些經常來來去去的客戶端應該知道去哪找服務端. 但反過來, 服務端完全不關心去哪找客戶端, 你愛來不來, 不來就滾, 不要打擾我飛昇. 對於不易變的結點, 應該使用zmq_bind()函式, 對於易變的結點, 應該採用zmq_connect

在傳統的linux socket程式設計中, 如果服務端還沒有上線工作, 這個時候去啟動客戶端程式, 客戶端程式的connect()呼叫會返回錯誤. 但在ZMQ中, 它妥善處理了這種情況. 客戶端呼叫zmq_connect(), 不會報錯, 僅會導致訊息被阻塞而發不出去.

不要小看這一點設計, 它反映出ZMQ的設計思想: 在請求-應答套路中, 它不光允許客戶端可以隨時退出, 再回來. 甚至允許服務端去上個廁所.

另外, 一個服務端可以多次呼叫zmq_bind()以將自己關聯到多個endpoint上.(所謂的endpoint, 就是通訊協議+通訊地址的組合, 它一般情況下指代了在這種通訊協議中的一個網路結點, 但這個結點可以是邏輯性的, 不一定只是一臺機器).這就意味著, zmq socket可以同時接受來自多個不同通訊協議的多簇請求訊息.

zmq_bind(socket, "tcp://*:5555");
zmq_bind(socket, "tcp://*:999");
zmq_bind(socket, "inproc://suprise_motherfucker");

但是, 對於同一種通訊協議裡的同一個endpoint, 你只能對其執行一次zmq_bind()操作. 這裡有個例外, 就是ipc程式間通訊. 邏輯上允許另外一個程式去使用之前一個程式已經使用過的ipc endpoint, 但不要濫用這特性: 這只是ZMQ提供給程式崩潰後恢復現場的一種手段, 在正常的程式碼邏輯中, 不要做這樣的事情.

所以看到這裡你大概能理解zmq對bind和connect這兩個概念的態度: ZMQ努力的將這兩個概念之間的差異抹平, 但很遺憾, zmq並沒有將這兩個操作抽象成一個類似於touch的操作. 但還是請謹記, 在你的網路拓撲中, 讓不易變結點去使用zmq_bind(), 讓易變結點去使用zmq_connect

zmq socket是分型別的, 不同型別的socket提供了差異化的服務, socket的型別與結點在拓撲中的角色有關, 也影響著訊息的出入, 以及快取策略. 不同型別的socket之間, 有些可以互相連線, 但有些並不能, 這些規則, 以及如何在套路中為各個結點安排合適型別的socket, 都是後續我們將要講到的內容.

如果從網路通訊的角度來講, zmq是一個將傳統傳輸層封裝起來的網路庫. 但從資料傳輸, 訊息傳輸, 以及訊息快取這個角度來講, zmq似乎又稱得上是一個訊息佇列庫. 總之, zmq是一個優秀的庫, 優秀不是指它的實現, 它的效能, 而是它能解決的問題, 它的設計思路.

收發訊息

在第一章裡, 我們接觸到了兩個有關訊息收發的函式, zmq_send()zmq_recv(), 現在, 我們需要把術語規範一下.

zmq_send()zmq_recv()是用來傳輸"資料"的介面. 而"訊息"這個術語, 在zmq中有指定含義, 傳遞訊息的介面是zmq_msg_send()zmq_msg_recv()

當我們說起"資料"的時候, 我們指的是二進位制串. 當我們說"訊息"的時候, 指提是zmq中的一種特定結構體.

需要額外注意的是, 無論是呼叫zmq_send()還是zmq_msg_send(), 當呼叫返回時, 訊息並沒有真正被髮送出去, 更沒有被對方收到. 呼叫返回只代表zmq將你要傳送的"訊息"或"資料"放進了一個叫"傳送緩衝區"的地方. 這是zmq實現收發非同步且帶緩衝佇列的一個設計.

單播傳輸

ZMQ底層封裝了三種單播通訊協議, 分別是: 共享記憶體實現的執行緒間通訊(inproc), 程式間通訊(ipc), 以及TCP/IP協議棧裡的TCP協議(tcp). 另外ZMQ底層還封裝了兩種廣播協議: PGM, EPGM. 多播我們在非常後面的章節才會介紹到, 在你瞭解它之前, 請不要使用多播協議, 即便你是在做一些類似於釋出-訂閱套路的東西.

對於多數場景來說, 底層協議選用tcp都是沒什麼問題的. 需要注意的是, zmq中的tcp, 被稱為 "無連線的tcp協議", 而之所以起這麼一個精神分裂的名字, 是因為zmq允許在對端不存在的情況下, 結點去zmq_connect(). 你大致可以想象zmq做了多少額外工作, 但這些對於你來說, 對於上層應用程式來說, 是透明瞭, 你不必去關心具體實現.

IPC通訊類似於tcp, 也是"無連線"的, 目前, 這種方式不能在windows上使用, 很遺憾. 並且, 按照慣例, 在使用ipc作為通訊方式時, 我們一般給endpoint加上一個.ipc的字尾. 另外, 在Unix作業系統上, 使用ipc連線還請格外的注意不同程式的許可權問題, 特別是從屬於兩個不同使用者的程式.

最後來說一下inproc, 也就是執行緒間通訊, 它只能用於同一程式內的不同執行緒通訊. 比起tcp和ipc, 這種通訊方式快的飛起. 它與tcp和ipc最大的區別是: 在有客戶端呼叫connect之前, 必須確保已經有一個服務端在對應的endpoint上呼叫了bind, 這個缺陷可能會在未來的某個版本被修正, 但就目前來講, 請務必小心注意.

ZMQ對底層封裝的通訊協議是有侵入性的

很遺憾的是, ZMQ對於其底層封裝的網路協議是有侵入性的, 換句話說, 你沒法使用ZMQ去實現一個HTTP伺服器. HTTP作為一個五層協議, 使用TCP作為傳輸層協議, 對TCP裡的報文格式是有規約限制的, 而ZMQ作為一個封裝了TCP的4.5層協議, 其在資料互動時, 已經侵入了TCP的報文格式. 你無法讓TCP裡的報文既滿足HTTP的格式要求, 還滿足ZMQ的格式要求.

關心ZMQ到底是如何侵入它封裝的通訊協議的, 這個在第三章, 當我們接觸到ZMQ_ROUTER_RAW這種socket配置項的時候才會深入討論, 目前你只需要明白, ZMQ對其底層封裝的通訊協議有侵入.

這意味著, 你無法無損的將ZMQ引入到一些現成的專案中. 這很遺憾.

I/O執行緒

我們先前提到過, ZMQ在後臺使用獨立的執行緒來實現非同步I/O處理. 一般情況下吧, 一個I/O執行緒就應該足以處理當前程式的所有socket的I/O作業, 但是這個凡事總有個極限情況, 所以總會存在一些很荀的場景, 你需要多開幾個I/O執行緒.

當你建立一個context的時候, ZMQ就在背後建立了一個I/O處理執行緒. 如果這麼一個I/O執行緒不能滿足你的需求, 那麼就需要在建立context的時候加一些料, 讓ZMQ多建立幾個I/O處理執行緒. 一般有一個簡單估算I/O執行緒數量的方法: 每秒你的程式有幾個G位元組的吞吐量, 你就開幾個I/O執行緒.

下面是自定義I/O執行緒數量的方法:

int io_threads = 4;
void * context = zmq_ctx_new();
zmq_ctx_set(context, ZMQ_IO_THREADS, io_threads);
assert(zmq_ctx_get(context, ZMQ_IO_THREADS) == io_threads);

回想一下你用linux socket + epoll編寫服務端應用程式的套路, 一般都是一個tcp連線專門開一個執行緒. ZMQ不一樣, ZMQ允許你在一個程式裡持有上千個連線(不一定是TCP哦), 但處理這上千個連線的I/O作業, 可能只有一個, 或者幾個執行緒而已, 並且事實也證明這樣做是可行的. 可能你的程式裡只有十幾個執行緒, 但就是能處理超過上千個連線.

當你的程式只使用inproc作為通訊手段的時候, 其實是不需要執行緒來處理非同步I/O的, 因為inproc是通過共享記憶體實現通訊的. 這個時候你可以手動設定I/O執行緒的數量為0. 這是一個小小的優化手段, 嗯, 對效能的提升基本為0.

套路, 套路, 套路

ZMQ的設計是親套路的, ZMQ的核心其實在於路由與快取, 這也是為什麼作為一個網路庫, 它更多的被人從訊息佇列這個角度瞭解到的原因. 要用ZMQ實現套路, 關鍵在於使用正確的socket型別, 然後把拓撲中的socket組裝配對起來. 所以, 要懂套路, 就需要懂zmq裡的socket型別.

zmq提供了你構建如下套路的手段:

  1. 請求-應答套路. 多對多的客戶端-服務端模型. 用於遠端呼叫以及任務分發場景.
  2. 釋出-訂閱套路. 多對多的喇叭-村民模型. 用於資料分發場景.
  3. 流水線套路. 用於並行作業處理場景.
  4. 一夫一妻套路. 一對一的連線模型. 這一般用於在程式中兩個執行緒進行通訊時使用.

我們在第一章中已經大致接觸了套路, 除了一夫一妻沒有接觸到, 這章稍後些部分我們也將接觸這種套路.要了解具體socket的各個型別都是幹嘛用的, 可以去閱讀zmq_socket()的manpage, 我建議你去閱讀, 並且仔細閱讀, 反覆閱讀.下面列出的是可以互相組合的socket型別. 雙方可以替換bindconnect操作.

  1. PUB SUB. 經典的釋出-訂閱套路
  2. REQ REP. 經典的請求-應答套路
  3. REQ ROUTER (注意, REQ發出的資料中, 以一個空幀來區分訊息頭與訊息體)
  4. DEALER REP(注意, REP假定收到的資料中, 有一個空幀用以區分訊息頭與訊息體)
  5. DEALER ROUTER
  6. DEALER DEALER
  7. ROUTER ROUTER
  8. PUSH PULL. 經典的流水線套路.
  9. PAIR PAIR. 一夫一妻套路

後續你還會看到有XPUB與XSUB兩種型別的socket. 就目前來說, 只有上面的socket配對連線是有效的, 其它沒列出的組合的行為是未定義的, 但就目前的版本來說, 錯誤的組合socket型別並不會導致連線時出錯, 甚至可能會碰巧按你的預期執行, 但強烈不建議你這個瞎jb搞. 在未來的版本中, 組合非法的socket型別可能會導致API呼叫出錯.

訊息, 訊息, 訊息

libzmq有兩套收發訊息的API介面, 這個之前我們已經講過. 並且在第一章裡建議你多使用zmq_send()zmq_recv(), 建議你規避zmq_msg_send()zmq_msg_recv(). 但zmq_recv有一個缺陷, 就是當你提供給zmq_recv()介面的接收buffer不夠長時, zmq_recv()會把資料截斷. 如果你無法預測你要收到的二進位制資料的長度, 那麼你只能使用zmq_msg_xxx()介面.

從介面名上的msg三個字母就能看出, 這個系列的介面是操縱結構體, 也就是"訊息"(其實是幀, 後面會講到), 而不是"資料", 而非緩衝區的介面, 實際上它們操縱的是zmq_msg_t型別的結構. 這個系列的介面功能更為豐富, 但使用起來也請務必萬分小心.

  1. 初始化訊息相關的介面: zmq_msg_init(), zmq_msg_init_size(), zmq_msg_init_data()
  2. 訊息收發介面: zmq_msg_send(), zmq_msg_recv()
  3. 訊息釋放介面: zmq_close()
  4. 訪問訊息內容的介面: zmq_msg_data(), zmq_msg_size(), zmq_msg_more()
  5. 訪問訊息配置項的介面: zmq_msg_get(), zmq_msg_set()
  6. 複製拷貝操作介面: zmq_msg_copy(), zmq_msg_move()

訊息結構中封裝的資料是二進位制的, 依然由程式設計師自己解釋. 關於zmq_msg_t結構型別, 下面是你需要知道的基礎知識:

  1. 去閱讀上面的訊息相關介面API的manpage, 你會發現傳遞引數都是以zmq_msg_t *. 也就是說這是一個內部實現不對外開放的型別, 建立, 傳遞, 都應當以指標型別進行操作.
  2. 要從socket中接收一個訊息, 你需要先通過zmq_msg_init()建立一個訊息物件, 然後將這個訊息物件傳遞給zmq_msg_recv()介面
  3. 要向socket中寫入訊息, 你需要先通過zmq_msg_init_size()建立一個資料容量指定的訊息物件, 然後把你要寫入的二進位制資料通過記憶體拷貝函式, 比如memcpy()寫入訊息中, 最後呼叫zmq_msg_send(), 看到這裡你應該明白, zmq_msg_init_size()介面內部進行了記憶體分配.
  4. 訊息的"釋放"和"銷燬"是兩個不同的概念. zmq_msg_t其實是引用計數方式實現的共享物件型別, "釋放"是指當前上下文放棄了對該訊息的引用, 內部導致了例項的引用計數-1, 而"銷燬"則是徹底把例項本身給free掉了. 當你"釋放"一個訊息的時候, 應當呼叫zmq_msg_close()介面. 如果訊息例項在釋放後引用計數歸0, 那麼這個訊息例項會被ZMQ自動銷燬掉.
  5. 要訪問訊息裡包裝的資料, 呼叫zmq_msg_data()介面, 要獲取訊息中資料的長度, 呼叫zmq_msg_size()
  6. 在你熟讀並理解相關manpage中的內容之前, 不要去呼叫zmq_msg_move(), zmq_msg_copy(), zmq_msg_init_data()這三個介面
  7. 當你通過zmq_msg_send()呼叫將訊息傳送給socket後, 這個訊息內部包裝的資料會被清零, 也就是zmq_msg_size() == 0, 所以, 你不應該連續兩次使用同一個zmq_msg_t *值呼叫zmq_msg_send(). 但需要注意的是, 這裡的"清零", 並不代表訊息被"釋放", 也不代表訊息被"銷燬". 訊息還是訊息, 只是其中的資料被扔掉了.

如果你想把同一段二進位制資料傳送多次, 正確的做法是下面這樣:

  1. 呼叫zmq_msg_init_size(), 建立第一個訊息, 再通過memcpy或類似函式將二進位制資料寫入訊息中
  2. 呼叫zmq_msg_init()建立第二個訊息, 再呼叫zmq_msg_copy()從第一個訊息將資料"複製"過來
  3. 重複上述步驟
  4. 依次呼叫zmq_msg_send()傳送上面的多個訊息

ZMQ還支援所謂的"多幀訊息", 這種訊息允許你把多段二進位制資料一次性傳送給對端. 這個特性在第三章我們再講. (P.S.: 這是一個很重要的特性, 路由代理等高階套路就嚴重依賴這種多幀訊息.). ZMQ中的訊息有三層邏輯概念: 訊息, 幀, 二進位制資料. 使用者自定義的二進位制資料被包裝成幀, 然後一個或多個幀組成一個訊息. 訊息是ZMQ拓撲網路中兩個結點收發的單位, 但在ZMQ底層的傳輸協議中, 最小單位是幀.

換一個角度來講, ZMQ使用其底層的傳輸協議, 比如tcp, 比如inproc, 比如ipc來傳輸資料, 當ZMQ呼叫這些傳輸協議傳遞資料的時候, 最小單元是幀. 幀的完整性由傳輸協議來保證, 即是ZMQ本身不關心這個幀會不會破損, 幀的完整傳輸應當由這些傳輸協議去保證. 而在使用ZMQ構建應用程式的程式設計師眼中, 最小的傳輸單位是訊息, 一個訊息裡可能會有多個幀, 程式設計師不去關心訊息從一端到另一端是否會出現丟幀, 訊息的完整性與原子性應當由ZMQ庫去保證.

前面我們講過, ZMQ對其底層的傳輸協議是有侵入性的. 如果要了解ZMQ到底是如何在傳輸協議的基礎上規定幀傳輸格式的, 可以去閱讀這個規範.

在我們到達第三章之前, 我們所討論的訊息中都僅包含一個幀. 這就是為什麼在這一小節的描述中, 我們幾乎有引導性的讓你覺得, zmq_msg_t型別, 就是"訊息", 其實不是, 其實zmq_msg_t訊息只是"幀".

  1. 一個訊息可以由多個幀組成
  2. 每個幀都是一個zmq_msg_t物件
  3. 使用zmq_msg_send(), zmq_msg_recv(), 你可以一幀一幀的傳送資料. 可以用多次呼叫這些介面的方式來傳送一個完整的訊息, 或者接收一個完整的訊息: 在傳送時傳入ZMQ_SNDMORE引數, 或在接收時, 通過zmq_getsockopt()來獲取ZMQ_RCVMORE選項的值. 更多關於如何使用低階API收發多幀訊息的資訊, 請參見相關介面的manpage
  4. ZMQ也提供了便於收發多幀訊息的高階API

關於訊息或幀, 還有下面的一些特性:

  1. ZMQ允許你傳送資料長度為0的幀. 比如在有些場合, 這只是一個訊號, 而沒有任何語義上的資料需要被攜帶
  2. ZMQ在傳送多幀訊息時, 保證訊息的原子性與完整性. 如果丟失, 所有幀都不會到達對端, 如果成功, 那麼必須所有幀都被正確送達, 幀在傳輸過程中不會出現破損.
  3. 在呼叫傳送資料的癌後, 訊息並不會被立即發出, 而是被放在傳送緩衝區中. 這和zmq_send()是一致的.
  4. 你必須在完成訊息接收後, 呼叫zmq_msg_close()介面來釋放這個zmq_msg_t物件

最後再強調一下, 在你不理解zmq_msg_t的原理之前, 不要使用zmq_msg_init_data()介面, 這是一個0拷貝介面, 如果不熟悉zmq_msg_t結構的原理, 瞎jb用, 是會core dump的

ZMQ中的多路I/O複用

在先前的所有例子程式中, 大多程式裡乾的都是這樣的事情

  1. 等待socket上有資料
  2. 接收資料, 處理
  3. 重複上面的過程

如果你接觸過linux中的select, pselect, epoll等多路IO複用介面, 你一定會好奇, 在使用zmq的時候, 如何實現類似的效果呢? 畢竟ZMQ不光把linux socket的細節給你封裝了, 連檔案描述符都給你遮蔽封裝掉了, 顯然你沒法直接呼叫類似於select, pselect, epoll這種介面了.

答案是, ZMQ自己搞了一個類似的玩意, zmq_poll()瞭解一下.

我們先看一下, 如果沒有多路IO介面, 如果我們要從兩個socket上接收資料, 我們會怎樣做. 下面是一個沒什麼卵用的示例程式, 它試圖從兩個socket上讀取資料, 使用了非同步I/O. (如果你有印象的話, 應該記得對應的兩個endpoint實際上是我們在第一章寫的兩個示例程式的資料生產方: 天氣預報程式與村口的大喇叭)

#include <zmq.h>
#include <stdio.h>

int main(void)
{
    void * context = zmq_ctx_new();
    void * receiver = zmq_socket(context, ZMQ_PULL);
    zmq_connect(receiver, "tcp://localhost:5557");

    void * subscriber = zmq_socket(context, ZMQ_SUB);
    zmq_connect(subscriber, "tcp://localhost:5556");
    zmq_setsockopt(subscriber, ZMQ_SUBSCRIBE, "10001 ", 6);

    while(1)
    {
        char msg[256];
        while(1)
        {
            int size = zmq_recv(receiver, msg, 255, ZMQ_DONTWAIT);
            if(size != -1)
            {
                // 接收資料成功
            }
            else
            {
                break;
            }
        }

        while(1)
        {
            int size = zmq_recv(subscriber, msg, 255, ZMQ_DONTWAIT);
            if(size == -1)
            {
                // 接收資料成功
            }
            else
            {
                break;
            }
        }

        sleep(1);   // 休息一下, 避免瘋狂迴圈
    }

    zmq_close(receiver);
    zmq_close(subscriber);
    zmq_ctx_destroy(context);

    return 0;
}

在沒有多路IO手段之前, 這基本上就是你能做到的最好情形了. 大迴圈裡的sleep()讓人渾身難受. 不加sleep()吧, 在沒有資料的時候, 這個無限空迴圈能把一個核心的cpu佔滿. 加上sleep()吧, 收包又會有最壞情況下1秒的延時.

但有了zmq_poll()介面就不一樣了, 程式碼就會變成這樣:

#include <zmq.h>
#include <stdio.h>

int main(void)
{
    void * context = zmq_ctx_new();
    void * receiver = zmq_socket(context, ZMQ_PULL);
    zmq_connect(receiver, "tcp://localhost:5557");

    void * subscriber = zmq_socket(context, ZMQ_SUB);
    zmq_connect(subscriber, "tcp://localhost:5556");
    zmq_setsockopt(subscriber, ZMQ_SUBSCRIBE, "10001 ", 6);

    while(1)
    {
        char msg[256];
        zmq_pollitem_t items[] = {
            {receiver,  0,  ZMQ_POLLIN,     0},
            {subscriber,0,  ZMQ_POLLIN,     0},
        };

        zmq_poll(items, 2, -1);

        if(items[0].revents & ZMQ_POLLIN)
        {
            int size = zmq_recv(receiver, msg, 255, 0);
            if(size != -1)
            {
                // 接收訊息成功
            }
        }

        if(items[1].revents & ZMQ_POLLIN)
        {
            int size = zmq_recv(subscriber, msg, 255, 0);
            if(size != -1)
            {
                // 接收訊息成功
            }
        }
    }

    zmq_close(receiver);
    zmq_close(subscriber);
    zmq_ctx_destroy(context);

    return 0;
}

zmq_pollitem_t型別定義如下, 這個定義可以從zmq_poll()的manpage裡查到

typedef struct{
    void * socket;  // ZMQ的socket
    int fd;         // 是的, zmq_poll()還可以用來讀寫linux file descriptor
    short events;   // 要被監聽的事件, 基礎事件有 ZMQ_POLLIN 和 ZMQ_POLLOUT, 分別是可讀可寫
    short revents;  // 從zmq_poll()呼叫返回後, 這裡儲存著觸發返回的事件
} zmq_pollitem_t;

多幀訊息的收發

我們之前提到過, 使用者資料被包裝成zmq_msg_t物件, 也就是幀, 而在幀上, 還有一個邏輯概念叫"訊息". 那麼在具體編碼中, 如何傳送多幀訊息呢? 而又如何接收多幀訊息呢? 簡單的講, 兩點:

  1. 在傳送時, 向zmq_msg_send()傳入ZMQ_SNDMORE選項, 告訴傳送介面, "我後面還有其它幀"
  2. 在接收訊息時, 每呼叫一次zmq_msg_recv()接收一個幀, 就呼叫一次zmq_msg_more()或者zmq_getsockopt() + ZMQ_RCVMORE來判斷是否這是訊息的最後一個幀

傳送示例:

zmq_msg_send(&msg, socket, ZMQ_SNDMORE);
zmq_msg_send(&msg, socket, ZMQ_SNDMORE);
zmq_msg_send(&msg, socket, 0);          // 訊息的最後一個幀

接收示例:

while(1)
{
    zmq_msg_t msg;
    zmq_msg_init(&msg);
    zmq_msg_recv(&msg, socket, 0);
    // 做處理
    zmq_msg_close(&msg);

    if(!zmq_msg_more(&msg)) // 注意, zmq_msg_more可以在zmq_msg_close後被安全的呼叫
    {
        break;
    }
}

這裡有一個需要注意的有趣小細節: 要判斷一個收來的幀是不是訊息的最後一個幀, 有兩種途徑, 一種是zmq_getsockopt(socket, ZMQ_RCVMORE, &more, &more_size), 另外一種是zmq_msg_more(&msg). 前一種途徑的入參是socket, 後一種途徑的入參是msg. 這真是很因缺思汀. 目前來說, 兩種方法都可以, 不過我建議你使用zmq_getsockopt(), 至於原因嘛, 因為在zmq_msg_recv()的manpage中, 是這樣建議的.

關於多幀訊息, 你需要注意以下幾點:

  1. 多幀訊息的傳輸是原子性的, 這是由ZMQ保證的
  2. 原子性也意味著, 當你使用zmq_poll()時, 當socket可讀, 並且用zmq_msg_recv()讀出一個幀時, 代表著不用等待下一次迴圈, 你直接繼續讀取, 一定能讀取能整個訊息中剩餘的其它所有幀
  3. 當一個多幀訊息開始被接收時, 無論你是否通過zmq_msg_more()zmq_getsockopt() + ZMQ_RCVMORE檢查訊息是否接收完整, 你一幀幀的收, 也會把整個訊息裡的所有幀收集齊. 所以從這個角度看, zmq_msg_more()可以在把所有可讀的幀從socket裡統一接收到手之後, 再慢慢判斷這些幀應該怎麼拼裝. 所以這樣看, 它和zmq_getsockopt()的功能也不算是完全重複.
  4. 當一個多幀訊息正在傳送時, 除了把socket關掉(暴力的), 否則你不能取消本次傳送, 本次傳送將持續至所有幀都被髮出.

中介與代理

ZMQ的目標是建立去中心化的訊息通訊網路拓撲. 但不要誤解"去中心"這三個字, 這並不意味著你的網路拓撲在中心圈內空無一物. 實際上, 用ZMQ搭建的網路拓撲中常常充滿了各種非業務處理的網路結點, 我們把這些感知訊息, 傳遞訊息, 分發訊息, 但不實際處理訊息的結點稱為"中介", 在ZMQ構建的網路中, 它們按應用場景有多個細化的名字, 比如"代理", "中繼", "裝置", "掮客"等.

這套邏輯在現實世界裡也很常見, 中間人, 中介公司, 它們不實際生產社會價值, 表面上看它們的存在是在吸兩頭的血, 這些皮條客在社會中的存在意義在於: 它們減少了溝通的複雜度, 對通訊雙方進行了封裝, 提高了社會執行效率.

在釋出-訂閱套路中加入中介: XPUB與XSUB

當構建一個稍有規模的頒式系統的時候, 一個避不開的問題就是, 網路中的結點是如何感知其它結點的存在的? 結點會當機, 會擴容, 在這些變化發生的時候, 網路中的其它正在工作的結點如何感知這些變化, 並保持系統整體正常執行呢? 這就是經典的"動態探索問題".

動態探索問題有一系列很經典的解決方案, 最簡單的解決方案就是把問題本身解決掉: 把網路拓撲設計死, 程式碼都寫死, 別讓它瞎jb來回變, 問題消滅了, done!. 這種解決方案的缺點就是如果網路拓撲要有變更, 比如業務規模擴充套件了, 或者有個結點當機了, 網路配置管理員會罵娘.

拓撲規模小的時候, 消滅問題的思路沒什麼壞處, 但拓撲稍微複雜一點, 顯然這就是一個很可笑的解決方案.比如說, 網路中有一個釋出者, 有100多個訂閱者, 釋出者bind到endpoint上, 訂閱者connect到endpoint上. 如果程式碼是寫死的, 如果釋出者本身出了點什麼問題, 或者釋出者一臺機器搞不住了, 需要橫向擴容, 你就得改程式碼, 然後手動部署到100多臺訂閱者上. 這樣的運維成本太大了.

這種場景, 你就需要一個"中介", 對釋出者而言, 它從此無需關心訂閱者是誰, 在哪, 有多少人, 只需要把訊息給中介就行了. 對於訂閱者而言, 它從此無需關注釋出者有幾個, 是否使用了多個endpoint, 在哪, 有多少人. 只需要向中介索取訊息就行了. 雖然這時釋出者身上的問題轉嫁到的中介身上: 即中介是網路中最易碎的結點, 如果中介掛了整個拓撲就掛了, 但由於中介不處理業務邏輯, 只是一個類似於交換機的存在, 所以同樣的機器效能, 中介在單位時間能轉發的訊息數量, 比釋出者和訂閱者能處理的訊息高一個甚至幾個數量級. 是的, 使用中介引入了新的問題, 但解決了老的問題.

中介並沒有解決所有問題, 當你引入中介的時候, 中介又變成了網路中最易碎的點, 所以在實際應用中, 要控制中介的權重, 避免整個網路拓撲嚴重依賴於一箇中介這種情況出現: ZMQ提倡去中心化, 不要把中介變成一個壟斷市場的掮客.

對於釋出者而言, 中介就是訂閱者, 而對於訂閱者而言, 中介就是釋出者. 中介使用兩種額外的socket型別: XPUB與XSUB. XSUB與真實的釋出者連線, XPUB與真實的訂閱者連線.

在請求-迴應套路中加入掮客: DELEAR與ROUTER

在我們之前寫的請求-迴應套路程式中, 我們有一個客戶端, 一個服務端. 這是一個十分簡化的例子, 實際應用場景中的請求-迴應套路中, 一般會有多個客戶端與多個服務端.

請求-應答模式有一個隱含的條件: 服務端是無狀態的. 否則就不能稱之為"請求-應答"套路, 而應該稱之為"嘮嗑套路".

要連線多個客戶端與多個服務端, 有兩種思路.

第一種暴力思路就是: 讓N個客戶端與M個服務端建立起N*M的全連線. 這確實是一個辦法, 雖然不是很優雅. 在ZMQ中, 實現起來還輕鬆不少: 因為ZMQ的socket可以向多個endpoint發起連線, 這對於客戶端來說, 編碼難度降低了. 客戶端應用程式中可以建立一個zmq_socket, 然後connect到多個服務端的endpoint上就行了. 這種思路做的話, 客戶端數量擴張很容易, 直接部署就可以, 程式碼不用改. 但是缺陷有兩個:

  1. 服務端擴容時, 所有客戶端的程式碼都得跟著改
  2. 客戶端程式碼裡必須知道所有服務端的endpoint

總的來說, 這是一種很暴力的解決辦法, 不適合用於健壯的生產環境. 但是這確實是一個辦法.

為了解決上面兩個缺陷, 自然而然的我們就會想到: 為什麼不能把服務端抽象出來呢? 讓一個掮客來做那個唯一的endpoint, 以供所有客戶端connect, 然後掮客在背後再把請求體分發給各個服務端, 服務端做出迴應後掮客再代替服務端把迴應返回給客戶端, 這樣就解決了上面的問題:

  1. 對於客戶端來說, 服務端抽象成了一個endpoint, 服務端擴容時, 客戶端是沒有感知的.
  2. 客戶端不需要知道服務端的所有endpoint, 只需要知道掮客的endpoint就可以了.

並且, 掮客還可以做到以下

  1. 如果N個客戶端傳送請求的速度時快時慢, 快的時候, M個服務端處理不過來. 掮客可以做一個緩衝地帶.
  2. 掮客可以記錄會話狀態, 可以保證某一個特定的客戶端始終與一個固定的服務端進行資料互動. 某種程度上, 掮客與客戶端分別記錄部分會話資訊, 服務端可以在無狀態的情況下實現"嘮嗑套路"

所以, 在請求迴應套路中加入掮客, 是一個很明智的選擇, 這就是第二種思路, 這種思路不是沒有缺陷, 有, 而且很明顯: 掮客是整個系統中最脆弱的部分.

但這個缺陷可以在一定程度上克服掉:

  1. 如果單機掮客轉發能力不夠, 那麼可以搞多個掮客. 比如N個客戶端,M個服務端, 3個掮客. 客戶端與3個掮客建立全連線, 3個掮客與M個服務端建立全連線. 總是要好過N個客戶端與M個服務端建立全連線的.
  2. 如果單機掮客緩衝能力不夠, 甚至可以加多層掮客. 這種使用方法就把掮客的緩衝特性放在了首位.

ZMQ中, 有兩個特殊的socket型別特別適合掮客使用:

  1. ROUTER 用於掮客與多個客戶端連線. 掮客bind, 客戶端connect.
  2. DEALER 用於掮客和多個服務端連線. 掮客bind, 服務端connect.

關於這兩種特殊的socket的特性, 後續我們會仔細深入, 目前來說, 你只需要瞭解

  1. 它們實現了訊息的緩衝
  2. 它們通過一種特殊的機制記錄了會話

多說無益, 來看程式碼. 下面是在客戶端與服務端中插入掮客的程式碼例項:

客戶端

#include <zmq.h>
#include "zmq_helper.h"

int main(void)
{
    void * context = zmq_ctx_new();

    void * socket = zmq_socket(context, ZMQ_REQ);
    zmq_connect(socket, "tcp://localhost:5559");

    for(int i = 0; i < 10; ++i)
    {
        s_send(socket, "Hello");

        char * strRsp = s_recv(socket);

        printf("Received reply %d [%s]\n", i, strRsp);
        free(strRsp);
    }

    zmq_close(socket);
    zmq_ctx_destroy(context);

    return 0;
}

服務端

#include <zmq.h>
#include <unistd.h>
#include "zmq_helper.h"

int main(void)
{
    void * context = zmq_ctx_new();

    void * socket = zmq_socket(context, ZMQ_REP);
    zmq_connect(socket, "tcp://localhost:5560");

    while(1)
    {
        char * strReq = s_recv(socket);
        printf("Received request: [%s]\n", strReq);
        free(strReq);

        sleep(1);

        s_send(socket, "World");
    }

    zmq_close(socket);
    zmq_ctx_destroy(context);
    
    return 0;
}

掮客

#include <zmq.h>
#include "zmq_helper.h"

int main(void)
{
    void * context = zmq_ctx_new();
    void * socket_for_client = zmq_socket(context, ZMQ_ROUTER);
    void * socket_for_server = zmq_socket(context, ZMQ_DEALER);
    zmq_bind(socket_for_client, "tcp://*:5559");
    zmq_bind(socket_for_server, "tcp://*:5560");

    zmq_pollitem_t items[] = {
        {   socket_for_client,  0,  ZMQ_POLLIN, 0   },
        {   socket_for_server,  0,  ZMQ_POLLIN, 0   },
    };

    while(1)
    {
        zmq_msg_t message;
        zmq_poll(items, 2, -1);

        if(items[0].revents & ZMQ_POLLIN)
        {
            while(1)
            {
                zmq_msg_init(&message);
                zmq_msg_recv(&message, socket_for_client, 0);
                int more = zmq_msg_more(&message);
                zmq_msg_send(&message, socket_for_server, more ? ZMQ_SNDMORE : 0);
                zmq_msg_close(&message);

                if(!more)
                {
                    break;
                }
            }
        }

        if(items[1].revents & ZMQ_POLLIN)
        {
            while(1)
            {
                zmq_msg_init(&message);
                zmq_msg_recv(&message, socket_for_server, 0);
                int more = zmq_msg_more(&message);
                zmq_msg_send(&message, socket_for_client, more ? ZMQ_SNDMORE : 0);
                zmq_msg_close(&message);

                if(!more)
                {
                    break;
                }
            }
        }
    }

    zmq_close(socket_for_client);
    zmq_close(socket_for_server);
    zmq_ctx_destroy(context);

    return 0;
}

客戶端和服務端由於掮客的存在, 程式碼都簡單了不少, 對於掮客的程式碼, 有以下幾點需要思考:

  1. 為什麼客戶端和服務端雙方在程式碼中以s_sends_recv互相傳遞字串, 但在掮客那裡就需要用zmq_msg_t進行轉發呢?
  2. 為什麼掮客在轉發訊息的時候, 還需要判斷是否是多幀訊息呢?
  3. 更進一步的, 如果有多個客戶端與多個服務端, 客戶端A向掮客傳送請求, 掮客將其轉發到了服務端B, 然後B回包, 發向掮客, 當回包訊息到達掮客時, 掮客是如何將回包訊息正確投遞給A, 而不是其它客戶端的呢?

上面三點其實是同一個問題: 掮客是如何實現帶會話追蹤的轉發訊息的?

另外, 如果你先啟動掮客, 再啟動客戶端, 再啟動服務端. 你會看到在服務端正確啟動後, 客戶端顯示它收到了回包.那麼:

  1. 在服務端未啟動時, 顯然在客戶端的角度來講, 客戶端已經將第一個請求投遞給了掮客. 如果此時有1000個客戶端與掮客相連, 1000個首請求訊息是如何儲存的? 10000個呢? 什麼時候掮客會丟棄請求?

這就是有關掮客的第二個問題: 如何配置緩衝區.

本章目前暫時不會對這三個問題做出解答, 大家先思考一下. 我們將在下一章深入掮客的細節進行進一步探索.

ZMQ內建的掮客函式

在上面的掮客程式碼示例中, 核心程式碼就是zmq_poll對兩個socket的監聽, 以及while(1)迴圈. ZMQ將這兩坨操作統一封裝到了一個函式中, 省得大家每次都要寫boring code.

int zmq_proxy (const void *frontend, const void *backend, const void *capture);

引數frontendbackend分別是與客戶端相連的socket與服務端相連的socket. 在使用zmq_proxy函式之前, 這兩個socket必須被正確配置好, 該呼叫connect就呼叫connect, 該呼叫bind就呼叫bind. 簡單來講, zmq_proxy負責把frontendbackend之間的資料互相遞送給對方. 而如果僅僅是單純的遞送的話, 第三個引數capture就應當被置為NULL, 而如果還想監聽一下資料, 那麼就再建立一個socket, 並將其值傳遞給capture, 這樣, frontendbackend之間的資料都會有一份拷貝被送到capture上的socket.

當我們用zmq_proxy重寫上面的掮客程式碼的話, 程式碼會非常簡潔, 會變成這樣:

#include <zmq.h>
#include "zmq_helper.h"

int main(void)
{
    void * context = zmq_ctx_new();
    void * socket_for_client = zmq_socket(context, ZMQ_ROUTER);
    void * socket_for_server = zmq_socket(context, ZMQ_DEALER);
    zmq_bind(socket_for_client, "tcp://*:5559");
    zmq_bind(socket_for_server, "tcp://*:5560");

    zmq_proxy(socket_for_client, socket_for_server, NULL);

    zmq_close(socket_for_client);
    zmq_close(socket_for_server);
    zmq_ctx_destroy(context);

    return 0;
}

橋接技巧

橋接是伺服器後端的一種常用技巧. 所謂的橋接有點類似於掮客, 但是解決問題的側重點不一樣. 掮客主要解決了三個問題:

  1. 降低網路連線數量. 從N*M降低到 (N+M)*X
  2. 向客戶端與服務端遮蔽彼此的具體實現, 隱藏彼此的具體細節.
  3. 緩衝

而橋接解決的問題的側重點主要在:

  1. 向通訊的一方, 遮蔽另一方的具體實現.

這種設計思路常用於後臺服務的介面層. 介面層一方面連線著後端內部區域網, 另外一方面對公提供服務. 這種服務可以是請求-迴應式的服務, 也可以是釋出-訂閱式的服務(顯然釋出方在後端內部的區域網裡). 這個時候介面層其實就完成了橋接的工作.

其實這種應用場景裡, 把這種技巧稱為橋接並不是很合適. 因為橋接是一個計算機網路中硬體層的術語, 最初是用於線纜過長訊號衰減時, 線上纜末端再加一個訊號放大器之類的裝置, 為通訊續命用的.

原版ZMQ文件在這裡提出bridging這個術語, 也只是為了說明一下, zmq_proxy的適用場景不僅侷限於做掮客, 而是應該在理解上更寬泛一點, zmq_proxy函式就是互相傳遞兩個socket之間資料函式, 僅此而已, 而具體這個函式能應用在什麼樣的場景下, 掮客與橋接場景均可以使用, 但絕不侷限於此. 寫程式碼思維要活.

妥善的處理錯誤

ZMQ庫對待錯誤, 或者叫異常, 的設計哲學是: 見光死. 前文中寫的多數示例程式碼, 都沒有認真的檢查ZMQ庫函式呼叫的返回值, 也沒有關心它們執行失敗後會發生什麼. 一般情況下, 這些函式都能正常工作, 但凡事總有個萬一, 萬一建立socket失敗了, 萬一bind或connect呼叫失敗了, 會發生什麼?

按照見光死的字面意思: 按我們上面寫程式碼的風格, 一旦出錯, 程式就掛掉退出了.

所以正確使用ZMQ庫的姿勢是: 生產環境執行的程式碼, 務必為每一個ZMQ庫函式的呼叫檢查返回值, 考慮呼叫失敗的情況. ZMQ庫函式的設計也繼續了POSIX介面風格里的一些設計, 這些設計包括:

  1. 建立物件的介面, 在失敗時一般返回NULL
  2. 處理資料的介面, 正常情況下將返回處理的資料的位元組數. 失敗情況下將返回-1
  3. 其它一般性的函式, 成功時返回0, 失敗時返回-1
  4. 當呼叫失敗發生時, 具體的錯誤碼存放在errno中, 或zmq_errno()
  5. 有關錯誤的詳情描述資訊, 通過zmq_strerror()可能獲得

真正健壯的程式碼, 應該像下面這樣寫, 是的, 它很囉嗦, 但它很健壯:

// ...
void * context = zmq_ctx_new();
assert(context);
void * socket = zmq_socket(context, ZMQ_REP);
assert(socket);
int rc = zmq_bind(socket, "tcp://*:5555");
if(rc == -1)
{
    printf("E: bind failed: %s\n", strerror(errno));
    return -1;
}
// ...

有兩個比較例外的情況需要你注意一下:

  1. 處理ZMQ_DONTWAIT的函式返回-1時, 一般情況下不是一個致命錯誤, 不應當導致程式退出. 比如在收包函式裡帶上這個標誌, 那麼語義只是說"沒資料可收", 是的, 收包函式會返回-1, 並且會置error值為EAGAIN, 但這並不代表程式發生了不可逆轉的錯誤.
  2. 當一個執行緒呼叫zmq_ctx_destroy()時, 如果此時有其它執行緒在忙, 比如在寫資料或者收資料什麼的, 那麼這會直接導致這些在幹活的執行緒, 呼叫的這些阻塞式介面函式返回-1, 並且errno被置為ETERM. 這種情況在實際編碼過程中不應當出現.

下面我們寫一個健壯的分治套路, 和我們在第一章中寫過的類似, 不同的是, 這次, 在監理收到"所有工作均完成"的訊息之後, 會發訊息給各個工程隊, 讓工程隊停止執行. 這個例子主要有兩個目的:

  1. 向大家展示, 在使用ZMQ庫的同時, 把程式碼寫健壯
  2. 向大家展示如何優雅的幹掉一個程式

原先的分治套路程式碼, 使用PUSH/PULL這兩種socket型別, 將任務分發給多個工程隊. 但在工作做完之後, 工程隊的程式還在執行, 工程隊的程式無法得知任務什麼進修終止. 這裡我們再摻入釋出-訂閱套路, 在工作做完之後, 監理向廣大工程隊, 通過PUB型別的socket傳送"活幹活了"的訊息, 而工程隊用SUB型別的socket一旦收到監理的訊息, 就停止執行.

包工頭ventilator的程式碼和上一章的一毛一樣, 只是對所有的ZMQ庫函式呼叫增加了錯誤處理. 照顧大家, 這裡再帖一遍

#include <zmq.h>
#include <stdio.h>
#include <time.h>
#include <assert.h>
#include "zmq_helper.h"

int main(void)
{
    void * context = zmq_ctx_new();
    assert(context);
    void * socket_to_sink = zmq_socket(context, ZMQ_PUSH);
    assert(socket_to_sink);
    void * socket_to_worker = zmq_socket(context, ZMQ_PUSH);
    assert(socket_to_worker);
    if(zmq_connect(socket_to_sink, "tcp://localhost:5558") == -1)
    {
        printf("E: connect failed: %s\n", strerror(errno));
        return -1;
    }
    if(zmq_bind(socket_to_worker, "tcp://*:5557") == -1)
    {
        printf("E: bind failed: %s\n", strerror(errno));
        return -1;
    }

    printf("Press Enter when all workers get ready:");
    getchar();
    printf("Sending tasks to workers...\n");

    if(s_send(socket_to_sink, "Get ur ass up") == -1)
    {
        printf("E: s_send failed: %s\n", strerror(errno));
        return -1;
    }

    srandom((unsigned)time(NULL));

    int total_ms = 0;
    for(int i = 0; i < 100; ++i)
    {
        int workload = randof(100) + 1;
        total_ms += workload;
        char string[10];
        snprintf(string, sizeof(string), "%d", workload);
        if(s_send(socket_to_worker, string) == -1)
        {
            printf("E: s_send failed: %s\n", strerror(errno));
            return -1;
        }
    }

    printf("Total expected cost: %d ms\n", total_ms);

    zmq_close(socket_to_sink);
    zmq_close(socket_to_worker);
    zmq_ctx_destroy(context);

    return 0;
}

接下來是工程隊worker的程式碼, 這一版新增了一個socket_to_sink_of_control來接收來自監理的停止訊息:

#include <zmq.h>
#include <assert.h>
#include "zmq_helper.h"

int main(void)
{
    void * context = zmq_ctx_new();
    assert(context);
    void * socket_to_ventilator = zmq_socket(context, ZMQ_PULL);
    assert(socket_to_ventilator);
    if(zmq_connect(socket_to_ventilator, "tcp://localhost:5557") == -1)
    {
        printf("E: connect failed: %s\n", strerror(errno));
        return -1;
    }

    void * socket_to_sink = zmq_socket(context, ZMQ_PUSH);
    assert(socket_to_sink);
    if(zmq_connect(socket_to_sink, "tcp://localhost:5558") == -1)
    {
        printf("E: connect failed: %s\n", strerror(errno));
        return -1;
    }

    void * socket_to_sink_of_control = zmq_socket(context, ZMQ_SUB);
    assert(socket_to_sink_of_control);
    if(zmq_connect(socket_to_sink_of_control, "tcp://localhost:5559") == -1)
    {
        printf("E: connect failed: %s\n", strerror(errno));
        return -1;
    }
    if(zmq_setsockopt(socket_to_sink_of_control, ZMQ_SUBSCRIBE, "", 0) == -1)
    {
        printf("E: setsockopt failed: %s\n", strerror(errno));
    }

    zmq_pollitem_t items [] = {
        {   socket_to_ventilator,   0,  ZMQ_POLLIN, 0   },
        {   socket_to_sink_of_control,  0,  ZMQ_POLLIN, 0   },
    };

    while(1)
    {
        if(zmq_poll(items, 2, -1) == -1)
        {
            printf("E: poll failed: %s\n", strerror(errno));
            return -1;
        }

        if(items[0].revents & ZMQ_POLLIN)
        {
            char * strWork = s_recv(socket_to_ventilator);
            assert(strWork);
            printf("%s.", strWork);
            fflush(stdout);
            s_sleep(atoi(strWork));
            free(strWork);
            if(s_send(socket_to_sink, "") == -1)
            {
                printf("E: s_send failed %s\n", strerror(errno));
                return -1;
            }
        }

        if(items[1].revents & ZMQ_POLLIN)
        {
            break;
        }
    }

    zmq_close(socket_to_ventilator);
    zmq_close(socket_to_sink);
    zmq_close(socket_to_sink_of_control);
    zmq_ctx_destroy(context);

    return 0;
}

接下來是監理的程式碼, 這一版新增了socket_to_worker_of_control來在任務結束之後給工程隊釋出停止訊息:

#include <zmq.h>
#include <assert.h>
#include <stdint.h>
#include "zmq_helper.h"

int main(void)
{
    void * context = zmq_ctx_new();
    assert(context);

    void * socket_to_worker = zmq_socket(context, ZMQ_PULL);
    if(zmq_bind(socket_to_worker, "tcp://*:5558") == -1)
    {
        printf("E: bind failed: %s\n", strerror(errno));
        return -1;
    }

    void * socket_to_worker_of_control = zmq_socket(context, ZMQ_PUB);
    if(zmq_bind(socket_to_worker_of_control, "tcp://*:5559") == -1)
    {
        printf("E: bind failed: %s\n", strerror(errno));
        return -1;
    }

    char * strBeginMsg = s_recv(socket_to_worker);
    assert(strBeginMsg);
    free(strBeginMsg);

    int64_t i64StartTime = s_clock();

    for(int i = 0; i < 100; ++i)
    {
        char * strRes = s_recv(socket_to_worker);
        assert(strRes);
        free(strRes);

        if(i % 10 == 0)
        {
            printf(":");
        }
        else
        {
            printf(".");
        }

        fflush(stdout);
    }

    printf("Total elapsed time: %d msec\n", (int)(s_clock() - i64StartTime));

    if(s_send(socket_to_worker_of_control, "STOP") == -1)
    {
        printf("E: s_send failed: %s\n", strerror(errno));
        return -1;
    }

    zmq_close(socket_to_worker);
    zmq_close(socket_to_worker_of_control);
    zmq_ctx_destroy(context);

    return 0;
}

這個例子也展示瞭如何將多種套路揉合在一個場景中. 所以說寫程式碼, 思維要靈活.

處理POSIX Signal

一般情況下, Linux上的程式在接收到諸如SIGINTSIGTERM這樣的訊號時, 其預設動作是讓程式退出. 這種退出訊號的預設行為, 只是簡單的把程式幹掉, 不會管什麼緩衝區有沒有正確重新整理, 也不會管檔案以及其它資源控制程式碼是不是正確被釋放了.

這對於實際應用場景中的程式來說是不可接受的, 所以在編寫後臺應用的時候一定要注意這一點: 要妥善的處理POSIX Signal. 限於篇幅, 這裡不會對Signal進行進一步討論, 如果對這部分內容不是很熟悉的話, 請參閱<Unix環境高階程式設計>(<Advanced Programming in the UNIX Environment>)第十章(chapter 10. Signals).

下面是妥善處理Signal的一個例子

#include <stdlib.h>
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <assert.h>
#include <string.h>

#include <zmq.h>

#define S_NOTIFY_MSG    " "
#define S_ERROR_MSG     "Error while writing to self-pipe.\n"

static int s_fd;

static void s_signal_handler(int signal_value)
{
    int rc = write(s_fd, S_NOTIFY_MSG, sizeof(S_NOTIFY_MSG));
    if(rc != sizeof(S_NOTIFY_MSG))
    {
        write(STDOUT_FILENO, S_ERROR_MSG, sizeof(S_ERROR_MSG) - 1);
        exit(1);
    }
}

static void s_catch_signals(int fd)
{
    s_fd = fd;

    struct sigaction action;
    action.sa_handler = s_signal_handler;

    action.sa_flags = 0;
    sigemptyset(&action.sa_mask);
    sigaction(SIGINT, &action, NULL);
    sigaction(SIGTERM, &action, NULL);
}

int main(void)
{
    int rc;

    void * context = zmq_ctx_new();
    assert(context);
    void * socket = zmq_socket(context, ZMQ_REP);
    assert(socket);

    if(zmq_bind(socket, "tcp://*:5555") == -1)
    {
        printf("E: bind failed: %s\n", strerror(errno));
        return -__LINE__;
    }

    int pipefds[2];
    rc = pipe(pipefds);
    if(rc != 0)
    {
        printf("E: creating self-pipe failed: %s\n", strerror(errno));
        return -__LINE__;
    }

    for(int i = 0; i < 2; ++i)
    {
        int flags = fcntl(pipefds[0], F_GETFL, 0);
        if(flags < 0)
        {
            printf("E: fcntl(F_GETFL) failed: %s\n", strerror(errno));
            return -__LINE__;
        }

        rc = fcntl(pipefds[0], F_SETFL, flags | O_NONBLOCK);
        if(rc != 0)
        {
            printf("E: fcntl(F_SETFL) failed: %s\n", strerror(errno));
            return -__LINE__;
        }
    }

    s_catch_signals(pipefds[1]);

    zmq_pollitem_t items[] = {
        {   0,      pipefds[0],     ZMQ_POLLIN,     0   },
        {   socket, 0,              ZMQ_POLLIN,     0   },
    };

    while(1)
    {
        rc = zmq_poll(items, 2, -1);
        if(rc == 0)
        {
            continue;
        }
        else if(rc < 0)
        {
            if(errno == EINTR)
            {
                continue;
            }
            else
            {
                printf("E: zmq_poll failed: %s\n", strerror(errno));
                return -__LINE__;
            }
        }

        // Signal pipe FD
        if(items[0].revents & ZMQ_POLLIN)
        {
            char buffer[2];
            read(pipefds[0], buffer, 2);    // clear notifying bytes
            printf("W: interrupt received, killing server...\n");
            break;
        }

        // Read socket
        if(items[1].revents & ZMQ_POLLIN)
        {
            char buffer[255];
            rc = zmq_recv(socket, buffer, 255, ZMQ_NOBLOCK);
            if(rc < 0)
            {
                if(errno == EAGAIN)
                {
                    continue;
                }

                if(errno == EINTR)
                {
                    continue;
                }

                printf("E: zmq_recv failed: %s\n", strerror(errno));

                return -__LINE__;
            }

            printf("W: recv\n");
            // Now send message back;
            // ...
        }
    }

    printf("W: cleaning up\n");
    zmq_close(socket);
    zmq_ctx_destroy(context);

    return 0;
}

上面這個程式的邏輯流程是這樣的:

  1. 首先這是一個典型的服務端應用程式. 先建立了一個型別為ZMQ_REP的zmq socket, 並將之bind在本地5555埠上
  2. 然後程式建立了一個管道, 並將管道0(寫端)置為非阻塞模式
  3. 然後程式為訊號SIGINTSIGTERM掛載了自定義的訊號處理函式, 訊號處理函式做的事如下:

    1. 向管道1(寫端)寫入字串" "
    2. 若寫入失敗, 則向標準輸出寫入錯誤字串"Err while writing to self-pipe"並呼叫exit()退出程式
  4. 然後將zmq socket與管道1讀端均加入zmq_poll

    1. 在zmq socket收到請求時, 正常處理請求
    2. 在管道1收到資料時, 說明接收到了SIGINTSIGTERM訊號, 則退出資料處理迴圈, 之後將依次呼叫zmq_close()zmq_ctx_destroy()

這種寫法使用了管道, 邏輯上清晰了, 程式碼上繁瑣了, 但這都不是重點, 重點是這個版本的服務端程式在接收到SIGINTSIGTERM時, 雖然也會退出程式, 但在退出之前會妥善的關閉掉zmq socket與zmq context.

而還有一種更簡潔的寫法(這種簡潔的寫在其實是有潛在的漏洞的, 詳情請參見<Unix環境高階程式設計>(<Advanced Programming in the UNIX Environment>) 第十章(chapter 10. Signals) )

  1. 定義一個全域性變數 s_interrupted
  2. 定義一個訊號處理函式, 該訊號處理函式在接收到諸如SIGINT之類的訊號時, 置s_interrupted為1
  3. 在業務處理邏輯中, 判斷全域性變數s_interrupted的值, 若該值為1, 則進入退出流程

大致如下:

s_catch_signals();      // 註冊事件回撥
client = zmq_socket(...);
while(!s_interrupted)   // 時刻檢查 s_interrupted 的值
{
    char * message = s_recv(client);
    if(!message)
    {
        break;          // 接收訊息異常時也退出
    }

    // 處理業務邏輯
}
zmq_close(close);

避免記憶體洩漏

服務端應用程式最蛋疼的問題就是記憶體洩漏了, 這個問題已經困擾了C/C++程式設計師二三十年了, ZMQ在這裡建議你使用工具去檢測你的程式碼是否有記憶體洩漏的風險. 這裡建議你使用的工具是: valgrind

預設情況下, ZMQ本身會導致valgrind報一大堆的警告, 首先先遮蔽掉這些警告. 在你的工程目錄下新建一個檔名為 vg.supp, 寫入下面的內容

{
    <socketcall_sendto>
    Memcheck:Param
    socketcall.sendto(msg)
    fun:send
    ...
}
{
    <socketcall_sendto>
    Memcheck:Param
    socketcall.send(msg)
    fun:send
}

然後記得妥善處理掉諸如SIGINTSIGTERM這樣的Signal. 否則valgrind會認為不正確的退出程式會有記憶體洩漏風險. 最後, 在編譯你的程式時, 加上 -DDEBUG 選項. 然後如下執行valgrind

valgrind --tool=memcheck --leak-check=full --suppression=vg.supp <你的程式>

如果你的程式碼寫的沒有什麼問題, 會得到下面這樣的讚賞

==30536== ERROR SUMMARY: 0 errors from 0 contexts...

在多執行緒環境使用 ZMQ

啊, 多執行緒, 給大家講一個笑話, 小明有一個問題, 然後小明決定使用多執行緒程式設計解決這個問題. 最後小明問題兩個了有.

傳統的多執行緒程式設計中, 或多或少都會摻入同步手段. 而這此同步手段一般都是程式設計師的噩夢, 訊號量, 鎖. ZMQ則告誡廣大程式設計師: 不要使用訊號量, 也不要使用鎖, 不要使用除了 zmq inproc之外的任何手段進行執行緒間的資料互動.

ZMQ在多執行緒上的哲學是這樣的:

  1. 多執行緒應該以並行優勢提高程式執行效率
  2. 避免執行緒同步. 如果你的多執行緒程式需要大量的程式碼來完成執行緒同步, 那麼一定是你的程式設計有問題.
  3. 如果非得同步, 那麼不要使用鎖或訊號量. 而使用 zmq inproc socket 來線上程間傳遞資訊
  4. 良好的多執行緒程式設計, 應當很容易的將其改造成多程式服務, 更進一步, 應該很容易的拆分程式以部署在不同的機器結點上.
  5. 總的來說, 以多程式的設計思路去設計多執行緒程式, 核心哲學是避免執行緒同步.

更細節的, 在進行多執行緒程式設計時, 你應當遵循以下的幾個點:

  1. 將資料進行獨立拆分, 每個執行緒只訪問自己的私有資料, 避免多執行緒共享資料. 除了一個例外: zmq的context例項
  2. 避免使用傳統的執行緒同步手段: 訊號量, 臨界區, 鎖. 上面已經強調過了, 不要使用這些手段.
  3. 在程式一開始處, 未建立執行緒時, 建立context例項, 隨後將這個context例項共享給所有執行緒
  4. 如果父執行緒需要建立資料例項, 那麼開attached執行緒建立程式中要使用的資料例項. 然後通過inproc pair socket將資料例項回傳. 父執行緒bind, 子執行緒connect
  5. 如果父執行緒需要並行的子執行緒來處理業務. 那麼開detached執行緒來跑業務, 並在各子執行緒中為各個子執行緒建立自己獨享的context. 父子執行緒使用tcp socket進行通訊. 這樣你的程式就會很容易的擴充套件成多程式服務, 而不需要改動過多的程式碼.
  6. 執行緒間的資料互動一律使用zmq socket傳遞訊息.
  7. 不要線上程間傳遞socket控制程式碼. zmq socket例項不是執行緒安全的. 從本質上講線上程間傳遞socket控制程式碼是可行的, 但這要建立在經驗豐富的基礎上, 否則只會讓事情更大條. 線上程間傳遞socket例項一般情況下只發生在zmq庫的其它程式語言的binding庫上, 一般也是用於帶GC的語言去處理自動物件回收. 這種技巧不應該出現在zmq的使用者身上.

如果你程式要用到多個掮客, 比如, 多個執行緒都擁有自己獨立的掮客, 一個常見的錯誤就是: 在A執行緒裡建立掮客的左右兩端socket, 然後將socket傳遞給B執行緒裡的掮客. 這違反了上面的規則: 不要線上程間傳遞socket. 這種錯誤很難發覺, 並且出錯是隨機的, 出現問題後很難排查.

ZMQ對執行緒庫是沒有侵入性的, ZMQ沒有內建執行緒庫, 也沒有使用其它的執行緒例項. 使用ZMQ寫多執行緒應用程式, 多執行緒介面就是作業系統操作的執行緒介面. 所以它對執行緒相關的檢查工具是友好的: 比如Intel的Thread Checker. 這種設計的壞處就是你寫的程式線上程介面這一層的可移植性需要你自己去保證. 或者你需要使用其它第三方的可移植執行緒庫.

這裡我們寫一個例子吧, 我們把最初的請求-迴應套路程式碼改造成多執行緒版的. 原始版的服務端是單程式單執行緒程式, 如果請求量比較低的話, 是沒有什麼問題的, 單執行緒的ZMQ應用程式吃滿一個CPU核心是沒有問題的, 但請求量再漲就有點捉襟見肘了, 這個時候就需要讓程式吃滿多個核心. 當然多程式服務也能完成任務, 但這裡主要是為了介紹在多執行緒程式設計中使用ZMQ, 所以我們把服務端改造成多執行緒模式.

另外, 顯然你可以使用一個掮客, 再外加一堆服務端結點(無論結點是獨立的程式, 還是獨立的機器)來讓服務端的處理能力更上一層樓. 但這更跑偏了.

還是看程式碼吧. 服務端程式碼如下:

#include <pthread.h>
#include <unistd.h>
#include <assert.h>
#include "zmq_helper.h"

static void * worker_routine(void * context)
{
    void * socket_to_main_thread = zmq_socket(context, ZMQ_REP);
    assert(socket_to_main_thread);
    zmq_connect(socket_to_main_thread, "inproc://workers");

    while(1)
    {
        char * strReq = s_recv(socket_to_main_thread);
        printf("Received request: [%s]\n", strReq);
        free(strReq);
        sleep(1);
        s_send(socket_to_main_thread, "World");
    }

    zmq_close(socket_to_main_thread);
    return NULL;
}

int main(void)
{
    void * context = zmq_ctx_new();
    assert(context);

    void * socket_to_client = zmq_socket(context, ZMQ_ROUTER);
    assert(socket_to_client);
    zmq_bind(socket_to_client, "tcp://*:5555");

    void * socket_to_worker_thread = zmq_socket(context, ZMQ_DEALER);
    assert(socket_to_worker_thread);
    zmq_bind(socket_to_worker_thread, "inproc://workers");

    for(int i = 0; i < 5; ++i)
    {
        pthread_t worker;
        pthread_create(&worker, NULL, worker_routine, context);
    }

    zmq_proxy(socket_to_client, socket_to_worker_thread, NULL);

    zmq_close(socket_to_client);
    zmq_close(socket_to_worker_thread);
    zmq_ctx_destroy(context);

    return 0;
}

這就是一個很正統的設計思路, 多個執行緒之間是互相獨立的, worker執行緒本身很容易能改造成獨立的程式, 主執行緒做掮客.

使用 PAIR socket 進行執行緒間通訊

來, 下面就是一個例子, 使用PAIR socket完成執行緒同步, 內部通訊使用的是inproc

#include <zmq.h>
#include <pthread.h>
#include "zmq_helper.h"

static void * thread1_routine(void * context)
{
    printf("thread 1 start\n");
    void * socket_to_thread2 = zmq_socket(context, ZMQ_PAIR);
    zmq_connect(socket_to_thread2, "inproc://thread_1_2");

    printf("thread 1 ready, send signal to thread 2\n");

    s_send(socket_to_thread2, "READY");

    zmq_close(socket_to_thread2);
    printf("thread 1 end\n");
    return NULL;
}

static void * thread2_routine(void * context)
{
    printf("thread 2 start\n");
    void * socket_to_thread1 = zmq_socket(context, ZMQ_PAIR);
    zmq_bind(socket_to_thread1, "inproc://thread_1_2");
    pthread_t thread1;
    pthread_create(&thread1, NULL, thread1_routine, context);

    char * str = s_recv(socket_to_thread1);
    free(str);
    zmq_close(socket_to_thread1);

    void * socket_to_mainthread = zmq_socket(context, ZMQ_PAIR);
    zmq_connect(socket_to_mainthread, "inproc://thread_2_main");
    printf("thread 2 ready, send signal to main thread\n");
    s_send(socket_to_mainthread, "READY");

    zmq_close(socket_to_mainthread);
    printf("thread 2 end\n");
    return NULL;
}

int main(void)
{
    printf("main thread start\n");
    void * context = zmq_ctx_new();

    void * socket_to_thread2 = zmq_socket(context, ZMQ_PAIR);
    zmq_bind(socket_to_thread2, "inproc://thread_2_main");
    pthread_t thread2;
    pthread_create(&thread2, NULL, thread2_routine, context);

    char * str = s_recv(socket_to_thread2);
    free(str);
    zmq_close(socket_to_thread2);

    printf("Test over\n");
    zmq_ctx_destroy(context);
    printf("main thread end\n");
    return 0;

}

這個簡單的程式包含了幾個編寫多執行緒同步時的潛規則:

  1. 執行緒間同步使用 inproc PAIR 型的socket. 共享context
  2. 父執行緒bind, 子執行緒connect

需要注意的是, 上面這種寫法的多執行緒, 很難拆成多個程式, 上面這種寫法一般用於壓根就不準備拆分的服務端應用程式. inproc很快, 效能很好, 但是不能用於多程式或多結點通訊.

另外一種常見的設計就是使用tcp來傳遞同步資訊. 使用tcp使得多執行緒拆分成多程式成為一種可能. 另外一種同步場景就是使用釋出-訂閱套路. 而不使用PAIR. 甚至可以使用掮客使用的ROUTER/DEALER進行同步. 但需要注意下面幾點:

  1. 在使用PUSH/PULL做同步時, 需要注意: PUSH會把訊息廣播給所有PULL.注意這一點, 不要把同步訊息發給其它執行緒
  2. 在使用ROUTER/DEALER做同步時. 需要注意: ROUTER會把你傳送的訊息裝進一個"信封", 也就是說, 你呼叫zmq_send介面傳送的訊息將變成一個多幀訊息被髮出去. 如果你發的同步訊息不帶語義, 那麼還好, 如果你傳送的訊息帶語義, 那麼請特別小心這一點, 多幀訊息的細節將在第三章進行進一步討論. 而DEALER則會把訊息廣播給所有對端, 這一點和PUSH一樣, 請額外注意. 總之建立在閱讀第三章之前, 不要用ROUTER或DEALER做執行緒同步.
  3. 你還可以使用PUB/SUB來做執行緒同步. PUB/SUB不會封裝你傳送的訊息, 你發啥就是啥, 但你需要每次為SUB端通過zmq_setsockopt設定過濾器, 否則SUB端收不到任何訊息, 這一點很煩.

所以總的來說, 用PAIR是最方便的選擇.

不同機器結點間的同步

當你需要同步, 或者協調的兩個結點位於兩個不同的機器上時, PAIR就不那麼好用了, 直接原因就是: PAIR不支援斷線重連. 在同一臺機器上, 多個程式之間同步, 沒問題, 多個執行緒之間同步, 也沒問題. 因為單機內建立起的通訊連線基本不可能發生意外中斷, 而一旦發生中斷, 一定是程式掛了, 這個時候麻煩事是程式為什麼掛了, 而不是通訊連線為什麼掛了.

但是在跨機器的結點間進行同步, 就需要考慮到網路波動的原因了. 結點本身上執行的服務可能沒什麼問題, 但就是網線被剪了, 這種情況下使用PAIR就不再合適了, 你就必須使用其它socket型別了.

另外, 執行緒同步與跨機器結點同步之間的另外一個重大區別是: 執行緒數量一般是固定的, 服務穩定執行期間, 執行緒數目一般不會增加, 也不會減少. 但跨機器結點可能會橫向擴容. 所以要考慮的事情就又我了一坨.

我們下面會給出一個示例程式, 向你展示跨機器結點之間的同步到底應該怎麼做. 還記得上一章我們講釋出-訂閱套路的時候, 提到的, 在訂閱方建立連線的那段短暫的時間內, 所有釋出方釋出的訊息都會被丟棄嗎? 這裡我們將改進那個程式, 在下面改進版的釋出-訂閱套路中, 釋出方會等待所有訂閱方都建立連線完成後, 才開始廣播訊息. 下面將要展示的程式碼主要做了以下的工作:

  1. PUB方提前知道SUB方的數量
  2. PUB方啟動, 等待SUB方連線, 傳送就緒資訊.
  3. 當所有SUB方連線完畢後, 開始工作.
  4. 而同步工作是由REQ/REP完成的.

來看程式碼:

釋出方程式碼:

#include <zmq.h>
#include "zmq_helper.h"

#define SUBSCRIBER_EXPECTED 10

int main(void)
{
    void * context = zmq_ctx_new();

    void * socket_for_pub = zmq_socket(context, ZMQ_PUB);

    int sndhwm = 1100000;
    zmq_setsockopt(socket_for_pub, ZMQ_SNDHWM, &sndhwm, sizeof(int));

    zmq_bind(socket_for_pub, "tcp://*:5561");

    void * socket_for_sync = zmq_socket(context, ZMQ_REP);
    zmq_bind(socket_for_sync, "tcp://*:5562");

    printf("waiting for subscribers\n");
    int subscribers_count = 0;
    while(subscribers_count < SUBSCRIBER_EXPECTED)
    {
        char * str = s_recv(socket_for_sync);
        free(str);

        s_send(socket_for_sync, "");
        subscribers_count++;
    }

    printf("broadingcasting messages\n");
    for(int i = 0; i < 1000000; ++i)
    {
        s_send(socket_for_pub, "Lalalal");
    }

    s_send(socket_for_pub, "END");

    zmq_close(socket_for_pub);
    zmq_close(socket_for_sync);
    zmq_ctx_destroy(context);

    return 0;
}

訂閱方程式碼

#include <zmq.h>
#include <unistd.h>
#include "zmq_helper.h"


int main(void)
{
    void * context = zmq_ctx_new();

    void * socket_for_sub = zmq_socket(context, ZMQ_SUB);
    zmq_connect(socket_for_sub, "tcp://localhost:5561");
    zmq_setsockopt(socket_for_sub, ZMQ_SUBSCRIBE, "", 0);

    sleep(1);

    void * socket_for_sync = zmq_socket(context, ZMQ_REQ);
    zmq_connect(socket_for_sync, "tcp://localhost:5562");

    s_send(socket_for_sync, "");

    char * str = s_recv(socket_for_sync);
    free(str);

    int i = 0;
    while(1)
    {
        char * str = s_recv(socket_for_sub);
        if(strcmp(str, "END") == 0)
        {
            free(str);
            break;
        }

        free(str);
        i++;
    }

    printf("Received %d broadcast message\n", i);

    zmq_close(socket_for_sub);
    zmq_close(socket_for_sync);
    zmq_ctx_destroy(context);

    return 0;
}

最後帶一個啟動指令碼:

#! /bin/bash

echo "Starting subscribers..."

for((a=0; a<10; a++)); do
    ./subscriber &
done

echo "Starting publisher..."
./publisher

執行啟動指令碼之後, 你大概會得到類似於下面的結果:

Starting subscribers...
Starting publisher...
waiting for subscribers
broadingcasting messages
Received 1000000 broadcast message
Received 1000000 broadcast message
Received 1000000 broadcast message
Received 1000000 broadcast message
Received 1000000 broadcast message
Received 1000000 broadcast message
Received 1000000 broadcast message
Received 1000000 broadcast message
Received 1000000 broadcast message
Received 1000000 broadcast message

你看, 這次有了同步手段, 每個訂閱者都確實收到了100萬條訊息, 一條不少

上面的程式碼還有一個細節需要你注意一下:

注意到在訂閱者的程式碼中, 有一行sleep(1), 如果去掉這一行, 執行結果可能(很小的概率)不是我們期望的那樣. 之所以這樣做是因為:

先建立用於接收訊息的socket_for_sub, 然後connect之. 再去做同步操作. 有可能: 同步的REQ與REP對話已經完成, 但是socket_for_sub的連線過程還沒有結束. 這個時候還是會丟掉訊息. 也就是說, 這個sleep(1)操作是為了確認: 在同步操作完成之後, 用於釋出-訂閱套路的通訊連線一定建立好了.

零拷貝

接觸過與效能有關的網路程式設計的*nix端後臺開發的同步一定聽說這這樣的一個術語: 零拷貝(Zero-Copy). 你仔細回想我們通過網路程式設計接收, 傳送訊息的過程. 如果我們要傳送一個訊息, 我們需要把這個訊息傳遞給傳送相關的介面, 如果我們需要接收一個訊息, 我們需要把我們的緩衝區提供給接收訊息的函式.

這裡就有一個效能痛點, 特別是在接收訊息的時候: 在網路介面API底層, 一定有另外一個緩衝區率先接收了資料, 之後, 你呼叫收包函式, 諸如recv這樣的函式, 將你的緩衝區提供給函式, 然後, 資料需要從事先收到資料的緩衝區, 拷貝至你自己提供給API的緩衝區.

如果我們向更底層追究一點, 會發現網路程式設計中, 最簡單的發收訊息模型裡, 至少存在著兩到三次拷貝, 不光收包的過程中有, 發包也有. 上面講到的只是離應用開發者最近的一層發生的拷貝動作. 而實際上, 可能發生拷貝的地方有: 應用程式與API互動層, API與協議棧互動層, 協議棧/核心空間互動層, 等等.

對於更深層次來講, 不是我們應用程式開發者應該關心的地方, 並且時至今日, 從協議棧到離我們最近的那一層, 作業系統基本上都做了避免拷貝的優化. 那麼, ZMQ作為一個網路庫, 在使用的進修, 應用程式開發就應當避免離我們最近的那一次拷貝.

這也是為什麼ZMQ庫除了zmq_sendzmq_recv之外, 又配套zmq_msg_t型別再提供了zmq_msg_sendzmq_msg_recv介面的原因. zmq_msg_t內建了一個緩衝區, 可以用來收發訊息, 當你使用msg系的介面時, 收與發都發生在zmq_msg_t例項的緩衝區中, 不存在拷貝問題.

總之, 要避免拷貝, 需要以下幾步:

  1. 使用zmq_msg_init_data()建立一個zmq_msg_t例項. 介面返回的是zmq_msg_t的控制程式碼. 應用開發者看不到底層實現.
  2. 傳送資料時, 將資料通過memcpy之類的介面寫入zmq_msg_t中, 再傳遞給zmq_msg_send. 接收資料時, 直接將zmq_msg_t控制程式碼傳遞給zmq_msg_recv
  3. 需要注意的是, zmq_msg_t被髮送之後, 其中的資料就自動被釋放了. 也就是, 對於同一個zmq_msg_t控制程式碼, 你不能連續兩次呼叫zmq_msg_send
  4. zmq_msg_t內部使用了引用計數的形式來指向真正儲存資料的緩衝區, 也就是說, zmq_msg_send會將這個計數減一. 當計數為0時, 資料就會被釋放. ZMQ庫對於zmq_msg_t的具體實現並沒有做過多介紹, 也只點到這一層為止.
  5. 所以這時你應該明白, 多個zmq_msg_t是有可能共享同一段二進位制資料的. 這也是zmq_msg_copy做的事情. 如果你需要將同一段二進位制資料傳送多次, 那麼請使用zmq_msg_copy來生成額外的zmq_msg_t控制程式碼. 每次zmq_msg_copy操作都將導致真正的資料的引用計數被+1. 每次zmq_msg_send則減1, 引用計數為0, 資料自動釋放.
  6. 資料釋放其實呼叫的是zmq_msg_close介面. 注意: 在zmq_msg_send被呼叫之後, ZMQ庫自動呼叫了zmq_msg_close, 你可以理解為, 在zmq_msg_send內部, 完成資料傳送後, 自動呼叫了zmq_msg_close
  7. 蛋疼的事在收包上. 由於zmq_msg_t的內部實現是一個黑盒, 所以如果要接收資料, 雖然呼叫zmq_msg_recv的過程中沒有發生拷貝, 但應用程式開發者最終還是需要把資料讀出來. 這就必須有一次拷貝. 這是無法避免的. 或者換一個角度來描述這個蛋疼的點: ZMQ沒有向我們提供真正的零拷貝收包介面. 收包時的拷貝是無可避免的.

最後給大家一個忠告: 拷貝確實是一個後端服務程式的效能問題. 但瓶頸一般不在呼叫網路庫時發生的拷貝, 而在於其它地方的拷貝. zmq_msg_t的使用重心不應該在"優化拷貝, 提升效能"這個點上, 而是第三章要提到和進一步深入講解的多幀訊息.

在釋出-訂閱套路中使用多幀訊息, 即"信封"

之前我們講到的釋出-訂閱套路里, 釋出者廣播的訊息全是字串, 而訂閱者篩選過濾訊息也是按字串匹配前幾個字元, 這種策略有點土. 假如我們能把釋出者廣播的訊息分成兩段: 訊息頭與訊息體. 訊息頭裡寫明資訊型別, 訊息體裡再寫具體的資訊內容. 這樣過濾器直接匹配訊息頭就能決定這個訊息要還是不要, 這就看起來洋氣多了.

ZMQ中使用多幀訊息支援這一點. 釋出者釋出多幀訊息時, 訂閱者的過濾器在匹配時, 只匹配第一幀.

多說無益, 來看例子, 在具體展示釋出者與訂閱者程式碼之前, 需要為我們的zmq_help.h檔案再加一個函式, 用於傳送多帖訊息的s_sendmore

/*
 * 把字串作為位元組資料, 傳送至zmq socket, 但不傳送字串末尾的'\0'位元組
 * 並且通知socket, 後續還有幀要傳送
 * 傳送成功時, 返回傳送的位元組數
 */
static inline int s_sendmore(void * socket, const char * string)
{
    return zmq_send(socket, string, strlen(string), ZMQ_SNDMORE);
}

下面是釋出者的程式碼:

#include <zmq.h>
#include <unistd.h>
#include "zmq_helper.h"

int main(void)
{
    void * context = zmq_ctx_new();
    void * socket = zmq_socket(context, ZMQ_PUB);
    zmq_bind(socket, "tcp://*:5563");

    while(1)
    {
        s_sendmore(socket, "A");
        s_send(socket, "We don't want to see this");
        s_sendmore(socket, "B");
        s_send(socket, "We would like to see this");

        sleep(1);
    }

    zmq_close(socket);
    zmq_ctx_destroy(context);

    return 0;
}

下面是訂閱者的程式碼:

#include <zmq.h>
#include "zmq_helper.h"

int main(void)
{
    void * context = zmq_ctx_new();
    void * socket = zmq_socket(context, ZMQ_SUB);
    zmq_connect(socket, "tcp://localhost:5563");
    zmq_setsockopt(socket, ZMQ_SUBSCRIBE, "B", 1);

    while(1)
    {
        char * strMsgType = s_recv(socket);
        char * strMsgContent = s_recv(socket);

        printf("[%s] %s\n", strMsgType, strMsgContent);

        free(strMsgType);
        free(strMsgContent);
    }

    zmq_close(socket);
    zmq_ctx_destroy(socket);

    return 0;
}

這裡有兩點:

  1. 過濾器過濾的是整個訊息, 第一幀對不上, 後面所有的幀都不要了
  2. ZMQ庫保證, 多幀訊息的傳輸是原子性的. 你不會收到一個缺幀的訊息

高水位閾值

訊息越發越快, 越發越多, 你慢慢的就會意識到一個問題: 記憶體資源很寶貴, 並且很容易被用盡. 如果你不注意到這一點, 伺服器上某個程式阻塞個幾秒鐘, 就炸了.

想象一下這個場景: 在同一臺機器上, 有一個程式A在瘋狂的向程式B傳送訊息. 突然, B覺得很累, 休息了3秒(比如CPU過載, 或者B在跑GC吧, 無所謂什麼原因), 這3秒鐘B處理不過來A傳送的資料了. 那麼在這3秒鐘, A依然瘋狂的試圖向B傳送訊息, 會發生什麼? 如果B有收包緩衝區的話, 這個緩衝區肯定被塞滿了, 如果A有傳送緩衝區的話, 這個緩衝區也應該被塞滿了. 剩餘的沒被髮出去的訊息就堆積到A程式的記憶體空間裡, 這個時候如果A程式寫的不好, 那麼A程式由於記憶體被瘋狂佔用, 很快就會掛掉.

這是一個訊息佇列裡的經典問題, 就是訊息生產者和消費者的速度不匹配的時候, 訊息中介軟體應當怎麼設計的問題. 這個問題的根其實是在B身上, 但B對於訊息佇列的設計者來說是不可控的: 這是訊息佇列使用者寫的B程式, 你怎麼知道那波屌人寫的啥屌程式碼? 所以雖然問題由B產生, 但最好還是在A那裡解決掉.

最簡單的策略就是: A保留一些快取能力, 應對突發性的狀況. 超過一定限度的話, 就要扔訊息了. 不能把這些生產出來的訊息, 發不出去還存著. 這太蠢了.

另外還有一種策略, 如果A只是一個訊息中轉者, 可以在超過限度後, 告訴生產訊息的上流, 你停一下, 我這邊滿了, 請不要再給我發訊息了. 這種情況下的解決方案, 其實就是經典的"流控"問題. 這個方案其實也不好, A只能向上遊發出一聲呻吟, 但上游如果執意還是要發訊息給A, A也沒辦法去剪網線, 所以轉一圈又回來了: 還是得扔訊息.

ZMQ裡, 有一個概念叫"高水位閾值", (high-water mark. HWM), 這個值其實是網路結點自身能快取的訊息的能力. 在ZMQ中, 每一個活動的連線, 即socket, 都有自己的訊息緩衝佇列, HWM指的就是這個佇列的容量. 對於某些socket型別, 諸如SUB/PULL/REQ/REP來說, 只有收包佇列. 對於某此socket型別來說, 諸如DEALER/ROUTER/PAIR, 既能收還能發, 就有兩個佇列, 一個用於收包, 一個用於發包.

在ZMQ 2.X版本中, HWM的值預設是無限的. 這種情況下很容易出現我們這一小節開頭講的問題: 傳送訊息的api介面永遠不會報錯, 對端假死之後記憶體就會炸. 在ZMQ 3.X版本中, 這個值預設是1000, 這就合理多了.

當socket的HWM被觸及後, 再呼叫傳送訊息介面, ZMQ要麼會阻塞介面, 要麼就扔掉訊息. 具體哪種行為取決於sokcet的型別.

  1. 對於PUB和ROUTER型別的socket來說, 會扔資料.
  2. 對於其它型別的socket, 會阻塞介面.

顯然在這種情況下, 如果以非阻塞形式發包, 介面會返回失敗.

另外, 很特殊的是, inproc型別兩端的兩個socket共享同一個佇列: 真實的HWM值是雙方設定的HWM值的總和. 你可以將inproc方式想象成一根管子, 雙方設定HWM時只是在宣稱我需要佔用多長的管子, 但真實的管子長度是二者的總和.

最後, 很反直覺的是, HWM的單位是訊息個數, 而不是位元組數. 這就很有意思了. 另外, HWM觸頂時, 佇列中的訊息數量一般不好剛好就等於你設定的HWM值, 真實情況下, 可能會比你設定的HWM值小, 極端情況下可能只有你設定的HWM的一半.

資料丟失問題

當你寫程式碼, 編譯, 連結, 執行, 然後發現收不到訊息, 這個時候你應當這樣排查:

  1. 如果你使用的是SUB型別的socket, 檢查一下有沒有呼叫zmq_setsockopt設定過濾器
  2. 如果你使用的是SUB型別的socket, 謹記在建立連線過程中, 對端的PUB傳送的資料你是收不到了, 如果你確實想要這部分資料, 請做同步處理
  3. 如果你使用的是SUB型別的socket, 上面兩點你都做正確了, 還是有可能收不到訊息. 這是因為ZMQ內部的佇列在連線建立之後可能還沒有初始化完成. 這種情況沒什麼好的解決辦法, 有兩個土辦法

    1. 讓訊息傳送方在同步之後再sleep(1)
    2. 如果拓撲允許的話在, 讓SUB socket去執行bind操作, 反過來讓PUB socket去執行connect操作. 這是ZMQ官方給出的糊屎方法, 我都不知道該怎麼吐槽.
  4. 如果你使用的是REQ/REP型別的socket, 注意收與發的先後順序, 順序錯了呼叫收發包介面會報錯, 這個時候如果你忽略掉了報錯, 程式的行為會和丟包差不多.
  5. 如果你在使用PUSH/PULL型別的socket, 如果你發現訊息的分發不是公平的, 那可能是因為在傳送訊息時, 還有PULL沒有與PUSH建立連線, 於是這個PULL就沒有位於公平分發的候選人中. 使用PUSH/PULL要特別注意這一點.
  6. 如果你線上程間共享了socket控制程式碼, 趕緊改程式碼, 順便打自己兩巴掌.
  7. 如果你使用的是inproc通訊手段, 那麼請確保通訊的雙方建立socket時使用的context是同一個context.
  8. ROUTER socket最能造作. 第三章會看到, ROUTER與DEALER涉及到會話追蹤, 如果這部分內容出現異常, 也會類似於資料丟失.
  9. 最後, 如果你確實找不到出錯的原因, 但就是看不到訊息, 請考慮向ZeroMQ 社群提問.

相關文章