本文已参与「新人创作礼」活动,一起开启掘金创作之路。
简要概述网络I/O与并发
以下内容可能存在错误,由博主自主查阅资料所得,仅作参考。
计算机的基本组成其实很简单,处理器,存储器加上输入输出设备就构成了计算机。大至超级计算机,小到手机发等都是一样的模型组成。计算的本质就是从输入设备读取数据处理后输出。可以理解计算机做的事就是IO和计算。
在网络发明之前,计算机从存储设备中读取数据,进程通过内存通道进行通信。互联网诞生之后,越来越多计算机通过互联网连接,将数据传输到世界各地。计算机之间可以通信,本质上也是计算机进程之间互相通信。为了方便不同终端进行通信,网络协议栈抽象出socket层,通过对socket文件描述符的操作来进行网络IO。当然,不同的应用场景,衍生出不同的网络模型。
本文描述为发起IO进程,也可以描述为发起IO的线程。
一次网络响应
互联网应用中,多数架构是CS模式,即client发出请求,server接收请求,处理之后返回响应。这样的一次交互,伴随着client和server的IO操作。对于常见的爬虫,client将尽可能提升其并发发送请求IO能力,对于后端server也需要尽可能提升其并发处理多个client请求的能力。
例如有多个用户,发送了一个请求,请求服务器上的一个文件。假设服务器监听的端口是8000。client请求的文件是hello.txt。当server开启服务后,就会监听来自8000端口的请求,client把请求发送给server,server再从自己的磁盘上读取hello.txt,然后返回给客户端。这样一次简单的交互,涉及到网络IO和磁盘文件的IO。大致流程如下:
![效果图
上图只表述server处理响应的过程:
- server的进程发起Read系统调用,内核随即从硬件Disk读取数据到内核缓冲区(kernel buf)。
- 内核再把缓冲区的数据copy到应用程序进程的缓冲区(app buffer),应用程序server进程就可以对数据进行修改。
- 应用程序server进程将数据通过系统调用Send发送到socket缓冲区,每个socket文件描述符都在内核维护一个发送/接收缓冲区。
- 最后再把socket发送缓冲区的数据copy到NIC网卡中,通过协议栈发送到对端的网卡中。
- 对端的网卡接收数据的过程中,client会发起一个Recv的系统调用,然后内核会从网卡中读取数据,然后copy到应用程序的缓冲区。
整个过程中,数据在三个主要层次流动,即==硬件、内核、应用程序==。在流动的过程中,从一个层流向另外一个层即为IO操作。
DMA Direct Memory Access,直接内存访问方式,即现在的计算机硬件设备可以独立地直接读写系统内存,而不是CPU完全介入处理。也就是数据从DISK或者NIC中copy到内核buf,不需要计算机CPU的参与,而是通过设备上的芯片(CPU)参与。即对于内核来说,这样的数据读取过程中,CPU可以做其它事情,大大提高了CPU的利用率。
一次I/O过程
通过上面的数据流动,可以看到IO的基本方式,那么什么是IO呢?通常现代的程序软件都运行在内存里,内存又分为用户态和内核态,后者隶属于操作系统。所谓的IO,就是将硬件(磁盘、网卡)的数据读取到程序的内存中。
因为应用程序很少可以直接和硬件交互,因为操作系统作为两者的桥梁。通常情况下,操作系统在对接两端(应用程序与硬件)时,自身有一个内核buf,用于数据的copy中转。
应用的读IO操作,即将网卡的数据copy到应用程序的buf,中途会经过内核的buf。
- 应用程序进程发起read系统调用。
- 内核接受到应用进程的请求,如果内核buf有数据,则把数据copy到应用buf中,调用结束。
- 如果内核buf中没有数据,会向IO模块发送请求,IO模块和硬件交互。
- 当NIC接收到协议栈的数据后,NIC会通过DMA技术将数据copy到内核buf中。
- 内核将内核buf 的数据copy到应用进程的buf中,调用结束。
一般网络IO分为两个阶段,等待数据阶段和拷贝数据阶段。前者是数据通过协议栈发送到网卡,网卡再通过DMA copy到内核buf。后者是进程阻塞,要么是同步调用,要么是异步调用。
I/O基本模型
《Unix网络编程》中提到了5种基本的网络I/O模型,主要分为同步和异步I/O:
- 阻塞I/O(blocking)
- 非阻塞I/O(nonblocking)
- 多路复用I/O(multiplexing)
- 信号驱动I/O(SIGIO)
- 异步I/O(asynchronous)
最好用的是第一种,代码逻辑简单,符合人的正常思考方式。现实中常用的是第三种,第二种不太好用,第四种也很少,第五种不太成熟。下面针对具体的方式逐一讲解。
阻塞I/O(blocking)
前面已经介绍,IO过程分为两个阶段,数据拷贝过程和等待数据准备。这里涉及两个对象,其一是发起IO操作的进程(线程),其二是内核对象。所谓阻塞是指进程在两个阶段都阻塞,即线程挂起,不能做别的事情。
红色的虚线表示IO函数调用过程。加粗的红线(CPU copy)过程表示数据从内核buf拷贝到应用buf的过程,在该过程种应用进程阻塞。
应用进程发起Recv操作,这是一个系统调用,然后内核会看内核buf是否有数据,如果没有数据,那么应用进程会被挂起,直到内核CPU从硬件或者网络中读到数据之后,内核再把数据从内核buf拷贝(copy)到进程buf中,然后唤醒发起调用的进程,并且Recv操作将会返回数据。接下来应用进程就可以对进程buf的数据进行处理。
一个简单的C++单线程同步阻塞server(Windows平台下):
#include <iostream>
//网络库
#include <WinSock2.h>
//引入ws2_32.lib
#pragma comment(lib,"ws2_32.lib")
using std::cout;
using std::endl;
using std::cin;
int main()
{
//在Windows平台下需要额外的加载socket库
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) == SOCKET_ERROR)
{
cout << "create WSAStartup Error " << GetLastError() << "\n";
return 0;
}
//1.创建一个socket套接字(socket)
SOCKET serverSock = socket(AF_INET, SOCK_STREAM, 0);
if (INVALID_SOCKET == serverSock)
{
cout << "create socket Error " << GetLastError() << "\n";
return 0;
}
//2.IP端口和socket相关联(bind)
SOCKADDR_IN serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(7890);//主机字节序转换为网络字节序
serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1");//将点分十进制字符串转换为网络地址(u_long)
if (SOCKET_ERROR == bind(serverSock, (sockaddr *)&serverAddr, sizeof(SOCKADDR_IN)))
{
cout << "bind Error " << GetLastError() << "\n";
return 0;
}
cout << "bind Success\n";
//3.监听端口(listen)
if (INVALID_SOCKET == listen(serverSock, 5))
{
cout << "listen Error\n";
return 0;
}
while (true)
{
//4.等地用户连接(accept)
printf("Hold User\n");
SOCKET clientSock;
SOCKADDR_IN clientAddr;
int clientAddrLen = sizeof(SOCKADDR_IN);
clientSock = accept(serverSock, (sockaddr *)&clientAddr, &clientAddrLen);
if (INVALID_SOCKET == clientSock)
{
cout << "accept Error " << GetLastError() << "\n";
return 0;
}
printf("accept Success SOCKET[%d] ip[%s] port[%d]\n", clientSock, inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port));
//5.跟连接的客户端通信(recv、send)
while (true)
{
char buffer[1024]{ 0 };
int recvLen = recv(clientSock, buffer, 1024, 0);
if (recvLen <= 0)
{
cout << "客户端断开连接或者发生错误\n";
break;
}
else
{
cout << "接收到的数据长度为: " << recvLen << "\n";
send(clientSock, buffer, sizeof(buffer), 0);
}
}
//关闭客户端
closesocket(clientSock);
}
//关闭服务端
closesocket(serverSock);
//卸载动态库
WSACleanup();
return 0;
}
server的socket套接字有两种,一种是监听套接字(sock),它有一个accept方法,该方法的作用就是从已握手的队列中取出一个连接。另外一种就是连接套接字(connet),即accept方法返回的socket。
非阻塞I/O(nonblocking)
线程在blockingIO(阻塞IO)中,发起IO之后随即被挂起,不能做其它事情。在nonblockingIO中,如果没有IO数据,那么发起的系统调用也会马上返回,会返回一个EWOULDBLOCK错误。函数返回之后,线程没有被挂起,当然可以继续做其它事情。
正如图上所示,在真实的环境中,进程发起了非阻塞IO请求,返回了EWOULDBLOCK之后,将会继续再次发起非阻塞的IO请求,这个过程还是会使用CPU,因此也称之为轮询(polling)。当内核有数据的时候,内核将内核buf的数据copy到应用buf的过程还是需要CPU参与,这个过程对于nonblocking来说,线程仍然是阻塞的。
多路复用I/O(multiplexing)
阻塞IO会让线程挂起不能做其它事情,非阻塞IO则提供了新的思路,函数调用之后就返回,可是为了完成IO,需要不同的polling(轮询)。每次轮询都是一次系统调用。某种程度下,非阻塞IO的性能还不如阻塞IO。既然需要内核频繁操作,这时有人想出了新的模型。
让内核代理去做轮询,然后应用进程只有数据准备了再发起IO操作不就好了吗?的确,==多路复用IO就是这样的原理==。由内核负责监控应用程序指定的socket文件描述符,当socket准备好数据(可读、可写、异常)的时候,通知应用进程。准备好数据是一个事件,当事件发生的时候,通知应用进程,而应用进程可以根据事件事先注册回调函数。
进程发起select或poll或epoll调用之后,可以设置进程阻塞。当内核数据准备好的时候通知应用进程,即事件发生。应用进程注册了回调函数(这里回调函数是recv)。因此进程可以再次发起recv系统调用。后面这个过程与前面的阻塞IO和非阻塞IO一样,都是系统调用recv。==只不过这里通常是一定可以读到数据,非阻塞的方式也不会返回错误==。但是整个copy的过程中,进程还是处于阻塞状态。
对于单个IO请求,这样的做法其实并没有多大优势,甚至不如阻塞IO。不过多路复用的好处在于多路,即可以同时监听多个socket描述符,当大量的文件描述符可读可写事件发生的时候,更有利于服务器的并发性能。
多路复用的I/O本质就是多路监听 + 阻塞/非阻塞IO。多路监听即select、poll、epoll这些系统调用。后半部分才是真正的IO。红色的线即是前文所叙述的阻塞IO或者非阻塞IO。
select、poll、epoll更多的时候是配合非阻塞的方式使用。如下:
一般多路复用IO都是配合非阻塞IO使用。因为读写socket的时候,并不确定读到什么时候才能读完。在一个循环里读,如果设置为阻塞模式,那么进程将会被挂起。比较好的做法是设置成非阻塞。
多路复用IO几乎成为了主流server方式。尤其是epoll,成为了nginx、redis,tornado等软件高性能的基石。
select模型的原理
网络通信过程在Unix系统中通常被抽象为文件的读写过程。select模型中的一个socket文件描述符通常可以看成一个由设备驱动程序管理的一个设备,驱动程序可以知道自身的数据是否可用。同时,该设备支持阻塞操作并实现了一组自身的等待队列,如读/写等待队列用户支持上层(用户层)所需的block(阻塞)和non-block(非阻塞)操作。设备的资源如果可用(可读/可写)则会通知应用进程。反之则会让进程睡眠,等待数据到来的时候,再唤醒应用进程。
多个这样的设备的文件描述符被放在一个队列中,然后select调用的时候遍历这个队列,如果对应的文件描述符可读/可写则会返回该文件描述符(调用应用进程的回调事件)。当遍历结束之后,如果仍然没有一个可用的文件描述符,select会让用户进程睡眠,直到等待资源可用的时候再唤醒用户进程并返回对应的文件描述符(调用应用进程的回调事件),select每次遍历都是线性的。
select模型的不足
尽管select模型使用很便利,且具有跨平台的特性。但是select模型还是存在一些问题。select模型需要遍队列中的文件描述符,并且这个队列还有最大限制(64)。随着文件描述数量的增长,用户态和内核的地址空间的复制引发的开销也会线性增长。即使监视的文件描述符长时间不活跃,select模型还是会进行线性扫描它。
为了解决这些问题,操作系统又提供了poll方案,但是poll的模型和select大致相当,只是改变了一些限制。目前Linux最先进的方式是epoll模型。
==select模型详情链接==:https://blog.csdn.net/qq135595696/article/details/121549469
信号I/O(SIGIO)
让内核在文件描述符就绪时发送SIGIO信号通知进程。这种模型为信号驱动式I/O(signal-driven I/O),和事件驱动类似,也是一种回调方式。与非阻塞方式不一样的是,发起了信号驱动的系统调用,进程没有挂起,可以做其他事情。可在实际中,代码逻辑通常还是主循环,主循环里可能还是会阻塞。因此用这类IO的软件很少。
当信号返回可以读写的时候,因为还需要CPU将内核数据copy到应用buf。这个过程毫无疑问还是阻塞的。
异步I/O(asynchronous)
前面一直强调,内核在copy数据从内核到应用buf的过程中,CPU需要参与,进程都会被阻塞。因此可以理解,进程和内核的步调是一致的,也就是同步。这样的IO模型称之为同步I/O。那么什么是异步I/O呢?
Unix下的异步I/O模型如下;
图中的I/O调用函数的红线只出现在第一步中。
即无论是第一阶段数据准备还是第二阶段数据拷贝过程,发起系统调用的进程都不会被阻塞。在第二阶段的过程中,进程没有阻塞,那么可以抢占CPU,而内核copy数据的时候,也需要CPU,这就造成了应用进程和内核进行CPU竞争,并且步调不一致了。某种情况下,其性能反而不如其它IO模式,使用的人很少。
版权声明:本文为CSDN博主「ufgnix0802」的原创文章:
原文链接:(blog.csdn.net/qq135595696…)