并发设计类分析(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();
}
}