Skip to content

Conversation

mraleph
Copy link
Member

@mraleph mraleph commented Sep 12, 2025

This extracts relevant parts of the bigger proposal into a separate proposal.

The following things have changed:

  • We now include formal memory model: our formalization pretty much follows JavaScripts with some minor tweaks.
  • All atomic operations are consolidates in a single AtomicInt class - which can be constructed as a view on Pointers or TypedData objects. This is a departure from the previous "zoo of extension methods" design. We should discuss if it is actually better.
  • I introduced ScopedThreadLocal class which should address some known problematic usages of static state in core libraries.
  • There are few design points (marked with TODO in the text) which I don't have concrete answers to. We should discuss these.

@lrhn @mkustermann @aam can you take a look?

@aam could you give me the current state of core-library APIs (which already work from IG bound code, which you are fixing and which we will not support). I want to align on what we think is MVP here and include this into the proposal.

@mraleph
Copy link
Member Author

mraleph commented Sep 15, 2025

cc @leafpetersen

```dart
@pragma('vm:deeply-immutable')
final class ScopedThreadLocal<T> {
/// Creates scoped thread local value with the given [initializer] function.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, but outside of isolate-group-bound callbacks, in the context of standard isolates, does ScopedThreadLocal instance behave like a static field? Isolates in the end can migrate from one thread to another.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that isolates can't move between threads in synchronous calls and this API is fully synchronous.

Copy link

@aam aam Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will there be something that would prevent this infrastructure from being used in asynchronous context?

 static Future<String> iterableToShortString(...) {
  return toStringVisiting.use((toStringVisitingValue) async {
    await Future.delayed(Duration(seconds: 10));
    ...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use it. But it is not going to do anything useful because the value of the scoped thread local will reset once the synchronous execution completes.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So toStringVisiting can become unbound after await Future.delayed(Duration(seconds: 10)) (in unlikely event) of isolate switching to a different thread? Realistically we are keeping isolate/(vm)thread affinity, so switching should not happen.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not related to moving between threads at all. ScopedThreadLocal are valid within the scope of synchronous execution (hence the name). So if you do stl.use(f) or stl.with(value, f) once synchronous execution of this expression completes stl (e.g. use or with completes) is unbound.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was curious about the case when f is asynchronous. Would that result in ScopedThreadLocal values bound only for initial synchronous part of f execution? In the example above - before await Future.delayed(Duration(seconds: 10));?

Document constructors memory model
@mraleph
Copy link
Member Author

mraleph commented Sep 17, 2025

cc @alexmarkov for memory model feedback

```

Note that `runSync` can only enter an `Isolate` when it is not used by
another thread.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How will the caller be able to know that?

The other isolate must be back at the event loop, and probably not just the microtask loop.
I guess the isolate can set a flag when its event loop is empty and it's waiting for new events.

You could potentially have a way to check that flag, but that won't guarantee that the isolate didn't get and started processing an event immediately after you checked.

you invoke this function `f` will be executed in the context of the given
isolate and then if it produced a `Future` we exit the isolate and block
the current thread, while allowing the event loop for that isolate to run
normally until returned future produces result.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That can be implemented using a synchronous/blocking receive port.

It's possible that there is a use for this, but it's rarely a good idea to block your own isolate.

external void withInitialized<R>(R Function(T) f);

/// Returns the value specified by the closest enclosing invocation of [with] or
/// throws [NotBoundError] if this [ScopedThreadLocal] is not bound to a value.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can just use StateError.

/// Throws [NotBoundError] if this [ScopedThreadLocal] does not have an initializer.
external void withInitialized<R>(R Function(T) f);

/// Returns the value specified by the closest enclosing invocation of [with] or
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expect I'll use the pattern:

String toString() {
  if (!_toStringVisiting.isBound) {
    return _toStringVisiting.runWith([], (_) => toString());
  }
  var visiting = _toStringVisiting.value;
  //...
}

in every function that depends on the value.

It can't all be abstracted into a helper method like it's it's today, the initialization must be on the main trunk of the execution tree.

Copy link

@aam aam Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't you use withInitialized instead?

Given

@pragma('vm:shared')
final toStringVisiting = ScopedThreadLocal<List<Object>>(() => <Object>[]);

you can write

String toString() {
  return toStringVisiting.withInitialized((toStringVisitingValue) {...})
}

which will only invoke initializer if (isolate-group) toStringVisiting instance of ScopedThreadLocal is not bound on this thread yet.

Copy link
Member

@lrhn lrhn Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to double the number of stack frames and generate extra closures if I can avoid it.
I don't expect the call to be a tail-call.


/// Execute [f] initializing this [ScopedThreadLocal] using default initializer if needed.
/// Throws [NotBoundError] if this [ScopedThreadLocal] does not have an initializer.
external void withInitialized<R>(R Function(T) f);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think with may be a reserved word. I think runWith would be the traditional name.

}
```

### Synchronization Primitives
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are use-cases?

Communicating between isolates?

SameLoc_{ww}(w, w') &\triangleq Loc_w(w) = Loc_w(w') \\
Write(e) & \triangleq Loc_w(e) \neq \emptyset \\
Unordered(e) & \triangleq \neg(\mathtt{Atomic(e)} \vee \mathtt{Init}(e))
\end{align*}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't review this, because I can't read how it looks. 😔
OK, I copied it into a markdown renderer.

Now I don't know what "≜" means. Is this defining functions or relations?
Says "functions and predicates", so probably functions (some to Bool).

The point of markdown is that the source is readable. LaTeX math isn't, so it feels like it's missing the point.

I was going to suggest using Unicode instead, but &vee; isn't much better then \vee.

Consider just using ASCII and words:

  • LocR(r) := [Addr(r), Addr(r), + |DataR(r)|]
  • LocW(r) := [Addr(r), Addr(r), + |DataW(r)|]
  • Loc(e) := Union(LocR(e), LocW(e))
  • Overlap(e) := Intersection(LocR(e), LocW(e))
  • SameLocWR(w, r) := LocW(w) = LocR(r)
  • SameLocWW(w, w’) := LocW(w) = LocW(w’)
  • Write(e) := LocW(e) is not empty
  • Unordered(e) := not Atomic(e) and not Init(e)

When object is constructed all field initializers (i.e. those provided in the
body of the class or in the initializer list) result in $\mathtt{Init}$
write events. The same applies to initialization of individual elements of
`TypedData` objects with `0`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And platform lists? (Probably want all object creation to act like initialization, also for collection literals.)


#### Isolates

Every `SendPort.send` generates a numbered event $\mathtt{Send}(p, i)$.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the number arbitrary or monotonically increasing?
(The "numbered" makes it sound like the numbers are 1, 2, ..., but it doesn't actually say that.)


$$
\forall p\,i . \mathtt{Send}(p, i) \leq_\mathtt{asw}\mathtt{Recv}(p, i)
$$
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also want:

p, i, j, Send(p, i) ≤asw Send(p, j) ↠ Recv(p, i) ≤asw Recv(p, j)

? That is, sends on the same port are not reordered, but that's proably only true if the sends are in the same isolate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants