Programação assíncrona no .NET: práticas recomendadas

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 Taskno .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() {//synchronous code
 
    Foo(params,() =>{//asynchronous code;continuation
    });
}

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() {
    ... //synchronous code
    
    Foo(params, () => { 
      ... //continuation 1 
      Bar(() => {
        //continuation 2
        Baz(() => {
          //continuation 3
        }); 
      });
    });
}

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() {
    ... //synchronous code 
    Foo(params, () => {
      ... //asynchronous code on success 
    },
    () => {
        ... //asynchronous code on failure
    }); 
}

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ê WaitHandlepode esperar, aguardando a conclusão da operação de forma assíncrona. Por outro lado, você pode chamar EndOperation, isto é, criar EndReade 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 OperationNameAsyncalgum objeto que tenha o evento Concluído e nos inscrevamos nesse evento. Como você notou, BeginOperationNamemuda para OperationNameAsync. Pode ocorrer confusão quando você entra na classe Socket, onde dois padrões são misturados: ConnectAsynce 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 RnToCompletionseparaçã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 IsCompletedque retorna uma continuação sem êxito, mas RnToCompletion, Cancelede 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 CancellationTokenisso 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 TaskCompletionSourepermite 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(() =>{
    ... //synchronous code
  })
  .ContinueWith(_ =>{
    Foo(); //continuation 1
  })
  .ContinueWith(_ =>{
    Bar(); //continuation 2
  })
  .ContinueWith(_ =>{
    Baz(); //continuation 3
  })
}

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 é scheduleraquele 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 TaskSchedulerpara thread pool, o método Queueleva um fio de thread poole envia sua tarefa lá.

Se você assumir schedulero 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 StartNewdentroAction, ThreadStatic. Currentexibido no TaskSchedulerque nós demos a ela.

Esse design parece bastante controverso devido ao contexto implícito. Houve casos em que ele TaskSchedulercontinha código assíncrono que herdou muito profundamente em algum lugar TaskScheduler.Currente 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 ThreadStaticconfiguração.

Tudo é o mesmo com continuações. Surge a pergunta: de onde ela vem TaskSchedulerpara continuações? Primeiro de tudo, é utilizado no método em que você iniciou Continuation. Também é TaskSchedulerretirado do ThreadStatic. É importante que, para assíncrono / espera, continuações funcionem de maneira muito diferente.



Nos voltamos para os parâmetros TaskCreationOptionse 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 ExecuteSynchronouslye RunContinuationsAsynchronouslyrepresentam 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 SetResultpara adaptar os padrões assíncronos anteriores ao mundo da tarefa. Você TaskCompletionSourcepode solicitar tcs.Task, e esta tarefa entrará em um estado finishquando 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.Waitquando tcsexposto.

Na linha azul chegamos a tcs, e depois o mais interessante. Com base em considerações internas do .NET, ele TaskCompletionSourceacredita que a continuação disso tcspode ser executada de forma síncrona, ou seja, diretamente na mesma pilha, e então task.Waitrealizada 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 SetResultsob 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 TaskCompletionSourcepena 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.SetResulttem algo sob o qual nada será lançado.



Por que a continuação deve ser realizada de forma síncrona? Porque se RunContinuationsAsynchronouslyrefere 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(() => 
{
  //... some parent activity

   Task.Factory.StartNew(() => {
      //... some child activity
   })

})
.ContinueWith(...) // don’t wait for child

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, ContinueWithnão esperará a tarefa iniciada dentro.



Se você escrever TaskCreationOptions.AttachedToParent, isso ContinueWithirá 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(() => 
{
  //... some parent activity
  Foo(); 

})
.ContinueWith(...) // still wait for child

void Foo() { 
   Task.Factory.StartNew(() => {
      //... parent task to attach is in ThreadStatic
   }, TaskCreationOptions.AttachedToParent); 
}

Pode haver um problema no qual a tarefa é transferida para algum lugar ThreadStatic, e tudo o que você iniciou AttachedToParentserá adicionado a essa tarefa pai, que é uma campainha de alarme.

Task.Factory.StartNew(() => 
{
  //... some parent activity

  Foo();
}, TaskCreationOptions.DenyChildAttach)
.ContinueWith(...) // don’t wait for child

void Foo() { 
   Task.Factory.StartNew(() => {
      //... some child activity
   }, 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(() => 
{
  //... some parent activity

  Foo(); 

})
.ContinueWith(...) //don’t wait for child

void Foo() { 
   Task.Factory.StartNew(() => {
      //... some child activity
    }, TaskCreationOptions.AttachedToParent); 
}

Vale lembrar que Task.Runesta é a maneira padrão de começar, o que, por padrão, implica DenyChildAttach.

O contexto implícito que você coloca ThreadStaticadiciona 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 OperationCanceledExceptionsão iguais.

Task.Factory.StartNew(() => 
{
    throw new OperationCanceledException(cancellationToken); 
}, cancellationToken);

                                                      Canceled

Para que a tarefa seja capaz Canceled, é necessário jogá-la OperationCanceledExceptionjunto 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() {
             //how to get cancellation token?
          } 
    }
}

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); // use AsyncLocal to put cancellation int

  void Foo() { 
     async Bar() {
      ...
         Baz() {
             asyncLocalCancellation.Value.CheckForInterrupt(); 
         }
   } 
}

É o mesmo que, ThreadStaticapenas o especial ThreadLocalque sobrevive a viagens assíncronas / a aguardar código. Como seu código é assíncrono e você tem esse kancellation, você o coloca AsyncLocale, em algum nível profundo, você pode dizer " CheckForInterrupt Throw If Cancellation Requested". Novamente, este é o único parâmetro CancellationTokenque precisa manchar completamente o código inteiro, mas, na minha opinião, para a maioria das tarefas, você só precisa saber o que aconteceu OperationCanceledExceptione, 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) //puts lifetime in AsyncLocal

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.StartNewe 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 InnerAsyncserá 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 InnerAsynce 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(); //guaranteed !IsCompleted 
   continuationCode();
}

Há um truque Task.Yield- essa é uma tarefa especial que garante que seu garçom nem sempre retorne para você IsCompleted. Por conseguinte, continuationnã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 InnerAsyncconcluí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, é chamadoSynchronizationContexte 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 SynchronizationContexte 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 TaskAwaiterdaquele 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(); //code must be absolutely context-agnostic
}

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. ConfigureAwaitpode 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() { //UI thread 
  synchronousCode();

    await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false); 
    continuationCode();
}
myFuncAsync().Wait() //on UI thread

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 continuationCodenunca será lançado e, Waitportanto , nunca retornará. Tudo isso acontece no começo.

async Task OnBluttionClick() { //UI thread 
  int v = Button.Text.ParseInt();

    await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false); 
  Button.Text.Set((v+1).ToString());
}
myFuncAsync().Wait() //on UI thread

Imagine que essa é uma atividade real. Clicamos no botão, pegamos Button.ParseInt, aguardamos , escrevemos ConfigureAwaitDizemos: "Por favor, não feche o fluxo da interface do usuário, execute a continuação". O problema é que queremos que a segunda parte ConfigureAwaittambé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() { //UI thread 
  synchronousCode();

    await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false); 
    continuationCode(); //The same UI context
}
PumpUntil(() => task.IsCompleted);
//VS synchronization contexts always pump on any Wait

Com um encadeamento da interface do usuário, você deve proibi-lo Waitem encadeamentos que tenham uma fila de mensagens comum. Em vez de fazer Waitou 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é SynchronizationContextmudou 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(); // on initial context

    await myTaskScheduler;
    continuationCode(); //on scheduler context 
}

Existe outra maneira interessante de usar async / waitit . Você pode escrever Awaiterem schedulere 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(); // on initial context

    await Task.Factory.StartNew(() => {...}, myTaskScheduler);
    continuationCode(); //on initial context 
}

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;

  // if task1 throws exception and task2 throws exception we only throw and
  // handle task1’s exception

  //4.0 -> 4.5 framework: unhandled exceptions now don’t crush process
  //still visible in UnobservedExceptionHandler
}

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 staticque 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); 
  //you should always pass use async API with cancelationToken  if possible 
} 
  
try { 
    await  MyAsync( cancellation  Token); 
} catch (OperationException e) { // do nothing: OCE happened
} 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 AggregateExceptionsempre lançam a primeira execução que estava nela (neste caso - OperationCanceled).

Na prática


Método síncrono


DataTable<File, ProcessedFile> sharedMemory;

// in any thread
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 workerlê 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 sharedMemorytrava, outros mecanismos já estão trabalhando com ela.

Método assíncrono


Ao reescrever o código para assíncrono, primeiro substituí-lo-emos voidpor 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;
// in any thread
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 ReadAsynce 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;

// in any thread
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();


  // lots of work with toposort, locks, etc.

  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 ProcessedFilesobre 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 actionterá a própria ação , e em beforeList - aquelas ações que devem ser executadas antes da nossa. Então crie a Lazypartir de action. Escrevemos em Tarefa await. Portanto, estamos aguardando todas as tarefas que devem ser concluídas antes dele. No beforeList, selecione o Lazyque 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;

// in any thread
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;
// in any thread
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 ReadAsyncalgum 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 inpute 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 .

All Articles