DEV Community

Cover image for The Practical Hands-On Guide to Flutter Isolates
Utkarsh Shrivastava
Utkarsh Shrivastava

Posted on

The Practical Hands-On Guide to Flutter Isolates

Introduction

Picture this: You're building a flutter application that needs to process large datasets, handle UI interactions, and maintain smooth animations - all simultaneously. If you're coming from JavaScript, you might be thinking, "I'll just use Web Workers!" But what if I told you Dart has an even more elegant solution?

Like JavaScript, Dart is fundamentally a single-threaded language, yes. This means that at its core, it executes one piece of code at a time in a single thread of execution, dubbed the "main isolate." However, where JavaScript uses Web Workers for parallel processing, Dart introduces us to the concept of Isolates - and this is where things get interesting.

> p.s we will be building a small project in the end

Let's first understand "how isolates are different compared to threads"?

Traditional threads are like multiple cooks sharing one kitchen. They're all working in the same space, using the same tools, and accessing the same ingredients. Sure, you can get more done, but:

  • Someone might grab the knife you were using (resource contention)
  • Two cooks might reach for the same ingredient (race conditions)
  • Everyone has to constantly check what others are doing (synchronization overhead)
  • The kitchen becomes chaos without strict rules (mutex/locks)

Isolates

Dart looked at this chaos and said, "What if each cook had their own kitchen?" That's essentially what isolates are. Instead of sharing memory and fighting over resources, each isolate gets its own memory heap. It's like having multiple copies of your app running independently.

Isolates provide:

  1. Complete Memory Isolation:
    • Each isolate has its own memory heap
    • No shared memory between isolates
    • Memory safety by design
  2. Message-Passing Communication (We will dive deep into this)
    • Since Isolates have separate memories, we communicate between them by passing messages using the receiver port and sender port.
  3. Resource Efficiency
    • Lighter weight than OS threads.
    • More efficient memory usage.
    • Better suited for Dart's garbage collection.

The messages between the isolates are like orders going between kitchens (Memory Space)

The dart event loop is the waiter taking orders and delivering dishes

The event loops ensures that:

  1. Messages are delivered in the right order
  2. No isolate gets overwhelmed
  3. Everything stays responsive

To understand dart event loop in detail, you can look up to this official blog by dart.

Dart event loop has two queues (like having two types of order tickets in our restaurant):

  1. Microtask Queue (VIP Orders)

    • Handles high-priority tasks
    • Always processed first
    • Perfect for quick, internal operations
  2. Event Queue (Regular Orders)

    • Handles regular async operations
    • I/O operations, timers, isolate messages
    • Processed after all microtasks are done

Understanding the Main Isolate

The main isolate in Flutter isn't just another isolate - it's the VIP of your app.

void main() {
  // You're already in the main isolate when this runs!
  runApp(MyApp());
}
Enter fullscreen mode Exit fullscreen mode
  • UI Updates happen only in main isolates.
  • Only the main isolate can communicate with platform-specific code.

Event loop example

void main() {
  print('πŸƒ Starting the show');  // 1: Regular synchronous code

  Future(() {
    print('🎭 Event queue');      // 3: Gets queued in event queue
  });

  Future.microtask(() {
    print('⚑ Microtask queue');   // 2: Gets queued but runs first
  });

  print('🏁 Synchronous end');    // 1: Regular synchronous code
}

// Output:
// πŸƒ Starting the show
// 🏁 Synchronous end
// ⚑ Microtask queue
// 🎭 Event queue
Enter fullscreen mode Exit fullscreen mode

The real magic happens when isolates and event loops work together. Each isolate has its own event loop, but they can communicate through message passing.

Working with isolates

void main() async {
  // Create a ReceivePort for incoming messages
  final receivePort = ReceivePort();

  // Spawn a new isolate
  final isolate = await Isolate.spawn(
    simpleWorker,        // Function to run in isolate
    receivePort.sendPort // Port for sending messages back
  );

  // Listen for messages
  receivePort.listen((message) {
    print('Received: $message');
    // Clean up when done
    receivePort.close();
    isolate.kill();
  });
}

// Worker function that runs in the isolate
void simpleWorker(SendPort sendPort) {
  sendPort.send('Hello from isolate!');
}
Enter fullscreen mode Exit fullscreen mode

Understanding Ports

  1. ReceivePort
  • Acts as the inbox for messages
  • Created in the isolate where you want to receive messages
  • Provides a SendPort that others can use to send messages to this isolate
  • Can be listened to using listen() or used in an async for loop
  1. SendPort
  • Acts as the outbox for sending messages
  • Obtained from a ReceivePort
  • Used to send messages to the corresponding ReceivePort
  • Can be passed between isolates

Passing complex messages

import 'dart:isolate';

class WorkMessage {
  final String command;
  final Map<String, dynamic> data;

  WorkMessage(this.command, this.data);
}

void main() async {
  final receivePort = ReceivePort();

  // Spawn isolate with initial message
  final isolate = await Isolate.spawn(
    dataProcessor,
    [
      receivePort.sendPort,
      WorkMessage('process', {'items': [1, 2, 3]})
    ],
  );

  // Handle responses
  await for (final message in receivePort) {
    if (message is String) {
      print('Received: $message');
      receivePort.close();  // Close after processing
      break;  // Exit loop when done
    }
  }

  isolate.kill();
}

void dataProcessor(List<dynamic> initialMessage) {
  final SendPort sendPort = initialMessage[0];
  final WorkMessage workMessage = initialMessage[1];
  final result = processData(workMessage);
  // Send back result
  sendPort.send('Processed: $result');
}

String processData(WorkMessage message) {
  final items = message.data['items'] as List<int>;
  final processedItems = items.map((item) => item * 2).toList();
  return processedItems.toString();
}


// Output
// Received: Processed: [2, 4, 6]

Enter fullscreen mode Exit fullscreen mode

Key Points to Remember:

  • Always close ReceivePorts when they're no longer needed to prevent memory leaks
  • Only send data that can be serialized (basic types, lists, maps, etc.)
  • Consider using typed ports or message enums for better code organization
  • Handle errors appropriately as they don't automatically propagate across isolates

Image processing using Isolates vs Without Isolates.

In this example code, We will apply filter over an image by vertically breaking the image into 4 equal chunks, applying blur filter over them using isolates. We will Process each chunk in parallel using isolates, and compare it to normal case

With Isolates

import 'dart:io';
import 'dart:isolate';
import 'package:image/image.dart' as img;

void main() async {
  print('Image processing using isolates...');
  final stopwatch = Stopwatch()..start();
  final inputFile = File('bin/input_image.jpg');

  // Decode the image from file bytes
  final image = img.decodeImage(inputFile.readAsBytesSync());
  final width = image!.width;
  final height = image.height;

  final numberOfIsolates = 4;
  final chunkHeight = height ~/ numberOfIsolates;


  // Divide image into chunks
  final chunks = List.generate(
    numberOfIsolates,
    (i) => img.copyCrop(image, x: 0, y: i * chunkHeight, width: width, height: chunkHeight),
  );

  // Process each chunk in parallel using isolates
  final results = await Future.wait(
    List.generate(numberOfIsolates, (i) => processInIsolate(chunks[i])),
  );

  // Create an empty image to hold the final result
  final resultImage = img.Image(width: width, height: height);

  // Manually copy pixels from processed chunks into the result image
  for (int i = 0; i < numberOfIsolates; i++) {
    final chunk = results[i];
    for (int y = 0; y < chunk.height; y++) {
      for (int x = 0; x < chunk.width; x++) {
        // Set pixel from chunk to the result image
        resultImage.setPixel(x, y + (i * chunkHeight), chunk.getPixel(x, y));
      }
    }
  }

  // Save the resulting image
  final outputFile = File('output_image.jpg');
  outputFile.writeAsBytesSync(img.encodeJpg(resultImage));

  stopwatch.stop();
  print('Image processed in ${stopwatch.elapsedMilliseconds} ms');
}

Future<img.Image> processInIsolate(img.Image chunk) async {
  final receivePort = ReceivePort();
  await Isolate.spawn(applyBlur, [chunk, receivePort.sendPort]);
  return await receivePort.first;
}

void applyBlur(List<dynamic> args) {
  final img.Image chunk = args[0];
  final SendPort sendPort = args[1];

  // Apply Gaussian blur to the image chunk
  final blurred = img.gaussianBlur(chunk, radius: 10);

  // Send the processed image back to the main isolate
  sendPort.send(blurred);
}
Enter fullscreen mode Exit fullscreen mode

Without Isolates

import 'dart:io';
import 'package:image/image.dart' as img;

void main() {
  final stopwatch = Stopwatch()..start();

  final inputFile = File('bin/input_image.jpg');
  final image = img.decodeImage(inputFile.readAsBytesSync())!;

  final blurred = img.gaussianBlur(image, radius: 10);

  File('output_no_isolates.jpg').writeAsBytesSync(img.encodeJpg(blurred));

  stopwatch.stop();
  print('Processing time without isolates: ${stopwatch.elapsedMilliseconds} ms');
}
Enter fullscreen mode Exit fullscreen mode

Comparing the results

  1. With Isolates
Image processing using isolates...
Image processed in 269 ms
Enter fullscreen mode Exit fullscreen mode
  1. Without Isolates
Processing time without isolates: 812 ms
Enter fullscreen mode Exit fullscreen mode

Messaging between Isolates

  1. Basic One-way Messaging The simplest form of isolate communication - when you just need to send data and forget about it.
void main() async {
  // Create a receive port for incoming messages
  final receivePort = ReceivePort();
  // Spawn an isolate and pass the SendPort
  await Isolate.spawn(
    simpleWorker,
    receivePort.sendPort,
  );
  // Listen for any messages
  receivePort.listen((message) {
    print('Main received: $message');
  });
}

void simpleWorker(SendPort sendPort) {
  sendPort.send('Task completed!');
  sendPort.send('More results...');
}
Enter fullscreen mode Exit fullscreen mode

Output:

Main received: Task completed!
Main received: More results...

Enter fullscreen mode Exit fullscreen mode

You saw that we didn't kill the port here, hence the code will run forever

  1. Two-way Communication Pattern When your isolates need to have a conversation - useful for ongoing tasks or complex data processing.
import 'dart:isolate';

void main() async {
  // Set up the initial communication channel
  final mainReceivePort = ReceivePort();

  // Spawn the isolate
  final isolate = await Isolate.spawn(
    twoWayWorker,
    mainReceivePort.sendPort,
  );

  // Store worker's SendPort when received
  late SendPort workerSendPort;

  // Set up the two-way communication
  mainReceivePort.listen((message) {
    if (message is SendPort) {
      // First message is the worker's SendPort
      workerSendPort = message;

      // Now we can start sending messages to worker
      workerSendPort.send('Start processing!');
    } else {
      print('Main received: $message');
    }
  });
}

void twoWayWorker(SendPort mainSendPort) {
  // Create a receiver for the worker
  final workerReceivePort = ReceivePort();

  // Send the worker's SendPort back to main
  mainSendPort.send(workerReceivePort.sendPort);

  // Listen for messages from main
  workerReceivePort.listen((message) {
    print('Worker received: $message');
    // Process and send back results
    mainSendPort.send('Processed: $message');
  });
}

Enter fullscreen mode Exit fullscreen mode

Thanks for reading :p
Connect with me on twitter at https:x.com/twtutkarsh

Top comments (0)