Pull to refresh

.NET 4: “стройная” синхронизация

Reading time 5 min
Views 18K
Наконец-то вышла RTM версия .NET 4 и Visual Studio 2010. Заключительные оптимизации в финальной версии платформы проведены, и можно смело подвергнуть её тестам.

Ни для кого не секрет, что одним из значительных нововведений .NET 4 является Parallel Extensions – набор средств для облегчения распараллеливания кода и работы в многопоточной среде. В числе прочих инструментов этого набора есть и примитивы синхронизации, которые также подверглись переработке.

В частности, появился модифицированный вариант весьма популярного примитива ManualResetEvent. Для тех, кому не слишком знакомо это средство: с его помощью вы можете синхронизировать выполнение участков кода, работающих в разных потоках. Объект может находиться в 2 состояниях – установленном и неустановленном. Переход из одного в другое выполняется с помощью методов Set() и Reset(). В двух словах как это работает (здесь mre – это экземпляр типа ManualResetEvent):
Поток 1 Поток 2 Время
mre.Reset();
mre.WaitOne();
//выполнение кода 0
//ожидание //выполнение кода 1
//ожидание //выполнение кода 2
//ожидание //выполнение кода 3
//ожидание mre.Set(); 4
//выполнение кода //… 5

Улучшенная версия этого примитива из .NET 4 называется ManualResetEventSlim. Основная идея заключается в том, чтобы снизить накладные расходы в случае, если к примитиву обращается только 1 поток. Используется т.н. “гибридная схема”, которая может быть реализована так:
internal sealed class SimpleHybridLock : IDisposable
{
  private Int32 m_waiters = 0;
  private AutoResetEvent m_waiterLock = new AutoResetEvent(false);

  public void Enter()
  {
    if (Interlocked.Increment(ref m_waiters) == 1)
      return;
    m_waiterLock.WaitOne();
  }

  public void Leave()
  {
    if (Interlocked.Decrement(ref m_waiters) == 0)
      return;
    m_waiterLock.Set();
  }

  public void Dispose()
  {
    m_waiterLock.Dispose();
  }
}


* This source code was highlighted with Source Code Highlighter.

Это пример из книги Рихтера “CLR via C#”, 3rd edition. Примитив SimpleHybridLock имеет пару открытых методов Enter() и Leave(). Вызовами этих методов стоит обрамить критическую секцию нашего кода, которую мы хотим выполнять всегда только в одном потоке. Код класса довольно прозрачный: первый же поток, вызвавший Enter(), увеличивает внутренний счётчик на 1. Второй поток также увеличивает счётчик, при этом блокируется до тех пор, пока кто-нибудь не вызовет Set() у объекта m_waiterLock. Т.о. если не будет конкурентного доступа к примитиву, не будут вызваны весьма “тяжелые” с точки зрения производительности методы WaitOne() и Set(). Это может положительно сказаться на скорости работы кода.

По похожему принципу построен и ManualResetEventSlim. Думаю, там предусмотрены более умные механизмы, например контроль рекурсивных вызовов и т.д. Меня как конечного пользователя платформы заинтересовала реальная разница в производительности между ManualResetEvent и его*-Slim версией. Чтобы её найти, я подготовил небольшой “бенчмарк”. Это консольное приложение такого вида:
  static void Main(string[] args)
  {
    ManualResetEventSlim mres = new ManualResetEventSlim(false);
    ManualResetEventSlim mres2 = new ManualResetEventSlim(false);

    ManualResetEvent mre = new ManualResetEvent(false);

    long total = 0;
    int COUNT = 50;

    for (int i = 0; i < COUNT; i++)
    {
      mres2.Reset();
      //счётчик затраченного времени
      Stopwatch sw = Stopwatch.StartNew();

      //запускаем установку в потоке пула
      ThreadPool.QueueUserWorkItem((obj) =>
      {
        //Method(mres, true);
        Method2(mre, true);
        mres2.Set();
      });
      //запускаем сброс в основном потоке
      //Method(mres, false);
      Method2(mre, false);

      //Ждём, пока выполнится поток пула
      mres2.Wait();
      sw.Stop();

      Console.WriteLine("Pass {0}: {1} ms", i, sw.ElapsedMilliseconds);
      total += sw.ElapsedMilliseconds;
    }

    Console.WriteLine();
    Console.WriteLine("===============================");
    Console.WriteLine("Done in average=" + total / (double)COUNT);
    Console.ReadLine();
  }

  // работаем с ManualResetEventSlim
  private static void Method(ManualResetEventSlim mre, bool value)
  {
    //в цикле повторяем действие достаточно большое число раз
    for (int i = 0; i < 9000000; i++)
    {
      if (value)
      {
        mre.Set();
      }
      else
      {
        mre.Reset();
      }
    }
  }

  // работаем с классическим ManualResetEvent
  private static void Method2(ManualResetEvent mre, bool value)
  {
    //в цикле повторяем действие достаточно большое число раз
    for (int i = 0; i < 9000000; i++)
    {
      if (value)
      {
        mre.Set();
      }
      else
      {
        mre.Reset();
      }
    }
  }
}


* This source code was highlighted with Source Code Highlighter.

В методе Main() создаём экземпляры примитивов и промоделируем доступ к ним из 2 потоков – основного и потока пула. При этом поток пула будет в цикле устанавливать состояние, а основной поток – сбрасывать. Повторим эксперимент COUNT раз и выведем среднее значение на экран. Вот что получилось на моём ноутбуке (2хядерный CPU T7250, Win 7 x64):

ManualResetEvent ManualResetEventSlim
image image

Разница очевидна и довольно существенна – примерно в 10 раз.

Т.о. предпочтительным является использование ManualResetEventSlim, поскольку не всегда при вызове Set() и Reset() будет происходить долгое обращение к объектам ядра Windows и можно выиграть “копеечку” в скорости работы ;)
Tags:
Hubs:
+15
Comments 2
Comments Comments 2

Articles