Compartir a través de


Programación asincrónica

Pausar y reproducir con await

Mads Torgersen

Descargar el ejemplo de código

Los métodos asincrónicos de las versiones previstas de Visual Basic y C# permiten eliminar las devoluciones de llamada del código asincrónico. En este artículo, analizaré con más detalle la nueva palabra clave await, partiendo por el nivel conceptual hasta llegar a los detalles subyacentes.

Composición secuencial

Visual Basic y C# son lenguajes de programación imperativos… ¡y me siento orgulloso de eso! Esto significa que sobresalen en dejarlo expresar su lógica de programación como una secuencia de pasos discretos, para llevarse a cabo uno tras otro. La mayoría de las construcciones de lenguaje de instrucción son estructuras de control que le proporcionan diversas maneras de especificar el orden en el cual se ejecutan los pasos discretos de un cuerpo de código dado.

  • Instrucciones condicionales como if y switch le permiten elegir diferentes acciones subsiguientes según el estado actual del mundo.
  • Instrucciones de bucle como for, foreach y while le permiten repetir la ejecución de cierto conjunto de pasos varias veces.
  • Instrucciones como continue, throw y goto le permiten transferir control que no es local a otras partes del programa.

Crear su lógica mediante estructuras de control genera composición secuencial y esto es el alma de la programación imperativa. Es justamente el porqué hay tantas estructuras de control de donde elegir: desea que la composición secuencial sea realmente conveniente y bien estructurada.

Ejecución continua

En los lenguajes más imperativos, incluidas las versiones actuales de Visual Basic y C#, la ejecución de métodos (o funciones o procedimientos o como sea que los llamemos) es continuo. Lo que quiero decir con eso es que una vez que el subproceso de control ha comenzado a ejecutar un método dado, estará continuamente ocupado haciéndolo hasta que la ejecución de dicho método finalice. Sí, algunas veces el subproceso ejecutará instrucciones en métodos llamados por el cuerpo del código, pero eso es solo parte de la ejecución del método. El subproceso nunca cambiará a nada que el método no le pida que cambie.

Esta continuidad a veces resulta problemática. Ocasionalmente no hay nada que un método pueda hacer para progresar, todo lo que puede hacer es esperar a que algo pase: una descarga, el acceso a un archivo, un cálculo en otro subproceso, cierto punto en el tiempo al cual llegar. En tales situaciones, el subproceso está totalmente ocupado haciendo nada. El término común para eso es que el subproceso está bloqueado; se dice que el método que lo tiene así está en bloqueo.

Este es un ejemplo de un método en bloqueo extremo:

static byte[] TryFetch(string url)
{
  var client = new WebClient();
  try
  {
    return client.DownloadData(url);
  }
  catch (WebException) { }
  return null;
}

Un subproceso que ejecuta este método permanecerá inmóvil durante la mayor parte de la llamada a client.DownloadData, sin hacer verdadero trabajo salvo esperar.

Esto es malo cuando los subprocesos son importantes… y a menudo lo son. En un típico nivel medio, atender cada solicitud, a su vez, requiere hablar con un back-end u otro servicio. Si cada solicitud la controla su propio subproceso y esos subprocesos están en su mayoría bloqueados esperando resultados inmediatos, la mera cantidad de subprocesos del nivel medio puede transformarse fácilmente en un cuello de botella de rendimiento.

Probablemente el tipo de subproceso más importante sea un subproceso de UI: hay solo uno de ellos. Virtualmente, todos los marcos de UI tienen un solo subproceso y requieren que todo aquello relacionado con UI (eventos, actualizaciones, la lógica de manipulación de la UI del usuario) ocurra en el mismo subproceso dedicado. Si una de esas actividades (por ejemplo, un controlador de eventos que elige descargar desde una URL) comienza a esperar, toda la UI es incapaz de progresar porque su subproceso está ocupado haciendo absolutamente nada.

Lo que necesitamos es una manera de que las diversas actividades secuenciales puedan compartir subprocesos. Para hacerlo, a veces necesitan “tomarse un descanso”, es decir, dejar agujeros en su ejecución donde otros pueden hacer algo en el mismo subproceso. En otras palabras, a veces deben ser discontinuos. Es particularmente conveniente si esas actividades secuenciales se toman ese descanso mientras no están haciendo nada de todos modos. Al rescate: ¡programación asincrónica!

Programación asincrónica

Hoy en día, debido a que los métodos siempre son continuos, debe dividir actividades discontinuas (como el antes y el después de una descarga) en varios métodos. Para hacer un agujero en medio de la ejecución de un método, debe descomponerlo en sus partes continuas. Las API pueden ayudar al ofrecer versiones asincrónicas (que no bloquean) de métodos de larga ejecución que inician la operación (inician la descarga, por ejemplo), almacenen una devolución de llamada para ejecución al finalizar y luego se devuelven inmediatamente a quien los llama. Pero para que el autor de la llamada proporcione la devolución de llamada, las actividades “de después” deben crearse en un método separado.

Así es cómo funciona esto para el método anterior TryFetch:

static void TryFetchAsync(string url, Action<byte[], Exception> callback)
{
  var client = new WebClient();
  client.DownloadDataCompleted += (_, args) =>
  {
    if (args.Error == null) callback(args.Result, null);
    else if (args.Error is WebException) callback(null, null);
    else callback(null, args.Error);
  };
  client.DownloadDataAsync(new Uri(url));
}

Aquí puede ver un par de maneras diferentes de pasar devoluciones de llamada: el método DownloadDataAsync espera que se haya suscrito un controlador de eventos para el evento DownloadDataCompleted, de manera que así es como pasa la parte “de después” del método. El mismo TryFetchAsync también debe enfrentar las devoluciones de llamada de sus autores. En lugar de configurar todo el asunto del evento usted mismo, use el enfoque más sencillo de simplemente tomar una devolución de llamada como parámetro. Es bueno que podamos usar una expresión lambda para el controlador de eventos de manera que solo puede captar y usar el parámetro “callback” directamente; si tratara de usar un método con nombre, tendría que pensar en alguna manera de obtener el delegado de la devolución de llamada al controlador de eventos. Simplemente deténgase un segundo y piense cómo escribiría este código sin lambdas.

Pero lo principal por observar aquí es lo mucho que cambió el flujo de control. En lugar de usar las estructuras de control del lenguaje para expresar el flujo, las emula:

  • La instrucción de retorno se emula al llamar la devolución de llamada.
  • La propagación implícita de excepciones se emula al llamar la devolución de llamada.
  • El control de excepciones se emula con una revisión de tipo.

Desde luego, este es un ejemplo muy simple. Conforme la estructura de control deseada se vuelve más compleja, emularla también se hace mucho más difícil.

En resumen, ganamos discontinuidad y, por tanto, la capacidad de que el subproceso de ejecución haga otra cosa mientras "espera" la descarga. Pero perdimos la facilidad de usar las estructuras de control para expresar el flujo. Cedimos nuestra herencia como un lenguaje imperativo estructurado.

Métodos asincrónicos

Cuando mira el problema de esta manera, se vuelve claro cómo ayudan los métodos asincrónicos en las versiones siguientes de Visual Basic y C#: le permiten expresar código secuencial discontinuo.

 Miremos la versión asincrónica de TryFetch con esta nueva sintaxis:

static async Task<byte[]> TryFetchAsync(string url)
{
  var client = new WebClient();
  try
  {
    return await client.DownloadDataTaskAsync(url);
  }
  catch (WebException) { }
  return null;
}

Los métodos asincrónicos le permiten tomarse un descanso en línea, en medio del código: No solo puede usar sus estructuras de control favoritas para expresar composición secuencial, también puede crear agujeros en la ejecución con expresiones await (agujeros donde el subproceso de ejecución está libre para hacer otras cosas).

Una buena manera de pensar en esto es imaginar que los métodos asincrónicos tienen botones “pausar” y “reproducir”. Cuando el subproceso en ejecución alcanza una expresión await, presiona el botón “pausar” y la ejecución del método se suspende. Cuando la tarea que se espera se completa, presiona el botón “reproducir” y la ejecución del método se reanuda.

Reescritura del compilador

Cuando algo complejo parece sencillo, por lo general significa que hay algo interesante detrás de todo eso y ese es ciertamente el caso con los métodos asincrónicos. La simplicidad le proporciona una agradable abstracción que hace mucho más fácil escribir y leer código asincrónico. Comprender qué está pasando debajo de todo no es un requisito. Pero si lo entiende de verdad, sin duda lo ayudará a convertirse en un mejor programador asincrónico y a ser capaz de utilizar la característica de manera más acabada. Y, si está leyendo esto, hay buenas probabilidades de que sea simplemente curioso. Así que profundicemos: ¿qué hacen realmente los métodos asincrónicos (y las expresiones await que contienen)?

Cuando el compilador de Visual Basic o C# se apodera de un método asincrónico, lo destroza bastante durante la compilación: la discontinuidad del método no es directamente compatible con el tiempo de ejecución subyacente y debe emularla el compilador. De manera que en lugar de tener que hacer trizas el método, el compilador lo hace por usted. Sin embargo, lo hace de manera bastante diferente de lo que usted lo haría manualmente.

El compilador transforma su método asincrónico en una MáquinaEstado. La máquina de estado lleva un registro de dónde está en la ejecución y cuál es su estado local. Puede estar en ejecución o suspendido. Cuando está en ejecución, puede llegara a un await, el cual presiona el botón “pausar” y suspende la ejecución. Cuando está suspendido, es posible que algo presione el botón “reproducir” para volverlo a activar.

La expresión await es responsable de configurar cosas para que el botón “reproducir” se presione al completarse la tarea que se espera. Antes de abordar ese punto, sin embargo, echemos un vistazo a la propia máquina de estado y lo que esos botones pausar y reproducir son en realidad.

Generadores de tareas

Los métodos asincrónicos producen tareas. Más específicamente, un método asincrónico devuelve una instancia de uno de los tipos Task o Task<T> de System.Threading.Tasks y esa instancia se genera de manera automática. No tiene que ser (no puede ser) suministrada por el código del usuario. (Esta es una mentirita: los métodos asincrónicos pueden volver nulos, pero pasaremos por alto eso por esta vez.)

Desde el punto de vista del compilador, producir tareas es la parte sencilla. Depende de una noción suministrada por marcos de un generador de tareas y se encuentra en System.Runtime.CompilerServices (ya que normalmente su objetivo no es el consumo humano directo). Por ejemplo, hay un tipo así:

public class AsyncTaskMethodBuilder<TResult>
{
  public Task<TResult> Task { get; }
  public void SetResult(TResult result);
  public void SetException(Exception exception);
}

El generador permite que el compilador obtenga una tarea y le permite completarla con un resultado o una excepción. La Figura 1 es un diagrama de cómo luce esta maquinaria para TryFetchAsync.

Figura 1 Generación de una tarea

static Task<byte[]> TryFetchAsync(string url)
{
  var __builder = new AsyncTaskMethodBuilder<byte[]>();
  ...
  Action __moveNext = delegate
  {
    try
    {
      ...
      return;
      ...
      __builder.SetResult(…);
      ...
    }
    catch (Exception exception)
    {
      __builder.SetException(exception);
    }
  };
  __moveNext();
  return __builder.Task;
}

Observe con cuidado:

  • Primero se crea un generador.
  • Luego se crea un delegado __moveNext. Este delegado es el botón “reproducir”. Lo llamamos el delegado de reanudación y contiene:
    • El código original de su método asincrónico (aunque lo hemos omitido hasta ahora).
    • Las instrucciones de devolución, que representan presionar el botón “pausar”.
    • Las llamadas que completan el generador con un resultado satisfactorio, que corresponden a las instrucciones de devolución del código original.
    • Un bloque try/catch que completa el generador con cualquier excepción escapada.
  • Ahora se presiona el botón “reproducir”; se llama al delegado de reanudación. Se ejecuta hasta que se presiona el botón “pausar”.
  • La tarea se devuelve al autor de la llamada.

Los generadores de tarea son tipos de ayuda especial pensados exclusivamente el consumo de compiladores. Sin embargo, su comportamiento no difiera mucho de lo que pasa cuando usa tipos TaskCompletionSource de la biblioteca TPL directamente.

Hasta ahora he creado una tarea para que vuelva y un botón “reproducir” (el delegado de reanudación) para que alguien lo llame cuando sea tiempo de retomar la ejecución. Todavía debo ver cómo se reanuda la ejecución y cómo la expresión await configura algo para que haga esto. Antes de poner todo junto, sin embargo, echemos un vistazo a cómo se consumen las tareas.

Esperables y factores en espera

Como ha visto, las tareas se pueden esperar. Sin embargo, Visual Basic y C# están perfectamente felices de esperar otras cosas también, siempre que sean esperables; es decir, siempre que tengan cierta forma contra la cual se pueda compilar la expresión await. A fin de ser esperable, algo debe tener un método GetAwaiter, el cual a su vez devuelve un factor en espera. Como un ejemplo, Task<TResult> tiene un método GetAwaiter que devuelve este tipo:

public struct TaskAwaiter<TResult>
{
  public bool IsCompleted { get; }
  public void OnCompleted(Action continuation);
  public TResult GetResult();
}

Los miembros del factor en espera permiten que el compilador revise si el esperable ya está listo, le registren una devolución de llamada si aún no lo está y obtengan el resultado (o la excepción) cuando sea el momento.

Ahora podemos comenzar a ver lo que debe hacer un await para pausar y reanudar en torno al esperable. Por ejemplo, el await dentro de nuestro ejemplo TryFetchAsync se transformaría en algo así:

 

__awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter();
  if (!__awaiter1.IsCompleted) {
    ... // Prepare for resumption at Resume1
    __awaiter1.OnCompleted(__moveNext);
    return; // Hit the "pause" button
  }
Resume1:
  ... __awaiter1.GetResult()) ...

Nuevamente, observe lo que pasa:

  • Se obtiene un factor en espera para la tarea devuelta desde DownloadDataTaskAsync.
  • Si este factor no está completo, el botón “reproducir” (el delegado de reanudación) se pasa al factor como una devolución de llamada.
  • Cuando el factor en espera reanuda la ejecución (en Resume1), el resultado se obtiene y usa en el código que lo sigue.

Claramente el caso común es que el esperable sea Task o Task<T>. De hecho, esos tipos (que ya están presentes en Microsoft .NET Framework 4) se han optimizado mucho para este rol. Sin embargo, hay buenas razones para permitir otros tipos de esperables también:

  • Puentes a otras tecnologías: F#, por ejemplo, tiene un tipo Async<T> que aproximadamente corresponde a Func<Task<T>>. Al ser capaz de esperar, Async<T> directamente desde Visual Basic y C# ayuda a salvar distancia entre código asincrónico escrito en dos lenguajes. F# es similar a exponer la funcionalidad de salvar distancia al otro lado, consumir tareas directamente en el código asincrónico F#.
  • Implementación de semántica especial: La propia TPL está agregando un par de ejemplos de esto. El método de utilidad Task.Yield estático, por ejemplo, devuelve un esperable que reclamará (a través de IsCompleted) no estar completo, pero que inmediatamente programará la devolución de llamada pasada a su método OnCompleted, como si de verdad hubiera estado completo. Esto le permite forzar la programación y omitir la optimización del compilador de saltársela si el resultado ya está disponible. Esto se puede usar para abrir agujeros en el código “vivo” y mejorar la capacidad de respuesta de código que no está sentado inactivo. Las propias tareas no pueden representar cosas que están completas y reclamar que no lo están, de manera que se usa un tipo especial de esperable para eso.

Antes de adentrarme en la implementación esperable de Task, terminemos de mirar la reescritura del método asincrónico por parte del compilador y examinemos la contabilidad que hace un seguimiento del estado de la ejecución del método.

La máquina de estado

Para hilvanar todo junto, necesito crear una máquina de estado en torno a la producción y el consumo de las tareas. En esencia, toda la lógica del usuario a partir del método original se coloca en el delegado de reanudación, pero las declaraciones de los locales se levantan para que puedan sobrevivir varias invocaciones. Además, se introduce una variable de estado para hacer un seguimiento de qué tan lejos han ido las cosas y la lógica del usuario en el delegado de reanudación se incluye en un gran switch que mira el estado y salta a un nivel correspondiente. De manera que siempre que se llame la reanudación, saltará de vuelta adonde se quedó la última vez. La Figura 2 reúne todo.

Figura 2 Creación de una máquina de estado

static Task<byte[]> TryFetchAsync(string url)
{
  var __builder = new AsyncTaskMethodBuilder<byte[]>();
  int __state = 0;
  Action __moveNext = null;
  TaskAwaiter<byte[]> __awaiter1;
 
  WebClient client = null;
 
  __moveNext = delegate
  {
    try
    {
      if (__state == 1) goto Resume1;
      client = new WebClient();
      try
      {
        __awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter();
        if (!__awaiter1.IsCompleted) {
          __state = 1;
          __awaiter1.OnCompleted(__moveNext);
          return;
        }
        Resume1:
        __builder.SetResult(__awaiter1.GetResult());
      }
      catch (WebException) { }
      __builder.SetResult(null);
    }
    catch (Exception exception)
    {
      __builder.SetException(exception);
    }
  };
 
  __moveNext();
  return __builder.Task;
}

Todo un trabalenguas Estoy seguro que se están preguntando por qué este código tiene muchas más palabras que la versión “asincronizada” manualmente visto antes. Existen un par de buenas razones, incluida la eficacia (menos asignaciones en el caso general) y generalidad (aplica a esperables definidos por el usuario, no solo Tasks). Sin embargo, la razón principal es esta: No tiene que desmembrar la lógica del usuario después de todo; simplemente auméntela con algunos saltos y devoluciones, etc.

Aunque el ejemplo es demasiado simple para realmente justificarlo, reescribir la lógica del método en un conjunto semánticamente equivalente de métodos discretos para cada una de sus partes continuas de lógica entre los awaits es un asunto muy complicado. Mientras en más estructuras de control estén anidados los awaits, es peor. Cuando no solo bucles con instrucciones continue and break pero bloques de try-finally e incluso instrucciones goto rodean los awaits, es extremadamente difícil, si acaso posible, producir una reescritura con alta fidelidad.

En lugar de intentar eso, parece un buen truco simplemente superponer el código original del usuario con otra capa de estructura de control, al entrarlo (con saltos adicionales) y sacarlo (con devoluciones) según lo requiera la situación. Reproducir y pausar. En Microsoft, hemos estado probando sistemáticamente la equivalencia de los métodos asincrónicos con sus contrapartes sincrónicas y hemos confirmado que este es un enfoque muy sólido. No existe mejor manera para preservar la semántica sincrónica en el territorio asincrónico que retener el código que describe esa semántica en primer lugar.

La fina huella

La descripción que he proporcionado se ha idealizado levemente, hay unos cuantos otros trucos respecto de la reescritura, como puede haber sospechado. Estas son algunas de las otras gotchas con que el compilador debe enfrentar:

Instrucciones Goto La reescritura en la Figura 2 realmente no compila, porque las instrucciones goto (en C# al menos) no pueden saltar a etiquetas enterradas en estructuras anidadas. Eso en sí mismo no es un problema, ya que el compilador genera para lenguaje intermedio (LI), sin código fuente, y sin que el anidamiento lo perturbe. Pero incluso LI no permite saltar al medio de un bloque de try, como se hace en mi ejemplo. En su lugar, lo que realmente pasa es que usted salta al principio de un bloque try, lo ingresa normalmente y luego switch y salta de nuevo.

Bloques finally Cuando vuelve el delegado de reanudación debido a un await, no desea que los cuerpos finally se ejecuten todavía. Se deben guardar para cuando las instrucciones de devolución original del código del usuario se ejecuten. Usted controla eso al generar un marcador booleano que señale si los cuerpos finally deben ejecutarse y que los aumente para revisarlo.

Orden de evaluación una expresión await no es necesariamente el primer argumento para un método u operador, puede ocurrir en el medio. Para preservar el orden de evaluación, todos los argumentos anteriores deben evaluarse antes del await y el acto de almacenarlos y recuperarlos de nuevo después de que el await se encuentra sorpresivamente involucrado.

Por encima de todo esto, existen algunas limitaciones que no puede obviar. Por ejemplo, no se permite a los awaits dentro de un bloque catch o finally, porque no sabemos una buena manera para restablecer el contexto de excepción correcto después del await.

El factor en espeta de tareas

El factor en espera usado por el código generado por el compilador para implementar la expresión await tiene considerable libertad en cuanto a cómo programa el delegado de reanudación, es decir, el resto del método asincrónico. Sin embargo, el escenario tendría que ser realmente avanzado antes de que tuviera que implementar su propio awaiter. Las tareas mismas tienen mucha flexibilidad en cómo programan porque respetan una noción de contexto de programación que en sí es conectable.

El contexto de programación es una de esas nociones que probablemente luciría un poco mejor si hubiéramos diseñado para ella desde el principio. Así como está, es una amalgama de unos cuantos conceptos existentes que hemos decidido no alterar más allá al tratar de introducir un concepto unificador encima. Veamos la idea en el nivel conceptual y luego me adentraré en la concreción.

La filosofía que sustenta la programación de devoluciones de llamadas asincrónicas para tareas con await es que desea continuar ejecutando “donde estaba antes”, para algún valor de “donde”. Es este “donde” el que llamo el contexto de programación. El contexto de programación es un concepto afín a los subprocesos; cada subproceso tiene (como mucho) uno. Cuando está ejecutando en un subproceso, puede preguntar por el contexto de programación que lo sustenta; y cuando tiene un contexto de programación, puede programar cosas para que se ejecuten en él.

De manera que esto es lo que un método asincrónico debe hacer cuando aguarda por una tarea:

  • En suspensión: pregunte al subproceso sobre el cual se está ejecutando para obtener su contexto de programación.
  • En reanudación: programe el delegado de reanudación de vuelta en ese contexto de programación.

¿Por qué esto es importante? Considere el subproceso de UI. Tiene su propio contexto de programación, el cual programa nuevo trabajo al enviarlo a través de la cola de mensaje de vuelta al subproceso de UI. Esto significa que si está ejecutando en el subproceso de UI y espera una tarea, cuando el resultado de la tarea esté listo, el resto del método asincrónico se ejecutará de vuelta en el subproceso de UI. Así, todas las cosas que puede hacer solo en el proceso de UI (manipulando la UI), todavía las puede hacer después de la espera, no experimentará un extraño “salto de subproceso” en el medio de su código.

Otros contextos de programación poseen varios subprocesos; específicamente, el grupo de subprocesos estándar se encuentra representado por un solo contexto de programación. Cuando tiene programado nuevo trabajo, puede ir a cualquiera de los subprocesos del grupo. Por tanto, un método asincrónico que comienza a ejecutarse en el grupo de subprocesos seguirá haciéndolo, aun cuando puede “perderse por ahí” entre diferentes subprocesos.

En la práctica, no existe un concepto único que corresponda al contexto de programación. A grandes rasgos, SynchronizationContext de un subproceso actúa como su contexto de programación. De manera que si un subproceso tiene uno de esos (un concepto existente que puede implementar el usuario), se usará. Si no, entonces se usa TaskScheduler (un concepto similar introducido por TPL) del subproceso. Si tampoco tiene uno de esos, se usa el valor TaskScheduler predeterminado; ese programa reanudaciones al grupo de subprocesos estándar.

Por supuesto, todo esta programación tiene un costo de rendimiento. Generalmente, en escenarios de usuario, es insignificante y bien vale la pena: que el código de UI se corte en pedacitos administrables de trabajo de verdad y se bombee a través de la bomba de mensajes como resultados esperados y esté disponible es normalmente justo lo que el médico ordenó.

Aunque a veces (especialmente en código de biblioteca), las cosas se pueden poner muy finas. Considere:

async Task<int> GetAreaAsync()
{
  return await GetXAsync() * await GetYAsync();
}

Esto programa de vuelta al contexto de programación dos veces (después de cada await) solo para realizar una multiplicación en el subproceso “correcto”. Pero a quién le importa sobre qué subproceso está multiplicando. Probablemente es una pérdida de tiempo (a menudo lo es) y hay trucos para evitarlo: Esencialmente puede incluir la tarea esperada en un esperable que no es de tarea que sabe desactivar el comportamiento de vuelta a la programación y solo ejecuta la reanudación en cualquier subproceso que complete la tarea, con lo que se evita el switch del contexto y el retraso de programación:

async Task<int> GetAreaAsync()
{
  return await GetXAsync().ConfigureAwait(continueOnCapturedContext: false)
    * await GetYAsync().ConfigureAwait(continueOnCapturedContext: false);
}

Menos bonito, de seguro, pero un buen truco para usar en código de biblioteca que termina en un cuello de botella para programación.

Adelante con Async

Ahora debe tener los conocimientos de trabajo de los aspectos fundamentales de métodos asincrónicos. Probablemente los puntos más útiles que sacar son:

  • El compilador preserva el significado de sus estructuras de control al preservarlas de verdad.
  • Los métodos asincrónicos no programan nuevos subprocesos, le permiten multiplexación en los existentes.
  • Cuando las tareas se esperan, lo colocan “donde estaba” por una definición razonable de lo que eso significa.

Si es como yo, ya ha estado alternando entre leer este artículo y escribir algo de código. Ha multiplexado varios flujos de control (leyendo y codificando) en el mismo subproceso: usted. Eso es justamente lo que los métodos asincrónicos le permiten hacer.

Mads Torgersen es administrador de programa principal en el equipo de lenguajes C# y Visual Basic en Microsoft.

Gracias al siguiente experto técnico por su ayuda en la revisión de este artículo: Stephen Toub