[译] 操作符融合

1,166 阅读14分钟


原文:Operator fusion in RxJava 2
链接:proandroiddev.com/operator-fu…

介绍

RxJava是一个非常强大的库,尽管它也有一些问题。特别是性能和内存问题,这些问题来自于问题库试图解决的问题,以及如何从技术角度设计解决方案。

为了最小化RxJava中的开销,有许多优化,这些优化被称为“操作符融合”。我们将在这篇文章中讨论它们。

但首先让我们回顾一下RxJava响应式类型是如何工作的,以及它们存在哪些问题。

Observable

1*-GlVgagYR7eyhAfd2Js82Q.png

当使用Observable时,有三个主要部分: Observable、Observer和Disposable。

我们都知道Observable以及它是如何创建的(比如Observable.just(“Hello, World !”))。Observable是每个Rx-chain的构建块。为了让Observable工作,我们需要通过将Observer传递给subscribe(…)方法来订阅它。

Observer基本上是一个带有onSubscribe, onNext, onErroronComplete回调的接口。

当我们用某个Observer订阅Observable时,Observable会创建一个Disposable对象,并通过 onSubscribe回调函数将其传递给Observer(这样,如果需要的话,Observer就可以处理Rx-chain)。

在此之后——建立通信,Observable可以通过onNext发出一些值,而无需额外等待。

因此,Observable不支持背压——Observer没有办法通知Observable它应该等待而不发出更多的值。

Flowable

1*WrJqMpUirEMZs_iGqT9qqQ.png

在Flowable中,一切都是类似的,但我们没有Observer和Disposable,而是 Subscriber和Subscription。

订阅具有附加的 request(n) 方法,Subscriber可以使用该方法显式地请求Flowable以发出所请求的项数量。如果没有请求值,Flowable不会发出任何信号,这就是为什么Flowable支持反压。

装配时和订阅时

在使用RxJava响应式类型时,有两个重要阶段: 装配时和订阅时。

在装配时建立Rx-chain,在订阅时我们“启动”Rx-chain。

考虑下面的例子:

1*34Evx_6n7M73fJTjVED2iA.png

在这种情况下,我们从上到下,会发生以下情况:

  • 创建ObservableJust对象
  • ObservableMap对象被创建,之前创建的ObservableJust被传递给新的Observable(所以它们被组装在一起)
  • ObservableFilter对象被创建,之前创建的ObservableMap(里面有ObservableJust)被传递给新的Observable
  • 我们订阅ObservableFilter,它会触发实际的订阅
  • ObservableFilter创建自己的内部观察者并订阅ObservableMap
  • ObservableMap创建自己的内部观察者并订阅ObservableJust
  • ObservableJust将onSubscribe事件发送到下游(其他的Observable也会将这个事件发送到他们下游链中最新的Observable)
  • ObservableJust开始发出值,它们通过onNext回调向下传递

1*pvHknxH6NsoabgUzaiB4Lg.png

所以你可以看到这个短 Rx-chain 发生了很多事情。如果这个链是 Flowable 类型的,那么附加的通信request(n)也会发生,这使得情况更加复杂。

队列和同步

操作符内部可能有用于处理事件的内部Queues。对这个队列的访问应该序列化(这意味着应该以适当的同步方式访问它)。

RxJava2具有基于Atomics(例如AtomicInteger)的非阻塞同步和使用compareAndSet方法的无限循环(为了更好的性能)。所以在库中,通常会看到这样的代码:

for (; ; ) {
    long r = state.get();

    if ((r & COMPLETED_MASK) != 0L) {
        return;
    }

    long u = r | COMPLETED_MASK;
    // (active, r) -> (complete, r) transition
    if (state.compareAndSet(r, u)) {
        // if the requested amount was non-zero, drain the queue
        if (r != 0L) {
            postCompleteDrain(u, actual, queue, state, isCancelled);
        }

        return;
    }
}

如果考虑到链中的每个操作符都可以有自己的Queue,则操作符中的Queue和Atomic对象也会带来开销。

问题

综上所述,RxJava存在以下问题:

  • 装配时开销——为了创建Rx-chain,需要创建大量的对象,这带来了内存开销
  • 订阅时开销——当我们订阅Rx-chain时,会发生大量的通信,这带来了性能开销
  • 分配和序列化开销——为每个操作符创建内部结构作为队列和原子对象会带来内存和性能开销

操作符融合

为了解决一些性能和内存问题,有“操作符融合”。

有两种类型的融合:

  • 宏融合 — 通过将一些操作符合并为一个操作符,最小化装配时或订阅时创建的对象数量
  • 微融合 — 删除操作符之间不必要的同步和共享内部结构(如队列)

装配时的宏融合

装配时

装配时的宏融合专注于最小化装配时创建的Observables和objects的数量。当我们说到“装配时”时,我们指的是这个地方:

@CheckReturnValue
@SchedulerSupport(SchedulerSupport.NONE)
public final <R> Observable<R> map(Function<? super T, ? extends R> mapper) {
    ObjectHelper.requireNonNull(mapper, "mapper is null");
    return RxJavaPlugins.onAssembly(new ObservableMap<T, R>(this, mapper));
}

装配时融合基础

优化一些Observables最简单的方法是添加特殊情况的检查,这样创建的Observables在实现方面就比一般的更简单。举个例子,我们可以看看Observable.fromArray,它可以降级为Observable.emptyObservable.just只要有0或1项分别提供:

public static <T> Observable<T> fromArray(T... items) {
    ObjectHelper.requireNonNull(items, "items is null");
    if (items.length == 0) {
        return empty();
    }
    if (items.length == 1) {
        return just(items[0]);
    }
    return RxJavaPlugins.onAssembly(new ObservableFromArray<T>(items));
}

ScalarCallable

我们关注的第一个“高级”优化是fuseable包里的ScalarCallable接口:

public interface ScalarCallable<T> extends Callable<T> {

    // overridden to remove the throws Exception
    @Override
    T call();
}

它基本上是与普通java Callable相同的接口,不同之处在于它不会抛出异常。

ScallarCallable是一个响应式类型可以实现的接口,它持有可以在装配时安全地提取的常量。具体来说,这种响应式类型可以只包含一个项目,也可以不包含任何项目。

因此,当我们调用call方法-我们检查返回值:如果它是空-然后反应类型没有任何值,如果它返回非空值-然后它只有那个值。

根据描述的契约,只有Observable, FlowableMaybe里的justempty操作符可以用这个接口标记。

然后,例如在xMap操作符(flatMap, switchMap, concatMap)中,如果源被标记为这个接口,我们可以应用优化:

public final <R> Observable<R> flatMap(Function<? super T, ? extends ObservableSource<? extends R>> mapper,
        boolean delayErrors, int maxConcurrency, int bufferSize) {
    ObjectHelper.requireNonNull(mapper, "mapper is null");
    ObjectHelper.verifyPositive(maxConcurrency, "maxConcurrency");
    ObjectHelper.verifyPositive(bufferSize, "bufferSize");
    if (this instanceof ScalarCallable) {
        @SuppressWarnings("unchecked")
        T v = ((ScalarCallable<T>)this).call();
        if (v == null) {
            return empty();
        }
        return ObservableScalarXMap.scalarXMap(v, mapper);
    }
    return RxJavaPlugins.onAssembly(new ObservableFlatMap<T, R>(this, mapper, delayErrors, maxConcurrency, bufferSize));
}

如果源被标记为ScalarCallable接口,我们可以切换到xMap的简化版本,而不是完全实现(这相当繁重)。

FuseToXXX

fuseable包中有三个接口:

public interface FuseToObservable<T> {
    Observable<T> fuseToObservable();
}

public interface FuseToMaybe<T> {
    Maybe<T> fuseToMaybe();
}

public interface FuseToFlowable<T> {
    Flowable<T> fuseToFlowable();
}

让我们仔细看看FuseToObservable,对于其他接口,一切都是相似的。

考虑我们有以下的Rx链

public static void count() {
    Observable.range(1, 10)
            .count()
            .toObservable()
            .subscribe();
}

在这里,我们创建了一些范围,并希望计算发出的项的数量。count操作符返回Single,但我们希望有一个Observable(例如,我们希望将这个结果与其他一些Observables合并)。然后我们在Rx-chain上添加了额外的toObservable操作符,使其更加复杂和沉重。

FuseToObservable在这里提供了帮助。这个接口说的是一些返回响应式类型而不是Observable的操作符有一些返回Observable的实现,这个实现可以在toObservable调用中提取。

考虑我们关于count的例子:

public final Single<Long> count() {
    return RxJavaPlugins.onAssembly(new ObservableCountSingle<T>(this));
}

默认情况下,它返回ObservableCountSingle,但是如果我们看看这个操作符的实现,我们会看到它实现了FuseToObservable 接口,并且在融合模式下调用时可以提供不同的实现:

public final class ObservableCountSingle<T> extends Single<Long> implements FuseToObservable<Long> {
    ...

    @Override
    public Observable<Long> fuseToObservable() {
        return RxJavaPlugins.onAssembly(new ObservableCount<T>(source));
    }
    
    ...
}

当我们调用toObservable时,实现将被提取,这实际上意味着toObservable Observable将不会被创建:

public final Observable<T> toObservable() {
    if (this instanceof FuseToObservable) {
        return ((FuseToObservable<T>)this).fuseToObservable();
    }
    return RxJavaPlugins.onAssembly(new SingleToObservable<T>(this));
}

订阅时的宏融合

订阅时的宏融合专注于装配时的相同类型的优化,但它们是在subscribeActual方法中完成的

@Override
public void subscribeActual(Observer<? super U> t) {
    source.subscribe(new MapObserver<T, U>(t, function));
}

有时在装配时应用优化是不可能的,因为在订阅之前数据是未知的,有时在订阅时执行一些优化比在装配时更方便。

订阅时融合基础

与装配时一样,也有简单的优化,即检查某些特殊条件,使用简化的实现而不是一般的实现。例如Observable.amb检查提供的源的数量,以决定是否应该实例化重的AmbCoordinator

public void subscribeActual(Observer<? super T> observer) {
    ObservableSource<? extends T>[] sources = this.sources;
    int count = 0;
    ...

    if (count == 0) {
        EmptyDisposable.complete(observer);
        return;
    } else
    if (count == 1) {
        sources[0].subscribe(observer);
        return;
    }

    AmbCoordinator<T> ac = new AmbCoordinator<T>(observer, count);
    ac.subscribe(sources);
}

Callable

在装配时,我们对ScalarCallable接口进行了一些优化。对于订阅时,我们对Callable接口有类似的优化。

NOTE: 因为ScalarCallable继承于Callable,在装配时可以应用于ScalarCallable的任何优化也可以应用于订阅时的Callable

例如,在为标记有Callable接口的Observables订阅的xMap操作符中,可以切换到简化的实现

@Override
public void subscribeActual(Observer<? super U> t) {

    if (ObservableScalarXMap.tryScalarXMapSubscribe(source, t, mapper)) {
        return;
    }

    source.subscribe(new MergeObserver<T, U>(t, mapper, delayErrors, maxConcurrency, bufferSize));
}

微融合

微融合的目标是通过减少一些同步或将内部结构共享为队列来最小化开销。

Conditional Subscriber

让我们看一下使用像Flowable.filter这样的操作符时会发生什么:

1*b8FsW2GVN0Gi3XohhssOiA.png

我们有Upstream,我们的filter操作符和downstream。假设我们的filter函数检查value是否小于5。在订阅建立后,下游必须向上游请求一些项目:

  • 下游从Filter请求一个item
  • Filter从上游请求一个item
  • 上游生成item并将其传递给Filter(假设它是数字 1)
  • Filter检查该值满足谓词,并将其传递给下游
  • 下游接受项目,并从Filter请求另一个项目
  • Filter从上游请求一个item
  • 上游生成item并将其传递给Filter(假设它是数字 10)
  • Filter检查值不满足谓词,不能传递给下游,尽管下游请求了一个项,但Filter没有提供它,因此Filter从上游请求另一个项
  • 这将一直重复,直到流终止

如您所见,操作符之间有很多通信,更重要的是,每个事件都是序列化的,这意味着有一些开销。

考虑我们之间有两个Filter操作符——通信可以显着增加,因此开销:

1*wYp5cEuFdQspawI8x1RbdA.png

这就是ConditionalSubscriber的救命之处:

public interface ConditionalSubscriber<T> extends FlowableSubscriber<T> {
    boolean tryOnNext(T t);
}

通常Subscriber中的onNext回调不会返回任何值,因为上游只是通过该回调提供值,并等待来自下游的新请求。

ConditionalSubscriber有附加的方法tryOnNext,它与onNext类似,不同之处在于它会立即返回布尔值,以判断值是否被接受。

当上游接收到直接响应时,这可以减少所需request(n)调用的数量。

如果我们以Flowable.filter为例。我们可以看到,基本上上游filter可以直接访问下游filter谓词的谓词: (原文是:If we look for example at the Flowable.filter implementation we can see that basically upstream filter can directly access predicate of downstream filter predicate:)

@Override
public boolean tryOnNext(T t) {
    ...
    
    boolean b;
    try {
        b = filter.test(t);
    } catch (Throwable e) {
        fail(e);
        return true;
    }
    return b && downstream.tryOnNext(t);
}

这可以节省一些request调用:

1*XpNdWcZQkE_ZoZycOvqJUw.png

但是,如果这种优化的目标是减少链式filter操作符的开销(因为它似乎无论如何都可以在一个filter操作符中编写),那就没有那么棒了。好的一面是,Flowable.map还实现了ConditionalSubscriber,当将多个filter和map接在一起时,它的开销更小:

@Override
public boolean tryOnNext(T t) {
    if (done) {
        return false;
    }

    U v;

    try {
        v = ObjectHelper.requireNonNull(mapper.apply(t), "The mapper function returned a null value.");
    } catch (Throwable ex) {
        fail(ex);
        return true;
    }
    return downstream.tryOnNext(v);
}

Queue 融合

最复杂的微融合是基于操作符之间共享内部队列,整个优化是基于QueueSubscription接口的

public interface QueueSubscription<T> extends QueueFuseable<T>, Subscription {
}

它基本上只是一个接口下的Queue和Subscription。但是Queue接口不仅仅是一个简单的Java接口,相反,它有额外的方法requestFusion:

public interface QueueFuseable<T> extends SimpleQueue<T> {
    int NONE = 0;
    int SYNC = 1;
    int ASYNC = 2;
    int ANY = SYNC | ASYNC;
    int BOUNDARY = 4;

    int requestFusion(int mode);
}

其思想是,与通过onXXX回调在Flowable和Subscriber之间的通常通信相比,下游不仅可以提供Subscription,还可以提供QueueSubscription,允许下游直接访问内部队列。

其机制如下。

首先,在onSubscribe过程中,上下游应该就融合达成一致,并选择它们将要工作的融合模式。 如果他们同意融合-那么新的通信实现将被使用,如果他们不同意-传统的通信通过onXXX回调将被建立。

通常在融合建立后,下游会通过在上游队列上调用poll()方法直接获取项:

1*B0toYBJuAiQnzZRTvWpfwg.png

有三种融合模式:

  • NONE: 不支持融合
  • SYNC: 同步融合模式
  • ASYNC: 异步融合模式

ANY -仅仅是 SYNC 或 ASYNC (具体要建立的是基于上游支持的模式)。

SYNC 融合

SYNC 融合仅在上游值已经静态可用或在同步调用poll()时生成时才可用。

如果上下游同意采用同步融合方式,则需签订以下合同:

  • 下游将在需要时直接调用poll()方法
  • poll()方法可以抛出异常—这相当于onError
  • poll()方法可以返回null—这相当于onComplete
  • poll()方法可以返回非空值——这相当于onNext
  • 上游不会调用任何onXXX回调

1*gTGkU2wgrHR4JVYr-lEAlw.png

支持同步融合模式的例子是Flowable.range:

@Override
public final int requestFusion(int mode) {
    return mode & SYNC;
}

@Nullable
@Override
public final Integer poll() {
    int i = index;
    if (i == end) {
        return null;
    }
    index = i + 1;
    return i;
}

ASYNC 融合

当上游值最终可能对poll()可用时,ASYNC融合模式可用。

如果上下游同意使用异步融合模式,则产生如下合同:

  • 上游像往常一样发出onErroronComplete信号

  • onNext实际上可能不包含上游值,而是使用null代替。

    下游应该将这样的onNext视为可以调用poll()的指示

  • poll()的调用者应该捕获异常

是的,它可能有空值在onNext在RxJava*

1*xmbFSSxE0fTW3mJQxrCMPg.png

支持异步融合模式的操作符示例是Flowable.filter:

@Nullable
    @Override
    public T poll() throws Exception {
        QueueSubscription<T> qs = this.qs;
        Predicate<? super T> f = filter;

        for (;;) {
            T t = qs.poll();
            if (t == null) {
                return null;
            }

            if (f.test(t)) {
                return t;
            }

            if (sourceMode == ASYNC) {
                qs.request(1);
            }
        }
    }
}
@Override
public boolean tryOnNext(T t) {
    if (done) {
        return false;
    }

    if (sourceMode != NONE) {
        return downstream.tryOnNext(null);
    }

    boolean b;
    try {
        b = filter.test(t);
    } catch (Throwable e) {
        fail(e);
        return true;
    }
    return b && downstream.tryOnNext(t);
}

我们已经看了一些支持融合模式的操作符的例子,但要启用这种模式,必须首先请求融合。例如,在Flowable.flatMap 内部为InnerSubscriber请求融合模式:

@Override
public void onSubscribe(Subscription s) {
    if (SubscriptionHelper.setOnce(this, s)) {

        if (s instanceof QueueSubscription) {
            @SuppressWarnings("unchecked")
            QueueSubscription<U> qs = (QueueSubscription<U>) s;
            int m = qs.requestFusion(QueueSubscription.ANY | QueueSubscription.BOUNDARY);
            if (m == QueueSubscription.SYNC) {
                fusionMode = m;
                queue = qs;
                done = true;
                parent.drain();
                return;
            }
            if (m == QueueSubscription.ASYNC) {
                fusionMode = m;
                queue = qs;
            }

        }

        s.request(bufferSize);
    }
}

在这里您可以看到,在订阅期间,当源实现QueueSubscription融合模式时,将请求ANY。

根据源所接受的模式,应用不同的策略。

QueueSubscription 线程

使用队列融合时,注意线程问题是很重要的。如果我们允许下游访问上游的队列,如果上游和下游在不同的线程上工作,可能会导致问题:

1*qMwn_G7xenYa4UDbF4rlsw.png

map内部可能会有一些繁重的计算,这(在直接轮询的情况下)可能会将计算泄漏到不同的线程。为了解决这个问题,有一个额外的标记选项BOUNDARY,它表示轮询方法的调用者可能在不同的线程上执行该操作。然后,操作符要么忽略BOUNDARY选项,允许从另一个线程访问它的队列,要么在BOUNDARY选项被请求的情况下拒绝融合。

如果我们看看Observable.map实现。我们可以看到它使用transitiveBoundaryFusion辅助函数:

@Override
public int requestFusion(int mode) {
    return transitiveBoundaryFusion(mode);
}

其中声明不允许使用BOUNDARY模式:

protected final int transitiveBoundaryFusion(int mode) {
    QueueDisposable<T> qd = this.qd;
    if (qd != null) {
        if ((mode & BOUNDARY) == 0) {
            int m = qd.requestFusion(mode);
            if (m != NONE) {
                sourceMode = m;
            }
            return m;
        }
    }
    return NONE;
}

结论

在这篇文章中,我们概述了RxJava中的一些优化,并发现了一些有趣的事情:

  • RxJava 2中的Observable不支持背压(因为它没有办法通知上游不要提供更多的项)
  • RxJava中不允许空值,因为下面的一些优化是基于在回调中传递空值
  • 如果想关闭所有优化,hide()操作符非常重要
  • 操作符融合是很奇特的,尽管它仍然只是一些优化。它们并不是适用于所有的操作符。令人惊讶的是,在某些情况下,它听起来像是可以优化的——实际上没有一个优化工作。 原因是这些优化应用于一些关键的地方和常见的解决方案,进行一般的优化是非常困难的。

所以,不要认为RxJava在本质上可以有效地完成所有事情,现在你可以编写长Rx-chain。对你的代码进行基准测试,分析重要的链,并试着找出如何分别优化它们。