【Spring Boot相关】探讨Spring mvc利用CompletableFuture实现的异步调用

【参考】

【本文内容】

  • Spring mvc利用CompletableFuture实现异步调用,探讨了以下问题:
    • 对比同步和异步单个请求的处理时间:这个异步是纯后端的异步,对前端是无感的,异步也并不会带来响应时间上的优化,原来该执行多久照样还是需要执行多久。
    • 在Controller中使用CompletableFuture,一定会提高效率吗?通过ab压力测试显示,不一定。
    • 同步请求 vs 异步请求所暂用的资源:异步处理暂用更多的资源。
  • CompletableFuture的默认线程池存在的问题:原因是ForkJoinPool默认的并发数太小。
  • 通过创建AOP,查看异步状态下的线程详细情况。

1. Spring mvc 异步请求 vs 同步请求

通常情况下Spring Controller返回请求数据的API长这样:

    @GetMapping("/sync_result")
    public String getResultSync() {
        sleep(500L);
        return "result is ready";
    }

但Spring也支持异步的调用,如返回Callable,或方法上加@Async,或返回CompletableFuture。这个功能实际上是Servlet 3.0之于异步请求的支持的实现。

例如返回CompletableFuture

    @GetMapping("/async_result")
    public CompletableFuture<String> getResultAsyc() {
        return CompletableFuture.supplyAsync(() -> {
            sleep(500L);
            return "result is ready";
        });
    }
1.1 对比同步和异步单个请求的处理时间

这个异步是纯后端的异步,对前端是无感的,异步也并不会带来响应时间上的优化,原来该执行多久照样还是需要执行多久。

下图为F12的debug截图,可以看到对于单个请求,无论是同步的请求,还是异步的请求(使用CompletableFutureCallable),都没有明显的差距:

image.png

1.2 在Controller中使用CompletableFuture,一定会提高效率吗?

默认情况下,Tomcat 的核心线程是 10,最大线程数是 200, 我们能及时回收线程,也就意味着我们能处理更多的请求,能够增加我们的吞吐量,这也是异步 Servlet 的主要作用。

那么使用CompletableFuture一定会提高吞吐量吗?

这里我们用apache bench做实验,关于ab,请参考:https://www.jianshu.com/p/557df0992795

测试两个case:

  • 同步请求数据,API为:/sync_request
  • 利用CompletableFuture异步请求数据,但使用自定义的线程池,线程数为500,API为:/async_request/custom_pool
    @GetMapping("/async_result/custom_pool")
    public CompletableFuture<String> getResultAsycWithCustomPool() {
        return CompletableFuture.supplyAsync(() -> {
            sleep(500L);
            return "result is ready";
        }, executorService);
    }

我们做如下请求测试:
使用ab压力测试工具进行100/200/400/800的并发量测试,方法内延时时间分别为:10ms/100ms/500ms:

ab命令为:

ab -k -n 2000 -c 100 localhost:8080/sync_result
ab -k -n 4000 -c 200 localhost:8080/sync_result
ab -k -n 8000 -c 400 localhost:8080/sync_result
ab -k -n 16000 -c 800 localhost:8080/sync_result

ab -k -n 2000 -c 100 localhost:8080/async_result/custom_pool
ab -k -n 4000 -c 200 localhost:8080/async_result/custom_pool
ab -k -n 8000 -c 400 localhost:8080/async_result/custom_pool
ab -k -n 16000 -c 800 localhost:8080/async_result/custom_pool

ab结果,表中的时间为每个request所需的时间(毫秒):
image.png

转为图表(省略小数点):
image.png

可以看到:

  • 并发用户数不高的情况下(如100-200个用户)时,同步请求和异步请求所需的时间差不多。
  • 而在高并发的情况下(如用户>=400)时,异步请求的优势较为明显。
1.3 同步请求 vs 异步请求所暂用的资源

以下是请求处理时间为500ms时(sleep time=500ms),不同用户并发数(100/200/400/800)下,并且请求为【同步】时的Jconsole:
image.png

以下是相同条件下,请求为【异步】时的Jconsole:
image.png

可以看到异步时占用的内存更多,线程数也更多,占用的cpu更高。

2. CompletableFuture的默认线程池存在的问题

在上述#1.2做压力测试的时,使用的是500个核心线程的线程池,那么如果我们不自定义线程池,而是使用CompletableFuture默认的线程池,效率会如何呢?

尝试请求处理时间为10ms时,使用默认线程池的表现:

ab -k -n 2000 -c 100 localhost:8080/async_result

结果每个request平均需要162.894ms!!!

而同步的状态下,#1.2的测试结果为16.368ms,使用500个核心线程池时异步状态下的结果为18.103ms。

为什么CompletableFuture在默认线程池下这么慢?
默认情况下,CompletableFuture使用的是ForkJoinPool.commonPool(),而ForkJoinPool默认支持的并发数为cpu-1:

System.out.println("Parallelism:" + ForkJoinPool.commonPool().getParallelism());
System.out.println("CPU: " + Runtime.getRuntime().availableProcessors());

在我的pc下,分别为7和8。即8核的情况下ForkJoinPool默认的可并行级别为7。

而7个并行级别显然是太低了,还不如同步。这也是为什么在使用CompletableFuture作为Controller返回值时,推荐加上线程池的原因。

3. 异步状态下的线程详细情况

代码借鉴了开头参考列表中的第1个github repository。

首先我们自定义一个注解类:

@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AsyncTimed {
}

为了更好的观察,为注解类加一个aop,需要引入aop包以及google的guava包:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>31.1-jre</version>
        </dependency>

@Pointcut定义的包名为AsyncTimed所在的包名:

@Slf4j
@Aspect
@Component
public class AsyncTimedAspect {

    @Pointcut("@annotation(com.async.aspect.AsyncTimed)")
    public void asyncTimedAnnotationPointcut() {
    }

    @Around("asyncTimedAnnotationPointcut()")
    public Object methodsAnnotatedWithAsyncTime(final ProceedingJoinPoint joinPoint) throws Throwable {
        return proceed(joinPoint);
    }

    private Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        final Object result = joinPoint.proceed();
        if (!isCompletableFuture(result)) {
            return result;
        }

        final String description = joinPoint.toString();
        final Stopwatch watch = Stopwatch.createStarted();

        String initiatingThread = Thread.currentThread().getName();
        return ((CompletableFuture<?>) result).thenApply(__ -> {
            String executingThread = Thread.currentThread().getName();
            watch.stop();
            log.info("description: {}", description);
            log.info("watch: {}", watch.toString());
            log.info("initiating thread: {}", initiatingThread);
            log.info("executing thread: {}", executingThread);
            return __;
        });
    }

    private boolean isCompletableFuture(Object result) {
        return CompletableFuture.class.isAssignableFrom(result.getClass());
    }
}

定义Service类:

@Service
public class MessageService {
    ExecutorService executorService = Executors.newFixedThreadPool(500);

    @AsyncTimed
    public CompletableFuture<String> getMessageWithDefaultPool() {
        return CompletableFuture.supplyAsync(() -> worker());
    }

    @AsyncTimed
    public CompletableFuture<String> getMessageWithCustomPool() {
        return CompletableFuture.supplyAsync(() -> worker(), executorService);
    }

    public String worker() {
        try {
            Thread.sleep(500L);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "result is ready";
    }
}

Controller类:

@RestController
@RequestMapping("message")
public class MessageController {
    @Autowired
    private MessageService messageService;

    @GetMapping("/custom-pool")
    public CompletableFuture<String> getResultAsyc() {
        log.info("Try to execute request with custom pool: {}", Thread.currentThread().getName());
        return messageService.getMessageWithCustomPool();
    }

    @GetMapping("/default-pool")
    public CompletableFuture<String> getResultAsycWithDefaultPool() {
        log.info("Try to execute request with default pool: {}", Thread.currentThread().getName());
        return messageService.getMessageWithDefaultPool();
    }
}

我们尝试调用API: http://localhost:8080/message/custom-pool

image.png

可以看到在Controller层用的是http-nio线程池。而在返回结果的时候使用了我们自已定义的线程池。

而使用默认线程池时,返回结果用的是ForkJoinPool的commonPool:
image.png

如果我们并发40个请求数去访问,可以看到并发数最高还是7个:
image.png

而自定义核心线程数为500时,是可以充分利用线程池中的线程的:
image.png
尝试扩大ForkJoinPool的并发数
ForkJoinPool forkJoinPool = new ForkJoinPool(500);

这时候测试请求处理时间在500ms,并发数为800,结果为平均每个request花费时间为837.612ms。
而在#1.2中使用自定义核心线程数为500时,同样条件下的花费时间:836.737ms。

可以看到异步请求,瓶颈还是在线程池中的线程数。

4. 和WebFlux对比

参考:https://www.baeldung.com/spring-mvc-async-vs-webflux

加入依赖:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

写一个测试:

    @GetMapping("/flux_result")
    public Mono getResult() {
        Mono mono = Mono.defer(() -> Mono.just("Result is ready"))
                .delaySubscription(Duration.ofMillis(500));
        return mono;
    }

第一章#1.2的测试加上Webflux,可以看出在高延时、高并发的情况下,Webflux还是有优势的:
image.png

后续对Webflux,500ms延时,1000个用户和2000个用户下做了测试,平均每个request耗时分别是535.087ms和551.254ms,还是比较稳定的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,657评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,889评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,057评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,509评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,562评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,443评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,251评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,129评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,561评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,779评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,902评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,621评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,220评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,838评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,971评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,025评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,843评论 2 354

推荐阅读更多精彩内容

  • 一、异步请求 1.1 同步请求与异步请求 首先看一下同步请求的线程执行模型: 接着看一下异步请求的线程执行模型: ...
    GeekerLou阅读 1,098评论 0 0
  • 前言 有时候,前端可能提交了一个耗时任务,如果后端接收到请求后,直接执行该耗时任务,那么前端需要等待很久一段时间才...
    Whyn阅读 8,280评论 0 3
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,656评论 18 139
  • Spring Web MVC Spring Web MVC 是包含在 Spring 框架中的 Web 框架,建立于...
    Hsinwong阅读 22,399评论 1 92
  • 异步调用介绍 异步调用异步调用就是在不阻塞主线程的情况下执行高耗时方法 常规异步通过开启新线程实现 在Spring...
    weisen阅读 4,280评论 0 15