.NET: Ferramentas para traballar con multithreading e asincronía. Parte 1

Estou publicando o artigo orixinal sobre Habr, cuxa tradución está publicada na empresa publicación do blogue.

A necesidade de facer algo de forma asíncrona, sen esperar o resultado aquí e agora, ou de dividir un gran traballo entre varias unidades que o realizaban, existía antes da aparición dos ordenadores. Coa súa chegada, esta necesidade fíxose moi tanxible. Agora, en 2019, estou escribindo este artigo nun portátil cun procesador Intel Core de 8 núcleos, no que se están executando máis de cen procesos en paralelo, e aínda máis fíos. Preto, hai un teléfono un pouco en mal estado, comprado hai un par de anos, ten a bordo un procesador de 8 núcleos. Os recursos temáticos están cheos de artigos e vídeos onde os seus autores admiran os teléfonos intelixentes insignia deste ano que contan con procesadores de 16 núcleos. MS Azure ofrece unha máquina virtual cun procesador de 20 núcleos e 128 TB de RAM por menos de 2 dólares por hora. Desafortunadamente, é imposible extraer o máximo e aproveitar esta potencia sen poder xestionar a interacción dos fíos.

Terminoloxía

Proceso - Obxecto SO, espazo de enderezos illado, contén fíos.
Fío - un obxecto SO, a unidade máis pequena de execución, parte dun proceso, os fíos comparten memoria e outros recursos entre si dentro dun proceso.
Multitarea - Propiedade do SO, a capacidade de executar varios procesos á vez
Multi-núcleo - unha propiedade do procesador, a capacidade de usar varios núcleos para o procesamento de datos
Multiprocesamento - unha propiedade dun ordenador, a capacidade de traballar simultaneamente con varios procesadores fisicamente
Multithreading — unha propiedade dun proceso, a capacidade de distribuír o procesamento de datos entre varios fíos.
Paralelismo - realizar varias accións fisicamente simultaneamente por unidade de tempo
Asincronía — execución dunha operación sen agardar á finalización deste procesamento; o resultado da execución pódese procesar posteriormente.

Metáfora

Non todas as definicións son boas e algunhas precisan unha explicación adicional, polo que engadirei unha metáfora sobre cociñar o almorzo á terminoloxía introducida formalmente. Cociñar o almorzo nesta metáfora é un proceso.

Mentres preparaba o almorzo pola mañá eu (CPU) Chego á cociña (Ordenador). Teño 2 mans (Núcleos). Hai unha serie de aparellos na cociña (IO): forno, chaleira, torradeira, neveira. Encendo o gas, póñolle unha tixola e bótolle aceite sen esperar a que quente (de forma asíncrona, Non-Blocking-IO-Wait), saco os ovos da neveira e rómpeos nun prato, despois báteos cunha man (Fío nº 1), e segundo (Fío nº 2) sostendo o prato (Recurso Compartido). Agora gustaríame acender a chaleira, pero non teño mans suficientes (Fame de fío) Durante este tempo, a tixola quéntase (Procesando o resultado) na que boto o que montei. Alcanzo a chaleira e acéndoa e vexo estúpidamente a auga ferver nela (Bloqueo-IO-Espera), aínda que durante este tempo puido lavar o prato onde batía a tortilla.

Cociñei unha tortilla con só 2 mans, e non teño máis, pero ao mesmo tempo, no momento de bater a tortilla, facíanse 3 operacións á vez: bater a tortilla, suxeitar o prato, quentar a tixola. A CPU é a parte máis rápida do ordenador, IO é o que máis veces todo se ralentiza, polo que moitas veces unha solución eficaz é ocupar a CPU con algo mentres recibe datos de IO.

Continuando coa metáfora:

  • Se no proceso de elaboración dunha tortilla, tamén tentaría cambiarme de roupa, este sería un exemplo de multitarefa. Un matiz importante: os ordenadores son moito mellores nisto que as persoas.
  • Unha cociña con varios chefs, por exemplo nun restaurante: un ordenador multinúcleo.
  • Moitos restaurantes nunha praza de abastos nun centro comercial - centro de datos

Ferramentas .NET

.NET é bo para traballar con fíos, como con moitas outras cousas. Con cada nova versión, introduce máis e máis novas ferramentas para traballar con elas, novas capas de abstracción sobre fíos do sistema operativo. Cando se traballa coa construción de abstraccións, os desenvolvedores de frameworks usan un enfoque que deixa a oportunidade, cando se usa unha abstracción de alto nivel, de baixar un ou máis niveis por baixo. Na maioría das veces, isto non é necesario, de feito abre a porta para dispararse no pé cunha escopeta, pero ás veces, en casos raros, pode ser a única forma de resolver un problema que non se soluciona no nivel actual de abstracción. .

Por ferramentas refírome tanto ás interfaces de programación de aplicacións (API) que proporciona o framework e os paquetes de terceiros, como a solucións completas de software que simplifican a busca de calquera problema relacionado co código multiproceso.

Iniciando un fío

A clase Thread é a clase máis básica en .NET para traballar con fíos. O construtor acepta un dos dous delegados:

  • ThreadStart — Sen parámetros
  • ParametrizedThreadStart: cun parámetro de tipo obxecto.

O delegado executarase no fío recentemente creado despois de chamar ao método Start. Se se pasou un delegado do tipo ParametrizedThreadStart ao construtor, entón debe pasarse un obxecto ao método Start. Este mecanismo é necesario para transferir calquera información local ao fluxo. Paga a pena notar que crear un fío é unha operación cara e que o fío en si é un obxecto pesado, polo menos porque asigna 1 MB de memoria na pila e require interacción coa API do SO.

new Thread(...).Start(...);

A clase ThreadPool representa o concepto de pool. En .NET, o grupo de fíos é unha peza de enxeñaría, e os desenvolvedores de Microsoft esforzáronse moito para asegurarse de que funciona de forma óptima nunha gran variedade de escenarios.

Concepto xeral:

Desde o momento en que se inicia a aplicación, crea varios fíos en reserva en segundo plano e ofrece a posibilidade de tomalos para utilizalos. Se os fíos se usan con frecuencia e en gran cantidade, o grupo expandirase para satisfacer as necesidades da persoa que chama. Cando non haxa fíos libres no grupo no momento adecuado, ou ben agardará a que volva un dos fíos ou ben creará un novo. Polo tanto, o grupo de fíos é excelente para algunhas accións a curto prazo e pouco axeitado para operacións que se executan como servizos durante todo o funcionamento da aplicación.

Para utilizar un fío do grupo, hai un método QueueUserWorkItem que acepta un delegado de tipo WaitCallback, que ten a mesma sinatura que ParametrizedThreadStart, e o parámetro que se lle pasa realiza a mesma función.

ThreadPool.QueueUserWorkItem(...);

O método de agrupación de fíos menos coñecido RegisterWaitForSingleObject úsase para organizar operacións de E/S non bloqueantes. O delegado pasado a este método chamarase cando o WaitHandle pasado ao método sexa "Released".

ThreadPool.RegisterWaitForSingleObject(...)

.NET ten un temporizador de subprocesos e difire dos temporizadores de WinForms/WPF en que o seu controlador chamarase nun fío extraído do grupo.

System.Threading.Timer

Tamén hai unha forma bastante exótica de enviar un delegado para a súa execución a un fío do grupo: o método BeginInvoke.

DelegateInstance.BeginInvoke

Gustaríame determe brevemente na función á que se poden chamar moitos dos métodos anteriores: CreateThread da API Win32 Kernel32.dll. Hai unha forma, grazas ao mecanismo dos métodos externos, de chamar a esta función. Vin tal chamada só unha vez nun terrible exemplo de código legado, e a motivación do autor que fixo exactamente isto aínda segue sendo un misterio para min.

Kernel32.dll CreateThread

Visualización e depuración de fíos

Os fíos creados por ti, todos os compoñentes de terceiros e o grupo .NET pódense ver na xanela Threads de Visual Studio. Esta xanela só mostrará información do fío cando a aplicación estea en fase de depuración e en modo de interrupción. Aquí podes ver comodamente os nomes de pila e as prioridades de cada fío e cambiar a depuración a un fío específico. Usando a propiedade Priority da clase Thread, pode establecer a prioridade dun fío, que o OC e o CLR percibirán como unha recomendación ao dividir o tempo do procesador entre fíos.

.NET: Ferramentas para traballar con multithreading e asincronía. Parte 1

Biblioteca paralela de tarefas

Task Parallel Library (TPL) introduciuse en .NET 4.0. Agora é o estándar e a principal ferramenta para traballar coa asincronía. Calquera código que utilice un enfoque máis antigo considérase legado. A unidade básica de TPL é a clase Task do espazo de nomes System.Threading.Tasks. Unha tarefa é unha abstracción sobre un fío. Coa nova versión da linguaxe C#, temos unha forma elegante de traballar con Tarefas: operadores asíncrono/espera. Estes conceptos permitiron escribir código asíncrono coma se fose sinxelo e sincrónico, isto permitiu que incluso persoas con pouca comprensión do funcionamento interno dos fíos puidesen escribir aplicacións que os usan, aplicacións que non se conxelan cando se realizan operacións longas. Usar async/await é un tema para un ou incluso varios artigos, pero tentarei entender o suxeito en poucas frases:

  • async é un modificador dun método que devolve Task ou void
  • e await é un operador de espera de tarefas sen bloqueo.

Unha vez máis: o operador await, no caso xeral (hai excepcións), liberará aínda máis o fío de execución actual, e cando a Tarefa remate a súa execución, e o fío (de feito, sería máis correcto dicir o contexto). , pero máis diso máis adiante) continuará executando o método. Dentro de .NET, este mecanismo implícase do mesmo xeito que o retorno de rendemento, cando o método escrito se converte nunha clase enteira, que é unha máquina de estados e pode executarse en pezas separadas dependendo destes estados. Calquera persoa interesada pode escribir calquera código sinxelo usando asynс/wait, compilar e ver o conxunto usando JetBrains dotPeek co código xerado por compilador activado.

Vexamos as opcións para iniciar e usar unha tarefa. No exemplo de código a continuación, creamos unha nova tarefa que non fai nada útil (Thread.Sleep (10000)), pero na vida real debería ser un traballo complexo de CPU.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

Créase unha tarefa cunha serie de opcións:

  • LongRunning é un indicio de que a tarefa non se completará rapidamente, o que significa que pode valer a pena considerar non sacar un fío do grupo, senón crear un separado para esta tarefa para non prexudicar aos demais.
  • AttachedToParent: as tarefas pódense organizar nunha xerarquía. Se se utilizou esta opción, entón a tarefa pode estar nun estado no que se completou e está esperando a execución dos seus fillos.
  • PreferFairness - significa que sería mellor executar as tarefas enviadas para a súa execución antes que as enviadas máis tarde. Pero esta é só unha recomendación e os resultados non están garantidos.

O segundo parámetro que se pasa ao método é CancellationToken. Para xestionar correctamente a cancelación dunha operación despois de que se iniciase, o código que se está a executar debe cubrirse con verificacións do estado CancellationToken. Se non hai comprobacións, entón o método Cancel chamado no obxecto CancellationTokenSource poderá deter a execución da Tarefa só antes de que comece.

O último parámetro é un obxecto planificador de tipo TaskScheduler. Esta clase e os seus descendentes están deseñados para xestionar estratexias de distribución de Tarefas entre fíos; por defecto, a Tarefa executarase nun fío aleatorio do grupo.

O operador await aplícase á Tarefa creada, o que significa que o código escrito despois, se o hai, executarase no mesmo contexto (moitas veces isto significa no mesmo fío) que o código antes de await.

O método está marcado como async void, o que significa que pode usar o operador await, pero o código de chamada non poderá esperar a súa execución. Se tal función é necesaria, entón o método debe devolver Tarefa. Os métodos marcados como async void son bastante comúns: por regra xeral, estes son controladores de eventos ou outros métodos que funcionan no principio do lume e do esquecemento. Se precisa non só dar a oportunidade de esperar ata o final da execución, senón tamén devolver o resultado, entón cómpre usar Tarefa.

Na Tarefa que devolveu o método StartNew, así como en calquera outro, pode chamar ao método ConfigureAwait co parámetro falso, entón a execución despois de await continuará non no contexto capturado, senón nun arbitrario. Isto sempre debe facerse cando o contexto de execución non é importante para o código despois de agardar. Esta é tamén unha recomendación de MS ao escribir código que se entregará empaquetado nunha biblioteca.

Detémonos un pouco máis sobre como podes esperar a que se complete unha tarefa. A continuación móstrase un exemplo de código, con comentarios sobre cando a expectativa se fai condicionalmente ben e cando se fai condicionalmente mal.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

No primeiro exemplo, agardamos a que se complete a tarefa sen bloquear o fío de chamada; volveremos a procesar o resultado só cando xa estea alí; ata entón, o fío de chamada déixase no seu propio dispositivo.

Na segunda opción, bloqueamos o fío de chamada ata que se calcule o resultado do método. Isto é malo non só porque ocupamos un fío, un recurso tan valioso do programa, con simple inactividade, senón tamén porque se o código do método que chamamos contén agarda, e o contexto de sincronización require volver ao fío de chamada despois de agarda, entón teremos un punto morto: o fío de chamada espera a que se calcule o resultado do método asíncrono, o método asíncrono tenta en balde continuar coa súa execución no fío de chamada.

Outra desvantaxe deste enfoque é a complicada xestión de erros. O feito é que os erros no código asíncrono ao usar async/wait son moi fáciles de manexar: compórtanse igual que se o código fose sincrónico. Mentres que se aplicamos o exorcismo de espera sincrónico a unha Tarefa, a excepción orixinal convértese nunha AggregateException, é dicir. Para xestionar a excepción, terás que examinar o tipo InnerException e escribir unha cadea if dentro dun bloque catch ou usar o catch when construír, en lugar da cadea de bloques catch que é máis familiar no mundo C#.

O terceiro e último exemplo tamén están marcados como malos polo mesmo motivo e conteñen todos os mesmos problemas.

Os métodos WhenAny e WhenAll son extremadamente convenientes para esperar un grupo de Tarefas; envolven un grupo de Tarefas nun só, que se disparará cando se inicie por primeira vez unha Tarefa do grupo ou cando todas teñan completada a súa execución.

Parando fíos

Por varias razóns, pode ser necesario deter o fluxo despois de que comezou. Hai varias formas de facelo. A clase Thread ten dous métodos nomeados apropiadamente: Abortar и Interromper. O primeiro non é moi recomendable para o seu uso, porque despois de chamalo en calquera momento aleatorio, durante o procesamento de calquera instrución, lanzarase unha excepción ThreadAbortedException. Non esperas que se lance unha excepción ao incrementar calquera variable enteira, non? E cando se usa este método, esta é unha situación moi real. Se precisa evitar que o CLR xere tal excepción nunha determinada sección de código, pode envolvela en chamadas Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Calquera código escrito nun bloque finally está envolto nesas chamadas. Por este motivo, nas profundidades do código marco podes atopar bloques cun try baleiro, pero finalmente non un baleiro. Microsoft desaconsella tanto este método que non o incluíron no núcleo .net.

O método de interrupción funciona de forma máis previsible. Pode interromper o fío cunha excepción ThreadInterruptedException só nos momentos nos que o fío está en estado de espera. Entra neste estado mentres se colga mentres espera a WaitHandle, o bloqueo ou despois de chamar a Thread.Sleep.

As dúas opcións descritas anteriormente son malas debido á súa imprevisibilidade. A solución é utilizar unha estrutura Token de cancelación e clase CancellationTokenSource. A cuestión é esta: créase unha instancia da clase CancellationTokenSource e só quen a posúe pode deter a operación chamando ao método cancelar. Só se pasa o CancellationToken á propia operación. Os propietarios de CancellationToken non poden cancelar a operación eles mesmos, pero só poden comprobar se a operación foi cancelada. Hai unha propiedade booleana para isto IsCancellationRequested e método ThrowIfCancelRequested. Este último lanzará unha excepción TaskCancelledException se se chamou ao método Cancel na instancia de CancellationToken que se está a reproducir. E este é o método que recomendo usar. Esta é unha mellora con respecto ás opcións anteriores ao conseguir un control total sobre o momento en que se pode abortar unha operación de excepción.

A opción máis brutal para deter un fío é chamar á función TerminateThread da API Win32. O comportamento do CLR despois de chamar a esta función pode ser imprevisible. En MSDN está escrito o seguinte sobre esta función: "TerminateThread é unha función perigosa que só debe usarse nos casos máis extremos. "

Converter a API heredada a baseada en tarefas usando o método FromAsync

Se tiveches a sorte de traballar nun proxecto que se iniciou despois da introdución de Tarefas e deixou de causar un horror silencioso á maioría dos desenvolvedores, entón non terás que lidiar con moitas API antigas, tanto as de terceiros como as do teu equipo. torturou no pasado. Menos mal que o equipo de .NET Framework coidou de nós, aínda que quizais o obxectivo era coidarnos. Sexa como for, .NET dispón dunha serie de ferramentas para converter sen dor o código escrito en enfoques de programación asincrónica antigos ao novo. Un deles é o método FromAsync de TaskFactory. No exemplo de código a continuación, englobo os antigos métodos asíncronos da clase WebRequest nunha Tarefa usando este método.

object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
    wr.BeginGetResponse,
    we.EndGetResponse
);

Este é só un exemplo e é improbable que teñas que facelo con tipos integrados, pero calquera proxecto antigo simplemente está cheo de métodos BeginDoSomething que devolven os métodos IAsyncResult e EndDoSomething que o reciben.

Converte a API heredada en baseada en tarefas usando a clase TaskCompletionSource

Outra ferramenta importante a ter en conta é a clase TaskCompletionSource. En termos de funcións, propósito e principio de funcionamento, pode lembrar algo ao método RegisterWaitForSingleObject da clase ThreadPool, sobre o que escribín anteriormente. Usando esta clase, podes envolver de xeito sinxelo e cómodo as antigas API asíncronas en Tarefas.

Dirás que xa falei do método FromAsync da clase TaskFactory destinado a estes fins. Aquí teremos que lembrar toda a historia do desenvolvemento de modelos asíncronos en .net que ofreceu Microsoft durante os últimos 15 anos: antes do Patrón asincrónico baseado en tarefas (TAP), existía o Patrón de programación asíncrona (APP), que trataba de métodos ComezarFai algo que volve IAsyncResult e métodos finalFai algo que o acepte e para o legado destes anos o método FromAsync é perfecto, pero co paso do tempo, foi substituído polo patrón asíncrono baseado en eventos (E AP), que asumiu que se produciría un evento cando se completase a operación asíncrona.

TaskCompletionSource é perfecto para envolver Tarefas e API legadas construídas en torno ao modelo de eventos. A esencia do seu traballo é a seguinte: un obxecto desta clase ten unha propiedade pública de tipo Task, cuxo estado se pode controlar mediante os métodos SetResult, SetException, etc. da clase TaskCompletionSource. Nos lugares onde se aplicou o operador await a esta Tarefa, executarase ou fallará cunha excepción dependendo do método aplicado ao TaskCompletionSource. Se aínda non está claro, vexamos este exemplo de código, onde algunha API EAP antiga está envolta nunha Tarefa usando un TaskCompletionSource: cando se desenvolva o evento, a Tarefa transferirase ao estado Completado e ao método que aplicou o operador await. a esta Tarefa retomará a súa execución unha vez recibido o obxecto resultar.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

TaskCompletionFonte Consellos e trucos

Envolver API antigas non é todo o que se pode facer usando TaskCompletionSource. Usar esta clase abre unha interesante posibilidade de deseñar varias API en Tarefas que non están ocupadas por fíos. E o fluxo, como lembramos, é un recurso caro e o seu número está limitado (principalmente pola cantidade de RAM). Esta limitación pódese conseguir facilmente desenvolvendo, por exemplo, unha aplicación web cargada cunha lóxica empresarial complexa. Consideremos as posibilidades das que falo ao implementar un truco como Long-Polling.

En resumo, a esencia do truco é a seguinte: cómpre recibir información da API sobre algúns eventos que ocorren no seu lado, mentres que a API, por algún motivo, non pode informar do evento, senón que só pode devolver o estado. Un exemplo destes son todas as API construídas sobre HTTP antes dos tempos de WebSocket ou cando por algún motivo era imposible usar esta tecnoloxía. O cliente pode preguntarlle ao servidor HTTP. O servidor HTTP non pode iniciar a comunicación co cliente. Unha solución sinxela é sondear o servidor mediante un temporizador, pero isto crea unha carga adicional no servidor e un atraso adicional de media TimerInterval / 2. Para evitar isto, inventouse un truco chamado Long Polling, que consiste en atrasar a resposta de o servidor ata que expire o tempo de espera ou se produza un evento. Se o evento ocorreu, procesase, se non, a solicitude envíase de novo.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Pero tal solución resultará terrible en canto aumente o número de clientes que agardan polo evento, porque... Cada un destes clientes ocupa un fío enteiro á espera dun evento. Si, e recibimos un atraso adicional de 1 ms cando se activa o evento, a maioría das veces isto non é significativo, pero por que empeorar o software do que pode ser? Se eliminamos Thread.Sleep(1), entón en balde cargaremos un núcleo de procesador 100% inactivo, xirando nun ciclo inútil. Usando TaskCompletionSource pode facilmente refacer este código e resolver todos os problemas identificados anteriormente:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        tasks[userId] = cs;
        await Task.WhenAny(Task.Delay(duration), cs.Task);
        return cs.Task.IsCompleted ? cs.Task.Result : null;
    }

    public void SendMessage(int userId, Msg m) {

        if (tasks.TryGetValue(userId, out var completionSource))
            completionSource.SetResult(m);
    }
}

Este código non está listo para a produción, senón só unha demostración. Para usalo en casos reais, tamén é necesario, como mínimo, xestionar a situación cando chega unha mensaxe nun momento no que ninguén a espera: neste caso, o método AsseptMessageAsync debería devolver unha Tarefa xa completada. Se este é o caso máis común, podes pensar en usar ValueTask.

Cando recibimos unha solicitude de mensaxe, creamos e colocamos unha TaskCompletionSource no dicionario e, a continuación, agardamos o que ocorre primeiro: o intervalo de tempo especificado caduca ou se recibe unha mensaxe.

ValueTask: por que e como

Os operadores asíncronos/espera, como o operador de retorno de rendemento, xeran unha máquina de estado a partir do método, e esta é a creación dun novo obxecto, que case sempre non é importante, pero en casos raros pode crear un problema. Este caso pode ser un método que se chama con moita frecuencia, estamos a falar de decenas e centos de miles de chamadas por segundo. Se un método deste tipo está escrito de tal xeito que na maioría dos casos devolve un resultado sen pasar por alto todos os métodos await, entón .NET ofrece unha ferramenta para optimizalo: a estrutura ValueTask. Para que quede claro, vexamos un exemplo do seu uso: hai unha caché á que acudimos con moita frecuencia. Hai algúns valores nel e despois simplemente devolvémolos; se non, imos a algún IO lento para obtelos. Quero facer o último de forma asíncrona, o que significa que todo o método resulta ser asíncrono. Así, a forma obvia de escribir o método é a seguinte:

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

Debido ao desexo de optimizar un pouco e a un lixeiro temor ao que xerará Roslyn ao compilar este código, podes reescribir este exemplo do seguinte xeito:

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

De feito, a solución óptima neste caso sería optimizar o hot-path, é dicir, obter un valor do dicionario sen ningunha asignación innecesaria e carga no GC, mentres que naqueles raros casos nos que aínda necesitamos ir a IO para obter datos. , todo seguirá sendo un plus/menos do xeito antigo:

public ValueTask<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask<string>(val);
    return new ValueTask<string>(RequestById(id));
}

Vexamos máis de cerca este anaco de código: se hai un valor na caché, creamos unha estrutura, se non, a tarefa real quedará envolta nunha significativa. O código de chamada non lle importa en que ruta se executou este código: ValueTask, desde o punto de vista da sintaxe C#, comportarase igual que unha Task normal neste caso.

TaskSchedulers: xestión de estratexias de lanzamento de tarefas

A seguinte API que me gustaría considerar é a clase Programador de tarefas e os seus derivados. Xa mencionei anteriormente que TPL ten a capacidade de xestionar estratexias para distribuír tarefas entre fíos. Tales estratexias defínense nos descendentes da clase TaskScheduler. Case calquera estratexia que necesites pódese atopar na biblioteca. ParallelExtensionsExtras, desenvolvido por Microsoft, pero non forma parte de .NET, senón que se ofrece como paquete Nuget. Vexamos brevemente algúns deles:

  • Current ThreadTaskScheduler — executa Tarefas no fío actual
  • LimitedConcurrencyLevelTaskScheduler — limita o número de Tarefas executadas simultaneamente polo parámetro N, que é aceptado no construtor
  • OrdenadoTaskScheduler — defínese como LimitedConcurrencyLevelTaskScheduler(1), polo que as tarefas executaranse secuencialmente.
  • WorkStealingTaskScheduler - apeiros roubo de traballo enfoque da distribución de tarefas. Esencialmente, é un ThreadPool separado. Resolve o problema de que en .NET ThreadPool é unha clase estática, unha para todas as aplicacións, o que significa que a súa sobrecarga ou uso incorrecto nunha parte do programa pode levar a efectos secundarios noutra. Ademais, é moi difícil comprender a causa destes defectos. Iso. Pode ser necesario utilizar WorkStealingTaskSchedulers separados en partes do programa onde o uso de ThreadPool pode ser agresivo e imprevisible.
  • QueuedTaskScheduler — permítelle realizar tarefas segundo as regras de fila de prioridade
  • ThreadPerTaskScheduler — crea un fío separado para cada Tarefa que se executa nel. Pode ser útil para tarefas que tardan moito tempo en completarse.

Hai un bo detallado artigo sobre TaskSchedulers no blog de Microsoft.

Para a depuración cómoda de todo o relacionado coas tarefas, Visual Studio ten unha xanela de Tarefas. Nesta xanela podes ver o estado actual da tarefa e ir á liña de código que se está a executar.

.NET: Ferramentas para traballar con multithreading e asincronía. Parte 1

PLinq e a clase Paralela

Ademais das Tarefas e todo o que se di sobre elas, hai dúas ferramentas máis interesantes en .NET: PLinq (Linq2Parallel) e a clase Parallel. O primeiro promete a execución paralela de todas as operacións de Linq en varios fíos. O número de fíos pódese configurar usando o método de extensión WithDegreeOfParallelism. Desafortunadamente, a maioría das veces PLinq no seu modo predeterminado non ten información suficiente sobre os elementos internos da súa fonte de datos para proporcionar unha ganancia de velocidade significativa, por outra banda, o custo de probar é moi baixo: só precisa chamar ao método AsParallel antes. a cadea de métodos Linq e realizar probas de rendemento. Ademais, é posible pasar información adicional a PLinq sobre a natureza da súa fonte de datos mediante o mecanismo de Particións. Podes ler máis aquí и aquí.

A clase estática Parallel proporciona métodos para iterar a través dunha colección Foreach en paralelo, executar un bucle For e executar varios delegados en paralelo Invoke. A execución do fío actual deterase ata que se completen os cálculos. O número de fíos pódese configurar pasando ParallelOptions como último argumento. Tamén pode especificar TaskScheduler e CancellationToken usando opcións.

Descubrimentos

Cando comecei a escribir este artigo baseándome nos materiais do meu informe e na información que recollín durante o meu traballo despois del, non esperaba que houbese tanto. Agora, cando o editor de texto no que escribo este artigo con reproche me diga que se foi a páxina 15, vou resumir os resultados intermedios. Outros trucos, API, ferramentas visuais e trampas trataranse no seguinte artigo.

Conclusións:

  • Cómpre coñecer as ferramentas para traballar con fíos, a asincronía e o paralelismo para poder utilizar os recursos dos ordenadores modernos.
  • .NET ten moitas ferramentas diferentes para estes fins
  • Non todas apareceron á vez, polo que moitas veces podes atopar outras antigas, pero hai formas de converter API antigas sen moito esforzo.
  • Traballar con fíos en .NET está representado polas clases Thread e ThreadPool
  • Os métodos Thread.Abort, Thread.Interrupt e Win32 API TerminateThread son perigosos e non se recomenda o seu uso. Pola contra, é mellor usar o mecanismo CancellationToken
  • O fluxo é un recurso valioso e a súa oferta é limitada. Deben evitarse situacións nas que os fíos están ocupados esperando eventos. Para iso é conveniente usar a clase TaskCompletionSource
  • As ferramentas .NET máis potentes e avanzadas para traballar co paralelismo e a asincronía son Tarefas.
  • Os operadores c# async/wait implementan o concepto de espera sen bloqueo
  • Pode controlar a distribución de Tarefas en fíos usando clases derivadas de TaskScheduler
  • A estrutura ValueTask pode ser útil para optimizar as rutas quentes e o tráfico de memoria
  • As fiestras de Tarefas e fíos de Visual Studio proporcionan moita información útil para depurar código multiproceso ou asíncrono
  • PLinq é unha ferramenta xenial, pero é posible que non teña suficiente información sobre a túa fonte de datos, pero isto pódese solucionar mediante o mecanismo de partición
  • Continuar ...

Fonte: www.habr.com

Engadir un comentario