Программирование на основе задач

Масштабируемое многопоточное программирование с применением задач

Рон Фоснер

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

Есть много способов распределять работу между несколькими ядрами. В прошлом номере MSDN Magazine (msdn.microsoft.com/magazine/gg232758) я ознакомил вас с некоторыми базовыми концепциями многопоточного программирования и показал, как добавить параллельно выполняемый код в программу с помощью OpenMP и пулов потоков. Я также продемонстрировал, как инструментарий Visual Studio 2010 позволяет измерять степень использования ядер и потоков и использовать эти измерения в качестве показателя эффективности многопоточной реализации для вашего приложения.

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

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

Мышь в лабиринте

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

На рис. 1 показана простой последовательный алгоритм поиска выхода из лабиринта. Это просто длинный, извилистый путь с множеством ответвлений, ведущих в тупики. Не удивительно, что я назвал свой алгоритм решения «мышью». Мышь будет осматривать свою текущую ячейку и пытаться перейти в следующую. Наткнувшись на загородку, она попытается повернуть налево. Если не удастся и это — попытается повернуть направо. Если ни в одном из этих направлений продвинуться нельзя, она отмечает текущий путь как тупик и возвращается.

image: Single-Threaded Maze

Рис. 1. Однопоточный лабиринт

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

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

Решение «мышь»

Это решение довольно простое в визуализации, и в результате легко понять его логику поиска. Это зрелище даже слегка гипнотизирует. Упрощенная схема последовательного алгоритма «мышь» показана на рис. 2.

image: Flow Chart of Mouse Actions

Рис. 2. Схема последовательного алгоритма «мышь»

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

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

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

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

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

В итоге я закончил тем, что помещал информацию о текущем пути в каждую мышь и сделал ее частью инициализирующих данных для мыши. Достигнув точки ветвления, я создавал структуру данных мыши и инициализировал ее на основе текущей информации мыши, тем самым создавая клон мыши, который, например, шел влево, а исходная мышь — вправо. Клон содержал память оригинала. Единственное, что различалось в каждой мыши, — это счетчик, отслеживающий количество созданных мышек; вот так, кстати, мыши присваивался цвет.

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

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

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

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

Очереди и зависимости задач

В Windows Vista появились новые алгоритмы планирования и новые синхронизирующие примитивы, которые образуют фундамент для целого ряда средств Microsoft .NET Framework 4. Одно из таких средств — библиотека Task Parallel Library (TPL), которая предоставляет довольно много распространенных алгоритмов параллельного программирования, в том числе fork/join, parallel for, «перехват работы» и др. Если вы кодируете на неуправляемом C++, вы можете задействовать преимущества Intel Threading Building Blocks (TBB) или Microsoft Parallel Patterns Library (PPL).

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

Так как это вводная статья по многопоточному программированию и задачам, но от более глубокого понимания того, как работают эти библиотеки, можно получить выигрыш, я написал собственный набор оболочек для двух новых средств в Windows Vista: ThreadPool и SlimReaderWriterLock (SRWLock). Это недорогие (в смысле издержек) способы превращения данных в безопасные для одновременного доступа нескольких потоков; они рассчитаны на ситуации, где имеются один поток-«писатель» и множество потоков-«читателей», а данные обычно не блокируются на длительное время. Заметьте, что цель этой статьи пошагово показать вам, как я выбрал реализацию пула потоков, использующего задачи.Причем задачи, у которых могут быть зависимости. Чтобы продемонстрировать базовые механизмы, я позволил себе несколько вольностей в коде, облегчающих понимание этих механизмов. Этот код работает, но практической реализации вы должны выбрать одну из многопоточных библиотек.

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

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

Адаптация ThreadPool

Вы должны четко понимать, чтобы построить систему на основе задач поверх ThreadPool, на самом деле достаточно использовать всего три его интерфейса:

CreateThreadpool();
CloseThreadpool();
TrySubmitThreadpoolCallback();

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

Чтобы это работало с моими задачами, я написал небольшую оболочку для ThreadPool, которая позволяет мне настраивать количество потоков в пуле (рис. 3). Я также написал функцию submit, берущую на себя отслеживание контекстных переменных, связанных с данной задачей.

Рис. 3. Оболочка ThreadPool

class ThreadPool {
  PTP_POOL m_Pool;
public:
  static VOID CALLBACK WorkCallback(
    PTP_CALLBACK_INSTANCE instance,
    void* Context);
  ThreadPool(void);
  ~ThreadPool(void);
  
  static unsigned GetHardwareThreadsCount();

  // create thread pool that has optimal 
  // number of threads for current hardware
  bool create() { 
    DWORD tc = GetHardwareThreadsCount(); 
    return create(tc,tc); 
  }
  bool create(
    unsigned int minThreads, 
    unsigned int maxThreads = 0);
  void release();
  bool submit(ITask* pWork);
};

Стоит отметить, что обычно вы создаете столько программных потоков, сколько аппаратных потоков поддерживается оборудованием. Иногда может быть на один поток меньше, если ваш основной поток делает что-то еще. Обратите внимание, что количество создаваемых вами потоков не имеет никакого отношения к размеру очереди задач — вполне допустимо создать ThreadPool с четырьмя потоками и поместить в очередь к нему сотни задач. Но вряд ли будет хорошей затеей взять единую последовательную задачу и разбить ее на тысячи задач. Это указывает на то, что у вас слишком много «мелкозернистых» задач. Если вы окажетесь в такой ситуации, просто создайте задачу, которая будет отвечать за планирование следующих 100 задач.Или, если вы используете одну из библиотек, поддерживающих задачи, создайте задачу перехвата работы (work stealing), встраиваемую задачу (inlining task) либо задачу второго эшелона (follow-on task).

Мой класс Task (обратите внимание на заглавную букву «T», которой я подчеркиваю, что это мой класс-оболочка задачи, показанный на рис. 4) допускает зависимость от других Task. Мне пришлось добавить такую возможность самостоятельно, так как ThreadPool в операционной системе этого не поддерживает. Таким образом, когда Task запускает выполнение потока, первым делом он проверяет, есть ли зависимости. Если да, поток, выполняющий данную задачу, блокируется в ожидании на SRWLock. Объект Task будет запланирован повторно только после освобождения SRWLock.

Рис. 4. Класс-оболочка Task

class ITask {
protected:
  vector<ITask*>    m_dependentsList;      // Those waiting for me
  ThreadSafe_Int32  m_dependencysRemaining;// Those I'm waiting for

  // The blocking event if we have dependencies waiting on
  SRWLOCK m_SRWLock; 
  CONDITION_VARIABLE m_Dependencies;

  void SignalDependentsImDone();
  ITask(); 
  virtual ~ITask();

public:  
  void blockIfDependenciesArePending();
  void isDependentUpon(ITask* pTask);

  unsigned int queryNumberDependentsRemaining()

  // A parent Task will call this
  void clearOneDependency();
  virtual void doWork(void*) = 0;
  virtual void* context() = 0;
};

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

В целом, планировать Task, который немедленно будет приостановлен, — не самая лучшая идея. Поскольку очередь задач в ThreadPool по большому счету построена по принципу FIFO, вам понадобиться первыми планировать задачи, у которых нет зависимостей. Если бы я писал код в расчете на оптимальную производительность, а не в иллюстративных целях, я добавил бы уровень, который передает пулу потоков только задачи без зависимостей. Это будет работать, поскольку блокированные потоки в конечном счете будут выгружены. В любом случае вам потребуется какой-то безопасный в многопоточной среде способ оповещения об окончании выполнения задачи, и применение SRWLocks в этой ситуации вполне допустимо. Включение этих случаев в класс Task вполне естественно:зачем писать специализированный код для обработки каждого из случаев?

По своей архитектуре Task может иметь любое количество зависимых Task. Обычно вы стремитесь по возможности к уменьшению или исключению любых ожиданий и используете такие инструменты, как Visual Studio Task List или Intel Graphics Performance Analyzers, которые помогают отслеживать причины любых ожиданий. Представленная здесь реализация является очень базовой системой поддержки задач, и ее нельзя использовать в высокопроизводительном коде. Это хороший код «песочницы» для первых экспериментов с многопоточностью, но при написании более эффективного кода вы должны смотреть в сторону TBB, TPL или PPL.

ThreadPool будет вызывать функцию WorkCallback, выполняющую какой-либо префиксный код (prefix code), который будет запрашивать структуру данных Task примерно так:

VOID CALLBACK ThreadPool::WorkCallback(
  PTP_CALLBACK_INSTANCE instance, void* pTask) {

  ITask * pCurrentTask = (ITask*) pTask;
  pCurrentTask->blockIfDependenciesArePending( );
  pCurrentTask->doWork(pCurrentTask->context() );
  pCurrentTask->SignalDependentsImDone();
}

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

  1. ThreadPool загружает работу в WorkCallback из своей внутренней очереди задач.
  2. Код запрашивает Task на наличие любых зависимостей (родительских зависимостей). Если зависимости есть, выполнение блокируется.
  3. В отсутствие зависимостей следует вызов doWork, который является частью кода Task, уникальной для каждого Task.
  4. После возврата из doWork очистите любые дочерние зависимости в этом Task.

Важно отметить, что в моем классе ThreadPool есть некий начальный (preamble) и оконечный (postscript) код, проверяющий и очищающий зависимости в Task. Этот код запускается в каждом потоке, но каждый экземпляр этого кода сопоставлен с уникальным Task. Как только начальный код заканчивает свою работу, вызываются рабочие функции Task.

Создание класса-оболочки Task для собственных задач

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

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

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

Вот базовый обзор того, как я спроектировал свои классы Task и ThreadPool. В конечном счете я пришел именно к таким проектам, потому что пул потоков, встроенный в операционную систему, не поддерживает часть нужной мне функциональности, а я хотел предоставить вам код для экспериментов, в котором вы могли бы управлять механизмом зависимостей. Изначально у меня была гораздо более сложная оболочка ThreadPool, которая включала очередь с поддержкой приоритетов, но потом я понял, что усложнять вещи нет никакой нужды и что мне достаточно простых отношений «дочерняя-родительская зависимость». Если у вас действительно есть желание посмотреть на настоящий нестандартный планировщик задач, прочтите статью Джо Даффи (Joe Duffy) «Building a Custom Thread Pool (Part 2): A Work Stealing Queue» по ссылке tinyurl.com/36k4jcy.

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

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

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

Оболочка ThreadPool и класс Task работали именно так, как и было задумано. Я использовал эти классы в некоторых модульных тестах, чтобы убедиться в правильности их поведения. Я также оснастил их средствами мониторинга, используя утилиту Intel Graphics Performance Analyzers, которая, в том числе, позволяет динамически помечать потоки и изучать, какие части кода выполняются в конкретном потоке. Эта визуализация хода выполнения потоков позволила мне удостовериться, что потоки выполнялись, блокировались и заново планировались так, как и ожидалось.

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

Пул мышей

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

По проекту, каждая мышь была клоном своего предка, поэтому каждая мышь наследовала весь обратный путь до точки своего рождения. Мыши этого не знали, но я намеренно написал алгоритмы генерации лабиринтов (maze-generation algorithms), пытаясь выбрать дальнейший возможный путь. Нет смысла делать его легким для них. Тестовая программа обеспечивала выбор между разными алгоритмами генерации лабиринтов — от исходного (который генерировал длинные коридоры с изредка встречающимися ответвлениями, время от времени заводящими в тупики) до таких, где коридоры были короткими, а ответвлений было очень много. Экспериментируя со столь разными лабиринтами, можно выявить весьма значительную разницу в поведении между однопоточным и многопоточным решениями.

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

Figure 5 Solving the Original Long-Maze Algorithm

Рис. 5. Решение по исходному алгоритму с длинными коридорами в лабиринте

На рис. 6 показан лабиринт с большим количеством ответвлений. Теперь появляется гораздо больше возможностей для порождения мышей и выполнения ими параллельного поиска. Рис. 6 иллюстрирует многопоточное решение для этого лабиринта с короткими коридорами, где время поиска удалось уменьшить на 95% за счет использования большего числа задач.

Figure 6 Multiple Mice Make It Easy to Solve the Maze in Much Less Time

Рис. 6. Создание множества мышей упрощает и намного ускоряет поиск выхода из лабиринта

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

Заключение

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

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

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

Чтобы добиться максимальной, масштабируемой производительности приложения, используйте преимущества одной из существующих параллельных библиотек и ищите наилучшее соответствие потребностей приложения различным архитектурам, предлагаемым этими библиотеками. Неуправляемые приложения, программы реального времени или требующие высочайшего быстродействия приложения обычно лучше всего работают с применением одного из интерфейсов, предоставляемых TBB, тогда как в управляемых приложениях выбор вариантов многопоточного выполнения шире при использовании .NET Framework 4. В любом случае выбор одного из этих API работы с потоками будет определять общую структуру вашего приложения и то, как вы проектируете задачи для выполнения работы и их взаимодействия.

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

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

Рон Фоснер (Ron Fosner)  в течение 20 лет занимался оптимизацией высокопроизводительных приложений и игр на платформе Windows и здорово набил на этом руку. Эксперт в области графики и оптимизаций в Intel; чувствует прилив счастья, когда видит, что все ядра процессора равномерно нагружены. С ним можно связаться по адресу Ron@directx.com.

Выражаю благодарность за рецензировании статьи экспертам Арону Кодею (Aaron Coday), Ориен Грэнатир (Orion Granatir) и Брэду Уерту (Brad Werth)