Flutter: Isolates

Flutter: Isolates

Hế lô các bạn, hôm trước khi nói về gọi api đồng thời mình có nhắc đến Isolates. Thì hôm nay, mình xin giới thiệu thêm một chút về nó. Một công cụ hạng nặng trong Flutter


Isolate là một cách để chạy mã đồng thời trong ứng dụng Flutter của bạn. Chúng cho phép bạn chuyển các tác vụ tốn kém về mặt tính toán sang một luồng riêng biệt, điều này có thể giúp cải thiện hiệu suất ứng dụng của bạn.

Tại sao bạn cần tìm hiểu về nó ư. Ok thôi, nếu bạn muốn-

Như này

https://miro.medium.com/v2/resize:fit:4800/format:webp/1*01NGis06wDK9oJ4_RBmy7g.gif

Thay vì như này

https://miro.medium.com/v2/resize:fit:4800/format:webp/1*yhx5JOZ8aMTqzkpfx5E_eg.gif


Chúng ta sẽ quay lại vấn đề này sau. Trước tiên, chúng ta hãy hiểu cơ bản - Thread (Luồng), Event loop (vòng lặp sự kiện) và tất cả.

Thread

Trước khi chuyển sang phần Isolate, hãy để mình giải thích Thread là gì.

Single Thread (Đơn luồng): Hãy coi một Single Thread như một cây cọ duy nhất mà bạn sử dụng để vẽ toàn bộ bức tranh. Bạn nhúng cọ vào từng màu một, thoa lên khung vẽ và chuyển sang màu tiếp theo. Đó là một quá trình tuần tự, trong đó bạn chỉ có thể vẽ một nét mỗi lần. Điều này tương tự như một ứng dụng đơn luồng, trong đó các tác vụ được thực thi lần lượt.

Muti-Thread (Đa luồng): Bây giờ, chúng ta có nhiều cọ vẽ. Mỗi cây đại diện cho một chủ đề riêng biệt. Với nhiều cọ vẽ, bạn có thể làm việc đồng thời trên các phần khác nhau của bức tranh (đấy là nếu bạn có đủ số tay cầm đống cọ đấy một lúc 😆). Một cọ vẽ có thể thêm các chi tiết vào nền, một cọ vẽ khác có thể tô màu ở nền trước, v.v. Tương tự, trong lập trình đa luồng, các luồng khác nhau có thể thực thi đồng thời các tác vụ, cải thiện hiệu suất bằng cách sử dụng hiệu quả các tài nguyên sẵn có.

Event Loop

Mọi người cần phải luôn nhớ rằng Dart là Single Thread. Dart thực thi từng thao tác một, lần lượt từng thao tác khác nhau, nghĩa là miễn là một thao tác đang thực thi thì nó không thể bị gián đoạn bởi bất kỳ mã Dart nào khác.

Dart thực sự quản lý chuỗi hoạt động sẽ được thực thi như thế nào?

Để trả lời câu hỏi này, chúng ta cần xem xét trình sắp xếp mã Dart, được gọi là Event Loop (Vòng lặp sự kiện). Khi bạn khởi động ứng dụng Flutter, một tiến trình Thread (Luồng) mới (trong ngôn ngữ Dart = “Isolate”) sẽ được tạo và khởi chạy. Thread này sẽ là Thread duy nhất mà bạn phải quan tâm đến trong toàn bộ ứng dụng. Vì vậy, khi luồng này được tạo, Dart sẽ tự động

  1. Khởi tạo 2 hàng đợi, cụ thể là hàng đợi FIFO “MicroTask” và “Event”.
  2. Sau đó thực thi phương thức main().
  3. Và cuối cùng cũng khởi chạy Vòng lặp sự kiện.

Trong toàn bộ vòng đời của luồng, một quy trình nội bộ và vô hình duy nhất, được gọi là "Vòng lặp sự kiện", sẽ quyết định cách thực thi và thứ tự thực thi đoạn mã, tùy thuộc vào nội dung của cả hàng đợi MicroTask và Event. Cần lưu ý rằng Hàng đợi MicroTask được ưu tiên hơn Hàng đợi Event.

Nhưng 2 hàng đợi đó dùng để làm gì?

Hàng đợi MicroTask

Hàng đợi MicroTask được sử dụng cho các hành động nội bộ rất ngắn cần được chạy không đồng bộ, ngay sau khi một việc khác hoàn thành và trước khi trả lại Hàng đợi Event.

Tốt nhất bạn nên cân nhắc sử dụng Hàng đợi Event.

Hàng đợi Event

Ngay khi không còn tác vụ vi nhiệm (micro task) nào để chạy, Vòng lặp sự kiện sẽ xem xét mục đầu tiên trong Hàng đợi Event và sẽ thực thi mục đó. Điều rất thú vị cần lưu ý là Future cũng được xử lý thông qua Hàng đợi Event

Để hiểu hơn về hàng đợi MicroTask và Event, các bạn có thể tìm hiểu bài toán sau
import 'dart:async';

void main(List<String> arguments) {
  print('Start');

  Future(() => 1).then(print);
  Future(() => Future(() => 2)).then(print);

  Future.value(3).then(print);
  Future.value(Future(() => 4)).then(print);

  Future.sync(() => 5).then(print);
  Future.sync(() => Future(() => 6)).then(print);

  Future.microtask(() => 7).then(print);
  Future.microtask(() => Future(() => 8)).then(print);

  Future(() => 9).then(print);
  Future(() => Future(() => 10)).then(print);

  print('End');
}
Giải thích: https://pmatatias.medium.com/flutter-future-future-sync-future-microtask-future-value-etc-3f46aeae1210

Isolate là gì?

Một Isolate tương ứng với của khái niệm Thread. “Isolate” trong Flutter không chia sẻ bộ nhớ. Về mặt giao tiếp, sự tương tác với nhau và với Main UI thread giữa các “Isolates” khác nhau được thực hiện thông qua “messages” , đây là lý do tại sao chúng được đặt tên là “Isolates”.

Mỗi “Isolate” có “Vòng lặp sự kiện” và Hàng đợi riêng (MicroTask và Event). Điều này có nghĩa là mã chạy bên trong một Isolate, độc lập với một Isolate khác. Vì vậy, đây là cách chúng giúp đưa tính đồng thời vào đoạn mã để giữ cho UI thread không bị ảnh hưởng bởi các tiêu tốn không cần thiết.

Xử lý đồng thời và song song?

Đồng thời là khi hai hoặc nhiều tác vụ có thể bắt đầu, chạy và hoàn thành trong khoảng thời gian chồng chéo. Điều đó không nhất thiết có nghĩa là cả hai sẽ chạy cùng lúc.

Tính song song là khi các tác vụ thực sự chạy cùng lúc, ví dụ: trên bộ xử lý đa lõi. Dart sử dụng mô hình Isolate cho hoạt động đồng thời.

Giao tiếp giữa hai Isolate

Mỗi Isolate có một vùng bộ nhớ riêng nên không thể chia sẻ các giá trị có thể thay đổi với các Isolate khác. Vì vậy hai Isolate sẽ liên lạc với nhau bằng cách trao đổi tin nhắn qua SendPort và AcceptPort.


Chúng ta sẽ cần tạo một đối tượng ReceivePort trong Isolate chính để nó lắng nghe phản hồi từ Isolate A

void main(List<String> args) async {
  final receivePort = ReceivePort();
  
  receivePort.listen((message) {
    print(message);
    receivePort.close();
  });
}


Tiếp theo, chúng ta sẽ tạo Isolate và truyền cho nó một đối tượng SendPort. Đối tượng SendPort có sẵn trong mỗi đối tượng ReceivePort như thế này receivePort.sendPort

Isolate.spawn(heavyTask, [receivePort.sendPort, 700000]);

Isolate chỉ hỗ trợ truyền một giá trị đầu vào duy nhất, nhưng ở đây chúng ta cần truyền hai giá trị đầu vào receivePort.sendPort700000 để có thể gói hai giá trị đầu vào này vào một List hoặc Map.

Làm cách nào để khởi chạy Isolate?

Chủ yếu có hai cách để tạo các Isolate trong Dart. Ngoài ra còn có một package ngoài cho việc này nhưng ở đây chúng ta sẽ thảo luận về hai cách vừa được nói đến:

  • Isolate.spawn()
  • compute()

Mặc dù hàm Isolate.spawn()compute() hầu như đều tương tự nhau, nhưng hàm Isoate.spawn() giúp chúng ta linh hoạt hơn.

  1. Isolate.spawn()

Hãy tưởng tượng bạn muốn tính tổng của một danh sách các số riêng biệt:

import 'dart:isolate';

void computeSum(SendPort sendPort) {
  final List<int> numbers = [1, 2, 3, 4, 5];
  final sum = numbers.reduce((value, element) => value + element);
  sendPort.send(sum);
}

void main() async {
  final receivePort = ReceivePort();
  await Isolate.spawn(computeSum, receivePort.sendPort);
  receivePort.listen((data) {
    print('Sum from isolate: $data');
    receivePort.close();
  });
}

Trong ví dụ này, hàm computeSum() chạy riêng biệt. Tổng của các số sau đó được gửi trở lại Main Isolate thông qua SendPort.

2. compute()

import 'package:flutter/foundation.dart';

int computeSum(List<int> numbers) {
  return numbers.reduce((value, element) => value + element);
}

void main() async {
  final List<int> numbers = [1, 2, 3, 4, 5];
  final sum = await compute(computeSum, numbers);
  print('Sum from isolate: $sum');
}


Ở đây, hàm compute() sẽ xử lý các chi tiết của việc tạo isolate và giao tiếp. Nó ngắn gọn hơn và dễ sử dụng hơn cho các tác vụ đơn giản.

Lưu ý: Đối với hàm compute(), bạn cần import Foundation.dart từ gói Flutter.

Chọn phương pháp phù hợp nhất với yêu cầu của bạn. Đối với các tác vụ đơn giản, liên quan đến CPU, compute() thường dễ dàng và ngắn gọn hơn. Đối với các tác vụ phức tạp hơn đòi hỏi nhiều giao tiếp hoặc thiết lập hơn, Isolate.spawn() có thể phù hợp hơn. Chúng ta cũng có thể sử dụng Isolate.run() để làm điều tương tự. Ngoài ra, bạn có thể kiểm tra các package isolate, chẳng hạn như Flutter_isolate.

The Problem

Quay lại vấn đề được đề cập ban đầu:


Giả sử chúng ta cần code một ứng dụng có một hàm yêu cầu tính toán cực kỳ nặng nề như chạy một vòng lặp 700.000.000 lần. Khi chạy ứng dụng, ứng dụng bị lag đến mức CircularProgressIndicator không thể xoay được.

https://miro.medium.com/v2/resize:fit:4800/format:webp/1*yhx5JOZ8aMTqzkpfx5E_eg.gif

Đối với vấn đề này, Future và async/await không thể giúp bạn vì Dart là một ngôn ngữ đơn luồng và async là đồng thời (chạy cùng một lúc) chứ không phải song song (chạy song song). Vì chỉ có một luồng, ngay cả khi nó chạy bất đồng bộ, nó cũng chỉ có thể thực hiện một việc tại một thời điểm. Nếu bộ xử lý tập trung vào việc thực hiện một tác vụ nặng nề như chạy một vòng lặp 100 tỷ lần, nó sẽ không thể thực hiện các tác vụ khác như cập nhật giao diện người dùng, dẫn đến hiện tượng lag giao diện như trên. Vì vậy, đây là một trong những ví dụ, nơi cần sử dụng Isolate. Bây giờ chúng ta hãy thử sử dụng Isolate để thực hiện cùng một tác vụ nặng và xem liệu CircularProgressIndicator của chúng ta có bị kẹt ở bất kỳ thời điểm nào không.

https://miro.medium.com/v2/resize:fit:4800/format:webp/1*01NGis06wDK9oJ4_RBmy7g.gif
Chúng ta sẽ nhận thấy rằng CircularProgressIndicator đã không bị ảnh hưởng nữa. Bởi vì bây giờ chúng ta đã thêm Isolate để thực hiện tác vụ nặng, vì vậy nó có thể chạy độc lập để thực hiện tính toán mà không ảnh hưởng đến luồng chính của Dart.

import 'dart:isolate';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueAccent),
        // useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

Future<int> runHeavyTaskIWithIsolate(int count) async {
  final ReceivePort receivePort = ReceivePort();
  int result = 0;
  try {
    await Isolate.spawn(useIsolate, [receivePort.sendPort, count]);
    result = await receivePort.first;
  } on Object catch (e, stackTrace) {
    debugPrint('Isolate Failed: $e');
    debugPrint('Stack Trace: $stackTrace');
    receivePort.close();
  }
  return result;
}

void useIsolate(List<dynamic> args) {
  SendPort resultPort = args[0];
  int value = 0;
  for (var i = 0; i < args[1]; i++) {
    value += i;
  }
  resultPort.send(value);
}



int runHeavyTaskWithOutIsolate(int count) {
  int value = 0;
  for (var i = 0; i < count; i++) {
    value += i;
  }
  print(value);
  return value;
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            CircularProgressIndicator(),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async{
          // Without isolate
          // int value1 = runHeavyTaskWithOutIsolate(700000000);
          // print(value1);

          // With isolate
          int value2 = await runHeavyTaskIWithIsolate(700000000);
          print(value2);
        },
        child: const Icon(Icons.numbers),
      ),
    );
  }
}

Vậy, khi nào chúng ta nên dùng Future và Isolate?

Cả Futures và Isolates đều cho phép bạn thực hiện các hoạt động bất đồng bộ trong Flutter, nhưng chúng được sử dụng trong các tình huống khác nhau dựa trên loại tác vụ bạn muốn thực thi. Hãy cùng xem khi nào bạn nên sử dụng từng loại:

1. Futures:

  • Các tác vụ I/O: Đây là những tác vụ dành phần lớn thời gian chờ đợi một thứ hoàn thành, chẳng hạn như đọc hoặc ghi vào cơ sở dữ liệu, thực hiện yêu cầu mạng hoặc đọc/ghi từ/đến tệp. Futures cho phép bạn thực thi các tác vụ này mà không chặn luồng UI chính.
  • Các tác vụ tính toán ngắn: Nếu bạn có một phép tính nhanh không phụ thuộc nhiều vào CPU và không chặn UI trong một khoảng thời gian đáng kể, bạn có thể sử dụng Futures.
  • Các tác vụ bất đồng bộ đa chuỗi: Futures cho phép thực hiện nhiều tác vụ bất đồng bộ theo thứ tự cụ thể bằng cách sử dụng then, catchError và whenComplete.
  • Tích hợp với cú pháp async/await của Flutter: Sử dụng Futures với cú pháp async/await trong Dart rất đơn giản, giúp cho code bất đồng bộ trông gần giống như code đồng bộ.

2. Isolates:

  • Các tác vụ CPU: Khi bạn có một tác vụ tính toán dài sẽ chặn luồng chính và làm cho UI không phản hồi, bạn nên cân nhắc sử dụng Isolates. Do Dart là single-threaded, các thao tác CPU trên luồng chính có thể khiến ứng dụng bị treo. Isolates chạy trong một luồng riêng biệt, đảm bảo rằng luồng chính vẫn hoạt động.
  • Xử lý đồng thời: Nếu bạn cần thực hiện nhiều phép tính nặng độc lập, bạn có thể tạo nhiều isolates và đạt được sự đồng thời.
  • Các tác vụ yêu cầu bộ nhớ riêng biệt: Vì mỗi isolate có heap bộ nhớ riêng, chúng hữu ích khi bạn muốn đảm bảo rằng một phần code cụ thể không ảnh hưởng đến bộ nhớ của một phần code khác.
  • Các kịch bản truyền tin nâng cao: Khi bạn có các kịch bản phức tạp trong đó các phần khác nhau của code cần giao tiếp bất đồng bộ theo cách được kiểm soát, Isolates với hệ thống port của chúng có thể có lợi.

Nói một cách đơn giản, bạn nên cố gắng sử dụng Futures càng nhiều càng tốt (trực tiếp hoặc gián tiếp thông qua các phương thức async) vì code của Futures sẽ được chạy ngay khi Event Loop có thời gian. Điều này sẽ giúp người dùng cảm thấy mọi thứ đang được xử lý song song (mặc dù thực tế không phải vậy).

Bạn có thể quyết định sử dụng Future hay Isolate dựa trên thời gian trung bình cần thiết để chạy một đoạn code.

Future:

  • Dùng cho các phương thức mất vài mili giây để thực thi.

 Ví dụ: truy vấn cơ sở dữ liệu, đọc/ghi file nhỏ....

Isolate:

  • Dùng cho các tác vụ có thể mất hàng trăm mili giây trở lên.

 Ví dụ: xử lý hình ảnh, chạy thuật toán phức tạp, mã hoá...

Kết luận


Isolates là một công cụ mạnh mẽ trong Flutter để cải thiện hiệu suất và khả năng phản hồi bằng cách thực hiện các tác vụ tính toán tốn nhiều tài nguyên ở chế độ nền. Việc hiểu cách hoạt động của Event Loop là điều cần thiết. Điều quan trọng cần lưu ý là Flutter (Dart) là Single-Thread, do đó, để đáp ứng người dùng, nhà phát triển phải đảm bảo rằng ứng dụng sẽ chạy mượt mà nhất có thể. Futures và Isolates là những công cụ rất mạnh mẽ có thể giúp bạn đạt được mục tiêu này.