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:
- High parallel cost for large object graphs and shared intermediate state: If you need to parse a large Dart syntax tree in parallel, load a large Kernel binary file, or have multiple workers repeatedly access the same complex intermediate result, it's hard to avoid cross-isolate copying or resorting to native memory, because the cost of cross-isolate transfer is usually linearly related to the size of the transferred data.
- High interoperability cost with native code: Native code (C/C++, Objective-C/Swift, Java/Kotlin) is generally based on a thread model. Native threads don't understand Dart's isolates and don't know which isolate a callback should enter.
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:
- Some immutable, safely shareable objects can be shared directly.
TransferableTypedDatacan transfer the underlying data; after sending, the original sender no longer holds usable data content.Isolate.exitcan send the result out when an isolate exits, avoiding the copying on the normal message send path.Isolate.runinternally utilizes the optimization of returning results via isolate exit; the result is sent viaexit, requiring no copy.
So this Dart multi-threading shared capability now has a relatively clear implementation form:
- Through
@pragma('vm:shared'), static fields or top-level variables can be marked as isolate group shared state. Currently, they must befinal, cannot belate, and the stored value must fall within the deeply immutable / trivially shareable range. - The shareable range includes strings, numbers, compile-time constants, some deeply immutable types, the internal implementation of
SendPort, static method tear-offs,TypedData,Struct, and closures that meet capture constraints. Arbitrary ordinary mutable objects likeList,Mapstill cannot be shared directly. dart:concurrentalready has some prototypes, including implementations ofMutexandConditionVariable.
The
@pragma('vm:shared')field itself must befinalin the current scheme, but it can reference shared binary state likeTypedData,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:
NativeCallable.isolateGroupBoundhas entered the Dart SDK and the Dart 3.12.2 API: Native code can call it synchronously from any thread; the callback will execute in the isolate group to which the creator belongs, but not within a specific isolate.- When the callback accesses ordinary non-shared static or top-level fields, it will throw an
AccessError. dart:concurrent'sMutexandConditionVariablealready have implementations and tests.- In the future, it may indeed expand to "share everything," supporting the sharing of ordinary Dart objects, but this is being advanced separately from the current shared native memory phase.
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:
- Some native library, such as the native layer of an audio engine or UI framework, synchronously calls back into Dart from any native thread via FFI's
NativeCallable.isolateGroupBound. - This callback does not enter the ordinary isolate that created it; instead, it executes within the corresponding isolate group, outside of any specific isolate.
- Inside your callback, you call a function from some third-party Dart package, like a log formatter, a parsing utility, or a synchronization algorithm.
- This third-party package internally happens to use ordinary non-shared static variables for caching, singletons, or lazy-loaded state.
- When execution reaches this part of the code, the runtime will fail because it accesses isolate-local state.
The main problems here are:
- The error occurs inside a third-party library, not in the line you directly wrote. So, does the blame fall on the native library, the third-party Dart library, or the caller for not correctly partitioning the execution context?
- An ordinary developer might not immediately realize that "this function does not belong to any ordinary isolate": native thread → FFI trampoline → isolate-group-bound callback → third-party function. This execution context is determined by API semantics, not by you explicitly writing an
Isolate.runShared(...). - When a third-party library author writes a static variable, it is perfectly compliant code under the original isolate model; but it might not be suitable for calling in an isolate-group-bound environment.
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:
@pragma('vm:shared')fields must befinal, cannot belate, and static types that are clearly impossible to be deeply immutable can be intercepted at compile time.- If the closure or captured values for
NativeCallable.isolateGroupBounddo not meet the trivially shareable constraint, anArgumentErrorcan be thrown when creating the callback. - After the code passes the previous checks, if it touches non-shared static state hidden deep in the call chain during actual execution, it can still only be exposed at runtime via an
AccessError.
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
- https://github.com/dart-lang/sdk/issues/55991
- https://github.com/dart-lang/sdk/issues/56841
- https://github.com/orgs/dart-lang/projects/110
- https://api.dart.dev/dart-ffi/NativeCallable/NativeCallable.isolateGroupBound.html
- https://github.com/dart-lang/sdk/blob/main/sdk/lib/ffi/ffi.dart
- https://github.com/dart-lang/sdk/blob/main/sdk/lib/concurrent/concurrent.dart
- https://github.com/dart-lang/sdk/blob/main/runtime/tests/vm/dart/isolates/shared_fail_without_flag_test.dart
- https://github.com/dart-lang/sdk/blob/main/tests/ffi/isolate_group_bound_callback_test.dart
- https://github.com/dart-lang/sdk/issues/61287
- https://github.com/dart-lang/sdk/issues/63559