Skip to content

Concurrent collections in Dotnet are not as safe as they seem#688

Merged
LukeButters merged 3 commits intomainfrom
luke/concurrent-collections-in-dotnet-are-sad
Sep 15, 2025
Merged

Concurrent collections in Dotnet are not as safe as they seem#688
LukeButters merged 3 commits intomainfrom
luke/concurrent-collections-in-dotnet-are-sad

Conversation

@LukeButters
Copy link
Contributor

@LukeButters LukeButters commented Sep 15, 2025

Background

Concurrent dictionary appears to be surprising in that Func passed to it are not executed in what one would consider to be "thread safe", so resort to manual locking.

ConcurrentBag does interesting things with thread locals, which has lead to strange memory leaks:

so lets just not use it.

For background read: https://octopusdeploy.slack.com/archives/CMESRJ1C3/p1703022896511849

This change is not because we have been seeing issues, but is done preemptively to prevent issues.

The only non test change is the one done to the Redis PRQ.

How to review this PR

Quality ✔️

Pre-requisites

  • I have read How we use GitHub Issues for help deciding when and where it's appropriate to make an issue.
  • I have considered informing or consulting the right people, according to the ownership map.
  • I have considered appropriate testing for my change.

@LukeButters LukeButters requested a review from a team as a code owner September 15, 2025 04:11
@LukeButters LukeButters changed the title Luke/concurrent collections in dotnet are sad Concurrent collections in Dotnet are not as safe as they seem Sep 15, 2025
@LukeButters LukeButters force-pushed the luke/concurrent-collections-in-dotnet-are-sad branch from ba138c3 to c77a0e4 Compare September 15, 2025 04:22
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Replaces concurrent collections with manually locked alternatives to avoid thread safety issues in .NET. The PR addresses potential memory leaks and unexpected behavior with ConcurrentBag and ConcurrentDictionary by using explicit locking patterns instead.

  • Replaced ConcurrentBag with List and manual locking in CancelOnDisposeCancellationToken
  • Replaced ConcurrentDictionary operations with manual locking in CallCountingHalibutRedisTransport
  • Updated documentation to reflect thread safety changes

Reviewed Changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
CancelOnDisposeCancellationToken.cs Replaced ConcurrentBag with List and added lock for thread safety
CallCountingHalibutRedisTransport.cs Added manual locking around ConcurrentDictionary operations

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment on lines +67 to 70
if (tasks.Count > 0)
{
await Task.WhenAll(tasks.Select(t => Try.IgnoringError(() => t)));
}
Copy link

Copilot AI Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The enumeration of tasks for Task.WhenAll is not thread-safe. This operation should be performed within a lock to ensure the collection doesn't change during enumeration.

Suggested change
if (tasks.Count > 0)
{
await Task.WhenAll(tasks.Select(t => Try.IgnoringError(() => t)));
}
List<Task> tasksToAwait;
lock (tasks)
{
tasksToAwait = tasks.Count > 0 ? new List<Task>(tasks) : null;
}
if (tasksToAwait != null)
{
await Task.WhenAll(tasksToAwait.Select(t => Try.IgnoringError(() => t)));
}

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doco suggests not to call dispose concurrently with what can modify the list.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason not to make the locking changes when reading from the collection here to prevent thread safety concerns given the move away from a ConcurrentBag?

Copy link
Contributor Author

@LukeButters LukeButters Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are trying to avoid extra work in the common case which is when we don't have anything to wait on.

This class has surprisingly shown up in the profiler, so performance seems to be a concern :(

I am not concerned about attempting to support unsupported use cases where someone is adding more tasks to wait for on an object they are currently disposing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed tradeoff here:

  • The case I've highlighted could occur if we're trying to access the tasks when we're already disposing, which would still create unpredictable behaviour.
  • The performance concerns around introducing additional locking in CancelOnDisposeCancellationToken means this is not something we'd like to introduce unless there's a strong requirement here.

Will approve this PR given the conscious trade off around performance.

Copy link
Contributor

@david-staniec-octopus david-staniec-octopus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving as per previous comment.

@LukeButters LukeButters merged commit 7de56a8 into main Sep 15, 2025
17 checks passed
@LukeButters LukeButters deleted the luke/concurrent-collections-in-dotnet-are-sad branch September 15, 2025 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants