Série de simultaneidade Swift 04 | Limites de uso de tarefas
A tarefa não é a entrada universal para código assíncrono. O que realmente importa é quem o cria, quem o cancela e quem é o responsável pelos resultados.
O mau hábito mais comum que muitas equipes desenvolvem quando encontram pela primeira vez o Swift Concurrency em escala é abusar do Task.
Porque é muito conveniente.
Você não pode await diretamente no retorno de chamada do botão e, em seguida, escrever um Task. Você não pode await diretamente no método proxy UIKit e, em seguida, escrever um Task. Se você quiser entrar no mundo assíncrono usando um determinado método de sincronização, a maneira mais fácil é escrever Task.
Com o tempo, o Task mudará de “um gateway que une sincronização e assincronidade” para “uma camada de fita de simultaneidade que cobre qualquer problema”.
O que este artigo realmente quer responder são três questões mais próximas da engenharia:
- Que tipo de problema resolve?
- Em que circunstâncias é uma selecção natural e em que circunstâncias é apenas uma ocultação de problemas estruturais.
- Que perguntas você deve se fazer primeiro ao decidir abrir um
Task.
1. Primeiro, vamos deixar o posicionamento claro: Task é o ponto de criação de tarefas assíncronas, não uma ferramenta de design de processos.
Quando vi Task {} pela primeira vez, pensei em “execução assíncrona de um trecho de código”.
Este entendimento não está errado, mas não é suficiente.
Mais precisamente, o que Task faz é:
- Crie uma nova tarefa simultânea
- Coloque um trecho de código em um contexto assíncrono
- Vincular responsabilidades como execução, cancelamento, prioridade, resultados, etc.
Portanto, Task nunca se trata apenas de “jogar o código em segundo plano e executá-lo”.
Depois de anotá-lo, tomei várias decisões ao mesmo tempo:
- Este trabalho agora começa a se sustentar por conta própria
- Pode terminar depois do ponto de chamada atual
- Pode ou não ser cancelado
- Seu resultado é consumido ou descartado
- Estabelece um certo relacionamento com o objeto atual, a página atual e a ação atual do usuário
Isto também mostra que Task não pode ser entendido apenas gramaticalmente.
Sintaticamente é apenas um bloco de código, mas tecnicamente significa “o ciclo de vida da tarefa é criado”.
2. O cenário mais adequado para Task: é realmente necessário passar do mundo síncrono para o mundo assíncrono
O cenário de uso mais natural do Task é na verdade muito simples:
O contexto atual não é
async, mas um processo assíncrono precisa ser acionado.
Como estes tipos de situações.
1. Retorno de chamada de interação do usuário
Button("保存") {
Task {
await viewModel.save()
}
}
É razoável usar Task aqui, porque a ação do Button em si não é async, mas a ação de salvar é obviamente um processo assíncrono.
2. Método proxy de sincronização do UIKit/AppKit
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
Task {
await viewModel.search(keyword: searchText)
}
}
A assinatura de retorno de chamada do proxy é determinada pela estrutura, e não se ela pode ser alterada para async. Para entrar em um processo assíncrono, é necessário um ponto de ponte.
3. Ciclo de vida do aplicativo ou retorno de chamada de notificação
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
Task {
await refreshIfNeeded()
}
}
O valor de Task aqui ainda é o mesmo: converter um evento síncrono em uma tarefa assíncrona.
Se você olhar esses exemplos juntos, encontrará uma coisa em comum:
- Os eventos vêm da API de sincronização
- Espera-se que o processamento de negócios seja assíncrono
Taské apenas a entrada, não o corpo principal
Neste momento, Task é uma boa ferramenta.
3. O perigo real: trate Task como um método de reparo de “corrigir qualquer erro relatado”
O problema mais comum na equipe é “usar com muita naturalidade”.
As posturas erradas mais típicas são as seguintes.
1. Se o compilador não permitir await, ele incluirá uma camada de Task
func refresh() {
Task {
await loadData()
}
}
Este código pode não estar errado quando visto isoladamente. O problema é que, em muitos casos, o próprio refresh() pode ser projetado como async e então a camada superior decide quando chamá-lo.
Quando o pensamento padrão se tornar “não é possível await, abra Task”, você perderá o controle dos limites da tarefa.
2. Já na função async, precisamos adicionar outro Task
func loadPage() async {
Task {
await loadUser()
}
Task {
await loadFeed()
}
}
O problema com esse tipo de código é que ele quebra o fluxo de controle que originalmente pertencia a uma função.
Você encontrará vários problemas imediatamente:
- Quem garante a ordem final destas duas tarefas.
- Como lidar com falhas de maneira uniforme.
- Como o chamador sabe quando todo o
loadPage()está realmente concluído. - Se a tarefa externa for cancelada, as duas subtarefas pararão juntas?
Se a intenção for executar em paralelo, geralmente é mais claro escrevê-lo como async let ou grupo de tarefas, em vez de criar dois Task opacos adicionais.
3. Ao encontrar competição de status, quero “mudar o pico” abrindo mais alguns Task
Algum código será escrito assim:
Task {
self.loading = true
}
Task {
let data = await service.fetch()
self.items = data
}
Task {
self.loading = false
}
Superficialmente, parece desmontar as coisas, mas na verdade deixa a consistência do estado diretamente à sorte.
Não sei se a terceira tarefa será concluída antes da segunda e não sei em que estado a interface irá parar quando ocorrer o cancelamento.
A causa raiz desse tipo de problema geralmente é que o fluxo de estado que deveria ser conectado está interrompido.
4. Para julgar se você deve abrir um Task, faça estas quatro perguntas primeiro
Este é o conjunto mais útil de listas de verificação de engenharia. Muito mais útil do que memorizar gramática.
1. Quem criou esta tarefa?
É criado por um clique de botão? Criado quando a página aparece? Criado durante a inicialização do ViewModel? Ou foi criado secretamente por uma camada de serviço?
Se a resposta para “quem o criou” não for clara, será quase impossível descobrir “quem deve cancelá-lo” mais tarde.
2. Quem é o responsável por esta tarefa?
Se a missão é apenas disparar e esquecer, isso geralmente significa que ninguém a está gerenciando.
Mas muitas empresas não são adequadas para “disparar e esquecer”.
Por exemplo, pesquisar, paginar, salvar, fazer upload e sondar, essas tarefas geralmente devem ser explicitamente mantidas por um objeto:
final class SearchViewModel: ObservableObject {
private var searchTask: Task<Void, Never>?
func updateKeyword(_ keyword: String) {
searchTask?.cancel()
searchTask = Task {
await search(keyword)
}
}
}
O que é realmente valioso aqui é:
-Há apenas uma entrada para tarefas semelhantes
- Quando novas tarefas aparecerem, as tarefas antigas serão canceladas
- A tarefa pertence a
SearchViewModel
Task que não “possui” geralmente se tornará uma missão fantasma mais tarde.
3. Se o usuário sair da página, ela deverá continuar em execução?
Esta questão é particularmente importante porque determina diretamente onde o ciclo de vida da tarefa deve ser direcionado.
Por exemplo:
- Solicitação de página acima da dobra: os usuários geralmente não precisam continuar depois de sair da página
- Envio de pedido: pode precisar continuar a ser concluído mesmo que a página esteja fechada
- Pré-busca de imagem: a prioridade pode ser muito baixa e deve ser cancelada ao sair da página
Diferentes tipos de tarefas têm designs completamente diferentes.
Sem responder a esta pergunta primeiro, é fácil escrever todas as tarefas assim:
Task {
await doSomething()
}
Superficialmente eles são unificados, mas na verdade a semântica está completamente confusa.
4. Quem consumirá os resultados desta tarefa?
Os resultados de algumas tarefas serão gravados na UI, os resultados de algumas tarefas precisarão atualizar o cache e algumas tarefas serão apenas relatadas.
Quando os resultados não têm um destino claro, normalmente surgem dois tipos de mau cheiro:
- A tarefa foi aberta, mas ninguém respondeu ao erro
- A tarefa foi concluída, mas ninguém a usou
Portanto, não sou um grande fã de disparar e esquecer desenfreado. A maioria das tarefas de negócios não é “apenas enviá-las”.
5. Quando já estiver no mundo async, dê prioridade à simultaneidade estruturada em vez de criar Task adicional
Este é um ponto que muitos artigos não conseguem abordar com veracidade.
Já na função async, significa que você já possui fluxo de controle assíncrono. Escrever Task adicional neste momento geralmente contorna as restrições impostas pela simultaneidade estruturada.
Veja as duas comparações.
Tendência de erro: use várias demolições difíceis Task em paralelo
func loadDashboard() async {
let userTask = Task { await api.loadUser() }
let statsTask = Task { await api.loadStats() }
let noticesTask = Task { await api.loadNotices() }
let user = await userTask.value
let stats = await statsTask.value
let notices = await noticesTask.value
self.state = .loaded(user, stats, notices)
}
Este código não está totalmente errado, mas não é suficientemente explícito. Porque o que o chamador vê é “Criei ativamente três tarefas” em vez de “Existem três dependências paralelas aqui”.
Melhor expressão: async let
func loadDashboard() async throws {
async let user = api.loadUser()
async let stats = api.loadStats()
async let notices = api.loadNotices()
self.state = try .loaded(user: user, stats: stats, notices: notices)
}
A vantagem desse tipo de escrita é que a semântica é mais clara:
- Esses trabalhos pertencem à função atual
- Eles estão encadeados à mesma estrutura da chamada atual
- A função atual aguardará o resultado antes de terminar
- Quando a camada externa é cancelada, a simultaneidade interna também é cancelada.
Em outras palavras, a diferença entre Task e a simultaneidade estruturada está em quem é o responsável pelo ciclo de vida.
6. O desastre mais comum no nível da página: abra um Task para cada entrada
Tomando como exemplo uma página de lista muito real, geralmente existem estes pontos de gatilho:
- Carregamento da primeira página de entrada
- Puxe para baixo para atualizar
- Alterações nas palavras-chave de pesquisa
- Trocar filtros
- Clique em “Tentar novamente”
- Vá para a próxima página para carregar mais automaticamente
Se cada entrada for escrita sozinha:
Task {
await load()
}
Após uma ou duas iterações, a página provavelmente apresentará estes fenômenos:
- Várias solicitações voam ao mesmo tempo
- Resultados antigos substituem novos resultados
- Obviamente a palavra-chave mais recente é
swift, mas a interface mostra os resultados deswi - Depois que o usuário sai da página, o retorno de chamada ainda está sendo gravado
loading,isRefreshing,errorlutam entre si
Uma situação comum neste estágio é pensar erroneamente que o que você está encontrando é uma “simultaneidade complexa”.
Na verdade, o problema é mais específico: **A entrada da tarefa está muito dispersa e não há fechamento unificado das alterações de status. **
Uma abordagem mais estável geralmente é concentrar “quais tarefas abrir” em um objeto de estado, em vez de deixar a camada de visualização criar novas tarefas em todos os lugares.
Por exemplo:
@MainActor
final class ArticlesViewModel: ObservableObject {
@Published private(set) var state: ViewState = .idle
private var reloadTask: Task<Void, Never>?
func reload() {
reloadTask?.cancel()
reloadTask = Task {
state = .loading
do {
let articles = try await repository.fetchArticles()
guard !Task.isCancelled else { return }
state = .loaded(articles)
} catch is CancellationError {
// 忽略取消
} catch {
state = .failed(error)
}
}
}
}
Existem três problemas que este código realmente resolve:
- Existe uma entrada exclusiva para tarefas semelhantes
- A relação de substituição entre tarefas semelhantes é clara
- Writeback de status em um só lugar
Task ainda é usado aqui, mas já é uma “entrada controlada para gerenciamento de tarefas”.
7. Quando é apropriado manter a referência Task e quando não é necessário?
Este também é um sinal para julgar a maturidade do código.
Adequado para cenários onde as referências são mantidas
- Anti-vibração de entrada de pesquisa
- Tarefa de atualização de página
- Solicitações que podem ser acionadas repetidamente e novas tarefas devem substituir tarefas antigas
- Processos de pesquisa, escuta e sincronização de longa duração
Porque esses cenários envolvem naturalmente cancelamento ou substituição.
Não é necessário segurar a cena referenciada
- Tarefas curtas que os usuários realizam apenas uma vez após clicar uma vez
- É claramente o ponto de enterro, limpeza de log e cache do tipo “dispare e esqueça”
- Tarefas cujo ciclo de vida foi gerenciado pela estrutura externa da equipe
O foco não está em “se deve ou não ser mais avançado”, mas em saber se a tarefa é gerenciada corretamente.
Se uma tarefa puder ser cancelada, substituída ou afetar a visibilidade do usuário, provavelmente não deverá ser desencadeada como fogos de artifício anônimos.
8. Task.detached é uma declaração de isolamento mais forte
Embora este artigo fale principalmente sobre Task, muitas equipes em breve usarão Task.detached após abrir Task aleatoriamente.
Aqui está um lembrete rápido:
Task {}herdará parte do contexto atualTask.detached {}é mais como "separado do contexto atual e executado de forma independente"Portanto, se a atribuição e o cancelamento doTaskcomum não forem corrigidos, odetachednão deve ser usado para aumentar o grau de liberdade.
Muitos Task.detached acabam sendo uma fuga de responsabilidades.
9. Um critério de julgamento prático: você está criando tarefas ou escapando da modelagem?
Esta é a pergunta mais frequente em meus comentários.
Quando estiver pronto para escrever:
Task {
...
}
Pare por dois segundos e pergunte-se:
- Estou criando uma tarefa com um ciclo de vida claro?
- Ou é apenas porque é muito problemático alterá-lo para
async, por isso fica temporariamente coberto por uma camada? - Sei quando termina, quem cancela e para quem termina?
Se você não conseguir responder a essas perguntas, na maioria dos casos o nível atual de abstração não foi corrigido.
10. Conclusão: vale a pena usar Task com frequência, mas não vale a pena usá-lo casualmente.
Task é certamente importante no Swift Simultaneamente e é frequentemente usado com frequência.
Mas seu valor correto é:
- Entre com segurança no processo assíncrono na entrada síncrona
- Crie tarefas explicitamente quando um ciclo de vida independente for necessário
- Fornece limites de simultaneidade claros quando cancelamento, substituição e isolamento são necessários
Então prefiro entender desta forma:
Taské uma declaração explícita do ciclo de vida da tarefa.
Quando usado como uma declaração, o código fica cada vez mais claro. Ao usá-lo como patch, mais cedo ou mais tarde o código se tornará “todas as camadas podem ser executadas, mas ninguém pode dizer por que funciona assim”.
What to read next
Want more posts about Swift Concurrency?
Posts in the same category are usually the best next step for reading more on this topic.
View same categoryWant to keep following #Swift Concurrency?
Tags are useful for related tools, specific problems, and similar troubleshooting notes.
View same tagWant to explore another direction?
If you are not sure what to read next, return to the homepage and start from categories, topics, or latest updates.
Back home