写点什么

网络 IO 模型 BIO->Select->Epoll 多路复用的进化史

  • 2023-04-08
    湖南
  • 本文字数:6859 字

    阅读完需:约 23 分钟

tcpdump 抓取网络请求包

  • 监听从 eth0 网卡发出去的,请求 80 端口的网络包。

-i 是 iterface 接口,eth0 是网卡;抓 80 端口,抓从 eth0 网卡出去的访问 80 端口的网络包。


  • 通过 curl 访问百度首页

访问百度,http 协议 80 端口。

就可以监听到完整的网络请求过程。

其中包含 TCP 三次握手、四次分手的过程,但没有体现出 http 协议的概念。


  • 与百度服务器建立 TCP 连接

和百度 80 端口,建立了 TCP 连接。


  • 用文件描述符代表这个连接

用一个数字 9 代表这个连接,现在只是和百度握手,握手了之后,再把基于 http 协议的数据包发给百度, 百度收到数据包后,进行解析,才知道要请求的是哪个页面,把该页面返回,这个环节就用到 http 协议了。

和百度建立 TCP 连接后,基于 http 协议,发送一段文本,规定了客户端的请求方式(比如 GET、POST)、URL 资源(访问是哪个页面),http/1.0 指定 http 协议的版本。


  • 从文件描述符读取服务端响应数据


重定向到 9,9 是一个 socket 文件描述符(代表着与百度建立的连接),把这个信息发给百度,百度通过这个 socket 收到之后,把页面信息通过 socket 连接返回。

客户端从文件描述符 9 中可以拿到返回的主页信息,这样就在没有使用浏览器的情况下获取到了百度首页。


  • 三次握手的过程


客户端要发出一个数据包,先三次握手建立连接,由传输控制层创建握手的第一个数据包 sync。

服务端回复一个 sync 的确认包,客户端再回复一个 ack。


经过三次数据包的往复就建立连接了,并且双方在各自的内存中开辟一些资源,这些资源是为了存放对方发送过来的数据使用。


只有三次握手之后,才能让双方建立资源并为对方提供服务,所以面向连接是经过三次握手开辟的为对方服务的资源而并不是一个真正的物理连接(比如网线)。

Dos 攻击

比如几万台或几十万台客户端肉机,对某一个网站发起 sync 请求,这个网站会给这些客户端回送 ack,肉机修改了 TCP 协议,不给服务器回复 ack,服务器会为这些连接开辟很多的资源,一直等着不同的肉机给它回送 ack 状态。服务器资源会被瞬间填满,真正想去连接的人却挤不进去了。


一般用负载均衡技术+黑名单技术解决。


而识别出来这些肉机并加入黑名单期间也会造成服务器的不稳定。


应用层中,无论是命令还是浏览器发 http 请求头之前,必须先调用系统内核的传输控制层,传输控制层先产生第一个数据包:握手包,然后给对方发送过去,回来 3 次之后,才能告诉应用层:你把 http 请求头的数据给我 ,我给你封装数据包,再帮你完成数据传输的过程。


在数据传输的过程当中,客户端把请求头发送出去了,对方再返回一个确认,客户端才能知道它发送成功了。


服务端收到数据包,解析完数据包之后,再给服务端的应用层(比如 tomcat),应用层把请求的主页的数据再返回给客户端,客户端再返回 ack 确认。

查看网络状态

-n 是显示 ip 和端口号;-a 是所有;-t 是 tcp;-p 是进程的 id 和名称。


状态列 state 中的 LISTEN 是监听状态,ESTABLISHED 是联通状态。


只有服务端才有 LISTEN,tomcat 启动来之后要绑定 8080 端口,这个时候 8080 就会有一个 LISTEN 监听状态。


客户端的数据包一进入这台主机的网卡,这时候谁先访问?有没有可能写数据的时候被覆盖?


就会出现混乱的情况,所以必然会有一个 kernel 来接管所有的 application 排队使用硬件、权限控制等一系列过程。

传输控制层、网络层、数据链路层,这些都在 kernel 内核里面实现的。


http 协议是在应用层来实现的。

计算机加载 kernel 的过程

主机中包含内存、CPU、磁盘、主板上的 blos,电源按钮等这些都是硬件,当接通电源的时候,blos 就会把它的一段代码放入了内存当中,即内存中出现的第一个程序是 blos,它里面有一些最基本的引导程序,假设跳过其他的引导,直接从磁盘中引导,磁盘中的第一个起始位置有一个分区表 MBR(记录了磁盘分了几个区),blos 会从磁盘的 MBR 中加载一段数据,从中找到 C 盘可引导分区,这个引导分区是被格式化的,文件格式有可能是 FATFAT32NTFS 或其他的文件系统,这个分区前面埋了一个线性地址,比如 linux 的 grud 程序,grud 程序会被 blos 加载入内存,grud 程序中有一个驱动(代码),这个驱动可以识别文件格式,就可以读取文件系统,在文件系统中就会读取到第一个文件叫 kernel(操作系统内核程序),kernel 会通过引导程序被引导进入内存,这个时候 kernel 就占领了内存,给 cpu 发 reset 指令,让 cpu 从 kernel 这个空间的第一个位置开始加载指令,此时 kernel 就接管了操作系统,然后开始完成操作系统的初始化,比如启动 ssh、bash、网络服务程序、tomcat 等都是由 kernel 把程序一个一个启动起来。

从文件系统加载 kernel 到内存的时候,线性地址空间会从实模式进入保护模式。


因为 kernel 是一个能够操纵所有硬件的程序,如果有人随意的修改它里面的代码,就变成病毒了,就会破坏计算机里面的内容,所以需要将这段地址空间保护起来,剩余的地址空间叫 user space(用户空间)。


内核启动一个程序的话,从磁盘中加载一个程序,放入内存的用户空间中,保护模式是不允许用户空间区域的程序直接访问修改 kernel 区域的地址。


程序想访问硬件,又不能直接访问,就通过 syscall 系统调用或者叫 80 中断,通过中断的方式来间接的从用户态的用户空间切换到内核态的内核空间。


不同的操作系统有各自的内核,不同内核提供的系统调用也不一样。

socket

# 安装帮助文档 有8类文档 系统调用systemcall是2类文档yum install  man man-pages
man 2 socket
复制代码


如果成功,则返回一个文件描述符且类型是 int 数值类型。


在 java 中 object 对象代表一个资源;


linux 中用一个数字来代表来代表一个资源。


面向对象是基于对象的很多方法;


面向过程也有很多的方法,比如 socket 有 accept 方法,accept 是得到客户端的一个连接。

man 2 bind
复制代码


先调用 socket 得到一个文件描述符,再给这个文件描述符绑定系统端口号,再监听该端口号。

tomcat 是用 java 代码写的,交由 JVM 管理,JVM 在内核调用了 socket 的 bind、listen、accept 这 3 个系统调用,才得到监听 8080 的文件描述符,比如文件描述符 3,3 就代表着监听的 8080。


在 tomcat 里面要开始调用内核的系统调用 accept,死循环一直调用 accept,参数就是文件描述符 3,看有没有客户端接入,在没有客户端接入的情况下一直是阻塞的。

假设一个客户端 TCP 三次握手建立了连接,accept 就由阻塞变成返回了一个文件描述符来代表这个客户端,

比如用文件描述符 4 来代表这个客户端 1。


JVM 主线程是一个方法,这个主方法里有一个循环,先是通过调用 socket 的 bind、listen、accept 得到一个文件描述符 3,除了 accept 接受客户端之外,还得读写客户端中的数据,

系统调用读取这个文件描述符。

tomcat 主线程代码逻辑:先是 while 循环,然后每次循环都调用 accpet,如果有客户端连接,就返回一个文件描述符来代表这个客户端,当前循环不结束,然后读取 read 文件描述符 4,一直阻塞,直到有数据到达。


客户端与你建立连接之后,可能没有给你发送数据,这个 read 会被阻塞。


一阻塞,这个循环没有结束的话,就进入不了下一次循环,其他客户端想连接也会被阻塞。


所以 tomcat 早期在写 socket 编程的时候,会使用多线程模型来解决这个问题。

多线程模型、同步阻塞(BIO)

在这个循环里面,得到一个文件描述符,先不去 read 读,先抛出去一个线程, 把文件描述符 4 作为参数传给它,由子线程去 read 读。


主线程可以抛一个线程,然后继续第二次循环,这就是 BIO 模型,解决了单服务器多客户端连接的问题。

一台服务器一个客户端开启了 n 多个线程去访问同一台服务器,每个线程要给一个随机端口号,客户端这边的端口号数量最多 65535 个,但服务端只需要一个 8080 就可以,因为每来一个,会得到一个文件描述符,这个文件描述符代表着服务端 IP:8080-客户端 IP:3333,再来一个客户端,IP:8080-客户端 IP:3334,所以服务端不需要再开辟端口号。

BIO 弊端

问题就在于多线程,你来了 5000 个客户端,代表要开启 5000 个线程,线程是要消耗内存资源的,JVM 默认线程大小为 1MB。有大量的线程切换,CPU 消耗也会越来越多。考究一台服务器的性能就要看是在用户空间花的时间多一些还是在内核上花的时间多一些。

同步非阻塞 SOCK_NONBLOCK

为什么要抛这么多线程?


是因为阻塞,只能用多线程。

man 2 socket
复制代码

socket 有一个方法 SOCK_NONBLOCK。

设置文件状态为非阻塞,非阻塞是什么意思?


read fd4 的时候,曾经是阻塞,直到数据到达才返回。

现在调用 SOCK_NONBLOCK,有数据则返回数据,没有数据不会进入阻塞,而是返回一个错误。


那程序应该怎么设计?

先绑定 8080 端口,死循环,accept 接收,得到相应的 fd4、fd5,这个时候不需要抛出多个线程,一个线程就可以了,这个线程的工作是接收、读取数据。


这个环节就是非阻塞的,这个过程减少了线程数,减少了没有必要的系统调用。


但这种方式顶多叫非阻塞,还不能叫多路复用。


如果有 5000 个客户端建立了连接,会有 5000 个文件描述符,在一次循环的时候,你会调用 5000 次 read,read 是系统调用,肯定会有用户态和内核态切换,read 方法是在内核实现的。


随着客户端的更多,单一线程里面会有 5000 次循环,可能只有 1 个客户端的数据要来,4999 次的 read 调用是浪费的,read 调用的太多,浪费了很多次。


如果程序知道哪些文件描述符有了数据,就调有限次数的 read,就减少了每个都调用一次的浪费。

select 同步非阻塞

man 2 select
复制代码


参数 nfds 表示有多少个文件描述符,readfds 想读取的文件描述符集合,writefds 想写入的文件描述符集合。


一个程序可以通过系统调用让内核一下可以监控很多的文件描述符,等待直到一个或多个文件描述符变成可用状态就返回了。


tomcat 先是监听 8080 端口,比如得到一个 fd3,来了一个客户端,到达内核说:我想和你建立连接。


tomcat 先调用 accept 得到了一个 fd4,

然后开始调用 select,第一个传入进去的是 fd3。


客户端想建立连接的时候,还没有 fd4,通过 fd3 这个文件描述符,产生了一个连接事件:客户端 1 想建立连接。


这个时候,调用 select 叫内核等待着,内核判断数据是否会达到,比如 fd4 数据到达之后,select 就会返回一个文件描述符 fd4。


当你有 1000 客户端(文件描述符)的时候,每循环一次,只需要调用一次 select,把 1000 个文件描述符作为参数传给内核,由内核去遍历的数据状态。


曾经也需要遍历,但是会读取有没有数据到达,而 select 模式不需要读取,只需要判断数据状态即是否有数据到达。


程序的一次循环里面调用 select,内核对 1000 个文件描述符进行状态的判断并通过 tomcat 返回了这 1000 里面有几个状态是可读可写,最终由 tomcat 对 select 返回的可用的文件描述符发起 read,读取还得由 tomcat 完成。


一次 select 和 read 读取有状态文件描述符,而不是全局遍历所有的文件描述符,这样减少了系统调用。


所有读数据都得由程序自己去读,依然属于同步非阻塞。


同步阻塞到同步非阻塞,依然是同步模型,从辩别到读取都得自己干。


异步模型是 read 不是自己去做了,只是调了多路复用器给了一个回调函数,未来是由内核帮你把数据读完 ,再调用你的处理函数处理完,不需要自己再去完成 read 调用了这才叫异步。


在 linux 当中 AIO 标准一直没有统一起来,只有 window 上 IOCP 实现了真正的 AIO。

select 模型的弊端

第一个弊端,每循环一次,要调用一次 select,把文件描述符的集合从用户态传到内核态,每次循环都要传递这个参数。


第二个弊端是内核需要完成 1000 个描述符状态的判断,循环遍历的过程依然是费时的,O(n)的时间复杂度,这是主动遍历的过程,比较慢。


先是传的东西比较多,每次都传过来之后,还得挨个去遍历。


如果想让它优美点的话,就需用到中断了。


计算机只有一个 CPU,插了好多设备,网卡、键盘、鼠标等任何的输入输出设备。


CPU 可能正在处理网卡数据,鼠标会产生一个中断,中断会产生切换,CPU 会切换到让鼠标挪动。


别人中断了,切换到别人,让别人干一点。


在单位时间内 CPU 的执行是碎片化的、分时的,所有的 IO 设备都是轮询着去处理,谁有事就处理谁。


网卡有数据到达了,也会产生中断,把自己忙的事情放一放,看看网卡里面的数据是不是要拷贝到内核空间等等之类的。


还有一个 DMA:内存是一个线性地址空间,是一个黑盒子,划分不同的区域干不同的事情。


每个不同的设备访问内存的不同地方,给空间划分权限就可以满足设备不通过 CPU 直接使用内存的某个区域。

比如这个区域就给网卡用,网卡就可以直接访问。


网卡收到数据了,通过 DMA 驱动,直接拷贝到内存。


这时候给内核产生一个中断,内核其实就已知道这个 DMA 区域当中有网卡传过来的数据了。


如果别人对这个网卡在内核有注册过中断回调事件,即别人关注过这个区域,待数据到达的时候,中断事件就会调用回调函数 callback。


由中断产生了一个函数的调用,产生了事件调用。


如果内核有 1000 个文件描述符,不去遍历了,而是由网卡的中断来知道是否有数据到达。


数据到达了,就被动的知道了哪个文件描述符、哪块的数据到达了,就减少了对 1000 个文件描述符没必要的全局的遍历,从而变成了一个被动事件。

Epoll

man epoll
# epoll属于7类杂项
复制代码


epoll_create、epoll_ctl、epoll_wait 这三个是二类系统调用,

man epoll_create
复制代码


如果成功,则返回 epoll 的文件描述符。

参数是刚刚创建的 epoll 文件描述符并且把操作符和相应的客户端文件描述符传进去,比如把代表一个客户端连接的文件描述符 8 传入进来,并准备一个 epoll events,等待文件描述符 8 可读写的时候,调用回调事件。

man epoll_wait
复制代码


参数中也要传刚才的 epoll 文件描述符,这个调用其实是告诉内核说把我曾经给过你的文件描述符里面有状态的可读的那一个返回给我或者将可以读写了的文件描述符返回。

tomcat 先去准备 8080 端口,得到一个文件描述符 fd3,tomcat 下一个指令是调用内核的 epoll create,返回一个 epoll 的文件描述符,比如 epoll 的 fd5,然后 tomcat 调用内核 epoll_ctl,传入 2 个参数,一个是 epoll 的 fd5,第二个参数是 fd3。


内核中准备了一个区域,里面放入了 fd3 和 fd5。


假设有一个客户端想通过 TCP 和 tomcat 8080 建立一个连接,fd3 上一定会产生一个客户端建立连接的事件,这个事件一定会被内核的事件机制(中断机制)发现,所以内核会把这个区域的 fd3 拿出来,因为它有数据到达了,再放入另外一个区域里,另外一个区域放入 fd3,只要 tomcat 这个程序未来调用过了 epoll wait,就会把这个区域的 fd3 给 tomcat,tomcat 就会判断 fd3 的事件是有人建立了连接,所以它会调用 accept 接收并得到一个 fd4。

这个时候又会调用 epoll_ctl,再把 fd4 放入进去。


fd3 注册是 accept 事件。


fd4 是客户端连接的文件描述符,fd4 注册是 read 读取事件。


tomcat 只需要 while 循环 epoll wait。


如果 fd4 有数据达到了同时有一个客户端 2 也想建立连接,这俩都到达内核了,即同时产生了 2 个中断(2 个事件)。

那么这个区域会放 fd3 和 fd4(红色部分),表示 fd4 有新的可读数据到达了。


一个新的客户端想建立连接走的 fd3 accept 事件,这个时候 fd 3、fd 4 上都有中断或都有事件回调,

fd 3、fd 4 都会被移动到这个区域,如果你的应用程序调用 epoll wait 的话,就会把 fd 3、fd 4 都取回来,取回来之后,会在 fd 3 上为新的客户端连接分配一个 fd 5,下一个指令会从 fd 4 读取回数据进行处理,处理完之后,再进入下一个循环,把 fd 5 放入这个区域,因为 fd 5 也是一个客户端,也关注可读这件事情,放完之后,再调 epoll_wait,这个 epoll_wait 等它们三个谁有事件,如果 fd4、fd5 数据到达的话,那么另外一个区域就要放 fd4、fd5 了,epoll_wait 发现有 fd4 和 fd5 了,就把 fd4、fd5 就带回来了,这个时候你的进程就会读 fd4、读 fd5,然后再 epoll_wait,这个是时候,再有一个客户端,经过 fd3,得到一个 fd6,这个时候要把 fd6 放入进来,再去 epoll_wait,未来谁到了,就把谁的文件描述符拿出来,这就是 epoll 的工作机制,靠的是内核的事件机制,使用 epoll 这种事件驱动的多路复用。


曾经是每次循环都会把 fd 传一次,现在内核开辟了一个区域之后,每个文件描述符一创建就会通过你的应用程序把它放入到内核里面,减少了数据的传递。


每循环调用的是 epoll_wait,曾经的是每循环调用的是 select 传入的是所有文件描述符,然后等着 select 的阻塞返回。


现在直接调用 epoll_wait ,因为内核通过中断事件,数据到达的 fd 被动的放入了另一个区域,所以应用程序通过 epoll_wait 只需要取回这个空间的 fd 就可以了,最主要的是通过中断事件机制避免了内核的全量遍历。


早期是通过 mmap 技术实现的,它也是一个系统调用,叫内存映射。


开辟一个空间,这个空间是用户程序和内核都可以访问的一个空间,即在内存中划分出来一个区域,用户态和内核态都可以访问。

man 2 mmap
复制代码


创建一个虚拟空间,内核和进程可以同时访问这个空间。


早期的时候就需要人为的使用 epoll 开辟 open/dev/epoll 得到 epoll fd,再通过 mmap 开辟一个共享空间,文件描述符在共享空间里面,这样更能减少拷贝数据的成本了,因为数据只需要放入空间一次就可以了,感觉速度会快些,但会存在数据安全问题,因为这个区域的数据,用户态和内核态都是可以访问的,多线程的情况下,会产生数据的混乱。


epoll_cratel、epoll_wait、epoll_ctl 则避免了使用 mmap,不需要用户准备的空间了。


由内核完成,只要将文件描述符传入内核的地址空间,再将到达数据的文件描述符拷贝到用户空间当中去,在多线程的情况下,尤其在 IO threads 的情况下,数据的处理过程都是线程安全的。


作者:平凡人笔记

链接:https://juejin.cn/post/7218915344130670647

来源:稀土掘金

用户头像

还未添加个人签名 2021-07-28 加入

公众号:该用户快成仙了

评论

发布
暂无评论
网络IO模型BIO->Select->Epoll多路复用的进化史_做梦都在改BUG_InfoQ写作社区