学习笔记——I/O多路复用

前言

我们在学习或者使用nginx、redis或者netty的时候,总是惊讶于它们的高并发性能。但有没有想过系统是如何在高并发下实现高性能I/O。

什么是I/O多路复用

I/O多路复用解决的就是并发性效率问题。举个例子,一个繁忙的WEB服务器每天都要处理上百万个请求,在网站高峰期的时候必然会同时生成多个请求。处理多个请求最低效的方法是排队,在处理其中一个请求时,其他所有请求都被阻塞掉,等待前面的请求处理完后再处理剩余的请求,这样的效率可想而知。稍好一点的方法是为每个请求fork()一个进程或者创建一个线程来处理,但是这必然带来进程(线程)上下文切换的损耗影响效率。
有没有一种新的方式来处理多个请求,同时实现最大的效率和吞吐量?
有,这就是I/O多路复用。

I/O多路复用本质上就是让系统内核去轮询多个Socket请求,只要至少有一个I/O就绪就会通知进程处理,而不用为了等待某个I/O就绪而阻塞其他请求。
和多线程相比,线程切换需要切换到内核进行线程切换,需要消耗时间和资源。而I/O多路复用不需要切换进程(线程),效率相对较高,特别是在高并发的场景应用如nginx用的就是I/O多路复用。

I/O模型

了解I/O多路复用之前,可以先了解一下I/O模型。分别为:

  • 阻塞性I/O
  • 非阻塞性I/O
  • I/O复用(select和poll)
  • 信号驱动式I/O
  • 异步I/O

关于I/O模型的比较可以参考这篇文章:https://www.cnblogs.com/cainingning/p/9556642.html

现在我们着重关注I/O复用的具体实现select()poll()

select()

让我们看一个简单的例子,关于一个回显的Socket客户端。
需求是这样的:实现一个Socket客户端,客户端连接到Socket服务端,并且客户端通过键盘输入文字,传送到服务端,服务端原封不动把文字回传到客户端中,最后客户端把内容重新显示到显示器上。
整个流程大概就是这样:
在这里插入图片描述

Version 1.0 阻塞版本的客户端

#include	"unp.h"

int
main(int argc, char **argv)
{
	int					sockfd;
	struct sockaddr_in	servaddr;

	if (argc != 2)
		err_quit("usage: tcpcli <IPaddress>");

	sockfd = Socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_port = htons(SERV_PORT);
	Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

	Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));

	str_cli(stdin, sockfd);		/* do it all */

	exit(0);
}

这段代码中,重点关注客户端在Connect()连接到服务端之后,调用str_cli()方法去获取键盘输入以及把文本传给服务端

#include	"unp.h"

void
str_cli(FILE *fp, int sockfd)
{
	char	sendline[MAXLINE], recvline[MAXLINE];

	while (Fgets(sendline, MAXLINE, fp) != NULL) {

		Writen(sockfd, sendline, strlen(sendline));

		if (Readline(sockfd, recvline, MAXLINE) == 0)
			err_quit("str_cli: server terminated prematurely");

		Fputs(recvline, stdout);
	}
}


这里的str_cli()方法完成客户端处理循环:

  • 从标准输入读入一行文本
  • 写到服务器上
  • 读回服务器对该行的返回
  • 并把返回写到标准输出上

其中第一个和第三个动作都是具有阻塞性的,当执行第一个动作(在键盘上键入文字)时,进程是阻塞在fgets()上的。同理,当客户端在接收服务端回写的文字时,客户端是无法处理用户输入的文字。

我们遇到的问题是,当用户阻塞在(标准输入)的时候,如果服务器进程被杀死,此时服务端TCP虽然正确地给客户端发送一个FIN,但是客户端却因为被阻塞而看不到这个EOF,直到socket超时为止。

如果有一个方式能够让系统只要有I/O就绪就通知进程去处理,而不是被阻塞掉,那该多好啊。
这就是select()存在的意义。

Version 2.0 I/O多路复用的客户端

先介绍一下select()的方法定义:

#include <sys/select.h>
#include <sys/time.h>

int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *excetset, const struct timeval * timeout);

// 返回:若有就绪描述符则为其数目,若超时则为0,若出错则为-1

我们可以调用select,告知内核仅在下列情况发生时才返回:

  • 集合{1, 4, 5}中的任何描述符准备好读;
  • 集合{2, 7}中的任何描述符准备好写;
  • 集合{1,4}中的任何描述符有异常条件待处理;
  • 已经历了n秒

这里只要知道fd_set代表描述符的集合,maxfdp1代表这些集合里面最大的描述符加1就可以了。
另外还有相关的四个宏:

void FD_ZERO(fd_set *fdset);	// 清除所有的描述符位
void FD_SET(int fd, fd_set *fdset);		// 设置描述符对应的位为1
void FD_CLR(int fd, fd_set *fdset);		// 设置描述符对应的位为0
int FD_ISSET(int fd, fd_set *fdset);	// 判断这个描述符是否已就绪

如果我们要定义一个fd_set的变量,然后打开描述符1、4和5的对应位。

fd_set rset;
FD_ZERO(&rset);		// 初始化fd_set:所有的位置为0
FD_SET(1, &rset);	// 描述符1的位设置为1
FD_SET(4, &rset);	// 描述符4的位设置为1
FD_SET(5, &rset);	// 描述符5的位设置为1

使用select实现I/O多路复用的str_cli()方法

#include	"unp.h"

void
str_cli(FILE *fp, int sockfd)
{
	int			maxfdp1;
	fd_set		rset;
	char		sendline[MAXLINE], recvline[MAXLINE];

	FD_ZERO(&rset);
	for ( ; ; ) {
		FD_SET(fileno(fp), &rset);
		FD_SET(sockfd, &rset);
		maxfdp1 = max(fileno(fp), sockfd) + 1;
		Select(maxfdp1, &rset, NULL, NULL, NULL);

		if (FD_ISSET(sockfd, &rset)) {	/* socket is readable */
			if (Readline(sockfd, recvline, MAXLINE) == 0)
				err_quit("str_cli: server terminated prematurely");
			Fputs(recvline, stdout);
		}

		if (FD_ISSET(fileno(fp), &rset)) {  /* input is readable */
			if (Fgets(sendline, MAXLINE, fp) == NULL)
				return;		/* all done */
			Writen(sockfd, sendline, strlen(sendline));
		}
	}
}

在这里可以看到,我们将需要等待的I/O描述符放到fd_set里面,然后通过select()方法阻塞,当至少有一个I/O就绪时,则继续往下执行,然后逐个检查是不是我们需要处理的描述符,如果是则分别对应进行不同的操作。
新的版本是由select方法来驱动的,而旧版本则是由fgets调用来驱动的。从效率和健壮性来说,I/O多路复用比传统的阻塞I/O更有优势。

poll()

poll()的功能与select()类似,不同点在于select()使用整型数组fd_set来记录描述符集合,并且最多只能关注1024个描述符。而poll()则使用结构数组来记录描述符,没有数量限制,并且多了优先级的设置。

#include <poll.h>
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);

struct pollfd {
	int fd;		// 需要检查的描述符
	short events;	// 关心的描述符
	short revents;	// 返回的描述符状态
}

具体的使用方法这里就不展开讨论了。

关于I/O多路复用的更佳实践还有epoll(),有兴趣的同学可以百度一下它们之间的区别,但是核心思想是相通的。

参考资料:《Unix 网络编程 卷1:套接字联网 API》 W.Richard Stevens

By ryan.ou

发布了87 篇原创文章 · 获赞 42 · 访问量 10万+

猜你喜欢

转载自blog.csdn.net/vipshop_fin_dev/article/details/101488765