Как показать, что шаблон двойной проверки блокировки с Trygetvalue словаря не является threadsafe

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

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 (вне блокировки) выполняет итерацию по коллекции. Это может быть крайне маловероятно во многих ситуациях, но все равно неправильно.

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

имеет ли смысл включить это в модульный тест? И если да, то как?

5 ответов


в этом примере исключение #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";
}

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

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


очевидно, что код не является threadsafe. Что у нас тут явный случай опасности преждевременной оптимизации.

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

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

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

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


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

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

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

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


Я сделаю еще один шаг и покажу вам, почему в 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:

  1. поток A: добавляет элемент, приводящий к динамическому изменению размера;
  2. поток B: вычисляет смещение ведра как (хэш-код % количество ведер);
  3. поток A: изменяет ведра, чтобы иметь другой (простой) размер;
  4. поток B: выбирает индекс элемента из новая массив ведра на старый ведре;
  5. указатель потока B больше не действителен.

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

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

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


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

Пред-2.0, До Обобщения (Б. Г.), Hashtable был основным ассоциативным контейнером в .NET,который действительно предоставляет некоторые гарантии потоковой передачи. От MSDN:
" 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(!).
Теперь мы меняем private buckets поле для указания на новые ведра.
Если есть читатель делает TryGetValue() на данный момент, он использует новая ведра, чтобы получить индекс, а затем использует новая индекс для чтения в the старый массив записей, начиная с entries поле по-прежнему указывает на старые записи.
Одна из вещей, которую можно получить-помимо ложных чтений-это дружественный IndexOutOfRangeException.
Еще один "отличный" способ получить это в @Aaronaught это объяснение. (...и оба могут произойти, например, как в ДТБ это примеру).

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


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

//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 перед запуском теста, в противном случае вероятность его провала-это мизер.