并发设计类分析(Guava/Netty)

发布时间 2023-12-06 21:49:24作者: kiper

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));
            });
        }
        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