业务高并发离不开,Netty\Redis\Zookeeper等分布式高性能工具,涉及到高并发肯定离不开频繁的IO读写操作,IO底层原理是这些高并发工具的一个基石;
IO的底层原理
当我们聊IO底层原理的时候我们应该想到哪些关键问题节点?
- 用户进程进行IO读写,对应操作系统内核(kernel)底层的IO的read/write必然有区分,是否是物理设备直接的读写?(必然不是)?
- 用户进程、系统内核 进行read、write操作是缓冲区起到了什么作用?
- 4种IO模型的流程?(同步阻塞、同步非阻塞、IO多路复用、异步IO)
- IO阻塞过程,cup执行的内核态用户态是如何进行切换的?(参考:CPU用户态和内核态)
- Java NIO的IO模型(io多路复用)?三个组件(channel、buffer、selector)?NIO在网络通信中的应用?
- Reactor反应器主要是用来解决什问题而产生的(Java OIO 网络通信阻塞式的等待连接,进而引入的一连接一线程,但资源消耗严重)?该模型如何应用到Netty框架的?
基于这些问题go on;
内核缓存区和进程缓冲区
首先我们应该明白一点就是在进行一次read/write(后续rw简写)系统调用或socket套接字调用,并不是直接进行物理设备和内存之间的数据置换;他们都是通过一层缓冲区进行数据置换的;
-
调用操作系统的read,是把数据从系统内核缓冲区复制到进程缓冲区;而w调用是把数据从进程缓冲区复制到内核缓存区;
- 调用socket套接字的r,是把数据从系统内核缓冲区复制到网卡(这里我理解网卡类似用户进程缓冲区);而w调用是把数据从网卡复制到内核缓存区;
- 无论是socket的IO,还是文件的IO都是上层应用的开发,他们属于输入Input和输出Output,在编程流程上都是一致的;
为何要设置这么多的缓存区呢?
缓存区的目的是为了减少频繁的与设备之间的物理交换,我们清楚外部物理设备的直接读写,涉及到操作系统的中断。发生中断时要保留进程的数据和状态信息,而结束中断后要恢复。为了减少这种中断带来底层系统的时间和性能损耗,于是出现了内存缓冲区;
有了内核缓冲区,操作系统会对内核缓存区进行监控,等待缓存区数据达到一定大小时,才进行IO设备的中断处理,集中执行物理设备的IO操作,这种机制提升系统性能;用户进程缓存区,则是将用户进程的IO操作集中处理;用户进程的IO操作实际上是用户进程缓存区和内核缓冲区之间的数据置换;
read一个socket套接字的流程如下:
- 客户端请求:Linux通过网卡读取客户端的请求数据,将数据读取到内核缓存区
- 获取数据请求:java服务器通过read调用,从linux内核缓冲区读取数据,再送入到java进程缓冲区。
- 服务器端业务处理:java服务器在自己的用户空间处理客户端的请求。
- 服务器端返回数据:java服务器完成处理后,通过系统write调用,将数据从系统内核缓冲区写入到用户缓冲区。
- 发送数据给客户端:系统内核通过网络IO,将内核缓冲区数据写入到网卡,网卡通过底层通信协议将数据发送给目标客户端;
四种主流的IO通信模型
同步阻塞IO(Blocking IO)
优点:程序开发简单,阻塞等待数据期间,用户线程挂起;阻塞期间,用户线程基本不会占用CPU;
缺点:每个连接会开启一个独立线程,维护一个连接的IO;并发量高的情况下,维护大量的网络连接,内存、线程切换开销巨大;不适合高并发。
同步非租塞IO(None Blocking IO)
优点:每次发起IO系统调用,在内核等待数据过程中可以立即返回。用户线程并不会阻塞,实时性好;
缺点:用户进程要想拿到数据,需要不断轮询,这将占用大量cpu;这里的同步非租塞IO不是java的NIO(NIO采用的IO多路复用);
IO多路复用(IO Multiplexing)
查询IO数据就绪状态的轮询过程交给了select/epoll调用完成,通过这种调用方式,一个进程可以监视多个文件描述符(文件句柄),一旦某个文件描述符数据准备就绪(内核缓存区可读、写),内核就将就绪的状态返回给用户进程,随后用户程序进行就绪状态数据的IO rw系统调用;与NIO模型相比,IO多路复用模型涉及两种系统调用,一种是select/epoll(就绪查询),另一种是IO操作;NIO只有IO操作;
流程:
- 选择器注册:read操作的目标socket网络连接,提前注册到select/epoll选择器中,Java对应Selector类。
- 就绪状态轮询:用户进程通过调用选择器的查询方法,查询注册过的所有socket连接的就绪状态。查询方法内核会返回一个socket的就绪列表;当任何一个注册过的socket中的数据准备好了,内核缓存区有数据了,内核将就绪socket加入到就绪列表返回;调用select查询是整个线程是阻塞的;
- 用户线程就绪socket列表,进行socket的read系统调用,用户线程阻塞,内核复制数据到用户缓存区;
- 用户线程解除阻塞,执行后续流程;
优点:与一个线程维护一个连接的阻塞IO模式相比,使用select/epoll的最大优势在于,一个选择器查询线程可以同时处理成千上万个连接。系统不必创建大量的线程,也不必维护这些线程,大大减小系统开销;
缺点:本质上select/epoll系统调用是阻塞式的,属于同步IO。都需要在读写事件就绪后,由系统调用本身负责进行读写,这个读写过程是阻塞的。
异步IO(Asynchronous IO)
用户线程通过系统调用,向内核注册某个IO操作。内核在整个IO操作(从网卡读取数据到内核缓冲区、数据复制到用户缓冲区)完成后,通知用户程序,用户程序执行后续业务操作;内核的整个IO操作过程中用户程序不阻塞;
优点:在内核等待数据和复制两个阶段,用户线程都不阻塞。用户线程接收内核IO操作完成的事件,或者用户线程需要注册一个IO操作完成的回调函数。吞吐量最大;
缺点:应用程序需要进行事件注册和接收,其余工作交给系统内核,底层系统内核需要支持;Linux 2.6版本才引入异步IO,目前不完善。
Java NIO 通信详解
之前就说了java NIO是基础IO多路复用模型进行通信的,New IO也有人称为非阻塞IO(Non-Block IO),弥补了老式IO(OIO)面向流阻塞的不足,它是面向缓冲区的;Java NIO被应用到文件读写和网络通信中;
提供了三个核心组件:
- Channel(通道):同一个网络连接关联的两个流的结合体,既可以从通道读取,也可以向通道写入;4种channel(FileChannel(不能设置非租塞模式),SocketChannel.ServerSocketChannel,DataGrameChannel)
- Buffer(缓冲区):读取和写入只需要从通道中将数据rw到缓冲区,可以随便一读取buffer的任何位置;8种数据类型(ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer、MappedBuffer),3个重要属性(capacity\position\limit);
- Selector(选择器):实现IO多路复用的关键,首先把通道注册到选择器中,选择器提供select方法进行select/epoll的系统调用,查询注册的通道是否有就绪的IO事件(选择键)(可读、可写、网络连接完成);
与Java OIO相比,java NIO网络通信编程特点大致如下:
(1)、在NIO中,服务器接收新连接的工作,是异步进行的。不像java的OIO那样,服务器监听连接是同步、阻塞的。NIO可以通过选择器(多路复用器)查看IO事件的就绪状态,后续只需不断进行轮询选择器中的选择键集合,选择新到来的连接;
(2)、NIO中SocketChannel通道的读写操作都是异步的。如果没有可读写的数据,负责IO的线程不会阻塞同步等待,线程可以处理其他的连接通道;
(3)、NIO中选择器可以同时处理成千上万个客户端连接,性能会随着客户端的增加而线性下降;
Reactor反应器模式
现状:高并发通信中间件Netty、Nginx、Redis等都是基于Reactor反应器模式实现的;
背景:在Reactor反应器模式出现之前;Java 原始的OIO编程,网络服务器程序,是通过一个while循环,不断的监听端口是否有新的连接。如果有才调用处理函数处理。示例代码
while(true){
socket=accept(); //阻塞,接收连接
hadler(socket); //读取数据,业务处理,写入结果
}
这种方法问题在一个连接handler(socket) 没有处理完,那么后面的连接请求没法被接收,于是后面的请求统统会被阻塞住,服务器的吞吐量就太低了。
进而引入了Connection Per Thread(一线程处理一连接)模式。代码逻辑如下:
class ConnectionPerThread implements Runnable{
public void run(){
try{
ServerSocket serverSocket=new ServerSocket(SOCKET_SERVER_PORT);
while(!Thread.interrupted()){
Socket socket = serverSocket.accept();
//接收一个连接后,为socket连接,新建一个专属的处理器对象
Handler handler=new Handler(socket);
//创建新线程专门处理这个连接
new Thread(handler).start();
}
}catch(Exception e){
//异常处理
}
}
}
static class Handler implements Runnable{
final Socket socket;
Handler(Socket s){
this.socket=s;
}
while(true){
try{
byte[] input=new byte[1024];
//读取数据源
socket.getInputStream().read(input);
//业务逻辑处理
....
byte[] output=null;
//写入结果
socket.getOutputStream().write(outPut);
}catch(Exception e){
//异常处理
}
}
}
解决了前面新连接被严重阻塞的问题,在一定程度上,极大提高了服务器的吞吐量;但是对于成千上万的连接,需要耗费大量线程资源(创建、销毁、线程切换代价太高);所以引入了Reactor反应器模式,对线程数据量进行控制,做到一个线程可以处理大量的连接;
反应器模式Reactor由反应器线程、Handler处理器两大角色组成:
- Reactor反应器职责:负责查询IO事件,就是检查NIO中注册到选择器上的IO事件(读、写、连接建立、连接接受),并分发到Handlers处理器。
- Handlers处理器的职责:与IO事件(或者叫选择键)绑定,非阻塞的处理IO事件,执行真正建立连接、通道数据读取、业务处理逻辑、写入到通道等。
单线程的Reactor反应器模式
Reactor选择器需要用到SelectionKey提供的两个方法:
- 一个void attach(Object o) 方法,将处理器作为附件绑定到选择键(或者叫做事件)上;
- 另一个方法Object attachment() ,可以从选择键上获取到绑定handler处理器;
通信服务端的Reactor反应器和处理器在同一个线程中:
public class Reactor implements Runnable {
private final ServerSocketChannel ssc;
private final Selector selector;
public TCPReactor(int port) throws IOException {
selector = Selector.open();
ssc = ServerSocketChannel.open();
InetSocketAddress addr = new InetSocketAddress(port);
// 在ServerSocketChannel绑定监听端口
ssc.socket().bind(addr);
// 设置ServerSocketChannel为非阻塞
ssc.configureBlocking(false);
// ServerSocketChannel向selector注册一个OP_ACCEPT事件,然后返回通道的key
SelectionKey sk = ssc.register(selector, SelectionKey.OP_ACCEPT);
// 核心1:给选择key绑定一个附加的Acceptor处理器
sk.attach(new Acceptor(selector, ssc));
}
@Override
public void run() {
// 在线程被中断前持续运行
while (!Thread.interrupted()) {
System.out.println("Waiting for new event on port:"+ssc.socket().getLocalPort() + "...");
try {
// 若沒有事件就就绪则不往下执行
if (selector.select() == 0)
continue;
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
// 取得所有已就绪事件的key集合
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> it = selectedKeys.iterator();
while (it.hasNext()) {
// 根據事件的key進行調度
dispatch((SelectionKey) (it.next()));
// 处理完后从就绪事件列表中移除
it.remove();
}
}
}
/*
* name: dispatch(SelectionKey key)
* description: 調度方法,根據事件绑定的对象新开线程
*/
private void dispatch(SelectionKey key) {
// 核心2:根据事件之key绑定的对象新开线程
Runnable r = (Runnable) (key.attachment());
if (r != null)
r.run();
}
class AcceptorHandler implements Runnable {
private final ServerSocketChannel ssc;
private final Selector selector;
// 核心3:使用同一个选择器注册其他事件
public Acceptor(Selector selector, ServerSocketChannel ssc) {
this.ssc=ssc;
this.selector=selector;
}
@Override
public void run() {
try {
// 接受client连接请求
SocketChannel sc= ssc.accept();
System.out.println(sc.socket().getRemoteSocketAddress().toString() + " is connected.");
if(sc!=null) {
// 设置为非阻塞
sc.configureBlocking(false);
// SocketChannel向selector注册一个OP_READ事件,然后返回该通道的key
SelectionKey sk = sc.register(selector, SelectionKey.OP_READ);
// 使一个阻塞住的selector操作立即返回
selector.wakeup();
// 给定key一个附加的IOHandler处理象
sk.attach(new IOHandler(sk, sc));
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
class IOHandler implements Runnable {
private final SelectionKey sk;
private final SocketChannel sc;
int state;
public TCPHandler(SelectionKey sk, SocketChannel sc) {
this.sk = sk;
this.sc = sc;
state = 0; // 初始狀態設定為READING
}
@Override
public void run() {
try {
if (state == 0)
read(); // 讀取網絡數據
else
send(); // 發送網絡數據
} catch (IOException e) {
System.out.println("[Warning!] A client has been closed.");
closeChannel();
}
}
private void closeChannel() {
try {
sk.cancel();
sc.close();
} catch (IOException e1) {
e1.printStackTrace();
}
}
private synchronized void read() throws IOException {
// non-blocking下不可用Readers,因為Readers不支援non-blocking
byte[] arr = new byte[1024];
ByteBuffer buf = ByteBuffer.wrap(arr);
int numBytes = sc.read(buf); // 讀取字符串
if(numBytes == -1)
{
System.out.println("[Warning!] A client has been closed.");
closeChannel();
return;
}
String str = new String(arr); // 將讀取到的byte內容轉為字符串型態
if ((str != null) && !str.equals(" ")) {
process(str); // 邏輯處理
System.out.println(sc.socket().getRemoteSocketAddress().toString()
+ " > " + str);
state = 1; // 改變狀態
sk.interestOps(SelectionKey.OP_WRITE); // 通過key改變通道註冊的事件
sk.selector().wakeup(); // 使一個阻塞住的selector操作立即返回
}
}
private void send() throws IOException {
// get message from message queue
String str = "Your message has sent to "
+ sc.socket().getLocalSocketAddress().toString() + "\r\n";
ByteBuffer buf = ByteBuffer.wrap(str.getBytes()); // wrap自動把buf的position設為0,所以不需要再flip()
while (buf.hasRemaining()) {
sc.write(buf); // 回傳給client回應字符串,發送buf的position位置 到limit位置為止之間的內容
}
state = 0; // 改變狀態
sk.interestOps(SelectionKey.OP_READ); // 通過key改變通道註冊的事件
sk.selector().wakeup(); // 使一個阻塞住的selector操作立即返回
}
void process(String str) {
// do process(decode, logically process, encode)..
// ..
}
}
}
通信服务端示例代码的关键:
- Reactor反应器建立完serverSocket管道后,初始注册一个OP_ACCEPT事件(选择键)到,Selector选择器中;并为该事件(选择键)绑定一个连接建立AccptorHandler处理器;
- Reactor的服务线程,会循环不断通过Selector选择器的select调用轮询就绪事件;当有就绪事件(选择键)时分发给相应的事件处理器;初始建立连接后会轮询到一个OP_ACCEPT就绪键,分发给AccetorHandler处理器;
- AccetorHandler处理器的作用两个,一个是接受新连接SocketChannel;另一个改变选择键的类型为OP_READ,并创建一个输入输出的IOHandler处理器,作为附件绑定到OP_READ选择键上,AccetorHandler处理器和Reactor反应器使用同一个选择器;
- IOHandler,负责数据输入、业务处理、结果输出;业务处理后,改变interestOps改变注册事件为OP_WTRIT,并唤醒阻塞事件(OP_READ);
- Reactor反应器,selector选择器轮询获取到OP_WRITE事件后,会在分发时获取对应的IOHandler处理器;进行结果返回处理;
优点:相对于传统的一连接一线程,是基于Java NIO实现,反应器模式不用再启动成千上万的线程了。
缺点:当其中某个handler处理器阻塞时,也会导致其他所有handler无法执行;handler不仅仅负责输入、输出业务处理,还包括建 立连接监听的AcceptorHandler处理器,问题很严重;其次也不能充分利用多核心资源;
多线程的Reactor反应器模式
多线程池的Reactor反应器演进在两个方面:
- 首先升级Handler处理器,既要使用多线程,又要高效率,则可以考虑使用线程池。
- 其次升级Reactor反应器,考虑引入多个选择器,替身选择大量ServerSocketChannel通道的能力;
总体设计思路如下:
- 负责输入输出处理的IOHandler处理器的执行,放入独立的线程池中,这样业务处理线程和负责服务监听和IO事件查询的反应器线程相互隔离,避免服务器连接监听收到阻塞。
- 如果服务器是多核的CPU,可以将反应器线程拆分为多个自反应器(SubReactor)线程;同时引入多个选择器,每个SubReactor子线程负责一个选择器。这样充分释放了系统资源的能力,也提高了反应器管理大量连接,提升选择大量通道的能力;
实例代码参考https://www.cnblogs.com/crazymakercircle/p/9833847.html 5.3. 多线程Reactor的参考代码