Дмитpий Hecтepук

Блог о программировании — C#, F#, C++, архитектура, и многое другое

Проблемы в многопоточной разработке

8 комментариев

Как-то интересно получается: когда читаешь статьи и умные книги, начинает казаться что все проблемы с асинхронностью в стеке .Net уже давно решены и все что нужно сделать – это брать и пользоваться. На самом же деле, ни в книжках ни на студенческой скамье нас не готовят к тем сложностям, с которыми мы можем столкнуться. В этом посте я хочу показать несколько нетривиальных решений, которые могут в чем-то упростить нам жизнь при работе с многопоточным кодом.

Тайм-ауты для исполнения

Бывают ситуации, когда нужно выдать методу определенное, ограниченное время на исполнение. Типичный пример – это вызов веб-сервиса, правда в этом случае параметры тайм-аута задаются в App.config а нам остается только ловить иключения. Если же мы хотим реализовать тайм-аут сами, то для нас сущестует исключение System.TimeoutException, которое мы можем бросать в случае, если время кончилось. Вот небольшой пример:

public void LongOperation(int numberOfElements, int timeoutMsec)
{
  var st = new Stopwatch();
  st.Start();
  while (numberOfElements --> 0)
  {
    someMethod(numberOfElements);
    #if RELEASE
    if (st.ElapsedMilliseconds > timeoutMsec)
    {
      st.Stop();
      throw new TimeoutException();
    }
    #endif
  }
  st.Stop();
}

Код выше в каком-то смысле “нечестный”, потому что мы делаем несколько разрывных вызовов someMethod(), между которыми можно вклиниться и выбросить исключение. N.b.: тут специально используется #if RELEASE для того чтобы во время отладки мы могли оценивать общее время исполнения, даже если оно выходит за рамки.

Если вас не волнует тот факт, что вызов метода будет “болтаться”, то конечно же можно использовать более примитивный подход, а именно запустить процесс (скажем через BeginInvoke()) а потом просто использовать WaitHandle.WaitOne() с параметром тайм-аута, и производить действия в зависимости от того, выдал вам этот метод true или false.

Если вы хотите более безопасный прием реализации тайм-аута, то вам нужно создать отдельный поток, и потом “убить” этот поток по истечении тайм-аута. Например, имея метод с задержкой:

void Wait5seconds()
{
    Thread.Sleep(5000);
}

Можно написать метод, который берет этот метод как Action и правильно дожидается выполнения:

static void CallWithTimeout(Action action, int timeoutMsec)
{
  Thread threadToKill = null;
  Action wrappedAction = () =>
  {
    threadToKill = Thread.CurrentThread;
    action();
 };
  IAsyncResult result = wrappedAction.BeginInvoke(null, null);
  if (result.AsyncWaitHandle.WaitOne(timeoutMsec))
  {
    wrappedAction.EndInvoke(result);
  }
  else
  {
    threadToKill.Abort();
    throw new TimeoutException();
  }
}   

А потом им воспользоваться примерно так:

CallWithTimeout(Wait5seconds, 10000); // ok
CallWithTimeout(Wait5seconds, 1000); // выбросит TimeoutException

Еще одна интересная реализация тайм-аута есть у Рината Абдуллина в его Lokad Shared Libraries. Этот подход тоже использует сторонный “наблюдатель” для мониторинга, но здесь используется не WaitHandle а Monitor, и добавляется fluent-интерфейс который позволяет нам, определив вот такую структуру:

public sealed class WaitFor<TResult>
{
  readonly TimeSpan _timeout;
  public WaitFor(TimeSpan timeout)
  {
          _timeout = timeout;
  }
  public TResult Run(Func<TResult> function)
  {
    if (function == null) throw new ArgumentNullException("function");
    var sync = new object();
    var isCompleted = false;
    WaitCallback watcher = obj =>
            {
              var watchedThread = obj as Thread;
              lock (sync)
              {
                if (!isCompleted)
                {
                  Monitor.Wait(sync, _timeout);
                }
                if (!isCompleted)
                {
                  watchedThread.Abort();
                }
              }
            };
    try
    {
      ThreadPool.QueueUserWorkItem(watcher, Thread.CurrentThread);
      return function();
    }
    catch (ThreadAbortException)
    {
      Thread.ResetAbort();
      throw new TimeoutException(string.Format("The operation has timed out after {0}.", _timeout));
    }
    finally
    {
      lock (sync)
      {
        isCompleted = true;
        Monitor.Pulse(sync);
      }
    }
  }
  public static TResult Run(TimeSpan timeout, Func<TResult> function)
  {
    return new WaitFor<TResult>(timeout).Run(function);
  }
}

…и использовать ее вот так:

var result = WaitFor<Result>.Run(1.Minutes(), () => service.GetSomeFragileResult());

Тайм-ауты для входа в критическую секцию

Критическая секция в .Net делается просто – создается (обычно) некий объект syncRoot типа object, который потом фигурирует в качестве параметра для ключевого слова lock:

class MyClass
{
  private object syncRoot = new object();
  void SometMethod()
  {
    lock (syncRoot)
    {
      // тут что-то интересное
    }
  }
}

Проблема в том, что если метод действительно занят, то не всегда хочется “стоять у ворот” и ждать, пока же нас пустят. Помимо методов Monitor.Enter() и Monitor.Exit(), который собственно и используются за кулисами когда вы пишете lock, существует также метод Motitor.TryEnter() который, как вы уже догадались, умеет реализовывать тайм-аут в случае, если время выделенное для входа в критическую секцию истекло.

В принципе, нужно делать примерно следующее:

if (!Monitor.TryEnter(obj, TimeSpan.FromSeconds(10))
{
  // не удалось попасть в критическую секцию
  // можно выбросить исключение или просто продолжить
}
try
{
   // все действия тут
}
finally
{
  Monitor.Exit(obj);
}

Писать это каждый раз сложно, поэтому я поискал на просторах интернета разные реализации, и нашел вот эту (аж 2004го года!). Если коротко, то автор решил просто создать disposable-структуру, которая бросала бы определенное исключение LockTimeoutException в случае, когда время истекло:

public struct TimedLock : IDisposable
{
  public static TimedLock Lock(object o)
  {
    return Lock(o, TimeSpan.FromSeconds(10));
  }
  public static TimedLock Lock(object o, TimeSpan timeout)
  {
    TimedLock tl = new TimedLock(o);
    if (!Monitor.TryEnter(o, timeout))
    {
#if DEBUG
          System.GC.SuppressFinalize(tl.leakDetector);
#endif
      throw new LockTimeoutException();
    }
    return tl;
  }
  private TimedLock(object o)
  {
    target = o;
#if DEBUG
      leakDetector = new Sentinel();
#endif
  }
  private object target;
  public void Dispose()
  {
    Monitor.Exit(target);
    // It's a bad error if someone forgets to call Dispose,
    // so in Debug builds, we put a finalizer in to detect
    // the error. If Dispose is called, we suppress the
    // finalizer.
#if DEBUG
      GC.SuppressFinalize(leakDetector);
#endif
  }
#if DEBUG
  // (In Debug mode, we make it a class so that we can add a finalizer
  // in order to detect when the object is not freed.)
  private class Sentinel
  {
      ~Sentinel()
      {
          // If this finalizer runs, someone somewhere failed to
          // call Dispose, which means we've failed to leave
          // a monitor!
          System.Diagnostics.Debug.Fail("Undisposed lock");
      }
  }
  private Sentinel leakDetector;
#endif
}
public class LockTimeoutException : ApplicationException
{
  public LockTimeoutException()
    : base("Timeout waiting for lock")
  {
  }
}

Этот код приносит с собой ряд инфраструктурных проблем – например, как только у вас появляется что-то типа IDisposable, тут же появляется ответственность за то, что это что-то нужно Dispose()-ить. Зато теперь можно писать нечто подобное:

using (new TimerLock(syncRoot, 1000))
{
  ...
}

Ну и конечно заключить это в try-catch или заместить обработку тайм-аута своей реализацией.

Асинхронная реализация последовательного исполнения

Если учесть количество всяких вещей которые существуют для т.н. “упрощения” работы с многопоточность, начинает кружиться голова. Я и писал, и делал доклады по этой теме, но тем не менее, одна из самых основных задач в ежедневной практике – это не то, как “свернуть” наборы асинхронных вызовов в APM-модели через какой-нибудь полезный фреймворк вроде PowerThreading или асинхронные воркфлоу в F#, а скорее то, как просто вызвать граф зависимостей асинхронно.

В свое время я сам реализовал подобную идею используя Microsoft DSL Tools а также кодогенерацию на основе вызовов Monitor.Pulse() и Monitor.Wait(). Помимо того, что этот API для большинства разработчиков – тайна, покрытая мраком, так еще и использование этих конструктов не очень-то безопасно. Например, при флуктуациях в процессе вызова, вы можете зависнуть в Monitor.Wait() навсегда, если Pulse() был вызван до того, как вы стали слушать.

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

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

Вот небольшая иллюстрация того, какие задачи эти два подхода решают:

Алгоритм для графов   Исполнение через APM/AsyncEnumerator

А теперь, собственно, мой piece de resistance – я совместил два этих подхода, создав одну модель, которая может исполнять вложенные наборы методов в обеих парадигмах, при условии что методы соответствуют следующей сигнатуре:

using AsyncMethod = System.Func<Wintellect.Threading.AsyncProgModel.AsyncEnumerator, 
                                System.Collections.Generic.IEnumerator<System.Int32>>;

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

class Program
{
  static IEnumerator<Int32> WasteTime(AsyncEnumerator ae)
  {
    Console.WriteLine("Wasting one second on thread " + Thread.CurrentThread.ManagedThreadId);
    Thread.Sleep(1000);
    yield break;
  }
  static void Main()
  {
    var dm = new DependencyManager();
    AsyncMethod op = WasteTime;
    dm.AddOperation(1, op);
    dm.AddOperation(2, op);
    dm.AddOperation(3, op);
    dm.AddOperation(4, op, 1);
    dm.AddOperation(5, op, 1, 2, 3);
    dm.AddOperation(6, op, 3, 4);
    dm.AddOperation(7, op, 5, 6);
    dm.AddOperation(8, op, 5);
    Stopwatch st = new Stopwatch();
    st.Start();
    dm.Execute();
    st.Stop();
    Console.WriteLine(st.Elapsed);
    Console.ReadKey();
  }
}

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

Заключение

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

На этом все, удачи в разработке! ■

Advertisements

Written by Dmitri

27 марта 2010 в 15:42

Опубликовано в C#, multithreading

комментариев 8

Subscribe to comments with RSS.

  1. Хорошая статья,

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

    А ваша одна из них.

    Вообщем спасибо.

    Николай

    27 марта 2010 at 16:22

  2. Мало, скорее всего потому, что основы, о которых напомнил Дмитрий в компаниях пишутся один раз при необходимости и забываются. Опять же все это, видимо, написано в 2004 году :) Недавно был небольшой тред статьей связанных с ParallelFX, а они нам дадут опять же достаточно интересных механизмов для работы с потоками. В .NET 4 опять же это уже будет вшито и у нас появятся всяческие Concurrency Dictionary, Concurrency Bag и т.п.

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

    Кстати, по поводу «Асинхронная реализация последовательного исполнения» насколько я понимаю это должно быть хорошо реализовано в Robotics Studio, потому если на производстве это действительно нужно, можно пробовать воспользоваться ею.

    Дмитрий, спасибо за интересную статью!

    Denis Gladkikh

    27 марта 2010 at 18:07

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

      Dmitri

      27 марта 2010 at 21:22

  3. Ваша реализация CallWithTimeout будет иногда падать с NullReferenceException, т.к. переменной threadToKill не всегда успеет присвоиться значение до вызова threadToKill.Abort().

    Алексей

    28 марта 2010 at 1:58

    • Вероятность этого почти равна нулю — если только вы не передаете ноль в качестве времени тайм-аута.

      Dmitri

      28 марта 2010 at 9:51

      • Не обязательно только при нулевом таймауте. Ведь время через которое начинает выполняться код в другом потоке при вызове BeginInvoke нигде не специфицировано. При большой загрузке процессоров такая ошибка становится вполне реальной.

        Алексей

        28 марта 2010 at 17:36

  4. Если я не ошибаюсь, то поток threadToKill — берется из thread pool’а. А разве для таких потоков можно вызывать Abort() ???

    Andrey

    1 апреля 2010 at 6:00


Оставить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s

%d такие блоггеры, как: