skip to content

Under Laravel Octane on Swoole, how do you fetch three dashboards' data in parallel with Octane::concurrently, and where do those closures actually run?

level: middleimportance: should knowfreq 30%

answer

  1. array of closures in, array out
  2. keys kept, destructure the result
  3. separate task worker processes
  4. closures serialized, capture scalars
  5. --task-workers, at most 1024 tasks

basics

~20 s

Pass Octane::concurrently an array of closures, one per dashboard query, and destructure the returned array; keys are preserved. Each closure is serialized and runs in a Swoole task worker process, so capture only serializable values like IDs.

solid answer

~40 s

`Octane::concurrently([fn () => ..., fn () => ..., fn () => ...])` takes an array of closures and returns an array of their results with the same keys, so `[$sales, $traffic, $support] = Octane::concurrently([...])` works, and string keys work too. On Swoole, Octane wraps each closure in a `SerializableClosure` and hands the set to the server's `taskWaitMulti()`, which runs them in **task worker** processes - separate processes from the HTTP worker, sized by `--task-workers` (default `auto`, the CPU count). The request waits until all finish or the wait time passes (default 3000 ms). Because the closures are serialized, captured variables must be serializable, and inside a task there is no current HTTP request, so pass IDs and scalars rather than `$request`. Octane's docs cap one call at 1024 tasks because of Swoole's task system.

code

php · 25 lines
php
<?php

namespace App\Http\Controllers;

use App\Reports\SalesReport;
use App\Reports\SupportReport;
use App\Reports\TrafficReport;
use Illuminate\Http\Request;
use Laravel\Octane\Facades\Octane;

class DashboardController
{
    public function __invoke(Request $request)
    {
        $teamId = $request->user()->team_id; // capture a scalar

        [$sales, $traffic, $support] = Octane::concurrently([
            fn () => SalesReport::forTeam($teamId),
            fn () => TrafficReport::forTeam($teamId),
            fn () => SupportReport::forTeam($teamId),
        ]);

        return view('dashboard', compact('sales', 'traffic', 'support'));
    }
}

go deeper

for a junior

Recall that Octane::concurrently takes an array of closures and returns their results with the same keys, on Swoole only.

for a middle

Explain task workers, closure serialization, the 3000 ms default wait, --task-workers sizing and the 1024-task ceiling.

for a senior

Show you capture only scalars, size the shared task pool for peak fan-out, and move slow or retryable work to queues.

for a principal

Weigh in-request fan-out on Swoole against asynchronous designs, given the lock-in and the shared task pool as a capacity bottleneck.

## The scenario A dashboard page shows three panels - sales, traffic and support tickets - each backed by a slow, independent query or API call of about 300 ms. Run one after another, the page takes about 900 ms. On **Laravel Octane with Swoole**, `Octane::concurrently` can run them side by side so the page takes roughly as long as the slowest panel. ## The API ```php [$sales, $traffic, $support] = Octane::concurrently([ fn () => SalesReport::forTeam($teamId), fn () => TrafficReport::forTeam($teamId), fn () => SupportReport::forTeam($teamId), ]); ``` - The argument is an **array of closures** (or other callables). - The return value is an array with the **same keys**: list destructuring works for a list, and `['sales' => fn () => ...]` gives `$result['sales']`. - An optional second argument is the wait time in milliseconds, **3000** by default. - An empty array returns `[]` immediately. ## Where the closures run This is the part interviewers probe. On Swoole, Octane's `SwooleTaskDispatcher`: 1. wraps each closure in a `SerializableClosure` - the closure and its captured variables are **serialized**; 2. calls the Swoole server's `taskWaitMulti()` with the batch and the wait time in seconds; 3. Swoole hands each task to a **task worker** - a separate process pool from the HTTP workers - which unserializes and runs it; 4. the HTTP worker blocks until every result is back or the wait time runs out, then maps results back to the original keys. Each task worker runs the closure inside its own clone of the application, with Octane's usual per-operation resets (`TaskReceived` listeners). Consequences: - **No shared memory with the request.** Changing a variable inside the closure does not change it in the caller; only the return value comes back. - **No current HTTP request or session in the task.** `request()` and `auth()->user()` do not describe the user who triggered the dashboard. Capture what you need - `$teamId = $request->user()->team_id;` - before the call. - **Captured values must serialize.** IDs, strings and arrays are ideal. Resources, open file handles and some objects cannot be serialized; Eloquent models serialize but carry their whole attribute set with them. - **Results must serialize too**, since they travel back between processes. ## What the parallelism buys | Approach | Page time for three 300 ms panels | Where the work runs | |---|---|---| | sequential calls in the controller | about 900 ms | the HTTP worker | | `Octane::concurrently` on Swoole | about 300 ms plus overhead | three task workers | | `Octane::concurrently` off Swoole | about 900 ms | the HTTP worker, one by one | The overhead is serializing each closure and its result and the hand-off between processes, which is small next to a slow query or API call but makes `concurrently` pointless for work that takes a few milliseconds. ## Capacity: task workers and the 1024 cap - `php artisan octane:start --workers=4 --task-workers=6` sizes the pools. `--task-workers` defaults to `auto`, which Octane resolves to the CPU count (reading the container's CPU quota when there is one). - Tasks from **all** HTTP workers share the task pool. If ten requests each launch three tasks and there are six task workers, some tasks wait in Swoole's queue, and the wait-time budget keeps running. - Octane's docs say not to pass more than **1024 tasks** to one `concurrently` call, because of limits in Swoole's task system. For large fan-outs, batch the work or move it to queued jobs. - Ticks also run as tasks on the same pool, so a busy pool delays them. ## When a queue fits better `concurrently` is for work the **current response needs** and that finishes well within the wait time. Choose a queued job instead when: - the user does not need the result in this response; - the work may take seconds or minutes, or must be retried on failure; - the work must survive a server restart or deploy. (How queued jobs work is a separate topic.) ## Summary Closures in, results out with the same keys, executed in separate Swoole task worker processes; serialize-friendly captures, a 3000 ms default wait, a 1024-task ceiling per call, and a shared task pool you size with `--task-workers`.

  • Why can't a task closure read auth()->user() for the dashboard's viewer?
    The closure runs in a task worker process, in that worker's own application clone. The HTTP request, its session and its authenticated guard stay in the HTTP worker. Read the user or their IDs before calling `concurrently` and capture those values in the closures.
  • What happens if many requests call Octane::concurrently at once?
    All HTTP workers share one task worker pool. When tasks outnumber task workers, Swoole queues them, so later tasks start late while each caller's wait time keeps counting. Size `--task-workers` for peak fan-out, keep tasks short, and move heavy or optional work to queued jobs.

saying these in an interview costs you the question

  • concurrently runs the closures as coroutines inside the same HTTP worker
  • A closure can modify a captured variable and the caller sees the change
  • Results come back in completion order, so keys are lost
  • Tasks inside concurrently run with the caller's request and session
  • --task-workers is shared with HTTP workers, so it needs no sizing