在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
前言redis作为应用最广泛的nosql数据库之一,大大小小也经历过很多次升级。在4.0版本之前,单线程+IO多路复用使得redis的性能已经达到一个非常高的高度了。作者也说过,之所以设计成单线程是因为redis的瓶颈不在cpu上,而且单线程也不需要考虑多线程带来的锁开销问题。 然而随着时间的推移,单线程越来越不满足一些应用场景了,比如针对大key删除会造成主线程阻塞的问题,redis4.0出了一个异步线程。 针对单线程由于无法利用多核cpu的特性而导致无法满足更高的并发,redis6.0也推出了多线程模式。所以说redis是单线程越来越不准确了。 事件模型redis本身是个事件驱动程序,通过监听文件事件和时间事件来完成相应的功能。其中文件事件其实就是对socket的抽象,把一个个socket事件抽象成文件事件,redis基于Reactor模式开发了自己的网络事件处理器。那么Reactor模式是什么? 通信思考一个问题,我们的服务器是如何收到我们的数据的?首先双方先要建立TCP连接,连接建立以后,就可以收发数据了。发送方向socket的缓冲区发送数据,等待系统从缓冲区把数据取走,然后通过网卡把数据发出去,接收方的网卡在收到数据之后,会把数据copy到socket的缓冲区,然后等待应用程序来取,这是大概的发收数据流程。 copy数据的开销因为涉及到系统调用,整个过程可以发现一份数据需要先从用户态拷贝到内核态的socket,然后又要从内核态的socket拷贝到用户态的进程中去,这就是数据拷贝的开销。 数据怎么知道发给哪个socket内核维护的socket那么多,网卡过来的数据怎么知道投递给哪个socket? 答案是端口,socket是一个四元组:
注意千万不要说一台机器的理论最大并发是65535个,除了端口,还有ip,应该是端口数*ip数 这也是为什么一台电脑可以同时打开多个软件的原因。 socket的数据怎么通知程序来取当数据已经从网卡copy到了对应的socket缓冲区中,怎么通知程序来取?假如socket数据还没到达,这时程序在干嘛?这里其实涉及到cpu对进程的调度的问题。从cpu的角度来看,进程存在运行态、就绪态、阻塞态。
当存在多个运行态的进程时,由于cpu的时间片技术,运行态的进程都会被cpu执行一段时间,看着好似同时运行一样,这就是所谓的并发。当我们创建一个socket连接时,它大概会这样: sockfd = socket(AF_INET, SOCK_STREAM, 0) connect(sockfd, ....) recv(sockfd, ...) doSometing() 操作系统会为每个socket建立一个fd句柄,这个fd就指向我们创建的socket对象,这个对象包含缓冲区、进程的等待队列...。对于一个创建socket的进程来说,如果数据没到达,那么他会卡在recv处,这个进程会挂在socket对象的等待队列中,对cpu来说,这个进程就是阻塞的,它其实不占有cpu,它在等待数据的到来。 当数据到来时,网卡会告诉cpu,cpu执行中断程序,把网卡的数据copy到对应的socket的缓冲区中,然后唤醒等待队列中的进程,把这个进程重新放回运行队列中,当这个进程被cpu运行的时候,它就可以执行最后的读取操作了。这种模式有两个问题: recv只能接收一个fd,如果要recv多个fd怎么办? 通过while循环效率稍低。 进程除了读取数据,还要处理接下里的逻辑,在数据没到达时,进程处于阻塞态,即使用了while循环来监听多个fd,其它的socket是不是因为其中一个recv阻塞,而导致整个进程的阻塞。 针对上述问题,于是Reactor模式和IO多路复用技术出现了。 ReactorReactor是一种高性能处理IO的模式,Reactor模式下主程序只负责监听文件描述符上是否有事件发生,这一点很重要,主程序并不处理文件描述符的读写。那么文件描述符的可读可写谁来做?答案是其他的工作程序,当某个socket发生可读可写的事件后,主程序会通知工作程序,真正从socket里面读取数据和写入数据的是工作程序。这种模式的好处就是就是主程序可以扛并发,不阻塞,主程序非常的轻便。事件可以通过队列的方式等待被工作程序执行。通过Reactor模式,我们只需要把事件和事件对应的handler(callback func),注册到Reactor中就行了,比如: type Reactor interface{ RegisterHandler(WriteCallback func(), "writeEvent"); RegisterHandler(ReadCallback func(), "readEvent"); } 当一个客户端向redis发起set key value的命令,这时候会向socket缓冲区写入这样的命令请求,当Reactor监听到对应的socket缓冲区有数据了,那么此时的socket是可读的,Reactor就会触发读事件,通过事先注入的ReadCallback回调函数来完成命令的解析、命令的执行。当socket的缓冲区有足够的空间可以被写,那么对应的Reactor就会产生可写事件,此时就会执行事先注入的WriteCallback回调函数。当发起的set key value执行完毕后,此时工作程序会向socket缓冲区中写入OK,最后客户端会从socket缓冲区中取走写入的OK。在redis中不管是ReadCallback,还是WriteCallback,它们都是一个线程完成的,如果它们同时到达那么也得排队,这就是redis6.0之前的默认模式,也是最广为流传的单线程redis。 整个流程下来可以发现Reactor主程序非常快,因为它不需要执行真正的读写,剩下的都是工作程序干的事:IO的读写、命令的解析、命令的执行、结果的返回..,这一点很重要。 IO多路复用器通过上面我们知道Reactor它是一个抽象的理论,是一个模式,如何实现它?如何监听socket事件的到来?。最简单的办法就是轮询,我们既然不知道socket事件什么时候到达,那么我们就一直来问内核,假设现在有1w个socket连接,那么我们就得循环问内核1w次,这个开销明显很大。 用户态到内核态的切换,涉及到上下文的切换(context),cpu需要保护现场,在进入内核前需要保存寄存器的状态,在内核返回后还需要从寄存器里恢复状态,这是个不小的开销。 由于传统的轮询方法开销过大,于是IO多路复用复用器出现了,IO多路复用器有select、poll、evport、kqueue、epoll。Redis在I/O多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时自动选择系统中性能最高的I/O多路复用函数库来作为Redis的I/O多路复用程序的底层实现: // Include the best multiplexing layer supported by this system. The following should be ordered by performances, descending. # ifdef HAVE_EVPORT # include "ae_evport.c" # else # ifdef HAVE_EPOLL # include "ae_epoll.c" # else # ifdef HAVE_KQUEUE # include "ae_kqueue.c" # else # include "ae_select.c" # endif # endif # endif 我们这里主要介绍两种非常经典的复用器select和epoll,select是IO多路复用器的初代,select是如何解决不停地从用户态到内核态的轮询问题的? select既然每次轮询很麻烦,那么select就把一批socket的fds集合一次性交给内核,然后内核自己遍历fds,然后判断每个fd的可读可写状态,当某个fd的状态满足时,由用户自己判断去获取。 fds = []int{fd1,fd2,...} for { select (fds) for i:= 0; i < len(fds); i++{ if isReady(fds[i]) { read() } } } select的缺点:当一个进程监听多个socket的时候,通过select会把内核中所有的socket的等待队列都加上本进程(多对一),这样当其中一个socket有数据的时候,它就会把告诉cpu,同时把这个进程从阻塞态唤醒,等待被cpu的调度,同时会把进程从所有的socket的等待队列中移除,当cpu运行这个进程的时候,进程因为本身传进去了一批fds集合,我们并不知道哪个fd来数据了,所以只能都遍历一次,这样对于没有数据到来的fd来说,就白白浪费了。由于每次select要遍历socket集合,那么这个socket集合的数量过大就会影响整体效率,这原因也是select为什么支持最大1024个并发的。 epoll如果有一种方法使得不用遍历所有的socket,当某个socket的消息到来时,只需要触发对应的socket fd,而不用盲目的轮询,那效率是不是会更高。epoll的出现就是为了解决这个问题: epfd = epoll_create() epoll_ctl(epfd, fd1, fd2...) for { epoll_wait() for fd := range fds { doSomething() } }
epoll是怎么做到的?首先内核的socket不在和用户的进程绑定了,而是和epoll绑定,这样当socket的数据到来时,中断程序就会给epoll的一个就绪对列添加对应socket fd,这个队列里都是有数据的socket,然后和epoll关联的进程也会被唤醒,当cpu运行进程的时候,就可以直接从epoll的就绪队列中获取有事件的socket,执行接下来的读。整个流程下来,可以发现用户程序不用无脑遍历,内核也不用遍历,通过中断做到"谁有数据处理谁"的高效表现。 单线程到多线程的演进单线程结合Reactor的思想加上高性能epoll IO模式,redis开发出一套高性能的网络IO架构:单线程的IO多路复用,IO多路复用器负责接受网络IO事件,事件最终以队列的方式排队等待被处理,这是最原始的单线程模型,为什么使用单线程?因为单线程的redis已经可以达到10w qps的负载(如果做一些复杂的集合操作,会降低),满足绝大部分应用场景了,同时单线程不用考虑多线程带来的锁的问题,如果还没达到你的要求,那么你也可以配置分片模式,让不同的节点处理不同的sharding key,这样你的redis server的负载能力就能随着节点的增长而进一步线性增长。 异步线程在单线程模式下有这样一个问题,当执行删除某个很大的集合或者hash的时候会很耗时(不是连续内存),那么单线程的表现就是其他还在排队的命令就得等待。当等待的命令越来越多,那么不好的事情就会发生。于是redis4.0针对大key删除的情况,出了个异步线程。用unlink代替del去执行删除,这样当我们unlink的时候,redis会检测当删除的key是否需要放到异步线程去执行(比如集合的数量超过64个...),如果value足够大,那么就会放到异步线程里去处理,不会影响主线程。同样的还有flushall、flushdb都支持异步模式。此外redis还支持某些场景下是否需要异步线程来处理的模式(默认是关闭的): lazyfree-lazy-eviction no lazyfree-lazy-expire no lazyfree-lazy-server-del no replica-lazy-flush no
多线程redis单线程+异步线程+分片已经能满足了绝大部分应用,然后没有最好只有更好,redis在6.0还是推出了多线程模式。默认情况下,多线程模式是关闭的。 # io-threads 4 # work线程数 # io-threads-do-reads no # 是否开启 多线程的作用点?通过上文我们知道当我们从一个socket中读取数据的时候,需要从内核copy到用户空间,当我们往socket中写数据的时候,需要从用户空间copy到内核。redis本身的计算还是很快的,慢的地方那么主要就是socket IO相关操作了。当我们的qps非常大的时候,单线程的redis无法发挥多核cpu的好处,那么通过开启多个线程利用多核cpu来分担IO操作是个不错的选择。
开启的话,官方建议对于一个4核的机器来说,开2-3个IO线程,如果有8核,那么开6个IO线程即可。 多线程的原理需要注意的是redis的多线程仅仅只是处理socket IO读写是多个线程,真正去运行指令还是一个线程去执行的。
多线程模式下,每个IO线程负责处理自己的队列,不会互相干扰,IO线程要么同时在读,要么同时在写,不会同时读或写。主线程也只会在所有的子线程的任务处理完毕之后,才会尝试再次分配任务。同时最终的命令执行还是由主线程自己来完成,整个过程不涉及到锁。 以上就是Redis线程IO模型的演进教程示例精讲的详细内容,更多关于Redis线程IO模型的演进的资料请关注极客世界其它相关文章! |
请发表评论