Ownership, borrowing, lifetimes, parallel programming and unnecessary complexity

that’s exactly what i meant by

Zig says “comptime evaluation will halt because I’ll halt it”,

yes :slight_smile:

1 Like

In the 80s and 90s, the standard was to pre-reserve a buffer with a fixed size.
It had several advantages:

  • Fixed upper bound enforces a strict limit on memory usage
  • Only need to allocate and free once, or in some cases not at all
  • The memory never leaks

They didn’t need garbage-collection or reference-counting, since they already knew that the memory would only be freed under very specific circumstances (program exit, usually).

Of course, this allowed very easy access of undefined memory by indexing the buffer wrong, which is why 80s and 90s programs are notorious for being exploitable in spectacular ways, but it was objectively the most performant way for developers to use their very limited memory.

In fact, the original DOOM always reserved 8 MB on the heap on program startup, and then used its own system to internally manage that memory, like a FixedBufferAllocator in Zig. The result is that after the program is initialised, it never, ever needs to allocate memory through syscalls again.

The glaring issue is that, of course, you might reserve more memory than you actually end up needing. Or more memory than the user has!

6 Likes

For database caches (what I’m working on) this is still the common approach. The fixed size is fine because it’s a cache – you decide up front how much memory to give the db and when it fills the least recently/frequently accessed items are evicted.

On the safety issue, this is solved in my case by never storing pointers in the cache, just plain-old-data. A bug can cause a data race, but not a memory safety issue.

Apparently TigerBeetle (also a database, implemented in Zig) uses fixed size allocations for everything. I’m not a OS person but I assume OS implementations also use a lot of fixed size data structures. It’s still a good tool to use whenever you can.

3 Likes

Regarding your more general feelings, yes, there absolutely are a lot of programmers who don’t know anything below the framework layer. Whether that’s a problem, though, depends on who you ask. It’s certainly working fine for the programmers and the companies they work for - even if they could hypothetically squeeze more performance and better software overall out of lower-level engineers, I don’t think they’d consider it cost-effective to do so.

Perhaps it’s better that systems-minded people direct their energy to systems where that makes the biggest impact? I don’t know.

I do think it would be unfair to discount the skills of experts just because their domain is higher level and not as concerned with performance, though. Sometimes worrying about teamwork (I know, the old “oop is good for large teams” argument, but it seems to be working well enough in a lot of spaces regardless of our personal feelings), modularity, correctness, or iteration speed is simply higher priority.

2 Likes

That makes sense that this was all common knowledge in the 80s. I had a sense that OSes worked that way but my experience was to start off focused on RAII C++ in the 90s then onto GC C# and then failing in my attempts to master C as the cross-platform lingua franca missed all that - probably distracted by always shooting myself in the foot.

What I’m getting at here is that this is the first time I’ve really been able to see all this so clearly and I totally lack the real historical insight there, so thanks. :slight_smile:

1 Like

The syscalls probably aren’t really an issue if one uses something like the PageAllocator as the backend for another Allocator, but with eg 64 pages at a time (256k blocks instead of 4k).

But for multi-threading, you still have synchronization overhead for the internal allocation and free routines.

Was the original Doom multi-threaded? Idk, but I guess not, given the typical CPUs of the time.

1 Like

Nope, not multithreaded. You can absolutely use pre-allocated blocks of memory in multithreaded code, though. Just be careful and methodical about how you chop it up and interact with it.

It could be, yes. In practice it doesn’t handle mutual recursion correctly, so it runs for an unbounded amount of time and overflows the program stack.

Which is a sort of halting. If you squint.

1 Like

Squinting, I see an implicit unpredictable hardware/OS-dependent stack usage quota.

Indeed! Joel David Hamkins said exactly that in a recent interview with Lex Fridman.

Fortunately squinting is my favorite thing to do when writing mathematical proves :slight_smile: (presumably to the dismay of my professors…)

3 Likes

In the 80s and 90s, the standard was to pre-reserve a buffer with a fixed size.
It had several advantages:

FWIW I use this approach (pre-allocated global state) to a bigger or lesser degree in most of my projects.

For instance this Pacman toy project has all state in a single static global:

I had to ‘rediscover’ this technique after nearly two decades of OOP/C++ cool-aid consumption.

…likewise in my home computer emulators, all state is in a single state struct (including the emulator memory and ROM dumps), and there I’m actually using this for a ‘poor man’s snapshotting’, I can simply store and load this struct to disk to create a cycle-accurate emulator snapshot (the snapshots break of course after changing the struct layout):

PS: the ‘missing language feature’ for this “all state is in a single global” philosophy in bigger codebases is a filesystem-like permission system (restrict read/write permissions for specific parts of the code to specific parts of the data).

1 Like

I think you could get close with a horrible abuse of C++ singletons and friend classes.

Note that I said could not should. I dislike myself for even thinking it.

5 Likes

You will get quite different answers in a Rust forum. To avoid bias and skew, I suggest that you do that as well. One important consideration is guarantees. For instance, with defer there is no guarantee that it won’t be omitted or scoped incorrectly or used when it shouldn’t be–these errors are common. Zig and its community favor manual and freedom, Rust goes the other way.

Note that there are languages that try to infer/automate borrowing and lifetimes–see, e.g.,

Memory Management in Lobster

and

Loon — A LISP that flies

The undecidability of the halting problem only applies to the set of Turing Machines. There’s an infinity of less computationally powerful mechanisms for which halting can be proven … one example not really logically different from having a branch quota is the singleton set of machines that have “halt” as their only operation.

1 Like

The glaring issue with fixed size buffers is buffer overflow.

1 Like

Not today. Buffers are represented as slices these days.

“So if I get the deinit() call right, I get lifetimes right“ –> you’re correct to point this out: Zig does allow to describe your memory management operations very simply, and for you to be very confident that it is correct. The issue is being 100% confident: Zig programs are not provably correct in terms of memory management (neither are Rust programs, but the more you code safe Rust, the more confident you can be that your program is memory-correct)

Also, Rust doesn’t encourage you to build harder programs. In fact, it’s the other way around!
Rust sucks when you have shared mutable state. Often, shared mutable state is a consequence of a poorly architected codebase. In that case, the Rust compiler will (might) bully you until you rethink some assumptions and re-architect your solution.

Now, sometimes shared mutable state is actually a necessity to solve some particular problem. Then, Rust will seriously get in your way**, and you might be better off with Zig. Still, because Rust is safe unless specified, you may be able to condense all unsafe memory behavior into small, easily-reasoned-with chunks of your codebase.

Zig is much more straightforward than Rust and gives you finer, simpler control over lower level concepts. Rust gives you more confidence that you’re handling those lower-level concepts right!!

**: you can build excellent shared mutable state models in Rust if you use reference counting, RefCell, rwlocks, etc., but naturally that comes with some performance overhead, which may or not be acceptable. More info in “Handles are better than pointers“ article by floooh

3 Likes

I tend to think so, yes. I’ll add something: Zig lets you do things which Rust forbids. Not all of those things are actually bad ideas.

Until it doesn’t.

An under-emphasized part of the Rust experience, is that the lifetime and borrow XOR mutable rules? They are not optional, and they must not be violated.

There’s a prevalent misconception (I’m not saying you have it, but it’s out there) that unsafe lets you do whatever you want. It does not! unsafe tells the Rust compiler “trust me, I am following all the rules” and it is allowed, encouraged even, to compile as though that were true.

So it’s quite possible to write unsafe code which: would work in C, would work in Zig, but doesn’t work in Rust, because the compiler thinks you won’t do it, and screws everything up. Sometimes you just want to share a pointer, y’know?

The Big Question is whether the Rust rules are necessary to solve the acknowledged problems with C and C++. Obviously I think the answer is a big Nope, but reasonable people will differ. Fortunately it’s not going anywhere, so if I ever start planning something out, and think “ok but this will be horribly difficult in Zig and things will go badly wrong” I can use Rust instead.

5 Likes

I recently read an article that was very inspiring to me.

Ownership, lifecycle, concurrency… these are all good concepts, real concepts that truly exist, and indeed need to be considered when programming.

I am not against programming languages managing them in some form. The problem is that programming languages often try to cram all their management into a universal type system, leading to an overloaded type system and causing serious inflexibility.

Using systems other than types to manage this can alleviate the problem, for example manually managing with defer x.deinit();, or using effect systems or capture systems, these ‘second-class citizens’ that are different from the type system.

4 Likes