Musings on Game Engine Design

An Anatomy of Despair: Object Ownership

with one comment

In every game engine that I worked on before Despair, I spent a lot of time tracking down memory leaks.  Some leaks were obvious and easy to find.  Some leaks involved complex patterns of ownership that thoroughly obscured what object was supposed to be responsible for deleting another.  And some leaks involved AddRef/Release mismatches that would create cycles of ownership or that would leak whole object hierarchies.

We wanted to improve stability and leak less memory in Despair, and this led us to consider exception safety.  Exception safety is the idea that a program should always remain in a valid state in the presence of exceptions.  Operations should succeed or fail, but if they fail, they should have no side effects and program data should remain unchanged.  Memory leaks frequently occur when exception unsafe code encounters an exceptional situation.  A function may throw an exception to signal bad data, for example, and thereby skip clean-up of temporary objects done at the end of the function.

Despair doesn’t use exceptions, because they introduce significant overhead, but we do use error codes and FAIL_RETURN macros that may early exit from a function.  These introduce similar problems.  Therefore we try to be exception-safe wherever possible without hurting performance.  In practice, performance concerns generally keep us from using the PIMPL idiom to implement atomic transactions.  But we do use the Resource Acquisition Is Initialization idiom extensively.  Our XML writer uses scoped EnterElement/LeaveElement objects.  Our critical sections use scoped locks.  And we use smart pointers extensively.

The Despair Engine contains about ten smart pointer types, depending on how broadly you define a smart pointer.  I’ve had very smart, experienced engineers roll their eyes and say, “One smart pointer type is too many!” when I tell them that, but in actual practice they’ve worked so well for us that I couldn’t imagine working again on an engine that didn’t use smart pointers.

There are some things that must be borne in mind when using smart pointers:

  • Smart pointers don’t remove any of the decisions about object ownership that have to be made in designing an engine.  You still need to decide what objects are shared, if any, and you need to define a strict ordering of which objects own which other objects.  All that smart pointers do is help document that ordering (you know that the object with a scoped_ptr owns the one without, even if there are pointers going both ways) and help enforce that ownership across the space of possible execution paths.
  • Smart pointers are not the same as raw pointers.  They may use pointer syntax, but it’s impossible to use them efficiently without understanding what they are and understanding when they’re going to free memory or call AddRef/Release functions.  Smart pointers passed as function parameters should almost always be passed by const reference.

Our smart pointer menagerie includes

  • a pointer types for internally-refcounted objects
  • a pointer type for externally-refcounted objects (rarely used)
  • scoped pointers (a std::auto_ptr replacement that’s sane)
  • scoped arrays
  • resource pointers that start NULL and are filled in as resources load asynchronously
  • intrusive weak pointers
  • and a few other specific pointers for managing internally-refcounted objects in third-party APIs

Programmers are encouraged to establish strong references at load time and use them at runtime.  Because we don’t generally create or pass around smart pointers during a frame, the runtime cost of smart pointers in Despair is minimal.

I credit our use of smart pointers, our rules for ownership, and our use of the RAII idiom for giving us an engine more stable than any I’ve ever worked on before, even as the number of engineers writing code in it has grown from two to thirty.  A new memory leak shows up about every month or two, and is almost always the result of someone’s not using smart pointers and then forgetting to call delete in all code paths.  In our engine, if you find yourself having to type out the keyword “delete”, then you’re probably doing something wrong.

Another, less frequent, error that sometimes occurs is for someone to introduce a cyclic reference that causes approximately every object in the game to leak.  In Despair, this happens about once every six months.  This is not a consequence of our use of smart pointers.  Most of the other game engines that I’ve worked on had manually-refcounted objects, and had similar issues on occasion.  Despair’s smart-pointer classes do help when we’re trying to debug this situation, though.  A debug macro enables capturing the callstack on each AddRef and associating each callstack with a particular smart pointer.  We can thus report the callstack of all leaked AddRef calls for each refcounted object.  Like I said, we don’t need this capability very often, but it’s nice to have.

Written by Kyle

April 15th, 2008 at 10:16 pm