在实现TCP的echo服务的那节中,客户端同时处理两个输入:标准输入和TCP套接字。会遇到这样的问题:客户在阻塞于(标准输入上的)fgets
调用期间,服务器进程会被杀死,服务器TCP虽然正确地给客户TCP发送了一个 FIN ,但是既然客户进程正阻塞于标准输入读入过程,它将看不到这个 EOF ,直到从套接字读时为止(可能过了很长时间)。这样的进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O 条件就绪(也就是说输入已准备好被读取,或者描述符已能承接更多的输入),它就通知进程。这称为I/O复用(I/O multiplexing),在linux下由select
、poll
或epoll
实现。
I/O复用典型使用在下列网络应用场合。
- 当客户处理多个描述符(通常是交互式输入和网络套接字)时,必须使用I/O复用。
- 如果一个TCP服务器既然处理监听套接字,又要处理已连接套接字,一般就要使用I/O复用。
- 如果一个服务器既要处理TCP,又要处理UDP,一般就要使用I/O复用。
- 如果一个服务器要处理多个服务或者多个协议,一般就要使用I/O复用。
I/O复用模型
有了I/O复用(I/O multiplexing),就可以调用select
、poll
或epoll
,阻塞在这几个系统调用中的某一个之上,而不是阻塞在真正的I/O。
select函数
该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。
1 |
|
timeout
告知内核等待所指定描述符中的任何一个就绪可花多长时间。其timeval
结构用于指定这段时间的秒数和微秒数。
1 | struct timeval { |
这个参数有以下三种可能。
- 永远等待下去:仅在有一个描述符准备好I/O时才返回。为此,该参数设置为空指针。
- 等待一段固定时间:在有一个描述符准备好I/O时返回,但是不超过由该参数所指向
timeval
结构中指定的秒数和微秒数。 - 根本不等待:检查描述符后立即返回,这称为轮询(polling)。为此,该参数必须指向一个
timeval
结构。而且其中的定时器值(由该结构指定的秒数和微秒数)必须为0。
前两种情形的等待通常会被进程在等待期间捕获的信号中断,并从信号处理函数返回。
尽管timeval
结构运行我们指定了一个微妙级的分辨率,然而内核支持的真实分辨率往往粗糙得多。举例来说,许多Unix内核把超时值向上舍入10ms的倍数。另外还涉及调度延迟,也就是说定时器时间到后,内核还需花一点时间调度相应进程运行。
中间的三个参数readset
、writeset
和exceptset
指定要让内核测试读、写和异常条件的描述符。目前支持的异常条件只有两个:
- 某个套接字的带外数据的到达。
- 某个已置为分组模式的伪终端存在可从其主端读取的控制状态信息。
select
使用描述符集,通常是一个整数数组,其中每个整数中的每一位对应一个描述符。举例来说,假设使用32位整数,那么该数组的第一个元素对应于描述符0~31,第二个元素对应于描述符32~63,以此类推。所有这些实现细节都与应用程序无关,它们隐藏在名为fd_set
的数据类型和以下四个宏中:
1 | void FD_ZERO(fd_set *fdset) /* clear all bits in fdset */ |
描述符集的初始化非常重要,因为作为自动变量分配的一个描述符集如果没有初始化,那么可能发生不可预期的后果。
select
函数的中间三个参数readset
、writeset
、exceptset
中,如果我们对某一个的条件不感兴趣,就可以把它设为空指针。事实上这三个指针均为空,我们就有了一个比Unix的sleep函数更为精确的定时器(sleep睡眠以秒为最小单位)。
maxfdp1
参数指定待测试的描述符个数,它的值是待测试的最大描述符加1,描述符0,1,2…一直到maxfdp1-1
均将被测试。
select
函数修改由指针readset
、writeset
、exceptset
所指向的描述符集,因而这三个参数都是值-结果参数。调用该函数时,我们指定所关心的描述符的值,该函数返回时,结果将指示哪些描述符已就绪。该函数返回后,我们使用FD_ISSET
宏来测试fd_set
数据类型中的描述符。描述符集内任何未就绪描述符对应的位返回时均清成0。为此,每次重新调用select
函数时,我们都得再次把所有描述符集内所关心的位均置为1。
该函数的返回值表示跨所有描述符集的已就绪的总位数。如果在任何描述符就绪之前定时器到时,那么返回0。返回-1表示出错(这是可能发生的,譬如本函数被一个所捕获的信号中断)。
描述符就绪的条件
满足下列四个条件中的任何一个时,一个套接字准备好读。
- 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。对这样的套接字执行读操作不会阻塞并将返回一个大于0的值(也就是返回准备好读入的数据)。我们可以使用
SO_RCVLOWAT
套接字选项设置该套接字的低水位标记。对于TCP和UDP套接字而言,其默认值为1。 - 该连接的读半部关闭(也就接收了FIN的TCP连接)。对这样的套接字的读操作将不阻塞返回0(也就是返回EOF)。
- 该套接字是一个监听套接字且已完成的连接数不为0。对这样的套接字的
accept
通常不会阻塞。 - 其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1(也就是一个错误),同时把
errno
设置成确切的错误条件。这些待处理错误也可以通过制定SO_ERROR
套接字选项调用getsockopt
获取并清除。
下列四个条件中的任何一个满足时,一个套接字准备好写。
- 该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且或者该套接字设置成非阻塞,写操作将不阻塞并返回一个正值(例如由传输层接受的字节数)。我们可以使用
SO_SNDLOWAT
套接字选项来设置该套接字的低水位标记。对于TCP和UDP套接字而言,其默认值通常为2048
。 - 该连接的写半部关闭。对这样的套接字的写操作将产生
SIGPIPE
信号。 - 使用非阻塞式
connect
的套接字已建立连接,或者connect
已经以失败告终。 - 其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1(也就是一个错误),同时把
errno
设置成确切的错误条件。这些待处理错误也可以通过制定SO_ERROR
套接字选项调用getsockopt
获取并清除。
select的最大描述符数
头文件<sys/select.h>
中定义的FD_SETSIZE
常值是数据类型fd_set
中的描述符总数,其值通常是1024
。不同的内核可能不同。
使用select的echo客户端程序: str_cli函数(修订版)
使用select
重写了str_cli
函数:这样服务器进程一终止,客户就能马上得到通知。客户端的套接字上的三个条件处理如下:
- 如果对端TCP发送数据,那么该套接字变为可读,并且
read
返回一个大于0的值(即读入数据的字节数)。 - 如果对端TCP发送一个 FIN (对端进程终止),那么该套接字变为可读,并且
read
返回0( EOF )。 - 如果对端TCP发送一个 RST (对端主机崩溃并重新启动),那么该套接字变为可读,并且
read
返回-1,而errno
中含有确切的错误码。
代码如下:
1 |
|
str_cli函数
- 定义一个可读性的描述符集,该集合由
FD_ZERO
初始化,并用FD_SET
打开两位:一个对应于标准I/O文件指针fp,一位对应于套接字sockfd
。fileno
函数把标准I/O文件指针转换为对应的描述符。计算出两个描述符中的较大值后,调用select
。在该调用中,写集合指针和异常集合指针都是空指针。最后一个参数(时间限制)也是空指针,因为希望调用阻塞到某个描述符就绪为止。 - 如果标准输入可读,那就先用
fgets
读入一行文本,再用writen
把它写到套接字。 - 如果在
select
返回时套接字是可读的,那就先用readline
读入服务器返回的数据,再用fputs
输出它。
批量输入
上面的str_cli
函数还是有问题。假设发出第一个请求后,立即发出下一个,紧接着再下一个。客户端能够以网络可以接受它们的最快速度持续发送请求,并且能够以网络可提供给它们的最快速度处理应答。如图:
在写完最后一个请求后,我们并不能立即关闭连接,因为管道中还有其他的数据和应答。问题在于对标准输入中的EOF的处理:str_cli
函数就此返回到main
函数,而main
函数随后终止。然而在批量方式下,标准输入中的EOF并不意味着我们同时也完成了从套接字的读入,可能仍有请求在去往服务器的路上,或者仍有应答在返回客户的路上。
我们需要的是一种关闭TCP连接其中一半的方法。也就是说,给服务器发送一个 FIN ,告诉服务器客户端已经完成了数据发送,但是仍然保持套接字打开以便读取。这可以由shutdown
函数来完成。
一般地说,为提升性能而引入缓冲机制增加了网络应用程序的复杂性,考虑有多个来自标准输入的文本输入行可用的情况。select
用fgets
读取输入,这又转而使已可用的文本输入行被读入到stdio
所用的缓冲区中。然而fgets
只返回其中第一行,其余输入行仍在stdio
缓冲区中。fgets
返回的单个输入行写给服务器,随后select
再次被调用以等待新的工作,而不管stdio
缓冲区中还有额外的输入待消费。究竟原因在于select
不知道stdio
使用了缓冲区——它只是从read
系统调用的角度指出是否有数据可读,而不是从fgets
之类调用的角度考虑。基于上述原因,混合使用stdio
和select
被认为是非常容易犯错误的,在这样做时必须极其小心。
shutdown函数
终止网络连接的通常方法是调用close
函数。不过close
有两个限制,却可以使用shutdown
来避免。
close
把描述符的引用计数减1,仅在该计数变为0时才关闭套接字。使用shtudown
可以不管引用计数就激发TCP的正常连接终止序列。close
终止读和写两个方向的数据传送。既然TCP连接是全双工的,有时候我们需要告知对端我们已经完成了数据发送,即使对端仍有数据要发送给我们。这就是上面遇到的str_cli
函数在批量输入时的情况。
1 |
|
该函数的行为依赖于howto
参数的值。
SHUT_RD 关闭连接的读这一半——套接字中不再有数据可接收,而且套接字接收缓冲区中的现有数据都被丢弃。进程不能再对这样的套接字调用任何读函数。对一个TCP套接字这样调用shutdown
函数后,由该套接字接收的来自对端的任何数据都被确认,然后悄然丢弃。
SHUT_WR 关闭连接的写这一半——对于TCP套接字,这称为半关闭(half-close)。当前留在套接字发送缓冲区中的数据将被发送掉,后跟TCP的正常连接终止序列。不管套接字描述符的引用计数是否等于0,这样的写半部关闭照样执行。进程不能再对这样的套接字调用任何写函数。
SHUT_REWR 连接的读半部和写半部都关闭——这与调用shutdown
两次等效:第一次调用SHUT_RD,第二次调用指定SHUT_WR。
str_cli函数(再修订版)
代码如下:
1 | static void str_cli(FILE *fp, int sockfd) { |
stdineof
是一个初始化为0的新标志。只要该标志位0,则每次在主循环中设置标准输入为可读。- 当在套接字上读到EOF时,如果已在标准输入上遇到EOF,那就是正常的终止,于是函数返回。如果在标准输出上没有遇到EOF,那么服务器进程已过早终止。
- 在标准输入上遇到EOF时,把新标志
stdineof
置为1,并把第二个参数指定为SHUT_WR
来调用shutdown
以发送FIN。
使用select的echo服务器程序
代码如下:
1 |
|
- 调用
select
等待某个事件发生:新客户连接的建立、或是数据、 FIN 、 RST 的到达。 - 如果监听套接字变为可读,那么已建立了一个新的连接。调用
accept
接收新的连接,使用client
数组中的第一个未用项记录这个已连接的描述符。检查select
的返回值是否还有就绪的描述符。 - 对于每个现有的客户连接,要测试其描述符是否在
select
返回的描述符集中。如果是就从客户端读入数据并返回给它。如果客户端关闭了连接,那么将返回0,则清除该描述符。