vlambda博客
学习文章列表

异步编程一:异步编程的魅力

一个故事

先来讲一个故事,在很久很久以前,当我还是一个懵懂的程序员,负责优化过公司里的鉴权网关,当时我们的架构大概是这个样子的:


image.png


当时利用tengine的鉴权模块,每次请求进来时,tengine会携带请求参数先问一下auth模块,当auth模块返回200状态才会把请求透传到业务API上面去。当时我们自定义开发的鉴权模块是图中的auth模块,基于tomcat开发。
当时的优化思路:

  • 保证auth里的逻辑尽量简单,多用缓存,少用数据库

  • 确认jvm gc没有问题

  • 调整tomcat默认线程池大小,降低上下文切换

事实证明,真正起到决定性效果的是第一点,然后是第三点,优化到最后,基本就是在不让停的实验,线程池在多大的情况下响应时间最优。
具体的数值,早已经忘记,总之最后也确定了一套配置,即某种最精简的代码逻辑下,在一个经过实验得出的最优的线程池大小情况下,在某一个95响应时间的前提下,达到了一个实例可制成的最大的一个并发,大概200~300吧,记不清楚了,再高,就需要扩节点,否则响应时间就无法保证。
后来公司里的大神号称用golang写了一版,同样的压力下,响应时间可以控制在30ms之内,并声称压测是系统上下文切换比java版本的明显降低。
上面是一个真实的经历,里面的数值已经很模糊了。
但重点不在这里,而是基于传统的tomcat来开发的web项目,在并发达到一定程度的时候,性能会急剧下降,解决这种问题只有两个思路:

  • 扩机器

  • 代码架构优化

几年前,我的团队选择了前者
又过了几年之后,我认为后者是更好的方案
现在,我觉得前者后者,只是一个权衡,回想起来当年的大神估计是经过了一番权衡,并没有那份golang的代码交给我

扩机器方案,看似low,但是对公司来讲,仅仅是多花了一些机器钱而已,代码维护成本低,初中级程序员即可玩得转;
代码架构优化这个方案,看似省了一些机器的钱,但需要招更高级的程序员进行开发和维护,这两条路适用于不同的阶段,采用哪种方案,不能只站在技术角度进行考虑。

本文主要讲异步编程,着重讲一讲代码架构优化
其实这个一个已知问题,业界早有成熟的方案,C10K问题
而解决这些问题的所有方案的目标都是在有限的物理资源情况下,支撑更多的并发,换句话讲,系统是可伸缩的,在请求并发加大的时候系统吞吐量会随之线性增长,实现高吞吐量低延迟。
这里引用一句vertx首页的描述

Eclipse Vert.x is event driven and non blocking. This means your app can handle a lot of concurrency using a small number of kernel threads. Vert.x lets your app scale with minimal hardware.

C10K

上文有提到C10K问题,权威解释建议看一看,这里我做一下"赘述"


image.png

这张图是在下原创手绘的,看图中一条一条的线,像不像一种“试纸”
图中每一条,表示一个请求要做的事情,条的长度代表请求的处理时间
红色的是代表io操作,绿色的标代表非io操作
现在的计算机,cpu内存的速度和磁盘、网络的速度不在一个数量级上,所以现实中绿色和红色的长短比例比图中画的要悬殊的多

单个接口处理登录
依次需要处理一系列的io操作和非io操作,io操作的处理时间要远远高于非io操作的处理时间,但总体请求在60ms左右可以返回

系统同时处理8个请求
这时,系统假如同时受到了8个,这时候和处理一个请求也差不多,处理时间也可以控制在60ms

系统同时处理8w个请求
图中的8w个请求的处理图,响应时间依然是60ms,这时一种很理想的状态,也是我们的优化目标
即同时处理一个请求和同时处理上万的请求,系统延迟没有降低,这便是可伸缩的系统。
如果图中的系统,后台是一个tomcat,那么别说是8w个并发,即使是2k并发,响应时间就可以用惨不忍睹来形容了

问题的根源
一个线程处理一个请求,即 per connect per thread。

tomcat的线程模型,或者说servlet的线程模型就是这种;上面的每一根“条条”代表一次请求,或者更具体讲代表一次请求背后服务器索要执行的动作;在这种模式下,服务器处理每一个请求索要做的动作 是与操作系统里的线程强绑定的,即用同一根线程从始至终的把一件任务做完,即使一件任务背后有时候是空闲的,在等待io的;如果线程是宝贵的资源,那么这一定是一种浪费。

事实上线程数真不能太多,一方面,每一个线程都需要消耗一定的内存,内存即一种瓶颈;另一方面,在海量线程状态下,CPU会存在大量的无谓的线程上下文切换,占用大量的cpu时钟,cpu看上去很忙,但都是“瞎忙”。

有人可能说了,8w个请求,加几台机器一起撑呗?
这当然也是一种解题思路,但做技术还是要有点追求的,不能永远靠扩机器解决问题,在某些阶段,某种场景,扩机器并不是最佳的解决方案。

再说一种更过分的场景,长连接的服务,比如websocket,一个服务对外提供websocket接口,同时在线8w个客户端,但是真正同时发过来的报文并不多,这种情况下,扩一堆机器,只是为了保持更多的连接,但其实机器都没有在处理真正的业务,都在做上下文切换,这未免也太说不过去了。

非阻塞IO

上面故事和问题分析,我们不停的在提到一个词 IO
那么解决这个问题的思路,也是要从IO入手
如果有了解过这方面的知识读者,会听说过如下几种IO模型:

  • 阻塞 I/O

  • 非阻塞 I/O

  • I/O 的多路复用(select 和 poll)

  • 信号驱动的 I/O(SIGIO)

  • 异步 I/O(POSIX 的 aio_functions)

但问题不必上来就讲的这么复杂,此处我按照自己的理解做一点赘述:

  • 我们的程序与io交互,无论是磁盘还是网络,都是要经过操作系统的

  • 最关键的第一步,程序与操作系统提供的io接口交互时,这个接口不能阻塞,否则程序的当前线程就死在这了,动也动不了了

  • 操作系统既然提供了非阻塞的io接口,那么接下来的问题就是,我们的程序如何感知到操作系统处理完了

  • 这当然都要依赖操作系统的实现,最好的解决方案自然是操作系统处理好了,回调一下我们的某一个接口,通知我们处理好了,程序在做下一步处理,这便是epoll了,linux体系的最佳方案

  • 还有一种不那么好的方案,程序定时轮询变量,发现哪个好了,就做接下来的处理,这便是 select 和 poll

  • 总结起来一句话,linux上,用epoll搞,就对(性价比角度)了

  • 网上有很多文章在解释什么是异步,什么是非阻塞,一般还要配合着拿餐馆举例子,在下觉得全扯淡,复杂了,容易把人绕晕了
    这里在引用耗子叔在《左耳听风》78讲里的观点

基本上来说,异步 I/O 模型的发展技术是:select -> poll -> epoll -> aio -> libevent -> libuv

耗子叔的专栏,真是经典中的经典,建议订阅

异步编程风格

什么是异步编程,举个例子:
假设我们要实现一个系统登录的逻辑,需要在网关层获取用户名密码,然后请求account服务校验用户名密码的正确性,如果正确,根据返回的用户id请求权限服务,获取用户对应的权限,并放置到用户的上下文中

同步方式的代码大概是这样写(kotlin 伪代码)

var loginResult = accountRpc.login(userName, password)
if(!loginResult.success){
throw LoginException("用户名或密码错误")
}
var permission = rbacRpc.permission(loginResult.userId)
UserContext.setContext(permission)

异步方式写代码(伪代码):

//入参 loginPromise
accountRpc.login(userName, password) {
if (!it.success){
loginPromise.failed(it.cause)
}
rbac.permission(it.userId){ permission ->
UserContext.setContext(permission)
loginPromise.success()
}
}

基于非阻塞IO进行编程,编程语言分为了两种解题思路,一种基于eventloop加回调(什么promise、reactive,说到底就是基于回调封装的一些模式),一种基于协程

eventloop + 回调
java就是典型代表, 代码逻辑都是运行在线程上的,java基于nio可以实现非阻塞IO, 基于nio需要开发者注册一堆的handler,就是回调。
nio太难用, 就有大神写了netty,netty对nio、epoll甚至bio都做了统一化的api封装,简化了java网络编程。
后来业界又推出了reactive编程范式(此处不展开,感兴趣可以看下:Reactor2-93.pdf

以上可能是基于线程来解决异步编程的已知的比较不错的方案了,还是无法避免编程上的复杂度。

协程
协程,一种更轻量级的线程,是语言层面对执行动作线程的一次解耦,此处说的是语言层面,即这个抽象是做在编程语言层面,底层基于操作系统的线程,上层在进程内基于编程语言开发了自己的一套调度策略,封装出了一个叫做协程的概念,这样做的好处是,开发者可以以同步的方式写异步的代码,典型的代表:golang、kotlin;
golang是天生支持,支持的效果会让开发者感觉不到线程的存在,反过来想,也会让开发者越来越白痴(这又是一个权衡,语言太好了,开发者就...)
jvm生态里,kotlin也号称支持协程,不过由于历史包袱的原因,开发者还是会感觉到线程和协程,比如runBlocking函数就是用来衔接线程和协程执行的, 此处贴一下函数注释:

Runs new coroutine and blocks current thread interruptibly until its completion

最近在下也一直在研究kotlin,我对这门语言还是持拥抱态度的,不只是协程,还有各种语法糖(相对于java来讲),可以减少一点开发工作量。

回到刚刚的话题往下聊,jvm体系里,能和golang相抗衡的协程方案,必须是要坐在jvm级别的,目前已知的是阿里的wisp
和openjdk的loom
这两种方案,都还没有深入了解过,不敢妄言。

异步编程的魅力

从本文最开始的一个故事开始,依次引出了c10k问题,然后又赘述了很多的解决之道,最后引出异步编程。按照这个套路来阐述,是因为本人神反感技术文档不讲解决什么问题,上来就抛出一大堆技术概念,在下喜欢从头讲故事。
现在可以聊一聊异步编程的魅力了,异步编程会比同步编程更复杂一些
基于此演进出了很多花里胡哨的技术名词,对于一个有追求的开发人员,异步编程是必须要了解和运用的一种技术;
异步编程的魅力在哪,我个人是因为对这个陌生领域一知半解的时间太久了,就是要搞定这件事情。

c10k的问题,不仅仅是异步编程就可以解决的,“不谈存储层设计的高并发,都是耍流氓”,高并发是对整个分布式系统里全链路的挑战,除非整个系统简单,无状态。

后续

后续会写一系列的文章,包括异步编程里的 promise模式、reactor模式、协程等。
这不是一篇解决具体问题的文章,是一系列需要静下心来慢慢读的文章,是关于异步编程的一些思考和总结。

后续将会针对异步编程写关于三种异步编程模式的文章:

  • promise模式
    https://www.jianshu.com/p/b3febf70c09e

  • reactor模式
    https://www.jianshu.com/p/a248205d6eb2

  • 协程
    https://www.jianshu.com/p/0d1b0c8b20ed