Could static mut be made safe by an attribute on functions indicating they are not called concurrently?

static mut variables can be convenient and are sometimes necessary (or an equivalent such as an interior-mutable static), but are effectively incompatible with the borrow checker, since any function can modify them from any thread, and so they are unsafe. However I am wondering if giving the borrow checker a small amount of extra information, through an attribute on functions which indicated they are never called concurrently (called e.g. #[mutually_exclusive]) would allow borrow checking, making usage of static mut safe.

main() and task entry points in embedded applications usually meet that requirement and so could be given that attribute, along with non-reentrant interrupt handlers. The attribute itself would be considered unsafe, i.e. the compiler trusts it blindly.

If static mut X is accessed only by #[mutually_exclusive] fn task(), then I would expect borrow checking it to be straightforward, as X can be treated identically to a local variable.

If another function f accesses X, and task calls f, then the call to f in task would need to be treated as a borrow of X (either mutable or shared depending on what f does). Likewise if task calls g which calls f, the borrowing would need to follow along the chain of function calls. It would need to be forbidden (or at least unsafe) to call f from a function not ultimately called by task.

Is this idea feasible? It would create a sort of whole-program borrow check which I don't believe Rust has currently. However it could eliminate a significant amount of unsafe code.

1 Like

It could. The usual method of ensuring such mutual exclusivity though is to use a "mutex".

Of course, and the naming above is meant to suggest that similarity. However it is not necessarily possible to use a mutex in an interrupt handler (for example) as that would generally involve calling a blocking function. Even where it is possible to use a mutex (i.e. in a normal thread/task, and assuming OS support on embedded), there is a performance cost and the potential for deadlocks. Just using the borrow checker to ensure safety would be preferable.

Without being able to comment on anything very specific, it sounds like this would either be literally impossible (in the general case) or so limiting as to be functionally useless. For sure a whole-program static mut borrow checker is possible if there is only a single thread of execution. If that is the only goal, then it's probably only more effort than it's worth.

2 Likes

It would almost surely be best-effort, not ensured; knowing if a given function is called or not is undecidable in the general case.[1] I don't see how this helps interrupt handlers (that might be called, invisible to Rust, when an exclusive function frame is on the stack) either.


  1. Silly example that defeats a naive approach. ↩︎

2 Likes

Another way to achieve this is to Box::leak() in main, and past the &mut to every function thereafter.

3 Likes

Even if they're only ever accessed from a single thread they would still be unsafe to use because you could still get multiple mutable references to them. The guarantee that they are not accessed from multiple threads is only enough to allow them to store !Sync types (e.g. RefCell/Cell), kind of like how thread_locals work (though they have the weird closure API because they are not really static, but this wouldn't be the case for !Sync statics).

If the attribute is unsafe how is the usage of static mut considered unsafe then? You just liften the unsafe requirement from the function in which it is used to an attribute which doesn't even require unsafe to be used (well, at least until unsafe attributes by RalfJung · Pull Request #3325 · rust-lang/rfcs · GitHub is merged).

Honestly I don't really see an improvement, I feel like it would rather create a false sense of security that you're code is safe while it actually isn't, which is just a footgun.

Yes, in isolation each of them would be #[mutually_exclusive], but when combined they are very likely not:

  • what if I call main() from some other function?

  • what if I mark both main() and a function that main() calls as #[mutually_exclusive]?

  • what if an interrupt handler starts executing while you're executing main()/some other #[mutually_exclusive] function?

4 Likes

I think this must be ensured to be suitable for Rust. Rust's guarantees for safety, in terms of what the compiler checks, are sound and not a best-effort approximation. An incomplete analysis wouldn't change static mut being unsafe to use, so it'd be a lot of effort for very minor improvement.

4 Likes

This bit of analysis is actually tractable. If there's only a single thread the borrow checker can simply pretend every single function call has an implicit &mut parameter for every static mut, and that parameter is recursively passed along. Then it's just regular borrow checking. It could even do reborrows.

(I'm not saying this should be done, but it can be made safe for a single thread)

1 Like

This would prevent the static mut from being borrowed while performing any function call because it would conflict with the implicit &mut parameter of that function, which would prevent you from doing pretty much anything with it.

Every usage would reborrow the implicit local &mut.

RTIC implements this idea using macros.

3 Likes

Yes, and that's the problem. If it's currently borrowed (e.g. you want to call a method on it, you want to pass it explicitly to some function, you're holding a reference to one of its field or in general which borrows from it) then it cannot be reborrowed, because the implicit local &mut is already borrowed.

Right! That's a very normal situation that the borrow checker understands though. The code fails to compile for the same reason that code which lacks a static mut, but instead passes around some reified Context, would fail to compile. Making static mut "work" for a single thread is a very fixable problem (just not necessarily one that needs to be fixed in this way).

(Also the Context would need to be Sync to be shared between threads, which is something static mut doesn't even obliquely reference, but that's a different discussion thread)

The difference is that with a reified Context you can choose where it gets passed and where it doesn't, and you fix those kind of errors by not passing the Context exactly everywhere. With a static mut there would be no such user control and it would have to be passed everywhere, making the error unfixable.

I mean there is no reason to assume this intensely hypothetical feature would have no user control or have to actually be passed everywhere, e.g. the compiler could insert it only in the call tree where necessary.

I generally tend to assume this is not the case unless someone manages to show a sound algorithm for it that doesn't feel like magic (i.e. I should be able to reason about it as a human without invoking the compiler). That said, such an algorithm would have to deal with:

  • function pointers/trait objects
    • assuming they won't be using a static mut is not sound unless you can prove it;
    • on the other hand conservatively assuming they can means even simple stuff like a println will be considered as borrowing them. This also introduces the possibility of hidden breaking changes if you internally use a trait object you weren't using previously.
  • panic handlers/panic hooks/global allocators
    • panics can happen pretty much everywhere, and panic hooks can be setup dynamically by the user, so I don't see how you would deal with this.
  • FFI that calls back into Rust
  • probably more (?)
1 Like

Wrap it in a RefCell. It's exactly intended to be used in single-threaded context, it doesn't block if you really uphold the safety requirements (i.e. no double mutable borrows), and the overhead of a simple integer increment/decrement is almost undetectable, even if you do it in a loop.

1 Like

If you want to use it in a single threaded context, you probably want a thread local

Since the whole context of this was embedded no-std, I'm not sure that is a useful suggestion. I was pretty sure that many embedded targets don't support thread locals. And in Rust thread locals are in std, not even in alloc.