Simultaneidade avançada em Swift com HoneyBee
Publicados: 2022-03-11Projetar, testar e manter algoritmos simultâneos no Swift é difícil e acertar os detalhes é fundamental para o sucesso do seu aplicativo. Um algoritmo simultâneo (também chamado de programação paralela) é um algoritmo projetado para executar várias (talvez muitas) operações ao mesmo tempo para aproveitar mais recursos de hardware e reduzir o tempo de execução geral.
Nas plataformas da Apple, a maneira tradicional de escrever algoritmos simultâneos é NSOperation. O design do NSOperation convida o programador a subdividir um algoritmo simultâneo em tarefas assíncronas individuais de longa duração. Cada tarefa seria definida em sua própria subclasse de NSOperation e as instâncias dessas classes seriam combinadas por meio de uma API objetiva para criar uma ordem parcial de tarefas em tempo de execução. Esse método de projetar algoritmos simultâneos foi o estado da arte nas plataformas da Apple por sete anos.
Em 2014, a Apple introduziu o Grand Central Dispatch (GCD) como um avanço dramático na expressão de operações simultâneas. O GCD, juntamente com os novos blocos de recursos de linguagem que o acompanhavam e alimentavam, forneceram uma maneira de descrever de forma compacta um manipulador de resposta assíncrona imediatamente após o início da solicitação assíncrona. Os programadores não eram mais incentivados a difundir a definição de tarefas simultâneas em vários arquivos em várias subclasses de NSOperation. Agora, um algoritmo concorrente inteiro poderia ser escrito dentro de um único método. Esse aumento na expressividade e segurança do tipo foi uma mudança conceitual significativa para a frente. Um algoritmo típico dessa maneira de escrever pode ter a seguinte aparência:
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) } } } } }
Vamos quebrar esse algoritmo um pouco. A função processImageData
é uma função assíncrona que faz quatro chamadas assíncronas próprias para concluir seu trabalho. As quatro invocações assíncronas são aninhadas uma dentro da outra da maneira mais natural para o tratamento assíncrono baseado em bloco. Cada um dos blocos de resultados tem um parâmetro Error opcional e todos, exceto um, contêm um parâmetro opcional adicional que significa o resultado da operação aysnc.
A forma do bloco de código acima provavelmente parece familiar para a maioria dos desenvolvedores Swift. Mas o que há de errado com essa abordagem? A lista de pontos problemáticos a seguir provavelmente será igualmente familiar.
- Essa forma de “pirâmide da destruição” de blocos de código aninhados pode se tornar rapidamente difícil de manejar. O que acontece se adicionarmos mais duas operações assíncronas? Quatro? E as operações condicionais? Que tal repetir o comportamento ou proteções para limites de recursos? O código do mundo real nunca é tão limpo e simples quanto exemplos em postagens de blog. O efeito “pirâmide da desgraça” pode facilmente resultar em código difícil de ler, difícil de manter e propenso a bugs.
- A tentativa de tratamento de erros no exemplo acima, embora Swifty, é de fato incompleta. O programador assumiu que os blocos de retorno de chamada assíncronos estilo Objective-C de dois parâmetros sempre fornecerão um dos dois parâmetros; eles nunca serão ambos nulos ao mesmo tempo. Esta não é uma suposição segura. Algoritmos simultâneos são conhecidos por serem difíceis de escrever e depurar, e suposições infundadas são parte do motivo. O tratamento de erros completo e correto é uma necessidade inescapável para qualquer algoritmo concorrente que pretenda operar no mundo real.
- Levando esse pensamento ainda mais longe, talvez o programador que escreveu as chamadas funções assíncronas não tivesse tantos princípios quanto você. E se houver condições sob as quais as funções chamadas não retornem? Ou ligue de volta mais de uma vez? O que acontece com a correção de processImageData nessas circunstâncias? Os profissionais não se arriscam. As funções de missão crítica precisam estar corretas mesmo quando dependem de funções escritas por terceiros.
- Talvez o mais atraente, o algoritmo assíncrono considerado é construído de forma subótima. As duas primeiras operações assíncronas são downloads de recursos remotos. Apesar de não terem interdependência, o algoritmo acima executa os downloads sequencialmente e não em paralelo. As razões para isso são óbvias; a sintaxe do bloco aninhado encoraja tal desperdício. Mercados competitivos não toleram atrasos desnecessários. Se seu aplicativo não realizar suas operações assíncronas o mais rápido possível, outro aplicativo o fará.
Como podemos fazer melhor? HoneyBee é uma biblioteca de futuros/promessas que torna a programação simultânea do Swift fácil, expressiva e segura. Vamos reescrever o algoritmo assíncrono acima com HoneyBee e examinar o resultado:
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) } }
A primeira linha que esta implementação inicia é uma nova receita HoneyBee. A segunda linha estabelece o manipulador de erros padrão. O tratamento de erros não é opcional nas receitas HoneyBee. Se algo pode dar errado, o algoritmo deve lidar com isso. A terceira linha abre uma ramificação que permite a execução paralela. As duas cadeias de loadWebResource
serão executadas em paralelo e seus resultados serão combinados (linha 5). Os valores combinados dos dois recursos carregados são encaminhados para decodeImage
e assim por diante na cadeia até que a conclusão seja chamada.
Vamos percorrer a lista acima de pontos problemáticos e ver como a HoneyBee melhorou esse código. Manter esta função agora é significativamente mais fácil. A receita HoneyBee se parece com o algoritmo que ela expressa. O código é legível, compreensível e rapidamente modificável. O design do HoneyBee garante que qualquer ordem incorreta de instruções resulte em um erro de tempo de compilação, não em um erro de tempo de execução. A função agora é muito menos suscetível a bugs e erros humanos.
Todos os possíveis erros de tempo de execução foram totalmente tratados. Cada assinatura de função que o HoneyBee suporta (existem 38 delas) é garantida para ser totalmente tratada. Em nosso exemplo, o retorno de chamada de dois parâmetros no estilo Objective-C produzirá um erro não nulo que será roteado para o manipulador de erros ou produzirá um valor não nulo que progredirá na cadeia, ou então se ambos os valores são nil HoneyBee irá gerar um erro explicando que o retorno de chamada da função não está cumprindo seu contrato.
HoneyBee também lida com a correção contratual para o número de vezes que os retornos de chamada de função são invocados. Se uma função não invocar seu retorno de chamada, HoneyBee produz uma falha descritiva. Se a função invocar seu retorno de chamada mais de uma vez, o HoneyBee suprimirá as invocações auxiliares e os avisos de log. Ambas as respostas a falhas (e outras) podem ser personalizadas para as necessidades individuais do programador.
Esperançosamente, já deve estar claro que essa forma de processImageData
paraleliza adequadamente os downloads de recursos para fornecer um desempenho ideal. Um dos objetivos de design mais fortes da HoneyBee é que a receita se pareça com o algoritmo que ela expressa.
Muito melhor. Certo? Mas HoneyBee tem muito mais a oferecer.
Esteja avisado: o próximo estudo de caso não é para os fracos de coração. Considere a seguinte descrição do problema: Seu aplicativo móvel usa CoreData
para manter seu estado. Você tem um modelo NSManagedObject
chamado Media, que representa um ativo de mídia carregado em seu servidor back-end. O usuário deve ter permissão para selecionar dezenas de itens de mídia de uma só vez e carregá-los em lote para o sistema de back-end. As mídias são representadas primeiro por meio de uma String de referência, que deve ser convertida em um objeto Media. Felizmente, seu aplicativo já contém um método auxiliar que faz exatamente isso:
func export(_ mediaRef: String, completion: @escaping (Media?, Error?) -> Void) { // transcoding stuff completion(Media(context: managedObjectContext), nil) }
Depois que a referência de mídia for convertida em um objeto de mídia, você deverá carregar o item de mídia no back-end. Novamente, você tem uma função auxiliar pronta para fazer as coisas da rede.
func upload(_ media: Media, completion: @escaping (Error?) -> Void) { // network stuff completion(nil) }
Como o usuário pode selecionar dezenas de itens de mídia de uma só vez, o designer de UX especificou uma quantidade bastante robusta de feedback sobre o progresso do upload. Os requisitos foram destilados nas quatro funções a seguir:
/// 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 }
No entanto, como seu aplicativo fornece referências de mídia que às vezes expiram, os gerentes de negócios decidiram enviar ao usuário uma mensagem de "sucesso" se pelo menos metade dos uploads forem bem-sucedidos. Isso quer dizer que o processo simultâneo deve declarar vitória — e chamar totalProcessSuccess
— se menos da metade das tentativas de upload falharem. Esta é a especificação entregue a você como desenvolvedor. Mas como um programador experiente, você percebe que há mais requisitos que devem ser aplicados.
Obviamente, o Business deseja que o upload em lote aconteça o mais rápido possível, portanto, o upload em série está fora de questão. Os uploads devem ser realizados em paralelo.
Mas não muito. Se você apenas async
indiscriminadamente todo o lote, as dezenas de uploads simultâneos inundarão a NIC móvel (placa de interface de rede) e os uploads serão realmente mais lentos do que em série, não mais rápidos.
As conexões de rede móvel não são consideradas estáveis. Mesmo transações curtas podem falhar devido apenas a alterações na conectividade de rede. Para declarar realmente que um upload falhou, precisaremos tentar novamente pelo menos uma vez.
A política de repetição não deve incluir a operação de exportação porque não está sujeita a falhas transitórias.
O processo de exportação é vinculado à computação e, portanto, deve ser executado fora do encadeamento principal.
Como a exportação é vinculada à computação, ela deve ter um número menor de instâncias simultâneas do que o restante do processo de upload para evitar sobrecarregar o processador.
As quatro funções de retorno de chamada descritas acima atualizam a interface do usuário e, portanto, todas devem ser chamadas no encadeamento principal.
Media é um NSManagedObject
, que vem de um NSManagedObjectContext
e tem seus próprios requisitos de encadeamento que devem ser respeitados.

Esta especificação do problema parece um pouco obscura? Não se surpreenda se você encontrar problemas como esse à espreita em seu futuro. Eu encontrei um assim em meu próprio trabalho. Vamos primeiro tentar resolver este problema com ferramentas tradicionais. Aperte o cinto, isso não vai ser bonito.
/// 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() } } } }
Uau! Sem comentários, são cerca de 75 linhas. Você seguiu o raciocínio até o fim? Como você se sentiria se encontrasse esse monstro em sua primeira semana em um novo emprego? Você se sentiria pronto para mantê-lo ou modificá-lo? Você saberia se continha erros? Contém erros?
Agora, considere a 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)
Como essa forma te impressiona? Vamos trabalhar com isso peça por peça. Na primeira linha, iniciamos a receita HoneyBee, começando no thread principal. Começando no encadeamento principal, garantimos que todos os erros serão passados para errorHandler (linha 2) no encadeamento principal. A linha 3 insere o array mediaReferences
na cadeia do processo. Em seguida, mudamos para a fila de segundo plano global em preparação para algum paralelismo. Na linha 5, começamos uma iteração paralela sobre cada um dos mediaReferences
. Limitamos esse paralelismo a um máximo de 4 operações simultâneas. Também declaramos que a iteração completa será considerada bem-sucedida se pelo menos metade das subcadeias for bem-sucedida (não cometer erros). A linha 6 declara um link finally
que será chamado se a subcadeia abaixo for bem-sucedida ou falhar. No link finally
, mudamos para o thread principal (linha 7) e chamamos singleUploadCompletion
(linha 8). Na linha 10, definimos uma paralelização máxima de 1 (execução única) em torno da operação de exportação (linha 11). A linha 13 muda para a fila privada de propriedade de nossa instância managedObjectContext
. A linha 14 declara uma única tentativa de repetição para a operação de upload (linha 15). A linha 17 muda para o encadeamento principal mais uma vez e a 18 invoca singleUploadSuccess
. Quando a linha do tempo 20 for executada, todas as iterações paralelas foram concluídas. Se menos da metade das iterações falharem, a linha 20 alternará para a fila principal uma última vez (lembre-se de que cada uma foi executada na fila em segundo plano), 21 descartará o valor de entrada (ainda mediaReferences
) e 22 totalProcessSuccess
.
O formulário HoneyBee é mais claro, limpo e fácil de ler, para não mencionar mais fácil de manter. O que aconteceria com a forma longa desse algoritmo se o loop fosse necessário para reintegrar os objetos de mídia em uma matriz como uma função de mapa? Depois de ter feito a mudança, quão confiante você estaria de que todos os requisitos do algoritmo ainda estavam sendo atendidos? No formulário HoneyBee, essa mudança seria substituir cada um por map para empregar uma função de mapa paralelo. (Sim, também reduziu.)
HoneyBee é uma poderosa biblioteca de futuros para Swift que torna a escrita de algoritmos assíncronos e simultâneos mais fácil, segura e expressiva. Neste artigo, vimos como o HoneyBee pode tornar seus algoritmos mais fáceis de manter, mais corretos e mais rápidos. HoneyBee também tem suporte para outros paradigmas assíncronos importantes, como suporte a novas tentativas, vários manipuladores de erros, proteção de recursos e processamento de coleção (formas assíncronas de mapa, filtro e redução). Para obter uma lista completa de recursos, consulte o site. Para saber mais ou fazer perguntas, consulte os novos fóruns da comunidade.
Apêndice: Garantindo a Correção Contratual das Funções Assíncronas
Garantir a exatidão contratual das funções é um princípio fundamental da ciência da computação. Tanto que praticamente todos os compiladores modernos têm verificações para garantir que uma função que declara retornar um valor, retorne exatamente uma vez. Retornar menos ou mais de uma vez é tratado como um erro e impede adequadamente uma compilação completa.
Mas essa assistência do compilador geralmente não se aplica a funções assíncronas. Considere o seguinte exemplo (lúdico):
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") } }
A função generateIcecream
aceita um Int e retorna de forma assíncrona um String. O compilador rápido aceita alegremente o formulário acima como correto, embora contenha alguns problemas óbvios. Dadas certas entradas, essa função pode chamar a conclusão zero, uma ou duas vezes. Os programadores que trabalharam com funções assíncronas geralmente lembrarão de exemplos desse problema em seu próprio trabalho. O que podemos fazer? Certamente, poderíamos refatorar o código para ficar mais organizado (uma opção com casos de intervalo funcionaria aqui). Mas às vezes a complexidade funcional é difícil de reduzir. Não seria melhor se o compilador pudesse nos ajudar a verificar a exatidão como faz com funções que retornam regularmente?
Acontece que há um caminho. Observe o seguinte encantamento 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") } }
As quatro linhas inseridas na parte superior dessa função forçam o compilador a verificar se o retorno de chamada de conclusão é invocado exatamente uma vez, o que significa que essa função não compila mais. O que está acontecendo? Na primeira linha, declaramos, mas não inicializamos o resultado que queremos que essa função produza. Ao deixá-lo indefinido, garantimos que ele deve ser atribuído uma vez antes de poder ser usado e, ao declará-lo, garantimos que ele nunca poderá ser atribuído duas vezes. A segunda linha é um adiamento que será executado como a ação final desta função. Ele invoca o bloco de conclusão com finalResult
- depois de ter sido atribuído pelo resto da função. A linha 3 cria uma nova constante chamada conclusão que oculta o parâmetro de retorno de chamada. A nova conclusão é do tipo Void que não declara nenhuma API pública. Essa linha garante que qualquer uso de conclusão após essa linha será um erro do compilador. O adiamento na linha 2 é o único uso permitido do bloco de conclusão. A linha 4 remove um aviso do compilador que, de outra forma, estaria presente sobre a nova constante de conclusão não ser usada.
Portanto, forçamos com sucesso o compilador rápido a relatar que essa função assíncrona não está cumprindo seu contrato. Vamos percorrer as etapas para torná-lo correto. Primeiro, vamos substituir todo o acesso direto ao retorno de chamada por uma atribuição 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" } }
Agora o compilador está relatando dois problemas:
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"
Como esperado, a função tem um caminho onde finalResult
é atribuído zero vezes e também um caminho onde é atribuído mais de uma vez. Resolvemos esses problemas da seguinte forma:
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" } }
O “Pistachio” foi movido para uma cláusula else adequada e percebemos que falhamos em cobrir o caso geral – que, claro, é “Napolitan”.
Os padrões descritos podem ser facilmente ajustados para retornar valores opcionais, erros opcionais ou tipos complexos como a enumeração Result comum. Ao forçar o compilador a verificar se os retornos de chamada são invocados exatamente uma vez, podemos afirmar a exatidão e integridade das funções assíncronas.