乐趣区

关于redis:Redis线程模型的前世今生

一、概述

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

退出移动版