Concorrência em iOS: A Teoria por Trás do Grand Central Dispatch
A Concorrência nos permite executar tasks simultâneas, possibilitando a criação de aplicativos responsivos, performáticos e eficientes. Entretanto, nós lidamos com recursos finitos, como CPU e memória, por isso é importante tomar decisões inteligentes sobre quais tarefas priorizar e quais colocar em espera.
Inicialmente, é importante entender que utilizamos o termo task como um trabalho ou comando que precisa ser executado pelo aplicativo, desde um print básico até uma chamada de serviço, por exemplo.
Antigamente, isso era feito por meio da criação e manutenção de threads de forma manual pelo desenvolvedor. Atualmente, possuímos frameworks que facilitam nosso trabalho, como o Grand Central Dispatch (GCD), em que basta criar uma task e enviá-la para ser executada em uma Dispatch Queue, que ficará responsável por criar e gerenciar as threads.
// Quem nunca usou esse código?
DispatchQueue.main.async { }
Para que essas tasks ocorram, elas são colocadas em uma fila de execução, ou Queues, no sistema FIFO (First In First Out), ou seja, primeiro a entrar primeiro a sair. Isso significa que cada task será iniciada na ordem em que foi adicionada à fila, mas não necessariamente serão finalizadas nessa mesma ordem. É aí que entra a classificação de queues como sequenciais e concorrentes.
Serial x Concurrent Dispatch Queues
A queue serial roda de forma sequencial, isto é, apenas uma task será executada de cada vez, portanto, nessa queue, temos apenas uma thread, em que a próxima task precisa aguardar a finalização da anterior para iniciar sua execução.
Temos também as queues concorrentes, em que é possível rodar mais de uma task ao mesmo tempo ao criar uma thread para cada task. Dessa forma, temos várias tasks sendo executadas simultaneamente em uma única queue, com várias threads.
Como principais queues seriais e concorrentes, temos a Main Queue e as Global Queues.
Main Queue x Global Queues
A Main Queue é sequencial e roda apenas uma task de cada vez, e tem apenas uma thread, a Main Thread. Esta é a queue padrão, quando nenhuma outra é informada.
ATENÇÃO, utilizamos a Main Queue para tasks relacionadas à UI, isto é, aquilo que o usuário vê, pois estas possuem o maior nível de prioridade. Por isso, MUITO cuidado para não travar a tela do aplicativo executando tarefas longas na Main Queue.
Global Queues são concorrentes, isto é, podem rodar mais de uma task ao mesmo tempo.
Entretanto, como falamos acima, os recursos do sistema são finitos, tornando-se inviável executar milhares e milhares de tasks ao mesmo tempo. Para melhor gerenciar os recursos disponíveis, é preciso informar qual o nível de prioridade de cada task, para que o sistema possa colocar em espera aquelas tasks menos importantes. Isso é feito por meio do QoS (Quality of Service) da Queue. A seguir temos os níveis do mais prioritário para o menos prioritário:
DispatchQueue.main
DispatchQueue.global(qos: .userInteractive)
DispatchQueue.global(qos: .userInitiated)
DispatchQueue.global() // qos .default
DispatchQueue.global(qos: .utility)
DispatchQueue.global(qos: .background)
DispatchQueue.global(qos: .unspecified)
- User interactive: quase tão rápida quanto a main queue, é utilizada para atualizar a interface do usuário, com animações ou eventos interativos.
- User initiated: são realizadas quando o usuário inicia a tarefa e tem resultados imediatos, como apertar um botão para exibir ou ocultar algo.
- Default: esse será o nível de prioridade padrão, quando você não definir outro.
- Utility: são tarefas mais longas, como chamadas de serviço para baixar dados, que geralmente possuem um progress indicator.
- Background: não envolvem interatividade com o usuário, que não sabe que essa tarefa está ocorrendo, como a realização de backups e manutenção.
De forma simplista, o maior nível de prioridade seria o mesmo que dizer ao sistema “execute isso agora”, enquanto o menor nível seria “execute isso quando der”.
Private Queues: também é possível criar suas próprias queues, elas são sequenciais por padrão, mas você pode alterar para concorrente e definir o nível de prioridade.
DispatchQueue(label: "com.project.serial")
DispatchQueue(label: "com.project.concurrent",
qos: .background,
attributes: .concurrent)
Nesse momento, você já sabe que uma queue pode executar várias tasks ao mesmo tempo. Mas é possível que várias queues sejam executadas paralelamente? Sim, mas vai depender se o comando de execução foi feito de forma síncrona ou assíncrona.
Async x Sync Dispatch
Ao enviar uma task de forma síncrona para ser executada em outra thread, a thread atual será bloqueada e ficará aguardando a finalização para que a próxima linha de código seja executada. Sendo assim, toda task síncrona é bloqueante.
Por outro lado, a execução assíncrona não bloqueia a thread atual, assim que a task for enviada para outra thread, a thread atual seguirá sendo executada mesmo sem sua finalização, portanto, a próxima linha de código é executada logo em seguida.
Não confunda: Assíncrono e Concorrente não são sinônimos!
Uma confusão bastante comum é a de que a execução de tasks de forma assíncrona seria o mesmo que concorrente, e síncrona seria sequencial. Mas não, é plenamente possível que uma task seja executada de forma síncrona e concorrente.
- Síncrono e assíncrono quer dizer se a queue atual deve esperar aquela nova task ser finalizada ou não, é uma definição acerca da origem da task, isto é, o que vai ocorrer na queue atual.
- Serial e Concorrente apenas diz se a queue tem uma ou mais threads, ou seja, se ela pode rodar apenas uma ou várias tasks simultaneamente. É uma definição acerca do destino da task, isto é, a queue para a qual ela será enviada.
Por exemplo, vamos supor que estamos dentro de uma queue concorrente, com várias tasks sendo executadas ao mesmo tempo. Eu posso mandar que uma nova task seja executada de forma síncrona em outra thread, isso vai fazer com que a THREAD de origem fique bloqueada, aguardando a nova task ser finalizada, mas as outras threads daquela queue concorrente continuarão sendo executadas normalmente.
Por outro lado, se eu estiver em uma queue serial e mandar que a execução seja síncrona, isso vai bloquear a QUEUE inteira, pois ela possui apenas uma thread.
Para ficar mais claro, criamos várias funções simples com um sleep() dentro, a fim de simular uma task mais longa. Observe o código abaixo:
import UIKit
import PlaygroundSupport
///Habilite a execução indefinida do playground para evitar que as tasks executadas em background sejam interrompidas quando a main thread for finalizada.
PlaygroundPage.current.needsIndefiniteExecution = true
///Criando uma private queue concorrente
let concurrentQueueA = DispatchQueue(label: "com.myproject.concurrent",
qos: .utility,
attributes: .concurrent)
func task1() {
print("Task 1 iniciada")
sleep(2) // usaremos o sleep() para simular uma task longa
print("Task 1 ainda em execução")
sleep(1)
print("Task 1 finalizada")
}
func task2() {
print("Task 2 iniciada")
sleep(3)
print("Task 2 finalizada")
}
func task3() {
print("Task 3 iniciada")
sleep(1)
print("Task 3 finalizada")
}
func task4() {
print("Task 4 iniciada")
sleep(1)
print("Task 4 finalizada")
}
concurrentQueueA.async {
///Concurrent Queue
concurrentQueueA.async {
task1()
}
concurrentQueueA.sync {
task2()
}
concurrentQueueA.async {
task3()
}
}
///Main Queue: Main Thread
task4()
Ao executar o trecho acima no Playground do Xcode, o resultado será algo parecido com a imagem abaixo, podendo conter pequenas variações. As tasks 1, 2 e 4 serão iniciadas praticamente ao mesmo tempo, isso porque nós saímos da Main Queue de forma assíncrona para uma queue concorrente. Entretanto, como a task 2 foi executada de forma síncrona, isso impedirá que task 3 seja iniciada, mas não impedirá que a task 1 continue, já que ela está rodando em outra thread.
URLSession e Concorrência
Chamadas de serviço não devem ser realizadas na Main Thread, pois vão travar a interface do usuário até o retorno da requisição. Por isso, é importante executar a requisição fora da Main Queue de forma assíncrona e, ao receber a resposta do servidor, quaisquer atualizações na view devem ser executadas de volta na Main Queue usando o DispatchQueue.main.async{}.
Esse é o motivo desse trecho de código ser tão utilizado com o URLSession data task, pois essa API executa a task automaticamente fora da Main Queue de forma assíncrona, por isso temos que retornar à ela quando recebemos os dados para atualizar a view.
Por mais que a Concorrência seja muito importante para um bom desempenho, nem tudo é perfeito. Sua utilização desenfreada e sem cuidado pode te trazer muitos problemas, como: race condition, priority inversion e deadlocks, mas isso será tema de um próximo artigo.
Para saber mais sobre race condition e data race, além de como criar uma classe threadsafe, acesse meu novo artigo aqui.