对于网络编程,非阻塞 IO 的第二部分是就绪选择,即能够选择读写时不阻塞的 socket。为了完成就绪选择,需要将不同的通道注册到一个 Selector 对象,每个通道分配有一个 SelectionKey。然后程序可以询问这个 Selector 对象,哪些通道已经准备就绪可以无阻塞地完成你希望完成的操作了。
Selector 类
Selector 一般称为选择器或者翻译为多路复用器,它能够检测一个或多个 NIO Channel,并且能够知晓通道是否为诸如连接、读、写等事件做好了准备。这样通过单个线程就可以管理多个 Channel,也就是管理多个网络连接了。
我们可以只用一个线程处理所有的通道,好处是只需要更少的线程资源。但现代操作系统和 CPU 在多任务方面表现的越来越好,所以多线程的开销随着时间的推移会变得越来越小。实际上,如果一个 CPU 有多个核,不使用多任务可能是在浪费 CPU 的能力。
1. 创建
Selector 类提供了静态工厂方法 open() 来创建一个新的 Selector 对象。
public static Selector open() throws IOException
在创建 Selector 时,程序会根据操作系统版本选择使用哪种 I/O 复用函数。在 JDK1.5 版本中,如果程序运行在 Linux 操作系统且内核版本在 2.6 以上,NIO 中会选择 epoll 来替代传统的 select/poll,这也极大地提升了 NIO 通信的性能。
2. 注册
为了能够让 Selector 对象管理多个 Channel,我们必须将具体的 Channel 对象注册到 Selector 中,并声明感兴趣的事件类型。Selector 本身没有注册通道的方法,register() 方法是在 SelectableChannel 类中声明的。
Channel 与 Selector 一起使用时,通道必须处于非阻塞模式下。并非所有通道都是可选择的,比如 FileChannel 就不可选择,因为它没有实现 SelectableChannel 接口。不过所有网络通道都是可选择的,我们将选择器传递给通道的 registry() 方法,就可以向选择器注册这个通道:
public final SelectionKey register(Selector sel, int ops) throws ClosedChannelException
public SelectionKey register(Selector sel, int ops, Object att) throws ClosedChannelException
第一个参数是通道要向哪个选择器注册,第二个参数是 SelectionKey 类中的一个命名常量,用来标识通道初始的感兴趣的操作。SelectionKey 定义了四个常量:
- OP_READ = 1 << 0
- OP_WRITE = 1 << 2
- OP_CONNECT = 1 << 3
- OP_ACCEPT = 1 << 4
这些都是位标志整形常量(1、2、4 等)。因此,如果一个通道需要在同一个选择器中关注多个事件,例如读和写一个 socket,只要在注册时利用位 “或” 操作符(|)组合这些常量就可以了。
channel.configureBlocking(false);
channel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
第三个参数是可选的,这是键的附件,通常用于存储连接的状态。例如,如果要实现一个 Web 服务器,可能要附加一个 FileInputStream 或 FileChannel,这个流或通道连接到服务器提供给客户端的本地文件。客户端能够通过该附件操作服务器的本地文件。
此外,通道还提供了 validOps() 方法用来返回该通道支持的 I/O 操作集的位图:
public abstract int validOps();
对于 ServerSocketChannel 来说,accept 是唯一的有效操作;对于 SocketChannel 来说,有效操作包括读、写和连接;对于 DatagramChannel 来说,只有读、写操作是有效的。
3. 选择
不同的通道注册到选择器后,就可以随时查询选择器,找出哪些通道已经准备好可以进行处理。通道可能已经准备好完成某些操作,但对另一些操作还没有准备好。比如一个通道已经准备就绪可以读取,但还不能写入。
有如下三个方法可以选择就绪的通道,返回已经准备好可以进行处理的通道数量:
public abstract int selectNow() throws IOException;
public abstract int select() throws IOException;
public abstract int select(long timeout) throws IOException;
这三个方法的区别在于寻找就绪通道等待的时间。第一个 selectNow() 方法会完成非阻塞选择,如果当前没有准备好要处理的连接,它会立即返回。
后两个选择方法都是阻塞的,select() 方法在返回前会等待,直到至少有一个注册的通道准备好可以进行处理或者其他线程调用了 Selector 的 wakeup() 方法唤醒了阻塞在 select() 方法上的线程。第三个 select() 方法在返回 0 之前只等待不超过 timeout 毫秒。
4. 获取
当知道有通道已经准备好处理时,可以使用 selectedKeys() 方法获取就绪的通道:
public abstract Set<SelectionKey> selectedKeys();
selectedKeys() 方法会返回相关事件已被 Selector 捕获的 SelectionKey 的集合,当执行 Selector 的 select() 方法时,如果与 SelectionKey 相关的事件发生了,则这个 SelectionKey 就会被加入到该集合当中。
我们在迭代处理返回的这个集合时,要依次处理各个 SelectionKey。如果这个键已经得到了处理,我们需要从迭代器中删除这个键,否则选择器在以后循环时还会一直通知你有这个键。
此外,我们还可以通过 keys() 方法获取当前所有向 Selector 注册的 SelectionKey 的集合:
public abstract Set<SelectionKey> keys();
实际上,在 Selector 对象中维护了三个 SelectionKey 的集合,如下图所示:
- all-keys 集合:当前所有向 Selector 注册的 SelectionKey 的集合,keys() 方法返回值。
- selected-keys 集合:相关事件已被 Selector 捕获的 SelectionKey 的集合,selectedKeys() 方法返回值。当执行 select() 方法时,如果与 SelectionKey 相关的事件发生了,则这个 SelectionKey 就会被加入到 selected-keys 集合中。
- cancel-keys 集合:已经被取消的 SelectionKey 的集合。Selector 没有提供访问这种集合的方法。
5. 关闭
最后,当准备关闭服务器或者不再需要选择器时,应当将它关闭。
public abstract void close() throws IOException;
这个步骤会释放与 Selector 关联的所有资源。它会取消向选择器注册的所有键,如果有其他线程正阻塞在该选择器的 select() 方法,close() 方法会中断这个被阻塞的线程。
SelectionKey 类
SelectionKey 对象相当于通道的指针,表示 Channel 与 Selector 之前的关联关系。在 SelectionKey 对象的有效期间内,Selector 会一直监控与 SelectionKey 对象相关的事件。如果事件发生,就会把 SelectionKey 对象加入到 selected-keys 集合中。它还可以保存一个对象附件,一般用来存储这个通道上的连接的状态。
1. 测试
将一个通道注册到一个选择器时,registry() 方法会返回保存这个关联关系的 SelectionKey 对象。不过,我们不需要保存这个引用,selectedKeys() 方法可以再次返回相同的对象。当从返回的键集合中获取一个 SelectionKey 对象时,通常首先要测试这些键能进行哪些操作:
public final boolean isReadable();
public final boolean isWritable();
public final boolean isConnectable();
public final boolean isAcceptable();
这个测试并不是必须的,但如果选择器确实要测试多种就绪状态,就要在操作前先测试通道处于哪个操作就绪状态。也有可能通道准备好了可以完成多个操作,如果不想一个一个的测试,还可以通过 readyOps() 方法获取所有已经准备就绪的事件。
public abstract int readyOps();
2. 获取
一旦了解了与键关联的通道准备好完成何种操作时,就可以用 channel() 方法来获取这个通道:
public abstract SelectableChannel channel();
如果在保存状态信息的 SelectionKey 中存储了一个对象(通道调用 registry() 时传入过一个附件),此时就可以通过 attachment() 方法获取该对象:
public final Object attachment()
最后,如果想要结束 Channel 与 Selector 的关联关系,就要撤销其 SelectionKey 对象的注册,这样选择器就不会浪费资源再去查询它是否准备就绪。
public abstract void cancel();
不过,只有在未关闭通道时,这个步骤才有必要。如果关闭通道,会自动在所有选择器中撤销对应这个通道的所有键的注册。类似的,关闭选择器也会使这个选择器中的所有键都失效。
Linux 多路复用实现
我们可以把标准输入、套接字等都看做 I/O 的一路,多路复用的意思,就是在任何一路 I/O 有“事件”发生的情况下,通知应用程序去处理相应的 I/O 事件,这样我们的程序就变成了“多面手”,在同一时刻仿佛可以处理多个 I/O 事件。如果标准输入有数据,会立即从标准输入读入数据,通过套接字发送出去;如果套接字有数据可以读则立即可以读出数据。
1. select
select 函数就是这样一种常见的 I/O 多路复用技术。使用 select 函数,可以通知内核挂起进程,当一个或多个 I/O 事件发生后,再将控制权返还给应用程序,由应用程序进行 I/O 事件的处理。
select 函数的使用方法有点复杂,我们先看一下它的声明:
int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
在这个函数中,maxfd 表示的是待测试的描述符基数,它的值是待测试的最大描述符加 1。紧接着的是三个描述符集合,分别是读描述符集合 readset、写描述符集合 writeset 和异常描述符集合 exceptset,这三个分别通知内核,在哪些描述符上检测数据可以读、可以写和有异常发生。
最后一个参数是 timeval 结构体时间,这个参数设置成不同的值会有不同的可能:
- 如果设置成空表示如果没有 I/O 事件发生则 select 一直等待下去。
- 如果设置一个非零的值,表示等待固定的一段时间后从 select 阻塞调用中返回。
- 如果设置成 0 表示根本不等待,检测完毕立即返回。
2. poll
select 方法是多个 UNIX 平台支持的非常常见的 I/O 多路复用技术,它通过描述符集合来表示检测的 I/O 对象,通过三个不同的描述符集合来描述 I/O 事件 :可读、可写和异常。但是 select 有一个缺点,那就是所支持的文件描述符的个数是有限的。在 Linux 系统中 select 的默认最大值为 1024。
那么有没有别的 I/O 多路复用技术可以突破文件描述符个数限制呢?当然有,这就是 poll 函数。poll 是除了 select 之外,另一种普遍使用的 I/O 多路复用技术,和 select 相比,它和内核交互的数据结构有所变化,也突破了文件描述符的个数限制。
我们来看一下 poll 函数的方法声明:
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
这个函数里面输入了三个参数,第一个参数是一个 pollfd 的数组。其中 pollfd 的结构如下:
struct pollfd {
int fd; /* file descriptor */
short events; /* events to look for */
short revents; /* events returned */
};
这个结构体由三个部分组成,首先是描述符 fd,然后是描述符上待检测的事件类型 events,这个 events 可以表示多个不同的事件,通过使用二进制掩码位操作来完成。例如,POLLIN 和 POLLOUT 可以表示读和写事件。
和 select 非常不同的地方在于,poll 每次检测之后的结果不会修改原来的传入值,而是将结果保留在 revents 字段中,这样就不需要每次检测完都得重置待检测的描述字和感兴趣的事件。events 类型的事件可分为两大类:
- 第一类是可读事件,是系统内核通知应用程序有数据可以读,通过 read 函数执行操作不会被阻塞。
- 第二类是可写事件,是系统内核通知套接字缓冲区已准备好,通过 write 函数执行写操作不会被阻塞。
第二个参数 nfds 描述的是数组 fds 的大小,就是向 poll 申请的事件检测的个数。
最后一个参数 timeout 描述了 poll 的行为。如果是一个负数,表示在有事件发生之前永远等待;如果是 0 表示不阻塞进程,立即返回;如果是一个正数,表示 poll 调用方等待指定的毫秒数后返回。
和 select 函数对比一下,我们发现 poll 函数和 select 不一样的地方就是,在 select 里面,文件描述符的个数已经随着 fd_set 的实现而固定,没有办法对此进行配置;而在 poll 函数里,我们可以控制 pollfd 结构的数组大小,这意味着我们可以突破原来 select 函数最大描述符的限制,在这种情况下,应用程序调用者需要分配 pollfd 数组并通知 poll 函数该数组的大小。
3. epoll
这里有放置了一张图,这张图来自 The Linux Programming Interface。这张图直观地为我们展示了 select、poll、epoll 几种不同的 I/O 复用技术在面对不同文件描述符大小时的表现差异。
从图中可以看到,随着文件描述符的增大,常规的 select 和 poll 方法性能逐渐变得很差,而 epoll 的性能是最好的。因为 epoll 进行了许多改进,具体内容总结如下:
- 支持一个进程打开的 socket 描述符不受限制(仅受限于操作系统的最大文件句柄数)
select 最大的缺陷就是单个进程所打开的 fd 是有一定限制的,它由 FD_SETSIZE 设置,默认值是 1024,这对于那些需要支持上万个 TCP 连接的大型服务器来说显然太少了。而 epoll 并没有这个限制,它所支持的 fd 上限是操作系统的最大文件句柄数,具体可通过 cat /proc/sys/fs/file-max 查看。
- IO 效率不会随着 fd 数目的增加而线性增加
传统 select、poll 的另一个致命缺点,就是当你拥有一个很大的 socket 集合时,由于网络延时或链路空闲,任一时刻只有少数 socket 是活跃的,但 select、poll 每次调用都会线性扫描全部集合,导致效率呈线性下降。
epoll 不存在这个问题,它只会对活跃的 socket 进行操作——这是因为在内核的实现中,epoll 是根据每个 fd 上面的 callback 函数实现的,只有活跃的 socket 才会去主动调用 callback 函数(数据到达网卡时触发回调),其他 idle 状态的 socket 则不会。在这一点上,epoll 实现了一个伪 AIO。
如果所有的 socket 都处于活跃状态,epoll 并不会比 select、poll 效率高太多,如果过多使用效率还有稍微的降低。一旦面对这种大量连接只有少量 socket 活跃的场景,epoll 的效率就远在 select、poll 之上了。
- 使用 mmap 加速内核与用户空间的消息传递
无论是 select、poll 还是 epoll 都需要内核把 fd 消息通知给用户空间,如何避免不必要的内存复制就非常重要了,epoll 是通过内核和用户空间 mmap 同一块内存来实现的。
- epoll 的 API 更加简单
使用 epoll 进行网络程序的编写,通常需要四个步骤,分别是创建一个 epoll 描述符(epoll_create)、添加监听事件(epoll_ctl)、阻塞等待所监听的事件发生(epoll_wait)、关闭 epoll。
3.1 epoll_create
epoll_create() 方法创建了一个 epoll 实例,这个 epoll 实例被用来调用 epoll_ctl 和 epoll_wait,如果这个 epoll 实例不再需要,比如服务器正常关机,需要调用 close() 方法释放 epoll 实例,这样系统内核可以回收 epoll 实例所分配使用的内核资源。
// 返回值: 若成功返回一个大于0的值,表示epoll实例;若返回-1表示出错
int epoll_create(int size);
int epoll_create1(int flags);
关于这个参数 size,在一开始的 epoll_create 实现中,是用来告知内核期望监控的文件描述字大小,然后内核使用这部分的信息来初始化内核数据结构,在新的实现中,这个参数不再被需要,因为内核可以动态分配需要的内核数据结构。我们只需要每次将 size 设置成一个大于 0 的整数就可以了。
3.2 epoll_ctl
在创建完 epoll 实例之后,可以通过调用 epoll_ctl 往这个 epoll 实例中增加或删除监控的事件。
// 返回值: 若成功返回0;若返回-1表示出错
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
第一个参数 epfd 是刚刚调用 epoll_create 创建的 epoll 实例描述字,可以简单理解成是 epoll 句柄。
第二个参数表示增加还是删除一个监控事件,它有三个选项可供选择:
- EPOLL_CTL_ADD: 向 epoll 实例注册文件描述符对应的事件;
- EPOLL_CTL_DEL:向 epoll 实例删除文件描述符对应的事件;
- EPOLL_CTL_MOD: 修改文件描述符对应的事件。
第三个参数是注册的事件的文件描述符,比如一个监听套接字。
第四个参数表示的是注册的事件类型,并且可以在这个结构体里设置用户需要的数据,其中最为常见的是使用联合结构里的 fd 字段,表示事件所对应的文件描述符。
3.3 epoll_wait
epoll_wait() 函数类似之前的 poll 和 select 函数,调用者进程被挂起,在等待内核 I/O 事件的分发。
// 返回值: 成功返回的是一个大于0的数,表示事件的个数;返回0表示的是超时时间到;若出错返回-1.
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
这个函数的第一个参数是 epoll 实例描述字,也就是 epoll 句柄。
第二个参数返回给用户空间需要处理的 I/O 事件,这是一个数组,数组的大小由 epoll_wait 的返回值决定,这个数组的每个元素都是一个需要待处理的 I/O 事件。
第三个参数是一个大于 0 的整数,表示 epoll_wait 可以返回的最大事件值。
第四个参数是 epoll_wait 阻塞调用的超时值,如果这个值设置为 -1,表示不超时;如果设置为 0 则立即返回,即使没有任何 I/O 事件发生。