執行緒同步 15:Select

本篇要來介紹與探討一個在網路程式設計中相當常見的函式:select()

對於網路程式,特別是需要直接操作使用 socket 的開發員來說, select() 應該是一個相當常見又熟悉的網路 API 函式了。 但是這與執行緒有什麼關係呢? 其實 select() 並不是設計只能使用在網路通信上的,它的設計其實是針對檔案; 而眾所皆知 UNIX (包含所有的 UNIX like 系統)之下什麼東西都是檔案, 因此這個 API 可以用來對所有廣泛的檔案類型物件進行使用。 然而本篇介紹完了之後你可能又會發現到一件事: select() 其本身不就也是執行緒同步工具裡面的其中一種工具罷了!

Tip
雖然說 select() 在 UNIX like 系統之下幾乎可以被使用在任何的檔案物件 (在 UNIX 之下所謂的檔案不一定是真的磁碟上的檔案,而是所有具有檔案描述符的東西都算), 並且全世界的作業系統大部份都是 UNIX like,在常見的裡面大概只有一個作業系統例外, 那就是大名鼎鼎的 Windows! 因此在 Windows 上,select() 就沒有這麼強大廣泛的用途了, 基本就只能被使用在網路 socket 的同步用途上。 但是對於使用 Windows 做開發平臺的讀者仍然可以繼續閱讀本篇, 因為除了 select() 的使用方式之外,還有相關的原理機制等仍是值得知曉的。

使用情境

在繼續解說 select() 的使用方式之前,要先來了解一下在什麼時候我們會需要使用到它呢? 不過既然本系列文章並不是網路程式設計教學文,因此在這個使用情境上還是以多緒設計的用途來作切入的。

回顧本系列前幾篇的內容,介紹了條件變量、信號量、以及中斷信號等,然而其目的都是一樣的: 就是讓我們能在該準備的東西準備完成的時候去叫醒那些可能正在等待東西的其它執行緒, 讓執行緒可以大膽放心的睡覺,可以擺脫純粹輪詢的兩難問題。 然而在兩種情況下,之前介紹的那些方法卻不再有效了。

在「生產/消費」形式的使用情境中,先前介紹的那些工具都需要我們自己在情況合適的時候主動去發起通知喚醒, 這本來並沒有什麼問題,只不過需要我們對於生產與消費的兩端程式都要有掌控的能力。 什麼意思呢? 比方說在前面幾篇的範例中,是自己寫的程式碼去產生與推送訊息,因此可以在裡面安插信號發送或其它的喚醒通知; 然而如果那端不是我們自己寫的功能,也不是我們能夠控制的程式呢? 比方說網路 socket 的資料收送,或者可能是 RS-232 的資料收送等。 這就是第一種面臨上述困難的情境。 在這種使用情境下,我們沒辦法在裡面去安插一個條件變量的喚醒呼叫, 而必需要使用作業系統提供的其它相應功能來作為通知使用; 而 select() 就是(其中一種)能用來滿足這種需求的 API。

第二種情況則是在我們需要同時處理好多個需要同步的物件的時候。 比方說我同時開啟了好多檔案、或網路 socket、或 UART 等物件出來, 並同時需要處理它們之間的資料讀寫收送等工作。 那麼在此情況下,即便先撇除我們可能沒法在這些系統呼叫裡面安插呼叫喚醒的問題,仍然會給我們帶來新的難題。 比如我們開了 100 條網路連線好了, 那麼我們是不是也需要建立 100 條執行緒,然後讓它們各自等候其中一個連線的資料抵達? (其實這也是一個可行的方法, 早先的 Apache Server 就是這麼做的,為每個連線建立一個專職處理該連線工作的執行緒。 就是不知道現在的 Apache Server 改變做法了沒?) 那麼比較理想的方式,是不是能夠有一個什麼工具能讓我們一次同時等候 100 個物件, 當其中的一些物件妥當的時候(比如期中的 5 個有資料了,而剩下的 95 個還沒有資料)就立刻中斷睡眠, 並告訴我們其中的哪一些物件已經好了?

以上這兩情況當然都有解決的方法, 如 Linux 的 poll() 和 epoll,或 BSD 的 kqueue, 或 Windows 的 IOCP 等便都是能夠解決這些問題的方案, 而本篇介紹的 select() 便是其中最古老的 API。

select() 使用方式

檔案描述符

在繼續介紹 select() 之前,有一些這函式會需要用到的東西必需要先做解釋, 那就是檔案描述符以及檔案表。

檔案描述符(File Descriptor (FD))就是一個用來表達已經開啟的檔案資源的操作器。 在 UNIX 相容作業系統下這就是一個整數值,其被稱為 file descriptor,也簡稱為 FD; 而在 Windows 下則是一個指標(就是 C/C++ 的記憶體指標), 稱為 handler 或 socket handler, 只不過在本篇我會將其統稱為描述符 FD,只有在特別要區分兩者的時候才會使用 handler。

如同前面所述,檔案描述符雖然含有「檔案」的字樣, 但其實是 UNIX 下的廣泛檔案,而非只能用來表示在磁碟上存在的那個檔案。 因此本文後面也不會再特別描述解釋如網路連線 socket 等不同用途稱呼,而會全部以檔案描述符(FD)統稱之。

另一個 fd_set 則如其名,就是 FD 的 Set,且這個 Set 和 C++ STL 的 std::set 是一樣的意思, 也就是說 fd_set 其實就是 FD 的集合容器。 關於這東西更詳細的內容會在稍候講解,這裡只需先知到這是什麼東西,以及它所表達的含意即可。

select 函式

select() 函式的介面定義如下:

#include <sys/select.h>

int select(
    int nfds,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *errorfds,
    struct timeval *timeout);

其中各參數的意義如下:

  • readfds, writefds, errorfds:

    這 3 個檔案集合物件的作用是用來表達哪些檔案處於哪些狀態的。 在呼叫的時候,這些集合需要由使用者填寫那些想要關心的檔案的描述符。 例如若我希望觀察 5 個檔案是否有資料可讀,就需把這 5 個檔案的描述符填入 readfds; 同理若要觀察哪些檔案是否為可接受寫入資料的狀態,就需把那些檔案的描述符填入 writefds; 而若要觀察哪些檔案發生錯誤狀態,就把那些檔案的描述符填入 errorfds

    這 3 個集合的內容會被 select() 修改覆寫, 在函式返回的時候仍留在集合內的檔案描述符,即表示該檔案處於對應的狀態。 此時 readfds 內存在的檔案描述符表示這些檔案有資料可讀, writefds 內的檔案描述符表示可接受資料寫入, 而 errorfds 內的檔案描述符表示這些檔案出現了錯誤。

    這 3 個檔案集合都是可選的,如果不關心某個狀態的話就可以傳入 NULL。 例如若我只關心檔案有沒有資料可讀而不關心可不可寫,那就可以把 writefds 設為 NULL,以此類推; 唯需注意該 3 參數雖皆可為 NULL 卻不可同時全都是 NULL, 其中至少要有一個集合是有效的,否則就失去了呼叫這個 API 的意義。

  • nfds:

    這個參數有點兒玄妙,後面會對此再做說明解釋, 這裡只需要知道這個參數要填寫為在剛才填入上面那 3 個集合中的描述符中, 數值最大的那個描述符(前面說了檔案描述符 FD 就是個整數)的值再加一即可! 例如若上面傳入的所有描述符中最大的 FD 為 18,那麼 nfds 就要填為 18 + 1 即 19。

    另外,Windows 的版本會忽略這個參數,原因到後面再做說明。

  • timeout:

    這個參數要填入想要等待的時間,當超過這個時間而所有被觀察的檔案都沒有發生相應的狀態, 則函式就會停止等待並返回。 這個參數是一個結構,其定義於後面補充,其精度為微秒。 不過要注意的是這個參數表達的是相對時間,而非像前兩篇裡面使用的絕對時間; 也就是說若想讓它等待 3 秒那就將秒數填為 3,而不用像前篇那樣使用當前時間再 +3。

    若這個參數內填寫的時間值皆為零,則表示函式不會等待,呼叫後就會立刻查詢並傳回那些檔案當前的狀態; 而若將 timeout 填為 NULL,則函式會一直等待下去直到其中有任何檔案終於變化成對應的狀態。

struct timeval 資料結構的定義如下:

struct timeval
{
    time_t      tv_sec;     // Seconds
    suseconds_t tv_usec;    // Microseconds

    // ......
    // 隨著不同的作業系統實做,
    // 除了上面兩個基本成員之外還可能會有其它的成員存在。
};

函式的回傳值意義如下:

  • > 0: 表示至少有一個被觀察的檔案處於對應的狀態, 此時的傳回值即表示被函式填寫回 readfdswritefds、和 errorfds 的檔案數量。

  • == 0: 表示函式等待超時而未有任何被觀察的檔案處於對應的狀態。

  • -1: 表示函式呼叫失敗或發生錯誤,此時 errno 會被填寫為對應的錯誤碼。

FD 集合

fd_set 如前所述就是檔案描述符(FD)的集合,其提供 4 個操作函式:

void FD_ZERO(fd_set *fdset);
void FD_SET(int fd, fd_set *fdset);
void FD_CLR(int fd, fd_set *fdset);
int FD_ISSET(int fd, fd_set *fdset);
  • FD_ZERO(): 清除整個集合。 一般對呼叫 select() 的使用者來說,這個功能用來在宣告了 fd_set 變數後對其進行初始化使用。

  • FD_SET(): 將一個 FD 存入集合。

  • FD_CLR(): 從集合內清除一個 FD。

  • FD_ISSET(): 檢查一個 FD 是否存在於集合內?

範例

以下以一個範例,假設在一個伺服器的處理程序內要同時監聽並處理可能上百個連線的資料傳輸:

int ProcessNetworkExchange()
{
    // 初始化 3 個 FD 集合
    fd_set rfds, wfds, efds;
    FD_ZERO(&rfds);
    FD_ZERO(&wfds);
    FD_ZERO(&efds);

    // 建立 FD 集合的內容
    int max_fd = -1;
    for(int fd : socklist)
    {
        // 在這範例裡不關心 socket FD 怎麼來的,
        // 總之就已經有成百上千的 FD 儲存在 socklist 容器裡,
        // 而現在要將這些 FD 設入 3 個集合裡。
        FD_SET(fd, &rfds);
        FD_SET(fd, &wfds);
        FD_SET(fd, &efds);

        // 建立 FD 集合的同時也順便記錄一下最大的 FD
        if( max_fd < fd )
            max_fd = fd;
    }

    // 設定超時,這裡 timeout 假定單位為毫秒
    struct timeval tv;
    tv.tv_sec = timeout / 1000;
    tv.tv_usec = timeout % 1000 * 1000;

    // 執行本文所重點關注的 select(),
    // 它會休眠等待直到超時,或者至少其中一個 FD 的觀察狀態成立。
    // 其中注意 select() 的執行會改變 rfds、wfds、和 efds 的內容。
    int n = select(max_fd + 1, &rfds, &wfds, &efds, &tv);
    if( n <= 0 )
    {
        // 若返回非正值,則可能為超時或者發生了錯誤。
        return n == 0 ? EAGAIN : errno;
    }

    // 當 select() 返回告訴我們有 FD 狀態成立時,
    // 我們只能知道其中有至少一個 FD 的狀態成立;
    // 至於是哪一個?就得一一檢查才能確認。
    // 所以這裡需要遍歷整個 FD 容器,
    // 看是哪些 FD 符合特定的狀態,
    // 然後才進行與它有關的資料收送等行為。
    for(int fd : socklist)
    {
        // 檢查 FD 是否有資料可讀取?
        // 若有則進行資料接收工作。
        if(FD_ISSET(fd, &rfds))
            ProcessRecvWorks(fd);

        // 檢查 FD 是否可以接受寫入?
        // 若有則進行資料發送工作。
        if(FD_ISSET(fd, &wfds))
            ProcessSendWorks(fd);

        // 檢查 FD 是否存在錯誤狀態?
        // 若是則報告連線錯誤,
        // 並可能需要將該 FD 關閉並從列表裡移除。
        if(FD_ISSET(fd, &efds))
            ReportSocketError(fd);
    }

    return 0;
}

上面的範例用法看上去好像稍微複雜點,但那其實主要目的是為了演示 select() 用法, 至於實際上的使用則通常會更加精簡些。 比如通常如果我們不是要做伺服器軟體而是要做一個客戶端, 那麼連線數量可能常見也就個位數,或可能只有一條連線。 那麼以對一條連線進行資料等待及接收的使用情況,上面的程式碼就可以簡化成下面這樣:

ssize_t Receive(
    int fd, void *buf, size_t bufsize, unsigned timeout)
{
    fd_set rfds;
    FD_ZERO(&rfds);
    FD_SET(fd, &rfds);

    struct timeval tv;
    tv.tv_sec = timeout / 1000;
    tv.tv_usec = timeout % 1000 * 1000;

    int n = select(fd + 1, &rfds, NULL, NULL, &tv);

    if( n > 0 )
        return recv(fd, buf, bufsize, MSG_DONTWAIT);
    else if( n == 0 )
        return 0;
    else
        return -1;
}

這兩個範例之中,尤其是第二個範例因為結構比較簡單, 可能更容藝能看出來其實這就和前幾篇的範例結構是一樣的。 在假設 socket 被建立設定為 non-blocking 模式下,如果去掉了 select() 的呼叫, 那麼其實就等同於輪詢用法中的其中一次資料收取動作。 而 select() 在這裡的作用相似於前篇的 cond.TimedWait() 呼叫, 只不過 cond.Wakeup() 的呼叫不是由我們所主動發起,而是在檔案的驅動程式中執行的。

此外補充,select() 偵測的是檔案描述符的可讀或可寫等狀態, 因此並不只是能用來等待連線另一端的資料傳送狀態。 比如說在建立 TCP 連線時用來等待客戶連線的監聽端 socket,也能使用 select() 檢查檔案是否可讀, 若為可讀的話就表示有一個連線正在連進來,這時就可以呼叫 accept() 建立新的客戶連線了。 或者在客戶端使用 non-blocking socket 連線時,呼叫 connect() 後就會立刻返回, 但什麼時候才知道連上線了或者連失敗了呢? 這時就可以等待 socket 是否為可寫狀態,若 socket 變成可寫則表示連線已建立; 若 socket 變成錯誤狀態則表示連線失敗。

為什麼 FD 集合的用法這麼奇怪?

我想對於初次接觸學習網路程式以及 select() 的人來說,可能會有一種共同的疑問, 就是為什麼那個 fd_set 是設計這樣使用的啊? 以及 nfds 為什麼不是如直覺一樣填寫為傳入的 FD 數量, 而是要寫為最大的 FD + 1 這種奇怪的數字啊? 當然也不否認可能存在部份不求甚解,雖不明白為什麼,但反正照著做就對了的人!

這一切的原因其實來自於 fd_set 的實現為一種位元遮罩(bit mask)的設計。 若要追究起來,這些問題應屬來自於不良的設計, 因為良好的設計應該要隱藏實做細節,或更白話的說法叫作使介面與實做無關, 然而這東西的實做方式卻大大影響了它的使用介面,使其之使用變得較為晦澀難明。 畢竟 select() 是一個非常古老的 API 設計,跟隨著經典的 Berkeley 網路 API 一起傳遍整個世界, 然後就一直沿用到了現在。 而作為早期的設計,存在一些不符未來應用需求的問題也就是合情合理的事情, 反正比較現代的網路 API 就沒再沿用這些不直覺的設計了。

FD 集合基本原理

fd_set 的作用就是為了一個目的:讓人知道一個特定的 FD 是否存在於集合內? 其實際的型態定義稍微複雜點,為了簡化並同時保留其基本原理的緣故, 這裡假定 fd_set 就是一個 8 位元的無號整數。

假設我現在有一個 fd=2,那麼在使用 FD_SET() 將其設定入集合後,這個集合的值就會變成:

0b00000100

若為 fd=5 設定入集合,則集合的值為:

0b00100000

由此可見,把一個數字 N 設定到集合上,就是把集合的值的第 N 個(由零起算)位元設為 1 而已。 那如果把 fd=2fd=5 都設定入集合,那麼集合的值就是:

0b00100100

以上是 FD_SET() 將 FD 設定入集合的方式。 而若是 FD_CLR() 要將 FD 從集合裡移除,那就是把 FD 對應的位元設為 0 就是了; 若是 FD_ZERO() 要清除整個集合,那就把集合的值設為 0 就可以了; 至於 FD_ISSET() 要檢查某個 FD 是否存在於集合內,則只要檢查 FD 對應的位元是 1 還是 0 就行了。

以上就是 fd_set 的運作原理。 在上面的示例使用 8 位元的集合值,所以可以支援的 FD 值為 0 至 7,並且沒有辦法處理大於 7 的 FD 值。 若要能支援更多的 FD 範圍,就要使用長度更常的整數來作為集合的型態, 若使用 64 位元的整數來作集合值,則最高可支援到 fd=63 的範圍。 如果還要再支援更大的 FD 範圍呢?那就得用陣列了,而這也是實際的 fd_set 比上面的示例複雜的原因。 以從我電腦上翻出來的 fd_set 為例,其定義為:

typedef struct
{
    __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;

nfds 的用法為什麼這麼怪?

前面已經解釋了 fd_set 的運作原理。 Windows 的部份比較不一樣,等會兒再談, 而除了 Windows 以外的一般通常下,fd_set 預設支援的 FD 值域為 0 ~ 1023。 但是支援這麼大範圍的 FD 值域也帶來的一個問題, select() 可能需要掃描整個集合,檢查 0 ~ 1023 的每一個 FD 是否存在, 然後才能對正確的 FD 群進行監視。 那麼如果我們可以主動告訴它只需要檢查某個範圍就好,是不是就可以給 select() 節省大量時間? 而這就是 nfds 的作用!

同樣假設 FD 集合的值為 8 位元整數,如果現在存入 3 個 FD:fd=0fd=2fd=5, 那麼 FD 集合的值就是:

0b00100101

那是不是告訴 select() 只需要檢查最低的 6 個位元即可,剩下的 2 個位元就可以忽略不管了? (當然在這個示例中只省掉了 2 個位元的檢查,效益並不明顯,那是因為示例只有 8 位元, 但別忘了真實的 fd_set 預設總共有 1024 個位元喔!) 於是我們在呼叫 select() 時就要將 nfds 設為 6,這個數字就是這麼來的! 這個 6,就是因為我們需要檢查的 FD 最大值為 5,而 5 + 1 就是 6 了; 至於為什麼要加一呢?那是因為 fd_set 的位元引數是從零開始算的, 我想熟悉 C/C++ 陣列引數的人應該就明白了。 也就是說這個 nfds 並不是直覺中的 FD 數量,而是指所有傳入的 FD 的值域範圍, 只要了解了 fd_set 的運作機制就能夠理解其作用; 然而其必須要了解運作細節才能夠知道如何正確設定與使用的這個部份,我認為是設計不佳的問題。

Windows 版本的 FD 集合

前面解釋的 fd_set 運作方式是建立在「檔案物件是由整數表示的編號」前提下才能夠良好運作, 因為一般情況下除非你的程式同時打開了一大堆的檔案,否則 FD 這個代表檔案代號的數字通常都蠻小的。 但是 Windows socket 不是這樣的數字代號而 socket handler,它其實就是個指標值, 而指標也就是記憶體的位址就是個很大的數字了,如果也用 bit mask 來記錄的話那所需要的值也太長! 因此 Windows 版本的 fd_set 就無法使用同樣的位元遮罩邏輯,而改由陣列的實做方案來實現相關功能。 Windows 版本的 fd_set 定義如下:

typedef struct fd_set
{
    u_int  fd_count;
    SOCKET fd_array[FD_SETSIZE];
} fd_set;

使用陣列或者使用位元遮罩的方案各有優劣之處(poll() 和 epoll 就都選用陣列方案), 但其中一個明顯的缺點就是資料尺寸的膨脹。 在使用位元遮罩的方案下,一個 32 位元的整數可以支援記錄最多 32 個 FD, 而同樣的整數(假定為 32 位元平臺)卻只能記錄一個指標值; 也就是說如果讓 fd_set 佔用 128 bytes 空間的話,在位元遮罩的方案下可以支援記錄最多 1024 個 FD, 然而在陣列方案下卻只能記錄最多 32 個 socket handler (假定為 32 位元平臺,指標為 4 bytes 大小)。 而在 Windows API 的說明文件裡記載,Windows 版本的 fd_set 最多可存入的 socket handler 為 64 個, 遠小於其它 UNIX like 預設的 1024 個。 這點對於一般普通程式來說大概沒有什麼差別,因為一般客戶端程式通常也用不了這麼多連線數量; 但對於伺服器程式來說,這就表示你一次能夠處理的連線數量就會嚴重的受到限制, 如果必需要能接受和處理超過這個數量的連線的話,那麼每次就得將連線分批進行檢查與處理, 會使軟體的設計工序更加複雜。

同樣因為使用陣列來記錄 FD 的原因,原來的 nfds 值就變得沒有意義了。 於是 Windows API 的作法就是忽略這個參數,並且在 fd_set 內另外準備一個成員變數用來記錄數量。 這樣的好處之一就是讓呼叫 select() 的使用者不用在準備設定 FD 的時候再去計算最大值, 而是可以讓 FD_SET() 自己去做計數累加; 而壞處就是讓 FD_ISSET() 的比對效率變差了,因為不再能夠使用位元遮罩直接檢查位元是否為零, 而是必須走訪遍歷整個陣列並檢查是否能找到指定的 FD。

為什麼 select() 效率差?

如果是有在寫作網路程式,或者有爬文研究過相關資料的人, 我想一定大多聽人說過 select() 的性能不好, 甚至不乏有人建議應該改用其它如 epoll 或 kqueue 等而不推薦使用 select()。 對此我給出的意見是很多時候改用其它的 API 不一定會更好,有時候反而會產生更多問題。 那麼這樣的話,什麼時候該用而什麼時候該不用呢? 要能夠正確的判斷,首先我們就要先知到 select() 究竟為什麼效能差的原因。

其實 select() 的效能和它的實現方式沒什麼關係, 因為底層的實做細節是可以隨著不同版本的作業系統和相關程式庫的不同而不斷疊代進步的, 因此其實 select() 與那些人人都說高效的 epoll 等的底層系統機制是一樣的。 那就奇怪了!既然底層是一樣的,那 select() 到底為什麼慢呢? 答案是它被拖累在那些無法改動的地方,也就是函式介面; 如果函式介面做了更動,那就失去了相容性,就必須得換個名字,比方說改叫作 epoll?!

首先再次回顧 select() 的使用範例。 當函式返回的時候,我們只能知道有一些 FD 已經達到期望的狀態; 但是具體到底是哪幾個 FD 已經備便可以進行進一步處理了呢? 於是我們得遍歷走訪所有的 FD 並一一檢查其是否被包含在 fd_set 內。 這是第一個導致效能降低的因素,特別是在於如果我們有成百上千個 FD 需要監視的時候! 再來是 select() 需要改變傳入的 fd_set 內容, 也就是說我們在呼叫 select() 前所建立的那些 fd_set 不能夠被重複使用, 每次呼叫 select() 之前都要再一遍重新建立 fd_set 物件, 這情況會在需要被監視的 FD 數量龐大的時候更加顯著的降低使用 select() 的效率! 最後一個因素在於每次呼叫 select() 的時候都要傳入、及傳回 fd_set 集合, 這表示 select() 函式的內部每次都要把一大堆的資料拷貝來又拷貝去 (預設情況下它應該有 128 bytes,Windows 版本更多), 即便每次呼叫 select() 的時候其實可能需要監視的 FD 都是一樣的。

對於 Windows 版本的 select() 來說情況會更加嚴峻! 因為 Windows 版本的 fd_set 是以整數陣列而非位元遮罩來實現, 因此在呼叫完 select() 後的 FD 比對工作效率會更加低下。 本來在連線的數量很多的時候,後面一一檢查比對的工作就已經不是很有效率了, 現在每一次 FD_ISSET() 又需要遍歷一次陣列,因此效率又更低了!

這就是為什麼 select() 的效能讓人嫌的原因。 並且造成這些問題的原因是來自於 select() 的函式介面設計, 因此無論底下內部如何升級如何改寫,只要它的介面改變不了,就無法解決它的效能問題; 而若要改變它的呼叫介面,那就失去了相容性,就成了新的 API,比如 epoll 等等的那些就是了。

另外除了效能的因素以外還有一些其它的差異因素也存在於這些不同的 API 之間, 因為本節著重在性能上的表現,就不特別展開那一部份了。 不過有興趣的讀者可以閱讀本篇下面的參考資料,他分析的更加完整詳盡。

為什麼要使用 select()?

前面講解了 select() 的使用方式,也解釋了為什麼常能聽聞它被人嫌的原因, 那麼有什麼理由我們還需要繼續使用 select() 呢? 在什麼樣的情況下使用 select() 會好過使用那些更加「先進」的 API 呢?

select() 相比於其它 API 的優勢在於兩點:相容性佳,以及一般通常場景下的效能表現並不俗。

首先是關於相容性的部份。 那些更加現代、更加「先進」的新式 API 都存在一個很明顯的缺點:都不俱備跨平臺通用性! epoll 是 Linux 限定的 API,kqueue 是 BSD 限定的 API,而 IOCP 則是 Windows 限定的 API。 這使得若使用了其中一套 API,就只能讓軟體在特定的作業系統下被使用; 若需要一個能夠跨平臺適用的方案,那就需要為個別平臺分別處理不同的處理程序, 這大概是跨平臺開發者討厭的事情之一。 當然還有別的解決方案,就是捨棄這全部並改用跨平臺的第三方程式庫,比如 libevent。 但如果程式需要考量移植性與相容性,並且希望使用原生 API 而非第三方程式庫, 那麼就只剩下一個方案:使用 select()

select() 與其他如 connect()listen() 等一眾函式, 隨著 Berkeley 的網路 API 一起流傳到全世界,成為世界上最早的通用網路 API 介面。 你管他思慮是不是不夠深遠而留下了在數十年之後讓人在效能上挑剔的問題呢, 作為世界第一個的開山始祖,幾乎所有的作業系統平臺都實現了這一套 API, 並且基於向後相容的緣故而一直保留至今。 也就是說,如果想要寫一份程式碼真正可以拿到大部份系統下都能流暢的使用, 那麼使用這一套 API 就成為相容性最高的做法了。 而如果因為任何緣故,使你想要、或者必須使用更先進的現代 API 介面, 則會遭遇到所開發的軟體只能限定作業系統使用的限制, 或者可能會讓你需要為不同系統製做不同處理方式的困擾了!

除了相容性的優勢之外,select() 的效能其實可能並沒有想像的那麼差! 雖然你可能已經聽聞過許多人說過 select() 的效能瓶頸, 本文前一節也才剛剛羅列了導致 select() 效能低落的許多原因, 但是這裡要告訴你, select() 的效能可能在大部份常見的應用用途上並沒有像大家所描述形容的那麼差, 甚至於在某些時候它的效能還能比那些當代的先進 API 還要更好! 而這就是讓我們選擇使用 select() 的另外一個考量因素。

前面才說過 select() 介面設計的先天缺陷,導致每次事件發生之後還得遍歷全部的 FD 並一一檢查, 然而這種情況多半是發生在伺服器軟體上,並且是同時可以接受並處理好多好多連線的那種服務程式。 可是若將目光放到一般普通開發者可能更常接觸的客戶端程式,則可能就是完全不一樣的風景! 對於一般普同常見的、執行在用戶終端的程式來說, 需要維護的連線數量往往只在個位數,甚至於可能經常也就是一條、兩條連線而已。 在這種連線數量極少的情況下,前一節所描述的那些不利因素的影響幾乎能夠忽略不計。 並且在這種情況下除非存在相當特別的條件,否則檔案描述符的數值一般也都不會太大 (據不負責任的經驗觀察,這時的 FD 數值大約小於 20), nfds 參數在此情況下可以提供相當的優化空間, 每次的函式呼叫也不見得就必須得為每個 fd_set 拷貝全部 128 bytes 的資料。 最終在同時需要監視的連線數量(也就是 FD 的數量)小於百位數的時候, 已經很難表現出明顯的性能差距(不過 Windows 版本不能套用這條件); 當連線數量只有個位數的時候,select() 和其他版本 API 之間基本已經難以觀察出差異。

此外若每個連線都不是會長時間佔用的連線,而是連上來簡單收送一些些資料就會斷線離開的情況下, 像 epoll 那樣的設計反而會因為需要更加頻繁的進行系統呼叫, 反而使得效能可能還沒有 select() 那樣單純的操作來得好! 類似 epoll 這樣的架構透過系統呼叫將連線資訊直接儲存在驅動程式的記憶空間裡, 所以不再需要像 select() 這樣每次呼叫的時候都要重新建立和拷貝一次 FD 列表, 然而若是在經常增加新連線和切斷舊連線的使用情境下,這方面的優勢得不到體現, 反而因為需要頻繁的切換使用者層和核心層去修改那些儲存的連線資訊,而拖慢了效能; 如果應用場景還都是短小資料的收送而缺少大資料的傳輸情境, 那又會進一步讓 epoll 這類框架的優勢無法發揮,使得 select() 的效能表現反而還更加良好! 那麼如果是在本段所描述的這種使用場景下, 則選用 select() 反而將是在效能上比起更加現代的 API 還更加優秀的選擇。

總結

本篇介紹了 select() API 函式的使用方式, 也解釋了它為什麼使用起來有那麼點不直覺的設計因素,並分析了它的優勢和劣勢。 select() 相比其它同類共能 API 的優勢在於簡單且移植相容性高, 並且在連線數量、或者觀測的檔案數量少的情況下性能並不俗, 甚至於在某些條件下表現的還比其它 API 更加優異; 而其主要缺點在於當連線數量很多的時候,使用 select() 在效能上的表現並不優良, 並且存在最大連監測數量的限制,對於大型伺服器程式的開發用途來說不利。

因此在實際的開發用途上,根據自身專案設定的使用場景與條件妥善挑選最合適的工具才是明智之舉, 並不需要刻意拉踩 select(),因為它在合適的條件下表現其實並不劣,甚至還可能更加優秀。 事實上一些相關的程式庫比如 libevent,就會判斷使用者的使用情況, 在底下分別依據不同的條件選用不同的 API 呼叫。

上一篇:「執行緒同步 14:條件變量」