Isolates
Information on writing isolates in Dart.
This page discusses some examples that use the Isolate API to implement
isolates.
You should use isolates whenever your application is handling computations that are large enough to temporarily block other computations. The most common example is in Flutter applications, when you need to perform large computations that might otherwise cause the UI to become unresponsive.
There aren't any rules about when you must use isolates, but here are some more situations where they can be useful:
- Parsing and decoding exceptionally large JSON blobs.
- Processing and compressing photos, audio and video.
- Converting audio and video files.
- Performing complex searching and filtering on large lists or within file systems.
- Performing I/O, such as communicating with a database.
- Handling a large volume of network requests.
Implementing a simple worker isolate
#
These examples implement a main isolate
that spawns a simple worker isolate.
Isolate.run()
simplifies the steps behind
setting up and managing worker isolates:
- Spawns (starts and creates) an isolate.
- Runs a function on the spawned isolate.
- Captures the result.
- Returns the result to the main isolate.
- Terminates the isolate once work is complete.
- Checks, captures, and throws exceptions and errors back to the main isolate.
Running an existing method in a new isolate
#-
Call
run()to spawn a new isolate (a background worker), directly in the main isolate whilemain()waits for the result:
const String filename = 'with_keys.json';
void main() async {
// Read some data.
final jsonData = await Isolate.run(_readAndParseJson);
// Use that data.
print('Number of JSON keys: ${jsonData.length}');
}
-
Pass the worker isolate the function you want it to execute
as its first argument. In this example, it's the existing function
_readAndParseJson():
Future<Map<String, dynamic>> _readAndParseJson() async {
final fileData = await File(filename).readAsString();
final jsonData = jsonDecode(fileData) as Map<String, dynamic>;
return jsonData;
}
-
Isolate.run()takes the result_readAndParseJson()returns and sends the value back to the main isolate, shutting down the worker isolate. -
The worker isolate transfers the memory holding the result to the main isolate. It does not copy the data. The worker isolate performs a verification pass to ensure the objects are allowed to be transferred.
_readAndParseJson() is an existing,
asynchronous function that could just as easily
run directly in the main isolate.
Using Isolate.run() to run it instead enables concurrency.
The worker isolate completely abstracts the computations
of _readAndParseJson(). It can complete without blocking the main isolate.
The result of Isolate.run() is always a Future,
because code in the main isolate continues to run.
Whether the computation the worker isolate executes
is synchronous or asynchronous doesn't impact the
main isolate, because it's running concurrently either way.
For the complete program, check out the send_and_receive.dart sample.
Sending closures with isolates
#
You can also create a simple worker isolate with run() using a
function literal, or closure, directly in the main isolate.
const String filename = 'with_keys.json';
void main() async {
// Read some data.
final jsonData = await Isolate.run(() async {
final fileData = await File(filename).readAsString();
final jsonData = jsonDecode(fileData) as Map<String, dynamic>;
return jsonData;
});
// Use that data.
print('Number of JSON keys: ${jsonData.length}');
}
This example accomplishes the same as the previous. A new isolate spawns, computes something, and sends back the result.
However, now the isolate sends a closure.
Closures are less limited than typical named functions,
both in how they function and how they're written into the code.
In this example, Isolate.run() executes what looks like local code,
concurrently. In that sense, you can imagine run() to work like a
control flow operator for "run in parallel".
Sending multiple messages between isolates with ports
#
Short-lived isolates are convenient to use,
but require performance overhead to spawn new isolates
and to copy objects from one isolate to another.
If your code relies on repeatedly running the same computation
using Isolate.run, you might improve performance by instead creating
long-lived isolates that don’t exit immediately.
To do this, you can use some of the low-level isolate APIs that
Isolate.run abstracts:
This section goes over the steps required to establish 2-way communication between a newly spawned isolate and the main isolate. The first example, Basic ports, introduces the process at a high-level. The second example, Robust ports, gradually adds more practical, real-world functionality to the first.
ReceivePort and SendPort
#
Setting up long-lived communication between isolates requires
two classes (in addition to Isolate): ReceivePort and
SendPort.
These ports are the only way isolates can communicate with each other.
A ReceivePort is an object that handles messages that are sent from other
isolates. Those messages are sent via a SendPort.
Ports behave similarly to Stream
objects
(in fact, receive ports implement Stream!)
You can think of a SendPort and ReceivePort like
Stream's StreamController and listeners, respectively.
A SendPort is like a StreamController because you "add" messages to them
with the SendPort.send() method, and those messages are handled by a listener,
in this case the ReceivePort. The ReceivePort then handles the messages it
receives by passing them as arguments to a callback that you provide.
Setting up ports
#
A newly spawned isolate only has the information it receives through the
Isolate.spawn call. If you need the main isolate to continue to communicate
with a spawned isolate past its initial creation, you must set up a
communication channel where the spawned isolate can send messages to the
main isolate. Isolates can only communicate via message passing.
They can’t “see” inside each others’ memory,
which is where the name “isolate” comes from.
To set up this 2-way communication, first create a ReceivePort
in the main isolate, then pass its SendPort
as an argument to the
new isolate when spawning it with Isolate.spawn.
The new isolate then creates its own ReceivePort, and sends its
SendPort
back on the SendPort it was passed by the main isolate.
The main isolate receives this SendPort, and
now both sides have an open channel to send and receive messages.
-
Create a
ReceivePortin the main isolate. TheSendPortis created automatically as a property on theReceivePort. - Spawn the worker isolate with
Isolate.spawn() -
Pass a reference to
ReceivePort.sendPortas the first message to the worker isolate. - Create another new
ReceivePortin the worker isolate. -
Pass a reference to the worker isolate's
ReceivePort.sendPortas the first message back to the main isolate.
Along with creating the ports and setting up communication, you’ll also need
to tell the ports what to do when they receive messages. This is done using
the listen method on each respective ReceivePort.
-
Send a message via the main isolate’s reference to the worker isolate's
SendPort. -
Receive and handle the message via a listener on the worker isolate's
ReceivePort. This is where the computation you want to move off the main isolate is executed. -
Send a return message via the worker isolate's reference to the main
isolate's
SendPort. - Receive the message via a listener on the main isolate's
ReceivePort.
Basic ports example
#This example demonstrates how you can set up a long-lived worker isolate with 2-way communication between it and the main isolate. The code uses the example of sending JSON text to a new isolate, where the JSON will be parsed and decoded, before being sent back to the main isolate.
Step 1: Define the worker class
#First, create a class for your background worker isolate. This class contains all the functionality you need to:
- Spawn an isolate.
- Send messages to that isolate.
- Have the isolate decode some JSON.
- Send the decoded JSON back to the main isolate.
The class exposes two public methods: one that spawns the worker isolate, and one that handles sending messages to that worker isolate.
The remaining sections in this example will show you how to fill in the class methods, one-by-one.
class Worker {
Future<void> spawn() async {
// TODO: Add functionality to spawn a worker isolate.
}
void _handleResponsesFromIsolate(dynamic message) {
// TODO: Handle messages sent back from the worker isolate.
}
static void _startRemoteIsolate(SendPort port) {
// TODO: Define code that should be executed on the worker isolate.
}
Future<void> parseJson(String message) async {
// TODO: Define a public method that can
// be used to send messages to the worker isolate.
}
}