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

async support #279

Open
madsmtm opened this issue Oct 27, 2022 · 12 comments
Open

async support #279

madsmtm opened this issue Oct 27, 2022 · 12 comments
Labels
A-objc2 Affects the `objc2`, `objc2-exception-helper` and/or `objc2-encode` crates enhancement New feature or request

Comments

@madsmtm
Copy link
Owner

madsmtm commented Oct 27, 2022

Objective-C uses "completion handlers" for concurrency.

Swift automatically translates these to async functions, we could consider doing something similar after #264.

Also would be a good step forwards for async in winit.

See also blockr's async support, and cidre's async support.

See also this blog post on how Zed uses async and GCD.

@madsmtm madsmtm added enhancement New feature or request A-objc2 Affects the `objc2`, `objc2-exception-helper` and/or `objc2-encode` crates labels Oct 27, 2022
@madsmtm
Copy link
Owner Author

madsmtm commented Nov 3, 2022

Would probably also be good to look at what is actually required on the runtime-side of this? Do we need to do stuff with NSRunLoop?

@madsmtm
Copy link
Owner Author

madsmtm commented Nov 3, 2022

Instead of async fn method(), we could use fn method() -> CompletionHandler<()>, and then impl IntoFuture for CompletionHandler

@notgull
Copy link
Contributor

notgull commented Dec 17, 2022

Would probably also be good to look at what is actually required on the runtime-side of this? Do we need to do stuff with NSRunLoop?

There is actually a way to make NSRunLoop fully async, since:

  1. NSRunLoop is a wrapper around CFRunLoop.
  2. CFRunLoop is a wrapper around Grand Central Dispatch.
  3. Grand Central Dispatch uses a Mach port to coordinate itself.
  4. Mach ports can be registered in kqueue.

So if we can register the global GCD Mach port into a kqueue, we can then put that into, say, a async-io::Async and then use the readable() function to tell when it's available. The main downside of this approach is that steps 2 and 3 is that gaining access to the GCD Mach port requires access to unstable OS APIs (namely, _dispatch_get_main_queue_port_4CF()), but it's not like the Rust async stack isn't already built on top of unstable OS APIs anyways.

@madsmtm
Copy link
Owner Author

madsmtm commented Dec 21, 2022

Interesting to know, thanks!

I don't think I would have that much against using unstable APIs like _dispatch_get_main_queue_port_4CF, but would ideally really like to avoid it (since it can have consequences for submitting apps to the App Store, as it is not public).

@madsmtm
Copy link
Owner Author

madsmtm commented May 14, 2023

Related Kotlin issue about their unfinished async support: https://youtrack.jetbrains.com/issue/KT-47610

@madsmtm
Copy link
Owner Author

madsmtm commented May 26, 2023

A necessary prerequisite is #168, since we need some way to tell that the completion handlers will only be run once.

@madsmtm
Copy link
Owner Author

madsmtm commented May 26, 2023

To take the example from the linked Swift proposal:

- (void)fetchShareParticipantWithUserRecordID:(CKRecordID *)userRecordID 
    completionHandler:(void (^)(CKShareParticipant * _Nullable, NSError * _Nullable))completionHandler;

And what that would (ideally) be in Rust, pre-async translation:

fn method_name_bikeshed(
    &self,
    userRecordID: &CKRecordID, 
    completionHandler: BlockOnceSendSync<(Option<&CKShareParticipant>, Option<&NSError>), ()>,
);

It would probably be prudent to first convert the Objective-C style error into the usual Rust error:

fn method_name_bikeshed(
    &self,
    userRecordID: &CKRecordID, 
    completionHandler: BlockOnceSendSync<(Result<&CKShareParticipant, &NSError>,), ()>,
);

Note that this has a different ABI, but I might be able to handle such things directly in block2.

Now, we could subsequently convert it to:

fn method_name_bikeshed(
    &self,
    userRecordID: &CKRecordID,
) -> BikeshedHelper<Result<&CKShareParticipant, &NSError>, impl FnOnce(...)>;

struct BikeshedHelper<T, F: FnOnce(BlockOnceSendSync<(T,), ()>)> { ... }
impl BikeshedHelper<T, F> {
    pub fn run_with_block(self, block: BlockOnceSendSync<(T,), ()>) { ... }
}
impl IntoFuture for BikeshedHelper<T, F> {
    type Output = T; // TODO: Convert &MyClass to Id<MyClass> here
    type Future = BlockFuture<T>;
}

struct BlockFuture<T> { ... }

Immediately we see a few issues:

  1. Ideally we'd be able to choose to do the call manually using blocks, instead of the async functionality. This is attempted made possible by the run_with_block method, but that does complicate the signature in a way I'm not sure will be possible to support easily. Alternatively we will have to emit both the async and the non-asynv version, which also sounds bad.
  2. We need some way to generically convert reference arguments given in the block, to retained arguments that are safe to return to the surrounding context.
  3. There is a performance cost to this, since we have to do said retain.

@madsmtm
Copy link
Owner Author

madsmtm commented May 26, 2023

Note: I think it makes sense to make separate adapters depending on whether the block can receive an error or not (one parameter is Option<&NSError> (though not necessarily the last?)), since the Swift design document seems to treat them differently.

And we also need to distinguish between blocks with one parameter vs. multiple parameters (the former should be a simple output, while the latter should return a tuple).

@madsmtm
Copy link
Owner Author

madsmtm commented Dec 3, 2023

Linking clang's _Nullable_result attribute, which is useful for determining whether the async function should return an Option or not.

@madsmtm
Copy link
Owner Author

madsmtm commented Feb 28, 2024

An alternative solution for integrating with the rest of the async ecosystem might be to implement an alternative mio::Source (related: tokio-rs/mio#1500)?

Though it doesn't seem like async-io provides a similar extension mechanism, likely for performance reasons?

(I can tell I'm wayy too inexperienced with the async ecosystem to even begin knowing what's up and what's down).

@notgull
Copy link
Contributor

notgull commented Feb 28, 2024

async-io exposes mechanisms for accessing other kqueue filters, like processes exiting and signals firing. Granted, so far what we expose is a pretty narrow subset of what's possible with kqueue.

However it wouldn't be too difficult to expose, say, EVFILT_MACHPORT, in async-io. It's just a few more types and another item in an enum.

The issue is that we hit the limits of what can be registered into kqueue pretty quickly. As above some mechanisms can be translated into Mach ports, but not all, and not in a stable way.

The current available async runtimes are built around networking, which means kqueue for macOS. An interesting idea I haven't had the time to implement yet is an async runtime that uses the GUI primitives as a base. So it would use NSRunLoop on macOS, MsgWaitForMultipleObjects on Windows, ALooper on Android, et cetera. But as the current GUI systems seems resistant to the idea of adopting async I haven't pursued it yet.

@madsmtm
Copy link
Owner Author

madsmtm commented Oct 28, 2024

Spent some time on this today, from my understanding there's effectively two things that need to be done:

  1. Allow converting completion handlers and delegate callbacks to futures.
  2. Integrating the async ecosystem with run loops, as talked about above.

I still only have a vague notion on how to progress on the second item, but I think the first one is tractable.


Effectively, we need a wrapper type that implements Future and can store the Waker that executors pass, and which will, when the callback is invoked, store (and retain) the callback parameters and .wake() the waker so that the future can take them out.

Basic usage of such a wrapper, to illustrate the idea:

let continuation = Continuation::new();
let completion_handler = block2::RcBlock::new(|x, y, z| {
    continuation.resume((x, y, z));
});
my_obj.my_method(my_arg, &completion_handler);
continuation.await

Swift calls this a "continuation", and @drewcrawford has recently extracted the implementation from blockr into the crate continue to make it possible in Rust. Also discussed on the Smol Matrix, they recommend the oneshot crate.

I suspect I'll still want a helper wrapper in block2 though:

  • For convenience.
  • For easier retain and NSError handling.
  • To optimize the Arc into the block itself.
  • To have a non-thread safe version (since main thread only code is common in Apple GUI code).

But for the delegate callback case I'll probably document continue as the recommended approach.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-objc2 Affects the `objc2`, `objc2-exception-helper` and/or `objc2-encode` crates enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants