什么是IO
计算机内存和外部设备之间交换数据的过程.
当用户线程发起read/write请求时,io会分为两个步骤:
- 操作系统内核会将外部设备(网卡,磁盘)的数据拷贝到内核空间(只有内核能访问)
- 操作系统内核会将内核空间的数据拷贝到用户空间
因为有了这两步,所以才会有不同的io模型,每种模型实现这两步的方式不一样。
通信模型分类
所谓的IO模型本质只是不同的调度策略,核心TCP的那一套没有发生改变。
另外Linux系统中一切皆文件,所以数据通道也是文件描述符fd。
分类
阻塞的概念: 调用方法后不会立即返回,会挂起线程,等待唤醒然后继续往下执行。
- BIO:同步阻塞,用户调用read后,用户线程会阻塞,并让出CPU,等到上面两步全部完成,唤醒用户线程,继续往下执行。
- NIO:从Java层面说是New IO ,从底层上看就是NON-Blocking IO,accpet()和read()都立即返回,成功就返回一个非null值。
- 多路复用:用户线程调用select后,用户线程会阻塞等待,然后内核会在数据拷贝到用户空间后唤醒用户线程,并且将所有完成了的数据通道都告知用户线程;本质是使用有限的线程去处理多个连接
- AIO:异步非阻塞,用户线程向内核注册一个监听器,等到内核完成那两步之后,通过监听器回调通知给用户线程。整个步骤都不会阻塞用户线程
实现原理
首先网络编程的必须步骤:
Socket server = new Socket();
server.bind(new InetSocketAddress("127.0.0.1",8080));
Socket socket = server.accept();
- 创建socket
- 将socket绑定到端口上面
- 执行accept,并在此处阻塞
在linux底层分别是create_socket() -> bind()->listen()->accept()
BIO
传统的网络模型,基本是每连接每线程/进程 的模式,主线程阻塞式accept,然后将fd交给线程池去进行read/write,但是同样也是阻塞的,所以效率比较低下,在连接数不多或者并发不高的场景下可以使用,因此BIO会存在C10k的问题。 默认情况下linux系统的每个进程的fd(文件描述符,就是socket)个数只有1024, 可以修改。
- 优点:简单,易理解,适用于并发不高的场景
- 缺点:C10k问题
BIO在服务端模式下没那么适用,但是对于客户端模式就没那么多问题,所以常见于客户端的网络编程。
NIO 非阻塞
这里说的是底层的Non-Blocking IO,通过fd设置为非阻塞,那么accept和read/write都是会直接返回,所以常用方式是循环执行。 此方式虽然不会阻塞线程,但是由于每次accept和read都是系统调用,因此会出现用户线程和内核线程的切换,正常来说一次accept和read都会经历两次线程切换,因而此种方式产生多次无效的线程切换,增加了损耗。
- 优点: 规避了C10k问题
- 缺点: 会出现很多无效的系统调用,从而浪费性能
多路复用
多路复用的出现也是为了解决NIO的问题,本质是通过一次系统调用从而将就绪的fd返回,避免了无效的系统调用,从整体上减少线程切换的损耗。
select
操作系统提供的函数,应用系统将accept到的socket添加到系统的fd数组里面,然后系统去遍历这个fd数组并返回那些文件描述符可以进行读写。
- 优点:解决了NIO无意义的系统调用,每次都会返回有效的fd。
- 缺点:
- 因为是fd数组,所以有1024个数限制,
- 需要用户copy给内核,在高并发场景下会有复制损耗
- select内核还是在全量遍历
poll
和select很类似,只是把fd数组换成了链表,从而解决了1024的限制,但是其他并没有改变。
epoll
多路复用的最终实现,解决了上面select和poll的一些缺点。
epoll提供了3个函数:epoll_create,epoll_ctl,epoll_wait
create: 创建一个epoll实例(selector的一个实现)
ctl:向epoll添加,删除需要监听的fd和对应的事件
wait: 类似select()调用,会阻塞,返回就绪的fd,但是底层使用的红黑树来存储这些fd,并且是通过事件来通知epoll哪些fd就绪了,不需要遍历了。
需要注意的是不管是BIO,NIO还是多路复用都是同步IO模型,因为还需应用程序自己去读取IO,这个过程是阻塞的