《Unix 网络编程》06:IO复用之select/poll
IO复用之select/poll
系列文章导航:《Unix 网络编程》笔记
概述
进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个 I/O 条件准备就绪,他就通知进程。这个能力被称为 I/O 复用。
典型应用场景有:
- 客户处理多个描述符(如之前的应用那样)
- 客户同时处理多个套接字,不过这种情况比较少见
- 服务器既要处理监听套接字,又要处理已经连接的套接字
- 服务器既要处理 TCP,又要处理 UDP
- 服务器要处理多个服务或多个协议
- 许多重要的应用程序也需要这种技术
IO模型
Unix 下的 5 种 I/O 模型:
- 阻塞式 I/O
- 非阻塞式 I/O
- I/O 复用(select 和 poll)
- 信号驱动式 I/O (SIGIO)
- 异步 I/O (aio_)
输入操作的阶段
- 等待数据准备好
- 从内核向进程复制数据
不同的 I/O 模型就是这两个阶段的行为的不同
阻塞式 I/O
阻塞 I/O 就是两个阶段都等待。
可以看出,这种方式比较缓慢,进程在等待期间不能做其他的事情。
非阻塞式 I/O
非阻塞 I/O 在所查询的数据没有就绪时不会阻塞(上述阶段一),而是直接返回一个错误。
用户进程需要不断地进行调用,询问是否准备就绪,一旦就绪才开始处理。
当一个应用进程像这样对一个非阻塞描述符循环调用查询操作时,我们称之为轮询(pollling)。
这种做法往往耗费大量 CPU 时间,反而不太好用。但是在特定的场合下也能发挥其作用。
I/O 复用
I/O 复用会等待在上述两个阶段。但是,第一个阶段的等待是等待在如 select 这些方法上的,而不是 recvfrom,而 select 可以同时等待多个套接字,所以说即使准备就绪 socket 的排在后面,也可以“插队”得到响应。
这种方式也类似于多线程的阻塞式 I/O 模式。
而根据具体的细节,I/O 复用又可以分为:
- select
- poll
- epoll
信号驱动式 I/O
与内核建立 SIGIO 的信号关联并设置回调,当内核有 FD 准备就绪时,发送 SIGIO 信号通知用户,期间用户应用可以执行其他业务,无需阻塞等待。
这种方式在阶段一不会发生阻塞,而且和非阻塞式 I/O 相比也不会发生轮询现象。
缺点是:
- 当有大量 IO 操作时,信号较多,SIGIO 处理函数不能及时处理可能导致信号队列溢出
- 内核空间与用户空间的频繁信号交互性能比较低
异步 I/O
告知内核启动某个操作,并让内核完成整个操作后通知我们。(像是在给下属布置任务)
这种方式在上述两个阶段都是非阻塞的,其缺点有:
- 由于调用方只需调用而无需等待,而具体工作可能很消耗资源,所以多线程高并发环境下可能会耗尽系统资源导致系统崩溃
- 为了限制并发,需要对上述情况进行处理,从而导致回调函数会变得复杂
问题:消息阻塞
在上一章中已经描述了这个问题发生的原因和造成的原因。
这里侧重问题的解决。
select 函数
等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。
#include <sys/select.h>
#include <sys/time.h>
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset,
const struct timeval *timeout);
参数和返回值
这里按照原书的倒序解释各个参数:
timeout :最长等待时间
struct timeval {
long tv_sec;
long tv_usec;
}
分别代表秒和微秒:
- 如果结构体的两个参数均设为 0,则不等待
- 如果结构体为空,则一直等待
readset、writeset、exceptset
设置具体关心的描述符集合,通常是一个整数数组,不同的位标识不同的描述符。
void FD_ZERO(fd_set *fdset); /* clear all bits in fdset */
void FD_SET(int fd, fd_set *fdset); /* turn on the bit for fd in fdset */
void FD_CLR(int fd, fd_set *fdset); /* turn off the bit for fd in fdset */
int FD_ISSET(int fd, fd_set *fdset); /* is the bit for fd on in fdset ? */
例如:
fd_set rset;
FD_ZERO(&rset); /* initialize the set: all bits off */
FD_SET(1, &rset); /* turn on bit for fd 1 */
FD_SET(4, &rset); /* turn on bit for fd 4 */
FD_SET(5, &rset); /* turn on bit for fd 5 */
如果把这三个参数都设置为空,相当于获得了一个比 sleep 更为精准的定时器。
maxfdp1
(max file descriptor plus 1)
最大描述符增加 1,这样我们就不用遍历完所有的位了
返回信息
- 有事件触发,返回事件的数量
- 定时器到时,返回 0
- 出错,如打断,返回 -1
就绪条件
套接字准备好读和写的条件:
套接字异常的条件:
如果一个套接字存在带外数据或仍处于带外标记,那么它有异常要处理(24章中讲解)
最大描述符数
一般不会使用太多的描述符,在一些特殊应用场景下确实需要担心这个问题
限制数量:
- 早先的系统会限制最大描述符的数量,现在的 Unix 版本往往没有这个限制
select
一般会定义一个FD_SETSIZE
来限制最大描述符的数量,如果想要修改这个值,可能要重新编译内核- 有些厂家正在扩展该值,以支持更大的连接数
如果强制修改造成的问题:
- 描述符数量增大时可能存在扩展性问题
其他方法:
- 改用 poll 代替 select,可以避免描述符有限的问题
str_cli 改进
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); // fileno 把标准IO文件指针转换成描述符
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));
}
}
}
问题:半关闭和缓冲
批量输入
在前面的 echo 例子中,交互式地发送消息是很合适的,但是在该例子中,管道容量的利用率并不高,管道上始终只有少量的消息在流动。
假如我们需要传输一个比较大的消息,那么用批量方式可以大大提高管道的利用效率
我们可以利用 Unix 的重定向实现,将文件内容重定向到套接字中,如下图所示:
但是批量输入中有一个问题:对于文件末尾的 EOF,strcli 函数会结束自身执行,并返回到 main 函数中。main 函数也执行完毕,程序随之关闭。
在这种输入方式下,标准输入中的 EOF 并不意味着我们同时也完成了从套接字的读取,可能还有消息还在去或回来的路上。
我们需要的是一种关闭 TCP 连接一半的方法,也就是说,我们想给服务器发送一个 FIN,告诉它我们已经完成了数据的发送,但是仍然保持套接字描述符的打开以便继续读取服务器发来的响应。这将由 shutdown 函数来完成。
缓冲机制
- 我们的客户端代码 str_cli 会在有数据时调用
fgets
读取,并读入 stdio 的缓冲区中 - 然而 fgets 只返回其中第一行,其余输入行仍在 stdio 缓冲区中
- 之后 write 把这一行输入写给服务器
- 但是尽管缓冲区中还有数据,select 却不会被触发了:因为 select 监听的是我们指定的某一个描述符,而对 stdio 中的各种函数自带的缓冲区毫无察觉
- 可以把 fgets 之类的函数换成系统调用 read 和 write,从而避免这种问题
shutdown
之前用的 close 函数的缺陷:
- close 只是把相关的引用计数 -1,而不能起到立刻关闭的效果,而 shutdown 则是直接关闭(四次挥手)
- close 终止两个方向的数据传送,shutdown 在这方面的控制更灵活一些
#include <sys/socket.h>
int shutdown(int sockfd, int howto);
howto 的可选值:
值 | 含义 | 说明 |
---|---|---|
SHUT_RD | 关闭读 | 丢弃套接字接收缓冲区数据,之后接收的消息都被确认,然后丢弃 |
SHUT_WR | 关闭写 | 留在发送缓冲区的数据被发送,后跟 TCP 的正常连接终止序列 |
SHUT_RDWR | 读写都关闭 | 等同于上面两个一起的作用 |
str_cli 改进
void str_cli(FILE *fp, int sockfd)
{
int maxfdp1, stdineof;
fd_set rset;
char buf[MAXLINE];
int n;
stdineof = 0;
FD_ZERO(&rset);
for (;;)
{
if (stdineof == 0)
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 ((n = Read(sockfd, buf, MAXLINE)) == 0)
{
if (stdineof == 1)
return; /* normal termination */
else
err_quit("str_cli: server terminated prematurely");
}
Write(fileno(stdout), buf, n);
}
if (FD_ISSET(fileno(fp), &rset))
{ /* input is readable */
if ((n = Read(fileno(fp), buf, MAXLINE)) == 0)
{
stdineof = 1;
Shutdown(sockfd, SHUT_WR); /* send FIN */
FD_CLR(fileno(fp), &rset);
continue;
}
Writen(sockfd, buf, n);
}
}
}
- 我们用 Read 和 Write 代替了 Fgets 和 Fputs
- 用 Shutdown 代替了直接终止
改进:select 代替多进程
多进程会占用大量的系统资源,影响系统的吞吐量,或许我们可以改用 Select 来同时为多个客户提供服务。
改进代码
int main(int argc, char** argv) {
int i, maxi, maxfd, listenfd, connfd, sockfd;
int nready, client[FD_SETSIZE];
ssize_t n;
fd_set rset, allset;
char buf[MAXLINE];
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(listenfd, (SA*)&servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);
maxfd = listenfd; /* initialize */
maxi = -1; /* index into client[] array */
for (i = 0; i < FD_SETSIZE; i++)
client[i] = -1; /* -1 indicates available entry */
FD_ZERO(&allset);
FD_SET(listenfd, &allset);
/* end fig01 */
/* include fig02 */
for (;;) {
rset = allset; /* structure assignment */
nready = Select(maxfd + 1, &rset, NULL, NULL, NULL);
if (FD_ISSET(listenfd, &rset)) { /* new client connection */
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA*)&cliaddr, &clilen);
printf("new client: %s, port %d\n",
Inet_ntop(AF_INET, &cliaddr.sin_addr, 4, NULL),
ntohs(cliaddr.sin_port));
for (i = 0; i < FD_SETSIZE; i++) {
if (client[i] < 0) {
client[i] = connfd; /* save descriptor */
break;
}
}
if (i == FD_SETSIZE)
err_quit("too many clients");
FD_SET(connfd, &allset); /* add new descriptor to set */
if (connfd > maxfd)
maxfd = connfd; /* for select */
if (i > maxi)
maxi = i; /* max index in client[] array */
if (--nready <= 0)
continue; /* no more readable descriptors */
}
for (i = 0; i <= maxi; i++) { /* check all clients for data */
if ((sockfd = client[i]) < 0)
continue;
if (FD_ISSET(sockfd, &rset)) {
if ((n = Read(sockfd, buf, MAXLINE)) == 0) {
/*4connection closed by client */
Close(sockfd);
FD_CLR(sockfd, &allset);
client[i] = -1;
} else
Writen(sockfd, buf, n);
if (--nready <= 0)
break; /* no more readable descriptors */
}
}
}
}
拒绝服务攻击
在上述模型中,如果有恶意用户不断建立新的链接,但是不发送有用的信息,则服务会被一直阻塞,耗费服务器的资源。
当一个服务器在处理多个客户时,它绝对不能阻塞于只与单个客户相关的某个函数调用。否则,可能会遭受 DoS 型攻击。
可能的解决方法包括:
- 使用非阻塞 IO
- 让每个客户由单独的控制线程提供服务
- 对 IO 操作设置一个超时
pselect
#include <sys/socket.h>
#include <signal.h>
#include <time.h>
int pselect(
int maxfdp1,
fd_set *readset, fd_set *writeset, fd_set *exceptset,
const struct timespec * timeout,
const sigset_t *sigmask
);
与 select
对比的参数解释:
-
使用
timespec
代替timeval
,最高精确到纳秒struct timespec { time_t tv_sec; long tv_nsec; }
-
pselect 增加了第六个参数:一个指向信号掩码的指针。该
poll
函数介绍
事件如下:
poll 程序修改
略,参见原文