【C/C++】用户态协议栈如何实现 epoll:红黑树、就绪队列和回调通知

【C/C++】用户态协议栈如何实现 epoll:红黑树、就绪队列和回调通知

1. 为什么用户态协议栈也需要 epoll

如果协议栈完全运行在用户态,那么内核的 socketrecvsendepoll_wait 都不能直接代表这套协议栈里的连接。

原因很简单:连接状态、接收队列、发送队列都在用户态自己的数据结构里。内核不知道你的 ng_tcp_stream 什么时候收到了数据,也不知道你的 rcvbuf 什么时候变成非空。

所以用户态协议栈要么提供一套自己的 API,例如:

nsocket();
nbind();
nlisten();
naccept();
nsend();
nrecv();
nepoll_create();
nepoll_ctl();
nepoll_wait();

要么做更复杂的兼容层,把应用无感迁移过去。这个示例选择了前者:用 n* 前缀实现教学版 socket/epoll。

在这里插入图片描述

2. eventpoll:关注集合 + 就绪队列 + 条件变量

Linux 内核 epoll 的核心概念可以简化成两部分:

  • 关注集合:应用通过 epoll_ctl(ADD/MOD/DEL) 注册自己关心哪些 fd。
  • 就绪队列:当某个 fd 真的可读/可写时,把它放到 ready list,epoll_wait 返回它。

示例代码也是这个思路。

代码片段来自 ustack-main/tcp_concurrency.c

struct epitem {
    RB_ENTRY(epitem) rbn;
    LIST_ENTRY(epitem) rdlink;
    int rdy;

    int sockfd;
    struct epoll_event event;
};

RB_HEAD(_epoll_rb_socket, epitem);
RB_GENERATE_STATIC(_epoll_rb_socket, epitem, rbn, sockfd_cmp);

typedef struct _epoll_rb_socket ep_rb_tree;

struct eventpoll {
    int fd;

    ep_rb_tree rbr;
    int rbcnt;

    LIST_HEAD(, epitem) rdlist;
    int rdnum;

    int waiting;

    pthread_mutex_t mtx;
    pthread_spinlock_t lock;

    pthread_cond_t cond;
    pthread_mutex_t cdmtx;
};

这里有三个关键字段:

  • rbr:红黑树,保存“我关注了哪些 sockfd”。
  • rdlist:就绪链表,保存“哪些 sockfd 已经有事件”。
  • cond:条件变量,nepoll_wait() 没事件时阻塞,有事件时被唤醒。

为什么关注集合用红黑树?因为 epoll_ctl 和协议栈回调都需要按 sockfd 快速查找 epitem。链表也能做,但连接数一多,线性查找会拖慢路径。

3. nepoll_create:创建用户态 eventpoll

nepoll_create() 的职责是分配一个用户态 epoll 对象,并给它分配一个 fd。

int nepoll_create(int size) {
    if (size <= 0) return -1;

    int epfd = get_fd_frombitmap();

    struct eventpoll *ep = (struct eventpoll *)rte_malloc(
        "eventpoll", sizeof(struct eventpoll), 0);
    if (!ep) {
        set_fd_frombitmap(epfd);
        return -1;
    }

    struct ng_tcp_table *table = tcpInstance();
    table->ep = ep;

    ep->fd = epfd;
    ep->rbcnt = 0;
    RB_INIT(&ep->rbr);
    LIST_INIT(&ep->rdlist);

    pthread_mutex_init(&ep->mtx, NULL);
    pthread_mutex_init(&ep->cdmtx, NULL);
    pthread_cond_init(&ep->cond, NULL);
    pthread_spin_init(&ep->lock, PTHREAD_PROCESS_SHARED);

    return epfd;
}

这个实现里有一个教学上很直观的设计:table->ep = ep

也就是说,TCP 协议栈表能直接找到当前 epoll 对象。后面 TCP 收到新连接或数据时,就可以调用 epoll_event_callback() 通知等待者。

真实工程中可能会有多个 epoll 实例、多线程归属、fd 到 epoll 的反向索引等更复杂的问题。这个示例先把最小闭环跑通了。

4. nepoll_ctl:把 sockfd 加入关注集合

nepoll_ctl() 的 ADD 操作,本质是创建 epitem,然后插入红黑树。

int nepoll_ctl(int epfd, int op, int sockid, struct epoll_event *event) {
    struct eventpoll *ep = (struct eventpoll *)get_hostinfo_fromfd(epfd);
    if (!ep || (!event && op != EPOLL_CTL_DEL)) {
        errno = -EINVAL;
        return -1;
    }

    if (op == EPOLL_CTL_ADD) {
        pthread_mutex_lock(&ep->mtx);

        struct epitem tmp;
        tmp.sockfd = sockid;
        struct epitem *epi = RB_FIND(_epoll_rb_socket, &ep->rbr, &tmp);
        if (epi) {
            pthread_mutex_unlock(&ep->mtx);
            return -1;
        }

        epi = (struct epitem *)rte_malloc("epitem", sizeof(struct epitem), 0);
        epi->sockfd = sockid;
        memcpy(&epi->event, event, sizeof(struct epoll_event));

        RB_INSERT(_epoll_rb_socket, &ep->rbr, epi);
        ep->rbcnt++;

        pthread_mutex_unlock(&ep->mtx);
    }

    return 0;
}

这一段对应内核 epoll 里的“兴趣列表”。注意,它只是表示“我关心这个 sockfd 的事件”,并不代表事件已经发生。

5. 协议栈回调:把关注项移动到就绪队列

真正让 epoll 动起来的是回调函数。

int epoll_event_callback(struct eventpoll *ep, int sockid, uint32_t event) {
    struct epitem tmp;
    tmp.sockfd = sockid;

    struct epitem *epi = RB_FIND(_epoll_rb_socket, &ep->rbr, &tmp);
    if (!epi) {
        printf("rbtree not exist\n");
        return -1;
    }

    if (epi->rdy) {
        epi->event.events |= event;
        return 1;
    }

    pthread_spin_lock(&ep->lock);
    epi->rdy = 1;
    LIST_INSERT_HEAD(&ep->rdlist, epi, rdlink);
    ep->rdnum++;
    pthread_spin_unlock(&ep->lock);

    pthread_mutex_lock(&ep->cdmtx);
    pthread_cond_signal(&ep->cond);
    pthread_mutex_unlock(&ep->cdmtx);
}

这段逻辑对应四步:

sockid -> 红黑树查 epitem
  -> 如果已在 ready list,只合并事件
  -> 如果不在 ready list,插入 rdlist
  -> signal 条件变量,唤醒 nepoll_wait

也就是说,nepoll_wait() 不负责轮询每个 socket 是否有数据。它只等 rdlist。协议栈在“状态真的变化”时主动把事件送进去。

这就是 epoll 相比普通轮询更高效的关键。

6. nepoll_wait:没事件就睡,有事件就拷贝返回

nepoll_wait() 的阻塞逻辑用条件变量完成。

int nepoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout) {
    struct eventpoll *ep = (struct eventpoll *)get_hostinfo_fromfd(epfd);
    if (!ep || !events || maxevents <= 0) {
        rte_errno = -EINVAL;
        return -1;
    }

    pthread_mutex_lock(&ep->cdmtx);

    while (ep->rdnum == 0 && timeout != 0) {
        ep->waiting = 1;

        if (timeout > 0) {
            struct timespec deadline;
            clock_gettime(CLOCK_REALTIME, &deadline);
            deadline.tv_nsec += timeout * 1000000;
            pthread_cond_timedwait(&ep->cond, &ep->cdmtx, &deadline);
            timeout = 0;
        } else if (timeout < 0) {
            pthread_cond_wait(&ep->cond, &ep->cdmtx);
        }

        ep->waiting = 0;
    }

    pthread_mutex_unlock(&ep->cdmtx);

    pthread_spin_lock(&ep->lock);

    int cnt = 0;
    int num = (ep->rdnum > maxevents ? maxevents : ep->rdnum);

    while (num != 0 && !LIST_EMPTY(&ep->rdlist)) {
        struct epitem *epi = LIST_FIRST(&ep->rdlist);
        LIST_REMOVE(epi, rdlink);
        epi->rdy = 0;

        memcpy(&events[cnt++], &epi->event, sizeof(struct epoll_event));

        num--;
        ep->rdnum--;
    }

    pthread_spin_unlock(&ep->lock);

    return cnt;
}

这里可以对应标准 epoll 的三个 timeout 语义:

  • timeout < 0:一直等。
  • timeout == 0:不等,马上返回。
  • timeout > 0:最多等指定毫秒数。

这个示例实现的是最小模型,已经能说明 epoll 的主干机制:关注集合不等于就绪队列,就绪队列由协议栈事件驱动。

7. TCP 在哪里触发 EPOLLIN

用户态 epoll 的事件来源不是内核,而是 TCP 状态机。

当三次握手完成后,监听 socket 应该变成可读,因为 accept() 可以拿到新连接。示例代码在 SYN_RCVD 收到 ACK 后触发:

static int ng_tcp_handle_syn_rcvd(struct ng_tcp_stream *stream,
                                  struct rte_tcp_hdr *tcphdr) {
    if (tcphdr->tcp_flags & RTE_TCP_ACK_FLAG) {
        if (stream->status == NG_TCP_STATUS_SYN_RCVD) {
            stream->status = NG_TCP_STATUS_ESTABLISHED;

            struct ng_tcp_stream *listener =
                ng_tcp_stream_search(0, 0, 0, stream->dport);

            pthread_mutex_lock(&listener->mutex);
            pthread_cond_signal(&listener->cond);
            pthread_mutex_unlock(&listener->mutex);

            struct ng_tcp_table *table = tcpInstance();
            epoll_event_callback(table->ep, listener->fd, EPOLLIN);
        }
    }
    return 0;
}

当连接已经建立,收到 PSH 数据时,连接 socket 应该变成可读:

static int ng_tcp_handle_established(struct ng_tcp_stream *stream,
                                     struct rte_tcp_hdr *tcphdr,
                                     int tcplen) {
    if (tcphdr->tcp_flags & RTE_TCP_PSH_FLAG) {
        ng_tcp_enqueue_recvbuffer(stream, tcphdr, tcplen);

        struct ng_tcp_table *table = tcpInstance();
        epoll_event_callback(table->ep, stream->fd, EPOLLIN);
    }

    return 0;
}

这两处就是 epoll 和 TCP 状态机的连接点:

三次握手完成 -> listener fd 可读 -> accept 不再阻塞
收到 PSH 数据 -> stream fd 可读 -> recv 不再阻塞

在这里插入图片描述

8. 应用侧怎么用

单 epoll 版本的 TCP server 入口大致是这样:

int listenfd = nsocket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(9999);

nbind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
nlisten(listenfd, 10);

int epfd = nepoll_create(1);

struct epoll_event ev, events[128];
ev.events = EPOLLIN;
ev.data.fd = listenfd;
nepoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

while (1) {
    int nready = nepoll_wait(epfd, events, 128, -1);
    for (int i = 0; i < nready; i++) {
        if (events[i].data.fd == listenfd) {
            int connfd = naccept(listenfd, NULL, NULL);
            ev.events = EPOLLIN;
            ev.data.fd = connfd;
            nepoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
        } else {
            char buffer[1024] = {0};
            nrecv(events[i].data.fd, buffer, sizeof(buffer), 0);
            nsend(events[i].data.fd, buffer, strlen(buffer), 0);
        }
    }
}

这段 API 形式和普通 Linux epoll 很像,但底层已经完全变了:

  • 普通 epoll_wait 等内核 fd。
  • 这里的 nepoll_wait 等用户态协议栈自己的 fd。
  • 普通 recv 从内核 socket buffer 取数据。
  • 这里的 nrecvng_tcp_stream->rcvbuf 取数据。

9. 这个教学实现还缺什么

这份代码适合学习 epoll 骨架,但离工程级实现还有距离:

  • 只展示了单 epoll 思路,多 epoll 实例和多线程归属还需要设计。
  • EPOLLETEPOLLONESHOT 等语义没有完整展开。
  • 事件合并、错误事件、关闭事件的边界还可以继续补齐。
  • TCP 本身也缺少完整重传、拥塞控制、乱序重排、滑动窗口等机制。
  • 内存释放和异常路径要更严格,否则长时间运行容易泄漏。

但作为学习材料,它已经把最关键的主线讲清楚了:

epoll_ctl 注册 sockfd
  -> 红黑树保存关注集合
  -> TCP/UDP 收到数据后触发 callback
  -> callback 插入就绪队列并 signal
  -> epoll_wait 拷贝就绪事件返回应用

10. 总结

用户态协议栈里的 epoll,本质上不是“调用一下内核 epoll”,而是自己维护事件系统。

核心数据结构是:

eventpoll
  ├── rbr: 关注集合,红黑树
  ├── rdlist: 就绪队列,链表
  ├── cond: 阻塞和唤醒 epoll_wait
  └── lock/mtx: 保护并发访问

核心事件流是:

协议栈收到报文
  -> 更新 TCP/UDP 接收缓冲
  -> 调用 epoll_event_callback
  -> epitem 进入 rdlist
  -> nepoll_wait 被唤醒
  -> 应用处理 accept/recv/send

看懂这一层之后,再去读内核 epoll、mTCP、F-Stack、Seastar 这类框架,会更容易抓住主线:高性能网络框架的核心不是“多线程 + 非阻塞”这几个词,而是数据到达时,事件如何从协议栈准确、高效地传递到应用。
学习链接: https://github.com/0voice

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值