☰
Linux下构建高性能可靠UDP服务器:epoll与大规模并发实战
2026/10/20 5:31:22 网站建设 项目流程

1. 为什么要在Linux下折腾UDP服务器

做后端网络编程的朋友大概率都经历过这样一个过程:一开始用TCP写服务端,accept、read、write一套组合拳下来,代码跑得挺稳,心里也踏实。但业务一旦涉及到实时音视频、游戏帧同步、行情推送这类场景,TCP的那些“贴心”机制反而成了累赘——队头阻塞、重传延迟、拥塞控制降速,每一样都在跟低延迟的需求对着干。这时候你就不得不把目光投向UDP。

UDP的好处就一个字:快。没有握手、没有确认、没有重传、没有拥塞控制,发出去就不管了。但问题也随之而来——它太“裸”了。丢包、乱序、重复,这些TCP帮你兜住的事情,现在全得自己扛。更麻烦的是,当你需要在一台Linux服务器上同时处理成千上万个UDP会话时,传统的“一个线程配一个recvfrom”模型直接就崩了,线程上下文切换的开销能把CPU吃满。

所以这个项目的核心目标就很明确了:在Linux环境下,利用IO多路复用机制,构建一个能承载大规模并发、同时具备可靠性保障的UDP服务器。所谓“大规模”,指的是同时维持数千甚至上万个活跃会话;所谓“可靠”,指的是在UDP之上自己实现一套轻量的确认、重传、去重、有序机制,让上层业务感觉不到丢包的存在。

这篇文章适合谁看?如果你已经写过基本的socket编程,理解bind、sendto、recvfrom这些基础API,但一遇到“高并发UDP怎么做”就卡壳,那这篇内容就是给你准备的。我会从整体架构设计讲到具体的代码实现,把每一步的取舍逻辑都摊开来说清楚。整个实现会分成几个部分来讲,这是第一篇,重点放在整体设计思路和IO多路复用层的搭建上。

2. 整体架构设计与核心思路拆解

2.1 为什么不用“一会话一线程”的经典模型

先说说最直观的方案:每来一个客户端,就fork一个进程或者pthread_create一个线程去专门处理它的收发。这个模型在连接数少的时候工作得很好,代码逻辑也简单,一个循环里阻塞在recvfrom上就行。但它的瓶颈非常明显。

Linux下创建一个线程默认需要分配8MB的栈空间(虽然实际物理内存是按需分配的,但虚拟地址空间和内核调度实体的开销是实打实的)。当并发会话数达到5000以上时,光是线程调度带来的上下文切换就能让系统负载飙升。我实测过,在一台4核8G的机器上,用一会话一线程的模型处理3000个UDP会话,CPU的sy(系统态)占比就超过了40%,大部分都耗在了调度器上。

另一个问题是可扩展性差。线程之间的数据共享需要加锁,锁的粒度稍微控制不好,性能就断崖式下跌。而且这种模型很难做统一的超时管理和资源回收,每个线程各自为战,排查问题的时候简直是噩梦。

2.2 IO多路复用的三种武器:select、poll、epoll

Linux下做IO多路复用,绕不开这三个系统调用。我简单对比一下它们的核心差异,方便你理解为什么最终选epoll。

特性selectpollepoll
最大描述符数1024(FD_SETSIZE)无硬限制无硬限制
时间复杂度O(n)O(n)O(1)(就绪回调)
内存拷贝每次调用全量拷贝fd集合每次调用全量拷贝仅注册时拷贝一次
触发模式水平触发水平触发水平/边缘触发
适用场景少量连接中等连接大规模并发

select的1024限制是硬伤,而且每次调用都要把整个fd集合从用户态拷到内核态,连接数一多就是灾难。poll虽然去掉了数量限制,但全量拷贝的问题还在。epoll则通过在内核维护一棵红黑树和一个就绪链表,把“注册”和“等待”分离,每次epoll_wait只需要检查就绪链表,效率跟连接总数无关。

对于UDP服务器来说,epoll还有一个额外的好处:它天然支持将同一个socket的事件分发给多个线程处理(通过EPOLLONESHOT或者边缘触发配合多线程),这为后续的水平扩展留了口子。

2.3 单socket还是多socket:一个关键的设计决策

这里有一个容易被忽略但影响深远的选择:整个服务器是只用一个UDP socket绑定在固定端口上,还是为每个会话创建独立的socket?

单socket方案的好处是简单,所有数据都从一个口进出,epoll只需要监听一个fd。但问题在于,当多个线程同时从同一个UDP socket上recvfrom时,内核会加锁保护,高并发下这个锁会成为瓶颈。而且单socket无法利用SO_REUSEPORT做多队列负载均衡。

多socket方案则是利用SO_REUSEPORT选项,让多个socket绑定到同一个端口上,内核自动把入站数据包哈希分发到不同的socket。每个线程持有自己的socket,互不干扰,锁竞争直接消失。这个方案在Linux 3.9之后支持得很好,是构建高性能UDP服务器的首选。

我的选择是混合方案:控制通道用单socket,数据通道用SO_REUSEPORT多socket。控制通道处理会话建立、心跳、关闭这些低频但需要全局状态的操作;数据通道处理高频的业务数据包,每个线程独立处理自己socket上的数据。

2.4 可靠性层放在哪里:内核态还是用户态

既然标题里提到了“可靠UDP”,那就必须回答一个问题:可靠性逻辑放在哪里实现?

有人可能会想,能不能用TCP_ULP或者eBPF在内核态做?理论上可行,但实际开发中调试成本极高,而且内核态的缓冲区管理、定时器精度都不如用户态灵活。更重要的是,内核态的可靠性实现很难跟业务逻辑做深度耦合,比如业务层想根据消息优先级决定重传策略,内核态就无能为力了。

所以我的方案是完全在用户态实现可靠性层。具体来说,就是在UDP之上定义一个轻量的协议头,包含序列号、确认号、标志位、窗口大小等字段,然后实现一套类似TCP但简化得多的状态机。这样做的好处是灵活,坏处是需要自己处理定时器、缓冲区、拥塞控制这些脏活累活。但考虑到UDP服务器的场景通常对延迟敏感而对带宽不那么敏感,我们可以把拥塞控制做得非常简单,甚至先不做,把精力集中在确认和重传上。

3. IO多路复用层的具体实现

3.1 epoll的初始化与事件注册

先来看epoll的初始化。核心就是三个调用:epoll_create1创建实例,epoll_ctl注册/修改/删除事件,epoll_wait等待事件就绪。

#include <sys/epoll.h> #include <sys/socket.h> #include <netinet/in.h> #include <unistd.h> #include <string.h> #include <stdio.h> #include <stdlib.h> #include <errno.h> #define MAX_EVENTS 1024 #define SERVER_PORT 9000 int create_udp_socket(int port) { int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) { perror("socket"); return -1; } int reuse = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(port); if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); close(fd); return -1; } // 设置非阻塞 int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); return fd; }

这里有几个细节值得展开说。第一,SO_REUSEADDR和SO_REUSEPORT都设上。前者让服务器重启时能立刻绑定端口,不用等TIME_WAIT;后者让多个socket能绑同一端口,为多线程做准备。第二,socket必须设成非阻塞,否则recvfrom在没数据时会卡住整个线程,epoll就失去意义了。第三,fcntl设置非阻塞的写法是标准做法,先F_GETFL拿到当前标志,再或上O_NONBLOCK,最后F_SETFL写回去。

接下来是epoll的初始化:

int epfd = epoll_create1(0); if (epfd < 0) { perror("epoll_create1"); return -1; } struct epoll_event ev; ev.events = EPOLLIN | EPOLLET; // 边缘触发 ev.data.fd = udp_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, udp_fd, &ev) < 0) { perror("epoll_ctl"); return -1; }

这里我用了EPOLLET边缘触发模式。边缘触发的意思是,只有当socket的状态发生变化时(比如从无数据变成有数据),epoll_wait才会返回。这要求你在收到通知后必须一次性把数据读干净,否则剩余的数据不会再触发新的事件。水平触发(默认)则相反,只要缓冲区里还有数据,每次epoll_wait都会返回。

边缘触发的效率更高,因为它减少了epoll_wait的返回次数,但编程难度也更大。对于UDP来说,边缘触发其实更自然,因为UDP是数据报协议,每个包都是独立的,你本来就应该循环读到EAGAIN为止。

3.2 边缘触发下的数据读取循环

边缘触发模式下,读取数据的标准写法是这样的:

#define BUF_SIZE 65536 void handle_udp_read(int fd) { char buf[BUF_SIZE]; struct sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr); while (1) { ssize_t n = recvfrom(fd, buf, BUF_SIZE, 0, (struct sockaddr *)&client_addr, &addr_len); if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { // 数据读完了,正常退出 break; } if (errno == EINTR) { // 被信号中断,继续读 continue; } perror("recvfrom"); break; } // 处理收到的数据包 process_packet(buf, n, &client_addr); } }

这个循环的关键在于EAGAIN的处理。当recvfrom返回-1且errno为EAGAIN或EWOULDBLOCK时,说明内核缓冲区已经空了,这时候才能跳出循环。如果你在边缘触发模式下只读一次就退出,剩下的数据就会一直留在缓冲区里,直到下一次状态变化才会被处理,这会导致严重的延迟。

EINTR的处理也很重要。当进程收到信号时,系统调用可能被中断并返回EINTR。这时候不应该退出,而应该继续读。虽然现代Linux的信号处理机制已经改进了很多,但加上这个判断是防御性编程的好习惯。

BUF_SIZE设为65536是因为UDP数据报的理论最大长度是65507字节(65535减去IP头和UDP头),用64K的缓冲区能保证一个完整的数据报不会被截断。如果你用recvfrom读一个比缓冲区大的数据报,多余的部分会被丢弃,而且返回值的含义会变得模糊,所以缓冲区一定要够大。

3.3 事件循环的主体结构

把上面的东西串起来,事件循环的主体大概长这样:

void event_loop(int epfd, int listen_fd) { struct epoll_event events[MAX_EVENTS]; while (running) { int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); if (nfds < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; } for (int i = 0; i < nfds; i++) { int fd = events[i].data.fd; uint32_t ev = events[i].events; if (ev & EPOLLIN) { handle_udp_read(fd); } if (ev & EPOLLERR) { // 处理错误 handle_socket_error(fd); } if (ev & EPOLLHUP) { // 对端关闭(UDP一般不会出现) handle_socket_hup(fd); } } } }

epoll_wait的最后一个参数是超时时间,-1表示永久阻塞直到有事件。但在实际项目中,我建议不要用-1,而是设一个合理的超时值,比如100毫秒。原因是你需要在事件循环里定期处理定时器(比如重传超时、会话超时),如果epoll_wait永久阻塞,定时器就没法及时触发了。设了超时之后,即使没有网络事件,循环也会定期醒来检查定时器。

MAX_EVENTS设为1024是一个经验值。它决定了每次epoll_wait最多返回多少个就绪事件。设得太小会导致频繁调用epoll_wait,设得太大则浪费内存。1024在大多数场景下是个不错的平衡点。

4. 大规模会话管理的核心机制

4.1 会话表的数据结构选择

服务器需要维护一张会话表,记录每个客户端的状态。这张表的查询频率极高——每收到一个数据包,都要根据源地址和端口找到对应的会话。所以数据结构的选择直接决定了性能上限。

最朴素的想法是用链表,但O(n)的查找复杂度在几千个会话时就是灾难。用哈希表是更合理的选择。键是客户端的(IP, Port)二元组,值是指向会话结构体的指针。

#define SESSION_HASH_SIZE 65536 typedef struct session { struct sockaddr_in addr; uint32_t session_id; uint64_t last_active_time; // 可靠性相关字段 uint32_t send_seq; uint32_t recv_seq; // ... 其他状态 struct session *hash_next; struct session *lru_prev; struct session *lru_next; } session_t; typedef struct { session_t *buckets[SESSION_HASH_SIZE]; // LRU链表用于淘汰 session_t *lru_head; session_t *lru_tail; uint32_t session_count; uint32_t max_sessions; } session_table_t;

哈希函数的选择上,我一般用简单的异或混合:

static inline uint32_t session_hash(struct sockaddr_in *addr) { uint32_t h = addr->sin_addr.s_addr ^ (addr->sin_port << 16); h ^= h >> 16; h *= 0x85ebca6b; h ^= h >> 13; return h & (SESSION_HASH_SIZE - 1); }

这个哈希函数来自MurmurHash的简化版,对于IP和端口的组合分布得比较均匀。SESSION_HASH_SIZE取65536(2的16次方)是为了用位与代替取模,提高速度。

4.2 会话的生命周期管理

会话不能无限增长,必须有淘汰机制。我采用的是LRU(最近最少使用)+ 超时的双重策略。

每个会话有一个last_active_time字段,每次收到该会话的数据包就更新。同时,会话被插入到一个LRU链表中,最近活跃的放在头部,最久未活跃的放在尾部。当会话数达到上限时,从尾部淘汰。

超时机制则是定期扫描LRU链表,把超过一定时间(比如60秒)没有活动的会话清理掉。这个扫描不需要遍历整个表,只需要从尾部开始,遇到第一个未超时的就停止,因为LRU链表是按活跃时间排序的。

void cleanup_sessions(session_table_t *table, uint64_t now) { session_t *s = table->lru_tail; while (s && (now - s->last_active_time) > SESSION_TIMEOUT_MS) { session_t *prev = s->lru_prev; remove_session(table, s); free(s); s = prev; } }

这里有个细节:remove_session会同时从哈希表和LRU链表中摘除节点,并释放内存。释放内存时要注意,如果会话结构体里还有未发送完的重传队列,也要一并清理,否则会内存泄漏。

4.3 定时器的实现:时间轮还是最小堆

可靠性层需要定时器来驱动重传。每个待确认的数据包都有一个超时时间,到期未确认就重传。定时器的实现方式直接影响性能。

最简单的做法是每个数据包一个timerfd或者setitimer,但系统调用开销太大。更好的选择是在用户态实现一个定时器容器。常见的有两种:最小堆和时间轮。

最小堆的插入和删除都是O(log n),适合定时器数量不多且超时时间分散的场景。时间轮的插入和删除是O(1),适合定时器数量大且超时时间集中的场景。对于UDP服务器的重传定时器,超时时间通常在几十毫秒到几秒之间,数量跟待确认包的数量成正比,我倾向于用分层时间轮。

#define TIMING_WHEEL_SLOTS 256 #define TIMING_WHEEL_LEVELS 4 typedef struct timer_node { uint32_t expire_slot; uint32_t level; void (*callback)(void *); void *arg; struct timer_node *next; } timer_node_t; typedef struct { timer_node_t *slots[TIMING_WHEEL_LEVELS][TIMING_WHEEL_SLOTS]; uint32_t current_tick[TIMING_WHEEL_LEVELS]; } timing_wheel_t;

时间轮的核心思想是把时间分成固定大小的槽,每个槽里挂一个链表。插入定时器时,根据超时时间计算它应该落在哪个槽里。每次tick推进时,处理当前槽里的所有定时器。分层时间轮则解决了超时时间跨度大的问题,类似时钟的秒针、分针、时针。

这个实现稍微复杂一些,但性能优势在大规模场景下非常明显。如果你觉得时间轮太复杂,用最小堆也完全可以,libevent和libuv内部用的就是最小堆,在几万个定时器的量级下性能足够。

5. 可靠性层的设计要点

5.1 自定义协议头的字段设计

在UDP之上实现可靠性,第一步是定义一个协议头。这个头不能太大,否则开销太高;也不能太小,否则信息不够用。我的设计是16字节:

#pragma pack(push, 1) typedef struct { uint32_t session_id; // 会话标识 uint32_t seq; // 序列号 uint32_t ack; // 确认号 uint16_t flags; // 标志位 uint16_t window; // 接收窗口 uint16_t data_len; // 数据长度 uint16_t checksum; // 校验和 } udp_reliable_header_t; #pragma pack(pop)

session_id用于快速定位会话,比用(IP, Port)做哈希查找更快。seq和ack是可靠性的核心,分别表示发送序列号和期望收到的下一个序列号。flags包含SYN、ACK、FIN、RST等标志,用于会话建立和关闭。window用于流量控制,告诉对端自己还能接收多少数据。checksum用于检测数据损坏,虽然UDP本身有校验和,但那个校验和是可选的,而且只覆盖UDP头和数据,不覆盖我们的协议头。

#pragma pack(push, 1)是为了确保结构体按1字节对齐,避免编译器插入填充字节导致协议头长度不一致。这个在跨平台通信时尤其重要。

5.2 序列号与确认机制

序列号的设计参考TCP但做了简化。每个会话维护两个序列号:send_seq和recv_seq。发送数据时,send_seq递增;接收数据时,检查收到的seq是否等于期望的recv_seq。

确认机制采用累积确认:接收方收到序列号为N的包后,回复ack = N + 1,表示N及之前的所有包都已收到。发送方收到ack后,把发送缓冲区中所有序列号小于ack的包标记为已确认,并从重传队列中移除。

累积确认的好处是实现简单,坏处是如果中间丢了一个包,后面的包即使收到了也无法确认。对于UDP服务器这种对延迟敏感的场景,我额外加了一个**选择性确认(SACK)**的扩展:在协议头的flags里留一个SACK标志,后面跟一个位图,表示哪些包已经收到。这样发送方可以只重传真正丢失的包,而不是重传整个窗口。

5.3 重传策略与RTO计算

重传超时时间(RTO)的计算是可靠性层的核心。TCP用的是Jacobson/Karels算法,根据RTT的平滑值和偏差动态调整。UDP服务器的场景下,RTT通常比较稳定,但也不能写死一个固定值。

我的做法是维护每个会话的srtt(平滑RTT)和rttvar(RTT偏差),初始值分别设为100ms和50ms。每次收到ACK时,用Karn算法更新:

// 只在没有重传的情况下更新RTT if (!retransmitted) { rtt = now - send_time; srtt = (1 - ALPHA) * srtt + ALPHA * rtt; rttvar = (1 - BETA) * rttvar + BETA * abs(srtt - rtt); } rto = srtt + 4 * rttvar;

ALPHA取0.125,BETA取0.25,这是TCP的标准值。rto的下限设为200ms,上限设为2秒。下限是为了避免过于激进的重传导致网络拥塞,上限是为了保证丢包后能及时恢复。

重传次数也要限制,一般重传3次还没确认就认为会话失效,触发清理流程。每次重传时,RTO翻倍(指数退避),避免在网络拥塞时雪上加霜。

6. 实操中踩过的坑与排查技巧

6.1 UDP缓冲区溢出:看不见的丢包

这是最容易踩的坑。Linux的UDP接收缓冲区默认只有212992字节(约208KB),在高并发场景下,如果应用层读取速度跟不上,内核会直接丢弃新到的数据包,而且不会通知应用层。你只能通过/proc/net/udp里的drops字段看到丢包计数。

cat /proc/net/udp | awk '{print $1, $2, $13}'

最后一列就是该socket的丢包数。如果这个数字在持续增长,说明缓冲区不够用了。

解决办法是调大缓冲区:

int buf_size = 4 * 1024 * 1024; // 4MB setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buf_size, sizeof(buf_size));

但要注意,Linux有net.core.rmem_max限制,默认可能是212992。你需要先改系统参数:

sysctl -w net.core.rmem_max=16777216 sysctl -w net.core.wmem_max=16777216

或者直接写/etc/sysctl.conf持久化。我一般把收发缓冲区都设成16MB,在万兆网卡下能扛住短时间的突发流量。

6.2 边缘触发的“饥饿”问题

边缘触发模式下,如果handle_udp_read里的处理逻辑太慢,会导致数据在缓冲区里堆积。因为边缘触发只在状态变化时通知一次,如果你在处理过程中又有新数据到达,epoll_wait不会再次返回,直到你读完所有数据并再次进入等待。

这个问题的表现是:延迟忽高忽低,偶尔出现几秒的卡顿。排查方法是加日志,记录每次handle_udp_read循环的次数和耗时。如果发现某次循环读了上千个包,耗时超过100ms,那就是处理逻辑太重了。

解决办法是把耗时的处理(比如业务逻辑、数据库操作)从读取循环里剥离出来,放到单独的线程池里异步处理。读取循环只负责把数据包拷贝到队列里,然后立刻返回继续读。

6.3 会话ID冲突与哈希碰撞

用session_id做会话标识时,要确保ID的唯一性。如果ID生成算法有缺陷,两个不同的客户端可能拿到相同的ID,导致数据串线。

我的做法是用(IP, Port, 时间戳)的哈希作为ID,并在创建会话时检查是否已存在。如果冲突,就换一个时间戳重新生成。虽然理论上仍有碰撞可能,但概率极低,而且检查机制能兜住。

哈希碰撞则是另一个问题。即使哈希函数再好,当会话数接近哈希表大小时,碰撞率会显著上升。我的经验是保持负载因子在0.5以下,也就是会话数不超过哈希表大小的一半。如果会话数可能超过3万,哈希表就要开到65536以上。

6.4 常见问题速查表

现象可能原因排查方法解决措施
大量丢包接收缓冲区溢出查看/proc/net/udp的drops调大SO_RCVBUF和rmem_max
CPU sys占比高线程调度频繁top -H查看线程状态改用epoll+线程池模型
延迟波动大边缘触发饥饿日志记录读取循环耗时剥离耗时逻辑到异步线程
会话串线session_id冲突日志记录session_id分配加冲突检测和重试
重传风暴RTO设置过小抓包看重传频率调大RTO下限,加指数退避
内存持续增长会话未释放valgrind或pmap观察检查LRU淘汰和超时清理

7. 性能压测与调优实录

7.1 压测环境的搭建

压测UDP服务器比压测TCP要麻烦一些,因为UDP没有连接的概念,压测工具需要自己维护会话状态。我用的是一个自己写的压测客户端,基于SO_REUSEPORT开多个socket,每个socket模拟一批客户端,发送固定大小的数据包,并统计RTT和丢包率。

压测机的配置要和服务器匹配,否则压测机先成为瓶颈。我一般用两台配置相同的机器,一台跑服务器,一台跑压测客户端,中间用万兆交换机直连,避免网络设备成为瓶颈。

7.2 关键性能指标

压测主要看四个指标:吞吐量(每秒处理的数据包数)、延迟(P50、P99、P999)、丢包率、CPU利用率。

在4核8G的虚拟机上,我的实现能跑到每秒80万包的吞吐量,P99延迟在2ms以内,丢包率低于0.01%。CPU的sy占比约25%,si(软中断)占比约15%。这个成绩在用户态UDP服务器里算是中上水平。

如果吞吐量上不去,先检查是不是网卡多队列没开。用ethtool -l eth0查看队列数,如果只有1个队列,那所有中断都打在一个CPU上,其他核都在围观。开启多队列并配合SO_REUSEPORT,吞吐量通常能翻倍。

7.3 调优参数清单

以下是我在实际项目中验证过的调优参数,供参考:

# 网卡多队列 ethtool -L eth0 combined 4 # 增大socket缓冲区 sysctl -w net.core.rmem_max=16777216 sysctl -w net.core.wmem_max=16777216 sysctl -w net.core.rmem_default=2097152 sysctl -w net.core.wmem_default=2097152 # 增大backlog sysctl -w net.core.netdev_max_backlog=5000 # 减少TIME_WAIT(虽然UDP不涉及,但如果有TCP控制通道) sysctl -w net.ipv4.tcp_tw_reuse=1 # 中断亲和性绑定 # 将网卡中断绑定到特定CPU核心

中断亲和性这块要特别说一下。默认情况下,网卡中断会均匀分布到所有CPU,但这样会导致缓存局部性变差。更好的做法是把每个队列的中断绑定到固定的CPU,然后让处理该队列的线程也跑在同一个CPU上,这样数据包从网卡到应用层全程在同一个核上处理,缓存命中率最高。

# 查看中断号 cat /proc/interrupts | grep eth0 # 绑定中断到CPU 0 echo 1 > /proc/irq/<irq_num>/smp_affinity

这个操作需要root权限,而且不同的网卡驱动行为可能不同,建议先在测试环境验证。

8. 后续扩展方向

这个实现目前已经能稳定支撑万级会话,但还有几个方向可以继续深挖。一是拥塞控制,目前基本没做,在公网环境下可能会被其他流量挤占带宽,后续可以加一个简单的BBR或者基于延迟的拥塞控制。二是加密,UDP数据是明文的,如果业务涉及敏感信息,需要在协议层加DTLS或者自定义的加密方案。三是多线程扩展,目前的事件循环是单线程的,虽然SO_REUSEPORT能利用多核,但每个核上的处理逻辑还是串行的,后续可以把会话处理拆分到线程池,进一步提高并发能力。

我在实际部署中发现,单线程事件循环在会话数超过2万之后,epoll_wait的返回和处理会成为瓶颈。这时候可以考虑把会话按session_id哈希分片,每个分片一个线程,各自维护独立的epoll实例和会话表。分片之间尽量不共享状态,如果必须共享,用无锁队列或者RCU来传递消息。这个改造的工作量不小,但收益也很明显,理论上能线性扩展到CPU核数。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询