В нашем случае лучше всего попросту блокировать поток-читатель, как
только ящик становится слишком большим. Добиться этого можно,
вставив вызов
```d
setMaxMailboxSize(tid, 1024, OnCrowding.block);
```
сразу же после вызова `spawn`.
В следующих разделах описываются подходы к организации межпоточ
ной передачи данных, служащие или альтернативой, или дополнением
к обмену сообщениями. Обмен сообщениями – рекомендуемый метод
организации межпоточного взаимодействия; этот метод легок для пони
мания, порождает удобный для чтения код, является надежным и мас
штабируемым. К более низкоуровневым механизмам стоит обращаться
лишь в совершенно особых обстоятельствах – и не забывайте, что «осо
бые» обстоятельства не всегда настолько особые, какими кажутся.
[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления)
## 13.11. Квалификатор типа shared
Мы уже познакомились с квалификатором `shared` в разделе 13.3. Для
системы типов ключевое слово `shared` служит сигналом о том, что не
сколько потоков обладают доступом к одному фрагменту данных. Ком
пилятор тоже признает этот факт и соответственно реагирует, накла
дывая ограничения на операции с разделяемыми данными, а также
посредством генерации особого кода для разрешенных операций.
С помощью глобального определения
```d
shared uint threadsCount;
```
в программу на D вводится значение типа `shared(uint)`, что соответству
ет глобально определенному целому числу без знака в программе на C.
Такая переменная видима всем потокам в системе. Примечание в виде
shared здорово помогает компилятору: язык «знает», что `threadsCount` от
крыт для свободного доступа множеству потоков, и запрещает обраще
ния к этой переменной наивными способами. Например:
```d
void bumpThreadsCount()
{
++threadsCount; // Ошибка! Увеличить на единицу значение типа shared int невозможно!
}
```
Что происходит? Где-то внизу, на машинном уровне, `++threadCount` не яв
ляется атомарной операцией; это сложная операция, представляющая
собой последовательность трех простых: прочесть – изменить – запи
сать. Сначала `threadCount` загружается в регистр, затем значение регист
ра увеличивается на единицу и, наконец, `threadCount` записывается об
ратно в память. Для обеспечения корректности всей сложной операции
эти три шага необходимо выполнять единым блоком. Корректный спо
соб увеличить на единицу разделяемое целое число – воспользоваться
одним из специализированных атомарных примитивов из модуля `std.concurrency`:
```d
import std.concurrency;
shared uint threadsCount;
void bumpThreadsCount()
{
// std.concurrency определяет atomicOp(string op)(ref shared uint, int)
atomicOp!"+="(threadsCount, 1); // Все в порядке
}
```
Поскольку все разделяемые данные тщательно учитываются и находят
ся под эгидой языка, передавать данные с квалификатором `shared` разре
шается с помощью функций `send` и `receive`.
[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления)
### 13.11.1. Сюжет усложняется: квалификатор shared транзитивен
В главе 8 объясняется, почему квалификаторы `const` и `immutable` долж
ны быть *транзитивными* (свойство, также известное как глубина или
рекурсивность): каким бы косвенным путем вы ни следовали, рассмат
ривая «внутренности» неизменяемого объекта, сами данные должны
оставаться неизменяемыми. В противном случае гарантии, предостав
ляемые квалификатором `immutable`, имели бы силу комментария в коде.
Нельзя сказать, что нечто «до определенного момента» неизменяемо
(`immutable`), а дальше меняется. Зато можно говорить, что данные *изменяемы* до определенного момента, а затем становятся совершенно неиз
меняемыми, вплоть до самых глубоко вложенных элементов. Применив
квалификатор `immutable`, вы сворачиваете на улицу с односторонним
движением. Мы уже видели, что присутствие квалификатора `immutable`
облегчает реализацию многих оправдавших себя идиом, не претендую
щих на свободу программиста, включая функциональный стиль и раз
деление данных между потоками. Если бы неизменяемость применя
лась «до определенного момента», то же самое относилось бы и к кор
ректности программы.
Точно такой же ход рассуждений применим и для квалификатора `shared`.
На самом деле, в случае с`shared` необходимость транзитивности абсо
лютно очевидна. Приведем пример. Выражение
```d
shared int* pInt;
```
в соответствии с синтаксисом квалификаторов (см. раздел 8.2) эквива
лентно выражению
```d
shared(int*) pInt;
```
Верная интерпретация `pInt` такова: «Указатель является разделяемым,
и данные, на которые он указывает, также разделяемы». При поверх
ностном, нетранзитивном подходе к разделению `pInt` превратился бы
в «разделяемый указатель на неразделяемую память», и все бы ничего,
если бы такой тип данных имел хоть какой-то смысл. Это все равно что
сказать: «Я делюсь этим бумажником со всеми; только, пожалуйста, не
забывайте, что деньгами из него я делиться не собирался»[^8]. Заявление,
что потоки разделяют указатель, но не данные, на которые он указыва
ет, возвращает нас к чудесной парадигме программирования на основе
системы доверия, которая всегда успешно проваливалась. И причина
большей части проблем не чьи-то происки, а честные ошибки. Про
граммное обеспечение имеет большой объем, сложно устроено и посто
янно изменяется, что плохо сочетается с обеспечением гарантий на ос
нове соглашений.
Тем не менее есть совершенно логичное понятие «неразделяемый указа
тель на разделяемые данные». Некоторый поток обладает «личным»
указателем, а этот указатель «смотрит» на разделяемые данные. Эту
идею легко выразить синтаксически:
```d
shared(int)* pInt;
```
Между нами, если бы существовала премия «За лучшее отображение со
держания», нотация `квалификатор(тип)`ее бы отхватила. Эта форма записи
совершенна. Синтаксис просто не позволит создать неправильный ука
затель. Некорректное сочетание синтаксических единиц выглядит так:
```d
int shared(*) pInt;
```
Такое выражение не имеет смысла даже синтаксически, поскольку
`(*)`– это не тип (ну да, *на самом деле* этот милый смайлик символизиру
ет циклопа).
Транзитивность квалификатора `shared` действует не только в отноше
нии указателей, но и в отношении полей объектов-структур и классов:
поля разделяемого объекта также автоматически воспринимаются как
помеченные квалификатором `shared`. Подробный разбор порядка взаи
модействия этого квалификатора с классами и структурами представ
лен далее в этой главе.
[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления)
## 13.12. Операции с разделяемыми данными и их применение
Работа с разделяемыми данными необычна, поскольку множество пото
ков могут читать и записывать разделяемые данные в любой момент. По
этому компилятор заботится о соблюдении целостности данных и при
чинности всеми операциями с разделяемыми данными.
Операции чтения и записи разделяемых (`shared`) значений разрешены,
и гарантированно будут атомарными для следующих типов: числовые
типы (кроме `real`), указатели, массивы, указатели на функции, делега
ты и ссылки на классы. Структуру с единственным полем одного из пе
речисленных типов также можно читать и записывать как неделимый
объект. Подчеркнутое отсутствие в списке «разрешенных типов» типа
`real` обусловлено тем, что это единственный тип, зависящий от платфор
мы. Вот почему в плане атомарного разделения компилятор смотрит на
`real`с опаской. На машинах Intel `real` занимает 80 бит, из-за чего пере
менным этого типа сложно делать атомарные присваивания в 32-раз
рядных программах. В любом случае, тип `real` предназначен для хране
ния временных результатов высокой точности, а не для обмена данны
ми, так что вряд ли у кого-то возникнет желание разделять значения
этого типа.
Для всех числовых типов и указателей на функции справедливо, что
значения этих типов с квалификатором `shared` могут неявно преобразо
вываться в значения без квалификатора и обратно. Преобразования
указателей между `shared(T*)` и `shared(T)*` разрешены в обоих направле
ниях. Арифметические операции на разделяемых числовых типах по
зволяют выполнять примитивы из модуля `std.concurrency`.
[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления)
### 13.12.1. Последовательная целостность разделяемых данных
Что касается видимости операций над разделяемыми данными между
потоками, D предоставляет следующие гарантии:
- порядок выполнения операций чтения и записи разделяемых дан
ных в рамках одного потока соответствует порядку, определенному
в исходном коде;
- глобальный порядок выполнения операций чтения и записи разде
ляемых данных представляет собой некоторое чередование опера
ций чтения и записи, выполнение которых инициируется из разных
потоков.
Выбор этих инвариантов кажется вполне резонным, даже очевидным.
И на самом деле такие гарантии довольно хорошо гармонируют с моде
лью вытесняющей многозадачности, реализованной на однопроцессор
ных системах.
Тем не менее в контексте мультипроцессорных систем такие гарантии
слишком строги. Проблема в следующем: для обеспечения этих гаран
тий необходимо сделать так, чтобы результат выполнения любой опера
ции записи был сразу же виден всем потокам. Единственный способ до
биться этого – окружить обращения к разделяемым данным особыми
машинными инструкциями (их называют *барьеры памяти*), которые
обеспечивали бы соответствие порядка, в котором выполняются опера
ции чтения и записи разделяемых данных, порядку обновления этих
данных в глазах всех запущенных потоков. Присутствие замыслова
тых иерархий кэшей значительно удорожает такую сериализацию.
Кроме того, непоколебимая приверженность принципу последователь
ной целостности заставляет отказаться от переупорядочивания опера
ций – основы множества способов оптимизации на уровне компилято
ра. В сочетании друг с другом эти два ограничения ведут к резкому за
медлению – вплоть до одного порядка единиц измерения.
Хорошая новость заключается в том, что такая потеря скорости имеет
место лишь в отношении разделяемых данных, которые используются
достаточно редко. В реальных ситуациях большинство данных не раз
деляются, а потому нет необходимости, чтобы в их отношении соблю
дался принцип последовательной целостности. Компилятор оптимизи
рует код, используя неразделяемые данные на всю катушку, в полной
уверенности, что другой поток никогда к ним не обратится, и относится
с осторожностью лишь к разделяемым данным. Повсеместно использу
емый и рекомендуемый прием для работы с разделяемыми данными –
копировать значения разделяемых переменных в локальные рабочие
копии потоков, работать с копиями и затем присваивать копии тем же
разделяемым переменным.
[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления)
## 13.13. Синхронизация на основе блокировок через синхронизированные классы
Традиционно популярный метод многопоточного программирования –
*синхронизация на основе блокировок*. В соответствии с этим подходом
разделяемые данные защищаются с помощью мьютексов – объектов
синхронизации, обеспечивающих переход от параллельного к последо
вательному исполнению фрагментов кода, которые или временно нару
шают когерентность данных, или могут видеть эти временные наруше
ния. Такие фрагменты кода называют *критическими участками*[^9].
Корректность программы, основанной на блокировках, обеспечивается
за счет ввода упорядоченного, последовательного доступа к разделяе
мым данным. Поток, которому требуется обратиться к фрагменту раз
деляемых данных, должен захватить (заблокировать) мьютекс, обрабо
тать данные, а затем освободить (разблокировать) мьютекс. В любой за
данный момент времени мьютексом может обладать только один поток,
благодаря чему и обеспечивается переход к последовательному выпол
нению: если захватить один и тот же мьютекс желают несколько пото
ков, то «выигрывает» лишь один, а остальные скромно ожидают своей
очереди. (Способ обслуживания очереди, то есть порядок очередности,
играет важную роль и может довольно заметно сказываться на работе
приложений и операционной системы.)
По всей вероятности, «Здравствуй, мир!» многопоточного программи
рования – это пример с банковским счетом: объект, доступный множе
ству потоков, должен предоставить безопасный интерфейс для пополне
ния счета и извлечения денежных средств со счета. Вот однопоточная,
базовая версия программы, позволяющей выполнять эти действия:
```d
import std.contracts;
// Однопоточный банковский счет
class BankAccount
{
private double _balance;
void deposit(double amount)
{
_balance += amount;
}
void withdraw(double amount)
{
enforce(_balance >= amount);
_balance -= amount;
}
@property double balance()
{
return _balance;
}
}
```
В отсутствие потоков операции `+=` и `-=` слегка вводят в заблуждение: они
«выглядят» как атомарные, но таковыми не являются –обе операции
состоят из тройки простых операций «прочесть – изменить – записать».
На самом деле, выражение `_balance += amount` кодируется как `_balance = _balance + amount`, а значит, процессор загружает `_balance` и `_amount` в соб
ственную оперативную память (регистры или внутренний стек), скла
дывает их, а затем переводит результат обратно в `_balance`.
Незащищенные параллельные операции типа «прочесть – изменить –
записать» становятся причиной некорректного поведения программы.
Скажем, баланс вашего счета характеризует истинное выражение `_balance == 100.0`. Некоторый поток, запуск которого был спровоцирован
требованием зачислить денежные средства по чеку, делает вызов `deposit(50)`. Сразу же после загрузки из памяти значения `100.0` выполнение
этой операции прерывает другой поток, осуществляющий вызов `withdraw(2.5)`. (Это вы в кофейне на углу оплачиваете латте своей дебетовой
картой.) Пусть ничто не вклинивается в обработку этого вызова, так что
поток, запущенный из кофейни, удачно обновляет поле `_balance`, и оно
принимает значение `97.5`. Однако это событие происходит совершенно
без ведома депонирующего потока, который уже загрузил число `100`
в регистр ЦПУ и все еще считает, что это верное количество. При вычис
лении нового значения баланса вызов `deposit(50)` получает `150` и записы
вает это число назад в переменную `_balance`. Это типичное *состояние гонки*. Поздравляю, вы получили бесплатный кофе (но остерегайтесь:
книжные примеры с ошибками еще могут работать на вас, а готовый
код с ошибками – нет). Для организации корректной синхронизации
многие языки предоставляют специальное средство – тип `Mutex`, кото
рый используется в программах, работающих с несколькими потоками
на основе блокировок, для защиты доступа к `balance`:
```d
// Этот код написан не на D
// Многопоточный банковский счет на языке с явным обращением к мьютексам
class BankAccount
{
private double _balance;
private Mutex _guard;
void deposit(double amount)
{
_guard.lock();
_balance += amount;
_guard.unlock();
}
void withdraw(double amount)
{
_guard.lock();
try
{
enforce(_balance >= amount);
_balance -= amount;
}
finally
{
_guard.unlock();
}
}
@property double balance()
{
_guard.lock();
double result = _balance;
_guard.unlock();
return result;
}
}
```
Все операции над `_balance` теперь защищены, поскольку для доступа
к этому полю необходимо заполучить `_guard`. Может показаться, что при
ставлять к `_balance` охранника в виде `_guard` излишне, так как значения
типа `double` можно читать и записывать «в один присест», однако защита
должна здесь присутствовать по причинам, скрытым многочисленны
ми завесами майи. Вкратце, из-за сегодняшних агрессивно оптимизи
рующих компиляторов и нестрогих моделей памяти *любое* обращение
к разделяемым данным должно сопровождаться своего рода секрет
ным соглашением между записывающим потоком, читающим потоком
и оптимизирующим компилятором; одно неосторожное чтение разде
ляемых данных – и вы оказываетесь в мире боли (хорошо, что D наме
ренно запрещает такую «наготу»). Первая и наиболее очевидная при
чина такого положения дел в том, что оптимизирующий компилятор,
не замечая каких-либо попыток синхронизировать доступ к данным
с вашей стороны, ощущает себя вправе оптимизировать код с обраще
ниями к `_balance`, удерживая значение этого поля в регистре. Вторая
причина в том, что во всех случаях, кроме самых тривиальных, компи
лятор *и* ЦПУ ощущают себя вправе свободно переупорядочивать неза
щищенные, не снабженные никаким дополнительным описанием обра
щения к разделяемым данным, поскольку считают, что имеют дело
с данными, принадлежащими лично одному потоку. (Почему? Да пото
му что чаще всего так и бывает, оптимизация порождает код с самым
высоким быстродействием, и в конце концов, почему должны страдать
плебеи, а не избранные и достойные?) Это один из тех моментов, с помо
щью которых современная многопоточность выражает свое пренебре
жение к интуиции и сбивает с толку программистов, сведущих в клас
сической многопоточности. Короче, чтобы обеспечить заключение сек
ретного соглашения, потребуется обязательно синхронизировать обра
щения к свойству `_balance`.
Чтобы гарантировать корректное снятие блокировки с`Mutex` в условиях
возникновения исключений и преждевременных возвратов управле
ния, языки, в которых продолжительность жизни объектов контекстно
ограничена (то есть деструкторы объектов вызываются на выходе из об
ластей видимости этих объектов), определяют вспомогательный тип
`Lock`, который устанавливает блок в конструкторе и снимает его в де
структоре. Эта идея развилась в самостоятельную идиому, известную
как *контекстное блокирование*. Приложение этой идиомы к клас
су`BankAccount` выглядит так:
```d
// Версия C++: банковский счет, защищенный методом контекстного блокирования
class BankAccount
{
private:
double _balance;
Mutex _guard;
public:
void deposit(double amount)
{
Lock lock = Lock(_guard);
balance += amount;
}
void withdraw(double amount)
{
Lock lock = Lock(_guard);
enforce(_balance >= amount);
balance -= amount;
}
double balance()
{
Lock lock = Lock(_guard);
return _balance;
}
}
```
Благодаря введению типа `Lock` код упрощается и повышается его кор
ректность: ведь соблюдение парности операций установления и снятия
блока теперь гарантировано, поскольку они выполняются автоматиче
ски. Java, C# и другие языки еще сильнее упрощают работу с блоки
ровками, встраивая `_guard` в объекты в качестве скрытого внутреннего
элемента и приподнимая логику блокирования вверх, до уровня сигна
туры метода. Наш пример, реализованный на Java, выглядел бы так:
```d
// Версия Java: банковский счет, защищенный методом контекстного
// блокирования, автоматизированного с помощью инструкции synchronized
class BankAccount
{
private double _balance;
public synchronized void deposit(double amount)
{
_balance += amount;
}
public synchronized void withdraw(double amount)
{
enforce(_balance >= amount);
_balance -= amount;
}
public synchronized double balance()
{
return _balance;
}
}
```
Соответствующий код на C# выглядит так же, за исключением того,
что ключевое слово `synchronized` должно быть заменено на `[MethodImpl(MethodImplOptions.Synchronized)]`.
Итак, вы только что узнали хорошую новость: небольшие программы,
основанные на блокировках, легки для понимания и, кажется, неплохо
работают. Плохая новость в том, что при большом масштабе очень слож
но сопоставлять должным образом блокировки и данные, выбирать
контекст и «калибр» блокирования и последовательно устанавливать
блокировки, затрагивающие сразу несколько объектов (не говоря о том,
что последнее может привести к тому, что взаимозаблокированные по
токи, ожидая завершения работы друг друга, попадают в *тупик*). В ста
рые добрые времена классического многопоточного программирования
подобные проблемы весьма осложняли кодирование на основе блокиро
вок; современная многопоточность (ориентированная на множество про
цессоров, с нестрогими моделями памяти и дорогим разделением дан
ных) поставила практику программирования с блокировками под удар. Тем не менее синхронизация на основе блокировок все еще полезна
для реализации множества задумок.
Для организации синхронизации с помощью блокировок D предостав
ляет лишь ограниченные средства. Эти границы установлены намерен
но: преимущество в том, что таким образом обеспечиваются серьезные
гарантии. Что касается случая с`BankAccount`, версия D очень проста:
```d
// Версия D: банковский счет, реализованный с помощью синхронизированного класса
synchronized class BankAccount
{
private double _balance;
void deposit(double amount)
{
_balance += amount;
}
void withdraw(double amount)
{
enforce(_balance >= amount);
_balance -= amount;
}
@property double balance()
{
return _balance;
}
}
```
D поднимает ключевое слово `synchronized` на один уровень выше, так
чтобы оно применялось к целому классу[^10]. Благодаря этому маневру
класс `BankAccount`, реализованный на D, получает возможность предос
тавлять более серьезные гарантии: даже если бы вы пожелали совер
шить ошибку, при таком синтаксисе нет способа оставить открытой
хоть какую-нибудь дверь с черного хода для несинхронизированных
обращений к `_balance`. Если бы в D позволялось смешивать использова
ние синхронизированных и несинхронизированных методов в рамках
одного класса, все обещания, данные синхронизированными метода
ми, оказались бы нарушенными. На самом деле опыт с синхронизацией
на уровне методов показал, что лучше всего синхронизировать или все
методы, или ни один из них; классы двойного назначения приносят
больше проблем, чем удобств.
Объявляемый на уровне класса атрибут `synchronized` действует на объек
ты типа `shared(BankAccount)` и автоматически превращает параллельное
выполнение вызовов любых методов класса в последовательное. Кроме
того, синхронизированные классы характеризуются возросшей строго
стью проверок уровня защиты их внутренних элементов. Вспомните,
в соответствии с разделом 11.1 обычные проверки уровня защиты в об
щем случае позволяют обращаться к любым не общедоступным (`public`)
внутренним элементам модуля любому коду внутри этого модуля. Толь
ко не в случае синхронизированных классов – классы с атрибутом `synchronized` подчиняются следующим правилам:
- объявлять общедоступные (`public`) данные и вовсе запрещено;
- право доступа к защищенным (`protected`) внутренним элементам
есть только у методов текущего класса и его потомков;
- право доступа к закрытым (`private`) внутренним элементам есть толь
ко у методов текущего класса.
[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления)
## 13.14. Типизация полей в синхронизированных классах
В соответствии с правилом транзитивности для разделяемых (`shared`)
объектов разделяемый объект класса распространяет квалификатор
`shared` на свои поля. Очевидно, что атрибут `synchronized` привносит неко
торый дополнительный закон и порядок, что отражается в нестрогой
проверке типов полей внутри методов синхронизированных классов.
Ключевое слово `synchronized` должно предоставлять серьезные гарантии,
поэтому его присутствие своеобразно отражается на семантической про
верке полей, в чем прослеживается настолько же своеобразная семанти
ка самого атрибута `synchronized`.
Защита синхронизированных методов от гонок *временна* и *локальна*.
Свойство временности означает, что как только метод возвращает управ
ление, поля от гонок больше не защищаются. Свойство локальности
подразумевает, что `synchronized` обеспечивает защиту данных, встроен
ных непосредственно в объект, но не данных, на которые объект ссыла
ется косвенно (то есть через ссылки на классы, указатели или массивы).
Рассмотрим каждое из этих свойств по очереди.
[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления)
### 13.14.1. Временная защита == нет утечкам
Возможно, это не вполне очевидно, но «побочным эффектом» времен
ной природы `synchronized` становится формирование следующего пра
вила: ни один адрес поля не в состоянии «утечь» из синхронизирован
ного кода. Если бы такое произошло, некоторый другой фрагмент кода
получил бы право доступа к некоторым данным за пределами времен
ной защиты, даруемой синхронизацией на уровне методов.
Компилятор пресечет любые поползновения возвратить из метода ссыл
ку или указатель на поле или передать значение поля по ссылке или по
указателю в некоторую функцию. Покажем, в чем смысл этого прави
ла, на следующем примере[^11]:
```d
double * nyukNyuk; // Обратите внимание: без shared
void sneaky(ref double r) { nyukNyuk = &r; }
synchronized class BankAccount
{
private double _balance;
void fun()
{
nyukNyuk = &_balance; // Ошибка! (как и должно быть в этом случае)
sneaky(_balance); // Ошибка! (как и должно быть в этом случае)
}
}
```
В первой строке `fun` осуществляется попытка получить адрес `_balance`
и присвоить его глобальной переменной. Если бы эта операция заверши
лась успехом, гарантии системы типов превратились бы в ничто –с мо
мента «утечки» адреса появилась бы возможность обращаться к разде
ляемым данным через неразделяемое значение. Присваивание не прохо
дит проверку типов. Вторая операция чуть более коварна в том смысле,
что предпринимает попытку создать псевдоним более изощренным спо
собом – через вызов функции, принимающей параметр по ссылке. Та
кое тоже не проходит; передача значения с помощью `ref` фактически
влечет получение адреса до совершения вызова. Операция получения
адреса запрещена, так что и вызов завершается неудачей.
[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления)
### 13.14.2. Локальная защита == разделение хвостов
Защита, которую предоставляет `synchronized`, обладает еще одним важ
ным качеством – она локальна. Имеется в виду, что она не обязательно
распространяется на какие-либо данные помимо непосредственных по
лей объекта. Как только на горизонте появляются косвенности, гаран
тия того, что потоки будут обращаться к данным по одному, практиче
ски утрачивается. Если считать, что данные состоят из «головы» (часть,
расположенная в физической памяти, которую занимает объект клас
са`BankAccount`) и, возможно, «хвоста» (косвенно доступная память), то
можно сказать, что синхронизированный класс в состоянии защитить
лишь «голову» данных, в то время как «хвост» остается разделяемым
(`shared`). По этой причине типизация полей синхронизированного (`synchronized`) класса внутри метода выполняется особым образом:
- значения любых числовых типов не разделяются (у них нет хвоста),
так что с ними можно обращаться как обычно (не применяется атри
бут `shared`);
- поля-массивы, тип которых объявлен как `T[]`, получают тип `shared(T)[]`; то есть голова (границы среза) не разделяется, а хвост (со
держимое массива) остается разделяемым;
- поля-указатели, тип которых объявлен как `T*`, получают тип `shared(T)*`; то есть голова (сам указатель) не разделяется, а хвост (дан
ные, на которые указывает указатель) остается разделяемым;
- поля-классы, тип которых объявлен как `T`, получают тип `shared(T)`.
К классам можно обратиться лишь по ссылке (это делается автома
тически), так что они представляют собой «сплошной хвост».
Эти правила накладываются поверх правила о запрете «утечек», опи
санного в предыдущем разделе. Прямое следствие такой совокупности
правил: операции, затрагивающие непосредственные поля объекта,
внутри метода можно свободно переупорядочивать и оптимизировать,
как если бы разделение этих полей было временно остановлено –а имен
но это и делает `synchronized`.
Иногда один объект полностью владеет другим. Предположим, что
класс `BankAccount` сохраняет все свои предыдущие транзакции в списке
значений типа `double`:
```d
// Не синхронизируется и вообще понятия не имеет о потоках
class List(T) {
...
void append(T value) {
...
}
}
// Ведет список транзакций
synchronized class BankAccount
{
private double _balance;
private List!double _transactions;
void deposit(double amount)
{
_balance += amount;
_transactions.append(amount);
}
void withdraw(double amount)
{
enforce(_balance >= amount);
_balance -= amount;
_transactions.append(-amount);
}
@property double balance()
{
return _balance;
}
}
```
Класс `List` не проектировался специально для разделения между пото
ками, поэтому он не использует никакой механизм синхронизации, но
к нему и на самом деле никогда не обращаются параллельно! Всеобра
щения к этому классу замурованы в объекте класса `BankAccount` и полно
стью защищены, поскольку находятся внутри синхронизированных ме
тодов. Если предполагать, что `List` не станет затевать никаких безумных
проделок вроде сохранения некоторого внутреннего указателя в гло
бальной переменной, такой код должен быть вполне приемлемым.
К сожалению, это не так. В языке D код, подобный приведенному выше,
не заработает никогда, поскольку вызов метода `append` применительно
к объекту типа `shared(List!double)` некорректен. Одна из очевидных при
чин отказа компилятора от такого кода в том, что компиляторы никому
не верят на слово. Класс `List` может хорошо себя вести и все такое, но
компилятору потребуется более веское доказательство того, что за его
спиной не происходит никакое создание псевдонимов разделяемых дан
ных. В теории компилятор мог бы пойти дальше и проверить определе
ние класса `List`, однако `List`, в свою очередь, мог бы использовать другие
компоненты, расположенные в других модулях, так что не успеете вы
сказать «межпроцедурный анализ», как код «на обещаниях» начнет
выходить из-под контроля.
*Межпроцедурный анализ* – это техника, применяемая компиляторами
и анализаторами программ для доказательства справедливости предпо
ложений о программе с помощью одновременного рассмотрения сразу
нескольких функций. Такие алгоритмы анализа обычно обладают низ
кой скоростью, начинают хуже работать с ростом программы и являют
ся заклятыми врагами раздельной компиляции. Некоторые системы
используют межпроцедурный анализ, но большинство современных
языков (включая D) выполняют все проверки типов, не прибегая к этой
технике.
Альтернативное решение проблемы с подобъектом-собственностью –
ввести новые квалификаторы, которые бы описывали отношения владе
ния, такие как «класс `BankAccount` является владельцем своего внутрен
него элемента `_transactions`, следовательно, мьютекс `BankAccount` также
обеспечивает последовательное выполнение операций над `_transactions`». При верном расположении таких примечаний компилятор смог бы
получить подтверждение того, что объект `_transactions` полностью ин
капсулирован внутри `BankAccount`, а потому безопасен в использовании,
и к нему можно обращаться, не беспокоясь о неуместном разделении.
Системы и языки, работающие по такому принципу, уже
были представлены, однако в настоящий момент они погоды не делают.
Ввод явного указания имеющихся отношений владения свидетельству
ет о появлении в языке и компиляторе значительных сложностей. Учи
тывая, что в настоящее время синхронизация на основе блокировок
борется за существование, D поостерегся усиливать поддержку этой
ущербной техники программирования. Не исключено, что это решение
еще будет пересмотрено (для D были предложены системы моделирова
ния отношений владения), но на настоящий момент, чтобы реализо
вать некоторые проектные решения, основанные на блокировках, при
ходится, как объясняется далее, переступать границы системы типов.
[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления)
### 13.14.3. Принудительные идентичные мьютексы
D позволяет сделать динамически то, что система типов не в состоянии
гарантировать статически: отношение «владелец/собственность» в кон
тексте блокирования. Для этого предлагается следующая глобально
В этом случае конструктор будет запущен только один раз:
```
Статический конструктор: counter = 1
Основной поток
Дочерний поток
```
Разделяемыми могут быть не только конструкторы и деструкторы мо
дуля, но и статические конструкторы и деструкторы класса. Порядок
выполнения разделяемых статических конструкторов и деструкторов
определяется теми же правилами, что и порядок выполнения локаль
ных статических конструкторов и деструкторов.
[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления)
## 13.18. Итоги
Реализация функции `setlsb`, грязная и с потеками масла на стыках,
была бы подходящим заключением для главы, которая началась со
строгой красоты обмена сообщениями и постепенно спустилась в под
земный мир разделения данных.
D предлагает широкий спектр средств для работы с потоками. Наибо
лее предпочтительный механизм для большинства приложений на со
временных машинах – определение протоколов на основе обмена сооб
щениями. При таком выборе может здорово пригодиться неизменяемое
разделение. Отличный совет для тех, кто хочет проектировать надеж
ные, масштабируемые приложения, использующие параллельные вы
числения, – организовать взаимодействие между потоками по методу
обмена сообщениями.
Если требуется определить синхронизацию на основе взаимоисключе
ния, это можно осуществить с помощью синхронизированных классов.
Но предупреждаю: по сравнению с другими языками, поддержка про
граммирования на основе блокировок в D ограничена, и на это есть ос
нования.
Если требуется простое разделение данных, можно воспользоваться раз
деляемыми (`shared`) значениями. D гарантирует, что операции с разде
ляемыми значениями выполняются в порядке, определенном в вашем
коде, и не провоцируют парадоксы видимости и низкоуровневые гонки.
Наконец, если вам наскучили такие аттракционы, как банджи-джам
пинг, укрощение крокодилов и прогулки по раскаленным углям, вы бу
дете счастливы узнать, что существует программирование без блокиро
вок и что вы можете заниматься этим в D, используя разделяемые
структуры и классы.
[В начало ⮍]() [Наверх ⮍](#13-параллельные-вычисления)
[^1]: Число транзисторов на кристалл будет увеличиваться вдвое каждые 24 месяца. –*Прим. пер.*
[^2]: Далее речь идет о параллельных вычислениях в целом и не рассматриваются распараллеливание операций над векторами и другие специализированные параллельные функции ядра.
[^3]: Что иронично, поскольку во времена классической многопоточности разделение памяти было быстрее, а обмен сообщениями – медленнее.
[^4]: Даже заголовок раздела был изменен с «Потоки» на «Параллельные вычисления», чтобы подчеркнуть, что потоки – это не что иное, как одна из моделей параллельных вычислений.
[^5]: Процессы языка Erlang отличаются от процессов ОС.
[^6]: Подразумевалось обратное от «насыплем соль на рану».
[^7]: Речь идет о самом процессе программирования: правила, соблюдение которых компилятор гарантировать не может, люди рано или поздно начнут нарушать (с плачевными последствиями). –*Прим. науч. ред.*
[^8]: Кстати, воспользовавшись квалификатором `const`, вы сможете делиться бумажником, зная при этом, что деньги в нем защищены от воров. Стоит лишь ввести тип `shared(const(Money)*)`.
[^9]: Возможна путаница из-за того, что Windows использует термин «критический участок» для обозначения легковесных объектов мьютексов, защищающих критические участки, а «мьютекс» – для более массивных мьютексов, с помощью которых организуется передача данных между процессами.
[^10]: Впрочем, D разрешает объявлять синхронизированными отдельные методы класса (в том числе статические). –*Прим. науч. ред.*
[^12]: На момент выхода книги возможность вызова функций как псевдочленов (см. раздел 5.9) не была реализована полностью, и вместо кода `obj.setSameMutex(owner)` нужно было писать `setSameMutex(obj, owner)`. Возможно, все уже изменилось. –*Прим. науч. ред.*
[^13]: Описание этой части языка не было включено в оригинал книги, но поскольку эта возможность присутствует в текущих реализациях языка, мы добавили ее описание в перевод. –*Прим. науч. ред.*