3  Scaling Single-Threaded Applications

3.0.0.1 Understanding the Single-Threaded Nature of R

R is inherently single-threaded, meaning each R session can process only one action at a time. This characteristic poses a unique challenge in web applications like Shiny. For instance, in a Shiny application running on a single R session, if two users simultaneously trigger a process that takes 5 seconds to complete, the first user will wait 5 seconds, while the second user will experience a 10-second delay. This sequential processing leads to scalability issues in multi-user environments.

3.0.0.2 Implementing Asynchronous (Async) Code

Implementing asynchronous code addresses concurrency challenges in single-threaded R applications. Async code in allows the creation of separate R sessions for handling different tasks concurrently. When applied in Shiny applications, this approach enables simultaneous task execution without blocking other users. A main Shiny session can offload specific tasks to separate R sessions, allowing multiple users to interact with the application without experiencing significant delays. However, this approach requires careful coding to manage the asynchronous tasks and ensure efficient communication between the primary and auxiliary sessions.

box::use(future, promises)

# Set the plan to multisession to enable asynchronous execution
future$plan(future$multisession)

# Define an asynchronous task, such as a slow computation
slow_function <- function() {
  Sys.sleep(5) # Simulates a time-consuming task
  return("Task completed")
}

future$future({
  slow_function()
}) |>
  promises$then(function(result) {
    print(result)
  })

# While the slow_function is running, the R console remains responsive
print("The async task is running in the background...")
[1] "The async task is running in the background..."

3.0.0.3 Creating a Load Balancer

Beyond asynchronous coding, another scalability solution is load balancing. Load balancing involves running multiple instances of the same Shiny application on different ports (e.g., 8000-8020). This setup distributes incoming user requests across multiple sessions, significantly reducing the chance of performance bottlenecks. For instance, with 20 instances running, the 21st user would only experience a delay if routed to the same session as the first user, and both users perform a resource-intensive action simultaneously.

Load balancing can be implemented using round-robin, proportional, or random distribution. The round-robin method, often used with Nginx upstream, distributes user requests evenly across available sessions. In contrast, random distribution can be more effective in complex setups.

While asynchronous programming helps optimize single-session Shiny applications, it demands careful coding. On the other hand, load balancing offers a more scalable solution by multiplying the number of available sessions. However, it also has limitations based on the number of concurrent users and open ports. Both approaches are crucial in efficiently scaling single-threaded R applications in a multi-user environment.

  1. Single User, One Session
    • Ports Consumed: 1
    • Async Operations: No
    • Concurrent Users: 1
    • Expectation: Normal Behavior
    • Explanation: R’s single-threaded nature implies standard performance. Enhancements can be made via parallelism using tools like furrr, depending on available resources such as cores and processor speed.
  2. Multiple Users, One Session
    • Ports Consumed: 1
    • Async Operations: No
    • Concurrent Users: More than One
    • Expectation: Slower with Increased Users
    • Explanation: With one R session accommodating multiple users, operations become sequential. A task that takes one second for one user will double if two users attempt simultaneous actions, with only one user noticing the delay.
  3. Multiple Users, One Session (Async Enabled)
    • Ports Consumed: 1
    • Async Operations: Yes
    • Concurrent Users: More than One
    • Expectation: Normal to Slightly Slower
    • Explanation: The session mimics a single-user environment with well-implemented asynchronous code, maintaining efficiency despite the increased user load.
  4. Users Fewer Than Sessions
    • Ports Consumed: Variable (n)
    • Async Operations: No
    • Concurrent Users: Less than or Equal to Sessions (u ≤ n)
    • Expectation: Normal to Slightly Slower
    • Explanation: This scenario simulates a single-user session experience, even without asynchronous code, as long as the number of users is less than or equal to the number of sessions and resources aren’t constrained.
  5. Users Exceed Sessions
    • Ports Consumed: Variable (n)
    • Async Operations: Yes
    • Concurrent Users: More than Sessions (n+1)
    • Expectation: Slower when Users Exceed Sessions
    • Explanation: Users are distributed across multiple instances within the port range. In cases where users exceed the number of sessions, the lack of asynchronous operations can lead to noticeable delays in workflows as users grow. If good async code is written, then you will primarily be limited by the total resources on your system.