Опубликован: 28.06.2006 | Уровень: специалист | Доступ: платный | ВУЗ: Московский государственный технический университет им. Н.Э. Баумана
Лекция 15:

Параллельные операции в .NET

Синхронизация и изоляция потоков

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

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

Атомарные операции

Платформа .NET предоставляет, аналогично базовой операционной системе Windows, набор некоторых основных операций над целыми числами ( int и long ), которые могут выполняться атомарно. Для этого предусмотрены четыре статических метода класса System.Threading.Interlocked, а именно Increment, Decrement, Exchange и CompareExchange. Применение этих методов аналогично соответствующим Interlocked... процедурам Win32 API.

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

public static void ThreadProc()
{
  int    i,j,k, from, to;
  from = ( m_stripused++ ) * m_stripsize;
  to = from + m_stripsize;
  ...

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

public static void ThreadProc()
{
 int    i,j,k, from, to;
 from = (Interlocked.Increment(ref m_stripused) - 1 ) * m_stripsize;
 to = from + m_stripsize;
  ...
Синхронизация потоков

Основные средства взаимной синхронизации потоков в .NET обладают заметным сходством со средствами операционной системы. Среди них можно выделить:

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

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

Мониторы

Мониторы в .NET являются аналогами критических секций в Win32 API. Использование мониторов достаточно эффективно (это один из самых эффективных механизмов) и удобно настолько, что в .NET был предусмотрен механизм, который позволяет использовать практически любой объект, хранящийся в управляемой куче, для синхронизации доступа. Для этого с каждым объектом ссылочного типа сопоставляется запись SyncBlock, являющаяся, по сути, аналогом структуры CRITICAL_SECTION в Win32 API. Добавление такой записи к каждому объекту в управляемой куче чересчур накладно, особенно если учесть, что используются они относительно редко. Поэтому все записи SyncBlock выносятся в отдельный кэш, а в информацию об объекте включается ссылка на запись кэша (см. рис. 7.2). Такой прием позволяет, с одной стороны, содержать кэш синхронизирующих записей минимального размера, а с другой - любому объекту при необходимости можно сопоставить запись.

Использование кэша SyncBlock записей объектами управляемой кучи

Рис. 7.2. Использование кэша SyncBlock записей объектами управляемой кучи

Обычно объекты не имеют сопоставленной с ними SyncBlock записи, однако она автоматически выделяется при первом использовании монитора.

Класс Monitor, определенный в пространстве имен System.Threading, предлагает несколько статических методов для работы с записями синхронизации. Методы Enter и Exit являются наиболее применяемыми и соответствуют функциям EnterCriticalSection и LeaveCriticalSection операционной системы. Аналогично критическим секциям Win32 API, мониторы могут использоваться одним потоком рекурсивно. Еще несколько методов класса Monitor - Wait, Pulse и PulseAll - позволяют при необходимости временно разрешить доступ к объекту другому потоку, ожидающему его освобождения, не покидая критической секции.

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

public static void ThreadProc()
{
  int    i,j,k, from, to;
  from = (Interlocked.Increment(ref m_stripused)-1)
    * m_stripsize;
  to = from + m_stripsize;
  if ( to > m_size ) to = m_size;
  for ( i = 0; i < m_size; i++ ) {
    for ( j = 0; j < m_size; j++ ) {
      for ( k = from; k < to; k++ )
        m_C[i,j] += m_A[i,k] * m_B[k,j];
    }
  }
}

Так как эта операция выполняется не атомарно, то вполне может быть так, что один поток считывает значение m_C[i,j], прибавляет к нему величину m_A[i,k] * m_B[k,j] и, прежде чем успевает записать в m_C[i,j] результат сложения, прерывается другим потоком. Второй поток успевает изменить величину m_C[i,j], потом первый снова пробуждается и записывает значение, вычисленное для предыдущего состояния элемента m_C[i,j], - то есть некорректную величину. Собственно говоря, именно эта ситуация и приводит к ошибкам, которые можно наблюдать в исходном примере.

Ситуацию можно исправить, используя синхронизацию при доступе к элементу m_C[i,j] с помощью мониторов:

...
    for ( j = 0; j < m_size; j++ ) {
      for ( k = from; k < to; k++ ) {
        Monitor.Enter( m_C );
        try {
          m_C[i,j] += m_A[i,k] * m_B[k,j];
        } finally {
          Monitor.Exit( m_C );
        }
      }
    }
  ...

В этом фрагменте надо выделить два существенных момента: во-первых, использование метода Exit в блоке finally, а во-вторых - использование всего массива m_C, а не отдельного элемента m_C[i,j].

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

Второе связано с тем, что элементы m_C[i,j] являются значениями, а не ссылочными типами. Для типов-значений соответствующее представление в управляемой куче не создается, и у них нет и не может быть ссылок на синхронизирующие записи SyncBlock.

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

...
    for ( j = 0; j < m_size; j++ ) {
      for ( k = from; k < to; k++ ) {
        Monitor.Enter( m_C[i,j] );
        try {
          m_C[i,j] += m_A[i,k] * m_B[k,j];
        } finally {
          Monitor.Exit( m_C[i,j] );
        }
      }
    }
  ...

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

Можно выделить интересный момент - типы объектов сами являются экземплярами класса Type, и для них выделяется место в управляемой куче. Это позволяет использовать тип объекта в качестве владельца записи SyncBlock:

...
    for ( j = 0; j < m_size; j++ ) {
      for ( k = from; k < to; k++ ) {
        Monitor.Enter( typeof(double) );
        try {
          m_C[i,j] += m_A[i,k] * m_B[k,j];
        } finally {
          Monitor.Exit( typeof(double) );
        }
      }
    }
  ...

Возможно неявное использование мониторов в C# с помощью ключевого слова lock:

lock ( obj ) { ... }

эквивалентна

Monitor.Enter( obj ); try { ... }  
finally { Mointor.Exit( obj ); }

Использование ключевого слова lock предпочтительно, так как при этом выполняется дополнительная синтаксическая проверка - попытка использовать для блокировки тип-значение приведет к диагностируемой компилятором ошибке, вместо трудно отлавливаемой ошибки во время исполнения:

public static void ThreadProc()
{
  int  	i,j,k, from, to;
  double  R;
  from = (Interlocked.Increment(ref m_stripused) - 1) * m_stripsize;
  to = from + m_stripsize;
  if ( to > m_size ) to = m_size;
  for ( i = 0; i < m_size; i++ ) {
    for ( j = 0; j < m_size; j++ ) {
      R = 0;
      for ( k = from; k < to; k++ ) R += m_A[i,k]*m_B[k,j];
      lock ( m_C ) { m_C[i,j] += R; }
    }
  }
}

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

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

Ожидающие объекты

.NET предоставляет базовый класс WaitHandle, служащий для описания объекта, который находится в одном из двух состояний: занятом или свободном. На основе этого класса строятся другие классы синхронизирующих объектов .NET, такие как события ( ManualResetEvent и AutoResetEvent ) и мьютексы ( Mutex ).

Класс WaitHandle является, по сути, оберткой объектов ядра операционной системы, поддерживающих интерфейс синхронизации. Свойство Handle объекта WaitHandle позволяет установить (или узнать) соответствие этого объекта .NET с объектом ядра операционной системы.

Существует три метода класса WaitHandle для ожидания освобождения объекта: метод WaitOne, являющийся методом объекта, и статические методы WaitAny и WaitAll. Метод WaitOne является оберткой вызова WaitForSingleObject Win32 API, а методы WaitAny и WaitAll - вызова WaitForMultipleObjects. Соответственно семантике конкретных объектов ядра, представленных объектом WaitHandle, методы Wait... могут изменять или не изменять состояние ожидаемого объекта. Так, например, для событий с ручным сбросом ( ManualResetEvent ) состояние не меняется, а события с автоматическим сбросом и мьютексы ( AutoResetEvent, Mutex ) переводятся в занятое состояние.

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

using System;
using System.Threading;

namespace TestNamespace {
  public class SomeData {
    public const int         m_queries = 10;
    private static int       m_counter = 0;
    private static Mutex      m_mutex = new Mutex(); 
    private static ManualResetEvent m_event = 
                new ManualResetEvent( false );
    public static void Invoke( int no ) {
      m_mutex.WaitOne();
      m_counter++;
      if ( m_counter >= m_queries ) m_event.Set();
      m_mutex.ReleaseMutex();
      m_event.WaitOne();
    }
  }
  public delegate void AsyncProcCallback( int no );
  class TestApp {
    public static void Main() {
      int        		i;
      WaitHandle[]    	wh;
      AsyncProcCallback	apd;
      wh = new WaitHandle[ SomeData.m_queries ];
      apd = new AsyncProcCallback( SomeData.Invoke );
      for ( i = 0; i < SomeData.m_queries; i++ ) wh[i] =
			  apd.BeginInvoke(i,null,null).AsyncWaitHandle;
      WaitHandle.WaitAll( wh );
    }
  }
}

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

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

Потоки не являются наследниками класса WaitHandle в силу того, что для разных базовых платформ потоки могут быть реализованы в качестве потоков операционной системы или легковесных потоков, управляемых CLR. В последнем случае потоки .NET не будут иметь никаких аналогов среди объектов ядра операционной системы. Для синхронизации с потоками надо использовать метод Join класса Thread.

Один "писатель", много "читателей"

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

.NET предоставляет весьма эффективное стандартное решение: класс ReaderWriterLock. В приводимом ниже примере демонстрируется применение методов Acquire... и Release... для корректного использования блокировки доступа при чтении и записи. Тестовый класс содержит две целочисленные переменные, которые считываются и увеличиваются на 1 с небольшими задержками по отношению друг к другу. Пока операции синхронизируются, попытка чтения или изменения всегда будет возвращать четный результат, а вот если бы синхронизация не выполнялась, то в некоторых случаях получались бы нечетные числа:

using System;
using System.Threading;
namespace TestNamespace {
  public class SomeData {
    public const int         m_queries = 10;
    private ReaderWriterLock  m_rwlock = new ReaderWriterLock();
    private int        m_a = 0, m_b = 0;
    public int summ() {
      int  r;
      m_rwlock.AcquireReaderLock( -1 );
      try {
        r = m_a; Thread.Sleep( 1000 ); return r + m_b;
      } finally {
        m_rwlock.ReleaseReaderLock();
      }
    }
    public int inc() {
      m_rwlock.AcquireWriterLock( -1 );
      try {
        m_a++; Thread.Sleep( 500 ); m_b++;
        return m_a + m_b;
      } finally {
        m_rwlock.ReleaseWriterLock();
      }
    }
    public static void Invoke( SomeData sd, int no ) {
      if ( no % 2 == 0 ) {
        Console.WriteLine( sd.inc() );
      } else {
        Console.WriteLine( sd.summ() );
      }
    }
  }
  public delegate void AsyncProcCallback(SomeData sd, int no);
  class TestApp {
    public static void Main() {
      int        		i;
      SomeData      	sd = new SomeData();
      WaitHandle[]  	wh;
      AsyncProcCallback  apd;
      wh = new WaitHandle[ SomeData.m_queries ];
      apd = new AsyncProcCallback( SomeData.Invoke );
      for ( i = 0; i < SomeData.m_queries; i++ ) wh[i] =
		     		apd.BeginInvoke(sd,i,null,null).AsyncWaitHandle;
      WaitHandle.WaitAll( wh );
    }
  }
}

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

Локальная для потока память

Применение локальной для потока памяти в .NET опирается на TLS память, поддерживаемую операционной системой. Аналогично Win32 API, возможны декларативный и императивный подходы для работы с локальной для потока памятью.

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

class SomeData {
  [ThreadStatic]
  public static double  xxx;
  ...

Поле класса SomeData.xxx будет размещено в локальной для каждого потока памяти.

Императивный подход связан с применением методов AllocateDataSlot, AllocateNamedDataSlot, GetNamedDataSlot, FreeNamedDataSlot, GetData и SetData класса Thread. Использование этих методов очень похоже на использование Tls... функций Win32 API, с той разницей, что вместо целочисленного индекса в TLS массиве потока (как это было в Win32 API) используется объект типа LocalDataStoreSlot, который выполняет функции прежнего индекса:

class SomeData {
  private static LocalDataStoreSlot m_tls = Thread.AllocateDataSlot();
  public static void ThreadProc() {
    Thread.SetData( m_tls, ... );
    ...
  }
  public void Main() {
    SomeData  sd = new SomeData();
    ...
    // создание и запуск потоков
  }
}

Методы Allocate... и GetNamedDataSlot позволяют выделить новую ячейку в TLS памяти (или получить существующую именованную), методы GetData и SetData позволяют получить или сохранить ссылку на объект в TLS памяти. Использование TLS памяти в .NET менее удобно и эффективно, чем в Win32 API, но это связано не с реализацией TLS, а с реализацией потоков:

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

Таймеры

.NET предлагает два вида таймеров: один описан в пространстве имен System.Timers, а другой - в пространстве имен System.Threading.

Таймер пространства имен System.Threading является опечатанным и предназначен для вызова указанной асинхронной процедуры с заданным интервалом времени.

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

using System;
using System.Timers;

namespace TestNamespace {
  class TestTimer : Timer {
    private int m_minimal, m_maximal, m_counter;
    public int count { get{ return m_counter - m_minimal; }}
    public TestTimer( int mn, int mx ) {
      Elapsed += new ElapsedEventHandler(OnElapsed);
      m_minimal = m_counter = mn;
      m_maximal = mx;
      AutoReset = true;
      Interval = 400;
    }
    static void OnElapsed( object src, ElapsedEventArgs e ) {
      TestTimer  tt = (TestTimer)src;
      if ( tt.m_counter < tt.m_maximal ) tt.m_counter++;
      if ( tt.m_counter >= tt.m_maximal ) tt.Stop();
    }
    static void Main(string[] args) {
      TestTimer  tm = new TestTimer( 0, 10 );
      tm.Start();
      Thread.Sleep( 5000 );
      tm.Stop();
    }
  }
}

Приведенный выше пример иллюстрирует использование таймера пространства имен System.Timers.

Анастасия Булинкова
Анастасия Булинкова
Рабочим названием платформы .NET было
Bogdan Drumov
Bogdan Drumov
Молдова, Республика
Azamat Nurmanbetov
Azamat Nurmanbetov
Киргизия, Bishkek