-
Notifications
You must be signed in to change notification settings - Fork 47
[RFC] Uniform task spawning (structural-typed version) #727
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
base: v0.2.0
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| //! This module contains helper traits and functions for services that run on the EC. | ||
|
|
||
| /// A trait for a service that can be run on the EC. | ||
| /// Implementations of RunnableService should have an init() function to construct the service that | ||
| /// returns a Runner, which the user is expected to spawn a task for. | ||
| pub trait RunnableService<'hw> { | ||
| /// A token type used to restrict users from spawning more than one service runner. Services will generally | ||
| /// define this as a zero-sized type and only provide a constructor for it that is private to the service module, | ||
| /// which prevents users from constructing their own tokens and spawning multiple runners. | ||
| /// Most services should consider using the `impl_runner_creation_token!` macro to do this automatically. | ||
| type RunnerCreationToken; | ||
|
|
||
| /// Run the service event loop. This future never completes. | ||
| fn run( | ||
| &'hw self, | ||
| _creation_token: Self::RunnerCreationToken, | ||
| ) -> impl core::future::Future<Output = crate::Never> + 'hw; | ||
| } | ||
|
|
||
| /// A handle that must be passed to a spawned task and `.run().await`'d to drive the service. | ||
| /// Dropping this without calling `runner.run().await` means the service will not process events | ||
| pub struct ServiceRunner<'hw, T: RunnableService<'hw>> { | ||
| service: &'hw T, | ||
| creation_token: T::RunnerCreationToken, // This token is used to ensure that only the service can create a runner for itself. It's probably a zero-sized type. | ||
| } | ||
williampMSFT marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| impl<'hw, T: RunnableService<'hw>> ServiceRunner<'hw, T> { | ||
| /// Runs the service event loop. This future never completes. | ||
| pub async fn run(self) -> crate::Never { | ||
| self.service.run(self.creation_token).await | ||
| } | ||
|
|
||
| /// Constructs a new service runner. This is something the service will do in its init function; users of | ||
| /// the service should not need to call this directly. | ||
| pub fn new(service: &'hw T, token: T::RunnerCreationToken) -> Self { | ||
| Self { | ||
| service, | ||
| creation_token: token, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Generates a default implementation of a runner creation token for a service. This token is used to ensure that | ||
| /// only the service can create a runner for itself, and therefore it can control the number of tasks that a user is | ||
| /// allowed to spawn to run the service (e.g. if the service is not designed to be run by multiple tasks, it can use | ||
| /// this token to prevent that). | ||
| /// | ||
| /// Most services will want to use this macro to generate a simple zero-sized token type - it needs to be a macro invoked | ||
| /// in the service module rather than a generic type in this module because the constructor needs to be private to the | ||
| /// service module to prevent users from constructing their own tokens and spawning multiple runners. | ||
| /// | ||
| /// Arguments: | ||
| /// - token_name: The name of the token type to generate. | ||
| #[macro_export] | ||
| macro_rules! impl_runner_creation_token { | ||
| ($token_name:ident) => { | ||
| /// A token type used to restrict users from spawning more than one service runner. | ||
| pub struct $token_name { | ||
| _private: (), | ||
| } | ||
|
|
||
| impl $token_name { | ||
| fn new() -> Self { | ||
| Self { _private: () } | ||
| } | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| pub use impl_runner_creation_token; | ||
|
|
||
| /// Initializes a service, creates an embassy task to run it, and spawns that task. | ||
| /// | ||
| /// This macro handles the boilerplate of: | ||
| /// 1. Creating a `static` [`OnceLock`](embassy_sync::once_lock::OnceLock) to hold the service | ||
| /// 2. Calling the service's `init()` method | ||
| /// 3. Defining an [`embassy_executor::task`] to run the service | ||
| /// 4. Spawning the task on the provided executor | ||
| /// | ||
| /// Returns a Result<reference-to-service, Error> where Error is the error type of $service_ty::init(). | ||
| /// | ||
| /// Note that for a service to be supported, it must have the following properties: // TODO figure out if this should be a trait. Would require a single associated-type arg rather than letting each service define its own init list though... | ||
| /// 1. Implements the RunnableService trait | ||
| /// 2. Has an init() function with the following properties: | ||
| /// i. Takes as its first argument a &OnceLock<service_ty> | ||
| /// ii. Returns a Result<(reference-to-service, service-runner), Error> where the service-runner | ||
| /// is an instance of RunnableService. | ||
| /// | ||
williampMSFT marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /// Arguments | ||
| /// | ||
| /// - spawner: An [`embassy_executor::Spawner`]. | ||
| /// - service_ty: The service type, wrapped in brackets to allow generic arguments | ||
| /// (e.g. `[my_crate::Service<'static>]`). | ||
| /// - init_args: The arguments to pass to `Service::init()`, excluding the `OnceLock` argument, which is codegenned. | ||
| /// | ||
| /// Example: | ||
| /// | ||
| /// ```ignore | ||
| /// let time_service = embedded_services::spawn_service!( | ||
| /// time_alarm_task, | ||
| /// spawner, | ||
| /// [time_alarm_service::Service<'static>], | ||
| /// dt_clock, tz, ac_expiration, ac_policy, dc_expiration, dc_policy | ||
| /// ).expect("failed to initialize time_alarm service"); | ||
williampMSFT marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /// ``` | ||
| #[macro_export] | ||
| macro_rules! spawn_service { | ||
| ($spawner:expr, [ $($service_ty:tt)* ], $($init_args:expr),* $(,)?) => { | ||
| { | ||
| static SERVICE: embassy_sync::once_lock::OnceLock<$($service_ty)*> = embassy_sync::once_lock::OnceLock::new(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think there's a lot of benefit to a macro like this. It forces the use of
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The theory is that we repeat more-or-less exactly this thing like 10 times in soc-embedded-controller and also in just about every example (excluding integration tests), and I figure if we have to do it that often, then OEMs will also run into this problem and there's probably some value in a 'default setup helper' to make it easier to adopt / harder to screw up. I don't think we'd want to (or be able to, for that matter) make this the only way to instantiate services, but I think it (or something sort-of like it, probably need to nail down the must_spawn() thing a bit) is likely what they'd want in the 95% case? Notably, integration tests fall into that 5% bucket, and I'd expect to continue doing nonstatic allocation in those. On hardware when you're using embassy, though, I think you're already priced into 'static/OnceLock because of how embassy tasks work, right? |
||
| match <$($service_ty)*>::init( | ||
| &SERVICE, | ||
| $($init_args),* | ||
| ) | ||
| .await { | ||
| Ok((service_ref, runner)) => { | ||
| #[embassy_executor::task] | ||
| async fn service_task_fn( | ||
| runner: $crate::service::ServiceRunner<'static, $($service_ty)*>, | ||
| ) { | ||
| runner.run().await; | ||
| } | ||
|
|
||
| $spawner.must_spawn(service_task_fn(runner)); | ||
williampMSFT marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Ok(service_ref) | ||
| }, | ||
| Err(e) => Err(e) | ||
| } | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| pub use spawn_service; | ||
This file was deleted.
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if a trait is the right design for this. If the service is in a mutex then this would require hold a mutex lock. I think the
runcode can end up onServiceRunner, with each service implementing their own code on their own struct.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could definitely convert ServiceRunner into a trait, move the run() method to that, and make it an associated type or something if we wanted to do that. I'd originally started with something along those lines, but ran into some snags writing a macro that could generate an implementation against a type that may or may not be generic so I bailed on that approach. Might be fine to just have everyone implement their own, though?
I think we do need a uniformly-shaped run() method on the runner, though, and I think that means the runner needs an internal reference to the service that it's the runner for, which kind of locks us out of having the mutex be external, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've been doing a bit of research on patterns for this, and stumbled across a few examples of this 'run token' pattern in embassy that I think are instructive - they all do things a little differently, though, and I'm interested in your thoughts on which is better for our use case:
(1) embassy-net is pretty similar to what I'm doing here. It yields a (Stack, Runner) tuple, and all methods on Stack take &self. This seems to me like a good fit for something that is maintaining its internal state on a separate 'thread' that it owns and is expected to be shared between multiple things (time-alarm comes to mind here - it's the way you get wall-clock time).
(2) embassy-usb doesn't have a separate Runner object - once you have the thing set up, you call
run(&mut self) -> !and then you can't call stuff on it ever again. I think this is a nonstarter if we want to continue with this direct async call approach, but @felipebalbi had some interesting thoughts on using ergot for communicating between services rather than direct async calls, and in that world, this might be viable. I took a quick glance at the ergot stuff and it looks like it uses a lot of 'static, though, so I'd want to make sure there's a path that doesn't require 'static for senders/receivers/topics before committing to that; more research required.(3) cyw43 (wifi driver) takes a third approach - they return a (NetDriver, Control, Runner) triple. The Runner is
run(self) -> !, and as far as I can tell, the NetDriver and Control objects both mostly require&mut self. This seems to me like a good fit for a driver for a hardware peripheral that is expected to be only used by a single higher-level abstraction - perhaps the USB-C stuff falls into that bucket?Notably, in the two of these that don't just tie up the entire object forever (embassy-net and cyw43), both use interior mutability to share state between the runner and the control handle, even in cases where the control handle requires &mut references to do stuff to the object.
I think if we go with (1) it buys us a free-ish implementation of ServiceRunner (which I have currently in this PR), but perhaps isn't as good of a fit for driver stuff like (3), which might be closer to what the USB-C service is doing, and we may want to support both - I missed this nuance during my initial stab at this. The 'free' implementation is also only like 5 lines of code, so not a huge deal if we need to replicate something like it in each service. I'm leaning towards something like this:
&mut selfvs&selfto the services - 'shared' services that are expecting to be talked to by a bunch of other components should take&selfand use interior mutability to achieve that, and 'driver' services like the USB-C service should take&mut selfand leave higher-level code to decide if they want to put a mutex around the handle or notI think this enables both (1) and (3), since the decision about mutability isn't really part of the RunnableService trait at all - it's just a matter of what you can do with the associated 'control handle'.
What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pushed an attempt at this to #726
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Different embassy crates having different patterns around this makes me more skeptical of a trait-based approach, especially at a global level. I think there's more room on a per-service basis, i.e. each service defines its own
ServiceRunnertrait. But I thinkServiceRunnerimplementations will be different enough in terms of arguments and return values that there won't be enough commonality for a trait. Application code will want a runner that hasfn run(_) -> !while tests will wantfn run(_). Likewise, test and application runners will require different arguments. I think we should implement our service runners first and then create a trait later if we find there's enough commonality.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tests should also be using runners with
fn run(_) -> !, I think - they just need toselect!()over a call torun()and a closure that runs their test code; when the test closure completes, then theselect!drops the-> !future and returns. We have examples of this approach intad_test.rs.I think
ServiceRunnerimplementations should always have zero arguments (other than self)? The only thing instantiating those should be the service, when it returns frominit(), and instantiation of the runner is out of scope for theServiceRunnertrait (unlike previously, when ServiceRunner was a type provided by this lib). Any arguments that the runner needs should be passed into the service at init time, and the fact that it gets handed off to the returnedServiceRunnerimpl is an implementation detail of the service that the user shouldn't have to care about. Runners taking no additional arguments is the same across all of the above patterns.To be a bit more explicit about goals here, one of the things I'm trying to achieve is to not require the user to have service-specific knowledge of an arbitrary number of 'worker tasks' that each have their own quirks around what parameters they need - I view that as implementation details that we're currently leaking, and that leakage makes it difficult for end users to reason about what the service actually needs be used and how to uptake breaking changes. That's the thing I'm trying to make 'uniform'.