Рассмотрим ещё один пример опасного использования часов в распределённой системе. Допустим, у вас есть база данных с единственным лидером на каждый раздел. Только лидер может принимать записи. Как узнает узел, что он все ещё лидер (что его не объявили мёртвым другие узлы) и что он может безопасно принимать записи?
Один из вариантов — лидер получает аренду (lease) от других узлов, что похоже на блокировку с тайм-аутом. Только один узел может удерживать аренду в любой момент времени. Таким образом, когда узел получает аренду, он знает, что он лидер в течение некоторого времени, пока аренда не истекает. Чтобы остаться лидером, узел должен периодически обновлять аренду перед её истечением. Если узел сбоит, перестав обновлять аренду, другой узел может взять её, когда аренда истечёт.
Можно представить себе цикл обработки запросов, выглядящий примерно так:
while (true) {
request = getIncomingRequest();
// Убедиться, что аренда всегда имеет не менее 10 секунд
if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000) {
lease = lease.renew();
}
if (lease.isValid()) {
process(request);
}
}
Что не так с этим кодом? Во-первых, он полагается на синхронизированные часы: время истечения аренды устанавливается на другой машине (где срок может быть рассчитан, например, как текущее время плюс 30 секунд) и оно сравнивается с локальными часами системы. Если часы не синхронизированы более чем на несколько секунд, этот код начнет вести себя странно.
Во-вторых, даже если мы изменяем протокол, чтобы использовать только локальные монотонные часы, есть ещё одна проблема: код предполагает, что между моментом проверки времени (System.currentTimeMillis()) и временем обработки запроса (process(request)) проходит очень мало времени. Обычно этот код выполняется очень быстро, поэтому буфер в 10 секунд более чем достаточен, чтобы обеспечить, чтобы аренда не истекла посередине обработки запроса.
Однако что, если происходит неожиданная пауза в выполнении программы? Например, представьте себе, что поток останавливается на 15 секунд вокруг строки lease.isValid(), прежде чем, наконец, продолжить. В этом случае, скорее всего, аренда истечет к моменту обработки запроса и другой узел уже станет лидером. Однако нет ничего, что скажет этому потоку, что он был приостановлен настолько долго, поэтому этот код не заметит, что аренда истекла, пока не следующей итерации цикла — к тому времени, возможно, он уже сделал что-то небезопасное, обработав запрос.
Не кажется ли странным предположение о том, что поток может быть приостановлен на столь долгий срок? К сожалению, нет. Есть различные причины, по которым это может произойти:
- Многие среды выполнения языков программирования (такие как Java Virtual Machine) имеют сборщик мусора (GC — garbage collector), который иногда должен останавливать все выполняющиеся потоки. Эти паузы сборки мусора типа «stop-the-world» иногда могут длиться несколько минут! Даже так называемые «конкурирующие» сборщики мусора, такие как CMS в JVM HotSpot, не могут полностью работать параллельно с кодом приложения — они также иногда должны останавливать мир. Хотя паузы часто можно уменьшить, изменяя шаблоны выделения памяти или настраивая параметры GC, мы должны предполагать худшее, если хотим предоставить надёжные гарантии.
- В виртуализированных средах виртуальную машину можно приостановить (приостановив выполнение всех процессов и сохраняя содержимое памяти на диск) и возобновить (восстанавливая содержимое памяти и продолжая выполнение). Эта пауза может произойти в любой момент выполнения процесса и может продолжаться произвольное время. Эта функция иногда используется для живой миграции виртуальных машин с одного хоста на другой без перезагрузки, в этом случае длительность паузы зависит от того, как быстро процессы пишут в память.
- На конечных устройствах, таких как ноутбуки, выполнение также может быть приостановлено и возобновлено произвольным образом, например, когда пользователь закрывает крышку своего ноутбука.
- Когда операционная система переключается на другой поток или когда гипервизор переключается на другую виртуальную машину (при выполнении в виртуальной машине), текущий выполняющийся поток может быть приостановлен в любой произвольной точке кода. В случае виртуальной машины время CPU, проведенное в других виртуальных машинах, известно как «время кражи». Если машина находится под тяжёлой нагрузкой, например, если есть длинная очередь потоков, ожидающих выполнения, потребуется некоторое время, прежде чем приостановленный поток снова начнет выполняться.
- Если приложение выполняет синхронный доступ к диску, поток может быть приостановлен в ожидании завершения медленной операции ввода-вывода с диском. На многих языках доступ к диску может происходить удивительно, даже если код явно не упоминает доступ к файлу — например, загрузчик классов Java лениво загружает файлы классов, когда они впервые используются, что может произойти в любое время выполнения программы. Паузы ввода-вывода и паузы GC могут даже сговориться и объединить свои задержки. Если диск фактически является сетевым файловым хранилищем или сетевым блочным устройством (например, Amazon's EBS), то задержка ввода-вывода также подвержена изменчивости сетевых задержек.
- Если операционная система настроена на разрешение подкачки на диск (страничный обмен), простое обращение к памяти может вызвать отказ страницы, который требует загрузки страницы с диска в память. Поток приостанавливается на время выполнения этой медленной операции ввода-вывода. При высоком давлении на память это в свою очередь может потребовать подкачки другой страницы на диск. В экстремальных случаях операционная система может проводить большую часть своего времени обменом страницами в память и выполнять мало фактической работы (это известно как трэшинг). Чтобы избежать этой проблемы, подкачка часто отключена на серверных машинах (если вы предпочтете завершить процесс, чтобы освободить память, чем рисковать трэшингом).
- Процесс Unix может быть приостановлен путем отправки ему сигнала SIGSTOP, например, нажатием Ctrl-Z в оболочке. Этот сигнал мгновенно останавливает процесс, лишая его возможности получать больше циклов CPU, пока он не возобновится с помощью SIGCONT, после чего он продолжает выполнение с того места, где он остановился. Даже если ваша среда обычно не использует SIGSTOP, его могут отправить случайно инженеры по эксплуатации.
Все эти события могут прервать выполняющийся поток в любой момент и возобновить его в какое-то позднее время, даже не предупредив поток об этом. Проблема аналогична созданию многозадачного кода на одной машине: нельзя ничего предполагать о времени, потому что могут происходить произвольные переключения контекста и параллелизм. При написании многозадачного кода на одной машине у нас есть довольно хорошие инструменты для его обеспечения: мьютексы, семафоры, атомарные счетчики, структуры данных без блокировок, блокирующие очереди и так далее. К сожалению, эти инструменты не переносятся непосредственно в распределённые системы, потому что в распределённой системе нет общей памяти — только сообщения, отправляемые по ненадёжной сети.
Узел в распределённой системе должен предполагать, что его выполнение может быть приостановлено на значительное время в любой точке, даже в середине функции. Во время паузы остальной мир продолжает двигаться и может даже объявить приостановленный узел мёртвым, поскольку он не отвечает на запросы. В конце концов, приостановленный узел может продолжить работу, даже не заметив, что он спал, пока не проверит свои часы некоторое время спустя.