并发设计类分析(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()方法直接休眠对应时长。

image

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流程

image

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设计如下。
image
看图可知对应并发设计模式中的Thread-Per-Message。由于不是协程实现,针对海量连接这个设计多烂就不细说了。

Reactor模式

如果一个服务想要处理C10K/C100K时,创建1w或者10w个线程不太现实。所以自然可以想到IO多路复用,让一个线程监视多个文件描述符。上文提到过BIO会从头阻塞到尾,所以配合NIO(AIO最好但是生态和使用难度大)这个问题就解决了。

经典Reactor模式如下。
image

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。

image

实际使用中,一般都会创建两个 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();
    }
}
posted @ 2023-12-06 21:46  kiper  阅读(38)  评论(0编辑  收藏  举报