Программирование PC Потоки на C#. Часть 1: введение Thu, April 25 2024  

Поделиться

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

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

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

C# поддерживает параллельное выполнение кода с помощью многопоточной среды (multithreading). Каждый поток (thread) имеет независимую обработку алгоритма своей программы, и он может выполнять его одновременно (параллельно) с работой других потоков.

Клиентское приложение C# (Console, WPF или Windows Forms) запускается в одном потоке, который создается автоматически библиотекой CLR (Common Language Runtime) и операционной системой (главный поток, поток "main"), и многопоточность обеспечивается созданием дополнительных потоков. Ниже приведен простой пример создания потоков и результат их вывода.

using System;
using System.Threading;
 
...
class ThreadTest
{
   static void Main()
   {
      // Создание нового потока из тела подпрограммы WriteY:
      Thread t = new Thread (WriteY);
      // Запуск потока:
      t.Start();
      // Вместе с работающим потоком внутри WriteY() также будем
      // выполнять что-нибудь в главном потоке приложения:
      for (int i = 0; i < 1000; i++)
         Console.Write ("x");
  }
 
  static void WriteY()
  {
      for (int i = 0; i < 1000; i++)
         Console.Write ("y");
  }
}

Примечание: все примеры, показанные здесь, подразумевают импорт в код пространств имен System и System.Threading (добавляются директивой using в начале модуля кода *.cs).

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

xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
...

В этом примере главный поток создает новый поток t, который выполняет свой цикл, в котором печатает символ "y". Одновременно главный поток печатает символ "x":

CSharp Threading NewThread

Когда поток запущен, его свойство IsAlive будет возвращать true, пока этот поток не завершится. Поток завершится, когда делегат, переданный конструктору Thread (в данном примере делегат это подпрограмма WriteY), завершит свое выполнение (т. е. выполнит возврат из тела своей подпрограммы). После своего завершения поток не может перезапуститься.

Библиотека CLR назначает каждому потоку свой стек в памяти, так что локальные для этого потока переменные сохраняются отдельно от других данных в программе (и отдельно от других потоков) - до момента завершения потока. В следующем примере мы определим метод с локальной переменной, затем вызовем этот метод в главном потоке и в новом создаваемом потоке:

static void Main()
{
   new Thread (Go).Start();   // Вызов Go() в новом потоке.
   Go();                      // Вызов Go() в основном потоке приложения.
}
 
static void Go()
{
   // Декларирование и использование локальной переменной cycles:
   for (int cycles = 0; cycles < 5; cycles++)
      Console.Write ('?');
}

Вывод:

??????????

Отдельная копия переменной cycles создается в отдельном стеке памяти каждого потока, и в результате в консоль было выведено 5+5=10 символов "?".

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

class ThreadTest
{
   bool done;
 
   static void Main()
   {
      ThreadTest tt = new ThreadTest();   // Создание общего экземпляра
      new Thread (tt.Go).Start();
      tt.Go();
   }
 
   // Обратите внимание, что Go теперь метод экземпляра:
   void Go() 
   {
      if (!done)
      {
         done = true;
         Console.WriteLine ("Done");
      }
   }
}

Из-за того, что оба потока вызвали Go() на одном и том же экземпляре ThreadTest, они используют общее поле (переменную) done. В результате сообщение "Done" будет выведено только один раз, а не дважды:

Done

Статические поля предоставляют другой способ предоставить общие данные для нескольких потоков. Вот еще один пример с переменной done в качестве статического поля (подробнее про ключевое слово static на C# см. статью [2]):

class ThreadTest
{
   // Статические поля класса могут использоваться совместно
   // всеми потоками этого класса:
   static bool done;
 
   static void Main()
   {
      new Thread (Go).Start();
      Go();
   }
 
   static void Go()
   {
      if (!done)
      {
         done = true;
         Console.WriteLine ("Done");
      }
   }
}

Оба этих примера иллюстрируют другую ключевую концепцию: потокобезопасность (thread safety [3]). Или, если быть точнее, с отсутствием всякой безопасности! Следует быть очень осторожным, когда потоки используют общие данные. В действительности с общими данными вывод программы может быть неопределенным. Есть некая возможность, что "Done" будет напечатано дважды. Особенно часто такие случаи будут происходить, если поменять местами операторы в методе Go:

static void Go()
{
   if (!done)
   {
      Console.WriteLine ("Done");
      done = true;
   }
}

Результат:

Done
Done

Проблема здесь в том, что один поток может проверить значение своего условия if сразу в тот момент, когда другой поток выполняет оператор WriteLine - в этом примере больше шансов, что это произойдет до того, как done будет установлено в true.

Вылечить эту проблему можно путем получения исключительной блокировки (exclusive lock [4]) в тот момент, когда происходит чтение и запись общего поля программы. C# предоставляет именно для этой цели оператор lock:

class ThreadSafe 
{
   static bool done;
   static readonly object locker = new object();
 
   static void Main()
   {
      new Thread (Go).Start();
      Go();
   }
 
   static void Go()
   {
      lock (locker)
      {
         // Начало критической секции кода
         if (!done)
         {
            Console.WriteLine ("Done");
            done = true;
         }
         // Конец критической секции кода
      }
   }
}

Когда два потока одновременно претендуют на доступ к блокировке lock (в этом пример к locker), один поток ждет, или блокирует доступ к данным до тех пор, пока lock не освободится. В этом примере такая блокировка гарантирует, что только один поток в любой момент времени может зайти в критическую секцию кода, в результате чего сообщение "Done" будет выведено только 1 раз. Защищенный таким способом код (тут подразумевается безопасный доступ к общим данным в многопоточной среде) называют потокобезопасным (thread-safe).

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

Если поток 1 дошел до критической секции, которую в данный момент выполняет поток 2, то поток 1 будет заблокирован (blocked) до момента освобождения критической секции. Заблокированный поток не потребляет процессорные ресурсы (не загружает CPU).

Join и Sleep. Вы можете ждать завершения другого потока путем вызова метода Join. Пример:

static void Main()
{
   Thread t = new Thread (Go);
   t.Start();
   t.Join();
   Console.WriteLine ("Поток t завершился!");
}
 
static void Go()
{
   for (int i = 0; i < 1000; i++)
      Console.Write ("y");
}

Этот пример напечатает символ "y" тысячу раз, и сразу за этим будет выведено сообщение "Поток t завершился!". Вы можете добавить таймаут при вызове Join, указав его либо в миллисекундах, либо как TimeSpan. Этот вызов вернет true, если поток завершился до таймаута, или false, если произошел таймаут.

Thread.Sleep приостанавливает текущий поток на указанный период времени:

Thread.Sleep (TimeSpan.FromHours (1));    // приостановит поток на 1 час
Thread.Sleep (500);                       // приостановка на 500 миллисекунд

При ожидании на Sleep или Join поток заблокирован и не потребляет ресурсы процессора.

Вызов Thread.Sleep(0) немедленно освобождает текущий квант времени, предоставляя тем самым процессорное время для других потоков. В Framework 4.0 есть новый метод Thread.Yield(), который делает то же самое, за исключением того, что процессорное время освобождается только для потоков, которые работают на том же процессоре (сегодня почти все компьютерные системы многоядерные).

Sleep(0) или Yield иногда полезно использовать в конечном коде релиза для подстройки скорости выполнения. Это также отличный инструмент диагностики, помогающий разобраться с проблемами безопасности потоков: если вызов Thread.Yield() в любом месте Вашего кода создает проблему или приводит к падению программы, то скорее всего в программе есть ошибка.

[Как работают потоки]

Многопоточная среда выполнения обслуживается внутри операционной системы с помощью планировщика (thread scheduler), библиотека CLR обычно делегирует эту функцию операционной системе. Планировщик гарантирует, что все активные потоки получают подходящее время выполнения, и что потоки, которые находятся в ожидании или заблокированы (например, на критической секции exclusive lock или на вводе пользователя) не будут потреблять процессорное время.

На компьютере с одним процессором планировщик работает по принципу распределения квантов времени (time-slicing), т. е. он часто выполняет переключение между каждым активным потоком. В операционной системе Windows кванты времени (тики) обычно измеряются десятках миллисекунд. Эти интервалы намного больше, чем CPU тратит на реальное переключение контекста потоков (процесс переключения контекста обычно занимает несколько микросекунд).

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

Говорят, что поток "вытесняется" (preempted), когда его выполнение принудительно приостанавливается внешним фактором, таким как time-slicing. В большинстве случаев поток не управляет тем, когда и где он будет вытеснен.

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

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

Обеспечение отзывчивости интерфейса пользователя. Когда задачи с интенсивными вычислениями работают параллельно в "рабочем" потоке, основной интерфейс приложения (UI) свободен для обработки событий клавиатуры и мыши.
Эффективное использование процессора. Без многопоточности процессор был бы вынужден простаивать в ожидании завершения каких-либо действий. Многопоточность полезна, когда поток ждет ответа от другого компьютера или от какой-либо части аппаратного обеспечения. А то время как один поток заблокирован на выполнении своей задачи, другие потоки могут беспрепятственно использовать свободное процессорное время в своих целях.
Параллельное программирование. Код, выполняющий интенсивные вычисления, может быстрее получить результат на многоядерных или многопроцессорных компьютерах, если рабочая нагрузка по вычислениям распределена на несколько потоков по принципу стратегии "разделяй и властвуй" (см. часть 5 этого руководства [11]).
Спекулятивное (упреждающее) выполнение. На многоядерных машинах Вы можете иногда улучшить быстродействие путем предсказания чего-то, что возможно должно быть сделано, и затем выполняя эти вычисления загодя. Пользователи LINQPad [5] применяюют эту технику для ускорения создания новых запросов. Вариация этого метода - запуск нескольких разных алгоритмов параллельно, которые выполняют одну и ту же задачу. Тот, кто быстрее выполнит задачу, будет "победителем" - это эффективно, когда Вы не знаете заранее, какой алгоритм выполнится быстрее.
• Одновременное выполнение запросов. На сервере запросы от клиентов могут поступать конкурентно по отношению друг к другу, поэтому запросы нуждаются в параллельной обработке(среда .NET Framework автоматически для этого создает потоки, если используется библиотеки ASP.NET, WCF, Web Services или Remoting). Это может быть также полезно на стороне клиента, например в процессе обмена каждый с каждым, peer-to-peer,  или когда от пользователя поступило несколько запросов.

С такими техниками, как ASP.NET и WCF, Вы можете даже не знать о существовании многопоточности - за исключением случаев, когда осуществляется доступ к общим данным (возможно через статические поля) без соответствующей блокировки, когда встает проблема с безопасностью потоков.

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

Хорошей стратегией будет инкапсуляция логики многопоточности в повторно используемых классах, которые можно независимо проверить и протестировать. Среда разработки .NET предоставляет множество высокоуровневых конструкций, связанных с потоками, которые мы обсудим позже.

Следует иметь в виду, что вовлечение потоков приводит к трате дополнительных ресурсов процессора на планирование и переключение контекста между потоками (scheduling and switching threads), особенно когда активных потоков намного больше, чем ядер CPU. Также на эти расходы накладывается процессорное время, затрачиваемое на создание и уничтожение потоков (creation/tear-down) и связанная с этим "уборка мусора" в куче. Многопоточность не всегда ускорит работу Вашего приложения, она может даже замедлить его, если потоки используются интенсивно и не соответствующим образом. Например, когда осуществляется интенсивный дисковый ввод/вывод, то он может выполниться быстрее, когда несколько рабочих потоков выполняются последовательно друг за другом, чем если бы сразу 10 потоков работали с диском одновременно. В материале "Обмен сигналами через Wait и Pulse" [7] будет описано, как реализовать очередь генератор/потребитель данных (producer/consumer queue), где будет предоставлена именно такая функциональность.

[Создание и запуск потоков]

Как уже было сказано выше, потоки создаются конструктором класса Thread путем передачи в него определенного делегата ThreadStart. Вот так определен делегат ThreadStart:

public delegate void ThreadStart();

Вызов Start на потоке переводит его в работающее состояние. Поток продолжает свое выполнения до момента возврата из его метода, в этом месте поток завершается. Ниже показан расширенный синтаксис C# для создания делегата TheadStart:

class ThreadTest
{
   static void Main() 
   {
      Thread t = new Thread (new ThreadStart (Go));
 
      t.Start();     // Запуск Go() на новом потоке.
      Go();          // Одновременный запуск метода Go() в главном потоке.
   }
 
   static void Go()
   {
      Console.WriteLine ("hello!");
   }
}

В этом примере поток t выполнит процедуру Go() в то же самое время (вероятнее всего), как основной поток вызовет Go(). В результате будет близко по времени друг к другу два сообщения "hello!".

Поток может быть создан более удобным способом - просто если указать метод группы, это позволит C# определить делегат ThreadStart:

Thread t = new Thread (Go);   // Не нужно явно использовать ThreadStart

Другой хитрый способ - применить lambda expression или метод anonymous:

static void Main()
{
   Thread t = new Thread ( () => Console.WriteLine ("Hello!") );
   t.Start();
}

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

static void Main()
{
   Thread t = new Thread ( () => Print ("Hello from t!") );
   t.Start();
}
 
static void Print (string message) 
{
   Console.WriteLine (message);
}

С этим способом можно передать любое количество аргументов в метод потока. Вы даже можете обернуть всю реализацию в один многооператорный lambda:

new Thread (() =>
{
   Console.WriteLine ("Я запустил другой поток!");
   Console.WriteLine ("Это оказалось так просто!");
}).Start();

Вы можете сделать то же самое и почти так же просто в C# 2.0 с помощью anonymous-методов:

new Thread (delegate()
{
   ...
}).Start();

Другая техника заключается в передаче аргумента в метод Start класса Thread:

static void Main()
{
   Thread t = new Thread (Print);
   t.Start ("Привет от потока t!");
}
 
static void Print (object messageObj)
{
   string message = (string) messageObj;  // Здесь надо сделать приведение типа (cast).
   Console.WriteLine (message);
}

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

public delegate void ThreadStart();
public delegate void ParameterizedThreadStart (object obj);

Ограничение ParameterizedThreadStart в том, что он может принять только один аргумент. И потому, что у него тип object, обычно требуется для применения параметра привести его к какому-либо типу (typecast).

Lambda-выражения и захваченные переменные. Выражения lambda наиболее мощный способ передать данные в поток. Однако Вы должны быть осторожны на предмет случайной модификации захваченных переменных после запуска потока, потому что эти переменные будут общими (их копия не делается). Например:

for (int i = 0; i < 10; i++)
   new Thread (() => Console.Write (i)).Start();

Вывод этого примера будет совершенно неопределенным! Вот типичный результат вывода этого примера:

0223557799

Проблема тут в том, что переменная i относится к одной и той же ячейке памяти за все время работы цикла for. Таким образом, каждый поток вызовет Console.Write с переменной, которая может поменяться в момент работы потока!

Это аналогично проблеме, которая описана в главе 8 "Captured Variables" книги "C# 4.0 in a Nutshell" [8]. Эта проблема меньше касается многопоточности, чем правил, по которым C# захватывает переменные (что несколько нежелательно для циклов for и foreach).

Решение состоит в применении временной переменной:

for (int i = 0; i < 10; i++)
{
   int temp = i;
   new Thread (() => Console.Write (temp)).Start();
}

Теперь переменная temp локальна для каждой итерации цикла. Таким образом, каждый поток захватывает разные ячейки памяти, и проблемы нет. Мы можем еще проще иллюстрировать проблему с предыдущим кодом:

string text = "t1";
Thread t1 = new Thread ( () => Console.WriteLine (text) );
 
text = "t2";
Thread t2 = new Thread ( () => Console.WriteLine (text) );
 
t1.Start();
t2.Start();

Из-за того, что lambda-выражение захватит одну и ту же переменную text, t2 будет напечатано дважды:

t2
t2

Именованные потоки. У каждого потока есть свойство Name, которое Вы можете установить в целях отладки. В частности это полезно в среде Visual Studio, поскольку имя потока отображается в окне Threads и тулбаре Debug Location. Вы можете установить имя потока только один раз; попытки изменить его позже приведут к выбрасыванию исключения.

Статическое свойство Thread.CurrentThread даст Вам имя выполняющегося в настоящий момент потока. В следующем примере мы установим имя главного потока:

class ThreadNaming
{
   static void Main()
   {
      Thread.CurrentThread.Name = "main";
      Thread worker = new Thread (Go);
      worker.Name = "worker";
      worker.Start();
      Go();
   }
 
   static void Go()
   {
      Console.WriteLine ("Привет от потока " + Thread.CurrentThread.Name);
   }
}

Потоки Foreground и Background. По умолчанию создаваемые Вами потоки работают исключительно как равноправные (foreground threads). Foreground-потоки удерживают приложение в работающем состоянии пока хотя бы один из потоков работает, в то время как background-потоки этого не делают. Как только завершатся все foreground-потоки, завершится и приложение, и при завершении работы приложения любые background-потоки резко завершают свое выполнение.

Статус foreground/background потока не имеет отношение к его приоритету или выделению для потока процессорного времени.

Вы можете опросить или поменять background-состояние потока с помощью его свойства IsBackground:

class PriorityTest
{
   static void Main (string[] args)
   {
      Thread worker = new Thread ( () => Console.ReadLine() );
      if (args.Length > 0)
         worker.IsBackground = true;
      worker.Start();
   }
}

Если эта программа запущена без аргументов, то поток worker подразумевает foreground-статус, и будет ждать на операторе ReadLine, пока пользователь не нажмет клавишу Enter. Между тем основной поток завершиться, но приложение останется работать, потому что foreground-поток все еще работает.

С другой стороны, если в Main() передан аргумент, потоку worker назначен background-статус, и программа сразу же завершится, как только завершиться главный поток (что прервет работу ReadLine).

Когда процесс завершается таким образом, любые блоки finally в стеке выполнения background-потоков не выполнятся. Это создаст проблему, если Ваша программа используется конструкции try/finally, чтобы чисто завершить работу наподобие освобождения ресурсов или удаления временных файлов. Чтобы избежать этого, Вы можете явно ожидать завершения таких background-потоков при выходе из приложения. Есть 2 способа для этого:

• Если Вы сами создали поток, вызовите Join на потоке.
• Если у Вас поток из пула (см. ниже "Thread Pooling"), реализуйте сигнализацию ожидания события (см. event wait handle, дескрипторы ожидания [7]).

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

Если пользователь применяет Task Manager для принудительного завершения процесса .NET, то все потоки независимо от своего статуса немедленно оборвут свою работу ("drop dead"), как если бы они были background-потоками. Это наблюдаемое, а не документированные поведение, и оно может поменяться в зависимости от версии библиотеки CLR и версии операционной системы.

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

Приоритет потока. Этот параметр определяет, какую долю процессорного времени (execution time) поток получит по отношению к другим активным потокамв операционной системе. Шкала приоритетов следующая:

enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }

Приоритет работает только когда становятся активными несколько потоков одновременно.

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

Повышение приоритета потока не приведет к его работе в режиме реального времени (как в системах RTOS), потому что процессорное время все еще регулирует приоритет процесса приложения. Чтобы работа потока была действительно близка к реальному времени, Вы также должны повысить приоритет процесса приложения с помощью класса Process в библиотеке System.Diagnostics:

using (Process p = Process.GetCurrentProcess())
   p.PriorityClass = ProcessPriorityClass.High;

ProcessPriorityClass.High в действительности только одна метка за исключением еще более высокого уровня приоритета: Realtime. Установка приоритета процесса в Realtime инструктирует операционную систему, что Вы не хотите, чтобы процесс уступал время CPU для других процессов. Если Ваша программа случайно войдет в бесконечный цикл, то Вы можете обнаружить, заблокировалась даже операционная система, и ничего не может помочь вернуть еще к жизни, кроме кнопки сброса или выключения питания на системном блоке! По этой причине High обычно лучший выбор для приложений (как бы) реального времени.

Если Ваше приложение реального времени снабжено интерфейсом пользователя (User Interface, UI), то повышение приоритета процесса приведет к тому, что обновление экрана будет потреблять дополнительное время CPU, замедляя работу всего компьютера (в частности если этот UI сложный). Понижение приоритета главного потока совместно с повышением приоритета процесса гарантирует, что поток, работающий в реальном времени, не будет вытеснен перерисовками экрана. Но это не решит проблему исчерпания ресурсов процессора для других приложений, потому что операционная система все еще будет не пропорционально выделять время CPU процессу в целом. Идеальное решение - запустить рабочий поток реального времени и интерфейс пользователя как отдельные приложения с разными приоритетами процессов, и организовать их взаимодействие через класс Remoting или отображаемые на память файлы (memory-mapped files). Отображаемые на память файлы идеально подходят для этой задачи; как это работает рассматривается в главах 14 и 25 книжки "C# 4.0 in a Nutshell" [8].

Даже с повышенным приоритетом процесса есть некий лимит пригодности управляемой среды выполнения приложения к требованиям жесткого реального времени. Дополнительно к проблемам, связанным с задержками в системе автоматического сбора мусора, операционная система может доставить дополнительные проблемы - даже для не обслуживаемых приложений (unmanaged applications). По этой причине для систем и приложений RTOS лучше всего решать с помощью специально выделенной аппаратуры или специализированной платформы реального времени (real-time operating system, RTOS).

Обработка исключений (Exception Handling). Любые блоки try/catch/finally [9] при создании потока не имеют к нему отношения, когда он начинает свое выполнение. Давайте рассмотрим следующую программу:

public static void Main()
{
   try
   {
      new Thread (Go).Start();
   }
   catch (Exception ex)
   {
      // Сюда мы никогда не попадем!
      Console.WriteLine ("Exception!");
   }
}
 
static void Go() { throw null; }    // Выбросит исключение NullReferenceException

Оператор try/catch в этом примере не работает, и новый созданный поток столкнется с необработанным исключением NullReferenceException. Такое поведение целесообразно, когда Вы примете во внимание, что каждый поток имеет независимый путь вычислений при выполнении алгоритма.

Решением будет переместить обработчик исключения внутрь метода Go:

public static void Main()
{
   new Thread (Go).Start();
}
 
static void Go()
{
   try
   {
      // ...
      throw null;    // Исключение NullReferenceException будет обработано ниже
      // ...
   }
   catch (Exception ex)
   {
      // В этом месте обычно в лог записывается факт исключения, и/или дается
      // сигнал другому потоку, что мы прокололись.
      // ...
   }
}

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

При написании такого рода блоков обработки исключения обычно редко ошибка полностью игнорируется. Чаще всего Вы записываете в лог подробности возникновения исключения, и затем возможно показываете пользователю диалог с предложением передать эту информацию на Ваш веб-сервер для диагностики. Затем Вы может завершить приложение - потому что возникшая ошибка может повредить состояние приложения (какие-то его данные). Однако цена такого поведения может привести к тому, что пользователь потеряет свою последнюю выполненную работу - например открытые документы.

"Глобальная" обработка событий исключения для приложений WPF и Windows Forms (Application.DispatcherUnhandledException и Application.ThreadException) срабатывает только для исключений, выбрасываемых главным потоком UI. Вы все еще должны вручную обработать исключения в своих рабочих потоках. AppDomain.CurrentDomain.UnhandledException срабатывает только на любое не обработанное исключение, однако не предоставляет средств для предотвращения завершения приложения после возникновения такого исключения.

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

• Асинхронные делегаты (см. ниже)
• BackgroundWorker
Task Parallel Library (применяется к условиям)

[Объединение потоков в пул (Thread Pooling)]

Всякий раз, когда Вы запускаете поток, несколько сотен микросекунд тратятся на организацию таких вещей, как чистый приватный стек для локальных переменных потока. Каждый поток также потребляет (по умолчанию) около 1 мегабайта памяти. Пул потоков сокращает эти издержки путем совместного использования потоков и переработки потоков (recycling), позволяя многопоточности применяться очень гранулярно без потери производительности. Особенно это полезно на многоядерных процессорах, чтобы интенсивные вычисления выполнялись параллельно в стиле "разделяй и властвуй".

Пул потоков также сохраняет контроль над общим количеством одновременно работающих потоков. Слишком большое количество активных потоков замедляют операционную систему административным бременем и делает работу кэшей CPU не эффективной. При достижении предела задания стоят в очереди и запускаются, только когда другие задачи завершились. По такому принципу работают многие приложения наподобие веб-сервера. Метод асинхронной маски (asynchronous method pattern) это продвинутая техника, которая делает еще более эффективным применение пула потоков; это описывается в главе 23 книжки "C# 4.0 in a Nutshell" [8].

Есть несколько способов создать пул потоков:

• С помощью библиотеки параллельных вычислений [6] (Task Parallel Library из Framework 4.0)
• Вызовом ThreadPool.QueueUserWorkItem (см. ниже)
• Через асинхронный делегат (asynchronous delegates, см. ниже)
• Через BackgroundWorker [10]

Следующие конструкции косвенно используют пул потоков:

• WCF, Remoting, ASP.NET и серверы приложений ASMX Web Services
System.Timers.Timer и System.Threading.Timer
• Framework-методы, которые оканчиваются на Async, такие как WebClient (event-based asynchronous pattern) и большинство методов BeginXXX (asynchronous programming model pattern)

• PLINQ

Библиотеки Task Parallel Library (TPL) и PLINQ очень мощные и высокоуровневые, Вы захотите использовать их, чтобы помочь в реализации многопоточности, даже когда применение пула потока не важно. Это подробно обсуждается в части 5 [11] этой документации; сейчас мы кратко рассмотрим, как Вы можете использовать класс Task в качестве простого средства выполнения делегата на потоке из пула.

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

• Вы не можете установить имя Name для потока в пуле, что усложняет отладку (хотя Вы можете прикрепить описание к потоку, когда делаете отладку в среде Visual Studio. Просмотреть описание можно с помощью окна Threads).
• Потоки в пуле всегда работают как потоки background (см. выше, что такое foreground и background потоки). Обычно это не составляет никакой проблемы.
• Блокировка [12] потока в пуле может привести к дополнительной задержке по сравнению с обычным запуском потоков, кроме случаев, когда Вы вызываете ThreadPool.SetMinThreads (см. ниже "Оптимизация Thread Pool").

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

Через свойство Thread.CurrentThread.IsThreadPoolThread Вы можете опросить, выполняется ли текущий код на потоке из пула.

Вход в Thread Pool через TPL. Вы можете просто войти в пул потоков с использованием классов Task (см. Task Parallelism в [11]) из библиотеки TPL (Task Parallel Library). Классы Task были представлены в Framework 4.0: если Вы знакомы с более старыми конструкциями, рассмотрите замену nongeneric-класса Task для ThreadPool.QueueUserWorkItem, и замену generic-класса Task < TResult > для асинхронных делегатов. Новые конструкции работают быстрее, более удобные и гибкие, чем старые.

Для использования nongeneric-класса Task вызовите Task.Factory.StartNew с передачей делегата целевого метода:

static void Main()   // Класс Task в System.Threading.Tasks
{
   Task.Factory.StartNew (Go);
}
 
static void Go()
{
   Console.WriteLine ("Привет из пула потоков!");
}

Task.Factory.StartNew вернет объект Task, который Вы можете применять для мониторинга задачи - например, Вы можете ждать его завершения с помощью метода Wait.

Любые не обработанные исключения удобно перебрасываются потоку хоста, когда Вы вызываете метод Wait экземпляра класса Task (если Вы не вызвали Wait и вместо этого забросили эту задачу, не обработанное исключение остановит процесс приложения точно так же, как это происходит с обычным потоком).

Generic-класс Task < TResult > является подклассом nongeneric-класса Task. Это позволяет Вам вернуть обратно значение из задачи после её завершения. В следующем примере мы загружаем веб-страницу с помощью использования Task < TResult >:

static void Main()
{
   // Запуск выполнения задачи:
   Task< string> task = Task.Factory.StartNew< string>
      ( () => DownloadString ("http://www.linqpad.net") );
 
   // Мы здесь можем заняться другой работой, которая будет выполняться параллельно:
   RunSomeOtherMethod();
 
   // Когда нам нужно возвращенное значение из задачи, то опрашиваем её свойство Result:
   // если задаче все еще выполняется, текущий поток заблокируется (будет ждать)
   // до завершения работы задачи:
   string result = task.Result;
}
 
static string DownloadString (string uri)
{
   using (var wc = new System.Net.WebClient())
      return wc.DownloadString (uri);
}

Примечание: аргумент типа < string> подсвечен желтым для ясности, он выводится компилятором автоматически, если мы его опустили.

Любое не обработанное исключение автоматически перебрасывается, когда Вы опрашиваете свойство Result, обернутое в AggregateException. Однако если Вы не смогли опросить свойство Result (и не вызвали Wait), то любое не обработанное исключение завершит процесс приложения.

Библиотека TPL имеет многие другие функции, которые в частности полезны для задействования многоядерных процессоров. Продолжение обсуждения TPL приведено в части 5 [11] этой документации.

Вход в пул потоков без TPL. Вы не можете использовать TPL, если применяете более ранние версии .NET Framework (до версии 4.0). Вместо этого Вы должны применить старые конструкции для входа в пул потоков: ThreadPool.QueueUserWorkItem и асинхронные делегаты (asynchronous delegates). Разница между ними в том, что асинхронные делегаты позволяют Вам вернуть данные из потока. Асинхронные делегаты также перенаправляют любые исключения обратно в вызывающий код.

QueueUserWorkItem. Для использования QueueUserWorkItem просто вызовите этот метод с делегатом, который хотите запустить в пуле потоков:

static void Main()
{
   ThreadPool.QueueUserWorkItem (Go);
   ThreadPool.QueueUserWorkItem (Go, 123);
   Console.ReadLine();
}
 
static void Go (object data)     // в первом вызове данные будут null.
{
  Console.WriteLine ("Привет из пула потоков! " + data);
}

Результат работы этого примера:

Привет из пула потоков!
Привет из пула потоков! 123

Целевой метод Go должен принять один аргумент типа object (чтобы удовлетворить делегату WaitCallback). Это дает удобный способ передать данные в метод, наподобие ParameterizedThreadStart. В отличие от Task, QueueUserWorkItem не возвращает объект, чтобы помочь в последующем управлении выполнением кода. Также Вы должны разобраться с исключениями в целевом коде - не обработанные исключения прервут выполнение программы.

Асинхронные делегаты. ThreadPool.QueueUserWorkItem не предоставляет простой механизм для возврата значений из потока после того, как он завершил свою работу. Вовлечение асинхронного делегата решает эту проблему, позволяя передать в обоих направлениях любое количество типизованных аргументов. Кроме того, не обработанные исключения на асинхронном делегате удобно перебрасываются в оригинальный поток (или точнее потоку, который вызвал EndInvoke), и таким образом им не нужна явная обработка исключений.

Не путайте асинхронные делегаты с асинхронными методами (методами, начинающимися на Begin или End, такими как File.BeginRead/File.EndRead). Асинхронные методы снаружи следуют похожему протоколу, но они существуют, чтобы решить намного более сложную проблему, которая описана в главе 23 книжки "C# 4.0 in a Nutshell" [8].

Покажем, как запустить рабочую задачу (worker task) через асинхронный делегат:

1. Инстанцируете делегата, нацеленного на метод, который Вы хотите запустить параллельно (обычно один из предварительно определенных делегатов Func).
2. Вызовите BeginInvoke с делегатом, сохранив его возвращенное значение IAsyncResult. BeginInvoke немедленно вернет управление в вызывающий код. Вы можете затем выполнять другие действия, пока поток работает в пуле.
3. Когда Вам нужно получить результат, вызовите EndInvoke на делегате, переданном в сохраненном объекте IAsyncResult.

В следующем примере мы применяем вовлечение асинхронного делегата для конкурентного выполнения вместе с главным потоком простого метода, который вернет длину строки:

static void Main()
{
   Func< string, int> method = Work;
   IAsyncResult cookie = method.BeginInvoke ("test", null, null);
   //
   // ... здесь мы можем параллельно выполнять другую работу ...
   //
   int result = method.EndInvoke (cookie);
   Console.WriteLine ("Длина строки: " + result);
}
 
static int Work (string s) { return s.Length; }

EndInvoke делает три вещи. Во-первых, он ждет завершения выполнения асинхронного делегата, если этого еще не произошло. Во-вторых, он получает возвращенное значение(как и любые параметры ref или out). В-третьих, он выбрасывает любое не обработанное исключение работающего потока обратно в вызывающий поток.

Если метод, который Вы вызвали с асинхронным делегатом, не имеет возвращаемого значения, то Вы все еще (технически) обязаны вызвать EndInvoke. На практике этот момент открыт для дебатов; нет никакой специальной полиции EndInvoke, чтобы применить административные санкции к отсутствию вызова. Однако если Вы выбрали путь не вызывать EndInvoke, то следует рассмотреть обработку исключения в работающем методе делегата, чтобы избежать тихих отказов.

Вы также можете указать делегат функции обратного вызова (callback), когда вызываете BeginInvoke - метод принимает объект IAsyncResult, который будет автоматически вызван при завершении. Это позволяет инициирующему запуск задачи потоку "забыть" про асинхронный делегат, но это требует дополнительной работы в функции callback:

static void Main()
{
   Func< string, int> method = Work;
   method.BeginInvoke ("test", Done, method);
   // ...
   //
}
 
static int Work (string s) { return s.Length; }
 
static void Done (IAsyncResult cookie)
{
   var target = (Func< string, int>) cookie.AsyncState;
   int result = target.EndInvoke (cookie);
   Console.WriteLine ("Длина строки равна: " + result);
}

Оптимизация Thread Pool. Пул потоков стартует с одним потоком в этом пуле. При назначении задачи менеджер пула "инжектирует" новые потоки, чтобы справиться с дополнительной параллельно работающей полезной нагрузкой, пока не будет достигнут определенный максимальный предел. После значительного периода отсутствия активности менеджер пула может "удалять" потоки, если есть подозрение, что это приведет к улучшению производительности.

Вы можете установить верхний предел потоков, которые создаст пул, путем вызова ThreadPool.SetMaxThreads; значения по умолчанию следующие:

• 1023 для Framework 4.0, 32-bit environment
• 32768 для Framework 4.0, 64-bit environment
• 250 на ядро для Framework 3.5
• 25 на ядро для Framework 2.0

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

Также Вы можете задать нижний лимит путем вызова ThreadPool.SetMinThreads. Роль нижнего предела более тонкая: это продвинутая техника оптимизации, которая инструктирует менеджер пула не задерживать вовлечение потоков, пока не будет достигнут нижний предел. Повышение минимального количества потоков улучшает конкурентное выполнение, когда есть заблокированные потоки.

Нижний предел по умолчанию равен одному потоку на ядро процессора - такой минимум позволяет реализовать полную загрузку CPU. Однако в рабочем окружении сервера (таком как ASP.NET, работающем в IIS), нижний предел обычно намного выше - 50 или еще больше.

Повышение минимума количества потоков до x в действительности не создает принудительно x потоков, потоки все равно создаются только по запросу. Вместо этого повышение минимума инструктирует менеджер пула немедленно создать до x потоков, если это необходимо. Тогда встает вопрос - почему, если не повышать минимум, добавляется задержка при создании потоков, когда необходимо их создание?

Ответ должен воспрепятствовать ситуации, когда короткий, не долго выполняемый пакет вызовет полное выделение ресурсов для потоков внезапно увеличив потребление памяти приложением. Для иллюстрации рассмотрим четырехядерный компьютер, на котором работает клиентское приложение, которое сразу ставит в очередь 40 задач. Если каждая задача выполняет вычисление за 10 мс, то все вычисления будут завершены примерно за больше чем 100 мс, если предположить, что работа будет разделена между 4 ядрами. Идеально мы хотели бы запустить эти 40 задач точно в 4 потоках:

• Если потоков будет меньше, то не будут максимально использоваться все 4 ядра.
• Если больше, то будет зря тратиться память и время CPU на создание ненужных потоков.

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

Однако предположим теперь, что вместо выполнения работы за 10 мс каждая задача опрашивает сеть Интернет, ожидая полсекунды ответа, пока локальный CPU простаивает. Стратегия экономии для потоков у менеджера пула рушится; теперь уже лучше создать больше потоков, поскольку все запросы к Интернет могут происходить одновременно.

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

Полусекундная задержка это палка о двух концах. С одной стороны, это не приведет к тому, что одиночный пакет краткого действия внезапно заставит программу сразу выделить 40 мегабайт (или больше) памяти. С другой стороны, это вводит нежелательную задержку, когда блокируется поток из пула, как в случае запроса к базе данных или вызова WebClient.DownloadFile. По этой причине Вы можете указать менеджеру пула не выполнять задержку при выделении первых x потоков путем вызова SetMinThreads, например:

ThreadPool.SetMinThreads (50, 50);

Примечание: второе значение показывает, сколько потоков назначать на порты завершения (I/O completion ports), которые используются на платформе APM (описывается в главе 23 книжки "C# 4.0 in a Nutshell" [8].

Значение по умолчанию - один поток на ядро.

[Ссылки]

1. Threading in C# PART 1: GETTING STARTED site:albahari.com.
2. Ключевое слово static на языке C#.
3. Безопасность потоков (Thread Safety).
4. Блокировка (Locking).
5. The .NET Programmer’s Playground site:linqpad.net.
6. Класс Parallel.
7. Обмен сигналами через Wait и Pulse.
8. C# 7.0 in a Nutshell site:albahari.com.
9. Как лучше всего обрабатывать исключения на C#.
10. BackgroundWorker.
11. Потоки на C#. Часть 5: параллельное программирование.
12. Что такое блокировка.
13. Потоки на C#. Часть 2: основы синхронизации.

 

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


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

Top of Page