Shiny: Performance Tuning mit future & promises – Die Praxis
Im dritten Teil unserer Blogserie zum Thema Shiny ging es um die Optimierung innerhalb von Shiny-Applikationen. Dabei wurde ein Blick auf die Theorie hinter der Arbeitsweise von Shiny sowie auf die Pakete future & promises geworfen. Im Kontext der beiden Pakete wurde vorgestellt, wie diese durch die Implementierung eines asynchronen Workflows dazu genutzt werden können, aufwändige Aufgaben auf Nebenprozesse auszulagern, um die App für alle anderen parallel zugreifenden Nutzer ansprechbar zu halten. Den Hintergrund dazu liefert die Arbeitsweise von Shiny, in der eine solche Aufgabe dafür sorgen kann, dass nicht nur der aktuelle Nutzer, sondern mehrere Nutzer gleichzeitig auf die Fertigstellung der Aufgabe warten müssen. Einige Beispiele dieser, von uns “prozessblockende” genannten Aufgaben, sollen in diesem Artikel in Form einer Beispiel-App mit future & promises implementiert werden, um anschließend den Transfer von der Theorie in die Praxis zeigen zu können. Dabei wird zunächst ein Überblick in die fertige App und ihre Funktionalität gegeben. Anschließend wird anhand von ausgewählten Code-Beispielen gezeigt, wie sich die Prozesse im Kontext der future & promises-Syntax programmieren lassen.
Sie möchten die hier erklärten Verfahren mit der App direkt ausprobieren? Hier finden Sie den Code!
Das Ziel – Erklärung der Test-Applikation mit Web-UI
- Info-PID: Hier kann die aktuelle Prozess-ID des R-Prozesses abgelesen werden, zu dem der Nutzer verbunden ist.
- Task starten: Hier können, zum Testen der Implementierung, aufwändige/prozessblockende Aufgaben gestartet werden. Diese umfassen:
- Das Laden einer CSV-Datei mit 1.500.000 Zeilen.
- Den Prozess 10 Sekunden stilllegen.
- Die Berechnung eines linearen Regressionsmodells auf Basis von zwei Vektoren mit jeweils 5.000.000 Einträgen
- Verfügbarkeitstest: Es kann getestet werden, ob der aktuelle Prozess ansprechbar ist, oder durch die Ausführung einer Aufgabe geblockt wird. Dabei wird zwischen der Verfügbarkeit des globalen R-Prozesses, auf dem die Shiny-App läuft, und der individuellen Nutzer-Session unterschieden (Die Unterscheidung wird später für das Testen interessant sein).
- „Live“ Task: Durch die Eingabeanzahl an Datenpunkten kann eine Aufgabe gestartet werden, welche das Ergebnis „Live“ im Plot darunter anzeigt. In Verbindung mit Punkt 2 dient dies lediglich der Vorstellung verschiedener Möglichkeiten zur Implementierung des asynchronen Workflows.
- Plan ändern: Hier kann der aktuelle Plan von einem sequenziellen Ablauf (sequential) zu einem asynchronen Ablauf (multisession) geändert werden. Auf diese Weise ist eine Evaluation im Rahmen der Verhaltensweise der App bzgl. beider Möglichkeiten möglich.
- Informationen/Resultate: Hier können die Resultate der aus Punkt 2 gestarteten Aufgaben eingesehen und zurückgesetzt werden. Außerdem finden sich hier Informationen über den aktuellen Plan sowie den Status der Aufgaben.
Die Komponenten – Wie wird der asynchrone Workflow implementiert?
1library(shiny)
2library(future)
3library(promises)
4library(dplyr)
⋮
10plan(multisession)
11
12reac <- reactiveValues(
13plan = "Multisession",
14random_number = round(runif(1, min = 1000, max = 9999))
15)
⋮
Code-Block #1
Um die Funktionsweise der App und die damit verbundene Verwendung von future & promises zu verstehen, wird nun der Aufbau der App anhand von Code-Snippets beschrieben.
Zu Beginn werden die nötigen Pakete geladen. In Zeile 10 wird der Ausgangsplan für die App gesetzt, in diesem Beispiel multisession. Das heißt, dass die mit future & promises implementierten Codeblöcke standardmäßig asynchron ausgeführt werden. In den Zeilen 12-15 werden zwei reaktive Variablen initialisiert, welche den aktuellen Plan und eine der Zufallszahlen für den Verfügbarkeitstest enthalten. Da die Definition hier in der global.R vorgenommen wird (d.h. außerhalb des Servers), gelten die Variablen für alle Nutzer, welche auf den R-Prozess verbunden sind.
Im Kontrast dazu werden im Server zusätzliche reaktive Variablen definiert, welche dadurch Session-spezifisch sind, d.h. sie werden für jeden Nutzer individuell bei Start einer Session angelegt.
Code-Block #2
Die Session-spezifischen Variablen enthalten die zweite Zufallszahl für den Verfügbarkeitstest sowie Informationen und Ergebnisse der laufenden/abgeschlossenen Aufgaben.
Auf Basis der notwendigen Definitionen, kann nun mit der ersten Aufgabe der Implementierung begonnen werden, dem Laden der CSV-Datei.
1function(input, output, session)}
2
3reac_sess <- reactiveValues(
4random_number = round(runif(1, min = 1000, max = 9999)),
5active_task = FALSE,
6current_task = "task_null",
7dataset = NULL,
8model = NULL
9)
⋮
Code-Block #3
In den Zeilen 65 & 66 wird eine Progress-Bar erstellt und gestartet, welche dem Nutzer den Status der Aufgabe anzeigt.
Achtung: Da die Aufgaben in einem separaten R-Prozess ausgeführt werden, lässt sich die von Shiny zur Verfügung gestellte Funktion withProgress nicht verwenden.
In den Zeilen 68 – 71 wird das Einlesen der CSV-Datei als asynchroner Prozess gestartet. Mit future({ Codeblock }) wird die Ausführung des Codes, nach dem zu Beginn von uns ausgewähltem Plan, auf einen anderen R-Prozess ausgelagert. Die Konsolenausgabe in Zeile 70 dient zur Nachvollziehung des Codes, d.h. welche Prozess-ID den Code ausgeführt hat. In den Zeilen 72 – 77 wird definiert was geschieht, sobald die Ausführung abgeschlossen ist. Wichtig hierbei ist der Pipe-Operator %…>% aus dem promises-Paket in Zeile 71, der dafür sorgt, dass der darauffolgende Code-Block erst nach erfolgreichem Abschluss des asynchronen Tasks ausgeführt wird.
Schließlich wird mit finally die Progress-Bar gelöscht, unabhängig davon, ob die Befehle vorher erfolgreich waren. Das Löschen der Progress-Bar ist nötig, sofern der vorherige asynchrone Block nicht erfolgreich abgeschlossen werden konnte. Deshalb wird dieser Block auch mit der normalen %>% Pipe ans Ende der Code-Kette angefügt.
⋮
63observeEvent(input$task_loaddata, {
64
65p <- Progress$new()
66p$set(value = 0.5, message = "Load Dataset")</span
67
68future({
69cat(paste0("--- Executed task 'Loading dataset' under PID: ", Sys.getpid(), "\n"))
70read.csv("1500000 Sales Records.csv")
71}) %...>%
72{
73reac_sess$dataset <- .
74reac_sess$current_task <- "task_load"
75reac_sess&active_task <- TRUE
76cat(paste0("--- Finished task 'Loading dataset' under PID: ", Sys.getpid(), "\n"))
77} %>%
78finally(~p$close())
79})
⋮
Nach der Implementierung der ersten asynchronen Aufgabe, kann diese direkt getestet werden. Dabei sollte sich folgendes Verhalten der App ergeben:
Code-Block #4
In den Zeilen 81 – 102 findet sich die Implementierung zum Stilllegen des Prozesses für zehn Sekunden. Diese läuft dabei zunächst identisch zum Laden des Datensatzes ab. Hervorzuheben ist die Anpassung der Progress-Bar in Zeile 89, nach fünf Sekunden Stillstand. Oft soll auch während der Ausführung der asynchron gestarteten Aufgabe der Überblick über die Aufgabe im Hauptprozess gewährleistet sein. Außerdem sollte es möglich sein, Änderungen, wie z.B. die Anpassung der Progress-Bar, vorzunehmen. Diese Änderungen müssen in der Regel immer außerhalb der future-Umgebung vorgenommen werden, da ohne Zusatzpakete wie bspw. ipc keine Kommunikation unter den verschiedenen Prozessen möglich ist.
⋮
81observeEvent(input$task_sleep, {
82
83p <- Progress$new()
84p$set(value = 0.1, message = "Begin Sleeping")
85
86future({
87Sys.sleep(5)
88}) %...>%
89{ p$set(value = 0.6, message = "Slept for 5 seconds") } %...>%
90{
91future({
92Sys.sleep(5)
93cat(paste0("--- Executed task 'Sleeping' under PID: ", Sys.getpid(), "\n"))
94})
95} %...>%
96{
97reac_sess$current_task <- "task_sleep"
98reac_sess$active_task <- TRUE
99cat(paste0("--- Finished task 'Sleeping' under PID: ", Sys.getpid(), "\n\n"))
100} %>%
101finally(~p$close())
102})
⋮
Code-Block #5
In den Zeilen 11 – 19 & 173 – 180 wird der Live-Task implementiert. Dies zeigt lediglich eine weitere Methode, wie sich der asynchrone Workflow verwenden lässt. In den Zeilen 11 – 19 wird der Datensatz für die Grafik als reaktive Variable gespeichert, in der die Erstellung des Datensatzes als asynchroner Prozess startet. In den Zeilen 173 – 180 kann der Datensatz schließlich abgerufen und gezeichnet werden. Hierbei ist es wichtig, dass die reaktive Variable plot_df nicht den Datensatz enthält, sondern das future-Objekt, welches den Datensatz erstellt. Deswegen muss der Zeichnungsprozess mit einer promise-Pipe (%…>%) an den Datensatz angefügt werden. Während der Datensatz „live“ erstellt und gezeichnet wird, sollte sich das gleiche Verhalten wie beim asynchronen Laden der CSV-Datei einstellen.
Der asynchrone Workflow lässt sich noch auf viele andere Möglichkeiten realisieren, so würde sich der future–Befehl aus dem letzten Codeblock bspw. auch direkt in der renderPlot({ … })-Umgebung implementieren lassen. Weiterhin lassen die Ergebnisse der future-Blöcke auf viele verschiedene Arten verwalten (z.B. Error-Handling für fehlgeschlagene Aufgaben/explizite Angaben von Funktion für erfolgreiche Aufgaben mit then( … )).
⋮
11plot_df <- eventReactive(input$number_cap, {
12cap <- input$number_cap
13
14future({
15x <- runif(cap)
16y <- runif(cap)
17data.frame("x" = x, "y" = y)
18})
19})
⋮
173output$number_plot <- renderPlot({
174plotdf() %...>%
175{
176df <- .
177ggplot(df, aes(x = x, y = y)) +
178geom_point()
179}
180})
⋮
Fazit
Auch wenn die Implementierung von Aufgaben mit future & promises nur einen spezifischen Aspekt der Optimierung von Shiny-Applikationen bedient, kann dies maßgeblich zur Steigerung der Zufriedenheit der User-Experience (UX) beitragen. Ihre Stärken spielen die oben gezeigten Optimierungspunkte dann aus, wenn viele Nutzer gleichzeitig mit der App arbeiten. Sind dann noch die individuellen Prozesse wie Datenbankzugriffe, Rendering von Grafiken und der Datenimport im Hinblick auf Laufzeit optimiert, so lässt sich in Zusammenarbeit mit dem asynchronen Workflow und der horizontalen Skalierbarkeit ein Gesamtpaket schnüren. Dieses verbindet die umfangreichen Möglichkeiten von Shiny-Applikationen mit den modernen Ansprüchen an Performance und Nutzerfreundlichkeit.
Sie haben Probleme mit der Performance ihrer Shiny-Apps? Wir von eoda helfen Ihnen gerne bei der Konzeption Ihrer Applikationen, vom initialen Design bis hin zur Performance-Optimierung bestehender Infrastrukturen und Apps. Erfahren Sie mehr.