Відмінності асинхронної і багатопотокової архітектури на прикладі Node.js і PHP

Останнім часом спостерігається зростання платформ, побудованих на асинхронної архітектурі. На асинхронної моделі побудований найшвидший в світі веб-сервер nginx. Активно розвивається швидкий серверний javascript (Node.js). Чим же хороша ця архітектура? Чим вона відрізняється від класичної багатопотокової системи? На цю тему було написано безліч статей, але повного розуміння предмета вони дали далеко не всім. Часто доводиться спостерігати суперечки навколо Node.js vs PHP + apache. Багато хто не розуміє, чому деякі речі можна зробити на Node.js, але не можна на PHP або навпаки - чому цілком правильний робочий код на PHP сильно сповільниться в Node.js, а то і повісить її. У даній статті я б хотів ще раз докладно пояснити різницю в їх архітектурі. Як приклади двох систем, візьмемо вебсервер з PHP і Node.js.

Багатопотокова модель

Ця модель відома кожному. Наше додаток створює деяку кількість потоків (пул), передаючи кожному з них завдання і дані для обробки. Завдання виконуються паралельно. Якщо потоки не мають загальних даних, то у нас не буде накладних витрат на синхронізацію, що робить роботу досить швидкої. Після завершення роботи потік не вбивається, а лежить в пулі, чекаючи наступного завдання. Це прибирає накладні витрати на створення і видалення потоків. Саме за такою системою і працює вебсервер з PHP. Кожен скрипт працює в своєму потоці. Один потік обробляє один запит. Потоків у нас досить велика кількість, повільні запити забирають потік надовго, швидкі - обробляються майже миттєво, звільняючи потік для іншої роботи. Це не дозволяє повільним запитами забирати весь процесорний час, змушуючи подвисать швидкі запити. Але у такої системи є певні обмеження. Може виникнути ситуація, коли до нас прийде велика кількість повільних запитів, наприклад працюють з БД або файлової системою. Такі запити заберуть собі всі потоки, що унеможливить виконання інших запитів. Навіть якщо запитом потрібно всього 1мс на виконання - він не буде вчасно оброблений. Це можна вирішити збільшенням числа потоків, щоб вони могли обробити досить велика кількість повільних запитів. Але на жаль потоки обробляються ОС, їй же виділяється і процесорний час. Тому чим більше потоків ми створюємо, тим більше накладних витрат на їх обробку і тим менше процесорного часу виділяється кожному потоку. Ситуація ускладнюється самим PHP - блокують операції роботи з БД, файлової системою, введенням-виведенням так само витрачають процесорний час, не виконуючи в цей момент ніякої корисної роботи. Тут ми детальніше зупинимося на особливостях блокують операцій. Уявімо собі таку ситуацію: у нас є кілька потоків. Кожен обробляє запити, що складаються з 1мс обробки самого запиту, 2мс на доступ і отримання даних з БД і 1мс рендеринга отриманих даних. Всього на кожен запит ми витрачаємо, таким чином, 4мс. При відправці запитів до БД потік починає чекати відповіді. Поки дані не повернуться - потік ніякої роботи виконувати не буде. Це 2мс простою на запит в 4мс! Так, ми не можемо зробити рендеринг сторінки, не отримавши дані з бази. Ми зобов'язані чекати. Але ж при цьому ми отримуємо 50% простою процесора! А сюди зверху можна накинути додаткові витрати ОС на виділення процесорного часу кожному потоку. І чим потоків більше - тим більше цих витрат. У підсумку ми отримуємо досить великий час простою. Це час безпосередньо залежить від тривалості запитів до БД і файлової системи. Краще рішення, яке дозволяє нам повністю завантажити процесор корисною роботою це перехід до архітектури, що використовує неблокірующіх операції.

Асинхронна модель

Менш поширена модель, ніж многопоточная, однак вона має не менших можливості. Асинхронна модель побудована на черзі подій (event-loop). При виникненні деякої події (прийшов запит, виповнилося зчитування файлу, прийшла відповідь від БД) воно поміщається в кінець черги. Потік, який обробляє цю чергу, бере подія з початку черги, і виконує пов'язаний з цією подією код. Поки черга не порожня процесор буде зайнятий роботою. За такою схемою працює Node.js. У нас є єдиний потік, що обробляє чергу подій (з модулем cluster - потік буде вже не один). Майже всі операції неблокующі. Операції які блокують теж є, але їх використання вкрай не рекомендується. Далі ви зрозумієте чому. Візьмемо той же приклад із запитом 1 + 2 + 1мс: з черги повідомлень береться подія, пов'язана з приходом запиту. Ми обробляємо запит, витрачаємо 1мс. далі робиться асинхронний неблокующий запит до бази даних і управління відразу ж передається далі. Ми можемо взяти з черги таку обставину і виконати його. Наприклад ми візьмемо ще 1 запит, проведемо обробку, надішлемо запит до БД, повернемо управління і виконаємо те ж саме ще один раз. І тут приходить відповідь БД на найперший запит. Подія, пов'язане з ним поміщається в чергу. Якщо в черзі нічого не було - він відразу ж виконається, дані відрендераять і віддадуться назад клієнту. Якщо в черзі щось є - доведеться почекати обробку інших подій. Зазвичай швидкість обробки одного запиту буде порівнянна зі швидкістю обробки багатопотокової системою і операціями які блокують. У гіршому випадку - на очікування обробки інших подій витратиться час, і запит буде опрацьовано повільніше. Але зате в той момент, поки система з блокуючими операціями просто чекала б 2мс відповіді, система з неблокующими операціями встигла виконати ще 2 частини 2х інших запитів! Кожен запит може виконуватися трішки повільніше в цілому, але в одиницю часу ми можемо обробити набагато більше запитів. Загальна продуктивність буде вище. Процесор завжди буде зайнятий корисною роботою. При цьому на обробку черги і перехід від події до події витрачається набагато менше часу, ніж на перемикання між потоками в багатопотокової системі. Тому асинхронні системи з неблокуючими операціями повинні мати не більше потоків, ніж кількість ядер в системі. Node.js спочатку взагалі працював тільки в однопоточном режимі, і для повного використання процесора доводилося вручну піднімати кілька копій сервера і розподіляти навантаження між ними, наприклад, за допомогою nginx. Зараз для роботи з декількома ядрами з'явився модуль cluster. Ось тут і проясняється ключова відмінність двох систем. Багатопотокова система з блокуючими операціями має великий час простою. Надмірна кількість потоків може створити багато накладних витрат, недостатнє ж кількість може привести до уповільнення роботи при великій кількості повільних запитів. Асинхронне додаток з неблокующими операціями використовує процесорний час ефективніше, але більш складно при проектуванні. Особливо сильно це позначається на витоках пам'яті - процес Node.js може працювати дуже велику кількість часу, і якщо програміст не подбає про очищення даних після обробки кожного запиту, ми отримаємо витік, що поступово призведе до необхідності перезавантаження сервера. Також існує асинхронна архітектура з блокуючими операціями, але вона набагато менш вигідна, що можна буде побачити далі на деяких прикладах. Виділимо особливості, які необхідно враховувати при розробці асинхронних додатків і розберемо деякі помилки, що виникають у людей при спробі розібратися з особливостями асинхронної архітектури.

Не використовуйте блокующі операції ніколи

Ну принаймні поки не зрозумієте повністю архітектуру Node.js і не зможете обережно працювати з блокуючими операціями. При переході з PHP на Node.js у деяких людей може виникнути бажання писати код в такому ж стилі, як і раніше. Дійсно, якщо нам треба спершу вважати файл, і тільки потім приступити до його обробці, то чому ми не можемо написати наступний код:

const fs = require('fs');
const data = fs.readFileSync("img.png");
response.write(data); 

Цей код правильний і цілком робочий, але він використовує операцію з блокуванянм. Це означає що до тих пір, поки файл не буде прочитаний, чергу повідомлень розглядатись не буде і Node.js буде просто висіти, не здійснюючи ніякої роботи. Це повністю вбиває основну ідею. У той час, поки файл читається, ми могли б виконувати іншу роботу. Для цього ми використовуємо таку конструкцію:


const fs = require('fs');
fs.readFile("img.png", function(err, data){
    response.write(data);
});

Розберемо її докладніше: у нас відбувається асинхронне читання з файлу, при виконанні функції читання управління відразу ж передається далі, Node.js обробляє інші запити. Як тільки файл буде прочитаний - викликається анонімна функція, передана в readFile другим параметром. А точніше подія, пов'язана з нею, лягає в чергу і коли черга доходить до неї - виконується. Таким чином, ми не порушуємо послідовність дій: спершу зчитується файл, потім обробляється. Але при цьому ми не займаємо процесорний час очікуванням, а дозволяємо обробляти інші події в черзі. Ця обставина дуже важливо пам'ятати, так як лише кілька неакуратно вставлених синхронних операцій можуть сильно просадити продуктивність. Використовуйте такий код, і ви безнадійно вб'єте event-loop:


const fs = require('fs');
let dataModified = false;
let myData;

fs.readFile("file.txt", function(err, data){
    dataModified = true;
    myData = data+" last read "+new Date();
});

while (true){
    if(dataModified)
        break;
}

response.write(myData);

Такий шматок коду буде займати все процесорний час собі, не даючи оброблений іншим подіям. Поки перевірка не завершиться успішно, цикл буде повторюватися, і ніякий інший код не виконається. Якщо вам необхідно дочекатися якої-небудь події то ... використовуйте події!


const fs = require('fs');
const events = require('events');
let myData;
const eventEmitter = new events.EventEmitter();

fs.readFile("file.txt", function(err, data){
    myData = data+" last read "+new Date();
    eventEmitter.emit('dataModified', myData);
});

eventEmitter.on('dataModified', function(data){
    response.write(data);
});

Знову-таки, цей код виконається тільки після виконання певної умови. Тільки ця перевірка не запускається в циклі - код, який виконує наша умова, за допомогою функції emit викликає подія, на яку ми вішаємо обробник. Об'єкт events.EventEmitter відповідає за створення і обробку наших подій. eventEmitter.on відповідає за виконання коду, при виникненні певної події. На цих прикладах можна побачити, як необережне використання блокуючого коду зупиняє обробку черги подій і відповідно стопорить роботу всього Node.js. Для запобігання таких ситуацій використовуйте асинхронний код, зав'язаний на події. Використовуйте асинхронні операції замість синхронних, використовуйте асинхронні перевірки настання деякої події.

Не використовуйте великих циклів для обробки даних. використовуйте події

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


function incredibleGigantCycle(){
    cycleProcess();
    process.nextTick(incredibleGigantCycle);
}

Даний код виконає тіло циклу і створить подія для наступної ітерації. Ніякої блокування черги подій в такому разі не буде.

Не створюйте великих операцій, що займають багато процесорного часу

Іноді виникає потреба в обробці величезного обсягу даних або виконання ресурсоемкого алгоритму. Така функція може займати багато процесорного часу (скажімо, 500мс) і поки вона не виконається, багато маленьких запитів будуть простоювати в черзі. Що робити якщо така функція все-таки є і відмовитися від неї ми ніяк не можемо? В такому випадку виходом може стати розбиття функції на кілька частин, які будуть викликатися по черзі як події. Ці події будуть лягати в кінець черги, тоді як події спочатку можуть пройти, не чекаючи, поки наш важкий алгоритм виконається повністю. У вашому коді не повинно бути великих послідовних шматків, що не розбитих на окремі події. Звичайно є ще вихід у вигляді створення свого модуля на С, але це вже з іншої опери

Уважно стежте за тим, який тип функції ви використовуєте

Читайте документацію, для того щоб зрозуміти чи використовуєте ви синхронну або асинхронну, що блокує або неблокірующіх функцію. У Node.js прийнято називати синхронні функції з постфіксом Sync. В асинхронних функціях обробник події по завершенню функції зазвичай передається останнім параметром і іменується callback. Якщо ж ви використовуєте асинхронну функцію там, де хотіли використовувати синхронну, у вас можуть виникнути помилки.

const fs = require('fs');
fs.readFile("img.png", function(err, data){

});
response.write(data);

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

Розберіться з перевагами асинхронних запитів

Іноді зустрічаються питання, чому доводиться писати «спагетті-код» на Node.js, постійно вкладаючи один в одного callback'і, коли на PHP все йде чітко послідовно? Адже алгоритм і там і там один і той же. Розберемо наступний код:

 $user->getCountry()->getCurrency()->getCode()

та

user.getCountry(function(country){
    country.getCurrency(function(currency){
        console.log(currency.getCode())
    })
})

І там і там обробка піде тільки після завершення всіх 3х запитів. Але тут є велика різниця: в PHP наші запити до бази будуть блокуючими. Спершу виконується перший запит, є деякий час простою процесора. Потім другий запит з простоєм, аналогічно третій. При асинхронної неблокірующіх архітектурі ми посилаємо перший запит, починаємо виконання будь-яких інших операцій, пов'язаних з іншими подіями. Коли запит від БД повертається - обробляємо його, формуємо другий, відсилаємо, продовжуємо обробку інших подій. В результаті і там і там отримаємо 3 послідовно виконаних запиту. Але у випадку з PHP у нас буде певний простий процесора, тоді як Node.js виконає ще кілька корисного коду, і може навіть встигне обробити декілька запитів, що не вимагають звернення до БД.

Висновок

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

2019-12-22 07:31:48