From 0f0d8bdfbfeab5006d11b95c6514ae5b2e1f3ffb Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 5 Mar 2023 14:19:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=BB=D0=B0=D0=B2=D0=B0=2013?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 13-параллельные-вычисления/README.md | 2635 ++++++++++++++++++++++++++ 1 file changed, 2635 insertions(+) diff --git a/13-параллельные-вычисления/README.md b/13-параллельные-вычисления/README.md index e69de29..5edaba3 100644 --- a/13-параллельные-вычисления/README.md +++ b/13-параллельные-вычисления/README.md @@ -0,0 +1,2635 @@ +# 13. Параллельные вычисления + +- [13.1. Революция в области параллельных вычислений]() +- [13.2. Краткая история механизмов разделения данных]() +- [13.3. Смотри, мам, никакого разделения (по умолчанию)]() +- [13.4. Запускаем поток]() + - [13.4.1. Неизменяемое разделение]() +- [13.5. Обмен сообщениями между потоками]() +- [13.6. Сопоставление по шаблону с помощью receive]() + - [13.6.1. Первое совпадение]() + - [13.6.2. Соответствие любому сообщению]() +- [13.7. Копирование файлов – с выкрутасом]() +- [13.8. Останов потока]() +- [13.9. Передача нештатных сообщений]() +- [13.10. Переполнение почтового ящика]() +- [13.11. Квалификатор типа shared]() + - [13.11.1. Сюжет усложняется: квалификатор shared транзитивен]() +- [13.12. Операции с разделяемыми данными и их применение]() + - [13.12.1. Последовательная целостность разделяемых данных]() +- [13.13. Синхронизация на основе блокировок через синхронизированные классы]() +- [13.14. Типизация полей в синхронизированных классах]() + - [13.14.1. Временная защита == нет утечкам]() + - [13.14.2. Локальная защита == разделение хвостов]() + - [13.14.3. Принудительные идентичные мьютексы]() + - [13.14.4. Фильм ужасов: приведение от shared]() +- [13.15. Взаимоблокировки и инструкция synchronized]() +- [13.16. Кодирование без блокировок с помощью разделяемых классов]() + - [13.16.1. Разделяемые классы]() + - [13.16.2. Пара структур без блокировок]() +- [13.17. Статические конструкторы и потоки]() +- [13.18. Итоги]() + +Благодаря сложившейся обстановке в индустрии аппаратного обеспече +ния качественно изменился способ доступа к вычислительным ресур +сам, которые, в свою очередь, требуют основательного пересмотра тех +ники вычислений и применяемых языковых абстракций. Сегодня ши +роко распространены параллельные вычисления, и программное обес +печение должно научиться извлекать из этого пользу. + +Несмотря на то что индустрия программного обеспечения в целом еще +не выработала окончательные ответы на вопросы, поставленные рево +люцией в области параллельных вычислений, молодость D позволила +его создателям, не связанным ни устаревшими концепциями прошло +го, ни огромным наследством базового кода, принять компетентные ре +шения относительно параллелизма. Главное отличие подхода D от стан +дарта поддерживающих параллелизм императивных языков – в том, +что он не поощряет разделение данных между потоками; по умолчанию +параллельные потоки фактически изолированы друг от друга с помо +щью механизмов языка. Разделение данных разрешено, но лишь в огра +ниченной управляемой форме, чтобы компилятор мог предоставлять +основательные глобальные гарантии. + +В то же время D, оставаясь в душе языком для системного программи +рования, разрешает применять ряд низкоуровневых, неконтролируе +мых механизмов параллельных вычислений. (При этом в безопасных +программах некоторые из этих механизмов использовать запрещено.) + +Вот краткий обзор уровней параллелизма, предлагаемых языком D: + +- Передовой подход к параллельным вычислениям заключается в ис +пользовании изолированных потоков или процессов, взаимодейст +вующих с помощью сообщений. Эта парадигма, называемая *обменом сообщениями* (*message passing*), позволяет создавать безопасные +модульные программы, легкие для понимания и сопровождения. +Обмен сообщениями успешно применяется в разнообразных языках +и библиотеках. Раньше обмен сообщениями был медленнее подходов, +основанных на разделении памяти, поэтому он и не стал общеприня +тым, но за последнее время здесь многое бесповоротно изменилось. +Параллельные программы на D используют обмен сообщениями – +парадигму, ориентированную на всестороннюю инфраструктурную +поддержку. +- D также поддерживает старомодную синхронизацию на основе кри +тических участков, защищенных мьютексами и флагами событий. +В последнее время этот подход к организации параллельных вычис +лений подвергается серьезной критике за недостаточную масшта +бируемость для настоящих и будущих параллельных архитектур. +D строго управляет разделением данных, ограничивая возможности +программирования с применением блокировок. На первый взгляд +это ограничение может показаться суровым, но оно избавляет осно +ванный на блокировках код от его злейшего врага – низкоуровневых +гонок за данными (ситуаций состязания). При этом разделение дан +ных остается наиболее эффективным средством передачи больших +объемов данных между потоками, так что пренебрегать им не стоит. +- По традиции языков системного уровня программы на D, не имею +щие атрибута `@safe`, могут посредством приведений достигать бес +препятственного разделения данных. За корректность таких про +грамм в основном отвечаете вы. +- Если вам мало предыдущего уровня, конструкция `asm` позволяет по +лучить полный контроль над машинными ресурсами. Для еще бо +лее низкоуровневого контроля потребуются микропаяльник и очень, +очень верная рука. + +Прежде чем с головой окунуться во все это, отвлечемся ненадолго, что +бы поближе присмотреться к тем аппаратным усовершенствованиям, +которые потрясли мир. + +[В начало ⮍](#13-параллельные-вычисления) + +## 13.1. Революция в области параллельных вычислений + +Что касается параллельных вычислений, то для них сейчас времена по +интереснее, чем когда-либо. Это времена, когда и хорошие, и плохие но +вости вписываются в общую панораму компромиссов, противоборств +и тенденций. + +Хорошие новости в том, что степень интеграции все еще растет по зако +ну Мура[^1]; судя по тому, что нам уже известно, и по тому, что мы сегодня +можем предположить, это продлится как минимум лет десять после +выхода этой книги. Курс на миниатюризацию означает рост плотности +вычислительной мощности пропорционально числу совместно работаю +щих транзисторов на единицу площади. Все ближе друг к другу компо +ненты, все короче соединения, а это означает повышение скорости ло +кальной связности – золотое дно в плане быстродействия. + +К сожалению, отдельные выводы, начинающиеся со слов «к сожале +нию», умеряют энтузиазм по поводу возросшей вычислительной плот +ности. Во-первых, существует не только локальная связность – она фор +мируется в иерархию: тесно связанные компоненты образуют бло +ки, которые должны связываться с другими блоками, образуя блоки +большего размера. В свою очередь, блоки большего размера также со +единяются с другими блоками большего размера, образуя функцио +нальные блоки еще большего размера, и т. д. На своем уровне связности +такие блоки остаются «далеки» друг от друга. Хуже того, возросшая +сложность каждого блока увеличивает сложность связей между блока +ми, что реализуется путем уменьшения толщины проводов и расстоя +ния между ними. Это означает рост сопротивления, электроемкости +и перекрестных помех. Перекрестные помехи – это способность сигнала +из одного провода распространяться на соседние провода посредством +(в данном случае) электромагнитного поля. На высоких частотах про +вод – практически антенна, и помехи становятся настолько невыноси +мыми, что сегодня параллельные соединения все чаще заменяют после +довательными (своего рода феномен нелогичности, заметный на всех +уровнях: USB заменил параллельный порт, в качестве интерфейса на +копителей данных SATA заменил PATA, а в подсистемах памяти после +довательные шины заменяют параллельные, и все из-за перекрестных +помех. Где те золотые деньки, когда параллельное было быстрее, а по +следовательное медленнее?). + +Кроме того, растет разрыв в производительности между вычислитель +ными элементами и памятью. В то время как плотность памяти, как +и ожидалось, увеличивается в соответствии с общей степенью интегра +ции, скорость доступа к ней все больше отстает от скорости вычислений +из-за множества разнообразных физических, технологических и ры +ночных факторов. В настоящее время неясно, что поможет сущест +венно сократить этот разрыв в быстродействии, и он лишь растет. Тыся +чи тактов могут отделять процессор от слова в памяти; а ведь еще не +сколько лет назад можно было купить микросхемы памяти «с нулевым +временем ожидания», обращение к которым осуществлялось за один +такт. + +Из-за широкого спектра архитектур памяти, представляющих собой +различные компромиссные решения относительно плотности, цены +и скорости, повысилась и изощренность иерархий памяти; обращение +к единственному слову памяти превратилось в детективное расследова +ние с опросом нескольких уровней кэша, начиная с драгоценного стати +ческого ОЗУ прямо на микросхеме и порой проходя весь путь до массо +вой памяти. Возможна и противоположная ситуация: копии указан +ных данных могут располагаться во множестве мест по всей иерархии. +Это, в свою очередь, тоже влияет на модели программирования. Мы +больше не можем позволить себе представлять память большим моно +литом, удобным для разделения всеми процессами системы: наличие +кэшей провоцирует рост локального трафика в памяти, превращая раз +деляемые данные в иллюзию, которую все труднее сопровождать. + +К последним сенсационным известиям относится то, что скорость света +упрямо решила оставаться неизменной (`immutable`, если хотите) – около +300 000 000 метров в секунду. Скорость же света в оксиде кремния (соот +ветствующая скорости распространения сигнала внутри современных +микросхем) составляет примерно половину этого значения, причем дос +тижимая сегодня скорость переноса самих данных существенно ниже +этого теоретического предела. Это означает больше проблем с глобаль +ной взаимосвязанностью на высоких частотах. Если бы у нас была мик +росхема с частотой 10 ГГц, то простое перемещение бита с одного на +другой конец этого чипа шириной 4,5 см (по сути, вообще без вычисле +ний) в идеальных условиях занимало бы три такта. + +Словом, наступает век процессоров очень высокой плотности и гигант +ской вычислительной мощности, при этом все более изолированных +и труднодоступных, которые сложно использовать из-за ограничений +взаимосвязности, скорости распространения сигнала и быстроты до +ступа к памяти. + +Компьютерная индустрия, естественно, обходит эти преграды. Одним +из феноменов стало резкое сокращение размеров и энергии, требуемых +для заданной вычислительной мощности; всего лишь пять лет назад +уровень технологии не позволял достичь компактности и возможно +стей КПК, без которых сегодня мы как без рук. При этом традицион +ные компьютеры, пытающиеся повысить вычислительную мощность +при тех же размерах, представляют все меньший интерес. Производи +тели микросхем для них уже не борются за повышение тактовой часто +ты, предлагая взамен вычислительную мощность в уже знакомой упа +ковке: несколько одинаковых центральных процессоров, соединенных +шинами друг с другом и с памятью. Так что спустя каких-то несколько +лет отвечать за разгон компьютеров будут не электронщики, а в основ +ном программисты. Вариант «побольше процессоров» может показать +ся довольно заманчивым, но типовым задачам настольного компьютера +не под силу эффективно использовать и восемь процессоров. В будущем +предполагается экспоненциальный рост числа доступных процессоров +до десятков, сотен и тысяч. При разгоне единственной программы про +граммистам придется очень много потрудиться, чтобы продуктивно ис +пользовать *все* эти процессоры. + +Из-за разных технологических и человеческих факторов в компьютер +ной индустрии постоянно случаются подвижки и сотрясения, но на +этот раз мы, кажется, дошли до точки. С недавних пор взять отпуск, +чтобы увеличить скорость работы программы, – уже не вариант. Это +возмутительно. Это подрыв устоев. Это революция в области парал +лельных вычислений. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.2. Краткая история механизмов разделения данных + +Один из аспектов перемен в компьютерной индустрии – внезапность, +с какой сегодня меняются модели обработки данных и параллелизма, +особенно на фоне темпа развития языков и парадигм программирова +ния. Чтобы язык и связанные с ним стили отпечатались в сознании со +общества программистов, нужны годы, даже десятки лет, а в области +параллелизма начиная с 2000-х все меняется в геометрической про +грессии. + +Например, наше прошлогоднее понимание основ параллелизма[^2] тяготе +ло к разделению данных, порожденному мейнфреймами 1960-х. Тогда +процессорное время было настолько дорогим, что повысить общую эф +фективность использования процессора можно было, только разделяя +его между множеством программ, управляемых множеством операто +ров. *Процесс* определялся и определяется как совокупность состояния +и ресурсов исполняющейся программы. Процессор (центральное про +цессорное устройство, ЦПУ) реализует разделение времени с помощью +планировщика задач и прерываний таймера. По каждому прерыванию +таймера планировщик решает, какому процессу предоставить ЦПУ на +следующий квант времени, создавая таким образом иллюзию одновре +менного исполнения нескольких процессов, хотя на самом деле все они +используют одно и то же ЦПУ. + +Чтобы ошибочные процессы не повредили друг другу и коду операцион +ной системы, была введена *аппаратная защита памяти*. Для надеж +ной изоляции процессов в современных системах защиту памяти соче +тают с *виртуализацией памяти*: каждый процесс считает память ма +шины «своей собственностью», хотя на самом деле все взаимодействие +между процессом и памятью, а также изоляцию процессов друг от дру +га берет на себя уровень-посредник, транслирующий логические адреса +(так видит память процесс) в физические (так обращается к памяти ма +шина). Хорошие новости в том, что процессы, вышедшие из-под конт +роля, могут навредить только себе, но не другим процессам и не ядру +операционной системы. Новости похуже в том, что каждое переключе +ние задач требует потенциально дорогой смены адресных пространств +процессов, не говоря о том, что каждый процесс при переключении на +него «просыпается» с амнезией кэша, поскольку глобальный кэш обыч +но используется всеми процессами. Так и появились *потоки* (*threads*). +Поток – это процесс, не владеющий информацией о том, как трансли +ровать адреса; это чистый контекст исполнения: состояние процессора +плюс стек. Несколько потоков разделяют адресное пространство про +цесса, то есть порождать потоки и переключаться между ними относи +тельно дешево, и они могут с легкостью и без особых затрат разделять +данные друг с другом. Разделение памяти между потоками, запущен +ными на одном ЦПУ, осуществляется настолько прямолинейно, на +сколько это возможно: один поток пишет, другой читает. При использо +вании техники разделения времени порядок записи данных, естествен +но, совпадает с порядком, в котором эти записи будут видны другим +потокам. Поддержку более высокоуровневых инвариантов данных +обеспечивают механизмы блокировки, например критические секции, +защищенные с помощью примитивов синхронизации (таких как сема +форы и мьютексы). В последние годы XX века то, что можно назвать +«классическим» многопоточным программированием (которое харак +теризуется разделяемым адресным пространством, простыми правила +ми видимости изменений и синхронизацией на мьютексах), обросло +массой наблюдений, народных мудростей и анекдотов. Существовали +и другие модели организации параллельных вычислений, но на боль +шинстве машин применялась классическая многопоточность. + +Основные императивные языки наших дней (такие как C, C++, Java) +развивались в век классической многопоточности – в старые добрые +времена простых архитектур памяти, понятных примитивов взаимо +блокировки и разделения данных без изысков. Языки, естественно, мо +делировали реалии аппаратного обеспечения того времени (когда подра +зумевалось, что потоки разделяют одну и ту же область памяти) и вклю +чали соответствующие средства. В конце концов само определение мно +гопоточности подразумевает, что все потоки, в отличие от процессов +операционной системы, разделяют одно общее адресное пространство. +Кроме того, API для реализации обмена сообщениями (например, спе +цификация MPI [29]) были доступны лишь в форме библиотек, изна +чально созданных для специализированного дорогостоящего аппарат +ного обеспечения, такого как кластеры (супер)компьютеров. + +Тогда еще только зарождающиеся функциональные языки заняли прин +ципиальную позицию, основанную на математической чистоте: «Мы не +заинтересованы в моделировании аппаратного обеспечения, – сказали +они. – Нам хотелось бы моделировать математику». А в математике ред +ко что-то меняется, математические результаты инвариантны во време +ни, что делает математические вычисления идеальным кандидатом для +распараллеливания. (Только представьте, как первые программисты – +вчерашние математики, услышав о параллельных вычислениях, чешут +затылки, восклицая: «Минуточку!..») Функциональные программисты +убеждены, что такая модель вычислений поощряет неупорядоченное, +параллельное выполнение, однако до недавнего времени эта возможно +сть являлась скорее потенциальной энергией, чем достигнутой целью. +Наконец был разработан язык Erlang. Он начал свой путь в конце 1980-х +как предметно-ориентированный встроенный язык приложений для +телефонии. Предметная область, предполагая десятки тысяч программ, +одновременно запущенных на одной машине, заставляла отдать пред +почтение обмену сообщениями, когда информация передается в стиле +«выстрелил – забыл». Аппаратное обеспечение и операционные систе +мы по большей части не были оптимизированы для таких нагрузок, но +Erlang изначально запускался на специализированной платформе. В ре +зультате получился язык, оригинальным образом сочетающий нечис +тый функциональный стиль, серьезные возможности для параллель +ных вычислений и стойкое предпочтение обмена сообщениями (ника +кого разделения памяти!). + +Перенесемся в 2010-е. Сегодня даже у средних машин больше одного +процессора, а главная задача десятилетия – уместить на кристалле как +можно больше ЦПУ. Отсюда и последствия, самое важное из которых – +конец монолитной разделяемой памяти. + +С одним разделяемым по времени ЦПУ связана одна подсистема памя +ти – с буферами, несколькими уровнями кэшей, все по полной програм +ме. Независимо от того, как ЦПУ управляет разделением времени, чте +ние и запись проходят по одному и тому же маршруту, а потому видение +памяти у разных потоков остается когерентным. Несколько взаимосвя +занных ЦПУ, напротив, не могут позволить себе разделять подсистему +кэша: такой кэш потребовал бы мультипортового доступа (что дорого +и слабо масштабируемо), и его было бы трудно разместить в непосредст +венной близости ко всем ЦПУ сразу. Вот почему практически все совре +менные ЦПУ производятся со своей кэш-памятью, предназначенной +лишь для их собственных нужд. Производительность мультипроцес +сорной системы зависит главным образом от аппаратного обеспечения +и протоколов, соединяющих комплексы ЦПУ+кэш. + +Несколько кэшей превращают разделение данных между потоками +в чертовски сложную задачу. Теперь операции чтения и записи в раз +ных потоках могут затрагивать разные кэши, поэтому сделать так, что +бы один поток делился данными с другим, стало сложнее, чем раньше. +На самом деле, этот процесс превращается в своего рода обмен сообще +ниями[^3]: в каждом случае такого разделения между подсистемами кэ +шей должно иметь место что-то вроде рукопожатия, обеспечивающего +попадание разделяемых данных от последнего записавшего потока к чи +тающему потоку, а также в основную память. + +Протоколы синхронизации кэшей добавляют к сюжету еще один пово +рот (хотя и без него все было достаточно лихо закручено): они восприни +мают данные только блоками, не предусматривая чтение и запись от +дельных слов. То есть общающиеся друг с другом процессы «не помнят» +точный порядок, в котором записывались данные, что приводит к пара +доксальному поведению, которое не поддается разумному объяснению +и противоречит здравому смыслу: один поток записывает x, а затем y, +и в некоторый промежуток времени другой поток видит новое y, но ста +рое x. Такие нарушения причинно-следственных связей слабо вписыва +ются в общую модель классической многопоточности. Даже наиболее +сведущим в классической многопоточности программистам невероятно +трудно адаптировать свой стиль и шаблоны программирования к но +вым архитектурам памяти. + +Проиллюстрируем скоростные изменения в современных параллель +ных вычислениях и серьезное влияние разделения данных на подходы +языков к параллелизму советом из чудесной книги «Java. Эффективное +программирование» издания 2001 года [8, разд. 51, с. 204]: + +> «Если есть несколько готовых к исполнению потоков, планировщик пото +ков определит, какие потоки должны запуститься и на какое время… Луч +ший способ написать отказоустойчивое, оперативное и переносимое прило +жение – стараться иметь минимум готовых к исполнению потоков в любой +момент времени.» + +Сегодняшний читатель сразу же отметит поразительную деталь: здесь +не просто говорится об однопроцессорном программировании с много +поточностью на основе разделения времени, но подразумевается един +ственность процессора, хоть и без явной констатации. Естественно, что +в издании 2008 года[^4] этот совет был изменен на «стремиться к тому, +чтобы среднее число готовых к исполнению потоков было ненамного +больше числа процессоров». Любопытно, что даже этот совет, на вид ра +зумный, подразумевает два невысказанных допущения: 1) за счет дан +ных потоки будут сильно связаны друг с другом, что в свою очередь +приведет к снижению быстродействия из-за накладных расходов на +взаимоблокировки, и 2) число процессоров на машинах, где может за +пускаться программа, примерно одинаково. И тогда этот совет полно +стью противоположен тому, что настойчиво повторяется в книге «Про +граммирование на языке Erlang» [5, глава 20, с. 363]: + +> «**Используйте много процессоров**. Это важно: мы должны держать свои +ЦПУ в занятом состоянии. Все ЦПУ должны быть заняты в каждый момент +времени. Легче всего достигнуть этого, имея много процессов[^5]. Говоря „мно +го процессов“, я имею в виду много по отношению к количеству ЦПУ. Если +у нас много процессов, то о занятом состоянии для ЦПУ можно не беспоко +иться.» + +Какой из этих трех рекомендаций следовать? Как обычно, все зависит от +обстоятельств. Первая прекрасно подходит для аппаратного обеспечения +2001 года; вторая – для сценариев, характеризующихся интенсивной +работой с разделяемыми данными и, следовательно, жестким соперни +чеством; третья полезна в условиях слабого соперничества и большого +количества ЦПУ. + +Поддерживать разделение памяти все сложнее, этот подход к организа +ции параллельных вычислений начинает казаться неубедительным, +в моду входят функциональность и обмен сообщениями. Неудивитель +но, что в последние годы растет интерес к Erlang и другим функцио +нальным языкам, удобным для разработки приложений с параллель +ными вычислениями. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.3. Смотри, мам, никакого разделения (по умолчанию) + +Вследствие последних усовершенствований аппаратного и программ +ного обеспечения D решил отойти от других императивных языков: да, +язык D поддерживает потоки, но они не разделяют никакие изменяе +мые данные по умолчанию – они изолированы друг от друга. Изоляция +обеспечивается не аппаратно (как в случае с процессами) и не с помо +щью проверок времени исполнения; она является естественным следст +вием устройства системы типов D. + +Это решение в духе функциональных языков, которые также старают +ся запретить любые изменения, а значит, и разделение изменяемых +данных. Но есть два различия. Во-первых, программы на D все же мо +гут свободно использовать изменяемые данные – закрыта лишь воз +можность непреднамеренного обращения к изменяемым данным для +других потоков. Во-вторых, «никакого разделения» – это лишь выбор +*по умолчанию*, но не *единственный* возможный. Чтобы определить дан +ные как разделяемые между потоками, необходимо уточнить их опре +деление с помощью ключевого слова `shared`. Рассмотрим пример двух +простых определений, размещенных в корне модуля: + +```d +int perThread; +shared int perProcess; +``` + +В большинстве языков первое определение (или его синтаксический эк +вивалент) означало бы ввод глобальной переменной, используемой все +ми потоками, но в D у переменной `perThread` есть отдельная копия для +каждого потока. Второе определение выделяет память лишь под одно +значение типа `int`, разделяемое всеми потоками, так что в некотором ро +де оно ближе (но не идентично) к традиционной глобальной переменной. + +Переменная `perThread` сохраняется при помощи средства операционной +системы, называемого локальным хранилищем потока (thread-local +storage, TLS). Скорость доступа к данным, память под которые выделе +на в TLS, зависит от реализации компилятора и базовой операционной +системы. В общем случае эта скорость лишь незначительно меньше, +скажем, скорости обращения к обычной глобальной переменной в про +грамме на C. В редких случаях, когда эта разница может иметь значе +ние, например в циклах, где делается множество обращений к перемен +ной в TLS, можно загрузить глобальную переменную в стековую. + +У такого подхода есть два важных преимущества. Во-первых, языки, +по умолчанию использующие разделение, должны тщательно синхро +низировать доступ к глобальным данным; для `perThread` же это необяза +тельно, потому что у каждого потока есть ее локальная копия. Во-вто +рых, квалификатор `shared` означает, что и система типов, и програм +мист в курсе, что к переменной `perProcess` одновременно обращаются +многие потоки. В частности, система типов активно защищает разде +ляемые данные, запрещая использовать их очевидно некорректным об +разом. D переворачивает традиционные представления с ног на голову: +в режиме разделения по умолчанию программист обязан вручную от +слеживать, какие данные разделяются, а какие нет, и ведь на самом де +ле, большинство ошибок, имеющих место при параллельных вычисле +ниях, бывают вызваны чрезмерным или незащищенным разделением +данных. В режиме явного разделения программист точно знает, что +данные, не помеченные квалификатором `shared`, действительно будут +видны только одному потоку. (Для обеспечения такой гарантии значе +ния с пометкой `shared` проходят дополнительные проверки, до которых +мы скоро доберемся.) + +Использование разделяемых данных остается делом не для новичков, +поскольку, хотя система типов и обеспечивает низкоуровневую коге +рентность, автоматически обеспечить соблюдение высокоуровневых +инвариантов невозможно. Наиболее предпочтительный метод органи +зации безопасного, простого и эффективного обмена информацией меж +ду потоками – использовать парадигму *обмена сообщениями*. Обладаю +щие изолированной памятью потоки взаимодействуют, отправляя друг +другу асинхронные сообщения, состоящие попросту из совместно упа +кованных значений D. + +Изолированные работники, общающиеся друг с другом с помощью про +стых каналов коммуникации, – это очень надежный, проверенный вре +менем подход к параллелизму. Язык Erlang и приложения, использую +щие спецификацию интерфейса передачи сообщений (Message Passing +Interface, MPI), применяют его уже давно. + +> Намажем мед на пластырь[^6]. Даже в языках, использующих разделение дан +ных по умолчанию, хорошая практика программирования фактически +предписывает изолировать потоки. Герб Саттер, известный эксперт по па +раллельным вычислениям, в статье с красноречивым названием «Исполь +зуйте потоки правильно = изоляция + асинхронные сообщения» пишет: +«Потоки – это низкоуровневый инструмент для выражения асинхронных +действий. „Приподнимите“ их, установив строгую дисциплину: старайтесь +делать их данные локальными, а синхронизацию и обмен информацией ор +ганизовывать через асинхронные сообщения. Всякий поток, которому нуж +но получать информацию от других потоков или от людей, должен иметь +очередь сообщений (простую очередь FIFO или очередь с приоритетами) и ор +ганизовывать свою работу, ориентируясь на управляемую событиями пом +повую магистраль сообщений; замена запутанной логики событийной логи +кой – чудесный способ улучшить ясность и детерминированность кода.» + +Если и есть что-то, чему нас научили десятилетия компьютерных вы +числений, так это то, что программирование на базе дисциплины не +масштабируется[^7]. Но пользователи D могут вздохнуть с облегчением: +в данной цитате в основном очень точно изложены тезисы нескольких +следующих частей – кроме того, что касается дисциплины. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.4. Запускаем поток + +Для запуска потока воспользуйтесь функцией `spawn`, как здесь: + +```d +import std.concurrency, std.stdio; + +void main() +{ + auto low = 0, high = 100; + spawn(&fun, low, high); + foreach (i; low .. high) + { + writeln("Основной поток: ", i); + } +} + +void fun(int low, int high) +{ + foreach (i; low .. high) + { + writeln("Дочерний поток: ", i); + } +} +``` + +Функция `spawn` принимает адрес функции `fun` и список аргументов `‹a1›`, +`‹a2›`, ..., `‹an›`. Число аргументов `n` и их типы должны соответствовать сиг +натуре функции `fun`, иными словами, вызов `fun(‹a1›, ‹a2›, ..., ‹an›)` дол +жен быть корректным. Эта проверка выполняется во время компиля +ции. `spawn` создает новый поток выполнения, который инициирует вы +зов `fun(‹a1›, ‹a2›, ..., ‹an›)`, а затем завершает свое выполнение. Конечно +же, функция `spawn` не ждет, когда поток закончит выполняться, – она +возвращает управление сразу же после создания потока и передачи ему +аргументов (в данном случае двух целых чисел). + +Эта программа выводит в стандартный поток вывода в общей сложно +сти 200 строк. Порядок следования этих строк зависит от множества +факторов; вполне возможно, что вы увидите 100 строк из основного по +тока, а затем 100 строк из побочного, в точности противоположную по +следовательность или некоторое чередование, кажущееся случайным. +Однако в одной строке никогда не появится смесь из двух сообщений. +Потому что функция `writeln` определена так, чтобы каждый вызов был +атомарен по отношению к потоку вывода. Кроме того, порядок строк, +в котором они порождаются каждым из потоков, также будет соблюден. + +Даже если выполнение `main` завершится до окончания выполнения `fun` +в дочернем потоке, программа будет терпеливо ждать того момента, ко +гда завершатся все потоки, и только тогда завершит свое выполнение. +Ведь библиотека поддержки времени исполнения подчиняется неболь +шому протоколу завершения выполнения программ, о котором мы по +говорим позже; а пока лишь возьмем на заметку, что даже если `main` воз +вращает управление, другие потоки не умирают тут же. + +Как и было обещано, у только что созданного потока нет ничего общего +с потоком, инициировавшим его. Ну, почти ничего: глобальный де +скриптор файла `stdout` *де факто* разделяется между всеми потоками. +Но все же жульничества тут нет: если вы взглянете на реализацию мо +дуля `std.stdio`, то увидите, что `stdout` определяется там как глобальная +разделяемая переменная. Все грамотно просчитано в системе типов. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +### 13.4.1. Неизменяемое разделение + +Какие именно функции можно вызывать из `spawn`? Установка на отсут +ствие разделения налагает определенные ограничения: в функцию, за +пускающую поток (в рассмотренном выше примере это функция `fun`), +параметры можно передавать лишь по значению. Любая передача по +ссылке, как явная (в виде параметра с квалификатором `ref`), так и неяв +ная (например, с помощью массива), должна быть под запретом. Имея +в виду это условие, обратимся к новой версии предыдущего примера: + +```d +import std.concurrency, std.stdio; + +void main() +{ + auto low = 0, high = 100; + auto message = "Да, привет #"; + spawn(&fun, message, low, high); + foreach (i; low .. high) + { + writeln("Основной поток: ", message, i); + } +} + +void fun(string text, int low, int high) +{ + foreach (i; low .. high) + { + writeln("Дочерний поток: ", text, i); + } +} +``` + +Переписанный пример идентичен исходному, за исключением того, что +печатает еще одну строку. Эта строка создается в основном потоке и пе +редается в дочерний поток без копирования. По сути, содержание `message` +разделяется между потоками. Таким образом, нарушен вышеупомяну +тый принцип, гласящий, что любое разделение данных должно быть +явно помечено ключевым словом `shared`. Тем не менее код этого примера +компилируется и запускается. Что же происходит? + +В главе 8 сообщается, что квалификатор `immutable` предоставляет серь +езные гарантии: гарантируется, что помеченное этим ключевым словом +значение ни разу не изменится за всю свою жизнь. В той же главе объ +ясняется (см. раздел 8.2), что тип `string` – это на самом деле псевдоним +для типа `immutable(char)[]`. Наконец, мы знаем, что все споры возникают +из-за разделения *изменяемых* данных – пока никто данные не изменя +ет, можно свободно разделять их, ведь все будут видеть в точности одно +и то же. Система типов и инфраструктура потоков в целом признают +этот факт, разрешая разделять между потоками все данные, помечен +ные квалификатором `immutable`. В частности, можно разделять значе +ния типа `string`, отдельные знаки которых изменить невозможно. На +самом деле, своим появлением в языке квалификатор `immutable` не в по +следнюю очередь обязан той помощи, которую он оказывает при разде +лении структурированных данных между потоками. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.5. Обмен сообщениями между потоками + +Потоки, печатающие сообщения в произвольном порядке, малоинтерес +ны. Изменим наш пример так, чтобы обеспечить работу потоков в тан +деме. Добьемся, чтобы они печатали сообщения следующим образом: + +``` +Основной поток: 0 +Дочерний поток: 0 +Основной поток: 1 +Дочерний поток: 1 +... +Основной поток: 99 +Дочерний поток: 99 +``` + +Для этого потребуется определить небольшой протокол взаимодейст +вия двух потоков: основной поток должен отправлять дочернему пото +ку сообщение «Напечатай это число», а дочерний – отвечать «Печать +завершена». Вряд ли здесь имеют место какие-либо параллельные вы +числения, но такой пример наглядно объясняет, как организуется взаи +модействие в чистом виде. В настоящих приложениях большую часть +своего времени потоки должны заниматься полезной работой, а на об +щение тратить лишь сравнительно малое время. + +Начнем с того, что для взаимодействия двух потоков им требуется знать, +как обращаться друг к другу. В программе может быть много перегова +ривающихся потоков, так что средство идентификации необходимо. +Чтобы обратиться к потоку, нужно получить возвращаемый функцией +`spawn` *идентификатор потока* (*thread id*), который с этих пор мы будем +неофициально называть «tid». (Тип tid так и называется – `Tid`.) Дочер +нему потоку, в свою очередь, также нужен tid, для того чтобы отпра +вить ответ. Это легко организовать, заставив отправителя указать соб +ственный `Tid`, как пишут адрес отправителя на конверте. Вот этот код: + +```d +import std.concurrency, std.stdio, std.exception; + +void main() +{ + auto low = 0, high = 100; + auto tid = spawn(&writer); + foreach (i; low .. high) + { + writeln("Основной поток: ", i); + tid.send(thisTid, i); + enforce(receiveOnly!Tid() == tid); + } +} + +void writer() +{ + for (;;) + { + auto msg = receiveOnly!(Tid, int)(); + writeln("Дочерний поток: ", msg[1]); + msg[0].send(thisTid); + } +} +``` + +Теперь функции `writer` аргументы не нужны: всю необходимую инфор +мацию она получает в форме сообщений. Основной поток сохраняет `Tid`, +возвращенный функцией `spawn`, а затем использует его при вызове мето +да `send`. С помощью этого вызова другому потоку отправляются два +фрагмента данных: `Tid` текущего потока (доступ к которому предостав +ляет глобальная переменная `thisTid`) и целое число, которое нужно на +печатать. Перекинув данные через забор другому потоку, основной по +ток начинает ждать подтверждение того, что его сообщение получено, +в виде вызова `receiveOnly`. Функции `send` и `receiveOnly` работают в танде +ме: всякому вызову `send` в одном потоке ставится в соответствие вызов +`receiveOnly` в другом. В названии `receiveOnly` присутствует слово «only» +(только), потому что `receiveOnly` принимает только определенные типы, +например, инициатор вызова `receiveOnly!bool()` принимает лишь сооб +щения в виде логических значений; если другой поток отправляет что- +либо другое, `receiveOnly` порождает исключение типа `MessageMismatch`. + +Предоставим `main` копаться в цикле `foreach` и сосредоточимся на функ +ции `writer`, реализующей вторую часть нашего мини-протокола. `writer` +коротает время в цикле, начинающемся получением сообщения, кото +рое должно состоять из значения типа `Tid` и значения типа `int`. Именно +это обеспечивает вызов `receiveOnly!(Tid, int)()`; опять же, если бы основ +ной поток отправил сообщение с каким-либо иным количеством аргу +ментов или с аргументами других типов, `receiveOnly` прервала бы свое +выполнение по исключению. Как уже говорилось, вызов `receiveOnly` +в теле `writer` полностью соответствует вызову `tid.send(thisTid, i)` из `main`. + +Типом `msg` является `Tuple!(Tid, int)`. В общем случае сообщения со мно +жеством аргументов упаковываются в кортежи, так что одному члену +кортежа соответствует один аргумент. Но если сообщение состоит всего +из одного значения, лишние движения не нужны, и упаковка в `Tuple` +опускается. Например, `receiveOnly!int()` возвращает `int`, а не `Tuple!int`. + +Продолжим разбор `writer`. Следующая строка, собственно, выполняет +печать (запись в консоль). Вспомните, что для кортежа `msg` выражение +`msg[0]` означает обращение к первому члену кортежа (то есть к `Tid`), а вы +ражение `msg[1]` – доступ к его второму члену (к целому числу). Наконец, +`writer` посылает уведомление о том, что завершила запись в консоль, по +просту отправляя собственный `Tid` отправителю предыдущего сообще +ния – своего рода пустой конверт, лишь подтверждающий личность от +правителя. «Да, я получил твое сообщение, – подразумевает пустое +письмо, – и принял соответствующие меры. Твоя очередь.» Основной +поток не продолжит работу, пока не получит такое уведомление, но как +только это произойдет, цикл начнет выполняться дальше. + +Отправлять `Tid` дочернего потока назад в данном случае излишне – хва +тило бы любой болванки, например `int` или `bool`. Однако в общем случае +в программе есть много потоков, отправляющих друг другу сообщения, +так что самоидентификация становится важна. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.6. Сопоставление по шаблону с помощью receive + +Большинство полезных протоколов взаимодействия сложнее, чем опре +деленный выше. Возможности, которые предоставляет `receiveOnly`, весь +ма ограничены. Например, с помощью `receiveOnly` довольно сложно реа +лизовать такой маневр, как «получить `int` или `string`». + +Гораздо более мощным примитивом является функция `receive`, которая +сопоставляет и диспетчирует сообщения в зависимости от их типа. Ти +пичный вызов `receive` выглядит так: + +```d +receive( + (string s) { writeln("Получена строка со значением ", s); }, + (int x) { writeln("Получено число со значением ", x); } +); +``` + +При сопоставлении этого вызова со следующими вызовами `send` во всех +случаях будет наблюдаться совпадение: + +```d +send(tid, "здравствуй"); +send(tid, 5); +send(tid, 'a'); +send(tid, 42u); +``` + +Первый вызов `send` соответствует типу `string` и направляется в литерал +функции, определенный в `receive` первым; остальные три вызова соот +ветствуют типу `int` и передаются во второй функциональный литерал. +Кстати, в качестве функций-обработчиков необязательно использовать +литералы – какие-то (или даже все) обработчики могут быть адресами +именованных функций: + +```d +void handleString(string s) { ... } +receive( + &handleString, + (int x) { writeln("Получено число со значением ", x); } +); +``` + +Сопоставление не является досконально точным; вместо того чтобы тре +бовать точного совпадения, соблюдают обычные правила перегрузки, +в соответствии с которыми `char` и `uint` могут быть неявно преобразованы +в `int`. При сопоставлении следующих вызовов соответствие, напротив, +обнаружено *не будет*: + +```d +send(tid, "hello"w); // Строка в кодировке UTF-16 (см. раздел 4.5) +send(tid, 5L); // long +send(tid, 42.0); // double +``` + +Когда функция `receive` видит сообщение неожиданного типа, она не по +рождает исключение (как это делает `receiveOnly`). Подсистема обмена со +общениями просто сохраняет неподходящие сообщения в очереди, в на +роде называемой *почтовым ящиком* (*mailbox*) потока. `receive` терпели +во ждет, когда в почтовом ящике появится сообщение нужного типа. +Такая политика делает `receive` и протоколы, реализованные на базе +этой функции, более гибкими, но и более восприимчивыми к блокиро +ванию и переполнению ящика. Одно недоразумение при обмене инфор +мацией – и в ящике потока начнут накапливаться сообщения не тех +типов, а `receive` тем временем будет ждать сообщения, которое никогда +не придет. + +Пользуясь посредническими услугами `Tuple`, дуэт `send`/`receive` с легко +стью обрабатывает и группы аргументов. Например: + +```d +receive( + (long x, double y) { ... }, + (int x) { ... } +); +``` + +соответствуют те же сообщения, что и + +```d +receive( + (Tuple!(long, double) tp) { ... }, + (int x) { ... } +); +``` + +Такой вызов, как `send(tid, 5, 6.3)`, соответствует первому функциональ +ному литералу как первого, так и второго предыдущих примеров. + +Существует особая версия `receive` – функция `receiveTimeout`, позволяю +щая потоку предпринять экстренные меры в случае задержки сообще +ний. У `receiveTimeout` есть «срок годности»: она завершает свое выполне +ние по истечении указанного промежутка времени. Об истечении «от +пущенного времени» `receiveTimeout` сообщает, возвращая `false`: + +```d +auto gotMessage = receiveTimeout( + 1000, // Время в милисекундах + (string s) { writeln("Получена строка со значением ", s); }, + (int x) { writeln("Получено число со значением ", x); } +); + +if (!gotMessage) { + stderr.writeln("Выполнение прервано по прошествии одной секунды."); +} +``` + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +### 13.6.1. Первое совпадение + +Рассмотрим пример: + +```d +receive( + (long x) { ... }, + (string x) { ... }, + (int x) { ... } +); +``` + +Такой вызов не скомпилируется: `receive` отвергает этот вызов, посколь +ку третий обработчик недостижим при любых условиях. Любое отправ +ленное по каналу передачи значение типа `int` застревает в первом обра +ботчике. + +Порядок аргументов `receive` определяет, каким образом осуществляют +ся попытки сопоставления. Принцип тот же, что и при вычислении бло +ков `catch` в инструкции `try`, но не при объектно-ориентированной диспет +черизации функций. Единого мнения насчет относительных преиму +ществ и недостатков использования первого совпадения или же лучше +го совпадения – нет; достаточно сказать, что, по всей видимости, первое +совпадение хорошо подходит для этого конкретного случая `receive`. + +Выполнение принципа первого совпадения обеспечивается функцией +`receive` с помощью простого анализа, выполняемого во время компиля +ции. Для любых типов сообщения `‹Сбщ1›` и `‹Сбщ2›` справедливо, что, если +в вызове `receive` обработчик `‹Сбщ2›` следует после обработчика `‹Сбщ1›`, +`receive` гарантирует, что тип `‹Сбщ2›` *невозможно* неявно преобразовать +в тип `‹Сбщ1›`. Если можно, то это означает, что обработчик `‹Сбщ1›` будет ло +вить сообщения `‹Сбщ2›`, так что в компиляции такому вызову будет отка +зано. Выполнение этой проверки для предыдущего примера завершает +ся неудачей в процессе той итерации, когда `‹Сбщ1›` присваивается значе +ние `long`, а `‹Сбщ2›` – `int`. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +### 13.6.2. Соответствие любому сообщению + +Что если бы вы пожелали обеспечить просмотр абсолютно всех сообще +ний в почтовом ящике – например, для уверенности в том, что он не пе +реполнится мусором? + +Ответ прост – нужно всего лишь включить обработчик сообщений типа +`Variant` последним в список аргументов `receive`. Например: + +```d +receive( + (long x) { ... }, + (string x) { ... }, + (double x, double y) { ... }, + ... + (Variant any) { ... } +); +``` + +Тип `Variant`, определенный в модуле `std.variant`, – это динамический +тип, вмещающий ровно одно значение любого другого типа. `receive` вос +принимает `Variant` как обобщенный контейнер для любого типа сообще +ния, а потому вызов `receive` с обработчиком для типа `Variant` всегда бу +дет отработан, если в очереди есть хотя бы одно сообщение. + +Расположить обработчик `Variant` в конце цепочки обработки сообще +ний – хороший способ избавить ваш почтовый ящик от случайных со +общений. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.7. Копирование файлов – с выкрутасом + +Напишем коротенькую программу для копирования файлов – один из +популярных способов познакомиться с интерфейсом языка файловой +системы. Классический пример в стиле Кернигана и Ричи целиком на +паре команд `getchar`/`putchar`! [34, глава 1, с. 15]. Конечно же, чтобы уско +рить передачу, «родные» программы системы, копирующие файлы, +практикуют буферное чтение и буферную запись, а также используют +множество других методов оптимизации, так что написать конкурен +тоспособную программу было бы сложно, однако параллельные вычис +ления нам помогут. + +Обычный способ копирования файлов: + +1. Прочесть данные из исходного файла и поместить в буфер. +2. Если ничего не было прочитано, копирование завершено. +3. Записать данные из буфера в целевой файл. +4. Повторить заново, начиная с шага 1. + +Добавление соответствующей обработки ошибок завершит полезную +(но не оригинальную) программу. Если размер буфера будет выбран дос +таточно большим, а оба файла (и источник, и целевой файл) окажутся +на одном и том же диске, быстродействие этого алгоритма приблизится +к оптимальному. + +В наше время файловыми хранилищами могут быть многие физические +устройства: жесткие диски, флеш-диски, оптические диски, подсоеди +ненные смартфоны, а также сетевые сервисы удаленного доступа. Эти +устройства характеризуются разнообразными показателями задержки +и скорости и подключаются с помощью разных аппаратных и программ +ных интерфейсов. Такие интерфейсы могут работать параллельно (а не +по одному в каждый момент времени, как предписывает алгоритм в сти +ле «прочесть данные из буфера/записать данные в буфер»), и именно +так и нужно их использовать. В идеале должна поддерживаться макси +мальная занятость как устройства-источника, так и устройства-полу +чателя, что мы можем изобразить как два потока, работающих по про +токолу «поставщик/потребитель»: + +1. Породить один дочерний поток, который в цикле ждет сообщений, +содержащих буферы памяти, и записывает их в целевой файл. +2. Прочесть данные из исходного файла и разместить их в заново соз +данном буфере. +3. Если ничего не было прочитано, копирование завершено. +4. Отправить дочернему потоку сообщение, содержащее буфер с прочи +танными данными. +5. Повторить, начав с шага 2. + +С таким подходом один поток будет работать с источником, а другой – +с приемником. В зависимости от природы «исходного пункта» и «пунк +та назначения» можно получить значительное ускорение. Если скоро +сти устройств сравнимы и невелики относительно пропускной способ +ности шины памяти, теоретически скорость копирования может быть +удвоена. Напишем простую программу, которая реализует модель «по +ставщик/потребитель» и копирует содержимое стандартного потока +ввода в стандартный поток вывода: + +```d +import std.concurrency, std.stdio; + +void main() +{ + enum bufferSize = 1024 * 100; + auto tid = spawn(&fileWriter); + // Цикл чтения + foreach (ubyte[] buffer; stdin.byChunk(bufferSize)) + { + send(tid, buffer.idup); + } +} + +void fileWriter() +{ + // Цикл записи + for (;;) + { + auto buffer = receiveOnly!(immutable(ubyte)[])(); + stdout.rawWrite(buffer); + } +} +``` + +В этой программе данные из основного потока передаются в дочерний +поток посредством разделения неизменяемых данных: передаваемые +сообщения имеют тип `immutable(ubyte)[]`, то есть являются массивами +неизменяемых значений типа `ubyte`. Эти буферы создаются в цикле +`foreach` при чтении данных из входного потока порциями, каждая из ко +торых имеет тип `immutable(ubyte)[]` и размер `bufferSize`. На каждом про +ходе цикла функция `byChunk` читает данные во временный буфер (пере +менную `buffer`), неизменная копия которого создается свойством `idup`. +Большую часть тяжелой работы выполняет управляющая часть `foreach`; +на долю тела этой конструкции остается лишь создание копии и от +правка буфера дочернему потоку. Как уже говорилось, передача данных +между потоками возможна благодаря присутствию квалификатора +`immutable`; если заменить `idup` на `dup`, вызов `send` не скомпилируется. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.8. Останов потока + +В приводившихся до сих пор примерах есть кое-что необычное, в част +ности в функции `writer`, определенной в разделе 13.5, и в только что опре +деленной функции `fileWriter` из раздела 13.7: обе функции содержат +бесконечный цикл. На самом деле, повнимательнее взглянув на пример +с копированием файлов, можно заметить, что `main` и `fileWriter` прекрас +но понимают друг друга в разговоре о копировании, но никогда не обсу +ждают друг с другом останов приложения; другими словами, `main` нико +гда не говорит `fileWriter`: «Дело сделано, собирайся и пойдем домой». + +Останов многопоточных приложений всегда был делом мудреным. По +ток легко запустить, но запустив, трудно остановить; завершение рабо +ты приложения – событие асинхронное и может застать приложение за +выполнением совершенно произвольной операции. Низкоуровневые API +для работы с потоками предоставляют средство для принудительного +останова потоков, неизменно сопровождая его предупреждением о чрез +мерной грубости этого инструмента и рекомендацией об использовании +какого-нибудь более высокоуровневого протокола завершения работы. + +D предоставляет простой и надежный протокол останова потоков. Каж +дый поток обладает *потоком-владельцем*; по умолчанию владельцем +считается поток, инициировавший вызов функции `spawn`. Владельца те +кущего потока можно изменить динамически, сделав вызов вида `setOwner(tid)`. У каждого потока только один владелец, но сам он может быть +владельцем множества потоков. + +Самое важное проявление отношения «владелец/собственность» за +ключается в том, что по завершении выполнения потока-владельца вы +зовы функции `receive` в дочернем потоке начнут порождать исключения +типа `OwnerTerminated`. Исключение порождается, только если в очереди +к `receive` больше нет подходящих сообщений и необходимо ждать при +хода новых; пока у `receive` есть что извлечь из ящика, она не породит +исключение `OwnerTerminated`. Другими словами, при останове потока- +владельца вызовы `receive` (или `receiveOnly`, коли на то пошло) в дочерних +потоках породят исключения тогда и только тогда, когда в противном +случае они заблокируют выполнение программы, так как продолжат +ожидать сообщение, которое никогда не придет. Отношение владения +необязательно однонаправленно. В действительности, возможна ситуа +ция, когда два потока являются владельцами друг друга; в таком слу +чае, какой бы поток ни завершался первым, он оповестит другой поток. + +Окинем программу копирования файлов свежим взглядом – с учетом +знания об отношении владения. В любой заданный момент времени в по +лете между основным и второстепенным потоками находится масса сооб +щений. Чем быстрее выполняются операции чтения по сравнению с опе +рациями записи, тем больше буферов будет находиться в почтовом ящи +ке записывающего потока в ожидании обработки. Возврат из `main` заста +вит `receive` породить исключение, но не раньше, чем будут обработаны +ожидающие сообщения. Сразу же после того, как ящик записывающего +потока опустеет (а последняя порция данных будет записана в целевой +файл), очередной вызов `receive` породит исключение. Записывающий по +ток прекращает выполнение по исключению `OwnerTerminated`; система +времени исполнения в курсе, что это за исключение, и просто его игнори +рует. Операционная система закрывает стандартные потоки ввода и вы +вода так, как обычно, и операция копирования успешно завершается. + +Может показаться, что в промежутке между моментом отправки по +следнего сообщения из `main` и моментом возврата из `main` (что заставляет +`receive` породить исключение) возникает гонка. Что если исключение +«опередит» последнее сообщение – или, хуже того, несколько послед +них сообщений? На самом деле никакой гонки нет. Поток, отправляю +щий сообщения, всегда думает о последствиях: последнее сообщение +помещается в конец очереди дочернего потока *до* того, как исключение +`OwnerTerminated` начнет свой путь (фактически распространение исклю +чения организуется при помощи той же очереди, что и в случае обыч +ных сообщений). Однако гонка *присутствовала бы*, если бы функция +`main` завершала свое выполнение в тот самый момент, когда другой, тре +тий поток отправлял бы сообщения в очередь `fileWriter`. + +Подобная же цепочка рассуждений показывает, что наш предыдущий +простой пример, в котором два потока «в ногу» записывают 200 сообще +ний, также корректен: функция `main` завершает свое выполнение после +отправки (ждет до конца) последнего сообщения дочернему потоку. До +черний поток сначала опустошает очередь, а затем заканчивает работу +по исключению `OwnerTerminated`. + +Если вы считаете, что для механизма, обрабатывающего завершение +выполнения потока, порождение исключения – выбор слишком суро +вый, то помните, что никто не лишал вас возможности обработать `OwnerTerminated` явно: + +```d +// Завершается без исключения +void fileWriter() +{ + // Цикл записи + for (bool running = true; running; ) + { + receive( + (immutable(ubyte)[] buffer) { tgt.write(buffer); }, + (OwnerTerminated) { running = false; } + ); + } + stderr.writeln("Выполнение завершено без приключений."); +} +``` + +В данном случае по завершении выполнения `main` поток `fileWriter` мирно +возвращает управление, и все счастливы. Но что произойдет, если ис +ключение породит дочерний, записывающий поток? Если возникнут +проблемы с записью данных в `tgt`, вызов функции `write` может завер +шиться неудачей. В таком случае вызов `send` из основного потока также +завершится неудачей (а именно будет порождено исключение типа `OwnerFailed`), то есть произойдет как раз то, что ожидалось. Кстати, если +дочерний поток завершит свое выполнение обычным способом (а не по +исключению), последующие вызовы `send`, отправлявшие сообщения это +му потоку, также завершатся неудачей, но с другим типом исключе +ния – `OwnedTerminated`. + +Рассмотренная программа для копирования файлов более отказоустой +чива, чем можно предположить, судя по ее простоте. Тем не менее нуж +но сказать, что протокол завершения выполнения гладко срабатывает +лишь тогда, когда отношения между потоками просты и предельно по +нятны, и полагаться на него стоит исключительно в таких случаях. +А когда в деле замешаны несколько потоков и отношения владения ме +жду ними отражаются сложным графом, лучше всего организовать +взаимодействие всех этих потоков по протоколам, предусматривающим +явное уведомление об окончании обмена данными. В случае примера +с копированием файлов можно реализовать следующую простую идею: +установить соглашение, по которому отправка буфера нулевого размера +записывающему потоку будет означать удачное завершение работы чи +тающим потоком. Получив такое сообщение и завершив запись, запи +сывающий поток также уведомляет поток, осуществлявший чтение, +о своем завершении. После чего «читатель», наконец, тоже может за +вершить свое выполнение. Такой протокол явного уведомления хорошо +масштабируется до случаев, когда по пути от «читателя» к «писателю» +данные обрабатываются множеством других потоков. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.9. Передача нештатных сообщений + +Допустим, с помощью предположительно прыткой программы, кото +рую мы только что написали, вы копируете большой файл из быстрого +локального хранилища на медленный сетевой диск. На полпути возни +кает ошибка чтения – файл поврежден. Это заставляет `read`, а затем +и `main` породить исключения, и все происходит тогда, когда множество +буферов находятся в полете, но еще не записаны. Более абстрактно, мы +видели, что если поток-владелец завершит свое выполнение *обычным способом*, любой блокирующий вызов `receive` из принадлежащих ему +потоков породит исключение. Но что произойдет, если владелец завер +шит выполнение по исключению? + +Если поток завершается посредством порождения исключения, это знак +серьезной проблемы, о которой с должной настойчивостью нужно уве +домить дочерние потоки. И, конечно, это выполняется с помощью *нештатного* сообщения. + +Вспомните, что функция `receive` заботится лишь о сообщениях, совпав +ших с заданными шаблонами, а остальным позволяет накапливаться +в очереди. Есть способ внести в это поведение поправку. Поток-отправи +тель может инициировать обработку сообщения потоком-получателем, +вызвав функцию `prioritySend` вместо `send`. Эти две функции принимают +одни и те же параметры, но ведут себя по-разному, что в действительно +сти отражается на поведении получателя. Передача сообщения типа `T` +с помощью `prioritySend` заставляет `receive` в потоке-получателе действо +вать следующим образом: + +- Если вызов `receive` предусматривает обработку типа `T`, то сообщение +с приоритетом будет извлечено сразу же после завершения обработки +текущего сообщения – даже если сообщение с приоритетом пришло +позже других обычных (неприоритетных) сообщений. Сообщения +с приоритетом всегда помещаются в начало очереди, так что послед +нее пришедшее сообщение с приоритетом всегда извлекается функ +цией `receive` первым (даже если другие сообщения с приоритетом +уже ждут). +- Если вызов `receive` не обрабатывает тип `T` (то есть совокупность ука +занных обстоятельств предписывает `receive` оставить сообщение та +кого типа в почтовом ящике в ожидании) и `T` является наследником +`Exception`, то `receive` напрямую порождает извлеченное сообщение- +исключение. +- Если вызов `receive` не обрабатывает тип `T` и `T` не является наследни +ком `Exception`, то `receive` порождает исключение типа `PriorityMessageException!T`. Объект этого исключения содержит копию полученно +го сообщения в виде внутреннего элемента `message`. + +Если поток завершается по исключению, исключение `OwnerFailed` рас +пространяется на все потоки, которыми он владеет, с помощью вызова +`prioritySend`. В программе копирования файлов порождение исключе +ния внутри `main` вызывает порождение исключения и внутри `fileWriter` +(как только там будет вызвана функция `receive`); в результате, напеча +тав сообщение об ошибке и вернув ненулевой код выхода, останавлива +ется весь процесс. В отличие от случая с «нормальным» завершением +исполнения, в данной ситуации вполне допустимо, что в подвешенном +состоянии останутся буферы, которые были уже прочитаны, но еще не +записаны. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.10. Переполнение почтового ящика + +Программа для копирования файлов на основе протокола «поставщик/ +потребитель» работает достаточно хорошо, однако обладает одним важ +ным недостатком. Рассмотрим копирование большого файла, при кото +ром данные передаются между устройствами, скорость доступа к кото +рым существенно различается, например копирование приобретенного +законным способом файла с фильмом с внутреннего диска (быстрый дос +туп) на сетевой диск (вероятно, значительно более медленный доступ). +В этом случае поставщик (основной поток, выполняющий функцию +`main`) порождает буферы со значительной скоростью, гораздо более вы +сокой, чем скорость, с которой потребитель в состоянии записать их +в целевой файл. Разница в скоростях вызывает скопление данных, на +прасно занимающих память, которую программа не может использо +вать для повышения производительности. + +Во избежание переполнения почтового ящика, API для параллельных +вычислений позволяет задать максимальный размер очереди сообще +ний, а также действие, предпринимаемое при достижении этого преде +ла. Соответствующие сигнатуры выглядят так: + +```d +// Внутри std.concurrency +void setMaxMailboxSize(Tid tid, size_t messages, bool function(Tid) onCrowdingDoThis); +``` + +Вызывая `setMailboxSize`, вы устанавливаете для подсистемы параллель +ных вычислений правило: всякий раз когда требуется отправить новое +сообщение, а очередь уже содержит число сообщений, указанное в `messages`, вызывать `onCrowdingDoThis(tid)`. Если `onCrowdingDoThis(tid)` возвра +щает `false` или порождает исключение, новое сообщение игнорируется. +В противном случае еще раз проверяется размер очереди потока, и если +выясняется, что он уже меньше, чем размер `messages`, новое сообщение +доставляется потоку с идентификатором `tid`. В противном случае весь +цикл возобновляется. + +Вызов `setMaxMailboxSize` выполняется в потоке, осуществляющем вы +зов, а не в потоке, этот вызов принимающем. Иными словами, поток, +инициирующий отправку сообщения, также является ответственным +и за принятие экстренных мер при переполнении почтового ящика по +лучателя. Кажется логичным спросить: почему нельзя расположить +этот вызов в потоке-получателе? При расширении масштаба, а именно +применительно к программам с большим количеством потоков, такой +подход породил бы порочные последствия: потоки, пытающиеся отпра +вить сообщения, угрожали бы лишить трудоспособности потоки с пол +ными ящиками. + +Есть ряд предопределенных действий, предпринимаемых в случае, ес +ли почтовый ящик полон: заблокировать отправителя до тех пор, пока +очередь не станет меньше, породить исключение или проигнорировать +новое сообщение. Такие предопределенные действия удобно упакованы: + +```d +// Внутри std.concurrency +enum OnCrowding { block, throwException, ignore } +void setMaxMailboxSize(Tid tid, size_t messages, OnCrowding doThis); +``` + +В нашем случае лучше всего попросту блокировать поток-читатель, как +только ящик становится слишком большим. Добиться этого можно, +вставив вызов + +```d +setMaxMailboxSize(tid, 1024, OnCrowding.block); +``` + +сразу же после вызова `spawn`. + +В следующих разделах описываются подходы к организации межпоточ +ной передачи данных, служащие или альтернативой, или дополнением +к обмену сообщениями. Обмен сообщениями – рекомендуемый метод +организации межпоточного взаимодействия; этот метод легок для пони +мания, порождает удобный для чтения код, является надежным и мас +штабируемым. К более низкоуровневым механизмам стоит обращаться +лишь в совершенно особых обстоятельствах – и не забывайте, что «осо +бые» обстоятельства не всегда настолько особые, какими кажутся. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.11. Квалификатор типа shared + +Мы уже познакомились с квалификатором `shared` в разделе 13.3. Для +системы типов ключевое слово `shared` служит сигналом о том, что не +сколько потоков обладают доступом к одному фрагменту данных. Ком +пилятор тоже признает этот факт и соответственно реагирует, накла +дывая ограничения на операции с разделяемыми данными, а также +посредством генерации особого кода для разрешенных операций. + +С помощью глобального определения + +```d +shared uint threadsCount; +``` + +в программу на D вводится значение типа `shared(uint)`, что соответству +ет глобально определенному целому числу без знака в программе на C. +Такая переменная видима всем потокам в системе. Примечание в виде +shared здорово помогает компилятору: язык «знает», что `threadsCount` от +крыт для свободного доступа множеству потоков, и запрещает обраще +ния к этой переменной наивными способами. Например: + +```d +void bumpThreadsCount() +{ + ++threadsCount; // Ошибка! Увеличить на единицу значение типа shared int невозможно! +} +``` + +Что происходит? Где-то внизу, на машинном уровне, `++threadCount` не яв +ляется атомарной операцией; это сложная операция, представляющая +собой последовательность трех простых: прочесть – изменить – запи +сать. Сначала `threadCount` загружается в регистр, затем значение регист +ра увеличивается на единицу и, наконец, `threadCount` записывается об +ратно в память. Для обеспечения корректности всей сложной операции +эти три шага необходимо выполнять единым блоком. Корректный спо +соб увеличить на единицу разделяемое целое число – воспользоваться +одним из специализированных атомарных примитивов из модуля `std.concurrency`: + +```d +import std.concurrency; +shared uint threadsCount; + +void bumpThreadsCount() +{ + // std.concurrency определяет atomicOp(string op)(ref shared uint, int) + atomicOp!"+="(threadsCount, 1); // Все в порядке +} +``` + +Поскольку все разделяемые данные тщательно учитываются и находят +ся под эгидой языка, передавать данные с квалификатором `shared` разре +шается с помощью функций `send` и `receive`. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +### 13.11.1. Сюжет усложняется: квалификатор shared транзитивен + +В главе 8 объясняется, почему квалификаторы `const` и `immutable` долж +ны быть *транзитивными* (свойство, также известное как глубина или +рекурсивность): каким бы косвенным путем вы ни следовали, рассмат +ривая «внутренности» неизменяемого объекта, сами данные должны +оставаться неизменяемыми. В противном случае гарантии, предостав +ляемые квалификатором `immutable`, имели бы силу комментария в коде. +Нельзя сказать, что нечто «до определенного момента» неизменяемо +(`immutable`), а дальше меняется. Зато можно говорить, что данные *изменяемы* до определенного момента, а затем становятся совершенно неиз +меняемыми, вплоть до самых глубоко вложенных элементов. Применив +квалификатор `immutable`, вы сворачиваете на улицу с односторонним +движением. Мы уже видели, что присутствие квалификатора `immutable` +облегчает реализацию многих оправдавших себя идиом, не претендую +щих на свободу программиста, включая функциональный стиль и раз +деление данных между потоками. Если бы неизменяемость применя +лась «до определенного момента», то же самое относилось бы и к кор +ректности программы. + +Точно такой же ход рассуждений применим и для квалификатора `shared`. +На самом деле, в случае с `shared` необходимость транзитивности абсо +лютно очевидна. Приведем пример. Выражение + +```d +shared int* pInt; +``` + +в соответствии с синтаксисом квалификаторов (см. раздел 8.2) эквива +лентно выражению + +```d +shared(int*) pInt; +``` + +Верная интерпретация `pInt` такова: «Указатель является разделяемым, +и данные, на которые он указывает, также разделяемы». При поверх +ностном, нетранзитивном подходе к разделению `pInt` превратился бы +в «разделяемый указатель на неразделяемую память», и все бы ничего, +если бы такой тип данных имел хоть какой-то смысл. Это все равно что +сказать: «Я делюсь этим бумажником со всеми; только, пожалуйста, не +забывайте, что деньгами из него я делиться не собирался»[^8]. Заявление, +что потоки разделяют указатель, но не данные, на которые он указыва +ет, возвращает нас к чудесной парадигме программирования на основе +системы доверия, которая всегда успешно проваливалась. И причина +большей части проблем не чьи-то происки, а честные ошибки. Про +граммное обеспечение имеет большой объем, сложно устроено и посто +янно изменяется, что плохо сочетается с обеспечением гарантий на ос +нове соглашений. + +Тем не менее есть совершенно логичное понятие «неразделяемый указа +тель на разделяемые данные». Некоторый поток обладает «личным» +указателем, а этот указатель «смотрит» на разделяемые данные. Эту +идею легко выразить синтаксически: + +```d +shared(int)* pInt; +``` + +Между нами, если бы существовала премия «За лучшее отображение со +держания», нотация `квалификатор(тип)` ее бы отхватила. Эта форма записи +совершенна. Синтаксис просто не позволит создать неправильный ука +затель. Некорректное сочетание синтаксических единиц выглядит так: + +```d +int shared(*) pInt; +``` + +Такое выражение не имеет смысла даже синтаксически, поскольку +`(*)` – это не тип (ну да, *на самом деле* этот милый смайлик символизиру +ет циклопа). + +Транзитивность квалификатора `shared` действует не только в отноше +нии указателей, но и в отношении полей объектов-структур и классов: +поля разделяемого объекта также автоматически воспринимаются как +помеченные квалификатором `shared`. Подробный разбор порядка взаи +модействия этого квалификатора с классами и структурами представ +лен далее в этой главе. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.12. Операции с разделяемыми данными и их применение + +Работа с разделяемыми данными необычна, поскольку множество пото +ков могут читать и записывать разделяемые данные в любой момент. По +этому компилятор заботится о соблюдении целостности данных и при +чинности всеми операциями с разделяемыми данными. + +Операции чтения и записи разделяемых (`shared`) значений разрешены, +и гарантированно будут атомарными для следующих типов: числовые +типы (кроме `real`), указатели, массивы, указатели на функции, делега +ты и ссылки на классы. Структуру с единственным полем одного из пе +речисленных типов также можно читать и записывать как неделимый +объект. Подчеркнутое отсутствие в списке «разрешенных типов» типа +`real` обусловлено тем, что это единственный тип, зависящий от платфор +мы. Вот почему в плане атомарного разделения компилятор смотрит на +`real` с опаской. На машинах Intel `real` занимает 80 бит, из-за чего пере +менным этого типа сложно делать атомарные присваивания в 32-раз +рядных программах. В любом случае, тип `real` предназначен для хране +ния временных результатов высокой точности, а не для обмена данны +ми, так что вряд ли у кого-то возникнет желание разделять значения +этого типа. + +Для всех числовых типов и указателей на функции справедливо, что +значения этих типов с квалификатором `shared` могут неявно преобразо +вываться в значения без квалификатора и обратно. Преобразования +указателей между `shared(T*)` и `shared(T)*` разрешены в обоих направле +ниях. Арифметические операции на разделяемых числовых типах по +зволяют выполнять примитивы из модуля `std.concurrency`. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +### 13.12.1. Последовательная целостность разделяемых данных + +Что касается видимости операций над разделяемыми данными между +потоками, D предоставляет следующие гарантии: +- порядок выполнения операций чтения и записи разделяемых дан +ных в рамках одного потока соответствует порядку, определенному +в исходном коде; +- глобальный порядок выполнения операций чтения и записи разде +ляемых данных представляет собой некоторое чередование опера +ций чтения и записи, выполнение которых инициируется из разных +потоков. + +Выбор этих инвариантов кажется вполне резонным, даже очевидным. +И на самом деле такие гарантии довольно хорошо гармонируют с моде +лью вытесняющей многозадачности, реализованной на однопроцессор +ных системах. + +Тем не менее в контексте мультипроцессорных систем такие гарантии +слишком строги. Проблема в следующем: для обеспечения этих гаран +тий необходимо сделать так, чтобы результат выполнения любой опера +ции записи был сразу же виден всем потокам. Единственный способ до +биться этого – окружить обращения к разделяемым данным особыми +машинными инструкциями (их называют *барьеры памяти*), которые +обеспечивали бы соответствие порядка, в котором выполняются опера +ции чтения и записи разделяемых данных, порядку обновления этих +данных в глазах всех запущенных потоков. Присутствие замыслова +тых иерархий кэшей значительно удорожает такую сериализацию. +Кроме того, непоколебимая приверженность принципу последователь +ной целостности заставляет отказаться от переупорядочивания опера +ций – основы множества способов оптимизации на уровне компилято +ра. В сочетании друг с другом эти два ограничения ведут к резкому за +медлению – вплоть до одного порядка единиц измерения. + +Хорошая новость заключается в том, что такая потеря скорости имеет +место лишь в отношении разделяемых данных, которые используются +достаточно редко. В реальных ситуациях большинство данных не раз +деляются, а потому нет необходимости, чтобы в их отношении соблю +дался принцип последовательной целостности. Компилятор оптимизи +рует код, используя неразделяемые данные на всю катушку, в полной +уверенности, что другой поток никогда к ним не обратится, и относится +с осторожностью лишь к разделяемым данным. Повсеместно использу +емый и рекомендуемый прием для работы с разделяемыми данными – +копировать значения разделяемых переменных в локальные рабочие +копии потоков, работать с копиями и затем присваивать копии тем же +разделяемым переменным. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.13. Синхронизация на основе блокировок через синхронизированные классы + +Традиционно популярный метод многопоточного программирования – +*синхронизация на основе блокировок*. В соответствии с этим подходом +разделяемые данные защищаются с помощью мьютексов – объектов +синхронизации, обеспечивающих переход от параллельного к последо +вательному исполнению фрагментов кода, которые или временно нару +шают когерентность данных, или могут видеть эти временные наруше +ния. Такие фрагменты кода называют *критическими участками*[^9]. + +Корректность программы, основанной на блокировках, обеспечивается +за счет ввода упорядоченного, последовательного доступа к разделяе +мым данным. Поток, которому требуется обратиться к фрагменту раз +деляемых данных, должен захватить (заблокировать) мьютекс, обрабо +тать данные, а затем освободить (разблокировать) мьютекс. В любой за +данный момент времени мьютексом может обладать только один поток, +благодаря чему и обеспечивается переход к последовательному выпол +нению: если захватить один и тот же мьютекс желают несколько пото +ков, то «выигрывает» лишь один, а остальные скромно ожидают своей +очереди. (Способ обслуживания очереди, то есть порядок очередности, +играет важную роль и может довольно заметно сказываться на работе +приложений и операционной системы.) + +По всей вероятности, «Здравствуй, мир!» многопоточного программи +рования – это пример с банковским счетом: объект, доступный множе +ству потоков, должен предоставить безопасный интерфейс для пополне +ния счета и извлечения денежных средств со счета. Вот однопоточная, +базовая версия программы, позволяющей выполнять эти действия: + +```d +import std.contracts; + +// Однопоточный банковский счет +class BankAccount +{ + private double _balance; + void deposit(double amount) + { + _balance += amount; + } + void withdraw(double amount) + { + enforce(_balance >= amount); + _balance -= amount; + } + @property double balance() + { + return _balance; + } +} +``` + +В отсутствие потоков операции `+=` и `-=` слегка вводят в заблуждение: они +«выглядят» как атомарные, но таковыми не являются – обе операции +состоят из тройки простых операций «прочесть – изменить – записать». +На самом деле, выражение `_balance += amount` кодируется как `_balance = _balance + amount`, а значит, процессор загружает `_balance` и `_amount` в соб +ственную оперативную память (регистры или внутренний стек), скла +дывает их, а затем переводит результат обратно в `_balance`. + +Незащищенные параллельные операции типа «прочесть – изменить – +записать» становятся причиной некорректного поведения программы. +Скажем, баланс вашего счета характеризует истинное выражение `_balance == 100.0`. Некоторый поток, запуск которого был спровоцирован +требованием зачислить денежные средства по чеку, делает вызов `deposit(50)`. Сразу же после загрузки из памяти значения `100.0` выполнение +этой операции прерывает другой поток, осуществляющий вызов `withdraw(2.5)`. (Это вы в кофейне на углу оплачиваете латте своей дебетовой +картой.) Пусть ничто не вклинивается в обработку этого вызова, так что +поток, запущенный из кофейни, удачно обновляет поле `_balance`, и оно +принимает значение `97.5`. Однако это событие происходит совершенно +без ведома депонирующего потока, который уже загрузил число `100` +в регистр ЦПУ и все еще считает, что это верное количество. При вычис +лении нового значения баланса вызов `deposit(50)` получает `150` и записы +вает это число назад в переменную `_balance`. Это типичное *состояние гонки*. Поздравляю, вы получили бесплатный кофе (но остерегайтесь: +книжные примеры с ошибками еще могут работать на вас, а готовый +код с ошибками – нет). Для организации корректной синхронизации +многие языки предоставляют специальное средство – тип `Mutex`, кото +рый используется в программах, работающих с несколькими потоками +на основе блокировок, для защиты доступа к `balance`: + +```d +// Этот код написан не на D +// Многопоточный банковский счет на языке с явным обращением к мьютексам +class BankAccount +{ + private double _balance; + private Mutex _guard; + + void deposit(double amount) + { + _guard.lock(); + _balance += amount; + _guard.unlock(); + } + + void withdraw(double amount) + { + _guard.lock(); + try + { + enforce(_balance >= amount); + _balance -= amount; + } + finally + { + _guard.unlock(); + } + } + + @property double balance() + { + _guard.lock(); + double result = _balance; + _guard.unlock(); + return result; + } +} +``` + +Все операции над `_balance` теперь защищены, поскольку для доступа +к этому полю необходимо заполучить `_guard`. Может показаться, что при +ставлять к `_balance` охранника в виде `_guard` излишне, так как значения +типа `double` можно читать и записывать «в один присест», однако защита +должна здесь присутствовать по причинам, скрытым многочисленны +ми завесами майи. Вкратце, из-за сегодняшних агрессивно оптимизи +рующих компиляторов и нестрогих моделей памяти *любое* обращение +к разделяемым данным должно сопровождаться своего рода секрет +ным соглашением между записывающим потоком, читающим потоком +и оптимизирующим компилятором; одно неосторожное чтение разде +ляемых данных – и вы оказываетесь в мире боли (хорошо, что D наме +ренно запрещает такую «наготу»). Первая и наиболее очевидная при +чина такого положения дел в том, что оптимизирующий компилятор, +не замечая каких-либо попыток синхронизировать доступ к данным +с вашей стороны, ощущает себя вправе оптимизировать код с обраще +ниями к `_balance`, удерживая значение этого поля в регистре. Вторая +причина в том, что во всех случаях, кроме самых тривиальных, компи +лятор *и* ЦПУ ощущают себя вправе свободно переупорядочивать неза +щищенные, не снабженные никаким дополнительным описанием обра +щения к разделяемым данным, поскольку считают, что имеют дело +с данными, принадлежащими лично одному потоку. (Почему? Да пото +му что чаще всего так и бывает, оптимизация порождает код с самым +высоким быстродействием, и в конце концов, почему должны страдать +плебеи, а не избранные и достойные?) Это один из тех моментов, с помо +щью которых современная многопоточность выражает свое пренебре +жение к интуиции и сбивает с толку программистов, сведущих в клас +сической многопоточности. Короче, чтобы обеспечить заключение сек +ретного соглашения, потребуется обязательно синхронизировать обра +щения к свойству `_balance`. + +Чтобы гарантировать корректное снятие блокировки с `Mutex` в условиях +возникновения исключений и преждевременных возвратов управле +ния, языки, в которых продолжительность жизни объектов контекстно +ограничена (то есть деструкторы объектов вызываются на выходе из об +ластей видимости этих объектов), определяют вспомогательный тип +`Lock`, который устанавливает блок в конструкторе и снимает его в де +структоре. Эта идея развилась в самостоятельную идиому, известную +как *контекстное блокирование*. Приложение этой идиомы к клас +су `BankAccount` выглядит так: + +```d +// Версия C++: банковский счет, защищенный методом контекстного блокирования +class BankAccount +{ +private: + double _balance; + Mutex _guard; +public: + void deposit(double amount) + { + Lock lock = Lock(_guard); + balance += amount; + } + void withdraw(double amount) + { + Lock lock = Lock(_guard); + enforce(_balance >= amount); + balance -= amount; + } + double balance() + { + Lock lock = Lock(_guard); + return _balance; + } +} +``` + +Благодаря введению типа `Lock` код упрощается и повышается его кор +ректность: ведь соблюдение парности операций установления и снятия +блока теперь гарантировано, поскольку они выполняются автоматиче +ски. Java, C# и другие языки еще сильнее упрощают работу с блоки +ровками, встраивая `_guard` в объекты в качестве скрытого внутреннего +элемента и приподнимая логику блокирования вверх, до уровня сигна +туры метода. Наш пример, реализованный на Java, выглядел бы так: + +```d +// Версия Java: банковский счет, защищенный методом контекстного +// блокирования, автоматизированного с помощью инструкции synchronized +class BankAccount +{ + private double _balance; + public synchronized void deposit(double amount) + { + _balance += amount; + } + public synchronized void withdraw(double amount) + { + enforce(_balance >= amount); + _balance -= amount; + } + public synchronized double balance() + { + return _balance; + } +} +``` + +Соответствующий код на C# выглядит так же, за исключением того, +что ключевое слово `synchronized` должно быть заменено на `[MethodImpl(MethodImplOptions.Synchronized)]`. + +Итак, вы только что узнали хорошую новость: небольшие программы, +основанные на блокировках, легки для понимания и, кажется, неплохо +работают. Плохая новость в том, что при большом масштабе очень слож +но сопоставлять должным образом блокировки и данные, выбирать +контекст и «калибр» блокирования и последовательно устанавливать +блокировки, затрагивающие сразу несколько объектов (не говоря о том, +что последнее может привести к тому, что взаимозаблокированные по +токи, ожидая завершения работы друг друга, попадают в *тупик*). В ста +рые добрые времена классического многопоточного программирования +подобные проблемы весьма осложняли кодирование на основе блокиро +вок; современная многопоточность (ориентированная на множество про +цессоров, с нестрогими моделями памяти и дорогим разделением дан +ных) поставила практику программирования с блокировками под удар. Тем не менее синхронизация на основе блокировок все еще полезна +для реализации множества задумок. + +Для организации синхронизации с помощью блокировок D предостав +ляет лишь ограниченные средства. Эти границы установлены намерен +но: преимущество в том, что таким образом обеспечиваются серьезные +гарантии. Что касается случая с `BankAccount`, версия D очень проста: + +```d +// Версия D: банковский счет, реализованный с помощью синхронизированного класса +synchronized class BankAccount +{ + private double _balance; + void deposit(double amount) + { + _balance += amount; + } + void withdraw(double amount) + { + enforce(_balance >= amount); + _balance -= amount; + } + @property double balance() + { + return _balance; + } +} +``` + +D поднимает ключевое слово `synchronized` на один уровень выше, так +чтобы оно применялось к целому классу[^10]. Благодаря этому маневру +класс `BankAccount`, реализованный на D, получает возможность предос +тавлять более серьезные гарантии: даже если бы вы пожелали совер +шить ошибку, при таком синтаксисе нет способа оставить открытой +хоть какую-нибудь дверь с черного хода для несинхронизированных +обращений к `_balance`. Если бы в D позволялось смешивать использова +ние синхронизированных и несинхронизированных методов в рамках +одного класса, все обещания, данные синхронизированными метода +ми, оказались бы нарушенными. На самом деле опыт с синхронизацией +на уровне методов показал, что лучше всего синхронизировать или все +методы, или ни один из них; классы двойного назначения приносят +больше проблем, чем удобств. + +Объявляемый на уровне класса атрибут `synchronized` действует на объек +ты типа `shared(BankAccount)` и автоматически превращает параллельное +выполнение вызовов любых методов класса в последовательное. Кроме +того, синхронизированные классы характеризуются возросшей строго +стью проверок уровня защиты их внутренних элементов. Вспомните, +в соответствии с разделом 11.1 обычные проверки уровня защиты в об +щем случае позволяют обращаться к любым не общедоступным (`public`) +внутренним элементам модуля любому коду внутри этого модуля. Толь +ко не в случае синхронизированных классов – классы с атрибутом `synchronized` подчиняются следующим правилам: + +- объявлять общедоступные (`public`) данные и вовсе запрещено; +- право доступа к защищенным (`protected`) внутренним элементам +есть только у методов текущего класса и его потомков; +- право доступа к закрытым (`private`) внутренним элементам есть толь +ко у методов текущего класса. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.14. Типизация полей в синхронизированных классах + +В соответствии с правилом транзитивности для разделяемых (`shared`) +объектов разделяемый объект класса распространяет квалификатор +`shared` на свои поля. Очевидно, что атрибут `synchronized` привносит неко +торый дополнительный закон и порядок, что отражается в нестрогой +проверке типов полей внутри методов синхронизированных классов. +Ключевое слово `synchronized` должно предоставлять серьезные гарантии, +поэтому его присутствие своеобразно отражается на семантической про +верке полей, в чем прослеживается настолько же своеобразная семанти +ка самого атрибута `synchronized`. + +Защита синхронизированных методов от гонок *временна* и *локальна*. +Свойство временности означает, что как только метод возвращает управ +ление, поля от гонок больше не защищаются. Свойство локальности +подразумевает, что `synchronized` обеспечивает защиту данных, встроен +ных непосредственно в объект, но не данных, на которые объект ссыла +ется косвенно (то есть через ссылки на классы, указатели или массивы). +Рассмотрим каждое из этих свойств по очереди. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +### 13.14.1. Временная защита == нет утечкам + +Возможно, это не вполне очевидно, но «побочным эффектом» времен +ной природы `synchronized` становится формирование следующего пра +вила: ни один адрес поля не в состоянии «утечь» из синхронизирован +ного кода. Если бы такое произошло, некоторый другой фрагмент кода +получил бы право доступа к некоторым данным за пределами времен +ной защиты, даруемой синхронизацией на уровне методов. + +Компилятор пресечет любые поползновения возвратить из метода ссыл +ку или указатель на поле или передать значение поля по ссылке или по +указателю в некоторую функцию. Покажем, в чем смысл этого прави +ла, на следующем примере[^11]: + +```d +double * nyukNyuk; // Обратите внимание: без shared + +void sneaky(ref double r) { nyukNyuk = &r; } + +synchronized class BankAccount +{ + private double _balance; + void fun() + { + nyukNyuk = &_balance; // Ошибка! (как и должно быть в этом случае) + sneaky(_balance); // Ошибка! (как и должно быть в этом случае) + } +} +``` + +В первой строке `fun` осуществляется попытка получить адрес `_balance` +и присвоить его глобальной переменной. Если бы эта операция заверши +лась успехом, гарантии системы типов превратились бы в ничто – с мо +мента «утечки» адреса появилась бы возможность обращаться к разде +ляемым данным через неразделяемое значение. Присваивание не прохо +дит проверку типов. Вторая операция чуть более коварна в том смысле, +что предпринимает попытку создать псевдоним более изощренным спо +собом – через вызов функции, принимающей параметр по ссылке. Та +кое тоже не проходит; передача значения с помощью `ref` фактически +влечет получение адреса до совершения вызова. Операция получения +адреса запрещена, так что и вызов завершается неудачей. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +### 13.14.2. Локальная защита == разделение хвостов + +Защита, которую предоставляет `synchronized`, обладает еще одним важ +ным качеством – она локальна. Имеется в виду, что она не обязательно +распространяется на какие-либо данные помимо непосредственных по +лей объекта. Как только на горизонте появляются косвенности, гаран +тия того, что потоки будут обращаться к данным по одному, практиче +ски утрачивается. Если считать, что данные состоят из «головы» (часть, +расположенная в физической памяти, которую занимает объект клас +са `BankAccount`) и, возможно, «хвоста» (косвенно доступная память), то +можно сказать, что синхронизированный класс в состоянии защитить +лишь «голову» данных, в то время как «хвост» остается разделяемым +(`shared`). По этой причине типизация полей синхронизированного (`synchronized`) класса внутри метода выполняется особым образом: + +- значения любых числовых типов не разделяются (у них нет хвоста), +так что с ними можно обращаться как обычно (не применяется атри +бут `shared`); +- поля-массивы, тип которых объявлен как `T[]`, получают тип `shared(T)[]`; то есть голова (границы среза) не разделяется, а хвост (со +держимое массива) остается разделяемым; +- поля-указатели, тип которых объявлен как `T*`, получают тип `shared(T)*`; то есть голова (сам указатель) не разделяется, а хвост (дан +ные, на которые указывает указатель) остается разделяемым; +- поля-классы, тип которых объявлен как `T`, получают тип `shared(T)`. +К классам можно обратиться лишь по ссылке (это делается автома +тически), так что они представляют собой «сплошной хвост». + +Эти правила накладываются поверх правила о запрете «утечек», опи +санного в предыдущем разделе. Прямое следствие такой совокупности +правил: операции, затрагивающие непосредственные поля объекта, +внутри метода можно свободно переупорядочивать и оптимизировать, +как если бы разделение этих полей было временно остановлено – а имен +но это и делает `synchronized`. + +Иногда один объект полностью владеет другим. Предположим, что +класс `BankAccount` сохраняет все свои предыдущие транзакции в списке +значений типа `double`: + +```d +// Не синхронизируется и вообще понятия не имеет о потоках +class List(T) { +... +void append(T value) { + ... +} +} +// Ведет список транзакций +synchronized class BankAccount +{ + private double _balance; + private List!double _transactions; + + void deposit(double amount) + { + _balance += amount; + _transactions.append(amount); + } + + void withdraw(double amount) + { + enforce(_balance >= amount); + _balance -= amount; + _transactions.append(-amount); + } + + @property double balance() + { + return _balance; + } +} +``` + +Класс `List` не проектировался специально для разделения между пото +ками, поэтому он не использует никакой механизм синхронизации, но +к нему и на самом деле никогда не обращаются параллельно! Все обра +щения к этому классу замурованы в объекте класса `BankAccount` и полно +стью защищены, поскольку находятся внутри синхронизированных ме +тодов. Если предполагать, что `List` не станет затевать никаких безумных +проделок вроде сохранения некоторого внутреннего указателя в гло +бальной переменной, такой код должен быть вполне приемлемым. + +К сожалению, это не так. В языке D код, подобный приведенному выше, +не заработает никогда, поскольку вызов метода `append` применительно +к объекту типа `shared(List!double)` некорректен. Одна из очевидных при +чин отказа компилятора от такого кода в том, что компиляторы никому +не верят на слово. Класс `List` может хорошо себя вести и все такое, но +компилятору потребуется более веское доказательство того, что за его +спиной не происходит никакое создание псевдонимов разделяемых дан +ных. В теории компилятор мог бы пойти дальше и проверить определе +ние класса `List`, однако `List`, в свою очередь, мог бы использовать другие +компоненты, расположенные в других модулях, так что не успеете вы +сказать «межпроцедурный анализ», как код «на обещаниях» начнет +выходить из-под контроля. + +*Межпроцедурный анализ* – это техника, применяемая компиляторами +и анализаторами программ для доказательства справедливости предпо +ложений о программе с помощью одновременного рассмотрения сразу +нескольких функций. Такие алгоритмы анализа обычно обладают низ +кой скоростью, начинают хуже работать с ростом программы и являют +ся заклятыми врагами раздельной компиляции. Некоторые системы +используют межпроцедурный анализ, но большинство современных +языков (включая D) выполняют все проверки типов, не прибегая к этой +технике. + +Альтернативное решение проблемы с подобъектом-собственностью – +ввести новые квалификаторы, которые бы описывали отношения владе +ния, такие как «класс `BankAccount` является владельцем своего внутрен +него элемента `_transactions`, следовательно, мьютекс `BankAccount` также +обеспечивает последовательное выполнение операций над `_transactions`». При верном расположении таких примечаний компилятор смог бы +получить подтверждение того, что объект `_transactions` полностью ин +капсулирован внутри `BankAccount`, а потому безопасен в использовании, +и к нему можно обращаться, не беспокоясь о неуместном разделении. +Системы и языки, работающие по такому принципу, уже +были представлены, однако в настоящий момент они погоды не делают. +Ввод явного указания имеющихся отношений владения свидетельству +ет о появлении в языке и компиляторе значительных сложностей. Учи +тывая, что в настоящее время синхронизация на основе блокировок +борется за существование, D поостерегся усиливать поддержку этой +ущербной техники программирования. Не исключено, что это решение +еще будет пересмотрено (для D были предложены системы моделирова +ния отношений владения), но на настоящий момент, чтобы реализо +вать некоторые проектные решения, основанные на блокировках, при +ходится, как объясняется далее, переступать границы системы типов. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +### 13.14.3. Принудительные идентичные мьютексы + +D позволяет сделать динамически то, что система типов не в состоянии +гарантировать статически: отношение «владелец/собственность» в кон +тексте блокирования. Для этого предлагается следующая глобально +доступная базовая функция: + +```d +// Внутри object.d +setSameMutex(shared Object ownee, shared Object owner); +``` + +Объект `obj` некоторого класса может сделать вызов `obj.setSameMutex(owner)`[^12], и в результате вместо текущего объекта синхронизации `obj` нач +нет использовать тот же объект синхронизации, что и объект `owner`. Та +ким способом можно гарантировать, что при блокировке объекта `owner` +блокируется и объект `obj`. Посмотрим, как это сработает применитель +но к нашим подопытным классам `BankAccount` и `List`. + +```d +// В курсе о существовании потоков +synchronized class List(T) +{ + ... + void append(T value) + { + ... + } +} + +// Ведет список транзакций +synchronized class BankAccount +{ + private double _balance; + private List!double _transactions; + + this() + { + // Счет владеет списком + setSameMutex(_transactions, this); + } + ... +} +``` + +Необходимое условие работы такой схемы – синхронизация обращений +к `List` (объекту-собственности). Если бы к объекту `_transactions` приме +нялись лишь обычные правила для полей, впоследствии при выполне +нии над ним операций он бы просто заблокировался в соответствии +с этими правилами. Но на самом деле при обращении к `_transactions` +происходит кое-что необычное: осуществляется явный захват мьютек +са объекта типа `BankAccount`. При такой схеме мы получаем довольный +компилятор: он думает, что каждый объект блокируется по отдельно +сти. Довольна и программа: на самом деле единственный мьютекс кон +тролирует как объект типа `BankAccount`, так и подобъект типа List. За +хват мьютекса поля `_transactions` – это в действительности захват уже +заблокированного мьютекса объекта `this`. К счастью, такой рекурсив +ный захват уже заблокированного, не запрашиваемого другими пото +ками мьютекса обходится относительно дешево, так что представлен +ный в примере код корректен и не снижает производительность про +граммы за счет частого блокирования. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +### 13.14.4. Фильм ужасов: приведение от shared + +Продолжим работать с предыдущим примером. Если вы абсолютно уве +рены в том, что желаете возвести список `_transactions` в ранг святой част +ной собственности объекта типа `BankAccount`, то можете избавиться от +`shared` и использовать `_transactions` без учета потоков: + +```d +// Не синхронизируется и вообще понятия не имеет о потоках +class List(T) +{ + ... + void append(T value) + { + ... + } +} + +synchronized class BankAccount +{ + private double _balance; + private List!double _transactions; + + void deposit(double amount) + { + _balance += amount; + (cast(List!double) _transactions).append(amount); + } + + void withdraw(double amount) + { + enforce(_balance >= amount); + _balance -= amount; + (cast(List!double) _transactions).append(-amount); + } + + @property double balance() + { + return _balance; + } +} +``` + +На этот раз код с несинхронизированным классом `List` и компилирует +ся, и запускается. Однако есть одно «но»: теперь корректность основан +ной на блокировках дисциплины в программе гарантируете вы, а не +система типов языка, так что ваше положение не намного лучше, чем +при использовании языков с разделением данных по умолчанию. Пре +имущество, которым вы все же можете наслаждаться, состоит в том, +что приведения типов локализованы, а значит, их легко находить, то +есть тщательно рассмотреть обращения к `cast` на предмет ошибок – не +проблема. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.15. Взаимоблокировки и инструкция synchronized + +Если пример с банковским счетом – это «Здравствуй, мир!» программ, +использующих потоки, то пример с переводом средств со счета на счет, +надо полагать, – соответствующее (но более мрачное) введение в пробле +му межпоточных взаимоблокировок. Условия для задачи с переводом +средств формулируются так: пусть даны два объекта типа `BankAccount` +(скажем, `checking` и `savings`); требуется определить атомарный перевод +некоторого количества денежных средств с одного счета на другой. + +Типичное наивное решение выглядит так: + +```d +// Перевод средств. Версия 1: не атомарная +void transfer(shared BankAccount source, shared BankAccount target, double amount) +{ + source.withdraw(amount); + target.deposit(amount); +} +``` + +Тем не менее эта версия не атомарна; в промежутке между двумя вызо +вами в теле `transfer` деньги отсутствуют на обоих счетах. Если точно +в этот момент времени другой поток выполнит функцию `inspectForAuditing`, обстановка может обостриться. + +Чтобы сделать операцию перевода средств атомарной, потребуется осу +ществить захват скрытых мьютексов двух объектов за пределами их +методов, в начале функции `transfer`. Это можно организовать с помо +щью инструкций `synchronized`: + +```d +// Перевод средств. Версия 2: ГЕНЕРАТОР ПРОБЛЕМ +void transfer(shared BankAccount source, shared BankAccount target, double amount) +{ + synchronized (source) + { + synchronized (target) + { + source.withdraw(amount); + target.deposit(amount); + } + } +} +``` + +Инструкция `synchronized` захватывает скрытый мьютекс объекта на +время выполнения своего тела. За счет этого вызванные методы данно +го объекта имеют преимущество уже установленного блока. + +Проблема со второй версией функции `transfer` в том, что она предраспо +ложена к взаимоблокировкам (тупикам): если два потока попытаются +выполнить операции перевода между одними и теми же счетами, но +*в противоположных направлениях*, то эти потоки могут заблокировать +друг друга навсегда. Поток, пытающийся перевести деньги со счета `checking` на счет `savings`, блокирует счет `checking`, а другой поток, пытаю +щийся перевести деньги со счета `savings` на счет `checking`, совершенно +симметрично умудряется заблокировать счет `savings`. Этот момент ха +рактеризуется тем, что каждый из потоков удерживает свой мьютекс, +но для продолжения работы каждому из потоков необходим мьютекс +другого потока. К согласию такие потоки не придут никогда. + +Решить эту проблему позволяет инструкция `synchronized` с *двумя* аргу +ментами: + +```d +// Перевод средств. Версия 3: верная +void transfer(shared BankAccount source, shared BankAccount target, double amount) +{ + synchronized (source, target) + { + source.withdraw(amount); + target.deposit(amount); + } +} +``` + +Синхронизация обращений сразу к нескольким объектам с помощью +одной и той же инструкции `synchronized` и последовательная синхрони +зация каждого из этих объектов – разные вещи. Сгенерированный код +захватывает мьютексы всегда в том же порядке во всех потоках, невзи +рая на синтаксический порядок, в котором вы укажете объекты син +хронизации. Таким образом, взаимоблокировки предотвращаются. + +В случае эталонной реализации компилятора истинный порядок уста +новки блокировок соответствует порядку увеличения адресов объектов. +Но здесь подходит любой порядок, лишь бы он учитывал все объекты. + +Инструкция `synchronized` с несколькими аргументами помогает, но, к со +жалению, не всегда. В общем случае действия, вызывающие взаимобло +кировку, могут быть «территориально распределены»: один мьютекс за +хватывается в одной функции, затем другой – в другой и так далее до +тех пор, пока круг не замкнется и не возникнет тупик. Однако `synchronized` со множеством аргументов дает дополнительные знания о пробле +ме и способствует написанию корректного кода с блочным захватом +мьютексов. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.16. Кодирование без блокировок с помощью разделяемых классов + +Теория синхронизации, основанной на блокировках, сформировалась +в 1960-х. Но уже к 1972 году исследователи стали искать пути ис +ключения из многопоточных программ медленных, неуклюжих мью +тексов, насколько это возможно. Например, операции присваивания +с некоторыми типами можно было выполнять атомарно, и программи +сты осознали, что охранять такие присваивания с помощью захвата +мьютексов нет нужды. Кроме того, некоторые процессоры стали выпол +нять транзакционно и более сложные операции, такие как атомарное +увеличение на единицу или «проверить-и-установить». Около тридцати +лет спустя, в 1990 году, появился луч надежды, который выглядел впол +не определенно: казалось, должна отыскаться какая-то хитрая комби +нация регистров для чтения и записи, позволяющая избежать тирании +блокировок. И в этот момент появилась полная плодотворных идей ра +бота, которая положила конец исследованиям в этом направлении, +предложив другое. + +Статья Мориса Херлихи «Синхронизация без ожидания» (1991) озна +меновала мощный рывок в развитии параллельных вычислений. До это +го разработчикам и аппаратного, и программного обеспечения было оди +наково неясно, с какими примитивами синхронизации лучше всего ра +ботать. Например, процессор, который поддерживает атомарные опера +ции чтения и записи значений типа `int`, интуитивно могли счесть менее +мощным, чем тот, который помимо названных операций поддерживает +еще и атомарную операцию `+=`, а третий, который вдобавок предостав +ляет атомарную операцию `*=`, казался еще мощнее. В общем, чем боль +ше атомарных примитивов в распоряжении пользователя, тем лучше. + +Херлихи разгромил эту теорию, в частности показав фактическую бес +полезность казавшихся мощными примитивов синхронизации, таких +как «проверить-и-установить», «получить-и-сложить» и даже глобаль +ная разделяемая очередь типа FIFO. В свете этих *парадоксов* мгновенно +развеялась иллюзия, что из подобных механизмов можно добыть маги +ческий эликсир для параллельных вычислений. К счастью, помимо по +лучения этих неутешительных результатов Херлихи доказал справед +ливость *выводов об универсальности*: определенные примитивы син +хронизации могут теоретически синхронизировать любое количество +параллельно выполняющихся потоков. Поразительно, но реализовать +«хорошие» примитивы ничуть не труднее, чем «плохие», причем на не +вооруженный глаз они не кажутся особенно мощными. Из всех полез +ных примитивов синхронизации прижился лишь один, известный как +сравнение с обменом (compare-and-swap). Сегодня этот примитив реали +зует фактически любой процессор. Семантика операции сравнения с об +меном: + +```d +// Эта функция выполняется атомарно +bool cas(T)(shared(T) * here, shared(T) ifThis, shared(T) writeThis) +{ + if (*here == ifThis) + { + *here = writeThis; + return true; + } + return false; +} +``` + +В переводе на обычный язык операция `cas` атомарно сравнивает данные +в памяти по заданному адресу с заданным значением и, если значение +в памяти равно переданному явно, сохраняет новое значение; в против +ном случае не делает ничего. Результат операции сообщает, выполня +лось ли сохранение. Операция `cas` целиком атомарна и должна предос +тавляться в качестве примитива. Множество возможных типов `T` огра +ничено целыми числами размером в слово той машины, где будет вы +полняться код (то есть 32 и 64 бита). Все больше машин предоставляют +операцию *сравнения с обменом для аргументов размером в двойное слово* (*double-word compare-and-swap*), иногда ее называют `cas2`. Операция +`cas2` автоматически обрабатывает 64-битные данные на 32-разрядных +машинах и 128-битные данные на 64-разрядных машинах. Ввиду того +что все больше современных машин поддерживают `cas2`, D предостав +ляет операцию сравнения с обменом для аргументов размером в двой +ное слово под тем же именем (`cas`), под которым фигурирует и перегру +женная внутренняя функция. Так что в D можно применять операцию +`cas` к значениям типов `int`, `long`, `float`, `double`, любых массивов, любых +указателей и любых ссылок на классы. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +### 13.16.1. Разделяемые классы + +Херлиховские доказательства универсальности спровоцировали появ +ление и рост популярности множества структур данных и алгоритмов +в духе нарождающегося «программирования на основе `cas»`. Но есть +один нюанс: хотя реализация на основе `cas` и возможна теоретически для +любой задачи синхронизации, но никто не сказал, что это легко. Опреде +ление структур данных и алгоритмов на основе `cas` и особенно доказа +тельство корректности их работы – дело нелегкое. К счастью, однажды +определив и инкапсулировав такую сущность, ее можно повторно ис +пользовать для решения самых разных задач. + +Чтобы ощутить благодать программирования без блокировок на основе +`cas`, воспользуйтесь атрибутом `shared` применительно к классу или +структуре: + +```d +shared struct LockFreeStruct +{ + ... +} + +shared class LockFreeClass +{ + ... +} +``` + +Обычные правила относительно транзитивности в силе: разделяемость +распространяется на поля структуры или класса, а методы не предо +ставляют никакой особой защиты. Все, на что вы можете рассчиты +вать, – это атомарные присваивания, вызовы `cas`, уверенность в том, что +ни компилятор, ни машина не переупорядочат операции, и собственная +безграничная самоуверенность. Однако остерегайтесь: если написание +кода – ходьба, а передача сообщений – бег трусцой, то программирова +ние без блокировок – Олимпийские игры, не меньше. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +### 13.16.2. Пара структур без блокировок + +Для разминки реализуем стек без блокировок. Основная идея проста: +стек моделируется с помощью односвязного списка, операции вставки +и удаления выполняются для элементов, расположенных в начале это +го списка: + +```d +shared struct Stack(T) +{ + private shared struct Node + { + T _payload; + Node * _next; + } + + private Node * _root; + + void push(T value) + { + auto n = new Node(value); + shared(Node)* oldRoot; + + do + { + oldRoot = _root; + n._next = oldRoot; + } while (!cas(&_root, oldRoot, n)); + } + + shared(T)* pop() + { + typeof(return) result; + shared(Node)* oldRoot; + + do + { + oldRoot = _root; + if (!oldRoot) return null; + result = & oldRoot._payload; + } while (!cas(&_root, oldRoot, oldRoot._next)); + + return result; + } +} +``` + +`Stack` является разделяемой структурой, отсюда прямое следствие: внут +ри нее практически все тоже разделяется. Внутренний тип `Node` – клас +сическая структура «полезные данные + указатель», а сам тип `Stack` +хранит указатель на начало списка. + +Циклы `do`/`while` в теле обеих функций, реализующих базовые операции +над стеком, могут показаться странноватыми, но в них нет ничего осо +бенного; медленно, но верно они прокладывают глубокую борозду в ко +ре головного мозга каждого будущего эксперта в `cas`-программирова +нии. Функция `push` работает так: сначала создается новый узел, в кото +ром будет сохранено новое значение. Затем в цикле переменной `_root` +присваивается указатель на новый узел, но *только* если тем временем +никакой другой поток не изменил ее! Вполне возможно, что другой по +ток также выполнил какую-то операцию со стеком, так что функции +`push` нужно удостовериться в том, что указатель на начало стека, кото +рому, как предполагается, соответствует значение переменной `oldRoot`, +не изменился за время подготовки нового узла. + +Метод `pop` возвращает результат не по значению, а через указатель. При +чина в том, что `pop` может обнаружить очередь пустой, ведь это не явля +ется нештатной ситуацией (как было бы, будь перед нами стек, предна +значенный лишь для последовательных вычислений). В случае разде +ляемого стека проверка наличия элемента, его удаление и возврат со +ставляют одну согласованную операцию. За исключением возвращения +результата, функция `pop` по реализации напоминает функцию `push`: за +мена `_root` выполняется с большой осторожностью, так чтобы никакой +другой поток не изменил значение этой переменной, пока извлекаются +полезные данные. В конце цикла извлеченное значение отсутствует +в стеке и может быть спокойно возвращено инициатору вызова. + +Если реализация класса `Stack` не показалась вам такой уж сложной, +возьмемся за реализацию более богатого односвязного интерфейса; +в конце концов большая часть инфраструктуры уже выстроена в рам +ках класса `Stack`. + +К сожалению, в случае со списком все угрожает быть гораздо сложнее. +Насколько сложнее? Нечеловечески сложнее. Одна из фундаменталь +ных проблем – вставка и удаление узлов в произвольных позициях спи +ска. Предположим, есть список значений типа `int`, а в нем есть узел +с числом `5`, за которым следует узел с числом `10`, и требуется удалить +узел с числом `5`. Тут проблем нет – просто пустите в ход волшебную опе +рацию cas, чтобы нацелить указатель `_root` на узел с числом `10`. Пробле +ма в том, что в то же самое время другой поток может вставлять новый +узел прямо после узла с числом `5` – узел, который будет безвозвратно +потерян, поскольку `_root` ничего не знает о нем. + +В литературе представлено несколько возможных решений; ни одно из +них нельзя назвать тривиально простым. Реализация, представленная +ниже, впервые была предложена Тимоти Харрисом в его работе с мно +гообещающим названием «Прагматическая реализация неблокирую +щих односвязных списков». Эта реализация немного шероховата, +поскольку ее логика основана на установке младшего неиспользуемого +бита указателя `_next`. Идея состоит в том, чтобы сначала сделать на этом +указателе пометку «логически удален» (обнулив его бит), а затем на вто +ром шаге вырезать соответствующий узел целиком. + +```d +shared struct SharedList(T) +{ + shared struct Node + { + private T _payload; + private Node * _next; + + @property shared(Node)* next() + { + return clearlsb(_next); + } + + bool removeAfter() + { + shared(Node)* thisNext, afterNext; + // Шаг 1: сбросить младший бит поля _next узла, предназначенного для удаления + do + { + thisNext = next; + if (!thisNext) return false; + afterNext = thisNext.next; + } while (!cas(&thisNext._next, afterNext, setlsb(afterNext))); + + // Шаг 2: вырезать узел, предназначенный для удаления + if (!cas(&_next, thisNext, afterNext)) + { + afterNext = thisNext._next; + while (!haslsb(afterNext)) + { + thisNext._next = thisNext._next.next; + } + _next = afterNext; + } + } + + void insertAfter(T value) + { + auto newNode = new Node(value); + for (;;) + { + // Попытка найти место вставки + auto n = _next; + while (n && haslsb(n)) + { + n = n._next; + } + // Найдено возможное место вставки, попытка вставки + auto afterN = n._next; + newNode._next = afterN; + if (cas(&n._next, afterN, newNode)) + { + break; + } + } + } + } + + private Node * _root; + + void pushFront(T value) + { + ... // То же, что Stack.push + } + + shared(T)* popFront() + { + ... // То же, что Stack.pop + } +} +``` + +Реализация непростая, но ее можно понять, если, разбирая код, дер +жать в голове пару инвариантов. Во-первых, для логически удаленных +узлов (то есть объектов типа `Node` с полем `_next`, младший бит которого +сброшен) вполне нормально повисеть какое-то время среди обычных уз +лов. Во-вторых, узел никогда не вставляют после удаленного узла. Та +ким образом, состояние списка остается корректным, несмотря на то, +что узлы могут появляться и исчезать в любой момент времени. + +Реализации функций `clearlsb`, `setlsb` и `haslsb` грубы, насколько это воз +можно; например: + +```d +T* setlsb(T)(T* p) +{ + return cast(T*) (cast(size_t) p | 1); +} +``` + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.17. Статические конструкторы и потоки[^13] + +В одной из предыдущих глав была описана конструкция `static this()`, +предназначенная для инициализации статических данных модулей +и классов: + +```d +module counters;int counter = 0; +static this() +{ + counter++; +} +``` + +Как уже говорилось, у каждого потока есть локальная копия перемен +ной `counter`. Каждый новый поток получает копию этой переменной, +и при создании этого потока запускается статический конструктор. + +```d +import std.concurrency, std.stdio; + +int counter = 0; + +static this() +{ + counter++; + writeln("Статический конструктор: counter = ", counter); +} + +void main() +{ + writeln("Основной поток"); + spawn(&fun); +} + +void fun() +{ + writeln("Дочерний поток"); +} +``` + +Запустив этот код, получим вывод: + +``` +Статический конструктор: counter = 1 +Основной поток +Статический конструктор: counter = 1 +Дочерний поток +``` + +Объявить статический конструктор, исполняемый один раз при запус +ке программы и предназначенный для инициализации разделяемых +данных, можно с помощью конструкции `shared static this()`, а объя +вить разделяемый деструктор – с помощью конструкции `shared static ~this()`: + +```d +import std.concurrency, std.stdio; + +shared int counter = 0; + +shared static this() +{ + counter++; + writeln("Статический конструктор: counter = ", counter); +} + +void main() +{ + writeln("Основной поток"); + spawn(&fun); +} + +void fun() +{ + writeln("Дочерний поток"); +} +``` + +В этом случае конструктор будет запущен только один раз: + +``` +Статический конструктор: counter = 1 +Основной поток +Дочерний поток +``` + +Разделяемыми могут быть не только конструкторы и деструкторы мо +дуля, но и статические конструкторы и деструкторы класса. Порядок +выполнения разделяемых статических конструкторов и деструкторов +определяется теми же правилами, что и порядок выполнения локаль +ных статических конструкторов и деструкторов. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +## 13.18. Итоги + +Реализация функции `setlsb`, грязная и с потеками масла на стыках, +была бы подходящим заключением для главы, которая началась со +строгой красоты обмена сообщениями и постепенно спустилась в под +земный мир разделения данных. + +D предлагает широкий спектр средств для работы с потоками. Наибо +лее предпочтительный механизм для большинства приложений на со +временных машинах – определение протоколов на основе обмена сооб +щениями. При таком выборе может здорово пригодиться неизменяемое +разделение. Отличный совет для тех, кто хочет проектировать надеж +ные, масштабируемые приложения, использующие параллельные вы +числения, – организовать взаимодействие между потоками по методу +обмена сообщениями. + +Если требуется определить синхронизацию на основе взаимоисключе +ния, это можно осуществить с помощью синхронизированных классов. +Но предупреждаю: по сравнению с другими языками, поддержка про +граммирования на основе блокировок в D ограничена, и на это есть ос +нования. + +Если требуется простое разделение данных, можно воспользоваться раз +деляемыми (`shared`) значениями. D гарантирует, что операции с разде +ляемыми значениями выполняются в порядке, определенном в вашем +коде, и не провоцируют парадоксы видимости и низкоуровневые гонки. + +Наконец, если вам наскучили такие аттракционы, как банджи-джам +пинг, укрощение крокодилов и прогулки по раскаленным углям, вы бу +дете счастливы узнать, что существует программирование без блокиро +вок и что вы можете заниматься этим в D, используя разделяемые +структуры и классы. + +[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления) + +[^1]: Число транзисторов на кристалл будет увеличиваться вдвое каждые 24 месяца. – *Прим. пер.* +[^2]: Далее речь идет о параллельных вычислениях в целом и не рассматриваются распараллеливание операций над векторами и другие специализированные параллельные функции ядра. +[^3]: Что иронично, поскольку во времена классической многопоточности разделение памяти было быстрее, а обмен сообщениями – медленнее. +[^4]: Даже заголовок раздела был изменен с «Потоки» на «Параллельные вычисления», чтобы подчеркнуть, что потоки – это не что иное, как одна из моделей параллельных вычислений. +[^5]: Процессы языка Erlang отличаются от процессов ОС. +[^6]: Подразумевалось обратное от «насыплем соль на рану». +[^7]: Речь идет о самом процессе программирования: правила, соблюдение которых компилятор гарантировать не может, люди рано или поздно начнут нарушать (с плачевными последствиями). – *Прим. науч. ред.* +[^8]: Кстати, воспользовавшись квалификатором `const`, вы сможете делиться бумажником, зная при этом, что деньги в нем защищены от воров. Стоит лишь ввести тип `shared(const(Money)*)`. +[^9]: Возможна путаница из-за того, что Windows использует термин «критический участок» для обозначения легковесных объектов мьютексов, защищающих критические участки, а «мьютекс» – для более массивных мьютексов, с помощью которых организуется передача данных между процессами. +[^10]: Впрочем, D разрешает объявлять синхронизированными отдельные методы класса (в том числе статические). – *Прим. науч. ред.* +[^11]: nyukNyuk («няк-няк») – «фирменный» смех комика Керли Ховарда. – *Прим. пер.* +[^12]: На момент выхода книги возможность вызова функций как псевдочленов (см. раздел 5.9) не была реализована полностью, и вместо кода `obj.setSameMutex(owner)` нужно было писать `setSameMutex(obj, owner)`. Возможно, все уже изменилось. – *Прим. науч. ред.* +[^13]: Описание этой части языка не было включено в оригинал книги, но поскольку эта возможность присутствует в текущих реализациях языка, мы добавили ее описание в перевод. – *Прим. науч. ред.*