|
MP2: My, oh, my, oh, malloc!
|
In this MP, you will re-implement the function heap-memory function calls malloc(), calloc(), realloc(), and free() in much the same way that valgrind re-implements many C-calls to understand how your program is using memory. Just like valgrind, you will be able to use almost any existing program with your new memory functions: this includes utility programs such as "ls" text editors like "vi", or any other programs that runs on Linux.
Your first goal will be a basic correct solution. Then, you can move on to improving your allocator's speed and/or efficiency of memory use. And for fun, you'll be able to submit your malloc to a class contest.
That's it! You now know the requirements for this MP. Note that we haven't specified how you should implement your memory allocator. You've learned a lot of different algorithms and data structures in CS 241 for how systems can manage memory and it's up to you to choose how to do it.
But don't worry; we'll provide some guidance. In the rest of this document we supply background to help you get started, and then discuss grading and the contest.
Memory allocation might seem a bit magical. But you might be surprised to find out how easy it is to write a memory allocator which actually works in some primitive way. Let's do it now. First, malloc():
void *malloc(size_t size)
{
return sbrk(size);
}
How does that work? Malloc gets a request for size bytes of memory. It then calls sbrk(size). This is a system call that asks the operating system to increase the heap by size bytes. sbrk() returns a pointer to this new memory, and away we go. Now, how about free?
void free(void *ptr)
{
}
That's right! It's empty. In a very limited way, this is a "correct" implementation. You could write programs that use this malloc and free. So what's wrong with this implementation?
To sum up, it's actually quite easy to request new memory from the operating system. Your memory allocator's other task is harder: do the necessary "bookkeeping" to track what chunks of memory are allocated, and where the free holes are.
So how do we do this "bookkeeping"? Let's see what information we need. When free(ptr) is called, its job is to free the block of memory starting at ptr. But how many bytes long was that block? Sadly, we are told where the memory segment starts but not how big it is. Similarly, take a look at realloc(ptr, size): it must "define a new memory segment, copy the old memory segment content to the new memory segment, free the old memory segment, and then return the new memory segment". We find that to do that, we need three pieces of information:
Sadly, realloc() has as arguments only the old memory location and the new memory size. So to sum up, for both free() and realloc() we must store some bookkeeping information behind the scenes when the memory is first allocated with malloc(). Specifically, we need some way to figure out the memory segment size given only the memory location ptr.
Let's define some structure to correlate the old memory location to the old memory size; that way we can look it up later:
typedef struct _mem_dictionary...and let's use global variables to keep a count of how many records are part of this structure, as well as a pointer to the first element of the dictionary:
{
void *addr;
size_t size;
} mem_dictionary;
mem_dictionary *dictionary = NULL;With these data structures set up, we can make simple modifications to malloc() so it initalizes the dictionary array when it starts up and stores the (memory address)->(size) record every time malloc() is called:
int dictionary_ct = 0;
void *malloc(size_t size)
{
void *return_ptr = sbrk(size);
if (dictionary == NULL)
dictionary = sbrk(1024 * sizeof(mem_dictionary)); /* Note the use of sbrk() and not malloc(), since malloc() would create an infinite loop of calling malloc(). */
dictionary[dictionary_ct].addr = return_ptr;
dictionary[dictionary_ct].size = size;
dictionary_ct++;
return return_ptr;
}
void *realloc(void *ptr, size_t size)
{
if (!size) { free(ptr); return NULL; }
void *return_ptr = malloc(size);
if (!ptr)
return return_ptr;
size_t old_size = 0;
int i;
for (i = 0; i < dictionary_ct; i++)
if (dictionary[i].addr == ptr)
old_size = dictionary[i].size;
memcpy(return_ptr, ptr, old_size);
free(ptr);
return return_ptr;
}
...and, at this point, you have something closer to a working program. One of the remaining problems is that free() still does not mark memory that was allocated to the expanding stack to be reused (eg: a program that grabs 1 MB and then frees the 1 MB will run out of memory since each 1 MB given isn't the same 1 MB, but a different 1 MB). Also, the dictionary contains a fixed number of entries that will result in a segfault if malloc() is called over 1,024 times. Also, malloc() has still done nothing to attempt to intelligently reuse memory—which is where algorithms like First Fit and Best Fit would be used. Other inefficiencies exist in the code, too, that you may want to optimize as part of the contest element of this code.
Note that we do not mean to imply that you should even begin with the dictionary-based strategy above. For example, a common trick is to integrate the bookkeeping, or metadata, with the memory segments themselves. Specifically, when you get an allocation request for size bytes you can instead reserve a few bytes more. The first part of this memory is for your metadata; malloc then returns a pointer to the memory that comes after the metadata. But when free(ptr) is called, it can figure out where the metadata is based on ptr. You can then use the metadata space to, for example, store the length of the segment and pointers for a linked list of segments.
make cleanAfter a successful make, you will have compiled your malloc() as a shared library object (.so) and can use the launcher to launch any Linux program using your allocation library. To do so, run:
make
./mreplace <Program Name> [<Program Args> ...]For example:
./mreplace /bin/lsYou will find that valgrind will not work on this program! The ./mreplace program works in the exact same way as valgrind, so running a command like valgrind ./mreplace /bin/ls will result in ./mreplace replacing the valgrind-defined malloc() call, just like valgrind replaced toe libc malloc() call. Since you control the entire memory allocation in this MP, it doesn't even make sense to even test if you've correctly free'd all of your memory. Therefore, the grading of this MP will be based entirely on execution.
./mreplace /bin/ps -aef
./mreplace anyprogram
...
Start by trying out the "bad malloc" above. Then think about the data structures and algorithms you'll use for your bookkeeping. We suggest you get a simple solution up and running first. Then for more points, you can think about how to make your solution simultaneously fast and efficient with memory use.
This MP can become large and difficult to debug as you add more features and complexity to your allocator. We have included the file debug.h for your convenience. It contains the function DPRINTF() which function such like printf() except that it writes to standard error, it also prints out useful information about the location of the print statement in your code (this is really hadny when you have a lot of print statements!). Here's an example of how you can use it in your code:
DPRINTF("This is a text (%i).\n", x);
This line would print out something along the lines of (where the DPRINTF() was called on line 132 of alloc.c in the function my_function()):
my_function(), alloc.c:132: This is a test (10).
You can turn all of your print statements on and off easily when you compile your code. This last feature is also extremely valuable because inserting and removing print statements to debug your code is time consuming, and speed is a key part of this MP so you won't want your final solution to be printing unnecessary information. If you want your DPRINTF() statements to show up you need to make with this command:
make clean
FLAGS+="-DDEBUG" make
You may wish to look at debug.h for more details on how DPRINTF() works.
Those of you familiar with GDB may wish to use it to debug your code as well. Unfortunately generating extra debugging information for GDB will also slow down your code. For this reason when we run the contest we disable symbols, which makes using GDB difficult. If you wish to generate this information at compile time you may use the following:
make clean
FLAGS+="-g" make
If you want to use DPRINTF() and symbols you may run:
make clean
FLAGS+="-g -DDEBUG" make
In either case you MUST make sure that your code will still run properly the way that we will make it for the contest, code is not considered correct until it works without the above debug flags:
make clean
make
% ./tester-1A correct re-implementation of the functions required in the MP should result in running the same command inside ./mreplace resulting in the same output:
Memory was allocated, used, and freed!
% ./mreplace ./tester-1Each output that matches correctly will award you an equal amount of points - for a total of 4 points. For your programs to be considered correct, they must PASS in the contest (see the specifics below); see the results for the offical results.
Memory was allocated, used, and freed!
% ./mcontest ./tester-4As reported by ./mcontest, the program makes use of 1,359,776,200 bytes, or 1.266 GB, of heap memory at its maximum and took a little over 20 seconds to run. (This would be a bad solution, you can certainly do better than these results!)
Memory was allocated, used, and freed!
[mcontest]: STATUS: OK
[mcontest]: MAX: 1359776200
[mcontest]: AVG: 2191596.819109
[mcontest]: TIME: 20.552874