除了Spring的依赖注入仅解决控制反转问题的1/5之外,Spring Reactive还基于事件循环。 尽管还有其他流行的事件循环驱动解决方案(NodeJS,Nginx),但单线程事件循环是每个请求线程(线程池)朝另一个方向摆动。 在事件循环与每个请求线程竞争的情况下,是否没有某种模式可以使它们成为基础? 好吧,实际上是的!
但是在开始之前,让我们看一下有关事件循环和每个请求线程的问题。 如果您对该解决方案更感兴趣,则可以跳过接下来的两个部分。
螺纹连接问题
事件循环
首先,“线程耦合”? 为什么要担心? 对于事件循环来说,单线程本质要求所有I / O都必须异步进行。 如果需要阻止数据库或HTTP调用,它将阻止单个事件循环线程并支撑系统。 这种限制本身就是一个很大的耦合问题,因为要使Reactive将所有I / O耦合到异步状态。 这意味着不再需要像JPA这样的ORM来简化对数据库的访问(因为JPA需要阻止数据库调用)。 是的,以前在应用程序中删除了40-60%的样板代码的东西现在已经不可用了(请重新写一遍!)
除了决定使用响应式模式的限制性I / O之外,还限制了使用多个处理器的能力,因为只有一个线程。 好的,反应式引擎的实例已复制到每个CPU,但是它们不能共享状态。 在两个事件循环之间共享状态的多线程含义很困难。 响应式编程非常困难,更不用说向其中添加多线程了。 是的,事件循环之间的通信可以通过事件进行。 但是,使用此方法在事件循环之间使共享状态的重复副本保持同步会产生一些可以避免的问题。 基本上,您会被告知要设计您的反应性系统,以免发生这种情况。
因此,您被卡在一个线程上。 所以呢? 好吧,如果您执行计算量大的操作(例如安全密码学(JWT)),则会产生调度问题。 通过在单个线程上,必须先完成此操作,然后才能执行其他任何操作。 使用多个线程,操作系统可以在时间上切入其他线程,以处理其他占用较少CPU资源的请求。 但是,您只有一个线程,因此所有可爱的操作系统线程调度现在都丢失了。 在维修其他任何东西之前,您都不得不等待昂贵的CPU密集型操作完成。
哦,请忽略这些问题! 我们开发人员喜欢性能。 响应式的所有目的都是为了提高性能和改善可伸缩性。 较少的线程可以减少开销,从而提高吞吐量。 好的,是的,我将拥有性能更好的生产系统,从而可能降低硬件成本。 但是,由于来自单线程事件循环的耦合限制,构建和增强该生产系统的速度将大大降低。 更不用说,必须重写算法才能避免占用CPU。 与缺乏足够的云硬件供应相比,由于开发人员稀缺,因此争论规模成本可能仅适用于那些罕见的大型系统。
我们会做出很多反应。 这可能是因为我们还没有充分考虑过这一点。 因此,可能是为什么Reactive框架警告不要更改整个销售。 它们通常指示响应模式仅适用于较小且较不复杂的系统。
每个请求线程(线程池)
另一方面,每个请求线程模式(例如Servlet 2.x)使用线程池来处理扩展。 它们分配一个线程来服务请求,并通过具有多个(通常是池化的)线程进行扩展。
我们可能会读到许多文章称Reactive超出了每个请求线程的规模限制,但是每个请求线程的主要问题实际上不是性能,也不是规模。 每个请求线程的问题在您的应用程序中更为宽松,实际上会污染整个体系结构。
要查看此问题,只需看一下调用方法:
Response result = object.method(identifier);
该方法的实现应如下:
@Inject Connection connection; @Inject HttpClient client; public Result method(Long identifier) { // Retrieve synchronous database result ResultSet resultSet = connection.createStatement() .executeQuery( "<some SQL> where id = " + identifier); resultSet.next(); String databaseValue = resultSet.getString( "value" ); // Retrieve synchronous HTTP result HttpResponse response = client.send( "<some URL>/" + databaseValue); // Return result requiring synchronous results to complete return new Result(response.getEntity()); }
这给请求的线程带来了一个耦合问题,可能会污染整个体系结构。 是的,您刚刚在请求线程上放置了一个耦合到其他系统。
当数据库调用是同步的时,HTTP调用也迫使下游系统同步响应。 我们不能将HTTP调用更改为异步调用,因为请求线程希望继续执行从该方法返回的结果。 与请求线程的这种同步耦合不仅限制了调用,还限制了下游系统必须提供同步响应。 因此,每个请求线程的线程耦合可能会污染您的其他系统,甚至可能污染整个体系结构。 难怪同步HTTP调用的REST微服务模式如此流行! 这是一种迫使自己自上而下地在系统上的模式。 听起来像每个请求线程和Reactive在强制一切自上而下支持自己方面都持有相同的观点。
支持I / O的线程
总之,问题如下。
单线程事件循环:
- 仅将您耦合到异步通信(不再提供简单的JPA代码)
- 只是避免了多线程,因为从事件队列执行事件的两个线程会产生大量的同步问题(可能会降低解决方案的速度,并导致难以为最好的开发人员编写的并发错误)
- 失去了线程调度的优势,即操作系统已花费大量精力进行优化
而按请求线程解决方案:
- 仅将您耦合到同步通信(因为可以立即看到结果;不久后不会通过回调)
- 由于管理更多的线程,因此具有较高的开销(单线程事件循环),因此可伸缩性较差
实际上,可以考虑从同步通信(每个请求线程)到异步通信(单线程事件循环)之间的线程池和响应式单线程之间的钟摆摆动。 剩下的问题实际上是专门为支持每种类型的通信而构建的线程模型的实现约束。 加上同步通信在下游系统上造成的耦合,这种摆动到异步通信的举动并不是一件坏事。
所以问题是,为什么我们被迫只选择一种沟通方式? 为什么我们不能同时使用同步和异步通信样式?
好吧,我们不能将异步调用放入同步方法调用中。 没有机会进行回调。 是的,我们可以阻止在回调中等待,但是Reactive会认为自己在规模上具有优势,因为其中涉及额外的线程开销。 因此,我们需要异步代码来允许同步调用。
但是,我们不能将同步调用放入事件循环中,因为它会中断事件循环线程。 因此,我们需要额外的线程来进行同步调用,以允许事件循环线程继续进行其他事件。
反应性就是答案。 使用调度程序:
Mono blockingWrapper = Mono.fromCallable(() -> { return /* make a remote synchronous call */ }).subscribeOn(Schedulers.elastic());
来自http://projectreactor.io/docs/core/release/reference/#faq.wrap-blocking的代码
是的,现在我们可以在事件循环中进行同步调用了。 问题解决了(很好)。
好吧,如果您可以相信已将所有同步调用正确包装在Callables中,则会对其进行排序。 弄错了,那么您就阻塞了事件循环线程并暂停了应用程序。 至少在多线程应用程序中,只有特定请求受苦,而不是整个应用程序受苦。
无论如何,对我而言,这似乎比实际解决问题更多的工作。 哦,等等,一切都需要自下而上地进行反应,这样才能解决此问题。 只是不要阻塞呼叫,而是将所有驱动程序和整个技术堆栈更改为Reactive。 总体而言,“以一种仅与我们集成的方式改变一切以适合我们的方式”似乎非常接近技术供应商的锁定-无论如何,我认为。
因此,我们可以考虑一个允许同步调用并且不非常依赖开发人员正确实现的解决方案吗? 为什么是!
反转螺纹联轴器
异步通信驱动的Reactive单线程事件循环(不好意思)被认为是正确的解决方案。 开发人员使用调度程序解决了同步通信。 在这两种情况下,Reactive函数都使用为其指定的线程来运行:
- 异步函数与事件循环的线程一起执行
- 通过调度程序中的线程执行的同步功能
函数执行线程的控制在很大程度上取决于开发人员能否正确执行。 开发人员有足够的精力专注于构建代码以满足功能要求。 现在,开发人员密切参与了应用程序的线程处理(每请求线程总是从开发人员那里某种程度上抽象出来的)。 对线程的这种亲密关系大大增加了构建任何Reactive的学习曲线。 另外,当开发人员在凌晨2点将其拔出时,他们会松开很多头发,以使代码在该截止日期或生产修复中正常工作。
那么我们可以从必须正确执行线程的工作中删除开发人员吗? 更重要的是,我们在哪里控制选择线程?
让我们看一个简单的事件循环:
public interface AsynchronousFunction { void run(); } public void eventLoop() { for (;;) { AsynchronousFunction function = getNextFunction(); function.run(); } }
好吧,我们唯一可以控制的对象就是异步函数本身。 使用Executor指定线程,我们可以如下增强事件循环:
public interface AsynchronousFunction { Executor getExecutor(); void run(); } public void eventLoop() { for (;;) { AsynchronousFunction function = getNextFunction(); function.getExecutor().execute(() -> function.run()); } }
现在,这允许异步函数指定其所需的线程,如下所示:
- 通过同步执行器使用事件循环线程:getExecutor(){return(runnable)-> runnable.run(); }
- 通过线程池支持的Executor使用单独的线程进行同步调用:getExecutor(){return Executors.newCachedThreadPool(); }
控件被反转,以便开发人员不再负责指定线程。 该函数现在指定用于执行自身的线程。
但是,我们如何将执行程序与功能关联?
我们使用控制反转的ManagedFunction :
public interface ManagedFunction { void run(); } public class ManagedFunctionImpl implements ManagedFunction, AynchronousFunction { @Inject P1 p1; @Inject P2 p2; @Inject Executor executor; @Override public void run() { executor.execute(() -> implementation(p1, p2)); } private void implementation(P1 p1, P2 p2) { // Use injected objects for functionality } }
请注意,仅包含相关的ManagedFunction详细信息。 请参阅(耦合)控件的反转以获取ManagedFunction的更多详细信息。
通过使用ManagedFunction,我们可以将Executor与增强事件循环的每个函数相关联。 (实际上,由于Executor封装在ManagedFunction中,因此我们可以返回到原始事件循环)。
因此,现在不再需要开发人员使用调度程序,因为ManagedFunction负责使用哪个线程来执行函数的逻辑。
但这只是将开发人员从代码正确配置到配置的问题。 在为函数指定正确的线程(执行程序)时,如何减少开发人员的错误?
确定执行线程
ManagedFunction的一个属性是所有对象都被依赖注入。 除非注入了依赖项,否则没有对系统其他方面的引用(强烈建议不要使用静态引用)。 因此,ManagedFunction的依赖关系注入元数据提供了ManagedFunction使用的所有对象的详细信息。
了解函数使用的对象有助于确定函数的异步/同步性质。 要将JPA与数据库一起使用,需要一个Connection(或DataSource)对象。 要对微服务进行同步调用,需要HttpClient对象。 如果ManagedFunction不需要这些,则可以安全地考虑没有进行阻塞通信。 换句话说,如果ManagedFunction没有注入HttpClient,则它将无法进行HttpClient同步阻塞调用。 因此,可以安全地由事件循环线程执行ManagedFunction,而不会暂停整个应用程序。
因此,我们可以识别一组依赖关系,这些依赖关系指示ManagedFunction是否需要由单独的线程池执行。 我们知道系统中的所有依赖项,因此可以将它们分类为异步/同步。 或更恰当地说,是否可以在事件循环线程上安全使用依赖项。 如果依赖关系不安全,则需要该依赖关系的ManagedFunctions由单独的线程池执行。 但是什么线程池?
我们只使用一个线程池吗? 好吧,响应式调度程序可以灵活地为涉及阻塞调用的各种功能使用/重用不同的线程池。 因此,在使用多个线程池时,我们需要类似的灵活性。
我们通过将线程池映射到依赖项来使用多个线程池。 好的,这有点使您动脑了。 因此,让我们用一个例子来说明:
public class ManagedFunctionOne implements ManagedFunction { // No dependencies // ... remaining omitted for brevity } public class ManagedFunctionTwo implements ManagedFunction { @Inject InMemoryCache cache; // ... } public class ManagedFunctionThree implements ManagedFunction { @Inject HttpClient client; // ... } public class ManagedFunctionFour implements ManagedFunction { @Inject EntityManager entityManager; // meta-data also indicates transitive dependency on Connection // ... }
现在,我们具有以下线程配置:
相依性 | 线程池 |
HttpClient | 线程池一 |
连接 | 线程池二 |
然后,我们使用依赖关系将ManagedFunctions映射到线程池:
托管功能 | 相依性 | 执行者 |
ManagedFunctionOne, ManagedFunctionTwo | (线程池表中没有) | 事件循环线程 |
ManagedFunction3 | HttpClient | 线程池一 |
托管功能四 | 连接(作为EntityManager的传递依赖项) | 线程池二 |
线程池(执行器)用于ManagedFunction的决定现在只是映射配置。 如果某个依赖项调用了阻塞调用,它将被添加到线程池映射中。 使用此依赖项的ManagedFunction将不再在事件线程循环上执行,从而避免了应用程序暂停。
此外,大大减少了丢失阻塞呼叫的可能性。 由于对依赖项进行分类相对容易,因此遗漏阻塞调用的机会较小。 另外,如果缺少依赖项,则仅是对线程池映射的配置更改。 它是固定的,无需更改代码。 随着应用程序的成长和发展,它特别有用。 这与要求代码更改和开发人员需要认真思考的反应式调度程序不同。
由于现在由框架(而不是应用程序代码)控制执行ManagedFunction的执行线程,因此它有效地反转了对执行线程的控制。 开发人员代码不再线程化。 框架根据ManagedFunctions的依赖关系特性对其进行配置。
办公楼层
从理论上讲,这一切都很好,但是请向我展示工作代码!
OfficeFloor( http://officefloor.net )是本文讨论的线程控制模式反转的实现。 我们发现框架的线程模型过于僵化,导致变通,例如Reactive Scheduler。 我们正在寻找基础模式来创建不需要这种解决方法的框架。 可以在教程中找到代码示例,我们重视所有反馈。
请注意,尽管OfficeFloor遵循线程控制的反转,但考虑其他方面(例如,依赖关系上下文,变异状态,线程局部变量,线程亲和力,背压和减少的锁定以提高性能),其实际的线程模型更为复杂。 但是,这些是其他文章的主题。 但是,正如本文所强调的,OfficeFloor应用程序的线程是基于依赖关系映射的简单配置文件。
结论
线程的控制权反转允许函数指定它自己的线程。 由于线程是由注入的Executor控制的,因此该模式称为Thread Injection 。 通过允许注入,线程的选择由配置而不是代码确定。 这使开发人员免于将线程编码到应用程序中的潜在容易出错的错误任务。
线程注入的另一个好处是可以根据应用程序运行的计算机来定制线程映射配置。 在具有许多CPU的计算机上,可以配置更多线程池以利用操作系统的线程调度。 在较小的计算机(例如嵌入式计算机)上,可以更多地重用线程池(对于单用途应用程序,甚至有可能不使用这些线程池,因为它们可以容忍阻塞以减少线程计数)。 这将不会对应用程序进行任何代码更改,而只需进行配置更改。
此外,可能占用事件循环的计算量大的功能也可以移至单独的线程池。 只需在线程池映射中添加此计算的依赖项,所有进行该计算的ManagedFunctions现在就不会占用事件循环线程。 线程注入的灵活性不仅仅是支持同步/异步通信。
由于线程注入全部由配置驱动,因此不需要更改代码。 实际上,开发人员根本不需要任何线程编码。 这是反应式调度程序无法提供的。
因此,问题是,您是否想将自己绑定到单线程事件循环,而这实际上只是异步I / O的单一目的实现? 还是您想使用更灵活的东西?
翻译自: https://www.javacodegeeks.com/2019/04/spring-reactive-already-obsolete-inversion-thread-coupling.html