Resource Acquisition
When a program acquires a resource (e.g. opens a file, allocates some memory, sets a lock, etc.), it is often very important for the future operation of the program that the resource is properly released.
For example, in normal C/C++, this is typically handled like:
void useFile(const char *filename)
{
FILE *fp = fopen(filename, "w");
// use fp
fclose(fp);
}but this is not safe when something goes wrong – for example, if the program fails to open the file, or an error occurs while accessing the file.
A better approach might be:
void useFile(const char *filename)
{
FILE *fp = fopen(filename, "w");
try
{
// use fp
}
catch(...)
{
fclose(f); // close on exception
throw;
}
fclose(fp); // close normally
}This is better, but it is verbose, tedious, potentially expensive, and error-prone.
The general form of such a program looks like:
void acquire_and_release()
{
// acquire resource 1
// ...
// acquire resource n
// use resources
// release resource n
// ...
// release resource 1
}Note the typical release sequence is in the reverse order of acquisition.
This resembles the behavior of objects created by constructors and destroyed by destructors.
So we could design a class to solve the problem:
//
// FilePtr.cpp
// Derived Exceptions
//
// Created by Bryan Higgs on 10/4/24.
//
#include <stdio.h>
class FilePtr
{
public:
FilePtr(const char *name, const char *access)
{
fp = fopen(name, access);
}
FilePtr(FILE *f) : fp(f) {}
~FilePtr() { fclose(fp); }
operator FILE *() { return fp; }
private:
FILE *fp;
};
void useFile(const char *filename)
{
FilePtr file(filename, "r");
// use file...
// Destructor for file will be called
// whether an exception is thrown or not.
}
int main(int argc, const char * argv[])
{
useFile(argv[1]);
return 0;
}“Resource acquisition is initialization.” (RAII)
This technique is called “Resource acquisition is initialization” or RAII.
It is considered to be a critical technique in C++. (See here for more details.)
An object is not considered (fully) constructed until its constructor has completed. Then, and only then, will stack unwinding (as a result of throwing an exception) call the destructor for the object.
Consider a class whose constructor needs to acquire two resources, a file x and a lock y:
class Resources
{
public:
Resources(const char *file, const char *lock)
: m_file(x), // acquire 'file'
m_lock(y) // acquire 'lock'
{}
// ...
private:
FilePtr m_file;
LockPtr m_lock;
};If an exception occurs after file has been constructed, but before lock has been constructed, then the destructor for file will be invoked, but the destructor for lock will not.
When this simple model is adhered to, the author of the constructor need not write explicit exception handling code.
The most common resource is free store (memory). For example:
class X
{
public:
X(int size) { m_p = new int[size]; init(); }
~X() { delete [] m_p; }
// ...
private:
void init();
int *m_p;
};But this code leads to memory leaks when used with exceptions – if init() throws an exception, then the memory acquired will not be freed, because the object wasn’t completely constructed.
A safe version is:
//
// SafeMemory.cpp
// Derived Exceptions
//
// Created by Bryan Higgs on 10/4/24.
//
#include <stdio.h>
class IntPtr
{
public:
IntPtr(size_t size) : m_p( new int[size] ) {}
~IntPtr() { delete [] m_p; }
operator int*() { return m_p; }
private:
int *m_p;
};
class Z
{
public:
Z(int size) : m_p(size) { init(); }
~Z() { delete [] m_p; }
// ...
private:
void init();
IntPtr m_p;
};“In the C++ standard library, RAII is pervasive: for example, memory (string, vector, map, unordered_map, etc.), files (ifstream, ofstream, etc.), threads (thread), locks (lock_guard, unique_lock, etc.), and general objects (through unique_ptr and shared_ptr). The result is implicit resource management that is invisible in common use and leads to low resource retention durations.
~ Bjarne Stroustrup. A Tour of C++ 3rd Edition.
Resource Exhaustion
What do we do when an attempt to acquire a resource fails? Two choices:
- Termination: Abandon the computation and return to some caller.
- Termination is in most cases far simpler, and allows a system to maintain a better separation of levels of abstraction. (Note that termination means only termination of the computation, not necessarily the program.)
- Resumption: Ask the caller to fix the problem and continue.
In C++:
- Resumption is supported by the function-call mechanism.
- Termination is supported by the exception-handling mechanism.
new_handler()
Both resumption and termination can be shown from the implementation of the new operator:
// new.h
#include <stdexcept>
class bad_alloc : public exception
{
//...
};
typedef void (*new_handler)();
// ...// new.cpp
#include <stdlib.h> // for malloc and size_t
#include <new>
// ...
new_handler _new_handler = 0;
void *operator new(size_t size)
{
for (;;)
{
// try to find memory
if (void *p = malloc(size))
{ // succeeded
return p;
}
if (_new_handler == 0)
{ // no handler; give up
throw bad_alloc();
}
new_handler();
}
}
// ...If operator new() cannot find memory, it calls _new_handler().
- If _new_handler() can supply enough memory for malloc, it can return.
- If it can’t, the handler can’t simply return without causing an infinite loop.
The new-handler might throw an exception:
void my_new_handler()
{
try_to_find_some_memory();
if (found_some)
return;
throw std::bad_alloc();
}The new_handler can do one of the following:
- Make more memory available: It can try to free up memory or acquire more memory from the system.
- Throw an exception: It can throw an exception, typically
std::bad_alloc, to signal the failure. - Terminate the program: It can call
std::terminateto terminate the program.
To set up a new-handler:
set_new_handler( &my_new_handler );
Here’s an example:
//
// main.cpp
// new_handler
//
// Created by Bryan Higgs on 10/5/24.
//
#include <iostream>
#include <new>
bool try_to_allocate_memory()
{
// ...
std::cerr << "In try_to_allocate_memory\n";
std::cerr << "Pretending to try...\n";
std::cerr << "Returning failure...\n";
return false;
}
void my_new_handler()
{
bool found_some = try_to_allocate_memory();
if (found_some)
return;
throw std::bad_alloc();
}
int main(int argc, const char * argv[])
{
std::cerr << "Setting new handler\n";
std::new_handler old_handler =
std::set_new_handler( &my_new_handler );
// Get address of current new handler
try
{
// ... operations that use new ...
while (true)
{
std::cerr << "Attampting to allocate a large array...\n";
long* arr = new long[5'000'000'000'000];
// Try to allocate a very large array
}
// ...
}
catch (std::bad_alloc)
{
// do something in response to failure...
std::cerr << "In std::bad_alloc handler\n";
}
catch (...)
{
std::cerr << "In catch(...) handler\n";
std::set_new_handler( old_handler ); // restore old handler
throw; // rethrow the exception
}
std::cerr << "Restoring handler\n";
std::set_new_handler( old_handler ); // restore handler
// ...
return 0;
}This program outputs:
Setting new handler
Attampting to allocate a large array...
Attampting to allocate a large array...
Attampting to allocate a large array...
In try_to_allocate_memory
Pretending to try...
Returning failure...
In std::bad_alloc handler
Restoring handler
Program ended with exit code: 0
Warning:
In a multithreaded environment, using
std::set_new_handlercan lead to a race condition.Here’s why:
- Multiple threads might simultaneously attempt to call
std::set_new_handlerto set their own new handler functions.- If this happens, the outcome depends on the order of execution, which can be unpredictable. This creates a classic race condition scenario.
Solution:
- C++11 and later:The C++ standard guarantees that
std::set_new_handleris thread-safe, meaning it handles the race condition internally.- Prior to C++11:You’ll need to implement your own synchronization mechanisms, such as a mutex, to protect the call to
std::set_new_handler.