|
@@ -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]: Описание этой части языка не было включено в оригинал книги, но поскольку эта возможность присутствует в текущих реализациях языка, мы добавили ее описание в перевод. – *Прим. науч. ред.*
|