Практика создания многопоточных приложений
В прошлом номере "Компьютерных вестей" речь шла о некоторых теоретических основах создания многопоточных приложений. Теперь давайте поговорим немного о практических аспектах многопоточного программирования.
Для начала стоит определиться с тем, на каком языке программирования и для какой операционной системы мы с вами будем писать многопоточное приложение. Признаться честно, я сначала хотел сразу рассказать про написание многопоточных приложений на каком-нибудь из тех современных языков, где предусмотрены специальные средства как раз для этого случая, - например, на Java. Сразу бы решился и вопрос, под какую операционную систему писать - с Java это не имеет значения, поскольку приложения полностью переносимы, была бы виртуальная машина под нужную ОС. Однако это было бы слишком скучно. Да и не всегда есть возможность использовать именно Java, и наверняка вопросы создания многопоточных приложений волнуют многих школьников и студентов, которые вряд ли знают этот язык... Поэтому я решил сначала рассказать про написание многопоточных приложений под Windows на Delphi, а затем уже и о написании их на Java, чтобы вы имели возможность самостоятельно сравнить и почувствовать, чем подход к многопоточным приложениям в Delphi отличается от подхода к ним же в Java.
Конечно, вряд ли удастся уложиться в один номер, но тема, как мне кажется, того стоит. Так что давайте не будем долго рассусоливать, а начнём сразу с места в карьер.
Начало
Итак, для начала стоит несколько слов сказать об особенностях многопоточных приложений, которые пишутся на Delphi. Особенностей, в общем-то, не сказать, чтобы было так уж сильно много, однако, сами понимаете, они в том или ином виде существуют везде, и без них просто никак.
Delphi - это, как вам, вероятно, известно, язык программирования объектно-ориентированный, со всеми вытекающими из данного факта последствиями. Кроме того, Delphi славится своей мощной библиотекой для создания приложений, и в этой библиотеке попросту не может не быть специальных объектов для создания многопоточных приложений. И такой класс, хотите - верьте, хотите - нет, действительно в ней существует и называется TThread. И именно им мы с вами и воспользуемся при создании нашего небольшого многопоточного приложения.
Создатели среды Delphi, видимо, догадывались, что время от времени тем, кто программирует в ней, потребуется создавать многопоточные приложения, а потому в списке новых файлов в старенькой Delphi 5, с незапамятных времён обосновавшейся у меня на компьютере, есть пункт "Thread Object", который поможет создать новый модуль с классом, являющимся потомком класса TThread. Не знаю, как называется и где располагается этот пункт в новых версиях Delphi или CodeGear RAD Studio, да и существует ли он вообще, но это, в принципе, не так уж и существенно по той простой причине, что весь тот код, который генерирует нам в помощь среда разработки, очень прост и совсем не так объёмен, чтобы было сложно написать его самостоятельно.
Итак, у нас есть потомок TThread (я назвал его SampleThread, но как его назовёте вы - это, в принципе, не имеет никакого значения). Что же дальше? Дальше мы должны нарастить на скелет, щедро предоставленный нам средой и объектной библиотекой Delphi, "мясо" - сиречь, программный код. Самый главный метод, который должен быть реализован в каждом классе, который наследуется от класса TThread, - это метод Execute. У него нет никаких параметров, и вызывается он приложением автоматически тогда, когда выполнение перейдёт потоку, который реализуется с помощью экземпляра данного класса. Помимо этого, конечно же, в классе потока могут быть и другие методы, и поля, и свойства, поскольку это такой же класс, как и все остальные в Delphi. Но метод Execute обязателен для реализации, даже если вы оставите его пустым, поскольку у родительского класса, TThread, он объявлен абстрактным, и отсутствие его в классе-наследнике вызовет ошибку ещё на этапе компиляции программы. Да и смысла, честно говоря, в таком потоке, который ничего не делает (а делать что-то без метода Execute он просто не может), скажем прямо, немного.
Собственно, практика
Что будет делать наш поток? Честно говоря, никакой толковой функциональности, достаточно простой в реализации, пока что не придумывается, а потому будем обходиться чем-нибудь не слишком толковым, но зато простым. Пусть, к примеру, наш поток отображает на главном окне приложения, сколько именно времени прошло с момента его запуска пользователем.
Что ж, давайте напишем код, который будет хоть что-нибудь делать в нашем многопоточном приложении, а потом уже будем разбираться с тем, что именно он делает. Этот код вы можете увидеть в листинге.
procedure SampleThread.Execute; begin repeat Synchronize(SetThreadInfo); Sleep(1000); Inc(Timer); until Terminated; end; procedure SampleThread.SetThreadInfo; begin Form1.Caption := 'Seconds passed since program''s start: ' + IntToStr(Timer); end;
В общем-то, в листинге, скажем прямо, немало различных тайн. Начиная с того, что вместо одного обещанного метода Execute здесь присутствует два, и заканчивая содержанием того самого метода Execute. Что ж, давайте, как и было обещано, разбираться со всем этим.
Сначала, конечно же, метод Execute. В нём мы видим цикл, который будет выполняться до тех пор, пока приложение не даст сигнал потоку завершиться. Конечно, можно придумать и другое условие выхода из этого цикла, связанное с тем, над чем именно трудится данный отдельно взятый поток, однако оно также должно включать в себя проверку встроенного в программу флага состояния Terminated. Без цикла наш метод, выполнившись один раз, на этом и завершил бы свою работу, но это ведь не совсем то, что от него требуется. Поэтому каждый поток в многопоточном приложении должен работать циклично, что мы и видим в этом простом примере.
Что касается процедуры Sleep, то это стандартная системная функция, которая прекращает выполнение приложения на заданное число миллисекунд. У нас она используется в отдельном потоке и, как следствие, выполнение самого приложения никак не страдает от её применения - вы сами в этом можете убедиться, создав полностью аналогичное рассматриваемому нами здесь приложение с помощью Delphi, немного "покрутив" его. Переменная Timer - это глобальная целочисленная переменная, используемая нами для подсчёта количества секунд, прошедших с момента запуска приложения. Процедура Inc увеличивает значение своего аргумента на единицу.
Теперь давайте посмотрим на второй метод нашего класса и на конструкцию Synchronize (SetThreadInfo). Дело в том, что потоки, обращающиеся к общим для них объектам, нужно каким-то образом синхронизировать - об этом я уже говорил в прошлой статье. Однако, к счастью, вручную нам с вами ничего синхронизировать не потребуется - за нас всё сделает объектная библиотека Delphi. Думаю, это хорошая новость, потому что всё, что нам остаётся, это заключить все обращения к общим для потоков объектам в Syncronize(). У этого метода класса TThread в качестве параметра должен быть метод класса-потомка, а потому нам и пришлось выносить отображение времени, прошедшего со старта программы, в отдельный метод.
Инициализация потоков
Однако, написав это всё, работоспособную программу мы с вами всё ещё не получим. Вернее, получим, но она всё ещё будет однопоточной. Почему? Потому что поток ещё нужно инициализировать и запустить, чтобы он смог заработать. Делается это, в общем-то, также весьма и весьма просто - а как именно, вы можете увидеть в листинге.
procedure TForm1.FormCreate(Sender: TObject); begin T := SampleThread.Create(true); T.Resume; T.Priority:=tpLower; end;
Что ж, здесь всё действительно очень и очень просто, а потому не стоит искать какие-то дополнительные сложности. Мы декларируем переменную класса SampleThread (локально в модуле, где находится главная форма приложения), а затем просто создаём в момент создания самой этой формы и новый поток. После создания мы передаём ему управление и изменяем его приоритет (хотя это и не принципиально, но в реальных многопоточных приложениях работа с приоритетами потоков - это действительно тонкий и очень важный момент, на который нужно обратить особое внимание). Как видите, и здесь Delphi большую часть работы снял с наших плеч - нам нужно лишь создать экземпляр класса и немного его инициализировать.
Однако, конечно, помимо инициализации, есть в жизни потока и противоположная ей фаза - завершение работы. В общем-то, здесь тоже всё более чем просто, однако для полноты рассказа, думаю, имеет смысл привести код, который завершает работу нашего потока. Он приведен в листинге.
procedure TForm1.FormDestroy(Sender: TObject); begin T.Terminate; end;
Что ж, думаю, разбирать особо подробно этот простой в силу своей краткости код смысла не имеет - мы просто при уничтожении формы вызываем метод Terminate, который и завершает работу потока-хронометра.
Как видите, большую часть грязной работы за нас сделала стандартная библиотека Delphi. Я уже об этом говорил, но хочу ещё раз обратить ваше внимание. Если бы мы взялись писать многопоточное приложение с использованием исключительно и только чистого Windows API, мы бы, конечно же, столь малым объёмом кода отделаться уже не смогли. Но в наше время на чистом API уже ничего существенного не пишут, а потому и в использовании возможностей VCL нет ничего зазорного.
Резюмируем
Что ж, давайте как-нибудь подытожим всё сказанное выше. Во-первых, как видите, с применением специальных библиотек, облегчающих жизнь программисту, создание многопоточных приложений становится не такой уж и сложной задачей, какой оно могло бы показаться после прочтения предыдущего материала. Хотя, в принципе, это можно сказать и вообще о любых задачах в программировании, поскольку гораздо выгоднее использовать специально написанную профессионалами библиотеку, чем самому изобретать велосипед, который заведомо будет экономически невыгоден и, кроме того, проиграет в качестве. Впрочем, обо всём этом я неоднократно говорил.
Во-вторых, многопоточное приложение, которое мы с вами создали, замечательно иллюстрирует саму идею разделения потоков: хотя выполнение вспомогательного потока-счётчика и приостанавливается функцией Sleep, на главный поток программы это никакого влияния не оказывает. Ну, а в-третьих, мы с вами теперь умеем создавать многопоточные приложения на Delphi. Хоть и простые, но, как говорится, лиха беда начало!
Вадим СТАНКЕВИЧ,
[email protected]
Комментарии
Метод Synchronize, хотя и называется Synchronize, но на самом деле ничего не синхронизирует. Если возьмете за труд и почитаете справку, то узнаете что этот метод "Executes a method call within the main thread." Т.е. выполняет ваш метод в главном потоке (его еще называют VCL-поток). И чуть далее: "This is the thread that handles all Windows messages received by components in your application."
А теперь представьте что у вас 15 потоков (кстати вполне реальная ситуация) и они используют такой способ синхронизации как вызов Synchronize. Главный поток будет обслуживать эти 15 потоков и у него просто не будет времени уделять внимание работе с пользовательским интерфейсом и сообщениям WIndows. На выходе - жуткие тормоза.
Пример в газете правильный и даже рабочий, но неудачный - не соответствует теме статьи. Если говорить про синхронизацию потоков, надо было уделить внимание критическим секциям, событиям (TEvent) и прочим семафорам.
А метод Synchronize следует использовать ТОЛЬКО для доступа к элементам интерфейса - кнопкам, меню и, как в примере, заголовку окна. Компоненты VCL устроены так, что использовать их можно только в главном (VCL) потоке. Вот для этого и нужен Synchronize. А для синхронизхации потоков, как я говорил, используются другие механизмы - критические секции и пр.
Кстати, если попытаться использовать VCL компоненты в не-главном потоке, то в лучшем случае получите пачку exception-ов. (Впрочем, любопытные могут поэксперементировать. У меня например получались интересные визуальные эффекты)
Не только. Применимо ко всему, что должно выполняться АТОМАРНО. Польза от возникающего при этом подтормаживания юзерского интерфейса - "защита от дурака".