网上的说法,大多数都是redis 是单线程,是基于内存,是io多路复用,但是只是都说了表面,比如为啥单线程就快,redis 单线程都干了什么?什么是io 多路复用,redis 怎么和io多路复用进行结合的等等都没有说清楚。
为什么采用单线程来实现?
如果采用多线程,那么势必就会有多线程上线文的开销。
如果采用多线程,对同一个key 的操作,必然就会有线程安全的问题,什么是线程安全?当有多个端或者线程去竞争一个公共的资源的时候,此时就会有线程安全的问题,那么要解决这个问题必然就会加锁,如果加锁就会带来锁竞争的消耗,同时也会增加redis 的<typo id="typo-276" data-origin="负杂性" ignoretag="true">负杂性</typo>。
如果你做过性能测试,你应该知道,随着你线程数量的增多,你应用的吞吐量是会增加,可是在随着线程的增加,你的应用的吞吐量就会持平,慢慢的就会下降。我想redis 也是因为考虑了<typo id="typo-368" data-origin="着" ignoretag="true">着</typo>点,如果能让你配置线程的参数,可能会出现性能下降的问题。
还有按照官网的说法就是redis 基于内存操作,速度很快,cpu 不是它的瓶颈。
所以redis 并没有采用多线程的方式,一个是会增加锁的竞争和 线程的切换消耗,同时也会增加redis 的复杂性。
redis 采用io多路复用模型
在介绍io 多路复用之前 我们先看下scoket 模型 有哪几种模型。
最基本的阻塞 socket
要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,它是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信。 建 Socket 的时候,可以指定网络层使用的是 IPv4 还是 IPv6,传输层使用的是 TCP 还是 UDP。 服务端首先调用 socket() 函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket ,接着调用 bind() 函数,给这个 Socket 绑定一个 IP 地址和端口。
绑定端口的目的:当内核收到 TCP 报文,通过 TCP 头里面的端口号,来找到我们的应用程序,然后把数据传递给我们。 绑定 IP 地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的 IP 地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给我们。
绑定完 IP 地址和端口后,就可以调用 listen() 函数进行监听,此时对应 TCP 状态图中的 listen。
服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。
那客户端是怎么发起连接的呢?客户端在创建好 Socket 后,调用 connect()函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号。
在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列:
- 一个是还没完全建立连接的队列,称为 TCP 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 的状态;
- 一个是一件建立连接的队列,称为 TCP 全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于 established 状态;
当 TCP 全连接队列不为空后,服务端的 accept()函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。
监听的 Socket 和真正用来传数据的 Socket 是两个:
- 一个叫作监听 Socket;
- 一个叫作已连接 Socket;
这个模型是最基本的io模型,是一个同步阻塞的方式,服务每次只能处理一个客户端的io请求,读写发生阻塞的时候,其他客户端是不能够和服务端进行连接的。
accept() 当去没有客户端请求链接的时候,会阻塞。 read() 当没有可读的数据时候,会阻塞。 write() 当没有可写的时候,会阻塞。
基于多进程的socket模型
前面提到最基本的socket 模型,属于一对一通信,那么你应用的并发量可想而知,同一时刻只能服务一个客户端,机器资源也会很浪费,那么我需要改进我们的socket 模型,以支持更多的客户端进行连接和读写操作。 可以使用多进程来解决这个问题,每来一个客户端的链接时候,就分配一个进行进行读写操作。
大致过程如下:
- 服务主进程监听客户端连接,与客户端连接成功后,accept() 函数会返回已经连接的 socket
- 主进程会fork() 一个子进程,会把主进程的文件描述符,内存地址空间、程序计数器、执行代码等都复制过来。
- 子进程直接使用已经连接的socket和客户端进行通信。
主进程只需要关心监听的socket,有连接成功的客户端就fork 子进程。 子进程只需关心已经连接的socket,进行客户端通信就可以了。
看下图,基本流程:
采用多进程来达到服务多个客户端连接的方式,当客户端数量很高的时候,你的进程数量就会很高,那么系统的负担就会加重,会占据系统大部分的资源,此外进程间的上下午切换是很重的,包含了虚拟内存、栈、全局变量等用户资源、还包含内核堆栈、寄存器等内核资源。所以在大并发量的情况下,这种方式并不是最优方案。
基于多线程的socket模型
基于多线程实现,多线程可以理解为是属于某个进程的,同进程内的线程是可以共享进程的部分资源的,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时是不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。
此外线程频繁的创建和销毁是消耗资源的,开销也会很小,所以我应该利用线程池来达到线程的重用,避免线程的创建和销毁的开销。
基本流程看下图:
其实使用线程来处理连接的读写请求,还是和进程的处理连接的读写请求的问题是一样,这样会给系统带来很大的资源消耗,如果达到c10k,那么服务端就要维护1万了线程,那么此时的操作系统会是什么样子。
I/O多路复用
上面的两种方式不管是进程间的切换、还是线程间的切换,已经创建的进程的数量和线程的数量多少,都会给系统带来负担,那么能不能用一个进程程或者叫线程就可以监听多个socket呢? 那么我们今天的主角 I/O多路复用就卡咔咔咔 闪亮登场了。
那我们怎么能够通俗<typo id="typo-2639" data-origin="的理" ignoretag="true">的理</typo>解这个玩意呢。
首先看的应为名字为 I/O multiplexing.
重要的事情再说一遍: I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个I/O流. 发明它的原因,是尽量多的提高服务器的吞吐能力。
在同一个线程里面, 通过拨开关的方式,来同时传输多个I/O流。
来来来, 看看下面的图:
I/O 多路复用三剑客
大家都应该听过select/poll/epoll ,其实它们三个就是 多路复用的三剑客,下面来介绍他们的来历和原理。
select/poll 基本流程
select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
epoll
epoll 通过两个方面,很好解决了 select/poll 的问题。
第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
Redis IO多路复用
redis利用epoll来实现IO多路复用,内核可以同时存在多个监听socket套接字和已经连接的套接字,内核会一直监听这些socket,只要有socket事件发生,就会交给redis 线程来处理,这样可以看出,一个redis线程就能处理多个io流了。
下图中redis 会利用epoll,让内核帮助它来监听socket,此时redis 并不会阻塞在某一个特定的监听或者已经连接的socket上,如果有事件过来,epoll_wait() 就会返回事件就绪的FD给redis主线程,redis主线程就会将连接信息和事件放到队列中,然后主线程会让事件分派器消费队列.大致流程看下图:
图中FD 是指 有事件就绪的socket。
连接处理器会处理 accept 事件。 命令请求处理器处理 命令请求,也就是可读的事件。 命令回复处理器 处理 命令回复 ,也就是写事件。
IO多路的单线程模型:
- redis启动时,向epoll注册还未使用的FD的可连接事件。
- 连接事件产生时,epoll机制自动将其放入自己的可连接事件队列中。
- redis线程调用epoll的wait,获取所有事件,拷贝存入自己用户线程的内存队列中。
- 遍历这些内存队列,通过事件分派器,交由对应事件处理器处理。如是可连接事件则交由对应的连接应答事件处理器处理。可读事件交给命令请求处理器,可写事件交给命令回复处理器处理。
- 对应处理器执行相应逻辑。执行完成时,再次向epoll注册对应事件,比如连接事件的那个FD,接下来要注册可读事件,并重新注册可连接事件。命令请求处理器执行完后,要将FD注册可写事件。命令回复处理器执行完后,需要将FD取消注册可写事件。
Redis 底层数据结构
- redis 哈希表,在查找key 的时候,能够做o(1) 的复杂度。
- 对于有序的集合,采用跳表来实现,复杂度为 o(logN)。
- 对于压缩列表 和整数数组 可以对短数据进行压缩,节省了内存空间。
- 对于压缩列表和双端链表,可以实现队列, pop 和 push 操作 的时间复杂度为 o(1)。
- bitmap,对于只有两种<typo id="typo-4803" data-origin="值的" ignoretag="true">值的</typo>判断来说,既能节能内存,也能做到低延迟。
- 集合类中元素的个数,redis 已经算好,例如LLEN ,SCARD 复杂度都是o(1)。
redis 速度慢了
- 尽量使用自己单个操作的时间很短。
- 合理使用redis 的数据结构,每种结构都有自己的应用场景。
- 减少对集合的全部查询,如HMGET HGETALL SMEMBBERS LRANGE 等 范围的查找。
- 不要使用keys* 之类的操作。
- 不要存储bigkey,bigkey 再分配空间(阻塞redis主线程)和删除释放空间(可以设置为异步)、带宽都会受到影响。
总结
今天学习了redis 快的原因,<typo id="typo-5093" data-origin="节" ignoretag="true">节</typo>下来我们来总结下。
Redis是纯内存数据库,相对于读写磁盘,读写内存的速度就不是几倍几十倍了,一般,hash查找可以达到每秒百万次的数量级。
Redis采用了单线程的模型(io解析和读写操作由一个线程来完成),保证了每个操作的原子性,也减少了线程的上下文切换、公共资源锁的开销与竞争。
redis 采用IO多路复用 ,“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗)。可以直接理解为:单线程的原子操作,避免上下文切换的时间和性能消耗;加上对内存中数据的处理速度,很自然的提高redis的吞吐量。
Redis全程使用hash结构,读取速度快,还有一些特殊的数据结构,对数据存储进行了优化,如压缩表,对短数据进行压缩存储,再如,跳表,使用有序的数据结构加快读取的速度。
作者:飓风zj 链接:https://juejin.cn/post/6976833557587197988