dart-lang / sdk

The Dart SDK, including the VM, JS and Wasm compilers, analysis, core libraries, and more.
https://dart.dev
BSD 3-Clause "New" or "Revised" License
10.28k stars 1.59k forks source link

Provide mechanism for scheduling back-to-back events #44450

Open yjbanov opened 3 years ago

yjbanov commented 3 years ago

Background

Currently the only way to push an immediate event into the event queue in the standard libraries is via a zero-length timer:

Timer.run(() { });
// or
Timer(Duration.zero, () { });

This is typically used to make sure all microtasks are flushed prior to starting a new chunk of work (example).

In the VM Timer.run simply pushes an event onto the event queue. Therefore, if you call Timer.run() multiple times in a row within the same event, you schedule that many back to back events. This is consistent with the documentation about how the Dart event loop works:

Problem

In dart2js and DDC Timer.run is implemented via browser's setTimeout. However, setTimeout does not have the same event loop semantics. The browser allows other events to be injected between timers even if the timers are scheduled to run immediately.

This discrepancy between the VM and dart2js/DDC has caused an issue in Flutter app start-up, where on the web timers and animation frames are racing each other: https://github.com/flutter/flutter/issues/69313.

There are likely a number of places in Flutter that are broken but we're not aware of it. Finally, Flutter users need to deal with this issue in code that's meant to be shared between web and mobile.

This is likely a smaller problem for AngularDart applications because AngularDart code is web-only. Most code sharing between AngularDart and VM are small utility libraries that do not have complex lifecycles.

Proposal

Add a new command-line option --use-vm-timer-behavior in dart2js and DDC. Flutter will use this option to make sure Dart code behaves the same across mobile and web. This option is intentionally opt-in to avoid breaking existing AngularDart apps.

Alternatives

yjbanov commented 3 years ago

/cc @sigmundch @rakudrama @lrhn

Hixie commented 3 years ago

Another option that wouldn't solve the Timer.run-semantics-differ-on-VM-and-web issue but would solve the warm-up-frame-getting-interrupted-by-real-frame issue that led to this being filed would be to have a "flushMicrotasks" feature that we could use to flush the microtasks between the two steps of the frame. That would also help with fixing the bug in the web engine whereby we don't flush microtasks between the two steps.

yjbanov commented 3 years ago

@Hixie interesting idea! Added it to the list of alternatives, with some pros/cons/unknowns that I could think of.

lrhn commented 3 years ago

The VM does not guarantee that no events get added to the event queue between two zero-duration timers. Example:

import "dart:async";
main() {
 Timer(Duration(milliseconds:200), () { print(3); });
 Timer.run(() { print(1); });
 var sw = Stopwatch()..start();
 while (sw.elapsedMilliseconds < 400) {}
 Timer.run(() { print(2); });
}

This prints 1, 3, 2 on the VM.

You should never assume that nothing happens between two event-loop events.

The fact that it rarely happens is an accident of implementation.

If we need a way to ensure that one event runs immediately after another, we should consider adding that functionality explicitly, not relying on accidental behavior which may not even be guaranteed in all cases.

We have no control over the event loop in the browser. The one thing we might be able to control is the microtask loop (which is actually also using the browser's equivalent functionality when run in a browser, but not for each event - we still have our own queue on top of it, and only fall back to the browser when we need to get back after something throws).

So, what if we had a two-level microtask queue:

Then adding two consecutive top-tasks ensures that no other top task can get between them, and each has its local queue run to completeness before moving on to the next one.

It feels a little too specific to have precisely two levels. A more general approach would be to be able to wrap any callback in such a way that all its microtasks are executed on a separate queue, and the task itself is only considered done when all its microtasks are done. I'm fairly sure I can write that, the only danger is that microtasks which throw will always be asynchronously uncaught, they can't be propagated back out because there is no way to ensure that we get back to our own microtask queue first afterwards.

Something like;

import "dart:async";
import "dart:collection" show Queue;

/// Runs [task], including all its own scheduled microtasks.
///
/// Any microtask scheduled by [task] will be run as part of the
/// invocation of this method as well.
///
/// If any of the microtasks throw, the error is reported as an uncaught
/// asynchronous error in the zone where [task] runs.
/// If [onError] is provided, throwing tasks will be reported by a call
/// to [onError] instead. Other uncaught async errors are reported as normal.
/// The [onError] function *must not throw*.
///
/// The [task] function should not run any code outside of the zone
/// that it gets invoked in.
void localMicrotasks(void Function() task,
    {void Function(Object, StackTrace)? onError}) {
  var queue = Queue<void Function()>()..add(task);
  bool done = false;

  runZoned(() {
    do {
      var task = queue.removeFirst();
      try {
        task();
      } catch (e, s) {
        if (onError != null) {
          // For extra defense, wrap this call in a try/catch too.
          onError(e, s);
        } else {
          Zone.current.handleUncaughtError(e, s);
        }
      }
    } while (queue.isNotEmpty);
    done = true;
  }, zoneSpecification: ZoneSpecification(
      scheduleMicrotask: (Zone s, ZoneDelegate p, Zone z, void Function() f) {
    if (!done) {
      queue.add(z.bindCallback(f));
    } else {
      p.scheduleMicrotask(z, f);
    }
  }));
}

// Example
main() {
  scheduleMicrotask(() {
    sched("x1", 5);
  });
  scheduleMicrotask(() {
    sched("x2", 5);
  });
  scheduleMicrotask(() {
    localMicrotasks(() {
      sched("x3", 5);
    });
  });
}

sched(String id, int depth) {
  print("$id:$depth");
  if (depth > 0)
    scheduleMicrotask(() {
      sched(id, depth - 1);
    });
}

You can then schedule the two tasks end-to-end as:

Timer.run(() {
  localMicrotasks(task1);
  task2();
});
Hixie commented 3 years ago

The VM does not guarantee that no events get added to the event queue between two zero-duration timers. ... If we need a way to ensure that one event runs immediately after another, we should consider adding that functionality explicitly, not relying on accidental behavior which may not even be guaranteed in all cases.

Yikes! Good to know. Yes, it sounds like we definitely need such a mechanism, since we're assuming we have one and using it in core parts of Flutter!