Разбирането на модела за вход / изход (I / O) на вашето приложение може да означава разликата между приложение, което се справя с натоварването, на което е подложено, и такова, което се мачка пред реалните случаи на употреба. Може би, докато приложението ви е малко и не обслужва големи натоварвания, може да има значение далеч по-малко. Но тъй като натоварването на трафика на приложението ви се увеличава, работата с грешен I / O модел може да ви отведе в свят на нараняване.
И както повечето ситуации, при които са възможни множество подходи, не е въпрос само кой е по-добър, а въпрос на разбиране на компромисите. Нека да се разходим през I / O пейзажа и да видим какво можем да шпионираме.
В тази статия ще сравним Node, Java, Go и PHP с Apache, ще обсъдим как различните езици моделират своите I / O, предимствата и недостатъците на всеки модел и ще завършим с някои основни критерии. Ако сте загрижени за I / O производителността на следващото си уеб приложение, тази статия е за вас.
За да разберем факторите, свързани с I / O, първо трябва да прегледаме концепциите на ниво операционна система. Въпреки че е малко вероятно да се наложи да се справят директно с много от тези понятия, вие се справяте с тях индиректно през средата на изпълнение на приложението си през цялото време. И подробностите имат значение.
Първо, имаме системни обаждания, които могат да бъдат описани по следния начин:
Току-що казах по-горе, че syscalls блокират и това е вярно в общ смисъл. Някои повиквания обаче са категоризирани като „неблокиращи“, което означава, че ядрото взема заявката ви, поставя я на опашка или буфер някъде и след това незабавно се връща, без да чака действителното въвеждане / изход. Така че „блокира“ само за много кратък период от време, достатъчно дълъг, за да постави заявката ви в опашка.
Някои примери (за системни повиквания на Linux) могат да помогнат за изясняване: - read()
е блокиращо повикване - предавате му манипулатор, който казва кой файл и буфер къде да достави данните, които чете, и обаждането се връща, когато данните са там. Имайте предвид, че това има предимството да бъде приятно и просто. - epoll_create()
, epoll_ctl()
и epoll_wait()
са повиквания, които, съответно, ви позволяват да създадете група манипулатори, които да слушате, да добавяте / премахвате манипулатори от тази група и след това да блокирате, докато има някаква активност. Това ви позволява ефективно да контролирате голям брой I / O операции с една нишка, но аз изпреварвам себе си. Това е чудесно, ако имате нужда от функционалността, но както виждате, със сигурност е по-сложна за използване.
Тук е важно да разберете реда на разликата във времето. Ако ядрото на процесора работи на 3GHz, без да навлиза в оптимизации, които CPU може да направи, то извършва 3 милиарда цикъла в секунда (или 3 цикъла в наносекунда). Неблокиращо системно обаждане може да отнеме 10-та цикъла, за да завърши - или „относително няколко наносекунди“. Обаждане, което блокира получаването на информация по мрежата, може да отнеме много повече време - да кажем например 200 милисекунди (1/5 от секундата). Да кажем, например, че блокиращото повикване отне 20 наносекунди, а блокиращото повикване отне 200 000 000 наносекунди. Вашият процес просто изчака 10 милиона пъти повече за блокиращото обаждане.
Ядрото предоставя средства за блокиране на I / O („прочетете от тази мрежова връзка и ми дайте данни“) и неблокиращи I / O („кажете ми, когато някоя от тези мрежови връзки има нови данни“). И кой механизъм се използва, ще блокира процеса на извикване за драматично различни периоди от време.
Третото нещо, което е критично важно да се следва, е какво се случва, когато имате много нишки или процеси, които започват да блокират.
За нашите цели няма голяма разлика между нишка и процес. В реалния живот най-забележимата разлика, свързана с производителността, е, че тъй като нишките споделят една и съща памет и всеки процес има свое собствено пространство в паметта, което прави отделните процеси склонни да заемат много повече памет. Но когато говорим за планиране, това, което всъщност се свежда, е списък с неща (нишки и процеси), за които всеки трябва да получи част от времето за изпълнение на наличните ядра на процесора. Ако имате 300 изпълнени нишки и 8 ядра, за да ги стартирате, трябва да разделите времето, така че всяка да получи своя дял, като всяко ядро се изпълнява за кратък период от време и след това преминава към следващата нишка. Това се прави чрез „контекстен превключвател“, като превключва процесора от превключване на една нишка / процес към следващата.
Тези превключватели на контекст имат свързана с тях цена - отнема известно време. В някои бързи случаи може да е по-малко от 100 наносекунди, но не е необичайно да отнеме 1000 наносекунди или повече в зависимост от подробностите за изпълнението, скоростта / архитектурата на процесора, кеша на процесора и т.н.
И колкото повече нишки (или процеси), толкова повече превключване на контекста. Когато говорим за хиляди нишки и стотици наносекунди за всяка, нещата могат да станат много бавни.
Обаче неблокиращите обаждания по същество казват на ядрото „обадете ми се само когато имате някакви нови данни или събитие на някоя от тези връзки.“ Тези неблокиращи повиквания са проектирани да се справят ефективно с големи I / O натоварвания и да намалят превключването на контекста.
С мен досега? Защото сега идва забавната част: Нека да разгледаме какво правят някои популярни езици с тези инструменти и да направим някои заключения относно компромисите между лекотата на използване и производителността ... и други интересни парчета.
Като бележка, докато примерите, показани в тази статия, са тривиални (и частични, като са показани само съответните битове); достъп до база данни, външни кеширащи системи (memcache и др. всички) и всичко, което изисква I / O, в крайна сметка ще извърши някакъв вид I / O повикване под капака, което ще има същия ефект като показаните прости примери. Също така, за сценариите, при които I / O е описан като „блокиращ“ (PHP, Java), HTTP заявката и отговорите четат и записват самите те блокират повикванията: Отново, повече I / O скрити в системата с придружаващите я проблеми с производителността да се вземат предвид.
Има много фактори, които влизат в избора на език за програмиране за даден проект. Има дори много фактори, когато имате предвид само представянето. Но ако се притеснявате, че програмата ви ще бъде ограничена предимно от I / O, ако I / O производителността е марка или пробив за вашия проект, това са неща, които трябва да знаете.
Още през 90-те много хора бяха облечени Конверс обувки и писане на CGI скриптове на Perl. След това се появи PHP и колкото и някои хора да обичат да го дразнят, това улесни динамичните уеб страници.
как да вляза в python
Моделът, който PHP използва е доста прост. Има някои вариации, но средният ви PHP сървър изглежда така:
HTTP заявка идва от браузъра на потребителя и удря вашия уеб сървър на Apache. Apache създава отделен процес за всяка заявка, с някои оптимизации, за да ги използва повторно, за да сведе до минимум колко трябва да направи (създаването на процеси е, относително казано, бавно). Apache извиква PHP и му казва да стартира подходящия .php
файл на диска. PHP кодът изпълнява и блокира I / O повикванията. Обаждате се file_get_contents()
в PHP и под капака прави read()
syscalls и чака резултатите.
И разбира се действителният код е просто вграден директно във вашата страница и операциите блокират:
query('SELECT id, data FROM examples ORDER BY id DESC limit 100'); ?>
По отношение на това как това се интегрира със системата, това е следното:
Доста просто: един процес на заявка. Входно-изходните повиквания просто блокират Предимство? Това е просто и работи. Недостатък? Ударете го с 20 000 клиенти едновременно и вашият сървър ще избухне в пламъци. Този подход не се мащабира добре, тъй като инструментите, предоставени от ядрото за справяне с I / O с голям обем (epoll и др.), Не се използват. И за да добавите обида към нараняване, стартирането на отделен процес за всяка заявка обикновено използва много системни ресурси, особено паметта, което често е първото нещо, което изчерпвате при сценарий като този.
Забележка: Подходът, използван за Ruby, е много подобен на този на PHP и в широк, общ, вълнообразен начин те могат да се считат за еднакви за нашите цели.
Така че Java се появява, точно по времето, когато сте закупили първото си име на домейн, и беше страхотно просто на случаен принцип да изречете „dot com“ след изречение. А Java има вграден в езика многопоточност, което (особено когато е създадено) е доста страхотно.
Повечето уеб сървъри на Java работят чрез стартиране на нова нишка на изпълнение за всяка заявка, която постъпва и след това в тази нишка в крайна сметка извиква функцията, която вие като разработчик на приложение сте написали.
Извършването на I / O в Java Servlet има тенденция да изглежда по следния начин:
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // blocking file I/O InputStream fileIs = new FileInputStream('/path/to/file'); // blocking network I/O URLConnection urlConnection = (new URL('http://example.com/example-microservice')).openConnection(); InputStream netIs = urlConnection.getInputStream(); // some more blocking network I/O out.println('...'); }
Тъй като нашите doGet
Методът по-горе съответства на една заявка и се изпълнява в собствена нишка, вместо отделен процес за всяка заявка, който изисква собствена памет, имаме отделна нишка. Това има някои приятни предимства, като възможността да споделят състояние, кеширани данни и т.н. между нишките, защото те могат да имат достъп до паметта на другия, но въздействието върху това как взаимодейства с графика все още е почти идентично с това, което се прави в PHP пример по-рано. Всяка заявка получава нова нишка и различните блокове I / O операции вътре в нея, докато заявката бъде напълно обработена. Нишките се обединяват, за да минимизират разходите за създаването и унищожаването им, но въпреки това хиляди връзки означават хиляди нишки, което е лошо за планиращия.
Важен етап е, че във версия 1.4 Java (и значително надграждане отново в 1.7) получи възможността да извършва неблокиращи I / O повиквания. Повечето приложения, в мрежата и по друг начин, не го използват, но поне е налице. Някои уеб сървъри на Java се опитват да се възползват от това по различни начини; обаче по-голямата част от внедрените Java приложения все още работят, както е описано по-горе.
Java ни сближава и със сигурност има добра добра функционалност за I / O, но все още не решава проблема какво се случва, когато имате силно обвързано I / O приложение, което се забива в земята с много хиляди блокиращи нишки.
Популярното дете в блока, когато става въпрос за по-добри I / O е Node.js. На всеки, който е имал дори най-краткото въведение в Node, е казано, че той „не блокира“ и че се справя ефективно с I / O. И това е вярно в общ смисъл. Но дяволът е в детайлите и средствата, с които е постигнато това магьосничество, имат значение, когато става въпрос за изпълнение.
По същество промяната на парадигмата, която Node изпълнява, е, че вместо по същество да казва „напишете кода си тук, за да обработвате заявката“, те вместо това казват „напишете код тук, за да започнете да обработвате заявката“. Всеки път, когато трябва да направите нещо, което включва I / O, вие правите заявката и давате функция за обратно извикване, която Node ще извика, когато приключи.
Типичният код на възел за извършване на I / O операция в заявка върви по следния начин:
http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });
Както можете да видите, тук има две функции за обратно извикване. Първият се извиква при стартиране на заявка, а вторият се извиква, когато данните за файла са налични.
Това, което прави по същество, дава възможност на Node да обработва ефективно I / O между тези обратни обаждания. Сценарий, при който би било още по-подходящо, е когато правите извикване на база данни в Node, но няма да се занимавам с примера, защото това е абсолютно същият принцип: стартирате повикването на базата данни и давате на Node функция за обратно извикване, изпълнява I / O операциите поотделно, като използва неблокиращи повиквания и след това извиква вашата функция за обратно извикване, когато данните, които сте поискали, са налични. Този механизъм за поставяне на опашки за I / O повиквания и оставяне на Node да се справи и след това получаване на обратно извикване се нарича „Loop Event“. И работи доста добре.
Този модел обаче има уловка. Под капака причината за това има много повече общо с това как е внедрен V8 JavaScript двигателят (JS двигателят на Chrome, който се използва от Node) един от всичко друго. JS кодът, който пишете, се изпълнява в една нишка. Помислете за това за момент. Това означава, че докато I / O се извършва с помощта на ефективни неблокиращи техники, вашият JS може, който прави операции, свързани с процесора, се изпълнява в една нишка, като всяка част от кода блокира следващата. Често срещан пример за това къде може да се появи е прелистване на записите в базата данни, за да ги обработи по някакъв начин, преди да ги изведе на клиента. Ето пример, който показва как работи това:
var handler = function(request, response) { connection.query('SELECT ...', function (err, rows) { if (err) { throw err }; for (var i = 0; i Докато Node се справя ефективно с I / O, това for
цикъл в примера по-горе използва цикли на процесора във вашата единствена и единствена основна нишка. Това означава, че ако имате 10 000 връзки, този цикъл може да доведе до обхождане на цялото ви приложение в зависимост от това колко време отнема. Всяка заявка трябва да споделя част от времето, един по един, във вашата основна нишка.
Предпоставката, на която се основава цялата концепция, е, че операциите за входно / изходни операции са най-бавната част, поради което е най-важно да се справим ефективно с тях, дори ако това означава извършване на друга обработка последователно. Това е вярно в някои случаи, но не във всички.
twitter набор от данни за копаене на данни
Другата точка е, че макар това да е само мнение, може да е доста уморително да пишете куп вложени обратни извиквания и някои твърдят, че прави кода значително по-труден за следване. Не е необичайно да видите обратни обаждания, вложени четири, пет или дори повече нива дълбоко в кода на Node.
Върнахме се отново към компромисите. Моделът Node работи добре, ако основният ви проблем с производителността е I / O. Неговата ахилесова пета обаче е, че можете да влезете във функция, която обработва HTTP заявка и да вкарате интензивен CPU код и да свържете всяка връзка с обхождане, ако не сте внимателни.
Естествено не блокира: Върви
Преди да вляза в раздела за Go, е подходящо да разкрия, че съм фен на Go. Използвал съм го за много проекти и открито съм привърженик на неговите предимства за производителност и ги виждам в работата си, когато го използвам.
Въпреки това, нека да разгледаме как се справя с I / O. Една ключова характеристика на езика Go е, че той съдържа свой собствен график. Вместо всяка нишка на изпълнение, съответстваща на отделна нишка на ОС, тя работи с концепцията за „goroutines“. И средата за изпълнение Go може да присвои goroutine на нишка на OS и да я накара да изпълни или да я спре и да не бъде свързана с нишка на OS въз основа на това, което прави тази goroutine. Всяка заявка, която идва от HTTP сървъра на Go, се обработва в отделна Goroutine.
Диаграмата на това как работи планировщикът изглежда така:

Под капака това се реализира от различни точки в изпълнението на Go, които изпълняват I / O повикването, като отправят заявката за писане / четене / свързване / и т.н., поставяне на текущата програма в режим на заспиване, с информация за събуждане на програмата обратно нагоре, когато могат да бъдат предприети по-нататъшни действия.
Всъщност изпълнението на Go прави нещо, което не е много различно от това, което Node прави, с изключение на това, че механизмът за обратно извикване е вграден в изпълнението на I / O повикването и взаимодейства автоматично с планиращия механизъм. Той също така не страда от ограничението да се налага целият ви код на манипулатора да се изпълнява в една и съща нишка, Go автоматично ще преобразува вашите Goroutines към толкова нишки на операционната система, които смята за подходящи въз основа на логиката в неговия планировчик. Резултатът е код като този:
func ServeHTTP(w http.ResponseWriter, r *http.Request) { // the underlying network call here is non-blocking rows, err := db.Query('SELECT ...') for _, row := range rows { // do something with the rows, // each request in its own goroutine } w.Write(...) // write the response, also non-blocking }
Както можете да видите по-горе, основната кодова структура на това, което правим, прилича на тази на по-опростените подходи и въпреки това постига неблокиращи I / O под капака.
В повечето случаи това се оказва „най-доброто от двата свята“. Неблокиращият вход / изход се използва за всички важни неща, но вашият код изглежда блокира и по този начин е по-лесен за разбиране и поддържане. С останалото се справя взаимодействието между планиращия механизъм Go и планировчика на операционната система. Това не е пълна магия и ако изградите голяма система, струва си да отделите време, за да разберете повече подробности за това как работи; но в същото време средата, която получавате „извън кутията“, работи и се мащабира доста добре.
Go може да има своите грешки, но най-общо казано, начинът, по който се справя с I / O, не е сред тях.
Лъжи, проклети лъжи и еталони
Трудно е да се дадат точни времена за превключване на контекста, свързани с тези различни модели. Бих могъл също да твърдя, че това е по-малко полезно за вас. Затова вместо това ще ви дам някои основни показатели, които сравняват общата производителност на HTTP сървъра на тези сървърни среди. Имайте предвид, че много фактори участват в изпълнението на целия HTTP заявка / отговор на HTTP от край до край, а представените тук номера са само някои мостри, които събрах, за да дам основно сравнение.
За всяка от тези среди написах подходящия код за четене във 64k файл с произволни байтове, пуснах SHA-256 хеш върху него N броя пъти (N е посочен в низа на заявката на URL, например .../test.php?n=100
) и отпечатайте получения хеш в шестнадесетичен. Избрах това, защото това е много прост начин за стартиране на едни и същи бенчмаркове с някои последователни I / O и контролиран начин за увеличаване на използването на процесора.
Вижте тези бележки за сравнение за малко повече подробности относно използваната среда.
Първо, нека разгледаме някои примери с ниска съвпадение. Изпълнението на 2000 итерации с 300 едновременни заявки и само един хеш на заявка (N = 1) ни дава това:

Времената са средният брой милисекунди за изпълнение на заявка за всички едновременни заявки. Долната е по-добра.Трудно е да се направи заключение само от тази графика, но това ми се струва, че при този обем на свързване и изчисление виждаме времена, които имат повече общо с общото изпълнение на самите езици, още повече, че I / O. Имайте предвид, че езиците, които се считат за „скриптови езици“ (свободно писане, динамично тълкуване) се представят най-бавно.
Но какво се случва, ако увеличим N до 1000, все още с 300 едновременни заявки - същото натоварване, но 100 пъти повече хеш итерации (значително повече натоварване на процесора):

Времената са средният брой милисекунди за изпълнение на заявка за всички едновременни заявки. Долната е по-добра.Изведнъж производителността на Node спада значително, тъй като интензивните процесори във всяка заявка се блокират взаимно. И достатъчно интересно, производителността на PHP става много по-добра (спрямо останалите) и побеждава Java в този тест. (Струва си да се отбележи, че в PHP изпълнението на SHA-256 е написано на C и пътят за изпълнение прекарва много повече време в този цикъл, тъй като сега правим 1000 хеш итерации).
Сега нека опитаме 5000 едновременни връзки (с N = 1) - или колкото се може по-близо до това. За съжаление, за повечето от тези среди процентът на отказите не беше незначителен. За тази диаграма ще разгледаме общия брой заявки в секунда. Колкото по-високо, толкова по-добре :

Общ брой заявки в секунда. По-високо е по-добре.И картината изглежда съвсем различно. Това е предположение, но изглежда, че при висок обем на връзката режийните разходи за връзка, свързани с появата на нови процеси и допълнителната памет, свързана с нея в PHP + Apache, изглежда се превръщат в доминиращ фактор и увеличава производителността на PHP. Ясно е, че Go е победителят тук, следван от Java, Node и накрая PHP.
Въпреки че факторите, свързани с цялостната ви производителност, са много и също варират в широки граници от приложение до приложение, колкото повече разбирате за вътрешността на това, което се случва под капака и свързаните компромиси, толкова по-добре ще бъдете.
В обобщение
С всичко изброено по-горе е съвсем ясно, че тъй като езиците са се развили, решенията за работа с мащабни приложения, които правят много I / O, са се развили заедно с него.
За да бъдем честни, както PHP, така и Java, въпреки описанията в тази статия, имат изпълнения на неблокиращ I / O на разположение за използване в уеб приложения . Но те не са толкова често срещани, колкото описаните по-горе подходи и трябва да се вземат предвид съпътстващите оперативни разходи за поддържане на сървъри, използващи такива подходи. Да не говорим, че вашият код трябва да бъде структуриран по начин, който работи с такива среди; вашето „нормално“ PHP или Java уеб приложение обикновено няма да работи без значителни модификации в такава среда.
За сравнение, ако разгледаме няколко важни фактора, които влияят върху производителността, както и лекотата на използване, получаваме следното:
Език Конци срещу процеси Неблокиращ I / O Лесно използване PHP Процеси Не Java Конци На разположение Изисква обратно извикване Node.js Конци Да Изисква обратно извикване Отивам Конци (Goroutines) Да Не са необходими обратни обаждания
причини за финансовата криза в Гърция
Нишките обикновено ще бъдат много по-ефективни от паметта, отколкото процесите, тъй като те споделят едно и също пространство в паметта, докато процесите не. Комбинирайки това с факторите, свързани с неблокиращия вход / изход, можем да видим, че поне с факторите, разгледани по-горе, докато се придвижваме надолу по списъка, общата настройка, тъй като тя се отнася до входно-изходните операции, се подобрява. Така че, ако трябваше да избера победител в горния конкурс, със сигурност щеше да е Go.
Въпреки това на практика изборът на среда, в която да изградите приложението си, е тясно свързан с познанията на вашия екип със споменатата среда и общата производителност, която можете да постигнете с нея. Така че може да няма смисъл всеки екип просто да се потопи и да започне да разработва уеб приложения и услуги в Node или Go. Всъщност намирането на разработчици или познаването на вашия вътрешен екип често се посочва като основната причина да не използвате различен език и / или среда. Въпреки това времената се промениха през последните петнадесет години или много, много.
Надяваме се, че горното помага да се изготви по-ясна картина на случващото се под капака и ви дава някои идеи как да се справите с реалната мащабируемост за вашето приложение. Честито въвеждане и извеждане!