I/O复用:select和poll函数
进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪(即输入准备好被读取,或描述符已经能承接更多的输出),就通知进程
这种能力称为I/O复用
- 由select和poll两个函数支持的
I/O复用典型使用在下列网络应用场合:
- 当客户处理多个描述符(通常是交互式输入和网络套接字)时,必须使用I/O复用
- 一个客户同时处理多个套接字是可能的,不过比较少见
- 如果一个TCP服务器即需要处理监听套接字,又要处理已连接套接字,一般就要使用I/O复用
- 如果一个服务器要处理多个服务或多个协议,一般就要使用I/O复用
I/O复用并非只限于网络编程,许多重要的应用程序也需要使用这项技术
I/O模型
Unix下可用的5中I/O模型的基本区别:
- 阻塞式I/O
- 非阻塞式I/O
- I/O复用(select和poll)
- 信号驱动式I/O(SIGIO)
- 异步I/O(POSIX的aio_系列函数)
一个输入操作通常包括两个不同的阶段:
- 等待数据准备好
- 从内核向进程复制数据
阻塞式I/O模型
最流行的I/O模型
默认情况下,所有套接字都是阻塞的
- 进程调用systemcall
- 其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发生错误才返回
最常见的错误是系统调用被信号中断
- 其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发生错误才返回
进程在从调用systemcall开始到它返回的整段时间内是被阻塞的
systemcall成功返回后,应用进程开始处理数据报
非阻塞式I/O模型
进程把一个套接字设置成非阻塞是在通知内核:
当所请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误
- 当前几次systemcall时没有数据可返回
- 内核转而立即返回一个EWOULDBLOCK错误
- 当调用systemcall时已有数据报准备好
- 则被复制到应用进程缓冲区,于是systemcall成功返回
当应用进程对非阻塞描述符循环调用时,称为轮询
应用进程持续轮询内核,以查看某个操作是否就绪
这么做往往耗费大量CPU时间
不过这种模型偶尔也会遇到,通常是在专门提供某种功能的系统中才有
I/O复用模型
有了I/O复用,就可以调用select或poll,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上
- 阻塞于select调用,等待数据报套接字变为可读
- 当select返回套接字可读这一条件时,调用systemcall把所读数据复制到应用进程缓冲区
由于使用select需要两个而不是单个系统调用,I/O复用还稍有劣势
但使用select的优势在于可以等待多个描述符就绪
与I/O复用密切相关的是在多线程中使用阻塞式I/O
使用多个线程,每个线程可以自由调用阻塞式I/O系统调用
信号驱动式I/O模型
可使用信号,让内核在描述符就绪时发送SIGIO信号通知进程
- 先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数
- 该系统调用将立即返回,进程继续工作,即没有阻塞
- 当数据报准备好读取时,内核就为该进程产生一个SIGIO信号
- 可以在信号处理函数中调用systemcall读取数据报,并通知主循环数据已经准备好待处理
- 也可以立即通知主循环,让它读取数据报
无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞
主循环可以继续执行,只要等待来自信号处理函数的通知:
- 即可以是数据已准备好被处理
- 也可以是数据报已经准备好被读取
异步I/O模型
这些函数的工作机制:
告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到自己的缓冲区)完成之后通知进程
与信号驱动模型的主要区别在于:
- 信号驱动式I/O是由内核通知何时可启动一个I/O操作
- 而异步I/O模型是由内核通知进程I/O操作何时完成
各种I/O模型的比较
前四中模型主要区别在于第一阶段
因为它们第二阶段是一样的:
在数据从内核复制到调用者的缓冲区期间,进程阻塞于systemcall调用
相反,异步I/O模型在这两个阶段都要处理,从而不同于其他4种模型
同步I/O和异步I/O对比
PISIX术语定义:
- 同步I/O操作:导致请求进程阻塞,直到I/O操作完成
- 异步I/O操作:不导致请求进程阻塞
因此前4种模型:阻塞式I/O模型,非阻塞式I/O模型,I/O复用模型,信号驱动式I/O模型 都是同步I/O模型
因为其中真正的I/O操作将阻塞进程
只有异步I/O模型与POSIX定义的异步I/O相匹配
select函数
该函数允许进程指示内核等待多个事件中的任何一个发生,并且只有在一个或多个事件发生或经历一段指定的时间后才唤醒它
任何描述符都可以使用select来测试
1 | #include <sys/select.h> |
timeout:告知内核等待所指定描述符中的任何一个就绪可花多长时间
其timeval结构用于指定这段时间的秒数和微秒数
1 | struct timeval { |
此参数有三种可能:
- 永远等待下去
- 仅在有一个描述符准备好I/O时才返回
- 为此把该参数设置为空指针
- 等待一段固定时间
- 在有一个描述符准备好I/O时返回
- 但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数
- 根本不等待
- 检查描述符后立即返回,称为轮询
- 为此,该参数必须指向一个timeval结构,而且其中的定时器值(由该结构指定的秒数和微秒数)必须为0
前两种情况的等待通常会被进程在等待期间捕获的信号中断,并从信号处理函数返回
不同内核可能会自动重启被中断的select
如果为了可移植性,在捕获信号时,必须做好select返回EINTR错误的准备
尽管timeval结构允许指定一个微秒级的分辨率,然而内核支持的真实分辨率往往粗糙的多
timeout参数的const限定词表示在返回时不会被select修改
若需要知道函数执行剩余的秒数,则需要取得执行前后的系统时间做差值
readset、writeset、exceptset指定要让内核测试读、写、异常条件的描述符
目前支持的异常条件只有两个:
- 某个套接字的带外数据的到达
- 某个已置为分组模式的伪终端存在可从其主端读取的控制状态信息
如何给这三盒参数指定描述符是设计上的问题
select使用描述符集
通常是一个整数数组,其中每个整数中的每一位对应一个描述符
如:若使用32位整数,则数组第一个元素对应描述符031,第二个元素对应描述符3263
所有实现细节都与应用程序无关,隐藏在名为fd_set的数据类型和一下四个宏中:
1 | void FD_ZERO(fd_set *fdset); |
描述符集的初始化非常重要
因为作为自动变量分配的一个描述符若未初始化,则会引发不可预测的后果
对于readset,writeset,exceptset三个参数
若对某个条件不感兴趣,就可以将其设为空指针
事实上,如果三个参数都为空,就有了一个比Unix的sleep函数更为精确的定时器(sleep睡眠以秒为最小单位)
maxfdp1参数指定待测试的描述符个数,其值是待测试的最大描述符加1
描述符0,1,…maxfdp1-1均将被测试
头文件<sys/select.h>
中定义的FD_SETSIZE常值是数据类型fd_set中的描述符总数
- 其值通常是1024,不过很少有程序用到那么多描述符
maxfdp1参数迫使程序员计算所关心的最大描述符并告知内核该值
存在这个参数的额外负担纯粹是为了效率
select函数修改由指针readset、writeset、exceptset所指向的描述符集
因为这三个参数都是值-结果参数
该函数返回后,使用FD_ISSET宏来测试fd_set数据类型中的描述符
- 描述符集内任何与未就绪描述符对应的位返回时均清成0
- 为此,每次重新调用select函数时,都得再次把描述符集内所关心的位均置为1
使用select的常见错误:
- 忘了对最大描述符加1
- 忘了描述符集是值-结果参数
- 导致调用select时,描述符集内被认为是1的位却实际置为0
该函数返回值表示跨所有描述符集的已就绪的总位数
如果在任何描述符就去之前定时器到时,则返回0
返回-1表示出错(可能发生,如函数被一个所捕获的信号中断)
描述符就绪条件
满足下列四个条件中的任何一个时,套接字准备好读
- 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小
- 对这样的套接字执行读操作不会阻塞并将返回一个大于0的值(即返回准备号读入的数据)
- 可以使用SO_RECVLOWAT套接字选项设置该套接字的低水位标记
- 对于TCP和UDP套接字而言,默认值为1
- 该连接的读半部关闭(也就是接收了FIN的TCP连接)
- 对这样的套接字的读操作将不阻塞并返回0(也就是返回EOF)
- 该套接字是一个监听套接字且已完成的连接数不为0
- 对这样的套接字的accept通常不会阻塞
- 其上有一个套接字错误待处理
- 对这样的套接字读操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件
- 这些待处理错误也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除
下列四个条件中的任何一个满足时,一个套接字准备好写
- 该套接字发送缓冲区中的可用字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且或者该套接字已连接,或者该套接字不需要连接(如UDP套接字)
- 这意味着如果把这样的套接字设置成非阻塞,写操作将不阻塞并返回一个正值(如传输层接受的字节数)
- 可以使用SO_SNDLOWAT套接字选项来设置该套接字的低水位标记
- 对于TCP和UDP套接字而言,其默认值通常为2048
- 该连接的写半部关闭
- 对这样的套接字的写操作将产生SIGPIPE信号
- 使用非阻塞式connect的套接字已建立连接,或者connect已经以失败告终
- 其上有一个套接字错误待处理
- 对于这样的套接字的写操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件
- 这些待处理的错误也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除
如果一个套接字存在带外数据或仍处于带外标记,那么它有异常条件待处理
注意:当某个套接字上发生错误时,它将由select标记为即可读又可写
接收低水位标记和发送低水位标记的目的在于:
允许应用进程控制在select返回可读或可写条件之前有多少数据可读或有多大空间可用于写
任何UDP套接字只需要其发送低水位标记小于等于发送缓冲区大小(默认应该总是这种关系)就总是可写的
因为UDP套接字不需要连接
select的最大描述符数
不重新编译内核的话,FD_SETSIZE的值无法改变
有些商家正在修改select的实现使其允许进程将FD_SETSIZE定义比默认值更大的某个值
然而从可移植性考虑,使用大描述符集需要小心
使用select的实现样例
1 | #include "unp.h" |
批量输入
上述代码依旧存在问题
对于以等-停方式工作的交互式使用是合适的
既然客户是从标准输入读并往标准输出中写,在Unix的shell环境下重定向输入输出又很容易,则可以以批量方式运行客户
问题在于,为提升性能而引入缓冲机制增加了网络应用程序的复杂性
select不知道stdio使用了缓冲区,只是从read系统调用的角度指出是否有数据可读,而不是从fgets之类调用的角度考虑
fgets返回单个行,而缓冲区中可能还有内容
解决方法之一是,在select之前使用那个函数,以查看是否存在已经读入而尚未消费的数据
然而为了处理自定义函数中的缓冲区中即可能有不完整的输入行(需要继续读入),也可能有一个或多个完整的输入行(可以直接消费),可能导致复杂度迅速增长难以控制
后续将解决这些缓冲区问题
shutdown
函数
终止网络连接的通常方法是调用close函数
但close有两个限制,可以使用shutdown来避免
- close把描述符引用计数减1,仅在该计数为0时才关闭套接字
- 使用shutdown可以不管引用计数就激发TCP的正常连接终止序列
- close终止读和写两个方向的数据传送
- 既然TCP连接是全双工的,有时需要告知对端已经完成了数据发送,即使对端仍有数据要发送给我们
1 | #include <sys/socket.h> |
该函数的行为依赖于howto参数的值
SHUT_RD
- 关闭连接的读这一半:套接字中不在有数据可接收
- 而且套接字接收缓冲区中的现有数据都被丢弃
- 进程不能再对这样的套接字调用任何读函数
- 对一个TCP套接字这样调用shutdown函数后,由该套接字接收的来自对端的任何数据都被确认,然后悄悄丢弃:(
SHUT_WR
- 关闭连接的写这一半:对于TCP套接字,称为版关闭
- 当前留在套接字发送缓冲区的数据将被发送掉,后跟TCP的正常连接终止序列
- 不管套接字描述符的引用计数是否等于0,这样的写半部关闭照样执行
- 进程不能再对这样的套接字调用任何写函数
SHUT_RDWR
- 连接的读半部和写半部都关闭:这与调用shutdown两次等效
- 第一次调用指定SHUT_RD
- 第二次调用指定SHUT_WR
这三个SHUT_XXX名字由POSIX规范定义
howto参数的典型值将会是0(关闭读半部),1(关闭写半部)和2(读半部和写半部都关闭)
select问题解决
改进版本,使用了select和shutdown
前者:服务器只要关闭它那一端的连接就会通知客户
后者:允许正确的处理批量输入
此版本废弃了以文本行为中心的代码,改而针对缓冲区操作,从而消除了之前的复杂性问题
1 | #include "unp.h" |
当在套接字上读到EOF时,如果已在标准输入上遇到EOF,那就是正常的终止
如果在标准输入上没有遇到EOF,那么服务器进程已过早终止
改用read和write对缓冲区而不是文本行进行操作,使得select能够如期工作
使用select来处理任意客户的单个进程程序
服务器有单个监听描述符
服务器只维护一个读描述符集
当客户与服务器建立连接时
- 监听描述符变为可读,服务器调用accept
- 服务器需要在数组中记录每个新的已连接描述符,并把它添加到描述符集中去
若客户终止它的连接
- 客户TCP发送一个FIN,服务器中的描述符变为可读
- 当服务器读这个已连接套接字时,read返回0
- 于是关闭该套接字并更新数据结构
使用单进程和select的TCP服务器程序:初始化
1 | #include "unp.h" |
使用单进程和select的TCP服务器程序:循环
1 | for( ; ; ) { |
该程序依旧存在问题,但通过将监听套接字设置成非阻塞,然后检查并忽略来自accept的若干错误可以很容易的解决该问题
拒绝服务型攻击
服务器可能因为某个用户而阻塞于下一个read调用,以等待来自该客户的其余数据
服务器因这一个客户而被阻塞,直到客户发出一个换行符或终止为止
此处一个基本概念是:
- 当一个服务器在处理多个客户时,绝对不能阻塞与只与单个客户相关的某个函数调用
- 否则可能导致服务器被挂起,拒绝为所有其他客户提供服务
这就是所谓的拒绝服务型攻击 - 它是针对服务器做些动作,导致服务器不能再为其他合法用户提供服务
可能的解决方法包括: - 使用非阻塞式I/O
- 让每个客户由单独的控制线程提供服务(创建一个子进程/线程来服务每个用户)
- 对I/O操作设置一个超时
pselect函数
pselect函数是由POSIX发明的,如今有许多Unix变种支持它
1 | #include <sys/select.h> |
pselect相对于通常的select有两个变化
pselect使用timespec结构,而不使用timeval结构
timespec结构是POSIX的又一个发明1
2
3
4struct timespec {
time_t tv_sec;
long tv_nsec;
};两个结果区别于第二个成员:新结构的该成员tv_nsec指定纳秒数
而旧结果的该成员tv_usec指定微秒数pselect函数增加了第六个参数:指向信号掩码的指针
- 该参数允许程序先进制递交某些信号
- 再测试由这些当前被禁止信号的信号处理函数设置的全局变量
- 然后调用pselect,告诉它重新设置信号掩码
下面例子:
这个程序的SIGINT信号处理函数仅仅设置全局变量intr_flag并返回
如果进程阻塞与select调用,那么从信号处理函数的返回将会导致select返回EINTR错误
然而调用select时:但是有问题,在测试intr_flag和调用select之间如果有信号发生,且如果select此时永远阻塞1
2
3
4
5
6
7
8
9if (intr_flag)
handle_intr();
if ((nready = select(...)) < 0) {
if (errno == EINTR) {
if (intr_flag)
handle_intr();
}
...
}
则该信号看起来如同丢失了一般
有了pselect后,则可以编写可靠的例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16sigset_t newmask, oldmask, zeromask;
sigemptyset(&zeromask);
sigemptyset(&newmask);
sigaddset(&newmask, SIGINT);
sigprocmask(SIG_BLOCK, &newmask, &oldmask);
if (intr_flag)
handle_intr();
if ((nready = pselect( ... , &zeromask)) < 0) {
if (errno == EINTR) {
if (intr_flag)
handle_intr();
}
...
}
在测试intr_flag变量前,阻塞SIGINT
当pselect被调用时,先以空集(zeromask)替代进程的信号掩码,在检查描述符,并可能进入睡眠
然而当pselect函数返回时,进程的信号掩码又被重置为调用pselect之前的值(SIGINT被阻塞)
(我觉得是将对应的信号处理限制在pselect执行期间 以确保pselect执行后若发生SIGINT信号事件并返回 可通过errno判断变量,信号不会丢失)
poll函数
起源于SVR3,最初局限于流设备
SVR4取消了这种限制,允许poll工作在任何描述符上
poll提供的功能与select相似,但在处理流设备时,能够提供额外的信息
1 | #include <poll.h> |
fdarray:指向一个结构数组第一个元素的指针
每一个数组元素都是一个pollfd结构,用于指定测试某个给定描述符fd的条件
1 | struct pollfd { |
要测试的条件由events成员指定
函数在相应的revents成员中返回该描述符的状态
(每个描述符有两个变量,一个调用值,一个返回结果,从而避免使用值-结果参数)
这两个成员中的每一个都由指定某个特定条件的一位或多位构成
指定events标志以及测试revents标志的一些常值可分为三部分:
- 处理输入的四个常值
- 处理输出的三个常值
- 处理错误的三个常值
- 这三个常值不能再events中设置
- 但当相应条件存在时就在revents中返回
poll识别三类数据:普通、优先级带、高优先级
这些术语均出自基于流的实现
POLLIN可被定义为POLLRDNORM和POLLRDBAND的逻辑或
POLLIN、POLLOUT为向后兼容继续保留
就TCP和UDP套接字而言,以下条件引起poll返回特定的revent
但POSIX在poll的定义中留了许多空洞(有多种方法可返回相同的条件)
- 所有正规TCP数据和所有UDP数据都被认为是普通数据
- TCP的带外数据被认为是优先级带数据
- 当TCP连接的读半部关闭时(如收到来自对端的FIN),也被认为是普通数据,随后的读操作将返回0
- TCP连接存在错误即可认为是普通数据,也可认为是错误(POLLERR)
- 无论哪种情况,随后的读操作将返回-1,并把errno设置成合适的值
- 这可用于处理诸如接受到RST或发生超时等条件
- 在监听套接字上有新的连接可用即可认为是普通数据,也可认为是优先级数据
- 大多数实现视为普通数据
- 非阻塞式connect的完成被认为是使相应套接字可写
结构数组中元素的个数是由nfds参数指定的
timeout参数指定poll函数返回前等待多长时间
是一个指定等待毫秒数的正值
可能取值:
- INFTIM:永远等待
- 0:立即返回,不阻塞进程
0:等待指定数目的毫秒数
若不在关心某个特定描述符,可把与它对应的pollfd结构的fd成员设置成一个负值
- poll函数将忽略这样的pollfd结构的events成员
- 返回时将它的revents成员值置为0
poll不存在select中的FD_SETSIZE和描述符集总最大描述符数目的问题
因为分配一个pollfd结构数组并把该数组中元素的数目通知内核成了调用者的责任
内核不在需要知道类似fd_set的固定大小的数据类型
从可移植性角度考虑,支持select的系统比poll多
POSIX还定义了pselect,能够处理信号阻塞并提供了更高时间分辨率的select的增强版本
但poll没有被定义类似的东西
基于poll的TCP回射服务器程序样例
使用poll函数的TCP服务器程序的前半部分
1 | #include "unp.h" |
为确定一个进程任何时刻能够打开的最大描述符数目并不容易
解决方法之一是:以参数_SC_OPEN_MAX调用POSIX的sysconf函数,返回动态分配一个合适大小的数组
- 然而sysconf的可能返回之一是
"indeterminate"
(不确定)
意味着仍然不得不猜测一个值
后半部分
1 | for ( ; ; ) { |
检查某个现有连接上的数据
检查的两个返回书剑POLLRDNORM和POLLERR
没有在event成员中设置第二个事件,因为它在条件成立时总是返回
检查POLLERR的原因在于:
有些实现在一个连接上接收到RST时返回的是POLLERR时间
而其他实现返回的只是POLLRDNORM事件
不论哪种情况都调用read
当有错误发生时,read返回错误
当现有连接由它的客户终止时,将fd成员置为-1
- 标题: I/O复用:select和poll函数
- 作者: GuangYing
- 创建于 : 2024-12-26 21:29:46
- 更新于 : 2024-12-26 21:31:30
- 链接: http://quebo.cn/2024/12/26/I-O复用-select和poll函数/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。