Open yjbanov opened 3 years ago
/cc @sigmundch @rakudrama @lrhn
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.
@Hixie interesting idea! Added it to the list of alternatives, with some pros/cons/unknowns that I could think of.
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();
});
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!
Background
Currently the only way to push an immediate event into the event queue in the standard libraries is via a zero-length timer:
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 callTimer.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'ssetTimeout
. 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
dart:async
for scheduling immediate events (e.g.scheduleImmediateEvent(void Function() callback)
that's a sibling toscheduleMicrotask
). Flutter would use it instead ofTimer.run
.flushMicrotasks
function. The function would run synchronously and would guarantee that there are no pending microtasks after it is done. The only remaining microtask would be the one callingflushMicrotasks
.scheduleImmediateEvent
, but also fixes the issue in the engine where microtasks need to be flushed within the animation frame.flushMicrotasks
while microtasks are already being flushed? Perhaps, the function should not be reentrant, and throw if accessed concurrently.