Isolate & Concurrency #
Di dalam dunia pengembangan aplikasi modern, kemampuan untuk memanfaatkan daya pemrosesan multi-core pada perangkat keras saat ini sangat krusial untuk menjaga kelancaran antarmuka pengguna. Jika kita membiarkan komputasi berat menghambat thread utama, pengguna akan langsung merasakan UI jank atau bahkan App Freeze. Dart menjawab tantangan ini dengan pendekatan arsitektur yang sangat unik yang disebut Isolates. Berbeda dengan sistem multi-threading tradisional yang berbagi ruang memori, Isolates memisahkan memori secara total, mengeliminasi race conditions sejak tingkat arsitektur. Kita akan membedah model konkurensi Dart, membedakan Concurrency dengan Parallelism, serta menguasai teknik komunikasi Isolate satu arah maupun dua arah untuk skenario produksi.
Konkurensi (Concurrency) vs Paralelisme (Parallelism) #
Sering kali, istilah konkurensi (concurrency) dan paralelisme (parallelism) disalahartikan sebagai hal yang sama. Padahal, keduanya memiliki perbedaan mendasar dalam cara kerja penanganan instruksi oleh prosesor:
- Konkurensi (Concurrency): Adalah kemampuan untuk menangani beberapa tugas sekaligus secara bergantian pada satu thread utama. Dart mengimplementasikan ini menggunakan mekanisme Event Loop bersama dengan instruksi
async/awaitdanFuture. Di sini, tugas-tugas non-blocking (seperti menunggu respons server) dilepaskan ke sistem operasi, dan saat data kembali, tugas tersebut dijadwalkan masuk kembali ke antrean untuk dieksekusi secara bergantian. - Paralelisme (Parallelism): Adalah kemampuan untuk mengeksekusi beberapa tugas secara bersamaan pada waktu yang persis sama dengan memanfaatkan beberapa inti fisik CPU (multi-core CPU). Di Dart, paralelisme sejati ini hanya dapat dicapai dengan membuat Isolates baru.
Mari kita perhatikan kapan kita harus menggunakan instrumen asinkron biasa dan kapan harus melibatkan Isolate:
- Gunakan
async/awaituntuk tugas yang bersifat I/O-bound (tugas yang banyak menghabiskan waktu menunggu perangkat keras eksternal seperti network request, membaca database lokal, atau menulis file ke penyimpanan). - Gunakan
Isolatesuntuk tugas yang bersifat CPU-bound (tugas yang menghabiskan daya komputasi prosesor secara intensif, seperti memproses file gambar, mendekripsi data besar, atau menjalankan algoritma matematika yang rumit).
Arsitektur Isolate: Mengapa Tanpa Berbagi Memori? #
Pada sistem operasi tradisional, Threads di dalam proses yang sama berbagi ruang memori heap yang sama (Shared-Memory Threading). Pendekatan ini memiliki kelemahan serius: jika dua thread mencoba mengubah variabel yang sama secara bersamaan, akan terjadi kerusakan data (race condition). Untuk mengatasinya, pengembang harus menulis kunci memori yang rumit (mutex / locks), yang rawan memicu kemacetan aplikasi (deadlock) dan membebani kinerja prosesor.
Dart memecahkan masalah ini dengan merancang Isolates. Sesuai dengan namanya, setiap Isolate adalah sebuah wadah eksekusi yang benar-benar terisolasi secara mandiri.
- Setiap Isolate memiliki heap memori sendiri dan menjalankan Event Loop-nya sendiri.
- Isolate A tidak diperbolehkan secara fisik mengakses memori milik Isolate B, begitu pula sebaliknya.
- Karena memori tidak dibagi (Shared-Nothing Architecture), Dart terhindar dari kebutuhan penggunaan mutex atau kunci memori apa pun. Ini membuat eksekusi kode terjamin 100% aman dari masalah race conditions.
Selain keamanan, keuntungan lain dari arsitektur ini adalah sistem pengumpul sampah memori (Garbage Collector atau GC) dapat berjalan secara mandiri di setiap Isolate tanpa perlu menghentikan sementara jalannya Isolate lain. Ini meminimalkan terjadinya jeda rendering visual pada layar aplikasi Flutter.
Perbandingan arsitektur ini dapat divisualisasikan melalui diagram berikut:
flowchart TD
subgraph Tradisional["Model Thread Tradisional (Shared Memory)"]
direction TB
T1["Thread 1"] --> SharedMem["Shared Heap Memori"]
T2["Thread 2"] --> SharedMem
SharedMem -.->|"Rawan Race Conditions & Deadlocks"| Err["Butuh Mutex / Lock Kompleks"]
end
subgraph DartIsolate["Model Isolate Dart (Shared Nothing)"]
direction TB
I1["Isolate Utama (Heap A)"] -->|"Message Passing"| Port["Saluran Port"]
I2["Isolate Pekerja (Heap B)"] -->|"Message Passing"| Port
Port -.->|"Aman & Terisolasi"| Ok["Bebas Race Conditions"]
endSatu-satunya cara agar dua Isolate dapat saling bertukar informasi adalah dengan mengirimkan salinan data melalui saluran port komunikasi khusus (Message Passing).
Cara Termudah: Isolate.run() dan compute() #
Sejak rilis Dart 3.0, kita diberikan cara yang sangat praktis untuk memindahkan tugas berat ke Isolate latar belakang tanpa harus menulis boilerplate port secara manual menggunakan fungsi Isolate.run().
Fungsi Isolate.run() akan secara otomatis melakukan tiga hal di bawah kap:
- Membuat (spawn) Isolate pekerja baru secara dinamis.
- Menjalankan fungsi komputasi yang kita kirimkan pada Isolate pekerja tersebut.
- Mengembalikan hasil kalkulasi ke Isolate utama dan langsung mematikan Isolate pekerja untuk menghemat memori.
Contoh Implementasi Isolate.run() #
Mari kita bandingkan eksekusi parsing JSON berukuran besar yang berpotensi memicu jank dengan solusi asinkron Isolate yang benar:
// ANTI-PATTERN: Menjalankan pemrosesan JSON besar di UI Thread (Memicu Jank!)
Future<List<User>> loadAndParseUsersSync() async {
final jsonString = await http.read(Uri.parse('https://api.example.com/large-users'));
// jsonDecode dan pemetaan objek ini berjalan di UI Thread.
// Jika ada 10.000 item, operasi ini bisa memakan waktu 200ms, layar akan macet seketika!
final List<dynamic> jsonList = jsonDecode(jsonString);
return jsonList.map((json) => User.fromJson(json)).toList();
}
// ====================================================================
// BENAR: Memindahkan proses decoding JSON besar ke Isolate pekerja
Future<List<User>> loadAndParseUsersAsync() async {
final jsonString = await http.read(Uri.parse('https://api.example.com/large-users'));
// Menjalankan fungsi berat di Isolate latar belakang. UI Thread tetap mulus 120 FPS!
return await Isolate.run(() {
final List<dynamic> jsonList = jsonDecode(jsonString);
return jsonList.map((json) => User.fromJson(json)).toList();
});
}
Isolate.run() vs compute() #
Di dalam ekosistem Flutter, kita mungkin juga sering melihat fungsi bernama compute().
compute()adalah fungsi pembungkus (wrapper) tipis milik framework Flutter yang berada di atasIsolate.run().- Perbedaan Utama: Fungsi
compute()hanya menerima fungsi statis atau top-level function eksternal dan mewajibkan pengiriman parameter terpisah. SedangkanIsolate.run()jauh lebih fleksibel karena dapat menerima fungsi anonim (closure) sehingga kita bisa mengakses variabel lokal secara langsung di dalam blok kodenya.
Komunikasi Port: SendPort dan ReceivePort #
Untuk skenario di mana kita memerlukan komunikasi yang lebih kompleks (seperti memantau progres persentase tugas yang sedang berjalan), kita harus mengelola saluran port secara manual menggunakan ReceivePort dan SendPort.
ReceivePort: Bertindak sebagai pintu penerima pesan di Isolate kita. Port ini menghasilkan objekStreamyang mendengarkan data masuk.SendPort: Bertindak sebagai pintu pengirim pesan. Kita mengirimkan pesan ke alamatSendPortmilik Isolate lain agar data tersebut sampai keReceivePortpasangannya.
Alur Komunikasi Satu Arah #
Berikut adalah sequence diagram dari siklus komunikasi dasar satu arah antara Isolate Utama dan Isolate Pekerja:
sequenceDiagram
participant Main as Isolate Utama
participant Worker as Isolate Pekerja
Main->>Main: Buat ReceivePort (mainPort)
Main->>Worker: Isolate.spawn(entryPoint, mainPort.sendPort)
Note over Worker: Jalankan Komputasi Berat
Worker->>Main: mainPort.sendPort.send(hasil)
Main->>Main: Terima Hasil & Tutup Port
Note over Worker: Isolate Pekerja Berhenti / MatiMari kita terjemahkan diagram di atas ke dalam kode Dart konkret:
import 'dart:isolate';
// Fungsi titik masuk Isolate Pekerja (Wajib berupa top-level atau static function)
void workerEntryPoint(SendPort mainSendPort) {
// Melakukan komputasi matematika berat
int sum = 0;
for (int i = 1; i <= 5000000; i++) {
sum += i;
}
// Mengirimkan hasil kembali ke Isolate Utama
mainSendPort.send(sum);
}
void startOneWayCommunication() async {
// 1. Membuat pintu penerima di Isolate Utama
final mainReceivePort = ReceivePort();
// 2. Membuat Isolate Pekerja dan memberikan alamat kirim (SendPort) utama
await Isolate.spawn(workerEntryPoint, mainReceivePort.sendPort);
// 3. Mendengarkan pesan hasil yang dikirimkan oleh pekerja
mainReceivePort.listen((message) {
print('Hasil komputasi diterima: $message');
// 4. Selalu tutup ReceivePort jika sudah selesai digunakan agar tidak bocor memori
mainReceivePort.close();
});
}
Long-Lived Worker: Komunikasi Dua Arah yang Persisten #
Membuat (spawning) Isolate baru memiliki biaya waktu (overhead startup) sekitar 5-10 milidetik untuk mengalokasikan memori heap baru. Jika kita terus-menerus membuat dan mematikan Isolate untuk tugas-tugas kecil yang sering terjadi, hal tersebut justru dapat menurunkan efisiensi aplikasi.
Solusi terbaik untuk masalah ini adalah dengan membuat Long-Lived Worker Isolate — sebuah Isolate pekerja yang dibuat sekali saat aplikasi pertama kali berjalan, tetap hidup di latar belakang untuk mendengarkan perintah kerja baru, dan mengirimkan respon berkali-kali.
Untuk mewujudkannya, kita memerlukan komunikasi dua arah di mana Isolate Utama dan Isolate Pekerja saling memegang SendPort satu sama lain.
Contoh Implementasi Long-Lived JsonWorker #
Mari kita rancang sebuah kelas pekerja persisten untuk memproses parsing JSON secara berulang:
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
class JsonWorker {
Isolate? _isolate;
SendPort? _workerSendPort;
final _readyCompleter = Completer<void>();
// Memulai inisiasi Isolate pekerja
Future<void> start() async {
final mainReceivePort = ReceivePort();
// Mendengarkan respon pertama dari worker (yang berisi SendPort miliknya)
mainReceivePort.listen((message) {
if (message is SendPort) {
_workerSendPort = message;
_readyCompleter.complete(); // Worker siap menerima tugas
} else {
_handleWorkerResponse(message);
}
});
// Spawn worker isolate
_isolate = await Isolate.spawn(_workerEntryPoint, mainReceivePort.sendPort);
await _readyCompleter.future;
}
// Mengirimkan tugas baru ke worker
void parseJson(String jsonStr) {
if (_workerSendPort == null) throw StateError('Worker belum siap.');
_workerSendPort!.send(jsonStr);
}
void _handleWorkerResponse(dynamic response) {
print('Respon dari Worker: $response');
}
// Menutup worker saat tidak lagi dibutuhkan
void dispose() {
_isolate?.kill(priority: Isolate.beforeNextEvent);
_isolate = null;
}
// Titik masuk internal Isolate Pekerja yang terus berjalan
static void _workerEntryPoint(SendPort mainSendPort) {
final workerReceivePort = ReceivePort();
// Langkah Awal: Kirim SendPort milik worker kembali ke Isolate Utama
mainSendPort.send(workerReceivePort.sendPort);
// Mendengarkan instruksi kerja berulang dari Isolate Utama
workerReceivePort.listen((message) {
if (message is String) {
// Melakukan proses parsing di latar belakang
try {
final decoded = jsonDecode(message);
mainSendPort.send({'status': 'success', 'data': decoded});
} catch (e) {
mainSendPort.send({'status': 'error', 'message': e.toString()});
}
}
});
}
}
TransferableTypedData — Transfer Tanpa Salin (Zero-Copy) #
Secara standar bawaan, saat kita mengirimkan pesan berupa objek (seperti List atau Map) antar Isolate, Dart akan melakukan proses duplikasi data mendalam (deep copy). Proses penyalinan ini bertujuan untuk menjamin isolasi memori heap tetap terjaga.
Namun, jika kita mengirimkan data biner mentah berukuran sangat besar (misalnya file gambar resolusi tinggi 20MB, rekaman audio, atau file PDF), proses penyalinan tersebut akan memakan waktu komputasi yang lama dan memboroskan kapasitas memori RAM.
Untuk menangani kasus manipulasi byte besar ini secara efisien, Dart menyediakan kelas TransferableTypedData. Fitur ini memindahkan hak kepemilikan data biner antar heap Isolate secara instan tanpa proses penyalinan memori (Zero-Copy Transfer).
import 'dart:isolate';
import 'dart:typed_data';
void workerProcessBytes(SendPort mainSendPort) {
// Membuat buffer data biner sebesar 20 Megabyte
final Uint8List rawBytes = Uint8List(20 * 1024 * 1024);
// Mengisi data...
rawBytes[0] = 255;
// Membungkus buffer ke dalam TransferableTypedData
final transferable = TransferableTypedData.fromList([rawBytes]);
// Mengirim data ke Isolate utama dengan pemindahan kepemilikan (Zero-copy)
mainSendPort.send(transferable);
// PENTING: Setelah transferable dikirim, variabel rawBytes di dalam Isolate ini
// sudah didealisasikan dan tidak boleh diakses lagi karena memorinya telah dipindahkan!
}
Batasan Isolate: Apa yang Boleh dan Tidak Boleh Dikirim? #
Karena isolasi memori yang ketat, tidak semua objek Dart dapat dilewatkan melalui parameter SendPort.send().
Berikut adalah matriks acuan untuk menentukan objek mana saja yang aman untuk dikirim:
| Jenis Objek | Status Kompatibilitas | Keterangan |
|---|---|---|
Tipe data primitif (null, bool, int, double, String) | BISA | Dikirim secara langsung. |
Koleksi dasar (List, Map, Set) | BISA | Dengan syarat seluruh elemen di dalamnya juga bertipe data yang kompatibel. |
SendPort | BISA | Sangat penting untuk inisiasi jalur komunikasi dua arah. |
| Objek kelas buatan kita sendiri | BISA | Objek Dart biasa tanpa ketergantungan native. |
TransferableTypedData | BISA | Dikirim tanpa salin (zero-copy). |
| Fungsi Anonim (Closures / Lambda) | TIDAK BISA | Karena closure membawa referensi ruang lingkup (context) memori tempat ia dibuat. |
ReceivePort | TIDAK BISA | Port penerima tidak dapat berpindah Isolate. |
Objek Native (Socket, open file handles) | TIDAK BISA | Memiliki dependensi sistem operasi langsung yang tidak bisa diisolasi. |
Ringkasan #
- Arsitektur Isolates: Dart menggunakan wadah Isolate yang tidak berbagi memori heap (Shared-Nothing), mengeliminasi risiko race conditions dan kebutuhan kunci memori (locks).
- Konkurensi vs Paralelisme: Gunakan
async/awaitbiasa untuk tugas penantian jaringan (I/O-bound) dan gunakanIsolatesuntuk tugas kalkulasi intensif CPU (CPU-bound).- Isolate.run(): Cara praktis modern untuk memindahkan komputasi berat ke latar belakang secara otomatis tanpa perlu menulis port komunikasi manual.
- SendPort & ReceivePort: Blok pembangun dasar komunikasi antar Isolate. Selalu tutup
ReceivePortjika sudah selesai digunakan untuk mencegah kebocoran memori.- Long-Lived Worker: Pola desain terbaik untuk mempertahankan satu Isolate latar belakang tetap hidup guna melayani tugas berulang tanpa overhead pembuatan Isolate baru.
- TransferableTypedData: Mengoptimalkan transfer data byte biner berukuran besar antar Isolate secara instan tanpa proses duplikasi memori (zero-copy).
← Sebelumnya: Functional Programming Berikutnya: Best Practice →