Isolate round-trip latency degrades ~100x once 8 isolates are concurrently executing Dart (engine only; flat on the standalone VM) · Issue #192343 · flutter/flutter · GitHub
Skip to content

Isolate round-trip latency degrades ~100x once 8 isolates are concurrently executing Dart (engine only; flat on the standalone VM) #192343

Description

@Crdzbird

Summary

Under the Flutter engine, round-trip message latency between two isolates degrades by roughly 100x as soon as 8 other isolates are concurrently executing Dart. The step is sharp and lands at exactly 8 on every run.

The identical program on the standalone Dart VM shows no degradation at all, at any count I tried. That is what makes me think this is engine-side rather than a VM property.

Steps to reproduce

Drop this in test/ of any Flutter project and run flutter test. No plugins, no FFI, only dart:isolate.

import 'dart:async';
import 'dart:io';
import 'dart:isolate';

import 'package:flutter_test/flutter_test.dart';

/// Stays inside its message handler forever, using almost no CPU.
void sleeperEntry(SendPort reply) {
  reply.send('up');
  while (true) {
    sleep(const Duration(milliseconds: 2));
  }
}

void echoEntry(SendPort reply) {
  final inbox = RawReceivePort();
  inbox.handler = (Object? m) => reply.send(m);
  reply.send(inbox.sendPort);
}

void main() {
  test('round-trip latency vs number of isolates executing Dart', () async {
    final rp = ReceivePort();
    final events = StreamIterator<dynamic>(rp);
    await Isolate.spawn(echoEntry, rp.sendPort);
    await events.moveNext();
    final inbox = events.current as SendPort;
    final sleepers = <Isolate>[];
    addTearDown(() {
      for (final s in sleepers) {
        s.kill(priority: Isolate.immediate);
      }
    });

    for (var k = 0; k <= 12; k++) {
      while (sleepers.length < k) {
        final up = ReceivePort();
        sleepers.add(await Isolate.spawn(sleeperEntry, up.sendPort));
        await up.first;
        up.close();
      }
      for (var i = 0; i < 10; i++) {
        inbox.send(i);
        await events.moveNext();
      }
      final sw = Stopwatch()..start();
      for (var i = 0; i < 100; i++) {
        inbox.send(i);
        await events.moveNext();
      }
      print('sleepers=$k roundTrip='
          '${(sw.elapsedMicroseconds / 100 / 1000).toStringAsFixed(3)}ms');
    }
    rp.close();
  }, timeout: const Timeout(Duration(minutes: 5)));
}

Expected results

Round-trip latency should not step at a fixed number of concurrently-running isolates. If there is an intentional cap on how many isolates may execute Dart at once, exceeding it should degrade gradually rather than by two orders of magnitude, and ideally it would be documented.

Actual results

flutter test, macOS host:

sleepers=0  roundTrip=0.025ms
sleepers=1  roundTrip=0.022ms
...
sleepers=7  roundTrip=0.022ms
sleepers=8  roundTrip=2.446ms   <-- ~100x
sleepers=9  roundTrip=1.233ms
sleepers=12 roundTrip=3.722ms

The same loop as a plain Dart program (dart run, no Flutter), identical isolate code:

sleepers=0  roundTrip=0.005ms
sleepers=7  roundTrip=0.005ms
sleepers=8  roundTrip=0.004ms
sleepers=12 roundTrip=0.004ms

Flat throughout. The step only appears under the engine.

Additional context

I hit this in a package that runs several long-lived worker threads, each owning an isolate created with Isolate.create and drained with runEventLoopSync on a thread the package owns. There the same threshold is far more punishing — round trips go from 0.025 ms at 7 threads to 194 ms at 8, about 8000x — because those threads also poll on a timeout, so the added latency compounds with the poll interval. Latency there tracks the poll cap exactly, which is what makes it look like the woken thread is waiting for something rather than being scheduled.

A few things I checked, in case they narrow it down:

  • It is not the count of isolates, but the count executing Dart. Isolates that park in the message handler between messages have no effect: a pool of 12 that idle between jobs stays flat at 0.082 ms. Only isolates continuously inside a handler count.
  • It is not specific to any one design. 8 sleeping isolates alone reproduce it, and so do 6 owned-thread carriers plus 2 sleepers. The trigger appears to be the total.
  • It is not CPU contention. The sleepers use almost no CPU by construction, and the process sits at 6–8% CPU while latency is at its worst.
  • I wondered about Scavenger::MaxMutatorThreadCount(), which computes to 8 with default flags — (new_gen_semi_max_size 16MB / kTLABSize 512KB) / 4. The arithmetic matches the observed threshold, but I could not test it: the engine rejects --new_gen_semi_max_size with [FATAL:flutter/shell/common/switches.cc(482)] Encountered disallowed Dart VM flag, and on the standalone VM there is no cliff to move. So I am reporting the observation rather than a cause.

Reproduced on the macOS host under flutter test, in two separate projects including a freshly created flutter create one. I have not checked whether the threshold is the same on Android or iOS — my Android testing used 4 worker threads, which is below it.

Flutter Doctor output

Flutter 3.47.1 • channel stable
Framework • revision 6655482ec0 • 2026-08-19
Engine • hash 11d79658c444477b06513d32b52c8c4ccb7276b0 (revision 5d53178869) • 2026-08-18
Host: macOS 26.5.1, arm64 (10 performance + 4 efficiency cores)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions