Опубликован: 15.10.2009 | Уровень: специалист | Доступ: платный
Лекция 6:

Класс System.Threading.Tasks.Future и координирующие структуры данных

< Лекция 5 || Лекция 6: 12 || Лекция 7 >
Аннотация: В лекции описано использование высокоуровневых примитивов синхронизации процессов, а также рассмотрена асинхронная модель программирования с использованием объектов класса Future<T>.

6.1 Класс System.Threading.Tasks.Future<T>

Класс System.Threading.Tasks.Future<T>, в действительности, наследует (inherits) класс System.Threading.Tasks.Task. Другими словами, Future<T> есть специальный класс задач, отличающийся от Task тем, что он представляет вычисления (функцию), возвращающие значение (в отличие от класса Task, который значений, как и класс Thread, не возвращает). При обращении к задаче Future<T> за возвращаемым значением, оно либо сразу будет возвращено (если оно уже вычислено задачей к этому моменту), либо произойдет блокирование работы вызывающей стороны до тех пор, пока требуемое значение не будет вычислено.Чтобы просто узнать вычислено значение задачей Future<T> или нет, можно обратиться к свойству IsCompleted, о котором уже шла речь в "лекции 5" .

Интересным вариантом работы с Future<T> является создание объектов класса Future<T> без указания тела задачи. При этом, возвращаемое значение такой задачи становится готовым при присваивании некоторого значения полю (свойству) Value этой задачи. Тогда, если данную задачу Future<T> (точнее, значение от нее) ожидали некоторые другие задачи, то они будут "разбужены" этим присваиванием. Необходимо отметить, что поле Value объекта Future<T> допускает только однократное присваивание. Если же по каким-то причинам нет возможности вычислить значение, возвращаемое задачей Future<T>, то сообщение об этом может быть передано через поле Exception данной задачи. Более подробно работа с исключительными ситуациями в PFX рассмотрена в "лекции 8" .

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

//   Определяем массив результатов обработки
var data = new Future<int> [10000];
//запускаем их параллельную обработку
Parallel.For(0, data.Length, i =>
{
     data[i] = Future.Create(() => Compute(i));
});

//выполняем другую работу
...
//работаем с результатами обработки данных
for(int i=0; i<data.Length; i++)
{
      DoSomethingWithResult(data[i].Value);
}

Рассмотрим еще один пример использования Future<T>, который часто встречается при рекурсивной обработке данных. Допустим, мы хотим определить количество узлов в дереве. Последовательный код рекурсивной функции, вычисляющей количество узлов в бинарном дереве, выглядит следующим образом:

int CountNodes(Tree<int> node)
{
      if (node == null) return 0;
      return 1 + CountNodes(node.Left) + CountNodes(node.Right);
}

При использовании Future<T> можно распараллелить выполнение этой функции следующим образом:

int CountNodes(Tree<int> node)
{
      if (node == null) return 0;
      var left = Future.Create(() => CountNodes(node.Left));
      int right = CountNodes(node.Right);
      return 1 + left.Value + right;
}

Однако, такой радикальный подход к распараллеливанию достаточно легковесных операций, приводит, зачастую, к увеличению времени работы параллельной программы по сравнению с последовательным аналогом. Это связано с ростом дополнительных издержек на создание объектов Future<T> и координацию взаимодействия рабочих потоков. Чтобы избежать этого, можно применить стандартную технику - на некотором этапе вычислений перейти от параллельного варианта функции к последовательному.

Класс Future<T> является аналогом конструкции future, которая существует во многих языках программирования (таких как, например Io, Oz, Java, Ocaml, Scheme). Еще одной подобной конструкцией, которая позволяет влиять на сам процесс вычислений, является конструкция continuation ("продолжение"), также реализованная во многих языках (Scheme, Scala, Ruby и т.д.). В библиотеке PFX аналогичными возможностями обладает метод Task.ContinueWith. Этот метод позволяет зарегистрировать задачу и указать, что она должна начать выполняться тогда, когда свое исполнение завершит другая задача:

var task = Task.Create(delegate {  });
var continuation = task.ContinueWith((prevTask) => {
                                        Console.WriteLine(prevTask==task);
                                        Console.WriteLine(prevTask.IsCompleted); 
                                        });

Данный фрагмент программы выведет на печать "True True".

6.2 Координирующие структуры данных

Пространство имен System.Threading стандартной библиотеки классов .NET Framework 3.5 содержит множество низкоуровневых примитивов синхронизации, таких как мьютексы, мониторы и события. Библиотека PFX предлагает к использованию ряд более высокоуровневых примитивов, которые будут рассмотрены ниже. Отметим, что в примерах ниже будет использоваться пул потоков ThreadPool, однако описываемые примитивы могут использоваться и при работе с задачами (Task).

System.Threading.CountdownEvent

Подобно стандартным событиям ManualResetEvent и AutoResetEvent событие System.Threading.CountdownEvent позволяет потокам обмениваться простейшими сообщениями (которые точнее было бы назвать сигналами). При создании события CountdownEvent программист указывает определенное начальное значение. Потоки могут уменьшать это значение, вызывая метод CountdownEvent.Decrement. Когда это значение уменьшается потоками до нуля, происходит порождение события CountdownEvent и все потоки, которые ожидают этого сообщения, могут продолжить свою работу. Подобная логика работы часто используется, когда основному потоку необходимо дождаться завершения порожденных потоков:

int n = 10;
using(var ce = new CountdownEvent(n))
{
      for(int i=0; i<n; i++)
      {
          ThreadPool.QueueUserWorkItem(delegate
          {
              ...
              ce.Decrement();
          });
      }
     ce.Wait();
}

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

System.Threading.LazyInit<T>

Ленивые вычисления достаточно распространенный подход к организации вычислений, поддерживаемый в таких языках программирования как, например, Haskell, Nemerle и др. Библиотека PFX предлагает т.н. "ленивую инициализацию" переменной - прием, который часто используется для того, чтобы отложить создание определенного объекта, до того момента когда он действительно потребуется. Обычно, этот прием реализовывается следующим образом:

private MyData _data;
...
public MyData Data
{
      get
      {
          if (_data == null) _data = new MyData();
          return _data;
      }
}

Однако в многопоточных приложениях такая реализация становится непотокобезопасной. Ситуацию можно исправить с помощью класса System.Threading.LazyInit<T>:

private LazyInit<MyData> _data;
...
public MyData Data { get { return _data.Value; } }

Возможны, однако, ситуации, при которых конструктор без параметров недоступен у класса, ленивую инициализацию объектов которого мы хотим реализовать. В этом случае можно прибегнуть к следующей форме работы с LazyInit<T> - к использованию делегата:

private LazyInit<MyData> _data = new LazyInit<MyData> (() => CreateMyData());
...
public MyData Data { get { return _data.Value; } }

Внимательный читатель может обратить внимание, что если несколько потоков будут обращаться к свойству Data, то возможен многократный вызов функции CreateMyData(). Действительно, по умолчанию "ленивая инициализация" работает именно так (при этом, однако, свойство Data будет возвращать всегда один и тот же объект). Если подобное поведение не желательно, то его можно избежать, указав при создании объекта LazyInit<T> флаг LazyInitMode.EnsureSingleExecution:

private LazyInit<MyData> _data = new LazyInit<MyData> (() => CreateMyData(), LazyInitMode.EnsureSingleExecution);
...
public MyData Data { get { return _data.Value; } }

Кроме того, возможен еще один вариант "ленивой инициализации", при котором каждый поток получит свою собственную копию объекта Data. Для этого нужно воспользоваться флагом LazyInitMode.ThreadLocal:

private LazyInit<MyData> _data = new LazyInit<MyData> (() => CreateMyData(), LazyInitMode.ThreadLocal);
...
public MyData Data { get { return _data.Value; } }

System.Threading.WriteOnce<T>

Поля read-only встречаются в .NET-программах достаточно часто. Такие поля инициализируются во время создания объекта и больше никогда не могут изменить своего значения. При параллельном же программировании зачастую полезнее иметь дело с полями, допускающими однократное присваивание. Подобная логика работы с полями реализована в библиотеке PFX классом WriteOnce<T>. Поля, обернутые данным классом, допускают однократное присваивание и многократное чтение. При повторном присваивании WriteOnce-полю, порождается исключительная ситуация.

System.Threading.Collections.ConcurrentQueue<T>

Класс System.Threading.Collections.ConcurrentQueue<T> является потокобезопасным вариантом стандартной очереди - System.Collections.Generics.Queue<T>. Также как и стандартный класс Queue<T>, класс ConcurrentQueue<T> поддерживает метод Enqueue, позволяющий добавить элемент в очередь. Однако стандартный метод Dequeue был замещен методом TryDequeue, который возвращает True, если извлечение элемента из очереди возможно, и False - в противном случае. Сам элемент при успешном извлечении возвращается как out параметр метода. Таким образом, стандартный код извлечения элементов из очереди:

while(queue.Count > 0)
{
     Data d = stack.Dequeue();
     Process(d);
}

должен быть изменен на:

Data d;
while(queue.TryDequeue(out d))
{
      Process(d);
}

System.Threading.Collections.ConcurrentStack<T>

Класс System.Threading.Collections. ConcurrentStack <T> является потокобезопасным вариантом стандартного стека - System.Collections.Generics. Stack <T>. Также как и стандартный класс Queue<T>, класс ConcurrentStack <T> поддерживает метод Push, позволяющий положить элемент в стек. Метод Pop замещен методом TryPop.

System.Threading.Collections.BlockingCollection<T>

Блокирующая очередь является классическим решением многих задач типа "производитель-потребитель". Производитель генерирует данные и помещает их в очередь, потребитель берет данные из очереди и обрабатывает их. Очевидно, что подобная очередь должна быть потокобезопасной. Кроме того, желательно, чтобы она поддерживала такие дополнительные функции как:

  1. Блокирование потребителей при отсутствии данных в очереди;
  2. Блокирование производителей при достижении определенного объема данных в очереди;
  3. Возможность сообщить потребителям, что поступление данных в очередь прекращено - чтобы избежать полной блокировки потребителей;
  4. Возможность параллельной работы с несколькими очередями.

Все эти и некоторые другие функции реализованы в библиотеке PFX с помощью класса BlockingCollection<T>. Данный класс является оберткой для любой коллекции, поддерживающей интерфейс System.Threading.Collections.IConcurrentCollection<T>. В частности, этот интерфейс реализует только что рассмотренные коллекции ConcurrentQueue<T> и ConcurrentStack<T>:

private BlockingCollection<string> _data = new BlockingCollection<string> (new ConcurrentQueue<string> ());

При этом работа с такой очередью может быть организована следующим образом:

private void Producer()
{
      while(true)
      {
           string s = ...;
           _data.Add(s);
      }
}

private void Consumer()
{
      while(true)
      {
              string s = _data.Remove();
              UseString(s);
       }
}

Можно отметить, что в PFX, поставляемом с .NET CTP 4, включена также такая структура данных как потокобезопасный словарь (хэш-таблица) - System.Collection.Concurrent.ConcurrentDictionary<T>.

< Лекция 5 || Лекция 6: 12 || Лекция 7 >
Максим Полищук
Максим Полищук
"...Изучение и анализ примеров.
В и приведены описания и приложены исходные коды параллельных программ..."
Непонятно что такое - "В и приведены описания" и где именно приведены и приложены исходные коды.
Дмитрий Молокоедов
Дмитрий Молокоедов
Россия, Новосибирск, НГПУ, 2009
Паулус Шеетекела
Паулус Шеетекела
Россия, ТГТУ, 2010