本篇涉及Netty、Linux网络IO等专业知识,适合对该方面有一定应用基础者食用
一、Epoll机制简述
NIO (Non-blocking IO),称之为非阻塞IO,传输过程如下:
- Epoll是Linux内核提供的一种高效的事件通知机制,它通过异步非阻塞的方式处理I/O操作,提高系统的性能和扩展性。Epoll的工作原理可以分为以下几个步骤:
- 创建Epoll实例:首先需要创建一个Epoll实例,通过调用epoll_create()系统调用来创建。该实例用于注册和管理事件。
- 注册文件描述符:将需要监听的文件描述符(通常是套接字)添加到Epoll实例中,通过调用epoll_ctl()系统调用。可以指定感兴趣的事件类型,如可读事件、可写事件等。
- 等待事件就绪:调用epoll_wait()系统调用来等待文件描述符上的事件就绪。该调用会阻塞,直到有事件就绪或超时。
- 处理就绪事件:当有事件就绪时,epoll_wait()会返回就绪事件集合。应用程序可以遍历该集合,处理相应的I/O操作。 - Epoll的工作原理主要依赖于内核中的三个数据结构:
- Epoll实例(epoll instance):用于注册和管理事件的数据结构。它在内核中维护着一个红黑树,用于快速查找和管理被注册的文件描述符。
- 文件描述符集合(file descriptor set):用于存储需要监听的文件描述符。它可以是一个数组或链表的形式,通过epoll_ctl()系统调用将文件描述符添加到集合中。
- 就绪事件集合(ready event set):用于存储已经就绪的事件。当调用epoll_wait()等待事件时,内核会将就绪的事件添加到该集合中,并返回给应用程序。
- Epoll的工作原理是基于事件驱动的,只有在事件就绪时才会进行相应的处理。与传统的阻塞式I/O模型相比,Epoll能够处理更多的并发连接,避免了不必要的阻塞和轮询操作,提高了系统的性能和扩展性。
二、代码实现
由于上篇文章【基于Netty实现NIO模型UDP服务端】在性能上还有很大的瓶颈,所以本篇在代码层片进行了优化。采用Epoll机制和多线程的方式在用户空间内快速处理业务逻辑
1. 创建springBoot项目
目录结构:
2. 导入Maven依赖配置
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.53.Final</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.codehaus.janino</groupId>
<artifactId>janino</artifactId>
<version>3.0.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
3. 创建UDP服务端并绑定端口
- SO_REUSEPORT是一个Socket选项,可以在多个进程或线程绑定到同一个端口上时,允许它们共享该端口。它可以提高并发连接的性能,特别是在负载均衡的场景下。
- 内核层面实现负载均衡
- 安全层面,监听同一个端口的套接字只能位于同一个用户下面
package com.demo.nettyDemo.udp;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.epoll.Epoll;
import io.netty.channel.epoll.EpollChannelOption;
import io.netty.channel.epoll.EpollDatagramChannel;
import io.netty.channel.epoll.EpollEventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioDatagramChannel;
/**
* @title
* @date 2023/7/19 13:44
**/
public class NettyUdp implements Runnable {
// UDP服务端口
private static int PORT = 15001;
// 缓冲区默认最小值
private static int DEFAULT_MINIMUM = 512;
// 缓冲区默认初始值
private static int DEFAULT_INITIAL = 2048;
// 缓冲区默认最大值
private static int DEFAULT_MAXIMUM = 1024 * 1024;
public static void main(String[] args) {
new NettyUdp().run();
}
@Override
public void run() {
// 创建Netty线程组,用于接收请求和处理IO请求
EventLoopGroup eventLoopGroup = Epoll.isAvailable() ? new EpollEventLoopGroup() : new NioEventLoopGroup();
// 创建netty客户端启动器
Bootstrap b = new Bootstrap();
// 创建客户端与服务器的交互管道
Channel channel;
// 设置线程模型
b.group(eventLoopGroup)
// 指明网络通讯的是udp通讯
.channel(Epoll.isAvailable() ? EpollDatagramChannel.class : NioDatagramChannel.class)
// 设置netty接收缓冲区大小
.option(ChannelOption.SO_RCVBUF, DEFAULT_MAXIMUM)
// 自定义处理接收的数据
.handler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel nch) throws Exception {
// 在处理器链的最后添加一个NettyHandler
nch.pipeline().addLast(new NettyHandler());
}
});
// linux平台下支持SO_REUSEPORT特性以提高性能
if (Epoll.isAvailable()) {
b.option(EpollChannelOption.SO_REUSEPORT, true);
}else {
b.option(ChannelOption.RCVBUF_ALLOCATOR, new AdaptiveRecvByteBufAllocator(DEFAULT_MINIMUM, DEFAULT_INITIAL, DEFAULT_MAXIMUM));
}
try {
// 绑定端口
channel = b.bind(PORT).sync().channel();
// 阻塞主线程,等待服务端关闭
channel.closeFuture().sync().await();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
eventLoopGroup.shutdownGracefully();
}
}
}
4. 自定义数据包处理器
package com.demo.nettyDemo.udp;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.socket.DatagramPacket;
import lombok.extern.slf4j.Slf4j;
import java.net.InetSocketAddress;
/**
* @title
* @date 2023/7/19 14:09
**/
@Slf4j(topic = "demoLog")
public class NettyHandler extends SimpleChannelInboundHandler<DatagramPacket> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket msg) throws Exception {
// 接收数据包的内容写入byte数组缓冲区
ByteBuf buf = msg.content();
// 读取byte数组缓冲区的长度
int len = buf.readableBytes();
// 创建字节数组,用于接收数据
byte[] data = new byte[len];
// 获取服务端的IP地址和端口号
InetSocketAddress inetSocketAddress = msg.sender();
// 将byte数组缓冲区的内容写入byte数组
buf.readBytes(data);
// 将字节数组转为string
String receive = new String(data, 0, len);
log.info(inetSocketAddress.getAddress().getHostAddress() + ":" + inetSocketAddress.getPort() + ":" + receive);
}
}
5. 在启动类中添加UDP服务启动线程
@SpringBootApplication
public class NettyDemoApplication {
public static void main(String[] args) {
SpringApplication.run(NettyDemoApplication.class, args);
// 开启线程启动UDP服务端
// linux系统下使用SO_REUSEPORT特性,使得多个线程绑定同一个端口
if (Epoll.isAvailable()) {
int cpuNum = Runtime.getRuntime().availableProcessors();
for (int i = 0; i < cpuNum; i++) {
Thread udpThread = new Thread(new NettyUdp());
udpThread.start();
}
}
}
}
三、丢包率测试
- 使用Jmeter请求UDP端口,模拟高并发请求
- 监控方法
- 测试情况:
- VMware:CentOS 7 2核4G
- net.core.rmem_default = 1048576
- net.core.rmem_max = 1048576
QPS | 测试时长(分钟) | 请求总量 | 丢包数 | 丢包率 |
---|---|---|---|---|
6000 | 5 | 1,806,981 | 28,678 | 1.59% |
5000 | 5 | 1,494,106 | 1,139 | 0.08% |
4000 | 5 | 1,180,284 | 234 | 0.02% |
3000 | 5 | 894,873 | 0 | 0.00% |
2000 | 5 | 599,873 | 0 | 0.00% |
四、结论
- 经过并发测试,每秒5000QPS以下,丢包率在万分之8以下,是一个能勉强接收的丢包容忍率。
- 使用SO_REUSEPORT,多个线程绑定一个端口,可以大幅提高应用处理内核中队列内的数据
- 增加应用的接收缓冲区大小,可以一定程度提高处理速度,减少因为内核中接收缓冲区满而丢包的几率。