HTTP可能是最流行的应用程序级别协议,并且有许多库在网络I / O之上实现它,这是常规I / O的一种特殊(面向流)情况。 由于所有I / O都有很多共同点1 ,所以让我们开始对其进行一些讨论。
我将集中讨论具有大量并发HTTP请求的I / O情况,例如微服务,其中一组较高级别的HTTP服务会调用多个较低级别的HTTP服务,其中一些并发调用是由于数据依赖性而依次执行的。
当满足许多此类请求时,同时打开的连接总数有时可能会很大。 如果存在数据依赖性,或者较低级别的服务速度较慢(或由于特殊情况而减慢了速度)。 因此,微服务层往往需要许多并发的,可能长期存在的连接。 看看我们有多少打开的连接需要支持没有崩溃让我们回忆一下利特尔法则2Ψ是正在进行中的请求数,ρ是平均到达率和τ是平均完成时间的平均值:
Ψ=ρτ
我们可以支持的处理中请求的数量取决于语言运行时,操作系统和硬件; 平均请求完成时间(或延迟)取决于我们为满足请求所必须执行的操作,当然包括对任何较低级别服务的调用,对存储的访问等。
我们可以支持多少个并发HTTP请求? 每个组件都需要一个开放的连接和一些可运行的原语,这些原语可以使用syscalls对其进行读写。 如果内存,I / O子系统和网络带宽可以保持同步,则现代OS可以支持数十万个开放的TCP连接。 它们提供的用于套接字的可运行基元是线程 。 线程比套接字要重得多:运行现代操作系统的单个盒子只能支持5000-15000个线程。
从10,000英尺开始:JVM上的I / O性能
如今,JDK线程是大多数平台3上的OS线程,但是如果在任何时候只有很少的并发连接,那么“每个连接的线程”模型就可以了。
如果没有呢? 这个问题的答案随着历史的变化而改变:
- JDK 1.4之前的版本仅具有调用操作系统的线程阻塞I / O的库(
java.io
pkgs),因此只能使用“每个连接线程”模型或线程池4 。 如果您想要更好的东西,可以通过JNI来利用操作系统的其他功能。 - JDK 1.4添加了非阻塞I / O或NIO(
java.nio
包),仅在可以立即完成连接时才可以从连接读取/写入数据,而不必让线程进入睡眠状态。 更重要的是,它增加了一种方法,使一个线程可以通过套接字选择在多个通道上有效工作,这意味着要求OS阻止当前线程,并在有可能立即从至少一个套接字中立即接收/发送数据时取消阻止该线程。一套。 - JDK 1.7添加了NIO.2,也称为异步I / O(仍为
java.nio
软件包)。 这意味着要求操作系统仅在I / O完成后才在后台完全执行I / O任务,并在稍后唤醒带有通知的线程。
从JVM调用HTTP
JVM提供了多种开源HTTP客户端库。 线程阻塞API易于使用和维护,但对于许多并发请求而言可能效率较低,而异步请求有效但较难使用。 异步API也会通过异步对代码产生病毒影响:消耗异步数据的任何方法本身都必须是异步的,或者阻止并抵消了异步的优势。
以下是Java和Clojure的开源HTTP客户端的选择:
- Java
-
JDK的
URLConnection
使用传统的线程阻塞I / O。
- Clojure
-
clj-http包装Apache HTTP客户端。
从10,000英尺开始:轻松
由于Java线程占用大量资源 ,因此,如果要执行I / O并扩展到许多并发连接,则必须使用NIO或异步NIO。 另一方面,它们很难编码和维护。 有解决这个难题的方法吗?
如果线程不繁重,我们只能使用直接的阻塞I / O,那么我们的问题确实是: 我们是否可以拥有足够便宜的线程 ,并且可以创建比OS线程大得多的线程?
目前,JVM本身不提供轻量级线程,但Quasar借助纤维 (在用户空间中实现的非常有效的线程)来进行救援。
从JVM调用HTTP
Comsat将现有的某些库与Quasar纤维集成在一起。 Comsat API与原始API相同,“ HTTP客户端”部分 )说明了如何将其挂钩; 其余的只需确保您正确运行Quasar ,在需要执行新的HTTP调用时启动光纤,并使用以下一个(或多个)光纤阻塞API(或从模板和示例中汲取灵感:
- Java的 :
-
Apache HTTP客户端 API的广泛子集,通过桥接异步者集成。
- Clojure :
-
clj-http API的广泛子集,通过桥接
http-kit
的异步API进行集成。
可以轻松添加新的集成 ,当然也总是欢迎您提供帮助。
JBender的一些负载测试
jbender是Pinterest基于Quasar的网络负载测试框架。 它高效而灵活,但是由于Quasar光纤阻塞,其源代码很小且可读性强。 使用它就像使用传统的线程阻塞I / O一样简单。
考虑这个项目 , 该项目建立在JBender之上,并以少量代码为所有Comsat集成库提供HTTP负载测试客户端,无论是其原始线程阻塞版本还是Comsat的光纤阻塞版本。
JBender可以使用任何(普通,重量级,OS)线程或光纤来执行请求,它们均被Quasar抽象到一个称为Strand
的共享抽象类中,因此线程阻塞和光纤阻塞版本共享HTTP代码:这证明了Comsat集成的API与原始API完全相同,并且光纤和线程的使用方式完全相同。
负载测试客户端接受参数以自定义其运行的几乎每个方面,但是我们将考虑的测试案例如下:
- 以可能的最高速率触发了41000个寿命长的HTTP连接。
- 执行10000个请求(加上1000个初始客户端和服务器预热),每个请求持续1秒,目标速率为1000 rps。
- 执行10000个请求(加上1000个初始客户端和服务器预热),每个请求持续100毫秒,目标速率为10000 rps。
- 执行10000个请求(加上1000个初始客户端和服务器预热),并立即做出答复,目标速率为100000 rps。
所有测试均针对运行Dropwizard的服务器触发,该服务器经过优化,可在HTTP服务器端使用带comsat-dropwizard
以实现最大的并发性。 服务器仅用“ Hello!”答复任何请求。
以下是有关我们的负载测试环境的一些信息:
第一个重要的结果是, 基于Comsat的客户端在不使用光纤模式的情况下赢得了成功 。 Apache用于许多持久连接,而OkHttp用于许多短期请求,这些请求具有很高的目标速率,堆的大小也较小(分别为990 MiB和3 GiB,为简洁起见仅显示第一个):
OkHttp在快速请求的速度和内存利用率方面表现出色。 JVM的光纤版本使用异步API,并且即使底层机制是线程池提供的传统阻塞I / O,其性能也显着提高。
更令人印象深刻的是基于http-kit
的光纤阻塞comsat-httpkit
击败传统clj-http
客户端的方法(仍然显示出很小的堆):
也有其他Jersey提供程序(Grizzly,Jetty和Apache),但Jersey证明是最差的,其占用空间通常更大,并且异步接口(由Comsat的光纤阻塞集成使用)不幸地为每个线程生成并阻塞了一个线程。每个请求; 由于这个原因(可能还取决于每个提供商的实施策略),光纤版本有时会提供明显的性能优势,而有时却没有。 无论如何,这些数字并不像Apache,OkHttp和http-kit那样有趣,因此我不在这里包括它们,但是请让我知道您是否希望看到它们。
(可选)从100 <10,000英尺开始:有关JVM上I / O性能的更多信息
因此,您想知道为什么在高并发情况下光纤比线程更好。
当只有少数并发套接字打开时,OS内核可以以非常低的延迟唤醒被阻塞的线程。 但是OS线程是通用的,并且在许多用例中会增加可观的开销:它们消耗大量内核内存用于簿记,同步syscall可能比过程调用慢几个数量级, 上下文切换昂贵 ,并且调度算法过于笼统。 。 所有这些都意味着,对于具有大量通信和同步的细粒度并发,或者对于一般来说高度并发的系统6而言,当前OS线程并不是最佳选择。
阻止I / O系统调用确实可以无限期地阻止昂贵的OS线程,因此,当您为大量并发连接提供服务时,“每个连接线程”方法将很快导致系统崩溃。 另一方面,使用线程池可能会使“可接受的”连接队列溢出,因为我们无法保持到达速度或至少导致不可接受的延迟。 相反,“每连接光纤”方法是完全可持续的,因为光纤非常轻便。
总结一下 :线程可以通过较少的并发连接来改善延迟,而光纤可以在有许多并发连接的情况下改善吞吐量。
当然,光纤需要在活动的OS线程之上运行,因为OS对光纤一无所知,因此Quasar在线程池上调度了光纤。 Quasar只是一个库,并且完全在用户空间中运行,这意味着执行syscall的光纤将在整个调用持续时间内阻塞其底层的JVM线程,从而使其他光纤无法使用它。 这就是为什么这样的调用要尽可能短的原因,尤其是它们不应该等待很长时间甚至无限期地等待是很重要的:在实践中,光纤应该只执行非阻塞的系统调用。 那么,如何使阻塞的HTTP客户端在光纤上运行得如此好呢? 由于这些库还提供了非阻塞(但不方便)的API,因此我们将该异步API转换为光纤阻塞的API,并使用它来实现原始的阻塞API。 新的实现(非常简短,只不过是一个包装器)将:
- 阻止当前的光纤。
- 启动等效的异步操作,然后传入完成处理程序,该处理程序将在完成后取消阻塞光纤。
从光纤(和程序员)的角度来看,当I / O完成时,执行将在库调用之后重新开始,就像使用线程和常规线程阻塞调用时一样。
包起来
使用Quasar和Comsat,您可以使用Java,Clojure或Kotlin轻松编写和维护高度并发且HTTP密集的代码,甚至可以选择自己喜欢的HTTP客户端库,而无需任何API锁定。 您还想使用其他东西吗? 让我们知道,或者自己将其与Quasar集成。
- …还有很多不同之处,例如文件I / O(面向块)支持内存映射的I / O,这与面向流的I / O无关。
- 阅读此博客文章以进一步讨论。
- 在1.2之前(只有) Green Threads时不是这样。
- 使用线程池意味着专用或有限数量的线程(或池 )来完成某种类型的任务,在这种情况下,服务于HTTP请求:进入的连接被排队,直到池中的线程可以为它服务(如顺便说一句,“连接池”是完全不同的,并且最常见的是重用数据库连接。
- 请查看此介绍以获得更多信息。
- 例如,如果要了解有关实现光纤的原因和方式的更多信息,请阅读this , this和this以获得更多信息和基准,以及在ZeroTurnaround RebelLabs博客上的客座帖子 。
翻译自: https://www.javacodegeeks.com/2015/12/high-concurrency-http-clients-jvm.html