201 KiB
13. Параллельные вычисления
[🢀 12. Перегрузка операторов] [13. Параллельные вычисления] [Содержание 🢂]
- 13.1. Революция в области параллельных вычислений
- 13.2. Краткая история механизмов разделения данных
- 13.3. Смотри, мам, никакого разделения (по умолчанию)
- 13.4. Запускаем поток
- 13.5. Обмен сообщениями между потоками
- 13.6. Сопоставление по шаблону с помощью receive
- 13.7. Копирование файлов – с выкрутасом
- 13.8. Останов потока
- 13.9. Передача нештатных сообщений
- 13.10. Переполнение почтового ящика
- 13.11. Квалификатор типа shared
- 13.12. Операции с разделяемыми данными и их применение
- 13.13. Синхронизация на основе блокировок через синхронизированные классы
- 13.14. Типизация полей в синхронизированных классах
- 13.15. Взаимоблокировки и инструкция synchronized
- 13.16. Кодирование без блокировок с помощью разделяемых классов
- 13.17. Статические конструкторы и потоки
- 13.18. Итоги
Благодаря сложившейся обстановке в индустрии аппаратного обеспечения качественно изменился способ доступа к вычислительным ресурсам, которые, в свою очередь, требуют основательного пересмотра техники вычислений и применяемых языковых абстракций. Сегодня широко распространены параллельные вычисления, и программное обеспечение должно научиться извлекать из этого пользу.
Несмотря на то что индустрия программного обеспечения в целом еще не выработала окончательные ответы на вопросы, поставленные революцией в области параллельных вычислений, молодость D позволила его создателям, не связанным ни устаревшими концепциями прошлого, ни огромным наследством базового кода, принять компетентные решения относительно параллелизма. Главное отличие подхода D от стандарта поддерживающих параллелизм императивных языков – в том, что он не поощряет разделение данных между потоками; по умолчанию параллельные потоки фактически изолированы друг от друга с помощью механизмов языка. Разделение данных разрешено, но лишь в ограниченной управляемой форме, чтобы компилятор мог предоставлять основательные глобальные гарантии.
В то же время D, оставаясь в душе языком для системного программирования, разрешает применять ряд низкоуровневых, неконтролируемых механизмов параллельных вычислений. (При этом в безопасных программах некоторые из этих механизмов использовать запрещено.)
Вот краткий обзор уровней параллелизма, предлагаемых языком D:
- Передовой подход к параллельным вычислениям заключается в использовании изолированных потоков или процессов, взаимодействующих с помощью сообщений. Эта парадигма, называемая обменом сообщениями (message passing), позволяет создавать безопасные модульные программы, легкие для понимания и сопровождения. Обмен сообщениями успешно применяется в разнообразных языках и библиотеках. Раньше обмен сообщениями был медленнее подходов, основанных на разделении памяти, поэтому он и не стал общепринятым, но за последнее время здесь многое бесповоротно изменилось. Параллельные программы на D используют обмен сообщениями – парадигму, ориентированную на всестороннюю инфраструктурную поддержку.
- D также поддерживает старомодную синхронизацию на основе критических участков, защищенных мьютексами и флагами событий. В последнее время этот подход к организации параллельных вычислений подвергается серьезной критике за недостаточную масштабируемость для настоящих и будущих параллельных архитектур. D строго управляет разделением данных, ограничивая возможности программирования с применением блокировок. На первый взгляд это ограничение может показаться суровым, но оно избавляет основанный на блокировках код от его злейшего врага – низкоуровневых гонок за данными (ситуаций состязания). При этом разделение данных остается наиболее эффективным средством передачи больших объемов данных между потоками, так что пренебрегать им не стоит.
- По традиции языков системного уровня программы на D, не имеющие атрибута
@safe
, могут посредством приведений достигать беспрепятственного разделения данных. За корректность таких программ в основном отвечаете вы. - Если вам мало предыдущего уровня, конструкция
asm
позволяет получить полный контроль над машинными ресурсами. Для еще более низкоуровневого контроля потребуются микропаяльник и очень, очень верная рука.
Прежде чем с головой окунуться во все это, отвлечемся ненадолго, чтобы поближе присмотреться к тем аппаратным усовершенствованиям, которые потрясли мир.
13.1. Революция в области параллельных вычислений
Что касается параллельных вычислений, то для них сейчас времена поинтереснее, чем когда-либо. Это времена, когда и хорошие, и плохие новости вписываются в общую панораму компромиссов, противоборств и тенденций.
Хорошие новости в том, что степень интеграции все еще растет по закону Мура1; судя по тому, что нам уже известно, и по тому, что мы сегодня можем предположить, это продлится как минимум лет десять после выхода этой книги. Курс на миниатюризацию означает рост плотности вычислительной мощности пропорционально числу совместно работающих транзисторов на единицу площади. Все ближе друг к другу компоненты, все короче соединения, а это означает повышение скорости локальной связности – золотое дно в плане быстродействия.
К сожалению, отдельные выводы, начинающиеся со слов «к сожалению», умеряют энтузиазм по поводу возросшей вычислительной плотности. Во-первых, существует не только локальная связность – она формируется в иерархию: тесно связанные компоненты образуют блоки, которые должны связываться с другими блоками, образуя блоки большего размера. В свою очередь, блоки большего размера также соединяются с другими блоками большего размера, образуя функциональные блоки еще большего размера, и т. д. На своем уровне связности такие блоки остаются «далеки» друг от друга. Хуже того, возросшая сложность каждого блока увеличивает сложность связей между блоками, что реализуется путем уменьшения толщины проводов и расстояния между ними. Это означает рост сопротивления, электроемкости и перекрестных помех. Перекрестные помехи – это способность сигнала из одного провода распространяться на соседние провода посредством (в данном случае) электромагнитного поля. На высоких частотах провод – практически антенна, и помехи становятся настолько невыносимыми, что сегодня параллельные соединения все чаще заменяют последовательными (своего рода феномен нелогичности, заметный на всех уровнях: USB заменил параллельный порт, в качестве интерфейса накопителей данных SATA заменил PATA, а в подсистемах памяти последовательные шины заменяют параллельные, и все из-за перекрестных помех. Где те золотые деньки, когда параллельное было быстрее, а последовательное медленнее?).
Кроме того, растет разрыв в производительности между вычислительными элементами и памятью. В то время как плотность памяти, как и ожидалось, увеличивается в соответствии с общей степенью интеграции, скорость доступа к ней все больше отстает от скорости вычислений из-за множества разнообразных физических, технологических и рыночных факторов. В настоящее время неясно, что поможет существенно сократить этот разрыв в быстродействии, и он лишь растет. Тысячи тактов могут отделять процессор от слова в памяти; а ведь еще несколько лет назад можно было купить микросхемы памяти «с нулевым временем ожидания», обращение к которым осуществлялось за один такт.
Из-за широкого спектра архитектур памяти, представляющих собой различные компромиссные решения относительно плотности, цены и скорости, повысилась и изощренность иерархий памяти; обращение к единственному слову памяти превратилось в детективное расследование с опросом нескольких уровней кэша, начиная с драгоценного статического ОЗУ прямо на микросхеме и порой проходя весь путь до массовой памяти. Возможна и противоположная ситуация: копии указанных данных могут располагаться во множестве мест по всей иерархии. Это, в свою очередь, тоже влияет на модели программирования. Мы больше не можем позволить себе представлять память большим монолитом, удобным для разделения всеми процессами системы: наличие кэшей провоцирует рост локального трафика в памяти, превращая разделяемые данные в иллюзию, которую все труднее сопровождать.
К последним сенсационным известиям относится то, что скорость света упрямо решила оставаться неизменной (immutable
, если хотите) – около 300 000 000 метров в секунду. Скорость же света в оксиде кремния (соответствующая скорости распространения сигнала внутри современных микросхем) составляет примерно половину этого значения, причем достижимая сегодня скорость переноса самих данных существенно ниже этого теоретического предела. Это означает больше проблем с глобальной взаимосвязанностью на высоких частотах. Если бы у нас была микросхема с частотой 10 ГГц, то простое перемещение бита с одного на другой конец этого чипа шириной 4,5 см (по сути, вообще без вычислений) в идеальных условиях занимало бы три такта.
Словом, наступает век процессоров очень высокой плотности и гигантской вычислительной мощности, при этом все более изолированных и труднодоступных, которые сложно использовать из-за ограничений взаимосвязности, скорости распространения сигнала и быстроты доступа к памяти.
Компьютерная индустрия, естественно, обходит эти преграды. Одним из феноменов стало резкое сокращение размеров и энергии, требуемых для заданной вычислительной мощности; всего лишь пять лет назад уровень технологии не позволял достичь компактности и возможностей КПК, без которых сегодня мы как без рук. При этом традиционные компьютеры, пытающиеся повысить вычислительную мощность при тех же размерах, представляют все меньший интерес. Производители микросхем для них уже не борются за повышение тактовой частоты, предлагая взамен вычислительную мощность в уже знакомой упаковке: несколько одинаковых центральных процессоров, соединенных шинами друг с другом и с памятью. Так что спустя каких-то несколько лет отвечать за разгон компьютеров будут не электронщики, а в основном программисты. Вариант «побольше процессоров» может показаться довольно заманчивым, но типовым задачам настольного компьютера не под силу эффективно использовать и восемь процессоров. В будущем предполагается экспоненциальный рост числа доступных процессоров до десятков, сотен и тысяч. При разгоне единственной программы программистам придется очень много потрудиться, чтобы продуктивно использовать все эти процессоры.
Из-за разных технологических и человеческих факторов в компьютерной индустрии постоянно случаются подвижки и сотрясения, но на этот раз мы, кажется, дошли до точки. С недавних пор взять отпуск, чтобы увеличить скорость работы программы, – уже не вариант. Это возмутительно. Это подрыв устоев. Это революция в области параллельных вычислений.
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.3. Смотри, мам, никакого разделения (по умолчанию)
Вследствие последних усовершенствований аппаратного и программного обеспечения D решил отойти от других императивных языков: да, язык D поддерживает потоки, но они не разделяют никакие изменяемые данные по умолчанию – они изолированы друг от друга. Изоляция обеспечивается не аппаратно (как в случае с процессами) и не с помощью проверок времени исполнения; она является естественным следствием устройства системы типов D.
Это решение в духе функциональных языков, которые также стараются запретить любые изменения, а значит, и разделение изменяемых данных. Но есть два различия. Во-первых, программы на D все же могут свободно использовать изменяемые данные – закрыта лишь возможность непреднамеренного обращения к изменяемым данным для других потоков. Во-вторых, «никакого разделения» – это лишь выбор по умолчанию, но не единственный возможный. Чтобы определить данные как разделяемые между потоками, необходимо уточнить их определение с помощью ключевого слова shared
. Рассмотрим пример двух простых определений, размещенных в корне модуля:
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.4. Запускаем поток
Для запуска потока воспользуйтесь функцией spawn
, как здесь:
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.4.1. Неизменяемое разделение
Какие именно функции можно вызывать из spawn
? Установка на отсутствие разделения налагает определенные ограничения: в функцию, запускающую поток (в рассмотренном выше примере это функция fun
), параметры можно передавать лишь по значению. Любая передача по ссылке, как явная (в виде параметра с квалификатором ref
), так и неявная (например, с помощью массива), должна быть под запретом. Имея в виду это условие, обратимся к новой версии предыдущего примера:
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.5. Обмен сообщениями между потоками
Потоки, печатающие сообщения в произвольном порядке, малоинтересны. Изменим наш пример так, чтобы обеспечить работу потоков в тандеме. Добьемся, чтобы они печатали сообщения следующим образом:
Основной поток: 0
Дочерний поток: 0
Основной поток: 1
Дочерний поток: 1
...
Основной поток: 99
Дочерний поток: 99
Для этого потребуется определить небольшой протокол взаимодействия двух потоков: основной поток должен отправлять дочернему потоку сообщение «Напечатай это число», а дочерний – отвечать «Печать завершена». Вряд ли здесь имеют место какие-либо параллельные вычисления, но такой пример наглядно объясняет, как организуется взаимодействие в чистом виде. В настоящих приложениях большую часть своего времени потоки должны заниматься полезной работой, а на общение тратить лишь сравнительно малое время.
Начнем с того, что для взаимодействия двух потоков им требуется знать, как обращаться друг к другу. В программе может быть много переговаривающихся потоков, так что средство идентификации необходимо. Чтобы обратиться к потоку, нужно получить возвращаемый функцией spawn
идентификатор потока (thread id), который с этих пор мы будем неофициально называть «tid». (Тип tid так и называется – Tid
.) Дочернему потоку, в свою очередь, также нужен tid, для того чтобы отправить ответ. Это легко организовать, заставив отправителя указать собственный Tid
, как пишут адрес отправителя на конверте. Вот этот код:
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.6. Сопоставление по шаблону с помощью receive
Большинство полезных протоколов взаимодействия сложнее, чем определенный выше. Возможности, которые предоставляет receiveOnly
, весьма ограничены. Например, с помощью receiveOnly
довольно сложно реализовать такой маневр, как «получить int
или string
».
Гораздо более мощным примитивом является функция receive
, которая сопоставляет и диспетчирует сообщения в зависимости от их типа. Типичный вызов receive
выглядит так:
receive(
(string s) { writeln("Получена строка со значением ", s); },
(int x) { writeln("Получено число со значением ", x); }
);
При сопоставлении этого вызова со следующими вызовами send
во всех случаях будет наблюдаться совпадение:
send(tid, "здравствуй");
send(tid, 5);
send(tid, 'a');
send(tid, 42u);
Первый вызов send
соответствует типу string
и направляется в литерал функции, определенный в receive
первым; остальные три вызова соответствуют типу int
и передаются во второй функциональный литерал. Кстати, в качестве функций-обработчиков необязательно использовать литералы – какие-то (или даже все) обработчики могут быть адресами именованных функций:
void handleString(string s) { ... }
receive(
&handleString,
(int x) { writeln("Получено число со значением ", x); }
);
Сопоставление не является досконально точным; вместо того чтобы требовать точного совпадения, соблюдают обычные правила перегрузки, в соответствии с которыми char
и uint
могут быть неявно преобразованы в int
. При сопоставлении следующих вызовов соответствие, напротив, обнаружено не будет:
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
с легкостью обрабатывает и группы аргументов. Например:
receive(
(long x, double y) { ... },
(int x) { ... }
);
соответствуют те же сообщения, что и
receive(
(Tuple!(long, double) tp) { ... },
(int x) { ... }
);
Такой вызов, как send(tid, 5, 6.3)
, соответствует первому функциональному литералу как первого, так и второго предыдущих примеров.
Существует особая версия receive
– функция receiveTimeout
, позволяющая потоку предпринять экстренные меры в случае задержки сообщений. У receiveTimeout
есть «срок годности»: она завершает свое выполнение по истечении указанного промежутка времени. Об истечении «отпущенного времени» receiveTimeout
сообщает, возвращая false
:
auto gotMessage = receiveTimeout(
1000, // Время в милисекундах
(string s) { writeln("Получена строка со значением ", s); },
(int x) { writeln("Получено число со значением ", x); }
);
if (!gotMessage) {
stderr.writeln("Выполнение прервано по прошествии одной секунды.");
}
13.6.1. Первое совпадение
Рассмотрим пример:
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.6.2. Соответствие любому сообщению
Что если бы вы пожелали обеспечить просмотр абсолютно всех сообщений в почтовом ящике – например, для уверенности в том, что он не переполнится мусором?
Ответ прост – нужно всего лишь включить обработчик сообщений типа Variant
последним в список аргументов receive
. Например:
receive(
(long x) { ... },
(string x) { ... },
(double x, double y) { ... },
...
(Variant any) { ... }
);
Тип Variant
, определенный в модуле std.variant
, – это динамический тип, вмещающий ровно одно значение любого другого типа. receive
воспринимает Variant
как обобщенный контейнер для любого типа сообщения, а потому вызов receive
с обработчиком для типа Variant
всегда будет отработан, если в очереди есть хотя бы одно сообщение.
Расположить обработчик Variant
в конце цепочки обработки сообщений – хороший способ избавить ваш почтовый ящик от случайных сообщений.
13.7. Копирование файлов – с выкрутасом
Напишем коротенькую программу для копирования файлов – один из популярных способов познакомиться с интерфейсом языка файловой системы. Классический пример в стиле Кернигана и Ричи целиком на паре команд getchar
/putchar
! [34, глава 1, с. 15]. Конечно же, чтобы ускорить передачу, «родные» программы системы, копирующие файлы, практикуют буферное чтение и буферную запись, а также используют множество других методов оптимизации, так что написать конкурентоспособную программу было бы сложно, однако параллельные вычисления нам помогут.
Обычный способ копирования файлов:
- Прочесть данные из исходного файла и поместить в буфер.
- Если ничего не было прочитано, копирование завершено.
- Записать данные из буфера в целевой файл.
- Повторить заново, начиная с шага 1.
Добавление соответствующей обработки ошибок завершит полезную (но не оригинальную) программу. Если размер буфера будет выбран достаточно большим, а оба файла (и источник, и целевой файл) окажутся на одном и том же диске, быстродействие этого алгоритма приблизится к оптимальному.
В наше время файловыми хранилищами могут быть многие физические устройства: жесткие диски, флеш-диски, оптические диски, подсоединенные смартфоны, а также сетевые сервисы удаленного доступа. Эти устройства характеризуются разнообразными показателями задержки и скорости и подключаются с помощью разных аппаратных и программных интерфейсов. Такие интерфейсы могут работать параллельно (а не по одному в каждый момент времени, как предписывает алгоритм в стиле «прочесть данные из буфера/записать данные в буфер»), и именно так и нужно их использовать. В идеале должна поддерживаться максимальная занятость как устройства-источника, так и устройства-получателя, что мы можем изобразить как два потока, работающих по протоколу «поставщик/потребитель»:
- Породить один дочерний поток, который в цикле ждет сообщений, содержащих буферы памяти, и записывает их в целевой файл.
- Прочесть данные из исходного файла и разместить их в заново созданном буфере.
- Если ничего не было прочитано, копирование завершено.
- Отправить дочернему потоку сообщение, содержащее буфер с прочитанными данными.
- Повторить, начав с шага 2.
С таким подходом один поток будет работать с источником, а другой – с приемником. В зависимости от природы «исходного пункта» и «пункта назначения» можно получить значительное ускорение. Если скорости устройств сравнимы и невелики относительно пропускной способности шины памяти, теоретически скорость копирования может быть удвоена. Напишем простую программу, которая реализует модель «поставщик/потребитель» и копирует содержимое стандартного потока ввода в стандартный поток вывода:
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.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
явно:
// Завершается без исключения
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.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.10. Переполнение почтового ящика
Программа для копирования файлов на основе протокола «поставщик/потребитель» работает достаточно хорошо, однако обладает одним важным недостатком. Рассмотрим копирование большого файла, при котором данные передаются между устройствами, скорость доступа к которым существенно различается, например копирование приобретенного законным способом файла с фильмом с внутреннего диска (быстрый доступ) на сетевой диск (вероятно, значительно более медленный доступ). В этом случае поставщик (основной поток, выполняющий функцию main
) порождает буферы со значительной скоростью, гораздо более высокой, чем скорость, с которой потребитель в состоянии записать их в целевой файл. Разница в скоростях вызывает скопление данных, напрасно занимающих память, которую программа не может использовать для повышения производительности.
Во избежание переполнения почтового ящика, API для параллельных вычислений позволяет задать максимальный размер очереди сообщений, а также действие, предпринимаемое при достижении этого предела. Соответствующие сигнатуры выглядят так:
// Внутри std.concurrency
void setMaxMailboxSize(Tid tid, size_t messages, bool function(Tid) onCrowdingDoThis);
Вызывая setMailboxSize
, вы устанавливаете для подсистемы параллельных вычислений правило: всякий раз когда требуется отправить новое сообщение, а очередь уже содержит число сообщений, указанное в messages
, вызывать onCrowdingDoThis(tid)
. Если onCrowdingDoThis(tid)
возвращает false
или порождает исключение, новое сообщение игнорируется. В противном случае еще раз проверяется размер очереди потока, и если выясняется, что он уже меньше, чем размер messages
, новое сообщение доставляется потоку с идентификатором tid
. В противном случае весь цикл возобновляется.
Вызов setMaxMailboxSize
выполняется в потоке, осуществляющем вызов, а не в потоке, этот вызов принимающем. Иными словами, поток, инициирующий отправку сообщения, также является ответственным и за принятие экстренных мер при переполнении почтового ящика получателя. Кажется логичным спросить: почему нельзя расположить этот вызов в потоке-получателе? При расширении масштаба, а именно применительно к программам с большим количеством потоков, такой подход породил бы порочные последствия: потоки, пытающиеся отправить сообщения, угрожали бы лишить трудоспособности потоки с полными ящиками.
Есть ряд предопределенных действий, предпринимаемых в случае, если почтовый ящик полон: заблокировать отправителя до тех пор, пока очередь не станет меньше, породить исключение или проигнорировать новое сообщение. Такие предопределенные действия удобно упакованы:
// Внутри std.concurrency
enum OnCrowding { block, throwException, ignore }
void setMaxMailboxSize(Tid tid, size_t messages, OnCrowding doThis);
В нашем случае лучше всего попросту блокировать поток-читатель, как только ящик становится слишком большим. Добиться этого можно, вставив вызов
setMaxMailboxSize(tid, 1024, OnCrowding.block);
сразу же после вызова spawn
.
В следующих разделах описываются подходы к организации межпоточной передачи данных, служащие или альтернативой, или дополнением к обмену сообщениями. Обмен сообщениями – рекомендуемый метод организации межпоточного взаимодействия; этот метод легок для понимания, порождает удобный для чтения код, является надежным и масштабируемым. К более низкоуровневым механизмам стоит обращаться лишь в совершенно особых обстоятельствах – и не забывайте, что «особые» обстоятельства не всегда настолько особые, какими кажутся.
13.11. Квалификатор типа shared
Мы уже познакомились с квалификатором shared
в разделе 13.3. Для системы типов ключевое слово shared
служит сигналом о том, что несколько потоков обладают доступом к одному фрагменту данных. Компилятор тоже признает этот факт и соответственно реагирует, накладывая ограничения на операции с разделяемыми данными, а также посредством генерации особого кода для разрешенных операций.
С помощью глобального определения
shared uint threadsCount;
в программу на D вводится значение типа shared(uint)
, что соответствует глобально определенному целому числу без знака в программе на C. Такая переменная видима всем потокам в системе. Примечание в виде shared
здорово помогает компилятору: язык «знает», что threadsCount
открыт для свободного доступа множеству потоков, и запрещает обращения к этой переменной наивными способами. Например:
void bumpThreadsCount()
{
++threadsCount; // Ошибка! Увеличить на единицу значение типа shared int невозможно!
}
Что происходит? Где-то внизу, на машинном уровне, ++threadCount
не является атомарной операцией; это сложная операция, представляющая собой последовательность трех простых: прочесть – изменить – записать. Сначала threadCount
загружается в регистр, затем значение регистра увеличивается на единицу и, наконец, threadCount
записывается обратно в память. Для обеспечения корректности всей сложной операции эти три шага необходимо выполнять единым блоком. Корректный способ увеличить на единицу разделяемое целое число – воспользоваться одним из специализированных атомарных примитивов из модуля std.concurrency
:
import std.concurrency;
shared uint threadsCount;
void bumpThreadsCount()
{
// std.concurrency определяет atomicOp(string op)(ref shared uint, int)
atomicOp!"+="(threadsCount, 1); // Все в порядке
}
Поскольку все разделяемые данные тщательно учитываются и находятся под эгидой языка, передавать данные с квалификатором shared
разрешается с помощью функций send
и receive
.
13.11.1. Сюжет усложняется: квалификатор shared транзитивен
В главе 8 объясняется, почему квалификаторы const
и immutable
должны быть транзитивными (свойство, также известное как глубина или рекурсивность): каким бы косвенным путем вы ни следовали, рассматривая «внутренности» неизменяемого объекта, сами данные должны оставаться неизменяемыми. В противном случае гарантии, предоставляемые квалификатором immutable
, имели бы силу комментария в коде. Нельзя сказать, что нечто «до определенного момента» неизменяемо (immutable
), а дальше меняется. Зато можно говорить, что данные изменяемы до определенного момента, а затем становятся совершенно неизменяемыми, вплоть до самых глубоко вложенных элементов. Применив квалификатор immutable
, вы сворачиваете на улицу с односторонним движением. Мы уже видели, что присутствие квалификатора immutable
облегчает реализацию многих оправдавших себя идиом, не претендующих на свободу программиста, включая функциональный стиль и разделение данных между потоками. Если бы неизменяемость применялась «до определенного момента», то же самое относилось бы и к корректности программы.
Точно такой же ход рассуждений применим и для квалификатора shared
. На самом деле, в случае с shared
необходимость транзитивности абсолютно очевидна. Приведем пример. Выражение
shared int* pInt;
в соответствии с синтаксисом квалификаторов (см. раздел 8.2) эквивалентно выражению
shared(int*) pInt;
Верная интерпретация pInt
такова: «Указатель является разделяемым, и данные, на которые он указывает, также разделяемы». При поверхностном, нетранзитивном подходе к разделению pInt
превратился бы в «разделяемый указатель на неразделяемую память», и все бы ничего, если бы такой тип данных имел хоть какой-то смысл. Это все равно что сказать: «Я делюсь этим бумажником со всеми; только, пожалуйста, не забывайте, что деньгами из него я делиться не собирался»8. Заявление, что потоки разделяют указатель, но не данные, на которые он указывает, возвращает нас к чудесной парадигме программирования на основе системы доверия, которая всегда успешно проваливалась. И причина большей части проблем не чьи-то происки, а честные ошибки. Программное обеспечение имеет большой объем, сложно устроено и постоянно изменяется, что плохо сочетается с обеспечением гарантий на основе соглашений.
Тем не менее есть совершенно логичное понятие «неразделяемый указатель на разделяемые данные». Некоторый поток обладает «личным» указателем, а этот указатель «смотрит» на разделяемые данные. Эту идею легко выразить синтаксически:
shared(int)* pInt;
Между нами, если бы существовала премия «За лучшее отображение содержания», нотация квалификатор(тип)
ее бы отхватила. Эта форма записи совершенна. Синтаксис просто не позволит создать неправильный указатель. Некорректное сочетание синтаксических единиц выглядит так:
int shared(*) pInt;
Такое выражение не имеет смысла даже синтаксически, поскольку (*)
– это не тип (ну да, на самом деле этот милый смайлик символизирует циклопа).
Транзитивность квалификатора shared
действует не только в отношении указателей, но и в отношении полей объектов-структур и классов: поля разделяемого объекта также автоматически воспринимаются как помеченные квалификатором shared
. Подробный разбор порядка взаимодействия этого квалификатора с классами и структурами представлен далее в этой главе.
13.12. Операции с разделяемыми данными и их применение
Работа с разделяемыми данными необычна, поскольку множество потоков могут читать и записывать разделяемые данные в любой момент. Поэтому компилятор заботится о соблюдении целостности данных и причинности всеми операциями с разделяемыми данными.
Операции чтения и записи разделяемых (shared
) значений разрешены, и гарантированно будут атомарными для следующих типов: числовые типы (кроме real
), указатели, массивы, указатели на функции, делегаты и ссылки на классы. Структуру с единственным полем одного из перечисленных типов также можно читать и записывать как неделимый объект. Подчеркнутое отсутствие в списке «разрешенных типов» типа real
обусловлено тем, что это единственный тип, зависящий от платформы. Вот почему в плане атомарного разделения компилятор смотрит на real
с опаской. На машинах Intel real
занимает 80 бит, из-за чего переменным этого типа сложно делать атомарные присваивания в 32-разрядных программах. В любом случае, тип real
предназначен для хранения временных результатов высокой точности, а не для обмена данными, так что вряд ли у кого-то возникнет желание разделять значения этого типа.
Для всех числовых типов и указателей на функции справедливо, что значения этих типов с квалификатором shared
могут неявно преобразовываться в значения без квалификатора и обратно. Преобразования указателей между shared(T*)
и shared(T)*
разрешены в обоих направлениях. Арифметические операции на разделяемых числовых типах позволяют выполнять примитивы из модуля std.concurrency
.
13.12.1. Последовательная целостность разделяемых данных
Что касается видимости операций над разделяемыми данными между потоками, D предоставляет следующие гарантии:
- порядок выполнения операций чтения и записи разделяемых данных в рамках одного потока соответствует порядку, определенному в исходном коде;
- глобальный порядок выполнения операций чтения и записи разделяемых данных представляет собой некоторое чередование операций чтения и записи, выполнение которых инициируется из разных потоков.
Выбор этих инвариантов кажется вполне резонным, даже очевидным. И на самом деле такие гарантии довольно хорошо гармонируют с моделью вытесняющей многозадачности, реализованной на однопроцессорных системах.
Тем не менее в контексте мультипроцессорных систем такие гарантии слишком строги. Проблема в следующем: для обеспечения этих гарантий необходимо сделать так, чтобы результат выполнения любой операции записи был сразу же виден всем потокам. Единственный способ добиться этого – окружить обращения к разделяемым данным особыми машинными инструкциями (их называют барьеры памяти), которые обеспечивали бы соответствие порядка, в котором выполняются операции чтения и записи разделяемых данных, порядку обновления этих данных в глазах всех запущенных потоков. Присутствие замысловатых иерархий кэшей значительно удорожает такую сериализацию. Кроме того, непоколебимая приверженность принципу последовательной целостности заставляет отказаться от переупорядочивания операций – основы множества способов оптимизации на уровне компилятора. В сочетании друг с другом эти два ограничения ведут к резкому замедлению – вплоть до одного порядка единиц измерения.
Хорошая новость заключается в том, что такая потеря скорости имеет место лишь в отношении разделяемых данных, которые используются достаточно редко. В реальных ситуациях большинство данных не разделяются, а потому нет необходимости, чтобы в их отношении соблюдался принцип последовательной целостности. Компилятор оптимизирует код, используя неразделяемые данные на всю катушку, в полной уверенности, что другой поток никогда к ним не обратится, и относится с осторожностью лишь к разделяемым данным. Повсеместно используемый и рекомендуемый прием для работы с разделяемыми данными – копировать значения разделяемых переменных в локальные рабочие копии потоков, работать с копиями и затем присваивать копии тем же разделяемым переменным.
13.13. Синхронизация на основе блокировок через синхронизированные классы
Традиционно популярный метод многопоточного программирования – синхронизация на основе блокировок. В соответствии с этим подходом разделяемые данные защищаются с помощью мьютексов – объектов синхронизации, обеспечивающих переход от параллельного к последовательному исполнению фрагментов кода, которые или временно нарушают когерентность данных, или могут видеть эти временные нарушения. Такие фрагменты кода называют критическими участками9.
Корректность программы, основанной на блокировках, обеспечивается за счет ввода упорядоченного, последовательного доступа к разделяемым данным. Поток, которому требуется обратиться к фрагменту разделяемых данных, должен захватить (заблокировать) мьютекс, обработать данные, а затем освободить (разблокировать) мьютекс. В любой заданный момент времени мьютексом может обладать только один поток, благодаря чему и обеспечивается переход к последовательному выполнению: если захватить один и тот же мьютекс желают несколько потоков, то «выигрывает» лишь один, а остальные скромно ожидают своей очереди. (Способ обслуживания очереди, то есть порядок очередности, играет важную роль и может довольно заметно сказываться на работе приложений и операционной системы.)
По всей вероятности, «Здравствуй, мир!» многопоточного программирования – это пример с банковским счетом: объект, доступный множеству потоков, должен предоставить безопасный интерфейс для пополнения счета и извлечения денежных средств со счета. Вот однопоточная, базовая версия программы, позволяющей выполнять эти действия:
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
// Многопоточный банковский счет на языке с явным обращением к мьютексам
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
выглядит так:
// Версия 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, выглядел бы так:
// Версия 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: банковский счет, реализованный с помощью синхронизированного класса
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.14. Типизация полей в синхронизированных классах
В соответствии с правилом транзитивности для разделяемых (shared
) объектов разделяемый объект класса распространяет квалификатор shared
на свои поля. Очевидно, что атрибут synchronized
привносит некоторый дополнительный закон и порядок, что отражается в нестрогой проверке типов полей внутри методов синхронизированных классов. Ключевое слово synchronized
должно предоставлять серьезные гарантии, поэтому его присутствие своеобразно отражается на семантической проверке полей, в чем прослеживается настолько же своеобразная семантика самого атрибута synchronized
.
Защита синхронизированных методов от гонок временна и локальна. Свойство временности означает, что как только метод возвращает управление, поля от гонок больше не защищаются. Свойство локальности подразумевает, что synchronized
обеспечивает защиту данных, встроенных непосредственно в объект, но не данных, на которые объект ссылается косвенно (то есть через ссылки на классы, указатели или массивы). Рассмотрим каждое из этих свойств по очереди.
13.14.1. Временная защита == нет утечкам
Возможно, это не вполне очевидно, но «побочным эффектом» временной природы synchronized
становится формирование следующего правила: ни один адрес поля не в состоянии «утечь» из синхронизированного кода. Если бы такое произошло, некоторый другой фрагмент кода получил бы право доступа к некоторым данным за пределами временной защиты, даруемой синхронизацией на уровне методов.
Компилятор пресечет любые поползновения возвратить из метода ссылку или указатель на поле или передать значение поля по ссылке или по указателю в некоторую функцию. Покажем, в чем смысл этого правила, на следующем примере11:
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.14.2. Локальная защита == разделение хвостов
Защита, которую предоставляет synchronized
, обладает еще одним важным качеством – она локальна. Имеется в виду, что она не обязательно распространяется на какие-либо данные помимо непосредственных полей объекта. Как только на горизонте появляются косвенности, гарантия того, что потоки будут обращаться к данным по одному, практически утрачивается. Если считать, что данные состоят из «головы» (часть, расположенная в физической памяти, которую занимает объект класса BankAccount
) и, возможно, «хвоста» (косвенно доступная память), то можно сказать, что синхронизированный класс в состоянии защитить лишь «голову» данных, в то время как «хвост» остается разделяемым (shared
). По этой причине типизация полей синхронизированного (synchronized
) класса внутри метода выполняется особым образом:
- значения любых числовых типов не разделяются (у них нет хвоста), так что с ними можно обращаться как обычно (не применяется атрибут
shared
); - поля-массивы, тип которых объявлен как
T[]
, получают типshared(T)[]
; то есть голова (границы среза) не разделяется, а хвост (содержимое массива) остается разделяемым; - поля-указатели, тип которых объявлен как
T*
, получают типshared(T)*
; то есть голова (сам указатель) не разделяется, а хвост (данные, на которые указывает указатель) остается разделяемым; - поля-классы, тип которых объявлен как
T
, получают типshared(T)
. К классам можно обратиться лишь по ссылке (это делается автоматически), так что они представляют собой «сплошной хвост».
Эти правила накладываются поверх правила о запрете «утечек», описанного в предыдущем разделе. Прямое следствие такой совокупности правил: операции, затрагивающие непосредственные поля объекта, внутри метода можно свободно переупорядочивать и оптимизировать, как если бы разделение этих полей было временно остановлено – а именно это и делает synchronized
.
Иногда один объект полностью владеет другим. Предположим, что класс BankAccount
сохраняет все свои предыдущие транзакции в списке значений типа double
:
// Не синхронизируется и вообще понятия не имеет о потоках
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.14.3. Принудительные идентичные мьютексы
D позволяет сделать динамически то, что система типов не в состоянии гарантировать статически: отношение «владелец/собственность» в контексте блокирования. Для этого предлагается следующая глобально доступная базовая функция:
// Внутри object.d
setSameMutex(shared Object ownee, shared Object owner);
Объект obj
некоторого класса может сделать вызов obj.setSameMutex(owner)
12, и в результате вместо текущего объекта синхронизации obj
начнет использовать тот же объект синхронизации, что и объект owner
. Таким способом можно гарантировать, что при блокировке объекта owner
блокируется и объект obj
. Посмотрим, как это сработает применительно к нашим подопытным классам BankAccount
и List
.
// В курсе о существовании потоков
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.14.4. Фильм ужасов: приведение от shared
Продолжим работать с предыдущим примером. Если вы абсолютно уверены в том, что желаете возвести список _transactions
в ранг святой частной собственности объекта типа BankAccount
, то можете избавиться от shared
и использовать _transactions
без учета потоков:
// Не синхронизируется и вообще понятия не имеет о потоках
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.15. Взаимоблокировки и инструкция synchronized
Если пример с банковским счетом – это «Здравствуй, мир!» программ, использующих потоки, то пример с переводом средств со счета на счет, надо полагать, – соответствующее (но более мрачное) введение в проблему межпоточных взаимоблокировок. Условия для задачи с переводом средств формулируются так: пусть даны два объекта типа BankAccount
(скажем, checking
и savings
); требуется определить атомарный перевод некоторого количества денежных средств с одного счета на другой.
Типичное наивное решение выглядит так:
// Перевод средств. Версия 1: не атомарная
void transfer(shared BankAccount source, shared BankAccount target, double amount)
{
source.withdraw(amount);
target.deposit(amount);
}
Тем не менее эта версия не атомарна; в промежутке между двумя вызовами в теле transfer
деньги отсутствуют на обоих счетах. Если точно в этот момент времени другой поток выполнит функцию inspectForAuditing
, обстановка может обостриться.
Чтобы сделать операцию перевода средств атомарной, потребуется осуществить захват скрытых мьютексов двух объектов за пределами их методов, в начале функции transfer
. Это можно организовать с помощью инструкций synchronized
:
// Перевод средств. Версия 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
с двумя аргументами:
// Перевод средств. Версия 3: верная
void transfer(shared BankAccount source, shared BankAccount target, double amount)
{
synchronized (source, target)
{
source.withdraw(amount);
target.deposit(amount);
}
}
Синхронизация обращений сразу к нескольким объектам с помощью одной и той же инструкции synchronized
и последовательная синхронизация каждого из этих объектов – разные вещи. Сгенерированный код захватывает мьютексы всегда в том же порядке во всех потоках, невзирая на синтаксический порядок, в котором вы укажете объекты синхронизации. Таким образом, взаимоблокировки предотвращаются.
В случае эталонной реализации компилятора истинный порядок установки блокировок соответствует порядку увеличения адресов объектов. Но здесь подходит любой порядок, лишь бы он учитывал все объекты.
Инструкция synchronized
с несколькими аргументами помогает, но, к сожалению, не всегда. В общем случае действия, вызывающие взаимоблокировку, могут быть «территориально распределены»: один мьютекс захватывается в одной функции, затем другой – в другой и так далее до тех пор, пока круг не замкнется и не возникнет тупик. Однако synchronized
со множеством аргументов дает дополнительные знания о проблеме и способствует написанию корректного кода с блочным захватом мьютексов.
13.16. Кодирование без блокировок с помощью разделяемых классов
Теория синхронизации, основанной на блокировках, сформировалась в 1960-х. Но уже к 1972 году исследователи стали искать пути исключения из многопоточных программ медленных, неуклюжих мьютексов, насколько это возможно. Например, операции присваивания с некоторыми типами можно было выполнять атомарно, и программисты осознали, что охранять такие присваивания с помощью захвата мьютексов нет нужды. Кроме того, некоторые процессоры стали выполнять транзакционно и более сложные операции, такие как атомарное увеличение на единицу или «проверить-и-установить». Около тридцати лет спустя, в 1990 году, появился луч надежды, который выглядел вполне определенно: казалось, должна отыскаться какая-то хитрая комбинация регистров для чтения и записи, позволяющая избежать тирании блокировок. И в этот момент появилась полная плодотворных идей работа, которая положила конец исследованиям в этом направлении, предложив другое.
Статья Мориса Херлихи «Синхронизация без ожидания» (1991) ознаменовала мощный рывок в развитии параллельных вычислений. До этого разработчикам и аппаратного, и программного обеспечения было одинаково неясно, с какими примитивами синхронизации лучше всего работать. Например, процессор, который поддерживает атомарные операции чтения и записи значений типа int
, интуитивно могли счесть менее мощным, чем тот, который помимо названных операций поддерживает еще и атомарную операцию +=
, а третий, который вдобавок предоставляет атомарную операцию *=
, казался еще мощнее. В общем, чем больше атомарных примитивов в распоряжении пользователя, тем лучше.
Херлихи разгромил эту теорию, в частности показав фактическую бесполезность казавшихся мощными примитивов синхронизации, таких как «проверить-и-установить», «получить-и-сложить» и даже глобальная разделяемая очередь типа FIFO. В свете этих парадоксов мгновенно развеялась иллюзия, что из подобных механизмов можно добыть магический эликсир для параллельных вычислений. К счастью, помимо получения этих неутешительных результатов Херлихи доказал справедливость выводов об универсальности: определенные примитивы синхронизации могут теоретически синхронизировать любое количество параллельно выполняющихся потоков. Поразительно, но реализовать «хорошие» примитивы ничуть не труднее, чем «плохие», причем на невооруженный глаз они не кажутся особенно мощными. Из всех полезных примитивов синхронизации прижился лишь один, известный как сравнение с обменом (compare-and-swap). Сегодня этот примитив реализует фактически любой процессор. Семантика операции сравнения с обменом:
// Эта функция выполняется атомарно
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.16.1. Разделяемые классы
Херлиховские доказательства универсальности спровоцировали появление и рост популярности множества структур данных и алгоритмов в духе нарождающегося «программирования на основе cas»
. Но есть один нюанс: хотя реализация на основе cas
и возможна теоретически для любой задачи синхронизации, но никто не сказал, что это легко. Определение структур данных и алгоритмов на основе cas
и особенно доказательство корректности их работы – дело нелегкое. К счастью, однажды определив и инкапсулировав такую сущность, ее можно повторно использовать для решения самых разных задач.
Чтобы ощутить благодать программирования без блокировок на основе cas
, воспользуйтесь атрибутом shared
применительно к классу или структуре:
shared struct LockFreeStruct
{
...
}
shared class LockFreeClass
{
...
}
Обычные правила относительно транзитивности в силе: разделяемость распространяется на поля структуры или класса, а методы не предоставляют никакой особой защиты. Все, на что вы можете рассчитывать, – это атомарные присваивания, вызовы cas
, уверенность в том, что ни компилятор, ни машина не переупорядочат операции, и собственная безграничная самоуверенность. Однако остерегайтесь: если написание кода – ходьба, а передача сообщений – бег трусцой, то программирование без блокировок – Олимпийские игры, не меньше.
13.16.2. Пара структур без блокировок
Для разминки реализуем стек без блокировок. Основная идея проста: стек моделируется с помощью односвязного списка, операции вставки и удаления выполняются для элементов, расположенных в начале этого списка:
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
. Идея состоит в том, чтобы сначала сделать на этом указателе пометку «логически удален» (обнулив его бит), а затем на втором шаге вырезать соответствующий узел целиком.
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
грубы, насколько это возможно; например:
T* setlsb(T)(T* p)
{
return cast(T*) (cast(size_t) p | 1);
}
13.17. Статические конструкторы и потоки13
В одной из предыдущих глав была описана конструкция static this()
, предназначенная для инициализации статических данных модулей и классов:
module counters;int counter = 0;
static this()
{
counter++;
}
Как уже говорилось, у каждого потока есть локальная копия переменной counter
. Каждый новый поток получает копию этой переменной, и при создании этого потока запускается статический конструктор.
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()
:
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.18. Итоги
Реализация функции setlsb
, грязная и с потеками масла на стыках, была бы подходящим заключением для главы, которая началась со строгой красоты обмена сообщениями и постепенно спустилась в подземный мир разделения данных.
D предлагает широкий спектр средств для работы с потоками. Наиболее предпочтительный механизм для большинства приложений на современных машинах – определение протоколов на основе обмена сообщениями. При таком выборе может здорово пригодиться неизменяемое разделение. Отличный совет для тех, кто хочет проектировать надежные, масштабируемые приложения, использующие параллельные вычисления, – организовать взаимодействие между потоками по методу обмена сообщениями.
Если требуется определить синхронизацию на основе взаимоисключения, это можно осуществить с помощью синхронизированных классов. Но предупреждаю: по сравнению с другими языками, поддержка программирования на основе блокировок в D ограничена, и на это есть основания.
Если требуется простое разделение данных, можно воспользоваться разделяемыми (shared
) значениями. D гарантирует, что операции с разделяемыми значениями выполняются в порядке, определенном в вашем коде, и не провоцируют парадоксы видимости и низкоуровневые гонки.
Наконец, если вам наскучили такие аттракционы, как банджи-джампинг, укрощение крокодилов и прогулки по раскаленным углям, вы будете счастливы узнать, что существует программирование без блокировок и что вы можете заниматься этим в D, используя разделяемые структуры и классы.
-
Число транзисторов на кристалл будет увеличиваться вдвое каждые 24 месяца. – Прим. пер. ↩︎
-
Далее речь идет о параллельных вычислениях в целом и не рассматриваются распараллеливание операций над векторами и другие специализированные параллельные функции ядра. ↩︎
-
Что иронично, поскольку во времена классической многопоточности разделение памяти было быстрее, а обмен сообщениями – медленнее. ↩︎
-
Даже заголовок раздела был изменен с «Потоки» на «Параллельные вычисления», чтобы подчеркнуть, что потоки – это не что иное, как одна из моделей параллельных вычислений. ↩︎
-
Процессы языка Erlang отличаются от процессов ОС. ↩︎
-
Подразумевалось обратное от «насыплем соль на рану». ↩︎
-
Речь идет о самом процессе программирования: правила, соблюдение которых компилятор гарантировать не может, люди рано или поздно начнут нарушать (с плачевными последствиями). – Прим. науч. ред. ↩︎
-
Кстати, воспользовавшись квалификатором
const
, вы сможете делиться бумажником, зная при этом, что деньги в нем защищены от воров. Стоит лишь ввести типshared(const(Money)*)
. ↩︎ -
Возможна путаница из-за того, что Windows использует термин «критический участок» для обозначения легковесных объектов мьютексов, защищающих критические участки, а «мьютекс» – для более массивных мьютексов, с помощью которых организуется передача данных между процессами. ↩︎
-
Впрочем, D разрешает объявлять синхронизированными отдельные методы класса (в том числе статические). – Прим. науч. ред. ↩︎
-
nyukNyuk («няк-няк») – «фирменный» смех комика Керли Ховарда. – Прим. пер. ↩︎
-
На момент выхода книги возможность вызова функций как псевдочленов (см. раздел 5.9) не была реализована полностью, и вместо кода
obj.setSameMutex(owner)
нужно было писатьsetSameMutex(obj, owner)
. Возможно, все уже изменилось. – Прим. науч. ред. ↩︎ -
Описание этой части языка не было включено в оригинал книги, но поскольку эта возможность присутствует в текущих реализациях языка, мы добавили ее описание в перевод. – Прим. науч. ред. ↩︎