(三)REDIS-单线程模型与IO多路复用
redis工作线程是单线程,但是整体来说是多线程的。
I/O的读和写本身是堵塞的,比如当socket中有数据时,Redis会通过调用先将数据从内核态空间拷贝到用户态空间,再交给Redis调用,而这个拷贝的过程就是阻塞的,当数据量越大时拷贝所需要的时间越多,而这些操作都是基于单线程完成的。
Redis采用Reactor模式的网络模型,对于一个客户端请求,主线程负责一个完整的处理流程。
对于一个socket而言,redis服务端与客户端建立连接和读取客户端发来的数据这两个操作都是阻塞的,这也是BIO的根本原因。
在Redis6.0中新增了多线程的功能来提高I/O的读写性能,它的主要实现思路是将主线程的IO读写任务拆分给一组独立的线程去执行,这样就可以使多个socket的读写可以并行化了,采用IO多路复用程序可以让单个线程高效的处理多个连接请求(尽量减少网络IO的消耗),将最耗时的Socket读取,请求解析、写入单独外包出去,剩下的命令执行仍然由主线程串行执行并和内存的数据交互。
Redis6.0将网络数据的读写、请求协议解析过程由多个IO线程处理,对于真正的命令执行来说,仍然使用主线程操作,一举两得。【外壳手术时,主治医生操刀,护士进行消毒,止血,监控仪器指标,准备手术刀给主治医生等】
完整的Redis单线程工作模型如下图所示:
(1)客户端server01向Redis的Server socket请求建立连接,此时server socket会产生一个AE_READABLE事件,IO多路复用器监听到server socket产生的事件之后,将该事件压入队列中。文件事件分派器从队列中获取该事件,交由连接应带处理器,随后创建一个能与客户端通信的socket01连接,并将socket01的AE_READABLE事件与一个命令请求处理器相关联。
(2)客户端server01发送set key value请求,此时redis的socket01会产生AE_READABLE事件,IO多路复用器监听到该事件并将其压入到队列中。文件事件分派器从队列中获取该事件,由于此前的server01发出的一个AE_READABLE事件已经与一个命令请求处理器关联,因此事件分派器直接将该此事件交由关联的处理器处理,随后读取server01发出的set key value操作,并在内存中完成值的设置,操作完成后它会将socket01的AE _WRITABLE事件与命令回复处理器相关联。
(3) 如果此时客户端已经做好了接受回复的准备,那么redis的socket01会产生一个AE _WRITABLE事件,同样压入队列中,事件分配器找到相关的命令回复处理器,命令回复处理器向 socket01输入本次操作的一个结果,比如"ok", 之后解除socket01的AE _WRITABLE事件与命令回复处理器的关联。
(一)BIO
1.1 BIO的阻塞之accept方法
BIO:当用户进程发起read操作时,如果kernel中的数据还没有准备好,那么它会block用户进程。如果连接不上也会阻塞用户进程。具体代码如下,分别介绍BIO的阻塞:
我们创建两个redis客户端和一个redis服务端,模拟客户的请求连接:
客户端1:
package redis.iomultiplex.bio.accept; import java.io.IOException; import java.net.Socket; public class RedisClient01 { public static void main(String[] args) throws IOException { System.out.println("---redis client start"); Socket socket = new Socket("127.0.0.1",6380); } }
客户端2
package redis.iomultiplex.bio.accept; import java.io.IOException; import java.net.Socket; public class RedisClient02 { public static void main(String[] args) throws IOException { System.out.println("---redis client02 start"); Socket socket = new Socket("127.0.0.1",6380); } }
服务端:
package redis.iomultiplex.bio.accept; import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; public class RedisServer { public static void main(String[] args) throws IOException { byte[] bytes = new byte[1024]; ServerSocket serverSocket = new ServerSocket(6380); System.out.println("模拟redisServer"); while(true){ System.out.println("没有连接,就一直阻塞"); Socket socket = serverSocket.accept(); System.out.println("成功连接,解除阻塞,socket:"+socket); } } }
测试时我们先运行服务端的代码:
这时没有客户端连接,此时服务端一直阻塞在accept方法。我们运行客户端1,结果如下:
客户端1启动成功:
服务端与客户端1成功连接,随后继续等待其它客户端的连接,阻塞在accept方法:
我们继续将客户端2启动:
此时服务端也与客户端2成功连接,随后继续等待其它连接,阻塞在accept方法
上述程序的运行结果就显示出,ServerSocket的accept方法是一个阻塞方法,有连接来了我就连接,然后继续干活,等到再次accpet时,如果没有连接就一直等待。
好的,接下来我们看下ServerSocket的read方法。
1.2 BIO的阻塞之read方法
创建可以发出命令的客户端1:
package redis.iomultiplex.bio.read; import java.io.IOException; import java.io.OutputStream; import java.net.Socket; import java.util.Scanner; public class RedisClient01 { public static void main(String[] args) throws IOException { System.out.println("---redis client01 start"); Socket socket = new Socket("127.0.0.1",6380); OutputStream outputStream = socket.getOutputStream(); while(true){ Scanner scanner = new Scanner(System.in); String string = scanner.next(); if(string.equalsIgnoreCase("quit")){ break; } socket.getOutputStream().write(string.getBytes()); System.out.println("---input quit keyword to finish"); } outputStream.close(); socket.close(); } }
创建可以发出命令的客户端2:
package redis.iomultiplex.bio.read; import java.io.IOException; import java.io.OutputStream; import java.net.Socket; import java.util.Scanner; public class RedisClient02 { public static void main(String[] args) throws IOException { System.out.println("---redis client02 start"); Socket socket = new Socket("127.0.0.1",6380); OutputStream outputStream = socket.getOutputStream(); while(true){ Scanner scanner = new Scanner(System.in); String string = scanner.next(); if(string.equalsIgnoreCase("quit")){ break; } socket.getOutputStream().write(string.getBytes()); System.out.println("---input quit keyword to finish"); } outputStream.close(); socket.close(); } }
创建接受连接和读取数据的服务端:
package redis.iomultiplex.bio.read; import java.io.IOException; import java.io.InputStream; import java.net.ServerSocket; import java.net.Socket; public class RedisServerBIO { public static void main(String[] args) throws IOException { byte[] bytes = new byte[1024]; ServerSocket serverSocket = new ServerSocket(6380); System.out.println("模拟redisServer 启动成功"); while(true){ //等待客户端连接,容易被阻塞 Socket socket = serverSocket.accept(); System.out.println("成功连接"); InputStream inputStream = socket.getInputStream(); int length = -1; System.out.println("等待读取,读取不到数据就一直阻塞"); //等待客户端发送数据 容易被阻塞 while((length = inputStream.read(bytes)) != -1){ System.out.println("成功读取到数据,打印:"+ new String(bytes,0,length)); } System.out.println("结束读取"); inputStream.close(); socket.close(); } } }
还是先运行服务端:
运行客户端1,服务端显示与客户端1成功连接
随后客户端输入123:
服务端接受到客户端1发来的数据并打印输出:
这个时候为我们可以看到输出中并没有包含”结束读取“的字样,这是因为服务端阻塞在了read方法上面。
继续服务端阻塞了,那么这个时候其它客户端申请连接,是无法连接上的,这个时候我们运行客户端2,再看服务端的输出。
客户端2启动成功
服务端并没有什么变化:
由此可见,单线程的服务端,会一直在accept方法处等待着与客户端连接,如我们先启动了客户端1,这个时候就会向下执行,并准备读取数据,等到客户端1发出数据“123”之后打印输出,并继续等待着继续读取数据,于是服务端出现了阻塞。而后,客户端2运行并发起连接,但是由于服务端阻塞在了read方法上,所以就无法与客户端2连接。这种情况就不太好,如果服务端先与客户端1连接上,并交互了一段时间,但是后面客户端1并不活跃,这就造成后面的客户端也无法与服务端进行交互。
BIO的缺点是同一时间只能在服务端主线程中监听到一个客户端建立连接的请求,并且只会在处理完当前建立了连接的客户端的请求后,才会继续与下一个客户端建立连接;
1.3 BIO的异步阻塞实现
为了解决这种服务端这边的read阻塞,我们可以考虑使用多线程,让连接上的socket异步处理来自客户端的请求。即使没有数据可读,也只会阻塞在异步的线程中,服务端的主进程仍然可以接受其它客户端的连接,实现如下:
package redis.iomultiplex.nio.MultiThread; import java.io.IOException; import java.io.InputStream; import java.net.ServerSocket; import java.net.Socket; public class RedisServerBIO { public static void main(String[] args) throws IOException { byte[] bytes = new byte[1024]; ServerSocket serverSocket = new ServerSocket(6380); while(true){ System.out.println("模拟redisServer 启动成功----111 等待连接"); //等待客户端连接,容易被阻塞 Socket socket = serverSocket.accept(); System.out.println("成功连接"); new Thread(()->{ try (InputStream inputStream = socket.getInputStream()) { int length = -1; System.out.println("等待读取"); //线程异步实现,只会在该线程内阻塞 while ((length = inputStream.read(bytes)) != -1) { System.out.println("成功读取" + new String(bytes, 0, length)); } try { inputStream.close(); } catch (IOException e) { e.printStackTrace(); } } catch (IOException e) { e.printStackTrace(); } try { socket.close(); } catch (IOException e) { e.printStackTrace(); } }).start(); } } }
还是执行上述步骤,先运行服务端,然后运行客户端1,并输入abc
客户端1:
服务端:
接着我们继续运行客户端2,并输出efg
此时服务端能够成功与客户端2连接,并打印了来自于客户端的请求:
通过这种多线程的方式,虽然能够解决服务端的阻塞,让服务端能够同时处理多个客户端的请求,但是如果客户端很多,那么服务端就需要开辟很多线程,这显然是非常消耗资源的。
(二)NIO
BIO的主要问题就是Socket的accept方法和read方法的阻塞,上述用多线程的方式解决了Socket的read方法引起的进程阻塞。但是可能存在线程开销大的弊端。那么NIO是一种非阻塞IO,它是如何解决阻塞的呢,我们先看下NIO的定义:
NIO:当用户进程发起read操作时,如果kernel中的数据还没有准备好N,那么它不会block用户进程,而是立刻返回一个error。从用户角度来看,在发起一个read操作后,并不需要马上等待,而是马上就得到了一个结果。用户进程在得到error响应后,就知道数据还没有准备好,于是它再次发起read操作。一旦kernel准备好数据,并且用户又发起了read操作,那么就会将数据被读到用户内存。所以NIO特点是用户进程需要不断的主动询问内核数据准备好了没有。
NIO的服务端代码如下:
package redis.iomultiplex.nio.read; import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; import java.nio.ByteBuffer; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.ArrayList; import java.util.List; public class RedisServerNIO { private static List<SocketChannel> socketChannelList = new ArrayList<SocketChannel>(); private static ByteBuffer byteBuffer = ByteBuffer.allocate(1024); public static void main(String[] args) throws IOException, InterruptedException { System.out.println("redisServerNIO 启动启动等待中"); ServerSocketChannel serverSocket = ServerSocketChannel.open(); serverSocket.bind(new InetSocketAddress("127.0.0.1",6380)); serverSocket.configureBlocking(false); while(true){ for(SocketChannel channel: socketChannelList){ System.out.println("开始读取:"+channel); int read = channel.read(byteBuffer); if(read > 0){ byteBuffer.flip(); byte[] bytes = new byte[read]; byteBuffer.get(bytes); System.out.println(new String(bytes)); byteBuffer.clear(); } System.out.println("读不到数据不会阻塞,结束读取:"+channel); //等待只是为了更好的展示效果 Thread.sleep(3000); } SocketChannel socketChannel = serverSocket.accept(); if(socketChannel != null){ System.out.println("成功连接"); socketChannel.configureBlocking(false); socketChannelList.add(socketChannel); System.out.println("socketList size:"+socketChannelList.size()); } } } }
我们可以看出,通过socketChannel将起阻塞的属性设置为false,这样就会在读取数据时,如果没有读取到就直接返回,而不是在傻等(当用户进程发起read操作时,如果kernel中的数据还没有准备好,那么它不会block用户进程,而是立刻返回一个error)。而服务端通过维护一个socketChannel的集合,每隔一段时间就遍历该集合中的socketChannel,并发起读数据的请求,这样就如同定义所描述的那样,NIO特点是用户进程需要不断的主动询问内核数据准备好了没有。
当然,BIO还是存在以下的问题:
需要循环遍历所有的SocketChannel,如果只有一小部分SocketChannel有数据,就不划算
需要用户态调用read方法与内核态进行交互,还是存在用户态到内核态的切换
(三)IO多路复用
为了解决NIO总是需要遍历Socket,并需要用户发起read请求的弊端,IO多路复用应运而生,它的存在就是由操作系统自己去循环遍历,并通知服务只去读取数据准备好了的连接。
IO多路复用:就是我们所说的select、poll、epoll。有些地方也称这种IO方式为event driven IO(事件驱动IO)就是通过一种机制,一个进程可以监控多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程,每次new一个线程),这样可以大大节省系统资源。所以IO多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符而这些文件描述符(套接字描述符)其中的任意一个进入读或者写继续状态时,select()函数就可以返回。
多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的while循环里多次系统调用变成了一次系统调用+内核层遍历这些文件描述符。
select函数的流程:
1 select 是一个阻塞函数,当没有数据时,会一直阻塞在select那一行
2 当有数据时会将rset中对应的那一个位置置为1
3 select函数返回,不再阻塞
4 遍历文件描述的数组,判断哪个fd被置位了
5 读取数据然后处理。
select函数的缺点:
bitmap最大为1024位,一个进程最多只能处理1024个客户端
&rset不可重用,每次socket有数据就相应的位被置位
文件描述符数组拷贝到了内核态(只不过无系统调用切换上下文的开销。(内核层可优化位异步事件通知)),仍然有开销。select调用需要传入fd数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是十分惊人的。(可优化为不复制)。
select并没有通知用户态socket哪一个socket有数据,仍然需要O(n)的遍历。select仅仅返回可读文件描述符的个数,具体哪个可读还需要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
epoll函数是非阻塞的
epoll函数的执行流程
1 当有数据的时候,会把相应的文件描述符置位,但是epoll没有revent标志位,所以并不是真正的置位,这时候会把数据的文件描述符放到队首。
2 epoll会返回数据的文件描述符个数
3 根据返回的个数,读取前N个文件描述符即可
4 读取、处理
IO多路复用模型对比 | |||
对比项 | select | poll | epoll |
文件描述符fd的数据存储结构 | 数组 | 链表 | B+树 |
最大连接数 | 1024 | 无上限 | 无上限 |
FD拷贝 | 每次调用select拷贝 | 每次调用poll拷贝 | FD每次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝 |
工作效率 | 轮询:O(n) | 轮询O(n) | 回调O(1) |
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)