Concorrenza avanzata in Swift con HoneyBee

Pubblicato: 2022-03-11

Progettare, testare e mantenere algoritmi simultanei in Swift è difficile e ottenere i dettagli giusti è fondamentale per il successo della tua app. Un algoritmo simultaneo (chiamato anche programmazione parallela) è un algoritmo progettato per eseguire più (forse molte) operazioni contemporaneamente per sfruttare più risorse hardware e ridurre il tempo di esecuzione complessivo.

Sulle piattaforme Apple, il modo tradizionale per scrivere algoritmi simultanei è NSOperation. Il design di NSOperation invita il programmatore a suddividere un algoritmo simultaneo in singole attività asincrone di lunga durata. Ogni attività verrebbe definita nella propria sottoclasse di NSOperation e le istanze di tali classi verrebbero combinate tramite un'API obiettivo per creare un ordine parziale di attività in fase di esecuzione. Questo metodo di progettazione di algoritmi simultanei è stato lo stato dell'arte sulle piattaforme Apple per sette anni.

Nel 2014 Apple ha introdotto Grand Central Dispatch (GCD) come un drammatico passo avanti nell'espressione delle operazioni simultanee. GCD, insieme ai nuovi blocchi di funzionalità del linguaggio che lo hanno accompagnato e alimentato, ha fornito un modo per descrivere in modo compatto un gestore di risposta asincrono immediatamente dopo l'avvio della richiesta asincrona. I programmatori non erano più incoraggiati a diffondere la definizione di attività simultanee su più file in numerose sottoclassi di NSOperation. Ora, un intero algoritmo simultaneo potrebbe essere scritto in modo fattibile all'interno di un singolo metodo. Questo aumento dell'espressività e della sicurezza del tipo è stato un significativo spostamento concettuale in avanti. Un algoritmo tipico di questo modo di scrivere potrebbe essere il seguente:

 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) } } } } }

Analizziamo un po' questo algoritmo. La funzione processImageData è una funzione asincrona che effettua quattro proprie chiamate asincrone per completare il proprio lavoro. Le quattro chiamate asincrone sono nidificate una dentro l'altra nel modo più naturale per la gestione asincrona basata su blocchi. I blocchi di risultati hanno ciascuno un parametro Error facoltativo e tutti tranne uno contengono un parametro facoltativo aggiuntivo che indica il risultato dell'operazione aysnc.

La forma del blocco di codice sopra sembra probabilmente familiare alla maggior parte degli sviluppatori Swift. Ma cosa c'è di sbagliato in questo approccio? Il seguente elenco di punti dolenti sarà probabilmente ugualmente familiare.

  • Questa forma a "piramide del destino" di blocchi di codice nidificati può diventare rapidamente ingombrante. Cosa succede se aggiungiamo altre due operazioni asincrone? Quattro? E le operazioni condizionali? Che ne dici del comportamento dei tentativi o delle protezioni per i limiti delle risorse? Il codice del mondo reale non è mai così pulito e semplice come gli esempi nei post del blog. L'effetto "piramide del destino" può facilmente risultare in codice difficile da leggere, difficile da mantenere e soggetto a bug.
  • Il tentativo di gestione degli errori nell'esempio sopra, sebbene Swifty, è in realtà incompleto. Il programmatore ha presupposto che i blocchi di callback asincrono a due parametri in stile Objective-C forniscano sempre uno dei due parametri; non saranno mai entrambi nulli allo stesso tempo. Questo non è un presupposto sicuro. Gli algoritmi simultanei sono famosi per essere difficili da scrivere ed eseguire il debug e le ipotesi infondate sono parte del motivo. La gestione completa e corretta degli errori è una necessità inevitabile per qualsiasi algoritmo simultaneo che intenda operare nel mondo reale.
  • Portando questo pensiero ancora oltre, forse il programmatore che ha scritto le funzioni asincrone chiamate non era di principi come te. Cosa succede se ci sono condizioni in cui le funzioni chiamate non riescono a richiamare? O richiamare più di una volta? Cosa succede alla correttezza di processImageData in queste circostanze? I professionisti non corrono rischi. Le funzioni mission-critical devono essere corrette anche quando si basano su funzioni scritte da terze parti.
  • Forse il più interessante, l'algoritmo asincrono considerato è costruito in modo non ottimale. Le prime due operazioni asincrone sono entrambe download di risorse remote. Anche se non hanno interdipendenza, l'algoritmo di cui sopra esegue i download in sequenza e non in parallelo. Le ragioni di ciò sono ovvie; la sintassi del blocco annidato incoraggia tale spreco. I mercati competitivi non tollerano inutili ritardi. Se la tua app non esegue le sue operazioni asincrone il più rapidamente possibile, lo farà un'altra app.

Come possiamo fare di meglio? HoneyBee è una libreria di futures/promesse che rende la programmazione simultanea di Swift facile, espressiva e sicura. Riscriviamo l'algoritmo asincrono sopra con HoneyBee ed esaminiamo il risultato:

 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) } }

La prima riga che inizia questa implementazione è una nuova ricetta HoneyBee. La seconda riga stabilisce il gestore degli errori predefinito. La gestione degli errori non è facoltativa nelle ricette HoneyBee. Se qualcosa può andare storto, l'algoritmo deve gestirlo. La terza riga apre un ramo che consente l'esecuzione parallela. Le due catene di loadWebResource verranno eseguite in parallelo ei loro risultati verranno combinati (riga 5). I valori combinati delle due risorse caricate vengono inoltrati a decodeImage e così via lungo la catena fino a quando non viene richiamato il completamento.

Esaminiamo l'elenco di punti dolenti sopra e vediamo come HoneyBee ha migliorato questo codice. Il mantenimento di questa funzione è ora notevolmente più semplice. La ricetta HoneyBee assomiglia all'algoritmo che esprime. Il codice è leggibile, comprensibile e rapidamente modificabile. Il design di HoneyBee garantisce che qualsiasi ordinamento errato delle istruzioni si traduca in un errore in fase di compilazione, non in un errore di runtime. La funzione ora è molto meno suscettibile a bug ed errori umani.

Tutti i possibili errori di runtime sono stati completamente gestiti. Ogni firma di funzione supportata da HoneyBee (ce ne sono 38) è garantita per essere completamente gestita. Nel nostro esempio, il callback a due parametri in stile Objective-C produrrà un errore non nullo che verrà indirizzato al gestore degli errori, oppure produrrà un valore non nullo che avanzerà lungo la catena, oppure se entrambi i valori sono nil HoneyBee genererà un errore che spiega che la funzione di callback non soddisfa il suo contratto.

HoneyBee gestisce anche la correttezza contrattuale per il numero di volte in cui vengono richiamate le funzioni di callback. Se una funzione non riesce a richiamare il suo callback, HoneyBee produce un errore descrittivo. Se la funzione richiama la sua richiamata più di una volta, HoneyBee eliminerà le chiamate ausiliarie e gli avvisi di registro. Entrambe queste risposte all'errore (e altre) possono essere personalizzate in base alle esigenze individuali del programmatore.

Si spera che dovrebbe già essere evidente che questa forma di processImageData parallelizza correttamente i download delle risorse per fornire prestazioni ottimali. Uno degli obiettivi di progettazione più importanti di HoneyBee è che la ricetta assomigli all'algoritmo che esprime.

Molto meglio. Destra? Ma HoneyBee ha molto di più da offrire.

Attenzione: il prossimo caso di studio non è per i deboli di cuore. Considera la seguente descrizione del problema: la tua app mobile usa CoreData per mantenere il suo stato. Hai un modello NSManagedObject chiamato Media, che rappresenta una risorsa multimediale caricata sul tuo server back-end. L'utente deve poter selezionare dozzine di elementi multimediali contemporaneamente e caricarli in batch sul sistema di back-end. I media vengono prima rappresentati tramite una stringa di riferimento, che deve essere convertita in un oggetto Media. Fortunatamente, la tua app contiene già un metodo di supporto che fa proprio questo:

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

Dopo che il riferimento multimediale è stato convertito in un oggetto multimediale, è necessario caricare l'elemento multimediale nel back-end. Ancora una volta hai una funzione di supporto pronta per fare le cose di rete.

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

Poiché l'utente può selezionare dozzine di elementi multimediali contemporaneamente, il designer dell'esperienza utente ha specificato una quantità abbastanza consistente di feedback sull'avanzamento del caricamento. I requisiti sono stati distillati nelle seguenti quattro funzioni:

 /// 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 }

Tuttavia, poiché la tua app genera riferimenti multimediali che a volte sono scaduti, i dirigenti aziendali hanno deciso di inviare all'utente un messaggio di "successo" se almeno la metà dei caricamenti ha esito positivo. Vale a dire, che il processo simultaneo dovrebbe dichiarare vittoria e chiamare totalProcessSuccess se meno della metà dei tentativi di caricamento falliscono. Questa è la specifica che ti è stata data come sviluppatore. Ma come programmatore esperto, ti rendi conto che ci sono più requisiti che devono essere applicati.

Naturalmente, Business vuole che il caricamento batch avvenga il più rapidamente possibile, quindi il caricamento seriale è fuori questione. I caricamenti devono essere eseguiti in parallelo.

Ma non troppo. Se async indiscriminatamente l'intero batch, le dozzine di caricamenti simultanei inonderanno la NIC mobile (scheda di interfaccia di rete) e i caricamenti procederanno effettivamente più lentamente rispetto a quelli seriali, non più veloci.

Le connessioni di rete mobile non sono considerate stabili. Anche le transazioni brevi potrebbero non riuscire a causa solo di modifiche nella connettività di rete. Per dichiarare veramente che un caricamento non è riuscito, dovremo riprovare il caricamento almeno una volta.

La politica dei tentativi non dovrebbe includere l'operazione di esportazione perché non è soggetta a errori temporanei.

Il processo di esportazione è vincolato al calcolo e pertanto deve essere eseguito al di fuori del thread principale.

Poiché l'esportazione è vincolata al calcolo, dovrebbe avere un numero inferiore di istanze simultanee rispetto al resto del processo di caricamento per evitare il sovraccarico del processore.

Le quattro funzioni di callback sopra descritte aggiornano tutte l'interfaccia utente, quindi devono essere tutte richiamate sul thread principale.

Media è un NSManagedObject , che proviene da un NSManagedObjectContext e ha i propri requisiti di threading che devono essere rispettati.

Questa specifica del problema sembra un po 'oscura? Non sorprenderti se trovi problemi come questo in agguato nel tuo futuro. Ne ho incontrato uno come questo nel mio stesso lavoro. Proviamo innanzitutto a risolvere questo problema con gli strumenti tradizionali. Allaccia le cinture, non sarà carino.

 /// 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! Senza commenti, sono circa 75 righe. Hai seguito il ragionamento fino in fondo? Come ti sentiresti se incontrassi questo mostro la prima settimana in un nuovo lavoro? Ti sentiresti pronto a mantenerlo o modificarlo? Sapresti se conteneva errori? Contiene errori?

Ora, considera l'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)

Come ti colpisce questo modulo? Esaminiamolo pezzo per pezzo. Nella prima riga, iniziamo la ricetta HoneyBee, iniziando dal thread principale. Iniziando dal thread principale assicuriamo che tutti gli errori vengano passati a errorHandler (riga 2) sul thread principale. La riga 3 inserisce l'array mediaReferences nella catena del processo. Successivamente, passiamo alla coda in background globale in preparazione per un certo parallelismo. Alla riga 5, iniziamo un'iterazione parallela su ciascuno dei mediaReferences . Limitiamo questo parallelismo a un massimo di 4 operazioni simultanee. Dichiariamo inoltre che l'intera iterazione sarà considerata riuscita se almeno la metà delle sottocatene avrà esito positivo (non commettere errori). La riga 6 dichiara un collegamento finally che verrà chiamato se la sottocatena sottostante ha esito positivo o negativo. Sul collegamento finally , passiamo al thread principale (riga 7) e chiamiamo singleUploadCompletion (riga 8). Alla riga 10, impostiamo una parallelizzazione massima di 1 (esecuzione singola) attorno all'operazione di esportazione (riga 11). La riga 13 passa alla coda privata di proprietà della nostra istanza managedObjectContext . La riga 14 dichiara un singolo tentativo di ripetizione per l'operazione di caricamento (riga 15). La riga 17 passa nuovamente al thread principale e la 18 richiama singleUploadSuccess . Quando la linea temporale 20 sarebbe stata eseguita, tutte le iterazioni parallele sono state completate. Se meno della metà delle iterazioni ha avuto esito negativo, la riga 20 passa alla coda principale un'ultima volta (ricorda che ciascuna è stata eseguita nella coda in background), 21 elimina il valore in entrata (sempre mediaReferences ) e 22 invoca totalProcessSuccess .

Il modulo HoneyBee è più chiaro, più pulito e più facile da leggere, per non parlare di più facile da mantenere. Cosa accadrebbe alla forma lunga di questo algoritmo se il ciclo fosse necessario per reintegrare gli oggetti Media in un array come una funzione di mappa? Dopo aver apportato la modifica, quanto saresti sicuro che tutti i requisiti dell'algoritmo fossero ancora soddisfatti? Nella forma HoneyBee, questa modifica consisterebbe nel sostituire ciascuno con una mappa per utilizzare una funzione di mappa parallela. (Sì, ha anche ridotto.)

HoneyBee è una potente libreria futures per Swift che rende la scrittura di algoritmi asincroni e simultanei più facile, più sicura e più espressiva. In questo articolo, abbiamo visto come HoneyBee può rendere i tuoi algoritmi più facili da mantenere, più corretti e più veloci. HoneyBee supporta anche altri paradigmi asincroni chiave come il supporto per i tentativi, gestori di errori multipli, protezione delle risorse ed elaborazione della raccolta (forme asincrone di mappa, filtro e riduzione). Per un elenco completo delle funzionalità fare riferimento al sito Web. Per saperne di più o porre domande, visita i nuovissimi forum della community.

Appendice: Garantire la correttezza contrattuale delle funzioni asincrone

Garantire la correttezza contrattuale delle funzioni è un principio fondamentale dell'informatica. Tanto che praticamente tutti i compilatori moderni hanno controlli per garantire che una funzione che dichiara di restituire un valore, restituisca esattamente una volta. Restituire meno o più di una volta viene considerato un errore e impedisce opportunamente una compilazione completa.

Ma questa assistenza del compilatore di solito non si applica alle funzioni asincrone. Considera il seguente esempio (giocoso):

 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") } }

La funzione generateIcecream accetta un Int e restituisce in modo asincrono una String. Il compilatore swift accetta felicemente il modulo precedente come corretto, anche se contiene alcuni problemi evidenti. Dati determinati input, questa funzione potrebbe chiamare il completamento zero, una o due volte. I programmatori che hanno lavorato con funzioni asincrone spesso ricorderanno esempi di questo problema nel proprio lavoro. Cosa possiamo fare? Certamente, potremmo rifattorizzare il codice in modo che sia più ordinato (un'opzione con casi di intervallo funzionerebbe qui). Ma a volte la complessità funzionale è difficile da ridurre. Non sarebbe meglio se il compilatore potesse aiutarci a verificare la correttezza proprio come fa con le funzioni che ritornano regolarmente?

Si scopre che c'è un modo. Osserva il seguente incantesimo 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") } }

Le quattro righe inserite all'inizio di questa funzione obbligano il compilatore a verificare che il callback di completamento venga richiamato esattamente una volta, il che significa che questa funzione non viene più compilata. Cosa sta succedendo? Nella prima riga, dichiariamo ma non inizializziamo il risultato che in definitiva vogliamo che questa funzione produca. Lasciandolo indefinito ci assicuriamo che debba essere assegnato a una volta prima che possa essere utilizzato e dichiarandolo ci assicuriamo che non possa mai essere assegnato a due volte. La seconda riga è un differimento che verrà eseguito come azione finale di questa funzione. Richiama il blocco di completamento con finalResult , dopo che è stato assegnato dal resto della funzione. La riga 3 crea una nuova costante denominata completamento che oscura il parametro di richiamata. Il nuovo completamento è di tipo Void che non dichiara alcuna API pubblica. Questa riga garantisce che qualsiasi utilizzo del completamento dopo questa riga sarà un errore del compilatore. Il differimento sulla riga 2 è l'unico uso consentito del blocco di completamento. La riga 4 rimuove un avviso del compilatore che sarebbe altrimenti presente sulla nuova costante di completamento non utilizzata.

Quindi abbiamo forzato con successo il compilatore swift a segnalare che questa funzione asincrona non sta rispettando il suo contratto. Esaminiamo i passaggi per renderlo corretto. Innanzitutto, sostituiamo tutto l'accesso diretto alla richiamata con un'assegnazione a 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" } }

Ora il compilatore segnala due problemi:

 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"

Come previsto, la funzione ha un percorso in cui finalResult viene assegnato zero volte e anche un percorso in cui viene assegnato più di una volta. Risolviamo questi problemi come segue:

 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" } }

Il "Pistacchio" è stato spostato in una clausola else e ci rendiamo conto che non siamo riusciti a coprire il caso generale, che ovviamente è "napoletano".

I modelli appena descritti possono essere facilmente modificati per restituire valori facoltativi, errori facoltativi o tipi complessi come il comune Result enum. Costringendo il compilatore a verificare che i callback vengano invocati esattamente una volta, possiamo affermare la correttezza e la completezza delle funzioni asincrone.