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