Това е третата статия в поредица от статии за дизайна на Rotor, библиотека, която прави асинхронен IO в Rust. Изминаха около три месеца от публикуването на моята първа статия. И първият ми асинхронен код, написан на rust, е почти на една година. Този код все още работи в производството (в роля, която не е критична за мисията). Това означава, че е време да спрете да пренаписвате ротора всеки път и да започнете да пишете истински производствен код с него. И ето как.

Библиотека на ядрото на ротора

Намерението да се създаде „ротор“ беше желанието да се изгради междинен софтуер, който позволява да се композират множество неща в един цикъл на събитие. Това е ценно, защото почти всяко приложение използва множество протоколи за комуникация с външния свят. И не искате да прилагате един протокол два пъти.

Rotor 1.0 ще съдържа само минимални неща, които са необходими за композиране на библиотеки и нищо повече

Ако сте следвали rotor преди, знаете, че съдържа абстракция за потоци, която има някакво мнение за това как изглежда буферирането. Много от тези неща сега са в `rotor-stream`, който е просто помощна библиотека. Може да го игнорирате. Можете да използвате няколко негови версии в един основен цикъл и т.н. Така че опасенията за обратната съвместимост са по-слаби за него.

И така, какво има в Core Library?

Ето минимума, който съдържа роторната кутия:

  1. Базова държавна машина, която картографира действията `mio::Handler` почти директно към действията на държавната машина
  2. Начин за композиране на две машини в една с един и същ интерфейс (имаме два начина: макро и общ, вижте по-долу)
  3. `mio::Handler`, който съдържа колекция (по-специално Slab) от държавните машини
  4. Всички необходими шаблони за комуникация между държавни машини (двойка Future/Port, вижте по-долу)

Това е основно всичко. Роторът дори не знае нищо за гнездата. Създавате сокети директно (чрез mio). Трябва да ги съхранявате в държавна машина, но тя не се прилага по никакъв начин. Можете да структурирате кода си както желаете.

Нека повторим малко основите:

  1. За да имаме множество държавни машини в един цикъл на събитие, трябва да правим разлика между тях. Така че ние използваме Slab, който съхранява държавни машини, и ги различаваме по токена mio.
  2. Множество изпълнения на държавна машина се комбинират с помощта на изброяване, което има опция за всяка реализация на държавна машина. При всяко действие стойността се съпоставя и съответната държавна машина се изпраща. Алтернативата би била да поставите обекти на признаци в Slab, което също е възможно (но вероятно по-малко производително, така че не е по подразбиране).
  3. Имате събитие за изчакване, което е изпратено до правилната машина, но не можете да носите стойност в изчакване. Носенето на стойност би било главоболие, когато правите композиция. Както и да е, обичайната практика е да проверите отново часа, така че не е проблем.
  4. По същия начин със съобщенията получавате събитие за събуждане и ваша отговорност е да проверите подходящия обект Future. Но имаме обект, който гарантира, че съобщението ще стигне до правилния получател.
  5. Имаме начин да предадем глобалния контекст на всички държавни машини. Където всяка държавна машина избира само необходимите характеристики в контекст. Писал съм за това и преди.
  6. На това ниво на абстракция трябва да гарантираме, че всяка грешка може да бъде обработена и никоя грешка не може да предизвика паника в нашето приложение. Дори изчерпването на ресурсите (като epoll не може да добави друг сокет). Освен, разбира се, липсата на памет, с която не можете да се справите в Rust.

Това е всичко, което е необходимо за 80% от случаите на употреба. Малко неща също ще бъдат добавени, но нека прегледаме как се разви роторът, преди да обсъдим списъка със задачи.

Промени на ротора

Ето и актуалните решения. Ако смятате, че някои от тях са лоши, моля, докладвайте ги на програмата за проследяване на проблеми. Ще стабилизирам сърцевината на ротора възможно най-бързо, така че да можем да създаваме приложения, без да се страхуваме, че утре ще се повредят.

Първо нека да разгледаме характеристиката на `rotor::Machine`:

Методите “ready”, “timeout” и “wakeup” трябва да са очевидни от текста по-горе.

Тройните методи “spawned”, “spawn_error” и “create” са отговорни за създаването на нови държавни машини и са необходими за приемане на сокети. Тъй като има абстракция `Accept` в `rotor-stream`, повечето потребители не трябва да се занимават директно с методите. Подробностите ще бъдат описани в документацията. Важното тук е, че се опитваме да покрием всеки възможен режим на повреда тук. Това е важно за бъдещо внедряване, дори ако `rotor_stream::Accept` не се справя добре с всички грешки.

Всеки манипулатор на събития получава себе си по стойност и променлив обект „Обхват“. И всеки манипулатор на събития връща някаква сложна върната стойност, която съдържа държавна машина и може да бъде някои други данни. Повече за върнатите стойности по-късно.

Тип обхват

Ако сте чели предишните ми статии, може би сте забелязали, че Scope е премахнат в полза на по-богата възвръщаема стойност. След като написах повече код, стана ясно, че е невъзможно да се справим с всичко чрез върнатата стойност. Ключовият случай е създаването на обект „Бъдеще“, който позволява да се изпрати съобщението до тази държавна машина от друга.

Технически подробности: за да изпратим съобщение до държавна машина, изпращаме известие до главния цикъл (това е функционалност на mio). Известието съдържа „mio::Token“, който идентифицира държавната машина. Обхватът съдържа вътрешно токен, но това е само детайл за изпълнение на текущия цикъл на mio събития и ядрото на ротора. Ще го променим в бъдеще.

В момента Scope обектът е нещо, което:

  1. Предава се между всички извиквания на функции на машина със състояния като аргумент
  2. Съдържа променлива препратка към контекста (вижте първата статия за подробности относно контекста).
  3. Има методи за регистриране на сокет и/или таймаут в рамките на цикъла на събития (и събитието винаги се доставя на тази машина на състоянието)
  4. Позволява да се създаде двойка Future/Port, която позволява изпращане на съобщения между държавни машини (`Scope::create_future()`)

За да ви даде някакво усещане за кода, ето как се инициализира rotor_stream::Stream:

Той регистрира сокет в режим, задействан от край, за да пропусне повторното регистриране при промяна на вътрешното състояние. Но тъй като същият обхват се получава във всеки манипулатор на действие, той може да се регистрира в режим, задействан от ниво, и да го променя при всеки случай.

И да, за разлика от първите версии на ротора, Scope вече е един тип (генеричен над контекст). Картографирането на обхват към различен обект на всяко ниво на абстракция беше кошмар.

Асинхронен резултат

В предишната статия имаше и общ обект `Async`, който щеше да представлява резултатен обект на асинхронна операция. Оказва се, че или е безполезно, или просто е трудно да разбера как трябва да изглежда. Основният ротор има подобен обект Response, който има три конструктора:

Конструкторът `ok` е подобен на `Result::Ok` и връща машина със състояния към основния цикъл. Може би конструкторът „done“ е подобен на „Option::None“, което означава, че тази машина на състоянието е свършила работата си и трябва да бъде премахната. И накрая, конструкторът `spawn` означава „тази машина на състоянието е в състояние A и моля, създайте друга от B“.

Типът „rotor::Response“ не е enum, а има частна вътрешна структура само за бъдеща съвместимост. Вероятно в бъдеще може да бъде изобретен някакъв тип Async или да се добави друго състояние. Въпреки че най-вероятно ще бъде замразено и ще стане публично преброяване.

Типът „rotor::Machine::Seed“ (N в Response) е тип, който може да се използва за създаване на машина за състояние. Въпреки че можете да изпратите държавна машина към цикъла (както в предишните версии на ротора), тя ще стигне там неинициализирана. Така че вместо да създаваме друго състояние във всяка машина, ние имаме отделен тип „инициализатор“ или „семена“. Машината е създадена с метода `create(seed, scope)`. Така че, когато се постави в слаб, той вече е инициализиран и регистриран чрез главния цикъл (използвайки обхват).

Изглежда, че други протоколи се нуждаят от различна върната стойност. Ето някои типове, използвани в роторната екосистема досега (преброяванията са малко опростени):

Протоколът на потока е малко многословен. При всяко действие, което искате да върнете самата държавна машина, следващото условие, което очаквате да бъде извикано, и максималното време, което това условие може да бъде изчакано. Това може да изглежда сложно, но помага да имате супер прости анализатори на протоколи. Като този:

HTTP протоколът, очертан по-горе, показва, че върнатите стойности понякога са различни в различните действия на един и същ протокол. Когато получите заглавки на заявка, вече можете да посочите дали ще буферирате цялата заявка в паметта или ще я прочетете на части. Освен това можете да поставите таймаут за цялата обработка на заявката. След това просто се справяте с всяка малка операция. Но в случай, че изчакването се случи и не сте предвидили, че заявката ще бъде толкова голяма, можете да коригирате времето за изчакване, като върнете нова стойност за краен срок от манипулатора на изчакване.

И в двата случая връщането на „Няма“ означава, че тази машина няма какво повече да прави. И в двата случая върнатата стойност на Option е достатъчна.

Променливи аргументи

В началото на разработването на ротора, ние избрахме да третираме държавните машини като неизменни неща, предавани по стойност. И се оказа страхотно решение. Все пак понякога може да забележите променливи неща.

Най-използваният е `Scope`, той позволява да се "мутира" цикъл на събитие чрез регистриране на сокети и изчаквания. Освен това Scope съдържа променлива препратка към контекст, което е нашият начин да променим глобално състояние, ако вашето приложение има такова.

Роторният поток има абстракция „Транспорт“. Което ви позволява да проверявате входните и изходните буфери. Можете да пишете изход и да консумирате вход във всеки манипулатор на събития. Докато на теория потребителят може да обърка буферите, повечето инварианти така или иначе се обработват от rotor_stream. Например, ако сте оставили нещо в изходния буфер, то в крайна сметка ще бъде изхвърлено в мрежата. Ако имате нужда от някои байтове от входа и те са във входния буфер, вашият манипулатор ще бъде изпратен незабавно. Ако не консумирате байтове и ги изисквате отново, вашият манипулатор ще бъде извикан непрекъснато консумиращ 100% CPU, така че ще го забележите бързо и коригирането ще бъде лесно.

От друга страна, променливият изглед на буферите опростява връщаната стойност и ви позволява да правите много интересни оптимизации. Например, вие пишете в съседния буфер, вместо да връщате данни на малки парчета (всеки да бъде разпределен в купчината). Друг пример, HTTP запазва малки парчета в буфер, без да консумира, за да може да избутва по-големи парчета към горните слоеве на абстракция без копиране.

Друг пример е обектът `Response` в rotor-http. Позволява ви да пишете отговор прогресивно, като първо зададете статус на отговора, след това добавите заглавки, накрая напишете малко тяло, възможно също и на няколко части:

Тъй като всеки манипулатор на събития получава обект с променлив отговор, вие сте свободни да извиквате конкретни методи по всяко време. Обектът на отговора капсулира променлив изглед на изходен буфер и всичко се записва директно в буфера, вместо да се съхранява в неефективни структури на купчина като хеш карта на заглавки или подобни. Освен това обектът Response има вътрешна машина за състояние, която предотвратява грешки при опити за писане на неща без ред и дори предпазва потребителя от тежки грешки като изпращане на Content-Length два пъти.

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

Състав

Съставът беше ключовият момент в развитието на ротора.
Има два вида състав:

  1. Хоризонтално, където събирате множество реализации на един и същ протокол
  2. Вертикално, където подреждате протоколи един върху друг

Вертикалните слоеве не се нуждаят от какъвто и да е шаблон за композицията, те се подреждат един върху друг по дизайн.

Първият вид композиция се прилага само за машини от най-високо ниво (`rotor::Machine`). Има две опции, или `rotor::Compose2` (където 2 е броят на комбинираните машини) или `rotor_compose!()` макрос. Macro ви дава по-добри имена за всяка дъщерна машина, например:

Това създава enum с две опции, всяка от които вгражда държавна машина от някакъв тип (и също enum за съставяне на Seed на машините). В случай на използване на тип `Compose2`, получавате само опции `A` и `B`.

Друг интересен случай, който ще изисква повече разследване, е събирането на множество дъщерни държавни машини на някакъв среден слой. т.е. за HTTP2 (и може би всеки друг мултиплексиран протокол) трябва да поддържате множество машини за състояние на заявка за една връзка. Накратко, просто ви трябва вектор от държавни машини вътре в манипулатора на връзката.

Списъкът с неща за правене

Изглежда, че имаме възможно най-малкото ядро ​​на ротора. Но все още липсват няколко неща, за да стане 1.0:

1. Глобални изчаквания и съобщения
2. Начин за излизане от главния цикъл
3. Взаимозаменяеми главни цикли

Отличителната черта на глобалното действие (или изчакване, или уведомително съобщение) е, че то може да инспектира множество държавни машини. напр. може да сканира набор от клиентски връзки и да изключи неактивните или да извърши някои подобни задачи по поддръжката. Понастоящем всяка държавна машина има достъп само до собственото си състояние и контекст (което не включва Slab на други държавни машини, невъзможно е поради програмата за проверка на заеми от ръжда).

Надяваме се, #1 и #2 няма да променят вече съществуващите API. Но номер 3 може да промени много. Много примамливата идея е да стартирате един и същ код както в цикъла на събития, базиран на epoll, така и в TCP стека на потребителското пространство, без да губите производителност.

Има много повече работа в rotor-stream, но тъй като е възможно да комбинирате няколко версии на rotor-stream в приложение, това не е толкова важно. Най-интересното нещо, което трябва да се имплементира в rotor-stream, е оптимизацията на `sendfile` и използването на карти на паметта файлове за буфери.

Бенчмарковете

Имайте предвид, че микро-бенчмарковете по-долу са напълно ненаучни. Но хората харесват показателите, така че ги публикувам тук, не за да покажа, че rotor-http е най-бързият, а за да покажа, че не се случва нещо напълно грешно. Всички тестове се изпълняват на настолен клас i7–4790K (4,00 GHz), Ubuntu trusty, ядро ​​3.16.0. Кодът е компилиран с Rust 1.5.

Hyper, текущ главен (a4230eb), с брой нишки по подразбиране (10):

> wrk -c 400 -d 60 -t2 http://localhost:1337
Running 1m test @ http://localhost:1337
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 29.08us 63.19us 26.92ms 99.74%
 Req/Sec 150.89k 102.84k 275.90k 47.92%
 18017291 requests in 1.00m, 1.51GB read
Requests/sec: 300267.47
Transfer/sec: 25.77MB

Хипер с 50 нишки:

> time ./wrk -c 400 -d 60 -t2 http://localhost:1337
Running 1m test @ http://localhost:1337
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 98.77us 340.63us 51.77ms 99.13%
 Req/Sec 203.51k 19.43k 246.10k 76.33%
 24300108 requests in 1.00m, 2.04GB read
Requests/sec: 404919.33
Transfer/sec: 34.75MB

Ротор с една резба (пример hello_world_server от rotor-http):

> wrk -c 400 -d 60 -t2 http://localhost:3000
Running 1m test @ http://localhost:3000
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 2.20ms 606.03us 198.18ms 90.77%
 Req/Sec 91.00k 12.71k 188.25k 88.16%
 10784514 requests in 1.00m, 524.53MB read
Requests/sec: 179722.03
Transfer/sec: 8.74MB

Ротор, 2 нишки (пример „с резба“ от rotor-http, 6a55b79):

> wrk -c 400 -d 60 -t2 http://localhost:3000
Running 1m test @ http://localhost:3000
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 1.02ms 524.95us 202.31ms 91.49%
 Req/Sec 196.18k 24.94k 264.13k 64.11%
 23443997 requests in 1.00m, 1.11GB read
Requests/sec: 390084.90
Transfer/sec: 18.97MB

Ротор, 10 резби:

> time ./wrk -c 400 -d 60 -t2 http://localhost:3000
Running 1m test @ http://localhost:3000
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 0.85ms 2.33ms 205.30ms 95.56%
 Req/Sec 245.43k 30.84k 384.37k 80.10%
 29085176 requests in 1.00m, 1.38GB read
Requests/sec: 484700.17
Transfer/sec: 23.57MB

Ето nginx 1.4.6 на същата машина, с 2 работници:

> time ./wrk -c 400 -d 60 -t2 http://localhost:3000
Running 1m test @ http://localhost:3000
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 4.99ms 14.02ms 852.80ms 89.76%
 Req/Sec 191.65k 24.34k 265.37k 86.25%
 22882230 requests in 1.00m, 5.20GB read
Requests/sec: 381356.17
Transfer/sec: 88.72MB

Nginx с 10 работници:

> time ./wrk -c 400 -d 60 -t2 http://localhost:3000
Running 1m test @ http://localhost:3000
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 570.06us 1.63ms 200.91ms 98.12%
 Req/Sec 246.49k 46.93k 321.76k 58.58%
 29424576 requests in 1.00m, 6.69GB read
Requests/sec: 490386.32
Transfer/sec: 114.09MB

Обърнете внимание, че nginx обслужва празен_gif вместо само hello world и изпраща повече заглавки. Ето защо обслужва много повече байтове.

Ето подобен golang пример, отидете 1.5. С GOMAXPROCS=1:

> time ./wrk -c 400 -d 60 -t2 http://localhost:3000
Running 1m test @ http://localhost:3000
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 3.98ms 1.21ms 202.91ms 77.23%
 Req/Sec 50.37k 2.02k 65.77k 92.83%
 6014951 requests in 1.00m, 734.25MB read
Requests/sec: 100242.23
Transfer/sec: 12.24MB

Отидете с GOMAXPROCS=2:

> time ./wrk -c 400 -d 60 -t2 http://localhost:3000
Running 1m test @ http://localhost:3000
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 2.49ms 1.14ms 33.77ms 80.48%
 Req/Sec 82.23k 12.61k 96.63k 77.92%
 9815339 requests in 1.00m, 1.17GB read
Requests/sec: 163500.80
Transfer/sec: 19.96MB

Отидете с GOMAXPROCS=10:

> time ./wrk -c 400 -d 60 -t2 http://localhost:3000
Running 1m test @ http://localhost:3000
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 2.01ms 2.44ms 204.44ms 89.92%
 Req/Sec 122.10k 8.51k 214.50k 74.56%
 14589585 requests in 1.00m, 1.74GB read
Requests/sec: 242809.56
Transfer/sec: 29.64MB

Бях просто достатъчно любопитен, за да тествам „fasthttp внедряването“ за golang, което се рекламира като потенциално 10x ускорение в сравнение с „net/http“. Да видим.. GOMAXPROCS=1:

> time ./wrk -c 400 -d 60 -t2 http://localhost:3000
Running 1m test @ http://localhost:3000
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 1.92ms 1.65ms 201.72ms 99.78%
 Req/Sec 104.89k 7.03k 126.57k 89.33%
 12526574 requests in 1.00m, 1.73GB read
Requests/sec: 208770.50
Transfer/sec: 29.47MB

Отидете бързоhttp с GOMAXPROCS=2:

> time ./wrk -c 400 -d 60 -t2 http://localhost:3000
Running 1m test @ http://localhost:3000
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 1.13ms 353.32us 5.93ms 76.20%
 Req/Sec 176.42k 31.72k 228.52k 60.58%
 21063512 requests in 1.00m, 2.90GB read
Requests/sec: 351034.14
Transfer/sec: 49.55MB

С GOMAXPROCS=10:

> time ./wrk -c 400 -d 60 -t2 http://localhost:3000
Running 1m test @ http://localhost:3000
 2 threads and 400 connections
 Thread Stats Avg Stdev Max +/- Stdev
 Latency 537.22us 719.53us 44.44ms 96.91%
 Req/Sec 235.20k 36.37k 333.38k 72.50%
 28082763 requests in 1.00m, 3.87GB read
Requests/sec: 467981.18
Transfer/sec: 66.05MB

Забележка: във всички тестове, които имат повече от 2 нишки на целевото приложение, wrk също е на същата машина, така че се конкурира за ресурсите (има само 4 ядра/8 хипернишки):

За съжаление тук няма графики, защото тестовете не са много добри. Но заключението е, че асинхронното внедряване изглежда по-бързо от HTTP изпълнението по подразбиране на golang. Не съм сигурен кои оптимизации позволяват на fasthttp да бъде по-бърз в една нишка (може би обединяване на буфери и избягване на синтактичен анализ на заглавка за hello world пример), но оптимизациите дори в две нишки не помагат. И nginx е малко по-бърз (дори при нечестен тест), но не мисля, че е значително.

Сравняването на rotor-http с hyper е трудно. Въпреки че изглежда, че роторът показва известно подобрение на производителността, тестът е твърде чувствителен към броя на използваните нишки и този брой нишки вероятно е полезен само за конкретния микробенчмарк.

Имайте предвид, че все още не съм профилирал ротор, така че може да пропусна някои важни регресии на производителността. Също така, по-интересните показатели са относно използването на паметта и процесора при голям брой поддържащи връзки или частично получени отговори. Но все още не съм тествал тези сложни сценарии.

Обобщавайки

Към момента на писане „роторът“ не е пипан от около две седмици. Докато по-голямата част от работата по rotor-stream и rotor-http течеше през това време. Това вероятно означава, че основите са доста добре и че можете да опитате достатъчно различни подходи за изграждане върху ротора.

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

Сега е време да напишем повече документация и тестове за внедряването. Както и да направи още няколко протокола, по-специално DNS и HTTP клиента. Може би те предлагат различни предизвикателства в сравнение със сървърната реализация.

Актуализация:reddit link (/r/golang), hackernews