【redis】redis的多线程和IO多路复用
- 【一】前言
- 【二】Redis单线程和多线程问题的背景
- 【1】Redis的单线程
- 【2】Redis为什么选择单线程?
- 【3】Redis为什么开始利用多核?
- 【4】Redis当前的性能瓶颈
- 【5】Redis的主线程如何和IO线程协同
- 【三】IO多路复用的理解
- 【1】什么是IO多路复用
- 【2】深入理解Redis的IO多路复用
- (1)I/O多路复用机制
- (2)为什么从bio,nio到io多路复用
- (3)如何解决服务器主线程阻塞的问题?
- (4)如何解决多线程资源浪费的问题
- (5)如何解决NIO模型cpu不友好的问题?
- (6)IO多路复用的具体实现
- 1-》select方法
- 2-》poll方法
- 3-》epoll方法
- 4-》三个方法的对比
【一】前言
Redis的单线程主要处理键值对读写,但其他功能如持久化、异步删除由额外线程执行。Redis选择单线程避免上下文切换和线程竞争,利用多路复用技术提高处理多个客户端请求的能力。Redis4.0引入异步删除以减少主线程阻塞。Redis6/7开始使用多IO线程处理网络请求,主线程仍单线程处理命令操作以保证原子性。IO多路复用(如epoll)配合主线程和IO线程协同,提升了Redis的性能。
【二】Redis单线程和多线程问题的背景
Redis版本迭代历史
【1】Redis的单线程
(1)主要是指Redis的网络IO和键值对读写是由一个线程来完成的,Redis在处理客户端的请求时包括获取(socket读)、解析、执行、内容返回(socket写)等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。这也是Redis对外提供键值存储服务的主要流程。
(2)但Redis的其他功能,比如持久化RDB、AOF、异步删除、集群数据同步等等,其实是由额外的线程执行的。
(3)Redis命令工作线程是单线程的,但是整个Redis来说是多线程的
【2】Redis为什么选择单线程?
(1)基于内存操作:Redis的所有数据都存在内存中,因此所有的运算都是内存级别的,性能比较高;
(2)数据结构简单:Rdis的数据结构是专门设计的,而这些简单的数据结构的查找和操作的时间大部分复杂度都是O(1),因此性能比较高
(3)多路复用和非阻塞I/O:Redis使用I/O多路复用功能来监听多个socket连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换带来的开销,避免了IO阻塞操作
(4)避免上下文功换:因为是单线程模型,因此就避免了不必要的上下文切换和多线程竞争,这就省去了多线程切换带来的时间和性能上的消耗
Redis采用的是基于内存的采用的是单进程单线程模型的 KV 数据库,由C语言编写,官方提供的数据是可以达到100000+的QPS(每秒内查询次数)。
这个数据不比采用单进程多线程的同样基于内存的 KV 数据库 Memcached 差!
上图是Redis官方的测试数据,横轴表示连接数,纵轴表示QPS。
如果Redis采用的是多线程,那么CPU会在多个线程之间来回切换,在如此高的QPS下,多个线程的切换时特别频繁的,十分消耗CPU。
假设我们有三个连接同时执行下面的操作:
incr count
由于Redis使用单线程来处理命令的,所以一条命令来了之后不会立即执行,而是将命令加到一个队列中,然后逐个被执行,这样就能保证在不加锁的情况下保证不会产生并发问题。
如果Redis采用多线程执行命令,那么对于三个连接同时执行incr操作的时候,就需要添加锁来保证线程的安全性了,会大大降低执行效率。
Redis采用非阻塞IO,使用epoll作为IO多路复用技术的实现,再加上Redis自身的树碱处理模型将epoll中的连接、读写、关闭都转为事件,不在网络IO上浪费过多的事件。
当然了,单线程机制也不是万能的,也会存在一个的问题:对于每个命令的执行事件是有要求的。如果某个命令执行事件过长,会导致其他命令被阻塞,对于Redis这种高性能的服务来说是致命的。
所以即使Redis性能很高,我们也要合理的去使用Redis,不合理的使用也会导致很多问题。
【3】Redis为什么开始利用多核?
使用多核的必要性
(1)Redis4.0引入异步删除,在删除大key时,单线程会出现阻塞(类似synchronized锁)
unlink key
flushdb async
flushall async
(2)利用惰性删除避免阻塞。因为Redis是单个主线程处理,而lazy free的本质就是把某些cost(主要时间复制度,占用主线程cpu时间片)较高删除操作,从redis主线程剥离让bio子线程来处理,极大地减少主线阻塞时间。从而减少删除导致性能和稳定性问题。
【4】Redis当前的性能瓶颈
(1)redis主要的性能瓶颈是内存或者网络IO,而不太可能是cpu
(2)虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF重写)。但是从网络IO处理到实际的读写命令处理,都是由单个线程完成的。
(3)单个主线程处理网络请求的速度跟不上底层网络硬件的速度,为了应对这个问题采用多个IO线程来处理网络请求,提高网络请求处理的并行度,Rdis6/7就是采用的这种方法。
但是,Redis的多IO线程只是用来处理网络请求的,对于读写操作命令Redis仍然使用单线程来处理。这是因为Redis处理请求时,网络处理经常是瓶颈,通过多个IO线程并行处理网络操作,可以提升实例的整体处理性能。而继续使用单线程执行命令操作,就不用为了保证Lua脚本、事务的原子性,额外开发多线程互斥加锁机制,这样一来Redis线程模型实现就简单了
【5】Redis的主线程如何和IO线程协同
既然瓶颈是网络IO,Redis的主线程如何和IO线程协同?
(1)服务端和客户端建立Socket连接,并分配处理线程。
首先,主线程负责接收建立连接请求。当有客户端请求和实例建立Socket连接时,主线程会创健和客户的连接,并把Socket放入全局等待队列中。紧接看主线程通过轮询方法把Socket连接分配给IO线程
(2)IO线程读取并解折请求。
主线程一旦把Socket分配给IO线程,就会进入阻塞状态,等待IO线程完成客户端请求读取和解析。因为有多个IO线程在并行处理,所似这个过程很快完成
(3)主线程执行请求操作。
等IO线程解析完请求,主线程以单线程的方式执行这些命令操作。
(4)IO线程回写Socket和主当主线程清空全局队列。
主线程执行完请求操作后,会把需要返回的结果写入缓冲区,然后主线程会阻塞等待IO线程,把这些结果回写到Socket中(多个线程在并发执行),并返回给客户端。等到lO线程回写Socket完毕,主线程会清空全局队列,等待客户端的后续请求。
【三】IO多路复用的理解
【1】什么是IO多路复用
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
IO多路复用的解释,即使用一个或一组线程处理多个tcp连接
(1)IO,指代网络IO,需要进行模态转换的读写操作
(2)多路,多个客户端连接(socket)
(3)复用,复用一个或多个线程
一个服务端进程可以同时处理多个socket,IO多路复用模型包括select,poll,epoll
在单个线程通过记录跟踪每一个Sockek(I/O流)的状态来同时管理多个I/O流。一个服务端进程可以同时处理多个套接字描述符。目的是尽量多的提高服务器的吞吐能力。
将用户socket对应的文件描述符(FileDescriptor)注册进epoll,然后epoll监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用非阻塞模式。这样整个过程只在调用select.、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor模式。
因此IO多路复用+epoll也是Redis速度快的根本保证。redis的多线程即
(1)单个工作线程保证线程安全和高性能
(2)多个IO线程解决网络IO的问题
【2】深入理解Redis的IO多路复用
(1)I/O多路复用机制
Redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器,事件分派器将事件分发给事件处理器。
Rdis是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以I/O操作在一般情况下往往不能直接返回,这会导致某一文件的I/O阻塞导致整个进程无法对其它客户提供服务,而I/O多路复用就是为了解决这个问题而出现
所谓I/O多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要select、pol川、epol来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
Redis服务采用Reactor的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符
Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器。它的组成结构为4部分:
(1)多个套接字
(2)IO多路复用程序
(3)文件事件分派器
(4)事件处理器。
因为文件事件分派器队列的消费是单线程的,所汉Redis才叫单线程模型
(2)为什么从bio,nio到io多路复用
在bio场景下,当client1请求并和server建立连接后,server在accept和read处发生阻塞,此时新的client2建立连接的请求无法被server处理(处于阻塞中)
注意:这里的应用程序指代的是server端
(3)如何解决服务器主线程阻塞的问题?
考虑使用子线程来处理新的客户端连接和请求。在连接socket后创建线程来处理read操作
client代码
Socket socket = new Scoket("127.0.0.1",6379);
OutputStream outputStream == socket.getOUtputStream();
// 获取标准输入到msg
outputStream.write(msg.getBytes());
server代码
ServerSocket serverSocket = new ServerSocket(6379);
while(true){Socket socket = serverSocket.accept(); // 阻塞等待连接new Thread(() -> {try{InputStream inputStream = socket.getInputStream();// 从input中获取输入,阻塞在读请求socket.close();}},Thread.currentThread().getName()).start();
}
(4)如何解决多线程资源浪费的问题
多线程模型要求每到来一个请求,就切换到内核态创建对应的的进程。新的进程和上下文切换都会产生性能的损耗
(1)池化技术,但是实际的线程数量未知
(2)NIO,使read方法不阻塞
NIO的特点是使用轮询替代阻塞
在客户端,如果kernal中的数据没有准备好,则会返回error
在NIO中一切都是非阻塞的
(1)accept方法非阻塞,如果没有客户端连接则返回无连接标识
(2)read方法非阻塞,如果read读不到数据返回空间中标识,有数据则只阻塞read读取数据的时间
(3)nio中服务端只有一个线程,对socker数组进行遍历
客户端代码和bio下相同
Socket socket = new Scoket("127.0.0.1",6379);
OutputStream outputStream == socket.getOUtputStream();
// 获取标准输入到msg
outputStream.write(msg.getBytes());
服务端代码
(1)通过ServerSocketChannel的configureBlocking(false)方法将获得连接设置为非阻塞的。此时若没有连接,accept会返回null
(2)通过SocketChannel的configureBlocking(false)方法将从通道中读取数据设置为非阻塞的。若此时通道中没有数据可读,read会返回-1
static ArrayList<SocketChannel> socketList = new ArrayList<>(); // 容纳socket数组
static ByteBuffer byteBuffer = ByteBuffer.allocate(1024); // 输入缓冲ServerSocketChannel serverSocket = new ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress(127.0.0.1,6379));
serverSocket.configureBlocking(false); // jdk1.4开始兼容阻塞模式,此处设置为非阻塞while(true){for (SocketChannel element : socketList){int read = element.read(byteBuffer); //不会阻塞if(read > 0){byteBuffer.flip();byte[] bytes = new byte[read];byteBuffer.get(byte);byteBuffer.clear();}}SocketChannel socketChannel = serverSocket.accpet(); // 不会阻塞if(socketChannel != null){sockerChannel.configureBlocking(false);socketList.add(socketChannel) // 连接成功}
}
(5)如何解决NIO模型cpu不友好的问题?
NIO模型存在的问题
(1)socketlist遍历存在性能瓶颈(空socket仍旧会轮询),cpu不友好
(2)对socket状态的判断仍旧需要切换模态(查询socket仍旧是通过read syscall),开销较大、
(3)轮询会不断询问内核,占用了大量cpu时间,系统资源利用率较低
如果通过linux内核完成上述工作(遍历socket),不需要模态切换,内核本身是非阻塞的。
多路复用就是使用一个信道传输多个信号,多个网络连接使用一个线程,多个socket使用一个通道是在内核和驱动层实现的
IO多路复用模型
基于I/O复用模型:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
Reactor模式,是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor模式也叫Dispatcher模式。即I/O多了复用统一监听事件,收到事件后分发(Dispatch给某进程),是编写高性能网络服务器的必备技术。Reactor模式中有2个关键组成:
(1)Reactor,Reactor在一个单独的线程中运行,负贵监听和分发事件,分发给适当的处理程序来对IO事件做出反应。它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人
(2)Handlers,处理程序执行I/O事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际办理人。Reactor通过调度适当的处理程序来响应I/O事件,处理程序执行非阻塞操作
(3)
(6)IO多路复用的具体实现
1-》select方法
select函数监视的文件描述符分3类,分别是readfds、writefds和exceptfds,将用户传入的数组拷贝到内核空间
(1)调用后select函数会阻塞,直到有描述符就绪(有数据可读、可写、或者有except)或超时(timeout指定等待时间,如果立即返回设为null即可),函数返回
(2)当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
分析select函数的执行流程:
(1)select是一个阻塞函数,当没有数据时,会一直阻塞在select调用
(2)当有数据时会将rset中对应的位置为1
(3)selecti函数返回,不再阻塞
(4)遍历文件描述符数组,判断哪个fd被置位
(5)读取数据,然后处理
下面的示例代码
(1)实际是将之前java示例的代码从用户态搬到内核态运行
(2)将fd拷贝到内核态,避免模态切换
sockfd = socket(AF_INET,SOCK_STREAM,0);
memse(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd,(struct sockaddr*)&addr, sizeof(addr));
listen (sockfd, 5);
for(i=0;i<5;i+){// 模拟5个客户端连接memset(&client,0,sizeof (client)); addrlen = sizeof(client);fds[i] = accept(sockfd, (struct sockaddr*)&client, &addrlen);if(fds[i]>max)max = fds[i]; //找到一个最大的文件描述符,在下面select中限制范围
}while(1){FD_ZERO(&rset); // 重置rset为0for(i=0;i<5;i++){FD_SET(fds[i],&rset); // &rset是个bitmap.如果5个文件描述符分别是1,2,4,5,7,那么这个bitmap为01101101}puts("round again"); select(max+1,&rset,NULL,NULL,NULL); // select是一个系统调用,它会堵塞直到有数据发送到socket,select会把&rset相应的位置置位,但并不会返回哪个socket有数据for(i=0;i<5;i++){// 用户态只要遍历&rst,看哪一位被置位了,不需要每次调用系统调用来判断了,效率有很大提升,遍历到被置位的文件描述符就进行读取if (FD_ISSET(fds[i],&rset)){memset(buffer, 0, MAXBUF);read(fds[i], buffer, MAXBUF);puts(buffer);}
}
select函数的缺点
(1)bitmap默认大小为1024(代码实现较早1983年),虽然可以调整但还是有限度的
(2)rset每次循环都必须重新置位为0,不可重复使用
(3)尽管将rset从用户态拷贝到内核态由内核态判断是否有数据,但是还是有拷贝的开销
(4)当有数据时select就会返回,但是selecti函数并不知道哪个文件描述符有数据了(仅仅置位)。后面还需要再次对文件描述符数组进行遍历,效率比较低
2-》poll方法
函数原型
int poll(struct pollfd *fds,nfds_t nfds,int timeout);
struct pollfdint fd; //file descriptor short events; // 读或者写事件short revents; // 文件描述符有事件则置1
};
poll的执行流程:
(1)将五个fd从用户态拷贝到内核态
(2)poll为阻塞方法,执行poll方法,如果有数据会将fd对应的revents置为POLLIN
(3)poll方法返回
(4)循环遍历,查找哪个fd被置位为POLLIN了
(5)将revents重置为0便于复用
(6)对置位的fd进行读取和处理
解决的问题
(1)不在使用bitmap,消除了client的大小限制(1024)
(2)rset不可复用的问题(每次置0)
代码实现
for(1=0;1<5;1++) //模拟5个客户端连接
{memset(&client,0,sizeof (client));addrlen = sizeof(client);pollfds[i].fd = accept(sockfd,(struct sockaddr)&client,&addr);pollfds[i].events = POLLIN; //这5个socket只读事件
}c
sleep(1);
while(1){puts("round again");poll(po11fds,5,50000); // poll中传入pollfds数组,交给内核判断是否有事件发生,如果哪个发生事件则revents置1for(1=0; 1<5; 1++){ //遍历数组,找到哪个pollfd有事件发生if (pollfds[i].revents & POLLIN){pollfds[i].revents=O; // 找到后revents置0memset(buffer,0,MAXBUF);read(pollfds[i].fd,buffer,MAXBUF); //读取数据puts(buffer);}
}
缺点
(1)本质上还是select方法
(2)拷贝fd到内核态仍旧存在开销
(3)仍旧需要遍历socket(O(n)复杂度)
3-》epoll方法
函数原型
// 创建一个epoll的句柄,size表示内核监听的数目。并非最大限制,而是内核初始分配数据结构的建议值
int epoll_create(int size); // 向内核添加,修改或删除要监控的fd
int epoll_ctl(int epfd, // epfd是epoll_create的返回值1nt op, // 表示op操作,用三个宏来表示:添加EPOLL.CTL_ADD,除EPOLL.CTL_DEL,修改EPOLL.CTL_MODint fd, //fd:是需要监听的fd(文件描述符)struct epoll_event *event // 告诉内核要监听什么事
);
struct epoll_event// events可以是以下几个宏的熊合:// EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭);// EPOLLOUT:表示对应的文件描述符可以写;__uint32_t events;epoll_data_t data; // User data variable
}// 类似select调用
int epoll_wait(int epfd,struct epoll event *events,int maxevents, // 最多返回的事件数int timeout
);
具体实现
(1)socket生命周期只会从用户态到内核态拷贝一次
(2)使用event通知级值,每次socket有数据会主动通知内核加入就绪列表,不需要遍历所有socket
struct epoll_event events[5];
int epfd epoll_create(10); // epoll_create在内核开辟一块内存空间,用来存放epolle中fd的数据结构for(i=0;i<5;i+) { //模拟S个客户端连接static struct epoll_event ev; // epoll中fd的数据结构和polI中的差不多,只是没有了reventsmemset(&client,0,sizeof (client));addrlen sizeof(client); ev.data.fd = accept(sockfd,(struct sockaddr&client,&addrlen);ev.events = EPOLLIN;epoll_ctl(epfd,EPOLL_CTL_ADD,ev.data.fd,&ev); // epoll ctl把每-个socket的fd数据结构放到epoll_create创建的内存空间中
}while(1){puts("round again");nfds = epoll_wait(epfd,events,5,1O0O0); // epoll_wait阻塞,只有当epoll_create中创建的内存空间中的fd有事件发生,才会把这些fd放入就绪链表中,返回就绪fd的个数for(i=0;i<nfds;i++){ //遍历就绪链表,读取数据memset(buffer,0,MAXBUF);read(events[i].data.fd, buffer, MAXBUF);puts(buffer);}
}
4-》三个方法的对比
不同操作系统选择的方式不同,windows没有epoll默认为select