DEV Community

mohamed Tayel
mohamed Tayel

Posted on

intro to Garbage Collection and Resource Management in C#

Introduction

C# simplifies memory management with its automatic garbage collector (GC). However, when working with unmanaged resources like files, streams, or network connections, developers must explicitly clean up these resources to prevent memory leaks and performance issues.

This article demystifies garbage collection, the IDisposable interface, and finalizers with step-by-step explanations, real-world examples, and a comparison of approaches.


1. Understanding Garbage Collection

The garbage collector automatically reclaims memory occupied by unused objects. However:

  • It does not manage unmanaged resources like file handles or network sockets.
  • Memory leaks can occur if references to objects are not released.

Key Concepts:

  • Managed Resources: Automatically cleaned by GC (e.g., objects, arrays).
  • Unmanaged Resources: Require explicit cleanup (e.g., streams, database connections).

2. The Problem: Memory Leaks

Let’s start with a simple example: opening a file without closing it.

public void OpenFileWithoutClosing()
{
    var file = new StreamReader("example.txt");
    Console.WriteLine(file.ReadToEnd());
    // Forgot to close the file!
}
Enter fullscreen mode Exit fullscreen mode

What happens?

  • The file remains open.
  • Other programs cannot access it.
  • The application unnecessarily uses memory.

3. The Solution: IDisposable and Dispose

To manage resources properly, we use the IDisposable interface.

Step 1: Implementing IDisposable

Here’s how to clean up a file resource.

public class FileProcessor : IDisposable
{
    private StreamReader _reader;
    private bool _disposed = false;

    public FileProcessor(string filePath)
    {
        _reader = new StreamReader(filePath);
    }

    public string ReadFile()
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(FileProcessor));

        return _reader.ReadToEnd();
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            _reader?.Dispose();
            Console.WriteLine("Resources cleaned up.");
            _disposed = true;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Using Dispose with using

The using statement ensures Dispose is called automatically.

using (var processor = new FileProcessor("example.txt"))
{
    Console.WriteLine(processor.ReadFile());
}
// File is automatically closed here.
Enter fullscreen mode Exit fullscreen mode

4. Comparing Approaches

Without Dispose

public void ProcessFilesWithoutDispose()
{
    for (int i = 0; i < 5; i++)
    {
        var reader = new StreamReader($"file{i}.txt");
        Console.WriteLine(reader.ReadToEnd());
        // Files remain open, causing memory issues.
    }
}
Enter fullscreen mode Exit fullscreen mode

With Dispose

public void ProcessFilesWithDispose()
{
    for (int i = 0; i < 5; i++)
    {
        using (var reader = new StreamReader($"file{i}.txt"))
        {
            Console.WriteLine(reader.ReadToEnd());
        } // Files are closed here.
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Advanced Cleanup with Finalizers

A finalizer is a safety net for cleaning up resources if Dispose is not called. However, it’s slower and less predictable than Dispose.

Step 1: Adding a Finalizer

public class FinalizerExample
{
    private readonly string _resourceName;
    private bool _disposed = false;

    public FinalizerExample(string resourceName)
    {
        _resourceName = resourceName;
        Console.WriteLine($"{_resourceName} acquired.");
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            Console.WriteLine($"{_resourceName} is being disposed.");
            _disposed = true;
        }
    }

    ~FinalizerExample()
    {
        Console.WriteLine($"{_resourceName} finalizer executed.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Using the Finalizer Example

public class Program
{
    public static void Main(string[] args)
    {
        Console.WriteLine("Creating FinalizerExample...");
        var example = new FinalizerExample("Resource 1");

        // Forgetting to call Dispose
        Console.WriteLine("Program is ending without Dispose...");

        // Force garbage collection for demonstration
        GC.Collect();
        GC.WaitForPendingFinalizers();
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

  1. "Resource 1 acquired."
  2. "Program is ending without Dispose..."
  3. "Resource 1 finalizer executed."

6. Measuring Memory Usage

How to Measure:

  1. Open Visual Studio > Diagnostic Tools.
  2. Run your application in debug mode.
  3. Perform actions and observe memory usage.

What to Look For:

  • Memory should remain stable after resources are released.
  • High memory usage without cleanup indicates potential leaks.

7. Best Practices

  1. Always Use Dispose: Implement IDisposable for classes using unmanaged resources.
  2. Use using Statements: Simplifies cleanup and ensures Dispose is called.
  3. Avoid Relying on Finalizers: Use as a fallback only.
  4. Monitor Memory Usage: Use tools like Visual Studio diagnostics to detect leaks.
  5. Unsubscribe from Events: Event handlers can hold references and prevent garbage collection.

Conclusion

Garbage collection in C# is powerful, but explicit resource management is essential for unmanaged resources. By mastering IDisposable, Dispose, and finalizers, you can write efficient, maintainable, and memory-safe code. Try these examples in your projects and see how they improve your application's performance.


Complete Code Example: Garbage Collection and Resource Management

using System;
using System.IO;

public class FileProcessor : IDisposable
{
    private StreamReader _reader;
    private bool _disposed = false;

    public FileProcessor(string filePath)
    {
        _reader = new StreamReader(filePath);
        Console.WriteLine($"FileProcessor: Opened file '{filePath}'.");
    }

    public string ReadFile()
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(FileProcessor));

        return _reader.ReadToEnd();
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            _reader?.Dispose();
            Console.WriteLine("FileProcessor: Resources have been cleaned up.");
            _disposed = true;
        }
    }

    ~FileProcessor()
    {
        Console.WriteLine("FileProcessor finalizer executed. Did you forget to call Dispose?");
    }
}

public class FinalizerExample
{
    private readonly string _resourceName;
    private bool _disposed = false;

    public FinalizerExample(string resourceName)
    {
        _resourceName = resourceName;
        Console.WriteLine($"{_resourceName}: Resource acquired.");
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            Console.WriteLine($"{_resourceName}: Resource disposed.");
            _disposed = true;
        }
    }

    ~FinalizerExample()
    {
        Console.WriteLine($"{_resourceName}: Finalizer executed.");
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        Console.WriteLine("---- Example 1: Properly Using Dispose ----");
        using (var processor = new FileProcessor("example.txt"))
        {
            string content = processor.ReadFile();
            Console.WriteLine("File content:");
            Console.WriteLine(content);
        }
        // Dispose is automatically called here.

        Console.WriteLine("\n---- Example 2: Forgetting Dispose ----");
        var finalizerExample = new FinalizerExample("Resource 1");

        // Intentionally not calling Dispose to demonstrate finalizer.
        Console.WriteLine("Forgetting to call Dispose...");

        // Force garbage collection for demonstration purposes.
        GC.Collect();
        GC.WaitForPendingFinalizers();

        Console.WriteLine("Program execution completed.");
    }
}
Enter fullscreen mode Exit fullscreen mode

What This Code Does:

  1. FileProcessor Class:

    • Implements IDisposable for deterministic cleanup.
    • Contains a Dispose method and a finalizer as a fallback.
    • Demonstrates proper resource management.
  2. FinalizerExample Class:

    • Simulates resource acquisition and cleanup.
    • Contains a finalizer to handle cases where Dispose is not called.
  3. Main Method:

    • Example 1: Properly uses Dispose with the using statement to release resources.
    • Example 2: Demonstrates what happens when Dispose is not called, relying on the finalizer instead.

Output

When running with example.txt as input:

---- Example 1: Properly Using Dispose ----
FileProcessor: Opened file 'example.txt'.
File content:
<contents of the file>
FileProcessor: Resources have been cleaned up.

---- Example 2: Forgetting Dispose ----
Resource 1: Resource acquired.
Forgetting to call Dispose...
Resource 1: Finalizer executed.
Program execution completed.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)