同步 VS 异步
同步和异步概念以 “调用者的行为方式” 做区分
同步
调用者发起一个调用后,主动等待被调用者返回的结果。
异步
调用者发起一个调用后,被动接收调用者结果。 如回调函数,状态,消息等方式通知调用者。
阻塞 VS 非阻塞
调用者发起一个调用后,在被调用者处理期间,调用者的状态来区分
阻塞
调用者什么都不干,一直等待结果
非阻塞
在被调用者处理期间,调用者可以干其他事情
同步阻塞 BIO
调用者发起一个调用后,调用者其他什么都不干, 直到被调用者的结果返回为止。
BIO(Block-IO)是一种阻塞同步的通信模式。常说的Socket IO 一般指的是BIO。是一个比较传统的通信方式,模式简单,使用方便。但并发处理能力低,通信耗时,依赖网速。
设计原理
服务器通过一个Acceptor线程负责监听客户端请求和为每个客户端创建一个新的线程进行链路处理。典型的一请求一应答模式。若客户端数量增多,频繁地创建和销毁线程会给服务器打开很大的压力。而且操作系统允许的线程数量是有限的,每当有一个请求过来,都会创建新的线程,当线程数达到一定数量,占满了整台机器的资源,那么机器就挂掉了。对于CPU来说也是一个不好的事情,因为会导致频繁的切换上下文。
后改良为用线程池的方式代替新增线程,被称为伪异步IO。但是还是有上面的一些问题,仅仅是解决了频繁创建线程带来额外开销的问题。不过由于是同步,如果读写速度慢,那么每个线程进来是会导致阻塞的,性能的高低完全取决于阻塞的时间。这个对于用户的体验也是相当不好的。
服务器提供IP地址和监听的端口,客户端通过TCP的三次握手与服务器连接,连接成功后,双方才能通过套接字(Stock)通信。 小结:BIO模型中通过Socket和ServerSocket完成套接字通道的实现。每一个线程在处理请求时(accept,read,write)都是阻塞,同步,建立连接耗时。
具体实现
BIO服务器代码,负责启动服务,阻塞服务,监听客户端请求,新建线程处理任务。
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ITDragonBIOServer {
private static final Integer PORT = 8888;
public static void main(String[] args) {
ServerSocket server = null;
Socket socket = null;
ThreadPoolExecutor executor = null;
try {
server = new ServerSocket(PORT);
System.out.println("BIO Server 服务器启动.........");
executor = new ThreadPoolExecutor(10, 100, 1000, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(50));
while (true) {
socket = server.accept();
ITDragonBIOServerHandler serverHandler = new ITDragonBIOServerHandler(socket);
executor.execute(serverHandler);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (null != socket) {
socket.close();
socket = null;
}
if (null != server) {
server.close();
server = null;
System.out.println("BIO Server 服务器关闭了!!!!");
}
executor.shutdown();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
BIO服务端处理任务代码,负责处理Stock套接字,返回套接字给客户端,解耦。
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import com.itdragon.util.CalculatorUtil;
public class ITDragonBIOServerHandler implements Runnable{
private Socket socket;
public ITDragonBIOServerHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
BufferedReader reader = null;
PrintWriter writer = null;
try {
reader = new BufferedReader(new InputStreamReader(this.socket.getInputStream()));
writer = new PrintWriter(this.socket.getOutputStream(), true);
String body = null;
while (true) {
body = reader.readLine();
if (null == body) {
break;
}
System.out.println("server服务端接收参数 : " + body);
writer.println(body + " = " + CalculatorUtil.cal(body).toString());
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (null != writer) {
writer.close();
}
try {
if (null != reader) {
reader.close();
}
if (null != this.socket) {
this.socket.close();
this.socket = null;
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
BIO客户端代码,负责启动客户端,向服务器发送请求,接收服务器返回的Stock套接字。
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Random;
public class ITDragonBIOClient {
private static Integer PORT = 8888;
private static String IP_ADDRESS = "127.0.0.1";
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
clientReq(i);
}
}
private static void clientReq(int i) {
Socket socket = null;
BufferedReader reader = null;
PrintWriter writer = null;
try {
socket = new Socket(IP_ADDRESS, PORT);
reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
writer = new PrintWriter(socket.getOutputStream(), true);
String []operators = {"+","-","*","/"};
Random random = new Random(System.currentTimeMillis());
String expression = random.nextInt(10)+operators[random.nextInt(4)]+(random.nextInt(10)+1);
writer.println(expression);
System.out.println(i + " 客户端打印返回数据 : " + reader.readLine());
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (null != reader) {
reader.close();
}
if (null != socket) {
socket.close();
socket = null;
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
同步非阻塞 NIO
调用者发起一个调用后,调用者转而可以去干其他事,只是需要时不时轮询被调用者获取结果。
NIO(New IO or Non-Block IO)是一种非阻塞同步的通信模式。即 同步是指线程不断轮询 IO 事件是否就绪(主动获取),非阻塞是指线程在等待 IO 的时候,可以同时做其他任务。
设计原理
NIO 相对于BIO来说一大进步。客户端和服务器之间通过Channel通信。NIO可以在Channel进行读写操作。这些Channel都会被注册在Selector多路复用器上。Selector通过一个线程不停的轮询这些Channel。找出已经准备就绪的Channel执行IO操作。 NIO 通过一个线程轮询,实现千万个客户端的请求,这就是非阻塞NIO的特点。
同步的核心就是 Selector,Selector 代替了线程本身轮询 IO 事件,避免了阻塞同时减少了不必要的线程消耗;
非阻塞的核心就是通道和缓冲区,当 IO 事件就绪时,可以通过读写缓冲区,保证 IO 的成功,而无需线程阻塞式地等待。
-
缓冲区Buffer:它是NIO与BIO的一个重要区别。BIO是将数据直接写入或读取到Stream对象中。而NIO的数据操作都是在缓冲区中进行的。缓冲区实际上是一个数组。Buffer最常见的类型是ByteBuffer,另外还有CharBuffer,ShortBuffer,IntBuffer,LongBuffer,FloatBuffer,DoubleBuffer。 -
通道Channel:和流不同,通道是双向的。NIO可以通过Channel进行数据的读,写和同时读写操作。 通道分为两大类:一类是网络读写(SelectableChannel),一类是用于文件操作(FileChannel),SocketChannel和ServerSocketChannel都是SelectableChannel的子类。 -
多路复用器Selector:NIO编程的基础。多路复用器提供选择已经就绪的任务的能力。就是Selector会不断地轮询注册在其上的通道(Channel),如果某个通道处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以取得就绪的Channel集合,从而进行后续的IO操作。服务器端只要提供一个线程负责Selector的轮询,就可以接入成千上万个客户端,这就是JDK NIO库的巨大进步。
NIO + 单线程Reactor模式
Reactor设计模式是 event-driven architecture (事件驱动架构)的一种实现方式,处理多个客户端并发的向服务端请求服务的场景。每种服务在服务端可能由多个方法组成。Reactor会解耦并发请求的服务并分发给对应的事件处理器来处理。
Reactor主要由以下几个角色构成:Handle、Synchronous Event Demultiplexer、Initiation Dispatcher、Event Handler、Concrete Event Handler
-
Handle:Handle在linux中一般称为文件描述符,在window称为句柄,两者的含义一样。Handle是事件的发源地。比如一个网络socket、磁盘文件等。而发生在Handle上的事件可以有connection、ready for read、ready for write等。 -
Synchronous Event Demultiplexer(同步事件分离器):本质上是系统调用,感知并获取事件发生的Handle并通知Initiation Dispatcher。同步事件分离器指的是常用的IO多路复用,比如select、poll、epoll。 select方法会一直阻塞直到Handle上有事件发生时才会返回。select会轮询所有注册到系统内核的Socket连接,一旦发现某个Socket连接数据准备完毕,select就会返回。用户线程获得了目标连接后,发起read系统调用,用户线程阻塞。内核开始复制数据。它就会将数据从kernel内核缓冲区,拷贝到用户缓冲区(用户内存),然后kernel返回结果。 poll类似与select,只是poll上注册的socket列表可以无限制,因为他是通过链表实现的。 epoll系统调用则是对每个注册的Socket连接加入一个回调函数,一旦Socket连接数据准备完毕,Socket连接执行回调函数,然后将该fd放入到就绪链表中。用户线程可以通过访问就绪链表获得准备数据的Socket,然后发起read系统调用获得数据。 在Java NIO领域中,同步事件分离器对应的组件就是selector,阻塞方法就是select。 -
Event Handler(事件处理器):定义一些回调方法或者称为钩子函数。当Handle上有事件发生时,回调方法便会执行,一种事件处理机制。Java NIO中没有对应的相关类,由开发者自行开发。 -
Concrete Event Handler(具体的事件处理器):实现了Event Handler。在回调方法中会实现具体的业务逻辑。 -
Initiation Dispatcher(初始分发器):也是Reactor角色,提供了注册、删除与转发Event handler的方法。当Synchronous Event Demultiplexer检测到Handle上有事件发生时,便会通知Initiation dispatcher调用特定的Event handler的回调方法。
处理流程:
- 当应用向Initiation Dispatcher注册Concrete Event Handler时,应用会标识出该事件处理器希望Initiation Dispatcher在某种类型的事件发生时向其通知,事件与Handle关联;
- Initiation Dispatcher要求注册在其上面的Concrete Event Handler传递内部关联的Handle,该Handle会向操作系统标识;
- 当所有的Concrete Event Handler都注册到 Initiation Dispatcher上后,应用会调用handle_events方法来启动Initiation Dispatcher的事件循环,这时Initiation Dispatcher会将每个Concrete Event Handler关联的handle合并,并使用Synchronous Event Demultiplexer来等待这些Handle上事件的发生;
- 当与某个事件源对应的Handle变为ready时,Synchronous Event Demultiplexer便会通知 Initiation Dispatcher。比如tcp的socket变为ready for read;
- Initiation Dispatcher会触发事件处理器的回调方法。当事件发生时, Initiation Dispatcher会将被一个“key”(表示一个激活的Handle)定位和分发给特定的Event Handler的回调方法;
- Initiation Dispatcher调用特定的Concrete Event Handler的回调方法来响应其关联的handle上发生的事件。
这种模型情况下,由于 Reactor 是单线程的,既要接受请求,还要去处理事件,如果某一些事件处理请求花费的时间比较长,那么这个请求将会进入等待,整个情况下会同步。基于这种问题下有什么改进措施呢?
NIO + 多线程Reactor模式
可以使用多线程去处理,使用线程池,让Reactor仅仅去接受请求,把事件的处理交给线程池中的线程去处理:
将处理器的执行放入线程池,多线程进行业务处理。但Reactor仍为单个线程,无法去并行的去响应多个客户端,那么要怎么处理呢?
NIO + 主从多线程Reactor模式
mainReactor负责监听连接(可能包括3次握手,安全接入认证),accept连接给subReactor处理(读写及编解码),具体业务处理由subReactor交给业务线程池处理,为什么要单独分一个Reactor来处理监听呢?因为像TCP这样需要经过3次握手才能建立连接,这个建立连接的过程也是要耗时间和资源的,单独分一个Reactor来处理,可以提高性能。
select、poll、epoll
select、poll、epoll这3个命令是linux中实现IO多路复用(IO multiplexing,也称事件驱动型IO event driven IO)的系统调用。
select、 poll 及 epoll实现的均是同步非阻塞IO。
select
select仅仅知道有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。select创建3个文件描述符集并拷贝到内核中,分别监听读、写、异常动作。
select有以下缺点:
- 文件描述符个数受限:单进程能够监控的文件描述符的数量存在最大限制,在Linux上32/64位系统一般为1024/2048,可以通过修改宏定义
__FD_SETSIZE 增大上限,但同样存在效率低的弱势; - select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
- 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
poll
poll本质上和select没有区别,依然存在select的2,3两个问题,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它解决了select的第1个问题,没有最大连接数的限制,原因是它是基于链表来存储的。
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
epoll
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时对这些流的操作都是有意义的(复杂度降低到了O(1))。
int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll第3步就绪队列的返回,文件描述符列表是通过mmap让内核和用户空间共享同一块内存实现传递的,减少了不必要的拷贝。
可以看到select、poll、epoll_wait都需要用户进程主动查询就绪的文件描述符列表,并不是回调等模式(虽然epoll的就绪列表更新是回调的,但是用户进程获取还是epoll_wait查询),因此是同步的,3者都提供了超时时间timeout =0时立即返回不阻塞的模式,因此都是同步非阻塞IO。
注意:虽然epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
异步非阻塞 AIO
调用者发起一个调用后,调用者转而可以去干其他事,当被调用者处理完成后会通知调用者处理结果
NIO是同步的IO,是因为程序需要IO操作时,必须获得了IO权限后亲自进行IO操作才能进行下一步操作。AIO是对NIO的改进(所以AIO又叫NIO.2),它是基于Proactor模型的。每个socket连接在事件分离器注册 IO完成事件 和 IO完成事件处理器。程序需要进行IO时,向分离器发出IO请求并把所用的Buffer区域告知分离器,分离器通知操作系统进行IO操作,操作系统自己不断尝试获取IO权限并进行IO操作(数据保存在Buffer区),操作完成后通知分离器;分离器检测到 IO完成事件,则激活 IO完成事件处理器,处理器会通知程序说“IO已完成”,程序知道后就直接从Buffer区进行数据的读写。
- 在 Windows 操作系统中,提供了一个叫做 I/O Completion Ports 的方案,通常简称为 IOCP,操作系统负责管理线程池,其性能非常优异,所以在 Windows 中 JDK 直接采用了 IOCP 的支持。
- 而在 Linux 中其实也是有AIO 的实现的,但是限制比较多,性能也一般,所以 JDK 采用了自建线程池的方式,也就是说JDK并没有用Linux提供的AIO。
参考:
Netty序章之BIO NIO AIO演变
Netty基础-BIO/NIO/AIO
select、poll、epoll之间的区别(搜狗面试)
彻底搞懂epoll高效运行的原理
|