并发设计类分析(Guava/Netty)
1. 限流器
1.1 限流器
常见限流算法:
-
计数器算法
计数器算法是一种简单的限流方法,通过对请求进行计数,当请求达到一定的阈值时,进行限制。这种方法适用于简单场景,但不够灵活。容易出现临界时间点限流失效问题。 -
滑动窗口算法
滑动窗口算法维护一个时间窗口内的请求数量,通过动态调整窗口大小,可以更灵活地适应流量的变化。其实就是计数器算法的优化版本,将计数器算法中的单位时间切割成了多块,但也没有完全解决临界时间点限流失效问题。 -
漏桶算法(Leaky Bucket)
漏桶算法与令牌桶算法类似,但是它是按照固定速率漏水,而不是放入令牌。请求被处理的速度是固定的,当请求到来时,如果漏桶未满,则请求被处理,否则被丢弃或等待。 -
令牌桶算法(Token Bucket)
令牌桶算法是漏桶算法的优化版,支持突发流量。在令牌桶中,令牌以固定的速率被放入到桶中,而请求要想通过,必须获取到一个令牌。如果桶中没有足够的令牌,请求就会被限制。
1.2 Guava实现原理
Guava就是令牌桶算法的实现。
需要实现的功能点:
- 令牌以固定的速率添加到令牌桶中,假设限流的速率是 r/ 秒,则令牌每 1/r 秒会添加一个;
- 假设令牌桶的容量是 b ,如果令牌桶已满,则新的令牌会被丢弃;
- 请求能够通过限流器的前提是令牌桶中有令牌。
很容易想到令牌桶的一个实现是:工作线程执行操作前去申请令牌,后台再开启一个子线程定时地添加令牌。但是Guava的RateLimiter限流器并不是这个原理。
RateLimiter的实现是:通过计算下个令牌颁发时间点,并让申请线程休眠对应时长。
假设每次只申请1个令牌(实际上可以申请n个),主要流程如下图所示。
线程执行调用acquire()方法请求令牌,acquire()会调用同步reserve(long time)方法计算下个令牌颁发时间点并返回,线程计算休眠时长后,调用sleep()方法直接休眠对应时长。
1.3 简易限流器代码实现
class RateLimiter { // 单个令牌颁发间隔时长,单位纳秒 private final long INTERVAL = 1000_000_000; // 最大令牌数,额外的突发量 private final long maxPermits; // 当前令牌数 private long storePermits; // 下个令牌颁发时间点 private long next; /** * @param initPermits 初始令牌数 * @param maxPermits 最大令牌数 */ public RateLimiter(long initPermits, long maxPermits) { // 省略合法性检查 this.storePermits = initPermits; this.maxPermits = maxPermits; } /** * 获取令牌,没有令牌则等待 */ @SneakyThrows public void acquire() { // 获取当前时间 long now = System.nanoTime(); // 预占令牌,计算可执行时间点 long timePoint = reverse(now); // 计算等待时长, 避免异常负数最小置为0 long wait = Math.max(timePoint - now, 0); // 如果wait大于0,则休眠对应时长 if (wait > 0) { TimeUnit.NANOSECONDS.sleep(wait); } } /** * 预占令牌,并返回线程可执行时间点 * 加锁为了保护共享变量storePermits及next * * @param now 线程申请令牌时间点 * @return 可执行时间点 */ synchronized private long reverse(long now) { // 更新令牌数及下个令牌颁发时间点 resync(now); // 获取下一个令牌颁发时间 long timePoint = next; // 判断是否有令牌 if (storePermits > 0) { // 有令牌,令牌数-1 storePermits--; } else { // 没有令牌,下一个令牌颁发时间需要增加一个间隔时长 next += INTERVAL; } return timePoint; } /** * 更新令牌数及下个令牌颁发时间点 * @param now 线程申请令牌时间点 */ private void resync(long now) { // 如果线程申请令牌时间点比下个令牌颁发时间点还早,那么不需要新增令牌数及更新下个令牌颁发时间点 if (now <= next) { return; } // 计算新增令牌数, 实际上Guava限流器实现用double更精准 long newPermits = (now - next) / INTERVAL; // 更新令牌数 storePermits = Math.min(storePermits + newPermits, maxPermits); // 更新下次颁发令牌时间点 next = now; } } @Slf4j public class Test { @SneakyThrows public static void main(String[] args) { // 创建固定6个线程的线程池 ExecutorService executor = Executors.newFixedThreadPool(6); // 创建一个初始令牌3个,最大令牌数为3的限流器 RateLimiter simpleLimiter = new RateLimiter(3, 3); // 丢9个数字打印观察 for (int i = 1; i <= 9; i++) { int finalI = i; executor.submit(() -> { simpleLimiter.acquire(); log.info(String.valueOf(finalI)); });q } executor.shutdown(); } }
打印如下。由于设置了初始令牌数为3,可以看到刚开始4个线程并发打印。后续则每秒只有1个线程打印。
21:43:31.319 [pool-1-thread-2] INFO com.huaxiaogou.MDog.myguava.Test - 2 21:43:31.319 [pool-1-thread-3] INFO com.huaxiaogou.MDog.myguava.Test - 3 21:43:31.319 [pool-1-thread-4] INFO com.huaxiaogou.MDog.myguava.Test - 4 21:43:31.319 [pool-1-thread-1] INFO com.huaxiaogou.MDog.myguava.Test - 1 21:43:32.323 [pool-1-thread-5] INFO com.huaxiaogou.MDog.myguava.Test - 5 21:43:33.323 [pool-1-thread-6] INFO com.huaxiaogou.MDog.myguava.Test - 6 21:43:34.323 [pool-1-thread-2] INFO com.huaxiaogou.MDog.myguava.Test - 7 21:43:35.323 [pool-1-thread-4] INFO com.huaxiaogou.MDog.myguava.Test - 8 21:43:36.323 [pool-1-thread-3] INFO com.huaxiaogou.MDog.myguava.Test - 9
2. 高性能网络框架Netty
2.1 网络编程性能瓶颈
2.1.1 IO流程
2.2.2 常见IO模型区别:
-
阻塞IO(BIO)
从IO流程中的第1步阻塞到第6步返回。 -
非阻塞IO(NIO)
调用完第1步后立马返回,不会阻塞。
内核异步地完成第2步到第4步后,线程轮询会发现数据可读。
线程最后再阻塞地完成第5步。 -
IO多路复用
I/O 多路复用允许一个程序监视一组文件描述符。Linux中一般是通过I/O 多路复用的系统调用
select()/poll()/epoll()
来完成 。
一般IO多路复用配合NIO来完成,当有可读事件的时候,线程依然阻塞地完成第5步。-
select()
最多监视1024个文件描述符,当其中任何一个文件描述符就绪(可读、可写、有错误等)时,select() 就会返回。然后程序可以进一步检查哪些文件描述符处于就绪状态。select() 的主要缺点是效率较低,它需要遍历整个文件描述符集合。 -
poll()
poll() 的使用方式类似于 select(),但它不再受到文件描述符数目的限制。poll() 仍然需要遍历整个文件描述符集合,但在某些场景下比 select() 更为高效。 -
epoll()
Linux 特有的 I/O 多路复用机制,引入了事件驱动的设计。相比于 select() 和 poll(),epoll() 在大规模并发连接的情况下性能更好。epoll() 使用事件触发的方式,只返回发生事件的文件描述符,而不需要遍历整个集合。这使得 epoll() 在处理大量并发连接时更为高效。macOS上并没有 epoll, 类似的是kqueue。
触发方式:- 水平触发(LT):默认工作模式,即当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件
- 边缘触发(ET): 当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。(边缘触发只在状态由未就绪变为就绪时只通知一次)。
对比如下。
维度 select poll epoll 操作方式 遍历 遍历 回调 底层实现 数组 链表 红黑树 IO效率 每次调用都进行线性遍历,时间复杂度为O(n) 每次调用都进行线性遍历,时间复杂度为O(n) 事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1) 最大连接数 1024(x86)或2048(x64) 无上限 无上限 fd拷贝 每次调用select,都需要把fd集合从用户态拷贝到内核态 每次调用poll,都需要把fd集合从用户态拷贝到内核态 调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝 -
-
信号驱动IO
信号驱动IO会注册信号处理函数。调用完第1步后立马返回,不会阻塞。
内核异步地完成第2步到第4步后,会发送信号,触发信号处理函数回调。
当然,信号处理函数的执行线程依然阻塞地完成第5步。 -
异步IO(AIO)
最吊的IO模型,因为前面4个都会在第5步阻塞,但是AIO不会。
调用完第1步后立马返回,不会阻塞。当操作系统通知数据到达时,用户态程序可以直接使用数据,因为第2步到第5步都已经异步完成了。
但是实际上基本没多少使用,因为生态不成熟。贴点评论。为什么需要AIO,无非是希望IO不干扰主线程,不阻塞。 那么关键要解决的问题,应该就是网络IO,send,recv,accpet,以及文件(大规模)读写的write, read。 1. glibc 的AIO,采用的是POSIX的接口。先不说有没有BUG,难不难调试,内部不是依靠内核实现。 至少API里面是没有connect, accept,send,recv(write,read可以替代部分)。所以,摊手。 2. 内核的AIO,有一个封装libaio。但如果只能O_DIRECT方式操作文件。估计普通用户不会愿意触及这个,除了数据库的开发者。 网络API一样没有。
2.2 Netty实现原理
现在有海量连接要处理了,最原始的BIO设计如下。
看图可知对应并发设计模式中的Thread-Per-Message。由于不是协程实现,针对海量连接这个设计多烂就不细说了。
Reactor模式
如果一个服务想要处理C10K/C100K时,创建1w或者10w个线程不太现实。所以自然可以想到IO多路复用,让一个线程监视多个文件描述符。上文提到过BIO会从头阻塞到尾,所以配合NIO(AIO最好但是生态和使用难度大)这个问题就解决了。
经典Reactor模式如下。
Reactor 这个类,通过register_handler() 和 remove_handler() 注册和删除事件处理器;
handle_events() 方式是核心,逻辑如下:首先通过同步事件多路选择器提供的 select() 方法监听网络事件,当有网络事件就绪后,就遍历事件处理器来处理该网络事件。由于网络事件是源源不断的,所以在主程序中启动 Reactor 模式,需要以 while(true){} 的方式调用 handle_events() 方法。
void Reactor::handle_events () { //通过同步事件多路选择器提供的select()方法监听网络事件 select(handlers); //处理网络事件 for(h in handlers){ h.handle_event(); } } // 在主程序中启动事件循环 while (true) { handle_events(); }
2.3 Netty设计
Netty基于Reactor再套了一层,可以理解为Reactor的集合,所以多了个概念叫EventLoopGroup。一个EventLoop其实就对应一个Reactor。
实际使用中,一般都会创建两个 EventLoopGroup,一个称为 bossGroup,一个称为 workerGroup。
bossGroup 就用来处理连接请求的,而 workerGroup 是用来处理读写请求的。bossGroup 处理完连接请求后,会将这个连接提交给 workerGroup 来处理。
workerGroup 里面有多个 EventLoop,默认的负载均衡算法是轮询算法。
2.4 Netty使用例子
public class Test { public static void main(String[] args) { // 事件处理器 final EchoServerHandler serverHandler = new EchoServerHandler(); //boss线程组 EventLoopGroup bossGroup = new NioEventLoopGroup(1); //worker线程组,演示1个线程也可以监控多个文件描述符 EventLoopGroup workerGroup = new NioEventLoopGroup(1); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap .group(bossGroup, workerGroup) // 选用支持NIO的ServerSocketChannel类 .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override public void initChannel(SocketChannel ch) { ch.pipeline().addLast(serverHandler); } }); //bind服务端端口 ChannelFuture f = serverBootstrap.bind(8081).sync(); f.channel().closeFuture().sync(); } catch (Exception e) { e.printStackTrace(); } finally { //终止工作线程组 workerGroup.shutdownGracefully(); //终止boss线程组 bossGroup.shutdownGracefully(); } } } //socket连接处理器 @Sharable @Slf4j class EchoServerHandler extends ChannelInboundHandlerAdapter { //处理读事件 @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { log.info("Received message from " + ctx.channel().remoteAddress() + ": " + msg); ctx.write(msg); } //处理读完成事件 @Override public void channelReadComplete(ChannelHandlerContext ctx) { ctx.flush(); } //处理异常事件 @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { cause.printStackTrace(); ctx.close(); } }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?