HTTP 协议可能是现在 Internet 上使用得最多、最重要的协议了,越来越多应用程序需要直接通过 HTTP 协议来访问网络资源。一般的情况下我们都是使用浏览器来访问一个 WEB 服务器,用来浏览页面查看信息或者提交一些数据、文件上传下载等等。不通过浏览器来访问服务器的资源呢?一种常见的场景是,通过向另一个 http 服务器发送请求,获得数据。最常规的做法是使用同步 http 请求的方式,即下文展示的同步模式。
场景 1:向服务端 URL_STR 提交了 nixian ,handsome 的账户密码,并且附件了名称为 LOCALFILE 说明文档。下文以及代码实现无特别说明参照该场景,模式演进和原理无特别说明以 Apache HTTP Client 的解决方案作为参考。
在追求高性能 HTTP 客户端的实践中,我们从同步模式,异步模式演进到极致模式,并开源了极致模式客户端框架 besthttp,在“场景 1”的检测中,其性能 10+ 倍优于同步模式。
<input type="text" name="userName" value="nixian">
<input type="text" name="password" value="handsome">
<input type="file" name="fileBody" value="LOCALFILE">
客户端发送 http 请求是串行的,也就是第一个请求未完成的情况下,第二个请求发不出去,请求按照提交的顺序发送给服务端。引入多线程提高并发性,然而多线程对并发发送 http 请求的性能提升也是有限的,比如 10 个线程同时只能发送 10 个请求,假如每个请求从发送到得到结果的时间是 1 秒,那么 10 个线程每秒钟也只能发送 10 个请求,而线程数量收到系统资源的约束,因此多线程并不能很好的解决客户端高并发发送请求的问题。
即使把多线程转换成线程池的模式,那么使用同步 http 时,在高并发场景下,线程池的队列中会堆积大量请求任务发不出去,而被请求的目标服务器,却还远没有达到瓶颈。
另外,对于发送请求的主体是阻塞的,即使与响应请求无依赖的逻辑也被阻塞。想要让发送请求与无关联的逻辑并行,需要开辟新的线程,这与上面的情况类似,线程的控制增加了代码维护难度和系统不稳定的风险。
异步模式可以帮助构建高性能的客户端,数据通过更快和无阻塞方式的发送服务端,逼近服务端处理的能力,系统性能的上限由客户端转移至服务端,而服务端通常有更好的扩展性。异步模式提供重要的措施一是无阻塞,另外一个措施是是零拷贝。如何实现无阻塞,目前大部分异步模式的组件使用非阻塞 I/O 模型。同步模式下的业务线程负责发送 ->阻塞等待 ->返回 ->业务处理,异步模式的 I/O 模型固定的 IO 线程负责发送 / 接受响应,业务线程负责业务逻辑,清晰的分工意味更高效的配合。
IO 线程委托给操作系统调度,成千上万连接可以同时进行,充分利用系统资源和原本等待返回的时间,而不是空等啥都干不了。同步模式中请求的发送的逻辑是把数据包按字节顺序的发送到网络和物理线路上,异步模式注册“写就绪”事件立即返回。http 结果,以“读就绪”事件的形式触发回调函数的响应,发送数据和处理结果是两个不同的分支,处于不同的线程上下文。事件的触发由操作系统内核同时对多个 TCP 连接监听,对已经就绪的写读事件经过分发器把读写事件源分发给 IO 线程。
发送请求和响应处理处于不同的线程上下文,主体发送完数据后,与响应没有依赖关系的业务逻辑可以方便地与响应处理并行,省去了新线程的创建,避免了系统资源峰谷变化之间的不稳定。与同步模式下 BIO + 线程池的模型相比,无限的连接的发送 / 接受可以被有限的线程处理,不但大量的并发连接得以处理,数据传输的效率也更高效。客户端可以在 1 秒内同时将 10 个请求发出,在处理其他业务逻辑同时处理响应的结果,如果服务端能顺利地同时处理 10 个请求,整个系统的吞吐率提高了 10 倍。
零拷贝依赖操作系统提供的“更合理的系统调用”,JAVA NIO 支持对通道和缓存的操作,在 sun.nio.ch.FileChannelImpl 提供了零拷贝的 API 。使用 FileChannel.transferTo() 方法节约了内核空间 / 用户空间的频繁切换和 CPU 拷贝,基于不同的系统内核和基础组件的实现,节约了两次以上的 CPU 拷贝。
使用异步模式组件 Apache HttpAsync Client 时有几个问题:
复杂场景功能上的缺失:如无法直接的使用 FORM 表单提交功能等;
未对内存有效的控制;
功能与性能无法兼顾:使用高性能 org.apache.http.nio.entity.NFileEntity 高效传输文件时无法用键值对表示发送内容。org.apache.http.nio.entity.NFileEntity 是 FileChannel.transferTo() 在 HTTP 交互场景的扩展,文件内容由硬盘直接传输至网卡。
编程模型复杂:异步编程模型为了获取方法异步执行的结果,在调用后返回 future 作为程序 “交互界面”,future 抽象类定义了获取结果,是否完成交互功能等。与 future 对应的 futurecallback 是对返回的结果正确和异常的处理。多个 future 存在依赖关系时,需要级联回调与之对应的 futurecallback。HttpAsyncClient 使用 BasicFuture 作为 future 与 futurecallback 的衔接,每两个 future 的依赖需要三个对象来维系,具体场景会表现得更加复杂。
极致模式提倡将连接,线程,内存等资源池化管理,减少系统运行时创建资源带来的性能损耗,降低开发员管理资源的风险。作为最亲近操作系统的 HTTP 客户端框架模式,可以在内存,传输路径选择适合不同场景的最佳组合,追求极致的性能表现。
极致模式继承了异步模式的所有优点,同时对提及的问题做了极致优化。在异步模式基础上,使用内存池加强对内存的缓存和回收,同时支持对堆内内存,直接内存以及自定义内存的托管。HTTP 交互有丰富且复杂的场景,不同场景之下选择不同的内存控制方案结合“拷贝”最少路径进一步提高场景方案的数据传输效率。内存自定义比较普遍存在于接受 HTTP 响应时,无法预先知晓报文的大小,既要对内存缓存,又要支持内存的自动扩张以容纳不可预料的内容,在内容使用后回收。另外一个优势是,将内存资源和连接资源进行解耦,报文数据被托管缓存后就立即释放连接,避免不及时消费导致内存和连接相互纠缠而泄露。
无论同步模式还是异步模式,Apache HTTP 解决方案体系都提供了对连接池的支持。连接池有助于提高网络资源的使用效率,但也引来一个极具挑战性的问题,即可能从池中获取一个不可用的连接。相对自身可控的关闭,服务端的单方面的关闭和网络异常显得随机很多,从池中获取的连接不可用的概率也随之增大。所以选择连接池化时,会单独开辟线程来检测所有连接的可用性,淘汰不可用的连接。
但这远远不够,一方面因为检测线程执行的间歇性会有一定的滞后,另一方面刚分配的可用连接下一刻也可能随机不可用了。极致模式如何进一步提供连接的可用性?在获取连接后与发送前的时间点上,会再一次检查连接的可用性,发现连接不用时,重新申请新的连接作为该连接的副本,可用副本会替换不可用的连接做接下来的事情,完成整个交互流程后所有副本和第一次获取的连接作为整体共同释放。正是对网络资源这样的管理,使得连接几乎达到 100% 的可用性,同时避免因为连接不可用导致整个业务的流程的阻塞或者将重试交给业务代码。
同步模式下会将文本内容和文件内容无差别地加载至堆内内存,经发送缓冲区传输到物理网络链路。文件经过磁盘加载至内核空间,再将内核空间的数据拷贝到程序运行空间,经过程序再次拷贝回内核空间,交给网卡发送到物理链路,如果文件内容比较大,这是个很耗时间和空间的过程。幸运的是,操作系统提供一些更合理的系统调用,将文件内容从磁盘直接发送到发送缓冲区,避免冗余的数据拷贝。
同样,文本内容也存在一些“性能损失”,发送的首部时,有个首部 Content-Length:16700,为了计算整个发送报文的长度,只能把全部内容提前加载的内存累加统计。当首部发送完,内容体正式发送时,需要再次把文本的内容加载到内存。将其中的部分内容进行缓存,内容体发送时,直接从缓存中获取,对整个发送报文,是一个混合数据流,针对性质不同的内容扩展不同的优化方式,充分发挥不同的流传输的优点,目的都是尽可能地减少数据的拷贝,在“场景 1” 中有四种分别来自缓存流,文件流,内存流的数据源。
同步模式网络交互和业务逻辑都是阻塞的;异步模式网络交互是非阻塞的,业务逻辑通过扩展 future 实现异步化,当业务逻辑本身存在多个层次依赖时,代码组织变得很复杂。为了简化,常常阻塞网络获得响应结果,同时牺牲了性能。为了追求业务逻辑的足够异步化同时使编程模型简单,极致模式定义了网络交互的事件链模型。一个层次的业务逻辑对应一个 Call,网络交互返回唯一的 Back,Back 提供两个 API :thencall ,thcallAsync 。thencall 自动将其内部逻辑封装成 Call 并且触发执行或者由 Back 触发执行。thcallAsync 切换线程的上下文,将 Call 的内部逻辑交给内置线程池完成。事件链的模型使得编码更加轻松,开发人员只关心内部逻辑的实现而不关心处于何种状态和模型下,更重要的是事件链的模型更好的保持了 NIO 的异步性,使得业务逻辑成为 TCP 双管道特性的延伸。
极致的性能不只是发挥 TCP 性能的优势,还结合了上层逻辑并发控制。模型主要定义两种之间的关系:位置关系和依赖关系。如上图,Back 是 Call A ,Call D 的依赖 Call,Call A 是 Call B,Call C 的依赖 Call。Call D 是 Call E 的前驱 Call,是一种位置关系。Call A 可以与 Call D 并行,Call B 与 Call C 可以并行,Back 的完成可以驱动 Call A,D。位置关系影响 Call 执行的顺序,依赖关系是 Call 执行的条件。事件链模型本质是并发控制的框架,赋予获取网络结果后的业务逻辑的并发和传导能力。无网络交互的场景下也可以使用事件链模型,这对整个系统的代码维护很有帮助。
文章代码主要使用组件的 MAVEN 坐标
【同步模式】
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.6</version>
【异步模式】
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpasyncclient</artifactId>
<version>4.1.4</version>
【极致模式】
<groupId>io.github.nixiantongxue</groupId>
<artifactId>nio-http</artifactId>
<version>0.1.23-release</version>
作者简介:
尼先,苏宁 IT 总部,高级架构师, 在线办公产品矩阵技术负责人。关注技术理论和实践持续探讨以及云时代企业信息流协作,安全,重塑的产品变革。
点击底部阅读原文访问 InfoQ 官网,获取更多精彩内容!
InfoQ 100 位优质创作者签约计划第二季火热进行中!欢迎各位同学踊跃报名~ 签约豪华大礼包、专属身份标志、百万流量扶持等好礼,等您来拿!
点个在看少个 bug 👇