Alexander Zhirov 0f0d8bdfbf | ||
---|---|---|
.. | ||
README.md |
README.md
13. Параллельные вычисления
- 13.1. Революция в области параллельных вычислений
- 13.2. Краткая история механизмов разделения данных
- 13.3. Смотри, мам, никакого разделения (по умолчанию)
- 13.4. Запускаем поток
- 13.4.1. Неизменяемое разделение
- 13.5. Обмен сообщениями между потоками
- 13.6. Сопоставление по шаблону с помощью receive
- 13.6.1. Первое совпадение
- 13.6.2. Соответствие любому сообщению
- 13.7. Копирование файлов – с выкрутасом
- 13.8. Останов потока
- 13.9. Передача нештатных сообщений
- 13.10. Переполнение почтового ящика
- 13.11. Квалификатор типа shared
- 13.11.1. Сюжет усложняется: квалификатор shared транзитивен
- 13.12. Операции с разделяемыми данными и их применение
- 13.12.1. Последовательная целостность разделяемых данных
- 13.13. Синхронизация на основе блокировок через синхронизированные классы
- 13.14. Типизация полей в синхронизированных классах
- 13.14.1. Временная защита == нет утечкам
- 13.14.2. Локальная защита == разделение хвостов
- 13.14.3. Принудительные идентичные мьютексы
- 13.14.4. Фильм ужасов: приведение от shared
- 13.15. Взаимоблокировки и инструкция synchronized
- 13.16. Кодирование без блокировок с помощью разделяемых классов
- 13.16.1. Разделяемые классы
- 13.16.2. Пара структур без блокировок
- 13.17. Статические конструкторы и потоки
- 13.18. Итоги
Благодаря сложившейся обстановке в индустрии аппаратного обеспече ния качественно изменился способ доступа к вычислительным ресур сам, которые, в свою очередь, требуют основательного пересмотра тех ники вычислений и применяемых языковых абстракций. Сегодня ши роко распространены параллельные вычисления, и программное обес печение должно научиться извлекать из этого пользу.
Несмотря на то что индустрия программного обеспечения в целом еще не выработала окончательные ответы на вопросы, поставленные рево люцией в области параллельных вычислений, молодость D позволила его создателям, не связанным ни устаревшими концепциями прошло го, ни огромным наследством базового кода, принять компетентные ре шения относительно параллелизма. Главное отличие подхода D от стан дарта поддерживающих параллелизм императивных языков – в том, что он не поощряет разделение данных между потоками; по умолчанию параллельные потоки фактически изолированы друг от друга с помо щью механизмов языка. Разделение данных разрешено, но лишь в огра ниченной управляемой форме, чтобы компилятор мог предоставлять основательные глобальные гарантии.
В то же время D, оставаясь в душе языком для системного программи рования, разрешает применять ряд низкоуровневых, неконтролируе мых механизмов параллельных вычислений. (При этом в безопасных программах некоторые из этих механизмов использовать запрещено.)
Вот краткий обзор уровней параллелизма, предлагаемых языком D:
- Передовой подход к параллельным вычислениям заключается в ис пользовании изолированных потоков или процессов, взаимодейст вующих с помощью сообщений. Эта парадигма, называемая обменом сообщениями (message passing), позволяет создавать безопасные модульные программы, легкие для понимания и сопровождения. Обмен сообщениями успешно применяется в разнообразных языках и библиотеках. Раньше обмен сообщениями был медленнее подходов, основанных на разделении памяти, поэтому он и не стал общеприня тым, но за последнее время здесь многое бесповоротно изменилось. Параллельные программы на D используют обмен сообщениями – парадигму, ориентированную на всестороннюю инфраструктурную поддержку.
- D также поддерживает старомодную синхронизацию на основе кри тических участков, защищенных мьютексами и флагами событий. В последнее время этот подход к организации параллельных вычис лений подвергается серьезной критике за недостаточную масшта бируемость для настоящих и будущих параллельных архитектур. D строго управляет разделением данных, ограничивая возможности программирования с применением блокировок. На первый взгляд это ограничение может показаться суровым, но оно избавляет осно ванный на блокировках код от его злейшего врага – низкоуровневых гонок за данными (ситуаций состязания). При этом разделение дан ных остается наиболее эффективным средством передачи больших объемов данных между потоками, так что пренебрегать им не стоит.
- По традиции языков системного уровня программы на D, не имею
щие атрибута
@safe
, могут посредством приведений достигать бес препятственного разделения данных. За корректность таких про грамм в основном отвечаете вы. - Если вам мало предыдущего уровня, конструкция
asm
позволяет по лучить полный контроль над машинными ресурсами. Для еще бо лее низкоуровневого контроля потребуются микропаяльник и очень, очень верная рука.
Прежде чем с головой окунуться во все это, отвлечемся ненадолго, что бы поближе присмотреться к тем аппаратным усовершенствованиям, которые потрясли мир.
13.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)
. Возможно, все уже изменилось. – Прим. науч. ред. ↩︎ -
Описание этой части языка не было включено в оригинал книги, но поскольку эта возможность присутствует в текущих реализациях языка, мы добавили ее описание в перевод. – Прим. науч. ред. ↩︎