跪拜 Guibai
← Back to the summary

Dart's Shared-Memory Multithreading Lands with IsolateGroupBound

A long time ago, Dart started adding shared memory capabilities to isolates. Anyone who has worked with Flutter knows that isolates, as Dart's parallel execution model, have the most troublesome aspect: ordinary Dart object state cannot be directly shared like Java or C++ threads.

So later Dart got isolate groups, and Flutter also got related support for background isolates, but one scenario has always been missing: native threads can synchronously call back into Dart without necessarily being bound to and waiting for a specific isolate.

In plain terms, you couldn't just call it arbitrarily.

So now this part is currently being implemented as part of shared memory:

Support for Dart code that exists within an Isolate Group but can execute on any isolate, provided that this code can only access the static or top-level state shared by that Isolate Group.

Isolates have always been Dart's parallel execution model. Each isolate has its own independent static state and accessible objects. By default, isolates cannot directly access each other's ordinary mutable Dart objects, so they have always passed messages via SendPort.

Later, when isolate groups arrived, multiple isolates within the same isolate group could share code, type information, and some VM internal structures, and might run on the same managed heap. However, the VM still maintains object access boundaries; just because they are physically on the same heap doesn't mean you can directly grab another isolate's ordinary objects.

So, for ordinary mutable objects, cross-isolate sending requires copying. The obvious benefit is that under the default Dart object model, developers basically don't need to worry about locks. Ordinary Dart objects cannot be modified by two isolates simultaneously, thus avoiding the common data races found in traditional shared memory models.

Of course, the downsides are also obvious:

So the Dart team's approach is: Shared memory doesn't necessarily mean immediately opening up arbitrary mutable Dart objects. You can first, on the basis of retaining the isolate message-passing model, allow some safe objects and native memory to be shared directly, while simultaneously solving the problem of native threads synchronously calling back into Dart.

Thus, some support has developed over this period:

So this Dart multi-threading shared capability now has a relatively clear implementation form:

The @pragma('vm:shared') field itself must be final in the current scheme, but it can reference shared binary state like TypedData, Struct, or native memory. Concurrent reads and writes to these mutable contents will not automatically get full sequential consistency, so mutexes, condition variables, atomic operations, etc., are still needed to avoid data races.

The current progress mainly includes:

In practice, isolateGroupBound and shared native memory can reduce the cost of copying and bridging data back and forth between isolates/native threads, such as Uint8List, Struct, native buffers, immutable configuration values, etc. However, it still cannot directly allow multiple isolates to share arbitrary ASTs, Lists, Maps, or business objects.

For example, in a large JSON scenario, you can keep the raw UTF-8 bytes in a Uint8List or native buffer, allowing different execution contexts to read the same binary input. But for example:

final Object? decoded = jsonDecode(utf8.decode(bytes));

At this point, what jsonDecode produces is usually still an ordinary Map / List object graph. These results are still isolate-local at this stage and will not be directly co-accessed by multiple isolates just because of isolateGroupBound.

Additionally, this set of capabilities can bring Dart closer to the calling model of ordinary C libraries in the future: after Dart AOT code is statically or dynamically linked into a native application, when any native thread calls an exported symbol, it can have a clear isolate-group-bound execution semantic.

For example, Dart code could be statically/dynamically linked into a native application like an ordinary C library, and any native thread could directly call Dart exported symbols (@ffi.Export()), no longer needing the restriction of "entering/leaving an isolate."

If completed, the C++ implementation of dart:io could even be migrated back to pure Dart and split into independent packages.

Of course, there are still some issues, such as the problem of hidden state access within an isolate-group-bound context:

If third-party dependency code quietly reads an ordinary global variable or static cache, then when it executes that step in an isolate-group-bound callback, it might throw a hard-to-locate AccessError. Trying to statically distinguish in the type system between "functions that only access shared state" and "functions that might access isolate state" would involve significant language changes.

For example, consider this old isolate code:

import 'dart:isolate';

int global = 0;

void main() async {
  global = 42;
  await Isolate.run(() {
    print(global); // => 0, note it's not 42
    global = 24;
  });
  print(global); // => 42, the main isolate's global was not modified
}

Here, Isolate.run creates a new isolate. The new isolate does not inherit the current state of global = 42 from the main isolate; instead, it re-initializes its own global to 0 according to the top-level initializer. No matter how the child isolate modifies its own field, it will not affect the copy in the main isolate.

This code is easy to understand in isolation because you can see at a glance that each isolate has its own independent copy of the state. But the real behavior might be hidden inside a third-party dependency, such as a logging library cache, a singleton, a lazily loaded field, or internal state of a platform library, making it much harder to discover.

If this code accessing global wasn't written by you, but exists inside some third-party package, or in AI-generated code, it could become a very tricky bug.

Then, introducing isolate-group-bound execution creates another category of problems.

import 'dart:concurrent';
import 'dart:ffi';
import 'dart:typed_data';

@pragma('vm:shared')
final counter = Uint32List(1);

@pragma('vm:shared')
final mutex = Mutex();

void increment() {
  mutex.runLocked(() {
    counter[0]++;
  });
}

void main() {
  final callback =
      NativeCallable<Void Function()>.isolateGroupBound(increment);

  // Pass callback.nativeFunction to a native library; native code can call it synchronously from any thread.

  callback.close();
}

Isolate-group-bound code can only access shared static or top-level state. Once it accesses ordinary non-shared static state, the specification requires throwing an AccessError.

For example, your application architecture might look like this:

The main problems here are:

An ideal solution would be to distinguish at compile time between "this function only touches shared state" and "this function might touch isolate state," similar to how Swift does static checking via Sendable and actor isolation.

But squeezing this static distinction into the existing Dart type system would require very large language changes. Especially when encountering higher-order functions, like APIs that accept callbacks such as List.map, List.forEach, closures can capture arbitrary state, and function effects can propagate through multiple layers of calls, making static analysis and type annotations complex.

So the current implementation uses a hybrid check:

Additionally, dart:async related capabilities cannot be used normally in an isolate-group-bound context. APIs that depend on the event loop and microtask queue, such as Future, Stream, Timer, Completer, Zone, will directly throw errors. So, at this stage, it is more suitable for synchronous, short-lived, predictable FFI callbacks.

You could say there are pros and cons. Once cross-thread access to mutable TypedData, Struct, or native memory is opened up, data race risks will appear. At the same time, a large number of packages in the ecosystem assume by default that they always run within a normal isolate and event loop. Therefore, after the isolate-group-bound semantics are introduced, both platform libraries and third-party libraries will need a period of compatibility and verification.

Links