关于 I/O 模型的文章比较多,参考多篇后理解上仍然不太满意,终需自己整理一次,也是编写高吞吐量高性能网络接口模块的基础。这里所说的主要针对网络 I/O,近几年面对越来越大的用户请求量,如何优化这些步骤直接影响接口用户体验。
一、前言
I/O 模型有几个名词的解释 (比较容易混淆):
阻塞与非阻塞:区别在于调用函数时,是否立即返回还是让线程等待。阻塞模型需要等待操作完成,而非阻塞模型则是立即返回 (未准备好则返回一个错误码)。
同步与非同步:区别在于网络数据从内核拷贝到用户空间时是否需要用户线程参与等待。UNIX 网络 I/O 模型分类有几种 (参考 UNIX Network Programming Volume.1.3rd.Ed):
1. Blocking I/O
2. Nonblocking I/O
3. I/O multiplexing (select and poll)
4. Signal driven I/O (SIGIO)
5. Asynchronous I/O (the POSIX aio_functions) 它们有两个独特的阶段差别 :
1. 等待数据准备好. (阻塞 / 非阻塞的差异)
2. 数据从内核复制到用户空间. (同步 / 非同步的差异)
理解上面 2 个阶段,对后面的解释就很容易明白。二、I/O 模型详解
2.1 Blocking I/O
下图以 UDP 服务端调用 recvfrom 为例描述线程等待过程 (TCP 稍微复杂):
主处理线程一直阻塞到有用户数据,并且数据从内核拷贝到用户空间,也就是说主处理线程同一时间只能处理一个用户请求:
伪代码类似如下:
- while (1)
- {
- Socket clientSock = serverSock.accept();
- processRequest(clientSock);
- }
- void processRequest(Socket clientSock)
- {
- read(...);
- write(...);
- }
在复杂的网络环境中,经常出现一个 "慢速" 客户端。也就是说这个客户端数据到达很慢,在 TCP 网络编程中,如果以此方式等待足够的数据 (根据协议定义),则会严重影响到其他客户端的处理等待时间。
由此进行改善的模型就是使用线程池,伪代码如下:
- while (1)
- {
- Socket clientSock = serverSock.accept();
- threadPool.execute(new Task(processRequest(clientSock));
- }
- void processRequest(Socket clientSock)
- {
- read(...);
- write(...);
- }
线程池模式使得主线程能处理更多的客户端,N 个客户端使用 M 个线程 (N:M),主线程不会被一个慢客户端阻塞,但是处理能力仍然是比较有限的。2.2 Nonblocking I/O
将 socket 设置为非阻塞模式,告诉内核如果操作不能立即完成则返回一个错误码而不是等待,描述图如下:
如下图,主线程一直尝试调用网络函数,直到数据准备好,该模型的缺陷就是 "忙等待",CPU 空转浪费系统资源。此模式极少使用,在此仅用于介绍。
2.3 I/O multiplexing (多路复用)
Linux 中提供 select/poll 系统调用实现,线程阻塞在此方法上,监测多个 socket fd(file descriptor) 是否就绪,一旦有事件发生则顺序扫描具体是哪个就绪,但这样做会比较费时,模型如下:
在 Linux kernel 2.6 + 提供了 epoll 实现,使用驱动方式替代顺序扫描,哪个 fd 有事件发生就返回哪一个 (避免了 select/poll 的只要有一个发生就扫描全部找出是哪个,新的内核也可能对这模式实现做了一些优化)。
在 ORACLE JDK 有如下源码对内核做判断:
- package sun.nio.ch;
- import...public class DefaultSelectorProvider {
- public static SelectorProvider create() {......
- if ("Linux".equals(str1)) {
- String str2 = (String) AccessController.doPrivileged(new GetPropertyAction("os.version"));
- String[] arrayOfString = str2.split("\\.", 0);
- if (arrayOfString.length >= 2) {
- try {
- int i = Integer.parseInt(arrayOfString[0]);
- int j = Integer.parseInt(arrayOfString[1]);
- if ((i > 2) || ((i == 2) && (j >= 6))) {
- return new EPollSelectorProvider();
- }
- } catch(NumberFormatException localNumberFormatException) {}
- }
- }
- return new PollSelectorProvider();
- }
- }
关于 epoll 的实现有专门文章详解,在此不做细说,另外一个 epoll 使用 mmap 内存映射避免内存复制损耗。 2.4 Asynchronous I/O (异步)
异步 I/O 告诉内核开始某个操作,在内核完成后 (包括数据从内核拷贝到用户空间) 通知我们,如下图:
在 ORACLE JDK 1.7 + 提供异步 I/O 的实现 AsynchronousChannel,相对于原 Selector 实现 I/O 复用模式简单很多。
在这几种模型中,只有异步 I/O 是用户线程不参与数据从内核拷贝到用户空间这个过程。2.5 几种 I/O 模型的对比
如下图,它们在 [等待数据] 和 [从内核复制数据到用户空间] 的差异:
从上图看出,只有 Asynchronous I/O 不参与内核数据复制。
三、实例举例说明
3.1 针对 BIO 拒绝服务攻击
BIO 中 Boss 线程处理请求后交给连接池 Worker 处理,但线程池有限,能轻易导致服务异常。比如针对 Tomcat 默认配置进行 HTTP 慢速攻击:
声明一个 Content-Length 为 300 的 POST 包,开启 300 个线程请求,每个请求中每秒发送 1 byte。默认情况下 Tomcat 的 200 个线程将爆满,在持续攻击的这 300 秒内,都无法正常处理其他正常用户请求,造成服务异常。
改善方法:将连接器使用 NIO 处理,修改默认配置 protocol 为 Http11NioProtocol,并且增大合适的线程数,对此类攻击有一定的缓解作用。3.2 对外接口服务应用
如果使用 Java 开发对外接口服务,在对响应时延和吞吐量有一定要求的话,通常可以考虑集成 Netty (NIO),如果遵循 Servlet 标准可考虑集成 Jetty 使用 NIO 连接器处理。在协议方面,内部服务可选择高效的私有协议和高压缩比的组件 (如 protobuf / kryo 等),对外服务可选择 HTTP+JSON。
web 服务可考虑动静分离,html/CSS/js/image 等文件使用 Nginx (sendfile 提供更高效的数据传输方式) 处理,业务数据才透到后端服务中,后端服务加入缓存等方式减少响应时延。3.3 关于 JDK 中一些名词解析
在 JDK 提供的 NIO API 与这里介绍的 NIO(Nonblocking I/O) 模型是不同的概念,JDK 在 1.7 之前提供的 Selector 基于 select/poll、epoll(Linux kernel> 2.6) 实现 I/O 复用技术的非阻塞 IO,JDK1.7 以后提供的 NIO2.0 API (如 AsynchronousServerSocketChannel) 才是真正的异步 I/O。
参考资料:
1. http://www.madwizard.org/programming/tutorials/netcpp/5
2. http://www.importnew.com/22019.html
3. 《UNIX Network Programming Volume.1.3rd.Edition》
4.《Netty 权威指南》
来源: https://www.cnblogs.com/mikevictor07/p/8142678.html