一、范围
本文会由浅入深的讨论Linux系统下基于IO多路复用的大规模可靠UDP实现,知识范围主要涉及到IO多路复用、UDP协议、UDP可靠传输、协议栈内核态/用户态实现等。Linux内核版本为5.13。本文将主要分为三大部分,第一部分介绍相关基础知识,第二部分介绍在内核态实现,第三部分介绍在用户态实现。UDP高并发比较难做,本文涉及的部分技术点也会比较难懂,那么我们这么做有什么好处呢?
二、好处与现有问题
使用可靠UDP协议的业务越来越多,如直播、网络游戏(王者荣耀、明日帝国等)、监控摄像头等等,好处是延迟低、传输速度快、弱网稳定性高等等。
但是现有的大部分可靠UDP协议的实现都是基于操作系统网络协议栈原生函数sendto/recvfrom,在不使用IO多路复用机制的前提下,单节点无法承受太多的客户端连接,链接数的提高会使CPU、内存占用迅速升高,并且很快达到瓶颈。而我们希望单服务器节点能够达到百万链接,这将会带来更大的好处。如果能够实现的话,适用场景会事怎样呢?
三、适用场景
大规模可靠UDP服务器适用性会广泛,例如聊天室、社交APP、高同时在线网络游戏、电商等对带宽需求不大,但是希望单服务器节点链接数越高越好的应用场景。理论上说基本可以替代TCP和其他的可靠UDP方案而适用于任何场景。下面我们从可靠性UDP、内核态实现、用户态实现这三方面分别介绍,每章都会涉及一些基础知识和如何实现。
四、可靠UDP传输
-
TCP与UDP相比
TCP协议全称"传输控制协议(Transmission Control Protocol)",是一种面向连接的、可靠的、基于字节流的通信协议,由于要兼顾”安全”和”性能”,所以在某些机制下性能会受到影响。
1.1丢包超时计算机制
TCP包超时计算是RTOx2,这样连续丢三次包就变成RTOx8了,这样一旦发生多次丢包重传反应可能会非常缓慢。
1.2丢包重传机制
假设TCP发送端连续发了1、2、3、4、5、6、7、8、9、10共10包,其中4号包丢失了,由于TCP的ACK是确认最后连续收到序号,这样发送端只能收到3号包的ACK,这样在TIME_OUT的时候,发送端会重传3号后面所有的包,也就是重传4~10号包。
由于效果不理想,所以后来有了快速重传、SACK、D-SACK等机制。判断内核是否支持D-SACK,可以调用static bool tcp_check_dsack函数。
1.3拥塞控制机制
发送开始的时候, 定义拥塞窗口大小为1; 每次收到一个ACK应答, 拥塞窗口大小+1; 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小作比较, 取较小的值作为实际发送的窗口(另外参考TCP慢启动、快恢复机制)。一旦发生拥塞丢包,则会重新进入慢启动过程。
为解决此问题,Google提出了其他拥塞控制算法 CDG、BBR(Bottleneck Bandwidth and RTT)。查看本机采用拥塞控制算法的命令:sysctl net.ipv4.tcp_congestion_control,查看本机是否开启BBR的命令:lsmod | grep bbr
而“安全可靠”在应用层实现的时候,则可以将以上问题最大限度的做好取舍,优化性能瓶颈。
2.可靠UDP传输
由于UDP协议的传输层无法保证数据的可靠传输,所以只能通过应用层实现,实现方法参照TCP。主要是需要实现seq/ack机制、收发窗口、超时重传机制等。一些利用UDP实现了可靠数据传输的协议有:RUDP、RTP、UDT、ENET、KCP、UDX等等。下面我们将稍微详细介绍一下KCP及相关算法。
3.KCP
KCP基本上中规中矩的实现了可靠性UDP,能以比 TCP 浪费 10%-20% 的带宽的代价,换取平均延迟降低 30%-40%,且最大延迟降低三倍的传输效果。纯算法实现,并不负责底层协议(如UDP)的收发,需要使用者自己定义下层数据包的发送方式,以 callback的方式提供给 KCP。 连时钟都需要外部传递进来,内部不会有任何一次系统调用。官网地址如下:https://github.com/skywind3000/kcp
在官网中,KCP技术特性介绍如下:可以自定义RTO、丢包选择性重传、快速重传、非延迟ACK、非退让流控等。更多对于KCP的介绍详见附录一。KCP只是一个协议,只能靠外部调用来驱动,真正使用起来还需要一个外部框架,在KCP官网中给了链接的XKCPTUN就是其中一个外部框架。
4.XKCPTUN简介
XKCPTUN主要用于OpenWrt, LEDE开发的路由器项目上,作为高速远程端口转发隧道,可以在类似于在线观看视频等应用场景起到作用。XKCPTUN使用了KCP作为协议,使用了libevent2作为消息通知管理工具。Libevent2代码下载地址如下:https://github.com/libevent/libevent 。XKCPTUN部分代码举例如下:
xkcp_server文件中,注册event,从KCP Client接收数据(其他部分的代码大同小异):
int server_main_loop()
{
…
xkcp_event = event_new(base, xkcp_fd, EV_READ|EV_PERSIST, xkcp_rcv_cb, base);
…
}
static void xkcp_rcv_cb(const int sock, short int which, void *arg)
{
…
int len = recvfrom(sock, buf, sizeof(buf) - 1, 0, (struct sockaddr *) &clientaddr, (socklen_t*)&clientlen);
accept_client_data(sock, base, &clientaddr, clientlen, buf, len);
…
}
static void accept_client_data(const int xkcpfd, struct event_base *base,
struct sockaddr_in *from, int from_len, char *data, int len)
{
…
int nret = ikcp_input(task->kcp, data, len);
xkcp_forward_all_data(task_list);
…
}
void xkcp_forward_all_data(iqueue_head *task_list)
{
struct xkcp_task *task;
iqueue_foreach(task, task_list, xkcp_task_type, head) {
if (task->kcp) {
xkcp_forward_data(task);
}
}
}
void xkcp_forward_data(struct xkcp_task *task)
{
while(1) {
…
int nrecv = ikcp_recv(task->kcp, obuf, OBUF_SIZE);
evbuffer_add(bufferevent_get_output(task->bev), obuf, nrecv);
…
}
}
对于libevent2来说,在Linux系统下是使用epoll作为最优的IO多路复用机制的,仅限于TCP协议(具体实现在libevent2\ epoll.c中)。对于UDP来说,由于内核不会维护一个可靠链接,也就不会生成一个socket,也就无法使用epoll强大的event机制。如果绕开Linux内核使用用户态协议栈如dpdk、netmap等也是一个不错的选择,本文会将使用内核态实现和用户态实现都探讨一番,不过在深入探讨之前,还有一些其他方面的知识需要交代。
5.KCP协议提高
KCP本身代码清晰,较为完整,这里是一份性能对比报告:bbr-vs-kcp-优化 http下载性能对比报告,其中一个结论是:” 比较糟糕的网络环境下,会发现,kcp受到的影响会很小,在这种场景下,kcp完胜bbr。”
但是由于KCP长时间没有维护,所以还有较大提高空间和方法,在某些特定情况下传输速度还可以提升很多、延迟也可以进一步提高,下面举例说明:
5.1KCP本身提高与提高方法(动态参数设置)
KCP启动的时候用下面几个函数来调整参数
int ikcp_nodelay(ikcpcb *kcp, int nodelay, int interval, int resend, int nc);
int ikcp_wndsize(ikcpcb *kcp, int sndwnd, int rcvwnd);
int ikcp_setmtu(ikcpcb *kcp, int mtu);
具体参数含义见附录一,在不同的网络条件下,初始参数应该按需设置;同时在通讯链接建立一段时间后、中间网络或者客户端网络发生变化等时候,应该由服务器端发现或者客户端主动通知服务器端动态调整其中部分参数。
采用新的算法如CUBIC、CDG、BBR、DSACK、Westwood等等。参考文章《TCP CDG算法与TCP Westwood算法》。
例如,在Linux内核中有如下tcp拥塞处理操作结构:
struct tcp_congestion_ops {
/* fast path fields are put first to fill one cache line */
/* return slow start threshold (required) 返回慢启动阈值*/
u32 (*ssthresh)(struct sock *sk);
/* do new cwnd calculation (required) *进行新的拥塞窗口计算/
void (*cong_avoid)(struct sock *sk, u32 ack, u32 acked);
/* call before changing ca_state (optional) */
void (*set_state)(struct sock *sk, u8 new_state);
/* call when cwnd event occurs (optional) 发生拥塞事件时调用*/
void (*cwnd_event)(struct sock *sk, enum tcp_ca_event ev);
/* call when ack arrives (optional)ack到达时调用 */
void (*in_ack_event)(struct sock *sk, u32 flags);
/* hook for packet ack accounting (optional) */
void (*pkts_acked)(struct sock *sk, const struct ack_sample *sample);
/* override sysctl_tcp_min_tso_segs */
u32 (*min_tso_segs)(struct sock *sk);
/* call when packets are delivered to update cwnd and pacing rate,
* after all the ca_state processing. (optional)
*/
void (*cong_control)(struct sock *sk, const struct rate_sample *rs);
…
} ____cacheline_aligned_in_smp;
进一步搜索cwnd_event,查看发生拥塞事件时都会调用什么,就会得到如下结果(持续搜索可以参考并借鉴更多TCP中采用的优秀算法和在什么情况下会用到哪种算法):
\linux-5.13\net\ipv4\bpf_tcp_ca.c(17): offsetof(struct tcp_congestion_ops, cwnd_event),
\linux-5.13\net\ipv4\tcp_bbr.c(1148): .cwnd_event = bbr_cwnd_event,
\linux-5.13\net\ipv4\tcp_cdg.c(395): .cwnd_event = tcp_cdg_cwnd_event,
\linux-5.13\net\ipv4\tcp_cubic.c(479): .cwnd_event = cubictcp_cwnd_event,
\linux-5.13\net\ipv4\tcp_dctcp.c(219): .cwnd_event = dctcp_cwnd_event,
\linux-5.13\net\ipv4\tcp_vegas.c(315): .cwnd_event = tcp_vegas_cwnd_event,
\linux-5.13\net\ipv4\tcp_veno.c(215): .cwnd_event = tcp_veno_cwnd_event,
\linux-5.13\net\ipv4\tcp_westwood.c(283): .cwnd_event = tcp_westwood_event,
\linux-5.13\net\ipv4\tcp_yeah.c(214): .cwnd_event = tcp_vegas_cwnd_event,
5.2框架
KCP只是一个协议,封装以及调用它的框架也非常重要,XKCPTUN对于KCP的封装调用就是典型的例子之一。
结合自己实际,适当的采用多线程,例如在框架内收发包、以及对KCP的ikcp_flush刷新函数的调用等都可以采用单独的线程。还可以支持多网卡绑定、虚拟网卡、如果实测发现网卡没达到最高负荷还可以采用同网卡多Bind的方法(SO_REUSEPORT)。
5.3前向纠错(FEC)
KCP协议本身没有前向纠错功能,根据实际应用场景,选择合适的前向纠错算法也是一个很重要的事情,对于稳定性有很大提高,对于传输效率也会有一定提升。
对于音频、视频、直播等应用,需要稳定有序的数据流,所以较容易将数据切分成合适的相等的长度从而组合为group。而对于游戏等应用数据包大小不等、有时候发包很少有时候又很多,并不是所有数据包都适合类似于RS(255 239)的切分,所以应该灵活的选择前向纠错算法。
https://water-melon.github.io/Melon/实现了FEC和里德所罗门(Reed-Solomon)编码。
http://openfec.org/开源的OPENFEC网站,也提供了不少好的算法。
https://webrtc.googlesource.com/src是一个支持网页浏览器进行实时语音对话或视频对话的API,里边的算法(RTP/RTCP)也值得借鉴。
http://feclib.sourceforge.net/ 可以通过冗余包找到丢失的数据包
http://rscode.sourceforge.net/可以纠正数据包内部错误
还有一些其他的FEC算法:RFC 5109 ULPFEC、RFC 6682 喷泉编码等等。
5.4测试
在测试中,除了实际环境以外,还可以采用网络损伤仪这样的设备。有趣的是由于KCP是在应用层实现的,所以我们可以自己写代码在应用层写模仿网络损伤的来测试KCP。我们可以架一个中间转发层,除了透明转发之外,可以实现固定丢包、随机丢包、网络拥塞、网络抖动、乱序/重复、瞬间峰值、接收端窗口溢出、大规模前端并发/后端广播/前端断网等等。
这样做的好处是我们可以自己灵活的实现更多网络损伤功能,并且可以自由组合,我们还可以按自己喜欢的方式输出日志丰富信息。通过丰富的信息可以极大的提高协议的适应性和各个参数/算法的准确度。
我们也可以使用Linux系统命令tc来模拟部分测试功能。详见《Linux下TC使用说明》。
6.中断还是轮询
中断是外部事件通知的手段,在外部没有通知的时候就处于等待状态,优势能应对较多的外部请求。轮询是不停的查询是否有外部事件,如果没有就紧接着再查,优势是响应会比较快,目前多数可靠UDP框架都采用轮询来实现。
DPDK提供了UIO+PMD模式,内核态的UIO Driver屏蔽了网卡发出的中断信号,用户态的PMD(Poll Mode Drivers)主动轮询,接收到数据包后,会通过DMA的方式直接将数据写入预分配的内存中。这种方式带来了零拷贝(Zero Copy)、无系统调用(System call)、避免了软中断的异步处理、减少了上下文切换,除了链路状态通知仍必须采用中断方式以外均使用无中断,极大的提升了网卡IO性能。带来的坏处就是执行top命令后,会发现会有一颗cpu的sy占用非常高。
DPDK也提供了Interrupt模式,跟NAPI类似在没有数据时进程休眠,改为中断通知,有数据后唤醒进程。
很多时候中断和轮询并不是二选一,例如网卡收到数据时,就会硬中断,然后激活软中断,硬中断返回时软中断继续进行(这就是采用NAPI技术的网卡运行方式)。我们可以把后面的软中断屏蔽,轮询一会,如果没有数据再返回。理论上说要支持更多的客户端,就无法让每个客户端都到达很高的带宽,所以较多的采用中断方式是比较好的选择。
如果中断过多导致过载,可以通过如下命令查看原因。
mpstat -I CPU -P ALL 1 确认那个设备/项目导致负载
cat /proc/interrupts 可以发现是哪个设备在使用中断
cat /proc/softirqs 查看软中断相关信息
7.内核态还是用户态
本文后面会分别给出这两种实现,从理论上来说,在用户态实现大规模可靠UDP服务会好一些,因为可以使用用户态TCP/IP协议栈,所有应用层代码和算法都可以自己实现,可以避免在内核态中很难解决的问题。但是工作量会大很多。
不过我们还是试图给出内核态的实现,因为我们可以由此借鉴内核中的优秀代码、看到内核中的不足。
除了DPDK(官网https://www.dpdk.org/)之外,netmap(官网http://info.iet.unipi.it/~luigi/netmap)和F-Stack(官网http://www.f-stack.org/)也是不错的用户态协议栈。mTcp(官网https://github.com/mtcp-stack/mtcp.git)是一个用户层TCP堆栈,底层需要dpdk、netmap、onvm或者psio的支持。
8.Epoll的IO多路复用
收包过程大致如下
1、网卡通过网线收到数据(此时数据缓存在网卡中)
2、通过DMA的方式将数据写到内核内存(形成skb_buffer)
3、硬中断通知内核
4、内核通过数据,解析出其中的SIP,SPORT,DIP,DPORT,找到对应的socket
5、将对应的事件写入epoll的rdllist
6、唤醒wait的进程
7、依次处理所有的事件和数据
8、处理完成后重新开启硬中断
参考文章
linux网络之数据包的接受过程:https://www.jianshu.com/p/e6162bc984c8
Epoll的本质:https://blog.csdn.net/sunxianghuang/article/details/105028062
Eopll的高效在于三大关键要素:mmap、红黑树、链表。
mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址,使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。
红黑树通过epitem存储epoll所监听的套接字、文件描述符,红黑树本身插入和删除性能比较好,时间复杂度O(logN)。
双向链表存储事件,一旦有事件发生,epoll就会将该事件添加到双向链表中。那么当我们调用epoll_wait时,epoll_wait只需要检查rdlist双向链表中是否有存在注册的事件。
参考文章:Epoll更详细的解释和源代码分析:
https://blog.csdn.net/weiyuefei/article/details/53006659
共享内存:https://blog.csdn.net/21cnbao/article/details/103470878
五、内核态实现
1.内核文件描述符
文件描述符(file descriptor)是一个int类型,对应结构是struct file,代表文件系统下的一个文件,支持open/read/write/release之类的接口,在设备读写、网络通信、进程通信随处可见。其中struct file结构下的private_data用来与其他结构互相调用和转换。
套接口文件
static ssize_t do_sock_read(struct msghdr *msg, struct kiocb *iocb,
struct file *file, const struct iovec *iov, unsigned long nr_segs)
{
struct socket *sock = file->private_data;
…
epoll文件
static int ep_eventpoll_close(struct inode *inode, struct file *file)
{
struct eventpoll *ep = file->private_data;
2.进程调度
对于accept、epoll_wait等阻塞函数,用到了Linux内核的进程调度机制,主要是进程休眠唤醒等相关知识,由于比较简单并且我们不会对这部分有所修改,所以简单理解就可以了。
参考文档:《linux wait queue详解》
3.epoll大致运行方式
使用TCP的epoll:
int listenfd = socket(…); bind(); listen();
int epollfd = epoll_create(…);
epoll_ctl(epollfd,EPOLL_CTL_ADD, listenfd, …);
for (;;) {
int num = epoll_wait(epollfd, events…);
for(i=0~num) {
int fd = events[i].data.fd;
if (fd == listenfd) {
Int clientfd = accept(listenfd, …);
epoll_ctl(epollfd,EPOLL_CTL_ADD, clientfd, …);
} else
read,write…
而普通的UDP:
int ufd = socket(…); bind();
recvfrom(ufd, …);
sendto(ufd, …);
对比上面的代码可见,UDP协议的实现里只有一个sock,比TCP少listen和accept步骤。我们来socket、listen和accept函数都干了什么。
4.socket函数主要部分(socket.c文件中)
int __sys_socket(int family, int type, int protocol)
{
struct socket *sock;
retval = sock_create(family, type, protocol, &sock); //创建一个socket
return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}
int __sock_create(struct net *net, int family, int type, int protocol,
struct socket **res, int kern)
{
struct socket *sock;
sock = sock_alloc(); //分配内存
}
static int sock_map_fd(struct socket *sock, int flags)
{
struct file *newfile;
int fd = get_unused_fd_flags(flags); //申请一个fd号
newfile = sock_alloc_file(sock, flags, NULL); //创建一个file,并于sock关联
fd_install(fd, newfile); //将fd与newfile关联
return fd
}
struct file *sock_alloc_file(struct socket *sock, int flags, const char *dname)
{
struct file *file;
file = alloc_file_pseudo(SOCK_INODE(sock), sock_mnt, dname,…);
sock->file = file;
file->private_data = sock;
stream_open(SOCK_INODE(sock), file);
return file;
}
可见socket的本质就是一个文件描述符,里边有一个文件和一个socket结构。此外还有一个sock_create_lite也可以创建socket,但是并没有关联文件。
参考文章:《一切都是文件之匿名inode》
5.listen函数主要部分(socket.c文件中)
SYSCALL_DEFINE2(listen, int, fd, int, backlog)
{
struct socket *sock;
sock = sockfd_lookup_light(fd, …); //通过fd找到sock
security_socket_listen(sock, backlog); //判断sock是否合法等
err = sock->ops->listen(sock, backlog); //由于sock->ops指向inet_stream_ops,所以执行到inet_listen
}
int inet_listen(struct socket *sock, int backlog) // af_inet.c文件中
{
struct sock *sk = sock->sk
inet_csk_listen_start(sk, backlog); //进行监听
…
}
int inet_csk_listen_start(struct sock *sk, int backlog) // inet_connection_sock.c文件中
{
struct inet_sock *inet = inet_sk(sk);
struct inet_connection_sock *icsk = inet_csk(sk);
reqsk_queue_alloc(&icsk->icsk_accept_queue, nr_table_entries); //为请求队列分配内存空间
inet_csk_delack_init(sk); //初始化传输控制块中与延时发送ACK段有关的控制数据结构
sk->sk_state = TCP_LISTEN; //设置传输控制块状态为监听状态
}
可见listen函数就是将用socket函数创建并bind到某个端口上的socket修改为TCP_LISTEN状态,并创建队列。
6.accept函数主要部分
int __sys_accept4(int fd, struct sockaddr __user *upeer_sockaddr,
int __user *upeer_addrlen, int flags)
{
struct fd f;
f = fdget(fd); //将文件描述符fd,转为struct fd格式
__sys_accept4_file(f.file, 0, upeer_sockaddr, …); //
}
int __sys_accept4_file(struct file *file, unsigned file_flags,
struct sockaddr __user *upeer_sockaddr,
int __user *upeer_addrlen, int flags,
unsigned long nofile)
{
struct socket *sock, *newsock;
struct file *newfile;
sock = sock_from_file(file); //通过file找到sock,通过”socket函数主要部分”这一节我们知道struct file *的struct socket *指针就在private_data里。
newsock = sock_alloc(); //创建一个新的socket,这个跟前面__sock_create函数中创建socket相同
newsock->type = sock->type; //类型相同
newsock->ops = sock->ops; //操作相同
__module_get(newsock->ops->owner); //当前module的引用计数+1
newfd = __get_unused_fd_flags(flags, nofile);//跟__sock_create相同
newfile = sock_alloc_file(newsock, flags, sock->sk->sk_prot_creator->name);//
security_socket_accept(sock, newsock); //将sock的selinux权限复制到newsock
sock->ops->accept(sock, newsock, sock->file->f_flags | file_flags,…);//调用inet_accept
fd_install(newfd, newfile);
…
}
int inet_accept(struct socket *sock, struct socket *newsock, int flags,
bool kern)
{
struct sock *sk1 = sock->sk;
struct sock *sk2 = sk1->sk_prot->accept(sk1, flags, &err, kern); //这里调用的是inet_csk_accept(参见struct proto tcp_prot的定义)
}
struct sock *inet_csk_accept(struct sock *sk, int flags, int *err, bool kern)
{
struct inet_connection_sock *icsk = inet_csk(sk);
struct request_sock_queue *queue = &icsk->icsk_accept_queue;
inet_csk_wait_for_connect(sk, timeo); //阻塞等待链接
…//这里检查之前listen队列中的某一个req是否三次握手完毕等等
}
static int inet_csk_wait_for_connect(struct sock *sk, long timeo)
{
for(;;)
{
prepare_to_wait_exclusive(sk_sleep(sk), &wait, //把等待任务加入到socket的等待队列中,把进程状态设置为TASK_INTERRUPTIBLE
schedule_timeout(timeo); //陷入睡眠
if (! reqsk_queue_empty(&icsk->icsk_accept_queue)) break; //醒来后如果队列不为空则退出,否则再次睡眠
}
}
7.详细说明收包过程(EPOLL、TCP)
前面曾经简略说过收包过程,此处需要更详细一些的说明:
网卡到内存:
内核的网络模块
IP层
最后一步的NF_INET_LOCAL_IN相关的钩子调用,数据包将会向下发送到ip_protocol_deliver_rcu函数中,ipprot = rcu_dereference(inet_protos[protocol]);去除对应的协议,INDIRECT_CALL_2(ipprot->handler, tcp_v4_rcv, udp_rcv, skb);,根据不同的协议分别执行tcp_v4_rcv,或者udp_rcv。下面我们分别看协议栈具体收包过程。
8.tcp_v4_rcv
int tcp_v4_rcv(struct sk_buff *skb)
{
…
struct sock *sk;
sk = __inet_lookup_skb(&tcp_hashinfo, skb, __tcp_hdrlen(th), th->source,
th->dest, sdif, &refcounted); //参数中tcp_hashinfo是全局唯一的全局变量,根据sip,
// sport,dip,dport计算出hash,通过for循环找到sk,这一步非常重要。
ret = tcp_v4_do_rcv(sk, skb);//
}
static inline struct sock *__inet_lookup_skb(struct inet_hashinfo *hashinfo,
struct sk_buff *skb, int doff, const __be16 sport, const __be16 dport,
const int sdif, bool *refcounted)
{
return __inet_lookup(dev_net(skb_dst(skb)->dev), hashinfo, skb,
doff, iph->saddr, sport, iph->daddr, dport, inet_iif(skb), sdif, refcounted);
}
static inline struct sock *__inet_lookup(struct net *net, struct inet_hashinfo *hashinfo,
struct sk_buff*skb, int doff, const __be32 saddr, const __be16 sport,
const __be32 daddr, const __be16 dport, const int dif, const int sdif,
bool *refcounted)
{
… //查找处于TCP_ESTABLISHED 或者TCP_LISTEN状态
sk = __inet_lookup_established(net, hashinfo, saddr, sport, daddr, hnum, dif, sdif);
…
return __inet_lookup_listener(net, hashinfo, skb, doff, saddr,sport, daddr, hnum, dif, sdif);
}
struct sock *__inet_lookup_established(struct net *net, struct inet_hashinfo *hashinfo,
const __be32 saddr, const __be16 sport, const __be32 daddr, const u16 hnum,
const int dif, const int sdif)
{
unsigned int hash = inet_ehashfn(net, daddr, hnum, saddr, sport);
unsigned int slot = hash & hashinfo->ehash_mask;
struct inet_ehash_bucket *head = &hashinfo->ehash[slot];
sk_nulls_for_each_rcu(sk, node, &head->chain) {…}
}
struct sock *__inet_lookup_listener(struct net *net, struct inet_hashinfo *hashinfo,
struct sk_buff *skb, int doff, const __be32 saddr, __be16 sport,
const __be32 daddr, const unsigned short hnum, const int dif, const int sdif)
{
struct inet_listen_hashbucket *ilb2;
unsigned int hash2;
//求hash值
hash2 = ipv4_portaddr_hash(net, daddr, hnum);
//取出bucket
ilb2 = inet_lhash2_bucket(hashinfo, hash2);
//在bucket里边用inet_lhash2_for_each_icsk_rcu,for循环来查找sock
result = inet_lhash2_lookup(net, ilb2, skb, doff, saddr, sport, daddr, hnum,dif,
sdif);
}
再看看收到数据后的处理
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
if (sk->sk_state == TCP_LISTEN)
tcp_child_process(sk, nsk, skb)
else
sock_rps_save_rxhash(sk, skb);
tcp_rcv_state_process(sk, skb)
}
int tcp_child_process(struct sock *parent, struct sock *child,
struct sk_buff *skb)
{
parent->sk_data_ready(parent); //其实是sock_def_readable
}
void sock_def_readable(struct sock *sk)
{
struct socket_wq *wq;
wq = rcu_dereference(sk->sk_wq);
wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN | EPOLLPRI |
EPOLLRDNORM | EPOLLRDBAND); //唤醒epoll_wait
}
tcp_rcv_state_process用于修改sock状态,并移进移出队列
tcp_rcv_state_process-> tcp_rcv_synsent_state_process-> tcp_finish_connect-> tcp_set_state-> inet_put_port-> __inet_put_port-> __sk_del_bind_node
9.udp_rcv
int __udp4_lib_rcv(struct sk_buff *skb, struct udp_table *udptable, int proto)
{
…
struct sock *sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest, udptable);
udp_unicast_rcv_skb(sk, skb, uh);
…
}
__udp4_lib_lookup_skb通过一个for循环,通过sip,sport,dip,dport找到对应的sock,也就是udp的bind的sock,然后通过udp_unicast_rcv_skb --->udp_queue_rcv_skb --->udp_queue_rcv_one_skb --->__udp_queue_rcv_skb --->__udp_enqueue_schedule_skb --->sk_data_ready把消息包放入队列通知应用层。应用层的recvfrom函数阻塞在那里等着数据来,这种情况下当socket收到通知后,recvfrom就会被唤醒。
10.总结
把以上所有信息做个最概括的TCP收包总结:
- 创建一个epfd,拥有了匿名inode文件描述符、红黑树、就绪队列等;
- 创建一个listenfd,设置为TCP_LISTEN,放入epfd红黑树;
- 调用epoll_wait进程进入休眠;
- 网卡收到消息,在ip层用sip,sport,dip,dport从tcp_hashinfo中找到对应的sock,将消息内容sk_buffer放到sock中;
- 如果sock是TCP_ESTABLISHED,或者是TCP_LISTEN的,并完成三次握手则将消息事件放入就绪队列,然后唤醒进程;
- 应用层处理消息,如果是新接入则调用accept,则创建sockfd放入epfd红黑树(对于这一步,在大多数应用层程序里都是定式,如果应用层没有特殊的要求,比如所有sock都关心IN/OUT/ERR等事件,那么可以在udp_accept函数中直接完成此步骤)。如果是普通消息则调用recv来收消息。
相比之下,UDP没有第2个过程,在第4,5,6个过程也完全不同。在第2个过程中,由于只是在等待tcp完成三次握手创建链接以及状态转换,所以可以不用关心。
修改第4,5,6个步骤,对于UDP来说需要在内核中建立一个udp_hashinfo(抛弃掉以前的udptable),新增加一个udp_accept函数,在收到数据包的时候,对于在udp_hashinfo查找不到的连接通过一些安全性判定后作为新接入的链接,增加一个udp_sock函数来创建一个新的sock,将sip、sport、dip、dport作为hash值将sock写入udp_hashinfo,同时将socket加入到udp的bind的epfd中。同样调用epoll_wait休眠进程,当收到包时,则在__udp4_lib_rcv函数中模仿tcp_v4_do_rcv,将事件写入就绪队列,唤醒epoll_wait进程即可。
对于tcp_hashinfo来说,是全局唯二的(listen和establish),对于不同的进程不同的CPU,需要用spin_lock这种全局锁来锁住,如果进程过多CPU核心数也较多则可能会带来效率问题,google的reuseport机制较好的解决了这个问题,https://github.com/fastos/fastsocket是采用此机制的较好实现。而对于新增加的udp_hashinfo来说,可以灵活的对待这个问题,即可以不是全局唯一的,也可以用更好的算法。参考文档:https://blog.csdn.net/dog250/article/details/80575731
11.内核重新编译
虽然这也是一个必要的步骤,但是相对来说就很简单了,网上文章比比皆是,在此不再赘述。另外看了内核中的enum bpf_prog_type,感觉我们似乎也没法使用ebpf技术。不过可以用docker来部署自定义的内核。
至此,通过修改内核实现IO多路复用的大规模可靠UDP服务器就讲完了,只要深入的了解了内核中TCP/UDP建立连接与收发包、epoll相关实现机制、可靠UDP协议(如KCP)及相关优化(如BBR等)就可以实现。虽然看似改动的地方不多,但是内核修改和完善起来还是非常复杂,下面我们介绍怎么站在前人的肩膀上,在用户态层面来实现。