Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revise shared memory multithreading proposal #4095

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

mraleph
Copy link
Member

@mraleph mraleph commented Sep 19, 2024

This revises shared memory multithreading proposal based on discussions we had internally and current implementation direction.

The biggest change is complete removal of Shareable type marker - instead we accept that most likely destination for us would be shared anything multithreading.

Another change is that proposal now describes in more details the intermediate shared native multithreading: which does not introduce any fundamentally new capabilities (only native memory can truly be shared), but at the same time addresses a lot of ergonomics issues around interoperability and concurrency model mismatch between Dart and native.

fyi @a-siva @johnmccutchan

> shareable, but in reality allowing to share these type does not actually
> introduce any fundamentally new capabilities. A `TypedData` instance can
> already be backed by native memory and as such shared between two
> isolates.
Copy link
Member

Choose a reason for hiding this comment

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

But now it can't not be shared, so one should expect every ByteBuffer to be potentially shared and accessed concurrently? (Probably not a problem, if we don't promise too much atomicity.)

> `SendPort` is an internal implementation or not. Similar tweak should probably
> be applied to the specification of `@pragma('vm:deeply-immutable')`
> allowing classes containing `SendPort` fields to be marked `deeply-immutable`
> at the cost of introducing additional runtime checks.
Copy link
Member

Choose a reason for hiding this comment

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

(At which point are those runtime checks made? When the object is created, or when the object is shared?)

///
/// If [task] is not [Shareable] then [ArgumentError] is thrown.
static Future<S> runShared<S extends Shared>(S Function() task);
static Future<S> runShared<S>(S Function() task);
Copy link
Member

Choose a reason for hiding this comment

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

(Add external, otherwise a static function can't have no body. Or add {...} to symbolize a body.)

The text above says "the shared isolate".
Does the shared isolate have an identity at all? Can there only be one? (I'd expect two concurrent native executions that both want to call into Dart without delay to run Dart concurrently. If we say that's OK in a single shared isolate, that's fine. If we say that they're running in a shared isolate, each their own, then that's also fine. We just need to determine whether it makes a difference or not.)

@@ -842,9 +594,7 @@ class Isolate {
/// Shared isolate contains a copy of the
Copy link
Member

Choose a reason for hiding this comment

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

Here "attempt to access isolated state" means "Accessing any non-shared non-const global/static variable", correct?

Which basically means running in an isolate variant where all non-shared global/static variable getters and setters throw.

You can still access a global getter, or method, but if it's backed by a non-shared variable, then it'll eventually throw anyway. (Maybe even doable using the memory controller, if mapping that isolate's global state to unmapped memory or something, so there is no extra check overhead for the same function in non-shared isolates.)

And a shared variable's getter/setter will access the single shared storage location as normal.

Is this correct?

(If so, still sounds very restrictive. Global state is everywhere. We may need a way to allow explicitly unshared variables, that can be accessed from the shared isolate, and we promise it doesn't matter if they are initialized or not. (But ... it's probably still not OK to use them concurrently, so more likely it needs multiple "shared isolates", each with a little bit of uninitialized storage in case it wants to use the variable. It's the unmarked variables that we don't know if it's safe to access, so those cannot be accessed.)

of `Shareable` and `m` must be `shared` method.
- If `C(...)` is a constructor invocation then `C` must be a shareable class.
- If `o.m(...)` is an instance method invocation, then `m` must be `shared` method.
- If `C(...)` is a constructor invocation then `C` must be a class with a `shared` constructor.
Copy link
Member

Choose a reason for hiding this comment

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

(This is a hypothetical that you're shooting down, right? Consider not going into as many details about it, it gets confusing to read details about something that isn't part of the proposal. Dangerous if read out of context.)

@@ -842,9 +594,7 @@ class Isolate {
/// Shared isolate contains a copy of the
Copy link
Member

Choose a reason for hiding this comment

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

Can a shared function access local variables? Are they shared?
That is, if I have:

shared void Function() inc = (){};
shared int Function() val = () => 0;
void initInc() {
  int counter = 0;
  inc = () { counter++; };
  val = () => counter;
}

then will it compile? Will it run? Will concurrent accesses update the same variable?

(If you can't share closures containing local variables, then some things become much harder. Most things.
Allowing final local variables might ease that, but you do want shared mutable state, so it's just a question of whether the compiler boxes the variable or the user has to.)

@@ -1096,43 +793,6 @@ All built-in asynchronous primitives will make the following API guarantee: **a
callback passed to `Future` or `Stream` APIs will be invoked using executor
which was running the code which registered the callback.**
Copy link
Member

Choose a reason for hiding this comment

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

Can you share futures or streams at all? (They're not trivally shareable, so likely not in the initial proposal.)

If you can, then we'll have solved the problem of Zone.current being non-shared state, and every operation on Future accessing Zone.current. That's not going to be trivial.

(Maybe we do want some non-shared global variables that are made accessible, so we can initialize them if necesssary..)

@@ -1195,7 +855,7 @@ to control threads.
```dart
// dart:concurrent
abstract class Thread implements Shareable {
abstract class Thread {
Copy link
Member

Choose a reason for hiding this comment

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

The below:

/// ... which uses the
/// spawned thread as an executor for all callbacks: this
/// means the thread will remain alive as long as there is
/// a callback referencing it.

I'm not sure what that means. Are the callbacks Zone-functions, like scheduleMicrotask, or are they the callback arguments to those zone functions?

Are executors part of zones, or separator?
(If zones are shareable, and you can share the value with a function run with a different Executor.schedule, then it seems the executor is not bound by the zone or vice-versa.)

@@ -1272,7 +932,7 @@ throw.
```dart
Copy link
Member

Choose a reason for hiding this comment

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

Why can't an AtomicRef contains not-real-ref-types? Seems like forcing boxing should be sufficient to make it work. Also, if you can store the value in a singleton unmodifiable list, and store the list in the AtomicRef, it feels like a distinctions without a real difference.
At least if we have Java-like memory model semantics for final fields, then:

class LockBox<T> {
  final T value;
  LockBox(this.value);
}
shared AtomicRef<LockBox<int>> _box = LockBox(0);
int get atomicInt => _box.load().value;
set atomicInt(int value) { _box.store(LockBox(value)); }

should give you a guaranteed store of an integer.

> [Dynamic sharedness checks as an escape hatch](https://github.com/WebAssembly/shared-everything-threads/issues/37)
> issue).
> would need to resolve this conundrum by employing some sort of thread local
> wrapper, which can be implemented on top of TLS storage and `WeakMap`.
Copy link
Member

Choose a reason for hiding this comment

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

If we can have a single object representing each Thread, then a ThreadLocal is just an Expando<Thread>.
That does mean not cloning Thread when communicating with other isolates.

- introduces the concept a concept of [_shared isolate_](#shared-isolates),
an isolate which only has access to a state shared between all isolates of the
group. This concept allows to bridge interoperability gap with native code.
_Shared isolate_ becomes an answer to the previously unresolved question
Copy link

Choose a reason for hiding this comment

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

I think the original answer to this question is that this arbitrary thread should "enter isolate" if it wants to run dart code on this thread.
I think "shared isolate" is an answer to the question of "what if native code wants to run dart code without access to any isolate state, with access to only isolate group state".

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