| Рабочим названием платформы .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.
