Рабочим названием платформы .NET было |
Параллельные операции в .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 записи, однако она автоматически выделяется при первом использовании монитора.
Класс 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.