Implementing Concurrent HashSet<T> in .NET Framework: Strategies and Best Practices

Dec 05, 2025 · Programming · 9 views · 7.8

Keywords: Concurrent Programming | Thread Safety | HashSet

Abstract: This article explores various approaches to achieve thread-safe HashSet<T> operations in the .NET Framework. It begins by analyzing basic implementations using lock statements with standard HashSet<T>, then details the recommended approach of simulating concurrent collections using ConcurrentDictionary<TKey, TValue> with complete code examples. The discussion extends to custom ConcurrentHashSet implementations based on ReaderWriterLockSlim, comparing performance characteristics and suitable scenarios for different solutions, while briefly addressing the inappropriateness of ConcurrentBag and other community alternatives.

Introduction and Problem Context

In multithreaded programming environments, concurrent access to shared data structures presents a common and critical challenge. While the .NET Framework's System.Collections.Concurrent namespace provides several thread-safe collection types such as ConcurrentDictionary, ConcurrentQueue, and ConcurrentBag, it notably lacks a native ConcurrentHashSet<T> type. This absence forces developers to seek alternatives or implement their own solutions when requiring thread-safe hash set operations.

Basic Thread-Safe Implementation: Using lock Statements

The most straightforward approach to thread safety involves wrapping HashSet<T> access with lock statements. A typical implementation appears as follows:

class Test {
    public HashSet<string> Data = new HashSet<string>();

    public void Add(string val) {
        lock(Data) {
            Data.Add(val);
        }
    }

    public void Remove(string val) {
        lock(Data) {
            Data.Remove(val);
        }
    }
}

This method offers simplicity and clarity, making it easy to understand and implement. The lock statement ensures that only one thread can execute the critical section at any time, preventing data races and inconsistent states. However, it comes with limitations: first, the lock granularity is coarse, requiring all operations (including read-only ones like Contains) to acquire the lock, which may become a performance bottleneck in high-concurrency scenarios. Second, HashSet<T> itself isn't designed for concurrent access, and even read operations might encounter issues without proper synchronization.

Recommended Approach: Simulating HashSet with ConcurrentDictionary

Given the absence of a built-in ConcurrentHashSet, a widely accepted alternative utilizes ConcurrentDictionary<TKey, TValue> to emulate hash set behavior. This involves using collection elements as dictionary keys, with values serving as placeholders, such as byte type (occupying only 1 byte of memory). The following code demonstrates this implementation:

using System.Collections.Concurrent;

class ConcurrentHashSetWrapper<T> {
    private ConcurrentDictionary<T, byte> _dictionary = new ConcurrentDictionary<T, byte>();

    public bool Add(T item) {
        return _dictionary.TryAdd(item, 0);
    }

    public bool Remove(T item) {
        byte removedValue;
        return _dictionary.TryRemove(item, out removedValue);
    }

    public bool Contains(T item) {
        return _dictionary.ContainsKey(item);
    }

    public int Count {
        get { return _dictionary.Count; }
    }
}

This approach benefits from ConcurrentDictionary's design for high-concurrency scenarios, employing fine-grained locks or lock-free algorithms internally to deliver superior performance. Additionally, it eliminates the complexity of manual lock management, reducing the potential for errors. Note that due to the key-value pair structure of dictionaries, this simulation may incur slightly higher memory usage compared to a pure HashSet, though this overhead is generally acceptable for most applications.

Advanced Implementation: Custom ConcurrentHashSet Based on ReaderWriterLockSlim

For scenarios requiring finer control or specific performance optimizations, a custom ConcurrentHashSet class can be implemented. Below is a complete example using ReaderWriterLockSlim, supporting recursive locks and implementing IDisposable for proper resource disposal:

using System;
using System.Collections.Generic;
using System.Threading;

public class ConcurrentHashSet<T> : IDisposable {
    private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
    private readonly HashSet<T> _hashSet = new HashSet<T>();

    public bool Add(T item) {
        _lock.EnterWriteLock();
        try {
            return _hashSet.Add(item);
        } finally {
            if (_lock.IsWriteLockHeld) {
                _lock.ExitWriteLock();
            }
        }
    }

    public bool Remove(T item) {
        _lock.EnterWriteLock();
        try {
            return _hashSet.Remove(item);
        } finally {
            if (_lock.IsWriteLockHeld) {
                _lock.ExitWriteLock();
            }
        }
    }

    public bool Contains(T item) {
        _lock.EnterReadLock();
        try {
            return _hashSet.Contains(item);
        } finally {
            if (_lock.IsReadLockHeld) {
                _lock.ExitReadLock();
            }
        }
    }

    public int Count {
        get {
            _lock.EnterReadLock();
            try {
                return _hashSet.Count;
            } finally {
                if (_lock.IsReadLockHeld) {
                    _lock.ExitReadLock();
                }
            }
        }
    }

    public void Dispose() {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing) {
        if (disposing && _lock != null) {
            _lock.Dispose();
        }
    }

    ~ConcurrentHashSet() {
        Dispose(false);
    }
}

This implementation leverages ReaderWriterLockSlim, allowing multiple threads to perform read operations concurrently while write operations remain exclusive. This can significantly enhance performance in read-heavy scenarios. The try-finally blocks ensure proper lock release, preventing deadlocks even when exceptions occur. Importantly, lock acquisition methods (EnterWriteLock and EnterReadLock) are placed outside try blocks because these methods might throw exceptions (e.g., due to lock recursion policy violations). If placed inside try blocks, exceptions could trigger finally blocks attempting to release unheld locks, leading to further errors.

Alternative Solutions and Non-Recommended Options

Beyond the methods described, community-driven implementations exist, such as open-source projects offering ConcurrentHashSet NuGet packages based on ConcurrentDictionary. These packages often provide more comprehensive APIs and better performance optimizations. However, developers should carefully evaluate the maintenance status and compatibility of third-party libraries before adoption.

It is crucial to note that ConcurrentBag<T> is unsuitable as a substitute for concurrent hash sets. Designed for producer-consumer scenarios, ConcurrentBag supports thread-safe addition and removal operations, but removal targets arbitrary elements rather than specific ones. Moreover, extension methods accessed via interfaces (e.g., IEnumerable) do not guarantee thread safety, potentially leading to undefined behavior in concurrent environments. Therefore, unless the scenario strictly aligns with its design purpose, ConcurrentBag should be avoided for hash set functionality.

Performance Considerations and Selection Guidelines

When choosing an appropriate concurrent hash set implementation, consider the following factors:

For most applications, the ConcurrentDictionary simulation approach is recommended first, as it balances performance, safety, and development efficiency. Custom implementations should be considered only when specific performance needs or functional requirements arise.

Conclusion

Multiple viable approaches exist for implementing thread-safe hash sets in the .NET Framework, ranging from simple lock wrappers to ConcurrentDictionary-based simulations and custom implementations using ReaderWriterLockSlim. Each solution has its applicable scenarios and trade-offs. Developers should select the most suitable implementation based on specific application requirements, such as concurrency levels, read-write ratios, and performance demands. While future .NET ecosystem developments might introduce an official ConcurrentHashSet to the standard library, the discussed alternatives provide reliable and efficient options in the interim.

Copyright Notice: All rights in this article are reserved by the operators of DevGex. Reasonable sharing and citation are welcome; any reproduction, excerpting, or re-publication without prior permission is prohibited.