Concorrência no Swift
A introdução dos conceitos de Task, async await e Actors no Swift revolucionou o desenvolvimento de código que utiliza multithreading e execução assíncrona. Eles oferecem uma abordagem muito mais simples e eficiente em comparação com o uso de RunLoop, DispatchQueue, DispatchGroup, OperationQueue e Thread. Com as novas ferramentas de código concorrente no Swift, é fácil substituir esses objetos e melhorar significativamente a qualidade do código.
Task
A Task no Swift é uma operação assíncrona que oferece uma alternativa aos métodos tradicionais, como DispatchQueue.main.async {}
. Com o uso de Task, podemos simplificar nosso código e obter um melhor controle sobre as operações assíncronas. Além disso, a Task oferece opções adicionais que podem ser exploradas para aprimorar ainda mais a execução de tarefas.
Uma dessas opções é o parâmetro priority
, que nos permite definir a prioridade da tarefa a ser executada. Essa funcionalidade substitui a necessidade de utilizar .global(qos:)
do GCD, tornando o código mais legível e conciso.
Outra opção interessante é o uso do atributo @MainActor
, que substitui o DispatchQueue.main
. Esse atributo permite que a tarefa seja executada no thread principal, simplificando o código que lida com atualizações de interface do usuário e evitando problemas de concorrência.
func oldConcurrencyCode() {
DispatchQueue.main.async {
// Runs on main thread asynchronously
}
OperationQueue.main.addOperation {
// Runs on main thread queued
}
DispatchQueue.global(qos: .background).async {
// Runs on global thread asynchronously
}
let operationQueue = OperationQueue()
operationQueue.qualityOfService = .background
operationQueue.addOperation {
// Runs on thread queued
}
}
O código acima mostra algumas as opções válidas para executar códigos concorrentes no Swift.
A versão aprimorada do código abaixo utiliza a Task para implementar as mesmas funcionalidades do código anterior, tornando-o mais simples e compreensível para os desenvolvedores:
func newConcurrencyCode() {
Task {
// Executes asynchronously on the main thread
// Depending on whether the newConcurrencyCode function
// is implemented by an object protected by MainActor previously
}
Task { @MainActor in
// Executes asynchronously on the main thread
// explicitly
}
Task(priority: .background) {
// Executes asynchronously on any thread
// with background priority
}
}
Além das funcionalidades mencionadas, a Task oferece recursos adicionais que podem ser explorados. Por exemplo, é possível cancelar uma tarefa utilizando o método cancel()
. Isso permite interromper a execução de uma tarefa em andamento, fornecendo mais controle sobre o fluxo de execução assíncrona.
Outro recurso interessante é a capacidade de criar tarefas totalmente desacopladas do fluxo principal, substituindo o uso de Thread {}
por Task.detached {}
. Essa abordagem oferece uma alternativa mais moderna e segura para lidar com execução em threads separadas.
Ao aproveitar todas essas funcionalidades fornecidas pela Task, podemos simplificar o código, obter um controle mais refinado e aprimorar a eficiência do nosso código concorrente.
Async await
O async/await é um recurso fundamental do Swift para lidar com código concorrente. No entanto, antes de começar a utilizar o async/await, é importante compreender o conceito de Task. Esses termos, async e await, são utilizados em contextos distintos. O async é usado para indicar que uma função ou propriedade computada retorna seu resultado de forma assíncrona, enquanto o await é utilizado para aguardar o resultado ao chamar funções assíncronas.
A utilização do await
é restrita a funções do tipo async
. Além disso, é possível combinar o uso do throws
para implementar funções que são assíncronas e podem gerar erros de forma concisa. Também é possível especificar que uma função deve ser executada em um actor específico, como o @MainActor
.
Veja o exemplo a seguir, que ilustra como fazer uma solicitação assíncrona e atualizar as visualizações:
func doSomeRequest() async throws -> MyModel {
// Faz a solicitação e lança um erro, caso ocorra algum.
}
@MainActor
func makesRequestAndUpdateViews() async {
do {
let model = try await doSomeRequest()
view.updateModel(model)
} catch {
view.switchErrorState(error)
}
}
O exemplo acima, embora simples, demonstra a facilidade de realizar uma solicitação assíncrona com o async/await
e atualizar a view. Normalmente, uma implementação desse tipo exigiria o uso de callbacks em conjunto com DispatchQueue
para troca de threads. Além disso, se fosse necessário tratar erros da mesma forma que no exemplo, seria necessário usar Result<MyModel, Error>
e fazer uso de instruções switch case
, tornando o código cada vez mais complexo e difícil de manter.
Continuation
Uma característica adicional interessante do async/await são os métodos withUnsafeContinuation(_:)
, withUnsafeThrowingContinuation(_:)
, withCheckedContinuation(_:)
, withCheckedThrowingContinuation(_:)
, withTaskCancellationHandler(operation:onCancel:)
. Esses métodos permitem encapsular métodos não assíncronos que retornam seus resultados por meio de closures. Com base nesses métodos, é possível transformar qualquer método existente em Swift que ainda não seja compatível com async/await e começar a utilizá-los imediatamente.
Os métodos unsafe devem ser usados para contornar algumas regras de consistência do Swift em relação às closures encapsuladas. O withUnsafeContinuation
ou withUnsafeThrowingContinuation
ignoram possíveis falhas, como não chamar o callback. Caso o desenvolvedor não esteja familiarizado com o funcionamento da closure, é recomendado utilizar o unsafe para garantir que o aplicativo continue executando.
Os métodos checked são utilizados em situações em que temos certeza de que a closure é confiável. Se a função encapsulada violar alguma regra de continuação, ocorrerá uma falha (crash) e o aplicativo será encerrado. Os métodos checked devem ser preferidos quando temos plena consciência de que a closure será chamada apenas uma vez. É recomendado utilizá-los durante o desenvolvimento e trocar para o unsafe na produção.
O último método mencionado, withTaskCancellationHandler
, permite cancelar a operação encapsulada por ele. Um exemplo comum é encapsular o método dataTask
do URLSession.shared
. A classe URLSessionTask
possui o método cancel
para cancelar uma requisição em andamento. Portanto, se a tarefa for cancelada, usamos o withTaskCancellationHandler
para cancelar a URLSessionTask
, caso estejamos abstraindo o método dataTask
.
O Swift já implementa o async/await para os métodos do URLSession
, portanto, não é necessário implementar isso localmente no seu projeto.
Operações em grupo
Fazer operações em grupo no Swift da forma tradicional tem sido um tema amplamente discutido na comunidade, especialmente quando se trata de encontrar a maneira mais otimizada de lidar com isso. Recentemente, tenho notado comentários sugerindo o uso do DispatchGroup em combinação com os métodos enter
, leave
e wait
. No entanto, gerenciar o estado das variáveis e evitar crashes na aplicação com essa abordagem é uma tarefa complexa que requer um bom conhecimento de operações multithread.
Com o advento do async/await, agora temos à nossa disposição os métodos withTaskGroup(of:returning:body:)
e withThrowingTaskGroup(of:returning:body:)
. O corpo da closure recebe um objeto como parâmetro, permitindo a execução de quantas tarefas forem necessárias, desde que todas retornem o mesmo tipo. É possível, é claro, executar funções distintas, mas isso geralmente é feito usando a Task de forma separada, o que simplifica o processo.
func readFileInChuncks(_ url: URL) async throws -> Data {
try await withThrowingTaskGroup(of: Data.self) { group in let totalSize = totalSize(url)
for slice in stride(from: .zero, to: totalSize, by: 1_024) {
group.addTask {
return readBytesData(slice, 1_024, url)
}
}
var data = Data()
for try await slice in group {
data.append(slice)
}
return data
}
}
O código acima mostra como podemos usar o withThrowingTaskGroup para realizar a operação de ler todos os bytes de um arquivo de forma paralela, ganhando velocidade geral.
Actors
Os Actors são uma ferramenta poderosa no Swift que nos permitem criar objetos protegidos durante a execução assíncrona com async/await. Essa abordagem garante a integridade dos dados ao isolar as operações realizadas em propriedades e métodos pertencentes a um actor, evitando conflitos entre várias threads. Portanto, incorporar o uso de actors em nosso código, especialmente para objetos que gerenciam o estado da aplicação, como os managers, pode ser uma escolha extremamente vantajosa.
Ao utilizar um actor para um objeto de gerenciamento, podemos ter a certeza de que as operações realizadas nesse objeto serão executadas de forma segura e consistente. O Swift se encarrega de sincronizar automaticamente o acesso ao actor, evitando condições de corrida e conflitos de leitura e escrita em propriedades ou chamadas de métodos. Isso garante que, mesmo em cenários com várias threads trabalhando simultaneamente, o estado do objeto será mantido íntegro e coerente.
Essa proteção oferecida pelos actors é especialmente valiosa em contextos onde múltiplas partes do código podem acessar e modificar o mesmo objeto. Ao utilizar um actor para encapsular esse objeto, asseguramos que todas as operações sejam realizadas de maneira sequencial, evitando que conflitos ocorram e preservando a consistência dos dados. Essa abordagem também simplifica o código, uma vez que o Swift cuida da sincronização internamente, reduzindo a necessidade de gerenciar explicitamente bloqueios de thread.
actor DataManager {
var array: [Int] = []
func append(_ element: Int) {
array.append(element)
}
func read() -> Int? {
array.last
}
}
O código acima ilustra um exemplo de utilização de um actor chamado DataManager para gerenciar o estado de um array. Em ambientes com múltiplas threads, realizar leituras e escritas diretas em um array compartilhado pode levar a falhas de execução no código. No entanto, ao criar um actor como o DataManager, garantimos a proteção desse objeto por meio do isolamento fornecido pelo Swift.
Ao utilizar um actor, é necessário utilizar a sintaxe await ao chamar os métodos read() e append(_:), mesmo que eles não sejam marcados como assíncronos. Isso ocorre porque o Swift impõe esse isolamento para garantir a integridade do objeto actor. Dessa forma, mesmo quando várias threads estão concorrendo para realizar operações de leitura e escrita no DataManager, podemos ter a certeza de que todas as operações serão bem-sucedidas e nenhuma informação será perdida.
No entanto, é importante destacar que existem considerações a serem feitas ao utilizar um actor nesse contexto. Se realizarmos uma sequência de operações, como fazer uma leitura e, em seguida, adicionar o valor lido + 1, não obteremos um array com uma sequência de números incrementados em 1. Isso ocorre porque, mesmo com a proteção fornecida pelo actor, cada chamada assíncrona usando await libera as demais threads que estavam aguardando o acesso ao recurso (neste caso, o actor) se tornar disponível.
Portanto, ao realizar a operação de leitura, a segunda thread irá obter o mesmo valor que foi obtido pela primeira thread. Consequentemente, o resultado obtido será muito diferente do esperado, devido ao bloqueio e liberação paralela das threads.
Esse comportamento ocorre porque, embora o actor proteja o estado interno e garanta a integridade das operações individuais, ele não impede que as threads sejam liberadas e executem em paralelo. Isso pode levar a resultados inconsistentes quando há dependências entre as operações e elas precisam ser executadas de forma sequencial.
extension DataManager {
func appendLastAddedOrIncrement() {
let integer = array.last ?? .zero
append(integer + 1)
}
}
Uma observação interessante é que é possível manter a integridade do array se agruparmos todas as pequenas operações dentro de um método adicional no actor. No exemplo acima, ao chamar o método appendLastAddedOrIncrement()
em várias threads simultaneamente, elas competirão e serão desbloqueadas somente quando o método appendLastAddedOrIncrement
retornar à thread em execução. Dessa forma, cada thread desbloqueada adicionará o último elemento inserido no array e somará mais 1, resultando em um array que contém uma sequência de números incrementados por 1.
@globalActor
Uma opção adicional ao utilizar o actor é aplicar o mesmo efeito em diferentes objetos. Quando lidamos com um problema dividido em várias classes, objetos e protocolos, como no caso do UIKit e SwiftUI com as divisões de View, ViewModel e Model, podemos utilizar o actor para proteger todos os objetos relevantes, independentemente deles serem atores. Para isso, podemos definir um global actor usando o atributo @globalActor.
Ao atribuir o atributo @globalActor a um determinado actor, ele pode ser amplamente utilizado como um mecanismo de proteção para objetos relacionados. Por exemplo, podemos usar o atributo @DataManager para proteger vários objetos e garantir a integridade das operações realizadas sobre eles.
Essa abordagem permite que tenhamos um único actor como ponto central de proteção, mesmo que os objetos individuais não sejam atores por si só. Dessa forma, podemos utilizar a capacidade de isolamento e sincronização fornecida pelos atores para garantir a consistência dos dados e evitar conflitos em um ambiente multi-thread.
@globalActor
actor CustomActor {
static let shared = CustomActor()
}
@CustomActor
class FlagManager {
var flag = false
}
@CustomActor
func toggleFlag(_ flagManager: FlagManager) {
flagManager.flag.toggle()
}
No exemplo apresentado, temos a definição do actor personalizado @CustomActor, que é utilizado para proteger a classe FlagManager. Essa abordagem permite implementar o método toggleFlag(_:) também protegido pelo @CustomActor, o qual realiza a alternância do valor da flag na classe FlagManager. Ao chamar esse método, é necessário utilizar a palavra-chave await, garantindo que todas as operações internas sejam executadas de forma isolada pelo actor CustomActor.
No SwiftUI e UIKit, é possível simplificar o código e melhorar a legibilidade removendo as chamadas DispatchQueue.main.async {}
ao realizar operações na main thread. Essa simplificação é alcançada através do uso do atributo @MainActor
, que pode ser adicionado aos protocolos, classes e objetos que são responsáveis pela implementação da interface de usuário.
Ao adicionar o atributo @MainActor
a um protocolo, classe ou objeto, garantimos que todas as operações relacionadas a esses elementos sejam executadas exclusivamente na main thread. Isso é extremamente útil em aplicativos iOS, onde a maioria das atualizações de interface do usuário precisa ocorrer na thread principal para evitar problemas de sincronização e garantir a responsividade da interface.
O uso do @MainActor
simplifica o código, eliminando a necessidade de envolver certas operações em blocos DispatchQueue.main.async {}
. Em vez disso, podemos confiar no atributo @MainActor
para garantir que todas as operações sejam executadas automaticamente na main thread, melhorando a legibilidade e reduzindo a complexidade do código.
Bloqueio de Threads
O uso de bloqueio é essencial para garantir a execução segura e consistente de threads em ambientes paralelos. Ele desempenha um papel crucial ao controlar o acesso a recursos compartilhados, evitando que várias threads acessem esses recursos simultaneamente. Ao aplicar um bloqueio, uma thread adquire o controle exclusivo sobre o recurso, impedindo o acesso das outras threads até que a primeira termine sua execução.
A principal vantagem do bloqueio é sua capacidade de proteger seções críticas do código, onde é necessário que apenas uma thread execute o trecho de forma exclusiva. Isso evita problemas como condições de corrida, em que múltiplas threads tentam modificar o mesmo recurso simultaneamente, resultando em inconsistências nos dados. Ao utilizar o bloqueio, cada thread aguarda sua vez para acessar a seção crítica, garantindo a integridade dos dados compartilhados.
struct Lock: Sendable {
private let lock = NSRecursiveLock()
init() {}
func withLock<Value: Sendable>(_ block: @Sendable () throws -> Value) rethrows -> Value {
lock.lock()
defer { lock.unlock() }
return try block()
}
}
No exemplo acima, é mostrado o uso do NSRecursiveLock para criar um mecanismo de bloqueio por meio do método withLock(_:), utilizando o NSRecursiveLock da biblioteca Foundation. No entanto, na linguagem Swift, existem outras classes específicas, como NSLock e NSCondition, que permitem bloquear threads em execução paralela. Essas classes fornecem métodos para adquirir e liberar o bloqueio, além de oferecerem mecanismos adicionais para controlar a execução das threads.
Além do bloqueio simples, a linguagem Swift disponibiliza outras ferramentas para lidar com a concorrência. Entre elas, destacam-se os semáforos, as filas de operações e as barreiras de sincronização. Cada uma dessas ferramentas possui características próprias e pode ser mais adequada para cenários específicos de programação concorrente.
Os semáforos permitem controlar o acesso concorrente a um determinado número de recursos, definindo quantas threads podem acessá-los simultaneamente. As filas de operações são úteis para gerenciar a execução de tarefas assíncronas, permitindo que várias threads trabalhem de forma coordenada e controlada. Já as barreiras de sincronização garantem que um conjunto de threads aguarde até que todas estejam prontas para prosseguir, sincronizando suas execuções.
Sendable
O protocolo Sendable é uma adição importante ao Swift, introduzido na versão 5.5 e posterior, que visa fornecer validação em tempo de compilação para classes, structs e enums, garantindo que esses objetos sejam seguros em um ambiente multithread. Ao marcar um objeto como Sendable, o compilador verifica se ele atende a todas as regras necessárias para execução segura em paralelo. O protocolo em si não adiciona nenhuma lógica adicional ao objeto, servindo como uma ferramenta de auxílio durante o desenvolvimento.
final class ModelManager: @unchecked Sendable {
private let lock = Lock()
var integers: [Int] {
get { lock.withLock { _integers } }
set { lock.withLock { _integers = newValue } }
}
private var _integers: [Int] = []
}
No exemplo acima, temos a classe ModelManager marcada como Sendable usando a anotação @unchecked
. Isso significa que, durante a compilação, o Swift irá ignorar as regras do protocolo Sendable e confiar que o desenvolvedor implementou mecanismos adequados para garantir a execução segura em um ambiente multithread.
No entanto, ao marcar um objeto com @unchecked Sendable
, é responsabilidade do desenvolvedor garantir que todas as operações sejam seguras em um contexto multithread. Isso geralmente requer o uso de bloqueios de thread, semáforos, filas e barreiras, com o objetivo de evitar inconsistências e conflitos de acesso a recursos durante a execução paralela.
AsyncSemaphore
No desenvolvimento async/await, ao lidar com operações assíncronas em recursos compartilhados, é importante garantir a consistência dos dados. Embora os mecanismos do Swift, como os Actors e o protocolo Sendable, tenham sido introduzidos para abordar essa questão, ainda podem ocorrer inconsistências. Nesse contexto, o uso de semáforos assíncronos, implementados pela biblioteca Semaphore, oferece uma abordagem mais flexível para controlar o acesso concorrente a recursos assíncronos compartilhados.
let lock = AsyncSemaphore(value: 1)
var counter = 0
func doSomeExpensiveComputation() async throws {
await lock.wait()
try await Task.sleep(for: .seconds(5))
counter += 1
lock.signal()
}
func concurrentCount() async {
await lock.wait()
counter += 1
lock.signal()
}
Task {
try await doSomeExpensiveComputation()
}
Task {
for _ in 0 ..< 100 {
await concurrentCount()
}
print(counter)
}
Ao contrário do lock tradicional, o semáforo assíncrono utiliza o conceito de await e opera em um nível mais abstrato. Isso permite que as operações assíncronas aguardem a disponibilidade de recursos antes de prosseguir. No exemplo apresentado, temos a utilização do semáforo assíncrono para bloquear o método doSomeExpensiveComputation
, aguardando até que o semáforo forneça o sinal de liberação. Dessa forma, o concurrentCount
espera que o recurso esteja disponível antes de incrementar o contador.
Esse mecanismo traz uma flexibilidade interessante ao permitir que sejam construídos bloqueios de código para aguardar o sucesso de operações assíncronas, como uma requisição de rede. As demais tarefas permanecerão bloqueadas até que o resultado seja produzido e o recurso (nesse caso, o resultado da requisição) esteja disponível. Essa abordagem proporciona uma nova forma de desenvolver soluções altamente eficientes e sincronizadas, promovendo a integridade dos dados compartilhados em ambientes concorrentes.
AsyncSequence
A implementação do protocolo AsyncSequence no Swift permite criar sequências assíncronas, onde os elementos são produzidos de forma assíncrona sob demanda. Esse recurso é especialmente útil em cenários em que a obtenção dos elementos da sequência requer operações assíncronas, como chamadas de rede, operações de I/O ou processamentos demorados.
func number(_ offset: Int) async throws -> Int? {
try await Task.sleep(for: .seconds(1))
if offset >= 10 {
return nil
}
return offset + 1
}
struct MySequence: AsyncSequence {
typealias Element = Int
struct MyIterator: AsyncIteratorProtocol {
var offset = 0
mutating func next() async throws -> Int? {
if let offset = try await number(offset) {
self.offset = offset
return offset
}
return nil
}
}
func makeAsyncIterator() -> MyIterator {
MyIterator()
}
}
Task {
try await Task.sleep(for: .seconds(15))
for try await integer in MySequence() {
print(integer)
}
print("Finish")
}
Ao adotar o protocolo AsyncSequence, como no exemplo acima, é necessário definir o tipo de elemento que a sequência produz, utilizando o typealias Element = [Tipo]. Em seguida, é necessário implementar a nested struct chamada Iterator, que deve conformar-se ao protocolo AsyncIteratorProtocol.
Dentro da struct Iterator, o método next() é responsável por fornecer os elementos da sequência de forma assíncrona. Esse método pode conter lógica personalizada para obter os elementos assíncronos, como a realização de chamadas assíncronas, espera por operações em background ou processamento incremental.
A cada chamada do método next(), um elemento da sequência é retornado encapsulado em um objeto assíncrono. Isso permite que o código cliente utilize a palavra-chave “await” para aguardar a chegada do próximo elemento antes de continuar a execução. Caso a sequência chegue ao fim, o método next() retorna nil para indicar que não há mais elementos a serem produzidos.
A implementação do protocolo AsyncSequence proporciona uma abordagem elegante e eficiente para trabalhar com sequências assíncronas no Swift. Ela permite que o código cliente consuma elementos assíncronos de forma síncrona, facilitando a lógica de processamento e melhorando a legibilidade do código. Além disso, a utilização do protocolo AsyncSequence é altamente flexível, permitindo adaptar a lógica de produção de elementos às necessidades específicas de cada caso de uso.
AsyncStream
Um objeto do Swift que implementa o AsyncSequence é o AsyncStream, que oferece uma implementação prática e eficiente para lidar com sequências assíncronas. O AsyncStream funciona de forma semelhante a um array assíncrono, permitindo a produção e consumo de elementos assíncronos sob demanda
var closure: ((Int?) -> Void)?
let sequence = AsyncStream<Int> { continuation in
closure = {
if let value = $0 {
continuation.yield(value)
} else {
continuation.finish()
}
}
}
Task {
for await integer in sequence {
print(integer)
}
print("Finish")
}
Task {
for index in 0 ..< 10 {
closure?(index)
try await Task.sleep(for: .seconds(1))
}
closure?(nil)
}
No exemplo fornecido, o AsyncStream é utilizado para criar uma sequência assíncrona de números inteiros. Através do uso de um closure de inicialização, o código define uma função de continuação (continuation) que recebe um valor inteiro como parâmetro. Se o valor for não nulo, o closure usa o método yield da continuação para enviar o valor para a sequência. Caso contrário, o método finish é chamado para indicar o término da sequência.
Essa abordagem com o AsyncStream permite obter um fluxo de execução assíncrono e controlado para lidar com sequências de dados. O uso do “for await” simplifica o código ao tratar automaticamente os valores produzidos pela sequência assíncrona, tornando o processo de consumo dos elementos mais conciso e legível.
Se quiser contribuir para que eu possa continuar produzindo mais conteúdos técnicos, sinta-se à vontade para me oferecer um café ☕️ através da plataforma Buy me a Coffee.
Seu apoio é fundamental para manter meu trabalho e contribuir com a comunidade de desenvolvimento.