Concurență avansată în Swift cu HoneyBee

Publicat: 2022-03-11

Proiectarea, testarea și menținerea algoritmilor concurenți în Swift este dificilă, iar obținerea corectă a detaliilor este esențială pentru succesul aplicației dvs. Un algoritm concurent (numit și programare paralelă) este un algoritm care este proiectat să efectueze mai multe (poate multe) operațiuni în același timp pentru a profita de mai multe resurse hardware și pentru a reduce timpul general de execuție.

Pe platformele Apple, modalitatea tradițională de a scrie algoritmi concurenți este NSOperation. Designul NSOperation invită programatorul să subdivizeze un algoritm concurent în sarcini individuale de lungă durată, asincrone. Fiecare sarcină ar fi definită în propria sa subclasă de NSOperation și instanțe ale acelor clase ar fi combinate printr-un API obiectiv pentru a crea o ordine parțială a sarcinilor în timpul execuției. Această metodă de proiectare a algoritmilor concurenți a fost stadiul tehnicii pe platformele Apple timp de șapte ani.

În 2014, Apple a introdus Grand Central Dispatch (GCD) ca un pas dramatic înainte în exprimarea operațiunilor concurente. GCD, împreună cu noile blocuri de caracteristici de limbaj care l-au însoțit și alimentat, au oferit o modalitate de a descrie în mod compact un handler de răspuns asincron imediat după inițierea cererii asincrone. Programatorii nu au mai fost încurajați să răspândească definiția sarcinilor concurente în mai multe fișiere din numeroase subclase NSOperation. Acum, un întreg algoritm concurent ar putea fi scris într-o singură metodă. Această creștere a expresivității și a siguranței tipului a fost o schimbare conceptuală semnificativă înainte. Un algoritm tipic pentru acest mod de scriere ar putea arăta astfel:

 func processImageData(completion: (result: Image?, error: Error?) -> Void) { loadWebResource("dataprofile.txt") { (dataResource, error) in guard let dataResource = dataResource else { completion(nil, error) return } loadWebResource("imagedata.dat") { (imageResource, error) in guard let imageResource = imageResource else { completion(nil, error) return } decodeImage(dataResource, imageResource) { (imageTmp, error) in guard let imageTmp = imageTmp else { completion(nil, error) return } dewarpAndCleanupImage(imageTmp) { imageResult in guard let imageResult = imageResult else { completion(nil, error) return } completion(imageResult, nil) } } } } }

Să defalcăm puțin acest algoritm. Funcția processImageData este o funcție asincronă care efectuează patru apeluri asincrone proprii pentru a-și finaliza activitatea. Cele patru invocări asincrone sunt imbricate una în alta în modul cel mai natural pentru gestionarea asincronă bazată pe blocuri. Blocurile de rezultate au fiecare un parametru opțional Error și toate, cu excepția unuia, conțin un parametru opțional suplimentar care semnifică rezultatul operației aysnc.

Forma blocului de cod de mai sus pare probabil familiară pentru majoritatea dezvoltatorilor Swift. Dar ce este în neregulă cu această abordare? Următoarea listă de puncte dureroase va fi probabil la fel de familiară.

  • Această formă de „piramidă a morții” a blocurilor de cod imbricate poate deveni rapid greoaie. Ce se întâmplă dacă mai adăugăm două operații asincrone? Patru? Dar operațiunile condiționate? Ce zici de comportamentul de reîncercare sau protecțiile pentru limitele de resurse? Codul din lumea reală nu este niciodată la fel de curat și simplu ca exemplele din postările de pe blog. Efectul „piramidă a morții” poate duce cu ușurință la un cod greu de citit, greu de întreținut și predispus la erori.
  • Încercarea de tratare a erorilor din exemplul de mai sus, deși Swifty, este de fapt incompletă. Programatorul a presupus că blocurile de apel invers asincron cu doi parametri, în stil Objective-C, vor furniza întotdeauna unul dintre cei doi parametri; nu vor fi niciodată amândoi nule în același timp. Aceasta nu este o presupunere sigură. Algoritmii concurenți sunt renumiți pentru că sunt greu de scris și de depanat, iar ipotezele nefondate fac parte din motiv. Tratarea completă și corectă a erorilor este o necesitate inevitabilă pentru orice algoritm concurent care intenționează să funcționeze în lumea reală.
  • Luând acest gând și mai departe, poate că programatorul care a scris funcțiile asincrone numite nu a fost la fel de principial ca tine. Ce se întâmplă dacă există condiții în care funcțiile apelate nu reușesc să apeleze înapoi? Sau sună înapoi de mai multe ori? Ce se întâmplă cu corectitudinea procesImageData în aceste circumstanțe? Profesioniştii nu riscă. Funcțiile critice pentru misiune trebuie să fie corecte chiar și atunci când se bazează pe funcții scrise de terți.
  • Poate cel mai convingător, algoritmul asincron considerat este construit suboptim. Primele două operațiuni asincrone sunt ambele descărcări de resurse de la distanță. Chiar dacă nu au nicio interdependență, algoritmul de mai sus execută descărcările secvenţial și nu în paralel. Motivele pentru aceasta sunt evidente; sintaxa blocurilor imbricate încurajează o astfel de risipă. Piețele competitive nu tolerează întârzierile inutile. Dacă aplicația dvs. nu își efectuează operațiunile asincrone cât mai repede posibil, o altă aplicație o va face.

Cum putem face mai bine? HoneyBee este o bibliotecă futures/promises care face programarea concomitentă Swift ușoară, expresivă și sigură. Să rescriem algoritmul asincron de mai sus cu HoneyBee și să examinăm rezultatul:

 func processImageData(completion: (result: Image?, error: Error?) -> Void) { HoneyBee.start() .setErrorHandler { completion(nil, $0) } .branch { stem in stem.chain(loadWebResource =<< "dataprofile.txt") + stem.chain(loadWebResource =<< "imagedata.dat") } .chain(decodeImage) .chain(dewarpAndCleanupImage) .chain { completion($0, nil) } }

Prima linie pe care o începe această implementare este o nouă rețetă HoneyBee. A doua linie stabilește gestionarea implicită a erorilor. Gestionarea erorilor nu este opțională în rețetele HoneyBee. Dacă ceva poate merge prost, algoritmul trebuie să se ocupe. A treia linie deschide o ramură care permite execuția paralelă. Cele două lanțuri de loadWebResource se vor executa în paralel și rezultatele lor vor fi combinate (linia 5). Valorile combinate ale celor două resurse încărcate sunt transmise către decodeImage și așa mai departe în lanț până când este invocată finalizarea.

Să parcurgem lista de mai sus a punctelor dureroase și să vedem cum HoneyBee a îmbunătățit acest cod. Menținerea acestei funcții este acum semnificativ mai ușoară. Rețeta HoneyBee arată ca algoritmul pe care îl exprimă. Codul este ușor de citit, de înțeles și de modificat rapid. Designul HoneyBee asigură că orice ordonare greșită a instrucțiunilor are ca rezultat o eroare de compilare, nu o eroare de rulare. Funcția este acum mult mai puțin susceptibilă la erori și erori umane.

Toate erorile posibile de rulare au fost tratate integral. Fiecare semnătură de funcție pe care o acceptă HoneyBee (există 38) este asigurată că va fi gestionată în totalitate. În exemplul nostru, callback-ul cu doi parametri în stilul Objective-C fie va produce o eroare non-nula, care va fi direcționată către gestionarea erorilor, fie va produce o valoare non-nula care va progresa în lanț, sau dacă ambele valorile sunt zero. HoneyBee va genera o eroare care explică faptul că funcția callback nu își îndeplinește contractul.

HoneyBee se ocupă, de asemenea, de corectitudinea contractuală pentru numărul de ori invocarea apelurilor pentru funcții. Dacă o funcție nu reușește să-și invoce apel invers, HoneyBee produce o eroare descriptivă. Dacă funcția își invocă apelul înapoi de mai multe ori, HoneyBee va suprima invocațiile auxiliare și avertismentele din jurnal. Ambele răspunsuri la erori (și altele) pot fi personalizate pentru nevoile individuale ale programatorului.

Sperăm că ar trebui să fie deja evident că această formă de processImageData paralelizează în mod corespunzător descărcările de resurse pentru a oferi performanțe optime. Unul dintre cele mai puternice obiective de design ale HoneyBee este ca rețeta să arate ca algoritmul pe care îl exprimă.

Mult mai bine. Dreapta? Dar HoneyBee are mult mai multe de oferit.

Fiți atenți: următorul studiu de caz nu este pentru cei slabi de inimă. Luați în considerare următoarea descriere a problemei: aplicația dvs. mobilă utilizează CoreData pentru a-și menține starea. Aveți un model NSManagedObject numit Media, care reprezintă un activ media încărcat pe serverul dvs. back-end. Utilizatorului i se va permite să selecteze zeci de articole media simultan și să le încarce într-un lot în sistemul backend. Media sunt mai întâi reprezentate printr-un șir de referință, care trebuie convertit într-un obiect Media. Din fericire, aplicația dvs. conține deja o metodă de ajutor care face exact asta:

 func export(_ mediaRef: String, completion: @escaping (Media?, Error?) -> Void) { // transcoding stuff completion(Media(context: managedObjectContext), nil) }

După ce referința media este convertită într-un obiect Media, trebuie să încărcați elementul media în back-end. Din nou, aveți o funcție de ajutor pregătită pentru a face lucrurile în rețea.

 func upload(_ media: Media, completion: @escaping (Error?) -> Void) { // network stuff completion(nil) }

Deoarece utilizatorului i se permite să selecteze zeci de articole media simultan, designerul UX a specificat o cantitate destul de solidă de feedback cu privire la progresul încărcării. Cerințele au fost distilate în următoarele patru funcții:

 /// Called if anything goes wrong in the upload func errorHandler(_ error: Error) { // do the right thing } /// Called once per mediaRef, after either a successful or unsuccessful upload func singleUploadCompletion(_ mediaRef: String) { // update a progress indicator } /// Called once per successful upload func singleUploadSuccess(_ media: Media) { // do celebratory things } /// Called if the entire batch was considered to be uploaded successfully. func totalProcessSuccess() { // declare victory }

Cu toate acestea, deoarece aplicația dvs. generează referințe media care sunt uneori expirate, managerii de afaceri au decis să trimită utilizatorului un mesaj de „succes” dacă cel puțin jumătate dintre încărcări au succes. Adică, procesul concurent ar trebui să declare victoria – și să apeleze totalProcessSuccess – dacă mai puțin de jumătate din încercările de încărcare eșuează. Aceasta este specificația care ți-a fost înmânată în calitate de dezvoltator. Dar, ca programator cu experiență, îți dai seama că există mai multe cerințe care trebuie aplicate.

Desigur, Business vrea ca încărcarea lotului să aibă loc cât mai repede posibil, așa că încărcarea în serie este exclusă. Încărcările trebuie efectuate în paralel.

Dar nu prea mult. Dacă doar async fără discernământ întregul lot, zecile de încărcări simultane vor inunda NIC-ul mobil (placa de interfață de rețea), iar încărcările vor continua de fapt mai lent decât în ​​serie, nu mai repede.

Conexiunile la rețeaua mobilă nu sunt considerate a fi stabile. Chiar și tranzacțiile scurte pot eșua doar din cauza modificărilor conectivității la rețea. Pentru a declara cu adevărat că o încărcare a eșuat, va trebui să încercăm din nou încărcarea cel puțin o dată.

Politica de reîncercare nu ar trebui să includă operațiunea de export, deoarece nu este supusă erorilor tranzitorii.

Procesul de export este legat de calcul și, prin urmare, trebuie efectuat în afara firului principal.

Deoarece exportul este legat de calcul, ar trebui să aibă un număr mai mic de instanțe concurente decât procesul de încărcare a restului pentru a evita distrugerea procesorului.

Cele patru funcții de apel invers descrise mai sus actualizează interfața de utilizare și, prin urmare, toate trebuie apelate în firul principal.

Media este un NSManagedObject , care provine dintr-un NSManagedObjectContext și are propriile cerințe de threading care trebuie respectate.

Această specificație a problemei pare puțin neclară? Nu fi surprins dacă găsești astfel de probleme în viitorul tău. Am întâlnit unul ca acesta în propria mea lucrare. Să încercăm mai întâi să rezolvăm această problemă cu instrumente tradiționale. Legați-vă, nu va fi frumos.

 /// An enum describing specific problems that the algorithm might encounter. enum UploadingError : Error { case invalidResponse case tooManyFailures } /// A semaphore to prevent flooding the NIC let outerLimit = DispatchSemaphore(value: 4) /// A semaphore to prevent thrashing the processor let exportLimit = DispatchSemaphore(value: 1) /// The number of times to retry the upload if it fails let uploadRetries = 1 /// Dispatch group to keep track of when the entire process is finished let fullProcessDispatchGroup = DispatchGroup() /// How many of the uploads fully completed. var uploadSuccesses = 0 // this notify block is called when the full process has completed. fullProcessDispatchGroup.notify(queue: DispatchQueue.main) { let successRate = Float(uploadSuccesses) / Float(mediaReferences.count) if successRate > 0.5 { totalProcessSuccess() } else { errorHandler(UploadingError.tooManyFailures) } } // start in the background DispatchQueue.global().async { for mediaRef in mediaReferences { // alert the group that we're starting a process fullProcessDispatchGroup.enter() // wait until it's safe to start uploading outerLimit.wait() /// common cleanup operations needed later func finalizeMediaRef() { singleUploadCompletion(mediaRef) fullProcessDispatchGroup.leave() outerLimit.signal() } // wait until it's safe to start exporting exportLimit.wait() export(mediaRef) { (media, error) in // allow another export to begin exportLimit.signal() if let error = error { DispatchQueue.main.async { errorHandler(error) finalizeMediaRef() } } else { guard let media = media else { DispatchQueue.main.async { errorHandler(UploadingError.invalidResponse) finalizeMediaRef() } return } // the export was successful var uploadAttempts = 0 /// define the upload process and its retry behavior func doUpload() { // respect Media's threading requirements managedObjectContext.perform { upload(media) { error in if let error = error { if uploadAttempts < uploadRetries { uploadAttempts += 1 doUpload() // retry } else { DispatchQueue.main.async { // too many upload failures errorHandler(error) finalizeMediaRef() } } } else { DispatchQueue.main.async { uploadSuccesses += 1 singleUploadSuccess(media) finalizeMediaRef() } } } } } // kick off the first upload doUpload() } } } }

Woah! Fără comentarii, înseamnă aproximativ 75 de rânduri. Ai urmat raționamentul până la capăt? Cum te-ai simți dacă ai întâlni acest monstru în prima săptămână la o nouă slujbă? V-ați simți pregătit să îl întrețineți sau să îl modificați? Ai ști dacă conține erori? Contine erori?

Acum, luați în considerare alternativa HoneyBee:

 HoneyBee.start(on: DispatchQueue.main) .setErrorHandler(errorHandler) .insert(mediaReferences) .setBlockPerformer(DispatchQueue.global()) .each(limit: 4, acceptableFailure: .ratio(0.5)) { elem in elem.finally { link in link.setBlockPerformer(DispatchQueue.main) .chain(singleUploadCompletion) } .limit(1) { link in link.chain(export) } .setBlockPerformer(managedObjectContext) .retry(1) { link in link.chain(upload) // subject to transient failure } .setBlockPerformer(DispatchQueue.main) .chain(singleUploadSuccess) } .setBlockPerformer(DispatchQueue.main) .drop() .chain(totalProcessSuccess)

Cum te lovește acest formular? Să lucrăm peste el bucată cu bucată. Pe prima linie, începem rețeta HoneyBee, începând cu firul principal. Începând cu firul principal, ne asigurăm că toate erorile vor fi transmise errorHandler (linia 2) pe firul principal. Linia 3 inserează matricea mediaReferences în lanțul de proces. În continuare, trecem la coada globală de fundal în pregătirea pentru un anumit paralelism. Pe linia 5, începem o iterație paralelă peste fiecare dintre mediaReferences . Limităm acest paralelism la maximum 4 operații concurente. De asemenea, declarăm că iterația completă va fi considerată reușită dacă cel puțin jumătate din sublanțuri reușesc (nu eroați). Linia 6 declară o legătură finally care va fi numită indiferent dacă sublanțul de mai jos reușește sau eșuează. Pe linkul finally , trecem la firul principal (linia 7) și apelăm singleUploadCompletion (linia 8). Pe linia 10, am stabilit o paralelizare maximă de 1 (execuție unică) în jurul operațiunii de export (linia 11). Linia 13 comută la coada privată deținută de instanța noastră managedObjectContext . Linia 14 declară o singură încercare de reîncercare pentru operația de încărcare (linia 15). Linia 17 comută din nou la firul principal și 18 invocă singleUploadSuccess . Până în momentul în care va fi executată linia temporală 20, toate iterațiile paralele s-au finalizat. Dacă mai puțin de jumătate dintre iterații au eșuat, atunci linia 20 trece la coada principală pentru ultima dată (reamintim că fiecare a fost rulată în coada de fundal), 21 scade valoarea de intrare (încă mediaReferences ) și 22 invocă totalProcessSuccess .

Forma HoneyBee este mai clară, mai curată și mai ușor de citit, ca să nu mai vorbim de mai ușor de întreținut. Ce s-ar întâmpla cu forma lungă a acestui algoritm dacă bucla ar fi necesară pentru a reintegra obiectele Media într-o matrice ca o funcție de hartă? După ce ați făcut schimbarea, cât de sigur ați fi că toate cerințele algoritmului sunt încă îndeplinite? În forma HoneyBee, această modificare ar fi înlocuirea fiecăruia cu hartă pentru a folosi o funcție de hartă paralelă. (Da, are și reducere.)

HoneyBee este o bibliotecă puternică de viitor pentru Swift, care face scrierea algoritmilor asincroni și concurenți mai ușor, mai sigur și mai expresiv. În acest articol, am văzut cum HoneyBee vă poate face algoritmii mai ușor de întreținut, mai corecti și mai rapid. HoneyBee are, de asemenea, suport pentru alte paradigme asincrone cheie, cum ar fi suportul de reîncercare, gestionarea erorilor multiple, protejarea resurselor și procesarea colecțiilor (forme asincrone de hartă, filtrare și reducere). Pentru o listă completă a funcțiilor, consultați site-ul web. Pentru a afla mai multe sau pentru a pune întrebări, consultați noile forumuri ale comunității.

Anexă: Asigurarea corectitudinii contractuale a funcțiilor asincrone

Asigurarea corectitudinii contractuale a funcțiilor este un principiu fundamental al informaticii. Atât de mult încât aproape toate compilatoarele moderne au verificări pentru a se asigura că o funcție care declară că returnează o valoare returnează exact o dată. Returnarea mai puțin sau mai mult de o dată este tratată ca o eroare și previne în mod corespunzător o compilare completă.

Dar această asistență pentru compilator nu se aplică de obicei funcțiilor asincrone. Luați în considerare următorul exemplu (jucăuș):

 func generateIcecream(from int: Int, completion: (String) -> Void) { if int > 5 { if int < 20 { completion("Chocolate") } else if int < 10 { completion("Strawberry") } completion("Pistachio") } else if int < 2 { completion("Vanilla") } }

Funcția generateIcecream acceptă un Int și returnează în mod asincron un String. Compilatorul Swift acceptă cu plăcere forma de mai sus ca fiind corectă, deși conține unele probleme evidente. Având în vedere anumite intrări, această funcție poate apela finalizarea zero, una sau de două ori. Programatorii care au lucrat cu funcții asincrone își vor aminti adesea exemple ale acestei probleme în propria lor muncă. Ce putem face? Cu siguranță, am putea refactoriza codul pentru a fi mai ordonat (un comutator cu cazuri de gamă ar funcționa aici). Dar uneori complexitatea funcțională este greu de redus. Nu ar fi mai bine dacă compilatorul ne-ar putea ajuta să verificăm corectitudinea, așa cum o face cu funcțiile care returnează regulat?

Se pare că există o cale. Observați următoarea incantație Swifty:

 func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { completion("Chocolate") } else if int < 10 { completion("Strawberry") } // else completion("Pistachio") } else if int < 2 { completion("Vanilla") } }

Cele patru linii inserate în partea de sus a acestei funcții forțează compilatorul să verifice dacă callback-ul de finalizare este invocat exact o dată, ceea ce înseamnă că această funcție nu se mai compila. Ce se întâmplă? În prima linie, declarăm, dar nu inițializam rezultatul pe care vrem în cele din urmă să îl producă această funcție. Lăsând-o nedefinită, ne asigurăm că trebuie atribuită o dată înainte de a putea fi utilizată, iar declarând-o ne asigurăm că nu poate fi niciodată atribuită de două ori. A doua linie este o amânare care se va executa ca acțiune finală a acestei funcții. Invocă blocul de completare cu finalResult - după ce i-a fost atribuit de restul funcției. Linia 3 creează o nouă constantă numită completare care umbrește parametrul de apel invers. Noua completare este de tip Void care nu declară nicio API publică. Această linie asigură că orice utilizare a completării după această linie va fi o eroare a compilatorului. Amânarea pe linia 2 este singura utilizare permisă a blocului de completare. Linia 4 elimină un avertisment al compilatorului care altfel ar fi prezent despre noua constantă de completare neutilizată.

Așa că am forțat cu succes compilatorul rapid să raporteze că această funcție asincronă nu își îndeplinește contractul. Să parcurgem pașii pentru a-l corecta. În primul rând, să înlocuim tot accesul direct la apel invers cu o atribuire la finalResult .

 func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { finalResult = "Chocolate" } else if int < 10 { finalResult = "Strawberry" } // else finalResult = "Pistachio" } else if int < 2 { finalResult = "Vanilla" } }

Acum compilatorul raportează două probleme:

 error: AsyncCorrectness.playground:1:8: error: constant 'finalResult' used before being initialized defer { completion(finalResult) } ^ error: AsyncCorrectness.playground:11:3: error: immutable value 'finalResult' may only be initialized once finalResult = "Pistachio"

După cum era de așteptat, funcția are o cale în care finalResult este atribuită de zero ori și, de asemenea, o cale în care este atribuită de mai multe ori. Rezolvăm aceste probleme după cum urmează:

 func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { finalResult = "Chocolate" } else if int < 10 { finalResult = "Strawberry" } else { finalResult = "Pistachio" } } else if int < 2 { finalResult = "Vanilla" } else { finalResult = "Neapolitan" } }

„Fisticul” a fost mutat la o clauză else adecvată și ne dăm seama că nu am reușit să acoperim cazul general – care, desigur, este „napolitan”.

Modelele descrise recent pot fi ajustate cu ușurință pentru a returna valori opționale, erori opționale sau tipuri complexe, cum ar fi enumerarea obișnuită a rezultatelor. Constrângând compilatorul să verifice dacă apelurile inverse sunt invocate exact o dată, putem afirma corectitudinea și completitudinea funcțiilor asincrone.