Потокобезопасное напоминание - PullRequest
23 голосов
/ 10 августа 2009

Давайте возьмем подход Уэса Дайера к запоминанию функций в качестве отправной точки:

public static Func<A, R> Memoize<A, R>(this Func<A, R> f)
{
  var map = new Dictionary<A, R>();
  return a =>
    {
      R value;
      if (map.TryGetValue(a, out value))
        return value;
      value = f(a);
      map.Add(a, value);
      return value;
    };
}

Проблема в том, что при использовании его из нескольких потоков мы можем столкнуться с проблемами:

Func<int, int> f = ...
var f1 = f.Memoize();
...
in thread 1:
var y1 = f1(1);
in thread 2:
var y2 = f1(1);
// We may be recalculating f(1) here!

Давайте попробуем избежать этого. Блокировка на map:

public static Func<A, R> Memoize<A, R>(this Func<A, R> f)
{
  var map = new Dictionary<A, R>();
  return a =>
    {
      R value;
      lock(map) 
      {
        if (map.TryGetValue(a, out value))
          return value;
        value = f(a);
        map.Add(a, value);
      }
        return value;
    };
}

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

Вот два варианта, о которых я могу подумать:

Предполагая класс Lazy<T> для ленивых вычислений (см. здесь ):

public static Func<A, R> Memoize<A, R>(this Func<A, R> f)
{
  var map = new Dictionary<A, Lazy<R>>();
  return a =>
    {
      Lazy<R> result;
      lock(map) 
      {
        if (!map.TryGetValue(a, out result))
        {  
          result = () => f(a);
          map.Add(a, result);
        }
      }
      return result.Value;
    };
}

Или ведение дополнительного словаря объектов для синхронизации:

public static Func<A, R> Memoize<A, R>(this Func<A, R> f)
{
  var map = new Dictionary<A, R>();
  var mapSync = new Dictionary<A, object>();
  return a =>
    {
      R value;
      object sync;
      lock(mapSync)
      { 
        if (!mapSync.TryGetValue(a, out sync))
        { 
          sync = new object();
          mapSync[a] = sync;
        }
      }
      lock(map)
      {
        if (map.TryGetValue(a, out value))
          return value;
      }
      lock(sync)
      {
        value = f(a);
        lock(map)
        {
          map[a] = value;
        }
        return value;
      }
    };
}

Есть ли лучшие варианты?

Ответы [ 7 ]

37 голосов
/ 17 февраля 2012

Используйте .net 4.0's ConcurrentDictionary<A, R> без лишних Lazy<R>.
Ключ GetOrAdd(A, Func<A, R>), который превращается в красивую тривиальную лямбду.

public static Func<A, R> Memoize<A, R>(this Func<A, R> f)
{
    var cache = new ConcurrentDictionary<A, R>();
    return a => cache.GetOrAdd(a, f);
};

Обновление Приведенное выше решение позволяет одновременно использовать несколько устройств чтения и записи с минимальными накладными расходами. Но это не препятствует выполнению f(a) более одного раза для одного и того же значения (в течение периода, пока оно вычисляется).

Если это жизненно важно для вас, вы можете обернуть значение в Lazy<R>, но вы платите за каждое чтение.

public static Func<A, R> Memoize<A, R>(this Func<A, R> f)
{
    var cache = new ConcurrentDictionary<A, Lazy<R>>();
    return a => cache.GetOrAdd(a, new Lazy<R>(() => f(a))).Value;
}

Обновление Временные тесты для миллиона чтений предварительно заполненного кэша из 1000 элементов показывают 19 мс для ConcurrentDictionary - то же, что и обычные Dictionary - но 720 мс для версии Lazy.

Если это звучит слишком круто, вы можете получить лучшее из обоих миров с более сложным решением.

public static Func<A, R> Memoize<A, R>(this Func<A, R> f)
{
    var cache = new ConcurrentDictionary<A, R>();
    var syncMap = new ConcurrentDictionary<A, object>();
    return a =>
    {
        R r;
        if (!cache.TryGetValue(a, out r))
        {
            var sync = syncMap.GetOrAdd(a, new object());
            lock (sync)
            {
                r = cache.GetOrAdd(a, f);
            }
            syncMap.TryRemove(a, out sync);
        }
        return r;
    };
}
10 голосов
/ 10 августа 2009

Если у вас уже есть тип Lazy<T>, я предполагаю, что вы используете .net 4.0, так что вы также можете использовать ConcurrentDictionary<A,R>:

public static Func<A, R> Memoize<A, R>(this Func<A, R> f)
{
  var map = new ConcurrentDictionary<A, Lazy<R>>();
  return a =>
    {
      Lazy<R> lazy = new Lazy<R>(() => f(a), LazyExecutionMode.EnsureSingleThreadSafeExecution);
      if(!map.TryAdd(a, lazy))
      {
        return map[a].Value;
      }
      return lazy.Value;
    };
}
2 голосов
/ 11 июня 2015

В продолжение ответа превосходного Найджела Тача я хотел предложить повторно используемый компонент, извлеченный из его решения, ограничивающий количество вызовов для f (a).

Я назвал его SynchronizedConcurrentDictionary, и это выглядит так:

public class SynchronizedConcurrentDictionary<TKey, TValue> : ConcurrentDictionary<TKey, TValue>
{
    private readonly ReaderWriterLockSlim _cacheLock = new ReaderWriterLockSlim();

    public new TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)
    {
        TValue result;

        _cacheLock.EnterWriteLock();
        try
        {
            result = base.GetOrAdd(key, valueFactory);
        }
        finally
        {
            _cacheLock.ExitWriteLock();
        }

        return result;
    }
}

Тогда функция Memoize становится двухстрочной:

public static Func<A, R> Memoize<A, R>(this Func<A, R> f)
{
    var cache = new SynchronizedConcurrentDictionary<A, R>();

    return key => cache.GetOrAdd(key, f);
}

Ура!

2 голосов
/ 13 декабря 2010

Ответ Томаса, похоже, не компилируется в .NET 4.0 из-за параметра enum для конструктора Lazy. Я пересмотрел это ниже. Я также добавил необязательный параметр для предоставления собственного компаратора равенства. Это полезно, если TInput не реализует свои собственные Equals или если TInput является строкой, и вы хотите, чтобы она, например, не учитывала регистр.

    public static Func<TInput, TResult> Memoize<TInput, TResult>(
        this Func<TInput, TResult> func, IEqualityComparer<TInput> comparer = null)
    {
        var map = comparer == null
                      ? new ConcurrentDictionary<TInput, Lazy<TResult>>()
                      : new ConcurrentDictionary<TInput, Lazy<TResult>>(comparer);

        return input =>
               {
                   var lazy = new Lazy<TResult>(() => func(input), LazyThreadSafetyMode.ExecutionAndPublication);

                   return map.TryAdd(input, lazy)
                              ? lazy.Value
                              : map[input].Value;
               };
    }

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

    public void TestMemoize()
    {
        Func<int, string> mainFunc = i =>
                                     {
                                         Console.WriteLine("Evaluating " + i);
                                         Thread.Sleep(1000);
                                         return i.ToString();
                                     };

        var memoized = mainFunc.Memoize();

        Parallel.ForEach(
            Enumerable.Range(0, 10),
            i => Parallel.ForEach(Enumerable.Range(0, 10), j => Console.WriteLine(memoized(i))));
    }

Кажется, он работает правильно.

1 голос
/ 10 августа 2009

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

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

    public static Func<A, R> Memoize<A, R>(this Func<A, R> f)
    {
        var map = new Dictionary<A, R>();
        var mapSync = new Dictionary<A, object>();
        return a =>
        {
            R value;
            object sync = null;
            bool calc = false;
            bool wait = false;
            lock (map)
            {
                if (!map.TryGetValue(a, out value))
                {
                    //its not in the map
                    if (!mapSync.TryGetValue(a, out sync))
                    {
                        //not currently being created
                        sync = new object();
                        mapSync[a] = sync;
                        calc = true;

                    }
                    else
                    {
                        calc = false;
                        wait = true;
                    }
                }
            }
            if(calc)
            {
                lock (sync)
                {
                    value = f(a);
                    lock (map)
                    {
                        map.Add(a, value);
                        mapSync.Remove(a);
                    }
                    Monitor.PulseAll(sync);
                    return value;
                }
            }
            else if (wait)
            {
                lock (sync)
                {
                    while (!map.TryGetValue(a, out value))
                    {
                        Monitor.Wait(sync);
                    }
                    return value;
                }
            }

            lock (map)
            {
                return map[a];
            }

        };
    }

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

1 голос
/ 10 августа 2009

Нет, они не лучшие варианты.

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

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

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

0 голосов
/ 10 августа 2009

Читали ли вы комментарий от Дайера , относящийся к поточно-ориентированным в статье?

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

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

В моем примере игры RoboRally я фактически использовал функцию памятки, чтобы действовать как «суррогатный синглтон». Это на самом деле не одиночка, так как на фабрике может быть один экземпляр (если только фабрика не статична). Но это именно то, что я хотел.

...