准备
首先了解一下 Socket
网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个socket。
建立网络通信连接至少要一对端口号(socket)。socket本质是编程接口(API),对TCP/IP的封装,TCP/IP也要提供可供程序员做网络开发所用的接口,这就是Socket编程接口;HTTP是轿车,提供了封装或者显示数据的具体形式;Socket是发动机,提供了网络通信的能力。
Socket的英文原义是“孔”或“插座”。作为BSD UNIX的进程通信机制,取后一种意思。通常也称作套接字,用于描述IP地址和端口,是一个通信链的句柄,可以用来实现不同虚拟机或不同计算机之间的通信。在Internet上的主机一般运行了多个服务软件,同时提供几种服务。每种服务都打开一个Socket,并绑定到一个端口上,不同的端口对应于不同的服务
然后回顾一下HTTP服务流程
- 服务端创建ServerSocket,监听一个端口
- 客户端请求服务端
- 服务端获取一个请求的Socket对象
- 开启新线程
- 读取socket 字节流
- 解码协议(HTTP协议),得到http请求对象
- 处理业务,将结果封装成一个HttpResponse对象
- 编码协议(HTTP协议),将结果序列化字节流写进socket
- 将字节流返回给客户端
- 结束
示例代码
//Server 端首先创建了一个serverSocket来监听 8000 端口,然后创建一个线程,线程里面不断调用阻塞方法 serversocket.accept();获取新的连接,见(1),当获取到新的连接之后,给每条连接创建一个新的线程,这个线程负责从该连接中读取数据,见(2),然后读取数据是以字节流的方式,见(3)。
public class IOServer {
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(8000);
// (1) 接收新连接线程
new Thread(() -> {
while (true) {
try {
// (1) 阻塞方法获取新的连接
Socket socket = serverSocket.accept();
// (2) 每一个新的连接都创建一个线程,负责读取数据
new Thread(() -> {
try {
int len;
byte[] data = new byte[1024];
InputStream inputStream = socket.getInputStream();
// (3) 按字节流方式读取数据
while ((len = inputStream.read(data)) != -1) {
System.out.println(new String(data, 0, len));
}
} catch (IOException e) {
}
}).start();
} catch (IOException e) {
}
}
}).start();
}
}
//连接上服务端 8000 端口之后,每隔 2 秒,我们向服务端写一个带有时间戳的 "hello world"
public class IOClient {
public static void main(String[] args) {
new Thread(() -> {
try {
Socket socket = new Socket("127.0.0.1", 8000);
while (true) {
try {
socket.getOutputStream().write((new Date() + ": hello world").getBytes());
Thread.sleep(2000);
} catch (Exception e) {
}
}
} catch (IOException e) {
}
}).start();
}
}
IO NIO AIO BIO
同步&异步,阻塞&非阻塞
- 同步与异步(synchronous/asynchronous):同步是一种可靠的有序运行机制,当我们进行同步操作时,后续的任务是等待当前调用返回,才会进行下一步;而异步则相反,其他任务不需要等待当前调用返回,通常依靠事件、回调等机制来实现任务间次序关系
- 阻塞与非阻塞:在进行阻塞操作时,当前线程会处于阻塞状态,无法从事其他任务,只有当条件就绪才能继续,比如ServerSocket新连接建立完毕,或者数据读取、写入操作完成;而非阻塞则是不管IO操作是否结束,直接返回,相应操作在后台继续处理
java.io包的好处是代码比较简单、直观,缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈。很多时候,人们也把 java.net下面提供的部分网络 API,比如 Socket、ServerSocket、HttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。
在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。
在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作
IO(同步、阻塞)
IO流即input和output流,是同步 阻塞
NIO(同步、非阻塞)
NIO之所以是同步,是因为它的accept/read/write方法的内核I/O操作都会阻塞当前线程
NIO三个组成部分,Channel(通道)、Buffer(缓冲区)、Selector(选择器)
-
Channel:Channel是一个对象,可以通过它读取和写入数据。可以把它看做是IO中的流,不同的是:
Channel是双向的,既可以读又可以写,而流是单向的 Channel可以进行异步的读写 对Channel的读写必须通过buffer对象
所有数据都通过Buffer对象处理,所以永远不会将字节直接写入到Channel中
在Java NIO中的Channel主要有如下几种类型:FileChannel:从文件读取数据的 DatagramChannel:读写UDP网络协议数据 SocketChannel:读写TCP网络协议数据 ServerSocketChannel:可以监听TCP连接
-
Buffer:Buffer是一个对象,它包含一些要写入或者读到Stream对象的。应用程序不能直接对 Channel 进行读写操作,而必须通过 Buffer 来进行,即 Channel 是通过 Buffer 来读写数据的
使用 Buffer 读写数据一般遵循以下四个步骤:1.写入数据到 Buffer; 2.调用 flip() 方法; 3.从 Buffer 中读取数据; 4.调用 clear() 方法或者 compact() 方法。
//CopyFile执行三个基本的操作:创建一个Buffer,然后从源文件读取数据到缓冲区,然后再将缓冲区写入目标文件
public static void copyFileUseNIO(String src,String dst) throws IOException{
//声明源文件和目标文件
FileInputStream fi=new FileInputStream(new File(src));
FileOutputStream fo=new FileOutputStream(new File(dst));
//获得传输通道channel
FileChannel inChannel=fi.getChannel();
FileChannel outChannel=fo.getChannel();
//获得容器buffer
ByteBuffer buffer=ByteBuffer.allocate(1024);
while(true){
//判断是否读完文件
int eof =inChannel.read(buffer);
if(eof==-1){
break;
}
//重设一下buffer的position=0,limit=position
buffer.flip();
//开始写
outChannel.write(buffer);
//写完要重置buffer,重设position=0,limit=capacity
buffer.clear();
}
inChannel.close();
outChannel.close();
fi.close();
fo.close();
}
- Selector:Selector是一个对象,它可以注册到很多个Channel上,监听各个Channel上发生的事件,并且能够根据事件情况决定Channel读写。这样,通过一个线程管理多个Channel,就可以处理大量网络连接了。
一条连接来了之后,现在不创建一个 while 死循环去监听是否有数据可读了,而是直接把这条连接注册到 selector 上,然后,通过检查这个 selector,就可以批量监测出有数据可读的连接,进而读取数据
线程之间的切换对操作系统来说代价是很高的,并且每个线程也会占用一定的系统资源。所以,对系统来说使用的线程越少越好。
NIO多路复用
主要步骤和元素:
- 首先,通过 Selector.open() 创建一个 Selector,作为类似调度员的角色。
- 然后,创建一个 ServerSocketChannel,并且向 Selector 注册,通过指定 SelectionKey.OP_ACCEPT,告诉调度员,它关注的是新的连接请求。
- 注意,为什么我们要明确配置非阻塞模式呢?这是因为阻塞模式下,注册操作是不允许的,会抛出 IllegalBlockingModeException 异常。
- Selector 阻塞在 select 操作,当有 Channel 发生接入请求,就会被唤醒。
- 在 具体的 方法中,通过 SocketChannel 和 Buffer 进行数据操作
IO 都是同步阻塞模式,所以需要多线程以实现多任务处理。而 NIO 则是利用了单线程轮询事件的机制,通过高效地定位就绪的 Channel,来决定做什么,仅仅 select 阶段是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题,应用的扩展能力有了非常大的提高
NIO2(异步、非阻塞)
AIO是异步IO的缩写
对于NIO来说,我们的业务线程是在IO操作准备好时,得到通知,接着就由这个线程自行进行IO操作,IO操作本身是同步的
但是对AIO来说,则更加进了一步,它不是在IO准备好时再通知线程,而是在IO操作已经完成后,再给线程发出通知。因此AIO是不会阻塞的,此时我们的业务逻辑将变成一个回调函数,等待IO操作完成后,由系统自动触发
与NIO不同,当进行读写操作时,只须直接调用API的read或write方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。 即可以理解为,read/write方法都是异步的,完成后会主动调用回调函数。 在JDK1.7中,这部分内容被称作NIO.2,主要在Java.nio.channels包下增加了下面四个异步通道:
- AsynchronousSocketChannel
- AsynchronousServerSocketChannel
- AsynchronousFileChannel
- AsynchronousDatagramChannel
在AIO socket编程中,服务端通道是AsynchronousServerSocketChannel,这个类提供了一个open()静态工厂,一个bind()方法用于绑定服务端IP地址(还有端口号),另外还提供了accept()用于接收用户连接请求。在客户端使用的通道是AsynchronousSocketChannel,这个通道处理提供open静态工厂方法外,还提供了read和write方法。
在AIO编程中,发出一个事件(accept read write等)之后要指定事件处理类(回调函数),AIO中的事件处理类是CompletionHandler,这个接口定义了如下两个方法,分别在异步操作成功和失败时被回调。
void completed(V result, A attachment);
void failed(Throwable exc, A attachment);
单工 半双工 双工通信
单工通信(simplex)
只支持信号在一个方向上传输
适用:数据收集系统,如气象数据收集 话费收集的集中计算等
半双工通信(half-duplex)
允许信号在两个方向上传输,但某一时刻只允许在一个信道上单向传输
适用:问讯 检索 科学计算等数据通信系统 如对讲机
全双工通信(dull-duplex)
允许数据同时在两个方向上传输,既有两个信道
适用:如计算机 手机 电话通信
协议&消息
通信中,协议是指双方实体完成通信或服务所必须遵循的规则和约定,是双方对传输/接收数据流的编解码的实现算法。
数据在网络上是以字节流(二进制流)的形式传输的,而字节的定义在所有计算机上都是8bit。所以面向协议编程是与语言无关的。
三要素:
- 语法:约定通信的数据格式,编码,信号等级
- 语义:在语法的基础上传递的数据内容
- 定时规则:明确通信内容的时序
消息:协议实例化后便是消息。
消息分类:
- 一类消息:服务端与客户端之间通信的所有消息长度都是一定范围的。
- 二类消息:绝大部分消息长度都未超过某阈值,但偶尔有几个消息长度超过,但不可以超过太多。
- 三类消息:消息太长而无法完整的进行内存存储
一般消息有两部分组成
- 消息头:固定一个byte长度,记录消息体长度
- 消息体:根据消息头中的数值决定长度,记录消息内容
消息长度=消息头长度+消息体长度
一个简单的协议算法:
- 标志当前buffer的position位置
- 获取本次消息的消息体长度,position递增1位
- 判断当前读取的消息长度是否满足消息体长度
- 出现半包,数据不完整,重置标志位,并返回null终止本次解码
- buffer中包含完整的消息体内容,则进行读取,position=position+增加消息体长度
- 更新标志位
- 将已读数据转换为字符串并返回,解码成功
public class StringProtocol implements Protocol {
public String decode(ByteBuffer buffer, AioSession session) {
buffer.mark(); // 1
byte length = buffer.get(); // 2
if (buffer.remaining() < length) { // 3
buffer.reset(); // 4
return null;
}
byte[] body = new byte[length];
buffer.get(body); // 5
buffer.mark(); // 6
return new String(body); // 7
}
}
绝大多数协议或协议中的某字段约定的解析规则有两种:定长解析,特殊结束符解析
Reactor
Reactor模式也叫反应器模式,大多数IO相关组件如Netty、Redis在使用的IO模式,是高性能网络编程的必知必会模式
简介
- 最原始的网络编程思路是,服务器用一个while循环,不断监听端口是否有新的套接字连接,如果有,那么就调用一个处理函数处理。伪代码:
while(true){
socket = accept();
handle(socket)
}
这种同步阻塞方式无法并发,效率太低
- 之后,想到了使用多线程,也就是很经典的connection per thread,每一个连接用一个线程处理。tomcat服务器的早期版本确实是这样实现的。
- 这种同步非阻塞方式极大地提高了服务器的吞吐量,但资源要求太高,系统中创建线程是需要比较高的系统资源的,如果连接数太高,系统无法承受,而且,线程的反复创建-销毁也需要代价
- 改进,便是Reactor模式,采用基于事件驱动的设计,当有事件触发时,才会调用处理器进行数据处理,对线程的数量进行控制,一个线程处理大量的事件
实际上的Reactor模式,是基于Java NIO的,在他的基础上,抽象出来两个组件——Reactor和Handler两个组件:(1)Reactor:负责响应IO事件,当检测到一个新的事件,将其发送给相应的Handler去处理;新的事件包含连接建立就绪、读就绪、写就绪等。
(2)Handler:将自身(handler)与事件绑定,负责事件的处理,完成channel的读入,完成处理业务逻辑后,负责将结果写出channel
- 网络连接:两个流,输入流,输出流
- channel: 一个连接就是一个channel,所有操作起始于channel
- selector:IO时间查询器,是一个独立线程,将channel注册到selector中后,selector就会不断select这些channel的IO事件(可读,可写,连接完成等)
-
buffer:channel读写操作的内存,利用byte[]作为缓存区,一些属性:
属性 描述 capacity 容量,即可以容纳的最大数据量;在缓冲区创建时被设定并且不能改变 limit 上界,缓冲区中当前数据量 position 位置,下一个要被读或写的元素的索引 mark(位置标记) 调用mark(pos)来设置mark=pos,再调用reset()可以让position恢复到标记的位置即position=mark
Reactor单线程模型
缺点:当其中某个 handler 阻塞时, 会导致其他所有的 client 的 handler 都得不到执行, 并且更严重的是, handler 的阻塞也会导致整个服务不能接收新的 client 请求(因为 acceptor 也被阻塞了)。 因为有这么多的缺陷, 因此单线程Reactor 模型用的比较少。这种单线程模型不能充分利用多核资源,所以实际使用的不多,
仅仅适用于handler 中业务处理组件能快速完成的场景。
Reactor多线程模型
将Handler处理器的执行放入线程池,多线程进行业务处理
Reactor主从模型
对于多个CPU的机器,为充分利用系统资源,将Reactor拆分为两部分
小结
Netty可以基于如上三种模型进行灵活的配置
Reactor编程的优点
1)响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的; 2)编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销; 3)可扩展性,可以方便的通过增加Reactor实例个数来充分利用CPU资源; 4)可复用性,reactor框架本身与具体事件处理逻辑无关,具有很高的复用性;
和缺点
1)相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,并且不易于调试。 2)Reactor模式需要底层的Synchronous Event Demultiplexer支持,比如Java中的Selector支持,操作系统的select系统调用支持,如果要自己实现Synchronous Event Demultiplexer可能不会有那么高效。 3) Reactor模式在IO读写数据时还是在同一个线程中实现的,即使使用多个Reactor机制的情况下,那些共享一个Reactor的Channel如果出现一个长时间的数据读写,会影响这个Reactor中其他Channel的相应时间,比如在大文件传输时,IO操作就会影响其他Client的相应时间,因而对这种操作,使用传统的Thread-Per-Connection或许是一个更好的选择,或则此时使用改进版的Reactor模式如Proactor模式。
拆包、粘包、半包
Netty位于应用层,而网络数据的处理是操作系统底层按照字节流来读写的,然后传到应用层重新拼装bytebuf,这就有可能造成读写不对等。
在Netty中,已经造好了许多类型的拆包器,可以直接使用:
零拷贝
传统意义上发送数据:
- 数据从磁盘读取到内核的read buffer
- 数据从内核缓冲区拷贝到用户缓冲区
- 数据从用户缓冲区拷贝到内核的socket buffer
- 数据从内核的socket buffer拷贝到网卡接口(硬件)的缓冲区
通过java的FileChannel.transferTo方法,可以避免上面两次多余的拷贝(当然这需要底层操作系统支持)
- 调用transferTo,数据从文件由DMA引擎拷贝到内核read buffer
- 接着DMA从内核read buffer将数据拷贝到网卡接口buffer
Netty中的零拷贝
- bytebuffer
Netty发送和接收消息主要使用bytebuffer,bytebuffer使用对外内存(DirectMemory)直接进行Socket读写。
原因:如果使用传统的堆内存进行Socket读写,JVM会将堆内存buffer拷贝一份到直接内存中然后再写入socket,多了一次缓冲区的内存拷贝。DirectMemory中可以直接通过DMA发送到网卡接口
- Composite Buffers
传统的ByteBuffer,如果需要将两个ByteBuffer中的数据组合到一起,我们需要首先创建一个size=size1+size2大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用Netty提供的组合ByteBuf,就可以避免这样的操作,因为CompositeByteBuf并没有真正将多个Buffer组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝。
- 对于FileChannel.transferTo的使用
Netty中使用了FileChannel的transferTo方法,该方法依赖于操作系统实现零拷贝
借鉴、引用&感谢:
彻底理解Netty,这一篇文章就够了
smart-socket
Reactor模式