【C/C++】用户态协议栈如何实现 epoll:红黑树、就绪队列和回调通知
1. 为什么用户态协议栈也需要 epoll
如果协议栈完全运行在用户态,那么内核的 socket、recv、send、epoll_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 取数据。 - 这里的
nrecv从ng_tcp_stream->rcvbuf取数据。
9. 这个教学实现还缺什么
这份代码适合学习 epoll 骨架,但离工程级实现还有距离:
- 只展示了单 epoll 思路,多 epoll 实例和多线程归属还需要设计。
EPOLLET、EPOLLONESHOT等语义没有完整展开。- 事件合并、错误事件、关闭事件的边界还可以继续补齐。
- 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
1290

被折叠的 条评论
为什么被折叠?



