Planificateurs dans RxJava

1. Vue d’ensemble

Dans cet article, nous allons nous intéresser aux différents types de Schedulers que nous allons utiliser pour écrire des programmes multithreading basés sur les méthodes RxJava Observable de subscribeOn et observeOn .

Schedulers donne la possibilité de spécifier où et probablement quand exécuter des tâches liées au fonctionnement d’une chaîne Observable .

Nous pouvons obtenir un Scheduler à partir des méthodes d’usine décrites dans la classe Planificateurs .

2. Comportement par défaut du threading

  • Par défaut, Rx est mono-threaded ** , ce qui implique qu’un Observable et la chaîne d’opérateurs que nous pouvons lui appliquer informeront ses observateurs du même thread sur lequel sa méthode subscribe () est appelée.

Les méthodes observeOn et subscribeOn prennent en argument un Scheduler, qui, comme son nom l’indique, est un outil que nous pouvons utiliser pour planifier des actions individuelles.

Nous allons créer notre implémentation d’un Scheduler en utilisant la méthode createWorker , qui renvoie un Scheduler.Worker . A worker accepte les actions et exécute les séquentiellement sur un seul thread.

D’une certaine manière, un worker est un _S cheduler lui-même, mais nous ne l’appellerons pas un Scheduler_ pour éviter toute confusion.

2.1. Planifier une action

Nous pouvons planifier un travail sur n’importe quel Scheduler en créant un nouveau worker et en planifiant certaines actions:

Scheduler scheduler = Schedulers.immediate();
Scheduler.Worker worker = scheduler.createWorker();
worker.schedule(() -> result += "action");

Assert.assertTrue(result.equals("action"));

L’action est ensuite mise en file d’attente sur le thread auquel est affecté le travailleur.

2.2. Annuler une action

Scheduler.Worker étend Subscription . L’appel de la méthode unsubscribe sur un worker aura pour effet de vider la file d’attente et d’annuler toutes les tâches en attente. Nous pouvons voir cela par exemple:

Scheduler scheduler = Schedulers.newThread();
Scheduler.Worker worker = scheduler.createWorker();
worker.schedule(() -> {
    result += "First__Action";
    worker.unsubscribe();
});
worker.schedule(() -> result += "Second__Action");

Assert.assertTrue(result.equals("First__Action"));

La deuxième tâche n’est jamais exécutée car celle qui l’a précédée a annulé toute l’opération. Les actions en cours d’exécution seront interrompues.

3. Schedulers.newThread

Ce planificateur démarre simplement un nouveau thread chaque fois qu’il est demandé via subscribeOn () ou observeOn () .

C’est rarement un bon choix, non seulement à cause de la latence lors du démarrage d’un thread, mais aussi parce que ce thread n’est pas réutilisé:

Observable.just("Hello")
  .observeOn(Schedulers.newThread())
  .doOnNext(s ->
    result2 += Thread.currentThread().getName()
  )
  .observeOn(Schedulers.newThread())
  .subscribe(s ->
    result1 += Thread.currentThread().getName()
  );
Thread.sleep(500);
Assert.assertTrue(result1.equals("RxNewThreadScheduler-1"));
Assert.assertTrue(result2.equals("RxNewThreadScheduler-2"));

Lorsque le Worker est terminé, le thread se termine simplement. Ce Scheduler ne peut être utilisé que lorsque les tâches sont à grain grossier: cela prend beaucoup de temps, mais il en existe très peu, de sorte qu’il est peu probable que les threads soient réutilisés.

Scheduler scheduler = Schedulers.newThread();
Scheduler.Worker worker = scheduler.createWorker();
worker.schedule(() -> {
    result += Thread.currentThread().getName() + "__Start";
    worker.schedule(() -> result += "__worker__");
    result += "__End";
});
Thread.sleep(3000);
Assert.assertTrue(result.equals(
  "RxNewThreadScheduler-1__Start__End__worker__"));

Lorsque nous avons planifié le worker sur un NewThreadScheduler, nous avons vu que le worker était lié à un thread particulier.

4. Schedulers.immediate

Schedulers.immediate est un planificateur spécial qui appelle une tâche dans le thread client de manière bloquante, plutôt que de manière asynchrone et qui retourne une fois l’action terminée:

Scheduler scheduler = Schedulers.immediate();
Scheduler.Worker worker = scheduler.createWorker();
worker.schedule(() -> {
    result += Thread.currentThread().getName() + "__Start";
    worker.schedule(() -> result += "__worker__");
    result += "__End";
});
Thread.sleep(500);
Assert.assertTrue(result.equals(
  "main__Start__worker____End"));

En fait, s’abonner à un Observable via immediate Scheduler a généralement le même effet que de ne pas s’abonner à un __S __cheduler particulier:

Observable.just("Hello")
  .subscribeOn(Schedulers.immediate())
  .subscribe(s ->
    result += Thread.currentThread().getName()
  );
Thread.sleep(500);
Assert.assertTrue(result.equals("main"));

5. Schedulers.trampoline

La trampoline Scheduler est très similaire à la immediate car elle planifie également les tâches dans le même thread, en les bloquant efficacement.

Cependant, la tâche à venir est attendue lorsque toutes les tâches planifiées précédemment sont terminées:

Observable.just(2, 4, 6, 8)
  .subscribeOn(Schedulers.trampoline())
  .subscribe(i -> result += "" + i);
Observable.just(1, 3, 5, 7, 9)
  .subscribeOn(Schedulers.trampoline())
  .subscribe(i -> result += "" + i);
Thread.sleep(500);
Assert.assertTrue(result.equals("246813579"));

Immediate appelle immédiatement une tâche donnée, alors que trampoline attend que la tâche en cours se termine.

La trampoline ’s worker exécute chaque tâche du thread qui a planifié la première tâche. Le premier appel à schedule bloque jusqu’à ce que la file d’attente soit vidée:

Scheduler scheduler = Schedulers.trampoline();
Scheduler.Worker worker = scheduler.createWorker();
worker.schedule(() -> {
    result += Thread.currentThread().getName() + "Start";
    worker.schedule(() -> {
        result += "__middleStart";
        worker.schedule(() ->
            result += "__worker__"
        );
        result += "__middleEnd";
    });
    result += "__mainEnd";
});
Thread.sleep(500);
Assert.assertTrue(result
  .equals("mainStart__mainEnd__middleStart__middleEnd__worker__"));

6. __Schedulers.from

Les planificateurs sont plus complexes en interne que les exécuteurs de java.util.concurrent__ - une abstraction distincte était donc nécessaire

Mais comme ils sont conceptuellement assez similaires, il existe sans surprise un wrapper qui peut transformer Executor en Scheduler en utilisant la méthode from factory:

private ThreadFactory threadFactory(String pattern) {
    return new ThreadFactoryBuilder()
      .setNameFormat(pattern)
      .build();
}

@Test
public void givenExecutors__whenSchedulerFrom__thenReturnElements()
 throws InterruptedException {

    ExecutorService poolA = newFixedThreadPool(
      10, threadFactory("Sched-A-%d"));
    Scheduler schedulerA = Schedulers.from(poolA);
    ExecutorService poolB = newFixedThreadPool(
      10, threadFactory("Sched-B-%d"));
    Scheduler schedulerB = Schedulers.from(poolB);

    Observable<String> observable = Observable.create(subscriber -> {
      subscriber.onNext("Alfa");
      subscriber.onNext("Beta");
      subscriber.onCompleted();
    });;

    observable
      .subscribeOn(schedulerA)
      .subscribeOn(schedulerB)
      .subscribe(
        x -> result += Thread.currentThread().getName() + x + "__",
        Throwable::printStackTrace,
        () -> result += "__Completed"
      );
    Thread.sleep(2000);
    Assert.assertTrue(result.equals(
      "Sched-A-0Alfa__Sched-A-0Beta____Completed"));
}

SchedulerB est utilisé pendant une courte période, mais il planifie à peine une nouvelle action sur schedulerA , qui effectue tout le travail. Ainsi, plusieurs méthodes subscribeOn ne sont pas seulement ignorées, mais introduisent également une légère surcharge.

7. Schedulers.io

Ce Scheduler est similaire au newThread , à la différence que les threads déjà démarrés sont recyclés et peuvent éventuellement gérer des requêtes futures.

Cette implémentation fonctionne de manière similaire à ThreadPoolExecutor à partir de java.util.concurrent avec un pool de threads non lié. Chaque fois qu’un nouveau worker est demandé, un nouveau thread est démarré (puis maintenu inactif pendant un certain temps) ou celui inactif est réutilisé:

Observable.just("io")
  .subscribeOn(Schedulers.io())
  .subscribe(i -> result += Thread.currentThread().getName());

Assert.assertTrue(result.equals("RxIoScheduler-2"));

Nous devons faire attention avec les ressources illimitées de toute nature - en cas de dépendances externes lentes ou non réactives telles que les services Web, io scheduler peut démarrer un nombre énorme de threads, ce qui entraîne la non-réponse de notre propre application.

En pratique, suivre Schedulers.io est presque toujours un meilleur choix.

8. Schedulers.computation

Par défaut, Computation S _cheduler limite le nombre de threads exécutés en parallèle à la valeur de availableProcessors () , comme indiqué dans la classe d’utilitaire Runtime.getRuntime () _ .

Nous devrions donc utiliser un ordonnanceur de calcul lorsque les tâches sont entièrement liées au processeur; c’est-à-dire qu’ils nécessitent une puissance de calcul et n’ont pas de code de blocage.

Il utilise une file d’attente sans limite devant chaque thread. Par conséquent, si la tâche est planifiée, mais que tous les cœurs sont occupés, elle sera mise en file d’attente. Cependant, la file d’attente juste avant chaque thread continuera à grossir:

Observable.just("computation")
  .subscribeOn(Schedulers.computation())
  .subscribe(i -> result += Thread.currentThread().getName());
Assert.assertTrue(result.equals("RxComputationScheduler-1"));

Si pour une raison quelconque, nous avons besoin d’un nombre de threads différent de celui par défaut, nous pouvons toujours utiliser la propriété rx.scheduler.max-computation-threads system.

En prenant moins de threads, nous pouvons nous assurer qu’il y a toujours un ou plusieurs cœurs de processeur inactifs, et même sous une charge importante, le pool de threads computation ne sature pas le serveur. Il n’est tout simplement pas possible d’avoir plus de fils de calcul que de cœurs.

9. Schedulers.test

Ce Scheduler est utilisé uniquement à des fins de test et nous ne le verrons jamais dans le code de production. Son principal avantage est sa capacité à avancer l’horloge en simulant le passage du temps de manière arbitraire:

List<String> letters = Arrays.asList("A", "B", "C");
TestScheduler scheduler = Schedulers.test();
TestSubscriber<String> subscriber = new TestSubscriber<>();

Observable<Long> tick = Observable
  .interval(1, TimeUnit.SECONDS, scheduler);

Observable.from(letters)
  .zipWith(tick, (string, index) -> index + "-" + string)
  .subscribeOn(scheduler)
  .subscribe(subscriber);

subscriber.assertNoValues();
subscriber.assertNotCompleted();

scheduler.advanceTimeBy(1, TimeUnit.SECONDS);
subscriber.assertNoErrors();
subscriber.assertValueCount(1);
subscriber.assertValues("0-A");

scheduler.advanceTimeTo(3, TimeUnit.SECONDS);
subscriber.assertCompleted();
subscriber.assertNoErrors();
subscriber.assertValueCount(3);
assertThat(
  subscriber.getOnNextEvents(),
  hasItems("0-A", "1-B", "2-C"));

10. Planificateurs par défaut

Certains opérateurs Observable dans RxJava ont d’autres formes permettant de définir le Scheduler que l’opérateur utilisera pour son opération. D’autres n’opèrent pas sur un Scheduler particulier ni sur un Scheduler par défaut particulier.

Par exemple, l’opérateur delay prend les événements en amont et les envoie en aval après un temps donné. Évidemment, il ne peut pas contenir le thread original pendant cette période, il doit donc utiliser un Scheduler différent:

ExecutorService poolA = newFixedThreadPool(
  10, threadFactory("Sched1-"));
Scheduler schedulerA = Schedulers.from(poolA);
Observable.just('A', 'B')
  .delay(1, TimeUnit.SECONDS, schedulerA)
  .subscribe(i -> result+= Thread.currentThread().getName() + i + " ");

Thread.sleep(2000);
Assert.assertTrue(result.equals("Sched1-A Sched1-B "));

Sans fournir un schedulerA personnalisé, tous les opérateurs situés en dessous de delay utiliseraient le computation Scheduler .

Les autres opérateurs importants qui prennent en charge les Schedulers personnalisés sont buffer, interval , range , timer , skip , take , timeout et plusieurs autres. Si nous ne fournissons pas un Scheduler à de tels opérateurs, le planificateur computation est utilisé, ce qui est un paramètre sécurisé par défaut dans la plupart des cas.

11. Conclusion

Dans les applications véritablement réactives, pour lesquelles toutes les opérations de longue durée sont asynchrones, très peu de threads et donc Schedulers sont nécessaires.

La maîtrise des ordonnanceurs est essentielle à la rédaction de code évolutif et sécurisé à l’aide de RxJava. La différence entre subscribeOn et observeOn est particulièrement importante lorsque la charge de travail est élevée et que chaque tâche doit être exécutée exactement comme prévu.

Dernier point mais non le moindre, nous devons nous assurer que les Schedulers utilisés en aval peuvent suivre le lom. Pour plus d’informations, vous trouverez cet article sur le lien:/rxjava-backpressure[backpressure].

Vous trouverez la mise en œuvre de tous ces exemples et extraits de code dans le projet GitHub - il s’agit d’un projet Maven, il devrait donc être facile à importer et à courir comme il est.