openlang

Marking the filter async did nothing for the freeze

Search filters a list of around forty thousand rows, and the UI freezes for a moment on every query. I figured async was the fix, so the filter became this and the tap handler awaits it.

Future<List<Entry>> filterRows(List<Entry> rows, String query) async {
  final out = <Entry>[];
  for (final r in rows) {
    if (r.matches(query)) out.add(r);
  }
  return out;
}

// in the tap handler
setState(() => loading = true);
final result = await filterRows(allRows, query);

Same freeze. The spinner I turn on right before the call doesn't even show up. I know Isolate.run exists, I've used it for a big JSON decode, but it felt like a lot for a filter, and I thought await was supposed to keep frames going while it waits. What is the await actually doing here?

The conversation continues 8

Follow the reasoning
  1. Permalink

    I read this the way I'd read a CompletableFuture.supplyAsync call in Java. There the body runs on a pool thread and the caller only waits for the value, so a slow loop inside it never touches the UI thread. If Future works the same way here, the filter itself isn't what freezes you. I'd look at what runs after the await instead. Rebuilding forty thousand rows on the UI thread in one go could easily cost that much, and the spinner may simply be losing the race with that rebuild.

  2. Permalink

    Nope, and it's an easy map to make coming from Java. A Dart Future is a value that arrives later, not a thread. The whole app runs in one isolate, and that isolate has one thread running one event loop. On top of that, an async function runs synchronously until its first await. filterRows has no await inside, so the entire loop runs right there in the tap handler, before the Future is even returned. The frame that would draw the spinner sits in the event queue behind it. The rebuild after the await isn't the slow part, it doesn't even start until the loop is done.

    Moving the work off that loop takes a second isolate.

    import 'dart:isolate';
    
    List<Entry> filterRows(List<Entry> rows, String query) =>
        rows.where((r) => r.matches(query)).toList();
    
    // in the tap handler
    final result = await Isolate.run(() => filterRows(rows, query));

    Now the await waits for a message from the other isolate, and the event loop is free in between. I do wish the keyword sounded less like it moves work somewhere else.

  3. Permalink

    You're right, and I had it backwards. I carried the pool thread over from supplyAsync, and that's exactly the part Dart never promised. Reading n.vos's code again with that fixed, the loop runs inside the call itself, and the rebuild I blamed hadn't even started when the screen froze.

  4. Permalink

    It depends on what is awaited. Follow the frame through the queues. An await on a future that has already completed resumes in a microtask. Every microtask runs before the next event. The frame is an event, so it still waits behind the whole loop. await Future.delayed(Duration.zero) resumes from a timer, and a timer is an event. Now the loop goes to the back of the event queue, and a frame that was already waiting gets its turn first. So the chunked version keeps the screen drawing. Each chunk still runs on the UI isolate, though, and a chunk that is too long drops frames just the same.

  5. Permalink

    That worked, after one stumble. In my handler allRows is a field, and the first try used it straight inside the closure. That captures this and drags the whole State along into the isolate, the same trap as my Loader. Copying it into a local before Isolate.run fixed it, and now the spinner shows and the list scrolls while the filter runs.

  6. Permalink

    The synchronous start is younger than it looks. Early Dart did it the other way round, and an async function handed back its Future first and started its body only later. Dart 2 changed that so the body runs synchronously up to the first await (listed as a breaking change, and fairly, since some code had leaned on the body starting later without anyone writing it down, and the new rule matched what JavaScript async functions had done from their first day). Under either rule n.vos's loop runs on the one isolate, so the freeze would have been there all along.

  7. Permalink

    Isolate.run is the right default here, with one narrowing. Whatever the closure captures is sent to the new isolate, so the forty thousand rows travel over as a copy on every query. The result comes back through Isolate.exit, which the docs say sends it without copying, so only the input side pays. That cost grows with the list, and it's worth timing a search once with the real data before calling the freeze solved.

Other sides of the same question

  1. asyncio.TaskGroup when one fetch fails

    Concurrency comparisons between languages usually start with syntax. I'd start with what the calculation needs when something…

  2. A println inside the swap! function

    The docs for swap! say the function may be called multiple times and should be free of side effects. Code in the wild still l…

    Replies 1clojure
  3. Isolate.run took my whole Loader with it

    Moved a big JSON decode off the main isolate with Isolate.run and got an error about an unsendable object instead of a speedu…

    Replies 2dart