Reserve First
62 points by llimllib
62 points by llimllib
errdefer comptime unreachable; is so damn cool. Wow.
yep, it is! there’s even a nicer, better self-documenting variant:
errdefer comptime unreachable;
// vs
errdefer @compileError("we can't return an error past this line as otherwise our data will be corrupted");
which will give the programmer making the mistake a nice explanation when doing so
Is that similar to this in Rust?
enum Never {}
fn foo<T>() -> Result<T, Never> { .. }
Or Result<T, !>
if that stabilizes?
That’s closer to an empty error set: error{}!T
What this allows you to do is in individual branches/blocks/after the setup within a function assert no errors are possible.
if (len_used < len_allocated) {
errdefer comptime unreachable;
// ...
} else {
const mem = try realloc();
// Also documents that errdefer free isn't needed
errdefer comptime unreachable;
// ...
}
Man, I really want to be excited about zig but reading stuff like this makes me nervous about going back to the land of uninitialized memory, leaks, dangling pointers, and so on.
Yeah I felt the same, especially after fighting segfaults again for the first time in a few years. (That being said, I think it’s good for a programmer to work through those issues every now and then! Of course, that’s a harder sell when it’s in production.)
Something really cool about Zig’s approach to allocator design is that different implementations can report interesting facts, like if memory has leaked or not. So when you unit test, you pass in a std.testing.allocator
everywhere you accept a std.mem.Allocator
and you’ll get a test error if it detects a leak. I’m sure there are some crazy cases you could get something into where it doesn’t detect the leak, but in my limited experience, having that kind of tooling has really helped.
Edit: The “general purpose allocator” can do that as well, so you can utilize that during different testing scenarios: integration, simulation, etc
Segfaults are great! You get a crash exactly where the bad memory access happens. What you don’t want are dangling pointers with valid memory addresses, corrupting memory instead of trapping.
Segfaults are okay (approximately as good or bad as fatal panics) if and only if they happen in well-defined places (modulo intentional non-determinism) according to the language semantics. If you’re relying on LLVM in the implementation I don’t see how that’s practically possible unless you insert (pessimizing) compiler barriers everywhere in the emitted LLVM IR, e.g. null pointer loads/stores are immediate UB in the LLVM semantics so it’s allowed to remove null pointer accesses and much worse. (To be clear, I don’t think null pointer accesses are a very scary bug class; that’s just an obvious segfault-related example where the semantic mismatch between LLVM and language implementations on top of LLVM might run into trouble.)
I feel like they’re kind of fine from a user perspective when they happen but terrible from a programmer’s perspective because you can’t rely on them happening. And the former is why so much work goes into many different exploit mitigations that each try to turn some piece of UB into clean segfaults.
Right - the thing that both of you are alluding to is that for some categories of bugs, when a segfault occurs, it’s possible that it was “lucky” in the sense that under different nondeterministic conditions, the same bug might have triggered UB/memory corruption instead. So the sense of dread that you get is based on that connection. Often, people who are newer to systems programming, however, understandably haven’t reached this level of nuance yet, and so they think more simply “segfault scary, unsafe bad.”
More concretely, in Zig it is idiomatic for deinit functions to do something like this:
fn deinit(thing: *Thing) void {
// free resources, and then...
thing.* = undefined;
}
In safe build modes, this memsets thing
to be all 0xaa
bytes, which has the benefit of being quite noticeable in a debugger, gives integers high probability of tripping index out of bounds and arithmetic overflow safety checks, and makes pointers trap (in fact 0xaaaaaaaaaaaaaaaa is outside the mappable range of x86_64).
Depending on your compiler infrastructure, segfaults might also be a totally valid method for control flow! The JVM uses segfault on NULL pointer accesses to implement NullPointerException
in some cases.
Yeah, it’s also common in JVM implementations to rely on probes such as test eax, [disp32]
for GC safepoints: you protect the guard page at [disp32]
when you want to stop threads at the next safepoint and then catch the segfaults. Machine-level implementation tricks are fine; the qualification “depending on your compiler infrastructure” is extremely important though, and considering how many people build their low-level systems languages on top of LLVM it’s something you have to take very seriously as a language implementor (and even as a language designer since it has implications for your ability to use LLVM/gcc as an optimizing compiler with minimal overhead if you want to avoid miscompilations).
That’s fair, but keep in mind that issues like this come up in all languages. Do you close all network sockets and file handles if your code returns early due to an exception? How about unlocking mutexes? Delete temporary files?
I haven’t written any Zig, but I write a lot of C++ and Rust. My experience is that a lot of things get easier when writing in those two languages, because they have solutions to these problems because they crop up so frequently with memory. Languages with garbage collectors automatically manage memory, but every other kind of resource management is typically left up to the programmer, resulting in resource leak bugs or dangling resource reference bugs.
Unclosed socket is not at all “like” pointer to a freed object, that’s a false equivalence. Yes, both memory and file descriptors are resources, but, like, me and cerulean warbler are both animals, but it’s not a particularly useful equivalence! Cerulean warbler won’t open calc.exe!
There’s a lot to be said about zig safety features which, in RelaseSafe, would ensure that “uninitialized” hash map slot actually stores 0xAAAAAAAA, which deterministically causes subsequent pointer dereference to segfault, or how DebugAllocator never reuses addresses (well, it confuses up and down, but the idea is sound!), so, again, UaF doesn’t generally lead to type confusion, but that’s a much finer statement than putting all resources into a single equivalence class.
I never said an unclosed pointer is “like” a pointer to a freed object, that’s a resource leak. The dangling resource reference bugs I’m talking about is things like variables which refer to deleted temp file paths and the like.
All resources aren’t alike, but resources share enough commonalities that having a strong model (or, in the case of C++, at least a model…) for handling resources safely is useful.
Sorry, I think I am being confusing here. What I aw trying to say is that I fairly strongly disagree that “ issues like this come up in all languages”. I claim that “issues like this” don’t crop up in Java, Python, Rust, Go (but do occur frequently in C and C++).
This is because a use after free issue is very different, in its consequences, from other resource issues.
Use after three can be weaponized to cause language-abstraction-breaking type confusion, which in turn may allow an attacker to execute arbitrary code, or to read arbitrary memory. You generally wont get issues like that from calling close twice on the same socket.
In other words, although the cause of bugs, resource mismanagement, is the same, the consequences of bugs are different, and that makes bugs not alike.
Resource leaks absolutely crop up in Java, Python and Go. I’ve seen it happen a few times, and I’ve certainly had the experience of needing to manually call .close()
in the right places to free resources.
References to freed resources sometimes happen. I’ve definitely had a program throw because I try to write to a file handle after calling close()
.
In other words, although the cause of bugs, resource mismanagement, is the same, the consequences of bugs are different, and that makes bugs not alike.
The consequences of that is much more benign than a use-after-free in C, and I never claimed otherwise. The consequences of a resource leak is typically about as bad: the program eventually crashes or causes other issues for the system.
My claim is: many languages without a GC have decent ways of dealing with resources (be they file descriptors, sockets or memory), while languages with a GC typically don’t have quite as nice ways of dealing with non-memory resources. Furthermore, you can’t really have a RAII-style mechanism (which is one of the possible decent ways of dealing with resources) in a language with a GC. That’s all.
The consequences of a resource leak is typically about as bad: the program eventually crashes or causes other issues for the system.
Software crashes (possibly even bringing down the whole OS) is really not “about as bad” as attackers getting to run arbitrary code.
Attackers don’t get to run arbitrary code from a memory leak.
Apologies, I misread.
Memory leaks are indeed pretty much on par with any other resource leak.
Furthermore, you can’t really have a RAII-style mechanism (which is one of the possible decent ways of dealing with resources) in a language with a GC.
I use AutoCloseable
liberally in Java with linter checks (error-prone I think) that yell at you if you don’t use try-with-resources for your AutoCloseable
object, or plainly use try-finally to close the object. Or you add an annotation to suppress it, which is a red flag to just not use the AutoCloseable
type and do your manual resource management - I mainly see it in legacy codebases where AutoCloseable
was added to existing JDK types.
Yeah, the linter is not the language, I know. Yeah, it’s not RAII because the object is not being closed upon deallocation, I know. But the actual code pattern is the same and it gets the job done.
A GC language that isn’t hobbled by backwards compatibility can have that check built-in to the language for its blessed try-with-resources interface/typeclass/relevant polymorphism mechanism.
Doesn’t AutoCloseable/try-with-resources tie the lifetime of the resource to a stack frame/scope? Like Python’s with
, that works for the very simple cases. But as soon as you need something slightly more complicated (maybe a map from some session identifier to socket that’s used in some event loop?), it falls apart.
That’s literally the same for RAII as well, unless you add back a GC with shared_ptr (pushing the problem to runtime where you can increment decrement a counter).
This isn’t true:
fn f() -> String {
// the string is allocated here
String::from("hello")
}
fn main() {
f();
// the string is deallocated here
}
No reference counting involved. This is what with
can’t do.
And now try to pass that String
to a generic higher-order function and see how the borrow checker will refuse to compile your code, unless you play by its very specific rules. In a complex enough case your best bet is (A)RC wrapping it.
Sure, RAII does compose further than try-with-resources, but in my experience you can usually write a with-resources block that does its job just fine.
There’s no references here, so there’s no borrow checker. You’re going to have to cough up a code example.
I don’t know the implementation details, but the borrow checker is always “on”. Your code just does a move from f
to main
, and then doesn’t do anything with it.
I don’t really get your point, do you argue that more complicated lifetimes don’t require ref counting? E.g. the original example:
maybe a map from some session identifier to socket that’s used in some event loop
Sure, it should be feasible to make it ref count-free, but only by architecting the code around this very specific design, e.g. making your session identifier an identity-less value.
The borrow checker is always on, but it only operates on references. If there’s no references, it doesn’t do anything.
I’m not arguing that there’s never any use for reference counting. I’m saying that it is used far more rarely than you imply, and that RAII can do much more, even without reference counting, than with.
The statement is that RAII is strictly more general than try-with-resources, because it doesn’t restrict the lifetime of resource to be lexically scoped. In particular, you can create a resource in the child function, and return it to the parent function.
The secondary claim is that no borrow checker is involved here (indeed, C++ has RAII but no borrow checker).
Here’s a specific example from code I wrote 10 years ago, which shows how RAII is strictly more powerful than try-with-resources:
Here, I accept a connection and create a socket on one thread, and then send this socket to another thread. No reference counting, no lifetimes, pure RAII. It shouldn’t be hard to find equivalent code in any threaded C++ accept loop.
This is an example that is a) very much practically relevant and b) can’t be expressed nicely using lexical try-with-resources
/ with
/ using
.
The statement is that RAII is strictly more general than try-with-resources
Definitely, I don’t argue that. My reply was originally to this claim:
But as soon as you need something slightly more complicated (maybe a map from some session identifier to socket that’s used in some event loop?), it falls apart.
, and we just probably have a different definition of “slightly more complicated”. My point is (that I expressed in a very convoluted way, apologies) that in practice, where try-with-resources break down given a general managed language are also non-trivial cases for RAII without ref counting [1].
The secondary claim is that no borrow checker is involved here (indeed, C++ has RAII but no borrow checker).
But this was a Rust example. In C++ the onus is on the developer to get everything right, which is non-trivial.
[1] - which given a bit more thought I’m not 100% standing behind, there are use cases where RAII fits exceptionally well, or can be designed to fit well, your code being a good example.
Sorry, there may be some misunderstanding. When I say “GC”, I mean a system like mark-and-sweep garbage collection: a system where memory is automatically freed at unpredictable times in a way which handles reference cycles.
But no, even without reference counting, you can have RAII-style objects referenced by other objects. You can have a std::unordered_map<std::string, SomeSocketType>
and the socket’s destructor will automatically run immediately whenever you remove it from the hash map. No reference counting involved.
Well, then we are just talking about destructors/finalizers, and sure tracing GCs can’t do them in a timely manner (though check out Java’s Cleaners)
But that doesn’t change that a strictly RAII alloc/dealloc pattern is severely limiting when it is the only applicable primitive, so either way, it doesn’t solve the issue, you have to pair it with ref counting which has overhead.
Well, then we are just talking about destructors
Yes, that is what RAII means: running destructors when a variable goes out of scope
But that doesn’t change that a strictly RAII alloc/dealloc pattern is severely limiting when it is the only applicable primitive, so either way, it doesn’t solve the issue, you have to pair it with ref counting which has overhead.
std::shared_ptr is built on RAII… the shared_ptr copy constructor increments a refcount, the destructor decrements the refcount.
Furthermore, you can’t really have a RAII-style mechanismin a language with GC
s/can’t/don’t
There is absolutely nothing fundamental preventing it, just a lack of need. If anything, try with resources is a dumber version of the same thing.
There’s absolutely something fundamental preventing it. If an object representing a socket is referenced by a GC-managed object, you can’t really call the destructor of the socket object until the GC-managed object is GC’d.
But here comes the distinction between memory and different kind of resources. You can close a file, just because the representing object will still exist. In fact, this exact situation happens each and every day in Python/Java etc, when you manually close the file, but still accidentally use it afterwards. You just get an exception.
Also, in C++ your exact situation is not prevented by the language, you have to make sure that no dangling pointers exist to your resource when it exits the scope. Though Rust does solve it.
You can close a file, just because the representing object will still exist.
I don’t think I implied otherwise?
In fact, this exact situation happens each and every day in Python/Java etc, when you manually close the file, but still accidentally use it afterwards. You just get an exception.
Yeah, that’s a use after free…
Also, in C++ your exact situation is not prevented by the language, you have to make sure that no dangling pointers exist to your resource when it exits the scope.
I never said C++ guarantes that you won’t ever make a mistake. I said it gives you better tools to deal with it than what GC’d languages do.
Yeah, that’s a use after free…
Well, is doing a fcntl(fd, F_GETFD)
check before using an fd considered using it? Resources have more potential states than plane memory regions, and these states can be well-defined and “safely navigable”. An object representing a resource is just a wrapper around it.
Also, this comment does a better job of communicating my thoughts: https://lobste.rs/s/7wwm4h/reserve_first#c_dmjwho
Nonetheless, I think we are not in as much disagreement, see https://lobste.rs/s/7wwm4h/reserve_first#c_vedfdb .
you can’t really have a RAII-style mechanism ([…]) in a language with a GC
Isn’t that exactly what D does, and hence its existence disproves that statement?
I don’t know enough about D to say. Can you have a RAII style file handle object and reference it from a GC-managed object? How does that work?
I’m not well practiced with it, but from a quick experiment - “there be dragons”, a dangling pointer.
One can avoid it by making use of the @safe annotation, at which point it forbids the reference. Possibly there is a way to combine the two concepts in general in a safe way.
A quick experiment where I had the RAII object dynamically allocated and referenced from a GC object, then deferred its destruction until the referencing GC object was destroyed.
Thinking about consequences of doing resource mismanagement in memory safe languages, outside of FFI code:
Hypothetically, if I write a buggy program in Python that calls os.close() twice on the FD for a socket, and my program is multithreaded (say some kind of server), then the symptoms could appear as other totally-unrelated connections sometimes getting erroneously closed (just because they got the same FD number).
That would be very silly because using raw FDs in Python isn’t something you would normally do, just because socket and file objects are much more convenient. Python’s socket and file objects track whether you’ve already called f.close()
on them and won’t emit a second close()
syscall if you try to close them twice. (Just checking now, file.close()
in python3 is idempotent and simply doesn’t do anything if you call it a second time, but calling any method other than .close()
on a closed file throws an error. Seems ideal.)
Also in Python the most convenient way to even work with files is a with open(...) as some_file:
block which gets the exactly-once cleanup right by default anyway.
I conclude that in high level languages you can mitigate the possibility of resource mismanagement causing anything excessively weird to happen, just by designing libraries and the languages with the Pit of Success in mind. So the most convenient thing to write is the one that gives you the least-messy behaviour.
I have never denied that the consequences of a resource management mistake in C++ is way worse than the consequences of a resource management mistake in Python. I’m saying that Python lacks features to manage resources (other than the with
statement, which only helps in the case where the lifetime of a resource can be tied to the lifetime of a block), while C++ has a fairly solid model for resource management which helps you avoid making a resource management mistake. Leaking file handles is easier in Python than in C++.
I’m not sure that’s a fair argument – I’d rather not have problem X no matter what. I don’t really care that problems Y and Z exist, I just would rather have two problems than three. And here, problem X is particularly insidious because it can produce all kinds of unpredictable behavior.
I don’t think it makes sense to have a language with a garbage collector for memory and also some kind of RAII for non-memory resources, since the lifetime of resources is typically so intimately tied to the memory which contains references to those resources. So I don’t think you can view “problems X, Y and Z” as separate. You either get “problem X” (no garbage collector; your program deterministically allocates and frees memory) or “problems Y and Z” (no RAII; you have to manually manage non-memory resources and are susceptible to resource leaks and dangling references).
I don’t see why you can’t have a GC that also releases external objects like sockets. I think I have used some.
Sure you can, but the GC isn’t guaranteed to run at any reasonable frequency. A garbage object can remain alive for hours if you’re not producing much garbage.
Seems like the kinda problem you probably never run into, and if you do then you fix it by fiddling with the knobs on the GC a little or manually invoke the GC occasionally.
I disagree. You absolutely shouldn’t rely on the GC to close your sockets in a timely fashion.
Real-world code I’ve seen agrees with me, FWIW. Pretty much all production code I’ve seen in GC’d languages explicitly calls .close()
on sockets and files, or uses mechanisms like Python’s with
to do the same.
Yeah, every language I’ve seen that’s GCed and has a “destructor” explicitly warns against using it for resource management because you can’t guarantee anything about the timing.
No one relies on the GC for this, this is a false dichotomy. Having a GC does not mean you’re not allowed to clean up resources at the proper time.
No one relies on the GC for this
That’s exactly what I said.
this is a false dichotomy.
How so? I never presented a dichotomy. (Unless you’re talking about the “RAII vs GC” dichotomy? If you think they’re compatible, please explain how?)
Having a GC does not mean you’re not allowed to clean up resources at the proper time.
To expand on this: having a GC does not mean you don’t have to clean up resources at the proper time. Having a GC does not free you from manual resource management. Having a GC rules out some classes of resource management systems which would free you from manual resource management.
Well, whether you think it makes sense or not, plenty of languages with garbage collectors have finalizers. And whatever problems they might have, I would much rather deal with those than with memory safety issues.
Finalizers can run hours or days after the object stops being referenced. It’s not remotely reliable. An object which keeps a resource alive might be literally 8 bytes large, such as an int
file descriptor + some object overhead. Yet it might keep alive kilobytes of kernel memory.
Yeah, no one relies on finalizers to close the socket or whatever. You write your software to do so at the proper time. And if you mess up, the socket will unfortunately hang around until the GC catches up.
Contrast this to a bug regarding reading uninitialized memory, which can cause problems that are extremely difficult to track down and also considerably more severe. Rather than running out of socket handles, maybe money gets moved to the wrong account or something.
Of the two, I know which one I would pick.
Yeah, no one relies on finalizers to close the socket or whatever. You write your software to do so at the proper time. And if you mess up, the socket will unfortunately hang around until the GC catches up.
Then why bring them up?
You have to manually manage non-memory resources in most languages with a GC, and there are hard design trade-offs where adding a GC rules out some classes of resource management systems. That’s what I said. As you acknowledge, finalizers don’t change this.
I didn’t, you did:
I don’t think it makes sense to have a language with a garbage collector for memory and also some kind of RAII for non-memory resources, since the lifetime of resources is typically so intimately tied to the memory which contains references to those resources.
All I said was that I don’t want to deal with memory safety issues.
All I said is that having a GC does not free you from issues caused by manual resource management. Your opinion on dealing with memory safety issues is frankly a completely unrelated thing. You’re allowed to like having a GC; I certainly do. But I find myself missing C++‘s and Rust’s RAII sometimes when writing Go or Python.
Honestly I feel the opposite: RAII in the Rust/C++ is good enough for managing memory, but it feels inadequate for managing more complex resources like files and sockets. Particularly if cleaning up a resource is an asynchronous operation, I find you often end up with quite complex destructor logic that gets implicitly called in surprising places and doesn’t necessarily reflect the developer’s intuition for what’s going on.
I prefer the Python/JS/C# approach where you have explicit sections that mark a resource’s lifecycle with a clear start and end, and where if that resource escapes the current function, the caller needs to be explicitly responsible for managing that lifecycle. That said, all these languages have the issue that it’s easy to forget to explicitly manage the resource, and I suspect explicit resource management alongside a type system that enforces that correct resource management would be even better.
I find it interesting, though, that while memory is conceptually a resource just like a file handle, it also has its own idiosyncrasies. As has been discussed to death here, GC isn’t suitable for managing file handles, but it often is fairly reasonable for handling memory allocations. Similarly, explicit closing of resources is important for sockets and the like (especially in async contexts), but Rust’s RAII with implicitly inserted destructor calls works really well for managing memory.
JavaScript is actually getting lexically scoped RAII in the form of the using
keyword. Weird and interesting, innit?
First of all, Zig is amazing, please keep it coming!
But personally I agree. The “look, one more trick to trick the language not to trick you” that I really enjoyed in the past as a C developer transitioning to C++ is a nice book title, but I know the plot twist already, thus no excitement (spoiler alert: designing a safe sub-language with a bunch of lints and warnings as errors, giving up and switching to a safe by default language).
Note that C and C++ experience doesn’t really match the Zig one. C and C++ are actively hostile (reading out of bounds just … reads whatever there is), while Zig is actively helpful (removing and item from the hashmap deterministically overwrites its memory with the pattern that makes all pointers and most indexes invalid).
I haven’t worked on liberally allocating Zig code bases, so I can’t exactly speak from experience, but I would expect a massive difference to C there!
While the design goal of handling OOM errors correctly is laudable, and Zig makes it possible, I’ve seen only one application, xit which passes “matklad spends 30 minutes grepping for errdefer” test.
Verona arrived at a similar observation.
Discarded solutions
There are several possible approaches that we have discarded as possible approaches.
new returns an option type
The simplest approach is for the new operator to return an option type (or throw on failure, which is roughly equivalent in Verona). This is to memory failure what malloc and free are to general memory management: a ‘solution’ by making it 100% the programmer’s problem.
This kind of approach may be useful in an OS kernel or embedded system but experience with C/C++ has shown that almost all code that does handle allocation failures does so by exiting the program. Requiring a checked exception on every allocation would be likely to lead to the same thing: a program littered with explicit termination on allocation failure without any graceful handling.
Meanwhile, i’m a happ, bean that i can gracefully handle OOM on my 32kB RAM microcontroller without the system immediatly crashing completely.
In Zig, i can just spawn some local allocator, do regular std.fmt.allocPrint
and release the memory later on, even on embedded. It’s so nice to have the ability to handle OOM gracefully that i’m willing to pay the price of handling it.
Another example where it’s not nice if crashes occur: Handling network requets that OOM can be gracefully nuked and the memory can then be used for future requests instead of crashing and restarting the application, killing all live socket connections
If you read the Verona doc I linked, it proposes some mechanisms to give the ability to handle OOM without surfacing allocation errors at each individual allocation site.
My point was that “surfacing allocation errors at individual allocation sites might not be a good idea”, not “the language should not give you the ability to handle OOM”.
None of these solutions solve the problem at hand which is a “low memory situation” in general and a specific “out of memory” situation based on that.
My point was that “surfacing allocation errors at individual allocation sites might not be a good idea”, not “the language should not give you the ability to handle OOM”.
And i’m on the opposite position. I think it’s an incredibly good idea and after working with Zig for quite some while, i’m now missing it in so many programs i write that i’m always in fear my software won’t be as stable as i’d wish it to be.
My biggest project is Ashet OS, a desktop operating system for low memory systems (roundabout 8 MB). On this system, you can’t just allocate and hope for the best or kill the application right on sight. You also can’t just kill other applications to regain the memory (which is what linux does) because all options that aren’t “handle allocation failure when it happens” is basically killing user experience one way or another.
If i got the verona docs right, they basically wanna solve the problem by having unused memory being collected and reusing it somehow. This isn’t a solution i can use in a lot of embedded systems, as most memory is allocated statically by the user and has dedicated use cases (and even sometimes requires specific locations)
In the fixed version of the internString
, potentially unused space gets allocated because of the calls to ensureUnusedCapacityContext
and ensureUnusedCapacity
before getOrPutAssumeCapacityAdapted
, right?
My brain wants to say that’s an issue or somehow exploitable something, but I guess this code will only ever “waste” as much unused space as the largest interned string that has internString
called on it again.