Skip to content

API review: During shutdown, revisit finalization and provide a way to clean up resources #16028

@kouvel

Description

@kouvel

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<AssemblyLoadContext> 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
        }
    }
}

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions