Redis

关注公众号 jb51net

关闭
首页 > 数据库 > Redis > Redis网络I/O模型

Redis网络I/O模型的使用及说明

作者:姓蔡小朋友

文章详细介绍了Redis的网络模型,包括单线程和多线程模型,以及I/O多路复用、事件通知机制、信号驱动I/O和异步I/O等技术,同时简要介绍了Redis的通信协议RESP

声明:这里的I/O表示redis从网卡读来自客户端的数据(包括指令、数据)

一、阻塞I/O

阻塞I/O读取数据过程:

对于单线程I/O模型,服务器只有一个线程用来监听所有客户端,线程每次调用recvfrom系统调用只能监听一个客户端的数据,如果该客户端没有数据会一直阻塞直到该客户端的数据到达内核缓冲区,无法处理其他客户端早已写入内核缓冲区的数据,性能差。

二、非阻塞I/O

非阻塞I/O相较于阻塞I/O优势在于不需要阻塞在某一个客户端,而是可以轮询所有客户端的状态,当某一客户端的数据可用会调用recvfrom读数据到用户空间,并处理该数据。

非阻塞I/O读取过程:和阻塞I/O的主要区别在步骤4和8

非阻塞I/O虽然不需要阻塞等待某一客户端的请求,并且可以同时while轮询监听多个客户端的请求,但是while轮询会查询所有客户端的数据是否到达,线程一直占用CPU,导致CPU利用率低,且轮询所有客户端所以性能也较差。

三、I/O多路复用*

I/O多路复用是利用单个线程同时监听多个文件描述符(每个文件描述符对应一个socket,也就是客户端),也就是说单线程可以 同时 监听多个socket的网络I/O请求。与阻塞I/O不同的是,I/O多路复用使用的是select、poll、epoll系统调用函数,该函数在阻塞状态下可以同时监听多个socket[监听socket、客户端socket],当某一个socket的数据到达时,就会唤醒阻塞线程回到用户态,线程调用recvfrom来读取已经到达的数据到用户空间并处理。与非阻塞I/O不同的是,当没有数据可到达时线程会阻塞并让出CPU。

1.select系统调用

select为读请求、写请求、异常事件创建三个独立的数组,每个数组有32个元素,每个元素占32bit,使用bit位作为标记,因此每个数组可监听1024个请求,其中请求来自不同的线程。通过timeout设定select阻塞等待的超时时间

单线程下select I/O多路复用读取网络数据过程:

select缺点:

2.poll系统调用

poll仍然使用数组的方式监听不同的请求,但数组中每个元素都是一个结构体,记录了请求的文件描述符fd(socket、客户端)、请求类型events、返回值revents。单线程在内核中监听时(等待中断响应),在超时时间内若监听到某个文件描述符的中断响应,就将响应类型记录到revents中,否则revents置0表示未收到相应

单线程下poll I/O多路复用读取网络数据过程:

3.epoll系统调用

epoll维护一棵红黑树和一个链表,红黑树记录要监听的文件描述符fd,就绪链表记录已就绪的文件描述符fd。与select最大的不同在于,epoll在内核态初始化epoll_create系统调用),不会频繁的在用户态和内核态拷贝。

由于在内核态,所以当线程通过accept()与客户端建立连接后,需要通过epoll_ctl()系统调用向epoll的红黑树中添加fd。此外,添加时会对fd设置一个回调函数,回调函数会在fd的数据写入内核缓存(触发中断响应)时将fd添加到就绪链表中。

线程使用epoll_wait()系统调用持续监听就绪链表,若在超时时间内有fd加入到就绪链表中那么唤醒该线程,将就绪的fd拷贝到用户空间

epoll方案很好的解决了select的三个问题。

单线程下epoll I/O多路复用读取网络数据过程:

思考:有没有可能连接请求还没有处理完,没有为客户端socket分配内核缓冲区,客户端的读请求就来了?

应该不会,这个连接请求应该就是TCP三次握手,只有建立连接了,服务器向客户端发送ACK,客户端才能发读请求到服务器。

事件通知机制:

LevelTriggered:LT,每次将就绪队列中的fd拷贝到用户空间时保留就绪队列中的fd。

EdgeTriggered:ET,每次将就绪队列中的fd拷贝到用户空间时清空就绪队列。

不太理解为什么用LT,每次都从网络读到用户内存就算一次读不全早晚也能读完吧,没必要每次都重复读吧,可能是因为数据在对应用户空间中不连续使用起来麻烦。

四、信号驱动I/O

信号驱动I/O通过sigaction()系统调用对fd设置回调函数,此时线程立即返回用户态执行其他任务。当fd的数据到达内核缓存时会触发回调函数,将就绪的fd拷贝到用户态的信号队列通知线程。线程调用recvfrom进入内核态将数据拷贝到用户内存。

与非阻塞I/O不同的是,线程返回用户态后不会轮询数据是否准备好,而是去执行其他任务,收到来自内核的通知才调用read读数据。

由于信号驱动I/O下每有一个fd就绪内核线程都会执行内核态与用户态切换通知用户线程,影响性能;而多路复用I/O可以一次性获取多个就绪的fd才切换到用户态,性能更好(多路复用I/O中虽然每次fd到达就绪链表都会唤醒线程,但是线程获得CPU前仍然可以有fd进入就绪链表,且调用wait()进入内核态时如果就绪链表有多个fd也可以一次性返回)。因此多路复用I/O的性能更好更适用于有高并发需求的redis。

五、异步I/O

异步I/O整个过程都是非阻塞的,用户进程调用aio_read()系统调用函数声明要读的fd和读到用户内存空间的地址就直接返回到用户态,进行其他任务。而读取数据和将数据从内核缓存拷贝到用户内存的任务完全交由内核线程来完成。

异步I/O比I/O多路复用更高效,因为用户线程对I/O操作完全解耦,可以实现更高并发的处理请求,使用频率较高但是不如多路复用I/O:由于所有任务都交由内核完成,每个任务内核都要开辟新线程来处理(内核是多线程,CPU也是多核,只有用户应用redis是单线程),对内核负载太大。解决方法是在用户应用进行并发控制,限制单位时间向内核分配的任务数量

六、Redis网络模型*

1.纯单线程模型

Redis通过I/O多路复用来提高网络性能,支持各种不同的多路复用实现,将这些实现封装为统一的接口:

Redis提供了通用的API接口,针对不同操作系统使用不同的实现方案。

#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

Linux下redis(单线程) epoll 处理网络数据过程:(相较于三.3没有内核态的切换,但是流程更完整)

因此,Redis中的I/O多路复用可以理解为:复用epoll同时监听客户端连接请求、客户端读请求、服务器向客户端的写请求,就绪的任何类型的请求都会放到就绪链表中,并每隔一段时间接收多条请求,针对不同类型的请求使用不同的分支(监听读处理函数step11、读处理函数step17+18、写处理函数step21)处理请求。

复用就绪链表接收三类不同的请求请求:

复用单线程处理三类不同的请求请求:

线程调用epoll_wait进入内核态检查就绪链表是否有数据,如果有直接将就绪链表拷贝到用户态并处理,如果没有回让出CPU并阻塞,直到就绪链表有数据或超过等待时长会进入就绪态等待分配CPU回到用户态。回到用户态会遍历就绪链表依次处理每个请求:

理一下写处理的过程:首先线程接收客户端读请求并处理,处理完成如果该客户端的内核缓冲区有足够的空闲那么将数据直接发送到内核缓冲区,后台会异步发送数据。只有该客户端的内核缓冲区满了才将数据放到clients_pending_write链表中,并设为可写类型,当该客户端的内核缓冲区中的数据发送完后触发中断,中断处理程序检查有足够的空闲了且该fd目前是可写状态,那么会将该fd的写请求放入就绪链表

Linux下redis(单线程) epoll 处理网络数据过程:(从用户态调用的角度分析,不涉及内核态,多路复用的思想更直观)

//redis网络I/O入口函数
main {
	server.el = aeCreateEventLoop();// 创建epoll
	listenToPort(server.port,server.ip);// redis创建监听socket,给定监听的ip和port(服务器的ip)
	createSocketAcceptHandler(acceptTcpHandler);// 将监听socket添加到epoll,并添加监听读处理函数(监听读处理函数就是step11) 
	
	// 死循环,该线程一直网络I/O
	while(true){
		// 为clients_pending_write链表中(有写需求)的客户端socket依次添加写处理函数(写处理函数就是step21)
		foreach(clients_pending_write){
			connSetWriteHandlerWithBarrier(sendReplyToClient);
		}
		
		/* epoll_wait等待中断信号,返回就绪链表
		客户端发送的连接请求引发的中断会将监听fd添加到就绪链表,设为读请求
		客户端发送的读请求引发的中断会将客户端fd添加到就绪链表,设为读请求
		服务器向客户端发送返回结果后引发的中断会将客户端fd添加到就绪链表,设为写请求
		*/
		numevents = aeApiPoll();
		// 依次处理就绪链表中的socket
		for(j = 0; j < numevents; j++){
			if(j is 监听socket){
				fd = accept(newClient);// 接收新客户端的连接请求,得到客户端socket的fd
				connSetReadHandler(fd, readQueryFromClient);// 将客户端socket添加到epoll,并添加读处理函数(读处理函数就是step17+18)
			}
			
			if(j is 客户端读socket){
				connRead(c);// 将数据从内核缓冲区读到内存中该客户端的输入缓冲区
				processInputBuffer(c);// 解析数据中的命令为字符串数组[set, name, jack]
				cmd = lookupCommand(c->argv[0]);// redis中命令是以键值对方式存储的,通过key=set就能找到对应的函数体
				proc(cmd,c);// 执行cmd命令,传入数据c
				addReply();// 将命令执行结果写入该客户端的等待队列
			}	

			if(j is 客户端写socket){
			    if (!clientHasPendingReplies(c)) {// 检查是否有数据要写
			        aeDeleteFileEvent(server.el, fd, AE_WRITABLE);// 没有数据,立即取消写事件监听
			        return;
			    }
			    int nwritten = write(c->fd, c->buf + c->sentlen, c->bufpos - c->sentlen);// 有数据,直接写入socket
			}	
		}
	}
}

2.命令处理单线程+网络I/O多线程模型

2.1 Redis单线程网络模型的瓶颈

Redis命令执行部分必须是单线程

为什么redis要做成单线程:

经过上面的分析,redis的执行过程为:接收网络请求(step12~17)->执行命令(step18)->返回响应结果(step19~22)

由于redis性能收到网络I/O的限制,经过上述分析,网络I/O的接受请求、返回响应部分可以设计成多线程,且这两部分不涉及命令的执行,所以不会出现并发问题。

2.2 Redis多线程网络模型

对于接收请求,由redis单线程分发“read系统调用将数据从内核态读到用户态(step17)”这一操作给多个子线程执行,大大提高了读取速度,且不会出现线程安全问题(不涉及请求数据中的命令执行)。

数据读取并解析完成后,由redis单(主)线程来顺序执行命令,内存执行速度快,切能保证原子操作。虽然接受请求使用多线程,但速度上仍然无法保证指令执行的主线程有100%的利用率。

对于返回响应,由redis单线程分发“send系统调用将数据从用户态读到内核态(step21)”这一操作给多个子线程执行,大大提高了读取速度,且不会出现线程安全问题。

虽然如此,但网络I/O依旧是短板。

七、RESP协议

Redis是CS架构

通信过程分为两步:

Redis中采用RESP协议来规定客户端和服务器发送请求的规范。

1.数据类型

RESP通过首字节的字符来区分不同数据类型,常用的数据类型包括5种:

例如:$3\r\nabc\r\n

如果大小为0,代表空字符串:“$0\r\n\r\n”

如果大小为-1,则代表不存在: “$-1\r\n”

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文