Программирование PC Потоки на C#. Часть 3: использование потоков Mon, July 23 2018  

Поделиться

нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.


Потоки на C#. Часть 3: использование потоков Печать
Добавил(а) microsin   

[EAP]

Шаблон использования, основанный на асинхронных событиях (event-based asynchronous pattern, EAP), предоставляет простой способ организации многопоточности без необходимости потребителям заботиться о явном запуске или управлении потоками. Это также предоставляет следующие функции:

• Модель согласованной отмены действий (cooperative cancellation model)
• Возможность безопасно обновлять графические элементы управления приложения (controls GUI-интерфейса WPF или Windows Forms), когда рабочий поток завершил свою работу
• Перенаправление исключений в событие завершения (completion event)

EAP только шаблон, поэтому обработка его функций должны быть написана разработчиком приложения. Только несколько классов в Framework следуют этому шаблону, прежде всего BackgroundWorker (см. далее) и WebClient в System.Net. Сущность шаблона в следующем: класс предоставляет семейство членов, которые внутри себя управляют многопоточностью, примерно так (помеченные желтым куски кода показывают код, который составляет часть этого шаблона):

// Эти члены из класса WebClient:
 
public byte[] DownloadData (Uri address);    // Синхронная версия
public void DownloadDataAsync (Uri address);
public void DownloadDataAsync (Uri address, object userToken);
public event DownloadDataCompletedEventHandler DownloadDataCompleted;
 
public void CancelAsync (object userState);  // Отменяет операцию
public bool IsBusy { get; }                  // Показывает, что работа все еще идет

Методы *Async выполняются асинхронно: другими словами, они запускают операцию в другом потоке и затем немедленно делают возврат управления в вызвавший их код. Когда операция завершилась, срабатывает событие *Completed - автоматически вызывая Invoke, если это требуется приложению WPF или Windows Forms. Это событие передает обратно объект аргументов событий, который содержит:

• Флаг, показывающий, была ли отменена операция (пользователем, вызвавшим CancelAsync)
• Объект Error, показывающий выброшенное исключение (если это имело место)
• Объект userToken, если он был предоставлен при вызове метода Async

Здесь показано, как мы можем использовать EAP-члены класса WebClient для загрузки веб-страницы:

var wc = new WebClient();
wc.DownloadStringCompleted += (sender, args) =>
{
   if (args.Cancelled)
      Console.WriteLine ("Отменено");
   else if (args.Error != null)
      Console.WriteLine ("Исключение (exception): " + args.Error.Message);
   else
   {
      Console.WriteLine (args.Result.Length + ": столько символов было загружено");
      // Здесь мы могли бы обновить интерфейс пользователя (UI)...
   }
};
wc.DownloadStringAsync (new Uri ("http://www.linqpad.net"));   // Запуск загрузки

Класс, следующий модели EAP может предоставить дополнительные группы асинхронных методов. Например:

public string DownloadString (Uri address);
public void DownloadStringAsync (Uri address);
public void DownloadStringAsync (Uri address, object userToken);
public event DownloadStringCompletedEventHandler DownloadStringCompleted;

Однако эти методы будут совместно использовать одни и те же члены CancelAsync и IsBusy. Таким образом одновременно может произойти только одна асинхронная операция.

EAP предоставляет возможность экономить на потоках, если внутренняя реализация следует APM (описывается в Главе 23 книжки "C# 4.0 in a Nutshell" []).

В части 5 [7] этой документации мы увидим, как Tasks [4] предоставляют подобные возможности - включая перенаправление исключения, токены продолжения выполнения и отмены (continuations, cancellation tokens) и поддержку контекстов синхронизации. Это делает реализацию EAP менее привлекательной - кроме простых случаев, которые может обеспечить класс BackgroundWorker.

[BackgroundWorker]

BackgroundWorker это вспомогательный класс из пространства имен System.ComponentModel, предназначенный для управления рабочим потоком (потоком, который выполняет какую-то фоновую работу приложения). Его можно считать реализацией EAP общего назначения с предоставлением следующих функций:

• Модель согласованной отмены действий (cooperative cancellation model)
• Возможность безопасно обновлять графические элементы управления приложения (controls GUI-интерфейса WPF или Windows Forms), когда рабочий поток завершил свою работу
• Перенаправление исключений в событие завершения (completion event)
• Протокол для сообщения о текущем статусе выполняемой операции (reporting progress)
• Реализация IComponent, позволяющая перетащить и бросить компонент в форму приложения Visual Studio

BackgroundWorker использует пул потоков (Thread Pool, см. [2]), что означает, что Вы никогда не должны вызывать Abort на потоке BackgroundWorker.

Использование BackgroundWorker. Вот минимальные шаги для применения BackgroundWorker:

1. Инстанцируйте экземпляр BackgroundWorker и определите обработчик события DoWork.
2. Вызовите метод RunWorkerAsync, опционально с аргументом типа object.

После этого все придет в движение. Любой аргумент, переданный в RunWorkerAsync, будет перенаправлен в обработчик события DoWork через свойство события аргумента Argument. Вот пример:

class Program
{
   static BackgroundWorker _bw = new BackgroundWorker();
 
   static void Main()
   {
      _bw.DoWork += bw_DoWork;
      _bw.RunWorkerAsync ("Сообщение для рабочего потока");
      Console.ReadLine();
   }
 
   static void bw_DoWork (object sender, DoWorkEventArgs e)
   {
      // Этот код будет работать в рабочем потоке:
      Console.WriteLine (e.Argument);  // выведется "Сообщение для рабочего потока"
      // Выполнение какой-то емкой по времени вычислительной задачи...
   }
}

У BackgroundWorker есть событие RunWorkerCompleted, которое сработает, когда обработчик события DoWork завершит свою работу. Обработка RunWorkerCompleted не обязательна, однако обычно она нужна, чтобы запросить информацию о любом исключении, которое было выброшено во время выполнения кода DoWork. Кроме того, код в обработчике RunWorkerCompleted может напрямую обновить интерфейс пользователя без явного маршалирования; код внутри обработчика события DoWork обновлять интерфейс пользователя не может.

Чтобы добавить поддержку сообщения о прогрессе выполнения задачи внутри DoWork (progress reporting):

1. Установите свойство WorkerReportsProgress в значение true.
2. Периодически вызывайте ReportProgress из тела обработчика DoWork, передавая ему процентное значение, обозначающее прогресс выполняемой работы. Также опционально можно передать пользовательский объект состояния (user-state object).
3. Напишите код обработчика события ProgressChanged, опрашивая в нем свойство аргумента события ProgressPercentage.
4. Код в обработчике события ProgressChanged выполняется в главном потоке приложения, поэтому он может свободно взаимодействовать с графическим интерфейсом пользователя (UI), т. е. обновлять его - точно так же, как это может делать обработчик RunWorkerCompleted. Типичный пример - обновление полоски прогресса операции (progress bar).

Чтобы добавить поддержку отмены операции рабочего потока (cancellation):

1. Установите свойство WorkerSupportsCancellation в значение true.
2. Периодически проверяйте свойство CancellationPending в коде обработчика события DoWork. Если оно равно true, установите свойство Cancel аргумента в true и выполните возврат из обработчика DoWork. Также рабочий поток, работающий в теле DoWork, может сам установить Cancel и выйти без установленного свойства CancellationPending, если он решит, что работа слишком сложная, и её нельзя завершить.
3. Вызовите CancelAsync для выставления запроса отмены операции рабочего потока.

Вот пример, который реализует все ранее перечисленные функции:

using System;
using System.Threading;
using System.ComponentModel;
 
class Program
{
   static BackgroundWorker _bw;
 
   static void Main()
   {
      _bw = new BackgroundWorker
      {
         WorkerReportsProgress = true,
         WorkerSupportsCancellation = true
      };
      _bw.DoWork += bw_DoWork;
      _bw.ProgressChanged += bw_ProgressChanged;
      _bw.RunWorkerCompleted += bw_RunWorkerCompleted;
 
      _bw.RunWorkerAsync ("Привет рабочему потоку");
 
      Console.WriteLine ("Нажмите Enter в течение следующих 5 "
                       + "секунд для отмены операции рабочего потока");
      Console.ReadLine();
      if (_bw.IsBusy) _bw.CancelAsync();
      Console.ReadLine();
   }
 
   static void bw_DoWork (object sender, DoWorkEventArgs e)
   {
      for (int i = 0; i < 101; i += 20)
      {
         if (_bw.CancellationPending) { e.Cancel = true; return; }
         _bw.ReportProgress (i);
         Thread.Sleep (1000);    // Это только для демонстрации... в реальных потоках
      }                          // пула никогда так не делайте!
 
      e.Result = 123;            // Это будет передано в RunWorkerCompleted
   }
 
   static void bw_RunWorkerCompleted (object sender,
                                      RunWorkerCompletedEventArgs e)
   {
      if (e.Cancelled)
         Console.WriteLine ("Вы отменили операцию рабочего потока!");
      else if (e.Error != null)
         Console.WriteLine ("Исключение рабочего потока (exception): " + e.Error.ToString());
      else
         Console.WriteLine ("Результат завершенной операции: " + e.Result);   // из DoWork
   }
 
   static void bw_ProgressChanged (object sender,
                                   ProgressChangedEventArgs e)
   {
      Console.WriteLine ("Выполнено " + e.ProgressPercentage + "%");
   }
}

Этот код выведет следующее:

Нажмите Enter в течение следующих 5 секунд для отмены операции рабочего потока
Выполнено 0%
Выполнено 20%
Выполнено 40%
Выполнено 60%
Выполнено 80%
Выполнено 100%
Результат завершенной операции: 123
 
Нажмите Enter в течение следующих 5 секунд для отмены операции рабочего потока
Выполнено 0%
Выполнено 20%
Выполнено 40%

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

BackgroundWorker не изолирован и предоставляет виртуальный метод OnDoWork, предлагая другой шаблон для использования. При реализации потенциально долго работающего метода Вы можете написать дополнительную версию, возвращающую подкласс BackgroundWorker, предварительно сконфигурированный, чтобы выполнить свою работу параллельно. Потребителю тогда нужно только обработать события RunWorkerCompleted и ProgressChanged. Для примера предположим, что пишется долго работающий метод с именем GetFinancialTotals:

public class Client
{
   Dictionary < string,int> GetFinancialTotals (int foo, int bar) { ... }
   ...
}

Переработка кода:

public class Client
{
   public FinancialWorker GetFinancialTotalsBackground (int foo, int bar)
   {
      return new FinancialWorker (foo, bar);
   }
}
 
public class FinancialWorker : BackgroundWorker
{
   public Dictionary < string,int> Result;   // Вы можете добавить типизованные поля.
   public readonly int Foo, Bar;
 
   public FinancialWorker()
   {
      WorkerReportsProgress = true;
      WorkerSupportsCancellation = true;
   }
 
   public FinancialWorker (int foo, int bar) : this()
   {
      this.Foo = foo; this.Bar = bar;
   }
 
   protected override void OnDoWork (DoWorkEventArgs e)
   {
      ReportProgress (0, "Тяжелая работа с этим отчетом...");
 
      // Инициализация данных финансового отчета
      // ...
 
      while (!< отчет завершен >)
      {
         if (CancellationPending) { e.Cancel = true; return; }
         // Выполнить другие шаги вычисления ...
         // ...
         ReportProgress (percentCompleteCalc, "Здесь получено...");
      }
      ReportProgress (100, "Завершено!");
      e.Result = Result = < данные завершенного отчета >;
   }
}

Всякий раз при вызове GetFinancialTotalsBackground будет вызван рабочий поток FinancialWorker: это обертка для управления фоновой операцией, сохраняющей работоспособным интерфейс пользователя программы. Он может сообщать о прогрессе вычислений отчета, может отменить вычисления, и дружественен с формами приложений WPF и Windows Forms, и также обрабатывает исключения в рабочем потоке.

[Interrupt и Abort]

Все блокирующие методы (такие как Sleep, Join, EndInvoke и Wait) блокируют выполнение потока навсегда, если условие разблокировки никогда не удовлетворяется, и не задан таймаут для блокировки. Иногда может быть полезно преждевременно освободить заблокированный поток; например, когда нужно завершить приложение. Это реализуют 2 метода:

• Thread.Interrupt
• Thread.Abort

Метод Abort также может завершить не заблокированный поток - который возможно завис в бесконечном цикле. Abort иногда бывает полезен в некоторых сценариях; Interrupt чаще всего никогда не бывает нужен.

Interrupt и Abort могут доставить значительные неприятности, потому что они предоставляют очевидный способ обойти проблему (баг) кода, который следовало бы исследовать и исправить.

Interrupt. Вызов Interrupt на заблокированном потоке принудительно освобождает его с выбрасыванием исключения ThreadInterruptedException, пример:

static void Main()
{
   Thread t = new Thread (delegate()
   {
      try { Thread.Sleep (Timeout.Infinite); }
      catch (ThreadInterruptedException) { Console.Write ("Принудительное "); }
      Console.WriteLine ("пробуждение!");
   });
   t.Start();
   t.Interrupt();
}

Вывод этого примера:

Принудительное пробуждение!

"Прерывание" (Interrupt) не приводит к завершению потока за исключением случаев, когда исключение ThreadInterruptedException не обрабатывается.

Если Interrupt был вызван на потоке, который не был заблокирован, то поток продолжит выполнение до следующего места блокировки, и при достижении этого места будет выброшено исключение ThreadInterruptedException. Это дает возможность избежать необходимости следующей проверки (что не является потокобезопасным, потому что есть возможность вытеснения между оператором if и worker.Interrupt):

if ((worker.ThreadState & ThreadState.WaitSleepJoin) > 0)
   worker.Interrupt();

Однако "прерывание" потока для общего случая опасно, потому что любые методы фреймворка или сторонних библиотек на стеке вызовов могут неожиданно принять прерывание вместо Вашего назначенного кода. Все что потребовалось бы - краткая блокировка (lock) потока на простом ресурсе синхронизации. Если метод не разработан, чтобы быть "прерванным" (с соответствующим кодом очистки в блоках finally), объекты были бы оставлены в недопустимом состоянии, или ресурсы не полностью были бы освобождены.

Кроме того, применять Interrupt не нужно: если Вы пишете код, который блокируется, то можете достичь того же самого результата безопаснее с помощью конструкции сигнализации - или с помощью cancellation tokens Framework 4.0. Если Вы хотите "разблокировать" в каком-нибудь коде, метод Abort скорее всего будет полезнее.

Abort. Блокировку потока также можно принудительно освободить методом Abort. Это дает эффект, подобный вызову Interrupt, за исключением того, что будет выброшено исключение ThreadAbortException вместо ThreadInterruptedException. Кроме того, исключение будет переброшено в конец блока catch (в попытке окончательно завершить поток), если не был вызван метод Thread.ResetAbort в блоке catch. Тем временем у потока есть состояние ThreadState в значении AbortRequested.

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

Большое отличие между Interrupt и Abort в том, что происходит, когда поток не заблокирован. Принимая во внимание, что Interrupt ждет появления следующей блокировки перед тем, чтобы что-то произошло, Abort выбрасывает исключение на потоке сразу в том месте, где поток выполняется (unmanaged code excepted). Проблема здесь в том, что код .NET Framework может быть прерван - код не безопасен от принудительного обрыва (не abort-safe). Например, если abort возник, когда конструируется FileStream, есть возможность, что unmanaged дескриптор файла останется открытым, пока не будет завершен домен приложения. Это исключает возможность использование Abort в большинстве не тривиальных контекстов. В части 4 этой документации [5] более подробно объясняется, почему Abort небезопасен (см. "Принудительное завершение потоков").

Хотя есть два случая, где Вы можете безопасно использовать Abort. Один такой случай - когда Вы хотите прекратить работу приложения после принудительного завершения потока через Abort. Хороший пример, когда это может понадобится - написание среды тестирования модулей библиотек (unit-testing framework). Другой случай, где Вы можете безопасно вызвать Abort - непосредственно сам обрываемый поток (потому что точно известно место, где будет оборван процесс вычисления в потоке). Abort, когда поток обрывает этим сам себя, выбросит "не проглатываемое" исключение: такое исключение будет повторно выбрасываться после каждого блока catch. ASP.NET делает именно это, когда Вы вызываете Redirect.

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

[Безопасная отмена операции потока]

Как было замечено в предыдущей секции, вызов Abort на потоке опасен для большинства сценариев. Альтернативой тогда будет реализация кооперативного шаблона, в котором рабочий поток периодически проверяет состояние флага, сообщающего о необходимости прервать операцию (так же, как это реализуется в BackgroundWorker). Для отмены операции инициатор просто устанавливает флаг, и затем ждет, когда рабочий поток примет его установку и оборвет сам себя. Такой способ реализован во вспомогательном классе BackgroundWorker, и Вы легко можете реализовать то же самое самостоятельно.

Очевидный недостаток такого метода остановки в том, что метод тела рабочего потока должен явно поддерживать остановку по флагу. Тем не менее это все-таки один из безопасных способов отмены операции рабочего потока. Для демонстрации сначала напишем класс, в котором инкапсулирован флаг отмены:

class RulyCanceler
{
   object _cancelLocker = new object();
   bool _cancelRequest;
   public bool IsCancellationRequested
   {
      get { lock (_cancelLocker) return _cancelRequest; }
   }
 
   public void Cancel() { lock (_cancelLocker) _cancelRequest = true; } 
 
   public void ThrowIfCancellationRequested()
   {
      if (IsCancellationRequested) throw new OperationCanceledException();
   }
}

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

Мы можем использовать это следующим образом:

class Test
{
   static void Main()
   {
      var canceler = new RulyCanceler();
      new Thread (() => {
                           try { Work (canceler); }
                           catch (OperationCanceledException)
                           {
                              Console.WriteLine ("Отменено!");
                           }
                        }).Start();
      Thread.Sleep (1000);
      canceler.Cancel();     // Безопасно отменит операцию рабочего потока Work.
   }
 
   static void Work (RulyCanceler c)
   {
      while (true)
      {
         c.ThrowIfCancellationRequested();
         // ...
         try      { OtherMethod (c); }
         finally  { /* любая необходимая очистка */ }
      }
   }
 
   static void OtherMethod (RulyCanceler c)
   {
      // Тут выполняются какие-то действия...
      c.ThrowIfCancellationRequested();
   }
}

Мы могли бы упростить наш пример путем удаления класса RulyCanceler и добавления к классу Test статического двоичного поля _cancelRequest. Однако это будет означать, что если несколько потоков сразу вызвали Work, то установка _cancelRequest = true отменит работу всех этих потоков. Таким образом, наш класс RulyCanceler является полезной абстракцией. Единственно, что не очень красиво - когда мы смотрим на сигнатуру метода Work, то его намерения не ясны:

static void Work (RulyCanceler c)

Может быть метод Work сам намеревается вызвать Cancel на объекте RulyCanceler? В этом случае ответ нет, поэтому было бы хорошо, если бы это работало в типах системы. Framework 4.0 для этой цели предоставляет Маркеры отмены (cancellation tokens).

Маркеры отмены. Framework 4.0 предоставляет два типа, которые формализуют шаблон кооперативной отмены операции потока, который м продемонстрированы: CancellationTokenSource и CancellationToken. Эти два типа работают в тандеме:

• CancellationTokenSource определяет метод Cancel.
• CancellationToken определяет свойство IsCancellationRequested и метод ThrowIfCancellationRequested.

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

Чтобы использовать эти типы, сначала инстанцируйте объект CancellationTokenSource:

var cancelSource = new CancellationTokenSource();

Затем передайте его свойство Token в метод, для которого хотите реализовать поддержку отмены:

new Thread (() => Work (cancelSource.Token)).Start();

Work должен быть определен следующим образом:

void Work (CancellationToken cancelToken)
{
   cancelToken.ThrowIfCancellationRequested();
   ...
}

Когда захотите отменить работу тела потока Work, просто вызовите Cancel на cancelSource.

CancellationToken в действительности структура, хотя Вы можете считать её классом. При неявном копировании копии ведут себя идентично и ссылаются на оригинальный CancellationTokenSource.

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

Cancellation tokens используются в самой среде .NET Framework, особенно в следующих классах:

• ManualResetEventSlim и SemaphoreSlim (см. [3])
• CountdownEvent (см. [3])
• Класс Barrier [6]
• BlockingCollection
• PLINQ и библиотека параллельных вычислений TPL [7] (Task Parallel Library)

Большинство этих классов используют cancellation tokens в своих методах Wait. Например, если Вы запустили ожидание Wait на ManualResetEventSlim, и указали cancellation token, другой поток может отменить (вызвать Cancel) это ожидание. Это намного опрятнее и безопаснее, чем вызов Interrupt на заблокированном потоке.

[Ленивая инициализация]

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

class Foo
{
   public readonly Expensive Expensive = new Expensive();
   ...
}
class Expensive {  /* предположим, что это затратно для конструирования */  }

Проблема тут в том, что этот код при инстанциации Foo влечет за собой потерю производительности для инстанциации Expensive - независимо от того, будет ли когда-нибудь осуществляться доступ к полю Expensive. Очевидный ответ - конструировать экземпляр Expensive по требованию (это называется ленивой инициализацией):

class Foo
{
   Expensive _expensive;
   public Expensive Expensive // Ленивая инстанциация Expensive
   {
      get
      {
         if (_expensive == null) _expensive = new Expensive();
         return _expensive;
      }
   }
   ...
}

Возникает вопрос: безопасно ли это для потоков? Кроме того, что мы получаем доступ _expensive вне блокировки без барьера памяти, предположим что произошло бы, если два потока обратятся к этому свойству одновременно. У них обоих выполнится положительно условие оператора if, и каждый завершится с отдельным экземпляром Expensive. Поскольку это может привести к трудно обнаруживаемым ошибкам, мы можем сказать, что в общем случае это решение не ориентировано на многопоточность (не thread-safe).

Решение проблемы состоит в блокировке вокруг проверки и создания экземпляра объекта Expensive:

Expensive _expensive;
readonly object _expenseLock = new object();
 
public Expensive Expensive
{
   get
   {
      lock (_expenseLock)
      {
         if (_expensive == null) _expensive = new Expensive();
         return _expensive;
      }
   }
}

Lazy< T>. Framework 4.0 предоставляет новый класс Lazy< T>, чтобы помочь с ленивой инициализацией. Если он инстанцирован с аргументом true, то реализуется с потокобезопасной инициализацией, как только что было показано выше.

Lazy< T> в действительности реализует несколько более эффективную версию этого шаблона, которая называется блокировкой с двойной проверкой (double-checked locking). Double-checked locking выполняет дополнительное volatile-чтение, чтобы избежать расходов ресурсов для получения блокировки, когда объект уже инициализирован.

Для использования Lazy< T> инстанцируйте класс с фактическим значением делегата, который укажет, как инициализировать новое значение, и аргументом true. Тогда доступ к этому значению осуществляется через свойство Value:

Lazy< Expensive> _expensive = new Lazy< Expensive>
   (() => new Expensive(), true);
 
public Expensive Expensive { get { return _expensive.Value; } }

Если Вы передадите false в конструктор Lazy< T>, то он реализует не предназначенную для многопоточной среды ленивую инициализацию, которую мы описывали в начале этой секции - Вы должны быть уверены, что хотите использовать Lazy< T> в контексте одного потока.

LazyInitializer. Это статический класс, работающий наподобие Lazy< T>, кроме:

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

Для использования LazyInitializer, вызовите EnsureInitialized перед доступом к полю с передачей ссылки на поле и фактический делегат:

Expensive _expensive;
public Expensive Expensive
{
   get   // Реализует double-checked locking
   { 
      LazyInitializer.EnsureInitialized (ref _expensive,
                                         () => new Expensive());
      return _expensive;
   }
}

Вы также можете передать в другом аргументе, что нужно устроить гонку для потоков, претендующих на инициализацию. Это выглядит похоже на наш оригинальный не потокобезопасный пример с тем отличием, что всегда выиграет только первый пришедший к финишу поток, и процесс инициализации закончится одним одним экземпляром. Достоинство такой техники в том, что она даже быстрее (на многоядерных процессорах), чем double-checked locking - потому что может быть реализована полностью без блокировок. Такая экстремальная реализация необходима редко, и она кое-чего стоит:

• Она медленнее, когда количество потоков, устраивающих гонку на инициализации, чем количество имеющихся ядер.
• Потенциально могут быть потрачены ресурсы CPU, когда выполняется избыточная инициализация.
• Логика инициализации должна быть реализована потокобезопасно (в этом случае она будет не потокобезопасной, например, если конструктор Expensive пишет данные в статические поля).
• Если инициализатор инстанцирует объект, который требует уничтожения, то "пропадающий впустую" объект не будет расформирован без дополнительной логики.

Как образец, здесь показано, как реализована техника блокировки с двойной проверкой (double-checked locking):

volatile Expensive _expensive;
public Expensive Expensive
{
   get
   {
      if (_expensive == null)          // Первая проверка (внешняя блокировка)
         lock (_expenseLock)
            if (_expensive == null)    // Вторая проверка (внутренняя блокировка)
               _expensive = new Expensive();
      return _expensive;
   }
}

И здесь показано, как реализован шаблон гонки-за-инициализацию:

volatile Expensive _expensive;
public Expensive Expensive
{
   get
   {
      if (_expensive == null)
      {
         var instance = new Expensive();
         Interlocked.CompareExchange (ref _expensive, instance, null);
      }
      return _expensive;
   }
}

[Локальное хранилище потока]

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

Решение для изоляции предлагает локальное хранилище потока (thread-local storage). Вам может быть трудно придумать какое-либо требование: данные, которые Вы хотите поддерживать изолированными, имеют тенденцию быть преходящими по своей природе. Их основное применение - хранить данные "вне диапазона" - чтобы поддерживать инфраструктуру путей выполнения (к этому относится обмен сообщениями, транзакции, токены безопасности). Передача таких данных в параметрах метода выглядит чрезвычайно неуклюже и отчуждает все, кроме Ваших собственных методов; сохранение такой информации в обычных статических полях означает совместное использование её всеми потоками.

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

Есть 3 способа реализовать локальное хранилище потока.

Атрибут ThreadStatic. Самый простой путь задействовать thread-local storage это пометить статическое поле атрибутом ThreadStatic:

[ThreadStatic] static int _x;

Тогда каждый поток увидит отдельную копию _x.

К сожалению, [ThreadStatic] не работает с полями экземпляра (просто с ними ничего не делает); и при этом не работает хорошо с инициализаторами полей - они выполняются только один раз в потоке, который запускается при выполнении статического конструктора. Если Вам нужно работать с полями экземпляра или начать работу со значения не по умолчанию, то ThreadLocal< T> будет лучшим выбором.

ThreadLocal< T>. Это нововведение появилось в Framework 4.0. Оно предоставляет локальное хранилище потока и для статических полей, и для полей экземпляра, и позволит Вам задать значения по умолчанию.

Вот так создается ThreadLocal< int> со значением по умолчанию 3 для каждого потока:

static ThreadLocal< int> _x = new ThreadLocal< int> (() => 3);

Затем Вы используете свойство Value поля _x, чтобы получить его значение, локальное для этого потока. Бонус при использовании ThreadLocal - его значения получают ленивое вычисление: заводская функция вычисляется не первом вызове (для каждого потока).

ThreadLocal< T> и поля экземпляра. ThreadLocal< T> также полезно для полей экземпляра и захваченных локальных переменных. Для примера рассмотрим проблему генерации случайных чисел в многопоточном рабочем окружении. Класс Random не потокобезопасный, так что у нас должна быть либо блокировка вокруг использования Random (ограниченное параллельное использование), или нужно генерировать отдельный объект Random для каждого потока. ThreadLocal< T> упрощает последнее:

var localRandom = new ThreadLocal< Random>(() => new Random());
Console.WriteLine (localRandom.Value.Next());

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

var localRandom = new ThreadLocal< Random>
   ( () => new Random (Guid.NewGuid().GetHashCode()) );

Этот способ мы используем в части 5 [7] (см. пример параллельной проверки синтаксиса в "PLINQ").

GetData и SetData. Третий вариант получить локальное хранилище потока - использование двух методов класса Thread: GetData и SetData. Они сохраняют данные в принадлежащих потоку "слотах". Thread.GetData читает из изолированного хранилища данных потока; Thread.SetData записывает в него. Оба метода требуют объект LocalDataStoreSlot для идентификации слота. Один и тот же слот может использоваться между всеми потоками, и он все еще получит разные значения. Пример:

class Test
{
   // Один и тот же объект LocalDataStoreSlot может использоваться всеми потоками.
   LocalDataStoreSlot _secSlot = Thread.GetNamedDataSlot ("securityLevel");
 
   // Это свойство получит отдельное значение для каждого потока.
   int SecurityLevel
   {
      get
      {
         object data = Thread.GetData (_secSlot);
         return data == null ? 0 : (int) data;    // null == не инстанцировано
      }
      set { Thread.SetData (_secSlot, value); }
   }
   ...

В этом примере мы вызываем Thread.GetNamedDataSlot, который создает именованный слот - это позволяет совместно использует этот слот в приложении. Альтернативно Вы можете управлять областью действия слота, если будете использовать не именованный слот, полученный вызовом Thread.AllocateDataSlot:

class Test
{
   LocalDataStoreSlot _secSlot = Thread.AllocateDataSlot();
   ...

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

[Таймеры]

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

new Thread (delegate()  {
                           while (enabled)
                           {
                              DoSomeAction();
                              Thread.Sleep (TimeSpan.FromHours (24));
                           }
                        }).Start();

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

.NET предоставляет четыре таймера. Два из них многопоточные таймеры общего назначения:

• System.Threading.Timer
• System.Timers.Timer

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

• System.Windows.Forms.Timer (таймер Windows Forms)
• System.Windows.Threading.DispatcherTimer (WPF timer)

Многопоточные таймеры мощнее, точнее и более гибкие; однопоточные таймеры безопаснее и более удобны для запуска простых задач, которые обновляют органы управления Windows Forms или элементы WPF.

Многопоточные таймеры. System.Threading.Timer самый простой многопоточный таймер: у него есть только конструктор и 2 метода. В следующем примере таймер вызывает метод Tick, который выводит "tick..." после истечения 5 секунд, и после этого через каждую секунду, пока пользователь не нажмет Enter:

using System;
using System.Threading;
 
class Program
{
   static void Main()
   {
      // Первый интервал = 5000 мс; последующие интервалы = 1000 мс
      Timer tmr = new Timer (Tick, "tick...", 5000, 1000);
      Console.ReadLine();
      tmr.Dispose();       // Это остановит таймер и выполнит очистку.
   }
 
   static void Tick (object data)
   {
      // Это будет запущено на потоке из пула:
      Console.WriteLine (data);     // Выведет "tick..."
   }
}

Вы можете поменять интервал таймера вызовом Change. Если Вы хотите запустить таймер только 1 раз, задайте Timeout.Infinite в последнем аргументе конструктора.

.NET Framework предоставляет другой класс таймера с тем же именем в пространстве имен System.Timers. Это просто обертка над System.Threading.Timer, использующая ту же самую нижележащую систему и предоставляющая дополнительное удобство. Вот какие функции были добавлены:

• Реализация Component, позволяющая использовать класс визуальным редактором Visual Studio
• Свойство Interval вместо Change
• Elapsedevent вместо делегата callback
• Свойство Enabled для запуска и остановки таймера (со значением по умолчанию false)
• Методы Start и Stop в случае если Вы путаетесь со свойством Enabled
• Флаг AutoReset для указания повторяющегося события (значение по умолчанию true)
• Свойство SynchronizingObject с методами Invoke и BeginInvoke для безопасного вызова методов элементов WPF и органов управления Windows Forms

Ниже дан пример:

using System;
using System.Timers;    // Пространство имен Timers вместо Threading
 
class SystemTimer
{
   static void Main()
   {
      Timer tmr = new Timer();      // Не требуется никаких аргументов
      tmr.Interval = 500;
      tmr.Elapsed += tmr_Elapsed;   // Использует событие вместо делегата
      tmr.Start();                  // Запуск таймера
      Console.ReadLine();
      tmr.Stop();                   // Остановка таймера
      Console.ReadLine();
      tmr.Start();                  // Повторный запуск таймера
      Console.ReadLine();
      tmr.Dispose();                // Остановка таймера навсегда
   }
 
   static void tmr_Elapsed (object sender, EventArgs e)
   {
      Console.WriteLine ("Tick");
   }
}

Многопоточные таймеры используют пул потоков, чтобы позволить нескольким потокам обслуживать много таймеров. Это означает, что метод callback или событие Elapsed может каждый раз быть вызвано на другом потоке. Кроме того, запуск Elapsed всегда сработает (приблизительно) во время - независимо от того, завершилось ли предыдущее выполнение Elapsed. Таким образом, callback-и или обработчики события должны быть потокобезопасными.

Windows multimedia timer. Точность многопоточных таймеров зависит от операционной системы и обычно попадает в интервал 10-20 мс. Если Вам нужна более высокая точность, то можете использовать native interop (?..) и вызов Windows multimedia timer. Это дает точность до 1 мс и определено в winmm.dll. Первый вызов timeBeginPeriod производится для информирования операционной системы, что нужна повышенная точность отсчета времени, затем вызывается timeSetEvent, чтобы запустить multimedia timer. Когда Вы завершили работу с таймером, вызовите timeKillEvent для остановки таймера и timeEndPeriod, чтобы информировать операционную систему, что больше не нужна повышенная точность отсчета времени. Полные примеры использования multimedia timer можно найти в Интернете, используйте для этого поиск по ключевым словам dllimport winmm.dll timesetevent.

Однопоточные таймеры. Библиотека .NET Framework предоставляет таймеры, разработанные для устранения проблем потокобезопасности в приложениях WPF и Windows Forms:

• System.Windows.Threading.DispatcherTimer (WPF)
• System.Windows.Forms.Timer (Windows Forms)

Однопоточные таймеры не разработаны, чтобы функционировать вне своего соответствующего окружения. Например, если Вы используете таймер Windows Forms в приложении Windows Service, то событие Timer не будет срабатывать!

Оба класса таймера наподобие System.Timers.Timer в своих членах предоставляют одинаковые методы и свойства (Interval, Tick, Start, и Stop), и используются также одинаково. Однако они отличаются тем, как они работают внутри себя. Вместо использования пула потоков для генерации событий таймера, таймеры WPF и Windows Forms полагаются на механизм обмена сообщениями нижележащей модели пользовательского интерфейса. Это означает, что событие Tick всегда сработает в том же потоке, где изначально был создан таймер - т. е. в обычном приложении это тот же поток, который используется для обслуживания всего интерфейса пользователя (кнопочки, галочки, окна вывода текста и т. п.). У этого есть определенные достоинства:

• Можно забыть про безопасность потоков.
• Новый Tick никогда не сработает, пока предыдущий Tick не завершит свою обработку.
• Вы можете обновлять элементы пользовательского интерфейса (UI) непосредственно из кода обработки события Tick без необходимости вызывать Control.Invoke или Dispatcher.Invoke.

Это звучит слишком хорошо, чтобы быть правдой, пока Вы не поймете, что программа, применяющая эти таймеры, на самом деле не многопоточная - здесь нет параллельного выполнения кода. Один поток обрабатывает все таймеры - как и делает обработку всех событий UI. Из-за этого мы имеем недостаток однопоточных таймеров:

• Если не сделать быструю обработку обработчика события Tick, то интерфейс пользователя станет неотзывчивым.

В результате однопоточные таймеры WPF и Windows Forms подходят только для простой работы, обычно заключающейся в обновлении каких-то аспектов интерфейса пользователя (например отображение часов или обратного отсчета). Иначе Вам нужно применять многопоточный таймер.

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

[Ссылки]

1. Threading in C# PART 3: USING THREADS site:albahari.com.
2. Потоки на C#. Часть 1: введение.
3. Потоки на C#. Часть 2: основы синхронизации.
4. Параллелизм задач (Task Parallelism).
5. Прекращение работы потока (Aborting Threads).
6. Класс Barrier.
7. Потоки на C#. Часть 5: параллельное программирование.

 

Добавить комментарий


Защитный код
Обновить

Top of Page