本书 《网络协议栈入门》 采用的代码是 基于 linux 内核 4.4.4 版本的。linux 内核源码下载地址: mirrors.edge.kernel.org
早期,我对 网络协议栈 还不是很熟悉的时候,经常在网络文章中见到 多路复用,TCP 用 epoll 解决高并发问题。
那时候我就有个疑惑,epoll 这么牛逼的技术,为何不用在 UDP 里面,UDP 编程为什么没用 epoll。
后面我看了 UDP 跟TCP 的协议栈代码实现,这个疑惑才在我心中解开,多路复用 的神秘面纱也揭开了。
首先,讲讲 UDP 编程为什么不需要用到系统的 epoll 函数,
Richard Stevens在不朽的经典《Unix网络编程卷一》中已经说了:“大多数情况下,TCP服务器是并发的,UDP的服务器是迭代的。”
上面这句话可能不太容易理解。
我基于自己的理解,用大白话跟大家讲,为什么 UDP 用不到 epoll 的系统函数。
首先借用一句话,"内核不是解决方案,而是问题所在"。
linux 操作系统为什么需要提供 epoll 函数,在 epoll 之前还有 select/poll 函数。这些函数是干什么的。
首先,多路复用 这个词是 针对 面向链接 协议的说法,一个客户端连接就是一路。
但是实际上,在 IP 层,或者说在网络底层,根本就没有什么多路,只有一路。没错,只有一路。
TCP 服务器一般是这样的流程,一个线程处理一个客户端连接:
//创建服务器描述符 s_fd
s_fd = socket()
//把 服务器 fd 绑定地址端口
bind(s_fd)
//正式开始监听
listen()
//阻塞等待 TCP 3次握手
c_fd = accept(s_fd)
//开启新线程 处理 c_fd 的读写。
UDP 服务器程序一般是这样的流程:
//创建服务器描述符 s_fd
s_fd = socket()
//把 服务器 fd 绑定地址端口
bind(s_fd)
//直接接受客户端数据包
recvfrom()
可以看到,UDP 实际上是 TCP 的简化版。recvfrom 是一个阻塞函数。 IP 层有数据包到了,就检查 IP header 的协议字段,判断 IP body里是不是 udp 的数据,如果是就激活 recvfrom(),这样 UDP 就能拿到 客户端的数据。这个激活,好像是一个软中断还是一个信号,具体在 linux 4.4 内核的哪一块代码,我需要花点时间找一找,先埋个坑,后续补充,暂时理解为激活就行。
从 UDP 跟 IP 层的交互来看,你可以看到,实际上没有什么多路,所有的客户端数据都在一路里面传递给 IP 层,再由 IP 层传给 UDP 层recvfrom()
。
既然如此,那为什么 TCP 会出现 多路,而且因为 多路 带来性能问题,需要再用 epoll 解决。
这里就需要先讲解 一下什么是 面向连接的协议?
我们在教科书经常看到 ,TCP 是面向连接的,而 UDP 不是。
实际上上面这句话是什么意思呢?
首先,TCP 的实现是在linux 内核代码里面的,所以 TCP 属于linux 内核的一部分。
首先讲解一下,连接状态,它并不是特别真实的一个东西,这个东西比较虚,连接状态,只是内存里面的一个变量,而且还不是实时更新的。
为什么说它不是实时更新的,就是当底层网络,通信链路不可达的时候,什么是通信链路不可达?直接把中间路由的网线拔开就是了。
当通信链路不可达的时候,TCP 里面的链接状态不会非常快的更改为断开状态。之前在 《视频传输协议设计》 演示过,把网线拔开之后,TCP 要经过多次重传失败,才会认定底层 通信链路不可达,然后返回一个信号给调用层。当网线断开的时候,TCP 客户端从 重试 到 确认链接断开用了 19 秒才知道网线断开。
所以说 TCP 里面的连接状态 只是内存的一个变量,一个虚拟的东西。
再来说说,为什么协议栈的发展,会演变出 连接状态 这个内存变量 ?
要讲这个问题,就需要先讲为什么 广域网的 协议,基本上都有 3次握手,TCP,UDT,SRT,你可以看到这些协议都有3次握手。
TCP 的3次握手是自带的,UDT 是基于 UDP 的协议,自己实现 3次握手,SRT 是基于 UDT 的。
这个先抛出个问题,UDT 既然是用 UDP 实现的, UDP 本身没有 3次握手,没有不是更好,可以少几次 RTT 的时间,通信更快。
为什么 UDT 还要自己搞一套 3 次握手,不用3次握手不是更快吗?
从直觉来讲,没有3次握手,确实会更快,例如 IP 层,MAC 层的各种协议,就没有什么3次握手。
但是,需要注意的是,IP 层 MAC 层,他是工作在路由器,交换机里面的,他的数据包发给下一站就完事了,通常下一站不是太远。
而 UDT,TCP,他们的传输,可能是从东大陆到北极,100M的数据,从东大陆某个主机发出来,经过那么多路由器,交换机,跨越海洋,到达北极的某台服务器。如果没有 3次握手,提取沟通一下双方的一些情况,100M的传输效率会极其低下,并不会因为没有 3次握手而更快,相反,由于上层协议栈没有握手导致一些信息掌握不全面,发送跟接受策略不行,会导致 IP 层丢包,大量重传。
所以 3 次握手,干的是什么的事?就是提前沟通一下双方 的信息,例如我的 MTU 是多少,你的MTU是多少之类的。
这里再抛出另一个问题,为什么是3次握手,不是4次,2次?
这个问题实际上是一个 ACK 设计问题,由于 IP 层不可靠会丢包,特别是经过那么多路由器转发,丢包的概率会增加,例如路由器负载太高,他就会丢弃数据包减轻负载。
所以基于 IP 层的协议,想要在广域网上实现可靠传输,都需要自己实现一套 ACK 机制,TCP 自带 ACK,UDT 协议是自己实现 ACK。
因为 ACK 机制的出现,再加上 广域网上传输可靠数据需要提前沟通,沟通好才开始正式开始传输数据。
ACK + 提前沟通,就形成了 3次握手,举个例子。
客户端要开始传数据给服务器,客户端先 发一个 UDP 包,里面有他的MTU等信息,然后服务器端收到了这个 UDP 包,因为要有确认机制,所以 服务器需要 发一个ACK 的UDP包给 客户端,告诉客户端,服务器已经收到了 他 MTU UDP包。但是同时,服务器也需要告诉客户端自己的一些信息,为了提高效率,服务器的MTU信息也会放进去 ACK里面一起返回去给客户端。
然后 客户端收到了服务器的 ACK,客户端知道 MTU 信息服务器端拿到了,客户端就不会重传。同时,客户端也收到服务器的MTU UDP 包,客户端需要回复服务器一个 ACK,说自己拿到了。
上面这些流程,就是3次握手,也就是 TCP 里面的 SYN
-> ACK+SYN
-> ACK
。
回到之前的问题,为什么 UDP 不需要多路复用,是因为 UDP 他本身没实现 3次握手跟链接状态的功能。那是不是如果一个基于 UDP 的协议栈 实现了3次握手跟建立连接,就会需要多路复用,其实也不是。
举个例子,基于UDP 设计一个协议。我把他 叫 UDL。
简洁的流程如下:
1,服务器 recvfrom()
阻塞等待 IP 层把 IP body 的数据丢上来。
2,服务器 recvfrom()
拿到了 客户端的 UDP 数据,有端口,有客户端IP,有客户端MTU 这些数据。
3,服务器开始回复 ACK,3次握手省略,3次握手 成功之后 然后我服务器 创建一个 map,把 客户端 IP+PORT 作为一个key传进去 map,代表这个 客户端 IP+PORT 已经建立连接了。可以正式传输数据。
4,服务器 继续 阻塞 在 recvfrom()
等待数据。
5,服务器 再次从 recvfrom()
拿到 UDP 包,检测UDP 包里面有没有 SYN ,如果有就是开一个新的链接,没有SYN就在 map里面找客户端 IP+PORT,如果存在就继续走,如果不存在就代表之前没建立连接,没沟通好,不能传数据,直接把 RST 标记放进去 UDP 发回去。
6,这里 服务器每次 从 recvfrom()
拿到 UDP 包,都是丢给线程池处理,主线程不阻塞处理。
上面的流程,我基于 UDP 实现了一个 3次握手,跟链接状态,大家觉得里面有没用到 多路复用?我觉得 没有,由此至终都是有一个路,所有客户端数据都是通过
recvfrom()
拿到了,然后传给线程池处理。
那为什么 TCP 有多路复用跟 EPOLL,是因为 TCP 把 3次握手,链接状态等等东西,封装进内部逻辑,做了抽象,方便调用层使用。
上层协议栈,实现了3次握手跟链接状态的功能 并不会 出现多路复用,而是由于对这些东西做了封装才会出现多路复用,这个具体是什么意思呢?
我再仔细讲解一下,Linux 里面有个谚语 "一切皆文件",包括 TCP 的socket 也是一个文件描述符, tcp 的socketfd 是跟 文件fd,复用了一个内核的数据结构,由于 linux 内核打开的文件描述符是有限制了,这个封装通用设计,就会导致一些问题。
大家可以看看 上面的 UDL 协议设计,并没打开什么文件描述符,我只是申请了块内存,来存 客户端 IP+PORT 的链接状态。
因为TCP 的内部实现用了文件描述符这个数据结构,所以他 受到了 文件描述符的数量限制。这是 TCP 实现没考虑的问题。TCP 实现是linux内核实现的一部分。
TCP 里面的客户端 socketfd,实际上跟上面的 UDL 一样,只是把 客户端 IP+PORT 转成一个 socketfd,一个整数,方便调用层使用。
讲了这么多,还没开始讲,TCP 为什么就有多路,而且要复用,而我们上面自己基于UDP实现的类似协议,却没有多路,也不需要复用。
其实这个问题我自己也不是很清楚,估计是因为 TCP 里面的 网络fd 跟 操作系统的文件fd 是共用的某个数据结构,所以一个TCP网络fd,跟文件fd是一样的,写一个文件就是一路,写多个文件就是多路。系统的文件描述符可以监听变化,TCP 服务器会生成很多的 client fd,每个线程阻塞监听 client fd 效率太低,所以出了 epoll 统一监听 所有网络 fd。
所以说,估计是因为 TCP 的内部实现跟操作系统的文件实现耦合太严重了,导致他出现多路,需要epoll来解决。
TCP 应用早期有 C10k 问题,但是运营商的 NAT 硬件防火墙,要处理的 TCP 链接,肯定超过10k,他是怎么解决的?修改 linux 内核,或者不用linux内核,把硬件性能压榨到极致。TCP 标准只是定义了 TCP 的行为,没有强制一定要像 linux 内核那样跟文件系统通用数据结构。可以自行实现 TCP 的行为,符合标准就行。
这里再埋个坑,UDT 里面自己实现了 epoll,我有空再看看UDT他的 epoll 实现。再完善这篇文章。
相关阅读:
2,听别人讲一千遍 epoll 的优势,不如自己看一遍 epoll ,select,poll 里面的代码实现。
由于笔者的水平有限, 加之编写的同时还要参与开发工作,文中难免会出现一些错误或者不准确的地方,恳请读者批评指正。如果读者有任何宝贵意见,可以加我微信 Loken1。QQ:2338195090。