Ответ
Потокобезопасная коллекция — это структура данных, предназначенная для безопасного использования из нескольких потоков одновременно без необходимости внешней синхронизации с помощью lock. В .NET они расположены в пространстве имен System.Collections.Concurrent.
Зачем они нужны? При работе с обычными коллекциями (List<T>, Dictionary<TKey, TValue>) из нескольких потоков требуется ручная синхронизация. Потокобезопасные коллекции инкапсулируют эту логику внутри себя, упрощая разработку и снижая риск ошибок (гонок данных, deadlock).
Основные типы и их применение:
| Коллекция | Описание | Аналог из System.Collections.Generic |
|---|---|---|
ConcurrentBag<T> |
Неупорядоченная коллекция, оптимизированная для сценариев, где один поток добавляет и удаляет данные. | List<T> (но без порядка) |
ConcurrentQueue<T> |
Потокобезопасная очередь FIFO (First-In-First-Out). | Queue<T> |
ConcurrentStack<T> |
Потокобезопасный стек LIFO (Last-In-First-Out). | Stack<T> |
ConcurrentDictionary<TKey, TValue> |
Потокобезопасный словарь. Самый часто используемый тип. | Dictionary<TKey, TValue> |
BlockingCollection<T> |
Коллекция с ограниченной емкостью, которая блокирует поток при попытке взять элемент из пустой коллекции или добавить в полную. Реализует шаблон Producer-Consumer. | Нет прямого аналога |
Пример использования ConcurrentDictionary:
using System.Collections.Concurrent;
// Создание потокобезопасного кеша в памяти
ConcurrentDictionary<string, DateTime> userLastActivityCache = new();
// Метод, который могут вызывать множество потоков одновременно
public void UpdateUserActivity(string userId)
{
// AddOrUpdate атомарно добавляет или обновляет запись
userLastActivityCache.AddOrUpdate(
key: userId,
addValueFactory: id => DateTime.UtcNow, // Если ключа нет — создаем новую запись
updateValueFactory: (id, oldValue) => DateTime.UtcNow // Если ключ есть — обновляем
);
// Потокобезопасное чтение
if (userLastActivityCache.TryGetValue(userId, out DateTime lastSeen))
{
Console.WriteLine($"User {userId} was last seen at {lastSeen}");
}
}
Важные нюансы:
- Атомарность операций: Методы типа
AddOrUpdate,GetOrAddвConcurrentDictionaryявляются атомарными. Однако последовательность вызовов этих методов уже не атомарна. - Итерация: Итерация по коллекции (
foreach) выполняется по snapshot (снимку) данных на момент начала перебора. Элементы, добавленные или удаленные во время итерации, не будут отражены. - Не для всех сценариев: Для сложных составных операций, требующих транзакционности над несколькими коллекциями, все еще может потребоваться внешняя блокировка (
lock).
Ответ 18+ 🔞
Ну, смотри, есть же обычные коллекции — листы, словарики, всё такое. Берёшь их в многопоточке и сразу получаешь пиздец, потому что один поток пишет, другой читает, а третий уже удаляет — и всё, гонка данных, выстрел в ногу, приложение падает с душераздирающими ошибками.
Так вот, чтобы не ебаться с lock на каждом чихе, в .NET придумали потокобезопасные коллекции. Они живут в System.Collections.Concurrent и сами внутри себя всё синхронизируют. То есть снаружи ты просто вызываешь методы, а они уже не дадут двум потокам одновременно накосячить в одной куче данных.
Основные бандиты в этой семейке:
ConcurrentBag<T>— это как мешок, туда всё скидывается без порядка. Оптимально, когда один поток и кладёт, и забирает. Если потоки разные — может просесть по производительности.ConcurrentQueue<T>— обычная очередь (первый зашёл — первый вышел), но для потоков. Классика для задач типа «производитель-потребитель».ConcurrentStack<T>— стек (последний зашёл — первый вышел), тоже потокобезопасный.ConcurrentDictionary<TKey, TValue>— это, блядь, звезда полей! Самый часто используемый. Потокобезопасный словарь. Когда нужно кэшировать что-то общее для всех потоков — это твой выбор.BlockingCollection<T>— хитрая штука. Она может блокировать поток, который пытается взять элемент из пустой коллекции, и будить его, когда там что-то появится. Или наоборот — не давать запихнуть в уже полную. Очень удобно для тех же производителей-потребителей, когда нужно контролировать нагрузку.
Вот смотри, как ConcurrentDictionary в деле выглядит:
using System.Collections.Concurrent;
// Делаем кэш последней активности пользователей, который будут долбить 100500 потоков
ConcurrentDictionary<string, DateTime> userLastActivityCache = new();
// Метод, который может вызываться одновременно из кучи мест
public void UpdateUserActivity(string userId)
{
// AddOrUpdate — это атомарная операция "или добавь, или обнови". Внутри всё залочено.
userLastActivityCache.AddOrUpdate(
key: userId,
addValueFactory: id => DateTime.UtcNow, // Если такого юзера нет — создаём запись
updateValueFactory: (id, oldValue) => DateTime.UtcNow // Если есть — просто обновляем время
);
// Потокобезопасное получение значения
if (userLastActivityCache.TryGetValue(userId, out DateTime lastSeen))
{
Console.WriteLine($"User {userId} was last seen at {lastSeen}");
}
}
Но есть, конечно, и подводные ебеня:
- Атомарность — она не везде. Отдельные операции типа
AddOrUpdate— атомарны. Но если ты делаешь несколько таких вызовов подряд (проверил ключ, потом добавил), то между ними состояние словаря могло уже поменяться другим потоком. Для сложной логики всё равно иногда приходится наружуlockвытаскивать. - Перебор (
foreach) работает по снимку данных. Начал итерировать — получил снепшот коллекции на тот момент. Что добавили или удалили после — не увидишь до следующего перебора. - Это не серебряная пуля. Если у тебя сложная транзакция, которая должна атомарно изменить пять коллекций сразу, то одними
Concurrent-коллекциями не обойдёшься. Придётся городить свою синхронизацию.
Короче, инструмент охуенный и нужный, но понимать, как он работает внутри — обязательно, чтобы не получить сюрприз в виде еле ползущего приложения или внезапной гонки там, где ты её не ждал.