Lists with malloc with new and free

When people say that there are 'memory unsafe languages', this API (malloc and free) amounts to most of the validity of what they mean, and even if you don't accept Rust's memory safety, you can pick a better way to manage memory than this. If you've no idea what I'm talking about please read https://www.rfleury.com/p/untangling-lifetimes-the-arena-allocator immediately.

Anyway, consider the following program:

package main

import "core:fmt"

// A list is nil or a pointer to one of these.
Cons :: struct($T: typeid) {
	car: T,
	cdr: ^Cons(T),
}

cons :: proc(car: $T, cdr: ^Cons(T)) -> (result: ^Cons(T)) {
	result = new(Cons(T))
	if result == nil {
		fmt.panicf("memory exhausted in cons")
	}
	result.car = car
	result.cdr = cdr
	return
}

// Free every cell of the provided list.
free_list :: proc(list: ^Cons($T)) {
	list := list
	for list != nil {
		next := list.cdr
		free(list)
		list = next
	}
}

// Print e.g. [1, 2, 3, 4] given a list of those values
println :: proc(list: ^Cons($T)) {
	list := list
	fmt.print('[')
	for list != nil {
		fmt.print(list.car)
		if list.cdr != nil {
			fmt.print(", ")
		}
		list = list.cdr
	}
	fmt.println(']')
}

// Return the last element of a list, if it exists.
last :: proc(list: ^Cons($T)) -> Maybe(T) {
	list := list
	for list != nil {
		switch list.cdr {
		case nil:
			return list.car
		case:
			list = list.cdr
		}
	}
	return nil
}

// Build e.g. the list [1, 2, 3, 4], given 4
iota :: proc(n: int) -> (result: ^Cons(int)) {
	n := n
	for n > 0 {
		result = cons(n, result)
		n -= 1
	}
	return
}

main :: proc() {
	list := cons(1, cons(2, cons(3, nil)))
	fmt.println(last(list).? or_else -1)
	free_list(list)

	list = iota(5)
	println(list)
	free_list(list)
}

Compared to lists/stack-allocated this might seem to have a new problem--that failure is possible, with a panic in cons, but failure was already possible with stack allocations! There, it was just pretty sure to result in a stack overflow and segfault. On modern Linux systems with overcommit the practical 'failure condition' is murky even with that nice panic, as new can claim to succeed when memory isn't actually available, and the Linux OOM killer can start making life unpleasant while this program allocates unimpeded. On a server with extremely heavy I/O that almost always hits filesystem cache, nightmarish conditions are possible even without the OOM killer kicking in as memory pressure reduces the cache and that I/O starts hitting slow disks.

Low memory conditions are pretty bad all around, in any language.

Anyway, we can confirm that dynamic allocation happens in this program, and also confirm how it responds to memory exhaustion, by setting the nil allocator:

$ odin run malloc1.odin -file -default-to-nil-allocator
malloc1.odin(14:7) Panic

Great! Now, running it with the default context allocator:

$ odin run malloc1.odin -file 
3
[1, 2, 3, 4, 5]

This is pretty standard malloc/free code. From main's perspective all that's changed is that there's now a convenience function to construct cons cells, and there's explicit freeing of lists. Despite the insistence above that this is a bad way to manage memory, it's a very popular way to manage memory and you could certainly make a useful and even a correct application that manages memory like this.

For fun, here's that cons-wasteful program again, this time 100mil_malloc:

package main

import "core:fmt"
import "core:time"

// A list is nil or a pointer to one of these.
Cons :: struct($T: typeid) {
	car: T,
	cdr: ^Cons(T),
}

// Free every cell of the provided list.
free_list :: proc(list: ^Cons($T)) {
	list := list
	for list != nil {
		next := list.cdr
		free(list)
		list = next
	}
}

// Return the last element of a list, if it exists.
last :: proc(list: ^Cons($T)) -> Maybe(T) {
	list := list
	for list != nil {
		switch list.cdr {
		case nil:
			return list.car
		case:
			list = list.cdr
		}
	}
	return nil
}

// Build e.g. the list [1, 2, 3, 4], given 4
iota :: proc(n: int) -> (result: ^Cons(int)) {
	n := n
	for n > 0 {
		cell := new(Cons(int))
		if cell == nil {
			fmt.panicf("exhausted memory in iota on n=%d", n)
		}
		cell^ = Cons(int){n, result}
		result = cell
		n -= 1
	}
	return
}

main :: proc() {
	start := time.tick_now()

	list := iota(100_000_000)
	fmt.println("iota:", time.tick_since(start))

	fmt.println(last(list))
	fmt.println("last:", time.tick_since(start))

	free_list(list)
	fmt.println("free:", time.tick_since(start))
}

With output:

$ odin run 100mil_malloc.odin -file -o:speed 
iota: 5.331800493s
100000000
last: 5.768355374s
free: 7.13808926s

That's only about seven times slower than the static option, but it also uses 4.5 GB of RAM. Note that free_list certainly isn't free, needing 1.3s just to clean up. In a batch program like this you could save that time by just not calling free_list. The only casualty is that memory checkers will be harder to use, as they'll report this as a leak. But as soon as you're not just exiting immediately after cleanup, you start to have to take 1.3s to do it.

Why is freeing so expensive?!

You have to involve the allocator 100 million times, once per cell of the list. So it's really lists that are more expensive. But, even if you use an array instead, once the array contains RAII objects (or objects implementing the Drop trait in Rust), you have to pay a 100,000,000x cost one way or another. You might appreciate a language that didn't push you into such patterns of memory management.

Slower than Python?

Even a completely naive Python program is faster and uses less memory than this. Taking <6s and 3.7 GB of RAM:

def iota(n):
    return [i for i in range(1, n+1)]

list = iota(100_000_000)
print(list[-1])

How is that possible? Python's not using linked lists. Here's a comparable Odin program:

package main

import "core:fmt"

iota :: proc(n: int) -> (result: [dynamic]int) {
	for i in 0 ..< n do append(&result, i+1)
	return
}

main :: proc() {
	list := iota(100_000_000)
	fmt.println(list[len(list)-1])
	delete(list)
}

This runs in about 830ms, needing 1 GB of RAM.

More on linked lists

Linked lists aren't associated with garbage collection (Lisp, SML) by accident: they permit constructions like circular lists, or multiple 'heads' of lists sharing the same 'tail', that greatly complicate the task of a function like free_list. For example, here's a program that prints a list forever:

package main

import "core:fmt"
import "core:time"

// A list is nil or a pointer to one of these.
Cons :: struct($T: typeid) {
	car: T,
	cdr: ^Cons(T),
}

cons :: proc(car: $T, cdr: ^Cons(T)) -> (result: ^Cons(T)) {
	result = new(Cons(T))
	if result == nil {
		fmt.panicf("memory exhausted in cons")
	}
	result.car = car
	result.cdr = cdr
	return
}

// Print e.g. [1, 2, 3, 4] given a list of those values
println :: proc(list: ^Cons($T)) {
	list := list
	fmt.print('[')
	for list != nil {
		fmt.print(list.car)
		if list.cdr != nil {
			fmt.print(", ")
		}
		list = list.cdr
		time.sleep(500 * time.Millisecond) // !
	}
	fmt.println(']')
}

main :: proc() {
	list := cons(1, cons(2, cons(3, nil)))
	list.cdr.cdr.cdr = list
	println(list)
}

Usage:

$ odin run circular.odin -file
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, ... continues forever

Circular lists can come up even when you don't want them, as in this bug in the Apache webserver:

  1. an apache module tries to spawn a subprocess
  2. to do that, it uses fork() to separate itself off into a new process
  3. it then, before calling exec(), dutifully cleans up some internal Apache datastructures
  4. ... which datastructures included some circular lists, because other threads were modifying those lists concurrently with the fork, and those modifications got interrupted.
  5. result: instead of spawning a subprocess, Apache spawned a process that consumed 100% CPU doing nothing to a circular list