首先探究一下unix/linux下I/O模型的种类,着重了解socket通信,以及linux中的API select/poll/epoll。进而在Java中进行代码实践,理解java中的NIO BIO AIO。
首先我们看一下在操作系统中有几种I/O模型。
在《Unix 网络编程》中归纳了5种I/O模型:
Unix下可用的I/O模型为5种
应用在使用 TCP 或 UDP 时,会用到操作系统提供的类库。这种类库一般被称为 API(Application Programming Interface,应用编程接口)。 使用 TCP 或 UDP 通讯时,优惠广泛使用到套接字(Socket)的 API。
简单来说TCP/IP是协议,socket则是对TCP/IP协议的封装和应用(程序员层面上)。
首先在操作系统中,例如linux中,为了保证用户进程不能直接操作内核(kernel),保证内核的安全 ,操作系统将虚拟空间划分为两部分,一部分为用户空间(user space),另一部分内核空间(kernel space)
我们操作API,需要从用户态切换到内核态。
图片来自:
用户空间和内核空间是什么?
阻塞和非阻塞是发生在内核状态中的。
所谓阻塞式,就是调用发起后不会直接返回用户态,由操作系统内核处理之后才会返回 。 相对的,还有一种叫做非阻塞式的。
fork 函数创建一个子进程, 调用 read 函数等待客户端的数据写入,如果没有数据写入,调用子进程将被挂起,进入阻塞状态。
java中的socket也是遵循这几个函数阻塞。我们通过实验代码来验证。
SocketClient:
public class SocketClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1", 9000);
//向服务端发送数据
socket.getOutputStream().write("HelloServer".getBytes());
socket.getOutputStream().flush();
System.out.println("向服务端发送数据结束");
byte[] bytes = new byte[1024];
//接收服务端回传的数据
socket.getInputStream().read(bytes);
System.out.println("接收到服务端的数据:" + new String(bytes));
socket.close();
}
}
SocketServer
public class SocketServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9000);
while (true) {
System.out.println("等待连接。。");
//阻塞方法
final Socket socket = serverSocket.accept();
System.out.println("有客户端连接了。。");
// new Thread(new Runnable() {
// public void run() {
// try {
// handler(socket);
// } catch (IOException e) {
// e.printStackTrace();
// }
// }
// }).start();
handler(socket);
}
}
private static void handler(Socket socket) throws IOException {
System.out.println("thread id = " + Thread.currentThread().getId());
byte[] bytes = new byte[1024];
System.out.println("准备read。。");
//接收客户端的数据,阻塞方法,没有数据可读时就阻塞
int read = socket.getInputStream().read(bytes);
System.out.println("read完毕。。");
if (read != -1) {
System.out.println("接收到客户端的数据:" + new String(bytes, 0, read));
System.out.println("thread id = " + Thread.currentThread().getId());
}
socket.getOutputStream().write("HelloClient".getBytes());
socket.getOutputStream().flush();
}
}
我们可以看到在SocketServer.java 启动之后,在16行,accept函数一直处于阻塞状态。
当客户端启动之后,server会执行完accept()方法到下一行。有客户端连接了。
这个过程 也就是tcp 3次握手。
代码中服务端没有使用多线程。也就是一个server端只能连接一个client端。当我们使用多线程之后。就可以让多个客户端进行连接server。
但是我们需要注意的是一个服务器,创建线程是有限制的。也就是如果线程过多,会导致服务器压力过大。
这样我们也就知道了BIO的应用场景,也就是连接数目不多比较小的固定架构,服务器资源要求也比较高。
Linux使用 fcntl 可以把connect accept read/write操作都设置为非阻塞操作。如果没有数据返回,就会直接返回一个 EWOULDBLOCK 或 EAGAIN 错误,此时进程就不会一直被阻塞。
首先我们需要明白什么是一路,也就是标准输入,套接字等都看做I/O的一路。
多路就是在任何一路I/O有“事件”发生的时候,通知应用程序去处理相应的I/O事件。
Linux 提供了 I/O 复用函数 select/poll/epoll
select() :
调用后 select() 函数会阻塞
而且select函数所支持的文件描述符个数是有限的。linux中为1024.
poll()函数和select函数类似,poll没有最大文件描述符数量的限制。
select/poll 是顺序扫描 fd 是否就绪,而且支持的 fd 数量不宜过大。
select/poll这两个有相同的缺点 ,就是包含大量文件描述符的数组被整体复制到用户态和内核态的地址空间之间。
epoll() 函数:
在linux2.6内核中,提供了epoll函数调用。
epoll 使用事件驱动的方式代替轮询扫描fd。
epoll 事先通过 epoll_ctl() 来注册一个文件描述符,将文件描述符存放到内核的一个事件表中,这个事件表是基于红黑树实现的,所以在大量 I/O 请求的场景下,插入和删除的性能比 select/poll 的数组 fd_set 要好,因此 epoll 的性能更胜一筹,而且不会受到 fd 数量的限制。
信号驱动式 I/O 类似观察者模式,内核就是一个观察者,信号回调则是通知。
信号驱动式 I/O 相比于前三种 I/O 模式,实现了在等待数据就绪时,进程不被阻塞,主循环可以继续工作,所以性能更佳。
但是信号没有附加信息,TCP socket 生产的信号事件有七种之多,所以无法处理。
但是可以用在UDP通信上。
真正的非I/O阻塞
支持异步 I/O 的操作系统比较少见(目前 Linux暂不支持 ,
而 Windows 已经实现了异步 I/O)
java中的NIO使用了 I/O复用器selector实现非阻塞I/O,selector就是使用了这五种类型中的I/O复用模型。Java 中的 Selector 其实就是 select/poll/epoll 的外包类。
服务器实现模式是一个线程可以处理多个请求(连接),客户端发送的连接请求都会注册到多路复用器selector上,多路复用器轮训到连接有I/O请求就进行处理。
Socket 通信中的 conect、accept、read 以及 write为阻塞操作,在 Selector 中分别对应 SelectionKey 的四个监听事件 OP_ACCEPT、OP_CONNECT、OP_READ 以及 OP_WRITE
java NIO有三大核心组件:Channel(通道), Buffer(缓冲区),Selector(选择器)
public class NIOServer {
public static void main(String[] args) throws IOException {
// 创建一个在本地端口进行监听的服务Socket通道.并设置为非阻塞方式
ServerSocketChannel ssc = ServerSocketChannel.open();
//必须配置为非阻塞才能往selector上注册,否则会报错,selector模式本身就是非阻塞模式
ssc.configureBlocking(false);
ssc.socket().bind(new InetSocketAddress(9000));
// 创建一个选择器selector
Selector selector = Selector.open();
// 把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
System.out.println("等待事件发生。。");
// 轮询监听channel里的key,select是阻塞的,accept()也是阻塞的
int select = selector.select();
System.out.println("有事件发生了。。");
// 有客户端请求,被轮询监听到
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
//删除本次已处理的key,防止下次select重复处理
it.remove();
handle(key);
}
}
}
private static void handle(SelectionKey key) throws IOException {
if (key.isAcceptable()) {
System.out.println("有客户端连接事件发生了。。");
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
//NIO非阻塞体现:此处accept方法是阻塞的,但是这里因为是发生了连接事件,所以这个方法会马上执行完,不会阻塞
//处理完连接请求不会继续等待客户端的数据发送
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
//通过Selector监听Channel时对读事件感兴趣
sc.register(key.selector(), SelectionKey.OP_READ);
} else if (key.isReadable()) {
System.out.println("有客户端数据可读事件发生了。。");
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
//NIO非阻塞体现:首先read方法不会阻塞,其次这种事件响应模型,当调用到read方法时肯定是发生了客户端发送数据的事件
int len = sc.read(buffer);
if (len != -1) {
System.out.println("读取到客户端发送的数据:" + new String(buffer.array(), 0, len));
}
ByteBuffer bufferToWrite = ByteBuffer.wrap("HelloClient".getBytes());
sc.write(bufferToWrite);
key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
} else if (key.isWritable()) {
SocketChannel sc = (SocketChannel) key.channel();
System.out.println("write事件");
// NIO事件触发是水平触发
// 使用Java的NIO编程的时候,在没有数据可以往外写的时候要取消写事件,
// 在有数据往外写的时候再注册写事件
key.interestOps(SelectionKey.OP_READ);
//sc.close();
}
}
}
client代码:
public class NioClient {
//通道管理器
private Selector selector;
public static void main(String[] args) throws IOException{
NioClient client = new NioClient();
client.initClient("127.0.0.1",9000);
client.connect();
}
/**
* 获得一个socket通道,并对该通道做一些初始化的工作
*
* @param ip 连接服务器的ip
* @param port 连接服务器的端口
*/
private void initClient(String ip, int port) throws IOException {
//获得一个socket通道
SocketChannel socketChannel = SocketChannel.open();
//设置通道为非阻塞
socketChannel.configureBlocking(false);
this.selector = Selector.open();
//客户端连接服务器,其实方法执行并没有实现连接,需要listen()方法中调用
//用channel.finishConnect() 才能完成连接
socketChannel.connect(new InetSocketAddress(ip,port));
//将通道管理器和该通道绑定,并且为该通道注册SelectionKey.OP_CONNECT事件
socketChannel.register(selector, SelectionKey.OP_CONNECT);
}
/**
* 采用轮训
*/
private void connect() throws IOException{
while(true){
selector.select();
Iterator<SelectionKey> it = this.selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = (SelectionKey) it.next();
// 删除已选的key,以防重复处理
it.remove();
// 连接事件发生
if (key.isConnectable()) {
SocketChannel channel = (SocketChannel) key.channel();
// 如果正在连接,则完成连接
if (channel.isConnectionPending()) {
channel.finishConnect();
}
// 设置成非阻塞
channel.configureBlocking(false);
//在这里可以给服务端发送信息哦
ByteBuffer buffer = ByteBuffer.wrap("HelloServer".getBytes());
channel.write(buffer);
//在和服务端连接成功之后,为了可以接收到服务端的信息,需要给通道设置读的权限。
channel.register(this.selector, SelectionKey.OP_READ); // 获得了可读的事件
} else if (key.isReadable()) {
read(key);
}
}
}
}
/**
* 处理读取服务端发来的信息 的事件
*
* @param key
* @throws IOException
*/
public void read(SelectionKey key) throws IOException {
//和服务端的read方法一样
// 服务器可读取消息:得到事件发生的Socket通道
SocketChannel channel = (SocketChannel) key.channel();
// 创建读取的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
int len = channel.read(buffer);
if (len != -1) {
System.out.println("客户端收到信息:" + new String(buffer.array(), 0, len));
}
}
}
从代码中我们可以看出,NIO相对于BIO
BIO中的read方法是需要等待客户端写数据的。
NIO把等待客户端的操作交给了Selector,selector负责轮询。前面我们提到这个就是select/poll/epoll的包装类。(如果运行在linux系统2.6版本以上,则会使用epoll。)
所以NIO中的channel read和write方法都是非阻塞的。
对于BIO中,如果需要多个客户端连接,我们可以开启多个线程。
而对于NIO来说,我们可以使用一个线程就可以进行连接。
Redis就是典型的NIO线程模型,selector会收集连接事件并且转交给server线程来处理。
AIOClient
public class AIOClient {
public static void main(String... args) throws Exception {
AsynchronousSocketChannel socketChannel = AsynchronousSocketChannel.open();
socketChannel.connect(new InetSocketAddress("127.0.0.1", 9000)).get();
socketChannel.write(ByteBuffer.wrap("HelloServer".getBytes()));
ByteBuffer buffer = ByteBuffer.allocate(512);
Integer len = socketChannel.read(buffer).get();
if (len != -1) {
System.out.println("客户端收到信息:" + new String(buffer.array(), 0, len));
}
}
}
AIOServer
public class AIOServer {
public static void main(String[] args) throws Exception {
final AsynchronousServerSocketChannel serverChannel =
AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(9000));
serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
@Override
public void completed(AsynchronousSocketChannel socketChannel, Object attachment) {
try {
// 再此接收客户端连接,如果不写这行代码后面的客户端连接连不上服务端
serverChannel.accept(attachment, this);
System.out.println(socketChannel.getRemoteAddress());
ByteBuffer buffer = ByteBuffer.allocate(1024);
socketChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buffer) {
buffer.flip();
System.out.println(new String(buffer.array(), 0, result));
socketChannel.write(ByteBuffer.wrap("HelloClient".getBytes()));
}
@Override
public void failed(Throwable exc, ByteBuffer buffer) {
exc.printStackTrace();
}
});
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void failed(Throwable exception, Object attachment) {
exception.printStackTrace();
}
});
Thread.sleep(Integer.MAX_VALUE);
}
}
对于NIO/BIO来说,都是同步的。AIO是异步的。
总结一下 :
在JAVA中,AIO异步来说,仅仅是对NIO做了一些回调的封装处理。Java中常常使用接口回调或者观察者模式等来进行一些回调。同步和异步是API层面封装。
而对于阻塞与非阻塞,是从系统层面考虑的,用户空间和内核空间之间的系统调用是否使用了select/poll/epoll这种方法(之前说的信号驱动式IO模式 对TCP通信的不支持,以及异步I/O模式 在linux上不成熟。目前大多数还是基于I/O复用模型)。
个人见解尚浅,欢迎指正。
极客时间:《java编程性能调优》
《Unix网络编程 卷1》
Java NIO