初識 libcurl multi:實現一個 http 請求處理客戶端,一個線程玩命發一個線程吐血收

一、引言

最近在工作中,遇到了這麼一個需求:

我們希望擁有一個高性能的 http 請求處理客戶端程序,這個客戶端要求有這樣的架構:

  1. 它擁有兩個線程
  2. 一個線程接收業務程序通過消息隊列發來的批量的 http 請求信息,進行批量的 http 請求
  3. 另一個線程接收外部的 http 應答,並將應答信息放到本地的消息隊列中供業務程序使用
  4. 要求在 http 請求的處理過程中,儘量保持不阻塞高性能處理

這個需求的框架圖大概是這個樣子:

n 個業務程序http clientn 個 http 服務端程序消息隊列:發送批量的 http 請求報文libcurl multi: 發起批量的 http 請求libcurl multi: 處理批量的 http 應答消息隊列:轉發 http 服務端的應答報文n 個業務程序http clientn 個 http 服務端程序

可以看到,我們的需求,其實就是滿足平臺中大量的業務程序的 http 請求處理需求。其中業務程序自然不止一個,業務程序要請求的 http 服務端也不止一個,我們要做的 http 請求客戶端程序實際上就像一個網關中間層。

那麼現在問題來了,如果做到 http client 程序的高性能處理。我們當然不想它請求一次就阻塞掉,這樣會非常非常的慢;我們也不想讓處理應答這種耗時的操作佔用太多時間,這樣也會非常非常的慢。

此時,我想到了當初學習 libcurl 的時候在官網上讀到的那句話:

The multi interface allows a single-threaded application to perform the same kinds of multiple, simultaneous transfers that multi-threaded programs can perform. It allows many of the benefits of multi-threaded transfers without the complexity of managing and synchronizing many threads.

尤其是最關鍵的執行請求的那個函數 curl_multi_perform 函數是異步的:

curl_multi_perform is asynchronous. It will only perform what can be done now and then return back control to your program. It is designed to never block. You need to keep calling the function until all transfers are completed.

這個接口非常適合用於我們現在的需求場景裏面,當 http client 發起批量的 http 請求的時候,使用 libcurl multi 接口就可以實現不阻塞式異步處理。

那麼如何設計和實現呢?

這裏,我通過參考 libcurl 的官方示例 10-at-a-time.c 的代碼框架,設計實現了兩個設計方案(思路略有不同)的實現;並且因爲工作環境的 libcurl 是老版本的庫(沒有 curl_multi_wait 函數的版本)而同時實現了新版本(使用 curl_multi_wait)的代碼和舊版本(沒有 curl_multi_wait 函數的版本)的代碼。

接下來讓我們開始吧:)

ps:
1. 10-at-a-time.c 官方示例代碼網址 https://curl.haxx.se/libcurl/c/10-at-a-time.html
2. 本篇博客中所有的實現代碼託管在 GitHub 上,倉庫地址 https://github.com/wangying2016/libcurl_multi_http_client

二、分析:10-at-a-time.c 到底實現了什麼

在設計實現我們自己的 http client 程序之前,我們先要來看看這個讓我們啓發很大的官方示例 demo 10-at-a-time.c 到底實現了什麼。

根據 libcurl 的版本不同,10-at-a-time.c 的代碼也稍有不同,這裏我只分析最新的使用 curl_multi_wait 函數的版本(這一版本的代碼也相當簡潔),思路都是一樣的,只是實現方式不一樣(如果你跟我一樣,也要考慮兼容老版本的 libcurl,那麼建議你去官網上下載一個 curl-7.20.0.tar,進去裏面的 docs/examples/10-at-a-time.c 中去看看老版本的實現方式)。

這裏大家可以通過網址 https://curl.haxx.se/libcurl/c/10-at-a-time.html 點擊進去閱讀 10-at-a-time.c 的代碼。我這裏簡單分析下這個程序的設計。

1. 10-at-a-time.c 程序功能

通過編譯:

$ gcc -o 10-at-a-time 10-at-a-time.c -lcurl

你就可以拿到 10-at-a-time 的執行文件:

$ ./10-at-a-time

你就可以發現,這個程序實現了大量的 http 請求功能。具體併發量是多少呢?10,這也是它的名字的來源。

那麼它是怎麼實現的呢?

2. 10-at-a-time.c 框架分析

這裏我也不浪費口舌,直接上流程圖(細節比如說 transfers 是否小於當前全部 url 總數、獲取消息是否處理成功等等判斷未體現在流程圖中,可自行仔細研讀代碼):

外層循環
使用 curl_multi_perform 批量發起 http 請求,使用 curl_multi_wait 輪詢全部請求 handle 可讀狀態。外層循環主要是在發起請求,並且輪詢請求 handle 狀態。

Created with Raphaël 2.2.0開始添加 10 個初始 url 請求curl_multi_perform 批量發起 http 請求內層循環處理應答並且新增請求curl_multi_wait 輪詢全部請求 handle是否還有活動請求或者還有未處理請求結束yesno

內層循環
使用 curl_multi_info_read 讀取應答消息結構體,使用 curl_easy_getinfo 獲取具體信息,使用 curl_multi_remove_handle 刪除已處理請求 handle,在 add_transfer 函數中使用 curl_multi_add_handle 添加新請求。

Created with Raphaël 2.2.0內層循環開始curl_multi_info_read 讀取應答消息curl_easy_getinfo 獲取具體信息curl_multi_remove_handle 刪除請求 handlecurl_multi_add_handle 添加新請求是否還有可處理應答信息內層循環結束yesno

總的來說
10-at-a-time.c 程序,外層循環批量發起 http 請求並且輪詢請求 handle 狀態,內層循環處理 http 應答,同時刪除處理完畢的請求然後添加新的請求。

就這樣,10-at-a-time.c 完成了一個高併發 http 請求,並且還能動態新增請求處理的功能。

相當於就是,既可以同時燒 10 把柴火,還能把燒完了的柴火清理掉再自動添加柴火!

這不就是我想要的功能嗎:)

三、方案一:線程一批量發和收,線程二處理線程一全局緩存的應答報文

10-at-a-time.c 是一個線程的程序,如何讓其變成兩個線程的框架呢,我稍稍動了腦子,設計出來了以下的方案:

線程一負責 http 請求的批量發送和接收,然後把接收到的報文存儲到全局變量中去;線程二負責將線程一存儲到全局變量中的應答報文進行實際的處理

既然用到了全局變量存儲應答報文,那麼兩個線程中的同步一定要做好(mutex)。另外,這裏我使用了非常簡單的方式來記錄是否處理:

定義一個應答報文結構,其中定義一個變量 consumable,它爲 1 即爲收到應答可以處理,爲 2 則意味着處理完畢

1. 存儲結構

首先,請求定義在全局變量中:

char *urls[] = {
  "https://www.microsoft.com",
  "https://opensource.org",
  ...
  };

爲了記錄應答信息,我定義了一個應答報文的結構,這裏沒有存儲應答信息,因爲實在是太大了,就簡單記錄下是否可以供線程二處理的狀態(也就是收到了應答,記錄 consumable 爲 1 狀態,線程二看到了 consumable 爲 1 就會去處理,處理完畢置爲 2):

// response struct
struct res_info {
    int index;
    // 0: initial 1: can consume 2: consume end
    int consumable;
    char url[MAX_URL_LENGTH];
};

其中 index 記錄着該 url 在全局 urls 中的序號,consumable 記錄着這個應答報文是否可以處理,0 是初始化狀態,1 是線程一添加到了全局變量中去可供線程二處理狀態,2 則是線程二處理完畢的狀態。

// response
struct res_info res_list[NUM_URLS];

res_list 則是全局的應答報文存儲變量,NUM_URLS 則是全部的 url 的個數(這只是一個 demo,後續可以簡單的將其擴展爲鏈表或者隊列進行動態擴展)。在線程二中,我只需要遍歷 res_list,讀取其中 consumable 爲 1 的應答報文進行處理,處理完畢後設置爲 2 即標記它處理完畢。

2. 個別函數

添加請求的函數
在線程一中的內層循環中,處理完了一個請求就會新增一個請求(如果還有未處理的請求的話)。這裏最重要的是 curl_multi_add_handle 函數。值得注意的是,這裏我將 url 記錄到了請求信息中去,在獲取了應答之後,我可以方便的獲取到 url。

// add request
static void add_transfer(CURLM *cm, int i)
{
  CURL *eh = curl_easy_init();
  curl_easy_setopt(eh, CURLOPT_WRITEFUNCTION, write_cb);
  curl_easy_setopt(eh, CURLOPT_URL, urls[i]);
  curl_easy_setopt(eh, CURLOPT_TIMEOUT, 10L);
  curl_easy_setopt(eh, CURLOPT_PRIVATE, urls[i]);
  curl_multi_add_handle(cm, eh);
}

記錄請求序號的函數
這個函數供給線程一來通過 url 獲取到它在全局請求列表中的序號,這個序號主要是是用來打日誌用的,可以告知你第幾個 url 已經被處理完了等等。

// find index
int find_index_of_urls(char *url) {
    int i;
    for (i = 0; i < NUM_URLS; ++i) {
        if (strcmp(urls[i], url) == 0) 
            return i;
    }
    return 0;
}

添加應答報文的函數
線程一在收到了應答,新增一個 res_info 結構,將 consumable 設置 1 標識其可以處理,記錄 index 和 url,最後將這個 res_info 更新到全局的 res_list 應答報文列表中去。

// add response
void add_response(struct res_info res) {
    int i = 0;
    pthread_mutex_lock(&res_mutex);
    res_list[res.index] = res;
    res_list[res.index].consumable = 1;
    // sleep(1);
    pthread_mutex_unlock(&res_mutex);
}

處理應答報文的函數
線程二中遍歷 res_list 全局應答報文列表,然後進行檢查,是否有可以處理的報文,有的話則將其 consumable 置爲 2 標識其處理完畢。

// consume response
void consume_response() {
    int i;
    pthread_mutex_lock(&res_mutex);
    for (i = 0; i < NUM_URLS; ++i) {
        if (res_list[i].consumable == 1) {
            printf("Log: t2, consume response, index [%d], url [%s]\n", \
            	res_list[i].index, res_list[i].url);
            res_list[i].consumable = 2;
        }
    }
    pthread_mutex_unlock(&res_mutex);
}

檢查是否全部處理完畢的函數
線程二通過這個函數檢查全部應答報文處理完畢。

/ check consume finished
int check_consume_finished() {
    int i, finished;
    finished = 1;
    pthread_mutex_lock(&res_mutex);
    for (i = 0; i < NUM_URLS; ++i) {
        if (res_list[i].consumable != 2) {
            finished = 0;
            break;
        }
    }
    pthread_mutex_unlock(&res_mutex);
    return finished;
}

3. 線程函數

線程一
線程一中,批量發送 http 請求,並且處理接收到的應答,並更新應答報文到全局的 res_list 變量中去。

// t1's thread function
void *fun1() {
    printf("Log: t1 begin...\n");

    CURLM *cm;
    CURLMsg *msg;
    int msgs_left = -1;
    int still_alive = 1;
    
    curl_global_init(CURL_GLOBAL_ALL);
    cm = curl_multi_init();
    
    /* Limit the amount of simultaneous connections curl should allow: */ 
    curl_multi_setopt(cm, CURLMOPT_MAXCONNECTS, (long)MAX_PARALLEL);
    
    for(transfers = 0; transfers < MAX_PARALLEL; transfers++)
        add_transfer(cm, transfers);
    
    do {
        curl_multi_perform(cm, &still_alive);
    
        while((msg = curl_multi_info_read(cm, &msgs_left))) {
            if(msg->msg == CURLMSG_DONE) {
                struct res_info res;
                char *url;
                CURL *e = msg->easy_handle;
                curl_easy_getinfo(msg->easy_handle, CURLINFO_PRIVATE, &url);
                strcpy(res.url, url);
                res.index = find_index_of_urls(res.url);
                add_response(res);
                printf("Log: t1, add response, error number [%d], error messsage [%s], url [%s], "
                       "index [%d]\n", msg->data.result, curl_easy_strerror(msg->data.result), res.url,  \
                        res.index);
                curl_multi_remove_handle(cm, e);
                curl_easy_cleanup(e);
            }
            else {
                printf("Log: t1, request error, error number [%d]\n", msg->msg);
            }
            if(transfers < NUM_URLS)
                add_transfer(cm, transfers++);
        }
        if(still_alive)
            curl_multi_wait(cm, NULL, 0, 1000, NULL);
    
    } while(still_alive || (transfers < NUM_URLS));
    
    curl_multi_cleanup(cm);
    curl_global_cleanup();

    printf("Log: t1 end...\n");
}

線程二
線程二中,一直檢查全局應答報文列表,如果存在可以處理的應答報文,則進行處理,直到全部都處理完畢爲止。

// t2's thread function
void *fun2() {
    int i = 0;
    
    printf("Log: t2 begin...\n");
    do {
        consume_response();
        sleep(1);
    } while (!check_consume_finished());
    printf("Log: t2 end...\n");
}

至此,我們就實現了一個簡單的高性能 http 請求處理客戶端 demo 程序。詳細代碼可以參考我的 GitHub 上的 http_client_v1.c,其中 http_client_v1_old.c 是兼容沒有 curl_multi_wait 函數的 libcurl 庫版本代碼。

四、方案二:線程一隻負責批量發,線程二隻負責批量處理

第一個方案,說實話有些不優雅,通過一個全局緩存的應答報文列表(並且通過 mutex 加鎖同步)實現兩個線程之間的分工。實際上,還是線程一做完了全部的工作,既負責發,又要負責收。

那麼,libcurl multi 到底是否支持將 http 的請求跟處理分離到兩個線程裏面去呢:

ibcurl is thread safe but has no internal thread synchronization. You may have to provide your own locking should you meet any of the thread safety exceptions below.

Handles. You must never share the same handle in multiple threads. You can pass the handles around among threads, but you must never use a single handle from more than one thread at any given time.

請看加粗的地方,libcurl 官方文檔中明確說明了,libcurl 不建議在兩個線程中共享 handle。這就麻煩了,libcurl multi 的 handle 究竟可不可以在兩個線程中共享使用呢?

我的設想是:一個線程使用 libcurl multi handle 就負責發請求,然後增加未處理請求;另一個線程使用 libcurl multi handle 就負責處理請求,然後刪已處理請求。按道理說,線程一就一直在添加請求,也就是往 libcurl multi 中的 easy handle 棧中壓棧,卻並沒有對已經存在的 easy handle 進行什麼操作,料想應該不會影響線程二中對應答報文的處理吧。

所以,說不定可以?!

所以,不如我們嘗試下?

T_T,說幹就幹!

1. 存儲結構

這一方案中去掉了方案一的用來存儲應答報文的全局變量,因爲用不到了(線程二直接進行處理)。

應答報文結構體
這個有點變化,consumable 爲 0 爲初始化,consumable 爲 1 則爲處理完畢。

// response struct
struct res_info {
    int index;
    // 0: initial 1: consume end
    int consumable;
    char url[MAX_URL_LENGTH];
};

libcurl multi handle
定義在了全局變量中,在 main 函數中進行初始化和清理:

// multi handle
CURLM *cm;
...
int main() {
	// Initialize multi handle
    curl_global_init(CURL_GLOBAL_ALL);
    cm = curl_multi_init();
    ...
    // Clean mutli handle
    curl_multi_cleanup(cm);
    curl_global_cleanup();
    ...
}

當前正在處理請求請求計數
增加了一個記錄當前正在處理的請求的變量,方便線程一在添加新請求的時候,不超過設置的併發上限的值(並對其進行加鎖同步處理):

// on-going request
unsigned int doing_cnt = 0;

// cnt mutex
pthread_mutex_t cnt_mutex = PTHREAD_MUTEX_INITIALIZER;

2. 線程函數

線程一
現在,線程一完全就在做 10-at-a-time.c 中的外層循環的工作。不停地發起 http 請求,然後輪詢請求狀態,然後添加新的請求。

// t1's thread function
void *fun1() {
    printf("Log: t1 begin...\n");

    int still_alive = 1;
    
    /* Limit the amount of simultaneous connections curl should allow: */ 
    curl_multi_setopt(cm, CURLMOPT_MAXCONNECTS, (long)MAX_PARALLEL);

    for(transfers = 0; transfers < MAX_PARALLEL; transfers++) {
        add_transfer(transfers);
        printf("Log: t1, add request, index = [%d], url = [%s]\n", \
            find_index_of_urls(urls[transfers]), urls[transfers]);
    }
    
    do {
        curl_multi_perform(cm, &still_alive);
        if(still_alive)
            curl_multi_wait(cm, NULL, 0, 1000, NULL);
        if(transfers < NUM_URLS && doing_cnt < MAX_PARALLEL) {
            add_transfer(transfers);
            printf("Log: t1, add request, index = [%d], url = [%s]\n", \
                find_index_of_urls(urls[transfers]), urls[transfers]);
            transfers++;
        }
        // sleep(1);
    } while(still_alive || (transfers < NUM_URLS));

    printf("Log: t1 end...\n");
}

線程二
線程二中,執行的是 10-at-a-time.c 中的內層循環的工作,一直在讀取可以處理的應答報文,然後刪除已處理的請求。

// t2's thread function
void *fun2() {
    int i = 0;
    int msgs_left = -1;
    CURLMsg *msg;
    
    printf("Log: t2 begin...\n");
    do {
        while((msg = curl_multi_info_read(cm, &msgs_left))) {
            if(msg->msg == CURLMSG_DONE) {
                char *url;
                CURL *e = msg->easy_handle;
                curl_easy_getinfo(msg->easy_handle, CURLINFO_PRIVATE, &url);

                struct res_info res;
                strcpy(res.url, url);
                res.index = find_index_of_urls(res.url);
                res.consumable = 1;
                res_list[res.index] = res;
                printf("Log: t2, accept response, index [%d], url [%s], error number [%d], error messsage [%s]\n", \
                    res.index, res.url, ms  g->data.result, curl_easy_strerror(msg->data.result));

                curl_multi_remove_handle(cm, e);
                curl_easy_cleanup(e);
            }
            else {
                printf("Log: t2, request error, error number [%d]\n", msg->msg);
            }
            pthread_mutex_lock(&cnt_mutex);
            doing_cnt--;
            pthread_mutex_unlock(&cnt_mutex);
        }
        // sleep(1);
    } while (!check_consume_finished());
    printf("Log: t2 end...\n");
}

3. 存疑

雖然我這邊修改了下代碼,實現了方案二的代碼。但是我還是很疑惑:

1. 我並沒有對 libcurl multi handle 進行同步加鎖機制,但是看上去不管怎麼測試,代碼都運行正常。難道官網上說的,不能在多個線程中共享 libcurl 的 handle 是有一定的例外情況的?

2. 我冒 libcurl 官網之大不韙,在兩個線程中共享了 libcurl multi 接口的 handle,實際上並未出現問題。難道是因爲我線程一隻有添加請求的操作,從而沒有影響到線程二的 handle 使用的原因嗎?

3. 我方案二的代碼是否是有問題的代碼呢?至少目前看來,在執行上沒有問題,並且再多 url 請求也沒有出現問題。

這些問題,目前的我解答不了,或許需要自己日後深入 libcurl 的源碼中去探索答案。這裏也希望有相關經驗的網友能夠提供給我答案,這裏悉心請教了:)

方案二的代碼在 GitHub 上的 http_client_v2.c 中,同樣的, http_client_v2_old.c 是兼容沒有 curl_multi_wait 函數的 libcurl 庫版本的代碼。

2020-02-19 更新,本方案代碼一定有問題

根據官網說明:

libcurl is thread safe but has no internal thread synchronization.

libcurl 是沒有內部的同步返回措施的,因此這個方案的代碼高併發一定會有問題。一個線程對 multi handle 操作增加,一個線程對 multi handle 操作刪除,高併發下會有線程安全問題。

因此,方案二的代碼僅供探索試錯,有問題的地方也在此強調,還請注意:)

五、總結

通過學習 libcurl multi 接口的使用示例 10-at-a-time.c 的代碼,我實現了一個使用了兩個線程的高性能 http 客戶端請求程序:

1. 一個線程玩命發
2. 一個線程吐血收

在這個過程中對 libcurl multi 接口的運行機制有了更加深入的瞭解。也在探索過程中實現了兩個版本的代碼,並且兼容了沒有 curl_multi_wait 的函數的 libcurl 老版本的庫代碼。

短暫的探索有了一些成果,不過長期的探索仍然還需要進行。在探索過程中,總會發現自己的知識儲存太薄弱,或許在我學習了更多網絡相關的知識後,再回來看這些內容,會有不一樣的理解吧 ^_^

To be Stronger:)

發佈了269 篇原創文章 · 獲贊 361 · 訪問量 93萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章