Одно из самых важных наблюдений, которые я сделал за годы работы, заключается в том, что во всех программах есть ошибки. С ростом сложности программы растёт и сложность ошибок, которые можно в ней встретить. Часто изъяны программы — сущности вполне простые и понятные. Их легко заметить, проверить и воспроизвести. Иногда процесс исправления заурядной ошибки выставляет идеи программиста о том, как работают те или иные механизмы, в столь новом и неожиданном свете, что в итоге кажется, будто кто-то просто над ним издевается.

Сегодня хочу рассказать об ошибке, которая была исправлена путём редактирования одной строчки кода. Это исправление избавило людей, работающих на огромном количестве устройств, от странных ошибок, которые очень трудно воспроизвести. Речь идёт о ситуациях, в которых Anubis не давал пользователям смотреть сайты, при том что никаких веских причин для этого у него не было. Не переключайтесь. Будет интересно.

Как это случилось

Anubis — это файрвол веб-приложений (Web Application Firewall, WAF). Его задача заключается в том, чтобы проверять, являются ли клиенты настоящими браузерами или роботами. Он, различая людей и машины, использует несколько методов проверки, но основным является так называемый «proof of work challenge» (PoW) — метод, когда клиенту предлагается пройти испытание, после которого он предоставляет доказательство проделанной работы. В частности, речь идёт о том, что клиенту предлагается усердно потрудиться над вычислением криптографических контрольных сумм. Это делается для того, чтобы ограничить частоту обращения к сайтам для слишком пылко настроенных клиентов.

Примечание

Сейчас, оглядываясь назад, я понимаю, что внедрение PoW, возможно, было ошибкой. PoW, вероятно, вытеснят другие методы проверки, вроде Proof of React (клиенту предлагается решить криптографическую задачу, код которой глубоко вложен в хук Preact; это позволяет подтвердить то, что клиент способен выполнять JavaScript-код) или каких-то таких методов, которые пока ещё не придуманы. Спасибо за ваши терпение и вежливость в баг-трекере.

Чтобы обеспечить как можно более быстрое исчезновение экрана PoW-испытания, код воркера до предела оптимизирован. Один из основных подходов, применяемых в повышении производительности этого кода, заключается в оптимизации того, как именно он выполняется. Последние 10-20 лет главным способом ускорения процессоров было повышение их производительности путём наращивания количества ядер. Anubis стремится к тому, чтобы воспользоваться максимально возможным количеством доступных ядер, а значит — взять всё, что можно, от процессора, установленного в устройстве пользователя.

Правда, такая стратегия не лишена недостатков, проявляющихся в некоторых ситуациях. Например — похоже, что Firefox сильно замедляется если Anubis пытается максимально загрузить все ядра, имеющиеся в устройстве. Кроме того, возникает серьёзная дополнительная нагрузка на систему, вызванная использованием API Web Crypto из JavaScript-кода, компилируемого с помощью JIT. Я провёл кое-какие тесты, и выяснил, что рубежом падения эффективности Firefox является тот момент, когда используется примерно половина ядер CPU.

Очередная ошибка: «invalid response»

Одна из жалоб, с которой обращались ко мне пользователи и администраторы, применяющие Anubis, заключалась в том, что они сталкивались с непредсказуемым отклонением соединений, когда всё, что они видели, выглядело как сообщение об ошибке «invalid response» (недопустимый отклик). Это происходило в тех случаях, когда процесс проверки клиента заканчивался неудачей. Из-за этой проблемы даже задерживался выпуск следующей версии Anubis.

Для того чтобы лучше это показать, я сделал небольшую интерактивную схему процесса PoW.

Схема, иллюстрирующая процесс PoW
Схема, иллюстрирующая процесс PoW

Интерактивный вариант схемы доступен в исходной статье

К моменту обнаружения этой проблемы я исправил в Anubis множество простых ошибок. То, что осталось, было, в основном, представлено сложными ошибками и, в особенности, такими, которые связаны с необычными аппаратными конфигурациями. Для того, чтобы выявить и отловить эти ошибки до выхода проекта в продакшн, я проверил Anubis на множестве устройств, которые оказались у меня под рукой. То, что я найду и исправлю до момента выпуска проекта, не доберётся до пользователей, работающих с программой.

Посмотрим на следующую строчку кода (её, а так же то, что её окружает, можно найти здесь), которая вызывала эту проблему:

threads = Math.max(navigator.hardwareConcurrency / 2, 1),

Этот код нужен для того, чтобы браузер запустил бы потоки PoW-воркера на половине доступных процессорных ядер. Если имеется всего одно ядро — будет запущен только один экземпляр воркера. Потокам передают эти сведения, они используют их, работая со значениями nonce, деля их между собой, в результате одни потоки не будут искать решения задач, которые уже решены другими потоками.

Но тут имеется одна небольшая проблема. Заключается она в том, что вся программа написана, исходя из предположения о том, что ID потоков и nonce — это целые числа, не имеющие дробной части. Но, как известно, все числа в JavaScript — это числа с плавающей запятой, соответствующие стандарту IEEE 754. Значение переменной theads, количество потоков, конечно же, не может быть дробным числом. Или может?

Вот устройства, на которых я тестировал Anubis, а так же — количество ядер их процессоров.

Устройство

Количество ядер

MacBook Pro M3 Max

16

MacBook Pro M4 Max

16

AMD Ryzen 9 7950x3D

32

Google Pixel 9a (GrapheneOS)

8

iPhone 15 Pro Max

6

iPad Pro (M1)

8

iPad mini

6

Steam Deck

8

Core i5 10600 (homelab)

12

ROG Ally

16

Что-нибудь заметили? Все эти устройства обладают процессорами с чётным количеством ядер. А некоторые устройства, вроде Pixel 8 Pro, имеют нечётное количество ядер. Что же происходит, когда, JavaScript-движок проводит вычисления, используя вышеприведённую строчку кода?

Заменим navigator.hardwareConcurrency на количество ядер Pixel 8 Pro — на число 9:

threads = Math.max(9 / 2, 1),

Что получится при делении 9 на 2?

threads = Math.max(4.5, 1),

Ничего хорошего. Но 4,5 больше единицы, поэтому после того, как функция Math.max сделает своё дело, получаем следующее:

threads = 4.5,

Это означает, что всякий раз, когда проводятся вычисления, в 50% случаев правильные решения будут включать в себя значение nonce, имеющее дробную часть. Если клиент найдёт решение с таким nonce — он подумает, что задача успешно решена, и отправит это решение серверу. А сервер ожидает получения только целых чисел, поэтому он решение отвергнет и выдаст ошибку «invalid response».

Я часто говорю начинающим разработчикам, что когда в программах появляются самые странные, самые неуловимые ошибки, причинами этих ошибок, в итоге, оказываются такие глупости, которые сложно даже себе представить. Люди мне не верят. А потом они сталкиваются с подобными ошибками, после чего внезапно проникаются доверием к тому, о чём я им говорил.

Вот код, исправляющий ошибку:

threads = Math.trunc(Math.max(navigator.hardwareConcurrency / 2, 1)),

Тут используется функция Math.trunc, которая возвращает целую часть числа. В результате на Pixel 8 Pro будет запущено 4, а не 4,5 воркера.

Сегодня я узнал о том, что такое возможно

Это был именно такой момент, после которого можно всем рассказывать о том, как «сегодня я узнал о том, что…». Я и не думал, что производители разных устройств ставят в них процессоры с нечётным количеством ядер. Но если посмотреть на конфигурацию ядер Pixel 8 Pro, окажется, что у него имеется три типа ядер, при этом в одной из групп имеется лишь одно ядро, а в двух других — по четыре:

Тип ядра

Модель ядра

Количество ядер

Высокопроизводительное

3 Ghz Cortex X3

1

Ядро средней производительности

2.45 Ghz Cortex A715

4

Высокоэффективное

2.15 Cortex A510

4

Всего ядер

9

Выходит, что все те гипотезы, которые разработчики строят относительно архитектур CPU, возможно, неверны.

Полагаю, это и неудивительно, так как я, всё то время, которое занимался программированием, не обращал особого внимания на количество процессорных ядер телефонов. А большинство CPU, установленных в моих настольных компьютерах или ноутбуках (там, где количество ядер имеет значение), использовало технологию одновременной многопоточности (Simultaneous Multithreading, SMT) для «умножения» количества ядер на два.

Исправление, сделанное на стороне клиента, отчасти похоже на «кнопку аварийной остановки», которая применяется для того, чтобы как можно раньше предотвратить возможные проблемы. В целом — я очень хорошо знаю о том, что описан��ому сбою сопутствует ужасный UX, и я всё ещё ищу способы улучшения этого UX и облегчения поиска проблем для пользователей или администраторов.

А именно, я рассматриваю следующее:

  1. Этой ошибки можно было бы избежать, если бы, на стороне сервера, проводились бы менее строгие проверки входных данных — в соответствии с законом Постела. Меня нервирует мысль об ослаблении строгости правил, касающихся принимаемых данных. Ведь речь идёт о конечной точке, обеспечение безопасной работы которой значит очень многое. Но, может, это вполне нормально? Мне, чтобы в этом разобраться, надо пообщаться с экспертом по безопасности.

  2. Можно показывать зашифрованное сообщение об ошибке на «invalid response»-странице, чтобы пользователь и администратор могли бы совместно поработать над исправлением ошибки или над тем, чтобы отправить отчёт о ней. Знаю, в Google так делали, по меньшей мере однажды, но не могу вспомнить — где я это видел. В любом случае — это, возможно, самый надёжный метод, несмотря даже на то, что его реализация потребует создания некоторых дополнительных инструментов. Полагаю, что это — стоящее вложение сил и времени.

Я, вероятно, остановлюсь на втором варианте. Мне нужно придумать хороший механизм реализации всего этого. Вероятно, тут будет использоваться инструмент для шифрования файлов age. Я расскажу об этом подробнее, когда будет что сказать.

Но, тем временем, похоже, мне не помешает потратиться на подержанный Pixel 8 Pro и «поселить» его в «зоопарке» устройств, на которых я тестирую Anubis. Если у кого есть хорошее предложение — дайте знать!

Спасибо всем тем, кто позитивно и спокойно помог мне разобраться в причинах этой ошибки и её исправить.

О, а приходите к нам работать? ? ?

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде

Комментарии (2)


  1. vadimr
    08.09.2025 09:59

    Интересно, откуда вообще могла возникнуть мысль, что количество ядер обязательно чётное.


  1. LinkToOS
    08.09.2025 09:59

    Правда, такая стратегия не лишена недостатков, проявляющихся в некоторых ситуациях. Например — похоже, что Firefox сильно замедляется если Anubis пытается максимально загрузить все ядра, имеющиеся в устройстве

    Firefox может замедляться и падать когда ему вздумается. Так что это не показатель. Но по хорошему, приложению позволительно перетягивать всю производительность на себя только в однозадачном режиме. Разработчик должен об этом подумать.

    А сервер ожидает получения только целых чисел, поэтому он решение отвергнет и выдаст ошибку «invalid response».

    А как в программе обрабатывается эта ошибка? Или "вся программа написана, исходя из предположения о том," что никаких ошибок не будет?
    Разработчик точно знал что сервер строго проверяет формат данных? Или он думал что сервер сам округлит все то, что не очень круглое.

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

    Неправильное наблюдение.
    Правильное наблюдение - грабли есть всегда. При проектировании программы нужно учитывать, что грабли раскиданы в неожиданных местах.
    Поэтому все стандартное нужно делать по стандартным правилам. И данные проверять на допустимость, и ошибки обрабатывать. А когда требуется что-то предельно оптимизированное по размеру или скорости, то тут надо много думать и все учитывать. Экстрасенсорные способности приветствуются.