Цікавий випадок тестування продуктивності setTimeout (0)

(Для повного ефекту читайте хриплим голосом в оточенні хмари диму)

Все почалося в сірий осінній день. Небо було хмарним, вітер дув, і хтось сказав мені, що setTimeout(0)створює в середньому затримку 4 мс. Вони стверджували, що це час, необхідний для виклику зворотного виклику зі стеку, у чергу зворотного виклику та повернення назад у стек. Я подумав, що це звучить по-рибськи (це такий біт, як ви уявляєте мене чорно-білим із сигарою в роті). Враховуючи, що конвеєр візуалізації повинен запускатися кожні 16 мс, щоб забезпечити плавну анімацію, 4 мс здалися мені довгим часом. Дуже довго.

Кілька наївних тестів у розробниках із console.time()підтвердженням цього. Середня затримка протягом 20 прогонів становила близько 1,5 мс. Звичайно, 20 прогонів - це недостатній розмір вибірки, але зараз я мав на меті довести. Я хотів запустити тести в більшому масштабі, які могли б отримати точнішу відповідь. Тоді я міг, звичайно, піти і помахати цим в обличчя колезі, щоб довести, що вони помилялися.

Чому ще ми робимо те, що робимо?

Традиційний метод

Одразу я опинився у гарячій воді. Для того, щоб виміряти, скільки часу потрібно було setTimeout(0)запустити, мені потрібна була функція, яка:

  • зробив знімок поточного часу
  • страчений setTimeout
  • потім негайно вийшов, щоб стек був чистим, а запланований зворотний виклик міг запустити і обчислити різницю в часі
  • і мені потрібна була ця функція для запуску достатньо великої кількості разів, щоб розрахунки були статистично значущими

Але побудова для цього - цикл for - не спрацює. Оскільки цикл for не очищає стек, поки не виконає кожен цикл, зворотний виклик не запускається відразу. Або, щоб помістити це в код, ми отримаємо таке:

Проблема тут була невід’ємною - якби я хотів запускати setTimeoutкілька разів автоматично, мені довелося б робити це з іншого контексту. Але, поки я працював з іншого контексту, завжди існувала додаткова затримка з моменту запуску тесту до моменту, коли виконувався зворотний виклик.

Звичайно, я міг би зірвати його, як деякі з цих марних детективів, написати функцію, яка робить те, що мені потрібно, а потім скопіювати та вставити 10 000 разів. Я дізнався б те, що хотів би знати, але страта була би далеко не витонченою. Якби я збирався втирати це в чуже обличчя, я волів би зробити це по-іншому.

Потім це дійшло до мене.

Революційний метод

Я міг би використати веб-працівника.

Веб-працівники працюють з іншою ниткою. Отже, якщо я setTimeoutрозміщу логіку у веб-співробітнику, я міг би це викликати кілька разів. Кожен виклик створив би власний контекст виконання, виклик setTimeoutі негайний вихід із функції, щоб зворотний виклик міг виконатись. Я з нетерпінням чекав на роботу з веб-працівниками.

Настав час перейти до мого довіреного піднесеного тексту.

Я розпочав лише тестування води. За допомогою цього коду в main.js:

Тут є трохи сантехніки, щоб підготуватися до власне тесту, але спочатку я просто хотів переконатися, що можу правильно спілкуватися з веб-працівником. Отже, це було початкове worker.js:

І хоча це працювало як шарм - це дало результати, яких я мав очікувати, але не було:

Будучи настільки звичним до синхронності в JS, я не міг не здивуватися цьому. У перший момент, коли я це побачив, мій мозок зареєстрував помилку. Але, оскільки кожен цикл встановлює нового веб-працівника, і вони працюють асинхронно, має сенс, що цифри не будуть роздруковуватися по порядку.

Можливо, це мене здивувало, але це працювало, як очікувалося. Я міг би продовжити тест.

Я хотів, щоб onmessageфункція веб-працівника реєструвалась t0, дзвонила setTimeout, а потім негайно виходила, щоб не блокувати стек. Однак я міг би додати додаткову функціональність всередину зворотного виклику після того, як встановив значення t1. Я додав свій postMessageу зворотний виклик, тому він не блокує стек:

І ось main.jsкод:

Ця версія має проблему.

Звичайно - оскільки я новачок у веб-співробітників, спочатку про це не думав. Але коли багаторазові запуски функції продовжували друкуватися 0, я зрозумів, що щось не так.

Коли я надрукував суми зсередини, onmessageя отримав свою відповідь. Основна функція рухалася синхронно і не чекала повернення повідомлення від працівника, тому вона обчислювала середнє значення до того, як веб-працівник був закінчений.

Швидке і брудне рішення полягає у додаванні лічильника та обчисленні лише тоді, коли лічильник досяг максимального значення. Тож ось новеmain.js:

І ось результати:

main(10): 0.1

main(100) : 1.41

main(1000) : 13.082

О Мій. Ну, це не чудово, правда? Що тут відбувається?

Я пожертвував тестуванням продуктивності, щоб зазирнути всередину. Зараз я веду журнал, t0і t1 коли вони створюються, просто щоб подивитися, що там відбувається.

І результати:

Виявляється, моє сподівання на t1підрахунок відразу після t0також було помилковим. Фактично той факт, що нічого про веб-працівників не є синхронним, означає, що мої найосновніші припущення про те, як поводиться мій код, просто не відповідають дійсності. Це важко побачити сліпе місце.

Мало того, але навіть ті результати, які я отримав main(10)і main(100)які спочатку робили мене дуже щасливим і самовдоволеним, не були тим, на що я міг покладатися.

Асинхронність веб-працівників також робить їх ненадійним проксі для того, як поводяться речі в нашому звичайному стеку. Отже, хоча вимірювання ефективності роботи setTimeoutвеб-працівника дає цікаві результати, це не результати, які відповідають на наше запитання.

Метод підручника

Я був розчарований ... чи справді я не міг знайти ванільного рішення JS, яке одночасно було б вишуканим і довело б, що колега помиляється?

І тоді я зрозумів - я щось міг зробити, але мені це не сподобалося б.

Я міг би дзвонити setTimeoutрекурсивно.

Тепер, коли я зателефоную, mainвін зателефонує, testRunnerякі заходи, t0а потім планує зворотний дзвінок. Потім зворотний дзвінок запускається негайно, обчислює, t1а потім дзвонить testRunnerзнову, поки не буде досягнуто бажаної кількості дзвінків.

Результати цього кодексу були особливо дивовижними. Ось деякі роздруківки main(10)та main(1000):

Результати суттєво відрізняються при виклику функції 1000 разів порівняно з викликом 10 разів. Я пробував це неодноразово і отримував майже однакові результати, main(10)починаючи з 3–4 мс і main(1000)досягаючи 5 мс.

Чесно кажучи, я не впевнений, що тут відбувається. Я шукав відповіді, але не міг знайти розумного пояснення. Якщо ви читаєте це і маєте освічену здогадку про те, що відбувається - я хотів би почути вас у коментарях.

Перевірений метод

Десь у глибині розуму я завжди знав, що це дійде до цього ... Кричущі речі приємні тим, хто може їх отримати, але спробувані і справді завжди будуть там, у кінці. Хоча я намагався цього уникнути, я завжди знав, що це варіант. setInterval.

Цей код робить фокус з дещо грубою силою. setIntervalзапускає функцію неодноразово, чекаючи 50 мс між кожним запуском, щоб переконатися, що стек чистий. Це неелегантно, але тестує саме те, що мені потрібно.

І результати також були багатообіцяючими. Часи, схоже, відповідають моїм початковим очікуванням - менше 1,5 мс.

Нарешті я міг покласти цю справу в ліжко. У мене були злети і падіння, і моя частка несподіваних результатів, але врешті-решт мало значення лише одне - я довів, що інший розробник помилявся! Це було досить добре для мене.

Хочете пограти з цим кодом? перевірте тут: //github.com/NettaB/setTimeout-test