加载中...

[epoll] EP5 跨平台 IO 多路复用对比

  • 16分钟
[epoll] EP5 跨平台 IO 多路复用对比

前几章我们深入学习了 Linux 的 epoll,了解了它的原理、用法和优化技巧。但现实世界的软件开发往往不会这么单纯——你的服务器可能跑在 Linux 上,但开发机器是 macOS;你的 CLI 工具可能需要同时支持 Linux、macOS 和 Windows。

这就引出了一个问题:epoll 只存在于 Linux,其他操作系统怎么办?

本章我们将探索三大操作系统的 IO 多路复用机制,理解它们的设计哲学,以及如何编写跨平台的网络程序。

为什么要了解跨平台差异?

在开始技术细节之前,让我们先思考一个问题:为什么不同操作系统会有不同的 IO 多路复用机制?

历史的分叉

Unix 的历史充满了分裂和竞争。1980 年代,AT&T 的 Unix System V 和 BSD(伯克利软件发行版)走上了不同的发展道路。这种分裂延续至今:

每个分支都独立发展了自己的 IO 多路复用机制:

  • Linux 在 2002 年引入 epoll(作为 select/poll 的替代)
  • FreeBSD 在 2000 年引入 kqueue(比 epoll 还早!)
  • Windows NT 在 1993 年就有了 IOCP(IO Completion Ports)

有趣的是,三者几乎同时期诞生,但设计思路各有不同。这不是巧合——随着互联网的爆发,所有操作系统都在思考如何高效处理海量并发连接。

设计哲学的差异

三种机制的核心差异在于"谁来驱动":

机制模型哲学
epoll (Linux)就绪通知模型
(Readiness Notification)
"告诉我哪些 fd 准备好了,我来处理"
kqueue (BSD/macOS)事件通知模型
(Event Notification)
"告诉我发生了什么事件,我来处理"
IOCP (Windows)完成通知模型
(Completion Notification)
"帮我做完这个 IO 操作,然后通知我"

打个比方:

  • epoll 像服务员告诉你"3 号桌客人准备点菜了",你得自己去
  • kqueue 像服务员告诉你"3 号桌发生了一个'准备点菜'事件",信息更丰富
  • IOCP 像服务员直接帮你点完菜,然后告诉你"3 号桌的菜已经点好了"

这些设计差异导致了编程模型的不同,也影响了性能特性。让我们深入了解每一种。

Linux: epoll

epoll 我们已经很熟悉了,这里做个总结和补充。

核心 API

#include <sys/epoll.h>

// 创建 epoll 实例,返回文件描述符
int epoll_create1(int flags);

// 添加/修改/删除监控的文件描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

// 等待事件发生
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

设计特点

1. 文件描述符中心

epoll 以文件描述符为核心。你告诉 epoll"监控这个 fd 的读事件",当数据到达时,epoll 告诉你"这个 fd 可读了"。

struct epoll_event {
    uint32_t events;  // 事件类型:EPOLLIN, EPOLLOUT, EPOLLERR...
    epoll_data_t data; // 用户数据,通常存放 fd 或指针
};

2. 红黑树 + 就绪链表

epoll 的高性能来自其数据结构设计:

  • 红黑树:O(log n) 管理所有监控的 fd
  • 就绪链表:O(1) 获取已就绪的 fd

3. 两种触发模式

  • 水平触发 (LT):只要 fd 可读/可写就持续通知
  • 边缘触发 (ET):只在状态变化时通知一次

完整示例

// epoll_server.c - Linux epoll 服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <errno.h>

#define MAX_EVENTS 64
#define BUFFER_SIZE 4096

void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    // 创建监听 socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(server_fd);

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_addr.s_addr = INADDR_ANY,
        .sin_port = htons(8080)
    };

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 128);

    // 创建 epoll 实例
    int epfd = epoll_create1(0);

    // 注册监听 socket
    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = server_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);

    printf("epoll 服务器启动,监听端口 8080\n");

    struct epoll_event events[MAX_EVENTS];
    char buffer[BUFFER_SIZE];

    while (1) {
        // 等待事件
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);

        for (int i = 0; i < nfds; i++) {
            int fd = events[i].data.fd;

            if (fd == server_fd) {
                // 新连接
                int client_fd = accept(server_fd, NULL, NULL);
                if (client_fd >= 0) {
                    set_nonblocking(client_fd);
                    ev.events = EPOLLIN | EPOLLET;
                    ev.data.fd = client_fd;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
                    printf("新连接: fd=%d\n", client_fd);
                }
            } else {
                // 客户端数据
                ssize_t n = read(fd, buffer, sizeof(buffer));
                if (n > 0) {
                    write(fd, buffer, n);  // Echo
                } else if (n == 0 || (n < 0 && errno != EAGAIN)) {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                    printf("连接关闭: fd=%d\n", fd);
                }
            }
        }
    }

    return 0;
}

epoll 的局限

尽管 epoll 很优秀,但它有一些局限:

  1. 只支持 Linux:无法在 macOS 或 Windows 上使用
  2. 只支持网络/管道等:不支持普通文件的异步 IO
  3. 没有定时器:需要配合 timerfd 使用
  4. 没有信号支持:需要配合 signalfd 使用

这些局限在 kqueue 中得到了更好的解决。

macOS/BSD: kqueue

kqueue(kernel queue)是 FreeBSD 在 2000 年引入的事件通知机制,后来被 macOS、NetBSD、OpenBSD 等继承。

设计哲学:统一的事件模型

kqueue 的设计理念是"一切皆事件"。不仅网络 IO 是事件,文件变化、信号、定时器、进程状态变化都是事件。这种统一的抽象非常优雅。

kqueue 支持的事件类型:

EVFILT_READ    - 文件描述符可读
EVFILT_WRITE   - 文件描述符可写
EVFILT_VNODE   - 文件系统事件(创建、删除、修改、重命名...)
EVFILT_TIMER   - 定时器事件
EVFILT_SIGNAL  - 信号事件
EVFILT_PROC    - 进程事件(退出、fork、exec...)
EVFILT_USER    - 用户自定义事件

对比 epoll:

  • epoll 需要配合 timerfd、signalfd 才能处理定时器和信号
  • kqueue 原生支持,一个 API 搞定所有

核心 API

#include <sys/event.h>

// 创建 kqueue 实例
int kq = kqueue();

// 添加/修改/删除事件,同时等待事件
int kevent(int kq,
           const struct kevent *changelist, int nchanges,  // 输入:要修改的事件
           struct kevent *eventlist, int nevents,          // 输出:发生的事件
           const struct timespec *timeout);

注意 kevent 的巧妙设计:它把"修改监控列表"和"等待事件"合并成一个系统调用,减少了系统调用次数。

kevent 结构

struct kevent {
    uintptr_t ident;   // 事件标识符(通常是 fd,但也可以是其他)
    int16_t   filter;  // 事件过滤器类型
    uint16_t  flags;   // 操作标志
    uint32_t  fflags;  // 过滤器特定的标志
    intptr_t  data;    // 过滤器特定的数据
    void      *udata;  // 用户数据
};

// 初始化 kevent 的宏
EV_SET(&kev, ident, filter, flags, fflags, data, udata);

基本使用示例

// kqueue_server.c - macOS/BSD kqueue 服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/event.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <errno.h>

#define MAX_EVENTS 64
#define BUFFER_SIZE 4096

void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    // 创建监听 socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(server_fd);

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_addr.s_addr = INADDR_ANY,
        .sin_port = htons(8080)
    };

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 128);

    // 创建 kqueue 实例
    int kq = kqueue();
    if (kq == -1) {
        perror("kqueue");
        return 1;
    }

    // 注册监听 socket
    struct kevent change;
    EV_SET(&change, server_fd, EVFILT_READ, EV_ADD | EV_ENABLE, 0, 0, NULL);

    // 注册事件(changelist)同时等待事件(eventlist)
    // 这里只注册,不等待
    if (kevent(kq, &change, 1, NULL, 0, NULL) == -1) {
        perror("kevent");
        return 1;
    }

    printf("kqueue 服务器启动,监听端口 8080\n");

    struct kevent events[MAX_EVENTS];
    char buffer[BUFFER_SIZE];

    while (1) {
        // 等待事件
        int nev = kevent(kq, NULL, 0, events, MAX_EVENTS, NULL);

        if (nev == -1) {
            if (errno == EINTR) continue;
            perror("kevent");
            break;
        }

        for (int i = 0; i < nev; i++) {
            int fd = (int)events[i].ident;

            // 检查错误
            if (events[i].flags & EV_ERROR) {
                fprintf(stderr, "EV_ERROR: %s\n", strerror(events[i].data));
                continue;
            }

            if (fd == server_fd) {
                // 新连接
                int client_fd = accept(server_fd, NULL, NULL);
                if (client_fd >= 0) {
                    set_nonblocking(client_fd);

                    // 注册客户端 socket
                    EV_SET(&change, client_fd, EVFILT_READ,
                           EV_ADD | EV_ENABLE, 0, 0, NULL);
                    kevent(kq, &change, 1, NULL, 0, NULL);

                    printf("新连接: fd=%d\n", client_fd);
                }
            } else {
                // 检查连接是否关闭
                if (events[i].flags & EV_EOF) {
                    printf("连接关闭: fd=%d\n", fd);
                    close(fd);  // kqueue 会自动移除已关闭的 fd
                    continue;
                }

                // 客户端数据
                ssize_t n = read(fd, buffer, sizeof(buffer));
                if (n > 0) {
                    write(fd, buffer, n);  // Echo
                } else if (n == 0) {
                    close(fd);
                    printf("连接关闭: fd=%d\n", fd);
                }
            }
        }
    }

    close(kq);
    close(server_fd);
    return 0;
}

kqueue 的高级功能

kqueue 最强大的地方在于它的多功能性。让我们看几个 epoll 做不到的事情:

1. 文件系统监控

监控文件的创建、删除、修改、重命名等:

// 监控文件变化
#include <sys/event.h>
#include <fcntl.h>
#include <stdio.h>

void monitor_file(const char *filename) {
    int kq = kqueue();
    int fd = open(filename, O_RDONLY);

    struct kevent change;
    // 监控写入、扩展、属性变化、删除、重命名
    EV_SET(&change, fd, EVFILT_VNODE,
           EV_ADD | EV_ENABLE | EV_CLEAR,
           NOTE_WRITE | NOTE_EXTEND | NOTE_ATTRIB |
           NOTE_DELETE | NOTE_RENAME,
           0, NULL);

    kevent(kq, &change, 1, NULL, 0, NULL);

    printf("开始监控文件: %s\n", filename);
    printf("尝试在另一个终端修改这个文件...\n");

    struct kevent event;
    while (1) {
        int nev = kevent(kq, NULL, 0, &event, 1, NULL);
        if (nev <= 0) continue;

        printf("文件事件:\n");
        if (event.fflags & NOTE_WRITE)  printf("  - 被写入\n");
        if (event.fflags & NOTE_EXTEND) printf("  - 被扩展\n");
        if (event.fflags & NOTE_ATTRIB) printf("  - 属性变化\n");
        if (event.fflags & NOTE_DELETE) printf("  - 被删除\n");
        if (event.fflags & NOTE_RENAME) printf("  - 被重命名\n");
    }
}

这功能非常实用。很多文件监控工具(如 fswatch)在 macOS 上就是用 kqueue 实现的。

2. 定时器

不需要额外的 timerfd,kqueue 原生支持定时器:

// kqueue 定时器
void timer_example() {
    int kq = kqueue();

    struct kevent change;
    // 创建一个 1 秒触发一次的定时器
    // ident 可以是任意值,用来标识这个定时器
    // data 是毫秒数
    EV_SET(&change, 1, EVFILT_TIMER, EV_ADD | EV_ENABLE, 0, 1000, NULL);

    kevent(kq, &change, 1, NULL, 0, NULL);

    printf("定时器已启动,每秒触发一次\n");

    struct kevent event;
    for (int i = 0; i < 5; i++) {
        kevent(kq, NULL, 0, &event, 1, NULL);
        printf("定时器触发!计数: %lld\n", event.data);
    }

    close(kq);
}

3. 信号处理

不需要 signalfd,也不需要担心信号处理函数的各种陷阱:

// kqueue 信号处理
#include <signal.h>

void signal_example() {
    int kq = kqueue();

    // 阻止信号的默认处理
    signal(SIGINT, SIG_IGN);
    signal(SIGTERM, SIG_IGN);

    struct kevent changes[2];
    // 监控 SIGINT 和 SIGTERM
    EV_SET(&changes[0], SIGINT, EVFILT_SIGNAL, EV_ADD | EV_ENABLE, 0, 0, NULL);
    EV_SET(&changes[1], SIGTERM, EVFILT_SIGNAL, EV_ADD | EV_ENABLE, 0, 0, NULL);

    kevent(kq, changes, 2, NULL, 0, NULL);

    printf("等待信号... (按 Ctrl+C)\n");

    struct kevent event;
    while (1) {
        kevent(kq, NULL, 0, &event, 1, NULL);

        int sig = (int)event.ident;
        printf("收到信号: %d (%s)\n", sig, strsignal(sig));

        if (sig == SIGINT || sig == SIGTERM) {
            printf("退出\n");
            break;
        }
    }

    close(kq);
}

4. 进程监控

监控子进程的状态变化:

// 监控子进程
#include <sys/wait.h>

void process_monitor() {
    int kq = kqueue();

    pid_t child = fork();
    if (child == 0) {
        // 子进程:模拟一些工作
        sleep(2);
        exit(42);
    }

    // 父进程:监控子进程
    struct kevent change;
    EV_SET(&change, child, EVFILT_PROC,
           EV_ADD | EV_ENABLE,
           NOTE_EXIT | NOTE_FORK | NOTE_EXEC,
           0, NULL);

    kevent(kq, &change, 1, NULL, 0, NULL);

    printf("开始监控子进程 PID: %d\n", child);

    struct kevent event;
    kevent(kq, NULL, 0, &event, 1, NULL);

    if (event.fflags & NOTE_EXIT) {
        int status = (int)event.data;
        printf("子进程退出,退出码: %d\n", WEXITSTATUS(status));
    }

    close(kq);
}

kqueue vs epoll 对比

特性epollkqueue
平台LinuxmacOS, BSD
网络 IO支持支持
普通文件不支持支持
定时器需要 timerfd原生支持
信号需要 signalfd原生支持
文件系统监控需要 inotify原生支持
进程监控不支持支持
用户事件不支持支持
API 优雅度中高
性能高高

从功能角度看,kqueue 确实比 epoll 更强大、更优雅。但 Linux 的生态和部署量远超 BSD,所以 epoll 的使用更广泛。

Windows: IOCP

IOCP(I/O Completion Ports)是 Windows NT 引入的异步 IO 机制,与 epoll/kqueue 有本质区别。

设计哲学:真正的异步

epoll 和 kqueue 都是"就绪通知"模型——它们告诉你"fd 准备好了,你可以读了",但读取操作还是你来做。

IOCP 是"完成通知"模型——你发起一个读取请求,Windows 在后台完成,然后通知你"读取已完成,数据在这里"。

epoll/kqueue 工作流程:
  1. epoll_wait() 阻塞等待
  2. 返回"fd 5 可读"
  3. 你调用 read(fd 5, buffer, ...)
  4. 数据复制到 buffer

IOCP 工作流程:
  1. 你调用 ReadFile(fd, buffer, ..., &overlapped)
  2. 立即返回,IO 在后台进行
  3. GetQueuedCompletionStatus() 阻塞等待
  4. 返回"读取已完成",数据已经在 buffer 里了

这种设计的好处是:

  • IO 操作与应用逻辑完全分离
  • 内核可以更好地优化 IO 调度
  • 天然支持 scatter/gather IO

核心 API

#include <windows.h>

// 创建完成端口
HANDLE CreateIoCompletionPort(
    HANDLE FileHandle,                  // 要关联的文件句柄
    HANDLE ExistingCompletionPort,      // 已存在的完成端口(或 NULL 创建新的)
    ULONG_PTR CompletionKey,            // 用户数据
    DWORD NumberOfConcurrentThreads     // 并发线程数
);

// 发起异步读取
BOOL ReadFile(
    HANDLE hFile,
    LPVOID lpBuffer,
    DWORD nNumberOfBytesToRead,
    LPDWORD lpNumberOfBytesRead,
    LPOVERLAPPED lpOverlapped           // 关键:使用 OVERLAPPED 结构
);

// 等待完成通知
BOOL GetQueuedCompletionStatus(
    HANDLE CompletionPort,
    LPDWORD lpNumberOfBytesTransferred,
    PULONG_PTR lpCompletionKey,
    LPOVERLAPPED *lpOverlapped,
    DWORD dwMilliseconds
);

OVERLAPPED 结构

OVERLAPPED 是 Windows 异步 IO 的核心概念:

typedef struct _OVERLAPPED {
    ULONG_PTR Internal;      // 内部使用
    ULONG_PTR InternalHigh;  // 内部使用
    union {
        struct {
            DWORD Offset;     // 文件偏移(低 32 位)
            DWORD OffsetHigh; // 文件偏移(高 32 位)
        };
        PVOID Pointer;
    };
    HANDLE hEvent;           // 事件句柄(可选)
} OVERLAPPED;

每个异步 IO 操作需要一个 OVERLAPPED 结构,它代表"一个正在进行的 IO 操作"。

完整示例

// iocp_server.c - Windows IOCP 服务器
#include <stdio.h>
#include <winsock2.h>
#include <windows.h>

#pragma comment(lib, "ws2_32.lib")

#define BUFFER_SIZE 4096
#define WORKER_THREADS 4

// 每个 IO 操作的上下文
typedef struct {
    OVERLAPPED overlapped;     // 必须是第一个成员
    WSABUF wsaBuf;
    char buffer[BUFFER_SIZE];
    SOCKET socket;
    DWORD operation;           // 0=读, 1=写
} IO_CONTEXT;

#define OP_READ  0
#define OP_WRITE 1

// 工作线程
DWORD WINAPI WorkerThread(LPVOID lpParam) {
    HANDLE hIOCP = (HANDLE)lpParam;

    while (1) {
        DWORD bytesTransferred;
        ULONG_PTR completionKey;
        LPOVERLAPPED lpOverlapped;

        // 等待完成通知
        BOOL success = GetQueuedCompletionStatus(
            hIOCP,
            &bytesTransferred,
            &completionKey,
            &lpOverlapped,
            INFINITE
        );

        // 获取 IO 上下文
        IO_CONTEXT *ctx = (IO_CONTEXT*)lpOverlapped;

        if (!success || bytesTransferred == 0) {
            // 连接关闭或错误
            printf("连接关闭: socket=%llu\n", ctx->socket);
            closesocket(ctx->socket);
            free(ctx);
            continue;
        }

        if (ctx->operation == OP_READ) {
            // 读取完成,发起写入(Echo)
            printf("收到 %lu 字节\n", bytesTransferred);

            ctx->operation = OP_WRITE;
            ctx->wsaBuf.len = bytesTransferred;

            DWORD flags = 0;
            WSASend(ctx->socket, &ctx->wsaBuf, 1, NULL, flags,
                    &ctx->overlapped, NULL);
        } else {
            // 写入完成,发起新的读取
            ctx->operation = OP_READ;
            ctx->wsaBuf.len = BUFFER_SIZE;

            DWORD flags = 0;
            WSARecv(ctx->socket, &ctx->wsaBuf, 1, NULL, &flags,
                    &ctx->overlapped, NULL);
        }
    }

    return 0;
}

int main() {
    // 初始化 Winsock
    WSADATA wsaData;
    WSAStartup(MAKEWORD(2, 2), &wsaData);

    // 创建完成端口
    HANDLE hIOCP = CreateIoCompletionPort(INVALID_HANDLE_VALUE,
                                          NULL, 0, WORKER_THREADS);

    // 创建工作线程
    for (int i = 0; i < WORKER_THREADS; i++) {
        CreateThread(NULL, 0, WorkerThread, hIOCP, 0, NULL);
    }

    // 创建监听 socket
    SOCKET listenSocket = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP,
                                     NULL, 0, WSA_FLAG_OVERLAPPED);

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(8080);

    bind(listenSocket, (struct sockaddr*)&addr, sizeof(addr));
    listen(listenSocket, SOMAXCONN);

    printf("IOCP 服务器启动,监听端口 8080\n");

    while (1) {
        // 接受新连接
        SOCKET clientSocket = accept(listenSocket, NULL, NULL);
        if (clientSocket == INVALID_SOCKET) continue;

        printf("新连接: socket=%llu\n", clientSocket);

        // 关联到完成端口
        CreateIoCompletionPort((HANDLE)clientSocket, hIOCP,
                               (ULONG_PTR)clientSocket, 0);

        // 创建 IO 上下文
        IO_CONTEXT *ctx = (IO_CONTEXT*)malloc(sizeof(IO_CONTEXT));
        memset(ctx, 0, sizeof(IO_CONTEXT));
        ctx->socket = clientSocket;
        ctx->wsaBuf.buf = ctx->buffer;
        ctx->wsaBuf.len = BUFFER_SIZE;
        ctx->operation = OP_READ;

        // 发起第一次异步读取
        DWORD flags = 0;
        WSARecv(clientSocket, &ctx->wsaBuf, 1, NULL, &flags,
                &ctx->overlapped, NULL);
    }

    closesocket(listenSocket);
    CloseHandle(hIOCP);
    WSACleanup();

    return 0;
}

IOCP 的特点

优点:

  • 真正的异步 IO,性能极高
  • 内置线程池管理
  • 可以控制并发线程数
  • 适合 CPU 密集型 + IO 密集型混合场景

缺点:

  • API 复杂,学习曲线陡峭
  • 需要手动管理 OVERLAPPED 和缓冲区
  • 只支持 Windows
  • 编程模型与 Unix 差异大

IOCP vs epoll/kqueue

特性epoll/kqueueIOCP
模型就绪通知完成通知
IO 执行者应用程序内核/系统
缓冲区管理简单复杂(需要预分配)
线程模型灵活内置线程池
性能高极高
复杂度中高
跨平台Unix 家族仅 Windows

三大机制综合对比

现在让我们做一个全面的对比:

功能对比

功能epollkqueueIOCP
网络 IO支持支持支持
文件 IO不支持支持支持
定时器需 timerfd支持需 Timer API
信号需 signalfd支持不支持
文件系统监控需 inotify支持不支持
进程监控不支持支持不支持
边缘触发支持默认N/A
水平触发支持需设置N/A

性能对比

在同等硬件条件下(8 核 CPU,16GB 内存),测试 10 万并发连接:

指标epollkqueueIOCP
QPS~300K~280K~350K
CPU 使用率45%48%42%
延迟 p998ms10ms6ms
内存使用低低中

IOCP 性能略高,主要因为它是真正的异步模型,减少了数据拷贝。

编程复杂度

// 代码行数对比(简单 Echo 服务器)

// epoll: ~80 行
int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
epoll_wait(epfd, events, MAX_EVENTS, -1);
// 然后自己 read/write

// kqueue: ~70 行
int kq = kqueue();
kevent(kq, &change, 1, NULL, 0, NULL);
kevent(kq, NULL, 0, events, MAX_EVENTS, NULL);
// 然后自己 read/write

// IOCP: ~150 行
CreateIoCompletionPort(...);
WSARecv(socket, &wsaBuf, 1, NULL, &flags, &overlapped, NULL);
GetQueuedCompletionStatus(...);
// 数据已经在 buffer 里
// 但需要管理 OVERLAPPED 和缓冲区

Go 的跨平台封装

作为开发者,最理想的情况是不用关心这些差异。Go 语言就做到了这一点。

Go 的设计

Go 的 net 包在底层自动选择最佳的 IO 多路复用机制:

// 开发者只需要写这样的代码
listener, _ := net.Listen("tcp", ":8080")
for {
    conn, _ := listener.Accept()
    go handleConnection(conn)  // 每个连接一个 goroutine
}

无论在 Linux、macOS 还是 Windows 上,这段代码都能高效运行。

底层实现

Go runtime 根据操作系统选择不同的实现:

// 伪代码,展示 Go runtime 的逻辑

// +build linux
func netpoll() {
    epfd := syscall.EpollCreate1(0)
    // 使用 epoll
}

// +build darwin
func netpoll() {
    kq := syscall.Kqueue()
    // 使用 kqueue
}

// +build windows
func netpoll() {
    // 使用 IOCP
}

pollDesc 结构

Go 为每个网络连接维护一个 pollDesc 结构:

// runtime/netpoll.go
type pollDesc struct {
    link *pollDesc  // 链表指针
    fd   uintptr    // 文件描述符

    // 等待读/写的 goroutine
    rg uintptr // 读 goroutine
    wg uintptr // 写 goroutine

    // 平台特定字段...
}

当 goroutine 尝试从一个没有数据的连接读取时:

  1. Go runtime 检测到会阻塞
  2. 保存当前 goroutine 的状态到 rg
  3. 将 goroutine 挂起(不占用系统线程)
  4. 当数据到达时,epoll/kqueue/IOCP 通知 runtime
  5. Runtime 唤醒对应的 goroutine

为什么 Go 能做到高效?

Go 的秘密在于 M:N 调度:

传统模型(1:1):
  goroutine 1 ←→ 系统线程 1
  goroutine 2 ←→ 系统线程 2
  ...
  goroutine N ←→ 系统线程 N

  问题:系统线程很贵,不能创建太多

Go 模型(M:N):
  goroutine 1 ─┐
  goroutine 2 ─┤
  ...          ├─→ M 个系统线程(M << N)
  goroutine N ─┘

  优势:可以创建成千上万的 goroutine

配合 epoll/kqueue/IOCP,Go 实现了:

  • 海量并发:10 万个连接,10 万个 goroutine,但可能只用 8 个系统线程
  • 简单编程:每个连接一个 goroutine,代码直观
  • 跨平台:一次编写,到处运行

Go 网络服务器示例

// go_server.go - 跨平台 Go 网络服务器
package main

import (
    "io"
    "log"
    "net"
)

func handleConnection(conn net.Conn) {
    defer conn.Close()

    // io.Copy 内部会使用 epoll/kqueue/IOCP
    // 当没有数据时,goroutine 会被挂起
    // 不占用系统线程
    io.Copy(conn, conn) // Echo
}

func main() {
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatal(err)
    }
    defer listener.Close()

    log.Println("服务器启动,监听端口 8080")

    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Printf("Accept 错误: %v", err)
            continue
        }

        // 每个连接一个 goroutine
        // 这在 Go 中是完全 OK 的
        go handleConnection(conn)
    }
}

编译到不同平台:

# Linux
GOOS=linux GOARCH=amd64 go build -o server_linux

# macOS
GOOS=darwin GOARCH=amd64 go build -o server_darwin

# Windows
GOOS=windows GOARCH=amd64 go build -o server_windows.exe

同样的代码,在三个平台上都能高效运行。这就是 Go 的魔力。

如何选择?

面对这么多选择,该如何决策?

决策流程

具体建议

场景推荐方案原因
跨平台应用Go自动选择最佳实现,开发效率高
Linux 服务器(性能优先)epoll + C/Rust最底层控制,性能最优
macOS 开发/部署kqueue 或 Gokqueue 功能丰富
Windows 服务器IOCP 或 GoIOCP 性能最高
需要文件系统监控kqueue(macOS)或 inotify(Linux)原生支持
需要快速开发Go代码简单,无需关心底层
需要极致性能C/C++/Rust + 原生 API完全控制

我的建议

对于大多数开发者,我的建议是:

  1. 首选 Go:除非有特殊需求,Go 是最平衡的选择
  2. 学习 epoll:理解底层原理,有助于性能调优和问题排查
  3. 了解 kqueue:如果你用 macOS 开发,了解 kqueue 很有帮助
  4. IOCP 按需学习:除非专门做 Windows 开发,否则优先级较低

总结

本章我们深入对比了三大 IO 多路复用机制:

核心要点

  1. epoll (Linux)

    • 就绪通知模型
    • 红黑树 + 就绪链表
    • 高性能,但功能相对单一
  2. kqueue (macOS/BSD)

    • 事件通知模型
    • 统一的事件抽象
    • 功能丰富,API 优雅
  3. IOCP (Windows)

    • 完成通知模型
    • 真正的异步 IO
    • 性能最高,但复杂度也最高
  4. Go 的跨平台封装

    • 自动选择最佳实现
    • M:N 调度模型
    • 简单高效,适合大多数场景

设计哲学的启示

三种机制的差异不是技术优劣,而是设计哲学的选择:

  • epoll:Unix "小而美"的哲学,专注做一件事
  • kqueue:BSD "统一抽象"的哲学,一套 API 处理所有事件
  • IOCP:Windows "完整解决方案"的哲学,系统帮你做完

理解这些设计哲学,有助于我们做出更好的技术选择,也能从中学到系统设计的智慧。

下一章,我们将深入 Linux 内核源码,看看 epoll 在内核中是如何实现的——红黑树怎么用的、回调怎么触发的、就绪链表怎么管理的。

更新于 2026-01-05
播放列表
当前歌曲封面
点击加载歌单
-