一、概述

家喻户晓,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