1.Redis是单线程的还是多线程的?
2.Redis性能很快的原因
3.Redis的瓶颈在哪里
4.I/O多路复用模型理论
5.I/O多路复用模型JAVA验证
6.Redis如何处理并发客户端链接
7.Linux内核函数select, poll, epoll解析
8.总结
1.Redis是单线程的还是多线程的?
当我们在学习和使用redis的时候,大家口耳相传的,说的都是redis是单线程的。其实这种说法并不严谨,Redis的版本有非常多,3.x、4.x、6.x,版本不同架构也是不同的,不限定版本问是否单线程不太严谨。
1)版本3.x,最早版本,也就是大家口口相传的redis是单线程。
2)版本4.x,严格意义上来说也不是单线程,而是负责客户端处理请求的线程是单线程,但是开始加了点多线程的东西(异步删除)
3)最新版本的6.0.x后,告别了刻板印象中的单线程,而采用了一种全新的多线程来解决问题。
所以我们可以得出,Redis 6.0版本以后,对于整个redis来说,是多线程的。
Redis是单线程到多线程的演变:
在以前,Redis的网络IO和键值对的读写都是由一个线程来完成的,Redis的请求时包括获取(Socket读),解析,执行,内容返回(socket写)等都是由一个顺序串行的主线程处理,这就是所谓的单线程。
但此时,Redis的瓶颈就出现了:
I/O的读写本身是阻塞的,比如当socket中有数据的时候,Redis会通过调用先将数据从内核态空间拷贝到用户态空间,再交给Redis调用,而这个拷贝的过程就是阻塞的,当数据量越大时拷贝所需要的时间就越多,而这些操作都是基于单线程完成的。
在Redis6.0中新增加了多线程的功能来提高I/O读写性能,他的主要实现思路是将主线程的IO读写任务拆分给一组独立的线程去执行,这样就可以使多个socket的读写并行化了,采用I/O多路复用技术可以让单个线程高效处理多个连接请求(尽量减少网络IO的时间消耗),将最耗时的socket读取,请求解析,写入单独外包出去,剩下的命令执行任然是由主线程串行执行和内存的数据交互。
结合上图可知,网络IO操作就变成多线程化了,其他核心部分仍然是线程安全的,是个不错的折中办法。
流程图:
流程简述:
1)主线程负责接收建立链接请求,获取socket放入全局等待读取队列
2)主线程处理完读事件后,将这些连接分配给这些IO线程
3)主线程阻塞等待IO线程读取socket完毕
4)IO线程组收集各个socket输入进来的命令,收集完毕
5)主线解除阻塞,程执行命令,执行完毕后输出结果数据
6)主线程阻塞等待 IO 线程将数据回写 socket 完毕
7)解除绑定,清空等待队列
2.Redis性能很快的原因
1)基于内存操作,Redis的所有数据都存在内存中,因此所有的运算都是内存级别的,所以他的性能比较高。
2)数据结构简单:Redis的数据结构都是专门设计的,而这些简单的数据结构的查找和操作的时间大部分复杂度都是O(1),因此性能比较强,可以参考我之前写过的这篇文章。深入理解redis——redis经典五种数据类型及底层实现
3)多路复用和非阻塞I/O,Redis使用I/O多路复用来监听多个socket链接客户端,这样就可以使用一个线程链接来处理多个请求,减少线程切换带来的开销,同时也避免了I/O阻塞操作。
4)主线程为单线程,避免上下文切换,因为是单线程模型,因此避免了不必要的上下文切换和多线程竞争(比如锁),这就省去了多线程切换带来的时间和性能上的消耗,而且单线程不会导致死锁问题的发生。
3.Redis的瓶颈在哪里?
说了这么多redis的优点,redis的性能瓶颈到底在哪里?
从cpu上看:
1)redis是基于内存的,因此减少了cpu将数据从磁盘复制到内存的时间
2)redis是单线程的,因此减少了多线程切换和恢复上下文的时间
3)redis是单线程的,因此多核cpu和单核cpu对于redis来说没有太大影响,单个线程的执行使用一个cpu即可
综上所述,redis并没有太多受到cpu的限制,所以cpu大概率不会成为redis的瓶颈。
而内存大小和网络IO才有可能是redis的瓶颈。
所以Redis使用了I/O多路复用模型,来优化redis。
4.I/O多路复用模型理论
在学习IO多路复用之前,我们先明白下面的几个词语的概念:
1)同步:调用者要一直等待调用结果的通知后,才能继续执行,类似于串行执行程序,执行完毕后返回。
2)异步:指被调用者先返回应答让调用者先回去,然后计算调用结果,计算完成后,再以回调的方式通知给调用方。
同步,异步的侧重点,在于被调用者,重点在于获得调用结果的通知方式上。
3)阻塞:调用方一直在等,且其它别的事情都不做,当前线程会被挂起,啥都不干
4)非阻塞:调用在发出去之后,调用方先去干别的事情,不会阻塞当前线程,而会立刻返回。
阻塞,非阻塞的侧重点,在于调用者在等待消息的时候的行为,调用者能否干其它的事。
以上的IO可以组合成4种组合方式:同步阻塞,同步非阻塞,异步阻塞,异步非阻塞
同时也就衍生出了unix网络编程中的五种模型:
接下来我们用代码进行验证
5.I/O多路复用模型JAVA验证
BIO(阻塞IO):
我们来看这样一段代码:
假设我们现在有一个服务器,监听6379端口,让别人来链接,我们用这样的方式来写代码:
public class RedisServer
{
public static void main(String[] args) throws IOException
{
byte[] bytes = new byte[1024];
//监听6379端口
ServerSocket serverSocket = new ServerSocket(6379);
while(true)
{
System.out.println("模拟RedisServer启动-----111 等待连接");
Socket socket = serverSocket.accept();
System.out.println("-----222 成功连接");
System.out.println();
}
}
}
客户端:
public class RedisClient01
{
public static void main(String[] args) throws IOException
{
System.out.println("------RedisClient01 start");
Socket socket = new Socket("127.0.0.1", 6379);
}
}
上面这个模型存在很大的问题,如果客户端与服务端建立了链接,如果上面这个链接的客户端迟迟不发数据,线程就会一直阻塞在read()方法上,这样其它客户端也不能进行链接,也就是一个服务器只能处理一个客户端,对客户很不友好,我们需要升级:
利用多线程:
只要连接了一个socket,操作系统就会分配一个线程来处理,这样read()方法堵在每个具体线程上而不阻塞主线程。
public class RedisServerBIOMultiThread
{
public static void main(String[] args) throws IOException
{
ServerSocket serverSocket = new ServerSocket(6379);
while(true)
{
Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
//每次都开一个线程
new Thread(() -> {
try {
InputStream inputStream = socket.getInputStream();
int length = -1;
byte[] bytes = new byte[1024];
System.out.println("-----333 等待读取");
while((length = inputStream.read(bytes)) != -1)//阻塞2 ,等待客户端发送数据
{
System.out.println("-----444 成功读取"+new String(bytes,0,length));
System.out.println("====================");
System.out.println();
}
inputStream.close();
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
},Thread.currentThread().getName()).start();
System.out.println(Thread.currentThread().getName());
}
}
}
存在的问题:
每来一个客户端,就要开辟一个线程,在操作系统中用户态不能直接开辟线程,需要调用内核来创建一个线程,其中还涉及到用户态的切换(上下文的切换),十分消耗资源。
解决办法:
1)使用线程池,在链接少的情况下可以使用,但是在用户量大的情况下,你不知道线程池要多大,太大了内存可能不够,也不可行。
2)NIO方式,这就引出了我们的NIO
NIO(非阻塞IO):
在NIO模式中,一切都是非阻塞的:
accept()方法是非阻塞的,如果没有客户端连接,就返回error
read()方法是非阻塞的,如果read()方法读取不到数据就返回error,如果读取到数据时只阻塞read()方法读数据的时间
在NIO模式中,只有一个进程:
当一个客户端与服务器进行链接,这个socket就会加入到一个数组中,隔一段时间遍历一次,这样一个线程就能处理多个客户端的链接和数据了。
public class RedisServerNIO
{
//将所有socket加入到这个数组中
static ArrayList socketList = new ArrayList<>();
static ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
public static void main(String[] args) throws IOException
{
System.out.println("---------RedisServerNIO 启动等待中......");
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress("127.0.0.1",6379));
serverSocket.configureBlocking(false);//设置为非阻塞模式
while (true)
{
//遍历所有数组
for (SocketChannel element : socketList)
{
int read = element.read(byteBuffer);
if(read > 0)
{
System.out.println("-----读取数据: "+read);
byteBuffer.flip();
byte[] bytes = new byte[read];
byteBuffer.get(bytes);
System.out.println(new String(bytes));
byteBuffer.clear();
}
}
SocketChannel socketChannel = serverSocket.accept();
if(socketChannel != null)
{
System.out.println("-----成功连接: ");
socketChannel.configureBlocking(false);//设置为非阻塞模式
socketList.add(socketChannel);
System.out.println("-----socketList size: "+socketList.size());
}
}
}
}
NIO成功解决了BIO需要开启多线程的问题,NIO中一个线程就能解决多个socket,但还是存在两个问题:
问题一:对cpu不友好
如果客户端非常多的话,每次遍历都要非常多的socket,很多都是无效遍历。
问题二:线程上下文切换
这个遍历是在用户态进行的,用户态判断socket是否有数据还是调用内核的read()方法实现的,这就会涉及到用户态和内核态的切换,每遍历一个就要切换一次,开销非常大。
优点:不会阻塞在内核的等待数据过程,每次发起的IO请求可以立刻返回,不用阻塞等待,实时性好。
缺点:会不断询问内核,占用大量cpu时间,资源利用率低。
改进:让linux内核搞定上述需求,我们将一批文件描述符通过一次系统调用传给内核,由内核层去遍历,才能真正解决这个问题,IO多路复用应运而生,就是将上述工作直接放进Linux内核,不再两态转换。
6.Redis如何处理并发客户端链接
我们先来看看redis是如何处理这么多客户端连接的:
Redis利用linux内核函数(下文会讲解)来实现IO多路复用,将连接信息和事件放到队列中,队列再放到事件派发器,事件派发器把事件分给事件处理器。
因为Redis主线程是跑在单线程里面的,所有操作都得按顺序执行,但是由于读写操作等待用户的数据输出都是阻塞的,IO在一般的情况下不能直接返回,这回导致某一文件的IO阻塞导致整个进程无法对其它客户端提供服务,而IO多路复用就是解决这个问题的。
所谓的I/O多路复用,就是通过一种机制,让一个线程可以监视多个链接描述符,一旦某个描述符就绪(一般都是读就绪或者写就绪),就能够通知程序进行相应的读写操作。这种机制的使用需要select 、 poll 、 epoll 来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
它的概念是:多个Socket链接复用一根网线,这个功能是在内核+驱动层实现的。
IO多路复用,也叫I/O multiplexing, 这里面的 multiplexing指的其实是在单个线程通过记录跟踪每一个sock(I/O流)的状态来同时管理多个IO流,目的是尽量多的提高服务器的吞吐能力。
7.Linux内核函数select, poll, epoll解析
select, poll, epoll 都是I/O多路复用的具体的实现。
select方法:
Linux官网源码:
select函数监视的文件描述符分3类,分别是readfds、writefds和exceptfds,将用户传入的数组拷贝到内核空间,调用select函数后会进行阻塞,直到有描述符就绪(有数据 可读,可写或者有except)或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。
当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
从代码中可以看出,select系统调用后,返回了一个位置后的&ret,这样用户态只需要很简单地进行二进制比较,就能很快知道哪些socket需要read数据,有效提高了效率。
优点:
select其实就是把NIO中用户态要遍历的fd数组(我们的每一个socket链接,安装进ArrayList里面的那个)拷贝到了内核态,让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直用用户态和内核态频繁切换了。
缺点:
1)bitmap最大1024位,一个进程最多只能处理1024个客户端
2)&rset不可重用,每次socket有数据就相应的位会被置位
3)文件描述符数组拷贝到了内核态,任然有开销。select调用需要传入fd数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗资源是惊人的。(可优化为不复制)
4)select并没有通知用户态哪一个socket用户有数据,仍需要O(n)的遍历。select仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
我们自己模拟写的是,RedisServerNIO.java,只不过将它内核化了。
poll方法:
看一下linux内核源码
优点:
1)poll使用pollfd数组来代替select中的bitmap,数组中没有1024的限制,去掉了select只能监听1024个文件描述符的限制。
2)当pollfds数组中有事件发生,相应revents置置为1,遍历的时候又置位回零,实现了pollfd数组的重用
缺点:
1、pollfds数组拷贝到了内核态,仍然有开销
2、poll并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历
epoll方法:
epoll操作过程需要三个接口:
epoll_create:创建一个 epoll 句柄
epoll_ctl:向内核添加、修改或删除要监控的文件描述符
epoll_wait:类似发起了select() 调用
epoll 是只轮询那些真正发出了事件的流,并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。 采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且 Redis 在内存中操作数据的速度非常快,也就是说内存内的操作不会成为影响Redis性能的瓶颈
为什么redis一定要部署在Linux机器上才能发挥出该性能?
因为只有linux有epoll函数,其它系统会自动降级成select函数。
三个方法对比:
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
数据结构 | bitmap | 数组 | 红黑树 |
最大连接数 | 1024(x86)或者2048(x64) | 无上限 | 无上限 |
最大支持文件描述符数 | 有最大值限制 | 65535 | 65535 |
fd拷贝 | 每次调用,都需要把fd结合从用户态拷贝到内核态 | 每次调用,都需要把fd结合从用户态拷贝到内核态 | 只有首次调用的时候拷贝 |
工作效率 | 每次都要遍历所有文件描述符,时间复杂度O(n) | 每次都要遍历所有文件描述符,时间复杂度O(n) | 每次只用遍历需要遍历的文件描述符,时间复杂度O(1) |
8.总结
Redis快的一部分原因在于多路复用,多路复用快的原因是系统提供了这样的调用,使得原来的while循环里的多次系统调用变成了,一次系统调用+内核层遍历这些文件描述符。