传统 HTTP服务器
- 创建一个 ServerSocket ,监听并绑定一个端口
- 一系列客户端来请求这个端口
- 服务器使用 Accept ,获得一个来自客户端的socket连接对象
- 启动一个新线程处理连接
- 读socket,得到字节流
- 解码协议,得到http请求对象
- 处理http请求,得到一个结果,封装成一个httpResponse对象
- 编码协议,将结果序列化字节流 写socket , 将字节流发给客户端
NIO处理的HTTP服务器
NIO 代表的一个词汇叫做 IO多路复用。它是由操作系统提供的系统调用,早起这个操作系统调用的名字是 select ,但是性能低下,后来渐渐演化成了 liunx下的 epoll 和 mac下的kqueue.
说NIO 之前先说一下 BIO(Blocking IO),如何理解这个Blocking呢?
- 客户端监听时,Accept是阻塞的,只有新连接来了,Accept 才会返回,主线程才能继续
- 读写 sockett时,Read 是阻塞的,只有请求消息来了,Read 才能返回 ,子线程才能继续处理
- 读写socket 时 ,Write是阻塞的,只有客户端把消息收了,Write 才能返回,子线程才能继续读取下一个请求
while true {
events = takeEvents(fds) // 获取事件,如果没有事件,线程就休眠
for event in events {
if event.isAcceptable {
doAccept() // 新链接来了
} elif event.isReadable {
request = doRead() // 读消息
if request.isComplete() {
doProcess()
}
} elif event.isWriteable {
doWrite() // 写消息
}
}
}
Netty 执行流程
ServerBootstrap serverBootStrap = new ServerBootstrap();
// boss 用于接受新连接的线程 主要负责创建新连接
//worker 用于负责读取数据的线程 主要用于读取数据以及业务逻辑处理
/**
* 首先创建两个 NioEventLoopGroup 这两个线程可以看做是传统IO 编程模型的两大线程组
* boosGroup 表示监听端口 accpet 新连接的线程组
* workGroup 表示处理每一条连接的数据读写的线程组
* *** 用生活中的例子来讲就是 一个工厂要运作 必然要有一个老板负责从外面接活 老板就是bossGroup
* *** 然后有很多员工,负责具体干活 ,员工就是 workerGroup bossGroup接收完连接,扔给workerGroup 处理
*
*/
NioEventLoopGroup boss = new NioEventLoopGroup();
NioEventLoopGroup worker = new NioEventLoopGroup();
/**
*接下来 我们创建了一个 引导类 ServerBootstrap 这个类将引导我们进行服务端的启动工作 直接new 出来就行了
* 我们通过 .group(bossGroup ,workerGroup) 给引导类配置两大线程组 这个引导类的线程模型也就定型了
*然后 我们指定我们服务端的IO模型为 NIO ,我们通过 .channel(NioServerSocketChannel.class) 来指定 IO模型
* 然,这里也有其他选择,如果我们想指定 IO模型为 BIO ,那么这里配置上 OioServerSocketChannel.class 类型即可
* 然通常我们也不会这么做,因为 Netty 的优势就在于 NIO
*接着,我们调用 childHandler() 方法给引导类创建一个 ChannelInitializer ,这里主要就是定义后续每条连接的数据读写 ,
* 业务处理 ,不理解没关系,后面继续详解
* ChannelInitializer 这个类中,我们注意到有一个泛型参数 NioSocketChannel ,
* 这个类呢就是 Netty 对NIO类型的连接的抽象,而我们前面 NioServerSocketChannel 也是对NIO类型的连接的抽象
* NioServerSocketChannel 和 NioSocketChannel 的概念都可以和 BIO 编程模型中的 ServerSocket 和 Socket 两个概念对应上
*
* 我们的最小化参数配置到这里就完成了
*
* !!! 总结一下 !!!
* 要启动一个 Netty 服务端,必须要指定 三类属性:
* 分别是线程模型 IO 模型 连接读写处理逻辑
* 有了这三者,之后在调用 bind(port) 就可以在本地绑定一个 port 端口启动起来
*
*
*/
serverBootStrap
.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel nioSocketChannel) throws Exception {
nioSocketChannel.pipeline().addLast(new FirstServerHandler())
.addLast(new SecondServerHandler());
}
});
// 可以和 childHandler() 对应起来, childHandler() 用于指定处理新连接数据的读写处理逻辑 Handler() 用于指定在服务端启动过程中的
//一些逻辑 通常情况下,我们用不着这个方法
serverBootStrap.handler(new ChannelInitializer<NioServerSocketChannel>() {
@Override
protected void initChannel(NioServerSocketChannel nioServerSocketChannel) throws Exception {
System.out.println("服务器启动中...");
}
});
/** attr() 方法可以给服务端的 channel ,也就是NioServerSocketChannel 指定一些自定义属性
* 然后可以通过 channel.attr() 这个方法取出这个属性 比如这里给服务端channel 指定了一个 severName ,属性值为 nettyName
* 说白了 就是给 NioServerSocketChannel 维护了一个Map 通常情况下 我们也用不上这个方法
*/
serverBootStrap.attr(AttributeKey.newInstance("serverName"), "nettyName");
/**
* attr() 是给 服务端 NioServerSocketChannel 指定一些属性
* childAttr() 是给每一条连接 NioSocketChannel 然后我们可以通过 channel.attr 取出该属性
*/
serverBootStrap.childAttr(AttributeKey.newInstance("cilentKey"), "cilentName");
/**
* childOption() 可以给每条连接设置一些 TCP 底层相关的属性,
* 比如 ChannelOption.SO_KEEPALIVE 表示是否开启 TCP底层心跳机制 true 为开启
* ChannelOption.TCP_NODELAY 表示是否开启 Nagle 算法 true 表示关闭 false 表示开启
* 通俗地说 如果实时性要求高 有数据就马上发送 就关闭 ; 如果要减少发送次数减少网络交互 就开启
*/
serverBootStrap.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.TCP_NODELAY, true);
/**
* 除了给每个连接设置一系列属性之外 还可以给服务端 channel 设置一些属性
* 最常见的就是 so_backlog
* 表示系统用于临时存放已经完成三次握手的请求的队列的最大长度
* 如果连接建立频繁 服务器创建新连接较慢,可以适当调大这个参数
*/
serverBootStrap.option(ChannelOption.SO_BACKLOG, 1024);
bind(serverBootStrap, 8000);
//.bind(8000)
User user = new User();
//user.getName();
}
private static void bind(final ServerBootstrap serverBootstrap, final int port) {
serverBootstrap.bind(port)
.addListener(new GenericFutureListener<Future<? super Void>>() {
@Override
public void operationComplete(Future<? super Void> future) throws Exception {
if (future.isSuccess()) {
System.out.println("端口: " + port +"绑定成功");
} else {
System.err.println("端口: " + port +"绑定失败");
bind(serverBootstrap, port + 1);
}
}
});
}
- 服务端创建流程
- 创建 ServerBootStrap 实例
- 创建并设置 Reactor 线程池:EventLoopGroup ,EventLoop 就是处理所有注册到本线程的Selector 上面的 channel
- 设置并绑定服务端的 channel
- 创建处理网络事件的 ChannelPipeline 和 handler ,网络事件以流的形式在其中流转
- hander 完成多数的功能定制
- 绑定并启动监听端口
- 当轮训到准备就绪的channel 后,由 Reactor 线程:NioEventLoop 执行 pipeline中的方法,最终调度并执行 channelHandler
- 客户端流程
Bootstrap bootStrap = new Bootstrap();
NioEventLoopGroup group = new NioEventLoopGroup();
bootStrap
//指定线程模型
.group(group)
//指定 IO类型为 NIO
.channel(NioSocketChannel.class)
// IO处理逻辑
.handler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel channel) throws Exception {
// ch.pipeline() 返回的是和这条连接相关的逻辑处理链 采用了责任链模式
channel.pipeline()
/** addLast() 方法添加一个逻辑处理器 这个逻辑处理器为的就是在客户端建立连接成功之后
* 向服务端写数据 下面编写这个逻辑处理器相关的代码
*/
//.addLast(new StringEncoder())
.addLast(new FirstClientHandler());
//指定 连接数据读写逻辑
}
});
// 常规建立连接
//bootStrap.connect("127.0.0.1", 8000)
// .addListener(future -> {
// if (future.isSuccess()) {
// System.out.println("连接成功");
// } else {
// System.out.println("连接失败");
// }
// });
String host = "127.0.0.1";
int port = 8000;
// 添加自动重连的连接
//ChannelFuture channelFuture = connect(bootStrap, host, port);
//智能化的自动重连机制
ChannelFuture channelFuture = connect(bootStrap, host, port, 5);
Channel channel = channelFuture.channel();
}
/**
* @Description: 支持自动重连的方式建立连接
* @Param: [bootstrap, host, port]
* @return: void
* @Author: Administrator
* @Date: 2021/8/23
*/
private static ChannelFuture connect(Bootstrap bootstrap, String host, int port) {
return bootstrap.connect(host, port).addListener( future -> {
if (future.isSuccess()) {
System.out.println("连接成功");
} else {
System.out.println("连接失败,开始重连");
connect(bootstrap, host, port);
}
});
}
/**
* @Description: 智能化的自动重连,超过失败次数就不再连接
* 连接建立失败不会立即重连,而是会通过一个指数退避的方法 比如 1秒 2秒 4秒 8秒,以2的幂次来建立连接
* 然后达到一定次数之后就放弃连接
* @Param: [bootstrap, host, port, retry]
* @return: void
* @Author: Administrator
* @Date: 2021/8/23
*/
private static ChannelFuture connect(Bootstrap bootstrap, String host, int port, int retry) {
return bootstrap.connect(host, port).addListener(future -> {
if (future.isSuccess()) {
System.out.println("连接成功");
} else if (retry ==0 ) {
System.err.println("重连次数已用完,放弃连接!");
} else {
//第几次连接
int order = (MAX_RETRY - retry) +1;
//本次重连的间隔
int delay = 1 << order;
System.err.println(new Date() + ": 连接失败, 第" + order + "次重连");
// 返回 BootstrapCofig 这是对Bootsrap配置参数的抽象
bootstrap.config()
// bootsrap.config().group() 返回的就是我们在一开始的时候配置的线程模型 workerGroup
.group()
//调用 workerGroup 的schedule 就可以实现定时任务逻辑
.schedule(()->
connect(bootstrap, host, port, retry - 1), delay , TimeUnit.SECONDS
);
}
});
}
相关概念和常见事件
socket里的零拷贝
1. `File.read(bytes)`
2. `Socket.send(bytes)`
这种方式需要四次数据拷贝和四次上下文切换:
- 具体流程
- 数据从磁盘读取到内核 read buffer
- 数据从内核缓冲区拷贝到用户缓冲区
- 数据从用户缓冲区拷贝到内核的 socket buffer
- 数据从内核的socket buffer 拷贝到网卡接口/硬件的 缓冲区
零拷贝的概念
上面的第二步和第三步是没必要的,通过 java 的 FileChannel.transferTo 方法,直接在通道之间传递数据,可以避免上面两次多余的拷贝
-
具体流程
- 调用 transferTo 数据从文件由DMA引擎拷贝到内核 readerBuffer
- 接着DMA从内核reader Buffer 将数据拷贝到网卡接口 bufer
- 上面两次操作都不需要CPU参与 ,所以就达到了零拷贝
Netty中的零拷贝
主要体现在三个方面:
-
bytebuffer
- Netty发送和接收消息主要使用 bytebuffer ,bytebuffer 使用堆外内存/直接内存 进行socket读写 (如果使用传统的堆内存进行socket读写,JVM会将堆内存buffer拷贝一份到直接内存再写入socket 多了一次缓冲区的拷贝,DirectMemory中可以直接通过DMA发送到网卡接口)
-
composite Buffers
- 传统的ByteBuffer ,如果需要将两个byteBuffer中的数据组合到一起,我们需要首先创建一个size = size 1 + size2 大小的新数组,然后将两个数组中的数据拷贝到新的数组中。但是netty中的compositeByteBuf并没有真正将多个buffer组合起来,而是保存了他们的引用,从而避免了数据的拷贝,实现了零拷贝
-
FileChannel. transferTo
-
该方法依赖于操作系统实现零拷贝
TCP 3次握手 和 四次挥手
-
握手
- 客户端发建立连接请求,第一次握手
- 服务端收到请求,给客户端发个确认,第二次握手
- 客户端收到服务器的确认,给服务端发个请求,用来确认客户端收到了服务端的确认 第三次握手
-
四次挥手
TCP 双工通讯,每个方向都必须单独进行关闭
以客户端主动发起关闭为例:- 客户端发请求,跟服务端说准备分手 这是第一次挥手
- 服务端收到请求后,就回了个滚进行确认 这是第二次挥手
- 服务端准备拆 服务端到客户端这条链路这是第三次挥手
- 客户端收到服务端的确认后,再给服务端发送最后一个确认,然后关闭客户端到服务端的链路,服务端收到之后马上也关闭服务端到客户端的链路 这就是第四次挥手
长链接和短连接
-
短连接
- 连接 -> 传输数据 ->关闭连接
- 短连接指socket 连接,发送数据,接收数据后,马上断开连接
-
长连接
- 连接 -> 传输数据 ->保持连接 ->传输数据 … 关闭连接
- 建立socket连接后,不管是否使用,一致保持连接
半包 和 粘包 分包
在TCP中只有流的概念,没有包的概念。
TCP是一种流协议,这就意味着数据是以字节流的形式传递给接收者的,没有固定的报文或者报文边界的概念。从这方面来讲,读取TCP数据就像从串口端口中读取数据一样,无法预知在一次读取中会返回多少个字节。
- 发送方粘包
TCP为了提高效率,发送方往往要收集到足够多的数据才发送一包数据,若连续几次发送的数据都很少,通常TCP会根据优化算法把这些数据合成一个包后一次发送出去,这样接收方就收到了粘包数据。
这么做的有点也很明显,就是为了减少广域网的小分组数目,从而减小网络阻塞的出现。总的来说就是:发送端发送了几次数据,接收端一次性读取了所有数据,造成多次发送一次读取
- 接收方粘包
接收方用户进程不及时接收,从而导致的粘包现象。这是因为接收方先把收到的数据放在系统接收缓冲区,用户进程从该缓冲区读取数据,若下一包数据到达时前一包数据尚未被用户进程取走,则下一包数据放到系统接收缓冲区时就接到前一包数据之后,而用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据,这样就一次性取到了多包数据。
-半包
接收方没有接收到一个完整的包,只接受了部分。
这种情况是因为TCP为了提高传输效率,将一个包分配的足够大,导致接收方并不能一次接收完。