笔记整理自 b站_黑马程序员Redis入门到实战教程
服务器大多都采用 Linux 系统,这里我们以 Linux 为例来讲解:
ubuntu 和 Centos 都是 Linux 的发行版,发行版可以看成对 Linux 包了一层壳,任何 Linux 发行版,其系统内核都是 Linux。我们的应用都需要通过 Linux 内核与硬件交互。
用户的应用,比如 redis,mysql 等其实是没有办法去执行访问我们操作系统的硬件的,所以我们可以通过发行版的这个壳子去访问内核,再通过内核去访问计算机硬件。
计算机硬件包括,如 cpu,内存,网卡等等,内核(通过寻址空间)可以操作硬件的,但是内核需要不同设备的驱动,有了这些驱动之后,内核就可以去对计算机硬件去进行 内存管理,文件系统的管理,进程的管理等等。
我们想要用户的应用来访问,计算机就必须要通过对外暴露的一些接口,才能访问到,从而简介的实现对内核的操控,但是内核本身上来说也是一个应用,所以他本身也需要一些内存,cpu 等设备资源,用户应用本身也在消耗这些资源,如果不加任何限制,用户去操作随意的去操作我们的资源,就有可能导致一些冲突,甚至有可能导致我们的系统出现无法运行的问题,因此我们需要把用户和内核隔离开。
什么是寻址空间呢?我们的应用程序也好,还是内核空间也好,都是没有办法直接去f访问物理内存的,而是通过分配一些虚拟内存映射到物理内存中,我们的内核和应用程序去访问虚拟内存的时候,就需要一个虚拟地址,这个地址是一个无符号的整数,比如一个 32 位的操作系统,他的带宽就是 32,他的虚拟地址就是 2 的 32 次方,也就是说他寻址的范围就是 0~2 的 32 次方, 这片寻址空间对应的就是 2 的 32 个字节,就是 4GB,这个 4GB,会有 3GB 分给用户空间,会有 1GB 给内核系统。
在 linux 中,他们权限分成两个等级,0 和 3:
所以一般情况下,用户的操作是运行在用户空间,而内核运行的数据是在内核空间的,而有的情况下,一个应用程序需要去调用一些特权资源,去调用一些内核空间的操作,所以此时他俩需要在用户态和内核态之间进行切换。
比如:
针对这个操作:我们的用户在写读数据时,会去向内核态申请,想要读取内核的数据,而内核数据要去等待驱动程序从硬件上读取数据,当从磁盘上加载到数据之后,内核会将数据写入到内核的缓冲区中,然后再将数据拷贝到用户态的 buffer 中,然后再返回给应用程序,整体而言,速度慢,就是这个原因,为了加速,我们希望 read 也好,还是 wait for data 也最好都不要等待,或者时间尽量的短。
在《UNIX网络编程》一书中,总结归纳了 5 种 IO 模型:
应用程序想要去读取数据,他是无法直接去读取磁盘数据的,他需要先到内核里边去等待内核操作硬件拿到数据,这个过程就是 1,是需要等待的,等到内核从磁盘上把数据加载出来之后,再把这个数据写给用户的缓存区,这个过程是 2,如果是阻塞IO,那么整个过程中,用户从发起读请求开始,一直到读取到数据,都是一个阻塞状态。
具体流程如下图:
用户去读取数据时,会去先发起 recvform 一个命令,去尝试从内核上加载数据,如果内核没有数据,那么用户就会等待,此时内核会去从硬件上读取数据,内核读取数据之后,会把数据拷贝到用户态,并且返回 ok,整个过程,都是阻塞等待的,这就是阻塞IO。
总结如下:
顾名思义,阻塞IO就是两个阶段都必须阻塞等待:
可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。
顾名思义,非阻塞IO的 recvfrom 操作会立即返回结果而不是阻塞用户进程。
阶段一:
阶段二:
将内核数据拷贝到用户缓冲区
拷贝过程中,用户进程依然阻塞等待
拷贝完成,用户进程解除阻塞,处理数据
可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。
虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致 CPU 空转,CPU 使用率暴增。
那么非阻塞IO有什么用呢?虽然看起来非阻塞IO在性能上并不比阻塞IO有太大的提升,比如下面的IO多路复用,必须结合非阻塞IO才能有更好的性能表现。
无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用 recvfrom 来获取数据,差别在于无数据时的处理方案:
如果调用 recvfrom 时,恰好没有数据,阻塞IO会使 CPU 阻塞,非阻塞IO使 CPU 空转,都不能充分发挥 CPU 的作用。
如果调用 recvfrom 时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据。
所以怎么看起来以上两种方式性能都不好。
而在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。
就比如服务员给顾客点餐,分两步:
要提高效率有几种办法?
方案一:增加更多服务员(多线程)
方案二:不排队,谁想好了吃什么(数据就绪了),服务员就给谁点餐(用户应用就去读取数据)
那么问题来了:用户进程如何知道内核中数据是否就绪呢?
所以接下来就需要详细的来解决多路复用模型是如何知道到底怎么知道内核数据是否就绪的问题了。
文件描述符(File Descriptor):简称 FD,是一个从 0 开始的无符号整数,用来关联 Linux 中的一个文件。在 Linux 中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
IO多路复用:通过 FD,我们的网络模型可以利用一个线程监听多个 FD,并在某个 FD 可读、可写时得到通知,从而避免无效的等待,充分利用 CPU 资源。
阶段一:
阶段二:
当用户去读取数据的时候,不再去直接调用 recvfrom 了,而是调用 select 的函数,select 函数会将需要监听的数据交给内核,由内核去检查这些数据是否就绪了,如果说这个数据就绪了,就会通知应用程序数据就绪,然后来读取数据,再从内核中把数据拷贝给用户态,完成数据处理,如果 N 多个 FD 一个都没处理完,此时就进行等待。
用IO复用模式,可以确保去读数据的时候,数据是一定存在的,他的效率比原来的阻塞IO和非阻塞IO性能都要高。
在阶段一等待 FD 就绪时,应用进程也是阻塞的,阶段二数据拷贝同样也是阻塞的,那么IO多路复用模式和阻塞模式有什么差别呢?
IO多路复用是利用单个线程来同时监听多个 FD,并在某个 FD 可读、可写时得到通知,从而避免无效的等待,充分利用 CPU 资源。
不过监听 FD 的方式、通知的方式又有多种实现,常见的有:
其中 select 和 poll 相当于是当被监听的数据准备好之后,他会把你监听的 FD 整个数据都发给你,你需要到整个 FD 中去找,哪些是处理好了的,需要通过遍历的方式,所以性能也并不是那么好。
而 epoll,则相当于内核准备好了之后,他会把准备好的数据,直接发给你,咱们就省去了遍历的动作。
select 是 Linux 最早是由的I/O多路复用技术:
简单说,就是我们把需要处理的数据封装成 FD,然后在用户态时创建一个 fd 的集合(这个集合的大小是要监听的那个 FD 的最大值 +1,但是大小整体是有限制的),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要控制哪些数据。
比如要监听的数据,是 1,2,5 三个数据,此时会执行 select 函数,然后将整个 fd 发给内核态,内核态会去遍历用户态传递过来的数据,如果发现这里边都数据都没有就绪,就休眠,直到有数据准备好时,就会被唤醒,唤醒之后,再次遍历一遍,看看谁准备好了,然后再将处理掉没有准备好的数据,最后再将这个 FD 集合写回到用户态中去,此时用户态就知道了,奥,有人准备好了,但是对于用户态而言,并不知道谁处理好了,所以用户态也需要去进行遍历,然后找到对应准备好数据的节点,再去发起读请求,我们会发现,这种模式下他虽然比阻塞IO和非阻塞IO好,但是依然有些麻烦的事情, 比如说频繁的传递 fd 集合,频繁的去遍历 FD 等问题。
select 模式存在的问题:
poll 模式对 select 模式做了简单改进,但性能提升不明显,部分关键代码如下:
IO流程:
与select对比:
epoll 模式是对 select 和 poll 的改进,它提供了三个函数:
eventpoll
的函数,他内部包含两个东西: epoll_ctl
操作epoll_wait
函数epoll 与 select 和 poll 模式最大的区别:
小总结
select 模式存在的三个问题:
poll 模式的问题:
epoll 模式中如何解决这些问题的?
当 FD 有数据可读时,我们调用 epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:
举个栗子:
结果:
如果我们采用 LT 模式,因为 FD 中仍有 1kb 数据,则第⑤步依然会返回结果,并且得到通知。
如果我们采用 ET 模式,因为第③步已经消费了 FD 可读事件,第⑤步 FD 状态没有变化,因此 epoll_wait 不会返回,数据无法读取,客户端响应超时。
结论
基于 epoll 模式的 web 服务的基本流程如图:
我们来梳理一下这张图:
信号驱动IO是与内核建立 SIGIO 的信号关联并设置回调,当内核有 FD 就绪时,会发出 SIGIO 信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。
阶段一:
阶段二:
当有大量IO操作时,信号较多,SIGIO 处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。
异步IO的整个过程都是非阻塞的,用户进程调用完异步 API 后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
阶段一:
阶段二:
可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。
这种方式,不仅仅是用户态在试图读取数据后,不阻塞,而且当内核的数据准备完成后,也不会阻塞。
他会由内核将所有数据处理完成后,由内核将数据写入到用户态中,然后才算完成,所以性能极高,不会有任何阻塞,全部都由内核完成。
这样看来,异步IO的性能非常好,但为什么不使用这种模式呢?但是它也存在一定的问题,异步IO中,用户进程不会阻塞,不阻塞它就会去处理新的用户请求,新的请求来了,又要调 aio_read 去做内存拷贝,这样一来 在高并发场景下,任务数量就会非常的多,从而IO读写的次数也会越来越多,IO读写的效率比较低,所以这里可能会积累越来越多的任务,可能会导致因为系统内存占用过多出现崩溃的现象,所以要使用异步IO必须要做好并发的限流工作,实现起来非常复杂。
IO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作),也就是阶段二是阻塞还是非阻塞。
Redis 到底是单线程还是多线程?
在 Redis 版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:
因此,对于 Redis 的核心网络模型,在 Redis 6.0 之前确实都是单线程。是利用 epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。
为什么 Redis 要选择单线程?
内存
操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升Redis 通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装, 提供了统一的高性能事件库 API 库 AE:
来看下 Redis 单线程网络模型的整个流程:
当我们的客户端想要去连接我们服务器,会去先到IO多路复用模型去进行排队,会有一个连接应答处理器,他会去接受读请求,然后又把读请求注册到具体模型中去,此时这些建立起来的连接,如果是客户端请求处理器去进行执行命令时,他会去把数据读取出来,然后把数据放入到 client 中, client 去解析当前的命令转化为 redis 认识的命令,接下来就开始处理这些命令,从 redis 中的 command 中找到这些命令,然后就真正的去操作对应的数据了,当数据操作完成后,会去找到命令回复处理器,再由它将数据写出。
Redis 6.0 版本中引入了多线程,目的是为了提高IO读写效率。因此在解析客户端命令、写响应结果时采用了多线程。核心的命令执行、IO多路复用模块依然是由主线程执行。
上一篇:对路由的基本理解和使用