ZeroMQ 教程 001 : 基本概覽

張浮生發表於2018-05-02

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

介紹性的話我這裡就不翻譯了, 總結起來就是zmq很cool, 你應該嘗試一下.

如何安裝與使用zmq

在Linux和Mac OS上, 請通過隨機附帶的包管理軟體, 或者home brew安裝zmq. 包名一般就叫zmq, 安裝上就好.

安裝後, 以Mac OS為例, 會出現一個新的標頭檔案 /usr/local/include/zmq.h , 和一個連結庫 /usr/local/lib/libzmq.a.

所以, 如果你使用C語言, 那麼很簡單, 寫程式碼的時候加上標頭檔案 #include <zmq.h> 就好了, 連結的時候加上庫 -lzmq 就好了.

如果你使用的不是C語言, 那麼也很簡單, 去複習一下C語言, 然後再回來看這個教程. 需要注意的是, 這個教程裡的所有示例程式碼在編譯的時候需要指定 -std=c99.

一問一答例子入門

先放一個一問一答的例子來讓你感受一下

這是服務端程式碼

#include <zmq.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <assert.h>

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

    while(1)
    {
        char buffer[10];
        int bytes = zmq_recv(socket, buffer, 10, 0);
        buffer[bytes] = '\0';
        printf("[Server] Recevied Request Message: %d bytes, content == \"%s\"\n", bytes, buffer);

        sleep(1);

        const char * replyMsg = "World";
        bytes = zmq_send(socket, replyMsg, strlen(replyMsg), 0);
        printf("[Server] Sended Reply Message: %d bytes, content == \"%s\"\n", bytes, replyMsg);
    }

    zmq_close(socket);
    zmq_ctx_destroy(context);

    return 0;
}

這是客戶端程式碼

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

int main(void)
{
    printf("Connecting to server...\n");

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

    for(int i = 0; i < 10; ++i)
    {
        char buffer[10];
        const char * requestMsg = "Hello";
        int bytes = zmq_send(socket, requestMsg, strlen(requestMsg), 0);
        printf("[Client][%d] Sended Request Message: %d bytes, content == \"%s\"\n", i, bytes, requestMsg);

        bytes = zmq_recv(socket, buffer, 10, 0);
        buffer[bytes] = '\0';
        printf("[Client][%d] Received Reply Message: %d bytes, content == \"%s\"\n", i, bytes, buffer);

    }

    zmq_close(socket);
    zmq_ctx_destroy(context);

    return 0;
}

這是makefile

all: client server
%: %.c
    gcc -std=c99 $^ -o $@ -lzmq

這個例子就很簡單, 要點有以下

服務端上:

  1. 服務端建立一個context, 再用context去建立一個socket, 再把socket繫結到tcp的5555埠上
  2. 先從socket中讀取資料, 再向socket中寫入資料

客戶端上

  1. 客戶端也是建立一個context, 再用context去建立一個socket, 與服務端不同的, 客戶端是用tcp協議連線到本機的5555埠上, 這也是服務端監聽的網路地址
  2. 客戶端先向socket裡寫入資料, 再從socket中讀取資料
  3. 客戶端執行 寫入-讀出 這樣的操作十遍後, 關閉socket, 銷燬context, 退出程式

看起來套路和你從<Unix 網路程式設計>裡學到的差不多嘛. 不過, 你可以試試, 先啟動客戶端, 然後再啟動服務端, 你會發現, 程式沒有崩潰. 這就是zmq高明的地方, 把作業系統原生脆弱的網路程式設計介面進行了封裝. 並且實際上不止於此, 後面我們會學到更多. 這只是開胃小菜.

注意: 網路通訊中沒有字串!

你可能注意到了我們上面的例子裡, 其實客戶端與服務端互相傳輸的資料裡, 並沒有包含C風格字串最後一位的'\0'. 請時刻謹記這一點, 網路通訊中, 流動在網路程式設計API上的資料, 對於API本身來說, 都是位元組序列而已, 如何解釋這些位元組序列, 是網路程式設計API的使用者的責任. 比如上面, 我們需要在每次接收資料的時候記錄接收的資料的大小, 並且在buffer中為接收到的資料之後的一個位元組賦值為0, 即人為的把接收到的資料解釋為字串. 而對於zmq_sendzmq_recv來說, 它並不關心客戶端與服務端傳輸的資料具體是什麼.

這在所有網路程式設計API中都是這個套路, 不光是zmq, linux socket, winsock, 都是這樣. 字串? 不存在的. 我能看見的, 只是位元組序列而已.

獲取zmq的版本資訊

當你要把zmq應用到實際專案中的時候, 版本號注是一個你必須關注的事情了. 當然, 專案初期你可以不關心它, 或者專案規模較小的時候你可以不關心它. 但隨著專案的進展, 專案中使用到的庫的版本號就成了所有人必須關心的事情. 實際上所有第三方庫的版本都是一個需要專案owner關心的事情, 因為總有一些sb會做出以下的事情:

  1. 當一個sb需要一個額外功能的時候, 他會以光速引入一個庫, 並且從不檢查這個庫是否已經被引入到專案中.
  2. 當這個sb引入這個第三方庫的時候, 這個sb只關心自己寫的程式碼能不能順利編譯執行
  3. 很大概率這個sb不會仔細閱讀專案的構造工具指令碼, 這個sb只關心如何把這坨他看不懂的東西, 搞的不報錯, 能執行起來.
  4. 很在可能這個sb引入的這個第三方庫, 專案已經在先前引入了, 經過這個sb這次操作, 專案中會存在不同版本的兩個同名庫的引用.
  5. 一般情況下這個sb由於追求cool, 會引入最新的版本, 甚至是beta版
  6. 多數情況下, 這次操作引入的負面影響會在幾個月後爆發.

所以, 在這裡衷心的建議你, 時刻關注你專案中使用的所有第三方庫, 搞清楚你的專案構造工具鏈的執行過程. 而對於zmq來說, 要獲得zmq的版本, 需要如下呼叫一些函式

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

int main(void)
{
    int major = 0;
    int minor = 0;
    int patch = 0;

    zmq_version(&major, &minor, &patch);

    printf("ZMQ_VERSION == %d.%d.%d\n", major, minor, patch);

    return 0;
}

在我寫(抄)這個教程的時候, 我使用的版本號是4.2.5

封裝一些工具函式, 閱讀manpage, 並關心zmq API的返回值

有三件事我建議你養成習慣

  1. 封裝一些工具函式, 並且在你的程式設計生涯中不斷的改進它們
  2. 多查閱程式設計手冊, 在*nix平臺上, 多查閱manpage
  3. 對於C網路的API, 多關心函式的返回值的意義. 這裡的返回值包括但不限於: 函式的返回值, errno, errmsg等

現在我要寫三個工具函式, 這三個函式都不完美, 但它們都會出現大後續的示例程式裡, 用於縮減示例程式的篇幅:

第一個工具函式: 向zmq socket傳送字串資料, 但不帶結尾的'\0'

/*
 * 把字串作為位元組資料, 傳送至zmq socket, 但不傳送字串末尾的'\0'位元組
 * 傳送成功時, 返回傳送的位元組數
 */
static inline int s_send(void * socket, const char * string)
{
    return zmq_send(socket, string, strlen(string), 0);
}

第二個工具函式: 從zmq socket中接收資料, 並把其解釋為一個字串

/*
 * 從zmq socket中接收資料, 並將其解釋為C風格字串
 * 注意: 該函式返回的字串是為在堆區建立的字串
 * 請在使用結束後手動呼叫free將其釋放
 */
static inline char * s_recv(void * socket)
{
    char buffer[256];
    int length = zmq_recv(socket, buffer, 255, 0);
    if(length == -1)
    {
        return NULL;
    }

    buffer[length] = '\0';

    return strndup(buffer, sizeof(buffer) - 1);
}

第三個函式: 在取值範圍 [0, x) 中隨機生成一個整數

/*
 * 生成一個位於 [0, num)區間的隨機數
 */
#define randof(num) (int)((float)(num) * random() / (RAND_MAX + 1.0))

這些工具函式都會以靜態行內函數的形式寫在一個名為 "zmq_helper.h" 的標頭檔案中, 在後續用得著這些工具函式的時候, 示例程式將直接使用, 而不做額外的說明. 對應的, 當新增一個工具函式的時候, 工具函式本身的原始碼會在合適的時候貼出

什麼是模式? pattern?

相信以Java為主要工作語言的同學, 在畢業面試的時候基本上都被面試官問過各種設計模式, design patterns. 不知道你們有沒有思考過一個哲學問題: 什麼是模式? 什麼是pattern? 為什麼我們需要設計模式?

我在這裡給出我的理解: 模式並不高大上, 模式其實就是"套路". 所謂的設計模式就是在物件導向程式設計架構中, 前人總結出來的一些慣用套路.

網路程式設計中也有這樣的套路, 也被稱之為模式, pattern. ZMQ作為一個像訊息庫的網路庫, 致力於向你提供套路, 或者說, 向你提供一些便於實現套路的工具集. 下面, 我們來看我們接觸的第二個套路: 釋出-訂閱套路. (第一個套路是 請求-應答 套路)

釋出-訂閱 套路

釋出-訂閱套路中有兩個角色: 釋出者, 訂閱者. 或者通俗一點: 村口的大喇叭, 與村民.

釋出者, 與村口的大喇叭的共性是: 只生產訊息, 不接收訊息. 而訂閱者與村民的共性是: 只接收訊息, 而不生產訊息(好嗎, 村民會生產八卦訊息, 抬槓就沒意思了). ZMQ提供了兩種特殊的socket用於實現這個模式, 這個套路, 下面是一個例子:

村口的大喇叭迴圈播放天氣預報, 播放的內容很簡單: 郵編+溫度+相對溫度. 各個村民只關心自己村的天氣情況, 他們村的郵編是10001, 對於其它地區的天氣, 村民不關心.

釋出者/村口的大喇叭:

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

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

    srandom((unsigned)time(NULL));

    while(1)
    {
        int zipcode = randof(100000);   // 郵編: 0 ~ 99999
        int temp = randof(84) - 42;     // 溫度: -42 ~ 41
        int relhumidity = randof(50) + 10;  // 相對溼度: 10 ~ 59

        char msg[20];
        snprintf(msg, sizeof(msg), "%5d %d %d", zipcode, temp, relhumidity);
        s_send(socket, msg);
    }

    zmq_close(socket);
    zmq_ctx_destroy(context);

    return 0;

}

訂閱者/村民:

#include <zmq.h>
#include <stdio.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:5556");

    char * zipcode = "10001";
    zmq_setsockopt(socket, ZMQ_SUBSCRIBE, zipcode, strlen(zipcode));

    for(int i = 0; i < 50; ++i)
    {
        char * string = s_recv(socket);
        printf("[Subscriber] Received weather report msg: %s\n", string);
        free(string);
    }

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

makefile

all: publisher subscriber
%: %.c
    gcc -std=c99 $^ -o $@ -lzmq

這個例子中需要特別注意的點有:

  1. 村民必須通過zmq_setsockopt函式設定一個過濾器, 以說明關心哪些訊息. 如果不設定過濾器, 那麼什麼訊息都不會收到
  2. 即便你先啟動村民, 再給大喇叭上電, 村民還是會遺漏掉大喇叭最初始傳送的一些訊息..呃, 這麼講吧, 大概能丟失幾萬條這樣.這是因為tcp建立連線需要時間. 在建立連線這段時間內, 大喇叭已經向外瘋狂傳送了很多訊息. 在後續章節, 大概在第三章, 我們將會學到如何嚴格同步村民與喇叭. 讓喇叭在所有村民就緒之後再開始傳送訊息.

另外, 關於這個例子中的兩種socket型別, 有以下特點

  1. ZMQ_PUB型別的socket, 如果沒有任何村民與其相連, 其所有訊息都將被簡單就地拋棄
  2. ZMQ_SUB型別的socket, 即是村民, 可以與多個ZMQ_PUB型別的socket相連, 即村民可以同時收聽多個喇叭, 但必須為每個喇叭都設定過濾器. 否則預設情況下, zmq認為村民不關心喇叭裡的所有內容.
  3. 當一個村民收聽多個喇叭時, 接收訊息採用公平佇列策略
  4. 如果存在至少一個村民在收聽這個喇叭, 那麼這個喇叭的訊息就不會被隨意拋棄: 這句話的意思是, 當訊息過多, 而村民的消化能力比較低的話, 未傳送的訊息會快取在喇叭裡.
  5. 在ZMQ大版本號在3以上的版本里, 當喇叭與村民的速度不匹配時. 若使用的傳輸層協議是tcpipc這種面向連線的協議, 則堆積的訊息快取在喇叭裡, 當使用epgm這種協議時, 堆積的訊息快取了村民裡. 在ZMQ 大版本號為2的版本中, 所有情況下, 訊息都將堆積在村民裡. 後續章節我們會學習到, 如何以"高水位閾值"來保護喇叭.

ZMQ裡的ZMQ_PUB型的釋出者, 也就是喇叭, 其傳送訊息的能力是很炸的, zmq的作者在官方的guide裡講到, 釋出者與訂閱者位於同臺機器上, 通過tcp://locahost連線, 釋出者釋出一千萬條訊息, 大概用時4秒多. 這還是一臺2011年的i5處理器的膝上型電腦. 還不是IDC機房裡的伺服器...你大致感受一下..這個時候有人就跳出來說了, 這同臺機器走了loopback, 肯定效率高啊.

如果你也冒出這樣的想法, pong友, 看來你沒理解zmq的作者想表達的意思. 顯然, 如果採用乙太網作鏈路層, 這個資料不可能這麼炸裂, 但作者只是想向你表達: ZMQ本身絕對不會成為效能的瓶頸, 瓶頸肯定在網路IO上, 而不是ZMQ庫, 甚至於說作業系統協議棧上. 應用程式的效能瓶頸, 99.9999%都不在協議棧與網路庫上, 而是受限於物理規格的網路IO.

效能低? 你不買個幾百張82599武裝你的機房, 效能低你怪誰? 心裡沒一點i3數嗎?

分治套路

分治套路里有三個角色:

  1. Ventilator. 包工頭, 向手下各個工程隊分派任務. 一個.
  2. Worker. 工程隊, 從包工頭裡接收任務, 幹活. 多個.
  3. Sink. 甲方監理, 工程隊幹完活後, 向甲方監理報告. 所以工程隊的活幹完之後, 監理統一收集所有工程隊的成果. 一個.

在介紹這一節的示例程式碼之前, 我們先引入了兩個工具函式:

/*
 * 獲取當時時間戳, 單位ms
 */
static inline int64_t s_clock(void)
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return (int64_t)(tv.tv_sec * 1000 + tv.tv_usec / 1000);
}

/*
 * 使當前程式睡眠指定毫秒
 */
static inline void s_sleep(int ms)
{
    struct timespec t;
    t.tv_sec = ms/1000;
    t.tv_nsec = (ms % 1000) * 1000000;

    nanosleep(&t, NULL);
}

分治套路也被稱為流水線套路. 下面是示例程式碼:

包工頭程式碼:

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

int main(void)
{
    void * context = zmq_ctx_new();
    void * socket_to_sink = zmq_socket(context, ZMQ_PUSH);
    void * socket_to_worker = zmq_socket(context, ZMQ_PUSH);
    zmq_connect(socket_to_sink, "tcp://localhost:5558");
    zmq_bind(socket_to_worker, "tcp://*:5557");

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

    s_send(socket_to_sink, "Get ur ass up");    // 通知監理, 幹活了

    srandom((unsigned)time(NULL));

    int total_ms = 0;
    for(int i = 0; i < 100; ++i)
    {
        int workload = randof(100) + 1;     // 工作需要的耗時, 單位ms
        total_ms += workload;
        char string[10];
        snprintf(string, sizeof(string), "%d", workload);
        s_send(socket_to_worker, string);   // 將工作分派給工程隊
    }

    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;
}

工程隊程式碼:

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

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

    while(1)
    {
        char * msg = s_recv(socket_to_ventilator);
        printf("Received msg: %s\n", msg);
        fflush(stdout);
        s_sleep(atoi(msg));     // 幹活, 即睡眠指定毫秒
        free(msg);
        s_send(socket_to_sink, "DONE"); // 活幹完了通知監理
    }

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

    return 0;
}

監理程式碼:

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

int main(void)
{
    void * context = zmq_ctx_new();
    void * socket_to_worker_and_ventilator = zmq_socket(context, ZMQ_PULL);
    zmq_bind(socket_to_worker_and_ventilator, "tcp://*:5558");

    char * msg = s_recv(socket_to_worker_and_ventilator);
    printf("Received msg: %s", msg);    // 接收來自包工頭的開始幹活的訊息
    free(msg);

    int64_t start_time = s_clock();

    for(int i = 0; i < 100; ++i)
    {
        // 接收100個worker幹完活的訊息
        char * msg = s_recv(socket_to_worker_and_ventilator);
        free(msg);

        if(i / 10 * 10 == i)
            printf(":");
        else
            printf(".");
        fflush(stdout);
    }

    printf("Total elapsed time: %d ms]\n", (int)(s_clock() - start_time));

    zmq_close(socket_to_worker_and_ventilator);
    zmq_ctx_destroy(context);

    return 0;
}

這個示例程式的邏輯流程是這樣的:

  1. 包工頭向兩個角色傳送訊息: 向工程隊傳送共計100個任務, 向監理髮送訊息, 通知監理開始幹活
  2. 工程隊接收來自包工頭的訊息, 並按訊息裡的數值, 睡眠指定毫秒. 每個任務結束後都通知監理.
  3. 監理先是接收來自包工頭的訊息, 開始計時. 然後統計來自工程隊的訊息, 當收集到100個任務完成的訊息後, 計算實際耗時.

包工頭裡輸出的預計耗時是100個任務的共計耗時, 在監理那裡統計的實際耗時則是由多個工程隊並行處理100個任務實際的耗時.

這裡個例子中需要注意的點有:

  1. 這個例子中使用了ZMQ_PULLZMQ_PUSH兩種socket. 分別供訊息分發方與訊息接收方使用. 看起來略微有點類似於釋出-訂閱套路, 具體之間的區別後續章節會講到.
  2. 工程隊上接包工頭, 下接監理. 在任務執行過程中, 你可以隨意的增加工程隊的數量.
  3. 我們通過讓包工頭通知監理, 以及手動輸入enter來啟動任務分發的方式, 手動同步了工程隊/包工頭/監理. PUSH/PULL模式雖然和PUB/SUB不一樣, 不會丟失訊息. 但如果不手動同步的話, 最先建立連線的工程隊將幾乎把所有任務都接收到手, 導致後續完成連線的工程隊拿不到任務, 任務分配不平衡.
  4. 包工頭分派任務使用的是輪流/平均分配的方式.這是一種簡單的負載均衡
  5. 監理接收多個工程隊的訊息, 使用的是公平佇列策略.

所以, 你大致能看出來, 分治套路里有一個核心問題, 就是任務分發者與任務執行者之間的同步. 如果在所有執行者均與分發者建立連線後, 進行分發, 那麼任務分發是比較公平的. 這就需要應用程式開發者自己負責同步事宜. 關於這個話題進一步的技巧將在第三章進一步討論.

使用ZMQ的一點建議

現在我們寫了三個例子, 分別是請求-迴應套路, 釋出-訂閱套路, 流水線套路. 在繼續進一步學習之前, 有必要對一些點進行強調

  1. 學習ZMQ請慢慢學. 不要著急. 其實學習所有庫工具都是如此, learn it by hard way. 很多程式設計師總是看不到 "看懂" 和 "學會" 這兩個層次之間的十萬公里距離, 覺得"看懂"了, 再抄點程式碼, 複製貼上一下, 就算是"精通"ZMQ了, 不, 不, 不, 差得遠, 當年你就這這樣學C語言的, 所以除了資料結構實驗, 你寫不出任何有用的程式碼. 我建議你一步一步的學習, 不要急功近利, 仔細的寫程式碼, 琢磨, 體會, 理解.
  2. 養成良好的程式設計風格, 不要寫屎一樣的程式碼.
  3. 重試自測, 無論是在工作還是在學習上, 用各種測試手段來保證程式碼質量, 不要從心理上過度依賴debug
  4. 學會抽象, 無論是工作還是學習中, 積累程式碼, 自己動手寫一些函式, 封裝, 並隨著時間去精煉它們, 慢慢的, 雖然最終你會發現你寫的程式碼99%都是屎, 但這個沉澱的過程對你一定有很大的幫助.
  5. 上面四條是zmq guide原作者的建議, 我表示比較贊同.

正確的處理context

你大致注意到了, 在上面的所有示例程式碼中, 每次都以zmq_ctx_new()函式建立出一個名為context的變數, 目前你不需要了解它的細節, 這只是ZMQ庫的標準套路. 甚至於你將來都不需要了解這個context裡面到底是什麼. 但你必須要遵循zmq中關於這個context的一些程式設計規定:

  1. 在一個程式起始時呼叫zmq_ctx_new()建立context
  2. 在程式結束之前呼叫zmq_ctx_destroy()銷燬掉它

每個程式, 應該持有, 且應該只持有, 一個context. 當然, 目前來說, 你這樣理解就行了, 後續章節或許我們會深入探索一下context, 但目前, 請謹記, one context per process.

如果你在程式碼中呼叫了fork系統呼叫, 那麼請在子程式程式碼區的開始處呼叫zmq_ctx_new(), 為子程式建立自己的context

把屁股擦乾淨

網路程式設計和記憶體洩漏簡直就是一對狗男女, 要避免這些狗血的場景, 寫程式碼的時候, 時刻要謹記: 把屁股擦乾淨.在使用ZMQ程式設計的過程中, 我建議你:

  1. 在呼叫zmq_ctx_destroy()之前, 先呼叫zmq_close()關閉掉所有的zmq socket. 否則zmq_ctx_destroy可能會被一直阻塞著
  2. 儘量使用zmq_send()zmq_recv()來收發訊息, 儘量避免使用與zmq_msg_t相關的API介面. 是的, 那些介面有額外的特性, 有額外的效能提升, 但在效能瓶頸不在這些細枝末節的時候, 不要過度造作.
  3. 假如你非得用zmq_msg_t相關的介面收發訊息, 那麼請在呼叫zmq_msg_recv()之後, 儘快的呼叫zmq_msg_close()釋放掉訊息物件
  4. 如果你在一個程式中開了一堆堆的socket, 那麼你就需要在架構上思考一下, 你的程式是不是有設計不合理的地方.
  5. 在程式退出的時候, 時刻謹記關閉socket, 銷燬context
  6. 不要在多個執行緒間共享socket.
  7. 用完socket之後記得關閉.
  8. 上面是zmq guide作者給出的建議, 下面, 我再給你一條: 熟讀相關介面的manpage, 注意介面的返回值, 做好呼叫失敗後的災後重建工作

當然, 上面主要是對C語言作者的一些建議, 對於其它語言, 特別是有GC的語言, 使用ZMQ相關介面之前建議確認相關的binding介面是否正確處理了資源控制程式碼.

你為什麼需要ZMQ

網路程式設計, 特別是*nix平臺的網路程式設計, 99%程式設計師的啟蒙始於<Unix網路程式設計>這本書, 90%裡的專案充斥著linux socket, epoll與fd. 是的, 2018年了, 他們還是這麼幹的. 我們就從這個視角來列舉一下, 使用*nix平臺原生的網路API與多路IO介面, 你在寫服務端程式時需要頭疼的事情:

  1. 如何處理IO. 阻塞式IO太低效, 非同步IO程式碼不好寫.
  2. 如何平滑的向你的服務增刪機器, 平行擴容
  3. 如何傳遞訊息? 如何設計訊息結構? 通訊協議?
  4. 訊息傳遞過程中如何緩衝? 生產消費速度不一致時採用何種策略?
  5. 如何處理訊息丟失? 如何保證通訊的可靠性?
  6. 如何處理多種三層四層協議之間的協同?
  7. 訊息如何路由? 如何負載均衡? 如何實現有狀態的會話?
  8. 如何處理多程式語言的協同?
  9. 如何使訊息在多種架構機器上能通用讀寫? 如何實現了, 如何保證效率和成本?
  10. 如何處理網路錯誤?

我問你, 你頭大不大? 想不想死?

讀過開源專案嗎? 比如Hadoop Zookeeper, 你去觀摩一下zookeeper.c, 真是看的人頭大想死. 你再翻翻其它開源專案, 特別是用C/C++寫的Linux端程式, 每個都要把網路庫事件庫重新寫一遍.

所以矛盾很突出, 為什麼不能造一個大家都用的輪子呢? 原因很簡單, 有兩個方面:

  1. 對於大佬來說, 作業系統提供的網路API和事件API已經算是輪子了
  2. 真的要做一個通用的網路庫, 或者訊息庫, 其實難度非常大. AMQP就是一個例子, 你可以去感受一下.

那麼ZMQ解決了什麼問題呢? ZMQ給上面提出的問題都給了完美答案嗎? 理性的說, 肯定沒有, 但是ZMQ是這樣回答這些問題的:

  1. ZMQ用後臺執行緒實現了IO的非同步處理. 應用間的通訊使用了無鎖的資料結構.
  2. 叢集中的結點動態增刪的時候, ZMQ能默默的正確處理重連/斷連等髒活.
  3. ZMQ努力的對訊息做了佇列快取, 多數情況下, 預設的ZMQ行為為你提供了便利, 也足夠應付你的應用場景.
  4. 當緩衝佇列爆掉時, ZMQ提供了"高水位閾值"這個機制. 這個機制在佇列爆掉時將自動阻塞傳送者, 或者靜靜的扔掉資料. 具體哪種行為, 取決於你使用的socket的型別
  5. ZMQ可以歡快的跑在多種傳輸層協議上, 更改協議甚至不需要怎麼改程式碼(好吧, 至少要改那麼一兩行)
  6. ZMQ在多種套路下, 都會像爸爸看兒子那樣小心翼翼的照顧那些低能兒(處理訊息的速度比較慢的那些結點)
  7. 有多種現成的套路讓你實現花式負載均衡. 比如請求迴應套路, 釋出訂閱套路.
  8. ZMQ可以很簡單的建立代理, 代理是一種有效降低網路區域性複雜度的技術.
  9. ZMQ保證訊息傳遞的原子性. 要麼所有訊息都收到, 要麼你一根毛都收不到.
  10. ZMQ本身並不引入二進位制訊息的規範. 你如何解釋訊息, 那完全是你的自由.
  11. ZMQ多數情況下可以妥善的處理網路異常, 比如在合適的場合進行合適的重試重傳, 這些髒活對於你來說, 都是透明的, 不可見的.
  12. ZMQ能有效降低你IDC裡的碳排放. 保護環境人人有責.

總之, 就是很好, 當然了沒有一個框架庫的作者會說自己的產品不好, 而具體好不好, 學了用了之後才會知道, 上面的點看一看得了, 別當真.

socket的可擴充套件性

在釋出-訂閱套路由, 當你開啟多個村民的時候, 你會發現, 所有村民都能收到訊息, 而村口的喇叭也工作正常. 這就是zmq socket的可擴充套件性. 對於釋出端來講, 開發人員始終面對的是一個socket, 而不用去管連線我到底下面會有多少訂閱使用者. 這樣極大簡化了開發人員的工作, 實際釋出端程式跑起來的時候, 會自主進行適應, 並執行最合理的行為. 更深層次一點, 你可能會說, 這樣的功能, 我用epoll在linux socket上也能實現, 但是, 當多個訂閱者開始接收資料的時候, 你仔細觀察你cpu的負載, 你會發現釋出端程式不光正確接納了所有訂閱者, 更重要的是把工作負載通過多執行緒均衡到了你電腦的多個核心上. 日最大程度的榨乾了你的cpu效能. 如果你單純的用epoll和linux socket來實現這個功能, 釋出端只會佔用一個核心, 除非你再寫一坨程式碼以實現多執行緒或多程式版的村口大喇叭.

相關文章