聊一聊 .NET在Linux下的IO多路復用select和epoll
一、背景
1. 講故事
在windows平臺上,相信很多人都知道.NET異步機制是借助了Windows自帶的 IO完成端口
實現的異步交互,那在 Linux 下.NET 又是怎么玩的呢?主要還是傳統的 select,poll,epoll 的IO多路復用,在 coreclr源代碼中我們都能找到它們的影子。
select & poll
在平臺適配層的 pal.cpp
文件中,有這樣的一句話。
#if HAVE_POLL
#include <poll.h>
#else
#include "pal/fakepoll.h"
#endif // HAVE_POLL
簡而言之就是在不支持 poll 的linux版本中使用 select(fakepoll) 模擬,參考代碼如下:
圖片
2. epoll
同樣的在 linux 中你也會發現很多,截圖如下:
圖片
二、select IO多路復用
1. select 解讀
在沒有 select 之前,我們需要手工管理多句柄的收發,在使用select IO多路復用技術之后,這些多句柄管理就由用戶轉交給linux系統了,這個也可以從核心的 select
函數看出。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- readfds,writefds,exceptfds
這三個字段依次監視著哪些句柄已成可讀狀態,哪些句柄已成可寫狀態,哪些句柄已成異常狀態,那技術上是如何實現的呢?在libc 中定義了一個 bit 數組,剛好文件句柄fd值
作為 bit數組的索引,linux 在內核中只需要掃描 __fds_bits 中哪些位為1 即可找到需要監控的句柄。
/* fd_set for select and pselect. */
typedef struct
{
/* XPG4.2 requires this member name. Otherwise avoid the name
from the global namespace. */
#ifdef __USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;
- nfds,timeout
為了減少掃描范圍,提高程序性能,需要用戶指定一個最大的掃描值到 nfds 上。后面的timeout即超時時間。
2. select 的一個小例子
說了再多還不如一個例子有說服力,我們使用 select 機制對 Console 控制臺句柄 (STDIN_FILENO) 進行監控,一旦有數據進來立馬輸出,參考代碼如下:
#include <stdio.h>
#include <sys/select.h>
#include <unistd.h>
int main()
{
fd_set readfds;
struct timeval timeout;
char buf[256];
printf("Enter text (press Ctrl+D to end):\n");
while (1)
{
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO, &readfds);
timeout.tv_sec = 5; // 5秒超時
timeout.tv_usec = 0;
int ready = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &timeout);
if (ready == -1)
{
perror("select");
break;
}
elseif (ready == 0)
{
printf("\nTimeout (5秒無輸入).\n");
break;
}
elseif (FD_ISSET(STDIN_FILENO, &readfds))
{
// 使用 fgets 逐行讀取
if (fgets(buf, sizeof(buf), stdin) != NULL)
{
printf("You entered: %s", buf); // 輸出整行(包含換行符)
}
else
{
printf("\nEnd of input (Ctrl+D pressed).\n");
break;
}
}
}
return0;
}
圖片
稍微解釋下代碼邏輯。
/* Standard file descriptors. */
#define STDIN_FILENO 0 /* Standard input. */
#define STDOUT_FILENO 1 /* Standard output. */
#define STDERR_FILENO 2 /* Standard error output. */
- 將 STDIN_FILENO=0 塞入到可讀句柄監控 (readfds) 中。
- 數據進來之后 select 被喚醒,執行后續邏輯。
- 通過 FD_ISSET 判斷 bit=0 的位置(STDIN_FILENO)是否可用,可用的話讀取數據。
如果大家對 select 底層代碼感興趣,可以看下 linux 的 do_select
簡化實現,大量的遍歷邏輯(bit)。
static noinline_for_stack int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
{
for (;;) {
unsignedlong *rinp, *routp, *rexp, *inp, *outp, *exp;
bool can_busy_loop = false;
inp = fds->in; outp = fds->out; exp = fds->ex;
rinp = fds->res_in; routp = fds->res_out; rexp = fds->res_ex;
for (i = 0; i < n; ++rinp, ++routp, ++rexp) {
in = *inp++; out = *outp++; ex = *exp++;
all_bits = in | out | ex;
for (j = 0; j < BITS_PER_LONG; ++j, ++i, bit <<= 1) {
mask = select_poll_one(i, wait, in, out, bit,busy_flag);
if ((mask & POLLIN_SET) && (in & bit)) {
res_in |= bit;
retval++;
wait->_qproc = NULL;
}
if ((mask & POLLOUT_SET) && (out & bit)) {
res_out |= bit;
retval++;
wait->_qproc = NULL;
}
if ((mask & POLLEX_SET) && (ex & bit)) {
res_ex |= bit;
retval++;
wait->_qproc = NULL;
}
}
}
if (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE, to, slack))
timed_out = 1;
}
return retval;
}
三、epoll IO多路復用
1. epoll 解讀
現在主流的軟件(Redis,Nigix) 都是采用 epoll,它解決了select低效的遍歷,畢竟數組最多支持1024個bit位,一旦句柄過多會影響異步讀取的效率。epoll的底層借助了。
- 紅黑樹:對句柄進行管理,復雜度為 O(logN)。
- 就緒隊列:一旦句柄變得可讀或可寫,內核會直接將句柄送到就緒隊列。
libc中使用 epoll_wait
函數監視著就緒隊列,一旦有數據立即提取,復雜度 O(1),其實這個機制和 Windows 的IO完成端口 已經很靠近了,最后配一下參考代碼。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
#define MAX_EVENTS 10 // 最大監聽事件數
#define TIMEOUT_MS 5000 // epoll_wait 超時時間(毫秒)
int main()
{
int epoll_fd, nfds; // epoll 文件描述符和返回的事件數
struct epoll_event ev, events[MAX_EVENTS];// epoll 事件結構體
char buf[256];
// 創建 epoll 實例
epoll_fd = epoll_create1(0);
if (epoll_fd == -1)
{
perror("epoll_create1");
exit(EXIT_FAILURE);
}
// 配置并添加標準輸入到 epoll 監聽
ev.events = EPOLLIN; // 監聽文件描述符的可讀事件(輸入)
ev.data.fd = STDIN_FILENO; // 監聽標準輸入(文件描述符 0)
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, STDIN_FILENO, &ev) == -1)
{
perror("epoll_ctl: STDIN_FILENO");
exit(EXIT_FAILURE);
}
printf("Enter text line by line (press Ctrl+D to end):\n");
// 主循環:監聽事件
while (1)
{
// 等待事件發生或超時
nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, TIMEOUT_MS);
if (nfds == -1)
{
perror("epoll_wait");
break;
}
elseif (nfds == 0)
{
printf("\nTimeout (5秒無輸入).\n");
break;
}
// 處理所有觸發的事件
for (int n = 0; n < nfds; ++n)
{
if (events[n].data.fd == STDIN_FILENO)
{
// 使用 fgets 逐行讀取輸入
if (fgets(buf, sizeof(buf), stdin) != NULL)
{
printf("You entered: %s", buf);
}
else
{
// 輸入結束(用戶按下 Ctrl+D)
printf("\nEnd of input (Ctrl+D pressed).\n");
break;
}
}
}
}
close(epoll_fd);
return0;
}
圖片
四、總結
說了這么多,文尾總結下目前主流的 epoll 和 iocp 各自的特點。
特性 | epoll (Linux) | IOCP (Windows) |
模型 | 事件驅動 (Reactor) | 完成端口 (Proactor) |
核心思想 | 通知可讀寫事件 | 通知I/O操作完成 |
適用場景 | 高并發網絡編程 | 高并發I/O操作 |
編程復雜度 | 較低 | 較高 |
網絡I/O性能 | 極佳(百萬級連接) | 優秀 |
磁盤I/O支持 | 有限 | 完善 |
CPU利用率 | 高 | 中 |
內存開銷 | 低 | 中 |