-
Notifications
You must be signed in to change notification settings - Fork 296
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
[Rust] - Managing memory without a garbage collector #2916
Comments
@ncave I have been thinking about those damn wrappers again. I am struggling with this seemingly trivial example
How can we declare that x needs to be Send and Sync without writing some clever lookahead algorithm? Attributes cannot be used in let bindings, so it seems like the only option is to use the type system. My first thought was type aliases, but it doesn't look like Fable can see these at all (they are just expanded). Is this correct? Are they in the input F# AST? I would like to do something like the following really:
I guess otherwise you would need to make Ts a real wrapper like a single case DU, which makes usage cumbersome, and dotnet code more inefficient (as double pointer indirection). The benefit with a wrapper is it allows you to cordon off built-in types to use alternative parallel versions (the big one being array of course). I am still torn if we should be trying to replace MutCell with RwLock/Mutex as locking is inconsistent with how dotnet works. I think the most accurate solution is just to make MutCell Send and Sync. The alternative is of course the global switch, but I still think this is far too heavy-handed. Most code will never cross thread boundaries, so it seems mad to pay the price everywhere. |
@alexswan10k I don't see how we can avoid a global switch. Unfortunately project files are compiled independently and out of order, so it's hard to do global look-ahead. Also, |
@alexswan10k Yes, in several points of the code Fable expands the type aliases to make sure in the Fable AST we only get the actual types. We could keep the alias information if necessary but this may complicate other scenarios as we always need to make sure the type is not an alias when checking something. The exact thing you need, if I'm not mistaken, is: for local mutable bindings, know if the binding is at any point assigned within a closure (lambda/delegate or object expression). If that it's correct, I can do it in the FSharp2Fable step for you. We only need to:
If that looks good to you, I can send a PR with the changes 👍 Hopefully this one will take less time than #2933 ;) |
Thanks for clarifying @alfonsogarciacaro this is good to know. Your suggestion sounds pretty useful beyond my problem actually, as we already have a similar thing we do for tracking multiple uses of idents (thus deciding if the compiler must clone or not). That being said, do not feel you need to push this forward any time soon as I am not sure how well the whole "smart" approach can even work comprehensively. The issue here is there are two types of closures conceptually - single threaded closures, and thread-safe closures (that expect Send and Sync stuff - Arc). The F# type system cannot really know which applies where, and baking the wrong one into the closure is a compiler error! @ncave Lrc is a really interesting idea, and it has got me thinking. I believe I might even have a plan inspired by this. It all evolves around Rust features. Phase 1
You can then turn thread safety on globally just by adjusting the cargo.toml
In order to test this, perhaps we can have two scripts "test-rust" and "test-rust-async" to run the tests in each context, copying one of two independent cargo.toml files where appropriate. It might even be possible to parameterise this from the command line and use the same cargo.toml file, need to look into that. Async tests would be omitted from the singular series via a local feature. Phase 2 (optional + caveats)This is where the semi-smart incrementally thread-safe stuff comes in. What if you could load the same package multiple times with different aliases? Imagine in your consumer Cargo.toml you did the following
Now before I go any further I need to point out that at this moment, features are additive, which will mean you actually end up with the same thread-safe version twice, but compiled once. This is however a commonly misunderstood and wrongly used feature, so I have a reasonable amount of confidence we may see mutually exclusive features in the future. Now assuming you can actually do this (which you can't yet), the attribute ThreadSafeAttribute could be used at the function level, and type level, to redirect not the calls but the actual namespace of the called code to fable_library_rust_ts, so you are effectively consuming an entirely independent parallel-safe implementation. We already have a context var to propagate this. The final problem is then conversions, which can presumably be inferred from if something comes from a thread-safe or a non thread-safe context. Again - we already do something similar when working out if references need to be dereferenced/cloned, so this is doable. MutCell and interior mutability wrappersA final point. With features we could actually let a user decide if MutCell should be just Send and Sync (and ignore risks DIY like dotnet), or a slower locking replacement (such as Mutex or RwLock). We could even use features to decide which one to use. |
Thanks a lot for the clarification @alexswan10k! If we establish that only variables captured by async/task CEs will be thread safe, this can be detected in the AST. Would that help? Not sure if it's expected that devs will use custom functions that need to capture variables in a thread safe manner. If that's the case maybe we could use a mechanism similar to the InlineIfLambda attribute. |
@alexswan10k We can start small with just doing the custom Naming things is hard, but:
|
@alfonsogarciacaro I think that could definitely work for some scenarios. I guess the slight risk here is some might prefer to use some kind of function composition to bind @ncave these are better names, happy to go with your suggestions. The interesting thing I like about features is it allows you to conditionally bring in transitive dependencies, so you only pay for what you use dependency bloat wise. It also means we can actually use Lrc to abstract over any future Rc/Gc style pointer types just by adding a new feature (and optionally take on a feature conditional dependency to that crate). They will also need to have some kind of bridge implementation in fable-library-rust, but I imagine the overhead will be quite small. (Gc is awkward though because it requires traits to be implemented too). I think the best next steps are probably to get the features working globally across the library as we covered above, and leave all the clever stuff till some time down the road. We can set up two test scripts that pass different features down to enable and disable async. I do think the clever stuff has merit, but it is clearly going to be quite challenging to iron out all of the edge cases. What do you both think? |
@alexswan10k +1 for starting simple.
Sounds like a cunning plan, I like it already! :) |
@alexswan10k @ncave I'm a bit rusty (pun unintended) on thread-sharing mechanisms in .NET because I usually just use |
@alfonsogarciacaro you are absolutely correct. I was trying to make this point in a convoluted way above, but the bottom line is that rust has guide rails (send and sync) to guarantee correctness whereas dotnet just assumes you know what you are doing. I believe the most accurate translation would be to just mark MutCell send and sync even though it probably isn't. The problem is we have to do something here to satisfy the rust compiler and end up capturing a thing that has send and sync traits. Currently this just doesn't build. For rust purists maybe we can even allow a feature switch to optionally enable locking wrappers (rwlock or mutex) im place of MutCell down the road, although this would be unlike dotnet as you pointed out. There is also a performance penalty to this so I think it is probably an inappropriate default. I believe equivalent of interleaved is The ref counting container (Arc/RC) absolutely must be threadsafe though, or you could end up with memory leaks. It may be that this also applies to mutable ownership transfer as contention could leave allocated memory with no owner, where a gc may be smarter. |
Maybe it's not a bad thing that something that it's potentially risky doesn't compile in Rust. So instead of trying to make all cases compilable we can force devs to use an explicit Mutex when necessary. It's unfortunate that Fable async tests overuse mutable captured values, but we could change this for Rust, as I don't think it's a common pattern in standard F# code (except maybe when you're accumulating a result on an async for loop). I had a similar situation with Dart because the compiler is now more strict with nullability. Most of the time, |
Agree. Maybe how it is today is actually the best default. We could make implicit send and sync on MutCell an opt in feature, perhaps "unsafe-cells" or something. I do like the idea of explicit wrappers but there is nothing in dotnet that maps to Mutex or RwLock so presumably we would need an F# only Implementation for parity? |
There is System.Threading.Mutex and ReaderWriterLock , unless you mean something else.
Makes sense.
Absolutely, so either a compiler flag is needed to choose atomic ref count pointers, or (better) a custom RC pointer type + a Rust feature to allow the library consumer to choose later whether to use atomic rc pointers. |
Sure but they are non generic and work a little differently. They do not guard a value as the rust one does. I'm not sure how we can make this fit? Oh one point to add as to why unsafe-cell might be useful: because rust is very strict you would not currently be able to define a non mutable array (it uses MutCell internally) outside of a thread closure and capture it once, even though it technically is only used by one thread. Passing to the closure requires send as it may run on a different thread. Lifting the restriction allows more freedom here to allow things that should compile to just work. |
@alexswan10k Then perhaps I'm missing your point, do you mean mapping (implementing) F# lock (i.e Perhaps we can enumerate the use cases / features we want to cover, and start simple. |
Sure. So monitor has the same problem in that it holds a lock independent from the object('s) it is protecting. You could probably implement this with a rust There is no direct analog to rust's |
I assume we would eventually auto-generate bindings for many standard Rust libraries, using some sort of In that sense, perhaps we don't need to find an analog to Rust mutex in F#, just a way to implement the .NET synchronization primitives and F# computation expressions I like your approach of incrementally adding the bare minimum to make a feature work, before going to the next one. It's possible that part of your reasoning went over my head, if so I apologize in advance for asking you to repeat it, where necessary. |
Not a problem at all, it's good to not just barrel in and take some time to think through consequences. So in summary
At this point I feel we have an mvp. Beyond that
|
@alexswan10k Possibly a typo, perhaps the naming can be:
Perhaps down the road we can even make MutCell actually thread-safe if the feature is enabled. |
@ncave I have updated this ticket with the latest progress. We are nearly there, if we are happy with the proposed MutCell feature switches I can add these as unfinished checkboxes. |
@alexswan10k I assume you mean this?
Yes, I agree that looks like a sensible scope and we should proceed. |
Nice one. Updated the TBD to your comment. |
I think the core of this is complete. I been going back and forth in my head about the implementation of Monitor and I believe it is actually thread safe in the sense you are already going through a global RWLock, so it is probably sufficient for an MVP. My hope with stuff like this is if it ever does gain traction, a Rust threading expert will probably look at this and go WTF, and then feel compelled to improve it :) Closing for now. |
Another couple of discoveries for future real GC impls https://github.com/fitzgen/bacon-rajan-cc |
Probably the biggest impedance mismatch between F# and Rust is the managed/unmanaged memory divide. Rust however has smart pointers, which can be used to achieve pretty near the same result. The philosophy in Rust however is "pick your guarantees", which is somewhat problematic when we really would like a one size fits all approach.
The point of this ticket is to track progress and finalize how these representations translate to Rust, and how we can ensure sensible defaults where possible, but allow enough control where it makes sense.
So far we have the following big open questions:
Pass by value or by reference
Currently implemented by:
Problems:
&Rc<T>
, or primitives being passed by reference&i32
.Ideas:
Direction:
GC substitution - Rc, Arc, GcCell
Currently implemented by:
Problems:
Ideas:
Direction:
Mutability - Cell/MutCell/Mutex/RwLock
Currently implemented by:
Problems:
Ideas
Direction:
MutCell
Send and Sync via opt-in rust featurefutures
even though it is unsafe.I will try and keep this ticket up to date as things progress.
The text was updated successfully, but these errors were encountered: