API review: During shutdown, revisit finalization and provide a way to clean up resources · Issue #16028 · dotnet/runtime (original) (raw)
Running finalizers on reachable objects during shutdown is currently unreliable. This is a proposal to fix that and provide a way to clean up resources on shutdown in a reliable way.
Issues observed on shutdown
Currently, a best-effort attempt is made to run finalizers for all finalizable objects during shutdown, including reachable objects. Running finalizers for reachable objects is not reliable, as the objects are in an undefined state.
- In order to finalize reachable objects, threads must be blocked, since objects that are still reachable cannot be used during or after finalization. Later, threads are terminated without running any more user code.
- Running user code in finalizers after blocking other threads is unreliable, as those threads may be blocked at an inopportune point, and may cause finalizers to block indefinitely or result in undefined behavior due to the undefined state of the object
- Example from a user code perspective. Consider an object that writes to a network stream using some stateful communication protocol. The finalizer would write a termination value to the stream and close the stream. Suppose that the termination value indicates to the receiving end that all data has been written, while abruptly closing the stream without writing the termination value would indicate incomplete transmission due to disconnection or some other reason. Writing the termination value in the finalizer assumes that there are no more references to the object, indicating that all data has been written. Suppose that a background thread is using the object, writing data to the pipe. During shutdown, the background thread is blocked at some arbitrary point, and the object is still referenced. Writing the termination value to the pipe in the finalizer at that point may be invalid according to the protocol.
- Example from a runtime perspective. If a thread is blocked during GC, and a finalizer tries to allocate something, it may block waiting for GC to complete, which will never happen. The finalizer itself may not even allocate anything, but even just jitting the finalizer method will trigger allocation. While this particular issue can be fixed separately, it demonstrates the unreliability of the current design not just from a user code perspective but from a runtime perspective.
- Effectively, the best-effort attempt to run finalizers for reachable finalizable objects is not reliable.
Proposal
- Don't run finalizers on shutdown (for reachable or unreachable objects)
- Don't block threads on shutdown
- Don't do a GC on shutdown (no change from current behavior)
- Under this proposal, it is not guaranteed that all finalizable objects will be finalized before shutdown.
- Doing a GC on shutdown and running finalizers for unreachable objects can guarantee that objects that are deterministically unreachable by the time of shutdown will be finalized. However, such objects should also be deterministically disposed before shutdown. For cases that require this, the user can trigger a GC explicitly and wait for finalizers before shutdown.
- When there are background threads that are still running, there would be no guarantee on how many objects will be finalized anyway
- Provide a public AssemblyLoadContext.Unloading event
- An AssemblyLoadContext manages the lifetime of assemblies loaded under that context
- Code should register for the event in the AssemblyLoadContext instance associated with the assembly
- The event is raised when the GC determines that the AssemblyLoadContext instance is no longer referenced. For the default AssemblyLoadContext instance and for a custom instance installed as the default load context, the event will be raised before normal shutdown.
* Abrupt shutdown due to unhandled exception, process kill, etc., will not raise any further Unloading events
* As unloading an AssemblyLoadContext is not yet implemented, instances that have been used to load assemblies will currently live until the end of the process - No timeout. The timeout on waiting for finalizers to complete on shutdown was removed in CoreCLR some time back. In favor of treating blocking issues as program errors, no timeout will be used for this event either.
- Event handler exceptions will crash the process. Any exception propagating out of an event handler will be treated as an unhandled exception.
- Since other threads are not blocked before this, and may continue to run for a short period after the event is raised, event handlers may need to handle concurrency, and safeguard from using cleaned up resources from other threads
Behavioral change
public static void Main() { var obj = new MyFinalizable(); }
private class MyFinalizable
{
MyFinalizable()
{
Console.WriteLine("MyFinalizable");
}
}
Previous output:
~MyFinalizable
Typical output with the proposal above (running the finalizer is not guaranteed, but may run if a GC is triggered):
(empty)
Proposed API
namespace System.Runtime.Loader { public abstract class AssemblyLoadContext { public event Action Unloading; } }
Example
public class Logger { private static readonly object s_lock = new object(); private static bool s_isClosed = false;
static Logger()
{
var currentAssembly = typeof(Loader).GetTypeInfo().Assembly;
AssemblyLoadContext.GetLoadContext(currentAssembly).Unloading += OnAssemblyLoadContextUnloading;
// Create log file based on configuration
}
private static void OnAssemblyLoadContextUnloading(AssemblyLoadContext sender)
{
// This may be called concurrently with WriteLine
Close();
}
private static void Close()
{
lock (s_lock)
{
if (s_isClosed)
return;
s_isClosed = true;
// Write remaining in-memory log messages to log file and close log file
}
}
public static void WriteLine(string message)
{
lock (s_lock)
{
if (s_isClosed)
return;
// Save log message in memory, if buffer is full, write messages to log file
}
}
}