前言
前段时间买本书研究了 TCP/IP 通信,弄清楚了计算机之间是怎么通信的。网络通信的的基础就是 TCP/IP 协议簇,也被称为 TCP/IP 协议栈 ,也被简称为 TCP/IP 协议。 TCP/IP 协议 并不是只有 TCP 和 IP 协议,只是这俩用的比较多,就用这两个起的名字。
我们目前使用的 HTTP , FTP , SMTP , DNS , HTTPS , SSH , MQTT , RPC 等都是以 TCP/IP协议 为基础。下图针对的是 传输层为 TCP 。
Linux 内核 为我们屏蔽了 TCP/IP 通信模型的复杂性,并且 Linux 中一切皆文件,因此为我们抽象了 Socket 文件,实际我们编码的时候,主要是通过一些系统调用和 Socket 打交道。
在 Java 中,网络通信这块 netty 提供了很大的便利,但是你了解了这些原理之后,netty 你也了解的差不多了。
内核参数说明
/proc/sys/net/* 说明
TCP/IP 内核参数说明
文件系统部分 /proc/sys/fs/* 说明
https:
修改内核参数,有两种改法,比如修改 tcp_syn_retries = 5
- 临时修改
#
- 永久修改
#
本文内容
- BIO 通信模型(画图说明)及 java 代码实现
- NIO 通信模型及 java 代码实现
- 多路复用通信模型(画图说明),主要是 epoll,会详细讲解
通信模型是按照 BIO -> NIO -> 多路复用 慢慢演变过来的,因为互联网的发展,并发要求比较高。
本文内容环境:
- jdk .18
- Linux version 3.10.0-693.5.2.el7.x86_64
BIO 通信
BIO 通信模型 中,服务端 ServerSocket.accpet 会阻塞等待新的客户端经过 TCP 三次握手 建立连接,当客户端 Socket 建立了链接,就可以通过 ServerSocket.accpet 得到这个 Socket ,然后对这个 Socket 进行读写数据。
Socket 读写数据时,会阻塞当前线程直到操作完成,因此我们需要为每个客户端分配一个线程,然后在线程中死循环从 Socket 读取数据(客户端发来的数据)。还需要分配一个线程池对 Socket 进行写数据 (发送数据到客户端)。
应用程序调用系统调用 read 将数据从 内核态 到 用户态 ,这个过程在 BIO 中是阻塞的。而且数据你不知道什么时候过来,只能在一个线程中死循环查看数据是否可读。
try
服务端主动往客户端写数据,应用程序调用 write 也是阻塞的。 我们可以通过线程池来做。为每个客户端会分配一个 id 属性维持会话,用 ConcurrentHashMap<Integer, SocketBioClient> 保持,要想 1 号客户端写数据,直接从这个 Map 拿出客户端,然后往里面写入数据。
public
BIO 通信 在并发比较大的时候,就显得力不从心了。比如有五万链接建立,就需要建立五万个线程来进行维护通信。在 java 中线程占用的内存假设为 512KB,内存占用 24GB(50000*0.5/1024GB),还有 CPU 需要调度五万个线程来读取客户端数据和应答,CPU 绝大数的资源都会浪费在线程切换上去了,并且通信的实时性更不能保证。
全连接队列和半链接队列
1、服务端需要绑定一个 serverIp 和 serverPort ; java 中 api 为 ServerSocket.bind
2、然后在这个 serverIp 和 serverPort 上监听客户端的链接的到来
3、客户单绑定一个 clientIp 和 clientPort,然后调用 Socket.conect(serverIp,serverPort),经过内核建立 Tcp 链接。
4、然后在服务端死循环调用 ServerSocket.accept 拿到建立连接 Socket
5、Socket.read 读取客户端发来的数据,Socket.wirte 写数据到客户端
serverIp 和 serverPort 是确定的,只要 clientIp 和 clientPort 只要有一个不同就可以看做是不同的客户端。
clientIp clientPort serverIp serverPort 在通信中也叫四元组,这四个确定才能建立 TCP/IP 链接。
比如我们的浏览器加载页面的时候,实际是随机创建了一个合法 本地 port ,加上已知的 clientIp 去请求 serverIp 和 serverPort 获取数据。
客户端链接服务端的 TCP 三次握手过程:
1、客户端 发送一个 SYN 包给服务端,在 客户端 运行 netstat -natp ,可以查看到处于 SYN-SENT 状态
2、服务端 接受到 客户端 SYN 包,将连接放入半链接队列,然后发送 客户端 一个 SYN+ACK 包,状态处于 SYN_REVD
3、客户端 收到来服务端的 SYN+ACK 包,回复一个 ACK,状态处于 ESTABLISHED (服务端全连接队列满的时候,客户端链接也是这个状态,当你发送数据的时候,服务端会回复一个 RST 包重置链接)
4、服务端 收到来自客户端的 ACK,链接状态变为 ESTABLISHED (只有服务端看这个状态状态的链接才是真正 TCP 链接过程走完的),并将连接放入到全连接队列
队列是一个有界队列,当全连接队列和半链接队列溢时,会有配置的内核参数决定采用对应的策略处理。
TCP 抓包
#
全连接队列溢出
我在写代码验证及抓包的时候发现,设置的全队列长度为 10,但是可以建立 11 个链接,12 个链接建立的时候就发生了全连接溢出。
cat
当 tcp_abort_on_overflow 为 0 时(默认),表示如果第三次握手(客户端发送了 ACK)的时候,全连接队列满了,服务端会发送给客户端一个包让其重试发送 ACK。sysctl -a | grep tcp_synack_retries 查看服务端配置第三次握手重试的次数,默认为 5 次。
TCP 三次握手中的第三次客户端发送 ACK 给服务端,全连接队列满了,会丢弃第三次的 ACK 包,所以后续的过程中,是客户端再次发送 ACK 的包给服务端,服务端一直丢弃,所以,客户端一直发送 ACK。
当 tcp_abort_on_overflow 为 1 时,表示如果第三次握手(客户端发送了 ACK)的时候,全连接队列满了,服务端会回复一个 RST 包,关闭连接过程
半链接队列溢出
半链接队列的长度计算公式,来源于 从一次 Connection Reset 说起,TCP 半连接队列与全连接队列
- backlog,listen 时传入的参数,我传入的 10
- somaxconn ,我的是 128
- tcp_max_syn_backlog,我的为 128
somaxconn 和 tcp_max_syn_backlog 参数含义
#
syn flood 攻击,模拟半链接溢出
#
我分别将 backlog 设置为 7,123,511 测试的公式正确
nr_table_entries
SYN FLOOD 的防御
客户端发送大量的 SYN 包,然后就不走后面的握手过程,导致服务端半链接队列满了,无法接受正常用户的握手链接。
#
内核参数 tcp_syncookies 设置可以帮我们做一些防御 SYN FLOOD 攻击,当设置为 0 的时候,半链接队列满了,服务端会丢弃客户端的 SYN 包,客户端链接的时候,没有收到 SYN+ACK 会重试发送 SYN 包,超过了重试次数,建立连接失败。
linux 中是内核参数 net.ipv4.tcp_syn_retries = 6 ,限制 SYN 重试次数,当前半链接队列已经满了,新的正常链接建立的时候,重试发送的 SYN 次数。
当设置 tcp_syncookies=0 时,是不能抵御 SYN FLOOD 攻击的,新的正常用户建立不了链接。
当设置 tcp_syncookies=1 时,新的正常链接(走三次握手)还是可以建立 TCP 连接的,前提是 全连接队列没有满,全连接队列满了,走全连接队列的逻辑。
#
全连接队列没有满,服务端会回复一个带 syncookie 的 SYN+ACK 包给客户端,就是给这个包加一个会话标识,客户端收到这个 SYN+ACK 包必须将 syncookie 携带发送 ACK 才能建立三次握手的链接。
全连接队列满的话会从上面全连接队列。
Socket Bio 通信 GitHub 地址
NIO 通信
从 BIO 演变到 NIO ,只是支持了同步非阻塞。不要小看非阻塞这个特性,他可以将我们的线程模型降低为一个(在不考虑读写客户端实时性的情况下),BIO 不管你怎么修改,始终都要一个客户端对应一个读线程。NIO 在不考虑性能的情况下,理论可以一个线程管理 n 个客户端。
ServerSocketChannel.accept 可以不阻塞等待客户端建立连接;
while
SocketChannel.read 可以不阻塞等待数据从内核态到用户态,内核态中没有数据,直接返回。
ByteBuffer
在 NIO 模型下,一个线程就可以管理所有的读写了(不考虑响应客户端的实时性 )。
package
NIO 代码 GitHub 地址
NIO 模型已经不错了,减少了线程和内存占用。但是它有一个弊端就是客户端有没有数据还是需要调用系统调用 read 来看看是否有数据到达。
当比如有五万个链接的时候,我们需要调用系统调用五万次 int read = client.read(byteBuffer),换而言之用户态到内核态需要切换五万次,这也是不小的计算机资源消耗。
IO 模型 继续演变到目前常用比较广泛的 多路复用,它解决了这个系统调用多次的问题,将五万次的系统调用减少到一次或者多次。
IO 多路复用
NIO 存在的弊端:不管你客户端有没有数据传过来,我都要调用系统调用看看有没有数据到来。
客户端建立连接之后,内核会为这个客户端分配一个 fd(文件描述符)。
IO 多路复用 指的是内核监控客户端(fd)有没有数据到来,当我们想要知道哪些客户端数据到来了,只需要调用多路复用器 select , poll , epoll 提供的系统调用即可,将想要知道的客户端(fd)传进去,内核就会返回哪些客户端(fd)数据准备好了。我们从原来的五万次系统调用,降低到一次,大大降低了系统开销。epoll 是这三个多路复用器中效率最高的一个。
1、select 一次调用传入的 fd 是有数量限制的(一次只能传入 1024 个,不同的内核参数可能会不同),五万链接会调用 30 次左右系统调用,但是内核还是会遍历这五万个链接,检查是否有数据可读。然后调用对应的系统调用,获得有数据到达的客户端 (fd),然后操作 fd 将数据从 内核态 copy 到 用户态 去做业务处理。
2、poll 和 select 差不多,只是系统调用时传入的 fd 没有限制。poll 和 select 只是减少了系统调用,实际内核也是遍历每个链接检查是否可读,所以效率和连接总数成线性关系,建立连接的客户端越多效率越低。
3、epoll 不是内核轮训每个 fd 检验是否可读。当客户端数据到达,内核将网卡中将数据读到到自己的内存空间,内核会将有数据到达的连接放入到一个队列中去,用户态的程序只需要调用 epoll 提供的系统调用,从这个队里中拿到链接对应的 fd 即可,所以效率和活跃连接数有关,和连接总数没有关系(百万链接中可能只有 20% 是活跃链接)。
epoll 相关的系统调用
epoll 内部维护了一个红黑树和队列,红黑树记录当前多路复用器需要监测哪些链接的那些操作(读写等),队列中就是哪些操作就绪的链接。
epoll_create
// 返回文件描述符,这个文件描述符对应 epoll 实例,fd 在后续 epoll 相关的系统调用中有用
epoll_create 创造一个多路复用器实例 epoll,返回一个 epfd,这个 epfd 指向了epoll的实例。epfd 实际就是一个文件描述符。
epoll_ctl
int
epoll_ctl 将客户端或者服务端对应的 socket fd 注册 epoll 上,op 就是指定当前系统调用的类型,是将 fd 注册到 epoll ,还是从 epoll 删除 fd,还是修改在 epoll 上 event 。event 指的是 io 操作(读、写等)。
epoll_ctl 设置 epoll 的实例监听哪些客户端或者服务端,并且指定监听它们的那些 io 操作。
epoll_wait
#
获取当前多路复用器(epfd)上有多少个客户端 io 操作就绪(注册 epoll 中时指定的操作)。epoll_wait 当没有指定 timeout 时,会一直阻塞等待至少有一个客户端 io 操作就绪。timeout 大于 0 会在超时时直接返回 0。
epoll_event 是接受这个系统调用中准备好的事件,事件数据结构中可以拿到对应的客户端 fd。
epoll_wait 是阻塞调用,返回的话:
- 有 io 操作就绪
- 指定的超时时间到了
- 调用被打断就会返回
epoll 触发方式
epoll 监控多个文件描述符的 io 事件,什么样的情况 epoll 认为是可以读写呢,这是就事件的触发方式。epoll 支持两重触发方式,边缘触发(edge trigger,ET)和水平触发 (level trigger,LT)。
每个 fd 缓冲区,fd 缓冲区中又可以分为读缓冲区和写缓冲区。每个客户端链接对应一个 fd。
客户端数据来了,网卡会将客户端来的数据从网卡的内存中写入到链接对应内核中的 fd 读缓冲区。应用程序调用 epoll_wait 知道那个链接有数据到达了,再将这个数据从内核态读到用户态,然后做数据处理。
往客户端写数据。应用程序调用 socket (对应一个 fd) api,将数据从用户态写入到内核态中的 fd 写缓冲区中去,然后内核会将数据写入到网卡中去,网卡在适当的时机再发给客户端。
如果 fd 的写缓冲区满了,当调用 write 的时候就会阻塞等待写缓冲区腾出空间来。
TCP 链接数据发送的时候,会有一个滑动窗口控制数据的发送。当发送的快,接受的慢,当超过了这个流量控制,发送的数据包,没有收到客户端发来的 ACK ,会继续重试发送数据包。
下图是在流控之内正常发送,服务端发包,客户端接收到,恢复一个 ACK。
这个是流控之外没有发送成功,会等待接着发送的。
这个也和 fd 的读写缓冲区有关系,客户端的度读缓冲区满了,服务端再怎么发,也不会成功的。
服务端写数据到客户端,会从
1、水平触发时机
- 对于读操作,只要读缓冲内容不为空,LT模式返回读就绪。
- 对于写操作,只要写缓冲区不满,LT模式会返回写就绪。
2、边缘触发时机
读操作
- 当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。
- 当有新数据到达时,即缓冲区中的待读数据变多的时候。
写操作
- 当缓冲区由不可写变为可写时。
- 当有旧数据被发送走,即缓冲区中的内容变少的时候。
边缘触发相当于只有增量的时候才会触发。
Java 多路复用
Java 中对多路复用器的抽象是 Selector 。根据不同的平台通过 SPI获得不同的 SelectorProvider。
// 根据 SPI 获取多路复用器,linux 是 epoll,mac 下是 KQueue
一个简单 Demo
/**