一、Linux 基础知识回顾
1. 用户空间和内核空间
现在操作系统都采用虚拟寻址,处理器先产生一个虚拟地址,通过地址翻译成物理地址(内存的地址),再通过总线的传递,最后处理器拿到某个物理地址返回的字节。
对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。
为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
针对linux操作系统而言:
将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间。
而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
2. 直接I/O和缓存I/O
文件系统IO 分为 DirectIO(直接I/O)和 BufferIO(缓存 I/O),其中 BufferIO 也叫Normal IO(标准 I/O)。
大多数文件系统的默认 I/O 操作都是缓存 I/O。
缓存 I/O
读操作:操作系统检查内核的缓冲区有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。
写操作:将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非显示地调用了sync同步命令。
以 write 为例,数据会先被拷贝进程缓冲区,在拷贝到操作系统内核的缓冲区中,然后才会写到存储设备中。
直接 I/O(少了拷贝到应用进程缓冲区这一步)
3.阻塞与同步
1 ) 阻塞(Block) / 非租塞(NonBlock)
阻塞和非阻塞是进程在访问数据的时候,数据是否准备就绪的一种处理方式,比如当数据没有准备就绪的时候
阻塞:往往需要等待缓冲区中的数据准备好过后才处理其他的事情,否则一直等待在那里。
非阻塞:当我们的进程访问我们的数据缓冲区的时候,如果数据没有准备好则直接返回,不会等待。如果数据已经准备好,也直接返回。
阻塞和非阻塞关注的是程序在等待结果(消息,返回值)时的状态。
2 ) 同步(Synchronization) / 异步(Asynchronization)
同步和异步都是基于应用程序私操作系统处理IO事件所采用的方式,比如
同步:是应用程序要直接参与IO读写的操作。
异步:所有的IO读写交给操作系统去处理,应用程序只需要等待通知。
同步方式在处理IO事件的时候,必须阻塞在某个方法上面等待我们的IO事件完成(阻塞IO事件或者通过轮询IO事件的方式)。
对于异步来说,所有的IO读写都交给了操作系统。这个时候,我们可以去做其他的事情,并不需要去完成真正的IO操作,当操作完成IO后.会给我们的应用程序一个通知。
同步和异步关注的是消息通信机制。
二、常见 IO 模型
对于一次IO访问,它会经历两个阶段:
- 等待数据准备就绪 (Waiting for the data to be ready)
- 操作:将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
举例来说:
读函数:分为等待系统可读和真正的读。
写函数:分为等待网卡可以写和真正的写。
说明:
等待就绪的阻塞是不使用 CPU 的,是在“空等”。
而真正的读写操作的阻塞是使用 CPU 的,真正在“干活”,而且这个过程非常快,属于memory copy,宽带通常在 1GB/s 级别以上,可以理解为基本不耗时。
下图是几种常见I/O模型的对比:
以socket.read()为例子:
传统的BIO里面socket.read(),如果TCP RecvBuffer里没有数据,函数会一直阻塞,直到收到数据,返回读到的数据。
对于NIO,如果TCP RecvBuffer有数据,就把数据从网卡读到内存,并且返回给用户;反之则直接返回0,永远不会阻塞。
最新的AIO(Async I/O)里面会更进一步:不但等待就绪是非阻塞的,就连数据从网卡到内存的过程也是异步的。
换句话说,BIO里用户最关心“我要读”,NIO里用户最关心"我可以读了",在AIO模型里用户更需要关注的是“读完了”。
NIO一个重要的特点是:socket主要的读、写、注册和接收函数,在等待就绪阶段都是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但性能非常高)。
三、什么是 BIO、NIO、AIO
1. 同步阻塞I/O(BIO)
同步阻塞I/O,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制来改善。
BIO方式适用于连接数目比较小且固定的架构,这种方式对服务端资源要求比较高,并发局限于应用中,在jdk1.4以前是唯一的io选择,但程序直观简单易理解。
BIO图解:
伪异步模型IO
也被成为M:N客户服务模型。即通过线程池模型的形式用M个线程来服务N个客户端的连接;
其中M的大小可以根据服务器的配置来设置最大值,而可服务客户端个数N则可以远远的大于M.
这样来提高服务器的服务效率,提高线程利用率。
同BIO模型类似,只不过,Acceptor接受客户端请求后,不再独立启动线程来处理,而是将客户请求交给线程池来处理,从而减少线程的创建数量,提高线程利用率,增加服务器的处理能力;
伪异步IO图解
2. 同步非阻塞I/O(NIO)
同步非阻塞I/O,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有IO请求时才启动一个线程进行处理。
NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,jdk1.4开始支持。
I/O多路复用模型
I/O多路复用:I/O就是指的我们网络I/O,多路指多个TCP连接(或多个Channel),复用指复用一个或少量线程。
串起来理解就是很多个网络I/O复用一个或少量的线程来处理这些连接。
多路复用的优势并不是单个连接处理的更快,而是在于能处理更多的连接。
目前的常用的IO复用模型有三种:select,poll,epoll。
I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
jdk1.4 是使用的 select/poll 模型
jdk1.5 以后把 select/poll 改为了epoll模型
1) select 模型
各个客户端连接的文件描述符(fd)也就是套接字,都被放到了一个集合中,调用select函数之后会一直监视这些文件描述符中有哪些可读,如果有可读的描述符那么我们的工作进程就去读取资源。
我们在select函数中告诉内核需要监听的不同状态的文件描述符以及能接受的超时时间,函数会返回所有状态下就绪的描述符的个数,并且可以通过遍历fdset,来找到就绪的文件描述符。
存在的问题:
- 每次调用select,都需要把待监控的fd集合从用户态拷贝到内核态,当fd很大时,开销很大。
- 每次调用select,都需要轮询一遍所有的fd,查看就绪状态。这个开销在fd很多时也很大。
- select支持的最大文件描述符数量有限,默认是1024
2) poll 模型
相对 于select,poll 已不存在最大文件描述符限制。
3) epoll 模型
epoll在Linux2.6内核正式提出,是基于事件驱动的I/O方式
相对于select来说,epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次(可以理解为一块公共内存,该内存既不属于用户态也不属于内核态)。
select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。
epoll 的好处:
- 避免内存级拷贝
- 事件驱动(不是轮询)
但是也并不是所有情况下 epoll 都比 select/poll 好,比如在如下场景:
在大多数客户端都很活跃的情况下,系统会把所有的回调函数都唤醒,所以会导致负载较高。既然要处理这么多的连接,那倒不如 select/poll 遍历简单有效。
4)select & poll & epoll 比较
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
底层实现 | 数组 | 链表 | 哈希表 |
IO 效率 | 每次调用都进行线性遍历,时间复杂度为O(n) | 每次调用都进行线性遍历,时间复杂度为O(n) | 事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1) |
最大连接数 | 1024 | 无上限 | 无上限 |
fd 拷贝 | 每次调用select,都需要把fd集合从用户态拷贝到内核态 | 每次调用poll,都需要把fd集合从用户态拷贝到内核态 | 调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝 |
NIO的3个核心概念
1) 缓冲区Buffer
Buffer是一个对象。它包含一些要写入或者读出的数据。在面向流的I/O中,可以将数据写入或者将数据直接读到Stream对象中。
在NIO中,所有的数据都是用缓冲区处理。IO是面向流的,NIO是面向缓冲区的。
最常用的缓冲区是ByteBuffer,一个ByteBuffer提供了一组功能用于操作byte数组。除了ByteBuffer,还有其他的一些缓冲区,事实上,每一种Java基本类型(除了Boolean)都对应一种缓冲区,具体如下:
- ByteBuffer:字节缓冲区
- CharBuffer:字符缓冲区
- ShortBuffer:短整型缓冲区
- IntBuffer:整型缓冲区
- LongBuffer:长整型缓冲区
- FloatBuffer:浮点型缓冲区
- DoubleBuffer:双精度浮点型缓冲区
2) 通道Channel
Channel是一个通道,可以通过它读取和写入数据,他就像自来水管一样,网络数据通过Channel读取和写入。
通道和流不同之处在于通道是双向的,流只是在一个方向移动,而且通道可以用于读,写或者同时用于读写。
因为Channel是全双工的,所以它比流更好地映射底层操作系统的API,特别是在UNIX网络编程中,底层操作系统的通道都是全双工的,同时支持读和写。
Channel有四种实现:
- FileChannel:是从文件中读取数据。
- DatagramChannel:从UDP网络中读取或者写入数据。
- SocketChannel:从TCP网络中读取或者写入数据。
- ServerSocketChannel:允许你监听来自TCP的连接,就像服务器一样。每一个连接都会有一个SocketChannel产生。
3) 多路复用器Selector
Selector选择器可以监听多个Channel通道感兴趣的事情(read、write、accept(服务端接收)、connect,实现一个线程管理多个Channel,节省线程切换上下文的资源消耗。
Selector只能管理非阻塞的通道,FileChannel是阻塞的,无法管理。
关键对象
Selector:选择器对象,通道注册、通道监听对象和Selector相关。
SelectorKey:通道监听关键字,通过它来监听通道状态。
监听注册 监听注册在Selector
socketChannel.register(selector, SelectionKey.OP_READ);
监听的事件
- OP_ACCEPT:接收就绪,serviceSocketChannel使用的
- OP_READ:读取就绪,socketChannel使用
- OP_WRITE:写入就绪,socketChannel使用
- OP_CONNECT:连接就绪, socketChannel使用
NIO的应用和框架
1) NIO的应用
Java NIO成功的应用在了各种分布式、即时通信和中间件Java系统中,充分的证明了基于NIO构建的通信基础,是一种高效,且扩展性很强的通信架构。
例如:Dubbo(服务框架),就默认使用Netty作为基础通信组件,用于实现各进程节点之间的内部通信。
Jetty、Mina、Netty、Dubbo、ZooKeeper等都是基于NIO方式实现。
Mina出身于开源界的大牛Apache组织 Netty出身于商业开源大亨Jboss Dubbo阿里分布式服务框架
2) NIO框架
特别是Netty是目前最流行的一个Java开源框架NIO框架,Netty提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。
相比JDK原生NIO,Netty提供了相对十分简单易用的API,非常适合网络编程。
Mina和Netty这两个NIO框架的创作者是同一个人Trustin Lee 。Netty从某种程度上讲是Mina的延伸和扩展,解决了一些Mina上的设计缺陷,也优化了一下Mina上面的设计理念。
另一方面Netty相比较Mina的优势:
- 更容易学习
- API更简单
- 详细的范例源码和API文档
- 更活跃的论坛和社区
- 更高的代码更新维护速度
Netty无疑是NIO框架的首选,它的健壮性、功能、性能、可定制性和可扩展性在同类框架都是首屈一指的,后续将重点详细谈Netty的实现原理以及实战场景。
异步非阻塞I/O(AIO)
服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理。
AIO方式适用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂。
AIO又称为NIO2,在JDK7才开始支持。
参考
BIO、NIO、AIO