O advento de async / waitit em C # levou a uma redefinição de como escrever código paralelo simples e correto. Freqüentemente, usando programação assíncrona, os programadores não apenas resolvem os problemas que estavam com os encadeamentos, mas também introduzem novos. Os impasses e os voos não vão a lugar algum - eles apenas se tornam mais difíceis de diagnosticar.
Dmitry Ivanov - Equipe de Análise de SoftwareLead na Huawei, um ex-desenvolvedor de tecnologia JetBrains Rider e desenvolvedor do núcleo ReSharper: estruturas de dados, caches, multithreading e palestrante regular na conferência DotNext .Sob a cena - gravação de vídeo e transcrição de texto do relatório de Dmitry da conferência DotNext 2019 Piter.Narração adicional em nome do orador.No código multithread ou assíncrono, algo geralmente quebra. O motivo pode ser tanto impasse quanto corrida. Como regra, uma corrida falha uma vez em cada mil, geralmente não localmente, mas apenas em um servidor de compilação, e leva vários dias para ser detectada. Tenho certeza de que, para muitos, é uma situação familiar.Além disso, olhando para o código assíncrono, mesmo por desenvolvedores experientes, me pego pensando que algumas coisas podem ser escritas três vezes mais curtas e mais corretamente.Isso sugere que o problema não está nas pessoas, mas no instrumento. As pessoas simplesmente usam a ferramenta e querem que ela resolva seu problema. A ferramenta em si possui um número muito grande de recursos (às vezes até supérfluos), configurações, um contexto implícito, o que leva ao fato de que é muito fácil de usar incorretamente. Vamos tentar descobrir como usar async / waitit e trabalhar com uma classe Task
no .NET.Plano
- Problemas com abordagens que são resolvidas com async / wait.
- Exemplos de design controverso.
- Uma tarefa da vida real que resolveremos de forma assíncrona.
Assíncrono / espera e problemas a serem resolvidos
Por que precisamos de async / wait? Digamos que temos um código que funciona com memória compartilhada compartilhada.No início do trabalho, lemos a solicitação, neste caso, o arquivo da fila de bloqueio (por exemplo, da Internet ou do disco), usando a solicitação de bloqueio de remoção da fila (as solicitações de bloqueio serão marcadas em vermelho nas figuras com exemplos).Essa abordagem requer muitos encadeamentos, e cada encadeamento requer recursos, cria uma carga no agendador. Mas este não é o principal problema. Suponha que as pessoas possam reescrever sistemas operacionais para que esses sistemas suportem tanto cem mil como um milhão de threads. Mas o principal problema é que alguns threads simplesmente não podem ser utilizados. Por exemplo, você tem um encadeamento da interface do usuário. Não há estruturas de interface do usuário adequadas normais em que o acesso aos dados não seria apenas de um encadeamento. O thread da interface do usuário não pode ser bloqueado. E para não bloqueá-lo, precisamos de código assíncrono.Agora vamos falar sobre a segunda tarefa. Depois de lermos o arquivo, ele precisa ser processado de alguma forma. Vamos fazer isso em paralelo.Muitos de vocês já ouviram dizer que paralelismo não é o mesmo que assincronia. Nesse caso, surge a pergunta: a assincronia pode ajudar a escrever código paralelo mais compacto, bonito e mais rápido?A última tarefa é trabalhar com memória compartilhada. Precisamos arrastar esse mecanismo com bloqueios, sincronização com código assíncrono ou isso pode ser evitado de alguma forma? O assíncrono / aguarda ajuda com isso?Caminho para assíncrono / aguardar
Vejamos a evolução da programação assíncrona em geral no mundo e no .NET.Ligue de volta
Void Foo(params, Action callback) {…}
Void OurMethod() {
…
Foo(params,() =>{
…
});
}
A programação assíncrona começou com retornos de chamada. Ou seja, primeiro você precisa chamar parte do código de forma síncrona e a segunda parte - de forma assíncrona. Por exemplo, você lê um arquivo e, quando os dados estiverem prontos, eles serão entregues a você de alguma forma. Esta parte assíncrona é passada como um retorno de chamada .Mais retornos de chamada
void Foo(params, Action callback) {...}
void Bar(Action callback) {...}
void Baz(Action callback) {...}
void OurMethod() {
...
Foo(params, () => {
...
Bar(() => {
Baz(() => {
});
});
});
}
Assim, a partir de um retorno de chamada, você pode registrar outro retorno de chamada , do qual é possível registrar um terceiro retorno de chamada e, no final, tudo se transforma em um inferno de retorno de chamada .
Retorno de chamada: exceções
void Foo(params, Action onSuccess, Action onFailure) {...}
void OurMethod() {
...
Foo(params, () => {
...
},
() => {
...
});
}
Como trabalhar com exceções? Por exemplo, o ReSharper, ao responder separadamente às exceções e à boa execução, não demonstra as partes mais bonitas do código - há retornos de chamada separados para uma situação excepcional e para uma continuação bem-sucedida. O resultado é um inferno de retorno de chamada , mas não linear, mas semelhante a uma árvore, o que pode ser completamente confuso.
No .NET, a primeira abordagem de retorno de chamada é chamada de modelo de programação assíncrona (APM). O método será chamado AsyncCallback
, que é essencialmente o mesmo que Action
, mas a abordagem possui alguns recursos. Antes de tudo, os métodos devem começar com a palavra "Begin" (a leitura de um arquivo é BeginRead), que retorna alguns AsyncResult
. Ele mesmoAsyncResult
- Este é um manipulador que sabe que a operação foi concluída e que possui um mecanismo WaitHandle
. Você WaitHandle
pode esperar, aguardando a conclusão da operação de forma assíncrona. Por outro lado, você pode chamar EndOperation
, isto é, criar EndRead
e travar de forma síncrona (que é muito semelhante a uma propriedade Task.Result
).Essa abordagem tem vários problemas. Em primeiro lugar, não nos protege do inferno de retorno de chamada . Em segundo lugar, ainda não está claro o que fazer com exceções. Em terceiro lugar, não está claro em qual thread esse retorno de chamada será chamado - não temos controle sobre a chamada. Quarto, surge a pergunta: como combinar trechos de código com retornos de chamada?
O segundo modelo é chamado Padrão Assíncrono Baseado em Evento. Essa é uma abordagem de retorno de chamada reativa. A idéia do método é que passemos para o método OperationNameAsync
algum objeto que tenha o evento Concluído e nos inscrevamos nesse evento. Como você notou, BeginOperationName
muda para OperationNameAsync
. Pode ocorrer confusão quando você entra na classe Socket, onde dois padrões são misturados: ConnectAsync
e BeginConnect
.Por favor, note que você deve ligar para cancelar OperationNameAsyncCancel
. Como no .NET isso não é encontrado em nenhum outro lugar, geralmente todos enviam CancellationToken s . Portanto, se você encontrar acidentalmente um método na biblioteca que termina com Async
, precisará entender que ele não necessariamente retorna Task
, mas pode retornar uma construção semelhante.
Considere um modelo conhecido em Java comoFuturos , em JavaScript, como Promessas , e em .NET, como Padrões Assíncronos de Tarefas , em outras palavras, “tarefas”. Este método pressupõe que você tenha algum objeto de cálculo e pode ver o status desse objeto (em execução ou concluído). No .NET, existe uma RnToCompletion
separação conveniente chamada de dois status: o início da tarefa e a conclusão da tarefa. Um erro comum ocorre quando um método é chamado em uma tarefa IsCompleted
que retorna uma continuação sem êxito, mas RnToCompletion
, Canceled
e Faulted
. Portanto, o resultado de clicar em "Cancelar" no aplicativo de interface do usuário deve diferir do retorno de exceções (execuções). No .NET, foi feita uma distinção: se a execução é seu erro que você deseja proteger, então Cancelar- operação forçada.No .NET, também foi introduzido um conceito TaskScheduler
- é um tipo de abstração em cima de threads que informa onde executar a tarefa. Nesse caso, o suporte de cancelamento foi projetado no nível do design. Quase todas as operações na biblioteca do .NET têm CancellationToken
isso que podem ser passadas. Isso não funciona para todos os idiomas: por exemplo, no Kotlin, você pode desfazer a tarefa, mas no .NET, não. A solução pode ser a divisão de responsabilidade entre aqueles que cancelam a tarefa e a própria tarefa. Quando você recebe uma tarefa, não pode cancelá-la, a não ser explicitamente - você deve transmiti-la CancellationToken
.Um objeto especial TaskCompletionSoure
permite adaptar facilmente as APIs antigas associadas ao Padrão Assíncrono Baseado em Evento ou ao Modelo de Programação Assíncrona. Há um documento que você deve ler se programar em tarefas. Ele descreve todos os acordos sobre tasas. Por exemplo, qualquer método, retornando a tarefa, deve retorná-lo em um estado de execução, o que significa que não pode ser Created
, enquanto todas essas operações devem terminar Async
.Combinando continuações
Task ourMethod() {
return Task.RunSynchronously(() =>{
...
})
.ContinueWith(_ =>{
Foo();
})
.ContinueWith(_ =>{
Bar();
})
.ContinueWith(_ =>{
Baz();
})
}
Quanto à combinação, levando em consideração o inferno de retorno de chamada , ela pode aparecer de uma forma mais linear, apesar da presença de partes do código repetido com alterações mínimas. Parece que o código está melhorando dessa maneira, mas também existem armadilhas.Iniciar e continuar tarefas
Task.Factory.StartNew(Action,
TaskCreationOptions,
TaskScheduler,
CancellationToken
)
Task.ContinueWith(Action<Task>,
TaskContinuationOptions,
TaskScheduler,
CancellationToken
)
Vamos passar para três parâmetros durante o início da tarefa padrão: o primeiro são as opções para iniciar a tarefa, o segundo é scheduler
aquele no qual a tarefa é iniciada e o terceiro - CancellationToken
.
TaskScheduler informa onde a tarefa é iniciada e é um objeto que você pode substituir independentemente. Por exemplo, você pode substituir um método Queue
. Se você faz TaskScheduler
para thread pool
, o método Queue
leva um fio de thread pool
e envia sua tarefa lá.Se você assumir scheduler
o encadeamento principal, ele colocará tudo em uma fila e as tarefas serão executadas sequencialmente no encadeamento principal. No entanto, o problema é que, no .NET, você pode executar tarefas sem passar TaskScheduler
. Surge a pergunta: como então o .NET calcula qual tarefa foi passada para ela? Quando a tarefa começa por StartNew
dentroAction
, ThreadStatic
. Current
exibido no TaskScheduler
que nós demos a ela.Esse design parece bastante controverso devido ao contexto implícito. Houve casos em que ele TaskScheduler
continha código assíncrono que herdou muito profundamente em algum lugar TaskScheduler.Current
e se sobrepôs a outro agendador, o que levou a conflitos. Neste caso, você pode usar a opção TaskCreationOption.HideScheduler
. Esta é uma campainha de alarme que diz que temos alguma opção que substitui a ThreadStatic
configuração.Tudo é o mesmo com continuações. Surge a pergunta: de onde ela vem TaskScheduler
para continuações? Primeiro de tudo, é utilizado no método em que você iniciou Continuation
. Também é TaskScheduler
retirado do ThreadStatic. É importante que, para assíncrono / espera, continuações funcionem de maneira muito diferente.
Nos voltamos para os parâmetros TaskCreationOptions
e TaskContinuationOptions
. O principal problema deles é que existem muitos deles. Alguns desses parâmetros se cancelam, outros são mutuamente exclusivos. Todos esses parâmetros podem ser usados em todas as combinações possíveis, por isso é difícil ter em mente tudo o que pode acontecer com o desejo. Algumas dessas opções funcionam de maneira completamente incompreensível.
Por exemplo, os parâmetros ExecuteSynchronously
e RunContinuationsAsynchronously
representam duas opções possíveis de aplicativos, mas se a continuação será iniciada de forma síncrona ou assíncrona depende de tantas coisas que você não saberá.
Outro exemplo: lançamos tarefa, lançamos continuação e, simultaneamente, fornecemos dois parâmetrosTaskContinuations.ExecuteSynchronously
, após o qual eles iniciaram a continuação de forma assíncrona. Será executado na mesma pilha em que a tarefa anterior termina ou será transferido para thread pool
? Nesse caso, haverá uma terceira opção: depende.
TaskCompletionSource
Considere TaskCompletionSource
. Ao criar uma tarefa, você define seu resultado SetResult
para adaptar os padrões assíncronos anteriores ao mundo da tarefa. Você TaskCompletionSource
pode solicitar tcs.Task
, e esta tarefa entrará em um estado finish
quando você ligar tcs.SetResult
. No entanto, se você executar isso no conjunto de encadeamentos , obterá um impasse . A questão é: por que se não escrevemos nada, mesmo que de forma síncrona?
Criamos TaskCompletionSource
, iniciamos uma nova tarefa e temos um segundo thread que inicia algo nessa tarefa. Ele repassa e cai na expectativa de cem milissegundos. Então nossa linha principal - verde - vai aguardar e é isso. Ele libera a pilha, a pilha trava, esperando para ser chamada em uma continuação emtask.Wait
quando tcs
exposto.Na linha azul chegamos a tcs
, e depois o mais interessante. Com base em considerações internas do .NET, ele TaskCompletionSource
acredita que a continuação disso tcs
pode ser executada de forma síncrona, ou seja, diretamente na mesma pilha, e então task.Wait
realizada de forma síncrona na mesma pilha. Isso é muito estranho, apesar de nem sequer escrevermos em lugar algum ExecuteSynchronously
. Este é provavelmente o problema com a mistura de código síncrono e assíncrono.
Outro problema TaskCompletionSource
é que, quando chamamos SetResult
sob o bloqueio , você não pode chamar código arbitrário, pois, sob o bloqueio, você pode executar apenas algumas pequenas atividades granulares. Corra sob algumas ações, é impossível vir de onde eles vieram. Como resolver este problema?var tcs = new TaskCompletionSource<int>(
TaskContinuationsOptions.RunContinuationsAsynchronously
) ;
lock(mylock)
{
tcs.SetResult(O);
});
Vale a TaskCompletionSource
pena usar apenas para adaptação do código não Task nas bibliotecas. Quase tudo o mais pode ser resolvido através do aguardar. Nesse caso, é sempre altamente recomendável prescrever o parâmetro "TaskCompletionSource.RunContinuationsAsynchronously" . Você quase sempre precisa executar uma continuação de forma assíncrona. Nesse caso, você tcs.SetResult
tem algo sob o qual nada será lançado.
Por que a continuação deve ser realizada de forma síncrona? Porque se RunContinuationsAsynchronously
refere ao seguinte ContinueWith
, e não ao nosso. Para que ele se relacione com o nosso, você precisa escrever o seguinte:
Este exemplo mostra como os parâmetros não são intuitivos, como eles se cruzam, como eles introduzem a complexidade cognitiva - é tão difícil escrever.Hierarquia pai-filho
Task.Factory.StartNew(() =>
{
Task.Factory.StartNew(() => {
})
})
.ContinueWith(...)
Existem outras opções para o uso de parâmetros. Por exemplo, uma hierarquia pai-filho surge quando você inicia uma tarefa e executa outra sob ela. Nesse caso, se você escrever ContinueWith
, ContinueWith
não esperará a tarefa iniciada dentro.
Se você escrever TaskCreationOptions.AttachedToParent
, isso ContinueWith
irá esperar. Você pode usar essa propriedade em seus produtos. Eu acho que todos podem criar um exemplo no qual existe uma hierarquia de tarefas, com a tarefa aguardando a subtarefa e a subtarefa por suas subtarefas. Não há necessidade de escrever em qualquer lugar WaitForChildren
, essa espera acontece de forma assíncrona. Ou seja, o corpo da tarefa pai termina e, depois disso, a tarefa pai não é considerada concluída, não inicia suas continuações até que as tarefas filho funcionem.Task.Factory.StartNew(() =>
{
Foo();
})
.ContinueWith(...)
void Foo() {
Task.Factory.StartNew(() => {
}, TaskCreationOptions.AttachedToParent);
}
Pode haver um problema no qual a tarefa é transferida para algum lugar ThreadStatic
, e tudo o que você iniciou AttachedToParent
será adicionado a essa tarefa pai, que é uma campainha de alarme.Task.Factory.StartNew(() =>
{
Foo();
}, TaskCreationOptions.DenyChildAttach)
.ContinueWith(...)
void Foo() {
Task.Factory.StartNew(() => {
}, TaskCreationOptions.AttachedToParent);
}
Por outro lado, há uma opção que cancela a opção anterior DenyChildAttach
. Esse aplicativo ocorre com bastante frequência.Task.Run(() =>
{
Foo();
})
.ContinueWith(...)
void Foo() {
Task.Factory.StartNew(() => {
}, TaskCreationOptions.AttachedToParent);
}
Vale lembrar que Task.Run
esta é a maneira padrão de começar, o que, por padrão, implica DenyChildAttach
.O contexto implícito que você coloca ThreadStatic
adiciona complexidade a você. Você não entende como a tarefa funciona, porque precisa conhecer o contexto. Outro problema que pode surgir está relacionado ao estado ocioso de assíncrono / espera. Isso porque em async / waitit você não tem tarefas, mas ações. A continuação não é tarefa honesta, mas ação. Ao escrever código assíncrono / aguardar, você não precisa usá-lo AttachedToParent
, porque vincula explicitamente as tarefas a aguardar pela espera, e essa é a abordagem correta.
Você tem seis opções sobre como iniciar uma continuação. Você lançou a tarefa, lançouContinueWith
. Pergunta: Qual status essa continuação terá? Existem cinco respostas possíveis:- a continuação geral será concluída com êxito; RunToCompletion ocorrerá;
- a tarefa estará errada;
- cancelamento ocorrerá;
- a tarefa não chegará à conclusão, será em algum tipo de limbo;
- opção - "depende".
Nesse caso, a tarefa estará no estado cancelado, embora em nenhum lugar a palavra "cancelado" em qualquer lugar. Aqui jogamos a recepção e não fazemos nada. O problema é que, quando você lê o código de outra pessoa com muitas opções - mesmo se você soubesse dessas opções há 10 minutos - ainda esquece o que acontece aqui. Então não escreva.Cancelamento
Task.Factory.StartNew(() =>
{
throw new OperationCanceledException();
});
Failed
O terceiro parâmetro no início da tarefa é kancellation. Você escreve OperationCanceledException
, ou seja, uma ação especial que coloca a tarefa no estado "Cancelado". Nesse caso, a tarefa estará no estado "Falha", porque nem todos OperationCanceledException
são iguais.Task.Factory.StartNew(() =>
{
throw new OperationCanceledException(cancellationToken);
}, cancellationToken);
Canceled
Para que a tarefa seja capaz Canceled
, é necessário jogá-la OperationCanceledException
junto com seu CancellationToken. Na realidade, você nunca faz isso explicitamente, mas faz o seguinte:Task.Factory.StartNew(() =>
{
cancellationToken.ThrowIfCancellationRequested();
}, cancellationToken);
Canceled
É necessário distinguir cancellationToken? Em algum lugar da tarefa, você verifica se alguém o excluiu: lance o cancelamento e a tarefa entra em estado Canceled
. Ou alguém clicou em "Cancelar" no tempo de execução e cancelou a tarefa. Nossa prática no JetBrains sugere que você não precisa distinguir entre esses tokens. Se você receber uma OperationCanceledException - um tipo especial que ocorre quando ocorre algum cancelamento, você pode distingui-lo. Nesse caso, você só precisa concluir a tarefa normalmente, não faça o login e, quando receber a execução, faça o login.Pilha profunda
Task.Factory.StartNew(() =>
{
Foo();
}, cancellationToken);
void Foo() {
Bar() {
...
Baz() {
}
}
}
Digamos que você tenha uma pilha profunda. Este CancellationToken
é o único parâmetro explícito que discutimos. Ele deve ser transmitido para todos os lugares através de absolutamente todas as hierarquias. O que devo fazer se, na presença de uma hierarquia profunda, você precisar cancelar sua tarefa em algum lugar, no nível mais baixo, para descartar a recepção? Existe um truque tão especial que usamos. Ele é chamado AsyncLocal
.static AsyncLocal<Cancelation> asyncLocalCancellation;
Task.Factory.StartNew(() =>
{
asyncLocalCancellation.Set(cancellationToken)
Foo();
}, cancellationToken);
void Foo() {
async Bar() {
...
Baz() {
asyncLocalCancellation.Value.CheckForInterrupt();
}
}
}
É o mesmo que, ThreadStatic
apenas o especial ThreadLocal
que sobrevive a viagens assíncronas / a aguardar código. Como seu código é assíncrono e você tem esse kancellation, você o coloca AsyncLocal
e, em algum nível profundo, você pode dizer " CheckForInterrupt Throw If Cancellation Requested
". Novamente, este é o único parâmetro CancellationToken
que precisa manchar completamente o código inteiro, mas, na minha opinião, para a maioria das tarefas, você só precisa saber o que aconteceu OperationCanceledException
e, a partir disso, tirar uma conclusão sobre o estado: Cancelado ou com falha.Complexidade cognitiva
Task.Factory.StartNew(Action,
TaskCreationOptions,
TaskScheduler,
CancellationToken
)
JetBrains.Lifetimes
lifetime.Start(TaskScheduler, Action)
lifetime.StartMainRead(Action)
lifetime.StartMainWrite(TaskScheduler, Action)
lifetime.StartBackgroundRead(TaskScheduler, Action)
Quanto mais difícil a leitura do código ao iniciar a tarefa, maior o risco de erro. Observando o código após um ano, você esquecerá o que faz, porque há um grande número de parâmetros. Mas temos a biblioteca JetBrains.Lifetimes , que oferece vida útil moderna, CancellationToken bem otimizado, com o qual o método Start foi reescrito e o problema de repetir trechos de código foi resolvido, como em Task.Factory.StartNew
e TaskCreationOptions
.Há um pequeno número de agendadores que permitem agendar uma tarefa no encadeamento principal com bloqueio de leitura. Ou seja, o bloqueio de leitura não é algo que você escolhe explicitamente, é um agendador especial que agenda seu código no segmento principal com bloqueio de leitura, bem como o encadeamento principal com bloqueio de gravação, encadeamento em segundo plano - e agora os métodos se tornam muito simples para iniciar o shuffle. Ao mesmo tempo, o tempo de vida útil é cancelado automaticamente AsyncLocal
, simplificando significativamente o código.
Vamos ver como o assíncrono / espera resolve esses problemas e quais problemas eles introduzem.Neste exemplo, parte do código é executada de forma síncrona e aguarda um código assíncrono. Em primeiro lugar, é bom que haja muito menos pedaços de código repetidos ( caldeira ). Em segundo lugar, é bom que o código assíncrono seja muito semelhante ao código síncrono. É exatamente para isso que assíncrono / espera . Você pode escrever de forma assíncrona da mesma maneira que escreveu de forma síncrona, sem ocupar threads.O que nesse caso o compilador implementará? O código síncrono será executado de forma síncrona, após o qual a tarefa InnerAsync
será executada de forma síncrona , de onde vem o objeto GetAwaiter especial. Nesse caso, estamos interessados TaskAwaiter
. Você pode escrever seu garçom para absolutamente qualquer objeto. Como resultado, aguardamos a conclusão da tarefa InnerAsync
e a executamos de forma síncrona continuationCode
. Se a tarefa não for concluída, continuationCode será agendado no planejador de contexto . Pode ser que, mesmo que você tenha escrito à espera , absolutamente tudo será chamado de forma síncrona.async Task MyFuncAsync() {
synchronousCode();
await InnerAsync();
await Task.Yield();
continuationCode();
}
Há um truque Task.Yield
- essa é uma tarefa especial que garante que seu garçom nem sempre retorne para você IsCompleted
. Por conseguinte, continuation
não será chamado de forma síncrona neste local. Para um encadeamento da interface do usuário, isso pode ser importante porque você não utiliza esse encadeamento por um longo período de tempo.
Como escolher um segmento para continuação? A filosofia assíncrona / aguardada é a seguinte: você escreve código assíncrono da mesma forma que síncrono. Se você possui um pool de threads , isso não faz diferença para você - o continuationCode será executado em outro thread. Independentemente de ter sido InnerAsync
concluído quando você disse aguardar ou não, você precisa de tudo para executar no encadeamento da interface do usuário.O mecanismo da tarefa aguardada é o seguinte: é utilizado static
, é chamadoSynchronizationContext
e a partir dele é criado TaskScheduler
. SynchronizationContext é uma coisa do método Post, que é muito semelhante ao método Queue
. De fato TaskScheduler
, anteriormente, ele simplesmente toma SynchronizationContext
e através do Post executa sua tarefa nele.async Task MyFuncAsync() {
synchronousCode();
await InnerAsync().ConfigureAwait(false);
continuationCode();
}
Existe uma maneira de alterar esse comportamento usando um parâmetro ContinueOnCapturedContext
. A API mais repugnante do .NET é chamada ConfigureAwait
. Nesse caso, a API cria um garçom especial diferente TaskAwaiter
daquele que muda a continuação, é executado no mesmo encadeamento, no mesmo contexto em que o método terminou InnerAsync
e onde a tarefa foi encerrada.async Task MyFuncAsync() {
synchronousCode();
await InnerAsync().ConfigureAwait(continueOnCapturedContext: false);
continuationCode();
}
Há uma quantidade insana de conselhos na Internet: se você tiver um impasse , limpe todo o seu código ConfigureAwait e tudo ficará bem. Este é o caminho errado. ConfigureAwait
pode ser usado nos casos em que você deseja melhorar um pouco o desempenho, ou no final do método, em alguns métodos da biblioteca.Deadlocks
async Task MyFuncAsync() {
synchronousCode();
await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false);
continuationCode();
}
myFuncAsync().Wait()
Este é um impasse clássico . No thread da interface do usuário, eles esperaram dez segundos e o fizeram Wait
. Devido ao que você fez Wait
, ele continuationCode
nunca será lançado e, Wait
portanto , nunca retornará. Tudo isso acontece no começo.async Task OnBluttionClick() {
int v = Button.Text.ParseInt();
await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false);
Button.Text.Set((v+1).ToString());
}
myFuncAsync().Wait()
Imagine que essa é uma atividade real. Clicamos no botão, pegamos Button.ParseInt
, aguardamos , escrevemos ConfigureAwait
Dizemos: "Por favor, não feche o fluxo da interface do usuário, execute a continuação". O problema é que queremos que a segunda parte ConfigureAwait
também seja executada no thread da interface do usuário, porque esta é a filosofia de aguardar . Ou seja, seu código assíncrono parece o mesmo que o código síncrono e é executado no mesmo contexto. Nesse caso, é claro, haverá um erro. Além disso Button.Text.Set
, pode haver qualquer número de chamadas de método que também assumem seu contexto. O que fazer nessa situação? Você consegue fazer isso:async Task MyFuncAsync() {
synchronousCode();
await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false);
continuationCode();
}
PumpUntil(() => task.IsCompleted);
Com um encadeamento da interface do usuário, você deve proibi-lo Wait
em encadeamentos que tenham uma fila de mensagens comum. Em vez de fazer Wait
ou escrever ConfigureAwait
, você pode bombear essa fila de mensagens e, ao mesmo tempo, o continuum também será bombeado. Se você não pode misturar código síncrono e assíncrono, não deve misturá-los. Mas às vezes isso não pode ser evitado.Por exemplo, você tem um código antigo e precisa misturá-lo e depois bombear o fluxo da interface do usuário. O Visual Studio bombeia o thread da interface do usuário com as expectativas, até SynchronizationContext
mudou um pouco. Se você entrar no WaitHandle em qualquer um Wait
, quando você travar, o fluxo da interface do usuário será bombeado. Assim, eles escolhem entre impasses e raças em favor das raças.Pumpuntil- Essa é uma API não ideal, ou seja, quando você executa continuidade aleatória em um local arbitrário, pode haver nuances. Infelizmente não há outro caminho. Misture códigos síncronos e assíncronos. Se qualquer coisa, todo o Cavaleiro está tão organizado nos lugares antigos, então às vezes também existem nuances.Alterar contexto
async Task MyFuncAsync() {
synchronousCode();
await myTaskScheduler;
continuationCode();
}
Existe outra maneira interessante de usar async / waitit . Você pode escrever Awaiter
em scheduler
e saltar sobre tópicos. Eu li postagens no Visual Studio, eles escreveram por muito tempo que não é bom ir e voltar no meio do método, mas agora eles fazem isso sozinhos. O Visual Studio tem uma API que salta nos threads pelos agendadores. Para uso normal, fazer isso não é bom.Concorrência estruturada
async Task MyFuncAsync() {
synchronousCode();
await Task.Factory.StartNew(() => {...}, myTaskScheduler);
continuationCode();
}
Para uma imersão conveniente no novo contexto e retorno ao antigo, alguma competição estrutural ou paralelismo estrutural deve ser construída. Por exemplo, nos anos sessenta, o operador GoTo foi considerado prejudicial por violar a estruturalidade. Então é aqui. Saltar sobre as linhas viola o estrutural. Surpreendentemente, usar uma máquina de estado assíncrona parece uma boa saída. Ou seja, onde sua estrutura usual é violada, você pula no GoTo, pode violar a estrutura do thread: aguarde , misture -a com tags. Essa é uma situação extremamente estranha e rara quando você precisa fazer isso. Ainda assim, é melhor esperar quando o retorno ao mesmo contexto. Portanto, o conjunto de encadeamentos não terá o mesmo encadeamento, mas o mesmo contexto que era originalmente.Comportamento sequencial
Por que esperar não é o mesmo que execução paralela? Aguardar execução é execução sequencial. Nesse caso, iniciamos a primeira tarefa, esperamos por ela, iniciamos a segunda tarefa - esperamos. Não temos paralelismo. Para a maioria dos usos, o paralelismo não é necessário. O paralelismo em si é mais complexo que a sequência. O código de série é mais simples que paralelo, é um axioma. Mas, às vezes, você precisa executar algo em código paralelo e faz o seguinte:async Task MyAsync() {
var task1 = StartTask1Async();
await task1;
var task2 = StartTask2Async();
await task2;
}
Comportamento concorrente
async Task MyAsync() {
var task1 = StartTask1Async();
var task2 = StartTask2Async();
await task1;
await task2;
}
Aqui as tarefas começam em paralelo. É claro que os métodos podem retornar a tarefa imediatamente em um estado de execução, então não haverá paralelismo. Digamos que ambos os tarefas executem uma execução. E você esperou a primeira tarefa, depois a primeira espera decolou. Ou seja, assim que você escreveu await task1
, você decolou e não processou exception task2
. Curiosamente, este é um código absolutamente válido. E foi esse código que levou o .NET ao fato de que, na versão 4.5, o comportamento de trabalhar com execuções mudou.Manipulação de exceção
async Task MyAsync() {
var task1 = StartTask1Async();
var task2 = StartTask2Async();
await task1;
await task2;
}
Anteriormente, as execuções não tratadas simplesmente lançavam o processo e, se você não capturava alguma execução UnobservedExceptionHandler
(também são algumas static
que podem ser anexadas aos agendadores), esse processo não era executado. Agora, este é um código absolutamente válido. Embora o .NET tenha alterado seu comportamento, ele manteve a configuração para retornar o comportamento na direção oposta.async Task MyAsync(CancellationToken cancellationToken) {
await SomeTask1 Async(cancellationToken);
await Some Task2Async( cancellation Token);
}
try {
await MyAsync( cancellation Token);
} catch (OperationException e) {
} catch (Exception e) {
log.Error(e);
}
Veja como é o processamento da execução. CancellationToken-s deve ser transmitido, é necessário "manchar" todo o código de CancellationToken-s. O comportamento normal do assíncrono é que você não verifica em nenhum lugar Task.Status ancellationToken
, trabalha com código assíncrono da mesma maneira que com síncrono. Ou seja, no caso de um cancelamento, você obtém uma execução e, nesse caso, não faz nada quando a recebe OperationCanceledException
.A diferença entre o status de Cancelado e Falha é que você não recebeu OperationCanceledException
, mas a execução usual. E, neste caso, podemos prometer, você só precisa obter uma execução e tirar conclusões com base nisso. Se você iniciou a tarefa explicitamente, através da Tarefa, teria voado AggregateException
. E em assíncrono, no caso, eles AggregateException
sempre lançam a primeira execução que estava nela (neste caso - OperationCanceled
).Na prática
Método síncrono
DataTable<File, ProcessedFile> sharedMemory;
void SynchronousWorker(...) {
File f = blockingQueue.Dequeue();
ProcessedFile p = ProcessInParallel(f);
lock (_lock) {
sharedMemory.add(f, p);
}
}
Por exemplo, um demônio trabalha no ReSharper - um editor que tinge o arquivo para você. Se o arquivo for aberto no editor, há alguma atividade que o coloca em uma fila de bloqueio. Nosso processo worker
lê a partir daí, após o qual ele executa várias tarefas diferentes com esse arquivo, o tinge, analisa, cria, após o qual esses arquivos são adicionados sharedMemory
. Com uma sharedMemory
trava, outros mecanismos já estão trabalhando com ela.Método assíncrono
Ao reescrever o código para assíncrono, primeiro substituí-lo-emos void
por async Task
. Certifique-se de escrever a palavra "Async" no final. Todos os métodos assíncronos devem terminar em Async - esta é uma convenção.DataTable<File, ProcessedFile> sharedMemory;
async Task WorkerAsync(...) {
File f = blockingQueue.Dequeue();
ProcessedFile p = ProcessInParallel(f);
lock (_lock) {
sharedMemory.add(f, p);
}
}
Depois disso, você precisa fazer algo com o nosso blockingQueue
. Obviamente, se houver alguma primitiva síncrona, deve haver alguma primitiva assíncrona.
Esse primitivo é chamado de canal: os canais que vivem no pacote System.Threading.Channels
. Você pode criar canais e filas, limitados e ilimitados, que podem esperar de forma assíncrona. Além disso, você pode criar um canal com o valor "zero", ou seja, ele não terá um buffer. Esses canais são chamados de canais de encontro e são promovidos ativamente em Go e Kotlin. E, em princípio, se é possível usar canais em código assíncrono, esse é um padrão muito bom. Ou seja, alteramos a fila para o canal em que existem métodos ReadAsync
e WriteAsync
.ProcessInParallel é um monte de código paralelo que processa um arquivo e o transforma emProcessedFile
. O assíncrono pode nos ajudar a escrever códigos não assíncronos, mas paralelos, de maneira mais compacta?Simplificar código paralelo
O código pode ser reescrito desta maneira:DataTable<File, ProcessedFile> sharedMemory;
async Task WorkerAsync(...) {
File f = await channel.ReadAsync();
ProcessedFile p = await ProcessInParallelAsync(f);
lock (_lock) {
sharedMemory.add(f, p);
}
}
Como eles são ProcessInParallel
? Por exemplo, temos um arquivo Primeiro, dividimos em lexemas e podemos ter duas tarefas em paralelo: a criação de caches de pesquisa e a construção de uma árvore de sintaxe. Depois disso, vem a tarefa de "procurar erros semânticos". É importante aqui que todas essas tarefas formem um gráfico acíclico direcionado. Ou seja, você pode executar algumas partes em encadeamentos paralelos, outras não, e obviamente existem dependências de qual tarefa deve aguardar outras. Você obtém um gráfico dessas tarefas e deseja dispersá-las de alguma forma pelos threads. É possível escrevê-lo lindamente, sem erros? Em nosso código, esse problema foi resolvido várias vezes, cada vez de uma maneira diferente. Isso raramente acontece quando esse código é escrito sem erros.
Definimos esse gráfico de tarefas da seguinte forma: digamos que cada tarefa tenha outras tarefas das quais depende; em seguida, usando o dicionário ExecuteBefore, escrevemos o esqueleto do nosso método.Soluções esqueléticas
Dictionary<Action<ProcessedFile>, Action<ProcessedFile>[]> ExecuteBefore; async Task<ProcessedFile> ProcessInParallelAsync() {
var res = new ProcessedFile();
return res;
}
Se você resolver esse problema de frente, precisará fazer uma classificação topológica deste gráfico. Em seguida, pegue uma tarefa que não possui tarefas dependentes, execute-a, analise a estrutura sob um bloqueio, veja quais tarefas não possuem dependentes. Corra, espalhe-os de alguma forma Task Runner
. Escrevemos um pouco de forma mais compacta: classificação topológica do gráfico + execução de tais tarefas em diferentes threads.Async preguiçoso
Dictionary<Action<ProcessedFile>, Action<ProcessedFile>[]> ExecuteBefore;
async Task<ProcessedFile> ProcessInParallelAsync() {
var res = new ProcessedFile();
var lazy = new Dictionary<Action<ProcessedFile>, Lazy<Task>>();
foreach ((action, beforeList) in ExecuteBefore)
lazy[action] = new Lazy<Task>(async () =>
{
await Task.WhenAll(beforeList.Select(b => lazy[b].Value))
await Task.Yield();
action(res);
}
await Task.WhenAll(lazy.Values.Select(l => l.Value))
return res;
}
Existe um padrão chamado Async Lazy
. Criamos o nosso ProcessedFile
sobre o qual diferentes ações devem ser executadas. Vamos criar um dicionário: formataremos cada um dos nossos estágios (Action ProcessedFile) em alguma tarefa, ou melhor, em Lazy from Task e executaremos o gráfico original. A variável action
terá a própria ação , e em beforeList - aquelas ações que devem ser executadas antes da nossa. Então crie a Lazy
partir de action
. Escrevemos em Tarefa await
. Portanto, estamos aguardando todas as tarefas que devem ser concluídas antes dele. No beforeList, selecione o Lazy
que está neste dicionário.Observe que aqui nada será executado de forma síncrona, portanto esse código não será ativado ItemNotFoundException in Dictionary
. Realizamos todas as tarefas anteriores à nossa, realizando uma pesquisa por açãoLazy Task
. Então, executamos nossa ação. No final, você só precisa solicitar que cada tarefa inicie, caso contrário, você nunca sabe se algo não foi iniciado. Nesse caso, nada começou. Essa é a solução. Este método é escrito em 10 minutos, é absolutamente óbvio.Assim, o código assíncrono tomou nossa decisão, inicialmente ocupou duas telas com código competitivo complexo. Aqui ele é absolutamente consistente. Eu nem uso ConcurrentDictionary
, eu uso o habitual Dictionary
, porque não escrevemos nada para ele de forma competitiva. Existe um código consistente e consistente. Resolvemos o problema de escrever código paralelo usando async-s lindamente, o que significa - sem erros.Livre-se dos bloqueios
DataTable<File, ProcessedFile> sharedMemory;
async Task WorkerAsync(...) {
File f = await channel.ReadAsync();
ProcessedFile p = await ProcessInParallelAsync(f);
lock (_lock) {
sharedMemory.add(f, p);
}
}
Vale a pena puxar assíncrono e esses bloqueios? Agora, existem todos os tipos de bloqueios assíncronos, semáforos assíncronos, ou seja, uma tentativa de usar os primitivos que estão no código síncrono e assíncrono. Esse conceito parece estar errado, porque com o bloqueio você protege algo da execução paralela. Nossa tarefa é traduzir a execução paralela em seqüencial, porque é mais fácil. E se for mais simples, menos erros.Channel<Pair<File, ProcessedFile>> output;
async Task WorkerAsync(...) {
File f = await channel.ReadAsync();
ProcessedFile p = await ProcessInParallelAsync(f);
await output.WriteAsync();
}
Podemos criar um canal e colocar alguns arquivos e ProcessedFile, e ReadAsync
algum outro procedimento processará esse canal e o fará sequencialmente. O próprio bloqueio, além de proteger a estrutura, lineariza essencialmente o acesso, um local onde todos os segmentos dos consecutivos se tornam paralelos. E estamos substituindo isso explicitamente pelo canal.
A arquitetura é a seguinte: os trabalhadores recebem arquivos input
e os enviam para algum lugar no processador, que também processa tudo sequencialmente, sem paralelismo. O código parece muito mais simples. Entendo que nem tudo pode ser feito dessa maneira. Essa arquitetura, quando você pode criar canais de dados, nem sempre funciona.
Pode ser que você tenha um segundo canal que entra no seu processador e não é formado um gráfico direcionado acíclico a partir dos canais, mas um gráfico com ciclos. Este é um exemplo que Roman Elizarov disse à KotlinConf em 2018. Ele escreveu um exemplo no Kotlin com esses canais, e havia ciclos lá, e esse exemplo foi encerrado. O problema era que, se você tem esses ciclos em um gráfico, tudo se torna mais complicado no mundo assíncrono. Os bloqueios assíncronos são ruins, pois são muito mais difíceis de resolver do que síncronos quando você tem uma pilha de encadeamentos, e está claro o que se manteve. Portanto, é uma ferramenta que deve ser usada corretamente.Sumário
- Evite a sincronização no código assíncrono.
- O código de série é mais simples que paralelo.
- O código assíncrono pode ser simples e usar um mínimo de parâmetros e um contexto implícito que altera seu comportamento.
Se você desenvolveu o hábito de escrever código síncrono, e mesmo se o código assíncrono for muito semelhante ao síncrono, não arraste as primitivas para lá, com as quais você está acostumado no código síncrono async mutex
. Use feeds, se possível, e outras primitivas para transmissão de mensagens .O código de série é mais simples que paralelo. Se você puder escrever sua arquitetura de forma que pareça sequencialmente, sem executar código paralelo e bloqueio, escreva a arquitetura sequencialmente.E a última coisa que vimos de um grande número de exemplos com tarefas. Ao projetar seu sistema, tente confiar menos no contexto implícito. O contexto implícito leva a um mal-entendido do que está acontecendo no código, e você pode esquecer os problemas implícitos em um ano. E se outra pessoa trabalha nesse código e refaz algo nele, isso pode levar a dificuldades que você conhecia e o novo programador não conhece por causa do contexto implícito. Como resultado, o design inadequado é caracterizado por um grande número de parâmetros, sua combinação e contexto implícito.O que ler
-10 . DotNext .