signed

QiShunwang

“诚信为本、客户至上”

从BIO到Netty(1)- Linux网络模型简介

2020/12/27 21:57:36   来源:

从BIO到Netty(1)- Linux网络模型简介

    • 前言
    • Linux网络模型简介
    • 阻塞I/O
    • 非阻塞I/O
    • I/O复用模型
    • 信号驱动I/O
    • 异步I/O
    • 总结
    • 参考资料

前言

其实我一直以来都有做笔记的习惯,但是却很少写博客。一方面我之前觉得,如果一篇博客文章仅仅只是从其他书籍或者资料中摘录一通然后拼接而成,而不添加一些自己的内容的话,好像意义不大。另一方面,如果我添加了自己的内容,但是内容不完善甚至是错误的理解怎么办,那不是坑别人了么?而且还很容易被人喷。因此我对写博文这事情一直畏畏缩缩的,当然懒也是重要原因。

但是最近和一些码圈的朋友交流之后,发现自己这种思维其实可能不太正确。第一,我可能太看小别人了,其实多数人对于这些博文或者相关资料都有自己的判断,什么人写的博客是比较权威的,什么人写的博客是可供参考的,其实大家往往都心中有数。第二、你的理解写出来分享了,才会有更多人跟你交流,你才能确定你的理解是否正确,是否还有改进的地方。第三、就是仅仅只是摘录了别的资料内容,只要你注明出处,别侵犯别人版权,问题也不大,毕竟也算是让好的资源更快的传播嘛。第四、现在不是经常提起一句话么,教别人是最好的学习方式。

因此最近我决定把自己之前在有道笔记积累的一下记录,搬到博客上来和大家一起交流分享,而且对于博客中一些自己的见解,我会尽量用文字提示这是个人见解,以免大家阅读的时候“踩坑”,也方便大家有更加明确的目标去“拍砖”。对于一些权威资料摘录的内容我可能会尽量保存原样,同时在文章后面注明参考资料的出处。

那接下来就开始记录我BIO->Netty的学习之路:第一篇《Linux网络模型简介》

Linux网络模型简介

对于I/O模型的介绍,相信接下来的一些文字或者图片的介绍,大家也是眼睛都看到张茧了!但是我毕竟也是一个学习过程中的小菜鸟,这方面也不能提出多少与众不同的见解,所以多数也是从各种资料里面摘取出来的(后面有参考连接)。虽然尽信书不如无书,但是这些相对出名的书籍总的来说还是比较有参考价值的。

Linux的内核将所有的外部设备都看作一个文件来操作,对于一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor(fd,文件描述符)。而对于一个socket的读写也会有相应的描述符,称为socketfd(socket描述符),描述符就是一个数字,它指向内核中的一个结构体(文件路径、数据区等一些属性)。

根据《UNIX网络编程》对I/O模型的分类,UNIX提供了5种I/O模型,分别是阻塞I/O模型、非阻塞I/O模型、I/O复用模型、信号驱动模型和异步I/O模型。

阻塞I/O

图(1):阻塞I/O

阻塞I/O模型:最常用的I/O模型就是阻塞I/O模型,缺省情形下,所有文件操作都是阻塞的。我们以UDP套接字接口为例:在进程空间中调用recvform,其系统调用直到数据包到达而且被复制到应用进程的缓冲区中或者发生错误才返回,在此期间一直会等待,进程在从调用recvfrom开始到它返回的整段时间内都是被阻塞的,因此被称为阻塞I/O模型。

下面这些是我的个人学习见解

对于图(1),我一开始学习的时候就曾经有几个误区(也许很蠢,但是我确实这样想过):

  • 下意识把图中的模型代入了Java TCP Socket的BIO编程模型。
  • 容易简单地把其想成不同主机端对端之间的阻塞,也就是client -> server。
  • 简单地只考虑来自网络的I/O阻塞。

也就是下面这段代码,也许是它太深入我心了,其实BIO模型里accept()确实是一种阻塞操作,但是我觉得不能单单靠这段代码来理解BIO,尤其对于网络编程不是很了解的人,很容易就直接把其和上面BIO的图直接一对一映射起来。

代码1:

serverSocket = new ServerSocket(port);
while (true) {
    Socket socket = serverSocket.accept();
    //处理接收的客户端连接
}

因为上面这些原因,让我一开始总是不能理解这几种不同I/O模型的优缺点和应用场景。

  • 首先图(1)来自《UNIX网络编程 卷1》,它其实是UDP套接字接收数据的BIO模型,不是TCP。TCP通信会比UDP来的复杂,并不是简单的发送和接收,这个大家感兴趣的话可以自己去翻看一下《UNIX网络编程 卷1》第六章。
  • recevfrom是数据报套接字接收数据的接口,既会发生在客户端也会发生在服务端,因此上面阻塞I/O的图,只是其中一端接收数据的情况,也就是应用程序和操作系统之间的交互。
  • 阻塞I/O不仅仅只有网络,比如输入流不仅仅有套接字输入,也有标准输入。

图(2):UDP客户端/服务端程序所用的套接字函数

图(2)也是来自《UNIX网络编程 卷1》,可以看到不管客户端还是服务端它们都会调用recevfrom()函数,都会在自己这端经历来自“等待数据->读取数据到内核缓冲区->复制数据到应用程序缓冲区->返回”一条龙式的阻塞。

为了更加形象说明I/O模型,这里也举一个例子,后面不同的I/O也会通过这个例子去演化。

  • 假如把我们的应用程序看作一名员工小A,我们是万恶的资本家(老板),我们希望尽量多点剥削小A劳动价值。
  • 小A平时负责的任务中有一部分是要和“操作系统”部门对接的,但是“操作系统”部门经常要和公司外面一些客户交流因此很多时候反馈很慢。
  • 一开始小A对接任务的时候,就会在“操作系统”部门门口架着凳子一直等,直到别人反馈了,再回去进行接下来的工作。
  • 这时候老板就觉得小A在执行任务的时候效率是挺高的,但是就是老闲等着。工作效率是高,但是总时长太少了,总产出明显不符合要求。
  • BIO就好比那个工作效率很高但是真正工作时长很短的员工。
  • 因此作为一个合格的资本家,必须要探索出能更加有效提高工作总输出的I/O模型。

对此,也许还有读者(比如像当初的我)会想到基于BIO的多线程处理,也就是类似下面这个代码。

代码2:

ServerSocket serverSocket = new ServerSocket(port);
while (true) {
    Socket socket = serverSocket.accept();
    Thread thread = new Thread(() ->{
        //todo socket
    });
    thread.start();
}

上面这种I/O模型其实和后面的I/O复用有点相似,它在不同线程里面自由地使用上面的BIO模型。但是在开始理解5种I/O模型的时候,你可以先把当你的应用程序当作是单线程的或者一个进程,这样子学习起来可能会更加容易点。

非阻塞I/O

非阻塞I/O模型

进程把一个套接字设置成非阻塞是在通知内核:当所请求的I/O操作非得把本进程投入睡眠状态才能完成,不要把本进程投入睡眠,而是返回一个错误。

上面非阻塞的图片同样是使用recvfrom()函数作为例子,前三次调用recivefrom()时没有数据可以返回,因此内核转而立即返回一个EWOULDBLOCK错误。第四次调用recvfrom时已经有一个数据报准备好了,它被复制到应用进程缓冲区,于是recvfrom成功返回。我们接着处理数据。

当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时候,我们称之为轮询(polling)。应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往会消耗大量的CPU时间,不过这种模型偶尔也会遇到,通常是专门提供某一种功能的系统中才有。

个人对非阻塞I/O的一些理解(拍砖的靶子来了)

  • 一开始我刚刚看见这个轮询的模型的时候,我在想它比BIO好在哪里?一个阻塞不能动,一个一直在轮询,不都是不能干别的么?

但是后来我细细想一下,谁让你那么蠢的一直在那儿轮询啊。你完全可以在调用了recvfrom()方法后继续干别的事情啊,等一会再回来查询结果啊。

比如一个客户端要对4台服务器发起connect操作。在BIO下connect操作是阻塞的,如果一个connect操作耗时1s,在阻塞的情况下就是4s。但是如果是非阻塞I/O,我们在发起connect操作后,可以不急着轮询结果,而是继续对剩下的三个服务器发起connect操作。最后再分别对每个操作结果进行轮询,这样总的耗时可能会接近1s。

  • 下面是我们的老板如何想尽办法剥削员工劳动价值的时候了
  • 之前说到,我们作为老板很不满意小A经常在闲等的状态。因此老板找小A让他别老在别人门口干等着,先回去干点别的。你隔一段时间再来问就是了。
  • 因此他就回去先做别的,甚至也把其他一些需要和“操作系统”部门对接的任务也先做了,然后每隔一段时间再过去“操作系统”部门询问。这样小A的工作效率就变低了,小A很烦,因为他要跑来跑去的,消耗了不少的体力,每个任务完成的时间也比之前长了。
  • 但是老板变高兴了,因为小A总的劳动价值输出变多了。如果用“有用工作X时长”来计算工作输出的话,10 X 1比9 X
    2可是要小上不少。老板目的达到了,但是他远远没有满足,他觉得还有剥削的空间。

I/O复用模型

有了I/O复用(I/O multiplexing),我们就可以调用select或者poll。这样阻塞就不是发生在真正的系统调用之上,而是在select或者poll调用上。

I/O复用模型

我们阻塞于select调用,等待数据报套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom()把所读的数据报复制到应用进程缓冲区。

比较BIO和I/O复用并不显得有什么优势,事实上由于使用了select和recvfrom两个系统调用,I/O复用还稍有劣势。不过使用select的优势在于我们可以等待多个描述符就绪。

与I/O复用密切相关的另一种I/O模型是在多线程中使用BIO(也就是代码2),这种模型与上述模型很相似,但是它没有使用select阻塞在多个文件描述符上,而是使用多个线程(每个文件描述符一个线程),这样每个线程都可以自由调用诸如recvfrom之类的阻塞式I/O系统调用。

个人理解:

  • 等待多个描述符就绪的优势是什么?为什么不直接使用多线程BIO模型?
    • 等待多个就绪的描述符可以避免未就绪的I/O阻塞其他I/O事件。比如我们调用了recvfrom()方法,但是系统缓冲区还没有数据,也就是相应的描述符还没就绪,它就会一直睡眠不干活,直到数据就绪了。如果是一个I/O非常高效的环境,I/O复用也许还不如BIO高效,因为那样I/O多数都是就绪的,但是如果一个系统里面存在很多idle描述符,那selector就会有很大优势。
    • 等待多个就绪描述符可以让我们有选择地处理这些I/O事件,比如我们优先处理那些描述符。比如netty在处理Channel读写的时候,就默认控制每个Channel的连续读写不能超过16次,这样就避免了某一类型的I/O事件长期占用了线程资源。
    • 多线程BIO模型,把BIO从进程细分到了线程,但是未就绪I/O会阻塞并且导致整个线程不干活这一特点并没有改变,如果我们调用了read()方法,而read()数据还没准备好的话,那线程就会被阻塞,那线程资源就会被浪费。我们知道在系统中创建线程是需要额外的资源的,并不能无限的创建,就算是使用线程池,休眠中的线程资源也是一种浪费。
    • I/O复用能提高线程的资源的利用率(尤其配合线程池的使用),简单来说它只执行就绪I/O,代价就是需要一个额外的selector去观察哪些I/O是就绪的。对于一个存在很多idle事件的系统来说,这点代价是值得的,因此它避免了很多无用的等待,让每条线程的资源都得到更加充分的利用。而且通过selector获取到多个就绪描述符的时候,我们也可以根据实际情况判断是否需要通过多线程的方法同时处理多个描述符。
  • 下面我们作为老板又要去剥削员工劳动价值了
  • 之前老板使用非阻塞I/O的模式,让小A的劳动总输出变高了,但是呢作为贪得无厌的资本家怎么能满足呢?不过他也不能去督促别人再跑快点吧,那样小A有意见,而且跑快几分钟效果也不大。
  • 因此老板决定再花点小钱给小A分配了个小秘书(Selector),让她去负责联系“操作系统”部门,如果那边有反馈了,她就记录下来.这样小A只要间隔一定时间就去问一下小秘书哪些任务可以对接了,如果有小A就继续去执行任务对接.这样小A就不用跑来跑去,专心执行自己的任务,劳动效率和时长都上去,总的价值输出大大提高.
  • 老板很满意,小投资大回报.

信号驱动I/O

我们可以说用信号,让内核在描述符就绪时发生SIGTO信号通知我们,我们称这种模型为信号驱动式I/O(signal-driven I/O).
信号驱动I/O

我首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用就立即返回,我们的进程继续工作,也就是说它没有被阻塞。

当数据报准备好被读取的时候,内核就为该进程产生一个SIGIO信号。我们随后既可以有两者处理:

  • 在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已经准备好了。
  • 也立即通知主循环,让它读取数据。

无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知。

信号驱动I/O我接触的比较少,也没什么自己的理解,如果用上面的例子举例的话大概应该是如此:

  • 老板觉得小A贡献了蛮多的劳动价值了,给了他一点特权,让他可以命令“操作系统的I/O小组”。
  • 这时候小A不再需要不断询问小秘书了,而是让小秘书亲自去传达自己的意图,小A的命令有两种:
    • 一种是完成后让小秘书汇报给他,他亲自来负责对接任务并且完成。
    • 一种是小秘书汇报给他后,他接着让别人来替他来完成,再让小秘书汇报给他。
  • “操作系统的I/O小组”的同事完成自己的任务,准备对接的时候,就根据小秘书传达给自己的具体命令来执行。

异步I/O

异步I/O模型

异步I/O(asynchronous)由POSLX规范定义。演变成当前POSLX规范的各种早期标准所定的实时函数中存在的差异已经取得了一致。一般的说,这些函数的工作机制是:告知内核启动某个动作,并让内核在整个操作(包括将数据从内核缓冲区复制到用户进程缓冲区)完成后通知我们。这种模型与前面的信号I/O模型主要区别就是:信号驱动I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O操作是由内核通知我们I/O操作何时完成。

同样对于异步I/O我个人也是很少接触,如果用例子来比划的话大概如此:

  • 小A后来权力更大了,他不再需要负责对接的任务,而是把任务封装好直接派发给“操作系统的I/O小组”,让他们把对接的后序任务也直接干了,然后再向他反馈即可。

总结

到这里5种I/O模型就都说完,对一I/O模型的图片和概念的介绍都是摘自比较出名的书籍,而个人见解部分因为水平有限,很可能有不合理或者不完善的地方,是重点拍砖对象,欢迎大家斧正。同时上面所举的小例子,大家从行为模式上类比一下就行了,不可能在各个层次的细节上和这些I/O模型完全一一对应的。后面会继续带来Socket编程方面的博文。

参考资料

《UNIX网络编程 卷1》(第3版)

《Java TCP/IP Socket 编程》(原书第2版)

《Netty权威指南》(第二版)