rxjava3.0 之 背压 Backpressure

899 阅读13分钟

rxjava3.0 之 背压 Backpressure

背压是指在可流动处理管道中,有些异步阶段不能足够快地处理这些值,因此需要一种方法让上游生产者放慢速度。

需要背压的经典情况是当生产者是一个hot source:

   PublishProcessor<Integer> source = PublishProcessor.create();

    source
    .observeOn(Schedulers.computation())
    .subscribe(v -> compute(v), Throwable::printStackTrace);

    for (int i = 0; i < 1_000_000; i++) {
        source.onNext(i);
    }

    Thread.sleep(10_000); 

​ 在本例中,主线程将为在后台线程上处理它的最终消费者生成100万个条目。compute(int)方法可能需要一些时间,但Flowable操作符链的开销也可能增加处理项所需的时间。然而,使用for循环的生成线程不知道这一点,并保持onNexting。

​ 在内部,异步操作符有缓冲区来保存这些元素,直到它们可以被处理。在经典的Rx中.NET和早期的RxJava,这些缓冲区是无界的,这意味着它们可能包含示例中几乎所有的100万个元素。例如,当程序中有10亿个元素或相同的100万个序列出现1000次时,问题就开始了,这会导致OutOfMemoryError,并且由于GC开销过大,通常会导致速度变慢。

​ 类似于错误处理成为一级公民并接收处理它的操作符(通过onErrorXXX操作符),backpressure 是程序员必须考虑和处理的数据流的另一个属性(通过onBackpressureXXX操作符)。

​ 除了上面的publishprocessor,还有其他操作符不支持反压,主要是由于功能原因。例如,操作间隔周期性地释放值,反压它将导致相对于壁钟的周期漂移。

​ 在现代RxJava,多数异步操作符现在都有一个有边界的内部缓冲区,比如上面的observeOn,任何试图溢出这个缓冲区的行为都会用MissingBackpressureException终止整个序列。每个作业者的文档都有关于其背压行为的描述。

​ 然而,反压力在常规冷序列中更微妙地存在(这不会也不应该产生MissingBackpressureException)。如果第一个例子被重写:

Flowable.range(1, 1_000_000)
.observeOn(Schedulers.computation())
.subscribe(v -> compute(v), Throwable::printStackTrace);

Thread.sleep(10_000);

​ 没有错误,一切运行顺利,内存使用量小。这样做的原因是,许多源操作符都可以根据需要“生成”值,因此操作符observeOn可以告诉observeOn缓冲区一次最多可以保存多少个值而不会溢出。

这个谈判是基于计算机科学的协同例程概念(I call you, you call me).操作符范围以org.reactivestreams.Subscription接口的实现形式发送回调,通过调用它的(内部订阅服务器的)onSubscribe来调用observeOn。作为返回,observeOn调用Subscription.request(n),并给出一个值,告诉它允许生成的范围(也就是onNext)有多少额外的元素。然后,observeOn负责在正确的时间用正确的值调用请求方法以保持数据流动而不是溢出。

​ 在终端消费者中表示反压力很少是必要的(因为他们与直接的上游是同步的,而反压力是由于调用堆栈阻塞而自然发生的),但它的工作方式可能更容易理解:

 Flowable.range(1, 1_000_000)
    .subscribe(new DisposableSubscriber<Integer>() {
        @Override
        public void onStart() {
            request(1);
        }

        public void onNext(Integer v) {
            compute(v);

            request(1);
        }

        @Override
        public void onError(Throwable ex) {
            ex.printStackTrace();
        }

        @Override
        public void onComplete() {
            System.out.println("Done!");
        }
    });

在这里,onStart实现指示生成第一个值的范围,然后onNext接收该值。一旦compute(int)完成,就会从range请求另一个值。 在range的简单实现中,这样的调用会递归地调用onNext,导致StackOverflowError,这当然是不可取的。

为了防止这种情况,操作符使用所谓的蹦床逻辑来防止这种可重入调用。在range的条件下,它将记住在调用onNext()时有一个request(1)调用,一旦onNext()返回,它将进行另一轮并使用下一个整数值调用onNext()。如果两者互换,示例仍然是相同的:

 @Override
    public void onNext(Integer v) {
        request(1);

        compute(v);
    }

然而,对于onStart却不是这样。尽管可流动基础结构保证在每个订阅服务器上最多调用一次,他的request(1)可以立即触发一个元素的发射。

如果在onNext调用request(1)之后有初始化逻辑,你可能会出现异常:

 Flowable.range(1, 1_000_000)
    .subscribe(new DisposableSubscriber<Integer>() {

        String name;

        @Override
        public void onStart() {
            request(1);

            name = "RangeExample";
        }

        @Override
        public void onNext(Integer v) {
            compute(name.length + v);

            request(1);
        }

        // ... rest is the same
    });

在这种同步情况下,一个NullPointerException将在仍然执行onStart时立即抛出。如果调用request(1)在其他线程上触发了对onNext的异步调用,并且在onNext中读取名称与在onStart post request中写入名称竞争,则会发生一个更微妙的错误。

因此,应该在onStart中或甚至在此之前进行所有字段的初始化,最后调用request()。操作符中的request()实现确保在必要时适当地发生在关系之前(或者用其他术语来说,内存释放或full fence)。

The onBackpressureXXX operators

多数开发人员遇到backpressure 时,他们的应用程序失败与MissingBackpressureException和异常通常指向observeOn操作符。实际原因通常是对PublishProcessor的非反压使用,Timer()或interval()或通过create()创建的自定义操作符。

有几种处理这种情况的方法。

Increasing the buffer sizes 增加缓冲区大小

有时这种溢出是由于突发源造成的。突然间,用户轻击屏幕太快,发现Android上eon默认的16个元素的内部缓冲区溢出。

在RxJava的最新版本中,大多数背压敏感操作符现在允许程序员指定其内部缓冲区的大小。相关参数通常称为bufferSize、prefetch或capacityHint。鉴于介绍中溢出的示例,我们可以增加observeOn的缓冲区大小,以便有足够的空间容纳所有的值。

PublishProcessor<Integer> source = PublishProcessor.create();

    source.observeOn(Schedulers.computation(), 1024 * 1024)
          .subscribe(e -> { }, Throwable::printStackTrace);

    for (int i = 0; i < 1_000_000; i++) {
        source.onNext(i);
    }

但是要注意,一般来说,这可能只是一个临时修复,因为如果源过度生成预测的缓冲区大小,溢出仍然可能发生。在这种情况下,可以使用下列操作符之一。

Batching(批处理)/skipping(跳过) values with standard operators

如果源数据可以更有效地批处理,可以通过使用一个标准的批处理操作符(通过大小和/或时间)来减少miss backpressursureexception的可能性。

 PublishProcessor<Integer> source = PublishProcessor.create();

    source
          .buffer(1024)
          .observeOn(Schedulers.computation(), 1024)
          .subscribe(list -> { 
              list.parallelStream().map(e -> e * e).first();
          }, Throwable::printStackTrace);

    for (int i = 0; i < 1_000_000; i++) {
        source.onNext(i);
    }

如果有些值可以安全地忽略,可以使用采样(带有时间或其他Flowable)和节流操作符(throttleFirst, throttllast, throttleWithTimeout)。

PublishProcessor<Integer> source = PublishProcessor.create();

    source
          .sample(1, TimeUnit.MILLISECONDS)
          .observeOn(Schedulers.computation(), 1024)
          .subscribe(v -> compute(v), Throwable::printStackTrace);

    for (int i = 0; i < 1_000_000; i++) {
        source.onNext(i);
    }

但是请注意,这些操作只是降低了下游的值接收率,因此仍然可能导致MissingBackpressureException。

onBackpressureBuffer()

这个无参数形式的操作符在上游源和下游操作符之间重新引入了一个无界缓冲区。不受限制意味着只要JVM没有耗尽内存,它就可以处理来自突发源的几乎任何数量的内存。

 Flowable.range(1, 1_000_000)
               .onBackpressureBuffer()
               .observeOn(Schedulers.computation(), 8)
               .subscribe(e -> { }, Throwable::printStackTrace);

在这个例子中,observeOn使用了一个非常低的缓冲区大小,但是没有MissingBackpressureException,因为onBackpressureBuffer吸收了所有的100万个值,并将小批的值交给observeOn。

但是请注意,onBackpressureBuffer以一种无界的方式消耗它的源,也就是说,没有对它施加任何反压力。这样的结果是,即使是像range这样的背压支持源也将完全实现。

onBackpressureBuffer有4个额外的重载

onBackpressureBuffer(int capacity)

这是一个有界版本,当它的缓冲区达到给定的容量时,它会发出bufferoverflowerror信号。

   Flowable.range(1, 1_000_000)
              .onBackpressureBuffer(16)
              .observeOn(Schedulers.computation())
              .subscribe(e -> { }, Throwable::printStackTrace);

随着越来越多的操作符允许设置其缓冲区大小,该操作符的相关性正在降低。对于其他的,这提供了一个机会“扩展他们的内部缓冲区”,通过onBackpressureBuffer有一个比默认值更大的数字。

onBackpressureBuffer(int capacity, Action onOverflow)

在发生溢出时,此重载调用(共享)操作。它的用处相当有限,因为除了当前调用堆栈之外,没有提供关于溢出的其他信息。

onBackpressureBuffer(int capacity, Action onOverflow, BackpressureOverflowStrategy strategy)

这个重载实际上更有用,因为它让我们定义在达到容量时该做什么。BackpressureOverflow.Strategy实际上是一个接口,但BackpressureOverflow类提供了4个静态字段,其实现代表了典型的操作:

ON_OVERFLOW_ERROR:这是前两个重载的默认行为,标志BufferOverflowException

ON_OVERFLOW_DEFAULT:目前与ON_OVERFLOW_ERROR相同

ON_OVERFLOW_DROP_LATEST:如果发生溢出,当前值将被简单地忽略,只有旧的值将被交付,一旦下游请求。

ON_OVERFLOW_DROP_OLDEST:删除缓冲区中最老的元素,并向其添加当前值。

 Flowable.range(1, 1_000_000)
              .onBackpressureBuffer(16, () -> { },
                  BufferOverflowStrategy.ON_OVERFLOW_DROP_OLDEST)
              .observeOn(Schedulers.computation())
              .subscribe(e -> { }, Throwable::printStackTrace);

注意,最后两种策略会导致流中的不连续,因为它们会删除元素。此外,它们不会发出BufferOverflowException的信号。

onBackpressureDrop()

当下游未准备好接收值时,该操作符将从序列中删除该元素网。*:*一个人可以认为它是一个0容量的onBackpressureBuffer与策略ON_OVERFLOW_DROP_LATEST。

当可以安全地忽略来自源的值(如鼠标移动或当前GPS位置信号)时,这个操作符很有用,因为稍后会有更多的最新值。

  component.mouseMoves()
     .onBackpressureDrop()
     .observeOn(Schedulers.computation(), 1)
     .subscribe(event -> compute(event.x, event.y));

它可能会与源操作符interval()一起使用。例如,如果一个人想要执行某个周期性的后台任务,但每次迭代可能持续的时间超过了周期,那么删除多余的间隔通知是安全的,因为后面会有更多的间隔通知:

  Flowable.interval(1, TimeUnit.MINUTES)
     .onBackpressureDrop()
     .observeOn(Schedulers.io())
     .doOnNext(e -> networkCall.doStuff())
     .subscribe(v -> { }, Throwable::printStackTrace);

这个操作符存在一个重载:onBackpressureDrop(Consumer<?super T> onDrop),其中(共享)操作被调用,值被删除。这种变体允许清理值本身(例如,释放相关资源)。

onBackpressureLatest()

final操作符只保留最新的值,实际上覆盖了较旧的、未交付的值。可以把它看作onBackpressureBuffer的一个变体,其容量为1,策略为ON_OVERFLOW_DROP_OLDEST。

像onBackpressureDrop,如果下游碰巧落后,总有一个可用的价值。这在一些类似于遥测的情况下很有用,在这些情况下,数据可能以某种突发模式出现,但只有最新的数据才有兴趣进行处理。

例如,如果用户经常点击屏幕,我们仍然希望对其最新输入做出反应。

  component.mouseClicks()
    .onBackpressureLatest()
    .observeOn(Schedulers.computation())
    .subscribe(event -> compute(event.x, event.y), Throwable::printStackTrace);

在这种情况下,onBackpressureDrop的使用将导致一种情况,即最后的点击被删除,并让用户想知道为什么业务逻辑没有执行。

Creating backpressured datasources

通常,在处理反压力时,创建反压力数据源是相对容易的任务,因为库已经在Flowable上为开发人员提供了处理反压力的静态方法。

我们可以区分两种工厂方法:基于下游需求返回和生成元素的冷“生成器”和通常连接非反应式和/或非反压数据源的热“推送器”,并在其上进行一些反压处理。

just

最基本的背压感知源是通过以下方式创建的:

Flowable.just(1).subscribe(new DisposableSubscriber<Integer>() {
        @Override
        public void onStart() {
            request(0);
        }

        @Override
        public void onNext(Integer v) {
            System.out.println(v);
        }
       
        // the rest is omitted for brevity
    }

因为我们在onStart中明确地没有请求,所以这不会打印任何东西。Just很好,当有一个常量时,我们想要启动一个序列。

不幸的是,just经常被误认为是一种动态计算订阅用户使用的东西的方法:

int counter;

    int computeValue() {
       return ++counter;
    }
    
    Flowable<Integer> o = Flowable.just(computeValue());

    o.subscribe(System.out:println);
    o.subscribe(System.out:println);

让一些人感到惊讶的是,这将打印两次1,而不是分别打印1和2。如果调用被重写,那么它工作的原因就很明显了:

int temp = computeValue();

Flowable<Integer> o = Flowable.just(temp);

computeValue是作为主例程的一部分调用的,而不是响应订阅者的订阅。

fromCallable

人们真正需要的是fromCallable方法:

Flowable<Integer> o = Flowable.fromCallable(() -> computeValue());

这里,computeValue仅在订阅者订阅时执行,对于每一个订阅者,输出预期的1和2。当然,fromCallable也适当地支持反压,除非请求,否则不会释放计算值。但是请注意,无论如何计算都会发生。如果计算本身应该延迟到下游实际请求时,我们可以只使用map:

 Flowable.just("This doesn't matter").map(ignored -> computeValue())...

just不会发出它的常量值,直到被请求时才将其映射到computeValue的结果,computeValue仍然被每个订阅者单独调用。

fromArray

如果数据已经作为对象数组可用,一个对象列表或任何Iterable源,分别由超载产生的压力将处理这些源的背压和排放:

Flowable.fromArray(1, 2, 3, 4, 5).subscribe(System.out::println);

为了方便(并避免关于泛型数组创建的警告),有2到10个参数重载,仅用于内部委托from。

fromIterable也提供了一个有趣的机会.许多值生成可以用状态机的形式表示。每个请求的元素都会触发状态转换和返回值的计算。

编写像Iterables这样的状态机有点复杂(但仍然比编写用于消费它的Flowable容易),而且与c#不同的是,Java不支持通过简单地编写看起来很经典的代码(带有yield return和yield break)来构建这样的状态机。*:*有些库提供了一些帮助,例如谷歌Guava的AbstractIterable和IxJava的Ix.generate()和Ix.forloop()。这些本身就值得一整个系列,所以让我们看看一些非常基本的Iterable源代码,它无限期地重复某个常量值:

 Iterable<Integer> iterable = () -> new Iterator<Integer>() {
        @Override
        public boolean hasNext() {
            return true;
        }

        @Override
        public Integer next() {
            return 1;
        }
    };

    Flowable.fromIterable(iterable).take(5).subscribe(System.out::println);

如果我们通过经典的for循环使用迭代器,将导致一个无限循环。因为我们从它构建了一个Flowable,我们可以表达我们的意愿,只消费它的前5个,然后停止请求任何东西。这就是在Flowables内部进行惰性评估和计算的真正力量。

generate()

有时,要转换为响应世界的数据源本身是同步的(阻塞的)和类似拉的,也就是说,我们必须调用一些get或read方法来获取下一段数据。当然,我们可以将其转换为一个Iterable,但是当这些源与资源关联时,如果下游在序列结束之前取消订阅,我们可能会泄漏这些资源。

为了处理这种情况,RxJava有一个生成工厂方法家族。

  Flowable<Integer> o = Flowable.generate(
         () -> new FileInputStream("data.bin"),
         (inputstream, output) -> {
             try {
                 int abyte = inputstream.read();
                 if (abyte < 0) {
                     output.onComplete();
                 } else {
                     output.onNext(abyte);
                 }
             } catch (IOException ex) {
                 output.onError(ex);
             }
             return inputstream;
         },
         inputstream -> {
             try {
                 inputstream.close();
             } catch (IOException ex) {
                 RxJavaPlugins.onError(ex);
             }
         } 
    );

一般来说,generate使用3个回调。

第一个回调允许创建每个订阅者的状态,如示例中的FileInputStream;该文件将对每个订阅者独立打开。

第二个回调函数接受这个状态对象,并提供一个输出观察者,可以调用它的onXXX方法来产生值。这个回调函数会随下游请求的次数而被执行。在每次调用时,它最多只能调用onNext一次,后面可选地跟着onError或onComplete.在这个例子中,如果读字节为负,则调用onComplete(),表示文件的结束,如果读抛出IOException,则调用onError。

当下行回调取消订阅(关闭输入流)或前一个回调调用终端方法时,将调用最后一个回调;它允许释放资源。因为不是所有的资源都需要这些特性,所以Flowable的静态方法。生成让我们创建一个没有它们的实例。

不幸的是,JVM和其他库中的许多方法调用会抛出受控异常,需要将它们封装到try-catch中,因为此类使用的函数接口不允许抛出受控异常。

当然,我们可以用它来模拟其他典型的源,比如一个无界范围:

 Flowable.generate(
         () -> 0,
         (current, output) -> {
             output.onNext(current);
             return current + 1;
         },
         e -> { }
    );

在这个设置中,current以0开始,下一次调用lambda时,参数current现在为1。

create(emitter)

有时,要封装到Flowable中的源已经是热的(如鼠标移动)或冷的,但在其API中不可反压(如异步网络回调)。

为了处理这种情况,RxJava的最新版本引入了create(发射器)工厂方法。它有两个参数:

一个回调函数,该回调函数将使用每个传入订阅者的Emitter接口实例来调用,

一个BackpressureStrategy枚举,要求开发人员指定要应用的反压行为。它有通常的模式,类似于onBackpressureXXX,除了信号一个MissingBackpressureException或简单地忽略它内部的溢出。

*:*注意,它目前不支持这些反压模式的附加参数。如果需要定制,使用NONE作为反压模式,并在由此产生的Flowable上应用相关的onBackpressureXXX是可行的方法。

当人们想要与基于推的源(如GUI事件)交互时,它的第一种典型使用情况。这些api具有一些addListener/removeListener调用的形式,可以利用:

   Flowable.create(emitter -> {
        ActionListener al = e -> {
            emitter.onNext(e);
        };

        button.addActionListener(al);

        emitter.setCancellation(() -> 
            button.removeListener(al));

    }, BackpressureStrategy.BUFFER);

*:*发射器使用起来相对简单;可以调用onNext, onError和onComplete,操作符自己处理反压和取消订阅管理。此外,如果包装API支持取消(如示例中的侦听器删除),一个可以使用setCancellation(或setSubscription Subscription-like资源)取消注册一个回调函数,当下游取消订阅或调用onError / onComplete呼吁Emitterinstance提供。

这些方法一次只允许单个资源与发射器关联,设置新资源将自动取消订阅旧资源。如果必须处理多个资源,创建一个compositesubsubscribe,将它与发射器关联,然后向compositesubsubscribe本身添加更多的资源:

 Flowable.create(emitter -> {
        CompositeSubscription cs = new CompositeSubscription();

        Worker worker = Schedulers.computation().createWorker();

        ActionListener al = e -> {
            emitter.onNext(e);
        };

        button.addActionListener(al);

        cs.add(worker);
        cs.add(Subscriptions.create(() -> 
            button.removeActionListener(al));

        emitter.setSubscription(cs);

    }, BackpressureMode.BUFFER);

第二种场景通常涉及一些异步的、基于回调的API,必须将其转换为Flowable。

Flowable.create(emitter -> {
        
        someAPI.remoteCall(new Callback<Data>() {
            @Override
            public void onSuccess(Data data) {
                emitter.onNext(data);
                emitter.onComplete();
            }

            @Override
            public void onFailure(Exception error) {
                emitter.onError(error);
            }
        });

    }, BackpressureMode.LATEST);

在本例中,委托的工作方式是相同的。不幸的是,这些经典的回调风格的api通常不支持取消,但如果它们支持,可以像前面的例子一样设置它们的取消(尽管可能使用更复杂的方法)。注意使用LATEST反压模式;如果我们知道只有一个值,我们就不需要BUFFER策略,因为它分配了一个默认的128元素长的缓冲区(根据需要增长),永远不会被充分利用。