Unique<T> for thread-safe usage of engine types - #1524
Conversation
1e43f6c to
28c83fc
Compare
|
API docs are being generated and will be shortly available at: https://godot-rust.github.io/docs/gdext/pr-1524 |
7a094fa to
967028a
Compare
There was a problem hiding this comment.
Thanks a lot for the effort, very interesting approach!
Several of the symbols (UniqueType, Array::to_unique, ...) do not appear in generated docs, which makes the PR a bit hard to introspect. Please make sure the full public API is visible, including required bounds 🙂
Since engine methods currently take &AnyArray/&AnyDictionary rather than impl AsArg<...>, the implicit conversions from Unique to array/dict arguments won't work. I'm not sure about making everything generic, as it increases complexity and compile time -- for now there could be an escape hook (explicit conversion) maybe? Although that might open it up for abuse 🤔
Element: ThreadSafeArgContext can be quite a restriction and breaking change, no? Especially after we just opened up Element to custom types for v0.5. The trait ThreadSafeArgContext not having a blanket impl is a problem -- it means that we have yet another trait that has to be implemented manually (yes, #[derive] can do it, but I also anticipate moving to builders and proc-macro less APIs one day, and making them more boilerplaty isn't great 😉). There's some prior art: I made a blanket-impl with AsArg<Variant> for impl ToGodot<Pass=ByValue> -- it would mean removing many explicit impls though (and not sure if that works out for all).
I see that apply() takes exclusive refs &mut T, is there a distinction to allow shared-ref access &T, or should we treat both the same?
d37d968 to
d0a4657
Compare
Yeah, docs weren't being rebuilt due to conflicts with
This is actually an unsolved problem. Explicit conversion creates a gap in the thread safety guarantees. It's possible to smuggle in non-thread-safe values via thread locals or singletons and pass them to an API of a
Breaking change, yes; restricting, I'm not sure. All user-defined types should be The blanked impl for That's why I currently have the
I don't see much use for the apply function besides mutating the inner type. You can't return anything from the closure, so immutable access appears to be pointless so far. |
That's a reason why the trait should be sealed though, not private 🙂 It can have a private
It's possible to enforce such absence, but it will limit
Is What about other blanket impls, like the one I suggested? It's still semi-manual, but at least most users defining their own types can deal with one trait less that they might ultimately not care about.
You cannot return, but it's still possible to transport values outside through thread-safe means (e.g. |
d0a4657 to
cda3510
Compare
Fair point, I made it public and sealed now.
Piggybacking off
It's not Using We had the same issue when dealing with the signal arguments for the |
5ed11e8 to
6112df7
Compare
|
I have now added an additional associated type to Additionally, I have moved the runtime thread-safety validation into codegen so it is decoupled from |
36a8655 to
c8efece
Compare
Bromeon
left a comment
There was a problem hiding this comment.
Thanks for keeping this up-to-date! There's probably still quite a few things to be looked at holistically, but I commented again on some parts to get a more detailed understanding on the approach 🙂
| function cmd_itest() { | ||
| findGodot && \ | ||
| run cargo build -p itest "${extraCargoArgs[@]}" || return 1 | ||
| run cargo build -p itest --features itest/experimental-threads --features itest/codegen-full "${extraCargoArgs[@]}" || return 1 |
There was a problem hiding this comment.
We can't have codegen-full in default config, it takes too long to compile. But I just added the --full flag for this 🙂
Also experimental-threads is rather discouraged -- discrepancies in feature flags between individual compiles (itest, clippy, test etc) will require re-compilations. (I think this might already be a problem today...)
There was a problem hiding this comment.
Yes, I fully agree. This is all just so it's easier to test this PR locally. I will remove all changes here before moving things out of draft status.
| /// This might fail if the object is referenced by anything else or any of its internal references are shared with other objects. | ||
| /// Specific reasons for this conversion to fail: | ||
| /// | ||
| /// - Reference counter is > 1. | ||
| /// - Reference count of any property value is > 1. | ||
| /// - Any property value directly inherits from Object (manually managed). | ||
| /// - Any property value is of type Dictionary or any of the Array types. | ||
| /// - Any property is a custom callable. | ||
| /// - Any property fails these checks recursively. | ||
| /// | ||
| /// Since all checks are applied recursively to all objects which are referenced by the given value this conversion can potentially be quite expensive. | ||
| pub fn try_from_ref_counted(value: Gd<T>) -> Option<Self> |
There was a problem hiding this comment.
This is quite restrictive, is this method still useful in practice?
Gd<Node>etc isn't necessarily owning, pointing to nodes in the scene tree is harmless- No arrays/dictionaries excludes a ton of use cases
At the same time it's also not airtight:
- You don't list
Variantwhich can contain anything else - Any fields that aren't
#[var]are invisible Gdstrong refs can be trivially worked around withInstanceIdweak refs -- although this can be OK iffrom_instance_idis sound- The recursion hits a stack overflow when you have cyclic references
I'm not saying it needs to be perfect -- for threading we definitely have to make compromises, and best-effort is better than nothing. But we should probably state a clear goal here, and then depending on that decide how far we want to go.
It's also worth noting that such a recursive check can be extremely expensive at runtime.
There was a problem hiding this comment.
This is quite restrictive, is this method still useful in practice?
This is something I'm currently evaluating inside my own project. My theoretical use case here is that this should work well for engine built-in resources like materials, textures, animations, and so on.
You don't list Variant which can contain anything else
Variant types are being inspected during conversion.
Any fields that aren't #[var] are invisible
Yes, and that's why only engine ref-counted classes without a script are allowed. It's even more restrictive than you thought. 😁 I see that this detail is not yet covered by the description.
Gd strong refs can be trivially worked around with InstanceId weak refs -- although this can be OK if from_instance_id is sound
Yes, but I don't think engine classes store InstanceIDs; at least I haven't come across one yet. So it should be covered by the previous point. try_from_ref_counted is now also restricted to the main thread as well.
The recursion hits a stack overflow when you have cyclic references
Good point. I should probably guard against that. So far I'm just trusting that engine classes don't have cycling references.
| + Inherits<RefCounted> | ||
| + Inherits<Object>, |
There was a problem hiding this comment.
Inherits<RefCounted> implies Inherits<Object>. In fact, the latter is always implied (but may sometimes be necessary for technical reasons).
There was a problem hiding this comment.
Yes, looks like the bound is superfluous.
| match self.get_type() { | ||
| VariantType::NIL | ||
| | VariantType::BOOL | ||
| | VariantType::INT | ||
| | VariantType::FLOAT | ||
| | VariantType::STRING | ||
| | VariantType::VECTOR2 | ||
| | VariantType::VECTOR2I | ||
| | VariantType::RECT2 | ||
| | VariantType::RECT2I | ||
| | VariantType::VECTOR3 | ||
| | VariantType::VECTOR3I | ||
| | VariantType::TRANSFORM2D | ||
| | VariantType::VECTOR4 | ||
| | VariantType::VECTOR4I | ||
| | VariantType::PLANE | ||
| | VariantType::QUATERNION | ||
| | VariantType::AABB | ||
| | VariantType::BASIS | ||
| | VariantType::TRANSFORM3D | ||
| | VariantType::PROJECTION | ||
| | VariantType::COLOR | ||
| | VariantType::STRING_NAME | ||
| | VariantType::RID => (), |
There was a problem hiding this comment.
It seems like this or similar checks happen more than once. Probably makes sense to add a method on VariantType.
Note that I'm already going to add a is_pod/needs_ffi_destructor function for anything that doesn't/does need a FFI destruction (i.e. isn't Copy). This covers everything here except STRING/STRING_NAME.
Also strange that two strings are on the list, but NODE_PATH isn't.
There was a problem hiding this comment.
Also strange that two strings are on the list, but NODE_PATH isn't.
We currently don't treat NodePath as thread-safe, so I omitted it here. I think it's also just a GString internally, but keep it the way it is for now.
| /// Whether arguments of this type are thread-safe or not. | ||
| /// | ||
| /// Can be either [`ThreadSafeArg`](crate::meta::ThreadSafeArg) or [`NonThreadSafeArg`](crate::meta::NonThreadSafeArg). Only engine | ||
| /// types make use of `NonThreadSafeArg`, all user defined types should use `ThreadSafeArg` by deriving [`GodotConvert`] or by manually | ||
| /// implementing this trait. The use of `ThreadSafeArg` also requires the type to be [`Send`]. Non [`Send`] user defined types are | ||
| /// currenlty not supported. | ||
| type Threads: ThreadSafety; |
There was a problem hiding this comment.
Conceptually, do we also need to cover the other side -- not just arguments, but return values from Godot?
There was a problem hiding this comment.
So far I'm thinking that we don't have to do that. There is no clue from the engine as to what the thread-safety properties of a return value are. So at the moment return values are just accepted, and we trust the engine that the return value is ok in the current context. Once you try to pass the return value back to the engine, thread safety checks will be applied. I think this keeps the restrictions somewhat balanced. Restricting the read and write access to shared references is out of scope of this PR, but once we get to that, you essentially can end up with a value that you can't do anything with. It would still be possible to run expensive checks to verify that the value is actually unique or read-only though. If we outright block return values, that wouldn't be possible.
There was a problem hiding this comment.
Thanks for elaboration. I think this might deserve a short section -- even if it's just reflecting the status quo, and doesn't imply that we'll never have to do that in the future.
| use crate::meta::{CowArg, GodotConvert, NullArg, ToGodot}; | ||
| use crate::obj::{DynGd, Gd, GodotClass}; | ||
|
|
||
| pub(crate) trait ThreadSafeSealed {} |
There was a problem hiding this comment.
Can we reuse the existing Sealed trait -- if the purpose is only that users cannot implement it outside?
There was a problem hiding this comment.
Correct me if I'm wrong, but we have a blanked impl of ThreadSafeSealed for any T that is also Send. This means ThreadSafeArgContext can only be implemented by our own types and by any type that is covered by the blanket impl. If we switch from ThreadSafeSealed to Sealed we would make a lot of user types Sealed which is not what we want.
There was a problem hiding this comment.
Ah, good point. Maybe add a quick comment then:
| pub(crate) trait ThreadSafeSealed {} | |
| // We can't use private::Sealed due to blanket impl for all T: Send. | |
| pub(crate) trait ThreadSafeSealed {} |
1b8978d to
64bf8d2
Compare
|
This is a drive by comment, but can you downcast a node stored in a unique? like, can you cast a |
At the moment that is not supported. Do you see a use case for downcasting here? |
1f3e4ae to
8d36cc3
Compare
All reference based Godot types can be created inside a Unique struct. The only way to interact with the godot class is with Send + Sync types. Unique::map and Unique::apply only allow Send and Sync closures and and trying to sneak in Gd<T>s via thread_locals will result in a runtime panic.
When experimental-threads is on we can only pass &Gd<T> to the engine on the main-thread. Other threads will panic.
Some tests to check if it works as intended. More real world use-cases would help.
8d36cc3 to
ab6575e
Compare
The
Unique<T>type constrains the way non-thread-safe engine types can be used outside the main thread. With these constraints applied, we can safely send types that are wrapped byUnique<T>across threads.Values that are wrapped by
Uniquecan only be accessed via theUnique::applyorUnique::apply_gdfunctions. These functions accept aSend + Syncclosure to prevent any non-thread-safe values from getting passed into the wrapped types.Usage Example
Breaking Changes
This will very likely break code that relies on the
experimental-threadsfeature. The extent of the breakage needs to be assessed, and ideally a migration path can be offered for all safe use cases.ToGodothas a new associated typeThreadsthat has to be added to all manual implementations of the trait. Default values for associated types are still not stable.To Dos: