Ownership, borrowing, lifetimes, parallel programming and unnecessary complexity

(Preamble: This is not really a zig related question, but I know there are some very experienced programmers on this forum and I respect y’all opinions. This post is a collection of some thoughts I had and that came up while writing this.)

Is ownership (and explicit lifetime bounds) just unnecessary complexity?

While watching a video on youtube about rust and its comparison with C++, it got to ownership, borrowing and RAII and thought:

  • I’d avoid automatic deconstruction like the plague: It hides away when resources are released, thus killing readability. Solution? deinit() or free() functions and (ideally) some sort of defer mechanism. This removes the need for a Drop trait and c++ deconstructurs.
  • Ownership is complicated (probably skill issue), especially when you dive into lifetimes (also probably skill issue): Lifetime of an object stored by value on the stack is ultimately bound to its call stack, if I need it to outlive the current scope I just copy its value out of it, right? Lifetime of a pointer’s value is ultimately bound to it’s presence on the heap (is it?) which without any Drop trait or implicit deconstructor calls is ultimately bound to a deinit()-like call. So if I get the deinit() call right, I get lifetimes right. To me, this seems much easier to understand.
  • Borrowing: I can see why they thought about it, but I don’t get why it’s actually used. The compiler should (should it?) implement guardrails for accessing references, especially mutable one and especially in shared resources contexts. But is borrowing really the solution? Just throw some rules in the language that remove this class of problem? My gut says that the tradeoff in complexity isn’t worth it. Because borrowing is ultimately a matter of lifetime: If the above points hold, then borrowing becomes superfluous. If I just handle my lifetimes correctly (i.e. my resource release and object deallocation), get locks when I need to change something and end of the story. Also I believe this forces programmers to really understand what’s going on. E.g. I’m taking a parallel programming class right now and though I already used threads primitives, I was surprised to see that in reality, I didn’t have any clue about how parallel programming really works at the kernel and CPU level, and now this enables me to write way better parallel programs, because I actually understand what the OS is going to do.

This takes me to another point that’s really out of scope, but: I did an internship in a firm that has around 50k daily users on their platform, sometimes all at the same time, and I don’t think that any of the people that I worked with knew any of what I’m studying in my parallel programming class. Not only that, but I strongly believe that out of the 6 people that work there, there’s not one that understands anything that goes deeper than setting up a JS/php framework, writing some HTML and some CSS (maybe not even that). I find this to be incredibly disturbing: How can someone serve 50k users and not know anything below the framework (not even the actual language, because probably they don’t understand that either) they use?

The results are then pretty clear: You end up with half-cooked software that does the job in double the time it’d actually need to, and end up spending thousands of dollars in monthly server costs just because you can’t program, because let’s be honest, if you don’t know anything about the actual computer (literally the execution mean of your work), or if you know the bare minimum to get work done, are you really an engineer, or are you a programming-bricklayer? And not only that: you lay the bricks, and then you have to remake the whole house every few years because new-shiney-framework.js came up, or the house just sets itself on fire because “react-native super flexible write once run everywhere” application broke because of an NPM dependency fucking everything up.

More in general the more I study this field, the more I can’t understand why do we end up with all this complexity or smart tricks that ultimately result in code that is very difficult to understand and maintain. I don’t understand why anybody would implement four different ways of doing the same thing. Isn’t one that is well-made enough?

Don’t get me wrong, I’m not saying it’s easy: Computer science is a very difficult topic to master, I surely can’t even consider myself a true engineer/computer scientist yet, but I don’t understand why doesn’t nobody asks themselves those question before pouring $100k a year into the hands of crappy programmers that ultimately produce crappy software.

I read Andrew’s Why we can’t have nice software article and I also believe that the above point is correlated to what he says: Why can’t we have nice software? We have to serve the users, but we don’t! We have a very fundamental job in the current world and we should take it as seriously as someone who goes and wants to be a medic. (Almost) Our entire society runs on these programmable machines, and we should be conscious about how we program such machines. We should know better than to ship 1GB application binaries that eat 8GB of ram.

An example of this: A while back I was in an airport and there were machines for automatic passport checks. Out of 15 machines, 10 where broken. The 5 that worked, took somewhere between 5-10 minutes per person. What’s the point of spending money for automation then? I don’t think those machines were cheap to buy nor they add any meaningful value to the users since I still have to wait a ton of time. The best part came when I scanned my passport: The display flashed out a random PowerShell window with some error logs on it. I thought: Why would you ever want to ship an embedded system with a fully fledged windows OS? Why would you even use windows for something like this? It’s a dumb technical decision IMHO.

Since I’m still a student of the arts of computer programming, I’d like to know your opinions about this: Am I missing some pieces? Do my thoughts make (any) sense?

I know it’s a long post and mostly out of this forum’s scope, but I don’t really know where else to ask/talk about this stuff.

Thanks for any answer. I wish y’all a wonderful day.

8 Likes

That’s more of a philosophical than technical question, but IMHO if you end up with thousands or tens-of-thousands of individual ownerships and lifetimes in your application that’s already a massive architectural failure, any sort of automatic/enforced memory management is just plastering over this underlying problem - and once you have that problem, a traditional garbage-collected language might be the better and “care-free” solution, since performance obviously doesn’t seem to matter with such an architecture.

E.g. the freedom you get from a programming language without “enforced” ownership and lifetime tracking is wasted when doing manual memory management on a “per-object-level”. E.g. minimize the problem, and a minimal solution will work just fine :wink:

IMHO restrictive languages like Rust have their place for implementing safe sandboxes, but not necessarily for any program that runs in those sandboxes.

PS: In the end, when performance matters, the hardware-constraints dictate the software architecture and nothing else. Those hardware constraints were radically different in the 1990s when languages like Java, Python or JS were created (the “cpu <=> memory gap” didn’t exist or wasn’t as wide as today). Lower-level and much older languages like C were lucky that their programming model is so simple and ‘unopinionated’ that they could easily adapt to the changing hardware landscape, while higher level languages either required massive investments to remain competitive (e.g. see the JS V8 engine) or became irrelevant (ok Python is still very relevant, but mainly because it is used for ‘scripting/glueing tasks’ where performance really doesn’t matter). Arguably Rust falls into the same ‘massive-investment’ category. It solves a problem that shouldn’t exist in the first place (safely managing individual object ownership and lifetime for tens-of-thousands individual objects) by moving the solution from run-time to compile-time, at the cost of a very restrictive programming model. A similar ‘brute-force solution’ is high-performance memory allocators. Allocator performance only matters when you call alloc/free at a very high frequency (e.g. for tens-of-thousands of short-lived objects), again solving a problem that shouldn’t exist in the first place.

10 Likes

Perhaps that it is not quite so black and white. There are all kinds of variations you’ll encounter in the real world:

  • C++ and Rust programmers who rely on RAII but also thoroughly understand what’s happening in their program and are not being misled by “invisible” allocations.
  • Programmers in GC-based languages such as Java who understand the cost of allocations because they do performance testing and profiling, and who use the profiling information to reduce allocations to acceptable levels for their use case.
  • etc.
4 Likes

The questions sound ‘Rusty’.
Ownership can be a logical structure. A programming language should not limit creativity or even debatable incorrectness. The borrowchecker and lifetimes were my absolute worst nightmare.
Just an opinion :slight_smile:

5 Likes

Been asking myself similar questions, and the current state of software/the industry really seems… meh. Maybe it’s just my workplace, but it would seem that you could divide software engineers into 2 categories: 1) The ones that love their work because it achieves results and 2) the ones that love experiencing the process. If you love results, code that doesn’t “get in the weeds” of memory management and simply focuses on business logic is the main appeal. Leave the icky stuff to the JIT and GC to worry about. But I believe there’s room for an ideal of defining your program with the minimum logic and computing resources necessary. Zig lets you do that. It’s more for people who love the process. Writing everything in Zig might be bringing a tank to a knife fight, so you gotta choose your battles wisely in terms of time and what our colleagues are willing to work on. It’s a really tough balance to maintain, especially if the people around you don’t share your passion or perspective.

(super subjective experience here lol)
I tried learning Rust and Zig and the same time and quickly found that Rust didn’t feel good. I know it clicks with a lot of people, so I’m not saying it’s a failure at all. To me, learning a programming language is kinda like learning the rules of a game. Zig felt like a more coherent game with a lower skill floor and a very high ceiling. Allows for so many play styles and strategies with a very clear design premise. When it came to Rust, control flow, the borrow-checker, and mutability felt okay. But then all the smart-pointers and macros felt like hyper-specific exceptions to all the other rules. To compare card games, I’d say Zig feels like Dominion while Rust feels more like Yu-Gi-Oh.
(I do enjoy both games; the analogy breaks down since TCGs are designed differently than games with a closed card pool, but I hope you get the point.)

But beyond compiler-enforced lifetimes, there is definitely something to be said about Zig making data-oriented design more ergonomic. In most mainstream languages, you definitely have to move against the grain to architect your code that way. I believe @floooh nailed it that any architecture with thousands or tens of thousands of lifetimes is probably a bad one. And unfortunately, Rust seems to let that path have the least resistance.

I’m only beginning my journey into DOD. I’ve been in server-side web development for almost 5 years, and it’s the only job I’ve had after college, so I’m far from what I consider a “seasoned” developer. All this stuff is a more subjective discussion at the end of the day, but it’s one that results in real consequences down the line. All that to say, I feel ya. Sometimes we need to know we’re not the only ones feeling like something’s missing.

6 Likes

I think I understand what you mean in general, but it would help me to know more specifically. (Rust doc itself has a known terminology problem with the word “lifetime”.) Are you referring to the number of different types with managed ownership? Or maybe complexity of data structures in general?

@vulpesx recently shared this eye-opening video, a worthy watch, and @matklad recently shared this great article/book (I’m not very far through it yet, but it’s worth reading anything @matklad shares).

1 Like

If you do simple linear software, you typically don’t have to deal with resource lifetimes, everything is acquired and released at the appropriate place.

It’s only once the program gains some concurrency, then you need to consider how long something lives. It doesn’t even have to be true parallelism, simple loop dispatching events also counts as concurrency. And here it doesn’t matter if they ownership rules are encoded in the language or the programmer’s head, you just have to deal with it, you can’t avoid it. The differences between borrowing, copying, moving, arenas, garbage collection, reference counting are just implementation details. Some people prefer more constrained languages like Rust, some people prefer the cost of GC cycles in Java and have the freedom to do whatever they want safely, some people invest effort into managing resources explicitly like in Zig, and some people and use semi-explicit way with reference counting like C++, which is kind of nice in some way, but potentially slower than GC if used too much.

3 Likes

Are you referring to the number of different types with managed ownership?

No, I mean the number of runtime objects in an application with individual lifetimes. Very often, entire groups of related objects also have similar lifetimes (e.g. let’s say you parse a JSON file which results in one heap-allocated ‘item’ per JSON node, where each item lives in its own heap allocation… now either you (for manual memory management), or the compiler (in Rust), or the garbage collector (in GC languages) have thousands of individual object lifetimes to keep track of.

But all those node items only need to remain alive until the code which loads the JSON no longer needs the parsed node-items, and they can all be deallocated at once - so all those items can be grouped into the same ‘lifetime bucket’ (and instead of thousands of lifetimes you only need to keep track of one) - and this is where manual memory management solutions like ArenaAllocators come in, all the items allocated in the ArenaAllocator share the same lifetime which ends when the ArenaAllocator is resetted/discarded.

Alternatively, stack-allocated items also share common lifetime buckets: their { }-scope. ArenaAllocators basically extend this lifetime-bucket concept for data that needs to survive the current scope block.

6 Likes

Ownership and lifetimes still exist even if the compiler doesn’t point out to you when you’re violating the corresponding rules. The Zig compiler team know this.

2 Likes

Yes. True. Seen that one.

Ok, thanks. RAII seems valuable to me when the ownership/sharing is complex, rather than when the number of managed objects is high.

I’m sure professional C++ and Rust programmers know their stuff waaaaaay better than I do and are most probably waaaaay better programmers than I am. I read about the Asahi Linux project and how one of the devs as a first serious rust project wrote a driver for M1 macbooks (thus reverse engineering the drive and reimplementing it in rust). This is a very very high level of software engineering. I can only aspire to become such a skilled developer, hopefully using zig in the meanwhile.

This I kinda said what I thought in my answer to @floooh.
TL;DR:

Software ends up either being used or not being used. The latter case isn’t of importance, so let’s assume your software is widely used.

  • GC langs: You’ll suffer from GC performance. It’s not an if, it’s a when. Inevitably at some point you and end up either rewriting the thing in system language X or finding a workaround by mixing GC lang X with system language Y. Either way you probably would’ve been better off without the GC in the first place. “If your code is slower than C, someone will rewrite it in C”.
  • Java/C#/strongly OOP languages: You’re gonna suffer from complexity. As the codebase grows, so does abstraction and inheritance. After 10 years, implementing a feature or solving a bug will take several days.

This is just the idea of a student, these are take on what’s my current experience. Real world is probably another pair of hands.

This does not change the fact that there are a lot of conscious developers out there who knows what they are doing and manage to create excellent software, even if they write idk python or bash or whatever.

Yeah I know that the question is a out of the forum’s scope, but I don’t think it would’ve been… well receipted by a rust-focused community.

Absolutely! That’s where I wanted to get. I never wrote anything that had any meaningful impact on anybody in rust, but I still felt that the language was trying to work against me sometimes, and not in a good way.

Yeah… I heard this “program correctness” argument a lot throughout the rust community.

2 Likes

This is interesting.

I try to write my code either in Zig or C, since I’m still a student and can choose what I want to work with, but I see that on the job it’s probably very difficult to convince someone to use something like zig.

Anyway this is an interesting take. I agree with the “room for an ideal” part, not only that, but I think it should be thought as the superior way of writing programs. We should aim to minimum logic and necessary computing resources. It’s such a no brainer for me. Why would anybody want to use more resources and more logic than necessary? It’s not right serving the users a sub-optimal program that eats resources (Hello Winbloat™).

I feel the same way about this.

It’s great that even on the job you take your time to try new stuff and still learn.

2 Likes

Nice. Thanks for the resources. The book about parallel programming is gold. Definitely gonna read it.

This is very interesting. I didn’t see it this way.
What’s your preferred way then? I personally have the feeling that overall language rules end up limiting what you can do on the long term as requirements (and maybe even paradigm) change. E.g. I don’t enjoy doing parallelism in Java. It’s just… complicated.

How’s reference counting slower than GC? I’m genuinely curious about this.

First of all, let me say that that I understand your feelings. Generally, crappy software has managed to totally eat up all those incredible progress in hardware, with the result that our programs now do basically do the same things like 30 years ago, but need a million times more powerful hardware for the same task.

And JS was a historical mistake (it’s great if you keep in mind how fast it was developed, though).

I learned a few dozens of programming languages over the decades, and there are only very few which I deem every developer should really know;

C, because it’s at the same time very simple, readable, and powerful, and it is more or less how computers actually work.

Python, because it is so incredible productive. If I need something working fast, I prefer Python. The runtime is “slow”, but the programming process itself is rapid.

SQL. It’s even more high level than Python, but incredible fast on modern RDBMs, probably because they are using DOD principles.

And finally, Zig. It gives you the power of C, but is much safer and I just enjoy the spirit of it.

And I have to admit that even Java is becoming usable now. What disturbs me about Java is that many Java developers seem to think that OOP is identical to Java.

I think it is important that developers know and actually use at least one OO language and one that’s not OO. And maybe a functional language as well (didn’t click for me, though).

Now to your question:

Ref counting is usually slightly slower than GC. I reckon that’s partly because most objects are far more often read than updated, but with RC you still have to increment and decrement the counter all the time, so the pages are effectively always R/W.

But, RC is much more predictable than GC and uses less memory. It’s only the combination of the GIL, the RC memory management, and most of all its extreme dynamic nature which makes Python “slow”.

Nevertheless, the Python ecosystem, starting with its highly optimized standard library (all the heavy lifting is written in C anyway) makes it fast enough for a lot of tasks (eg think of Home Assistant), and the ease of development is unbeatable.

If top performance is crucial, clever manual memory management, eg using arenas, can be much faster than automatic memory management. However, a lot of problems are I/O bound anyway, so the internal performance of the processing code is not as important as one might think.

In my company, I am “the performance guy”. And I love Python. That’s not a contradiction.

Always have a few different tools in your toolbox, and know your tools. Then choose the right tool for the task at hand.

2 Likes

In some ref counting impls, they’re slower because the ref count is not stored adjacent to the value, so there are separate cpu cache misses for accessing the ref count. Modern ref counted values (Swift and Rust anyway) store them adjacently.

1 Like

This is an excellent counter point to the idea that “if you’re using a GC language, you must not care about performance.” That’s not always true, and how often is it true is unknown. It’s not a good assumption to make.

1 Like