I/O多路复用API始祖
什么是 Select ?
在探究什么是 select()
之前,我们先讨论一下 select()
是因为什么原因而诞生。在很遥远的时代,Internet (互联网) 的使用并不广泛,普通日常用户占总用户量的极小部分, 并且很多网站的内容也十分简单,因此一天的访问量最多可能也就几百的浏览次数。 但随着计算机技术的发展,互联网变得越来越普及,网络用户数开始激增。
因此网络服务器应用程序对性能的要求也随之而提高。其实不难理解,互联网普及之后,肯定会有更多的人想要使用网络和感受网络带来的全新体验。因此各个门户的内容也开始变得丰富,这意味着数据的传输将会变得更复杂。
网络的数据传输都要涉及到操作系统的网络I/O。在没有 I/O Multiplexing (I/O复用) 的时期,网络服务器应用程序是没有办法同时感知到多个网络链接的到来,也就是说程序没有办法在一段很短的时间内感应到多个I/O事件和各种异常情况。有了I/O复用技术之后,我们可以将此技术应用到网络程序中,从而改善网络服务器应用程序和网络客户端应用程序的表现。
为什么要用 Select ?
select()
是第一个I/O复用技术的实现。它的移植性很好,在不同的操作系统平台上都能见到它的身影。但除此之外,我找不到其他使用 select()
的理由 。当然,如果一个程序对性能和响应时间没有更高的要求的话,照样可以使用 select()
,因为它并不差。
其实 select()
的思想很简单,它无非就是返回一段时间内对应的各个检测事件中就绪的文件描述符的数量。这句话听起来有点绕口,但不要紧,我会举例说明。假设你有 10 个 file descriptor (fd, 文件描述符) ,这些文件描述符可以是 socket (网络套接字) 也可以是 file (普通的文件) 或者是 standard input (标准输入)。你可以通过 select()
提供的一系列函数去检查这 10 个文件描述符中哪些文件描述符已经处于可读的就绪状态。不仅读事件,select()
还可以同时监测写事件和异常事件,我在后面会详细说明。
如何使用 Select ?
我们先来看看 select()
的声明:
int select(int max_fd_plus_1, fd_set *read_fds, fd_set *write_fds, fd_set *except_fds, struct timeval *timeout);
第一个参数 max_fd_plus_1
, 顾名思义,它的值就是当前所有要检测的文件描述符中的最大值加上1。为什么要加1呢?那是因为 select()
采用了 bit vector (二进制位数组) 的数据结构来存储它所需要检测的文件描述符。假设读事件文件描述符是 {1, 3, 7},并且其它两个事件的文件描述符都不存在,那么 max_fd_plus_1
的值应该就是8。这样 select()
就知道检查范围,也就是第0个bit到第7个bit,而不需要把整个二进制位数组都检查一遍。
后三个参数 fd_set *read_fds, fd_set *write_fds, fd_set *except_fds
分别代表的是读事件二进制位数组、写事件二进制位数组和异常事件二进制位数组。它们把程序感兴趣的事件对应的文件描述符保存起来,程序调用 select()
检测每个数组中的文件描述符就绪状态。
最后一个参数 struct timeval *timeout
用来控制等待时间,结构体的定义如下。第一个数据成员 tv_sec
是秒级别,而第二个数据成员 tv_usec
是微妙级别。如果你不想 select()
有等待时间限制的话,这个参数可以传进一个 nullptr
。这就意味着,当某一个二进制位数组中的一个文件描述符处于就绪状态 select()
才返回 - 永远等待。如果 tv_sec
和 tv_usec
的值都不为0,那么只要时间一到,无论有没有就绪文件描述符,select()
照样返回 - 等待一段时间。如果 tv_sec
和 tv_usec
的值都为0的话,select() 直接返回 - 根本不等待。
struct timeval
{
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
接下来我们看看要如何给 select()
安排工作内容。select()
提供了4个工作函数。
void FD_ZERO(fd_set *fdset)
- 清空二进制位数组。
void FD_SET(int fd, fd_set *fdset)
- 把文件描述符加到二进制位数组。
void FD_CLR(int fd, fd_set *fdset)
- 把文件描述符在二进制位数组中的值清空。
int FD_ISSET(int fd, fd_set *fdset)
- 检查文件描述符在二进制位数组中的值是否为空。
就绪条件
引用自 UNP 第6章
读就绪
a) 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。对这样的套接字执行读操作不会阻塞并将返回一个大于0的值(也就是返回准备好读入的数据)。我们可以使用SO_RCVLOWAT套接字选项设置该套接字的低水位标记。对于TCP和UDP套接字而言,其默认值为1。
b) 该连接的读半部关闭(也就是接收了FIN的TCP连接)。对这样的套接字的读操作将不阻塞并返回0(也就是返回EOF)。
c)该套接字是一个监听套接字且已完成的连接数不为0。对这样的套接字的accept通常不会阻塞,不过我们将在15.6节讲解accept可能阻塞一种时序条件。
d) 其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。这些待处理错误(pending error)也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。
写就绪
a) 该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且或者该套接字已连接,或者该套接字不需要连接(如UDP套接字)。这意味着如果我们把这样的套接字设置成非阻塞(第16章),写操作将不阻塞并返回一个正值(如由传输层接受的字节数)。我们可以使用SO_SNDLOWAT套接字选项来设置该套接字的低水位标记。对于TCP和UDP套接字而言,其默认值通常为2048。
b) 该连接的写半部关闭。对这样的套接字的写操作将产生SIGPIPE信号(5.12节)。
c) 使用非阻塞式connect的套接字已建立连接,或者connect已经以失败告终。
d) 其上有一个套接字错误待处理。对这样的套接字的写操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。这些待处理的错误也可以通过指定 SO_ERROR套接字选项调用getsockopt获取并清除。
我会用一份简单的服务器代码作为 select()
的使用案例,先上代码。下面会解释每一行做了什么。
#include <sys/socket.h>
#include <sys/select.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
const int MAX_CLIENT_SIZE = 40;
const int MAX_BUFF_SIZE = 256;
class Client
{
public:
Client