美文网首页异步编程异步编程
异步编程一:异步编程的魅力

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

作者: 青_雉 | 来源:发表于2020-01-18 22:46 被阅读0次

    一个故事

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


    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体系的最佳方案

    • 其实还有更好的方案,操作系统处理好了之后,把io处理的结果,放到我们程序需要的指定的内存地址,对我们程序来讲就是操作系统把准备好的数据,赋值给我们指定的变量,程序拿来即用, 真正的异步IO, 也就是AIO,目前linux是没有实现,windows有,server一般都跑在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模式
    异步编程三:reactor模式
    异步编程四:协程

    相关文章

      网友评论

        本文标题:异步编程一:异步编程的魅力

        本文链接:https://www.haomeiwen.com/subject/esscnctx.html