Spring Kotlin non-blocking practice (1): Why non-blocking

说在前面

这是我第一次开始认真写技术博客。起因有两个吧,一是因为今年给公司写engineering blog有指标,先写个中文版练练手(狗头保命)。二是在升senior之后,平时工作和思考的重心不可避免地倾向domain knowledge和platform未来的发展方向了,自己对于纯粹的system design的敏感度在下降,这并不是一个好的趋势,逼着自己做一些输出会对这种情况有所改善。

非阻塞编程是我在公司的internal sharing中讲过的一个主题。时过境迁,一年后我踩了更多的坑,造了更多的玩具,有了更多的交流,深切地体会到不同的程序员在非阻塞编程的理解和实践上是有很大的gap的。所以第一篇engineering blog也就想试着先从把自诩擅长的这部分讲好开始。

华罗庚的智慧

我与非阻塞编程的缘分,要从小学语文中读到的华罗庚先生的《统筹方法》一文说起。

从「资源」与「任务」两个角度来看,这段话可以得出如下结论:

  • 无论这些任务是如何完成的,从始至终都只有一个人(有限的资源)在试图完成任务

  • 完成耗时的任务并不需要每时每刻都占用资源

  • 每个任务的完成时间并没有发生变化

  • 从资源的角度来看,完成所有任务的耗时缩短了

所以这便是非阻塞编程的意义所在:

用有限的资源,通过合理的编排,在更短的时间内完成多项任务

当我们谈阻塞和同步时,我们谈些什么

我在写这篇文章时,特地用了「non-blocking」这个措辞,而没有用常见的「async」关键字。无论是工作还是生活中我都是力求用词精确的,所以我也希望能将「非阻塞」与「异步」这两个经常混为一谈的概念可以分开讲。

我在英文语义下花过很大力气去搜索,搜来搜去,似乎英文程序员们也没有一个业界统一的标准来精确区分这两者。对于我个人来说,我最喜欢的解释出自我还在读研时在52im上读过的一篇讲I/O的文章 ——

(这里乱码了我是没想到的)

http://www.52im.net/thread-1935-1-1.html

阻塞、非阻塞的讨论对象是调用者;同步、异步的讨论对象是被调用者。

为了试图解释这两者间的区别,我在公司internal sharing时画了一张图:

post image

作为一名web服务端开发,实际上我的服务端应用在处理请求时,「异步响应」的工作已经被现代编程框架和操作系统(Webflux, epoll, …)接手过去了,我并不需要过度操心这一部分。

难点在如何高效地处理完任务,而这一部分的本质是完成I/O密集型client应用的工作。所以,这篇文章的重点会是在non-blocking,而不是asynchronous。

Kotlin的"How" —— 协程与结构化并发

协程并不是什么新鲜的概念了,gopher们天天都在跟goroutine打得头破血流。Kotlin的协程其实就是JVM下Thread的一个简单的包装,并没有在用户态层面进行精妙的抽象和调度设计。不过Kotlin coroutine的这套api抽象确实做到了如他们宣传的“像写sync一样去写async”,比起Java的Future着实是要阳间不少的,所以用还是要用的。

真正让我对Kotlin coroutine路转粉的,是这一篇文章,以及它末尾引用的另一篇。谈结构化并发的哲学与Kotlin中的实现。

https://elizarov.medium.com/structured-concurrency-722d765aa952

https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/

Structured Concurrency
Structured Concurrency

再具体一点有什么好处呢?可以用下面这段代码来进行说明:

suspend fun kotlinStyle() {
    val startTime = Instant.now().toEpochMilli()
    println("Running on ${Thread.currentThread().name}")
    coroutineScope {
        launch {
            println("Kotlin async running on ${Thread.currentThread().name}")
            delay(1000)
        }
    }
    val endTime = Instant.now().toEpochMilli() - startTime
    println("Running on ${Thread.currentThread().name}")
    assert(endTime > 1000) # succeed
}

fun javaStyle() {
    val startTime = Instant.now().toEpochMilli()
    println("Running on ${Thread.currentThread().name}")
    val future = CompletableFuture.runAsync {
        println("Java async running on ${Thread.currentThread().name}")
        Thread.sleep(1000)
    }
    //future.get()
    val endTime = Instant.now().toEpochMilli() - startTime
    println("Running on ${Thread.currentThread().name}")
    assert(endTime > 1000) # fail
}

Kotlin中的 launch 是不需要校验函数返回结果的异步关键字,即便如此,也只有当coroutineScope中的所有异步操作结束后,代码才会执行到下一行。

Java的Future就不一样了,在Future作用域外的代码开始执行时,作用域内的代码并没有chronologically finalized。

(当然可能有人要杠了,我就是需要fire and forget怎么办?Kotlin是提供了 GlobalScope 这个抽象的,扔到这个scope下执行的异步任务lifecycle跳出三界外不在五行中, GlobalScope.launch 就是你要的fire and forget了。

https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-global-scope/

Kotlin的这种结构化并发practice,一是为理解代码执行时序减少了智识负担,二是更容易写出stateless的代码,对于天天堆I/O intensive CRUD屎山的Spring选手来说简直是福音。

I/O的"HOW" —— Java NIO, Netty

感觉这个话题都被讲烂了,在这里就不再赘述了。网上随便一搜就是一篇介绍文章。

https://tech.meituan.com/2016/11/04/nio.html

TLDR,NIO最大的意义就是可以在等待I/O返回结果的时间里,使当前工作线程让出CPU。从实现上,只要你的web project大框架是基于Netty的,这个要求就自动满足了,不需要把事情想太复杂,毕竟Spring的目的就是屏蔽细节让你无脑爽撸的。

高级编程语言抽象模型的"HOW" —— Project Reactor

这里写Project Reactor而不是Reactive Programming,是因为毕竟我只写过基于JVM世界中的这一种实现的Reactive Programming。毕竟FRP这个话题太大了,我的内功还远没修炼到可以聊paradigm level的程度。

https://projectreactor.io/

要聊Project Reactor的设计哲学,我们可以回到《统筹方法》中提到的泡茶这件事上。整个泡茶流程用Project Reactor的作图法大致可以抽象成下图:

post image

然而在实际写代码时,我们会通常遇到如下问题:

  • 执行“提供茶水”这一任务的对象(上茶者),并不是烧水的华罗庚本人

  • 上茶者不知道也不关心热水是水壶中的冷水烧开而来的

  • 上茶者不关心究竟是谁在烧热水,谁在洗茶杯,谁在拿茶叶

  • 上茶者不知道烧热水一定比洗茶杯更耗时

  • 上茶者需要知道什么时候茶叶、茶杯、热水全都准备好了

  • 每个前提条件的「准备完毕」和「被调用」之间一定存在空隙

  • 如果需要上茶者自己去确认每个前提条件是否已备好,上茶者并不知道每个前提条件究竟什么时候才准备完毕

  • 上茶者同一时间需要给多位顾客提供茶水

可以看到,似乎在高级编程语言的抽象调度上,我们遇到了和设计I/O模型时差不多的问题。于是Project Reactor应运而生,将一切都抽象成event和signal,并创造一个更高维的抽象去管理不同的事件。当事件发生时,该高维抽象积极地去抢占系统资源来执行下一步函数,从而在非阻塞等待的前提下最大程度地压缩「准备完毕」和「被调用」之间的空隙。

(To be continued