CodeGym /课程 /C# SELF /ConcurrentBag

ConcurrentBagConcurrentDictionary

C# SELF
第 58 级 , 课程 2
可用

1. 介绍

ConcurrentBag<T> 是一个线程安全的、无序集合。它最大的特点和优势体现在单词“Bag”(袋子)上,这意味着在取出元素时不保证顺序。也就是说,你取出的元素可能不是按添加顺序所期望的那个。相比之下,ConcurrentBag 在某些场景下有独特的优化,使它非常快。

ConcurrentBag 的特点

无序: 与队列(FIFO)和栈(LIFO)不同,ConcurrentBag 不保证 TryTake() 会按任何特定顺序返回元素。这个是它的关键区别

针对本地访问的优化 (Thread-Local Storage): ConcurrentBag 存在的主要原因就是在这样一种场景下具有高性能:添加元素的线程很可能就是随后取出该元素的线程。

示例:ConcurrentBag — 添加和取出

using System.Collections.Concurrent;

ConcurrentBag<string> itemBag = new ConcurrentBag<string>();

// 添加元素
itemBag.Add("条目 A");
itemBag.Add("条目 B");
itemBag.Add("条目 C");

Console.WriteLine($"袋中元素数量: {itemBag.Count}"); // 输出: 袋中元素数量: 3

// 取出元素(顺序不保证!)
if (itemBag.TryTake(out string item1))
{
    Console.WriteLine($"已取出: {item1}"); // 可能是 "条目 C", "条目 B" 或 "条目 A"
}

if (itemBag.TryTake(out string item2))
{
    Console.WriteLine($"已取出: {item2}");
}

Console.WriteLine($"剩余元素数量: {itemBag.Count}"); // 输出: 剩余元素数量: 1

你可以多次运行这段代码,会注意到取出元素的顺序可能会变化。

方法 Add(), TryTake()

Add(T item): 用于向 ConcurrentBag 添加元素。该操作是线程安全的。

TryTake(out T item): 尝试从 ConcurrentBag 取出一个元素。如果成功取出则返回 true,如果袋子为空则返回 false。注意,TryTake 不会阻塞线程。

2. 使用场景

ConcurrentBag 不是用来替代 ConcurrentQueueConcurrentStack。它在一些特定情况下表现出色:

对象/资源池: 当你有一个可重用对象池,并且希望通常返回对象的线程更可能再次取到它时。这样可以减少对共享资源的争用。

在 TPL 中的动态任务分配: 类似 Parallel.ForEachParallel.For 的内部实现使用本地袋子和“work-stealing”机制来高效分配工作。

使用 ConcurrentBag 的任务池并优化本地性

using System.Collections.Concurrent;
using System.Threading.Tasks;
using System.Threading;

ConcurrentBag<string> taskPool = new ConcurrentBag<string>();

// 用初始任务填充池
for (int i = 0; i < 10; i++)
{
    taskPool.Add($"任务 {i}");
}

void Worker()
{
    // 每个线程都尝试取任务
    while (taskPool.TryTake(out string task))
    {
        Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId}: 处理 {task}");
        Thread.Sleep(50); // 模拟工作
    }
    Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId}: 完成工作。");
}

// 启动几个工作线程
// Task.Run(Worker);
// Task.Run(Worker);
// Task.Run(Worker);
// Thread.Sleep(1000); // 给点时间执行

在这个例子里,ConcurrentBag 允许线程高效地取任务,借助内部结构最小化锁竞争。

内部机制

ConcurrentBag 通过使用线程本地存储 (Thread-Local Storage) 提升性能。当线程添加元素时,元素会放到该线程本地的结构里。调用 TryTake() 时会先读取本地结构;如果本地为空,则会从其他线程或全局池执行“work-stealing”。这减少了竞争,对于访问本地性重要且不关心顺序的场景来说,ConcurrentBag 是很好的选择。

3. 线程安全的字典

ConcurrentDictionary<TKey, TValue> 是最常用的线程安全集合之一:用于在多个线程间安全地添加、读取、更新和删除键值对的高性能字典。

普通的 Dictionary<TKey, TValue> 完全不是线程安全的。任何写操作(添加/修改/删除),甚至在写入期间的读取,都可能导致异常 (InvalidOperationException) 或数据损坏。

示例:普通 Dictionary 的问题(复习)

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

Dictionary<int, int> concurrentDictProblem = new Dictionary<int, int>();

void AddToDict(int start, int count)
{
    for (int i = 0; i < count; i++)
    {
        // 尝试同时添加/修改
        // 会导致异常或不正确的行为
        concurrentDictProblem[start + i] = start + i;
    }
}

// 在 Main 中运行示例:
try
{
    Task t1 = Task.Run(() => AddToDict(0, 10000));
    Task t2 = Task.Run(() => AddToDict(5000, 10000)); // 键有重叠
    Task.WaitAll(t1, t2);
    Console.WriteLine($"(有问题)字典中的元素数量: {concurrentDictProblem.Count}");
}
catch (Exception ex)
{
    Console.WriteLine($"普通字典错误: {ex.Message}");
}

这段代码几乎肯定会由于线程安全问题抛出异常或死锁。

4. 主要操作

ConcurrentDictionary 提供了一些专门的原子操作来实现“检查 + 操作”的语义。

TryAdd(TKey key, TValue value): 原子地添加键值对。如果键被添加则返回 true,如果键已存在则返回 false

ConcurrentDictionary<string, int> scores = new ConcurrentDictionary<string, int>();
if (scores.TryAdd("Alice", 100))
    Console.WriteLine("Alice 已添加."); // 输出: Alice 已添加.
if (!scores.TryAdd("Alice", 150))
    Console.WriteLine("Alice 已存在."); // 输出: Alice 已存在.

TryGetValue(TKey key, out TValue value): 原子地按键获取值。

if (scores.TryGetValue("Alice", out int aliceScore))
    Console.WriteLine($"Alice 的分数: {aliceScore}"); // 输出: Alice 的分数: 100

TryUpdate(TKey key, TValue newValue, TValue comparisonValue): 原子地更新值,只有当当前值等于 comparisonValue 时才会更新。用于防止竞态条件。

// 当前 Alice 的值 = 100
if (scores.TryUpdate("Alice", 120, 100)) // 将 100 更新为 120
    Console.WriteLine("Alice 的分数已更新为 120."); // 输出: Alice 的分数已更新为 120.
if (!scores.TryUpdate("Alice", 130, 100)) // 不会更新,因为当前值是 120,而不是 100
    Console.WriteLine("Alice 的分数未更新(数据已过时)。"); // 输出: ...

TryRemove(TKey key, out TValue value): 原子地按键移除元素。

if (scores.TryRemove("Alice", out int removedScore))
    Console.WriteLine($"Alice 已删除,分数为: {removedScore}"); // 输出: Alice 已删除,分数为: 120

5. 进阶原子操作

下面两个方法是 ConcurrentDictionary 的主力,能覆盖很多场景。

GetOrAdd(TKey key, TValue valueFactory(TKey key)): 原子地返回已有的值,或者通过工厂方法创建并添加新的值。非常适合缓存和唯一实体的场景。

// 假设我们缓存一些重量级对象
ConcurrentDictionary<int, HeavyObject> objectCache = new ConcurrentDictionary<int, HeavyObject>();

HeavyObject GetOrCreateHeavyObject(int id)
{
    // 如果已存在 — 返回它,否则通过工厂创建并添加
    return objectCache.GetOrAdd(id, (key) =>
    {
        Console.WriteLine($"为 ID 创建新的 HeavyObject: {key}");
        return new HeavyObject(key); // 模拟创建昂贵对象
    });
}

// 在 Main 中:
HeavyObject obj1 = GetOrCreateHeavyObject(1); // 会创建新的
HeavyObject obj2 = GetOrCreateHeavyObject(2); // 会创建新的
HeavyObject obj3 = GetOrCreateHeavyObject(1); // 会返回已存在的 obj1

AddOrUpdate(TKey key, TValue addValue, Func<TKey, TValue, TValue> updateValueFactory): 原子地添加值(如果键不存在),或通过工厂函数更新已有值。

  • addValue: 如果键不存在时用来添加的值。
  • updateValueFactory: 基于键和当前值计算新值的函数。
// 统计页面访问量
ConcurrentDictionary<string, int> pageViews = new ConcurrentDictionary<string, int>();

void IncrementPageView(string page)
{
    pageViews.AddOrUpdate(page, 1, // 如果是新页面,添加 1
                          (key, existingVal) => existingVal + 1); // 否则加 1
    Console.WriteLine($"页面 '{page}' 被访问了 {pageViews[page]} 次。");
}

// 在 Main:
IncrementPageView("Home");   // Home: 1
IncrementPageView("About");  // About: 1
IncrementPageView("Home");   // Home: 2
IncrementPageView("Home");   // Home: 3
IncrementPageView("Contact"); // Contact: 1

6. 缓存或状态管理的使用示例

数据缓存: ConcurrentDictionary 是内存缓存的优秀选择:使用 GetOrAdd 可以避免重复创建昂贵对象。

管理用户会话: 在多个请求中安全地存储和更新会话数据。

统计计数: 使用 AddOrUpdate 很方便地递增事件计数、页面浏览量、投票数等。

注册表/Service Locator: 存储已注册的服务或插件,以便从不同线程访问。

ConcurrentDictionary<TKey, TValue> 是一个高度优化的集合,通过一组原子操作大大简化了多线程环境下对字典的操作,无需手动同步。

2
任务
C# SELF, 第 58 级, 课程 2
已锁定
使用 `ConcurrentDictionary` 实现原子统计
使用 `ConcurrentDictionary` 实现原子统计
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION