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

Пример посторения простого многопоточного приложения.

Рожден о причине большого числа вопросов о построении многопоточных приложений в Delphi.

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

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

Итак пример. Для удобства поместил и код, и прикрепил архив с кодом модуля и формы

unit ExThreadForm;

uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, StdCtrls;

// константы используемые при передаче данных из потока в форму с помощью
// отсылки оконных сообщений
const
WM_USER_SendMessageMetod = WM_USER+10;
WM_USER_PostMessageMetod = WM_USER+11;

type
// описание класса потока, потомка от tThread
tMyThread = class(tThread)
private
SyncDataN:Integer;
SyncDataS:String;
procedure SyncMetod1;
protected
procedure Execute; override;
public
Param1:String;
Param2:Integer;
Param3:Boolean;
Stopped:Boolean;
LastRandom:Integer;
IterationNo:Integer;
ResultList:tStringList;

Constructor Create (aParam1:String);
destructor Destroy; override;
end;

// описание класса использующей поток формы
TForm1 = class(TForm)
Label1: TLabel;
Memo1: TMemo;
btnStart: TButton;
btnStop: TButton;
Edit1: TEdit;
Edit2: TEdit;
CheckBox1: TCheckBox;
Label2: TLabel;
Label3: TLabel;
Label4: TLabel;
procedure btnStartClick(Sender: TObject);
procedure btnStopClick(Sender: TObject);
private
{ Private declarations }
MyThread:tMyThread;
procedure EventMyThreadOnTerminate (Sender:tObject);
procedure EventOnSendMessageMetod (var Msg: TMessage);message WM_USER_SendMessageMetod;
procedure EventOnPostMessageMetod (var Msg: TMessage); message WM_USER_PostMessageMetod;

Public
{ Public declarations }
end;

var
Form1: TForm1;

{
Stopped - демонстрирует передачу данных от формы к потоку.
Дополнительной синхронизации не требует, поскольку является простым
однословным типом, и пишется только одним потоком.
}

procedure TForm1.btnStartClick(Sender: TObject);
begin
Randomize(); // обеспечение случайнсти в последовательности по Random() - к потоком отношения не имеет

// Создание экземпляра объекта потока, с передачей ему входного параметра
{
ВНИМАНИЕ!
Конструктор потока написан таким образом что поток создается
приостановленным, поскольку это позволяет:
1. Контролировать момент его запуска. Это почти всегда удобнее, т.к.
позволяет еще до запуска настроить поток, передать ему входные
параметры, и т.п.
2. Т.к. ссылка на созданный объект будет сохранена в поле формы, то
после самоуничтожения потока (см.ниже) которое при запущенном потоке
может произойти в любой момент, эта ссылка станет недействительной.
}
MyThread:= tMyThread.Create(Form1.Edit1.Text);

// Однако, поскольку поток создан приостановленным, то при любых ошибках
// во время его инициализации (до запуска), мы должны его сами уничтожить
// для чегу используем try / except блок
try

// Назначение обработчика завершения потока в котором будем принимать
// результаты работы потока, и "затирать" ссылку на него
MyThread.OnTerminate:= EventMyThreadOnTerminate;

// Поскольку результаты будем забирать в OnTerminate, т.е. до самоуничтожения
// потока то снимем с себя заботы по его уничтожению
MyThread.FreeOnTerminate:= True;

// Пример передачи входных параметров через поля объекта-потока, в точке
// создания экземпляра, когда он еще не запущен.
// Лично я, предпочитаю делать это через параметры переопределяемого
// конструктора (tMyThread.Create)
MyThread.Param2:= StrToInt(Form1.Edit2.Text);

MyThread.Stopped:= False; // своего рода тоже параметр, но меняющийся во
// время работы потока
except
// поскольку поток еще не запущен и не сможет самоуничтожиться, уничтожим его "вручную"
FreeAndNil(MyThread);
// а дальше пусть исключительная ситуация обрабатывается обычным порядком
raise;
end;

// Поскольку объект потока успешно создан и настроен, настало время запустить его
MyThread.Resume;

ShowMessage("Поток запущен");
end;

procedure TForm1.btnStopClick(Sender: TObject);
begin
// Если экземпляр потока еще существует, то попросим его остановиться
// Причем, именно "попросим". "Заставить" в принципе тоже можем, но это будет
// исключительно аварийный вариант, требующий четкого понимания всей этой
// потоковой кухни. Поэтому, здесь не рассматривается.
if Assigned(MyThread) then
MyThread.Stopped:= True
else
ShowMessage("Поток не запущен!");
end;

procedure TForm1.EventOnSendMessageMetod(var Msg: TMessage);
begin
// метод обработки синхронного сообщения
// в WParam адрес объекта tMyThread, в LParam тек.значение LastRandom потока
with tMyThread(Msg.WParam) do begin
Form1.Label3.Caption:= Format("%d %d %d",);
end;
end;

procedure TForm1.EventOnPostMessageMetod(var Msg: TMessage);
begin
// метод обработки асинхронного сообщения
// в WParam тек.значение IterationNo, в LParam тек.значение LastRandom потока
Form1.Label4.Caption:= Format("%d %d",);
end;

procedure TForm1.EventMyThreadOnTerminate (Sender:tObject);
begin
// ВАЖНО!
// Метот обработки события OnTerminate всегда вызывается в контексте основного
// потока - это гарантируется реализацией tThread. Поэтому, в нем можно свободно
// использовать любые свойства и методы любых объектов

// На всякий случай, убедимся что экземпляр объекта еще существует
if not Assigned(MyThread) then Exit; // если его нет, то и делать нечего

// получение результатов работы потока экземпляра объекта потока
Form1.Memo1.Lines.Add(Format("Поток завершился с результатом %d",));
Form1.Memo1.Lines.AddStrings((Sender as tMyThread).ResultList);

// Уничтожение ссылки на экземпляр объекта потока.
// Поскольку поток у нас самоуничтожающийся (FreeOnTerminate:= True)
// то после завершения обрабтчика OnTerminate, экземпляр объекта-потока будет
// уничтожен (Free), и все ссылки на него станут недействительными.
// Что бы случайно не напороться на такую ссылку, затрем MyThread
// Еще раз замечу - не уничтожим объект, а только затрем ссылку. Объект
// уничтожится сам!
MyThread:= Nil;
end;

constructor tMyThread.Create (aParam1:String);
begin
// Создаем экземпляр ПРИОСТАНОВЛЕННОГО потока (см.коментарий при создании экземпляра)
inherited Create(True);

// Создание внутренних объектов (если необходимо)
ResultList:= tStringList.Create;

// Получение исходных данных.

// Копирование входных данных переданных через параметр
Param1:= aParam1;

// Пример получения входных данных из VCL-компонентов в конструкторе объекта-потока
// Такое в данном случае допустимо, поскольку конструктор вызывается в контексте
// основного потока. Следовательно, здесь можно обращаться к VCL-компонентам.
// Но, я такого не люблю, поскольку считаю что плохо когда поток знает что-то
// о какой-то там форме. Но, чего не сделаешь для демонстрации.
Param3:= Form1.CheckBox1.Checked;
end;

destructor tMyThread.Destroy;
begin
// уничтожение внутренних объектов
FreeAndNil(ResultList);
// уничтожение базового tThread
inherited;
end;

procedure tMyThread.Execute;
var
t:Cardinal;
s:String;
begin
IterationNo:= 0; // счетчик результатов (номер цикла)

// В моем примере тело потока представляет собой цикл, который завершается
// либо по внешней "просьбе" завершиться передаваемый через изменяемый параметр Stopped,
// либо просто совершив 5 циклов
// Мне приятнее такое записывать через "вечный" цикл.

While True do begin

Inc(IterationNo); // очередной номер цикла

LastRandom:= Random(1000); // слючайное число - для демонстрации передачи параметров от потока в форму

T:= Random(5)+1; // время на которое будем засыпать если нас не завершат

// Тупая работа (зависящая от входного параметра)
if not Param3 then
Inc(Param2)
else
Dec(Param2);

// Сформируем промежуточный результат
s:= Format("%s %5d %s %d %d",
);

// Добавим промежуточный результат к списку резуольтатов
ResultList.Add(s);

//// Примеры передачи промежуточного результата на форму

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

//// Достоинства:
//// - стандартность и универсальность
//// - в синхронизированном методе можно пользоваться
//// всеми полями объекта-потока.
// сначала, если необходимо, надо сохранить передаваемые данные в
// специальных полях объекта объекта.
SyncDataN:= IterationNo;
SyncDataS:= "Sync"+s;
// и затем обеспечить синхронизированный вызов метода
Synchronize(SyncMetod1);

//// Передача через синхронную отсылку сообщения (SendMessage)
//// в этом случае, данные можно передать как через параметры сообщения (LastRandom),
//// так и через поля объекта, передав в параметре сообщения адрес экземпляра
//// объекта-потока - Integer(Self).
//// Недостатки:
//// - поток должен знать handle окна формы
//// - как и при Synchronize, текущий поток будет приостановлен до
//// завершения обработки сообщения основным потоком
//// - требует существенных затрат процессорного времени на каждый вызов
//// (на переключение потоков) поэтому нежелателен очень частый вызов
//// Достоинства:
//// - как и при Synchronize, при обработке сообщения можно пользоваться
//// всеми полями объекта-потока (если конечно был передан его адрес)


//// запуска потока.
SendMessage(Form1.Handle,WM_USER_SendMessageMetod,Integer(Self),LastRandom);

//// Передача через асинхронную отсылку сообщения (PostMessage)
//// Поскольку в этом случае к моменту получения сообщения основным потоком,
//// посылающий поток может уже завершиться, передача адреса экземпляра
//// объекта-потока недопустима!
//// Недостатки:
//// - поток должен знать handle окна формы;
//// - из-за асинхронности, передача данных возможна только через параметры
//// сообщения, что существенно усложняет передачу данных имеющих размер
//// более двух машинныхх слов. Удобно применять для передачи Integer и т.п.
//// Достоинства:
//// - в отличие от предыдущих методов, текущий поток НЕ будет
//// приостановлен, а сразу же продолжит свое выполнение
//// - в отличии от синхронизированного вызова, обработчиком сообщения
//// является метод формы, который должен иметь знания об объекте-потоке,
//// или вовсе ничего не знать о потоке, если данные передаеются только
//// через параметры сообщения. Т.е., поток может ничего не знать о форме
//// вообще - только ее Handle, который может быть передан как параметр до
//// запуска потока.
PostMessage(Form1.Handle,WM_USER_PostMessageMetod,IterationNo,LastRandom);

//// Проверка возможного завершения

// Проверка завершения по параметру
if Stopped then Break;

// Проверка завершения по случаю
if IterationNo >= 10 then Break;

Sleep(t*1000); // Засыпаем на t секунд
end;
end;

procedure tMyThread.SyncMetod1;
begin
// этот метод вызывается посредством метода Synchronize.
// Т.е., не смотря на то что он является методом потока tMyThread,
// он выполняется в контексте основного потока приложения.
// Следовательно, ему все можно, ну или почти все:)
// Но помним, здесь не стоит долго "возиться"

// Переданные параметры, мы можем извлечь из специальных поле, куда мы их
// сохранили перед вызовом.
Form1.Label1.Caption:= SyncDataS;

// либо из других полей объекта потока, например отражающих его тек.состояние
Form1.Label2.Caption:= Format("%d %d",);
end;

А вообще, примеру предшествовали следующие мои рассуждения на тему....

Во первых:
ВАЖНЕЙШЕЕ правило многопоточного программирования на Delphi:
В контексте не основного потока нельзя, обращаться к свойствам и методам форм, да и вообще всех компонентов которые "растут" из tWinControl.

Это означает (несколько упрощенно) что ни в методе Execute унаследованного от TThread, ни в других методах/процедурах/функциях вызываемых из Execute, нельзя напрямую обращаться ни к каким свойствам и методам визуальных компонентов.

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

Если коротенько на пальцах:

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

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

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

Существуют и другие случаи, но реже. Впрочем, это и не важно. Сейчас не об этом.

Теперь, как все это пишется. Естественно рассматривается некий наиболее частый случай, несколько обобщенный. Итак.

Работа, выносимая в отдельный поток, в общем случае имеет четыре сущности (уж и не знаю как назвать точнее):
1. Исходные данные
2. Собственно сама работа (она может зависеть от исходных данных)
3. Промежуточные данные (например, информация о текущем состоянии выполнения работы)
4. Выходные данные (результат)

Чаще всего для считывания и вывода большей части данных используются визуальные компоненты. Но, как было сказано выше – нельзя из потока напрямую обращаться к визуальным компонентам. Как же быть?
Разработчики Delphi предлагают использовать метод Synchronize класса TThread. Здесь я не буду описывать то, как его применять – для этого есть вышеупомянутая статья. Скажу лишь, что его применение, даже правильное, не всегда оправдано. Имеются две проблемы:

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

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

Причем, обе проблемы взаимосвязаны, и вызывают противоречие: с одной стороны, для решения первой, надо «размельчать» методы вызываемые через Synchronize, а с другой, их тогда чаще приходится вызывать, теряя драгоценный процессорный ресурс.

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

Исходные данные
Все данные которые передаются в поток, и не изменяются во время его работы, нужно передавать еще до его запуска, т.е. при создании потока. Для их использования в теле потока, нужно сделать их локальную копию (обычно в полях потомка TThread).
Если есть исходные данные которые могут меняться во время работы потока, то доступ к таким данным нужно осуществлять либо через синхронизируемые методы (методы вызываемые через Synchronize), либо через поля объекта-потока (потомка TThread). Последнее требует определенной осторожности.

Промежуточные и выходные данные
Здесь, опять же есть несколько способов (в порядке моих предпочтений):
- Метод асинхронной отсылки сообщений главному окну приложению.
Используется обычно для отсылки основному окну приложения сообщений о состоянии протекания процесса, с передачей незначительного объема данных (например, процента выполнения)
- Метод синхронной отсылки сообщений главному окну приложению.
Используется обычно для тех же целей что и асинхронная отсылка, но позволяет передать больший объем данных, без создания отдельной копии.
- Синхронизируемые методы, по возможности, объединяя в один метод передачу как можно большего объема данных.
Можно использовать и для получения данных с формы.
- Через поля объекта-потока, обеспечением взаимоисключающего доступа.
Подробнее, можно почитать в статье.

Эх. Коротенько опять не получилось

В более ранних постах было рассказано про многопоточность в Windows при помощи CreateThread и прочего WinAPI , а также многопоточность в Linux и других *nix системах при помощи pthreads . Если вы пишите на C++11 или более поздних версиях, то вам доступны std::thread и другие многопоточные примитивы, появившиеся в этом стандарте языка. Далее будет показано, как с ними работать. В отличие от WinAPI и pthreads, код, написанный на std::thread, является кроссплатформенным.

Примечание: Приведенный код был проверен на GCC 7.1 и Clang 4.0 под Arch Linux , GCC 5.4 и Clang 3.8 под Ubuntu 16.04 LTS, GCC 5.4 и Clang 3.8 под FreeBSD 11, а также Visual Studio Community 2017 под Windows 10. CMake до версии 3.8 не умеет говорить компилятору использовать стандарт C++17, указанный в свойствах проекта. Как установить CMake 3.8 в Ubuntu 16.04 . Чтобы код компилировался при помощи Clang, в *nix системах должен быть установлен пакет libc++. Для Arch Linux пакет доступен на AUR . В Ubuntu есть пакет libc++-dev, но вы можете столкнуться с , из-за которого код так просто собираться не будет. Воркэраунд описан на StackOverflow . Во FreeBSD для компиляции проекта нужно установить пакет cmake-modules.

Мьютексы

Ниже приведен простейший пример использования трэдов и мьютексов:

#include
#include
#include
#include

Std:: mutex mtx;
static int counter = 0 ;


for (;; ) {
{
std:: lock_guard < std:: mutex > lock(mtx) ;

break ;
int ctr_val = ++ counter;
std:: cout << "Thread " << tnum << ": counter = " <<
ctr_val << std:: endl ;
}

}
}

int main() {
std:: vector < std:: thread > threads;
for (int i = 0 ; i < 10 ; i++ ) {


}

// can"t use const auto& here since .join() is not marked const

thr.join () ;
}

Std:: cout << "Done!" << std:: endl ;
return 0 ;
}

Обратите внимание на оборачивание std::mutex в std::lock_guard в соответствии c идиомой RAII . Такой подход гарантирует, что мьютекс будет отпущен при выходе из скоупа в любом случае, в том числе при возникновении исключений. Для захвата сразу нескольких мьютексов с целью предотвращения дэдлоков существует класс std::scoped_lock . Однако он появился только в C++17 и потому может работать не везде. Для более ранних версий C++ есть аналогичный по функционалу шаблон std::lock , правда для корректного освобождения локов по RAII он требует написания дополнительного кода.

RWLock

Нередко возникает ситуация, в которой доступ к объекту чаще происходит на чтение, чем на запись. В этом случае вместо обычного мьютекса эффективнее использовать read-write lock, он же RWLock. RWLock может быть захвачен сразу несколькими потоками на чтение, или только одним потоком на запись. RWLock’у в C++ соответствуют классы std::shared_mutex и std::shared_timed_mutex:

#include
#include
#include
#include

// std::shared_mutex mtx; // will not work with GCC 5.4
std:: shared_timed_mutex mtx;

static int counter = 0 ;
static const int MAX_COUNTER_VAL = 100 ;

void thread_proc(int tnum) {
for (;; ) {
{
// see also std::shared_lock
std:: unique_lock < std:: shared_timed_mutex > lock(mtx) ;
if (counter == MAX_COUNTER_VAL)
break ;
int ctr_val = ++ counter;
std:: cout << "Thread " << tnum << ": counter = " <<
ctr_val << std:: endl ;
}
std:: this_thread :: sleep_for (std:: chrono :: milliseconds (10 ) ) ;
}
}

int main() {
std:: vector < std:: thread > threads;
for (int i = 0 ; i < 10 ; i++ ) {
std:: thread thr(thread_proc, i) ;
threads.emplace_back (std:: move (thr) ) ;
}

for (auto & thr : threads) {
thr.join () ;
}

Std:: cout << "Done!" << std:: endl ;
return 0 ;
}

По аналогии с std::lock_guard для захвата RWLock’а используются классы std::unique_lock и std::shared_lock, в зависимости от того, как мы хотим захватить лок. Класс std::shared_timed_mutex появился в C++14 и работает на всех* современных платформах (не скажу за мобильные устройства, игровые консоли, и так далее). В отличие от std::shared_mutex , он имеет методы try_lock_for, try_lock_unti и другие, которые пытаются захватить мьютекс в течение заданного времени. Я сильно подозреваю, что std::shared_mutex должен быть дешевле std::shared_timed_mutex. Однако std::shared_mutex появился только в C++17, а значит поддерживается не везде. В частности, все еще широко используемый GCC 5.4 про него не знает.

Thread Local Storage

Иногда бывает нужно создать переменную, вроде глобальной, но которую видит только один поток. Другие потоки тоже видят переменную, но у них она имеет свое локальное значение. Для этого придумали Thread Local Storage, или TLS (не имеет ничего общего с Transport Layer Security !). Помимо прочего, TLS может быть использован для существенного ускорения генерации псевдослучайных чисел. Пример использования TLS на C++:

#include
#include
#include
#include

Std:: mutex io_mtx;
thread_local int counter = 0 ;
static const int MAX_COUNTER_VAL = 10 ;

void thread_proc(int tnum) {
for (;; ) {
counter++ ;
if (counter == MAX_COUNTER_VAL)
break ;
{
std:: lock_guard < std:: mutex > lock(io_mtx) ;
std:: cout << "Thread " << tnum << ": counter = " <<
counter << std:: endl ;
}
std:: this_thread :: sleep_for (std:: chrono :: milliseconds (10 ) ) ;
}
}

int main() {
std:: vector < std:: thread > threads;
for (int i = 0 ; i < 10 ; i++ ) {
std:: thread thr(thread_proc, i) ;
threads.emplace_back (std:: move (thr) ) ;
}

for (auto & thr : threads) {
thr.join () ;
}

Std:: cout << "Done!" << std:: endl ;
return 0 ;
}

Мьютекс здесь используется исключительно для синхронизации вывода в консоль. Для доступа к thread_local переменным никакая синхронизация не требуется.

Атомарные переменные

Атомарные переменные часто используются для выполнения простых операций без использования мьютексов. Например, вам нужно инкрементировать счетчик из нескольких потоков. Вместо того, чтобы оборачивать int в std::mutex, эффективнее воспользоваться std::atomic_int. Также C++ предлагает типы std::atomic_char, std::atomic_bool и многие другие . Еще на атомарных переменных реализуют lock-free алгоритмы и структуры данных. Стоит отметить, что они весьма сложны в разработке и отладке, и не на всех системах работают быстрее аналогичных алгоритмов и структур данных с локами.

Пример кода:

#include
#include
#include
#include
#include

static std:: atomic_int atomic_counter(0 ) ;
static const int MAX_COUNTER_VAL = 100 ;

Std:: mutex io_mtx;

void thread_proc(int tnum) {
for (;; ) {
{
int ctr_val = ++ atomic_counter;
if (ctr_val >= MAX_COUNTER_VAL)
break ;

{
std:: lock_guard < std:: mutex > lock(io_mtx) ;
std:: cout << "Thread " << tnum << ": counter = " <<
ctr_val << std:: endl ;
}
}
std:: this_thread :: sleep_for (std:: chrono :: milliseconds (10 ) ) ;
}
}

int main() {
std:: vector < std:: thread > threads;

int nthreads = std:: thread :: hardware_concurrency () ;
if (nthreads == 0 ) nthreads = 2 ;

for (int i = 0 ; i < nthreads; i++ ) {
std:: thread thr(thread_proc, i) ;
threads.emplace_back (std:: move (thr) ) ;
}

for (auto & thr : threads) {
thr.join () ;
}

Std:: cout << "Done!" << std:: endl ;
return 0 ;
}

Обратите внимание на использование процедуры hardware_concurrency. Она возвращает оценку количества трэдов, которое в текущей системе может выполняться параллельно. Например, на машине с четырехядерным процессором, поддерживающим hyper threading, процедура возвращает число 8. Также процедура может возвращать ноль, если сделать оценку не удалось или процедура попросту не реализована.

Кое-какую информацию о работе атомарных переменных на уровне ассемблера можно найти в заметке Шпаргалка по основным инструкциям ассемблера x86/x64 .

Заключение

Насколько я вижу, все это действительно неплохо работает. То есть, при написании кроссплатформенных приложений на C++ про WinAPI и pthreads можно благополучно забыть. В чистом C начиная с C11 также существуют кроссплатформенные трэды . Но они все еще не поддерживаются Visual Studio (я проверил) , и вряд ли когда-либо будут поддерживаться. Не секрет, что Microsoft не видит интереса в развитии поддержки языка C в своем компиляторе, предпочитая концентрироваться на C++.

За кадром осталось еще немало примитивов: std::condition_variable(_any), std::(shared_)future, std::promise, std::sync и другие. Для ознакомления с ними я рекомендую сайт cppreference.com . Также может иметь смысл прочитать книгу C++ Concurrency in Action . Но должен предупредить, что она уже не новая, содержит многовато воды, и в сущности пересказывает десяток статей с cppreference.com.

Полная версия исходников к этой заметке, как обычно, лежит на GitHub . А как вы сейчас пишите многопоточные приложения на C++?

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

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

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

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

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

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

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

Любой поток состоит из двух компонентов:

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

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

1. Поток (thread) определяет последовательность исполнения кода в процессе.

2. Процесс ничего не исполняет, он просто служит контейнером потоков.

3. Потоки всегда создаются в контексте какого-либо процесса, и вся их жизнь проходит только в его границах.

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

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

Многозада́чность (англ. multitasking ) - свойство операционной системы или среды программирования обеспечивать возможность параллельной (или псевдопараллельной) обработки нескольких процессов. Истинная многозадачность операционной системы возможна только в распределённых вычислительных системах.

Файл:Screenshot of Debian (Release 7.1, "Wheezy") running the GNOME desktop environment, Firefox, Tor, and VLC Player.jpg

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

Существует 2 типа многозадачности :

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

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

Многопоточность - специализированная форма многозадачности .

· 1 Свойства многозадачной среды

· 2 Трудности реализации многозадачной среды

· 3 История многозадачных операционных систем

· 4 Типы псевдопараллельной многозадачности

o 4.1 Невытесняющая многозадачность

o 4.2 Совместная или кооперативная многозадачность

o 4.3 Вытесняющая или приоритетная многозадачность (режим реального времени)

· 5 Проблемные ситуации в многозадачных системах

o 5.1 Голодание (starvation)

o 5.2 Гонка (race condition)

· 7 Примечания

Свойства многозадачной среды[править | править исходный текст]

Примитивные многозадачные среды обеспечивают чистое «разделение ресурсов», когда за каждой задачей закрепляется определённый участок памяти, и задача активизируется в строго определённые интервалы времени.

Более развитые многозадачные системы проводят распределение ресурсов динамически, когда задача стартует в памяти или покидает память в зависимости от её приоритета и от стратегии системы. Такая многозадачная среда обладает следующими особенностями:

· Каждая задача имеет свой приоритет, в соответствии с которым получает процессорное время и память

· Система организует очереди задач так, чтобы все задачи получили ресурсы, в зависимости от приоритетов и стратегии системы

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

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

· Система обеспечивает защиту адресного пространства задачи от несанкционированного вмешательства других задач

· Система обеспечивает защиту адресного пространства своего ядра от несанкционированного вмешательства задач

· Система распознаёт сбои и зависания отдельных задач и прекращает их

· Система решает конфликты доступа к ресурсам и устройствам, не допуская тупиковых ситуаций общего зависания от ожидания заблокированных ресурсов

· Система гарантирует каждой задаче, что рано или поздно она будет активирована

· Система обрабатывает запросы реального времени

· Система обеспечивает коммуникацию между процессами

Трудности реализации многозадачной среды[править | править исходный текст]

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

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

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

Такие потоки называют также потоками выполнения (от англ. thread of execution ); иногда называют «нитями» (буквальный перевод англ. thread ) или неформально «тредами».

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

Многопоточность (как доктрину программирования) не следует путать ни с многозадачностью, ни с многопроцессорностью, несмотря на то, что операционные системы, реализующие многозадачность, как правило реализуют и многопоточность.

К достоинствам многопоточности в программировании можно отнести следующее:

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

· Меньшие относительно процесса временны́е затраты на создание потока.

· Повышение производительности процесса за счет распараллеливания процессорных вычислений и операций ввода-вывода.

· 1 Типы реализации потоков

· 2 Взаимодействие потоков

· 3 Критика терминологии

· 6 Примечания

Типы реализации потоков[править | править исходный текст]

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

Достоинства и недостатки этого типа следующие: Недостатки

1. Отсутствие прерывания по таймеру внутри одного процесса

2. При использовании блокирующего системного запроса для процесса все его потоки блокируются.

3. Сложность реализации

· Поток в пространстве ядра. Наряду с таблицей процессов в пространстве ядра имеется таблица потоков.

· «Волокна» (англ. fibers ). Несколько потоков режима пользователя, исполняющихся в одном потоке режима ядра. Поток пространства ядра потребляет заметные ресурсы, в первую очередь физическую память и диапазон адресов режима ядра для стека режима ядра. Поэтому было введено понятие «волокна» - облегчённого потока, выполняемого исключительно в режиме пользователя. У каждого потока может быть несколько «волокон».

Взаимодействие потоков[править | править исходный текст]

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

· Взаимоисключения (mutex, мьютекс) - это объект синхронизации, который устанавливается в особое сигнальное состояние, когда не занят каким-либо потоком. Только один поток владеет этим объектом в любой момент времени, отсюда и название таких объектов (от английского mut ually ex clusive access - взаимно исключающий доступ) - одновременный доступ к общему ресурсу исключается. После всех необходимых действий мьютекс освобождается, предоставляя другим потокам доступ к общему ресурсу. Объект может поддерживать рекурсивный захват второй раз тем же потоком, увеличивая счетчик, не блокируя поток, и требуя потом многократного освобождения. Такова, например, критическая секция в Win32. Тем не менее есть и такие реализации, которые не поддерживают такое и приводят к взаимной блокировке потока при попытке рекурсивного захвата. Это FAST_MUTEX в ядре Windows.

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

· События. Объект, хранящий в себе 1 бит информации «просигнализирован или нет», над которым определены операции «просигнализировать», «сбросить в непросигнализированное состояние» и «ожидать». Ожидание на просигнализированном событии есть отсутствие операции с немедленным продолжением исполнения потока. Ожидание на непросигнализированном событии приводит к приостановке исполнения потока до тех пор, пока другой поток (или же вторая фаза обработчика прерывания в ядре ОС) не просигнализирует событие. Возможно ожидание нескольких событий в режимах «любого» или «всех». Возможно также создания события, автоматически сбрасываемого в непросигнализированное состояние после пробуждения первого же - и единственного - ожидающего потока (такой объект используется как основа для реализации объекта «критическая секция»). Активно используются в MS Windows, как в режиме пользователя, так и в режиме ядра. Аналогичный объект имеется и в ядре Linux под названием kwait_queue.

· Критические секции обеспечивают синхронизацию подобно мьютексам за исключением того, что объекты, представляющие критические секции, доступны в пределах одного процесса. События, мьютексы и семафоры также можно использовать в однопроцессном приложении, однако реализации критических секций в некоторых ОС (например, Windows NT) обеспечивают более быстрый и более эффективный механизм взаимно-исключающей синхронизации - операции «получить» и «освободить» на критической секции оптимизированы для случая единственного потока (отсутствия конкуренции) с целью избежать любых ведущих в ядро ОС системных вызовов. Подобно мьютексам объект, представляющий критическую секцию, может использоваться только одним потоком в данный момент времени, что делает их крайне полезными при разграничении доступа к общим ресурсам.

· Условные переменные (condvars). Сходны с событиями, но не являются объектами, занимающими память - используется только адрес переменной, понятие «содержимое переменной» не существует, в качестве условной переменной может использоваться адрес произвольного объекта. В отличие от событий, установка условной переменной в просигнализированное состояние не влечет за собой никаких последствий в случае, если на данный момент нет потоков, ожидающих на переменной. Установка события в аналогичном случае влечет за собой запоминание состояния «просигнализировано» внутри самого события, после чего следующие потоки, желающие ожидать события, продолжают исполнение немедленно без остановки. Для полноценного использования такого объекта необходима также операция «освободить mutex и ожидать условную переменную атомарно». Активно используются в UNIX-подобных ОС. Дискуссии о преимуществах и недостатках событий и условных переменных являются заметной частью дискуссий о преимуществах и недостатках Windows и UNIX.

· Порт завершения ввода-вывода (IO completion port, IOCP). Реализованный в ядре ОС и доступный через системные вызовы объект «очередь» с операциями «поместить структуру в хвост очереди» и «взять следующую структуру с головы очереди» - последний вызов приостанавливает исполнение потока в случае, если очередь пуста, и до тех пор, пока другой поток не осуществит вызов «поместить». Самой важной особенностью IOCP является то, что структуры в него могут помещаться не только явным системным вызовом из режима пользователя, но и неявно внутри ядра ОС как результат завершения асинхронной операции ввода-вывода на одном из дескрипторов файлов. Для достижения такого эффекта необходимо использовать системный вызов «связать дескриптор файла с IOCP». В этом случае помещенная в очередь структура содержит в себе код ошибки операции ввода-вывода, а также, для случая успеха этой операции - число реально введенных или выведенных байт. Реализация порта завершения также ограничивает число потоков, исполняющихся на одном процессоре/ядре после получения структуры из очереди. Объект специфичен для MS Windows, и позволяет обработку входящих запросов соединения и порций данных в серверном программном обеспечении в архитектуре, где число потоков может быть меньше числа клиентов (нет требования создавать отдельный поток с расходами ресурсов на него для каждого нового клиента).

· ERESOURCE. Мьютекс, поддерживающий рекурсивный захват, с семантикой разделяемого или эксклюзивного захвата. Семантика: объект может быть либо свободен, либо захвачен произвольным числом потоков разделяемым образом, либо захвачен всего одним потоком эксклюзивным образом. Любые попытки осуществить захваты, нарушающее это правило, приводят к блокировке потока до тех пор, пока объект не освободится так, чтобы сделать захват разрешенным. Также есть операции вида TryToAcquire - никогда не блокирует поток, либо захватывает, либо (если нужна блокировка) возвращает FALSE, ничего не делая. Используется в ядре Windows, особенно в файловых системах - так, например, любому кем-то открытому дисковому файлу соответствует структура FCB, в которой есть 2 таких объекта для синхронизации доступа к размеру файла. Один из них - paging IO resource - захватывается эксклюзивно только в пути обрезания файла, и гарантирует, что в момент обрезания на файле нет активного ввода-вывода от кэша и от отображения в память.

· Rundown protection. Полудокументированный (вызовы присутствуют в файлах-заголовках, но отсутствуют в документации) объект в ядре Windows. Счетчик с операциями «увеличить», «уменьшить» и «ждать». Ожидание блокирует поток до тех пор, пока операции уменьшения не уменьшат счетчик до нуля. Кроме того, операция увеличения может отказать, и наличие активного в данный момент времени ожидания заставляет отказывать все операции увеличения.

Андрей Колесов

Приступая к рассмотрению принципов создания многопоточных приложений для среды Microsoft .NET Framework, сразу оговоримся: хотя все примеры приведены на Visual Basic .NET, методика создания таких программ в целом одинакова для всех языков программирования, поддерживающих.NET, в том числе для C#. VB выбран для демонстрации технологии создания многопоточных приложений в первую очередь потому, что предыдущие версии этого инструмента такой возможности не предоставляли.

Осторожно: Visual Basic .NET тоже может делать ЭТО!

Как известно, Visual Basic (до версии 6.0 включительно) никогда ранее не позволял создавать многопоточные программные компоненты (EXE, ActiveX DLL и OCX). Тут нужно вспомнить, что архитектура COM включает три разные потоковые модели: однопоточную (Single Thread), совместную (Single Threaded Apartment, STA) и свободную (Multi-Threaded Apartment). VB 6.0 позволяет создавать программы первых двух типов. Вариант STA предусматривает псевдомногопоточный режим - несколько потоков действительно работают параллельно, но при этом программный код каждого из них защищен от доступа к нему извне (в частности, потоки не могут использовать общие ресурсы).

Visual Basic .NET теперь может реализовать свободную многопоточность в ее настоящем (native) варианте. Точнее сказать, в.NET такой режим поддерживается на уровне общих библиотек классов Class Library и среды исполнения Common Language Runtime. В результате VB.NET наравне с другими языками программирования.NET получил доступ к этим возможностям.

В свое время сообщество VB-разработчиков, выражая недовольство многими будущими новшествами этого языка, с большим одобрением отнеслось к известию о том, что с помощью новой версии инструмента можно будет создавать многопоточные программы (см. "В ожидании Visual Studio .NET", "BYTE/Россия" № 1/2001). Однако многие эксперты высказывали более сдержанные оценки по поводу этого новшества. Вот, например, мнение Дана Эпплмана (Dan Appleman), известного разработчика и автора многочисленных книг для VB-программистов: "Многопоточность в VB.NET страшит меня больше, чем все остальные новшества, причем, как и во многих новых технологиях.NET, это объясняется скорее человеческими, нежели технологическими факторами... Я боюсь многопоточности в VB.NET, потому что VB-программисты обычно не обладают опытом проектирования и отладки многопоточных приложений" .

Действительно, как и прочие средства низкоуровневого программирования (например, те же интерфейсы Win API), свободная многопоточность, с одной стороны, предоставляет более широкие возможности для создания высокопроизводительных масштабируемых решений, а с другой - предъявляет более высокие требования к квалификации разработчиков. Причем проблема тут усугубляется тем, что поиск ошибок в многопоточном приложении весьма сложен, так как они проявляются чаще всего случайным образом, в результате специфического пересечения параллельных вычислительных процессов (воспроизвести еще раз такую ситуацию зачастую бывает просто невозможно). Именно поэтому методы традиционной отладки программ в виде их повторного прогона в данном случае обычно не помогают. И единственный путь к безопасному применению многопоточности - это качественное проектирование приложения с соблюдением всех классических принципов "правильного программирования".

Проблема же с VB-программистами заключается еще и в том, что хотя многие из них - достаточно опытные профессионалы и отлично знают о подводных камнях многопоточности, использование VB6 могло притупить их бдительность. Ведь, обвиняя VB в ограниченности, мы порой забываем, что многие ограничения определялись улучшенными средствами безопасности этого инструмента, которые предупреждают или исключают ошибки разработчика. Например, VB6 автоматически создает отдельную копию всех глобальных переменных для каждого потока, предупреждая таким образом возможные конфликты между ними. В VB.NET подобные проблемы полностью перекладываются на плечи программиста. При этом следует также помнить, что применение многопоточной модели вместо однопоточной далеко не всегда приводит к повышению производительности программы, производительность может даже снизиться (даже в многопроцессорных системах!).

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

Параллельная обработка в VB6

Конечно, организовать псевдопараллельную обработку данных можно было и с помощью VB6, но возможности эти были весьма ограниченными. Например, мне несколько лет назад понадобилось написать процедуру, которая приостанавливает выполнение программы на указанное число секунд (соответствующий оператор SLEEP в готовом виде присутствовал в Microsoft Basic/DOS). Ее нетрудно реализовать самостоятельно в виде следующей простой подпрограммы:

В ее работоспособности можно легко убедиться, например, с помощью такого кода обработки щелчка кнопки на форме:

Чтобы решить эту проблему в VB6, внутри цикла Do...Loop процедуры SleepVB нужно снять комментарий с обращения к функции DoEvents, которая передает управление операционной системе и возвращает число открытых форм в данном VB-приложении. Но обратите внимание, что вывод окна с сообщением "Еще один привет!", в свою очередь, блокирует выполнение всего приложения, в том числе и процедуры SleepVB.

Используя глобальные переменные в качестве флагов, можно обеспечить также аварийное завершение запущенной процедуры SleepVB. Она, в свою очередь, представляет собой простейший пример вычислительного процесса, полностью занимающего ресурсы процессора. Но если вы будете совершать какие-то полезные вычисления (а не крутиться в пустом цикле), то нужно иметь в виду, что обращение к функции DoEvent занимает довольно много времени, поэтому это нужно делать через достаточно большие интервалы времени.

Чтобы увидеть ограниченность поддержки параллельных вычислений в VB6, замените обращение к функции DoEvents на вывод метки:

Label1.Caption = Timer

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

Для проведения еще одного эксперимента добавьте вызов ожидания в код для Command2 (это можно сделать, так как процедура SleepVB реентерабельна):

Private Sub Command2_Click() Call SleepVB(5) MsgBox "Еще один привет!" End Sub

Далее запустите приложение и щелкните Command1, а спустя 2-3 с - Command2. Первым появится сообщение "Еще один привет"!, хотя соответствующий процесс был запущен позднее. Причина этого в том, что функция DoEvents проверяет только события визуальных элементов, но не наличие других вычислительных потоков. Более того, VB-приложение фактически работает в одном потоке, поэтому управление вернулось в событийную процедуру, которая была запущена последней.

Управление потоками в.NET

Построение многопоточных.NET-приложений основывается на использовании группы базовых классов.NET Framework, описываемых пространством имен System.Threading. При этом ключевая роль принадлежит классу Thread, с помощью которого выполняются практически все операции по управлению потоками. С этого места все сказанное о работе с потоками относится ко всем средствам программирования в.NET, в том числе к C#.

Для первого знакомства с созданием параллельных потоков создадим Windows-приложение с формой, на которой разместим кнопки ButtonStart и ButtonAbort и напишем следующий код:

Сразу же хотелось бы обратить внимание на три момента. Во-первых, ключевые слова Imports используются для обращения к сокращенным именам классов, описанных здесь пространством имен. Я специально привел еще один вариант применения Imports для описания сокращенного эквивалента длинного названия пространства имен (VB = Microsoft.VisualBasic), который можно применить к тексту программы. В этом случае сразу видно, к какому пространству имен относится объект Timer.

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

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

Запустите приложение и щелкните кнопку ButtonStart. Запустился процесс ожидания в цикле заданного интервала времени, причем в данном случае (в отличие от примера с VB6) - в независимом потоке. В этом легко убедиться - все визуальные элементы формы являются доступными. Например, нажав кнопку ButtonAbort, можно аварийно завершить процесс с помощью метода Abort (но закрытие формы с помощью системной кнопки Close не прервет выполнение процедуры!). Для наглядности динамики процесса вы можете разместить на форме метку, а в цикл ожидания процедуры SleepVBNET добавить вывод текущего времени:

Label1.Text = _ "Текущее время = " & VB.TimeOfDay

Выполнение процедуры SleepVBNET (которая в данном случае уже представляет собой метод нового объекта) будет продолжаться, даже если вы добавите в код ButtonStart вывод окна сообщения о начале вычислений после запуска потока (рис. 1).

Более сложный вариант - поток в виде класса

Для проведения дальнейших экспериментов с потоками создадим новое VB-приложение типа Console, состоящее из обычного модуля кода с процедурой Main (которая начинает выполняться при запуске приложения) и модуля класса WorkerThreadClass:

Запустим созданное приложение. Появится консольное окно, в котором будет видна бегущая строка символов, демонстрирующая модель запущенного вычислительного процесса (WorkerThread). Потом появится окно сообщения, выданного вызывающим процессом (Main), и в завершение мы увидим картинку, изображенную на рис. 2 (если вас не устраивает скорость выполнения моделируемого процесса, то уберите или добавьте какие-нибудь арифметические операции с переменной "а" в процедуре WorkerThread).

Обратите внимание: окно сообщения "Запущен первый поток" было выдано на экран с заметной задержкой, после старта процесса WorkerThread (в случае с формой, описанном в предыдущем пункте, такое сообщение появилось бы почти мгновенно после нажатия кнопки ButtonStart). Скорее всего, это происходит потому, что при работе с формой событийные процедуры имеют более высокий приоритет по сравнению с запускаемым процессом. В случае же консольного приложения все процедуры имеют одинаковый приоритет. Вопрос приоритетов мы обсудим позднее, а пока установим для вызывающего потока (Main) самый высокий приоритет:

Thread.CurrentThread.Priority = _ ThreadPriority.Highest Thread1.Start()

Теперь окно появляется почти сразу. Как видим, создавать экземпляры объекта Thread можно двумя способами. Сначала мы применяли первый из них - создали новый объект (поток) Thread1 и работали с ним. Второй вариант - получить объект Thread для выполняемого в данный момент потока с помощью статического метода CurrentThread. Именно таким образом процедура Main сама для себя установила более высокий приоритет, но могла она это сделать и для любого другого потока, например:

Thread1.Priority = ThreadPriority.Lowest Thread1.Start()

Чтобы показать возможности управления запущенным процессом, добавим в конце процедуры Main такие строчки кода:

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

Сначала в консольном окне начнется "Процесс 1", и появится сообщение "Первый поток запущен". "Процесс 1" выполняется, а вы быстренько нажмите кнопку ОК в окне сообщения.

Далее - "Процесс 1" продолжается, но через две секунды появляется сообщение "Поток приостановлен". "Процесс 1" замер. Нажмите кнопку "ОК" в окне сообщения: "Процесс 1" продолжил свое выполнение и успешно завершил его.

В этом фрагменте мы использовали метод Sleep для приостановки текущего процесса. Заметьте: Sleep является статическим методом и может применяться только к текущему процессу, но не к какому-то экземпляру объекта Thread. Синтаксис языка позволяет написать Thread1.Sleep или Thread.Sleep, но все равно в этом случае используется объект CurrentThread.

Метод Sleep может также использовать аргумент 0. В этом случае текущий поток освободит неиспользованный остаток кванта выделенного для него времени.

Еще один интересный вариант использования Sleep - со значением Timeout.Infinite. В этом случае поток будет приостановлен на неопределенный срок, пока это состояние не будет прервано другим потоком с помощью метода Thread.Interrupt.

Чтобы приостановить внешний поток из другого потока без остановки последнего, нужно использовать вызов метода Thread.Suspend. Тогда продолжить его выполнение можно будет методом Thread.Resume, что мы и сделали в приведенном выше коде.

Немного о синхронизации потоков

Синхронизация потоков - это одна из главных задач при написании многопоточных приложений, и в пространстве System.Threading имеется большой набор средств для ее решения. Но сейчас мы познакомимся только с методом Thread.Join, который позволяет отлеживать окончание выполнение потока. Чтобы увидеть, как он работает, замените последние строки процедуры Main на такой код:

Управление приоритетами процессов

Распределение квантов времени процессора между потоками выполняется с помощью приоритетов, которые задаются в виде свойства Thread.Priority. Для потоков, создаваемых в период выполнения, можно устанавливать пять значений: Highest, AboveNormal, Normal (используется по умолчанию), BelowNormal и Lowest. Чтобы посмотреть, как влияют приоритеты на скорость выполнения потоков, напишем такой код для процедуры Main:

Sub Main() " описание первого процесса Dim Thread1 As Thread Dim oWorker1 As New WorkerThreadClass() Thread1 = New Thread(AddressOf _ oWorker1.WorkerThread) " Thread1.Priority = _ " ThreadPriority.BelowNormal " передаем исходные данные: oWorker1.Start = 1 oWorker1.Finish = 10 oWorker1.ThreadName = "Отсчет 1" oWorker1.SymThread = "." " описание второго процесса Dim Thread2 As Thread Dim oWorker2 As New WorkerThreadClass() Thread2 = New Thread(AddressOf _ oWorker2.WorkerThread) " передаем исходные данные: oWorker2.Start = 11 oWorker2.Finish = 20 oWorker2.ThreadName = "Отсчет 2" oWorker2.SymThread = "*" " " запускаем наперегонки Thread.CurrentThread.Priority = _ ThreadPriority.Highest Thread1.Start() Thread2.Start() " Ждем завершения процессов Thread1.Join() Thread2.Join() MsgBox("Оба процесса завершились") End Sub

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

Теперь перед запуском первого потока установим для него приоритет на один уровень ниже:

Thread1.Priority = _ ThreadPriority.BelowNormal

Картина резко поменялась: второй поток практически полностью отнял все время у первого (рис. 4).

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

Заключение

Мы лишь затронули основы разработки многопоточных.NET-приложений. Один из наиболее сложных и на практике актуальных вопросов - это синхронизация потоков. Кроме применения описанного в этой статье объекта Thread (у него есть много методов и свойств, которые мы не рассматривали здесь), очень важную роль в управлении потоками играют классы Monitor и Mutex, а также операторы lock (C#) и SyncLock (VB.NET).

Более подробное описание этой технологии приведено в отдельных главах книг и , из которых мне хотелось бы привести несколько цитат (с которыми я полностью согласен) в качестве очень краткого подведения итогов по теме "Многопоточность в.NET".

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

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

"Не следует рассматривать применение многопоточности как подход по умолчанию" .

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

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

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

Литература:

  1. Дан Эпплман. Переход на VB.NET: стратегии, концепции, код/Пер. с англ. - СПб.: "Питер", 2002, - 464 с.: ил.
  2. Том Арчер. Основы C#. Новейшие технологии/Пер. с англ. - М.: Издательско-торговый дом "Русская Редакция", 2001. - 448 с.: ил.

Многозадачность и многопоточность

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

Многозадачность - это режим работы, когда компьютер может выполнять несколько задач одновременно, параллельно. Понятно, что если компьютер имеет один процессор, то речь идет о псевдопараллельности, когда ОС по некоторым правилам может выполнять быстрое переключение между различными задачами. Задача - это программа или часть программы (приложения), выполняющая некоторое логическое действие и являющаяся единицей, для которой ОС выделяет ресурсы. Несколько в упрощенном виде можно считать, что в Windows задачей является каждый программный компонент, реализованный в виде отдельного исполняемого модуля (EXE, DLL). Для Windows понятие "задача" имеет тот же смысл, что и "процесс", что, в частности, означает выполнение программного кода строго в отведенном для него адресном пространстве.

Имеется два основных вида многозадачности - совместная (cooperative) и вытесняющая (preemptive). Первый вариант, реализованный в ранних версиях Windows, предусматривает переключение между задачами только в момент обращения активной задачи к ОС (например, для ввода-вывода). При этом каждый поток отвечает за возврат управления ОС. Если же задача забывала делать такую операцию (например, зацикливалась), то довольно часто это приводило к зависанию всего компьютера.

Вытесняющая многозадачность - режим, когда сама ОС отвечает за выдачу каждому потоку причитающегося ему кванта времени (time-slice), по истечении которого она (при наличии запросов от других задач) автоматически прерывает этот поток и принимает решение, что запускать далее. Раньше этот режим так и назывался - "с разделением времени".

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

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

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

Тут также можно вспомнить, что при эксплуатации мощных вычислительных систем коллективного пользования, родоначальником которых стало в конце 60-х годов семейство IBM System/360, одной из наиболее актуальных задач был выбор оптимального варианта управления многозадачностью - в том числе в динамическом режиме с учетом различных параметров. В принципе управление многозадачным режимом - это функция операционной системы. Но эффективность реализации того или иного варианта непосредственно связана с особенностями архитектуры компьютера в целом, и особенно процессора. Например, та же высокопроизводительная IBM System/360 отлично работала в системах коллективного пользования в сфере бизнес-задач, но при этом она была совершенно не приспособлена для решения задач класса "реального масштаба времени". В этой области тогда явно лидировали существенно более дешевые и простые мини-компьютеры типа DEC PDP 11/20.

Клэй Бреширс (Clay Breshears)

Введение

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

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

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

Правило 1. Выделите операции, выполняемые в программном коде независимо друг от друга

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

Рассмотрим ещё один пример – рабочий цикл пункта проката DVD-дисков, в который приходят заказы на определённые фильмы. Заказы распределяются между работниками пункта, которые ищут эти фильмы на складе. Естественно, если один из работников возьмёт со склада диск, на котором записан фильм с участием Одри Хепбёрн, это никоим образом не затронет другого работника, ищущего очередной боевик с Арнольдом Шварценеггером, и уж тем более не повлияет на их коллегу, находящегося в поисках дисков с новым сезоном сериала «Друзья». В нашем примере мы считаем, что все проблемы, связанные с отсутствием фильмов на складе, были решены до того, как заказы поступили в пункт проката, а упаковка и отправка любого заказа не повлияет на обработку других.

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

Правило 2. Применяйте параллельность с низким уровнем детализации

Существует два подхода к параллельному разделению последовательного программного кода: «снизу-вверх» и «сверху-вниз». Сначала, на этапе анализа кода, определяются сегменты кода (так называемые «горячие» точки), на которые уходит значительная часть времени выполнения программы. Параллельное разделение этих сегментов кода (если оно возможно) обеспечит максимальный прирост производительности.

В подходе «снизу-вверх» реализуется многопоточная обработка «горячих» точек кода. Если параллельное разделение найденных точек невозможно, нужно исследовать стек вызовов приложения, чтобы определить другие сегменты, доступные для параллельного разделения и выполняющиеся достаточно долгое время. Предположим, вы работаете над приложением, предназначенным для сжатия графических изображений. Сжатие можно реализовать с помощью нескольких независимых параллельных потоков, обрабатывающих отдельные сегменты изображения. Однако даже если вам удалось реализовать многопоточность «горячих» точек, не пренебрегайте анализом стека вызовов, в результате которого можно найти доступные для параллельного разделения сегменты, находящиеся на более высоком уровне программного кода. Таким образом, вы сможете увеличить степень детализации параллельной обработки.

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

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

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

Не так давно, помимо двухъядерных процессоров, на рынке появились четырёхъядерные. Более того, Intel уже объявила о создании процессора с 80 ядрами, способного выполнять триллион операций с плавающей точкой в секунду. Поскольку количество ядер в процессорах будет со временем только расти, ваш программный код должен иметь соответствующий потенциал для масштабируемости. Масштабируемость – параметр, по которому можно судить о способности приложения адекватно реагировать на такие изменения, как увеличение системных ресурсов (количество ядер, объём памяти, частота шины и проч.) или увеличение объёма данных. Учитывая, что количество ядер в процессорах будущего увеличится, создавайте масштабируемый код, производительность которого будет расти благодаря увеличению системных ресурсов.

Перефразируя один из законов Норткота Паркинсона (C. Northecote Parkinson), можно сказать, что «обработка данных занимает все доступные системные ресурсы». Это означает, что при увеличении вычислительных ресурсов (например, количества ядер), все они, вероятнее всего, будут использоваться для обработки данных. Вернёмся к приложению для сжатия видео, рассмотренному выше. Появление у процессора дополнительных ядер вряд ли скажется на размере обрабатываемых кадров – вместо этого увеличится число потоков, обрабатывающих кадр, что приведёт к уменьшению количества пикселей на поток. В результате, из-за организации дополнительных потоков, возрастет объем служебных данных, а степень детализации параллелизма снизится. Ещё одним более вероятным сценарием может стать увеличение размера или количества видеофайлов, которые нужно будет кодировать. В этом случае организация дополнительных потоков, которые будут обрабатывать более объёмные (или дополнительные) видеофайлы, позволит разделить весь объём работ непосредственно на том этапе, где произошло увеличение. В свою очередь, приложение с такими возможностями будет иметь высокий потенциал для масштабируемости.

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

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

Правило 4. Применяйте поточно-ориентированные библиотеки

Если для обработки данных в «горячих» точках кода может понадобиться какая-либо библиотека, обязательно подумайте об использовании готовых функций вместо собственного кода. Одним словом, не пытайтесь изобрести велосипед, разрабатывая сегменты кода, функции которых уже предусмотрены в оптимизированных процедурах из состава библиотек. Многие библиотеки, в том числе Intel® Math Kernel Library (Intel® MKL) и Intel® Integrated Performance Primitives (Intel® IPP), уже содержат многопоточные функции, оптимизированные под многоядерные процессоры.

Стоит заметить, что при использовании процедур из состава многопоточных библиотек необходимо убедиться, что вызов той или иной библиотеки не повлияет на нормальную работу потоков. То есть, если вызовы процедур осуществляются из двух различных потоков, в результате каждого вызова должны возвращаться правильные результаты. Если же процедуры обращаются к общим переменным библиотеки и обновляют их, возможно возникновение «гонки данных», которая пагубно отразится на достоверности результатов вычислений. Для корректной работы с потоками библиотечная процедура добавляется как новая (то есть не обновляет ничего, кроме локальных переменных) или синхронизируется для защиты доступа к общим ресурсам. Вывод: перед тем, как использовать в своём программном коде какую-либо библиотеку стороннего производителя, ознакомьтесь с приложенной к ней документацией, чтобы убедиться в ее корректной работе с потоками.

Правило 5. Используйте подходящую модель многопоточности

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

Минусом явной многопоточности является невозможность точного управления потоками.

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

Библиотека OpenMP ориентирована на декомпозицию данных и особенно хорошо подходит для поточной обработки циклов, работающих с большими объёмами информации. Несмотря на то, что к некоторым приложениям применима лишь декомпозиция данных, необходимо учесть и дополнительные требования (например, работодателя или заказчика), согласно которым использование OpenMP недопустимо и остаётся реализовывать многопоточность явными методами. В таком случае OpenMP можно использовать для предварительной организации потоков, чтобы оценить потенциальный прирост производительности, масштабируемость и примерные усилия, которые потребуются для последующего разделения программного кода методом явной многопоточности.

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

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

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

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

Правило 7. Используйте локальное хранение потоков. При необходимости назначайте блокировки на отдельные области данных

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

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

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

Как поступить, если возникла необходимость синхронизировать доступ к большому объёму данных, например, к массиву, состоящему из 10000 элементов? Организовать единственную блокировку для всего массива – значит наверняка создать узкое место в приложении. Неужели придётся организовывать блокировку для каждого элемента в отдельности? Тогда, даже если к данным будут обращаться 32 или 64 параллельных потока, придётся предотвращать конфликты доступа к довольно большой области памяти, причём вероятность возникновения таких конфликтов – 1%. К счастью, существует своеобразная золотая середина, так называемые «блокировки по модулю». Если используется N блокировок по модулю, каждая из них будет синхронизировать доступ к N-й части общей области данных. Например, если организовано две таких блокировки, одна из них будет предотвращать доступ к чётным элементам массива, а вторая – к нечётным. В таком случае, потоки, обращаясь к необходимому элементу, определяют его чётность и устанавливают соответствующую блокировку. Количество блокировок по модулю выбирается с учётом количества потоков и вероятности одновременного обращения нескольких потоков к одной и той же области памяти.

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

Правило 8. Измените программный алгоритм, если это требуется для реализации многопоточности

Критерием оценки производительности приложений, как последовательных, так и параллельных, является время выполнения. В качестве оценки алгоритма подходит асимптотический порядок. По этому теоретическому показателю практически всегда можно оценить производительность приложения. То есть, при всех прочих равных условиях, приложение со степенью роста O(n log n) (быстрая сортировка), будет работать быстрее приложения со степенью роста O(n2) (выборочная сортировка), хотя результаты работы этих приложений одинаковы.

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

В качестве иллюстрации последнего утверждения рассмотрим умножение двух квадратных матриц. Алгоритм Штрассена имеет один из лучших асимптотических порядков выполнения: O(n2.81), который намного лучше, чем порядок O(n3) алгоритма с обычным тройным вложенным циклом. Согласно алгоритму Штрассена, каждая матрица делится на четыре подматрицы, после чего производится семь рекурсивных вызовов для перемножения n/2 × n/2 подматриц. Для распараллеливания рекурсивных вызовов можно создать новый поток, который последовательно выполнит семь независимых перемножений подматриц, пока они не достигнут заданного размера. В таком случае количество потоков будет экспоненциально возрастать, а степень детализации вычислений, выполняемых каждым вновь образованным потоком, будет повышаться с уменьшением размера подматриц. Рассмотрим другой вариант – организацию пула из семи потоков, работающих одновременно и выполняющих по одному перемножению подматриц. По завершению работы пула потоков происходит рекурсивный вызов метода Штрассена для умножения подматриц (как и в последовательной версии программного кода). Если в системе, выполняющей такую программу, будет больше восьми процессорных ядер, часть из них будет простаивать.

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

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

Чтобы вернуться на web-страницу учебных курсов по многопоточному программированию, перейдите по