Assincronia em C # e F #. Armadilhas de assincronia em C #

Olá, Habr! Apresento a sua atenção a tradução do artigo "Async in C # and F # Asynchronous gotchas in C #" de Tomas Petricek.



Em fevereiro, participei do Annual MVP Summit, um evento organizado pela Microsoft para MVPs. Aproveitei a oportunidade para visitar Boston e Nova York também, fazer duas palestras sobre F # e gravar a palestra do Channel 9 sobre provedores de tipos . Apesar de outras atividades (como visitar pubs, conversar com outras pessoas sobre F # e tirar longos cochilos pela manhã), eu também pude ter algumas discussões.



imagem



Uma discussão (não sob o NDA) foi a palestra da Async Clinic sobre as novas palavras-chave em C # 5.0 - async e await. Lucian e Stephen falaram sobre os problemas comuns que os desenvolvedores C # enfrentam ao escrever programas assíncronos. Nesta postagem, examinarei alguns dos problemas da perspectiva do F #. A conversa foi bastante animada e alguém descreveu a reação do público do F # da seguinte maneira:



imagem

(Quando MVPs escrevem em F #, veem exemplos de código C #, eles riem como garotas)



Por que isso está acontecendo? Acontece que muitos dos erros comuns são impossíveis (ou muito menos prováveis) ao usar o modelo assíncrono F # (que apareceu no F # 1.9.2.7, lançado em 2007 e fornecido com o Visual Studio 2008).



Pitfall # 1: Async não funciona de forma assíncrona



Vamos pular direto para o primeiro aspecto complicado do modelo de programação assíncrona C #. Dê uma olhada no exemplo a seguir e tente imaginar em que ordem as linhas serão impressas (não consegui encontrar o código exato mostrado na palestra, mas me lembro de Lucian demonstrando algo semelhante):



  async Task WorkThenWait()
  {
      Thread.Sleep(1000);
      Console.WriteLine("work");
      await Task.Delay(1000);
  }
 
  void Demo() 
  {
      var child = WorkThenWait();
      Console.WriteLine("started");
      child.Wait();
      Console.WriteLine("completed");
  }


Se você acha que "iniciado", "trabalho" e "concluído" serão impressos, você está errado. O código imprime "trabalho", "iniciado" e "concluído", tente você mesmo! O autor queria começar a trabalhar (chamando WorkThenWait) e depois esperar a conclusão da tarefa. O problema é que WorkThenWait começa fazendo alguns cálculos pesados ​​(aqui Thread.Sleep) e só depois disso usa o await.



Em C #, o primeiro trecho de código em um método assíncrono é executado de forma síncrona (no segmento do chamador). Você pode corrigir isso, por exemplo, adicionando await Task.Yield () no início.



Código F # correspondente



Em F #, isso não é um problema. Ao escrever código assíncrono em F #, todo o código dentro do bloco async {…} é adiado e executado mais tarde (quando você o executa explicitamente). O código C # acima corresponde ao seguinte em F #:



let workThenWait() = 
    Thread.Sleep(1000)
    printfn "work done"
    async { do! Async.Sleep(1000) }
 
let demo() = 
    let work = workThenWait() |> Async.StartAsTask
    printfn "started"
    work.Wait()
    printfn "completed"
  


Obviamente, a função workThenWait não realiza o trabalho (Thread.Sleep) como parte do cálculo assíncrono, e que será executado quando a função for chamada (e não quando o workflow assíncrono iniciar). Um padrão comum em F # é envolver todo o corpo de uma função em assíncrono. Em F #, você escreveria o seguinte, que funciona conforme o esperado:



let workThenWait() = async
{ 
    Thread.Sleep(1000)
    printfn "work done"
    do! Async.Sleep(1000) 
}
  


Armadilha nº 2: ignorar resultados



Aqui está outro problema com o modelo de programação assíncrona C # (este artigo foi retirado diretamente dos slides de Lucian). Adivinhe o que acontece quando você executa o seguinte método assíncrono:



async Task Handler() 
{
   Console.WriteLine("Before");
   Task.Delay(1000);
   Console.WriteLine("After");
}
 


Você espera que ele imprima "Antes", aguarde 1 segundo e, em seguida, imprima "Depois"? Errado! Ambas as mensagens serão impressas de uma vez, sem demora intermediária. O problema é que Task.Delay retorna uma Task, e esquecemos de esperar até que seja concluído (usando await).



Código F # correspondente



Novamente, você provavelmente não teria encontrado isso no F #. Você pode escrever facilmente um código que chame Async.Sleep e ignore o Async retornado:



let handler() = async
{
    printfn "Before"
    Async.Sleep(1000)
    printfn "After" 
}
 


Se você colar este código no Visual Studio, MonoDevelop ou Try F #, receberá imediatamente um aviso:



aviso FS0020: Esta expressão deve ter tipo unit, mas tem tipo Async ‹unit›. Use ignore para descartar o resultado da expressão ou let para vincular o resultado a um nome.


aviso FS0020: Esta expressão deve ser do tipo unidade, mas é do tipo Async ‹unit›. Use ignore para descartar o resultado de uma expressão ou let para associar o resultado a um nome.




Você ainda pode compilar o código e executá-lo, mas se ler o aviso, verá que a expressão retorna Async e você precisa aguardar seu resultado usando do!:



let handler() = async 
{
   printfn "Before"
   do! Async.Sleep(1000)
   printfn "After" 
}
 


Armadilha nº 3: métodos assíncronos que retornam vazio



Grande parte da conversa foi dedicada a métodos de vazio assíncronos. Se você escrever void Foo () {…} assíncrono, o compilador C # gera um método que retorna void. Mas, por baixo do capô, ele cria e executa uma tarefa. Isso significa que você não pode prever quando o trabalho será realmente feito.



No discurso, a seguinte recomendação foi feita sobre o uso do padrão de vazio assíncrono:



imagem

(Pelo amor de Deus, pare de usar vazio async!)



Para ser justo, deve-se notar que os métodos de vazio assíncrono podemser útil ao escrever manipuladores de eventos. Os manipuladores de eventos devem retornar void e geralmente iniciam algum trabalho que continua em segundo plano. Mas eu não acho que seja realmente útil no mundo MVVM (embora certamente faça boas demonstrações em conferências).



Deixe-me demonstrar o problema com um snippet do artigo da MSDN Magazine sobre Programação Assíncrona em C #:



async void ThrowExceptionAsync() 
{
    throw new InvalidOperationException();
}

public void CallThrowExceptionAsync() 
{
    try 
    {
        ThrowExceptionAsync();
    } 
    catch (Exception) 
    {
        Console.WriteLine("Failed");
    }
}
 


Você acha que este código exibirá "Falha"? Espero que você já entenda o estilo deste artigo ...

Na verdade, a exceção não será tratada, porque depois de iniciar o trabalho, ThrowExceptionAsync será encerrado imediatamente e a exceção será lançada em algum lugar em um thread de segundo plano.



Código F # correspondente



Portanto, se você não precisa usar os recursos de uma linguagem de programação, provavelmente é melhor não habilitar esse recurso em primeiro lugar. F # não permite que você escreva funções void assíncronas - se você envolver o corpo de uma função em um bloco {…} assíncrono, o tipo de retorno será Async. Se você usar anotações de tipo e exigir uma unidade, obterá uma incompatibilidade de tipo.



Você pode escrever um código que corresponda ao código C # acima usando Async.Start:



let throwExceptionAsync() = async {
    raise <| new InvalidOperationException()  }

let callThrowExceptionAsync() = 
  try
     throwExceptionAsync()
     |> Async.Start
   with e ->
     printfn "Failed"


A exceção também não será tratada aqui. Mas o que está acontecendo é mais óbvio, porque temos que escrever Async.Start explicitamente. Caso contrário, receberemos um aviso de que a função está retornando Async e estamos ignorando o resultado (assim como na seção anterior, Ignorando resultados).



Armadilha nº 4: funções lambda assíncronas que retornam void



A situação se torna ainda mais complicada quando você passa uma função lambda assíncrona para um método como delegado. Nesse caso, o compilador C # infere o tipo do método do tipo do delegado. Se você usar um delegado Action (ou semelhante), o compilador cria uma função void assíncrona que inicia o trabalho e retorna void. Se você usar o delegado Func, o compilador gera uma função que retorna Task.



Aqui está uma amostra dos slides de Lucian. Quando o próximo código (perfeitamente correto) terminará - um segundo (depois que todas as tarefas tiverem terminado de esperar) ou imediatamente?



Parallel.For(0, 10, async i => 
{
    await Task.Delay(1000);
});


Você não será capaz de responder a esta pergunta a menos que saiba que existem apenas sobrecargas para os delegados For that aceitam Action - e, portanto, o lambda sempre será compilado como um void assíncrono. Também significa que adicionar alguma carga (possivelmente carga útil) será uma alteração significativa.



Código F # correspondente



F # não tem "funções lambda assíncronas" especiais, mas você pode escrever uma função lambda que retorne cálculos assíncronos. Essa função retornará Async, portanto, não pode ser passada como um argumento para métodos que esperam um delegado de retorno void. O seguinte código não compilará:



Parallel.For(0, 10, fun i -> async {
  do! Async.Sleep(1000) 
})


A mensagem de erro simplesmente diz que o tipo de função int -> Async não é compatível com o delegado de ação (em F # deve ser int -> unidade):



Erro FS0041: Nenhuma correspondência de sobrecargas para o método For. As sobrecargas disponíveis são mostradas abaixo (ou na janela Lista de Erros).


erro FS0041: Nenhuma sobrecarga encontrada para o método For. As sobrecargas disponíveis são mostradas abaixo (ou na caixa de lista de erros).




Para obter o mesmo comportamento do código C # acima, devemos começar explicitamente. Se você deseja executar uma sequência assíncrona em segundo plano, isso é feito facilmente com Async.Start (que faz um cálculo assíncrono que retorna uma unidade, a agenda e retorna uma unidade):



Parallel.For(0, 10, fun i -> Async.Start(async {
  do! Async.Sleep(1000) 
}))


Você pode escrever isso, é claro, mas é muito fácil ver o que está acontecendo. Também é fácil perceber que estamos desperdiçando recursos, como a peculiaridade do Parallel.For é que ele realiza cálculos intensivos de CPU (que geralmente são funções síncronas) em paralelo.



Armadilha nº 5: tarefas de aninhamento



Acho que Lucian incluiu esta pedra apenas para testar a inteligência das pessoas na platéia, mas aqui está. A questão é: o código a seguir esperará 1 segundo entre os dois pinos do console?



Console.WriteLine("Before");
await Task.Factory.StartNew(
    async () => { await Task.Delay(1000); });
Console.WriteLine("After");


Inesperadamente, não há atraso entre essas conclusões. Como isso é possível? O método StartNew pega um delegado e retorna Task, onde T é o tipo retornado pelo delegado. Em nosso caso, o delegado retorna Task, então obtemos Task como resultado. aguarde apenas espera a conclusão da tarefa externa (que retorna imediatamente a tarefa interna), enquanto a tarefa interna é ignorada.



Em C #, isso pode ser corrigido usando Task.Run em vez de StartNew (ou removendo async / await na função lambda).



Você pode escrever algo assim em F #? Podemos criar uma tarefa que retornará Async usando a função Task.Factory.StartNew e uma função lambda que retorna um bloco assíncrono. Para esperar a conclusão da tarefa, precisaremos convertê-la para execução assíncrona usando Async.AwaitTask. Isso significa que obtemos Async <Async>:



async {
  do! Task.Factory.StartNew(fun () -> async { 
    do! Async.Sleep(1000) }) |> Async.AwaitTask }


Novamente, este código não compila. O problema é que o faça! requer Async à direita, mas na verdade recebe Async <Async>. Em outras palavras, não podemos simplesmente ignorar o resultado. Precisamos fazer algo sobre isso explicitamente

(você pode usar Async.Ignore para reproduzir o comportamento do C #). A mensagem de erro pode não ser tão clara quanto as anteriores, mas dá uma ideia geral:



erro FS0001: Esperava-se que esta expressão tivesse o tipo Async ‹unit›, mas aqui tem o tipo de unidade


erro FS0001: Expressão assíncrona 'unidade' esperada, tipo de unidade presente


Armadilha # 6: Async não funciona



Aqui está outra parte problemática do código do slide de Lucian. Desta vez, o problema é bem simples. O snippet a seguir define o método FooAsync assíncrono e o chama do Handler, mas o código não é executado de forma assíncrona:



async Task FooAsync() 
{
    await Task.Delay(1000);
}
void Handler() 
{
    FooAsync().Wait();
}


É fácil localizar o problema - chamamos FooAsync (). Wait (). Isso significa que criamos uma tarefa e, em seguida, usando Wait, bloqueamos o programa até que seja concluído. Uma simples remoção de Wait resolve o problema, porque queremos apenas iniciar a tarefa.



Você pode escrever o mesmo código em F #, mas os fluxos de trabalho assíncronos não usam tarefas .NET (originalmente projetadas para computação vinculada à CPU), mas usam o tipo F # Async, que não é fornecido com Wait. Isso significa que você deve escrever:



let fooAsync() = async {
    do! Async.Sleep(1000) }
let handler() = 
    fooAsync() |> Async.RunSynchronously


É claro que esse código pode ser escrito por acidente, mas se você se deparar com o problema de assincronia interrompida , notará facilmente que o código chama RunSynchronously, então o trabalho é feito - como o nome sugere - de forma síncrona .



Resumo



Neste artigo, examinei seis casos em que o modelo de programação assíncrona em C # se comporta de maneiras inesperadas. A maioria deles é baseada na conversa de Lucian e Stephen no MVP Summit, então, obrigado a ambos por uma lista interessante de armadilhas comuns!



Para F #, tentei encontrar os trechos de código relevantes mais próximos usando fluxos de trabalho assíncronos. Na maioria dos casos, o compilador F # emitirá um aviso ou erro - ou o modelo de programação não tem uma maneira (direta) de expressar o mesmo código. Acho que isso confirma uma afirmação que fiz em um post anterior : “O modelo de programação F # definitivamente parece mais apropriado para linguagens de programação funcionais (declarativas). Também acho que fica mais fácil raciocinar sobre o que está acontecendo. "



Finalmente, este artigo não deve ser entendido como uma crítica destrutiva à assincronia em C # :-). Eu entendo perfeitamente por que o design do C # segue os mesmos princípios que segue - para o C #, faz sentido usar Task (em vez de Async separado), que tem várias consequências. E posso entender as razões para as outras soluções - esta é provavelmente a melhor maneira de integrar a programação assíncrona em C #. Mas, ao mesmo tempo, acho que F # se sai melhor - em parte por causa de sua capacidade de composição, mas mais importante por causa de complementos legais como agentes F # . Além disso, a assincronia em F # também tem seus problemas (o erro mais comum é que as funções recursivas de cauda devem ser usadas return! Em vez de do!, Para evitar vazamentos), mas este é um tópico para uma postagem separada no blog.



PS Do tradutor. O artigo foi escrito em 2013, mas me pareceu interessante e relevante o suficiente para ser traduzido para o russo. Esta é a minha primeira postagem no Habré, então não chute forte.



All Articles