Как лучше всего реализовать потокобезопасный словарь?

109

Мне удалось реализовать потокобезопасный словарь на C #, унаследовав его от IDictionary и определив частный объект SyncRoot:

public class SafeDictionary<TKey, TValue>: IDictionary<TKey, TValue>
{
    private readonly object syncRoot = new object();
    private Dictionary<TKey, TValue> d = new Dictionary<TKey, TValue>();

    public object SyncRoot
    {
        get { return syncRoot; }
    } 

    public void Add(TKey key, TValue value)
    {
        lock (syncRoot)
        {
            d.Add(key, value);
        }
    }

    // more IDictionary members...
}

Затем я блокирую этот объект SyncRoot для всех моих потребителей (несколько потоков):

Пример:

lock (m_MySharedDictionary.SyncRoot)
{
    m_MySharedDictionary.Add(...);
}

Мне удалось заставить его работать, но это привело к некрасивому коду. Мой вопрос: есть ли лучший и более элегантный способ реализации поточно-ориентированного словаря?

GP.
источник
3
Что вы находите в этом уродливого?
Asaf R
1
Я думаю, он имеет в виду все операторы блокировки, которые он использует в своем коде в потребителях класса SharedDictionary - он блокирует вызывающий код каждый раз, когда он обращается к методу объекта SharedDictionary.
Питер Мейер,
Вместо использования метода Add попробуйте присвоить значения ex-m_MySharedDictionary ["key1"] = "item1", это потокобезопасно.
testuser

Ответы:

43

Как сказал Питер, вы можете инкапсулировать всю потокобезопасность внутри класса. Вам нужно будет быть осторожным с любыми открытыми или добавляемыми событиями, убедившись, что они вызываются вне любых блокировок.

public class SafeDictionary<TKey, TValue>: IDictionary<TKey, TValue>
{
    private readonly object syncRoot = new object();
    private Dictionary<TKey, TValue> d = new Dictionary<TKey, TValue>();

    public void Add(TKey key, TValue value)
    {
        lock (syncRoot)
        {
            d.Add(key, value);
        }
        OnItemAdded(EventArgs.Empty);
    }

    public event EventHandler ItemAdded;

    protected virtual void OnItemAdded(EventArgs e)
    {
        EventHandler handler = ItemAdded;
        if (handler != null)
            handler(this, e);
    }

    // more IDictionary members...
}

Изменить: документы MSDN указывают, что перечисление по своей сути не является потокобезопасным. Это может быть одной из причин раскрытия объекта синхронизации за пределами вашего класса. Другой способ подхода - предоставить некоторые методы для выполнения действия над всеми членами и заблокировать перечисление членов. Проблема заключается в том, что вы не знаете, вызывает ли действие, переданное этой функции, какой-либо член вашего словаря (что может привести к тупиковой ситуации). Открытие объекта синхронизации позволяет потребителю принимать эти решения и не скрывает тупик внутри вашего класса.

Fryguybob
источник
@fryguybob: Перечисление было фактически единственной причиной, по которой я выставлял объект синхронизации. По соглашению я бы заблокировал этот объект только при перечислении через коллекцию.
ГП.
1
Если ваш словарь не слишком велик, вы можете выполнить перечисление по копии и встроить ее в класс.
fryguybob 01
2
Мой словарь не слишком велик, и я думаю, что это помогло. Что я сделал, так это создал новый общедоступный метод под названием CopyForEnum (), который возвращает новый экземпляр словаря с копиями частного словаря. Затем этот метод был вызван для перечисления, и SyncRoot был удален. Спасибо!
ГП.
12
Это также не по своей сути потокобезопасный класс, так как словарные операции обычно детализированы. Немного логики в духе if (dict.Contains (something)) {dict.Remove (something); dict.Add (что угодно, newval); }, несомненно, ожидает своего появления состояние гонки.
плинтус
207

Назван класс .NET 4.0, поддерживающий параллелизм ConcurrentDictionary.

Гектор Корреа
источник
4
Пожалуйста, отметьте это как ответ, вам не нужен собственный словарь, если в собственном .Net есть решение
Альберто Леон,
27
(Помните, что другой ответ был написан задолго до появления .NET 4.0 (выпущенного в 2010 году).)
Джепп Стиг Нильсен,
1
К сожалению, это не безблокировочное решение, поэтому оно бесполезно в безопасных сборках SQL Server CLR. Вам понадобится что-то вроде того, что описано здесь: cse.chalmers.se/~tsigas/papers/Lock-Free_Dictionary.pdf или, возможно, эта реализация: github.com/hackcraft/Ariadne
Triynko
2
На самом деле старый, я знаю, но важно отметить, что использование ConcurrentDictionary и Dictionary может привести к значительным потерям производительности. Скорее всего, это результат дорогостоящего переключения контекста, поэтому убедитесь, что вам нужен потокобезопасный словарь, прежде чем его использовать.
беспородный 07
60

Попытки внутренней синхронизации почти наверняка будет недостаточно, потому что она находится на слишком низком уровне абстракции. Скажем , вы делаете Addи ContainsKeyоперации по отдельности поточно- следующим образом :

public void Add(TKey key, TValue value)
{
    lock (this.syncRoot)
    {
        this.innerDictionary.Add(key, value);
    }
}

public bool ContainsKey(TKey key)
{
    lock (this.syncRoot)
    {
        return this.innerDictionary.ContainsKey(key);
    }
}

Что тогда происходит, когда вы вызываете этот якобы потокобезопасный фрагмент кода из нескольких потоков? Всегда ли будет работать нормально?

if (!mySafeDictionary.ContainsKey(someKey))
{
    mySafeDictionary.Add(someKey, someValue);
}

Простой ответ - нет. В какой-то момент Addметод вызовет исключение, указывающее, что ключ уже существует в словаре. Вы можете спросить, как это может быть с поточно-ориентированным словарем? Просто потому, что каждая операция является потокобезопасной, комбинация двух операций - нет, поскольку другой поток может изменить ее между вашим вызовом ContainsKeyи Add.

Это означает, что для правильного написания этого типа сценария вам понадобится блокировка вне словаря, например

lock (mySafeDictionary)
{
    if (!mySafeDictionary.ContainsKey(someKey))
    {
        mySafeDictionary.Add(someKey, someValue);
    }
}

Но теперь, когда вам нужно написать код внешней блокировки, вы смешиваете внутреннюю и внешнюю синхронизацию, что всегда приводит к таким проблемам, как нечеткий код и взаимоблокировки. Так что в конечном итоге вам, вероятно, лучше:

  1. Используйте обычный Dictionary<TKey, TValue>и внешнюю синхронизацию, включив в него составные операции, или

  2. Напишите новую потокобезопасную оболочку с другим интерфейсом (т. Е. Нет IDictionary<T>), которая объединяет такие операции, как AddIfNotContainedметод, поэтому вам никогда не придется комбинировать операции из него.

(Я сам предпочитаю №1)

Грег Бич
источник
10
Стоит отметить, что .NET 4.0 будет включать в себя целый набор поточно-ориентированных контейнеров, таких как коллекции и словари, которые имеют другой интерфейс по сравнению со стандартной коллекцией (т.е. они используют вариант 2 выше для вас).
Грег Бич,
1
Также стоит отметить, что гранулярность, предлагаемая даже грубой блокировкой, часто бывает достаточной для подхода с одним записывающим устройством и несколькими читателями, если разработать подходящий перечислитель, который позволяет базовому классу знать, когда он удаляется (методы, которые хотели бы записать словарь, пока существует нераспределенный перечислитель, который должен заменить словарь его копией).
supercat 03
6

Вы не должны публиковать свой частный объект блокировки через свойство. Объект блокировки должен существовать в частном порядке с единственной целью - действовать как точка встречи.

Если при использовании стандартной блокировки производительность оказывается низкой, то набор блокировок Wintellect Power Threading может оказаться очень полезным.

Джонатан Уэбб
источник
5

Есть несколько проблем с описываемым вами способом реализации.

  1. Вы никогда не должны раскрывать свой объект синхронизации. Это откроет вам доступ к потребителю, который схватит объект и заблокирует его, и тогда вы будете тостом.
  2. Вы реализуете небезопасный интерфейс с потокобезопасным классом. ИМХО это будет стоить вам в будущем

Лично я нашел, что лучший способ реализовать потокобезопасный класс - это неизменяемость. Это действительно снижает количество проблем, с которыми вы можете столкнуться с безопасностью потоков. Посетите Блог Эрика Липперта для получения более подробной информации.

JaredPar
источник
3

Вам не нужно блокировать свойство SyncRoot в ваших потребительских объектах. У вас есть блокировка в методах словаря.

Для уточнения: в конечном итоге ваш словарь заблокирован на более длительный период времени, чем необходимо.

В вашем случае происходит следующее:

Скажем, поток A получает блокировку SyncRoot перед вызовом m_mySharedDictionary.Add. Затем поток B пытается получить блокировку, но блокируется. Фактически, все остальные потоки заблокированы. Потоку A разрешено вызывать метод Add. В операторе блокировки в методе Add потоку A разрешается снова получить блокировку, потому что он уже владеет ею. После выхода из контекста блокировки в методе, а затем вне метода, поток A снял все блокировки, позволяя другим потокам продолжить работу.

Вы можете просто разрешить любому потребителю вызывать метод Add, поскольку оператор блокировки в методе Add вашего класса SharedDictionary будет иметь такой же эффект. На данный момент у вас есть избыточная блокировка. Вы могли бы заблокировать SyncRoot только вне одного из методов словаря, если бы вам нужно было выполнить две операции с объектом словаря, которые должны были гарантированно выполняться последовательно.

Питер Мейер
источник
1
Неправда ... если вы выполняете две операции за другой, которые внутренне потокобезопасны, это не означает, что весь блок кода является потокобезопасным. Например: if (! MyDict.ContainsKey (someKey)) {myDict.Add (someKey, someValue); } не будет потокобезопасным, даже если ContainsKey и Add являются потокобезопасными
Тим
Ваша точка зрения верна, но не соответствует моему ответу и вопросу. Если вы посмотрите на вопрос, он не говорит о вызове ContainsKey, как и мой ответ. Мой ответ относится к получению блокировки SyncRoot, что показано в примере в исходном вопросе. В контексте оператора блокировки одна или несколько поточно-ориентированных операций действительно будут выполняться безопасно.
Питер Мейер
Я предполагаю, что все, что он когда-либо делал, - это добавление в словарь, но поскольку у него есть «// больше членов IDictionary ...», я предполагаю, что в какой-то момент он также захочет прочитать данные из словаря. Если это так, то должен быть какой-то доступный извне механизм блокировки. Не имеет значения, является ли это SyncRoot в самом словаре или другой объект, используемый исключительно для блокировки, но без такой схемы общий код не будет потокобезопасным.
Тим
Внешний механизм блокировки такой, как он показывает в своем примере в вопросе: lock (m_MySharedDictionary.SyncRoot) {m_MySharedDictionary.Add (...); } - было бы совершенно безопасно сделать следующее: lock (m_MySharedDictionary.SyncRoot) {if (! m_MySharedDictionary.Contains (...)) {m_MySharedDictionary.Add (...); }} Другими словами, внешний механизм блокировки - это оператор блокировки, который работает с общедоступным свойством SyncRoot.
Питер Мейер
0

Просто мысль, почему бы не воссоздать словарь? Если чтение - это множество записей, то блокировка синхронизирует все запросы.

пример

    private static readonly object Lock = new object();
    private static Dictionary<string, string> _dict = new Dictionary<string, string>();

    private string Fetch(string key)
    {
        lock (Lock)
        {
            string returnValue;
            if (_dict.TryGetValue(key, out returnValue))
                return returnValue;

            returnValue = "find the new value";
            _dict = new Dictionary<string, string>(_dict) { { key, returnValue } };

            return returnValue;
        }
    }

    public string GetValue(key)
    {
        string returnValue;

        return _dict.TryGetValue(key, out returnValue)? returnValue : Fetch(key);
    }
вербедр
источник