[Перевод] При полной луне этот код работал иначе

Люблю хорошие баги, особенно такие, которые поначалу сложно объяснить, а потом приходит момент, когда хлопаешь себя по лбу — ну конечно!

На Github есть один баг, он называется «Эффект гистерезиса в методе подъема на холм применительно к пулу потоков» — очень интересное чтение. Подъем на холм — это алгоритмическая техника: у вас есть холм (некая проблема), вы понемногу улучшаете ситуацию (поднимаетесь), пока не достигнете определенного максимально приемлемого решения (вершины холма).

Себастьян, автор описания бага, говорит, что у пула потоков прослеживается влияние эффекта гистерезиса. «Гистерезис — это зависимость состояния системы от предшествующих событий». Нечто странное происходит по той причине, что до этого произошло еще что-то…, но что именно?
Разглядывать зигзагообразные графики с их движением вверх-вниз — не слишком увлекательное занятие, но взгляните на ось X. Она отображает спады и подъемы не от минуты к минуте и даже не от миллисекунды к миллисекунде, как на графиках, которые вы, вероятно, видели до сих пор. Эта ось использует в качестве единицы измерения месяцы. Перечитайте еще раз и проникнитесь.

rmepqrbdsrc9xtp4bdddwympi5k.jpeg

В феврале всё отлично, потом несколько недель всё плохо, затем, в марте, опять хорошо, и этот цикл постоянно повторяется. Не в полном соответствии с лунным циклом, но довольно близко.
Автор отмечает, что число задействованных ядер при использовании PortableThreadPool меняется от месяца к месяцу:

Мы заметили регулярный паттерн в логике подъема на холм применительно к пулу потоков, а именно: используется либо n-ядер, либо n-ядер + 20 with с эффектом гистерезиса, который проявляется каждые 3–4 недели.

Знали ли вы (я вот знаю, потому что старый), что система Windows 95 какое-то время не могла непрерывно работать дольше, чем 49,7 дней? Если выждать этот срок, она в конце концов падала! Это объяснялось тем, что один день содержит 86 миллионов миллисекунд, так как 1000×60 * 60×24 = 86,400,000, а 32 бита — это 4,294,967,296, соответственно, 4,294,967,296 / 86,400,000 = 49.7102696 дня!

Кевин в комментариях к багу на Github также приводит этот факт:

Вся эта периодичность квадратной волны очень сильно напоминает ситуацию с 49,7 днями. Именно столько функции GetTickCount () требуется, чтобы обернуться. На платформах POSIX имплементация происходит на платформенном уровне абстракции, и значение, которое возвращается, определяется не аптаймом, а временем на часах, которое распространяется по всем машинам, меняясь в один и тот же день.


Этот срок в 49,7 дней хорошо всем известен, так как именно столько времени проходит, прежде чем GetTickCount () переходит через ноль. Далее Кевин приводит даты обнуления, которые вполне соответствуют графику:

  • Четверг, 14 января 2021
  • Воскресенье, 7 февраля 2021
  • Четверг, 4 марта, 2021
  • Понедельник, 29 марта, 2021
  • Пятница, 23 апреля, 2021

Затем он отыскивает в PortableThreadPool.cs код, в котором кроется объяснение проблемы:

private bool ShouldAdjustMaxWorkersActive(int currentTimeMs)
{
// We need to subtract by prior time because Environment.TickCount can wrap around, making a comparison of absolute times unreliable.
int priorTime = Volatile.Read(ref _separated.priorCompletedWorkRequestsTime);
int requiredInterval = _separated.nextCompletedWorkRequestsTime - priorTime;
int elapsedInterval = currentTimeMs - priorTime;
if (elapsedInterval >= requiredInterval)
{
...

Он говорит (это всё цитаты Кевина): currentTimeMs — это Environment.TickCount, который по стечению обстоятельств в данном случае отрицательная величина.

Условный оператор if определяет, будет ли вообще запускаться подъем на холм. _separated.priorCompletedWorkRequestsTime и _separated.nextCompletedWorkRequestsTime на старте процесса начинают с нуля и обновляются только в том случае, если запускается код подъема на холм.

Соответственно, requiredInterval = 0 — 0 и elapsedInterval = negativeNumber — 0. В результате условный оператор принимает следующий вид: if (negativeNumber — 0 >= 0 — 0), что возвращает значение false, а значит, код подъема на холм так и не запускается, вследствие чего переменные не обновляются и остаются нулями. Нативная версия кода для пула потоков производит все вычисления в беззнаковых числах, что предотвратило бы возникновение подобного бага (плюс соответствующие вычисления в принципе были бы несколько другими).

Пожалуй, самое простое решение здесь — использовать беззнаковые расчеты. Но как вариант можно инициализировать оба поля в Environment.TickCount; это, вероятно, тоже сработает.

Возвращаемся к моим собственным соображениям. Превосходно. Решение — переводить результаты в беззнаковую целочисленную форму при помощи (uint).

Было:

int requiredInterval = _separated.nextCompletedWorkRequestsTime - priorTime;
int elapsedInterval = currentTimeMs - priorTime;

Стало:

uint requiredInterval = (uint)(_separated.nextCompletedWorkRequestsTime - priorTime);
uint elapsedInterval = (uint)(currentTimeMs - priorTime);

Какой интересный и злокозненный баг! Баги, которые вызваны расчетом времени, часто проявляются далеко не сразу, только когда смотришь на них с большего расстояния и в крупных календарных масштабах. Иногда это случается куда позже, чем можно было бы предположить.

© Habrahabr.ru