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


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(...);
}

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


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: Перечисление было фактически единственной причиной, по которой я выставлял объект синхронизации. По соглашению я бы заблокировал этот объект только при перечислении через коллекцию.
— ГП.

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. Вы реализуете небезопасный интерфейс с потокобезопасным классом. ИМХО это будет стоить вам в будущем

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


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);
    }

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.