Learning memory management - looking for review/critique

Hi all, Odin is my first exposure to manual memory management. When learning something I take notes as I go and then write these up into a single peice so it cements in my mind and I can refer back to it.

I would therefore appreciate it if anyone could read any or all of the following and correct me where I’ve gone wrong/misunderstood, or provide additional clarification to anything below? Appreciate your time, thank you

Memory

Areas of memory

Memory for our program lives in one of three conceptual areas:

  • The global space reserved for fixed data known at compile time, e.g. constants, procedures

  • The stack, where memory is allocated for variables within the scope of each procedure, and that memory is freed upon leaving the function automatically. This is why if you try and return a pointer to a variable defined in the scope of a procedure, you get warned about it (dangling pointer) - the stack memory it points to is no longer deterministic after the stack frame is wiped.

main :: proc()
{
    b: ^int = mytest()
}

mytest :: proc() -> ^int {
    a: int = 1
    return &a
}
  • The heap, where we allocate memory for data we want to persist beyond a single stack frame. Memory allocated here needs to be tracked so we can free it; memory that isn’t freed appropriately is referred to as a memory leak. Often this can go un-noticed since a bit of unused extra memory here and there won’t impact your program, but when you start allocating memory repeatedly (say in a loop, or because your program runs for a long time), then the memory usage of the program can grow and grow and the results are undesirable for obvious reasons.

Manual memory management

There are two main ways of manually allocating memory:

  • new and free - For allocating memory for a given type, typically a single value
  • make and delete - For constructing a family of particular first-class data types: array, slice, dynamic, map

Context

There is an implicit context that gets passed into every function, and contains among other things:

  • context.allocator - a general purpose heap allocator used to allocate and deallocate memory imperatively
  • context.temp_allocator- a growable “arena” allocator used for short-lived data. You build up your allocations and then call free_all() to deallocate them all in one fell swoop. This is very useful in game loops, for instance.

When we call something like new(i32) or delete(myarr), it’s sugar for new(int, context.allocator) etc. So, if we change the allocator we’re using by reassigning context.allocator, we can still use new() and it’ll respect the new allocator.

When do allocations happen?

We can either choose allocate memory on the stack, or the heap. Or sometimes, it’s a mixture of both!

// Allocate an int on the stack, zero-val'd. Gets automatically freed.
a: i32
// Allocate an int on the heap and return a pointer to it. Needs to be freed.
a := new(i32)
// Allocate an array on the stack. Gets automatically freed.
a := [3]int{1, 2, 3}
// Allocate an array on the heap, return pointer. Needs to be freed.
a := make([3]int, 3)

... etc

// Allocate a dynamic on the stack, but the data will be on heap. Thus it still needs to be freed by hand.
a := [dynamic]int{1,2,3}
// Allocate a dynamic on the heap, and the data is on the heap. Needs to be freed.
a := make([dynamic]int, 3)

// Same behaviour for dynamic applies to maps too

Allocation behaviour of first-class types

So in general, we can choose to allocate any first-class type on the stack or the heap, BUT dynamic and map have implicit allocations regardless of if we do:

a := [dynamic]int{1, 2, 3}

OR

a := make([dynamic]int, 3)

However, in either case, we need to make sure we return the result out of the procedure or the allocated data will no longer be referenced and that allocated memory will sit around for the program’s lifetime unused: a memory leak. This is also obviously the case with heap-allocated basic types.

Ultimately, everything is passed in and out by copy, but with heap-allocated data you either get a pointer to that data you can copy out, or the struct contains a pointer to the data anyway, so it basically has reference semantics without the need to expressly return a pointer.

When to allocate/deallocate memory?

Ultimately all memory needs to be allocated and deallocated. The difference is that we can consider the stack an automatic memory manager in that we don’t have to explicitly deallocate if we don’t care about the data outside of a procedure’s scope. For everything else, there’s the heap.

We should allocate memory for data on the heap when:

  • We need it live longer than the current stack frame it was allocated in
  • We need to modify the data directly in memory rather than a copy of the data when passing it into procedures

We should deallocate memory for data on the heap:

  • When we no longer have use for it at that point in our program
  • To clean up data types that implicitly allocate memory (map, dynamic and potentially other package’s data/procs)

Some data may just want to last the entire lifetime of the program, in which case we can just let the OS clean up on program exit, but for shorter lived objects, we’ll need to allocate then free at some point.

What about structs?

Structs are copied like everything else, but obviously if the struct contains a field that is a dynamic or map or pointer to some heap memory, then if that struct gets popped off the stack, the memory created on heap will still remain. So we need to make sure we free the relevant fields - this seems like a non-trivial problem because you can’t pass a whole struct to something like free()

QUESTION: Look into how we would deallocate a struct full of pointers cleanly? Is there a well-known pattern? Do we build a custom destructor for it?

1 Like

make doesn’t allocate a dynarray on the heap - it’s actually just an initializer procedure for a dynamic array or map, so the end effect is largely the same.
new([dynamic]int) would allocate the array itself dynamically, but that’s generally not useful because a dynarray already dynamically allocates anyway.

As for when you should allocate memory, it is worth noting it’s really less about returning something (you could just pass in a pointer to write to instead if you want it to last more than the current stack frame; most init procs do this, [as well as any that take a slice to write to like fmt.bprintf()]) and more about dynamically sized, or large.
The stack is small (1M or 8M depending on platform) and because it’s managed in a push-on, pop-off fashion it [can be more limited in what you can do with it than you want.]

The stack also doesn’t free things when you return - the stack is literally just one block of memory with a cursor that moves when you call or return from functions.
It just reuses the part where stuff was popped off before when you push more stuff on later.

The key feature of manual memory management is that it’s up to you to spot what your program wants to do / how you think to design it, and consider if there’s ways to make your memory allocation simpler – that will mean that memory is much easier to reason about, and thus easier to not bollock it up.
Ultimately, if you have a struct with 20 dynarrays and maps that have allocated memory, you must either come up with a way to free them all in one go somehow (using a particular allocator designed for that; letting the OS do it on exit, etc.) or you just have to go and delete each one, one at a time, explicitly.
This “destructor” approach, when you have loads to stuff to clean up, can be an indication that you’re not really thinking that hard about the data in your program – but it’s ultimately all tradeoffs; maybe it just doesn’t matter that much for your code right now.
It is however a valid way to approach it; ultimately there’s no ‘right’ way – it’s about matching the code to your usecase and solving your problem.
strings.builder_destroy() is such a ‘destructor’ – though it’s not often used because you generally want the string to persist for a bit, and then you’ll delete(the_string) later instead.

3 Likes

Technically, there’s more than one variant of this. Global variables are usually in a section that’s read/write (i.e. you can modify it), but constant data such as string literals can also be in a section that is marked as read-only. Trying to modify those will most likely result in a crash.

Another variant would be the memory that the actual program code is in. It’s also read-only, but additionally marked as executable, which allows you to actually run code from it. Trying to call code in a non-executable section will, similarly, result in a crash. This isn’t really a concern unless you’re trying to generate code at run-time, e.g. writing a JIT compiler, which is advanced territory.

Fixed-size arrays are just a basic type, and can’t be created through make. You’d just use new for those. The others are correct.

Not necessarily. You’d pass a pointer to the data you want to potentially be able to modify, but it doesn’t necessarily have to a pointer to something on the heap. An example:

foo :: proc (i: ^int) {
    i^ = 123
}

bar :: proc () {
    a : int
    foo(&a)
    assert(a == 123) // passes
}

A pointer just points to a location in memory. This can be in any of the data regions you mentioned, including on the stack. You do need to be aware of how long those pointers remain valid, though; if, for instance, foo in the example were to store the i argument somewhere (say, in a global), the data it points to would no longer be valid after bar exits (as you mentioned in stack section). Thus, if you tried to access it later, it could point to something else on the stack, unused memory in the stack, or even a no-longer-accessible region of memory (say, if the thread has exited). There is no practical way to know this based on the pointer alone.

You would need to build your own destructor for it. The compiler doesn’t have enough information to know how to handle it for you. A string could point to allocated memory, in which case you might want to free it (unless you’re using it somewhere else), or it could point to a string literal, in which case trying to free it would be bad. It could also have been allocated using a different allocator than is currently on the context, and trying to free it with the wrong allocator would also be bad. The same applies to any other allocated memory too. There’s no way for the compiler to know how to handle freeing the data, so it’s up to you to handle it.

2 Likes

Also, there’s one additional case I’d add to this list: if the data is large. The stack is fairly limited, usually around a few MB, so trying to keep a very large amount of data, for example [1_000_000]int, can easily overflow the stack. This is cumulative too, so you don’t have the whole stack to yourself in a proc–the proc that called you is also using stack space, as is the proc that called it, and so on.

Odin will generate a warning for any local variable larger than 256 KB or so, I think.

2 Likes

Gotcha, although I’m a bit unsure about when I would init a dynamic with a literal and when I would use make() in that case?

1 Like

The dynamic literal will always use context.allocator as it’s backing allocator, make([dynamic]T) is essentially the same thing but allows you to specify the allocator if needed.

I’d just not use dynamic literals, makes it harder to spot where you allocated and what you should be cleaning up. Also, if you have a custom allocator set up in a call for context.allocator you might be in for a nasty surprise…

package bad_dynamic_literal

import "core:fmt"
import virt "core:mem/virtual"

okay :: proc() -> [dynamic]int {
	xs := [dynamic]int{1,2,3,4,5}

	arena: virt.Arena
	if virt.arena_init_growing(&arena) != nil {
		panic("oops")
	}
	defer virt.arena_destroy(&arena)
	context.allocator = virt.arena_allocator(&arena)

	do_some_compute_with_xs(&xs)

	return xs
}

fault :: proc() -> [dynamic]int {
	arena: virt.Arena
	if virt.arena_init_growing(&arena) != nil {
		panic("oops")
	}
	defer virt.arena_destroy(&arena)
	context.allocator = virt.arena_allocator(&arena)

	xs := [dynamic]int{1,2,3,4,5}
	do_some_compute_with_xs(&xs)
	return xs
}

main :: proc() {
	xs := okay()
	defer delete(xs)
	fmt.println(xs)

	ys := fault()
	defer delete(ys)
	// segfault, allocator is gone, memory is gone
	fmt.println(ys)
}

Note: dynamic literals are not inherently bad, and as long as you don’t mess around with allocators it’s easy to track when to delete them, otherwise tiny shift in the logic may result in :fire: :firecracker:

4 Likes

I would generally always just use make or nothing (array: [dynamic]int)

Dynamic literals (like [dynamic]int{1, 4, 9}) one of the few implicit allocations in Odin, in a language which otherwise doesn’t allow them.
They’re a bit of syntax sugar that trades off no implicit allocations.

It’s a good habit, I think, to just not use them, even when you can.
You can just do x: [dynamic]int; append(&x, 1, 4, 9} to get the same effect.

The only actual difference between make and a mere declaration incidentally, is that make will set the allocator field of the array/map to context.allocator (or whatever you gave it) immediately. Merely declaring one simply memsets the structure to zeroes.
That works fine because append will set the allocator field if it isn’t already, and the zeroing means that the capacity field is 0, which is not treated any differently from “having insufficient capacity.”

3 Likes

I think that the notion of Heap and Stack are virus misconceptions, they are literally fake since that is not a computer works, The Operating System provides us with “Heap” memory and “Stack” memory but I’m pretty sure that the real thing that is happening is only VirutalAllocs.

To keep it short:
There is only the concept of reserved memory that you ask to the OS and you make up the rules in how to shape the memory and windows HeapAlloc (syscall) is just another rule that you can choose not to use (since it maximizes fragmentation and versatility).

I dont know if this holds for linux. And I am kinda new to serious programming.

While true to an extent, it’s not a misconception by any stretch–it’s a usage pattern. All of memory is just an address space of bytes, but that doesn’t make the concepts any less applicable. There’s nothing stopping you from treating the stack like linear memory, because it still is; generated code will often just use pointer offsets relative to the stack pointer, and adjust the stack pointer in fixed increments rather than pushing/popping individual values. If you wanted to, you could ask the OS for a block of memory and then change your stack pointer to point into that new memory region. That is, in fact, an approach to implementing coroutines. But most existing code (and code generation) is built around the idea of stack memory, and thus the concept is still applicable.

On top of that, the idea of the stack does exist in the hardware itself, even if it’s not a separate region of memory. x86 has a dedicated stack pointer register rsp, along with push/pop instructions to push/pop a single value (read/write a value and adjust the stack pointer), call to push the instruction pointer and jump, and ret to pop the instruction pointer from the stack. There’s nothing special about the memory they interact with, it’s all pointers in the end. A “push” is just “adjust the stack pointer and write a value at the new location”, a “pop” is just “read the value at the current location and adjust the stack pointer”–but the concept isn’t “fake”, it’s just a common and useful way of interacting with memory.

It’s also true that it is the OS that provides the memory to you, in the same way that the OS provides everything to you. The OS loads your code, assigns memory for the various sections, facilitates any interaction with the outside world, and orchestrates its execution. There’s basically nothing involved in running application code that the OS isn’t involved in. That’s its job.

2 Likes

Sure, and that is why I qualified the start of my post with “conceptual areas” wrt to memory. For learning the basics of manual memory management and lifetimes, abstractions like the stack and the heap are quite sufficient! :slight_smile: