其实在写这篇文章的时候,心里也充满了疑惑性。但是还是将自己心里的所想表达出来,望各位大佬们能够详细解答一下;
众所周知,Redis对文件的处理是单线程模型,但是,在Redis6.0版本之后加入了多线程处理。其实这个理念好像在3.2版本就开始有想法。但是不知道为啥在6.0才实现。
但是,虽然Redis提出了多线程模型,我们的文件事件处理器依旧是单线程。所以,这就是我们今天最主要研究的话题:为什么Redis单线程依旧能处理这么快。
为了搞懂上面的问题,我们先看看一张图
从上面的图我们简单分析一波:
上面的一些介绍就是对redis处理命令的一点简要的说明。
我们在描述这个的时候先看看一个专业名词:QPS(Query Per Second):服务器每秒可以执⾏的查询次数;
(1)纯内存访问
我们知道,redis所有的数据都是放在内存中的,这也是redis为什么这么快的原因。一般来说,内存的处理速度大概在100纳秒。这也是QPS为什么能这么高的原因。
(2)非阻塞式IO
在这儿引入了一个新名词,非阻塞式IO。我们所有的操作都属于是IO式操作。一般来说,IO操作分为以下四种:
同步阻塞,同步非阻塞,异步阻塞,异步非阻塞。
这四个名词在我刚开始接触的时候还是很有迷惑性的,这四个名词意思是非常接近的。我也摘抄了一些网上的解答加上一些通俗的理解:
同步阻塞:你想吃饭,然后你就把米放进电饭锅,就一直看着它煮。等煮好了就揭开锅。
同步非阻塞:你想吃饭,你把米放进了电饭锅,然后你去洗菜,每几分钟又回来看。等煮好了再揭开锅。
异步阻塞:你想吃饭,你把米放进去了电饭锅,但是这个电饭锅可以自动跳闸,我们就不需要自己去关电饭锅。你还是在傻傻等。
异步非阻塞:你想吃饭,你用了一款可以自动跳闸的锅,然后你去打游戏,等着饭煮好了,你游戏也刚好打完了。
所以综合上面的例子:大概就是这样;
同步阻塞:一直等着线程执行结束。
同步非阻塞:每隔一段时间就去看线程执行到了什么程度。
异步阻塞:线程会自动提示结束,但是你还是去看着他结束。
异步非阻塞:线程会自动结束,你也能知道结果。线程执行的时候你可以去做其他业务。
(3)IO多路复用
这个技术有点意思哈。如果说有读过操作系统的书的兄弟应该理解这个不难。操作系统中有一个时间片轮转调度算法。通俗一点就是:我们以为所有的进程是在同时执行,但是,是每一个进程都读一两秒然后就切换。
我们上面说的非阻塞式IO也有个问题。考虑这样一种情况,redis是单线程,要处理多个读写请求,处理某个客户端的读数据的请求,结果读了一半就返回了,那么线程怎么去直到什么时候应该再继续读取数据呢?我们在写数据的时候,如果缓存区满了,写不完,剩下的数据应该怎么写,什么时候去写?
我们再对IO多路复用画个图:
在上图中,redis需要处理3个IO请求,同时返回3个响应。所以一共需要处理6个IO事件。由于redis是单线程模型,同时也只能处理一个IO事件,于是redis需要在合适的事件暂停对某个IO事件的处理,转而去处理另一个IO事件,这样redis就好比一个开关,一直转转转,转到哪个就执行哪个。这个就是IO多路复用;
在系统底层,IO多路复用一共有三种实现机制,这个也是我面试被问到的(不得不说,问我这个也真的是看得起我,,恐怖如斯)。
–Redis是跑在单线程种,所有的操作都是按照顺序执行,但是由于读写操作等待用户输入或者输出都是阻塞,所以IO操作在一般情况下不能直接返回,这回导致某一文件的IO阻塞导致整个进程无法对其他客户提供服务。
IO多路复用就是为了解决这个问题。
Redis的io模型主要是基于epoll实现,不过它也提供了select和kqueue的实现,默认采用epoll
epoll是啥?其实就是众多IO多路复用技术当中的一种,但是想比其他的复用技术(select,poll)等,spoll优点。
1、epoll没有最大并发连接的限制,上线是最大可以打开文件的数目,这个数字一半远大于2048,一般来说这个数目可以cat /proc/sys/fs/file-max查看
2、效率的提升,epoll最大的有点就在于它只管你活跃的连接,而跟连接总数无关,因此在实际的网络环境种,epoll的效率就会远远高于select和poll
3、内存拷贝,epoll在这点上使用了共享内存 其实就是在计算机中,有内核空间和用户空间,如果不用epoll技术,那么需要从物理内存技术中将数据从内核空间复制到用户空间中,这样会浪费效率,如果使用epoll,那么会在物理内存中将数据映射到内核空间和用户空间。可以提升效率。
epoll与select/poll的区别
select,poll,epoll都是io多路复用的机制。IO多路复用就通过一种机制,可以监视多个描述符,一旦某个描述就绪,能够通知程序进行相应的操作。
select的本质是采用32个整数的32位,即3232=1024来标识,fd值为1-1024 当fd的值超过1024限制时,就必须修改FD_SETSIZE的大小。这个时候就可以标识32max值范围的fd
poll与select不同,通过一个pollfd数组向内核传递需要关注的事件,故没有描述符合个数的限制,pollfd中的events字段和revents分别用于标示关注的事件和发生的事件,因此pollfd数组只需要被初始化一次。
epoll还是poll的一种优化,返回后不需要对所有的fd进行遍历,在内核中维持了fd的列表。select和poll是将这个内核列表维持在用户态,然后传递到内核中。与poll/select 不同,epoll不再是一个单独的系统调用,而是由epoll_create/epoll_ctl/epoll_wait三个系统调用组成。
(4)单线程模式的好处和坏处
好处:单线程避免了用户态和系统态的来回切换,节省资源。
坏处:单线程比较害怕阻塞;所以也就是说redis比较适合快速处理的场景。
Reactor模式,这应该算得上redis中比较舒服的技术了。为什么说舒服呢?因为我们能感受到处理的快速以及响应的及时。
我们先知道Reactor模式是什么;
Reactor模式,又称反应式模式。就是能及时的监听变化然后能够给出响应。
Reactor模式就是那位IT界很牛逼的那位大爷提出来的。
Netty是典型的Reactor模型结构,关于Reactor的详尽阐释,本文站在巨人的肩膀上,借助 Doug Lea(就是那位让人无限景仰的大爷)的“Scalable IO in Java”中讲述的Reactor模式。
“Scalable IO in Java”的地址是:http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
Reactor模式也叫反应器模式,大多数IO相关组件如Netty、Redis在使用的IO模式,为什么需要这种模式,它是如何设计来解决高性能并发的呢?
如果说要了解这个,我们还是得先看看网络编程这块(如果不想了解的,可以直接跳到最下面去阅读redis部分)
在原始时期,那时候的网络编程一般怎么处理呢?一般就是写个while循环,一直去判断是否有新的套接字,如果有,就去处理它,类似于下面的这种:
while(true){
socket = accept();
handle(socket)
}
后来啊,这种模型遇到了一个大问题,那就是如果前面的没有处理完,但是下一个访问又来了,也就只能阻塞住,然后就只有一种后果,服务器宕机。
然后呢,有人提出了一种新的思想。那就是给每个访问都开一条线程去处理,这样每个访问都有单独的线程做事,并且是互不干扰的。也就是很经典的connection per thread,类似:
package com.crazymakercircle.iodemo.base;
import com.crazymakercircle.config.SystemConfig;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
class BasicModel implements Runnable {
public void run() {
try {
ServerSocket ss =
new ServerSocket(SystemConfig.SOCKET_SERVER_PORT);
while (!Thread.interrupted())
new Thread(new Handler(ss.accept())).start();
//创建新线程来handle
// or, single-threaded, or a thread pool
} catch (IOException ex) { /* ... */ }
}
static class Handler implements Runnable {
final Socket socket;
Handler(Socket s) { socket = s; }
public void run() {
try {
byte[] input = new byte[SystemConfig.INPUT_SIZE];
socket.getInputStream().read(input);
byte[] output = process(input);
socket.getOutputStream().write(output);
} catch (IOException ex) { /* ... */ }
}
private byte[] process(byte[] input) {
byte[] output=null;
/* ... */
return output;
}
}
}
如果你读过tomcat官网,那么会发现,早期的tomcat就是这样干。这样做的好处是什么呢?确实在一定程度上极大的提高了服务器的吞吐量。但是每一个访问都开一条线程势必造成资源浪费,并且如果线程开的太多,系统本身也无法承受住。
所以针对上面的问题,有了一次改进:
采用基于事件驱动模型,当有事件触发的时候,才会调用处理器进行数据处理。使用Reactor模式(响应式编程),对线程的数量进行控制,一个线程处理大量的事件。
什么是单线程的Reactor呢?
我们先看个图:
从上面的图我们简单的分析一下:reactor负责的就是将多个客户端进行分发到各个handler进行处理掉。我们在看看“Scalable IO in Java”里面的解释:
看到这儿有木有一种很豁然开朗的感觉。
基于上面的一种思想,书中也给出了Reactor模型的初始形态代码:
static class Server
{
public static void testServer() throws IOException
{
// 1、获取Selector选择器
Selector selector = Selector.open();
// 2、获取通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 3.设置为非阻塞
serverSocketChannel.configureBlocking(false);
// 4、绑定连接
serverSocketChannel.bind(new InetSocketAddress(SystemConfig.SOCKET_SERVER_PORT));
// 5、将通道注册到选择器上,并注册的操作为:“接收”操作
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 6、采用轮询的方式,查询获取“准备就绪”的注册过的操作
while (selector.select() > 0)
{
// 7、获取当前选择器中所有注册的选择键(“已经准备就绪的操作”)
Iterator<SelectionKey> selectedKeys = selector.selectedKeys().iterator();
while (selectedKeys.hasNext())
{
// 8、获取“准备就绪”的时间
SelectionKey selectedKey = selectedKeys.next();
// 9、判断key是具体的什么事件
if (selectedKey.isAcceptable())
{
// 10、若接受的事件是“接收就绪” 操作,就获取客户端连接
SocketChannel socketChannel = serverSocketChannel.accept();
// 11、切换为非阻塞模式
socketChannel.configureBlocking(false);
// 12、将该通道注册到selector选择器上
socketChannel.register(selector, SelectionKey.OP_READ);
}
else if (selectedKey.isReadable())
{
// 13、获取该选择器上的“读就绪”状态的通道
SocketChannel socketChannel = (SocketChannel) selectedKey.channel();
// 14、读取数据
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int length = 0;
while ((length = socketChannel.read(byteBuffer)) != -1)
{
byteBuffer.flip();
System.out.println(new String(byteBuffer.array(), 0, length));
byteBuffer.clear();
}
socketChannel.close();
}
// 15、移除选择键
selectedKeys.remove();
}
}
// 7、关闭连接
serverSocketChannel.close();
}
public static void main(String[] args) throws IOException
{
testServer();
}
}
实际上的Reactor模式,是基于NIO的,在他的基础上,抽象出来两个组件------Reactor和Handler:
Reactor:负责响应IO事件,当检测到一个新的事件,就把他发送给相应的Handler去处理;新的事件包含连接建立就绪、读就绪和写就绪等等。
Haldler:将事件和Handler进行绑定,负责完成对事件的处理,完成通道中的读,事件处理完后,再将结果写出通道。
在书中,也有对这两个组件的基本代码介绍:
Reactor代码如下:
package com.crazymakercircle.ReactorModel;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
class Reactor implements Runnable
{
final Selector selector;
final ServerSocketChannel serverSocket;
Reactor(int port) throws IOException
{ //Reactor初始化
selector = Selector.open();
serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(port));
//非阻塞
serverSocket.configureBlocking(false);
//分步处理,第一步,接收accept事件
SelectionKey sk =
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
//attach callback object, Acceptor
sk.attach(new Acceptor());
}
public void run()
{
try
{
while (!Thread.interrupted())
{
selector.select();
Set selected = selector.selectedKeys();
Iterator it = selected.iterator();
while (it.hasNext())
{
//Reactor负责dispatch收到的事件
dispatch((SelectionKey) (it.next()));
}
selected.clear();
}
} catch (IOException ex)
{ /* ... */ }
}
void dispatch(SelectionKey k)
{
Runnable r = (Runnable) (k.attachment());
//调用之前注册的callback对象
if (r != null)
{
r.run();
}
}
// inner class
class Acceptor implements Runnable
{
public void run()
{
try
{
SocketChannel channel = serverSocket.accept();
if (channel != null)
new Handler(selector, channel);
} catch (IOException ex)
{ /* ... */ }
}
}
}
Handler代码如下:
package com.crazymakercircle.ReactorModel;
import com.crazymakercircle.config.SystemConfig;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
class Handler implements Runnable
{
final SocketChannel channel;
final SelectionKey sk;
ByteBuffer input = ByteBuffer.allocate(SystemConfig.INPUT_SIZE);
ByteBuffer output = ByteBuffer.allocate(SystemConfig.SEND_SIZE);
static final int READING = 0, SENDING = 1;
int state = READING;
Handler(Selector selector, SocketChannel c) throws IOException
{
channel = c;
c.configureBlocking(false);
// Optionally try first read now
sk = channel.register(selector, 0);
//将Handler作为callback对象
sk.attach(this);
//第二步,注册Read就绪事件
sk.interestOps(SelectionKey.OP_READ);
selector.wakeup();
}
boolean inputIsComplete()
{
/* ... */
return false;
}
boolean outputIsComplete()
{
/* ... */
return false;
}
void process()
{
/* ... */
return;
}
public void run()
{
try
{
if (state == READING)
{
read();
}
else if (state == SENDING)
{
send();
}
} catch (IOException ex)
{ /* ... */ }
}
void read() throws IOException
{
channel.read(input);
if (inputIsComplete())
{
process();
state = SENDING;
// Normally also do first write now
//第三步,接收write就绪事件
sk.interestOps(SelectionKey.OP_WRITE);
}
}
void send() throws IOException
{
channel.write(output);
//write完就结束了, 关闭select key
if (outputIsComplete())
{
sk.cancel();
}
}
}
这两段代码,如果能看懂,就尽量看懂,看不懂,死磕也要看懂。
当其中某个handler阻塞的时候,会导致其他所有的client的handler都得不到执行,因为没有对应的处理器去处理。因此,单线程模型仅仅适用于handler中业务处理组件能够快速完成的场景。
既然我们知道了单线程的Reactor模型的缺陷,那么肯定就有对应的多线程模型来负责解决这个缺陷。
其实这里说的多线程,也是引进了一个线程池的概念。为了综合利用CPU的资源。将acceptor和reactor抽离开,使用线程池来进行处理;看一下原理图:
我们再结合书中的详细图来看看:
我们再看看改进后的Reactor多线程版本的代码:
package com.crazymakercircle.ReactorModel;
import com.crazymakercircle.config.SystemConfig;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
class MthreadHandler implements Runnable
{
final SocketChannel channel;
final SelectionKey selectionKey;
ByteBuffer input = ByteBuffer.allocate(SystemConfig.INPUT_SIZE);
ByteBuffer output = ByteBuffer.allocate(SystemConfig.SEND_SIZE);
static final int READING = 0, SENDING = 1;
int state = READING;
ExecutorService pool = Executors.newFixedThreadPool(2);
static final int PROCESSING = 3;
MthreadHandler(Selector selector, SocketChannel c) throws IOException
{
channel = c;
c.configureBlocking(false);
// Optionally try first read now
selectionKey = channel.register(selector, 0);
//将Handler作为callback对象
selectionKey.attach(this);
//第二步,注册Read就绪事件
selectionKey.interestOps(SelectionKey.OP_READ);
selector.wakeup();
}
boolean inputIsComplete()
{
/* ... */
return false;
}
boolean outputIsComplete()
{
/* ... */
return false;
}
void process()
{
/* ... */
return;
}
public void run()
{
try
{
if (state == READING)
{
read();
}
else if (state == SENDING)
{
send();
}
} catch (IOException ex)
{ /* ... */ }
}
synchronized void read() throws IOException
{
// ...
channel.read(input);
if (inputIsComplete())
{
state = PROCESSING;
//使用线程pool异步执行
pool.execute(new Processer());
}
}
void send() throws IOException
{
channel.write(output);
//write完就结束了, 关闭select key
if (outputIsComplete())
{
selectionKey.cancel();
}
}
synchronized void processAndHandOff()
{
process();
state = SENDING;
// or rebind attachment
//process完,开始等待write就绪
selectionKey.interestOps(SelectionKey.OP_WRITE);
}
class Processer implements Runnable
{
public void run()
{
processAndHandOff();
}
}
}
其实滴对比以下,就是在创建线程时,采用了线程池的概念。
我们知道,现在的CPU都是多核CPU,那么为了进一步的利用好CPU的资源,将Reactor和Handler进一步的进行了抽离。这样才能最大限度的利用好CPU资源。
package com.crazymakercircle.ReactorModel;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
class MthreadReactor implements Runnable
{
//subReactors集合, 一个selector代表一个subReactor
Selector[] selectors=new Selector[2];
int next = 0;
final ServerSocketChannel serverSocket;
MthreadReactor(int port) throws IOException
{ //Reactor初始化
selectors[0]=Selector.open();
selectors[1]= Selector.open();
serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(port));
//非阻塞
serverSocket.configureBlocking(false);
//分步处理,第一步,接收accept事件
SelectionKey sk =
serverSocket.register( selectors[0], SelectionKey.OP_ACCEPT);
//attach callback object, Acceptor
sk.attach(new Acceptor());
}
public void run()
{
try
{
while (!Thread.interrupted())
{
for (int i = 0; i <2 ; i++)
{
selectors[i].select();
Set selected = selectors[i].selectedKeys();
Iterator it = selected.iterator();
while (it.hasNext())
{
//Reactor负责dispatch收到的事件
dispatch((SelectionKey) (it.next()));
}
selected.clear();
}
}
} catch (IOException ex)
{ /* ... */ }
}
void dispatch(SelectionKey k)
{
Runnable r = (Runnable) (k.attachment());
//调用之前注册的callback对象
if (r != null)
{
r.run();
}
}
class Acceptor { // ...
public synchronized void run() throws IOException
{
SocketChannel connection =
serverSocket.accept(); //主selector负责accept
if (connection != null)
{
new Handler(selectors[next], connection); //选个subReactor去负责接收到的connection
}
if (++next == selectors.length) next = 0;
}
}
}
优点:
响应式编程,不必为单个同步时间阻塞
编程i暗淡,最大程度避免了多线程开销
可扩展,增加Reactor实例个数就能进一步利用CPU资源
可复用性,Reactor是一种模式,本身与业务逻辑无关
缺点:
相比传统模型,Reactor增加了一定的复杂性
Reactor需要底层的SED支持(Synchronous Event Demultiplexer),比如Java中的Selector,操作系统中的select系统调用
Reactor实际上还是在同一个线程中实现,公共使用一个channel,所以需要慎重选择
实际场景:SpringMVC中新出的web Flux 俗称响应式编程。
最后的总结引用《Redis的设计与实现》这本书的一段话:
Redis 基于 Reactor 模式开发了⾃⼰的⽹络事件处理器:
这个处理器被称为⽂件事件处理器(file event handler)⽂件事件处理器使⽤ I/O 多路复⽤(multiplexing)程序来同时监听多个套接字,并根据 套接字⽬前执⾏的任务来为套接字关联不同的事件处理器。
当被监听的套接字准备好执⾏连接应答(accept)、读取(read)、写⼊(write)、关 闭(close)等操作时,与操作相对应的⽂件事件就会产⽣,这时⽂件事件处理器就会调⽤套接字之前关联好的事件处理器来处理这些事件。
虽然⽂件事件处理器以单线程⽅式运⾏,但通过使⽤ I/O 多路复⽤程序来监听多个套接字,⽂件事件处理器既实现了⾼性能的⽹络通信模型,⼜可以很好地与 Redis 服务器中其他同样以单线程⽅式运⾏的模块进⾏对接,这保持了 Redis 内部单线程设计的简单性。
参考资料:
高性能IO模型分析-Reactor模式和Proactor模式(二)
Java Guide 面经
拓展阅读:
Redis 6.0 新特性-多线程连环13问!