Concurrent HashSet
.NET Framework
Thread Safety
C# Collections
System.Collections.Concurrent

Concurrent HashSetT in .NET Framework?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

The .NET Framework has ConcurrentDictionary, ConcurrentQueue, ConcurrentBag, and other thread-safe collections, but it does not ship with a built-in ConcurrentHashSet<T>. If you need set semantics with concurrent access, the usual answer is to build on top of ConcurrentDictionary<TKey, TValue> or to protect a normal HashSet<T> with your own locking strategy.

The right choice depends on what operations you need. If your workload is mostly add, remove, and contains checks, a dictionary-backed set is usually the simplest and safest option.

Why There Is No Built-In ConcurrentHashSet<T>

A hash set is essentially a key-only hash table. ConcurrentDictionary<TKey, TValue> already solves most of the concurrency problem, so the framework did not add a separate concurrent set type in the same way it added queue or bag types.

That means a practical concurrent set can be modeled as:

  • dictionary key = set item
  • dictionary value = placeholder such as byte or bool

The value is not meaningful. It just gives the dictionary something to store.

The Standard ConcurrentDictionary Approach

A minimal wrapper looks like this:

csharp
1using System.Collections.Concurrent;
2using System.Collections.Generic;
3
4public sealed class ConcurrentHashSet<T>
5{
6    private readonly ConcurrentDictionary<T, byte> _items;
7
8    public ConcurrentHashSet() : this(null) { }
9
10    public ConcurrentHashSet(IEqualityComparer<T>? comparer)
11    {
12        _items = new ConcurrentDictionary<T, byte>(comparer ?? EqualityComparer<T>.Default);
13    }
14
15    public bool Add(T item) => _items.TryAdd(item, 0);
16
17    public bool Remove(T item) => _items.TryRemove(item, out _);
18
19    public bool Contains(T item) => _items.ContainsKey(item);
20
21    public int Count => _items.Count;
22}

This gives you atomic add and remove behavior with thread-safe lookup.

Example Use from Multiple Threads

csharp
1using System;
2using System.Threading.Tasks;
3
4public class Program
5{
6    public static void Main()
7    {
8        var set = new ConcurrentHashSet<int>();
9
10        Parallel.For(0, 1000, i =>
11        {
12            set.Add(i % 100);
13        });
14
15        Console.WriteLine(set.Count);
16        Console.WriteLine(set.Contains(42));
17    }
18}

Because duplicate keys are ignored by TryAdd, the final count reflects unique items, not insertion attempts.

When a Lock Around HashSet<T> Is Better

A dictionary-backed set is not always the best design. If you need more complex multi-step operations or if several actions must be performed as one atomic block, a normal HashSet<T> with a lock can be easier to reason about.

csharp
1using System.Collections.Generic;
2
3public sealed class LockedHashSet<T>
4{
5    private readonly object _gate = new object();
6    private readonly HashSet<T> _set = new HashSet<T>();
7
8    public bool Add(T item)
9    {
10        lock (_gate)
11        {
12            return _set.Add(item);
13        }
14    }
15
16    public bool Contains(T item)
17    {
18        lock (_gate)
19        {
20            return _set.Contains(item);
21        }
22    }
23}

This can be a better fit if the API needs stronger consistency guarantees than independent concurrent dictionary operations naturally provide.

Enumeration Semantics Matter

This is one area people often skip. Enumerating a concurrent collection while other threads are mutating it is not the same as taking a stable snapshot of a normal HashSet<T>.

With a ConcurrentDictionary-backed set, enumeration is thread-safe, but it may represent a moment-in-time view that does not reflect a single global lock-protected state across the entire iteration.

If your code needs a stable snapshot, copy the items first.

csharp
1var snapshot = new List<int>();
2foreach (var key in setSnapshotSource)
3{
4    snapshot.Add(key);
5}

Or expose a snapshot method that materializes the current keys into a separate list or array.

Alternatives Beyond Mutable Concurrent Sets

Sometimes the best answer is neither a custom concurrent set nor a locked HashSet<T>. Other options include:

  • 'ImmutableHashSet<T> with atomic reference replacement'
  • channel or queue-based ownership so only one thread mutates the set
  • a normal HashSet<T> used only on one actor or scheduler

Those designs can be simpler if the application already has a higher-level concurrency model.

Common Pitfalls

The biggest mistake is assuming HashSet<T> itself becomes safe just because reads are common and writes are rare. It does not. Another is wrapping ConcurrentDictionary but then exposing multi-step operations that are not truly atomic as a whole. Developers also often forget about enumeration behavior and assume a concurrent collection provides a perfectly stable iteration snapshot. Finally, if equality semantics matter, do not forget to pass the appropriate comparer into the underlying dictionary.

Summary

  • .NET Framework does not include a built-in ConcurrentHashSet<T>.
  • The usual implementation uses ConcurrentDictionary<T, byte> as the backing store.
  • A locked HashSet<T> can be better when you need stronger multi-step consistency.
  • Enumeration of concurrent collections is safe but not necessarily a stable snapshot.
  • Choose the concurrency model that matches the operations you actually need, not just the collection name you wish existed.

Course illustration
Course illustration

All Rights Reserved.