No one can live entirely on their own, nor can any country or society exist in isolation.

-- Daisaku Ikeda

Every language is now having its own way to deal with concurrency and parallel programming, in the case of Dart, the designers created Isolates. By default, Dart is running its procedures inside the main isolate, in a single thread, a bit like JavaScript. Using one thread can have drawbacks, especially when the application deals with I/O and high volume of data. If the Future and Stream classes don't fix the issues, a developer can spawn another Isolate, isolated from the main one, with its own thread. One can imagine this new environment as a totally fresh sandbox... Let see how to play with that.

Introduction

The next examples present in this article will require dart:async and dart:isolate modules.

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

Enter fullscreen mode Exit fullscreen mode

The Dart code is running by default in the main isolate, like previously said, do we have a way to know on which isolate our code is running? Yes. The Isolate.current property will return an Isolate object as a reference to the current Isolate. Let creates a new function called printIsolateInfo() to see what kind of values have been set on those objects.

void printIsolateInfo(Isolate isolate) {       
  print({
    'debugName': isolate.debugName,
    'hashCode': isolate.hashCode,
    'runtimeType': isolate.runtimeType,
    'errors': {
      'hashCode': isolate.errors.hashCode,
      'runtimeType': isolate.errors.runtimeType,
      'isBroadcast': isolate.errors.isBroadcast,
    },
    'controlPort': {
      'hashCode': isolate.controlPort.hashCode,
      'runtimeType': isolate.controlPort.runtimeType,
    },
    'pauseCapability': {
      'hashCode': isolate.pauseCapability.hashCode,
      'runTimeType': isolate.pauseCapability.runtimeType,
    },
    'terminateCapability': {
      'hashCode': isolate.pauseCapability.hashCode,
      'runTimeType': isolate.pauseCapability.runtimeType,
    }
  });
}

Enter fullscreen mode Exit fullscreen mode

Don't forget to create the entry-point function...

void main(List<String> arguments) async {
  printIsolateInfo(Isolate.current);
}

Enter fullscreen mode Exit fullscreen mode

... And then run the code.

$ dart run
{
  debugName: main,
  hashCode: 366158347,
  runtimeType: Isolate,
  errors: {
    hashCode: 953705591,
    runtimeType: _BroadcastStream<dynamic>,
    isBroadcast: true
  },
  controlPort: {
    hashCode: 1217433094,
    runtimeType: _SendPort},
    pauseCapability: {
      hashCode: -1197642896,
      runTimeType: _Capability
    }, 
  terminateCapability: {
    hashCode: -1197642896,
    runTimeType: _Capability
  }
}

Enter fullscreen mode Exit fullscreen mode

As you can see, the debugName attribute is containing the string main, a direct reference to our main isolate. The unique hashCode should always be the same across the code, because it's a reference to the main isolate. The errors attributes broadcast all errors from the isolate via a Stream. The pauseCapability contains the information about the pause capability, a way to pause/resume the execution of an isolate. Finally, the terminateCapability is containing the capabilities to use kill or setErrorsFatal on the isolate.

Now, let spawn a new Isolate and see what this function is printing.

void main(List<String> arguments) async {
  printIsolateInfo(Isolate.current);
  Isolate.spawn(
    (dynamic msg) {
      printIsolateInfo(Isolate.current);
    },
    'init',
    debugName: 'spawnTest'
  );
}

Enter fullscreen mode Exit fullscreen mode

$ dart run
{
  debugName: main,
  hashCode: 17494084,
  runtimeType: Isolate,
  errors: {hashCode: 891951382, runtimeType: _BroadcastStream<dynamic>, isBroadcast: true},
  controlPort: {hashCode: -353652440, runtimeType: _SendPort},
  pauseCapability: {hasCode: -69010913, runTimeType: _Capability},
  terminateCapability: {hasCode: -69010913, runTimeType: _Capability}
}

{
  debugName: spawnTest,
  hashCode: 488819858,
  runtimeType: Isolate,
  errors: {hashCode: 616290898, runtimeType: _BroadcastStream<dynamic>, isBroadcast: true},
  controlPort: {hashCode: 1976022887, runtimeType: _SendPort}, 
  pauseCapability: {hasCode: -259943465, runTimeType: _Capability}, 
  terminateCapability: {hasCode: -259943465, runTimeType: _Capability}
}

Enter fullscreen mode Exit fullscreen mode

The hashCode and the debugName are different. In fact, the content of the debugName, if net set, would be main.<anonymous closure> with a anonymous function or the name of the function called. What does it mean? Well, the code is executing in a totally different isolate, outside of the main function.

Spawning an Isolate

An isolated Dart execution context.

-- Dart Language Documentation, Isolate class

The easiest way to spawn an isolate is to use the Isolate.run() static function. When using it, the computation will be done inside an isolate and the value will be returned to the caller.

void main(List<String> arguments) async {      
  final int result = await Isolate.run(
    () {       
      return List                              
        .generate(100, (i) => i)               
        .reduce((i,acc) => i+acc );            
    }                                          
  );                                           
  print(result);       
}

Enter fullscreen mode Exit fullscreen mode

$ dart run
4950

Enter fullscreen mode Exit fullscreen mode

The idea behind Isolate.run() is to hide all the complexity of sharing a ReceivePort on the caller side. Indeed, when an isolate is started, only a controlPort (used to send message to the isolate) can be shared with it. To receive message from the spawned isolate, a SendPort should be created and shared with the caller. In fact, this can be seen with the Isolate.spawn() function.

Future<Object?> myRun(Object? Function() compute) {
  ReceivePort retPort = ReceivePort();
  Isolate.spawn(
    (SendPort sendPort) {
      var result = compute();
      sendPort.send(result);
      Isolate.exit();
    },
    retPort.sendPort,
  );
  return retPort.take(1).toList();
}

void main(List<String> arguments) async {
  print(await myRun(() => 1));
}

Enter fullscreen mode Exit fullscreen mode

$ dart run
[1]

Enter fullscreen mode Exit fullscreen mode

When an Isolate is spawned, the first argument passed can be any kind of data but because an Isolate is... isolated, then a way to send back the result of the computation is required. This is where the ReceivePort class is taking place. A ReceivePort() object is like a channel, on one side, messages are sent, and messages are received on the other side. When starting our Isolate, the only argument passed is ReceivePort().sendPort, in short, a reference to a SendPort() object, the one side used to send message. This means the newly spawned Isolate can send messages to the main one via this port.

The computation as an anonymous function is then executed, the result sent to the caller via the SendPort() object and the Isolate terminated using the Isolate.exit() method. On the caller side, the ReceivePort is acting as a Stream object, only one message is taken via the Stream.take() method and this message is converted to a List, then returned. The official implementation is a bit more complex, it catch the errors and exceptions as well and use a Completer object to deal with the Future returned by the Isolate.

The last way to spawn an isolate is to use Isolate.spawnUri(). This one is a bit special, because it will call another Dart program inside its own isolate, where the uri parameter is the path to this executable.

// bin/i.dart
import 'dart:async';
import 'dart:isolate';
import 'dart:io';

void main(List<String> arguments) async {
  ReceivePort receiver = ReceivePort();
  Isolate.spawnUri(
    Uri.parse('./t.dart'),
    ["this", "is", "a", "test"],
    receiver.sendPort
  );
  await receiver.take(2).listen((message) {
    final hashCode = Isolate.current.hashCode;
    final date = DateTime.now();
    print("received ($hashCode}): ${date} ${message}");
  });
}

Enter fullscreen mode Exit fullscreen mode

// bin/t.dart
import 'dart:isolate';
import 'dart:async';

void main(List<String> arguments, SendPort sender) async {
  sender.send(
    [Isolate.current.hashCode, arguments]
  );
  await Future.delayed(
    Duration(seconds: 1),
    () {
      sender.send([Isolate.current.hashCode, 'delayed!']);
    }
  );
  Isolate.exit();
}

Enter fullscreen mode Exit fullscreen mode

$ dart run
received (1058204931}): 2026-07-21 11:16:54.266153 [53502371, [this, is, a, test]]
received (1058204931}): 2026-07-21 11:16:55.277365 [53502371, delayed!]

Enter fullscreen mode Exit fullscreen mode

Stack Memory Isolation Test

A whole stack of memories never equal one little hope.

-- Charles M. Schulz

Is an isolate's stack isolated from other isolate(s)? That's a good questions in fact, because true isolation also means having its own stack. In case of stack overflow, the isolate crashes without impacting the main isolate. The best way to check that is to create an uncontrolled recursive function (acting as an infinite loop). Dart does not support tail recursive function or other functional method to avoid using the stack when creating a recursive function. In fact, if one wants to use recursive functions in Dart and avoid stack overflows, a trampoline function is required.

void loop_init(SendPort sender) {
  loop(sender, 0);
}

void loop(SendPort sender, int counter) {
  loop(sender, counter+1);
}

void main(List<String> arguments) async {
  bool breaker = true;
  ReceivePort receiver = ReceivePort();
  ReceivePort error = ReceivePort();
  Isolate.spawn(
    loop_init,
    receiver.sendPort,
    onError: error.sendPort,
    onExit: receiver.sendPort,
  );
  error.listen((x) {
    print(x);
    breaker = false;
  });
  receiver.listen((x) {
    print(x);
  });
  Future.delayed(Duration(seconds: 10), () {
    print("test");
  });
}

Enter fullscreen mode Exit fullscreen mode

$ dart run
[Stack Overflow, #0      loop (file:///home/user/tmp/i/bin/i.dart:32:1)
#1      loop (file:///home/user/tmp/i/bin/i.dart:33:3)
...
#16921  loop_init (file:///home/user/tmp/i/bin/i.dart:29:3)
#16922  _delayEntrypointInvocation.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:316:17)
#16923  _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:192:12)
]
null
test

Enter fullscreen mode Exit fullscreen mode

It seems the program is still alive, even after a crash on the new isolate via a stack overflow, the main one is still running.

Heap Memory Isolation Test

The life of the dead is placed in the memory of the living.

-- Marcus Tullius Cicero

An isolate has its own stack, but what about the heap? The stack is limited in size by the system (usually few Megabytes), but the heap is using dynamic allocation by asking memory from the kernel itself (e.g. malloc). I'm still not really sure how Dart is doing the memory allocation, and in fact, it should probably depend on the final application, a native program will not use the same methods to allocate memory than a program running inside the Dart VM. Anyway, I assume any kind of object creation is created on the heap, so, one object from the main isolate should not be available from another isolate.

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

Enter fullscreen mode Exit fullscreen mode

The O class can be used as a factory when creating it via the O.create() function. This class is a simple one, it will only have one private attribute called _value as integer, a getter and also a setter.

class O {
  static O _instance = O();
  int _value;

  O({int value = 0}) : this._value = value;

  int getValue() => _value;
  void setValue(int v) => _value = v;

  factory O.create({int value = 0}) {
    _instance.setValue(value);
    return _instance;
  }
}

Enter fullscreen mode Exit fullscreen mode

The main entry-point creates a new O() object, and then, will do some operation on it after a delay via the Future.delayed() function. The same is done inside an Isolate, both are sharing the same object reference.

void main(List<String> arguments) async {
  var object = O.create(value: 1024);

  Future.delayed(Duration(seconds: 1)).then((_) {
    print('main(hashcode): ${object.hashCode}');
    print('main: ${object.getValue()}');
    object.setValue(2048);
    print('main: ${object.getValue()}');
  });

  Isolate.run(() async {
    await Future.delayed(Duration(seconds: 2)).then((_) {
      print('isolate(hashcode): ${object.hashCode}');
      print('isolate: ${object.getValue()}');
      object.setValue(4096);
      print('isolate: ${object.getValue()}');
    });
  });
}

Enter fullscreen mode Exit fullscreen mode

When executed, the hashCode of the O object is different on the main isolate and the new one. The initialized value (1024) can be seen on both output, but if the object was shared across those isolates, the value 2048 would have been printed twice. So, when starting an isolate a part of the heap (or stack?) is being cloned during the initialization phase.

$ dart run
main(hashcode): 3590485802
main: 1024
main: 2048
isolate(hashcode): 713534114
isolate: 1024
isolate: 4096

Enter fullscreen mode Exit fullscreen mode

I'm still not really sure to understand everything, but the native implementation can be found in heap.cc native implementation and allocation.cc (for the stack) from the Dart SDK. It seems the heap is correctly isolated from each isolate.

Conclusion

Similar features exist on other languages, but the real game changer with the Isolate is the isolation. Indeed, this feature is close to the Actor model, like in Erlang, where a process is spawned and will have its own heap, stack and mailbox. The big difference is the dedicated thread, on the BEAM, the threads are shared. Anyway, using a ReceivePort and SenderPort to communicate is also a smart move, similar to the method used to send data to a process via pipes on the Unix world, the main drawback is the relative complexity to deal with that.

Isolates are great, and could be a good start for creating autonomous workers or actor model. Luckily, the community is full of packages trying to do that:

  • actor: actors is a library that enables the use of the Actors Model in Dart;

  • async_task: this package brings asynchronous tasks and parallel executors (similar to classic thread pools) for all Dart platforms;

  • easy_isolate: an abstraction of isolates providing an easy way to work with different threads;

  • isolate_manager: Isolate Manager is a powerful Flutter/Dart package designed to simplify concurrent programming using isolates;

  • squadron: multithreading and worker thread pool for Dart / Flutter, to offload CPU-bound and heavy I/O tasks to Isolate or Web Worker threads.

As usual, I am not the only one to write about this topic, and in fact, thanks to all the resources, I was able to understand this concept a bit faster. Here a (long) list of resources.

Happy hack and have fun!


Cover Image by sol on Unsplash