一 多路复用
并发多客户端连接, 在多路复用之前最简单和典型的方案:同步阻塞网络IO模型
这种模型的特点就是用一个进程来处理一个网络连接(一个用户请求),比如一段典型的示例代码如下:
//直接调用recv函数从一个socket上读取数据
int main(){
...
recv(sock,...)//从用户角度来看非常简单,一个recv一用,要接收的数据就到我们手里了。
}
这种方式的优点就是非常容易让人理解,写起来非常的自然,符合人的直线型思维。
缺点就是性能差,每个用户请求到来都得占用一个进程来处理,来一个请求就要分配一个进程跟进处理。
类似一个学生配一个老师,一位患者配一个医生,可能吗?进程是一个很笨重的东西,一台服务器上创建不了多少个进程。
进程在Linux上是一个不小的开销,先不说创建,光是上下文切换一次就得几个微妙。所以为了高效地对海量用户提供服务,必须要让一个进程能同时处理很多个tcp连接才行
。现在假设一个进程保持了10000条连接,那么如何发现哪条连接上有数据可读,哪条连接可写呢?
我们当然可以采用循环遍历的方式来发现IO时间,但这种方式太低级了。
我们希望有一种更高效的机制,在很多连接中的某条上有IO是事件发生的时候直接快速地把它找出来。
其实这个事情Linux操作系统已经替我们做好了,它就是我们所熟知的IO多路复用机制,这里的复用指的就是对进程的复用
。
二 I/O多路复用模型
2.1 介绍
- I/O:网络I/O
- 多路:多个客户端连接(连接就是套接字描述符,即socket或者channel),指的是多条TCP连接
- 复用:用一个进程来处理多条的连接,使用单进程就能够实现同时处理多个客户端的连接。
- 总结:实现了用一个进程处理大量的用户连接,IO多路复用类似一个规范和接口落地实现。可以分select->poll->epoll三个阶段来描述。
2.2 Redis单线程如何处理那么多客户端连接,为什么单线程,为什么快?
Redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中
,依次放到文件事件分派器
,事件分派器将事件分发给事件处理器
。
三 Unix网络编程中的术语
3.1 同步
调用者要一直等待调用结果的通知后才能进行后续的执行,现在就要,我可以等,等出结果为止。
3.2 异步
指被调用方先返回应答让调用者先回去。然后再计算调用结果,计算完最终结果后再通知并返回给调用方。异步调用要想获得结果一般通过回调。
3.3 同步与异步的理解
同步、异步的讨论对象是被调用者(服务提供者),重点在于获得调用结果的消息通知方式上。
3.4 阻塞
调用方一直在等待而且别的事情什么都不做,当前进/线程会被挂起,啥都不干。
3.5 非阻塞
调用在发出去后,调用方先去忙别的事情,不会阻塞当前进/线程,而会立即返回。
3.6 阻塞与非阻塞的理解
阻塞、非阻塞的讨论对象是调用者(服务请求者),重点在于等消息时候的行为,调用者是否能干其它事。
3.7 总结
- 同步阻塞
- 同步非阻塞
- 异步阻塞
- 异步非阻塞
四 Unix网络编程中的五种IO模型
4.1 Blocking IO 阻塞IO
recvfrom():用于从(已连接)套接口上接收数据,并捕获数据发送源的地址。
4.1.1 ServiceBIO
/**
* @author seapp
* @date 2023/6/7 9:11
*/
public class RedisServerBIO {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(6399 );
while (true){
System.out.println("----111 等待连接");
Socket socket = serverSocket.accept();
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){
System.out.println("---- 444 读取成功" + new String(bytes,0,length));
System.out.println("======================" + "\t" + IdUtil.simpleUUID());
System.out.println();
}
//资源关闭
inputStream.close();
socket.close();
}
}
}
4.1.1 client
/**
* @author seapp
* @date 2023/6/7 9:14
*/
public class RedisClient1 {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1", 6399);
OutputStream outputStream = socket.getOutputStream();
while (true){
Scanner scanner = new Scanner(System.in);
String string = scanner.next();
if(string.equalsIgnoreCase("quit")){
break;
}
outputStream.write(string.getBytes());
System.out.println("---------RedisClient01 input quit keyword to finish----------");
}
outputStream.close();;
socket.close();
}
}
4.1.3 上述模型存在的问题
上面的模型存在很大的问题,如果客户端与服务端建立了连接。如果这个连接的客户端迟迟不发数据,线程就会一直阻塞在read()方法上
。这样其他客户端也不能进行连接,也就是一次只能处理一个客户端,对客户很不友好。
解决方案:
利用多线程,只要连接了一个socket,操作系统分配一个线程来处理,这样read()方法堵塞在每个具体线程上面不堵塞主线程
,就能操作多个socket了。哪个线程中的socket有数据,就读哪个socket。
程序服务端只负责监听是否有客户端连接,使用accept()阻塞。
任何一个线程上的socket有数据发送过来,read()就能立马读到,cpu就能进行处理。
4.1.4 多线程修改
/**
* @author seapp
* @date 2023/6/7 9:11
*/
public class RedisServerBIO {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(6399);
while (true) {
System.out.println("----111 等待连接");
Socket socket = serverSocket.accept();
System.out.println("-----222 连接成功");
new Thread(() -> {
try {
//获取输入流
InputStream inputStream = socket.getInputStream();
int length = -1;
byte[] bytes = new byte[1024];
System.out.println("-----333 等待读取" + "\t" + IdUtil.simpleUUID());
while ((length = inputStream.read(bytes)) != -1) {
System.out.println("---- 444 读取成功" + new String(bytes, 0, length));
System.out.println();
}
//资源关闭
inputStream.close();
socket.close();
} catch (Exception e) {
e.printStackTrace();
}
}, Thread.currentThread().getName()).start();
}
}
}
4.1.5 多线程模型存在的问题
多线程模型,每来一个客户端就要开辟一个线程,如果来1万个客户端,那就要开辟1万个线程。在操作系统中用户态不能直接开辟线程,需要调用内核来创建一个线程。这其中还涉及到用户状态的切换(上下文的切换),十分耗资源。
解决方案:
- 使用线程池:这个在客户端连接少的情况下可以使用,但是用户量大的情况下,你不知道线程池要多大,太大了内存可能不够,也不可行。
- NIO(非阻塞式IO)方式:因为read()方法阻塞了,所以要开辟多个线程,如果什么方法能够使read()方法不堵塞,这应就不用开辟多个线程了,这就用到了另一个IO模型,NIO(非阻塞式IO)。
4.1.6 小总结
在阻塞式IO模型中,应用程序在从调用recvfrom开始到它返回有数据包准备好这段时间是阻塞的,recvfrom返回成功后,应用进程才能开始处理数据包。
每一个线程分配一个连接,必然会产生多个。既然是多个socket连接必然需要放进容器,进行统一管理。
4.2 NoneBlocking IO 非阻塞IO
4.2.1 NIO
在NIO模式中,一切都是非阻塞的:
accept()方法是非阻塞的,如果没有客户端连接,就返回无连接标识。
read()方法是非阻塞的,如果read()方法读取不到数据就返回空闲标识,如果读取到数据时只阻塞read()方法读数据的时间。
在NIO模型中,只有一个线程:
当一个客户端与服务端进行连接,这个socket就会加入到一个数组中,隔一段时间遍历一次,看这个socket的read()方法能否读到数据,这样一个线程就能处理多个客户端的连接和读取了
。
4.2.2 ServiceNIO实现
/**
* @author seapp
* @date 2023/6/7 14:19
*/
public class RedisServiceNIO {
static ArrayList<SocketChannel> socketList = new ArrayList<>();
static ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
public static void main(String[] args) throws IOException {
System.out.println("-------------RedisServiceNIO 启动等待中.....");
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress("127.0.0.1", 6399));
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();
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
SocketChannel channel = serverSocket.accept();
if (channel != null) {
System.out.println("-------成功连接----------");
channel.configureBlocking(false);//设置为非阻塞
socketList.add(channel);
System.out.println("----------socketList size: " + socketList.size());
}
}
}
}
4.2.3 NIO的优缺点
缺点:
NIO成功的解决了BIO需要开启多线程的问题,NIO中一个线程就能解决多个socket,但是还存在2个问题。
- 这个模型在客户端少的时候十分好用,但是客户端如果很多。比如有1万个客户端进行连接,那么每次循环就要遍历1万个socket,如果一万个socket中只有10个socket有数据,也会遍历一万个socket,就会做
很多无用功,每次遍历遇到read 返回 -1时,仍然是一次浪费资源的系统调用。
- 而且这个遍历过程是在用户态进行的,用户态判断socket是否有数据还是调用内核的read()方法实现的,这就涉及到用户态和内核态的切换,每遍历一个就要切换一次,开销很大因为这些问题的存在。
优点:
- 不会阻塞在内核的等待数据过程,每次发起的I/O请求可以立即返回,不用阻塞等待,实时性较好。
但是轮询将会不断的询问内核,这将占用大量的CPU时间,系统资源利用率较低,所以一般web服务器不使用这种I/O模型。
小总结:
让Linux内核搞定上述需求,我们将一批文件描述符通过一次系统调用传给内核由内核层去遍历,才能真正解决这个问题。IO多路复用应运而生,也即将上述工作直接放进Linux内核,不再两态转换而是直接从内核获得结果,因为内核是非阻塞的。
4.3 IO multiplexing IO多路复用
4.3.1 定义
I/O multiplexing 这里面的multiplexing指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个I/O流,目的是尽量多的提高服务器的吞吐能力。
FileDescriptor: 文件描述符(File descriptor -> fd)是计算机科学中的一个术语,是一个用于表述指向文件的应用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
IO多路复用
IO multiplexing就是我们说的select,poll,epoll,有些技术书籍也称这种IO方式为event driven IO事件驱动IO。就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。可以基于一个阻塞对象并同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程,每次new一个线程),这样可以大大节省系统资源。所以,IO多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符(套接字描述符)而这些文件描述符其中的任意一个进入就绪状态,select、poll、epoll等函数就可以返回。
将用户socket对应的fd注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用非阻塞模式。这样,整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进行或者线程就被充分利用起来,这就是事件驱动,所谓的reactor反应模式。
4.3.2 作用
Redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到事件分派器,事件分派器将事件分发给事件处理器。
4.3.2 Reactor
基于IO复用模型,多个连接共用一个阻塞对象
,应用程序只需要在上一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
Reactor模型,是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor模式也叫Dispatch模式。即IO多了复用统一监听事件,收到事件后分发(Dispatch给某进程),是编写高性能网络服务器的必备技术。
Reactor模式中有2个关键组成:
- Reactor在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对IO事件做出反应。
- Handlers处理程序执行IO事件要完成的实际事件。Reactor通过调度适当的处理程序来响应IO事件,处理程序执行非阻塞操作。
4.3.3 IO多路复用的select方法
缺点:
4.3.4 IO多路复用的poll方法
优点:
- poll使用pollfd数组来代替select中的bitmap,数组没有1024的限制,可以一次管理更多的client。它和select的主要区别就是,去掉了select只能监听1024个文件描述符的限制。
- 当pollfds数组中有事件发生,相应的revents置位为1,遍历的时候又置位回零,实现了pollfd数组的重用。
缺点:
本质上还是select的方法。
- pollfds数组拷贝到了内核态,仍然有开销。
- poll并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历。
4.3.5 IO多路复用的epoll方法
## 4.4 signal driven IO 信号驱动IO
## 4.5 asynchronous IO 异步IO