Содержание


Работаем с Mono

Часть 6. Разработка многопоточных приложений

Серия контента:

Этот контент является частью # из серии # статей: Работаем с Mono

Следите за выходом новых статей этой серии.

Этот контент является частью серии:Работаем с Mono

Следите за выходом новых статей этой серии.

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

Создание и управление потоками

За работу с потоками в Mono отвечает пространство имен System.Threading. Для создания потока потребуется создать экземпляр класса Thread и передать в конструктор в качестве параметра имя метода, который будет выполняться в создаваемом потоке. По умолчанию поток создается в остановленном состоянии, и для его запуска потребуется вызвать метод Start, как показано в листинге 1.

Листинг 1. Создание и запуск потоков
using System;
using System.Threading;

namespace Mono6_1
{
  class MainClass
  {
    static public int i = 0;
    public static void Main (string[] args)
    {
      Thread t1 = new Thread(T1_Run);
      Thread t2 = new Thread(T2_Run);
      t1.Start();
      t2.Start();
    }

    public static void T1_Run()
    {
      while(true)
      {
        Console.WriteLine("T1, i = {0}", i);
        i++;
        if(i > 50)
          break;
      }
    }

    public static void T2_Run()
    {
      while(true)
      {
        Console.WriteLine("T2, i = {0}", i);
        i++;
        if(i > 50)
          break;
      }
    }
  }
}

Для компиляции и запуска в консоли необходимо ввести следующие команды:

gmsc Mono6_1.cs
mono Mono6_1.exe
Рисунок 1. Результат выполнения программы
Рисунок 1. Результат выполнения программы
Рисунок 1. Результат выполнения программы

Ниже приводится анализ информации, выведенной приложением. В момент запуска программы оба потока считывают значение переменной i, затем выполнение потока T2 прерывается и с переменной работает только поток T1. В момент, когда управление было передано потоку T2 (i=24), поток T1 успел увеличить значение переменной, но не вывел строку на экран. Поток T2 вывел на экран старое прочитанное значение (i=0), затем прочитал текущее значение (i=25), увеличил его и вывел на экран (i=26). В дальнейшем такая же ситуация повторилась в момент переключения с потока T2 на поток T1.

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

Проблемы, возникающие при разработке многозадачных приложений

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

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

Проблема «взаимная блокировка» (dead lock) может возникнуть от излишнего или неправильного применения синхронизации. Например, поток 1 приостанавливает свою работу и ожидает каких-то действий от потока 2, а поток 2 приостанавливает свою работу и ожидает действий от потока 1. Для продолжения работы хотя бы один из потоков должен пройти дальше, однако они оба заблокированы. Со стороны это выглядит как зависание потоков. Эта проблема решается путем пересмотра алгоритмов работы с объектами синхронизации.

Синхронизация потоков через стандартную блокировку

В стандарте языка C# предусмотрена возможность синхронизации потоков без использования каких-либо дополнительных классов. Для этого используется оператор lock, имеющий следующий синтаксис:

lock (переменная) { блок операторов }

В качестве переменной может выступать любая переменная ссылочного типа для объектов или выражение typeof(имя класса) для статических переменных или для синхронизации внутри статических методов класса. Пример использования lock продемонстрирован в листинге 2.

Листинг 2. Использование оператора lock
private void ThreadFunc1()
{
  lock(this)
  {
    // выполняется доступ к общему ресурсу
  }
}

private void ThreadFunc2()
{
  lock(this)
  {
    // выполняется доступ к общему ресурсу
  }
}

Пока один поток находится внутри блока операторов lock, другой поток, дойдя до блока lock, будет вынужден приостановить свою работу. Использование this в качестве переменной, отвечающей за блокировку, – один из самых распространенных случаев, рекомендуемый даже в .NET SDK от Microsoft. Однако в других источниках (например, статья lock(this): don't) использовать this (а точнее любое public поле класса) не рекомендуется, так как в этом случае из внешнего кода может быть выполнена нежелательная блокировка. Это может произойти, если внутри класса блокировка производится по this, а вне класса – по переменной-объекту класса, как показано в листинге 3.

Листинг 3. Конфликт внутренней и внешней блокировки
// блокировка внутри класса
lock(this)
{
  // доступ к общему ресурсу
}
// …
// блокировка вне класса
CClassWithBlocking cClassVar = new CClassWithBlocking();
lock(cClassVar)
{
  // доступ к тому же ресурсу
}

В данном случае блокировки lock(this) и lock(cClassVar) могут заблокировать друг друга. Класс, использующий CClassWithBlocking, может ничего не знать о внутреннем устройстве CClassWithBlocking и непреднамеренно использовать для блокировки переменную cClassVar.

В листинге 4 приведена измененная версия кода из листинга 1 с добавлением синхронизации доступа к переменной i.

Листинг 4. Синхронизация доступа к общему ресурсу из разных потоков
public static void T1_Run()
{
  while(true)
  {
    lock(typeof(MainClass)
    {
      Console.WriteLine("T1, i = {0}", i);
      i++;
      if(i > 50)
        break;
    }
  }
}

public static void T2_Run()
{
  while(true)
  {
    lock(typeof(MainClass)
    {
      Console.WriteLine("T2, i = {0}", i);
      i++;
      if(i > 50)
        break;
    }
  }
}

На рисунке 2 приведен результат запуска измененной программы. Как видно, теперь потоки не прерываются в процессе "чтения->изменения->записи" переменной.

Рисунок 2. Результат выполнения программы с синхронизацией доступа
Рисунок 2. Результат выполнения программы с синхронизацией доступа
Рисунок 2. Результат выполнения программы с синхронизацией доступа

Блокировка при помощи событий

Объект EventWaitHandle является объектом синхронизации и может находиться в одном из двух состояний – нормальном и сигнальном. При синхронизации с помощью данного объекта поток останавливается, если EventWaitHandle находится в нормальном состоянии, и продолжает свою работу, если EventWaitHandle находится в сигнальном состоянии.

Как правило, данный объект синхронизации используется для контроля доступа к общему ресурсу или для контроля прохождения определенного участка кода одним из потоков. Объект-событие может быть безымянным (тогда область его использования ограничена потоками одного процесса) и именованным (возможно управление объектом-событием из разных процессов).

Конструктор для создания безымянного объекта-события принимает на вход два параметра:

  • bool bInitialState – начальное состояние объекта; если значение этого параметра равно true, то объект создается в сигнальном состоянии, если false – объект создается в нормальном состоянии.
  • EventResetMode mode – определяет способ перевода события из сигнального в нормальное состояние и может принимать одно из двух значений: EventResetMode.ManualReset – перевод вручную или EventResetMode.AutoReset – автоматический перевод.

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

Если требуется обращаться к событию за пределами процесса, где оно было создано, то используется именованное событие, при создании которого указывается третий параметр:

  • string name – имя создаваемого события; если событие с данным именем уже существует, то возвращается объект, привязанный к существующему событию, а указанные в конструкторе параметры bInitialState и mode игнорируются.

Событие создается следующим образом:

EventWaitHandle wh = new EventWaitHandle(true, EventResetMode.ManualReset);

Для перевода события из сигнального состояния в нормальное (для событий с ручным сбросом) используется метод Reset:

wh.Reset();

Для перевода события из нормального в сигнальное используется метод Set:

wh.Set();

Ожидание сигнального состояния события осуществляется при помощи метода WaitOne:

wh.WaitOne();

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

Все версии метода WaitOne() возвращают значение типа bool, которое равно true, если событие перешло в сигнальное состояние, и false, если не перешло (актуально для методов с периодом ожидания). По окончании работы с EventWaitHandle необходимо закрыть объект:

wh.Close();

В листинге 4 приведена еще одна модифицированная версия примера из листинга 1, на этот раз с использованием объектов-событий.

Листинг 4. Синхронизация доступа к общему ресурсу при помощи событий
private EventWaitHandle wh;

public static void Main (string[] args)
{
  wh = new EventWaitHandle(true, EventResetMode.AutoReset);
  Thread t1 = new Thread(T1_Run);
  Thread t2 = new Thread(T2_Run);
  t1.Start();
  t2.Start();
  t1.Join();
  t2.Join();
  wh.Close();
}

public static void T1_Run()
{
  while(true)
  {
    wh.WaitOne();
    // wh.Reset();
    Console.WriteLine("T1, i = {0}", i);
    i++;
    wh.Set();
    if(i > 50)
      break;
  }
}

public static void T2_Run()
{
  while(true)
  {
    wh.WaitOne();
    // wh.Reset();
    Console.WriteLine("T2, i = {0}", i);
    i++;
    wh.Set();
    if(i > 50)
      break;
  }
}

В листинге 4 создается объект-событие с автоматическим сбросом, так как для подобной ситуации использование ручного сброса не подходит. Чтобы проверить это утверждение, следует изменить тип сброса с AutoReset на ManualReset и убрать комментарии со строк wh.Reset(). Если запустить такую версию программы, то синхронизация потоков работать не будет, так как поток может быть прерван между вызовами WaitOne() и Reset(), в результате чего другой поток также может пройти WaitOne().

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

Методу WaitAll в качестве параметра передается массив объектов WaitHandle. Поток, остановленный методом WaitAll, продолжит свою работу, только если все объекты из заданного массива перейдут в сигнальное состояние одновременно. Также, по аналогии с WaitOne, можно указать значение времени ожидания. Метод WaitAny имеет тот же набор параметров, что и WaitAll, однако позволяет потоку продолжить работу, если хотя бы один элемент из массива объектов перейдет в сигнальное состояние.

Также следует отметить наличие двух объектов синхронизации, унаследованных от EventWaitHandle. Это объекты классов AutoResetEvent и ManualResetEvent. Объект AutoResetEvent полностью аналогичен EventWaitHandle, созданному с указанием EventResetMode.AutoReset, а ManualResetEventEventWaitHandle, созданному с указанием EventResetMode.ManualReset. Эти два объекта не могут быть именованными и доступны только в пределах создавшего их процесса.

Блокировка при помощи мютексов

Объект синхронизации мютекс (класс Mutex, от англ. MUTually EXclusive (взаимно исключающий)) похож на EventWaitHandle за некоторыми исключениями:

  1. Мютекс оперирует состояниями «захвачен»/«освобожден» вместо «нормальное»/«сигнальное».
  2. Если мютекс захвачен потоком, то все остальные потоки при попытке захвата мютекса будут приостановлены до его освобождения;
  3. В случае, если поток попытается повторно захватить мютекс, то он не будет остановлен (иначе это привело бы к блокировке работы программы, так как поток уже захватил мютекс и не может его освободить), но внутренний счетчик захватов мютекса будет увеличен. Таким образом, для освобождения мютекса, поток должен будет освободить его столько раз, сколько захватывал.
  4. По окончании синхронизируемого участка программы мютекс должен быть освобожден. Если мютекс, захваченный потоком, не будет освобожден до его окончания, то это считается ошибочной ситуацией, и при попытке другого потока захватить подобный мютекс возникнет исключение AbandonedMutexException.

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

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

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

Захват мютекса выполняется при вызове метода WaitOne (аналогичный методу EventWaitHandle.WaitOne). При этом если мютекс уже захвачен другим потоком, то текущий поток приостанавливается. Освободить мютекс можно вызовом метода ReleaseMutex, а по окончании использования закрыть, вызвав метод Close.

Блокировка при помощи семафоров

Семафор (класс Semaphore) – это еще один объект синхронизации, частично похожий на мютекс с той разницей, что он может быть захвачен сразу несколькими потоками одновременно. Хотя сложно найти ситуацию, когда использование семафоров на 100% необходимо и когда нельзя заменить их на другой тип синхронизации, иногда семафоры оказываются единственным возможным вариантом. Речь идет о ситуации, когда есть ограниченный (но не в единственном числе) ресурс, которым могут воспользоваться, допустим, не более, чем пять потоков, а за обладание этим ресурсом могут бороться десять потоков. Семафор имеет встроенный счетчик, который уменьшается каждый раз при захвате семафора потоком и увеличивается до заданного при создании значения при освобождении. Когда счетчик будет равен нулю, поток, который попытается захватить семафор, будет остановлен до тех пор, пока хотя бы один из потоков, уже захвативших данный семафор, не освободит его.

При создании объекта типа Semaphore в его конструктор передаются два параметра типа Int32. Первый определяет текущее значение счетчика, а второй – его максимальное значение. В нормальной ситуации эти значения должны совпадать, однако можно создать семафор, в котором часть ресурсов уже будет зарезервирована (первый параметр будет меньше второго). Для создания именованного семафора в конструктор передается третий параметр – имя семафора. Как обычно, если семафор с указанным именем уже существует в системе, то он и будет возвращен.

Чтобы проверить, существует ли уже семафор с указанным именем, можно воспользоваться статическим методом OpenExisting, в который передается имя семафора, а возвращается объект типа Semaphore или возникает исключение WaitHandleCannotBeOpenedException, если такого семафора не существует.

Захват семафора производится при вызове метода WaitOne. При этом, если счетчик семафора равен нулю, поток приостанавливается, в противном случае счетчик семафора уменьшается на единицу, и поток продолжает выполнение.

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

s.Release(2);

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

Заключение

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


Ресурсы для скачивания


Похожие темы

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux, Open source
ArticleID=751569
ArticleTitle=Работаем с Mono: Часть 6. Разработка многопоточных приложений
publish-date=08092011