Mert Özen Her Satırda Daha İleri
Frontend Flutter ile Mobil — #05

Dart'ta Asenkron Programlama: Future, async/await, Stream ve Isolate

Mert Özen 15 Kas 2026 13 dk 4 görüntülenme
Dart'ta Asenkron Programlama: Future, async/await, Stream ve Isolate

Dart tek thread'li bir event loop'ta çalışır: async/await işi arka plana taşımaz, ağır bir iş UI'ı dondurur ve gerçek paralellik için ayrı bellekli isolate gerekir. Future'ı C#'taki Task ile, Stream'i IAsyncEnumerable ile karşılaştırıyor; 20 MB'lık bir JSON'un ana isolate'te frame'leri durdurduğunu, Isolate.run ile durdurmadığını ölçüyor, await edilmeyen Future'ın hatasının nasıl kaybolduğunu gösteriyoruz.

Sürüm notu: Bu yazıdaki Dart örnekleri Dart 3.13.7 (Flutter 3.47.8 ile gelen SDK) ile, Flutter testleri Flutter 3.47.8 ile çalıştırılmıştır. Süre ölçümleri yazının hazırlandığı makineye aittir; kendi makinende sayılar farklı çıkar, oranlar benzer kalır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) mobil uygulamasında her şey asenkron: katalog API'den gelir, sepet cihaza yazılır, sipariş arka planda gönderilir. Dart'ın bu işleri nasıl sıraya koyduğunu bilmeden Flutter'da doğru kod yazmak zor. Bu programın çıktı sırasını tahmin et:

// Çalışma sırası: senkron kod, microtask'lar, sonra event'ler (yazı 05).
import 'dart:async';

Future<String> loadTitle() async {
  print('loadTitle starts synchronously');
  await Future<void>.delayed(Duration.zero);
  print('loadTitle resumes after await');
  return 'Dune';
}

void main() {
  print('main starts');
  Timer.run(() => print('timer (event queue)'));
  unawaited(Future(() => print('Future() (event queue)')));
  scheduleMicrotask(() => print('microtask'));
  unawaited(loadTitle().then((title) => print('got $title')));
  print('main ends');
}

Sekiz satır var. "loadTitle starts synchronously" satırı "main ends"ten önce mi sonra mı gelir? scheduleMicrotask ile Timer.run hangisi önce çalışır? Cevabı bir sonraki bölümde gerçek çıktıyla veriyoruz.

Tek Thread, İki Queue: Event Loop

dart run bin/event_loop.dart
main starts
loadTitle starts synchronously
main ends
microtask
timer (event queue)
Future() (event queue)
loadTitle resumes after await
got Dune

Bir Dart programı (ve bir Flutter uygulamasının UI'ı) tek bir thread'de, bir event loop üzerinde çalışır. Loop'un iki queue'su var. Event queue'ya dış dünyadan gelen işler girer: timer'lar, I/O tamamlanmaları, dokunma event'leri, Flutter'da her frame'in çizim isteği. Microtask queue ise Dart'ın kendi iç işleri için: scheduleMicrotask ile eklenen kod ve tamamlanan Future'ların devam adımları. Kural basit: senkron kod bitince önce microtask queue tamamen boşaltılır, sonra event queue'dan bir iş alınır, ardından yine microtask'lar boşaltılır.

Çıktıyı bu kurala göre oku. main() senkron olarak sonuna kadar çalışıyor; loadTitle() çağrıldığında gövdesi ilk await'e kadar hemen çalıştığı için "loadTitle starts synchronously" satırı "main ends"ten önce geliyor. Ardından microtask çalışıyor. Sonra event queue'dan sırayla iki iş geliyor: Timer.run ve Future(...) ikisi de event queue'ya iş ekliyor ve eklenme sırasıyla çalışıyorlar. Future.delayed(Duration.zero) da bir timer olduğu için loadTitle'ın devamı en sona kalıyor.

Bu model JavaScript'in event loop'una çok benziyor; JavaScript İleri serisindeki "JavaScript Event Loop Derinlemesine: Task, Microtask ve Rendering Sırası" yazısını okuduysan Dart'ın microtask ve event queue'su sana tanıdık gelecek. Flutter açısından sonucu şu: event loop'u uzun süre meşgul eden her senkron iş, sıradaki frame'in çizilmesini de bekletir. 60 Hz bir ekranda bir frame için yaklaşık 16 ms var.

Future ve async/await: C#'taki Task ile Farklar

Future<T>, C#'taki Task<T>'nin karşılığı; async ve await aynı anlamda. Benzerlikler fazla: ikisi de oluşturulduğu anda çalışmaya başlar, yani "sıcak" başlar (C# serisindeki "C# Task ve Task<T>: Sıcak Başlayan Bir Söz" yazısı). İkisinde de async bir method ilk await'e kadar çağıranın thread'inde senkron çalışır; çıktıdaki ikinci satır bunun kanıtı.

Farklar thread modelinden geliyor. C#'ta await'ten sonra kodun hangi thread'de devam edeceği SynchronizationContext'e bağlı; UI uygulamalarında ConfigureAwait(false) tartışması bu yüzden var. Dart'ta bir isolate'in tek thread'i var; await'ten sonra kod her zaman aynı isolate'te devam eder. ConfigureAwait yok, gerekmiyor. Aynı nedenle lock da yok: iki await arasındaki kod başka bir kod tarafından kesilmez, bir field'ı okuyup yazarken araya başka bir thread giremez.

İkinci fark iptal. C#'ta CancellationToken standart bir parametre; Dart'ın Future'ında yerleşik bir iptal mekanizması yok. Bir HTTP isteğini iptal etmek kullandığın kütüphaneye bağlı (API yazısında dio'nun CancelToken'ına bakacağız). İptal Dart'ta en temiz şekilde Stream'lerde var.

Stream: IAsyncEnumerable'ın Karşılığı

Future tek bir değer, Stream zaman içinde gelen değerler dizisi. C#'taki IAsyncEnumerable<T> ve await foreach ikilisinin karşılığı Stream<T> ve await for; C#'taki yield return'lü async iterator'ın karşılığı ise async* fonksiyon ve yield:

// async*, await for, single-subscription ve broadcast, cancel (yazı 05).
import 'dart:async';

// C#'taki IAsyncEnumerable<T> gibi: sayfalar tek tek gelir
Stream<List<String>> catalogPages() async* {
  const pages = [
    ['Dune', 'Emma'],
    ['Middlemarch', 'The Hobbit'],
    ['Persuasion'],
  ];
  try {
    for (final (index, page) in pages.indexed) {
      await Future<void>.delayed(const Duration(milliseconds: 10));
      print('  generator: yielding page ${index + 1}');
      yield page;
    }
  } finally {
    print('  generator: closed');
  }
}

Future<void> main() async {
  print('await for:');
  await for (final page in catalogPages()) {
    print('  received $page');
    if (page.contains('The Hobbit')) break; // break subscription'ı iptal eder
  }

  print('single subscription:');
  final pages = catalogPages();
  final first = pages.listen((_) {});
  try {
    pages.listen((_) {});
  } on StateError catch (e) {
    print('  $e');
  }
  await first.cancel();

  print('broadcast:');
  final cartCount = StreamController<int>.broadcast();
  final badge = cartCount.stream.listen((n) => print('  badge: $n'));
  cartCount.stream.listen((n) => print('  total bar: $n'));
  cartCount.add(1);
  await Future<void>.delayed(Duration.zero);
  await badge.cancel();
  cartCount.add(2);
  await cartCount.close();
}
await for:
  generator: yielding page 1
  received [Dune, Emma]
  generator: yielding page 2
  received [Middlemarch, The Hobbit]
  generator: closed
single subscription:
  Bad state: Stream has already been listened to.
  generator: yielding page 1
  generator: closed
broadcast:
  badge: 1
  total bar: 1
  total bar: 2

İlk blokta break, üçüncü sayfa hiç üretilmeden döngüyü bitirdi ve generator'ın finally bloğu çalıştı. Dart'ta iptal budur: await for'dan çıkmak ya da StreamSubscription.cancel() çağırmak üreticiye "dur" der. C#'ta aynı etki için await foreach'ten çıkmak enumerator'ı dispose eder; iptal token'ı ise ayrıca verilir.

Single-subscription ve broadcast

async* ile üretilen stream single-subscription'dır: tek bir dinleyicisi olabilir. İkinci listen çağrısı çıktıdaki Bad state: Stream has already been listened to. hatasını fırlattı. Bunun nedeni, bu tür stream'lerin bir veri kaynağını (bir dosya, bir HTTP yanıtı) temsil etmesi ve ancak biri dinlemeye başlayınca üretmeye başlaması. Çıktıdaki "generator: yielding page 1, generator: closed" satırları da buna bir örnek: ilk subscription'ı iptal ettiğimizde generator o sırada beklediği delayed'ı bitirdi, bir sonraki yield'e geldiğinde iptali gördü ve kapandı. İptal anında değil, bir sonraki yield'de etkili oluyor.

Broadcast stream ise birden fazla dinleyiciye aynı değeri dağıtır; sepetteki kitap sayısını hem rozet hem alttaki toplam çubuğu dinleyebilir. badge iptal edildikten sonra gelen 2 değerini yalnız ikinci dinleyici aldı. Broadcast stream geçmişi tutmaz: geç abone olan, önceki değerleri görmez. RxJS'teki Subject ile BehaviorSubject farkı burada da geçerli; Flutter'da "son değeri tut ve yeni dinleyiciye ver" ihtiyacını genelde ChangeNotifier ya da ValueNotifier ile çözeceğiz.

Isolate: Ayrı Bellekli Paralellik

Event loop tek thread'li olduğuna göre CPU'yu uzun süre meşgul eden bir iş ne olacak? Örneğin katalogun tamamını tek seferde indirip parse etmek. Dart'ın cevabı isolate: kendi belleği ve kendi event loop'u olan ayrı bir çalışma birimi. Thread'den farkı, isolate'ler bellek paylaşmaz; birbirleriyle yalnız mesaj göndererek konuşurlar. Bu yüzden Dart'ta veri yarışı (data race) yaşamazsın, ama veriyi bir isolate'ten diğerine taşımanın bir maliyeti var. Isolate.run bunu tek satıra indiriyor: verdiğin fonksiyonu yeni bir isolate'te çalıştırıp sonucu döndürüyor.

// Büyük bir JSON'u ana isolate'te ve Isolate.run ile parse etmek (yazı 05).
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';

String buildCatalogJson(int count) => jsonEncode([
  for (var i = 0; i < count; i++)
    {
      'isbn': '978${i.toString().padLeft(10, '0')}',
      'title': 'Book $i',
      'author': 'Author ${i % 500}',
      'price': 10000 + i % 9000,
      'tags': ['novel', 'classic'],
    },
]);

int parseAndCount(String json) => (jsonDecode(json) as List).length;

// 16 ms'lik timer frame'lerin yerini tutuyor: tick atamıyorsa UI donardı
Future<void> measure(String label, Future<int> Function() parse) async {
  var ticks = 0;
  final frames = Timer.periodic(
    const Duration(milliseconds: 16),
    (_) => ticks++,
  );
  final watch = Stopwatch()..start();
  final count = await parse();
  watch.stop();
  frames.cancel();
  final expected = watch.elapsedMicroseconds ~/ 16000;
  print(
    '$label: $count books in ${(watch.elapsedMicroseconds / 1000).toStringAsFixed(1)} ms, frame ticks $ticks of ~$expected',
  );
}

Future<void> main() async {
  final json = buildCatalogJson(200000);
  print('JSON size: ${(json.length / 1024 / 1024).toStringAsFixed(1)} MB');

  await measure('main isolate', () async => parseAndCount(json));
  await measure('Isolate.run ', () => Isolate.run(() => parseAndCount(json)));

  // Küçük bir sayfada isolate kazandırdığından fazlasına mal olur
  final page = buildCatalogJson(20);
  await measure('small, main   ', () async => parseAndCount(page));
  await measure('small, isolate', () => Isolate.run(() => parseAndCount(page)));
}

Üç koşudan ikisi:

JSON size: 20.8 MB
main isolate: 200000 books in 130.4 ms, frame ticks 0 of ~8
Isolate.run : 200000 books in 120.1 ms, frame ticks 7 of ~7
small, main   : 20 books in 0.1 ms, frame ticks 0 of ~0
small, isolate: 20 books in 0.2 ms, frame ticks 0 of ~0

JSON size: 20.8 MB
main isolate: 200000 books in 128.8 ms, frame ticks 0 of ~8
Isolate.run : 200000 books in 117.0 ms, frame ticks 7 of ~7
small, main   : 20 books in 0.1 ms, frame ticks 0 of ~0
small, isolate: 20 books in 0.3 ms, frame ticks 0 of ~0

20,8 MB'lık JSON'u ana isolate'te parse etmek yaklaşık 130 ms sürdü ve bu sürede 16 ms'lik timer bir kez bile tick atamadı: Flutter'da bu, ekranın yaklaşık sekiz frame boyunca donması, kaydırmanın takılması demek. Aynı iş Isolate.run ile benzer sürede bitti ama ana isolate serbest kaldı ve timer beklenen sayıda çalıştı. Toplam süre kısalmadı, çünkü amaç hızlandırmak değil, UI thread'ini boş tutmak. Küçük sayfada ise tablo tersine dönüyor: 20 kitabı parse etmek 0,1 ms, isolate açmak bunun birkaç katı. Sayfalı bir API yanıtı için isolate gereksiz.

Flutter'da aynı işi compute(fonksiyon, mesaj) ile de yapabilirsin; mobil ve desktop'ta arka planda Isolate.run kullanıyor. Web'de ise isolate desteği yok ve compute işi aynı thread'de çalıştırıyor. C#'ta bu iş için Task.Run yazardın; fark, Task.Run'ın aynı heap'i paylaşan bir thread pool thread'i kullanması, isolate'in ise veriyi kopyalaması.

Kaybolan Hata: Await Edilmeyen Future

Asenkron kodda en sık hata, bir Future'ı başlatıp beklemeyi unutmak. Sepeti kaydeden bir akış:

Future<void> saveCart() async {
  await Future<void>.delayed(const Duration(milliseconds: 10));
  throw StateError('API returned 500');
}

Future<void> checkout() async {
  saveCart(); // await unutuldu
  print('checkout: done');
}

void onBuyPressed() {
  checkout(); // async bir fonksiyonu çağıran senkron callback
}

Future<void> main() async {
  onBuyPressed();
  await Future<void>.delayed(const Duration(milliseconds: 50));
  print('main: end');
}
checkout: done
Unhandled exception:
Bad state: API returned 500
#0      saveCart (file:///home/user/src/bookstore-flutter/scratch/bin/lost_error.dart:3:3)
<asynchronous suspension>

"checkout: done" yazıldı, yani kullanıcıya sipariş başarılı gösterildi; kayıt ise başarısız oldu ve hatayı yakalayan kimse yoktu. Konsol programında bu yakalanmamış hata süreci sonlandırdı, "main: end" hiç yazılmadı. Flutter'da uygulama çökmez; hata PlatformDispatcher.onError'a gider ve log'a düşer, kullanıcı hiçbir şey görmez. C#'ta da await edilmeyen bir Task'ın exception'ı kolayca gözden kaçar, ama derleyici async bir method içindeki await edilmeyen çağrı için varsayılan olarak uyarır (CS4014); Dart'ta bunu yakalayan lint'ler varsayılan olarak kapalı. analysis_options.yaml'a iki kural ekliyoruz:

include: package:flutter_lints/flutter.yaml

linter:
  rules:
    # Kimsenin await etmediği bir Future hatasını kaybeder (yazı 05)
    - unawaited_futures
    - discarded_futures
   info - bin/lost_error.dart:7:3 - Missing an 'await' for the 'Future' computed by this expression. Try adding an 'await' or wrapping the expression with 'unawaited'. - unawaited_futures
   info - bin/lost_error.dart:12:3 - 'Future'-returning calls in a non-'async' function. Try converting the enclosing function to be 'async' and then 'await' the future, or wrap the expression in 'unawaited'. - discarded_futures

unawaited_futures async bir fonksiyon içinde beklenmeyen Future'ı, discarded_futures ise senkron bir fonksiyonda atılan Future'ı yakalıyor. Bir Future'ı gerçekten beklemek istemiyorsan (log göndermek gibi) bunu dart:async'teki unawaited(...) ile açıkça yaz; event loop örneğindeki unawaited çağrıları bu yüzden orada. Her iki kural da hem bookstore_app'te hem dart_samples'ta artık açık.

Flutter'da: build İçinde Future Başlatmak

Flutter'a özgü bir tuzak: bir widget'ın build method'unda Future oluşturmak. build, Flutter'ın istediği her an ve sıklıkta çağırabileceği bir fonksiyon; parent rebuild olduğunda, tema değiştiğinde, klavye açıldığında. Bunu bookstore_app'e bir test olarak ekledik:

import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';

var apiCalls = 0;

Future<int> fetchBookCount() async {
  apiCalls++;
  return 42;
}

// Yanlış: build() birçok kez çalışabilir, her çalışma yeni bir istek başlatır
class CountInBuild extends StatelessWidget {
  const CountInBuild({super.key, required this.label});
  final String label;

  @override
  Widget build(BuildContext context) => FutureBuilder<int>(
    future: fetchBookCount(),
    builder: (context, snapshot) => Text('$label: ${snapshot.data ?? '...'}'),
  );
}

// Doğru: isteği bir kez başlat ve Future'ı State'te tut
class CountInState extends StatefulWidget {
  const CountInState({super.key, required this.label});
  final String label;

  @override
  State<CountInState> createState() => _CountInStateState();
}

class _CountInStateState extends State<CountInState> {
  late final Future<int> _count = fetchBookCount();

  @override
  Widget build(BuildContext context) => FutureBuilder<int>(
    future: _count,
    builder: (context, snapshot) =>
        Text('${widget.label}: ${snapshot.data ?? '...'}'),
  );
}

void main() {
  testWidgets('a Future created in build runs on every rebuild', (
    tester,
  ) async {
    apiCalls = 0;
    for (final label in ['Books', 'Books ', 'Books  ']) {
      await tester.pumpWidget(MaterialApp(home: CountInBuild(label: label)));
    }
    debugPrint('Future in build(): $apiCalls API calls for 3 builds');
    expect(apiCalls, 3);
  });

  testWidgets('a Future kept in State runs once', (tester) async {
    apiCalls = 0;
    for (final label in ['Books', 'Books ', 'Books  ']) {
      await tester.pumpWidget(MaterialApp(home: CountInState(label: label)));
    }
    debugPrint('Future in State: $apiCalls API call for 3 builds');
    expect(apiCalls, 1);
  });
}
flutter test test/pitfalls/future_in_build_test.dart
00:00 +0: loading test/pitfalls/future_in_build_test.dart
00:00 +0: a Future created in build runs on every rebuild
Future in build(): 3 API calls for 3 builds
00:00 +1: a Future kept in State runs once
Future in State: 1 API call for 3 builds
00:00 +2: All tests passed!

Üç rebuild, üç API çağrısı. Gerçek bir uygulamada bu, ekran her yeniden çizildiğinde katalogun yeniden indirilmesi ve FutureBuilder'ın her seferinde yükleniyor durumuna dönüp ekranın titremesi demek. Doğru yol, isteği bir kez başlatıp Future'ı State'te tutmak; late final field ilk okunduğunda bir kez oluşturuluyor. Bunu genellikle initState'te yaparız, bir sonraki yazıda State'in yaşam döngüsünü açtığımızda göreceğiz. Mimari yazısından itibaren ise veri çekme işi widget'tan tamamen çıkıp ViewModel'e taşınacak.

Üretimde Dikkat

Uzun senkron işleri ölç. Flutter DevTools'un performans görünümü, 16 ms'yi aşan frame'leri kırmızıyla işaretler; bir frame'in uzun sürmesinin sebebi çoğu zaman build'de yapılan ağır bir hesap ya da ana isolate'te parse edilen büyük bir yanıttır. Ölçümü profile modda, gerçek cihazda yap.

Yakalanmamış asenkron hataları bir yerde topla. Flutter'da FlutterError.onError framework hatalarını, PlatformDispatcher.instance.onError ise yakalanmamış asenkron hataları alır. İkisini de bir log ya da crash raporlama servisine bağlamazsan, yukarıdaki gibi kaybolan bir kayıt hatasını kullanıcı şikâyet edene kadar görmezsin.

Stream subscription'larını kapat. Bir ekran bir stream'i dinlemeye başlayıp kapanırken cancel çağırmazsa, dinleyici yaşamaya ve ekranın state'ini tutmaya devam eder. Bir sonraki yazıda bunu dispose ile nasıl yapacağımızı göreceğiz.

Sık Düşülen Tuzaklar

Birinci tuzak, async yazmanın kodu arka plana taşıdığını sanmak. async bir fonksiyon ana isolate'te çalışır; içindeki ağır bir döngü UI'ı aynen dondurur. Arka plana taşımak isolate ister.

İkinci tuzak, Future.wait ile Task.WhenAll'ı birebir aynı sanmak. İkisi de bütün işleri paralel bekler, ama Future.wait birden fazla hata olduğunda yalnız ilkini verir, diğerlerini sessizce atar; C#'ta ise Task.Exception içindeki AggregateException hepsini taşır. Bütün hataları görmek istiyorsan Dart 3'ün [f1, f2].wait extension'ını kullan: hata durumunda her sonucu ve hatayı içeren bir ParallelWaitError fırlatır.

Üçüncü tuzak, broadcast stream'e geç abone olup son değeri beklemek. Broadcast stream geçmişi tutmaz; "son durumu ver" ihtiyacı varsa ValueNotifier ya da son değeri saklayan bir yapı kullan.

Dördüncü tuzak, isolate'e büyük bir object grafiği gönderip geri almak. Mesajlar kopyalanır; 20 MB'lık bir yapıyı iki yönde taşımak, parse süresini kazandığından fazlasına mal edebilir. Mümkünse isolate'e string ya da byte gönder, sonucu küçük ve düz tut.

Küçük Bir Deneme

★☆☆ event_loop.dart'ta scheduleMicrotask satırını Timer.run'dan önceye ve sonraya taşı. Çıktı değişiyor mu? Future.microtask(...) kullanırsan ne olur?

★★☆ catalogPages'i bir C# IAsyncEnumerable<List<string>> olarak yaz ve await foreach içinde break ile çık. finally bloğu C#'ta da çalışıyor mu? İptal için [EnumeratorCancellation] ile bir CancellationToken ekle.

★★★ isolate_parse.dart'ı değiştir: isolate yalnız sayıyı değil, parse edilen list'in tamamını döndürsün. Süre ve tick sayısı nasıl değişiyor? Sonucu küçük tutmanın etkisini kendi ölçümünle göster.

Bir sonraki yazıda Dart'ı bırakıp Flutter'ın merkezine dönüyoruz: her şeyin widget olması, StatelessWidget ve StatefulWidget, BuildContext, State'in yaşam döngüsü ve Flutter'ın widget, element ve render object olarak tuttuğu üç ağaç.