Swift Concurrency Series 09|Problema de invalidação semântica de cancelamento em Swift Concurrency
O que é realmente difícil de coletar é se o sinal de cancelamento pode passar pela tarefa, fazendo a ponte entre a camada e os limites dos efeitos colaterais, e não permitir que os resultados antigos sejam realimentados na página
Após o projeto alterar o retorno de chamada para async/await, uma situação comum é que haja uma ilusão de que o problema de simultaneidade foi contido.
A assinatura da função ficou mais limpa, a cadeia de chamadas pode ser visualizada ao longo do await e há ainda menos avisos no Xcode. Mas os problemas online mais irritantes muitas vezes começam a aparecer nesta fase: a página foi abandonada, mas a solicitação não parou; os termos de pesquisa mudaram e os resultados antigos voltaram; o usuário cancela manualmente o upload, mas as tarefas subjacentes continuam em execução.
Esse tipo de problema é mais facilmente atribuído a “uma determinada interface é muito lenta” ou “o thread principal é atualizado na hora errada”. Mas se você realmente desmontar o link e observá-lo, o núcleo geralmente é que o sinal de cancelamento não é transmitido ao longo da árvore de tarefas, da camada de ponte e dos limites de efeitos colaterais até o final.
Meu julgamento é: **Depois que a migração do Swift Concurrency for concluída, o bug de simultaneidade mais comum é que as pessoas pensam que “a tarefa pai foi cancelada e as tarefas seguintes serão interrompidas naturalmente”. Na realidade, enquanto houver uma camada Task não controlada, um wrapper que faça a ponte entre a API antiga ou um efeito colateral que não verifique o status de cancelamento, a semântica de cancelamento será quebrada nessa camada. No final, a página parece tremer ocasionalmente, mas na verdade significa que o status foi bifurcado. **
Esse tipo de problema geralmente é exposto principalmente em feedback de estado
Esta é a primeira vez que lidei sistematicamente com este problema. Superficialmente parece um acidente, mas na realidade está mais próximo de uma página de pesquisa. Algumas pessoas sempre relatam que “os resultados voltarão por si mesmos”.
A lógica da página não é complicada:
- O usuário insere palavras-chave;
- ViewModel inicia pesquisa;
- Cancelar a tarefa anterior quando chegar uma nova palavra-chave;
- Atualize a lista após o retorno da solicitação.
Superficialmente, esse processo é totalmente consistente com o método de escrita recomendado do Swift Concurrency. O problema é que um fenômeno muito estranho pode ser visto na gravação de tela online:
- O usuário primeiro procura por
swift; - Em seguida altere para
swift concurrency; - Novos resultados aparecem primeiro na interface;
- Após meio segundo, os resultados antigos sobrescrevem a lista novamente.
Isto não pode ser explicado simplesmente por “solicitação fora de ordem”. Porque searchTask?.cancel() está claramente no código e o cancelamento também pode ser visto no log.
O verdadeiro problema reside em: **A tarefa de nível superior foi cancelada, mas a camada inferior não considerou o “cancelamento” como uma mudança de status que deve ser encerrada imediatamente. **
Enquanto houver outra camada no sistema que continue enviando resultados antigos, a IU irá aceitá-lo como um resultado legítimo.
Muitos cancelamentos falharam, quebrados na camada de código de ponte de aparência mais inofensiva.
O ponto de interrupção mais comum é que, ao agrupar a API de retorno de chamada antiga em uma função assíncrona, ela apenas “espera o resultado voltar” e não faz “o que fazer quando o resultado não deveria voltar”.
Por exemplo, uma situação comum é empacotar uma solicitação de rede como esta:
func loadUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
apiClient.loadUser(id: id) { result in
continuation.resume(with: result)
}
}
}
A sintaxe está correta e as funções funcionam. Mas este código tem duas premissas padrão fatais:
- Mesmo que a tarefa externa seja cancelada, a solicitação subjacente irá parar por conta própria;
- Mesmo que a camada inferior não pare, o retorno de chamada não afetará o estado atual se retornar mais tarde.
Estas duas premissas muitas vezes não são verdadeiras em projetos reais.
Se apiClient ainda estiver abaixo de URLSessionDataTask, de um SDK de terceiros ou de sua própria camada de armazenamento de retorno de chamada, o cancelamento da camada externa Task não será transferido automaticamente. O wrapper assíncrono acima apenas altera o método de chamada para await, mas não permite que a camada subjacente obtenha semântica de cancelamento.
O que a camada de ponte realmente precisa fazer é “traduzir o cancelamento da camada externa em ações de cancelamento executáveis subjacentes”. Algo assim:
func loadUser(id: String) async throws -> User {
var request: Cancellable?
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
request = apiClient.loadUser(id: id) { result in
continuation.resume(with: result)
}
}
} onCancel: {
request?.cancel()
}
}
Este código está apenas começando a chegar perto de “o cancelamento pode realmente ser repassado”.
Mas escrever aqui não basta, pois só resolve “tentar não continuar correndo”, mas não resolve “como fechar os resultados atrasados”. Se cancel() do SDK subjacente não tiver um cancelamento semântico forte, mas apenas terminar tanto quanto possível, o retorno de chamada ainda poderá retornar na condição de corrida. O nível superior terá que continuar a fazer uma verificação de cancelamento antes de receber os resultados.
O que realmente atrapalha a página é que os resultados antigos ainda são considerados resultados válidos.
Muitas equipes ficam aliviadas ao ver Task.isCancelled, mas ele só pode responder “se a tarefa atual foi marcada como cancelada”, mas não pode responder “este resultado ainda deve cair na página atual?”
Em cenários como pesquisa, associação e troca de detalhes, o que realmente precisa ser protegido é a propriedade dos resultados.
A seguinte forma de escrever ViewModel é muito comum:
final class SearchViewModel: ObservableObject {
@Published private(set) var items: [Item] = []
private var searchTask: Task<Void, Never>?
func search(keyword: String) {
searchTask?.cancel()
searchTask = Task {
do {
let items = try await repository.search(keyword: keyword)
self.items = items
} catch {
self.items = []
}
}
}
}
O problema parece ser apenas um cancelamento de chamada, mas o que falta mesmo são duas camadas de proteção:
- Após o retorno bem-sucedido, confirme se a tarefa atual ainda é válida;
- O cancelamento não pode ser tratado como um erro normal quando falha.
Uma maneira mais estável de escrever seria assim:
final class SearchViewModel: ObservableObject {
@MainActor @Published private(set) var items: [Item] = []
private var searchTask: Task<Void, Never>?
func search(keyword: String) {
searchTask?.cancel()
searchTask = Task { [weak self] in
guard let self else { return }
do {
let items = try await repository.search(keyword: keyword)
try Task.checkCancellation()
await MainActor.run {
self.items = items
}
} catch is CancellationError {
// 取消不是失败,不清空 UI,不弹错误
} catch {
await MainActor.run {
self.items = []
}
}
}
}
}
O que realmente importa aqui é a atitude por trás disso: **O cancelamento é um fluxo de controle normal, não um acidente anormal. **
Muitas páginas tremem porque o código muda “o usuário saiu” para “a solicitação falhou, então limpe a IU”. Como resultado, a nova tarefa ainda não foi renderizada e o ramo errado da tarefa antiga primeiro retorna a página a um estado vazio, que visualmente parece uma oscilação aleatória.
Outro problema mais oculto é que a árvore de tarefas está quebrada há muito tempo e todos pensam que estão em simultaneidade estruturada.
Um dos benefícios do Swift Concurrency é que a simultaneidade estruturada torna o relacionamento do ciclo de vida entre tarefas pai e filho muito mais claro. Mas a coisa mais fácil de perder no projeto é o Task {} que todos escolhem aleatoriamente apenas para “evitar problemas”.
Por exemplo, quando uma página de lista é inserida para obter detalhes, obter recomendações e destacar destaques, muito código será dividido assim:
func refresh() async {
Task {
async let detail = repository.loadDetail()
async let recommendation = repository.loadRecommendation()
let result = try await (detail, recommendation)
render(result)
}
}
Parece ser assíncrono/espera, mas o problema mais crítico com este código é: o próprio refresh() e a camada Task {} interna não têm mais um relacionamento pai-filho estruturado.
Quer dizer:
- A chamada da camada superior
refresh()termina imediatamente; - Mesmo que a página seja destruída;
- Mesmo que a tarefa externa seja cancelada;
O recém-inaugurado Task neste andar ainda pode continuar funcionando.
Esta é a razão pela qual muitas páginas ainda fazem solicitações mesmo depois de terem saído. É o código que ignora ativamente a simultaneidade estruturada.
Se este tipo de cenário é apenas para obter resultados em paralelo, basta escrever diretamente no contexto assíncrono atual:
func refresh() async throws -> ScreenData {
async let detail = repository.loadDetail()
async let recommendation = repository.loadRecommendation()
return try await ScreenData(
detail: detail,
recommendation: recommendation
)
}
Desta forma, a semântica de cancelamento será coletada junto com a cadeia de chamadas. Quem iniciar será responsável; quem cancelar irá parar junto.
Se o limite do efeito colateral não for verificado para cancelamento, o estado sujo mais difícil de explicar aparecerá.
Se a solicitação não for interrompida, será apenas um desperdício de recursos. Se os efeitos colaterais não forem interrompidos, o status será escrito sujo.
Posteriormente, investiguei especificamente um tipo de problema difícil de reproduzir: depois que um usuário troca rapidamente de conta, os dados da conta anterior aparecem ocasionalmente no cache. Finalmente convergiu, e a semântica de cancelamento parou antes de “obter dados” e não continuou na etapa de “escrever efeitos colaterais”.
Código como este é perigoso:
func refreshProfile() async throws {
let profile = try await repository.fetchProfile()
cache.save(profile)
analytics.trackProfileLoaded(profile.id)
state = .loaded(profile)
}
Se a tarefa tiver sido cancelada quando fetchProfile() retornar, mas não houver verificação de cancelamento, as gravações de cache subsequentes, os pontos ocultos e as atualizações de status continuarão a ocorrer.
O que você vê na interface do usuário neste momento pode ser apenas um salto ocasional, mas dentro do sistema, os dados sujos foram colocados no disco e o custo da solução de problemas aumentará repentinamente.
Uma abordagem mais prudente geralmente é fazer outra verificação explícita antes do limite do efeito colateral:
func refreshProfile() async throws {
let profile = try await repository.fetchProfile()
try Task.checkCancellation()
cache.save(profile)
analytics.trackProfileLoaded(profile.id)
state = .loaded(profile)
}
Este passo pode parecer um pouco mecânico, mas resolve um problema muito real: **O cancelamento não cancela apenas a “espera”, mas também cancela o “envio”. **
O que realmente precisa ser protegido são muitas vezes as próximas ações que irão reescrever o velho mundo.
O mal-entendido mais comum em casos de falha é lidar com todos os erros de maneira uniforme.
A razão pela qual muitas migrações simultâneas deixam caudas longas é porque as equipes gostam de escrever encerramentos de erros em um modelo unificado:
do {
let data = try await service.load()
state = .loaded(data)
} catch {
state = .error(error)
}
Isso não é problema em cenários de falha comuns, mas uma vez colocado em cenários como troca de página de alta frequência, pesquisa Lenovo, anti-vibração de entrada e cancelamento de upload, CancellationError não é a mesma coisa que uma falha comercial real.
Misturar os dois trará pelo menos três consequências:
- O usuário saiu ativamente da página, mas foi registrada como falha;
- A taxa de erro nos pontos ocultos é artificialmente elevada, induzindo em erro o julgamento de estabilidade;
- Devido ao erro unificado na UI, aparecem brindes, estados vazios ou botões de nova tentativa que não deveriam aparecer.
Contanto que o cancelamento seja mostrado como falha uma vez no projeto, vários comentários estranhos e aparentemente não relacionados aparecerão mais tarde:
- A lista é apagada repetidamente durante a pesquisa;
- Ocasionalmente ocorre um erro após a conclusão da atualização do menu suspenso;
- Quando a página retornar ao nível anterior, o status de falha no carregamento piscará.
Esses fenômenos são muito fragmentados, mas a raiz é o mesmo problema: ** o cancelamento do fluxo de controle é confundido com uma exceção de negócios. **
Limites aplicáveis: nem toda função assíncrona precisa ser preenchida com verificações de cancelamento
A semântica de cancelamento é importante, mas nem toda camada precisa escrever Task.checkCancellation() mecanicamente.
Existem três posições que valorizo mais agora:
- Construindo a entrada para a API antiga: É responsável por destraduzir a camada externa para os recursos subjacentes;
- Pontos de comutação de fase para links demorados: Por exemplo, após completar a rede, preparar-se para decodificar e preparar-se para gravar o cache;
- Efeitos colaterais antes do envio: Vale a pena verificar novamente qualquer local que altere o status, armazene em cache, poste ou grave no banco de dados.
Por outro lado, se uma função é apenas cálculo puro, não tem ponto de suspensão e não tem efeitos colaterais, então não faz muito sentido inserir especificamente uma verificação de cancelamento. Porque a verdadeira solução para o cancelamento sempre foi “Não continue a escrever sobre o velho mundo”.
Resumo
A ilusão mais fácil criada pelo Swift Concurrency é que o código foi movido do retorno de chamada para await e o sistema entrou naturalmente em uma era de simultaneidade mais confiável.
Mas projetos reais não ganharão automaticamente semântica de cancelamento só porque a sintaxe é nova.
Se a tarefa pai ainda pode controlar a tarefa filho, se a camada de ponte pode cancelar o cancelamento e se os resultados antigos serão bloqueados antes que os efeitos colaterais sejam enviados. Se uma dessas três coisas for perdida, o que você verá na página será um sistema de estado bifurcado.
Portanto, o que realmente precisa ser examinado nesse tipo de questão é em que nível o cancelamento para. Enquanto esta questão não for respondida com clareza, quanto mais nova a gramática, mais fácil será para as pessoas pensarem erroneamente que escreveram a simultaneidade corretamente.
What to read next
Want more posts about iOS?
Posts in the same category are usually the best next step for reading more on this topic.
View same categoryWant to keep following #iOS?
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