前言

"redis是单线程的" 这句话我们耳熟能详。但它有一定的前提,redis整个服务不可能只用到一个线程完成所有工作,它还有持久化、key过期删除、集群管理等其它模块,redis会通过fork子进程或开启额外的线程去处理。所谓的单线程是指从网络连接(accept) -> 读取请求内容(read) -> 执行命令 -> 响应内容(write),这整个过程是由一个线程完成的,至于为什么redis要设计为单线程,主要有以下原因:

  1. 基于内存。redis命令操作主要都是基于内存,这已经足够快,不需要借助多线程。
  2. 高效的数据结构。redis底层提供了动态简单动态字符串(SDS)、跳表(skiplist)、压缩列表(ziplist)等数据结构来高效访问数据。
  3. 保持简单。引入多线程会使redis变得复杂,例如需要考虑多线程并发访问资源竞争问题,数据结构也会变得复杂,hash就不能是单纯的hash,需要像java一样设计一个ConcurrentHashMap。还需要考虑线程切换带来的性能损耗,基于第一点,当程序执行已经足够快,多线程并不能带来正面收益。

按照redis官方介绍,单个节点的redis qps可以达到10w+,已经非常优秀,如果有更高的要求,则可以通过部署主从、集群方式进一步提升。

单线程不是没有缺点的,我们需要辩证的看待问题,不然所有的组件都可以使用redis替代了。首先是基于内存的操作有丢失数据的风险,尽管你可以配置appendfsync always每次将执行请求通过aof文件持久化,但这也会带来性能的下降。另外单线程的执行意味着所有的请求都需要排队执行,如果有一个命令阻塞了,其它命令也都执行不了,可以与之比较的是mysql,如果有一条sql语句执行比较慢,只要它不完全拖垮数据库,其它请求的sql语句还是可以执行。最后,从上面可以看到从接收网络连接到写回响应内容,对于网络请求部分的处理其实是可以多线程执行来提升网络IO效率的。

redis 6.0

从redis 6.0开始,网络连接(accept) -> 读取请求内容(read) -> 执行命令 -> 响应内容(write) 这个过程中的“执行命令”这个步骤依然保持单线程执行,而对于网络IO读写是多线程执行的了。原因是这部分是网络IO的解析、响应处理,已经不是单纯的内存操作,可以充分利用多核CPU的优势提升性能,对于这部分的性能需求其实一直都存在,社区也有KeyDB这样的产品,其核心就是在redis的基础上对多线程的支持,这多redis来说无疑是一种挑战,所有redis6.0开始在网络IO处理支持多线程就显得非常必要了。

我们知道redis客户端连接是可以有很多个的,最多可以有maxclients参数配置的数量,默认是10000个,那么redis是如何高效处理这么多连接的呢?以及6.0和之前的版本是如何具体处理从接收连接到响应整个过程的,或者说redis线程模型是怎么样的,清楚的了解这些有助于我们更好的学习redis,其中的知识在以后学习其它中间件也可以很好的借鉴。

linux IO模型

在学习redis网络IO模型之前我们必须先了解一下linux的IO模型,以为redis也是基于操作系统去设计的。I/O是Input/Output的缩写,是指操作系统与外部设备进行读取、输出的交互过程,外部设备可以是网卡、磁盘等。操作系统一般都分为内核和用户空间两部分,内核负责与底层硬件交互,用户程序读写数据都需要经过内核空间,也就是数据会不断的在内核-用户空间进行复制,不同的IO模型在这个复制过程用户线程有不同的表现,有的是阻塞,有的是非阻塞,有的是同步,有的是异步。

以linux为例,常见的IO模型有阻塞IO、非阻塞IO、IO多路复用、信号驱动IO、异步IO 5种,这次我们主要关注前3个,重点是IO多路复用,另外两个在使用上有一些局限性,实际应用并不多。这5种IO模型我们在这一篇已经有详细的介绍,这里简单再复习一遍。

以一个最简单例子,现在有两个客户端需要连接、发送数据到我们的服务端,看下服务端在各种IO模型下是如何接收、读取请求的。

阻塞IO(Blocking IO)



假设服务端只开启一个线程处理请求,第一个请求到来,开始调用内核read函数,然后就会发生阻塞,第二个请求到来时服务端将无法处理,只能等第一个请求读取完成。这种方式的缺点很明显,每次只能处理一个请求,无法发挥cpu多核优势,性能低下。

为了解决这个问题,我们可以引入多线程,这样就可以同时处理多个请求了,但服务端可能同时有成千上万的请求需要处理,随之而来的是线程数膨胀,频繁创建、销毁线程带来的性能影响,当然我们可以使用线程池,但服务能处理的总体数量就会受限于线程池线程数量。

非阻塞IO(NON-Blocking IO)



相比阻塞IO,非阻塞IO会立即返回,调用者不会阻塞,此时可以做一些其它事情,例如处理其它请求。但是非阻塞IO需要主动轮询是否有数据需要处理,且这种轮询需要从用户态切换到内核态这,假如没有数据产生就会有很多空轮询,白白浪费cpu资源。

阻塞IO、非阻塞IO,要么需要开启更多线程去处理IO,要么需要从用户态切换到内核态轮询IO事件,那么有没有一种机制,用户程序只需要将请求提交给内核,由内核用少量的线程去监听,有事件就通知用户程序呢?这就是IO多路复用。

IO多路复用(IO Multiplexing)



IO多路复用机制是指一个线程处理多个IO流,多路是指网络连接,复用指的是同一个线程。

如果简单从图上看IO多路复用相比阻塞IO似乎并没有什么高明之处,假设服务只处理少量的连接,那么相比阻塞IO确实没有太大的提升,但如果连接数非常多,差距就会立竿见影。

首先IO多路复用会提交一批需要监听的文件句柄(socket也是一种文件句柄)到内核,由内核开启一个线程负责监听,把轮询工作交给内核,当有事件发生时,由内核通知用户程序。这不需要用户程序开启更多的线程去处理连接,也不需要用户程序切换到内核态去轮询,用一个线程就能处理大量网络IO请求。

redis底层采用的就是IO多路复用模型,实际上基本所有中间件在处理网络IO这一块都会使用到IO多路复用,如kafka,rocketmq等,所以本次学习之后对其它中间件的理解也是很有帮助的。

select/poll/epoll

这三个函数是实现linux io多路复用的内核函数,我们简单了解下。

linux最开始提供的是select函数,方法如下:

select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)

该方法需要传递3个集合,r,e,w分别表示读、写、异常事件集合。集合类型是bitmap,通过0/1表示该位置的fd(文件描述符,socket也是其中一种)是否关心对应读、写、异常事件。例如我们对fd为1和2的读事件关心,r参数的第1,2个bit就设置为1。

用户进程调用select函数将关心的事件传递给内核系统,然后就会阻塞,直到传递的事件至少有一个发生时,方法调用会返回。内核返回时,同样把发生的事件用这3个参数返回回来,如r参数第1个bit为1表示fd为1的发生读事件,第2个bit依然为0,表示fd为2的没有发生读事件。用户进程调用时传递关心的事件,内核返回时返回发生的事件。

select存在的问题:

  1. 大小有限制。为1024,由于每次select函数调用都需要在用户空间和内核空间传递这些参数,为了提升拷贝效率,linux限制最大为1024。
  2. 这3个集合有相应事件触发时,会被内核修改,所以每次调用select方法都需要重新设置这3个集合的内容。
  3. 当有事件触发select方法返回,需要遍历集合才能找到就绪的文件描述符,例如传1024个读事件,只有一个读事件发生,需要遍历1024个才能找到这一个。
  4. 同样在内核级别,每次需要遍历集合查看有哪些事件发生,效率低下。

poll函数对select函数做了一些改进

poll(struct pollfd *fds, int nfds, int timeout)

struct pollfd {
int fd;
short events;
short revents;
}

poll函数需要传一个pollfd结构数组,其中fd表示文件描述符,events表示关心的事件,revents表示发生的事件,当有事件发生时,内核通过这个参数返回回来。

poll相比select的改进:

  1. 传不固定大小的数组,没有1024的限制了(问题1)
  2. 将关心的事件和实际发生的事件分开,不需要每次都重新设置参数(问题2)。例如poll数组传1024个fd和事件,实际只有一个事件发生,那么只需要重置一下这个fd的revent即可,而select需要重置1024个bit。

poll没有解决select的问题3和4。另外,虽然poll没有1024个大小的限制,但每次依然需要在用户和内核空间传输这些内容,数量大时效率依然较低。

这几个问题的根本实际很简单,核心问题是select/poll方法对于内核来说是无状态的,内核不会保存用户调用传递的数据,所以每次都是全量在用户和内核空间来回拷贝,如果调用时传给内核就保存起来,有新增文件描述符需要关注就再次调用增量添加,有事件触发时就只返回对应的文件描述符,那么问题就迎刃而解了,这就是epoll做的事情。

epoll对应3个方法

int epoll_create(int 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_create负责创建一个上下文,用于存储数据,底层是用红黑树,以后的操作就都在这个上下文上进行。

epoll_ctl负责将文件描述和所关心的事件注册到上下文。

epoll_wait用于等待事件的发生,当有有事件触发,就只返回对应的文件描述符了。

reactor模式

前面我们介绍的IO多路复用是操作系统的底层实现,借助IO多路复用我们实现了一个线程就可以处理大量网络IO请求,那么接收到这些请求后该如何高效的响应,这就是reactor要关注的事情,reactor模式是基于事件的一种设计模式。在reactor中分为3中角色:

Reactor:负责监听和分发事件

Acceptor:负责处理连接事件

Handler:负责处理请求,读取数据,写回数据

从线程角度出发,reactor又可以分为单reactor单线程,单reactor多线程,多reactor多线程3种。

单reactor单线程



处理过程:reactor负责监听连接事件,当有连接到来时,通过acceptor处理连接,得到建立好的socket对象,reactor监听scoket对象的读写事件,读写事件触发时,交由handler处理,handler负责读取请求内容,处理请求内容,响应数据。

可以看到这种模式比较简单,读取请求数据,处理请求内容,响应数据都是在一个线程内完成的,如果整个过程响应都比较快,可以获得比较好的结果。缺点是请求都在一个线程内完成,无法发挥多核cpu的优势,如果处理请求内容这一块比较慢,就会影响整体性能。

单reactor多线程



既然处理请求这里可能由性能问题,那么这里可以开启一个线程池来处理,这就是单reactor多线程模式,请求连接、读写还是由主线程负责,处理请求内容交由线程池处理,相比之下,多线程模式可以利用cpu多核的优势。单仔细思考这里依然有性能优化的点,就是对于请求的读写这里依然是在主线程完成的,如果这里也可以多线程,那效率就可以进一步提升。

多reactor多线程



多reactor多线程下,mainReactor接收到请求交由acceptor处理后,mainReactor不再读取、写回网络数据,直接将请求交给subReactor线程池处理,这样读取、写回数据多个请求之间也可以并发执行了。

redis网络IO模型

redis网络IO模型底层使用IO多路复用,通过reactor模式实现的,在redis 6.0以前属于单reactor单线程模式。如图:

在linux下,IO多路复用程序使用epoll实现,负责监听服务端连接、socket的读取、写入事件,然后将事件丢到事件队列,由事件分发器对事件进行分发,事件分发器会根据事件类型,分发给对应的事件处理器进行处理。我们以一个get key简单命令为例,一次完整的请求如下:



请求首先要建立TCP连接(TCP3次握手),过程如下:

redis服务启动,主线程运行,监听指定的端口,将连接事件绑定命令应答处理器。

客户端请求建立连接,连接事件触发,IO多路复用程序将连接事件丢入事件队列,事件分发器将连接事件交由命令应答处理器处理。

命令应答处理器创建socket对象,将ae_readable事件和命令请求处理器关联,交由IO多路复用程序监听。

连接建立后,就开始执行get key请求了。如下:



客户端发送get key命令,socket接收到数据变成可读,IO多路复用程序监听到可读事件,将读事件丢到事件队列,由事件分发器分发给上一步绑定的命令请求处理器执行。

命令请求处理器接收到数据后,对数据进行解析,执行get命令,从内存查询到key对应的数据,并将ae_writeable写事件和响应处理器关联起来,交由IO多路复用程序监听。

客户端准备好接收数据,命令请求处理器产生ae_writeable事件,IO多路复用程序监听到写事件,将写事件丢到事件队列,由事件分发器发给命令响应处理器进行处理。

命令响应处理器将数据写回socket返回给客户端。

reids 6.0以前网络IO的读写和请求的处理都在一个线程完成,尽管redis在请求处理基于内存处理很快,不会称为系统瓶颈,但随着请求数的增加,网络读写这一块存在优化空间,所以redis 6.0开始对网络IO读写提供多线程支持。需要知道的是,redis 6.0对多线程的默认是不开启的,可以通过 io-threads 4 参数开启对网络写数据多线程支持,如果对于读也要开启多线程需要额外设置 io-threads-do-reads yes 参数,该参数默认是no,因为redis认为对于读开启多线程帮助不大,但如果你通过压测后发现有明显帮助,则可以开启。

redis 6.0多线程模型思想上类似单reactor多线程和多reactor多线程,但不完全一样,这两者handler对于逻辑处理这一块都是使用线程池,而redis命令执行依旧保持单线程。如下:

可以看到对于网络的读写都是提交给线程池去执行,充分利用了cpu多核优势,这样主线程可以继续处理其它请求了。

开启多线程后多redis进行压测结果可以参考这里,如下图可以看到,对于简单命令qps可以达到20w左右,相比单线程有一倍的提升,性能提升效果明显,对于生产环境如果大家使用了新版本的redis,现在7.0也出来了,建议开启多线程。

总结

本篇我们学习redis单线程具体是如何单线程以及在不同版本的区别,通过网络IO模型知道IO多路复用如何用一个线程处理监听多个网络请求,并详细了解3种reactor模型,这是在IO多路复用基础上的一种设计模式。最后学习了redis单线程、多线程版本是如何基于reactor模型处理请求。其中IO多路复用和reactor模型在许多中间件都有使用到,后续再接触到就不陌生了。

欢迎关注我的github:https://github.com/jmilktea/jtea

详解redis网络IO模型的更多相关文章

  1. Java网络编程和NIO详解3:IO模型与Java网络编程模型

    Java网络编程和NIO详解3:IO模型与Java网络编程模型 基本概念说明 用户空间与内核空间 现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32 ...

  2. 详解Redis持久化(RDB和AOF)

    详解Redis持久化(RDB和AOF) 什么是Redis持久化? Redis读写速度快.性能优越是因为它将所有数据存在了内存中,然而,当Redis进程退出或重启后,所有数据就会丢失.所以我们希望Red ...

  3. Unix 网络IO模型介绍

    带着问题阅读 1.什么是同步异步.阻塞非阻塞 2.有几种IO模型,不同模型之间有什么区别 3.不同IO模型的应用场景都是什么 同步和异步.阻塞和非阻塞 同步和异步 广义上讲同步异步描述的是事件中发送方 ...

  4. 反射实现Model修改前后的内容对比 【API调用】腾讯云短信 Windows操作系统下Redis服务安装图文详解 Redis入门学习

    反射实现Model修改前后的内容对比   在开发过程中,我们会遇到这样一个问题,编辑了一个对象之后,我们想要把这个对象修改了哪些内容保存下来,以便将来查看和追责. 首先我们要创建一个User类 1 p ...

  5. 通过实例理解Java网络IO模型

    网络IO模型及分类 网络IO模型是一个经常被提到的问题,不同的书或者博客说法可能都不一样,所以没必要死抠字眼,关键在于理解. Socket连接 不管是什么模型,所使用的socket连接都是一样的. 以 ...

  6. 【转】5种网络IO模型

    5种网络IO模型(有图,很清楚) IO多路复用—由Redis的IO多路复用yinch Linux中对文件描述符的操作(FD_ZERO.FD_SET.FD_CLR.FD_ISSET

  7. 从操作系统层面理解Linux下的网络IO模型

    I/O( INPUT OUTPUT),包括文件I/O.网络I/O. 计算机世界里的速度鄙视: 内存读数据:纳秒级别. 千兆网卡读数据:微妙级别.1微秒=1000纳秒,网卡比内存慢了千倍. 磁盘读数据: ...

  8. 网络IO模型与Reactor模式

    一.三种网络IO模型: 分类: BIO 同步的.阻塞式 IO NIO 同步的.非阻塞式 IO AIO 异步非阻塞式 IO 阻塞和同步的概念: 阻塞:若读写未完成,调用读写的线程一直等待 非阻塞:若读写 ...

  9. 高并发之网络IO模型

    你好,我是坤哥 今天我们聊一下高并发下的网络 IO 模型 高并发即我们所说的 C10K(一个 server 服务 1w 个 client),C10M,写出高并发的程序相信是每个后端程序员的追求,高并发 ...

  10. [编织消息框架][网络IO模型]BIO

    既然跟网络内容有关就不得不学习网络IO模型,时代在进步,技术也在进步,采取使用那种网络IO模型就已经确定应用程序规模 阻塞IO(blocking IO) 在linux中,默认情况下所有的socket都 ...

随机推荐

  1. Logstash: 如何创建可维护和可重用的Logstash管道

  2. 4_爬NMPA药监总局_动态加载_传ID

    http://scxk.nmpa.gov.cn:81/xk/ import requests url = 'http://scxk.nmpa.gov.cn:81/xk/itownet/portalAc ...

  3. 为什么同行业,同个软件,有些 ERP 成功,有的失败了?

    企业的差异性是各类系统部署必须正视的关键问题!同行业,同个软件,有些 ERP 成功,有的失败,基本上是企业差异性没有得到重视的,所以一点也不应该感到奇怪.规模不同.行业不同.发展阶段不同.生产模式不同 ...

  4. nsis离开自定义页面保存设置

    这是群里一位朋友问他的自定义页面设置完成后返回上一步无法保存怎么办写的一个小例子,拓展了下,只要不关闭,不管上一步还是进入下一步返回都可以保留原页面设置. !include LogicLib.nsh ...

  5. GCC Arm 12.2编译提示 LOAD segment with RWX permissions 警告

    使用GCC Arm工具链开发的项目, 在升级到 arm-gnu-toolchain-12.2 之后, 编译出现警告 arm-gnu-toolchain-12.2.mpacbti-bet1-x86_64 ...

  6. POJ1741 tree (点分治模板)

    题目大意: 给一棵有 n 个顶点的树,每条边都有一个长度(小于 1001 的正整数).定义 dist(u,v)=节点 u 和 v 之间的最小距离.给定一个整数 k,对于每一对 (u,v) 顶点当且仅当 ...

  7. 图解不同版本的HTTP协议

    前言 大家好,我是蜗牛,今天我们聊聊HTTP协议,通过这篇文章我们能了解到不同版本HTTP优缺点.他们之间的性能差异以及现在主流的HTTP协议用的那个版本 HTTP/1.1 时代 HTTP/1.1 对 ...

  8. 驱动开发:内核枚举LoadImage映像回调

    在笔者之前的文章<驱动开发:内核特征码搜索函数封装>中我们封装实现了特征码定位功能,本章将继续使用该功能,本次我们需要枚举内核LoadImage映像回调,在Win64环境下我们可以设置一个 ...

  9. .NET Core C#系列之XiaoFeng.Data.IQueryableX ORM框架

    ​ 当前对象操作数据库写法和EF Core极度类似,因为现在大部分程序员都懒得去写SQL,再一个就是项目作大了或其它原因要改数据库,每次改数据库,那么写的SQL语句大部分要作调整,相当麻烦,并且写SQ ...

  10. springMVC必要jar包

    spring-aop-4.3.2.RELEASE.jar :: 包含在应用中使用Spring 的AOP 特性时所需的类和源码级元数据支持. spring-beans-4.3.2.RELEASE.jar ...