梳理IO网络模型

Posted 每天告诉自己要努力

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了梳理IO网络模型相关的知识,希望对你有一定的参考价值。

IO网络模型是网络编程里面很重要的内容,我自己在刚开始学习的时候有的地方没有理解清楚,现在回过头来梳理串起来。

首先,有几个概念需要知道:

  • 流:本质是字节序列,可以理解成是:可以进行IO操作的内核对象。比如文件、管道、套接字等。可以把流想象成是内核里面的一个用来通信的容器,而这个容器的入口和出口都是文件描述符fd。
  • IO操作:严格的说法应该是处理器访问任何寄存器和缓存等封装以外的数据资源都可以当成 I/O 操作,包括内存,磁盘,显卡等外部设备。而一般软件系统的 I/O 通常指磁盘和网络,而我们现在谈的是网络编程,因此可以把IO操作理解成:流的读写操作。
  • 五种IO网络模型:阻塞、非阻塞、IO多路复用、信号驱动、异步(其中前四种属于同步);
  • 同步和异步是应用程序与内核的交互方式,在处理 IO 的时候,阻塞和非阻塞都是同步 IO,只有使用了特殊的 API 才是异步 IO。

偶然间,我看到了一种比较好的梳理方法:按照历史的诞生顺序
1、 1970年左右,UNIX出现的时候,没有多线程,没有网络。所有的IO都是阻塞IO。
2、 1983年左右,TCP/IP开始引入到BSD,这个时候还没有多线程,为了解决单线程服务器程序,同时处理多个请求。
此时有两种办法,1 多进程模式 2 单进程IO多路复用 (select)。可以理解为第一版的IO多路复用。
3 、1993年左右,出现了多线程技术
4 、再后来,linux引入了poll。可以理解为第二版的IO多路复用。
5、 再后来,linux引入了epoll。可以理解为第三版的IO多路复用。
6、 现在,一般是多线程和epoll结合使用。

·

·

·

·
按照这个逻辑,用server端为例子,描述各种IO网络模型的优缺点以及应用场景。(为了结构更加清晰,代码省略出错检测等步骤)

一、阻塞IO

1.1单线程服务器程序

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>

int main () {
	//第一步,创建用于监听连接的lfd
	int lfd = socket(AF_INET, SOCK_STREAM, 0);

	//第二步, bind
	struct sockaddr_in serv_addr;
	bzero(&serv_addr, sizeof(serv_addr));
	serv_addr.sin_family = AF_INET;
	serv_addr.sin_port = htons(9756);
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

	//第三步,listen 阻塞监听
	listen(lfd, 128);
	
	//第四步,循环阻塞accept
	struct sockaddr_in clie_addr;
	char clie_IP[64];
	char buf[BUFSIZ];
	while (1) {
		socklen_t clie_addr_len = sizeof(clie_addr);
		int cfd = accept(lfd, (struct sockaddr*)&clie_addr, &clie_addr_len);
		
		//输出连接成功信息
		printf("客户端连接成功!客户端IP = %s,客户端PORT = %d\\n",
		inet_ntop(AF_INET, &clie_addr.sin_addr.s_addr, clie_IP, sizeof(clie_IP)),
		ntohs(clie_addr.sin_port));

		//第五步,TCP三次握手成功之后创建了新的用于通信的cfd,进行读写操作
		sleep(10);//模拟大量的任务,进程被阻塞
		close(cfd);
	}
	
	//第六步,关闭文件描述符
	close(lfd);
	return 0;
}

在第五步中,用sleep10秒去模拟运行连接过来的这个客户端所进行的通信逻辑,在这10秒内,进程阻塞在sleep这个函数,如果有第二个客户端发起连接请求则会被阻塞,10秒过后,如果阻塞的请求队列里面有别的客户端刚刚发来的连接请求,则会再次连接成功,创建新的一个cfd。可以从代码运行结果看出:该server在同一个时刻只能接受一个客户端的连接,直到这个客户端断开连接之后,才可以去跟下一个客户端进行连接。

优点:阻塞的时候不占用cpu的时间片,不会浪费cpu资源; 缺点显而易见:效率太低了。

1.2 多进程/多线程服务器程序

一个简单的改进方案是在服务器端使用多线程或多进程。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。因为进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。通常,使用 pthread_create ()创建新线程,fork()创建新进程。

多进程服务器
父进程一直不断的accept,每当有新的客户端连接成功之后,会fork一个子进程与客户端进行通信,不影响父进程的本职工作(accept)。而父进程还要负责回收子进程(防止子进程变成僵尸进程);

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <ctype.h>
#include <string.h>

#define SERV_PORT 9756

//信号捕捉函数,设置为非阻塞回收子进程,不影响父进程accept
void catch_child(int sigNum) {
	while (waitpid(0, NULL, WNOHANG) > 0);
	printf("回收结束\\n");
	return;
}

int main() {
	//1. socket
	int lfd = socket(AF_INET, SOCK_STREAM, 0);

	//设置端口复用
	int opt = 1;
	setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

	//2.bind
	struct sockaddr_in serv_addr;
	bzero(&serv_addr, sizeof(serv_addr));
	serv_addr.sin_family = AF_INET;
	serv_addr.sin_port = htons(SERV_PORT);
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));

	//3.listen
	listen(lfd, 128);

	//4.父进程负责循环accept连接客户端
	pid_t pid;
	struct sockaddr_in clie_addr;
	char clie_IP[64];
	while (1) {
		socklen_t clie_addr_len = sizeof(clie_addr);
		int cfd = accept(lfd, (struct sockaddr*)&clie_addr, &clie_addr_len);

		//打印连接成功的信息
		printf("客户端连接成功!客户端IP = %s,客户端PORT = %d\\n",
		inet_ntop(AF_INET, &clie_addr.sin_addr.s_addr, clie_IP, sizeof(clie_IP)),
		ntohs(clie_addr.sin_port));

		//创建子进程
		pid = fork();
		if (pid < 0) {
			perror("fork error");
			exit(1);
		} else if (pid == 0) {
			//子进程在外面实现逻辑
			close(lfd);
			break;
		} else {
			//在多进程模型中,父进程不仅要循环accept。
			//为了防止子进程变成僵尸,也要负责回收子进程
			struct sigaction act;
			act.sa_handler = catch_child;
			sigemptyset(&act.sa_mask);
			act.sa_flags = 0;

			int ret = sigaction(SIGCHLD, &act, NULL);//注册信号捕捉函数,成功了不返回
			if (ret != 0) {
				perror("sigaction error");
				exit(1);
			}			
			close(cfd);
			continue;
		}		
	}

	//子进程跳出while,在这里执行逻辑
	if(pid == 0) {
		//子进程是负责跟客户端进行业务通信
		sleep(10);//模拟子进程阻塞10秒,并不会影响父进程进行accpet别的客户端
	}

	return 0;
}

(目前代码有问题,不知道为什么accept没有阻塞住,为什么会一直打印客户端信息还有回收成功信息。。。。明天改bug,然后接着写)原因应该是出在了回收子进程,但是逻辑应该没问题。

·

·

多线程服务器
跟多进程的思想差不多,主线程负责accept,每当accept成功之后马上thread_create一个子线程去跟客户端处理业务逻辑。主线程马上又回到了自己的工作岗位,继续accept。

#include <stdio.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <strings.h>
#include <ctype.h>
#include <pthread.h>

#define SERV_PORT 9756

//定义一个结构体, 将地址结构跟cfd捆绑
struct s_info {
	struct sockaddr_in clie_addr;
	int cfd;
};


//子线程工作函数
void *do_work(void *arg) {
	//int n, i;
	struct s_info *ts = (struct s_info*)arg;
	sleep(10);//模拟子线程需要阻塞10秒,在这阻塞期间不妨碍主线程accept
	close(ts->cfd);
	printf("子线程工作完毕,已退出\\n");
	return (void *)0;
}

int main () {
	int lfd;
	lfd = socket(AF_INET, SOCK_STREAM, 0);

	//设置端口复用
	int opt = 1;
	setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

	struct sockaddr_in serv_addr;
	bzero(&serv_addr, sizeof(serv_addr));
	serv_addr.sin_family = AF_INET;
	serv_addr.sin_port = htons(SERV_PORT);
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));

	listen(lfd, 128);

	struct sockaddr_in clie_addr;
	int cfd;
	struct s_info ts[256];  //根据最大线程数创建结构体数组
	int i = 0;
	pthread_t tid;
	char clie_IP[64];
	while (1) {
		socklen_t clie_addr_len = sizeof(clie_addr);
		cfd = accept(lfd, (struct sockaddr *)&clie_addr, &clie_addr_len);

		//打印连接成功的信息
		printf("客户端连接成功!客户端IP = %s,客户端PORT = %d\\n",
		inet_ntop(AF_INET, &clie_addr.sin_addr.s_addr, clie_IP, sizeof(clie_IP)),
		ntohs(clie_addr.sin_port));
		
		ts[i].clie_addr = clie_addr;
		ts[i].cfd = cfd;

		pthread_create(&tid, NULL, do_work, (void *)&ts[i]);
		pthread_detach(tid);
		i++;
	}

	return 0;
}

看到有一个说法:之所以每次accpet之后都能创建一个新的cfd,是因为socket的设计者可能特意为多客户机的情况留下了伏笔。
需要明确一个流程:在执行完bind和listen之后,操作系统已经开始在制定的端口处监听所有的连接请求,如果有请求,则该请求加入lfd套接字的请求队列。而调用accpet函数正式从这个请求队列中抽取第一个连接信息,然后创建一个跟lfd同类型的cfd作为返回的文件描述符。如果这个请求队列当前为空,则accpet则会进入阻塞态,直到有请求队列不为空了才会被唤醒。
上述多线程/多进程服务器看起来好像已经可以解决一个服务端为多个客户端提供服务,但实际上,如果并发量很高很高,则该方式会严重占据系统资源,降低系统对外界的响应,线程或进程也会很容易进入假死状态(无响应、崩溃)。

·

·

1.3 “线程池”、“连接池”

与多线程/多进程相比,使用线程池/连接池可以减少创建和销毁线程的频率,而且可以维持一定合理数量的线程,并让空闲的线程执行新的客户端业务。连接池可以维持连接的缓存池,尽量重复使用已经建立的连接、减少创建和关闭连接的频率。

·线程池和连接池这两种技术看起来好像可以很好的降低系统开销,看起来貌似可以解决多进程/多线程的严重占用系统资源的缺点。但实际上,这两种技术只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而当并发量很高很高的时候,仅仅缓解IO接口的调用是不够的。

·所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。

在面对成千上万的客户端请求时,使用线程池连接池或许可以缓解部分压力,但是并不能解决所有问题。总之,不管是多线程还是线程池,可以方便高效的解决低并发的小规模请求。但是面对大规模高并发的服务请求时,都会严重占用系统资源。因此可以尝试使用非阻塞接口来尝试改善这个问题。

·

·

·

二、非阻塞IO

非阻塞IO模型 不断轮询流里面是否有数据,有的话就处理,处理完继续轮询;没有的话就直接轮询。

有一个非常重要的点需要弄清楚:
IO模型包括文件IO和网络IO等等,而上面提到的几种方式都是属于网络IO,是跟阻塞监听accept客户端有关系的。
而现在谈到的非阻塞IO更多的应用是在于建立好网络连接之后的文件IO。用于接受数据。

上面的几个代码都是对于accpet连接客户端的情况来讨论的阻塞非阻塞。
对于非阻塞IO应用在网络中,尝尝是用于处理数据的接收场景,与阻塞等待接收数据应该放在同一个场景下讨论。 阻塞:不占用CPU的时间片
非阻塞:需要忙轮询读取数据,占用CPU时间片,浪费系统资源

非阻塞的方式,对比于多线程的方式,可以用单线程去接收多个连接中的数据。但是这个线程需要付出代价:需要不断的忙轮询;

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <ctype.h>
#include <errno.h>

int main () {
	//第一步,创建用于监听连接的lfd
	int lfd = socket(AF_INET, SOCK_STREAM, 0);

	//第二步, bind
	struct sockaddr_in serv_addr;
	bzero(&serv_addr, sizeof(serv_addr));
	serv_addr.sin_family = AF_INET;
	serv_addr.sin_port = htons(9756);
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

	//第三步,listen 阻塞监听
	listen(lfd, 128);
	
	//第四步,循环阻塞accept
	struct sockaddr_in clie_addr;
	char clie_IP[64];
	char buf[BUFSIZ];
	while (1) {
		socklen_t clie_addr_len = sizeof(clie_addr);
		int cfd = accept(lfd, (struct sockaddr*)&clie_addr, &clie_addr_len);
		
		//把cfd设置成非阻塞
		int flag = fcntl(cfd, F_GETFL);
		flag |= O_NONBLOCK;
		fcntl(cfd, F_SETFL, flag);

		//输出连接成功信息
		printf("客户端连接成功!客户端IP = %s,客户端PORT = %d\\n",
		inet_ntop(AF_INET, &clie_addr.sin_addr.s_addr, clie_IP, sizeof(clie_IP)),
		ntohs(clie_addr.sin_port));

		//第五步,TCP三次握手成功之后创建了新的用于通信的cfd,进行读写操作
		while (1) {
			int n = read(cfd, buf, sizeof(buf));
			if (n == 0) {
				//客户端断开连接
				break;
			} else if (n == -1){
				if (errno == EAGAIN) {
					continue;
				}
			} else {
				//小写转大写的逻辑业务
				for (int i = 0; i < n; i++) buf[i] = toupper(buf[i]);
				write(cfd, buf, n);
				write(STDOUT_FILENO, buf, n);				
			}

		}
		close(cfd);
	}
	
	//第六步,关闭文件描述符
	close(lfd);
	return 0;
}

可以看到服务器线程可以通过循环调用 read()接口,可以在单个线程内实现对所有连接的数据接收工作。但是上述模型绝不被推荐。因为,循环调用 read()将大幅度推高 CPU 占用率;此外,在这个方案中 read()更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如 select()多路复用模式(可以把这种忙轮询的工作交给内核处理),可以一次检测多个连接是否活跃。

·

·

·

三、多路复用IO——select 、 poll 、 epoll

多路复用IO也成为事件驱动IO(event driven IO)。多路复用IO,就是可以用单进程或单线程就可以同时处理多个网络连接请求。基本原理就是类似于找了个助手帮忙去阻塞监听,而这个助手是在内核里的。所以对于用户程序来说,调用了select/epoll后会阻塞,然后内核会监听所有的socket,当任何一个socket有数据了,就会返回唤醒阻塞的用户程序。

1 、 select

  1. 监听的IO有上限。默认1024
  2. 不会精准的告诉用户程序哪些IO是可读可写的,需要遍历
  3. 可以跨平台

2 、 epoll

  1. 监听的IO上限为系统可以打开的最大文件数目
  2. 只关心“活跃”的连接,不需要像select那样遍历全部文件描述符集合
  3. 只支持Linux平台

四、异步IO

Linux中,可以调用 aio_read 函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序。

陈硕:同步和异步是应用程序与内核的交互方式,在处理 IO 的时候,阻塞和非阻塞都是同步 IO,只有使用了特殊的 API 才是异步 IO。

牛客看到有的面经会说:同步约等于阻塞,异步约等于非阻塞;
我觉得这样的说法是不太准确。一个网络IO接口的调用,分为两个阶段,分别是“数据就绪”和“数据读写”,数据就绪阶段分为阻塞(阻塞当前线程)和非阻塞(不管结果如何都立即返回)。

同步表示A向B请求调用一个接口(网络接口或者某个API),数据的读写都是由请求方A自己来完成的(不管是阻塞还是非阻塞);

异步则表示在这个过程中,A只需要向B传入(一般是内核)请求的事件以及事件发生时的通知方式,然后A就可以继续处理其他逻辑的,当B监听到事件处理完成后,会用事先A传入的通知方式,通知A处理结果。

五、信号驱动IO

Linux 用套接口进行信号驱动 IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO 信号,然后处理 IO事件。

内核在第一个阶段是异步,在第二个阶段是同步;与非阻塞IO的区别在于它提供了消息通知机制,不需要用户进程不断的轮询检查,减少了系统API的调用次数,提高了效率。

以上是关于梳理IO网络模型的主要内容,如果未能解决你的问题,请参考以下文章

五种 IO 模型

IO 模型知多少 | 代码篇

IO 模型知多少 | 代码篇

IO 模型知多少 | 代码篇

浅谈Netty中ServerBootstrap服务端源码(含bind全流程)

基础入门_Python-网络编程.分分钟掌握阻塞/非阻塞/同步/异步IO模型?