Потокобезопасные коллекции и Channels
↑ 2.3 Асинхронность и многопоточность · ← Предыдущая · → Следующая
Зачем это на собесе
Спрашивают, какую коллекцию выбрать для очереди задач или кэша и чем Channel лучше BlockingCollection.
Объяснение
System.Collections.Concurrent
| Тип | Назначение | Замечание |
|---|---|---|
ConcurrentDictionary<K,V> | кэш, реестр | GetOrAdd/AddOrUpdate атомарны, но фабрики могут выполниться несколько раз |
ConcurrentQueue<T> | FIFO, lock-free | нет блокирующего ожидания |
ConcurrentStack<T> | LIFO | |
ConcurrentBag<T> | неупорядоченный пул, оптимален когда продюсер = консюмер | сложнее в отладке |
BlockingCollection<T> | блокирующая очередь producer/consumer | блокирует потоки, не async |
ImmutableArray/ImmutableDictionary | копирование при записи, безопасное чтение | подходит для конфигураций |
Channel<T>
Асинхронная очередь producer/consumer из System.Threading.Channels.
var channel = Channel.CreateBounded<Job>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false,
SingleWriter = false,
});
// Продюсер
async Task ProduceAsync(IEnumerable<Job> jobs, CancellationToken ct)
{
foreach (var j in jobs)
await channel.Writer.WriteAsync(j, ct); // ждёт, если канал полон
channel.Writer.Complete();
}
// Консюмеры
async Task ConsumeAsync(CancellationToken ct)
{
await foreach (var job in channel.Reader.ReadAllAsync(ct))
await job.RunAsync(ct);
}
await Task.WhenAll(
ProduceAsync(source, ct),
ConsumeAsync(ct), ConsumeAsync(ct), ConsumeAsync(ct));Ограниченный (bounded) канал даёт backpressure: медленные потребители тормозят производителя, память не растёт бесконечно. Unbounded канал безопаснее только если поток данных заведомо мал.
Нюансы и подводные камни
ConcurrentDictionaryпотокобезопасен для отдельных операций, но составные («прочитал — проверил — записал») не атомарны.- Перечисление
ConcurrentDictionaryдаёт снимок без гарантии согласованности. - Не забывайте
Writer.Complete(), иначе читатели будут ждать вечно. - В
ChannelвыбирайтеFullModeосознанно:Wait,DropOldest,DropNewest,DropWrite. SingleReader/SingleWriter = trueвключает более быстрые реализации, но нарушение контракта приводит к ошибкам.
Практика
- Построить конвейер «читаем файлы → парсим → пишем в БД» на трёх каналах.
- Заменить
BlockingCollectionв фоновом воркере наChannelи сравнить использование потоков. - Реализовать кэш на
ConcurrentDictionary<string, Lazy<Task<T>>>, чтобы дорогая загрузка выполнялась один раз.
Вопросы с ответами
Чем Channel лучше BlockingCollection?
Channel полностью асинхронный (не блокирует потоки), поддерживает bounded/unbounded,
CancellationTokenиIAsyncEnumerable, быстрее на типичных нагрузках.
Почему в ConcurrentDictionary.GetOrAdd фабрика может вызваться дважды?
Пока один поток вычисляет значение, другой может тоже не найти ключ и вызвать фабрику; в словарь попадёт только одно значение. Для однократности используют
Lazy<T>.
Что такое backpressure?
Механизм, при котором потребитель сообщает производителю о перегрузке. В Channel это ограниченная ёмкость и
WriteAsync, который ждёт свободного места.