Garbage collection is a key responsibility of the runtime. Nevertheless, it is important to recognize that the garbage collection process centers on the code’s memory utilization. It is not about the cleaning up of file handles, database connection strings, ports, or other limited resources.
Finalizers allow developers to write code that will clean up a class’s resources. Unlike constructors that are called explicitly using the new operator, finalizers cannot be called explicitly from within the code. There is no new equivalent such as a delete operator. Rather, the garbage collector is responsible for calling a finalizer on an object instance. Therefore, developers cannot determine at compile time exactly when the finalizer will execute. All they know is that the finalizer will run sometime between when an object was last used and generally when the application shuts down normally. The deliberate injection of incertitude with the word “Generally” highlights the fact that finalizers might not execute. This possibility is obvious when you consider that a process might terminate abnormally. For instance, events such as the computer being turned off or a forced termination of the process, such as when debugging the process, will prevent the finalizer from running. However, with .NET Core, even under normal circumstances, finalizers may not get processed before the application shuts down. As we shall see in the next section, it thus may be necessary to take additional action to register finalization activities with other mechanisms.
The finalizer declaration is identical to the destructor syntax of C#’s predecessor—that is, C++. As shown in Listing 10.15, the finalizer declaration is prefixed with a tilde before the name of the class.
Finalizers do not allow any parameters to be passed, so they cannot be overloaded. Furthermore, finalizers cannot be called explicitly—that is, only the garbage collector can invoke a finalizer. Access modifiers on finalizers are therefore meaningless, and as such, they are not supported. Finalizers in base classes will be invoked automatically as part of an object finalization call.
Because the garbage collector handles all memory management, finalizers are not responsible for de-allocating memory. Rather, they are responsible for freeing up resources such as database connections and file handles—resources that require an explicit activity that the garbage collector doesn’t know about.
In the finalizer shown in Listing 10.15, we start by disposing of the FileStream. This step is optional because the FileStream has its own finalizer that provides the same functionality as Dispose(). The purpose of invoking Dispose() now is to ensure that it is cleaned up when TemporaryFileStream is finalized, since the latter is responsible for instantiating the former. Without the explicit invocation of Stream?.Dispose(), the garbage collector will clean it up independently from the TemporaryFileStream once the TemporaryFileStream object is garbage collected and releases its reference on the FileStream object. That said, if we didn’t need a finalizer for resource cleanup anyway, it would not make sense to define a finalizer just for invoking FileStream.Dispose(). In fact, limiting the need for a finalizer to only objects that need resource cleanup that the runtime isn’t already aware of (resources that don’t have finalizers) is an important guideline that significantly reduces the number of scenarios where it is necessary to implement a finalizer.
In Listing 10.15, the purpose of the finalizer is to delete the file9—an unmanaged resource in this case. Hence we have the call to File?.Delete(). Now, when the finalizers are executed, the file will get cleaned up.
Finalizers execute on an unspecified thread, making their execution even less deterministic. This indeterminate nature makes an unhandled exception within a finalizer (outside of the debugger) likely to crash the application—and the source of this problem is difficult to diagnose because the circumstances that led to the exception are not clear. From the user’s perspective, the unhandled exception will be thrown relatively randomly and with little regard for any action the user was performing. For this reason, you should take care to avoid exceptions within finalizers. Instead, you should use defensive programming techniques such as checking for null (refer to the use of the null-conditional operator in Listing 10.15). In fact, it is advisable to catch all exceptions in the finalizer and report them via an alternative means (such as logging or via the user interface) rather than keeping them as unhandled exceptions. This guideline leads to the try/catch block surrounding the Delete() invocation.
Another potential option to force finalizers to execute is to invoke System.GC.WaitForPendingFinalizers(). When this method is invoked, the current thread will be suspended until all finalizers for objects that are no longer referenced have executed.
The problem with finalizers on their own is that they don’t support deterministic finalization (the ability to know when a finalizer will run). Rather, finalizers serve the important role of being a backup mechanism for cleaning up resources if a developer using a class neglects to call the requisite cleanup code explicitly.
For example, consider the TemporaryFileStream, which includes not only a finalizer but also a Close() method. This class uses a file resource that could potentially consume a significant amount of disk space. The developer using TemporaryFileStream can explicitly call Close() to restore the disk space.
Providing a method for deterministic finalization is important because it eliminates a dependency on the indeterminate timing behavior of the finalizer. Even if the developer fails to call Close() explicitly, the finalizer will take care of the call. In such a case, the finalizer will run later than if it was called explicitly.
Because of the importance of deterministic finalization, the base class library includes a specific interface for the pattern and C# integrates the pattern into the language. The IDisposable interface defines the details of the pattern with a single method called Dispose(), which developers call on a resource class to “dispose” of the consumed resources. Listing 10.16 demonstrates the IDisposable interface and some code for calling it.
From Program.Search(), there is an explicit call to Dispose() after using the TemporaryFileStream. Dispose() is the method responsible for cleaning up the resources (in this case, a file) that are not related to memory and therefore are subject to cleanup implicitly by the garbage collector. Nevertheless, the execution here contains a hole that would prevent execution of Dispose()—namely, the chance that an exception will occur between the time when TemporaryFileStream is instantiated and the time when Dispose() is called. If this happens, Dispose() will not be invoked and the resource cleanup will have to rely on the finalizer. To avoid this problem, callers need to implement a try/finally block. Instead of requiring programmers to code such a block explicitly, C# provides a using statement expressly for the purpose (see Listing 10.17).
In the first highlighted code snippet, the resultant CIL code is identical to the code that would be created if the programmer specified an explicit try/finally block, where fileStream.Dispose() is called in the finally block. The using statement, however, provides a syntax shortcut for the try/finally block.
Within this using statement, you can instantiate more than one variable by separating each variable from the others with a comma. The key considerations are that all variables must be of the same type, the type must implement IDisposable, and initialization occurs at the time of declaration. To enforce the use of the same type, the data type is specified only once rather than before each variable declaration.
C# 8.0 introduces a potential simplification with regard to resource cleanup. As shown in the second highlighted snippet of Listing 10.17, you can prefix the declaration of a disposable resource (one that implements IDisposable) with the using keyword. As is the case with the using statement, this will generate the try/finally behavior, with the finally block placed just before the variable goes out of scope (in this case, before the closing curly brace of the Search() method). One additional constraint on the using declaration is that the variable is read-only, so it can’t be assigned a different value.
There are several additional noteworthy items to point out in Listing 10.16. First, the IDisposable.Dispose() method contains an important call to System.GC.SuppressFinalize(). Its purpose is to remove the TemporaryFileStream class instance from the finalization (f-reachable) queue. This is possible because all cleanup was done in the Dispose() method rather than waiting for the finalizer to execute.
Without the call to SuppressFinalize(), the instance of the object will be included in the f-reachable queue—a list of all the objects that are mostly ready for garbage collection, except they also have finalization implementations. The runtime cannot garbage-collect objects with finalizers until after their finalization methods have been called. However, garbage collection itself does not call the finalization method. Rather, references to finalization objects are added to the f-reachable queue and are processed by an additional thread at a time deemed appropriate based on the execution context. In an ironic twist, this approach delays garbage collection for the managed resources—when it is most likely that these very resources should be cleaned up earlier. The reason for the delay is that the f-reachable queue is a list of “references”; as such, the objects are not considered garbage until after their finalization methods are called and the object references are removed from the f-reachable queue.
For this reason, Dispose() invokes System.GC.SuppressFinalize. Invoking this method informs the runtime that it should not add this object to the finalization queue, but instead should allow the garbage collector to de-allocate the object when it no longer has any references (including any f-reachable references).
Second, Dispose() calls Dispose(bool disposing) with an argument of true. The result is that the Dispose() method on Stream is invoked (cleaning up its resources and suppressing its finalization). Next, the temporary file itself is deleted immediately upon calling Dispose(). This important call eliminates the need to wait for the finalization queue to be processed before cleaning up potentially expensive resources.
Third, rather than calling Close(), the finalizer now calls Dispose(bool disposing) with an argument of false. The result is that Stream is not closed (disposed) even though the file is deleted. The condition around closing Stream ensures that if Dispose(bool disposing) is called from the finalizer, the Stream instance itself will also be queued up for finalization processing (or possibly it would have already run depending on the order). Therefore, when executing the finalizer, objects owned by the managed resource should not be cleaned up, as this action will be the responsibility of the finalization queue.
Fourth, you should use caution when creating both a Close() type and a Dispose() method. It is not clear by looking at only the API that Close() calls Dispose(), so developers will be left wondering whether they need to explicitly call Close() and Dispose().
Fifth, to increase the probability that the functionality defined in the finalizer will execute before a process shuts down even in .NET Core, you should register the code with the AppDomain.CurrentDomain.ProcessExit event handler. Any finalization code registered with this event handler will be invoked, barring an abnormal process termination (discussed in the next section).
Although finalizers are similar to destructors in C++, the fact that their execution cannot be determined at compile time makes them distinctly different. The garbage collector calls C# finalizers sometime after they were last used but before the program shuts down; C++ destructors are automatically called when the object (not a pointer) goes out of scope.
Although running the garbage collector can be a relatively expensive process, the fact that garbage collection is intelligent enough to delay running until process utilization is somewhat reduced offers an advantage over deterministic destructors, which will run at compile-time–defined locations, even when a processor is in high demand.