openlang

What survives a catalog reload?

A slice is an address and a length. Neither field keeps the backing allocation alive, and the Zig reference is plain that pointer validity and lifetime are the programmer's problem. Returning a slice from a function extends nothing.

So the docs for a catalog's names call have to say who owns what it lends. Here the catalog owns the allocation and the caller never frees the view. Destroying the catalog invalidates every view. A successful reload that swaps in new storage invalidates every earlier view too. If adding a name can grow and move the storage, adding a name needs the same warning. Copying the slice copies the descriptor, not the names.

Failure gets its own sentence. A reload that builds the replacement first and installs it only on success leaves the old allocation and its views valid when preparation fails. An implementation that clears old storage first can't promise that, and its docs should say so.

The other shape is to hand back an allocated copy and make the caller free it with a documented allocator. Independent lifetime, more ownership work at every call site. Which one wins depends on how callers actually read, reload and clean up, and I haven't traced that yet.

后续讨论 15

顺着彼此的依据读下去
  1. 固定链接

    Wrapping this in Swift doesn't buy as much as it seems to. A strong reference keeps the catalog instance alive, sure. The reload still runs inside that live instance and swaps the storage underneath it. ARC knows when the object dies and nothing about where the names it lent out sit, so the invalidation rules you listed carry over unchanged.

  2. 固定链接

    Which read is still running when the reload starts? That overlap is the real problem, and as nell says, keeping the instance alive doesn't reach it. Rust shows it plainly. This doesn't compile, even though the capacity is there and nothing would move.

    fn main() {
        let mut names: Vec<u8> = Vec::with_capacity(16);
        names.extend_from_slice(b"ab");
        let view: &[u8] = &names;
        names.push(b'c');
        println!("{:?}", view);
    }

    rustc stops at the push with E0502. push needs &mut self while the shared borrow is still live. Keeping the owner alive answers a different question. Two cheap ways out. Finish the read before the update, or copy the small lookup key that has to outlive the replacement. For a key that size I'd count the copy as a reasonable cost.

  3. 固定链接

    Sequencing the read fixes the overlap. It does nothing for a replacement that fails partway through construction.

    Take a C++ replacement catalog with three members in declaration order. A file owner, an index owner, a buffer. The buffer's constructor throws. Any subobjects of the buffer that had finished are destroyed. Then the index member, then the file member, in reverse order of completion. The catalog's destructor never runs, because the catalog never finished constructing.

    So a raw handle that only the catalog's destructor would release leaks. Every acquired resource has to sit in a member that completed, the same per-member ownership a buffer wrapper needs, one level up. Even with that right, unwinding cleans up the new object and nothing else. The old catalog keeps its invariants only if the swap waits until construction has succeeded.

  4. 固定链接

    Copy the key where, though? If the copy lands in Odin's temp allocator, free_all(context.temp_allocator) at the end of the request takes it along with every view into that memory. The key survived the reload and died with the request. So the copy has to go somewhere that is cleared after its last reader, and with temp storage the clear usually sits far away from the line that made the copy.

  5. 固定链接

    Easy to miss here. Moving the array to a longer-lived allocator moves the views. Not the bytes. Each entry is still an address and a length pointing into temp storage.

    So two owners. One for the outer collection, one for each name's bytes, and neither may depend on the temp allocator outliving its clear. My earlier FFI buffer question never hit this, since the Vec<u8> there owned the bytes outright. A catalog of names adds the second layer. I haven't checked yet that the copy path really copies both.

  6. 固定链接

    Here's the fun failure. Every name's bytes get copied and survive, and one descriptor still points at the old generation. Nothing about that view is independent. Free the old generation and it dangles just as before. So the copy needs one of two promises. Rebuild every descriptor against the new storage, or keep the old generation around until the last view into it is gone. That second promise is bigger than it looks, since the catalog turns into a thing with generations without anyone deciding it should.

  7. 固定链接

    I wrote that the catalog's destructor never runs. True for an ordinary constructor. With a delegating constructor, once the target constructor has finished, an exception leaving the delegating constructor's body does invoke the destructor. [except.ctor] gives that case its own paragraph. So a handle acquired in the delegating body and released in the destructor is released. Still, a handle held by a completed member is released in both cases, so that is the rule I keep relying on.

  8. 固定链接

    I keep circling the generations idea and I think Rust already has the shape for it, and it isn't scary. If names hands out an Arc of the whole generation instead of a slice, the old generation lives until the last reader drops its clone, and reload just swaps the pointer, and nobody has to remember anything.

    use std::sync::Arc;
    
    struct Catalog { current: Arc<Vec<String>> }
    
    impl Catalog {
        fn names(&self) -> Arc<Vec<String>> {
            Arc::clone(&self.current)
        }
        fn reload(&mut self, fresh: Vec<String>) {
            self.current = Arc::new(fresh);
        }
    }
    
    fn main() {
        let mut c = Catalog { current: Arc::new(vec!["a".into()]) };
        let old = c.names();
        c.reload(vec!["b".into()]);
        println!("{} {}", old[0], c.names()[0]);
    }

    That prints a b. The old view still reads the old names after the reload, and the catalog never had to know who was holding them. And if a reader holds the Arc across a long await, the old generation stays in memory for that long, which is the cost moth was pointing at, only now it shows in the type instead of hiding in a promise in the docs. And if reloads come fast while readers are slow, several generations can be alive at once, and bounding that probably needs a counter somewhere, which I haven't thought through.

    What I like is that the answer to "who owns the bytes" becomes a value you can hold. What I don't like is that every lookup now bumps a reference count, and for a catalog read on a hot path that might matter or might not, I haven't measured it. For the Zig version this means a refcount or an arena per generation, by hand. I'd still start with the Arc and see who complains.

  9. 固定链接

    Your open question about how many generations stay alive has a plain count. While a slow reader holds a clone, each reload can strand one more generation, as long as some other reader grabbed the newer one in between and is slow too. The worst case is one plus the number of reloads that fit inside the longest hold. Ten reloads during one slow await means up to eleven copies of the catalog in memory at once, and nothing in the Arc version caps that.

    The refcount cost is the smaller worry. Each names call is one atomic increment and one decrement on the same counter, and if many threads read the catalog at once they all hit that one cache line. I'd measure that only after putting a number on the longest hold.

  10. 固定链接

    That count is the reason I won't copy the Arc shape into Zig as it is. A hand-written refcount with no limit on generations gives me a leak that only shows up under slow readers.

    The version I'm sketching keeps exactly two generation slots. reload builds the new storage, then looks at the slot it would overwrite. If that generation still has readers, it frees the new storage and returns error.ReloadBusy. The caller gets a refusal it has to handle. Nothing is invalidated, and memory stays bounded at two catalogs plus whatever the failed build briefly held.

    The open part is what the caller of reload does with error.ReloadBusy. Retrying in a loop just moves the unbounded wait somewhere else.

  11. 固定链接

    Honestly the Arc is handy, but look at the signature you ended up with. reload takes &mut self. As long as that's true, it can't be called while anyone holds a & borrow from the catalog, so a plain names(&self) -> &[String] already gets that protection for free, checked by the compiler, no counting at all. That's alden's E0502 again.

    The Arc only starts paying rent when readers live on other threads and the catalog is shared. And then reload(&mut self) can't be called through the shared handle anyway, so you need a lock around current, which is a second thing readers have to go through. Worth deciding which situation the catalog is actually in before picking.

  12. 固定链接

    The lock does not have to cover the read. Readers take it only long enough to clone the pointer, so a slow reader never holds up a reload.

    use std::sync::{Arc, RwLock};
    
    struct Catalog {
        current: RwLock<Arc<Vec<String>>>,
    }
    
    impl Catalog {
        fn names(&self) -> Arc<Vec<String>> {
            Arc::clone(&self.current.read().unwrap())
        }
    
        fn reload(&self, fresh: Vec<String>) {
            let old = {
                let mut slot = self.current.write().unwrap();
                std::mem::replace(&mut *slot, Arc::new(fresh))
            };
            drop(old); // may free the old generation, outside the lock
        }
    }
    
    fn main() {
        let c = Catalog { current: RwLock::new(Arc::new(vec!["a".into()])) };
        let held = c.names();
        std::thread::scope(|s| {
            s.spawn(|| c.reload(vec!["b".into()]));
        });
        println!("{} {}", held[0], c.names()[0]);
    }

    The write side took a second look. Writing *slot = Arc::new(fresh) would drop the old Arc while the write guard is still held, and if that was the last clone, the whole old generation is freed under the lock. Taking it out with std::mem::replace and dropping it after the guard keeps that work outside. This prints a b, the same as yaro's version, with reload now taking &self.

    What it still leaves open is the count max8 gave. The lock bounds how long a reader can delay a reload, and nothing here bounds how many generations readers keep.

  13. 固定链接

    From the screen side, my click handler holds the old Arc, reads "a" fine, and sends it to a save that finds nothing in the current generation. Memory safe and still wrong, so views should carry a generation number.

同一个问题的其他角度

  1. Assigning realloc back to the only pointer leaks on failure

    *buffer = realloc(*buffer, n) turns up in a lot of growth helpers, and it is wrong in a specific way. If the allocation fails…

    回复 8最近活动 clua
  2. The dangling pointer warning went away, the pointer did not

    Passing a name into a C++ library through a wrapper, and the first version was one line, CString::new(name).unwrap().as_ptr()…

    回复 3最近活动 rustcpp
  3. Same sink call, a copy on one line and a move on the next

    Nim 2 decides copy or move per call site. Not per proc. Same argument, same proc. The first call copies, because a is still n…

    回复 2最近活动 nim