简要概述网络I/O与并发

273 阅读13分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

简要概述网络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。大致流程如下:

![效果图](https://img-blog.csdnimg.cn/2433f64095c94223a8eba0f48e49445f.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBAdWZnbml4MDgwMg==,size_20,color_FFFFFF,t_70,g_se,x_16)

上图只表述server处理响应的过程:

  1. server的进程发起Read系统调用,内核随即从硬件Disk读取数据到内核缓冲区(kernel buf)。
  2. 内核再把缓冲区的数据copy到应用程序进程的缓冲区(app buffer),应用程序server进程就可以对数据进行修改。
  3. 应用程序server进程将数据通过系统调用Send发送到socket缓冲区,每个socket文件描述符都在内核维护一个发送/接收缓冲区。
  4. 最后再把socket发送缓冲区的数据copy到NIC网卡中,通过协议栈发送到对端的网卡中。
  5. 对端的网卡接收数据的过程中,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。

  1. 应用程序进程发起read系统调用。
  2. 内核接受到应用进程的请求,如果内核buf有数据,则把数据copy到应用buf中,调用结束。
  3. 如果内核buf中没有数据,会向IO模块发送请求,IO模块和硬件交互。
  4. 当NIC接收到协议栈的数据后,NIC会通过DMA技术将数据copy到内核buf中。
  5. 内核将内核buf 的数据copy到应用进程的buf中,调用结束。

一般网络IO分为两个阶段,等待数据阶段和拷贝数据阶段。前者是数据通过协议栈发送到网卡,网卡再通过DMA copy到内核buf。后者是进程阻塞,要么是同步调用,要么是异步调用。

I/O基本模型

《Unix网络编程》中提到了5种基本的网络I/O模型,主要分为同步和异步I/O:

  1. 阻塞I/O(blocking)
  2. 非阻塞I/O(nonblocking)
  3. 多路复用I/O(multiplexing)
  4. 信号驱动I/O(SIGIO)
  5. 异步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…)