这里对IO的学习做一个记录:
什么是I/O?
简单的将就是input和output,通常我们对I/O的操作可分为文件I/O操作,这时候可以使用文件流方式进行。而大部分的应用系统之间的交互则是用到网络I/O,因此这里也针对网络I/O进行学习。
I/O有几种模型?
I/O的概念,其实主体是针对当前应用系统所运行的操作系统而言(开发过网络编程的同学如果不清楚其概念的话很容易将它与网络通讯搞混淆)
这里沿用几张图: tcp连接以及网络I/O的几个问题
应用程序通过网络获取到数据通常有2个步骤:
- 数据通过网络获取到分组的数据后先方在内核的某个缓冲区中
- 准备好后再从内核拷贝到用户空间
从阻塞和非阻塞的两张图中可以看出,阻塞就是线程交出cpu处于阻塞状态等待数据准备好后再继续执行,而非阻塞则是进程不交出cpu不断尝试获取数据。这样的情况如果数据准备时常不长的话,则可以避免因为上下文切换而引起的系统消耗。
阻塞I/O是在I/O的操作上进行了阻塞。
前面两种方式都是同步方式。
I/O复用模型,是调用系统函数,select、poll去进行io资源的监控,所以其阻塞是在调用select等函数上,而不是具体的i/o操作。它的优势是在于select函数可以同时监控多个连接请求,而不必没有请求线程都去执行操作。其他对于当个连接而言并没有加快它的访问速度
这里有几个概念理清楚了会更好的理解多路复用:
(1) 首先是系统的并发量,是只同一时间请求的数量。
(2)系统的并行量,是只同一时间能处理的并发数。可以直到针对程序某一个时间能处理的并发量是有限的,可以根据cpu核数分配最适合的并行数。
(3)假设我们的应用系统是单线程运行的,如redis
(4)如果不是多路复用,则一个i/o请求过来之后,单线程系统就会阻塞在i/o上等待本地i/o结束,才能处理下一个请求。而采用多路复用后就等于单线程可以一次处理多个i/o请求,大大增加了系统的并发和资源的利用。 这里可能有小伙伴会问那是不是可以采用多线程的方式去处理i/o请求?理论上应该是可以的但要直到系统的线程资源是很有限的,增加线程为增加很多额外的开销。
(5) 多路复用调用的select等方法使用的是操作系统内核函数,速度上会快很多
这也是为什么redis采用,多路复用技术。
信号驱动IO是指进程事先告诉内核,在某个描述符上发生某事的时候通知进程,所以在数据准备阶段进程不进行阻塞
针对使用套接字连接的应用,需要下面的步骤
//设置sgio信号处理函数,用于处理sigio信号
signal(SIGIO, sig_io);
// 设置套接字的属主进程,因该在设置套接字属主之前建立信号处理函数,因为在调用fcntl后调用signal之前有较小的机会产生SIGIO信号,此时信号被丢弃
fcntl(sockfd, F_SETOWN, getpid());
//开启该套接字的信号驱动式I/O
const int on = 1;
ioctl(sockfd, O_ASYNC, &on);
信号驱动IO模式其中一个主要的部分是针对接收到信号的处理,针对TCP的套接字而言,下列情况会产生信号:
- 监听套接字上某个连接请求已经完成
- 某个断连请求已经发起
- 某个断连请求已经完成
- 某个连接之半已经关闭
- 数据到达套接字
- 数据已经从套接字发送走
- 发生某个异步错误
由于信号产生的过于频繁,并且它的出现并没有告诉我们发生了什么事情。因此tcp模式下信号驱动io近乎无用, 因此应该只针对监听TCP套接字使用SIGIO,因为对于监听套接字产生SIGIO的唯一条件是某个新的连接已完成(这里不知道怎么只针对监听tcp使用sigio?)
针对UDP模式下列两种情况会产生信号:
- 数据报到达套接字
- 套接字上发生异步错误
因此在接收到信号时,调用recvfrom拷贝内核缓存数据,最后处理数据报。或处理异常
异步IO下,进程在接收到i/o操作后,则调用aio_read方法,无论系统是否准备完毕,该方法都会立即返回数据。此时用户进程就可以做其他事情继续执行,内核会自动将数据拷贝到用户空间中,同发送信号。应用程序进行信号处理,程序处理,数据报处理。
这里linux系统针对信号的处理有下列三种情况:
- 如果这个进程正在用户态忙着做别的事(例如在计算两个矩阵的乘积),那就强行打断之,调用事先注册的信号处理函数,这个函数可以决定何时以及如何处理这个异步任务。由于信号处理函数是突然闯进来的,因此跟中断处理程序一样,有很多事情是不能做的,因此保险起见,一般是把事件 “登记” 一下放进队列,然后返回该进程原来在做的事。
- 如果这个进程正在内核态忙着做别的事,例如以同步阻塞方式读写磁盘,那就只好把这个通知挂起来了,等到内核态的事情忙完了,快要回到用户态的时候,再触发信号通知。
- 如果这个进程现在被挂起了,例如无事可做 sleep 了,那就把这个进程唤醒,下次有 CPU 空闲的时候,就会调度到这个进程,触发信号通知。
前面把IO的五种模型了解了一下。接下来我们来看下JAVA中对于IO的支持。
Java的IO支持
Java针对IO提供了两种支持方案,一种是IO在java.io包中,NIO在java.nio中
先来看下两者的区别:
IO | NIO |
---|---|
面向字节流 | 面向缓冲区 |
阻塞IO | 非阻塞IO |
无 | Selectors选择器 |
java 的io直接对流进行操作,并是阻塞式的io也就式在对io进行读写时线程式阻塞的。
Java NIO
NIO的三个核心是Channel(通道),Buffer(缓冲区),Selector(选择器)
一.Channel
channel通道是io操作的载体,相当与流,但流是单向的而通道是双向的,并且读取到channel中的数据总是buffer
二.Buffer缓冲区
Buffer用于与channel进行交互,buffer实质上是一块被包装成NIO Buffer对象的内存,可以对其进行数据的写入和读取。
三. Selectors 选择器
selectors 是Java NIO的核心内容。
selector 有选择已就绪任务的能力,它会轮训注册在上面的channel并选择有读写时间的channel进行后续的读写任务操作。NIO的非阻塞模式使用的是多路复用模式
我们先来写一个基于socket的nio应用
package com.heroschool.study;
import com.sun.xml.internal.ws.policy.privateutil.PolicyUtils;
import org.junit.Test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class NioTest {
@Test
public void server() throws IOException {
// 获取通道
ServerSocketChannel server = ServerSocketChannel.open();
// 切换到非阻塞模式
server.configureBlocking(false);
server.bind(new InetSocketAddress(8880));
// 获取选择器
Selector selector = Selector.open();
// 注册接收事件
server.register(selector, SelectionKey.OP_ACCEPT);
// 如果有准备就绪的事件(接收到客户端数据)
while (true) {
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> keys = selectionKeys.iterator();
while (keys.hasNext()) {
SelectionKey key = keys.next();
if (key.isAcceptable()) {
System.out.println("客户端连接完毕");
SocketChannel client = server.accept();
client.configureBlocking(false);
// 注册客户端的读事件
client.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
System.out.println("客户端数据传输准备完成");
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int len = 0;
// 读取客户端数据
while ((len = client.read(byteBuffer)) > 0) {
byteBuffer.flip();
System.out.println(new String(byteBuffer.array(), 0, len, "utf-8"));
byteBuffer.clear();
}
if(len == -1) { // 关闭客户端连接
key.cancel();
client.close();
}
}
keys.remove();
}
}
}
@Test
public void client() throws IOException {
// 获取通道
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 8880));
// 切换非阻塞模式
socketChannel.configureBlocking(false);
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
String s = "this is test";
byteBuffer.put(s.getBytes("utf-8"));
byteBuffer.flip();
socketChannel.write(byteBuffer);
byteBuffer.clear();
socketChannel.close();
}
}
这里服务端选择器会调用select()方法,该方法底层便是使用到操作系统的select或epoll方法。我们可以稍微来看下代码:
最终实现的类是:
实现的方法为:
poll方法即调用底层的操作系统方法,获取到数据后更新selectKeys
这里在提一下,这种多路复用的方法使用的是Reactor模式:
(1)采用事件驱动
(2)可以处理一个或多个输入源
(3)通过service Handler将事件分发给request handler
Java NIO的AIO模式
JAVA1.7之后nio支持AIO异步IO的模式,可以让客户端或服务端不需要等待操作系统层面上的io等待。这里就不进行详细的看了。后面会专门看下Netty框架,该框架对这种异步需求有着很好的支持。