Как показать, что шаблон с двойной проверкой с помощью словаря TryGetValue не является потоковым

Недавно я видел несколько проектов на С#, которые используют шаблон с двойной проверкой на Dictionary. Что-то вроде этого:

private static readonly object _lock = new object();
private static volatile IDictionary<string, object> _cache = 
    new Dictionary<string, object>();

public static object Create(string key)
{
    object val;
    if (!_cache.TryGetValue(key, out val))
    {
        lock (_lock)
        {
            if (!_cache.TryGetValue(key, out val))
            {
                val = new object(); // factory construction based on key here.
                _cache.Add(key, val);
            }
        }
    }
    return val;
}

Этот код неверен, так как Dictionary может "увеличивать" коллекцию в _cache.Add(), а _cache.TryGetValue (вне блокировки) выполняет итерацию по коллекции. Это может быть крайне маловероятным во многих ситуациях, но по-прежнему не так.

Есть ли простая программа, демонстрирующая, что этот код не работает?

Имеет ли смысл включить это в unit test? И если да, то как?

Ответы

Ответ 1

В этом примере исключение # 1 бросается почти мгновенно на мою машину:

var dict = new Dictionary<int, string>() { { 1234, "OK" } };

new Thread(() =>
{
    for (; ; )
    {
        string s;
        if (!dict.TryGetValue(1234, out s))
        {
            throw new Exception();  // #1
        }
        else if (s != "OK")
        {
            throw new Exception();  // #2
        }
    }
}).Start();

Thread.Sleep(1000);
Random r = new Random();
for (; ; )
{
    int k;
    do { k = r.Next(); } while (k == 1234);
    Debug.Assert(k != 1234);
    dict[k] = "FAIL";
}

Тем не менее, точное поведение кода, который не предназначен для потоковой защиты, непредсказуем.
Вы не можете полагаться на него. Таким образом, код двойной проверки действительно сломан.

Я не уверен, что если бы я был unit test, это, так как тестирование параллельного кода (и правильное его) намного сложнее, чем запись параллельного кода в первую очередь.

Ответ 2

Очевидно, что код не является потокобезопасным. То, что мы имеем здесь, является явным примером опасностей преждевременной оптимизации.

Помните, что целью двойной проверки блокировки является улучшение производительности кода за счет исключения стоимости блокировки. Если замок не оспаривается, он уже невероятно дешев. Таким образом, дважды проверенный шаблон блокировки оправдан только в случаях (1), где блокировка будет сильно оспариваться, или (2), где код настолько невероятно чувствителен к производительности, что стоимость незаконсервированной блокировки все еще слишком высокий.

Ясно, что мы не во втором случае. Вы используете словарь ради бога. Даже без блокировки он будет выполнять поиск и сравнения, которые будут в сотни или тысячи раз дороже, чем экономия, позволяющая избежать неоспоримой блокировки.

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

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

Ответ 3

Я действительно не думаю, что вам нужно это доказать, вам просто нужно отправить людей в документацию для Dictionary<TKey, TValue>:

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

На самом деле это хорошо известный факт (или должен быть), который вы не можете прочитать из словаря, пока на него пишет другой поток. Я видел несколько "странных проблем с многопоточными проблемами" здесь, на SO, где выяснилось, что автор не понимал, что это небезопасно.

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


Я сделаю еще один шаг и покажу вам, почему в Reflector это не потокобезопасно:

private int FindEntry(TKey key)
{
    // Snip a bunch of code
    for (int i = this.buckets[num % this.buckets.Length]; i >= 0;
        i = this.entries[i].next)
    // Snip a bunch more code
}

private void Resize()
{
    int prime = HashHelpers.GetPrime(this.count * 2);
    int[] numArray = new int[prime];
    // Snip a whole lot of code
    this.buckets = numArray;
}

Посмотрите, что может случиться, если метод Resize работает, хотя даже один читатель вызывает FindEntry:

  • Thread A: добавляет элемент, приводящий к динамическому изменению размера;
  • Thread B: вычисляет смещение ковша как (хеш-код% count),
  • Тема A: изменяет ведра на другой (простой) размер;
  • Thread B: выбирает индекс элемента из нового массива ковша в индексе старого ковша;
  • Указатель Thread B больше не действителен.

И это именно то, что терпит неудачу в примере dtb. Тема A ищет ключ, который известен заранее, чтобы быть в словаре, и все же он не найден. Зачем? Поскольку метод FindValue выбрал то, что, по его мнению, было правильным ведром, но до того, как он даже имел возможность заглянуть внутрь, Thread B изменил ведра, и теперь Thread A ищет в некотором абсолютно случайном ведре, который не содержит или даже не ведет справа.

Мораль истории: TryGetValue не является атомной операцией, а Dictionary<TKey, TValue> не является потокобезопасным классом. Это не просто параллельные записи, о которых вам нужно беспокоиться; вы не можете одновременно выполнять операции чтения и записи.

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

Ответ 4

Я думаю, что этот вопрос возникает снова и снова:

Pre-2.0, Before Generics (BG), Hashtable был основным ассоциативным контейнером в .NET, который действительно обеспечивает некоторую потоковую обработку гарантии. Из MSDN:
"Hashtable является потокобезопасным для использования несколькими потоками считывателей и одним потоком записи. Он является потокобезопасным для многопоточного использования, когда только один из потоков выполняет операции записи (обновления), что позволяет читать без блокировки при условии, что авторы сериализуются в Hashtable."

Прежде чем кто-нибудь станет очень взволнованным, есть некоторые ограничения.
См. этот пост от Брэда Абрамса, которому принадлежит Hashtable.
Еще один исторический фон на Hashtable можно найти здесь (... ближе к концу: "После этой длинной утечки - как насчет Hashtable?" ).

Почему Dictionary<TKey, TValue> не работает в приведенном выше случае:

Чтобы доказать, что это не удается, достаточно найти один пример, поэтому я попробую именно это.
Изменение размера происходит по мере роста таблицы.
При изменении размера происходит переделка, и это рассматривается как две последние строки:

this.buckets = newBuckets;
//One of the problems here.
this.entries = newEntries;

В массиве buckets содержатся индексы в массиве entries. Скажем, у нас есть 10 записей, и сейчас мы добавляем новое.
Позвольте еще притворяться ради простоты, что мы этого не сделали и не столкнемся. В старом buckets у нас были индексы от 0 до 9, если у нас не было столкновений.
Теперь индексы в новом массиве buckets работают от 0 до 10 (!).
Теперь мы изменим приватное поле buckets, чтобы указать на новые ведра.
Если в данный момент читатель делает TryGetValue(), он использует новые ковши для получения индекса, но затем использует новый индекс для чтения в массив старых записей, поскольку поле entries все еще указывает на старые записи.
Одна из вещей, которую можно получить - помимо ложных чтений - это дружественный IndexOutOfRangeException.
Еще один "отличный" способ получить это в объяснении @Aaronaught. (... и оба могут произойти, например, в примере dtb).

Это действительно один из примеров: Dictonary не был спроектирован и не предназначен для потокобезопасности. Он был разработан так, чтобы быть быстрым, однако это означает, что замок не будет удерживаться долго.

Ответ 5

Включая код в вопрос, вы можете проверить его с помощью следующего кода.

//using System.Collections.Generic;
//using System.Threading;

private static volatile int numRunning = 2;
private static volatile int spinLock = 0;

static void Main(string[] args)
{
    new Thread(TryWrite).Start();
    new Thread(TryWrite).Start();
}

static void TryWrite()
{
    while(true) 
    {
        for (int i = 0; i < 1000000; i++ )
        {
            Create(i.ToString());
        }

        Interlocked.Decrement(ref numRunning);
        while (numRunning > 0) { } // make sure every thread has passed the previous line before proceeding (call this barrier 1)

        while (Interlocked.CompareExchange(ref spinLock, 1, 0) != 0){Thread.Sleep(0);} // Aquire lock (spin lock)
        // only one thread can be here at a time...

        if (numRunning == 0) // only the first thread to get here executes this...
        {
            numRunning = 2; // resets barrier 1
            // since the other thread is beyond the barrier, but is waiting on the spin lock,
            //  nobody is accessing the cache, so we can clear it...
            _cache = new Dictionary<string, object>(); // clear the cache... 
        }

        spinLock = 0; // release lock...
    }

}

Эта программа просто пытается получить Create, чтобы пересечь коллекцию, когда она "выросла". Он должен запускаться на машине с не менее чем двумя ядрами (или двумя процессорами) и, скорее всего, сработает через некоторое время с этим исключением.

System.Collections.Generic.Dictionary`2.FindEntry(TKey key)

Добавление этого теста затруднено, так как оно является вероятностным тестом, и вы не знаете, сколько времени потребуется для отказа (если когда-либо). Я думаю, вы могли бы выбрать значение, равное 10 секундам, и позволить ему работать так долго. Если это не сработает в течение этого промежутка времени, тест пройдет. Не лучшее, но что-то. Вы также должны проверить, что Environment.ProcessorCount > 1 перед запуском теста, в противном случае вероятность его отказа будет незначительной.