27 comments

[ 2.0 ms ] story [ 56.2 ms ] thread
Nice read, although a small section on how it's implemented exactly would've been nice.
Rust’s interface for using different allocators is janky, and I wish they had something like this, or had moved forward with the proposal for the mechanism for making it a part of a flexible implicit context mechanism that was passed along with function calls.

But mentioning the borrow checker raises an obvious question that I don’t see addressed in this post: what happens if you try to take a reference to an object in the temporary allocator, and use it outside of the temporary allocator’s scope? Is that an error? Rust’s borrow checker has no runtime behavior, it only exists to create errors in cases like that, so the title invites the question of how your this mechanism handles that case but doesn’t answer it.

I feel like "solved" is a strong word for what's described here. This works for some - possibly even many - scenarios, but it does not solve memory lifetime in the general case, especially when data from different scopes needs to interact.
I'm struggling to understand how this has anything to do with borrow checking. Borrow checking is a way to reason about aliasing, which doesn't seem to be a concern here.

This post is about memory management and doesn't seem to be concerned much about safety in any way. In C3, does anything prevent me from doing this:

  fn int* example(int input)
  {
      @pool()
      {
          int* temp_variable = mem::tnew(int);
          *temp_variable = input;
          return temp_variable;
      };
  }
"No more [...] slow compile times with complex ownership tracking."

Presumably this is referring to Rust, which has a borrow checker and slow compile times. The author is, I assume, under the common misconception that these facts are closely related. They're not; I think the borrow checker runs in linear time though I can't find confirmation of this, and in any event profiling reveals that it only accounts for a small fraction of compile times. Rust compile times are slow because the language has a bunch of other non-borrow-checking-related features that trade off compilation speed for other desiderata (monomorphization, LLVM optimization, procedural macros, crates as a translation unit). Also because the rustc codebase is huge and fairly arcane and not that many people understand it well, and while there's a lot of room for improvement in principle it's mostly not low-hanging fruit, requiring major architectural changes, so it'd require a large investment of resources which no one has put up.

The core benefit of the borrow checker is not "make sure to remember to clean up memory to avoid leaks." The core benefits are "make sure that you can't access memory after it has been destroyed" and "make sure that you can't mutate something that somebody else needs to be constant." This is fundamentally a statement about the relationship between many objects, which may have different lifetimes and which are allocated in totally different parts of the program.

Lexically scoped lifetimes don't address this at all.

I really do not see the benefit of this over C++ destructors and or facilities like `unique_ptr` and `shared_ptr`.

@pool appears to be exactly what C++ does automatically when objects fall out of scope.

Ok, now give me an example of a resource manager (eg in a game) that has methods for loading resources into memory and also for releasing such resources - all of a sudden if a system needs to give away pointer access to its buffers, things become more complicated and arena allocators are not enough.
Seems overly simplistic and doesn't seem to cover extremely common cases such as a thread allocating some memory then putting it into a queue to be consumed by other threads which then eventually free the memory, or any allocation lifetime that isn't simply the scope of the enclosing block.
This literally doesn't solve any actual problems. If all memory allocation patterns were lexical this is the most easy and most obvious thing to do. That is why stack allocation is the default and works exactly like this.
Smart compilers already do this with escape analysis.
Is this different from NSAutoreleasePool, which has been around for over 30 years?
I don't think technical writing needs this kind of rage-bait. They could have presented just the features of the language. Borrow-checker is clearly unrelated here.
The post doesn't even mention how it works/improves DX in a multi-threaded environment, borrow checkers are targeting specifically that use case.
The post's title is quite hyperbolic and I don't think it serves the topic right.

Memory arenas/pools have been around for ages, and binding arenas to a lexical scope is also not a new concept. C++ was doing this with RAAI, and you could implement this in Go with defer and in other languages by wrapping the scope with a closure.

This post discusses how arenas are implemented in C3 and what they're useful for, but as other people have said this doesn't make sense to compare arenas to reference counting or a borrow checker. Arenas make memory management simpler in many scenarios, and greatly reduce (but don't necessarily eliminate - without other accompanying language features) the chances of a memory leak. But they contribute very little to memory safety and they're not nearly as versatile as a full-fledged borrow checker or reference counting.

funny thing is that Malloc also behaves like an arena. When your program starts, Malloc reserves a lot of memory, and when your program ends, all this memory is released. Memory Leak ends up not being a problem with Memory Safety.

So, you will still need a borrow checker for the same reasons Rust needs one, and C/C++ also needed.

I never heard about this language, so I quickly looked through the docs and here is what I didn't like:

- integers use names like "short" instead of names with numbers like "i16"

- they use printf-like formatting functions instead of Python's f-strings

- it seems that there is no exception in case of integer overflow or floating point errors

- it seems that there is no pointer lifetime checking

- functions are public by default

- "if" statement still requires parenthesis around boolean expression

Also I don't think scopes solve the problem when you need to add and delete objects, for example, in response to requests.

Wow, this is such a fascinating concept. The syntax can’t stop reminding me of @autoreleasepool from ObjC. I’ll definitely try this out on a small project soon.

Also, since D lang usually implements all kinds of possible concepts and mechanism from other languages, I would love to see those being implemented aswell! D already has a borrow checker no so why not also add this, would be very cool to play with it!

It took me some time to collect my thoughts on this.

One: I don't believe they have solved use-after-free. Marking memory freed and crashing at runtime is as good as checked bounds indexing. It turns RCE into DOS which is reasonable, but what would be much better is solving it provably at compile time to reject invalid programs (those that use memory after it has been deallocated). But enough about that.

I want to write about memory leaks. Solving memory leaks is not hard because automatically cleaning up memory is hard. This is a solved problem, and the domain of automatic memory management/reclamation aka garbage collection. However I don't think they've gone through the rigor to prove why this is significantly different than say, segmented stacks (where each stack segment is your arena). By "significantly different" you should be able to prove this enables language semantics that are not possible with growable stacks - not just nebulous claims about performance.

No, the hard part of solving memory leaks is that they need to be solved for a specific class of program: one that must handle resource exhaustion (otherwise - assume infinite memory; leaks are not a bug). The actual hard thing is when there are no memory leaks in the sense that your program has correctly cleaned itself up everywhere it is able and you are still exhausting resources and must selectively crash tasks (in O(1) memory, because you can't allocate), those tasks need to be able to handle being crashed, and they must not spawn so many more tasks as to overwhelm again. This is equivalent to the halting problem, by the way, so automatic solutions for the general case are provably impossible.

I don't believe that can be solved by semantically inventing an infinite stack. It's a hard architectural problem, which is why people don't bother to solve it - they assume infinite memory, crash the whole program as needed, and make a best effort at garbage collection.

All that said, this is a very interesting design space. We are trapped in the malloc/free model of the universe which are known performance and correctness pits and experimenting with different allocation semantics is a good thing. I like where C3 and Zig's heads are at here, because ignoring allocators is actually a huge problem in Rust in practice.

I am having a hard time to connect the title with what they present. Is this just freeing memory when the program leaves the current context? How that matches the rust lifetime borrow checker?… this is a defer function that frees whatever has been allocated within a given scope, not that useful, unless i am missing something…
Why add a default '@pool' for main?

Operating systems free resources upon process exit. That's one of the fundamental responsibilities of an OS. You can use malloc without free all you want if you are just going to exit the process.

Whether I like this feature or not depends on the low-level details of how @pool behaves and whether and how I can control it. I can't tell what @pool is going to do to my program, unlike when I'm using an arena (or another allocator) directly.

It seems that @pool is providing context the the allocator function(s), but is the memory in the pool contiguous? What is the initial capacity of the pool? What happens when the pool needs to grow?

I think I prefer the explicit allocator passing in Zig. I don't need to ask these questions because I'm choosing and passing the allocator myself.

This does not seem like it solves any hard problem. It's just a 10x better alloca() with allocator integration?
Alloca would not allow you to pass data from the current scope up to a parent scope.
I did say 10x better alloca. I'm saying that's not good enough, and seems very narrow.

You could do this in C++, with RAII stacked arena allocators. Though it's unclear to me from the blog post if C3 would prevent returning a pointer to memory in the top most pool. C++ probably wouldn't help you prevent that.