Pull to refresh
107.33
InlyIT
Для старательного нет ничего невозможного

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

Reading time3 min
Views7.9K
Original author: Scott Hanselman
Люблю хорошие баги, особенно такие, которые поначалу сложно объяснить, а потом приходит момент, когда хлопаешь себя по лбу – ну конечно!

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

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

Разглядывать зигзагообразные графики с их движением вверх-вниз – не слишком увлекательное занятие, но взгляните на ось X. Она отображает спады и подъемы не от минуты к минуте и даже не от миллисекунды к миллисекунде, как на графиках, которые вы, вероятно, видели до сих пор. Эта ось использует в качестве единицы измерения месяцы. Перечитайте еще раз и проникнитесь.



В феврале всё отлично, потом несколько недель всё плохо, затем, в марте, опять хорошо, и этот цикл постоянно повторяется. Не в полном соответствии с лунным циклом, но довольно близко.
Автор отмечает, что число задействованных ядер при использовании 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);

Какой интересный и злокозненный баг! Баги, которые вызваны расчетом времени, часто проявляются далеко не сразу, только когда смотришь на них с большего расстояния и в крупных календарных масштабах. Иногда это случается куда позже, чем можно было бы предположить.
Tags:
Hubs:
Total votes 24: ↑22 and ↓2+27
Comments6

Articles

Information

Website
inlyit.com
Registered
Founded
Employees
31–50 employees
Location
Россия