前言

Redis6.0引入了多线程模型,那么在Redis6.0之前,Redis是单线程模型,那么单线程模型的Redis的底层模型是什么,为什么单线程模型还能那么快,本篇文章将对Redis的单线程模型进行学习。

注:Redis是单线程仅指Redis服务端的网络IO是单线程的,Redis的集群数据同步,持久化等都是多线程的。

正文

一. 准备知识

学习Redis中的单线程模型,会涉及一些操作系统相关的知识,所以先对这些知识进行一个简单介绍。

1. linux操作系统中的I/O

linux操作系统中,一切事物对象都是文件,linux执行任何形式的I/O操作时,都是对一个文件描述符进行读取或写入,注意这里的文件描述符不单会关联传统意义上的文件,还可能会关联管道,键盘,显示器或者网络连接。

2. socket概念

既然linux操作系统中的任何形式的I/O都是对一个文件描述符的读取或写入,那么网络I/O也不例外,通过socket()函数可以创建网络连接,其返回的socket就是文件描述符,通过socket就可以像操作文件那样来操作网络通信,例如使用read()函数来读取对端计算机传来的数据,使用write()函数来向对端计算机发送数据。

socket又叫套接字,其将不同主机上的进程或者相同主机上的不同进程进行通信做了一层抽象,也可以将socket理解为应用层到传输层的一层抽象。下表给出了两种常用的socket

socket类型说明
流式socket用于TCP通信,提供可靠的,面向连接的通信。
数据报socket用于UDP通信,提供不可靠,无连接的通信。

在进行网络通信的时候,需要一对socket,一个运行于客户端,一个运行于服务端,下图进行一个简单示意。

那么整个通信流程可以进行如下概括。

  • 服务端运行后,会在服务端创建listen-socketlisten-socket会绑定服务端的ipport,然后服务端进入监听状态;
  • 客户端请求服务端时,客户端创建connect-socketconnect-socket描述了其要连接的服务端的listen-socket,然后connect-socketlisten-socket发起连接请求;
  • connect-socketlisten-socket成功连接后(TCP三次握手成功),服务端会为已连接的客户端创建一个代表该客户端的client-socket,用于后续和客户端进行通信;
  • 客户端与服务端通过socket进行网络I/O操作,此时就实现了客户端和服务端中的不同进程的通信。

3. 用户进程缓冲区与内核缓冲区

用户进程访问系统资源(磁盘,网卡,键盘等)时,需要切换到内核态(Kernel Mode),访问结束后,又需要从内核态切换为用户态(User Mode),这种切换十分耗时,所以用户进程会在用户进程空间中开辟一块缓冲区域,叫做用户进程缓冲区,用户进程如果是读系统资源,则会将读到的系统资源写入用户进程缓冲区,后续读就读用户进程缓冲区的内容,用户进程如果是写数据到系统资源,则会将写的数据先写入用户进程缓冲区,然后再将用户进程缓冲区的内容写到系统资源。所以用户进程缓存区会减少用户进程在用户态和内核态之间的切换次数,从而降低切换的时间。

用户进程访问系统资源实际上需要借助操作系统内核完成,所以与系统资源发生I/O的实际是操作系统内核,操作系统内核为了减少与系统资源实际的I/O的次数,也有一个缓冲区叫做内核缓冲区,如果是对系统资源的读,则先将系统资源数据读取并写入内核缓冲区中,然后再将内核缓冲区的内容写入用户进程缓冲区,如果是对系统资源的写,则先将用户进程缓冲区的内容写入内核缓冲区, 然后再将内核缓冲区的内容写到系统资源。这样可以有效降低操作系统内核与系统资源的实际I/O次数,降低I/O带来的时间消耗。

下面以一个服务端处理一个客户端请求为例,对用户进程缓冲区与内核缓冲区进行一个更直观的说明。(注:实际的网络请求比下面的图示更为复杂,下面的图示只是一个大致流程的体现,目的是帮助体会用户进程缓冲区与内核缓冲区的作用)

那么上述过程可以概括如下。

  • 操作系统内核通过与网卡(系统资源)进行网络I/O读取客户端请求数据到内核缓冲区中;
  • 服务端用户进程将内核缓冲区内容写入用户进程缓冲区中,随后用户进程读取用户进程缓冲区的内容并处理业务;
  • 服务端用户进程将响应内容写入用户进程缓冲区,然后再将用户进程缓冲区的内容写入内核缓冲区;
  • 操作系统内核通过与网卡进行网络I/O,将内核缓冲区的内容写到网卡。

4. 同步阻塞I/O,同步非阻塞I/OI/O多路复用

同步阻塞I/O是用户进程调用read时发起的I/O操作,此时用户进程由用户态转换到内核态,只有在内核态中将I/O操作执行完后,才会从内核态切换回用户态,这期间用户进程会一直阻塞。同步阻塞I/OBlocking IOBIO)示意图如下。

同步非阻塞I/O是用户进程调用read时,用户进程由用户态转换到内核态后,此时如果没有系统资源数据能够被读取到内核缓冲区中,返回read失败,并从内核态切换回用户态。也就是用户进程发起I/O操作后会立即得到一个操作结果,同时用户进程需要在read失败时一直重复的发起read,直至read成功。同步非阻塞I/ONon-Blocking IO,NIO)示意图如下。

I/O多路复用是一个用户进程中对多个文件描述符进行监控,一旦有文件描述符可以进行I/O操作,内核会通知用户进程对相应的文件描述符进行I/O操作。最简单的实现是使用select操作来完成对多个文件描述符的监控,具体做法如下。

  • 在用户进程中将文件描述符注册到select的文件描述符列表中;
  • 执行select操作,此时用户进程由用户态转换到内核态,然后内核会查找出select的文件描述符列表中所有可以进行I/O操作的文件描述符,并返回,此时内核态转换到用户态;
  • 用户进程在select操作返回前会一直阻塞,直至select操作返回,此时用户进程获得了可以I/O的文件描述符列表;
  • 用户进程获得了可以I/O操作的文件描述符列表后,会对列表中每个文件描述符发起I/O操作。

I/O多路复用(IO Multiplexing)可以用下图进行示意。

5. 单Reactor单线程模型

有了上面1-4点的基础,现在来介绍单Reactor单线程模型。已知在客户端与服务端通信的过程中,出现了三种socket,如下所示。

  • listen-socket,是服务端用于监听客户端建立连接的socket
  • connect-socket,是客户端用于连接服务端的socket
  • client-socket,是服务端监听到客户端连接请求后,在服务端生成的与客户端连接的socket

(注:上述中的socket,可以被称为套接字,也可以被称为文件描述符。)

那么先看一下如下的客户端请求服务端的模型。

上图中的Server主线程中创建了listen-socket用于监听客户端的连接请求,当Client1创建connect1-socket并发起connect操作时,Server主线程会从accept操作返回并得到代表Client1client1-socket,随后Server在主线程中处理Client1的请求,此时Client2创建connect2-socket并发起connect操作,由于Server主线程正在处理Client1的请求,所以Server此时不会立即与Client2建立连接,等到Server主线程中处理完了Client1的请求并断开与Client1的连接后,此时Server才会再与Client2建立连接。上述的客户端请求服务端的模型,本质就是同步阻塞I/O模型,对于服务端来说,这种模型有两个问题,如下所示。

  • 服务端是单线程的,同一时间只能在服务端主线程中监听到一个客户端建立连接的请求,并且只会在处理完当前建立了连接的客户端的请求后,才会继续与下一个客户端建立连接;
  • 服务端的listen-socketaccept操作是阻塞的,服务端与客户端建立连接后,client-socketreadwrite操作是阻塞的,换句话说,服务端要么阻塞在listen-socketaccept操作上,要么阻塞在client-socketreadwrite操作上。

那么单Reactor单线程模型在引入了多路复用I/O后,对上面第二个问题进行了优化:服务端主线程中使用select或者epoll等操作,来同时监视listen-socketclient-socket。以select举例,服务端主线程中一开始会在select的文件描述符列表中添加listen-socket,随后调用select进入监视状态(此时主线程阻塞在select上),此时如果客户端的connect-socket发起了connect操作,服务端主线程就会从select上返回,并且判断是listen-socket准备就绪,所以会得到代表客户端的client-socket,该client-socket会被加入到select的文件描述符列表中,然后服务端主线程又调用select进入监视状态,此时是同时监视listen-socketclient-socket,后续主线程从select返回后,判断如果是listen-socket准备就绪,则将得到的client-socket加入select的文件描述符列表,如果是client-socket准备就绪,则处理对应的客户端的请求。单Reactor单线程模型可以用下图进行示意。

Reactor单线程模型中,只有一个Reactor,负责调用select来监视listen-socketclient-socket,当有socket准备就绪时,称有事件发生,如果是listen-socket准备就绪,则发生了连接事件,如果是client-socket准备就绪,则发生了读写事件,不同的事件由dispatch来分发到不同的模块进行处理,连接事件由Acceptor来获取client-socket并加入到select的文件描述符列表,读写事件由Handler来处理即执行客户端的请求并响应。

Reactor单线程模型的单线程体现在上述的操作均都是发生在主线程中,即当同时有连接事件和读写事件准备就绪时,单Reactor单线程模型会串行的处理连接事件和读写事件,该模型的优点就是简单且没有并发问题,缺点就是通常处理连接事件很快但是处理读写事件会较慢从而造成CPU资源被浪费,假若处理读写事件也很快,那么单Reactor单线程模型会是一个优秀的选择,恰好在Redis中,由于数据都是存储在内存中,Redis服务端响应客户端的读写事件的速度是很快的,所以,Redis中的单线程模型,实际就是单Reactor单线程模型

二. Redis中的文件事件处理器

Redis服务端是通过listen-socket来获取客户端连接,通过client-socket来处理客户端请求,listen-socketclient-socket可连接,可读或者可写时都会产生事件,称为文件事件,即文件事件是Redis服务端对socket的操作的抽象。Redis有一个文件事件处理器来处理文件事件,示意图如下所示。

Redis服务端会在I/O多路复用器中将socket准备就绪的操作入队列,所以准备就绪的操作会作为文件事件有序的被文件事件分派器分派到事件处理器的不同模块处理。 Redis中的文件事件处理器就是一个单Reactor单线程模型,并且单线程是体现在事件处理器处理不同的事件时是单线程的。

下面给出客户端请求Redis服务端的流程示意图。

最后对上图做如下几点说明。

  • 如果客户端的connect-socket执行connect操作,或者客户端向Redis发起写请求,那么对应的socket会产生AE_READABLE事件;
  • 如果客户端向Redis发起读请求,那么对应的socket会产生 AE_WRITABLE事件;
  • 上述流程图中,编号相同表示同一个请求的处理步骤。

总结

Redis的单线程模型,就是单Reactor单线程模型,Redis使用I/O多路复用,在单线程中轮询socket,并将对Redis库的建立连接,关闭连接,读数据和写数据请求都转换成了文件事件,最后Redis还使用其实现的文件事件分派器和事件处理器来处理不同的事件,整体执行效率高,还节省了多线程的开销。


半夏之沫
65 声望31 粉丝