本文主要是《Netty权威指南(第2版)》的学习总结。在学习的同时也顺便记录下来自己的心得,让自己更加深刻的理解这些知识
1.Linux网络I/O模型简介
1.1 I/O的概念
I/O系统,英文全称为“Input output system”,中文全称为“输入输出系统”,由输入输出控制系统和外围设备两部分组成,是计算机系统的重要组成部分。
在计算机系统中,通常把处理器和主存储器之外的部分称为输入输出系统。
1.2 内核空间和用户空间
操作系统都包括内核空间和用户空间(或者说内核态和用户态),
内核空间主要存放的是内核代码和数据,是供系统进程使用的空间 (进程共享)。
而用户空间主要存放的是用户代码和数据,是供用户进程使用的空间 (进程独立)。
目前Linux系统简化了分段机制,使得虚拟地址与线性地址总是保持一致,因此,Linux系统的虚拟地址也是0-4G。Linux系统将这4G空间分为了两个部分:将最高的1G空间(从虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,即为“内核空间”,而将较低的3G空间(从虚拟地址 0x00000000到0xBFFFFFFF)供用户进程使用,即为“用户空间”。
同时由于每个用户进程都可以通过系统调用进入到内核空间,因此Linux的内核空间可以认为是被所有用户进程所共享的,因此对于一个具体用户进程来说,它可以访问的虚拟内存地址就是0-4G。另外Linux系统分为了四种特权级:0~3,主要是用来保护资源。0级特权最高,而3级则为最低,系统进程主要运行在0级,用户进程主要运行在3级。
整个linux内部结构可以分为三部分,从最底层到最上层依次是:硬件->内核空间->用户空间,如下图所示:
1.3 Linux网络I/O模型简介
1.3.1 Linux 的内核将所有外部设备都看做一个文件来操作(一切皆文件),对一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor(fd,文件描述符)。而对一个socket的 读写也会有响应的描述符,称为socket fd(socket文件描述符),描述符就是一个数字,指向内核中的一个结构体(文件路径,数据区等一些属性)。
1.3.2 UNIX 5中I/O模型
阻塞I/O:
最常用的I/O模型,默认情况下,所有文件操作都是阻塞的。
比如I/O模型下的套接字接口:在进程空间中调用recvfrom,其系统调用直到数据包到达且被复制到应用用户进程的缓冲区中或者发生错误时才返回,在此期间一直等待。 进程在调用recvfrom开始到它返回的整段时间内都是 被阻塞的,所以叫阻塞I/O模型。
非阻塞I/O:
recvfrom从应用层到内核的时候,就直接返回一个EWOULDBLOCK错误,一般都对非阻塞I/O模型进行轮询检查这个状态,看内核是不是有数据到来。
I/O复用模型:
Linux提供select/poll,进程通过将一个或多个fd传递给select或poll系统调用,阻塞在select操作上,这样,select/poll可以帮我们侦测多个fd是否处于就绪状态。select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限,因此它 的使用受到了一些制约。Linux还提供一个epoll系统调用,epoll使用基于事件驱动方式代替顺序扫描,因此性能更高。当有fd就绪时,立即回调函数rollback。
信号驱动I/O模型:
首先开启套接口信号驱动I/O功能,并通过系统调用sigaction执行一个信号处理函数(此系统调用立即返回,进程继续工作,非阻塞)。当数据准备就绪时,就为改进程生成一个SIGIO信号,通过信号回调通知应用程序调用recvfrom来读取数据,并通知主循环函数处理树立。
异步I/O:
告知内核启动某个操作,并让内核在整个操作完成后(包括数据的复制)通知进程。
信号驱动I/O模型通知的是何时可以开始一个I/O操作,异步I/O模型有内核通知I/O操作何时已经完成。
2.I/O发展史
2.1 在jdk1.4推出NIO之前,基于java所有的Socket通信都采用了同步阻塞模式(BIO),这种一请求一应答的通信模式简化了上层的应用开发,但是在性能和可靠性上却存在着巨大的瓶颈。
因此,在很长一段时间里,大型服务应用都是采用C或者C++开发的,因为他们可以直接使用操作系统提供的异步I/O或者AIO的能力。
2.2 BIO的服务端通信模型:
BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。
这就是典型的一请求一应答通信模型。
该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈1:1 的正比关系,由于线程是Java虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能将急剧下降,随着 并发访问量的继续增大,系统会发生线程堆栈溢出、创建新线程失败等问题,并最终导致进程宕机或者僵死,不能对外提供服务。
以经典的时间服务器(TimeServer)为例,通过代码分析来回顾和熟悉下BIO编程。
首先 创建一个服务端ServerSocket负责绑定IP地址,启动监听端口8080
package net.xdclass.bio;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @program: netty
* @author: luohaojie
* @create: 2019-04-03 14:06
**/
public class BioServer {
private static final int port = 8080;
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
//首先服务端监听8080端口
serverSocket = new ServerSocket(port);
System.out.println("server start in prot=" + port);
Socket socket = null;
//通过死循环监听客户端链接
while (true) {
//如果没有连接,会阻塞到accept上
socket = serverSocket.accept();
//开启线程
System.out.println("=====================");
//如果有连接,则开启一个新的线程来处理该连接
new Thread(new TimeServerHandler(socket)).start();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (serverSocket != null) {
try {
serverSocket.close();
System.out.println("the time server is close");
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
然后创建一个服务处理类,来读写客户端传过来的数据
package net.xdclass.bio;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Date;
/**
* @program: netty
* @author: luohaojie
* @create: 2019-04-03 17:12
**/
public class TimeServerHandler implements Runnable {
private Socket socket;
public TimeServerHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
BufferedReader in = null;
PrintWriter out = null;
try {
in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()), true);
String msg = null;
//读取客户端传来的数据
if ((msg = in.readLine()) != null && msg.length() != 0) {
System.out.println("the time server receive msg " + msg);
//并将数据写出
out.println("return=" + new Date().toString());
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (out != null) {
out.close();
}
}
if (this.socket != null) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
再创建一个客户端,来连接服务端
package net.xdclass.bio;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
/**
* @program: netty
* @author: luohaojie
* @create: 2019-04-03 17:53
**/
public class BioClient {
private static final int PORT = 8080;
private static final String HOST = "127.0.0.1";
public static void main(String[] args) {
Socket socket = null;
BufferedReader in = null;
PrintWriter out = null;
try {
socket = new Socket(HOST, PORT);
in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()), true);
out.println("im client ");
System.out.println("recive time is " + in.readLine());
} catch (Exception e) {
e.printStackTrace();
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (out != null) {
try {
out.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
1.启动服务端
服务端打印
server start in prot=8080
2.启动客户端
客户端打印
recive time is return=Wed Jun 19 17:06:58 CST 2019
服务端打印
server start in prot=8080
the time server receive msg im client
通过代码我们发现,BIO最主要的问题是,每接收客户端一次连接,就会创建一个新的线程,这种模式在高并发场景下几乎是灾难性的。为了改进这种模式,后面提出了用线程池来创建线程,由于它的底层通信机制依然使用同步阻塞I/O,所以被称为“伪异步”IO
2.3伪异步I/O
采用线程池和任务队列可以实现一种叫做伪异步的I/O通信框架,它的模型图如下所示。
当有新的客户端接入的时候,将客户端的Socket封装成一个Task(该任务实现java.lang.Runnable接口)投递到后端的线程池中进行处理,JDK的线程池维护一个消息队列和N个活跃线程对消息队列中的任务进行处理。由于线程池可以设置消息队列的大小和最大线程数。因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机
首先创建一个线程池
package net.xdclass.bio;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* @author luohaojie
* @snice 2019-06-19 17:28
**/
public class TimeServerHandlerExecutePool {
private ExecutorService executor;
public TimeServerHandlerExecutePool(int maxPoolSize, int queueSize) {
executor = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(),
maxPoolSize, 120L, TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(queueSize));
}
public void execute(Runnable task) {
executor.execute(task);
}
}
然后服务端的连接执行修改
package net.xdclass.bio;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @program: netty
* @author: luohaojie
* @create: 2019-04-03 14:06
**/
public class BioServer {
private static final int port = 8080;
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
//首先服务端监听8080端口
serverSocket = new ServerSocket(port);
System.out.println("server start in prot=" + port);
Socket socket = null;
//创建一个线程池
TimeServerHandlerExecutePool singleExecutor = new
TimeServerHandlerExecutePool(50, 10000);
//通过死循环监听客户端链接
while (true) {
//如果没有连接,会阻塞到accept上
socket = serverSocket.accept();
//开启线程
System.out.println("=====================");
//如果有连接,则用线程池来执行任务
singleExecutor.execute(new TimeServerHandler(socket));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (serverSocket != null) {
try {
serverSocket.close();
System.out.println("the time server is close");
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
伪异步I/O通信框架采用了线程池实现,因此避免了为每个请求都创建一个独立线程造成的线程资源耗尽问题。但是由于它底层的通信依然采用同步阻塞模型,因此无法从根本上解决问题。
2.4 伪异步I/O通信弊端
要对伪异步I/O的弊端进行深入分析,首先我们看两个Java同步I/O的API说明。随后我们结合代码进行详细分析。
请注意加粗斜体字部分的API说明,当对Socket的输入流进行读取操作的时候,它会一直阻塞下去,直到发生如下三种事件。
有数据可读;
可用数据已经读取完毕;
发生空指针或者I/O异常。
这意味着当对方发送请求或者应答消息比较缓慢、或者网络传输较慢时,读取输入流一方的通信线程将被长时间阻塞,如果对方要60s才能够将数据发送完成,读取一方的I/O线程也将会被同步阻塞60s,在此期间,其他接入消息只能在消息队列中排队。
下面我们接着对输出流进行分析,还是看JDK I/O类库输出流的API文档,然后结合文档说明进行故障分析。
当调用OutputStream的write方法写输出流的时候,它将会被阻塞,直到要发送的字节全部写入完毕,或者发生异常。学习过TCP/IP相关知识的人都知道,当消息的接收方处理缓慢的时候,将不能及时地从TCP缓冲区读取数据,这将会导致发送方的TCP window size不断减小,直到为0,双方处于Keep-Alive状态,消息发送方将不能再向TCP缓冲区写入消息,这是如果采用的是同步阻塞I/O,write操作将会被无限期阻塞,直到TCP window size大于0或者发生I/O异常。
通过对输入和输出流的API文档进行分析,我们了解到读和写操作都是同步阻塞的,阻塞的时间取决于对方I/O线程的处理速度和网络I/O传输速度。本质上来讲,我们无法保证生产环境的网络状况和对端的应用程序能够足够快,如果我们的应用程序依赖对方的处理速度,它的可靠性就非常差。
伪异步I/O实际上仅仅只是对之前I/O线程模型的一个简单优化,它无法从根本上解决同步I/O导致的通信线程阻塞问题。下面我们就简单分析下如果通信对方返回应答时间过长,会引起的级联故障。
服务端处理缓慢,返回应答消息耗费60s,平时只需要10ms。
采用伪异步I/O的线程正在读取故障服务节点的响应,由于读取输入流是阻塞的,因此,它将会被同步阻塞60s。
假如所有的可用线程都被故障服务器阻塞,那后续所有的I/O消息都将在队里中排队。
由于线程池采用阻塞队里实现,当队列积满之后,后续入队的操作将被阻塞。
由于前端只有一个Accptor线程接收客户端接入,它被阻塞在线程池的同步阻塞队列之后,新的客户端请求消息将被拒绝, 客户端会发生大量的连接超时。
由于几乎所有的连接都超时,调用者会认为系统已经崩溃,无法接收新的请求消息。