文章目录
- 多路复用要解决的问题
- IO多路复用的定义
- Redis单线程如何处理那么多并发客户端连接,为什么单线程,为什么快
- 同步,异步,非阻塞,阻塞
- 五种IO模型,介绍前三个
- BIO
- NIO
- IO多路复用
- 重点:select,poll,epoll都是IO多路复用的具体实现
- select
- poll
- epoll(非阻塞的)
- 三个方法对比
多路复用要解决的问题
多路复用以前最简单的方案就是同步阻塞网络IO模型:用一个进程来处理一个网络连接,优点使容易理解,缺点使性能差,每个用户请求到来都得占用一个进程来处理,来一个请求就得分配一个进程跟进处理。
进程在linux上开销不小,光上下文切换就得几个微秒。为了高效地对海量用户提供服务,必须让一个进程能同时处理多个TCP连接才行。
IO多路复用的定义
IO:网络io
多路:多个客户端连接,指的是多条TCP连接
复用:用一个进程来处理多条的连接,使用单进程就能够实现同时处理多个客户端的连接
实现了用一个进程来处理大量的用户连接,IO多路复用类似一个规范和接口,可以分select->poll->epoll三个阶段来描述。
Redis单线程如何处理那么多并发客户端连接,为什么单线程,为什么快
Redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,一次放到文件事件分派器,事件分派器将事件分发给事件处理器。
网络事件处理器:
因为文件事件分派器队列的消费使单线程的,所以Redis才叫单线程模型。
同步,异步,非阻塞,阻塞
同步:,调用者要一直等待调用结果的通知后才能进行后续的执行,现在就要,我可以等,等出结果为止。
异步:调用者收到被调用者返回通知,自己先回去,然后再计算调用结果,计算完最终结果后再通知并返回给调用方。异步调用要想获得结果一般通过回调。
同步异步重点在于获得调用结果的消息通知方式。
阻塞:调用方一直等待什么也不做,当前线程被挂起,啥都不干。
非阻塞:调用再发出去后,调用方先去忙别的事情不会阻塞当前线程,而会立即返回。
阻塞和非阻塞重点在于等消息时候的行为,调用者是否能干其它事情。
五种IO模型,介绍前三个
BIO
RedisClient01
package com.atguigu.redis7.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
{
Socket socket = new Socket("127.0.0.1",6379);
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("------RedisClient01 input quit keyword to finish......");
}
outputStream.close();
socket.close();
}
}
RedisClient02
package com.atguigu.redis7.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
{
Socket socket = new Socket("127.0.0.1",6379);
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("------RedisClient02 input quit keyword to finish......");
}
outputStream.close();
socket.close();
}
}
RedisServerBIO
package com.atguigu.redis7.iomultiplex.bio.read;
import cn.hutool.core.util.IdUtil;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @auther zzyy
* @create 2021-06-01 10:35
*/
public class RedisServerBIO
{
public static void main(String[] args) throws IOException
{
ServerSocket serverSocket = new ServerSocket(6379);
while(true)
{
System.out.println("-----111 等待连接");
Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
System.out.println("-----222 成功连接");
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("===================="+"\t"+ IdUtil.simpleUUID());
System.out.println();
}
inputStream.close();
socket.close();
}
}
}
上面的模型存在很大问题,如果客户端与服务端建立连接,如果这个连接的客户端迟迟不发数据,程序就会一直堵塞在read方法上,这样其他客户端也不能进行连接。一次只能处理一个客户端,对客户很不友好,因此我们可以采用多线程的方式来解决这个问题。
多线程处理方式就是只要连接了一个socket,操作系统分配一个线程来处理,这样read方法堵塞在每个具体线程上而不堵塞在主线程上了,程序服务端只负责监听是否有客户端连接,使用accept方法阻塞,客户端1连接服务端就开辟一个线程执行read方法,程序服务端继续监听,以此类推。
任何一个线程上的socket有数据发送过来,read方法都能读到,cpu就能进行处理。
客户端代码和上面一样
RedisServerBIOMultiThread
package com.atguigu.redis7.iomultiplex.bio.read.mthread;
import cn.hutool.core.util.IdUtil;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class RedisServerBIOMultiThread
{
public static void main(String[] args) throws IOException
{
ServerSocket serverSocket = new ServerSocket(6379);
while(true)
{
System.out.println("-----RedisServerBIOMultiThread 111 等待连接");
Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
System.out.println("-----RedisServerBIOMultiThread 222 成功连接");
new Thread(() -> {
try {
InputStream inputStream = socket.getInputStream();
int length = -1;
byte[] bytes = new byte[1024];
System.out.println("-----333 等待读取"+ IdUtil.simpleUUID());
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();
new Thread().start();
}
}
}
多线程的方式存在哪些问题呢?
每来一个客户端就要开辟一个线程,如果来一万个客户端就要开辟一万个线程,操作系统用户态不能开辟线程,需要调用内核来创建一个线程,其中涉及上下文切换,很耗资源。
可以采用线程池的方式来解决,但仅限于客户端连接少的情况,太多客户端连接,内存可能不够用,也不可行。
需要注意我们是由于read方法堵塞了,所以要开辟多个线程,可不可以不让read方法堵塞,这时候就要引出非阻塞式IO也就是NIO了。
NIO
accept方法和read方法都是非阻塞的,如果没有客户端连接就返回无连接标识,如果没有数据被读取就返回空闲标识,如果读取到数据时只阻塞read方法读数据的时间。
NIO只有一个线程:
当一个客户端和服务端进行连接,这个socket就会加入到一个数组中,隔一段时间遍历一次(轮询),看这个socket的read方法是否读到数据,这样一个线程就能够处理多个客户端的连接和读取了。
客户端代码一样
服务端代码
package com.atguigu.redis7.iomultiplex.nio;
import cn.hutool.core.util.IdUtil;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
public class RedisServerNIO
{
static ArrayList<SocketChannel> 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);//设置为非阻塞模式
/**
* select 其实就是把NIO中用户态要遍历的fd数组(我们的每一个socket链接,安装进ArrayList里面的那个)拷贝到了内核态,
* 让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态的,所有拷贝到内核态后,
* 这样遍历判断的时候就不用一直用户态和内核态频繁切换了
*/
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());
}
}
}
}
ServerSocketChannel和SocketChannel是可以设置为阻塞的。
IO多路复用
多路指的是单个线程通过记录跟踪每个Socket的状态来同时管理多个IO流,目的是尽量多的提高服务器的吞吐能力。
多个Socket复用一根网线这个功能是在内核+驱动层实现的。
什么是文件描述符?
当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。(每个网络连接对应一个文件描述符)
Redis的IO多路复用:Redis利用epoll实现IO多路复用,将连接信息和事件放到事件队列中,依次放到事件分派器,事件分派器将事件分发给事件处理器。
I/O 多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(读就绪或者写就绪),能够通知程序进行相应的读写操作。多个连接共用一个阻塞对象,应用程序只需要一个阻塞对象上等待,无需阻塞等待所有连接,当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回进行业务处理。
Reactor模式是什么?
IO多了复用统一监听事件,收到事件后分发(Dispatch给某进程),是编写高性能网络服务器的必备技术。
Reactor模式 有两个关键组成:
- Reactor:在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对IO事件做出反应。(接线员)
- Handlers:处理程序执行IO事件要完成的实际事件。(办理人)
Redis基于Reactor模式开发了网络事件处理器,也叫做文件事件处理器。
组成结构: - 多个套接字
- IO多路复用程序
- 文件事件分派器
- 事件处理器(由于文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型)
重点:select,poll,epoll都是IO多路复用的具体实现
select
该方法就是把NIO中用户态要遍历的fd数组拷贝到内核态,让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态,所有拷贝到内核态后,这样遍历判断的时候就不用一直用户态和内核态频繁切换了。
从代码中看,select方法调用后,返回一个置位后的&rset,&rset是一个bitmap,用户态只需要进行很简单的二进制比较就能很快知道哪些socket需要read数据,有效提高了效率。
缺点:
bitmap最大1024位也就戴白哦。一个进程最多只能处理1024个客户端。
&rset不可重用,每次循环都必须置位为0.
虽然将rset从用户态拷贝到内核态,由内核态判断是否有数据,但是还是有拷贝的开销(select调用时需要传入fd数组,拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的)。
当有数据时select就会返回,但select函数并不知道哪个描述符有数据,后面还需要再次对文件描述符数组进行遍历,效率低。
总的来说:select方式既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销,多个文件描述符只有依次select的系统调用+n次就绪状态的文件描述符的read系统调用。
poll
poll方法使用pollfd数组来代替select中的bitmap,数组没有1024的限制,可以一次管理更多的client。它和select的主要区别就是去掉了select只能监听1024个文件描述符的限制。
当pollfds数组中有事件发生,相应的revents置位为1,遍历的时候又置位回0,实现pollfd数组的重用。
epoll(非阻塞的)
epoll_create 创建一个epoll句柄
在内核中开辟一块内存空间,用来存放epoll中的fd数据结构(epoll中fd的数据结构和poll中的差不多,只是没有了revents)
epoll_ctl 向内核添加,修改或删除要监控的文件描述符
把每个socket的fd数据结构放到刚创建的内存空间中
epoll_wait 类似发起了select方法调用
阻塞,只有当刚创建的内存空间中的fd有事件发生,才会把这些fd放入就绪链表中,返回就绪fd的个数。
遍历就绪链表,读取数据。
多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。
1、一个socket的生命周期中只有一次从用户态拷贝到内核态的过程,开销小
2、使用event事件通知机制,每次socket中有数据会主动通知内核,并加入到就绪链表中,不需要遍历所有的socket(epoll只轮询那些真正发出了事件的流)
三个方法对比
io多路复用:一次系统调用+内核层遍历这些文件描述符
linux使用redis最好的原因出来了: