Manual Release

Dear Computer

Chapter 11: Managing Memory

Manual Release

Some languages so thoroughly abstract away the hardware of the computer that we may forget it's there. C and C++ do not. By design, these languages operate under the assumption that programmers want direct control of the machine. With that control comes a lot of responsibility. In particular, programmers must manually shepherd heap memory allocations.

Catch and Release

In C, we allocate heap memory by calling malloc with the requested number of bytes. Our request is handled by the allocator, which is part of the C runtime library linked to our executable. The allocator tracks a list of free memory blocks. It searches through this list until it finds a block with enough bytes to satisfy our request. It then carves out the block from its free list and returns a pointer to it.

When we no longer need the allocated memory in our program, we must manually release it by calling free on the pointer. The responsibility of freeing memory lies entirely with us, the programmers. If we do not free the memory, the data will continue to wastefully occupy RAM until the program finishes or crashes after running out of memory. When the host operating system is low on memory, programs run very slowly as inactive memory segments are temporarily written to disk to open up more space.

If a program runs for only a short time and consumes little memory, we will likely see no adverse effects from not freeing our allocations. The operating system will get the memory back when the program finishes. But small programs sometimes imperceptibly become big ones that serve as the foundation of critical infrastructure. To prevent disaster, we should free our dynamically-allocated memory.

Allocator Implementation

Managing stack memory is relatively easy. New stack frames are allocated at the top when a function is called. When the function returns, the topmost frame is released. The stack only changes at one end, and the changes only happen on calls and returns.

Heap memory is not so simple. The lifetime of each allocation is independent and unpredictable. One statement may allocate a block that persists for the duration of the program, while the next statement allocates a block that is released almost immediately. An allocator may end up running out of memory if it doesn't anticipate this haphazard schedule.

Suppose the heap is only 10 kilobytes in size. A first malloc call requests 4 kilobytes, and the allocator gives it a block at the start of the heap. A second malloc call requests 3 kilobytes, and the allocator gives it a block just after the first allocation. Then the first block is released with free. The remaining allocated block is preceded by a 4-kilobyte block and succeeded by a 3-kilobyte block. Even though a total of 7 kilobytes are free, a request for data just slightly over 4 kilobytes will fail. There's no single block that can meet the request.

When contiguous memory is broken up by allocations, the remaining free memory is split or fragmented into smaller chunks that may not be large enough to satisfy a request. The C language specification does not mandate how malloc and free are implemented, but there are several strategies in use that prevent and recover from fragmentation:

Programming languages generally insulate programmers from the inner workings of the allocator. Usually we don't need to concern ourselves with fragmentation, but C and C++ give us the option to plug in our own allocator if we need to speed up our program.

Memory Safety

An allocator needs to maintain information about its allocated and free blocks in order to do its job. It could reserve some space on the heap to store this information, but predicting how much space will be needed for this metadata is hard. Instead, allocators distribute this bookkeeping information in the allocated and freed blocks themselves.

One piece of information that is commonly stored in a block is its size. When we call malloc, the allocator iterates through its free list and checks each block's size to see if it can satisfy our request. When we call free, the allocator examines the size to see if free blocks are adjacent and merges them if possible.

Each allocated block must be big enough to store both our data and this metadata. If we ask for \(n\) bytes, the allocator searches its free list for a block that has at least \(n + \text{sizeof(metadata)}\) bytes. The metadata is commonly stored at the beginning of the allocated block, and the pointer that we receive from malloc points at the memory just after the metadata. When we call free, the allocator looks at the bytes preceding the pointer we pass it to find the metadata.

If we write into the memory cells preceding the pointer that malloc returns, we will violate the integrity of the allocator's distributed data structure. This will in turn wreak havoc when we free the block later on. The corrupted size may break the merging algorithm. If the recorded size is increased by our wild writes, the freed block will overlap with other blocks, and some bytes may end up being allocated twice.

Even if we don't violate the allocator's metadata, there are other ways for memory to be abused:

So, the manual memory management of C and C++ presents two kinds of dangers:

Both manual memory management and pointers are integral features of C and C++. The designers of these languages provide these features in the name of performance. But the designers of other languages have presented safer alternatives.

← A Place for EverythingTracing →