Многопоточность: синхронизация

Часть первая. Критические секции

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

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

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

И начнём с критических секций, поскольку этот вариант синхронизации считается самым простым.

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

Здесь, правда, есть маленькое "но", которое вряд ли можно назвать приятным сюрпризом для тех, кто привык к библиотеке VCL и не слишком любит работать с родными интерфейсами программирования операционной системы Windows: для того, чтобы работать с критическими секциями, нам нужно будет прибегнуть всё-таки именно к Windows API. Не столько, впрочем, нужно в силу ограничений VCL, сколько в силу того, что статья предназначена не только для "дельфистов", и потому использование API-функций будет более удачным примером в силу того, что и в других языках программирования можно будет применять показанные приёмы. Да и, в общем-то, как вы сами сможете убедиться, ничего особенно страшного в том, чтобы воспользоваться WinAPI, нет, но многих из тех, кто только начинает разбираться с Delphi, WinAPI почему-то пугает. Впрочем, думаю, если вы уже заинтересовались написанием многопоточных приложений (а иначе зачем вы стали бы читать эту статью?), то такая вещь, как функции Windows API, вряд ли может вас остановить.

Нам потребуются специальные функции для работы с критическими секциями - впрочем, их совсем немного, всего четыре. Первая - это InitializeCriticalSection. Как видно из названия, она занимается тем, что инициализирует критическую секцию. Фактически она создаёт дескриптор критической секции, который, в отличие от обычных дескрипторов, имеет в Delphi тип не THandle (или просто integer), а TRTLCriticalSection. Что именно находится внутри этого типа, нас, в общем-то, особенно не интересует, а даже если и интересовало бы, мы вряд ли могли узнать очень многое. Функция InitializeCriticalSection создаёт и инициализирует критическую секцию, которую мы затем можем использовать с помощью других двух функций, предназначенных для работы с ними - EnterCriticalSection и LeaveCriticalSection. Хотя названия этих функций также являются "говорящими", стоит отметить, что критическая секция - это именно тот участок кода, который будет располагаться между вызовами этих двух функций. Обе они имеют всего один параметр - да-да, это именно тот самый дескриптор (хотя, наверное, было бы всё же грамотнее говорить здесь "идентификатор"), который мы с вами создаём с помощью вызова функции InitializeCriticalSection. После того, как критическая секция отработала и перестала быть актуальной и нужной, мы удаляем её с помощью функции DeleteCriticalSection. Она также имеет только один параметр, который, как нетрудно догадаться, содержит всё тот же идентификатор.

Где следует располагать критическую секцию? В общем-то, ответ очевиден: в методе Execute того класса потока, который и будет у нас работать в режиме эксклюзивного доступа к определённому ресурсу.

Для того, чтобы продемонстрировать вам работу критических секций в многопоточном приложении, я вновь обращусь к тому примеру с таймером, который мы рассматривали ранее в нашей практике по Delphi (во второй статье про многопоточность). Давайте модифицируем наш класс SampleThread так, как показано в листинге 1.

SampleThread = class(TThread)
private
 Timer: integer;
protected
procedure Execute; override;
procedure SetThreadInfo;
public
 fInternalID: string; 
end;

Если вы ещё не забыли, о чём мы говорили в тот раз, то заметите, что здесь у нас появилось в секции public новое поле - fInternalID. Конечно, это не очень хорошо - делать поля в public-секции (обычно рекомендуют делать поля в private, а в public выносить соответствующие им свойства), но, думаю, в этом нет ничего крамольного. Это поле нужно для того, чтобы вы могли различить, какой именно поток в настоящее время выводит информацию о числе прошедших секунд в заголовок главного окна: значение поля fInternalID будет через пробел выводиться после информации о времени: Form1.Caption := 'Seconds passed since program start: ' + IntToStr(Timer) + ' ' + fInternalID;.

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

procedure TForm1.FormCreate(Sender: TObject);
begin
 T1 := SampleThread.Create(true);
 T2 := SampleThread.Create(true);
 T1.Priority:=tpLower;
 T2.Priority:=tpLower;
 T1.fInternalID := 'Thread 1';
 T2.fInternalID := 'Thread 2';
 T1.Resume;
 T2.Resume;
end;

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

Теперь, когда вы обновите программу, откомпилируете её и запустите, то сможете наблюдать любопытный эффект: иногда надпись в заголовке окна будет заканчиваться на "Thread 1", а иногда на "Thread 2". Причём в переключении с одного потока на другой вы вряд ли сможете уловить какую-либо закономерность - оно может происходить чаще или реже при различных запусках приложения, но и здесь закономерность не наблюдается. Естественно, надпись оставляет поток, который отрабатывает вторым. С помощью этого несложного примера вы можете убедиться, что в многопоточном приложении без синхронизации потоки переключаются как то угодно системе, и программист никогда не сможет предугадать, когда произойдёт переключение с одного потока на другой.

Теперь давайте обратимся к критическим секциям и модифицируем код нашего приложения таким образом, чтобы нашлось место критической секции, раз уж именно о ней мы сегодня с вами говорим. Для этого в интерфейсной секции модуля с классом потока объявим переменную cs: TRTLCriticalSection, а код метода Execute этого самого класса модифицируем так, как показано в листинге 3.

procedure SampleThread.Execute;
begin
 EnterCriticalSection(cs);
repeat
 Synchronize(SetThreadInfo);
 Sleep(1000);
 Inc(Timer);
 until Terminated;
 LeaveCriticalSection(cs);
end;

Нужно ещё добавить немного кода в процедуры инициализации и финализации работы потоков. В первую добавим строку InitializeCriticalSection(cs); во вторую, соответственно, наоборот - DeleteCriticalSection(cs). После чего скомпилируем и запустим приложение.

Как видите, критическая секция действительно начала действовать - в заголовке окна надпись всё время оканчивается на "Thread 1". Второй поток же, фактически, всё время работы нашей демонстрационной программы проводит в бесплодном ожидании того счастливого момента, когда критическая секция наконец-то освободится и он сможет тоже что-нибудь написать в заголовок окна.

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

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

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

(Продолжение следует)

Вадим СТАНКЕВИЧ

Версия для печатиВерсия для печати

Номер: 

02 за 2009 год

Рубрика: 

Software
Заметили ошибку? Выделите ее мышкой и нажмите Ctrl+Enter!

Комментарии

Аватар пользователя Инкогнито
Пример очень и очень неудачный. Он не демонстрирует принципработы крит. секции

Такая вот конструкция

EnterCriticalSection(cs);

Synchronize(SetThreadInfo);

LeaveCriticalSection(cs);

похожа, пардон, на презерватив на вибраторе - толку никакова.

И, раз уж говорить про пример, то имхо манипуляции с CS надо все таки поместить ВНУТРЬ цикла, а не снаружи. ВОт так:

repeat

EnterCriticalSection(cs);

Synchronize(SetThreadInfo);

LeaveCriticalSection(cs);

Sleep(1000);

Inc(Timer);

until Terminated;

Аватар пользователя Инкогнито
Бред. Вредно для чтения.
Аватар пользователя mike
Парни, может лучше обсудим, чем дышло отличается от оглобли?