前言
我们在学习或者使用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