Написать интересную статью на техническую тему очень сложно. Приходится балансировать между тем, чтобы не скатиться в технические дебри и тем, чтобы совсем ничего не сказать. Сегодня я попробую в общих словах (без деталей) поговорить о том, как обстоят дела с разработкой многопоточных desktop-приложений в не столь популярной на сегодняшний день, но наверняка знакомой многим российским разработчикам среде Delphi. Статья ориентирована на НЕ новичков в программировании, являющихся при этом новичками в области создания многопоточных приложений.
Затронутая в заголовке тема очень обширна. Все, что будет написано ниже, — это даже не верхушка айсберга, это скорее полет на высоте 10000 метров над океаном, в котором эти айсберги плавают. Зачем писать такую статью? Скорее для того, чтобы обратить внимание на широкие возможности, которые уже давно доступны, но которых почему-то многие побаиваются и сторонятся.
Почему Delphi?
Я программирую на Delphi очень давно и не перестаю наслаждаться. Это во многих отношениях замечательный язык. Его уникальность в том, что он одновременно позволяет создавать код сколь угодно высокого уровня, при этом оставаясь «близким к железу», т.к. на выходе мы получаем native-приложение, а не код для виртуальной машины Java или .Net. И при этом язык Delphi очень прост и лаконичен, код на нем приятно читать и в нем достаточно легко разобраться, чего не могу сказать о коде на C или C++ (при всем моем великом уважении к разработчикам на C, хотя кто-то скажет, что это лишь дело привычки).
В настоящий момент Delphi утратил былую популярность. Вероятно, произошло это из-за того, что в 2000-х годах данный продукт был на несколько лет практически заброшен разработчиками, в результате чего он на какое-то время выпал из конкурентной гонки сред разработки. Действительно, после Delphi 7, выпущенного фирмой Borland в 2002-м году, более менее стабильный продукт появился лишь в 2007-м. Это был CodeGear Delphi 2007, выпущенный фирмой CodeGear, являющейся дочерней компанией Borland. Все версии между Delphi 7 и Delphi 2007 были практически непригодны к использованию. В 2008-м Borland продала подразделение CodeGear фирме Embarcadero Technologies, которая (за что ей особое спасибо!) незамедлительно начала превращать то, что ей досталось, в современную качественную среду разработки. Актуальной версией Delphi на момент написания статьи является Embarcadero Delphi XE2, выпущенная в сентябре 2011 года. Благодаря достаточно высокому качеству последних версий Delphi, данная среда разработки постепенно отыгрывает утраченные позиции.
Зачем нам многопоточность?
Люди хотели выполнять на компьютере несколько задач одновременно. Это называют многозадачностью. Реализуется многозадачность средствами операционной системы. Но если ОС умеет выполнять одновременно несколько приложений, почему бы и одному приложению внутри себя тоже не выполнять сразу несколько задач. Например, при архивации большого списка файлов архиватор может одновременно читать следующий файл, в это время в памяти архивировать текущий прочитанный и записывать результат в выходной файл на диске. Т.е. вместо того, чтобы в одном потоке выполнять над каждым файлом последовательно действия «прочитать» -> «заархивировать» -> «записать результат на диск», можно запустить 3 потока, один из которых будет читать файлы в память, второй поток — архивировать, а третий — сохранять на диск. Другим примером является выполнение в фоне какой-то малоприоритетной задачи — например, фоновое сохранение резервной копии файла, открытого в текстовом редакторе.
Если бы процессоры продолжали увеличивать свою тактовую частоту теми же темпами, как это происходило в 90-х и начале 2000-х годов, можно было бы не заморачиваться с многопоточностью и продолжать писать классический однопоточный код. Однако в последние годы процессоры перестали активно увеличивать скорость одного ядра, но зато начали наращивать количество самих этих ядер. Чтобы использовать потенциал современных процессоров на 100% без многопоточности просто не обойтись.
Почему сложно писать многопоточный код?
1) Легко допустить ошибку.
Когда на компьютере выполняется одновременно несколько приложений, адресное пространство (память) каждого процесса надежно изолировано от других процессов операционной системой и влезть в чужое адресное пространство довольно сложно. С потоками внутри одного процесса наоборот — все они работают с общим адресным пространством процесса и могут изменять его произвольным образом. Поэтому в многопоточном приложении приходится самостоятельно реализовывать защиту памяти и синхронизацию потоков, что приводит к необходимости написания относительно сложного, но при этом не несущего полезной нагрузки кода. Такой код называют «boilerplate» (сковородка), потому что сковородку надо сначала приготовить перед тем, как начнешь на ней что-то жарить. Именно необходимость написания «нестандартного» boilerplate-кода сдерживает развитие многопоточных вычислений. Для синхронизации потоков предусмотрено множество специальных механизмов: потокозащищенные (interlocked) команды процессора, объекты синхронизации операционной системы (критические секции, мьютексы, семаформы, события и т.п.), spin locks и т.д.
2) Код многопоточного приложения сложно анализировать.
Одна из сложностей многопоточного приложения состоит в том, что просматривая код многопоточного приложения визуально не понятно, может ли какой-то конкретный метод вызываться (и вызывается ли) из разных потоков. Т.е. вам придется держать в голове, какие методы могут вызываться из разных потоков, а какие нет. Поскольку делать абсолютно все методы потокозащищенными — это не вариант, всегда есть шанс нарваться на ошибку, вызвав из нескольких потоков метод, не являющийся потокозащищенным.
3) Многопоточное приложение сложно отлаживать.
В многопоточном приложении множество ошибок может возникать при определенном состоянии параллельно выполняющихся потоков (как правило, при последовательности команд, выполненных в разных потоках). Интересный пример описан тут (http://www.thedelphigeek.com/2011/08/multithreading-is-hard.html). Воссоздать такую ситуацию искусственно зачастую очень сложно, практически нереально. К тому же в Delphi инструментов для отладки многопоточных приложений не очень много, Visual Studio в этом плане явный лидер.
4) В многопоточном приложении сложно обрабатывать ошибки.
Если приложение имеет графический интерфейс пользователя, то взаимодействовать с пользователем может только один поток. Обычно, когда в приложении происходит какая-то ошибка, мы либо обрабатываем ее внутри приложения, либо показываем сообщение пользователю. Если же ошибка происходит в дополнительном потоке, он не может ничего сказать пользователю «немедленно». Соответственно, приходится сохранять ошибку, произошедшую в дополнительном потоке, до момента его синхронизации с основным потоком и лишь потом выдавать пользователю. Это может приводить к относительно сложной и запутанной структуре кода.
Есть ли способ хоть немного упростить себе жизнь?
Представляю вашему вниманию OmniThreadLibrary (сокращенно OTL). OmniThreadLibrary — это библиотека для создания многопоточных приложений в Delphi. Ее автор — Primoz Gabrijelcic из Словении — непревзойденный профессионал с многолетним стажем разработки приложений на Delphi. OmniThreadLibrary — это абсолютно бесплатная библиотека с открытыми исходными кодами. В настоящий момент библиотека находится уже в достаточно зрелой стадии и вполне пригодна для использования в серьезных проектах.
Где найти информацию по OTL?
Также автор библиотеки сейчас занимается наполнением wiki-книги про OmniThreadLibrary и многопоточность, уже готовы статьи про большинство высокоуровневых примитивов OTL.
Какие возможности предоставляет OTL?
Данная библиотека содержит в себе низкоуровневые и высокоуровневые классы, позволяющие упрощенно управлять многопоточностью, не вдаваясь в подробности процессов создания/освобождения/синхронизации потоков на уровне WinAPI.
Особенный интерес представляют высокоуровневые примитивы для упрощенного управления многопточностью. Они примечательны тем, что их сравнительно легко интегрировать в готовое однопоточное приложение, практически не меняя структуры исходного кода. Данные примитивы позволяют создавать многопоточные приложения, концентрируясь на полезном коде приложения, а не на вспомогательном коде для управления многопоточностью.
К основным высокоуровневым примитивам относятся Future (асинхронная функция), Pipeline (конвейер), Join (параллельный вызов нескольких методов), ForkJoin (рекурсия с параллелизмом), Async (асинхронный метод), ForEach (параллельный цикл).
На мой взгляд, самыми интересными и полезными примитивами являются Future и Pipeline, т.к. для их использования имеющиеся код почти не нужно переписывать.
Future
Данный примитив позволяет выполнить асинхронный вызов функции и в нужный момент дождаться завершения расчета и получить результат выполнения. С помощью данного примитива вызов любой процедуры или функции можно безболезненно превратить в асинхронный.
Выглядит это примерно так:
uses
OtlParallel;
...
procedure TestFuture;
var
vFuture: IOmniFuture<integer>;
begin
// Запускаем вычисления в параллельном потоке
vFuture := Parallel.Future<integer>(
function: integer
var
i: integer;
begin
Result := 0;
for i := 1 to 100000 do
Result := Result + i;
end
);
// Здесь делаем какие-то вычисления в основном потоке (в это время параллельный поток работает (он может еще не запустился, а может уже завершился, но мы об этом ничего пока не знаем)
// Теперь нам понадобилось узнать результат, полученный в параллельном потоке
ShowMessage(IntToStr(vFuture.Value));
end;
Обратите внимание, что именно обращение к vFuture.Value является моментом синхронизации основного потока с дополнительным, т.е. до тех пор, пока мы не обратимся к Value мы вообще ничего не знаем о состоянии другого потока. Как только мы вызвали Value, основной поток приостанавливается до момента завершения расчета в дополнительном потоке.
Если требуется, можно в основном потоке реализовать неблокирующее ожидание результата:
while not vFuture.IsDone do
Application.ProcessMessages;
Таким образом, примитив Future позволяет асинхронно выполнить какую-то задачу и вернуть результат в основной поток именно в тот момент, в который он там потребуется.
Pipeline
Pipeline (конвейер) — это гораздо более мощный примитив по сравнению с Future.
Представьте, что некий алгоритм выполняется в цикле для множества элементов. Например, производится какая-то обработка файлов в каталоге. Однопоточная программа будет брать очередной файл, прочитывать его, выполнять какие-то действия и сохранять измененный файл на диск. Имея конвейер можно исходный алгоритм разделить на этапы (чтение, обработка, сохранение) и запустить эти этапы в параллельных потоках. В самом начале запустится лишь самый первый этап и прочитает первый файл. Как только чтение завершится, запустится второй этап и начнет обработку прочитанного файла или его порции (если первый этап читает файлы не целиком а порциями). В это время первый этап уже начнет читать второй файл. Как только второй этап обработает первый файл, подключится третий этап и начнет сохранение. В этот момент мы получим состояние, при котором все три этапа работают параллельно.
Пример для Pipeline, близкий к реальной жизни, слишком загрузил бы статью, поэтому для иллюстрации использования Pipeline ограничиваюсь копией абсолютно синтетического примера из OtlBook (чур сильно не бить!):
uses
OtlCommon,
OtlCollections,
OtlParallel;
var
sum: integer;
begin
sum := Parallel.Pipeline
.Stage(
procedure (const input, output: IOmniBlockingCollection)
var
i: integer;
begin
for i := 1 to 1000000 do
output.Add(i);
end)
.Stage(
procedure (const input: TOmniValue; var output: TOmniValue)
begin
output := input.AsInteger * 3;
end)
.Stage(
procedure (const input, output: IOmniBlockingCollection)
var
sum: integer;
value: TOmniValue;
begin
sum := 0;
for value in input do
Inc(sum, value);
output.Add(sum);
end)
.Run.Output.Next;
end;
В данном примере первый этап генерирует миллион чисел, передавая их по одному на следующий этап. Второй этап умножает каждое число на 3 и передает на третий этап. Третий этап суммирует результаты и возвращает одно число. Каждый Stage выполняется в своем потоке. Более того, Otl позволяет указать, какое число потоков для каждого Stage'а использовать (если одного мало) за счет простого модификатора .NumTasks(N). Возможности OTL действительно очень широки.
Базовым классом для поддержки обмена данными между этапами конвейера является класс потокозащищенной очереди — TOmniBlockingCollection. Данный класс позволяет нескольким потокам одновременно добавлять и считывать элементы. Высокая скорость работы коллекции достигается за счет хитрого управления памятью и применения блокировок на основе потокозащищенных инструкций процессора вместо блокировок на основе объектов синхронизации ОС. О подробностях реализации класса TOmniBlockingCollection можно почитать тут, тут и тут.
Заключение
Кто-то посмотрев на приведенные примеры скажет «да я все это уже видел». Действительно, в Task Parallel Library для .Net Framework 4 присутствуют примерно такие же классы. При этом существует ряд различий между тем, как исполняются потоки внутри машины .Net и как потоки исполняются на реальном процессоре. Рассмотрение данных различий — за пределами этой статьи. Я лишь хотел акцентировать внимание на замечательной библиотеке, и тех широких возможностях которые она предоставляет Delphi разработчикам. Хочу отметить, что библиотека снабжена большим количеством примеров, иллюстрирующих использование как низкоуровневых, так и высокоуровневых классов.
Чтобы развеять опасения по поводу зрелости и надежности данной библиотеки, скажу лишь, что за счет использования Pipeline в сложном коммерческом многопользовательском приложении (не web) удалось сократить время выполнения операции над группой файлов на клиенте почти в два раза за счет разнесения по отдельным потокам обработку файлов на клиенте и их передачу на сервер. Использовать ли связку Delphi + OmniThreadLibrary в ваших проектах — решать вам ;)
Автор: alan008