Работа с потоками в C#

Часть 1

Автор: Joseph Albahari
Перевод: Алексей Кирюшкин
Источники: Threading in C#
базируется на книге
Joseph Albahari Ben Albahari "C# 3.0 in a Nutshell"

Материал предоставил: RSDN Magazine #1-2007
Опубликовано: 24.03.2007
Версия текста: 1.0
Благодарности
1. Начало работы
Обзор и ключевые понятия
Создание и запуск потоков
2. Базовые сведения о синхронизации
Важнейшие средства синхронизации
Блокирование и потоковая безопасность
Interrupt и Abort
Состояния потока
Wait Handles
Контексты синхронизации

Благодарности

Хотел бы поблагодарить Бена Албахари (Ben Albahari) из Microsoft Corporation и Джона Осборна (John Osborn) из O'Reilly Media, Inc. за их ценный вклад, а также Эрика Беднарца (Eric Bednarz) за его (до сих пор безотказный!) обход position:fixed-бага Internet Explorer.

1. Начало работы

Обзор и ключевые понятия

C# поддерживает параллельное выполнение кода через многопоточность. Поток – это независимый путь исполнения, способный выполняться одновременно с другими потоками.

Программа на C# запускается как единственный поток, автоматически создаваемый CLR и операционной системой (“главный” поток), и становится многопоточной при помощи создания дополнительных потоков. Вот простой пример и его вывод:

ПРИМЕЧАНИЕ

Все примеры предполагают, что импортируются следующие пространства имен (если этот момент специально не оговаривается):

using System;

using System.Threading;

class ThreadTest 
{
  static void Main()
  {
    Thread t = new Thread(WriteY);
    t.Start();            // Выполнить WriteY в новом потоке
    while (true) 
      Console.Write("x"); // Все время печатать 'x'
  }
 
  static void WriteY() 
  {
    while (true) 
      Console.Write("y"); // Все время печатать 'y'
  }
}

Вывод:

xxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyy
yyyyyyyyyyyyyxxxxyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxx
xxxxxxxxxyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyx
xxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyyy
yyyyyyyyyyyyxxxxy...

В главном потоке создается новый поток t, исполняющий метод, который непрерывно печатает символ ‘y’. Одновременно главный поток непрерывно печатает символ ‘x’.

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 создается в стеке каждого потока, так что выводится, как и ожидалось, десять знаков ‘?’.

Вместе с тем потоки разделяют данные, относящиеся к тому же экземпляру объекта, что и сами потоки:

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:

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

Оба примера демонстрируют также другое ключевое понятие – потоковую безопасность (или скорее её отсутствие). Фактически результат исполнения программы не определен: возможно (хотя и маловероятно), "Done" будет напечатано дважды. Однако если мы поменяем порядок вызовов в методе Go(), шансы увидеть “Done” напечатанным два раза повышаются радикально:

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

Консольный вывод:

Done
Done (появляется в большинстве случаев!)

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

Лекарство состоит в получении эксклюзивной блокировки на время чтения и записи разделяемых полей. C# обеспечивает это при помощи оператора lock:

class ThreadSafe 
{
  static bool done;
  static object locker = new object();
 
  static void Main() 
  {
    new Thread(Go).Start();
    Go();
  }
 
  static void Go() 
  {
    lock (locker) 
    {
      if (!done)
      {
        Console.WriteLine("Done");
        done = true;
      }
    }
  }
}

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

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

Thread.Sleep(TimeSpan.FromSeconds(30)); // Блокировка на 30 секунд

Также поток может ожидать завершения другого потока, вызывая его метод Join:

Thread t = new Thread(Go);     // Go – статический метод
t.Start();
t.Join();                       // Ожидаем завершения потока

Будучи блокированным, поток не потребляет ресурсов CPU.

Как работает многопоточность

Управление многопоточностью осуществляет планировщик потоков, эту функцию CLR обычно делегирует операционной системе. Планировщик потоков гарантирует, что активным потокам выделяется соответствующее время на выполнение, а потоки, ожидающие или блокированные, к примеру, на ожидании эксклюзивной блокировки, или пользовательского ввода – не потребляют времени CPU.

На однопроцессорных компьютерах планировщик потоков использует квантование времени – быстрое переключение между выполнением каждого из активных потоков. Это приводит к непредсказуемому поведению, как в самом первом примере, где каждая последовательность символов ‘X’ и ‘Y’ соответствует кванту времени, выделенному потоку. В Windows XP типичное значение кванта времени – десятки миллисекунд – выбрано как намного большее, чем затраты CPU на переключение контекста между потоками (несколько микросекунд).

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

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

Потоки vs. процессы

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

В некоторых аспектах потоки и процессы схожи – например, время разделяется между процессами, исполняющимися на одном компьютере, так же, как между потоками одного C#-приложения. Ключевое различие состоит в том, что процессы полностью изолированы друг от друга. Потоки разделяют память (кучу) с другими потоками этого же приложения. Благодаря этому один поток может поставлять данные в фоновом режиме, а другой – показывать эти данные по мере их поступления.

Когда использовать потоки

Типовое приложение с многопоточностью выполняет длительные вычисления в фоновом режиме. Главный поток продолжает выполнение, в то время как рабочий поток выполняет фоновую задачу. В приложениях Windows Forms, когда главный поток занят длительными вычислениями, он не может обрабатывать сообщения клавиатуры и мыши, и приложение перестает откликаться. По этой причине следует запускать отнимающие много времени задачи в рабочем потоке, даже если главный поток в это время демонстрирует пользователю модальный диалог с надписью “Работаю... Пожалуйста, ждите”, так как программа не может перейти к следующей операции, пока не закончена текущая. Такое решение гарантирует, что приложение не будет помечено операционной системой как “Не отвечающее”, соблазняя пользователя с горя прикончить процесс. Опять же, в этом случае модальный диалог может предоставить кнопку “Отмена”, так как форма продолжает получать сообщения, пока задача выполняется в фоновом потоке. Класс BackgroundWorker наверняка пригодится вам при реализации такой модели.

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

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

C#-приложение можно сделать многопоточным двумя способами: либо явно создавая дополнительные потоки и управляя ими, либо используя возможности неявного создания потоков .NET Framework – BackgroundWorker, пул потоков, потоковый таймер, Remoting-сервер, Web-службы или приложение ASP.NET. В двух последних случаях альтернативы многопоточности не существует. Однопоточный web-сервер не просто плох, он попросту невозможен! К счастью, в случае серверов приложений, не хранящих состояние (stateless), многопоточность реализуется обычно довольно просто, сложности возможны разве что в синхронизации доступа к данным в статических переменных.

Когда потоки не нужны

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

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

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

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

public delegate void ThreadStart();

Вызов метода Start начинает выполнение потока. Поток продолжается до выхода из исполняемого метода. Вот пример, использующий полный синтаксис C# для создания делегата ThreadStart:

class ThreadTest 
{
  static void Main() 
  {
    Thread t = new Thread(new ThreadStart(Go));
    t.Start();   // Выполнить Go() в новом потоке.
    Go();        // Одновременно запустить Go() в главном потоке.
  }

  static void Go() { Console.WriteLine("hello!"); }

В этом примере поток выполняет метод Go() одновременно с главным потоком. Результат – два почти одновременных «hello»:

hello!
hello!

Поток можно создать, используя для присваивания значений делегатам более удобный сокращенный синтаксис C#:

static void Main() 
{
  Thread t = new Thread(Go); // Без явного использования ThreadStart
  t.Start();
  ...
}

static void Go() { ... }

В этом случае делегат ThreadStart выводится компилятором автоматически. Другой вариант сокращенного синтаксиса использует анонимный метод для создания потока:

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

Поток имеет свойство IsAlive, возвращающее true после вызова Start() и до завершения потока.

Поток, который закончил исполнение, не может быть начат заново.

Передача данных в ThreadStart

Допустим, что в рассматриваемом выше примере мы захотим более явно различать вывод каждого из потоков, например, по регистру символов. Можно добиться этого, передавая соответствующий флаг в метод Go(), но в этом случае нельзя использовать делегат ThreadStart, так он не принимает аргументов. К счастью, .NET Framework определяет другую версию делегата – ParameterizedThreadStart, которая может принимать один аргумент:

public delegate void ParameterizedThreadStart(object obj);

Предыдущий пример можно переписать так:

class ThreadTest 
{
  static void Main() 
  {
    Thread t = new Thread(Go);
    t.Start(true);             // == Go(true) 
    Go(false);
  }

  static void Go(object upperCase) 
  {
    bool upper = (bool)upperCase;
    Console.WriteLine(upper ? "HELLO!" : "hello!");
  }
}

Консольный вывод:

hello!
HELLO!

В этом примере компилятор автоматически выводит делегат ParameterizedThreadStart, так как метод Go() принимает в качестве параметра один object. С тем же успехом можно было написать:

Thread t = new Thread(new ParameterizedThreadStart(Go));
t.Start(true);

Особенность использования ParameterizedThreadStart состоит в том, что перед использованием нужно привести аргумент из типа object к нужному типу (в данном случае bool). К тому же существует только версия, принимающая единственный аргумент.

В качестве альтернативы можно использовать анонимный метод:

static void Main() 
{
  Thread t = new Thread(delegate(){ WriteText("Hello"); });
  t.Start();
}

static void WriteText(string text) { Console.WriteLine(text); }

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

static void Main() 
{
  string text = "Before";
  Thread t = new Thread(delegate() { WriteText(text); });
  text = "After";
  t.Start();
}

static void WriteText(string text) { Console.WriteLine(text); }

Консольный вывод:

After
ПРЕДУПРЕЖДЕНИЕ

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

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

class ThreadTest 
{
  bool upper;
 
  static void Main() 
  {
    ThreadTest instance1 = new ThreadTest();
    instance1.upper = true;
    Thread t = new Thread(instance1.Go);
    t.Start();
    ThreadTest instance2 = new ThreadTest();
    instance2.Go();  // Запуск в главном потоке - с upper=false
  }
 
  void Go(){ Console.WriteLine(upper ? "HELLO!" : "hello!"); }

Именование потоков

Поток можно поименовать, используя свойство Name. Это предоставляет большое удобство при отладке: имена потоков можно вывести в Console.WriteLine и увидеть в окне Debug – Threads в Microsoft Visual Studio. Имя потоку может быть назначено в любой момент, но только один раз – при попытке изменить его будет сгенерировано исключение.

Главному потоку приложения также можно назначить имя – в следующем примере доступ к главному потоку осуществляется через статическое свойство CurrentThread класса Thread:

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("Hello from " + Thread.CurrentThread.Name);
  }
}

Консольный вывод:

Hello from main
Hello from worker

Основные и фоновые потоки

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

ПРИМЕЧАНИЕ

Изменение статуса потока с основного на фоновый не изменяет его приоритет или статус в планировщике потоков.

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

class PriorityTest 
{
  static void Main(string[] args) 
  {
    Thread worker = new Thread(delegate() { Console.ReadLine(); });

    if (args.Length > 0)
      worker.IsBackground = true;

    worker.Start();
  }
}

Если программа вызывается без аргументов, рабочий поток выполняется по умолчанию как основной поток и ожидает на ReadLine, пока пользователь не нажмет Enter. Тем временем главный поток завершается, но приложение продолжает исполняться, так как рабочий поток еще жив.

Если же программу запустить с аргументами командной строки, рабочий поток получит статус фонового и программа завершится практически сразу после завершения главного потока, с уничтожением потока, ожидающего ввода пользователя с помощью метода ReadLine.

Когда фоновый поток завершается таким способом, все блоки finally внутри потока игнорируются. Поскольку невыполнение кода в finally обычно нежелательно, будет правильно ожидать завершения всех фоновых потоков перед выходом из программы, назначив нужный таймаут (при помощи Thread.Join). Если по каким-то причинам рабочий поток не завершается за выделенное время, можно попытаться аварийно завершить его (Thread.Abort), а если и это не получится, позволить умереть ему вместе с процессом (также не помешает записать информацию о проблеме в лог).

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

ПРЕДУПРЕЖДЕНИЕ

Обычная причина появления приложений, которые не могут завершиться должным образом – это такие “забытые” основные потоки.

Приоритеты потоков

Свойство Priority определяет, сколько времени на исполнение будет выделено потоку относительно других потоков того же процесса. Существует 5 градаций приоритета потока:

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

Значение приоритета становится существенным, когда одновременно исполняются несколько потоков.

Установка приоритета потока на максимум еще не означает работу в реальном времени (real-time), так как существуют еще приоритет процесса приложения. Чтобы работать в реальном времени, нужно использовать класс Process из пространства имен System.Diagnostics для поднятия приоритета процесса (если что, я вам этого не говорил):

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

От ProcessPriorityClass.High один шаг до наивысшего приоритета процесса – Realtime. Устанавливая приоритет процесса в Realtime, вы говорите операционной системе, что хотите, чтобы ваш процесс никогда не вытеснялся. Если ваша программа случайно попадет в бесконечный цикл, операционная система может быть полностью заблокирована. Спасти вас в этом случае сможет только кнопка выключения питания. По этой причине ProcessPriorityClass.High считается максимальным приоритетом процесса, пригодным к употреблению.

Если real-time приложение имеет пользовательский интерфейс, может быть не желательно поднимать приоритет его процесса, так как обновление экрана будет съедать чересчур много времени CPU – тормозя весь компьютер, особенно если UI достаточно сложный. (Хотя, когда я это пишу, программа интернет-телефонии Skype не дает такого эффекта, может быть, потому, что ее интерфейс достаточно прост.) Уменьшение приоритета главного потока в сочетании с повышением приоритета процесса гарантирует, что real-time поток не будет вытесняться перерисовкой экрана, но не спасает от тормозов весь компьютер, так как операционная система все еще будет выделять много времени CPU всему процессу в целом. Идеальное решение состоит в том, чтобы держать работу в реальном времени и пользовательский интерфейс в различных процессах (с разными приоритетами), поддерживающих связь через Remoting или shared memory. Разделяемая память требует обращения к Win32 API (погуглите про CreateFileMapping и MapViewOfFile).

Обработка исключений

Обрамление кода создания и запуска потока блоками try/catch/finally имеет мало смысла. Посмотрите следующий пример:

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

try/catch здесь фактически совершенно бесполезны, и NullReferenceException во вновь созданном потоке обработано не будет. Вы поймете почему, если вспомните, что поток имеет свой независимый путь исполнения. Решение состоит в добавлении обработки исключений непосредственно в метод потока:

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

Начиная с .NET 2.0, необработанное исключение в любом потоке приводит к закрытию всего приложения, а значит игнорирование исключений – это не наш метод. Следовательно, блок try/catch необходим в каждом методе потока – по крайней мере, в приложениях не для собственного употребления – чтобы избежать закрытия приложения из-за необработанного исключения. Это может быть довольно обременительно, особенно для программистов Windows Forms, которые используют глобальный перехватчик исключений, как показано ниже:

using System;
using System.Threading;
using System.Windows.Forms;
 
static class Program 
{
  static void Main() 
  {
    Application.ThreadException += HandleError;
    Application.Run(new MainForm());
  }
 
  static void HandleError(object sender, ThreadExceptionEventArgs e) 
  {
    Логирование исключения, завершение или продолжение работы
  }
}

Событие Application.ThreadException возникает, когда исключение генерируется в коде, который был вызван (возможно, по цепочке) из обработчика сообщения Windows (например, от клавиатуры, мыши и т.д.) – короче говоря, практически из любого кода приложения Windows Forms. Поскольку это замечательно работает, появляется чувство ложной безопасности, - что все исключения будут обработаны этим центральным обработчиком. Исключения, возникающие в рабочих потоках – хороший пример исключений, которые не ловятся в Application.ThreadException (код в методе Main – другой такой пример, включая конструктор MainForm, отрабатывающий до запуска цикла обработки сообщений).

.NET Framework предоставляет низкоуровневое событие для глобальной обработки исключений - AppDomain.UnhandledException. Это событие происходит, когда есть необработанное исключение в любом потоке и для любых типов приложений (с пользовательским интерфейсом или без него). Однако, хотя это и хороший способ регистрации необработанных исключений, он не предоставляет никакого способа предотвратить закрытие приложения или подавить сообщение .NET о необработанном исключении.

Итак, явная обработка исключений требуется во всех потоковых методах. Упростить работу можно, используя классы-обертки, например, BackgroundWorker (рассматриваемый в 3-й части).

2. Базовые сведения о синхронизации

Важнейшие средства синхронизации

В следующих таблицах приведена информация об инструментах .NET для координации (синхронизации) потоков:

КонструкцияНазначение
SleepБлокировка на указанное время
JoinОжидание окончания другого потока
Простейшие методы блокировки
КонструкцияНазначениеДоступна из других процессов?Скорость
lockГарантирует, что только один поток может получить доступ к ресурсу или секции кода.нетбыстро
MutexГарантирует, что только один поток может получить доступ к ресурсу или секции кода. Может использоваться для предотвращения запуска нескольких экземпляров приложения.дасредне
SemaphoreГарантирует, что не более заданного числа потоков может получить доступ к ресурсу или секции кода.дасредне
Блокировочные конструкции

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

КонструкцияНазначениеДоступна из других процессов?Скорость
EventWaitHandleПозволяет потоку ожидать сигнала от другого потока.дасредне
Wait and Pulse*Позволяет потоку ожидать, пока не выполнится заданное условие блокировки.нетсредне
Сигнальные конструкции
КонструкцияНазначениеДоступна из других процессов?Скорость
Interlocked*Выполнение простых не блокирующих атомарных операций.Да – через разделяемую памятьочень быстро
volatile*Для безопасного не блокирующего доступа к полям.Да – через разделяемую памятьочень быстро
Не блокирующие конструкции синхронизации

* - Рассматриваются в части 4

Блокировка

Когда поток остановлен в результате использования конструкций, перечисленных в вышеприведенных таблицах, говорят, что он блокирован. Будучи блокированным, поток немедленно перестает получать время CPU, устанавливает свойство ThreadState в WaitSleepJoin и остается в таком состоянии, пока не разблокируется. Разблокировка может произойти в следующих четырех случаях (кнопка выключения питания не считается!):

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

Sleeping и Spinning

Вызов Thread.Sleep блокирует текущий поток на указанное время (либо до прерывания):

static void Main()
{
  Thread.Sleep(0);   // отказаться от одного кванта времени CPU
  Thread.Sleep(1000);                   // заснуть на 1000 миллисекунд
  Thread.Sleep(TimeSpan.FromHours(1));  // заснуть на 1 час
  Thread.Sleep(Timeout.Infinite);       // заснуть до прерывания
}

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

Уникальность Thread.Sleep среди других методов блокировки в том, что он приостанавливает прокачку сообщений Windows в приложениях Windows Forms или COM-окружении потока в однопоточном апартаменте. Из-за этого продолжительная блокировка главного (UI) потока приложения Windows Forms приводит к тому что приложение перестает откликаться – и следовательно, использования Thread.Sleep нужно избегать независимо от того, действительно ли прокачка очереди сообщений технически приостановлена. В старой COM-среде ситуация сложнее, там иногда может быть желательна блокировка при помощи Sleep с одновременной прокачкой очереди сообщений. Крис Брумм (Chris Brumme) из Microsoft подробно обсуждает это в своем блоге.

Класс Thread также предоставляет метод SpinWait, который не отказывается от времени CPU, а наоборот, загружает процессор в цикле на заданное количество итераций. 50 итераций эквивалентны паузе примерно в микросекунду, хотя это зависит от скорости и загрузки CPU. Технически SpinWait – не блокирующий метод: ThreadState такого потока не устанавливается в WaitSleepJoin, и поток не может быть прерван из другого потока. SpinWait редко используется – его главное применение это ожидание ресурса, который должен освободится очень скоро (в течении микросекунд) без вызова Sleep и траты процессорного времени на переключение потока. Однако эта методика выгодна только на многопроцессорных компьютерах, на однопроцессорном компьютере у ресурса нет никакого шанса освободиться, пока ожидающий на SpinWait поток не растратит остаток кванта времени, а значит, требуемый результат недостижим изначально. А частые или продолжительные вызовы SpinWait впустую растрачивает время CPU.

Блокирование против ожидания в цикле

Поток может ожидать выполнения некоторого условия, непосредственно прокручивая цикл проверки, например:

while (!proceed)
  ;

или:

while (DateTime.Now < nextStartTime)
  ;

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

Иногда используется гибрид блокирования и ожидания в цикле:

while (!proceed)
  Thread.Sleep(x);    // "Spin-Sleeping!"

Чем больше x, тем эффективнее используется CPU. Платой за компромисс становится увеличение латентности. При превышении 20 мс накладные расходы незначительны – если условие в while не особенно сложное.

За исключением незначительной задержки эта комбинация блокирования и периодических опросов может работать весьма неплохо (вопросы параллельного доступа к флагу proceed рассматриваются в 4-й части). Возможно, самое частое её использование – когда программист уже потерял надежду запустить в работу более продвинутые сигнальные конструкции!

Ожидание завершения потока

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

class JoinDemo 
{
  static void Main() 
  {
    Thread t = new Thread(delegate() { Console.ReadLine(); });
    t.Start();
    t.Join();    // ожидать, пока поток не завершится
    Console.WriteLine("Thread t's ReadLine complete!");
  }
}

Метод Join может также принимать в качестве аргумента timeout - в миллисекундах или как TimeSpan. Если указанное время истекло, а поток не завершился, Join возвращает false. Join с timeout функционирует как Sleep – фактически следующие две строки кода приводят к одинаковому результату:

Thread.Sleep(1000);
Thread.CurrentThread.Join(1000);

(Отличие заметно только в однопоточных COM-апартаментах, и состоит в отношении к прокачке очереди сообщений Windows: Join не затрагивает прокачку сообщений, а Sleep ее приостанавливает.)

Блокирование и потоковая безопасность

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

class ThreadUnsafe 
{
  static int val1, val2;
 
  static void Go() 
  {
    if (val2 != 0)
      Console.WriteLine(val1 / val2);
    val2 = 0;
  }
}

Он не является потокобезопасным: если бы метод Go вызывался двумя потоками одновременно, можно было бы получить ошибку деления на 0, так как переменная val2 могла быть установлена в 0 в одном потоке, в то время когда другой поток находился бы между if и Console.WriteLine.

Вот как при помощи блокировки можно решить эту проблему:

class ThreadSafe 
{
  static object locker = new object();
  static int val1, val2;
 
  static void Go() 
  {
    lock (locker) 
    {
      if (val2 != 0)
        Console.WriteLine(val1 / val2);

      val2 = 0;
    }
  }
}

Только один поток может единовременно заблокировать объект синхронизации (в данном случае locker), а все другие конкурирующие потоки будут приостановлены, пока блокировка не будет снята. Если за блокировку борются несколько потоков, они ставятся в очередь ожидания – "ready queue" – и обслуживаются, как только это становится возможным, по принципу “первым пришел – первым обслужен”. Эксклюзивная блокировка, как уже говорилось, обеспечивает последовательный доступ к тому, что она защищает, так что выполняемые потоки уже не могут наложиться друг на друга. В данном случае мы защитили логику внутри метода Go, так же, как и поля val1 и val2.

Поток, заблокированный на время ожидания освобождения блокировки, имеет свойство ThreadState, установленное в WaitSleepJoin. Позже мы обсудим, как поток, заблокированный в таком состоянии, может быть принудительно освобожден из другого потока вызовом методов Interrupt или Abort. Это достаточно мощная возможность, используемая обычно для завершения рабочего потока.

Оператор lock языка C# фактически является синтаксическим сокращением для вызовов методов Monitor.Enter и Monitor.Exit в рамках блоков try-finally. Вот во что фактически разворачивается реализация метода Go из предыдущего примера:

Monitor.Enter(locker);
try 
{
  if (val2 != 0)
    Console.WriteLine(val1 / val2);

  val2 = 0;
}

finally { Monitor.Exit(locker); }  

Вызов Monitor.Exit без предшествующего вызова Monitor.Enter для того же объекта синхронизации вызовет исключение.

Monitor также предоставляет метод TryEnter, позволяющий задать время ожидания в миллисекундах или в виде TimeSpan. Метод возвращает true, если блокировка была получена, и false, если блокировка не была получена за заданное время. TryEnter может также быть вызван без параметров и в этом случае возвращает управление немедленно.

Выбор объекта синхронизации

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

class ThreadSafe() 
{
  List <string> list = new List <string>();
 
  void Test() 
  {
    lock (list) 
    {
      list.Add("Item 1");
      ...

Обычно используется выделенное поле (как locker в предыдущих примерах), так как это позволяет точнее контролировать область видимости и степень детализации блокировки. Использование объекта или типа в качестве объекта синхронизации, то есть:

lock (this)
{
  ...
}

или:

lock (typeof(Widget)) // Для защиты статических данных
{
  ...
}

не одобряется, так как предполагает public-область видимости объекта синхронизации.

Блокировка не запрещает вообще любой доступ к объекту. Другими словами, вызов x.ToString() не будет заблокирован из-за того, что другой поток вызвал lock(x) – чтобы произошла блокировка, оба потока должны вызвать lock(x).

Вложенные блокировки

Поток может неоднократно блокировать один и тот же объект многократными вызовами Monitor.Enter или вложенными lock-ами. Объект будет освобожден, когда будет выполнено соответствующее количество раз Monitor.Exit или произойдет выход из самой внешней конструкции lock. Поэтому допустима естественная семантика, когда один метод вызывает другой следующим образом:

static object x = new object();
 
static void Main() 
{
  lock (x) 
  {
     Console.WriteLine(“I have the lock”);
     Nest();
     Console.WriteLine(“I still have the lock”);
  } // Здесь блокировка будет снята!
}
 
static void Nest() 
{
  lock (x) 
  {
    ... 
  } 

  //Блокировка еще не снята!
}

Поток может быть блокирован только на самом первом, внешнем lock-е.

Когда блокировать

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

class ThreadUnsafe 
{
  static int x;
  static void Increment() { x++; }
  static void Assign()    { x = 123; }
}

А вот их потокобезопасные варианты:

class ThreadUnsafe 
{
  static object locker = new object();
  static int x;
 
  static void Increment()
  {
    lock (locker)
      x++;
  }

  static void Assign()
  {
    lock (locker)
      x = 123;
  }
}

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

Блокировки и атомарность

Если группа переменных всегда читается и записывается в пределах одной блокировки, можно сказать, что переменные читаются и пишутся атомарно. Предположим, что поля x и y всегда читаются и пишутся с блокировкой на объекте locker:

lock (locker)
{
  if (x != 0)
    y /= x;
}

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

Соображения о производительности

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

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

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

Потоковая безопасность

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

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

Поэтому потокобезопасность реализуется обычно только там, где она действительно требуется в многопоточном сценарии.

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

ПРЕДУПРЕЖДЕНИЕ

Исключая примитивные типы, очень немногие типы .NET Framework безопасны для чего-то большего, чем доступ только для чтения. Потокобезопасноть обеспечивает разработчик – обычно используя эксклюзивные блокировки.

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

Потокобезопасность и типы .NET Framework

Для преобразования кода в потокобезопасный можно использовать блокировки. Хороший пример - почти все непримитивные типы .NET Framework непотокобезопасны, и все же они могут использоваться в многопоточном коде, если любой доступ к любому объекту защищен блокировкой. Вот пример, в котором два потока одновременно добавляют элементы в один и тот же список, а затем перечисляют все элементы списка:

class ThreadSafe() 
{
  static List <string> list = new List <string>();
 
  static void Main() 
  {
    new Thread(AddItems).Start();
    new Thread(AddItems).Start();
  }
 
  static void AddItems() 
  {
    for (int i = 0; i < 100; i++)
      lock (list)
        Add("Item " + list.Count);
 
    string[] items;

    lock (list)
      items = list.ToArray();

    foreach (string s in items)
      Console.WriteLine(s);
  }
}

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

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

А вот интересный вопрос: если бы класс List был полностью потокобезопасным, что это изменило бы? Потенциально очень немногое! Для примера рассмотрим добавление элемента к нашему гипотетическому потокобезопасному списку:

if (!myList.Contains(newItem)) myList.Add(newItem);

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

myList.Clear();

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

Этот момент может быть спорным при написании заказных компонентов – зачем нужна встроенная потокобезопасность, если она, скорее всего, окажется избыточной?

Есть и контраргумент: внешняя блокировка объекта работает, только если все конкурирующие потоки знают о ее необходимости и используют ее – а это может быть не так при широком использовании объекта. Хуже всего дела обстоят со статическими полями в публичных типах. Для примера представьте, что статическое свойство структуры DateTimeDateTime.Now – непотокобезопасное, и два параллельных запроса могут привести к неправильным результатам или исключению. Единственная возможность исправить положение с использованием внешней блокировки - lock(typeof(DateTime)) при каждом обращении к DateTime.Now – сработала бы, если бы все программисты согласились делать так и только так. Но это вряд ли возможно, потому что многие считают блокировку типа Плохой Штукой.

По этой причине статические поля структуры DateTime гарантированно потокобезопасны. Это обычное поведение типов в .NET Framework – статические члены потокобезопасны, нестатические – нет. Так же следует проектировать и собственные типы – во избежание неразрешимых загадок потокобезопасности.

СОВЕТ

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

Interrupt и Abort

Заблокированный поток может быть преждевременно разблокирован двумя путями:

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

Interrupt

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

class Program 
{
  static void Main() 
  {
    Thread t = new Thread(delegate() 
      {
        try 
        {
          Thread.Sleep(Timeout.Infinite);
        }
        catch(ThreadInterruptedException) 
        {
          Console.Write("Forcibly ");
        }

        Console.WriteLine("Woken!");
      });
 
    t.Start();
    t.Interrupt();
  }
}

Консольный вывод:

Forcibly Woken!

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

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

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

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

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

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

Abort

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

Большое отличие между Interrupt и Abort состоит в том, что происходит, если их вызвать для неблокированного потока. Если Interrupt ничего не делает, пока поток не дойдет до следующей блокировки, то Abort генерирует исключение непосредственно в том месте, где сейчас находится поток – может быть, даже не в вашем коде. Аварийное завершение неблокированного потока может иметь существенные последствия, которые подробнее рассматриваются ниже, в разделе "Аварийное завершение потоков".

Состояния потока


Рисунок 1: Диаграмма состояний потока

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

В результате ThreadState – битовая комбинация из членов каждого уровня! Вот примеры ThreadState:

Перечисление также имеет два члена, которые никогда не используются в текущей реализации CLR: StopRequested и Aborted.

И это еще не все. ThreadState.Running имеет значение 0, так что следующее выражение работать не будет:

if ((t.ThreadState & ThreadState.Running) > 0) ...

и вместо этого нужно проверять наличие ThreadState.Running путем исключения или в качестве альтернативы, использовать свойство IsAlive. Свойство IsAlive, однако, может вам не подойти, так как возвращает true, если поток блокирован или приостановлен (false возвращается только до того, как поток начался, и после того, как он завершится).

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

public static ThreadState SimpleThreadState(ThreadState ts)
{
  return ts & ThreadState.Aborted & ThreadState.AbortRequested
            & ThreadState.Stopped & ThreadState.Unstarted
            & ThreadState.WaitSleepJoin;
}

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

Wait Handles

Оператор lock (aka Monitor.Enter/Monitor.Exit) – один из примеров конструкций синхронизации потоков. Lock является самым подходящим средством для организации монопольного доступа к ресурсу или секции кода, но есть задачи синхронизации (типа подачи сигнала начала работы ожидающему потоку), для которых lock будет не самым адекватным и удобным средством.

В Win32 API имеется богатый набор конструкций синхронизации, и они доступны в .NET Framework в виде классов EventWaitHandle, Mutex и Semaphore. Некоторые из них практичнее других: Mutex, например, по большей части дублирует возможности lock, в то время как EventWaitHandle предоставляет уникальные возможности сигнализации.

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

EventWaitHandle имеет два производных класса – AutoResetEvent и ManualResetEvent (не имеющие никакого отношения к событиям и делегатам C#). Обоим классам доступны все функциональные возможности базового класса, единственное отличие состоит в вызове конструктора базового класса с разными параметрами.

В части производительности, все WaitHandle обычно исполняются в районе нескольких микросекунд. Это редко имеет значение с учетом контекста, в котором они применяются.

AutoResetEvent – наиболее часто используемый WaitHandle-класс и основная конструкция синхронизации, наряду с lock.

AutoResetEvent

AutoResetEvent очень похож на турникет – один билет позволяет пройти одному человеку. Приставка “auto” в названии относится к тому факту, что открытый турникет автоматически закрывается или “сбрасывается” после того, как позволяет кому-нибудь пройти. Поток блокируется у турникета вызовом WaitOne (ждать (wait) у данного (one) турникета, пока он не откроется), а билет вставляется вызовом метода Set. Если несколько потоков вызывают WaitOne, за турникетом образуется очередь. Билет может “вставить” любой поток – другими словами, любой (неблокированный) поток, имеющий доступ к объекту AutoResetEvent, может вызвать Set, чтобы пропустить один блокированный поток.

Если Set вызывается, когда нет ожидающих потоков, хэндл будет находиться в открытом состоянии, пока какой-нибудь поток не вызовет WaitOne. Эта особенность помогает избежать гонок между потоком, подходящим к турникету, и потоком, вставляющим билет (“опа, билет вставлен на микросекунду раньше, очень жаль, но вам придется подождать еще сколько-нибудь!”). Однако многократный вызов Set для свободного турникета не разрешает пропустить за раз целую толпу – сможет пройти только один человек, все остальные билеты будут потрачены впустую.

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

Метод Reset обеспечивает закрытие открытого турникета, безо всяких ожиданий и блокировок.

AutoResetEvent может быть создан двумя путями. Во-первых, с помощью своего конструктора:

EventWaitHandle wh = new AutoResetEvent(false);

Если аргумент конструктора true, метод Set будет вызван автоматически сразу после создания объекта.

Другой метод состоит в создании объекта базового класса, EventWaitHandle:

EventWaitHandle wh = new EventWaitHandle(false, EventResetMode.Auto);

Конструктор EventWaitHandle также может использоваться для создания объекта ManualResetEvent (если задать в качестве параметра EventResetMode.Manual).

Метод Close нужно вызывать сразу же, как только WaitHandle станет не нужен – для освобождения ресурсов операционной системы. Однако если WaitHandle используется на протяжении всей жизни приложения (как в большинстве примеров этого раздела), этот шаг можно опустить, так как он будет выполнен автоматически при разрушении домена приложения.

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

class BasicWaitHandle 
{
  static EventWaitHandle wh = new AutoResetEvent(false);
 
  static void Main() 
  {
    new Thread(Waiter).Start();
    Thread.Sleep(1000);                 // Подождать некоторое время...
    wh.Set();                            // OK – можно разбудить
  }

  static void Waiter() 
  {
    Console.WriteLine("Ожидание...");
    wh.WaitOne();                        // Ожидать сигнала
    Console.WriteLine("Получили сигнал");
  }
}

Консольный вывод:

Ожидание... (пауза) Получили сигнал

Создание межпроцессных EventWaitHandle

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

EventWaitHandle wh = new EventWaitHandle(false, EventResetMode.Auto,
  "MyCompany.MyApp.SomeName");

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

Получите и распишитесь

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

Однако нужно решить что делать, если рабочий поток еще занят исполнением предыдущей задачи, а уже появилась следующая. Можно, например, блокировать исполнение, пока не завершена предыдущая задача. Это можно реализовать, используя два объекта типа AutoResetEvent – ready, который открывается (устанавливается путем вызова метода Set) рабочим потоком, когда он готов к работе, и go, который открывается вызывающим потоком, когда появляется новая задача. В следующем примере для демонстрации задачи используется простое строковое поле (объявленное с ключевым словом volatile для гарантии того, что оба потока будут видеть его в одном и том же состоянии):

class AcknowledgedWaitHandle 
{
  static EventWaitHandle ready = new AutoResetEvent(false);
  static EventWaitHandle go = new AutoResetEvent(false);
  static volatile string task;
 
  static void Main() 
  {
    new Thread(Work).Start();
 
    // Сигнализируем рабочему потоку 5 раз
    for (int i = 1; i <= 5; i++) 
    {
      ready.WaitOne();  // Сначала ждем, когда рабочий поток будет готов
      task = "a".PadRight(i, 'h'); // Назначаем задачу
      go.Set();         // Говорим рабочему потоку, что можно начинать
    }
 
    // Сообщаем о необходимости завершения рабочего потока,
    // используя null-строку
    ready.WaitOne();
    task = null;
    go.Set();
  }
 
  static void Work() 
  {
    while (true) 
    {
      ready.Set();                  // Сообщаем о готовности
      go.WaitOne();                 // Ожидаем сигнала начать...

      if (task == null)
        return;                     // Элегантно завершаемся

      Console.WriteLine(task);
    }
  }
}

Консольный вывод:

ah
ahh
ahhh
ahhhh

Обратите внимание, что для завершения рабочего потока используется задача со значением null. Для рабочего потока в данном случае успешно можно было бы использовать вызов Interrupt или Abort – но только сразу после ready.WaitOne, так как в этом случае нам известно состояние рабочего потока – непосредственно перед go.WaitOne или на этом вызове – и можно избежать осложнений при прерывании неизвестного кода. Использование Interrupt или Abort также потребовало бы добавить обработку исключений в рабочем потоке.

Очередь Поставщик/Потребитель (Producer/Consumer)

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

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

В следующем примере единственный AutoResetEvent используется для сигнализации рабочему потоку, который приостанавливается, только если ему больше нечего исполнять (очередь пуста). Для очереди используется Queue<>, доступ к ней защищен блокировкой для обеспечения потоковой безопасности. Рабочий поток завершается, если встречает null-задачу:

using System;
using System.Threading;
using System.Collections.Generic;
 
class ProducerConsumerQueue : IDisposable 
{
  EventWaitHandle wh = new AutoResetEvent(false);
  Thread worker;
  object locker = new object();
  Queue<string> tasks = new Queue<string>();
 
  public ProducerConsumerQueue() 
  {
    worker = new Thread(Work);
    worker.Start();
  }
 
  public void EnqueueTask(string task)
  {
    lock (locker)
      tasks.Enqueue(task);

    wh.Set();
  }
 
  public void Dispose() 
  {
    EnqueueTask(null);      // Сигнал Потребителю на завершение
    worker.Join();          // Ожидание завершения Потребителя
    wh.Close();             // Освобождение ресурсов
  }
 
  void Work() 
  {
    while (true) 
    {
      string task = null;

      lock (locker)
      {
        if (tasks.Count > 0) 
        {
          task = tasks.Dequeue();
          if (task == null)
            return;
        }
      }

      if (task != null) 
      {
        Console.WriteLine("Выполняется задача: " + task);
        Thread.Sleep(1000); // симуляция работы...
      }
      else
        wh.WaitOne();       // Больше задач нет, ждем сигнала...
    }
  }
}

А это код тестирования очереди:

class Test 
{
  static void Main() 
  {
    using(ProducerConsumerQueue q = new ProducerConsumerQueue()) 
    {
      q.EnqueueTask("Привет!");

      for (int i = 0; i < 10; i++)
        q.EnqueueTask("Сообщение " + i);

      q.EnqueueTask("Пока!");
    }
    // Выход из using приводит к вызову Dispose, который ставит
    // в очередь null-задачу и ожидает, пока Потребитель не завершится.
  }
}

Консольный вывод:

Выполняется задача: Привет!
Выполняется задача: Сообщение 0
Выполняется задача: Сообщение 1
Выполняется задача: Сообщение 2
Выполняется задача: Сообщение 3
Выполняется задача: Сообщение 4
Выполняется задача: Сообщение 5
Выполняется задача: Сообщение 6
Выполняется задача: Сообщение 7
Выполняется задача: Сообщение 8
Выполняется задача: Сообщение 9
Выполняется задача: Пока!

Обратите внимание, что WaitHandle явно закрывается, когда для ProducerConsumerQueue вызывается Dispose(), так как в течении жизни приложения возможно создание и разрушение многих объектов типа ProducerConsumerQueue.

ManualResetEvent

ManualResetEvent – это разновидность AutoResetEvent. Отличие состоит в том, что он не сбрасывается автоматически, после того как поток проходит через WaitOne, и действует как шлагбаум – Set открывает его, позволяя пройти любому количеству потоков, вызвавших WaitOne. Reset закрывает шлагбаум, потенциально накапливая очередь ожидающих следующего открытия.

Эту функциональность можно эмулировать при помощи булевой переменной "gateOpen" (объявленной как volatile) в комбинации со "spin-sleeping" – повторением проверок флага и ожидания в течении короткого промежутка времени.

ManualResetEvent может использоваться для сигнализации о завершении какой-либо операции или инициализации потока и готовности к выполнению работы.

Mutex

Мьютекс обеспечивает те же самые функциональные возможности, что и оператор lock в C#, что делает его не очень востребованным. Единственное преимущество состоит в том, что Mutex доступен из разных процессов, обеспечивая блокировку на уровне компьютера, в отличии от оператора lock, который действует только на уровне приложения.

Mutex относительно быстр, но lock быстрее в сотни раз. Получение мьютекса занимает несколько микросекунд, вызов lock – десятки наносекунд (если не происходит собственно блокировки).

Метод WaitOne для Mutex получает исключительную блокировку, блокируя поток, если это необходимо. Исключительная блокировка может быть снята вызовом метода ReleaseMutex. Точно также как оператор lock в C#, Mutex может быть освобожден только из того же потока, что его захватил.

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

class OneAtATimePlease 
{
  // Используем уникальное имя приложения,
  // например, с добавлением имени компании
  static Mutex mutex = new Mutex(false, "oreilly.com OneAtATimeDemo");
  
  static void Main() 
  {
    // Ожидаем получения мьютекса 5 сек – если уже есть запущенный
    // экземпляр приложения - завершаемся.
    if (!mutex.WaitOne(TimeSpan.FromSeconds(5), false)) 
    {
      Console.WriteLine("В системе запущен другой экземпляр программы!");
      return;
    }

    try 
    {
      Console.WriteLine("Работаем - нажмите Enter для выхода...");
      Console.ReadLine();
    }
    finally { mutex.ReleaseMutex(); }
  }
}

Полезное свойство Mutex-а – если приложение завершается без вызова ReleaseMutex, CLR освобождает мьютекс автоматически.

Semaphore

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

Semaphore с емкостью, равной единице, подобен Mutex или lock, за исключением того, что он не имеет потока-хозяина. Любой поток может вызвать Release для Semaphore, в то время как в случае с Mutex или lock только поток, захвативший ресурс, может его освободить.

В следующем примере по очереди запускаются десять потоков, выполняющих вызов Sleep. Semaphore гарантирует, что не более трех потоков могут вызвать Sleep одновременно:

class SemaphoreTest 
{
  static Semaphore s = new Semaphore(3, 3);  // Available=3; Capacity=3
 
  static void Main() 
  {
    for (int i = 0; i < 10; i++)
      new Thread(Go).Start();
  }
 
  static void Go() 
  {
    while (true) 
    {
      s.WaitOne();
      // Только 3 потока могут находиться здесь одновременно
      Thread.Sleep(100);
      s.Release();
    }
  }
}

WaitAny, WaitAll и SignalAndWait

Кроме Set и WaitOne, есть еще несколько статических методов класса WaitHandle для более крепких орешков синхронизации.

Методы WaitAny, WaitAll и SignalAndWait облегчают взаимодействие нескольких WaitHandle, возможно разных типов.

SignalAndWait, возможно, самый полезный метод – он в рамках единой атомарной операции вызывает WaitOne для одного WaitHandle, и Set – для другого.

Классическим вариантом использования этого метода является использование с парой EventWaitHandle для подготовки встречи двух потоков в нужной точке в нужное время. Подойдут и AutoResetEvent и ManualResetEvent. Первый поток делает следующее:

WaitHandle.SignalAndWait(wh1, wh2);

в то время как второй поток – наоборот:

WaitHandle.SignalAndWait(wh2, wh1);

WaitHandle.WaitAny ожидает освобождения одного (любого) WaitHandle из переданного ему списка, WaitHandle.WaitAll ожидает освобождения сразу всех переданных ему WaitHandle. Используя аналогию с турникетом метро, эти методы организуют общую очередь одновременно для всех турникетов – с прохождением через первый открывшийся турникет (WaitAny) или с ожиданием, пока они не откроются все (WaitAll).

На самом деле, значение WaitAll сомнительно из-за странной связи с потоковыми апартаментами, унаследованными от COM-архитектуры. WaitAll требует, чтобы вызывающий поток находился в многопоточном апартаменте, в то время как эта модель может быть наименее подходящей, особенно для приложений Windows Forms, которые должны выполнять столь мирские задачи, как взаимодействие с буфером обмена.

К счастью, .NET Framework обеспечивает более продвинутый сигнальный механизм для случаев, когда WaitHandle являются неудобными или неподходящими – Monitor.Wait и Monitor.Pulse.

Контексты синхронизации

Вместо ручной блокировки можно осуществлять блокировки декларативно. Используя наследование от ContextBoundObject и применяя атрибут Synchronization, можно поручить CLR делать блокировки автоматически. Вот пример:

using System;
using System.Threading;
using System.Runtime.Remoting.Contexts;
 
[Synchronization]
public class AutoLock : ContextBoundObject 
{
  public void Demo() 
  {
    Console.Write("Старт...");
    Thread.Sleep(1000);        // Поток не может быть вытеснен здесь
    Console.WriteLine("стоп"); // спасибо автоматической блокировке!
  } 
}
 
public class Test 
{
  public static void Main() 
  {
    AutoLock safeInstance = new AutoLock ();
    new Thread(safeInstance.Demo).Start(); // Запустить метод 
    new Thread(safeInstance.Demo).Start(); // Demo 3 раза
    safeInstance.Demo();                   // одновременно.
  }
}

Консольный вывод:

Старт...стоп
Старт...стоп
Старт...стоп

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

Как это работает? Ответ находится в пространстве имен атрибута SynchronizationSystem.Runtime.Remoting.Contexts. О ContextBoundObject можно думать как об “удаленном” объекте, перехватывающем все вызовы методов. Чтобы сделать этот перехват возможным, CLR, когда создается AutoLock, фактически возвращает прокси-объект со всеми методами и свойствами AutoLock, который работает как посредник. Именно через это посредничество и работает автоблокировка. В целом перехват добавляет около микросекунды к вызову каждого метода.

Автоматическая синхронизация не может быть использована для защиты членов статических типов и классов, не являющихся наследниками ContextBoundObject (например, Windows Form).

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

[Synchronization]
public class AutoLock : ContextBoundObject 
{
  public void Demo()
  {
    Console.Write("Start...");
    Thread.Sleep(1000);
    Console.WriteLine("end");
  }
 
  public void Test()
  {
    new Thread(Demo).Start();
    new Thread(Demo).Start();
    new Thread(Demo).Start();
    Console.ReadLine();
  }
 
  public static void Main() 
  {
    new AutoLock ().Test();
  }
}

(Обратите внимание, куда вкрался вызов Console.ReadLine). Поскольку в некий момент времени только один поток может выполнять код данного объекта, три новых потока блокированы на вызове метода Demo, пока метод Test не будет завершен – а для этого нужно, чтобы завершился вызов Console.ReadLine. Следовательно, результат будет такой же, как в предыдущем примере, но только после нажатия клавиши Enter. Этот потокобезопасный молоток достаточно велик, чтобы воспрепятствовать любой полезной многопоточности внутри класса!

Кроме того, мы не решили проблему, описанную ранее: если бы AutoLock был классом коллекции, например, все еще требовался бы lock вокруг следующего выражения, вызванного из другого класса:

if (safeInstance.Count > 0)
  safeInstance.RemoveAt(0);

за исключением случая, когда этот класс сам синхронизирован при помощи ContextBoundObject.

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

КонстантаЗначение
NOT_SUPPORTEDЭквивалентно неиспользованию атрибутов синхронизации.
SUPPORTEDПрисоединиться к существующему контексту синхронизации, если создание происходит из синхронизированного объекта, иначе не использовать синхронизацию.
REQUIRED(default)Присоединиться к существующему контексту синхронизации, если создание происходит из синхронизированного объекта, иначе создать новый контекст.
REQUIRES_NEWВсегда создавать новый контекст синхронизации.

Так, если объект класса SynchronizedA создает объект класса SynchronizedB, у них будут разные контексты синхронизации, если SynchronizedB декларирован следующим образом:

[Synchronization(SynchronizationAttribute.REQUIRES_NEW)]
public class SynchronizedB : ContextBoundObject
{
  ...

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

using System;
using System.Runtime.Remoting.Contexts;
using System.Threading;

[Synchronization]
public class Deadlock : ContextBoundObject
{
  public Deadlock Other;

  public void Demo()
  {
    Thread.Sleep(1000);
    Other.Hello();
  }

  void Hello() { Console.WriteLine("hello"); }
}

public class Test
{
  static void Main()
  {
    Deadlock dead1 = new Deadlock();
    Deadlock dead2 = new Deadlock();
    dead1.Other = dead2;
    dead2.Other = dead1;
    new Thread(dead1.Demo).Start();
    dead2.Demo();
  }
}

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

Реентерабельность

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

Реентерабельность, однако, имеет другой, более мрачный смысл в режиме автоматической блокировки. Если атрибут Synchronization применяется с аргументом reEntrant, установленным в true:

[Synchronization(true)]

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

Поскольку [Synchronization(true)] применяется на уровне класса, этот атрибут превращает каждый вызов метода, покидающего созданный классом контекст, в троянского коня реентерабельности.

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

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

Продолжение - в следующем номере.


Эта статья опубликована в журнале RSDN Magazine #1-2007. Информацию о журнале можно найти здесь