一、概述
家喻户晓,Redis是一个高性能的数据存储框架,在高并发的零碎设计中,Redis也是一个比拟要害的组件,是咱们晋升零碎性能的一大利器。深刻去了解Redis高性能的原理显得越发重要,当然Redis的高性能设计是一个系统性的工程,波及到很多内容,本文重点关注Redis的IO模型,以及基于IO模型的线程模型。
咱们从IO的起源开始,讲述了阻塞IO、非阻塞IO、多路复用IO。基于多路复用IO,咱们也梳理了几种不同的Reactor模型,并剖析了几种Reactor模型的优缺点。基于Reactor模型咱们开始了Redis的IO模型和线程模型的剖析,并总结出Redis线程模型的长处、毛病,以及后续的Redis多线程模型计划。本文的重点是对Redis线程模型设计思维的梳理,捋顺了设计思维,就是一通百通的事了。
注:本文的代码都是伪代码,次要是为了示意,不可用于生产环境。
二、网络IO模型发展史
咱们常说的网络IO模型,次要蕴含阻塞IO、非阻塞IO、多路复用IO、信号驱动IO、异步IO,本文重点关注跟Redis相干的内容,所以咱们重点剖析阻塞IO、非阻塞IO、多路复用IO,帮忙大家后续更好的了解Redis网络模型。
咱们先看上面这张图;
2.1 阻塞IO
咱们常常说的阻塞IO其实分为两种,一种是单线程阻塞,一种是多线程阻塞。这外面其实有两个概念,阻塞和线程。
阻塞:指调用后果返回之前,以后线程会被挂起,调用线程只有在失去后果之后才会返回;
线程:零碎调用的线程个数。
像建设连贯、读、写都波及到零碎调用,自身是一个阻塞的操作。
2.1.1 单线程阻塞
服务端单线程来解决,当客户端申请来长期,服务端用主线程来解决连贯、读取、写入等操作。
以下用代码模仿了单线程的阻塞模式;
import java.net.Socket; public class BioTest { public static void main(String[] args) throws IOException { ServerSocket server=new ServerSocket(8081); while(true) { Socket socket=server.accept(); System.out.println("accept port:"+socket.getPort()); BufferedReader in=new BufferedReader(new InputStreamReader(socket.getInputStream())); String inData=null; try { while ((inData = in.readLine()) != null) { System.out.println("client port:"+socket.getPort()); System.out.println("input data:"+inData); if("close".equals(inData)) { socket.close(); } } } catch (IOException e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } }}
咱们筹备用两个客户端同时发动连贯申请、来模仿单线程阻塞模式的景象。同时发动连贯,通过服务端日志,咱们发现此时服务端只承受了其中一个连贯,主线程被阻塞在上一个连贯的read办法上。
咱们尝试敞开第一个连贯,看第二个连贯的状况,咱们心愿看到的景象是,主线程返回,新的客户端连贯被承受。
从日志中发现,在第一个连贯被敞开后,第二个连贯的申请被解决了,也就是说第二个连贯申请在排队,直到主线程被唤醒,能力接管下一个申请,合乎咱们的预期。
此时不仅要问,为什么呢?
次要起因在于accept、read、write三个函数都是阻塞的,主线程在零碎调用的时候,线程是被阻塞的,其余客户端的连贯无奈被响应。
通过以上流程,咱们很容易发现这个过程的缺点,服务器每次只能解决一个连贯申请,CPU没有失去充分利用,性能比拟低。如何充分利用CPU的多核个性呢?自然而然的想到了——多线程逻辑。
2.1.2 多线程阻塞
对工程师而言,代码解释所有,间接上代码。
BIO多线程
package net.io.bio; import java.io.BufferedReader;import java.io.IOException;import java.io.InputStreamReader;import java.net.ServerSocket;import java.net.Socket; public class BioTest { public static void main(String[] args) throws IOException { final ServerSocket server=new ServerSocket(8081); while(true) { new Thread(new Runnable() { public void run() { Socket socket=null; try { socket = server.accept(); System.out.println("accept port:"+socket.getPort()); BufferedReader in=new BufferedReader(new InputStreamReader(socket.getInputStream())); String inData=null; while ((inData = in.readLine()) != null) { System.out.println("client port:"+socket.getPort()); System.out.println("input data:"+inData); if("close".equals(inData)) { socket.close(); } } } catch (IOException e) { e.printStackTrace(); } finally { } } }).start(); } } }
同样,咱们并行发动两个申请;
两个申请,都被承受,服务端新增两个线程来解决客户端的连贯和后续申请。
咱们用多线程解决了,服务器同时只能解决一个申请的问题,但同时又带来了一个问题,如果客户端连贯比拟多时,服务端会创立大量的线程来解决申请,但线程自身是比拟耗资源的,创立、上下文切换都比拟耗资源,又如何去解决呢?
2.2 非阻塞
如果咱们把所有的Socket(文件句柄,后续用Socket来代替fd的概念,尽量减少概念,加重浏览累赘)都放到队列里,只用一个线程来轮训所有的Socket的状态,如果筹备好了就把它拿进去,是不是就缩小了服务端的线程数呢?
一起看下代码,单纯非阻塞模式,咱们基本上不必,为了演示逻辑,咱们模仿了相干代码如下;
package net.io.bio; import java.io.BufferedReader;import java.io.IOException;import java.io.InputStreamReader;import java.net.ServerSocket;import java.net.Socket;import java.net.SocketTimeoutException;import java.util.ArrayList;import java.util.List; import org.apache.commons.collections4.CollectionUtils; public class NioTest { public static void main(String[] args) throws IOException { final ServerSocket server=new ServerSocket(8082); server.setSoTimeout(1000); List<Socket> sockets=new ArrayList<Socket>(); while (true) { Socket socket = null; try { socket = server.accept(); socket.setSoTimeout(500); sockets.add(socket); System.out.println("accept client port:"+socket.getPort()); } catch (SocketTimeoutException e) { System.out.println("accept timeout"); } //模仿非阻塞:轮询已连贯的socket,每个socket期待10MS,有数据就解决,无数据就返回,持续轮询 if(CollectionUtils.isNotEmpty(sockets)) { for(Socket socketTemp:sockets ) { try { BufferedReader in=new BufferedReader(new InputStreamReader(socketTemp.getInputStream())); String inData=null; while ((inData = in.readLine()) != null) { System.out.println("input data client port:"+socketTemp.getPort()); System.out.println("input data client port:"+socketTemp.getPort() +"data:"+inData); if("close".equals(inData)) { socketTemp.close(); } } } catch (SocketTimeoutException e) { System.out.println("input client loop"+socketTemp.getPort()); } } } } }}
零碎初始化,期待连贯;
发动两个客户端连贯,线程开始轮询两个连贯中是否有数据。
两个连贯别离输出数据后,轮询线程发现有数据筹备好了,开始相干的逻辑解决(单线程、多线程都可)。
再用一张流程图辅助解释下(零碎理论采纳文件句柄,此时用Socket来代替,不便大家了解)。
服务端专门有一个线程来负责轮询所有的Socket,来确认操作系统是否实现了相干事件,如果有则返回解决,如果无持续轮询,大家一起来思考下?此时又带来了什么问题呢。
CPU的空转、零碎调用(每次轮询到波及到一次零碎调用,通过内核命令来确认数据是否筹备好),造成资源的节约,那有没有一种机制,来解决这个问题呢?
2.3 IO多路复用
server端有没专门的线程来做轮询操作(应用程序端非内核),而是由事件来触发,当有相干读、写、连贯事件到来时,被动唤起服务端线程来进行相干逻辑解决。模仿了相干代码如下;
IO多路复用
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.nio.charset.Charset;import java.util.Iterator;import java.util.Set; public class NioServer { private static Charset charset = Charset.forName("UTF-8"); public static void main(String[] args) { try { Selector selector = Selector.open(); ServerSocketChannel chanel = ServerSocketChannel.open(); chanel.bind(new InetSocketAddress(8083)); chanel.configureBlocking(false); chanel.register(selector, SelectionKey.OP_ACCEPT); while (true){ int select = selector.select(); if(select == 0){ System.out.println("select loop"); continue; } System.out.println("os data ok"); Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iterator = selectionKeys.iterator(); while (iterator.hasNext()){ SelectionKey selectionKey = iterator.next(); if(selectionKey.isAcceptable()){ ServerSocketChannel server = (ServerSocketChannel)selectionKey.channel(); SocketChannel client = server.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); //持续能够接管连贯事件 selectionKey.interestOps(SelectionKey.OP_ACCEPT); }else if(selectionKey.isReadable()){ //失去SocketChannel SocketChannel client = (SocketChannel)selectionKey.channel(); //定义缓冲区 ByteBuffer buffer = ByteBuffer.allocate(1024); StringBuilder content = new StringBuilder(); while (client.read(buffer) > 0){ buffer.flip(); content.append(charset.decode(buffer)); } System.out.println("client port:"+client.getRemoteAddress().toString()+",input data: "+content.toString()); //清空缓冲区 buffer.clear(); } iterator.remove(); } } } catch (Exception e) { e.printStackTrace(); } }}
同时创立两个连贯;
两个连贯无阻塞的被创立;
无阻塞的接管读写;
再用一张流程图辅助解释下(零碎理论采纳文件句柄,此时用Socket来代替,不便大家了解)。
当然操作系统的多路复用有好几种实现形式,咱们常常应用的select(),epoll模式这里不做过多的解释,有趣味的能够查看相干文档,IO的倒退前面还有异步、事件等模式,咱们在这里不过多的赘述,咱们更多的是为了解释Redis线程模式的倒退。
三、NIO线程模型解释
咱们一起来聊了阻塞、非阻塞、IO多路复用模式,那Redis采纳的是哪种呢?
Redis采纳的是IO多路复用模式,所以咱们重点来理解下多路复用这种模式,如何在更好的落地到咱们零碎中,不可避免的咱们要聊下Reactor模式。
首先咱们做下相干的名词解释;
Reactor:相似NIO编程中的Selector,负责I/O事件的派发;
Acceptor:NIO中接管到事件后,解决连贯的那个分支逻辑;
Handler:音讯读写解决等操作类。
3.1 单Reactor单线程模型
解决流程
- Reactor监听连贯事件、Socket事件,当有连贯事件过去时交给Acceptor解决,当有Socket事件过去时交个对应的Handler解决。
长处
- 模型比较简单,所有的处理过程都在一个连贯里;
- 实现上比拟容易,模块性能也比拟解耦,Reactor负责多路复用和事件散发解决,Acceptor负责连贯事件处理,Handler负责Scoket读写事件处理。
毛病
- 只有一个线程,连贯解决和业务解决共用一个线程,无奈充分利用CPU多核的劣势。
- 在流量不是特地大、业务解决比拟快的时候零碎能够有很好的体现,当流量比拟大、读写事件比拟耗时状况下,容易导致系统呈现性能瓶颈。
怎么去解决上述问题呢?既然业务解决逻辑可能会影响零碎瓶颈,那咱们是不是能够把业务解决逻辑单拎进去,交给线程池来解决,一方面减小对主线程的影响,另一方面利用CPU多核的劣势。这一点心愿大家要了解透彻,不便咱们后续了解Redis由单线程模型到多线程模型的设计的思路。
3.2 单Reactor多线程模型
这种模型绝对单Reactor单线程模型,只是将业务逻辑的解决逻辑交给了一个线程池来解决。
解决流程
- Reactor监听连贯事件、Socket事件,当有连贯事件过去时交给Acceptor解决,当有Socket事件过去时交个对应的Handler解决。
- Handler实现读事件后,包装成一个工作对象,交给线程池来解决,把业务解决逻辑交给其余线程来解决。
长处
- 让主线程专一于通用事件的解决(连贯、读、写),从设计上进一步解耦;
- 利用CPU多核的劣势。
毛病
- 貌似这种模型曾经很完满了,咱们再思考下,如果客户端很多、流量特地大的时候,通用事件的解决(读、写)也可能会成为主线程的瓶颈,因为每次读、写操作都波及零碎调用。
有没有什么好的方法来解决上述问题呢?通过以上的剖析,大家有没有发现一个景象,当某一个点成为零碎瓶颈点时,想方法把他拿进去,交个其余线程来解决,那这种场景是否实用呢?
3.3 多Reactor多线程模型
这种模型绝对单Reactor多线程模型,只是将Scoket的读写解决从mainReactor中拎进去,交给subReactor线程来解决。
解决流程
- mainReactor主线程负责连贯事件的监听和解决,当Acceptor解决完连贯过程后,主线程将连贯调配给subReactor;
- subReactor负责mainReactor调配过去的Socket的监听和解决,当有Socket事件过去时交个对应的Handler解决;
Handler实现读事件后,包装成一个工作对象,交给线程池来解决,把业务解决逻辑交给其余线程来解决。
长处
- 让主线程专一于连贯事件的解决,子线程专一于读写事件吹,从设计上进一步解耦;
- 利用CPU多核的劣势。
毛病
- 实现上会比较复杂,在极度谋求单机性能的场景中能够思考应用。
四、Redis的线程模型
4.1 概述
以上咱们聊了,IO网路模型的倒退历史,也聊了IO多路复用的reactor模式。那Redis采纳的是哪种reactor模式呢?在答复这个问题前,咱们先梳理几个概念性的问题。
Redis服务器中有两类事件,文件事件和工夫事件。
文件事件:在这里能够把文件了解为Socket相干的事件,比方连贯、读、写等;
工夫工夫:能够了解为定时工作事件,比方一些定期的RDB长久化操作。
本文重点聊下Socket相干的事件。
4.2 模型图
首先咱们来看下Redis服务的线程模型图;
IO多路复用负责各事件的监听(连贯、读、写等),当有事件产生时,将对应事件放入队列中,由事件散发器依据事件类型来进行散发;
如果是连贯事件,则散发至连贯应答处理器;GET、SET等redis命令散发至命令申请处理器。
命令解决完后产生命令回复事件,再由事件队列,到事件散发器,到命令回复处理器,回复客户端响应。
4.3 一次客户端和服务端的交互流程
4.3.1 连贯流程
连贯过程
- Redis服务端主线程监听固定端口,并将连贯事件绑定连贯应答处理器。
- 客户端发动连贯后,连贯事件被触发,IO多路复用程序将连贯事件包装好后丢人事件队列,而后由事件散发处理器分发给连贯应答处理器。
- 连贯应答处理器创立client对象以及Socket对象,咱们这里关注Socket对象,并产生ae_readable事件,和命令处理器关联,标识后续该Socket对可读事件感兴趣,也就是开始接管客户端的命令操作。
- 以后过程都是由一个主线程负责解决。
4.3.2 命令执行流程
SET命令执行过程
- 客户端发动SET命令,IO多路复用程序监听到该事件后(读事件),将数据包装成事件丢到事件队列中(事件在上个流程中绑定了命令申请处理器);
- 事件散发处理器依据事件类型,将事件分发给对应的命令申请处理器;
- 命令申请处理器,读取Socket中的数据,执行命令,而后产生ae_writable事件,并绑定命令回复处理器;
- IO多路复用程序监听到写事件后,将数据包装成事件丢到事件队列中,事件散发处理器依据事件类型散发至命令回复处理器;
- 命令回复处理器,将数据写入Socket中返回给客户端。
4.4 模型优缺点
以上流程剖析咱们能够看出Redis采纳的是单线程Reactor模型,咱们也剖析了这种模式的优缺点,那Redis为什么还要采纳这种模式呢?
Redis自身的个性
命令执行基于内存操作,业务解决逻辑比拟快,所以命令解决这一块单线程来做也能维持一个很高的性能。
长处
- Reactor单线程模型的长处,参考上文。
毛病
- Reactor单线程模型的毛病也同样在Redis中来体现,惟一不同的中央就在于业务逻辑解决(命令执行)这块不是零碎瓶颈点。
- 随着流量的上涨,IO操作的的耗时会越来越显著(read操作,内核中读数据到应用程序。write操作,应用程序中的数据到内核),当达到肯定阀值时零碎的瓶颈就体现进去了。
Redis又是如何去解的呢?
哈哈~将耗时的点从主线程拎进去呗?那Redis的新版本是这么做的吗?咱们一起来看下。
4.5 Redis多线程模式
Redis的多线程模型跟”多Reactor多线程模型“、“单Reactor多线程模型有点区别”,但同时用了两种Reactor模型的思维,具体如下;
Redis的多线程模型是将IO操作多线程化,自身逻辑处理过程(命令执行过程)仍旧是单线程,借助了单Reactor思维,实现上又有所辨别。
将IO操作多线程化,又跟单Reactor衍生出多Reactor的思维统一,都是将IO操作从主线程中拎进去。
命令执行大抵流程
- 客户端发送申请命令,触发读就绪事件,服务端主线程将Socket(为了简化了解老本,对立用Socket来代表连贯)放入一个队列,主线程不负责读;
- IO 线程通过Socket读取客户端的申请命令,主线程忙轮询,期待所有 I/O 线程实现读取工作,IO线程只负责读不负责执行命令;
- 主线程一次性执行所有命令,执行过程和单线程一样,而后须要返回的连贯放入另外一个队列中,有IO线程来负责写出(主线程也会写);
- 主线程忙轮询,期待所有 I/O 线程实现写出工作。
五、总结
理解一个组件,更多的是要去理解他的设计思路,要去思考为什么要这么设计,做这种技术选型的背景是啥,对后续做零碎架构设计有什么参考意义等等。一通百通,心愿对大家有参考意义。
作者:vivo互联网服务器团队-Wang Shaodong