Избягвайте състезателни условия в SharedArrayBuffer с Atomics

състезателни

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

Но разработчиците на библиотеки, които имат опит с многопоточно програмиране на други езици, могат да използват тези нови API на ниско ниво, за да създадат инструменти от по-високо ниво, а разработчиците на приложения могат да използват тези инструменти, без да имат директен достъп до SharedArrayBuffers или Atomics.

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

Но първо, какво е условие за състезание?

Състезание: пример, който може да сте виждали преди

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

Първоначално fileExists е зададено на false.

Ако първо се изпълни кодът в нишка 2, файлът ще бъде зареден.

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

Но това е фалшива грешка. Не че файлът не съществува. Истинският проблем е състоянието на състезанието.

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

Различни видове състезателни условия и как Atomics ви помага да се справите с тях

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

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

Състояние на състезание в една операция

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

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

На ниво CPU, увеличаването на стойност отнема три инструкции. Това е така, защото компютърът има както дългосрочна, така и краткосрочна памет. (Разгледах как всичко това работи по-подробно в друга статия.)

Всички нишки споделят дългосрочна памет. Но краткосрочната памет (регистри) не се споделя между нишките.

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

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

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

Една от целите на атомарните операции е да изпълнява онези операции, които хората смятат за отделни операции, но които не са такива за компютъра като цяло.

Ето защо те се наричат ​​атомни операции. Това е така, защото те изпълняват операции, които обикновено имат множество инструкции (където инструкциите могат да бъдат поставени на пауза и възобновени) и ги карат всички да изглеждат изпълнени наведнъж, сякаш са една единствена инструкция. Това е като неделим атом.

Когато използвате атомарни операции, кодът за нарастването ще изглежда малко по-различно.

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

Атомни методи, които помагат да се избегне този вид надпревара:

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

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

Състезателни условия в множество операции

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

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

В този случай нишка 2 ще поеме контрола върху заключването на данни и ще зададе стойността на locked на true. Това означава, че нишка 1 няма достъп до данните, докато нишка 2 не ги отключи.

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

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

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

Библиотеката за управление на заключване може да използва много различни методи на обекта Atomics, но най-важните за този вариант саупотреби са:

Състезателни условия, причинени от пренареждане на инструкции

Има още един проблем с времето, за който Atomics също са се погрижили. Това може да е изненадващо.

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

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

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

Засега всичко е според очакванията.

Това, което не е очевидно, освен ако не разберете как работят компютрите на ниво процесор (и как тръбопроводите, които процесорите използват за изпълнение на код), е, че ред 2 в нашия код трябва да изчака малко, преди да може да бъде изпълнен.

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

Ето един пример за стъпките, през които преминава инструкцията:

  1. Вземете следващата команда от паметта
  2. Разберете какво ни казва инструкцията (с други думи, декодирайте инструкцията) и вземете стойностите​​от регистрите
  3. Изпълнение на инструкцията
  4. Запишете резултата обратно, за да се регистрирате

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

Проблемът е в товаима зависимост между инструкция #1 и инструкция #2.

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

За да направят изпълнението на кода по-ефективно, много компилатори и процесори променят реда на изпълнение на кода. Те търсят други инструкции, които не използват subTotal или total и ги поставят между тези два реда.

Това гарантира постоянен поток от инструкции, движещи се по конвейера.

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

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

Ако сте използвали isDone като флаг, че общата сума се отчита и може да се използва в друга нишка, тогава това пренареждане би създало условие за състезание.

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

Атомарните операции не се пренареждат една спрямо друга и други операции не се движат около тях. По-специално, две операции, които често се използват за налагане на ред, са:

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

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

Забележка. Цикълът while, който показвам тук, се нарича кръгово блокиране и е много неефективен. Ако е в основната нишка, може да спре вашето приложение. Почти със сигурност не искате да се натъкнете на това в реален код.

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

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

Ето защо не е необходимо да използвате SharedArrayBuffers и Atomics директно в кода на вашето приложение. Вместо това трябва да разчитате на доказани библиотеки от разработчици, които имат опит с многонишкови процеси и които са прекарали време в изучаване на това как работи паметта.

SharedArrayBuffer и Atomics са все още млади и такива библиотеки все още не са създадени. Но тези нови API осигуряват основната основа за изграждане.

О, Лин Кларк