当前位置: 首页 > 科技观察

I-O多路复用,从来没遇到过这么清晰的文章

时间:2023-03-13 05:51:08 科技观察

本文转载自微信公众号《二玛读书》,作者涛哥。转载本文请联系尔玛读书公众号。很多对技术有追求的读者朋友,都希望在技术达到一定阶段后,再提高一下。有的读者朋友可能会去研究一些中间件的技术架构和实现原理。比如Nginx为什么可以同时支持几万甚至几十万的连接?为什么单工作线程的Redis性能优于多线程的Memcached?Dubbo的底层实现是什么,为什么通信效率这么高?事实上,上面的一些问题与网络模型有关。本文从基本概念和网络编程入手,逐步讲解Linux的5大网络模型,其中也包括大家熟悉的多路复用IO模型。相信看完这篇文章,大家对网络编程和网络模型会有更清晰的认识。基本概念我们先了解几个基本概念。什么是输入输出?所谓I/O(Input/Output)操作,其实就是输入输出的数据传输行为。程序员最关心的是磁盘IO和网络IO,因为这两个IO操作与应用程序的关系最直接、最密切。DiskIO:磁盘输入输出,比如磁盘和内存之间的数据传输。NetworkIO:不同系统之间跨网络的数据传输,比如两个系统之间的远程接口调用。下图是应用中IO的具体场景:通过上图,我们可以了解IO操作的具体场景。一个请求过程可能有很多IO操作:1、向服务器请求页面时会发生网络IO2,服务间远程调用时会发生网络IO3,应用访问数据库时会发生网络IO4,数据库查询时会发生网络IO4或者数据写入会发生磁盘IO阻塞和非阻塞所谓阻塞就是一个请求不能立即返回,直到所有的逻辑都处理完才能返回响应。相反,发送请求并立即返回响应,而无需等待所有逻辑处理完毕。内核空间和用户空间在Linux中,应用程序的稳定性远不如操作系统程序。为了保证操作系统的稳定性,Linux区分了内核空间和用户空间。可以理解为内核空间运行操作系统程序和驱动程序,用户空间运行应用程序。通过这种方式,Linux将操作系统程序和应用程序隔离开来,防止应用程序影响操作系统本身的稳定性。这也是Linux系统超级稳定的主要原因。所有的系统资源操作都在内核空间进行,如磁盘文件的读写、内存的分配与回收、网络接口的调用等。因此,在一次网络IO读取过程中,数据并不是直接从网卡读取到用户空间的应用程序缓冲区,而是先从网卡复制到内核空间缓冲区,再从内核复制到用户空间空间应用缓冲区。对于网络IO写入过程,流程相反,先将数据从用户空间的applicationbuffer复制到kernelbuffer,然后通过网卡将kernelbuffer中的数据发送出去。Socket(套接字)Socket可以理解为,当两个应用程序通过网络进行通信时,一个应用程序将数据写入Socket,然后通过网卡将数据发送到另一个应用程序的Socket。所有的网络协议都是基于Socket进行通信的,无论是TCP还是UDP协议,应用层的HTTP协议也不例外。这些协议都需要基于Socket实现网络通信。五种网络IO模型也必须基于Socket实现网络通信。实际上,HTTP协议是建立在TCP协议之上的应用层协议。HTTP协议负责如何打包数据,而TCP协议负责如何传输数据。大多数编程语言都支持Socket编程,如Java、Php、Python等。这些语言的SocketSDK都是基于操作系统提供的socket()函数实现的。Linux和Windows都提供了相应的socket()函数。Socket编程过程下面我们来看一下Socket编程过程。不管Java、Python还是Php,很多编程语言都支持Socket编程。Linux、Windows等操作系统开放了网络编程接口。只不过是各种编程语言封装了底层操作系统提供的网络编程接口。从服务端开始,服务端首先调用socket()函数,根据指定的网络协议和传输协议创建一个Socket,例如创建一个网络协议为IPv4,传输协议为TCP的Socket。然后调用bind()函数为这个Socket绑定一个IP地址和端口。绑定这两个的目的是什么?绑定端口的目的:当内核收到一条TCP报文后,通过TCP头中的端口号,来找到我们的应用程序,然后将数据传递给我们绑定IP地址的目的:一台机器可能有多个网卡,每个网卡对应一个IP地址,只有绑定了一个网卡对应的IP,内核在收到网卡上的包后,才会发给我们的应用程序。绑定IP地址和端口后,我们就可以调用listen()函数进行监听了。如果我们想判断服务器上的某个网络程序是否启动了,可以使用netstat命令查看相应的端口号是否被监听。服务器进入监听状态后,调用accept()函数从内核获取客户端连接。如果没有客户端连接,它将阻塞并等待客户端连接到达。客户端如何发起连接?客户端创建Socket后,调用connect()函数发起连接。该函数的参数必须指明服务器的IP地址和端口号,然后开始著名的TCP三次握手。连接建立后,客户端和服务端开始互相传输数据,双方可以通过read()和write()函数读写数据。基于TCP协议的Socket编程过程就结束了。整个过程如下图所示:NetworkIOModelLinux的五种网络IO模型包括:同步阻塞IO、同步非阻塞IO、多路复用IO、信号驱动IO和异步IO。同步阻塞IO我们先来看看传统的阻塞IO。在Linux中,默认情况下所有套接字都处于阻塞模式。当用户线程调用系统函数read()时,内核开始准备数据(从网络接收数据)。内核准备数据后,将数据从内核复制到用户空间的应用程序缓冲区中。数据拷贝完成后,请求返回。从发起读请求到最终完成从内核到应用程序的拷贝,整个过程都是阻塞的。为了提高性能,可以为每个连接分配一个线程。因此,在连接数较多的情况下,需要大量的线程,会造成巨大的性能损失,这也是传统阻塞IO最大的缺陷。同步非阻塞IO用户线程发起Read请求后立即返回,无需等待内核准备数据。如果Read请求没有读取到数据,用户线程会继续轮询并发起Read请求,直到数据到达(内核准备好数据)才会停止轮询。非阻塞IO模型虽然避免了线程阻塞问题导致的大量线程消耗,但是频繁的重复轮询大大增加了请求数,消耗更多的CPU。这种模型在实际应用中很少使用。多路复用IO模型多路复用IO模型基于多路事件分离函数select、poll和epoll。在发起读请求之前,先更新select的socket监听列表,然后等待select函数返回(这个过程是阻塞的,所以多路复用IO不是完全非阻塞的)。当套接字有数据到达时,选择函数返回。这时用户线程正式发起读请求,对数据进行读取和处理。这种模式使用一个专门的监控线程来检查多个套接字,如果一个套接字有数据到达,则交给工作线程处理。由于等待Socket数据到达的过程是非常耗时的,所以该方法解决了阻塞IO模型中1个Socket连接需要1个线程的问题,不存在因忙轮询导致CPU性能损失的问题非阻塞IO模型。多路复用IO模型有很多实际应用场景。比如大家熟悉的JavaNIO、Redis、Nginx、Netty,Dubbo采用的通信框架,都是采用这种模型。下图是基于select函数的Socket编程的详细过程。用一句话解释多路复用模型。多通道:可以理解为多个网络连接(TCP连接)。多路复用:服务端重复使用同一个线程来监听所有网络连接中是否有IO事件(如果有IO事件,则交给工作线程从对应的连接中读取和处理数据)。Signal-drivenIOmodel信号驱动IO模型,应用进程使用sigaction函数,内核会立即返回,也就是说应用进程在内核的数据准备阶段是非阻塞的。内核准备好数据后,向应用进程发送SIGIO信号,应用进程收到信号后将数据复制到应用进程。这样CPU利用率就高了。但是,在这种模式下,在大量IO操作的情况下,可能会导致信号队列溢出,导致信号丢失,造成灾难性的后果。异步IO模型异步IO模型的基本机制是应用进程告诉??内核开始一个操作,内核操作完成后通知应用进程。在多路复用IO模型中,应用进程在socket状态事件到达并得到通知后,开始自行读取和处理数据。在异步IO模型中,当通知应用进程时,内核已经读取数据并将数据放入应用进程的缓冲区中,此时应用进程可以直接使用数据。显然,异步IO模型是高性能的。然而,到目前为止,异步IO和信号驱动IO模型很少被使用,传统的阻塞IO和多路复用IO模型仍然是当前应用的主流。异步IO模型是在Linux2.6版本之后引入的。目前很多系统还不够成熟,无法支持异步IO模型。许多应用场景使用多路复用IO而不是异步IO模型。