libunifex
libunifex copied to clipboard
`std::execution::ensure_started`
Title. Where is it?
Lack of something like this prohibits me from starting event loops on worker threads before doing a huge amount of work on a main thread and consequently sync_waiting all worker's event loops.
refer to async_scope?
Yes and no. I figured out a hacky way to use async_scope, but I am not happy with it. The use case is as follows: The main event loop needs to do several steps: update simulation state, extract graphics data, spawn a rendering job for the current frame (that shall use the captured data). Moreover, at most N rendering jobs can be running simultaneously, so whenever the main loop "outruns" the rendering jobs and wants to spawn an (N+1)th job, it needs to sync_wait the oldest job.
The solution that I want: Main thread stores the rendering job senders that were force-started using ensure_started in a queue and pop-backs and sync_waits the oldest job if there are N jobs in the queue.
The current hacky solution: Instead of senders, the queue stores async_scopes, therefore manually controlling their lifetime C-style instead of leaving it up to the scopes, which contradicts the name :(
There will be more than one async-scope type.
One that I have proposed, but that has not yet been built, would work well for your case.
static_scope<N, senders...>
This will contain something like a
std::array<N,std::variant<connect_result_t<sender, implementation-detail>>>
This sets a fixed upper limit on concurrent work as well as eliminating allocations in the static_scope.
Spawn changes as well. IMO this spawn is the more general form.
started-sender static_scope::spawn(sender)
This form of spawn returns a sender that completes when the sender argument has been connected and started. The returned sender does not wait for the sender argument to complete. This is async allocation, which I expect to become common for fixed-concurrency constructs. So static_scope::spawn would return a sender that would complete after a slot became available (the receiver it passes to each sender will signal when a slot becomes available) and the sender argument has been connected into the free slot and started.
Thus a simple loop on spawn will only ever have N senders in flight.
That loop could sync_wait each spawn() - not recommended. The best options would be something like a coroutine that has a loop over co_await spawn(..) or a spawn(..) | repeat_effect_until() that is composed with the rest of the work.
This static_scope
idea sounds kid of good for my case, but I see 2 issues with it:
- N cannot be set at runtime (e.g. from a config file).
- When there are no slots available,
spawn
waits for any sender to complete, while my use case requires for the oldest sender to complete (as the rendering engine uses cyclic buffers for GPU resource management).
The first issue is pretty general and should be addressed in the design of static_scope
. Maybe a container policy as a template parameter?
The second one only applies to my use case, so I guess I should write a custom scope type structure, something like a cyclic_scope
or a sequential_scope
.
Yes, in general think of the building blocks in libunifex as concepts and the types as implementations for a particular strategy. There can be many strategies implemented using the building blocks.
I agree with the issues you would have using the static_scope I described and that you could write one with a strategy that matches your requirements.
@kirkshoop I've sketched up a simple locking implementation of static_scope here in case you are interested, so far works like a charm for me. (please ignore the poor code quality, this is a uni project that needs to be finished by yesterday :) ) https://github.com/Mrkol/hipng/blob/main/engine/include/concurrency/StaticScope.hpp
@Mrkol this is pretty cool! It looks like on_done is holding the spinlock over the wake. As you said in do_spawn, this could deadlock.
@kirkshoop not exactly, the wake_one
method of OpParkingLot
works kind of like std::condition_variable
's wait
, it takes a locked unique_lock
, tries to grab a task, unlocks it, and only then does it launch the task, therefore guaranteed no deadlocks. I am not sure whether OpParkingLot
is a good abstraction, but it helps to avoid operation queue copypasta.