¿Es posible crear su propia tarea asincrónica sin bloqueo en C#?

CorePress2024-01-25  10

Muchas de las funciones IO integradas en C# no son bloqueantes, es decir, no retienen su hilo mientras esperan que se complete su operación.

Por ejemplo System.IO.File.ReadAllLinesAsync que devuelve una tarea<string[]> no es bloqueante.

No solo pausa el hilo que está usando, sino que en realidad lo libera para que otros procesos puedan usarlo.

Supongo que esto se logra llamando al sistema operativo de tal manera que el sistema operativo vuelva a llamar al programa cuando haya recuperado el archivo sin que el programa tenga que desperdiciar un hilo esperándolo.

¿Es posible crear una tarea asíncrona sin bloqueo usted mismo?

Hacer algo como Thread.sleep() claramente no libera el hilo actual como lo hace System.IO.File.ReadAllLinesAsyncs.

Me doy cuenta de que un hilo inactivo no consume recursos de la CPU, pero aún así ocupa un hilo, lo que puede ser un problema en un servidor web que maneja numerosas solicitudes.

No estoy hablando de cómo generar tareas en general. Estoy hablando de lo que hacen las funciones integradas de C# para manejar llamadas de archivo/red para liberar sus subprocesos mientras esperan.

3

Tienes que esperar algo. Cuando se crea una tarea que debe esperar algún evento externo, este suele ser un TaskCompletionSource.

– Klaus Gütter

28/03/2021 a las 15:27

Hice una búsqueda rápida en Google "C# task io" y el segundo éxito fue este bonito artículo de Microsoft, ¿es útil?

- JH Bonarius

28/03/2021 a las 15:36

@JHBonarius Sí, parece que este es un tema bastante profundo que normalmente no es necesario abordar.tratar porque las funciones integradas lo manejan. Es bueno saber que existen varias formas de implementar una tarea y no todas crean un hilo. ¡Gracias!

-markv12

28/03/2021 a las 15:53

1

Quizás quieras echarle un vistazo a esto: ¿Por qué File.ReadAllLinesAsync() bloquea el subproceso de la interfaz de usuario? A veces la realidad no coincide con nuestras expectativas.

- Theodor Zoulias

28/03/2021 a las 18:15



------------------------------------

Básicamente, cada función asíncrona que libera un hilo finalmente se compila en una devolución de llamada, normalmente ejecutada por el sistema operativo.

En la terminología moderna, este estilo suele denominarse Promesa, pero ha formado parte de todos los buenos sistemas operativos desde tiempos inmemoriales. El método general es tomar una función de devolución de llamada y registrarla, luego iniciar algún tipo de operación. Cuando se completa la operación, se llama a la devolución de llamada.

Esto llega hasta el nivel del procesador, donde los dispositivos IO señalan una línea de interrupción, que pasa al kernel del sistema operativo, los controladores en modo kernel, los controladores en modo usuario y, finalmente, algún tipo de espera que controla una aplicación. el hilo está esperando (como mensajes de ventana o IO asíncrono).

Echemos un vistazo más profundo auno de los principales ejemplos para ver cómo se hace. Revisaremos el repositorio principal de .NET Github, así como los documentos de Win32 en MSDN. Se aplican principios similares a la mayoría de los sistemas operativos modernos. Voy a asumir que ya tengo un conocimiento razonable de las operaciones básicas de E/S y de los componentes básicos de las PC modernas.

Clases de IO masivas como FileStream, Socket, PipeStream, SerialPort

Estos utilizan métodos bastante similares. Veamos solo FileStream.

Al revisar el código fuente, utiliza una clase llamada AsyncWindowsFileStreamStrategy, que a su vez utiliza una API Win32 llamada Overlapped IO. Eventualmente pasa a través de una función de devolución de llamada a ThreadPoolBoundHandle.AllocateNativeOverlapped y toma la estructura OVERLAPPED resultante para pasarla a las API de Win32 como ReadFileEx.

No tenemos la fuente para Win32, pero a nivel general, estas funciones llamarán a las API Kernel32 y ntdll. Estos, a su vez, pasan al modo kernel, donde los controladores del sistema de archivos pasan a los controladores de disco.

El sistema que utiliza la mayoría del hardware IO masivo, como unidades y adaptadores de red, es el acceso directo a la memoria. El controlador simplemente le indicará al hardware en qué parte de la RAM colocar los datos. El hardware carga los datos directamente en la RAM, sin pasar por la CPU.

Luego envía una señal a una línea de interrupción a la CPU, que detiene lo que estaba haciendo y transfiere el control al controlador de interrupciones del núcleo. Esto luego transfiere el control de regreso a la cadena a los conductores, nuevamente al modo de usuario y, finalmente, la devolución de llamada en la aplicación está lista para funcionar.

¿Qué recoge la devolución de llamada en la aplicación? La clase ThreadPool (thLa versión nativa, que está aquí), que utiliza un puerto de finalización de IO (esto se usa para fusionar muchas devoluciones de llamadas de IO en un solo identificador para esperar). Los subprocesos de nivel nativo en nuestra aplicación realizan un bucle continuo en una llamada a GetQueuedCompletionStatus, que se bloquea si no hay nada disponible. Tan pronto como regresa, se activa la devolución de llamada relevante, que regresa a nuestro FileStream y finalmente continúa nuestra función donde la dejamos, como se verá más adelante.

Esto puede estar o no en nuestro hilo nativo original, dependiendo de cómo hayamos configurado nuestro SynchronizationContext. Si necesitamos ordenar una devolución de llamada al hilo de la interfaz de usuario, esto se hace a través de un mensaje en la ventana.

Controladores de espera como ManualResetEvent, Semaphore y ReaderWriterLock, así como la mensajería de ventana clásica

Estos bloquean completamente el hilo de llamada, no se pueden usar con async/await directamente, ya que dependen completamente del modelo de subprocesos de Win32. Pero ese modelo general es algo similar a Task: puede esperar un evento o varios eventos y enviar sus devoluciones de llamada cuando sea necesario. Hay versiones independientes de algunos de estos que son compatibles con async/await.

Un evento de espera es esencialmente una llamada al kernel que dice "suspenda mi hilo hasta que suceda tal o cual cosa".

¿Qué sucede con los subprocesos del sistema operativo nativo cuando se suspenden?

Los subprocesos del sistema operativo nativo se ejecutan continuamente en los núcleos del procesador. El programador del kernel de Win32 configura temporizadores de procesador de hardware para interrumpir subprocesos y ceder el paso a otros que puedan necesitar ejecutarse. En cualquier momento, si el esquema Win32 suspende un subproceso nativoduler (ya sea cuando se le solicita o debido al rendimiento del programador), se elimina de la cola de subprocesos ejecutables. Tan pronto como un hilo está listo para funcionar nuevamente, se coloca en la cola ejecutable y se ejecutará cuando el programador tenga la oportunidad.

Si no hay más subprocesos para ejecutar, el procesador se detiene por bajo consumo de energía y se activa con la siguiente señal de interrupción.

Tarea y asíncrono/espera

Este es un tema muy extenso que voy a dejar en manos de otros. Pero volviendo a mi premisa original de que la liberación de un hilo desencadena una devolución de llamada a nivel del sistema operativo: ¿cómo hace esto Task?

Lo primero es lo primero, ya hemos cometido un error. Un hilo y una tarea son cosas diferentes. Un hilo sólo puede ser suspendido por el núcleo, una tarea es sólo una unidad de trabajo que queremos que se realice, que podemos retomary suelte según sea necesario.

Cuando se alcanza una espera en el nivel más profundo (el punto en el que queremos suspender la ejecución), cualquier devolución de llamada se registra como mencionamos anteriormente. Cuando se llama, la función de devolución de llamada pondrá en cola el código de continuación de la tarea en el programador para su ejecución. Task utiliza el programador existente configurado por CLR para seleccionar y eliminar tareas y continuaciones según sea necesario.

Finalmente, TaskScheduler es la clase que implementa la lógica sobre cómo programar tareas: ¿deberían ejecutarse a través de ThreadPool? ¿Deberían volver a conectarse al subproceso de la interfaz de usuario o simplemente ejecutarse en línea en un bucle?

2

1

No entiendo cómo esta respuesta solo tiene un voto. Esto me ayudó bastante

- Yarek T

1 de noviembre de 2022 a las 11:53

Aclaraste mis años de confusión sobre la programación asincrónica. Lo que cientos de artículos no pueden hacer, lo lograste con esta simple respuesta. Eres genial. Gracias.

- Rahul

ayer



------------------------------------

Para tareas vinculadas a IO

Para tareas vinculadas a IO, simplemente puede definir un método de tipo Tarea<T> y devolver su valor de tipo T en el método. Por ejemplo, si tiene un método string getHTML(string url), puede llamarlo de forma asincrónica así:

public async Task<string> getHTMLAsync(string url) {
    return getHTML(url)
}

Puedes ver un ejemplo de esto en la fuente de referencia del método System.IO.File.ReadAllLinesAsync.

Para tareas vinculadas a la CPU

La clase Task en el espacio de nombres System.Threading.Tasks debe proporcionar la funcionalidad que busca. Puede usarlo para crear un objeto Tarea para ejecutar cualquier proceso que esté intentando lograr. Por ejemplo, si tiene un método int LongRunner que tarda mucho en ejecutarse y desea acceder a él de forma asincrónica, puede definir Task<int> longRunnerAsync:

public Task<int> LongRunnerAsync() {
    return Task.Run( () => LongRunner() );
}

Hay varias formas de definir su tarea personalizada:

Definición de una tarea utilizando el método Task.Run(...). Este es mi método predeterminado para definir una tarea, ya que es sencillo de escribir e inicia la tarea inmediatamente. Puedes hacerlo llamando a:
Task.Run( () => {
    doWork();
}
Definir una tarea para ejecutar una acción predefinida utilizando el constructor. Esto le permite definir una tarea que no se inicia inmediatamente. Esto se puede hacer con:
Action action = () => doWork();
Task task = new Task(action);
task.Start();
Definición de una tarea utilizando el método Task.Factory.StartNew(...). Este método permite una mayor personalización que Task.Run(...) pero proporciona una funcionalidad similar. Solo recomendaría usar este método si hay una razón específica por la que lo necesita en lugar de Task.Run(...)

Consulte la página de documentación de Microsoft.

4

Buena respuesta, especialmente la referencia a los documentos. Todavía faltan muchas cosas, como async/await, subprocesos, etc. Task.Run no siempre es la mejor solución. Además, debe esperar a que se completen las tareas (o perder las excepciones que posiblemente se produzcan, etc.)

- JH Bonarius

28/03/2021 a las 15:02

1

Soy consciente de cómo funcionan las tareas en general.. Estoy hablando del caso específico de una tarea que no retiene su hilo mientras espera algo (Archivo/Red/etc...). Todos los ejemplos que proporcionó se refieren a Tareas que ejecutan código intensivo de CPU. Me refiero a una situación en la que desea esperar a que se complete un proceso externo sin ocupar un hilo. Muchas funciones integradas de C# hacen esto, pero no sé cómo. Me pregunto si es posible replicar ese comportamiento.

-markv12

28/03/2021 a las 15:03

@markv12 Eche un vistazo a mi actualización de esta respuesta y de esta sección del Async Guía detallada para obtener más información

-bisen2

28/03/2021 a las 15:56

@JHBonarius Estoy totalmente de acuerdo en que hay mucho más de lo que habla esta respuesta (o cualquier respuesta SO podría cubrir). Lo actualicé con información sobre tareas vinculadas a IO donde Task.Run no sería la mejor solución. Sentí que async/await y esperar a que se completaran las tareas estaban un poco fuera del alcance de esta pregunta, pero si cree que la respuesta se beneficiaría, no dude en agregarlo.

-bisen2

28 de marzo de 2021a las 16:02



------------------------------------

Parece haber una buena cantidad de discusión en los comentarios sobre esto, pero no estoy seguro de si alguno de ellos respondió como usted quería, así que haré lo mejor que pueda.

Por ahora, normalmente hay dos formas en las que puedo pensar en llamar a un método asíncrono sin una tarea. Suelen ser API antiguas (como SqlCommand.BeginExecuteNonQuery) que ya han sido reemplazadas por llamadas basadas en tareas. Si tiene un escenario más específico en mente, sería útil proporcionar mejores ejemplos.

Estoy hablando de lo que hacen las funciones integradas de C# para manejar llamadas de archivos/redes para liberar sus subprocesos mientras esperan.

Preguntas esto, pero ya habías dicho 'Supongo que esto es acEsto se logra llamando al sistema operativo de tal manera que el sistema operativo vuelva a llamar al programa. En cierto modo respondiste tu propia pregunta. Estas operaciones integradas realizan llamadas que se transfieren al sistema operativo y el sistema operativo les avisa cuando se han completado.

En mis ejemplos, supongamos que CallFoo llama a algún tipo de operación del sistema operativo que se encarga de hacer todo. No es muy importante que te preocupes por la implementación real de cómo se llama el sistema operativo, pero puedes considerar llamar al Kernel de Windows desde C# si quieres saber más.

Asíncrono con devolución de llamada

Imagina que la función se parece a esta:

public void CallFoo(Action finishedCallback);

Y quieres poder hacerlo para poder llamarlo así:

public Task CallFoo();

Yo lo definiría así:

public Task CallFoo()
{
    var taskCompletionSource = new TaskCompletionSource();

    // Calls to the API that has a non blocking IO call but no async Task API
    CallFoo(() =>
    {
        // Callback is called when the IO task has finished.
        // SetResult will mark the returned Task as complete
        taskCompletionSource.SetResult();
    });

    return taskCompletionSource.Task;
}
Asíncrono con mango

Al otro lado yoLo que puedo pensar es que funciona con algún tipo de 'identificador' que se devuelve y que especifica si la tarea asíncrona se ha completado o no.

El método podría verse así:

public IAsyncHandle CallFoo();

En este caso, lo implementaría de esta manera:

public async Task CallFoo()
{
    var handle = CallFoo();

    while (!handle.IsCompleted)
    {
        await Task.Delay(100);
    }
}

Esto es menos ideal porque solo estás sondeando para ver si está hecho, pero utiliza muchos menos recursos que hacer un thread.sleep. La desventaja obvia es que en realidad no reacciona en tiempo real al finalizar la acción asíncrona. Puedes reducir/aumentar tu retraso dependiendo de tus necesidades.



------------------------------------

Aquí está el código que se ejecuta cuandollamas System.IO.File.ReadAllLinesAsync:

private static async Task<string[]> InternalReadAllLinesAsync(string path, Encoding encoding, CancellationToken cancellationToken)
{
    using StreamReader sr = AsyncStreamReader(path, encoding);
    cancellationToken.ThrowIfCancellationRequested();
    List<string> lines = new List<string>();
    string item;
    while ((item = await sr.ReadLineAsync().ConfigureAwait(continueOnCapturedContext: false)) != null)
    {
        lines.Add(item);
        cancellationToken.ThrowIfCancellationRequested();
    }
    return lines.ToArray();
}

Son simplemente cosas asincrónicas antiguas. Y si profundiza en .ReadLineAsync(), todo es solo código asíncrono. Nada específicamente especial.

Su guía para un futuro mejor - libreflare
Su guía para un futuro mejor - libreflare