Skip to main content

Combine

· 6 min read

Combine logo

One of the keys to a successful application is performance. At the most time, it's fine to do everything in the main Isolate. However, sometimes you need to do some heavy task that may take significant time - 8 or even 16 ms. In that case, you should create a new Isolate to perform a calculation in it. Dart's Isolates API is complicated. So it would be great to use a package that simplifies it. In pub dev, we have a lot of great packages for that and in this article I'll show one new and special - Combine

Haven't heard about isolate?

All Dart code runs inside of isolates. Each isolate has its own memory heap, ensuring that none of the state in an isolate is accessible from any other isolate.

You can read about them in the Dart documentation.

The combine package is created to make it easier to use Isolate in these scenarios:

  • Create Isolate and communicate with it
  • Efficiently execute tasks in the Isolates pool
  • Use Method Channels in the Isolate
  • Laziness. Nothing will be created until it's used
  • The stub on the web platform

Let's go through some items and compare Combine with Isolate!

Combine Isolate

Everything you need is to call a spawn method and pass entryPoint function.

final isolateInfo = await Combine().spawn((context) {
print("Argument from main isolate: ${context.argument}");

context.messenger.messages.listen((message) {
print("Message from main isolate: $message");
});
}, argument: 42);

isolateInfo.messenger
..messages.listen((message) {
print("Message from isolate: $message");
})
..send("Hello from main isolate!");
The same using pure Isolate

This code is two times longer and more complicated.

final port = ReceivePort();
await Isolate.spawn(
(args) {
print("Argument from main isolate: ${args[1]}");

final SendPort sendPort = values[0];
final port = ReceivePort();
sendPort.send(port.sendPort);

port.listen((message) {
print("Message from main isolate: $message");
});
},
[port.sendPort, 42],
);

late final SendPort sendPort
port.listen((data) {
if (data is SendPort) {
sendPort = data;
sendPort.send("Hello from main isolate!");
} else {
print("Message from isolate: $data");
}
});

The spawn method returns IsolateInfo which holds:

  • IsolateMessenger to communicate with isolate. It has:
    • messages getter which returns a stream of messages from the isolate
    • send method which sends a message to the isolate
  • CombineIsolate is a representation of isolate. It's used to kill isolate.

entryPoint function will be executed in the isolate. It may be a static method or a top-level function as well as a class method or lambda function. It even may use closure variables with some limitations. See the closure variables section for more info.

The entry point receives context as an argument. Context is a holder for:

  • argument parameter which was passed to the spawn method
  • IsolateMessenger
  • CombineIsolate.
The spawn method parameters.
This method takes a few parameters:
  • entryPoint which was described above.
  • The argument parameter which will be accessible with context.argument from the entry point function
  • errorsAreFatal which specifies whether isolate should be killed on the uncaught exception.
  • debugName is a name of the isolate which is visible in the editor.

Isolates pool

The most interesting part of this package is an Isolates pool which is named CombineWorker. With it, you can schedule the tasks which will be executed in the isolates pool.

It's designed to be as safe and easy to use as possible.

  • by default, you don't need to initialize the worker. It will be initialized while the first usage.
  • you can initialize the worker manually and without waiting for initialization to complete, start to schedule the tasks.
  • while initialization, you can specify the number of tasks per isolate using the tasksPerIsolate parameter. If you want to execute asynchronous tasks which work with a file, network, or just an event loop, it will be more efficient to run 2 or more tasks in the same isolate.
  • you can close the worker and cancel all the tasks or let them finish before closing.
  • exception in the task will be sent to the main isolate and thrown in it. For example final futureResult = CombineWorker().execute(() => throw IsolateException()). In that case, futureResult will be completed with IsolateException.

Small usage example from the documentation:

await CombineWorker().initialize();

final helloWorld = await CombineWorker().execute(zeroArgsFunction);
final maksim = await CombineWorker().executeWithArg(oneArgFunction, "Maksim");
final helloArshak = await CombineWorker().executeWith2Args(
twoArgsFunction,
"Hello", "Arshak!"
);

String zeroArgsFunction() => "Hello, World!";
String oneArgFunction(String str) => str;
String twoArgsFunction(String a, String b) => "$a, $b";
tip

Multiple CombineWorker instances.

Although it is a singleton you still can create a few instances of workers with different configurations. Combine worker uses CombineWorkerImpl under the hood which is not a singleton and nothing prevents you from using it.

Method Channels

By default, you can't use Method Channels outside of the main Isolate. However, it's possible to use them in the Combine Isolate!

It works because Combine overrides the default binary messenger and redirects messages to the main Isolate. Sometimes it's not good for performance because this binary message will be copied twice. To send it to the main Isolate and the platform. So I recommend you to be careful when working with Method Channels.

Closure variables

Isolate entryPoint function for spawn method or task function for the execute methods may be a first-level, as well as a static or top-level.

Also, it may use closure variables but with some restrictions:

  • closure variable will be copied (as every variable passed to isolate) so it won't be synchronized across Isolates.
  • if you use at least one variable from closure all closure variables will be copied to the Isolate due to this issue. It can lead to high memory consumption or event exception because some variables may contain native resources.

Due to the above points, I highly recommend you avoid using closure variables until this issue is fixed.

Comparison

Each of these plugins is great however it's impossible to be perfect. So I'll try to show the weakness and strengths of Combine.

Executor

Executor pros:

  • It's mature
  • The task can be canceled
  • Task and isolate pool can be paused
  • Doesn't depends on the flutter

Combine pros:

  • Provides API for creating and working with isolate
  • Allows to work with method channels
  • Customizable. You can specify the number of parallel tasks per isolate and worker closing strategy.

Flutter isolate

Flutter isolate pros:

  • It's mature too
  • It works with method channels in the much more efficient way

Combine pros:

  • Easier to use
  • It's possible to send non-primitive objects
  • Doesn't starts a new Flutter engine
  • Works on all Flutter platforms

I suggest you use Flutter isolate only when your main goal is to work with method channels.


Thank you for reading this article. I hope it will help you write cool and performant applications🔥