Usecase for write-only references: OnceLock

currently, std::sync::OnceLock makes it pretty easy to accidentally create TOC-TOU errors when trying to use OnceLock to make code that is only run once. take the following pattern:

if ONCE.get().is_some() { return }
unsafe { /* modify some os-specific state */ }
let _ = ONCE.set(some_os_handle);

if this code is run twice in parallel, it may end up corrupting the OS state. the "proper" way of fixing this is with get_or_init, but there's a lot of problems with this:

  1. the signature will make a lot of people only think to use it if they actually need the result for something
  2. a lot of low-level programmers will avoid using callbacks whenever possible, due to perceiving them as inefficient. (even though it should get inlined in this case, bypassing the overhead).
  3. callbacks have several limitations, namely their inability to return from their containing function (unlike blocks), so many rust programmers may avoid them for that reason.

if we had write-only references, this could be avoided.

interestingly, this would require a type of write-only reference that must be written to before it is dropped, although this is also desirable to allow them to point to data that implements Drop (so they can be dropped when the reference is taken), and to allow taking write-only references to uninitialized variables.

alternativly, there could be a OnceLockWriteGuard or something similar, although this would need a similar restriction of being unable to be dropped without being written (the write method would have a self receiver instead of &self)

If this is true, then that's a documentation failure. get_or_init is the primary API for OnceLock.

One of the primary tenets of Rust is "zero cost abstractions". We don't need to cater to people who don't understand basic things about the language.

If you need that kind of thing, use a different data structure, such as a Mutex.

3 Likes

the fact that OnceLock::get_or_init is marked as #[inline] isn't even part of the language, and i'm not sure i would call it "basic" either. callbacks have a nonzero cpu overhead unless they can be optimized away. just because rust prefers zero-cost abstractions does not mean every abstraction provided by the standard library is zero-cost, in fact that is decidedly not the case.

1 Like

IMO this is the only real limitation of the 3 above, but it should be solved with OnceLock::get_or_try_init when it will be stabilized.


Why do you think it must be written to for it to solve this usecase? The fact that you could do this with a OnceLockWriteGuard struct already shows that the write is not strictly necessary (since the guard could just be dropped/leaked without any write occurring).


That said I don't think this a completly new proposal. From what I remember the biggest issue for such type of references are panics. The fact that they could happen pretty much anywhere and can also be catched means that you could consume a reference of this kind without actually writing anything to it.

1 Like

what state would the OnceLock get put into if the guard is dropped? not every type has an acceptable default value.

relatedly, what state does the lock get put in if the callback passed to get_or_init panics?

In both cases, it would be left in the <uninit> state, just like when it is first constructed.

2 Likes

does get_or_init leave it in the empty state until the callback returns?

(Assuming first run) get_or_init locks the structure before executing the callback (preventing races), executes the callback, then moves the return value into the structure. While the callback is executing, the structure holds no value. If the structure already holds a value (or is locked) then the callback is not executed.

1 Like

Slight correction; if the structure is locked, OnceLock waits until it can get the lock, then checks to see if the structure holds a value. If it does, we can release the lock and return; if it doesn't, the callback is executed as if we didn't have to wait until we got the lock.

Seems it is a misuse.

You could initialize oncelock at the time you really want to use the handle.

If you really need to initialize the handle in parallel, maybe AtomicBool is a choice

I strongly doubt about that

An AtomicBool here might be better. If the handle could be converted to a pointer, maybe AtomicUsize is the best choice.

yes, it is, since it's a race condition, but i would contend it is non-obvious that it is so. i would contend that many programmers who try to write run-once code without using a callback would create something like this.

what if the initialization is fallible and it needs to be used in infallible operations?

in the end AtomicBool is what i used to ensure the code is only run once, although i still have the OnceLocks in there, both to catch programmer error, and because SyncUnsafeCell is still unstable.

In the context of Rust, this is the root cause of the problem; you're trying to avoid something (closure parameters) by analogy to something different (callbacks) with different costs, and this is going to lead to suboptimal outcomes.

It's a lot like saying that C needs reference types, because if you try to write code without pointers, you have problems expressing a lot of important concepts in C; while this is true, it's not a convincing statement if you're trying to get people to introduce reference types into C.

5 Likes

This may be just terminology, but I don't think callback gives the ideal mental model for a closure. Let's look at the signature in question:

pub fn get_or_init<F>(&self, f: F) -> &T
where
    F: FnOnce() -> T,

The call is generic over type F, which needs to implement the trait FnOnce() -> T, meaning it has a method that consumes the F and returns a T.

Would you consider the parameter f a callback in this hypothetical API:

pub fn get_or_set_from<F>(&self, f: F) -> &T
where
    F: Into<T>,

These may look and feel very different, but are essentially equivalent! (F is something that can be consumed to get a T)

The second API is just less convenient to use as a substitute for a closure:

struct FooArgs(A,B);
impl Into<Foo> for FooArgs {
    fn into(self) -> Foo {
        let FooArgs(a,b) = self;
         /* do whatever with a and b */
    }
}

ONCE.get_or_set_from(FooArgs(a,b));

The closure syntax is a very ergonomic way to do the same:

ONCE.get_or_init(move || { /* do whatever with a and b */ } );

In both cases, the arguments to the method on ONCE only contain a and b; there is no function pointer being passed. The function that is called in the implementation is just some static trait method: <FooArgs as Into<Foo>::into or <{anonymous closure 123} as FnOnce>::call_once respectively.

In other words, with an API like get_or_init, the code that is run is statically determined by the choice of the type F, and the only dynamic part are the arguments to that code. Of course, you can statically choose the code that is run to be "call the function pointer passed as an argument", (as with F = fn() -> T) which gives you the traditional callback signature as a special case of the generic function.

7 Likes

I was under the impression that most generic functions will create a new instance whenever they are called with different type paramaters, but calling a closure will only instantiate it once.. that's how closures work in most languages, but maybe it's different with the opaque types...

It's true that a closure value can't be generic,[1] but all the “create a new instance” work is done at compile time. There is no more run-time cost to calling a generic function that a non-generic one, so even if closures could be generic that would be irrelevant to the run-time cost.

(Also, <FooArgs as Into<Foo>>::into() isn't a generic function: there aren't any type parameters on the function itself nor even on the trait implementation. The function is chosen through trait solving but the code isn't generic at all. Not that that matters here.)

get_or_init() and get_or_set_from() are both equally generic, with different traits involved. The functions they call could be, but don't have to be.


  1. except for lifetimes ↩︎

2 Likes

closures are not generic, but any function that accepts a closure must be generic.

i'm still sceptical about the whole "no function pointers involved" thing, doesn't that mean basically every closure-accepting function is effectively always inlined? wouldn't that have an impact on code size?

Note that any method on OnceLock<T> is already generic over T.

It is a candidate for inlining, but it doesn't mean it will be.

If you are concerned about a specific generic function over a closure being inlined you can always choose to not make it generic by manually requiring a function pointer or a trait object (e.g. a &mut dyn FnMut()).

3 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.