Linux中國

並發伺服器(三):事件驅動

這是並發伺服器系列的第三節。第一節 介紹了阻塞式編程,第二節:線程 探討了多線程,將其作為一種可行的方法來實現伺服器並發編程。

另一種常見的實現並發的方法叫做 事件驅動編程,也可以叫做 非同步 編程 注1 。這種方法變化萬千,因此我們會從最基本的開始,使用一些基本的 API 而非從封裝好的高級方法開始。本系列以後的文章會講高層次抽象,還有各種混合的方法。

本系列的所有文章:

阻塞式 vs. 非阻塞式 I/O

作為本篇的介紹,我們先講講阻塞和非阻塞 I/O 的區別。阻塞式 I/O 更好理解,因為這是我們使用 I/O 相關 API 時的「標準」方式。從套接字接收數據的時候,調用 recv 函數會發生 阻塞,直到它從埠上接收到了來自另一端套接字的數據。這恰恰是第一部分講到的順序伺服器的問題。

因此阻塞式 I/O 存在著固有的性能問題。第二節里我們講過一種解決方法,就是用多線程。哪怕一個線程的 I/O 阻塞了,別的線程仍然可以使用 CPU 資源。實際上,阻塞 I/O 通常在利用資源方面非常高效,因為線程就等待著 —— 操作系統將線程變成休眠狀態,只有滿足了線程需要的條件才會被喚醒。

非阻塞式 I/O 是另一種思路。把套接字設成非阻塞模式時,調用 recv 時(還有 send,但是我們現在只考慮接收),函數返回的會很快,哪怕沒有接收到數據。這時,就會返回一個特殊的錯誤狀態 注2 來通知調用者,此時沒有數據傳進來。調用者可以去做其他的事情,或者嘗試再次調用 recv 函數。

示範阻塞式和非阻塞式的 recv 區別的最好方式就是貼一段示例代碼。這裡有個監聽套接字的小程序,一直在 recv 這裡阻塞著;當 recv 返回了數據,程序就報告接收到了多少個位元組 注3

int main(int argc, const char** argv) {
  setvbuf(stdout, NULL, _IONBF, 0);

  int portnum = 9988;
  if (argc >= 2) {
    portnum = atoi(argv[1]);
  }
  printf("Listening on port %dn", portnum);

  int sockfd = listen_inet_socket(portnum);
  struct sockaddr_in peer_addr;
  socklen_t peer_addr_len = sizeof(peer_addr);

  int newsockfd = accept(sockfd, (struct sockaddr*)&peer_addr, &peer_addr_len);
  if (newsockfd < 0) {
    perror_die("ERROR on accept");
  }
  report_peer_connected(&peer_addr, peer_addr_len);

  while (1) {
    uint8_t buf[1024];
    printf("Calling recv...n");
    int len = recv(newsockfd, buf, sizeof buf, 0);
    if (len < 0) {
      perror_die("recv");
    } else if (len == 0) {
      printf("Peer disconnected; I&apos;m done.n");
      break;
    }
    printf("recv returned %d bytesn", len);
  }

  close(newsockfd);
  close(sockfd);

  return 0;
}

主循環重複調用 recv 並且報告它返回的位元組數(記住 recv 返回 0 時,就是客戶端斷開連接了)。試著運行它,我們會在一個終端里運行這個程序,然後在另一個終端里用 nc 進行連接,發送一些字元,每次發送之間間隔幾秒鐘:

$ nc localhost 9988
hello                                   # wait for 2 seconds after typing this
socket world
^D                                      # to end the connection>

監聽程序會輸出以下內容:

$ ./blocking-listener 9988
Listening on port 9988
peer (localhost, 37284) connected
Calling recv...
recv returned 6 bytes
Calling recv...
recv returned 13 bytes
Calling recv...
Peer disconnected; I&apos;m done.

現在試試非阻塞的監聽程序的版本。這是代碼:

int main(int argc, const char** argv) {
  setvbuf(stdout, NULL, _IONBF, 0);

  int portnum = 9988;
  if (argc >= 2) {
    portnum = atoi(argv[1]);
  }
  printf("Listening on port %dn", portnum);

  int sockfd = listen_inet_socket(portnum);
  struct sockaddr_in peer_addr;
  socklen_t peer_addr_len = sizeof(peer_addr);

  int newsockfd = accept(sockfd, (struct sockaddr*)&peer_addr, &peer_addr_len);
  if (newsockfd < 0) {
    perror_die("ERROR on accept");
  }
  report_peer_connected(&peer_addr, peer_addr_len);

  // 把套接字設成非阻塞模式
  int flags = fcntl(newsockfd, F_GETFL, 0);
  if (flags == -1) {
    perror_die("fcntl F_GETFL");
  }

  if (fcntl(newsockfd, F_SETFL, flags | O_NONBLOCK) == -1) {
    perror_die("fcntl F_SETFL O_NONBLOCK");
  }

  while (1) {
    uint8_t buf[1024];
    printf("Calling recv...n");
    int len = recv(newsockfd, buf, sizeof buf, 0);
    if (len < 0) {
      if (errno == EAGAIN || errno == EWOULDBLOCK) {
        usleep(200 * 1000);
        continue;
      }
      perror_die("recv");
    } else if (len == 0) {
      printf("Peer disconnected; I&apos;m done.n");
      break;
    }
    printf("recv returned %d bytesn", len);
  }

  close(newsockfd);
  close(sockfd);

  return 0;
}

這裡與阻塞版本有些差異,值得注意:

  1. accept 函數返回的 newsocktfd 套接字因調用了 fcntl, 被設置成非阻塞的模式。
  2. 檢查 recv 的返回狀態時,我們對 errno 進行了檢查,判斷它是否被設置成表示沒有可供接收的數據的狀態。這時,我們僅僅是休眠了 200 毫秒然後進入到下一輪循環。

同樣用 nc 進行測試,以下是非阻塞監聽器的輸出:

$ ./nonblocking-listener 9988
Listening on port 9988
peer (localhost, 37288) connected
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
recv returned 6 bytes
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
Calling recv...
recv returned 13 bytes
Calling recv...
Calling recv...
Calling recv...
Peer disconnected; I&apos;m done.

作為練習,給輸出添加一個時間戳,確認調用 recv 得到結果之間花費的時間是比輸入到 nc 中所用的多還是少(每一輪是 200 ms)。

這裡就實現了使用非阻塞的 recv 讓監聽者檢查套接字變為可能,並且在沒有數據的時候重新獲得控制權。換句話說,用編程的語言說這就是 輪詢 polling —— 主程序周期性的查詢套接字以便讀取數據。

對於順序響應的問題,這似乎是個可行的方法。非阻塞的 recv 讓同時與多個套接字通信變成可能,輪詢這些套接字,僅當有新數據到來時才處理。就是這樣,這種方式 可以 用來寫並發伺服器;但實際上一般不這麼做,因為輪詢的方式很難擴展。

首先,我在代碼中引入的 200ms 延遲對於演示非常好(監聽器在我輸入 nc 之間只列印幾行 「Calling recv...」,但實際上應該有上千行)。但它也增加了多達 200ms 的伺服器響應時間,這無意是不必要的。實際的程序中,延遲會低得多,休眠時間越短,進程佔用的 CPU 資源就越多。有些時鐘周期只是浪費在等待,這並不好,尤其是在移動設備上,這些設備的電量往往有限。

但是當我們實際這樣來使用多個套接字的時候,更嚴重的問題出現了。想像下監聽器正在同時處理 1000 個客戶端。這意味著每一個循環迭代裡面,它都得為 這 1000 個套接字中的每一個 執行一遍非阻塞的 recv,找到其中準備好了數據的那一個。這非常低效,並且極大的限制了伺服器能夠並發處理的客戶端數。這裡有個準則:每次輪詢之間等待的間隔越久,伺服器響應性越差;而等待的時間越少,CPU 在無用的輪詢上耗費的資源越多。

講真,所有的輪詢都像是無用功。當然操作系統應該是知道哪個套接字是準備好了數據的,因此沒必要逐個掃描。事實上,就是這樣,接下來就會講一些 API,讓我們可以更優雅地處理多個客戶端。

select

select 的系統調用是可移植的(POSIX),是標準 Unix API 中常有的部分。它是為上一節最後一部分描述的問題而設計的 —— 允許一個線程可以監視許多文件描述符 注4 的變化,而不用在輪詢中執行不必要的代碼。我並不打算在這裡引入一個關於 select 的全面教程,有很多網站和書籍講這個,但是在涉及到問題的相關內容時,我會介紹一下它的 API,然後再展示一個非常複雜的例子。

select 允許 多路 I/O,監視多個文件描述符,查看其中任何一個的 I/O 是否可用。

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

readfds 指向文件描述符的緩衝區,這個緩衝區被監視是否有讀取事件;fd_set 是一個特殊的數據結構,用戶使用 FD_* 宏進行操作。writefds 是針對寫事件的。nfds 是監視的緩衝中最大的文件描述符數字(文件描述符就是整數)。timeout 可以讓用戶指定 select 應該阻塞多久,直到某個文件描述符準備好了(timeout == NULL 就是說一直阻塞著)。現在先跳過 exceptfds

select 的調用過程如下:

  1. 在調用之前,用戶先要為所有不同種類的要監視的文件描述符創建 fd_set 實例。如果想要同時監視讀取和寫入事件,readfdswritefds 都要被創建並且引用。
  2. 用戶可以使用 FD_SET 來設置集合中想要監視的特殊描述符。例如,如果想要監視描述符 2、7 和 10 的讀取事件,在 readfds 這裡調用三次 FD_SET,分別設置 2、7 和 10。
  3. select 被調用。
  4. select 返回時(現在先不管超時),就是說集合中有多少個文件描述符已經就緒了。它也修改 readfdswritefds 集合,來標記這些準備好的描述符。其它所有的描述符都會被清空。
  5. 這時用戶需要遍歷 readfdswritefds,找到哪個描述符就緒了(使用 FD_ISSET)。

作為完整的例子,我在並發的伺服器程序上使用 select,重新實現了我們之前的協議。完整的代碼在這裡;接下來的是代碼中的重點部分及注釋。警告:示例代碼非常複雜,因此第一次看的時候,如果沒有足夠的時間,快速瀏覽也沒有關係。

使用 select 的並發伺服器

使用 I/O 的多發 API 諸如 select 會給我們伺服器的設計帶來一些限制;這不會馬上顯現出來,但這值得探討,因為它們是理解事件驅動編程到底是什麼的關鍵。

最重要的是,要記住這種方法本質上是單線程的 注5 。伺服器實際上在 同一時刻只能做一件事。因為我們想要同時處理多個客戶端請求,我們需要換一種方式重構代碼。

首先,讓我們談談主循環。它看起來是什麼樣的呢?先讓我們想像一下伺服器有一堆任務,它應該監視哪些東西呢?兩種類型的套接字活動:

  1. 新客戶端嘗試連接。這些客戶端應該被 accept
  2. 已連接的客戶端發送數據。這個數據要用 第一節 中所講到的協議進行傳輸,有可能會有一些數據要被回送給客戶端。

儘管這兩種活動在本質上有所區別,我們還是要把它們放在一個循環里,因為只能有一個主循環。循環會包含 select 的調用。這個 select 的調用會監視上述的兩種活動。

這裡是部分代碼,設置了文件描述符集合,並在主循環里轉到被調用的 select 部分。

// 「master」 集合存活在該循環中,跟蹤我們想要監視的讀取事件或寫入事件的文件描述符(FD)。
fd_set readfds_master;
FD_ZERO(&readfds_master);
fd_set writefds_master;
FD_ZERO(&writefds_master);

// 監聽的套接字一直被監視,用於讀取數據,並監測到來的新的端點連接。
FD_SET(listener_sockfd, &readfds_master);

// 要想更加高效,fdset_max 追蹤當前已知最大的 FD;這使得每次調用時對 FD_SETSIZE 的迭代選擇不是那麼重要了。
int fdset_max = listener_sockfd;

while (1) {
  // select() 會修改傳遞給它的 fd_sets,因此進行拷貝一下再傳值。
  fd_set readfds = readfds_master;
  fd_set writefds = writefds_master;

  int nready = select(fdset_max + 1, &readfds, &writefds, NULL, NULL);
  if (nready < 0) {
    perror_die("select");
  }
  ...

這裡的一些要點:

  1. 由於每次調用 select 都會重寫傳遞給函數的集合,調用器就得維護一個 「master」 集合,在循環迭代中,保持對所監視的所有活躍的套接字的追蹤。
  2. 注意我們所關心的,最開始的唯一那個套接字是怎麼變成 listener_sockfd 的,這就是最開始的套接字,伺服器藉此來接收新客戶端的連接。
  3. select 的返回值,是在作為參數傳遞的集合中,那些已經就緒的描述符的個數。select 修改這個集合,用來標記就緒的描述符。下一步是在這些描述符中進行迭代。
...
for (int fd = 0; fd <= fdset_max && nready > 0; fd++) {
  // 檢查 fd 是否變成可讀的
  if (FD_ISSET(fd, &readfds)) {
    nready--;

    if (fd == listener_sockfd) {
      // 監聽的套接字就緒了;這意味著有個新的客戶端連接正在聯繫
      ...
    } else {
      fd_status_t status = on_peer_ready_recv(fd);
      if (status.want_read) {
        FD_SET(fd, &readfds_master);
      } else {
        FD_CLR(fd, &readfds_master);
      }
      if (status.want_write) {
        FD_SET(fd, &writefds_master);
      } else {
        FD_CLR(fd, &writefds_master);
      }
      if (!status.want_read && !status.want_write) {
        printf("socket %d closingn", fd);
        close(fd);
      }
    }

這部分循環檢查 可讀的 描述符。讓我們跳過監聽器套接字(要瀏覽所有內容,看這個代碼) 然後看看當其中一個客戶端準備好了之後會發生什麼。出現了這種情況後,我們調用一個叫做 on_peer_ready_recv回調 函數,傳入相應的文件描述符。這個調用意味著客戶端連接到套接字上,發送某些數據,並且對套接字上 recv 的調用不會被阻塞 注6 。這個回調函數返回結構體 fd_status_t

typedef struct {
  bool want_read;
  bool want_write;
} fd_status_t;

這個結構體告訴主循環,是否應該監視套接字的讀取事件、寫入事件,或者兩者都監視。上述代碼展示了 FD_SETFD_CLR 是怎麼在合適的描述符集合中被調用的。對於主循環中某個準備好了寫入數據的描述符,代碼是類似的,除了它所調用的回調函數,這個回調函數叫做 on_peer_ready_send

現在來花點時間看看這個回調:

typedef enum { INITIAL_ACK, WAIT_FOR_MSG, IN_MSG } ProcessingState;

#define SENDBUF_SIZE 1024

typedef struct {
  ProcessingState state;

  // sendbuf 包含了伺服器要返回給客戶端的數據。on_peer_ready_recv 句柄填充這個緩衝,
  // on_peer_read_send 進行消耗。sendbuf_end 指向緩衝區的最後一個有效位元組,
  // sendptr 指向下個位元組
  uint8_t sendbuf[SENDBUF_SIZE];
  int sendbuf_end;
  int sendptr;
} peer_state_t;

// 每一端都是通過它連接的文件描述符(fd)進行區分。只要客戶端連接上了,fd 就是唯一的。
// 當客戶端斷開連接,另一個客戶端連接上就會獲得相同的 fd。on_peer_connected 應該
// 進行初始化,以便移除舊客戶端在同一個 fd 上留下的東西。
peer_state_t global_state[MAXFDS];

fd_status_t on_peer_ready_recv(int sockfd) {
  assert(sockfd < MAXFDs);
  peer_state_t* peerstate = &global_state[sockfd];

  if (peerstate->state == INITIAL_ACK ||
      peerstate->sendptr < peerstate->sendbuf_end) {
    // 在初始的 ACK 被送到了客戶端,就沒有什麼要接收的了。
    // 等所有待發送的數據都被發送之後接收更多的數據。
    return fd_status_W;
  }

  uint8_t buf[1024];
  int nbytes = recv(sockfd, buf, sizeof buf, 0);
  if (nbytes == 0) {
    // 客戶端斷開連接
    return fd_status_NORW;
  } else if (nbytes < 0) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
      // 套接字 *實際* 並沒有準備好接收,等到它就緒。
      return fd_status_R;
    } else {
      perror_die("recv");
    }
  }
  bool ready_to_send = false;
  for (int i = 0; i < nbytes; ++i) {
    switch (peerstate->state) {
    case INITIAL_ACK:
      assert(0 && "can&apos;t reach here");
      break;
    case WAIT_FOR_MSG:
      if (buf[i] == &apos;^&apos;) {
        peerstate->state = IN_MSG;
      }
      break;
    case IN_MSG:
      if (buf[i] == &apos;$&apos;) {
        peerstate->state = WAIT_FOR_MSG;
      } else {
        assert(peerstate->sendbuf_end < SENDBUF_SIZE);
        peerstate->sendbuf[peerstate->sendbuf_end++] = buf[i] + 1;
        ready_to_send = true;
      }
      break;
    }
  }
  // 如果沒有數據要發送給客戶端,報告讀取狀態作為最後接收的結果。
  return (fd_status_t){.want_read = !ready_to_send,
                       .want_write = ready_to_send};
}

peer_state_t 是全狀態對象,用來表示在主循環中兩次回調函數調用之間的客戶端的連接。因為回調函數在客戶端發送的某些數據時被調用,不能假設它能夠不停地與客戶端通信,並且它得運行得很快,不能被阻塞。因為套接字被設置成非阻塞模式,recv 會快速的返回。除了調用 recv, 這個句柄做的是處理狀態,沒有其它的調用,從而不會發生阻塞。

舉個例子,你知道為什麼這個代碼需要一個額外的狀態嗎?這個系列中,我們的伺服器目前只用到了兩個狀態,但是這個伺服器程序需要三個狀態。

來看看 「套接字準備好發送」 的回調函數:

fd_status_t on_peer_ready_send(int sockfd) {
  assert(sockfd < MAXFDs);
  peer_state_t* peerstate = &global_state[sockfd];

  if (peerstate->sendptr >= peerstate->sendbuf_end) {
    // 沒有要發送的。
    return fd_status_RW;
  }
  int sendlen = peerstate->sendbuf_end - peerstate->sendptr;
  int nsent = send(sockfd, peerstate->sendbuf, sendlen, 0);
  if (nsent == -1) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
      return fd_status_W;
    } else {
      perror_die("send");
    }
  }
  if (nsent < sendlen) {
    peerstate->sendptr += nsent;
    return fd_status_W;
  } else {
    // 所有東西都成功發送;重置發送隊列。
    peerstate->sendptr = 0;
    peerstate->sendbuf_end = 0;

    // 如果我們現在是處於特殊的 INITIAL_ACK 狀態,就轉變到其他狀態。
    if (peerstate->state == INITIAL_ACK) {
      peerstate->state = WAIT_FOR_MSG;
    }

    return fd_status_R;
  }
}

這裡也一樣,回調函數調用了一個非阻塞的 send,演示了狀態管理。在非同步代碼中,回調函數執行的很快是受爭議的,任何延遲都會阻塞主循環進行處理,因此也阻塞了整個伺服器程序去處理其他的客戶端。

用腳步再來運行這個伺服器,同時連接 3 個客戶端。在一個終端中我們運行下面的命令:

$ ./select-server

在另一個終端中:

$ python3.6 simple-client.py  -n 3 localhost 9090
INFO:2017-09-26 05:29:15,864:conn1 connected...
INFO:2017-09-26 05:29:15,864:conn2 connected...
INFO:2017-09-26 05:29:15,864:conn0 connected...
INFO:2017-09-26 05:29:15,865:conn1 sending b&apos;^abc$de^abte$f&apos;
INFO:2017-09-26 05:29:15,865:conn2 sending b&apos;^abc$de^abte$f&apos;
INFO:2017-09-26 05:29:15,865:conn0 sending b&apos;^abc$de^abte$f&apos;
INFO:2017-09-26 05:29:15,865:conn1 received b&apos;bcdbcuf&apos;
INFO:2017-09-26 05:29:15,865:conn2 received b&apos;bcdbcuf&apos;
INFO:2017-09-26 05:29:15,865:conn0 received b&apos;bcdbcuf&apos;
INFO:2017-09-26 05:29:16,866:conn1 sending b&apos;xyz^123&apos;
INFO:2017-09-26 05:29:16,867:conn0 sending b&apos;xyz^123&apos;
INFO:2017-09-26 05:29:16,867:conn2 sending b&apos;xyz^123&apos;
INFO:2017-09-26 05:29:16,867:conn1 received b&apos;234&apos;
INFO:2017-09-26 05:29:16,868:conn0 received b&apos;234&apos;
INFO:2017-09-26 05:29:16,868:conn2 received b&apos;234&apos;
INFO:2017-09-26 05:29:17,868:conn1 sending b&apos;25$^ab0000$abab&apos;
INFO:2017-09-26 05:29:17,869:conn1 received b&apos;36bc1111&apos;
INFO:2017-09-26 05:29:17,869:conn0 sending b&apos;25$^ab0000$abab&apos;
INFO:2017-09-26 05:29:17,870:conn0 received b&apos;36bc1111&apos;
INFO:2017-09-26 05:29:17,870:conn2 sending b&apos;25$^ab0000$abab&apos;
INFO:2017-09-26 05:29:17,870:conn2 received b&apos;36bc1111&apos;
INFO:2017-09-26 05:29:18,069:conn1 disconnecting
INFO:2017-09-26 05:29:18,070:conn0 disconnecting
INFO:2017-09-26 05:29:18,070:conn2 disconnecting

和線程的情況相似,客戶端之間沒有延遲,它們被同時處理。而且在 select-server 也沒有用線程!主循環 多路 處理所有的客戶端,通過高效使用 select 輪詢多個套接字。回想下 第二節中 順序的 vs 多線程的客戶端處理過程的圖片。對於我們的 select-server,三個客戶端的處理流程像這樣:

多客戶端處理流程

所有的客戶端在同一個線程中同時被處理,通過乘積,做一點這個客戶端的任務,然後切換到另一個,再切換到下一個,最後切換回到最開始的那個客戶端。注意,這裡沒有什麼循環調度,客戶端在它們發送數據的時候被客戶端處理,這實際上是受客戶端左右的。

同步、非同步、事件驅動、回調

select-server 示例代碼為討論什麼是非同步編程、它和事件驅動及基於回調的編程有何聯繫,提供了一個良好的背景。因為這些辭彙在並發伺服器的(非常矛盾的)討論中很常見。

讓我們從一段 select 的手冊頁面中引用的一句話開始:

select,pselect,FD_CLR,FD_ISSET,FD_SET,FD_ZERO - 同步 I/O 處理

因此 select同步 處理。但我剛剛演示了大量代碼的例子,使用 select 作為 非同步 處理伺服器的例子。有哪些東西?

答案是:這取決於你的觀察角度。同步常用作阻塞處理,並且對 select 的調用實際上是阻塞的。和第 1、2 節中講到的順序的、多線程的伺服器中對 sendrecv 是一樣的。因此說 select同步的 API 是有道理的。可是,伺服器的設計卻可以是 非同步的,或是 基於回調的,或是 事件驅動的,儘管其中有對 select 的使用。注意這裡的 on_peer_* 函數是回調函數;它們永遠不會阻塞,並且只有網路事件觸發的時候才會被調用。它們可以獲得部分數據,並能夠在調用過程中保持穩定的狀態。

如果你曾經做過一些 GUI 編程,這些東西對你來說應該很親切。有個 「事件循環」,常常完全隱藏在框架里,應用的 「業務邏輯」 建立在回調上,這些回調會在各種事件觸發後被調用,用戶點擊滑鼠、選擇菜單、定時器觸發、數據到達套接字等等。曾經最常見的編程模型是客戶端的 JavaScript,這裡面有一堆回調函數,它們在瀏覽網頁時用戶的行為被觸發。

select 的局限

使用 select 作為第一個非同步伺服器的例子對於說明這個概念很有用,而且由於 select 是很常見、可移植的 API。但是它也有一些嚴重的缺陷,在監視的文件描述符非常大的時候就會出現。

  1. 有限的文件描述符的集合大小。
  2. 糟糕的性能。

從文件描述符的大小開始。FD_SETSIZE 是一個編譯期常數,在如今的操作系統中,它的值通常是 1024。它被硬編碼在 glibc 的頭文件里,並且不容易修改。它把 select 能夠監視的文件描述符的數量限制在 1024 以內。曾有些人想要寫出能夠處理上萬個並發訪問的客戶端請求的伺服器,所以這個問題很有現實意義。有一些方法,但是不可移植,也很難用。

糟糕的性能問題就好解決的多,但是依然非常嚴重。注意當 select 返回的時候,它向調用者提供的信息是 「就緒的」 描述符的個數,還有被修改過的描述符集合。描述符集映射著描述符「就緒/未就緒」,但是並沒有提供什麼有效的方法去遍歷所有就緒的描述符。如果只有一個描述符是就緒的,最壞的情況是調用者需要遍歷 整個集合 來找到那個描述符。這在監視的描述符數量比較少的時候還行,但是如果數量變的很大的時候,這種方法弊端就凸顯出了 注7

由於這些原因,為了寫出高性能的並發伺服器, select 已經不怎麼用了。每一個流行的操作系統有獨特的不可移植的 API,允許用戶寫出非常高效的事件循環;像框架這樣的高級結構還有高級語言通常在一個可移植的介面中包含這些 API。

epoll

舉個例子,來看看 epoll,Linux 上的關於高容量 I/O 事件通知問題的解決方案。epoll 高效的關鍵之處在於它與內核更好的協作。不是使用文件描述符,epoll_wait 用當前準備好的事件填滿一個緩衝區。只有準備好的事件添加到了緩衝區,因此沒有必要遍歷客戶端中當前 所有 監視的文件描述符。這簡化了查找就緒的描述符的過程,把空間複雜度從 select 中的 O(N) 變為了 O(1)。

關於 epoll API 的完整展示不是這裡的目的,網上有很多相關資源。雖然你可能猜到了,我還要寫一個不同的並發伺服器,這次是用 epool 而不是 select。完整的示例代碼 在這裡。實際上,由於大部分代碼和 用 select 的伺服器相同,所以我只會講要點,在主循環里使用 epoll

struct epoll_event accept_event;
accept_event.data.fd = listener_sockfd;
accept_event.events = EPOLLIN;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listener_sockfd, &accept_event) < 0) {
  perror_die("epoll_ctl EPOLL_CTL_ADD");
}

struct epoll_event* events = calloc(MAXFDS, sizeof(struct epoll_event));
if (events == NULL) {
  die("Unable to allocate memory for epoll_events");
}

while (1) {
  int nready = epoll_wait(epollfd, events, MAXFDS, -1);
  for (int i = 0; i < nready; i++) {
    if (events[i].events & EPOLLERR) {
      perror_die("epoll_wait returned EPOLLERR");
    }

    if (events[i].data.fd == listener_sockfd) {
      // 監聽的套接字就緒了;意味著新客戶端正在連接。
      ...
    } else {
      // A peer socket is ready.
      if (events[i].events & EPOLLIN) {
        // 準備好了讀取
        ...
      } else if (events[i].events & EPOLLOUT) {
        // 準備好了寫入
        ...
      }
    }
  }
}

通過調用 epoll_ctl 來配置 epoll。這時,配置監聽的套接字數量,也就是 epoll 監聽的描述符的數量。然後分配一個緩衝區,把就緒的事件傳給 epoll 以供修改。在主循環里對 epoll_wait 的調用是魅力所在。它阻塞著,直到某個描述符就緒了(或者超時),返回就緒的描述符數量。但這時,不要盲目地迭代所有監視的集合,我們知道 epoll_write 會修改傳給它的 events 緩衝區,緩衝區中有就緒的事件,從 0 到 nready-1,因此我們只需迭代必要的次數。

要在 select 裡面重新遍歷,有明顯的差異:如果在監視著 1000 個描述符,只有兩個就緒, epoll_waits 返回的是 nready=2,然後修改 events 緩衝區最前面的兩個元素,因此我們只需要「遍歷」兩個描述符。用 select 我們就需要遍歷 1000 個描述符,找出哪個是就緒的。因此,在繁忙的伺服器上,有許多活躍的套接字時 epollselect 更加容易擴展。

剩下的代碼很直觀,因為我們已經很熟悉 「select 伺服器」 了。實際上,「epoll 伺服器」 中的所有「業務邏輯」和 「select 伺服器」 是一樣的,回調構成相同的代碼。

這種相似是通過將事件循環抽象分離到一個庫/框架中。我將會詳述這些內容,因為很多優秀的程序員曾經也是這樣做的。相反,下一篇文章里我們會了解 libuv,一個最近出現的更加受歡迎的時間循環抽象層。像 libuv 這樣的庫讓我們能夠寫出並發的非同步伺服器,並且不用考慮系統調用下繁瑣的細節。

  • 注1:我試著在做網路瀏覽和閱讀這兩件事的實際差別中突顯自己,但經常做得頭疼。有很多不同的選項,從「它們是一樣的東西」到「一個是另一個的子集」,再到「它們是完全不同的東西」。在面臨這樣主觀的觀點時,最好是完全放棄這個問題,專註特殊的例子和用例。
  • 注2:POSIX 表示這可以是 EAGAIN,也可以是 EWOULDBLOCK,可移植應用應該對這兩個都進行檢查。
  • 注3:和這個系列所有的 C 示例類似,代碼中用到了某些助手工具來設置監聽套接字。這些工具的完整代碼在這個 倉庫utils 模塊里。
  • 注4:select 不是網路/套接字專用的函數,它可以監視任意的文件描述符,有可能是硬碟文件、管道、終端、套接字或者 Unix 系統中用到的任何文件描述符。這篇文章里,我們主要關注它在套接字方面的應用。
  • 注5:有多種方式用多線程來實現事件驅動,我會把它放在稍後的文章中進行討論。
  • 注6:由於各種非實驗因素,它 仍然 可以阻塞,即使是在 select 說它就緒了之後。因此伺服器上打開的所有套接字都被設置成非阻塞模式,如果對 recvsend 的調用返回了 EAGAIN 或者 EWOULDBLOCK,回調函數就裝作沒有事件發生。閱讀示例代碼的注釋可以了解更多細節。
  • 注7:注意這比該文章前面所講的非同步輪詢的例子要稍好一點。輪詢需要 一直 發生,而 select 實際上會阻塞到有一個或多個套接字準備好讀取/寫入;select 會比一直詢問浪費少得多的 CPU 時間。

via: https://eli.thegreenplace.net/2017/concurrent-servers-part-3-event-driven/

作者:Eli Bendersky 譯者:GitFuture 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國