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

Introduce a new, simpler way to open long-living isolates #53877

Closed
Levi-Lesches opened this issue Oct 26, 2023 · 5 comments
Closed

Introduce a new, simpler way to open long-living isolates #53877

Levi-Lesches opened this issue Oct 26, 2023 · 5 comments
Labels
area-core-library SDK core library issues (core, async, ...); use area-vm or area-web for platform specific libraries. library-isolate type-enhancement A request for a change that isn't a bug

Comments

@Levi-Lesches
Copy link

Levi-Lesches commented Oct 26, 2023

Consider this example of long-living isolates. Right now the standard practice is to:

  • make a ReceivePort for the main isolate
  • send the SendPort to the worker isolate
  • have the worker isolate make a ReceivePort for itself
  • send the worker's SendPort back to the main isolate
  • then start listening for useful data

This effectively allows for 2-way communication, but it gets pretty messy and has a lot of boilerplate for something that should be simple. In particular, it also clogs up the "listen for responses" part of the code, which now has to separate real responses from the initial "here's my SendPort" message.

Or, here's an even simpler API:

// S is what you send, R is what you receive. 
// Note that I intentionally flipped R and S, see below examples
abstract class IsolateManager<R, S> {
  FutureOr<void> init();
  S handleRequest(R request);
}

class Isolate {
  static Stream<S> spawn<S, R>(IsolateManager<S, R> manager) { }
}

Now opening an isolate and having 2-way communications is easy!

sealed class IsolateRequest {}
class UpdateProfile extends IsolateRequest { }
class DownloadData extends IsolateRequest { }

sealed class IsolateResponse { }
class DownloadFailed extends IsolateResponse { }
class UserNotFound extends IsolateResponse { }

/// Notice how the flipped type arguments above help this look nicer
/// Technically, this isolate is sending [IsolateResponse] and receiving [IsolateRequest]
/// But it's cleaner to think of it as "handling" [IsolateRequests] with [IsolateResponse]
class UserIsolate extends IsolateManager<IsolateRequest, IsolateResponse> {
  @override
  Future<void> init() async {
    print("Authenticating...");
  }

  @override
  IsolateResponse handleRequest(IsolateRequest request) {
    switch(request) {
      case UpdateProfile():
        print("Updating profile...");
        return UserNotFound();
      case DownloadData(): 
        print("Downloading data...");
        return DownloadFailed();
    }
  }
}

void main() => Isolate.spawn(UserIsolate())..listen((IsolateResponse response) {
  switch (response) {
    case UserNotFound(): print("User could not be found");
    case DownloadFailed(): print("Download failed");
  }
);

If typed send and receive ports are implemented (#3432), then this could also be done like:

// Issue dart-lang/sdk#53962 effectively proposes:
abstract class SendPort<T> {
  void send(T obj);
}
abstract class ReceivePort<T> implements Stream<T>{
  SendPort<T> get sendPort;
}

// This proposal proposes:
abstract class IsolateManager<R, S> {
  late final SendPort<S> sender;
  late final ReceivePort<R> receiver;

  void run();
}

class Isolate {
  static ReceivePort<S> spawn<S, R>(IsolateManager<S, R> manager) { }
}

which would be implemented as:

class UserIsolate extends IsolateManager<IsolateRequest, IsolateResponse> {
  @override
  void run() => receiver.listen(onMessage);

  void onMessage(IsolateRequest request) {
    switch(request) {
      case UpdateProfile():
        print("Updating profile...");
        sender.send(UserNotFound());
      case DownloadData(): 
        print("Downloading data...");
        sender.send(DownloadFailed());
    }
  }
}

void main() => Isolate.spawn(UserIsolate());

This would have the advantage of both sides choosing when to send messages, instead of the above request-response format. An idea from @abitofevrything is to make IsolateManager implement Sink<S> and Stream<R> directly.

@lrhn
Copy link
Member

lrhn commented Oct 26, 2023

Check package:isolate and its IsolateRunner.

Streams are less trivial, not because they're much harder, there are just more options, more choices, when it comes to communicating back and forth.
One size might not fit all.

@Levi-Lesches
Copy link
Author

Makes sense there would be a package:isolate, just like a package:html and package:async. Unfortunately, that package has been discontinued 2 years ago.

But moreover, that class is equivalent to just saying Isolate.spawn(_functionThatAcceptsASendPort, SendPort()). In other words, there is no way to receive messages back from the isolate. My proposed classes would basically handle the process of:

  • Create a ReceivePort r1 (or accept an existing one) to receive messages from the worker isolate
  • Send r1.sendPort to the worker isolate
  • Have the worker isolate create a ReceivePort r2
  • Send r2.sendPort back to the main isolate
    which should allow two-way communications without needing a setup like this.

@lrhn
Copy link
Member

lrhn commented Oct 26, 2023

Unfortunately, that package has been discontinued 2 years ago

It still works.

It creates a "Runner", which means you can send any FutureOr<R> Function() to it, and get the result of calling it back. It's like Isolate.run, except that it keeps the isolate alive afterwards (which also means it doesn't get the cheap non-serializing of the return value - can't have both). It handles all the ports for you.

@lrhn lrhn transferred this issue from dart-lang/language Oct 26, 2023
@lrhn lrhn added area-core-library SDK core library issues (core, async, ...); use area-vm or area-web for platform specific libraries. library-isolate type-enhancement A request for a change that isn't a bug labels Oct 26, 2023
@Levi-Lesches
Copy link
Author

Levi-Lesches commented Oct 27, 2023

I see. But it hinges on the request --> response mechanism (which, admittedly, my first proposal does as well), but I'm more interested in the two isolates acting asynchronously. For example, my use case is a main isolate spawning a bunch of worker isolates to perform heavy IO. The worker isolates do their work on their own schedule and send their results back to the main isolate when ready. However the main isolate can request a worker to change its parameters, so I need 2-way communication. The request/response scheme wouldn't work because the workers are always working and sending responses over the lifetime of the application.

I ended up making my own code that does this, built on type-safe ports. I can publish it as a package but I'll include it here as well if you'd like to consider adding it/a variation to the dart:isolate library. The code below is working around the existing types and semantics of Isolate.spawn. If those were changes such that a ReceivePort were automatically created for an isolate and its corresponding SendPort were returned from Isolate.spawn, that would solve the entire problem.

EDIT I published package:typed_isolate with all the code to make the following example possible.

/// Sends integer n to the child isolate, receives a string back
class NumberSender extends IsolateParent<int, String> {
  @override
  Future<void> run() async {
    print("Opening parent...");
    print("Sending: 1");
    sendPort.send(1);
    await Future<void>.delayed(const Duration(seconds: 1));

    print("Sending: 2");
    sendPort.send(2);
    await Future<void>.delayed(const Duration(seconds: 1));

    print("Sending: 3");
    sendPort.send(3);
    await Future<void>.delayed(const Duration(seconds: 1));

    close();
  }

  @override
  void onData(String str) => print("Got: $str");
}

/// Receives integer n, sends "[n]" back
class NumberConverter extends IsolateChild<String, int> {
  @override
  void run() => print("Opening child...");

  @override
  void onData(int data) => send("[$data]");
}

void main() async {
  final parent = NumberSender();
  final child = NumberConverter();
  await parent.spawn(child);
  parent.run();
}

Output:

Opening child...
Opening parent...
Sending: 1
Got: [1]
Sending: 2
Got: [2]
Sending: 3
Got: [3]
Closed

@Levi-Lesches
Copy link
Author

I've published package:typed_isolate with the code above, hopefully it catches on. I'll close this for now but feel free to re-open it if you're looking to add this functionality to somewhere like dart:isolate or package:isolate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-core-library SDK core library issues (core, async, ...); use area-vm or area-web for platform specific libraries. library-isolate type-enhancement A request for a change that isn't a bug
Projects
None yet
Development

No branches or pull requests

2 participants