Принципи на проектиране и модели за силно едновременни приложения

1. Общ преглед

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

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

2. Основи на едновременността

Преди да продължим по-нататък, нека прекараме известно време в разбиране на основите. Като начало трябва да изясним разбирането си за това, което наричаме едновременна програма. Имаме предвид, че една програма е едновременна, ако се извършват множество изчисления едновременно .

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

2.1. Как да създам едновременни модули?

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

  • Процес : Процесът е екземпляр на работеща програма, която е изолирана от други процеси в същата машина. Всеки процес на машина има свое собствено изолирано време и пространство. Следователно обикновено не е възможно да се споделя памет между процесите и те трябва да комуникират чрез предаване на съобщения.
  • Тема : Нишката, от друга страна, е само сегмент от процес . В рамките на програма може да има множество нишки, които споделят едно и също пространство в паметта. Въпреки това, всяка нишка има уникален стек и приоритет. Нишката може да бъде естествена (естествено насрочена от операционната система) или зелена (насрочена от библиотека по време на изпълнение).

2.2. Как взаимодействат едновременните модули?

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

  • Споделена памет : В този модел едновременните модули си взаимодействат, като четат и записват споделени обекти в паметта . Това често води до преплитане на едновременни изчисления, причинявайки състезателни условия. Следователно, това може да доведе недетерминирано до неправилни състояния.
  • Предаване на съобщения : В този модел едновременните модули си взаимодействат, като предават съобщения един на друг чрез комуникационен канал . Тук всеки модул обработва входящите съобщения последователно. Тъй като няма споделено състояние, е относително по-лесно да се програмира, но това все още не е свободно от състезателни условия!

2.3. Как се изпълняват едновременните модули?

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

Сега знаем, че едно ядро ​​може да изпълнява наведнъж само по една нишка или набор от инструкции. Броят на процесите и нишките обаче може да бъде съответно в стотици и хиляди. И така, как наистина работи? Тук операционната система симулира едновременност за нас . Операционната система постига това чрез разделяне на времето - което на практика означава, че процесорът превключва между нишките често, непредсказуемо и недетерминирано.

3. Проблеми при едновременно програмиране

Докато обсъждаме принципи и модели за проектиране на едновременно приложение, би било разумно първо да разберем какви са типичните проблеми.

В много голяма степен опитът ни с едновременното програмиране включва използване на естествени нишки със споделена памет . Следователно ще се съсредоточим върху някои от често срещаните проблеми, които произтичат от него:

  • Взаимно изключване (Примитиви за синхронизация) : Преплитащите се нишки трябва да имат изключителен достъп до споделено състояние или памет, за да се гарантира коректността на програмите . Синхронизирането на споделените ресурси е популярен метод за постигане на взаимно изключване. Налични са няколко примитива за синхронизация - например заключване, монитор, семафор или мутекс. Програмирането за взаимно изключване обаче е склонно към грешки и често може да доведе до тесни места в производителността. Има няколко добре обсъждани въпроса, свързани с това като задънена улица и livelock.
  • Превключване на контекст (тежки нишки) : Всяка операционна система има естествена, макар и разнообразна поддръжка за едновременни модули като процес и нишка. Както беше обсъдено, една от основните услуги, които операционната система предоставя, е планиране на нишки за изпълнение на ограничен брой процесори чрез нарязване на времето. Това на практика означава, че нишките често се превключват между различни състояния . В процеса трябва да се запази и възобнови текущото им състояние. Това отнема време дейност, пряко влияеща върху общата производителност.

4. Проектирайте модели за висока паралелност

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

4.1. Съвместност, базирана на актьор

Първият дизайн, който ще обсъдим по отношение на едновременното програмиране, се нарича модел на актьора. Това е математически модел на едновременно изчисление, който в основата си третира всичко като актьор . Актьорите могат да си предават съобщения и в отговор на съобщение могат да вземат местни решения. Това беше предложено за първи път от Карл Хюит и вдъхнови редица езици за програмиране.

Основната конструкция на Scala за едновременно програмиране са актьорите. Актьорите са нормални обекти в Scala, които можем да създадем чрез създаване на инстанция на класа Actor . Освен това, библиотеката Scala Actors предоставя много полезни операции с актьори:

class myActor extends Actor { def act() { while(true) { receive { // Perform some action } } } }

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

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

Има, разбира се, и други модели, които могат да ни помогнат тук - ще ги разгледаме по-късно!

4.2. Съпоставено въз основа на събития

Проектите, базирани на събития, изрично адресират проблема, че родните нишки са скъпи за създаване и работа. Един от дизайните, базирани на събития, е цикълът на събитията. Цикълът на събитията работи с доставчик на събития и набор от обработчици на събития. В тази настройка цикълът на събития блокира доставчика на събития и изпраща събитие към манипулатора на събития при пристигане .

По принцип цикълът на събития не е нищо друго освен диспечер на събития! Самият цикъл на събитията може да работи само на една родна нишка. И така, какво наистина се случва в цикъл на събития? Нека да разгледаме псевдокода на наистина прост цикъл на събития за пример:

while(true) { events = getEvents(); for(e in events) processEvent(e); }

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

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

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

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

4.3. Неблокиращи алгоритми

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

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

Това означава, че трябва да напишем нови структури от данни и библиотеки, които да използват тази атомна операция. Това ни даде огромен набор от внедрявания без изчакване и заключване на няколко езика. Java има няколко не-блокиране на структури от данни като AtomicBoolean , AtomicInteger , AtomicLong и AtomicReference .

Помислете за приложение, при което множество нишки се опитват да получат достъп до един и същ код:

boolean open = false; if(!open) { // Do Something open=false; }

Ясно е, че горният код не е безопасен за нишки и поведението му в среда с много нишки може да бъде непредсказуемо. Нашите опции тук са или да синхронизирате този код с ключалка или да използвате атомна операция:

AtomicBoolean open = new AtomicBoolean(false); if(open.compareAndSet(false, true) { // Do Something }

Както можем да видим, използването на неблокираща структура на данни като AtomicBoolean ни помага да напишем безопасен за нишки код, без да се отдадем на недостатъците на ключалките!

5. Подкрепа в езици за програмиране

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

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

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

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

5.1. Goroutines в Go

Goroutines в езика за програмиране Go са леки нишки. Те предлагат функции или методи, които могат да работят едновременно с други функции или методи. Goroutines са изключително евтини, тъй като те започват само няколко килобайта в размер на стека .

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

5.2. Процеси в Erlang

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

Под капака процесите на Erlang не са нищо друго освен функции, за които изпълнението се справя с планирането. Освен това процесите на Erlang не споделят никакви данни и те комуникират помежду си чрез предаване на съобщения. Това е причината, поради която ние първо наричаме тези „процеси“!

5.3. Fibers в Java (предложение)

Историята на едновременността с Java е непрекъсната еволюция. Java наистина имаше поддръжка за зелени нишки, поне за операционните системи Solaris. Това обаче беше прекратено поради препятствия извън обхвата на този урок.

Оттогава едновременността в Java е свързана с естествени нишки и как да се работи с тях интелигентно! Но по очевидни причини скоро може да имаме нова абстракция на паралелността в Java, наречена fiber. Project Loom предлага да се въведат продължения заедно с влакна, което може да промени начина, по който пишем едновременни приложения в Java!

Това е само кратък поглед върху това, което се предлага в различни езици за програмиране. Има много по-интересни начини, по които други езици за програмиране са се опитали да се справят с едновременността.

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

6. Приложения с висока паралелност

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

Как да осигурим висока съвпадение в такива ситуации? Нека разгледаме някои от тези слоеве и опциите, които имаме за изграждане на силно едновременно приложение.

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

6.1. Уеб слой

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

  • Node (наричан още NodeJS или Node.js) е изпълнение с JavaScript с отворен код на различни платформи, изградено върху JavaScript двигателя на V8 на Chrome. Node работи доста добре при работа с асинхронни I / O операции. Причината, поради която Node го прави толкова добре, е, че реализира цикъл на събития върху една нишка. Цикълът на събития с помощта на обратно извикване обработва всички блокиращи операции като I / O асинхронно.
  • nginx е уеб сървър с отворен код, който използваме често като обратен прокси сред другите си обичаи. Причината, поради която nginx осигурява висока паралелност, е, че използва асинхронен подход, управляван от събития. nginx работи с главен процес в една нишка. Главният процес поддържа работни процеси, които извършват действителната обработка. Следователно работните процеси обработват всяка заявка едновременно.

6.2. Приложен слой

Докато проектираме приложение, има няколко инструмента, които да ни помогнат да изградим висока конкурентност. Нека разгледаме няколко от тези библиотеки и рамки, които са ни достъпни:

  • Akka е инструментариум, написан в Scala за изграждане на силно едновременни и разпределени приложения на JVM. Подходът на Akka към работа с паралелност се основава на модела на актьора, който обсъдихме по-рано. Akka създава слой между актьорите и основните системи. Рамката се справя със сложността на създаване и планиране на нишки, получаване и изпращане на съобщения.
  • Project Reactor е реактивна библиотека за изграждане на неблокиращи приложения на JVM. Той се основава на спецификацията на реактивните потоци и се фокусира върху ефективно предаване на съобщения и управление на търсенето (обратен натиск). Операторите на реактори и планировчиците могат да поддържат висока скорост на пропускане на съобщенията. Няколко популярни рамки предоставят реакторни реализации, включително Spring WebFlux и RSocket.
  • Netty е асинхронна, управлявана от събития, рамка за мрежови приложения. Можем да използваме Netty за разработване на много едновременни сървъри и клиенти за протоколи. Netty използва NIO, което представлява колекция от Java API, която предлага асинхронен трансфер на данни през буфери и канали. Предлага ни няколко предимства като по-добра производителност, по-ниска латентност, по-малко консумация на ресурси и минимизиране на ненужното копиране на паметта.

6.3. Слой от данни

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

  • Cassandra е безплатна и с отворен код NoSQL разпределена база данни, която осигурява висока наличност, висока мащабируемост и устойчивост на неизправности на стоковия хардуер. Cassandra обаче не предоставя ACID транзакции, обхващащи множество таблици. Така че, ако нашето приложение не изисква силна последователност и транзакции, можем да се възползваме от операциите с ниска латентност на Касандра.
  • Kafka е разпределена платформа за стрийминг . Kafka съхранява поток от записи в категории, наречени теми. Той може да осигури линейна хоризонтална мащабируемост както за производителите, така и за потребителите на записите, като в същото време осигурява висока надеждност и дълготрайност. Дяловете, репликите и брокерите са някои от основните концепции, върху които той осигурява масово разпределена паралелност.

6.4. Кеш слой

Е, нито едно уеб приложение в съвременния свят, което се стреми към висока паралелност, не може да си позволи да попада в базата данни всеки път. Това ни оставя да изберем кеш - за предпочитане кеш в паметта, който може да поддържа нашите изключително едновременни приложения:

  • Hazelcast е разпределен, удобен за облак, съхраняващ и изчисляващ обект памет, който поддържа голямо разнообразие от структури от данни като Map , Set , List , MultiMap , RingBuffer и HyperLogLog . Той има вградена репликация и предлага висока наличност и автоматично разделяне.
  • Redis е хранилище за структура на данни в паметта, което предимно използваме като кеш . Той осигурява база данни с ключ-стойност в паметта с допълнителна трайност. Поддържаните структури от данни включват низове, хешове, списъци и набори. Redis има вградена репликация и предлага висока наличност и автоматично разделяне. В случай че не се нуждаем от постоянство, Redis може да ни предложи богат на функции, мрежов кеш в паметта с изключителна производителност.

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

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

7. Заключение

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

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