Эпоха параллельности.
Способы выживания в эпоху многоядерного параллелизма

Автор: Иван Бодягин
The RSDN Group

Источник: RSDN Magazine #3-2009
Опубликовано: 25.01.2010
Версия текста: 1.0
Постановка задачи и описание проблемы
Task Parallel Library (TPL)
Параллелизм, основанный на задачах (Task based parallelism)
Новый пул потоков
Fork/Join-параллелизм
ParallelLINQ (PLINQ) – Декларативный параллелизм по данным.
Coordination Data Structures
Примитивы синхронизации
Ленивая инициализация
Коллекции
Исключения
Заключение

Постановка задачи и описание проблемы

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

Данное положение дел почти всем устраивало. Однако на данный момент теоретический предел производительности одного ядра процессора практически достигнут. И вроде бы закон Мура по прежнему продолжает действовать, но теперь он не означает роста производительности. Процессоры, если можно так выразиться, стали расти "вширь", на одном кристалле стали размещать несколько ядер, и число этих ядер неуклонно растет. Но производительность одного ядра почти не увеличивается, что, с точки зрения разработки довольно печально. Ранее разработчики получали так называемые "дивиденды Мура", или еще это называют "free lunch", за счет того, что давным-давно написанная программа при выходе более нового процессора автоматически начинала работать быстрее. Теперь же, когда вектор изменился и вместо роста производительности одного ядра происходит рост количества ядер, старая однопоточная программ не начнет работать быстрее, если ее запустить на новом, двух/четырехядерном процессоре вместо одноядерного.

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

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

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

Но теперь выбора практически нет, и чтобы угнаться за по-прежнему растущими потребностями, необходимо создавать приложения, которые хорошо распараллеливаются. К сожалению, счастья не существует, как и Деда Мороза. Тот же Андерс Хэйлсберг, создатель турбо-паскаля, Delphi и C#, и вообще, матерый человечище, в своем последнем видеоинтервью, также говорил, что бесплатных завтраков в чистом виде мы больше не получим. Но это не значит, что не стоит к этому стремиться – способы облегчения участи существуют... :)

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

ПРИМЕЧАНИЕ

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

MS также начинает движение и в этом направлении, недавно широкой публике продемонстрировали проект под названием Axum. Это новый язык, предназначенный для параллельных вычислений. Не стоит бояться, и бросать заниматься C#. :) Этот чисто исследовательский язык, и его промышленную версию выпускать не планируются. Точно так же Microsoft поступал, когда создавал экспериментальный язык COmega, чтобы проверить LINQ в боевых условиях перед выпуском C# 3.0. Так что рождения нового языка от MS ожидать не стоит, а вот новые конструкции из этого языка с хорошей вероятностью можно будет увидеть в C# версии к пятой...

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

Функциональное программирование предоставляет в этом плане очень богатые возможности. Прежде всего, в функциональной парадигме нет изменяемых структур и, как следствие, нет разделяемого состояния, а значит, уходит одна из главных проблем параллелизма – совместный доступ к ресурсам. Как следствие, каждая функция зависит только от входных параметров, и результатом ее работы являются только выходные параметры. Такой подход практически на корню снимает проблемы синхронизации. Другое преимущество функциональности заключается в том, что функциональная программа является, по сути, декларативной, то есть описывается не пошаговая реализация алгоритма, а то, что хочется получить в конечном итоге. Хороший пример – это SQL: при построении запроса описывается не алгоритм прохождения по индексу, а набор столбцов конечного результата, и то, из каких столбцов и таблиц его надо получить. Иными словами, не "как", а "что". Декларативность дает гораздо больше свободы оптимизатору/компилятору, в том числе и по распараллеливанию алгоритма. Таким образом, это еще один шажок в сторону усугубления автоматического использования всех доступных мощностей.

В этом направлении Microsoft движется довольно давно и планомерно. Это и F#, и заимствованные из функциональных языков возможности C#, и наработки типа PLINQ, о которых пойдет речь чуть позже, и, наконец, дальнейшего развития этого направления следует ждать опять-таки и в пятой версии C#.

В-третих, можно ввести новые примитивы и паттерны для распараллеливания на уровне фреймворков или библиотек. Здесь Microsoft тоже не отдыхает и довольно давно и плодотворно работает над библиотекой под названием Task Parallel Library (TPL). Включена она будет, по всей видимости, уже в .Net 4.0.

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

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

Task Parallel Library (TPL)

Теперь можно рассмотреть красоту, описанную выше, подробнее.

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

Иными словами, вместо решения прикладной задачи разработчик вынужден кучу сил тратить на борьбу с недружественным окружением.

Решение этих проблем в TPL начинается с того, что вместо привычных потоков вводится другая базовая абстракция – Task (задача). Идея в том, что разработчик все еще должен сам явно разбить решение на независимые задачи, которые могут выполняться параллельно, без ущерба для логики, но оптимальным распределением этих задач по потокам уже занимается TPL.

Параллелизм, основанный на задачах (Task based parallelism)

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

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

Еще один, весьма часто встречающийся сценарий – необходимость получить результат работы задачи, запущенной в другом потоке. Такой сценарий поддерживается классом Task<T>, где T – тип результата.

ПРИМЕЧАНИЕ

В предыдущих CTP .Net 4.0, куда войдет Task Parallel Library, этот класс назывался Future<T>, так как сама концепция не сейчас придумана, и в классической версии такая штука называется именно Future, намекая на то, что потом будет получен результат. Но так как по факту это все-таки Task, просто с дополнительной функциональностью, то его и переименовали именно в Task, чтобы не вводить лишний раз в заблуждение.

Кроме того, что это обобщенный тип, от обычного класса Task его отличает только наличие свойства Value, где хранится результат работы задания после его выполнения. Причем обращение к этому свойству блокирует вызывающий поток до окончания работы Task<T>.

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

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

Итак, для эталона возьмем обычную однопоточную процедуру:

        double[,] SeqMatrixMult(int size, double[,] m1, double[,] m2)
        {
            double[,] result = newdouble[size, size];
            for (int i = 0; i < size; i++)
            {
                for (int j = 0; j < size; j++)
                {
                    result[i, j] = 0;
                    for (int k = 0; k < size; k++)
                    {
                        result[i, j] += m1[i, k] * m2[k, j];
                    }
                }
            }
            return result;
        }

На моем двухядерном ноутбуке такой код выполняет перемножение матриц размером 1000x1000 элементов порядка 32 секунд. При этом оба ядра загружены на 50%, работает 1 поток.

Теперь попробуем распараллелить задачу классическим способом, разложив вручную по потокам:

                double[,] ThreadMatrixMult(int size, double[,] m1, double[,] m2)
        {
            double[,] result = newdouble[size, size];
            var threads = new List<Thread>(size);
            for (int i = 0; i < size; i++)
            {
                var t = new Thread( ti =>
                {
                    int ii = (int)ti;
                    for (int j = 0; j < size; j++)
                    {
                        result[ii, j] = 0;
                        for (int k = 0; k < size; k++)
                        {
                            result[ii, j] += m1[ii, k] * m2[k, j];
                        }
                    }
                });
                threads.Add(t);
                t.Start(i);
            }
            
            foreach (Thread thread in threads)
                thread.Join();

            return result;
        }
        

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

Теперь давайте посмотрим, как выглядело бы распараллеливание такого алгоритма вручную не в наивном варианте, а по-честному, чтобы не стыдно было в рабочий код пустить (этот код вообще содран из MSDN практически без изменений :) )

        double[,] ThreadpoolMatrixMult(int size, double[,] m1, double[,] m2)
{
  double[,] result = newdouble[size, size];

  int N = size;
  // количество потоков равно удвоенному количеству CPU
int P = 2 * Environment.ProcessorCount; 
  // вычисляется размер кусочков для независимой обработки
int Chunk = N / P;                    
  AutoResetEvent signal = new AutoResetEvent(false);
  // используется счетчик для уменьшения числа переключений ядраint counter = P;          
  for (int c = 0; c < P; c++)
  // каждый кусок запускается в отдельном потоке
  {     
    ThreadPool.QueueUserWorkItem(delegate(Object o)
    {
      int lc = (int)o;
      for (int i = lc * Chunk;       // перебор по каждому куску
        i < (lc + 1 == P ? N : (lc + 1) * Chunk); 
          i++)
      {
        // собственно перемножениеfor (int j = 0; j < size; j++)
        {
           result[i, j] = 0;
           for (int k = 0; k < size; k++)
           {
             result[i, j] += m1[i, k] * m2[k, j];
           }
        }
      }
      if (Interlocked.Decrement(ref counter) == 0)
        // используются эффективные инструкции для синхронизации
      {          
        // Переключение ядра, когда работа завершена
        signal.Set();
      }
    }, c);
  }
  signal.WaitOne();
  return result;
}

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

Что же нам может предложить TPL:

        double[,] TaskMatrixMult(int size, double[,] m1, double[,] m2)
    {
      double[,] result = newdouble[size, size];
      List<Task> tasks = new List<Task>(size);
      for (int i = 0; i < size; i++)
      {
        tasks.Add(new Task(ti =>
        {
          int ii = (int)ti;
          for (int j = 0; j < size; j++)
          {
            result[ii, j] = 0;
            for (int k = 0; k < size; k++)
            {
              result[ii, j] += m1[ii, k] * m2[k, j];
            }
          }
        }, i));
      }
      Task.WaitAll(tasks.ToArray());
      return result;
    }

Как легко заметить, этот вариант даже короче, чем наивный, с ручным раскладыванием по потокам, и такой же очевидный... Но при этом его выполнение занимает порядка 17 секунд, а число потоков практически оптимально, что сравнимо с ручным выпиливанием поверх thread pool-а, однако не в пример проще.

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

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

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

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

Новый пул потоков

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

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

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

В итоге, как можно заметить, класс Task со всей своей инфраструктурой представляют собой уже довольно высокоуровневую конструкцию. С другой стороны, они могут покрыть практически все задачи, связанные с параллелизмом. Однако существуют и более высокоуровневые паттерны для решения многопоточных задач, которые также покрывают достаточно большое количество сценариев. Например, сейчас одним из самых распространенных механизмов распараллеливания является Fork/Join-параллелизм.

Fork/Join-параллелизм

Идея в целом весьма проста – большая задача дробится на задачи поменьше, те, в свою очередь, на еще более мелкие задачи, и так далее, пока имеет смысл разделять. В самом конце получившаяся тривиальная задача выполняется последовательно. Этот этап называется Fork. Затем результат выполнения задач объединяется вверх по цепочке, пока не получится решение самой верхней задачи. Легко догадаться, что этот этап называется Join, и в общем случае является не обязательным. Выполнение же всех задач, в том числе и разделение на подзадачи, происходит параллельно. 

TPL поддерживает данный паттерн двумя способами – с помощью относительно низкоуровневой конструкции Parallel.Invoke, которая позволяет явно отдать на выполнение произвольный Action отдельному потоку, и достаточно высокоуровневых методов Parallel.For/Parallel.ForEach, которые позволяют декларативно распараллелить циклы, если, конечно, можно позволить делать каждую итерацию независимо.

Все эти методы уровнем ниже реализованы через те же Task, но если задача укладывается в этот паттерн, то пользоваться ими удобнее.

К Parallel.Invoke мы вернемся чуть позже, а на примере Parallel.For  уже знакомая задача пересчета матриц решается следующим образом:

        double[,] ForMatrixMult(int size, double[,] m1, double[,] m2)
{
  double[,] result = newdouble[size, size];
  Parallel.For(0, size, 
    i =>
    {
      for (int j = 0; j < size; j++)
      {
        result[i, j] = 0;
        for (int k = 0; k < size; k++)
        {
          result[i, j] += m1[i, k] * m2[k, j];
        }
      }
    });
  return result;
}

В данном случае, как можно заметить, от однопоточного варианта этот код отличается только одной строчкой – оператор for(…) был заменен на метод Parallel.For(…). В результате код волшебным образом распараллелился в нужном месте до нужного количества потоков и практически сравнился по производительности с той версией, где используются задачи. :)

ParallelLINQ (PLINQ) – Декларативный параллелизм по данным.

PLINQ представляет собой практически самый высокий уровень абстракции от потоков, позволяющий, тем не менее, пользоваться всеми преимуществами многоядерных систем. Существует довольно большой класс задач работы с данными, который отлично распараллеливается и естественным образом разбивается на оптимально гранулированные подзадачи, о которых говорилось ранее – это различного рода объединения, фильтрация, группировка и прочие операции с наборами данных. В .Net 3.5 для такого рода задач была представлена библиотека декларативных запросов – LINQ, и это отличный кандидат на автоматическое распараллеливание. Собственно, о привлекательности декларативности в этом аспекте уже говорилось ранее. Когда код декларативен, очень многие решения можно отдать на откуп компилятору/оптимизатору/библиотеке, которые лучше знают о конкретном окружении, числе ядер и прочих особенностях... PLINQ ровно это и демонстрирует, легким движением руки распараллеливая запросы к данным.

Например, простой запрос

      var result = from s in source where ... select ...

может быть распараллелен примерно таким образом:

      var result = from s in source.AsParallel()where ... select ...

То есть, простое добавление метода AsParallel() позволяет распараллелить выполнение запроса оптимальным образом на имеющееся количество ядер. 

Как это работает? Очень просто, никакой магии... :) Если переписать запрос без query comprehensions, то получится следующая цепочка вызовов extension-методов:

      var result = source.Where(s => ...).Select(s => ...);

И когда захотелось этот запрос распараллелить, то получилось следующее:

      var result = source.AsParallel().Where(s => ...).Select(s => ...);

Здесь метод AsParallel() выполняет преобразование обычного IEnumerable к некоторому типу, а конкретно, к ParallelEnumerable. ParallelEnumerable имеет те же самые методы расширения, что и обычный IEnumerable (Where, Select, ect...), но уже знающие о многоядерном окружении и умеющие правильно с ним работать.

Иными словами, при вызове AsParallel, реализация методов расширения для IEnumerable подменяется реализацией для ParallelEnumerable.

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

ParallelEnumerable также имеет ряд дополнительных методов расширения, которые позволяют задать различные настройки распараллеливания, для простоты все эти методы начинаются со слова With...

WithDegreeOfParallelism(...)

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

        var result = from s in source.AsParallel().WithDegreeOfPErellelism(4) where ... select ... 

WithExecutionMode(...)

На данный момент может принимать два значения – default и ForceParallelism. Дело в том, что PLINQ старается выполнить запросы как минимум не хуже, чем обычный LINQ, в том числе и по расходам памяти, и по затратам на обработку одного элемента. По этим причинам PLINQ, если видит, что запрос обходится дорого, может свалиться в последовательное выполнение. Если же есть уверенность, что запрос все-таки лучше распараллелить, то можно указать параметр ForceParallelism. 

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

WithCancellation(...)

Позволяет выполнять запросы с возможностью отмены. Для этого в WithCancellation нужно передать объект CancellationTokenSource, чей метод Cancel и вызывает отмену выполнения запроса. 

WithMergeOptions(...)

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

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

Coordination Data Structures

Классы Task, сами по себе, хоть и прекрасны, но все же нуждаются в некоторой инфраструктуре и всякого рода вспомогательных конструкциях для реализации своих алгоритмов распараллеливания. Набор таких структур называется Coordination Data Structures (CDS). Собственно, CDS существовали и раньше, и служили они для поддержки параллелизма еще в эпоху голых потоков. Но с появлением Task и других высокоуровневых решений эти структуры нуждались в некоторых расширениях, что, собственно, и произошло. Эти структуры TPL использует в своих реализациях алгоритмов, но их вполне можно использовать и в своем прикладном коде. Сами структуры можно условно разделить на несколько типов.

Примитивы синхронизации

SpinWait

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

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

SpinLock

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

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

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

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

SpinLock l = new SpinLock();
l.Enter(); // Вызов не блокируется
l.Enter(); // Поток блокирует сам себя

Также, при создании SpinLock конструктором по умолчанию допускается освобождение блокировки из другого потока. Делать так не рекомендуется, но это возможно.

Для изменения описанного выше поведения существует конструктор, принимающий параметр isThreadOwnerTrackingEnabled. Если этот параметр выставить в true, при попытке дважды захватить блокировку из одного потока будет сгенерировано LockRecursionException, а при попытке снять блокировку из другого потока – SynchronizationLockException.

ManualResetEventSlim и SemaphoreSlim

Это улучшенные реализации аналогичных структур без суффикса "slim", изначально присутствующих в .Net. Основное улучшение – все тот же SpinWait, позволяющий ожидать, не переключая контекст. 

CountDownEvent

Этот примитив синхронизации специально предназначен для облегчения работы с Fork/Join-параллелизмом. Он позволяет основному потоку ждать, пока один или несколько дочерних потоков не завершат выполнять свои задачи. При этом основному потоку нет необходимости знать, сколько именно будет дочерних потоков.

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

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

Понятное дело, что без SpinWait и здесь не обошлось, так что и этот примитив является multicore friendly.

Barrier

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

На самом деле, это смахивает на давно уже привычный WaitHandle.WaitAll, но для синхронизации X потоков посредством WaitHandle нужно X примитивов, в то время как Barrier – один на все потоки, а в некоторых сценариях это может быть довольно критично.

Ленивая инициализация

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

Lazy<T> и LazyValue<T>

Это объекты, которые реализуют оптимистическую или пессимистическую стратегию ленивой инициализации объекта в зависимости от параметра LasyInitMode. При этом, очевидно, Lazy отвечает за ссылочные типы, а LazyValue – за значения.

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

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

ThreadLocal<T> 

Этот объект реализует еще одну стратегию – каждому потоку достается своя копия объекта.

LazyInitializer

Представляет собой набор статических хелперов для работы с ленивой инициализацией. 

Коллекции

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

IProducerConsumerCollection<T>

Для обобщенности все lock-free коллекции реализуют этот интерфейс. 

ConcurrentBag<T>

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

СoncurrentBag<T> можно представить как набор очередей с двусторонним доступом (deque). Каждый поток при работе с коллекцией обращается к своей собственной очереди, добавляя и удаляя элементы с ее вершины. Когда случается так, что очередь одного потока пуста, а ему нужно извлечь элемент, то он извлекает его из очереди соседнего потока, но уже не с вершины, а снизу – с противоположного конца очереди. Такой подход позволяет практически не пересекаться разным потокам по данным и в то же время дружить с кэшем. Как можно заметить, по этому же принципу работает и новый диспетчер потоков ThreadPool-а, описанный ранее.

Однако справедливости ради надо сказать, что ConcurrentBag<T> далеко не всегда будет самым шустрым решением. Если сценарий предполагает только одного поставщика элементов и одного потребителя, то ConcurrentQueue<T> будет намного эффективнее, а это не такой уж редкий случай.

ConcurrentLinkedList<T>

Эта коллекция представляет собой рассчитанную на многопоточность версию обычного LinkedList<T>. Проблема с обычным LinkedList в многопоточной среде состоит в том, что пока один поток, найдя место для вставки, пытается вставить новый элемент, другой поток может успеть вставить свой или удалить элемент, и место для вставки нового элемента первым потоком окажется неправильным. Чтобы обойти эту неприятность, класс ConcurrentLinkedList<T> оборудован методом TryInsertBetween(), который принимает в качестве аргумента, помимо самого элемента, предикат с левым и правым элементом списка, между которыми нужно вставить новый объект. И вставка производится только в том случае, если предикат вернул true, если же случился false, то вставка не производится, и метод, в свою очередь, возвращает false.

Partitioner<T> и OrderablePartitioner<T>

Эти две коллекции позволяют высокоуровневым конструкциям типа Parallel.ForEach более интеллектуально разгребать очередь. Дело в том, что по умолчанию TPL и PLINQ при работе с коллекцией смотрят на наличие интерфейсов IEnumerable или IList, и осуществляют последовательный или индексный доступ, соответственно. Однако бывают случаи, когда более хитрые сценарии доступа были бы намного эффективнее.

Допустим, есть коллекция, которая синхронизирует доступ к элементам посредством блокировок, причем важно, чтобы определенные элементы защищались одной и той же блокировкой (скажем элементы 1, 3, 7 блокировкой A, а элементы 2, 4 и 9 блокировкой B). Очевидно, что последовательный перебор элементов в данном случае выльется в последовательный захват и освобождение блокировок по циклу, тогда как гораздо эффективнее было бы захватить нужную блокировку один раз и обработать все нужные элементы оптом. Такую возможность и предоставляет коллекция Partitioner<T>, а OrderedPartitioner<T> к тому же позволяет это сделать еще и с сохранением порядка элементов. 

ConcurrentQueue<T> и ConcurrentStack<T>

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

BlockingCollection<T>

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

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

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

Исключения

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

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

Такое поведение намного предпочтительнее покорного замалчивания проблемы, но влечет за собой ряд неудобств. Кроме того, по-прежнему есть исключения из такого поведения, например, работа с объектами Asynchronous Programming Model, реализованными в .Net для поддержки соответствующего паттерна. Это паттерн асинхронного программирования, в котором вызов метода, выполняющего работу, разбивается на два: BeginXxx и EndXxx. Если при работе кода, вызванного посредством BeginXxx в отдельном потоке, происходит выброс исключения, то это исключение автоматически перехватывается и генерируется заново при вызове метода EndXxx из основного потока. На первый взгляд все отлично, основной поток получит исключение и сможет правильно его обработать, но проблемы появляются, если забыть вызвать метод EndXxx – в этом случае исключение, как это было и в первом фреймворке, игнорируется.

Собственно, похожая проблема встала и перед TPL, так как Task-и представляют асинхронные операции, которые могут генерировать неперехватываемые исключения. Как и в случае с APM, если такое произошло, то информация об этом хранится в соответствующем экземпляре объекта Task и это исключение будет повторно сгенерировано в основном потоке в момент синхронизации с ним. Так же информация об исключении может быть получена через специальное свойство. Основная проблема, как и в случае с APM, состоит в так называемых fire-and-forget сценариях («запустил и забыл»), когда основной поток никогда не синхронизируется с запущенной задачей, ему просто неинтересны результаты ее работы.

Однако, в отличие от APM, в TPL эту проблему решили. Сделали это следующим образом. TPL отслеживает, было ли исключение, сгенерированное в Task-е (и не перехваченное в нем же), обработано основным потоком. Под «обработано» здесь понимается возможность хоть как-то проинформировать об исключении основной поток - это может быть вызов Wait, проверка свойства TaskException или обращение к свойству Task<TResult>.Result. Если все ссылки на проблемный Task исчезли, и тот стал доступен для сборки мусора, а исключение так и не было обработано основным потоком, Task пропихивает это исключение в поток финализации, и оно выбрасывается там, что вызывает отработку стандартной для всех неперехваченных исключений логики.

Конечно же, по-прежнему возможны сценарии, когда нужно просто выбросить Task и забыть о нем, при этом разве что сбросить возможное исключение в лог из основного потока. Добиться этого можно, например, посредством такого метода расширения:

      public
      static Task WithLogException(this Task task, ILogWriter logWriter)
{
  task.ContinueWith(c => logWriter.Write(c.Exception),
    TaskContinuationOptions.OnlyOnFaulted| 
    TaskContinuationOptions.ExecuteSynchronously| 
    TaskContinuationOptions.DetachedFromParent);
  return task;
}

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

var t = Task.Factory.StartNew(…).WithLogException();

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

Заключение

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


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