高性能IO操作在互联网应用中尤为重要,如游戏,社交等,常用的服务端框架如netty,mina等,缓存框架如redis, ignite等,要了解这些框架高性能的原因,就要了解底层IO模式。
使用Netty构建服务器时,需要指定parent线程池和child线程池,parent线程负责监听端口,一旦有连接接入,则注册到child线程池中的一个线程上,该连接的IO操作/任务都由该线程完成。
换句话说,一个线程会负责多个连接的IO操作,也就是多路复用。Netty底层是使用系统提供的select或者epoll来实现多路复用的。
先来科普下select/poll/epoll。
select/poll
服务端建立每个连接,相当于打开文件,会获得对应的文件描述符(fd),相同的源IP/源端口/目标IP/目标端口对应同一个fd。
select和poll是相似的,不一样的地方是,select是使用数组,有连接数限制,而poll使用链表,无连接数限制。
监听连接时,从用户层的角度看,
(1)会构建3个fd数组,分别关注读/写/异常事件,设置超时时间,调用系统提供的select方法。
(2)调用select方法时,需要将fd数组传到内核态,等待部分fd就绪后,把fd数组(包含就绪状态)返回到用户态
(3)用户程序对fd数组进行遍历,处理就绪的fd
(4)重新调用select方法。
可以看出不好的地方是
(1)每次都要传入fd数组,返回整个fd数组,导致了大量在用户空间和内核空间的相互拷贝。
(2)用户程序仍需要遍历fd数组才能找出就绪的fd
从系统层的角度看,调用select方法时
(1)遍历fd数组,对于每个fd,调用其对应的poll方法(由设备对应的驱动程序实现),将fd所在线程加入等待队列,并且检查就绪状态,记录感兴趣的就绪状态。
(2)如果存在感兴趣的就绪状态,直接返回
(3)如果不存在感兴趣的就绪状态,进入休眠,等待fd就绪后,会唤醒等待队列中的线程
(4)被唤醒后,重复1-4的操作。
可以看出不好的地方是每次都需要检查所有fd。
epoll
epoll相对select改善了很多。
(1)在使用epoll时,首先会构建epoll对象。
(2)有连接接入时,会插入到epoll对象中,epoll对象里实际是一个红黑树+双向链表,fd插入到红黑树中,通过红黑树查找到是否重复
(3)一旦fd就绪,会触发回调把fd的插入到就绪链表中,并唤醒等待队列中的线程。
(4)调用epoll_wait方法时只需要检查就绪链表,如有则返回给用户程序,如没有进入等待队列。
由于epoll把fd管理起来,不需要每次都重复传入,而且只返回就绪的fd,因此减少了用户空间和内核空间的相互拷贝,在fd数量庞大的时候更加高效。
Netty可以选择使用不同的多路复用技术。
I/O模型 BIO
阻塞I/O(Blocking I/O BIO)
应用程序进程/线程如果发起1K个请求,则开启1K个socket文件描述符,socket在等待内核返回数据时是阻塞式的,数据未准备好就一直阻塞等待,一次只会返回一个socket结果,直到返回数据后才等待下一个socket的返回
BIO Server端代码
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket ss = new ServerSocket();
ss.bind(new InetSocketAddress("127.0.0.1", 8888));
while(true) {
Socket s = ss.accept(); //阻塞方法
//新起线程来处理client端请求,让主线程可以接受下一个client的accept
new Thread(() -> {
handle(s);
}).start();
}
}
static void handle(Socket s) {
try {
byte[] bytes = new byte[1024];
int len = s.getInputStream().read(bytes); //阻塞方法
System.out.println(new String(bytes, 0, len));
s.getOutputStream().write(bytes, 0, len); //阻塞方法
s.getOutputStream().flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}
BIO Client端代码
public class Client {
public static void main(String[] args) throws IOException {
Socket s = new Socket("127.0.0.1", 8888);
s.getOutputStream().write("HelloServer".getBytes());
s.getOutputStream().flush();
//s.getOutputStream().close();
System.out.println("write over, waiting for msg back...");
byte[] bytes = new byte[1024];
int len = s.getInputStream().read(bytes);
System.out.println(new String(bytes, 0, len));
s.close();
}
}
轮询非阻塞I/O(Non-Blocking I/O NIO)
应用进程如果发起1K个请求,则在用户空间不停轮询这1K个socket文件描述符,查看是否有结果返回。这种方法虽然不阻塞,但是效率太低,有大量无效的循环
单线程NIO Server代码:
一个selector做所有的事,既有accept、又有read
public class Server {
public static void main(String[] args) throws IOException {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.socket().bind(new InetSocketAddress("127.0.0.1", 8888));
ssc.configureBlocking(false);
System.out.println("server started, listening on :" + ssc.getLocalAddress());
Selector selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);
while(true) {
selector.select(); //阻塞方法
Set keys = selector.selectedKeys();
Iterator it = keys.iterator();
while(it.hasNext()) {
SelectionKey key = it.next();
it.remove();
handle(key);
}
}
}
private static void handle(SelectionKey key) {
if(key.isAcceptable()) { //判断为连接请求
try {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
//new Client
//
//String hostIP = ((InetSocketAddress)sc.getRemoteAddress()).getHostString();
/*
log.info("client " + hostIP + " trying to connect");
for(int i=0; i
NIO-reactor Server代码:
一个selector只负责accept(Boss),对每个client的handler由一个线程池处理(Workers)
public class PoolServer {
ExecutorService pool = Executors.newFixedThreadPool(50);
private Selector selector;
//中文测试
/**
* @throws IOException
*/
public static void main(String[] args) throws IOException {
PoolServer server = new PoolServer();
server.initServer(8000);
server.listen();
}
/**
* @param port
* @throws IOException
*/
public void initServer(int port) throws IOException {
//
ServerSocketChannel serverChannel = ServerSocketChannel.open();
//
serverChannel.configureBlocking(false);
//
serverChannel.socket().bind(new InetSocketAddress(port));
//
this.selector = Selector.open();
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务端启动成功!");
}
/**
* @throws IOException
*/
@SuppressWarnings("unchecked")
public void listen() throws IOException {
// 轮询访问selector
while (true) {
selector.select();
//
Iterator ite = this.selector.selectedKeys().iterator();
while (ite.hasNext()) {
SelectionKey key = (SelectionKey) ite.next();
//
ite.remove();
//
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
//
SocketChannel channel = server.accept();
//
channel.configureBlocking(false);
//
channel.register(this.selector, SelectionKey.OP_READ);
//
} else if (key.isReadable()) {
//
key.interestOps(key.interestOps()&(~SelectionKey.OP_READ));
//
pool.execute(new ThreadHandlerChannel(key));
}
}
}
}
}
/**
*
* @param
* @throws IOException
*/
class ThreadHandlerChannel extends Thread{
private SelectionKey key;
ThreadHandlerChannel(SelectionKey key){
this.key=key;
}
@Override
public void run() {
SocketChannel channel = (SocketChannel) key.channel();
//
ByteBuffer buffer = ByteBuffer.allocate(1024);
//
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try {
int size = 0;
while ((size = channel.read(buffer)) > 0) {
buffer.flip();
baos.write(buffer.array(),0,size);
buffer.clear();
}
baos.close();
//
byte[] content=baos.toByteArray();
ByteBuffer writeBuf = ByteBuffer.allocate(content.length);
writeBuf.put(content);
writeBuf.flip();
channel.write(writeBuf);//
if(size==-1){
channel.close();
}else{
//
key.interestOps(key.interestOps()|SelectionKey.OP_READ);
key.selector().wakeup();
}
}catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
多路复用I/O(Multiplexing I/O)
select:能打开的文件描述符个数有限(最多1024个),如果有1K个请求,用户进程每次都要把1K个文件描述符发送给内核,内核在内部轮询后将可读描述符返回,用户进程再依次读取。因为文件描述符(fd)相关数据需要在用户态和内核态之间拷来拷去,所以性能还是比较低
poll:可打开的文件描述符数量提高,因为用链表存储,但性能仍然不够,和selector一样数据需要在用户态和内核态之间拷来拷去
epoll(Linux下多为该技术):用户态和内核态之间不用文件描述符(fd)的拷贝,而是通过mmap技术开辟共享空间,所有fd用红黑树存储,有返回结果的fd放在链表中,用户进程通过链表读取返回结果,伪异步I/O,性能较高。epoll分为水平触发和边缘出发两种模式,ET是边缘触发,LT是水平触发,一个表示只有在变化的边际触发,一个表示在某个阶段都会触发
用Netty的示例代码来展现一下epoll的应用层用法
Server代码:
class Handler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//super.channelRead(ctx, msg);
System.out.println("server: channel read");
ByteBuf buf = (ByteBuf)msg;
System.out.println(buf.toString(CharsetUtil.UTF_8));
ctx.writeAndFlush(msg);
ctx.close();
//buf.release();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
//super.exceptionCaught(ctx, cause);
cause.printStackTrace();
ctx.close();
}
}
Client代码:
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println("channel is activated.");
final ChannelFuture f = ctx.writeAndFlush(Unpooled.copiedBuffer("HelloNetty".getBytes()));
f.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
System.out.println("msg send!");
//ctx.close();
}
});
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
try {
ByteBuf buf = (ByteBuf)msg;
System.out.println(buf.toString());
} finally {
ReferenceCountUtil.release(msg);
}
}
}
小结
- 多路复用技术的发展代表目前I/O发展的方向。
- select --- 只支持1024个句柄,轮询导致性能下降
- poll --- 支持句柄数增多,性能仍然不高
- epoll --- 支持句柄数增多,事件性驱动,性能高
Redis的I/O采用Linux下最先进的epoll,包括Netty也是使用的epoll
AIO虽然更加先进,但是写起来更加复杂,而且在Linux内核下还没有真正支持AIO,但是Windows支持AIO.